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] == "#"