Skip to main content

game_toolkit_gfx/
graphics.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use anyhow::{Context, Result};
6use wgpu::util::DeviceExt;
7use winit::window::Window;
8
9use crate::camera::{Camera2D, CameraUniform};
10use crate::camera3d::{Camera, Camera3D};
11use crate::frame::Frame;
12use crate::mesh::{MeshBatcher, MeshId, MeshInstance, MeshRegistry, MeshVertex};
13use crate::primitives::PrimitiveBatcher;
14use crate::sprite::SpriteBatcher;
15use crate::target::Targets;
16use crate::text::TextSystem;
17use crate::texture::{TextureId, TextureRegistry};
18#[cfg(feature = "vector")]
19use crate::vector::VectorPass;
20
21pub struct Graphics {
22    pub(crate) device: wgpu::Device,
23    pub(crate) queue: wgpu::Queue,
24    surface: wgpu::Surface<'static>,
25    surface_config: wgpu::SurfaceConfiguration,
26    window: Arc<Window>,
27    pub camera: Camera2D,
28    camera_buf: wgpu::Buffer,
29    pub(crate) camera_bg: wgpu::BindGroup,
30    pub(crate) sprites: SpriteBatcher,
31    pub(crate) primitives: PrimitiveBatcher,
32    pub(crate) text: TextSystem,
33    pub(crate) textures: TextureRegistry,
34    pub(crate) clear_color: [f32; 4],
35    texture_paths: HashMap<PathBuf, TextureId>,
36    sample_count: u32,
37    depth_format: Option<wgpu::TextureFormat>,
38    /// Multisampled color target (resolved to the surface) when `sample_count > 1`.
39    msaa_view: Option<wgpu::TextureView>,
40    /// Depth attachment when `depth_format` is set. Recreated on resize.
41    depth_view: Option<wgpu::TextureView>,
42    /// Perspective camera for 3D meshes. Set its fields to move the view.
43    pub camera3d: Camera3D,
44    camera3d_buf: wgpu::Buffer,
45    camera3d_bg: wgpu::BindGroup,
46    meshes: MeshRegistry,
47    mesh_batcher: MeshBatcher,
48    /// Vello vector backend; composites over the 2D layers each frame. `None` on adapters
49    /// without compute shaders (WebGL2), where vector drawing is skipped.
50    #[cfg(feature = "vector")]
51    pub(crate) vector: Option<VectorPass>,
52    /// Whether the surface supports `COPY_SRC` (required to read frames back for screenshots).
53    capture_copy_src: bool,
54    /// Path to write a screenshot of the next presented frame, set by `request_screenshot`.
55    pending_screenshot: Option<PathBuf>,
56}
57
58impl Graphics {
59    pub async fn new(
60        window: Arc<Window>,
61        vsync: bool,
62        depth_format: Option<wgpu::TextureFormat>,
63        msaa_samples: u32,
64    ) -> Result<Self> {
65        let size = window.inner_size();
66        let (width, height) = (size.width.max(1), size.height.max(1));
67
68        let mut idesc = wgpu::InstanceDescriptor::new_without_display_handle();
69        // Web: WebGPU where the browser has it, WebGL2 otherwise. Native: Vulkan/Metal/DX12.
70        idesc.backends = if cfg!(target_arch = "wasm32") {
71            wgpu::Backends::BROWSER_WEBGPU | wgpu::Backends::GL
72        } else {
73            wgpu::Backends::PRIMARY
74        };
75        // A browser may expose `navigator.gpu` yet hand out no adapter (headless Chrome, blocked
76        // GPUs). This probes for a real WebGPU adapter and drops to WebGL2 when there is none;
77        // natively it is a plain `Instance::new`.
78        let instance = wgpu::util::new_instance_with_webgpu_detection(idesc).await;
79        let surface = instance
80            .create_surface(window.clone())
81            .context("create_surface")?;
82        let adapter = instance
83            .request_adapter(&wgpu::RequestAdapterOptions {
84                power_preference: wgpu::PowerPreference::HighPerformance,
85                compatible_surface: Some(&surface),
86                force_fallback_adapter: false,
87                apply_limit_buckets: false,
88            })
89            .await
90            .context("no compatible adapter")?;
91
92        let backend = adapter.get_info().backend;
93        log::info!("wgpu adapter: {:?} ({backend:?})", adapter.get_info().name);
94        // WebGL2 can't meet even the downlevel limits (and has no compute, so no vello).
95        // Otherwise vello needs the standard (non-downlevel) limits; bump them when it's on.
96        let required_limits = if backend == wgpu::Backend::Gl {
97            wgpu::Limits::downlevel_webgl2_defaults()
98        } else if cfg!(feature = "vector") {
99            wgpu::Limits::default()
100        } else {
101            wgpu::Limits::downlevel_defaults()
102        }
103        .using_resolution(adapter.limits());
104        let (device, queue) = adapter
105            .request_device(&wgpu::DeviceDescriptor {
106                label: Some("toolkit.device"),
107                required_features: wgpu::Features::empty(),
108                required_limits,
109                memory_hints: wgpu::MemoryHints::Performance,
110                ..Default::default()
111            })
112            .await
113            .context("request_device")?;
114
115        let caps = surface.get_capabilities(&adapter);
116        let format = caps
117            .formats
118            .iter()
119            .copied()
120            .find(wgpu::TextureFormat::is_srgb)
121            .unwrap_or(caps.formats[0]);
122        let present_mode = if vsync {
123            wgpu::PresentMode::AutoVsync
124        } else {
125            wgpu::PresentMode::AutoNoVsync
126        };
127        // Add COPY_SRC when the surface supports it so frames can be read back for
128        // screenshots; fall back to render-only otherwise (screenshots become a no-op).
129        let capture_copy_src = caps.usages.contains(wgpu::TextureUsages::COPY_SRC);
130        let surface_usage = if capture_copy_src {
131            wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC
132        } else {
133            wgpu::TextureUsages::RENDER_ATTACHMENT
134        };
135        let surface_config = wgpu::SurfaceConfiguration {
136            usage: surface_usage,
137            format,
138            width,
139            height,
140            present_mode,
141            desired_maximum_frame_latency: 2,
142            alpha_mode: caps.alpha_modes[0],
143            view_formats: vec![],
144            color_space: wgpu::SurfaceColorSpace::Auto,
145        };
146        surface.configure(&device, &surface_config);
147
148        let camera = Camera2D::new(width as f32, height as f32);
149        let camera_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
150            label: Some("camera.buf"),
151            contents: bytemuck::bytes_of(&CameraUniform {
152                view_proj: camera.view_proj(),
153            }),
154            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
155        });
156        let camera_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
157            label: Some("camera.bgl"),
158            entries: &[wgpu::BindGroupLayoutEntry {
159                binding: 0,
160                visibility: wgpu::ShaderStages::VERTEX,
161                ty: wgpu::BindingType::Buffer {
162                    ty: wgpu::BufferBindingType::Uniform,
163                    has_dynamic_offset: false,
164                    min_binding_size: None,
165                },
166                count: None,
167            }],
168        });
169        let camera_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
170            label: Some("camera.bg"),
171            layout: &camera_bgl,
172            entries: &[wgpu::BindGroupEntry {
173                binding: 0,
174                resource: camera_buf.as_entire_binding(),
175            }],
176        });
177
178        let camera3d = Camera3D::new(width as f32 / height.max(1) as f32);
179        let camera3d_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
180            label: Some("camera3d.buf"),
181            contents: bytemuck::bytes_of(&CameraUniform {
182                view_proj: camera3d.view_proj(),
183            }),
184            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
185        });
186        let camera3d_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
187            label: Some("camera3d.bg"),
188            layout: &camera_bgl,
189            entries: &[wgpu::BindGroupEntry {
190                binding: 0,
191                resource: camera3d_buf.as_entire_binding(),
192            }],
193        });
194
195        let sample_count = msaa_samples.max(1);
196        let textures = TextureRegistry::new(&device, &queue);
197        let sprites = SpriteBatcher::new(
198            &device,
199            format,
200            &camera_bgl,
201            &textures.layout,
202            sample_count,
203            depth_format,
204        );
205        let primitives =
206            PrimitiveBatcher::new(&device, format, &camera_bgl, sample_count, depth_format);
207        let text = TextSystem::new(
208            &device,
209            &queue,
210            format,
211            width,
212            height,
213            sample_count,
214            depth_format,
215        );
216        let (msaa_view, depth_view) =
217            make_attachments(&device, &surface_config, sample_count, depth_format);
218        let meshes = MeshRegistry::new();
219        let mesh_batcher =
220            MeshBatcher::new(&device, format, &camera_bgl, sample_count, depth_format);
221        #[cfg(feature = "vector")]
222        let vector = if adapter
223            .get_downlevel_capabilities()
224            .flags
225            .contains(wgpu::DownlevelFlags::COMPUTE_SHADERS)
226        {
227            Some(VectorPass::new(&device, format, width, height))
228        } else {
229            log::error!("vector graphics disabled: adapter has no compute shaders ({backend:?})");
230            None
231        };
232
233        Ok(Self {
234            device,
235            queue,
236            surface,
237            surface_config,
238            window,
239            camera,
240            camera_buf,
241            camera_bg,
242            sprites,
243            primitives,
244            text,
245            textures,
246            clear_color: [0.0, 0.0, 0.0, 1.0],
247            texture_paths: HashMap::new(),
248            sample_count,
249            depth_format,
250            msaa_view,
251            depth_view,
252            camera3d,
253            camera3d_buf,
254            camera3d_bg,
255            meshes,
256            mesh_batcher,
257            #[cfg(feature = "vector")]
258            vector,
259            capture_copy_src,
260            pending_screenshot: None,
261        })
262    }
263
264    pub fn resize(&mut self, width: u32, height: u32) {
265        if width == 0 || height == 0 {
266            return;
267        }
268        self.surface_config.width = width;
269        self.surface_config.height = height;
270        self.surface.configure(&self.device, &self.surface_config);
271        let (msaa_view, depth_view) = make_attachments(
272            &self.device,
273            &self.surface_config,
274            self.sample_count,
275            self.depth_format,
276        );
277        self.msaa_view = msaa_view;
278        self.depth_view = depth_view;
279        self.camera.resize(width as f32, height as f32);
280        self.camera3d.resize(width as f32, height as f32);
281        self.text.resize(&self.queue, width, height);
282        #[cfg(feature = "vector")]
283        if let Some(vector) = self.vector.as_mut() {
284            vector.resize(&self.device, width, height);
285        }
286    }
287
288    pub fn window(&self) -> &Arc<Window> {
289        &self.window
290    }
291
292    pub fn size(&self) -> (u32, u32) {
293        (self.surface_config.width, self.surface_config.height)
294    }
295
296    pub fn surface_format(&self) -> wgpu::TextureFormat {
297        self.surface_config.format
298    }
299
300    pub fn device(&self) -> &wgpu::Device {
301        &self.device
302    }
303    pub fn queue(&self) -> &wgpu::Queue {
304        &self.queue
305    }
306
307    /// Load a PNG from disk and remember its path for [`Graphics::refresh_textures`]. Not
308    /// available on the web; use [`Graphics::load_texture_bytes`] (or `ctx.load_texture`,
309    /// which picks the right one per platform).
310    #[cfg(not(target_arch = "wasm32"))]
311    pub fn load_texture(&mut self, path: impl AsRef<Path>) -> Result<TextureId> {
312        let path = path.as_ref();
313        let id = self.textures.load_file(&self.device, &self.queue, path)?;
314        let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
315        self.texture_paths.insert(canon, id);
316        Ok(id)
317    }
318
319    /// Register an extra font face (TTF/OTF bytes) as a glyph fallback for text rendering.
320    pub fn load_font_bytes(&mut self, bytes: Vec<u8>) {
321        self.text.load_font_bytes(bytes);
322    }
323
324    /// Decode a PNG held in memory and upload it. `label` names the texture in GPU debuggers
325    /// and error messages.
326    pub fn load_texture_bytes(&mut self, bytes: &[u8], label: Option<&str>) -> Result<TextureId> {
327        self.textures
328            .load_bytes(&self.device, &self.queue, bytes, label)
329    }
330
331    /// Re-decode any texture whose backing file is in `changed_paths` and update its bind
332    /// group in place. Returns the number of textures actually reloaded.
333    pub fn refresh_textures<I, P>(&mut self, changed_paths: I) -> usize
334    where
335        I: IntoIterator<Item = P>,
336        P: AsRef<Path>,
337    {
338        let mut n = 0;
339        for p in changed_paths {
340            let canon = p
341                .as_ref()
342                .canonicalize()
343                .unwrap_or_else(|_| p.as_ref().to_path_buf());
344            if let Some(&id) = self.texture_paths.get(&canon) {
345                match self.textures.reload(&self.device, &self.queue, id, &canon) {
346                    Ok(()) => n += 1,
347                    Err(e) => log::warn!("reload {} failed: {e:?}", canon.display()),
348                }
349            }
350        }
351        n
352    }
353
354    pub fn create_texture_rgba(
355        &mut self,
356        width: u32,
357        height: u32,
358        rgba: &[u8],
359        label: Option<&str>,
360    ) -> TextureId {
361        self.textures
362            .create_from_rgba(&self.device, &self.queue, width, height, rgba, label)
363    }
364
365    pub fn white_texture(&self) -> TextureId {
366        self.textures.white()
367    }
368
369    /// Upload a static mesh (positions + normals, indexed) and return its handle. Meshes are
370    /// drawn depth-tested via [`Graphics::draw_mesh`] using the perspective [`Self::camera3d`].
371    pub fn create_mesh(&mut self, vertices: &[MeshVertex], indices: &[u16]) -> MeshId {
372        self.meshes.create(&self.device, vertices, indices)
373    }
374
375    /// Queue one instance of `mesh` for this frame. Meshes render before the 2D layers, so
376    /// 2D sprites, primitives and text draw on top of them.
377    pub fn draw_mesh(&mut self, mesh: MeshId, instance: MeshInstance) {
378        self.mesh_batcher.push(mesh, instance);
379    }
380
381    /// Save a PNG of the next presented frame to `path`. The read-back stalls that frame, so
382    /// this is for debug/dev use. A no-op (with a warning) if the surface lacks `COPY_SRC`,
383    /// and on the web, where there is neither a blocking read-back nor a filesystem.
384    pub fn request_screenshot(&mut self, path: impl Into<PathBuf>) {
385        if cfg!(target_arch = "wasm32") {
386            log::warn!("screenshot unsupported on the web");
387            return;
388        }
389        if !self.capture_copy_src {
390            log::warn!("screenshot unsupported: surface does not allow COPY_SRC read-back");
391            return;
392        }
393        self.pending_screenshot = Some(path.into());
394    }
395
396    pub fn begin_frame(&mut self) -> Result<Frame> {
397        self.queue.write_buffer(
398            &self.camera_buf,
399            0,
400            bytemuck::bytes_of(&CameraUniform {
401                view_proj: self.camera.view_proj(),
402            }),
403        );
404        self.queue.write_buffer(
405            &self.camera3d_buf,
406            0,
407            bytemuck::bytes_of(&CameraUniform {
408                view_proj: self.camera3d.view_proj(),
409            }),
410        );
411
412        let surface_texture = match self.surface.get_current_texture() {
413            wgpu::CurrentSurfaceTexture::Success(t)
414            | wgpu::CurrentSurfaceTexture::Suboptimal(t) => t,
415            other => anyhow::bail!("surface unavailable: {other:?}"),
416        };
417        let view = surface_texture
418            .texture
419            .create_view(&wgpu::TextureViewDescriptor::default());
420        let encoder = self
421            .device
422            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
423                label: Some("frame.encoder"),
424            });
425        Ok(Frame {
426            encoder: Some(encoder),
427            view,
428            surface_texture: Some(surface_texture),
429            clear_color: self.clear_color,
430            flushed: false,
431        })
432    }
433
434    pub fn present(&mut self, mut frame: Frame) {
435        if !frame.flushed {
436            self.flush_into(&mut frame);
437        }
438
439        if let Some(encoder) = frame.encoder.take() {
440            self.queue.submit(std::iter::once(encoder.finish()));
441        }
442
443        // Capture the just-submitted surface before presenting (the queue runs the read-back
444        // copy after the render submit, so it sees the finished frame).
445        let shot_path = self.pending_screenshot.take();
446        if let (Some(path), Some(st)) = (&shot_path, frame.surface_texture.as_ref()) {
447            self.save_screenshot(&st.texture, path);
448        }
449
450        if let Some(st) = frame.surface_texture.take() {
451            self.window.pre_present_notify();
452            self.queue.present(st);
453        }
454    }
455
456    #[cfg(target_arch = "wasm32")]
457    fn save_screenshot(&self, _texture: &wgpu::Texture, _path: &Path) {}
458
459    /// Read the surface texture back to the CPU and save it. Synchronous (blocks on a poll),
460    /// so only used for the occasional debug screenshot. `miniscreenshot-wgpu` handles the
461    /// staging copy, row-padding strip, and BGRA->RGBA conversion.
462    #[cfg(not(target_arch = "wasm32"))]
463    fn save_screenshot(&self, texture: &wgpu::Texture, path: &Path) {
464        match miniscreenshot_wgpu::capture(&self.device, &self.queue, texture) {
465            Ok(shot) => match shot.save(path) {
466                Ok(()) => log::info!("saved screenshot {}", path.display()),
467                Err(e) => log::warn!("screenshot save failed: {e}"),
468            },
469            Err(e) => log::warn!("screenshot capture failed: {e}"),
470        }
471    }
472
473    /// Flush all queued 2D layers (sprites + primitives + text) into the current `frame`.
474    /// Called automatically when a [`crate::Painter`] is dropped; call manually before
475    /// stacking other passes (e.g. egui) when no painter was created.
476    pub fn flush_pending(&mut self, frame: &mut Frame) {
477        self.flush_into(frame);
478    }
479
480    pub(crate) fn flush_into(&mut self, frame: &mut Frame) {
481        if frame.flushed {
482            return;
483        }
484        let Some(encoder) = frame.encoder.as_mut() else {
485            return;
486        };
487        // Interleave sprites and circles by layer so a circle on layer -1 draws under a
488        // sprite on layer 0; within a layer, sprites draw under circles. Text renders once
489        // on top (glyphon prepares a single vertex buffer per call, so it is not layered).
490        let mut layers = std::collections::BTreeSet::new();
491        self.sprites.collect_layers(&mut layers);
492        self.primitives.collect_layers(&mut layers);
493
494        // Upload each batcher's instances exactly once before drawing: `queue.write_buffer`
495        // is not part of the encoder command stream, so writing per layer pass would clobber
496        // earlier layers' instance data.
497        self.sprites.upload(&self.device, &self.queue);
498        self.primitives.upload(&self.device, &self.queue);
499
500        // When multisampling, draws target the MSAA texture and resolve to the surface;
501        // otherwise they target the surface directly.
502        let (color, resolve) = match self.msaa_view.as_ref() {
503            Some(msaa) => (msaa, Some(&frame.view)),
504            None => (&frame.view, None),
505        };
506        let targets = Targets {
507            color,
508            resolve,
509            depth: self.depth_view.as_ref(),
510        };
511
512        // Clear color (and depth) once up front; every layer pass then loads onto it.
513        let _ = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
514            label: Some("clear.pass"),
515            color_attachments: &[Some(targets.color_attachment(wgpu::LoadOp::Clear(
516                wgpu::Color {
517                    r: frame.clear_color[0] as f64,
518                    g: frame.clear_color[1] as f64,
519                    b: frame.clear_color[2] as f64,
520                    a: frame.clear_color[3] as f64,
521                },
522            )))],
523            depth_stencil_attachment: targets.depth_attachment(wgpu::LoadOp::Clear(1.0)),
524            occlusion_query_set: None,
525            timestamp_writes: None,
526            multiview_mask: None,
527        });
528
529        // 3D meshes draw first, depth-tested, so the 2D layers (which do not write depth)
530        // composite on top of them.
531        self.mesh_batcher.draw(
532            &self.device,
533            &self.queue,
534            &self.meshes,
535            encoder,
536            &targets,
537            &self.camera3d_bg,
538        );
539
540        for &layer in &layers {
541            self.sprites
542                .draw_layer(layer, encoder, &targets, &self.camera_bg, &self.textures);
543            self.primitives
544                .draw_layer(layer, encoder, &targets, &self.camera_bg);
545        }
546
547        self.text
548            .flush(&self.device, &self.queue, encoder, &targets);
549
550        // Vector content composites on top of the 2D layers, directly onto the surface.
551        #[cfg(feature = "vector")]
552        if let Some(vector) = self.vector.as_mut() {
553            vector.render_and_composite(&self.device, &self.queue, encoder, &frame.view);
554        }
555
556        self.sprites.clear();
557        self.primitives.clear();
558        self.mesh_batcher.clear();
559        frame.flushed = true;
560    }
561}
562
563/// Allocate the MSAA color target (when `sample_count > 1`) and depth target (when a depth
564/// format is set), both sized to the surface. Returns `(msaa_view, depth_view)`.
565fn make_attachments(
566    device: &wgpu::Device,
567    config: &wgpu::SurfaceConfiguration,
568    sample_count: u32,
569    depth_format: Option<wgpu::TextureFormat>,
570) -> (Option<wgpu::TextureView>, Option<wgpu::TextureView>) {
571    let size = wgpu::Extent3d {
572        width: config.width,
573        height: config.height,
574        depth_or_array_layers: 1,
575    };
576    let msaa_view = (sample_count > 1).then(|| {
577        device
578            .create_texture(&wgpu::TextureDescriptor {
579                label: Some("msaa.color"),
580                size,
581                mip_level_count: 1,
582                sample_count,
583                dimension: wgpu::TextureDimension::D2,
584                format: config.format,
585                usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
586                view_formats: &[],
587            })
588            .create_view(&wgpu::TextureViewDescriptor::default())
589    });
590    let depth_view = depth_format.map(|format| {
591        device
592            .create_texture(&wgpu::TextureDescriptor {
593                label: Some("depth"),
594                size,
595                mip_level_count: 1,
596                sample_count,
597                dimension: wgpu::TextureDimension::D2,
598                format,
599                usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
600                view_formats: &[],
601            })
602            .create_view(&wgpu::TextureViewDescriptor::default())
603    });
604    (msaa_view, depth_view)
605}