Skip to content

Halve LCC2 read peak memory by decoding chunks into a preallocated table#260

Merged
slimbuck merged 2 commits into
playcanvas:mainfrom
slimbuck:lcc2-dev
Jun 12, 2026
Merged

Halve LCC2 read peak memory by decoding chunks into a preallocated table#260
slimbuck merged 2 commits into
playcanvas:mainfrom
slimbuck:lcc2-dev

Conversation

@slimbuck

Copy link
Copy Markdown
Member

Reading an LCC2 scene currently holds every decoded chunk DataTable in memory and then combine() allocates a second full-size copy, so peak memory is ~2× the decoded scene (~53GB for a real 110M-splat scene with 3-band SH). This PR replaces decode-then-combine with a preallocate-and-copy scheme and fixes several smaller inefficiencies found in the same path.

Changes

  • Preallocated output (the main fix). Per-chunk splat counts are read from the meta's data['3dgs'].count fields (summed across nodes sharing a chunk file, matching real XGrids exports). When a count is missing (old-protocol files), a cheap fallback reads just the chunk header: SOG meta.json count/means.shape[0], or SPZ numPoints (including streaming-gunzip of just the 16-byte header for gzip-wrapped v1–3 containers). Prefix-summed offsets fix the output layout before decoding, so workers completing out of order still write into disjoint regions and the result stays deterministic. Each decoded chunk is validated against its expected count, copied into the output arrays at its offset and released. Output columns are allocated once at exact final size on first sighting, preserving combine()'s zero-fill semantics for heterogeneous chunks. Peak memory is now ~1× the scene plus the chunks in flight.
  • WebP WASM module memoized. WebPCodec.create() previously compiled and instantiated a fresh Emscripten module (with its own heap) on every call — once per SOG chunk via readSog. The module promise is now cached, mirroring the existing getSpzModule() pattern.
  • Progress bar fix. Logger bars are strictly LIFO, but readLcc2 ran 4 concurrent readSog calls each opening its own bar, corrupting the display (sibling bars popped as failed, outer bar suppressed). readSog/readSogV1 now accept { logging: 'own' | 'silent' } (mirroring writeSog) and readLcc2 silences the per-chunk bars under its own single bar.
  • Single octree traversal. collectChunksByLevel gathers every level's chunk files (with counts) in one pass, replacing the per-LOD collectFileIndicesForLod traversals.
  • Safer tolerant JSON parsing. Strict JSON.parse is tried first; only on failure is a string-aware trailing-comma stripper applied. The old ,\s*} regex corrupted string values containing ",}" and missed trailing commas before ].
  • Chunk file index validation. Out-of-range or empty splatFiles references now fail with a descriptive error instead of crashing in join().

Verification

  • Full test suite passes (548 tests), including 20 new LCC2 tests (meta-count path, count-mismatch rejection, SPZ v3 gzip and SOG header fallbacks, SOG/SPZ end-to-end, heterogeneous-SH zero-fill, index validation) and 3 new WebPCodec memoization tests.
  • Outputs verified byte-identical to the previous implementation on two real XGrids scenes (110.3M-splat Cecil_Ivar across single- and multi-LOD selections, and a 44.3M-splat scene).
  • Measured peak RSS on Cecil_Ivar (read-only, null output): LOD 3 (6.8M splats) 3.50GB → 2.51GB, LOD 2 (13.7M splats) 6.71GB → 4.48GB, with run time at parity. The gap grows with scene size since the overhead beyond 1× is now a constant ~LOAD_CONCURRENCY chunks.

@slimbuck slimbuck requested a review from Copilot June 12, 2026 16:58
@slimbuck slimbuck self-assigned this Jun 12, 2026
@slimbuck slimbuck added the enhancement New feature or request label Jun 12, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 reduces peak memory usage when reading LCC2 scenes by switching from “decode all chunks then combine()” to a deterministic preallocate-and-copy pipeline, while also improving related ergonomics (progress logging, tolerant meta parsing) and eliminating repeated WebP WASM instantiation overhead.

Changes:

  • Precompute per-chunk counts (from meta when available; otherwise via cheap header/meta reads) and decode chunks directly into preallocated output arrays with deterministic offsets.
  • Memoize the WebP WASM module in WebPCodec.create() and update SOG/image writers to rely on the memoization.
  • Add progress-bar control for SOG decoding, safer trailing-comma JSON parsing, chunk index validation, and expanded unit tests for LCC2/WebP paths.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated no comments.

Show a summary per file
File Description
test/webp-codec.test.mjs Adds coverage for WebP WASM module memoization (sequential + concurrent) and a lossless round-trip.
test/read-lcc2.test.mjs Adds extensive LCC2 tests for meta parsing, chunk count fallbacks, deterministic assembly, heterogeneous columns, and index validation.
src/lib/writers/write-sog.ts Removes per-module codec instance cache; relies on WebPCodec.create() module memoization.
src/lib/writers/write-image.ts Removes per-module codec instance cache; relies on WebPCodec.create() module memoization.
src/lib/utils/webp-codec.ts Memoizes the compiled/instantiated WASM module promise and resets cache on rejection.
src/lib/readers/read-sog.ts Adds logging option to suppress internal progress bars for callers doing concurrent chunk reads.
src/lib/readers/read-sog-v1.ts Threads through ReadSogOptions to support silent progress mode for v1 as well.
src/lib/readers/read-lcc2.ts Implements preallocated output decoding, count scanning/header parsing, safer JSON parsing, single traversal chunk collection, and index validation.
Comments suppressed due to low confidence (1)

src/lib/readers/read-lcc2.ts:798

  • envFileIndex is derived from meta.root.data.env.name but isn’t validated before indexing into splatFiles. If it’s out of range or points to an empty path, related(splatFiles[envFileIndex]) can throw (or later decode will fail) and the error is currently swallowed as a warn in the optional-env path. This undermines the new “chunk file index validation” behavior; invalid env references should fail fast with a descriptive error (or be handled explicitly).
    if (envFileIndex !== undefined) {
        try {
            const envFull = related(splatFiles[envFileIndex]);
            const envTable = await decodeChunk(fileSystem, splatType, envFull);
            envTable.addColumn(

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@slimbuck slimbuck marked this pull request as ready for review June 12, 2026 17:06
@slimbuck slimbuck requested a review from a team June 12, 2026 17:06
@slimbuck slimbuck merged commit 319273c into playcanvas:main Jun 12, 2026
3 checks passed
@slimbuck slimbuck deleted the lcc2-dev branch June 12, 2026 17:06
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