Add Film gate, a video effect of film running through a projector
Film grain's field moves to effects/grain_field.hpp so both share it; Film grain renders exactly as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,11 @@ commit that made it. The interface shows this file under Changelog in its menu.
|
|||||||
and back, so the streaks spread over one part of the picture after
|
and back, so the streaks spread over one part of the picture after
|
||||||
another. On the command line it is `-e sweep` with `--sweep-from`,
|
another. On the command line it is `-e sweep` with `--sweep-from`,
|
||||||
`--sweep-to`, `--sweep-width`, `--sweep-speed` and the rest.
|
`--sweep-to`, `--sweep-width`, `--sweep-speed` and the rest.
|
||||||
|
- Film gate, a video effect: film running through a projector, with grain
|
||||||
|
drawn anew on every frame as Film grain draws it, the picture swaying in
|
||||||
|
the gate, flicker, dust and scratches. On the command line it is
|
||||||
|
`-e filmgate` with `--gate-grain`, `--gate-weave`, `--gate-flicker`,
|
||||||
|
`--gate-dust` and `--gate-scratches`.
|
||||||
- A Quality choice under Video on Setup: Small file, Standard or High, for
|
- A Quality choice under Video on Setup: Small file, Standard or High, for
|
||||||
how the saved video is encoded. It is kept in the project, and on the
|
how the saved video is encoded. It is kept in the project, and on the
|
||||||
command line it is `--video-quality small|standard|high`. Standard writes
|
command line it is `--video-quality small|standard|high`. Standard writes
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ add_library(effector_core STATIC
|
|||||||
src/effects/channelsplit.cpp
|
src/effects/channelsplit.cpp
|
||||||
src/effects/corrupt.cpp
|
src/effects/corrupt.cpp
|
||||||
src/effects/feedback.cpp
|
src/effects/feedback.cpp
|
||||||
|
src/effects/filmgate.cpp
|
||||||
src/effects/flow.cpp
|
src/effects/flow.cpp
|
||||||
src/effects/grain.cpp
|
src/effects/grain.cpp
|
||||||
src/effects/monochrome.cpp
|
src/effects/monochrome.cpp
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
#include "effects/channelsplit.hpp"
|
#include "effects/channelsplit.hpp"
|
||||||
#include "effects/corrupt.hpp"
|
#include "effects/corrupt.hpp"
|
||||||
#include "effects/feedback.hpp"
|
#include "effects/feedback.hpp"
|
||||||
|
#include "effects/filmgate.hpp"
|
||||||
#include "effects/flow.hpp"
|
#include "effects/flow.hpp"
|
||||||
#include "effects/grain.hpp"
|
#include "effects/grain.hpp"
|
||||||
#include "effects/monochrome.hpp"
|
#include "effects/monochrome.hpp"
|
||||||
@@ -47,6 +48,7 @@ const std::vector<EffectPtr>& effects() {
|
|||||||
std::make_shared<TunnelEffect>(),
|
std::make_shared<TunnelEffect>(),
|
||||||
std::make_shared<FlowEffect>(),
|
std::make_shared<FlowEffect>(),
|
||||||
std::make_shared<SweepEffect>(),
|
std::make_shared<SweepEffect>(),
|
||||||
|
std::make_shared<FilmGateEffect>(),
|
||||||
};
|
};
|
||||||
return registry;
|
return registry;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
#include "effects/filmgate.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <array>
|
||||||
|
#include <cmath>
|
||||||
|
#include <numbers>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/parallel.hpp"
|
||||||
|
#include "effects/grain_field.hpp"
|
||||||
|
#include "effects/sampling.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
const std::vector<ParamSpec>& FilmGateEffect::params() const {
|
||||||
|
static const std::vector<ParamSpec> specs = {
|
||||||
|
ParamSpec::makeDouble("gate_grain", "Grain", 0.3, 0.0, 1.0,
|
||||||
|
"How far a grain may push a pixel, as a fraction of full scale; "
|
||||||
|
"the grain is new on every frame."),
|
||||||
|
ParamSpec::makeInt("gate_grain_size", "Grain size", 2, 1, 32,
|
||||||
|
"Width in pixels of one grain; larger sizes look coarser."),
|
||||||
|
ParamSpec::makeInt("gate_weave", "Weave", 2, 0, 64,
|
||||||
|
"Furthest in pixels the picture sways sideways in the gate, and half "
|
||||||
|
"that up and down."),
|
||||||
|
ParamSpec::makeDouble("gate_flicker", "Flicker", 0.06, 0.0, 1.0,
|
||||||
|
"How far the brightness may jump from one frame to the next, as a "
|
||||||
|
"fraction of itself."),
|
||||||
|
ParamSpec::makeInt("gate_dust", "Dust", 3, 0, 500,
|
||||||
|
"Specks of dust on each frame, every one there for that frame alone."),
|
||||||
|
ParamSpec::makeInt("gate_scratches", "Scratches", 1, 0, 16,
|
||||||
|
"Thin bright lines running down the film, wandering a little and "
|
||||||
|
"now and then missing a frame."),
|
||||||
|
};
|
||||||
|
return specs;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
struct Settings {
|
||||||
|
double grain;
|
||||||
|
int grainSize;
|
||||||
|
int weave;
|
||||||
|
double flicker;
|
||||||
|
int dust;
|
||||||
|
int scratches;
|
||||||
|
|
||||||
|
explicit Settings(const ParamValues& values)
|
||||||
|
: grain(std::clamp(paramDouble(values, "gate_grain", 0.3), 0.0, 1.0)),
|
||||||
|
grainSize(static_cast<int>(std::max(1L, paramInt(values, "gate_grain_size", 2)))),
|
||||||
|
weave(static_cast<int>(std::max(0L, paramInt(values, "gate_weave", 2)))),
|
||||||
|
flicker(std::clamp(paramDouble(values, "gate_flicker", 0.06), 0.0, 1.0)),
|
||||||
|
dust(static_cast<int>(std::max(0L, paramInt(values, "gate_dust", 3)))),
|
||||||
|
scratches(static_cast<int>(std::max(0L, paramInt(values, "gate_scratches", 1)))) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
double unit(Rng& rng) { return unitDouble(rng.next()); }
|
||||||
|
|
||||||
|
// The swings the sway is made of: roughly how often each comes round a
|
||||||
|
// second, and how much of the sway it carries.
|
||||||
|
constexpr std::array<double, 3> kSwingRate = {0.7, 1.9, 4.3};
|
||||||
|
constexpr std::array<double, 3> kSwingShare = {0.5, 0.3, 0.2};
|
||||||
|
|
||||||
|
// Blends a pixel towards `shade` by `mix` out of 256.
|
||||||
|
void towards(std::uint8_t* px, int shade, int mix) {
|
||||||
|
for (int c = 0; c < 3; ++c) {
|
||||||
|
px[c] = static_cast<std::uint8_t>(px[c] + (shade - px[c]) * mix / 256);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
Image FilmGateEffect::animate(Image input, const ParamValues& values, const Moment& at) const {
|
||||||
|
const Settings settings(values);
|
||||||
|
const int width = input.width();
|
||||||
|
const int height = input.height();
|
||||||
|
constexpr double tau = 2.0 * std::numbers::pi;
|
||||||
|
|
||||||
|
// What stays put for the whole clip: where each swing of the sway
|
||||||
|
// starts, and where each scratch runs down the film.
|
||||||
|
Rng steady = at.steady();
|
||||||
|
std::array<double, 3> phaseX{};
|
||||||
|
std::array<double, 3> phaseY{};
|
||||||
|
for (std::size_t i = 0; i < kSwingRate.size(); ++i) {
|
||||||
|
phaseX[i] = unit(steady) * tau;
|
||||||
|
phaseY[i] = unit(steady) * tau;
|
||||||
|
}
|
||||||
|
std::vector<double> scratchAt(static_cast<std::size_t>(settings.scratches));
|
||||||
|
for (double& x : scratchAt) x = unit(steady);
|
||||||
|
|
||||||
|
// Where this frame is in time: how far the picture has swayed.
|
||||||
|
double swayX = 0.0;
|
||||||
|
double swayY = 0.0;
|
||||||
|
for (std::size_t i = 0; i < kSwingRate.size(); ++i) {
|
||||||
|
const double turn = at.cycle(kSwingRate[i]) * tau;
|
||||||
|
swayX += kSwingShare[i] * std::sin(turn + phaseX[i]);
|
||||||
|
swayY += kSwingShare[i] * std::sin(turn + phaseY[i]);
|
||||||
|
}
|
||||||
|
const int dx = static_cast<int>(std::lround(swayX * settings.weave));
|
||||||
|
const int dy = static_cast<int>(std::lround(swayY * settings.weave * 0.5));
|
||||||
|
|
||||||
|
// What changes on every frame, all drawn before a pixel is touched: the
|
||||||
|
// flicker, the grain, the dust, and which scratches show and where.
|
||||||
|
Rng fresh = at.fresh();
|
||||||
|
const double flicker = settings.flicker * (unit(fresh) * 2.0 - 1.0);
|
||||||
|
const int gain = static_cast<int>(std::lround((1.0 + flicker) * 256.0));
|
||||||
|
GrainField grain;
|
||||||
|
if (settings.grain > 0.0) grain = drawGrain(fresh, width, height, settings.grainSize, false);
|
||||||
|
struct Speck {
|
||||||
|
int x;
|
||||||
|
int y;
|
||||||
|
int radius;
|
||||||
|
};
|
||||||
|
std::vector<Speck> specks(static_cast<std::size_t>(settings.dust));
|
||||||
|
for (Speck& speck : specks) {
|
||||||
|
speck.x = static_cast<int>(fresh.inRange(0, width - 1));
|
||||||
|
speck.y = static_cast<int>(fresh.inRange(0, height - 1));
|
||||||
|
speck.radius = static_cast<int>(fresh.inRange(0, 2));
|
||||||
|
}
|
||||||
|
std::vector<int> scratchX;
|
||||||
|
for (const double x : scratchAt) {
|
||||||
|
const int wander = static_cast<int>(fresh.inRange(-2, 2));
|
||||||
|
if (unit(fresh) < 0.85) scratchX.push_back(static_cast<int>(x * (width - 1)) + wander);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The picture moved in the gate, the edges it leaves held from the last
|
||||||
|
// row and column, and lit up or down by the flicker.
|
||||||
|
Image frame(width, height);
|
||||||
|
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
|
||||||
|
for (std::size_t y = begin; y < end; ++y) {
|
||||||
|
const int sy = std::clamp(static_cast<int>(y) - dy, 0, height - 1);
|
||||||
|
const std::uint8_t* in = input.row(sy);
|
||||||
|
std::uint8_t* out = frame.row(static_cast<int>(y));
|
||||||
|
for (int x = 0; x < width; ++x) {
|
||||||
|
const int sx = std::clamp(x - dx, 0, width - 1);
|
||||||
|
const std::uint8_t* from = in + static_cast<std::size_t>(sx) * 3;
|
||||||
|
std::uint8_t* to = out + static_cast<std::size_t>(x) * 3;
|
||||||
|
for (int c = 0; c < 3; ++c) {
|
||||||
|
to[c] = static_cast<std::uint8_t>(std::min(255, from[c] * gain >> 8));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (settings.grain > 0.0) applyGrain(frame, grain, settings.grain, true);
|
||||||
|
|
||||||
|
// Dust lies dark on the picture; a scratch through the emulsion lets the
|
||||||
|
// light through.
|
||||||
|
for (const Speck& speck : specks) {
|
||||||
|
for (int y = speck.y - speck.radius; y <= speck.y + speck.radius; ++y) {
|
||||||
|
if (y < 0 || y >= height) continue;
|
||||||
|
for (int x = speck.x - speck.radius; x <= speck.x + speck.radius; ++x) {
|
||||||
|
if (x < 0 || x >= width) continue;
|
||||||
|
const int ox = x - speck.x;
|
||||||
|
const int oy = y - speck.y;
|
||||||
|
if (ox * ox + oy * oy > speck.radius * speck.radius) continue;
|
||||||
|
towards(frame.row(y) + static_cast<std::size_t>(x) * 3, 16, 220);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const int x : scratchX) {
|
||||||
|
if (x < 0 || x >= width) continue;
|
||||||
|
for (int y = 0; y < height; ++y) {
|
||||||
|
towards(frame.row(y) + static_cast<std::size_t>(x) * 3, 255, 150);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "effects/video_effect.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
// Film running through a projector: the grain is new on every frame, the
|
||||||
|
// picture sways a little in the gate and its brightness flickers, and dust
|
||||||
|
// and scratches pass over it.
|
||||||
|
//
|
||||||
|
// The sway is a sum of slow swings, each a whole number of times in the
|
||||||
|
// clip, so it loops; grain, flicker and dust are drawn from at.fresh() and
|
||||||
|
// are different on every frame, as they are on film.
|
||||||
|
class FilmGateEffect : public VideoEffect {
|
||||||
|
public:
|
||||||
|
std::string name() const override { return "filmgate"; }
|
||||||
|
std::string title() const override { return "Film gate"; }
|
||||||
|
std::string description() const override {
|
||||||
|
return "Film running through a projector: grain new on every frame, as Film grain draws "
|
||||||
|
"it, a picture swaying in the gate, flicker, dust and scratches.";
|
||||||
|
}
|
||||||
|
const std::vector<ParamSpec>& params() const override;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
Image animate(Image input, const ParamValues& values, const Moment& at) const override;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
+3
-38
@@ -4,7 +4,7 @@
|
|||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "core/parallel.hpp"
|
#include "effects/grain_field.hpp"
|
||||||
#include "effects/source_fill.hpp"
|
#include "effects/source_fill.hpp"
|
||||||
|
|
||||||
namespace effector {
|
namespace effector {
|
||||||
@@ -23,14 +23,6 @@ const std::vector<ParamSpec>& GrainEffect::params() const {
|
|||||||
return specs;
|
return specs;
|
||||||
}
|
}
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
// Response curve: 1 at mid grey, falling to 0 at black and at white, which is
|
|
||||||
// roughly how grain behaves on film.
|
|
||||||
inline int midtoneWeight(int value) { return 255 - std::abs(2 * value - 255); }
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
Image GrainEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
Image GrainEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||||
const ParamValues& values) const {
|
const ParamValues& values) const {
|
||||||
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
|
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
|
||||||
@@ -54,35 +46,8 @@ Image GrainEffect::render(const std::vector<Image>& sources, int width, int heig
|
|||||||
|
|
||||||
// The whole grain field is drawn serially from the rng, exactly like the
|
// The whole grain field is drawn serially from the rng, exactly like the
|
||||||
// scanlines plan, and only the pixel arithmetic afterwards is parallel.
|
// scanlines plan, and only the pixel arithmetic afterwards is parallel.
|
||||||
const int cellsX = (width + size - 1) / size;
|
const GrainField field = drawGrain(rng, width, height, size, colour);
|
||||||
const int cellsY = (height + size - 1) / size;
|
applyGrain(canvas, field, amount, midtones);
|
||||||
const int channels = colour ? 3 : 1;
|
|
||||||
std::vector<std::int8_t> field(static_cast<std::size_t>(cellsX) * cellsY * channels);
|
|
||||||
for (auto& cell : field) cell = static_cast<std::int8_t>(rng.inRange(-127, 127));
|
|
||||||
|
|
||||||
// 127 * 255 is the largest product of a grain and a midtone weight; scaling
|
|
||||||
// by `amount` there keeps the whole thing in integers.
|
|
||||||
const int scale = static_cast<int>(amount * 255.0 * 65536.0 / 127.0);
|
|
||||||
|
|
||||||
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
|
|
||||||
for (std::size_t y = begin; y < end; ++y) {
|
|
||||||
std::uint8_t* out = canvas.row(static_cast<int>(y));
|
|
||||||
const std::size_t rowCell =
|
|
||||||
static_cast<std::size_t>(static_cast<int>(y) / size) * cellsX;
|
|
||||||
|
|
||||||
for (int x = 0; x < width; ++x) {
|
|
||||||
const std::size_t cell = (rowCell + static_cast<std::size_t>(x / size)) * channels;
|
|
||||||
for (int c = 0; c < 3; ++c) {
|
|
||||||
const int value = out[static_cast<std::size_t>(x) * 3 + c];
|
|
||||||
const int grain = field[cell + (colour ? static_cast<std::size_t>(c) : 0)];
|
|
||||||
const int weight = midtones ? midtoneWeight(value) : 255;
|
|
||||||
const int delta = grain * weight / 255 * scale / 65536;
|
|
||||||
out[static_cast<std::size_t>(x) * 3 + c] =
|
|
||||||
static_cast<std::uint8_t>(std::clamp(value + delta, 0, 255));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return canvas;
|
return canvas;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/image.hpp"
|
||||||
|
#include "core/parallel.hpp"
|
||||||
|
#include "core/rng.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
// A field of grain over a canvas: one value in [-127, 127] per cell of
|
||||||
|
// `size` pixels square, per channel when the grain is coloured. Shared by the
|
||||||
|
// film grain and the film gate effects.
|
||||||
|
struct GrainField {
|
||||||
|
int size = 1;
|
||||||
|
int cellsX = 0;
|
||||||
|
bool colour = false;
|
||||||
|
std::vector<std::int8_t> cells;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Draws the whole field serially from the rng, so that only the pixel
|
||||||
|
// arithmetic that applies it runs in parallel.
|
||||||
|
inline GrainField drawGrain(Rng& rng, int width, int height, int size, bool colour) {
|
||||||
|
GrainField field;
|
||||||
|
field.size = size;
|
||||||
|
field.colour = colour;
|
||||||
|
field.cellsX = (width + size - 1) / size;
|
||||||
|
const int cellsY = (height + size - 1) / size;
|
||||||
|
field.cells.resize(static_cast<std::size_t>(field.cellsX) * cellsY * (colour ? 3 : 1));
|
||||||
|
for (auto& cell : field.cells) cell = static_cast<std::int8_t>(rng.inRange(-127, 127));
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response curve: 1 at mid grey, falling to 0 at black and at white, which is
|
||||||
|
// roughly how grain behaves on film.
|
||||||
|
inline int midtoneWeight(int value) { return 255 - std::abs(2 * value - 255); }
|
||||||
|
|
||||||
|
// Pushes every pixel by its grain, at most `amount` of full scale, and less in
|
||||||
|
// the shadows and highlights when `midtones` is set.
|
||||||
|
inline void applyGrain(Image& canvas, const GrainField& field, double amount, bool midtones) {
|
||||||
|
const int width = canvas.width();
|
||||||
|
const int channels = field.colour ? 3 : 1;
|
||||||
|
// 127 * 255 is the largest product of a grain and a midtone weight;
|
||||||
|
// scaling by `amount` there keeps the whole thing in integers.
|
||||||
|
const int scale = static_cast<int>(amount * 255.0 * 65536.0 / 127.0);
|
||||||
|
|
||||||
|
parallelFor(static_cast<std::size_t>(canvas.height()), [&](std::size_t begin, std::size_t end) {
|
||||||
|
for (std::size_t y = begin; y < end; ++y) {
|
||||||
|
std::uint8_t* out = canvas.row(static_cast<int>(y));
|
||||||
|
const std::size_t rowCell =
|
||||||
|
static_cast<std::size_t>(static_cast<int>(y) / field.size) * field.cellsX;
|
||||||
|
|
||||||
|
for (int x = 0; x < width; ++x) {
|
||||||
|
const std::size_t cell =
|
||||||
|
(rowCell + static_cast<std::size_t>(x / field.size)) * channels;
|
||||||
|
for (int c = 0; c < 3; ++c) {
|
||||||
|
const int value = out[static_cast<std::size_t>(x) * 3 + c];
|
||||||
|
const std::size_t plane = field.colour ? static_cast<std::size_t>(c) : 0;
|
||||||
|
const int grain = field.cells[cell + plane];
|
||||||
|
const int weight = midtones ? midtoneWeight(value) : 255;
|
||||||
|
const int delta = grain * weight / 255 * scale / 65536;
|
||||||
|
out[static_cast<std::size_t>(x) * 3 + c] =
|
||||||
|
static_cast<std::uint8_t>(std::clamp(value + delta, 0, 255));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -2110,6 +2110,11 @@ int main() {
|
|||||||
{"flow", {{"flow_mode", std::string("swirl")}}, {}, {{"flow_strength", 0L}}},
|
{"flow", {{"flow_mode", std::string("swirl")}}, {}, {{"flow_strength", 0L}}},
|
||||||
{"sweep", {{"sweep_reverse", true}, {"sweep_max_run", 20L}}, {},
|
{"sweep", {{"sweep_reverse", true}, {"sweep_max_run", 20L}}, {},
|
||||||
{{"sweep_line_chance", 0.0}}},
|
{{"sweep_line_chance", 0.0}}},
|
||||||
|
{"filmgate", {{"gate_weave", 6L}, {"gate_dust", 20L}, {"gate_scratches", 3L}},
|
||||||
|
{{"gate_weave", 6L}, {"gate_grain", 0.0}, {"gate_flicker", 0.0}, {"gate_dust", 0L},
|
||||||
|
{"gate_scratches", 0L}},
|
||||||
|
{{"gate_weave", 0L}, {"gate_grain", 0.0}, {"gate_flicker", 0.0}, {"gate_dust", 0L},
|
||||||
|
{"gate_scratches", 0L}}},
|
||||||
};
|
};
|
||||||
for (const auto& [name, lively, smooth, off] : moving) {
|
for (const auto& [name, lively, smooth, off] : moving) {
|
||||||
const auto effect = effectByName(name);
|
const auto effect = effectByName(name);
|
||||||
|
|||||||
Reference in New Issue
Block a user