Problem
WebGLRenderer.drawMesh is currently one draw call per mesh:
drawMesh(mesh) {
this.setBatcher("mesh");
// ...
gl.enable(gl.DEPTH_TEST);
gl.depthFunc(gl.LEQUAL);
gl.depthMask(true);
gl.clearDepth(1.0);
gl.clear(gl.DEPTH_BUFFER_BIT); // per-mesh
gl.disable(gl.BLEND);
// ... addMesh + flush ...
this.flush(); // per-mesh drawElements
}
Every Mesh in the scene triggers its own gl.clear(DEPTH_BUFFER_BIT) + its own flush(). With ~9 meshes (player jet + enemies in the feat/camera3d AfterBurner showcase), that's 9 framebuffer depth clears and 9 separate drawElements per frame, plus the state-toggle overhead (depth-test on/off, blend on/off, cull on/off).
Measured on a Mac Studio with the AfterBurner scene: draw time hovers at 8ms, peaks at 12ms even though the geometry budget is trivial (small low-poly Kenney models, 14 total draw calls, 96 sprites). The 60 fps target is met today, but there's no headroom for richer Camera3d scenes.
Why it's like this
The per-mesh depth clear enforces a useful contract: each mesh self-occludes independently of every other mesh. Two meshes intersecting in screen space don't have to be sorted correctly relative to each other for the picture to look right — each one's own backface/frontface ordering is preserved by its own depth buffer pass.
If we just removed the clear, intersecting meshes would Z-fight or draw in wrong order whenever the painter's sort got them slightly off.
Proposed direction
Engine-level refactor — not safe to bolt on inside a feature branch:
- Sort all meshes back-to-front within a frame (already mostly true under
world.sortOn = "depth", but a dedicated mesh pass would make this explicit).
- Clear the depth buffer once per frame, not per mesh.
- Batch all meshes into a single
MeshBatcher flush when possible (current batcher is already indexed; the per-mesh state changes are what's blocking batching).
- For intersecting meshes, fall back to the current per-mesh clear path (rare).
Open questions for the follow-up:
- How does this interact with multiple cameras / RT passes? Each camera owns its own FBO already, so probably fine.
- How does this interact with
customShader-set-per-Mesh? Could force a flush boundary the same way per-light shaders do today.
- What's the simplest API surface? Probably a
flushMeshes() at end of frame, with drawMesh queueing instead of drawing immediately.
Surface area
packages/melonjs/src/video/webgl/webgl_renderer.js — drawMesh() and friends
packages/melonjs/src/video/webgl/batchers/mesh_batcher.js
- Possibly
Mesh.draw() if the queueing model wants the mesh to push itself
Discovered while profiling the Camera3d AfterBurner showcase on feat/camera3d. No correctness bug; this is purely a performance follow-up.
Problem
WebGLRenderer.drawMeshis currently one draw call per mesh:Every
Meshin the scene triggers its owngl.clear(DEPTH_BUFFER_BIT)+ its ownflush(). With ~9 meshes (player jet + enemies in thefeat/camera3dAfterBurner showcase), that's 9 framebuffer depth clears and 9 separatedrawElementsper frame, plus the state-toggle overhead (depth-test on/off, blend on/off, cull on/off).Measured on a Mac Studio with the AfterBurner scene: draw time hovers at 8ms, peaks at 12ms even though the geometry budget is trivial (small low-poly Kenney models, 14 total draw calls, 96 sprites). The 60 fps target is met today, but there's no headroom for richer Camera3d scenes.
Why it's like this
The per-mesh depth clear enforces a useful contract: each mesh self-occludes independently of every other mesh. Two meshes intersecting in screen space don't have to be sorted correctly relative to each other for the picture to look right — each one's own backface/frontface ordering is preserved by its own depth buffer pass.
If we just removed the clear, intersecting meshes would Z-fight or draw in wrong order whenever the painter's sort got them slightly off.
Proposed direction
Engine-level refactor — not safe to bolt on inside a feature branch:
world.sortOn = "depth", but a dedicated mesh pass would make this explicit).MeshBatcherflush when possible (current batcher is already indexed; the per-mesh state changes are what's blocking batching).Open questions for the follow-up:
customShader-set-per-Mesh? Could force a flush boundary the same way per-light shaders do today.flushMeshes()at end of frame, withdrawMeshqueueing instead of drawing immediately.Surface area
packages/melonjs/src/video/webgl/webgl_renderer.js—drawMesh()and friendspackages/melonjs/src/video/webgl/batchers/mesh_batcher.jsMesh.draw()if the queueing model wants the mesh to push itselfDiscovered while profiling the Camera3d AfterBurner showcase on
feat/camera3d. No correctness bug; this is purely a performance follow-up.