RFD 1149 details: the measurements behind the material and the renderer

Held against three-vrm, pixel for pixel

A unit sphere under an orthographic camera framed exactly to it, so pixel (u, v) carries normal (u, v, sqrt(1 - u^2 - v^2)). No plateau finding and no fitting: our model is evaluated at precisely the normal the render used. three 0.185.1, three-vrm 3.5.5, headless Chromium through swiftshader, colour management off and the output colour space linear.

After dividing ours by pi:

shadingToonyFactor px compared over 4/255 p99 max
0.9 3048 0 0.0020 0.0025
0.5 3048 0 0.0020 0.0021
0.0 3048 0 0.0020 0.0021
1.0 3048 2 0.0018 0.1351

One 8-bit readback step is 0.0039, so three of the four agree below the noise floor of the measurement. The two pixels at a hard ramp are the terminator rather than the model: the sphere’s tessellated normal disagrees with the analytic one by a hair and the step turns that into the whole base-to-shade gap, which over pi is 0.1337 against the 0.1351 measured.

The factor of pi, and which side is the outlier

Unscaled, theirs over ours is a near-constant 0.3167 to 0.3183 against 1/pi = 0.31831, flat across every toony value, which is a uniform scale rather than a difference in shape.

Two independent implementations apply it by two different mechanisms. V-Sekai’s Godot port writes vec3 lighting = lightColor / 3.14159; into mtoon_common.gdshaderinc:156 by hand, and three-vrm inherits RECIPROCAL_PI * diffuseColor from three.js. The VRM 1.0 pseudocode ends color = color * lightColor with no such term, so it is the spec text that omits what every renderer does rather than a convention either engine invented.

A tone ladder feels this. CIELAB scales non-linearly, so one material at two exposures gives two dE readings, and the targets have yet to be checked at the exposure a viewer uses.

VRM0 and VRM1.0 are different parameterisations

The Godot port implements MToon 3.3, whose ramp is

clamp((I - shadeShift) / (mix(1, shadeShift, shadeToony) - shadeShift), 0, 1)

and VRM 1.0 uses linearstep(-1 + shadingToonyFactor, 1 - shadingToonyFactor, ...). mtoon.py targeted the VRM0 form for one commit and now implements VRM1.0. A difference worth naming because it silently inverts a term: VRM0 lerps the rim toward BLACK as rimLightingMix falls and VRM1.0 lerps toward WHITE, so rimLightingMixFactor 0 leaves the rim at full strength.

What a deferred G-buffer cost

approach ns/px 4K frame shadows
Python forward, scalar_rgb 102,000 848 s yes
deferred G-buffer plus a Slang kernel 62.2 0.516 s no
wide forward, Dr.Jit, llvm_ad_rgb 40.6 0.337 s yes

The deferred path silently dropped the shadow ray, which is the whole reason the test shape is an abacus rather than a sphere: a convex shape cannot occlude itself. So it was slower AND lost the thing the shape was chosen for.

What the Slang kernel bought

mtoon.slang compiles to C++ and agrees with mtoon.py to 7e-08 against a float32 epsilon of 1.19e-07, which chains the validation to three-vrm through two differentials.

The forward integrator shades faster, so the kernel packs frames instead. The win there came from the wrapper: Slang’s CPU target emits scalar code and one call runs every thread group serially:

original pow, numpy float64   0.520 s per 4K frame
numpy lookup table            0.231
Slang, one thread             0.221
Slang, group range threaded   0.062

Against a lookup table the kernel alone bought 4 per cent. Splitting the group range across cores bought 3.8x, which belongs to the wrapper rather than to Slang.

Left unmeasured

Whether the tone targets hold at the exposure a viewer uses, given the factor of pi. giEqualizationFactor, which the model does not carry and the comparison set to zero. Shadowing against three-vrm, where shadow maps and ray-traced occlusion legitimately differ, so a per-pixel comparison measures the difference between two renderers.