Skip to content

Commit 60676bf

Browse files
rgilksclaude
andcommitted
Add event moments: predation flashes and cull/bloom shockwaves
A lightweight cosmetic effects layer draws expanding glowing rings into the HDR scene: hot-gold flashes at predation kill sites (subsampled so a busy food web sparkles rather than strobes), a green seed-burst on bloom and on each click-to-seed, and a red shockwave on cull. Effects are sim-side side-data (capped, aged per tick, never read back so determinism holds) with their own additive pipeline; the ring is interpolated between ticks for a smooth 60fps expansion. Birth/death already read via the health-driven fade. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c946b60 commit 60676bf

5 files changed

Lines changed: 328 additions & 0 deletions

File tree

src/lib.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,17 @@ const FLOATS_PER_ENTITY: usize = 10;
2121
/// Floats packed per food patch in the render buffer: x, y, radius, intensity_fraction.
2222
const FLOATS_PER_PATCH: usize = 4;
2323

24+
/// Floats packed per visual effect in the render buffer:
25+
/// x, y, base_radius, life, life_step, kind.
26+
const FLOATS_PER_EFFECT: usize = 6;
27+
2428
#[wasm_bindgen]
2529
pub struct WebSimulation {
2630
simulation: simulation::Simulation,
2731
seed: u64,
2832
entity_buffer: Vec<f32>, // Reusable buffer for entity data
2933
food_buffer: Vec<f32>, // Reusable buffer for food-patch render data
34+
effect_buffer: Vec<f32>, // Reusable buffer for transient visual effects
3035
}
3136

3237
#[wasm_bindgen]
@@ -103,6 +108,27 @@ impl WebSimulation {
103108
(self.food_buffer.len() / FLOATS_PER_PATCH) as u32
104109
}
105110

111+
/// Update the effect buffer and return a pointer for the WebGPU renderer.
112+
/// Layout per effect: x, y, base_radius, life, life_step, kind (6 floats).
113+
pub fn update_effect_buffer(&mut self) -> *const f32 {
114+
let effects = self.simulation.get_effects();
115+
self.effect_buffer.clear();
116+
for (x, y, radius, life, life_step, kind) in effects {
117+
self.effect_buffer.push(x);
118+
self.effect_buffer.push(y);
119+
self.effect_buffer.push(radius);
120+
self.effect_buffer.push(life);
121+
self.effect_buffer.push(life_step);
122+
self.effect_buffer.push(kind);
123+
}
124+
self.effect_buffer.as_ptr()
125+
}
126+
127+
/// Number of effects in the last buffered frame (from `update_effect_buffer`).
128+
pub fn effect_count(&self) -> u32 {
129+
(self.effect_buffer.len() / FLOATS_PER_EFFECT) as u32
130+
}
131+
106132
pub fn get_stats(&self) -> JsValue {
107133
let config = self.simulation.config();
108134
let stats = stats::SimulationStats::from_world(
@@ -180,6 +206,7 @@ impl WebSimulation {
180206
seed,
181207
entity_buffer: Vec::with_capacity(100000), // 10000 entities * 10 floats
182208
food_buffer: Vec::with_capacity(64), // a handful of patches * 4 floats
209+
effect_buffer: Vec::with_capacity(2400), // up to ~400 effects * 6 floats
183210
})
184211
}
185212
}

