Skip to content

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

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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions