Skip to content

3D: static-mesh VBO caching (retained-mode draw for unchanging geometry) #1507

@obiot

Description

@obiot

Summary

Add a static-mesh VBO cache so geometry that doesn't change between frames is uploaded to a GPU buffer once and re-drawn from VRAM, instead of being re-projected and re-uploaded on the CPU every frame.

Motivation

melonJS's mesh path is currently immediate-mode: under Camera3d, every Mesh re-runs _projectVerticesWorld (+ _projectNormalsWorld for lit meshes) and the batcher rebuilds + re-uploads the full vertex buffer every frame, even for completely static geometry. That makes the CPU/upload cost the throughput limiter.

Measured on a Mac Studio (Chrome/ANGLE-Metal), whole scene re-drawn each frame, no culling:

Vertices/frame FPS Frame time
158k 60 16.7 ms
376k 59 16.8 ms
718k 36 27.4 ms
1.16M 21 47.9 ms

→ ~400–480k vertices/frame at 60 fps; sustained ~20M verts/sec — but that ceiling exists because static geometry pays full re-stream cost every frame.

Dedicated/retained-mode 3D engines (Three.js, Babylon, PlayCanvas, pixi3d, Cocos) upload static geometry once and draw it from VRAM, so the CPU does ~nothing per frame for a static mesh — which is why they handle millions of static verts at 60 fps. A VBO cache is the single biggest lever to close that gap for static scenes.

(The recent zero-allocation batcher refactor — versioned typed-array remap replacing the per-chunk Map, ~7× less GC garbage, ~30% faster draw — addressed the allocation half of the per-frame cost. This ticket addresses the re-upload/re-transform half.)

Proposal

  • Add an opt-in "static" flag on Mesh (e.g. mesh.static = true, or auto-detect: a mesh whose currentTransform / pos / vertices haven't changed since last frame).
  • On first draw, project the geometry once and upload it to a persistent per-mesh GPU ARRAY_BUFFER (its own VBO + a cached index buffer), keyed/owned by the mesh.
  • On subsequent frames, if nothing changed, bind the cached VBO and issue the draw directly — skip _projectVerticesWorld, the batcher rebuild, and bufferData. The per-frame view/projection still happens in the shader via the camera's uniforms, so the static VBO holds model-space (or world-space) geometry and the camera matrix does the rest.
  • Invalidate + re-upload when the mesh's transform/geometry/material actually changes (dirty flag).
  • Must coexist with the existing dynamic batched path (animated GLTFModel parts, meshes with changing transforms stay on the immediate path).

Scope / considerations

  • Lifecycle: free the cached GPU buffers on Mesh destroy and on WebGL context loss/restore (hook into the existing ONCONTEXT_RESTORED recovery).
  • Lit meshes: cache the normal attribute too (world-space normals are static if the transform is).
  • Multi-material / vertexColors meshes: the baked per-vertex colors are static — cache as-is.
  • Interaction with depth pass + MeshBatcher.bind() mesh-mode state (the depth-clear / LEQUAL setup must still apply around cached draws).
  • Decide world-space vs model-space caching (model-space + per-mesh model uniform is the cleaner retained design and avoids re-baking pos/transform into vertices).
  • This is independent of #INSTANCING (instancing draws one geometry N times; VBO caching makes a single static geometry cheap to redraw). They compose but can ship separately.

Acceptance criteria

  • A static mesh re-drawn every frame does no _projectVerticesWorld / bufferData after the first frame (verifiable via a GL spy / profiler).
  • Large static scenes (e.g. the night-city example) hold 60 fps at vertex counts well past the current ~450k ceiling.
  • Dynamic/animated meshes are unaffected (still correct under the immediate path).
  • Buffers are released on destroy + re-created on context restore.
  • No visual regression across the gltf / mesh / depth test suites.

References

  • src/renderable/mesh.js (_projectVerticesWorld, _projectNormalsWorld, draw)
  • src/video/webgl/batchers/mesh_batcher.js / lit_mesh_batcher.js (addMesh, the chunked rebuild)
  • src/video/webgl/batchers/batcher.js (flush, bufferData uploads)
  • src/video/buffer/vertex.js, src/video/webgl/buffer/index.js
  • Prior art: the zero-alloc batcher refactor (versioned remap) in 19.8

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