struct Particle {
pos: vec3<f32>,
scale: f32,
color: u32,
vel: vec3<f32>,
life: f32,
max_life: f32,
generation: u32,
}
var<storage,read_write> particles: array<Particle>;
struct FreeList {
count: atomic<i32>,
data: array<u32>,
}
var<storage,read_write> free_list: FreeList;
@compute @workgroup_size(64, 1, 1)
fn reset(
@builtin(global_invocation_id) global_id: vec3<u32>,
@builtin(num_workgroups) num_groups: vec3<u32>,
) {
let total = num_groups.x * 64u;
// reversing the order because it works like a stack
free_list.data[global_id.x] = total - 1u - global_id.x;
var p: Particle;
particles[global_id.x] = p;
if (global_id.x == 0u) {
atomicStore(&free_list.count, i32(total));
}
}
struct EmitParams {
origin: vec3<f32>,
emitter_radius: f32,
direction: vec3<f32>,
cone_half_angle_cos: f32,
colors: vec4<u32>,
color_count: u32,
emit_count: u32,
life_min: f32,
life_max: f32,
speed_min: f32,
speed_max: f32,
scale_min: f32,
scale_max: f32,
}
var<uniform> emit_params: EmitParams;
struct UpdateParams {
time_delta: f32,
}
var<uniform> update_params: UpdateParams;
fn hash_u32(x: u32) -> u32 {
var h = x;
h = h ^ (h >> 16u);
h = h * 0x45d9f3bu;
h = h ^ (h >> 16u);
h = h * 0x45d9f3bu;
h = h ^ (h >> 16u);
return h;
}
fn rand01(seed: u32) -> f32 {
return f32(hash_u32(seed) & 0xFFFFu) / 65535.0;
}
/// Rotate a vector from +Z axis to the given axis direction.
fn rotate_to(to: vec3<f32>, v: vec3<f32>) -> vec3<f32> {
// d = dot(+Z, to) = to.z
let d = to.z;
if (d > 0.9999) {
return v;
}
if (d < -0.9999) {
return vec3<f32>(v.x, -v.y, -v.z);
}
// cross(+Z, to) = (-to.y, to.x, 0)
let a = normalize(vec3<f32>(-to.y, to.x, 0.0));
let s = sqrt(1.0 - d * d);
// Rodrigues rotation
return v * d + cross(a, v) * s + a * dot(a, v) * (1.0 - d);
}
var<workgroup> emit_end: i32;
@compute @workgroup_size(64, 1, 1)
fn emit(@builtin(local_invocation_index) local_index: u32) {
let count = i32(emit_params.emit_count);
if (local_index == 0u) {
emit_end = atomicSub(&free_list.count, count);
if (emit_end < count) {
atomicAdd(&free_list.count, count - max(0, emit_end));
}
}
workgroupBarrier();
let my_index = i32(local_index);
let list_index = emit_end - 1 - my_index;
if (my_index >= count || list_index < 0) {
return;
}
let p_index = free_list.data[list_index];
var p: Particle;
p.generation += 1u;
let seed = p_index * 1337u + p.generation * 7919u;
let r0 = rand01(seed);
let r1 = rand01(seed + 1u);
let r2 = rand01(seed + 2u);
let r3 = rand01(seed + 3u);
let r4 = rand01(seed + 4u);
let r5 = rand01(seed + 5u);
p.life = mix(emit_params.life_min, emit_params.life_max, r0);
p.max_life = p.life;
p.scale = mix(emit_params.scale_min, emit_params.scale_max, r1);
let speed = mix(emit_params.speed_min, emit_params.speed_max, r2);
// Random direction in a cone around emit_params.direction.
// cos_phi is uniformly distributed in [cone_half_angle_cos, 1].
let theta = r3 * 6.283185;
let cos_phi = mix(1.0, emit_params.cone_half_angle_cos, r4);
let sin_phi = sqrt(1.0 - cos_phi * cos_phi);
// Local direction with cone axis = +Z
let local_dir = vec3<f32>(sin_phi * cos(theta), sin_phi * sin(theta), cos_phi);
// Rotate from +Z to emit_params.direction
let dir = rotate_to(emit_params.direction, local_dir);
p.vel = speed * dir;
// Position: origin + shape offset
p.pos = emit_params.origin + dir * emit_params.emitter_radius;
// Pick color from palette
let ci = u32(r5 * f32(emit_params.color_count)) % emit_params.color_count;
p.color = emit_params.colors[ci];
particles[p_index] = p;
}
@compute @workgroup_size(64, 1, 1)
fn update(@builtin(global_invocation_id) global_id: vec3<u32>) {
let p = &particles[global_id.x];
if ((*p).scale != 0.0) {
(*p).pos += (*p).vel * update_params.time_delta;
(*p).life -= update_params.time_delta;
if ((*p).life < 0.0) {
let list_index = atomicAdd(&free_list.count, 1);
free_list.data[list_index] = global_id.x;
(*p).scale = 0.0;
}
}
}
// Draw pass
var<storage,read> draw_particles: array<Particle>;
struct CameraParams {
view_proj: mat4x4<f32>,
camera_right: vec4<f32>,
camera_up: vec4<f32>,
}
var<uniform> camera: CameraParams;
struct VertexOutput {
@builtin(position) proj_pos: vec4<f32>,
@location(0) color: vec4<f32>,
@location(1) uv: vec2<f32>,
}
@vertex
fn draw_vs(
@builtin(vertex_index) vertex_index: u32,
@builtin(instance_index) instance_index: u32,
) -> VertexOutput {
let particle = draw_particles[instance_index];
var out: VertexOutput;
if (particle.scale == 0.0) {
out.proj_pos = vec4<f32>(0.0, 0.0, -1.0, 1.0);
out.color = vec4<f32>(0.0);
out.uv = vec2<f32>(0.0);
return out;
}
// Billboard: offset particle position in world space along camera axes
let zero_one = vec2<f32>(vec2<u32>(vertex_index & 1u, vertex_index >> 1u));
let offset = 2.0 * zero_one - vec2<f32>(1.0);
let world_pos = particle.pos
+ camera.camera_right.xyz * (offset.x * particle.scale)
+ camera.camera_up.xyz * (offset.y * particle.scale);
// Project to clip space
out.proj_pos = camera.view_proj * vec4<f32>(world_pos, 1.0);
// Unpack base color and apply lifetime fade
let base_color = unpack4x8unorm(particle.color);
let age = 1.0 - particle.life / particle.max_life;
// Fade out alpha over lifetime
let alpha = base_color.a * (1.0 - age * age);
out.color = vec4<f32>(base_color.rgb, alpha);
out.uv = 2.0 * zero_one - vec2<f32>(1.0);
return out;
}
@fragment
fn draw_fs(in: VertexOutput) -> @location(0) vec4<f32> {
// Soft circular particle: smooth falloff from center
let dist_sq = dot(in.uv, in.uv);
if (dist_sq > 1.0) {
discard;
}
let softness = 1.0 - dist_sq;
return vec4<f32>(in.color.rgb, in.color.a * softness);
}