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

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

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

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

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

View file

@ -238,6 +238,16 @@ function emit_ns_call(ns: pointer, meth: pointer, e: Node) -> Val {
if (meth == "flood") { bare = "grid_flood"; push(labels, "x"); push(labels, "y"); push(labels, "wall") }
if (meth == "a_star") { bare = "path_a_star"; push(labels, "x0"); push(labels, "y0"); push(labels, "x1"); push(labels, "y1"); push(labels, "wall") }
}
# Light.* — the 2D light-accumulation pass (runtime/native/light.ludic, spliced
# on demand). A game runs it in its render phase: ambient multiplies the scene
# down, point adds a radial glow (blocked by occluders -> hard shadows). Screen
# space, deterministic (integer + Q16.16), diffable. `energy` is a fixed.
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 == "occlude") { bare = "light_occlude"; push(labels, "x"); push(labels, "y"); push(labels, "width"); push(labels, "height") }
if (meth == "clear_occluders") { bare = "light_clear_occluders" }
}
# 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
# read two int fields (field ids) as (x, y). nearest/first return an entity (-1 =

View file

@ -164,6 +164,7 @@ function p_postfix() -> Node {
if e.a.kind == E_ID and e.a.s == "Regex" { g_uses_regex = true } # splice the regex runtime on demand
if e.a.kind == E_ID and e.a.s == "Query" { g_uses_query = true } # splice the ECS spatial-query runtime on demand
if e.a.kind == E_ID and e.a.s == "Reflect" { g_uses_reflect = true } # force-emit the reflection ABI (Reflect.* reads the world schema)
if e.a.kind == E_ID and e.a.s == "Light" { g_uses_light = true } # splice the 2D light-accumulation pass on demand
}
else { if is_op("[") { pi = pi + 1; let lo = expr()
if is_op("..") { pi = pi + 1; let sl = node(E_SLICE); sl.a = e; sl.b = lo; sl.c = expr(); eat_op("]"); e = sl } # s[a..b] substring
@ -378,6 +379,7 @@ var cur_dir: pointer
var g_uses_regex: bool = false # a program mentioned Regex.* -> splice the regex runtime
var g_uses_query: bool = false # a program mentioned Query.* -> splice the query runtime + reflection ABI
var g_uses_reflect: bool = false # a program mentioned Reflect.* -> force-emit the reflection ABI
var g_uses_light: bool = false # a program mentioned Light.* -> splice the 2D light pass
function already_loaded(full: pointer) -> bool {
var i = 0
@ -537,6 +539,13 @@ function maybe_splice_runtime() -> void {
do_import("runtime/native/query.ludic")
cur_dir = saved
}
# any program that uses Light.* gets the 2D light-accumulation pass spliced in;
# it reads and writes the framebuffer (rt_fb), so it links with core.ludic.
if g_uses_light {
cur_dir = ""
do_import("runtime/native/light.ludic")
cur_dir = saved
}
}
function parse_program() -> void {
@ -557,6 +566,7 @@ function parse_program() -> void {
g_uses_regex = false
g_uses_query = false
g_uses_reflect = false
g_uses_light = false
loaded_paths = new []pointer
skipnl()
g_game_name = "Ludic"

File diff suppressed because it is too large Load diff