Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/help/help.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
84 changes: 81 additions & 3 deletions src/light/effects/PictureEffect.h
Original file line number Diff line number Diff line change
Expand Up @@ -26,26 +26,51 @@
/// 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 {
static constexpr const char* kFits[] = {"fit", "fill", "stretch"};
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<size_t>(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();
Expand All @@ -69,8 +94,11 @@
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
}
Expand All @@ -79,6 +107,12 @@
/// 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();
Expand All @@ -87,10 +121,13 @@
const int32_t used = static_cast<int32_t>(columns ? usedW : usedH);
const uint32_t pic = columns ? picW : picH;
const int32_t offset = (static_cast<int32_t>(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<uint32_t>(pic) * edgeDepth / 100u;
for (uint32_t i = 0; i < n; i++) {
const int32_t k = static_cast<int32_t>(i) - offset;
if (k < 0 || k >= used) { out[i] = {0, 0}; continue; }
uint32_t lo = static_cast<uint32_t>(k) * pic / static_cast<uint32_t>(used), hi = (static_cast<uint32_t>(k) + 1) * pic / static_cast<uint32_t>(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<uint16_t>(lo), static_cast<uint16_t>(hi)};
}
Expand All @@ -108,6 +145,7 @@

/// 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++) {
Expand All @@ -116,11 +154,45 @@
const Range c = cols_[x];
const size_t cell = static_cast<size_t>(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<int32_t>(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<int32_t>(target[ch]) << 8;
const int32_t delta = wanted - static_cast<int32_t>(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<uint16_t>(now);
out[ch] = static_cast<uint8_t>(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<int32_t>(static_cast<int64_t>(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<uint32_t>(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<size_t>(y) * f.width + x) * 3;
Expand Down Expand Up @@ -152,6 +224,12 @@
return static_cast<uint8_t>(root);
}

ScratchBuffer<uint16_t> state_{*this}; ///< 8.8 per channel per cell, only while smoothing

Check warning on line 227 in src/light/effects/PictureEffect.h

View workflow job for this annotation

GitHub Actions / vale

[vale] reported by reviewdog 🐶 [MoonLight.SentenceLength] Sentence runs past 30 words; it is probably two thoughts joined by a comma. Raw Output: {"message":"[MoonLight.SentenceLength] Sentence runs past 30 words; it is probably two thoughts joined by a comma.","location":{"path":"src/light/effects/PictureEffect.h","range":{"start":{"line":227,"column":51},"end":{"line":227,"column":52}}},"severity":"INFO","code":{"value":"MoonLight.SentenceLength"}}
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<Range> cols_{*this}; ///< each grid column's picture columns
ScratchBuffer<Range> rows_{*this}; ///< each grid row's picture rows
uint16_t picW_ = 0, picH_ = 0; ///< the picture size the tables were filled for
Expand Down
89 changes: 89 additions & 0 deletions test/unit/light/unit_PictureEffect.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<size_t>(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<size_t>(rig.pic.width) * rig.pic.height * 3);
rig.pic.endWrite();
rig.layer.tick();
CHECK(rig.cell(0, 0)[0] == 0);
}
Loading