Source code for simvx.core.nodes_3d.sprite

"""Sprite3D, SpriteAnimation3D, AnimatedSprite3D -- billboarded sprites in 3D."""

from collections.abc import Callable
from dataclasses import dataclass

from ..descriptors import Property
from ..graphics.texture_slot import TextureSlot
from ..math.types import Vec2
from ..properties import Colour
from ..signals import Signal
from .node3d import Node3D


[docs] class Sprite3D(TextureSlot, Node3D): """Billboarded textured quad in 3D space. Like Text3D but for images. Renders a textured quad positioned in the 3D world, optionally billboarded to always face the camera. Used for health bars, retro-3D sprites, vegetation cards, and particle-like effects. The ``texture`` property takes a file path, encoded bytes, an RGBA ndarray or a :class:`~simvx.core.graphics.Texture`; the graphics backend resolves it and publishes the handle through :meth:`~simvx.core.graphics.TextureSlot.publish_texture_slot`, along with the image's pixel dimensions. Reassigning ``texture`` re-resolves, and the quad resizes to the new image. The ``pixel_size`` property controls how many world units each pixel occupies. Example:: sprite = Sprite3D(texture="tree.png", position=(0, 1, -5)) sprite.pixel_size = 0.01 sprite.billboard = True """ texture = Property( None, hint="Texture source: file path, PNG bytes, or RGBA uint8 ndarray", on_change="_invalidate_texture_slot", ) pixel_size = Property(0.01, range=(0.001, 1.0), hint="World units per pixel") billboard = Property(True, hint="Always face camera") flip_h = Property(False, hint="Flip texture horizontally") flip_v = Property(False, hint="Flip texture vertically") modulate = Colour((1.0, 1.0, 1.0, 1.0)) centered = Property(True, hint="Centre the sprite on its position") offset = Property(None, hint="Pixel offset from position (Vec2)") alpha_cut = Property("disabled", enum=["disabled", "discard", "opaque_prepass"], hint="Alpha mode") render_priority = Property(0, range=(-128, 127), hint="Draw order priority") region_enabled = Property(False, hint="Use a sub-region of the texture") region_rect = Property(None, hint="Source rectangle (x, y, w, h) in pixels") #: Native pixel dimensions of the resolved texture, published by the backend. _texture_width: int = 0 _texture_height: int = 0 def __init__(self, texture=None, **kwargs): super().__init__(**kwargs) if texture is not None: self.texture = texture def _invalidate_texture_slot(self) -> None: """Drop the cached handle and the old image's dimensions.""" super()._invalidate_texture_slot() self._texture_width = 0 self._texture_height = 0
[docs] def publish_texture_size(self, width: int, height: int) -> None: """Record the resolved image's pixel dimensions (drives :attr:`quad_size`).""" self._texture_width = int(width) self._texture_height = int(height)
[docs] @property def texture_size(self) -> tuple[int, int]: """Native texture size in pixels (width, height). Set by the graphics backend.""" return (self._texture_width, self._texture_height)
[docs] @property def quad_size(self) -> Vec2: """Quad's world-space size based on ``pixel_size`` and the texture/region dimensions.""" ps = float(self.pixel_size) if self.region_enabled and self.region_rect is not None: pw = float(self.region_rect[2]) ph = float(self.region_rect[3]) elif self._texture_width > 0 and self._texture_height > 0: pw = float(self._texture_width) ph = float(self._texture_height) else: pw, ph = 64.0, 64.0 # fallback when texture not yet loaded return Vec2(pw * ps, ph * ps)
[docs] @property def uv_rect(self) -> tuple[float, float, float, float]: """``(u0, v0, u1, v1)`` UV coordinates, accounting for region and flip.""" if self.region_enabled and self.region_rect is not None and self._texture_width > 0: rx, ry, rw, rh = self.region_rect tw, th = float(self._texture_width), float(self._texture_height) u0, v0 = rx / tw, ry / th u1, v1 = (rx + rw) / tw, (ry + rh) / th else: u0, v0, u1, v1 = 0.0, 0.0, 1.0, 1.0 if self.flip_h: u0, u1 = u1, u0 if self.flip_v: v0, v1 = v1, v0 return (u0, v0, u1, v1)
[docs] def get_aabb(self): """Return axis-aligned bounding box for frustum culling. Returns: AABB centred on the node's global position, sized by the quad dimensions. """ from ..math import AABB qs = self.quad_size s = self.world_scale hw = qs.x * float(s.x) * 0.5 hh = qs.y * float(s.y) * 0.5 # Billboard sprites can face any direction, so use max extent for depth extent = max(hw, hh) p = self.world_position return AABB( float(p.x) - hw, float(p.y) - hh, float(p.z) - extent, hw * 2, hh * 2, extent * 2, )
[docs] def on_draw(self, renderer) -> None: """Emit a depth-tested billboard quad for the 3D renderer. ``on_draw`` is invoked by both the 2D ``Draw2D`` tree walk and the 3D scene adapter; only the latter passes a renderer exposing ``draw_sprite_3d`` (Billboard2DPass), so the 2D walk is a safe no-op here. """ if self._texture_id < 0 or not self.visible: return if getattr(renderer, "draw_sprite_3d", None) is None: return qs = self.quad_size s = self.world_scale w = qs.x * float(s.x) h = qs.y * float(s.y) pos = self.world_position u0, v0, u1, v1 = self.uv_rect # Pixel offset in world units ox, oy = 0.0, 0.0 if self.offset is not None: ps = float(self.pixel_size) ox = float(self.offset[0]) * ps oy = float(self.offset[1]) * ps renderer.draw_sprite_3d( texture_id=self._texture_id, position=pos, width=w, height=h, uv=(u0, v0, u1, v1), colour=self.modulate, billboard=self.billboard, centered=self.centered, offset=(ox, oy), alpha_cut=self.alpha_cut, render_priority=self.render_priority, )
[docs] @dataclass class SpriteAnimation3D: """Named sprite animation with frame indices and playback settings.""" name: str frames: list[int] fps: float = 10.0 loop: bool = True
[docs] class AnimatedSprite3D(Sprite3D): """Animated billboarded sprite in 3D space. Plays frame-based animations from a sprite sheet, mirroring ``AnimatedSprite2D`` but positioned in 3D world space with billboard support. The sheet grid, the registered animations and whether playback runs are authoring state, so they are Properties and a saved scene carries them. The playback position (``frame``, and the private frame timer) is not: a reloaded scene is the sprite that was set up, not the instant a save happened to catch. Example:: sprite = AnimatedSprite3D( texture="enemy_sheet.png", frames_h=4, frames_v=2, position=(0, 1, -5), ) sprite.add_animation("walk", frames=[0, 1, 2, 3], fps=10, loop=True) sprite.add_animation("die", frames=[4, 5, 6, 7], fps=8, loop=False) sprite.play("walk") """ speed_scale = Property(1.0, range=(0.0, 10.0), hint="Animation speed multiplier") frames_h = Property(1, range=(1, 4096), hint="Sprite-sheet columns; below 1 clamps to 1") frames_v = Property(1, range=(1, 4096), hint="Sprite-sheet rows; below 1 clamps to 1") animations = Property(default_factory=dict, hint="Named animations, keyed by name") current_animation = Property(None, hint="Animation selected by play()") playing = Property(False, hint="Whether playback advances on update") #: Playback position and the callback slot, which is not state at all. The #: frame timer and the finished flag are private, so nothing has to declare #: them here to keep them out of a save. __transient__ = frozenset({"frame", "on_frame_changed"}) frame_changed = Signal() animation_finished = Signal() def __init__(self, **kwargs): super().__init__(**kwargs) self.frame: int = 0 self._frame_time: float = 0.0 self._animation_finished: bool = False self.on_frame_changed: Callable[[int], None] | None = None
[docs] def add_animation(self, name: str, frames: list[int], fps: float = 10.0, loop: bool = True): """Register a named animation. Args: name: Animation identifier. frames: List of frame indices into the sprite sheet. fps: Playback speed in frames per second. loop: Whether the animation loops. """ self.animations[name] = SpriteAnimation3D(name, frames, fps, loop)
[docs] def play(self, animation_name: str = "default"): """Start playing a named animation. If the animation name is not registered, a default animation covering all frames in the sheet is created automatically. """ if animation_name not in self.animations: total_frames = self.frames_h * self.frames_v self.add_animation(animation_name, list(range(total_frames))) self.current_animation = animation_name self.frame = 0 self._frame_time = 0.0 self.playing = True self._animation_finished = False
[docs] def stop(self): """Stop animation and reset to the start of the current animation. ``playing`` becomes ``False`` and the frame counter resets so a subsequent ``play()`` or ``resume()`` begins from frame 0. """ self.playing = False self.frame = 0 self._frame_time = 0.0
[docs] def pause(self): """Pause animation, preserving the current frame and frame time. ``playing`` becomes ``False`` but no state is reset; ``resume()`` continues from where playback left off. """ self.playing = False
[docs] def resume(self): """Resume animation from the current frame.""" self.playing = True
[docs] def on_update(self, dt: float): """Advance animation by *dt* seconds, respecting speed_scale.""" if not self.playing or not self.current_animation: return anim = self.animations[self.current_animation] if not anim.frames: return self._frame_time += dt * float(self.speed_scale) frame_duration = 1.0 / anim.fps if anim.fps > 0 else 0.0 while self._frame_time >= frame_duration and frame_duration > 0: self._frame_time -= frame_duration old_frame = self.frame self.frame += 1 if self.frame >= len(anim.frames): if anim.loop: self.frame = 0 else: self.frame = len(anim.frames) - 1 self.playing = False self._animation_finished = True self.animation_finished() if self.frame != old_frame: self.frame_changed() if self.on_frame_changed: self.on_frame_changed(self.frame)
[docs] @property def current_frame_index(self) -> int: """Absolute frame index in the sprite sheet.""" if not self.current_animation or self.current_animation not in self.animations: return 0 anim = self.animations[self.current_animation] return anim.frames[self.frame] if self.frame < len(anim.frames) else 0
[docs] @property def frame_uv(self) -> tuple[Vec2, Vec2]: """UV coordinates for the current frame (top-left, bottom-right) as Vec2 with flip applied.""" idx = self.current_frame_index col = idx % self.frames_h row = idx // self.frames_h u0 = col / self.frames_h v0 = row / self.frames_v u1 = (col + 1) / self.frames_h v1 = (row + 1) / self.frames_v if self.flip_h: u0, u1 = u1, u0 if self.flip_v: v0, v1 = v1, v0 return (Vec2(u0, v0), Vec2(u1, v1))
[docs] @property def uv_rect(self) -> tuple[float, float, float, float]: """Override to use sprite-sheet frame UVs instead of region_rect.""" uv0, uv1 = self.frame_uv return (float(uv0.x), float(uv0.y), float(uv1.x), float(uv1.y))
[docs] @property def quad_size(self) -> Vec2: """Frame size is the texture size divided by sheet grid dimensions.""" ps = float(self.pixel_size) if self._texture_width > 0 and self._texture_height > 0: pw = float(self._texture_width) / self.frames_h ph = float(self._texture_height) / self.frames_v else: pw, ph = 64.0, 64.0 return Vec2(pw * ps, ph * ps)