2D specular highlights, and lit stops lying under a Camera2d (#1576) - #1683
Merged
Merged
Conversation
Three pieces that close out the lighting scope question. **Sprite.shininess** gives a normal-mapped sprite a specular highlight: one that appears only where a texel reflects a light back at the screen, so it slides across the surface as the light moves rather than the whole sprite brightening. Gated on the exponent exactly as MTL `Ns` is, so 0 is matte and every existing sprite is bit-identical. The normal map's alpha masks it per texel, which costs nothing — that sample is already taken, and normal maps upload with premultiply off so the channel survives. Carried per quad on a vertex attribute rather than a uniform array indexed by the normal-map slot: uniform arrays are charged against `MAX_FRAGMENT_UNIFORM_VECTORS`, the scarcity that drove the light data into a UBO, and a per-slot value would make two sprites sharing one normal map at different exponents fight over it. WebGL widens its lit layout to 36 bytes; WebGPU registers its OWN `litQuad` layout rather than widening the shared frozen quad one, so unlit sprites pay nothing either side. The highlight is multiplied by the sprite's own alpha. The pipeline is premultiplied, so the diffuse term self-cancels where a sprite is transparent but an ADDED highlight does not — and a normal map is typically opaque even where its diffuse is cut away (every shipped SpriteIlluminator export is 100% opaque against a 55%-transparent diffuse). Without the weight, a shiny sprite paints an additive glow across its own cut-out. **`lit: true` under a `Camera2d`** warns once instead of silently doing nothing (#1576). Lighting a mesh needs world-space normals and a world-space fragment position; that path has neither, since its vertices are already the projected output. Suppressed when the mesh's own camera is 3D, so a Camera3d-main/Camera2d-minimap scene is not nagged about a mesh that really is lit. **The normal-map example** demonstrates it: three orbs across the exponent range, one half-polished and half-worn through the alpha mask, a second orbiting coloured light so each highlight is visibly per-light, and a procedural stone wall showing what the same lighting does with flat geometry. Orb textures are supersampled 2x and the colour falloff tightened, since the canvas upscales ~1.8x. Review caught two defects before this landed: the missing alpha weight above, and an inverted green channel in the wall's normal map (image Y grows down, normal maps encode Y up) which rendered its relief inside-out. It also found two assertions that could not fail — the one-shot warning test was reading a latch already consumed by earlier tests in the file, now isolated with a fresh module registry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
The test drove the hide tween in a loop bounded by `loaded.length === 0`, but with the game loop running the load is deferred through a timer — so `loaded` is still empty on the next iteration, the tween fires `onComplete` again, and a second and third load queue up behind the first. It failed roughly one run in three, and took out CI on #1683. One tick past the duration completes the tween outright, then wait for the deferred load without touching it again. Ten consecutive runs green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
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.
Three pieces that close out the lighting scope question.
Sprite.shininess— specular highlights on 2D spritesA normal map gives a sprite shape: the light knows which way each texel faces.
shininessdecides whether it also shines — a highlight appears only where a texel reflects a light back at the screen, so it slides across the surface as the light moves, rather than the whole sprite merely brightening.Gated on the exponent exactly as MTL
Nsis, so0is matte and every existing sprite is bit-identical. The normal map's alpha channel masks it per texel, which costs nothing: that sample is already taken, and normal maps upload with premultiply off so the channel survives intact.Per quad on a vertex attribute, not a uniform array indexed by the normal-map slot. Uniform arrays are charged against
MAX_FRAGMENT_UNIFORM_VECTORS— the scarcity that drove the light data into a UBO — and a per-slot value would make two sprites sharing one normal map at different exponents fight over it. WebGL widens its lit layout 32→36 bytes; WebGPU registers its ownlitQuadlayout rather than widening the shared frozen quad one, so unlit sprites pay nothing on either backend.The highlight is weighted by the sprite's own alpha. The pipeline is premultiplied, so the diffuse term self-cancels where a sprite is transparent but an added highlight does not — and a normal map is typically opaque even where its diffuse is cut away. The shipped SpriteIlluminator assets are exactly that shape: normal map 100% opaque, diffuse 55% transparent. Without the weight, a shiny sprite paints an additive glow across its own cut-out.
lit: trueunder aCamera2dwarns instead of silently doing nothingCloses #1576. Lighting a mesh needs world-space normals and a world-space fragment position; the accumulated path has neither, since its vertices are already the projected output. The mesh degrades to unlit and now says so once, naming
Camera3d.Deliberately not the full implementation the issue proposed.
vWorldPoshas four consumers there, not the two it names — the specular half-vector and the normal-map tangent frame read it too — so lighting that path would trade an honestly-unlit render for a plausible-looking wrong one. Reasoning is on the issue.Suppressed when the mesh's own camera is 3D, so a Camera3d-main/Camera2d-minimap scene is not nagged about a mesh that really is lit.
The
normal-mapexample demonstrates itThree orbs across the exponent range; one half-polished and half-worn through the alpha mask; a second orbiting coloured light so each highlight is visibly per-light; and a procedural stone wall showing what the same lighting does with flat geometry rather than spheres. Orb textures supersampled 2x and the colour falloff tightened, since the canvas upscales ~1.8x.
Review found two defects before this landed
It also found two assertions that could not fail: the one-shot warning test was reading a latch already consumed by earlier tests in the same file. Rewritten with a fresh module registry, and verified red both when the
litgate is removed and when the warning is deleted.Verification
npm run docbuilds.Docs
Light2dhad no JSDoc examples at all; it now has four covering the complete enable path, plus examples onilluminationOnly,lightHeight,intensityandcolor.Sprite.normalMapand bothStageambient knobs likewise. Gallery links added across the lighting surface. The lighting skill documents per-light accumulation and the alpha-mask corollary.🤖 Generated with Claude Code
https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t