Skip to main content

khora_infra/ui/egui/
renderer.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//! Custom egui renderer for wgpu 28.0.
16//!
17//! This module renders egui's [`ClippedPrimitive`] output using the engine's
18//! wgpu backend. It manages its own render pipeline, textures, and per-frame
19//! vertex/index buffers.
20
21use egui::epaint::{ClippedPrimitive, ImageDelta, Primitive, Vertex};
22use egui::{ImageData, TextureId, TexturesDelta};
23use std::collections::HashMap;
24use wgpu::util::DeviceExt;
25
26/// Per-frame render state passed from [`EguiOverlay`] to the renderer.
27pub struct EguiRenderState<'a> {
28    /// The wgpu device.
29    pub device: &'a wgpu::Device,
30    /// The wgpu queue.
31    pub queue: &'a wgpu::Queue,
32    /// The command encoder for this frame.
33    pub encoder: &'a mut wgpu::CommandEncoder,
34    /// The swapchain texture view to render onto.
35    pub target_view: &'a wgpu::TextureView,
36    /// Physical width in pixels.
37    pub width_px: u32,
38    /// Physical height in pixels.
39    pub height_px: u32,
40}
41
42/// Custom wgpu renderer for egui primitives.
43pub struct EguiWgpuRenderer {
44    pipeline: Option<wgpu::RenderPipeline>,
45    screen_uniform_buffer: Option<wgpu::Buffer>,
46    screen_bind_group: Option<wgpu::BindGroup>,
47    screen_bind_group_layout: Option<wgpu::BindGroupLayout>,
48    texture_bind_group_layout: Option<wgpu::BindGroupLayout>,
49    sampler: Option<wgpu::Sampler>,
50    textures: HashMap<TextureId, (wgpu::Texture, wgpu::BindGroup)>,
51    surface_format: wgpu::TextureFormat,
52    next_user_texture_id: u64,
53}
54
55impl EguiWgpuRenderer {
56    /// Creates a new, uninitialized renderer.
57    pub fn new(surface_format: wgpu::TextureFormat) -> Self {
58        Self {
59            pipeline: None,
60            screen_uniform_buffer: None,
61            screen_bind_group: None,
62            screen_bind_group_layout: None,
63            texture_bind_group_layout: None,
64            sampler: None,
65            textures: HashMap::new(),
66            surface_format,
67            next_user_texture_id: 0,
68        }
69    }
70
71    /// Initializes the render pipeline and bind group layouts.
72    ///
73    /// Must be called once with the wgpu device before any rendering.
74    pub fn initialize(&mut self, device: &wgpu::Device, shader_source: &str) {
75        // --- Bind group layout 0: screen size uniform ---
76        let screen_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
77            label: Some("egui_screen_bgl"),
78            entries: &[wgpu::BindGroupLayoutEntry {
79                binding: 0,
80                visibility: wgpu::ShaderStages::VERTEX,
81                ty: wgpu::BindingType::Buffer {
82                    ty: wgpu::BufferBindingType::Uniform,
83                    has_dynamic_offset: false,
84                    min_binding_size: Some(std::num::NonZero::new(8).unwrap()),
85                },
86                count: None,
87            }],
88        });
89
90        // --- Bind group layout 1: texture + sampler ---
91        let texture_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
92            label: Some("egui_texture_bgl"),
93            entries: &[
94                wgpu::BindGroupLayoutEntry {
95                    binding: 0,
96                    visibility: wgpu::ShaderStages::FRAGMENT,
97                    ty: wgpu::BindingType::Texture {
98                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
99                        view_dimension: wgpu::TextureViewDimension::D2,
100                        multisampled: false,
101                    },
102                    count: None,
103                },
104                wgpu::BindGroupLayoutEntry {
105                    binding: 1,
106                    visibility: wgpu::ShaderStages::FRAGMENT,
107                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
108                    count: None,
109                },
110            ],
111        });
112
113        // --- Pipeline layout ---
114        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
115            label: Some("egui_pipeline_layout"),
116            bind_group_layouts: &[Some(&screen_bgl), Some(&texture_bgl)],
117            immediate_size: 0,
118        });
119
120        // --- Shader module ---
121        let shader_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
122            label: Some("egui_shader"),
123            source: wgpu::ShaderSource::Wgsl(shader_source.into()),
124        });
125
126        // --- Vertex buffer layout (matches egui::epaint::Vertex) ---
127        // pos: [f32; 2], uv: [f32; 2], color: [u8; 4]
128        // Total stride: 20 bytes
129        let vertex_buffer_layout = wgpu::VertexBufferLayout {
130            array_stride: std::mem::size_of::<Vertex>() as u64,
131            step_mode: wgpu::VertexStepMode::Vertex,
132            attributes: &[
133                // position: vec2<f32> at offset 0
134                wgpu::VertexAttribute {
135                    format: wgpu::VertexFormat::Float32x2,
136                    offset: 0,
137                    shader_location: 0,
138                },
139                // uv: vec2<f32> at offset 8
140                wgpu::VertexAttribute {
141                    format: wgpu::VertexFormat::Float32x2,
142                    offset: 8,
143                    shader_location: 1,
144                },
145                // color: [u8; 4] as Unorm at offset 16
146                wgpu::VertexAttribute {
147                    format: wgpu::VertexFormat::Unorm8x4,
148                    offset: 16,
149                    shader_location: 2,
150                },
151            ],
152        };
153
154        // --- Render pipeline ---
155        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
156            label: Some("egui_pipeline"),
157            layout: Some(&pipeline_layout),
158            vertex: wgpu::VertexState {
159                module: &shader_module,
160                entry_point: Some("vs_main"),
161                buffers: &[vertex_buffer_layout],
162                compilation_options: Default::default(),
163            },
164            primitive: wgpu::PrimitiveState {
165                topology: wgpu::PrimitiveTopology::TriangleList,
166                strip_index_format: None,
167                front_face: wgpu::FrontFace::Ccw,
168                cull_mode: None, // egui can have CW and CCW triangles
169                unclipped_depth: false,
170                polygon_mode: wgpu::PolygonMode::Fill,
171                conservative: false,
172            },
173            depth_stencil: None,
174            multisample: wgpu::MultisampleState::default(),
175            fragment: Some(wgpu::FragmentState {
176                module: &shader_module,
177                entry_point: Some("fs_main"),
178                targets: &[Some(wgpu::ColorTargetState {
179                    format: self.surface_format,
180                    blend: Some(wgpu::BlendState {
181                        color: wgpu::BlendComponent {
182                            src_factor: wgpu::BlendFactor::One,
183                            dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
184                            operation: wgpu::BlendOperation::Add,
185                        },
186                        alpha: wgpu::BlendComponent {
187                            src_factor: wgpu::BlendFactor::OneMinusDstAlpha,
188                            dst_factor: wgpu::BlendFactor::One,
189                            operation: wgpu::BlendOperation::Add,
190                        },
191                    }),
192                    write_mask: wgpu::ColorWrites::ALL,
193                })],
194                compilation_options: Default::default(),
195            }),
196            multiview_mask: None,
197            cache: None,
198        });
199
200        // --- Screen uniform buffer (2 × f32: width, height) ---
201        let screen_buffer = device.create_buffer(&wgpu::BufferDescriptor {
202            label: Some("egui_screen_uniform"),
203            size: 8,
204            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
205            mapped_at_creation: false,
206        });
207
208        let screen_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
209            label: Some("egui_screen_bg"),
210            layout: &screen_bgl,
211            entries: &[wgpu::BindGroupEntry {
212                binding: 0,
213                resource: screen_buffer.as_entire_binding(),
214            }],
215        });
216
217        // --- Sampler ---
218        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
219            label: Some("egui_sampler"),
220            mag_filter: wgpu::FilterMode::Linear,
221            min_filter: wgpu::FilterMode::Linear,
222            mipmap_filter: wgpu::MipmapFilterMode::Nearest,
223            address_mode_u: wgpu::AddressMode::ClampToEdge,
224            address_mode_v: wgpu::AddressMode::ClampToEdge,
225            ..Default::default()
226        });
227
228        self.pipeline = Some(pipeline);
229        self.screen_uniform_buffer = Some(screen_buffer);
230        self.screen_bind_group = Some(screen_bind_group);
231        self.screen_bind_group_layout = Some(screen_bgl);
232        self.texture_bind_group_layout = Some(texture_bgl);
233        self.sampler = Some(sampler);
234
235        log::info!(
236            "EguiWgpuRenderer: Initialized with format {:?}",
237            self.surface_format
238        );
239    }
240
241    /// Updates textures from egui's [`TexturesDelta`].
242    pub fn update_textures(
243        &mut self,
244        device: &wgpu::Device,
245        queue: &wgpu::Queue,
246        textures_delta: &TexturesDelta,
247    ) {
248        for (id, delta) in &textures_delta.set {
249            self.set_texture(device, queue, *id, delta);
250        }
251
252        for id in &textures_delta.free {
253            self.textures.remove(id);
254        }
255    }
256
257    /// Registers an externally-created wgpu texture view (e.g. an offscreen
258    /// viewport render target) as an egui-managed texture.
259    ///
260    /// Returns the [`TextureId`] that can be used in egui `Image` widgets.
261    pub fn register_external_texture(
262        &mut self,
263        device: &wgpu::Device,
264        view: &wgpu::TextureView,
265    ) -> TextureId {
266        let (Some(texture_bgl), Some(sampler)) = (
267            self.texture_bind_group_layout.as_ref(),
268            self.sampler.as_ref(),
269        ) else {
270            // Hand back a fresh, unique id with no backing bind group; `render`
271            // already skips (and logs) ids whose texture is missing, so this
272            // degrades to a non-drawn widget instead of crashing the frame.
273            let id = TextureId::User(self.next_user_texture_id);
274            self.next_user_texture_id += 1;
275            log::error!(
276                "EguiWgpuRenderer: register_external_texture called before initialize(); \
277                 returning unbacked texture id {id:?}"
278            );
279            return id;
280        };
281
282        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
283            label: Some("egui_external_texture_bg"),
284            layout: texture_bgl,
285            entries: &[
286                wgpu::BindGroupEntry {
287                    binding: 0,
288                    resource: wgpu::BindingResource::TextureView(view),
289                },
290                wgpu::BindGroupEntry {
291                    binding: 1,
292                    resource: wgpu::BindingResource::Sampler(sampler),
293                },
294            ],
295        });
296
297        // Use a user-managed texture ID (egui::TextureId::User).
298        let id = TextureId::User(self.next_user_texture_id);
299        self.next_user_texture_id += 1;
300
301        // We store a dummy wgpu::Texture — only the bind_group matters for
302        // external textures. Create a 1×1 placeholder.
303        let placeholder = device.create_texture(&wgpu::TextureDescriptor {
304            label: Some("egui_ext_placeholder"),
305            size: wgpu::Extent3d {
306                width: 1,
307                height: 1,
308                depth_or_array_layers: 1,
309            },
310            mip_level_count: 1,
311            sample_count: 1,
312            dimension: wgpu::TextureDimension::D2,
313            format: wgpu::TextureFormat::Rgba8UnormSrgb,
314            usage: wgpu::TextureUsages::TEXTURE_BINDING,
315            view_formats: &[],
316        });
317
318        self.textures.insert(id, (placeholder, bind_group));
319        id
320    }
321
322    /// Updates the wgpu texture view backing an existing external texture.
323    ///
324    /// Call this when the offscreen render target is resized.
325    pub fn update_external_texture(
326        &mut self,
327        device: &wgpu::Device,
328        id: TextureId,
329        view: &wgpu::TextureView,
330    ) {
331        let (Some(texture_bgl), Some(sampler)) = (
332            self.texture_bind_group_layout.as_ref(),
333            self.sampler.as_ref(),
334        ) else {
335            log::error!(
336                "EguiWgpuRenderer: update_external_texture called before initialize(); skipping"
337            );
338            return;
339        };
340
341        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
342            label: Some("egui_external_texture_bg_updated"),
343            layout: texture_bgl,
344            entries: &[
345                wgpu::BindGroupEntry {
346                    binding: 0,
347                    resource: wgpu::BindingResource::TextureView(view),
348                },
349                wgpu::BindGroupEntry {
350                    binding: 1,
351                    resource: wgpu::BindingResource::Sampler(sampler),
352                },
353            ],
354        });
355
356        if let Some(entry) = self.textures.get_mut(&id) {
357            entry.1 = bind_group;
358        }
359    }
360
361    /// Renders the egui output.
362    pub fn render(
363        &self,
364        state: &mut EguiRenderState<'_>,
365        clipped_primitives: &[ClippedPrimitive],
366        pixels_per_point: f32,
367    ) {
368        let pipeline = match &self.pipeline {
369            Some(p) => p,
370            None => {
371                log::warn!("EguiWgpuRenderer: Cannot render — not initialized");
372                return;
373            }
374        };
375
376        // The pipeline check above already proves `initialize()` ran; the
377        // screen bind group and uniform buffer are created in the same call, so
378        // they are present whenever the pipeline is. Guard defensively anyway
379        // rather than unwrap on a GPU resource.
380        let (Some(screen_bg), Some(screen_buf)) = (
381            self.screen_bind_group.as_ref(),
382            self.screen_uniform_buffer.as_ref(),
383        ) else {
384            log::error!("EguiWgpuRenderer: screen resources missing despite initialized pipeline");
385            return;
386        };
387
388        // Update screen size uniform
389        let screen_size = [state.width_px as f32, state.height_px as f32];
390        state
391            .queue
392            .write_buffer(screen_buf, 0, bytemuck::cast_slice(&screen_size));
393
394        // Collect all vertex/index data and build draw calls
395        let mut vertices: Vec<u8> = Vec::new();
396        let mut indices: Vec<u32> = Vec::new();
397        let mut draw_calls: Vec<DrawCall> = Vec::new();
398
399        for primitive in clipped_primitives {
400            match &primitive.primitive {
401                Primitive::Mesh(mesh) => {
402                    if mesh.vertices.is_empty() || mesh.indices.is_empty() {
403                        continue;
404                    }
405
406                    let vertex_offset = vertices.len() / std::mem::size_of::<Vertex>();
407                    let index_offset = indices.len();
408
409                    // Append vertices as raw bytes
410                    let vertex_bytes: &[u8] = bytemuck::cast_slice(&mesh.vertices);
411                    vertices.extend_from_slice(vertex_bytes);
412
413                    // Append indices with offset
414                    for &idx in &mesh.indices {
415                        indices.push(idx + vertex_offset as u32);
416                    }
417
418                    // Compute scissor rect in physical pixels
419                    let clip = primitive.clip_rect;
420                    let x = (clip.min.x * pixels_per_point).round().max(0.0) as u32;
421                    let y = (clip.min.y * pixels_per_point).round().max(0.0) as u32;
422                    let w = ((clip.max.x - clip.min.x) * pixels_per_point)
423                        .round()
424                        .max(1.0) as u32;
425                    let h = ((clip.max.y - clip.min.y) * pixels_per_point)
426                        .round()
427                        .max(1.0) as u32;
428
429                    // Clamp to render target
430                    let x = x.min(state.width_px.saturating_sub(1));
431                    let y = y.min(state.height_px.saturating_sub(1));
432                    let w = w.min(state.width_px.saturating_sub(x));
433                    let h = h.min(state.height_px.saturating_sub(y));
434
435                    if w == 0 || h == 0 {
436                        continue;
437                    }
438
439                    draw_calls.push(DrawCall {
440                        texture_id: mesh.texture_id,
441                        scissor: [x, y, w, h],
442                        index_start: index_offset as u32,
443                        index_count: mesh.indices.len() as u32,
444                    });
445                }
446                Primitive::Callback(_) => {
447                    log::warn!("EguiWgpuRenderer: Paint callbacks not supported");
448                }
449            }
450        }
451
452        if draw_calls.is_empty() {
453            return;
454        }
455
456        // Create GPU buffers
457        let vertex_buffer = state
458            .device
459            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
460                label: Some("egui_vertex_buffer"),
461                contents: &vertices,
462                usage: wgpu::BufferUsages::VERTEX,
463            });
464
465        let index_buffer = state
466            .device
467            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
468                label: Some("egui_index_buffer"),
469                contents: bytemuck::cast_slice(&indices),
470                usage: wgpu::BufferUsages::INDEX,
471            });
472
473        // Render pass — LOAD (not clear) to preserve the 3D scene underneath
474        let mut render_pass = state
475            .encoder
476            .begin_render_pass(&wgpu::RenderPassDescriptor {
477                label: Some("egui_render_pass"),
478                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
479                    view: state.target_view,
480                    resolve_target: None,
481                    ops: wgpu::Operations {
482                        load: wgpu::LoadOp::Load,
483                        store: wgpu::StoreOp::Store,
484                    },
485                    depth_slice: None,
486                })],
487                depth_stencil_attachment: None,
488                timestamp_writes: None,
489                occlusion_query_set: None,
490                multiview_mask: None,
491            });
492
493        render_pass.set_pipeline(pipeline);
494        render_pass.set_bind_group(0, Some(screen_bg), &[]);
495        render_pass.set_vertex_buffer(0, vertex_buffer.slice(..));
496        render_pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint32);
497
498        for call in &draw_calls {
499            if let Some((_, bind_group)) = self.textures.get(&call.texture_id) {
500                render_pass.set_bind_group(1, Some(bind_group), &[]);
501                render_pass.set_scissor_rect(
502                    call.scissor[0],
503                    call.scissor[1],
504                    call.scissor[2],
505                    call.scissor[3],
506                );
507                render_pass.draw_indexed(
508                    call.index_start..call.index_start + call.index_count,
509                    0,
510                    0..1,
511                );
512            } else {
513                log::warn!("EguiWgpuRenderer: Missing texture {:?}", call.texture_id);
514            }
515        }
516    }
517
518    // --- Private helpers ---
519
520    fn set_texture(
521        &mut self,
522        device: &wgpu::Device,
523        queue: &wgpu::Queue,
524        id: TextureId,
525        delta: &ImageDelta,
526    ) {
527        let (width, height) = (delta.image.width() as u32, delta.image.height() as u32);
528
529        let data: Vec<u8> = match &delta.image {
530            ImageData::Color(color_image) => color_image
531                .pixels
532                .iter()
533                .flat_map(|c| c.to_array())
534                .collect(),
535        };
536
537        if let Some(pos) = delta.pos {
538            // Partial update — write to existing texture
539            if let Some((texture, _)) = self.textures.get(&id) {
540                let [x, y] = pos;
541                queue.write_texture(
542                    wgpu::TexelCopyTextureInfo {
543                        texture,
544                        mip_level: 0,
545                        origin: wgpu::Origin3d {
546                            x: x as u32,
547                            y: y as u32,
548                            z: 0,
549                        },
550                        aspect: wgpu::TextureAspect::All,
551                    },
552                    &data,
553                    wgpu::TexelCopyBufferLayout {
554                        offset: 0,
555                        bytes_per_row: Some(4 * width),
556                        rows_per_image: None,
557                    },
558                    wgpu::Extent3d {
559                        width,
560                        height,
561                        depth_or_array_layers: 1,
562                    },
563                );
564            }
565            return;
566        }
567
568        // Full texture creation
569        let texture = device.create_texture(&wgpu::TextureDescriptor {
570            label: Some(&format!("egui_texture_{id:?}")),
571            size: wgpu::Extent3d {
572                width,
573                height,
574                depth_or_array_layers: 1,
575            },
576            mip_level_count: 1,
577            sample_count: 1,
578            dimension: wgpu::TextureDimension::D2,
579            format: wgpu::TextureFormat::Rgba8UnormSrgb,
580            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
581            view_formats: &[],
582        });
583
584        queue.write_texture(
585            wgpu::TexelCopyTextureInfo {
586                texture: &texture,
587                mip_level: 0,
588                origin: wgpu::Origin3d::ZERO,
589                aspect: wgpu::TextureAspect::All,
590            },
591            &data,
592            wgpu::TexelCopyBufferLayout {
593                offset: 0,
594                bytes_per_row: Some(4 * width),
595                rows_per_image: None,
596            },
597            wgpu::Extent3d {
598                width,
599                height,
600                depth_or_array_layers: 1,
601            },
602        );
603
604        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
605
606        let (Some(texture_bgl), Some(sampler)) = (
607            self.texture_bind_group_layout.as_ref(),
608            self.sampler.as_ref(),
609        ) else {
610            log::error!(
611                "EguiWgpuRenderer: set_texture called before initialize(); skipping upload"
612            );
613            return;
614        };
615
616        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
617            label: Some(&format!("egui_texture_bg_{id:?}")),
618            layout: texture_bgl,
619            entries: &[
620                wgpu::BindGroupEntry {
621                    binding: 0,
622                    resource: wgpu::BindingResource::TextureView(&view),
623                },
624                wgpu::BindGroupEntry {
625                    binding: 1,
626                    resource: wgpu::BindingResource::Sampler(sampler),
627                },
628            ],
629        });
630
631        self.textures.insert(id, (texture, bind_group));
632    }
633}
634
635/// Internal draw call descriptor.
636struct DrawCall {
637    texture_id: TextureId,
638    scissor: [u32; 4], // x, y, w, h
639    index_start: u32,
640    index_count: u32,
641}