afterglow/view/diorama.pyΒΆ

Part of Afterglow.

  1"""Diorama: turn a sim Room's static tile grid into one lit 3D mesh scene.
  2
  3The diorama is the *static* half of the 3D view: solid '#' tiles batched into a
  4single merged stone mesh, '^' hazard tiles raised as red-emissive spikes, and a
  5back/floor plane for depth. Dynamic entities (player, crystals, orbs, ...) are
  6the EntityView's job (sprites.py): the diorama only reads the grid.
  7
  8Coordinates follow the authoritative view mapping:
  9    WORLD_SCALE S = 1 / TILE_SIZE   (1 tile == 1.0 world unit)
 10    logical pixel (x, y), y-DOWN  ->  world (x * S, -y * S, 0)
 11Tiles are addressed by their tile coordinate (tx, ty); a tile's centre in world
 12space is ((tx + 0.5), -(ty + 0.5), 0) since one tile is one world unit.
 13
 14The view is STRICTLY read-only over the sim. Building geometry is GPU-free
 15(MeshBuilder + Material are pure data), so a Diorama instantiates and builds
 16headlessly; nothing here touches a renderer directly.
 17"""
 18
 19from __future__ import annotations
 20
 21import numpy as np
 22
 23from simvx.core import Material, MeshBuilder, MeshInstance3D, Node3D
 24from simvx.core.mesh_builder import PrimitiveType
 25
 26from ..assets.textures import stone, world_palette
 27
 28# View mapping constants (kept local; the sim owns TILE_SIZE for logic).
 29TILE_SIZE = 8
 30WORLD_SCALE = 1.0 / TILE_SIZE
 31
 32# Diorama look: solids are unit boxes extruded along Z so the room reads as a
 33# shallow relief carving rather than a flat tilemap.
 34SOLID_DEPTH = 1.5
 35BACKWALL_Z = -SOLID_DEPTH * 0.5 - 0.25  # just behind the carved solids
 36SPIKE_DEPTH = 0.9
 37
 38
 39def _face(st: MeshBuilder, verts, normal, *, colour=None) -> None:
 40    """Emit one quad (4 corners CCW) as two triangles with per-face UVs."""
 41    st.set_normal(normal)
 42    if colour is not None:
 43        st.set_colour(colour)
 44    base = st.vertex_count
 45    uvs = ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0))
 46    for v, uv in zip(verts, uvs, strict=True):
 47        st.set_uv(uv)
 48        st.add_vertex(v)
 49    for i in (0, 1, 2, 0, 2, 3):
 50        st.add_index(base + i)
 51
 52
 53def _box(st: MeshBuilder, cx: float, cy: float, cz: float, hx: float, hy: float, hz: float, *, faces=None) -> None:
 54    """Emit an axis-aligned box centred at (cx, cy, cz) with half-extents.
 55
 56    ``faces`` optionally restricts which faces are emitted (e.g. skip the back
 57    face that the wall hides) as a set of {'+x','-x','+y','-y','+z','-z'}.
 58    """
 59    x0, x1 = cx - hx, cx + hx
 60    y0, y1 = cy - hy, cy + hy
 61    z0, z1 = cz - hz, cz + hz
 62    quads = {
 63        "+z": ([(x0, y0, z1), (x1, y0, z1), (x1, y1, z1), (x0, y1, z1)], (0, 0, 1)),
 64        "-z": ([(x1, y0, z0), (x0, y0, z0), (x0, y1, z0), (x1, y1, z0)], (0, 0, -1)),
 65        "+x": ([(x1, y0, z1), (x1, y0, z0), (x1, y1, z0), (x1, y1, z1)], (1, 0, 0)),
 66        "-x": ([(x0, y0, z0), (x0, y0, z1), (x0, y1, z1), (x0, y1, z0)], (-1, 0, 0)),
 67        "+y": ([(x0, y1, z1), (x1, y1, z1), (x1, y1, z0), (x0, y1, z0)], (0, 1, 0)),
 68        "-y": ([(x0, y0, z0), (x1, y0, z0), (x1, y0, z1), (x0, y0, z1)], (0, -1, 0)),
 69    }
 70    for key, (verts, normal) in quads.items():
 71        if faces is None or key in faces:
 72            _face(st, verts, normal)
 73
 74
 75class Diorama(Node3D):
 76    """Static 3D geometry for one room: solids, hazards, and a back/floor plane.
 77
 78    Usage::
 79
 80        dio = Diorama()
 81        parent.add_child(dio)
 82        dio.build(room)        # (re)build for a room; safe to call on room change
 83
 84    ``build`` is idempotent: it clears prior geometry first, so the same node
 85    can host every room in sequence. All meshes are children MeshInstance3D
 86    nodes, so the diorama composes with the rest of the scene tree normally.
 87    """
 88
 89    def __init__(self, **kwargs):
 90        super().__init__(**kwargs)
 91        self._world: str | None = None
 92        self._solid_mi: MeshInstance3D | None = None
 93        self._spike_mi: MeshInstance3D | None = None
 94        self._wall_mi: MeshInstance3D | None = None
 95
 96    # -- public ------------------------------------------------------------
 97
 98    def clear(self) -> None:
 99        """Destroy all built geometry, leaving an empty diorama node."""
100        self.clear_children()
101        self._solid_mi = self._spike_mi = self._wall_mi = None
102
103    def build(self, room) -> None:
104        """Build (or rebuild) static geometry from ``room``'s tile grid."""
105        self.clear()
106        self._world = room.palette
107        # Only the stone maps are used here: the crystal / metal generators in
108        # textures.py are swap-in placeholders for future geometry, so building
109        # them for a room nobody renders them in would be wasted work.
110        stone_maps = stone(self._world)
111        pal = world_palette(self._world)
112
113        self._wall_mi = self.add_child(self._build_backwall(room, pal))
114        solid = self._build_solids(room)
115        if solid is not None:
116            self._solid_mi = self.add_child(
117                MeshInstance3D(mesh=solid, material=self._stone_material(stone_maps), name="solids")
118            )
119        spikes = self._build_spikes(room)
120        if spikes is not None:
121            self._spike_mi = self.add_child(
122                MeshInstance3D(mesh=spikes, material=self._hazard_material(pal), name="hazards")
123            )
124
125    # -- materials ---------------------------------------------------------
126
127    @staticmethod
128    def _stone_material(maps) -> Material:
129        return Material(
130            albedo_map=maps["albedo"],
131            normal_map=maps["normal"],
132            roughness=0.85,
133            metallic=0.0,
134        )
135
136    @staticmethod
137    def _hazard_material(pal) -> Material:
138        # Hot red-shifted spikes that read as danger and feed bloom.
139        glow = (0.95, 0.18, 0.16)
140        return Material(
141            colour=(0.5, 0.06, 0.06, 1.0),
142            roughness=0.4,
143            emissive_colour=glow,
144            emissive_strength=2.4,
145        )
146
147    def _backwall_material(self, pal) -> Material:
148        # A deep, slightly desaturated back plane that is distinctly darker than
149        # the lit stone so the play-space reads as a diorama inside a darker
150        # frame. A faint emissive lift keeps it from crushing to pure black (so
151        # the silhouette of the relief still separates) without ever blooming.
152        lo = np.array(pal["stone_low"], dtype=np.float32) / 255.0
153        back = lo * 0.65
154        return Material(
155            colour=(*back, 1.0),
156            roughness=1.0,
157            metallic=0.0,
158            emissive_colour=tuple(back * 0.5),
159            emissive_strength=0.6,
160        )
161
162    # -- geometry ----------------------------------------------------------
163
164    def _build_solids(self, room):
165        """Batch every '#' tile into ONE extruded box mesh (back face culled)."""
166        st = MeshBuilder()
167        st.begin(PrimitiveType.TRIANGLES)
168        any_tile = False
169        grid = room.grid
170        for ty in range(room.h):
171            row = grid[ty]
172            for tx in range(room.w):
173                if row[tx] != "#":
174                    continue
175                any_tile = True
176                cx = tx + 0.5
177                cy = -(ty + 0.5)
178                # Skip interior faces that abut another solid: fewer triangles,
179                # cleaner normals on the carved silhouette.
180                faces = {"+z"}
181                if not self._solid(grid, room, tx - 1, ty):
182                    faces.add("-x")
183                if not self._solid(grid, room, tx + 1, ty):
184                    faces.add("+x")
185                if not self._solid(grid, room, tx, ty - 1):
186                    faces.add("+y")
187                if not self._solid(grid, room, tx, ty + 1):
188                    faces.add("-y")
189                _box(st, cx, cy, 0.0, 0.5, 0.5, SOLID_DEPTH * 0.5, faces=faces)
190        if not any_tile:
191            return None
192        st.generate_tangents()
193        return st.commit()
194
195    def _build_spikes(self, room):
196        """Raise each '^' hazard tile as a cluster of small pyramids."""
197        st = MeshBuilder()
198        st.begin(PrimitiveType.TRIANGLES)
199        any_tile = False
200        for ty in range(room.h):
201            row = room.grid[ty]
202            for tx in range(room.w):
203                if row[tx] != "^":
204                    continue
205                any_tile = True
206                self._spike_cluster(st, tx, ty)
207        if not any_tile:
208            return None
209        return st.commit()
210
211    def _spike_cluster(self, st: MeshBuilder, tx: int, ty: int) -> None:
212        """Three small upward pyramids filling one hazard tile (top at -ty)."""
213        base_y = -(ty + 1.0)  # bottom edge of the tile in world Y
214        tip_y = -ty + 0.05  # tips poke just above the tile's top edge
215        for ox in (0.18, 0.5, 0.82):
216            bx = tx + ox
217            half = 0.16
218            apex = (bx, tip_y, SPIKE_DEPTH * 0.5)
219            corners = [
220                (bx - half, base_y, SPIKE_DEPTH * 0.5),
221                (bx + half, base_y, SPIKE_DEPTH * 0.5),
222                (bx + half, base_y, -SPIKE_DEPTH * 0.5),
223                (bx - half, base_y, -SPIKE_DEPTH * 0.5),
224            ]
225            for j in range(4):
226                a = corners[j]
227                b = corners[(j + 1) % 4]
228                edge1 = np.subtract(b, a)
229                edge2 = np.subtract(apex, a)
230                n = np.cross(edge1, edge2)
231                ln = float(np.linalg.norm(n))
232                normal = (n / ln) if ln > 1e-6 else (0.0, 0.0, 1.0)
233                st.set_normal(normal)
234                base = st.vertex_count
235                st.set_uv((0.0, 1.0))
236                st.add_vertex(a)
237                st.set_uv((1.0, 1.0))
238                st.add_vertex(b)
239                st.set_uv((0.5, 0.0))
240                st.add_vertex(apex)
241                st.add_index(base)
242                st.add_index(base + 1)
243                st.add_index(base + 2)
244
245    def _build_backwall(self, room, pal) -> MeshInstance3D:
246        """One quad behind the whole room for depth + a thin floor lip."""
247        st = MeshBuilder()
248        st.begin(PrimitiveType.TRIANGLES)
249        w, h = float(room.w), float(room.h)
250        # Vertical back plane spanning the room, facing +Z toward the camera.
251        _face(
252            st,
253            [(0.0, -h, BACKWALL_Z), (w, -h, BACKWALL_Z), (w, 0.0, BACKWALL_Z), (0.0, 0.0, BACKWALL_Z)],
254            (0, 0, 1),
255        )
256        mesh = st.commit()
257        return MeshInstance3D(mesh=mesh, material=self._backwall_material(pal), name="backwall")
258
259    # -- helpers -----------------------------------------------------------
260
261    @staticmethod
262    def _solid(grid, room, tx: int, ty: int) -> bool:
263        """True if (tx, ty) is an in-bounds '#'. Out of bounds counts open."""
264        if tx < 0 or ty < 0 or tx >= room.w or ty >= room.h:
265            return False
266        return grid[ty][tx] == "#"