Skip to main content

khora_lanes/render_lane/
wireframe_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//! Wireframe overlay lane — debug visualization drawing every scene mesh as
16//! edge lines for triangle-edge visibility.
17//!
18//! Registered under [`OverlayAgent`](khora_agents::overlay_agent). The pipeline
19//! is composed from `khora::pipelines::wireframe` and uses a barycentric
20//! edge-detection fragment shader (it does not rely on
21//! `Features::POLYGON_MODE_LINE`, which is not part of wgpu's downlevel-safe
22//! baseline).
23//!
24//! Opt-in like the grid: a host application enables the shared
25//! [`WireframeConfig`](khora_data::render::WireframeConfig) runtime resource
26//! (the editor drives it from a viewport toggle; the sandbox leaves it off).
27//! The lane loads the scene's color + depth read-only and draws after the scene
28//! pass, so the wireframe overlays the lit image.
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::pipeline::RenderPipelineId;
36use khora_core::renderer::api::resource::BufferId;
37use khora_core::renderer::api::scene::GpuMesh;
38use khora_core::renderer::api::util::dynamic_uniform_buffer::DynamicUniformRingBuffer;
39use khora_core::renderer::traits::CommandEncoder;
40use khora_data::assets::Assets;
41use khora_data::render::{RenderWorld, WireframeConfig};
42use std::sync::{Arc, Mutex, OnceLock, RwLock};
43
44/// Shared wireframe-overlay config — the host app enables it, `WireframeLane`
45/// reads it.
46pub type SharedWireframeConfig = Arc<Mutex<WireframeConfig>>;
47
48/// Wireframe material uniform — matches `WireframeMaterialUniforms` in
49/// `wireframe.wgsl` (line color + width, padded to 16 bytes).
50#[repr(C, align(16))]
51#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
52struct WireframeMaterialUniforms {
53    color: [f32; 4],
54    line_width: f32,
55    _padding: [f32; 3],
56}
57
58/// Wireframe debug overlay lane.
59#[derive(Debug, Default)]
60pub struct WireframeLane {
61    pipeline: OnceLock<RenderPipelineId>,
62    camera_layout: OnceLock<BindGroupLayoutId>,
63    material_layout: OnceLock<BindGroupLayoutId>,
64    camera_buffer: OnceLock<BufferId>,
65    camera_bind_group: OnceLock<BindGroupId>,
66    material_buffer: OnceLock<BufferId>,
67    material_bind_group: OnceLock<BindGroupId>,
68    /// Per-mesh model transforms — one buffer, a per-draw dynamic offset, same
69    /// machinery the lit lanes use.
70    model_ring: Mutex<Option<DynamicUniformRingBuffer>>,
71}
72
73// ─── Free functions (CLAD: no inherent methods on the lane struct) ───
74
75fn init_gpu_resources(
76    lane: &WireframeLane,
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::pipeline::{LayoutKey, ShaderVariantKey};
84    use khora_core::renderer::api::resource::{BufferDescriptor, BufferUsage, CameraUniformData};
85    use khora_core::renderer::api::scene::ModelUniforms;
86    use khora_core::renderer::api::util::dynamic_uniform_buffer::{
87        DEFAULT_MAX_ELEMENTS, MIN_UNIFORM_ALIGNMENT,
88    };
89    use khora_core::renderer::api::util::ShaderStageFlags;
90    use std::borrow::Cow;
91
92    // Group 0 (camera) + group 2 (material) are single bespoke uniforms; group
93    // 1 (per-mesh model) reuses the canonical dynamic Model layout so the ring
94    // matches the pipeline byte-for-byte.
95    let camera_layout = pipeline_system.inline_layout(
96        device,
97        WIREFRAME_CAMERA_LAYOUT_LABEL,
98        &wireframe_uniform_layout_entries(ShaderStageFlags::VERTEX | ShaderStageFlags::FRAGMENT),
99    )?;
100    let material_layout = pipeline_system.inline_layout(
101        device,
102        WIREFRAME_MATERIAL_LAYOUT_LABEL,
103        &wireframe_uniform_layout_entries(ShaderStageFlags::FRAGMENT),
104    )?;
105    let model_layout =
106        pipeline_system.layout(device, LayoutKey::Model, &ShaderVariantKey::empty())?;
107
108    let pipeline_id = pipeline_system.pipeline(device, &wireframe_pipeline_spec(device))?;
109
110    let camera_buffer = device
111        .create_buffer(&BufferDescriptor {
112            label: Some(Cow::Borrowed("wireframe_camera_ubo")),
113            size: std::mem::size_of::<CameraUniformData>() as u64,
114            usage: BufferUsage::UNIFORM | BufferUsage::COPY_DST,
115            mapped_at_creation: false,
116        })
117        .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
118    let camera_bind_group = device
119        .create_bind_group(&BindGroupDescriptor {
120            label: Some("wireframe_camera_bg"),
121            layout: camera_layout,
122            entries: &[BindGroupEntry {
123                binding: 0,
124                resource: BindingResource::Buffer(BufferBinding {
125                    buffer: camera_buffer,
126                    offset: 0,
127                    size: None,
128                }),
129                _phantom: std::marker::PhantomData,
130            }],
131        })
132        .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
133
134    let material_buffer = device
135        .create_buffer(&BufferDescriptor {
136            label: Some(Cow::Borrowed("wireframe_material_ubo")),
137            size: std::mem::size_of::<WireframeMaterialUniforms>() as u64,
138            usage: BufferUsage::UNIFORM | BufferUsage::COPY_DST,
139            mapped_at_creation: false,
140        })
141        .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
142    let material_bind_group = device
143        .create_bind_group(&BindGroupDescriptor {
144            label: Some("wireframe_material_bg"),
145            layout: material_layout,
146            entries: &[BindGroupEntry {
147                binding: 0,
148                resource: BindingResource::Buffer(BufferBinding {
149                    buffer: material_buffer,
150                    offset: 0,
151                    size: None,
152                }),
153                _phantom: std::marker::PhantomData,
154            }],
155        })
156        .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
157
158    let model_ring = DynamicUniformRingBuffer::new(
159        device,
160        model_layout,
161        0,
162        std::mem::size_of::<ModelUniforms>() as u32,
163        DEFAULT_MAX_ELEMENTS,
164        MIN_UNIFORM_ALIGNMENT,
165        "Wireframe Model Ring",
166    )
167    .map_err(khora_core::renderer::error::RenderError::ResourceError)?;
168
169    let _ = lane.camera_layout.set(camera_layout);
170    let _ = lane.material_layout.set(material_layout);
171    let _ = lane.camera_buffer.set(camera_buffer);
172    let _ = lane.camera_bind_group.set(camera_bind_group);
173    let _ = lane.material_buffer.set(material_buffer);
174    let _ = lane.material_bind_group.set(material_bind_group);
175    *crate::render_lane::util::lock::mutex_lock_render(
176        &lane.model_ring,
177        "WireframeLane init.model_ring",
178    )? = Some(model_ring);
179    let _ = lane.pipeline.set(pipeline_id);
180    Ok(())
181}
182
183/// Stable cache labels for the wireframe lane's bespoke layouts.
184const WIREFRAME_CAMERA_LAYOUT_LABEL: &str = "wireframe_camera_layout";
185const WIREFRAME_MATERIAL_LAYOUT_LABEL: &str = "wireframe_material_layout";
186
187/// A single uniform-buffer layout entry visible to `visibility`.
188fn wireframe_uniform_layout_entries(
189    visibility: khora_core::renderer::api::util::ShaderStageFlags,
190) -> Vec<khora_core::renderer::api::command::BindGroupLayoutEntry> {
191    use khora_core::renderer::api::command::{
192        BindGroupLayoutEntry, BindingType, BufferBindingType,
193    };
194    vec![BindGroupLayoutEntry {
195        binding: 0,
196        visibility,
197        ty: BindingType::Buffer {
198            ty: BufferBindingType::Uniform,
199            has_dynamic_offset: false,
200            min_binding_size: None,
201        },
202    }]
203}
204
205/// The declarative pipeline spec for the wireframe overlay — barycentric edge
206/// detection, alpha-blended, depth read-only (LessEqual).
207fn wireframe_pipeline_spec(
208    device: &dyn khora_core::renderer::GraphicsDevice,
209) -> khora_core::renderer::api::pipeline::PipelineSpec {
210    use khora_core::renderer::api::pipeline::enums::{
211        CompareFunction, VertexFormat, VertexStepMode,
212    };
213    use khora_core::renderer::api::pipeline::state::{
214        BlendStateDescriptor, ColorWrites, DepthBiasState, StencilFaceState,
215    };
216    use khora_core::renderer::api::pipeline::{
217        ColorTargetStateDescriptor, DepthStencilStateDescriptor, LayoutKey, LayoutSpec,
218        MultisampleStateDescriptor, PipelineSpec, PrimitiveStateDescriptor, ShaderVariantKey,
219        VertexAttributeDescriptor, VertexBufferLayoutDescriptor,
220    };
221    use khora_core::renderer::api::util::ShaderStageFlags;
222    use khora_core::renderer::api::util::{SampleCount, TextureFormat};
223    use std::borrow::Cow;
224
225    PipelineSpec {
226        label: "Wireframe Pipeline",
227        shader: "khora::pipelines::wireframe",
228        variant: ShaderVariantKey::empty(),
229        bind_group_layouts: vec![
230            LayoutSpec::Inline {
231                label: WIREFRAME_CAMERA_LAYOUT_LABEL,
232                entries: Cow::Owned(wireframe_uniform_layout_entries(
233                    ShaderStageFlags::VERTEX | ShaderStageFlags::FRAGMENT,
234                )),
235            },
236            LayoutSpec::Named(LayoutKey::Model),
237            LayoutSpec::Inline {
238                label: WIREFRAME_MATERIAL_LAYOUT_LABEL,
239                entries: Cow::Owned(wireframe_uniform_layout_entries(ShaderStageFlags::FRAGMENT)),
240            },
241        ],
242        vertex_buffers: vec![VertexBufferLayoutDescriptor {
243            array_stride: 32,
244            step_mode: VertexStepMode::Vertex,
245            attributes: Cow::Owned(vec![
246                VertexAttributeDescriptor {
247                    format: VertexFormat::Float32x3,
248                    offset: 0,
249                    shader_location: 0,
250                },
251                VertexAttributeDescriptor {
252                    format: VertexFormat::Float32x3,
253                    offset: 12,
254                    shader_location: 1,
255                },
256            ]),
257        }],
258        vs_entry: "vs_main",
259        fs_entry: Some("fs_main"),
260        primitive: PrimitiveStateDescriptor::default(),
261        depth_stencil: Some(DepthStencilStateDescriptor {
262            format: TextureFormat::Depth32Float,
263            // Overlay: depth-test against the scene so hidden edges are
264            // occluded, but never write depth.
265            depth_write_enabled: false,
266            depth_compare: CompareFunction::LessEqual,
267            stencil_front: StencilFaceState::default(),
268            stencil_back: StencilFaceState::default(),
269            stencil_read_mask: 0,
270            stencil_write_mask: 0,
271            bias: DepthBiasState::default(),
272        }),
273        color_targets: vec![ColorTargetStateDescriptor {
274            format: device
275                .get_surface_format()
276                .unwrap_or(TextureFormat::Rgba8UnormSrgb),
277            blend: Some(BlendStateDescriptor::alpha_blending()),
278            write_mask: ColorWrites::ALL,
279        }],
280        multisample: MultisampleStateDescriptor {
281            count: SampleCount::X1,
282            mask: !0,
283            alpha_to_coverage_enabled: false,
284        },
285    }
286}
287
288#[allow(clippy::too_many_arguments)]
289fn render_wireframe(
290    lane: &WireframeLane,
291    device: &dyn khora_core::renderer::GraphicsDevice,
292    encoder: &mut dyn CommandEncoder,
293    color_target: khora_core::renderer::api::resource::TextureViewId,
294    depth_target: khora_core::renderer::api::resource::TextureViewId,
295    view: &khora_data::render::ExtractedView,
296    render_world: &RenderWorld,
297    gpu_meshes: &RwLock<Assets<GpuMesh>>,
298    config: &WireframeConfig,
299) {
300    use khora_core::renderer::api::command::{
301        LoadOp, Operations, RenderPassColorAttachment, RenderPassDepthStencilAttachment,
302        RenderPassDescriptor, StoreOp,
303    };
304    use khora_core::renderer::api::resource::CameraUniformData;
305    use khora_core::renderer::api::scene::ModelUniforms;
306
307    let (
308        Some(pipeline),
309        Some(camera_buffer),
310        Some(camera_bg),
311        Some(material_buffer),
312        Some(material_bg),
313    ) = (
314        lane.pipeline.get().copied(),
315        lane.camera_buffer.get().copied(),
316        lane.camera_bind_group.get().copied(),
317        lane.material_buffer.get().copied(),
318        lane.material_bind_group.get().copied(),
319    )
320    else {
321        log::warn!("WireframeLane: GPU resources not initialized, skipping");
322        return;
323    };
324
325    // Camera + material uniforms (the latter picks up live editor changes to
326    // color / width).
327    let camera_uniforms = CameraUniformData {
328        view_projection: view.view_proj.to_cols_array_2d(),
329        camera_position: [view.position.x, view.position.y, view.position.z, 1.0],
330    };
331    if let Err(e) = device.write_buffer(camera_buffer, 0, bytemuck::bytes_of(&camera_uniforms)) {
332        log::error!("WireframeLane: camera buffer write failed: {:?}", e);
333        return;
334    }
335    let material_uniforms = WireframeMaterialUniforms {
336        color: [
337            config.line_color.r,
338            config.line_color.g,
339            config.line_color.b,
340            config.line_color.a,
341        ],
342        line_width: config.line_width,
343        _padding: [0.0; 3],
344    };
345    if let Err(e) = device.write_buffer(material_buffer, 0, bytemuck::bytes_of(&material_uniforms))
346    {
347        log::error!("WireframeLane: material buffer write failed: {:?}", e);
348        return;
349    }
350
351    let gpu_mesh_assets =
352        crate::lock_or_log!(gpu_meshes.read(), "WireframeLane::render gpu_meshes");
353    let mut model_ring_lock =
354        crate::lock_or_log!(lane.model_ring.lock(), "WireframeLane::render model_ring");
355    let model_ring = match model_ring_lock.as_mut() {
356        Some(r) => r,
357        None => {
358            log::warn!("WireframeLane: model ring not initialized");
359            return;
360        }
361    };
362    model_ring.advance();
363
364    // Build one draw per mesh up front (the ring's Copy handles only, no borrow
365    // of the asset guard) so the render pass does not overlap the mutable push.
366    use khora_core::renderer::api::util::IndexFormat;
367    let mut draws: Vec<(u32, BufferId, BufferId, IndexFormat, u32)> =
368        Vec::with_capacity(render_world.meshes.len());
369    for extracted_mesh in &render_world.meshes {
370        let Some(gpu_mesh) = gpu_mesh_assets.get(&extracted_mesh.cpu_mesh_uuid) else {
371            continue;
372        };
373        let model_mat = extracted_mesh.transform.to_matrix();
374        let normal_mat = model_mat
375            .inverse()
376            .map(|inv| inv.transpose())
377            .unwrap_or(model_mat);
378        let model_uniforms = ModelUniforms {
379            model_matrix: model_mat.to_cols_array_2d(),
380            normal_matrix: normal_mat.to_cols_array_2d(),
381        };
382        match model_ring.push(device, bytemuck::bytes_of(&model_uniforms)) {
383            Ok(offset) => draws.push((
384                offset,
385                gpu_mesh.vertex_buffer,
386                gpu_mesh.index_buffer,
387                gpu_mesh.index_format,
388                gpu_mesh.index_count,
389            )),
390            Err(e) => {
391                log::error!("WireframeLane: model push failed: {:?}", e);
392                return;
393            }
394        }
395    }
396    let model_bg = *model_ring.current_bind_group();
397
398    // Overlay pass — `LoadOp::Load` preserves the scene's color + depth.
399    let color_attachment = RenderPassColorAttachment {
400        view: &color_target,
401        resolve_target: None,
402        ops: Operations {
403            load: LoadOp::Load,
404            store: StoreOp::Store,
405        },
406        base_array_layer: 0,
407        base_mip_level: 0,
408    };
409    let pass_desc = RenderPassDescriptor {
410        label: Some("Wireframe Overlay Pass"),
411        color_attachments: &[color_attachment],
412        depth_stencil_attachment: Some(RenderPassDepthStencilAttachment {
413            view: &depth_target,
414            depth_ops: Some(Operations {
415                load: LoadOp::Load,
416                store: StoreOp::Store,
417            }),
418            stencil_ops: None,
419            base_array_layer: 0,
420        }),
421    };
422
423    let mut pass = encoder.begin_render_pass(&pass_desc);
424    pass.set_pipeline(&pipeline);
425    pass.set_bind_group(0, &camera_bg, &[]);
426    pass.set_bind_group(2, &material_bg, &[]);
427    for (offset, vertex_buffer, index_buffer, index_format, index_count) in &draws {
428        pass.set_bind_group(1, &model_bg, &[*offset]);
429        pass.set_vertex_buffer(0, vertex_buffer, 0);
430        pass.set_index_buffer(index_buffer, 0, *index_format);
431        pass.draw_indexed(0..*index_count, 0, 0..1);
432    }
433}
434
435impl Lane for WireframeLane {
436    fn strategy_name(&self) -> &'static str {
437        "Wireframe"
438    }
439
440    fn lane_kind(&self) -> LaneKind {
441        LaneKind::Render
442    }
443
444    fn on_initialize(&self, ctx: &mut LaneContext) -> Result<(), LaneError> {
445        let device = ctx
446            .get::<Arc<dyn khora_core::renderer::GraphicsDevice>>()
447            .ok_or(LaneError::missing("Arc<dyn GraphicsDevice>"))?
448            .clone();
449        let pipeline_system = ctx
450            .get::<Arc<dyn khora_core::renderer::traits::PipelineSystem>>()
451            .ok_or(LaneError::missing("Arc<dyn PipelineSystem>"))?
452            .clone();
453        init_gpu_resources(self, device.as_ref(), pipeline_system.as_ref())
454            .map_err(|e| LaneError::InitializationFailed(Box::new(e)))
455    }
456
457    fn execute(&self, ctx: &mut LaneContext) -> Result<(), LaneError> {
458        // Opt-in: a host application enables the shared `WireframeConfig`.
459        // Absent or disabled ⇒ nothing to draw.
460        let config = ctx
461            .get::<SharedWireframeConfig>()
462            .and_then(|cfg| cfg.lock().ok().map(|c| c.clone()));
463        let Some(config) = config else {
464            return Ok(());
465        };
466        if !config.enabled {
467            return Ok(());
468        }
469
470        let Some(render_world) = ctx.get::<Ref<RenderWorld>>() else {
471            return Ok(());
472        };
473        let render_world = render_world.get();
474        let Some(view) = render_world.views.first() else {
475            return Ok(());
476        };
477        let view = view.clone();
478
479        let device = ctx
480            .get::<Arc<dyn khora_core::renderer::GraphicsDevice>>()
481            .ok_or(LaneError::missing("Arc<dyn GraphicsDevice>"))?
482            .clone();
483        let gpu_meshes = ctx
484            .get::<Arc<RwLock<Assets<GpuMesh>>>>()
485            .ok_or(LaneError::missing("Arc<RwLock<Assets<GpuMesh>>>"))?
486            .clone();
487        let encoder = ctx
488            .get::<Slot<dyn CommandEncoder>>()
489            .ok_or(LaneError::missing("Slot<dyn CommandEncoder>"))?
490            .get();
491        let color_target = ctx
492            .get::<khora_core::lane::ColorTarget>()
493            .ok_or(LaneError::missing("ColorTarget"))?
494            .0;
495        // The wireframe depth-tests against the scene buffer — no depth target
496        // ⇒ skip (cannot run the depth-enabled pipeline).
497        let Some(depth_target) = ctx.get::<khora_core::lane::DepthTarget>().map(|d| d.0) else {
498            return Ok(());
499        };
500
501        render_wireframe(
502            self,
503            device.as_ref(),
504            encoder,
505            color_target,
506            depth_target,
507            &view,
508            render_world,
509            gpu_meshes.as_ref(),
510            &config,
511        );
512        Ok(())
513    }
514
515    fn as_any(&self) -> &dyn std::any::Any {
516        self
517    }
518
519    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
520        self
521    }
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527
528    #[test]
529    fn wireframe_lane_strategy_name() {
530        let lane = WireframeLane::default();
531        assert_eq!(lane.strategy_name(), "Wireframe");
532        assert_eq!(lane.lane_kind(), LaneKind::Render);
533    }
534
535    #[test]
536    fn wireframe_material_uniforms_is_std140_sized() {
537        assert_eq!(std::mem::size_of::<WireframeMaterialUniforms>(), 32);
538        assert_eq!(std::mem::align_of::<WireframeMaterialUniforms>(), 16);
539    }
540}