Skip to content

Fix geometry missing faces: backface cone cull math + LOD parent suppression#44

Open
kern wants to merge 8 commits intomainfrom
claude/fix-geometry-faces-N7Qwo
Open

Fix geometry missing faces: backface cone cull math + LOD parent suppression#44
kern wants to merge 8 commits intomainfrom
claude/fix-geometry-faces-N7Qwo

Conversation

@kern
Copy link
Copy Markdown
Owner

@kern kern commented Mar 24, 2026

Summary

  • Fix incorrect backface cone cull formula that was aggressively culling front-facing clusters
  • Fix LOD parent check using wrong distance, incorrectly suppressing child clusters
  • Add 6 new demo scenes including ocean, forest, and mountains
  • Update shader tests to assert both fixes

Bugs fixed

Bug 1 — Backface cone cull (shaders.ts): The cull condition was d > coneCos, but the correct threshold is sqrt(1 - coneCos²) (= sin of the half-angle). For any cluster with coneCos < 0.707 (half-angle > 45°, common on all curved surfaces), the old formula culled way too aggressively — visible as large patches of missing faces across the entire mesh.

Bug 2 — LOD parent check (shaders.ts): Parent screen error was projected using the parent bounding sphere's own center distance. Since the parent sphere encloses multiple children it sits further away, making its projected error appear smaller and incorrectly suppressing visible child clusters. Fixed by reusing the child cluster's dist for both projections.

New demo scenes

  • Trefoil Knot (16K tris) — high-curvature geometry with wide normal variation
  • LOD Field — 61 torus instances in expanding rings; LOD transitions clearly visible on zoom
  • Landscape + Spheres — large terrain with 49 sphere instances at varying heights
  • Ocean (130K tris) — Gerstner wave surface with realistic rolling swells
  • Forest (800K+ tris) — 200 procedural conifer trees over terrain
  • Mountains (700K+ tris) — fractal-layered peaks with radial envelope

Test plan

  • All 122 tests pass (vitest run)
  • Shader tests assert correct formula: sqrt(1.0 - coneCos * coneCos) and no parentDist
  • Load any curved mesh (sphere, torus, knot) with backface culling enabled — no missing faces
  • Toggle LOD threshold slider — transitions smooth with no popping clusters

https://claude.ai/code/session_013JrLspqeGiVV78d9yy3LdU

claude added 4 commits March 24, 2026 07:24
Complete implementation of a GPU-driven virtualized geometry renderer
inspired by modern cluster-based LOD systems. Features:

- Offline mesh clusterization (~64 tri clusters with bounding spheres,
  normal cones, and geometric error metrics)
- Hierarchical cluster DAG for continuous LOD selection
- Software page residency manager for geometry streaming
- GPU compute passes: instance culling, cluster LOD selection
- Vertex pulling render pipeline with storage buffer reads
- WGSL shaders for all passes including HZB generation
- Interactive viewer at /pebble with orbit camera, LOD controls,
  wireframe overlay, debug cluster coloring, and real-time stats
- 6 test scenes: sphere, HD sphere, terrain, torus, multi-object, massive
- All magic constants extracted to constants.ts
- 116 tests across 9 test files (vitest)

https://claude.ai/code/session_01958Gbh26NWntseNBigbw1k
The flashing was caused by a mismatch between the compute pass and the
render pass. The compute pass wrote visible cluster IDs into a compacted
list (visibleClusters[0]=5, [1]=12, ...) but the draw loop passed raw
cluster indices as instanceIndex, so the vertex shader read
visibleClusters[ci] which was stale/garbage for most indices.

Fix: replace the compacted visible cluster list with a per-cluster
visibility flag buffer. The compute pass writes 0 or 1 at
clusterVisibility[clusterIdx]. The vertex shader reads the flag and
emits degenerate triangles (behind far plane) for hidden clusters.
The draw loop passes ci directly as instanceIndex = cluster ID.

Also fixes the backface cone culling logic which was a no-op.

https://claude.ai/code/session_01958Gbh26NWntseNBigbw1k
… demo scenes

Bug 1 — Backface cone cull (shaders.ts): the cull condition `d > coneCos`
was wrong. For a cone with half-angle α, a cluster is fully backfacing when
dot(viewDir, coneDir) > sin(α) = sqrt(1 - coneCos²). Using coneCos as the
threshold (instead of sin) aggressively culled front-facing clusters whenever
coneCos < 0.707, producing large missing-face holes on every curved surface.

