Subpixel Culling - Aukio 3D
Table of Contents
1. Shapes too small to see
A distant city contains thousands of triangles whose entire screen footprint is a fraction of a pixel. They cannot influence the image — but the pipeline still transforms their vertices, clips them against the near plane, computes their bounds and queues them for sorting.
Subpixel culling drops them at the earliest possible moment. It is off by default; enable it with a threshold in pixels:
java -Daukio.cull.subpixel=0.4 -jar my-app.jar
The verdict is evaluated in AbstractCoordinateShape.transform(),
immediately after the shape's screen-space bounds are known and
before the shape is queued: if the raw span (maxX - minX and
maxY - minY, before any paint margins) is below the threshold on
both axes, the shape is dropped for this pass. The bounds were
already computed for the other culling stages, so the test itself is
nearly free.
Subpixel culling is one rung of the engine's culling ladder — each rung discards work earlier and more cheaply than the previous one could have:
Figure 1: The culling ladder. Subpixel culling is the third rung: frustum culling skips whole invisible subtrees, screen-bounds culling drops individual off-frame shapes, subpixel culling drops the ones too small to matter, and Hi-Z removes occluded mesh blocks.
2. The verdict cache: skip setup, not just drawing
Re-testing every tiny shape every frame would still walk the scene
graph and run per-shape setup. So once a shape is culled, the
verdict itself is cached on the shape (a flag plus the epoch it was
culled in). While the epoch still matches, transform() returns at
the very top — no vertex transforms, no clipping, no bounds loop at
all. Tiny geometry costs literally nothing on stationary frames.
The epoch is owned by ShapeCollection and advances exactly when a
culled shape could have become visible again:
- the camera translated more than
-Daukio.cull.subpixel.translate(default 25 world units), or - the camera rotated (quaternion component delta over
-Daukio.cull.subpixel.rotate, default 0.01 ≈ 1.1°), or -Daukio.cull.subpixel.framesframes elapsed (default 30) — an unconditional bump that catches shape-side transform changes.
Figure 2: The epoch timeline. Cached verdicts are trusted while the camera stays put; any significant camera change re-evaluates every culled shape once, then the cache settles again.
One safety note for engine work: the cached skip leaves the shape's per-slot vertex state stale. That is safe only because unqueued shapes are never painted — any future code path that reads per-slot state for a shape that was not queued will see stale data.
3. Tuning
| Property | Default | Meaning |
|---|---|---|
aukio.cull.subpixel |
0 (off) | screen-span threshold in pixels; shapes below it on both axes are culled |
aukio.cull.subpixel.translate |
25 | camera translation (world units) that invalidates the cache |
aukio.cull.subpixel.rotate |
0.01 | camera rotation (quaternion delta ≈ 1.1°) that invalidates the cache |
aukio.cull.subpixel.frames |
30 | unconditional invalidation period in frames |
Measured on the Fallout 4 environment's downtown scene (a triangle-heavy skyline at altitude): at 0.4 px the paint queue shrank ~13% (525k to 456k shapes) and frame time dropped from ~135 to ~125 ms, with golden-image diffs under 0.006% of pixels — visually indistinguishable. The bigger win is the verdict cache on stationary frames, where culled shapes cost no setup at all.
Start at 0.4–0.5 px and raise only if artifacts are absent: too large a threshold eats small lights, antennae and text at mid distances.