1pub 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#[derive(Clone, Copy, Debug)]
31pub struct FullscreenDesc {
32 pub texture_slots: u32,
34 pub filter: TextureFilter,
36}
37
38#[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
104pub 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 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 pub fn set_uniform_bytes(&mut self, bytes: &[u8]) {
131 self.uniforms.clear();
132 self.uniforms.extend_from_slice(bytes);
133 }
134
135 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 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 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 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 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 let needs_tex = inst.desc.texture_slots > 0;
562 if needs_tex && inst.tex_bind.is_none() {
563 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 #[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 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, 0, 0, 0, 255, 0, 0, 0, 255, 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 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}