Skip to main content

khora_lanes/render_lane/
grid_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//! Grid overlay lane — the infinite editor ground grid.
16//!
17//! Renders `khora::pipelines::grid` (a fullscreen-triangle infinite
18//! grid) on top of the main render target, depth-tested against the
19//! scene so closer geometry occludes it. Registered under
20//! [`OverlayAgent`](khora_agents::overlay_agent), it runs in the OUTPUT
21//! phase **after** the scene pass — `LoadOp::Load` preserves what the
22//! scene drew.
23//!
24//! The grid is debug / editor viz: a host application opts in by
25//! enabling the shared [`GridConfig`](khora_data::render::GridConfig)
26//! runtime resource. The sandbox leaves it disabled — no grid.
27//!
28//! Per CLAD this struct holds only persistent state; init / render
29//! bodies are private free functions in this module.
30
31use khora_core::lane::{Lane, LaneContext, LaneError, LaneKind, Ref, Slot};
32use khora_core::renderer::api::command::{BindGroupId, BindGroupLayoutId};
33use khora_core::renderer::api::pipeline::RenderPipelineId;
34use khora_core::renderer::api::resource::BufferId;
35use khora_core::renderer::traits::CommandEncoder;
36use khora_data::render::{GridConfig, RenderWorld};
37use std::sync::{Arc, Mutex, OnceLock};
38
39/// Shared editor-grid config — the host app enables it, `GridLane` reads it.
40pub type SharedGridConfig = Arc<Mutex<GridConfig>>;
41
42/// Infinite editor ground-grid overlay lane.
43#[derive(Debug, Default)]
44pub struct GridLane {
45    pipeline: OnceLock<RenderPipelineId>,
46    camera_layout: OnceLock<BindGroupLayoutId>,
47    camera_buffer: OnceLock<BufferId>,
48    camera_bind_group: OnceLock<BindGroupId>,
49}
50
51// ─── Free functions (CLAD: no inherent methods on the lane struct) ───
52
53fn init_gpu_resources(
54    lane: &GridLane,
55    device: &dyn khora_core::renderer::GraphicsDevice,
56    pipeline_system: &dyn khora_core::renderer::traits::PipelineSystem,
57) -> Result<(), khora_core::renderer::error::RenderError> {
58    use khora_core::renderer::api::{
59        command::{BindGroupDescriptor, BindGroupEntry, BindingResource, BufferBinding},
60        resource::{BufferDescriptor, BufferUsage, CameraUniformData},
61    };
62    use std::borrow::Cow;
63
64    // Group 0 — camera UBO (mat4 view-proj + vec4 position = 80 B). Bespoke
65    // layout resolved + cached by the PipelineSystem so the pipeline and the
66    // lane's bind group share one layout id.
67    let camera_layout = pipeline_system.inline_layout(
68        device,
69        GRID_CAMERA_LAYOUT_LABEL,
70        &grid_camera_layout_entries(),
71    )?;
72
73    let camera_buffer = device
74        .create_buffer(&BufferDescriptor {
75            label: Some(Cow::Borrowed("grid_camera_ubo")),
76            size: std::mem::size_of::<CameraUniformData>() as u64,
77            usage: BufferUsage::UNIFORM | BufferUsage::COPY_DST,
78            mapped_at_creation: false,
79        })
80        .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
81
82    let camera_bind_group = device
83        .create_bind_group(&BindGroupDescriptor {
84            label: Some("grid_camera_bg"),
85            layout: camera_layout,
86            entries: &[BindGroupEntry {
87                binding: 0,
88                resource: BindingResource::Buffer(BufferBinding {
89                    buffer: camera_buffer,
90                    offset: 0,
91                    size: None,
92                }),
93                _phantom: std::marker::PhantomData,
94            }],
95        })
96        .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
97
98    // Pipeline — compiled + cached by the backend from `khora::pipelines::grid`.
99    let pipeline_id = pipeline_system.pipeline(device, &grid_pipeline_spec(device))?;
100
101    let _ = lane.camera_layout.set(camera_layout);
102    let _ = lane.camera_buffer.set(camera_buffer);
103    let _ = lane.camera_bind_group.set(camera_bind_group);
104    let _ = lane.pipeline.set(pipeline_id);
105    Ok(())
106}
107
108/// Stable cache label for the grid camera layout.
109const GRID_CAMERA_LAYOUT_LABEL: &str = "grid_camera_layout";
110
111/// Group-0 camera layout: a single uniform buffer (vertex + fragment).
112fn grid_camera_layout_entries() -> Vec<khora_core::renderer::api::command::BindGroupLayoutEntry> {
113    use khora_core::renderer::api::command::{
114        BindGroupLayoutEntry, BindingType, BufferBindingType,
115    };
116    use khora_core::renderer::api::util::ShaderStageFlags;
117    vec![BindGroupLayoutEntry {
118        binding: 0,
119        visibility: ShaderStageFlags::VERTEX | ShaderStageFlags::FRAGMENT,
120        ty: BindingType::Buffer {
121            ty: BufferBindingType::Uniform,
122            has_dynamic_offset: false,
123            min_binding_size: None,
124        },
125    }]
126}
127
128/// The declarative pipeline spec for the grid overlay — fullscreen triangle,
129/// alpha-blended, depth-tested (LessEqual) against the scene buffer.
130fn grid_pipeline_spec(
131    device: &dyn khora_core::renderer::GraphicsDevice,
132) -> khora_core::renderer::api::pipeline::PipelineSpec {
133    use khora_core::renderer::api::pipeline::enums::{
134        BlendFactor, BlendOperation, CompareFunction, PrimitiveTopology,
135    };
136    use khora_core::renderer::api::pipeline::state::{
137        BlendComponentDescriptor, BlendStateDescriptor, ColorWrites, DepthBiasState,
138        StencilFaceState,
139    };
140    use khora_core::renderer::api::pipeline::{
141        ColorTargetStateDescriptor, DepthStencilStateDescriptor, LayoutSpec,
142        MultisampleStateDescriptor, PipelineSpec, PrimitiveStateDescriptor, ShaderVariantKey,
143    };
144    use khora_core::renderer::api::util::{SampleCount, TextureFormat};
145    use std::borrow::Cow;
146
147    // Alpha blend — antialiased grid lines fade against the scene.
148    let blend = BlendStateDescriptor {
149        color: BlendComponentDescriptor {
150            src_factor: BlendFactor::SrcAlpha,
151            dst_factor: BlendFactor::OneMinusSrcAlpha,
152            operation: BlendOperation::Add,
153        },
154        alpha: BlendComponentDescriptor {
155            src_factor: BlendFactor::One,
156            dst_factor: BlendFactor::OneMinusSrcAlpha,
157            operation: BlendOperation::Add,
158        },
159    };
160
161    PipelineSpec {
162        label: "Grid Pipeline",
163        shader: "khora::pipelines::grid",
164        variant: ShaderVariantKey::empty(),
165        bind_group_layouts: vec![LayoutSpec::Inline {
166            label: GRID_CAMERA_LAYOUT_LABEL,
167            entries: Cow::Owned(grid_camera_layout_entries()),
168        }],
169        vertex_buffers: vec![],
170        vs_entry: "vs_main",
171        fs_entry: Some("fs_main"),
172        primitive: PrimitiveStateDescriptor {
173            topology: PrimitiveTopology::TriangleList,
174            ..Default::default()
175        },
176        // The grid fragment shader writes `@builtin(frag_depth)`; depth
177        // testing against the scene buffer (LessEqual) lets closer geometry
178        // occlude the grid.
179        depth_stencil: Some(DepthStencilStateDescriptor {
180            format: TextureFormat::Depth32Float,
181            depth_write_enabled: true,
182            depth_compare: CompareFunction::LessEqual,
183            stencil_front: StencilFaceState::default(),
184            stencil_back: StencilFaceState::default(),
185            stencil_read_mask: 0,
186            stencil_write_mask: 0,
187            bias: DepthBiasState::default(),
188        }),
189        color_targets: vec![ColorTargetStateDescriptor {
190            format: device
191                .get_surface_format()
192                .unwrap_or(TextureFormat::Rgba8UnormSrgb),
193            blend: Some(blend),
194            write_mask: ColorWrites::ALL,
195        }],
196        multisample: MultisampleStateDescriptor {
197            count: SampleCount::X1,
198            mask: !0,
199            alpha_to_coverage_enabled: false,
200        },
201    }
202}
203
204fn render_grid(
205    lane: &GridLane,
206    device: &dyn khora_core::renderer::GraphicsDevice,
207    encoder: &mut dyn CommandEncoder,
208    color_target: khora_core::renderer::api::resource::TextureViewId,
209    depth_target: khora_core::renderer::api::resource::TextureViewId,
210    view: &khora_data::render::ExtractedView,
211) {
212    use khora_core::renderer::api::command::{
213        LoadOp, Operations, RenderPassColorAttachment, RenderPassDepthStencilAttachment,
214        RenderPassDescriptor, StoreOp,
215    };
216    use khora_core::renderer::api::resource::CameraUniformData;
217
218    let (Some(pipeline), Some(camera_buffer), Some(camera_bg)) = (
219        lane.pipeline.get().copied(),
220        lane.camera_buffer.get().copied(),
221        lane.camera_bind_group.get().copied(),
222    ) else {
223        log::warn!("GridLane: GPU resources not initialized, skipping");
224        return;
225    };
226
227    // Upload the camera uniforms (view-projection + position).
228    let camera_uniforms = CameraUniformData {
229        view_projection: view.view_proj.to_cols_array_2d(),
230        camera_position: [view.position.x, view.position.y, view.position.z, 1.0],
231    };
232    if let Err(e) = device.write_buffer(camera_buffer, 0, bytemuck::bytes_of(&camera_uniforms)) {
233        log::error!("GridLane: camera buffer write failed: {:?}", e);
234        return;
235    }
236
237    // Overlay pass — `LoadOp::Load` preserves the scene's color + depth.
238    let color_attachment = RenderPassColorAttachment {
239        view: &color_target,
240        resolve_target: None,
241        ops: Operations {
242            load: LoadOp::Load,
243            store: StoreOp::Store,
244        },
245        base_array_layer: 0,
246        base_mip_level: 0,
247    };
248    let pass_desc = RenderPassDescriptor {
249        label: Some("Grid Overlay Pass"),
250        color_attachments: &[color_attachment],
251        depth_stencil_attachment: Some(RenderPassDepthStencilAttachment {
252            view: &depth_target,
253            depth_ops: Some(Operations {
254                load: LoadOp::Load,
255                store: StoreOp::Store,
256            }),
257            stencil_ops: None,
258            base_array_layer: 0,
259        }),
260    };
261
262    let mut pass = encoder.begin_render_pass(&pass_desc);
263    pass.set_pipeline(&pipeline);
264    pass.set_bind_group(0, &camera_bg, &[]);
265    // Fullscreen triangle pair — the vertex shader derives positions
266    // from `vertex_index` (no vertex buffer bound).
267    pass.draw(0..6, 0..1);
268}
269
270impl Lane for GridLane {
271    fn strategy_name(&self) -> &'static str {
272        "Grid"
273    }
274
275    fn lane_kind(&self) -> LaneKind {
276        LaneKind::Render
277    }
278
279    fn on_initialize(&self, ctx: &mut LaneContext) -> Result<(), LaneError> {
280        let device = ctx
281            .get::<Arc<dyn khora_core::renderer::GraphicsDevice>>()
282            .ok_or(LaneError::missing("Arc<dyn GraphicsDevice>"))?
283            .clone();
284        let pipeline_system = ctx
285            .get::<Arc<dyn khora_core::renderer::traits::PipelineSystem>>()
286            .ok_or(LaneError::missing("Arc<dyn PipelineSystem>"))?
287            .clone();
288        init_gpu_resources(self, device.as_ref(), pipeline_system.as_ref())
289            .map_err(|e| LaneError::InitializationFailed(Box::new(e)))
290    }
291
292    fn execute(&self, ctx: &mut LaneContext) -> Result<(), LaneError> {
293        // The grid is opt-in: a host application enables the shared
294        // `GridConfig`. Absent or disabled ⇒ nothing to draw.
295        let enabled = ctx
296            .get::<SharedGridConfig>()
297            .and_then(|cfg| cfg.lock().ok().map(|c| c.enabled))
298            .unwrap_or(false);
299        if !enabled {
300            return Ok(());
301        }
302
303        // Camera comes from the primary extracted view (the editor
304        // viewport override is folded into `RenderWorld.views` by
305        // `RenderFlow`).
306        let Some(render_world) = ctx.get::<Ref<RenderWorld>>() else {
307            return Ok(());
308        };
309        let render_world = render_world.get();
310        let Some(view) = render_world.views.first() else {
311            return Ok(());
312        };
313        let view = view.clone();
314
315        let device = ctx
316            .get::<Arc<dyn khora_core::renderer::GraphicsDevice>>()
317            .ok_or(LaneError::missing("Arc<dyn GraphicsDevice>"))?
318            .clone();
319        let encoder = ctx
320            .get::<Slot<dyn CommandEncoder>>()
321            .ok_or(LaneError::missing("Slot<dyn CommandEncoder>"))?
322            .get();
323        let color_target = ctx
324            .get::<khora_core::lane::ColorTarget>()
325            .ok_or(LaneError::missing("ColorTarget"))?
326            .0;
327        // The grid depth-tests against the scene buffer — no depth
328        // target ⇒ skip (cannot run the depth-enabled pipeline).
329        let Some(depth_target) = ctx.get::<khora_core::lane::DepthTarget>().map(|d| d.0) else {
330            return Ok(());
331        };
332
333        render_grid(
334            self,
335            device.as_ref(),
336            encoder,
337            color_target,
338            depth_target,
339            &view,
340        );
341        Ok(())
342    }
343
344    fn as_any(&self) -> &dyn std::any::Any {
345        self
346    }
347
348    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
349        self
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    #[test]
358    fn grid_lane_strategy_name() {
359        let lane = GridLane::default();
360        assert_eq!(lane.strategy_name(), "Grid");
361        assert_eq!(lane.lane_kind(), LaneKind::Render);
362    }
363}