Skip to main content

pfx_live/
effects.rs

1use bytemuck::{Pod, Zeroable};
2use pfx_gpu::wgpu;
3use pfx_physics::{Boid, Emitter, ParticleKind, Speck};
4use wgpu::util::DeviceExt;
5
6pub const HDR_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
7pub const SCENE_DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::R32Float;
8
9#[repr(C)]
10#[derive(Clone, Copy, Debug, PartialEq, Pod, Zeroable)]
11pub struct Plume {
12    pub source: [f32; 3],
13    pub radius: f32,
14    pub spread: [f32; 2],
15    pub drift: [[f32; 2]; 2],
16    pub sway: [[f32; 5]; 2],
17    pub fade: [f32; 3],
18    pub depth_per_width: f32,
19    pub grain: [f32; 2],
20    pub lift: f32,
21    pub warp: [f32; 2],
22    pub churn: f32,
23    pub threshold: [f32; 2],
24}
25
26impl Default for Plume {
27    fn default() -> Self {
28        Self {
29            source: [0.0; 3],
30            radius: 0.02,
31            spread: [1.0, 2.0],
32            drift: [[0.0; 2]; 2],
33            sway: [[10.0, 1.0, 0.005, 0.5, 2.0]; 2],
34            fade: [0.01, 0.05, 0.15],
35            depth_per_width: 6.0,
36            grain: [60.0, 40.0],
37            lift: 0.05,
38            warp: [2.0, 0.2],
39            churn: 0.6,
40            threshold: [0.4, 0.85],
41        }
42    }
43}
44
45impl Plume {
46    pub fn at(source: [f32; 3]) -> Self {
47        Self {
48            source,
49            ..Self::default()
50        }
51    }
52
53    fn look(&self) -> [f32; 28] {
54        let [sx, sz] = self.sway;
55        [
56            self.spread[0],
57            self.spread[1],
58            self.drift[0][0],
59            self.drift[0][1],
60            self.drift[1][0],
61            self.drift[1][1],
62            sx[0],
63            sx[1],
64            sx[2],
65            sx[3],
66            sx[4],
67            sz[0],
68            sz[1],
69            sz[2],
70            sz[3],
71            sz[4],
72            self.fade[0],
73            self.fade[1],
74            self.fade[2],
75            self.depth_per_width,
76            self.grain[0],
77            self.grain[1],
78            self.lift,
79            self.warp[0],
80            self.warp[1],
81            self.churn,
82            self.threshold[0],
83            self.threshold[1],
84        ]
85    }
86
87    fn key(&self) -> [u32; 28] {
88        self.look().map(f32::to_bits)
89    }
90
91    fn constants(&self) -> Vec<(&'static str, f64)> {
92        PLUME_CONSTANTS
93            .iter()
94            .zip(self.look())
95            .map(|(name, value)| (*name, f64::from(value)))
96            .collect()
97    }
98}
99
100const MAX_VOLUME_PIPELINES: usize = 16;
101
102fn volume_constants(cuts: VolumeCuts, plume: &Plume) -> Vec<(&'static str, f64)> {
103    let mut constants = cuts.constants().to_vec();
104    constants.extend(plume.constants());
105    constants
106}
107
108const PLUME_CONSTANTS: [&str; 28] = [
109    "SPREAD_BASE",
110    "SPREAD_GROWTH",
111    "DRIFT_X_QUAD",
112    "DRIFT_X_LIN",
113    "DRIFT_Z_QUAD",
114    "DRIFT_Z_LIN",
115    "SWAY_X_WAVE",
116    "SWAY_X_RATE",
117    "SWAY_X_AMP",
118    "SWAY_X_BASE",
119    "SWAY_X_GROWTH",
120    "SWAY_Z_WAVE",
121    "SWAY_Z_RATE",
122    "SWAY_Z_AMP",
123    "SWAY_Z_BASE",
124    "SWAY_Z_GROWTH",
125    "FADE_IN",
126    "FADE_OUT_START",
127    "FADE_OUT_END",
128    "DEPTH_PER_WIDTH",
129    "GRAIN_H",
130    "GRAIN_V",
131    "LIFT_RATE",
132    "WARP_AMP",
133    "WARP_RATE",
134    "CHURN",
135    "THRESHOLD_LOW",
136    "THRESHOLD_HIGH",
137];
138
139#[derive(Clone, Copy, Debug, PartialEq)]
140pub enum VolumeKind {
141    Plume(Plume),
142    Smoke,
143}
144
145pub const HAZE_AMBIENT_GREY: f32 = 0.03;
146
147#[derive(Clone, Copy, Debug, PartialEq)]
148pub struct RoomHaze {
149    pub fog: f32,
150    pub smoke: f32,
151    pub mist: f32,
152    pub floor: f32,
153    pub phase: f32,
154    pub back: f32,
155    pub gold: [f32; 3],
156    pub ambient: [f32; 3],
157    pub reach: f32,
158    pub unmapped: f32,
159    pub lo: [f32; 3],
160    pub hi: [f32; 3],
161    pub seed: u32,
162}
163
164impl RoomHaze {
165    pub fn new(lo: [f32; 3], hi: [f32; 3]) -> Self {
166        Self {
167            fog: 0.0,
168            smoke: 0.0,
169            mist: 0.0,
170            floor: 0.0,
171            phase: 0.3,
172            back: 0.5,
173            gold: [1.0; 3],
174            ambient: [HAZE_AMBIENT_GREY; 3],
175            reach: 3.0,
176            unmapped: 0.0,
177            lo,
178            hi,
179            seed: 0,
180        }
181    }
182
183    pub fn from_amount(lo: [f32; 3], hi: [f32; 3], amount: f32) -> Self {
184        let amount = if amount.is_nan() {
185            0.0
186        } else {
187            amount.clamp(0.0, 1.0)
188        };
189        Self {
190            fog: amount / 6.0,
191            smoke: amount * 20.0,
192            mist: amount * 0.2,
193            floor: amount * 2.0,
194            ..Self::new(lo, hi)
195        }
196    }
197}
198
199#[derive(Clone, Copy, Debug, PartialEq)]
200pub struct RoomMotes {
201    pub lo: [f32; 3],
202    pub hi: [f32; 3],
203    pub cell: f32,
204    pub share: f32,
205    pub brightness: f32,
206    pub gold: [f32; 3],
207}
208
209impl RoomMotes {
210    pub fn new(
211        lo: [f32; 3],
212        hi: [f32; 3],
213        cell: f32,
214        share: f32,
215        brightness: f32,
216        gold: [f32; 3],
217    ) -> Self {
218        Self {
219            lo,
220            hi,
221            cell,
222            share,
223            brightness,
224            gold,
225        }
226    }
227}
228
229#[repr(C)]
230#[derive(Clone, Copy, Debug, Pod, Zeroable)]
231pub struct EffectFrame {
232    pub view_proj: [[f32; 4]; 4],
233    pub eye: [f32; 4],
234    pub right: [f32; 4],
235    pub up: [f32; 4],
236    pub forward: [f32; 4],
237    pub lens: [f32; 4],
238    pub sun: [f32; 4],
239    pub sun_color: [f32; 4],
240    pub extent: [f32; 4],
241    pub volume_lo: [f32; 4],
242    pub volume_hi: [f32; 4],
243    pub volume_color: [f32; 4],
244    pub volume_control: [f32; 4],
245    pub room_light: [f32; 4],
246    pub room_shape: [f32; 4],
247    pub room_lo: [f32; 4],
248    pub room_hi: [f32; 4],
249    pub plume: Plume,
250}
251
252impl EffectFrame {
253    pub fn new(width: u32, height: u32, time: f32, seed: u32) -> Self {
254        Self {
255            view_proj: [
256                [1.0, 0.0, 0.0, 0.0],
257                [0.0, 1.0, 0.0, 0.0],
258                [0.0, 0.0, 1.0, 0.0],
259                [0.0, 0.0, 0.0, 1.0],
260            ],
261            eye: [0.0, 0.0, 3.0, 0.0],
262            right: [1.0, 0.0, 0.0, 0.0],
263            up: [0.0, 1.0, 0.0, 0.0],
264            forward: [0.0, 0.0, -1.0, 0.0],
265            lens: [1.0, 1.0, 0.0, 0.0],
266            sun: [0.4, 0.8, 0.4, 0.0],
267            sun_color: [1.0, 0.9, 0.7, 1.0],
268            extent: [width as f32, height as f32, time, f32::from_bits(seed)],
269            volume_lo: [-0.25, -0.4, -0.5, 0.0],
270            volume_hi: [0.25, 0.7, 0.5, 0.0],
271            volume_color: [0.75, 0.8, 0.85, 0.0],
272            volume_control: [2.0, 0.35, 1.0, 0.0],
273            room_light: [0.0; 4],
274            room_shape: [0.0; 4],
275            room_lo: [0.0; 4],
276            room_hi: [1.0; 4],
277            plume: Plume::default(),
278        }
279    }
280
281    pub fn set_volume(
282        &mut self,
283        kind: VolumeKind,
284        lo: [f32; 3],
285        hi: [f32; 3],
286        color: [f32; 3],
287        density: f32,
288        anisotropy: f32,
289    ) {
290        self.volume_lo = [lo[0], lo[1], lo[2], 0.0];
291        self.volume_hi = [hi[0], hi[1], hi[2], 0.0];
292        self.volume_color = [color[0], color[1], color[2], 0.0];
293        self.volume_control = [
294            density.max(0.0),
295            anisotropy.clamp(-0.9, 0.9),
296            1.0,
297            if kind == VolumeKind::Smoke { 1.0 } else { 0.0 },
298        ];
299        if let VolumeKind::Plume(plume) = kind {
300            self.plume = plume;
301        }
302    }
303
304    pub fn scale_time(&mut self, scale: f32) {
305        self.extent[2] *= scale.max(0.0);
306    }
307
308    pub fn set_room_haze(&mut self, haze: RoomHaze) {
309        self.volume_lo = [-1.0e4, -1.0e4, -1.0e4, haze.floor.max(0.0)];
310        self.volume_hi = [1.0e4, 1.0e4, 1.0e4, haze.smoke.max(0.0)];
311        self.volume_color = [
312            haze.ambient[0].max(0.0),
313            haze.ambient[1].max(0.0),
314            haze.ambient[2].max(0.0),
315            haze.mist.max(0.0),
316        ];
317        self.volume_control = [haze.fog.max(0.0), haze.phase.clamp(-0.9, 0.9), 1.0, 2.0];
318        self.room_light = [
319            haze.gold[0].max(0.0),
320            haze.gold[1].max(0.0),
321            haze.gold[2].max(0.0),
322            haze.back.clamp(0.0, 1.0),
323        ];
324        self.room_shape = [haze.reach.max(0.0), haze.unmapped.clamp(0.0, 1.0), 0.0, 0.0];
325        self.room_lo = [haze.lo[0], haze.lo[1], haze.lo[2], 0.0];
326        self.room_hi = [haze.hi[0], haze.hi[1], haze.hi[2], 0.0];
327    }
328}
329
330pub fn fog_transmittance(fog: f32, distance: f32, reach: f32) -> f32 {
331    (-fog.max(0.0) * distance.max(0.0).min(reach.max(0.0))).exp()
332}
333
334pub fn fog_in_scatter(density: f32, fog: f32, light: f32, distance: f32) -> f32 {
335    let distance = distance.max(0.0);
336    if fog <= 0.0 {
337        return density * light * distance;
338    }
339    density * light * (1.0 - (-fog * distance).exp()) / fog
340}
341
342pub fn henyey_greenstein(cosine: f32, g: f32) -> f32 {
343    let d = 1.0 + g * g - 2.0 * g * cosine;
344    (1.0 - g * g) / (4.0 * std::f32::consts::PI * d * d.sqrt())
345}
346
347fn cell_hash(cell: [i32; 3], seed: u32) -> f32 {
348    let z = hash((cell[2] as u32).wrapping_mul(83_492_791) ^ seed);
349    let y = hash((cell[1] as u32).wrapping_mul(19_349_663) ^ z);
350    let h = hash((cell[0] as u32).wrapping_mul(73_856_093) ^ y);
351    (h >> 8) as f32 / 16_777_216.0
352}
353
354pub fn room_motes(seed: u32, time: f32, motes: RoomMotes) -> Vec<ParticleInstance> {
355    let cell = motes.cell.max(1.0e-3);
356    let flow = [0.0021 * time, 0.0012 * time, -0.0009 * time];
357    let range = |axis: usize| {
358        let first = ((motes.lo[axis] - flow[axis]) / cell).floor() as i32;
359        let last = ((motes.hi[axis] - flow[axis]) / cell).floor() as i32;
360        first..=last
361    };
362    let add =
363        |c: [i32; 3], offset: [i32; 3]| [c[0] + offset[0], c[1] + offset[1], c[2] + offset[2]];
364    let mut out = Vec::new();
365    for x in range(0) {
366        for y in range(1) {
367            for z in range(2) {
368                let c = [x, y, z];
369                let h = cell_hash(add(c, [17, 5, 91]), seed);
370                if h >= motes.share {
371                    continue;
372                }
373                let j = [
374                    cell_hash(add(c, [3, 0, 0]), seed),
375                    cell_hash(add(c, [0, 7, 0]), seed),
376                    cell_hash(add(c, [0, 0, 11]), seed),
377                ];
378                let wander = [
379                    (time * 0.31 + h * 40.0).sin() * 0.12,
380                    (time * 0.23 + j[0] * 30.0).sin() * 0.12,
381                    (time * 0.27 + j[1] * 20.0).cos() * 0.12,
382                ];
383                let centre: [f32; 3] = std::array::from_fn(|axis| {
384                    (c[axis] as f32 + 0.5 + (j[axis] - 0.5) * 0.5 + wander[axis]) * cell
385                        + flow[axis]
386                });
387                let radius = 0.00025 + 0.0005 * j[2] * j[2];
388                let twinkle = 0.55 + 0.45 * (time * (0.7 + 1.3 * j[0]) + h * 60.0).sin();
389                let glow = motes.brightness * twinkle;
390                out.push(ParticleInstance {
391                    position_size: [centre[0], centre[1], centre[2], radius],
392                    velocity_alpha: [0.0, 0.0, 0.0, 1.0],
393                    color_rotation: [
394                        motes.gold[0] * glow,
395                        motes.gold[1] * glow,
396                        motes.gold[2] * glow,
397                        0.0,
398                    ],
399                    detail: [1.0, 0.0, h, 1.0],
400                });
401            }
402        }
403    }
404    out
405}
406
407pub fn plume_optical_depth(
408    density: f32,
409    path_length: f32,
410    box_width: f32,
411    depth_per_width: f32,
412) -> f32 {
413    density.max(0.0) * path_length.max(0.0) * depth_per_width / box_width.max(1.0e-6)
414}
415
416#[repr(C)]
417#[derive(Clone, Copy, Debug, PartialEq, Pod, Zeroable)]
418pub struct ParticleInstance {
419    pub position_size: [f32; 4],
420    pub velocity_alpha: [f32; 4],
421    pub color_rotation: [f32; 4],
422    pub detail: [f32; 4],
423}
424
425fn hash(seed: u32) -> u32 {
426    let state = seed.wrapping_mul(747_796_405).wrapping_add(2_891_336_453);
427    let mixed = ((state >> ((state >> 28) + 4)) ^ state).wrapping_mul(277_803_737);
428    (mixed >> 22) ^ mixed
429}
430
431pub fn pack_particles(
432    specks: &[Speck],
433    kind: ParticleKind,
434    time: f32,
435    color: [f32; 3],
436) -> Vec<ParticleInstance> {
437    specks
438        .iter()
439        .filter(|speck| speck.life > 0.0 && speck.age < speck.life)
440        .map(|speck| {
441            let random = hash(speck.seed) as f32 / u32::MAX as f32;
442            let life = (1.0 - speck.age / speck.life).clamp(0.0, 1.0);
443            let (size, alpha, kind_id) = match kind {
444                ParticleKind::Petal => (0.035 + 0.02 * random, 0.8, 0.0),
445                ParticleKind::Dust => (0.003 + 0.004 * random, 0.5, 1.0),
446                ParticleKind::Steam => (0.08 + 0.04 * random, 0.2, 2.0),
447            };
448            let speed = (speck.vel[0] * speck.vel[0]
449                + speck.vel[1] * speck.vel[1]
450                + speck.vel[2] * speck.vel[2])
451                .sqrt();
452            let angle = random * std::f32::consts::TAU
453                + time
454                    * (1.0 + random * 2.0)
455                    * if kind == ParticleKind::Petal {
456                        1.0
457                    } else {
458                        0.1
459                    };
460            ParticleInstance {
461                position_size: [speck.pos[0], speck.pos[1], speck.pos[2], size],
462                velocity_alpha: [
463                    speck.vel[0],
464                    speck.vel[1],
465                    speck.vel[2],
466                    alpha * life.min(0.2 + speck.age * 4.0),
467                ],
468                color_rotation: [color[0], color[1], color[2], angle],
469                detail: [kind_id, speed, random, 0.0],
470            }
471        })
472        .collect()
473}
474
475pub fn pack_emitter(
476    emitter: &Emitter,
477    kind: ParticleKind,
478    time: f32,
479    color: [f32; 3],
480) -> Vec<ParticleInstance> {
481    pack_particles(emitter.specks(), kind, time, color)
482}
483
484#[derive(Clone, Copy, Debug, PartialEq, Eq)]
485pub enum CreatureKind {
486    Bird,
487    Fish,
488}
489
490#[repr(C)]
491#[derive(Clone, Copy, Debug, PartialEq, Pod, Zeroable)]
492pub struct CreatureInstance {
493    pub position_scale: [f32; 4],
494    pub right: [f32; 4],
495    pub up: [f32; 4],
496    pub forward: [f32; 4],
497    pub motion: [f32; 4],
498}
499
500fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
501    [
502        a[1] * b[2] - a[2] * b[1],
503        a[2] * b[0] - a[0] * b[2],
504        a[0] * b[1] - a[1] * b[0],
505    ]
506}
507
508fn unit(a: [f32; 3]) -> [f32; 3] {
509    let length = (a[0] * a[0] + a[1] * a[1] + a[2] * a[2]).sqrt();
510    if length < 1e-6 {
511        [0.0, 0.0, -1.0]
512    } else {
513        a.map(|v| v / length)
514    }
515}
516
517fn distance_squared(position: [f32; 4], eye: [f32; 4]) -> f32 {
518    (0..3)
519        .map(|axis| (position[axis] - eye[axis]).powi(2))
520        .sum()
521}
522
523pub fn pack_creatures(
524    boids: &[Boid],
525    kind: CreatureKind,
526    size: f32,
527    seed: u32,
528) -> Vec<CreatureInstance> {
529    boids
530        .iter()
531        .enumerate()
532        .map(|(index, boid)| {
533            let forward = unit(boid.vel);
534            let reference = if forward[1].abs() > 0.95 {
535                [0.0, 0.0, 1.0]
536            } else {
537                [0.0, 1.0, 0.0]
538            };
539            let right = unit(cross(reference, forward));
540            let up = cross(forward, right);
541            let speed =
542                (boid.vel[0] * boid.vel[0] + boid.vel[1] * boid.vel[1] + boid.vel[2] * boid.vel[2])
543                    .sqrt();
544            let phase = hash(seed ^ index as u32) as f32 / u32::MAX as f32 * std::f32::consts::TAU;
545            CreatureInstance {
546                position_scale: [boid.pos[0], boid.pos[1], boid.pos[2], size],
547                right: [right[0], right[1], right[2], 0.0],
548                up: [up[0], up[1], up[2], 0.0],
549                forward: [forward[0], forward[1], forward[2], 0.0],
550                motion: [
551                    phase,
552                    speed,
553                    if kind == CreatureKind::Bird { 0.0 } else { 1.0 },
554                    0.0,
555                ],
556            }
557        })
558        .collect()
559}
560
561#[repr(C)]
562#[derive(Clone, Copy, Debug, PartialEq, Pod, Zeroable)]
563pub struct CreatureVertex {
564    pub position: [f32; 4],
565}
566
567pub fn upsample_weights(
568    full_depth: f32,
569    depths: [f32; 4],
570    fractions: [f32; 2],
571    depth_scale: f32,
572) -> [f32; 4] {
573    let x = fractions[0].clamp(0.0, 1.0);
574    let y = fractions[1].clamp(0.0, 1.0);
575    let spatial = [(1.0 - x) * (1.0 - y), x * (1.0 - y), (1.0 - x) * y, x * y];
576    let mut weights = [0.0; 4];
577    let mut total = 0.0;
578    for i in 0..4 {
579        let difference = (full_depth - depths[i]).abs();
580        weights[i] = spatial[i] * (-difference * depth_scale.max(0.0)).exp();
581        total += weights[i];
582    }
583    if total > 1e-6 {
584        weights.map(|weight| weight / total)
585    } else {
586        let nearest = depths
587            .iter()
588            .enumerate()
589            .min_by(|left, right| {
590                (full_depth - *left.1)
591                    .abs()
592                    .total_cmp(&(full_depth - *right.1).abs())
593            })
594            .map(|(index, _)| index)
595            .unwrap_or(0);
596        let mut fallback = [0.0; 4];
597        fallback[nearest] = 1.0;
598        fallback
599    }
600}
601
602fn volume_bounds(frame: &EffectFrame, width: u32, height: u32) -> Option<(u32, u32, u32, u32)> {
603    let mut min = [f32::INFINITY; 2];
604    let mut max = [f32::NEG_INFINITY; 2];
605    for x in [frame.volume_lo[0], frame.volume_hi[0]] {
606        for y in [frame.volume_lo[1], frame.volume_hi[1]] {
607            for z in [frame.volume_lo[2], frame.volume_hi[2]] {
608                let point = [x, y, z, 1.0];
609                let mut clip = [0.0; 4];
610                for (row, value) in clip.iter_mut().enumerate() {
611                    *value = (0..4)
612                        .map(|column| frame.view_proj[column][row] * point[column])
613                        .sum();
614                }
615                if clip[3] <= 0.0 || !clip.iter().all(|value| value.is_finite()) {
616                    return Some((0, 0, width, height));
617                }
618                let screen = [
619                    (clip[0] / clip[3] * 0.5 + 0.5) * width as f32,
620                    (0.5 - clip[1] / clip[3] * 0.5) * height as f32,
621                ];
622                for axis in 0..2 {
623                    min[axis] = min[axis].min(screen[axis]);
624                    max[axis] = max[axis].max(screen[axis]);
625                }
626            }
627        }
628    }
629    let left = (min[0] - 8.0).floor().clamp(0.0, width as f32) as u32;
630    let top = (min[1] - 8.0).floor().clamp(0.0, height as f32) as u32;
631    let right = (max[0] + 8.0).ceil().clamp(0.0, width as f32) as u32;
632    let bottom = (max[1] + 8.0).ceil().clamp(0.0, height as f32) as u32;
633    if right <= left || bottom <= top {
634        None
635    } else {
636        Some((left, top, right - left, bottom - top))
637    }
638}
639
640pub const PARTICLE_WGSL: &str = r#"
641struct Frame {
642    view_proj: mat4x4f, eye: vec4f, right: vec4f, up: vec4f, forward: vec4f,
643    lens: vec4f, sun: vec4f, sun_color: vec4f, extent: vec4f,
644    volume_lo: vec4f, volume_hi: vec4f, volume_color: vec4f, volume_control: vec4f,
645}
646struct Shadow {
647    splits: vec4f, filter_params: vec4f, sun: vec4f, texels: vec4f,
648    view_proj_0: mat4x4f, view_proj_1: mat4x4f, view_proj_2: mat4x4f,
649}
650struct Particle {
651    position_size: vec4f, velocity_alpha: vec4f, color_rotation: vec4f, detail: vec4f,
652}
653@group(0) @binding(0) var<uniform> frame: Frame;
654@group(0) @binding(1) var scene_depth: texture_2d<f32>;
655@group(0) @binding(2) var<storage, read> particles: array<Particle>;
656@group(0) @binding(3) var shadow_atlas: texture_depth_2d_array;
657@group(0) @binding(4) var<uniform> shadow: Shadow;
658struct Out { @builtin(position) clip: vec4f, @location(0) uv: vec2f, @location(1) color: vec4f, @location(2) distance: f32, @location(3) kind: f32, @location(4) world: vec3f, @location(5) lit: f32, }
659@vertex fn vs_main(@builtin(vertex_index) vertex: u32, @builtin(instance_index) index: u32) -> Out {
660    let p = particles[index];
661    let corners = array<vec2f, 6>(vec2f(-1.0,-1.0),vec2f(1.0,-1.0),vec2f(-1.0,1.0),vec2f(-1.0,1.0),vec2f(1.0,-1.0),vec2f(1.0,1.0));
662    let corner = corners[vertex];
663    let spin = p.color_rotation.w;
664    var axis_x = frame.right.xyz * cos(spin) + frame.up.xyz * sin(spin);
665    var axis_y = frame.up.xyz * cos(spin) - frame.right.xyz * sin(spin);
666    if (p.detail.x > 0.5 && p.detail.x < 1.5 && p.detail.y > 0.02) {
667        let v = normalize(p.velocity_alpha.xyz - frame.forward.xyz * dot(p.velocity_alpha.xyz, frame.forward.xyz));
668        axis_y = v;
669        axis_x = cross(axis_y, frame.forward.xyz);
670    }
671    let tumble = select(1.0, 0.45 + 0.55 * abs(sin(spin * 0.67)), p.detail.x < 0.5);
672    let world = p.position_size.xyz + (axis_x * corner.x * tumble + axis_y * corner.y) * p.position_size.w;
673    var out: Out;
674    out.clip = frame.view_proj * vec4f(world, 1.0);
675    out.uv = corner;
676    out.color = vec4f(p.color_rotation.xyz, p.velocity_alpha.w);
677    out.distance = length(world - frame.eye.xyz);
678    out.kind = p.detail.x;
679    out.world = world;
680    out.lit = p.detail.w;
681    return out;
682}
683fn sunlight(world: vec3f, distance: f32) -> f32 {
684    if (shadow.splits.w <= 0.0) { return 1.0; }
685    var layer = 0;
686    var matrix = shadow.view_proj_0;
687    if (distance > shadow.splits.y) { layer = 1; matrix = shadow.view_proj_1; }
688    if (distance > shadow.splits.z) { layer = 2; matrix = shadow.view_proj_2; }
689    let clip = matrix * vec4f(world, 1.0);
690    let point = clip.xyz / clip.w;
691    let uv = point.xy * vec2f(0.5, -0.5) + vec2f(0.5);
692    if (any(uv < vec2f(0.0)) || any(uv > vec2f(1.0)) || point.z < 0.0 || point.z > 1.0) { return 1.0; }
693    let size = textureDimensions(shadow_atlas);
694    let at = clamp(vec2i(uv * vec2f(size)), vec2i(0), vec2i(size) - vec2i(1));
695    let depth = textureLoad(shadow_atlas, at, layer, 0);
696    return select(0.0, 1.0, point.z - shadow.sun.w <= depth);
697}
698@fragment fn fs_main(in: Out) -> @location(0) vec4f {
699    let pixel = vec2i(in.clip.xy);
700    let depth = textureLoad(scene_depth, pixel, 0).x;
701    let soft = clamp((depth - in.distance) * 4.0, 0.0, 1.0);
702    let r = length(in.uv);
703    let petal = smoothstep(1.0, 0.7, length(vec2f(in.uv.x, in.uv.y * 1.35)));
704    let dust = exp(-r * r * 5.0) * (0.7 + 0.3 * pow(max(0.0, 1.0 - r), 16.0));
705    let steam = exp(-r * r * 3.0);
706    let shape = select(select(petal, dust, in.kind > 0.5), steam, in.kind > 1.5);
707    if (in.lit > 0.5) {
708        let glint = max(1.0 - r, 0.0) * select(0.0, 1.0, depth > dot(in.world - frame.eye.xyz, frame.forward.xyz));
709        return vec4f(in.color.rgb * frame.sun_color.rgb * frame.sun_color.w * sunlight(in.world, in.distance) * glint, 0.0);
710    }
711    let alpha = in.color.a * shape * soft;
712    return vec4f(in.color.rgb * alpha, alpha);
713}
714"#;
715
716pub const CREATURE_WGSL: &str = r#"
717struct Frame {
718    view_proj: mat4x4f, eye: vec4f, right: vec4f, up: vec4f, forward: vec4f,
719    lens: vec4f, sun: vec4f, sun_color: vec4f, extent: vec4f,
720    volume_lo: vec4f, volume_hi: vec4f, volume_color: vec4f, volume_control: vec4f,
721}
722struct Creature { position_scale: vec4f, right: vec4f, up: vec4f, forward: vec4f, motion: vec4f, }
723@group(0) @binding(0) var<uniform> frame: Frame;
724@group(0) @binding(1) var<storage, read> creatures: array<Creature>;
725@group(0) @binding(2) var<storage, read> vertices: array<vec4f>;
726@group(0) @binding(3) var scene_depth: texture_2d<f32>;
727struct Out { @builtin(position) clip: vec4f, @location(0) color: vec3f, @location(1) distance: f32, }
728@vertex fn vs_main(@builtin(vertex_index) vertex: u32, @builtin(instance_index) index: u32) -> Out {
729    let instance = creatures[index];
730    var local = vertices[vertex].xyz;
731    let phase = frame.extent.z * (4.0 + instance.motion.y * 1.3) + instance.motion.x;
732    if (instance.motion.z < 0.5) {
733        local.y += sin(phase) * abs(local.x) * 0.5;
734    } else {
735        local.x += sin(phase + local.z * 4.0) * max(-local.z, 0.0) * 0.2;
736    }
737    let world = instance.position_scale.xyz + instance.position_scale.w * (instance.right.xyz * local.x + instance.up.xyz * local.y + instance.forward.xyz * local.z);
738    var out: Out;
739    out.clip = frame.view_proj * vec4f(world, 1.0);
740    out.distance = length(world - frame.eye.xyz);
741    let light = 0.45 + 0.55 * max(0.0, dot(normalize(instance.up.xyz + vec3f(0.0, 0.3, 0.0)), normalize(frame.sun.xyz)));
742    out.color = select(vec3f(0.72, 0.58, 0.43), vec3f(0.28, 0.54, 0.63), instance.motion.z > 0.5) * light * frame.sun_color.rgb;
743    return out;
744}
745@fragment fn fs_main(in: Out) -> @location(0) vec4f {
746    if (in.distance > textureLoad(scene_depth, vec2i(in.clip.xy), 0).x) { discard; }
747    return vec4f(in.color, 1.0);
748}
749"#;
750
751pub const VOLUME_WGSL: &str = r#"
752struct Frame {
753    view_proj: mat4x4f, eye: vec4f, right: vec4f, up: vec4f, forward: vec4f,
754    lens: vec4f, sun: vec4f, sun_color: vec4f, extent: vec4f,
755    volume_lo: vec4f, volume_hi: vec4f, volume_color: vec4f, volume_control: vec4f,
756    room_light: vec4f, room_shape: vec4f, room_lo: vec4f, room_hi: vec4f,
757    plume: array<vec4f, 8>,
758}
759struct Shadow {
760    splits: vec4f, filter_params: vec4f, sun: vec4f, texels: vec4f,
761    view_proj_0: mat4x4f, view_proj_1: mat4x4f, view_proj_2: mat4x4f,
762}
763override STEPS: u32 = 20u;
764override WARP_OCTAVES: i32 = 4;
765override OCTAVES: i32 = 4;
766override EARLY_OUT: bool = false;
767override SPREAD_BASE: f32 = 1.0;
768override SPREAD_GROWTH: f32 = 2.0;
769override DRIFT_X_QUAD: f32 = 0.0;
770override DRIFT_X_LIN: f32 = 0.0;
771override DRIFT_Z_QUAD: f32 = 0.0;
772override DRIFT_Z_LIN: f32 = 0.0;
773override SWAY_X_WAVE: f32 = 10.0;
774override SWAY_X_RATE: f32 = 1.0;
775override SWAY_X_AMP: f32 = 0.005;
776override SWAY_X_BASE: f32 = 0.5;
777override SWAY_X_GROWTH: f32 = 2.0;
778override SWAY_Z_WAVE: f32 = 10.0;
779override SWAY_Z_RATE: f32 = 1.0;
780override SWAY_Z_AMP: f32 = 0.005;
781override SWAY_Z_BASE: f32 = 0.5;
782override SWAY_Z_GROWTH: f32 = 2.0;
783override FADE_IN: f32 = 0.01;
784override FADE_OUT_START: f32 = 0.05;
785override FADE_OUT_END: f32 = 0.15;
786override DEPTH_PER_WIDTH: f32 = 6.0;
787override GRAIN_H: f32 = 60.0;
788override GRAIN_V: f32 = 40.0;
789override LIFT_RATE: f32 = 0.05;
790override WARP_AMP: f32 = 2.0;
791override WARP_RATE: f32 = 0.2;
792override CHURN: f32 = 0.6;
793override THRESHOLD_LOW: f32 = 0.4;
794override THRESHOLD_HIGH: f32 = 0.85;
795@group(0) @binding(0) var<uniform> frame: Frame;
796@group(0) @binding(1) var scene_depth: texture_2d<f32>;
797@group(0) @binding(2) var shadow_atlas: texture_depth_2d_array;
798@group(0) @binding(3) var<uniform> shadow: Shadow;
799@group(0) @binding(4) var room_field: texture_3d<f32>;
800@group(0) @binding(5) var room_sampler: sampler;
801struct Out { @builtin(position) clip: vec4f, @location(0) uv: vec2f, }
802@vertex fn vs_main(@builtin(vertex_index) vertex: u32) -> Out {
803    let xy = array<vec2f, 3>(vec2f(-1.0,-1.0),vec2f(3.0,-1.0),vec2f(-1.0,3.0));
804    var out: Out;
805    out.clip = vec4f(xy[vertex], 0.0, 1.0);
806    out.uv = xy[vertex] * vec2f(0.5,-0.5) + vec2f(0.5);
807    return out;
808}
809fn pcg(value: u32) -> u32 {
810    let state = value * 747796405u + 2891336453u;
811    let mixed = ((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u;
812    return (mixed >> 22u) ^ mixed;
813}
814fn hash3(p: vec3i) -> f32 {
815    let seed = select(0u, bitcast<u32>(frame.extent.w), frame.volume_control.w > 0.5);
816    let h = pcg(u32(p.x) * 73856093u ^ u32(p.y) * 19349663u ^ u32(p.z) * 83492791u ^ seed);
817    return f32(h >> 8u) / 16777216.0;
818}
819fn noise3(p: vec3f) -> f32 {
820    let i = vec3i(floor(p));
821    let f = fract(p);
822    let t = f * f * (3.0 - 2.0 * f);
823    let a = mix(mix(hash3(i), hash3(i + vec3i(1,0,0)), t.x), mix(hash3(i + vec3i(0,1,0)), hash3(i + vec3i(1,1,0)), t.x), t.y);
824    let b = mix(mix(hash3(i + vec3i(0,0,1)), hash3(i + vec3i(1,0,1)), t.x), mix(hash3(i + vec3i(0,1,1)), hash3(i + vec3i(1,1,1)), t.x), t.y);
825    return mix(a, b, t.z);
826}
827fn fbm(p: vec3f, octaves: i32) -> f32 {
828    var sum = 0.0;
829    var amplitude = 0.55;
830    var q = p;
831    for (var i = 0; i < octaves; i++) {
832        sum += noise3(q) * amplitude;
833        q = q * 2.07 + vec3f(1.7, 9.2, 3.1);
834        amplitude *= 0.5;
835    }
836    return sum * (1.03125 / (1.1 * (1.0 - pow(0.5, f32(octaves)))));
837}
838fn density(p: vec3f) -> f32 {
839    let rise = p.y - frame.volume_lo.y;
840    let lift = p.y - frame.plume[0].y;
841    let smoke = frame.volume_control.w > 0.5;
842    let width = select(max(frame.volume_hi.z - frame.volume_lo.z, 0.000001), max(frame.volume_hi.x - frame.volume_lo.x, 0.000001), smoke);
843    let origin = frame.plume[0].xyz;
844    let drift = vec3f(DRIFT_X_QUAD * lift * lift + DRIFT_X_LIN * lift, 0.0, DRIFT_Z_QUAD * lift * lift + DRIFT_Z_LIN * lift);
845    let sway = vec3f(sin(lift * SWAY_X_WAVE - frame.extent.z * SWAY_X_RATE) * SWAY_X_AMP * (SWAY_X_BASE + lift * SWAY_X_GROWTH), 0.0, cos(lift * SWAY_Z_WAVE - frame.extent.z * SWAY_Z_RATE) * SWAY_Z_AMP * (SWAY_Z_BASE + lift * SWAY_Z_GROWTH));
846    let steam_centre = origin + vec3f(0.0, lift, 0.0) + drift + sway;
847    let smoke_rise = clamp(rise / max(frame.volume_hi.y - frame.volume_lo.y, 0.001), 0.0, 1.0);
848    let smoke_centre = (frame.volume_lo.xyz + frame.volume_hi.xyz) * 0.5 + vec3f(sin(smoke_rise * 9.0 - frame.extent.z * 1.3) * width * 0.29 * smoke_rise, 0.0, cos(smoke_rise * 7.0 - frame.extent.z) * width * 0.21 * smoke_rise);
849    let centre = select(steam_centre, smoke_centre, smoke);
850    let radius = select(frame.plume[0].w * (SPREAD_BASE + lift * SPREAD_GROWTH), width * (0.4 + smoke_rise * 0.25), smoke);
851    let core = exp(-dot((p - centre).xz, (p - centre).xz) / max(radius * radius, 0.00000001));
852    let steam_fade = smoothstep(0.0, FADE_IN, lift) * (1.0 - smoothstep(FADE_OUT_START, FADE_OUT_END, lift));
853    if (EARLY_OUT && !smoke && core * steam_fade < 0.002) { return 0.0; }
854    let q = vec3f(p.x * GRAIN_H, (p.y - frame.extent.z * LIFT_RATE) * GRAIN_V, p.z * GRAIN_H);
855    let warp = vec3f(fbm(q * 0.5 + vec3f(frame.extent.z * WARP_RATE, 0.0, 0.0), WARP_OCTAVES), 0.0, fbm(q * 0.5 + vec3f(3.1, 0.0, frame.extent.z * WARP_RATE), WARP_OCTAVES)) * WARP_AMP;
856    let steam_turbulence = smoothstep(THRESHOLD_LOW, THRESHOLD_HIGH, fbm(q + warp + vec3f(0.0, -frame.extent.z * CHURN, 0.0), OCTAVES));
857    let smoke_q = p * 18.0 + vec3f(0.0, -frame.extent.z * 0.7, 0.0);
858    let smoke_turbulence = smoothstep(0.3, 0.72, noise3(smoke_q) * 0.65 + noise3(smoke_q * 2.07 + vec3f(1.7,9.2,3.1)) * 0.35);
859    let turbulence = select(steam_turbulence, 0.25 + 0.75 * smoke_turbulence, smoke);
860    let smoke_fade = smoothstep(0.0, 0.1, smoke_rise) * (1.0 - smoothstep(0.85, 1.0, smoke_rise));
861    let fade = select(steam_fade, smoke_fade, smoke);
862    if (!smoke && core * fade < 0.002) { return 0.0; }
863    return core * turbulence * fade * frame.volume_control.x;
864}
865fn phase(cosine: f32, g: f32) -> f32 {
866    let g2 = g * g;
867    return (1.0 - g2) / pow(max(1.0 + g2 - 2.0 * g * cosine, 0.001), 1.5);
868}
869struct VolumeOut { @location(0) radiance: vec4f, @location(1) distance: f32, }
870fn room_density(p: vec3f) -> f32 {
871    let drift = vec3f(0.011, 0.0035, -0.004) * frame.extent.z * 1.6;
872    let uvw = (p + drift - frame.room_lo.xyz) / max(frame.room_hi.xyz - frame.room_lo.xyz, vec3f(0.0001));
873    var field = vec4f(0.4375, 0.0, 0.46875, 0.4375);
874    if (all(uvw >= vec3f(0.0)) && all(uvw <= vec3f(1.0))) {
875        field = textureSampleLevel(room_field, room_sampler, uvw, 0.0);
876    }
877    let behind = smoothstep(0.45, 0.02, p.z);
878    let top = smoothstep(0.2, 0.46, p.y) * (0.35 + 0.9 * field.z);
879    let low = exp(-max(p.y, 0.0) / 0.035) * (0.3 + 0.9 * field.w);
880    return frame.volume_control.x * (0.5 + field.x) + behind * (frame.volume_hi.w * field.y + frame.volume_color.w * top) + frame.volume_lo.w * low;
881}
882fn room_sunlight(world: vec3f, distance: f32) -> f32 {
883    if (shadow.splits.w <= 0.0) { return 1.0; }
884    var layer = 0;
885    var matrix = shadow.view_proj_0;
886    if (distance > shadow.splits.y) { layer = 1; matrix = shadow.view_proj_1; }
887    if (distance > shadow.splits.z) { layer = 2; matrix = shadow.view_proj_2; }
888    let clip = matrix * vec4f(world, 1.0);
889    let point = clip.xyz / clip.w;
890    let uv = point.xy * vec2f(0.5, -0.5) + vec2f(0.5);
891    if (any(uv < vec2f(0.0)) || any(uv > vec2f(1.0)) || point.z < 0.0 || point.z > 1.0) { return frame.room_shape.y; }
892    let size = textureDimensions(shadow_atlas);
893    let at = clamp(vec2i(uv * vec2f(size)), vec2i(0), vec2i(size) - vec2i(1));
894    return select(0.0, 1.0, point.z - shadow.sun.w <= textureLoad(shadow_atlas, at, layer, 0));
895}
896fn room_phase(cosine: f32, g: f32) -> f32 {
897    let d = 1.0 + g * g - 2.0 * g * cosine;
898    return (1.0 - g * g) / (12.566371 * d * sqrt(d));
899}
900fn room(low: vec2f, limit: f32, ray: vec3f) -> VolumeOut {
901    var out: VolumeOut;
902    out.distance = limit;
903    let along = max(dot(ray, normalize(frame.forward.xyz)), 0.001);
904    let span = min(limit / along, frame.room_shape.x);
905    if (span <= 0.0) {
906        out.radiance = vec4f(0.0);
907        return out;
908    }
909    let steps = 6u;
910    let step_length = span / f32(steps);
911    let turn = f32(pcg(bitcast<u32>(frame.extent.z) ^ bitcast<u32>(frame.extent.w)) >> 8u) / 16777216.0;
912    let jitter = fract(fract(52.9829189 * fract(dot(floor(low), vec2f(0.06711056, 0.00583715)))) + turn);
913    let sun = normalize(frame.sun.xyz);
914    let cosine = dot(ray, sun);
915    let phase = mix(room_phase(cosine, frame.volume_control.y), room_phase(cosine, -0.55), frame.room_light.w);
916    let direct = frame.sun_color.rgb * frame.sun_color.w * frame.room_light.rgb * phase;
917    let fog = frame.volume_control.x;
918    var light = vec3f(0.0);
919    for (var i = 0u; i < steps; i++) {
920        let distance = (f32(i) + jitter) * step_length;
921        let p = frame.eye.xyz + ray * distance;
922        let density = room_density(p);
923        if (density <= 0.0) { continue; }
924        light += density * exp(-fog * distance) * (direct * room_sunlight(p, distance * along) + frame.volume_color.rgb) * step_length;
925    }
926    out.radiance = vec4f(light, 1.0 - exp(-fog * span));
927    return out;
928}
929@fragment fn fs_main(in: Out) -> VolumeOut {
930    let screen = vec2i(clamp(in.uv * frame.extent.xy, vec2f(0.0), frame.extent.xy - vec2f(1.0)));
931    let scene_limit = textureLoad(scene_depth, screen, 0).x;
932    let limit = select(1e30, scene_limit, frame.volume_control.w > 0.5);
933    let centre_clip = frame.view_proj * vec4f(frame.eye.xyz + frame.forward.xyz, 1.0);
934    let centre = centre_clip.xy / centre_clip.w;
935    let ray = normalize(frame.forward.xyz + frame.right.xyz * ((in.uv.x * 2.0 - 1.0 - centre.x) * frame.lens.x) + frame.up.xyz * ((1.0 - in.uv.y * 2.0 - centre.y) * frame.lens.y));
936    if (frame.volume_control.w > 1.5) { return room(in.clip.xy, scene_limit, ray); }
937    let safe = select(ray, select(vec3f(-0.00001), vec3f(0.00001), ray >= vec3f(0.0)), abs(ray) < vec3f(0.00001));
938    let t0 = (frame.volume_lo.xyz - frame.eye.xyz) / safe;
939    let t1 = (frame.volume_hi.xyz - frame.eye.xyz) / safe;
940    let enter = max(max(min(t0.x,t1.x),min(t0.y,t1.y)),max(min(t0.z,t1.z),0.0));
941    let leave = min(min(max(t0.x,t1.x),max(t0.y,t1.y)),min(max(t0.z,t1.z),limit));
942    var out: VolumeOut;
943    out.radiance = vec4f(0.0);
944    out.distance = scene_limit;
945    if (leave <= enter) { return out; }
946    let steps = STEPS;
947    let step_length = (leave - enter) / f32(steps);
948    let sun = normalize(frame.sun.xyz);
949    let scatter = frame.volume_color.rgb * (vec3f(0.32) + frame.sun_color.rgb * frame.sun_color.w * phase(dot(ray,sun), frame.volume_control.y)) * 0.85;
950    var transmittance = 1.0;
951    var light = vec3f(0.0);
952    for (var i = 0u; i < steps; i++) {
953        let distance = enter + (f32(i) + 0.5) * step_length;
954        let p = frame.eye.xyz + ray * distance;
955        let width = select(max(frame.volume_hi.z - frame.volume_lo.z, 0.000001), max(frame.volume_hi.x - frame.volume_lo.x, 0.000001), frame.volume_control.w > 0.5);
956        let scale = select(DEPTH_PER_WIDTH, 3.0, frame.volume_control.w > 0.5);
957        let absorbed = 1.0 - exp(-density(p) * step_length * scale / width);
958        let weight = transmittance * absorbed;
959        light += weight * scatter;
960        transmittance *= 1.0 - absorbed;
961    }
962    out.radiance = vec4f(light, 1.0 - transmittance);
963    return out;
964}
965"#;
966
967pub const ROOM_FIELD_WGSL: &str = r#"
968struct Field { lo: vec4f, hi: vec4f, size: vec4u, }
969@group(0) @binding(0) var<uniform> field: Field;
970@group(0) @binding(1) var target_field: texture_storage_3d<rgba8unorm, write>;
971fn pcg(value: u32) -> u32 {
972    let state = value * 747796405u + 2891336453u;
973    let mixed = ((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u;
974    return (mixed >> 22u) ^ mixed;
975}
976fn hash3(p: vec3i) -> f32 {
977    let h = pcg(bitcast<u32>(p.x) * 73856093u ^ pcg(bitcast<u32>(p.y) * 19349663u ^ pcg(bitcast<u32>(p.z) * 83492791u ^ field.size.w)));
978    return f32(h >> 8u) / 16777216.0;
979}
980fn vnoise(p: vec3f) -> f32 {
981    let i = vec3i(floor(p));
982    let f = fract(p);
983    let u = f * f * (3.0 - 2.0 * f);
984    let a = mix(hash3(i), hash3(i + vec3i(1, 0, 0)), u.x);
985    let b = mix(hash3(i + vec3i(0, 1, 0)), hash3(i + vec3i(1, 1, 0)), u.x);
986    let c = mix(hash3(i + vec3i(0, 0, 1)), hash3(i + vec3i(1, 0, 1)), u.x);
987    let d = mix(hash3(i + vec3i(0, 1, 1)), hash3(i + vec3i(1, 1, 1)), u.x);
988    return mix(mix(a, b, u.y), mix(c, d, u.y), u.z);
989}
990fn fbm(p: vec3f, octaves: i32) -> f32 {
991    var sum = 0.0;
992    var amp = 0.5;
993    var q = p;
994    for (var i = 0; i < octaves; i++) {
995        sum += amp * vnoise(q);
996        q = q * 2.03 + vec3f(1.7, 9.2, 3.1);
997        amp *= 0.5;
998    }
999    return sum;
1000}
1001@compute @workgroup_size(4, 4, 4)
1002fn main(@builtin(global_invocation_id) id: vec3u) {
1003    if (any(id >= field.size.xyz)) { return; }
1004    let p = field.lo.xyz + (vec3f(id) + vec3f(0.5)) / vec3f(field.size.xyz) * (field.hi.xyz - field.lo.xyz);
1005    let w = p * 2.4;
1006    let warp = vec3f(fbm(w + vec3f(1.7, 0.0, 0.0), 3), fbm(w + vec3f(0.0, 9.2, 0.0), 3), fbm(w + vec3f(0.0, 0.0, 4.4), 3)) - vec3f(0.5);
1007    let q = p + warp * 0.18;
1008    let body = fbm(q * vec3f(2.6, 4.0, 2.6), 3);
1009    let ridge = 1.0 - abs(fbm(q * vec3f(7.0, 16.0, 7.0) + vec3f(11.0), 4) * 2.0 - 1.0);
1010    let curl = pow(ridge, 6.0) * smoothstep(0.42, 0.7, body);
1011    let top = fbm(q * vec3f(3.0, 6.0, 3.0) + vec3f(5.0), 4);
1012    let low = fbm(q * vec3f(5.0, 12.0, 5.0) + vec3f(3.0), 3);
1013    textureStore(target_field, vec3i(id), vec4f(body, curl, top, low));
1014}
1015"#;
1016
1017pub const ROOM_FIELD_SPACING: f32 = 0.012;
1018
1019pub const ROOM_BLUR_WGSL: &str = r#"
1020struct Frame {
1021    view_proj: mat4x4f, eye: vec4f, right: vec4f, up: vec4f, forward: vec4f,
1022    lens: vec4f, sun: vec4f, sun_color: vec4f, extent: vec4f,
1023    volume_lo: vec4f, volume_hi: vec4f, volume_color: vec4f, volume_control: vec4f,
1024}
1025@group(0) @binding(0) var<uniform> frame: Frame;
1026@group(0) @binding(2) var low_color: texture_2d<f32>;
1027@group(0) @binding(3) var low_depth: texture_2d<f32>;
1028@vertex fn vs_main(@builtin(vertex_index) vertex: u32) -> @builtin(position) vec4f {
1029    let xy = array<vec2f, 3>(vec2f(-1.0,-1.0),vec2f(3.0,-1.0),vec2f(-1.0,3.0));
1030    return vec4f(xy[vertex], 0.0, 1.0);
1031}
1032override low_scale: f32 = 4.0;
1033@fragment fn fs_main(@builtin(position) position: vec4f) -> @location(0) vec4f {
1034    let pixel = vec2i(position.xy);
1035    let last = vec2i(min(vec2f(textureDimensions(low_color)), ceil(frame.extent.xy / low_scale))) - vec2i(1);
1036    let centre = textureLoad(low_depth, pixel, 0).x;
1037    let edge = 0.01 + 0.05 * centre;
1038    var total = vec4f(0.0);
1039    var weight = 0.0;
1040    for (var y = -1; y <= 1; y++) {
1041        for (var x = -1; x <= 1; x++) {
1042            let at = clamp(pixel + vec2i(x, y), vec2i(0), last);
1043            let depth = textureLoad(low_depth, at, 0).x;
1044            let w = exp(-f32(x * x + y * y) * 0.5) * select(0.0, 1.0, abs(depth - centre) < edge);
1045            total += textureLoad(low_color, at, 0) * w;
1046            weight += w;
1047        }
1048    }
1049    return total / max(weight, 0.000001);
1050}
1051"#;
1052
1053pub const UPSAMPLE_WGSL: &str = r#"
1054struct Frame {
1055    view_proj: mat4x4f, eye: vec4f, right: vec4f, up: vec4f, forward: vec4f,
1056    lens: vec4f, sun: vec4f, sun_color: vec4f, extent: vec4f,
1057    volume_lo: vec4f, volume_hi: vec4f, volume_color: vec4f, volume_control: vec4f,
1058}
1059@group(0) @binding(0) var<uniform> frame: Frame;
1060@group(0) @binding(1) var scene_depth: texture_2d<f32>;
1061@group(0) @binding(2) var low_color: texture_2d<f32>;
1062@group(0) @binding(3) var low_depth: texture_2d<f32>;
1063@vertex fn vs_main(@builtin(vertex_index) vertex: u32) -> @builtin(position) vec4f {
1064    let xy = array<vec2f, 3>(vec2f(-1.0,-1.0),vec2f(3.0,-1.0),vec2f(-1.0,3.0));
1065    return vec4f(xy[vertex], 0.0, 1.0);
1066}
1067override low_scale: f32 = 4.0;
1068@fragment fn fs_main(@builtin(position) position: vec4f) -> @location(0) vec4f {
1069    let size = min(vec2f(textureDimensions(low_color)), ceil(frame.extent.xy / low_scale));
1070    let point = position.xy / frame.extent.xy * size - vec2f(0.5);
1071    let base = vec2i(floor(point));
1072    let fraction = fract(point);
1073    let max_pixel = vec2i(size) - vec2i(1);
1074    let full = textureLoad(scene_depth, vec2i(position.xy), 0).x;
1075    var total = 0.0;
1076    var result = vec4f(0.0);
1077    var nearest = 1e30;
1078    var nearest_color = vec4f(0.0);
1079    for (var y = 0; y < 2; y++) {
1080        for (var x = 0; x < 2; x++) {
1081            let pixel = clamp(base + vec2i(x,y), vec2i(0), max_pixel);
1082            let depth = textureLoad(low_depth, pixel, 0).x;
1083            let difference = abs(full - depth);
1084            let color = textureLoad(low_color, pixel, 0);
1085            if (difference < nearest) {
1086                nearest = difference;
1087                nearest_color = color;
1088            }
1089            let spatial = select(1.0 - fraction.x, fraction.x, x == 1) * select(1.0 - fraction.y, fraction.y, y == 1);
1090            let weight = spatial * exp(-difference * frame.volume_control.z);
1091            result += color * weight;
1092            total += weight;
1093        }
1094    }
1095    return select(nearest_color, result / max(total, 0.000001), total > 0.000001);
1096}
1097"#;
1098
1099fn uniform(device: &wgpu::Device, frame: &EffectFrame) -> wgpu::Buffer {
1100    device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1101        label: Some("effects frame"),
1102        contents: bytemuck::bytes_of(frame),
1103        usage: wgpu::BufferUsages::UNIFORM,
1104    })
1105}
1106
1107fn storage<T: Pod>(device: &wgpu::Device, label: &'static str, values: &[T]) -> wgpu::Buffer {
1108    device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1109        label: Some(label),
1110        contents: bytemuck::cast_slice(values),
1111        usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
1112    })
1113}
1114
1115fn buffer_entry(
1116    binding: u32,
1117    visibility: wgpu::ShaderStages,
1118    ty: wgpu::BufferBindingType,
1119) -> wgpu::BindGroupLayoutEntry {
1120    wgpu::BindGroupLayoutEntry {
1121        binding,
1122        visibility,
1123        ty: wgpu::BindingType::Buffer {
1124            ty,
1125            has_dynamic_offset: false,
1126            min_binding_size: None,
1127        },
1128        count: None,
1129    }
1130}
1131
1132fn texture_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
1133    wgpu::BindGroupLayoutEntry {
1134        binding,
1135        visibility: wgpu::ShaderStages::FRAGMENT,
1136        ty: wgpu::BindingType::Texture {
1137            sample_type: wgpu::TextureSampleType::Float { filterable: false },
1138            view_dimension: wgpu::TextureViewDimension::D2,
1139            multisampled: false,
1140        },
1141        count: None,
1142    }
1143}
1144
1145fn shadow_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
1146    wgpu::BindGroupLayoutEntry {
1147        binding,
1148        visibility: wgpu::ShaderStages::FRAGMENT,
1149        ty: wgpu::BindingType::Texture {
1150            sample_type: wgpu::TextureSampleType::Depth,
1151            view_dimension: wgpu::TextureViewDimension::D2Array,
1152            multisampled: false,
1153        },
1154        count: None,
1155    }
1156}
1157
1158fn pipeline(
1159    device: &wgpu::Device,
1160    label: &'static str,
1161    source: &str,
1162    layout: &wgpu::BindGroupLayout,
1163    targets: &[Option<wgpu::ColorTargetState>],
1164) -> wgpu::RenderPipeline {
1165    pipeline_with(device, label, source, layout, targets, &[])
1166}
1167
1168fn pipeline_with(
1169    device: &wgpu::Device,
1170    label: &'static str,
1171    source: &str,
1172    layout: &wgpu::BindGroupLayout,
1173    targets: &[Option<wgpu::ColorTargetState>],
1174    constants: &[(&str, f64)],
1175) -> wgpu::RenderPipeline {
1176    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1177        label: Some(label),
1178        source: wgpu::ShaderSource::Wgsl(source.into()),
1179    });
1180    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1181        label: Some(label),
1182        bind_group_layouts: &[Some(layout)],
1183        immediate_size: 0,
1184    });
1185    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1186        label: Some(label),
1187        layout: Some(&pipeline_layout),
1188        vertex: wgpu::VertexState {
1189            module: &shader,
1190            entry_point: Some("vs_main"),
1191            compilation_options: wgpu::PipelineCompilationOptions::default(),
1192            buffers: &[],
1193        },
1194        primitive: wgpu::PrimitiveState {
1195            topology: wgpu::PrimitiveTopology::TriangleList,
1196            cull_mode: None,
1197            ..Default::default()
1198        },
1199        depth_stencil: None,
1200        multisample: wgpu::MultisampleState::default(),
1201        fragment: Some(wgpu::FragmentState {
1202            module: &shader,
1203            entry_point: Some("fs_main"),
1204            compilation_options: wgpu::PipelineCompilationOptions {
1205                constants,
1206                ..Default::default()
1207            },
1208            targets,
1209        }),
1210        multiview_mask: None,
1211        cache: None,
1212    })
1213}
1214
1215fn premultiplied_target() -> Option<wgpu::ColorTargetState> {
1216    Some(wgpu::ColorTargetState {
1217        format: HDR_FORMAT,
1218        blend: Some(wgpu::BlendState {
1219            color: wgpu::BlendComponent {
1220                src_factor: wgpu::BlendFactor::One,
1221                dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
1222                operation: wgpu::BlendOperation::Add,
1223            },
1224            alpha: wgpu::BlendComponent {
1225                src_factor: wgpu::BlendFactor::One,
1226                dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
1227                operation: wgpu::BlendOperation::Add,
1228            },
1229        }),
1230        write_mask: wgpu::ColorWrites::ALL,
1231    })
1232}
1233
1234fn color_attachment(view: &wgpu::TextureView, clear: bool) -> wgpu::RenderPassColorAttachment<'_> {
1235    wgpu::RenderPassColorAttachment {
1236        view,
1237        resolve_target: None,
1238        ops: wgpu::Operations {
1239            load: if clear {
1240                wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT)
1241            } else {
1242                wgpu::LoadOp::Load
1243            },
1244            store: wgpu::StoreOp::Store,
1245        },
1246        depth_slice: None,
1247    }
1248}
1249
1250pub struct CreatureMesh {
1251    vertices: wgpu::Buffer,
1252    indices: wgpu::Buffer,
1253    index_count: u32,
1254}
1255
1256pub struct EffectCommands<'a> {
1257    pub device: &'a wgpu::Device,
1258    pub encoder: &'a mut wgpu::CommandEncoder,
1259}
1260
1261#[derive(Clone, Copy)]
1262pub struct EffectTargets<'a> {
1263    pub hdr: &'a wgpu::TextureView,
1264    pub depth: &'a wgpu::TextureView,
1265    pub shadow: Option<(&'a wgpu::TextureView, &'a wgpu::Buffer)>,
1266}
1267
1268pub struct VolumeTimestamps<'a> {
1269    pub march: Option<wgpu::RenderPassTimestampWrites<'a>>,
1270    pub upsample: Option<wgpu::RenderPassTimestampWrites<'a>>,
1271}
1272
1273impl CreatureMesh {
1274    pub fn new(device: &wgpu::Device, vertices: &[CreatureVertex], indices: &[u32]) -> Self {
1275        let index_count = indices.len() as u32;
1276        let vertices = storage(device, "creature vertices", vertices);
1277        let indices = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1278            label: Some("creature indices"),
1279            contents: bytemuck::cast_slice(indices),
1280            usage: wgpu::BufferUsages::INDEX,
1281        });
1282        Self {
1283            vertices,
1284            indices,
1285            index_count,
1286        }
1287    }
1288
1289    pub fn shadow_buffers(&self) -> (&wgpu::Buffer, &wgpu::Buffer, u32) {
1290        (&self.vertices, &self.indices, self.index_count)
1291    }
1292}
1293
1294#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1295pub struct VolumeCuts {
1296    pub steps: u32,
1297    pub warp_octaves: u32,
1298    pub octaves: u32,
1299    pub early_out: bool,
1300}
1301
1302impl VolumeCuts {
1303    pub const FULL: Self = Self {
1304        steps: 20,
1305        warp_octaves: 4,
1306        octaves: 4,
1307        early_out: false,
1308    };
1309
1310    pub const DEFAULT: Self = Self {
1311        early_out: true,
1312        ..Self::FULL
1313    };
1314
1315    fn constants(&self) -> [(&'static str, f64); 4] {
1316        [
1317            ("STEPS", f64::from(self.steps.max(1))),
1318            ("WARP_OCTAVES", f64::from(self.warp_octaves.clamp(1, 4))),
1319            ("OCTAVES", f64::from(self.octaves.clamp(1, 4))),
1320            ("EARLY_OUT", f64::from(u8::from(self.early_out))),
1321        ]
1322    }
1323}
1324
1325impl Default for VolumeCuts {
1326    fn default() -> Self {
1327        Self::DEFAULT
1328    }
1329}
1330
1331fn volume_targets() -> [Option<wgpu::ColorTargetState>; 2] {
1332    [
1333        Some(wgpu::ColorTargetState {
1334            format: HDR_FORMAT,
1335            blend: None,
1336            write_mask: wgpu::ColorWrites::ALL,
1337        }),
1338        Some(wgpu::ColorTargetState {
1339            format: SCENE_DEPTH_FORMAT,
1340            blend: None,
1341            write_mask: wgpu::ColorWrites::ALL,
1342        }),
1343    ]
1344}
1345
1346pub struct Effects {
1347    cuts: VolumeCuts,
1348    look: Plume,
1349    cached_volumes: Vec<(VolumeCuts, [u32; 28], wgpu::RenderPipeline)>,
1350    width: u32,
1351    height: u32,
1352    viewport: (u32, u32),
1353    scale: u32,
1354    _low_color: wgpu::Texture,
1355    low_color_view: wgpu::TextureView,
1356    _low_depth: wgpu::Texture,
1357    low_depth_view: wgpu::TextureView,
1358    _low_blur: wgpu::Texture,
1359    low_blur_view: wgpu::TextureView,
1360    _empty_shadow: wgpu::Texture,
1361    empty_shadow_view: wgpu::TextureView,
1362    empty_shadow_data: wgpu::Buffer,
1363    particle_layout: wgpu::BindGroupLayout,
1364    creature_layout: wgpu::BindGroupLayout,
1365    volume_layout: wgpu::BindGroupLayout,
1366    upsample_layout: wgpu::BindGroupLayout,
1367    particle_pipeline: wgpu::RenderPipeline,
1368    creature_pipeline: wgpu::RenderPipeline,
1369    upsample_pipeline: wgpu::RenderPipeline,
1370    room_blur_pipeline: wgpu::RenderPipeline,
1371    room_field_layout: wgpu::BindGroupLayout,
1372    room_field_pipeline: wgpu::ComputePipeline,
1373    room_sampler: wgpu::Sampler,
1374    _empty_field: wgpu::Texture,
1375    empty_field_view: wgpu::TextureView,
1376    room_field: Option<RoomField>,
1377}
1378
1379struct RoomField {
1380    key: ([u32; 3], [u32; 3], u32),
1381    _texture: wgpu::Texture,
1382    view: wgpu::TextureView,
1383}
1384
1385fn field_key(haze: &RoomHaze) -> ([u32; 3], [u32; 3], u32) {
1386    (
1387        haze.lo.map(f32::to_bits),
1388        haze.hi.map(f32::to_bits),
1389        haze.seed,
1390    )
1391}
1392
1393pub fn room_field_size(haze: &RoomHaze) -> [u32; 3] {
1394    std::array::from_fn(|axis| {
1395        (((haze.hi[axis] - haze.lo[axis]) / ROOM_FIELD_SPACING).ceil() as u32).clamp(1, 256)
1396    })
1397}
1398
1399fn field_texture(
1400    device: &wgpu::Device,
1401    size: [u32; 3],
1402    usage: wgpu::TextureUsages,
1403) -> wgpu::Texture {
1404    device.create_texture(&wgpu::TextureDescriptor {
1405        label: Some("effects room haze field"),
1406        size: wgpu::Extent3d {
1407            width: size[0],
1408            height: size[1],
1409            depth_or_array_layers: size[2],
1410        },
1411        mip_level_count: 1,
1412        sample_count: 1,
1413        dimension: wgpu::TextureDimension::D3,
1414        format: wgpu::TextureFormat::Rgba8Unorm,
1415        usage,
1416        view_formats: &[],
1417    })
1418}
1419
1420impl Effects {
1421    pub fn new(device: &wgpu::Device, width: u32, height: u32, scale: u32) -> Result<Self, String> {
1422        if width == 0 || height == 0 || !matches!(scale, 2 | 4) {
1423            return Err("effects need nonzero dimensions and half or quarter resolution".into());
1424        }
1425        let low_width = width.div_ceil(scale);
1426        let low_height = height.div_ceil(scale);
1427        let texture = |label, format| {
1428            device.create_texture(&wgpu::TextureDescriptor {
1429                label: Some(label),
1430                size: wgpu::Extent3d {
1431                    width: low_width,
1432                    height: low_height,
1433                    depth_or_array_layers: 1,
1434                },
1435                mip_level_count: 1,
1436                sample_count: 1,
1437                dimension: wgpu::TextureDimension::D2,
1438                format,
1439                usage: wgpu::TextureUsages::RENDER_ATTACHMENT
1440                    | wgpu::TextureUsages::TEXTURE_BINDING,
1441                view_formats: &[],
1442            })
1443        };
1444        let low_color = texture("effects low color", HDR_FORMAT);
1445        let low_color_view = low_color.create_view(&Default::default());
1446        let low_depth = texture("effects low depth", SCENE_DEPTH_FORMAT);
1447        let low_depth_view = low_depth.create_view(&Default::default());
1448        let low_blur = texture("effects low blur", HDR_FORMAT);
1449        let low_blur_view = low_blur.create_view(&Default::default());
1450        let empty_shadow = device.create_texture(&wgpu::TextureDescriptor {
1451            label: Some("effects empty shadow"),
1452            size: wgpu::Extent3d {
1453                width: 1,
1454                height: 1,
1455                depth_or_array_layers: 3,
1456            },
1457            mip_level_count: 1,
1458            sample_count: 1,
1459            dimension: wgpu::TextureDimension::D2,
1460            format: wgpu::TextureFormat::Depth32Float,
1461            usage: wgpu::TextureUsages::TEXTURE_BINDING,
1462            view_formats: &[],
1463        });
1464        let empty_shadow_view = empty_shadow.create_view(&wgpu::TextureViewDescriptor {
1465            dimension: Some(wgpu::TextureViewDimension::D2Array),
1466            ..Default::default()
1467        });
1468        let empty_shadow_data = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1469            label: Some("effects empty shadow data"),
1470            contents: &[0; 256],
1471            usage: wgpu::BufferUsages::UNIFORM,
1472        });
1473        let particle_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1474            label: Some("particle bindings"),
1475            entries: &[
1476                buffer_entry(
1477                    0,
1478                    wgpu::ShaderStages::VERTEX_FRAGMENT,
1479                    wgpu::BufferBindingType::Uniform,
1480                ),
1481                texture_entry(1),
1482                buffer_entry(
1483                    2,
1484                    wgpu::ShaderStages::VERTEX,
1485                    wgpu::BufferBindingType::Storage { read_only: true },
1486                ),
1487                shadow_entry(3),
1488                buffer_entry(
1489                    4,
1490                    wgpu::ShaderStages::FRAGMENT,
1491                    wgpu::BufferBindingType::Uniform,
1492                ),
1493            ],
1494        });
1495        let creature_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1496            label: Some("creature bindings"),
1497            entries: &[
1498                buffer_entry(
1499                    0,
1500                    wgpu::ShaderStages::VERTEX,
1501                    wgpu::BufferBindingType::Uniform,
1502                ),
1503                buffer_entry(
1504                    1,
1505                    wgpu::ShaderStages::VERTEX,
1506                    wgpu::BufferBindingType::Storage { read_only: true },
1507                ),
1508                buffer_entry(
1509                    2,
1510                    wgpu::ShaderStages::VERTEX,
1511                    wgpu::BufferBindingType::Storage { read_only: true },
1512                ),
1513                texture_entry(3),
1514            ],
1515        });
1516        let volume_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1517            label: Some("volume bindings"),
1518            entries: &[
1519                buffer_entry(
1520                    0,
1521                    wgpu::ShaderStages::FRAGMENT,
1522                    wgpu::BufferBindingType::Uniform,
1523                ),
1524                texture_entry(1),
1525                shadow_entry(2),
1526                buffer_entry(
1527                    3,
1528                    wgpu::ShaderStages::FRAGMENT,
1529                    wgpu::BufferBindingType::Uniform,
1530                ),
1531                wgpu::BindGroupLayoutEntry {
1532                    binding: 4,
1533                    visibility: wgpu::ShaderStages::FRAGMENT,
1534                    ty: wgpu::BindingType::Texture {
1535                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
1536                        view_dimension: wgpu::TextureViewDimension::D3,
1537                        multisampled: false,
1538                    },
1539                    count: None,
1540                },
1541                wgpu::BindGroupLayoutEntry {
1542                    binding: 5,
1543                    visibility: wgpu::ShaderStages::FRAGMENT,
1544                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
1545                    count: None,
1546                },
1547            ],
1548        });
1549        let upsample_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1550            label: Some("upsample bindings"),
1551            entries: &[
1552                buffer_entry(
1553                    0,
1554                    wgpu::ShaderStages::FRAGMENT,
1555                    wgpu::BufferBindingType::Uniform,
1556                ),
1557                texture_entry(1),
1558                texture_entry(2),
1559                texture_entry(3),
1560            ],
1561        });
1562        let particle_pipeline = pipeline(
1563            device,
1564            "effects particles",
1565            PARTICLE_WGSL,
1566            &particle_layout,
1567            &[premultiplied_target()],
1568        );
1569        let creature_pipeline = pipeline(
1570            device,
1571            "effects creatures",
1572            CREATURE_WGSL,
1573            &creature_layout,
1574            &[Some(wgpu::ColorTargetState {
1575                format: HDR_FORMAT,
1576                blend: None,
1577                write_mask: wgpu::ColorWrites::ALL,
1578            })],
1579        );
1580        let cuts = VolumeCuts::default();
1581        let look = Plume::default();
1582        let cached_volumes = vec![(
1583            cuts,
1584            look.key(),
1585            pipeline_with(
1586                device,
1587                "effects volume",
1588                VOLUME_WGSL,
1589                &volume_layout,
1590                &volume_targets(),
1591                &volume_constants(cuts, &look),
1592            ),
1593        )];
1594        let low_scale = [("low_scale", f64::from(scale))];
1595        let upsample_pipeline = pipeline_with(
1596            device,
1597            "effects upsample",
1598            UPSAMPLE_WGSL,
1599            &upsample_layout,
1600            &[premultiplied_target()],
1601            &low_scale,
1602        );
1603        let room_blur_pipeline = pipeline_with(
1604            device,
1605            "effects room haze blur",
1606            ROOM_BLUR_WGSL,
1607            &upsample_layout,
1608            &[Some(wgpu::ColorTargetState {
1609                format: HDR_FORMAT,
1610                blend: None,
1611                write_mask: wgpu::ColorWrites::ALL,
1612            })],
1613            &low_scale,
1614        );
1615        let room_field_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1616            label: Some("room haze field bindings"),
1617            entries: &[
1618                buffer_entry(
1619                    0,
1620                    wgpu::ShaderStages::COMPUTE,
1621                    wgpu::BufferBindingType::Uniform,
1622                ),
1623                wgpu::BindGroupLayoutEntry {
1624                    binding: 1,
1625                    visibility: wgpu::ShaderStages::COMPUTE,
1626                    ty: wgpu::BindingType::StorageTexture {
1627                        access: wgpu::StorageTextureAccess::WriteOnly,
1628                        format: wgpu::TextureFormat::Rgba8Unorm,
1629                        view_dimension: wgpu::TextureViewDimension::D3,
1630                    },
1631                    count: None,
1632                },
1633            ],
1634        });
1635        let room_field_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1636            label: Some("effects room haze field"),
1637            source: wgpu::ShaderSource::Wgsl(ROOM_FIELD_WGSL.into()),
1638        });
1639        let room_field_pipeline_layout =
1640            device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1641                label: Some("effects room haze field"),
1642                bind_group_layouts: &[Some(&room_field_layout)],
1643                immediate_size: 0,
1644            });
1645        let room_field_pipeline =
1646            device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
1647                label: Some("effects room haze field"),
1648                layout: Some(&room_field_pipeline_layout),
1649                module: &room_field_module,
1650                entry_point: Some("main"),
1651                compilation_options: Default::default(),
1652                cache: None,
1653            });
1654        let room_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
1655            label: Some("effects room haze field"),
1656            mag_filter: wgpu::FilterMode::Linear,
1657            min_filter: wgpu::FilterMode::Linear,
1658            address_mode_u: wgpu::AddressMode::ClampToEdge,
1659            address_mode_v: wgpu::AddressMode::ClampToEdge,
1660            address_mode_w: wgpu::AddressMode::ClampToEdge,
1661            ..Default::default()
1662        });
1663        let empty_field = field_texture(device, [1, 1, 1], wgpu::TextureUsages::TEXTURE_BINDING);
1664        let empty_field_view = empty_field.create_view(&Default::default());
1665        Ok(Self {
1666            cuts,
1667            look,
1668            cached_volumes,
1669            width,
1670            height,
1671            viewport: (width, height),
1672            scale,
1673            _low_color: low_color,
1674            low_color_view,
1675            _low_depth: low_depth,
1676            low_depth_view,
1677            _low_blur: low_blur,
1678            low_blur_view,
1679            _empty_shadow: empty_shadow,
1680            empty_shadow_view,
1681            empty_shadow_data,
1682            particle_layout,
1683            creature_layout,
1684            volume_layout,
1685            upsample_layout,
1686            particle_pipeline,
1687            creature_pipeline,
1688            upsample_pipeline,
1689            room_blur_pipeline,
1690            room_field_layout,
1691            room_field_pipeline,
1692            room_sampler,
1693            _empty_field: empty_field,
1694            empty_field_view,
1695            room_field: None,
1696        })
1697    }
1698
1699    pub fn dimensions(&self) -> (u32, u32, u32) {
1700        (self.width, self.height, self.scale)
1701    }
1702
1703    pub fn viewport(&self) -> (u32, u32) {
1704        self.viewport
1705    }
1706
1707    pub fn textures(&self) -> Vec<wgpu::Texture> {
1708        vec![
1709            self._low_color.clone(),
1710            self._low_depth.clone(),
1711            self._low_blur.clone(),
1712        ]
1713    }
1714
1715    pub fn set_viewport(&mut self, width: u32, height: u32) -> Result<(), String> {
1716        crate::viewport::check([width, height], [self.width, self.height])?;
1717        self.viewport = (width, height);
1718        Ok(())
1719    }
1720
1721    fn low_viewport(&self) -> [u32; 2] {
1722        [
1723            self.viewport.0.div_ceil(self.scale),
1724            self.viewport.1.div_ceil(self.scale),
1725        ]
1726    }
1727
1728    pub fn volume_cuts(&self) -> VolumeCuts {
1729        self.cuts
1730    }
1731
1732    pub fn set_volume_cuts(&mut self, device: &wgpu::Device, cuts: VolumeCuts) {
1733        self.cuts = cuts;
1734        let look = self.look;
1735        self.ensure_volume(device, &look);
1736    }
1737
1738    pub fn set_plume(&mut self, device: &wgpu::Device, plume: &Plume) {
1739        self.ensure_volume(device, plume);
1740    }
1741
1742    fn ensure_volume(&mut self, device: &wgpu::Device, plume: &Plume) {
1743        let key = plume.key();
1744        self.look = *plume;
1745        if self
1746            .cached_volumes
1747            .iter()
1748            .any(|(known, look, _)| *known == self.cuts && *look == key)
1749        {
1750            return;
1751        }
1752        if self.cached_volumes.len() >= MAX_VOLUME_PIPELINES {
1753            self.cached_volumes.remove(0);
1754        }
1755        let built = pipeline_with(
1756            device,
1757            "effects volume",
1758            VOLUME_WGSL,
1759            &self.volume_layout,
1760            &volume_targets(),
1761            &volume_constants(self.cuts, plume),
1762        );
1763        self.cached_volumes.push((self.cuts, key, built));
1764    }
1765
1766    pub fn prepare_room(&mut self, commands: &mut EffectCommands<'_>, haze: &RoomHaze) {
1767        let key = field_key(haze);
1768        if self
1769            .room_field
1770            .as_ref()
1771            .is_some_and(|field| field.key == key)
1772        {
1773            return;
1774        }
1775        let size = room_field_size(haze);
1776        let texture = field_texture(
1777            commands.device,
1778            size,
1779            wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::STORAGE_BINDING,
1780        );
1781        let view = texture.create_view(&Default::default());
1782        let mut values = [0u32; 12];
1783        for axis in 0..3 {
1784            values[axis] = haze.lo[axis].to_bits();
1785            values[4 + axis] = haze.hi[axis].to_bits();
1786            values[8 + axis] = size[axis];
1787        }
1788        values[11] = haze.seed;
1789        let uniform = commands
1790            .device
1791            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
1792                label: Some("room haze field box"),
1793                contents: bytemuck::cast_slice(&values),
1794                usage: wgpu::BufferUsages::UNIFORM,
1795            });
1796        let bind = commands
1797            .device
1798            .create_bind_group(&wgpu::BindGroupDescriptor {
1799                label: Some("room haze field bindings"),
1800                layout: &self.room_field_layout,
1801                entries: &[
1802                    wgpu::BindGroupEntry {
1803                        binding: 0,
1804                        resource: uniform.as_entire_binding(),
1805                    },
1806                    wgpu::BindGroupEntry {
1807                        binding: 1,
1808                        resource: wgpu::BindingResource::TextureView(&view),
1809                    },
1810                ],
1811            });
1812        {
1813            let mut pass = commands
1814                .encoder
1815                .begin_compute_pass(&wgpu::ComputePassDescriptor {
1816                    label: Some("effects room haze field"),
1817                    timestamp_writes: None,
1818                });
1819            pass.set_pipeline(&self.room_field_pipeline);
1820            pass.set_bind_group(0, &bind, &[]);
1821            pass.dispatch_workgroups(
1822                size[0].div_ceil(4),
1823                size[1].div_ceil(4),
1824                size[2].div_ceil(4),
1825            );
1826        }
1827        self.room_field = Some(RoomField {
1828            key,
1829            _texture: texture,
1830            view,
1831        });
1832    }
1833
1834    pub fn render_volume(
1835        &mut self,
1836        commands: &mut EffectCommands<'_>,
1837        frame: &EffectFrame,
1838        targets: EffectTargets<'_>,
1839        timestamps: VolumeTimestamps<'_>,
1840    ) {
1841        self.ensure_volume(commands.device, &frame.plume);
1842        let look_key = frame.plume.key();
1843        let (full_width, full_height) = self.viewport;
1844        let (left, top, width, height) = volume_bounds(frame, full_width, full_height).unwrap_or((
1845            0,
1846            0,
1847            full_width,
1848            full_height,
1849        ));
1850        let low_left = left / self.scale;
1851        let low_top = top / self.scale;
1852        let low_right = (left + width).div_ceil(self.scale);
1853        let low_bottom = (top + height).div_ceil(self.scale);
1854        let uniform = uniform(commands.device, frame);
1855        let volume_bind = commands
1856            .device
1857            .create_bind_group(&wgpu::BindGroupDescriptor {
1858                label: Some("volume bindings"),
1859                layout: &self.volume_layout,
1860                entries: &[
1861                    wgpu::BindGroupEntry {
1862                        binding: 0,
1863                        resource: uniform.as_entire_binding(),
1864                    },
1865                    wgpu::BindGroupEntry {
1866                        binding: 1,
1867                        resource: wgpu::BindingResource::TextureView(targets.depth),
1868                    },
1869                    wgpu::BindGroupEntry {
1870                        binding: 2,
1871                        resource: wgpu::BindingResource::TextureView(
1872                            targets
1873                                .shadow
1874                                .map_or(&self.empty_shadow_view, |pair| pair.0),
1875                        ),
1876                    },
1877                    wgpu::BindGroupEntry {
1878                        binding: 3,
1879                        resource: targets
1880                            .shadow
1881                            .map_or(&self.empty_shadow_data, |pair| pair.1)
1882                            .as_entire_binding(),
1883                    },
1884                    wgpu::BindGroupEntry {
1885                        binding: 4,
1886                        resource: wgpu::BindingResource::TextureView(
1887                            self.room_field
1888                                .as_ref()
1889                                .map_or(&self.empty_field_view, |field| &field.view),
1890                        ),
1891                    },
1892                    wgpu::BindGroupEntry {
1893                        binding: 5,
1894                        resource: wgpu::BindingResource::Sampler(&self.room_sampler),
1895                    },
1896                ],
1897            });
1898        {
1899            let mut pass = commands
1900                .encoder
1901                .begin_render_pass(&wgpu::RenderPassDescriptor {
1902                    multiview_mask: None,
1903                    label: Some("effects volume quarter resolution"),
1904                    color_attachments: &[
1905                        Some(color_attachment(&self.low_color_view, true)),
1906                        Some(wgpu::RenderPassColorAttachment {
1907                            view: &self.low_depth_view,
1908                            resolve_target: None,
1909                            ops: wgpu::Operations {
1910                                load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
1911                                store: wgpu::StoreOp::Store,
1912                            },
1913                            depth_slice: None,
1914                        }),
1915                    ],
1916                    depth_stencil_attachment: None,
1917                    timestamp_writes: timestamps.march,
1918                    occlusion_query_set: None,
1919                });
1920            let volume_pipeline = self
1921                .cached_volumes
1922                .iter()
1923                .find(|(known, look, _)| *known == self.cuts && *look == look_key)
1924                .map(|(_, _, built)| built)
1925                .expect("the active volume cuts and plume have a pipeline");
1926            crate::viewport::apply(&mut pass, self.low_viewport());
1927            pass.set_pipeline(volume_pipeline);
1928            pass.set_bind_group(0, &volume_bind, &[]);
1929            pass.set_scissor_rect(
1930                low_left,
1931                low_top,
1932                low_right - low_left,
1933                low_bottom - low_top,
1934            );
1935            pass.draw(0..3, 0..1);
1936        }
1937        let room = frame.volume_control[3] > 1.5;
1938        let (blur_writes, upsample_writes) = match (room, timestamps.upsample) {
1939            (true, Some(writes)) => (
1940                Some(wgpu::RenderPassTimestampWrites {
1941                    query_set: writes.query_set,
1942                    beginning_of_pass_write_index: writes.beginning_of_pass_write_index,
1943                    end_of_pass_write_index: None,
1944                }),
1945                Some(wgpu::RenderPassTimestampWrites {
1946                    query_set: writes.query_set,
1947                    beginning_of_pass_write_index: None,
1948                    end_of_pass_write_index: writes.end_of_pass_write_index,
1949                }),
1950            ),
1951            (_, writes) => (None, writes),
1952        };
1953        if room {
1954            let blur_bind = commands
1955                .device
1956                .create_bind_group(&wgpu::BindGroupDescriptor {
1957                    label: Some("room haze blur bindings"),
1958                    layout: &self.upsample_layout,
1959                    entries: &[
1960                        wgpu::BindGroupEntry {
1961                            binding: 0,
1962                            resource: uniform.as_entire_binding(),
1963                        },
1964                        wgpu::BindGroupEntry {
1965                            binding: 1,
1966                            resource: wgpu::BindingResource::TextureView(&self.low_depth_view),
1967                        },
1968                        wgpu::BindGroupEntry {
1969                            binding: 2,
1970                            resource: wgpu::BindingResource::TextureView(&self.low_color_view),
1971                        },
1972                        wgpu::BindGroupEntry {
1973                            binding: 3,
1974                            resource: wgpu::BindingResource::TextureView(&self.low_depth_view),
1975                        },
1976                    ],
1977                });
1978            let mut pass = commands
1979                .encoder
1980                .begin_render_pass(&wgpu::RenderPassDescriptor {
1981                    multiview_mask: None,
1982                    label: Some("effects room haze blur"),
1983                    color_attachments: &[Some(color_attachment(&self.low_blur_view, true))],
1984                    depth_stencil_attachment: None,
1985                    timestamp_writes: blur_writes,
1986                    occlusion_query_set: None,
1987                });
1988            crate::viewport::apply(&mut pass, self.low_viewport());
1989            pass.set_pipeline(&self.room_blur_pipeline);
1990            pass.set_bind_group(0, &blur_bind, &[]);
1991            pass.set_scissor_rect(
1992                low_left,
1993                low_top,
1994                low_right - low_left,
1995                low_bottom - low_top,
1996            );
1997            pass.draw(0..3, 0..1);
1998        }
1999        let low_color = if room {
2000            &self.low_blur_view
2001        } else {
2002            &self.low_color_view
2003        };
2004        let upsample_bind = commands
2005            .device
2006            .create_bind_group(&wgpu::BindGroupDescriptor {
2007                label: Some("volume upsample bindings"),
2008                layout: &self.upsample_layout,
2009                entries: &[
2010                    wgpu::BindGroupEntry {
2011                        binding: 0,
2012                        resource: uniform.as_entire_binding(),
2013                    },
2014                    wgpu::BindGroupEntry {
2015                        binding: 1,
2016                        resource: wgpu::BindingResource::TextureView(targets.depth),
2017                    },
2018                    wgpu::BindGroupEntry {
2019                        binding: 2,
2020                        resource: wgpu::BindingResource::TextureView(low_color),
2021                    },
2022                    wgpu::BindGroupEntry {
2023                        binding: 3,
2024                        resource: wgpu::BindingResource::TextureView(&self.low_depth_view),
2025                    },
2026                ],
2027            });
2028        let mut pass = commands
2029            .encoder
2030            .begin_render_pass(&wgpu::RenderPassDescriptor {
2031                multiview_mask: None,
2032                label: Some("effects depth aware upsample"),
2033                color_attachments: &[Some(color_attachment(targets.hdr, false))],
2034                depth_stencil_attachment: None,
2035                timestamp_writes: upsample_writes,
2036                occlusion_query_set: None,
2037            });
2038        crate::viewport::apply(&mut pass, [full_width, full_height]);
2039        pass.set_pipeline(&self.upsample_pipeline);
2040        pass.set_bind_group(0, &upsample_bind, &[]);
2041        pass.set_scissor_rect(left, top, width, height);
2042        pass.draw(0..3, 0..1);
2043    }
2044
2045    pub fn render_particles(
2046        &self,
2047        commands: &mut EffectCommands<'_>,
2048        frame: &EffectFrame,
2049        targets: EffectTargets<'_>,
2050        particles: &[ParticleInstance],
2051        timestamps: Option<wgpu::RenderPassTimestampWrites<'_>>,
2052    ) {
2053        if particles.is_empty() {
2054            return;
2055        }
2056        let uniform = uniform(commands.device, frame);
2057        let mut ordered = particles.to_vec();
2058        ordered.sort_by(|left, right| {
2059            distance_squared(right.position_size, frame.eye)
2060                .total_cmp(&distance_squared(left.position_size, frame.eye))
2061        });
2062        let instances = storage(commands.device, "particle instances", &ordered);
2063        let bind = commands
2064            .device
2065            .create_bind_group(&wgpu::BindGroupDescriptor {
2066                label: Some("particle bindings"),
2067                layout: &self.particle_layout,
2068                entries: &[
2069                    wgpu::BindGroupEntry {
2070                        binding: 0,
2071                        resource: uniform.as_entire_binding(),
2072                    },
2073                    wgpu::BindGroupEntry {
2074                        binding: 1,
2075                        resource: wgpu::BindingResource::TextureView(targets.depth),
2076                    },
2077                    wgpu::BindGroupEntry {
2078                        binding: 2,
2079                        resource: instances.as_entire_binding(),
2080                    },
2081                    wgpu::BindGroupEntry {
2082                        binding: 3,
2083                        resource: wgpu::BindingResource::TextureView(
2084                            targets
2085                                .shadow
2086                                .map_or(&self.empty_shadow_view, |pair| pair.0),
2087                        ),
2088                    },
2089                    wgpu::BindGroupEntry {
2090                        binding: 4,
2091                        resource: targets
2092                            .shadow
2093                            .map_or(&self.empty_shadow_data, |pair| pair.1)
2094                            .as_entire_binding(),
2095                    },
2096                ],
2097            });
2098        let mut pass = commands
2099            .encoder
2100            .begin_render_pass(&wgpu::RenderPassDescriptor {
2101                multiview_mask: None,
2102                label: Some("effects particles"),
2103                color_attachments: &[Some(color_attachment(targets.hdr, false))],
2104                depth_stencil_attachment: None,
2105                timestamp_writes: timestamps,
2106                occlusion_query_set: None,
2107            });
2108        crate::viewport::apply(&mut pass, [self.viewport.0, self.viewport.1]);
2109        pass.set_pipeline(&self.particle_pipeline);
2110        pass.set_bind_group(0, &bind, &[]);
2111        pass.draw(0..6, 0..particles.len() as u32);
2112    }
2113
2114    pub fn render_creatures(
2115        &self,
2116        commands: &mut EffectCommands<'_>,
2117        frame: &EffectFrame,
2118        targets: EffectTargets<'_>,
2119        mesh: &CreatureMesh,
2120        creatures: &[CreatureInstance],
2121        timestamps: Option<wgpu::RenderPassTimestampWrites<'_>>,
2122    ) {
2123        if creatures.is_empty() || mesh.index_count == 0 {
2124            return;
2125        }
2126        let uniform = uniform(commands.device, frame);
2127        let mut ordered = creatures.to_vec();
2128        ordered.sort_by(|left, right| {
2129            distance_squared(right.position_scale, frame.eye)
2130                .total_cmp(&distance_squared(left.position_scale, frame.eye))
2131        });
2132        let instances = storage(commands.device, "creature instances", &ordered);
2133        let bind = commands
2134            .device
2135            .create_bind_group(&wgpu::BindGroupDescriptor {
2136                label: Some("creature bindings"),
2137                layout: &self.creature_layout,
2138                entries: &[
2139                    wgpu::BindGroupEntry {
2140                        binding: 0,
2141                        resource: uniform.as_entire_binding(),
2142                    },
2143                    wgpu::BindGroupEntry {
2144                        binding: 1,
2145                        resource: instances.as_entire_binding(),
2146                    },
2147                    wgpu::BindGroupEntry {
2148                        binding: 2,
2149                        resource: mesh.vertices.as_entire_binding(),
2150                    },
2151                    wgpu::BindGroupEntry {
2152                        binding: 3,
2153                        resource: wgpu::BindingResource::TextureView(targets.depth),
2154                    },
2155                ],
2156            });
2157        let mut pass = commands
2158            .encoder
2159            .begin_render_pass(&wgpu::RenderPassDescriptor {
2160                multiview_mask: None,
2161                label: Some("effects creatures"),
2162                color_attachments: &[Some(color_attachment(targets.hdr, false))],
2163                depth_stencil_attachment: None,
2164                timestamp_writes: timestamps,
2165                occlusion_query_set: None,
2166            });
2167        crate::viewport::apply(&mut pass, [self.viewport.0, self.viewport.1]);
2168        pass.set_pipeline(&self.creature_pipeline);
2169        pass.set_bind_group(0, &bind, &[]);
2170        pass.set_index_buffer(mesh.indices.slice(..), wgpu::IndexFormat::Uint32);
2171        pass.draw_indexed(0..mesh.index_count, 0, 0..creatures.len() as u32);
2172    }
2173}
2174
2175#[cfg(test)]
2176mod tests {
2177    use super::*;
2178    use pfx_gpu::{Gpu, GpuProfiler};
2179
2180    const LO: [f32; 3] = [-1.0, 0.0, -1.0];
2181    const HI: [f32; 3] = [1.0, 1.0, 1.0];
2182
2183    #[test]
2184    fn haze_from_zero_amount_has_no_density() {
2185        let haze = RoomHaze::from_amount(LO, HI, 0.0);
2186        assert_eq!([haze.fog, haze.smoke, haze.mist, haze.floor], [0.0; 4]);
2187        let mut frame = EffectFrame::new(8, 8, 0.0, 0);
2188        frame.set_room_haze(haze);
2189        assert_eq!(frame.volume_control[0], 0.0);
2190        assert_eq!(frame.volume_lo[3], 0.0);
2191        assert_eq!(frame.volume_hi[3], 0.0);
2192        assert_eq!(frame.volume_color[3], 0.0);
2193        assert_eq!(RoomHaze::from_amount(LO, HI, -1.0), haze);
2194        assert_eq!(RoomHaze::from_amount(LO, HI, f32::NAN), haze);
2195    }
2196
2197    #[test]
2198    fn haze_from_amount_takes_the_bounds_and_neutral_defaults() {
2199        let haze = RoomHaze::from_amount(LO, HI, 0.3);
2200        let neutral = RoomHaze::new(LO, HI);
2201        assert!((haze.fog - 0.05).abs() < 1.0e-6);
2202        assert!((haze.smoke - 6.0).abs() < 1.0e-5);
2203        assert!((haze.floor - 0.6).abs() < 1.0e-6);
2204        assert!(haze.mist > 0.0 && haze.mist < 0.1);
2205        assert_eq!(
2206            (haze.phase, haze.back, haze.reach, haze.lo, haze.hi),
2207            (
2208                neutral.phase,
2209                neutral.back,
2210                neutral.reach,
2211                neutral.lo,
2212                neutral.hi
2213            )
2214        );
2215        assert_eq!(haze.gold, [1.0; 3]);
2216        assert_eq!(haze.ambient, [HAZE_AMBIENT_GREY; 3]);
2217    }
2218
2219    #[test]
2220    fn haze_from_amount_grows_and_clamps_at_one() {
2221        let low = RoomHaze::from_amount(LO, HI, 0.3);
2222        let full = RoomHaze::from_amount(LO, HI, 1.0);
2223        assert!(full.fog > low.fog && full.smoke > low.smoke);
2224        assert!(full.mist > low.mist && full.floor > low.floor);
2225        assert_eq!(RoomHaze::from_amount(LO, HI, 7.0), full);
2226    }
2227
2228    #[test]
2229    fn plume_depth_follows_box_scale() {
2230        let small = plume_optical_depth(1.8, 0.1, 0.1, 9.0);
2231        let large = plume_optical_depth(1.8, 1.0, 1.0, 9.0);
2232        assert!((small - large).abs() < 1.0e-5);
2233        assert!(1.0 - (-small).exp() > 0.9);
2234    }
2235
2236    #[test]
2237    fn room_fog_follows_beer_lambert() {
2238        let through = (0..64).fold(1.0_f32, |through, _| {
2239            through * (-0.05_f32 * 2.0 / 64.0).exp()
2240        });
2241        assert!((fog_transmittance(0.05, 2.0, 3.0) - through).abs() < 1e-6);
2242        assert!((fog_transmittance(0.05, 9.0, 3.0) - (-0.15_f32).exp()).abs() < 1e-6);
2243        assert_eq!(fog_transmittance(0.0, 3.0, 3.0), 1.0);
2244        assert_eq!(fog_transmittance(0.05, 0.0, 3.0), 1.0);
2245    }
2246
2247    #[test]
2248    fn room_in_scatter_integrates_attenuated_light() {
2249        let steps = 4096;
2250        let step = 1.3 / steps as f32;
2251        let marched: f32 = (0..steps)
2252            .map(|i| 0.07 * (-0.05 * (i as f32 + 0.5) * step).exp() * 2.9 * step)
2253            .sum();
2254        assert!((fog_in_scatter(0.07, 0.05, 2.9, 1.3) - marched).abs() < 1e-5);
2255        assert!((fog_in_scatter(0.07, 0.0, 2.9, 1.3) - 0.07 * 2.9 * 1.3).abs() < 1e-6);
2256    }
2257
2258    #[test]
2259    fn henyey_greenstein_integrates_to_one() {
2260        let steps = 20_000;
2261        for g in [-0.55, 0.0, 0.35] {
2262            let total: f32 = (0..steps)
2263                .map(|i| {
2264                    let cosine = -1.0 + 2.0 * (i as f32 + 0.5) / steps as f32;
2265                    henyey_greenstein(cosine, g) * 2.0 * std::f32::consts::PI * 2.0 / steps as f32
2266                })
2267                .sum();
2268            assert!((total - 1.0).abs() < 1e-3, "g {g}: {total}");
2269        }
2270    }
2271
2272    fn fixture_motes() -> RoomMotes {
2273        RoomMotes::new(
2274            [-0.6, 0.0, -0.4],
2275            [0.7, 0.5, 0.5],
2276            0.04,
2277            0.2,
2278            0.7,
2279            [1.0, 0.9, 0.7],
2280        )
2281    }
2282
2283    #[test]
2284    fn motes_repeat_for_seed_and_time() {
2285        let motes = fixture_motes();
2286        let first = room_motes(7, 2.5, motes);
2287        assert!(!first.is_empty());
2288        assert_eq!(first, room_motes(7, 2.5, motes));
2289        assert_ne!(first, room_motes(8, 2.5, motes));
2290        assert_ne!(first, room_motes(7, 3.5, motes));
2291    }
2292
2293    #[test]
2294    fn motes_fill_their_share_of_the_cells() {
2295        let motes = fixture_motes();
2296        let cells = (0..3)
2297            .map(|axis| ((motes.hi[axis] - motes.lo[axis]) / motes.cell).ceil())
2298            .product::<f32>();
2299        let share = room_motes(0, 0.0, motes).len() as f32 / cells;
2300        assert!((share - 0.2).abs() < 0.02, "{share}");
2301        for mote in room_motes(0, 0.0, motes) {
2302            assert!((0.00025..=0.00075).contains(&mote.position_size[3]));
2303        }
2304    }
2305
2306    #[test]
2307    fn plume_footprint_overlaps_its_analytic_shape() {
2308        let plume = Plume {
2309            drift: [[-0.8, -0.1], [0.0, 0.0]],
2310            sway: [[15.0, 1.0, 0.01, 0.4, 5.0], [11.0, 0.8, 0.01, 0.4, 4.0]],
2311            ..Plume::default()
2312        };
2313        let radius = plume.radius;
2314        let weight = plume.depth_per_width / (radius * 6.0);
2315        let mut reference = 0;
2316        let mut overlap = 0;
2317        let mut displaced = 0;
2318        for yi in 0..201 {
2319            let rise = yi as f32 * 0.001;
2320            let edge = |a: f32, b: f32, x: f32| {
2321                let t = ((x - a) / (b - a)).clamp(0.0, 1.0);
2322                t * t * (3.0 - 2.0 * t)
2323            };
2324            let fade =
2325                edge(0.0, plume.fade[0], rise) * (1.0 - edge(plume.fade[1], plume.fade[2], rise));
2326            let drift = plume.drift[0];
2327            let sway = plume.sway[0];
2328            let axis = drift[0] * rise * rise
2329                + drift[1] * rise
2330                + (rise * sway[0]).sin() * sway[2] * (sway[3] + rise * sway[4]);
2331            let width = radius * (plume.spread[0] + rise * plume.spread[1]);
2332            for xi in 0..161 {
2333                let x = (xi as f32 - 80.0) * 0.001;
2334                let core = (-((x - axis) / width).powi(2)).exp();
2335                let analytic = 1.0 - (-1.8 * core * fade * weight * 0.012).exp();
2336                let engine = 1.0
2337                    - (-plume_optical_depth(
2338                        1.8 * core * fade,
2339                        0.012,
2340                        radius * 6.0,
2341                        plume.depth_per_width,
2342                    ))
2343                    .exp();
2344                let shifted = 1.0
2345                    - (-plume_optical_depth(
2346                        1.8 * (-((x - axis - 0.03) / width).powi(2)).exp() * fade,
2347                        0.012,
2348                        radius * 6.0,
2349                        plume.depth_per_width,
2350                    ))
2351                    .exp();
2352                if analytic > 0.05 {
2353                    reference += 1;
2354                    overlap += usize::from(engine > 0.05);
2355                    displaced += usize::from(shifted > 0.05);
2356                }
2357            }
2358        }
2359        assert!(reference > 400, "{reference}");
2360        assert!(overlap as f32 / reference as f32 >= 0.8);
2361        assert!(displaced as f32 / (reference as f32) < 0.8);
2362    }
2363
2364    #[test]
2365    fn particle_and_creature_packing() {
2366        let specks = [Speck {
2367            pos: [1.0, 2.0, 3.0],
2368            vel: [0.0, 1.0, 0.0],
2369            age: 0.5,
2370            life: 2.0,
2371            seed: 42,
2372        }];
2373        let petals = pack_particles(&specks, ParticleKind::Petal, 1.0, [1.0; 3]);
2374        assert_eq!(
2375            petals,
2376            pack_particles(&specks, ParticleKind::Petal, 1.0, [1.0; 3])
2377        );
2378        assert_eq!(petals[0].position_size[..3], specks[0].pos);
2379        assert_ne!(
2380            petals[0].color_rotation[3],
2381            pack_particles(&specks, ParticleKind::Petal, 2.0, [1.0; 3])[0].color_rotation[3]
2382        );
2383        assert_eq!(std::mem::size_of::<ParticleInstance>(), 64);
2384        let boids = [Boid {
2385            pos: [2.0, 3.0, 4.0],
2386            vel: [0.0, 0.0, -2.0],
2387        }];
2388        let birds = pack_creatures(&boids, CreatureKind::Bird, 0.3, 4);
2389        assert_eq!(birds, pack_creatures(&boids, CreatureKind::Bird, 0.3, 4));
2390        assert_eq!(birds[0].position_scale, [2.0, 3.0, 4.0, 0.3]);
2391        assert_eq!(birds[0].forward[..3], [0.0, 0.0, -1.0]);
2392        assert_eq!(std::mem::size_of::<CreatureInstance>(), 80);
2393    }
2394
2395    #[test]
2396    fn shaders_parse() {
2397        for source in [
2398            PARTICLE_WGSL,
2399            CREATURE_WGSL,
2400            VOLUME_WGSL,
2401            UPSAMPLE_WGSL,
2402            ROOM_BLUR_WGSL,
2403            ROOM_FIELD_WGSL,
2404        ] {
2405            naga::front::wgsl::parse_str(source).unwrap();
2406        }
2407    }
2408
2409    #[test]
2410    fn bilateral_weights_respect_depth() {
2411        let weights = upsample_weights(2.0, [2.0, 100.0, 2.0, 100.0], [0.5, 0.5], 1.0);
2412        assert!(weights[0] > 0.49 && weights[2] > 0.49);
2413        assert!(weights[1] < 1e-6 && weights[3] < 1e-6);
2414        assert!((weights.iter().sum::<f32>() - 1.0).abs() < 1e-6);
2415        let flat = upsample_weights(2.0, [2.0; 4], [0.25, 0.75], 1.0);
2416        assert!((flat[0] - 0.1875).abs() < 1e-6);
2417    }
2418
2419    #[test]
2420    #[ignore = "needs a GPU; run under pgpu"]
2421    fn room_haze_transmittance_matches_analytic_fog() {
2422        let gpu = pollster::block_on(Gpu::headless()).unwrap();
2423        let (width, height) = (256, 128);
2424        let mut effects = Effects::new(&gpu.device, width, height, 4).unwrap();
2425        for (depth, ambient) in [(2.0_f32, 0.3_f32), (5.0, 0.3), (1.2, 0.0)] {
2426            let hdr = gpu.offscreen(width, height, HDR_FORMAT).unwrap();
2427            let scene_depth = gpu.device.create_texture(&wgpu::TextureDescriptor {
2428                label: Some("room haze test depth"),
2429                size: wgpu::Extent3d {
2430                    width,
2431                    height,
2432                    depth_or_array_layers: 1,
2433                },
2434                mip_level_count: 1,
2435                sample_count: 1,
2436                dimension: wgpu::TextureDimension::D2,
2437                format: SCENE_DEPTH_FORMAT,
2438                usage: wgpu::TextureUsages::RENDER_ATTACHMENT
2439                    | wgpu::TextureUsages::TEXTURE_BINDING,
2440                view_formats: &[],
2441            });
2442            let depth_view = scene_depth.create_view(&Default::default());
2443            let mut encoder = gpu.device.create_command_encoder(&Default::default());
2444            {
2445                let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2446                    multiview_mask: None,
2447                    label: Some("room haze test clear"),
2448                    color_attachments: &[
2449                        Some(color_attachment(&hdr.view, true)),
2450                        Some(wgpu::RenderPassColorAttachment {
2451                            view: &depth_view,
2452                            resolve_target: None,
2453                            ops: wgpu::Operations {
2454                                load: wgpu::LoadOp::Clear(wgpu::Color {
2455                                    r: f64::from(depth),
2456                                    g: 0.0,
2457                                    b: 0.0,
2458                                    a: 0.0,
2459                                }),
2460                                store: wgpu::StoreOp::Store,
2461                            },
2462                            depth_slice: None,
2463                        }),
2464                    ],
2465                    depth_stencil_attachment: None,
2466                    timestamp_writes: None,
2467                    occlusion_query_set: None,
2468                });
2469            }
2470            let mut frame = EffectFrame::new(width, height, 1.0, 17);
2471            frame.lens = [0.001, 0.001, 0.0, 0.0];
2472            frame.sun_color = [1.0, 1.0, 1.0, 0.0];
2473            let haze = RoomHaze {
2474                fog: 0.05,
2475                smoke: 0.0,
2476                mist: 0.0,
2477                floor: 0.0,
2478                phase: 0.35,
2479                back: 0.7,
2480                gold: [1.0; 3],
2481                ambient: [ambient; 3],
2482                reach: 3.0,
2483                unmapped: 1.0,
2484                lo: [-1.0, -1.0, -1.0],
2485                hi: [1.0, 1.0, 1.0],
2486                seed: 0,
2487            };
2488            frame.set_room_haze(haze);
2489            effects.prepare_room(
2490                &mut EffectCommands {
2491                    device: &gpu.device,
2492                    encoder: &mut encoder,
2493                },
2494                &haze,
2495            );
2496            frame.eye = [0.0, 1.0, 3.0, 0.0];
2497            effects.render_volume(
2498                &mut EffectCommands {
2499                    device: &gpu.device,
2500                    encoder: &mut encoder,
2501                },
2502                &frame,
2503                EffectTargets {
2504                    hdr: &hdr.view,
2505                    depth: &depth_view,
2506                    shadow: None,
2507                },
2508                VolumeTimestamps {
2509                    march: None,
2510                    upsample: None,
2511                },
2512            );
2513            gpu.queue.submit(Some(encoder.finish()));
2514            let pixels = gpu.readback_rgba16(&hdr).unwrap();
2515            let at = ((height / 2 * width + width / 2) * 4) as usize;
2516            let value = |channel: usize| half::f16::from_bits(pixels[at + channel]).to_f32();
2517            let span = depth.min(3.0);
2518            let alpha = 1.0 - fog_transmittance(0.05, span, 3.0);
2519            assert!(
2520                (value(3) - alpha).abs() < 2e-3,
2521                "depth {depth}: alpha {} against {alpha}",
2522                value(3)
2523            );
2524            let flat = fog_in_scatter(0.05, 0.05, ambient, span);
2525            assert!(
2526                value(0) >= 0.5 * flat - 1e-3 && value(0) <= 1.5 * flat + 1e-3,
2527                "depth {depth}: {} against {flat}",
2528                value(0)
2529            );
2530        }
2531    }
2532
2533    #[test]
2534    #[ignore = "needs a GPU; run under pgpu"]
2535    fn four_k_effects() {
2536        let gpu = pollster::block_on(Gpu::headless()).unwrap();
2537        let width = 3840;
2538        let height = 2160;
2539        let hdr = gpu.offscreen(width, height, HDR_FORMAT).unwrap();
2540        let depth = gpu.device.create_texture(&wgpu::TextureDescriptor {
2541            label: Some("effects test depth"),
2542            size: wgpu::Extent3d {
2543                width,
2544                height,
2545                depth_or_array_layers: 1,
2546            },
2547            mip_level_count: 1,
2548            sample_count: 1,
2549            dimension: wgpu::TextureDimension::D2,
2550            format: SCENE_DEPTH_FORMAT,
2551            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
2552            view_formats: &[],
2553        });
2554        let depth_view = depth.create_view(&Default::default());
2555        let mut clear = gpu.device.create_command_encoder(&Default::default());
2556        {
2557            let _pass = clear.begin_render_pass(&wgpu::RenderPassDescriptor {
2558                multiview_mask: None,
2559                label: Some("effects test clear"),
2560                color_attachments: &[
2561                    Some(color_attachment(&hdr.view, true)),
2562                    Some(wgpu::RenderPassColorAttachment {
2563                        view: &depth_view,
2564                        resolve_target: None,
2565                        ops: wgpu::Operations {
2566                            load: wgpu::LoadOp::Clear(wgpu::Color {
2567                                r: 100.0,
2568                                g: 0.0,
2569                                b: 0.0,
2570                                a: 0.0,
2571                            }),
2572                            store: wgpu::StoreOp::Store,
2573                        },
2574                        depth_slice: None,
2575                    }),
2576                ],
2577                depth_stencil_attachment: None,
2578                timestamp_writes: None,
2579                occlusion_query_set: None,
2580            });
2581        }
2582        gpu.queue.submit(Some(clear.finish()));
2583        let mut effects = Effects::new(&gpu.device, width, height, 4).unwrap();
2584        let mut frame = EffectFrame::new(width, height, 1.0, 17);
2585        frame.volume_lo = [-0.5, -0.05, -0.5, 0.0];
2586        frame.volume_hi = [0.5, 0.15, 0.5, 0.0];
2587        let a = -100.0 / 99.9;
2588        let b = -10.0 / 99.9;
2589        frame.view_proj = [
2590            [1.0, 0.0, 0.0, 0.0],
2591            [0.0, 1.0, 0.0, 0.0],
2592            [0.0, 0.0, a, -1.0],
2593            [0.0, 0.0, -3.0 * a + b, 3.0],
2594        ];
2595        let petals: Vec<_> = (0..10_000)
2596            .map(|index| {
2597                let x = 0.35 + (index % 100) as f32 * 0.002;
2598                let y = -0.2 + (index / 100) as f32 * 0.004;
2599                Speck {
2600                    pos: [x, y, 0.0],
2601                    vel: [0.0, -0.2, 0.0],
2602                    age: 0.5,
2603                    life: 2.0,
2604                    seed: index,
2605                }
2606            })
2607            .collect();
2608        let petals = pack_particles(&petals, ParticleKind::Petal, 1.0, [1.0; 3]);
2609        let boids: Vec<_> = (0..200)
2610            .map(|index| Boid {
2611                pos: [
2612                    -0.6 + (index % 20) as f32 * 0.01,
2613                    (index / 20) as f32 * 0.01,
2614                    0.0,
2615                ],
2616                vel: [0.0, 0.0, -1.0],
2617            })
2618            .collect();
2619        let birds = pack_creatures(&boids, CreatureKind::Bird, 0.1, 9);
2620        let mesh = CreatureMesh::new(
2621            &gpu.device,
2622            &[
2623                CreatureVertex {
2624                    position: [-1.0, 0.0, 0.0, 1.0],
2625                },
2626                CreatureVertex {
2627                    position: [0.0, 0.0, -0.3, 1.0],
2628                },
2629                CreatureVertex {
2630                    position: [1.0, 0.0, 0.0, 1.0],
2631                },
2632                CreatureVertex {
2633                    position: [0.0, 0.0, 0.3, 1.0],
2634                },
2635            ],
2636            &[0, 1, 2, 0, 2, 3],
2637        );
2638        let mut profiler = GpuProfiler::new(&gpu.device, &gpu.queue);
2639        let mut encoder = gpu.device.create_command_encoder(&Default::default());
2640        let march = profiler.pass("volume march");
2641        let upsample = profiler.pass("volume upsample");
2642        let creatures = profiler.pass("creatures");
2643        let particles = profiler.pass("particles");
2644        let targets = EffectTargets {
2645            hdr: &hdr.view,
2646            depth: &depth_view,
2647            shadow: None,
2648        };
2649        let mut commands = EffectCommands {
2650            device: &gpu.device,
2651            encoder: &mut encoder,
2652        };
2653        effects.render_volume(
2654            &mut commands,
2655            &frame,
2656            targets,
2657            VolumeTimestamps {
2658                march: profiler.render_writes(march),
2659                upsample: profiler.render_writes(upsample),
2660            },
2661        );
2662        effects.render_creatures(
2663            &mut commands,
2664            &frame,
2665            targets,
2666            &mesh,
2667            &birds,
2668            profiler.render_writes(creatures),
2669        );
2670        effects.render_particles(
2671            &mut commands,
2672            &frame,
2673            targets,
2674            &petals,
2675            profiler.render_writes(particles),
2676        );
2677        let slot = profiler.finish(&mut encoder);
2678        gpu.queue.submit(Some(encoder.finish()));
2679        if let Some(slot) = slot {
2680            profiler.submitted(slot);
2681            gpu.device
2682                .poll(wgpu::PollType::wait_indefinitely())
2683                .unwrap();
2684            for timing in profiler.collect(&gpu.device).into_iter().flatten() {
2685                eprintln!("{}: {:.3} ms", timing.label, timing.milliseconds);
2686            }
2687        }
2688        let pixels = gpu.readback_rgba16(&hdr).unwrap();
2689        let lit = |x: usize, y: usize| -> bool {
2690            let index = (y * width as usize + x) * 4;
2691            pixels[index] != 0 || pixels[index + 1] != 0 || pixels[index + 2] != 0
2692        };
2693        assert!(lit(1920, 1080));
2694        assert!(lit(2200, 1080));
2695        assert!(lit(1600, 1050));
2696        assert!(!lit(20, 20));
2697    }
2698
2699    #[test]
2700    fn steam_clock_scales_only_time() {
2701        let base = EffectFrame::new(8, 8, 3.0, 5);
2702        let mut same = base;
2703        same.scale_time(1.0);
2704        assert_eq!(bytemuck::bytes_of(&same), bytemuck::bytes_of(&base));
2705        let mut half = base;
2706        half.scale_time(0.5);
2707        assert_eq!(half.extent, [8.0, 8.0, 1.5, f32::from_bits(5)]);
2708        let mut frozen = base;
2709        frozen.scale_time(-2.0);
2710        assert_eq!(frozen.extent[2], 0.0);
2711    }
2712
2713    fn steam_pixels(gpu: &Gpu, effects: &mut Effects, frame: &EffectFrame) -> Vec<u16> {
2714        let (width, height) = (256, 128);
2715        let hdr = gpu.offscreen(width, height, HDR_FORMAT).unwrap();
2716        let depth = gpu.device.create_texture(&wgpu::TextureDescriptor {
2717            label: Some("steam clock test depth"),
2718            size: wgpu::Extent3d {
2719                width,
2720                height,
2721                depth_or_array_layers: 1,
2722            },
2723            mip_level_count: 1,
2724            sample_count: 1,
2725            dimension: wgpu::TextureDimension::D2,
2726            format: SCENE_DEPTH_FORMAT,
2727            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
2728            view_formats: &[],
2729        });
2730        let depth_view = depth.create_view(&Default::default());
2731        let mut encoder = gpu.device.create_command_encoder(&Default::default());
2732        {
2733            let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2734                multiview_mask: None,
2735                label: Some("steam clock test clear"),
2736                color_attachments: &[
2737                    Some(color_attachment(&hdr.view, true)),
2738                    Some(wgpu::RenderPassColorAttachment {
2739                        view: &depth_view,
2740                        resolve_target: None,
2741                        ops: wgpu::Operations {
2742                            load: wgpu::LoadOp::Clear(wgpu::Color {
2743                                r: 100.0,
2744                                g: 0.0,
2745                                b: 0.0,
2746                                a: 0.0,
2747                            }),
2748                            store: wgpu::StoreOp::Store,
2749                        },
2750                        depth_slice: None,
2751                    }),
2752                ],
2753                depth_stencil_attachment: None,
2754                timestamp_writes: None,
2755                occlusion_query_set: None,
2756            });
2757        }
2758        effects.render_volume(
2759            &mut EffectCommands {
2760                device: &gpu.device,
2761                encoder: &mut encoder,
2762            },
2763            frame,
2764            EffectTargets {
2765                hdr: &hdr.view,
2766                depth: &depth_view,
2767                shadow: None,
2768            },
2769            VolumeTimestamps {
2770                march: None,
2771                upsample: None,
2772            },
2773        );
2774        gpu.queue.submit(Some(encoder.finish()));
2775        gpu.readback_rgba16(&hdr).unwrap()
2776    }
2777
2778    fn test_plume() -> Plume {
2779        Plume {
2780            radius: 0.15,
2781            ..Plume::at([0.0, -0.05, 0.0])
2782        }
2783    }
2784
2785    fn steam_frame(time: f32) -> EffectFrame {
2786        let mut frame = EffectFrame::new(256, 128, time, 17);
2787        frame.set_volume(
2788            VolumeKind::Plume(test_plume()),
2789            [-0.5, -0.05, -0.5],
2790            [0.5, 0.15, 0.5],
2791            [0.82, 0.85, 0.88],
2792            1.6,
2793            0.25,
2794        );
2795        let a = -100.0 / 99.9;
2796        let b = -10.0 / 99.9;
2797        frame.view_proj = [
2798            [1.0, 0.0, 0.0, 0.0],
2799            [0.0, 1.0, 0.0, 0.0],
2800            [0.0, 0.0, a, -1.0],
2801            [0.0, 0.0, -3.0 * a + b, 3.0],
2802        ];
2803        frame
2804    }
2805
2806    #[test]
2807    #[ignore = "needs a GPU; run under pgpu"]
2808    fn the_steam_at_a_given_time_is_the_same_pixels_under_every_cut() {
2809        let gpu = pollster::block_on(Gpu::headless()).unwrap();
2810        let mut effects = Effects::new(&gpu.device, 256, 128, 4).unwrap();
2811        let cuts = [
2812            VolumeCuts::FULL,
2813            VolumeCuts::default(),
2814            VolumeCuts {
2815                steps: 6,
2816                warp_octaves: 1,
2817                octaves: 1,
2818                early_out: true,
2819            },
2820        ];
2821        for cut in cuts {
2822            effects.set_volume_cuts(&gpu.device, cut);
2823            let first = steam_pixels(&gpu, &mut effects, &steam_frame(2.0));
2824            let again = steam_pixels(&gpu, &mut effects, &steam_frame(2.0));
2825            let later = steam_pixels(&gpu, &mut effects, &steam_frame(2.5));
2826            assert!(first.iter().any(|v| *v != 0), "{cut:?} draws nothing");
2827            assert_eq!(first, again, "{cut:?}");
2828            assert_ne!(first, later, "{cut:?}");
2829        }
2830        effects.set_volume_cuts(&gpu.device, VolumeCuts::FULL);
2831        let reference = steam_pixels(&gpu, &mut effects, &steam_frame(2.0));
2832        let mut fresh = Effects::new(&gpu.device, 256, 128, 4).unwrap();
2833        assert_eq!(
2834            reference,
2835            steam_pixels(&gpu, &mut fresh, &steam_frame(2.0)).as_slice()
2836        );
2837    }
2838
2839    #[test]
2840    #[ignore = "needs a GPU; run under pgpu"]
2841    fn the_early_out_leaves_the_steam_byte_identical() {
2842        let gpu = pollster::block_on(Gpu::headless()).unwrap();
2843        let mut effects = Effects::new(&gpu.device, 256, 128, 4).unwrap();
2844        effects.set_volume_cuts(&gpu.device, VolumeCuts::FULL);
2845        let today = steam_pixels(&gpu, &mut effects, &steam_frame(2.0));
2846        effects.set_volume_cuts(
2847            &gpu.device,
2848            VolumeCuts {
2849                early_out: true,
2850                ..VolumeCuts::FULL
2851            },
2852        );
2853        assert_eq!(today, steam_pixels(&gpu, &mut effects, &steam_frame(2.0)));
2854    }
2855
2856    #[test]
2857    #[ignore = "needs a GPU; run under pgpu"]
2858    fn steam_at_half_scale_equals_steam_at_half_the_time() {
2859        let gpu = pollster::block_on(Gpu::headless()).unwrap();
2860        let (width, height) = (256, 128);
2861        let mut effects = Effects::new(&gpu.device, width, height, 4).unwrap();
2862        let frame_at = |time: f32, scale: f32| {
2863            let mut frame = EffectFrame::new(width, height, time, 17);
2864            frame.set_volume(
2865                VolumeKind::Plume(test_plume()),
2866                [-0.5, -0.05, -0.5],
2867                [0.5, 0.15, 0.5],
2868                [0.82, 0.85, 0.88],
2869                1.6,
2870                0.25,
2871            );
2872            let a = -100.0 / 99.9;
2873            let b = -10.0 / 99.9;
2874            frame.view_proj = [
2875                [1.0, 0.0, 0.0, 0.0],
2876                [0.0, 1.0, 0.0, 0.0],
2877                [0.0, 0.0, a, -1.0],
2878                [0.0, 0.0, -3.0 * a + b, 3.0],
2879            ];
2880            frame.scale_time(scale);
2881            frame
2882        };
2883        let halved = steam_pixels(&gpu, &mut effects, &frame_at(2.0, 0.5));
2884        let earlier = steam_pixels(&gpu, &mut effects, &frame_at(1.0, 1.0));
2885        let later = steam_pixels(&gpu, &mut effects, &frame_at(2.0, 1.0));
2886        assert!(halved.iter().any(|v| *v != 0), "the steam draws nothing");
2887        assert_eq!(halved, earlier);
2888        assert_ne!(halved, later);
2889    }
2890}