Tilemap¶
Demonstrates the GPU-batched tilemap renderer with:
Procedurally generated tileset texture (grass, dirt, water, stone, flowers)
Two layers: background terrain + foreground decorations
Arrow key / WASD camera panning
Single draw call per layer via SSBO instancing
Source Code¶
1#!/usr/bin/env python3
2"""TileMap GPU rendering demo -- procedural tileset, multi-layer, camera panning.
3
4Demonstrates the GPU-batched tilemap renderer with:
5- Procedurally generated tileset texture (grass, dirt, water, stone, flowers)
6- Two layers: background terrain + foreground decorations
7- Arrow key / WASD camera panning
8- Single draw call per layer via SSBO instancing
9"""
10
11
12import numpy as np
13
14from simvx.core import Camera3D, Input, InputMap, Key, Node, Property, Sprite2D, Vec2
15from simvx.core.tilemap import TileMap, TileSet
16from simvx.graphics import App
17
18# -- Procedural tileset texture -----------------------------------------------
19
20TILE_PX = 16 # pixels per tile
21ATLAS_COLS = 4
22ATLAS_ROWS = 2
23ATLAS_W = ATLAS_COLS * TILE_PX # 64
24ATLAS_H = ATLAS_ROWS * TILE_PX # 32
25
26# Tile IDs (row-major from create_from_grid)
27GRASS = 0
28DIRT = 1
29WATER = 2
30STONE = 3
31FLOWERS = 4
32TREE_TOP = 5
33WALL = 6
34EMPTY = 7
35
36
37def _fill_tile(atlas: np.ndarray, col: int, row: int, colour: tuple[int, ...]):
38 """Fill a tile region with a solid colour plus some noise for texture."""
39 x0, y0 = col * TILE_PX, row * TILE_PX
40 rng = np.random.RandomState(col * 7 + row * 13)
41 for dy in range(TILE_PX):
42 for dx in range(TILE_PX):
43 noise = rng.randint(-15, 16)
44 r = max(0, min(255, colour[0] + noise))
45 g = max(0, min(255, colour[1] + noise))
46 b = max(0, min(255, colour[2] + noise))
47 a = colour[3] if len(colour) > 3 else 255
48 atlas[y0 + dy, x0 + dx] = (r, g, b, a)
49
50
51def _add_detail(atlas: np.ndarray, col: int, row: int, detail_colour: tuple[int, ...], count: int = 8):
52 """Scatter random detail pixels on a tile."""
53 x0, y0 = col * TILE_PX, row * TILE_PX
54 rng = np.random.RandomState(col * 31 + row * 37)
55 c = detail_colour if len(detail_colour) == 4 else (*detail_colour, 255)
56 for _ in range(count):
57 dx, dy = rng.randint(1, TILE_PX - 1), rng.randint(1, TILE_PX - 1)
58 atlas[y0 + dy, x0 + dx] = c
59
60
61def generate_tileset_atlas() -> np.ndarray:
62 """Generate a simple procedural tileset atlas (RGBA uint8, shape HxWx4)."""
63 atlas = np.zeros((ATLAS_H, ATLAS_W, 4), dtype=np.uint8)
64
65 # Row 0: terrain
66 _fill_tile(atlas, 0, 0, (34, 139, 34)) # GRASS
67 _add_detail(atlas, 0, 0, (50, 160, 50), 12)
68 _fill_tile(atlas, 1, 0, (139, 90, 43)) # DIRT
69 _add_detail(atlas, 1, 0, (120, 75, 35), 6)
70 _fill_tile(atlas, 2, 0, (30, 100, 200)) # WATER
71 _add_detail(atlas, 2, 0, (60, 130, 220), 10)
72 _fill_tile(atlas, 3, 0, (128, 128, 128)) # STONE
73 _add_detail(atlas, 3, 0, (100, 100, 100), 8)
74
75 # Row 1: decorations
76 _fill_tile(atlas, 0, 1, (34, 139, 34)) # FLOWERS (grass + dots)
77 _add_detail(atlas, 0, 1, (255, 100, 100), 6)
78 _add_detail(atlas, 0, 1, (255, 255, 50), 4)
79 _fill_tile(atlas, 1, 1, (20, 100, 20)) # TREE_TOP
80 _add_detail(atlas, 1, 1, (30, 120, 30), 15)
81 _fill_tile(atlas, 2, 1, (90, 90, 90)) # WALL (brick pattern)
82 for dy in [0, 8]:
83 for dx in range(TILE_PX):
84 atlas[TILE_PX + dy, 2 * TILE_PX + dx] = (60, 60, 60, 255)
85 for dx in [0, 8]:
86 for dy in range(TILE_PX):
87 atlas[TILE_PX + dy, 2 * TILE_PX + dx] = (60, 60, 60, 255)
88 _fill_tile(atlas, 3, 1, (0, 0, 0, 0)) # EMPTY (transparent)
89
90 return atlas
91
92
93# -- Map generation -----------------------------------------------------------
94
95MAP_W, MAP_H = 40, 30
96
97
98def build_tilemap() -> TileMap:
99 """Create a TileMap with two layers: terrain + decorations."""
100 ts = TileSet.from_atlas_array(
101 generate_tileset_atlas(),
102 width=ATLAS_W,
103 height=ATLAS_H,
104 tile_size=(TILE_PX, TILE_PX),
105 )
106
107 tilemap = TileMap(name="DemoTileMap")
108 tilemap.tile_set = ts
109 tilemap.cell_size = (TILE_PX, TILE_PX)
110 tilemap.add_layer("Decorations")
111
112 rng = np.random.RandomState(42)
113
114 # Layer 0: terrain
115 for y in range(MAP_H):
116 for x in range(MAP_W):
117 if x == 0 or x == MAP_W - 1 or y == 0 or y == MAP_H - 1:
118 tilemap.set_cell(0, x, y, WALL)
119 elif 13 <= y <= 15 and 5 <= x <= MAP_W - 6:
120 tilemap.set_cell(0, x, y, WATER)
121 elif 19 <= x <= 21:
122 tilemap.set_cell(0, x, y, DIRT)
123 elif rng.random() < 0.05:
124 tilemap.set_cell(0, x, y, STONE)
125 else:
126 tilemap.set_cell(0, x, y, GRASS)
127
128 # Layer 1: decorations on grass
129 for y in range(1, MAP_H - 1):
130 for x in range(1, MAP_W - 1):
131 if tilemap.get_cell(0, x, y) != GRASS:
132 continue
133 r = rng.random()
134 if r < 0.08:
135 tilemap.set_cell(1, x, y, FLOWERS)
136 elif r < 0.12:
137 tilemap.set_cell(1, x, y, TREE_TOP)
138
139 return tilemap
140
141
142# -- Player sprite ------------------------------------------------------------
143
144PLAYER_PX = 24 # sprite display size
145CAMERA_Z = 500.0
146
147
148def _make_player_sprite(size: int = PLAYER_PX) -> np.ndarray:
149 """Procedural player sprite — yellow disc with a dark outline."""
150 img = np.zeros((size, size, 4), dtype=np.uint8)
151 cx, cy = (size - 1) / 2.0, (size - 1) / 2.0
152 r_outer = size / 2.0 - 0.5
153 r_inner = r_outer - 2.0
154 for y in range(size):
155 for x in range(size):
156 d = ((x - cx) ** 2 + (y - cy) ** 2) ** 0.5
157 if d <= r_inner:
158 img[y, x] = (255, 215, 80, 255) # warm yellow
159 elif d <= r_outer:
160 img[y, x] = (40, 28, 10, 255) # dark outline
161 return img
162
163
164# -- Game scene ---------------------------------------------------------------
165
166
167class TileMapDemo(Node):
168 """Root node: sets up tilemap + camera + player, handles player input.
169
170 The tilemap renders in world space via the 3D camera, while the player
171 sprite is drawn through Draw2D in screen space. To unify the two, we
172 keep the sprite pinned at screen centre and move a separate world-space
173 cursor that both the camera and movement bounds use.
174 """
175
176 player_speed = Property(180.0, range=(50, 500))
177
178 def ready(self):
179 InputMap.add_action("move_left", [Key.A, Key.LEFT])
180 InputMap.add_action("move_right", [Key.D, Key.RIGHT])
181 InputMap.add_action("move_up", [Key.W, Key.UP])
182 InputMap.add_action("move_down", [Key.S, Key.DOWN])
183 InputMap.add_action("quit", [Key.ESCAPE])
184
185 # Playable area = inside the wall border (one-tile-thick wall around
186 # the map). Half the sprite stays inside the wall so no pixel crosses
187 # the border. Bounds are in tilemap-world coordinates.
188 half = PLAYER_PX / 2.0
189 self._bounds_min = Vec2(TILE_PX + half, TILE_PX + half)
190 self._bounds_max = Vec2(
191 (MAP_W - 1) * TILE_PX - half,
192 (MAP_H - 1) * TILE_PX - half,
193 )
194
195 # Player's world-space position (not the sprite's screen position).
196 self._player_world = Vec2(MAP_W * TILE_PX / 2, MAP_H * TILE_PX / 2)
197
198 # TileMap (atlas pixels are on the TileSet; scene adapter uploads lazily)
199 self.add_child(build_tilemap())
200
201 # Player sprite — centred on screen. Its position is updated every
202 # frame to ``tree.screen_size / 2`` so it stays put even on resize.
203 self._player = self.add_child(Sprite2D(
204 texture=_make_player_sprite(),
205 width=PLAYER_PX, height=PLAYER_PX,
206 name="Player",
207 ))
208
209 # Camera looks straight down at the tile plane and follows the
210 # player's world position.
211 self._cam = self.add_child(Camera3D(name="Camera"))
212 self._cam.fov = 60
213 self._cam.near = 0.1
214 self._cam.far = 2000.0
215 self._sync_view()
216
217 def _sync_view(self) -> None:
218 """Point the camera at the player's world position; re-centre the sprite."""
219 self._cam.position = np.array(
220 [self._player_world.x, self._player_world.y, CAMERA_Z],
221 dtype=np.float32,
222 )
223 sw, sh = self.tree.screen_size
224 self._player.position = Vec2(sw / 2.0, sh / 2.0)
225
226 def process(self, dt: float):
227 if Input.is_action_just_pressed("quit"):
228 self.app.quit()
229 return
230 move = Input.get_vector("move_left", "move_right", "move_up", "move_down")
231 if move.x != 0 or move.y != 0:
232 step = self.player_speed * dt
233 # Input.get_vector treats "down" as +Y, but the tilemap renders
234 # with world +Y pointing up-screen via the Camera3D projection,
235 # so negate to map "up key → visually up".
236 nx = min(max(self._player_world.x + move.x * step, self._bounds_min.x), self._bounds_max.x)
237 ny = min(max(self._player_world.y - move.y * step, self._bounds_min.y), self._bounds_max.y)
238 self._player_world = Vec2(nx, ny)
239 # Keep camera + sprite in sync every frame (also re-centres on resize).
240 self._sync_view()
241
242
243# -- Entry point --------------------------------------------------------------
244
245if __name__ == "__main__":
246 App(width=1024, height=768, title="TileMap Demo").run(TileMapDemo())