Proposal: 2D lighting — ambient, point lights, occluders/shadows, normal maps, as ECS components #4

Closed
opened 2026-08-29 18:13:01 +02:00 by orkun · 1 comment
Owner

Context

2D lighting turns a flat scene into an atmosphere — caves, night, torches, explosions, glow. Ludic renders its own framebuffer (a software renderer), so lighting is a light-accumulation pass we own end to end. This proposes 2D lighting modeled on Godot's 2D lights & shadows (the clearest reference: an emitter, an occluder, an ambient modulate) but expressed ECS-natively, which fits Ludic better than a node tree.

Lighting is a rendering concern, not simulation — it does not affect the deterministic game state or replays. It should still be deterministic given identical inputs (fixed-point), so screenshots/headless renders stay diffable.

Model (three parts, like Godot)

  1. Ambient — a global tint that darkens the scene; lights add back on top. Screen.ambient(color) / a CanvasModulate-style call. This alone gives night/cave mood.
  2. Lights — emitters with position, radius, color, energy, falloff, blend (additive/mix).
  3. Occluders + shadows — geometry that blocks light, casting hard (later soft) shadows.

ECS-native shape (the Ludic-idiomatic part)

Lights and occluders as components the renderer consumes automatically each frame — no wiring:

property Light2D  { radius: int = 64, color: int = 0xFFEEAA, energy: fixed = 1.0,
                    falloff: fixed = 2.0, mode: int = Light.Additive }
property Occluder { shape: int = Occluder.Rect }   # rect | circle | polygon (from a slice)

# a torch is just an entity carrying Position + Light2D
spawn Torch { Position { column: 10, row: 6 }, Light2D { radius: 90, color: Color.Amber } }

The built-in Render phase composites: draw scene → multiply by ambient → additively accumulate each Light2D (radial falloff) → subtract shadow regions cast by Occluders.

Imperative escape hatch for one-offs (explosions, muzzle flash):
Light.point(pos, radius, color, energy), Light.ambient(color), Light.occlude(rect).

