Skip to main content

repame_sprite/
fullscreen.rs

1//! Fullscreen procedural passes: game-specific WGSL over a generated
2//! triangle, with engine-owned pipeline/uniform/texture management.
3pub const SOLID_WGSL: &str = r#"
4    struct U { color: vec4<f32>, };
5    @group(0) @binding(0) var<uniform> u: U;
6    struct VsOut {
7        @builtin(position) pos: vec4<f32>,
8    };
9    @vertex
10    fn vs_main(@builtin(vertex_index) i: u32) -> VsOut {
11        let x = f32(i / 2u) * 4.0 - 1.0;
12        let y = f32(i % 2u) * 4.0 - 1.0;
13        var out: VsOut;
14        out.pos = vec4<f32>(x, y, 0.0, 1.0);
15        return out;
16    }
17    @fragment
18    fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
19        return u.color;
20    }
21"#;
22
23use std::collections::HashMap;
24
25use repose_render_wgpu::{CallbackRenderPass, CallbackResources, ScreenDescriptor, WgpuCallback};
26
27use super::TextureFilter;
28
29/// Construction parameters for one effect.
30#[derive(Clone, Copy, Debug)]
31pub struct FullscreenDesc {
32    /// Texture slot count (`@group(1)` bindings `0..N`, sampler at `N`).
33    pub texture_slots: u32,
34    /// Shared sampler for all slots.
35    pub filter: TextureFilter,
36}
37
38/// One texture upload: tight `w`*`h`*4 RGBA8, row-major top first.
39/// Recreates the slot texture when the size changes, plain rewrite
40/// otherwise. Out-of-range or malformed uploads are dropped with a
41/// warning.
42#[derive(Clone, Debug)]
43pub struct FullscreenTexture {
44    pub slot: u32,
45    pub w: u32,
46    pub h: u32,
47    pub rgba: Vec<u8>,
48}
49
50#[derive(Clone, Copy, Debug)]
51pub struct FullscreenTextureRef<'a> {
52    pub slot: u32,
53    pub w: u32,
54    pub h: u32,
55    pub rgba: &'a [u8],
56}
57
58pub trait FullscreenTextureUpload {
59    fn slot(&self) -> u32;
60    fn width(&self) -> u32;
61    fn height(&self) -> u32;
62    fn rgba(&self) -> &[u8];
63    fn generation(&self) -> Option<u64> {
64        None
65    }
66}
67
68impl FullscreenTextureUpload for FullscreenTexture {
69    fn slot(&self) -> u32 {
70        self.slot
71    }
72
73    fn width(&self) -> u32 {
74        self.w
75    }
76
77    fn height(&self) -> u32 {
78        self.h
79    }
80
81    fn rgba(&self) -> &[u8] {
82        &self.rgba
83    }
84}
85
86impl FullscreenTextureUpload for FullscreenTextureRef<'_> {
87    fn slot(&self) -> u32 {
88        self.slot
89    }
90
91    fn width(&self) -> u32 {
92        self.w
93    }
94
95    fn height(&self) -> u32 {
96        self.h
97    }
98
99    fn rgba(&self) -> &[u8] {
100        self.rgba
101    }
102}
103
104/// A game-owned procedural effect. `Send + Sync` for the compositor
105/// thread. Rebuilt from the snapshot every frame (same pattern as the
106/// sprite batch): uniforms refresh each prepare, texture uploads ride
107/// along only on change frames.
108pub struct FullscreenPass {
109    id: String,
110    wgsl: &'static str,
111    desc: FullscreenDesc,
112    uniforms: Vec<u8>,
113    uploads: Vec<FullscreenTexture>,
114}
115
116impl FullscreenPass {
117    /// `id` disambiguates pipelines when several passes share one app;
118    /// `wgsl` must follow the module binding contract above.
119    pub fn new(id: impl Into<String>, wgsl: &'static str, desc: FullscreenDesc) -> Self {
120        Self {
121            id: id.into(),
122            wgsl,
123            desc,
124            uniforms: Vec::new(),
125            uploads: Vec::new(),
126        }
127    }
128
129    /// Replace the uniform buffer contents (raw bytes).
130    pub fn set_uniform_bytes(&mut self, bytes: &[u8]) {
131        self.uniforms.clear();
132        self.uniforms.extend_from_slice(bytes);
133    }
134
135    /// Replace the uniform buffer contents (f32 words).
136    pub fn set_uniform_f32(&mut self, words: &[f32]) {
137        self.uniforms.clear();
138        self.uniforms
139            .extend(words.iter().flat_map(|w| w.to_le_bytes()));
140    }
141
142    /// Queue texture uploads, applied in the next `prepare`.
143    pub fn extend_textures(&mut self, uploads: impl IntoIterator<Item = FullscreenTexture>) {
144        self.uploads.extend(uploads);
145    }
146}
147
148struct PassInstance {
149    key: (wgpu::TextureFormat, u32),
150    pipeline: wgpu::RenderPipeline,
151    uniforms: wgpu::Buffer,
152    uniform_cap: u64,
153    uniform_layout: wgpu::BindGroupLayout,
154    uniform_bind: wgpu::BindGroup,
155    slots: Vec<Option<(wgpu::Texture, u32, u32, Option<u64>)>>,
156    tex_bind: Option<wgpu::BindGroup>,
157    tex_layout: wgpu::BindGroupLayout,
158    sampler: wgpu::Sampler,
159    desc: FullscreenDesc,
160}
161
162struct PassResources {
163    passes: HashMap<String, PassInstance>,
164}
165
166impl FullscreenPass {
167    fn sampler_desc(filter: TextureFilter) -> wgpu::SamplerDescriptor<'static> {
168        let f = match filter {
169            TextureFilter::Nearest => wgpu::FilterMode::Nearest,
170            TextureFilter::Linear => wgpu::FilterMode::Linear,
171        };
172        let m = match filter {
173            TextureFilter::Nearest => wgpu::MipmapFilterMode::Nearest,
174            TextureFilter::Linear => wgpu::MipmapFilterMode::Linear,
175        };
176        wgpu::SamplerDescriptor {
177            label: Some("fullscreen_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: f,
182            min_filter: f,
183            mipmap_filter: m,
184            lod_min_clamp: 0.0,
185            lod_max_clamp: 1.0,
186            compare: None,
187            anisotropy_clamp: 1,
188            border_color: None,
189        }
190    }
191
192    fn ensure_resources(
193        &self,
194        device: &wgpu::Device,
195        screen: &ScreenDescriptor,
196        resources: &mut CallbackResources,
197        uniform_len: usize,
198    ) {
199        let key = (screen.target_format, screen.sample_count);
200        let fresh = resources
201            .get::<PassResources>()
202            .is_none_or(|r| !r.passes.contains_key(self.id.as_str()));
203        let stale = !fresh
204            && resources
205                .get::<PassResources>()
206                .is_some_and(|r| r.passes[self.id.as_str()].key != key);
207        if !fresh && !stale {
208            return;
209        }
210        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
211            label: Some("fullscreen_pass"),
212            source: wgpu::ShaderSource::Wgsl(self.wgsl.into()),
213        });
214        let uniform_cap = (uniform_len.max(16)) as u64;
215        let uniforms = device.create_buffer(&wgpu::BufferDescriptor {
216            label: Some("fullscreen_uniforms"),
217            size: uniform_cap,
218            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
219            mapped_at_creation: false,
220        });
221        let uniform_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
222            label: Some("fullscreen_uniform_bgl"),
223            entries: &[wgpu::BindGroupLayoutEntry {
224                binding: 0,
225                visibility: wgpu::ShaderStages::FRAGMENT,
226                ty: wgpu::BindingType::Buffer {
227                    ty: wgpu::BufferBindingType::Uniform,
228                    has_dynamic_offset: false,
229                    min_binding_size: None,
230                },
231                count: None,
232            }],
233        });
234        let mut tex_entries = Vec::with_capacity(self.desc.texture_slots as usize + 1);
235        for b in 0..self.desc.texture_slots {
236            tex_entries.push(wgpu::BindGroupLayoutEntry {
237                binding: b,
238                visibility: wgpu::ShaderStages::FRAGMENT,
239                ty: wgpu::BindingType::Texture {
240                    sample_type: wgpu::TextureSampleType::Float { filterable: true },
241                    view_dimension: wgpu::TextureViewDimension::D2,
242                    multisampled: false,
243                },
244                count: None,
245            });
246        }
247        tex_entries.push(wgpu::BindGroupLayoutEntry {
248            binding: self.desc.texture_slots,
249            visibility: wgpu::ShaderStages::FRAGMENT,
250            ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
251            count: None,
252        });
253        let tex_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
254            label: Some("fullscreen_tex_bgl"),
255            entries: &tex_entries,
256        });
257        let sampler = device.create_sampler(&Self::sampler_desc(self.desc.filter));
258        let uniform_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
259            label: Some("fullscreen_uniform_bg"),
260            layout: &uniform_layout,
261            entries: &[wgpu::BindGroupEntry {
262                binding: 0,
263                resource: uniforms.as_entire_binding(),
264            }],
265        });
266        let two_groups = [Some(&uniform_layout), Some(&tex_layout)];
267        let one_group = [Some(&uniform_layout)];
268        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
269            label: Some("fullscreen_pl"),
270            // Uniform-only passes (zero texture slots) drop the texture
271            // group entirely: their shaders declare no group(1), and the
272            // validation layer requires every layout group to be bound.
273            bind_group_layouts: if self.desc.texture_slots == 0 {
274                &one_group
275            } else {
276                &two_groups
277            },
278            immediate_size: 0,
279        });
280        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
281            label: Some("fullscreen_pipeline"),
282            layout: Some(&pipeline_layout),
283            vertex: wgpu::VertexState {
284                module: &shader,
285                entry_point: Some("vs_main"),
286                buffers: &[],
287                compilation_options: Default::default(),
288            },
289            fragment: Some(wgpu::FragmentState {
290                module: &shader,
291                entry_point: Some("fs_main"),
292                targets: &[Some(wgpu::ColorTargetState {
293                    format: screen.target_format,
294                    blend: Some(wgpu::BlendState::ALPHA_BLENDING),
295                    write_mask: wgpu::ColorWrites::ALL,
296                })],
297                compilation_options: Default::default(),
298            }),
299            primitive: wgpu::PrimitiveState {
300                topology: wgpu::PrimitiveTopology::TriangleList,
301                ..Default::default()
302            },
303            depth_stencil: Some(wgpu::DepthStencilState {
304                format: wgpu::TextureFormat::Depth24PlusStencil8,
305                depth_write_enabled: Some(false),
306                depth_compare: Some(wgpu::CompareFunction::Always),
307                stencil: wgpu::StencilState {
308                    front: wgpu::StencilFaceState {
309                        compare: wgpu::CompareFunction::LessEqual,
310                        ..Default::default()
311                    },
312                    back: wgpu::StencilFaceState {
313                        compare: wgpu::CompareFunction::LessEqual,
314                        ..Default::default()
315                    },
316                    ..Default::default()
317                },
318                bias: wgpu::DepthBiasState::default(),
319            }),
320            multisample: wgpu::MultisampleState {
321                count: screen.sample_count,
322                mask: !0,
323                alpha_to_coverage_enabled: false,
324            },
325            multiview_mask: None,
326            cache: None,
327        });
328        let instance = PassInstance {
329            key,
330            pipeline,
331            uniforms,
332            uniform_cap,
333            uniform_layout,
334            uniform_bind,
335            slots: vec![None; self.desc.texture_slots as usize],
336            tex_bind: None,
337            tex_layout,
338            sampler,
339            desc: self.desc,
340        };
341        match resources.get_mut::<PassResources>() {
342            Some(all) => {
343                all.passes.insert(self.id.clone(), instance);
344            }
345            None => {
346                let mut all = PassResources {
347                    passes: HashMap::new(),
348                };
349                all.passes.insert(self.id.clone(), instance);
350                resources.insert(all);
351            }
352        }
353    }
354
355    fn apply_uploads<T: FullscreenTextureUpload>(
356        &self,
357        device: &wgpu::Device,
358        queue: &wgpu::Queue,
359        inst: &mut PassInstance,
360        uploads: &[T],
361    ) {
362        if uploads.is_empty() {
363            return;
364        }
365        let mut relayout = false;
366        for up in uploads {
367            let slot = up.slot() as usize;
368            let width = up.width();
369            let height = up.height();
370            let rgba = up.rgba();
371            if slot >= inst.slots.len() || width == 0 || height == 0 {
372                log::warn!("fullscreen: dropping bad upload slot={}", up.slot());
373                continue;
374            }
375            if rgba.len() != width as usize * height as usize * 4 {
376                log::warn!(
377                    "fullscreen: dropping malformed upload slot={} {}x{} ({} bytes)",
378                    up.slot(),
379                    width,
380                    height,
381                    rgba.len()
382                );
383                continue;
384            }
385            let generation = up.generation();
386            let size_changed = match &inst.slots[slot] {
387                Some((_, w, h, _)) => *w != width || *h != height,
388                None => true,
389            };
390            let fresh = match &inst.slots[slot] {
391                Some((_, _, _, applied_generation)) => {
392                    size_changed || *applied_generation != generation
393                }
394                None => true,
395            };
396            if !fresh && generation.is_some() {
397                continue;
398            }
399            if size_changed {
400                let texture = device.create_texture(&wgpu::TextureDescriptor {
401                    label: Some("fullscreen_art"),
402                    size: wgpu::Extent3d {
403                        width,
404                        height,
405                        depth_or_array_layers: 1,
406                    },
407                    mip_level_count: 1,
408                    sample_count: 1,
409                    dimension: wgpu::TextureDimension::D2,
410                    format: wgpu::TextureFormat::Rgba8UnormSrgb,
411                    usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
412                    view_formats: &[],
413                });
414                inst.slots[slot] = Some((texture, width, height, generation));
415                relayout = true;
416            }
417            let (texture, _, _, _) = inst.slots[slot].as_ref().expect("stored above");
418            queue.write_texture(
419                wgpu::TexelCopyTextureInfo {
420                    texture,
421                    mip_level: 0,
422                    origin: wgpu::Origin3d::ZERO,
423                    aspect: wgpu::TextureAspect::All,
424                },
425                rgba,
426                wgpu::TexelCopyBufferLayout {
427                    offset: 0,
428                    bytes_per_row: Some(width * 4),
429                    rows_per_image: Some(height),
430                },
431                wgpu::Extent3d {
432                    width,
433                    height,
434                    depth_or_array_layers: 1,
435                },
436            );
437            if !size_changed && let Some((_, _, _, applied_generation)) = inst.slots[slot].as_mut()
438            {
439                *applied_generation = generation;
440            }
441        }
442        if relayout && inst.slots.iter().all(|s| s.is_some()) {
443            let views: Vec<wgpu::TextureView> = inst
444                .slots
445                .iter()
446                .map(|slot| {
447                    slot.as_ref()
448                        .expect("all slots checked Some above")
449                        .0
450                        .create_view(&wgpu::TextureViewDescriptor::default())
451                })
452                .collect();
453            let mut entries = Vec::with_capacity(views.len() + 1);
454            for (b, view) in views.iter().enumerate() {
455                entries.push(wgpu::BindGroupEntry {
456                    binding: b as u32,
457                    resource: wgpu::BindingResource::TextureView(view),
458                });
459            }
460            entries.push(wgpu::BindGroupEntry {
461                binding: inst.desc.texture_slots,
462                resource: wgpu::BindingResource::Sampler(&inst.sampler),
463            });
464            inst.tex_bind = Some(device.create_bind_group(&wgpu::BindGroupDescriptor {
465                label: Some("fullscreen_tex_bg"),
466                layout: &inst.tex_layout,
467                entries: &entries,
468            }));
469        }
470    }
471}
472
473impl FullscreenPass {
474    /// Prepare with explicit data instead of the stored snapshot.
475    /// Lets game-owned passes (custom snapshot types) delegate without
476    /// rebuilding engine state: same pipeline, same draw calls.
477    pub fn prepare_with<T: FullscreenTextureUpload>(
478        &self,
479        device: &wgpu::Device,
480        queue: &wgpu::Queue,
481        screen: &ScreenDescriptor,
482        resources: &mut CallbackResources,
483        uniforms: &[u8],
484        uploads: &[T],
485    ) -> Vec<wgpu::CommandBuffer> {
486        self.ensure_resources(device, screen, resources, uniforms.len());
487        let Some(all) = resources.get_mut::<PassResources>() else {
488            return Vec::new();
489        };
490        let Some(inst) = all.passes.get_mut(self.id.as_str()) else {
491            return Vec::new();
492        };
493
494        // frame's data outgrows it (effect snapshots are fixed-size in
495        // practice, so this is a safety net, not a hot path).
496        let need = uniforms.len() as u64;
497        if need > inst.uniform_cap {
498            inst.uniforms = device.create_buffer(&wgpu::BufferDescriptor {
499                label: Some("fullscreen_uniforms"),
500                size: need.max(16),
501                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
502                mapped_at_creation: false,
503            });
504            inst.uniform_cap = need.max(16);
505            inst.uniform_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
506                label: Some("fullscreen_uniform_bg"),
507                layout: &inst.uniform_layout,
508                entries: &[wgpu::BindGroupEntry {
509                    binding: 0,
510                    resource: inst.uniforms.as_entire_binding(),
511                }],
512            });
513        }
514        if !uniforms.is_empty() {
515            queue.write_buffer(&inst.uniforms, 0, uniforms);
516        }
517        self.apply_uploads(device, queue, inst, uploads);
518        Vec::new()
519    }
520}
521
522impl WgpuCallback for FullscreenPass {
523    fn resource_key(&self) -> Option<&str> {
524        Some(&self.id)
525    }
526
527    fn prepare(
528        &self,
529        device: &wgpu::Device,
530        queue: &wgpu::Queue,
531        _encoder: &mut wgpu::CommandEncoder,
532        screen: &ScreenDescriptor,
533        resources: &mut CallbackResources,
534    ) -> Vec<wgpu::CommandBuffer> {
535        self.prepare_with(
536            device,
537            queue,
538            screen,
539            resources,
540            &self.uniforms,
541            &self.uploads,
542        )
543    }
544
545    fn paint(
546        &self,
547        _info: repose_core::PaintCallbackInfo,
548        rpass: &mut CallbackRenderPass<'_, '_>,
549        resources: &CallbackResources,
550    ) {
551        let Some(all) = resources.get::<PassResources>() else {
552            return;
553        };
554        let Some(inst) = all.passes.get(self.id.as_str()) else {
555            return;
556        };
557
558        // need no art: draw with the uniform bind group alone. Textured
559        // passes with no uploads yet keep the old behavior (skip: nothing
560        // to shade with).
561        let needs_tex = inst.desc.texture_slots > 0;
562        if needs_tex && inst.tex_bind.is_none() {
563            // Art missing: nothing to shade with yet.
564            return;
565        }
566        rpass.set_pipeline(&inst.pipeline);
567        rpass.set_bind_group(0, &inst.uniform_bind, &[]);
568        if let Some(tex_bind) = &inst.tex_bind {
569            rpass.set_bind_group(1, tex_bind, &[]);
570        }
571        rpass.draw(0..3, 0..1);
572    }
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578
579    /// Mechanism proof, independent of any game content: a trivial
580    /// uniform-driven shader paints exact pixels offscreen.
581    #[test]
582    fn offscreen_fullscreen_paints_uniform_color() {
583        use repose_core::{Color, Rect, Scene, SceneNode};
584        use repose_render_wgpu::{Callback, offscreen::OffscreenRenderer};
585
586        const WGSL: &str = r#"
587            struct U { color: vec4<f32>, };
588            @group(0) @binding(0) var<uniform> u: U;
589            @group(1) @binding(0) var t: texture_2d<f32>;
590            @group(1) @binding(1) var s: sampler;
591            struct VsOut {
592                @builtin(position) pos: vec4<f32>,
593                @location(0) uv: vec2<f32>,
594            };
595            @vertex
596            fn vs_main(@builtin(vertex_index) i: u32) -> VsOut {
597                let x = f32(i / 2u) * 4.0 - 1.0;
598                let y = f32(i % 2u) * 4.0 - 1.0;
599                var out: VsOut;
600                out.pos = vec4<f32>(x, y, 0.0, 1.0);
601                out.uv = vec2<f32>((x + 1.0) * 0.5, (1.0 - y) * 0.5);
602                return out;
603            }
604            @fragment
605            fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
606                // Sample the art so the texture path is proven too.
607                let tex = textureSample(t, s, vec2<f32>(0.5, 0.5));
608                return vec4<f32>(u.color.rgb * tex.rgb, 1.0);
609            }
610        "#;
611
612        let mut renderer = match OffscreenRenderer::new_blocking(64, 64, 1) {
613            Ok(r) => r,
614            Err(e) => {
615                eprintln!("SKIP fullscreen test (no GPU): {e}");
616                return;
617            }
618        };
619        let mut pass = FullscreenPass::new(
620            "test-solid",
621            WGSL,
622            FullscreenDesc {
623                texture_slots: 1,
624                filter: TextureFilter::Nearest,
625            },
626        );
627        // Opaque red uniforms; art texel (0,0) green -> output black.
628        // (red * green = black proves the texture is really sampled.)
629        pass.set_uniform_f32(&[1.0, 0.0, 0.0, 1.0]);
630        pass.extend_textures([FullscreenTexture {
631            slot: 0,
632            w: 2,
633            h: 2,
634            rgba: vec![
635                0, 255, 0, 255, //
636                0, 0, 0, 255, //
637                0, 0, 0, 255, //
638                0, 0, 0, 255,
639            ],
640        }]);
641        let scene = Scene {
642            clear_color: Color::from_rgba(0, 0, 0, 255),
643            nodes: vec![SceneNode::Callback {
644                rect: Rect {
645                    x: 0.0,
646                    y: 0.0,
647                    w: 64.0,
648                    h: 64.0,
649                },
650                payload: Callback::new(pass),
651            }],
652        };
653        let px = renderer
654            .render_rgba(&scene, Some([0.0, 0.0, 0.0, 1.0]))
655            .expect("render");
656        // Center pixel: red uniforms x green texel = black.
657        let i = ((32 * 64 + 32) * 4) as usize;
658        assert_eq!([px[i], px[i + 1], px[i + 2], px[i + 3]], [0, 0, 0, 255]);
659    }
660}