use glam::Vec3;
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum Palette {
#[default]
None,
Viridis,
Magma,
Plasma,
Inferno,
Rainbow,
Sunset,
Ocean,
Fire,
Ice,
Neon,
Forest,
Grayscale,
Custom([Vec3; 5]),
}
impl Palette {
pub fn colors(&self) -> [Vec3; 5] {
match self {
Palette::None => [
Vec3::new(1.0, 1.0, 1.0),
Vec3::new(1.0, 1.0, 1.0),
Vec3::new(1.0, 1.0, 1.0),
Vec3::new(1.0, 1.0, 1.0),
Vec3::new(1.0, 1.0, 1.0),
],
Palette::Viridis => [
Vec3::new(0.267, 0.004, 0.329), Vec3::new(0.282, 0.140, 0.458), Vec3::new(0.127, 0.566, 0.551), Vec3::new(0.369, 0.789, 0.383), Vec3::new(0.993, 0.906, 0.144), ],
Palette::Magma => [
Vec3::new(0.001, 0.0, 0.014), Vec3::new(0.329, 0.071, 0.435), Vec3::new(0.716, 0.215, 0.475), Vec3::new(0.994, 0.541, 0.380), Vec3::new(0.987, 0.991, 0.749), ],
Palette::Plasma => [
Vec3::new(0.050, 0.030, 0.528), Vec3::new(0.494, 0.012, 0.658), Vec3::new(0.798, 0.280, 0.470), Vec3::new(0.973, 0.580, 0.254), Vec3::new(0.940, 0.975, 0.131), ],
Palette::Inferno => [
Vec3::new(0.001, 0.0, 0.014), Vec3::new(0.341, 0.063, 0.429), Vec3::new(0.735, 0.216, 0.330), Vec3::new(0.988, 0.645, 0.198), Vec3::new(0.988, 1.0, 0.644), ],
Palette::Rainbow => [
Vec3::new(1.0, 0.0, 0.0), Vec3::new(1.0, 1.0, 0.0), Vec3::new(0.0, 1.0, 0.0), Vec3::new(0.0, 1.0, 1.0), Vec3::new(0.5, 0.0, 1.0), ],
Palette::Sunset => [
Vec3::new(0.1, 0.0, 0.2), Vec3::new(0.5, 0.0, 0.5), Vec3::new(1.0, 0.2, 0.4), Vec3::new(1.0, 0.5, 0.2), Vec3::new(1.0, 0.9, 0.4), ],
Palette::Ocean => [
Vec3::new(0.0, 0.05, 0.15), Vec3::new(0.0, 0.2, 0.4), Vec3::new(0.0, 0.4, 0.6), Vec3::new(0.2, 0.6, 0.8), Vec3::new(0.6, 0.9, 1.0), ],
Palette::Fire => [
Vec3::new(0.1, 0.0, 0.0), Vec3::new(0.5, 0.0, 0.0), Vec3::new(1.0, 0.3, 0.0), Vec3::new(1.0, 0.7, 0.0), Vec3::new(1.0, 1.0, 0.8), ],
Palette::Ice => [
Vec3::new(1.0, 1.0, 1.0), Vec3::new(0.8, 0.9, 1.0), Vec3::new(0.4, 0.7, 1.0), Vec3::new(0.1, 0.4, 0.8), Vec3::new(0.0, 0.1, 0.4), ],
Palette::Neon => [
Vec3::new(1.0, 0.0, 0.5), Vec3::new(0.5, 0.0, 1.0), Vec3::new(0.0, 0.5, 1.0), Vec3::new(0.0, 1.0, 1.0), Vec3::new(0.5, 1.0, 0.5), ],
Palette::Forest => [
Vec3::new(0.1, 0.05, 0.0), Vec3::new(0.3, 0.15, 0.05), Vec3::new(0.2, 0.4, 0.1), Vec3::new(0.3, 0.6, 0.2), Vec3::new(0.5, 0.8, 0.3), ],
Palette::Grayscale => [
Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.25, 0.25, 0.25),
Vec3::new(0.5, 0.5, 0.5),
Vec3::new(0.75, 0.75, 0.75),
Vec3::new(1.0, 1.0, 1.0), ],
Palette::Custom(colors) => *colors,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum ColorMapping {
#[default]
None,
Index,
Speed {
min: f32,
max: f32,
},
Age {
max_age: f32,
},
PositionY {
min: f32,
max: f32,
},
Distance {
max_dist: f32,
},
Random,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BlendMode {
#[default]
Alpha,
Additive,
Multiply,
Screen,
Overlay,
SoftLight,
Subtractive,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ParticleShape {
#[default]
Circle,
CircleHard,
Square,
Ring,
Star,
Triangle,
Hexagon,
Diamond,
Point,
}
impl ParticleShape {
pub fn to_wgsl_fragment(&self) -> &'static str {
match self {
ParticleShape::Circle => r#" let dist = length(in.uv);
if dist > 1.0 {
discard;
}
let alpha = 1.0 - smoothstep(0.5, 1.0, dist);
return vec4<f32>(in.color, alpha);"#,
ParticleShape::CircleHard => r#" let dist = length(in.uv);
if dist > 1.0 {
discard;
}
return vec4<f32>(in.color, 1.0);"#,
ParticleShape::Square => r#" return vec4<f32>(in.color, 1.0);"#,
ParticleShape::Ring => r#" let dist = length(in.uv);
if dist > 1.0 || dist < 0.6 {
discard;
}
let alpha = 1.0 - smoothstep(0.85, 1.0, dist);
return vec4<f32>(in.color, alpha);"#,
ParticleShape::Star => r#" // 5-pointed star using polar coordinates
let angle = atan2(in.uv.y, in.uv.x);
let dist = length(in.uv);
// Star shape: varies radius based on angle (5 points)
let points = 5.0;
let star_angle = angle + 3.14159 / 2.0; // Rotate so point faces up
let star_factor = cos(star_angle * points) * 0.4 + 0.6;
if dist > star_factor {
discard;
}
return vec4<f32>(in.color, 1.0);"#,
ParticleShape::Triangle => r#" // Equilateral triangle pointing up
let p = in.uv;
// Triangle: use simple half-plane tests
// Top vertex at (0, 0.8), bottom edge at y = -0.6
// Left edge: from (-0.8, -0.6) to (0, 0.8)
// Right edge: from (0.8, -0.6) to (0, 0.8)
// Bottom edge
if p.y < -0.6 {
discard;
}
// Left edge: points right of line from (-0.8, -0.6) to (0, 0.8)
// Line equation: 1.4x - 0.8y + 0.64 > 0 for inside
let left = 1.4 * p.x - 0.8 * p.y + 0.64;
if left < 0.0 {
discard;
}
// Right edge: points left of line from (0.8, -0.6) to (0, 0.8)
// Line equation: -1.4x - 0.8y + 0.64 > 0 for inside
let right = -1.4 * p.x - 0.8 * p.y + 0.64;
if right < 0.0 {
discard;
}
return vec4<f32>(in.color, 1.0);"#,
ParticleShape::Hexagon => r#" // Regular hexagon using max of 3 axes
let p = abs(in.uv);
// Hexagon distance: check against 3 edge normals
// Pointy-top hexagon
let hex_dist = max(p.x * 0.866025 + p.y * 0.5, p.y);
if hex_dist > 0.9 {
discard;
}
return vec4<f32>(in.color, 1.0);"#,
ParticleShape::Diamond => r#" // Diamond/rhombus: Manhattan distance
let dist = abs(in.uv.x) + abs(in.uv.y);
if dist > 1.0 {
discard;
}
return vec4<f32>(in.color, 1.0);"#,
ParticleShape::Point => r#" // Single pixel - no shape calculation needed
return vec4<f32>(in.color, 1.0);"#,
}
}
}
#[derive(Debug, Clone)]
pub struct VisualConfig {
pub blend_mode: BlendMode,
pub shape: ParticleShape,
pub trail_length: u32,
pub connections_enabled: bool,
pub connections_radius: f32,
pub connections_color: Vec3,
pub velocity_stretch: bool,
pub velocity_stretch_factor: f32,
pub palette: Palette,
pub color_mapping: ColorMapping,
pub background_color: Vec3,
pub post_process_shader: Option<String>,
pub spatial_grid_opacity: f32,
pub wireframe_mesh: Option<WireframeMesh>,
pub wireframe_thickness: f32,
}
impl Default for VisualConfig {
fn default() -> Self {
Self {
blend_mode: BlendMode::Alpha,
shape: ParticleShape::Circle,
trail_length: 0,
connections_enabled: false,
connections_radius: 0.1,
connections_color: Vec3::new(0.5, 0.7, 1.0),
velocity_stretch: false,
velocity_stretch_factor: 2.0,
palette: Palette::None,
color_mapping: ColorMapping::None,
background_color: Vec3::new(0.02, 0.02, 0.05), post_process_shader: None,
spatial_grid_opacity: 0.0, wireframe_mesh: None,
wireframe_thickness: 0.003, }
}
}
impl VisualConfig {
pub fn new() -> Self {
Self::default()
}
pub fn blend_mode(&mut self, mode: BlendMode) -> &mut Self {
self.blend_mode = mode;
self
}
pub fn shape(&mut self, shape: ParticleShape) -> &mut Self {
self.shape = shape;
self
}
pub fn trails(&mut self, length: u32) -> &mut Self {
self.trail_length = length;
self
}
pub fn connections(&mut self, radius: f32) -> &mut Self {
self.connections_enabled = true;
self.connections_radius = radius;
self
}
pub fn connections_color(&mut self, color: Vec3) -> &mut Self {
self.connections_color = color;
self
}
pub fn velocity_stretch(&mut self, max_factor: f32) -> &mut Self {
self.velocity_stretch = true;
self.velocity_stretch_factor = max_factor;
self
}
pub fn palette(&mut self, palette: Palette, mapping: ColorMapping) -> &mut Self {
self.palette = palette;
self.color_mapping = mapping;
self
}
pub fn background(&mut self, color: Vec3) -> &mut Self {
self.background_color = color;
self
}
pub fn spatial_grid(&mut self, opacity: f32) -> &mut Self {
self.spatial_grid_opacity = opacity.clamp(0.0, 1.0);
self
}
pub fn post_process(&mut self, wgsl_code: &str) -> &mut Self {
self.post_process_shader = Some(wgsl_code.to_string());
self
}
pub fn wireframe(&mut self, mesh: WireframeMesh, line_thickness: f32) -> &mut Self {
self.wireframe_mesh = Some(mesh);
self.wireframe_thickness = line_thickness;
self
}
pub fn diff(&self, other: &VisualConfig) -> ConfigDiff {
let mut hot_swappable = Vec::new();
if self.background_color != other.background_color {
hot_swappable.push(HotSwapChange::BackgroundColor(other.background_color));
}
if self.spatial_grid_opacity != other.spatial_grid_opacity {
hot_swappable.push(HotSwapChange::GridOpacity(other.spatial_grid_opacity));
}
let needs_render_rebuild = self.blend_mode != other.blend_mode
|| self.shape != other.shape
|| self.palette != other.palette
|| self.color_mapping != other.color_mapping
|| self.trail_length != other.trail_length
|| self.connections_enabled != other.connections_enabled
|| self.connections_radius != other.connections_radius
|| self.velocity_stretch != other.velocity_stretch
|| self.velocity_stretch_factor != other.velocity_stretch_factor
|| self.wireframe_mesh != other.wireframe_mesh
|| self.wireframe_thickness != other.wireframe_thickness
|| self.post_process_shader != other.post_process_shader;
ConfigDiff {
needs_render_rebuild,
hot_swappable,
}
}
}
#[derive(Debug, Clone)]
pub struct ConfigDiff {
pub needs_render_rebuild: bool,
pub hot_swappable: Vec<HotSwapChange>,
}
impl ConfigDiff {
pub fn is_empty(&self) -> bool {
!self.needs_render_rebuild && self.hot_swappable.is_empty()
}
pub fn needs_rebuild(&self) -> bool {
self.needs_render_rebuild
}
}
#[derive(Debug, Clone)]
pub enum HotSwapChange {
BackgroundColor(Vec3),
GridOpacity(f32),
}
#[derive(Debug, Clone, PartialEq)]
pub struct WireframeMesh {
pub lines: Vec<(Vec3, Vec3)>,
}
impl WireframeMesh {
pub fn custom(lines: Vec<(Vec3, Vec3)>) -> Self {
Self { lines }
}
pub fn tetrahedron() -> Self {
let s = 0.5;
let v0 = Vec3::new(s, s, s);
let v1 = Vec3::new(s, -s, -s);
let v2 = Vec3::new(-s, s, -s);
let v3 = Vec3::new(-s, -s, s);
Self {
lines: vec![
(v0, v1),
(v0, v2),
(v0, v3),
(v1, v2),
(v1, v3),
(v2, v3),
],
}
}
pub fn cube() -> Self {
let s = 0.5;
let v000 = Vec3::new(-s, -s, -s);
let v001 = Vec3::new(-s, -s, s);
let v010 = Vec3::new(-s, s, -s);
let v011 = Vec3::new(-s, s, s);
let v100 = Vec3::new(s, -s, -s);
let v101 = Vec3::new(s, -s, s);
let v110 = Vec3::new(s, s, -s);
let v111 = Vec3::new(s, s, s);
Self {
lines: vec![
(v000, v100),
(v100, v101),
(v101, v001),
(v001, v000),
(v010, v110),
(v110, v111),
(v111, v011),
(v011, v010),
(v000, v010),
(v100, v110),
(v101, v111),
(v001, v011),
],
}
}
pub fn octahedron() -> Self {
let s = 0.5;
let px = Vec3::new(s, 0.0, 0.0);
let nx = Vec3::new(-s, 0.0, 0.0);
let py = Vec3::new(0.0, s, 0.0);
let ny = Vec3::new(0.0, -s, 0.0);
let pz = Vec3::new(0.0, 0.0, s);
let nz = Vec3::new(0.0, 0.0, -s);
Self {
lines: vec![
(py, px),
(py, nx),
(py, pz),
(py, nz),
(ny, px),
(ny, nx),
(ny, pz),
(ny, nz),
(px, pz),
(pz, nx),
(nx, nz),
(nz, px),
],
}
}
pub fn diamond() -> Self {
let s = 0.5;
let h = 0.7;
let top = Vec3::new(0.0, h, 0.0);
let bot = Vec3::new(0.0, -h, 0.0);
let e0 = Vec3::new(s, 0.0, 0.0);
let e1 = Vec3::new(0.0, 0.0, s);
let e2 = Vec3::new(-s, 0.0, 0.0);
let e3 = Vec3::new(0.0, 0.0, -s);
Self {
lines: vec![
(top, e0),
(top, e1),
(top, e2),
(top, e3),
(bot, e0),
(bot, e1),
(bot, e2),
(bot, e3),
],
}
}
pub fn icosahedron() -> Self {
let phi = (1.0 + 5.0_f32.sqrt()) / 2.0;
let s = 0.3;
let vertices = [
Vec3::new(-1.0, phi, 0.0) * s,
Vec3::new(1.0, phi, 0.0) * s,
Vec3::new(-1.0, -phi, 0.0) * s,
Vec3::new(1.0, -phi, 0.0) * s,
Vec3::new(0.0, -1.0, phi) * s,
Vec3::new(0.0, 1.0, phi) * s,
Vec3::new(0.0, -1.0, -phi) * s,
Vec3::new(0.0, 1.0, -phi) * s,
Vec3::new(phi, 0.0, -1.0) * s,
Vec3::new(phi, 0.0, 1.0) * s,
Vec3::new(-phi, 0.0, -1.0) * s,
Vec3::new(-phi, 0.0, 1.0) * s,
];
let edges = [
(0, 1), (0, 5), (0, 7), (0, 10), (0, 11),
(1, 5), (1, 7), (1, 8), (1, 9),
(2, 3), (2, 4), (2, 6), (2, 10), (2, 11),
(3, 4), (3, 6), (3, 8), (3, 9),
(4, 5), (4, 9), (4, 11),
(5, 9), (5, 11),
(6, 7), (6, 8), (6, 10),
(7, 8), (7, 10),
(8, 9),
(10, 11),
];
Self {
lines: edges.iter().map(|(i, j)| (vertices[*i], vertices[*j])).collect(),
}
}
pub fn axes() -> Self {
let s = 0.5;
Self {
lines: vec![
(Vec3::ZERO, Vec3::new(s, 0.0, 0.0)), (Vec3::ZERO, Vec3::new(0.0, s, 0.0)), (Vec3::ZERO, Vec3::new(0.0, 0.0, s)), ],
}
}
pub fn star() -> Self {
let inner = 0.2;
let outer = 0.5;
let points = 6;
let mut lines = Vec::new();
for i in 0..points {
let angle = (i as f32 / points as f32) * std::f32::consts::TAU;
let outer_point = Vec3::new(angle.cos() * outer, angle.sin() * outer, 0.0);
lines.push((Vec3::ZERO, outer_point));
let angle_prev = ((i as f32 - 0.5) / points as f32) * std::f32::consts::TAU;
let angle_next = ((i as f32 + 0.5) / points as f32) * std::f32::consts::TAU;
let inner_prev = Vec3::new(angle_prev.cos() * inner, angle_prev.sin() * inner, 0.0);
let inner_next = Vec3::new(angle_next.cos() * inner, angle_next.sin() * inner, 0.0);
lines.push((outer_point, inner_prev));
lines.push((outer_point, inner_next));
}
Self { lines }
}
pub fn spiral(turns: f32, segments: u32) -> Self {
let mut lines = Vec::new();
let height = 0.5;
let radius = 0.3;
for i in 0..segments {
let t0 = i as f32 / segments as f32;
let t1 = (i + 1) as f32 / segments as f32;
let angle0 = t0 * turns * std::f32::consts::TAU;
let angle1 = t1 * turns * std::f32::consts::TAU;
let p0 = Vec3::new(
angle0.cos() * radius,
t0 * height - height / 2.0,
angle0.sin() * radius,
);
let p1 = Vec3::new(
angle1.cos() * radius,
t1 * height - height / 2.0,
angle1.sin() * radius,
);
lines.push((p0, p1));
}
Self { lines }
}
pub fn line_count(&self) -> u32 {
self.lines.len() as u32
}
pub fn to_vertices(&self) -> Vec<f32> {
self.lines
.iter()
.flat_map(|(a, b)| [a.x, a.y, a.z, b.x, b.y, b.z])
.collect()
}
}
#[derive(Debug, Clone)]
pub enum VertexEffect {
Rotate {
speed: f32,
},
Wobble {
frequency: f32,
amplitude: f32,
},
Pulse {
frequency: f32,
amplitude: f32,
},
Wave {
direction: Vec3,
frequency: f32,
speed: f32,
amplitude: f32,
},
Jitter {
amplitude: f32,
},
StretchToVelocity {
max_stretch: f32,
},
ScaleByDistance {
center: Vec3,
min_scale: f32,
max_scale: f32,
max_distance: f32,
},
FadeByDistance {
near: f32,
far: f32,
},
BillboardCylindrical {
axis: Vec3,
},
BillboardFixed {
forward: Vec3,
up: Vec3,
},
FacePoint {
target: Vec3,
},
Orbit {
center: Vec3,
speed: f32,
radius: f32,
axis: Vec3,
},
Spiral {
center: Vec3,
speed: f32,
expansion: f32,
vertical_speed: f32,
},
Sway {
frequency: f32,
amplitude: f32,
axis: Vec3,
},
ScaleBySpeed {
min_scale: f32,
max_scale: f32,
max_speed: f32,
},
Squash {
axis: Vec3,
amount: f32,
},
Tumble {
speed: f32,
},
Attract {
target: Vec3,
strength: f32,
max_displacement: f32,
},
Repel {
source: Vec3,
strength: f32,
radius: f32,
},
Turbulence {
frequency: f32,
amplitude: f32,
speed: f32,
},
ScaleByAge {
start_scale: f32,
end_scale: f32,
lifetime: f32,
},
FadeByAge {
start_alpha: f32,
end_alpha: f32,
lifetime: f32,
},
Vortex {
center: Vec3,
speed: f32,
pull: f32,
radius: f32,
},
Bounce {
height: f32,
frequency: f32,
damping: f32,
},
Figure8 {
width: f32,
height: f32,
speed: f32,
ratio: f32,
},
Helix {
axis: Vec3,
radius: f32,
speed: f32,
progression: f32,
},
Flutter {
intensity: f32,
speed: f32,
},
Brownian {
intensity: f32,
speed: f32,
},
}
impl VertexEffect {
pub fn to_wgsl(&self) -> String {
match self {
VertexEffect::Rotate { speed } => format!(
r#"
// Rotate effect
{{
let rot_speed = {speed}f;
let rot_angle = uniforms.time * rot_speed + f32(instance_index) * 0.1;
let cos_a = cos(rot_angle);
let sin_a = sin(rot_angle);
let rx = rotated_quad.x;
let ry = rotated_quad.y;
rotated_quad = vec2<f32>(
rx * cos_a - ry * sin_a,
rx * sin_a + ry * cos_a
);
}}"#
),
VertexEffect::Wobble { frequency, amplitude } => format!(
r#"
// Wobble effect
{{
let wobble_freq = {frequency}f;
let wobble_amp = {amplitude}f;
let phase = f32(instance_index) * 0.5;
pos_offset += vec3<f32>(
sin(uniforms.time * wobble_freq + phase) * wobble_amp,
cos(uniforms.time * wobble_freq * 1.3 + phase * 0.7) * wobble_amp,
sin(uniforms.time * wobble_freq * 0.7 + phase * 0.3) * wobble_amp
);
}}"#
),
VertexEffect::Pulse { frequency, amplitude } => format!(
r#"
// Pulse effect
{{
let pulse_freq = {frequency}f;
let pulse_amp = {amplitude}f;
let phase = f32(instance_index) * 0.2;
size_mult *= 1.0 + sin(uniforms.time * pulse_freq + phase) * pulse_amp;
}}"#
),
VertexEffect::Wave { direction, frequency, speed, amplitude } => format!(
r#"
// Wave effect
{{
let wave_dir = vec3<f32>({}f, {}f, {}f);
let wave_freq = {frequency}f;
let wave_speed = {speed}f;
let wave_amp = {amplitude}f;
let wave_phase = dot(particle_pos, wave_dir) * wave_freq - uniforms.time * wave_speed;
pos_offset += wave_dir * sin(wave_phase) * wave_amp;
}}"#,
direction.x, direction.y, direction.z
),
VertexEffect::Jitter { amplitude } => format!(
r#"
// Jitter effect
{{
let jitter_amp = {amplitude}f;
let seed = u32(uniforms.time * 60.0) + instance_index * 12345u;
let jx = fract(sin(f32(seed) * 12.9898) * 43758.5453) * 2.0 - 1.0;
let jy = fract(sin(f32(seed + 1u) * 12.9898) * 43758.5453) * 2.0 - 1.0;
let jz = fract(sin(f32(seed + 2u) * 12.9898) * 43758.5453) * 2.0 - 1.0;
pos_offset += vec3<f32>(jx, jy, jz) * jitter_amp;
}}"#
),
VertexEffect::StretchToVelocity { max_stretch } => format!(
r#"
// Stretch to velocity effect (approximated from position delta)
{{
let stretch_max = {max_stretch}f;
// Note: This is a visual approximation. For true velocity stretching,
// use with_vertex_shader() with velocity passed as attribute.
let stretch_dir = normalize(particle_pos + vec3<f32>(0.001, 0.001, 0.001));
let stretch_factor = 1.0 + length(particle_pos) * (stretch_max - 1.0);
// Stretch quad in the direction of motion
let stretch_dot = dot(normalize(rotated_quad), stretch_dir.xy);
size_mult *= mix(1.0, stretch_factor, abs(stretch_dot));
}}"#
),
VertexEffect::ScaleByDistance { center, min_scale, max_scale, max_distance } => format!(
r#"
// Scale by distance effect
{{
let scale_center = vec3<f32>({}f, {}f, {}f);
let scale_min = {min_scale}f;
let scale_max = {max_scale}f;
let scale_max_dist = {max_distance}f;
let dist = length(particle_pos - scale_center);
let t = clamp(dist / scale_max_dist, 0.0, 1.0);
size_mult *= mix(scale_min, scale_max, t);
}}"#,
center.x, center.y, center.z
),
VertexEffect::FadeByDistance { near, far } => format!(
r#"
// Fade by distance effect
{{
let fade_near = {near}f;
let fade_far = {far}f;
let dist = length(particle_pos);
let fade = 1.0 - clamp((dist - fade_near) / (fade_far - fade_near), 0.0, 1.0);
color_mod *= fade;
}}"#
),
VertexEffect::BillboardCylindrical { axis } => format!(
r#"
// Cylindrical billboard - fixed axis, face camera horizontally
{{
let fixed_axis = normalize(vec3<f32>({}f, {}f, {}f));
// Camera is at origin looking at particles, so camera_dir approximates view
let to_camera = normalize(-particle_pos);
// Project to_camera onto plane perpendicular to fixed_axis
let camera_flat = normalize(to_camera - fixed_axis * dot(to_camera, fixed_axis));
// Right vector is perpendicular to both
let right = cross(fixed_axis, camera_flat);
// Build world-space offset from quad coordinates
billboard_right = right;
billboard_up = fixed_axis;
use_world_billboard = true;
}}"#,
axis.x, axis.y, axis.z
),
VertexEffect::BillboardFixed { forward, up } => format!(
r#"
// Fixed billboard - no camera facing
{{
let fwd = normalize(vec3<f32>({}f, {}f, {}f));
let up_dir = normalize(vec3<f32>({}f, {}f, {}f));
let right = cross(up_dir, fwd);
billboard_right = right;
billboard_up = up_dir;
use_world_billboard = true;
}}"#,
forward.x, forward.y, forward.z,
up.x, up.y, up.z
),
VertexEffect::FacePoint { target } => format!(
r#"
// Face point - orient toward target
{{
let target_pos = vec3<f32>({}f, {}f, {}f);
let to_target = normalize(target_pos - particle_pos);
// Use world up as reference
let world_up = vec3<f32>(0.0, 1.0, 0.0);
var right = cross(world_up, to_target);
if length(right) < 0.001 {{
right = vec3<f32>(1.0, 0.0, 0.0);
}} else {{
right = normalize(right);
}}
let up_dir = cross(to_target, right);
billboard_right = right;
billboard_up = up_dir;
use_world_billboard = true;
}}"#,
target.x, target.y, target.z
),
VertexEffect::Orbit { center, speed, radius, axis } => format!(
r#"
// Orbit effect - circular motion around center
{{
let orbit_center = vec3<f32>({}f, {}f, {}f);
let orbit_speed = {speed}f;
let orbit_radius = {radius}f;
let orbit_axis = normalize(vec3<f32>({}f, {}f, {}f));
// Phase offset per particle for variety
let phase = f32(instance_index) * 0.618033988749; // Golden ratio
let angle = uniforms.time * orbit_speed + phase * 6.283185;
// Build rotation around axis
let cos_a = cos(angle);
let sin_a = sin(angle);
// Get perpendicular vectors to orbit axis
var perp1 = cross(orbit_axis, vec3<f32>(0.0, 1.0, 0.0));
if length(perp1) < 0.001 {{
perp1 = cross(orbit_axis, vec3<f32>(1.0, 0.0, 0.0));
}}
perp1 = normalize(perp1);
let perp2 = cross(orbit_axis, perp1);
// Circular offset
let orbit_offset = (perp1 * cos_a + perp2 * sin_a) * orbit_radius;
pos_offset += orbit_offset;
}}"#,
center.x, center.y, center.z,
axis.x, axis.y, axis.z
),
VertexEffect::Spiral { center, speed, expansion, vertical_speed } => format!(
r#"
// Spiral effect - expanding/contracting spiral motion
{{
let spiral_center = vec3<f32>({}f, {}f, {}f);
let spiral_speed = {speed}f;
let spiral_expansion = {expansion}f;
let spiral_vertical = {vertical_speed}f;
let phase = f32(instance_index) * 0.618033988749;
let t = uniforms.time + phase;
let angle = t * spiral_speed;
let radius_t = spiral_expansion * t;
pos_offset += vec3<f32>(
cos(angle) * radius_t,
t * spiral_vertical,
sin(angle) * radius_t
);
}}"#,
center.x, center.y, center.z
),
VertexEffect::Sway { frequency, amplitude, axis } => format!(
r#"
// Sway effect - grass/tree-like motion
{{
let sway_freq = {frequency}f;
let sway_amp = {amplitude}f;
let sway_axis = normalize(vec3<f32>({}f, {}f, {}f));
// Use particle height (Y) as anchor point - higher = more sway
let height_factor = max(0.0, particle_pos.y + 0.5);
let phase = f32(instance_index) * 0.37 + particle_pos.x * 2.0;
// Primary sway
let sway1 = sin(uniforms.time * sway_freq + phase) * sway_amp * height_factor;
// Secondary slower sway for more organic feel
let sway2 = sin(uniforms.time * sway_freq * 0.4 + phase * 1.3) * sway_amp * 0.3 * height_factor;
pos_offset += sway_axis * (sway1 + sway2);
}}"#,
axis.x, axis.y, axis.z
),
VertexEffect::ScaleBySpeed { min_scale, max_scale, max_speed } => format!(
r#"
// Scale by speed effect (approximated from position variance)
{{
let speed_min_scale = {min_scale}f;
let speed_max_scale = {max_scale}f;
let speed_max = {max_speed}f;
// Approximate speed from position-based hash (changes with movement)
let pos_hash = fract(sin(dot(particle_pos, vec3<f32>(12.9898, 78.233, 45.164))) * 43758.5453);
let approx_speed = pos_hash * speed_max;
let t = clamp(approx_speed / speed_max, 0.0, 1.0);
size_mult *= mix(speed_min_scale, speed_max_scale, t);
}}"#
),
VertexEffect::Squash { axis, amount } => format!(
r#"
// Squash effect - flatten along axis
{{
let squash_axis = normalize(vec3<f32>({}f, {}f, {}f));
let squash_amount = {amount}f;
// Project quad onto squash plane
let axis_component = dot(vec3<f32>(rotated_quad, 0.0), squash_axis);
let squash_factor = mix(squash_amount, 1.0, 1.0 - abs(axis_component));
// Apply squash to the appropriate quad dimension
if abs(squash_axis.x) > 0.5 {{
rotated_quad.x *= squash_amount;
}}
if abs(squash_axis.y) > 0.5 {{
rotated_quad.y *= squash_amount;
}}
}}"#,
axis.x, axis.y, axis.z
),
VertexEffect::Tumble { speed } => format!(
r#"
// Tumble effect - chaotic 3D rotation
{{
let tumble_speed = {speed}f;
let idx = f32(instance_index);
// Different rotation speeds per axis per particle
let rx = uniforms.time * tumble_speed * (0.5 + fract(idx * 0.1234));
let ry = uniforms.time * tumble_speed * (0.7 + fract(idx * 0.5678));
// Apply X rotation
let cos_rx = cos(rx);
let sin_rx = sin(rx);
var q = rotated_quad;
q.y = rotated_quad.y * cos_rx;
// Apply Y rotation
let cos_ry = cos(ry);
let sin_ry = sin(ry);
rotated_quad = vec2<f32>(
q.x * cos_ry - q.y * sin_ry,
q.x * sin_ry + q.y * cos_ry
);
}}"#
),
VertexEffect::Attract { target, strength, max_displacement } => format!(
r#"
// Attract effect - pull toward target
{{
let attract_target = vec3<f32>({}f, {}f, {}f);
let attract_strength = {strength}f;
let attract_max = {max_displacement}f;
let to_target = attract_target - particle_pos;
let dist = length(to_target);
if dist > 0.001 {{
let dir = to_target / dist;
// Inverse square falloff
let force = attract_strength / (1.0 + dist * dist);
let displacement = min(force, attract_max);
pos_offset += dir * displacement;
}}
}}"#,
target.x, target.y, target.z
),
VertexEffect::Repel { source, strength, radius } => format!(
r#"
// Repel effect - push away from source
{{
let repel_source = vec3<f32>({}f, {}f, {}f);
let repel_strength = {strength}f;
let repel_radius = {radius}f;
let from_source = particle_pos - repel_source;
let dist = length(from_source);
if dist < repel_radius && dist > 0.001 {{
let dir = from_source / dist;
// Linear falloff within radius
let t = 1.0 - (dist / repel_radius);
let force = repel_strength * t * t;
pos_offset += dir * force;
}}
}}"#,
source.x, source.y, source.z
),
VertexEffect::Turbulence { frequency, amplitude, speed } => format!(
r#"
// Turbulence effect - noise-based displacement
{{
let turb_freq = {frequency}f;
let turb_amp = {amplitude}f;
let turb_speed = {speed}f;
// Simple 3D noise approximation using sin combinations
let p = particle_pos * turb_freq + uniforms.time * turb_speed;
let n1 = sin(p.x * 1.0 + p.y * 0.5 + p.z * 0.3);
let n2 = sin(p.y * 1.1 + p.z * 0.6 + p.x * 0.4 + 1.5);
let n3 = sin(p.z * 0.9 + p.x * 0.7 + p.y * 0.5 + 3.0);
// Layer multiple octaves
let o1 = vec3<f32>(n1, n2, n3);
let p2 = p * 2.0 + 5.0;
let o2 = vec3<f32>(
sin(p2.x + p2.y * 0.5),
sin(p2.y + p2.z * 0.5),
sin(p2.z + p2.x * 0.5)
) * 0.5;
pos_offset += (o1 + o2) * turb_amp;
}}"#
),
VertexEffect::ScaleByAge { start_scale, end_scale, lifetime } => format!(
r#"
// Scale by age effect (approximated using time + particle index)
{{
let age_start_scale = {start_scale}f;
let age_end_scale = {end_scale}f;
let age_lifetime = {lifetime}f;
// Approximate age using time modulo lifetime, offset by particle index
let phase = fract(f32(instance_index) * 0.1);
let age = fract((uniforms.time + phase * age_lifetime) / age_lifetime);
size_mult *= mix(age_start_scale, age_end_scale, age);
}}"#
),
VertexEffect::FadeByAge { start_alpha, end_alpha, lifetime } => format!(
r#"
// Fade by age effect
{{
let fade_start = {start_alpha}f;
let fade_end = {end_alpha}f;
let fade_lifetime = {lifetime}f;
// Approximate age using time modulo lifetime
let phase = fract(f32(instance_index) * 0.1);
let age = fract((uniforms.time + phase * fade_lifetime) / fade_lifetime);
color_mod *= mix(fade_start, fade_end, age);
}}"#
),
VertexEffect::Vortex { center, speed, pull, radius } => format!(
r#"
// Vortex effect - tornado/whirlpool motion
{{
let vortex_center = vec3<f32>({}f, {}f, {}f);
let vortex_speed = {speed}f;
let vortex_pull = {pull}f;
let vortex_radius = {radius}f;
let to_center = particle_pos - vortex_center;
let dist_xz = length(vec2<f32>(to_center.x, to_center.z));
if dist_xz > 0.001 {{
// Rotation around Y axis
let angle = uniforms.time * vortex_speed * (1.0 + 0.5 / (dist_xz + 0.1));
let phase = f32(instance_index) * 0.618033988749;
let cos_a = cos(angle + phase);
let sin_a = sin(angle + phase);
// Tangential offset (perpendicular to radius)
let tangent = vec3<f32>(-to_center.z, 0.0, to_center.x) / dist_xz;
let orbit_offset = tangent * sin_a * min(dist_xz, vortex_radius) * 0.3;
// Vertical pull toward center (stronger near center)
let pull_factor = vortex_pull * (1.0 - clamp(dist_xz / vortex_radius, 0.0, 1.0));
pos_offset += orbit_offset + vec3<f32>(0.0, pull_factor * sin(uniforms.time * 2.0 + phase), 0.0);
}}
}}"#,
center.x, center.y, center.z
),
VertexEffect::Bounce { height, frequency, damping } => format!(
r#"
// Bounce effect - gravity-like bouncing
{{
let bounce_height = {height}f;
let bounce_freq = {frequency}f;
let bounce_damp = {damping}f;
let phase = f32(instance_index) * 0.37;
let t = fract(uniforms.time * bounce_freq + phase);
// Parabolic bounce with damping
let bounce_cycle = floor(uniforms.time * bounce_freq + phase);
let damp_factor = pow(1.0 - bounce_damp, bounce_cycle % 4.0);
// Parabola: starts at 0, peaks at 0.5, returns to 0
let h = 4.0 * t * (1.0 - t);
pos_offset.y += h * bounce_height * damp_factor;
}}"#
),
VertexEffect::Figure8 { width, height, speed, ratio } => format!(
r#"
// Figure-8 / Lissajous curve motion
{{
let fig_width = {width}f;
let fig_height = {height}f;
let fig_speed = {speed}f;
let fig_ratio = {ratio}f;
let phase = f32(instance_index) * 0.618033988749;
let t = uniforms.time * fig_speed + phase;
// Lissajous curve: x = sin(t), y = sin(ratio * t)
// ratio = 2 gives figure-8, other values give other patterns
pos_offset.x += sin(t) * fig_width;
pos_offset.y += sin(t * fig_ratio) * fig_height;
pos_offset.z += sin(t * 0.5) * fig_width * 0.3;
}}"#
),
VertexEffect::Helix { axis, radius, speed, progression } => format!(
r#"
// Helix effect - corkscrew/DNA motion
{{
let helix_axis = normalize(vec3<f32>({}f, {}f, {}f));
let helix_radius = {radius}f;
let helix_speed = {speed}f;
let helix_prog = {progression}f;
let phase = f32(instance_index) * 0.618033988749;
let t = uniforms.time * helix_speed + phase;
// Get perpendicular vectors to axis
var perp1 = cross(helix_axis, vec3<f32>(0.0, 1.0, 0.0));
if length(perp1) < 0.001 {{
perp1 = cross(helix_axis, vec3<f32>(1.0, 0.0, 0.0));
}}
perp1 = normalize(perp1);
let perp2 = cross(helix_axis, perp1);
// Circular motion perpendicular to axis
let circle_offset = (perp1 * cos(t) + perp2 * sin(t)) * helix_radius;
// Forward progression along axis
let prog_offset = helix_axis * fract(t * helix_prog * 0.1) * 2.0;
pos_offset += circle_offset + prog_offset;
}}"#,
axis.x, axis.y, axis.z
),
VertexEffect::Flutter { intensity, speed } => format!(
r#"
// Flutter effect - rapid small oscillations
{{
let flutter_int = {intensity}f;
let flutter_speed = {speed}f;
let idx = f32(instance_index);
let t = uniforms.time * flutter_speed;
// Multiple high-frequency oscillations with different phases
let f1 = sin(t * 15.0 + idx * 1.23) * 0.4;
let f2 = sin(t * 23.0 + idx * 2.34) * 0.3;
let f3 = sin(t * 31.0 + idx * 3.45) * 0.2;
let f4 = cos(t * 19.0 + idx * 4.56) * 0.35;
let f5 = cos(t * 27.0 + idx * 5.67) * 0.25;
pos_offset += vec3<f32>(
(f1 + f2) * flutter_int,
(f3 + f4) * flutter_int,
(f2 + f5) * flutter_int * 0.5
);
}}"#
),
VertexEffect::Brownian { intensity, speed } => format!(
r#"
// Brownian motion - random walk
{{
let brown_int = {intensity}f;
let brown_speed = {speed}f;
let idx = f32(instance_index);
let t = uniforms.time * brown_speed;
// Layered noise-like motion that evolves over time
// Using multiple sine waves with irrational frequency ratios
let n1 = sin(t * 1.0 + idx * 12.9898) * sin(t * 0.7 + idx * 4.1414);
let n2 = sin(t * 1.3 + idx * 78.233) * sin(t * 0.9 + idx * 2.7182);
let n3 = sin(t * 0.8 + idx * 43.758) * sin(t * 1.1 + idx * 3.1415);
// Accumulate "steps" for random walk effect
let walk_x = n1 + sin(t * 0.3 + idx) * 0.5;
let walk_y = n2 + sin(t * 0.4 + idx * 1.1) * 0.5;
let walk_z = n3 + sin(t * 0.35 + idx * 0.9) * 0.5;
pos_offset += vec3<f32>(walk_x, walk_y, walk_z) * brown_int;
}}"#
),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum GlyphMode {
#[default]
None,
VectorField {
field_index: usize,
},
ParticleVelocity,
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum GlyphColorMode {
#[default]
Uniform,
ByMagnitude,
ByDirection,
}
#[derive(Debug, Clone, PartialEq)]
pub struct GlyphConfig {
pub mode: GlyphMode,
pub grid_resolution: u32,
pub scale: f32,
pub color_mode: GlyphColorMode,
pub color: Vec3,
pub thickness: f32,
}
impl Default for GlyphConfig {
fn default() -> Self {
Self {
mode: GlyphMode::None,
grid_resolution: 8,
scale: 0.1,
color_mode: GlyphColorMode::Uniform,
color: Vec3::new(0.0, 1.0, 0.5),
thickness: 0.002,
}
}
}
pub fn combine_vertex_effects(effects: &[VertexEffect], color_expr: &str) -> String {
if effects.is_empty() {
return format!(
r#" let world_pos = vec4<f32>(particle_pos, 1.0);
var clip_pos = uniforms.view_proj * world_pos;
clip_pos.x += quad_pos.x * particle_size * clip_pos.w;
clip_pos.y += quad_pos.y * particle_size * clip_pos.w;
out.clip_position = clip_pos;
out.color = {color_expr};
out.uv = quad_pos;
return out;"#
);
}
let has_billboard = effects.iter().any(|e| matches!(e,
VertexEffect::BillboardCylindrical { .. } |
VertexEffect::BillboardFixed { .. } |
VertexEffect::FacePoint { .. }
));
let effects_code: String = effects.iter().map(|e| e.to_wgsl()).collect();
let billboard_vars = if has_billboard {
r#"
var use_world_billboard = false;
var billboard_right = vec3<f32>(1.0, 0.0, 0.0);
var billboard_up = vec3<f32>(0.0, 1.0, 0.0);"#
} else {
""
};
let final_transform = if has_billboard {
r#" // Final transformation
let final_pos = particle_pos + pos_offset;
let final_size = particle_size * size_mult;
let world_pos = vec4<f32>(final_pos, 1.0);
if use_world_billboard {
// World-space billboarding
let world_offset = billboard_right * rotated_quad.x * final_size
+ billboard_up * rotated_quad.y * final_size;
let offset_world_pos = vec4<f32>(final_pos + world_offset, 1.0);
out.clip_position = uniforms.view_proj * offset_world_pos;
} else {
// Screen-space billboarding (default)
var clip_pos = uniforms.view_proj * world_pos;
clip_pos.x += rotated_quad.x * final_size * clip_pos.w;
clip_pos.y += rotated_quad.y * final_size * clip_pos.w;
out.clip_position = clip_pos;
}
out.color = color_mod;
out.uv = rotated_quad;
return out;"#
} else {
r#" // Final transformation
let final_pos = particle_pos + pos_offset;
let final_size = particle_size * size_mult;
let world_pos = vec4<f32>(final_pos, 1.0);
var clip_pos = uniforms.view_proj * world_pos;
clip_pos.x += rotated_quad.x * final_size * clip_pos.w;
clip_pos.y += rotated_quad.y * final_size * clip_pos.w;
out.clip_position = clip_pos;
out.color = color_mod;
out.uv = rotated_quad;
return out;"#
};
format!(
r#" // Initialize effect variables
var pos_offset = vec3<f32>(0.0);
var rotated_quad = quad_pos;
var size_mult = 1.0;
var color_mod = {color_expr};{billboard_vars}
// Apply vertex effects
{effects_code}
{final_transform}"#
)
}