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

180
runtime/native/light.ludic Normal file
View file

@ -0,0 +1,180 @@
# ============================================================================
# light.ludic — 2D light accumulation over the framebuffer, in Ludic.
#
# The Light.* namespace (see emit_call.ludic) is a software light pass a game
# runs in its render phase, after drawing the scene and before Screen.show:
#
# Screen.clear(0); draw the world ...
# Light.ambient(0x303040) # night: multiply the scene down
# Light.clear_occluders()
# Light.occlude(wall_x, wall_y, w, h) # geometry that blocks light
# Light.point(torch_x, torch_y, 90, Color.Amber, 1.0) # add a glow
# Screen.show()
#
# It owns the framebuffer end to end (rt_fb in core.ludic), so lighting is a
# rendering concern only — it never touches game state, and it is fully
# deterministic (integer + Q16.16 fixed): the same scene lights identically on
# every run and in a headless render, so screenshots stay diffable.
#
# 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.
# ============================================================================
# ---- occluder store -------------------------------------------------------
# Up to 64 rectangular occluders, stored flat as (x, y, w, h) i32 quads. A game
# clears them each frame (Light.clear_occluders) and re-registers the geometry
# that should cast shadows this frame.
var rt_light_occ: words = null # occluder rects: 4 i32 each — x, y, w, h
var rt_light_occ_n: int = 0 # number of occluders currently stored
function light_occ_init() -> void {
if rt_light_occ == null { rt_light_occ = words(64 * 4) }
}
# forget every occluder — call once per frame before re-registering geometry.
function light_clear_occluders() -> void { rt_light_occ_n = 0 }
# register a rectangular shadow caster (screen space). Silently ignored past 64.
function light_occlude(x: int, y: int, w: int, h: int) -> void {
light_occ_init()
if rt_light_occ_n >= 64 { return }
let i = rt_light_occ_n * 4
rt_light_occ[i] = x
rt_light_occ[i + 1] = y
rt_light_occ[i + 2] = w
rt_light_occ[i + 3] = h
rt_light_occ_n = rt_light_occ_n + 1
}
# ---- shadow geometry ------------------------------------------------------
# Orientation of point c relative to the directed segment a->b: 1 = left/ccw,
# -1 = right/cw, 0 = colinear. Pure integer; screen coordinates keep the cross
# product well inside i32.
function light_orient(ax: int, ay: int, bx: int, by: int, cx: int, cy: int) -> int {
let v = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax)
if v > 0 { return 1 }
if v < 0 { return 0 - 1 }
return 0
}
# do segments a-b and c-d straddle each other (proper crossing)? Colinear
# touching is treated as no-cross — negligible for a light pass.
function light_seg_cross(ax: int, ay: int, bx: int, by: int, cx: int, cy: int, dx: int, dy: int) -> bool {
let d1 = light_orient(cx, cy, dx, dy, ax, ay)
let d2 = light_orient(cx, cy, dx, dy, bx, by)
let d3 = light_orient(ax, ay, bx, by, cx, cy)
let d4 = light_orient(ax, ay, bx, by, dx, dy)
if (d1 != d2) and (d3 != d4) { return true }
return false
}
function light_pt_in_rect(px: int, py: int, rx: int, ry: int, rw: int, rh: int) -> bool {
return (px >= rx) and (py >= ry) and (px < rx + rw) and (py < ry + rh)
}
# is the segment from light (lx,ly) to pixel (px,py) blocked by any occluder?
# A pixel inside an occluder is in shadow; otherwise the ray is blocked if it
# crosses any of the rectangle's four edges.
function light_blocked(lx: int, ly: int, px: int, py: int) -> bool {
var k = 0
while k < rt_light_occ_n {
let i = k * 4
let rx = rt_light_occ[i]
let ry = rt_light_occ[i + 1]
let rw = rt_light_occ[i + 2]
let rh = rt_light_occ[i + 3]
if light_pt_in_rect(px, py, rx, ry, rw, rh) { return true }
let x0 = rx
let y0 = ry
let x1 = rx + rw
let y1 = ry + rh
if light_seg_cross(lx, ly, px, py, x0, y0, x1, y0) { return true } # top
if light_seg_cross(lx, ly, px, py, x1, y0, x1, y1) { return true } # right
if light_seg_cross(lx, ly, px, py, x1, y1, x0, y1) { return true } # bottom
if light_seg_cross(lx, ly, px, py, x0, y1, x0, y0) { return true } # left
k = k + 1
}
return false
}
# integer square root (Newton) — 0..sqrt(n). Deterministic, overflow-safe for
# any screen-scale radius (unlike a fixed(radius^2) that would wrap past ~181px).
function light_isqrt(n: int) -> int {
if n <= 0 { return 0 }
var x = n
var y = (x + 1) / 2
while y < x {
x = y
y = (x + n / x) / 2
}
return x
}
# ---- 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
# 0xFFFFFF is a no-op; darker/colored tints dim and gel the scene.
function light_ambient(color: int) -> void {
let ar = (color >> 16) & 255
let ag = (color >> 8) & 255
let ab = color & 255
let n = rt_fbw * rt_fbh
var i = 0
while i < n {
let cur = rt_fb[i]
let nr = (((cur >> 16) & 255) * ar) / 255
let ng = (((cur >> 8) & 255) * ag) / 255
let nb = ((cur & 255) * ab) / 255
rt_fb[i] = (nr << 16) | (ng << 8) | nb
i = i + 1
}
}
# 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 {
if radius <= 0 { return }
let lr = (color >> 16) & 255
let lg = (color >> 8) & 255
let lb = color & 255
var py = cy - radius
while py <= cy + radius {
if (py >= 0) and (py < rt_fbh) {
var px = cx - radius
while px <= cx + radius {
if (px >= 0) and (px < rt_fbw) {
let ddx = px - cx
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
}
}
}
px = px + 1
}
}
py = py + 1
}
}