Make Tile swap slow at a low Swap rate, on a curved slider

A tile has to come back to the cut it started with for the clip to
loop, so every tile swapped at least twice a clip, and below that
rounding left every tile still: the bottom of the slider was either
frozen or busy. Swap rate is now the average per tile: below two swaps
a clip only a share of the tiles swap (twice each), fewer the lower it
goes, and above it each tile rounds up or down by its own draw.

ParamSpec::slider() takes an optional curve: the scale runs over the
slider's travel and sliderValue/sliderPosition map it onto the number
as position^curve. Swap rate uses a curve of 3, so the low end gets
most of the slider; the command line and project file are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
simon
2026-10-02 01:11:28 +02:00
co-authored by Claude Opus 5.5
parent ef8f728126
commit 3c8c5bed8c
8 changed files with 123 additions and 38 deletions
+6
View File
@@ -10,6 +10,12 @@ commit that made it. The interface shows this file under Changelog in its menu.
- The Swap rate of Tile swap is a slider marked with its range, and goes - The Swap rate of Tile swap is a slider marked with its range, and goes
down in steps of 0.01 rather than 0.1, so a clip can swap its tiles much down in steps of 0.01 rather than 0.1, so a clip can swap its tiles much
more slowly. Its top end is now 5 swaps a second (it was 30). more slowly. Its top end is now 5 swaps a second (it was 30).
- At a low Swap rate, Tile swap now moves only some of its tiles, fewer the
lower the rate, instead of swapping every tile at least twice a clip (or,
below that, none at all). Each step down the bottom of the slider now
looks slower than the one above it.
- The Swap rate slider is curved: its low end takes up most of its length
(1 swap a second sits just past the middle), so slow rates are easy to set.
## [0.8.1] - 2026-09-26 ## [0.8.1] - 2026-09-26
+3 -1
View File
@@ -82,7 +82,9 @@ travel as `ParamValues` (a
An Int or Double param marked `.slider()` is shown in An Int or Double param marked `.slider()` is shown in
the interface as a slider with its minimum and maximum marked instead of a spin the interface as a slider with its minimum and maximum marked instead of a spin
button; nothing else changes. button; nothing else changes. `.slider(curve)` bends it: the scale runs over the
slider's travel and `sliderValue`/`sliderPosition` map that onto the number as
`position^curve`, so a curve above 1 gives the low end more room.
A param can also be marked `.styled()`, which says a *named style* carries it A param can also be marked `.styled()`, which says a *named style* carries it
rather than the stage it sits on: the font and the colour of a text stage, but rather than the stage it sits on: the font and the colour of a text stage, but
+17 -1
View File
@@ -1,6 +1,7 @@
#include "core/params.hpp" #include "core/params.hpp"
#include <algorithm> #include <algorithm>
#include <cmath>
namespace effector { namespace effector {
@@ -16,12 +17,27 @@ ParamSpec ParamSpec::styled() const {
return copy; return copy;
} }
ParamSpec ParamSpec::slider() const { ParamSpec ParamSpec::slider(double curve) const {
ParamSpec copy = *this; ParamSpec copy = *this;
copy.onSlider = true; copy.onSlider = true;
copy.curve = std::max(1e-3, curve);
return copy; return copy;
} }
double ParamSpec::sliderValue(double position) const {
const double span = maximum - minimum;
const double raw = minimum + span * std::pow(std::clamp(position, 0.0, 1.0), curve);
const double unit = type == Type::Int ? std::max(1.0, std::round(step)) : step;
const double stepped = unit > 0.0 ? minimum + std::round((raw - minimum) / unit) * unit : raw;
return std::clamp(stepped, minimum, maximum);
}
double ParamSpec::sliderPosition(double value) const {
const double span = maximum - minimum;
if (span <= 0.0) return 0.0;
return std::pow(std::clamp((value - minimum) / span, 0.0, 1.0), 1.0 / curve);
}
ParamSpec ParamSpec::makeInt(std::string key, std::string label, long defaultValue, long minimum, ParamSpec ParamSpec::makeInt(std::string key, std::string label, long defaultValue, long minimum,
long maximum, std::string help, long step) { long maximum, std::string help, long step) {
ParamSpec spec; ParamSpec spec;
+13 -2
View File
@@ -30,6 +30,10 @@ struct ParamSpec {
// Whether the interface shows this number as a slider marked with its // Whether the interface shows this number as a slider marked with its
// range rather than as a spin button: see slider(). // range rather than as a spin button: see slider().
bool onSlider = false; bool onSlider = false;
// How a slider's travel is spread over its range: the value is
// minimum + (maximum - minimum) * position^curve, so 1 is even and a
// larger curve gives the low end more of the slider. See slider().
double curve = 1.0;
std::string flag() const; // "strip_height" -> "--strip-height" std::string flag() const; // "strip_height" -> "--strip-height"
@@ -40,8 +44,15 @@ struct ParamSpec {
// A copy of this Int or Double spec shown as a slider in the interface, for // A copy of this Int or Double spec shown as a slider in the interface, for
// a number whose place between its minimum and maximum says more than its // a number whose place between its minimum and maximum says more than its
// digits do. The command line and the project file are unchanged. // digits do. A `curve` above 1 spreads the low end of the range over more
ParamSpec slider() const; // of the slider, for a number whose small values differ the most. The
// command line and the project file are unchanged.
ParamSpec slider(double curve = 1.0) const;
// Where a slider at `position` (0 to 1) puts this number, rounded to its
// step and kept in range, and where on the slider `value` sits.
double sliderValue(double position) const;
double sliderPosition(double value) const;
static ParamSpec makeInt(std::string key, std::string label, long defaultValue, long minimum, static ParamSpec makeInt(std::string key, std::string label, long defaultValue, long minimum,
long maximum, std::string help = {}, long step = 1); long maximum, std::string help = {}, long step = 1);
+30 -18
View File
@@ -26,10 +26,11 @@ const std::vector<ParamSpec>& TileSwapEffect::params() const {
ParamSpec::makeBool("tileswap_keep_order", "Cycle images in order", false, ParamSpec::makeBool("tileswap_keep_order", "Cycle images in order", false,
"Take tiles from the images in order instead of picking randomly."), "Take tiles from the images in order instead of picking randomly."),
ParamSpec::makeDouble("tileswap_rate", "Swap rate", 1.0, 0.0, 5.0, ParamSpec::makeDouble("tileswap_rate", "Swap rate", 1.0, 0.0, 5.0,
"Swaps per second of each tile, rounded so that the clip loops " "Swaps per second of each tile, on average; 0 for none. Below two "
"without a jump; 0 for none.", "swaps in the clip only some of the tiles swap, fewer the lower it "
"goes, so that the clip still loops without a jump.",
0.01, 2) 0.01, 2)
.slider(), .slider(3.0),
}; };
return specs; return specs;
} }
@@ -57,8 +58,8 @@ struct Settings {
double unit(Rng& rng) { return static_cast<double>(rng.next() >> 11) * 0x1.0p-53; } double unit(Rng& rng) { return static_cast<double>(rng.next() >> 11) * 0x1.0p-53; }
// Where a tile sits for the whole clip, when in its swaps it turns over, and // Where a tile sits for the whole clip, when in its swaps it turns over, the
// the seed its cuts come from. // seed its cuts come from, and the draw that settles how often it swaps.
struct Cell { struct Cell {
int x; int x;
int y; int y;
@@ -66,6 +67,7 @@ struct Cell {
int h; int h;
double phase; double phase;
std::uint64_t seed; std::uint64_t seed;
double share;
}; };
} // namespace } // namespace
@@ -81,7 +83,7 @@ Image TileSwapEffect::compose(const std::vector<Image>& sources, int width, int
Rng steady = at.steady(); Rng steady = at.steady();
std::vector<Cell> cells; std::vector<Cell> cells;
auto addCell = [&](int x, int y, int w, int h) { auto addCell = [&](int x, int y, int w, int h) {
cells.push_back(Cell{x, y, w, h, 0.0, 0}); cells.push_back(Cell{x, y, w, h, 0.0, 0, 0.0});
}; };
if (scatter) { if (scatter) {
const Image& src = sources[steady.below(sources.size() - 1)]; const Image& src = sources[steady.below(sources.size() - 1)];
@@ -120,27 +122,37 @@ Image TileSwapEffect::compose(const std::vector<Image>& sources, int width, int
for (Cell& cell : cells) { for (Cell& cell : cells) {
cell.phase = unit(steady); cell.phase = unit(steady);
cell.seed = steady.next(); cell.seed = steady.next();
cell.share = unit(steady);
} }
// Where this frame is in time: each tile has swapped a whole number of // How often each tile swaps in one pass of the clip, `each` on average. A
// times, counted round the clip so that the frame after the last shows // tile has to come back to the cut it started with, so one that swaps at
// what the first does. // all swaps at least twice; below that a share of the tiles swap twice and
long swaps = 0; // the rest hold still, so a low rate thins out the tiles that move instead
double perSecond = 0.0; // of moving every one of them. Above it, each tile rounds `each` up or
if (settings.rate > 0.0) { // down by its own draw.
perSecond = at.loopRate(settings.rate); const double length = at.length();
swaps = std::max(1L, std::lround(perSecond * at.length())); const double each = settings.rate * length;
} auto swapsOf = [&](const Cell& cell) -> long {
if (each <= 0.0) return 0;
if (each < 2.0) return cell.share < each / 2.0 ? 2 : 0;
const double whole = std::floor(each);
return static_cast<long>(whole) + (cell.share < each - whole ? 1 : 0);
};
// What a tile shows depends only on the tile and its swap, so it holds // What a tile shows depends only on the tile and how many times it has
// still between swaps and any frame can be drawn on its own. // swapped, counted round the clip so that the frame after the last shows
// what the first does; so it holds still between swaps and any frame can
// be drawn on its own.
std::vector<Tile> plan; std::vector<Tile> plan;
plan.reserve(cells.size()); plan.reserve(cells.size());
for (std::size_t i = 0; i < cells.size(); ++i) { for (std::size_t i = 0; i < cells.size(); ++i) {
const Cell& cell = cells[i]; const Cell& cell = cells[i];
const long swaps = swapsOf(cell);
std::uint64_t turn = 0; std::uint64_t turn = 0;
if (swaps > 0) { if (swaps > 0) {
const auto passed = static_cast<long>(std::floor(at.seconds() * perSecond + cell.phase)); const auto passed =
static_cast<long>(std::floor(at.seconds() * swaps / length + cell.phase));
turn = static_cast<std::uint64_t>(passed % swaps); turn = static_cast<std::uint64_t>(passed % swaps);
} }
Rng cut(cell.seed + turn * 0x9e3779b97f4a7c15ULL); Rng cut(cell.seed + turn * 0x9e3779b97f4a7c15ULL);
+15 -11
View File
@@ -957,22 +957,25 @@ void EffectorWindow::buildParamWidgets() {
paramInt(values, spec.key, static_cast<long>(spec.defaultValue))) paramInt(values, spec.key, static_cast<long>(spec.defaultValue)))
: paramDouble(values, spec.key, spec.defaultValue); : paramDouble(values, spec.key, spec.defaultValue);
if (spec.onSlider) { if (spec.onSlider) {
// The ends are marked with their values, so where the // The scale runs over the slider's travel, 0 to 1, and
// number sits in its range can be read at a glance. // the spec maps that onto the number, so a curved slider
// gives its low end more room. The ends are marked with
// their values, so where the number sits in its range can
// be read at a glance.
const int digits = spec.type == ParamSpec::Type::Int ? 0 : spec.digits; const int digits = spec.type == ParamSpec::Type::Int ? 0 : spec.digits;
auto scale = std::make_unique<Gtk::Scale>(Gtk::Orientation::HORIZONTAL); auto scale = std::make_unique<Gtk::Scale>(Gtk::Orientation::HORIZONTAL);
scale->set_range(spec.minimum, spec.maximum); scale->set_range(0.0, 1.0);
scale->set_increments(spec.step, spec.step * 10); scale->set_increments(0.01, 0.1);
scale->set_round_digits(digits);
scale->set_digits(digits);
scale->set_draw_value(true); scale->set_draw_value(true);
scale->set_value_pos(Gtk::PositionType::RIGHT); scale->set_value_pos(Gtk::PositionType::RIGHT);
const auto mark = [digits](double at) { const auto mark = [digits](double at) {
return Glib::ustring::format(std::fixed, std::setprecision(digits), at); return Glib::ustring::format(std::fixed, std::setprecision(digits), at);
}; };
scale->add_mark(spec.minimum, Gtk::PositionType::BOTTOM, mark(spec.minimum)); scale->set_format_value_func(
scale->add_mark(spec.maximum, Gtk::PositionType::BOTTOM, mark(spec.maximum)); [spec, mark](double position) { return mark(spec.sliderValue(position)); });
scale->set_value(value); scale->add_mark(0.0, Gtk::PositionType::BOTTOM, mark(spec.minimum));
scale->add_mark(1.0, Gtk::PositionType::BOTTOM, mark(spec.maximum));
scale->set_value(spec.sliderPosition(value));
scale->signal_value_changed().connect( scale->signal_value_changed().connect(
sigc::mem_fun(*this, &EffectorWindow::onParamChanged)); sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
scrollPastUnlessFocused(*scale); scrollPastUnlessFocused(*scale);
@@ -1093,8 +1096,9 @@ ParamValues EffectorWindow::paramValues() const {
case ParamSpec::Type::Int: case ParamSpec::Type::Int:
case ParamSpec::Type::Double: { case ParamSpec::Type::Double: {
const double value = const double value =
entry.spec.onSlider ? static_cast<Gtk::Scale*>(entry.widget)->get_value() entry.spec.onSlider
: static_cast<Gtk::SpinButton*>(entry.widget)->get_value(); ? entry.spec.sliderValue(static_cast<Gtk::Scale*>(entry.widget)->get_value())
: static_cast<Gtk::SpinButton*>(entry.widget)->get_value();
if (entry.spec.type == ParamSpec::Type::Int) { if (entry.spec.type == ParamSpec::Type::Int) {
values[entry.spec.key] = std::lround(value); values[entry.spec.key] = std::lround(value);
} else { } else {
+33
View File
@@ -4,8 +4,10 @@
#include <array> #include <array>
#include <atomic> #include <atomic>
#include <clocale> #include <clocale>
#include <cmath>
#include <cstdio> #include <cstdio>
#include <cstdlib> #include <cstdlib>
#include <cstring>
#include <filesystem> #include <filesystem>
#include <fstream> #include <fstream>
#include <iostream> #include <iostream>
@@ -2079,6 +2081,37 @@ int main() {
frozenSwap.values["tileswap_rate"] = 0.0; frozenSwap.values["tileswap_rate"] = 0.0;
check(sameBytes(frameOf(frozenSwap, 0), frameOf(frozenSwap, 5)), check(sameBytes(frameOf(frozenSwap, 0), frameOf(frozenSwap, 5)),
"at a rate of 0 it holds still"); "at a rate of 0 it holds still");
// How many pixels ever show something other than frame 0.
auto moving = [&](const ChainStage& at) {
const auto first = frameOf(at, 0);
std::vector<bool> moved(static_cast<std::size_t>(80 * 48), false);
for (int index = 1; index < 8; ++index) {
const auto frame = frameOf(at, index);
for (int y = 0; y < 48; ++y) {
for (int x = 0; x < 80; ++x) {
if (std::memcmp(frame.row(y) + x * 3, first.row(y) + x * 3, 3) != 0) {
moved[static_cast<std::size_t>(y * 80 + x)] = true;
}
}
}
}
return std::count(moved.begin(), moved.end(), true);
};
ChainStage slowSwap = swapStage;
slowSwap.values["tileswap_rate"] = 0.3;
const auto slowMoving = moving(slowSwap);
check(slowMoving > 0 && slowMoving < moving(swapStage),
"a rate below two swaps a clip moves only some of the tiles");
const auto& rate = swap->params().back();
check(rate.onSlider && rate.curve > 1.0, "its swap rate is a curved slider");
check(rate.sliderValue(0.0) == 0.0 && rate.sliderValue(1.0) == 5.0 &&
rate.sliderValue(0.5) < 2.5,
"which spans the whole range and gives the low end more travel");
check(std::abs(rate.sliderValue(rate.sliderPosition(0.37)) - 0.37) < 1e-9 &&
rate.sliderValue(0.123456) * 100.0 ==
std::round(rate.sliderValue(0.123456) * 100.0),
"a value finds its place on the slider, and a place rounds to the step");
ChainStage scattered = swapStage; ChainStage scattered = swapStage;
scattered.values["tileswap_layout"] = std::string("scatter"); scattered.values["tileswap_layout"] = std::string("scatter");
ChainStage bricks = swapStage; ChainStage bricks = swapStage;
+6 -5
View File
@@ -658,17 +658,18 @@ void testSlider(Gtk::Window& window, const std::filesystem::path& dir) {
check(scales.size() == 1, "a param marked as a slider is shown as one"); check(scales.size() == 1, "a param marked as a slider is shown as one");
if (scales.size() != 1) return; if (scales.size() != 1) return;
auto* scale = scales.front(); auto* scale = scales.front();
auto adjustment = scale->get_adjustment(); const auto& spec = effector::effectByName("tileswap")->params().back();
// Written the way the locale writes numbers, as the interface does. // Written the way the locale writes numbers, as the interface does.
const auto mark = [](double at) { const auto mark = [](double at) {
return std::string(Glib::ustring::format(std::fixed, std::setprecision(2), at)); return std::string(Glib::ustring::format(std::fixed, std::setprecision(2), at));
}; };
check(adjustment->get_lower() == 0.0 && adjustment->get_upper() == 5.0 && check(showsText(*scale, mark(0.0)) && showsText(*scale, mark(5.0)),
showsText(*scale, mark(0.0)) && showsText(*scale, mark(5.0)),
"with its range marked at both ends"); "with its range marked at both ends");
check(scale->get_value() == 0.5, "and shows the value saved"); check(spec.sliderValue(scale->get_value()) == 0.5, "and shows the value saved");
check(scale->get_value() > 0.5 / 5.0 + 0.3,
"a curved slider gives the low end of its range more of its travel");
scale->set_value(0.05); scale->set_value(spec.sliderPosition(0.05));
pump(100); pump(100);
window.activate_action("win.save-project"); window.activate_action("win.save-project");
pump(100); pump(100);