Skip to main content

aetna_wgpu/
surface.rs

1//! GPU compositing for app-owned [`AppTexture`]s.
2//!
3//! Where [`crate::image::ImagePaint`] uploads + content-hash-caches a
4//! CPU pixel buffer per `Image`, this module samples a *pre-existing*
5//! GPU texture the app allocated and filled itself. There is no upload
6//! path; the bind-group cache is keyed on
7//! [`AppTextureId`] and entries unreferenced for one frame are dropped
8//! at flush.
9//!
10//! Three render pipelines are built up front, one per
11//! [`SurfaceAlpha`] mode: they share the vertex stage, sampler, and
12//! bind-group layout, and differ only in fragment entry point and
13//! blend state. Per-instance data is just the destination rect — no
14//! tint, no radius (those are deliberately out of 0.3.x scope).
15//!
16//! Per-frame lifecycle:
17//! 1. `frame_begin()` clears the per-frame instance + run buffers.
18//! 2. `record(...)` is called once per `DrawOp::AppTexture`. The first
19//!    call for an [`AppTextureId`] builds a bind group from the
20//!    texture's view; subsequent calls reuse the cached one.
21//! 3. `flush()` writes the instance buffer and drops cache entries
22//!    that weren't touched this frame.
23//! 4. The render loop dispatches each `SurfaceRun` with its alpha-
24//!    mode pipeline and the cached bind group active.
25
26use std::any::Any;
27use std::borrow::Cow;
28use std::collections::HashMap;
29use std::ops::Range;
30use std::sync::Arc;
31
32use aetna_core::affine::Affine2;
33use aetna_core::paint::PhysicalScissor;
34use aetna_core::shader::stock_wgsl;
35use aetna_core::surface::{
36    AppTexture, AppTextureBackend, AppTextureId, SurfaceAlpha, SurfaceFormat, next_app_texture_id,
37};
38use aetna_core::tree::Rect;
39
40use bytemuck::{Pod, Zeroable};
41
42const INITIAL_INSTANCE_CAPACITY: usize = 16;
43
44const SURFACE_INSTANCE_ATTRS: [wgpu::VertexAttribute; 3] = wgpu::vertex_attr_array![
45    1 => Float32x4, // rect (xy = top-left logical px, zw = size)
46    2 => Float32x4, // affine matrix (a, b, c, d)
47    3 => Float32x2, // affine translation (tx, ty)
48];
49
50#[repr(C)]
51#[derive(Copy, Clone, Pod, Zeroable, Debug)]
52struct SurfaceInstance {
53    rect: [f32; 4],
54    matrix: [f32; 4],
55    translation: [f32; 2],
56}
57
58pub(crate) struct SurfaceRun {
59    pub texture_idx: usize,
60    pub scissor: Option<PhysicalScissor>,
61    pub alpha: SurfaceAlpha,
62    pub first: u32,
63    pub count: u32,
64}
65
66struct CachedBindGroup {
67    bind_group: wgpu::BindGroup,
68    /// Frame index of the most recent `record` call for this texture
69    /// id. Slots not touched in the current frame are dropped at flush.
70    last_used_frame: u64,
71}
72
73pub(crate) struct SurfacePaint {
74    instances: Vec<SurfaceInstance>,
75    instance_buf: wgpu::Buffer,
76    instance_capacity: usize,
77    runs: Vec<SurfaceRun>,
78
79    pipeline_premul: wgpu::RenderPipeline,
80    pipeline_straight: wgpu::RenderPipeline,
81    pipeline_opaque: wgpu::RenderPipeline,
82    bind_layout: wgpu::BindGroupLayout,
83    sampler: wgpu::Sampler,
84
85    /// AppTextureId(u64) → cached bind group for that texture's view.
86    cache: HashMap<u64, CachedBindGroup>,
87    /// Parallel per-frame index so `SurfaceRun::texture_idx` names a
88    /// stable slot. Rebuilt each `frame_begin`.
89    bind_group_lookup: Vec<u64>,
90    frame_counter: u64,
91}
92
93impl SurfacePaint {
94    pub(crate) fn new(
95        device: &wgpu::Device,
96        target_format: wgpu::TextureFormat,
97        sample_count: u32,
98        frame_bind_layout: &wgpu::BindGroupLayout,
99    ) -> Self {
100        let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
101            label: Some("aetna_wgpu::surface::texture_bind_layout"),
102            entries: &[
103                wgpu::BindGroupLayoutEntry {
104                    binding: 0,
105                    visibility: wgpu::ShaderStages::FRAGMENT,
106                    ty: wgpu::BindingType::Texture {
107                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
108                        view_dimension: wgpu::TextureViewDimension::D2,
109                        multisampled: false,
110                    },
111                    count: None,
112                },
113                wgpu::BindGroupLayoutEntry {
114                    binding: 1,
115                    visibility: wgpu::ShaderStages::FRAGMENT,
116                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
117                    count: None,
118                },
119            ],
120        });
121
122        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
123            label: Some("aetna_wgpu::surface::pipeline_layout"),
124            bind_group_layouts: &[Some(frame_bind_layout), Some(&bind_layout)],
125            immediate_size: 0,
126        });
127
128        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
129            label: Some("stock::surface"),
130            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(stock_wgsl::SURFACE)),
131        });
132
133        let pipeline_premul = build_pipeline(
134            device,
135            &pipeline_layout,
136            &shader,
137            target_format,
138            sample_count,
139            "fs_premul",
140            premultiplied_blend(),
141            "aetna_wgpu::surface::pipeline_premul",
142        );
143        let pipeline_straight = build_pipeline(
144            device,
145            &pipeline_layout,
146            &shader,
147            target_format,
148            sample_count,
149            "fs_straight",
150            premultiplied_blend(),
151            "aetna_wgpu::surface::pipeline_straight",
152        );
153        let pipeline_opaque = build_pipeline(
154            device,
155            &pipeline_layout,
156            &shader,
157            target_format,
158            sample_count,
159            "fs_opaque",
160            // SurfaceAlpha::Opaque replaces destination pixels — skip
161            // blending entirely so the surface texture overwrites
162            // whatever was painted underneath it within the rect.
163            opaque_blend(),
164            "aetna_wgpu::surface::pipeline_opaque",
165        );
166
167        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
168            label: Some("aetna_wgpu::surface::sampler"),
169            address_mode_u: wgpu::AddressMode::ClampToEdge,
170            address_mode_v: wgpu::AddressMode::ClampToEdge,
171            address_mode_w: wgpu::AddressMode::ClampToEdge,
172            mag_filter: wgpu::FilterMode::Linear,
173            min_filter: wgpu::FilterMode::Linear,
174            mipmap_filter: wgpu::MipmapFilterMode::Linear,
175            ..Default::default()
176        });
177
178        let instance_buf = device.create_buffer(&wgpu::BufferDescriptor {
179            label: Some("aetna_wgpu::surface::instance_buf"),
180            size: (INITIAL_INSTANCE_CAPACITY * std::mem::size_of::<SurfaceInstance>()) as u64,
181            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
182            mapped_at_creation: false,
183        });
184
185        Self {
186            instances: Vec::with_capacity(INITIAL_INSTANCE_CAPACITY),
187            instance_buf,
188            instance_capacity: INITIAL_INSTANCE_CAPACITY,
189            runs: Vec::new(),
190            pipeline_premul,
191            pipeline_straight,
192            pipeline_opaque,
193            bind_layout,
194            sampler,
195            cache: HashMap::new(),
196            bind_group_lookup: Vec::new(),
197            frame_counter: 0,
198        }
199    }
200
201    pub(crate) fn frame_begin(&mut self) {
202        self.instances.clear();
203        self.runs.clear();
204        self.bind_group_lookup.clear();
205        self.frame_counter = self.frame_counter.wrapping_add(1);
206    }
207
208    pub(crate) fn record(
209        &mut self,
210        device: &wgpu::Device,
211        rect: Rect,
212        scissor: Option<PhysicalScissor>,
213        texture: &AppTexture,
214        alpha: SurfaceAlpha,
215        transform: Affine2,
216    ) -> Range<usize> {
217        if rect.w <= 0.0 || rect.h <= 0.0 {
218            let start = self.runs.len();
219            return start..start;
220        }
221        let start = self.runs.len();
222        let texture_idx = self.ensure_bind_group(device, texture);
223        let instance = SurfaceInstance {
224            rect: [rect.x, rect.y, rect.w, rect.h],
225            matrix: [transform.a, transform.b, transform.c, transform.d],
226            translation: [transform.tx, transform.ty],
227        };
228        let first = self.instances.len() as u32;
229        self.instances.push(instance);
230        self.runs.push(SurfaceRun {
231            texture_idx,
232            scissor,
233            alpha,
234            first,
235            count: 1,
236        });
237        start..self.runs.len()
238    }
239
240    fn ensure_bind_group(&mut self, device: &wgpu::Device, texture: &AppTexture) -> usize {
241        let id = texture.id().0;
242        if !self.cache.contains_key(&id) {
243            let backend = texture.backend();
244            let wgpu_tex = backend.as_any().downcast_ref::<WgpuAppTexture>().unwrap_or_else(|| {
245                panic!(
246                    "AppTexture passed to aetna-wgpu was not constructed by aetna_wgpu::app_texture \
247                     (actual backend: {}); mixing backends in one runtime is unsupported",
248                    texture.backend_name(),
249                )
250            });
251            let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
252                label: Some("aetna_wgpu::surface::bind_group"),
253                layout: &self.bind_layout,
254                entries: &[
255                    wgpu::BindGroupEntry {
256                        binding: 0,
257                        resource: wgpu::BindingResource::TextureView(&wgpu_tex.view),
258                    },
259                    wgpu::BindGroupEntry {
260                        binding: 1,
261                        resource: wgpu::BindingResource::Sampler(&self.sampler),
262                    },
263                ],
264            });
265            self.cache.insert(
266                id,
267                CachedBindGroup {
268                    bind_group,
269                    last_used_frame: 0,
270                },
271            );
272        }
273        let entry = self.cache.get_mut(&id).expect("just inserted");
274        entry.last_used_frame = self.frame_counter;
275        if let Some(idx) = self.bind_group_lookup.iter().position(|&i| i == id) {
276            idx
277        } else {
278            self.bind_group_lookup.push(id);
279            self.bind_group_lookup.len() - 1
280        }
281    }
282
283    pub(crate) fn flush(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) {
284        let frame = self.frame_counter;
285        self.cache.retain(|_, v| v.last_used_frame == frame);
286
287        if self.instances.len() > self.instance_capacity {
288            let new_cap = self.instances.len().next_power_of_two();
289            self.instance_buf = device.create_buffer(&wgpu::BufferDescriptor {
290                label: Some("aetna_wgpu::surface::instance_buf (resized)"),
291                size: (new_cap * std::mem::size_of::<SurfaceInstance>()) as u64,
292                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
293                mapped_at_creation: false,
294            });
295            self.instance_capacity = new_cap;
296        }
297        if !self.instances.is_empty() {
298            queue.write_buffer(&self.instance_buf, 0, bytemuck::cast_slice(&self.instances));
299        }
300    }
301
302    pub(crate) fn run(&self, index: usize) -> &SurfaceRun {
303        &self.runs[index]
304    }
305
306    pub(crate) fn pipeline_for(&self, alpha: SurfaceAlpha) -> &wgpu::RenderPipeline {
307        match alpha {
308            SurfaceAlpha::Premultiplied => &self.pipeline_premul,
309            SurfaceAlpha::Straight => &self.pipeline_straight,
310            SurfaceAlpha::Opaque => &self.pipeline_opaque,
311        }
312    }
313
314    pub(crate) fn instance_buf(&self) -> &wgpu::Buffer {
315        &self.instance_buf
316    }
317
318    pub(crate) fn bind_group_for_run(&self, run: &SurfaceRun) -> &wgpu::BindGroup {
319        let id = self.bind_group_lookup[run.texture_idx];
320        &self
321            .cache
322            .get(&id)
323            .expect("cache entry alive for the frame")
324            .bind_group
325    }
326}
327
328#[allow(clippy::too_many_arguments)]
329fn build_pipeline(
330    device: &wgpu::Device,
331    pipeline_layout: &wgpu::PipelineLayout,
332    shader: &wgpu::ShaderModule,
333    target_format: wgpu::TextureFormat,
334    sample_count: u32,
335    fs_entry: &'static str,
336    blend: Option<wgpu::BlendState>,
337    label: &'static str,
338) -> wgpu::RenderPipeline {
339    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
340        label: Some(label),
341        layout: Some(pipeline_layout),
342        vertex: wgpu::VertexState {
343            module: shader,
344            entry_point: Some("vs_main"),
345            compilation_options: Default::default(),
346            buffers: &[
347                wgpu::VertexBufferLayout {
348                    array_stride: (2 * std::mem::size_of::<f32>()) as u64,
349                    step_mode: wgpu::VertexStepMode::Vertex,
350                    attributes: &[wgpu::VertexAttribute {
351                        shader_location: 0,
352                        format: wgpu::VertexFormat::Float32x2,
353                        offset: 0,
354                    }],
355                },
356                wgpu::VertexBufferLayout {
357                    array_stride: std::mem::size_of::<SurfaceInstance>() as u64,
358                    step_mode: wgpu::VertexStepMode::Instance,
359                    attributes: &SURFACE_INSTANCE_ATTRS,
360                },
361            ],
362        },
363        fragment: Some(wgpu::FragmentState {
364            module: shader,
365            entry_point: Some(fs_entry),
366            compilation_options: Default::default(),
367            targets: &[Some(wgpu::ColorTargetState {
368                format: target_format,
369                blend,
370                write_mask: wgpu::ColorWrites::ALL,
371            })],
372        }),
373        primitive: wgpu::PrimitiveState {
374            topology: wgpu::PrimitiveTopology::TriangleStrip,
375            strip_index_format: None,
376            front_face: wgpu::FrontFace::Ccw,
377            cull_mode: None,
378            polygon_mode: wgpu::PolygonMode::Fill,
379            unclipped_depth: false,
380            conservative: false,
381        },
382        depth_stencil: None,
383        multisample: wgpu::MultisampleState {
384            count: sample_count,
385            mask: !0,
386            alpha_to_coverage_enabled: false,
387        },
388        multiview_mask: None,
389        cache: None,
390    })
391}
392
393fn premultiplied_blend() -> Option<wgpu::BlendState> {
394    Some(wgpu::BlendState {
395        color: wgpu::BlendComponent {
396            src_factor: wgpu::BlendFactor::One,
397            dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
398            operation: wgpu::BlendOperation::Add,
399        },
400        alpha: wgpu::BlendComponent {
401            src_factor: wgpu::BlendFactor::One,
402            dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
403            operation: wgpu::BlendOperation::Add,
404        },
405    })
406}
407
408fn opaque_blend() -> Option<wgpu::BlendState> {
409    Some(wgpu::BlendState {
410        color: wgpu::BlendComponent {
411            src_factor: wgpu::BlendFactor::One,
412            dst_factor: wgpu::BlendFactor::Zero,
413            operation: wgpu::BlendOperation::Add,
414        },
415        alpha: wgpu::BlendComponent {
416            src_factor: wgpu::BlendFactor::One,
417            dst_factor: wgpu::BlendFactor::Zero,
418            operation: wgpu::BlendOperation::Add,
419        },
420    })
421}
422
423// ---- Public AppTexture constructor ----
424
425/// Concrete wgpu-side [`AppTextureBackend`]. Holds the texture +
426/// view + a cached id so the runtime can downcast and pull what it
427/// needs without re-creating views per frame.
428#[derive(Debug)]
429pub struct WgpuAppTexture {
430    /// The app-owned texture. Held as `Arc` so `AppTexture` can be
431    /// cheaply cloned into the El tree without releasing the GPU
432    /// resource.
433    pub texture: Arc<wgpu::Texture>,
434    /// Default 2D view over the full texture, created once at
435    /// construction so the per-frame record path doesn't allocate.
436    pub view: Arc<wgpu::TextureView>,
437    id: AppTextureId,
438    size: (u32, u32),
439    format: SurfaceFormat,
440}
441
442impl AppTextureBackend for WgpuAppTexture {
443    fn id(&self) -> AppTextureId {
444        self.id
445    }
446    fn size_px(&self) -> (u32, u32) {
447        self.size
448    }
449    fn format(&self) -> SurfaceFormat {
450        self.format
451    }
452    fn as_any(&self) -> &dyn Any {
453        self
454    }
455}
456
457/// Wrap an app-allocated `wgpu::Texture` for compositing via a
458/// [`aetna_core::tree::surface`] widget.
459///
460/// The texture must have `TEXTURE_BINDING` usage and one of the three
461/// supported RGBA8 formats: `Rgba8UnormSrgb`, `Bgra8UnormSrgb`, or
462/// `Rgba8Unorm`. Sample count must be 1 — Aetna composites the texture
463/// into its own (possibly multisampled) render pass; multisampled
464/// source textures aren't supported in 0.3.x.
465///
466/// # Panics
467///
468/// Panics if the texture is missing `TEXTURE_BINDING` usage, its format
469/// is outside the supported set, or its sample count is not 1. These are
470/// app-side mistakes, not runtime errors — fail loudly rather than
471/// silently miscompositing.
472pub fn app_texture(texture: Arc<wgpu::Texture>) -> AppTexture {
473    let format = match texture.format() {
474        wgpu::TextureFormat::Rgba8UnormSrgb => SurfaceFormat::Rgba8UnormSrgb,
475        wgpu::TextureFormat::Bgra8UnormSrgb => SurfaceFormat::Bgra8UnormSrgb,
476        wgpu::TextureFormat::Rgba8Unorm => SurfaceFormat::Rgba8Unorm,
477        f => panic!(
478            "aetna_wgpu::app_texture: unsupported texture format {:?} \
479             (expected Rgba8UnormSrgb / Bgra8UnormSrgb / Rgba8Unorm)",
480            f
481        ),
482    };
483    assert!(
484        texture
485            .usage()
486            .contains(wgpu::TextureUsages::TEXTURE_BINDING),
487        "aetna_wgpu::app_texture: source texture must include TEXTURE_BINDING usage (got {:?})",
488        texture.usage(),
489    );
490    assert_eq!(
491        texture.sample_count(),
492        1,
493        "aetna_wgpu::app_texture: source texture must be single-sampled (got sample_count = {})",
494        texture.sample_count(),
495    );
496    let extent = texture.size();
497    let size = (extent.width, extent.height);
498    let view = Arc::new(texture.create_view(&wgpu::TextureViewDescriptor::default()));
499    AppTexture::from_backend(Arc::new(WgpuAppTexture {
500        texture,
501        view,
502        id: next_app_texture_id(),
503        size,
504        format,
505    }))
506}