Texture resource

change a sprite’s image at runtime.

▶ Run in browser

Tags: 2d

A plain path, PNG bytes or RGBA NumPy array is the right way to hand a sprite an image that never changes. When the image does change, there are three routes, shown here side by side:

  • Reassign the sprite’s texture to another source: sprite.texture = other_image. The sprite resolves the new source and draws it on the next frame.

  • Edit the pixels in place behind a Texture and call update():

    pixels = rings(0.0)
    tex = Texture(pixels)
    ...
    pixels[:] = rings(t)
    tex.update()
    

    Editing the array alone changes nothing on screen, because the renderer keeps the upload it already made. update() bumps the texture’s version, and the renderer writes the new pixels into the same GPU slot, so nothing is reassigned.

  • Swap the source with tex.update(new_pixels), the shorter route when each frame is a fresh array rather than an edit. The new array must be the same size: a texture’s size is fixed, and a bigger image is a new Texture assigned to the sprite, which is the first route.

The left panel flips between two checkerboards, the middle one animates rings by editing its array, and the right one by handing update() a new array. The footer shows the middle texture’s version climbing. Press SPACE, click or tap to pause and resume: the panels keep their last frame.

Source

  1# /// simvx
  2# web = { width = 900, height = 620 }
  3# ///
  4"""Texture resource: change a sprite's image at runtime.
  5
  6A plain path, PNG bytes or RGBA NumPy array is the right way to hand a sprite an
  7image that never changes. When the image does change, there are three routes,
  8shown here side by side:
  9
 10- **Reassign** the sprite's `texture` to another source:
 11  `sprite.texture = other_image`. The sprite resolves the new source and draws
 12  it on the next frame.
 13- **Edit the pixels in place** behind a `Texture` and call `update()`:
 14
 15      pixels = rings(0.0)
 16      tex = Texture(pixels)
 17      ...
 18      pixels[:] = rings(t)
 19      tex.update()
 20
 21  Editing the array alone changes nothing on screen, because the renderer keeps
 22  the upload it already made. `update()` bumps the texture's `version`, and the
 23  renderer writes the new pixels into the same GPU slot, so nothing is
 24  reassigned.
 25- **Swap the source** with `tex.update(new_pixels)`, the shorter route when each
 26  frame is a fresh array rather than an edit. The new array must be the same
 27  size: a texture's `size` is fixed, and a bigger image is a new `Texture`
 28  assigned to the sprite, which is the first route.
 29
 30The left panel flips between two checkerboards, the middle one animates rings
 31by editing its array, and the right one by handing `update()` a new array. The
 32footer shows the middle texture's `version` climbing. Press SPACE, click or tap
 33to pause and resume: the panels keep their last frame.
 34"""
 35
 36import numpy as np
 37
 38from simvx.core import Anchor, CanvasLayer, Key, Label, Node2D, Pointer, Sprite2D, Text2D, Texture, on_input
 39from simvx.graphics import App
 40
 41SIZE = 64
 42PANEL = 180
 43
 44
 45def checker(a: tuple[int, int, int], b: tuple[int, int, int], cell: int = 8) -> np.ndarray:
 46    ys, xs = np.mgrid[:SIZE, :SIZE]
 47    even = ((xs // cell + ys // cell) % 2 == 0)[..., None]
 48    return np.where(even, (*a, 255), (*b, 255)).astype(np.uint8)
 49
 50
 51def rings(phase: float) -> np.ndarray:
 52    ys, xs = np.mgrid[:SIZE, :SIZE]
 53    band = (np.sin(np.hypot(xs - SIZE / 2, ys - SIZE / 2) * 0.5 - phase) * 0.5 + 0.5) ** 2
 54    img = np.empty((SIZE, SIZE, 4), dtype=np.uint8)
 55    img[..., 0] = band * 240
 56    img[..., 1] = band * 120
 57    img[..., 2] = (1.0 - band) * 220
 58    img[..., 3] = 255
 59    return img
 60
 61
 62class TextureResourceScene(Node2D):
 63    input_context = {"pause": [Key.SPACE, Pointer.PRIMARY]}
 64
 65    def on_ready(self):
 66        self.time = 0.0
 67        self.running = True
 68
 69        # Reassignment: two plain arrays, swapped on the sprite's property.
 70        self.sources = (checker((235, 90, 70), (40, 40, 55)), checker((70, 200, 130), (25, 45, 40)))
 71        self.shown = 0
 72        self.reassigned = Sprite2D(
 73            texture=self.sources[0], width=PANEL, height=PANEL, filter="nearest", name="Reassigned"
 74        )
 75
 76        # In-place edit: one Texture wrapping one array that is rewritten every frame.
 77        self.pixels = rings(0.0)
 78        self.live = Texture(self.pixels)
 79        self.mutated = Sprite2D(texture=self.live, width=PANEL, height=PANEL, name="Mutated")
 80
 81        # Source swap: one Texture handed a fresh array every frame.
 82        self.swapped_tex = Texture(rings(0.0))
 83        self.swapped = Sprite2D(texture=self.swapped_tex, width=PANEL, height=PANEL, name="Swapped")
 84
 85        captions = ("sprite.texture = other_image", "pixels[:] = ...; tex.update()", "tex.update(new_pixels)")
 86        self.panels = (self.reassigned, self.mutated, self.swapped)
 87        for sprite, caption in zip(self.panels, captions, strict=True):
 88            self.add_child(sprite)
 89            sprite.add_child(Text2D(text=caption, position=(0, PANEL / 2 + 30), align="centre"))
 90
 91        hud = self.add_child(CanvasLayer())
 92        hud.add_child(
 93            Label("Texture resource: SPACE, click or tap to pause", font_size=24, anchor=Anchor.TOP_LEFT, inset=16)
 94        )
 95        self.status = hud.add_child(Label("", anchor=Anchor.BOTTOM_LEFT, inset=16))
 96
 97    @on_input("pause")
 98    def toggle_pause(self, event):
 99        self.running = not self.running
100
101    def on_update(self, dt: float):
102        w, h = self.viewport_size
103        gap = (w - 3 * PANEL) / 4
104        for i, sprite in enumerate(self.panels):
105            sprite.position = (gap * (i + 1) + PANEL * (i + 0.5), h * 0.42)
106
107        if not self.running:
108            return
109        self.time += dt
110
111        shown = int(self.time * 1.5) % 2
112        if shown != self.shown:
113            self.shown = shown
114            self.reassigned.texture = self.sources[shown]
115
116        self.pixels[:] = rings(self.time * 4.0)
117        self.live.update()
118
119        self.swapped_tex.update(rings(-self.time * 2.5))
120
121        self.status.text = f"live version: {self.live.version}   size: {tuple(self.live.size)}"
122
123
124if __name__ == "__main__":
125    App(width=900, height=620, title="Texture Resource").run(TextureResourceScene())