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>
172 lines
7.4 KiB
C++
172 lines
7.4 KiB
C++
#include "effects/tileswap.hpp"
|
|
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <vector>
|
|
|
|
#include "effects/source_fill.hpp"
|
|
#include "effects/tile_plan.hpp"
|
|
|
|
namespace effector {
|
|
|
|
const std::vector<ParamSpec>& TileSwapEffect::params() const {
|
|
static const std::vector<ParamSpec> specs = {
|
|
ParamSpec::makeChoice("tileswap_layout", "Layout", "grid", {"grid", "brick", "scatter"},
|
|
"grid lines the tiles up in rows and columns; brick packs every row "
|
|
"on its own so the seams do not meet; scatter drops loose tiles over "
|
|
"a filled background, overlapping."),
|
|
ParamSpec::makeInt("tileswap_width", "Tile width", 96, 1, 2048,
|
|
"Width in pixels of each cut-out tile."),
|
|
ParamSpec::makeInt("tileswap_height", "Tile height", 96, 1, 2048,
|
|
"Height in pixels of each cut-out tile."),
|
|
ParamSpec::makeInt("tileswap_jitter", "Size jitter", 0, 0, 512,
|
|
"Random +/- variation added to both tile sizes (0 = uniform)."),
|
|
ParamSpec::makeInt("tileswap_count", "Tile count", 64, 1, 4096,
|
|
"How many tiles are dropped on the canvas; scattered layout only."),
|
|
ParamSpec::makeBool("tileswap_keep_order", "Cycle images in order", false,
|
|
"Take tiles from the images in order instead of picking randomly."),
|
|
ParamSpec::makeDouble("tileswap_rate", "Swap rate", 1.0, 0.0, 5.0,
|
|
"Swaps per second of each tile, on average; 0 for none. Below two "
|
|
"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)
|
|
.slider(3.0),
|
|
};
|
|
return specs;
|
|
}
|
|
|
|
namespace {
|
|
|
|
struct Settings {
|
|
std::string layout;
|
|
int tileWidth;
|
|
int tileHeight;
|
|
int jitter;
|
|
long count;
|
|
bool keepOrder;
|
|
double rate;
|
|
|
|
explicit Settings(const ParamValues& values)
|
|
: layout(paramChoice(values, "tileswap_layout", "grid")),
|
|
tileWidth(static_cast<int>(std::max(1L, paramInt(values, "tileswap_width", 96)))),
|
|
tileHeight(static_cast<int>(std::max(1L, paramInt(values, "tileswap_height", 96)))),
|
|
jitter(static_cast<int>(std::max(0L, paramInt(values, "tileswap_jitter", 0)))),
|
|
count(std::max(1L, paramInt(values, "tileswap_count", 64))),
|
|
keepOrder(paramBool(values, "tileswap_keep_order", false)),
|
|
rate(std::max(0.0, paramDouble(values, "tileswap_rate", 1.0))) {}
|
|
};
|
|
|
|
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, the
|
|
// seed its cuts come from, and the draw that settles how often it swaps.
|
|
struct Cell {
|
|
int x;
|
|
int y;
|
|
int w;
|
|
int h;
|
|
double phase;
|
|
std::uint64_t seed;
|
|
double share;
|
|
};
|
|
|
|
} // namespace
|
|
|
|
Image TileSwapEffect::compose(const std::vector<Image>& sources, int width, int height,
|
|
const ParamValues& values, const Moment& at) const {
|
|
const Settings settings(values);
|
|
const bool scatter = settings.layout == "scatter";
|
|
Image canvas(width, height);
|
|
|
|
// What stays put for the whole clip: the cells, and for loose tiles the
|
|
// backdrop they lie on.
|
|
Rng steady = at.steady();
|
|
std::vector<Cell> cells;
|
|
auto addCell = [&](int x, int y, int w, int h) {
|
|
cells.push_back(Cell{x, y, w, h, 0.0, 0, 0.0});
|
|
};
|
|
if (scatter) {
|
|
const Image& src = sources[steady.below(sources.size() - 1)];
|
|
const int sx = static_cast<int>(steady.inRange(0, std::max(0, src.width() - width)));
|
|
const int sy = static_cast<int>(steady.inRange(0, std::max(0, src.height() - height)));
|
|
fillFromSource(canvas, src, sx, sy);
|
|
|
|
cells.reserve(static_cast<std::size_t>(settings.count));
|
|
for (long i = 0; i < settings.count; ++i) {
|
|
int w = settings.tileWidth;
|
|
int h = settings.tileHeight;
|
|
if (settings.jitter > 0) {
|
|
w += static_cast<int>(steady.inRange(-settings.jitter, settings.jitter));
|
|
h += static_cast<int>(steady.inRange(-settings.jitter, settings.jitter));
|
|
}
|
|
const int x = static_cast<int>(steady.inRange(0, width - 1));
|
|
const int y = static_cast<int>(steady.inRange(0, height - 1));
|
|
addCell(x, y, std::max(1, std::min(w, width - x)), std::max(1, std::min(h, height - y)));
|
|
}
|
|
} else {
|
|
const bool brick = settings.layout == "brick";
|
|
const std::vector<int> rows = planSpans(steady, settings.tileHeight, settings.jitter, height);
|
|
std::vector<int> columns;
|
|
if (!brick) columns = planSpans(steady, settings.tileWidth, settings.jitter, width);
|
|
int y = 0;
|
|
for (const int rowHeight : rows) {
|
|
if (brick) columns = planSpans(steady, settings.tileWidth, settings.jitter, width);
|
|
int x = 0;
|
|
for (const int cellWidth : columns) {
|
|
addCell(x, y, cellWidth, rowHeight);
|
|
x += cellWidth;
|
|
}
|
|
y += rowHeight;
|
|
}
|
|
}
|
|
for (Cell& cell : cells) {
|
|
cell.phase = unit(steady);
|
|
cell.seed = steady.next();
|
|
cell.share = unit(steady);
|
|
}
|
|
|
|
// How often each tile swaps in one pass of the clip, `each` on average. A
|
|
// tile has to come back to the cut it started with, so one that swaps at
|
|
// all swaps at least twice; below that a share of the tiles swap twice and
|
|
// the rest hold still, so a low rate thins out the tiles that move instead
|
|
// of moving every one of them. Above it, each tile rounds `each` up or
|
|
// down by its own draw.
|
|
const double length = 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 how many times it has
|
|
// 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;
|
|
plan.reserve(cells.size());
|
|
for (std::size_t i = 0; i < cells.size(); ++i) {
|
|
const Cell& cell = cells[i];
|
|
const long swaps = swapsOf(cell);
|
|
std::uint64_t turn = 0;
|
|
if (swaps > 0) {
|
|
const auto passed =
|
|
static_cast<long>(std::floor(at.seconds() * swaps / length + cell.phase));
|
|
turn = static_cast<std::uint64_t>(passed % swaps);
|
|
}
|
|
Rng cut(cell.seed + turn * 0x9e3779b97f4a7c15ULL);
|
|
const std::size_t sourceIndex =
|
|
settings.keepOrder ? (i + turn) % sources.size() : cut.below(sources.size() - 1);
|
|
const Image& src = sources[sourceIndex];
|
|
const int sx = static_cast<int>(cut.inRange(0, std::max(0, src.width() - cell.w)));
|
|
const int sy = static_cast<int>(cut.inRange(0, std::max(0, src.height() - cell.h)));
|
|
plan.push_back(Tile{cell.x, cell.y, cell.w, cell.h, static_cast<int>(sourceIndex), sx, sy});
|
|
}
|
|
|
|
paintTiles(canvas, sources, plan, scatter);
|
|
return canvas;
|
|
}
|
|
|
|
} // namespace effector
|