#include "effects/grain.hpp" #include #include #include #include "core/parallel.hpp" #include "effects/source_fill.hpp" namespace effector { const std::vector& GrainEffect::params() const { static const std::vector specs = { ParamSpec::makeDouble("grain_amount", "Amount", 0.35, 0.0, 1.0, "How far a grain may push a pixel, as a fraction of full scale."), ParamSpec::makeInt("grain_size", "Grain size", 1, 1, 32, "Width in pixels of one grain; larger sizes look coarser."), ParamSpec::makeBool("grain_colour", "Coloured grain", false, "Draw the channels separately instead of one grey grain per pixel."), ParamSpec::makeBool("grain_midtones", "Weight towards midtones", true, "Fade the grain out in the shadows and highlights, as film does."), }; 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& sources, int width, int height, Rng& rng, const ParamValues& values) const { if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels."); const double amount = std::clamp(paramDouble(values, "grain_amount", 0.35), 0.0, 1.0); const int size = static_cast(std::max(1L, paramInt(values, "grain_size", 1))); const bool colour = paramBool(values, "grain_colour", false); const bool midtones = paramBool(values, "grain_midtones", true); Image canvas(width, height); if (!sources.empty()) { // Drawn before any threading, so that a seed always gives the same image. const std::size_t index = rng.below(sources.size() - 1); const Image& src = sources[index]; const int sx = static_cast(rng.inRange(0, std::max(0, src.width() - width))); const int sy = static_cast(rng.inRange(0, std::max(0, src.height() - height))); fillFromSource(canvas, src, sx, sy); } if (amount <= 0.0) return canvas; // The whole grain field is drawn serially from the rng, exactly like the // scanlines plan, and only the pixel arithmetic afterwards is parallel. const int cellsX = (width + size - 1) / size; const int cellsY = (height + size - 1) / size; const int channels = colour ? 3 : 1; std::vector field(static_cast(cellsX) * cellsY * channels); for (auto& cell : field) cell = static_cast(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(amount * 255.0 * 65536.0 / 127.0); parallelFor(static_cast(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(y)); const std::size_t rowCell = static_cast(static_cast(y) / size) * cellsX; for (int x = 0; x < width; ++x) { const std::size_t cell = (rowCell + static_cast(x / size)) * channels; for (int c = 0; c < 3; ++c) { const int value = out[static_cast(x) * 3 + c]; const int grain = field[cell + (colour ? static_cast(c) : 0)]; const int weight = midtones ? midtoneWeight(value) : 255; const int delta = grain * weight / 255 * scale / 65536; out[static_cast(x) * 3 + c] = static_cast(std::clamp(value + delta, 0, 255)); } } } }); return canvas; } } // namespace effector