1use std::borrow::Cow;
33use std::sync::OnceLock;
34
35use khora_core::asset::AssetUUID;
36use khora_core::math::{Extent3D, LinearRgba, Vec3};
37use khora_core::renderer::api::command::{
38 BindGroupDescriptor, BindGroupEntry, BindGroupLayoutEntry, BindingResource, BindingType,
39 BufferBinding, BufferBindingType, LoadOp, Operations, RenderPassColorAttachment,
40 RenderPassDescriptor, SamplerBindingType, StoreOp, TextureSampleType, TextureViewDimension,
41};
42use khora_core::renderer::api::ibl::IblGpuBindings;
43use khora_core::renderer::api::pipeline::state::ColorWrites;
44use khora_core::renderer::api::pipeline::{
45 ColorTargetStateDescriptor, LayoutSpec, MultisampleStateDescriptor, PipelineSpec,
46 PrimitiveStateDescriptor, ShaderVariantKey,
47};
48use khora_core::renderer::api::resource::{
49 AddressMode, BufferDescriptor, BufferId, BufferUsage, FilterMode, ImageAspect,
50 MipmapFilterMode, SamplerDescriptor, SamplerId, TextureDescriptor, TextureDimension, TextureId,
51 TextureUsage, TextureViewDescriptor, TextureViewId,
52};
53use khora_core::renderer::api::util::{SampleCount, ShaderStageFlags, TextureFormat};
54use khora_core::renderer::error::RenderError;
55use khora_core::renderer::traits::PipelineSystem;
56use khora_core::renderer::GraphicsDevice;
57
58const ENV_FACE_SIZE: u32 = 256;
61const IRRADIANCE_FACE_SIZE: u32 = 32;
64const IBL_FORMAT: TextureFormat = TextureFormat::Rgba16Float;
66
67const PREFILTER_FACE_SIZE: u32 = 128;
70const PREFILTER_MIPS: u32 = 5;
72const BRDF_LUT_SIZE: u32 = 512;
74
75const SKY_SHADER: &str = "khora::pipelines::ibl_sky";
76const EQUIRECT_SHADER: &str = "khora::pipelines::ibl_equirect";
77const EQUIRECT_LAYOUT: &str = "ibl_equirect";
78const IRRADIANCE_SHADER: &str = "khora::pipelines::ibl_irradiance";
79const PREFILTER_SHADER: &str = "khora::pipelines::ibl_prefilter";
80const BRDF_SHADER: &str = "khora::pipelines::ibl_brdf_lut";
81const FACE_BASIS_LAYOUT: &str = "ibl_sky_face_basis";
82const IRRADIANCE_LAYOUT: &str = "ibl_irradiance_conv";
83const PREFILTER_LAYOUT: &str = "ibl_prefilter";
84
85#[repr(C)]
89#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
90struct FaceBasisUniform {
91 forward: [f32; 4],
92 right: [f32; 4],
93 up: [f32; 4],
94 sun: [f32; 4],
100}
101
102const DEFAULT_SUN_DIRECTION: Vec3 = Vec3::new(0.35, 0.78, 0.52);
105
106const FACE_BASES: [FaceBasisUniform; 6] = [
113 FaceBasisUniform {
114 forward: [1.0, 0.0, 0.0, 0.0],
115 right: [0.0, 0.0, -1.0, 0.0],
116 up: [0.0, 1.0, 0.0, 0.0],
117 sun: [0.0; 4],
118 }, FaceBasisUniform {
120 forward: [-1.0, 0.0, 0.0, 0.0],
121 right: [0.0, 0.0, 1.0, 0.0],
122 up: [0.0, 1.0, 0.0, 0.0],
123 sun: [0.0; 4],
124 }, FaceBasisUniform {
126 forward: [0.0, 1.0, 0.0, 0.0],
127 right: [1.0, 0.0, 0.0, 0.0],
128 up: [0.0, 0.0, -1.0, 0.0],
129 sun: [0.0; 4],
130 }, FaceBasisUniform {
132 forward: [0.0, -1.0, 0.0, 0.0],
133 right: [1.0, 0.0, 0.0, 0.0],
134 up: [0.0, 0.0, 1.0, 0.0],
135 sun: [0.0; 4],
136 }, FaceBasisUniform {
138 forward: [0.0, 0.0, 1.0, 0.0],
139 right: [1.0, 0.0, 0.0, 0.0],
140 up: [0.0, 1.0, 0.0, 0.0],
141 sun: [0.0; 4],
142 }, FaceBasisUniform {
144 forward: [0.0, 0.0, -1.0, 0.0],
145 right: [-1.0, 0.0, 0.0, 0.0],
146 up: [0.0, 1.0, 0.0, 0.0],
147 sun: [0.0; 4],
148 }, ];
150
151struct Cube {
154 texture: TextureId,
155 cube_view: TextureViewId,
156 face_views: Vec<TextureViewId>,
157}
158
159struct IblResources {
163 bindings: IblGpuBindings,
164 _keep_textures: Vec<TextureId>,
165 _keep_views: Vec<TextureViewId>,
166 _keep_buffers: Vec<BufferId>,
167}
168
169#[derive(Debug, Default, Clone)]
180pub struct EnvironmentMap {
181 pub texture: Option<AssetUUID>,
183}
184
185impl EnvironmentMap {
186 pub fn from_asset(texture: AssetUUID) -> Self {
188 Self {
189 texture: Some(texture),
190 }
191 }
192}
193
194#[derive(Default)]
200pub struct IblBaker {
201 res: OnceLock<IblResources>,
202 env_wait: std::sync::atomic::AtomicU32,
203}
204
205const MAX_ENV_WAIT_TICKS: u32 = 120;
212
213impl IblBaker {
214 pub fn new() -> Self {
217 Self::default()
218 }
219
220 pub fn is_baked(&self) -> bool {
222 self.res.get().is_some()
223 }
224
225 pub fn wait_for_environment(&self) -> bool {
229 let waited = self
230 .env_wait
231 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
232 waited < MAX_ENV_WAIT_TICKS
233 }
234
235 pub fn ensure_baked(
248 &self,
249 device: &dyn GraphicsDevice,
250 pipeline_system: &dyn PipelineSystem,
251 sun_direction: Vec3,
252 env_source: Option<&khora_core::renderer::api::resource::CpuTexture>,
253 ) {
254 if self.res.get().is_some() {
255 return;
256 }
257 let sun = normalized_or_default(sun_direction);
258 match bake(device, pipeline_system, sun, env_source) {
259 Ok(res) => {
260 log::info!(
261 "IBL: baked environment ({0}x{0}) + diffuse irradiance ({1}x{1}) cubes, sun=({2:.2}, {3:.2}, {4:.2})",
262 ENV_FACE_SIZE,
263 IRRADIANCE_FACE_SIZE,
264 sun.x,
265 sun.y,
266 sun.z
267 );
268 let _ = self.res.set(res);
269 }
270 Err(e) => log::error!("IBL: bake failed: {e:?}"),
271 }
272 }
273
274 pub fn bindings(&self) -> Option<IblGpuBindings> {
277 self.res.get().map(|r| r.bindings)
278 }
279}
280
281fn normalized_or_default(dir: Vec3) -> Vec3 {
284 let len_sq = dir.length_squared();
285 if len_sq > 1e-6 {
286 dir / len_sq.sqrt()
287 } else {
288 DEFAULT_SUN_DIRECTION.normalize()
289 }
290}
291
292fn create_cube(
295 device: &dyn GraphicsDevice,
296 face_size: u32,
297 label: &str,
298) -> Result<Cube, RenderError> {
299 let texture = device.create_texture(&TextureDescriptor {
300 label: Some(Cow::Owned(format!("{label} Texture"))),
301 size: Extent3D {
302 width: face_size,
303 height: face_size,
304 depth_or_array_layers: 6,
305 },
306 mip_level_count: 1,
307 sample_count: SampleCount::X1,
308 dimension: TextureDimension::D2,
309 format: IBL_FORMAT,
310 usage: TextureUsage::RENDER_ATTACHMENT | TextureUsage::TEXTURE_BINDING,
311 view_formats: Cow::Borrowed(&[]),
312 })?;
313 let cube_view = device.create_texture_view(
314 texture,
315 &TextureViewDescriptor {
316 label: Some(Cow::Owned(format!("{label} Cube View"))),
317 format: Some(IBL_FORMAT),
318 dimension: Some(TextureViewDimension::Cube),
319 aspect: ImageAspect::All,
320 base_mip_level: 0,
321 mip_level_count: Some(1),
322 base_array_layer: 0,
323 array_layer_count: Some(6),
324 },
325 )?;
326 let mut face_views = Vec::with_capacity(6);
327 for face in 0..6u32 {
328 face_views.push(device.create_texture_view(
329 texture,
330 &TextureViewDescriptor {
331 label: Some(Cow::Owned(format!("{label} Face [{face}]"))),
332 format: Some(IBL_FORMAT),
333 dimension: Some(TextureViewDimension::D2),
334 aspect: ImageAspect::All,
335 base_mip_level: 0,
336 mip_level_count: Some(1),
337 base_array_layer: face,
338 array_layer_count: Some(1),
339 },
340 )?);
341 }
342 Ok(Cube {
343 texture,
344 cube_view,
345 face_views,
346 })
347}
348
349fn uniform_entry(binding: u32) -> BindGroupLayoutEntry {
351 BindGroupLayoutEntry {
352 binding,
353 visibility: ShaderStageFlags::FRAGMENT,
354 ty: BindingType::Buffer {
355 ty: BufferBindingType::Uniform,
356 has_dynamic_offset: false,
357 min_binding_size: None,
358 },
359 }
360}
361
362fn create_face_basis_buffers(
365 device: &dyn GraphicsDevice,
366 sun: Vec3,
367) -> Result<Vec<BufferId>, RenderError> {
368 let mut buffers = Vec::with_capacity(6);
369 for (face, basis) in FACE_BASES.iter().enumerate() {
370 let mut b = *basis;
371 b.sun = [sun.x, sun.y, sun.z, 0.0];
372 buffers.push(device.create_buffer_with_data(
373 &BufferDescriptor {
374 label: Some(Cow::Owned(format!("IBL Face Basis [{face}]"))),
375 size: std::mem::size_of::<FaceBasisUniform>() as u64,
376 usage: BufferUsage::UNIFORM | BufferUsage::COPY_DST,
377 mapped_at_creation: false,
378 },
379 bytemuck::bytes_of(&b),
380 )?);
381 }
382 Ok(buffers)
383}
384
385fn create_ibl_sampler(device: &dyn GraphicsDevice) -> Result<SamplerId, RenderError> {
389 device
390 .create_sampler(&SamplerDescriptor {
391 label: Some(Cow::Borrowed("ibl_sampler")),
392 address_mode_u: AddressMode::ClampToEdge,
393 address_mode_v: AddressMode::ClampToEdge,
394 address_mode_w: AddressMode::ClampToEdge,
395 mag_filter: FilterMode::Linear,
396 min_filter: FilterMode::Linear,
397 mipmap_filter: MipmapFilterMode::Linear,
398 lod_min_clamp: 0.0,
399 lod_max_clamp: 16.0,
400 compare: None,
401 anisotropy_clamp: 1,
402 border_color: None,
403 })
404 .map_err(RenderError::ResourceError)
405}
406
407fn create_mip_cube(
413 device: &dyn GraphicsDevice,
414 face_size: u32,
415 mips: u32,
416 label: &str,
417) -> Result<(TextureId, TextureViewId), RenderError> {
418 let texture = device.create_texture(&TextureDescriptor {
419 label: Some(Cow::Owned(format!("{label} Texture"))),
420 size: Extent3D {
421 width: face_size,
422 height: face_size,
423 depth_or_array_layers: 6,
424 },
425 mip_level_count: mips,
426 sample_count: SampleCount::X1,
427 dimension: TextureDimension::D2,
428 format: IBL_FORMAT,
429 usage: TextureUsage::RENDER_ATTACHMENT | TextureUsage::TEXTURE_BINDING,
430 view_formats: Cow::Borrowed(&[]),
431 })?;
432 let cube_view = device.create_texture_view(
433 texture,
434 &TextureViewDescriptor {
435 label: Some(Cow::Owned(format!("{label} Cube View"))),
436 format: Some(IBL_FORMAT),
437 dimension: Some(TextureViewDimension::Cube),
438 aspect: ImageAspect::All,
439 base_mip_level: 0,
440 mip_level_count: Some(mips),
441 base_array_layer: 0,
442 array_layer_count: Some(6),
443 },
444 )?;
445 Ok((texture, cube_view))
446}
447
448fn create_brdf_lut(device: &dyn GraphicsDevice) -> Result<(TextureId, TextureViewId), RenderError> {
451 let texture = device.create_texture(&TextureDescriptor {
452 label: Some(Cow::Borrowed("IBL BRDF LUT")),
453 size: Extent3D {
454 width: BRDF_LUT_SIZE,
455 height: BRDF_LUT_SIZE,
456 depth_or_array_layers: 1,
457 },
458 mip_level_count: 1,
459 sample_count: SampleCount::X1,
460 dimension: TextureDimension::D2,
461 format: IBL_FORMAT,
462 usage: TextureUsage::RENDER_ATTACHMENT | TextureUsage::TEXTURE_BINDING,
463 view_formats: Cow::Borrowed(&[]),
464 })?;
465 let view = device.create_texture_view(
466 texture,
467 &TextureViewDescriptor {
468 label: Some(Cow::Borrowed("IBL BRDF LUT View")),
469 format: Some(IBL_FORMAT),
470 dimension: Some(TextureViewDimension::D2),
471 aspect: ImageAspect::All,
472 base_mip_level: 0,
473 mip_level_count: Some(1),
474 base_array_layer: 0,
475 array_layer_count: Some(1),
476 },
477 )?;
478 Ok((texture, view))
479}
480
481fn create_prefilter_basis_buffers(
484 device: &dyn GraphicsDevice,
485 roughness: f32,
486) -> Result<Vec<BufferId>, RenderError> {
487 let mut buffers = Vec::with_capacity(6);
488 for (face, basis) in FACE_BASES.iter().enumerate() {
489 let mut b = *basis;
490 b.forward[3] = roughness;
491 buffers.push(device.create_buffer_with_data(
492 &BufferDescriptor {
493 label: Some(Cow::Owned(format!(
494 "IBL Prefilter Basis [r={roughness:.2} f={face}]"
495 ))),
496 size: std::mem::size_of::<FaceBasisUniform>() as u64,
497 usage: BufferUsage::UNIFORM | BufferUsage::COPY_DST,
498 mapped_at_creation: false,
499 },
500 bytemuck::bytes_of(&b),
501 )?);
502 }
503 Ok(buffers)
504}
505
506fn bake(
513 device: &dyn GraphicsDevice,
514 pipeline_system: &dyn PipelineSystem,
515 sun: Vec3,
516 env_source: Option<&khora_core::renderer::api::resource::CpuTexture>,
517) -> Result<IblResources, RenderError> {
518 let env = create_cube(device, ENV_FACE_SIZE, "IBL Env")?;
519 let irradiance = create_cube(device, IRRADIANCE_FACE_SIZE, "IBL Irradiance")?;
520 let (prefilter_texture, prefilter_view) =
521 create_mip_cube(device, PREFILTER_FACE_SIZE, PREFILTER_MIPS, "IBL Prefilter")?;
522 let (brdf_texture, brdf_view) = create_brdf_lut(device)?;
523 let sampler = create_ibl_sampler(device)?;
524
525 let irr_pipeline = pipeline_system.pipeline(device, &irradiance_pipeline_spec())?;
527 let irr_layout =
528 pipeline_system.inline_layout(device, IRRADIANCE_LAYOUT, &irradiance_layout_entries())?;
529 let pre_pipeline = pipeline_system.pipeline(device, &prefilter_pipeline_spec())?;
530 let pre_layout =
531 pipeline_system.inline_layout(device, PREFILTER_LAYOUT, &irradiance_layout_entries())?;
532 let brdf_pipeline = pipeline_system.pipeline(device, &brdf_pipeline_spec())?;
533
534 let sky_bufs = create_face_basis_buffers(device, sun)?;
535 let irr_bufs = create_face_basis_buffers(device, sun)?;
536
537 let mut equirect_keep: Option<(TextureId, TextureViewId)> = None;
541 let (env_pipeline, env_bgs) = match env_source {
542 Some(cpu) => {
543 let (texture, view) = upload_equirect(device, cpu)?;
544 equirect_keep = Some((texture, view));
545 let equirect_sampler = create_equirect_sampler(device)?;
546 let pipeline = pipeline_system.pipeline(device, &equirect_pipeline_spec())?;
547 let layout = pipeline_system.inline_layout(
548 device,
549 EQUIRECT_LAYOUT,
550 &equirect_layout_entries(),
551 )?;
552 let mut bgs = Vec::with_capacity(6);
553 for buf in &sky_bufs {
554 bgs.push(sampled_texture_bind_group(
555 device,
556 layout,
557 view,
558 equirect_sampler,
559 *buf,
560 )?);
561 }
562 log::info!(
563 "IBL: environment from authored equirectangular map ({}x{}, {:?})",
564 cpu.size.width,
565 cpu.size.height,
566 cpu.format
567 );
568 (pipeline, bgs)
569 }
570 None => {
571 let pipeline = pipeline_system.pipeline(device, &sky_pipeline_spec())?;
572 let layout =
573 pipeline_system.inline_layout(device, FACE_BASIS_LAYOUT, &[uniform_entry(0)])?;
574 let mut bgs = Vec::with_capacity(6);
575 for buf in &sky_bufs {
576 bgs.push(device.create_bind_group(&BindGroupDescriptor {
577 label: Some("IBL Sky Face BG"),
578 layout,
579 entries: &[uniform_bg_entry(0, *buf)],
580 })?);
581 }
582 (pipeline, bgs)
583 }
584 };
585 let mut irr_bgs = Vec::with_capacity(6);
587 for buf in &irr_bufs {
588 irr_bgs.push(sampled_texture_bind_group(
589 device,
590 irr_layout,
591 env.cube_view,
592 sampler,
593 *buf,
594 )?);
595 }
596 let mut pre_bufs: Vec<BufferId> = Vec::with_capacity((PREFILTER_MIPS * 6) as usize);
598 let mut pre_bgs = Vec::with_capacity((PREFILTER_MIPS * 6) as usize);
599 for mip in 0..PREFILTER_MIPS {
600 let roughness = mip as f32 / (PREFILTER_MIPS - 1) as f32;
602 let bufs = create_prefilter_basis_buffers(device, roughness)?;
603 for buf in &bufs {
604 pre_bgs.push(sampled_texture_bind_group(
605 device,
606 pre_layout,
607 env.cube_view,
608 sampler,
609 *buf,
610 )?);
611 }
612 pre_bufs.extend(bufs);
613 }
614
615 let mut sky_encoder = device.create_command_encoder(Some("IBL Env Bake"));
621 record_face_passes(
622 &mut *sky_encoder,
623 &env_pipeline,
624 &env.face_views,
625 &env_bgs,
626 "IBL Env Face",
627 );
628 match sky_encoder.finish() {
629 Some(cb) => device.submit_command_buffer(cb),
630 None => log::error!("IBL: sky bake encoder finish returned None; skipping submit"),
631 }
632
633 let mut encoder = device.create_command_encoder(Some("IBL Filter Bake"));
636 record_face_passes(
637 &mut *encoder,
638 &irr_pipeline,
639 &irradiance.face_views,
640 &irr_bgs,
641 "IBL Irradiance Face",
642 );
643 for mip in 0..PREFILTER_MIPS {
645 for face in 0..6usize {
646 let idx = (mip as usize) * 6 + face;
647 let attachments = [RenderPassColorAttachment {
648 view: &prefilter_view,
649 resolve_target: None,
650 ops: Operations {
651 load: LoadOp::Clear(LinearRgba::BLACK),
652 store: StoreOp::Store,
653 },
654 base_array_layer: face as u32,
655 base_mip_level: mip,
656 }];
657 let mut pass = encoder.begin_render_pass(&RenderPassDescriptor {
658 label: Some("IBL Prefilter Face"),
659 color_attachments: &attachments,
660 depth_stencil_attachment: None,
661 });
662 pass.set_pipeline(&pre_pipeline);
663 pass.set_bind_group(0, &pre_bgs[idx], &[]);
664 pass.draw(0..3, 0..1);
665 }
666 }
667 {
669 let attachments = [RenderPassColorAttachment {
670 view: &brdf_view,
671 resolve_target: None,
672 ops: Operations {
673 load: LoadOp::Clear(LinearRgba::BLACK),
674 store: StoreOp::Store,
675 },
676 base_array_layer: 0,
677 base_mip_level: 0,
678 }];
679 let mut pass = encoder.begin_render_pass(&RenderPassDescriptor {
680 label: Some("IBL BRDF LUT"),
681 color_attachments: &attachments,
682 depth_stencil_attachment: None,
683 });
684 pass.set_pipeline(&brdf_pipeline);
685 pass.draw(0..3, 0..1);
686 }
687 match encoder.finish() {
688 Some(cb) => device.submit_command_buffer(cb),
689 None => log::error!("IBL: filter bake encoder finish returned None; skipping submit"),
690 }
691
692 let bindings = IblGpuBindings {
693 env_cube: env.cube_view,
694 irradiance_cube: irradiance.cube_view,
695 prefiltered_cube: prefilter_view,
696 brdf_lut: brdf_view,
697 sampler,
698 };
699
700 let mut keep_views = vec![
701 env.cube_view,
702 irradiance.cube_view,
703 prefilter_view,
704 brdf_view,
705 ];
706 keep_views.extend(env.face_views);
707 keep_views.extend(irradiance.face_views);
708 let mut keep_textures = vec![
709 env.texture,
710 irradiance.texture,
711 prefilter_texture,
712 brdf_texture,
713 ];
714 if let Some((texture, view)) = equirect_keep {
717 keep_textures.push(texture);
718 keep_views.push(view);
719 }
720 let mut keep_buffers = sky_bufs;
721 keep_buffers.extend(irr_bufs);
722 keep_buffers.extend(pre_bufs);
723
724 Ok(IblResources {
725 bindings,
726 _keep_textures: keep_textures,
727 _keep_views: keep_views,
728 _keep_buffers: keep_buffers,
729 })
730}
731
732fn uniform_bg_entry<'a>(binding: u32, buffer: BufferId) -> BindGroupEntry<'a> {
734 BindGroupEntry {
735 binding,
736 resource: BindingResource::Buffer(BufferBinding {
737 buffer,
738 offset: 0,
739 size: None,
740 }),
741 _phantom: std::marker::PhantomData,
742 }
743}
744
745fn record_face_passes(
748 encoder: &mut dyn khora_core::renderer::traits::CommandEncoder,
749 pipeline: &khora_core::renderer::api::pipeline::RenderPipelineId,
750 face_views: &[TextureViewId],
751 bind_groups: &[khora_core::renderer::api::command::BindGroupId],
752 label: &'static str,
753) {
754 for face in 0..6usize {
755 let attachments = [RenderPassColorAttachment {
756 view: &face_views[face],
757 resolve_target: None,
758 ops: Operations {
759 load: LoadOp::Clear(LinearRgba::BLACK),
760 store: StoreOp::Store,
761 },
762 base_array_layer: face as u32,
767 base_mip_level: 0,
768 }];
769 let mut pass = encoder.begin_render_pass(&RenderPassDescriptor {
770 label: Some(label),
771 color_attachments: &attachments,
772 depth_stencil_attachment: None,
773 });
774 pass.set_pipeline(pipeline);
775 pass.set_bind_group(0, &bind_groups[face], &[]);
776 pass.draw(0..3, 0..1);
777 }
778}
779
780fn sampled_texture_bind_group(
787 device: &dyn GraphicsDevice,
788 layout: khora_core::renderer::api::command::BindGroupLayoutId,
789 source_view: TextureViewId,
790 sampler: SamplerId,
791 basis_buffer: BufferId,
792) -> Result<khora_core::renderer::api::command::BindGroupId, RenderError> {
793 device
794 .create_bind_group(&BindGroupDescriptor {
795 label: Some("IBL Texture-Sample BG"),
796 layout,
797 entries: &[
798 BindGroupEntry {
799 binding: 0,
800 resource: BindingResource::TextureView(source_view),
801 _phantom: std::marker::PhantomData,
802 },
803 BindGroupEntry {
804 binding: 1,
805 resource: BindingResource::Sampler(sampler),
806 _phantom: std::marker::PhantomData,
807 },
808 uniform_bg_entry(2, basis_buffer),
809 ],
810 })
811 .map_err(RenderError::ResourceError)
812}
813
814fn sky_pipeline_spec() -> PipelineSpec {
816 bake_pipeline_spec(
817 "IBL Sky Bake",
818 SKY_SHADER,
819 vec![LayoutSpec::Inline {
820 label: FACE_BASIS_LAYOUT,
821 entries: Cow::Owned(vec![uniform_entry(0)]),
822 }],
823 )
824}
825
826fn prefilter_pipeline_spec() -> PipelineSpec {
829 bake_pipeline_spec(
830 "IBL Prefilter Bake",
831 PREFILTER_SHADER,
832 vec![LayoutSpec::Inline {
833 label: PREFILTER_LAYOUT,
834 entries: Cow::Owned(irradiance_layout_entries()),
835 }],
836 )
837}
838
839fn brdf_pipeline_spec() -> PipelineSpec {
842 let mut spec = bake_pipeline_spec("IBL BRDF LUT Bake", BRDF_SHADER, vec![]);
843 spec.label = "IBL BRDF LUT Bake";
846 spec
847}
848
849fn irradiance_layout_entries() -> Vec<BindGroupLayoutEntry> {
852 vec![
853 BindGroupLayoutEntry {
854 binding: 0,
855 visibility: ShaderStageFlags::FRAGMENT,
856 ty: BindingType::Texture {
857 sample_type: TextureSampleType::Float { filterable: true },
858 view_dimension: TextureViewDimension::Cube,
859 multisampled: false,
860 },
861 },
862 BindGroupLayoutEntry {
863 binding: 1,
864 visibility: ShaderStageFlags::FRAGMENT,
865 ty: BindingType::Sampler(SamplerBindingType::Filtering),
866 },
867 uniform_entry(2),
868 ]
869}
870
871fn equirect_layout_entries() -> Vec<BindGroupLayoutEntry> {
875 vec![
876 BindGroupLayoutEntry {
877 binding: 0,
878 visibility: ShaderStageFlags::FRAGMENT,
879 ty: BindingType::Texture {
880 sample_type: TextureSampleType::Float { filterable: true },
881 view_dimension: TextureViewDimension::D2,
882 multisampled: false,
883 },
884 },
885 BindGroupLayoutEntry {
886 binding: 1,
887 visibility: ShaderStageFlags::FRAGMENT,
888 ty: BindingType::Sampler(SamplerBindingType::Filtering),
889 },
890 uniform_entry(2),
891 ]
892}
893
894fn equirect_pipeline_spec() -> PipelineSpec {
896 bake_pipeline_spec(
897 "IBL Equirect Bake",
898 EQUIRECT_SHADER,
899 vec![LayoutSpec::Inline {
900 label: EQUIRECT_LAYOUT,
901 entries: Cow::Owned(equirect_layout_entries()),
902 }],
903 )
904}
905
906fn create_equirect_sampler(device: &dyn GraphicsDevice) -> Result<SamplerId, RenderError> {
909 device
910 .create_sampler(&SamplerDescriptor {
911 label: Some(Cow::Borrowed("ibl_equirect_sampler")),
912 address_mode_u: AddressMode::Repeat,
913 address_mode_v: AddressMode::ClampToEdge,
914 address_mode_w: AddressMode::ClampToEdge,
915 mag_filter: FilterMode::Linear,
916 min_filter: FilterMode::Linear,
917 mipmap_filter: MipmapFilterMode::Linear,
918 lod_min_clamp: 0.0,
919 lod_max_clamp: 0.0,
920 compare: None,
921 anisotropy_clamp: 1,
922 border_color: None,
923 })
924 .map_err(RenderError::ResourceError)
925}
926
927fn upload_equirect(
934 device: &dyn GraphicsDevice,
935 cpu: &khora_core::renderer::api::resource::CpuTexture,
936) -> Result<(TextureId, TextureViewId), RenderError> {
937 use khora_core::math::Origin3D;
938
939 let texture = device.create_texture(&TextureDescriptor {
940 label: Some(Cow::Borrowed("IBL Equirect Source")),
941 size: cpu.size,
942 mip_level_count: 1,
943 sample_count: SampleCount::X1,
944 dimension: TextureDimension::D2,
945 format: cpu.format,
946 usage: TextureUsage::TEXTURE_BINDING | TextureUsage::COPY_DST,
947 view_formats: Cow::Borrowed(&[]),
948 })?;
949 device.write_texture(
950 texture,
951 &cpu.pixels,
952 Some(cpu.format.bytes_per_pixel() * cpu.size.width),
953 Origin3D::default(),
954 cpu.size,
955 )?;
956 let view = device.create_texture_view(
957 texture,
958 &TextureViewDescriptor {
959 label: Some(Cow::Borrowed("IBL Equirect Source View")),
960 format: Some(cpu.format),
961 dimension: Some(TextureViewDimension::D2),
962 aspect: ImageAspect::All,
963 base_mip_level: 0,
964 mip_level_count: Some(1),
965 base_array_layer: 0,
966 array_layer_count: Some(1),
967 },
968 )?;
969 Ok((texture, view))
970}
971
972fn irradiance_pipeline_spec() -> PipelineSpec {
974 bake_pipeline_spec(
975 "IBL Irradiance Bake",
976 IRRADIANCE_SHADER,
977 vec![LayoutSpec::Inline {
978 label: IRRADIANCE_LAYOUT,
979 entries: Cow::Owned(irradiance_layout_entries()),
980 }],
981 )
982}
983
984fn bake_pipeline_spec(
987 label: &'static str,
988 shader: &'static str,
989 bind_group_layouts: Vec<LayoutSpec>,
990) -> PipelineSpec {
991 PipelineSpec {
992 label,
993 shader,
994 variant: ShaderVariantKey::empty(),
995 bind_group_layouts,
996 vertex_buffers: vec![],
997 vs_entry: "vs_main",
998 fs_entry: Some("fs_main"),
999 primitive: PrimitiveStateDescriptor::default(),
1000 depth_stencil: None,
1001 color_targets: vec![ColorTargetStateDescriptor {
1002 format: IBL_FORMAT,
1003 blend: None,
1004 write_mask: ColorWrites::ALL,
1005 }],
1006 multisample: MultisampleStateDescriptor {
1007 count: SampleCount::X1,
1008 mask: !0,
1009 alpha_to_coverage_enabled: false,
1010 },
1011 }
1012}
1013
1014#[cfg(test)]
1015mod tests {
1016 use super::*;
1017
1018 #[test]
1019 fn face_bases_cover_six_faces_with_unit_axes() {
1020 assert_eq!(FACE_BASES.len(), 6);
1021 for basis in &FACE_BASES {
1022 let f = basis.forward;
1023 let mag = f[0] * f[0] + f[1] * f[1] + f[2] * f[2];
1024 assert!((mag - 1.0).abs() < 1e-6, "forward must be a unit axis");
1025 }
1026 }
1027
1028 #[test]
1029 fn unbaked_baker_has_no_bindings() {
1030 let baker = IblBaker::new();
1031 assert!(baker.bindings().is_none());
1032 assert!(!baker.is_baked());
1033 }
1034
1035 #[test]
1036 fn environment_wait_is_bounded() {
1037 let baker = IblBaker::new();
1040 for _ in 0..MAX_ENV_WAIT_TICKS {
1041 assert!(baker.wait_for_environment(), "should still be waiting");
1042 }
1043 assert!(
1044 !baker.wait_for_environment(),
1045 "must give up and fall back to the procedural sky"
1046 );
1047 }
1048
1049 #[test]
1050 fn environment_map_defaults_to_procedural() {
1051 assert!(EnvironmentMap::default().texture.is_none());
1052 let uuid = AssetUUID::new_v5("test/env.hdr");
1053 assert_eq!(EnvironmentMap::from_asset(uuid).texture, Some(uuid));
1054 }
1055
1056 #[test]
1057 fn equirect_layout_declares_2d_source_sampler_uniform() {
1058 let entries = equirect_layout_entries();
1061 assert_eq!(entries.len(), 3);
1062 assert!(matches!(
1063 entries[0].ty,
1064 BindingType::Texture {
1065 view_dimension: TextureViewDimension::D2,
1066 ..
1067 }
1068 ));
1069 }
1070
1071 #[test]
1072 fn irradiance_layout_has_cube_sampler_uniform() {
1073 let entries = irradiance_layout_entries();
1074 assert_eq!(entries.len(), 3);
1075 assert_eq!(entries[0].binding, 0);
1076 assert_eq!(entries[1].binding, 1);
1077 assert_eq!(entries[2].binding, 2);
1078 }
1079}