1use 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
46const FRAMES: u32 = 8;
48
49const SKY_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8UnormSrgb;
51
52const SKY_FACE_SIZE: u32 = 256;
58
59const SKY_VERTICES: u32 = 3;
61
62const SKY_EXTENT: f32 = 1.0e4;
67
68const PARTICLE_VERTICES: u32 = 6;
70
71const PARTICLE_COUNT: usize = 22;
73
74const PARTICLE_INSTANCE_SLOT: u32 = 1;
76
77const ORBIT_RADIUS: f32 = 4.4;
79
80const ORBIT_HEIGHT: f32 = 2.1;
82
83const ORBIT_START: f32 = 0.30;
85
86const ORBIT_STEP: f32 = 0.42;
91
92const ORBIT_RADIANS_PER_SECOND: f32 = ORBIT_STEP / SEQUENCE_STEP;
97
98const CAMERA_TARGET: glam::Vec3 = glam::Vec3::new(0.0, 1.05, 0.0);
100
101const SUN_DIRECTION: glam::Vec3 = glam::Vec3::new(-0.45, 0.30, -0.84);
103
104const 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
116pub 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 step_seconds: None,
130 samples: 1,
131 depth: true,
132 ui: false,
133 reproducible_ui: false,
134 build,
135};
136
137#[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 zenith: glam::Vec4,
159 horizon: glam::Vec4,
161 ground: glam::Vec4,
163 sun_direction: glam::Vec4,
165 sun_color: glam::Vec4,
167 view_rotation: glam::Mat4,
169 projection: glam::Vec4,
171}
172
173#[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 clock: glam::Vec4,
189}
190
191#[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 origin: [f32; 4],
208 motion: [f32; 4],
211 tint: [f32; 4],
213}
214
215#[derive(Clone, Copy, Debug)]
217struct Particle {
218 origin: glam::Vec3,
220 phase: f32,
222 radius: f32,
224 speed: f32,
226 size: f32,
228 bob: f32,
230 shape: f32,
232 tint: glam::Vec4,
234}
235
236struct 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
252struct 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
270const 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
362const 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#[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#[derive(Clone, Debug)]
498struct SkyVariant {
499 descriptor: Arc<RenderPipelineDesc>,
501 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 let mut descriptor = self.descriptor.as_ref().clone();
539 descriptor.set_surface(self.surface);
540 descriptor
541 }
542}
543
544struct SkyFactory {
552 layout: wgpu::BindGroupLayout,
554 uniforms: ResHandle<wgpu::Buffer>,
556 view: ResHandle<TextureView>,
558 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#[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#[derive(Clone, Debug)]
634struct ParticleVariant {
635 descriptor: Arc<RenderPipelineDesc>,
637 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
677struct 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#[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
741fn 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
778fn 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
797fn 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 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 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
849fn 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 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 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 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
916fn 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
927fn 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
991fn 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
1009fn 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 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
1030fn value_noise(point: glam::Vec3) -> f32 {
1032 let base = point.floor();
1033 let fraction = point - base;
1034 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
1054fn 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
1067fn 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 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 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 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 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 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 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 let reproducible = options.reproducible;
1257 let mut clock = 0.0f32;
1258
1259 SceneControl {
1260 advance: Box::new(move |world, frame, delta, frame_size| {
1261 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 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
1325fn 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
1334fn 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 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}