Feature tiers (ship incrementally; software-renderer cost matters)

  1. Ambient + additive radial lights — cheap, huge visual payoff.
  2. Hard shadows — raycast light→occluder edges (or a 2D SDF like Godot's) to carve umbra.
  3. Normal-mapped sprites — a per-sprite normal buffer so lights shade sprite surfaces (Godot supports 2D normal maps).
  4. Soft shadows / penumbra, colored/gel lights, light cookies, day/night cycle helper (Light.time_of_day(t) animating ambient + sun color/direction via a DirectionalLight2D-style global light).

Performance notes

Software 2D lighting is fill-rate bound. Keep it opt-in and tiered: radial falloff is a per-pixel add over each light's bounding box; shadows are the expensive part (start with a small number of shadow-casting lights). Deterministic and diffable for headless tests.

Phasing

  1. Screen.ambient + Light2D component + additive radial accumulation.
  2. Occluder component + hard shadows.
  3. Normal maps; directional/day-night light.
  4. Soft shadows, colored shadows, cookies.

References

Software 2D light-accumulation, expressed as ECS components — deterministic and diffable.

## Context 2D lighting turns a flat scene into an atmosphere — caves, night, torches, explosions, glow. Ludic renders its own framebuffer (a software renderer), so lighting is a **light-accumulation pass** we own end to end. This proposes 2D lighting modeled on **Godot's 2D lights & shadows** (the clearest reference: an emitter, an occluder, an ambient modulate) but expressed **ECS-natively**, which fits Ludic better than a node tree. > Lighting is a **rendering** concern, not simulation — it does not affect the deterministic game state or replays. It should still be deterministic given identical inputs (fixed-point), so screenshots/headless renders stay diffable. ## Model (three parts, like Godot) 1. **Ambient** — a global tint that darkens the scene; lights add back on top. `Screen.ambient(color)` / a `CanvasModulate`-style call. This alone gives night/cave mood. 2. **Lights** — emitters with position, radius, color, energy, falloff, blend (additive/mix). 3. **Occluders + shadows** — geometry that blocks light, casting hard (later soft) shadows. ## ECS-native shape (the Ludic-idiomatic part) Lights and occluders as **components** the renderer consumes automatically each frame — no wiring: ``` property Light2D { radius: int = 64, color: int = 0xFFEEAA, energy: fixed = 1.0, falloff: fixed = 2.0, mode: int = Light.Additive } property Occluder { shape: int = Occluder.Rect } # rect | circle | polygon (from a slice) # a torch is just an entity carrying Position + Light2D spawn Torch { Position { column: 10, row: 6 }, Light2D { radius: 90, color: Color.Amber } } ``` The built-in Render phase composites: draw scene → multiply by ambient → additively accumulate each `Light2D` (radial falloff) → subtract shadow regions cast by `Occluder`s. Imperative escape hatch for one-offs (explosions, muzzle flash): `Light.point(pos, radius, color, energy)`, `Light.ambient(color)`, `Light.occlude(rect)`. ## Feature tiers (ship incrementally; software-renderer cost matters) 1. **Ambient + additive radial lights** — cheap, huge visual payoff. 2. **Hard shadows** — raycast light→occluder edges (or a 2D SDF like Godot's) to carve umbra. 3. **Normal-mapped sprites** — a per-sprite normal buffer so lights shade sprite surfaces (Godot supports 2D normal maps). 4. **Soft shadows / penumbra**, colored/gel lights, light cookies, day/night cycle helper (`Light.time_of_day(t)` animating ambient + sun color/direction via a `DirectionalLight2D`-style global light). ## Performance notes Software 2D lighting is fill-rate bound. Keep it opt-in and tiered: radial falloff is a per-pixel add over each light's bounding box; shadows are the expensive part (start with a small number of shadow-casting lights). Deterministic and diffable for headless tests. ## Phasing 1. `Screen.ambient` + `Light2D` component + additive radial accumulation. 2. `Occluder` component + hard shadows. 3. Normal maps; directional/day-night light. 4. Soft shadows, colored shadows, cookies. ## References - [Godot — 2D lights and shadows](https://docs.godotengine.org/en/stable/tutorials/2d/2d_lights_and_shadows.html), [`PointLight2D`](https://docs.godotengine.org/en/latest/classes/class_pointlight2d.html), [`Light2D`](https://docs.godotengine.org/en/stable/classes/class_light2d.html) (Color/Energy/Blend, `LightOccluder2D`, `CanvasModulate`, 2D SDF shadows, normal maps) - Related: `color` type in #1; `Vec.*` in #2. _Software 2D light-accumulation, expressed as ECS components — deterministic and diffable._
orkun added the
proposal
priority:low
area:rendering
labels 2026-08-29 19:51:36 +02:00
Author
Owner

Shipped in a71279a — the Light.* namespace: a deterministic software light-accumulation pass over the framebuffer, the imperative surface the proposal sketched (Light.point, Light.ambient, Light.occlude).

What landed (tiers 1 & 2):

  • Light.ambient(color) — the CanvasModulate: multiplies the whole scene toward a tint (channel * tint / 255) for night/cave mood before lights add brightness back.
  • Light.point(x, y, radius, color, energy) — additive radial light, linear falloff to zero at the radius, scaled by a fixed energy and clamped per channel at 255 so overlapping lights add without wrapping. Only the light's bounding box is touched.
  • Light.occlude(x, y, w, h) + Light.clear_occluders() — hard shadows: a point light is cut wherever its ray to a pixel crosses (or the pixel lies inside) a registered rectangle. Clear-then-rebuild each frame, up to 64 occluders.

It owns the framebuffer end to end (rt_fb), so lighting is a rendering concern only — it never touches game state or replays — and it is fully deterministic (integer + Q16.16 fixed, integer isqrt for falloff so it's overflow-safe at any screen-scale radius). Same scene lights identically every run and in a headless render, so screenshots stay diffable.

How it's built: engine in runtime/native/light.ludic, spliced on demand (g_uses_light) the same way the regex/query runtimes are; namespace wired in emit_call.ludic. Docs at docs/language/light/. A 14-assertion pixel-readback test (examples/library/lighting.ludic) is in the regression suite (x test, now 66 passing), and x check-impl/check-docs cover the new pages.

Deferred to follow-ups (tiers 3-4 + the ECS-native shape): normal-mapped sprites, soft shadows/penumbra, colored shadows/cookies, and a day/night DirectionalLight2D. The Godot-style Light2D/Occluder components consumed automatically by a built-in Render phase need an engine-owned system over user components — the same ECS hook #43 is blocked on. Until that lands, Light.* is the imperative escape hatch the proposal named for exactly this. Closing the core; the auto-component layer will ride along with the #43 work.

Shipped in a71279a — the `Light.*` namespace: a deterministic software light-accumulation pass over the framebuffer, the imperative surface the proposal sketched (`Light.point`, `Light.ambient`, `Light.occlude`). **What landed (tiers 1 & 2):** - **`Light.ambient(color)`** — the CanvasModulate: multiplies the whole scene toward a tint (channel * tint / 255) for night/cave mood before lights add brightness back. - **`Light.point(x, y, radius, color, energy)`** — additive radial light, linear falloff to zero at the radius, scaled by a `fixed` energy and clamped per channel at 255 so overlapping lights add without wrapping. Only the light's bounding box is touched. - **`Light.occlude(x, y, w, h)`** + **`Light.clear_occluders()`** — hard shadows: a point light is cut wherever its ray to a pixel crosses (or the pixel lies inside) a registered rectangle. Clear-then-rebuild each frame, up to 64 occluders. It owns the framebuffer end to end (`rt_fb`), so lighting is a rendering concern only — it never touches game state or replays — and it is fully deterministic (integer + Q16.16 fixed, integer isqrt for falloff so it's overflow-safe at any screen-scale radius). Same scene lights identically every run and in a headless render, so screenshots stay diffable. **How it's built:** engine in `runtime/native/light.ludic`, spliced on demand (`g_uses_light`) the same way the regex/query runtimes are; namespace wired in `emit_call.ludic`. Docs at `docs/language/light/`. A 14-assertion pixel-readback test (`examples/library/lighting.ludic`) is in the regression suite (`x test`, now 66 passing), and `x check-impl`/`check-docs` cover the new pages. **Deferred to follow-ups** (tiers 3-4 + the ECS-native shape): normal-mapped sprites, soft shadows/penumbra, colored shadows/cookies, and a day/night `DirectionalLight2D`. The Godot-style **`Light2D`/`Occluder` components consumed automatically by a built-in Render phase** need an engine-owned system over user components — the same ECS hook #43 is blocked on. Until that lands, `Light.*` is the imperative escape hatch the proposal named for exactly this. Closing the core; the auto-component layer will ride along with the #43 work.
orkun closed this issue 2026-08-31 13:07:58 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: workshopsoft/ludic#4
No description provided.