afterglow/view/lighting.pyΒΆ
Part of Afterglow.
1"""Per-world 3D lighting rigs for the Afterglow diorama.
2
3The WorldEnvironment (environment.py) owns tone/bloom/grade; this module owns the
4*direct* light that actually shapes the relief. Each world gets a deliberate
5three-point rig plus a movable Wisp light the game attaches to the player:
6
7 KEY : the dominant shaping light, off to one side and in front.
8 FILL : a softer, cooler (or accent-tinted) light from the opposite side that
9 lifts the shadows so the room stays readable (high contrast, not murky).
10 RIM : a hot back/edge light that separates platforms from the back wall and
11 drives the bloom on bright surfaces (warm gold for glade/spire, an icy
12 cyan accent for caverns).
13 WISP : a small, bright point light that rides the player so the Wisp casts its
14 own pool of light onto nearby geometry; it brightens while glowing.
15
16Lights are positioned in the diorama's world space (one tile == one world unit,
17y-DOWN logical -> y-UP world). Every rig position is an OFFSET from the room
18centre, so passing the room's tile size recentres the whole rig over that room;
19rooms a little larger or smaller still read well because the ranges are generous
20enough to cover the grid from the centre outward.
21
22Construction is GPU-free (PointLight3D is plain Property data), so a rig builds
23headlessly for the capture/verify loop and for tests.
24
25Usage::
26
27 rig = setup_world_lights("glade", room.w, room.h)
28 rig.add_to(scene_root)
29 # each frame, after the room steps:
30 rig.update(player_world_pos, glowing=room.player.glowing)
31"""
32
33from __future__ import annotations
34
35from dataclasses import dataclass
36
37from simvx.core import DirectionalLight3D, Node3D, PointLight3D, Vec3
38
39# Fallback room frame in tiles (== world units) for a rig built without a room,
40# e.g. the title screen. Every authored room is this size (see rooms_data), and
41# a rig built for a real room takes that room's own width / height.
42ROOM_W = 56.0
43ROOM_H = 30.0
44
45# Wisp light: a soft warm halo that rides the player. With a bright base rig this
46# is an ACCENT (a gentle pool near the hero), never the room's main light, so the
47# "flashlight in the dark" look is gone; it lifts + warms a touch while glowing.
48WISP_Z = 2.0
49WISP_RANGE = 8.0
50WISP_INTENSITY = 6.0
51WISP_INTENSITY_GLOW = 12.0
52WISP_COLOUR = (0.72, 1.0, 0.88)
53WISP_COLOUR_GLOW = (1.0, 0.96, 0.76)
54
55# Shadow-casting directional sun: intensity is a meaningful slice of the rig so
56# its cast shadows actually read; the fill is softened so it does not flood the
57# shadowed areas back to full brightness.
58_SUN_INTENSITY = 10.0
59_FILL_SCALE = 0.5
60
61
62@dataclass(frozen=True)
63class _LightDef:
64 """One static rig light: colour, intensity, range and offset from the centre."""
65
66 colour: tuple[float, float, float]
67 intensity: float
68 range: float
69 offset: tuple[float, float, float]
70 name: str
71
72
73# Per-world three-point rigs. ``offset`` is (dx, dy, z) in world units measured
74# from the room centre; +z is toward the camera, so all three sit WELL in front
75# of the z=0 play plane (z ~ 22-26) and near the centre, making each one a broad,
76# flat wash over the WHOLE room rather than a hot spot in one corner. Intensities
77# are modest and ranges are large (the whole room is inside the falloff), giving
78# a soft, near-uniform base light with gentle directionality: key slightly left,
79# fill slightly right + cooler, rim high-centre + warm to lift the top edges.
80_RIGS: dict[str, tuple[_LightDef, _LightDef, _LightDef]] = {
81 # GLADE: bright warm daylight key, cool sky fill, golden rim. Sunny + clean.
82 "glade": (
83 _LightDef((1.0, 0.95, 0.80), 16.0, 90.0, (-9.0, 5.0, 24.0), "key_light"),
84 _LightDef((0.66, 0.80, 0.96), 11.0, 90.0, (11.0, -4.0, 24.0), "fill_light"),
85 _LightDef((1.0, 0.86, 0.52), 9.0, 80.0, (0.0, 9.0, 22.0), "rim_light"),
86 ),
87 # CAVERNS: cool blue key + crystal-cyan fill keep the moody hue, but the broad
88 # wash makes the stone plainly read (not just a silhouette) across the room.
89 "caverns": (
90 _LightDef((0.70, 0.82, 1.0), 21.0, 92.0, (-9.0, 5.0, 24.0), "key_light"),
91 _LightDef((0.50, 0.92, 1.0), 15.0, 92.0, (11.0, -4.0, 24.0), "fill_light"),
92 _LightDef((0.55, 1.0, 0.98), 12.0, 82.0, (0.0, 9.0, 22.0), "rim_light"),
93 ),
94 # SPIRE: warm gold key + amber rim for a dramatic look, but the fill lifts the
95 # bronze so the whole room reads brightly rather than crushing to black.
96 "spire": (
97 _LightDef((1.0, 0.86, 0.50), 21.0, 92.0, (-9.0, 5.0, 24.0), "key_light"),
98 _LightDef((1.0, 0.70, 0.38), 15.0, 92.0, (11.0, -4.0, 24.0), "fill_light"),
99 _LightDef((1.0, 0.78, 0.34), 12.0, 82.0, (0.0, 9.0, 22.0), "rim_light"),
100 ),
101}
102
103
104class WorldLights:
105 """A built three-point rig + a movable Wisp light for one world.
106
107 Holds the four ``PointLight3D`` nodes (key, fill, rim, wisp) under a single
108 ``Node3D`` so ``add_to`` parents the whole rig in one call and the game drives
109 only the Wisp each frame via ``update``.
110
111 ``room_w`` / ``room_h`` are the room's size in tiles; the rig centres itself
112 on that room so the wash covers the whole grid instead of drifting toward one
113 side of it.
114 """
115
116 def __init__(self, world_id: str, room_w: float = ROOM_W, room_h: float = ROOM_H) -> None:
117 self.world_id = world_id
118 self.root = Node3D(name=f"lights_{world_id}")
119 self._cx = float(room_w) * 0.5
120 self._cy = -float(room_h) * 0.5
121 key_d, fill_d, rim_d = _RIGS.get(world_id, _RIGS["glade"])
122 self.key = self._make(key_d)
123 # The fill is deliberately softened (the rig was washing the room so flat
124 # that no shadow could read); a dimmer fill lets the sun's cast shadows
125 # show while still lifting the room out of black.
126 self.fill = self._make(fill_d)
127 self.fill.intensity *= _FILL_SCALE
128 self.rim = self._make(rim_d)
129 # Directional sun: a single DirectionalLight3D that rakes across the relief
130 # to give the carved stone a consistent shaping light on top of the broad
131 # point wash (the point rig alone is near-flat). It looks down-and-across
132 # the play plane from the upper-left.
133 #
134 # CAST SHADOWS ARE DELIBERATELY OFF here, and that is the correct call for
135 # THIS geometry, not an oversight. The diorama (diorama.py) is a wall of
136 # unit cubes whose camera-facing +Z faces are all COPLANAR at z=0, sitting
137 # just in front of a single flat back-wall; interior faces between adjacent
138 # solids are culled. A cast shadow therefore has essentially no receiving
139 # surface in the camera's view to fall on -- a caster's shadow lands on the
140 # culled side face of its neighbour or on the hidden back wall. Verified by
141 # A/B render: toggling CSM on/off (even with a steepened, brightened sun and
142 # the point fill dimmed) moved the framed image by < 0.4 mean pixel value,
143 # i.e. no readable shadow, while costing a full shadow-map pass per frame.
144 # The contact darkening this shallow relief CAN express (crevice/silhouette
145 # occlusion the camera does see) is delivered by SSAO in environment.py,
146 # which is the right technique for a flat-fronted diorama. Re-enabling CSM
147 # would need real depth separation between casters and receivers (a deeper,
148 # multi-plane set), which would change the art direction.
149 self.sun = DirectionalLight3D(
150 colour=(1.0, 0.96, 0.9),
151 intensity=_SUN_INTENSITY,
152 position=Vec3(self._cx - 14.0, self._cy + 8.0, 22.0),
153 name="sun_directional",
154 )
155 self.sun.shadows = False
156 self.sun.look_at((self._cx + 6.0, self._cy - 4.0, 0.0))
157 self.wisp = PointLight3D(
158 colour=WISP_COLOUR,
159 intensity=WISP_INTENSITY,
160 range=WISP_RANGE,
161 position=Vec3(self._cx, self._cy, WISP_Z),
162 name="wisp_light",
163 )
164 for light in (self.sun, self.key, self.fill, self.rim, self.wisp):
165 self.root.add_child(light)
166
167 def _make(self, d: _LightDef) -> PointLight3D:
168 pos = Vec3(self._cx + d.offset[0], self._cy + d.offset[1], d.offset[2])
169 return PointLight3D(colour=d.colour, intensity=d.intensity, range=d.range, position=pos, name=d.name)
170
171 def add_to(self, scene_root: Node3D) -> WorldLights:
172 """Parent the whole rig under ``scene_root``; returns self for chaining."""
173 scene_root.add_child(self.root)
174 return self
175
176 def update(self, player_world_pos, glowing: bool) -> None:
177 """Move the Wisp light to the player and brighten/warm it while glowing.
178
179 ``player_world_pos`` is the player centre in diorama world space (e.g.
180 ``Vec3(cx * WORLD_SCALE, -cy * WORLD_SCALE, 0)``); the light sits a little
181 in front of the play plane so it pools onto the relief.
182 """
183 x, y = float(player_world_pos[0]), float(player_world_pos[1])
184 self.wisp.position = Vec3(x, y, WISP_Z)
185 if glowing:
186 self.wisp.intensity = WISP_INTENSITY_GLOW
187 self.wisp.colour = WISP_COLOUR_GLOW
188 self.wisp.range = WISP_RANGE * 1.25
189 else:
190 self.wisp.intensity = WISP_INTENSITY
191 self.wisp.colour = WISP_COLOUR
192 self.wisp.range = WISP_RANGE
193
194
195def setup_world_lights(world_id: str, room_w: float = ROOM_W, room_h: float = ROOM_H) -> WorldLights:
196 """Build the per-world three-point rig + Wisp light holder for ``world_id``.
197
198 Pass the room's tile width / height so the rig centres on it; the defaults
199 cover a rig built without a room (the title screen). Unknown ids fall back to
200 the glade rig so the view never crashes on a not-yet-themed world.
201 """
202 return WorldLights(world_id, room_w, room_h)