feat(video): light data in a std140 uniform buffer, 32-light cap (#1552) - #1559
Merged
Conversation
Light data for both lit paths moves out of GLSL uniform arrays and into a std140 uniform buffer, raising MAX_LIGHTS from 8 to 32 for Light2d (lit sprites) and Light3d (lit meshes). The old cap was a compatibility limit, not a design choice: uniform arrays are charged against MAX_FRAGMENT_UNIFORM_VECTORS, a small driver-reported budget shared with every other uniform a shader declares, and a vec3 costs a full slot there. A uniform block is charged against MAX_UNIFORM_BLOCK_SIZE instead (>=16 KB everywhere); 32 lights occupy 1056 bytes. A static light rig still costs zero GL calls per frame — the upload is skipped when the packed bytes are unchanged. This raises capacity, not shading cost: the fragment loop still runs once per pixel per live light, so unused slots are free but filling them is not. The four lit shaders move to GLSL ES 3.00, since uniform blocks do not exist in ES 1.00. User shaders are unaffected — ShaderEffect bodies and raw GLShader sources stay ES 1.00. Also fixes a regression from #1468, found while working on this: a scene containing only meshes stopped clearing its depth buffer after the first frame and its geometry disappeared. The depth clear and the lit-mesh light upload both ran from MeshBatcher.bind(), which is a per-transition hook and not a per-frame one — setBatcher returns early when the requested batcher is already current. A scene with nothing else to draw bound once and never again, leaving the depth attachment on the first frame's values, so anything receding from the camera failed LEQUAL and was not drawn. The same silence froze Light3d lighting at its first-frame values. Both now refresh on the draw path via MeshBatcher.updatePassState(). Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JvxaXQRgQn5AoR8DNkv6Wg
Contributor
There was a problem hiding this comment.
Pull request overview
This PR modernizes melonJS’ WebGL lighting pipeline by moving per-light data from GLSL uniform arrays to std140 uniform buffer objects (UBOs), raising the engine-wide light cap to 32 for both 2D lit sprites (Light2d) and 3D lit meshes (Light3d). It also fixes a rendering regression where mesh-only scenes stopped clearing depth after the first frame, causing geometry to disappear, and adds extensive tests to pin the new layout/wiring and the depth-clear behavior.
Changes:
- Replace lit-shader light uniform arrays with
std140uniform blocks + aUniformBlockWebGL2 buffer wrapper; increaseMAX_LIGHTSfrom 8 to 32. - Migrate the four built-in lit shaders to GLSL ES 3.00 (
#version 300 es) while keeping user shader inputs unchanged at the API level. - Fix mesh-only scene rendering by moving depth clear + lit-mesh light refresh onto the per-draw path (
updatePassState()), plus add regression and layout/wiring tests.
Reviewed changes
Copilot reviewed 25 out of 25 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| README.md | Updates example description wording for the Aquarium demo. |
| packages/spine-plugin/src/Spine.js | Clarifies Spine Y-down comment (coordinate-system phrasing). |
| packages/melonjs/tests/webgl_uniformblock.spec.js | Adds tests for the new UniformBlock wrapper (binding, upload skipping, context loss). |
| packages/melonjs/tests/webgl_pipeline_adversarial.spec.js | Adds an end-to-end context-loss test ensuring lit meshes still shade after restore. |
| packages/melonjs/tests/webgl_mesh_depth.spec.js | Adds regression tests for depth clear when no batcher switch occurs (mesh-only scenes). |
| packages/melonjs/tests/lights.spec.js | Updates tests to use MAX_LIGHTS constant (now 32) rather than hardcoded 8. |
| packages/melonjs/tests/lighting3d.spec.js | Updates shader drift guard to expect the new std140 block-based light declaration. |
| packages/melonjs/tests/lighting_std140.spec.js | Adds thorough std140 layout tests and driver-reported offset/size verification for both blocks. |
| packages/melonjs/tests/lighting_block_wiring.spec.js | Adds GPU readback tests to prove batcher→UBO wiring and validates >8 light indexing via pixels. |
| packages/melonjs/src/video/webgl/webgl_renderer.js | Stops restoring programs after light upload (no longer needed) and documents per-draw refresh model for meshes. |
| packages/melonjs/src/video/webgl/shaders/quad-multi-lit.vert | Migrates lit quad vertex shader to GLSL ES 3.00 (in/out, #version 300 es). |
| packages/melonjs/src/video/webgl/shaders/multitexture-lit.js | Generates a GLSL ES 3.00 lit fragment with std140 light block + texture() and explicit fragment output. |
| packages/melonjs/src/video/webgl/shaders/mesh-lit.vert | Migrates lit mesh vertex shader to GLSL ES 3.00. |
| packages/melonjs/src/video/webgl/shaders/mesh-lit.frag | Migrates lit mesh fragment shader to GLSL ES 3.00 and reads lights from a std140 uniform block. |
| packages/melonjs/src/video/webgl/lighting/std140.ts | Adds the std140 writer for 2D/3D light blocks (header + packed per-light layout). |
| packages/melonjs/src/video/webgl/lighting/pack.ts | Updates docstring to reflect the new light cap and transport. |
| packages/melonjs/src/video/webgl/lighting/constants.ts | Raises MAX_LIGHTS to 32 and introduces binding-point constants for 2D/3D light blocks. |
| packages/melonjs/src/video/webgl/buffer/uniformblock.js | Adds a UBO wrapper with dirty-checking and program block-binding helper. |
| packages/melonjs/src/video/webgl/batchers/mesh_batcher.js | Moves lazy depth clear consumption to updatePassState() and calls it on draw paths. |
| packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js | Reworks lit quad batcher to own a Light2dBlock UBO and write/upload std140 bytes. |
| packages/melonjs/src/video/webgl/batchers/lit_mesh_batcher.js | Reworks lit mesh batcher to own a Light3dBlock UBO and refresh/upload per draw. |
| packages/melonjs/src/camera/camera3d.ts | Tweaks documentation wording (spring-arm phrasing). |
| packages/melonjs/CHANGELOG.md | Adds release notes for the 32-light UBO change and the mesh-only depth-clear regression fix. |
| packages/examples/src/main.tsx | Adjusts example description wording (removes engine-specific reference). |
| packages/examples/src/examples/aquarium/ExampleAquarium.tsx | Updates Aquarium example comment describing the screen-texture pattern. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1552.
Up to 32 lights, via a uniform buffer
MAX_LIGHTSrises from 8 to 32, for both the lit sprite path (Light2d+ normal maps) and the lit mesh path (Light3d).The old cap was a compatibility limit, not a design choice. Light data travelled in GLSL uniform arrays, which are charged against
MAX_FRAGMENT_UNIFORM_VECTORS— a small driver-reported budget shared with every other uniform a shader declares, and one avec3consumes a whole slot of. It now travels in astd140uniform buffer, charged againstMAX_UNIFORM_BLOCK_SIZEinstead (at least 16 KB everywhere, typically 64 KB). 32 lights occupy 1056 bytes there.A static light rig still costs zero GL calls per frame — the upload is skipped when the packed bytes are unchanged, preserving what the uniform cache gave before.
This raises capacity, not shading cost: the fragment loop still runs once per pixel per live light, so unused slots are free but filling them is not. Many-light scenes would need clustering or a light-list texture; out of scope here.
The four lit shaders move to GLSL ES 3.00, since uniform blocks do not exist in ES 1.00. User shaders are unaffected —
ShaderEffectbodies and rawGLShadersources stay ES 1.00 and compile unchanged.New internals:
video/webgl/buffer/uniformblock.js(the buffer wrapper) andvideo/webgl/lighting/std140.ts(the layout writer). Neither is public.Bug fix: a mesh-only scene lost its depth buffer, and its geometry
A regression from #1468, found while working on the above and worth reading separately — it has nothing to do with lighting and affects unlit meshes too.
MeshBatcher.bind()was being used as if it ran every frame. It doesn't:WebGLRenderer.setBatcherreturns early when the requested batcher is already current. Two things parked inbind()therefore stopped happening in a scene that draws only meshes — no sprites, no UI, no unlit mesh beside a lit one to force a transition:_meshDepthDirtywas re-armed on every render-target change but only ever consumed inbind(), so it sat armed forever while the depth attachment kept the first frame's values. Anything receding from the camera then failedLEQUALand was not drawn at all.Light3dlighting froze at its first-frame values.Measured on
master, three frames of a mesh-only scene:binds=1, clears=0, dirty flag stilltrueat the end. The same quad drawn each frame while receding reads255 → 0 → 0— it renders once and then vanishes. With the fix,clears=3and255 → 255 → 255.Both now refresh on the draw path, in
MeshBatcher.updatePassState(). Cost measured over 200k iterations: ~4 ns per unlit mesh draw (one boolean test), ~70 ns per lit mesh draw with static lights (no GL), ~220 ns on the draw where lights actually changed. Zero uploads across 1000 draws with a static rig.Most scenes escaped this because almost anything on screen forces a batcher transition —
night-citysurvives because its glowing windows arelit: false, alternating the two mesh batchers every frame.Verification
tscclean; spine-plugin builds.getActiveUniforms(…, UNIFORM_OFFSET)reports offsets0/16/32/48/64and a 1056-byte block for both shaders. Reordering two block members fails it.MAX_LIGHTSto 8 fails them.Notes for review
bind()is not a per-frame hook. The dirty check keeps it free.INVALID_OPERATIONat draw time, which takes the whole program down rather than just the lighting. Blocks are bound eagerly ininit()so nothing relies on that safety net.bindTois masked today and its test cannot fail. The 3D path (point 1) is genuinely load-bearing.🤖 Generated with Claude Code
https://claude.ai/code/session_01JvxaXQRgQn5AoR8DNkv6Wg