140 lines
6 KiB
Text
140 lines
6 KiB
Text
# ============================================================================
|
|
# 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(rt_core_st: mut RtCoreState, rt_light_st: mut RtLightState) -> 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(rt_core_st, World.get(ae, pa, af)) }
|
|
}
|
|
}
|
|
|
|
# 2. occluders — re-register every `Occluder` as a shadow caster this frame.
|
|
light_clear_occluders(rt_light_st)
|
|
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(rt_light_st, 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). Beyond the core five fields, a
|
|
# Light2D may carry optional tier fields, each defaulting off when absent so an
|
|
# older five-field component still lights exactly as before:
|
|
# direction, spread — a cone/spot (spread < 0 or absent = omnidirectional)
|
|
# falloff — brightness ramp exponent (1 = linear, absent = 1)
|
|
# softness — penumbra radius in px (0 / absent = hard shadows)
|
|
# gel — outer rim colour for a colour cookie (< 0 / absent = off)
|
|
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")
|
|
let ldf = World.field_id(pl, "direction")
|
|
let lsf = World.field_id(pl, "spread")
|
|
let lff = World.field_id(pl, "falloff")
|
|
let lkf = World.field_id(pl, "softness")
|
|
let lgf = World.field_id(pl, "gel")
|
|
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) }
|
|
|
|
# tier controls: apply per light, resetting to defaults each time so one
|
|
# cone/soft light does not bleed its settings onto the next.
|
|
var falloff = 1
|
|
if lff >= 0 { falloff = World.get(e, pl, lff) }
|
|
light_set_falloff(rt_light_st, falloff)
|
|
var soft = 0
|
|
if lkf >= 0 { soft = World.get(e, pl, lkf) }
|
|
light_set_soft(rt_light_st, soft)
|
|
if lgf >= 0 { light_set_gel(rt_light_st, World.get(e, pl, lgf)) } else { light_clear_gel(rt_light_st) }
|
|
|
|
var spread = -1
|
|
if lsf >= 0 { spread = World.get(e, pl, lsf) }
|
|
var direction = 0
|
|
if ldf >= 0 { direction = World.get(e, pl, ldf) }
|
|
if spread >= 0 { light_spot(rt_core_st, rt_light_st, x, y, radius, color, energy, direction, spread) }
|
|
else { light_point(rt_core_st, rt_light_st, 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()
|
|
}
|