Skip to content

Own the GPU contract: uniform block, shaders, and the batcher - #37

Merged
beetlebugorg merged 5 commits into
mainfrom
feat/gpu-uniforms
Jul 29, 2026
Merged

Own the GPU contract: uniform block, shaders, and the batcher#37
beetlebugorg merged 5 commits into
mainfrom
feat/gpu-uniforms

Conversation

@beetlebugorg

Copy link
Copy Markdown
Owner

The engine already owned the vertex half of the GPU contract — Vertex, Quad, Range, and an ABI self-check so a header/library skew refuses loudly instead of shearing. Everything else a GPU host needs was left to the host, and lookout wrote it three times for three backends. The three had drifted.

Moves three pieces in:

  • The uniform block (render/gpu.zig, tile57_gpu_uniforms). The other half of the vertex contract: the shaders read exactly this, and a host that lays it out differently gets silently wrong shading, not an error. A layout test pins it — std140 and C agree on the field order only by luck of it.
  • The reference shaders (shaders/). Two languages, one portrayal. They were hand-synced copies under comments asserting they matched; by the time they moved, they didn't — the Metal copy documented color as a per-range flat colour while both languages only ever read it as the SDF halo.
  • The batcher (render/batch.zig, tile57_gpu_batch). Which pipeline draws a range, which atlas it samples, what the uniform says, and what folds into the previous draw — a reading of kind/prim/atlas/pattern, all defined here. Pure, so it is testable, which the three copies never were: the merge is now pinned by the cases that are easy to get wrong, like a gated-off range having to break contiguity instead of letting its neighbours merge through the hole.

The batcher takes only ranges — no vertex, quad or pattern buffer. The host derives a pattern's cell_px from its own texture, and comparing the pattern index is equivalent to comparing the dimensions it implies, so the merge stays correct without them.

The engine still fills in no uniforms and compiles no shaders. It owns what things mean; the host owns the device.

Also adds a GPU Rendering docs page: the four pieces, a worked frame loop, and the parts worth understanding rather than copying. Retitles rendering.md to "Rendering Engine".

The engine already owns the vertex contract — Vertex, Quad, Range, and an ABI
self-check so a header/library skew refuses loudly instead of shearing. The
other half of that contract, the uniform block the shaders read, was left to
the host, and three lookout backends each grew their own copy of it. They
drifted: two documented `color` as the SDF halo background, the third as a
per-range flat colour. The shaders only ever read it as the halo.

So declare it here, next to the vertices it pairs with, with one set of field
docs. The engine still never fills it in — every field is per-frame host state,
and baking it into a scene would force a rebuild on every zoom — but the LAYOUT
is not the host's to choose.

sizeof rides in the top byte of tile57_abi_gpu_layout(), and the layout test
pins the offsets: std140 and C agree on this field order (color at 96, 128
bytes total) only by luck of it, so a reorder has to fail loudly.
They read the vertex, quad and uniform layouts render/gpu.zig defines, so this
is where they belong — same reason include/tile57.h lives here. The engine
still neither compiles nor runs them.

They were two hand-synced copies in a host repo, one per shading language,
under comments asserting they matched each other. By the time they moved they
did not: the Metal copy documented `color` as a per-range flat colour, while
both languages had only ever read it as the SDF halo background.

Hosts consume them through dep.path("shaders/..."), so the directory joins the
package paths. One .spv set serves both Vulkan-flavoured backends — the
raw-Vulkan pipeline layout mirrors SDL_GPU's set numbering.
Ranges come out in paint order; a host still has to decide per range which
pipeline draws it, which atlas it samples, what the uniform block says, and
whether it folds into the previous draw. That decision reads Range.kind, prim,
atlas and pattern — all defined here — but it was written out three times in
the lookout backends, and they had diverged: one never merged at all, issuing a
draw per range where the others issued one per run.

batch() is that decision, as a pure function from (ranges, per-frame host
scalars) to draw items. The host keeps what the engine cannot know: its
pipeline objects, its textures, its encoder, and any pass structure of its own
— the Metal backend's front-to-back depth prepass is an early-z optimization,
not a portrayal rule, so exclude_opaque_tris keeps it out rather than this
imposing it on everyone.

Being pure is what makes it testable, which the three copies never were: the
merge is now pinned by cases that are easy to get wrong by hand — a gated-off
range must break contiguity rather than let its neighbours merge through the
hole, and a sounding's forced OTHER bit must split the run.

Registered in render.zig's test block. Without that a re-exported file is never
analyzed, so its tests silently do not run — this one did not even compile.
tile57_gpu_batch() over render/batch.zig, plus its two C mirrors.

Takes only the ranges: the batcher needs no vertex, quad or pattern buffer, so
a host that keeps just its uploaded ranges (all three lookout backends do) need
not hold a whole scene alive to ask. The pattern cell's size comes out of the
draw's pattern index instead — the host looks its own texture up by it, and
comparing that index is equivalent to comparing the dimensions it implies, so
the merge stays correct without them. That also puts the "cell never
rasterized" case back where the knowledge is: only the host knows what
uploaded.

Layouts pinned by a test, since the C structs are hand-written and nothing else
would notice them drifting until a host read garbage.
The C API page documented the scene structs, but nothing said what a GPU host
actually has to do end to end — and the contract has since grown to four
pieces: the draw-ready buffers, the uniform block, the reference shaders and
the batcher. This is that page: what the engine owns and why, each piece with
its struct, a worked frame loop, and the handful of things worth understanding
rather than copying (the screen-space vertex channel, the visibility gates,
world-anchored pattern phase, host-applied rotation, the ABI gate).

Also retitles rendering.md to "Rendering Engine".
@beetlebugorg
beetlebugorg merged commit 2539569 into main Jul 29, 2026
5 checks passed
@beetlebugorg
beetlebugorg deleted the feat/gpu-uniforms branch July 29, 2026 03:38
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