feat(rendering): add Light.* — deterministic 2D light accumulation with hard shadows (#4)
All checks were successful
bootstrap / cfree-fixpoint (push) Successful in 18s
ci / build-and-test (push) Successful in 1m13s
commit-lint / conventional-commits (push) Successful in 5s
docs / build-and-deploy (push) Successful in 18s

A software light pass over the framebuffer, run in a render phase after drawing
the scene: Light.ambient multiplies the scene toward a tint (night/cave mood),
Light.point additively accumulates a radial glow with linear falloff clamped per
channel, and Light.occlude / Light.clear_occluders cast hard shadows by blocking
a light's rays against rectangular occluders. Integer + Q16.16 fixed throughout,
so a scene lights identically every run and in a headless render (diffable).

Engine in runtime/native/light.ludic, spliced on demand (g_uses_light) like the
regex/query runtimes; namespace wired in emit_call.ludic. Ships issue #4 tiers 1
(ambient + additive radial lights) and 2 (hard shadows). Normal-mapped sprites,
soft shadows, a day/night directional light, and auto-consuming Light2D/Occluder
components are follow-ups (the auto-system hook is tracked by #43).

- runtime/native/light.ludic: the light-accumulation engine (isqrt falloff,
  segment/occluder shadow test, ambient modulate)
- examples/library/lighting.ludic: 14 pixel-readback assertions
- docs/language/light/: Light.ambient/point/occlude/clear_occluders
- tools/x/test.ludic: lighting.ludic in the regression suite

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-08-31 14:07:19 +03:00
parent 31cfbc2465
commit a71279a7b9
12 changed files with 15747 additions and 14968 deletions

View file

@ -0,0 +1,7 @@
---
id: light
title: Light
order: 33
---
A software 2D light-accumulation pass over the framebuffer, run in a render phase after drawing the scene and before <a href="screen-show"><code>Screen.show</code></a>. <a href="light-ambient"><code>Light.ambient</code></a> multiplies the whole scene toward a tint — the night/cave modulate that darkens everything so lights add mood back on top. <a href="light-point"><code>Light.point</code></a> accumulates a radial glow that falls off with distance and clamps per channel, and <a href="light-occlude"><code>Light.occlude</code></a> registers rectangles that block a light's rays to cast hard shadows (cleared each frame with <a href="light-clear_occluders"><code>Light.clear_occluders</code></a>). Lighting is a rendering concern only — it never touches game state or replays — and it is fully deterministic (integer and Q16.16 fixed), so the same scene lights identically every run and in a headless render, keeping screenshots diffable. Colours are <code>0x00RRGGBB</code>. This is the imperative surface; consuming <code>Light2D</code>/<code>Occluder</code> components automatically, normal-mapped sprites and soft shadows are planned follow-ups. Related: <a href="screen"><code>Screen</code></a>, <a href="color"><code>Color</code></a>, <a href="camera"><code>Camera</code></a>.

View file

@ -0,0 +1,30 @@
---
id: light-ambient
name: Light.ambient
category: light
kind: namespace-method
tokens: Light.ambient
sig: Light.ambient(color)
tip: Multiply the whole scene by a tint — the night/cave modulate.
order: 1
ns: Light
member: ambient
---
Multiplies every framebuffer pixel by <code>color</code> (a <code>0x00RRGGBB</code> tint), channel by channel (<code>channel * tint / 255</code>). This is the global modulate that sets mood before any light adds brightness back: a dim blue-grey gives night, a warm brown gives a cave, and <code>0xFFFFFF</code> is a no-op. Call it once, after drawing the scene and before the <a href="light-point"><code>Light.point</code></a> calls that light it back up.
Parameters:
- `color` — the ambient tint, `0x00RRGGBB` (darker/cooler = a darker scene)
```ludic
program Demo {
property Tag { v: int = 0 }
model Marker { Tag }
handler Render phase Update {
Screen.clear(Color.rgb(120, 120, 140))
Light.ambient(Color.rgb(48, 48, 72)) # dusk: dim and cool
Light.point(160, 120, 90, Color.Amber, 1.0)
Screen.show()
}
}
```

View file

@ -0,0 +1,30 @@
---
id: light-clear_occluders
name: Light.clear_occluders
category: light
kind: namespace-method
tokens: Light.clear_occluders
sig: Light.clear_occluders()
tip: Forget every occluder — call once per frame before re-registering.
order: 4
ns: Light
member: clear_occluders
---
Forgets every rectangle previously passed to <a href="light-occlude"><code>Light.occlude</code></a>. Occluders accumulate across calls and persist between frames, so a game that casts shadows calls this once at the top of its render phase and then re-registers the walls that should block light this frame — the same clear-then-rebuild rhythm as an immediate-mode draw list. With no occluders registered, <a href="light-point"><code>Light.point</code></a> lights its whole radius unobstructed.
Parameters: none.
```ludic
program Demo {
property Tag { v: int = 0 }
model Marker { Tag }
handler Render phase Update {
Screen.clear(0)
Light.clear_occluders() # start the frame with no shadows
Light.occlude(90, 60, 6, 40)
Light.point(40, 80, 100, Color.rgb(255, 255, 255), 1.0)
Screen.show()
}
}
```

View file

@ -0,0 +1,32 @@
---
id: light-occlude
name: Light.occlude
category: light
kind: namespace-method
tokens: Light.occlude
sig: Light.occlude(x, y, width, height)
tip: Register a rectangle that blocks light — a hard shadow caster.
order: 3
ns: Light
member: occlude
---
Registers an axis-aligned rectangle (screen space) that blocks light: for every <a href="light-point"><code>Light.point</code></a> that follows, any pixel whose ray from the light centre crosses this rectangle — or lies inside it — is left in shadow, carving a hard umbra behind the wall. Register the geometry that should cast shadows this frame, then draw the lights. Occluders persist until <a href="light-clear_occluders"><code>Light.clear_occluders</code></a>, so call that once per frame first; up to 64 occluders are kept.
Parameters:
- `x`, `y` — the top-left corner, in screen pixels
- `width`, `height` — the rectangle size in pixels
```ludic
program Demo {
property Wall { x: int = 0, y: int = 0 }
model Block { Wall }
handler Render phase Update {
Screen.clear(0)
Light.clear_occluders()
Light.occlude(120, 80, 8, 48) # a pillar
Light.point(60, 100, 120, Color.rgb(255, 240, 200), 1.0)
Screen.show()
}
}
```

View file

@ -0,0 +1,33 @@
---
id: light-point
name: Light.point
category: light
kind: namespace-method
tokens: Light.point
sig: Light.point(x, y, radius, color, energy)
tip: Add a radial glow that falls off with distance.
order: 2
ns: Light
member: point
---
Additively accumulates a radial point light centred at <code>(x, y)</code>. Brightness is full at the centre and falls off linearly to zero at <code>radius</code> pixels, scaled by <code>energy</code> (a <code>fixed</code> multiplier — <code>1.0</code> is full, <code>2.0</code> over-drives toward white, <code>0.5</code> is dim), and clamped per channel at 255 so overlapping lights add without wrapping. Only the light's bounding box is touched, and any <a href="light-occlude"><code>Light.occlude</code></a> rectangle between the centre and a pixel puts that pixel in shadow. Colours are <code>0x00RRGGBB</code>.
Parameters:
- `x`, `y` — the light centre, in screen pixels
- `radius` — the reach in pixels; brightness is zero at and beyond it
- `color` — the light colour, `0x00RRGGBB`
- `energy` — a `fixed` brightness multiplier (`1.0` = full)
```ludic
program Demo {
property Torch { x: int = 0, y: int = 0 }
model Lamp { Torch }
handler Render phase Update {
Screen.clear(0)
Light.ambient(Color.rgb(32, 32, 48))
Light.point(80, 60, 70, Color.rgb(255, 210, 130), 1.0) # a warm torch
Screen.show()
}
}
```