Skip to main content

khora_data/gpu/
ibl.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Image-based lighting (IBL) bake — the data-layer "environment → GPU"
16//! projection.
17//!
18//! [`IblBaker`] runs **once** at startup (driven by the `ibl_bake` DataSystem)
19//! and produces the static GPU resources image-based lighting samples:
20//! - an environment cubemap (a procedural sky for now, an authored HDR later),
21//! - a diffuse irradiance cube (cosine-convolved environment),
22//! - and — added in a later increment — a prefiltered specular cube + the
23//!   split-sum BRDF LUT (both stand-ins for now).
24//!
25//! Because the bake is one-time and needs render passes, it records into a
26//! **standalone** command encoder ([`GraphicsDevice::create_command_encoder`])
27//! and submits it directly, outside the per-frame loop — no lane or agent
28//! required. The resulting [`IblGpuBindings`] is stored behind interior
29//! mutability (the baker is a shared resource) and consumed by the lit lanes
30//! at group 3 via [`IblBaker::bindings`].
31
32use 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
58/// Env-cube face resolution. 256² is ample for a smooth procedural sky and as
59/// the source the irradiance convolution / specular prefilter sample.
60const ENV_FACE_SIZE: u32 = 256;
61/// Irradiance cube face resolution. Irradiance is very low-frequency, so a
62/// tiny cube captures it (sampled with linear filtering).
63const IRRADIANCE_FACE_SIZE: u32 = 32;
64/// Linear HDR format for every IBL cube (sky may exceed 1.0 once authored).
65const IBL_FORMAT: TextureFormat = TextureFormat::Rgba16Float;
66
67/// Prefiltered specular cube face resolution (mip 0). Higher-roughness mips
68/// are progressively smaller.
69const PREFILTER_FACE_SIZE: u32 = 128;
70/// Number of prefiltered roughness levels (mips). Roughness = mip / (mips-1).
71const PREFILTER_MIPS: u32 = 5;
72/// Split-sum BRDF integration LUT resolution.
73const 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/// Per-face basis uploaded to the bake shaders: `forward` / `right` / `up` in
86/// world space (w unused). The fragment reconstructs a texel's world direction
87/// as `normalize(forward + ndc.x*right + ndc.y*up)`.
88#[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    /// xyz = unit direction **toward** the sun in world space (w unused). Read
95    /// only by the sky bake, which draws the sun disk there so the procedural
96    /// sky agrees with the scene's directional light. The convolution and
97    /// prefilter shaders declare only the first three fields and ignore it (a
98    /// uniform buffer larger than the shader's struct is valid).
99    sun: [f32; 4],
100}
101
102/// Direction **toward** the sun used when the scene has no directional light —
103/// a high afternoon sun, so the default environment still reads as a sky.
104const DEFAULT_SUN_DIRECTION: Vec3 = Vec3::new(0.35, 0.78, 0.52);
105
106/// The six cube faces in wgpu layer order (+X, -X, +Y, -Y, +Z, -Z), each as
107/// `[forward, right, up]`. Chosen so `normalize(forward + ndc.x*right +
108/// ndc.y*up)` reproduces the direction wgpu's cube sampling maps to that
109/// texel, keeping the baked cubes correctly oriented for later sampling.
110/// `sun` is a placeholder here — the bake stamps the real direction into each
111/// copy before upload.
112const 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    }, // +X
119    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    }, // -X
125    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    }, // +Y
131    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    }, // -Y
137    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    }, // +Z
143    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    }, // -Z
149];
150
151/// A color cubemap plus the views the bake needs: one `Cube` view for sampling
152/// and one `D2` render-target view per face.
153struct Cube {
154    texture: TextureId,
155    cube_view: TextureViewId,
156    face_views: Vec<TextureViewId>,
157}
158
159/// The baked IBL resources. The `_keep_*` vectors retain GPU handles for
160/// lifetime (the bake submission reads them asynchronously, and the cubes are
161/// sampled every frame); they are freed together on shutdown (future).
162struct IblResources {
163    bindings: IblGpuBindings,
164    _keep_textures: Vec<TextureId>,
165    _keep_views: Vec<TextureViewId>,
166    _keep_buffers: Vec<BufferId>,
167}
168
169/// Selects the scene's environment source for the IBL bake.
170///
171/// Registered as a shared resource by the host application. When it names an
172/// equirectangular texture asset (an HDR `.hdr`/`.exr` keeps the dynamic range
173/// that makes reflections read well), the bake projects it onto the environment
174/// cube; absent — or naming an asset that has not been loaded — the procedural
175/// sky is baked instead, so a scene without an authored environment still
176/// lights correctly.
177///
178/// The bake is one-time, so the selection is read on the first tick.
179#[derive(Debug, Default, Clone)]
180pub struct EnvironmentMap {
181    /// Equirectangular environment texture, as a loaded `CpuTexture` asset.
182    pub texture: Option<AssetUUID>,
183}
184
185impl EnvironmentMap {
186    /// Points the environment at an equirectangular texture asset.
187    pub fn from_asset(texture: AssetUUID) -> Self {
188        Self {
189            texture: Some(texture),
190        }
191    }
192}
193
194/// One-time IBL bake service. Registered as a shared resource at bootstrap and
195/// driven by the `ibl_bake` DataSystem, which calls [`ensure_baked`] every
196/// frame; the bake itself runs only on the first call.
197///
198/// [`ensure_baked`]: IblBaker::ensure_baked
199#[derive(Default)]
200pub struct IblBaker {
201    res: OnceLock<IblResources>,
202    env_wait: std::sync::atomic::AtomicU32,
203}
204
205/// How many ticks the bake waits for a selected environment asset to finish
206/// loading before falling back to the procedural sky.
207///
208/// The lit lanes skip rendering entirely until the IBL bindings exist, so
209/// waiting forever on an asset that never arrives (a mistyped UUID, a missing
210/// file) would leave the screen black. This bounds the wait and logs loudly.
211const MAX_ENV_WAIT_TICKS: u32 = 120;
212
213impl IblBaker {
214    /// Creates an unbaked baker. The bake happens lazily on the first
215    /// [`ensure_baked`](Self::ensure_baked) once a device is available.
216    pub fn new() -> Self {
217        Self::default()
218    }
219
220    /// Whether the one-time bake has already run.
221    pub fn is_baked(&self) -> bool {
222        self.res.get().is_some()
223    }
224
225    /// Records one tick spent waiting for the scene's environment asset to
226    /// load. Returns `true` while the caller should keep waiting, and `false`
227    /// once the budget is spent and it must bake the procedural sky instead.
228    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    /// Bakes the IBL resources on the first call; a no-op afterwards.
236    /// Idempotent and safe to call every frame.
237    ///
238    /// `sun_direction` points **toward** the sun in world space — the scene's
239    /// directional light, so the procedural sky's sun disk agrees with the
240    /// light that casts the shadows. A zero/degenerate vector falls back to
241    /// [`DEFAULT_SUN_DIRECTION`]. The bake is one-time, so this captures the
242    /// light as it stands on the first tick.
243    ///
244    /// `env_source` is an authored equirectangular environment map (see
245    /// [`EnvironmentMap`]); `None` bakes the procedural sky instead. Both fill
246    /// the same env cube, so the rest of the chain is unaffected.
247    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    /// Returns the group-3 IBL bindings once baked, else `None` (lit lanes skip
275    /// the IBL term until it is ready).
276    pub fn bindings(&self) -> Option<IblGpuBindings> {
277        self.res.get().map(|r| r.bindings)
278    }
279}
280
281/// Normalizes `dir`, falling back to [`DEFAULT_SUN_DIRECTION`] when it is
282/// degenerate (no directional light in the scene, or a zero vector).
283fn 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
292/// Creates a color cubemap (6 layers) with a `Cube` sampling view and one `D2`
293/// render-target view per face.
294fn 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
349/// A single uniform-buffer bind-group layout entry (the per-face basis).
350fn 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
362/// Uploads the six per-face basis uniform buffers, stamping the world-space
363/// direction toward the sun into each (used only by the sky bake).
364fn 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
385/// The filtering sampler shared by the IBL cubes + LUT: trilinear, edge-clamped
386/// (no cube-face seams), LOD range wide enough for the future prefiltered mip
387/// chain.
388fn 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
407/// Creates a mipmapped color cubemap (6 layers, `mips` levels) with a `Cube`
408/// sampling view spanning all mips. Per-(mip, face) render targets are not
409/// pre-created: the backend recreates the target from the attachment's
410/// `base_array_layer` + `base_mip_level`, so one view (for its source texture)
411/// suffices.
412fn 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
448/// Creates the split-sum BRDF LUT texture (2D, HDR, render-target + sampled)
449/// and a view; the contents are produced by the BRDF integration pass.
450fn 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
481/// Uploads the six per-face basis buffers for a given roughness (packed in
482/// `forward.w`, read by the prefilter shader; ignored by sky/irradiance).
483fn 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
506/// Runs the full one-time bake: env cube → diffuse irradiance cube → prefiltered
507/// specular cube + BRDF LUT, plus the shared sampler.
508///
509/// The env cube is filled either by projecting an authored equirectangular map
510/// (`env_source`) or by the procedural sky. Everything downstream reads the
511/// cube and is identical in both cases.
512fn 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    // Pipelines + inline layouts.
526    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    // Environment source — an authored equirectangular map when the scene
538    // supplies one, else the procedural sky. Both paths write the same six env
539    // cube faces with the same per-face basis uniforms.
540    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    // Irradiance bind groups (env cube + sampler + uniform), one per face.
586    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    // Prefilter bind groups — one per (mip, face); roughness rises with the mip.
597    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        // Roughness spans [0, 1] across the mip chain (PREFILTER_MIPS >= 2).
601        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    // Submission 1: sky → env cube. The convolution + prefilter SAMPLE the env
616    // cube, so it must fully complete first; cross-submission ordering on the
617    // queue guarantees that (a single encoder would leave the write→read hazard
618    // unsynchronised — nothing else in the engine writes then reads a texture
619    // within one encoder).
620    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    // Submission 2: irradiance + prefiltered specular (both read env) + the
634    // environment-independent BRDF LUT.
635    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    // Prefilter: one pass per (mip, face), each writing that mip's roughness.
644    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    // BRDF LUT: a single environment-independent integration pass (no bindings).
668    {
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    // The equirect source is only read during the bake, but it must outlive the
715    // submission that samples it.
716    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
732/// A uniform-buffer bind-group entry.
733fn 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
745/// Records one fullscreen-triangle pass per cube face into `face_views`,
746/// clearing then drawing with `pipeline` + the matching per-face bind group.
747fn 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            // The wgpu backend recreates the render target from the source
763            // texture + this layer index (it ignores the view's own layer), so
764            // this MUST be the real face index — else every face renders into
765            // layer 0 and the other five stay black.
766            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
780/// A bind group of (source texture @0, sampler @1, per-face basis uniform @2).
781///
782/// Shared by every bake pass that reads a texture per face: the irradiance
783/// convolution and the specular prefilter (which bind the env **cube**), and
784/// the equirectangular projection (which binds a **2D** lat-long map). Only the
785/// layout's declared view dimension differs; the entry shape is identical.
786fn 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
814/// The declarative spec for the procedural-sky bake pipeline.
815fn 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
826/// The declarative spec for the specular prefilter pipeline (same bindings as
827/// the irradiance convolution: env cube + sampler + per-face/roughness basis).
828fn 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
839/// The declarative spec for the split-sum BRDF LUT pipeline. No bindings — the
840/// integration is pure math over the fragment's (N·V, roughness).
841fn brdf_pipeline_spec() -> PipelineSpec {
842    let mut spec = bake_pipeline_spec("IBL BRDF LUT Bake", BRDF_SHADER, vec![]);
843    // The LUT stores (scale, bias) in RG; the shared IBL_FORMAT (Rgba16Float)
844    // carries them fine.
845    spec.label = "IBL BRDF LUT Bake";
846    spec
847}
848
849/// The bind-group layout entries for the irradiance convolution: the env cube
850/// at 0, the sampler at 1, the per-face basis at 2.
851fn 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
871/// The bind-group layout entries for the equirectangular projection: the source
872/// lat-long map at 0 (a **2D** texture, unlike the cube the convolution reads),
873/// its sampler at 1, and the per-face basis at 2.
874fn 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
894/// The declarative spec for the equirectangular → cube projection pipeline.
895fn 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
906/// Sampler for the equirectangular source: longitude **wraps** (the map is
907/// seamless in u), latitude clamps at the poles. Linear filtering, mip 0 only.
908fn 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
927/// Uploads a decoded equirectangular environment map and returns its texture +
928/// sampleable view.
929///
930/// The source keeps the layout the decoder produced (`Rgba16Float` for an HDR
931/// `.hdr`/`.exr`, 8-bit for an LDR image), and the row stride follows that
932/// format — an HDR row is twice as wide as an 8-bit one.
933fn 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
972/// The declarative spec for the irradiance convolution pipeline.
973fn 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
984/// Shared shape for the bake pipelines: a fullscreen triangle (no vertex
985/// buffer, no depth) writing linear HDR into one target (cube face or 2D LUT).
986fn 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        // A selected-but-never-loaded environment must not stall the bake
1038        // forever: the lit lanes render nothing until the bindings exist.
1039        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        // The equirect source is a 2D lat-long map, unlike the cube the
1059        // convolution and prefilter read at the same binding index.
1060        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}