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>
This commit is contained in:
parent
31cfbc2465
commit
a71279a7b9
12 changed files with 15747 additions and 14968 deletions
3
changes/light-namespace.md
Normal file
3
changes/light-namespace.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
bump: minor
|
||||||
|
type: feat
|
||||||
|
2D lighting — the `Light.*` namespace: a deterministic software light-accumulation pass over the framebuffer. `Light.ambient` modulates the scene toward a tint (night/cave mood), `Light.point` adds 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, so a scene lights identically every run and in a headless render.
|
||||||
7
docs/language/light/_section.md
Normal file
7
docs/language/light/_section.md
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
---
|
||||||
|
id: light
|
||||||
|
title: Light
|
||||||
|
order: 33
|
||||||
|
---
|
||||||
|
|
||||||
|
A software 2D light-accumulation pass over the framebuffer, run in a render phase after drawing the scene and before <a href="screen-show"><code>Screen.show</code></a>. <a href="light-ambient"><code>Light.ambient</code></a> multiplies the whole scene toward a tint — the night/cave modulate that darkens everything so lights add mood back on top. <a href="light-point"><code>Light.point</code></a> accumulates a radial glow that falls off with distance and clamps per channel, and <a href="light-occlude"><code>Light.occlude</code></a> registers rectangles that block a light's rays to cast hard shadows (cleared each frame with <a href="light-clear_occluders"><code>Light.clear_occluders</code></a>). Lighting is a rendering concern only — it never touches game state or replays — and it is fully deterministic (integer and Q16.16 fixed), so the same scene lights identically every run and in a headless render, keeping screenshots diffable. Colours are <code>0x00RRGGBB</code>. This is the imperative surface; consuming <code>Light2D</code>/<code>Occluder</code> components automatically, normal-mapped sprites and soft shadows are planned follow-ups. Related: <a href="screen"><code>Screen</code></a>, <a href="color"><code>Color</code></a>, <a href="camera"><code>Camera</code></a>.
|
||||||
30
docs/language/light/light-ambient.md
Normal file
30
docs/language/light/light-ambient.md
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
---
|
||||||
|
id: light-ambient
|
||||||
|
name: Light.ambient
|
||||||
|
category: light
|
||||||
|
kind: namespace-method
|
||||||
|
tokens: Light.ambient
|
||||||
|
sig: Light.ambient(color)
|
||||||
|
tip: Multiply the whole scene by a tint — the night/cave modulate.
|
||||||
|
order: 1
|
||||||
|
ns: Light
|
||||||
|
member: ambient
|
||||||
|
---
|
||||||
|
|
||||||
|
Multiplies every framebuffer pixel by <code>color</code> (a <code>0x00RRGGBB</code> tint), channel by channel (<code>channel * tint / 255</code>). This is the global modulate that sets mood before any light adds brightness back: a dim blue-grey gives night, a warm brown gives a cave, and <code>0xFFFFFF</code> is a no-op. Call it once, after drawing the scene and before the <a href="light-point"><code>Light.point</code></a> calls that light it back up.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- `color` — the ambient tint, `0x00RRGGBB` (darker/cooler = a darker scene)
|
||||||
|
|
||||||
|
```ludic
|
||||||
|
program Demo {
|
||||||
|
property Tag { v: int = 0 }
|
||||||
|
model Marker { Tag }
|
||||||
|
handler Render phase Update {
|
||||||
|
Screen.clear(Color.rgb(120, 120, 140))
|
||||||
|
Light.ambient(Color.rgb(48, 48, 72)) # dusk: dim and cool
|
||||||
|
Light.point(160, 120, 90, Color.Amber, 1.0)
|
||||||
|
Screen.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
30
docs/language/light/light-clear_occluders.md
Normal file
30
docs/language/light/light-clear_occluders.md
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
---
|
||||||
|
id: light-clear_occluders
|
||||||
|
name: Light.clear_occluders
|
||||||
|
category: light
|
||||||
|
kind: namespace-method
|
||||||
|
tokens: Light.clear_occluders
|
||||||
|
sig: Light.clear_occluders()
|
||||||
|
tip: Forget every occluder — call once per frame before re-registering.
|
||||||
|
order: 4
|
||||||
|
ns: Light
|
||||||
|
member: clear_occluders
|
||||||
|
---
|
||||||
|
|
||||||
|
Forgets every rectangle previously passed to <a href="light-occlude"><code>Light.occlude</code></a>. Occluders accumulate across calls and persist between frames, so a game that casts shadows calls this once at the top of its render phase and then re-registers the walls that should block light this frame — the same clear-then-rebuild rhythm as an immediate-mode draw list. With no occluders registered, <a href="light-point"><code>Light.point</code></a> lights its whole radius unobstructed.
|
||||||
|
|
||||||
|
Parameters: none.
|
||||||
|
|
||||||
|
```ludic
|
||||||
|
program Demo {
|
||||||
|
property Tag { v: int = 0 }
|
||||||
|
model Marker { Tag }
|
||||||
|
handler Render phase Update {
|
||||||
|
Screen.clear(0)
|
||||||
|
Light.clear_occluders() # start the frame with no shadows
|
||||||
|
Light.occlude(90, 60, 6, 40)
|
||||||
|
Light.point(40, 80, 100, Color.rgb(255, 255, 255), 1.0)
|
||||||
|
Screen.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
32
docs/language/light/light-occlude.md
Normal file
32
docs/language/light/light-occlude.md
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
---
|
||||||
|
id: light-occlude
|
||||||
|
name: Light.occlude
|
||||||
|
category: light
|
||||||
|
kind: namespace-method
|
||||||
|
tokens: Light.occlude
|
||||||
|
sig: Light.occlude(x, y, width, height)
|
||||||
|
tip: Register a rectangle that blocks light — a hard shadow caster.
|
||||||
|
order: 3
|
||||||
|
ns: Light
|
||||||
|
member: occlude
|
||||||
|
---
|
||||||
|
|
||||||
|
Registers an axis-aligned rectangle (screen space) that blocks light: for every <a href="light-point"><code>Light.point</code></a> that follows, any pixel whose ray from the light centre crosses this rectangle — or lies inside it — is left in shadow, carving a hard umbra behind the wall. Register the geometry that should cast shadows this frame, then draw the lights. Occluders persist until <a href="light-clear_occluders"><code>Light.clear_occluders</code></a>, so call that once per frame first; up to 64 occluders are kept.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- `x`, `y` — the top-left corner, in screen pixels
|
||||||
|
- `width`, `height` — the rectangle size in pixels
|
||||||
|
|
||||||
|
```ludic
|
||||||
|
program Demo {
|
||||||
|
property Wall { x: int = 0, y: int = 0 }
|
||||||
|
model Block { Wall }
|
||||||
|
handler Render phase Update {
|
||||||
|
Screen.clear(0)
|
||||||
|
Light.clear_occluders()
|
||||||
|
Light.occlude(120, 80, 8, 48) # a pillar
|
||||||
|
Light.point(60, 100, 120, Color.rgb(255, 240, 200), 1.0)
|
||||||
|
Screen.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
33
docs/language/light/light-point.md
Normal file
33
docs/language/light/light-point.md
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
---
|
||||||
|
id: light-point
|
||||||
|
name: Light.point
|
||||||
|
category: light
|
||||||
|
kind: namespace-method
|
||||||
|
tokens: Light.point
|
||||||
|
sig: Light.point(x, y, radius, color, energy)
|
||||||
|
tip: Add a radial glow that falls off with distance.
|
||||||
|
order: 2
|
||||||
|
ns: Light
|
||||||
|
member: point
|
||||||
|
---
|
||||||
|
|
||||||
|
Additively accumulates a radial point light centred at <code>(x, y)</code>. Brightness is full at the centre and falls off linearly to zero at <code>radius</code> pixels, scaled by <code>energy</code> (a <code>fixed</code> multiplier — <code>1.0</code> is full, <code>2.0</code> over-drives toward white, <code>0.5</code> is dim), and clamped per channel at 255 so overlapping lights add without wrapping. Only the light's bounding box is touched, and any <a href="light-occlude"><code>Light.occlude</code></a> rectangle between the centre and a pixel puts that pixel in shadow. Colours are <code>0x00RRGGBB</code>.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- `x`, `y` — the light centre, in screen pixels
|
||||||
|
- `radius` — the reach in pixels; brightness is zero at and beyond it
|
||||||
|
- `color` — the light colour, `0x00RRGGBB`
|
||||||
|
- `energy` — a `fixed` brightness multiplier (`1.0` = full)
|
||||||
|
|
||||||
|
```ludic
|
||||||
|
program Demo {
|
||||||
|
property Torch { x: int = 0, y: int = 0 }
|
||||||
|
model Lamp { Torch }
|
||||||
|
handler Render phase Update {
|
||||||
|
Screen.clear(0)
|
||||||
|
Light.ambient(Color.rgb(32, 32, 48))
|
||||||
|
Light.point(80, 60, 70, Color.rgb(255, 210, 130), 1.0) # a warm torch
|
||||||
|
Screen.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
69
examples/library/lighting.ludic
Normal file
69
examples/library/lighting.ludic
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
# 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() }
|
||||||
|
}
|
||||||
180
runtime/native/light.ludic
Normal file
180
runtime/native/light.ludic
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 == "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") }
|
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,
|
# 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
|
# 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 =
|
# read two int fields (field ids) as (x, y). nearest/first return an entity (-1 =
|
||||||
|
|
|
||||||
|
|
@ -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 == "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 == "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 == "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()
|
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
|
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_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_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_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 {
|
function already_loaded(full: pointer) -> bool {
|
||||||
var i = 0
|
var i = 0
|
||||||
|
|
@ -537,6 +539,13 @@ function maybe_splice_runtime() -> void {
|
||||||
do_import("runtime/native/query.ludic")
|
do_import("runtime/native/query.ludic")
|
||||||
cur_dir = saved
|
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 {
|
function parse_program() -> void {
|
||||||
|
|
@ -557,6 +566,7 @@ function parse_program() -> void {
|
||||||
g_uses_regex = false
|
g_uses_regex = false
|
||||||
g_uses_query = false
|
g_uses_query = false
|
||||||
g_uses_reflect = false
|
g_uses_reflect = false
|
||||||
|
g_uses_light = false
|
||||||
loaded_paths = new []pointer
|
loaded_paths = new []pointer
|
||||||
skipnl()
|
skipnl()
|
||||||
g_game_name = "Ludic"
|
g_game_name = "Ludic"
|
||||||
|
|
|
||||||
30310
selfhost/ludicc.seed.ll
30310
selfhost/ludicc.seed.ll
File diff suppressed because it is too large
Load diff
|
|
@ -108,6 +108,7 @@ function cmd_test() -> int {
|
||||||
feat_case("library/query", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "query.ludic (Query count/first/nearest/within — ECS spatial queries over the reflection ABI)")
|
feat_case("library/query", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "query.ludic (Query count/first/nearest/within — ECS spatial queries over the reflection ABI)")
|
||||||
feat_case("library/reflect", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20", "reflect.ludic (Reflect prop/field enumeration + type + get/set/has/kind — runtime reflection over the world schema)")
|
feat_case("library/reflect", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20", "reflect.ludic (Reflect prop/field enumeration + type + get/set/has/kind — runtime reflection over the world schema)")
|
||||||
feat_case("library/render", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "render.ludic (Screen pixel/oval/camera/clip/blend_mode/measure_text + Camera set/follow/shake, verified by pixel readback)")
|
feat_case("library/render", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "render.ludic (Screen pixel/oval/camera/clip/blend_mode/measure_text + Camera set/follow/shake, verified by pixel readback)")
|
||||||
|
feat_case("library/lighting", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14", "lighting.ludic (Light ambient/point radial falloff + occluder hard shadows — 2D light accumulation, verified by pixel readback)")
|
||||||
feat_case("library/logging", "", "0 5 2 1", "logging.ludic (Log levels, set_level/level threshold, structured fields)")
|
feat_case("library/logging", "", "0 5 2 1", "logging.ludic (Log levels, set_level/level threshold, structured fields)")
|
||||||
# Os known-folders/arch and Fs.list read the BSD utsname/dirent layout, so
|
# Os known-folders/arch and Fs.list read the BSD utsname/dirent layout, so
|
||||||
# their asserted values are macOS-specific; skip off Darwin (see is_darwin).
|
# their asserted values are macOS-specific; skip off Darwin (see is_darwin).
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue