use std::collections::VecDeque;
use glam::Vec3;
use super::particles::{ParticleBlend3D, ParticleCurve, ParticleGradient};
use super::{Camera3D, Transform3D};
use crate::{Color, Texture};
#[derive(Debug, Clone)]
pub struct Trail3D {
pub lifetime: f32,
pub width: ParticleCurve,
pub color: ParticleGradient,
pub texture: Option<Texture>,
pub blend: ParticleBlend3D,
pub min_distance: f32,
pub max_points: usize,
pub emitting: bool,
points: VecDeque<(Vec3, f32)>,
}
impl Trail3D {
#[must_use]
pub fn new(lifetime: f32, width: f32) -> Self {
Self {
lifetime,
width: ParticleCurve::new(vec![(0.0, width), (1.0, 0.0)]),
color: ParticleGradient::new(vec![(0.0, Color::WHITE), (1.0, Color::rgba(1.0, 1.0, 1.0, 0.0))]),
texture: None,
blend: ParticleBlend3D::Alpha,
min_distance: 0.05,
max_points: 64,
emitting: true,
points: VecDeque::new(),
}
}
#[must_use]
pub fn point_count(&self) -> usize {
self.points.len()
}
pub fn clear(&mut self) {
self.points.clear();
}
pub(crate) fn update(&mut self, dt: f32, at: Vec3) {
for p in &mut self.points {
p.1 += dt;
}
let lifetime = self.lifetime.max(1e-4);
while self.points.front().is_some_and(|p| p.1 >= lifetime) {
self.points.pop_front();
}
if !self.emitting || !at.is_finite() {
return;
}
let far = self
.points
.back()
.is_none_or(|p| p.0.distance_squared(at) >= self.min_distance * self.min_distance);
if far {
self.points.push_back((at, 0.0));
while self.points.len() > self.max_points.max(2) {
self.points.pop_front();
}
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Line3D {
pub points: Vec<Vec3>,
pub width: f32,
pub color: Color,
pub texture: Option<Texture>,
pub blend: ParticleBlend3D,
pub texture_length: f32,
}
impl Line3D {
#[must_use]
pub fn new(points: Vec<Vec3>, width: f32, color: Color) -> Self {
Self {
points,
width,
color,
texture: None,
blend: ParticleBlend3D::Alpha,
texture_length: 0.0,
}
}
}
#[repr(C)]
#[derive(Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub(crate) struct StripVertex {
pub position: [f32; 3],
pub uv: [f32; 2],
pub color: [f32; 4],
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct StripRun {
pub first: u32,
pub count: u32,
pub texture: Option<u32>,
pub blend: ParticleBlend3D,
pub depth: f32,
}
#[derive(Default)]
pub(crate) struct StripFrame {
pub vertices: Vec<StripVertex>,
pub runs: Vec<StripRun>,
points: Vec<(Vec3, f32, [f32; 4], f32)>,
}
impl StripFrame {
pub fn collect(&mut self, world: &mut crate::World, camera: &Camera3D) {
self.vertices.clear();
self.runs.clear();
let (vertices, runs, points) = (&mut self.vertices, &mut self.runs, &mut self.points);
world.query::<(&Trail3D, &Transform3D)>().for_each(|(t, at)| {
points.clear();
let n = t.points.len();
if n == 0 {
return;
}
let lifetime = t.lifetime.max(1e-4);
let head = at.position;
let mut add = |p: Vec3, age: f32| {
let a = (age / lifetime).clamp(0.0, 1.0);
points.push((p, t.width.sample(a), super::linear(t.color.sample(a)), a));
};
if t.emitting && t.points.back().is_some_and(|p| p.0 != head) {
add(head, 0.0);
}
for &(p, age) in t.points.iter().rev() {
add(p, age);
}
build(vertices, runs, points, camera, t.texture, t.blend);
});
world.query::<(&Line3D, &Transform3D)>().for_each(|(l, at)| {
points.clear();
if l.points.len() < 2 {
return;
}
let matrix = at.matrix();
let width = l.width * at.scale.abs().max_element();
let color = super::linear(l.color);
let total: f32 = l
.points
.windows(2)
.map(|w| matrix.transform_vector3(w[1] - w[0]).length())
.sum();
let mut along = 0.0;
let mut last = None;
for &p in &l.points {
let p = matrix.transform_point3(p);
if let Some(q) = last {
along += p.distance(q);
}
last = Some(p);
let u = if l.texture_length > 0.0 {
along / l.texture_length
} else {
along / total.max(1e-6)
};
points.push((p, width, color, u));
}
build(vertices, runs, points, camera, l.texture, l.blend);
});
runs.sort_unstable_by(|a, b| b.depth.total_cmp(&a.depth));
}
}
fn build(
vertices: &mut Vec<StripVertex>,
runs: &mut Vec<StripRun>,
points: &[(Vec3, f32, [f32; 4], f32)],
camera: &Camera3D,
texture: Option<Texture>,
blend: ParticleBlend3D,
) {
if points.len() < 2 || points.iter().any(|p| !p.0.is_finite()) {
return;
}
let first = vertices.len();
let side = |i: usize| {
let p = points[i].0;
let tangent = points[(i + 1).min(points.len() - 1)].0 - points[i.saturating_sub(1)].0;
let to_eye = camera.position - p;
let s = tangent.cross(to_eye).normalize_or_zero();
let s = if s == Vec3::ZERO { camera.rotation * Vec3::X } else { s };
s * (points[i].1.max(0.0) * 0.5)
};
let corner = |i: usize, s: Vec3, v: f32| StripVertex {
position: (points[i].0 + s * (1.0 - 2.0 * v)).to_array(),
uv: [points[i].3, v],
color: points[i].2,
};
let mut previous = side(0);
#[allow(clippy::needless_range_loop)]
for i in 1..points.len() {
let next = side(i);
let (a0, a1) = (corner(i - 1, previous, 0.0), corner(i - 1, previous, 1.0));
let (b0, b1) = (corner(i, next, 0.0), corner(i, next, 1.0));
vertices.extend([a0, a1, b0, b0, a1, b1]);
previous = next;
}
let center = points[1..].iter().map(|p| p.0).sum::<Vec3>() / (points.len() - 1) as f32;
runs.push(StripRun {
first: first as u32,
count: (vertices.len() - first) as u32,
texture: texture.map(|t| t.id),
blend,
depth: (center - camera.position).dot(camera.forward()),
});
}
#[cfg(test)]
mod tests {
use super::*;
fn camera() -> Camera3D {
let mut c = Camera3D::new(glam::Vec2::new(100.0, 100.0));
c.position = Vec3::new(0.0, 0.0, 10.0);
c.look_at(Vec3::ZERO);
c
}
#[test]
fn trails_keep_spaced_points_and_drop_old_ones() {
let mut t = Trail3D::new(1.0, 0.1);
t.min_distance = 0.5;
t.update(0.1, Vec3::ZERO);
t.update(0.1, Vec3::new(0.1, 0.0, 0.0)); t.update(0.1, Vec3::new(1.0, 0.0, 0.0));
assert_eq!(t.point_count(), 2);
t.update(0.85, Vec3::new(1.0, 0.0, 0.0));
assert_eq!(t.point_count(), 1, "the first point is 1.15 s old");
t.emitting = false;
t.update(1.0, Vec3::new(5.0, 0.0, 0.0));
assert_eq!(t.point_count(), 0, "not emitting: it fades out");
t.emitting = true;
t.max_points = 3;
for x in 0..10 {
t.update(0.0, Vec3::new(x as f32, 0.0, 0.0));
}
assert_eq!(t.point_count(), 3);
assert_eq!(t.points.back().unwrap().0.x, 9.0);
}
#[test]
fn strips_face_the_camera_with_their_width() {
let mut world = crate::World::new();
world.spawn((
Transform3D::default(),
Line3D::new(
vec![Vec3::new(-1.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0)],
0.5,
Color::WHITE,
),
));
let mut frame = StripFrame::default();
frame.collect(&mut world, &camera());
assert_eq!(frame.runs.len(), 1);
assert_eq!(frame.vertices.len(), 6, "one segment, two triangles");
for v in &frame.vertices {
assert!((v.position[1].abs() - 0.25).abs() < 1e-5, "{v:?}");
assert_eq!(v.position[2], 0.0);
}
for tri in frame.vertices.chunks(3) {
let [a, b, c] = [0, 1, 2].map(|k| Vec3::from(tri[k].position));
assert!((b - a).cross(c - a).length() > 1e-4);
}
}
#[test]
fn lines_follow_their_entity_and_repeat_textures_by_length() {
let mut world = crate::World::new();
let mut line = Line3D::new(vec![Vec3::ZERO, Vec3::X, Vec3::new(3.0, 0.0, 0.0)], 0.1, Color::WHITE);
line.texture_length = 0.5;
world.spawn((Transform3D::at(Vec3::new(0.0, 2.0, 0.0)), line));
let mut frame = StripFrame::default();
frame.collect(&mut world, &camera());
assert_eq!(frame.vertices.len(), 12);
let us: Vec<f32> = frame.vertices.iter().map(|v| v.uv[0]).collect();
assert_eq!(us.iter().copied().fold(0.0, f32::max), 6.0, "3 m at 0.5 m per repeat");
assert!(frame.vertices.iter().all(|v| (v.position[1] - 2.0).abs() < 0.06));
}
#[test]
fn trails_reach_their_entity_and_fade_along_their_length() {
let mut world = crate::World::new();
let mut t = Trail3D::new(1.0, 0.2);
t.min_distance = 10.0;
t.update(0.5, Vec3::ZERO);
let e = world.spawn((Transform3D::at(Vec3::new(2.0, 0.0, 0.0)), t));
world
.get_mut::<Trail3D>(e)
.unwrap()
.update(0.25, Vec3::new(2.0, 0.0, 0.0));
let mut frame = StripFrame::default();
frame.collect(&mut world, &camera());
assert_eq!(frame.vertices.len(), 6, "the kept point and the entity");
let head = frame.vertices.iter().find(|v| v.uv[0] == 0.0).unwrap();
assert!((head.position[0] - 2.0).abs() < 1e-5);
let tail = frame
.vertices
.iter()
.max_by(|a, b| a.uv[0].total_cmp(&b.uv[0]))
.unwrap();
assert_eq!(tail.uv[0], 0.25, "kept 0.25 s ago: a quarter of its life");
assert!(tail.color[3] < head.color[3]);
world.get_mut::<Trail3D>(e).unwrap().clear();
frame.collect(&mut world, &camera());
assert!(frame.runs.is_empty() && frame.vertices.is_empty());
}
}