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>
180 lines
7.4 KiB
Text
180 lines
7.4 KiB
Text
# ============================================================================
|
|
# 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
|
|
}
|
|
}
|