Summary
lit: true only does anything under a Camera3d. On the 2D-camera (accumulated) path the engine never writes Mesh#normals — the array is documented as "world-space normals for the current draw, recomputed from originalNormals along the Camera3d path" — so the lit batcher pushes zeros and the shader has no surface to shade against.
Until recently that rendered black (normalizing a zero vector gives NaN). That is now guarded: a lit mesh with no usable normals degrades to unlit on both backends, which is recognisably the model rather than a hole. This ticket is about making it actually light.
Why it is not just "turn it on"
1. Normals need a CPU transform on that path. The Camera3d path rotates them on the GPU (mat3(uModelMatrix) * aNormal). The accumulated path has no model matrix at draw time, so normals would have to be rotated per vertex on the CPU. That is acceptable on this path specifically — it already CPU-projects every vertex, so it fits its own cost model rather than adding a new penalty. It is precisely the work #1507's retained path exists to avoid, which is presumably why it was never wired up.
2. Positional lights cannot work as-is — only directional and ambient. Lighting is evaluated in world space: the fragment stage compares vWorldPos against each light's world position for point/spot falloff. Under a 2D camera the vertices are the projected output, so vWorldPos would carry screen-ish coordinates and a point light's range would attenuate against projected pixel distances. That is not merely approximate, it is meaningless — and worse, it would look plausible.
Proposal
- Rotate
originalNormals by the mesh's model rotation into normals on the accumulated path, so directional + ambient shading works.
- Make point and spot lights explicitly not apply there — ignored with a one-shot warning naming the camera, rather than silently shading against a bogus position.
- Optionally, later: thread a genuine world-space position through as a separate varying to lift that restriction, at the cost of another per-vertex attribute on a path that is already CPU-bound. Worth measuring before committing to it.
Acceptance
- An OBJ or raw-geometry mesh with
lit: true under a Camera2d shades from a Light3d directional light plus ambient, and matches the Camera3d result for the same orientation.
- A point/spot light warns once and does not contribute, instead of producing a wrong result.
- A mesh with no normals at all still renders unlit (the current guard) rather than black.
References
src/renderable/mesh.js (originalNormals / normals, _projectVertices), src/video/webgl/batchers/lit_mesh_batcher.js (_pushVertex reads mesh.normals), src/video/webgl/shaders/mesh-lit.frag and src/video/webgpu/shaders/mesh-lit.wgsl (the zero-normal guard). Related: #1572 (OBJ normals), #1507 (retained path).
Summary
lit: trueonly does anything under aCamera3d. On the 2D-camera (accumulated) path the engine never writesMesh#normals— the array is documented as "world-space normals for the current draw, recomputed fromoriginalNormalsalong the Camera3d path" — so the lit batcher pushes zeros and the shader has no surface to shade against.Until recently that rendered black (normalizing a zero vector gives NaN). That is now guarded: a lit mesh with no usable normals degrades to unlit on both backends, which is recognisably the model rather than a hole. This ticket is about making it actually light.
Why it is not just "turn it on"
1. Normals need a CPU transform on that path. The Camera3d path rotates them on the GPU (
mat3(uModelMatrix) * aNormal). The accumulated path has no model matrix at draw time, so normals would have to be rotated per vertex on the CPU. That is acceptable on this path specifically — it already CPU-projects every vertex, so it fits its own cost model rather than adding a new penalty. It is precisely the work #1507's retained path exists to avoid, which is presumably why it was never wired up.2. Positional lights cannot work as-is — only directional and ambient. Lighting is evaluated in world space: the fragment stage compares
vWorldPosagainst each light's world position for point/spot falloff. Under a 2D camera the vertices are the projected output, sovWorldPoswould carry screen-ish coordinates and a point light'srangewould attenuate against projected pixel distances. That is not merely approximate, it is meaningless — and worse, it would look plausible.Proposal
originalNormalsby the mesh's model rotation intonormalson the accumulated path, so directional + ambient shading works.Acceptance
lit: trueunder aCamera2dshades from aLight3ddirectional light plus ambient, and matches the Camera3d result for the same orientation.References
src/renderable/mesh.js(originalNormals/normals,_projectVertices),src/video/webgl/batchers/lit_mesh_batcher.js(_pushVertexreadsmesh.normals),src/video/webgl/shaders/mesh-lit.fragandsrc/video/webgpu/shaders/mesh-lit.wgsl(the zero-normal guard). Related: #1572 (OBJ normals), #1507 (retained path).