ailloli_ui_render_vulkan/
renderer.rs

1//! Stateful Vulkan pipeline cache, frame upload, command recording, and submission.
2//!
3//! The renderer owns layouts, format-specific pipelines, and an optional glyph
4//! atlas, but never owns host targets, queues, or command pools. Rendering is
5//! synchronous: transient geometry is allocated per frame and the host queue is
6//! waited idle before those buffers and the framebuffer are destroyed.
7
8use std::collections::HashMap;
9use std::ffi::CString;
10use std::sync::Arc;
11
12use ailloli_ui_core::math::Scale;
13use ailloli_ui_core::Color;
14use ailloli_ui_runtime::Scene;
15use ash::vk;
16
17use crate::context::VulkanRenderContext;
18use crate::error::VulkanRendererError;
19use crate::frame_plan::{
20    build_frame_geometry, full_scissor, DrawBatch, FrameStats, LUCIDE_ICON_FACE_ID,
21};
22use crate::gpu::create_buffer_with_data;
23use crate::target::VulkanFrameTarget;
24use crate::text_atlas::TextAtlas;
25use crate::vertices::{BorderRRectVertex, BoxShadowVertex, RRectVertex, SolidVertex, TextVertex};
26
27/// Renderer configuration retained for the renderer lifetime.
28///
29/// # Examples
30///
31/// ```
32/// use ailloli_ui_render_vulkan::VulkanRendererOptions;
33/// let options = VulkanRendererOptions::default();
34/// assert!(!options.enable_debug_labels);
35/// ```
36#[derive(Debug, Clone, Copy)]
37pub struct VulkanRendererOptions {
38    /// Reserved debug-label switch; currently stored but does not record labels.
39    pub enable_debug_labels: bool,
40}
41
42/// Conservative defaults that avoid optional debug instrumentation.
43impl Default for VulkanRendererOptions {
44    /// Disables the reserved debug-label option.
45    fn default() -> Self {
46        Self {
47            enable_debug_labels: false,
48        }
49    }
50}
51
52/// Saturating lowering counters for the most recently processed scene.
53///
54/// The values update after geometry lowering, even if later Vulkan allocation or
55/// submission fails. A newly constructed renderer reports all zeros.
56///
57/// # Examples
58///
59/// ```
60/// use ailloli_ui_render_vulkan::VulkanRendererStats;
61/// let stats = VulkanRendererStats::default();
62/// assert_eq!((stats.rects_rendered, stats.glyphs_rendered, stats.commands_ignored), (0, 0, 0));
63/// ```
64#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
65pub struct VulkanRendererStats {
66    /// Solid/rounded fills and text-decoration rectangles emitted.
67    pub rects_rendered: u32,
68    /// Text and Lucide glyph quads emitted.
69    pub glyphs_rendered: u32,
70    /// Unsupported scene commands skipped.
71    pub commands_ignored: u32,
72}
73
74/// Converts internal frame counters without changing their units.
75impl From<FrameStats> for VulkanRendererStats {
76    /// Copies all three saturating counters.
77    fn from(stats: FrameStats) -> Self {
78        Self {
79            rects_rendered: stats.rects_rendered,
80            glyphs_rendered: stats.glyphs_rendered,
81            commands_ignored: stats.commands_ignored,
82        }
83    }
84}
85
86/// Copyable handles needed while recording one render pass.
87#[derive(Clone, Copy)]
88struct PipelineHandles {
89    /// Format-compatible single-color render pass.
90    render_pass: vk::RenderPass,
91    /// Solid rectangle pipeline.
92    rect_pipeline: vk::Pipeline,
93    /// Rounded fill pipeline.
94    rrect_pipeline: vk::Pipeline,
95    /// Rounded border pipeline.
96    border_pipeline: vk::Pipeline,
97    /// Box-shadow pipeline.
98    shadow_pipeline: vk::Pipeline,
99    /// Text-atlas pipeline.
100    text_pipeline: vk::Pipeline,
101    /// Descriptor-aware layout used by the text pipeline.
102    text_pipeline_layout: vk::PipelineLayout,
103}
104
105/// Pipelines and render pass cached for exactly one target format.
106struct FormatResources {
107    /// Pixel format for which all resources were created.
108    format: vk::Format,
109    /// Single-color render pass.
110    render_pass: vk::RenderPass,
111    /// Solid rectangle pipeline.
112    rect_pipeline: vk::Pipeline,
113    /// Rounded fill pipeline.
114    rrect_pipeline: vk::Pipeline,
115    /// Rounded border pipeline.
116    border_pipeline: vk::Pipeline,
117    /// Box-shadow pipeline.
118    shadow_pipeline: vk::Pipeline,
119    /// Text-atlas pipeline.
120    text_pipeline: vk::Pipeline,
121}
122
123/// Stateful renderer for host-owned Vulkan frame targets.
124///
125/// One format-specific pipeline set is cached at a time. Changing target format
126/// destroys the prior set and rebuilds all five pipelines. The glyph atlas is
127/// lazy and remains independent of target format.
128///
129/// # Examples
130///
131/// ```no_run
132/// use ailloli_ui_render_vulkan::{VulkanRenderContext, VulkanRenderer};
133/// fn build(context: &VulkanRenderContext<'_>) {
134///     let renderer: VulkanRenderer = VulkanRenderer::new(context, Default::default()).unwrap();
135///     assert_eq!(renderer.stats().rects_rendered, 0);
136/// }
137/// ```
138pub struct VulkanRenderer {
139    /// Device whose dispatch table owns every renderer-created handle.
140    device: ash::Device,
141    /// Immutable construction options.
142    options: VulkanRendererOptions,
143    /// Counters from the most recently lowered scene.
144    stats: VulkanRendererStats,
145    /// Caller-owned font blobs keyed by stable face ID.
146    text_face_blobs: Arc<HashMap<u64, Arc<[u8]>>>,
147    /// Descriptor-free layout shared by solid/SDF pipelines.
148    rect_pipeline_layout: vk::PipelineLayout,
149    /// One combined image/sampler binding used by atlas pages.
150    text_descriptor_set_layout: vk::DescriptorSetLayout,
151    /// Pipeline layout containing [`Self::text_descriptor_set_layout`].
152    text_pipeline_layout: vk::PipelineLayout,
153    /// Optional resources for the last rendered target format.
154    format_resources: Option<FormatResources>,
155    /// Lazily allocated bounded glyph atlas.
156    text_atlas: Option<TextAtlas>,
157}
158
159/// Debug output exposes options/stats without leaking raw Vulkan handles.
160impl std::fmt::Debug for VulkanRenderer {
161    /// Formats a non-exhaustive summary of stable diagnostic state.
162    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163        f.debug_struct("VulkanRenderer")
164            .field("options", &self.options)
165            .field("stats", &self.stats)
166            .finish_non_exhaustive()
167    }
168}
169
170/// Construction, configuration, statistics, and synchronous frame rendering.
171impl VulkanRenderer {
172    /// Creates pipeline layouts; pipelines and the glyph atlas remain lazy.
173    ///
174    /// `context.memory_properties` is not needed until a frame allocates geometry
175    /// or a glyph-atlas image, so construction can succeed without it.
176    ///
177    /// # Errors
178    ///
179    /// Returns a typed pipeline-layout or descriptor-layout error. Any layout
180    /// created before a later failure is destroyed before returning.
181    ///
182    /// # Examples
183    ///
184    /// ```no_run
185    /// use ailloli_ui_render_vulkan::{VulkanRenderContext, VulkanRenderer, VulkanRendererError};
186    /// fn build(context: &VulkanRenderContext<'_>) -> Result<VulkanRenderer, VulkanRendererError> {
187    ///     VulkanRenderer::new(context, Default::default())
188    /// }
189    /// ```
190    pub fn new(
191        context: &VulkanRenderContext<'_>,
192        options: VulkanRendererOptions,
193    ) -> Result<Self, VulkanRendererError> {
194        let rect_pipeline_layout = unsafe {
195            context
196                .device
197                .create_pipeline_layout(&vk::PipelineLayoutCreateInfo::default(), None)
198        }
199        .map_err(|result| VulkanRendererError::CreatePipelineLayout { result })?;
200
201        let bindings = [vk::DescriptorSetLayoutBinding::default()
202            .binding(0)
203            .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
204            .descriptor_count(1)
205            .stage_flags(vk::ShaderStageFlags::FRAGMENT)];
206        let text_descriptor_set_layout = match unsafe {
207            context.device.create_descriptor_set_layout(
208                &vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings),
209                None,
210            )
211        } {
212            Ok(layout) => layout,
213            Err(result) => {
214                unsafe {
215                    context
216                        .device
217                        .destroy_pipeline_layout(rect_pipeline_layout, None);
218                }
219                return Err(VulkanRendererError::CreateDescriptorSetLayout { result });
220            }
221        };
222
223        let set_layouts = [text_descriptor_set_layout];
224        let text_pipeline_layout = match unsafe {
225            context.device.create_pipeline_layout(
226                &vk::PipelineLayoutCreateInfo::default().set_layouts(&set_layouts),
227                None,
228            )
229        } {
230            Ok(layout) => layout,
231            Err(result) => {
232                unsafe {
233                    context
234                        .device
235                        .destroy_descriptor_set_layout(text_descriptor_set_layout, None);
236                    context
237                        .device
238                        .destroy_pipeline_layout(rect_pipeline_layout, None);
239                }
240                return Err(VulkanRendererError::CreatePipelineLayout { result });
241            }
242        };
243
244        Ok(Self {
245            device: context.device.clone(),
246            options,
247            stats: VulkanRendererStats::default(),
248            text_face_blobs: Arc::new(HashMap::new()),
249            rect_pipeline_layout,
250            text_descriptor_set_layout,
251            text_pipeline_layout,
252            format_resources: None,
253            text_atlas: None,
254        })
255    }
256
257    /// Returns the copyable construction options.
258    ///
259    /// # Examples
260    ///
261    /// ```no_run
262    /// use ailloli_ui_render_vulkan::VulkanRenderer;
263    /// fn inspect(renderer: &VulkanRenderer) {
264    ///     let enabled: bool = renderer.options().enable_debug_labels;
265    ///     assert!(!enabled);
266    /// }
267    /// ```
268    pub fn options(&self) -> VulkanRendererOptions {
269        self.options
270    }
271
272    /// Returns counters for the most recently lowered scene.
273    ///
274    /// # Examples
275    ///
276    /// ```no_run
277    /// use ailloli_ui_render_vulkan::{VulkanRenderer, VulkanRendererStats};
278    /// fn inspect(renderer: &VulkanRenderer) {
279    ///     let stats: VulkanRendererStats = renderer.stats();
280    ///     println!("glyphs: {}", stats.glyphs_rendered);
281    /// }
282    /// ```
283    pub fn stats(&self) -> VulkanRendererStats {
284        self.stats
285    }
286
287    /// Replaces the shared map used to resolve non-Lucide text face IDs.
288    ///
289    /// Existing atlas entries are not invalidated. Callers must assign a new
290    /// face ID when blob contents change, or construct a new renderer. Missing
291    /// IDs skip affected glyphs without failing the frame.
292    ///
293    /// # Examples
294    ///
295    /// ```no_run
296    /// use std::{collections::HashMap, sync::Arc};
297    /// use ailloli_ui_render_vulkan::VulkanRenderer;
298    /// fn install(renderer: &mut VulkanRenderer) {
299    ///     let blobs: Arc<HashMap<u64, Arc<[u8]>>> = Arc::new(HashMap::new());
300    ///     renderer.set_text_face_blobs(blobs);
301    /// }
302    /// ```
303    pub fn set_text_face_blobs(&mut self, blobs: Arc<HashMap<u64, Arc<[u8]>>>) {
304        self.text_face_blobs = blobs;
305    }
306
307    /// Clears and renders one scene into a host-owned target, then waits for queue idle.
308    ///
309    /// Logical scene coordinates are multiplied by `scale.dpr`. Target image and
310    /// view ownership remain with the host; the renderer records transitions from
311    /// `initial_layout` to color attachment and then to `final_layout`. Geometry
312    /// buffers and framebuffer are allocated per call and destroyed after the
313    /// synchronous submission. An empty scene still clears the target.
314    ///
315    /// # Errors
316    ///
317    /// Rejects null handles and zero extents before Vulkan calls. Then propagates
318    /// glyph, memory, pipeline, framebuffer, command, submission, and queue-idle
319    /// errors. The target's actual layout after a driver error is host-dependent.
320    ///
321    /// # Panics
322    ///
323    /// Panics only if successful text-atlas initialization fails to leave the
324    /// renderer's internal atlas slot populated.
325    ///
326    /// # Examples
327    ///
328    /// ```no_run
329    /// use ailloli_ui_core::{math::Scale, Color};
330    /// use ailloli_ui_render_vulkan::{VulkanFrameTarget, VulkanRenderContext, VulkanRenderer};
331    /// use ailloli_ui_runtime::Scene;
332    /// fn draw(renderer: &mut VulkanRenderer, context: &VulkanRenderContext<'_>,
333    ///     target: &VulkanFrameTarget, scene: &Scene) {
334    ///     renderer.render_scene(context, Color::BLACK, scene, Scale::new(1.0), target).unwrap();
335    /// }
336    /// ```
337    pub fn render_scene(
338        &mut self,
339        context: &VulkanRenderContext<'_>,
340        clear: Color,
341        scene: &Scene,
342        scale: Scale,
343        target: &VulkanFrameTarget,
344    ) -> Result<(), VulkanRendererError> {
345        validate_target(target)?;
346
347        let face_blobs = self.text_face_blobs.clone();
348        let text_descriptor_set_layout = self.text_descriptor_set_layout;
349        let text_atlas = &mut self.text_atlas;
350        let geometry = build_frame_geometry(scene, scale, target.extent, |key| {
351            let font_data: &[u8] = if key.face_id == LUCIDE_ICON_FACE_ID {
352                lucide_icons::LUCIDE_FONT_BYTES
353            } else {
354                let Some(font_data) = face_blobs.get(&key.face_id) else {
355                    return Ok(None);
356                };
357                font_data.as_ref()
358            };
359            if text_atlas.is_none() {
360                *text_atlas = Some(TextAtlas::new(context, text_descriptor_set_layout)?);
361            }
362            text_atlas
363                .as_mut()
364                .expect("text atlas exists")
365                .get_or_rasterize(context, key, font_data)
366        })?;
367        self.stats = geometry.stats.into();
368
369        let handles = self.ensure_format_resources(context, target.format)?;
370        let solid_buffer = create_buffer_with_data(
371            context,
372            vk::BufferUsageFlags::VERTEX_BUFFER,
373            bytemuck::cast_slice(&geometry.solid_vertices),
374        )?;
375        let rrect_buffer = create_buffer_with_data(
376            context,
377            vk::BufferUsageFlags::VERTEX_BUFFER,
378            bytemuck::cast_slice(&geometry.rrect_vertices),
379        )?;
380        let border_buffer = create_buffer_with_data(
381            context,
382            vk::BufferUsageFlags::VERTEX_BUFFER,
383            bytemuck::cast_slice(&geometry.border_vertices),
384        )?;
385        let shadow_buffer = create_buffer_with_data(
386            context,
387            vk::BufferUsageFlags::VERTEX_BUFFER,
388            bytemuck::cast_slice(&geometry.shadow_vertices),
389        )?;
390        let text_buffer = create_buffer_with_data(
391            context,
392            vk::BufferUsageFlags::VERTEX_BUFFER,
393            bytemuck::cast_slice(&geometry.text_vertices),
394        )?;
395
396        let framebuffer = create_framebuffer(
397            context.device,
398            handles.render_pass,
399            target.view,
400            target.extent,
401        )?;
402        let render_result = submit_one_time_commands(context, |command_buffer| unsafe {
403            record_render_pass(
404                context.device,
405                command_buffer,
406                handles,
407                framebuffer,
408                target,
409                clear,
410                &geometry.batches,
411                solid_buffer.as_ref().map(|buffer| buffer.buffer),
412                rrect_buffer.as_ref().map(|buffer| buffer.buffer),
413                border_buffer.as_ref().map(|buffer| buffer.buffer),
414                shadow_buffer.as_ref().map(|buffer| buffer.buffer),
415                text_buffer.as_ref().map(|buffer| buffer.buffer),
416                self.text_atlas.as_ref(),
417            );
418        });
419        unsafe {
420            context.device.destroy_framebuffer(framebuffer, None);
421        }
422        render_result
423    }
424
425    /// Returns cached handles or rebuilds all format-dependent resources.
426    ///
427    /// Only one format is retained. On any partial pipeline-build failure, every
428    /// resource created for the attempted format is destroyed and no cache is installed.
429    ///
430    /// # Errors
431    ///
432    /// Propagates render-pass, shader-module, or graphics-pipeline creation
433    /// failures from the Vulkan driver. A failed rebuild leaves the format cache
434    /// empty after destroying all resources created by that attempt.
435    fn ensure_format_resources(
436        &mut self,
437        context: &VulkanRenderContext<'_>,
438        format: vk::Format,
439    ) -> Result<PipelineHandles, VulkanRendererError> {
440        if self
441            .format_resources
442            .as_ref()
443            .map(|resources| resources.format == format)
444            .unwrap_or(false)
445        {
446            let resources = self.format_resources.as_ref().expect("checked resources");
447            return Ok(PipelineHandles {
448                render_pass: resources.render_pass,
449                rect_pipeline: resources.rect_pipeline,
450                rrect_pipeline: resources.rrect_pipeline,
451                border_pipeline: resources.border_pipeline,
452                shadow_pipeline: resources.shadow_pipeline,
453                text_pipeline: resources.text_pipeline,
454                text_pipeline_layout: self.text_pipeline_layout,
455            });
456        }
457
458        self.destroy_format_resources();
459        let render_pass = create_render_pass(context.device, format)?;
460        let rect_pipeline = create_graphics_pipeline(
461            context.device,
462            render_pass,
463            self.rect_pipeline_layout,
464            crate::shaders::SOLID_RECT_VERT_SPV,
465            crate::shaders::SOLID_RECT_FRAG_SPV,
466            SolidVertex::binding(),
467            &SolidVertex::attributes(),
468            true,
469        )?;
470        let rrect_pipeline = match create_graphics_pipeline(
471            context.device,
472            render_pass,
473            self.rect_pipeline_layout,
474            crate::shaders::ROUNDED_RECT_VERT_SPV,
475            crate::shaders::ROUNDED_RECT_FRAG_SPV,
476            RRectVertex::binding(),
477            &RRectVertex::attributes(),
478            true,
479        ) {
480            Ok(pipeline) => pipeline,
481            Err(error) => {
482                unsafe {
483                    context.device.destroy_pipeline(rect_pipeline, None);
484                    context.device.destroy_render_pass(render_pass, None);
485                }
486                return Err(error);
487            }
488        };
489        let border_pipeline = match create_graphics_pipeline(
490            context.device,
491            render_pass,
492            self.rect_pipeline_layout,
493            crate::shaders::BORDER_RRECT_VERT_SPV,
494            crate::shaders::BORDER_RRECT_FRAG_SPV,
495            BorderRRectVertex::binding(),
496            &BorderRRectVertex::attributes(),
497            true,
498        ) {
499            Ok(pipeline) => pipeline,
500            Err(error) => {
501                unsafe {
502                    context.device.destroy_pipeline(rrect_pipeline, None);
503                    context.device.destroy_pipeline(rect_pipeline, None);
504                    context.device.destroy_render_pass(render_pass, None);
505                }
506                return Err(error);
507            }
508        };
509        let shadow_pipeline = match create_graphics_pipeline(
510            context.device,
511            render_pass,
512            self.rect_pipeline_layout,
513            crate::shaders::BOX_SHADOW_VERT_SPV,
514            crate::shaders::BOX_SHADOW_FRAG_SPV,
515            BoxShadowVertex::binding(),
516            &BoxShadowVertex::attributes(),
517            true,
518        ) {
519            Ok(pipeline) => pipeline,
520            Err(error) => {
521                unsafe {
522                    context.device.destroy_pipeline(border_pipeline, None);
523                    context.device.destroy_pipeline(rrect_pipeline, None);
524                    context.device.destroy_pipeline(rect_pipeline, None);
525                    context.device.destroy_render_pass(render_pass, None);
526                }
527                return Err(error);
528            }
529        };
530        let text_pipeline = match create_graphics_pipeline(
531            context.device,
532            render_pass,
533            self.text_pipeline_layout,
534            crate::shaders::TEXTURED_TEXT_VERT_SPV,
535            crate::shaders::TEXTURED_TEXT_FRAG_SPV,
536            TextVertex::binding(),
537            &TextVertex::attributes(),
538            true,
539        ) {
540            Ok(pipeline) => pipeline,
541            Err(error) => {
542                unsafe {
543                    context.device.destroy_pipeline(shadow_pipeline, None);
544                    context.device.destroy_pipeline(border_pipeline, None);
545                    context.device.destroy_pipeline(rrect_pipeline, None);
546                    context.device.destroy_pipeline(rect_pipeline, None);
547                    context.device.destroy_render_pass(render_pass, None);
548                }
549                return Err(error);
550            }
551        };
552
553        self.format_resources = Some(FormatResources {
554            format,
555            render_pass,
556            rect_pipeline,
557            rrect_pipeline,
558            border_pipeline,
559            shadow_pipeline,
560            text_pipeline,
561        });
562        Ok(PipelineHandles {
563            render_pass,
564            rect_pipeline,
565            rrect_pipeline,
566            border_pipeline,
567            shadow_pipeline,
568            text_pipeline,
569            text_pipeline_layout: self.text_pipeline_layout,
570        })
571    }
572
573    /// Takes and destroys every format-specific pipeline before its render pass.
574    fn destroy_format_resources(&mut self) {
575        let Some(resources) = self.format_resources.take() else {
576            return;
577        };
578        unsafe {
579            if resources.rect_pipeline != vk::Pipeline::null() {
580                self.device.destroy_pipeline(resources.rect_pipeline, None);
581            }
582            if resources.rrect_pipeline != vk::Pipeline::null() {
583                self.device.destroy_pipeline(resources.rrect_pipeline, None);
584            }
585            if resources.border_pipeline != vk::Pipeline::null() {
586                self.device
587                    .destroy_pipeline(resources.border_pipeline, None);
588            }
589            if resources.shadow_pipeline != vk::Pipeline::null() {
590                self.device
591                    .destroy_pipeline(resources.shadow_pipeline, None);
592            }
593            if resources.text_pipeline != vk::Pipeline::null() {
594                self.device.destroy_pipeline(resources.text_pipeline, None);
595            }
596            if resources.render_pass != vk::RenderPass::null() {
597                self.device.destroy_render_pass(resources.render_pass, None);
598            }
599        }
600    }
601}
602
603/// Releases atlas, format resources, pipeline layouts, then descriptor layout.
604///
605/// The host must ensure no in-flight work references these objects; normal
606/// [`VulkanRenderer::render_scene`] calls wait for queue idle before returning.
607impl Drop for VulkanRenderer {
608    fn drop(&mut self) {
609        self.text_atlas.take();
610        self.destroy_format_resources();
611        unsafe {
612            if self.text_pipeline_layout != vk::PipelineLayout::null() {
613                self.device
614                    .destroy_pipeline_layout(self.text_pipeline_layout, None);
615            }
616            if self.text_descriptor_set_layout != vk::DescriptorSetLayout::null() {
617                self.device
618                    .destroy_descriptor_set_layout(self.text_descriptor_set_layout, None);
619            }
620            if self.rect_pipeline_layout != vk::PipelineLayout::null() {
621                self.device
622                    .destroy_pipeline_layout(self.rect_pipeline_layout, None);
623            }
624        }
625    }
626}
627
628/// Rejects null image/view handles and zero physical-pixel dimensions.
629///
630/// # Errors
631///
632/// Returns [`VulkanRendererError::InvalidTargetImage`] for a null image,
633/// [`VulkanRendererError::InvalidTargetView`] for a null view, or
634/// [`VulkanRendererError::InvalidTargetExtent`] when either extent is zero.
635fn validate_target(target: &VulkanFrameTarget) -> Result<(), VulkanRendererError> {
636    if target.image == vk::Image::null() {
637        return Err(VulkanRendererError::InvalidTargetImage);
638    }
639    if target.view == vk::ImageView::null() {
640        return Err(VulkanRendererError::InvalidTargetView);
641    }
642    if target.extent.width == 0 || target.extent.height == 0 {
643        return Err(VulkanRendererError::InvalidTargetExtent {
644            width: target.extent.width,
645            height: target.extent.height,
646        });
647    }
648    Ok(())
649}
650
651/// Creates a one-sample, one-color render pass that clears then stores.
652///
653/// The attachment is color-optimal at both render-pass boundaries because
654/// explicit barriers outside the pass handle the host-requested layouts.
655///
656/// # Errors
657///
658/// Returns [`VulkanRendererError::CreateRenderPass`] with the original driver
659/// result when `vkCreateRenderPass` fails.
660fn create_render_pass(
661    device: &ash::Device,
662    format: vk::Format,
663) -> Result<vk::RenderPass, VulkanRendererError> {
664    let attachment = [vk::AttachmentDescription::default()
665        .format(format)
666        .samples(vk::SampleCountFlags::TYPE_1)
667        .load_op(vk::AttachmentLoadOp::CLEAR)
668        .store_op(vk::AttachmentStoreOp::STORE)
669        .stencil_load_op(vk::AttachmentLoadOp::DONT_CARE)
670        .stencil_store_op(vk::AttachmentStoreOp::DONT_CARE)
671        .initial_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL)
672        .final_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL)];
673    let color_attachment = [vk::AttachmentReference::default()
674        .attachment(0)
675        .layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL)];
676    let subpass = [vk::SubpassDescription::default()
677        .pipeline_bind_point(vk::PipelineBindPoint::GRAPHICS)
678        .color_attachments(&color_attachment)];
679    let create_info = vk::RenderPassCreateInfo::default()
680        .attachments(&attachment)
681        .subpasses(&subpass);
682    unsafe { device.create_render_pass(&create_info, None) }
683        .map_err(|result| VulkanRendererError::CreateRenderPass { result })
684}
685
686/// Creates a triangle-list pipeline with dynamic viewport/scissor and no culling.
687///
688/// Shader modules are always destroyed before return. Any pipelines returned
689/// alongside a Vulkan creation error are also destroyed. The static `main`
690/// entry point cannot contain an interior NUL.
691///
692/// # Errors
693///
694/// Propagates [`VulkanRendererError::CreateShaderModule`] for either shader, or
695/// returns [`VulkanRendererError::CreateGraphicsPipeline`] with the original
696/// driver result when pipeline creation fails.
697///
698/// # Panics
699///
700/// Panics only if the hard-coded `main` entry point unexpectedly contains an
701/// interior NUL, or a Vulkan implementation reports successful pipeline
702/// creation without returning the single requested pipeline.
703fn create_graphics_pipeline(
704    device: &ash::Device,
705    render_pass: vk::RenderPass,
706    layout: vk::PipelineLayout,
707    vertex_spv: &[u32],
708    fragment_spv: &[u32],
709    binding: vk::VertexInputBindingDescription,
710    attributes: &[vk::VertexInputAttributeDescription],
711    alpha_blend: bool,
712) -> Result<vk::Pipeline, VulkanRendererError> {
713    let vertex_module = create_shader_module(device, vertex_spv)?;
714    let fragment_module = match create_shader_module(device, fragment_spv) {
715        Ok(module) => module,
716        Err(error) => {
717            unsafe {
718                device.destroy_shader_module(vertex_module, None);
719            }
720            return Err(error);
721        }
722    };
723    let main = CString::new("main").expect("static entry point");
724    let stages = [
725        vk::PipelineShaderStageCreateInfo::default()
726            .stage(vk::ShaderStageFlags::VERTEX)
727            .module(vertex_module)
728            .name(main.as_c_str()),
729        vk::PipelineShaderStageCreateInfo::default()
730            .stage(vk::ShaderStageFlags::FRAGMENT)
731            .module(fragment_module)
732            .name(main.as_c_str()),
733    ];
734    let bindings = [binding];
735    let vertex_input = vk::PipelineVertexInputStateCreateInfo::default()
736        .vertex_binding_descriptions(&bindings)
737        .vertex_attribute_descriptions(attributes);
738    let input_assembly = vk::PipelineInputAssemblyStateCreateInfo::default()
739        .topology(vk::PrimitiveTopology::TRIANGLE_LIST);
740    let viewport_state = vk::PipelineViewportStateCreateInfo::default()
741        .viewport_count(1)
742        .scissor_count(1);
743    let rasterization = vk::PipelineRasterizationStateCreateInfo::default()
744        .polygon_mode(vk::PolygonMode::FILL)
745        .cull_mode(vk::CullModeFlags::NONE)
746        .front_face(vk::FrontFace::COUNTER_CLOCKWISE)
747        .line_width(1.0);
748    let multisample = vk::PipelineMultisampleStateCreateInfo::default()
749        .rasterization_samples(vk::SampleCountFlags::TYPE_1);
750    let color_attachment = [color_blend_attachment(alpha_blend)];
751    let color_blend =
752        vk::PipelineColorBlendStateCreateInfo::default().attachments(&color_attachment);
753    let dynamic_states = [vk::DynamicState::VIEWPORT, vk::DynamicState::SCISSOR];
754    let dynamic_state =
755        vk::PipelineDynamicStateCreateInfo::default().dynamic_states(&dynamic_states);
756    let pipeline_info = [vk::GraphicsPipelineCreateInfo::default()
757        .stages(&stages)
758        .vertex_input_state(&vertex_input)
759        .input_assembly_state(&input_assembly)
760        .viewport_state(&viewport_state)
761        .rasterization_state(&rasterization)
762        .multisample_state(&multisample)
763        .color_blend_state(&color_blend)
764        .dynamic_state(&dynamic_state)
765        .layout(layout)
766        .render_pass(render_pass)
767        .subpass(0)];
768
769    let result = unsafe {
770        device.create_graphics_pipelines(vk::PipelineCache::null(), &pipeline_info, None)
771    };
772    unsafe {
773        device.destroy_shader_module(fragment_module, None);
774        device.destroy_shader_module(vertex_module, None);
775    }
776    match result {
777        Ok(mut pipelines) => Ok(pipelines.remove(0)),
778        Err((pipelines, result)) => {
779            unsafe {
780                for pipeline in pipelines {
781                    device.destroy_pipeline(pipeline, None);
782                }
783            }
784            Err(VulkanRendererError::CreateGraphicsPipeline { result })
785        }
786    }
787}
788
789/// Enables all RGBA writes and optional straight-alpha source-over blending.
790///
791/// Color uses `src_alpha`/`one_minus_src_alpha`; alpha uses
792/// `one`/`one_minus_src_alpha`. `false` disables blending without changing masks.
793fn color_blend_attachment(alpha_blend: bool) -> vk::PipelineColorBlendAttachmentState {
794    let mut attachment = vk::PipelineColorBlendAttachmentState::default().color_write_mask(
795        vk::ColorComponentFlags::R
796            | vk::ColorComponentFlags::G
797            | vk::ColorComponentFlags::B
798            | vk::ColorComponentFlags::A,
799    );
800    if alpha_blend {
801        attachment = attachment
802            .blend_enable(true)
803            .src_color_blend_factor(vk::BlendFactor::SRC_ALPHA)
804            .dst_color_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA)
805            .color_blend_op(vk::BlendOp::ADD)
806            .src_alpha_blend_factor(vk::BlendFactor::ONE)
807            .dst_alpha_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA)
808            .alpha_blend_op(vk::BlendOp::ADD);
809    }
810    attachment
811}
812
813/// Creates one shader module from SPIR-V words and preserves the driver result on error.
814///
815/// # Errors
816///
817/// Returns [`VulkanRendererError::CreateShaderModule`] with the original driver
818/// result when `vkCreateShaderModule` fails.
819fn create_shader_module(
820    device: &ash::Device,
821    code: &[u32],
822) -> Result<vk::ShaderModule, VulkanRendererError> {
823    unsafe { device.create_shader_module(&vk::ShaderModuleCreateInfo::default().code(code), None) }
824        .map_err(|result| VulkanRendererError::CreateShaderModule { result })
825}
826
827/// Creates a single-layer framebuffer over the exact target extent and view.
828///
829/// # Errors
830///
831/// Returns [`VulkanRendererError::CreateFramebuffer`] with the original driver
832/// result when `vkCreateFramebuffer` fails.
833fn create_framebuffer(
834    device: &ash::Device,
835    render_pass: vk::RenderPass,
836    view: vk::ImageView,
837    extent: vk::Extent2D,
838) -> Result<vk::Framebuffer, VulkanRendererError> {
839    let attachments = [view];
840    let create_info = vk::FramebufferCreateInfo::default()
841        .render_pass(render_pass)
842        .attachments(&attachments)
843        .width(extent.width)
844        .height(extent.height)
845        .layers(1);
846    unsafe { device.create_framebuffer(&create_info, None) }
847        .map_err(|result| VulkanRendererError::CreateFramebuffer { result })
848}
849
850/// Records one primary command buffer, submits it, and synchronously waits idle.
851///
852/// The command buffer is freed for all recoverable results after allocation.
853/// A panic in the recording closure unwinds before explicit freeing.
854///
855/// # Errors
856///
857/// Returns the matching allocate, begin, end, queue-submit, or queue-idle
858/// [`VulkanRendererError`] with the original driver result.
859///
860/// # Panics
861///
862/// Propagates a panic from `record`. It may also panic if a Vulkan implementation
863/// reports successful allocation without returning the one requested command
864/// buffer; neither path reaches this helper's explicit free operation.
865fn submit_one_time_commands<F>(
866    context: &VulkanRenderContext<'_>,
867    record: F,
868) -> Result<(), VulkanRendererError>
869where
870    F: FnOnce(vk::CommandBuffer),
871{
872    let command_buffer = unsafe {
873        context.device.allocate_command_buffers(
874            &vk::CommandBufferAllocateInfo::default()
875                .command_pool(context.command_pool)
876                .level(vk::CommandBufferLevel::PRIMARY)
877                .command_buffer_count(1),
878        )
879    }
880    .map_err(|result| VulkanRendererError::AllocateCommandBuffer { result })?[0];
881
882    let result = (|| {
883        unsafe {
884            context.device.begin_command_buffer(
885                command_buffer,
886                &vk::CommandBufferBeginInfo::default()
887                    .flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT),
888            )
889        }
890        .map_err(|result| VulkanRendererError::BeginCommandBuffer { result })?;
891        record(command_buffer);
892        unsafe { context.device.end_command_buffer(command_buffer) }
893            .map_err(|result| VulkanRendererError::EndCommandBuffer { result })?;
894        let command_buffers = [command_buffer];
895        let submit_infos = [vk::SubmitInfo::default().command_buffers(&command_buffers)];
896        unsafe {
897            context
898                .device
899                .queue_submit(context.queue, &submit_infos, vk::Fence::null())
900        }
901        .map_err(|result| VulkanRendererError::QueueSubmit { result })?;
902        unsafe { context.device.queue_wait_idle(context.queue) }
903            .map_err(|result| VulkanRendererError::QueueWaitIdle { result })?;
904        Ok(())
905    })();
906
907    unsafe {
908        context
909            .device
910            .free_command_buffers(context.command_pool, &[command_buffer]);
911    }
912    result
913}
914
915/// Records target transitions, clear, ordered batches, and the final transition.
916///
917/// A missing type-specific buffer or atlas descriptor skips that batch
918/// defensively. The viewport covers the full physical extent and each batch sets
919/// its own scissor.
920///
921/// # Safety
922///
923/// All handles must be live and belong to `device`; `command_buffer` must be
924/// recording; pipeline/render-pass/framebuffer compatibility and vertex ranges
925/// must match the supplied geometry; and target layout ownership must follow the
926/// host contract.
927unsafe fn record_render_pass(
928    device: &ash::Device,
929    command_buffer: vk::CommandBuffer,
930    pipelines: PipelineHandles,
931    framebuffer: vk::Framebuffer,
932    target: &VulkanFrameTarget,
933    clear: Color,
934    batches: &[DrawBatch],
935    solid_buffer: Option<vk::Buffer>,
936    rrect_buffer: Option<vk::Buffer>,
937    border_buffer: Option<vk::Buffer>,
938    shadow_buffer: Option<vk::Buffer>,
939    text_buffer: Option<vk::Buffer>,
940    text_atlas: Option<&TextAtlas>,
941) {
942    transition_target(
943        device,
944        command_buffer,
945        target.image,
946        target.initial_layout,
947        vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL,
948    );
949
950    let clear_values = [vk::ClearValue {
951        color: vk::ClearColorValue {
952            float32: clear.to_array(),
953        },
954    }];
955    let render_area = full_scissor(target.extent);
956    let begin_info = vk::RenderPassBeginInfo::default()
957        .render_pass(pipelines.render_pass)
958        .framebuffer(framebuffer)
959        .render_area(render_area)
960        .clear_values(&clear_values);
961    device.cmd_begin_render_pass(command_buffer, &begin_info, vk::SubpassContents::INLINE);
962    let viewport = vk::Viewport {
963        x: 0.0,
964        y: 0.0,
965        width: target.extent.width as f32,
966        height: target.extent.height as f32,
967        min_depth: 0.0,
968        max_depth: 1.0,
969    };
970    device.cmd_set_viewport(command_buffer, 0, &[viewport]);
971
972    for batch in batches {
973        match *batch {
974            DrawBatch::Solid {
975                first_vertex,
976                vertex_count,
977                scissor,
978            } => {
979                let Some(buffer) = solid_buffer else {
980                    continue;
981                };
982                device.cmd_bind_pipeline(
983                    command_buffer,
984                    vk::PipelineBindPoint::GRAPHICS,
985                    pipelines.rect_pipeline,
986                );
987                device.cmd_set_scissor(command_buffer, 0, &[scissor]);
988                device.cmd_bind_vertex_buffers(command_buffer, 0, &[buffer], &[0]);
989                device.cmd_draw(command_buffer, vertex_count, 1, first_vertex, 0);
990            }
991            DrawBatch::RRect {
992                first_vertex,
993                vertex_count,
994                scissor,
995            } => {
996                let Some(buffer) = rrect_buffer else {
997                    continue;
998                };
999                device.cmd_bind_pipeline(
1000                    command_buffer,
1001                    vk::PipelineBindPoint::GRAPHICS,
1002                    pipelines.rrect_pipeline,
1003                );
1004                device.cmd_set_scissor(command_buffer, 0, &[scissor]);
1005                device.cmd_bind_vertex_buffers(command_buffer, 0, &[buffer], &[0]);
1006                device.cmd_draw(command_buffer, vertex_count, 1, first_vertex, 0);
1007            }
1008            DrawBatch::BorderRRect {
1009                first_vertex,
1010                vertex_count,
1011                scissor,
1012            } => {
1013                let Some(buffer) = border_buffer else {
1014                    continue;
1015                };
1016                device.cmd_bind_pipeline(
1017                    command_buffer,
1018                    vk::PipelineBindPoint::GRAPHICS,
1019                    pipelines.border_pipeline,
1020                );
1021                device.cmd_set_scissor(command_buffer, 0, &[scissor]);
1022                device.cmd_bind_vertex_buffers(command_buffer, 0, &[buffer], &[0]);
1023                device.cmd_draw(command_buffer, vertex_count, 1, first_vertex, 0);
1024            }
1025            DrawBatch::BoxShadow {
1026                first_vertex,
1027                vertex_count,
1028                scissor,
1029            } => {
1030                let Some(buffer) = shadow_buffer else {
1031                    continue;
1032                };
1033                device.cmd_bind_pipeline(
1034                    command_buffer,
1035                    vk::PipelineBindPoint::GRAPHICS,
1036                    pipelines.shadow_pipeline,
1037                );
1038                device.cmd_set_scissor(command_buffer, 0, &[scissor]);
1039                device.cmd_bind_vertex_buffers(command_buffer, 0, &[buffer], &[0]);
1040                device.cmd_draw(command_buffer, vertex_count, 1, first_vertex, 0);
1041            }
1042            DrawBatch::Text {
1043                page,
1044                first_vertex,
1045                vertex_count,
1046                scissor,
1047            } => {
1048                let (Some(buffer), Some(atlas)) = (text_buffer, text_atlas) else {
1049                    continue;
1050                };
1051                let Some(descriptor_set) = atlas.descriptor_set(page) else {
1052                    continue;
1053                };
1054                device.cmd_bind_pipeline(
1055                    command_buffer,
1056                    vk::PipelineBindPoint::GRAPHICS,
1057                    pipelines.text_pipeline,
1058                );
1059                device.cmd_set_scissor(command_buffer, 0, &[scissor]);
1060                device.cmd_bind_descriptor_sets(
1061                    command_buffer,
1062                    vk::PipelineBindPoint::GRAPHICS,
1063                    pipelines.text_pipeline_layout,
1064                    0,
1065                    &[descriptor_set],
1066                    &[],
1067                );
1068                device.cmd_bind_vertex_buffers(command_buffer, 0, &[buffer], &[0]);
1069                device.cmd_draw(command_buffer, vertex_count, 1, first_vertex, 0);
1070            }
1071        }
1072    }
1073    device.cmd_end_render_pass(command_buffer);
1074
1075    transition_target(
1076        device,
1077        command_buffer,
1078        target.image,
1079        vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL,
1080        target.final_layout,
1081    );
1082}
1083
1084/// Records a one-mip, one-layer color-image barrier unless layouts are equal.
1085///
1086/// # Safety
1087///
1088/// The image and recording command buffer must belong to `device`, and
1089/// `old_layout` must describe the target's actual current layout.
1090unsafe fn transition_target(
1091    device: &ash::Device,
1092    command_buffer: vk::CommandBuffer,
1093    image: vk::Image,
1094    old_layout: vk::ImageLayout,
1095    new_layout: vk::ImageLayout,
1096) {
1097    if old_layout == new_layout {
1098        return;
1099    }
1100    let barrier = vk::ImageMemoryBarrier::default()
1101        .image(image)
1102        .old_layout(old_layout)
1103        .new_layout(new_layout)
1104        .src_access_mask(access_mask_for_layout(old_layout))
1105        .dst_access_mask(access_mask_for_layout(new_layout))
1106        .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
1107        .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
1108        .subresource_range(vk::ImageSubresourceRange {
1109            aspect_mask: vk::ImageAspectFlags::COLOR,
1110            base_mip_level: 0,
1111            level_count: 1,
1112            base_array_layer: 0,
1113            layer_count: 1,
1114        });
1115    device.cmd_pipeline_barrier(
1116        command_buffer,
1117        pipeline_stage_for_layout(old_layout),
1118        pipeline_stage_for_layout(new_layout),
1119        vk::DependencyFlags::empty(),
1120        &[],
1121        &[],
1122        &[barrier],
1123    );
1124}
1125
1126/// Maps known layouts to exact access masks and falls back to memory read/write.
1127fn access_mask_for_layout(layout: vk::ImageLayout) -> vk::AccessFlags {
1128    match layout {
1129        vk::ImageLayout::UNDEFINED => vk::AccessFlags::empty(),
1130        vk::ImageLayout::TRANSFER_DST_OPTIMAL => vk::AccessFlags::TRANSFER_WRITE,
1131        vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL => vk::AccessFlags::COLOR_ATTACHMENT_WRITE,
1132        vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL => vk::AccessFlags::SHADER_READ,
1133        vk::ImageLayout::GENERAL => vk::AccessFlags::MEMORY_READ | vk::AccessFlags::MEMORY_WRITE,
1134        _ => vk::AccessFlags::MEMORY_READ | vk::AccessFlags::MEMORY_WRITE,
1135    }
1136}
1137
1138/// Maps known layouts to precise stages and falls back to all commands.
1139fn pipeline_stage_for_layout(layout: vk::ImageLayout) -> vk::PipelineStageFlags {
1140    match layout {
1141        vk::ImageLayout::UNDEFINED => vk::PipelineStageFlags::TOP_OF_PIPE,
1142        vk::ImageLayout::TRANSFER_DST_OPTIMAL => vk::PipelineStageFlags::TRANSFER,
1143        vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL => {
1144            vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT
1145        }
1146        vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL => vk::PipelineStageFlags::FRAGMENT_SHADER,
1147        vk::ImageLayout::GENERAL => vk::PipelineStageFlags::ALL_COMMANDS,
1148        _ => vk::PipelineStageFlags::ALL_COMMANDS,
1149    }
1150}