ludic/examples/library/lighting.ludic
Orkuncakilkaya a71279a7b9
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
feat(rendering): add Light.* — deterministic 2D light accumulation with hard shadows (#4)
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>
2026-08-31 14:07:19 +03:00

69 lines
3.1 KiB
Text

# lighting.ludic — the 2D light-accumulation pass: Light.ambient (scene modulate),
# Light.point (additive radial glow with linear falloff), and Light.occlude /
# Light.clear_occluders (hard shadows). Each assertion that holds prints its
# number, so a full run prints:
# 1 2 3 4 5 6 7 8 9 10 11 12 13 14
# It lights the headless framebuffer and reads pixels back with Screen.pixel, so
# it verifies the actual composited result — not just that a call compiled. All
# deterministic (integer + Q16.16 fixed). Colours are 0x00RRGGBB.
program Lighting {
property Tag { v: int = 0 }
model Marker { Tag }
handler Boot phase Start {
let WHITE = 16777215 # 0xFFFFFF
let RED = 16711680 # 0xFF0000
let BLUE = 64 # 0x000040
let GREY = 8421504 # 0x808080 (128,128,128)
# --- Light.ambient multiplies the whole scene by a tint (channel * a / 255) ---
Screen.clear(0)
Screen.put_pixel(5, 5, WHITE)
Screen.put_pixel(6, 5, 0)
Light.ambient(GREY) # white -> (128,128,128); black stays black
if Screen.pixel(5, 5) == GREY { print(1) }
if Screen.pixel(6, 5) == 0 { print(2) }
# --- Light.point adds a radial glow, brightest at the centre, 0 at the radius ---
Screen.clear(0)
Light.point(50, 50, 20, WHITE, 1.0)
if Screen.pixel(50, 50) == WHITE { print(3) } # centre: full brightness
if (Screen.pixel(60, 50) & 255) == 127 { print(4) } # d=10: 255*10/20 = 127
if Screen.pixel(75, 50) == 0 { print(5) } # d=25 > radius: untouched
# --- falloff is monotonic: nearer pixels are brighter ---
if (Screen.pixel(55, 50) & 255) > (Screen.pixel(65, 50) & 255) { print(6) }
if (Screen.pixel(55, 50) & 255) == 191 { print(7) } # d=5: 255*15/20 = 191
if (Screen.pixel(65, 50) & 255) == 63 { print(8) } # d=15: 255*5/20 = 63
# --- energy scales brightness (0.5 halves the centre) ---
Screen.clear(0)
Light.point(50, 50, 20, WHITE, 0.5)
if (Screen.pixel(50, 50) & 255) == 127 { print(9) } # 255 * 0.5 = 127
# --- lights accumulate additively, clamped per channel at 255 ---
Screen.clear(0)
Light.point(50, 50, 20, BLUE, 1.0)
Light.point(50, 50, 20, BLUE, 1.0)
if (Screen.pixel(50, 50) & 255) == 128 { print(10) } # 64 + 64
Screen.clear(0)
Light.point(50, 50, 20, RED, 2.0) # 255 * 2 -> clamp 255
if ((Screen.pixel(50, 50) >> 16) & 255) == 255 { print(11) }
# --- Light.occlude casts a hard shadow: a wall blocks the ray behind it ---
Screen.clear(0)
Light.clear_occluders()
Light.occlude(60, 45, 4, 10) # a thin wall just right of the light
Light.point(50, 50, 40, WHITE, 1.0)
if Screen.pixel(75, 50) == 0 { print(12) } # behind the wall: in shadow
if (Screen.pixel(25, 50) & 255) != 0 { print(13) } # opposite side: still lit
# --- clearing occluders reopens the light ---
Screen.clear(0)
Light.clear_occluders()
Light.point(50, 50, 40, WHITE, 1.0)
if Screen.pixel(75, 50) != 0 { print(14) } # no wall now: lit again
}
handler Run phase Update { quit() }
}