Skip to main content

damascene_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 damascene_core::affine::Affine2;
33use damascene_core::paint::PhysicalScissor;
34use damascene_core::shader::stock_wgsl;
35use damascene_core::surface::{
36    AppTexture, AppTextureBackend, AppTextureId, SurfaceAlpha, SurfaceFormat, next_app_texture_id,
37};
38use damascene_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    // Pipeline layout + sample count retained so the three
86    // swapchain-format-bound pipelines above (one per `SurfaceAlpha` mode)
87    // can be rebuilt in place on a surface-format renegotiation
88    // (`set_target_format`). The shader module is cheap to recreate, so it
89    // is not stored. The texture bind-group layout is unchanged, so cached
90    // per-texture bind groups stay valid.
91    pipeline_layout: wgpu::PipelineLayout,
92    sample_count: u32,
93
94    /// AppTextureId(u64) → cached bind group for that texture's view.
95    cache: HashMap<u64, CachedBindGroup>,
96    /// Parallel per-frame index so `SurfaceRun::texture_idx` names a
97    /// stable slot. Rebuilt each `frame_begin`.
98    bind_group_lookup: Vec<u64>,
99    frame_counter: u64,
100}
101
102impl SurfacePaint {
103    pub(crate) fn new(
104        device: &wgpu::Device,
105        target_format: wgpu::TextureFormat,
106        sample_count: u32,
107        frame_bind_layout: &wgpu::BindGroupLayout,
108    ) -> Self {
109        let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
110            label: Some("damascene_wgpu::surface::texture_bind_layout"),
111            entries: &[
112                wgpu::BindGroupLayoutEntry {
113                    binding: 0,
114                    visibility: wgpu::ShaderStages::FRAGMENT,
115                    ty: wgpu::BindingType::Texture {
116                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
117                        view_dimension: wgpu::TextureViewDimension::D2,
118                        multisampled: false,
119                    },
120                    count: None,
121                },
122                wgpu::BindGroupLayoutEntry {
123                    binding: 1,
124                    visibility: wgpu::ShaderStages::FRAGMENT,
125                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
126                    count: None,
127                },
128            ],
129        });
130
131        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
132            label: Some("damascene_wgpu::surface::pipeline_layout"),
133            bind_group_layouts: &[Some(frame_bind_layout), Some(&bind_layout)],
134            immediate_size: 0,
135        });
136
137        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
138            label: Some("stock::surface"),
139            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(stock_wgsl::SURFACE)),
140        });
141
142        let pipeline_premul = build_pipeline(
143            device,
144            &pipeline_layout,
145            &shader,
146            target_format,
147            sample_count,
148            "fs_premul",
149            premultiplied_blend(),
150            "damascene_wgpu::surface::pipeline_premul",
151        );
152        let pipeline_straight = build_pipeline(
153            device,
154            &pipeline_layout,
155            &shader,
156            target_format,
157            sample_count,
158            "fs_straight",
159            premultiplied_blend(),
160            "damascene_wgpu::surface::pipeline_straight",
161        );
162        let pipeline_opaque = build_pipeline(
163            device,
164            &pipeline_layout,
165            &shader,
166            target_format,
167            sample_count,
168            "fs_opaque",
169            // SurfaceAlpha::Opaque replaces destination pixels — skip
170            // blending entirely so the surface texture overwrites
171            // whatever was painted underneath it within the rect.
172            opaque_blend(),
173            "damascene_wgpu::surface::pipeline_opaque",
174        );
175
176        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
177            label: Some("damascene_wgpu::surface::sampler"),
178            address_mode_u: wgpu::AddressMode::ClampToEdge,
179            address_mode_v: wgpu::AddressMode::ClampToEdge,
180            address_mode_w: wgpu::AddressMode::ClampToEdge,
181            mag_filter: wgpu::FilterMode::Linear,
182            min_filter: wgpu::FilterMode::Linear,
183            mipmap_filter: wgpu::MipmapFilterMode::Linear,
184            ..Default::default()
185        });
186
187        let instance_buf = device.create_buffer(&wgpu::BufferDescriptor {
188            label: Some("damascene_wgpu::surface::instance_buf"),
189            size: (INITIAL_INSTANCE_CAPACITY * std::mem::size_of::<SurfaceInstance>()) as u64,
190            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
191            mapped_at_creation: false,
192        });
193
194        Self {
195            instances: Vec::with_capacity(INITIAL_INSTANCE_CAPACITY),
196            instance_buf,
197            instance_capacity: INITIAL_INSTANCE_CAPACITY,
198            runs: Vec::new(),
199            pipeline_premul,
200            pipeline_straight,
201            pipeline_opaque,
202            bind_layout,
203            sampler,
204            pipeline_layout,
205            sample_count,
206            cache: HashMap::new(),
207            bind_group_lookup: Vec::new(),
208            frame_counter: 0,
209        }
210    }
211
212    /// Rebuild the three swapchain-format-bound pipelines (one per
213    /// `SurfaceAlpha` mode) for a new target format, preserving the
214    /// per-texture bind-group cache, instance buffer, and sampler. Called by
215    /// `Runner::set_target_format`. The shader module + blend modes are
216    /// re-derived identically; the pipeline + texture bind-group layouts are
217    /// unchanged, so cached bind groups stay valid.
218    pub(crate) fn set_target_format(
219        &mut self,
220        device: &wgpu::Device,
221        target_format: wgpu::TextureFormat,
222    ) {
223        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
224            label: Some("stock::surface"),
225            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(stock_wgsl::SURFACE)),
226        });
227        self.pipeline_premul = build_pipeline(
228            device,
229            &self.pipeline_layout,
230            &shader,
231            target_format,
232            self.sample_count,
233            "fs_premul",
234            premultiplied_blend(),
235            "damascene_wgpu::surface::pipeline_premul",
236        );
237        self.pipeline_straight = build_pipeline(
238            device,
239            &self.pipeline_layout,
240            &shader,
241            target_format,
242            self.sample_count,
243            "fs_straight",
244            premultiplied_blend(),
245            "damascene_wgpu::surface::pipeline_straight",
246        );
247        self.pipeline_opaque = build_pipeline(
248            device,
249            &self.pipeline_layout,
250            &shader,
251            target_format,
252            self.sample_count,
253            "fs_opaque",
254            opaque_blend(),
255            "damascene_wgpu::surface::pipeline_opaque",
256        );
257    }
258
259    pub(crate) fn frame_begin(&mut self) {
260        self.instances.clear();
261        self.runs.clear();
262        self.bind_group_lookup.clear();
263        self.frame_counter = self.frame_counter.wrapping_add(1);
264    }
265
266    pub(crate) fn record(
267        &mut self,
268        device: &wgpu::Device,
269        rect: Rect,
270        scissor: Option<PhysicalScissor>,
271        texture: &AppTexture,
272        alpha: SurfaceAlpha,
273        transform: Affine2,
274    ) -> Range<usize> {
275        if rect.w <= 0.0 || rect.h <= 0.0 {
276            let start = self.runs.len();
277            return start..start;
278        }
279        let start = self.runs.len();
280        let texture_idx = self.ensure_bind_group(device, texture);
281        let instance = SurfaceInstance {
282            rect: [rect.x, rect.y, rect.w, rect.h],
283            matrix: [transform.a, transform.b, transform.c, transform.d],
284            translation: [transform.tx, transform.ty],
285        };
286        let first = self.instances.len() as u32;
287        self.instances.push(instance);
288        self.runs.push(SurfaceRun {
289            texture_idx,
290            scissor,
291            alpha,
292            first,
293            count: 1,
294        });
295        start..self.runs.len()
296    }
297
298    fn ensure_bind_group(&mut self, device: &wgpu::Device, texture: &AppTexture) -> usize {
299        let id = texture.id().0;
300        if !self.cache.contains_key(&id) {
301            let backend = texture.backend();
302            let wgpu_tex = backend.as_any().downcast_ref::<WgpuAppTexture>().unwrap_or_else(|| {
303                panic!(
304                    "AppTexture passed to damascene-wgpu was not constructed by damascene_wgpu::app_texture \
305                     (actual backend: {}); mixing backends in one runtime is unsupported",
306                    texture.backend_name(),
307                )
308            });
309            let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
310                label: Some("damascene_wgpu::surface::bind_group"),
311                layout: &self.bind_layout,
312                entries: &[
313                    wgpu::BindGroupEntry {
314                        binding: 0,
315                        resource: wgpu::BindingResource::TextureView(&wgpu_tex.view),
316                    },
317                    wgpu::BindGroupEntry {
318                        binding: 1,
319                        resource: wgpu::BindingResource::Sampler(&self.sampler),
320                    },
321                ],
322            });
323            self.cache.insert(
324                id,
325                CachedBindGroup {
326                    bind_group,
327                    last_used_frame: 0,
328                },
329            );
330        }
331        let entry = self.cache.get_mut(&id).expect("just inserted");
332        entry.last_used_frame = self.frame_counter;
333        if let Some(idx) = self.bind_group_lookup.iter().position(|&i| i == id) {
334            idx
335        } else {
336            self.bind_group_lookup.push(id);
337            self.bind_group_lookup.len() - 1
338        }
339    }
340
341    pub(crate) fn flush(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) {
342        let frame = self.frame_counter;
343        self.cache.retain(|_, v| v.last_used_frame == frame);
344
345        if self.instances.len() > self.instance_capacity {
346            let new_cap = self.instances.len().next_power_of_two();
347            self.instance_buf = device.create_buffer(&wgpu::BufferDescriptor {
348                label: Some("damascene_wgpu::surface::instance_buf (resized)"),
349                size: (new_cap * std::mem::size_of::<SurfaceInstance>()) as u64,
350                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
351                mapped_at_creation: false,
352            });
353            self.instance_capacity = new_cap;
354        }
355        if !self.instances.is_empty() {
356            queue.write_buffer(&self.instance_buf, 0, bytemuck::cast_slice(&self.instances));
357        }
358    }
359
360    pub(crate) fn run(&self, index: usize) -> &SurfaceRun {
361        &self.runs[index]
362    }
363
364    pub(crate) fn pipeline_for(&self, alpha: SurfaceAlpha) -> &wgpu::RenderPipeline {
365        match alpha {
366            SurfaceAlpha::Premultiplied => &self.pipeline_premul,
367            SurfaceAlpha::Straight => &self.pipeline_straight,
368            SurfaceAlpha::Opaque => &self.pipeline_opaque,
369        }
370    }
371
372    pub(crate) fn instance_buf(&self) -> &wgpu::Buffer {
373        &self.instance_buf
374    }
375
376    pub(crate) fn bind_group_for_run(&self, run: &SurfaceRun) -> &wgpu::BindGroup {
377        let id = self.bind_group_lookup[run.texture_idx];
378        &self
379            .cache
380            .get(&id)
381            .expect("cache entry alive for the frame")
382            .bind_group
383    }
384}
385
386#[allow(clippy::too_many_arguments)]
387fn build_pipeline(
388    device: &wgpu::Device,
389    pipeline_layout: &wgpu::PipelineLayout,
390    shader: &wgpu::ShaderModule,
391    target_format: wgpu::TextureFormat,
392    sample_count: u32,
393    fs_entry: &'static str,
394    blend: Option<wgpu::BlendState>,
395    label: &'static str,
396) -> wgpu::RenderPipeline {
397    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
398        label: Some(label),
399        layout: Some(pipeline_layout),
400        vertex: wgpu::VertexState {
401            module: shader,
402            entry_point: Some("vs_main"),
403            compilation_options: Default::default(),
404            buffers: &[
405                Some(wgpu::VertexBufferLayout {
406                    array_stride: (2 * std::mem::size_of::<f32>()) as u64,
407                    step_mode: wgpu::VertexStepMode::Vertex,
408                    attributes: &[wgpu::VertexAttribute {
409                        shader_location: 0,
410                        format: wgpu::VertexFormat::Float32x2,
411                        offset: 0,
412                    }],
413                }),
414                Some(wgpu::VertexBufferLayout {
415                    array_stride: std::mem::size_of::<SurfaceInstance>() as u64,
416                    step_mode: wgpu::VertexStepMode::Instance,
417                    attributes: &SURFACE_INSTANCE_ATTRS,
418                }),
419            ],
420        },
421        fragment: Some(wgpu::FragmentState {
422            module: shader,
423            entry_point: Some(fs_entry),
424            compilation_options: Default::default(),
425            targets: &[Some(wgpu::ColorTargetState {
426                format: target_format,
427                blend,
428                write_mask: wgpu::ColorWrites::ALL,
429            })],
430        }),
431        primitive: wgpu::PrimitiveState {
432            topology: wgpu::PrimitiveTopology::TriangleStrip,
433            strip_index_format: None,
434            front_face: wgpu::FrontFace::Ccw,
435            cull_mode: None,
436            polygon_mode: wgpu::PolygonMode::Fill,
437            unclipped_depth: false,
438            conservative: false,
439        },
440        depth_stencil: None,
441        multisample: wgpu::MultisampleState {
442            count: sample_count,
443            mask: !0,
444            alpha_to_coverage_enabled: false,
445        },
446        multiview_mask: None,
447        cache: None,
448    })
449}
450
451fn premultiplied_blend() -> Option<wgpu::BlendState> {
452    Some(wgpu::BlendState {
453        color: wgpu::BlendComponent {
454            src_factor: wgpu::BlendFactor::One,
455            dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
456            operation: wgpu::BlendOperation::Add,
457        },
458        alpha: wgpu::BlendComponent {
459            src_factor: wgpu::BlendFactor::One,
460            dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
461            operation: wgpu::BlendOperation::Add,
462        },
463    })
464}
465
466fn opaque_blend() -> Option<wgpu::BlendState> {
467    Some(wgpu::BlendState {
468        color: wgpu::BlendComponent {
469            src_factor: wgpu::BlendFactor::One,
470            dst_factor: wgpu::BlendFactor::Zero,
471            operation: wgpu::BlendOperation::Add,
472        },
473        alpha: wgpu::BlendComponent {
474            src_factor: wgpu::BlendFactor::One,
475            dst_factor: wgpu::BlendFactor::Zero,
476            operation: wgpu::BlendOperation::Add,
477        },
478    })
479}
480
481// ---- Public AppTexture constructor ----
482
483/// Concrete wgpu-side [`AppTextureBackend`]. Holds the texture +
484/// view + a cached id so the runtime can downcast and pull what it
485/// needs without re-creating views per frame.
486#[derive(Debug)]
487pub struct WgpuAppTexture {
488    /// The app-owned texture. Held as `Arc` so `AppTexture` can be
489    /// cheaply cloned into the El tree without releasing the GPU
490    /// resource.
491    pub texture: Arc<wgpu::Texture>,
492    /// Default 2D view over the full texture, created once at
493    /// construction so the per-frame record path doesn't allocate.
494    pub view: Arc<wgpu::TextureView>,
495    id: AppTextureId,
496    size: (u32, u32),
497    format: SurfaceFormat,
498}
499
500impl AppTextureBackend for WgpuAppTexture {
501    fn id(&self) -> AppTextureId {
502        self.id
503    }
504    fn size_px(&self) -> (u32, u32) {
505        self.size
506    }
507    fn format(&self) -> SurfaceFormat {
508        self.format
509    }
510    fn as_any(&self) -> &dyn Any {
511        self
512    }
513}
514
515/// Wrap an app-allocated `wgpu::Texture` for compositing via a
516/// [`damascene_core::tree::surface`] widget.
517///
518/// The texture must have `TEXTURE_BINDING` usage and one of the three
519/// supported RGBA8 formats: `Rgba8UnormSrgb`, `Bgra8UnormSrgb`, or
520/// `Rgba8Unorm`. Sample count must be 1 — Damascene composites the texture
521/// into its own (possibly multisampled) render pass; multisampled
522/// source textures aren't supported in 0.3.x.
523///
524/// # Panics
525///
526/// Panics if the texture is missing `TEXTURE_BINDING` usage, its format
527/// is outside the supported set, or its sample count is not 1. These are
528/// app-side mistakes, not runtime errors — fail loudly rather than
529/// silently miscompositing.
530pub fn app_texture(texture: Arc<wgpu::Texture>) -> AppTexture {
531    let format = match texture.format() {
532        wgpu::TextureFormat::Rgba8UnormSrgb => SurfaceFormat::Rgba8UnormSrgb,
533        wgpu::TextureFormat::Bgra8UnormSrgb => SurfaceFormat::Bgra8UnormSrgb,
534        wgpu::TextureFormat::Rgba8Unorm => SurfaceFormat::Rgba8Unorm,
535        wgpu::TextureFormat::Rgba16Float => SurfaceFormat::Rgba16Float,
536        f => panic!(
537            "damascene_wgpu::app_texture: unsupported texture format {:?} \
538             (expected Rgba8UnormSrgb / Bgra8UnormSrgb / Rgba8Unorm / Rgba16Float)",
539            f
540        ),
541    };
542    assert!(
543        texture
544            .usage()
545            .contains(wgpu::TextureUsages::TEXTURE_BINDING),
546        "damascene_wgpu::app_texture: source texture must include TEXTURE_BINDING usage (got {:?})",
547        texture.usage(),
548    );
549    assert_eq!(
550        texture.sample_count(),
551        1,
552        "damascene_wgpu::app_texture: source texture must be single-sampled (got sample_count = {})",
553        texture.sample_count(),
554    );
555    let extent = texture.size();
556    let size = (extent.width, extent.height);
557    let view = Arc::new(texture.create_view(&wgpu::TextureViewDescriptor::default()));
558    AppTexture::from_backend(Arc::new(WgpuAppTexture {
559        texture,
560        view,
561        id: next_app_texture_id(),
562        size,
563        format,
564    }))
565}
566
567/// An app-owned texture updated from CPU pixel data every frame or so —
568/// video frames, animated images, camera feeds, software-rendered
569/// panels. Owns the texture lifecycle that every media consumer
570/// otherwise hand-rolls around [`app_texture`]:
571///
572/// - creates (and on size/format change, recreates) the
573///   `TEXTURE_BINDING | COPY_DST` texture;
574/// - uploads frames with [`Self::write_frame`], or
575///   [`Self::write_frame_if_changed`] for sources that repeat frames
576///   (paused video, looping animations) where a cheap content hash
577///   saves the redundant upload;
578/// - hands out a stable [`AppTexture`] handle via
579///   [`Self::app_texture`] for the [`surface()`] tree builder. The
580///   handle's identity is stable across `write_frame` calls — which is
581///   what keeps the per-texture bind-group cache warm — and changes
582///   only when a size/format change forces a new GPU texture (one cold
583///   bind-group rebuild, then warm again).
584///
585/// `data` is tightly-packed rows of `width * height` pixels in the
586/// texture's format (RGBA8 variants: 4 bytes/pixel; `Rgba16Float`:
587/// 8 bytes/pixel). Formats follow [`app_texture`]'s supported set.
588///
589/// [`surface()`]: damascene_core::surface
590pub struct StreamingTexture {
591    texture: Arc<wgpu::Texture>,
592    handle: AppTexture,
593    format: wgpu::TextureFormat,
594    size: (u32, u32),
595    last_hash: Option<u64>,
596}
597
598impl StreamingTexture {
599    /// Create with an initial size. The texture contents are undefined
600    /// until the first [`Self::write_frame`]; draw order guarantees
601    /// nothing samples it before the first queue submission that
602    /// follows a write.
603    pub fn new(
604        device: &wgpu::Device,
605        format: wgpu::TextureFormat,
606        width: u32,
607        height: u32,
608    ) -> Self {
609        let (texture, handle) = Self::create(device, format, width, height);
610        Self {
611            texture,
612            handle,
613            format,
614            size: (width, height),
615            last_hash: None,
616        }
617    }
618
619    fn create(
620        device: &wgpu::Device,
621        format: wgpu::TextureFormat,
622        width: u32,
623        height: u32,
624    ) -> (Arc<wgpu::Texture>, AppTexture) {
625        let texture = Arc::new(device.create_texture(&wgpu::TextureDescriptor {
626            label: Some("damascene-streaming-texture"),
627            size: wgpu::Extent3d {
628                width: width.max(1),
629                height: height.max(1),
630                depth_or_array_layers: 1,
631            },
632            mip_level_count: 1,
633            sample_count: 1,
634            dimension: wgpu::TextureDimension::D2,
635            format,
636            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
637            view_formats: &[],
638        }));
639        let handle = app_texture(texture.clone());
640        (texture, handle)
641    }
642
643    /// Bytes per pixel for the texture's format (validated by
644    /// [`app_texture`] to be one of the supported set).
645    fn bytes_per_pixel(&self) -> u32 {
646        match self.format {
647            wgpu::TextureFormat::Rgba16Float => 8,
648            _ => 4,
649        }
650    }
651
652    /// Upload one frame of tightly-packed pixel rows, recreating the
653    /// texture first if `width`/`height` differ from the current size.
654    /// Panics if `data` is not exactly `width * height *
655    /// bytes_per_pixel` long.
656    pub fn write_frame(
657        &mut self,
658        device: &wgpu::Device,
659        queue: &wgpu::Queue,
660        width: u32,
661        height: u32,
662        data: &[u8],
663    ) {
664        if (width, height) != self.size {
665            let (texture, handle) = Self::create(device, self.format, width, height);
666            self.texture = texture;
667            self.handle = handle;
668            self.size = (width, height);
669            self.last_hash = None;
670        }
671        let bpp = self.bytes_per_pixel();
672        assert_eq!(
673            data.len() as u64,
674            width as u64 * height as u64 * bpp as u64,
675            "StreamingTexture::write_frame: data length {} != {}x{} * {} bytes/px",
676            data.len(),
677            width,
678            height,
679            bpp,
680        );
681        queue.write_texture(
682            wgpu::TexelCopyTextureInfo {
683                texture: &self.texture,
684                mip_level: 0,
685                origin: wgpu::Origin3d::ZERO,
686                aspect: wgpu::TextureAspect::All,
687            },
688            data,
689            wgpu::TexelCopyBufferLayout {
690                offset: 0,
691                bytes_per_row: Some(width * bpp),
692                rows_per_image: Some(height),
693            },
694            wgpu::Extent3d {
695                width,
696                height,
697                depth_or_array_layers: 1,
698            },
699        );
700    }
701
702    /// [`Self::write_frame`], skipped when `data` hashes identically
703    /// to the previous accepted frame. Worth it for sources that
704    /// repeat frames (paused video, looping GIFs at rest); for live
705    /// video where every frame differs, prefer [`Self::write_frame`]
706    /// and skip the hash. Returns `true` when an upload happened.
707    pub fn write_frame_if_changed(
708        &mut self,
709        device: &wgpu::Device,
710        queue: &wgpu::Queue,
711        width: u32,
712        height: u32,
713        data: &[u8],
714    ) -> bool {
715        use std::hash::{Hash, Hasher};
716        let mut hasher = std::collections::hash_map::DefaultHasher::new();
717        (width, height).hash(&mut hasher);
718        data.hash(&mut hasher);
719        let hash = hasher.finish();
720        if self.last_hash == Some(hash) && (width, height) == self.size {
721            return false;
722        }
723        self.write_frame(device, queue, width, height, data);
724        self.last_hash = Some(hash);
725        true
726    }
727
728    /// The handle to embed in the tree via
729    /// [`damascene_core::surface`]. Cheap clone; identity stays stable
730    /// across [`Self::write_frame`] calls so backend bind-group caches
731    /// stay warm.
732    pub fn app_texture(&self) -> AppTexture {
733        self.handle.clone()
734    }
735
736    /// Current texture size `(width, height)` in texels.
737    pub fn size(&self) -> (u32, u32) {
738        self.size
739    }
740}