Source code for simvx.core.nodes_3d.sprite

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

from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass

from ..descriptors import Property, Signal
from ..math.types import Vec2, Vec3
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 path or image") 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 = Property((1.0, 1.0, 1.0, 1.0), hint="Colour tint RGBA") 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 @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] def get_quad_size(self) -> Vec2: """Return the quad's world-space size based on pixel_size and texture/region dimensions. Returns: Vec2 with (width, height) in world units. """ 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] def get_uv_rect(self) -> tuple[float, float, float, float]: """Return (u0, v0, u1, v1) UV coordinates, accounting for region and flip. Returns: Tuple of (u0, v0, u1, v1) in 0-1 normalised texture space. """ 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_types import AABB qs = self.get_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 draw(self, renderer) -> None: """Emit a billboarded textured quad for the renderer.""" if self._texture_id < 0 or not self.visible: return qs = self.get_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.get_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 # Legacy callbacks self.on_animation_finished: Callable | None = None 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 at current frame.""" self.playing = False
[docs] def pause(self): """Pause animation (alias for stop).""" self.stop()
[docs] def resume(self): """Resume animation from current frame.""" self.playing = True
[docs] def 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.on_animation_finished: self.on_animation_finished() if self.frame != old_frame: self.frame_changed() if self.on_frame_changed: self.on_frame_changed(self.frame)
[docs] def get_current_frame_index(self) -> int: """Get the 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] def get_frame_uv(self) -> tuple[Vec2, Vec2]: """Get UV coordinates for the current frame (top-left, bottom-right). Returns: Tuple of (uv0, uv1) as Vec2, with flip applied. """ idx = self.get_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] def get_uv_rect(self) -> tuple[float, float, float, float]: """Override to use sprite-sheet frame UVs instead of region_rect.""" uv0, uv1 = self.get_frame_uv() return (float(uv0.x), float(uv0.y), float(uv1.x), float(uv1.y))
[docs] def get_quad_size(self) -> Vec2: """Override: frame size is 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)