src/shader.wgsl

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,3 +230,79 @@ fn food_fs(in: FoodVertexOutput) -> @location(0) vec4<f32> {
230230

231231
return vec4<f32>(rgb, soft);
232232
}
233+
234+
// ---------------------------------------------------------------------------
235+
// Transient effects: expanding glowing rings for predation flashes (kind 0),
236+
// bloom/seed bursts (kind 1), and cull shockwaves (kind 2). Drawn additively
237+
// into the HDR scene AFTER the creatures so they read as flashes on top. The
238+
// ring animation is interpolated between sim ticks via the shared
239+
// interpolation_factor, so it stays smooth even though effects age at the
240+
// (slower) tick rate.
241+
// ---------------------------------------------------------------------------
242+
243+
struct EffectInstance {
244+
// x = world x, y = world y, z = base radius (world), w = life (age/max_age, 0..1)
245+
@location(0) a: vec4<f32>,
246+
// x = life_step (1/max_age), y = kind (0..2)
247+
@location(1) b: vec2<f32>,
248+
}
249+
250+
struct EffectVertexOutput {
251+
@builtin(position) position: vec4<f32>,
252+
@location(0) uv: vec2<f32>,
253+
@location(1) life: f32,
254+
@location(2) kind: f32,
255+
}
256+
257+
@vertex
258+
fn effect_vs(
259+
instance: EffectInstance,
260+
@builtin(vertex_index) vertex_index: u32,
261+
) -> EffectVertexOutput {
262+
var out: EffectVertexOutput;
263+
264+
let quad_pos = QUAD_VERTICES[vertex_index];
265+
let world_pos = instance.a.xy;
266+
let base_radius = instance.a.z;
267+
// Interpolate life between ticks for a smooth 60fps expansion.
268+
let life = clamp(instance.a.w + uniforms.interpolation_factor * instance.b.x, 0.0, 1.0);
269+
let world_size = uniforms.world_size;
270+
271+
let world_to_screen_x = (world_pos.x + world_size / 2.0) / world_size * 2.0 - 1.0;
272+
let world_to_screen_y = -((world_pos.y + world_size / 2.0) / world_size * 2.0 - 1.0);
273+
let screen_x = (world_to_screen_x + uniforms.camera_x) * uniforms.camera_zoom;
274+
let screen_y = (world_to_screen_y + uniforms.camera_y) * uniforms.camera_zoom;
275+
let screen_pos = vec2<f32>(screen_x, screen_y);
276+
277+
let screen_radius = base_radius / world_size * 2.0 * uniforms.camera_zoom;
278+
279+
out.position = vec4<f32>(screen_pos + quad_pos * screen_radius, 0.0, 1.0);
280+
out.uv = quad_pos;
281+
out.life = life;
282+
out.kind = instance.b.y;
283+
return out;
284+
}
285+
286+
@fragment
287+
fn effect_fs(in: EffectVertexOutput) -> @location(0) vec4<f32> {
288+
let d = length(in.uv);
289+
let life = in.life;
290+
// A ring expanding from centre to rim over its life, widening as it goes.
291+
let r = life;
292+
let thickness = 0.10 + 0.12 * life;
293+
let ring = exp(-pow((d - r) / thickness, 2.0));
294+
// Dissolve as it reaches the rim.
295+
let fade = pow(1.0 - life, 1.5);
296+
let intensity = ring * fade;
297+
298+
// Colour by kind: hot gold flash, green seed-burst, red cull ripple.
299+
var col = vec3<f32>(1.0, 0.85, 0.5);
300+
if (in.kind > 1.5) {
301+
col = vec3<f32>(1.0, 0.4, 0.45);
302+
} else if (in.kind > 0.5) {
303+
col = vec3<f32>(0.5, 1.0, 0.6);
304+
}
305+
306+
let rgb = col * intensity * 1.7;
307+
return vec4<f32>(rgb, intensity);
308+
}

src/simulation/mod.rs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,26 @@ use rng::{
2323
generate_particle_matrix, mix_seed, BLOOM_SALT, CULL_SALT, DEFAULT_SEED, OFFSPRING_SALT,
2424
};
2525

26+
/// Max simultaneous visual effects. Effects are short-lived, so the live set
27+
/// stays well under this; new ones past the cap are dropped (purely cosmetic).
28+
const EFFECT_CAP: usize = 400;
29+
30+
/// A transient visual flourish (predation flash, bloom burst, cull shockwave) —
31+
/// an expanding glowing ring the renderer draws. Pure cosmetic side-data derived
32+
/// from events that already happen deterministically; never read back into the
33+
/// simulation, so it can't affect determinism.
34+
#[derive(Clone)]
35+
struct Effect {
36+
x: f32,
37+
y: f32,
38+
/// Final ring radius in world units.
39+
base_radius: f32,
40+
age: u32,
41+
max_age: u32,
42+
/// 0 = predation flash, 1 = bloom/seed burst, 2 = cull shockwave.
43+
kind: f32,
44+
}
45+
2646
/// Stable numeric id for a movement style, packed into the render buffer so the
2747
/// shader can vary a creature's look by behaviour (e.g. mark predators).
2848
fn movement_style_id(style: &MovementType) -> f32 {
@@ -70,6 +90,9 @@ pub struct Simulation {
7090
/// the engine of the boom/bust waves. Updated once per tick in the serial
7191
/// phase; read immutably by the parallel compute.
7292
crowding_pressure: f32,
93+
/// Transient visual effects (flashes/rings) drawn by the renderer. Cosmetic
94+
/// side-data only; aged each tick and never read back into the sim.
95+
effects: Vec<Effect>,
7396

7497
// System instances
7598
movement_system: MovementSystem,
@@ -127,6 +150,7 @@ impl Simulation {
127150
particle_matrix: generate_particle_matrix(seed),
128151
food_field,
129152
crowding_pressure,
153+
effects: Vec::new(),
130154
movement_system: MovementSystem,
131155
interaction_system: InteractionSystem,
132156
energy_system: EnergySystem,
@@ -218,6 +242,8 @@ impl Simulation {
218242
for entity in doomed {
219243
let _ = self.world.despawn(entity);
220244
}
245+
// A cool shockwave ripple radiating from the centre.
246+
self.add_effect(0.0, 0.0, self.world_size * 0.6, 45, 2.0);
221247
}
222248

223249
/// Instantly spawn a burst of `count` fresh random creatures near the centre
@@ -250,6 +276,8 @@ impl Simulation {
250276
self.config.physics.min_entity_radius,
251277
));
252278
}
279+
// A bright burst of life at the centre.
280+
self.add_effect(0.0, 0.0, self.world_size * 0.14, 30, 1.0);
253281
}
254282

