Skip to main content

proof_engine/particle/
gpu_density.rs

1//! GPU density entities: a figure described by a handful of bones, rendered
2//! as millions of particles that never touch the CPU.
3//!
4//! A [`GpuDensityEntityData`] is sixteen capsules with a radius, a density
5//! weight and a colour, plus a few numbers about how the whole body behaves:
6//! how much of it is still bound (`hp`), how it breathes, how tightly its
7//! matter holds to the skeleton. That is all that crosses to the GPU each
8//! frame: under a kilobyte.
9//!
10//! Every particle is then derived in the vertex shader from its own instance
11//! index. A hash picks a bone in proportion to the bones' weights, a point in
12//! that capsule, a phase for its jitter, and whether it is one of the ones
13//! that has come loose. Nothing is stored per particle, nothing is uploaded,
14//! and the count is whatever the GPU can draw: two million particles is one
15//! instanced draw call of two million quads.
16//!
17//! Loose matter drifts away from the body and fades. Lower `hp` and more of
18//! the body is loose, which is what damage looks like on something that is
19//! made of matter rather than drawn.
20//!
21//! This needs nothing past OpenGL 3.3: no compute, no storage buffers.
22
23use glam::Mat4;
24use glow::HasContext;
25
26/// The number of bones an entity carries. Fixed so the whole skeleton fits
27/// in one uniform array.
28pub const MAX_BONES: usize = 16;
29
30/// The most particles one entity is ever drawn with, whatever was asked for.
31/// Above this the draw is fill-bound on any GPU and the picture stops
32/// improving, because there are more particles than pixels.
33pub const MAX_PARTICLES_PER_ENTITY: u32 = 4_000_000;
34
35/// One capsule of the skeleton, laid out for the shader.
36#[repr(C)]
37#[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
38pub struct GpuBone {
39    /// `(start_x, start_y, end_x, end_y)` in the entity's own space, `y` down.
40    pub start_end: [f32; 4],
41    /// `(radius, density, weight, unused)`. Particles are shared out among
42    /// the bones by `weight`; `density` is kept for the caller's own use.
43    pub params: [f32; 4],
44    /// RGBA. Near-white or saturated bright colours are drawn emissive.
45    pub color: [f32; 4],
46}
47
48impl Default for GpuBone {
49    fn default() -> Self {
50        GpuBone { start_end: [0.0; 4], params: [0.0; 4], color: [0.0; 4] }
51    }
52}
53
54/// Everything the GPU needs to draw one entity this frame.
55#[repr(C)]
56#[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
57pub struct GpuDensityEntityData {
58    /// World position, and the scale from entity space to world units.
59    pub position_scale: [f32; 4],
60    /// A tint over every bone's colour. White leaves them alone.
61    pub color: [f32; 4],
62    /// `(hp_ratio, breath_phase, breath_amplitude, density_falloff)`.
63    pub params: [f32; 4],
64    /// `(bone_count, particle_count, jitter, binding_strength)`.
65    pub params2: [f32; 4],
66    pub bones: [GpuBone; MAX_BONES],
67}
68
69impl GpuDensityEntityData {
70    /// How many bones are live.
71    pub fn bone_count(&self) -> usize {
72        (self.params2[0].max(0.0) as usize).min(MAX_BONES)
73    }
74
75    /// How many particles the entity asked to be drawn with.
76    pub fn requested_particles(&self) -> u32 {
77        self.params2[1].max(0.0).min(u32::MAX as f32) as u32
78    }
79
80    /// The particle count actually drawn, under a budget.
81    pub fn particle_count(&self, budget: u32) -> u32 {
82        self.requested_particles()
83            .min(budget)
84            .min(MAX_PARTICLES_PER_ENTITY)
85    }
86
87    /// The bone weights, and their sum. Bones with no weight get none of
88    /// the particles.
89    pub fn weights(&self) -> ([f32; MAX_BONES], f32) {
90        let mut w = [0.0f32; MAX_BONES];
91        let mut total = 0.0;
92        for (i, b) in self.bones.iter().enumerate().take(self.bone_count()) {
93            w[i] = b.params[2].max(0.0);
94            total += w[i];
95        }
96        (w, total)
97    }
98}
99
100/// The size of one particle on screen, in pixels, for a count. Fewer
101/// particles are drawn bigger so the body still reads as solid.
102pub fn particle_pixels(count: u32) -> f32 {
103    let n = count.max(1) as f32;
104    (1.35 * (2_000_000.0 / n).sqrt()).clamp(1.0, 6.0)
105}
106
107/// The alpha of one particle, so that a body of any count sums to about
108/// the same brightness.
109pub fn particle_alpha(count: u32) -> f32 {
110    let n = count.max(1) as f32;
111    (400_000.0 / n).clamp(0.03, 0.85)
112}
113
114const VERT: &str = r#"
115#version 330 core
116layout(location = 0) in vec2 v_pos;
117layout(location = 1) in vec2 v_uv;
118
119uniform mat4  u_view_proj;
120uniform vec4  u_pos_scale;
121uniform vec4  u_color;
122uniform vec4  u_params;    // hp, breath_phase, breath_amp, falloff
123uniform vec4  u_params2;   // bone_count, particle_count, jitter, binding
124uniform vec4  u_bones[48]; // per bone: start_end, params, color
125uniform float u_time;
126uniform vec2  u_px;        // particle size in clip units per w
127uniform float u_alpha;
128
129out vec2  f_uv;
130out vec4  f_color;
131out float f_emission;
132
133// Same hash the glyph pass uses, so the two kinds of matter share a grain.
134float hf(uint seed, uint v) {
135    uint n = seed * 374761393u + v * 668265263u;
136    n ^= (n >> 13u);
137    n *= 0x5851F42Du;
138    n ^= (n >> 16u);
139    return float(n & 0x00FFFFFFu) / float(0x01000000u);
140}
141
142void main() {
143    uint id = uint(gl_InstanceID);
144    int nb = int(clamp(u_params2.x, 1.0, 16.0));
145
146    // Which bone: in proportion to the weights.
147    float total = 0.0;
148    for (int i = 0; i < nb; ++i) total += max(u_bones[i * 3 + 1].z, 0.0);
149    float r = hf(id, 1u) * max(total, 1e-6);
150    int b = nb - 1;
151    float acc = 0.0;
152    for (int i = 0; i < nb; ++i) {
153        acc += max(u_bones[i * 3 + 1].z, 0.0);
154        if (r <= acc) { b = i; break; }
155    }
156    vec4 se = u_bones[b * 3];
157    vec4 bp = u_bones[b * 3 + 1];
158    vec4 bc = u_bones[b * 3 + 2];
159
160    // Where in the capsule. Uniform along the axis, a disc across it, the
161    // disc pulled toward the core by the falloff so the surface is a
162    // gradient rather than a hard edge.
163    float t = hf(id, 2u);
164    vec2 axis = se.zw - se.xy;
165    vec2 c = se.xy + axis * t;
166    float falloff = max(u_params.w, 0.5);
167    float rr = bp.x * pow(hf(id, 3u), 0.5 + 0.12 * (falloff - 2.0));
168    rr *= 1.0 + (hf(id, 7u) - 0.5) * 0.12;
169    float ang = hf(id, 4u) * 6.28318530;
170    vec3 local = vec3(c.x + cos(ang) * rr, c.y, sin(ang) * rr * 0.7);
171
172    // Breathing: the chest swells and the rest follows less.
173    local.xz *= 1.0 + u_params.z * sin(u_params.y + local.y * 2.0);
174
175    // Damage: the share of matter past hp has come loose. It drifts out
176    // from the body's middle and fades, then is reseeded.
177    float hp = clamp(u_params.x, 0.0, 1.0);
178    float loose = step(hp, hf(id, 5u));
179    float age = fract(u_time * 0.12 + hf(id, 6u));
180    vec3 away = normalize(vec3(local.x, local.y + 0.4, local.z) + vec3(1e-4));
181    local += away * loose * age * 1.6;
182    float alpha = u_alpha * mix(1.0, (1.0 - age) * (1.0 - age), loose);
183
184    // Jitter: matter held by springs is never still. The stronger the
185    // binding, the smaller the wander.
186    float jit = u_params2.z / max(u_params2.w, 1.0) * 6.0;
187    float ph = u_time * 2.6 + hf(id, 11u) * 6.28318530;
188    local += (vec3(hf(id, 8u), hf(id, 9u), hf(id, 10u)) - 0.5) * jit * (0.6 + 0.4 * sin(ph));
189
190    // Entity space is y-down like the screen; world is y-up.
191    vec3 world = u_pos_scale.xyz + vec3(local.x, -local.y, local.z) * u_pos_scale.w;
192    vec4 clip = u_view_proj * vec4(world, 1.0);
193    clip.xy += v_pos * u_px * clip.w;
194    clip.y = -clip.y;
195    gl_Position = clip;
196
197    vec3 rgb = mix(bc.rgb, bc.rgb * u_color.rgb, 0.35);
198    float bright = max(rgb.r, max(rgb.g, rgb.b));
199    f_uv = v_uv;
200    f_color = vec4(rgb, alpha * bc.a);
201    f_emission = smoothstep(0.72, 1.0, bright) * 0.9 + 0.04;
202}
203"#;
204
205const FRAG: &str = r#"
206#version 330 core
207in vec2  f_uv;
208in vec4  f_color;
209in float f_emission;
210layout(location = 0) out vec4 o_color;
211layout(location = 1) out vec4 o_emission;
212void main() {
213    float d = length(f_uv - 0.5) * 2.0;
214    float a = (1.0 - smoothstep(0.55, 1.0, d)) * f_color.a;
215    if (a < 0.002) discard;
216    // Premultiplied, added: a million faint discs sum to a solid body.
217    o_color    = vec4(f_color.rgb * a, a);
218    o_emission = vec4(f_color.rgb * f_emission * a, a);
219}
220"#;
221
222#[rustfmt::skip]
223const QUAD: [f32; 24] = [
224    -0.5,  0.5,  0.0, 1.0,
225    -0.5, -0.5,  0.0, 0.0,
226     0.5,  0.5,  1.0, 1.0,
227    -0.5, -0.5,  0.0, 0.0,
228     0.5, -0.5,  1.0, 0.0,
229     0.5,  0.5,  1.0, 1.0,
230];
231
232/// The GL side: one program, one quad, no per-particle data at all.
233pub struct GpuDensityRenderer {
234    program: glow::Program,
235    vao: glow::VertexArray,
236    quad_vbo: glow::Buffer,
237    loc_view_proj: Option<glow::UniformLocation>,
238    loc_pos_scale: Option<glow::UniformLocation>,
239    loc_color: Option<glow::UniformLocation>,
240    loc_params: Option<glow::UniformLocation>,
241    loc_params2: Option<glow::UniformLocation>,
242    loc_bones: Option<glow::UniformLocation>,
243    loc_time: Option<glow::UniformLocation>,
244    loc_px: Option<glow::UniformLocation>,
245    loc_alpha: Option<glow::UniformLocation>,
246    /// Particles drawn last frame, for the stats.
247    pub drawn: u32,
248}
249
250impl GpuDensityRenderer {
251    pub unsafe fn new(gl: &glow::Context) -> Self {
252        let program = compile(gl, VERT, FRAG);
253        let vao = gl.create_vertex_array().expect("density vao");
254        gl.bind_vertex_array(Some(vao));
255        let quad_vbo = gl.create_buffer().expect("density quad");
256        gl.bind_buffer(glow::ARRAY_BUFFER, Some(quad_vbo));
257        gl.buffer_data_u8_slice(glow::ARRAY_BUFFER, bytemuck::cast_slice(&QUAD), glow::STATIC_DRAW);
258        gl.vertex_attrib_pointer_f32(0, 2, glow::FLOAT, false, 16, 0);
259        gl.enable_vertex_attrib_array(0);
260        gl.vertex_attrib_pointer_f32(1, 2, glow::FLOAT, false, 16, 8);
261        gl.enable_vertex_attrib_array(1);
262        gl.bind_vertex_array(None);
263        let loc = |n: &str| gl.get_uniform_location(program, n);
264        Self {
265            loc_view_proj: loc("u_view_proj"),
266            loc_pos_scale: loc("u_pos_scale"),
267            loc_color: loc("u_color"),
268            loc_params: loc("u_params"),
269            loc_params2: loc("u_params2"),
270            loc_bones: loc("u_bones[0]"),
271            loc_time: loc("u_time"),
272            loc_px: loc("u_px"),
273            loc_alpha: loc("u_alpha"),
274            program,
275            vao,
276            quad_vbo,
277            drawn: 0,
278        }
279    }
280
281    /// Draw every entity into whatever framebuffer is bound. The caller has
282    /// set the viewport; this sets and restores the blend mode.
283    pub unsafe fn draw(
284        &mut self,
285        gl: &glow::Context,
286        entities: &[GpuDensityEntityData],
287        budget: u32,
288        view_proj: &Mat4,
289        viewport: (u32, u32),
290        time: f32,
291    ) -> u32 {
292        self.drawn = 0;
293        if entities.is_empty() || budget == 0 {
294            return 0;
295        }
296        let mut draws = 0;
297        gl.use_program(Some(self.program));
298        gl.bind_vertex_array(Some(self.vao));
299        gl.enable(glow::BLEND);
300        gl.blend_func(glow::ONE, glow::ONE_MINUS_SRC_ALPHA);
301        gl.uniform_matrix_4_f32_slice(self.loc_view_proj.as_ref(), false, &view_proj.to_cols_array());
302        gl.uniform_1_f32(self.loc_time.as_ref(), time);
303        let (vw, vh) = (viewport.0.max(1) as f32, viewport.1.max(1) as f32);
304
305        for e in entities {
306            let n = e.particle_count(budget);
307            if n == 0 || e.bone_count() == 0 {
308                continue;
309            }
310            let px = particle_pixels(n);
311            gl.uniform_4_f32_slice(self.loc_pos_scale.as_ref(), &e.position_scale);
312            gl.uniform_4_f32_slice(self.loc_color.as_ref(), &e.color);
313            gl.uniform_4_f32_slice(self.loc_params.as_ref(), &e.params);
314            gl.uniform_4_f32_slice(self.loc_params2.as_ref(), &e.params2);
315            let flat: &[f32] = bytemuck::cast_slice(&e.bones);
316            gl.uniform_4_f32_slice(self.loc_bones.as_ref(), flat);
317            gl.uniform_2_f32(self.loc_px.as_ref(), px * 2.0 / vw, px * 2.0 / vh);
318            gl.uniform_1_f32(self.loc_alpha.as_ref(), particle_alpha(n));
319            gl.draw_arrays_instanced(glow::TRIANGLES, 0, 6, n as i32);
320            self.drawn += n;
321            draws += 1;
322        }
323
324        gl.blend_func(glow::SRC_ALPHA, glow::ONE_MINUS_SRC_ALPHA);
325        gl.bind_vertex_array(None);
326        draws
327    }
328
329    pub unsafe fn destroy(&self, gl: &glow::Context) {
330        gl.delete_program(self.program);
331        gl.delete_vertex_array(self.vao);
332        gl.delete_buffer(self.quad_vbo);
333    }
334}
335
336unsafe fn compile(gl: &glow::Context, vs_src: &str, fs_src: &str) -> glow::Program {
337    let vs = gl.create_shader(glow::VERTEX_SHADER).expect("density vs");
338    gl.shader_source(vs, vs_src);
339    gl.compile_shader(vs);
340    if !gl.get_shader_compile_status(vs) {
341        panic!("density vertex shader:\n{}", gl.get_shader_info_log(vs));
342    }
343    let fs = gl.create_shader(glow::FRAGMENT_SHADER).expect("density fs");
344    gl.shader_source(fs, fs_src);
345    gl.compile_shader(fs);
346    if !gl.get_shader_compile_status(fs) {
347        panic!("density fragment shader:\n{}", gl.get_shader_info_log(fs));
348    }
349    let p = gl.create_program().expect("density program");
350    gl.attach_shader(p, vs);
351    gl.attach_shader(p, fs);
352    gl.link_program(p);
353    if !gl.get_program_link_status(p) {
354        panic!("density link:\n{}", gl.get_program_info_log(p));
355    }
356    gl.detach_shader(p, vs);
357    gl.detach_shader(p, fs);
358    gl.delete_shader(vs);
359    gl.delete_shader(fs);
360    p
361}