Skip to main content

shuyi_ui/frame/
raster.rs

1//! The raster: a frame painted in software, the reference picture of a
2//! [`GlyphBuffer`] that every backend must match. `shuyi-app`'s GPU
3//! renderer is held to it (ticket 2018), and [`compare`] is the one
4//! comparison every backend's pixels go through. `frame-png`, the tool
5//! contract and a game's PNG snapshots paint with it; a game may too, at
6//! run time (a save's thumbnail, a photo mode). It takes decoded pictures:
7//! decoding a file is the caller's (`shuyi-cli` decodes PNG). Its
8//! pictures are [`shuyi_assets::image::Image`], what the image formats
9//! decode, so a decoded picture is painted as it is.
10//!
11//! [`Painter`] is a software copy of `shuyi-app`'s renderer, pixel for
12//! pixel at whole scales: the clear colour, cell backgrounds, `Under`
13//! items in order (a painted picture, ADR-0078, from the frame's table,
14//! as a sprite of the same pixels), glyphs tinted with their fg (a wide one over its two
15//! cells, a wide one without its continuation as the fallback), `Over`
16//! items in order (a gizmo, ADR-0085, as the runs of whole console pixels
17//! [`GizmoItem::for_each_run`](crate::frame::GizmoItem::for_each_run)
18//! gives);
19//! and before all that, a backdrop's scene in its window. Textures are
20//! sampled at the nearest pixel and blended over what is beneath with
21//! their alpha, as the GPU does. [`one_bit_picture`] paints a one-bit
22//! frame black and white, and [`test_card`] is a generated test image for
23//! sprite items. [`card`] is the render card: one frame with every kind of
24//! thing a frame draws, which a backend draws to hold its pixels to the
25//! raster's.
26//!
27//! Behind the `raster` feature (the tests have it anyway): the default
28//! engine and a console's build don't need it.
29
30use alloc::collections::BTreeMap;
31
32use serde::{Deserialize, Serialize};
33use shuyi_assets::font::{AtlasRect, FALLBACK_GLYPH, FontAtlasDef};
34use shuyi_assets::image::ImageId;
35
36use crate::prelude::*;
37
38use crate::frame::color::Rgb;
39use crate::frame::glyph_buffer::{Backdrop, GlyphBuffer, Item, Layer, Paint, PxRect, Rect, Sprite};
40use crate::frame::onebit::{self, Frame};
41use crate::frame::painted::{Painted, PaintedSprite, PaintedTable};
42
43pub mod card;
44pub mod test_card;
45
46/// The colour of what is missing, as in `shuyi-app` (macroquad's
47/// `MAGENTA`): an image the painter lacks, a glyph the atlas lacks, and a
48/// pixel over the tolerance in a [`Comparison`]'s `diff`.
49pub const MISSING_COLOR: Rgb = Rgb::new(255, 0, 255);
50
51/// `frame` as a black and white picture, each pixel `scale` × `scale`.
52pub fn one_bit_picture(frame: &Frame, scale: u32) -> Image {
53    let (width, height) = (onebit::WIDTH.unsigned_abs(), onebit::HEIGHT.unsigned_abs());
54    let mut image = Image::filled(width * scale, height * scale, Rgb::new(0, 0, 0));
55    let s = i64::from(scale);
56    let at = |v: i64| i32::try_from(v / s).ok();
57    for y in 0..i64::from(image.height) {
58        for x in 0..i64::from(image.width) {
59            if at(x)
60                .zip(at(y))
61                .is_some_and(|(x, y)| onebit::is_white(frame, x, y))
62            {
63                blend(&mut image, x, y, [1.0; 3], 1.0);
64            }
65        }
66    }
67    image
68}
69
70/// A picture of four bytes a pixel: the image formats' (`shuyi-assets`),
71/// so a picture a [`Formats`](shuyi_assets::format::Formats) registry
72/// decodes paints as it is.
73pub use shuyi_assets::image::Image;
74
75/// `picture`'s pixels as RGBA ([`Painted::rgba_into`]).
76pub fn of_painted(picture: &Painted) -> Image {
77    let (width, height) = picture.size();
78    let mut rgba = Vec::new();
79    picture.rgba_into(&mut rgba);
80    Image {
81        width: u32::from(width),
82        height: u32::from(height),
83        rgba,
84    }
85}
86
87/// The pixel of `image` at `(x, y)`, clamped to the picture (an empty
88/// picture is transparent).
89fn texel(image: &Image, x: i64, y: i64) -> [u8; 4] {
90    if image.width == 0 || image.height == 0 {
91        return [0; 4];
92    }
93    let clamp = |v: i64, size: u32| usize::try_from(v.clamp(0, i64::from(size) - 1)).unwrap_or(0);
94    let i = (clamp(y, image.height) * image.width as usize + clamp(x, image.width)) * 4;
95    [
96        image.rgba[i],
97        image.rgba[i + 1],
98        image.rgba[i + 2],
99        image.rgba[i + 3],
100    ]
101}
102
103/// Blends `color` with alpha `alpha` (0 to 1) over the pixel of `image`
104/// at `(x, y)`, as the GPU does: in floats, rounded once. Off the picture
105/// it does nothing.
106fn blend(image: &mut Image, x: i64, y: i64, color: [f32; 3], alpha: f32) {
107    let (Ok(x), Ok(y)) = (u32::try_from(x), u32::try_from(y)) else {
108        return;
109    };
110    if x >= image.width || y >= image.height {
111        return;
112    }
113    let i = (y as usize * image.width as usize + x as usize) * 4;
114    for (c, &src) in color.iter().enumerate() {
115        let dst = f32::from(image.rgba[i + c]) / 255.0;
116        let out = src * alpha + dst * (1.0 - alpha);
117        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // 0..=255
118        let byte = (out * 255.0).round().clamp(0.0, 255.0) as u8;
119        image.rgba[i + c] = byte;
120    }
121    image.rgba[i + 3] = 255;
122}
123
124/// Fills `(x, y, w, h)` of `image` with `color`, solid.
125fn fill(image: &mut Image, (x, y, w, h): (i64, i64, i64, i64), color: Rgb) {
126    for py in y..y + h {
127        for px in x..x + w {
128            blend(image, px, py, unit(color), 1.0);
129        }
130    }
131}
132
133/// `c` as floats from 0 to 1.
134fn unit(c: Rgb) -> [f32; 3] {
135    [c.r, c.g, c.b].map(|v| f32::from(v) / 255.0)
136}
137
138/// The font atlas and images, decoded, ready to render any number of
139/// frames.
140pub struct Painter {
141    atlas: FontAtlasDef,
142    atlas_image: Image,
143    /// Background drawn behind the console; cells with this bg skip their
144    /// fill, as in `app`.
145    clear: Rgb,
146    /// The images there are; the others draw as `MISSING_COLOR`.
147    images: BTreeMap<ImageId, Image>,
148}
149
150impl Painter {
151    /// A painter of font atlas `atlas`, whose picture is `atlas_image`,
152    /// with `images` (an image not among them draws as a `MISSING_COLOR`
153    /// rectangle). `clear` is the console background (the palette's
154    /// `black`).
155    pub fn new(
156        atlas: FontAtlasDef,
157        atlas_image: Image,
158        images: BTreeMap<ImageId, Image>,
159        clear: Rgb,
160    ) -> Self {
161        Self {
162            atlas,
163            atlas_image,
164            clear,
165            images,
166        }
167    }
168
169    /// Draws `buf` with each console pixel `scale` × `scale` pixels big.
170    /// With a backdrop (game ADR-0048): the clear colour, the scene inside its
171    /// window, then the console over it, as in `app`.
172    pub fn render(&self, buf: &GlyphBuffer, scale: u32) -> Image {
173        let s = i64::from(scale);
174        let spec = buf.spec();
175        let (cell_w, cell_h) = (i64::from(spec.cell_w) * s, i64::from(spec.cell_h) * s);
176        let width = u32::try_from(i64::from(buf.width()) * cell_w).unwrap_or(0);
177        let height = u32::try_from(i64::from(buf.height()) * cell_h).unwrap_or(0);
178        let mut out = Image::filled(width, height, self.clear);
179        let pictures = buf.pictures();
180        if let Some(backdrop) = buf.backdrop() {
181            self.draw_backdrop(&mut out, backdrop, pictures, scale);
182        }
183        // Over a scene, a cell in the clear colour must hide it.
184        let all_fills = buf.backdrop().is_some();
185        self.draw_layers(&mut out, (buf, pictures), (0, 0), s, all_fills);
186        out
187    }
188
189    /// Draws `backdrop`'s scene in its window of `out`: every scene pixel
190    /// `zoom × scale` pixels big, placed by [`Backdrop::scene_offset`].
191    /// The scene is drawn into a picture the size of the window, which
192    /// clips it, and that is copied in. Its painted items show the frame's
193    /// `pictures`.
194    fn draw_backdrop(
195        &self,
196        out: &mut Image,
197        backdrop: &Backdrop,
198        pictures: &PaintedTable,
199        scale: u32,
200    ) {
201        let s = i64::from(scale);
202        let clip = backdrop.clip_px();
203        let size = |v: i32| u32::try_from(i64::from(v) * s).unwrap_or(0);
204        let mut window = Image::filled(size(clip.w), size(clip.h), self.clear);
205        let scene_px = i64::from(backdrop.zoom()) * s;
206        self.draw_layers(
207            &mut window,
208            (backdrop.scene(), pictures),
209            backdrop.scene_offset(scale),
210            scene_px,
211            false,
212        );
213        let (left, top) = (i64::from(clip.x) * s, i64::from(clip.y) * s);
214        for y in 0..i64::from(window.height) {
215            for x in 0..i64::from(window.width) {
216                let [r, g, b, _] = texel(&window, x, y);
217                blend(out, left + x, top + y, unit(Rgb::new(r, g, b)), 1.0);
218            }
219        }
220    }
221
222    /// Draws `buf`'s cells and items into `out` with its top-left corner
223    /// at `corner` and each of its pixels `px` × `px` big: cell
224    /// backgrounds (all of them if `all_fills`, else all but those in the
225    /// clear colour; never a see-through cell's), `Under` items, glyphs,
226    /// `Over` items. Its painted items show `pictures`, the frame's.
227    fn draw_layers(
228        &self,
229        out: &mut Image,
230        (buf, pictures): (&GlyphBuffer, &PaintedTable),
231        corner: (i64, i64),
232        px: i64,
233        all_fills: bool,
234    ) {
235        let spec = buf.spec();
236        let (cell_w, cell_h) = (i64::from(spec.cell_w) * px, i64::from(spec.cell_h) * px);
237        let at = |x: i32, y: i32| {
238            (
239                corner.0 + i64::from(x) * cell_w,
240                corner.1 + i64::from(y) * cell_h,
241            )
242        };
243        let cells = || {
244            (0..i32::from(buf.height())).flat_map(move |y| {
245                (0..i32::from(buf.width()))
246                    .filter_map(move |x| buf.get(x, y).map(|cell| (at(x, y), cell)))
247            })
248        };
249        for ((x, y), cell) in cells() {
250            if !cell.see_through && (all_fills || cell.bg != self.clear) {
251                fill(out, (x, y, cell_w, cell_h), cell.bg);
252            }
253        }
254        self.draw_items(out, (buf, pictures), Layer::Under, corner, px);
255        for (cx, cy, cell, wide) in buf.glyphs() {
256            if cell.glyph == ' ' {
257                continue;
258            }
259            let set = buf.widths().wide_font();
260            let Some((rect, fg, cells)) = self.atlas_cell(cell.glyph, cell.fg, wide, set) else {
261                continue;
262            };
263            let fg = unit(fg);
264            #[allow(clippy::cast_precision_loss)] // atlas coordinates are small
265            let src = [rect.x, rect.y, rect.w, rect.h].map(|v| v as f32);
266            let (x, y) = at(cx, cy);
267            blit(
268                out,
269                &self.atlas_image,
270                src,
271                (x, y, cell_w * cells, cell_h),
272                false,
273                Paint::Image,
274                [fg[0], fg[1], fg[2], 1.0],
275            );
276        }
277        self.draw_items(out, (buf, pictures), Layer::Over, corner, px);
278    }
279
280    /// Draws `buf`'s items of `layer`, in order, from `corner`, each of
281    /// the buffer's pixels `scale` big, its painted items from `pictures`.
282    fn draw_items(
283        &self,
284        out: &mut Image,
285        (buf, pictures): (&GlyphBuffer, &PaintedTable),
286        layer: Layer,
287        corner: (i64, i64),
288        scale: i64,
289    ) {
290        for item in buf.items().iter().filter(|item| item.layer() == layer) {
291            let to_px = |r: PxRect| {
292                (
293                    corner.0 + i64::from(r.x) * scale,
294                    corner.1 + i64::from(r.y) * scale,
295                    i64::from(r.w) * scale,
296                    i64::from(r.h) * scale,
297                )
298            };
299            match item {
300                Item::Rect(rect) => fill(out, to_px(rect.rect), rect.color),
301                Item::Sprite(sprite) => self.draw_sprite(out, sprite, to_px(sprite.clip)),
302                Item::Painted(painted) => draw_painted(out, painted, pictures, to_px(painted.clip)),
303                // In whole console pixels, by `frame::shape`'s rules.
304                Item::Gizmo(gizmo) => gizmo.for_each_run(|y, x0, x1| {
305                    fill(out, to_px(Rect::new(x0, y, x1 - x0, 1)), gizmo.color);
306                }),
307            }
308        }
309    }
310
311    /// Draws the visible part of `sprite` into `dest`: the matching part
312    /// of its image, or a `MISSING_COLOR` rectangle.
313    fn draw_sprite(&self, out: &mut Image, sprite: &Sprite, dest: (i64, i64, i64, i64)) {
314        let Some(image) = self.images.get(&sprite.image) else {
315            fill(out, dest, MISSING_COLOR);
316            return;
317        };
318        let opacity = f32::from(sprite.opacity) / 255.0;
319        // Darker as the sprite's brightness has it (game ADR-0075).
320        let bright = f32::from(sprite.brightness) / 255.0;
321        let src = sprite.clipped_src();
322        blit(
323            out,
324            image,
325            src,
326            dest,
327            sprite.flip_x,
328            sprite.paint,
329            [bright, bright, bright, opacity],
330        );
331    }
332
333    /// Where to find `glyph` in the atlas, what colour to tint it and how
334    /// many cells it covers: `fg` over both cells for a wide glyph whose
335    /// continuation follows it (`wide`), from the language's wide set `set`
336    /// first ([`FontAtlasDef::wide_cell`]), over one for a narrow glyph, or
337    /// the fallback glyph in `MISSING_COLOR` over one.
338    fn atlas_cell(
339        &self,
340        glyph: char,
341        fg: Rgb,
342        wide: bool,
343        set: Option<&str>,
344    ) -> Option<(AtlasRect, Rgb, i64)> {
345        if let Some(rect) = self.atlas.wide_rect_in(set, glyph).filter(|_| wide) {
346            return Some((rect, fg, 2));
347        }
348        if let Some(rect) = self.atlas.glyph_rect(glyph) {
349            return Some((rect, fg, 1));
350        }
351        Some((self.atlas.glyph_rect(FALLBACK_GLYPH)?, MISSING_COLOR, 1))
352    }
353}
354
355/// Draws the visible part of `painted` into `dest` from its picture in
356/// `pictures`, as a sprite of the same pixels draws (ADR-0078): or a
357/// `MISSING_COLOR` rectangle if the table lacks it.
358fn draw_painted(
359    out: &mut Image,
360    painted: &PaintedSprite,
361    pictures: &PaintedTable,
362    dest: (i64, i64, i64, i64),
363) {
364    let Some(picture) = pictures.get(painted.picture) else {
365        fill(out, dest, MISSING_COLOR);
366        return;
367    };
368    let bright = f32::from(painted.brightness) / 255.0;
369    blit(
370        out,
371        &of_painted(picture),
372        painted.clipped_src(),
373        dest,
374        false,
375        Paint::Image,
376        [bright, bright, bright, 1.0],
377    );
378}
379
380/// Stretches the `src` part of `image` (`[x, y, w, h]` in image pixels)
381/// over `dest` in `out`, mirrored if `flip_x`, each texel coloured as
382/// `paint`, multiplied by `tint` (RGBA, 0 to 1) and blended with the
383/// result's alpha. Each pixel takes the texel under its centre, as nearest
384/// sampling does.
385#[allow(clippy::cast_precision_loss)] // pixel counts are small
386fn blit(
387    out: &mut Image,
388    image: &Image,
389    src: [f32; 4],
390    (left, top, width, height): (i64, i64, i64, i64),
391    flip_x: bool,
392    paint: Paint,
393    tint: [f32; 4],
394) {
395    let [src_x, src_y, src_w, src_h] = src;
396    for dy in 0..height {
397        let tex_y = src_y + (dy as f32 + 0.5) / height as f32 * src_h;
398        for dx in 0..width {
399            let across = (dx as f32 + 0.5) / width as f32;
400            let tex_x = if flip_x {
401                src_x + src_w * (1.0 - across)
402            } else {
403                src_x + src_w * across
404            };
405            #[allow(clippy::cast_possible_truncation)] // floored image coordinates
406            let texel = texel(image, tex_x.floor() as i64, tex_y.floor() as i64);
407            let [red, green, blue] = unit(paint.apply(Rgb::new(texel[0], texel[1], texel[2])));
408            let color = [red * tint[0], green * tint[1], blue * tint[2]];
409            let alpha = f32::from(texel[3]) / 255.0;
410            blend(out, left + dx, top + dy, color, alpha * tint[3]);
411        }
412    }
413}
414
415/// How far a backend's picture may be from the reference and still pass
416/// [`compare`]. Read from a plan file as `(channel: 4)`; a key left out
417/// takes its default.
418#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
419#[serde(default, deny_unknown_fields)]
420// Built with `Tolerance::new` or `default()`: it may grow (ADR-0039).
421#[non_exhaustive]
422pub struct Tolerance {
423    /// The most a colour channel of a pixel may differ (0 to 255) before
424    /// the pixel counts as different.
425    pub channel: u8,
426    /// How many pixels may differ.
427    pub pixels: u32,
428}
429
430impl Tolerance {
431    /// Up to `channel` in each colour channel, and up to `pixels` pixels
432    /// past that.
433    pub const fn new(channel: u8, pixels: u32) -> Self {
434        Self { channel, pixels }
435    }
436}
437
438impl Default for Tolerance {
439    /// 2 in each channel (a GPU's rounding), and no pixel past that.
440    fn default() -> Self {
441        Self::new(2, 0)
442    }
443}
444
445/// What [`compare`] found. Over pictures of different sizes, every field
446/// but `same_size` covers the top-left area both share.
447#[derive(Debug, Clone, PartialEq, Eq)]
448// Made by `compare` alone: it may grow (ADR-0039).
449#[non_exhaustive]
450pub struct Comparison {
451    /// Whether the two pictures are the same size.
452    pub same_size: bool,
453    /// How many pixels differ by more than `tolerance.channel` in some
454    /// colour channel (alpha is ignored: a window has none).
455    pub over: u32,
456    /// The largest difference in any colour channel of any pixel.
457    pub worst: u8,
458    /// The first pixel counted in `over`, in row order.
459    pub first: Option<(u32, u32)>,
460    /// The reference with every colour channel halved, and each pixel
461    /// counted in `over` in [`MISSING_COLOR`].
462    pub diff: Image,
463    /// The tolerance compared with.
464    pub tolerance: Tolerance,
465}
466
467impl Comparison {
468    /// Whether the pictures match: the same size, and no more pixels over
469    /// the tolerance than it allows.
470    pub fn passes(&self) -> bool {
471        self.same_size && self.over <= self.tolerance.pixels
472    }
473}
474
475/// Compares a backend's picture, `window`, with the `reference` the
476/// raster painted, pixel by pixel, within `tolerance`.
477pub fn compare(window: &Image, reference: &Image, tolerance: Tolerance) -> Comparison {
478    let width = window.width.min(reference.width);
479    let height = window.height.min(reference.height);
480    let mut diff = Image {
481        width,
482        height,
483        rgba: Vec::with_capacity(width as usize * height as usize * 4),
484    };
485    let (mut over, mut worst, mut first) = (0, 0, None);
486    for y in 0..height {
487        for x in 0..width {
488            let (Some(seen), Some(want)) = (window.pixel(x, y), reference.pixel(x, y)) else {
489                continue;
490            };
491            let most = (0..3).map(|c| seen[c].abs_diff(want[c])).max().unwrap_or(0);
492            worst = worst.max(most);
493            if most > tolerance.channel {
494                over += 1;
495                first = first.or(Some((x, y)));
496                let Rgb { r, g, b } = MISSING_COLOR;
497                diff.rgba.extend([r, g, b, 255]);
498            } else {
499                diff.rgba
500                    .extend([want[0] / 2, want[1] / 2, want[2] / 2, 255]);
501            }
502        }
503    }
504    Comparison {
505        same_size: (window.width, window.height) == (reference.width, reference.height),
506        over,
507        worst,
508        first,
509        diff,
510        tolerance,
511    }
512}
513
514#[cfg(test)]
515mod tests;