Skip to content

Add sparse voxel grid and exterior fill for nav simplification#197

Draft
slimbuck wants to merge 11 commits intoplaycanvas:mainfrom
slimbuck:block-dev
Draft

Add sparse voxel grid and exterior fill for nav simplification#197
slimbuck wants to merge 11 commits intoplaycanvas:mainfrom
slimbuck:block-dev

Conversation

@slimbuck
Copy link
Copy Markdown
Member

Summary

  • Introduce SparseVoxelGrid and MaskStore data structures for memory-efficient block-level voxel operations, replacing dense bitfield grids in the nav simplification pipeline
  • Add fillExterior() function that floods exterior space from grid boundaries inward, enabling automatic enclosure detection for indoor scenes (rooms, buildings)
  • Refactor simplifyForCapsule to use sparse block-aware dilation, erosion, and two-level BFS (block-level for empty space, voxel-level for mixed blocks)
  • Optimize sparse-octree.ts to use typed arrays (Uint32Array, Uint8Array, Int32Array) and block-scoped intermediates for earlier GC
  • Switch collision mesh simplification from ratio-based (--mesh-simplify) to error-based (--mesh-simplify-error) using meshoptimizer's ErrorAbsolute mode
  • Add --mem CLI flag for memory usage reporting and --nav-exterior-radius option for controlling exterior fill dilation
  • Add gen-room.mjs test generator for enclosed room scenes with optional floor holes and tunnels

Details

Sparse Voxel Grid

The core change replaces the flat Uint32Array bitfield representation (1 bit per voxel across the entire grid) with SparseVoxelGrid, which tracks blocks as EMPTY/SOLID/MIXED and only stores per-voxel masks for MIXED blocks via MaskStore (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-simplify replaced 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)
  • Added --nav-exterior-radius <n> for exterior fill radius
  • Added --mem for memory usage in progress output

Test plan

  • Run voxel output on an open outdoor scene and verify nav simplification produces equivalent results to main
  • Run voxel output on an enclosed room scene (e.g. gen-room.mjs) with --nav-exterior-radius and verify exterior is correctly filled
  • Verify --mesh-simplify-error produces reasonable collision mesh simplification
  • Verify --mem flag outputs memory stats in progress lines
  • Run npm run lint and npm test

@slimbuck slimbuck requested a review from Copilot March 30, 2026 20:45
@slimbuck slimbuck self-assigned this Mar 30, 2026
@slimbuck slimbuck added the enhancement New feature or request label Mar 30, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 + MaskStore to 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.

Comment on lines +229 to 234
navExteriorRadius,
navCapsule,
navSeed
navSeed,
collisionMesh: v['collision-mesh'],
meshSimplifyError: parseNumber(v['mesh-simplify-error']) || undefined
};
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +564 to +566
let vqIx = new Uint16Array(vqCap);
let vqIy = new Uint16Array(vqCap);
let vqIz = new Uint16Array(vqCap);
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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);

Copilot uses AI. Check for mistakes.
Comment on lines +19 to +27
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);
}
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +15 to +17
* 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
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
* 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

Copilot uses AI. Check for mistakes.
Comment on lines 500 to 510
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']
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants