Skip to main content

unlit3d_examples/scenes/
custom_pipeline.rs

1//! A caller-owned pipeline: a procedural sky and an animated particle cloud.
2//!
3//! Every other scene in the example draws through the built-in lit family.
4//! This one registers two families of its own — pipelines, vertex layouts and
5//! bind groups the example owns — to pin the parts of the renderer a caller
6//! reaches without going through the built-in shader at all:
7//!
8//! * a **sky** family whose shader reconstructs a view ray from the frame's
9//!   camera, samples a procedurally generated cube map, and blends a gradient
10//!   and a sun from a uniform block the example writes every frame. Its only
11//!   bind group is the caller's own: the family names its three inputs when it
12//!   makes the recipe, so the group is rebuilt when one of them is replaced and
13//!   never because a draw moved to another camera;
14//! * a **particle** family whose per-instance stream comes from an ordinary
15//!   world component, and whose vertex stage animates the sprite from a clock
16//!   the example advances. It takes the frame-bound recipe for the camera, as a
17//!   built-in family does, and adds its own uniform block at the material group.
18//!
19//! Neither family declares a single mesh vertex attribute: the sky's three
20//! vertices and each particle's six come from the vertex index, so the meshes
21//! carry nothing but a count and a bounding box. The animation is parametric:
22//! the camera orbits the cloud while the particles drift and spin from one
23//! clock, so the window drives it from the frame delta instead of holding each
24//! frozen frame, and its snapshots freeze eight instants of that orbit. Its
25//! look is deliberately flat: banded colours, hard-edged sprites and a
26//! hard-edged sun rather than any attempt at a photographic sky.
27
28use std::sync::Arc;
29use unlit_ecs::{Bundle, DynamicBundle, bundle};
30
31use super::{
32    SEQUENCE_STEP, SceneControl, SceneDef, SceneOptions, TEST_FORMAT, TEST_SIZE, camera_looking_at,
33};
34use unlit_wgpu::pipeline::{GlobalBindings, global_bind_group_layout, supports_storage_buffers};
35use unlit_wgpu::resources::{ResHandle, ResourceGraph, TextureExt, TextureView};
36use unlit_wgpu::specialize::{
37    PipelineVariant, RenderPipelineDesc, SpecializedPipeline, SurfaceKey, SurfaceTarget,
38};
39use unlit3d::pipeline::{
40    DrawContext, FamilyContext, GlobalRecipe, InstanceContext, InstanceData, InstanceStreamDesc,
41    RegisteredRenderPipeline, RenderPipelineFactory, RenderPipelineKey,
42};
43use unlit3d::prelude::*;
44use zerocopy::IntoBytes;
45
46/// How many frames the scene freezes.
47const FRAMES: u32 = 8;
48
49/// The format of the procedural cube map.
50const SKY_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8UnormSrgb;
51
52/// The edge of one cube face of the procedural sky, in texels.
53///
54/// The face is written straight into the texture, so the row pitch has to
55/// satisfy the copy alignment; the pitch is computed from this and the format
56/// rather than written out.
57const SKY_FACE_SIZE: u32 = 256;
58
59/// The vertices of the sky's full-target triangle.
60const SKY_VERTICES: u32 = 3;
61
62/// How far the sky's bounding box reaches, in metres.
63///
64/// The triangle is authored in clip space and ignores the transform entirely,
65/// so the box exists only to keep the frustum cull from dropping the draw.
66const SKY_EXTENT: f32 = 1.0e4;
67
68/// The vertices of one particle's billboard quad.
69const PARTICLE_VERTICES: u32 = 6;
70
71/// How many particles the cloud holds.
72const PARTICLE_COUNT: usize = 22;
73
74/// The vertex-buffer slot the particle family's instance stream takes.
75const PARTICLE_INSTANCE_SLOT: u32 = 1;
76
77/// The radius the camera orbits at, in metres.
78const ORBIT_RADIUS: f32 = 4.4;
79
80/// The height the camera orbits at, in metres.
81const ORBIT_HEIGHT: f32 = 2.1;
82
83/// The angle the orbit starts at, in radians.
84const ORBIT_START: f32 = 0.30;
85
86/// Radians the orbit turns between two frames of the frozen sequence.
87///
88/// Eight of them sweep a little over half a turn, so the sun crosses the frame
89/// and the sky turns under it.
90const ORBIT_STEP: f32 = 0.42;
91
92/// Radians the window turns the orbit per second.
93///
94/// The frozen sweep holds one step for a `SEQUENCE_STEP`, so a continuous run
95/// turning at this rate shows the same orbit at the pace its snapshots imply.
96const ORBIT_RADIANS_PER_SECOND: f32 = ORBIT_STEP / SEQUENCE_STEP;
97
98/// The point the camera looks at.
99const CAMERA_TARGET: glam::Vec3 = glam::Vec3::new(0.0, 1.05, 0.0);
100
101/// The direction the sun sits in, in world space.
102const SUN_DIRECTION: glam::Vec3 = glam::Vec3::new(-0.45, 0.30, -0.84);
103
104/// The hues the particles cycle through.
105///
106/// Flat and saturated rather than lit: a sprite's whole colour is this one,
107/// with no shading and no falloff.
108const PARTICLE_TINTS: [glam::Vec4; 5] = [
109    glam::Vec4::new(1.00, 0.35, 0.62, 1.0),
110    glam::Vec4::new(0.25, 0.85, 1.00, 1.0),
111    glam::Vec4::new(1.00, 0.86, 0.25, 1.0),
112    glam::Vec4::new(0.35, 1.00, 0.62, 1.0),
113    glam::Vec4::new(0.70, 0.50, 1.00, 1.0),
114];
115
116/// The scene: a procedural sky and an orbiting particle cloud, both drawn by
117/// families the example owns and animated in real time from the frame delta.
118pub static SCENE: SceneDef = SceneDef {
119    id: "custom_pipeline",
120    title: "Custom pipeline: sky and particles",
121    description: "a caller-registered sky family and particle family",
122    size: TEST_SIZE,
123    baseline: Some(TEST_SIZE),
124    clear: None,
125    frames: FRAMES,
126    // The motion is parametric, so the window runs it from the frame delta
127    // rather than holding each frozen frame for a step; the capture still walks
128    // the sequence its snapshots store.
129    step_seconds: None,
130    samples: 1,
131    depth: true,
132    ui: false,
133    reproducible_ui: false,
134    build,
135};
136
137/// The sky family's uniform block, as the shader reads it.
138///
139/// The example writes this every frame: the three band colours, the sun's
140/// direction and colour, the view-to-world rotation the ray is built through,
141/// and the projection's half-angle tangents. The last two are the reason the
142/// sky needs no camera of its own — it derives the ray from the frame's camera
143/// rather than from a hard-coded field of view.
144#[repr(C)]
145#[derive(
146    Clone,
147    Copy,
148    Debug,
149    PartialEq,
150    zerocopy_derive::FromBytes,
151    zerocopy_derive::Immutable,
152    zerocopy_derive::IntoBytes,
153    zerocopy_derive::KnownLayout,
154    const_shader_layout::ShaderLayoutCompat,
155)]
156struct SkyUniforms {
157    /// The colour overhead.
158    zenith: glam::Vec4,
159    /// The colour at the horizon.
160    horizon: glam::Vec4,
161    /// The colour below the horizon.
162    ground: glam::Vec4,
163    /// The sun's world-space direction, with the cloud opacity in w.
164    sun_direction: glam::Vec4,
165    /// The sun's colour.
166    sun_color: glam::Vec4,
167    /// The rotation the view ray is taken into world space through.
168    view_rotation: glam::Mat4,
169    /// The projection's half-angle tangents in x and y.
170    projection: glam::Vec4,
171}
172
173/// The particle family's uniform block, as the shader reads it.
174#[repr(C)]
175#[derive(
176    Clone,
177    Copy,
178    Debug,
179    PartialEq,
180    zerocopy_derive::FromBytes,
181    zerocopy_derive::Immutable,
182    zerocopy_derive::IntoBytes,
183    zerocopy_derive::KnownLayout,
184    const_shader_layout::ShaderLayoutCompat,
185)]
186struct ParticleUniforms {
187    /// The scene clock in x, in seconds.
188    clock: glam::Vec4,
189}
190
191/// One particle's record on the instance stream.
192///
193/// The layout is the example's own: three vec4s where the built-in family's
194/// record has a transform, a colour and a handful of indices.
195#[repr(C)]
196#[derive(
197    Clone,
198    Copy,
199    Debug,
200    zerocopy_derive::FromBytes,
201    zerocopy_derive::Immutable,
202    zerocopy_derive::IntoBytes,
203    zerocopy_derive::KnownLayout,
204)]
205struct ParticleRecord {
206    /// The orbit's centre in xyz, with the phase offset in w.
207    origin: [f32; 4],
208    /// The orbit's radius in x, its speed in y, the sprite's size in z and the
209    /// vertical bob's amplitude in w.
210    motion: [f32; 4],
211    /// The sprite's colour in rgb and its shape in a.
212    tint: [f32; 4],
213}
214
215/// The per-particle data the family reads, as a plain world component.
216#[derive(Clone, Copy, Debug)]
217struct Particle {
218    /// The centre the particle orbits.
219    origin: glam::Vec3,
220    /// The offset into the orbit's cycle.
221    phase: f32,
222    /// The radius of the orbit.
223    radius: f32,
224    /// The angular speed of the orbit, in radians per second.
225    speed: f32,
226    /// The sprite's half-size, in metres.
227    size: f32,
228    /// How far the particle bobs vertically.
229    bob: f32,
230    /// Which flat shape the sprite is: a disc, a star or a ring.
231    shape: f32,
232    /// The sprite's colour.
233    tint: glam::Vec4,
234}
235
236/// The scene's sky: the mesh that covers the target and the pipeline that
237/// shades it.
238///
239/// The pipeline is compiled for the mesh's vertex layout, so the two are
240/// spawned as one object rather than as two independent components.
241struct Sky {
242    mesh: GpuMesh,
243    pipeline: GpuRenderPipeline,
244}
245
246impl Bundle for Sky {
247    fn into_bundle(self) -> DynamicBundle {
248        bundle![self.mesh, self.pipeline]
249    }
250}
251
252/// One particle of the cloud.
253///
254/// The mesh, the pipeline and the material are shared by the whole cloud — one
255/// of each, so the renderer folds every sprite into a single instanced draw —
256/// while the record is what places this one; a spawn states all four together.
257struct Sprite {
258    mesh: GpuMesh,
259    pipeline: GpuRenderPipeline,
260    material: GpuMaterial,
261    particle: Particle,
262}
263
264impl Bundle for Sprite {
265    fn into_bundle(self) -> DynamicBundle {
266        bundle![self.mesh, self.pipeline, self.material, self.particle]
267    }
268}
269
270/// The sky's shader.
271///
272/// The vertex stage emits a triangle that covers the whole target at the far
273/// plane of the frame's reverse-z projection, so the fragment stage runs once
274/// per pixel that no opaque draw has covered. It rebuilds the view ray through
275/// the pixel from the uniform block's projection and rotation, paints the
276/// gradient in flat bands, draws the sun as a hard-edged disc, and lays the
277/// procedural cube map's clouds over it.
278const SKY_SHADER: &str = r#"
279struct SkyUniforms {
280    zenith: vec4<f32>,
281    horizon: vec4<f32>,
282    ground: vec4<f32>,
283    sun_direction: vec4<f32>,
284    sun_color: vec4<f32>,
285    view_rotation: mat4x4<f32>,
286    projection: vec4<f32>,
287};
288
289@group(0) @binding(0) var<uniform> sky: SkyUniforms;
290@group(0) @binding(1) var sky_cube: texture_cube<f32>;
291@group(0) @binding(2) var sky_sampler: sampler;
292
293struct VertexOutput {
294    @builtin(position) position: vec4<f32>,
295    @location(0) ndc: vec2<f32>,
296};
297
298@vertex
299fn vs_main(@builtin(vertex_index) index: u32) -> VertexOutput {
300    // The one triangle that covers the target, with its far corners outside it.
301    var corners = array<vec2<f32>, 3>(
302        vec2<f32>(-1.0, -1.0),
303        vec2<f32>( 3.0, -1.0),
304        vec2<f32>(-1.0,  3.0),
305    );
306    let corner = corners[index];
307    var out: VertexOutput;
308    // z is the reverse-z far plane, which the depth clear already holds, so the
309    // sky passes the test wherever nothing closer has been drawn.
310    out.position = vec4<f32>(corner, 0.0, 1.0);
311    out.ndc = corner;
312    return out;
313}
314
315@fragment
316fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
317    let view_direction = vec3<f32>(
318        input.ndc.x * sky.projection.x,
319        input.ndc.y * sky.projection.y,
320        -1.0,
321    );
322    let direction = normalize((sky.view_rotation * vec4<f32>(view_direction, 0.0)).xyz);
323
324    let elevation = direction.y;
325    // Both halves are painted in flat bands rather than blended: a cartoon sky
326    // shows the steps it was painted in. Each band is spaced by a power of the
327    // elevation, because the camera looks along the horizon and would otherwise
328    // see the first band of a half alone.
329    //
330    // The lower half keeps the same banded language as the upper one, darkening
331    // in steps from the horizon colour to the ground colour. Painting it one
332    // flat colour instead left a hard seam across the frame, with the saturated
333    // ground meeting the pale horizon with nothing between them.
334    let above = clamp(elevation, 0.0, 1.0);
335    let below = clamp(-elevation, 0.0, 1.0);
336    let band = floor(pow(above, 0.42) * 8.0) / 7.0;
337    let lower_band = floor(pow(below, 0.42) * 8.0) / 7.0;
338    let upper = mix(sky.horizon.rgb, sky.zenith.rgb, band);
339    let lower = mix(sky.horizon.rgb, sky.ground.rgb, lower_band);
340    let side = step(0.0, elevation);
341    var colour = mix(lower, upper, side);
342
343    // The sun is a disc with one hard edge, not a glow: past the limb there is
344    // nothing added at all.
345    let cosine = dot(direction, normalize(sky.sun_direction.xyz));
346    colour = mix(colour, sky.sun_color.rgb, step(0.988, cosine));
347
348    // The cube map holds flat clouds rather than a star field, and its alpha is
349    // the mask: a texel outside a cloud leaves the banded sky untouched.
350    //
351    // A cloud thins into the haze as it reaches the horizon rather than being
352    // masked off at it. Cutting them with a step at zero elevation sliced every
353    // puff the horizon crossed along a dead-straight line, which read as the
354    // sky being torn rather than as clouds meeting the ground.
355    let haze = smoothstep(0.0, 0.08, elevation);
356    let clouds = textureSample(sky_cube, sky_sampler, direction);
357    colour = mix(colour, clouds.rgb, clouds.a * sky.sun_direction.w * haze);
358
359    return vec4<f32>(colour, 1.0);
360}"#;
361
362/// The particle shader.
363///
364/// The mesh carries no vertex attributes at all: the six vertices of each
365/// sprite come from the vertex index, and the sprite's own placement comes from
366/// the instance stream. The global group is the renderer's own — the camera's
367/// view and projection matrices — while the clock lives in the family's own
368/// uniform block at the material group.
369const PARTICLE_SHADER: &str = r#"
370struct View {
371    clip_from_world: mat4x4<f32>,
372    view_from_world: mat4x4<f32>,
373    camera_position: vec4<f32>,
374};
375
376struct Globals {
377    time: f32,
378    delta_time: f32,
379    frame_count: u32,
380    pad0: u32,
381};
382
383struct ParticleUniforms {
384    clock: vec4<f32>,
385};
386
387@group(0) @binding(0) var<uniform> view: View;
388@group(0) @binding(1) var<uniform> frame: Globals;
389@group(1) @binding(0) var<uniform> particles: ParticleUniforms;
390
391struct ParticleInput {
392    @builtin(vertex_index) vertex_index: u32,
393    @location(2) origin: vec4<f32>,
394    @location(3) motion: vec4<f32>,
395    @location(4) tint: vec4<f32>,
396};
397
398struct ParticleOutput {
399    @builtin(position) position: vec4<f32>,
400    @location(0) colour: vec3<f32>,
401    @location(1) corner: vec2<f32>,
402    @location(2) @interpolate(flat) shape: u32,
403};
404
405@vertex
406fn vs_main(input: ParticleInput) -> ParticleOutput {
407    var corners = array<vec2<f32>, 6>(
408        vec2<f32>(-1.0, -1.0),
409        vec2<f32>( 1.0, -1.0),
410        vec2<f32>(-1.0,  1.0),
411        vec2<f32>(-1.0,  1.0),
412        vec2<f32>( 1.0, -1.0),
413        vec2<f32>( 1.0,  1.0),
414    );
415    let corner = corners[input.vertex_index];
416
417    let phase = input.origin.w + particles.clock.x * input.motion.y;
418    // The particle drifts around its own centre rather than spinning in place,
419    // so the cloud reshapes instead of only rotating.
420    let centre = vec3<f32>(
421        input.origin.x + cos(phase) * input.motion.x,
422        input.origin.y + sin(phase * 1.7) * input.motion.w,
423        input.origin.z + sin(phase) * input.motion.x,
424    );
425    let size = input.motion.z * (0.85 + 0.15 * sin(phase * 3.0));
426
427    // The camera's world-space axes are the view matrix's rows, which is what
428    // keeps the sprite facing the viewer whichever way the camera is turned.
429    let right = vec3<f32>(
430        view.view_from_world[0][0],
431        view.view_from_world[1][0],
432        view.view_from_world[2][0],
433    );
434    let up = vec3<f32>(
435        view.view_from_world[0][1],
436        view.view_from_world[1][1],
437        view.view_from_world[2][1],
438    );
439    let world = centre + right * (corner.x * size) + up * (corner.y * size);
440
441    var out: ParticleOutput;
442    out.position = view.clip_from_world * vec4<f32>(world, 1.0);
443    out.corner = corner;
444    // A flat fill: the sprite is one colour with no shading, and the spin is
445    // the whole of its motion.
446    out.colour = input.tint.rgb;
447    out.shape = u32(input.tint.a + 0.5);
448    return out;
449}
450
451@fragment
452fn fs_main(input: ParticleOutput) -> @location(0) vec4<f32> {
453    // Each sprite is one flat shape with a hard edge, drawn over the frame
454    // rather than added to it, so its colour is exactly the tint it was given.
455    if input.shape == 0u {
456        if dot(input.corner, input.corner) > 1.0 {
457            discard;
458        }
459    } else if input.shape == 1u {
460        let point = input.corner;
461        if length(point) > 1.0 || length(point - vec2<f32>(0.0, -0.62)) < 0.42 {
462            discard;
463        }
464    } else {
465        let radius = length(input.corner);
466        if radius > 1.0 || radius < 0.55 {
467            discard;
468        }
469    }
470    return vec4<f32>(input.colour, 1.0);
471}"#;
472
473/// The identity of the sky family's pipeline, keyed on the compiled descriptor.
474///
475/// The descriptor is immutable once built, so two keys that share it are the
476/// same pipeline; the key compares the allocation rather than the contents.
477#[derive(Clone)]
478struct SkyKey {
479    descriptor: Arc<RenderPipelineDesc>,
480}
481
482impl PartialEq for SkyKey {
483    fn eq(&self, other: &Self) -> bool {
484        Arc::ptr_eq(&self.descriptor, &other.descriptor)
485    }
486}
487
488impl Eq for SkyKey {}
489
490impl core::hash::Hash for SkyKey {
491    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
492        state.write_usize(Arc::as_ptr(&self.descriptor) as usize);
493    }
494}
495
496/// The sky family's blueprint, specialised for one render target.
497#[derive(Clone, Debug)]
498struct SkyVariant {
499    /// The pipeline as built, before the target is applied.
500    descriptor: Arc<RenderPipelineDesc>,
501    /// The target the pipeline is compiled for.
502    surface: SurfaceKey,
503}
504
505impl PartialEq for SkyVariant {
506    fn eq(&self, other: &Self) -> bool {
507        self.surface == other.surface && Arc::ptr_eq(&self.descriptor, &other.descriptor)
508    }
509}
510
511impl Eq for SkyVariant {}
512
513impl core::hash::Hash for SkyVariant {
514    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
515        self.surface.hash(state);
516        state.write_usize(Arc::as_ptr(&self.descriptor) as usize);
517    }
518}
519
520impl RenderPipelineKey for SkyKey {
521    type Variant = SkyVariant;
522
523    fn variant(&self, draw: &DrawContext<'_>) -> SkyVariant {
524        SkyVariant {
525            descriptor: self.descriptor.clone(),
526            surface: draw.surface,
527        }
528    }
529}
530
531impl PipelineVariant<wgpu::RenderPipeline> for SkyVariant {
532    type Descriptor = RenderPipelineDesc;
533
534    fn descriptor(&self, _device: &wgpu::Device) -> RenderPipelineDesc {
535        // The stored descriptor carries the format the example draws its own
536        // captures with; rewriting the target here is what lets the same family
537        // draw into a window's swap chain, whose colour format differs.
538        let mut descriptor = self.descriptor.as_ref().clone();
539        descriptor.set_surface(self.surface);
540        descriptor
541    }
542}
543
544/// The sky family's factory.
545///
546/// The family owns its whole group 0: its shader reads a uniform block, a cube
547/// map and a sampler the example keeps, and nothing of the frame's. The recipe
548/// it hands the renderer therefore declares exactly those three as its inputs
549/// ([GlobalRecipe::own]), so the group is rebuilt when the example replaces one
550/// of them and never because a draw moved to another camera.
551struct SkyFactory {
552    /// The layout the group must match.
553    layout: wgpu::BindGroupLayout,
554    /// The uniform block the group binds.
555    uniforms: ResHandle<wgpu::Buffer>,
556    /// The cube map's view.
557    view: ResHandle<TextureView>,
558    /// The cube map's sampler.
559    sampler: ResHandle<wgpu::Sampler>,
560}
561
562impl RenderPipelineFactory<RenderPipelineDesc> for SkyFactory {
563    fn descriptor(
564        &self,
565        _context: &FamilyContext<'_>,
566        value: &SpecializedPipeline<wgpu::RenderPipeline, RenderPipelineDesc>,
567    ) -> RegisteredRenderPipeline {
568        let layout = self.layout.clone();
569        let uniforms = self.uniforms.clone();
570        let view = self.view.clone();
571        let sampler = self.sampler.clone();
572        let inputs = vec![uniforms.clone().erase(), view.clone().erase()];
573        let mut inputs = inputs;
574        inputs.push(sampler.clone().erase());
575        let build =
576            move |device: &wgpu::Device, graph: &ResourceGraph, _uniforms: &FrameUniforms| {
577                let uniforms = graph
578                    .get(&uniforms)
579                    .expect("the sky's uniform buffer is in the graph");
580                let view = graph
581                    .get(&view)
582                    .expect("the sky's cube view is in the graph");
583                let sampler = graph
584                    .get(&sampler)
585                    .expect("the sky's sampler is in the graph");
586                device.create_bind_group(&wgpu::BindGroupDescriptor {
587                    label: Some("unlit3d_examples::custom_pipeline::sky"),
588                    layout: &layout,
589                    entries: &[
590                        wgpu::BindGroupEntry {
591                            binding: 0,
592                            resource: uniforms.as_entire_binding(),
593                        },
594                        wgpu::BindGroupEntry {
595                            binding: 1,
596                            resource: wgpu::BindingResource::TextureView(view.view()),
597                        },
598                        wgpu::BindGroupEntry {
599                            binding: 2,
600                            resource: wgpu::BindingResource::Sampler(sampler),
601                        },
602                    ],
603                })
604            };
605        RegisteredRenderPipeline {
606            pipeline: value.pipeline.clone(),
607            global: Some(GlobalRecipe::own(build, inputs)),
608        }
609    }
610}
611
612/// The identity of the particle family's pipeline.
613#[derive(Clone)]
614struct ParticleKey {
615    descriptor: Arc<RenderPipelineDesc>,
616}
617
618impl PartialEq for ParticleKey {
619    fn eq(&self, other: &Self) -> bool {
620        Arc::ptr_eq(&self.descriptor, &other.descriptor)
621    }
622}
623
624impl Eq for ParticleKey {}
625
626impl core::hash::Hash for ParticleKey {
627    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
628        state.write_usize(Arc::as_ptr(&self.descriptor) as usize);
629    }
630}
631
632/// The particle family's blueprint, specialised for one render target.
633#[derive(Clone, Debug)]
634struct ParticleVariant {
635    /// The pipeline as built, before the target is applied.
636    descriptor: Arc<RenderPipelineDesc>,
637    /// The target the pipeline is compiled for.
638    surface: SurfaceKey,
639}
640
641impl PartialEq for ParticleVariant {
642    fn eq(&self, other: &Self) -> bool {
643        self.surface == other.surface && Arc::ptr_eq(&self.descriptor, &other.descriptor)
644    }
645}
646
647impl Eq for ParticleVariant {}
648
649impl core::hash::Hash for ParticleVariant {
650    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
651        self.surface.hash(state);
652        state.write_usize(Arc::as_ptr(&self.descriptor) as usize);
653    }
654}
655
656impl RenderPipelineKey for ParticleKey {
657    type Variant = ParticleVariant;
658
659    fn variant(&self, draw: &DrawContext<'_>) -> ParticleVariant {
660        ParticleVariant {
661            descriptor: self.descriptor.clone(),
662            surface: draw.surface,
663        }
664    }
665}
666
667impl PipelineVariant<wgpu::RenderPipeline> for ParticleVariant {
668    type Descriptor = RenderPipelineDesc;
669
670    fn descriptor(&self, _device: &wgpu::Device) -> RenderPipelineDesc {
671        let mut descriptor = self.descriptor.as_ref().clone();
672        descriptor.set_surface(self.surface);
673        descriptor
674    }
675}
676
677/// The particle family's factory.
678///
679/// The family keeps the renderer's shared global group, so its group is built
680/// from the frame's own camera and globals exactly as a built-in family's is.
681struct ParticleFactory;
682
683impl RenderPipelineFactory<RenderPipelineDesc> for ParticleFactory {
684    fn descriptor(
685        &self,
686        context: &FamilyContext<'_>,
687        value: &SpecializedPipeline<wgpu::RenderPipeline, RenderPipelineDesc>,
688    ) -> RegisteredRenderPipeline {
689        let bindings = GlobalBindings::default();
690        let layout = context.resources.layout(context.device, bindings);
691        let global = context.resources.recipe(layout, bindings);
692        RegisteredRenderPipeline {
693            pipeline: value.pipeline.clone(),
694            global: Some(global),
695        }
696    }
697}
698
699/// The particle family's per-instance stream.
700#[derive(Clone, Copy, Debug, Default)]
701struct ParticleInstances;
702
703impl InstanceData for ParticleInstances {
704    fn stream(&self) -> InstanceStreamDesc {
705        InstanceStreamDesc {
706            slot: PARTICLE_INSTANCE_SLOT,
707            array_stride: core::mem::size_of::<ParticleRecord>() as u32,
708            label: None,
709        }
710    }
711
712    fn write(&mut self, context: &mut InstanceContext<'_>, out: &mut [u8]) {
713        let mut particle = context
714            .world
715            .query_one::<&Particle>(context.entity)
716            .expect("every visible particle carries a Particle component");
717        let record = ParticleRecord {
718            origin: [
719                particle.get().origin.x,
720                particle.get().origin.y,
721                particle.get().origin.z,
722                particle.get().phase,
723            ],
724            motion: [
725                particle.get().radius,
726                particle.get().speed,
727                particle.get().size,
728                particle.get().bob,
729            ],
730            tint: [
731                particle.get().tint.x,
732                particle.get().tint.y,
733                particle.get().tint.z,
734                particle.get().shape,
735            ],
736        };
737        out.copy_from_slice(record.as_bytes());
738    }
739}
740
741/// The layout of the sky family's one bind group.
742fn sky_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
743    device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
744        label: Some("unlit3d_examples::custom_pipeline::sky"),
745        entries: &[
746            wgpu::BindGroupLayoutEntry {
747                binding: 0,
748                visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
749                ty: wgpu::BindingType::Buffer {
750                    ty: wgpu::BufferBindingType::Uniform,
751                    has_dynamic_offset: false,
752                    min_binding_size: wgpu::BufferSize::new(
753                        <SkyUniforms as const_shader_layout::ShaderLayout>::SIZE.get(),
754                    ),
755                },
756                count: None,
757            },
758            wgpu::BindGroupLayoutEntry {
759                binding: 1,
760                visibility: wgpu::ShaderStages::FRAGMENT,
761                ty: wgpu::BindingType::Texture {
762                    sample_type: wgpu::TextureSampleType::Float { filterable: true },
763                    view_dimension: wgpu::TextureViewDimension::Cube,
764                    multisampled: false,
765                },
766                count: None,
767            },
768            wgpu::BindGroupLayoutEntry {
769                binding: 2,
770                visibility: wgpu::ShaderStages::FRAGMENT,
771                ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
772                count: None,
773            },
774        ],
775    })
776}
777
778/// The layout of the particle family's material group.
779fn particle_material_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
780    device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
781        label: Some("unlit3d_examples::custom_pipeline::particles"),
782        entries: &[wgpu::BindGroupLayoutEntry {
783            binding: 0,
784            visibility: wgpu::ShaderStages::VERTEX,
785            ty: wgpu::BindingType::Buffer {
786                ty: wgpu::BufferBindingType::Uniform,
787                has_dynamic_offset: false,
788                min_binding_size: wgpu::BufferSize::new(
789                    <ParticleUniforms as const_shader_layout::ShaderLayout>::SIZE.get(),
790                ),
791            },
792            count: None,
793        }],
794    })
795}
796
797/// Compile the sky family's pipeline against the family's bind group layout.
798fn sky_pipeline(device: &wgpu::Device, layout: &wgpu::BindGroupLayout) -> RenderPipelineDesc {
799    let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
800        label: Some("unlit3d_examples::custom_pipeline::sky"),
801        source: wgpu::ShaderSource::Wgsl(SKY_SHADER.into()),
802    });
803    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
804        label: Some("unlit3d_examples::custom_pipeline::sky"),
805        bind_group_layouts: &[Some(layout)],
806        immediate_size: 0,
807    });
808    let descriptor = wgpu::RenderPipelineDescriptor {
809        label: Some("unlit3d_examples::custom_pipeline::sky"),
810        layout: Some(&pipeline_layout),
811        vertex: wgpu::VertexState {
812            module: &module,
813            entry_point: Some("vs_main"),
814            compilation_options: Default::default(),
815            // The triangle is generated from the vertex index.
816            buffers: &[],
817        },
818        primitive: wgpu::PrimitiveState {
819            cull_mode: None,
820            ..Default::default()
821        },
822        depth_stencil: Some(wgpu::DepthStencilState {
823            format: unlit_wgpu::render_attachments::default_depth_stencil_format(device),
824            // The sky is the backdrop: it is tested against the depth buffer so
825            // anything opaque in front of it wins, but it writes nothing, so it
826            // never hides a draw that comes after it.
827            depth_write_enabled: Some(false),
828            depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
829            stencil: Default::default(),
830            bias: Default::default(),
831        }),
832        multisample: Default::default(),
833        fragment: Some(wgpu::FragmentState {
834            module: &module,
835            entry_point: Some("fs_main"),
836            compilation_options: Default::default(),
837            targets: &[Some(wgpu::ColorTargetState {
838                format: TEST_FORMAT,
839                blend: None,
840                write_mask: wgpu::ColorWrites::ALL,
841            })],
842        }),
843        multiview_mask: None,
844        cache: None,
845    };
846    RenderPipelineDesc::from_wgpu(&descriptor)
847}
848
849/// Compile the particle family's pipeline.
850///
851/// Slot 0 is left empty — the mesh has no vertex buffer — and slot 1 is the
852/// family's own instance stream. Group 0 is the renderer's shared global group
853/// and group 1 is the family's uniform block.
854fn particle_pipeline(
855    device: &wgpu::Device,
856    material: &wgpu::BindGroupLayout,
857) -> RenderPipelineDesc {
858    let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
859        label: Some("unlit3d_examples::custom_pipeline::particles"),
860        source: wgpu::ShaderSource::Wgsl(PARTICLE_SHADER.into()),
861    });
862    let global = global_bind_group_layout(
863        device,
864        GlobalBindings::default(),
865        !supports_storage_buffers(device),
866    );
867    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
868        label: Some("unlit3d_examples::custom_pipeline::particles"),
869        bind_group_layouts: &[Some(&global), Some(material)],
870        immediate_size: 0,
871    });
872    let descriptor = wgpu::RenderPipelineDescriptor {
873        label: Some("unlit3d_examples::custom_pipeline::particles"),
874        layout: Some(&pipeline_layout),
875        vertex: wgpu::VertexState {
876            module: &module,
877            entry_point: Some("vs_main"),
878            compilation_options: Default::default(),
879            buffers: &[None, Some(particle_instance_layout())],
880        },
881        primitive: wgpu::PrimitiveState {
882            // A billboard's winding flips with the camera, so neither face is
883            // culled.
884            cull_mode: None,
885            ..Default::default()
886        },
887        depth_stencil: Some(wgpu::DepthStencilState {
888            format: unlit_wgpu::render_attachments::default_depth_stencil_format(device),
889            // The sprites are opaque, so they write depth and occlude one
890            // another; the shapes are flat, so overlapping them would only
891            // flatten the cloud into one silhouette.
892            depth_write_enabled: Some(true),
893            depth_compare: Some(wgpu::CompareFunction::Greater),
894            stencil: Default::default(),
895            bias: Default::default(),
896        }),
897        multisample: Default::default(),
898        fragment: Some(wgpu::FragmentState {
899            module: &module,
900            entry_point: Some("fs_main"),
901            compilation_options: Default::default(),
902            targets: &[Some(wgpu::ColorTargetState {
903                format: TEST_FORMAT,
904                // Opaque: a sprite's colour is exactly its tint, with no
905                // blending to soften it.
906                blend: None,
907                write_mask: wgpu::ColorWrites::ALL,
908            })],
909        }),
910        multiview_mask: None,
911        cache: None,
912    };
913    RenderPipelineDesc::from_wgpu(&descriptor)
914}
915
916/// The instance-step vertex layout of one particle record.
917fn particle_instance_layout() -> wgpu::VertexBufferLayout<'static> {
918    const ATTRIBUTES: [wgpu::VertexAttribute; 3] =
919        wgpu::vertex_attr_array![2 => Float32x4, 3 => Float32x4, 4 => Float32x4];
920    wgpu::VertexBufferLayout {
921        array_stride: core::mem::size_of::<ParticleRecord>() as u64,
922        step_mode: wgpu::VertexStepMode::Instance,
923        attributes: &ATTRIBUTES,
924    }
925}
926
927/// Create the procedural cube map and fill each of its six faces.
928///
929/// The faces are generated on the CPU from a deterministic hash of the
930/// direction each texel looks along, so the pattern is continuous across face
931/// seams and identical on every machine — which is what lets a snapshot pin it.
932fn upload_sky_cubemap(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::Texture {
933    let size = SKY_FACE_SIZE;
934    let texture = device.create_texture(&wgpu::TextureDescriptor {
935        label: Some("unlit3d_examples::custom_pipeline::sky_cube"),
936        size: wgpu::Extent3d {
937            width: size,
938            height: size,
939            depth_or_array_layers: 6,
940        },
941        mip_level_count: 1,
942        sample_count: 1,
943        dimension: wgpu::TextureDimension::D2,
944        format: SKY_FORMAT,
945        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
946        view_formats: &[],
947    });
948    let texel_size = SKY_FORMAT
949        .block_copy_size(None)
950        .expect("the sky format copies whole texels") as usize;
951    let mut face = vec![0u8; (size * size) as usize * texel_size];
952    for layer in 0..6u32 {
953        for y in 0..size {
954            for x in 0..size {
955                let u = (x as f32 + 0.5) / size as f32;
956                let v = (y as f32 + 0.5) / size as f32;
957                let texel = sky_texel(cube_direction(layer, u, v));
958                let start = ((y * size + x) as usize) * texel_size;
959                for (channel, value) in texel.iter().enumerate() {
960                    face[start + channel] = (value.clamp(0.0, 1.0) * 255.0).round() as u8;
961                }
962            }
963        }
964        queue.write_texture(
965            wgpu::TexelCopyTextureInfo {
966                texture: &texture,
967                mip_level: 0,
968                origin: wgpu::Origin3d {
969                    x: 0,
970                    y: 0,
971                    z: layer,
972                },
973                aspect: wgpu::TextureAspect::All,
974            },
975            &face,
976            wgpu::TexelCopyBufferLayout {
977                offset: 0,
978                bytes_per_row: Some(size * texel_size as u32),
979                rows_per_image: Some(size),
980            },
981            wgpu::Extent3d {
982                width: size,
983                height: size,
984                depth_or_array_layers: 1,
985            },
986        );
987    }
988    texture
989}
990
991/// The direction a cube-map texel looks along.
992///
993/// The faces are in the order a cube map samples them — +X, -X, +Y, -Y, +Z, -Z
994/// — and the pair (u, v) runs across the face from its top-left.
995fn cube_direction(face: u32, u: f32, v: f32) -> glam::Vec3 {
996    let sc = 2.0 * u - 1.0;
997    let tc = 2.0 * v - 1.0;
998    let direction = match face {
999        0 => glam::Vec3::new(1.0, -tc, -sc),
1000        1 => glam::Vec3::new(-1.0, -tc, sc),
1001        2 => glam::Vec3::new(sc, 1.0, tc),
1002        3 => glam::Vec3::new(sc, -1.0, -tc),
1003        4 => glam::Vec3::new(sc, -tc, 1.0),
1004        _ => glam::Vec3::new(-sc, -tc, -1.0),
1005    };
1006    direction.normalize()
1007}
1008
1009/// The colour one cube-map texel takes, from the direction it looks along.
1010///
1011/// The map holds two flat cloud colours and nothing else: a texel is either
1012/// inside a cloud or it is not, so the shader can lay it over the sky with one
1013/// blend and no falloff. The alpha of the result says whether there is a cloud
1014/// there at all.
1015fn sky_texel(direction: glam::Vec3) -> [f32; 4] {
1016    let puff = value_noise(direction * 9.0);
1017    if puff < 0.66 {
1018        return [0.0, 0.0, 0.0, 0.0];
1019    }
1020    // Two flat tones, picked by the same noise, so a cloud reads as a drawn
1021    // shape rather than as a shaded one.
1022    let colour = if puff > 0.72 {
1023        glam::Vec3::new(1.00, 0.86, 0.92)
1024    } else {
1025        glam::Vec3::new(0.72, 0.62, 0.88)
1026    };
1027    [colour.x, colour.y, colour.z, 1.0]
1028}
1029
1030/// Trilinearly interpolated value noise over the integer lattice.
1031fn value_noise(point: glam::Vec3) -> f32 {
1032    let base = point.floor();
1033    let fraction = point - base;
1034    // The interpolation is smoothed so a cloud's outline is a clean curve
1035    // rather than the staircase of the lattice it was sampled on.
1036    let smooth = fraction * fraction * (glam::Vec3::splat(3.0) - fraction * 2.0);
1037    let (x, y, z) = (base.x as i32, base.y as i32, base.z as i32);
1038    let mut value = 0.0;
1039    for dz in 0..2 {
1040        for dy in 0..2 {
1041            for dx in 0..2 {
1042                let corner = glam::Vec3::new(
1043                    if dx == 1 { smooth.x } else { 1.0 - smooth.x },
1044                    if dy == 1 { smooth.y } else { 1.0 - smooth.y },
1045                    if dz == 1 { smooth.z } else { 1.0 - smooth.z },
1046                );
1047                value += corner.x * corner.y * corner.z * hash3(x + dx, y + dy, z + dz);
1048            }
1049        }
1050    }
1051    value
1052}
1053
1054/// A deterministic hash of three integers into the unit interval.
1055fn hash3(x: i32, y: i32, z: i32) -> f32 {
1056    let mut hash = (x as u32).wrapping_mul(0x9E37_79B9)
1057        ^ (y as u32).wrapping_mul(0x85EB_CA6B)
1058        ^ (z as u32).wrapping_mul(0xC2B2_AE35);
1059    hash ^= hash >> 15;
1060    hash = hash.wrapping_mul(0x2C1B_3C6D);
1061    hash ^= hash >> 12;
1062    hash = hash.wrapping_mul(0x297A_2D39);
1063    hash ^= hash >> 15;
1064    (hash >> 8) as f32 / (1u32 << 24) as f32
1065}
1066
1067/// Build the scene's world content.
1068fn build(
1069    world: &mut World,
1070    context: RenderContext,
1071    pass: Entity,
1072    _ui: Option<Entity>,
1073    size: (u32, u32),
1074    options: SceneOptions,
1075) -> SceneControl {
1076    let mut source = MeshSource::new(&world.root_scope(), context);
1077    let device = source.device(&world.root_scope());
1078    let queue = source.queue(&world.root_scope());
1079
1080    // -- the sky family ------------------------------------------------------
1081    let sky_layout = sky_bind_group_layout(&device);
1082    let sky_uniforms_buffer = device.create_buffer(&wgpu::BufferDescriptor {
1083        label: Some("unlit3d_examples::custom_pipeline::sky_uniforms"),
1084        size: <SkyUniforms as const_shader_layout::ShaderLayout>::SIZE.get(),
1085        usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1086        mapped_at_creation: false,
1087    });
1088    let sky_texture = upload_sky_cubemap(&device, &queue);
1089    // The texture's default view is two-dimensional; a cube map is sampled
1090    // through a view of the whole array as a cube, which is created here and
1091    // entered in the graph as a resource of its own.
1092    let sky_view = TextureExt::create_view(
1093        &sky_texture,
1094        &wgpu::TextureViewDescriptor {
1095            label: Some("unlit3d_examples::custom_pipeline::sky_cube"),
1096            dimension: Some(wgpu::TextureViewDimension::Cube),
1097            ..Default::default()
1098        },
1099    );
1100    let (sky_view, sky_uniforms) = {
1101        let root = world.root_scope();
1102        let mut graph = context.graph(&root);
1103        let sky_texture = graph.get().insert(sky_texture, None);
1104        let sky_view = graph.get().insert(sky_view, None);
1105        // The view keeps the texture alive, so the graph needs no other handle
1106        // on it.
1107        graph.get().add_dependency(&sky_view, &sky_texture);
1108        let sky_uniforms = graph.get().insert(sky_uniforms_buffer.clone(), None);
1109        (sky_view, sky_uniforms)
1110    };
1111    let sky_sampler = source.register_sampler(
1112        &world.root_scope(),
1113        Some(wgpu::SamplerDescriptor {
1114            label: Some("unlit3d_examples::custom_pipeline::sky"),
1115            address_mode_u: wgpu::AddressMode::ClampToEdge,
1116            address_mode_v: wgpu::AddressMode::ClampToEdge,
1117            address_mode_w: wgpu::AddressMode::ClampToEdge,
1118            mag_filter: wgpu::FilterMode::Linear,
1119            min_filter: wgpu::FilterMode::Linear,
1120            ..Default::default()
1121        }),
1122    );
1123    source.register_family::<SkyKey, _, _>(
1124        &world.root_scope(),
1125        SkyFactory {
1126            layout: sky_layout.clone(),
1127            uniforms: sky_uniforms,
1128            view: sky_view,
1129            sampler: sky_sampler,
1130        },
1131        (),
1132    );
1133    #[cfg_attr(
1134        target_arch = "wasm32",
1135        expect(
1136            clippy::arc_with_non_send_sync,
1137            reason = "a wgpu handle is not `Send` on wasm32; one `Arc` type everywhere is simpler, and on wasm32 it costs nothing over `Rc`"
1138        )
1139    )]
1140    let sky_pipeline = GpuRenderPipeline::new(SkyKey {
1141        descriptor: Arc::new(sky_pipeline(&device, &sky_layout)),
1142    });
1143    let sky_mesh = source.allocate_mesh(
1144        &world.root_scope(),
1145        MeshDesc {
1146            count: SKY_VERTICES,
1147            aabb: Aabb::new(glam::Vec3::ZERO, glam::Vec3::splat(SKY_EXTENT)),
1148            ..Default::default()
1149        },
1150    );
1151
1152    // -- the particle family -------------------------------------------------
1153    let particle_layout = particle_material_layout(&device);
1154    let particle_uniforms_buffer = device.create_buffer(&wgpu::BufferDescriptor {
1155        label: Some("unlit3d_examples::custom_pipeline::particle_uniforms"),
1156        size: <ParticleUniforms as const_shader_layout::ShaderLayout>::SIZE.get(),
1157        usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1158        mapped_at_creation: false,
1159    });
1160    let particle_uniforms = context
1161        .graph(&world.root_scope())
1162        .get()
1163        .insert(particle_uniforms_buffer.clone(), None);
1164    source.register_family::<ParticleKey, _, _>(
1165        &world.root_scope(),
1166        ParticleFactory,
1167        ParticleInstances,
1168    );
1169    #[cfg_attr(
1170        target_arch = "wasm32",
1171        expect(
1172            clippy::arc_with_non_send_sync,
1173            reason = "a wgpu handle is not `Send` on wasm32; one `Arc` type everywhere is simpler, and on wasm32 it costs nothing over `Rc`"
1174        )
1175    )]
1176    let particle_pipeline = GpuRenderPipeline::new(ParticleKey {
1177        descriptor: Arc::new(particle_pipeline(&device, &particle_layout)),
1178    });
1179    let particle_mesh = source.allocate_mesh(
1180        &world.root_scope(),
1181        MeshDesc {
1182            count: PARTICLE_VERTICES,
1183            aabb: Aabb::new(
1184                glam::Vec3::new(0.0, 1.30, 0.0),
1185                glam::Vec3::new(3.0, 1.60, 3.0),
1186            ),
1187            ..Default::default()
1188        },
1189    );
1190    let particle_material = {
1191        let device = device.clone();
1192        let layout = particle_layout.clone();
1193        let uniforms = particle_uniforms.clone();
1194        source.allocate_material(
1195            &world.root_scope(),
1196            move |graph| {
1197                let uniforms = graph
1198                    .get(&uniforms)
1199                    .expect("the particles' uniform buffer is in the graph");
1200                device.create_bind_group(&wgpu::BindGroupDescriptor {
1201                    label: Some("unlit3d_examples::custom_pipeline::particles"),
1202                    layout: &layout,
1203                    entries: &[wgpu::BindGroupEntry {
1204                        binding: 0,
1205                        resource: uniforms.as_entire_binding(),
1206                    }],
1207                })
1208            },
1209            [particle_uniforms.clone().erase()],
1210        )
1211    };
1212
1213    // No family of the built-in lit pipeline is registered: every draw in this
1214    // scene goes through a pipeline the example compiled itself.
1215    let source_entity = world
1216        .scope_mut(pass)
1217        .expect("the pass is alive")
1218        .spawn(bundle![Source::new(source)]);
1219    let camera_entity = super::spawn_in(
1220        world,
1221        source_entity,
1222        bundle![camera_looking_at(
1223            orbit_eye(ORBIT_START),
1224            CAMERA_TARGET,
1225            size,
1226        ),],
1227    );
1228
1229    super::spawn_in(
1230        world,
1231        source_entity,
1232        Sky {
1233            mesh: sky_mesh,
1234            pipeline: sky_pipeline,
1235        },
1236    );
1237    // One mesh, one pipeline and one material for the whole cloud, so the
1238    // renderer folds the particles into a single instanced draw and every
1239    // sprite's placement comes from its own record.
1240    for particle in particle_cloud() {
1241        super::spawn_in(
1242            world,
1243            source_entity,
1244            Sprite {
1245                mesh: particle_mesh.clone(),
1246                pipeline: particle_pipeline.clone(),
1247                material: particle_material.clone(),
1248                particle,
1249            },
1250        );
1251    }
1252
1253    // A capture replays the frozen sequence so a stored frame stays
1254    // reproducible; the window instead runs the motion continuously from the
1255    // frame delta, which is what this clock accumulates.
1256    let reproducible = options.reproducible;
1257    let mut clock = 0.0f32;
1258
1259    SceneControl {
1260        advance: Box::new(move |world, frame, delta, frame_size| {
1261            // A capture walks the sequence the snapshots froze, so its frames
1262            // are exactly the stored ones. The window runs the same motion
1263            // continuously from the frame delta, so the orbit and the particles
1264            // move at the pace the display refreshes rather than in the two
1265            // steps a second a held sequence gives.
1266            let (time, angle) = if reproducible {
1267                (
1268                    frame as f32 * SEQUENCE_STEP,
1269                    ORBIT_START + frame as f32 * ORBIT_STEP,
1270                )
1271            } else {
1272                clock += delta;
1273                (clock, ORBIT_START + clock * ORBIT_RADIANS_PER_SECOND)
1274            };
1275            super::aim_camera(
1276                world,
1277                source_entity,
1278                camera_entity,
1279                orbit_eye(angle),
1280                CAMERA_TARGET,
1281                frame_size,
1282            );
1283
1284            let mut camera = world
1285                .scope(source_entity)
1286                .expect("the source is alive")
1287                .query_one::<&Camera>(camera_entity)
1288                .expect("the scene's camera exists");
1289            let sky = SkyUniforms {
1290                zenith: glam::Vec4::new(0.29, 0.45, 0.95, 1.0),
1291                horizon: glam::Vec4::new(0.98, 0.62, 0.52, 1.0),
1292                ground: glam::Vec4::new(0.20, 0.24, 0.52, 1.0),
1293                sun_direction: glam::Vec4::new(
1294                    SUN_DIRECTION.x,
1295                    SUN_DIRECTION.y,
1296                    SUN_DIRECTION.z,
1297                    1.0,
1298                ),
1299                sun_color: glam::Vec4::new(1.0, 0.88, 0.35, 1.0),
1300                // The ray is built in view space and rotated here, so the sky
1301                // follows whatever camera the frame chose.
1302                view_rotation: glam::Mat4::from_mat3(
1303                    glam::Mat3::from_mat4(camera.get().view_from_world).transpose(),
1304                ),
1305                projection: glam::Vec4::new(
1306                    1.0 / camera.get().clip_from_view.x_axis.x,
1307                    1.0 / camera.get().clip_from_view.y_axis.y,
1308                    0.0,
1309                    0.0,
1310                ),
1311            };
1312            queue.write_buffer(&sky_uniforms_buffer, 0, sky.as_bytes());
1313
1314            let particles = ParticleUniforms {
1315                clock: glam::Vec4::new(time, 0.0, 0.0, 0.0),
1316            };
1317            queue.write_buffer(&particle_uniforms_buffer, 0, particles.as_bytes());
1318        }),
1319        snapshot: Box::new(|frame| {
1320            (frame < FRAMES).then(|| format!("custom_pipeline/frame_{frame:02}.webp"))
1321        }),
1322    }
1323}
1324
1325/// The eye point of the orbit at `angle` radians.
1326fn orbit_eye(angle: f32) -> glam::Vec3 {
1327    glam::Vec3::new(
1328        ORBIT_RADIUS * angle.sin(),
1329        ORBIT_HEIGHT,
1330        ORBIT_RADIUS * angle.cos(),
1331    )
1332}
1333
1334/// The particle cloud: a ring of sprites, each with its own orbit, size, hue
1335/// and shape.
1336fn particle_cloud() -> [Particle; PARTICLE_COUNT] {
1337    core::array::from_fn(|index| {
1338        let slot = index as f32;
1339        let angle = slot * core::f32::consts::TAU / PARTICLE_COUNT as f32;
1340        // Alternating radii keep the cloud from reading as a flat ring.
1341        let ring = if index.is_multiple_of(2) { 1.35 } else { 2.05 };
1342        Particle {
1343            origin: glam::Vec3::new(
1344                angle.cos() * ring,
1345                1.05 + (index % 3) as f32 * 0.28,
1346                angle.sin() * ring,
1347            ),
1348            phase: slot * 0.9,
1349            radius: 0.22 + (index % 3) as f32 * 0.11,
1350            speed: 0.75 + (index % 4) as f32 * 0.22,
1351            size: 0.20 + (index % 3) as f32 * 0.06,
1352            bob: 0.16 + (index % 2) as f32 * 0.12,
1353            shape: (index % 3) as f32,
1354            tint: PARTICLE_TINTS[index % PARTICLE_TINTS.len()],
1355        }
1356    })
1357}