Skip to main content

renderling/cubemap/
cpu.rs

1//! CPU side of the cubemap module.
2use std::sync::Arc;
3
4use glam::{Mat4, UVec2, Vec3, Vec4};
5use image::GenericImageView;
6
7use crate::{
8    stage::{Stage, StageRendering},
9    texture::Texture,
10};
11
12use super::shader::{CubemapDescriptor, CubemapFaceDirection};
13
14pub fn cpu_sample_cubemap(cubemap: &[image::DynamicImage; 6], coord: Vec3) -> Vec4 {
15    let coord = coord.normalize_or(Vec3::X);
16    let (face_index, uv) = CubemapDescriptor::get_face_index_and_uv(coord);
17
18    // Get the selected image
19    let image = &cubemap[face_index];
20
21    // Convert 2D UV to pixel coordinates
22    let (width, height) = image.dimensions();
23    let px = uv.x * (width as f32 - 1.0);
24    let py = uv.y * (height as f32 - 1.0);
25
26    // Sample using the nearest neighbor for simplicity
27    let image::Rgba([r, g, b, a]) = image.get_pixel(px.round() as u32, py.round() as u32);
28
29    // Convert the sampled pixel to Vec4
30    Vec4::new(
31        r as f32 / 255.0,
32        g as f32 / 255.0,
33        b as f32 / 255.0,
34        a as f32 / 255.0,
35    )
36}
37
38/// A cubemap that acts as a render target for an entire scene.
39///
40/// Use this to create and update a skybox with scene geometry.
41pub struct SceneCubemap {
42    pipeline: Arc<wgpu::RenderPipeline>,
43    cubemap_texture: wgpu::Texture,
44    depth_texture: crate::texture::Texture,
45    clear_color: wgpu::Color,
46}
47
48impl SceneCubemap {
49    pub fn new(
50        device: &wgpu::Device,
51        size: UVec2,
52        format: wgpu::TextureFormat,
53        clear_color: Vec4,
54    ) -> Self {
55        let label = Some("scene-to-cubemap");
56        let cubemap_texture = device.create_texture(&wgpu::TextureDescriptor {
57            label,
58            size: wgpu::Extent3d {
59                width: size.x,
60                height: size.y,
61                depth_or_array_layers: 6,
62            },
63            mip_level_count: 1,
64            sample_count: 1,
65            dimension: wgpu::TextureDimension::D2,
66            format,
67            usage: wgpu::TextureUsages::RENDER_ATTACHMENT
68                | wgpu::TextureUsages::TEXTURE_BINDING
69                | wgpu::TextureUsages::COPY_DST
70                | wgpu::TextureUsages::COPY_SRC,
71            view_formats: &[],
72        });
73        let depth_texture = Texture::create_depth_texture(device, size.x, size.y, 1, label);
74        let pipeline = Arc::new(Stage::create_primitive_pipeline(device, format, 1));
75        Self {
76            pipeline,
77            cubemap_texture,
78            depth_texture,
79            clear_color: wgpu::Color {
80                r: clear_color.x as f64,
81                g: clear_color.y as f64,
82                b: clear_color.z as f64,
83                a: clear_color.w as f64,
84            },
85        }
86    }
87
88    pub fn run(&self, stage: &Stage) {
89        let previous_camera_id = stage.used_camera_id();
90
91        // create a new camera for our cube, and use it to render with
92        let camera = stage.geometry.new_camera();
93        stage.use_camera(&camera);
94
95        // By setting this to 90 degrees (PI/2 radians) we make sure the viewing
96        // field is exactly large enough to fill a single face of the
97        // cubemap such that all faces align correctly to each other at
98        // the edges.
99        let fovy = std::f32::consts::FRAC_PI_2;
100        let aspect = self.cubemap_texture.width() as f32 / self.cubemap_texture.height() as f32;
101        let projection = Mat4::perspective_lh(fovy, aspect, 1.0, 25.0);
102        // Render each face by rendering the scene from each camera angle into
103        // the cubemap
104        for (i, face) in CubemapFaceDirection::FACES.iter().enumerate() {
105            // Update the camera angle, no need to sync as calling
106            // `Stage::render` does this implicitly
107            camera.set_projection_and_view(projection, face.view());
108            let label_s = format!("scene-to-cubemap-{i}");
109            let view = self
110                .cubemap_texture
111                .create_view(&wgpu::TextureViewDescriptor {
112                    label: Some(&label_s),
113                    base_array_layer: i as u32,
114                    array_layer_count: Some(1),
115                    dimension: Some(wgpu::TextureViewDimension::D2),
116                    ..Default::default()
117                });
118            let color_attachment = wgpu::RenderPassColorAttachment {
119                view: &view,
120                resolve_target: None,
121                ops: wgpu::Operations {
122                    load: wgpu::LoadOp::Clear(self.clear_color),
123                    store: wgpu::StoreOp::Store,
124                },
125                depth_slice: None,
126            };
127            let depth_stencil_attachment = wgpu::RenderPassDepthStencilAttachment {
128                view: &self.depth_texture.view,
129                depth_ops: Some(wgpu::Operations {
130                    load: wgpu::LoadOp::Clear(1.0),
131                    store: wgpu::StoreOp::Store,
132                }),
133                stencil_ops: None,
134            };
135            let (_, _) = StageRendering {
136                pipeline: &self.pipeline,
137                stage,
138                color_attachment,
139                depth_stencil_attachment,
140            }
141            .run();
142        }
143
144        stage.use_camera_id(previous_camera_id);
145    }
146}
147
148/// A render pipeline for blitting an equirectangular image as a cubemap.
149pub struct EquirectangularImageToCubemapBlitter(pub wgpu::RenderPipeline);
150
151impl EquirectangularImageToCubemapBlitter {
152    pub fn create_bindgroup_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
153        device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
154            label: Some("cubemap-making bindgroup"),
155            entries: &[
156                wgpu::BindGroupLayoutEntry {
157                    binding: 0,
158                    visibility: wgpu::ShaderStages::VERTEX,
159                    ty: wgpu::BindingType::Buffer {
160                        ty: wgpu::BufferBindingType::Storage { read_only: true },
161                        has_dynamic_offset: false,
162                        min_binding_size: None,
163                    },
164                    count: None,
165                },
166                wgpu::BindGroupLayoutEntry {
167                    binding: 1,
168                    visibility: wgpu::ShaderStages::FRAGMENT,
169                    ty: wgpu::BindingType::Texture {
170                        sample_type: wgpu::TextureSampleType::Float { filterable: false },
171                        view_dimension: wgpu::TextureViewDimension::D2,
172                        multisampled: false,
173                    },
174                    count: None,
175                },
176                wgpu::BindGroupLayoutEntry {
177                    binding: 2,
178                    visibility: wgpu::ShaderStages::FRAGMENT,
179                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering),
180                    count: None,
181                },
182            ],
183        })
184    }
185
186    pub fn create_bindgroup(
187        device: &wgpu::Device,
188        label: Option<&str>,
189        buffer: &wgpu::Buffer,
190        // The texture to sample the environment from
191        texture: &Texture,
192    ) -> wgpu::BindGroup {
193        device.create_bind_group(&wgpu::BindGroupDescriptor {
194            label,
195            layout: &Self::create_bindgroup_layout(device),
196            entries: &[
197                wgpu::BindGroupEntry {
198                    binding: 0,
199                    resource: wgpu::BindingResource::Buffer(buffer.as_entire_buffer_binding()),
200                },
201                wgpu::BindGroupEntry {
202                    binding: 1,
203                    resource: wgpu::BindingResource::TextureView(&texture.view),
204                },
205                wgpu::BindGroupEntry {
206                    binding: 2,
207                    resource: wgpu::BindingResource::Sampler(&texture.sampler),
208                },
209            ],
210        })
211    }
212
213    /// Create the rendering pipeline that creates cubemaps from equirectangular
214    /// images.
215    pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
216        log::trace!("creating cubemap-making render pipeline with format '{format:?}'");
217        let vertex_linkage = crate::linkage::skybox_cubemap_vertex::linkage(device);
218        let fragment_linkage = crate::linkage::skybox_equirectangular_fragment::linkage(device);
219        let bg_layout = Self::create_bindgroup_layout(device);
220        let pp_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
221            label: Some("cubemap-making pipeline layout"),
222            bind_group_layouts: &[&bg_layout],
223            push_constant_ranges: &[],
224        });
225        EquirectangularImageToCubemapBlitter(device.create_render_pipeline(
226            &wgpu::RenderPipelineDescriptor {
227                label: Some("cubemap-making pipeline"),
228                layout: Some(&pp_layout),
229                vertex: wgpu::VertexState {
230                    module: &vertex_linkage.module,
231                    entry_point: Some(vertex_linkage.entry_point),
232                    buffers: &[],
233                    compilation_options: Default::default(),
234                },
235                primitive: wgpu::PrimitiveState {
236                    topology: wgpu::PrimitiveTopology::TriangleList,
237                    strip_index_format: None,
238                    front_face: wgpu::FrontFace::Ccw,
239                    cull_mode: None,
240                    unclipped_depth: false,
241                    polygon_mode: wgpu::PolygonMode::Fill,
242                    conservative: false,
243                },
244                depth_stencil: None,
245                multisample: wgpu::MultisampleState {
246                    mask: !0,
247                    alpha_to_coverage_enabled: false,
248                    count: 1,
249                },
250                fragment: Some(wgpu::FragmentState {
251                    module: &fragment_linkage.module,
252                    entry_point: Some(fragment_linkage.entry_point),
253                    targets: &[Some(wgpu::ColorTargetState {
254                        format,
255                        blend: Some(wgpu::BlendState::ALPHA_BLENDING),
256                        write_mask: wgpu::ColorWrites::ALL,
257                    })],
258                    compilation_options: Default::default(),
259                }),
260                multiview: None,
261                cache: None,
262            },
263        ))
264    }
265}
266
267#[cfg(test)]
268mod test {
269    use craballoc::slab::SlabAllocator;
270    use glam::Vec4;
271    use image::GenericImageView;
272
273    use crate::{
274        context::Context,
275        geometry::Vertex,
276        math::{UNIT_INDICES, UNIT_POINTS},
277        test::BlockOnFuture,
278        texture::CopiedTextureBuffer,
279    };
280
281    use super::*;
282
283    #[test]
284    fn hand_rolled_cubemap_sampling() {
285        let width = 256;
286        let height = 256;
287        let ctx = Context::headless(width, height).block();
288        let stage = ctx
289            .new_stage()
290            .with_background_color(Vec4::ZERO)
291            .with_lighting(false)
292            .with_msaa_sample_count(4);
293        let projection = crate::camera::perspective(width as f32, height as f32);
294        let view = Mat4::look_at_rh(Vec3::splat(3.0), Vec3::ZERO, Vec3::Y);
295        let _camera = stage
296            .new_camera()
297            .with_projection_and_view(projection, view);
298        // geometry is the "clip cube" where colors are normalized 3d space
299        // coords
300        let _rez = stage
301            .new_primitive()
302            .with_vertices(stage.new_vertices(UNIT_POINTS.map(|unit_cube_point| {
303                Vertex::default()
304                    // multiply by 2.0 because the unit cube's AABB bounds are at 0.5, and we want
305                    // 1.0
306                    .with_position(unit_cube_point * 2.0)
307                    // "normalize" (really "shift") the space coord from [-0.5, 0.5] to [0.0, 1.0]
308                    .with_color((unit_cube_point + 0.5).extend(1.0))
309            })))
310            .with_indices(stage.new_indices(UNIT_INDICES.map(|u| u as u32)));
311
312        let frame = ctx.get_next_frame().unwrap();
313        stage.render(&frame.view());
314        let img = frame.read_image().block().unwrap();
315        img_diff::assert_img_eq("cubemap/hand_rolled_cubemap_sampling/cube.png", img);
316        frame.present();
317
318        let scene_cubemap = SceneCubemap::new(
319            ctx.get_device(),
320            UVec2::new(width, height),
321            wgpu::TextureFormat::Rgba8Unorm,
322            Vec4::ZERO,
323        );
324        scene_cubemap.run(&stage);
325
326        let slab = SlabAllocator::new(&ctx, "cubemap-sampling-test", wgpu::BufferUsages::empty());
327        let uv = slab.new_value(Vec3::ZERO);
328        let buffer = slab.commit();
329        let label = Some("cubemap-sampling-test");
330        let bind_group_layout =
331            ctx.get_device()
332                .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
333                    label,
334                    entries: &[
335                        wgpu::BindGroupLayoutEntry {
336                            binding: 0,
337                            visibility: wgpu::ShaderStages::VERTEX,
338                            ty: wgpu::BindingType::Buffer {
339                                ty: wgpu::BufferBindingType::Storage { read_only: true },
340                                has_dynamic_offset: false,
341                                min_binding_size: None,
342                            },
343                            count: None,
344                        },
345                        wgpu::BindGroupLayoutEntry {
346                            binding: 1,
347                            visibility: wgpu::ShaderStages::FRAGMENT,
348                            ty: wgpu::BindingType::Texture {
349                                sample_type: wgpu::TextureSampleType::Float { filterable: true },
350                                view_dimension: wgpu::TextureViewDimension::Cube,
351                                multisampled: false,
352                            },
353                            count: None,
354                        },
355                        wgpu::BindGroupLayoutEntry {
356                            binding: 2,
357                            visibility: wgpu::ShaderStages::FRAGMENT,
358                            ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering),
359                            count: None,
360                        },
361                    ],
362                });
363        let cubemap_sampling_pipeline_layout =
364            ctx.get_device()
365                .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
366                    label,
367                    bind_group_layouts: &[&bind_group_layout],
368                    push_constant_ranges: &[],
369                });
370        let vertex = crate::linkage::cubemap_sampling_test_vertex::linkage(ctx.get_device());
371        let fragment = crate::linkage::cubemap_sampling_test_fragment::linkage(ctx.get_device());
372        let cubemap_sampling_pipeline =
373            ctx.get_device()
374                .create_render_pipeline(&wgpu::RenderPipelineDescriptor {
375                    label,
376                    layout: Some(&cubemap_sampling_pipeline_layout),
377                    vertex: wgpu::VertexState {
378                        module: &vertex.module,
379                        entry_point: Some(vertex.entry_point),
380                        compilation_options: wgpu::PipelineCompilationOptions::default(),
381                        buffers: &[],
382                    },
383                    primitive: wgpu::PrimitiveState {
384                        topology: wgpu::PrimitiveTopology::TriangleList,
385                        strip_index_format: None,
386                        front_face: wgpu::FrontFace::Ccw,
387                        cull_mode: None,
388                        unclipped_depth: false,
389                        polygon_mode: wgpu::PolygonMode::Fill,
390                        conservative: false,
391                    },
392                    depth_stencil: None,
393                    multisample: wgpu::MultisampleState::default(),
394                    fragment: Some(wgpu::FragmentState {
395                        module: &fragment.module,
396                        entry_point: Some(fragment.entry_point),
397                        compilation_options: Default::default(),
398                        targets: &[Some(wgpu::ColorTargetState {
399                            format: wgpu::TextureFormat::Rgba8Unorm,
400                            blend: None,
401                            write_mask: wgpu::ColorWrites::all(),
402                        })],
403                    }),
404                    multiview: None,
405                    cache: None,
406                });
407
408        let cubemap_view =
409            scene_cubemap
410                .cubemap_texture
411                .create_view(&wgpu::TextureViewDescriptor {
412                    label,
413                    dimension: Some(wgpu::TextureViewDimension::Cube),
414                    ..Default::default()
415                });
416        let cubemap_sampler = ctx.get_device().create_sampler(&wgpu::SamplerDescriptor {
417            label,
418            ..Default::default()
419        });
420        let bind_group = ctx
421            .get_device()
422            .create_bind_group(&wgpu::BindGroupDescriptor {
423                label,
424                layout: &bind_group_layout,
425                entries: &[
426                    wgpu::BindGroupEntry {
427                        binding: 0,
428                        resource: buffer.as_entire_binding(),
429                    },
430                    wgpu::BindGroupEntry {
431                        binding: 1,
432                        resource: wgpu::BindingResource::TextureView(&cubemap_view),
433                    },
434                    wgpu::BindGroupEntry {
435                        binding: 2,
436                        resource: wgpu::BindingResource::Sampler(&cubemap_sampler),
437                    },
438                ],
439            });
440        let render_target = ctx.get_device().create_texture(&wgpu::TextureDescriptor {
441            label,
442            size: wgpu::Extent3d {
443                width: 1,
444                height: 1,
445                depth_or_array_layers: 1,
446            },
447            mip_level_count: 1,
448            sample_count: 1,
449            dimension: wgpu::TextureDimension::D2,
450            format: wgpu::TextureFormat::Rgba8Unorm,
451            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
452            view_formats: &[],
453        });
454        let render_target_view = render_target.create_view(&wgpu::TextureViewDescriptor::default());
455
456        let sample = |dir: Vec3| -> Vec4 {
457            uv.set(dir.normalize_or(Vec3::ZERO));
458            slab.commit();
459
460            let mut encoder = ctx
461                .get_device()
462                .create_command_encoder(&wgpu::CommandEncoderDescriptor { label });
463            {
464                let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
465                    label,
466                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
467                        view: &render_target_view,
468                        resolve_target: None,
469                        ops: wgpu::Operations {
470                            load: wgpu::LoadOp::Clear(wgpu::Color {
471                                r: 0.0,
472                                g: 0.0,
473                                b: 0.0,
474                                a: 0.0,
475                            }),
476                            store: wgpu::StoreOp::Store,
477                        },
478                        depth_slice: None,
479                    })],
480                    depth_stencil_attachment: None,
481                    timestamp_writes: None,
482                    occlusion_query_set: None,
483                });
484                render_pass.set_pipeline(&cubemap_sampling_pipeline);
485                render_pass.set_bind_group(0, &bind_group, &[]);
486                render_pass.draw(0..6, 0..1);
487            }
488            let submission_index = ctx.get_queue().submit(Some(encoder.finish()));
489            ctx.get_device()
490                .poll(wgpu::PollType::WaitForSubmissionIndex(submission_index))
491                .unwrap();
492
493            let img = Texture::read(&ctx, &render_target, 1, 1, 4, 1)
494                .into_image::<u8, image::Rgba<u8>>(ctx.get_device())
495                .block()
496                .unwrap();
497            let image::Rgba([r, g, b, a]) = img.get_pixel(0, 0);
498            Vec4::new(
499                r as f32 / 255.0,
500                g as f32 / 255.0,
501                b as f32 / 255.0,
502                a as f32 / 255.0,
503            )
504        };
505
506        fn index_to_face_string(index: usize) -> &'static str {
507            match index {
508                0 => "+X",
509                1 => "-X",
510                2 => "+Y",
511                3 => "-Y",
512                4 => "+Z",
513                5 => "-Z",
514                _ => "?",
515            }
516        }
517
518        let mut cpu_cubemap = vec![];
519        for i in 0..6 {
520            let img = CopiedTextureBuffer::read_from(
521                &ctx,
522                &scene_cubemap.cubemap_texture,
523                width as usize,
524                height as usize,
525                4,
526                1,
527                0,
528                Some(wgpu::Origin3d { x: 0, y: 0, z: i }),
529            )
530            .into_image::<u8, image::Rgba<u8>>(ctx.get_device())
531            .block()
532            .unwrap();
533
534            img_diff::assert_img_eq(
535                &format!(
536                    "cubemap/hand_rolled_cubemap_sampling/face_{}.png",
537                    index_to_face_string(i as usize)
538                ),
539                img.clone(),
540            );
541
542            cpu_cubemap.push(img);
543        }
544        let cpu_cubemap = [
545            cpu_cubemap.remove(0),
546            cpu_cubemap.remove(0),
547            cpu_cubemap.remove(0),
548            cpu_cubemap.remove(0),
549            cpu_cubemap.remove(0),
550            cpu_cubemap.remove(0),
551        ];
552
553        {
554            // assert a few sanity checks on the cpu cubemap
555            println!("x samples sanity");
556            let x_samples_uv = [
557                UVec2::ZERO,
558                UVec2::new(255, 0),
559                UVec2::new(127, 127),
560                UVec2::new(255, 255),
561                UVec2::new(0, 255),
562            ];
563
564            for uv in x_samples_uv {
565                let image::Rgba([r, g, b, a]) = cpu_cubemap[0].get_pixel(uv.x, uv.y);
566                println!("uv: {uv}");
567                println!("rgba: {r} {g} {b} {a}");
568            }
569        }
570
571        let mut uvs = vec![
572            // start with cardinal directions
573            Vec3::X,
574            Vec3::NEG_X,
575            Vec3::Y,
576            Vec3::NEG_Y,
577            Vec3::Z,
578            Vec3::NEG_Z,
579        ];
580
581        // add corners to the uvs to sample
582        for x in [-1.0, 1.0] {
583            for y in [-1.0, 1.0] {
584                for z in [-1.0, 1.0] {
585                    let uv = Vec3::new(x, y, z);
586                    uvs.push(uv);
587                }
588            }
589        }
590
591        // add in some deterministic pseudo-randomn points
592        {
593            let mut prng = crate::math::GpuRng::new(666);
594            let mut rf32 = move || prng.gen_f32(0.0, 1.0);
595            let mut rxvec3 = { || Vec3::new(f32::MAX, rf32(), rf32()).normalize_or(Vec3::X) };
596            // let mut rvec3 = || Vec3::new(rf32(), rf32(), rf32());
597            uvs.extend((0..20).map(|_| rxvec3()));
598        }
599
600        const THRESHOLD: f32 = 0.005;
601        for uv in uvs.into_iter() {
602            let nuv = uv.normalize_or(Vec3::X);
603            let color = sample(uv);
604            let (face_index, uv2d) =
605                CubemapDescriptor::get_face_index_and_uv(uv.normalize_or(Vec3::X));
606            let px = (uv2d.x * (width as f32 - 1.0)).round() as u32;
607            let py = (uv2d.y * (height as f32 - 1.0)).round() as u32;
608            let puv = UVec2::new(px, py);
609            let cpu_color = cpu_sample_cubemap(&cpu_cubemap, uv);
610            let dir_string = index_to_face_string(face_index);
611            println!(
612                "__uv: {uv},\n_nuv: {nuv},\n_gpu: {color}\n_cpu: {cpu_color}\nfrom: \
613                 {dir_string}({face_index}) {uv2d} {puv}\n"
614            );
615            let cmp = pretty_assertions::Comparison::new(&color, &cpu_color);
616            let distance = color.distance(cpu_color);
617            if distance > THRESHOLD {
618                println!("distance: {distance}");
619                println!("{cmp}");
620                panic!("distance {distance} greater than {THRESHOLD}");
621            }
622        }
623    }
624}