the symptom, in your words

"ModelRenderer.Tint above 1.0 wraps a byte and renders dark, not brighter"

✓ verified on 26.08.05
lane Getting art inposted

▸ SYMPTOM

You raise ModelRenderer.Tint above 1.0 to brighten a model. The model renders darker instead of brighter.

You author a ladder of tints a few percent apart to vary a set of props. They render as a small number of unrelated colours: flat saturated red, yellow, and near-black. Each frame keeps its texture detail intact, so it reads like an atlas fault, a UV collapse, or a mip problem.

▸ CAUSE

ModelRenderer.Tint is packed to eight bits per channel. The cast from the float wraps instead of clamping. A component above 1.0 does not saturate at white. It wraps past 255 and starts again from 0, so it renders dark.

Rendered luminance follows (tint * 255) mod 256. Thirteen identical dev cubes under one light, differing only in the tint number, measured this ramp:

  • tint 0.90 renders grey 255
  • 1.00 renders 255
  • 1.02 renders 56
  • 1.04 renders 70
  • 1.06 renders 82
  • 1.20 renders 138

The row climbs to 1.000, falls off a cliff just past it, then climbs again. Rendered luminance fits (tint * 255) mod 256 within two percent at every rung. So 1.10 is not ten percent brighter. It is 24 of 255.

The channels do not wrap together, and that is the disguise. A cool tint has blue as its largest channel, so blue wraps first, green next, red last. A population jittered a few percent around 1.05 renders as three outcomes: flat saturated red (only red survived), yellow (red and green survived), and near-black (none survived). Every frame keeps its texture detail, because the sheet is fine and only the multiply is wrong.

▸ FIX

  1. Order the wrong colours by which tint channel is largest. If the set of broken colours sorts by the largest tint channel, the multiply is the fault, not the sheet.
  2. Eliminate the sheet with a whole-sheet check, not a survey of the regions in use. On the live case the most saturated bright pixel on the entire atlas was (122, 34, 30), while broken frames held (176, 32, 48) and (176, 176, 48). No multiply at or under 1.0 can produce those values, so the sheet cannot be the source.
  3. Normalise the authored tint ladder so the largest draw reaches white. Scale the ladder by max * (1 + jitter headroom) so the largest upward draw reaches white and never passes it.
  4. Clamp at every sink where a colour becomes a renderer component. A clamp at the assignment point stops any single value from wrapping, whatever the author wrote.
  5. Assert on the placement value, not on the renderer. Once the sink clamps, a renderer walk reports green over content that is being corrected every build, so the renderer readback hides the real input. Assert on the value the placement code holds instead.

▸ WHY IT WORKS

The defect is one narrow fact: the float-to-byte cast wraps. Every downstream symptom, the dark model, the three-colour split, the intact texture, follows from a value crossing 1.0 in one channel before another. A clamp at the sink removes the crossing, so no channel wraps and the ladder reads as authored.

Two measurement traps cost an elimination round on the live case. An A/B that forced the tint to white through a static readonly field proves nothing, because the field freezes across a hotload and the build keeps using the stale value. Log the value the build actually used. A fault called view-dependent needs the frame that is supposed to be the counter-example sampled, not assumed.

Verified on engine 26.08.05: seen in a real project.
s&box moves fast; an undated fix is a liability. Spot a stale detail?
changelog
  • Published

Want to know when new guides or fixes drop? Join the community to help build this out. Report gotchas, flag outdated fixes, or just lurk.

Join the Discord