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