Texture resource¶

reassignment and live pixel updates.

â–¶ Run in browser

Tags: 2d

Three ways a texture changes at runtime, side by side:

  • Reassign a sprite’s texture to 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.Texture and call update(). 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 new Texture assigned 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())