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
Binary file modified docs/assets/light/drivers/fixtureprofiles.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 16 additions & 0 deletions docs/help/help.json
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,10 @@
"title": "16-bit channels",
"text": "A fine role is the low byte of a 16-bit channel, the DMX convention. `RGB 16-bit`, `GRB 16-bit` and `RGBW 16-bit` ship built in. Effects stay 8-bit, and brightness and curve go to 16 bits on output. Pan and tilt widen by repeating their byte, and a master dimmer is held wide open."
},
{
"title": "Color balance",
"text": "A batch whose white looks tinted gets a profile of its own with the strong die pulled down: green at 220 takes a green cast out. The gain applies after the curve, so 128 is half the light, and every driver on that profile follows."
},
{
"title": "Which profile a chip takes",
"text": "| Chip | Profile |\n|---|---|\n| WS2812B, WS2813, WS2815 | `GRB` |\n| SK6812 RGBW | `GRBW` |\n| WS2814 | `WRGB` |\n| WS2816 | `GRB 16-bit` |\n| UCS8903 | `RGB 16-bit` |\n| UCS8904 | `RGBW 16-bit` |\n\nA WS2811 orders its colors as its maker wired it, so try `RGB`, then the other five orders. The TM1814 and TM1914 need a settings preamble that a profile cannot describe."
Expand All @@ -173,6 +177,18 @@
},
"ch": {
"line": "What one channel carries, such as red, dimmer or pan; none for a spacer or a channel set elsewhere."
},
"gainRed": {
"line": "The red die pulled down to meet the others, 255 for none; on emitted light, so 128 is half."
},
"gainGreen": {
"line": "The green die, likewise."
},
"gainBlue": {
"line": "The blue die, likewise."
},
"gainWhite": {
"line": "The white dies, cold and warm, offered where the profile carries one."
}
}
}
Expand Down
6 changes: 3 additions & 3 deletions docs/reference/metrics/prose.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"(total)": 2017,
"(total)": 2015,
"MoonLight.EmDash": 1106,
"MoonLight.LinkText": 7,
"MoonLight.NegatedHeading": 6,
Expand All @@ -8,6 +8,6 @@
"MoonLight.Spelling": 39,
"MoonLight.Temporal": 170,
"MoonLight.TonePadding": 1,
"MoonLight.VagueClose": 28,
"MoonLight.Weasel": 602
"MoonLight.VagueClose": 27,
"MoonLight.Weasel": 601
}
118 changes: 95 additions & 23 deletions src/light/drivers/Correction.h
Original file line number Diff line number Diff line change
Expand Up @@ -26,27 +26,38 @@ inline constexpr uint8_t kWhiteModeCount =
sizeof(kWhiteModeOptions) / sizeof(kWhiteModeOptions[0]);


