Files
effectcraft/crates/gpu/src/fx_particles.rs
T
Brandon ThomasandClaude Opus 5.5 1c855c3e07 M13.29: GPU effects part D (particles and remaining fallbacks)
GPU render passes for CC Particle World / Systems II, Particle Playground,
CC Ball Action, CC Pixel Polly, CC Scatterize and Curl Noise (fx_particles,
chunked multi-pass raster); Shatter wireframe views, Foam extras, Timewarp
with a Matte Layer, strong Warp Fisheye/Twist (CPU f64 inverse map) and
custom .ocio configs no longer fall back to the CPU. CPU refactors pinned by
particle_golden / timewarp_golden / sim_golden hashes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:35:56 -04:00

285 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! GPU effects, particle render passes (kernels in `shaders/fx_particles.wgsl`, every entry
//! point prefixed `fxp_`): CC Particle World, CC Particle Systems II, Particle Playground, CC
//! Ball Action, CC Pixel Polly, CC Scatterize and Curl Noise; plus Foam's textured discs,
//! reflections and flow-map preview (`fx_sim` draws Foam's bubble sprites).
//!
//! As in `fx_sim`, each frame's plan is built on the CPU and shared with the CPU effect, which
//! stays the oracle: the particle systems' sprites (`effects::sprite_plan`, from the stepped
//! simulation — on the GPU's `ParticleSim` when the host offers it — sorted as the CPU sorts
//! them), Particle Playground's dots, text strokes and Layer Map frames
//! (`effects::playground_plan`), and the plans of the effects that read the layer's pixels to
//! place or colour their particles (`effects::pixel_plan`: CC Ball Action's balls, CC
//! Scatterize's pixel dots, CC Pixel Polly's shards; the frame is read back once). Rasterising
//! runs here: items are tiled like `fx_sim`'s and `fxp_raster` composites each pixel's items in
//! the CPU's order over a base image, so a plan too large for one item table is drawn in
//! several passes, each over the previous one ([`Chunked`]; the per-pixel arithmetic is the
//! same). Particle Playground's Layer Map frames are packed into one atlas texture.
//!
//! Curl Noise runs fully here: the fBm potential, its curl (central differences, edges
//! clamped) and the backwards streamline trace, each a kernel mirroring the CPU's passes.
use effectcraft_effects::{Buf, EffectCtx, PixelPlan};
use effectcraft_raster::Image;
use crate::context::{Enc, GpuImage, Params};
use crate::effects::GBuf;
use crate::fx_sim::{Items, piece_pass, sprite_layer, sprites};
/// Compute entry points in `fx_particles.wgsl`.
pub(crate) const KERNELS: &[&str] = &["fxp_raster", "fxp_under", "fxp_curl_pot", "fxp_curl_vel", "fxp_curl_view", "fxp_curl_flow"];
/// Effect ids implemented here.
pub(crate) const IDS: &[&str] = &[
"ec.sim.ccparticleworld",
"ec.sim.ccparticlesystems2",
"ec.sim.particleplayground",
"ec.sim.ccballaction",
"ec.sim.ccpixelpolly",
"ec.sim.ccscatterize",
"ec.noise.curlnoise",
];
/// Run effect `id` (one of [`IDS`]); `None` = this parameter combination runs on the CPU.
pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let geo = Buf { img: Image::new(0, 0), offset: b.offset, scale: b.scale };
match id {
"ec.sim.ccparticleworld" | "ec.sim.ccparticlesystems2" => match effectcraft_effects::sprite_plan(id, ctx, &geo) {
Some(plan) => sprites(e, b, &plan),
None => Some(b),
},
"ec.sim.particleplayground" => playground(e, ctx, b, &geo),
"ec.noise.curlnoise" => curl_noise(e, ctx, b),
_ => {
// The plan reads the layer's pixels.
let img = e.download(&b.img)?;
let cpu = Buf { img, offset: b.offset, scale: b.scale };
match effectcraft_effects::pixel_plan(id, ctx, &cpu) {
Some(PixelPlan::Sprites(plan)) => sprites(e, b, &plan),
Some(PixelPlan::Pieces(plan)) => piece_pass(e, b, &plan),
None => Some(b),
}
}
}
}
// ---------------------------------------------------------------- chunked raster
/// Items rasterised by `fxp_raster` over a base image (transparent at first), flushed into a
/// new pass whenever the item table would outgrow one kernel's data. `kind`: 0 sprites, 1
/// Layer Map frames, 2 Foam texture discs, 3 Foam reflection discs, 4 pieces without a back
/// texture; `mode`: 0 over, 1 add, 2 Foam's disc compositing.
pub(crate) struct Chunked {
kind: u32,
mode: u32,
stride: usize,
w: u32,
h: u32,
items: Items,
base: Option<GpuImage>,
ok: bool,
/// Kernel parameters besides `u[0]` and `u[1].x`.
pub p: Params,
}
impl Chunked {
/// `stride` = floats per item, its four bounds included.
pub(crate) fn new(kind: u32, mode: u32, stride: usize, w: u32, h: u32) -> Chunked {
Chunked { kind, mode, stride, w, h, items: Items::new(stride, w, h), base: None, ok: true, p: Params::default() }
}
/// Start from `base` instead of a transparent frame.
pub(crate) fn over(mut self, base: GpuImage) -> Chunked {
self.base = Some(base);
self
}
/// Add an item (`rec` without its bounds) covering float bounds `bb`; `src` is the texture
/// the items sample.
pub(crate) fn push(&mut self, e: &mut Enc, src: &GpuImage, bb: Option<[f32; 4]>, rec: &[f32]) {
if !self.items.fits(bb) && !self.items.is_empty() {
self.flush(e, src);
}
self.items.push(bb, rec);
}
fn flush(&mut self, e: &mut Enc, src: &GpuImage) {
let items = std::mem::replace(&mut self.items, Items::new(self.stride, self.w, self.h));
let tx = items.tx as u32;
let Some((data, items_at, stride)) = items.data() else {
self.ok = false;
return;
};
let buf = e.data(&data);
let base = self.base.take().unwrap_or_else(|| e.zeros(self.w, self.h));
let mut p = self.p;
p.u[0] = [self.kind, self.mode, items_at, stride];
p.u[1][0] = tx;
let out = e.scratch(self.w, self.h);
e.pixels("fxp_raster", &p, src, Some(&base), &out, Some(&buf));
self.base = Some(out);
}
/// The result (`None` when an item alone outgrew a kernel's data).
pub(crate) fn finish(mut self, e: &mut Enc, src: &GpuImage) -> Option<GpuImage> {
if !self.items.is_empty() {
self.flush(e, src);
}
if !self.ok {
return None;
}
Some(match self.base {
Some(b) => b,
None => e.image(self.w, self.h),
})
}
}
/// Bounds of a disc drawn over `[floor(x − r − 1), ceil(x + r + 1))` (sim2::foam_disc), as
/// inclusive float bounds for [`Items`].
fn disc_bounds(x: f64, y: f64, r: f64) -> Option<[f32; 4]> {
(r > 0.0).then(|| [(x - r - 1.0) as f32, (y - r - 1.0) as f32, ((x + r + 1.0).ceil() - 1.0) as f32, ((y + r + 1.0).ceil() - 1.0) as f32])
}
// ---------------------------------------------------------------- Foam's extras
/// Foam: the bubble sprites, then the User Defined texture's discs, the Environment Map's
/// reflections and the flow-map preview (sim2::FoamPlan::finish).
pub(crate) fn foam(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let (w, h) = (b.img.width, b.img.height);
// The plan fits other layers to the buffer: it needs the buffer's size (not its pixels).
let geo = Buf { img: Image::new(w, h), offset: b.offset, scale: b.scale };
let plan = effectcraft_effects::foam_full_plan(ctx, &geo);
let mut fx = sprite_layer(e, &b.img, &plan.sprites)?;
if let Some((tex, discs)) = &plan.user {
let t = e.g.upload_image(&tex.buf.img)?;
let mut ch = Chunked::new(2, 2, 16, w, h).over(fx);
for d in discs {
let (s, c) = d.rot.sin_cos();
let rec = [2.0, d.x as f32, d.y as f32, d.r, s, c, d.fade, tex.size[0] as f32, tex.size[1] as f32, tex.buf.scale as f32];
let rec = [&rec[..], &[tex.buf.offset[0] as f32, tex.buf.offset[1] as f32]].concat();
ch.push(e, &t, disc_bounds(d.x, d.y, d.r as f64), &rec);
}
fx = ch.finish(e, &t)?;
}
if let Some(env) = &plan.env {
let t = e.g.upload_image(&env.img)?;
let mut ch = Chunked::new(3, 2, 12, w, h).over(fx);
for d in &env.discs {
let rec = [3.0, d.x as f32, d.y as f32, d.r, env.conv as f32, env.strength, w as f32, h as f32];
ch.push(e, &t, disc_bounds(d.x, d.y, d.r as f64), &rec);
}
fx = ch.finish(e, &t)?;
}
if let Some(fm) = &plan.flow {
let f = e.g.upload_image(fm)?;
let out = e.scratch(w, h);
e.pixels("fxp_under", &Params::default(), &fx, Some(&f), &out, None);
fx = out;
}
Some(GBuf { img: fx, ..b })
}
// ---------------------------------------------------------------- Particle Playground
fn playground(e: &mut Enc, ctx: &EffectCtx, b: GBuf, geo: &Buf) -> Option<GBuf> {
let plan = effectcraft_effects::playground_plan(ctx, geo);
let (w, h) = (b.img.width, b.img.height);
let mut fx = sprite_layer(e, &b.img, &plan.sprites)?;
if !plan.blits.is_empty() {
// The Layer Map frames, packed into one atlas on a grid of equal cells.
let cw = plan.frames.iter().map(|f| f.buf.img.width).max().unwrap_or(1).max(1);
let chh = plan.frames.iter().map(|f| f.buf.img.height).max().unwrap_or(1).max(1);
let max = e.g.max_dim;
let cols = (max / cw).max(1).min(plan.frames.len() as u32);
let rows = (plan.frames.len() as u32).div_ceil(cols);
if !e.g.fits(cw * cols, chh * rows) {
return None;
}
let mut atlas = Image::new(cw * cols, chh * rows);
let mut at = vec![];
for (i, f) in plan.frames.iter().enumerate() {
let (ax, ay) = ((i as u32 % cols) * cw, (i as u32 / cols) * chh);
let img = &f.buf.img;
for y in 0..img.height {
let row = &img.data[(y * img.width) as usize..((y + 1) * img.width) as usize];
let o = ((ay + y) * atlas.width + ax) as usize;
atlas.data[o..o + row.len()].copy_from_slice(row);
}
at.push((ax, ay));
}
let t = e.g.upload_image(&atlas)?;
let mut ch = Chunked::new(1, 0, 24, w, h).over(fx);
for q in &plan.blits {
let lp = &plan.frames[q.frame];
if q.scale[0] == 0.0 || q.scale[1] == 0.0 {
continue;
}
// sim3::blit_layer's pixel range.
let (fw, fh) = (lp.size[0] * q.scale[0].abs() * b.scale, lp.size[1] * q.scale[1].abs() * b.scale);
let r = (fw * fw + fh * fh).sqrt() * 0.5 + 1.0;
let bb = [(q.x - r) as f32, (q.y - r) as f32, ((q.x + r).ceil() - 1.0) as f32, ((q.y + r).ceil() - 1.0) as f32];
let (sa, ca) = q.angle.to_radians().sin_cos();
let (ax, ay) = at[q.frame];
let rec = [
1.0,
q.x as f32,
q.y as f32,
sa as f32,
ca as f32,
q.scale[0] as f32,
q.scale[1] as f32,
b.scale as f32,
q.alpha,
lp.size[0] as f32,
lp.size[1] as f32,
lp.buf.scale as f32,
lp.buf.offset[0] as f32,
lp.buf.offset[1] as f32,
ax as f32,
ay as f32,
lp.buf.img.width as f32,
lp.buf.img.height as f32,
];
ch.push(e, &t, Some(bb), &rec);
}
fx = ch.finish(e, &t)?;
}
// The particles replace the layer's own pixels.
Some(GBuf { img: fx, ..b })
}
// ---------------------------------------------------------------- Curl Noise
/// noise2::curl_noise: the potential, its curl and the streamline trace.
fn curl_noise(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let pr = ctx.params;
let amount = pr.f("displacementAmount") * b.scale;
let view = pr.e("view");
if amount.abs() < 1e-6 && view == 0 {
return Some(b);
}
let scale = (pr.f("scale").max(1.0) * b.scale) as f32;
let rot = pr.f("rotation").to_radians() as f32;
let off = pr.v2("offset");
let (ox, oy) = ((off[0] * b.scale + b.offset[0]) as f32, (off[1] * b.scale + b.offset[1]) as f32);
let evo = (pr.f("evolution") / 360.0) as f32;
let seed = pr.f("randomSeed") as i64 as u32 ^ 0x51_7cc1;
let steps = pr.f("steps").clamp(1.0, 16.0) as usize;
let (w, h) = (b.img.width, b.img.height);
let mut p = Params::default();
p.u[0] = [seed, steps as u32, (pr.e("edgeBehavior") == 1) as u32, 0];
p.f[0] = [ox, oy, scale, pr.f("complexity") as f32];
p.f[1] = [rot.cos(), rot.sin(), evo * 4.0, amount as f32 / steps as f32];
let pot = e.scratch(w, h);
e.pixels("fxp_curl_pot", &p, &b.img, None, &pot, None);
let vel = e.scratch(w, h);
e.pixels("fxp_curl_vel", &p, &pot, None, &vel, None);
let out = e.scratch(w, h);
if view == 1 {
e.pixels("fxp_curl_view", &p, &vel, None, &out, None);
} else {
e.pixels("fxp_curl_flow", &p, &b.img, Some(&vel), &out, None);
}
Some(GBuf { img: out, ..b })
}