Source code for simvx.core.particle_trail

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