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)