Skip to content

perf(client): reuse chunk mesh geometry instead of dispose/recreate cycle#27

Open
RZDESIGN wants to merge 2 commits intohytopiagg:mainfrom
RZDESIGN:perf/chunk-mesh-geometry-reuse
Open

perf(client): reuse chunk mesh geometry instead of dispose/recreate cycle#27
RZDESIGN wants to merge 2 commits intohytopiagg:mainfrom
RZDESIGN:perf/chunk-mesh-geometry-reuse

Conversation

@RZDESIGN
Copy link
Copy Markdown

@RZDESIGN RZDESIGN commented Mar 5, 2026

Summary

ChunkMeshManager._createOrUpdateMesh() currently disposes the old BufferGeometry and creates an entirely new one (with fresh BufferAttribute objects) every time a chunk batch mesh is updated — e.g. when a player breaks or places a block, or when light propagation triggers a rebuild.

This forces Three.js to:

  1. Delete all existing WebGL buffers for the old geometry (gl.deleteBuffer × 5-8 attributes + index)
  2. Allocate brand-new WebGL buffers for the new geometry (gl.createBuffer + gl.bufferData × 5-8 attributes + index)
  3. Garbage collect the discarded BufferGeometry and all its BufferAttribute JS objects

This PR eliminates that cycle by reusing the existing geometry — swapping the typed arrays on the existing BufferAttribute objects and marking them dirty.

What changed

Single file: client/src/chunks/ChunkMeshManager.ts

The _createOrUpdateMesh method is split into two paths:

  • New mesh (no existing cache entry): unchanged — creates BufferGeometry + BufferAttribute objects normally.
  • Existing mesh (cache hit): instead of dispose()new BufferGeometry(), the existing geometry's BufferAttribute objects are kept alive. Their .array property is swapped to the new typed array from the worker, and .needsUpdate = true is set.

Two small private helpers keep the update path clean:

  • _swapAttribute(geometry, name, data, itemSize) — swaps the array on an existing attribute, or creates the attribute if it doesn't exist yet.
  • _swapOptionalAttribute(geometry, name, data, itemSize) — same as above, but also handles the case where the attribute should be removed (e.g. a chunk that previously had lightLevel data no longer does).

The index buffer handles a special case: chunk indices can be Uint16Array or Uint32Array depending on vertex count. When the type matches, the array is swapped in-place. When it changes (e.g. chunk grows past 65535 vertices), a new BufferAttribute is created for the index only.

After swapping, boundingSphere and boundingBox are explicitly nulled and recomputed from the new data. This is necessary because updateAABB() skips recomputation when boundingBox is already present.

How Three.js handles this under the hood

When a BufferAttribute has needsUpdate = true, Three.js's WebGLAttributes compares the new array's byteLength against the stored GPU buffer size:

Scenario GPU operation Cost
Same byte-size (most common — same face count) gl.bufferSubData (reuse existing buffer) Fast — no allocation, no deallocation
Different byte-size (face count changed) gl.deleteBuffer + gl.createBuffer + gl.bufferData Same cost as before, but no JS GC overhead

In a typical play session, the majority of chunk updates are block place/break operations that change only a few faces. The vertex/index count stays identical or changes by a small amount. This means most updates hit the fast bufferSubData path.

Impact

For a scene with ~50 loaded chunk batches where a player is actively building:

Before After
GPU buffer ops per chunk update Delete 6-8 buffers + Create 6-8 buffers 0 deletes + 0 creates (same-size) or 1:1 (size change)
JS objects allocated per update 1 BufferGeometry + 6-8 BufferAttribute + 6-8 TypedArray wrappers 0 (arrays come from worker transfer)
GC pressure All discarded geometry/attribute objects become garbage Only the swapped-out typed arrays (unavoidable — owned by previous worker transfer)
GPU pipeline stalls Buffer deletion can stall if the GPU is still reading No deletion on fast path — buffer is reused in-place

The biggest win is eliminating the paired gl.deleteBuffer/gl.createBuffer calls. GPU drivers must synchronize to ensure the old buffer isn't still in use before deleting it, which can introduce micro-stalls in the rendering pipeline. Reusing the buffer via bufferSubData avoids this entirely.

Edge cases handled

  • First creation: No mesh in cache → full geometry creation (unchanged behavior)
  • Optional attributes appear/disappear: _swapOptionalAttribute adds or removes attributes as needed (e.g. lightLevel for lit vs non-lit chunks, foamLevel for liquid meshes)
  • Index type change (Uint16 ↔ Uint32): Detected via constructor comparison, falls back to creating a new index attribute
  • Bounding volumes: Explicitly invalidated (= null) before recomputation to ensure computeBoundingSphere() and updateAABB() use the new data
  • Mesh removal: _removeMesh still calls geometry.dispose() — WebGL buffers are properly cleaned up when a batch is removed from the world

Files changed

File Change
client/src/chunks/ChunkMeshManager.ts Refactored _createOrUpdateMesh to reuse geometry on update. Added _swapAttribute and _swapOptionalAttribute helpers. Removed dispose() + reassignment from the update path.

Test plan

  • Place and break blocks rapidly — verify chunk mesh updates correctly with no visual glitches
  • Walk through the world to trigger chunk loading/unloading — verify new chunks render correctly
  • Verify transparent blocks (glass, leaves) still render correctly after chunk updates
  • Verify liquid (water) meshes update correctly when blocks are placed/broken near water
  • Verify block lighting updates propagate visually after chunk rebuild
  • Check for WebGL errors/warnings in browser console during extended play sessions
  • Compare FPS during rapid block placement between this branch and main

RicardoDeZoete added 2 commits March 5, 2026 17:24
…ycle

When a chunk batch mesh is updated (e.g. block placed/broken), the
existing code disposes the old BufferGeometry and creates an entirely
new one with fresh BufferAttribute objects. This forces Three.js to
delete the old WebGL buffers and allocate new ones every time — even
when the buffer sizes haven't changed.

Swap the typed arrays on the existing BufferAttribute objects instead
and mark them dirty. Three.js then uses gl.bufferSubData (fast path)
when the byte-size is unchanged, and only recreates the buffer when
the size actually differs.

Made-with: Cursor
Three.js (v0.167.1) throws when a BufferAttribute's backing array is
swapped to a different byteLength.  Add a byteLength equality check in
_swapAttribute and in the index-buffer path so we only reuse the
existing WebGL buffer (fast bufferSubData path) when sizes match;
otherwise fall back to creating a new BufferAttribute, which lets
Three.js allocate a correctly-sized GPU buffer.

Made-with: Cursor
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.

1 participant