feat(light): render-quality tiers 3-4 — cones, falloff, soft shadows, gels, normals, day/night (#49)
All checks were successful
bootstrap / cfree-fixpoint (push) Successful in 18s
ci / build-and-test (push) Successful in 1m21s
commit-lint / conventional-commits (push) Successful in 4s
docs / build-and-deploy (push) Successful in 19s

The remaining lighting tiers from the original proposal, all extending the
deterministic accumulation core (light.ludic) — no new ECS plumbing:

- Light.spot: cone / flashlight lights (direction + spread degrees), with a
  self-contained integer atan2-in-degrees and a feathered edge.
- Light.falloff: a brightness-ramp exponent (1 linear, 2 quadratic, …) via
  repeated fixed multiply.
- Light.soft: soft shadows — an area-sampled light so an occluder edge fades
  through a penumbra instead of a hard cut.
- Light.gel + Light.clear_gel: colour cookies — a light gels from its centre
  colour to a rim colour.
- Light.normal + Light.clear_normals + Light.height: a normal G-buffer so
  surfaces shade by facing (N·L), not distance alone (tier 3).
- Light.time_of_day: a day/night ambient ramp from a single 0..1 value.

The engine lighting system (systems_light.ludic) consumes matching optional
Light2D fields — direction/spread/falloff/softness/gel — each defaulting off so
an older five-field Light2D lights exactly as before. Every tier is integer +
Q16.16 fixed, so scenes light identically on every run and headless.

Worked example + regression: examples/library/light_tiers.ludic (1 1 1 1 1 1 1 1 1).
Nine new docs/language/light pages. Full suite 76 passed, self-host C-free
fixpoint intact, no golden drift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-08-31 16:35:41 +03:00
parent 377b6d1186
commit 382826889f
18 changed files with 7752 additions and 6502 deletions

View file

@ -4,4 +4,4 @@ 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>.
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>. Beyond the radial core, the pass carries the render-quality tiers: <a href="light-spot"><code>Light.spot</code></a> cones, a <a href="light-falloff"><code>Light.falloff</code></a> exponent, <a href="light-soft"><code>Light.soft</code></a> shadows (penumbra), <a href="light-gel"><code>Light.gel</code></a> colour cookies, normal-mapped surfaces (<a href="light-normal"><code>Light.normal</code></a> + <a href="light-height"><code>Light.height</code></a>) that shade by facing, and a <a href="light-time_of_day"><code>Light.time_of_day</code></a> day/night ramp — every one deterministic. This is the imperative surface; the engine also consumes <code>Light2D</code>/<code>Occluder</code> components automatically (a Light2D may carry optional <code>direction</code>/<code>spread</code>/<code>falloff</code>/<code>softness</code>/<code>gel</code> fields). 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-clear_gel
name: Light.clear_gel
category: light
kind: namespace-method
tokens: Light.clear_gel
sig: Light.clear_gel()
tip: Clear the gel — later lights are a flat single colour again.
order: 9
ns: Light
member: clear_gel
---
Clears any <a href="light-gel"><code>Light.gel</code></a> set earlier, so lights emitted after it are a flat single colour again (no centre-to-rim tint). Pair it with <code>Light.gel</code> to scope a colour cookie to just one light.
```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(24, 24, 32))
Light.gel(Color.rgb(40, 60, 160))
Light.point(60, 60, 60, Color.rgb(255, 200, 120), 1.0) # gelled
Light.clear_gel()
Light.point(150, 60, 60, Color.rgb(255, 240, 200), 1.0) # flat again
Screen.show()
}
}
```

View file

@ -0,0 +1,28 @@
---
id: light-clear_normals
name: Light.clear_normals
category: light
kind: namespace-method
tokens: Light.clear_normals
sig: Light.clear_normals()
tip: Forget every stamped normal — call once per frame before re-stamping.
order: 11
ns: Light
member: clear_normals
---
Clears the normal buffer set by <a href="light-normal"><code>Light.normal</code></a>, so every pixel is flat again (lit by distance only). Call it once per frame before re-stamping the surfaces that should shade by facing this frame — the same clear-then-register rhythm as <a href="light-clear_occluders"><code>Light.clear_occluders</code></a>. A no-op if no normal was ever stamped.
```ludic
program Demo {
property Wall { x: int = 0, y: int = 0 }
model Block { Wall }
handler Render phase Update {
Screen.clear(0)
Light.clear_normals()
Light.normal(60, 50, 16, 24, 0.7, 0.0)
Light.point(120, 62, 90, Color.rgb(255, 240, 200), 1.0)
Screen.show()
}
}
```

View file

@ -0,0 +1,31 @@
---
id: light-falloff
name: Light.falloff
category: light
kind: namespace-method
tokens: Light.falloff
sig: Light.falloff(exponent)
tip: Set the brightness-ramp exponent for later lights (1 linear, 2 quadratic…).
order: 6
ns: Light
member: falloff
---
Sets the **falloff curve** applied to every light emitted after it. A light's brightness ramps from full at the centre to zero at its radius; the ramp is raised to <code>exponent</code>, so <code>1</code> is the original linear falloff, <code>2</code> is quadratic (a softer, more concentrated core), <code>3</code> cubic, and so on. Like the occluder store, the setting persists across frames until changed — a game sets it once, or per light, and it rides alongside the short <a href="light-point"><code>Light.point</code></a> / <a href="light-spot"><code>Light.spot</code></a> signatures. Integer exponent, Q16.16 ramp — deterministic.
Parameters:
- `exponent` — the ramp power, an integer `>= 1` (`1` = linear, the default)
```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(24, 24, 32))
Light.falloff(2) # a tighter, quadratic glow
Light.point(80, 60, 70, Color.rgb(255, 210, 130), 1.0)
Screen.show()
}
}
```

View file

@ -0,0 +1,32 @@
---
id: light-gel
name: Light.gel
category: light
kind: namespace-method
tokens: Light.gel
sig: Light.gel(color)
tip: A colour cookie — later lights gel from their centre colour to this rim colour.
order: 8
ns: Light
member: gel
---
Sets a **gel** (a colour cookie) for lights emitted after it: the light blends from its own <code>color</code> at the centre to this <code>color</code> at the rim, so a single light can carry a two-tone tint — a warm core cooling to a blue edge, a fire that reddens outward. Clear it with <a href="light-clear_gel"><code>Light.clear_gel</code></a> to return to a flat single-colour light. The gel rides on both <a href="light-point"><code>Light.point</code></a> and <a href="light-spot"><code>Light.spot</code></a>, and persists until cleared. Per-channel Q16.16 interpolation — deterministic.
Parameters:
- `color` — the rim colour, `0x00RRGGBB`
```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(24, 24, 32))
Light.gel(Color.rgb(40, 60, 160)) # cool blue rim
Light.point(80, 60, 70, Color.rgb(255, 200, 120), 1.0) # warm core
Light.clear_gel()
Screen.show()
}
}
```

View file

@ -0,0 +1,32 @@
---
id: light-height
name: Light.height
category: light
kind: namespace-method
tokens: Light.height
sig: Light.height(height)
tip: Set the virtual height of lights above the surface, for normal-map shading.
order: 12
ns: Light
member: height
---
Sets how far above the surface plane lights sit, in pixels — the <code>z</code> distance used when shading <a href="light-normal"><code>normal-mapped</code></a> surfaces. A low height makes light rays graze the surface, so tilted faces contrast sharply; a high height lights everything more head-on. It only affects the <code>N·L</code> term, so a scene with no stamped normals is unchanged. The default (<code>64</code>) suits most scenes. Persists until changed.
Parameters:
- `height` — the light's height above the plane in pixels (`> 0`)
```ludic
program Demo {
property Wall { x: int = 0, y: int = 0 }
model Block { Wall }
handler Render phase Update {
Screen.clear(0)
Light.clear_normals()
Light.height(24) # low, grazing light
Light.normal(60, 50, 16, 24, 0.7, 0.0)
Light.point(120, 62, 90, Color.rgb(255, 240, 200), 1.0)
Screen.show()
}
}
```

View file

@ -0,0 +1,33 @@
---
id: light-normal
name: Light.normal
category: light
kind: namespace-method
tokens: Light.normal
sig: Light.normal(x, y, width, height, nx, ny)
tip: Stamp a surface normal over a rectangle so lights shade it by facing (N·L).
order: 10
ns: Light
member: normal
---
Stamps a **surface normal** over a screen rectangle into the light pass's normal buffer. Where a normal is stamped, a light shades the surface by the angle it faces — the Lambert term <code>N·L</code> — not by distance alone: a wall facing a torch is bright, one turned away is dim, giving flat sprites a sense of relief (tier 3). <code>nx</code> and <code>ny</code> are the normal's x/y as a <code>fixed</code> in <code>[-1, 1]</code> (a surface facing straight at the camera is <code>0, 0</code>); the z component is derived. Unstamped pixels are unaffected, and the buffer is allocated only when you stamp — clear it each frame with <a href="light-clear_normals"><code>Light.clear_normals</code></a>. Deterministic (Q16.16 dot product).
Parameters:
- `x`, `y`, `width`, `height` — the screen rectangle to stamp
- `nx`, `ny` — the surface normal's x/y, a `fixed` in `[-1, 1]`
```ludic
program Demo {
property Wall { x: int = 0, y: int = 0 }
model Block { Wall }
handler Render phase Update {
Screen.clear(0)
Light.ambient(Color.rgb(24, 24, 32))
Light.clear_normals()
Light.normal(60, 50, 16, 24, 0.7, 0.0) # this face is tilted rightward
Light.point(120, 62, 90, Color.rgb(255, 240, 200), 1.0)
Screen.show()
}
}
```

View file

@ -0,0 +1,33 @@
---
id: light-soft
name: Light.soft
category: light
kind: namespace-method
tokens: Light.soft
sig: Light.soft(radius)
tip: Soft shadows — an occluder edge fades through a penumbra of this radius.
order: 7
ns: Light
member: soft
---
Turns on **soft shadows** for lights emitted after it. A hard shadow tests a single ray from the light centre to each pixel, so an <a href="light-occlude"><code>occluder</code></a> edge cuts sharply. With <code>radius > 0</code> the light is treated as a small disk of that radius: each pixel samples visibility from a cross of points across the disk and averages them, so the shadow edge fades through a **penumbra** instead of a hard line. <code>Light.soft(0)</code> restores hard, single-sample shadows. The setting persists until changed. Deterministic (fixed-point average of integer ray tests).
Parameters:
- `radius` — the light's apparent size in pixels; larger = wider penumbra (`0` = hard)
```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(24, 24, 32))
Light.clear_occluders()
Light.occlude(96, 40, 6, 40) # a pillar casting a shadow
Light.soft(5) # 5px penumbra
Light.point(60, 60, 90, Color.rgb(255, 240, 200), 1.0)
Screen.show()
}
}
```

View file

@ -0,0 +1,35 @@
---
id: light-spot
name: Light.spot
category: light
kind: namespace-method
tokens: Light.spot
sig: Light.spot(x, y, radius, color, energy, direction, spread)
tip: A cone / flashlight light aimed at a direction with a half-angle spread.
order: 5
ns: Light
member: spot
---
Accumulates a **cone** (spot) light — like <a href="light-point"><code>Light.point</code></a>, but only the wedge aimed at <code>direction</code> degrees (measured from <code>+x</code>, counter-clockwise) within a half-angle of <code>spread</code> degrees receives light. Pixels outside the cone stay dark, and the last few degrees of the edge feather so the boundary is not a hard line. A spot shares every quality control with a radial light — <a href="light-falloff"><code>Light.falloff</code></a>, <a href="light-soft"><code>Light.soft</code></a>, <a href="light-gel"><code>Light.gel</code></a> and the <a href="light-normal"><code>normal buffer</code></a> all apply. Fully deterministic (integer degrees + Q16.16).
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)
- `direction` — the aim, in whole degrees (`0` = right, `90` = up, `180` = left)
- `spread` — the cone half-angle, in whole degrees (`180` ≈ omnidirectional)
```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(24, 24, 32))
Light.spot(80, 60, 70, Color.rgb(255, 240, 200), 1.0, 0, 30) # a flashlight pointing right
Screen.show()
}
}
```

