I have been working on a solution for a renderer with multiple engine views where each view can have diverging material states (I.e one view shows wireframe while another doesnt)
I wanted to come up with a solution that prevents me from needing to dirty materials between each frame to render correctly, so I started using material clones for each view, and making use of the mesh setMaterialForRenderPassID feature. This works quite well but Ive hit a significant snag with how MultiMaterial and SubMesh interact in the getMaterial method
Because I am using a unique material in each pass, the subMesh is resetting its draw cache before each view renders, causing a large build up of effect disposals that is chunking some frame times.
I would like to make a PR which updates the SubMeshto cache the currentMaterial and draw effects per render pass id, such that it only resets draw cache if the rootMaterial (the material from the current render pass ID) is different from the cached material for that renderPassID. This would allow a submesh using a different multi-material each render pass to preserve draw effects it would currently be recreating each frame.
resetDrawCache already supports a specific render pass ID, so it seems this is partially supported, just not used internally to SubMesh. I already proof of concepted it with a prototype override to see if it would kill the dispose build ups and that did work.
I am more than happy to make the PR myself, but wanted to check in to see if there was any reason this would be a bad idea I am not thinking of?
Would it be preferred to make it a flag on subMesh construction? (preserveDrawCache?)
Thanks for the detailed explanation. I’m open to a focused fix here: the draw wrappers are already stored per render-pass ID, whereas _currentMaterial is shared across passes. So this looks like an inconsistency in the existing mechanism rather than a need for an entirely new effect cache.
However, changing the comparison and calling resetDrawCache(renderPassId) may not be sufficient on their own. There are a few interactions we need to consider:
The comparison should use the effective submaterial, not just the root material. Replacing an entry in multiMaterial.subMaterials, or changing subMesh.materialIndex, must still invalidate the appropriate wrapper even when the root material is unchanged.
Material changes must reach caches belonging to other passes. For example, _markAllSubMeshesAsDirty() currently filters submeshes using getMaterial(), which depends on the current render pass. Changing a material used only in another pass can therefore miss its cached defines. Recreating the wrappers can currently mask that limitation; preserving them could expose stale rendering state.
The additional material tracking needs consistent cleanup, including when a render pass is released, draw caches are reset, or objects are disposed.
Since getMaterial() is called frequently, we should also keep the steady-state path allocation-free and avoid adding unnecessary overhead to unaffected scenes.
I would prefer not to introduce a preserveDrawCache constructor flag at this stage. Ideally, this should be correct behavior for the existing per-pass material API, rather than a separate mode with different invalidation semantics.
Please share a minimal reproduction and some before/after profiling from your prototype. That would help us establish the benefit and judge whether the fix can stay reasonably contained before committing to a particular implementation.
Ill put together a repo that contains the situation for testing, but here is a profile I recorded of my main app while stress testing this problem. There are 4 engine views and about ~70-90 mutliMaterials in this scene, and I profiled it with devTools with and without my prototype override
Thanks, those profiles make the benefit much clearer. The disappearance of the disposal tail, together with the reduction from roughly 53 ms to 25 ms in the captured animation-frame callbacks, is a good reason to pursue this.
The remaining concern is correctness rather than whether the optimization is worthwhile. Repeatedly recreating the wrappers may currently hide missed invalidation, so we need to verify that retaining them still behaves correctly when a material changes—not just when alternating between views with fixed material states. In particular, changing a material while another pass is active would be an important case to cover.
When the reproduction is ready, please include your prototype override so we can compare both paths and assess how contained the fix can be. Ideally, we can preserve the existing pass-local draw state, handle invalidation and cleanup consistently, and keep the steady-state path allocation-free without adding unnecessary overhead to ordinary single-pass rendering. I’d still prefer that over introducing a separate preserveDrawCache mode.
Thanks, this makes the problem easy to reproduce. After warm-up, I observed 2,400 draw-cache resets over three scene frames without the patch, versus none with it. The benefit is clear, and the prototype correctly compares the effective submaterial rather than just the root material.
However, I could also reproduce the invalidation issue mentioned earlier:
Let all four camera passes render so their wrappers are populated.
With engine.currentRenderPassId set to the first camera’s pass, assign a new diffuse texture to a submaterial used by the fourth camera.
Render the scene again.
Without the patch, the wrapper gets recreated and the texture is used. With the patch, the fourth pass retains its wrapper, but its texture defines are not marked dirty: DIFFUSE remains disabled despite the material now having a diffuse texture. Explicitly marking that material dirty while its own pass is current makes it update correctly.
This comes from _markAllSubMeshesAsDirty() filtering submeshes through getMaterial(), which resolves the material for the current pass. It is a pre-existing limitation that the repeated cache resets were masking, rather than something fundamentally wrong with preserving the wrappers.
So I think this is worth pursuing, but the fix needs to address pass-aware material invalidation alongside cache preservation. Please include this kind of material-change scenario in the regression coverage.
The additional tracking also needs pass-release/disposal cleanup. The outer WeakMap avoids retaining an unreachable submesh, but it does not remove obsolete entries from the inner Map while that submesh remains alive. Ideally, we can handle both concerns without adding allocations to the steady-state rendering path.
Here is a version 2 of the prototypes in the sandbox
Space bar should force a texture change in the first camera’s viewPort only when the engine is not on that camera’s render pass.
Added overrides to _markAllSubMeshesAsDirty() as well as resetDrawCache() on Material, which should both now use the indexes of the subMeshes drawWrappers to identify what renderPassIds the subMesh is uses and compare itself against each of those materials instead of a single getMaterial() check with the current renderPassId.
getMaterial() was updated to take an optional passId parameter which hunts for a material for a specific pass rather than the current renderPassId.
I also prototyped an override to SubMesh.resetDrawCache() to have it clear away pass → material association when a drawCache is reset, which should cover pass release I believe.
The weakMap is a temp thing only because the prototype is finnicky about accessing private class members when overriding, but in the final I think I would go for making an _currentMaterials: Array<Material> to replace _currentMaterial that is accessed and assigned the same way the SubMesh._drawWrappers or AbstractMesh._internalABstractMeshDataInfo._materialForRenderPass are used, which should get rid of that map I had originally in the proto.
Thanks, V2 fixes the inactive-pass texture update from the previous example, and the scoped material reset now preserves the other passes’ wrappers. The performance benefit is also clear.
I think it makes sense to move this into a PR now, with the following remaining points addressed there:
Material.markDirty() also needs pass-aware handling. This is separate from _markAllSubMeshesAsDirty() and still filters through the current pass’s getMaterial(). I reproduced a failure by freezing a material belonging to one pass, switching to another pass, assigning a diffuse texture, and calling material.markDirty(true). The defines become dirty, but _wasPreviouslyReady remains true, so the frozen-material readiness shortcut prevents recompilation. Calling markDirty(true) while the material’s own pass is current makes it update correctly.
Cleanup needs to cover direct wrapper removal and full resets.engine.releaseRenderPassId() calls subMesh._removeDrawWrapper(id) directly, so it bypasses the new cleanup in resetDrawCache(). Also, the prototype clears tracked materials only when a specific pass is supplied; the full-reset branch leaves the tracking array intact. Clearing the corresponding association in _removeDrawWrapper(), and all associations on a full reset, should address these paths. Moving the tracking onto the instance makes sense, but those cleanup paths still need to be wired up.
Avoid the temporary arrays in the material traversals.Object.keys(...).map(Number) allocates two arrays per submesh per invalidation/reset call. These are not steady-state draw-path allocations, but they can still create unnecessary churn when many materials change together. Please avoid them in the production implementation and skip missing wrappers before resolving their materials.
Please include regression coverage for inactive-pass updates, frozen-material markDirty(true), effective-submaterial replacement, scoped/full resets, and pass release, while preserving ordinary single-pass behavior.
I’d keep the PR focused on making the existing per-pass caching consistent, without introducing a separate preserveDrawCache mode.