#13810·cesium

Gaussian splat snapshot rebuilds allocate four fresh attribute copies (~44 bytes/splat) every rebuild, fragmenting the heap until ArrayBuffer allocation fails

Author: diegomazalaCreated Sep 17, 2026Updated Sep 17, 2026

What happened?

Leaving a Gaussian splat 3D Tiles scene running with a moving camera eventually kills rendering:

An error occurred while rendering. Rendering has stopped.
RangeError: Array buffer allocation failed
    at arrayBufferConstructor_DoNotInitialize (<anonymous>)
    at new Float32Array (<anonymous>)
    at GaussianSplatPrimitive.generateSplatTexture
    at GaussianSplatPrimitive.update
    at PrimitiveCollection.update
    at updateAndRenderPrimitives

Reproduced with maximumRequestsPerServer=1 and a minimal request load, so it is not the request path.

Root cause

GaussianSplatPrimitive.generateSplatTexture duplicates every attribute array on each snapshot rebuild:

javascript
const promise = GaussianSplatTextureGenerator.generateFromAttributes({
  attributes: {
    positions: new Float32Array(snapshot.positions),
    scales: new Float32Array(snapshot.scales),
    rotations: new Float32Array(snapshot.rotations),
    colors: new Uint8Array(snapshot.colors),
  },
  count: snapshot.numSplats,
});

The copies are not gratuitous — generateFromAttributes passes all four .buffers in the worker transfer list, and transferring the snapshot's own arrays would detach data that is still read afterwards (the sort path, and the hard-cap truncation in processGeneratedSplatTextureData). The copy is the price of the transfer.

The cost is ~44 bytes per splat (positions 12 + scales 12 + rotations 16 + colors 4), allocated as four contiguous blocks while the originals are live:

splats copies live originals transient total
1 M ~44 MB ~44 MB ~88 MB
4.2 M ~185 MB ~185 MB ~370 MB

The primitive rebuilds its snapshot whenever the selected tile set changes (with a forced rebuild after ~0.5 s of sustained instability), so a session with a moving camera performs these allocations continuously. The transferred buffers die in the worker after each generation. Over a long session the heap fragments until one of the four allocations fails.

Beyond the crash, this is also a per-rebuild main-thread cost: ~185 MB of memcpy plus four large allocations on the frame that kicks off a rebuild, at the moment the viewer is already busiest.

Related to #13503 — this is transient allocation churn that the cacheBytes accounting model never sees.

Proposed fix

Keep the copy, drop the allocations: have the worker push the four attribute buffers into its return transfer list and echo attributes in its result, and have the main thread keep one reusable staging set per primitive, sized to the high-water splat count. Each rebuild copies into the pooled set and transfers length-limited subarray views; when the result arrives the buffers are pooled again. Steady state allocates nothing, output is byte-identical, and the set dies with the primitive.

A lost worker task (transfer went out, no result returned) just means the next rebuild allocates once more — postMessage transfer is all-or-nothing, so a detached set is detectable from any one buffer.

Note this touches the same neighborhood as #13669 (WebAssembly off the main thread); the fix is orthogonal to that refactor (the scheduleTaskundefined contract and the copy site both survive it) but will need rebasing whichever lands second.

PR incoming.

Reproduction

Any large splat tileset (millions of splats across tiles) + free camera motion + time. Fastest with a fixed cache ceiling and continuous rendering; the failure point depends on total heap pressure, so it presents as "long sessions eventually crash".

Environment

  • CesiumJS: reproduced on 1.145, code unchanged on main (08a0d8db4b)
  • Browser: Chrome 152, macOS