Skip to content

Mesh rendering: batch meshes within a frame to amortize depth-clear and drawElements cost #1468

@obiot

Description

@obiot

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:

  1. 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).
  2. Clear the depth buffer once per frame, not per mesh.
  3. 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).
  4. 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.jsdrawMesh() 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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions