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
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, everyMeshre-runs_projectVerticesWorld(+_projectNormalsWorldfor 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:
→ ~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
Mesh(e.g.mesh.static = true, or auto-detect: a mesh whosecurrentTransform/pos/verticeshaven't changed since last frame).ARRAY_BUFFER(its own VBO + a cached index buffer), keyed/owned by the mesh._projectVerticesWorld, the batcher rebuild, andbufferData. 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.GLTFModelparts, meshes with changing transforms stay on the immediate path).Scope / considerations
Meshdestroy and on WebGL context loss/restore (hook into the existingONCONTEXT_RESTOREDrecovery).vertexColorsmeshes: the baked per-vertex colors are static — cache as-is.MeshBatcher.bind()mesh-mode state (the depth-clear / LEQUAL setup must still apply around cached draws).Acceptance criteria
_projectVerticesWorld/bufferDataafter the first frame (verifiable via a GL spy / profiler).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,bufferDatauploads)src/video/buffer/vertex.js,src/video/webgl/buffer/index.js