diff --git a/docs/help/help.json b/docs/help/help.json index d1631202..3fa96030 100644 --- a/docs/help/help.json +++ b/docs/help/help.json @@ -1295,6 +1295,17 @@ "sampling": { "line": "One pixel per light, or the mean of the light's rectangle.", "more": "**point** takes the one pixel at a light's center, sharp and the fastest. With **area** each light averages its rectangle of the picture, so fine detail stays steady when a large picture shrinks onto few lights." + }, + "edgeDepth": { + "line": "How far in the edge lights look, in percent; 0 is their own share.", + "more": "A border strip's outermost lights own a sliver at the edge, where letterboxes and overscan live. Raise **edgeDepth** and they reach further in, as Hyperion's 8% does; a stride keeps the reads the same." + }, + "smoothing": { + "line": "How slowly a light eases to a new color, as a half-life; 0 is off.", + "more": "Raise **smoothing** and a light eases toward its new color, settling a noisy source alike on every source and board. A cut is a real jump, so **snapAbove** lands a bigger move whole; at 0 every move eases." + }, + "snapAbove": { + "line": "A channel moving further than this jumps instead of easing." } }, "role": "effect", diff --git a/src/light/effects/PictureEffect.h b/src/light/effects/PictureEffect.h index 39713f0a..5dcec752 100644 --- a/src/light/effects/PictureEffect.h +++ b/src/light/effects/PictureEffect.h @@ -26,6 +26,14 @@ class PictureEffect : public EffectBase { /// One pixel per light, or the mean of the light's rectangle. /// @guide **point** takes the one pixel at a light's center, sharp and the fastest. With **area** each light averages its rectangle of the picture, so fine detail stays steady when a large picture shrinks onto few lights. uint8_t sampling = kPoint; + /// How far in the edge lights look, in percent; 0 is their own share. + /// @guide A border strip's outermost lights own a sliver at the edge, where letterboxes and overscan live. Raise **edgeDepth** and they reach further in, as Hyperion's 8% does; a stride keeps the reads the same. + uint8_t edgeDepth = 0; + /// How slowly a light eases to a new color, as a half-life; 0 is off. + /// @guide Raise **smoothing** and a light eases toward its new color, settling a noisy source alike on every source and board. A cut is a real jump, so **snapAbove** lands a bigger move whole; at 0 every move eases. + uint8_t smoothing = 0; + /// A channel moving further than this jumps instead of easing. + uint8_t snapAbove = 80; /// Publish the placement and the sampling. void defineControls() override { @@ -33,19 +41,36 @@ class PictureEffect : public EffectBase { static constexpr const char* kSamplings[] = {"point", "area"}; controls_.addSelect("fit", fit, kFits, kFitCount); controls_.addSelect("sampling", sampling, kSamplings, kSamplingCount); + controls_.addControl("edgeDepth", edgeDepth, 0, 50); + controls_.addControl("smoothing", smoothing, 0, 255); + controls_.addControl("snapAbove", snapAbove, 0, 255); + controls_.setHidden(controls_.count() - 1, smoothing == 0); } /// The placement changes the ranges and the sampling the picture drawn, so both redraw. - bool affectsPrepare(const char* name) const override { return std::strcmp(name, "fit") == 0 || std::strcmp(name, "sampling") == 0; } + bool affectsPrepare(const char* name) const override { + // edgeDepth moves the ranges, and smoothing allocates or frees the accumulators. + return std::strcmp(name, "fit") == 0 || std::strcmp(name, "sampling") == 0 + || std::strcmp(name, "edgeDepth") == 0 || std::strcmp(name, "smoothing") == 0; + } /// Size the range tables to the grid; the first picture fills them. void prepare() override { cols_.resize(width()); rows_.resize(height()); + // 8.8 per channel, and only while smoothing, since a step is often a fraction of a byte. + const size_t cells = static_cast(cols_.count()) * rows_.count(); + state_.resize(smoothing != 0 ? cells * 3u : 0); + primed_ = false; picW_ = picH_ = 0; // stale, so the next tick refills and draws } /// Sample a picture that is new, or redraw one the layer overwrote; with nothing to sample the layer is cleared once. void tick() MM_NONBLOCKING override { + // The time since the last tick, which the smoothing's half-life is measured in. + const uint32_t now = platform::millis(); + dtMs_ = ticked_ ? now - lastMs_ : 0; + lastMs_ = now; + ticked_ = true; const PictureService::Reader reader; if (!reader) return; // the source is swapping or filling its only picture: sample on the next tick const PictureFrame* f = &reader.frame(); @@ -69,8 +94,11 @@ class PictureEffect : public EffectBase { picH_ = f->height; redraw = true; } - if (!redraw && f->generation == lastGen_ && layerGen == lastLayerGen_) return; + // A settling smoother has not reached the picture yet, so it draws again on an unchanged one. + if (!redraw && !settling_ && f->generation == lastGen_ && layerGen == lastLayerGen_) return; + settling_ = false; gather(*f); + primed_ = true; lastGen_ = f->generation; lastLayerGen_ = layerGen + 1; // the Layer bumps the generation after this tick, for our own write } @@ -79,6 +107,12 @@ class PictureEffect : public EffectBase { /// One axis of a cell's source rectangle, [lo, hi) in picture pixels; empty outside a fitted picture. struct Range { uint16_t lo, hi; }; + /// An edge cell's range at `deep` picture lines in: the first reaches inward from 0, the last back from the end. + static void reachEdge(int32_t k, int32_t used, uint32_t pic, uint32_t deep, uint32_t& lo, uint32_t& hi) { + if (k == 0) hi = deep; + if (k == used - 1 && pic > deep) lo = pic - deep; + } + /// Each cell's range along one axis: the grid's `n` cells over the picture's `picW` × `picH`, `columns` naming the axis. void fillRanges(Range* out, uint16_t n, uint16_t picW, uint16_t picH, bool columns) const { const uint32_t gw = cols_.count(), gh = rows_.count(); @@ -87,10 +121,13 @@ class PictureEffect : public EffectBase { const int32_t used = static_cast(columns ? usedW : usedH); const uint32_t pic = columns ? picW : picH; const int32_t offset = (static_cast(columns ? gw : gh) - used) / 2; // negative when cropped + // What an edge cell reaches instead of its own share, deeper OR shallower. The control sets the depth rather than raising a floor under it, and 0 leaves every cell its share. + const uint32_t deep = static_cast(pic) * edgeDepth / 100u; for (uint32_t i = 0; i < n; i++) { const int32_t k = static_cast(i) - offset; if (k < 0 || k >= used) { out[i] = {0, 0}; continue; } uint32_t lo = static_cast(k) * pic / static_cast(used), hi = (static_cast(k) + 1) * pic / static_cast(used); + if (deep) reachEdge(k, used, pic, deep, lo, hi); if (hi <= lo) hi = lo + 1; // a picture smaller than the grid: neighboring cells share a pixel out[i] = {static_cast(lo), static_cast(hi)}; } @@ -108,6 +145,7 @@ class PictureEffect : public EffectBase { /// One pass over the grid, each cell from its rectangle. void gather(const PictureFrame& f) { + const uint32_t keep = halfLifeKeep(dtMs_, halfLifeMs()); // the share of each gap this step leaves const draw::Canvas cv = canvas(); const uint32_t gw = cols_.count(), gh = rows_.count(); for (uint32_t y = 0; y < gh; y++) { @@ -116,11 +154,45 @@ class PictureEffect : public EffectBase { const Range c = cols_[x]; const size_t cell = static_cast(y) * gw + x; if (r.hi == r.lo || c.hi == c.lo) { draw::pixel(cv, cell, RGB{0, 0, 0}); continue; } - draw::pixel(cv, cell, sampling == kArea ? average(f, c, r) : at(f, (c.lo + c.hi - 1) / 2, (r.lo + r.hi - 1) / 2)); + const RGB want = sampling == kArea ? average(f, c, r) : at(f, (c.lo + c.hi - 1) / 2, (r.lo + r.hi - 1) / 2); + draw::pixel(cv, cell, state_ ? eased(cell, want, keep) : want); } } } + /// One cell eased toward `want` by the step's `keep`, its 8.8 state keeping the fraction of a step a byte would lose. + RGB eased(size_t cell, RGB want, uint32_t keep) { + const uint8_t target[3] = {want.r, want.g, want.b}; + const int32_t snap = static_cast(snapAbove) << 8; + uint8_t out[3]; + for (int ch = 0; ch < 3; ch++) { + const size_t slot = cell * 3 + ch; + const int32_t wanted = static_cast(target[ch]) << 8; + const int32_t delta = wanted - static_cast(state_[slot]); + // The first picture, and any move big enough to be a cut, land whole. + const bool jump = !primed_ || (snapAbove && (delta > snap || delta < -snap)); + const int32_t now = jump ? wanted : easeToward(state_[slot], wanted, keep); + state_[slot] = static_cast(now); + out[ch] = static_cast(now >> 8); + if (now != wanted) settling_ = true; + } + return RGB{out[0], out[1], out[2]}; + } + +public: + /// Exponential smoothing: `held` moves toward `wanted`, both 8.8, leaving `keep` (0..65536, from `halfLifeKeep`) of the gap. A step below one count still moves one, so a light arrives; no time passed moves nothing. + static int32_t easeToward(int32_t held, int32_t wanted, uint32_t keep) MM_NONBLOCKING { + const int32_t delta = wanted - held; + if (delta == 0 || keep >= 65536u) return held; + int32_t now = wanted - static_cast(static_cast(delta) * keep / 65536); + if (now == held) now += delta > 0 ? 1 : -1; + return now; + } + +private: + /// The smoothing control as a half-life in milliseconds, squared so the short range gets the travel, as Trails' persistence is. + uint32_t halfLifeMs() const { return 20u + static_cast(smoothing) * smoothing / 16u; } + /// The pixel at (x, y). static RGB at(const PictureFrame& f, uint32_t x, uint32_t y) { const uint8_t* p = f.rgb + (static_cast(y) * f.width + x) * 3; @@ -152,6 +224,12 @@ class PictureEffect : public EffectBase { return static_cast(root); } + ScratchBuffer state_{*this}; ///< 8.8 per channel per cell, only while smoothing + bool primed_ = false; ///< whether a first picture has landed whole + bool settling_ = false; ///< whether the last gather left a cell short of its target + uint32_t lastMs_ = 0; ///< when the last tick ran + uint32_t dtMs_ = 0; ///< the milliseconds since it + bool ticked_ = false; ///< whether a tick has run, so the first measures no time ScratchBuffer cols_{*this}; ///< each grid column's picture columns ScratchBuffer rows_{*this}; ///< each grid row's picture rows uint16_t picW_ = 0, picH_ = 0; ///< the picture size the tables were filled for diff --git a/test/unit/light/unit_PictureEffect.cpp b/test/unit/light/unit_PictureEffect.cpp index 89078829..bf3264e1 100644 --- a/test/unit/light/unit_PictureEffect.cpp +++ b/test/unit/light/unit_PictureEffect.cpp @@ -135,3 +135,92 @@ TEST_CASE("PictureEffect without a Picture service says so and shows black") { CHECK(std::string(r.fx.status()) == "no picture: add a Picture service under Services"); CHECK(r.cell(0, 0)[0] == 0); } + +// --- Border sampling --------------------------------------------------------------------------- +// A border strip's outermost lights own a sliver at the edge, where a letterbox and the overscan junk live, so edgeDepth reaches further in. Smoothing eases a light toward its new color. + +// The control sets the depth rather than raising a floor under it, so on a coarse grid an edge cell takes less than its own share. +TEST_CASE("PictureEffect edgeDepth gives the outermost lights the depth they ask for") { + // Four columns over 100 picture columns: each owns 25, so 10% is deliberately shallower. + PictureRig deep(100, 100, 4, 4, 1, mm::PictureEffect::kStretch, mm::PictureEffect::kArea); + deep.fx.edgeDepth = 10; + deep.fx.prepare(); + deep.layer.tick(); + + PictureRig share(100, 100, 4, 4, 1, mm::PictureEffect::kStretch, mm::PictureEffect::kArea); + share.layer.tick(); + + // The ramp rises left to right, so a shallower first column reads darker: the leftmost tenth, not quarter. + CHECK(deep.cell(0, 3)[0] < share.cell(0, 3)[0]); + // And the last column reads brighter, averaging the rightmost tenth rather than its quarter. + CHECK(deep.cell(3, 3)[0] > share.cell(3, 3)[0]); + // Interior columns are on no edge, so they are untouched. + CHECK(deep.cell(1, 3)[0] == share.cell(1, 3)[0]); + CHECK(deep.cell(2, 3)[0] == share.cell(2, 3)[0]); +} + +// 0 is the whole point of the default: it must leave the ranges exactly as they were. +TEST_CASE("PictureEffect edgeDepth of 0 samples precisely what it did before") { + PictureRig off(64, 64, 8, 8, 1, mm::PictureEffect::kStretch, mm::PictureEffect::kArea); + off.fx.edgeDepth = 0; + off.fx.prepare(); + off.layer.tick(); + PictureRig plain(64, 64, 8, 8, 1, mm::PictureEffect::kStretch, mm::PictureEffect::kArea); + plain.layer.tick(); + for (size_t y = 0; y < 8; y++) + for (size_t x = 0; x < 8; x++) + for (int ch = 0; ch < 3; ch++) CHECK(off.cell(x, y)[ch] == plain.cell(x, y)[ch]); +} + +// Smoothing is a half-life in time, so after one half-life half the gap is closed, whatever the frame rate. +TEST_CASE("PictureEffect smoothing closes half the gap in one half-life") { + const int32_t from = 0, to = 255 << 8; + const int32_t half = mm::PictureEffect::easeToward(from, to, mm::halfLifeKeep(500, 500)); + CHECK(half >= (to / 2) - 256); + CHECK(half <= (to / 2) + 256); +} + +// Frame-rate independence: ten short steps land where one long step does, within the 8.8 rounding. +TEST_CASE("PictureEffect smoothing settles at the same speed at any frame rate") { + int32_t fast = 0; + for (int i = 0; i < 10; i++) fast = mm::PictureEffect::easeToward(fast, 255 << 8, mm::halfLifeKeep(20, 500)); + const int32_t slow = mm::PictureEffect::easeToward(0, 255 << 8, mm::halfLifeKeep(200, 500)); + CHECK(fast >= slow - 512); + CHECK(fast <= slow + 512); +} + +// The one 8-bit state would fail: a step is a fraction of a byte, so a light arrives only if the fractions are kept. +TEST_CASE("PictureEffect smoothing arrives exactly, and no time passed moves nothing") { + int32_t v = 255 << 8; + for (int i = 0; i < 2000; i++) v = mm::PictureEffect::easeToward(v, 0, mm::halfLifeKeep(20, 4000)); + CHECK(v == 0); + CHECK(mm::PictureEffect::easeToward(100, 5000, mm::halfLifeKeep(0, 500)) == 100); +} + +// A cut is a real jump, and easing through it reads as the lights lagging the picture. +TEST_CASE("PictureEffect snapAbove lands a cut whole instead of easing into it") { + PictureRig rig(8, 8, 8, 8, 1, mm::PictureEffect::kStretch, mm::PictureEffect::kPoint); + rig.fx.smoothing = 200; + rig.fx.snapAbove = 40; // 255 to 0 is far beyond this + rig.fx.prepare(); + rig.layer.tick(); + REQUIRE(rig.cell(0, 0)[0] == 255); + + uint8_t* rgb = rig.pic.beginWrite(); + std::memset(rgb, 0, static_cast(rig.pic.width) * rig.pic.height * 3); + rig.pic.endWrite(); + rig.layer.tick(); + CHECK(rig.cell(0, 0)[0] == 0); // one tick, not four hundred +} + +// Smoothing off is the default, so it must cost nothing and follow the picture exactly. +TEST_CASE("PictureEffect without smoothing follows the picture tick for tick") { + PictureRig rig(8, 8, 8, 8, 1, mm::PictureEffect::kStretch, mm::PictureEffect::kPoint); + rig.layer.tick(); + REQUIRE(rig.cell(0, 0)[0] == 255); + uint8_t* rgb = rig.pic.beginWrite(); + std::memset(rgb, 0, static_cast(rig.pic.width) * rig.pic.height * 3); + rig.pic.endWrite(); + rig.layer.tick(); + CHECK(rig.cell(0, 0)[0] == 0); +}