Texture resource¶
reassignment and live pixel updates.
â–¶ Run in browserTags: 2d
Three ways a texture changes at runtime, side by side:
Reassign a sprite’s
textureto a different source. The renderer re-resolves and the new image appears; the old one is not latched.Mutate the pixels in place behind a :class:
~simvx.core.Textureand callupdate(). The GPU slot stays the same, so nothing has to be reassigned – the image behind the handle is replaced.Swap the source with
update(new_pixels), the cheaper route when the replacement is a fresh array rather than an in-place edit. It has to be the same size: a texture’s size is fixed, and a bigger image is a newTextureassigned over the old – which is the first case above.
A plain path, bytes or ndarray still works everywhere and is the right short
spelling for an image that never changes. Wrap it in a Texture when it does.
Press SPACE to pause the animation; the panels keep their last frame.
Run: uv run python examples/features/2d/texture_resource.py Headless self-check: uv run python examples/features/2d/texture_resource.py –test
Source¶
1"""Texture resource: reassignment and live pixel updates.
2
3# /// simvx
4# web = { width = 900, height = 620 }
5# ///
6
7Three ways a texture changes at runtime, side by side:
8
9 - **Reassign** a sprite's ``texture`` to a different source. The renderer
10 re-resolves and the new image appears; the old one is not latched.
11 - **Mutate the pixels in place** behind a :class:`~simvx.core.Texture` and
12 call ``update()``. The GPU slot stays the same, so nothing has to be
13 reassigned -- the image behind the handle is replaced.
14 - **Swap the source** with ``update(new_pixels)``, the cheaper route when
15 the replacement is a fresh array rather than an in-place edit. It has to
16 be the same size: a texture's size is fixed, and a bigger image is a new
17 ``Texture`` assigned over the old -- which is the first case above.
18
19A plain path, bytes or ndarray still works everywhere and is the right short
20spelling for an image that never changes. Wrap it in a ``Texture`` when it does.
21
22Press SPACE to pause the animation; the panels keep their last frame.
23
24Run: uv run python examples/features/2d/texture_resource.py
25Headless self-check: uv run python examples/features/2d/texture_resource.py --test
26"""
27
28import numpy as np
29
30from simvx.core import Key, Node2D, Sprite2D, Text2D, Texture, Vec2, on_input
31from simvx.graphics import App
32
33SIZE = 64
34QUAD = 180
35
36
37def _checker(a: tuple[int, int, int], b: tuple[int, int, int], cells: int = 8) -> np.ndarray:
38 """A two-colour checkerboard, RGBA uint8."""
39 img = np.zeros((SIZE, SIZE, 4), dtype=np.uint8)
40 step = SIZE // cells
41 ys, xs = np.mgrid[0:SIZE, 0:SIZE]
42 mask = ((xs // step) + (ys // step)) % 2 == 0
43 img[mask, :3] = a
44 img[~mask, :3] = b
45 img[..., 3] = 255
46 return img
47
48
49def _rings(phase: float) -> np.ndarray:
50 """Concentric rings whose radius travels with *phase*."""
51 img = np.zeros((SIZE, SIZE, 4), dtype=np.uint8)
52 ys, xs = np.mgrid[0:SIZE, 0:SIZE]
53 r = np.hypot(xs - SIZE / 2, ys - SIZE / 2)
54 band = (np.sin(r * 0.5 - phase) * 0.5 + 0.5) ** 2
55 img[..., 0] = (band * 240).astype(np.uint8)
56 img[..., 1] = (band * 120).astype(np.uint8)
57 img[..., 2] = ((1.0 - band) * 220).astype(np.uint8)
58 img[..., 3] = 255
59 return img
60
61
62class TextureResourceScene(Node2D):
63 """Three sprites showing the three ways a texture changes."""
64
65 input_actions = {"pause": [Key.SPACE]}
66
67 def on_ready(self):
68 self._time = 0.0
69 self._running = True
70 self._flip = 0
71
72 # The three panels are spread across whatever width the viewport has, so
73 # the same layout fits the interactive size and the smaller published
74 # frame. Equal gaps either side of each panel: 4 gaps, 3 panels.
75 win_w, win_h = self.tree.screen_size
76 gap = (win_w - 3 * QUAD) / 4
77 centres = [gap * (i + 1) + QUAD * (i + 0.5) for i in range(3)]
78 row_y = win_h * 0.42
79 caption_y = row_y + QUAD / 2 + 30
80
81 # 1. Reassignment: two plain ndarray sources, swapped on the property.
82 self._sources = (
83 _checker((235, 90, 70), (40, 40, 55)),
84 _checker((70, 200, 130), (25, 45, 40)),
85 )
86 self._reassigned = self.add_child(
87 Sprite2D(
88 texture=self._sources[0],
89 position=Vec2(centres[0], row_y),
90 width=QUAD,
91 height=QUAD,
92 filter="nearest",
93 name="Reassigned",
94 )
95 )
96
97 # 2. In-place mutation: one resource, one array, edited every frame.
98 self._live_pixels = _rings(0.0)
99 self._live = Texture(self._live_pixels)
100 self.add_child(
101 Sprite2D(texture=self._live, position=Vec2(centres[1], row_y), width=QUAD, height=QUAD, name="Mutated")
102 )
103
104 # 3. Source swap: one resource, a fresh array each time.
105 self._swapped = Texture(_rings(0.0))
106 self.add_child(
107 Sprite2D(texture=self._swapped, position=Vec2(centres[2], row_y), width=QUAD, height=QUAD, name="Swapped")
108 )
109
110 self.add_child(Text2D(text="Texture resource -- SPACE to pause", position=(20, 20), font_scale=1.5))
111 for centre, caption in zip(
112 centres,
113 ("sprite.texture = other_image", "pixels[:] = ...; tex.update()", "tex.update(new_pixels)"),
114 strict=True,
115 ):
116 self.add_child(Text2D(text=caption, position=(centre, caption_y), align="centre"))
117 self._status = self.add_child(Text2D(text="", position=(20, win_h - 30)))
118
119 @on_input(action="pause")
120 def _toggle_pause(self, _event):
121 self._running = not self._running
122 return True
123
124 def on_update(self, dt: float):
125 if not self._running:
126 return
127 self._time += dt
128
129 # Reassigning the property re-resolves: the sprite shows the new source
130 # on the next frame instead of latching the first one it resolved.
131 flip = int(self._time * 1.5) % 2
132 if flip != self._flip:
133 self._flip = flip
134 self._reassigned.texture = self._sources[flip]
135
136 # Editing the array the resource wraps is invisible on its own -- the
137 # renderer caches the upload. update() is what says "this changed".
138 self._live_pixels[:] = _rings(self._time * 4.0)
139 self._live.update()
140
141 # Or hand it a new array. Same resource, same GPU slot, new image.
142 self._swapped.update(_rings(-self._time * 2.5))
143
144 flips = int(self._time * 1.5)
145 self._status.text = f"reassignments: {flips} live version: {self._live.version} size: {self._live.size}"
146
147
148WIDTH, HEIGHT = 900, 620
149
150
151def _selftest() -> bool:
152 """Headless: check each of the three routes actually reaches the screen.
153
154 A texture that changed only in Python proves nothing, so every claim here is
155 read off captured frames: the crop each sprite occupies is compared between
156 captures. SPACE goes through the real action map, and the pause is checked by
157 requiring the three crops to come back byte-identical afterwards.
158 """
159 from simvx.core.testing import InputSimulator
160 from simvx.graphics.testing import assert_not_blank, save_png
161
162 # flip = int(t * 1.5) % 2, so the reassigned panel changes source at t = 2/3 s
163 # and back at t = 4/3 s. These sit well inside each of the first three spans.
164 RED, GREEN, RED_AGAIN = 20, 60, 100
165 PAUSE = 110
166 HELD_A, HELD_B = 130, 145
167 FRAMES = 150
168
169 app = App(width=WIDTH, height=HEIGHT, title="Texture Resource", visible=False)
170 scene = TextureResourceScene(name="TextureResourceScene")
171 sim = InputSimulator()
172
173 shots: dict[int, np.ndarray] = {}
174 marks: dict[int, tuple[float, int, int, tuple[int, int]]] = {}
175
176 def on_frame(idx: int, _t: float) -> bool:
177 if idx == PAUSE:
178 sim.press_key(Key.SPACE)
179 elif idx == PAUSE + 1:
180 sim.release_key(Key.SPACE)
181 if idx in (RED, GREEN, RED_AGAIN, HELD_A, HELD_B):
182 marks[idx] = (scene._time, scene._live.version, scene._swapped.version, tuple(scene._live.size))
183 return True
184
185 frames = app.run_headless(
186 scene,
187 frames=FRAMES,
188 on_frame=on_frame,
189 capture_frames=[RED, GREEN, RED_AGAIN, HELD_A, HELD_B],
190 )
191 for idx, frame in zip((RED, GREEN, RED_AGAIN, HELD_A, HELD_B), frames, strict=True):
192 shots[idx] = frame
193 assert_not_blank(shots[RED])
194 save_png(shots[HELD_B], "/tmp/texture_resource_test.png")
195
196 def crop(sprite: Sprite2D, frame: np.ndarray) -> np.ndarray:
197 """The block of pixels this sprite draws, read off a captured frame."""
198 pos, size = sprite.world_position, sprite.draw_size
199 # A few pixels in from the edges, so a half-covered border pixel cannot
200 # blur the comparison.
201 x0 = int(pos.x - size.x / 2) + 4
202 y0 = int(pos.y - size.y / 2) + 4
203 return frame[y0 : y0 + int(size.y) - 8, x0 : x0 + int(size.x) - 8, :3].astype(int)
204
205 reassigned = scene._reassigned
206 mutated = scene.find("Mutated")
207 swapped = scene.find("Swapped")
208
209 ok = True
210
211 def check(label: str, passed: bool, detail: str) -> None:
212 nonlocal ok
213 ok = ok and passed
214 print(f"{'ok ' if passed else 'FAIL'} {label}: {detail}")
215
216 def spread(sprite: Sprite2D, a: int, b: int) -> float:
217 return float(np.abs(crop(sprite, shots[a]) - crop(sprite, shots[b])).mean())
218
219 # 1. Reassignment. The two sources are a red-on-dark and a green-on-dark
220 # checkerboard, so the panel's dominant channel says which one the renderer
221 # resolved. It has to swap and then swap BACK: a renderer that latched the
222 # first source it saw would pass a one-way check by accident.
223 means = {idx: crop(reassigned, shots[idx]).reshape(-1, 3).mean(axis=0) for idx in (RED, GREEN, RED_AGAIN)}
224 reds = [means[RED][0] > means[RED][1], means[RED_AGAIN][0] > means[RED_AGAIN][1]]
225 check(
226 "reassigning sprite.texture shows the other source, and back again",
227 all(reds) and means[GREEN][1] > means[GREEN][0],
228 " -> ".join(f"rgb({m[0]:.0f},{m[1]:.0f},{m[2]:.0f})" for m in means.values()),
229 )
230
231 # 2. In-place mutation behind one Texture. The array is edited and update()
232 # is called every frame, so the panel is different pixels every capture while
233 # the resource itself is never reassigned.
234 live_moved = spread(mutated, RED, GREEN)
235 check(
236 "pixels[:] = ...; tex.update() reaches the screen",
237 live_moved > 8.0,
238 f"the panel differs by {live_moved:.1f} per channel between captures",
239 )
240
241 # 3. Source swap. Same resource and same GPU slot, a fresh array each frame,
242 # and the size is what makes it legal: update() replaces, it cannot resize.
243 swap_moved = spread(swapped, RED, GREEN)
244 check(
245 "tex.update(new_pixels) reaches the screen at a fixed size",
246 swap_moved > 8.0 and marks[RED][3] == marks[HELD_B][3] == (SIZE, SIZE),
247 f"the panel differs by {swap_moved:.1f} per channel, size held at {marks[HELD_B][3]}",
248 )
249
250 # Both resources version their contents, which is how the renderer knows the
251 # upload it cached is stale. One bump per update() call, so the count tracks
252 # the frames that ran rather than being a flag.
253 live_bumps = marks[RED_AGAIN][1] - marks[RED][1]
254 swap_bumps = marks[RED_AGAIN][2] - marks[RED][2]
255 check(
256 "each update() bumps the resource version",
257 live_bumps >= RED_AGAIN - RED and swap_bumps >= RED_AGAIN - RED,
258 f"{live_bumps} and {swap_bumps} bumps over {RED_AGAIN - RED} frames",
259 )
260
261 # 4. SPACE, pressed through the action map the scene declares, stops the
262 # updates. Nothing is redrawn, so the three panels must come back identical
263 # pixel for pixel: a still frame is the visible half of the claim, and the
264 # frozen version counters are the half underneath.
265 frozen = all(np.array_equal(crop(s, shots[HELD_A]), crop(s, shots[HELD_B])) for s in (reassigned, mutated, swapped))
266 check(
267 "SPACE pauses: the panels keep their last frame",
268 frozen and marks[HELD_A][:3] == marks[HELD_B][:3],
269 f"time held at {marks[HELD_B][0]:.3f}s, versions at {marks[HELD_B][1]} and {marks[HELD_B][2]}",
270 )
271
272 print("screenshot: /tmp/texture_resource_test.png")
273 print("SELFTEST:", "PASS" if ok else "FAIL")
274 return ok
275
276
277if __name__ == "__main__":
278 import sys
279
280 if "--test" in sys.argv:
281 sys.exit(0 if _selftest() else 1)
282 App(width=WIDTH, height=HEIGHT, title="SimVX Texture Resource").run(TextureResourceScene())