Skip to main content

khora_lanes/render_lane/
gizmo_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//! Gizmo overlay lane — editor / debug line gizmos.
16//!
17//! Reads a [`GizmoFrame`](khora_data::render::GizmoFrame) (shared via
18//! an `Arc<Mutex<…>>` runtime resource — see `OverlayAgent`) and draws
19//! each `GizmoLineInstance` as an alpha-blended line list on top of
20//! the main render target. The host application (editor, debug tooling)
21//! is the sole producer of the lines; the engine only provides the
22//! mechanism — no `EditorAgent` in the engine, per CLAD.
23//!
24//! Per CLAD this struct holds only persistent state; the init / render
25//! bodies are private free functions in this module.
26
27use khora_core::lane::{Lane, LaneContext, LaneError, LaneKind, Ref, Slot};
28use khora_core::renderer::api::command::{BindGroupId, BindGroupLayoutId};
29use khora_core::renderer::api::pipeline::RenderPipelineId;
30use khora_core::renderer::api::resource::BufferId;
31use khora_core::renderer::traits::CommandEncoder;
32use khora_core::ui::editor::GizmoLineInstance;
33use khora_data::render::{GizmoFrame, RenderWorld};
34use std::sync::{Arc, Mutex, OnceLock};
35
36/// Maximum number of gizmo line instances the storage buffer holds.
37const GIZMO_CAPACITY: usize = 4096;
38
39/// Shared gizmo line container — the host app writes, `GizmoLane` reads.
40pub type SharedGizmoFrame = Arc<Mutex<GizmoFrame>>;
41
42/// Editor / debug gizmo overlay lane.
43#[derive(Debug)]
44pub struct GizmoLane {
45    pipeline: OnceLock<RenderPipelineId>,
46    camera_layout: OnceLock<BindGroupLayoutId>,
47    storage_layout: OnceLock<BindGroupLayoutId>,
48    camera_buffer: OnceLock<BufferId>,
49    storage_buffer: OnceLock<BufferId>,
50    camera_bind_group: OnceLock<BindGroupId>,
51    storage_bind_group: OnceLock<BindGroupId>,
52    /// Maximum number of line instances the storage buffer can hold.
53    capacity: usize,
54}
55
56impl Default for GizmoLane {
57    fn default() -> Self {
58        Self {
59            pipeline: OnceLock::new(),
60            camera_layout: OnceLock::new(),
61            storage_layout: OnceLock::new(),
62            camera_buffer: OnceLock::new(),
63            storage_buffer: OnceLock::new(),
64            camera_bind_group: OnceLock::new(),
65            storage_bind_group: OnceLock::new(),
66            capacity: GIZMO_CAPACITY,
67        }
68    }
69}
70
71// ─── Free functions (CLAD: no inherent methods on the lane struct) ───
72
73fn init_gpu_resources(
74    lane: &GizmoLane,
75    device: &dyn khora_core::renderer::GraphicsDevice,
76    pipeline_system: &dyn khora_core::renderer::traits::PipelineSystem,
77) -> Result<(), khora_core::renderer::error::RenderError> {
78    use khora_core::renderer::api::{
79        command::{BindGroupDescriptor, BindGroupEntry, BindingResource, BufferBinding},
80        resource::{BufferDescriptor, BufferUsage},
81    };
82    use std::borrow::Cow;
83
84    // Bespoke layouts resolved + cached by the PipelineSystem so the pipeline
85    // and the lane's bind groups share one layout id.
86    let camera_layout = pipeline_system.inline_layout(
87        device,
88        GIZMO_CAMERA_LAYOUT_LABEL,
89        &gizmo_camera_layout_entries(),
90    )?;
91    let storage_layout = pipeline_system.inline_layout(
92        device,
93        GIZMO_STORAGE_LAYOUT_LABEL,
94        &gizmo_storage_layout_entries(),
95    )?;
96
97    let camera_buffer = device
98        .create_buffer(&BufferDescriptor {
99            label: Some(Cow::Borrowed("gizmo_camera_ubo")),
100            size: std::mem::size_of::<khora_core::renderer::api::resource::CameraUniformData>()
101                as u64,
102            usage: BufferUsage::UNIFORM | BufferUsage::COPY_DST,
103            mapped_at_creation: false,
104        })
105        .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
106    let storage_size = (lane.capacity * std::mem::size_of::<GizmoLineInstance>()) as u64;
107    let storage_buffer = device
108        .create_buffer(&BufferDescriptor {
109            label: Some(Cow::Borrowed("gizmo_storage_buffer")),
110            size: storage_size,
111            usage: BufferUsage::STORAGE | BufferUsage::COPY_DST,
112            mapped_at_creation: false,
113        })
114        .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
115
116    let camera_bind_group = device
117        .create_bind_group(&BindGroupDescriptor {
118            label: Some("gizmo_camera_bg"),
119            layout: camera_layout,
120            entries: &[BindGroupEntry {
121                binding: 0,
122                resource: BindingResource::Buffer(BufferBinding {
123                    buffer: camera_buffer,
124                    offset: 0,
125                    size: None,
126                }),
127                _phantom: std::marker::PhantomData,
128            }],
129        })
130        .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
131    let storage_bind_group = device
132        .create_bind_group(&BindGroupDescriptor {
133            label: Some("gizmo_storage_bg"),
134            layout: storage_layout,
135            entries: &[BindGroupEntry {
136                binding: 0,
137                resource: BindingResource::Buffer(BufferBinding {
138                    buffer: storage_buffer,
139                    offset: 0,
140                    size: None,
141                }),
142                _phantom: std::marker::PhantomData,
143            }],
144        })
145        .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
146
147    // Pipeline — compiled + cached by the backend from
148    // `khora::pipelines::gizmo`.
149    let pipeline_id = pipeline_system.pipeline(device, &gizmo_pipeline_spec(device))?;
150
151    let _ = lane.camera_layout.set(camera_layout);
152    let _ = lane.storage_layout.set(storage_layout);
153    let _ = lane.camera_buffer.set(camera_buffer);
154    let _ = lane.storage_buffer.set(storage_buffer);
155    let _ = lane.camera_bind_group.set(camera_bind_group);
156    let _ = lane.storage_bind_group.set(storage_bind_group);
157    let _ = lane.pipeline.set(pipeline_id);
158    Ok(())
159}
160
161/// Stable cache labels for the gizmo lane's bespoke layouts.
162const GIZMO_CAMERA_LAYOUT_LABEL: &str = "gizmo_camera_layout";
163const GIZMO_STORAGE_LAYOUT_LABEL: &str = "gizmo_storage_layout";
164
165/// Group-0 camera layout: a single uniform buffer (vertex stage).
166fn gizmo_camera_layout_entries() -> Vec<khora_core::renderer::api::command::BindGroupLayoutEntry> {
167    use khora_core::renderer::api::command::{
168        BindGroupLayoutEntry, BindingType, BufferBindingType,
169    };
170    use khora_core::renderer::api::util::ShaderStageFlags;
171    vec![BindGroupLayoutEntry {
172        binding: 0,
173        visibility: ShaderStageFlags::VERTEX,
174        ty: BindingType::Buffer {
175            ty: BufferBindingType::Uniform,
176            has_dynamic_offset: false,
177            min_binding_size: None,
178        },
179    }]
180}
181
182/// Group-1 storage layout: the read-only line-instance storage buffer.
183fn gizmo_storage_layout_entries() -> Vec<khora_core::renderer::api::command::BindGroupLayoutEntry> {
184    use khora_core::renderer::api::command::{
185        BindGroupLayoutEntry, BindingType, BufferBindingType,
186    };
187    use khora_core::renderer::api::util::ShaderStageFlags;
188    vec![BindGroupLayoutEntry {
189        binding: 0,
190        visibility: ShaderStageFlags::VERTEX,
191        ty: BindingType::Buffer {
192            ty: BufferBindingType::Storage { read_only: true },
193            has_dynamic_offset: false,
194            min_binding_size: None,
195        },
196    }]
197}
198
199/// The declarative pipeline spec for the gizmo overlay — line-list, alpha
200/// blended, no depth (always on top).
201fn gizmo_pipeline_spec(
202    device: &dyn khora_core::renderer::GraphicsDevice,
203) -> khora_core::renderer::api::pipeline::PipelineSpec {
204    use khora_core::renderer::api::pipeline::enums::{
205        BlendFactor, BlendOperation, PrimitiveTopology,
206    };
207    use khora_core::renderer::api::pipeline::state::{
208        BlendComponentDescriptor, BlendStateDescriptor, ColorWrites,
209    };
210    use khora_core::renderer::api::pipeline::{
211        ColorTargetStateDescriptor, LayoutSpec, MultisampleStateDescriptor, PipelineSpec,
212        PrimitiveStateDescriptor, ShaderVariantKey,
213    };
214    use khora_core::renderer::api::util::{SampleCount, TextureFormat};
215    use std::borrow::Cow;
216
217    // Alpha blend; no depth attachment — gizmos always draw on top (the legacy
218    // infra path used `CompareFunction::Always`, equivalent to no depth test
219    // for an overlay).
220    let blend = BlendStateDescriptor {
221        color: BlendComponentDescriptor {
222            src_factor: BlendFactor::SrcAlpha,
223            dst_factor: BlendFactor::OneMinusSrcAlpha,
224            operation: BlendOperation::Add,
225        },
226        alpha: BlendComponentDescriptor {
227            src_factor: BlendFactor::One,
228            dst_factor: BlendFactor::OneMinusSrcAlpha,
229            operation: BlendOperation::Add,
230        },
231    };
232
233    PipelineSpec {
234        label: "Gizmo Pipeline",
235        shader: "khora::pipelines::gizmo",
236        variant: ShaderVariantKey::empty(),
237        bind_group_layouts: vec![
238            LayoutSpec::Inline {
239                label: GIZMO_CAMERA_LAYOUT_LABEL,
240                entries: Cow::Owned(gizmo_camera_layout_entries()),
241            },
242            LayoutSpec::Inline {
243                label: GIZMO_STORAGE_LAYOUT_LABEL,
244                entries: Cow::Owned(gizmo_storage_layout_entries()),
245            },
246        ],
247        vertex_buffers: vec![],
248        vs_entry: "vs_main",
249        fs_entry: Some("fs_main"),
250        primitive: PrimitiveStateDescriptor {
251            topology: PrimitiveTopology::LineList,
252            ..Default::default()
253        },
254        depth_stencil: None,
255        color_targets: vec![ColorTargetStateDescriptor {
256            format: device
257                .get_surface_format()
258                .unwrap_or(TextureFormat::Rgba8UnormSrgb),
259            blend: Some(blend),
260            write_mask: ColorWrites::ALL,
261        }],
262        multisample: MultisampleStateDescriptor {
263            count: SampleCount::X1,
264            mask: !0,
265            alpha_to_coverage_enabled: false,
266        },
267    }
268}
269
270fn render_gizmos(
271    lane: &GizmoLane,
272    device: &dyn khora_core::renderer::GraphicsDevice,
273    encoder: &mut dyn CommandEncoder,
274    color_target: khora_core::renderer::api::resource::TextureViewId,
275    view: &khora_data::render::ExtractedView,
276    lines: &[GizmoLineInstance],
277) {
278    use khora_core::renderer::api::command::{
279        LoadOp, Operations, RenderPassColorAttachment, RenderPassDescriptor, StoreOp,
280    };
281    use khora_core::renderer::api::resource::CameraUniformData;
282
283    let line_count = lines.len().min(lane.capacity);
284    if line_count == 0 {
285        return;
286    }
287
288    let (
289        Some(pipeline),
290        Some(camera_buffer),
291        Some(storage_buffer),
292        Some(camera_bg),
293        Some(storage_bg),
294    ) = (
295        lane.pipeline.get().copied(),
296        lane.camera_buffer.get().copied(),
297        lane.storage_buffer.get().copied(),
298        lane.camera_bind_group.get().copied(),
299        lane.storage_bind_group.get().copied(),
300    )
301    else {
302        log::warn!("GizmoLane: GPU resources not initialized, skipping");
303        return;
304    };
305
306    // Upload camera + line data into the persistent buffers (the bind
307    // groups created at init still point at them).
308    let camera_uniforms = CameraUniformData {
309        view_projection: view.view_proj.to_cols_array_2d(),
310        camera_position: [view.position.x, view.position.y, view.position.z, 1.0],
311    };
312    if let Err(e) = device.write_buffer(camera_buffer, 0, bytemuck::bytes_of(&camera_uniforms)) {
313        log::error!("GizmoLane: camera buffer write failed: {:?}", e);
314        return;
315    }
316    if let Err(e) = device.write_buffer(
317        storage_buffer,
318        0,
319        bytemuck::cast_slice(&lines[..line_count]),
320    ) {
321        log::error!("GizmoLane: storage buffer write failed: {:?}", e);
322        return;
323    }
324
325    // Overlay pass — LoadOp::Load preserves the main render's color.
326    let color_attachment = RenderPassColorAttachment {
327        view: &color_target,
328        resolve_target: None,
329        ops: Operations {
330            load: LoadOp::Load,
331            store: StoreOp::Store,
332        },
333        base_array_layer: 0,
334        base_mip_level: 0,
335    };
336    let pass_desc = RenderPassDescriptor {
337        label: Some("Gizmo Overlay Pass"),
338        color_attachments: &[color_attachment],
339        depth_stencil_attachment: None,
340    };
341
342    let mut pass = encoder.begin_render_pass(&pass_desc);
343    pass.set_pipeline(&pipeline);
344    pass.set_bind_group(0, &camera_bg, &[]);
345    pass.set_bind_group(1, &storage_bg, &[]);
346    // Two vertices per line; the vertex shader derives endpoints from
347    // `vertex_index` (no vertex buffer bound).
348    pass.draw(0..(line_count as u32 * 2), 0..1);
349}
350
351impl Lane for GizmoLane {
352    fn strategy_name(&self) -> &'static str {
353        "Gizmo"
354    }
355
356    fn lane_kind(&self) -> LaneKind {
357        LaneKind::Render
358    }
359
360    fn on_initialize(&self, ctx: &mut LaneContext) -> Result<(), LaneError> {
361        let device = ctx
362            .get::<Arc<dyn khora_core::renderer::GraphicsDevice>>()
363            .ok_or(LaneError::missing("Arc<dyn GraphicsDevice>"))?
364            .clone();
365        let pipeline_system = ctx
366            .get::<Arc<dyn khora_core::renderer::traits::PipelineSystem>>()
367            .ok_or(LaneError::missing("Arc<dyn PipelineSystem>"))?
368            .clone();
369        init_gpu_resources(self, device.as_ref(), pipeline_system.as_ref())
370            .map_err(|e| LaneError::InitializationFailed(Box::new(e)))
371    }
372
373    fn execute(&self, ctx: &mut LaneContext) -> Result<(), LaneError> {
374        // The shared `GizmoFrame` is published by the host application.
375        // Absent ⇒ no gizmo producer this run ⇒ nothing to draw.
376        let Some(shared) = ctx.get::<SharedGizmoFrame>() else {
377            return Ok(());
378        };
379        let lines: Vec<GizmoLineInstance> = match shared.lock() {
380            Ok(frame) => {
381                if frame.lines.is_empty() {
382                    return Ok(());
383                }
384                frame.lines.clone()
385            }
386            Err(_) => {
387                log::warn!("GizmoLane: shared GizmoFrame mutex poisoned");
388                return Ok(());
389            }
390        };
391
392        // Camera comes from the primary extracted view — the editor
393        // viewport override is already folded into `RenderWorld.views`
394        // by `RenderFlow`.
395        let Some(render_world) = ctx.get::<Ref<RenderWorld>>() else {
396            return Ok(());
397        };
398        let render_world = render_world.get();
399        let Some(view) = render_world.views.first() else {
400            return Ok(());
401        };
402        let view = view.clone();
403
404        let device = ctx
405            .get::<Arc<dyn khora_core::renderer::GraphicsDevice>>()
406            .ok_or(LaneError::missing("Arc<dyn GraphicsDevice>"))?
407            .clone();
408        let encoder = ctx
409            .get::<Slot<dyn CommandEncoder>>()
410            .ok_or(LaneError::missing("Slot<dyn CommandEncoder>"))?
411            .get();
412        let color_target = ctx
413            .get::<khora_core::lane::ColorTarget>()
414            .ok_or(LaneError::missing("ColorTarget"))?
415            .0;
416
417        render_gizmos(self, device.as_ref(), encoder, color_target, &view, &lines);
418        Ok(())
419    }
420
421    fn as_any(&self) -> &dyn std::any::Any {
422        self
423    }
424
425    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
426        self
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    #[test]
435    fn gizmo_lane_strategy_name() {
436        let lane = GizmoLane::default();
437        assert_eq!(lane.strategy_name(), "Gizmo");
438        assert_eq!(lane.lane_kind(), LaneKind::Render);
439        assert_eq!(lane.capacity, GIZMO_CAPACITY);
440    }
441}