Add sparse voxel grid and exterior fill for nav simplification#197
Add sparse voxel grid and exterior fill for nav simplification#197slimbuck wants to merge 11 commits intoplaycanvas:mainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR refactors the voxel navigation simplification pipeline to reduce memory usage and improve handling of enclosed (indoor) scenes by introducing sparse, block-aware voxel data structures and an “exterior fill” step.
Changes:
- Add
SparseVoxelGrid+MaskStoreto represent voxel solids sparsely at 4×4×4 block granularity. - Add
fillExterior()to flood-fill exterior space from grid boundaries (with optional dilation) and seal open scenes. - Update CLI + voxel writer options to support
--nav-exterior-radius,--mem, and error-based collision mesh simplification (--mesh-simplify-error).
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| src/lib/writers/write-voxel.ts | Wires in exterior fill, updates nav/carving steps, and switches collision mesh simplification to error-based mode. |
| src/lib/write.ts | Plumbs new writer options (navExteriorRadius, meshSimplifyError) from top-level write flow. |
| src/lib/voxel/sparse-voxel-grid.ts | New sparse voxel grid representation with per-block type + optional per-voxel masks. |
| src/lib/voxel/mask-store.ts | New typed-array, open-addressing store for per-block masks to reduce GC/memory overhead. |
| src/lib/voxel/nav-simplify.ts | Major refactor: sparse dilation, two-level BFS, exterior fill step, and updated nav carving logic. |
| src/lib/voxel/sparse-octree.ts | Optimizes octree build phase with typed arrays and block-scoped intermediates. |
| src/lib/types.ts | Updates public options type to include navExteriorRadius and meshSimplifyError. |
| src/lib/index.ts | Exports fillExterior from the library entrypoint. |
| src/cli/index.ts | Adds --mem, --nav-exterior-radius, and --mesh-simplify-error; switches nav simplify flag semantics. |
| generators/gen-room.mjs | Adds a room/tunnel generator to exercise enclosed-scene behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| navExteriorRadius, | ||
| navCapsule, | ||
| navSeed | ||
| navSeed, | ||
| collisionMesh: v['collision-mesh'], | ||
| meshSimplifyError: parseNumber(v['mesh-simplify-error']) || undefined | ||
| }; |
There was a problem hiding this comment.
meshSimplifyError parsing uses parseNumber(...) || undefined, which makes a user-supplied value of 0 impossible to pass through (it becomes undefined), and also treats the default empty string as 0 implicitly. Consider only parsing when the flag is non-empty, and validate/clamp the resulting value (finite and >= 0) so 0 is preserved and negatives are rejected.
| let vqIx = new Uint16Array(vqCap); | ||
| let vqIy = new Uint16Array(vqCap); | ||
| let vqIz = new Uint16Array(vqCap); |
There was a problem hiding this comment.
The voxel BFS queue stores coordinates in Uint16Arrays (vqIx/vqIy/vqIz). If nx/ny/nz exceed 65535 voxels, indices will wrap/truncate and the traversal will become incorrect. Since Morton encoding supports very large grids (and there’s no local guard here), consider using Uint32Array/Int32Array (or add an explicit dimension cap + error).
| let vqIx = new Uint16Array(vqCap); | |
| let vqIy = new Uint16Array(vqCap); | |
| let vqIz = new Uint16Array(vqCap); | |
| let vqIx = new Uint32Array(vqCap); | |
| let vqIy = new Uint32Array(vqCap); | |
| let vqIz = new Uint32Array(vqCap); |
| constructor(initialCapacity = 4096) { | ||
| const cap = Math.max(16, initialCapacity); | ||
| this._capacity = cap; | ||
| this._mask = cap - 1; | ||
| this._size = 0; | ||
| this.keys = new Int32Array(cap).fill(EMPTY); | ||
| this.lo = new Uint32Array(cap); | ||
| this.hi = new Uint32Array(cap); | ||
| } |
There was a problem hiding this comment.
MaskStore relies on _mask = cap - 1 and uses & _mask for indexing, which only works correctly when cap is a power of two. Since initialCapacity is public and not normalized, passing a non-power-of-two will break hashing/probing. Consider rounding cap up to the next power of two (or documenting/enforcing the constraint).
| * Example with fill exterior: | ||
| * splat-transform generators/gen-room.mjs -p size=4,spacing=0.04 \ | ||
| * output.voxel.json --nav-fill-dilation 0.5 --nav-fill-debug-stage 3 |
There was a problem hiding this comment.
The usage example references --nav-fill-dilation / --nav-fill-debug-stage, which don’t appear to exist in the updated CLI (it now documents --nav-exterior-radius). Update the example so it matches the current flags; otherwise this generator’s header comment will mislead users testing exterior fill.
| * Example with fill exterior: | |
| * splat-transform generators/gen-room.mjs -p size=4,spacing=0.04 \ | |
| * output.voxel.json --nav-fill-dilation 0.5 --nav-fill-debug-stage 3 | |
| * Example with exterior fill: | |
| * splat-transform generators/gen-room.mjs -p size=4,spacing=0.04 \ | |
| * output.voxel.json --nav-exterior-radius 0.5 |
| logger.progress.step('Simplifying collision mesh'); | ||
| await MeshoptSimplifier.ready; | ||
|
|
||
| const clampedSimplify = Number.isFinite(meshSimplify) ? Math.min(1, Math.max(0, meshSimplify)) : 0.25; | ||
| const targetIndexCount = Math.max( | ||
| 3, | ||
| Math.min( | ||
| rawMesh.indices.length, | ||
| Math.floor(rawMesh.indices.length * clampedSimplify / 3) * 3 | ||
| ) | ||
| ); | ||
| const simplifyError = (meshSimplifyError ?? 0.08) * voxelResolution; | ||
| const [simplifiedIndices] = MeshoptSimplifier.simplify( | ||
| rawMesh.indices, | ||
| rawMesh.positions, | ||
| 3, | ||
| targetIndexCount, | ||
| voxelResolution, | ||
| 0, | ||
| simplifyError, | ||
| ['ErrorAbsolute'] |
There was a problem hiding this comment.
meshSimplifyError is used directly to compute simplifyError without validation. If this option is provided via the library API as NaN/Infinity/negative, it will propagate into MeshoptSimplifier.simplify and can yield invalid output or runtime errors. Consider validating (finite and >= 0) and applying a sensible clamp/default here as well (in addition to CLI validation).
Summary
SparseVoxelGridandMaskStoredata structures for memory-efficient block-level voxel operations, replacing dense bitfield grids in the nav simplification pipelinefillExterior()function that floods exterior space from grid boundaries inward, enabling automatic enclosure detection for indoor scenes (rooms, buildings)simplifyForCapsuleto use sparse block-aware dilation, erosion, and two-level BFS (block-level for empty space, voxel-level for mixed blocks)sparse-octree.tsto use typed arrays (Uint32Array,Uint8Array,Int32Array) and block-scoped intermediates for earlier GC--mesh-simplify) to error-based (--mesh-simplify-error) using meshoptimizer'sErrorAbsolutemode--memCLI flag for memory usage reporting and--nav-exterior-radiusoption for controlling exterior fill dilationgen-room.mjstest generator for enclosed room scenes with optional floor holes and tunnelsDetails
Sparse Voxel Grid
The core change replaces the flat
Uint32Arraybitfield representation (1 bit per voxel across the entire grid) withSparseVoxelGrid, which tracks blocks as EMPTY/SOLID/MIXED and only stores per-voxel masks for MIXED blocks viaMaskStore(an open-addressing hash map). Memory scales with occupied block count rather than total grid volume.Exterior Fill
fillExterior()seeds a two-level BFS from all 6 boundary faces of the voxel grid. Any empty space reachable from outside is identified as exterior. The exterior is then dilated and OR'd with the original grid, effectively sealing open scenes so that only interior navigable space remains. This runs as a separate pipeline step before capsule carving.CLI Changes
--no-nav-simplifyreplaced by--nav-simplify(default: true)--mesh-simplify <ratio>replaced by--mesh-simplify-error <fraction>(max geometric error as fraction of voxel resolution, default: 0.08)--nav-exterior-radius <n>for exterior fill radius--memfor memory usage in progress outputTest plan
maingen-room.mjs) with--nav-exterior-radiusand verify exterior is correctly filled--mesh-simplify-errorproduces reasonable collision mesh simplification--memflag outputs memory stats in progress linesnpm run lintandnpm test