/// The transform itself, its offsets derived cold from the role array so the hot path stays an indexed store per channel.
struct Correction {
/// What a correction resolves to, copied whole: the curve's table, the gains and the channel offsets.
struct CorrectionData {
/// Marks a role this light does not carry.
static constexpr uint8_t kAbsent = 255; // color role not carried by this light

/// The perceptual curve the output LUT is filled through; Linear is a requirement, not a fallback, since correcting twice darkens as the square.
enum class Curve : uint8_t { Cie = 0, Gamma22, Gamma28, Linear };
/// How many gained rows the tables carry: red, green, blue and the white dies.
static constexpr uint8_t kGainRows = 4;

/// CIE 1931 lightness, inverted: a control position to a luminance fraction; the constants place the toe so the two segments meet in slope.
static float cieLuminance(float control255) MM_NONBLOCKING {
const float L = control255 * 100.0f / 255.0f;
return (L <= 8.0f) ? (L / 903.3f)
: ((L + 16.0f) / 116.0f) * ((L + 16.0f) / 116.0f) * ((L + 16.0f) / 116.0f);
}
/// The tables one light is read through, so a channel's gain costs a pointer at rebuild and nothing per light.
template <typename T>
struct Tables {
const T* base = nullptr; ///< the ungained curve, which amber, UV and every channel at gain 255 read
const T* red = nullptr; ///< the red die's table, gained or the curve itself
const T* green = nullptr; ///< the green die's
const T* blue = nullptr; ///< the blue die's
const T* white = nullptr; ///< both white dies, which the RGB gains do not reach
};

/// The perceptual curve the output LUT is filled through; Linear is a requirement, not a fallback, since correcting twice darkens as the square.
enum class Curve : uint8_t { Cie = 0, Gamma22, Gamma28, Linear };

uint8_t briLut[256] = {}; // briLut[v] = curve(v * brightness / 255)
/// The same curve to 16 bits, filled only when the table is wanted; the driver owns the 256 entries.
uint16_t* lut16 = nullptr;
/// Set by a driver whose output resolves more than a byte, so the 16-bit table is filled whatever the profile.
bool wide = false;
/// The fixture profile's per-channel gain, 255 where the LED type needs none. Applied after the curve, so the value means emitted light: green at 128 is half the green.
uint8_t gainRed = 255, gainGreen = 255, gainBlue = 255, gainWhite = 255;
/// The gained tables, `kGainRows` of 256, which a driver allocates only for a profile that carries a gain; null leaves every channel on the ungained curve.
uint8_t* gainLut = nullptr;
/// The same to 16 bits, so a wide output keeps its full resolution rather than the 8-bit table's levels.
uint16_t* gainLut16 = nullptr;
/// Which curve the brightness rebuild fills through; a driver's setting, not a global one.
Curve curve = Curve::Cie;
/// Output byte position of the red role; each color role's position is recomputed from the role array.
Expand Down Expand Up @@ -82,15 +93,70 @@ struct Correction {
bool plainRgb = true;
uint8_t outChannels = 3; // bytes emitted per light (= channelsPerLight of the wiring)
WhiteMode whiteMode = WhiteMode::Min; // how white is synthesized from RGB (white lights only)
};

/// The transform itself, its offsets derived cold from the role array so the hot path stays an indexed store per channel.
struct Correction : CorrectionData {
/// A plain RGB strip on the CIE curve, every table on that curve.
Correction() = default;
/// A copy resolves its own tables, since the ungained ones point into the curve each correction carries.
Correction(const Correction& o) : CorrectionData(o) { resolveTables(); }
/// The same on assignment, which is how a driver is handed a correction.
Correction& operator=(const Correction& o) {
CorrectionData::operator=(o);
resolveTables();
return *this;
}

/// Refresh the brightness LUT alone, leaving the channel offsets untouched; order is the design: brightness is a linear pre-scale and the curve is applied last.
/// The 8-bit tables one light is read through, resolved at rebuild: each channel's gained row, or the curve itself.
Tables<uint8_t> t8{briLut, briLut, briLut, briLut, briLut};
/// The same to 16 bits, each row null where no table is filled, which leaves that channel 8-bit.
Tables<uint16_t> t16{};

/// CIE 1931 lightness, inverted: a control position to a luminance fraction; the constants place the toe so the two segments meet in slope.
static float cieLuminance(float control255) MM_NONBLOCKING {
const float L = control255 * 100.0f / 255.0f;
return (L <= 8.0f) ? (L / 903.3f)
: ((L + 16.0f) / 116.0f) * ((L + 16.0f) / 116.0f) * ((L + 16.0f) / 116.0f);
}


/// Whether any channel carries a gain, so a driver allocates the gained tables only for a profile that needs them.
bool wantsGains() const MM_NONBLOCKING {
return gainRed != 255 || gainGreen != 255 || gainBlue != 255 || gainWhite != 255;
}

/// Refresh the brightness LUT alone, leaving the channel offsets untouched; order is the design: brightness is a linear pre-scale, the curve lands next, and the gain last, on emitted light.
void rebuildBrightness(uint8_t brightness) MM_NONBLOCKING {
const uint8_t gain[kGainRows] = {gainRed, gainGreen, gainBlue, gainWhite};
for (int v = 0; v < 256; v++) {
const float out = shape(static_cast<float>(v) * brightness / 255.0f); // scale first, then curve
const bool lit = v > 0 && brightness > 0;
briLut[v] = static_cast<uint8_t>(quantize(out, 255, lit));
if (wantsTable() && lut16) lut16[v] = static_cast<uint16_t>(quantize(out * 257.0f, 65535, lit)); // 255 * 257 = 65535
for (uint8_t g = 0; g < kGainRows; g++) {
// From the curve's own float, so the gain does not quantize a value the curve already rounded.
const float scaled = out * gain[g] / 255.0f;
const bool onAtAll = lit && gain[g] > 0;
if (gainLut) gainLut[g * 256 + v] = static_cast<uint8_t>(quantize(scaled, 255, onAtAll));
if (gainLut16) gainLut16[g * 256 + v] = static_cast<uint16_t>(quantize(scaled * 257.0f, 65535, onAtAll));
}
}
resolveTables();
}

/// Point each channel at its gained row, or at the curve where its gain is 255 or the gained tables are absent, so a light reads its table without a test.
void resolveTables() MM_NONBLOCKING {
const uint8_t gain[kGainRows] = {gainRed, gainGreen, gainBlue, gainWhite};
const uint8_t* row8[kGainRows];
const uint16_t* row16[kGainRows];
for (uint8_t g = 0; g < kGainRows; g++) {
const bool gained = gain[g] != 255;
row8[g] = gained && gainLut ? gainLut + g * 256 : briLut;
row16[g] = gained && gainLut16 ? gainLut16 + g * 256 : lut16;
}
t8 = Tables<uint8_t>{briLut, row8[0], row8[1], row8[2], row8[3]};
t16 = Tables<uint16_t>{lut16, row16[0], row16[1], row16[2], row16[3]};
}

/// Whether the 16-bit table is filled: a profile with fine roles, or a wide driver.
Expand Down Expand Up @@ -145,19 +211,20 @@ struct Correction {
}

/// Hot path: transform one source light into its output bytes, integer-only and allocation-free.
inline void apply(const uint8_t* src, uint8_t* out, uint8_t srcChannels) const { applyThrough(briLut, src, out, srcChannels); }
inline void apply(const uint8_t* src, uint8_t* out, uint8_t srcChannels) const { applyThrough(t8, src, out, srcChannels); }

/// Hot path, wide drivers only: the same transform through the 16-bit table, one value per channel; needs `lut16`.
inline void applyWide(const uint8_t* src, uint16_t* out, uint8_t srcChannels) const { applyThrough(lut16, src, out, srcChannels); }
inline void applyWide(const uint8_t* src, uint16_t* out, uint8_t srcChannels) const { applyThrough(t16, src, out, srcChannels); }

/// The transform through a curve table of either width, so the 8-bit and 16-bit outputs share every line. A remap, not a copy: the layer's packed slots become the fixture's own offsets.
template <typename T>
inline void MM_RAMFUNC applyThrough(const T* lut, const uint8_t* src, T* out, uint8_t srcChannels) const {
inline void MM_RAMFUNC applyThrough(const Tables<T>& t, const uint8_t* src, T* out, uint8_t srcChannels) const {
const T* lut = t.base;
// A plain strip: the curve on each color and nothing optional, the drivers' commonest light.
if (plainRgb) {
if (offRed != kAbsent) out[offRed] = lut[src[0]];
if (offGreen != kAbsent) out[offGreen] = lut[src[1]];
if (offBlue != kAbsent) out[offBlue] = lut[src[2]];
if (offRed != kAbsent) out[offRed] = t.red[src[0]];
if (offGreen != kAbsent) out[offGreen] = t.green[src[1]];
if (offBlue != kAbsent) out[offBlue] = t.blue[src[2]];
return;
}
constexpr T kFull = static_cast<T>(~T{0}); // 255 or 65535
Expand Down Expand Up @@ -190,7 +257,7 @@ struct Correction {
} else {
w = r < g ? (r < b ? r : b) : (g < b ? g : b); // min(r,g,b): the white component
// Computed off the PRE-subtraction values, which only rebalance the RGB emitters.
if (offWarmWhite != kAbsent) out[offWarmWhite] = lut[w];
if (offWarmWhite != kAbsent) out[offWarmWhite] = t.white[w];
// yellow ≈ min(R,G) (the shared red+green component).
if (offYellow != kAbsent) out[offYellow] = lut[r < g ? r : g];
// Driven from the blue with no red or green to pair with, so it stays dark on warm colors.
Expand All @@ -201,26 +268,31 @@ struct Correction {
// White last: it is the only emitter that rebalances RGB.
if (offWhite != kAbsent) {
if (whiteMode == WhiteMode::Accurate) { r -= w; g -= w; b -= w; } // pull white out of RGB
out[offWhite] = lut[w];
out[offWhite] = t.white[w];
}
}
// The curve, applied ONCE: everything above this line is linear light.
if (offRed != kAbsent) out[offRed] = lut[r];
if (offGreen != kAbsent) out[offGreen] = lut[g];
if (offBlue != kAbsent) out[offBlue] = lut[b];
if (offRed != kAbsent) out[offRed] = t.red[r];
if (offGreen != kAbsent) out[offGreen] = t.green[g];
if (offBlue != kAbsent) out[offBlue] = t.blue[b];
if constexpr (sizeof(T) == 1) { if (hasFine) applyFine(r, g, b, w, out); }
}

/// Hot path, 16-bit profiles only: each color channel with a fine role as its high and low byte from the same table, and a fine dimmer wide open.
void applyFine(uint8_t r, uint8_t g, uint8_t b, uint8_t w, uint8_t* out) const {
if (offDimmerFine != kAbsent) out[offDimmerFine] = 255; // wide open is 65535, not 65280
const uint8_t value[5] = {r, g, b, w, w}; // both whites carry the white component, 0 when none is synthesized
// Each channel's own gained table, so a fine channel is gained exactly as an 8-bit one and at full resolution.
const Tables<uint16_t>& w16 = t16;
const Tables<uint8_t>& n8 = t8;
const uint16_t* wide16[5] = {w16.red, w16.green, w16.blue, w16.white, w16.white};
const uint8_t* narrow8[5] = {n8.red, n8.green, n8.blue, n8.white, n8.white};
const uint8_t coarse[5] = {offRed, offGreen, offBlue, offWhite, offWarmWhite};
const uint8_t fine[5] = {offRedFine, offGreenFine, offBlueFine, offWhiteFine, offWarmWhiteFine};
for (uint8_t k = 0; k < 5; k++) {
if (fine[k] == kAbsent) continue;
// Without its table (an allocation that failed) the channel stays 8-bit: the coarse byte as written, the fine one 0.
const uint16_t q = lut16 ? lut16[value[k]] : static_cast<uint16_t>(briLut[value[k]] << 8);
const uint16_t q = wide16[k] ? wide16[k][value[k]] : static_cast<uint16_t>(narrow8[k][value[k]] << 8);
if (coarse[k] != kAbsent) out[coarse[k]] = static_cast<uint8_t>(q >> 8);
out[fine[k]] = static_cast<uint8_t>(q & 0xFF);
}
Expand Down
41 changes: 40 additions & 1 deletion src/light/drivers/DriverBase.h
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,9 @@ class DriverBase : public MoonModule {
const bool firstTable = correction_.wantsTable() && !lut16_;
if (firstTable) lut16_.resize(256);
correction_.lut16 = lut16_.data();
if (firstTable) correction_.rebuildBrightness(tableBrightness); // a table that existed was filled by the rebuild above
const bool firstGain = resolveGainTables();
if (firstTable || firstGain) correction_.rebuildBrightness(tableBrightness); // a table that existed was filled by the rebuild above
showGainShortage();
onCorrectionChanged(); // let a driver resize its correction-applied buffer
}

Expand Down Expand Up @@ -130,6 +132,9 @@ class DriverBase : public MoonModule {
void release() override {
freeWire(); // the shared correction scratch: owned here, so no driver re-frees it
correction_.lut16 = nullptr; // the table goes with the module's scratch buffers below
correction_.gainLut = nullptr;
correction_.gainLut16 = nullptr;
correction_.resolveTables(); // back onto the curve, so no row points into a freed table
clearFailBuf();
clearConfigErr();
setConfigWarn(nullptr);
Expand Down Expand Up @@ -175,6 +180,8 @@ class DriverBase : public MoonModule {
/// The resolved correction: the wiring comes from a named profile by stable id, and the render loop never reads the library.
Correction correction_;
ScratchBuffer<uint16_t> lut16_{*this}; // the correction's 16-bit table, allocated the first time it is wanted
ScratchBuffer<uint8_t> gainLut_{*this}; // the gained tables, only for a profile whose LED type carries a gain
ScratchBuffer<uint16_t> gainLut16_{*this}; // the same to 16 bits, only where a channel is read wide
uint32_t profileId_ = 0; // stable id into the FixtureProfiles library (0 → resolve to default)
/// The fixture profile applied per light, for channel order and white.
/// @guide A reorder or a reboot keeps it. After renaming the profile, re-pick it: the next reboot falls back to the default.
Expand Down Expand Up @@ -219,6 +226,34 @@ class DriverBase : public MoonModule {
/// Set this driver's default referenced profile by name, from its constructor.
void setDefaultFixtureName(const char* name) { defaultFixture_ = name; }

/// Give the correction the gained tables its profile asks for, and take them back when it asks for none; true when one is new, which the rebuild before could not fill.
bool resolveGainTables() {
const bool wants = correction_.wantsGains();
const bool wide = wants && correction_.wantsTable();
const bool freshGain = fitGainTable(gainLut_, wants);
const bool freshWide = fitGainTable(gainLut16_, wide);
correction_.gainLut = wants ? gainLut_.data() : nullptr;
correction_.gainLut16 = gainLut16_ ? gainLut16_.data() : nullptr;
return freshGain || freshWide;
}

/// Warn while a gain the profile asks for has no table to live in, so the strip shows ungained light visibly rather than silently; the next rebuild retries the allocation.
void showGainShortage() {
const bool dropped = correction_.wantsGains() && (!gainLut_ || (correction_.wantsTable() && !gainLut16_));
if (dropped) setStatus(kNoGainMemory, Severity::Warning);
else if (gainsDropped_ && status() == kNoGainMemory) clearStatus();
gainsDropped_ = dropped;
}

/// Size one gained table to whether it is wanted, true when this call allocated it.
template <typename T>
static bool fitGainTable(ScratchBuffer<T>& table, bool wanted) {
const bool fresh = wanted && !table;
if (fresh) table.resize(Correction::kGainRows * 256);
if (!wanted) table.resize(0);
return fresh;
}

/// Whether `name` is one of the correction controls, for a driver's own prepare test.
static bool isCorrectionControl(const char* name) {
return std::strcmp(name, "fixture") == 0 || std::strcmp(name, "fixtureRef") == 0 || std::strcmp(name, "localBrightness") == 0
Expand Down Expand Up @@ -309,6 +344,9 @@ class DriverBase : public MoonModule {
/// The status verdicts this driver set; each is cleared only when the status still shows the one this driver set.
const char* configErr_ = nullptr;
const char* configWarn_ = nullptr;
/// The warning while a profile's gains have no table, which the driving line yields to.
static constexpr const char* kNoGainMemory = "out of memory for the color balance, gains off";
bool gainsDropped_ = false;
char* failBuf_ = nullptr;
/// The status buffer size: the widest verdict is the loopback's worst case, which a narrower buffer clips.
static constexpr size_t kFailBufLen = 64;
Expand Down Expand Up @@ -352,6 +390,7 @@ class DriverBase : public MoonModule {
/// Show how many lights are driven, set last so an error or warning from earlier in the same parse still wins.
void setDrivingInfo(unsigned driven, unsigned total, unsigned channels = 1,
const char* mode = nullptr) {
if (gainsDropped_) { setStatus(kNoGainMemory, Severity::Warning); return; }
if (char* buf = failBufEnsure()) {
int n;
if (channels > 1)
Expand Down
Loading
Loading