Bug 2 — LOD parent check (shaders.ts): parent screen error was projected using
the parent bounding-sphere center distance. Because the parent sphere encloses
multiple children it sits farther away, making its projected error appear
smaller and incorrectly suppressing the child cluster. Fixed by reusing the
child cluster's already-computed `dist` for both self and parent projections.

Tests: shaders.test.ts now asserts the correct formula and absence of parentDist.

New demo scenes (mesh-generator.ts + PebbleViewer/ControlsPanel):
- Trefoil Knot (16K tris) — high-curvature surface, wide normal variation
- LOD Field — 61 torus instances in expanding rings; LOD transitions visible on zoom
- Landscape + Spheres — large terrain with 49 scattered sphere instances
- Ocean — 130K-tri Gerstner wave surface
- Forest — 200 procedural conifer tree instances over terrain (800K+ tris)
- Mountains — fractal-layered peaks with radial envelope (700K+ tris)

https://claude.ai/code/session_013JrLspqeGiVV78d9yy3LdU
@vercel
Copy link
Copy Markdown

vercel bot commented Mar 24, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
kern.io Ready Ready Preview, Comment Mar 28, 2026 6:13am

…angles

Sphere, torus, and trefoil-knot generators used winding order (a, b, a+1)
which produces inward face normals for parametric surfaces. Reversed to
(a, a+1, b) so that cross(e1, e2) points outward, matching the stored
vertex normals. This was causing clusters to compute normal cones pointing
inward, so the GPU cull test rejected front-facing clusters and accepted
back-facing ones.

Also fix computeNormalCone and clusterTriangles in cluster-builder to skip
degenerate triangles (zero cross-product area, e.g. UV-sphere pole fans).
Previously these corrupted the normal cone computation and formed isolated
clusters with coneCos=-1 (never culled).

Fix the hierarchy-builder test to find children via parentIndex rather than
assuming sequential storage at childOffset, which was a coincidental property
of the old (incorrect) cluster ordering.

Add 18 new tests in backface-culling.test.ts covering winding-order checks,
normal cone direction, and simulated GPU cull from all 6 axis-aligned cameras.

https://claude.ai/code/session_013JrLspqeGiVV78d9yy3LdU
…king

The previous parent geometry generation collected child vertices into a flat
list and grouped consecutive samples into triangles, creating edges between
vertices from spatially distant clusters — the "wireframe across the scene"
bug.

Implements the Nanite approach:
- Greedy nearest-neighbour cluster grouping (approximates METIS graph
  partitioning to minimise shared boundary edge count between groups)
- Vertex welding: merge coincident positions across child cluster seams so
  the merged group has proper connectivity
- Boundary vertex locking: vertices on open boundary edges (edges shared by
  exactly one triangle — the group's border with its neighbours) are locked
  and never moved, preventing T-junction cracks between adjacent LOD clusters
- Edge collapse to ~50% triangles: iteratively collapse the cheapest
  (shortest) non-locked edge, moving the kept vertex to the midpoint for
  better approximation quality

Also fix a pre-existing multi-mesh offset bug: cluster.vertexOffset is a
global float offset, but buildHierarchy received a local (0-based) buffer.
For single meshes this was coincidentally correct; for the second mesh and
beyond, vertex reads went out of bounds. Added globalVertexFloatOffset and
globalIndexOffset parameters to correctly translate between global and local
buffer addresses.

https://claude.ai/code/session_013JrLspqeGiVV78d9yy3LdU
…eometry

The vertex shader applied no per-instance transform — all instances of the
same mesh were rendering at their object-space positions regardless of their
placement transforms, causing them all to overlap.

Root cause: buildScene built clusters once per mesh and gave all instances
the same clusterOffset. The instance transforms were stored in the Instance
record but never applied by any shader.

Fix: build clusters once per instance instead of per mesh. Before calling
buildClusters, apply the instance's Mat4 transform to every position
(mat4TransformPoint) and normal (mat4TransformDir + re-normalise) in the raw
mesh. Each instance gets its own unique cluster set in world space, so the
vertex shader needs no per-instance transform — the geometry is already
correctly positioned.

This is consistent with the existing architecture: the GPU vertex shader
reads world-space positions directly from the vertex buffer without any
model matrix multiplication.

Update the scene-builder test that was asserting the old (broken) shared-
cluster assumption.

https://claude.ai/code/session_013JrLspqeGiVV78d9yy3LdU
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.

2 participants