1use glam::{Vec2, Vec3, Vec4, Mat4};
10
11use super::gpu_particles::{GpuParticle, GpuParticleSystem, GpuIndirectDrawParams};
12
13#[repr(C)]
21#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
22pub struct ParticleRenderInstance {
23 pub position: [f32; 3],
24 pub size: f32,
25 pub color: [f32; 4],
26 pub age_frac: f32,
27 pub engine_type: u32,
28 pub _pad: [f32; 2],
29}
30
31const _: () = assert!(std::mem::size_of::<ParticleRenderInstance>() == 48);
33
34pub const PARTICLE_VERT_SRC: &str = r#"
41#version 430 core
42
43// Per-vertex (unit quad)
44layout(location = 0) in vec2 v_pos; // [-0.5, 0.5]
45layout(location = 1) in vec2 v_uv; // [0, 1]
46
47// Particle SSBO (read-only in vertex shader)
48struct Particle {
49 vec3 position;
50 float _pad0;
51 vec3 velocity;
52 float _pad1;
53 vec4 color;
54 float life;
55 float max_life;
56 float size;
57 uint engine_type;
58 float seed;
59 uint flags;
60 float _reserved0;
61 float _reserved1;
62};
63
64layout(std430, binding = 0) readonly buffer ParticleBuffer {
65 Particle particles[];
66};
67
68uniform mat4 u_view_proj;
69uniform vec3 u_camera_right;
70uniform vec3 u_camera_up;
71uniform float u_time;
72
73out vec2 f_uv;
74out vec4 f_color;
75out float f_age_frac;
76out float f_emission;
77
78void main() {
79 Particle p = particles[gl_InstanceID];
80
81 // Skip dead particles (alpha will be zero, but also move off-screen).
82 if (p.life <= 0.0) {
83 gl_Position = vec4(0.0, 0.0, -999.0, 1.0);
84 f_color = vec4(0.0);
85 f_uv = vec2(0.0);
86 f_age_frac = 1.0;
87 f_emission = 0.0;
88 return;
89 }
90
91 float age_frac = 1.0 - clamp(p.life / p.max_life, 0.0, 1.0);
92
93 // Size modulation over lifetime: starts at full, shrinks in last 20%.
94 float size_mod = 1.0;
95 if (age_frac > 0.8) {
96 size_mod = 1.0 - (age_frac - 0.8) * 5.0;
97 }
98 float particle_size = p.size * size_mod;
99
100 // Billboard: orient quad to face camera.
101 vec3 world_pos = p.position
102 + u_camera_right * v_pos.x * particle_size
103 + u_camera_up * v_pos.y * particle_size;
104
105 gl_Position = u_view_proj * vec4(world_pos, 1.0);
106 gl_Position.y = -gl_Position.y; // FBO Y inversion
107
108 f_uv = v_uv;
109 f_color = p.color;
110 f_age_frac = age_frac;
111
112 // Emission based on engine type: some engines glow more.
113 float base_emission = 0.0;
114 if (p.engine_type == 1u) base_emission = 0.5; // Lorenz
115 if (p.engine_type == 4u) base_emission = 0.4; // Rossler
116 if (p.engine_type == 7u) base_emission = 0.3; // Halvorsen
117 f_emission = base_emission * (1.0 - age_frac);
118}
119"#;
120
121pub const PARTICLE_FRAG_SRC: &str = r#"
126#version 430 core
127
128in vec2 f_uv;
129in vec4 f_color;
130in float f_age_frac;
131in float f_emission;
132
133layout(location = 0) out vec4 o_color;
134layout(location = 1) out vec4 o_emission;
135
136void main() {
137 // Soft circle: distance from center of UV quad.
138 vec2 center = f_uv - 0.5;
139 float dist = length(center) * 2.0;
140 float alpha = smoothstep(1.0, 0.6, dist);
141
142 if (alpha < 0.01) discard;
143
144 vec4 color = f_color;
145 color.a *= alpha;
146
147 o_color = color;
148
149 // Emission for bloom.
150 float bloom = max(f_emission, 0.0);
151 o_emission = vec4(color.rgb * bloom, color.a * bloom);
152}
153"#;
154
155#[derive(Clone, Debug)]
159pub struct ParticleRenderConfig {
160 pub indirect_draw: bool,
162 pub billboard: bool,
164 pub additive_blend: bool,
166 pub max_render_distance: f32,
168 pub atlas_char: Option<char>,
170 pub depth_sort: bool,
172}
173
174impl Default for ParticleRenderConfig {
175 fn default() -> Self {
176 Self {
177 indirect_draw: true,
178 billboard: true,
179 additive_blend: true,
180 max_render_distance: 100.0,
181 atlas_char: None,
182 depth_sort: false,
183 }
184 }
185}
186
187pub fn extract_render_instances(particles: &[GpuParticle]) -> Vec<ParticleRenderInstance> {
194 let mut instances = Vec::with_capacity(particles.len());
195 for p in particles {
196 if p.life <= 0.0 {
197 continue;
198 }
199 let age_frac = 1.0 - (p.life / p.max_life).clamp(0.0, 1.0);
200 instances.push(ParticleRenderInstance {
201 position: p.position,
202 size: p.size,
203 color: p.color,
204 age_frac,
205 engine_type: p.engine_type,
206 _pad: [0.0; 2],
207 });
208 }
209 instances
210}
211
212pub fn sort_instances_back_to_front(instances: &mut [ParticleRenderInstance], camera_pos: Vec3) {
214 instances.sort_by(|a, b| {
215 let da = Vec3::from(a.position).distance_squared(camera_pos);
216 let db = Vec3::from(b.position).distance_squared(camera_pos);
217 db.partial_cmp(&da).unwrap_or(std::cmp::Ordering::Equal)
218 });
219}
220
221#[derive(Clone, Copy, Debug, PartialEq, Eq)]
225pub enum ParticleLodTier {
226 Full,
228 Medium,
230 Low,
232 Minimal,
234}
235
236impl ParticleLodTier {
237 pub fn stride(self) -> u32 {
239 match self {
240 ParticleLodTier::Full => 1,
241 ParticleLodTier::Medium => 2,
242 ParticleLodTier::Low => 4,
243 ParticleLodTier::Minimal => 8,
244 }
245 }
246
247 pub fn from_distance(distance: f32) -> Self {
249 if distance < 20.0 {
250 ParticleLodTier::Full
251 } else if distance < 50.0 {
252 ParticleLodTier::Medium
253 } else if distance < 100.0 {
254 ParticleLodTier::Low
255 } else {
256 ParticleLodTier::Minimal
257 }
258 }
259}
260
261#[derive(Clone, Debug)]
267pub struct DepthLayerConfig {
268 pub z_offset: f32,
270 pub opacity: f32,
272 pub size_scale: f32,
274}
275
276impl DepthLayerConfig {
277 pub fn from_z_offsets(offsets: &[f32]) -> Vec<Self> {
279 let count = offsets.len();
280 offsets.iter().enumerate().map(|(i, &z)| {
281 let depth_frac = i as f32 / (count.max(1) - 1).max(1) as f32;
282 Self {
283 z_offset: z,
284 opacity: 0.4 + 0.6 * (1.0 - depth_frac), size_scale: 0.8 + 0.4 * depth_frac, }
287 }).collect()
288 }
289}
290
291#[cfg(test)]
294mod tests {
295 use super::*;
296
297 #[test]
298 fn render_instance_size() {
299 assert_eq!(std::mem::size_of::<ParticleRenderInstance>(), 48);
300 }
301
302 #[test]
303 fn extract_filters_dead() {
304 let particles = vec![
305 GpuParticle { life: 1.0, max_life: 2.0, size: 1.0, ..GpuParticle::dead() },
306 GpuParticle::dead(),
307 GpuParticle { life: 0.5, max_life: 1.0, size: 0.5, ..GpuParticle::dead() },
308 ];
309 let instances = extract_render_instances(&particles);
310 assert_eq!(instances.len(), 2);
311 }
312
313 #[test]
314 fn lod_tiers() {
315 assert_eq!(ParticleLodTier::from_distance(5.0), ParticleLodTier::Full);
316 assert_eq!(ParticleLodTier::from_distance(30.0), ParticleLodTier::Medium);
317 assert_eq!(ParticleLodTier::from_distance(75.0), ParticleLodTier::Low);
318 assert_eq!(ParticleLodTier::from_distance(150.0), ParticleLodTier::Minimal);
319 }
320
321 #[test]
322 fn depth_layers_from_offsets() {
323 let layers = DepthLayerConfig::from_z_offsets(&[-5.0, 0.0, 5.0]);
324 assert_eq!(layers.len(), 3);
325 assert!(layers[0].opacity > layers[2].opacity, "Front should be brighter");
326 }
327}