Skip to content

2D specular highlights, and lit stops lying under a Camera2d (#1576) - #1683

Merged
obiot merged 2 commits into
masterfrom
feat/2d-specular-and-lit-camera2d-warning
Sep 18, 2026
Merged

obiot merged 2 commits into
masterfrom
feat/2d-specular-and-lit-camera2d-warning

Conversation

@obiot

@obiot obiot commented Sep 18, 2026

Copy link
Copy Markdown
Member

Three pieces that close out the lighting scope question.

Sprite.shininess — specular highlights on 2D sprites

A normal map gives a sprite shape: the light knows which way each texel faces. shininess decides 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 Ns is, so 0 is 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 own litQuad layout 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: true under a Camera2d warns instead of silently doing nothing

Closes #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. vWorldPos has 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-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 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

  • The missing alpha weight above.
  • 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 same file. Rewritten with a fresh module registry, and verified red both when the lit gate is removed and when the warning is deleted.

Verification

  • 7026 tests, lint and biome clean, engine + examples typecheck clean, npm run doc builds.
  • Both backends on every lit example, plus the full 41-route gallery swept on WebGPU and WebGL2 with no console errors.
  • Each new assertion verified to fail with its fix reverted.

Docs

Light2d had no JSDoc examples at all; it now has four covering the complete enable path, plus examples on illuminationOnly, lightHeight, intensity and color. Sprite.normalMap and both Stage ambient 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

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
Copilot AI lite review requested due to automatic review settings September 18, 2026 07:08

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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
Copilot AI review requested due to automatic review settings September 18, 2026 07: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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@obiot
obiot merged commit 8bab160 into master Sep 18, 2026
6 checks passed
@obiot
obiot deleted the feat/2d-specular-and-lit-camera2d-warning branch September 18, 2026 07:22
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.

Lit meshes under a Camera2d: populate normals so directional/ambient lighting works

2 participants