Skip to main content

game_toolkit_gfx/
canvas.rs

1//! Virtual resolution: the painter draws into a fixed-size canvas texture, which is then
2//! scaled onto the window.
3
4/// How the virtual-resolution canvas is scaled onto the window. See
5/// [`GraphicsConfig::virtual_resolution`](crate::GraphicsConfig::virtual_resolution).
6#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
7pub enum ScaleMode {
8    /// Scale by the largest whole number that fits, so every canvas pixel becomes an equal
9    /// square of window pixels; the rest of the window is border. Falls back to [`Fit`]
10    /// when the window is smaller than the canvas.
11    ///
12    /// [`Fit`]: ScaleMode::Fit
13    #[default]
14    Integer,
15    /// Scale as large as fits while keeping the aspect ratio, by a fractional factor if need
16    /// be (e.g. 800 × 600 on a 1080p screen). Canvas pixels then differ in size by one window
17    /// pixel; [`ScaleFilter::Linear`] or a post-process filter hides that.
18    Fit,
19    /// Fill the whole window, distorting the aspect ratio.
20    Stretch,
21}
22
23/// Texture filter for scaling the canvas onto the window.
24#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
25pub enum ScaleFilter {
26    /// Crisp pixels (default).
27    #[default]
28    Nearest,
29    /// Smooth, slightly blurry; can look better with [`ScaleMode::Fit`].
30    Linear,
31}
32
33impl ScaleFilter {
34    /// Position in the `[nearest, linear]` sampler and bind-group arrays. A match rather than
35    /// `as usize`, so a new variant fails to compile here instead of picking the wrong one.
36    pub(crate) const fn index(self) -> usize {
37        match self {
38            Self::Nearest => 0,
39            Self::Linear => 1,
40        }
41    }
42}
43
44/// Where the canvas lands in the window, in physical window pixels.
45#[derive(Copy, Clone, Debug, PartialEq)]
46pub struct CanvasRect {
47    /// Top-left corner.
48    pub origin: [f32; 2],
49    /// Width and height.
50    pub size: [f32; 2],
51}
52
53impl CanvasRect {
54    /// Map a window position (e.g. a raw cursor position) to canvas pixels for a canvas of
55    /// `canvas` size. Positions in the border map outside `0..canvas`.
56    pub fn window_to_canvas(&self, canvas: [u32; 2], pos: [f32; 2]) -> [f32; 2] {
57        let s = self.canvas_per_window(canvas);
58        [
59            (pos[0] - self.origin[0]) * s[0],
60            (pos[1] - self.origin[1]) * s[1],
61        ]
62    }
63
64    /// Canvas pixels per window pixel along each axis, for scaling deltas.
65    pub fn canvas_per_window(&self, canvas: [u32; 2]) -> [f32; 2] {
66        [
67            canvas[0] as f32 / self.size[0].max(1e-6),
68            canvas[1] as f32 / self.size[1].max(1e-6),
69        ]
70    }
71}
72
73/// Place a `canvas`-sized image in a `window`-sized one with `mode`. Origins and sizes are
74/// whole pixels so the edges stay sharp.
75pub(crate) fn layout(window: [u32; 2], canvas: [u32; 2], mode: ScaleMode) -> CanvasRect {
76    let (ww, wh) = (window[0].max(1) as f32, window[1].max(1) as f32);
77    let (cw, ch) = (canvas[0].max(1) as f32, canvas[1].max(1) as f32);
78    let fit = (ww / cw).min(wh / ch);
79    let scale = match mode {
80        ScaleMode::Stretch => {
81            return CanvasRect {
82                origin: [0.0, 0.0],
83                size: [ww, wh],
84            };
85        }
86        ScaleMode::Integer if fit >= 1.0 => fit.floor(),
87        ScaleMode::Integer | ScaleMode::Fit => fit,
88    };
89    let size = [(cw * scale).round(), (ch * scale).round()];
90    CanvasRect {
91        origin: [
92            ((ww - size[0]) * 0.5).floor(),
93            ((wh - size[1]) * 0.5).floor(),
94        ],
95        size,
96    }
97}
98
99/// An offscreen colour texture the toolkit renders into and samples from, with blit bind
100/// groups for both filters.
101pub(crate) struct RenderTexture {
102    pub size: [u32; 2],
103    pub texture: wgpu::Texture,
104    pub view: wgpu::TextureView,
105    /// Indexed by [`ScaleFilter::index`].
106    pub blit_bind_groups: [wgpu::BindGroup; 2],
107}
108
109impl RenderTexture {
110    pub fn new(
111        device: &wgpu::Device,
112        blit: &Blit,
113        format: wgpu::TextureFormat,
114        size: [u32; 2],
115        label: &str,
116    ) -> Self {
117        let size = [size[0].max(1), size[1].max(1)];
118        let texture = device.create_texture(&wgpu::TextureDescriptor {
119            label: Some(label),
120            size: wgpu::Extent3d {
121                width: size[0],
122                height: size[1],
123                depth_or_array_layers: 1,
124            },
125            mip_level_count: 1,
126            sample_count: 1,
127            dimension: wgpu::TextureDimension::D2,
128            format,
129            usage: wgpu::TextureUsages::RENDER_ATTACHMENT
130                | wgpu::TextureUsages::TEXTURE_BINDING
131                | wgpu::TextureUsages::COPY_SRC
132                | wgpu::TextureUsages::COPY_DST,
133            view_formats: &[],
134        });
135        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
136        let blit_bind_groups = blit.bind_groups(device, &view);
137        Self {
138            size,
139            texture,
140            view,
141            blit_bind_groups,
142        }
143    }
144}
145
146/// The offscreen scene the painter draws into: the virtual-resolution canvas, or a
147/// window-sized stand-in while post effects need the scene as a texture.
148pub(crate) struct Canvas {
149    pub tex: RenderTexture,
150    /// `true` for the window-sized stand-in (no virtual resolution): it is resized with the
151    /// window and placed 1:1.
152    pub follows_window: bool,
153}
154
155impl Canvas {
156    pub fn size(&self) -> [u32; 2] {
157        self.tex.size
158    }
159}
160
161/// A full-screen-triangle copy of one texture into a viewport of another, nearest or linear.
162pub(crate) struct Blit {
163    pipeline: wgpu::RenderPipeline,
164    layout: wgpu::BindGroupLayout,
165    samplers: [wgpu::Sampler; 2],
166}
167
168impl Blit {
169    pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
170        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
171            label: Some("blit.wgsl"),
172            source: wgpu::ShaderSource::Wgsl(include_str!("blit.wgsl").into()),
173        });
174        let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
175            label: Some("blit.bgl"),
176            entries: &[
177                wgpu::BindGroupLayoutEntry {
178                    binding: 0,
179                    visibility: wgpu::ShaderStages::FRAGMENT,
180                    ty: wgpu::BindingType::Texture {
181                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
182                        view_dimension: wgpu::TextureViewDimension::D2,
183                        multisampled: false,
184                    },
185                    count: None,
186                },
187                wgpu::BindGroupLayoutEntry {
188                    binding: 1,
189                    visibility: wgpu::ShaderStages::FRAGMENT,
190                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
191                    count: None,
192                },
193            ],
194        });
195        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
196            label: Some("blit.layout"),
197            bind_group_layouts: &[Some(&layout)],
198            immediate_size: 0,
199        });
200        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
201            label: Some("blit.pipeline"),
202            layout: Some(&pipeline_layout),
203            vertex: wgpu::VertexState {
204                module: &shader,
205                entry_point: Some("vs_main"),
206                compilation_options: Default::default(),
207                buffers: &[],
208            },
209            fragment: Some(wgpu::FragmentState {
210                module: &shader,
211                entry_point: Some("fs_main"),
212                compilation_options: Default::default(),
213                targets: &[Some(wgpu::ColorTargetState {
214                    format,
215                    blend: None,
216                    write_mask: wgpu::ColorWrites::ALL,
217                })],
218            }),
219            primitive: wgpu::PrimitiveState::default(),
220            depth_stencil: None,
221            multisample: wgpu::MultisampleState::default(),
222            multiview_mask: None,
223            cache: None,
224        });
225        let sampler = |filter| {
226            device.create_sampler(&wgpu::SamplerDescriptor {
227                label: Some("blit.sampler"),
228                mag_filter: filter,
229                min_filter: filter,
230                ..Default::default()
231            })
232        };
233        Self {
234            pipeline,
235            layout,
236            samplers: [
237                sampler(wgpu::FilterMode::Nearest),
238                sampler(wgpu::FilterMode::Linear),
239            ],
240        }
241    }
242
243    /// Bind groups sampling `src` with the nearest and the linear sampler, indexed by
244    /// [`ScaleFilter::index`].
245    pub fn bind_groups(
246        &self,
247        device: &wgpu::Device,
248        src: &wgpu::TextureView,
249    ) -> [wgpu::BindGroup; 2] {
250        self.samplers.each_ref().map(|sampler| {
251            device.create_bind_group(&wgpu::BindGroupDescriptor {
252                label: Some("blit.bg"),
253                layout: &self.layout,
254                entries: &[
255                    wgpu::BindGroupEntry {
256                        binding: 0,
257                        resource: wgpu::BindingResource::TextureView(src),
258                    },
259                    wgpu::BindGroupEntry {
260                        binding: 1,
261                        resource: wgpu::BindingResource::Sampler(sampler),
262                    },
263                ],
264            })
265        })
266    }
267
268    /// Clear `dst` to `clear` and draw the bound source into `rect`.
269    pub fn draw(
270        &self,
271        encoder: &mut wgpu::CommandEncoder,
272        bind_group: &wgpu::BindGroup,
273        dst: &wgpu::TextureView,
274        rect: CanvasRect,
275        clear: wgpu::Color,
276    ) {
277        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
278            label: Some("blit.pass"),
279            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
280                view: dst,
281                depth_slice: None,
282                resolve_target: None,
283                ops: wgpu::Operations {
284                    load: wgpu::LoadOp::Clear(clear),
285                    store: wgpu::StoreOp::Store,
286                },
287            })],
288            depth_stencil_attachment: None,
289            occlusion_query_set: None,
290            timestamp_writes: None,
291            multiview_mask: None,
292        });
293        if rect.size[0] < 1.0 || rect.size[1] < 1.0 {
294            return;
295        }
296        pass.set_viewport(
297            rect.origin[0],
298            rect.origin[1],
299            rect.size[0],
300            rect.size[1],
301            0.0,
302            1.0,
303        );
304        pass.set_pipeline(&self.pipeline);
305        pass.set_bind_group(0, bind_group, &[]);
306        pass.draw(0..3, 0..1);
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    #[test]
315    fn integer_scale_centres_with_border() {
316        // 320x180 in 1920x1080 is exactly 6x.
317        let r = layout([1920, 1080], [320, 180], ScaleMode::Integer);
318        assert_eq!(r.origin, [0.0, 0.0]);
319        assert_eq!(r.size, [1920.0, 1080.0]);
320        // 1000x700: 3x (960x540), centred.
321        let r = layout([1000, 700], [320, 180], ScaleMode::Integer);
322        assert_eq!(r.size, [960.0, 540.0]);
323        assert_eq!(r.origin, [20.0, 80.0]);
324    }
325
326    #[test]
327    fn integer_falls_back_to_fit_when_window_is_smaller() {
328        let r = layout([160, 90], [320, 180], ScaleMode::Integer);
329        assert_eq!(r.size, [160.0, 90.0]);
330    }
331
332    #[test]
333    fn fit_is_fractional() {
334        // 800x600 on 1920x1080: 1.8x -> 1440x1080, pillarboxed.
335        let r = layout([1920, 1080], [800, 600], ScaleMode::Fit);
336        assert_eq!(r.size, [1440.0, 1080.0]);
337        assert_eq!(r.origin, [240.0, 0.0]);
338        assert_eq!(
339            layout([1920, 1080], [800, 600], ScaleMode::Integer).size,
340            [800.0, 600.0]
341        );
342    }
343
344    #[test]
345    fn stretch_fills_window() {
346        let r = layout([1000, 700], [320, 180], ScaleMode::Stretch);
347        assert_eq!(r.origin, [0.0, 0.0]);
348        assert_eq!(r.size, [1000.0, 700.0]);
349    }
350
351    #[test]
352    fn window_to_canvas_inverts_layout() {
353        let canvas = [320, 180];
354        let r = layout([1000, 700], canvas, ScaleMode::Integer);
355        assert_eq!(r.window_to_canvas(canvas, [20.0, 80.0]), [0.0, 0.0]);
356        assert_eq!(r.window_to_canvas(canvas, [980.0, 620.0]), [320.0, 180.0]);
357        assert_eq!(r.window_to_canvas(canvas, [23.0, 83.0]), [1.0, 1.0]);
358        assert_eq!(r.canvas_per_window(canvas), [1.0 / 3.0, 1.0 / 3.0]);
359    }
360}