feat(light): render-quality tiers 3-4 — cones, falloff, soft shadows, gels, normals, day/night (#49)
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:
parent
377b6d1186
commit
382826889f
18 changed files with 7752 additions and 6502 deletions
|
|
@ -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>.
|
||||
|
|
|
|||
30
docs/language/light/light-clear_gel.md
Normal file
30
docs/language/light/light-clear_gel.md
Normal 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()
|
||||
}
|
||||
}
|
||||
```
|
||||
28
docs/language/light/light-clear_normals.md
Normal file
28
docs/language/light/light-clear_normals.md
Normal 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()
|
||||
}
|
||||
}
|
||||
```
|
||||
31
docs/language/light/light-falloff.md
Normal file
31
docs/language/light/light-falloff.md
Normal 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()
|
||||
}
|
||||
}
|
||||
```
|
||||
32
docs/language/light/light-gel.md
Normal file
32
docs/language/light/light-gel.md
Normal 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()
|
||||
}
|
||||
}
|
||||
```
|
||||
32
docs/language/light/light-height.md
Normal file
32
docs/language/light/light-height.md
Normal 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()
|
||||
}
|
||||
}
|
||||
```
|
||||
33
docs/language/light/light-normal.md
Normal file
33
docs/language/light/light-normal.md
Normal 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()
|
||||
}
|
||||
}
|
||||
```
|
||||
33
docs/language/light/light-soft.md
Normal file
33
docs/language/light/light-soft.md
Normal 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()
|
||||
}
|
||||
}
|
||||
```
|
||||
35
docs/language/light/light-spot.md
Normal file
35
docs/language/light/light-spot.md
Normal 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()
|
||||
}
|
||||
}
|
||||
```
|
||||
30
docs/language/light/light-time_of_day.md
Normal file
30
docs/language/light/light-time_of_day.md
Normal 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()
|
||||
}
|
||||
}
|
||||
```
|
||||
Loading…
Add table
Add a link
Reference in a new issue