This repository is a Rive-compatible vector player that grew a second renderer, and then a 3D engine to feed them both. That sounds like duplication. It is closer to an experiment under controlled conditions: one scene description is rasterised three ways — through a hand-written GL pipeline, through the stock Rive runtime, and through a hand-rolled serialiser that writes the bytes of a real .riv file — and the three can be compared pixel for pixel on the same frame. Because every arm consumes the same list, a new primitive is written once and inherited everywhere, and a disagreement between arms is a measurable defect rather than a matter of opinion.
What makes it worth reading is not the plumbing but the derivations inside it. A glass ball is not a shader; it is a texture whose every texel is one ray solved in closed form, and the absence of a total-internal-reflection branch is a two-line algebraic collapse rather than an optimisation. A photon lightmap conserves energy not because a Gaussian was normalised analytically but because each deposit divides by the weights it actually managed to deposit. An animated export keys node transforms rather than path vertices — and under an orthographic camera those transform channels are the exact analytic screen shape, so the fast fix and the correct fix turn out to be the same fix.
The third unusual thing is this project’s relationship with its own mistakes. Several of the most instructive mechanisms here are corrections, and they are left in place rather than tidied away.
That distinction is not pedantry. This codebase has shipped a comment asserting both render arms honoured an opacity one shader never read; another calling a hardware clamp a tonemap after the target became 16-bit float; and a constant whose banner carried three different values at once.
The spine
Five stages, one vocabulary
A render is described by a node graph. The graph compiles to an ordered execution plan. Each technique node emits a layer. The layers flatten into one flat list of drawables. Three independent consumers rasterise or serialise that identical list. Everything else hangs off that spine.
Wires carry one of four types — Geometry (a filtered set of scene surfaces), Camera (the live orbit pose), Layer (a rendered slice), and Clock (a scalar time-of-flight in nanoseconds). A Layer satisfies a Geometry or Camera input, because a Layer carries that context — but Clock is deliberately excluded from that widening, so a clock wire cannot cross-contaminate another port. The header calls this free isolation.
The subtlety is in what counts as declared. Optional Clock inputs are intentionally absent from each node’s port spec, because declaring them would make the “every input must be connected” rule require them, and that would break every clock-free photon subgraph. Membership lives in one exported predicate — adopted after three stale copies of the same list quietly cost two node types their clock sockets.
The plan also tags each node static or dynamic, which is what lets an orbit avoid recomputing anything pose-free. A baked-faces node takes a Camera input yet is tagged static by type, because its bake does not depend on the pose — the camera only re-projects a cheap quad over it.
Scene = 0 through Cel = 33, pinned by a gate on the last ordinal so appending one is a visible act rather than silent driftDeep dive — refraction
The glass ball is a texture, and its ray problem has no branches
A glass sphere here is a 128×128 texture rebuilt on the CPU when the camera settles, drawn as a single quad. There is no glass shader. And the reason there is no total-internal-reflection branch is not an optimisation — it is an algebraic collapse that makes TIR impossible at a sphere’s exit.
Why a texture at all
A closed-form GLSL glass ball would exist only on the GL arm. As a texture on a quad, the same appearance is drawn by the depth-buffer renderer, by the stock Rive runtime, and can be written into a .riv file. That single decision is why glass survives export.
The build is keyed on a hash folding the quantised eye position, the ball list, refraction mode, bounce depth and blur roughness. An orbit re-solves roughly four thousand rays per moving frame and re-uploads one small texture — and never touches the walls, whose bake is pose-free.
The environment lookup is exact, not approximated
Box-projected environment mapping is normally a parallax correction applied to a cubemap, to fake a roughly box-shaped room. Here the environment is the room box, so intersecting the reflected ray with it is the first bounce’s true geometry. That one observation deletes an entire asset class: no cube capture, no six extra passes, no seam filtering.
Sampling happens in linear and is tone-mapped afterwards, which is not a stylistic preference. At the midpoint of a 4.0 | 0.01 linear pair, tone(mean) is 212 where mean(tone) is 131 — a gap of 81 code values.
The collapse
At entry with outward normal n and view direction w, write cᵢ = −n·w and η = 1/ior, so the transmitted cosine is cₜ = √(1 − η²(1 − cᵢ²)). Because both radii of a sphere make the same angle with a chord, the interior triangle is isoceles — and everything follows from that:
The isoceles property propagates: every internal chord is the same length, every internal incidence is the same angle, every exit discriminant is the same value. That is what makes the entire Fresnel tail closed-form, and the internal-bounce loop three branch-free lines — reflect, step one chord, exit.
Why the room appears upside down
The ball is a thick lens of focal length f = nR / (2(n−1)), which at n = 1.5 is 1.5R. The walls sit around 2.5R. Everything beyond the focal length images real and inverted — so the upside-down room is a lens doing its job, not a bug to be fixed.
With per-channel IOR the three wavelengths exit at slightly different angles and the rim picks up chromatic fringes, out of the same closed form.
The glass ball as the renderer draws it: the room visibly inverted through the sphere, the bright Fresnel rim in the outer tenth of the disc, and the chromatic fringes that per-channel IOR produces at the edge.
Presets → Specular & look → “Glass: refraction + dispersion”
.riv never captures the draftStated limits
- Fresnel is not dispersed. F is computed once and reused for all three channels while only the IOR varies. Physically F₀ would differ by about 6% relative across them. No comment states this.
- The IOR is not a scene property. The call site hard-codes 1.5, so the focal-length derivation describes about the only glass this renderer has.
- The environment walk ignores room rotation. A rotated room would silently read the wrong wall. The rooms are axis-aligned, so it has never mattered.
- The texel map is orthographic while shading is perspective. Stated as an approximation, exact in the limit — and it is exactly what keeps the texture a fixed, cacheable square grid.
Deep dive — baking
A lightmap that conserves energy without ever normalising a Gaussian
The splat kernel’s radius is not a brightness knob — and the reason is that nothing here is normalised analytically. Each deposit divides by the weights it actually managed to deposit, so conservation survives any kernel shape, any grid resolution, and a kernel clipped in half at a wall corner.
The discrete renormalisation
The obvious way to conserve energy under a Gaussian splat is to normalise the Gaussian analytically and hope the discretisation is fine enough. This does the opposite. It stamps the kernel, sums the weights that actually landed in texels, and divides by that.
It is also why the radius can be exposed as a look control at all: a gate shows that halving it leaves the baked mean bit-identical. A kernel redistributes light; it does not create it.
The texel–world map must be an exact inverse
A texel’s coordinates and the scene pattern’s coordinates must name the same surface point, or baked light lands beside the pattern it belongs to. The round trip is gated to within 1e-5 across two rooms, six faces and five samples — including a rotated, non-uniformly scaled box.
The display tail, and why the dither goes last
Cavity, then exposure, then linear radiance, then tone and dither into an 8-bit store. The ordered dither is applied after the tone transform, because its job is to de-band the quantisation step; its offsets have mean exactly zero, so it moves no radiometric claim.
This is worth stating plainly because an earlier round claimed the dither had fixed a banding artifact. Two measurements showed the banding was a depth fight between coplanar geometry, and the retraction was left in the source rather than quietly deleted.
Becoming a Rive texture
Each baked face is uploaded twice — once as a GL texture for the native arm, once as a runtime image for Rive — because both arms must be able to draw the same wall. The honest note that ships alongside it: the real GPU footprint is about twice the modelled budget.
Two frames of the same baked wall at different clock times, showing the light-in-flight sweep: the wavefront reaching the far wall, then the first indirect bounce arriving after it.
Presets → Light in flight → any TOF tile, then drag the Clock’s time
Stated limits
- The partition law is a law of the splat, not of the wall you see. The analytic direct term is added to the integrated grid only, never to the time slices — and it is the dominant term, so the shipped integrated texture is not the sum of the shipped slices.
- The 8-bit slices do not sum even where the linear grids do, because the tone curve is nonlinear. The linear half is gated; the 8-bit non-summation is asserted only in tooltip text.
- The bake refuses a rotated room, by its own typed reason code, because the splat has no inverse rotation and would stamp into the wrong texels.
Deep dive — gaussian splats
Four rounds of “the renderer is wrong”, and four times it was geometry
Round splats. Unobserved ceilings. Floaters. Fog. Each arrived as a rendering complaint, and each turned out to be a property of camera positions, seed points, capture flatness, or a diagnostic reading the wrong ratio.
The ring that could not see the ceiling
Dense fits came back with holes at every quality setting. The rig was a ring: twenty-four cameras on one circle at one elevation, which is a 1-DOF sample of a 2-DOF viewing sphere. It interpolates beautifully along the ring and extrapolates the moment you tilt off it. Surfaces no camera ever sees receive no gradient and therefore no gaussians — holes by construction, not by undertraining.
The tell was a reported PSNR of 45.8, implausibly good for a real fit: the images were easy. The fix was a full-sphere layout, and the gate states the property as arithmetic rather than as a picture — a ring’s eye-height span is under 1e-3, where a sphere’s exceeds 4.0.
Anisotropy is a product of training, not a render setting
Clouds came back looking round, and the renderer was the natural suspect. The measurement was that anisotropy grows with optimisation — about 1.34 at 500 steps against 2.78 at 5000. A second finding explained the rest: an unpersisted “fast” default had silently overwritten a 30k-step fit.
The diagnostic itself had been lying. A single needle ratio reads about 1.0 for a healthy plate, which is exactly the shape a surface fit should produce — so the old verdict accused the renderer precisely when the trainer was working. Two ratios separate them, because a plate is flat but not needly.
The capture was the problem
A 256-view dense capture measured 0.32% gradient content — 98.9% flat, 4.6 distinct colours per image — against 54.7% for a properly shaded render. A gaussian fit on flat fill can only ever be flat.
The same scene fitted from a ring rig and from a full-sphere rig, side by side — the holes in the ring fit are the argument the diagram above makes geometrically.
Presets → Gsplat: train from any chain, versus the DENSE rig tile
Stated limits
- Most figures here are recorded measurements, not gated constants — the PSNR ladder, the seed comparison, the floater counts. Only the two ratios pushed through the verdict are re-driven by a gate.
- The thin axis is dropped in the isotropic radius: a plate renders as a disc of the right area, not as a plate. Only the covariance path un-spheres it.
Deep dive — export
Writing real Rive files by hand, where a valid file is a wrong file
The worst bug in this module produces a perfectly valid file. It imports without error, the artboard exists, the animation exists — and the animation is empty, because one type key named an abstract class the runtime’s factory has no case for.
No structural check of your own bytes can see it
Emitting type key 139 instead of 28 names a class the core registry has no case for. The file is well-formed. Every field is where the specification says it should be. The tracks are silently dropped on import.
The only gate that catches this class of defect is importing through the official runtime and counting what survived — which is why export parity here is asserted as a rendered image comparison rather than as a schema check.
Transform-keying, and why the fast fix was also the correct one
Keying an animation as per-vertex positions re-tessellates the path every frame and writes an enormous file. Keying the node transform instead writes a handful of channels. The elegant part is that this is not a compression trade-off: under an orthographic camera a projected circle is exactly an ellipse, so the transform channels are the analytic screen shape. The cheap encoding and the exact one coincide.
Where they stop coinciding is stated rather than hidden: a perspective divide is not affine, so a perspective motion is a fit with an error rather than an identity — and the response is to subdivide time until the error falls under tolerance.
What the format cannot say
Colour is a 32-bit integer by format. No amount of internal precision changes that, which is why high-dynamic-range rendering here is labelled a live-view affordance rather than something the export promises. A feathered fill is silently dropped unless the fill rule is clockwise — adding those two bytes took a fixture from zero painted pixels to roughly 335,000.
Stated limits
- An asymmetric cubic vertex loses a handle. The format carries a single rotation, so a vertex whose handles are not collinear is quantised to collinear on export. No comment or gate states this loss.
- The curve fit has a tolerance hole: a monotonic run with no interior sample is accepted even when its error exceeds tolerance, because there is no key to subdivide at.
The method
How the project decides what is true
The interesting response to a false comment was not to write better comments. It was to make the claims executable — and, where a claim turned out wrong, to leave the retraction in the source.
A worked example. Two estimators bake the same wall from opposite ends — photons travelling forward from the light, camera paths travelling backward from the surface. They disagreed by 36%. Five hypotheses died against measurements: kernel edge loss, kernel width, the emission convention, the visibility lattice, and a missing albedo fold.
What identified the culprit was not the size of the disagreement but its shape. Swept against bounce depth, the ratio plateaued at 1.363 instead of converging toward 1. No amount of sampling or kernel tuning produces a plateau; only a per-bounce error does. The cause was a per-face colour read as a whole-object colour — 0.80 where the transport folded 0.73, compounding once per bounce. The arithmetic predicted the observation before the fix was run:
That is the pattern throughout: prefer a property that cannot be satisfied by accident. A cap that must be bit-identical to a smaller trace. A kernel radius that must not move a mean. A dither whose offsets must sum to zero. A file that must import through someone else’s runtime and still count.