feat(ecs): Light2D/Occluder/Ambient as auto-consumed components (#47)
The ECS-native shape the 2D lighting follow-up asked for, built on the engine-owned-system hook from #43. A torch is just an entity carrying Light2D, a wall an entity carrying Occluder, and one Ambient entity sets the night tint — the engine runs the whole deterministic light pass at the end of the Render phase (ambient modulate -> carve occluder shadows -> accumulate additive radial lights) and presents. No Light.* calls wired. - runtime/native/systems_light.ludic: esys_light2d, consuming the components through the by-name reflection ABI and reusing the #4 accumulation core (light_ambient / light_occlude / light_point). Reads position from a Position component when present, else the light's own x/y. - Split from systems.ludic so a SpriteAnim/Motion-only game never links the light pass; spliced (with light.ludic) only when Light2D/Occluder declared. - emit_main now boots rt_init like the auto-loop/test runner, so an entry-driven game that renders has its framebuffer allocated (headless: allocate only, no window, byte-identical stdout for non-rendering games). Worked example + regression: examples/library/light_ecs.ludic (32 1 32 1). Full suite 74 passed, self-host C-free fixpoint intact, no golden drift. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
b0143337d9
commit
3679ce1797
8 changed files with 14660 additions and 14404 deletions
13
LANGUAGE.md
13
LANGUAGE.md
|
|
@ -270,6 +270,9 @@ enough, and a game that declares none is byte-for-byte unchanged.
|
|||
|---|---|---|
|
||||
| `SpriteAnim { ticks, fps, frames, mode, frame }` | `Update` | advances `frame` — spritesheet frame animation (`mode` 0 loop, 1 once, 2 ping-pong) |
|
||||
| `Motion { ticks, dur, from, to, ease, value, done }` | `Update` | advances `value` — value tween (`ease` 0 linear, 1 in, 2 out, 3 in-out), latches `done` |
|
||||
| `Light2D { x, y, radius, color, intensity }` | `Render` | additive radial glow; the engine runs the whole 2D light pass and presents |
|
||||
| `Occluder { x, y, w, h }` | `Render` | a rectangular shadow caster the light pass carves out |
|
||||
| `Ambient { color }` | `Render` | one entity tints the whole scene (night/cave) before lights accumulate |
|
||||
|
||||
```ludic
|
||||
# doc-check: skip — illustrative engine-owned system
|
||||
|
|
@ -279,9 +282,15 @@ model Hero { Pos, SpriteAnim }
|
|||
spawn Hero { Pos { x: 0, y: 0 } SpriteAnim { fps: 10, frames: 6, mode: 0 } }
|
||||
```
|
||||
|
||||
A `Light2D` / `Occluder` reads its position from a `Position { x, y }` component
|
||||
on the same entity when the entity carries one, else from its own `x` / `y`
|
||||
fields — so "Position + Light2D" and a self-positioned light both work. With
|
||||
`Light2D` present the engine owns the frame flip: a draw handler renders the
|
||||
scene and does **not** call `Screen.show`.
|
||||
|
||||
Everything is integer and deterministic (the frame clock ticks at a fixed 60/s),
|
||||
so animation and motion reproduce exactly under replay and lockstep netcode. See
|
||||
`examples/library/anim_ecs.ludic`.
|
||||
so animation, motion and lighting reproduce exactly under replay and lockstep
|
||||
netcode. See `examples/library/anim_ecs.ludic` and `examples/library/light_ecs.ludic`.
|
||||
|
||||
## Annotations
|
||||
|
||||
|
|
|
|||
3
changes/light-ecs-components.md
Normal file
3
changes/light-ecs-components.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
bump: minor
|
||||
type: feat
|
||||
ECS-native 2D lighting (#47) — a torch is now just an entity carrying `Light2D { x, y, radius, color, intensity }`, a wall an entity carrying `Occluder { x, y, w, h }`, and one `Ambient { color }` entity sets the night tint. The engine runs the whole deterministic light pass (ambient modulate → carve occluder shadows → accumulate each additive radial light) at the end of the Render phase and presents — no `Light.*` calls wired by hand. A `Light2D`/`Occluder` takes its position from a `Position { x, y }` component when the entity carries one, else from its own `x`/`y`. Built on the engine-owned-system hook, so it costs nothing in a game that declares none of these components.
|
||||
45
examples/library/light_ecs.ludic
Normal file
45
examples/library/light_ecs.ludic
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# light_ecs.ludic — engine-owned 2D lighting over user components (#47). A torch
|
||||
# is just an entity carrying Light2D, a wall an entity carrying Occluder, and a
|
||||
# single Ambient entity sets the night tint. The engine runs the whole light
|
||||
# pass at the end of the Render phase — no Light.* calls wired — then presents.
|
||||
#
|
||||
# We draw a white scene in a Render handler, drive one render tick, and sample
|
||||
# the framebuffer with Screen.pixel to prove ambient, additive light and a hard
|
||||
# shadow all landed. Deterministic (integer + Q16.16), so:
|
||||
# bin/ludic examples/library/light_ecs.ludic -> 32 1 32 1
|
||||
program LightEcs {
|
||||
property Ambient { color: int = 0 }
|
||||
property Light2D { x: int = 0, y: int = 0, radius: int = 0, color: int = 0, intensity: int = 0 }
|
||||
property Occluder { x: int = 0, y: int = 0, w: int = 0, h: int = 0 }
|
||||
|
||||
model Night { Ambient }
|
||||
model Torch { Light2D }
|
||||
model Wall { Occluder }
|
||||
|
||||
# the game draws its scene; the engine lights it and presents (do NOT call
|
||||
# Screen.show here — with Light2D the engine owns the flip).
|
||||
handler Draw phase Render {
|
||||
Screen.fill_rectangle(0, 0, 64, 64, 16777215) # a flat white scene (0xFFFFFF)
|
||||
}
|
||||
|
||||
entry {
|
||||
spawn Night { Ambient { color: 2105376 } } # 0x202020 — dim night
|
||||
spawn Torch { Light2D { x: 20, y: 20, radius: 15, color: 16777215, intensity: 100 } }
|
||||
spawn Wall { Occluder { x: 25, y: 10, w: 2, h: 20 } } # a pillar just right of the torch
|
||||
|
||||
tick_render() # Draw (white) then the engine light pass
|
||||
|
||||
# a far corner sees ambient only: white 255 * 0x20/255 = 32.
|
||||
print(Screen.pixel(60, 60) & 255) # 32
|
||||
# the torch centre is fully lit, far brighter than ambient.
|
||||
print(bool_i((Screen.pixel(20, 20) & 255) > 32)) # 1
|
||||
# a point the pillar shadows from the torch stays at ambient.
|
||||
print(Screen.pixel(30, 20) & 255) # 32
|
||||
# an equidistant point the pillar does not block is lit.
|
||||
print(bool_i((Screen.pixel(20, 30) & 255) > 32)) # 1
|
||||
|
||||
quit()
|
||||
}
|
||||
|
||||
function bool_i(b: bool) -> int { if b { return 1 }; return 0 }
|
||||
}
|
||||
113
runtime/native/systems_light.ludic
Normal file
113
runtime/native/systems_light.ludic
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
# ============================================================================
|
||||
# systems_light.ludic — the engine-owned 2D lighting render system (#47).
|
||||
#
|
||||
# This is the ECS-native shape the lighting follow-up asked for: a torch is just
|
||||
# an entity carrying `Light2D` (and optionally `Position`), a wall is an entity
|
||||
# carrying `Occluder`, and the engine runs the whole light pass for you at the
|
||||
# end of the Render phase — no `Light.point` / `Light.occlude` calls wired by
|
||||
# hand. It reuses the deterministic accumulation core shipped in #4 (light.ludic:
|
||||
# light_ambient / light_occlude / light_point), consuming components through the
|
||||
# by-name reflection ABI instead of imperative calls.
|
||||
#
|
||||
# Split from systems.ludic because it links against the light pass (light.ludic):
|
||||
# a game that uses SpriteAnim/Motion but no lights must not pull light_point in,
|
||||
# so esys_light2d lives in its own file, spliced only when Light2D / Occluder is
|
||||
# declared (parse.ludic). It is inserted into the Render phase after the game's
|
||||
# own draw handlers (emit_engine_systems_for_phase), so the scene is already on
|
||||
# the framebuffer when the light pass runs; it finishes by presenting the frame,
|
||||
# so an ECS-lit game leaves Screen.show to the engine.
|
||||
#
|
||||
# Component contracts:
|
||||
# property Ambient { color: int } # optional, one entity
|
||||
# property Light2D { x, y, radius, color, intensity } # intensity is 0..100 %
|
||||
# property Occluder { x, y, w, h }
|
||||
# A Light2D / Occluder reads its position from a `Position { x, y }` component on
|
||||
# the same entity when the entity carries one, else from its own x / y fields —
|
||||
# so "Position + Light2D" and a self-positioned Light2D both work.
|
||||
# ============================================================================
|
||||
|
||||
# read entity `e`'s world x: a `Position { x }` component wins when present, else
|
||||
# the component's own x field (fx) on prop `p`. Same idea for y below.
|
||||
function esys_pos_x(e: int, p: int, fx: int) -> int {
|
||||
let pp = World.prop_id("Position")
|
||||
if pp >= 0 {
|
||||
if World.has(e, pp) != 0 {
|
||||
let f = World.field_id(pp, "x")
|
||||
if f >= 0 { return World.get(e, pp, f) }
|
||||
}
|
||||
}
|
||||
if fx >= 0 { return World.get(e, p, fx) }
|
||||
return 0
|
||||
}
|
||||
function esys_pos_y(e: int, p: int, fy: int) -> int {
|
||||
let pp = World.prop_id("Position")
|
||||
if pp >= 0 {
|
||||
if World.has(e, pp) != 0 {
|
||||
let f = World.field_id(pp, "y")
|
||||
if f >= 0 { return World.get(e, pp, f) }
|
||||
}
|
||||
}
|
||||
if fy >= 0 { return World.get(e, p, fy) }
|
||||
return 0
|
||||
}
|
||||
|
||||
function esys_light2d() -> void {
|
||||
let pl = World.prop_id("Light2D")
|
||||
if pl < 0 { return }
|
||||
|
||||
# 1. ambient — a single `Ambient { color }` entity modulates the whole scene
|
||||
# toward its tint (night / cave). Absent: the scene keeps its drawn brightness.
|
||||
let pa = World.prop_id("Ambient")
|
||||
if pa >= 0 {
|
||||
let af = World.field_id(pa, "color")
|
||||
let ae = World.query_next(pa, 0)
|
||||
if ae >= 0 {
|
||||
if af >= 0 { light_ambient(World.get(ae, pa, af)) }
|
||||
}
|
||||
}
|
||||
|
||||
# 2. occluders — re-register every `Occluder` as a shadow caster this frame.
|
||||
light_clear_occluders()
|
||||
let po = World.prop_id("Occluder")
|
||||
if po >= 0 {
|
||||
let oxf = World.field_id(po, "x")
|
||||
let oyf = World.field_id(po, "y")
|
||||
let owf = World.field_id(po, "w")
|
||||
let ohf = World.field_id(po, "h")
|
||||
var oe = World.query_next(po, 0)
|
||||
while oe >= 0 {
|
||||
let ox = esys_pos_x(oe, po, oxf)
|
||||
let oy = esys_pos_y(oe, po, oyf)
|
||||
var ow = 0
|
||||
var oh = 0
|
||||
if owf >= 0 { ow = World.get(oe, po, owf) }
|
||||
if ohf >= 0 { oh = World.get(oe, po, ohf) }
|
||||
light_occlude(ox, oy, ow, oh)
|
||||
oe = World.query_next(po, oe + 1)
|
||||
}
|
||||
}
|
||||
|
||||
# 3. lights — additively accumulate each `Light2D` as a radial glow, cut by the
|
||||
# occluders registered above (hard shadows).
|
||||
let lxf = World.field_id(pl, "x")
|
||||
let lyf = World.field_id(pl, "y")
|
||||
let lrf = World.field_id(pl, "radius")
|
||||
let lcf = World.field_id(pl, "color")
|
||||
let lif = World.field_id(pl, "intensity")
|
||||
var e = World.query_next(pl, 0)
|
||||
while e >= 0 {
|
||||
let x = esys_pos_x(e, pl, lxf)
|
||||
let y = esys_pos_y(e, pl, lyf)
|
||||
var radius = 0
|
||||
if lrf >= 0 { radius = World.get(e, pl, lrf) }
|
||||
var color = 16777215 # 0xFFFFFF (white) default
|
||||
if lcf >= 0 { color = World.get(e, pl, lcf) }
|
||||
var energy = fixed(1) # full intensity default
|
||||
if lif >= 0 { energy = fixed(World.get(e, pl, lif)) / fixed(100) }
|
||||
light_point(x, y, radius, color, energy)
|
||||
e = World.query_next(pl, e + 1)
|
||||
}
|
||||
|
||||
# 4. present — the engine owns the flip for an ECS-lit frame.
|
||||
Screen.show()
|
||||
}
|
||||
|
|
@ -52,6 +52,11 @@ function emit_main(d: Node) -> void {
|
|||
emit(" store i32 %argc, ptr @L_argc\n")
|
||||
emit(" store ptr %argv, ptr @L_argv\n")
|
||||
emit(" store i32 0, ptr %retval\n")
|
||||
# boot the runtime like the auto-loop / test runner do, so an `entry`-driven
|
||||
# game that renders (drives tick_render, draws, uses the engine light pass) has
|
||||
# its framebuffer allocated. Headless it only allocates — no window, no output —
|
||||
# so a non-rendering entry game is unchanged.
|
||||
if (find_fn("rt_init") != null) { emit(" call void @fn_rt_init()\n") }
|
||||
emit_block(d.a)
|
||||
if not g_term { emit(" br label %ret\n") }
|
||||
emit("ret:\n")
|
||||
|
|
|
|||
|
|
@ -613,7 +613,18 @@ function maybe_splice_runtime() -> void {
|
|||
if uses_engine_systems() {
|
||||
g_uses_esys = true
|
||||
cur_dir = ""
|
||||
do_import("runtime/native/systems.ludic")
|
||||
# SpriteAnim / Motion -> the animation systems (self-contained, reflection only)
|
||||
if (find_comp("SpriteAnim") != null) or (find_comp("Motion") != null) {
|
||||
do_import("runtime/native/systems.ludic")
|
||||
}
|
||||
# Light2D / Occluder -> the lighting render system, which links against the
|
||||
# 2D light pass (light.ludic). do_import dedupes, so this is a no-op when the
|
||||
# game also uses Light.* directly (g_uses_light already pulled it in).
|
||||
if (find_comp("Light2D") != null) or (find_comp("Occluder") != null) {
|
||||
do_import("runtime/native/light.ludic")
|
||||
do_import("runtime/native/systems_light.ludic")
|
||||
g_uses_light = true
|
||||
}
|
||||
cur_dir = saved
|
||||
}
|
||||
}
|
||||
|
|
|
|||
28868
selfhost/ludicc.seed.ll
28868
selfhost/ludicc.seed.ll
File diff suppressed because it is too large
Load diff
|
|
@ -246,6 +246,10 @@ function cmd_test() -> int {
|
|||
# game merely declares and carries — no handler wired. Entry-driven so it can
|
||||
# assert the auto-advanced fields at known tick counts.
|
||||
net_case("library/anim_ecs", "1 5 2 10 0 2 10 1 1")
|
||||
# #47: the engine light pass consumes Light2D / Occluder / Ambient components at
|
||||
# the end of the Render phase — ambient tint, additive glow, hard shadow — with
|
||||
# no Light.* calls wired. Sampled back off the framebuffer with Screen.pixel.
|
||||
net_case("library/light_ecs", "32 1 32 1")
|
||||
|
||||
feat_case("events/recurse", "", "16", "recurse.ludic (EV6: re-entrant emit is depth-bounded, no runaway cycle)")
|
||||
net_case("events/scoped", "2")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue