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>
69 lines
3.1 KiB
Text
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() }
|
|
}
|