Skip to content

Release 1.6.0: fewer draws, less overdraw, shadows that scroll - #5

Merged
nolanbaxter merged 16 commits into
mainfrom
develop
Oct 7, 2026
Merged

nolanbaxter merged 16 commits into
mainfrom
develop

Conversation

@nolanbaxter

Copy link
Copy Markdown
Owner

Fewer draws, less overdraw, shadows that scroll. Every mesh's geometry now lives in one
arena, and every mesh drawn once is pooled with the others of its material into one draw; the
sun's shadow maps scroll with the camera instead of being redrawn; shadow passes read positions
alone; and the merged meshes draw their depth before they shade. Each change A/B'd before it was
kept, with the same pixels. On Iris Xe at 720p: Sponza 4.64 -> 4.15 ms a frame, 256 point lights
9.17 -> 6.64 ms, a town of 900 separate buildings 14.8 -> 4.0 ms of GPU.

Changed

  • Merged meshes draw their depth first. With no depth prepass, a pixel's cost depended on draw
    order: a surface drawn before the wall in front of it was shaded and then covered. The opaque
    merged groups now draw depth alone first, and then shade only where they match it, so every
    pixel they cover shades once, whatever the order; other opaque meshes test against that depth
    too. Sponza walking the nave: main pass -19%, GPU -16%; with 256 point lights, the main pass
    -29% to -34%. A town of 900 buildings, one group: no measurable cost. The same pixels, but for
    one in 900,000 where two surfaces meet at exactly equal depth. GPUs that hide overdraw in
    hardware (Apple's) gain nothing from it and pay its depth draw; unmeasured.
  • Shadow passes read positions alone. The geometry arena keeps every vertex's position a
    second time, by itself, and a depth-only shadow draw reads those 12 bytes instead of the whole
    60-byte vertex. Sponza under a turning sun, every cascade redrawn each frame: shadow passes -10%.
    The same pixels; vertex memory +20%.
  • Big meshes are drawn a chunk at a time. Each mesh's triangles are boxed in runs of 128 at
    load. A strip of a scrolled shadow cascade draws only a mesh's chunks that reach it -- a strip
    is thin, and its cost was every vertex of every mesh it touched -- and a merged mesh's chunks
    out of view are dropped from the camera's draw as its whole was. Sponza walking the nave, against
    the last change: GPU -8%, the nearest cascade's strips 0.49 -> 0.18 ms; the same pixels.
  • Their shadows too, when a shadow map is drawn whole. Each material's merged meshes also
    keep a list of every one's triangles, copied once, and a shadow view drawn whole -- a cascade
    under a turning sun, a point or spot light's -- is a draw per material rather than per mesh.
    A strip of a scrolled cascade stays mesh by mesh, where culling each mesh to the strip skips
    more. A town of 900 buildings under a turning sun: frame 17.9 -> 10.1 ms, CPU 2.4 -> 0.9 ms.
  • Meshes drawn once each share one draw call per material. A scene of distinct meshes -- a
    level, Sponza, a town of separate buildings -- was a draw call per mesh, and each costs the GPU
    about 7 us whether it draws anything or not. Now every mesh part drawn once is pooled with the
    others of its material into one indexed draw, its vertices read straight from the geometry arena;
    a part out of view keeps its place as triangles of one repeated vertex, which the GPU discards,
    and only a part that comes into view or leaves it is rewritten. A town of 900 buildings: 900
    draws -> 1, GPU 14.8 -> 4.0 ms, frame 23.3 -> 9.6 ms; Sponza: 103 draws -> 25, GPU -14%. The same
    pixels. These parts are culled to the view, not by occlusion; meshes drawn many times are drawn
    instanced, as before. stats.draws counts the calls.
  • Every mesh's geometry lives in one arena. One vertex buffer and one index buffer for every
    primitive loaded, where each had its own; a pass binds them once instead of per batch, and a
    freed range is reused by the next load. Groundwork for drawing many meshes in one call.
  • Identical materials are one material. A material identical to one already loaded -- every
    factor but its name, and the same textures -- shares its id, so the same asset loaded many
    times is one material, not one each. A material an animation drives is never shared.
  • Off means off: bloom at strength 0 draws none of its passes. A strength of 0 mixed no bloom
    in, but its nine passes were drawn all the same: 0.36 ms a frame at 720p on Iris Xe.
  • A scene with no point or spot lights skips their loop. The shader that loops over them is
    compiled out, in pipelines built in the background the first time such a scene draws; until then
    it draws with the loop, as before. Sponza with only the sun: forward pass 5% faster.
  • A moving camera scrolls the sun's shadow maps instead of redrawing them. When a cascade's box
    steps along its grid and nothing else changed, its map keeps every texel where it is -- the map
    wraps, and only its origin moves -- and only the strip the box moved onto is drawn. The same
    shadows: a scrolled map matches one drawn whole to within a level on a few dozen channels.
    Sponza at 720p on Iris Xe, walking the nave: shadow passes 3.9 -> 0.55 ms a frame, the whole
    frame 11.9 -> 7.6 ms. The new stats.cascadesScrolled counts them.
  • Every caster in the scene casts into every cascade. A cascade's depth now spans the scene
    along the light, rather than reaching four cascade radii toward it -- so a tall building far up
    the light no longer loses its shadow -- and stays put while the camera moves, which is what
    lets a cascade scroll. casterExtent is deprecated and no
    longer used.
  • Each sun cascade draws only the casters that reach it. Every caster went into every cascade,
    to be transformed and clipped away by the GPU; now a batch whose box lies outside a cascade is
    not drawn into it. The same shadows. A town of 900 separate buildings: shadow passes 0.40 ->
    0.22 ms a frame. Sponza, whose cost is a few large meshes every cascade reaches, is unchanged.

npm install winding-engine

🤖 Generated with Claude Code

nolanbaxter and others added 16 commits October 7, 2026 10:51
Every caster was drawn into every cascade and clipped on the GPU. Batches get a world box (rebuilt with the batches, grown by what moved) tested against each cascade's ortho box, the same clip test touchesOrtho made, now boxInOrtho. Same shadows. Town of 900 buildings: shadow 0.40 -> 0.22 ms; Sponza unchanged (its few big meshes reach every cascade).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ing them

A cascade's map wraps: when its box steps along the grid and nothing else changed, the texels stay where they are, the origin moves, and only the strip the box moved onto is cleared and drawn (scissored pieces, each with its own matrix and caster cull). The depth range now spans the scene along the light, so it stays put while the camera moves; casterExtent is deprecated. The graph gains a keep attachment flag (load an imported texture nothing wrote this frame). Sponza 720p, Iris Xe, walking: shadows 3.9 -> 0.55 ms, frame 11.9 -> 7.6 ms; scrolled maps match redrawn ones (GPU suite step).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hout point lights

Bloom at strength 0 skips its chain and the tonemap reads the scene (0.36 ms at 720p). FEATURE_NO_POINTS compiles the point and spot loop out for scenes with none (Sponza sun-only: forward -5%); the fallback while it builds drops that bit first, so a set with the loop and the scene's decals or probes wins over one without both. renderer._pipelinesBuilt() waits for every set a frame asked for; the GPU tests use it instead of waiting on a set by number. Skipping occlusion culling for small scenes was measured too: no gain on Sponza (the early pass draws what the late one did), so not added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every primitive's vertices and indices go into one GeometryArena (vertex + index buffers, also STORAGE for later vertex pulling), placed by baseVertex/firstIndex; forward, transparent and shadow passes bind it once instead of per batch. RangeAllocator (from the morph store's hole logic) is shared by both arenas. MaterialRegistry.share gives identical materials (all factors but the name, the same textures) one id, held and released by count; animated materials register alone. Sponza A/B vs HEAD: GPU unchanged, wall -1.4%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e arena's

Since the geometry arena, vertex_index counts from the arena's start, so a morphed primitive placed past it read another vertex's deltas. Draw data now carries morphBase less baseVertex * count * stride (u32, wrapping as the GPU's arithmetic does). The GPU test called applyMorph with primitive-relative indices and could not see it; it now passes the arena index, as the vertex shader does, and fails without the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One-object, unskinned opaque batches are pooled by material and winding into groups; each group is one drawIndexedIndirect over a list of its objects' indices, tagged (slot << shift) | vertex, and vsMerged pulls the vertex from the geometry arena via a table in the visible buffer. Each object has a fixed place in the list; a pass tests every merged object against this frame's view and rewrites only those that came into view (real indices) or left it (one repeated vertex, discarded before raster). Town of 900 buildings: 900 draws -> 1, GPU 14.8 -> 4.0 ms; Sponza A/B: 103 draws -> 25, GPU -14%, forward -17%; pixels identical (tools/merged-check.html). Merged objects are frustum-culled only. stats.draws counts real calls. New GPU step: objects come and go with the view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…al (stage 3)

Each merged group gets a second, static index list with every object's real indices, filled once at rebuild by a copyAll dispatch. A cascade drawn whole, or a point/spot view, draws one call per group (vsMerged/vsMergedAlpha pull positions, and uv/colour for masked materials, from the arena; table and arena vertices added to the shadow bind group), culled per group by a box grown from its batches' boxes. Scroll strips stay per mesh: their per-batch culling is worth more than the draws (Sponza walk unchanged). LOD items are no longer merged (shadows must cast the level shown). Masked merged batches were drawn twice in shadows; now once. Town of 900 under a turning sun: shadow 1.99 -> 1.02 ms GPU, CPU 2.38 -> 0.86 ms, wall 17.9 -> 10.1 ms; pixels identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
chunkBoxes boxes each primitive's triangles in runs of CHUNK_TRIANGLES (file order) at load. Shadow strips (scrolled cascades) draw a single-instance mesh's chunk runs whose world box reaches the strip, CPU-side: strips were all vertex work (measured: vertex-only == normal). Merged draws now keep a record per chunk: the merge pass tests each chunk's box through its object's model matrix (draw data) against the view and rewrites only chunks that flip. Morphed meshes are no longer merged (their vertices leave the rest-pose boxes). Sponza walk A/B vs stage 3: GPU -8.3%, forward -2%, c0 strips 0.49 -> 0.18 ms, CPU unchanged; pixels identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…atched settings

The same three scenes (Sponza with a sun, 10,000 moving cubes, Sponza with 256 point lights) through each engine's WebGPU renderer in one page, alternating blocks of 30 frames, frame time to GPU completion. Matched: 4 cascades of 1024 to 40 m split 0.5, ACES tone mapping, no MSAA/FXAA/bloom/AO/TAA/auto exposure. Not matched, and stated: Winding's environment-map ambient and occlusion culling. ?gpu=1 adds per-pass GPU timestamps as a check; ?shot= draws one engine. Engines pinned on jsDelivr: three 0.186.1, babylonjs 9.29.0, playcanvas 2.23.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A chart of frame time by scene (Sponza with shadows, 10,000 moving cubes, 256 point lights), light and dark SVGs picked by the reader's theme, from two matched runs of bench/compare.html. The footnote gives the hardware, the versions, what was matched and what was not, and how to rerun it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A skinned primitive's joints and weights were in a buffer of its own, counted from 0, while its draws used its arena base vertex -- which moves every vertex buffer -- so any skinned mesh not first in the arena read another mesh's influences and came apart once posed (a walking Fox in pieces; unreleased, since the arena). They now live in the arena too, a third buffer at the same numbering, made with the first skinned primitive. New GPU step: a posed rigged quad at vertex 0 and past it give the same pixels (fails without the fix).

The arena also keeps positions alone (12 bytes a vertex); depth-only shadow pipelines and the merged shadow path read them instead of the 60-byte vertex. Sponza with a turning sun: shadow passes 5.10 -> 4.61 ms; pixels identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e the results

Opened from disk (or without ?scene), it shows the machine as the browser reports it -- the GPU by name from WebGL's renderer string, WebGPU's default and high-performance adapters (with a warning when the default is the slower of two), CPU threads, memory, Windows and browser versions, the screen -- and a Start button. The run goes scene by scene, a page load each, keeping results in localStorage, then shows the means of two runs beside the README's laptop numbers, a chart, and a button that copies everything. From a file, Winding comes from GitHub through jsDelivr at a pinned commit (4a251a8); served, it is /src as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The opaque merged groups draw depth alone (vsMerged, an empty fragment
shader, targets masked off) before anything shades, then shade with
greater-equal and no depth writes, so each pixel they cover shades once
whatever the draw order. The prepass binds the merged index list, so it
runs before the arena is bound. @invariant on the clip position keeps the
two draws' depths identical.

Sponza: main pass -19%, GPU -16%. 256 lights: main pass -29 to -34%.
Town of 900 buildings: no measurable cost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A panel per machine, each on its own axis: an RTX 5060 Ti with a Ryzen 5
5500 (Edge), and the Intel Iris Xe laptop (Chrome), both at commit
52d010e, the depth prepass. Values under 1 ms get two decimals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nolanbaxter
nolanbaxter merged commit 328a168 into main Oct 7, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant