Skip to content

feat(video): light data in a std140 uniform buffer, 32-light cap (#1552) - #1559

Merged
obiot merged 1 commit into
masterfrom
feat/1552-light-uniform-block
Aug 1, 2026
Merged

feat(video): light data in a std140 uniform buffer, 32-light cap (#1552)#1559
obiot merged 1 commit into
masterfrom
feat/1552-light-uniform-block

Conversation

@obiot

@obiot obiot commented Aug 1, 2026

Copy link
Copy Markdown
Member

Closes #1552.

Up to 32 lights, via a uniform buffer

MAX_LIGHTS rises 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 a vec3 consumes a whole slot of. It now travels in a std140 uniform buffer, charged against MAX_UNIFORM_BLOCK_SIZE instead (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 unaffectedShaderEffect bodies and raw GLShader sources stay ES 1.00 and compile unchanged.

New internals: video/webgl/buffer/uniformblock.js (the buffer wrapper) and video/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.setBatcher returns early when the requested batcher is already current. Two things parked in bind() 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:

  • the depth-buffer clear_meshDepthDirty was re-armed on every render-target change but only ever consumed in bind(), so it sat armed forever while the depth attachment kept the first frame's values. Anything receding from the camera then failed LEQUAL and was not drawn at all.
  • the lit-mesh light upload — so Light3d lighting froze at its first-frame values.

Measured on master, three frames of a mesh-only scene: binds=1, clears=0, dirty flag still true at the end. The same quad drawn each frame while receding reads 255 → 0 → 0 — it renders once and then vanishes. With the fix, clears=3 and 255 → 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-city survives because its glowing windows are lit: false, alternating the two mesh batchers every frame.

Verification

  • 5208 passed / 1 skipped / 0 failed across 181 files, run twice; eslint, biome and tsc clean; spine-plugin builds.
  • Layout pinned against the driver, not against my own arithmeticgetActiveUniforms(…, UNIFORM_OFFSET) reports offsets 0/16/32/48/64 and a 1056-byte block for both shaders. Reordering two block members fails it.
  • Pixel tests for the cap on both paths — every light below the one under test is black, so only slot 20 can colour the pixel. Reverting MAX_LIGHTS to 8 fails them.
  • Visual gate: 18 example routes captured before/after under a virtual clock, each compared against its own same-code noise floor. Every route at or below its noise. The four that moved were proven to be animation-phase offsets — pixel-identical (0.000%, maxΔ 0) at a shifted clock advance, caused by added startup work pushing the first animated frame across a tick boundary.
  • Context loss/restore covered end-to-end for the lit mesh path; dropping the block re-bind fails 8 tests.

Notes for review

  • Light data is uploaded per draw rather than per pass. That is the fix, not an oversight — bind() is not a per-frame hook. The dirty check keeps it free.
  • Binding point 0 is deliberately occupied. Leaving it free looks safer but isn't: an active uniform block with no buffer bound is INVALID_OPERATION at draw time, which takes the whole program down rather than just the lighting. Blocks are bound eagerly in init() so nothing relies on that safety net.
  • One consequence: because a GLSL block defaults to point 0, the 2D path's explicit bindTo is 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

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
Copilot AI review requested due to automatic review settings August 1, 2026 00:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 std140 uniform blocks + a UniformBlock WebGL2 buffer wrapper; increase MAX_LIGHTS from 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.

@obiot
obiot merged commit 06d3f4f into master Aug 1, 2026
7 checks passed
@obiot
obiot deleted the feat/1552-light-uniform-block branch August 1, 2026 00:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Lighting: uniform buffer objects for light data (raise MAX_LIGHTS, modernize the lit shaders to GLSL ES 3.00)

2 participants