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
|
|
@ -270,7 +270,7 @@ enough, and a game that declares none is byte-for-byte unchanged.
|
|||
|---|---|---|
|
||||
| `SpriteAnim { ticks, fps, frames, mode, frame }` | `Update` | advances `frame` — spritesheet frame animation (`mode` 0 loop, 1 once, 2 ping-pong) |
|
||||
| `Motion { ticks, dur, from, to, ease, value, done }` | `Update` | advances `value` — value tween (`ease` 0 linear, 1 in, 2 out, 3 in-out), latches `done` |
|
||||
| `Light2D { x, y, radius, color, intensity }` | `Render` | additive radial glow; the engine runs the whole 2D light pass and presents |
|
||||
| `Light2D { x, y, radius, color, intensity }` | `Render` | additive radial glow; the engine runs the whole 2D light pass and presents. Optional `direction`/`spread` (cone), `falloff`, `softness`, `gel` fields select the render-quality tiers |
|
||||
| `Occluder { x, y, w, h }` | `Render` | a rectangular shadow caster the light pass carves out |
|
||||
| `Ambient { color }` | `Render` | one entity tints the whole scene (night/cave) before lights accumulate |
|
||||
|
||||
|
|
@ -286,7 +286,11 @@ A `Light2D` / `Occluder` reads its position from a `Position { x, y }` component
|
|||
on the same entity when the entity carries one, else from its own `x` / `y`
|
||||
fields — so "Position + Light2D" and a self-positioned light both work. With
|
||||
`Light2D` present the engine owns the frame flip: a draw handler renders the
|
||||
scene and does **not** call `Screen.show`.
|
||||
scene and does **not** call `Screen.show`. Beyond the radial core the light pass
|
||||
carries the render-quality tiers — `Light.spot` cones, a `Light.falloff`
|
||||
exponent, `Light.soft` shadows (penumbra), `Light.gel` colour cookies,
|
||||
normal-mapped surfaces (`Light.normal` + `Light.height`), and a
|
||||
`Light.time_of_day` day/night ramp — every one deterministic.
|
||||
|
||||
### Input actions & deterministic replay
|
||||
|
||||
|
|
|
|||
12
changes/light-tiers.md
Normal file
12
changes/light-tiers.md
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
bump: minor
|
||||
type: feat
|
||||
**Lighting render-quality tiers (#49).** The 2D light pass gains the tiers 3-4
|
||||
from the original proposal, all on the same deterministic accumulation core:
|
||||
`Light.spot` cone/flashlight lights (direction + spread), a `Light.falloff`
|
||||
exponent (linear → quadratic → …), `Light.soft` soft shadows (occluder edges
|
||||
fade through a penumbra), `Light.gel` colour cookies (centre → rim tint),
|
||||
normal-mapped surfaces via `Light.normal` + `Light.height` (surfaces shade by
|
||||
facing, N·L), and a `Light.time_of_day` day/night ambient ramp. The engine
|
||||
lighting system consumes matching optional `Light2D` fields
|
||||
(`direction`/`spread`/`falloff`/`softness`/`gel`) too. Every tier is integer +
|
||||
Q16.16 fixed, so scenes light identically on every run and headless.
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
```
|
||||
91
examples/library/light_tiers.ludic
Normal file
91
examples/library/light_tiers.ludic
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
# light_tiers.ludic — the lighting render-quality tiers from #49, all riding on
|
||||
# the same deterministic accumulation core (integer + Q16.16 fixed):
|
||||
#
|
||||
# * Light.spot — a cone / flashlight (direction + spread degrees)
|
||||
# * Light.falloff — the brightness-ramp exponent (linear → quadratic → …)
|
||||
# * Light.soft — soft shadows: an occluder edge fades through a penumbra
|
||||
# * Light.gel — a colour cookie: the light gels centre → rim
|
||||
# * Light.normal — normal-mapped surfaces shade by facing (N·L), not distance
|
||||
# * Light.time_of_day — a day/night ambient ramp from one 0..1 value
|
||||
#
|
||||
# Additive blocks light a *black* scene (so brightness is the light itself, not a
|
||||
# saturated white); the day/night block modulates a *white* scene (ambient is
|
||||
# multiplicative). Deterministic, so:
|
||||
# bin/ludic examples/library/light_tiers.ludic -> 1 1 1 1 1 1 1 1 1
|
||||
program LightTiers {
|
||||
property Marker { n: int = 0 } # a component makes this a game (links the runtime)
|
||||
model M { Marker }
|
||||
|
||||
function dark() -> void { Screen.clear(0) }
|
||||
function white() -> void { Screen.fill_rectangle(0, 0, 64, 64, 16777215) }
|
||||
function bit(b: bool) -> int { if b { return 1 }; return 0 }
|
||||
function lum(x: int, y: int) -> int { return Screen.pixel(x, y) & 255 }
|
||||
|
||||
entry {
|
||||
# --- 1. spot / cone: aimed right (0°), a narrow 25° cone -----------------
|
||||
# a pixel to the right is inside the cone (lit); one straight up is outside.
|
||||
dark()
|
||||
Light.spot(32, 32, 24, 16777215, 1.0, 0, 25)
|
||||
print(bit(lum(44, 32) > 0)) # 1 — right of centre, inside the cone
|
||||
print(bit(lum(32, 14) == 0)) # 1 — above centre, outside the cone (dark)
|
||||
|
||||
# --- 2. falloff exponent: quadratic dims the mid-radius vs linear ---------
|
||||
dark()
|
||||
Light.falloff(1)
|
||||
Light.point(32, 32, 24, 16777215, 1.0)
|
||||
let lin = lum(44, 32) # linear brightness a little off-centre
|
||||
dark()
|
||||
Light.falloff(2)
|
||||
Light.point(32, 32, 24, 16777215, 1.0)
|
||||
let quad = lum(44, 32) # quadratic: same spot, dimmer
|
||||
Light.falloff(1) # reset for later blocks
|
||||
print(bit(quad < lin)) # 1 — a steeper curve is darker mid-radius
|
||||
|
||||
# --- 3. soft shadows: a penumbra pixel is partly lit where a hard edge cuts
|
||||
dark()
|
||||
Light.clear_occluders()
|
||||
Light.occlude(38, 22, 3, 12) # a pillar right of the torch
|
||||
Light.point(32, 32, 28, 16777215, 1.0)
|
||||
let hard = lum(46, 34) # hard shadow at this pixel → fully dark
|
||||
dark()
|
||||
Light.soft(5) # 5px penumbra
|
||||
Light.point(32, 32, 28, 16777215, 1.0)
|
||||
let soft = lum(46, 34) # same pixel, now inside the penumbra
|
||||
Light.soft(0) # back to hard shadows
|
||||
Light.clear_occluders()
|
||||
print(bit(hard == 0)) # 1 — a hard shadow cuts the ray fully
|
||||
print(bit(soft > 0)) # 1 — the penumbra lets partial light through
|
||||
|
||||
# --- 4. gel / colour cookie: rim tints toward the outer colour -----------
|
||||
dark()
|
||||
Light.gel(255) # rim gels to pure blue (0x0000FF)
|
||||
Light.point(32, 32, 24, 16711680, 1.0) # inner is pure red (0xFF0000)
|
||||
let center_b = Screen.pixel(34, 32) & 255 # blue near the centre → low
|
||||
let rim_b = Screen.pixel(50, 32) & 255 # blue near the rim → high
|
||||
Light.clear_gel()
|
||||
print(bit(rim_b > center_b)) # 1 — the gel tints the rim bluer than the core
|
||||
|
||||
# --- 5. normal mapping: a face tilted toward the light is brighter --------
|
||||
dark()
|
||||
Light.clear_normals()
|
||||
Light.normal(18, 30, 8, 8, 0.7, 0.0) # this patch faces right (toward the light)
|
||||
Light.normal(46, 30, 8, 8, 0.0 - 0.7, 0.0) # this patch faces left (away)
|
||||
Light.point(32, 34, 44, 16777215, 1.0) # a light centred between the patches
|
||||
let toward = lum(22, 34) # patch facing the light
|
||||
let away = lum(48, 34) # patch facing away
|
||||
Light.clear_normals()
|
||||
print(bit(toward > away)) # 1 — N·L brightens the facing surface
|
||||
|
||||
# --- 6. time of day: midnight is dark, noon is bright (ambient modulate) --
|
||||
white()
|
||||
Light.time_of_day(0.0) # midnight
|
||||
let night = lum(10, 10)
|
||||
white()
|
||||
Light.time_of_day(0.5) # noon
|
||||
let noon = lum(10, 10)
|
||||
print(bit(noon > night)) # 1 — daylight lifts the ambient
|
||||
print(bit(night < 60)) # 1 — midnight stays dim
|
||||
|
||||
quit()
|
||||
}
|
||||
}
|
||||
|
|
@ -19,13 +19,15 @@
|
|||
# ludicc splices this file into a game via core.ludic (it reads/writes the
|
||||
# framebuffer), so it links only where the renderer does.
|
||||
#
|
||||
# Tiers shipped here: (1) ambient modulate + additive radial point lights, and
|
||||
# (2) hard shadows — a point light is blocked along any segment that crosses a
|
||||
# registered rectangular occluder. Normal-mapped sprites, soft shadows and a
|
||||
# day/night directional light (issue #4 tiers 3-4), and consuming Light2D /
|
||||
# Occluder *components* automatically, are follow-ups: the auto-consumption
|
||||
# needs an engine-owned system over user components, the same ECS hook issue #43
|
||||
# tracks. Until then Light.* is the imperative escape hatch the proposal names.
|
||||
# Tiers shipped here (issue #4 + #49): (1) ambient modulate + additive radial
|
||||
# point lights; (2) hard shadows — a light is blocked along any segment crossing
|
||||
# a registered rectangular occluder; (3) cone/spot lights (light_spot: direction +
|
||||
# spread), a falloff-curve exponent, soft shadows (a penumbra from area sampling),
|
||||
# colour-cookie gels (centre → rim), normal-mapped surfaces (light_normal_rect +
|
||||
# an N·L buffer), and a day/night ambient ramp (light_time_of_day). Every tier is
|
||||
# integer + Q16.16 fixed, so a scene lights identically on every run and headless.
|
||||
# The engine also consumes Light2D / Occluder *components* automatically
|
||||
# (systems_light.ludic); Light.* is the imperative surface the proposal names.
|
||||
# ============================================================================
|
||||
|
||||
# ---- occluder store -------------------------------------------------------
|
||||
|
|
@ -118,6 +120,167 @@ function light_isqrt(n: int) -> int {
|
|||
return x
|
||||
}
|
||||
|
||||
# sqrt of a Q16.16 fixed in [0, ~1] — isqrt(raw) << 8, since sqrt(v/2^16)*2^16 =
|
||||
# sqrt(v)*2^8. Used by the normal-map N·L (unit vectors), where inputs are <= 1.
|
||||
function light_fsqrt(v: fixed) -> fixed {
|
||||
if v <= 0 { return fixed(0) }
|
||||
return light_isqrt(v) << 8
|
||||
}
|
||||
|
||||
# ---- tier controls (globals) ----------------------------------------------
|
||||
# Quality knobs the light pass reads. They persist across frames like the
|
||||
# occluder store — a game (or the engine ECS system) sets them before emitting a
|
||||
# light and they stay until changed, so the simple Light.point call keeps its
|
||||
# short signature while spot/soft/falloff/gel ride on this side-band state.
|
||||
var rt_light_falloff: int = 1 # brightness ramp exponent: 1 linear, 2 quadratic, 3 cubic…
|
||||
var rt_light_soft: int = 0 # penumbra radius in px (0 = hard single-sample shadow)
|
||||
var rt_light_gel: int = 0 # outer gel colour 0x00RRGGBB (used only when rt_light_gel_on)
|
||||
var rt_light_gel_on: bool = false # gel active? off = a flat single-colour light
|
||||
var rt_light_h: int = 64 # virtual light height above the surface, for normal-map N·L
|
||||
|
||||
function light_set_falloff(exp: int) -> void {
|
||||
if exp < 1 { rt_light_falloff = 1 } else { rt_light_falloff = exp }
|
||||
}
|
||||
function light_set_soft(radius: int) -> void { rt_light_soft = radius }
|
||||
function light_set_gel(outer: int) -> void { rt_light_gel = outer; rt_light_gel_on = true }
|
||||
function light_clear_gel() -> void { rt_light_gel_on = false }
|
||||
function light_set_height(h: int) -> void { if h > 0 { rt_light_h = h } }
|
||||
|
||||
# ---- normal buffer --------------------------------------------------------
|
||||
# An optional per-pixel surface-normal G-buffer, parallel to the framebuffer.
|
||||
# Each entry packs (nx, ny) as two signed bytes biased by 128; 0 means "no normal
|
||||
# here" so an unstamped scene lights exactly as before (factor 1). nz is recovered
|
||||
# from the unit constraint, so a Light2D shades a surface by the angle it faces,
|
||||
# not distance alone (tier 3). Allocated only when a game stamps a normal.
|
||||
var rt_light_nrm: words = null
|
||||
|
||||
function light_nrm_init() -> void {
|
||||
if rt_light_nrm == null { rt_light_nrm = words(rt_fbw * rt_fbh) }
|
||||
}
|
||||
|
||||
# forget every stamped normal — call once per frame before re-stamping surfaces.
|
||||
function light_clear_normals() -> void {
|
||||
if rt_light_nrm == null { return }
|
||||
let n = rt_fbw * rt_fbh
|
||||
var i = 0
|
||||
while i < n { rt_light_nrm[i] = 0; i = i + 1 }
|
||||
}
|
||||
|
||||
# stamp a rectangular region's surface normal. nx, ny are the normal's x/y as a
|
||||
# Q16.16 fixed in [-1, 1] (a flat surface facing the camera is nx = ny = 0); nz is
|
||||
# derived. Screen space, clipped to the framebuffer.
|
||||
function light_normal_rect(x: int, y: int, w: int, h: int, nx: fixed, ny: fixed) -> void {
|
||||
light_nrm_init()
|
||||
var nxq = floor(nx * fixed(127)) + 128
|
||||
var nyq = floor(ny * fixed(127)) + 128
|
||||
if nxq < 0 { nxq = 0 }; if nxq > 255 { nxq = 255 }
|
||||
if nyq < 0 { nyq = 0 }; if nyq > 255 { nyq = 255 }
|
||||
let packed = 65536 | (nyq << 8) | nxq # bit 16 = "stamped" flag (nonzero even at 128,128)
|
||||
var py = y
|
||||
while py < y + h {
|
||||
if (py >= 0) and (py < rt_fbh) {
|
||||
var px = x
|
||||
while px < x + w {
|
||||
if (px >= 0) and (px < rt_fbw) { rt_light_nrm[py * rt_fbw + px] = packed }
|
||||
px = px + 1
|
||||
}
|
||||
}
|
||||
py = py + 1
|
||||
}
|
||||
}
|
||||
|
||||
# ---- angle helpers (integer degrees, self-contained) ----------------------
|
||||
# atan(num/den) in degrees for 0 <= num <= den, 0..45. A minimax cubic
|
||||
# (Q16.16), max error < ~0.25°, so cone edges are smooth without a trig prelude.
|
||||
function light_atan_deg01(num: int, den: int) -> int {
|
||||
if den <= 0 { return 0 }
|
||||
let t = fixed(num) / fixed(den) # 0..1
|
||||
let inner = 0.2447 + 0.0663 * t # 0.2447 + 0.0663 t
|
||||
let rad = 0.785398 * t - t * (t - 1.0) * inner
|
||||
let deg = rad * 57.2957 # 180/pi
|
||||
return floor(deg)
|
||||
}
|
||||
|
||||
# atan2(y, x) in whole degrees, 0..359, measured from +x, counter-clockwise. The
|
||||
# light's coordinate frame is consistent between direction and pixels, so screen
|
||||
# y-down cancels out — a cone points the way its `direction` field says.
|
||||
function light_atan2_deg(y: int, x: int) -> int {
|
||||
if (x == 0) and (y == 0) { return 0 }
|
||||
let ax = abs(x)
|
||||
let ay = abs(y)
|
||||
var a = 0
|
||||
if ax >= ay { a = light_atan_deg01(ay, ax) }
|
||||
else { a = 90 - light_atan_deg01(ax, ay) }
|
||||
if (x >= 0) and (y >= 0) { return a }
|
||||
if (x < 0) and (y >= 0) { return 180 - a }
|
||||
if (x < 0) and (y < 0) { return 180 + a }
|
||||
return 360 - a
|
||||
}
|
||||
|
||||
# smallest absolute difference between two whole-degree angles, 0..180.
|
||||
function light_ang_diff(a: int, b: int) -> int {
|
||||
var d = a - b
|
||||
if d < 0 { d = 0 - d }
|
||||
if d > 180 { d = 360 - d }
|
||||
return d
|
||||
}
|
||||
|
||||
# t^exp for a Q16.16 t in [0,1] and a small integer exponent (repeated fixed
|
||||
# multiply) — the falloff curve. exp 1 is the original linear ramp.
|
||||
function light_pow_t(t: fixed, exp: int) -> fixed {
|
||||
if exp <= 1 { return t }
|
||||
var r = t
|
||||
var i = 1
|
||||
while i < exp { r = r * t; i = i + 1 }
|
||||
return r
|
||||
}
|
||||
|
||||
# fraction of the light visible at a pixel, 0..1 (Q16.16). A hard light samples
|
||||
# only its centre (0 or 1); a soft light (rt_light_soft > 0) samples a small cross
|
||||
# on the light disk and averages, so an occluder edge fades through a penumbra
|
||||
# instead of cutting sharply (tier 4).
|
||||
function light_vis(cx: int, cy: int, px: int, py: int) -> fixed {
|
||||
if rt_light_soft <= 0 {
|
||||
if light_blocked(cx, cy, px, py) { return fixed(0) }
|
||||
return fixed(1)
|
||||
}
|
||||
let s = rt_light_soft
|
||||
var hit = 0
|
||||
if not light_blocked(cx, cy, px, py) { hit = hit + 1 }
|
||||
if not light_blocked(cx + s, cy, px, py) { hit = hit + 1 }
|
||||
if not light_blocked(cx - s, cy, px, py) { hit = hit + 1 }
|
||||
if not light_blocked(cx, cy + s, px, py) { hit = hit + 1 }
|
||||
if not light_blocked(cx, cy - s, px, py) { hit = hit + 1 }
|
||||
return fixed(hit) / fixed(5)
|
||||
}
|
||||
|
||||
# Lambert factor at a pixel from the normal G-buffer, 0..1 (Q16.16). Flat / no
|
||||
# normal -> 1 (unchanged). Otherwise N·L with L the (normalized) direction from
|
||||
# the surface to the light in 3D, the light lifted rt_light_h above the plane.
|
||||
function light_normal_factor(cx: int, cy: int, px: int, py: int) -> fixed {
|
||||
if rt_light_nrm == null { return fixed(1) }
|
||||
let packed = rt_light_nrm[py * rt_fbw + px]
|
||||
if packed == 0 { return fixed(1) }
|
||||
let nxq = (packed & 255) - 128
|
||||
let nyq = ((packed >> 8) & 255) - 128
|
||||
let lx = cx - px
|
||||
let ly = cy - py
|
||||
let lz = rt_light_h
|
||||
let lm = light_isqrt(lx * lx + ly * ly + lz * lz)
|
||||
if lm <= 0 { return fixed(1) }
|
||||
let lxf = fixed(lx) / fixed(lm)
|
||||
let lyf = fixed(ly) / fixed(lm)
|
||||
let lzf = fixed(lz) / fixed(lm)
|
||||
let nxf = fixed(nxq) / fixed(127)
|
||||
let nyf = fixed(nyq) / fixed(127)
|
||||
var nz2 = fixed(1) - nxf * nxf - nyf * nyf
|
||||
if nz2 < 0 { nz2 = fixed(0) }
|
||||
let nzf = light_fsqrt(nz2)
|
||||
var dot = nxf * lxf + nyf * lyf + nzf * lzf
|
||||
if dot < 0 { dot = fixed(0) }
|
||||
return dot
|
||||
}
|
||||
|
||||
# ---- the light pass -------------------------------------------------------
|
||||
# Multiply the whole scene by an ambient tint (0x00RRGGBB): the CanvasModulate
|
||||
# that gives a night/cave mood before any light adds brightness back. Ambient
|
||||
|
|
@ -138,16 +301,28 @@ function light_ambient(color: int) -> void {
|
|||
}
|
||||
}
|
||||
|
||||
# Additively accumulate a radial point light centred at (cx,cy) with the given
|
||||
# radius (px), colour (0x00RRGGBB) and energy (a Q16.16 fixed multiplier; 1.0 is
|
||||
# full). Brightness falls off linearly with distance to zero at the radius, is
|
||||
# clamped per channel at 255, and is cut where a registered occluder blocks the
|
||||
# ray — a hard shadow. Only the light's bounding box is touched.
|
||||
function light_point(cx: int, cy: int, radius: int, color: int, energy: fixed) -> void {
|
||||
# The one light emitter. Additively accumulate a light centred at (cx,cy) with
|
||||
# the given radius (px), colour (0x00RRGGBB) and energy (a Q16.16 fixed
|
||||
# multiplier; 1.0 is full). It reads the tier-control globals for everything past
|
||||
# the basic radial glow:
|
||||
# * rt_light_falloff — the brightness ramp exponent (1 linear, 2 quadratic, …).
|
||||
# * rt_light_gel — an outer colour: the light gels from `color` at the
|
||||
# centre to this at the rim (a colour cookie).
|
||||
# * rt_light_soft — a penumbra: soft shadows fade over this radius.
|
||||
# * rt_light_nrm — a normal buffer: surfaces shade by facing (N·L).
|
||||
# `dir_deg`/`spread_deg` make it a cone: with spread >= 0 a pixel outside the cone
|
||||
# gets no light and the last few degrees feather. spread < 0 is omnidirectional.
|
||||
# Brightness is clamped per channel at 255; only the bounding box is touched.
|
||||
const LIGHT_FEATHER: int = 6 # cone-edge softening, in degrees
|
||||
|
||||
function light_emit(cx: int, cy: int, radius: int, color: int, energy: fixed, dir_deg: int, spread_deg: int) -> void {
|
||||
if radius <= 0 { return }
|
||||
let lr = (color >> 16) & 255
|
||||
let lg = (color >> 8) & 255
|
||||
let lb = color & 255
|
||||
let gr = (rt_light_gel >> 16) & 255
|
||||
let gg = (rt_light_gel >> 8) & 255
|
||||
let gb = rt_light_gel & 255
|
||||
var py = cy - radius
|
||||
while py <= cy + radius {
|
||||
if (py >= 0) and (py < rt_fbh) {
|
||||
|
|
@ -158,17 +333,38 @@ function light_point(cx: int, cy: int, radius: int, color: int, energy: fixed) -
|
|||
let ddy = py - cy
|
||||
let d = light_isqrt(ddx * ddx + ddy * ddy)
|
||||
if d < radius {
|
||||
if not light_blocked(cx, cy, px, py) {
|
||||
let fall = radius - d # linear falloff, 0..radius
|
||||
let br = lr * fall / radius # channel * falloff, 0..255
|
||||
let bg = lg * fall / radius
|
||||
let bb = lb * fall / radius
|
||||
let idx = py * rt_fbw + px
|
||||
let cur = rt_fb[idx]
|
||||
let nr = min(255, ((cur >> 16) & 255) + floor(fixed(br) * energy))
|
||||
let ng = min(255, ((cur >> 8) & 255) + floor(fixed(bg) * energy))
|
||||
let nb = min(255, (cur & 255) + floor(fixed(bb) * energy))
|
||||
rt_fb[idx] = (nr << 16) | (ng << 8) | nb
|
||||
# cone gate: outside the spread contributes nothing; the rim feathers.
|
||||
var cone = fixed(1)
|
||||
if spread_deg >= 0 {
|
||||
let diff = light_ang_diff(light_atan2_deg(ddy, ddx), dir_deg)
|
||||
if diff > spread_deg { cone = fixed(0) }
|
||||
else {
|
||||
let edge = spread_deg - LIGHT_FEATHER
|
||||
if diff > edge { cone = fixed(spread_deg - diff) / fixed(LIGHT_FEATHER) }
|
||||
}
|
||||
}
|
||||
if cone > 0 {
|
||||
let vis = light_vis(cx, cy, px, py)
|
||||
if vis > 0 {
|
||||
let t_lin = fixed(radius - d) / fixed(radius) # 1 at centre, 0 at rim
|
||||
let atten = light_pow_t(t_lin, rt_light_falloff)
|
||||
let nf = light_normal_factor(cx, cy, px, py)
|
||||
let k = atten * energy * vis * cone * nf
|
||||
# gel: mix the light colour toward the rim colour by (1 - t_lin).
|
||||
var cr = lr; var cg = lg; var cb = lb
|
||||
if rt_light_gel_on {
|
||||
let mix = fixed(1) - t_lin
|
||||
cr = lr + floor(fixed(gr - lr) * mix)
|
||||
cg = lg + floor(fixed(gg - lg) * mix)
|
||||
cb = lb + floor(fixed(gb - lb) * mix)
|
||||
}
|
||||
let idx = py * rt_fbw + px
|
||||
let cur = rt_fb[idx]
|
||||
let nr = min(255, ((cur >> 16) & 255) + floor(fixed(cr) * k))
|
||||
let ng = min(255, ((cur >> 8) & 255) + floor(fixed(cg) * k))
|
||||
let nb = min(255, (cur & 255) + floor(fixed(cb) * k))
|
||||
rt_fb[idx] = (nr << 16) | (ng << 8) | nb
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -178,3 +374,35 @@ function light_point(cx: int, cy: int, radius: int, color: int, energy: fixed) -
|
|||
py = py + 1
|
||||
}
|
||||
}
|
||||
|
||||
# a radial (omnidirectional) point light — the original short-signature call,
|
||||
# now a thin wrapper over light_emit with the cone disabled.
|
||||
function light_point(cx: int, cy: int, radius: int, color: int, energy: fixed) -> void {
|
||||
light_emit(cx, cy, radius, color, energy, 0, 0 - 1)
|
||||
}
|
||||
|
||||
# a cone / spot light aimed at `direction` degrees (0 = +x, CCW) with a half-angle
|
||||
# `spread` in degrees — a flashlight, a lamp cone. Shares every tier control (soft
|
||||
# shadows, falloff, gel, normals) with the radial form.
|
||||
function light_spot(cx: int, cy: int, radius: int, color: int, energy: fixed, direction: int, spread: int) -> void {
|
||||
light_emit(cx, cy, radius, color, energy, direction, spread)
|
||||
}
|
||||
|
||||
# ---- day / night ----------------------------------------------------------
|
||||
# Set the ambient tint from a time-of-day phase `t` (Q16.16 in [0,1]: 0 midnight,
|
||||
# 0.25 dawn, 0.5 noon, 0.75 dusk). A cosine-free triangle ramps a deep-blue night
|
||||
# up to full daylight and back, so a game animates one value and the world's mood
|
||||
# follows. Deterministic; drives the same light_ambient modulate.
|
||||
function light_time_of_day(t: fixed) -> void {
|
||||
# day factor 0..1: 0 at midnight, 1 at noon (triangle over the day).
|
||||
var day = t * fixed(2) # 0..2 across the day
|
||||
if day > fixed(1) { day = fixed(2) - day } # fold 0.5..1 back down: peak at noon
|
||||
if day < 0 { day = fixed(0) }
|
||||
# night tint 0x14142a (deep blue) -> day 0xffffff, per channel.
|
||||
let nr = 20; let ng = 20; let nb = 42
|
||||
let dr = 255; let dg = 255; let db = 255
|
||||
let r = nr + floor(fixed(dr - nr) * day)
|
||||
let g = ng + floor(fixed(dg - ng) * day)
|
||||
let b = nb + floor(fixed(db - nb) * day)
|
||||
light_ambient((r << 16) | (g << 8) | b)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,12 +88,23 @@ function esys_light2d() -> void {
|
|||
}
|
||||
|
||||
# 3. lights — additively accumulate each `Light2D` as a radial glow, cut by the
|
||||
# occluders registered above (hard shadows).
|
||||
# occluders registered above (hard shadows). Beyond the core five fields, a
|
||||
# Light2D may carry optional tier fields, each defaulting off when absent so an
|
||||
# older five-field component still lights exactly as before:
|
||||
# direction, spread — a cone/spot (spread < 0 or absent = omnidirectional)
|
||||
# falloff — brightness ramp exponent (1 = linear, absent = 1)
|
||||
# softness — penumbra radius in px (0 / absent = hard shadows)
|
||||
# gel — outer rim colour for a colour cookie (< 0 / absent = off)
|
||||
let lxf = World.field_id(pl, "x")
|
||||
let lyf = World.field_id(pl, "y")
|
||||
let lrf = World.field_id(pl, "radius")
|
||||
let lcf = World.field_id(pl, "color")
|
||||
let lif = World.field_id(pl, "intensity")
|
||||
let ldf = World.field_id(pl, "direction")
|
||||
let lsf = World.field_id(pl, "spread")
|
||||
let lff = World.field_id(pl, "falloff")
|
||||
let lkf = World.field_id(pl, "softness")
|
||||
let lgf = World.field_id(pl, "gel")
|
||||
var e = World.query_next(pl, 0)
|
||||
while e >= 0 {
|
||||
let x = esys_pos_x(e, pl, lxf)
|
||||
|
|
@ -104,7 +115,23 @@ function esys_light2d() -> void {
|
|||
if lcf >= 0 { color = World.get(e, pl, lcf) }
|
||||
var energy = fixed(1) # full intensity default
|
||||
if lif >= 0 { energy = fixed(World.get(e, pl, lif)) / fixed(100) }
|
||||
light_point(x, y, radius, color, energy)
|
||||
|
||||
# tier controls: apply per light, resetting to defaults each time so one
|
||||
# cone/soft light does not bleed its settings onto the next.
|
||||
var falloff = 1
|
||||
if lff >= 0 { falloff = World.get(e, pl, lff) }
|
||||
light_set_falloff(falloff)
|
||||
var soft = 0
|
||||
if lkf >= 0 { soft = World.get(e, pl, lkf) }
|
||||
light_set_soft(soft)
|
||||
if lgf >= 0 { light_set_gel(World.get(e, pl, lgf)) } else { light_clear_gel() }
|
||||
|
||||
var spread = 0 - 1
|
||||
if lsf >= 0 { spread = World.get(e, pl, lsf) }
|
||||
var direction = 0
|
||||
if ldf >= 0 { direction = World.get(e, pl, ldf) }
|
||||
if spread >= 0 { light_spot(x, y, radius, color, energy, direction, spread) }
|
||||
else { light_point(x, y, radius, color, energy) }
|
||||
e = World.query_next(pl, e + 1)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -273,8 +273,17 @@ function emit_ns_call(ns: pointer, meth: pointer, e: Node) -> Val {
|
|||
if (ns == "Light") {
|
||||
if (meth == "ambient") { bare = "light_ambient"; push(labels, "color") }
|
||||
if (meth == "point") { bare = "light_point"; push(labels, "x"); push(labels, "y"); push(labels, "radius"); push(labels, "color"); push(labels, "energy") }
|
||||
if (meth == "spot") { bare = "light_spot"; push(labels, "x"); push(labels, "y"); push(labels, "radius"); push(labels, "color"); push(labels, "energy"); push(labels, "direction"); push(labels, "spread") }
|
||||
if (meth == "occlude") { bare = "light_occlude"; push(labels, "x"); push(labels, "y"); push(labels, "width"); push(labels, "height") }
|
||||
if (meth == "clear_occluders") { bare = "light_clear_occluders" }
|
||||
if (meth == "falloff") { bare = "light_set_falloff"; push(labels, "exponent") }
|
||||
if (meth == "soft") { bare = "light_set_soft"; push(labels, "radius") }
|
||||
if (meth == "gel") { bare = "light_set_gel"; push(labels, "color") }
|
||||
if (meth == "clear_gel") { bare = "light_clear_gel" }
|
||||
if (meth == "height") { bare = "light_set_height"; push(labels, "height") }
|
||||
if (meth == "normal") { bare = "light_normal_rect"; push(labels, "x"); push(labels, "y"); push(labels, "width"); push(labels, "height"); push(labels, "nx"); push(labels, "ny") }
|
||||
if (meth == "clear_normals") { bare = "light_clear_normals" }
|
||||
if (meth == "time_of_day") { bare = "light_time_of_day"; push(labels, "t") }
|
||||
}
|
||||
# Query.* — ECS spatial queries over the reflection ABI (runtime/native/query.ludic,
|
||||
# spliced on demand). `prop` is a property id (World.prop_id); the spatial forms
|
||||
|
|
|
|||
13540
selfhost/ludicc.seed.ll
13540
selfhost/ludicc.seed.ll
File diff suppressed because it is too large
Load diff
|
|
@ -201,6 +201,7 @@ function cmd_test() -> int {
|
|||
feat_case("library/serialize", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16", "serialize.ludic (Value tree + Json encode/parse + Reflect.serialize/apply — bit-exact save/load; issue #44)")
|
||||
feat_case("library/render", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "render.ludic (Screen pixel/oval/camera/clip/blend_mode/measure_text + Camera set/follow/shake, verified by pixel readback)")
|
||||
feat_case("library/lighting", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14", "lighting.ludic (Light ambient/point radial falloff + occluder hard shadows — 2D light accumulation, verified by pixel readback)")
|
||||
feat_case("library/light_tiers", "", "1 1 1 1 1 1 1 1 1", "light_tiers.ludic (Light spot/falloff/soft/gel/normal/time_of_day — render-quality tiers 3-4; issue #49)")
|
||||
spec_case("library/testing", "== 6 passed, 0 failed ==")
|
||||
spec_case("library/coverage", "== 3 passed, 0 failed ==")
|
||||
feat_case("library/errors", "", "5 10 0 7 1", "errors.ludic (assert guards an invariant, holds -> runs to the end; issue #8 success path)")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue