"""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)