Skip to main content

khora_lanes/render_lane/
skybox_lane.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//! Skybox lane — draws the IBL environment cube as the scene background.
16//!
17//! Renders `khora::pipelines::skybox` (a fullscreen triangle pinned to the far
18//! plane) into the main color target, **after** the scene pass. It is
19//! depth-tested (`LessEqual`) against the scene's depth buffer with depth-write
20//! disabled, so it only paints the pixels the geometry left at the far plane —
21//! the sky shows through the background, the meshes are untouched. The visible
22//! sky is therefore exactly the environment the lit surfaces reflect.
23//!
24//! Registered under [`SkyboxAgent`](khora_agents::skybox_agent), which runs in
25//! the OUTPUT phase after `RenderAgent` and buffers this lane's pass into the
26//! FrameGraph (`writes(Color).reads(Depth)`), mirroring how `OverlayAgent`
27//! contributes the grid/gizmo overlays. `LoadOp::Load` preserves what the scene
28//! drew.
29//!
30//! Per CLAD this struct holds only persistent state; init / render bodies are
31//! private free functions in this module.
32
33use khora_core::lane::{Lane, LaneContext, LaneError, LaneKind, Ref, Slot};
34use khora_core::renderer::api::command::{BindGroupId, BindGroupLayoutId};
35use khora_core::renderer::api::ibl::IblGpuBindings;
36use khora_core::renderer::api::pipeline::RenderPipelineId;
37use khora_core::renderer::api::resource::BufferId;
38use khora_core::renderer::traits::CommandEncoder;
39use khora_data::render::RenderWorld;
40use std::sync::{Arc, OnceLock};
41
42/// Stable cache label for the group-0 (inverse-VP + camera) layout.
43const SKY_UNIFORM_LAYOUT_LABEL: &str = "skybox_uniform_layout";
44/// Stable cache label for the group-1 (env cube + sampler) layout.
45const SKY_ENV_LAYOUT_LABEL: &str = "skybox_env_layout";
46
47/// Skybox uniform block — matches `SkyUniforms` in `skybox.wgsl`. The inverse
48/// view-projection lets the fragment recover each pixel's world-space view ray;
49/// `camera_pos` is the ray origin.
50#[repr(C, align(16))]
51#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
52struct SkyUniforms {
53    inv_view_proj: [[f32; 4]; 4],
54    camera_pos: [f32; 4],
55}
56
57/// Environment-background lane.
58///
59/// Holds the pipeline, the two bespoke layouts, the per-frame uniform buffer +
60/// its bind group (group 0), and — created lazily once the IBL bake is ready —
61/// the static env-cube bind group (group 1). The env cube is baked once and
62/// never changes, so its bind group is built a single time and cached.
63#[derive(Debug, Default)]
64pub struct SkyboxLane {
65    pipeline: OnceLock<RenderPipelineId>,
66    sky_layout: OnceLock<BindGroupLayoutId>,
67    env_layout: OnceLock<BindGroupLayoutId>,
68    sky_buffer: OnceLock<BufferId>,
69    sky_bind_group: OnceLock<BindGroupId>,
70    env_bind_group: OnceLock<BindGroupId>,
71}
72
73// ─── Free functions (CLAD: no inherent methods on the lane struct) ───
74
75fn init_gpu_resources(
76    lane: &SkyboxLane,
77    device: &dyn khora_core::renderer::GraphicsDevice,
78    pipeline_system: &dyn khora_core::renderer::traits::PipelineSystem,
79) -> Result<(), khora_core::renderer::error::RenderError> {
80    use khora_core::renderer::api::command::{
81        BindGroupDescriptor, BindGroupEntry, BindingResource, BufferBinding,
82    };
83    use khora_core::renderer::api::resource::{BufferDescriptor, BufferUsage};
84    use std::borrow::Cow;
85
86    let sky_layout = pipeline_system.inline_layout(
87        device,
88        SKY_UNIFORM_LAYOUT_LABEL,
89        &sky_uniform_layout_entries(),
90    )?;
91    let env_layout =
92        pipeline_system.inline_layout(device, SKY_ENV_LAYOUT_LABEL, &env_layout_entries())?;
93
94    let sky_buffer = device
95        .create_buffer(&BufferDescriptor {
96            label: Some(Cow::Borrowed("skybox_uniform_ubo")),
97            size: std::mem::size_of::<SkyUniforms>() as u64,
98            usage: BufferUsage::UNIFORM | BufferUsage::COPY_DST,
99            mapped_at_creation: false,
100        })
101        .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
102
103    let sky_bind_group = device
104        .create_bind_group(&BindGroupDescriptor {
105            label: Some("skybox_uniform_bg"),
106            layout: sky_layout,
107            entries: &[BindGroupEntry {
108                binding: 0,
109                resource: BindingResource::Buffer(BufferBinding {
110                    buffer: sky_buffer,
111                    offset: 0,
112                    size: None,
113                }),
114                _phantom: std::marker::PhantomData,
115            }],
116        })
117        .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
118
119    let pipeline_id = pipeline_system.pipeline(device, &skybox_pipeline_spec(device))?;
120
121    let _ = lane.sky_layout.set(sky_layout);
122    let _ = lane.env_layout.set(env_layout);
123    let _ = lane.sky_buffer.set(sky_buffer);
124    let _ = lane.sky_bind_group.set(sky_bind_group);
125    let _ = lane.pipeline.set(pipeline_id);
126    Ok(())
127}
128
129/// Group-0 layout: a single uniform buffer (fragment-only — the vertex shader
130/// derives clip positions from `vertex_index`).
131fn sky_uniform_layout_entries() -> Vec<khora_core::renderer::api::command::BindGroupLayoutEntry> {
132    use khora_core::renderer::api::command::{
133        BindGroupLayoutEntry, BindingType, BufferBindingType,
134    };
135    use khora_core::renderer::api::util::ShaderStageFlags;
136    vec![BindGroupLayoutEntry {
137        binding: 0,
138        visibility: ShaderStageFlags::FRAGMENT,
139        ty: BindingType::Buffer {
140            ty: BufferBindingType::Uniform,
141            has_dynamic_offset: false,
142            min_binding_size: None,
143        },
144    }]
145}
146
147/// Group-1 layout: environment cube at 0, filtering sampler at 1.
148fn env_layout_entries() -> Vec<khora_core::renderer::api::command::BindGroupLayoutEntry> {
149    use khora_core::renderer::api::command::{
150        BindGroupLayoutEntry, BindingType, SamplerBindingType, TextureSampleType,
151        TextureViewDimension,
152    };
153    use khora_core::renderer::api::util::ShaderStageFlags;
154    vec![
155        BindGroupLayoutEntry {
156            binding: 0,
157            visibility: ShaderStageFlags::FRAGMENT,
158            ty: BindingType::Texture {
159                sample_type: TextureSampleType::Float { filterable: true },
160                view_dimension: TextureViewDimension::Cube,
161                multisampled: false,
162            },
163        },
164        BindGroupLayoutEntry {
165            binding: 1,
166            visibility: ShaderStageFlags::FRAGMENT,
167            ty: BindingType::Sampler(SamplerBindingType::Filtering),
168        },
169    ]
170}
171
172/// The declarative pipeline spec for the skybox — fullscreen triangle, no
173/// vertex buffer, depth-tested `LessEqual` with **no depth write** so it sits
174/// behind every mesh.
175fn skybox_pipeline_spec(
176    device: &dyn khora_core::renderer::GraphicsDevice,
177) -> khora_core::renderer::api::pipeline::PipelineSpec {
178    use khora_core::renderer::api::pipeline::enums::{CompareFunction, PrimitiveTopology};
179    use khora_core::renderer::api::pipeline::state::{
180        ColorWrites, DepthBiasState, StencilFaceState,
181    };
182    use khora_core::renderer::api::pipeline::{
183        ColorTargetStateDescriptor, DepthStencilStateDescriptor, LayoutSpec,
184        MultisampleStateDescriptor, PipelineSpec, PrimitiveStateDescriptor, ShaderVariantKey,
185    };
186    use khora_core::renderer::api::util::{SampleCount, TextureFormat};
187    use std::borrow::Cow;
188
189    PipelineSpec {
190        label: "Skybox Pipeline",
191        shader: "khora::pipelines::skybox",
192        variant: ShaderVariantKey::empty(),
193        bind_group_layouts: vec![
194            LayoutSpec::Inline {
195                label: SKY_UNIFORM_LAYOUT_LABEL,
196                entries: Cow::Owned(sky_uniform_layout_entries()),
197            },
198            LayoutSpec::Inline {
199                label: SKY_ENV_LAYOUT_LABEL,
200                entries: Cow::Owned(env_layout_entries()),
201            },
202        ],
203        vertex_buffers: vec![],
204        vs_entry: "vs_main",
205        fs_entry: Some("fs_main"),
206        primitive: PrimitiveStateDescriptor {
207            topology: PrimitiveTopology::TriangleList,
208            ..Default::default()
209        },
210        // Depth-tested against the scene buffer (LessEqual); the sky is emitted
211        // at the far plane (clip z = 1) and writes NO depth, so it fills only
212        // the background pixels the geometry left untouched.
213        depth_stencil: Some(DepthStencilStateDescriptor {
214            format: TextureFormat::Depth32Float,
215            depth_write_enabled: false,
216            depth_compare: CompareFunction::LessEqual,
217            stencil_front: StencilFaceState::default(),
218            stencil_back: StencilFaceState::default(),
219            stencil_read_mask: 0,
220            stencil_write_mask: 0,
221            bias: DepthBiasState::default(),
222        }),
223        color_targets: vec![ColorTargetStateDescriptor {
224            format: device
225                .get_surface_format()
226                .unwrap_or(TextureFormat::Rgba8UnormSrgb),
227            blend: None,
228            write_mask: ColorWrites::ALL,
229        }],
230        multisample: MultisampleStateDescriptor {
231            count: SampleCount::X1,
232            mask: !0,
233            alpha_to_coverage_enabled: false,
234        },
235    }
236}
237
238/// Returns the cached env-cube bind group, building it on first use once the
239/// IBL bake has published its bindings. `None` only if the bind group could not
240/// be created.
241fn env_bind_group(
242    lane: &SkyboxLane,
243    device: &dyn khora_core::renderer::GraphicsDevice,
244    ibl: &IblGpuBindings,
245) -> Option<BindGroupId> {
246    if let Some(bg) = lane.env_bind_group.get().copied() {
247        return Some(bg);
248    }
249    use khora_core::renderer::api::command::{
250        BindGroupDescriptor, BindGroupEntry, BindingResource,
251    };
252    let layout = lane.env_layout.get().copied()?;
253    let bg = device
254        .create_bind_group(&BindGroupDescriptor {
255            label: Some("skybox_env_bg"),
256            layout,
257            entries: &[
258                BindGroupEntry {
259                    binding: 0,
260                    resource: BindingResource::TextureView(ibl.env_cube),
261                    _phantom: std::marker::PhantomData,
262                },
263                BindGroupEntry {
264                    binding: 1,
265                    resource: BindingResource::Sampler(ibl.sampler),
266                    _phantom: std::marker::PhantomData,
267                },
268            ],
269        })
270        .map_err(|e| log::error!("SkyboxLane: env bind group creation failed: {e:?}"))
271        .ok()?;
272    let _ = lane.env_bind_group.set(bg);
273    Some(bg)
274}
275
276#[allow(clippy::too_many_arguments)]
277fn render_skybox(
278    lane: &SkyboxLane,
279    device: &dyn khora_core::renderer::GraphicsDevice,
280    encoder: &mut dyn CommandEncoder,
281    color_target: khora_core::renderer::api::resource::TextureViewId,
282    depth_target: khora_core::renderer::api::resource::TextureViewId,
283    view: &khora_data::render::ExtractedView,
284    ibl: &IblGpuBindings,
285) {
286    use khora_core::renderer::api::command::{
287        LoadOp, Operations, RenderPassColorAttachment, RenderPassDepthStencilAttachment,
288        RenderPassDescriptor, StoreOp,
289    };
290
291    let (Some(pipeline), Some(sky_buffer), Some(sky_bg)) = (
292        lane.pipeline.get().copied(),
293        lane.sky_buffer.get().copied(),
294        lane.sky_bind_group.get().copied(),
295    ) else {
296        log::warn!("SkyboxLane: GPU resources not initialized, skipping");
297        return;
298    };
299    let Some(env_bg) = env_bind_group(lane, device, ibl) else {
300        return;
301    };
302
303    // The fragment reconstructs its world ray from the inverse view-projection;
304    // no inverse ⇒ a degenerate camera, skip this frame.
305    let Some(inv_view_proj) = view.view_proj.inverse() else {
306        return;
307    };
308    let uniforms = SkyUniforms {
309        inv_view_proj: inv_view_proj.to_cols_array_2d(),
310        camera_pos: [view.position.x, view.position.y, view.position.z, 1.0],
311    };
312    if let Err(e) = device.write_buffer(sky_buffer, 0, bytemuck::bytes_of(&uniforms)) {
313        log::error!("SkyboxLane: uniform buffer write failed: {:?}", e);
314        return;
315    }
316
317    // Background pass — `LoadOp::Load` preserves the scene's color + depth; the
318    // depth buffer is loaded (not cleared) so the LessEqual test rejects pixels
319    // the geometry already owns. Depth is not written.
320    let color_attachment = RenderPassColorAttachment {
321        view: &color_target,
322        resolve_target: None,
323        ops: Operations {
324            load: LoadOp::Load,
325            store: StoreOp::Store,
326        },
327        base_array_layer: 0,
328        base_mip_level: 0,
329    };
330    let pass_desc = RenderPassDescriptor {
331        label: Some("Skybox Pass"),
332        color_attachments: &[color_attachment],
333        depth_stencil_attachment: Some(RenderPassDepthStencilAttachment {
334            view: &depth_target,
335            depth_ops: Some(Operations {
336                load: LoadOp::Load,
337                store: StoreOp::Store,
338            }),
339            stencil_ops: None,
340            base_array_layer: 0,
341        }),
342    };
343
344    let mut pass = encoder.begin_render_pass(&pass_desc);
345    pass.set_pipeline(&pipeline);
346    pass.set_bind_group(0, &sky_bg, &[]);
347    pass.set_bind_group(1, &env_bg, &[]);
348    // Fullscreen triangle — the vertex shader derives positions from
349    // `vertex_index` (no vertex buffer bound).
350    pass.draw(0..3, 0..1);
351}
352
353impl Lane for SkyboxLane {
354    fn strategy_name(&self) -> &'static str {
355        "Skybox"
356    }
357
358    fn lane_kind(&self) -> LaneKind {
359        LaneKind::Render
360    }
361
362    fn on_initialize(&self, ctx: &mut LaneContext) -> Result<(), LaneError> {
363        let device = ctx
364            .get::<Arc<dyn khora_core::renderer::GraphicsDevice>>()
365            .ok_or(LaneError::missing("Arc<dyn GraphicsDevice>"))?
366            .clone();
367        let pipeline_system = ctx
368            .get::<Arc<dyn khora_core::renderer::traits::PipelineSystem>>()
369            .ok_or(LaneError::missing("Arc<dyn PipelineSystem>"))?
370            .clone();
371        init_gpu_resources(self, device.as_ref(), pipeline_system.as_ref())
372            .map_err(|e| LaneError::InitializationFailed(Box::new(e)))
373    }
374
375    fn execute(&self, ctx: &mut LaneContext) -> Result<(), LaneError> {
376        // The environment cube is published by the IBL bake once it has run.
377        // Absent ⇒ nothing to draw yet (the scene's clear color shows through).
378        let Some(ibl) = ctx.get::<IblGpuBindings>().copied() else {
379            return Ok(());
380        };
381
382        // Camera comes from the primary extracted view (the editor viewport
383        // override is folded into `RenderWorld.views` by `RenderFlow`).
384        let Some(render_world) = ctx.get::<Ref<RenderWorld>>() else {
385            return Ok(());
386        };
387        let render_world = render_world.get();
388        let Some(view) = render_world.views.first() else {
389            return Ok(());
390        };
391        let view = view.clone();
392
393        let device = ctx
394            .get::<Arc<dyn khora_core::renderer::GraphicsDevice>>()
395            .ok_or(LaneError::missing("Arc<dyn GraphicsDevice>"))?
396            .clone();
397        let encoder = ctx
398            .get::<Slot<dyn CommandEncoder>>()
399            .ok_or(LaneError::missing("Slot<dyn CommandEncoder>"))?
400            .get();
401        let color_target = ctx
402            .get::<khora_core::lane::ColorTarget>()
403            .ok_or(LaneError::missing("ColorTarget"))?
404            .0;
405        // The skybox depth-tests against the scene buffer — no depth target ⇒
406        // skip (it would otherwise overwrite the geometry).
407        let Some(depth_target) = ctx.get::<khora_core::lane::DepthTarget>().map(|d| d.0) else {
408            return Ok(());
409        };
410
411        render_skybox(
412            self,
413            device.as_ref(),
414            encoder,
415            color_target,
416            depth_target,
417            &view,
418            &ibl,
419        );
420        Ok(())
421    }
422
423    fn as_any(&self) -> &dyn std::any::Any {
424        self
425    }
426
427    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
428        self
429    }
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435
436    #[test]
437    fn skybox_lane_strategy_name() {
438        let lane = SkyboxLane::default();
439        assert_eq!(lane.strategy_name(), "Skybox");
440        assert_eq!(lane.lane_kind(), LaneKind::Render);
441    }
442
443    #[test]
444    fn sky_uniforms_are_std140_sized() {
445        // mat4 (64) + vec4 (16) = 80 bytes, 16-aligned.
446        assert_eq!(std::mem::size_of::<SkyUniforms>(), 80);
447        assert_eq!(std::mem::align_of::<SkyUniforms>(), 16);
448    }
449}