"""Particle trail data — ring-buffer position history with ribbon mesh generation."""
from __future__ import annotations
import logging
import numpy as np
log = logging.getLogger(__name__)
__all__ = ["ParticleTrailData"]
# Vertex layout: position (3) + colour (4) + uv (2) = 9 floats per vertex
TRAIL_VERTEX_FLOATS = 9
[docs]
class ParticleTrailData:
"""Stores per-particle position history and generates ribbon mesh vertices.
Each particle maintains a ring buffer of recent positions. The trail renderer
reads these to produce a camera-facing ribbon strip that narrows and fades
toward the tail.
Args:
max_particles: Maximum number of particles to track.
trail_length: Number of history samples per particle.
trail_width: Base width of the ribbon at the head.
"""
__slots__ = ("_max_particles", "_trail_length", "_trail_width", "_history", "_head", "_counts")
def __init__(self, max_particles: int, trail_length: int = 5, trail_width: float = 0.1):
self._max_particles = max_particles
self._trail_length = max(2, trail_length)
self._trail_width = trail_width
# Ring buffer: (max_particles, trail_length, 3) for XYZ positions
self._history = np.zeros((max_particles, self._trail_length, 3), dtype=np.float32)
# Per-particle write head in the ring buffer
self._head = np.zeros(max_particles, dtype=np.int32)
# Number of valid samples per particle (fills up to trail_length)
self._counts = np.zeros(max_particles, dtype=np.int32)
@property
def trail_length(self) -> int:
return self._trail_length
@property
def trail_width(self) -> float:
return self._trail_width
@trail_width.setter
def trail_width(self, value: float):
self._trail_width = value
@property
def history(self) -> np.ndarray:
"""Raw history buffer (max_particles, trail_length, 3)."""
return self._history
@property
def counts(self) -> np.ndarray:
"""Number of valid trail samples per particle."""
return self._counts
[docs]
def clear(self):
"""Reset all trail data."""
self._history[:] = 0
self._head[:] = 0
self._counts[:] = 0
[docs]
def update(self, alive_particles: np.ndarray):
"""Record current positions of alive particles into the ring buffer.
Args:
alive_particles: Structured array slice with 'position' field.
"""
n = len(alive_particles)
if n == 0:
return
# Write current position at head index
heads = self._head[:n]
positions = alive_particles["position"]
for i in range(n):
self._history[i, heads[i]] = positions[i]
# Advance head (ring buffer wrap)
self._head[:n] = (heads + 1) % self._trail_length
# Track how many valid samples we have (up to trail_length)
self._counts[:n] = np.minimum(self._counts[:n] + 1, self._trail_length)
[docs]
def get_ordered_positions(self, particle_idx: int) -> np.ndarray:
"""Get trail positions for one particle in chronological order (oldest first).
Returns:
Array of shape (count, 3) with positions from oldest to newest.
"""
count = int(self._counts[particle_idx])
if count == 0:
return np.empty((0, 3), dtype=np.float32)
head = int(self._head[particle_idx])
tl = self._trail_length
# Oldest entry is at head (since head points to next write = oldest)
# But only if we've filled the buffer; otherwise start from 0
if count < tl:
return self._history[particle_idx, :count].copy()
indices = [(head + i) % tl for i in range(tl)]
return self._history[particle_idx, indices].copy()
[docs]
def get_trail_vertices(self, alive_particles: np.ndarray | None = None) -> np.ndarray | None:
"""Generate ribbon mesh vertices from trail history.
Each trail segment becomes a quad (2 triangles, 6 verts). The ribbon
faces up (Y-axis billboard) and tapers from ``trail_width`` at the head
to zero at the tail. Colour fades from particle colour to transparent.
Args:
alive_particles: Alive particle structured array (needs 'colour' field).
If None, uses white colour.
Returns:
Float32 array of shape (N, 9) with [x,y,z, r,g,b,a, u,v] per vertex,
or None if no trails to render.
"""
if alive_particles is None or len(alive_particles) == 0:
return None
n = len(alive_particles)
verts_list: list[np.ndarray] = []
for i in range(n):
count = int(self._counts[i])
if count < 2:
continue
positions = self.get_ordered_positions(i) # (count, 3) oldest→newest
colour = alive_particles["colour"][i] if alive_particles is not None else np.array([1, 1, 1, 1], np.float32)
# Generate ribbon quads
num_segments = len(positions) - 1
segment_verts = np.zeros((num_segments * 6, TRAIL_VERTEX_FLOATS), dtype=np.float32)
for s in range(num_segments):
p0 = positions[s]
p1 = positions[s + 1]
# Parametric t: 0 at tail (oldest), 1 at head (newest)
t0 = s / num_segments
t1 = (s + 1) / num_segments
# Width tapers toward tail
w0 = self._trail_width * t0
w1 = self._trail_width * t1
# Direction along trail
fwd = p1 - p0
fwd_len = np.linalg.norm(fwd)
if fwd_len < 1e-6:
continue
# Perpendicular in XZ plane (simple billboard)
perp = np.array([-fwd[2], 0.0, fwd[0]], dtype=np.float32)
perp_len = np.linalg.norm(perp)
if perp_len < 1e-6:
perp = np.array([1.0, 0.0, 0.0], dtype=np.float32)
else:
perp /= perp_len
# Colour fades toward tail
alpha0 = colour[3] * t0
alpha1 = colour[3] * t1
c0 = np.array([colour[0], colour[1], colour[2], alpha0], dtype=np.float32)
c1 = np.array([colour[0], colour[1], colour[2], alpha1], dtype=np.float32)
# Quad corners
v0 = p0 + perp * w0 # bottom-left
v1 = p0 - perp * w0 # bottom-right
v2 = p1 + perp * w1 # top-left
v3 = p1 - perp * w1 # top-right
# Two triangles: (v0,v2,v1) and (v1,v2,v3)
base = s * 6
for vi, (pos, col, uv) in enumerate(
[
(v0, c0, (0.0, t0)),
(v2, c1, (0.0, t1)),
(v1, c0, (1.0, t0)),
(v1, c0, (1.0, t0)),
(v2, c1, (0.0, t1)),
(v3, c1, (1.0, t1)),
]
):
segment_verts[base + vi, :3] = pos
segment_verts[base + vi, 3:7] = col
segment_verts[base + vi, 7:9] = uv
if len(segment_verts) > 0:
verts_list.append(segment_verts)
if not verts_list:
return None
return np.concatenate(verts_list, axis=0)