Skip to main content

khora_lanes/render_lane/
ui_render_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//! Implements a dedicated rendering lane for UI elements using taffy layout and instancing.
16
17use std::any::Any;
18use std::borrow::Cow;
19use std::sync::{Arc, Mutex, OnceLock};
20
21use khora_core::lane::{Lane, LaneContext, LaneError, LaneKind, Ref, Slot};
22use khora_core::math::{Mat4, Vec4};
23use khora_core::renderer::api::command::{
24    BindGroupDescriptor, BindGroupEntry, BindGroupId, BindGroupLayoutEntry, BindGroupLayoutId,
25    BindingResource, BindingType, BufferBinding, BufferBindingType, LoadOp, Operations,
26    RenderPassColorAttachment, RenderPassDescriptor, StoreOp,
27};
28use khora_core::renderer::api::pipeline::{
29    ColorTargetStateDescriptor, ColorWrites, MultisampleStateDescriptor, PrimitiveStateDescriptor,
30    PrimitiveTopology, RenderPipelineId,
31};
32use khora_core::renderer::api::resource::{
33    BufferDescriptor, BufferId, BufferUsage, TextureViewDimension, TextureViewId,
34};
35use khora_core::renderer::api::text::TextRenderer;
36use khora_core::renderer::api::util::{SampleCount, ShaderStageFlags, TextureFormat};
37use khora_core::renderer::GraphicsDevice;
38use khora_data::ui::UiScene;
39
40/// Data for a single UI instance sent to the GPU.
41#[repr(C)]
42#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
43struct UiInstanceData {
44    pos: [f32; 2],
45    size: [f32; 2],
46    color: [f32; 4],
47    params: [f32; 4],
48    uv_min: [f32; 2],
49    uv_max: [f32; 2],
50}
51
52/// A lane designed for high-performance UI rendering using instancing.
53///
54/// All GPU handles are initialised exactly once in `init_gpu_resources`
55/// and only read on subsequent frames, so each is held in a [`OnceLock`]
56/// for lock-free hot-path reads (the underlying buffer/texture *contents*
57/// are mutated per-frame at the GPU level — only the abstract IDs are
58/// stored here).
59pub struct UiRenderLane {
60    /// The UI render pipeline.
61    pipeline: OnceLock<RenderPipelineId>,
62    /// Layout for the UI global uniform buffer (projection matrix).
63    global_layout: OnceLock<BindGroupLayoutId>,
64    /// Layout for the instance data storage buffer.
65    instance_layout: OnceLock<BindGroupLayoutId>,
66    /// The projection matrix buffer.
67    projection_buffer: OnceLock<BufferId>,
68    /// The instance data buffer.
69    instance_buffer: OnceLock<BufferId>,
70    /// The global bind group (set 0).
71    global_bind_group: OnceLock<BindGroupId>,
72    /// The instance bind group (set 1).
73    instance_bind_group: OnceLock<BindGroupId>,
74    /// Layout for the atlas texture and sampler (set 2).
75    atlas_layout: OnceLock<BindGroupLayoutId>,
76    /// Fixed sampler for UI textures.
77    sampler: OnceLock<khora_core::renderer::api::resource::SamplerId>,
78    /// Cached atlas bind group (set 2), keyed by the atlas texture view it
79    /// binds. Rebuilt only when the atlas view changes (the atlas texture was
80    /// reallocated); atlas *content* updates keep the same view, so the bind
81    /// group stays valid. Rebuilding destroys the previous one, so the device's
82    /// bind-group table never grows across frames. `None` until the first
83    /// atlas-bearing frame.
84    atlas_bind_group: Mutex<Option<(TextureViewId, BindGroupId)>>,
85    /// Maximum number of UI elements supported in a single batch.
86    max_instances: usize,
87}
88
89impl Default for UiRenderLane {
90    fn default() -> Self {
91        Self {
92            pipeline: OnceLock::new(),
93            global_layout: OnceLock::new(),
94            instance_layout: OnceLock::new(),
95            projection_buffer: OnceLock::new(),
96            instance_buffer: OnceLock::new(),
97            global_bind_group: OnceLock::new(),
98            instance_bind_group: OnceLock::new(),
99            atlas_layout: OnceLock::new(),
100            sampler: OnceLock::new(),
101            atlas_bind_group: Mutex::new(None),
102            max_instances: 1024,
103        }
104    }
105}
106
107impl UiRenderLane {
108    /// Creates a new `UiRenderLane`.
109    pub fn new() -> Self {
110        Self::default()
111    }
112
113    fn init_gpu_resources(
114        &self,
115        device: &dyn GraphicsDevice,
116        pipeline_system: &dyn khora_core::renderer::traits::PipelineSystem,
117    ) -> Result<(), LaneError> {
118        // 1. Bind Group Layouts — bespoke to the UI lane, resolved + cached by
119        // the PipelineSystem so the pipeline and the lane's bind groups share
120        // one layout id.
121        let global_layout = pipeline_system
122            .inline_layout(device, UI_GLOBAL_LAYOUT_LABEL, &ui_global_layout_entries())
123            .map_err(|e| LaneError::InitializationFailed(Box::new(e)))?;
124        let instance_layout = pipeline_system
125            .inline_layout(
126                device,
127                UI_INSTANCE_LAYOUT_LABEL,
128                &ui_instance_layout_entries(),
129            )
130            .map_err(|e| LaneError::InitializationFailed(Box::new(e)))?;
131        let atlas_layout = pipeline_system
132            .inline_layout(device, UI_ATLAS_LAYOUT_LABEL, &ui_atlas_layout_entries())
133            .map_err(|e| LaneError::InitializationFailed(Box::new(e)))?;
134
135        // 2. Pipeline — compiled + cached by the backend from
136        // `khora::pipelines::ui`.
137        let pipeline_id = pipeline_system
138            .pipeline(device, &ui_pipeline_spec(device))
139            .map_err(|e| LaneError::InitializationFailed(Box::new(e)))?;
140
141        // 3. Create Buffers
142        let projection_buffer = device
143            .create_buffer(&BufferDescriptor {
144                label: Some(Cow::Borrowed("UI Projection Buffer")),
145                size: 64, // 4x4 matrix
146                usage: BufferUsage::UNIFORM | BufferUsage::COPY_DST,
147                mapped_at_creation: false,
148            })
149            .map_err(|e| LaneError::InitializationFailed(Box::new(e)))?;
150
151        let instance_buffer = device
152            .create_buffer(&BufferDescriptor {
153                label: Some(Cow::Borrowed("UI Instance Buffer")),
154                size: (self.max_instances * std::mem::size_of::<UiInstanceData>()) as u64,
155                usage: BufferUsage::STORAGE | BufferUsage::COPY_DST,
156                mapped_at_creation: false,
157            })
158            .map_err(|e| LaneError::InitializationFailed(Box::new(e)))?;
159
160        // 4. Create Bind Groups
161        let global_bind_group = device
162            .create_bind_group(&BindGroupDescriptor {
163                label: Some("ui_global_bind_group"),
164                layout: global_layout,
165                entries: &[BindGroupEntry {
166                    binding: 0,
167                    resource: BindingResource::Buffer(BufferBinding {
168                        buffer: projection_buffer,
169                        offset: 0,
170                        size: None,
171                    }),
172                    _phantom: std::marker::PhantomData,
173                }],
174            })
175            .map_err(|e| LaneError::InitializationFailed(Box::new(e)))?;
176
177        let instance_bind_group = device
178            .create_bind_group(&BindGroupDescriptor {
179                label: Some("ui_instance_bind_group"),
180                layout: instance_layout,
181                entries: &[BindGroupEntry {
182                    binding: 0,
183                    resource: BindingResource::Buffer(BufferBinding {
184                        buffer: instance_buffer,
185                        offset: 0,
186                        size: None,
187                    }),
188                    _phantom: std::marker::PhantomData,
189                }],
190            })
191            .map_err(|e| LaneError::InitializationFailed(Box::new(e)))?;
192
193        // Store resources — init-once writes via OnceLock; second `set`
194        // would Err but `init_gpu_resources` is only called once.
195        let _ = self.pipeline.set(pipeline_id);
196        let _ = self.global_layout.set(global_layout);
197        let _ = self.instance_layout.set(instance_layout);
198        let _ = self.projection_buffer.set(projection_buffer);
199        let _ = self.instance_buffer.set(instance_buffer);
200        let _ = self.global_bind_group.set(global_bind_group);
201        let _ = self.instance_bind_group.set(instance_bind_group);
202        let _ = self.atlas_layout.set(atlas_layout);
203
204        let sampler = device
205            .create_sampler(&khora_core::renderer::api::resource::SamplerDescriptor {
206                label: Some(Cow::Borrowed("ui_sampler")),
207                address_mode_u: khora_core::renderer::api::resource::AddressMode::ClampToEdge,
208                address_mode_v: khora_core::renderer::api::resource::AddressMode::ClampToEdge,
209                address_mode_w: khora_core::renderer::api::resource::AddressMode::ClampToEdge,
210                mag_filter: khora_core::renderer::api::resource::FilterMode::Linear,
211                min_filter: khora_core::renderer::api::resource::FilterMode::Linear,
212                mipmap_filter: khora_core::renderer::api::resource::MipmapFilterMode::Linear,
213                lod_min_clamp: 0.0,
214                lod_max_clamp: 100.0,
215                compare: None,
216                anisotropy_clamp: 1,
217                border_color: None,
218            })
219            .map_err(|e| LaneError::InitializationFailed(Box::new(e)))?;
220        let _ = self.sampler.set(sampler);
221
222        Ok(())
223    }
224}
225
226impl Lane for UiRenderLane {
227    fn strategy_name(&self) -> &'static str {
228        "UiRender"
229    }
230
231    fn lane_kind(&self) -> LaneKind {
232        LaneKind::Render
233    }
234
235    fn estimate_cost(&self, _ctx: &LaneContext) -> f32 {
236        0.1
237    }
238
239    fn on_initialize(&self, ctx: &mut LaneContext) -> Result<(), LaneError> {
240        let device = ctx
241            .get::<Arc<dyn GraphicsDevice>>()
242            .ok_or(LaneError::missing("Arc<dyn GraphicsDevice>"))?
243            .clone();
244        let pipeline_system = ctx
245            .get::<Arc<dyn khora_core::renderer::traits::PipelineSystem>>()
246            .ok_or(LaneError::missing("Arc<dyn PipelineSystem>"))?
247            .clone();
248        self.init_gpu_resources(device.as_ref(), pipeline_system.as_ref())
249    }
250
251    fn execute(&self, ctx: &mut LaneContext) -> Result<(), LaneError> {
252        let device = ctx
253            .get::<Arc<dyn GraphicsDevice>>()
254            .ok_or(LaneError::missing("Arc<dyn GraphicsDevice>"))?;
255        let ui_scene = ctx
256            .get::<Ref<UiScene>>()
257            .ok_or(LaneError::missing("Ref<UiScene>"))?
258            .get();
259        let atlas_map = ctx
260            .get::<Ref<khora_data::ui::UiAtlasMap>>()
261            .ok_or(LaneError::missing("Ref<UiAtlasMap>"))?
262            .get();
263        let encoder = ctx
264            .get::<Slot<dyn khora_core::renderer::traits::CommandEncoder>>()
265            .ok_or(LaneError::missing("Slot<dyn CommandEncoder>"))?
266            .get();
267        let color_target = ctx
268            .get::<khora_core::lane::ColorTarget>()
269            .ok_or(LaneError::missing("ColorTarget"))?
270            .0;
271
272        // 1. Update Projection Matrix
273        let (width, height) = ui_scene.surface_size;
274        let projection = Mat4::orthographic_rh_zo(0.0, width as f32, height as f32, 0.0, 0.0, 1.0);
275
276        if let Some(buffer_id) = self.projection_buffer.get().copied() {
277            device
278                .write_buffer(buffer_id, 0, bytemuck::bytes_of(&projection))
279                .map_err(|e| LaneError::ExecutionFailed(Box::new(e)))?;
280        }
281
282        // 2. Process UI Nodes into Instance Data
283        let mut instances = Vec::with_capacity(ui_scene.nodes.len().min(self.max_instances));
284
285        for node in &ui_scene.nodes {
286            if instances.len() >= self.max_instances {
287                break;
288            }
289
290            let color_vec = node.color.map(|c| c.0).unwrap_or(Vec4::ONE);
291            let border_radius = node.border.map(|b| b.radius).unwrap_or(0.0);
292            let border_width = node
293                .border
294                .map(|b| {
295                    if b.width.left > 0.0 {
296                        b.width.left
297                    } else {
298                        0.0
299                    }
300                })
301                .unwrap_or(0.0);
302            let has_texture = if node.image.is_some() { 1.0 } else { 0.0 };
303
304            let (uv_min, uv_max) = node
305                .image
306                .and_then(|img| atlas_map.get(&img.texture))
307                .map(|rect| (rect.min.into(), rect.max.into()))
308                .unwrap_or(([0.0, 0.0], [1.0, 1.0]));
309
310            instances.push(UiInstanceData {
311                pos: node.pos.into(),
312                size: node.size.into(),
313                color: color_vec.into(),
314                params: [border_radius, border_width, has_texture, 0.0],
315                uv_min,
316                uv_max,
317            });
318        }
319
320        if instances.is_empty() {
321            return Ok(());
322        }
323
324        // 3. Upload Instance Data
325        if let Some(buffer_id) = self.instance_buffer.get().copied() {
326            device
327                .write_buffer(buffer_id, 0, bytemuck::cast_slice(&instances))
328                .map_err(|e| LaneError::ExecutionFailed(Box::new(e)))?;
329        }
330
331        // 4. Atlas bind group (set 2), cached by the atlas texture view it binds.
332        //    It is rebuilt only when the view changes (the atlas texture was
333        //    reallocated) and the previous one is destroyed then — atlas *content*
334        //    updates keep the same view, so the bind group stays valid. This keeps
335        //    the device's bind-group table bounded instead of leaking one entry
336        //    per UI frame.
337        let mut atlas_bg = None;
338        if let Some(atlas_slot) = ctx.get::<Slot<khora_core::renderer::api::util::TextureAtlas>>() {
339            let atlas = atlas_slot.get();
340            if let (Some(layout), Some(sampler)) = (
341                self.atlas_layout.get().copied(),
342                self.sampler.get().copied(),
343            ) {
344                let view = atlas.view();
345                let mut cache = self
346                    .atlas_bind_group
347                    .lock()
348                    .unwrap_or_else(|e| e.into_inner());
349                let bg = match *cache {
350                    // Same atlas view → reuse the existing bind group.
351                    Some((cached_view, bg)) if cached_view == view => bg,
352                    // First atlas, or the atlas texture was reallocated: build a
353                    // fresh bind group and destroy the stale one (if any).
354                    _ => {
355                        let bg = device
356                            .create_bind_group(&BindGroupDescriptor {
357                                label: Some("ui_atlas_bind_group"),
358                                layout,
359                                entries: &[
360                                    BindGroupEntry {
361                                        binding: 0,
362                                        resource: BindingResource::TextureView(view),
363                                        _phantom: std::marker::PhantomData,
364                                    },
365                                    BindGroupEntry {
366                                        binding: 1,
367                                        resource: BindingResource::Sampler(sampler),
368                                        _phantom: std::marker::PhantomData,
369                                    },
370                                ],
371                            })
372                            .map_err(|e| LaneError::ExecutionFailed(Box::new(e)))?;
373                        if let Some((_, stale)) = cache.replace((view, bg)) {
374                            let _ = device.destroy_bind_group(stale);
375                        }
376                        bg
377                    }
378                };
379                atlas_bg = Some(bg);
380            }
381        }
382
383        // 5. Render Pass — lock-free reads via OnceLock.
384        if let (Some(pipeline_id), Some(g_bg), Some(i_bg)) = (
385            self.pipeline.get().copied(),
386            self.global_bind_group.get().copied(),
387            self.instance_bind_group.get().copied(),
388        ) {
389            let color_attachment = RenderPassColorAttachment {
390                view: &color_target,
391                resolve_target: None,
392                ops: Operations {
393                    load: LoadOp::Load,
394                    store: StoreOp::Store,
395                },
396                base_array_layer: 0,
397                base_mip_level: 0,
398            };
399
400            let attachments = [color_attachment];
401            let render_pass_desc = RenderPassDescriptor {
402                label: Some("UI Render Pass"),
403                color_attachments: &attachments,
404                depth_stencil_attachment: None,
405            };
406
407            let mut render_pass = encoder.begin_render_pass(&render_pass_desc);
408            render_pass.set_pipeline(&pipeline_id);
409            render_pass.set_bind_group(0, &g_bg, &[]);
410            render_pass.set_bind_group(1, &i_bg, &[]);
411
412            if let Some(bg) = &atlas_bg {
413                render_pass.set_bind_group(2, bg, &[]);
414            }
415
416            // Draw 4 vertices per instance (quad)
417            render_pass.draw(0..4, 0..(instances.len() as u32));
418        }
419
420        // 5. Render Text
421        if let Some(tr) = ctx.get::<Arc<dyn TextRenderer>>() {
422            for text in &ui_scene.texts {
423                tr.queue_text(text.layout.as_ref(), text.pos, text.color, text.z_index);
424            }
425            tr.flush(device.as_ref(), encoder, &color_target)
426                .map_err(LaneError::ExecutionFailed)?;
427        }
428
429        Ok(())
430    }
431
432    fn as_any(&self) -> &dyn Any {
433        self
434    }
435
436    fn as_any_mut(&mut self) -> &mut dyn Any {
437        self
438    }
439}
440
441// ─── Free functions (CLAD: declarative spec + bespoke layouts) ───
442
443/// Stable cache label for the UI global uniform layout (set 0).
444const UI_GLOBAL_LAYOUT_LABEL: &str = "ui_global_layout";
445/// Stable cache label for the UI instance storage layout (set 1).
446const UI_INSTANCE_LAYOUT_LABEL: &str = "ui_instance_layout";
447/// Stable cache label for the UI atlas texture + sampler layout (set 2).
448const UI_ATLAS_LAYOUT_LABEL: &str = "ui_atlas_layout";
449
450/// Set-0 layout: the projection-matrix uniform buffer.
451fn ui_global_layout_entries() -> Vec<BindGroupLayoutEntry> {
452    vec![BindGroupLayoutEntry {
453        binding: 0,
454        visibility: ShaderStageFlags::VERTEX | ShaderStageFlags::FRAGMENT,
455        ty: BindingType::Buffer {
456            ty: BufferBindingType::Uniform,
457            has_dynamic_offset: false,
458            min_binding_size: None,
459        },
460    }]
461}
462
463/// Set-1 layout: the read-only instance storage buffer.
464fn ui_instance_layout_entries() -> Vec<BindGroupLayoutEntry> {
465    vec![BindGroupLayoutEntry {
466        binding: 0,
467        visibility: ShaderStageFlags::VERTEX | ShaderStageFlags::FRAGMENT,
468        ty: BindingType::Buffer {
469            ty: BufferBindingType::Storage { read_only: true },
470            has_dynamic_offset: false,
471            min_binding_size: None,
472        },
473    }]
474}
475
476/// Set-2 layout: the atlas texture + filtering sampler.
477fn ui_atlas_layout_entries() -> Vec<BindGroupLayoutEntry> {
478    vec![
479        BindGroupLayoutEntry {
480            binding: 0,
481            visibility: ShaderStageFlags::FRAGMENT,
482            ty: BindingType::Texture {
483                sample_type: khora_core::renderer::api::command::TextureSampleType::Float {
484                    filterable: true,
485                },
486                view_dimension: TextureViewDimension::D2,
487                multisampled: false,
488            },
489        },
490        BindGroupLayoutEntry {
491            binding: 1,
492            visibility: ShaderStageFlags::FRAGMENT,
493            ty: BindingType::Sampler(
494                khora_core::renderer::api::command::SamplerBindingType::Filtering,
495            ),
496        },
497    ]
498}
499
500/// The declarative pipeline spec for the UI lane — instanced, no vertex buffer,
501/// no depth, surface-format color target.
502fn ui_pipeline_spec(
503    device: &dyn GraphicsDevice,
504) -> khora_core::renderer::api::pipeline::PipelineSpec {
505    use khora_core::renderer::api::pipeline::{LayoutSpec, PipelineSpec, ShaderVariantKey};
506    PipelineSpec {
507        label: "UI Render Pipeline",
508        shader: "khora::pipelines::ui",
509        variant: ShaderVariantKey::empty(),
510        bind_group_layouts: vec![
511            LayoutSpec::Inline {
512                label: UI_GLOBAL_LAYOUT_LABEL,
513                entries: Cow::Owned(ui_global_layout_entries()),
514            },
515            LayoutSpec::Inline {
516                label: UI_INSTANCE_LAYOUT_LABEL,
517                entries: Cow::Owned(ui_instance_layout_entries()),
518            },
519            LayoutSpec::Inline {
520                label: UI_ATLAS_LAYOUT_LABEL,
521                entries: Cow::Owned(ui_atlas_layout_entries()),
522            },
523        ],
524        vertex_buffers: vec![],
525        vs_entry: "vs_main",
526        fs_entry: Some("fs_main"),
527        primitive: PrimitiveStateDescriptor {
528            topology: PrimitiveTopology::TriangleList,
529            ..Default::default()
530        },
531        depth_stencil: None,
532        color_targets: vec![ColorTargetStateDescriptor {
533            format: device
534                .get_surface_format()
535                .unwrap_or(TextureFormat::Rgba8UnormSrgb),
536            blend: None,
537            write_mask: ColorWrites::ALL,
538        }],
539        multisample: MultisampleStateDescriptor {
540            count: SampleCount::X1,
541            mask: !0,
542            alpha_to_coverage_enabled: false,
543        },
544    }
545}