|
|
Donner SVG Editor & Engine
SVG-native editor and embeddable SVG2 + CSS3 engine in C++20, with GPU (WebGPU) and compact CPU renderers, built for correctness, security, and performance.
|
Status: Rejected — precision-loss at u8 premul storage Author: Claude Opus 4.7 Created: 2026-04-19 Rejected: 2026-04-19 (same-day, during implementation)
Abandoned. Storing RendererTinySkia::frame_ as u8 premultiplied RGBA loses irrecoverable precision at low alpha values. A pixel with (rgb=17, a=6) — #111111 at 6/255 coverage, a routine antialiased edge — premultiplies to 17 × 6 / 255 = 0.4 in float space, which stores to u8 as 0. After the u8 roundtrip, no unpremultiply math can recover the original rgb=17; the information is gone.
Tiny-skia's Paint::unpremulStore = true flag exists specifically to dodge this: the pipeline blends in premultiplied float space and only unpremultiplies on final u8 store, preserving per-channel precision at low alpha. Donner has relied on that guarantee (sometimes unknowingly) across its entire golden corpus. Flipping frame_ to premul fails at least three existing renderer goldens (2×2 cubic paths) on antialiased edge pixels — and those are the simple scenes. More complex goldens with semi-transparent edges would fail more broadly.
Skia sidesteps the same problem by doing blends in kRGBA_F16_SkColorType intermediate buffers when precision matters and only converting to u8 at final present. Tiny-skia-cpp has no f16 pixmap support — its pipeline is float in registers, u8 in storage, and every store/load across surface boundaries quantizes. That's the structural difference that makes Skia's "premul internal + unpremul at snapshot" safe and tiny-skia's same-pivot unsafe.
Kept:
Reverted:
Procedure (before revert):
Failure surface:
The design claimed:
frame_ becomes premul; a single unpremul pass runs inside takeSnapshot(). Every existing PNG golden must byte-match before and after — pure performance refactor with zero observable output change.
The flawed step: "every existing PNG golden must byte-match." That holds only if the pre-pivot and post-pivot u8 representations of the same pipeline-float state produce the same snapshot bytes. They don't.
Concretely: pipeline-float state (r=0.0666, a=0.0235) after unpremultiply stage (pre-pivot path) stores as (unnorm(0.0666)=17, unnorm(0.0235)=6). The same float state without the unpremultiply stage (post-pivot path) stores as (unnorm(0.0666*0.0235)=0, unnorm(0.0235)=6) — the premul multiplication happens before the unnorm round-to-u8, so the tiny value rounds to 0.
The float→u8 rounding is lossy and asymmetric. Doing it pre-unpremul loses precision on RGB at low alpha. Doing it post-unpremul (the unpremulStore=true path) preserves it. Both produce a valid premultiplied-or-unpremultiplied pixel, but the former has already thrown away the information needed to recover the straight-alpha rgb.
My audit (informed by TinySkiaBot's thorough investigation) missed this because I focused on which surfaces are already premul (all non-root surfaces — true) and whether filter primitives expect premul input (yes — true). Both correct. Neither implied that frame_ could safely make the same switch, because none of those non-root surfaces exit the pipeline via a u8 unpremul snapshot to the outside world. They're intermediate; they compose onto frame_ (or a wider parent) and the blend math on that parent still goes through tiny-skia's precision-preserving float pipeline.
The cost remains real. compositePixmapInto on the segment → frame_ blit path still runs unpremul=true and pays the premul → blend → unpremul round-trip. On splash drag frames, that's still ~1-2 ms across 6-8 segments. Lost ground.
Alternative paths forward, ordered by expected ROI:
The doc's "Premultiplied RGBA8 loses precision at low alpha values." line item was buried under "Non-Goals — precision loss: premul storage (uint8) clips low-alpha channels to discrete steps." That's not a non-goal, it's a reason not to do the change. Moving it to the front — before "the change, concretely" — and verifying it against a concrete low-alpha example would have ended the design at that step.
Future perf-refactor designs touching pixel format should lead with:
"On our representative low-alpha test pixel, what bytes does the current code store? What bytes will the proposed code store? Demonstrate they are bit-identical, or describe the drift bound."
If that can't be answered at design time, the design is not ready.