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::{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
50pub 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 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 pub fn set_uniform_bytes(&mut self, bytes: &[u8]) {
77 self.uniforms.clear();
78 self.uniforms.extend_from_slice(bytes);
79 }
80
81 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 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 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 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 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 let needs_tex = inst.desc.texture_slots > 0;
487 if needs_tex && inst.tex_bind.is_none() {
488 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 #[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 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, 0, 0, 0, 255, 0, 0, 0, 255, 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 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}