2026 10 22 voxel half unit design - xopherdeep/do-it-for-the-xp GitHub Wiki
Date: 2026-10-22. Status: design approved in chat by the owner; this spec awaits review.
Builds on: 2026-10-13-hero-builder-design.md (the Hero Builder and its thumbnails) and the voxel shading work of
PRs #28 and #29 (baked ambient occlusion, bevels, per-cell tint).
The owner asked whether the voxels can be made finer without hurting performance. They can, under one condition: the GPU is nowhere near its limit, the CPU mesher is. The hero is nine draw calls and about a thousand triangles; a phone GPU would not notice ten times that. What a phone would notice is the build: the Hero Builder's thumbnails rebuild a hero three times per frame, and the mesher's hidden-face culling is quadratic in the number of boxes.
Every box of a hero was split into equal same-colour sub-boxes to bound what a finer grid costs today.
| Hero | Grid | Boxes | Quads | Triangles | Build |
|---|---|---|---|---|---|
| default look | 1x | 46 | 489 | 978 | 9.5 ms |
| default look | 2x | 368 | 1,279 | 2,558 | 31 ms |
| default look | 4x | 2,944 | 4,111 | 8,222 | 151 ms |
| armour + two held items | 1x | 69 | 714 | 1,428 | 18 ms |
| armour + two held items | 4x | 4,416 | 6,291 | 12,582 | 597 ms |
A phone runs this three to five times slower. The quadratic culling is what turns 2x into 3x the time and 4x into 16x to 33x.
There is no voxel-size knob. The hero is 46 hand-placed boxes on a 16-unit body, at fractional coordinates: the face already uses quarter-unit and eighth-unit boxes. Finer voxels means re-authoring parts with smaller boxes. On a 390px phone at pixel ratio 2 one unit is about 25 device pixels on the stage, so half-unit boxes read clearly; quarter-unit boxes vanish in the 96px builder thumbnails. Half-unit is the target.
- Subtle art. Today's silhouettes stay. The head and hats get cut corners so they read rounder; eyes, mouths, brows and ears move to half-unit steps; hair gets tufts; held gear gets trims. Unmistakably the same hero.
- Head and held gear only in this pass. Outfits, buildings, rooms and temples keep their boxes.
- The pixel-ratio cap stays at 2 for this pass.
- Mesher first, art second, one gate after each wave, and the owner judges the look on a phone.
A linear-time face culler in the shared mesher, a bevel that cannot swallow a small box, a build-cost readout in the dev VoxelLab, a perf spec that pins quad counts and build time, and then the head, hair, hats and held gear re-authored at half-unit inside their present bounds.
src/components/organisms/HeroModel/voxel/mesh.ts is the mesher for every voxel model (heroes, buildings, dungeon
rooms, stages, temples). Today visibleFaces(box, others) tests each of a box's six faces against every other box of
the part. It will instead query a bucket grid, the same unit-cell bucketing bucketed() already builds for the
occlusion probes, for the boxes whose volume touches the face's plane within the face's rectangle.
- The culling grid holds only the part's own boxes. Occluders from a neighbouring part (the torso under the head) shade a contact and stop a bevel but must never hide a face; that rule stands.
-
buildMeshData(boxes, occluders)keeps its signature and produces the same quads in the same order as today for the same input. The face-hiding rule (touching on the plane, or swallowing the plane, and covering the rectangle) does not change; only how candidates are found. -
visibleFacesstays exported and keeps its behaviour for the existing spec, now backed by the grid.
In src/components/organisms/voxel/voxelMaterial.ts the bevel width is max(EDGE_WIDTH, 1.25 px). A half-unit box
with a 0.09 bevel on each side is a fifth rim; a quarter-unit trim would be nearly all rim. The width is capped at a
fraction of the quad's shorter side (the quad's size already arrives in voxelUv.zw): min(width, 0.2 * min(size)).
A one-unit face is unchanged (0.09 is well under 0.2).
The occlusion constants do not change: the contact band stays one unit (AO_CELL), the probes still lift 0.3
(AO_LIFT, AO_REACH). Flat painted features keep their 0.25 standoff and stay invisible to the probes; a feature
deeper than 0.3 (an ear, a hat trim) casts a contact shade, which is wanted.
- A pure helper beside
buildHeroinvoxel/build.ts,measureHero(look), assembles the look, meshes every part without the cache, and returns{ boxes, quads, ms }. It touches no three.js objects, so it runs in jest. - The dev VoxelLab (
src/app/Dev/VoxelLab.vue) printsboxes · quads · msunder each hero of both benches. The owner opens/dev/voxel-labon a phone from the shared dev server and reads real numbers. - No change to the hero's mesh cache: stringifying a few hundred boxes costs under a millisecond.
Files: voxel/head.ts, voxel/hair.ts, voxel/hats.ts, voxel/gear.ts, voxel/held.ts.
Hard rules, each already asserted by __tests__/voxel.spec.ts or added to it:
-
Bounds do not move. The head block stays x ±3.5, y 8..15, z ±3.5 (
HEAD); hats reach no higher than the wizard hat does today; held items keep their extents soraiseangles and the camera frames hold. - The face stays on +Z, painted features at most 0.25 off the face plane.
-
Hair never pokes through a hat that covers the crown (
HAT_COVERS_TOP), and the hood still gives way to a hat. -
Half-unit is the floor. In
head.ts,hair.tsandhats.ts, which are re-authored whole, every box obeys it: the two larger sides at least 0.5, the smallest at least 0.5 or flat paint of 0.25 and under (a test pins this; the 0.4 ears and the 0.3-tall slabs of today move onto the grid). Ingear.tsandheld.tsonly new boxes must obey it; existing thin boxes may stay.
What changes:
- Head: the eight vertical edges of the head block are chamfered by half a unit (the block becomes a cross of two boxes, or the block plus cut-corner strips, whichever meshes to fewer quads); ears get a lobe; eyes, brows and mouths of every face style move to half-unit steps.
- Hair: fringes and tufts step at half-unit; the long and curly cuts get a few strands.
- Hats: cut corners, a band or a trim per style.
- Gear and held items: guards, straps, buckles and gems at half-unit, inside each item's present box.
Budgets, pinned by the perf spec and deterministic (no wall clock, so CI cannot flake): the default hero stays under 1,200 quads and every VoxelLab look under 2,000 quads. Today, with the occluder contacts that cut faces into shade strips, the default hero is 602 and the heaviest bench look 1,260 (the 489 and 714 first quoted were measured without occluders), so these leave the art about 2x. Build time is read in the VoxelLab readout on the owner's phone, not asserted.
A new src/components/organisms/HeroModel/__tests__/voxelPerf.spec.ts:
- Same output. For the four VoxelLab looks, the quad count and a checksum of positions equal the values recorded before the culler changed (the first task records them; the culler task must not touch them). After the art lands, the recorded values are updated in the same commit as the art, so the test documents the change.
- Linear build. The one timed test compares the same machine against itself, so CI speed cannot flake it: a default hero subdivided 8x must build in under twenty times what the 4x hero takes (minimum of three timings per size, subdivision outside the timer). Measured 2026-10-22 with the quadratic culler: 4x 387 ms, 8x 9,041 ms, a ratio of 23 (culling alone 142 ms and 8,289 ms); the shading part grows about 3x per doubling, so the linear culler lands the ratio between 8 and 9 once allocation is kept out of the timer (the unit grid holds many boxes per cell at 8x), against 73 for the whole-part scan in jest; twenty leaves better than 2x on both sides.
- Budgets from section 4.
The existing voxel.spec.ts keeps passing unchanged except where a bound it asserts is tightened.
- Two PRs, each from its own branch, commits from a private index with only the task's own paths, files locked with
chemx team lock. -
PR 1,
feat/voxel-mesher, wave one, one Sonnet builder: sections 1, 2, 3 and 5 (culler, bevel cap, helper, readout, perf spec with the recorded baselines). Opened with auto-merge (gh pr merge <n> --auto --squash); its Tests check is the gate. -
PR 2,
feat/voxel-half-unit, wave two, branched from PR 1's merge, two Sonnet builders one after the other (they share the baseline file): head + hair + hats, then gear + held items. Opened without auto-merge; the owner judges the look in VoxelLab on a phone, and auto-merge is switched on after that nod. - One
chemx verify --buildafter each wave, never during.
- Cross-box quad merging (coplanar, same colour, open occlusion, same edge flags) if the quad budget is missed.
- Pixel-ratio cap to 3 on 3x phones, after a fill-rate check on a real device.
- Outfits at half-unit, then buildings and rooms, once the head has set the style.