View file

@ -0,0 +1,30 @@
---
id: light-time_of_day
name: Light.time_of_day
category: light
kind: namespace-method
tokens: Light.time_of_day
sig: Light.time_of_day(t)
tip: Set the ambient tint from a 0..1 time-of-day — a day/night cycle in one value.
order: 13
ns: Light
member: time_of_day
---
Sets the scene's <a href="light-ambient"><code>ambient</code></a> tint from a single **time-of-day** phase <code>t</code> — a <code>fixed</code> in <code>[0, 1]</code> where <code>0</code> is midnight, <code>0.25</code> dawn, <code>0.5</code> noon and <code>0.75</code> dusk. It ramps a deep-blue night up to full daylight and back over the day, so a game animates one value and the whole world's mood follows, without wiring the ambient colour by hand. It drives the same multiplicative modulate as <code>Light.ambient</code>, so call it once before adding lights. Deterministic (Q16.16 triangle ramp).
Parameters:
- `t` — the time of day, a `fixed` in `[0, 1]` (`0` = midnight, `0.5` = noon)
```ludic
program Demo {
property Clock { t: int = 0 }
model World { Clock }
handler Render phase Update {
Screen.clear(Color.rgb(255, 255, 255))
Light.time_of_day(0.5) # high noon — full daylight
Light.point(80, 60, 60, Color.rgb(255, 240, 200), 1.0)
Screen.show()
}
}
```