Force Three's WebGPURenderer to discard the cached RenderObject
associated with a given mesh + its material, so the next render
builds a fresh one with a clean vertexBuffers cache.
Why this exists: when a commit swaps a mesh's InstancedBufferGeometry
(pool grow / best-fit adoption / non-pool rebuild — the per-instance
buffers, e.g. aSortedIndex, are new objects),
Three's RenderObject (cached in RenderObjects.chainMap keyed
by [mesh, material, …]) still holds:
attributes / attributesId — reset to null by
setGeometry when Three's needsGeometryUpdate notices the
attribute ids changed.
vertexBuffers — NOT reset by setGeometry. Stays as the
OLD Set<InterleavedBuffer> until getAttributes() rebuilds
it.
getAttributes() is called from Geometries.updateForRender,
which Renderer._renderObjectDirect (r185 common/Renderer.js
lines 3690-3714) only runs when
_nodes.needsRefresh(renderObject) is true. A
pure-geometry / buffer-resize change does NOT mark needsRefresh,
so on the next draw WebGPUBackend.draw calls
renderObject.getVertexBuffers() which returns the stale
vertexBuffers array (skips getAttributes() because
vertexBuffers !== null). The renderer then binds the OLD GPU
buffer at slot 0 and validation fails with "Instance range …
requires a larger buffer than the bound buffer size (oldSize)".
The cleanest in-userland fix is to dispatch a dispose event on
the mesh's material. RenderObject.onMaterialDispose (r185 lines
324-328 in common/RenderObject.js) calls renderObject.dispose(), whose
onDispose callback (set in RenderObjects.createRenderObject,
r185 lines 201-209) deletes the entry from chainMap. The next
RenderObjects.get(...) call returns undefined from the map and
builds a fresh RenderObject whose vertexBuffers cache starts
null — forcing getVertexBuffers() to call getAttributes() and
pick up the current InterleavedBuffer.
This RenderObject eviction is WebGPU-only. The chainMapvertexBuffers staleness it fixes exists only in Three's WebGPU
RenderObjects cache; the classic WebGLRenderer (Luxar's
production default) has no such cache and re-reads geometry
attributes every draw. Worse, on the classic backend the SAME
dispose event is caught by WebGLRenderer's own
onMaterialDispose, which deallocates the material's compiled GL
program — forcing a full GLSL recompile on the very next frame
(multi-10ms hitches on every progressive / additive-ladder commit).
So the dispatch is not merely unnecessary on classic WebGL, it is
actively harmful, and is gated behind
configureRenderObjectEviction (renderer-setup enables it
only when the backend is WebGPU). On classic WebGL — and in
headless/unit contexts with no renderer — the dispose dispatch is
skipped entirely, so no compiled program is ever destroyed.
When the eviction IS dispatched (WebGPU), Luxar's MaterialManager
also listens for 'dispose' to unregister the material from its
global-update set / caches. To prevent that side effect while still
triggering Three's RenderObject eviction, we set a transient
symbol-keyed flag on the material before dispatching;
MaterialManager's listener checks for the flag and treats the
event as a soft cache-invalidation rather than a real dispose.
See SOFT_DISPOSE_FLAG in rendering/material-manager/soft-dispose-flag.ts.
Force Three's
WebGPURendererto discard the cachedRenderObjectassociated with a given mesh + its material, so the next render builds a fresh one with a cleanvertexBufferscache.Why this exists: when a commit swaps a mesh's
InstancedBufferGeometry(pool grow / best-fit adoption / non-pool rebuild — the per-instance buffers, e.g.aSortedIndex, are new objects), Three'sRenderObject(cached inRenderObjects.chainMapkeyed by[mesh, material, …]) still holds:attributes/attributesId— reset tonullbysetGeometrywhen Three'sneedsGeometryUpdatenotices the attribute ids changed.vertexBuffers— NOT reset bysetGeometry. Stays as the OLDSet<InterleavedBuffer>untilgetAttributes()rebuilds it.getAttributes()is called fromGeometries.updateForRender, whichRenderer._renderObjectDirect(r185common/Renderer.jslines 3690-3714) only runs when_nodes.needsRefresh(renderObject)istrue. A pure-geometry / buffer-resize change does NOT markneedsRefresh, so on the next drawWebGPUBackend.drawcallsrenderObject.getVertexBuffers()which returns the stalevertexBuffersarray (skipsgetAttributes()becausevertexBuffers !== null). The renderer then binds the OLD GPU buffer at slot 0 and validation fails with "Instance range … requires a larger buffer than the bound buffer size (oldSize)".The cleanest in-userland fix is to dispatch a
disposeevent on the mesh's material.RenderObject.onMaterialDispose(r185 lines 324-328 incommon/RenderObject.js) callsrenderObject.dispose(), whoseonDisposecallback (set inRenderObjects.createRenderObject, r185 lines 201-209) deletes the entry fromchainMap. The nextRenderObjects.get(...)call returnsundefinedfrom the map and builds a freshRenderObjectwhosevertexBufferscache startsnull— forcinggetVertexBuffers()to callgetAttributes()and pick up the currentInterleavedBuffer.This RenderObject eviction is WebGPU-only. The
chainMapvertexBuffersstaleness it fixes exists only in Three's WebGPURenderObjectscache; the classicWebGLRenderer(Luxar's production default) has no such cache and re-reads geometry attributes every draw. Worse, on the classic backend the SAMEdisposeevent is caught byWebGLRenderer's ownonMaterialDispose, which deallocates the material's compiled GL program — forcing a full GLSL recompile on the very next frame (multi-10ms hitches on every progressive / additive-ladder commit). So the dispatch is not merely unnecessary on classic WebGL, it is actively harmful, and is gated behind configureRenderObjectEviction (renderer-setup enables it only when the backend is WebGPU). On classic WebGL — and in headless/unit contexts with no renderer — the dispose dispatch is skipped entirely, so no compiled program is ever destroyed.When the eviction IS dispatched (WebGPU), Luxar's MaterialManager also listens for
'dispose'to unregister the material from its global-update set / caches. To prevent that side effect while still triggering Three'sRenderObjecteviction, we set a transient symbol-keyed flag on the material before dispatching; MaterialManager's listener checks for the flag and treats the event as a soft cache-invalidation rather than a real dispose. SeeSOFT_DISPOSE_FLAGinrendering/material-manager/soft-dispose-flag.ts.