255283
/// Spawn a tight burst of `count` fresh creatures around world `(x, y)` — the
@@ -285,6 +313,32 @@ impl Simulation {
285313
self.config.physics.min_entity_radius,
286314
));
287315
}
316+
// A seed-of-life burst at the cursor, even if the cap left no room.
317+
self.add_effect(x, y, self.world_size * 0.08, 26, 1.0);
318+
}
319+
320+
/// Queue a transient visual effect (cosmetic ring/flash). Dropped silently
321+
/// once the active set hits `EFFECT_CAP`.
322+
fn add_effect(&mut self, x: f32, y: f32, base_radius: f32, max_age: u32, kind: f32) {
323+
if self.effects.len() >= EFFECT_CAP {
324+
return;
325+
}
326+
self.effects.push(Effect {
327+
x,
328+
y,
329+
base_radius,
330+
age: 0,
331+
max_age,
332+
kind,
333+
});
334+
}
335+
336+
/// Advance every effect by one tick and drop the expired ones.
337+
fn age_effects(&mut self) {
338+
for e in &mut self.effects {
339+
e.age += 1;
340+
}
341+
self.effects.retain(|e| e.age < e.max_age);
288342
}
289343

290344
pub fn update(&mut self) {
@@ -330,6 +384,7 @@ impl Simulation {
330384
self.rebuild_spatial_grid();
331385
let updates = self.process_entities_parallel();
332386
self.apply_entity_updates(updates);
387+
self.age_effects();
333388
}
334389

335390
/// The initial population density (used to seed the crowding pressure).
@@ -596,6 +651,18 @@ impl Simulation {
596651
for bundle in offspring {
597652
self.world.spawn(bundle);
598653
}
654+
655+
// Record a few predation flashes at the kill sites — subsampled so a
656+
// churning food web sparkles rather than strobes. Purely cosmetic.
657+
let mut pred_seen = 0u32;
658+
for update in &updates {
659+
if update.eaten_entity.is_some() && update.energy.current > 0.0 {
660+
if pred_seen.is_multiple_of(3) {
661+
self.add_effect(update.pos.x, update.pos.y, self.world_size * 0.022, 12, 0.0);
662+
}
663+
pred_seen += 1;
664+
}
665+
}
599666
}
600667

601668
/// Per-entity render data:
@@ -650,6 +717,24 @@ impl Simulation {
650717
.collect()
651718
}
652719

720+
/// Render data for the transient visual effects:
721+
/// `(x, y, base_radius, life, life_step, kind)` per effect, where `life` is
722+
/// `age/max_age` (0..1) and `life_step` is `1/max_age` so the renderer can
723+
/// interpolate the ring animation smoothly between ticks.
724+
pub fn get_effects(&self) -> Vec<(f32, f32, f32, f32, f32, f32)> {
725+
self.effects
726+
.iter()
727+
.map(|e| {
728+
let (life, step) = if e.max_age > 0 {
729+
(e.age as f32 / e.max_age as f32, 1.0 / e.max_age as f32)
730+
} else {
731+
(1.0, 0.0)
732+
};
733+
(e.x, e.y, e.base_radius, life, step, e.kind)
734+
})
735+
.collect()
736+
}
737+
653738
/// Centroid and a robust focus radius of the live population, in world units,
654739
/// so the UI can frame the swarm. The radius is ~2.4× the RMS distance from
655740
/// the centroid (a handful of stragglers can't blow it up), clamped to a sane

0 commit comments

Comments
 (0)