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 ..math.types import Vec2
from ..properties import Colour
from ..signals import Signal
from .node3d import Node3D


[docs] class Sprite3D(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 holds a file path; the graphics backend loads it and stores the GPU index in ``_texture_id``. 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") 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") def __init__(self, texture=None, **kwargs): super().__init__(**kwargs) if texture is not None: self.texture = texture # Set by graphics backend when texture is loaded on GPU self._texture_id: int = -1 self._texture_width: int = 0 self._texture_height: int = 0
[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 billboarded textured quad for the renderer.""" if self._texture_id < 0 or not self.visible: 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. Example:: sprite = AnimatedSprite3D( texture="enemy_sheet.png", frames_horizontal=4, frames_vertical=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") frame_changed = Signal() animation_finished = Signal() def __init__( self, texture=None, frames_horizontal: int = 1, frames_vertical: int = 1, **kwargs, ): super().__init__(texture=texture, **kwargs) self.frames_h = frames_horizontal self.frames_v = frames_vertical # Animation state self.animations: dict[str, SpriteAnimation3D] = {} self.current_animation: str | None = None self.frame: int = 0 self._frame_time: float = 0.0 self.playing: bool = False 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_process(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)