Skip to main content

aetna_vulkano/
surface.rs

1//! GPU compositing for app-owned [`AppTexture`]s on the vulkano
2//! backend. Mirrors `aetna-wgpu/src/surface.rs`.
3//!
4//! Three pipelines are built up front (one per [`SurfaceAlpha`])
5//! sharing a single `stock::surface` shader module; the dispatch loop
6//! picks the matching pipeline per run. Bind groups (descriptor sets
7//! here) are cached on [`AppTextureId`]; cache entries unreferenced
8//! for one frame are dropped at flush.
9
10use std::any::Any;
11use std::collections::HashMap;
12use std::ops::Range;
13use std::sync::Arc;
14
15use aetna_core::affine::Affine2;
16use aetna_core::paint::PhysicalScissor;
17use aetna_core::shader::stock_wgsl;
18use aetna_core::surface::{
19    AppTexture, AppTextureBackend, AppTextureId, SurfaceAlpha, SurfaceFormat, next_app_texture_id,
20};
21use aetna_core::tree::Rect;
22use bytemuck::{Pod, Zeroable};
23use vulkano::{
24    buffer::{
25        BufferUsage, Subbuffer,
26        allocator::{SubbufferAllocator, SubbufferAllocatorCreateInfo},
27    },
28    descriptor_set::{
29        DescriptorSet, WriteDescriptorSet, allocator::StandardDescriptorSetAllocator,
30    },
31    device::Device,
32    format::Format,
33    image::{
34        Image as VkImage, ImageAspects, ImageSubresourceRange, ImageUsage,
35        sampler::{Filter, Sampler, SamplerAddressMode, SamplerCreateInfo, SamplerMipmapMode},
36        view::{ImageView, ImageViewCreateInfo},
37    },
38    memory::allocator::{MemoryTypeFilter, StandardMemoryAllocator},
39    pipeline::{
40        DynamicState, GraphicsPipeline, Pipeline, PipelineShaderStageCreateInfo,
41        graphics::{
42            GraphicsPipelineCreateInfo,
43            color_blend::{
44                AttachmentBlend, BlendFactor, BlendOp, ColorBlendAttachmentState, ColorBlendState,
45            },
46            input_assembly::{InputAssemblyState, PrimitiveTopology},
47            rasterization::RasterizationState,
48            subpass::PipelineSubpassType,
49            vertex_input::{
50                VertexInputAttributeDescription, VertexInputBindingDescription, VertexInputRate,
51                VertexInputState,
52            },
53            viewport::ViewportState,
54        },
55    },
56    render_pass::Subpass,
57    shader::{ShaderModule, ShaderModuleCreateInfo},
58};
59
60use crate::naga_compile::wgsl_to_spirv;
61use crate::pipeline::{build_shared_pipeline_layout, multisample_state};
62
63const INSTANCE_ARENA_SIZE: u64 = 32 * 1024;
64
65#[repr(C)]
66#[derive(Copy, Clone, Pod, Zeroable, Debug)]
67pub(crate) struct SurfaceInstance {
68    rect: [f32; 4],
69    matrix: [f32; 4],
70    translation: [f32; 2],
71}
72
73pub(crate) struct SurfaceRun {
74    pub texture_idx: usize,
75    pub scissor: Option<PhysicalScissor>,
76    pub alpha: SurfaceAlpha,
77    pub first: u32,
78    pub count: u32,
79}
80
81struct CachedDescriptor {
82    descriptor_set: Arc<DescriptorSet>,
83    last_used_frame: u64,
84}
85
86pub(crate) struct SurfacePaint {
87    instances: Vec<SurfaceInstance>,
88    instance_alloc: SubbufferAllocator,
89    instance_buf: Option<Subbuffer<[SurfaceInstance]>>,
90    runs: Vec<SurfaceRun>,
91
92    pipeline_premul: Arc<GraphicsPipeline>,
93    pipeline_straight: Arc<GraphicsPipeline>,
94    pipeline_opaque: Arc<GraphicsPipeline>,
95    sampler: Arc<Sampler>,
96
97    cache: HashMap<u64, CachedDescriptor>,
98    bind_group_lookup: Vec<u64>,
99    frame_counter: u64,
100
101    descriptor_alloc: Arc<StandardDescriptorSetAllocator>,
102}
103
104impl SurfacePaint {
105    pub(crate) fn new(
106        device: Arc<Device>,
107        memory_alloc: Arc<StandardMemoryAllocator>,
108        descriptor_alloc: Arc<StandardDescriptorSetAllocator>,
109        subpass: Subpass,
110        sample_count: u32,
111    ) -> Self {
112        let (pipeline_premul, pipeline_straight, pipeline_opaque) =
113            build_surface_pipelines(device.clone(), subpass, sample_count);
114        let sampler = Sampler::new(
115            device,
116            SamplerCreateInfo {
117                mag_filter: Filter::Linear,
118                min_filter: Filter::Linear,
119                mipmap_mode: SamplerMipmapMode::Linear,
120                address_mode: [SamplerAddressMode::ClampToEdge; 3],
121                ..Default::default()
122            },
123        )
124        .expect("aetna-vulkano: surface sampler");
125        let instance_alloc = SubbufferAllocator::new(
126            memory_alloc,
127            SubbufferAllocatorCreateInfo {
128                arena_size: INSTANCE_ARENA_SIZE,
129                buffer_usage: BufferUsage::VERTEX_BUFFER,
130                memory_type_filter: MemoryTypeFilter::PREFER_HOST
131                    | MemoryTypeFilter::HOST_SEQUENTIAL_WRITE,
132                ..Default::default()
133            },
134        );
135
136        Self {
137            instances: Vec::new(),
138            instance_alloc,
139            instance_buf: None,
140            runs: Vec::new(),
141            pipeline_premul,
142            pipeline_straight,
143            pipeline_opaque,
144            sampler,
145            cache: HashMap::new(),
146            bind_group_lookup: Vec::new(),
147            frame_counter: 0,
148            descriptor_alloc,
149        }
150    }
151
152    pub(crate) fn frame_begin(&mut self) {
153        self.instances.clear();
154        self.runs.clear();
155        self.bind_group_lookup.clear();
156        self.frame_counter = self.frame_counter.wrapping_add(1);
157    }
158
159    pub(crate) fn record(
160        &mut self,
161        rect: Rect,
162        scissor: Option<PhysicalScissor>,
163        texture: &AppTexture,
164        alpha: SurfaceAlpha,
165        transform: Affine2,
166    ) -> Range<usize> {
167        if rect.w <= 0.0 || rect.h <= 0.0 {
168            let start = self.runs.len();
169            return start..start;
170        }
171        let start = self.runs.len();
172        let texture_idx = self.ensure_descriptor(texture);
173        let instance = SurfaceInstance {
174            rect: [rect.x, rect.y, rect.w, rect.h],
175            matrix: [transform.a, transform.b, transform.c, transform.d],
176            translation: [transform.tx, transform.ty],
177        };
178        let first = self.instances.len() as u32;
179        self.instances.push(instance);
180        self.runs.push(SurfaceRun {
181            texture_idx,
182            scissor,
183            alpha,
184            first,
185            count: 1,
186        });
187        start..self.runs.len()
188    }
189
190    fn ensure_descriptor(&mut self, texture: &AppTexture) -> usize {
191        let id = texture.id().0;
192        if !self.cache.contains_key(&id) {
193            let backend = texture.backend();
194            let vk_tex = backend
195                .as_any()
196                .downcast_ref::<VulkanoAppTexture>()
197                .unwrap_or_else(|| {
198                    panic!(
199                        "AppTexture passed to aetna-vulkano was not constructed by \
200                         aetna_vulkano::app_texture (actual backend: {}); mixing \
201                         backends in one runtime is unsupported",
202                        texture.backend_name(),
203                    )
204                });
205            let descriptor_set = DescriptorSet::new(
206                self.descriptor_alloc.clone(),
207                self.pipeline_premul.layout().set_layouts()[1].clone(),
208                [
209                    WriteDescriptorSet::image_view(0, vk_tex.view.clone()),
210                    WriteDescriptorSet::sampler(1, self.sampler.clone()),
211                ],
212                [],
213            )
214            .expect("aetna-vulkano: surface descriptor set");
215            self.cache.insert(
216                id,
217                CachedDescriptor {
218                    descriptor_set,
219                    last_used_frame: 0,
220                },
221            );
222        }
223        let entry = self.cache.get_mut(&id).expect("just inserted");
224        entry.last_used_frame = self.frame_counter;
225        if let Some(idx) = self.bind_group_lookup.iter().position(|&i| i == id) {
226            idx
227        } else {
228            self.bind_group_lookup.push(id);
229            self.bind_group_lookup.len() - 1
230        }
231    }
232
233    pub(crate) fn flush(&mut self) {
234        let frame = self.frame_counter;
235        self.cache.retain(|_, v| v.last_used_frame == frame);
236
237        if self.instances.is_empty() {
238            self.instance_buf = None;
239            return;
240        }
241        let buf = self
242            .instance_alloc
243            .allocate_slice::<SurfaceInstance>(self.instances.len() as u64)
244            .expect("aetna-vulkano: surface instance suballocate");
245        buf.write()
246            .expect("aetna-vulkano: surface instance suballocation write")
247            .copy_from_slice(&self.instances);
248        self.instance_buf = Some(buf);
249    }
250
251    pub(crate) fn run(&self, index: usize) -> &SurfaceRun {
252        &self.runs[index]
253    }
254
255    pub(crate) fn pipeline_for(&self, alpha: SurfaceAlpha) -> &Arc<GraphicsPipeline> {
256        match alpha {
257            SurfaceAlpha::Premultiplied => &self.pipeline_premul,
258            SurfaceAlpha::Straight => &self.pipeline_straight,
259            SurfaceAlpha::Opaque => &self.pipeline_opaque,
260        }
261    }
262
263    /// Per-frame instance suballocation. Panics if called for a frame
264    /// with no surface draws — bind sites are gated by
265    /// `PaintItem::AppTexture`, which `record(...)` only emits when
266    /// `instances` is non-empty.
267    pub(crate) fn instance_buf(&self) -> &Subbuffer<[SurfaceInstance]> {
268        self.instance_buf
269            .as_ref()
270            .expect("aetna-vulkano: surface instance_buf accessed with no draws")
271    }
272
273    pub(crate) fn descriptor_for_run(&self, run: &SurfaceRun) -> &Arc<DescriptorSet> {
274        let id = self.bind_group_lookup[run.texture_idx];
275        &self
276            .cache
277            .get(&id)
278            .expect("cache entry alive for the frame")
279            .descriptor_set
280    }
281}
282
283fn build_surface_pipelines(
284    device: Arc<Device>,
285    subpass: Subpass,
286    sample_count: u32,
287) -> (
288    Arc<GraphicsPipeline>,
289    Arc<GraphicsPipeline>,
290    Arc<GraphicsPipeline>,
291) {
292    let words = wgsl_to_spirv("stock::surface", stock_wgsl::SURFACE)
293        .expect("aetna-vulkano: surface WGSL compile");
294    let module = unsafe {
295        ShaderModule::new(device.clone(), ShaderModuleCreateInfo::new(&words))
296            .expect("aetna-vulkano: surface ShaderModule::new")
297    };
298    let premul = build_one(
299        device.clone(),
300        subpass.clone(),
301        sample_count,
302        &module,
303        "fs_premul",
304        Some(premultiplied_blend()),
305    );
306    let straight = build_one(
307        device.clone(),
308        subpass.clone(),
309        sample_count,
310        &module,
311        "fs_straight",
312        Some(premultiplied_blend()),
313    );
314    let opaque = build_one(
315        device,
316        subpass,
317        sample_count,
318        &module,
319        "fs_opaque",
320        Some(opaque_blend()),
321    );
322    (premul, straight, opaque)
323}
324
325fn build_one(
326    device: Arc<Device>,
327    subpass: Subpass,
328    sample_count: u32,
329    module: &Arc<ShaderModule>,
330    fs_entry: &'static str,
331    blend: Option<AttachmentBlend>,
332) -> Arc<GraphicsPipeline> {
333    let vs = module
334        .entry_point("vs_main")
335        .expect("surface.wgsl: missing vs_main");
336    let fs = module
337        .entry_point(fs_entry)
338        .unwrap_or_else(|| panic!("surface.wgsl: missing {fs_entry}"));
339    let stages = [
340        PipelineShaderStageCreateInfo::new(vs),
341        PipelineShaderStageCreateInfo::new(fs),
342    ];
343    let layout = build_shared_pipeline_layout(device.clone(), &stages);
344
345    let bind_vertex = VertexInputBindingDescription {
346        stride: (2 * std::mem::size_of::<f32>()) as u32,
347        input_rate: VertexInputRate::Vertex,
348        ..Default::default()
349    };
350    let bind_instance = VertexInputBindingDescription {
351        stride: std::mem::size_of::<SurfaceInstance>() as u32,
352        input_rate: VertexInputRate::Instance { divisor: 1 },
353        ..Default::default()
354    };
355    let attr = |binding: u32, offset: u32, format: Format| VertexInputAttributeDescription {
356        binding,
357        offset,
358        format,
359        ..Default::default()
360    };
361    let vertex_input_state = VertexInputState::new()
362        .binding(0, bind_vertex)
363        .binding(1, bind_instance)
364        .attribute(0, attr(0, 0, Format::R32G32_SFLOAT))
365        // location 1: rect @ offset 0 (4*f32 = 16)
366        .attribute(1, attr(1, 0, Format::R32G32B32A32_SFLOAT))
367        // location 2: affine matrix @ offset 16 (4*f32 = 16)
368        .attribute(2, attr(1, 16, Format::R32G32B32A32_SFLOAT))
369        // location 3: affine translation @ offset 32 (2*f32 = 8)
370        .attribute(3, attr(1, 32, Format::R32G32_SFLOAT));
371
372    GraphicsPipeline::new(
373        device,
374        None,
375        GraphicsPipelineCreateInfo {
376            stages: stages.into_iter().collect(),
377            vertex_input_state: Some(vertex_input_state),
378            input_assembly_state: Some(InputAssemblyState {
379                topology: PrimitiveTopology::TriangleStrip,
380                ..Default::default()
381            }),
382            viewport_state: Some(ViewportState::default()),
383            rasterization_state: Some(RasterizationState::default()),
384            multisample_state: Some(multisample_state(sample_count)),
385            color_blend_state: Some(ColorBlendState::with_attachment_states(
386                subpass.num_color_attachments(),
387                ColorBlendAttachmentState {
388                    blend,
389                    ..Default::default()
390                },
391            )),
392            dynamic_state: [DynamicState::Viewport, DynamicState::Scissor]
393                .into_iter()
394                .collect(),
395            subpass: Some(PipelineSubpassType::BeginRenderPass(subpass)),
396            ..GraphicsPipelineCreateInfo::layout(layout)
397        },
398    )
399    .expect("aetna-vulkano: surface GraphicsPipeline::new")
400}
401
402fn premultiplied_blend() -> AttachmentBlend {
403    AttachmentBlend {
404        src_color_blend_factor: BlendFactor::One,
405        dst_color_blend_factor: BlendFactor::OneMinusSrcAlpha,
406        color_blend_op: BlendOp::Add,
407        src_alpha_blend_factor: BlendFactor::One,
408        dst_alpha_blend_factor: BlendFactor::OneMinusSrcAlpha,
409        alpha_blend_op: BlendOp::Add,
410    }
411}
412
413fn opaque_blend() -> AttachmentBlend {
414    AttachmentBlend {
415        src_color_blend_factor: BlendFactor::One,
416        dst_color_blend_factor: BlendFactor::Zero,
417        color_blend_op: BlendOp::Add,
418        src_alpha_blend_factor: BlendFactor::One,
419        dst_alpha_blend_factor: BlendFactor::Zero,
420        alpha_blend_op: BlendOp::Add,
421    }
422}
423
424// ---- Public AppTexture constructor ----
425
426/// Concrete vulkano-side [`AppTextureBackend`]. Holds the image + a
427/// default-view + cached id so the runtime can downcast and bind the
428/// view directly into a descriptor set.
429#[derive(Debug)]
430pub struct VulkanoAppTexture {
431    /// The app-owned image. Held as `Arc` so `AppTexture` can be
432    /// cheaply cloned into the El tree without releasing the GPU
433    /// resource.
434    pub image: Arc<VkImage>,
435    /// Default 2D view over the full image, created once at
436    /// construction so the per-frame record path doesn't allocate.
437    pub view: Arc<ImageView>,
438    id: AppTextureId,
439    size: (u32, u32),
440    format: SurfaceFormat,
441}
442
443impl AppTextureBackend for VulkanoAppTexture {
444    fn id(&self) -> AppTextureId {
445        self.id
446    }
447    fn size_px(&self) -> (u32, u32) {
448        self.size
449    }
450    fn format(&self) -> SurfaceFormat {
451        self.format
452    }
453    fn as_any(&self) -> &dyn Any {
454        self
455    }
456}
457
458/// Wrap an app-allocated `vulkano::image::Image` for compositing via a
459/// [`aetna_core::tree::surface`] widget.
460///
461/// The image must have `SAMPLED` usage and one of the three supported
462/// RGBA8 formats: `R8G8B8A8_SRGB`, `B8G8R8A8_SRGB`, or
463/// `R8G8B8A8_UNORM`. Sample count must be 1.
464///
465/// # Panics
466///
467/// Panics if the image is missing `SAMPLED` usage, its format is outside
468/// the supported set, or its sample count is not 1. These are app-side
469/// mistakes, not runtime errors — fail loudly rather than silently
470/// miscompositing.
471pub fn app_texture(image: Arc<VkImage>) -> AppTexture {
472    let format = match image.format() {
473        Format::R8G8B8A8_SRGB => SurfaceFormat::Rgba8UnormSrgb,
474        Format::B8G8R8A8_SRGB => SurfaceFormat::Bgra8UnormSrgb,
475        Format::R8G8B8A8_UNORM => SurfaceFormat::Rgba8Unorm,
476        f => panic!(
477            "aetna_vulkano::app_texture: unsupported image format {:?} \
478             (expected R8G8B8A8_SRGB / B8G8R8A8_SRGB / R8G8B8A8_UNORM)",
479            f
480        ),
481    };
482    assert!(
483        image.usage().intersects(ImageUsage::SAMPLED),
484        "aetna_vulkano::app_texture: source image must include SAMPLED usage (got {:?})",
485        image.usage(),
486    );
487    let samples = image.samples();
488    assert_eq!(
489        samples as u32, 1,
490        "aetna_vulkano::app_texture: source image must be single-sampled (got {:?})",
491        samples,
492    );
493    let extent = image.extent();
494    let size = (extent[0], extent[1]);
495    let view = ImageView::new(
496        image.clone(),
497        ImageViewCreateInfo {
498            subresource_range: ImageSubresourceRange {
499                aspects: ImageAspects::COLOR,
500                mip_levels: 0..1,
501                array_layers: 0..1,
502            },
503            ..ImageViewCreateInfo::from_image(&image)
504        },
505    )
506    .expect("aetna-vulkano: app_texture image view");
507    AppTexture::from_backend(Arc::new(VulkanoAppTexture {
508        image,
509        view,
510        id: next_app_texture_id(),
511        size,
512        format,
513    }))
514}