Skip to main content

pfx_live/
canopy.rs

1use bytemuck::{Pod, Zeroable};
2use pfx_geom::tree::{Card, CardLod, Tree, tree_sway};
3use pfx_gpu::wgpu;
4use pfx_physics::{Emitter, ParticleKind};
5use wgpu::util::DeviceExt;
6
7pub const DEFAULT_RESOLUTION: u32 = 1024;
8pub const DEFAULT_PROCEDURAL_STRENGTH: f32 = 1.0;
9
10#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
11pub enum CanopyMode {
12    #[default]
13    Cookie,
14    Procedural,
15}
16
17#[derive(Clone, Copy, Debug, PartialEq)]
18pub struct Gobo {
19    pub gust: [f32; 5],
20    pub sway: [[f32; 4]; 2],
21    pub branch: [f32; 4],
22    pub clusters: [f32; 5],
23    pub leaves: [f32; 6],
24    pub weight: f32,
25}
26
27impl Gobo {
28    pub fn validate(&self) -> Result<(), String> {
29        let all = self
30            .gust
31            .iter()
32            .chain(self.sway.iter().flatten())
33            .chain(&self.branch)
34            .chain(&self.clusters)
35            .chain(&self.leaves)
36            .chain(std::iter::once(&self.weight));
37        if all.clone().any(|value| !value.is_finite()) {
38            return Err("canopy gobo values must be finite".into());
39        }
40        if self.branch[0] == self.branch[1]
41            || self.clusters[3] == self.clusters[4]
42            || self.leaves[1] == self.leaves[2]
43        {
44            return Err("canopy gobo smoothstep edges must differ".into());
45        }
46        if self.weight <= 0.0 {
47            return Err("canopy gobo weight must be positive".into());
48        }
49        Ok(())
50    }
51}
52
53impl Default for Gobo {
54    fn default() -> Self {
55        Self {
56            gust: [0.2, 0.6, 0.35, 1.0, 0.4],
57            sway: [[1.0, 8.0, 0.005, 0.01], [0.8, 6.0, 0.004, 0.004]],
58            branch: [-0.5, 0.2, 0.8, 0.6],
59            clusters: [3.0, 4.0, 2.0, 0.3, 0.6],
60            leaves: [16.0, 0.35, 0.55, 1.2, 1.0, 0.08],
61            weight: 1.0,
62        }
63    }
64}
65
66#[derive(Clone, Copy, Debug, PartialEq)]
67pub struct CardColors {
68    pub tint_g: [f32; 3],
69    pub tint_b: [f32; 3],
70    pub glow: [f32; 3],
71}
72
73impl Default for CardColors {
74    fn default() -> Self {
75        Self {
76            tint_g: [0.9, 0.9, 0.85],
77            tint_b: [0.8, 0.85, 0.7],
78            glow: [0.8, 0.8, 0.6],
79        }
80    }
81}
82
83#[derive(Clone, Copy, Debug)]
84pub struct CanopyFrame {
85    pub origin: [f32; 3],
86    pub time: f32,
87    pub strength: f32,
88    pub direction: [f32; 3],
89    pub sun: [f32; 3],
90    pub receiver_y: f32,
91    pub angular_radius: f32,
92}
93
94pub const CANOPY_LIGHT_HEAD: &str = r#"
95struct CanopyParams {
96    center_extent: vec4f,
97    sun_receiver: vec4f,
98    wind: vec4f,
99    direction: vec4f,
100    projected_extent: vec4f,
101    mode_sharpness: vec4f,
102}
103@group(3) @binding(9) var canopy_cookie: texture_2d<f32>;
104@group(3) @binding(10) var canopy_sampler: sampler;
105@group(3) @binding(11) var<uniform> canopy_params: CanopyParams;
106fn canopy_hash(p: vec2f) -> f32 {
107    let q = fract(p * vec2f(123.34, 456.21));
108    return fract((q.x + 45.32) * (q.y + 45.32) * 34.23 + q.x * 13.7);
109}
110fn canopy_noise(p: vec2f) -> f32 {
111    let i = floor(p);
112    let f = fract(p);
113    let u = f * f * (3.0 - 2.0 * f);
114    return mix(mix(canopy_hash(i), canopy_hash(i + vec2f(1.0, 0.0)), u.x), mix(canopy_hash(i + vec2f(0.0, 1.0)), canopy_hash(i + vec2f(1.0, 1.0)), u.x), u.y);
115}
116fn canopy_fbm(p: vec2f) -> f32 {
117    return canopy_noise(p) * 0.55 + canopy_noise(p * 2.13 + 3.1) * 0.28 + canopy_noise(p * 4.37 + 7.7) * 0.17;
118}
119"#;
120
121pub const CANOPY_LIGHT_TAIL: &str = r#"fn canopy_light(world_position: vec3f) -> f32 {
122    let sun_input = canopy_params.sun_receiver.xyz;
123    let sun = select(vec3f(0.0, 1.0, 0.0), sun_input / max(length(sun_input), 1e-6), length(sun_input) > 1e-6);
124    if (canopy_params.mode_sharpness.x > 0.5) {
125        let origin = vec3f(canopy_params.center_extent.x, canopy_params.wind.z, canopy_params.center_extent.y);
126        let horizontal = vec3f(-sun.z, 0.0, sun.x);
127        let gobo_u = horizontal / max(length(horizontal), 1e-6);
128        let gobo_v = cross(gobo_u, sun);
129        let q = vec2f(dot(world_position - origin, gobo_u), dot(world_position - origin, gobo_v));
130        let light = procedural_canopy(q, canopy_params.wind.x);
131        let scale = canopy_params.mode_sharpness.z;
132        if (scale == 1.0) { return light; }
133        return 1.0 - (1.0 - light) * scale;
134    }
135    let reference = select(vec3f(0.0, 1.0, 0.0), vec3f(0.0, 0.0, 1.0), abs(sun.y) > 0.99);
136    let axis_u = normalize(cross(sun, reference));
137    let axis_v = cross(axis_u, sun);
138    let projected = vec2f(dot(world_position, axis_u), dot(world_position, axis_v));
139    let uv = (projected - canopy_params.projected_extent.xy) / canopy_params.projected_extent.zw + vec2f(0.5);
140    if (any(uv < vec2f(0.0)) || any(uv > vec2f(1.0))) { return 1.0; }
141    let texel = 1.0 / vec2f(textureDimensions(canopy_cookie));
142    let crown = vec3f(canopy_params.center_extent.x, canopy_params.wind.w, canopy_params.center_extent.y);
143    let distance = dot(crown - world_position, sun);
144    if (distance <= 0.0) { return 1.0; }
145    let radius = max(canopy_params.direction.w * distance * canopy_params.mode_sharpness.y / canopy_params.projected_extent.z, texel.x);
146    var shade = 0.0;
147    for (var y = -2; y <= 2; y++) {
148        for (var x = -2; x <= 2; x++) {
149            if (x * x + y * y <= 4) {
150                let offset = vec2f(f32(x), f32(y)) * radius * 0.5;
151                shade += textureSampleLevel(canopy_cookie, canopy_sampler, uv + offset, 0.0).r;
152            }
153        }
154    }
155    return 1.0 - shade / 13.0;
156}
157"#;
158
159fn literal(value: f32) -> String {
160    format!("{value:?}")
161}
162
163pub fn canopy_light_wgsl(gobo: &Gobo) -> String {
164    let [g0, g1, g2, g3, g4] = gobo.gust.map(literal);
165    let [x0, x1, x2, x3] = gobo.sway[0].map(literal);
166    let [z0, z1, z2, z3] = gobo.sway[1].map(literal);
167    let [b0, b1, b2, b3] = gobo.branch.map(literal);
168    let [c0, c1, c2, c3, c4] = gobo.clusters.map(literal);
169    let [l0, l1, l2, l3, l4, l5] = gobo.leaves.map(literal);
170    let weight = literal(gobo.weight);
171    let procedural = format!(
172        "fn procedural_canopy(q: vec2f, t: f32) -> f32 {{
173    let gust = sin(t * {g0}) * {g1} + sin(t * {g2} + {g3}) * {g4};
174    let sway = vec2f(sin(t * {x0} + q.y * {x1}) * {x2} + gust * {x3}, cos(t * {z0} + q.x * {z1}) * {z2} + gust * {z3});
175    let p = q + sway;
176    let branch = smoothstep({b0}, {b1}, p.x * {b2} + p.y * {b3});
177    let clusters = smoothstep({c3}, {c4}, canopy_fbm(p * {c0} + vec2f({c1}, {c2})));
178    let leaves = smoothstep({l1}, {l2}, canopy_fbm(p * {l0} + vec2f(sin(t * {l3}) * {l5}, cos(t * {l4}) * {l5})));
179    return 1.0 - leaves * clusters * branch * {weight};
180}}
181"
182    );
183    [CANOPY_LIGHT_HEAD, &procedural, CANOPY_LIGHT_TAIL].concat()
184}
185
186pub const CANOPY_COOKIE_WGSL: &str = r#"
187struct CanopyParams {
188    center_extent: vec4f,
189    sun_receiver: vec4f,
190    wind: vec4f,
191    direction: vec4f,
192    projected_extent: vec4f,
193    mode_sharpness: vec4f,
194}
195struct Card {
196    position_size: vec4f,
197    right_size: vec4f,
198    up: vec4f,
199    pivot_level: vec4f,
200    motion_color: vec4f,
201    atlas: vec4f,
202    lod: vec4f,
203}
204@group(0) @binding(0) var<uniform> params: CanopyParams;
205@group(0) @binding(1) var<storage, read> cards: array<Card>;
206@group(0) @binding(2) var alpha_atlas: texture_2d<f32>;
207@group(0) @binding(3) var alpha_sampler: sampler;
208fn tree_sway(rest: vec3f, pivot: vec3f, level: f32, stiffness: f32, wind_time: f32, strength: f32, direction: vec3f) -> vec3f {
209    let d = normalize(direction.xz + vec2f(0.00001, 0.0));
210    let arm = length(rest - pivot);
211    let phase = wind_time * (0.7 + level * 0.41) + pivot.x * 0.83 + pivot.z * 0.67;
212    let bend = sin(phase) * strength * (1.0 - stiffness) * arm * (0.012 + 0.009 * level);
213    return rest + vec3f(d.x * bend, -abs(bend) * 0.12, d.y * bend);
214}
215struct Varying { @builtin(position) clip: vec4f, @location(0) uv: vec2f, @location(1) @interpolate(flat) atlas: vec4f, @location(2) @interpolate(flat) lod: vec4f, }
216@vertex fn vertex(@builtin(vertex_index) vertex: u32, @builtin(instance_index) instance: u32) -> Varying {
217    let card = cards[instance];
218    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));
219    let corner = corners[vertex];
220    let rest = card.position_size.xyz + card.right_size.xyz * card.position_size.w * corner.x + card.up.xyz * card.right_size.w * corner.y;
221    let moved = tree_sway(rest, card.pivot_level.xyz, card.pivot_level.w, card.motion_color.x, params.wind.x, params.wind.y, params.direction.xyz) + vec3f(params.center_extent.x, params.wind.z, params.center_extent.y);
222    let sun_input = params.sun_receiver.xyz;
223    let sun = select(vec3f(0.0, 1.0, 0.0), sun_input / max(length(sun_input), 1e-6), length(sun_input) > 1e-6);
224    let reference = select(vec3f(0.0, 1.0, 0.0), vec3f(0.0, 0.0, 1.0), abs(sun.y) > 0.99);
225    let axis_u = normalize(cross(sun, reference));
226    let axis_v = cross(axis_u, sun);
227    let projected = vec2f(dot(moved, axis_u), dot(moved, axis_v));
228    let uv = (projected - params.projected_extent.xy) / params.projected_extent.zw + vec2f(0.5);
229    var out: Varying;
230    out.clip = vec4f(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.0, 1.0);
231    out.uv = (corner + vec2f(1.0)) * 0.5;
232    out.atlas = card.atlas;
233    out.lod = card.lod;
234    return out;
235}
236@fragment fn fragment(input: Varying) -> @location(0) f32 {
237    let uv = input.atlas.xy + input.uv * input.atlas.zw;
238    let weight = select(1.0 - input.lod.x, input.lod.x, input.lod.z > 0.5);
239    return textureSample(alpha_atlas, alpha_sampler, uv).r * weight;
240}
241"#;
242
243const CANOPY_VISIBLE_TEMPLATE: &str = r#"
244struct CanopyParams { center_extent: vec4f, sun_receiver: vec4f, wind: vec4f, direction: vec4f, projected_extent: vec4f, mode_sharpness: vec4f, }
245struct Card { position_size: vec4f, right_size: vec4f, up: vec4f, pivot_level: vec4f, motion_color: vec4f, atlas: vec4f, lod: vec4f, }
246@group(0) @binding(0) var<uniform> params: CanopyParams;
247@group(0) @binding(1) var<storage, read> cards: array<Card>;
248@group(0) @binding(2) var alpha_atlas: texture_2d<f32>;
249@group(0) @binding(3) var alpha_sampler: sampler;
250@group(1) @binding(0) var<uniform> view_proj: mat4x4f;
251fn tree_sway(rest: vec3f, pivot: vec3f, level: f32, stiffness: f32, wind_time: f32, strength: f32, direction: vec3f) -> vec3f {
252    let d = normalize(direction.xz + vec2f(0.00001, 0.0));
253    let arm = length(rest - pivot);
254    let phase = wind_time * (0.7 + level * 0.41) + pivot.x * 0.83 + pivot.z * 0.67;
255    let bend = sin(phase) * strength * (1.0 - stiffness) * arm * (0.012 + 0.009 * level);
256    return rest + vec3f(d.x * bend, -abs(bend) * 0.12, d.y * bend);
257}
258struct Varying { @builtin(position) clip: vec4f, @location(0) uv: vec2f, @location(1) @interpolate(flat) atlas: vec4f, @location(2) @interpolate(flat) color: vec3f, @location(3) @interpolate(flat) normal: vec3f, }
259@vertex fn vertex(@builtin(vertex_index) vertex: u32, @builtin(instance_index) instance: u32) -> Varying {
260    let card = cards[instance];
261    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));
262    let corner = corners[vertex];
263    let rest = card.position_size.xyz + card.right_size.xyz * card.position_size.w * corner.x + card.up.xyz * card.right_size.w * corner.y;
264    let moved = tree_sway(rest, card.pivot_level.xyz, card.pivot_level.w, card.motion_color.x, params.wind.x, params.wind.y, params.direction.xyz) + vec3f(params.center_extent.x, params.wind.z, params.center_extent.y);
265    var out: Varying;
266    out.clip = view_proj * vec4f(moved, 1.0);
267    out.uv = (corner + vec2f(1.0)) * 0.5;
268    out.atlas = card.atlas;
269    out.color = card.motion_color.yzw;
270    out.normal = normalize(cross(card.right_size.xyz, card.up.xyz));
271    return out;
272}
273@fragment fn fragment(input: Varying, @builtin(front_facing) front: bool) -> @location(0) vec4f {
274    let uv = input.atlas.xy + input.uv * input.atlas.zw;
275    let texel = textureSample(alpha_atlas, alpha_sampler, uv);
276    let normal = select(-input.normal, input.normal, front);
277    let sun = normalize(params.sun_receiver.xyz);
278    let facing = dot(normal, sun);
279    let diffuse = max(facing, 0.0);
280    let transmission = max(-facing, 0.0) * 0.48;
281    let pigment = mix(input.color, @TINT_G@, texel.g * 0.6);
282    let petal = mix(pigment, @TINT_B@, texel.b * 0.65);
283    let lit = petal * (0.22 + diffuse * 0.78) + @GLOW@ * transmission * 0.52;
284    return vec4f(lit, texel.r);
285}
286"#;
287
288pub fn canopy_visible_wgsl(colors: &CardColors) -> String {
289    let colour = |c: [f32; 3]| {
290        format!(
291            "vec3f({}, {}, {})",
292            literal(c[0]),
293            literal(c[1]),
294            literal(c[2])
295        )
296    };
297    CANOPY_VISIBLE_TEMPLATE
298        .replace("@TINT_G@", &colour(colors.tint_g))
299        .replace("@TINT_B@", &colour(colors.tint_b))
300        .replace("@GLOW@", &colour(colors.glow))
301}
302
303#[repr(C)]
304#[derive(Clone, Copy, Pod, Zeroable)]
305struct Params {
306    center_extent: [f32; 4],
307    sun_receiver: [f32; 4],
308    wind: [f32; 4],
309    direction: [f32; 4],
310    projected_extent: [f32; 4],
311    mode_sharpness: [f32; 4],
312}
313
314#[repr(C)]
315#[derive(Clone, Copy, Pod, Zeroable)]
316struct GpuCard {
317    position_size: [f32; 4],
318    right_size: [f32; 4],
319    up: [f32; 4],
320    pivot_level: [f32; 4],
321    motion_color: [f32; 4],
322    atlas: [f32; 4],
323    lod: [f32; 4],
324}
325
326fn gpu_card(card: &Card, coverage: f32, id: u32, near: bool) -> GpuCard {
327    GpuCard {
328        position_size: [
329            card.position[0],
330            card.position[1],
331            card.position[2],
332            card.size[0],
333        ],
334        right_size: [card.right[0], card.right[1], card.right[2], card.size[1]],
335        up: [card.up[0], card.up[1], card.up[2], 0.0],
336        pivot_level: [
337            card.sway.pivot[0],
338            card.sway.pivot[1],
339            card.sway.pivot[2],
340            card.sway.level,
341        ],
342        motion_color: [
343            card.sway.stiffness,
344            card.color[0],
345            card.color[1],
346            card.color[2],
347        ],
348        atlas: card.atlas,
349        lod: [coverage, id as f32, if near { 1.0 } else { 0.0 }, 0.0],
350    }
351}
352
353fn near_coverage(diameter: f32, projection_y: f32, height: u32, depth: f32) -> f32 {
354    let pixels = diameter * projection_y.abs() * height as f32 / (2.0 * depth.abs().max(0.05));
355    ((pixels - 9.0) / 3.0).clamp(0.0, 1.0)
356}
357
358struct CardGroup {
359    start: usize,
360    end: usize,
361    cluster: bool,
362}
363
364pub struct Canopy {
365    pub texture: wgpu::Texture,
366    pub view: wgpu::TextureView,
367    pub sampler: wgpu::Sampler,
368    pub resolution: u32,
369    pub center: [f32; 2],
370    pub extent: [f32; 2],
371    pub card_count: u32,
372    source: Vec<GpuCard>,
373    groups: Vec<CardGroup>,
374    visible: Vec<GpuCard>,
375    card_buffer: wgpu::Buffer,
376    params: wgpu::Buffer,
377    bind: wgpu::BindGroup,
378    pipeline: wgpu::RenderPipeline,
379    cookie_layout: wgpu::BindGroupLayout,
380    sample_layout: wgpu::BindGroupLayout,
381    current: Params,
382    canopy_height: f32,
383    weight: f32,
384}
385
386impl Canopy {
387    pub fn new(device: &wgpu::Device, queue: &wgpu::Queue, tree: &Tree, resolution: u32) -> Self {
388        let resolution = resolution.max(16);
389        let cards: Vec<GpuCard> = tree
390            .cards
391            .iter()
392            .enumerate()
393            .map(|(index, card)| gpu_card(card, 1.0, index as u32, true))
394            .collect();
395        let mut groups = Vec::new();
396        let mut index = 0;
397        while index < tree.cards.len() {
398            let start = index;
399            let cluster = tree.cards[index].lod == CardLod::Cluster;
400            index += 1;
401            if cluster {
402                while index < tree.cards.len() && tree.cards[index].lod == CardLod::Individual {
403                    index += 1;
404                }
405            }
406            groups.push(CardGroup {
407                start,
408                end: index,
409                cluster,
410            });
411        }
412        let card_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
413            label: Some("canopy cards"),
414            contents: bytemuck::cast_slice(&cards),
415            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
416        });
417        let extent = [tree.spec.crown_width * 2.8, tree.spec.crown_width * 2.8];
418        let current = Params {
419            center_extent: [0.0, 0.0, extent[0], extent[1]],
420            sun_receiver: [0.0, 1.0, 0.0, 0.0],
421            wind: [0.0, 0.0, 0.0, tree.spec.height * 0.8],
422            direction: [1.0, 0.0, 0.0, 0.00465],
423            projected_extent: [0.0, 0.0, extent[0], extent[1]],
424            mode_sharpness: [0.0, 1.0, 0.0, DEFAULT_PROCEDURAL_STRENGTH],
425        };
426        let params = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
427            label: Some("canopy params"),
428            contents: bytemuck::bytes_of(&current),
429            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
430        });
431        let texture = device.create_texture(&wgpu::TextureDescriptor {
432            label: Some("canopy cookie"),
433            size: wgpu::Extent3d {
434                width: resolution,
435                height: resolution,
436                depth_or_array_layers: 1,
437            },
438            mip_level_count: 1,
439            sample_count: 1,
440            dimension: wgpu::TextureDimension::D2,
441            format: wgpu::TextureFormat::R8Unorm,
442            usage: wgpu::TextureUsages::RENDER_ATTACHMENT
443                | wgpu::TextureUsages::TEXTURE_BINDING
444                | wgpu::TextureUsages::COPY_SRC,
445            view_formats: &[],
446        });
447        let view = texture.create_view(&Default::default());
448        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
449            mag_filter: wgpu::FilterMode::Linear,
450            min_filter: wgpu::FilterMode::Linear,
451            ..Default::default()
452        });
453        let atlas = device.create_texture(&wgpu::TextureDescriptor {
454            label: Some("tree alpha atlas"),
455            size: wgpu::Extent3d {
456                width: tree.atlas.width,
457                height: tree.atlas.height,
458                depth_or_array_layers: 1,
459            },
460            mip_level_count: 1,
461            sample_count: 1,
462            dimension: wgpu::TextureDimension::D2,
463            format: wgpu::TextureFormat::Rgba8Unorm,
464            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
465            view_formats: &[],
466        });
467        let atlas_view = atlas.create_view(&Default::default());
468        queue.write_texture(
469            wgpu::TexelCopyTextureInfo {
470                texture: &atlas,
471                mip_level: 0,
472                origin: wgpu::Origin3d::ZERO,
473                aspect: wgpu::TextureAspect::All,
474            },
475            &tree.atlas.pixels,
476            wgpu::TexelCopyBufferLayout {
477                offset: 0,
478                bytes_per_row: Some(tree.atlas.width * 4),
479                rows_per_image: Some(tree.atlas.height),
480            },
481            wgpu::Extent3d {
482                width: tree.atlas.width,
483                height: tree.atlas.height,
484                depth_or_array_layers: 1,
485            },
486        );
487        let atlas_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
488            mag_filter: wgpu::FilterMode::Linear,
489            min_filter: wgpu::FilterMode::Linear,
490            address_mode_u: wgpu::AddressMode::ClampToEdge,
491            address_mode_v: wgpu::AddressMode::ClampToEdge,
492            ..Default::default()
493        });
494        let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
495            label: Some("canopy cookie layout"),
496            entries: &cookie_entries(),
497        });
498        let bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
499            label: Some("canopy cookie bind"),
500            layout: &layout,
501            entries: &[
502                wgpu::BindGroupEntry {
503                    binding: 0,
504                    resource: params.as_entire_binding(),
505                },
506                wgpu::BindGroupEntry {
507                    binding: 1,
508                    resource: card_buffer.as_entire_binding(),
509                },
510                wgpu::BindGroupEntry {
511                    binding: 2,
512                    resource: wgpu::BindingResource::TextureView(&atlas_view),
513                },
514                wgpu::BindGroupEntry {
515                    binding: 3,
516                    resource: wgpu::BindingResource::Sampler(&atlas_sampler),
517                },
518            ],
519        });
520        let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
521            label: Some("canopy cookie shader"),
522            source: wgpu::ShaderSource::Wgsl(CANOPY_COOKIE_WGSL.into()),
523        });
524        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
525            label: Some("canopy cookie pipeline layout"),
526            bind_group_layouts: &[Some(&layout)],
527            immediate_size: 0,
528        });
529        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
530            label: Some("canopy cookie pipeline"),
531            layout: Some(&pipeline_layout),
532            vertex: wgpu::VertexState {
533                module: &module,
534                entry_point: Some("vertex"),
535                compilation_options: Default::default(),
536                buffers: &[],
537            },
538            fragment: Some(wgpu::FragmentState {
539                module: &module,
540                entry_point: Some("fragment"),
541                compilation_options: Default::default(),
542                targets: &[Some(wgpu::ColorTargetState {
543                    format: wgpu::TextureFormat::R8Unorm,
544                    blend: Some(wgpu::BlendState {
545                        color: wgpu::BlendComponent {
546                            src_factor: wgpu::BlendFactor::One,
547                            dst_factor: wgpu::BlendFactor::OneMinusSrc,
548                            operation: wgpu::BlendOperation::Add,
549                        },
550                        alpha: wgpu::BlendComponent::REPLACE,
551                    }),
552                    write_mask: wgpu::ColorWrites::ALL,
553                })],
554            }),
555            primitive: wgpu::PrimitiveState {
556                cull_mode: None,
557                ..Default::default()
558            },
559            depth_stencil: None,
560            multisample: Default::default(),
561            multiview_mask: None,
562            cache: None,
563        });
564        let sample_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
565            label: Some("canopy sample layout"),
566            entries: &[
567                wgpu::BindGroupLayoutEntry {
568                    binding: 9,
569                    visibility: wgpu::ShaderStages::FRAGMENT,
570                    ty: wgpu::BindingType::Texture {
571                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
572                        view_dimension: wgpu::TextureViewDimension::D2,
573                        multisampled: false,
574                    },
575                    count: None,
576                },
577                wgpu::BindGroupLayoutEntry {
578                    binding: 10,
579                    visibility: wgpu::ShaderStages::FRAGMENT,
580                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
581                    count: None,
582                },
583                wgpu::BindGroupLayoutEntry {
584                    binding: 11,
585                    visibility: wgpu::ShaderStages::FRAGMENT,
586                    ty: wgpu::BindingType::Buffer {
587                        ty: wgpu::BufferBindingType::Uniform,
588                        has_dynamic_offset: false,
589                        min_binding_size: None,
590                    },
591                    count: None,
592                },
593            ],
594        });
595        Self {
596            texture,
597            view,
598            sampler,
599            resolution,
600            center: [0.0, 0.0],
601            extent,
602            card_count: cards.len() as u32,
603            source: cards,
604            groups,
605            visible: Vec::with_capacity(tree.cards.len()),
606            card_buffer,
607            params,
608            bind,
609            pipeline,
610            cookie_layout: layout,
611            sample_layout,
612            current,
613            canopy_height: tree.spec.height * 0.8,
614            weight: 1.0,
615        }
616    }
617
618    pub fn sample_layout(&self) -> &wgpu::BindGroupLayout {
619        &self.sample_layout
620    }
621
622    pub fn select_lod(
623        &mut self,
624        queue: &wgpu::Queue,
625        camera: crate::frame::Camera,
626        height: u32,
627        origin: [f32; 3],
628    ) {
629        self.visible.clear();
630        for group in &self.groups {
631            let index = group.start;
632            let card = self.source[index];
633            if group.cluster {
634                let center = [
635                    card.position_size[0] + origin[0],
636                    card.position_size[1] + origin[1],
637                    card.position_size[2] + origin[2],
638                ];
639                let view =
640                    crate::frame::transform(camera.view, [center[0], center[1], center[2], 1.0]);
641                let near = near_coverage(0.032, camera.projection[1][1], height, view[2]);
642                let id = index as u32;
643                if near < 1.0 {
644                    let mut far = card;
645                    far.lod = [near, id as f32, 0.0, 0.0];
646                    self.visible.push(far);
647                }
648                if near > 0.0 {
649                    for flower in &self.source[index + 1..group.end] {
650                        let mut flower = *flower;
651                        flower.lod = [near, id as f32, 1.0, 0.0];
652                        self.visible.push(flower);
653                    }
654                }
655            } else {
656                self.visible.push(card);
657            }
658        }
659        self.card_count = self.visible.len() as u32;
660        if !self.visible.is_empty() {
661            queue.write_buffer(&self.card_buffer, 0, bytemuck::cast_slice(&self.visible));
662        }
663    }
664    pub fn cookie_layout(&self) -> &wgpu::BindGroupLayout {
665        &self.cookie_layout
666    }
667    pub fn cookie_bind(&self) -> &wgpu::BindGroup {
668        &self.bind
669    }
670    pub fn params(&self) -> &wgpu::Buffer {
671        &self.params
672    }
673    pub fn set_mode(&mut self, mode: CanopyMode) {
674        self.current.mode_sharpness[0] = match mode {
675            CanopyMode::Cookie => 0.0,
676            CanopyMode::Procedural => 1.0,
677        };
678    }
679    pub fn set_sharpness(&mut self, sharpness: f32) -> Result<(), String> {
680        if !sharpness.is_finite() || sharpness <= 0.0 {
681            return Err("canopy sharpness must be finite and positive".into());
682        }
683        self.current.mode_sharpness[1] = sharpness;
684        Ok(())
685    }
686    pub fn set_strength(&mut self, strength: f32) -> Result<(), String> {
687        if !strength.is_finite() || !(0.0..=1.0).contains(&strength) {
688            return Err("canopy strength must be finite and between 0 and 1".into());
689        }
690        self.current.mode_sharpness[2] = strength / self.weight;
691        self.current.mode_sharpness[3] = strength;
692        Ok(())
693    }
694    pub fn set_weight(&mut self, weight: f32) {
695        self.weight = weight;
696        self.current.mode_sharpness[2] = self.current.mode_sharpness[3] / weight;
697    }
698    pub fn strength(&self) -> f32 {
699        self.current.mode_sharpness[3]
700    }
701    pub fn sample_bind(&self, device: &wgpu::Device) -> wgpu::BindGroup {
702        device.create_bind_group(&wgpu::BindGroupDescriptor {
703            label: Some("canopy sample bind"),
704            layout: &self.sample_layout,
705            entries: &[
706                wgpu::BindGroupEntry {
707                    binding: 9,
708                    resource: wgpu::BindingResource::TextureView(&self.view),
709                },
710                wgpu::BindGroupEntry {
711                    binding: 10,
712                    resource: wgpu::BindingResource::Sampler(&self.sampler),
713                },
714                wgpu::BindGroupEntry {
715                    binding: 11,
716                    resource: self.params.as_entire_binding(),
717                },
718            ],
719        })
720    }
721    pub fn update(
722        &mut self,
723        queue: &wgpu::Queue,
724        encoder: &mut wgpu::CommandEncoder,
725        frame: CanopyFrame,
726        timestamps: Option<wgpu::RenderPassTimestampWrites<'_>>,
727    ) {
728        let (center, extent) = projected_bounds(
729            frame.origin,
730            self.canopy_height,
731            self.current.center_extent[2],
732            frame.sun,
733            frame.receiver_y,
734        );
735        self.center = center;
736        self.extent = extent;
737        self.current.center_extent[0..2].copy_from_slice(&[frame.origin[0], frame.origin[2]]);
738        self.current.projected_extent = [center[0], center[1], extent[0], extent[1]];
739        self.current.sun_receiver = [frame.sun[0], frame.sun[1], frame.sun[2], frame.receiver_y];
740        self.current.wind = [
741            frame.time,
742            frame.strength,
743            frame.origin[1],
744            self.canopy_height + frame.origin[1],
745        ];
746        self.current.direction = [
747            frame.direction[0],
748            frame.direction[1],
749            frame.direction[2],
750            frame.angular_radius,
751        ];
752        queue.write_buffer(&self.params, 0, bytemuck::bytes_of(&self.current));
753        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
754            multiview_mask: None,
755            label: Some("canopy cookie"),
756            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
757                view: &self.view,
758                resolve_target: None,
759                ops: wgpu::Operations {
760                    load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
761                    store: wgpu::StoreOp::Store,
762                },
763                depth_slice: None,
764            })],
765            depth_stencil_attachment: None,
766            timestamp_writes: timestamps,
767            occlusion_query_set: None,
768        });
769        pass.set_pipeline(&self.pipeline);
770        pass.set_bind_group(0, &self.bind, &[]);
771        pass.draw(0..6, 0..self.card_count);
772    }
773}
774
775fn projected_bounds(
776    origin: [f32; 3],
777    canopy_height: f32,
778    base_extent: f32,
779    sun: [f32; 3],
780    _receiver_y: f32,
781) -> ([f32; 2], [f32; 2]) {
782    let [u, v] = projection_axes(sun);
783    let crown = [origin[0], origin[1] + canopy_height, origin[2]];
784    let center = [dot3(crown, u), dot3(crown, v)];
785    let extent = [base_extent + canopy_height, base_extent + canopy_height];
786    (center, extent)
787}
788
789fn dot3(a: [f32; 3], b: [f32; 3]) -> f32 {
790    a.into_iter().zip(b).map(|(x, y)| x * y).sum()
791}
792
793fn projection_axes(sun: [f32; 3]) -> [[f32; 3]; 2] {
794    let length = dot3(sun, sun).sqrt();
795    let s = if length > 1.0e-6 {
796        sun.map(|component| component / length)
797    } else {
798        [0.0, 1.0, 0.0]
799    };
800    let reference = if s[1].abs() > 0.99 {
801        [0.0, 0.0, 1.0]
802    } else {
803        [0.0, 1.0, 0.0]
804    };
805    let u = [
806        s[1] * reference[2] - s[2] * reference[1],
807        s[2] * reference[0] - s[0] * reference[2],
808        s[0] * reference[1] - s[1] * reference[0],
809    ];
810    let length = dot3(u, u).sqrt().max(1.0e-6);
811    let u = u.map(|component| component / length);
812    let v = [
813        u[1] * s[2] - u[2] * s[1],
814        u[2] * s[0] - u[0] * s[2],
815        u[0] * s[1] - u[1] * s[0],
816    ];
817    [u, v]
818}
819
820pub fn projected_coverage(
821    tree: &Tree,
822    time: f32,
823    strength: f32,
824    direction: [f32; 3],
825    sun: [f32; 3],
826    _receiver_y: f32,
827) -> f32 {
828    let [axis_u, axis_v] = projection_axes(sun);
829    let tile_width = tree.atlas.width as usize / 6;
830    let mut opacity = [0.0_f32; 6];
831    for row in tree
832        .atlas
833        .pixels
834        .chunks_exact(tree.atlas.width as usize * 4)
835    {
836        for (tile, sum) in opacity.iter_mut().enumerate() {
837            *sum += (tile * tile_width..(tile + 1) * tile_width)
838                .map(|x| row[x * 4] as f32 / 255.0)
839                .sum::<f32>();
840        }
841    }
842    let divisor = (tile_width * tree.atlas.height as usize).max(1) as f32;
843    for value in &mut opacity {
844        *value /= divisor;
845    }
846    let mut area = 0.0;
847    for c in &tree.cards {
848        let corners = [[-1.0, -1.0], [1.0, -1.0], [-1.0, 1.0]];
849        let mut p = [[0.0; 2]; 3];
850        for (i, corner) in corners.iter().enumerate() {
851            let rest = [
852                c.position[0]
853                    + c.right[0] * c.size[0] * corner[0]
854                    + c.up[0] * c.size[1] * corner[1],
855                c.position[1]
856                    + c.right[1] * c.size[0] * corner[0]
857                    + c.up[1] * c.size[1] * corner[1],
858                c.position[2]
859                    + c.right[2] * c.size[0] * corner[0]
860                    + c.up[2] * c.size[1] * corner[1],
861            ];
862            let m = tree_sway(
863                rest,
864                c.sway.pivot,
865                c.sway.level,
866                c.sway.stiffness,
867                time,
868                strength,
869                direction,
870            );
871            p[i] = [dot3(m, axis_u), dot3(m, axis_v)];
872        }
873        let ax = p[1][0] - p[0][0];
874        let ay = p[1][1] - p[0][1];
875        let bx = p[2][0] - p[0][0];
876        let by = p[2][1] - p[0][1];
877        area += (ax * by - ay * bx).abs() * opacity[(c.atlas[0] * 6.0).round() as usize];
878    }
879    area
880}
881
882pub fn petal_emitters(
883    tree: &Tree,
884    origin: [f32; 3],
885    time: f32,
886    strength: f32,
887    direction: [f32; 3],
888    max_total_rate: f32,
889) -> Vec<Emitter> {
890    if !max_total_rate.is_finite() || max_total_rate <= 0.0 || tree.petal_sources.is_empty() {
891        return Vec::new();
892    }
893    let native_rate = tree.petal_sources[0].rate.max(0.001);
894    let count = ((max_total_rate / native_rate).ceil() as usize).clamp(1, tree.petal_sources.len());
895    let per_source = max_total_rate / count as f32;
896    (0..count)
897        .map(|i| {
898            let index = ((i as f32 + 0.5) * tree.petal_sources.len() as f32 / count as f32).floor()
899                as usize;
900            let source = &tree.petal_sources[index.min(tree.petal_sources.len() - 1)];
901            let position = tree_sway(
902                source.position,
903                source.sway.pivot,
904                source.sway.level,
905                source.sway.stiffness,
906                time,
907                strength,
908                direction,
909            );
910            let position = [
911                position[0] + origin[0],
912                position[1] + origin[1],
913                position[2] + origin[2],
914            ];
915            let jitter = 0.7
916                + 0.3
917                    * ((source.seed.wrapping_mul(0x9e3779b97f4a7c15) >> 40) as f32 / 16_777_216.0);
918            Emitter::new(
919                ParticleKind::Petal,
920                position,
921                source.rate.min(per_source) * jitter,
922                source.seed,
923            )
924        })
925        .collect()
926}
927
928pub fn cookie_entries() -> Vec<wgpu::BindGroupLayoutEntry> {
929    vec![
930        wgpu::BindGroupLayoutEntry {
931            binding: 0,
932            visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
933            ty: wgpu::BindingType::Buffer {
934                ty: wgpu::BufferBindingType::Uniform,
935                has_dynamic_offset: false,
936                min_binding_size: None,
937            },
938            count: None,
939        },
940        wgpu::BindGroupLayoutEntry {
941            binding: 1,
942            visibility: wgpu::ShaderStages::VERTEX,
943            ty: wgpu::BindingType::Buffer {
944                ty: wgpu::BufferBindingType::Storage { read_only: true },
945                has_dynamic_offset: false,
946                min_binding_size: None,
947            },
948            count: None,
949        },
950        wgpu::BindGroupLayoutEntry {
951            binding: 2,
952            visibility: wgpu::ShaderStages::FRAGMENT,
953            ty: wgpu::BindingType::Texture {
954                sample_type: wgpu::TextureSampleType::Float { filterable: true },
955                view_dimension: wgpu::TextureViewDimension::D2,
956                multisampled: false,
957            },
958            count: None,
959        },
960        wgpu::BindGroupLayoutEntry {
961            binding: 3,
962            visibility: wgpu::ShaderStages::FRAGMENT,
963            ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
964            count: None,
965        },
966    ]
967}
968
969#[cfg(test)]
970mod tests {
971    use super::*;
972    #[test]
973    fn cluster_lod_changes_with_distance_without_coverage_jump() {
974        let mut previous = 1.0;
975        for depth in [4.0, 6.0, 8.0, 10.0, 14.0, 20.0, 30.0] {
976            let near = near_coverage(0.032, 2.7, 2160, depth);
977            let far = 1.0 - near;
978            assert!(near <= previous);
979            assert!((near + far - 1.0).abs() < 1e-6);
980            previous = near;
981        }
982        assert_eq!(near_coverage(0.032, 2.7, 2160, 4.0), 1.0);
983        assert_eq!(near_coverage(0.032, 2.7, 2160, 30.0), 0.0);
984        for pixels in [9.0_f32, 12.0] {
985            let depth = 0.032 * 2.7 * 2160.0 / (2.0 * pixels);
986            let before = near_coverage(0.032, 2.7, 2160, depth - 0.001);
987            let after = near_coverage(0.032, 2.7, 2160, depth + 0.001);
988            assert!((before - after).abs() < 0.001);
989        }
990    }
991    #[test]
992    fn shaders_and_area() {
993        for source in [
994            CANOPY_COOKIE_WGSL.to_string(),
995            canopy_visible_wgsl(&CardColors::default()),
996        ] {
997            let module = naga::front::wgsl::parse_str(&source).unwrap();
998            naga::valid::Validator::new(
999                naga::valid::ValidationFlags::all(),
1000                naga::valid::Capabilities::all(),
1001            )
1002            .validate(&module)
1003            .unwrap();
1004        }
1005        naga::front::wgsl::parse_str(&canopy_light_wgsl(&Gobo::default())).unwrap();
1006        let tree = Tree::new(9, pfx_geom::tree::TreeSpec::plum(), 0.02);
1007        let overhead = projected_coverage(&tree, 0.0, 0.0, [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], 0.0);
1008        let tile_width = tree.atlas.width as usize / 6;
1009        let alpha = |tile: usize| -> f32 {
1010            tree.atlas
1011                .pixels
1012                .chunks_exact(4)
1013                .enumerate()
1014                .filter(|(index, _)| index % tree.atlas.width as usize / tile_width == tile)
1015                .map(|(_, rgba)| rgba[0] as f32 / 255.0)
1016                .sum::<f32>()
1017                / (tile_width * tree.atlas.height as usize) as f32
1018        };
1019        let expected = tree
1020            .cards
1021            .iter()
1022            .map(|c| {
1023                4.0 * c.size[0]
1024                    * c.size[1]
1025                    * (c.right[0] * c.up[2] - c.right[2] * c.up[0]).abs()
1026                    * alpha((c.atlas[0] * 6.0).round() as usize)
1027            })
1028            .sum::<f32>();
1029        assert!((overhead - expected).abs() < expected * 1e-5);
1030        let angled = projected_coverage(&tree, 0.0, 0.0, [1.0, 0.0, 0.0], [0.4, 1.0, 0.2], 0.0);
1031        assert!((overhead - angled).abs() > overhead * 0.001);
1032    }
1033
1034    #[test]
1035    #[ignore = "needs a GPU; run under pgpu"]
1036    fn cookie_angular_radius_softens_an_edge() {
1037        let gpu = pollster::block_on(pfx_gpu::Gpu::headless()).unwrap();
1038        let tree = Tree::new(9, pfx_geom::tree::TreeSpec::plum(), 0.02);
1039        let mut canopy = Canopy::new(&gpu.device, &gpu.queue, &tree, 256);
1040        canopy.current.wind[3] = 1.0;
1041        canopy.current.projected_extent = [0.0, 0.0, 1.0, 1.0];
1042        let pixels: Vec<u8> = (0..256 * 256)
1043            .map(|index| if index % 256 >= 128 { 255 } else { 0 })
1044            .collect();
1045        let cookie = gpu.device.create_texture(&wgpu::TextureDescriptor {
1046            label: Some("filter edge"),
1047            size: wgpu::Extent3d {
1048                width: 256,
1049                height: 256,
1050                depth_or_array_layers: 1,
1051            },
1052            mip_level_count: 1,
1053            sample_count: 1,
1054            dimension: wgpu::TextureDimension::D2,
1055            format: wgpu::TextureFormat::R8Unorm,
1056            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
1057            view_formats: &[],
1058        });
1059        gpu.queue.write_texture(
1060            wgpu::TexelCopyTextureInfo {
1061                texture: &cookie,
1062                mip_level: 0,
1063                origin: wgpu::Origin3d::ZERO,
1064                aspect: wgpu::TextureAspect::All,
1065            },
1066            &pixels,
1067            wgpu::TexelCopyBufferLayout {
1068                offset: 0,
1069                bytes_per_row: Some(256),
1070                rows_per_image: Some(256),
1071            },
1072            wgpu::Extent3d {
1073                width: 256,
1074                height: 256,
1075                depth_or_array_layers: 1,
1076            },
1077        );
1078        let cookie_bind = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor {
1079            label: Some("filter edge bind"),
1080            layout: canopy.sample_layout(),
1081            entries: &[
1082                wgpu::BindGroupEntry {
1083                    binding: 9,
1084                    resource: wgpu::BindingResource::TextureView(
1085                        &cookie.create_view(&Default::default()),
1086                    ),
1087                },
1088                wgpu::BindGroupEntry {
1089                    binding: 10,
1090                    resource: wgpu::BindingResource::Sampler(&canopy.sampler),
1091                },
1092                wgpu::BindGroupEntry {
1093                    binding: 11,
1094                    resource: canopy.params.as_entire_binding(),
1095                },
1096            ],
1097        });
1098        let empty = gpu
1099            .device
1100            .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1101                label: Some("filter empty layout"),
1102                entries: &[],
1103            });
1104        let empty_bind = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor {
1105            label: Some("filter empty bind"),
1106            layout: &empty,
1107            entries: &[],
1108        });
1109        let layout = gpu
1110            .device
1111            .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1112                label: Some("filter layout"),
1113                bind_group_layouts: &[
1114                    Some(&empty),
1115                    Some(&empty),
1116                    Some(&empty),
1117                    Some(canopy.sample_layout()),
1118                ],
1119                immediate_size: 0,
1120            });
1121        let shader = gpu.device.create_shader_module(wgpu::ShaderModuleDescriptor {
1122            label: Some("filter shader"),
1123            source: wgpu::ShaderSource::Wgsl(
1124                format!(
1125                    "{}\n@vertex fn vertex(@builtin(vertex_index) index: u32) -> @builtin(position) vec4f {{ let corners = array<vec2f, 3>(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0)); return vec4f(corners[index], 0.0, 1.0); }}\n@fragment fn fragment() -> @location(0) vec4f {{ let light = canopy_light(vec3f(0.03, 0.0, 0.0)); return vec4f(light, light, light, 1.0); }}",
1126                    canopy_light_wgsl(&Gobo::default())
1127                )
1128                .into(),
1129            ),
1130        });
1131        let pipeline = gpu
1132            .device
1133            .create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1134                label: Some("filter pipeline"),
1135                layout: Some(&layout),
1136                vertex: wgpu::VertexState {
1137                    module: &shader,
1138                    entry_point: Some("vertex"),
1139                    compilation_options: Default::default(),
1140                    buffers: &[],
1141                },
1142                fragment: Some(wgpu::FragmentState {
1143                    module: &shader,
1144                    entry_point: Some("fragment"),
1145                    compilation_options: Default::default(),
1146                    targets: &[Some(wgpu::ColorTargetState {
1147                        format: wgpu::TextureFormat::Rgba8UnormSrgb,
1148                        blend: None,
1149                        write_mask: wgpu::ColorWrites::ALL,
1150                    })],
1151                }),
1152                primitive: Default::default(),
1153                depth_stencil: None,
1154                multisample: Default::default(),
1155                multiview_mask: None,
1156                cache: None,
1157            });
1158        let target = gpu
1159            .offscreen(1, 1, wgpu::TextureFormat::Rgba8UnormSrgb)
1160            .unwrap();
1161        let mut sample = |angular_radius, sharpness| {
1162            canopy.current.direction[3] = angular_radius;
1163            canopy.set_sharpness(sharpness).unwrap();
1164            gpu.queue
1165                .write_buffer(&canopy.params, 0, bytemuck::bytes_of(&canopy.current));
1166            let mut encoder = gpu.device.create_command_encoder(&Default::default());
1167            {
1168                let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1169                    multiview_mask: None,
1170                    label: Some("filter sample"),
1171                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1172                        view: &target.view,
1173                        resolve_target: None,
1174                        ops: wgpu::Operations {
1175                            load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1176                            store: wgpu::StoreOp::Store,
1177                        },
1178                        depth_slice: None,
1179                    })],
1180                    depth_stencil_attachment: None,
1181                    timestamp_writes: None,
1182                    occlusion_query_set: None,
1183                });
1184                pass.set_pipeline(&pipeline);
1185                for group in 0..3 {
1186                    pass.set_bind_group(group, &empty_bind, &[]);
1187                }
1188                pass.set_bind_group(3, &cookie_bind, &[]);
1189                pass.draw(0..3, 0..1);
1190            }
1191            gpu.queue.submit([encoder.finish()]);
1192            gpu.readback_rgba8(&target).unwrap()[0]
1193        };
1194        let sharp = sample(0.0001, 1.0);
1195        let soft = sample(0.08, 1.0);
1196        let sharper = sample(0.08, 0.3);
1197        assert!(sharp < 5, "sharp={sharp}");
1198        assert!(soft > 100 && soft < 200, "soft={soft}");
1199        assert!(sharper < soft);
1200    }
1201    #[test]
1202    fn morning_and_evening_shade_tracks_projected_crown() {
1203        let tree = Tree::new(9, pfx_geom::tree::TreeSpec::plum(), 0.02);
1204        let origin = [2.0, 0.0, -3.0];
1205        let height = tree.spec.height * 0.8;
1206        let base = tree.spec.crown_width * 2.8;
1207        for hour in [7.0, 18.0] {
1208            let sun = pfx_core::daylight::Daylight {
1209                hour,
1210                day: pfx_core::daylight::Daylight::DAY,
1211                latitude: pfx_core::daylight::Daylight::LATITUDE,
1212                heading: pfx_core::daylight::Daylight::HEADING,
1213            }
1214            .sun()
1215            .y_up
1216            .map(|value| value as f32);
1217            let (center, extent) = projected_bounds(origin, height, base, sun, 0.0);
1218            let [u, v] = projection_axes(sun);
1219            let crown = [origin[0], origin[1] + height, origin[2]];
1220            let expected = [dot3(crown, u), dot3(crown, v)];
1221            assert!((center[0] - expected[0]).abs() < 1e-5);
1222            assert!((center[1] - expected[1]).abs() < 1e-5);
1223            assert!(extent[0] > base);
1224            let mut sum = [0.0; 2];
1225            let mut weight = 0.0;
1226            for card in &tree.cards {
1227                let world = [
1228                    origin[0] + card.position[0],
1229                    origin[1] + card.position[1],
1230                    origin[2] + card.position[2],
1231                ];
1232                let projected = [dot3(world, u), dot3(world, v)];
1233                let area = card.size[0] * card.size[1];
1234                sum[0] += projected[0] * area;
1235                sum[1] += projected[1] * area;
1236                weight += area;
1237            }
1238            let centroid = [sum[0] / weight, sum[1] / weight];
1239            assert!((centroid[0] - center[0]).abs() < extent[0] * 0.2);
1240            assert!((centroid[1] - center[1]).abs() < extent[1] * 0.2);
1241        }
1242    }
1243    #[test]
1244    fn vertical_receiver_uses_sun_perpendicular_coordinates() {
1245        let sun = [0.4, 0.7, 0.3];
1246        let [u, v] = projection_axes(sun);
1247        let receiver = [0.1, -0.2, 0.0];
1248        let along_sun = [
1249            receiver[0] + sun[0] * 3.0,
1250            receiver[1] + sun[1] * 3.0,
1251            receiver[2] + sun[2] * 3.0,
1252        ];
1253        assert!((dot3(receiver, u) - dot3(along_sun, u)).abs() < 1.0e-6);
1254        assert!((dot3(receiver, v) - dot3(along_sun, v)).abs() < 1.0e-6);
1255        assert!(dot3(u, v).abs() < 1.0e-6);
1256    }
1257    #[test]
1258    fn petals_are_seeded_and_rate_limited() {
1259        let tree = Tree::new(41, pfx_geom::tree::TreeSpec::cherry(), 0.02);
1260        let mut first = petal_emitters(&tree, [0.0; 3], 1.0, 1.0, [1.0, 0.0, 0.0], 5.0);
1261        let mut second = petal_emitters(&tree, [0.0; 3], 1.0, 1.0, [1.0, 0.0, 0.0], 5.0);
1262        assert_eq!(first.len(), second.len());
1263        assert!(first.len() < tree.petal_sources.len());
1264        let wind = pfx_physics::Wind::new(4, [1.0, 0.0, 0.0], 0.5);
1265        for frame in 0..1200 {
1266            let time = frame as f32 / 60.0;
1267            for emitter in &mut first {
1268                emitter.step(1.0 / 60.0, time, &wind);
1269            }
1270            for emitter in &mut second {
1271                emitter.step(1.0 / 60.0, time, &wind);
1272            }
1273        }
1274        let total = first.iter().map(|e| e.specks().len()).sum::<usize>();
1275        assert!(total > 0 && total < 40, "{total} live petals");
1276        for (a, b) in first.iter().zip(&second) {
1277            assert_eq!(a.specks().len(), b.specks().len());
1278            for (left, right) in a.specks().iter().zip(b.specks()) {
1279                assert_eq!(left.pos, right.pos);
1280            }
1281        }
1282    }
1283}
1284
1285#[cfg(test)]
1286mod gpu_tests {
1287    use super::*;
1288    use pfx_geom::tree::TreeSpec;
1289    use pfx_gpu::{Gpu, GpuProfiler};
1290
1291    #[test]
1292    #[ignore = "needs a GPU; run under pgpu"]
1293    fn low_sun_cookie_centroid() {
1294        let gpu = pollster::block_on(Gpu::headless()).unwrap();
1295        let tree = Tree::new(9, TreeSpec::plum(), 0.02);
1296        let mut canopy = Canopy::new(&gpu.device, &gpu.queue, &tree, 256);
1297        let origin = [2.0, 0.0, -3.0];
1298        for hour in [7.0, 18.0] {
1299            let sun = pfx_core::daylight::Daylight {
1300                hour,
1301                day: pfx_core::daylight::Daylight::DAY,
1302                latitude: pfx_core::daylight::Daylight::LATITUDE,
1303                heading: pfx_core::daylight::Daylight::HEADING,
1304            }
1305            .sun()
1306            .y_up
1307            .map(|value| value as f32);
1308            let mut encoder = gpu.device.create_command_encoder(&Default::default());
1309            canopy.update(
1310                &gpu.queue,
1311                &mut encoder,
1312                CanopyFrame {
1313                    origin,
1314                    time: 0.0,
1315                    strength: 0.0,
1316                    direction: [1.0, 0.0, 0.0],
1317                    sun,
1318                    receiver_y: 0.0,
1319                    angular_radius: 0.00465,
1320                },
1321                None,
1322            );
1323            let readback = gpu.device.create_buffer(&wgpu::BufferDescriptor {
1324                label: Some("low sun cookie readback"),
1325                size: 256 * 256,
1326                usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
1327                mapped_at_creation: false,
1328            });
1329            encoder.copy_texture_to_buffer(
1330                canopy.texture.as_image_copy(),
1331                wgpu::TexelCopyBufferInfo {
1332                    buffer: &readback,
1333                    layout: wgpu::TexelCopyBufferLayout {
1334                        offset: 0,
1335                        bytes_per_row: Some(256),
1336                        rows_per_image: Some(256),
1337                    },
1338                },
1339                wgpu::Extent3d {
1340                    width: 256,
1341                    height: 256,
1342                    depth_or_array_layers: 1,
1343                },
1344            );
1345            gpu.queue.submit(Some(encoder.finish()));
1346            let (send, receive) = std::sync::mpsc::channel();
1347            readback
1348                .slice(..)
1349                .map_async(wgpu::MapMode::Read, move |result| {
1350                    send.send(result).unwrap();
1351                });
1352            gpu.device
1353                .poll(wgpu::PollType::wait_indefinitely())
1354                .unwrap();
1355            receive.recv().unwrap().unwrap();
1356            let mapped = readback
1357                .slice(..)
1358                .get_mapped_range()
1359                .expect("a mapped range");
1360            let mut sum = [0.0_f32; 2];
1361            let mut weight = 0.0_f32;
1362            for (index, alpha) in mapped.iter().enumerate() {
1363                let x = index % 256;
1364                let y = index / 256;
1365                let uv = [(x as f32 + 0.5) / 256.0, (y as f32 + 0.5) / 256.0];
1366                let world = [
1367                    canopy.center[0] + (uv[0] - 0.5) * canopy.extent[0],
1368                    canopy.center[1] + (uv[1] - 0.5) * canopy.extent[1],
1369                ];
1370                let alpha = f32::from(*alpha) / 255.0;
1371                sum[0] += world[0] * alpha;
1372                sum[1] += world[1] * alpha;
1373                weight += alpha;
1374            }
1375            assert!(weight > 1.0);
1376            let centroid = [sum[0] / weight, sum[1] / weight];
1377            let [u, v] = projection_axes(sun);
1378            let crown = [origin[0], origin[1] + tree.spec.height * 0.8, origin[2]];
1379            let expected = [dot3(crown, u), dot3(crown, v)];
1380            assert!((centroid[0] - expected[0]).abs() < canopy.extent[0] * 0.2);
1381            assert!((centroid[1] - expected[1]).abs() < canopy.extent[1] * 0.2);
1382            drop(mapped);
1383            readback.unmap();
1384        }
1385    }
1386
1387    #[repr(C)]
1388    #[derive(Clone, Copy, Pod, Zeroable)]
1389    struct WoodVertex {
1390        position: [f32; 3],
1391        pivot: [f32; 3],
1392        level: f32,
1393        stiffness: f32,
1394    }
1395
1396    const WOOD_WGSL: &str = r#"
1397struct CanopyParams {
1398    center_extent: vec4f,
1399    sun_receiver: vec4f,
1400    wind: vec4f,
1401    direction: vec4f,
1402    projected_extent: vec4f,
1403}
1404@group(0) @binding(0) var<uniform> params: CanopyParams;
1405fn tree_sway(rest: vec3f, pivot: vec3f, level: f32, stiffness: f32, wind_time: f32, strength: f32, direction: vec3f) -> vec3f {
1406    let d = normalize(direction.xz + vec2f(0.00001, 0.0));
1407    let arm = length(rest - pivot);
1408    let phase = wind_time * (0.7 + level * 0.41) + pivot.x * 0.83 + pivot.z * 0.67;
1409    let bend = sin(phase) * strength * (1.0 - stiffness) * arm * (0.012 + 0.009 * level);
1410    return rest + vec3f(d.x * bend, -abs(bend) * 0.12, d.y * bend);
1411}
1412struct Wood { @location(0) position: vec3f, @location(1) pivot: vec3f, @location(2) level: f32, @location(3) stiffness: f32, }
1413@vertex fn vertex(input: Wood) -> @builtin(position) vec4f {
1414    let p = tree_sway(input.position, input.pivot, input.level, input.stiffness, params.wind.x, params.wind.y, params.direction.xyz);
1415    return vec4f(p.x / 8.0, (p.y - p.z) / 14.0, 0.0, 1.0);
1416}
1417@fragment fn fragment() -> @location(0) vec4f { return vec4f(0.29, 0.16, 0.11, 1.0); }
1418"#;
1419
1420    const GROUND_WGSL: &str = r#"
1421@vertex fn vertex(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {
1422    let p = array<vec2f, 3>(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0));
1423    return vec4f(p[i], 0.0, 1.0);
1424}
1425@fragment fn fragment(@builtin(position) pos: vec4f) -> @location(0) vec4f {
1426    let uv = pos.xy / vec2f(3840.0, 2160.0);
1427    let world = vec3f((uv.x * 2.0 - 1.0) * 8.0, 0.0, (uv.y * 2.0 - 1.0) * 14.0);
1428    let light = canopy_light(world);
1429    return vec4f(vec3f(0.65, 0.74, 0.52) * (0.3 + 0.7 * light), 1.0);
1430}
1431"#;
1432
1433    #[test]
1434    fn preview_shaders_validate() {
1435        for source in [
1436            WOOD_WGSL.to_string(),
1437            format!("{}\n{GROUND_WGSL}", canopy_light_wgsl(&Gobo::default())),
1438        ] {
1439            let module = naga::front::wgsl::parse_str(&source).unwrap();
1440            naga::valid::Validator::new(
1441                naga::valid::ValidationFlags::all(),
1442                naga::valid::Capabilities::all(),
1443            )
1444            .validate(&module)
1445            .unwrap();
1446        }
1447    }
1448
1449    fn pipeline(
1450        device: &wgpu::Device,
1451        shader: &str,
1452        layout: &wgpu::PipelineLayout,
1453        buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
1454        alpha: bool,
1455    ) -> wgpu::RenderPipeline {
1456        let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1457            label: Some("tree test shader"),
1458            source: wgpu::ShaderSource::Wgsl(shader.into()),
1459        });
1460        device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1461            label: Some("tree test pipeline"),
1462            layout: Some(layout),
1463            vertex: wgpu::VertexState {
1464                module: &module,
1465                entry_point: Some("vertex"),
1466                compilation_options: Default::default(),
1467                buffers,
1468            },
1469            fragment: Some(wgpu::FragmentState {
1470                module: &module,
1471                entry_point: Some("fragment"),
1472                compilation_options: Default::default(),
1473                targets: &[Some(wgpu::ColorTargetState {
1474                    format: wgpu::TextureFormat::Rgba8UnormSrgb,
1475                    blend: alpha.then_some(wgpu::BlendState::ALPHA_BLENDING),
1476                    write_mask: wgpu::ColorWrites::ALL,
1477                })],
1478            }),
1479            primitive: wgpu::PrimitiveState {
1480                cull_mode: None,
1481                ..Default::default()
1482            },
1483            depth_stencil: None,
1484            multisample: Default::default(),
1485            multiview_mask: None,
1486            cache: None,
1487        })
1488    }
1489
1490    #[test]
1491    #[ignore = "needs a GPU; run under pgpu"]
1492    fn cherry_over_ground_at_two_wind_times() {
1493        let gpu = pollster::block_on(Gpu::headless()).unwrap();
1494        let tree = Tree::new(49, TreeSpec::cherry(), 0.004);
1495        let mut canopy = Canopy::new(&gpu.device, &gpu.queue, &tree, DEFAULT_RESOLUTION);
1496        let vertices: Vec<WoodVertex> = tree
1497            .wood
1498            .positions
1499            .iter()
1500            .zip(&tree.sway)
1501            .map(|(position, sway)| WoodVertex {
1502                position: *position,
1503                pivot: sway.pivot,
1504                level: sway.level,
1505                stiffness: sway.stiffness,
1506            })
1507            .collect();
1508        let vertex_buffer = gpu
1509            .device
1510            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
1511                label: Some("cherry wood vertices"),
1512                contents: bytemuck::cast_slice(&vertices),
1513                usage: wgpu::BufferUsages::VERTEX,
1514            });
1515        let index_buffer = gpu
1516            .device
1517            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
1518                label: Some("cherry wood indices"),
1519                contents: bytemuck::cast_slice(&tree.wood.indices),
1520                usage: wgpu::BufferUsages::INDEX,
1521            });
1522        let empty = gpu
1523            .device
1524            .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1525                label: Some("unused ground group"),
1526                entries: &[],
1527            });
1528        let empty_bind = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor {
1529            label: Some("unused ground bind"),
1530            layout: &empty,
1531            entries: &[],
1532        });
1533        let ground_layout = gpu
1534            .device
1535            .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1536                label: Some("ground layout"),
1537                bind_group_layouts: &[
1538                    Some(&empty),
1539                    Some(&empty),
1540                    Some(&empty),
1541                    Some(canopy.sample_layout()),
1542                ],
1543                immediate_size: 0,
1544            });
1545        let wood_bind_layout =
1546            gpu.device
1547                .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1548                    label: Some("wood wind layout"),
1549                    entries: &[wgpu::BindGroupLayoutEntry {
1550                        binding: 0,
1551                        visibility: wgpu::ShaderStages::VERTEX,
1552                        ty: wgpu::BindingType::Buffer {
1553                            ty: wgpu::BufferBindingType::Uniform,
1554                            has_dynamic_offset: false,
1555                            min_binding_size: None,
1556                        },
1557                        count: None,
1558                    }],
1559                });
1560        let wood_bind = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor {
1561            label: Some("wood wind bind"),
1562            layout: &wood_bind_layout,
1563            entries: &[wgpu::BindGroupEntry {
1564                binding: 0,
1565                resource: canopy.params.as_entire_binding(),
1566            }],
1567        });
1568        let wood_layout = gpu
1569            .device
1570            .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1571                label: Some("wood layout"),
1572                bind_group_layouts: &[Some(&wood_bind_layout)],
1573                immediate_size: 0,
1574            });
1575        let camera_layout = gpu
1576            .device
1577            .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1578                label: Some("cherry camera layout"),
1579                entries: &[wgpu::BindGroupLayoutEntry {
1580                    binding: 0,
1581                    visibility: wgpu::ShaderStages::VERTEX,
1582                    ty: wgpu::BindingType::Buffer {
1583                        ty: wgpu::BufferBindingType::Uniform,
1584                        has_dynamic_offset: false,
1585                        min_binding_size: None,
1586                    },
1587                    count: None,
1588                }],
1589            });
1590        let camera: [f32; 16] = [
1591            1.0 / 8.0,
1592            0.0,
1593            0.0,
1594            0.0,
1595            0.0,
1596            1.0 / 14.0,
1597            0.0,
1598            0.0,
1599            0.0,
1600            -1.0 / 14.0,
1601            0.0,
1602            0.0,
1603            0.0,
1604            0.0,
1605            0.0,
1606            1.0,
1607        ];
1608        let camera_buffer = gpu
1609            .device
1610            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
1611                label: Some("cherry camera"),
1612                contents: bytemuck::cast_slice(&camera),
1613                usage: wgpu::BufferUsages::UNIFORM,
1614            });
1615        let camera_bind = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor {
1616            label: Some("cherry camera bind"),
1617            layout: &camera_layout,
1618            entries: &[wgpu::BindGroupEntry {
1619                binding: 0,
1620                resource: camera_buffer.as_entire_binding(),
1621            }],
1622        });
1623        let visible_layout = gpu
1624            .device
1625            .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1626                label: Some("cherry cards layout"),
1627                bind_group_layouts: &[Some(canopy.cookie_layout()), Some(&camera_layout)],
1628                immediate_size: 0,
1629            });
1630        let ground = pipeline(
1631            &gpu.device,
1632            &format!("{}\n{GROUND_WGSL}", canopy_light_wgsl(&Gobo::default())),
1633            &ground_layout,
1634            &[],
1635            false,
1636        );
1637        let wood = pipeline(
1638            &gpu.device,
1639            WOOD_WGSL,
1640            &wood_layout,
1641            &[Some(wgpu::VertexBufferLayout {
1642                array_stride: 32,
1643                step_mode: wgpu::VertexStepMode::Vertex,
1644                attributes: &[
1645                    wgpu::VertexAttribute {
1646                        format: wgpu::VertexFormat::Float32x3,
1647                        offset: 0,
1648                        shader_location: 0,
1649                    },
1650                    wgpu::VertexAttribute {
1651                        format: wgpu::VertexFormat::Float32x3,
1652                        offset: 12,
1653                        shader_location: 1,
1654                    },
1655                    wgpu::VertexAttribute {
1656                        format: wgpu::VertexFormat::Float32,
1657                        offset: 24,
1658                        shader_location: 2,
1659                    },
1660                    wgpu::VertexAttribute {
1661                        format: wgpu::VertexFormat::Float32,
1662                        offset: 28,
1663                        shader_location: 3,
1664                    },
1665                ],
1666            })],
1667            false,
1668        );
1669        let visible = pipeline(
1670            &gpu.device,
1671            &canopy_visible_wgsl(&CardColors::default()),
1672            &visible_layout,
1673            &[],
1674            true,
1675        );
1676        let ground_bind = canopy.sample_bind(&gpu.device);
1677        let target = gpu
1678            .offscreen(3840, 2160, wgpu::TextureFormat::Rgba8UnormSrgb)
1679            .unwrap();
1680        let mut images = Vec::new();
1681        for (index, time) in [0.0_f32, 2.3].into_iter().enumerate() {
1682            let mut profiler = GpuProfiler::new(&gpu.device, &gpu.queue);
1683            let mut encoder = gpu.device.create_command_encoder(&Default::default());
1684            let cookie_time = profiler.pass("canopy cookie");
1685            let wood_time = profiler.pass("cherry draw 4K");
1686            canopy.update(
1687                &gpu.queue,
1688                &mut encoder,
1689                CanopyFrame {
1690                    origin: [0.0; 3],
1691                    time,
1692                    strength: 2.0,
1693                    direction: [1.0, 0.0, 0.35],
1694                    sun: [0.25, 0.95, 0.15],
1695                    receiver_y: 0.0,
1696                    angular_radius: 0.00465,
1697                },
1698                profiler.render_writes(cookie_time),
1699            );
1700            {
1701                let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1702                    multiview_mask: None,
1703                    label: Some("ground and cherry"),
1704                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1705                        view: &target.view,
1706                        resolve_target: None,
1707                        ops: wgpu::Operations {
1708                            load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1709                            store: wgpu::StoreOp::Store,
1710                        },
1711                        depth_slice: None,
1712                    })],
1713                    depth_stencil_attachment: None,
1714                    timestamp_writes: None,
1715                    occlusion_query_set: None,
1716                });
1717                pass.set_pipeline(&ground);
1718                pass.set_bind_group(0, &empty_bind, &[]);
1719                pass.set_bind_group(1, &empty_bind, &[]);
1720                pass.set_bind_group(2, &empty_bind, &[]);
1721                pass.set_bind_group(3, &ground_bind, &[]);
1722                pass.draw(0..3, 0..1);
1723            }
1724            {
1725                let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1726                    multiview_mask: None,
1727                    label: Some("cherry draw"),
1728                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1729                        view: &target.view,
1730                        resolve_target: None,
1731                        ops: wgpu::Operations {
1732                            load: wgpu::LoadOp::Load,
1733                            store: wgpu::StoreOp::Store,
1734                        },
1735                        depth_slice: None,
1736                    })],
1737                    depth_stencil_attachment: None,
1738                    timestamp_writes: profiler.render_writes(wood_time),
1739                    occlusion_query_set: None,
1740                });
1741                pass.set_pipeline(&wood);
1742                pass.set_bind_group(0, &wood_bind, &[]);
1743                pass.set_vertex_buffer(0, vertex_buffer.slice(..));
1744                pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint32);
1745                pass.draw_indexed(0..tree.wood.indices.len() as u32, 0, 0..1);
1746                pass.set_pipeline(&visible);
1747                pass.set_bind_group(0, canopy.cookie_bind(), &[]);
1748                pass.set_bind_group(1, &camera_bind, &[]);
1749                pass.draw(0..6, 0..canopy.card_count);
1750            }
1751            let slot = profiler.finish(&mut encoder);
1752            gpu.queue.submit(Some(encoder.finish()));
1753            if let Some(slot) = slot {
1754                profiler.submitted(slot);
1755                gpu.device
1756                    .poll(wgpu::PollType::wait_indefinitely())
1757                    .unwrap();
1758                for t in profiler.collect(&gpu.device).into_iter().flatten() {
1759                    eprintln!("{} at {}: {:.3} ms", t.label, time, t.milliseconds);
1760                }
1761            }
1762            let pixels = gpu.readback_rgba8(&target).unwrap();
1763            let output = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tmp");
1764            std::fs::create_dir_all(&output).unwrap();
1765            let file =
1766                std::fs::File::create(output.join(format!("trees-wind-{index}.png"))).unwrap();
1767            let mut png = png::Encoder::new(file, 3840, 2160);
1768            png.set_color(png::ColorType::Rgba);
1769            png.set_depth(png::BitDepth::Eight);
1770            png.write_header()
1771                .unwrap()
1772                .write_image_data(&pixels)
1773                .unwrap();
1774            images.push(pixels);
1775        }
1776        let mut single = tree.clone();
1777        let card = tree
1778            .cards
1779            .iter()
1780            .max_by(|a, b| {
1781                let projected = |c: &pfx_geom::tree::Card| {
1782                    (c.right[0] * c.up[2] - c.right[2] * c.up[0]).abs() * c.size[0] * c.size[1]
1783                };
1784                projected(a).total_cmp(&projected(b))
1785            })
1786            .copied()
1787            .unwrap();
1788        single.cards = vec![card];
1789        let mut one = Canopy::new(&gpu.device, &gpu.queue, &single, DEFAULT_RESOLUTION);
1790        let mut encoder = gpu.device.create_command_encoder(&Default::default());
1791        one.update(
1792            &gpu.queue,
1793            &mut encoder,
1794            CanopyFrame {
1795                origin: [0.0; 3],
1796                time: 0.0,
1797                strength: 0.0,
1798                direction: [1.0, 0.0, 0.0],
1799                sun: [0.0, 1.0, 0.0],
1800                receiver_y: 0.0,
1801                angular_radius: 0.00465,
1802            },
1803            None,
1804        );
1805        let row = DEFAULT_RESOLUTION;
1806        let buffer = gpu.device.create_buffer(&wgpu::BufferDescriptor {
1807            label: Some("one card cookie readback"),
1808            size: u64::from(row) * u64::from(row),
1809            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
1810            mapped_at_creation: false,
1811        });
1812        encoder.copy_texture_to_buffer(
1813            one.texture.as_image_copy(),
1814            wgpu::TexelCopyBufferInfo {
1815                buffer: &buffer,
1816                layout: wgpu::TexelCopyBufferLayout {
1817                    offset: 0,
1818                    bytes_per_row: Some(row),
1819                    rows_per_image: Some(row),
1820                },
1821            },
1822            wgpu::Extent3d {
1823                width: row,
1824                height: row,
1825                depth_or_array_layers: 1,
1826            },
1827        );
1828        gpu.queue.submit(Some(encoder.finish()));
1829        let (send, recv) = std::sync::mpsc::channel();
1830        buffer
1831            .slice(..)
1832            .map_async(wgpu::MapMode::Read, move |result| {
1833                send.send(result).unwrap();
1834            });
1835        gpu.device
1836            .poll(wgpu::PollType::wait_indefinitely())
1837            .unwrap();
1838        recv.recv().unwrap().unwrap();
1839        let mapped = buffer.slice(..).get_mapped_range().expect("a mapped range");
1840        let covered =
1841            mapped.iter().map(|v| *v as f32 / 255.0).sum::<f32>() * one.extent[0] * one.extent[1]
1842                / (row * row) as f32;
1843        let expected = projected_coverage(&single, 0.0, 0.0, [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], 0.0);
1844        assert!(
1845            (covered - expected).abs() < expected * 0.35,
1846            "cookie area {covered} versus projected {expected}"
1847        );
1848        drop(mapped);
1849        buffer.unmap();
1850        let sway_shader = format!(
1851            "{}\n@group(0) @binding(0) var<storage, read_write> result: array<vec4f, 1>; @compute @workgroup_size(1) fn main() {{ result[0] = vec4f(tree_sway(vec3f(1.0, 6.0, 1.0), vec3f(0.0, 5.0, 0.0), 2.0, 0.18, 1.0, 2.0, vec3f(1.0, 0.0, 0.3)), 1.0); }}",
1852            pfx_geom::tree::TREE_SWAY_WGSL
1853        );
1854        let module = gpu
1855            .device
1856            .create_shader_module(wgpu::ShaderModuleDescriptor {
1857                label: Some("tree sway reference"),
1858                source: wgpu::ShaderSource::Wgsl(sway_shader.into()),
1859            });
1860        let layout = gpu
1861            .device
1862            .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1863                label: Some("tree sway reference layout"),
1864                entries: &[wgpu::BindGroupLayoutEntry {
1865                    binding: 0,
1866                    visibility: wgpu::ShaderStages::COMPUTE,
1867                    ty: wgpu::BindingType::Buffer {
1868                        ty: wgpu::BufferBindingType::Storage { read_only: false },
1869                        has_dynamic_offset: false,
1870                        min_binding_size: None,
1871                    },
1872                    count: None,
1873                }],
1874            });
1875        let pipeline_layout = gpu
1876            .device
1877            .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1878                label: Some("tree sway reference pipeline layout"),
1879                bind_group_layouts: &[Some(&layout)],
1880                immediate_size: 0,
1881            });
1882        let pipeline = gpu
1883            .device
1884            .create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
1885                label: Some("tree sway reference pipeline"),
1886                layout: Some(&pipeline_layout),
1887                module: &module,
1888                entry_point: Some("main"),
1889                compilation_options: Default::default(),
1890                cache: None,
1891            });
1892        let output = gpu.device.create_buffer(&wgpu::BufferDescriptor {
1893            label: Some("tree sway result"),
1894            size: 16,
1895            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
1896            mapped_at_creation: false,
1897        });
1898        let readback = gpu.device.create_buffer(&wgpu::BufferDescriptor {
1899            label: Some("tree sway readback"),
1900            size: 16,
1901            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
1902            mapped_at_creation: false,
1903        });
1904        let bind = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor {
1905            label: Some("tree sway reference bind"),
1906            layout: &layout,
1907            entries: &[wgpu::BindGroupEntry {
1908                binding: 0,
1909                resource: output.as_entire_binding(),
1910            }],
1911        });
1912        let mut encoder = gpu.device.create_command_encoder(&Default::default());
1913        {
1914            let mut pass = encoder.begin_compute_pass(&Default::default());
1915            pass.set_pipeline(&pipeline);
1916            pass.set_bind_group(0, &bind, &[]);
1917            pass.dispatch_workgroups(1, 1, 1);
1918        }
1919        encoder.copy_buffer_to_buffer(&output, 0, &readback, 0, 16);
1920        gpu.queue.submit(Some(encoder.finish()));
1921        let (send, recv) = std::sync::mpsc::channel();
1922        readback
1923            .slice(..)
1924            .map_async(wgpu::MapMode::Read, move |result| {
1925                send.send(result).unwrap();
1926            });
1927        gpu.device
1928            .poll(wgpu::PollType::wait_indefinitely())
1929            .unwrap();
1930        recv.recv().unwrap().unwrap();
1931        let mapped = readback
1932            .slice(..)
1933            .get_mapped_range()
1934            .expect("a mapped range");
1935        let expected = tree_sway(
1936            [1.0, 6.0, 1.0],
1937            [0.0, 5.0, 0.0],
1938            2.0,
1939            0.18,
1940            1.0,
1941            2.0,
1942            [1.0, 0.0, 0.3],
1943        );
1944        for (i, value) in expected.into_iter().enumerate() {
1945            let actual = f32::from_le_bytes(mapped[i * 4..i * 4 + 4].try_into().unwrap());
1946            assert!(
1947                (actual - value).abs() < 1e-4,
1948                "sway component {i}: {actual} versus {value}"
1949            );
1950        }
1951        drop(mapped);
1952        readback.unmap();
1953        let blossoms = images[0]
1954            .chunks_exact(4)
1955            .filter(|pixel| pixel[0] > pixel[1].saturating_add(10) && pixel[2] > pixel[1])
1956            .count();
1957        assert!(blossoms > 1000, "only {blossoms} blossom pixels");
1958        let changed = images[0]
1959            .chunks_exact(4)
1960            .zip(images[1].chunks_exact(4))
1961            .filter(|(a, b)| a != b)
1962            .count();
1963        assert!(changed > 1000, "only {changed} pixels changed");
1964    }
1965}