Skip to main content

proof_engine/particle/
particle_render.rs

1//! GPU particle rendering bridge — renders particles directly from compute SSBO
2//! using indirect draw, with zero CPU readback.
3//!
4//! The render pipeline reads from the SSBO that was written by `particle_update.comp`
5//! and draws instanced quads (one per alive particle) using the standard glyph
6//! vertex format.  The indirect draw buffer's instance count is written by the
7//! compute shader, so the CPU never needs to know how many particles are alive.
8
9use glam::{Vec2, Vec3, Vec4, Mat4};
10
11use super::gpu_particles::{GpuParticle, GpuParticleSystem, GpuIndirectDrawParams};
12
13// ── Particle render instance (GPU-side) ─────────────────────────────────────
14
15/// Per-particle data extracted from the SSBO for rendering.
16///
17/// In the fully GPU path, this conversion happens in a second compute pass
18/// or in the vertex shader itself.  For the hybrid path, this struct is used
19/// for CPU-side extraction.
20#[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
31// Verify 48 bytes.
32const _: () = assert!(std::mem::size_of::<ParticleRenderInstance>() == 48);
33
34// ── Particle vertex shader (embedded) ───────────────────────────────────────
35
36/// Vertex shader that reads particle data from SSBO and renders instanced quads.
37///
38/// Each particle is a camera-facing (billboard) quad.  The vertex shader
39/// reads position/size/color from the SSBO via gl_InstanceID.
40pub 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
121/// Fragment shader for GPU particles.
122///
123/// Renders a soft circular particle with color from the SSBO.
124/// Outputs to dual attachments (color + emission) for the bloom pipeline.
125pub 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// ── Render configuration ────────────────────────────────────────────────────
156
157/// Configuration for GPU particle rendering.
158#[derive(Clone, Debug)]
159pub struct ParticleRenderConfig {
160    /// Whether to use indirect draw (GPU-driven instance count).
161    pub indirect_draw: bool,
162    /// Whether to render particles as billboards (camera-facing quads).
163    pub billboard: bool,
164    /// Whether to use additive blending (true) or alpha blending (false).
165    pub additive_blend: bool,
166    /// Maximum render distance (particles beyond this are culled in the vertex shader).
167    pub max_render_distance: f32,
168    /// Particle character for atlas-based rendering (when not using soft circles).
169    pub atlas_char: Option<char>,
170    /// Whether to sort particles back-to-front (expensive, only for alpha blend).
171    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
187// ── CPU fallback: extract render instances from particle buffer ──────────────
188
189/// Extract render instances from a CPU-side particle buffer.
190///
191/// Used when compute shaders are unavailable.  Filters dead particles and
192/// produces a compact list of render instances.
193pub 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
212/// Sort render instances back-to-front relative to the camera position.
213pub 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// ── LOD system ──────────────────────────────────────────────────────────────
222
223/// LOD tier for particle rendering based on camera distance.
224#[derive(Clone, Copy, Debug, PartialEq, Eq)]
225pub enum ParticleLodTier {
226    /// Full quality: all particles rendered.
227    Full,
228    /// Medium: skip every 2nd particle.
229    Medium,
230    /// Low: skip every 4th particle.
231    Low,
232    /// Minimal: skip every 8th particle.
233    Minimal,
234}
235
236impl ParticleLodTier {
237    /// Get the skip stride for this LOD tier.
238    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    /// Determine LOD tier based on camera distance to the particle system's center.
248    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// ── Depth layer rendering ───────────────────────────────────────────────────
262
263/// Configuration for rendering particles across multiple depth layers.
264///
265/// Each layer has its own Z range and can have different opacity/blend settings.
266#[derive(Clone, Debug)]
267pub struct DepthLayerConfig {
268    /// Z offset for this layer.
269    pub z_offset: f32,
270    /// Opacity multiplier for this layer (back layers dimmer).
271    pub opacity: f32,
272    /// Size multiplier (back layers can be larger for parallax).
273    pub size_scale: f32,
274}
275
276impl DepthLayerConfig {
277    /// Generate layer configs from Z offsets with automatic opacity/size scaling.
278    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), // back layers dimmer
285                size_scale: 0.8 + 0.4 * depth_frac,       // back layers slightly larger
286            }
287        }).collect()
288    }
289}
290
291// ── Tests ───────────────────────────────────────────────────────────────────
292
293#[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}