Skip to main content

shuyi_app/
render.rs

1//! Blits a [`GlyphBuffer`] to the window with the font atlas (game ADR-0003):
2//! integer-scaled, centred, black letterbox. The buffer's items are drawn
3//! scaled, in the order they were added: `Under` ones after the cell
4//! backgrounds, `Over` ones after the glyphs. A rectangle (game ADR-0018) is a
5//! solid fill; a sprite (game ADR-0038) is part of an image's texture, or of a
6//! recoloured copy of it when the sprite isn't painted in the image's own
7//! colours (game ADR-0049). A buffer's backdrop (game ADR-0048), a second buffer
8//! panned and zoomed, is drawn the same way inside its window, behind the
9//! console. A wide glyph (its continuation cell after it) is one quad over
10//! both its cells, as the raster paints it. A painted picture (ADR-0078) is
11//! a texture of its own, uploaded only in a frame whose revision moved
12//! (`PaintedTextures`, in `painted.rs`), drawn with nearest-pixel sampling.
13
14use std::collections::{HashMap, HashSet};
15
16use macroquad::prelude::*;
17use shuyi_assets::font::{AtlasRect, FALLBACK_GLYPH, FontAtlasDef};
18use shuyi_assets::image::{ImageId, ImageTable};
19use shuyi_assets::lighting::Light;
20use shuyi_ui::frame::Rgb;
21use shuyi_ui::frame::console::{Layout, layout};
22use shuyi_ui::frame::glyph_buffer::{Backdrop, GlyphBuffer, Item, Layer, Paint, Sprite};
23use shuyi_ui::frame::painted::{PaintedSprite, PaintedTable};
24
25use crate::painted::PaintedTextures;
26use crate::pictures::{Fresh, Remake, src_fits};
27
28/// The colour of what is missing: the fallback glyph drawn for a glyph the
29/// atlas lacks, and the rectangle drawn for a sprite whose image has no
30/// texture.
31const MISSING_COLOR: Color = MAGENTA;
32
33/// A recoloured copy of an image, for a sprite that isn't painted in the
34/// image's own colours.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
36enum Recolour {
37    /// Every pixel white, as see-through as it was: drawn tinted, it is
38    /// the picture's silhouette in one colour ([`Paint::Solid`]).
39    Silhouette,
40    /// Every pixel grey and darker ([`Paint::Dimmed`]).
41    Dimmed,
42    /// Every pixel as it is in a light ([`Paint::Shaded`]).
43    Shaded(Light),
44}
45
46/// Which solid cells get their background drawn.
47#[derive(Clone, Copy, PartialEq, Eq)]
48enum Fills {
49    /// All but those in the clear colour: it is already there.
50    NotClear,
51    /// Every one: something else may be behind them.
52    All,
53}
54
55/// Draws glyph buffers with a font atlas texture.
56pub struct Renderer {
57    texture: Texture2D,
58    atlas: FontAtlasDef,
59    /// Background drawn behind the console; cells with this bg skip their fill.
60    clear: Rgb,
61    /// Missing glyphs already logged, so each is reported once.
62    warned: HashSet<char>,
63    /// A texture per image of the image table.
64    images: HashMap<ImageId, Texture2D>,
65    /// The recoloured copies made so far: each is made the first time a
66    /// sprite needs it. `None` for an image that can't be decoded.
67    recoloured: HashMap<(ImageId, Recolour), Option<Texture2D>>,
68    /// Images without a texture already logged, so each is reported once.
69    warned_images: HashSet<ImageId>,
70    /// The file of each texture of [`images`](Self::images), for the
71    /// recoloured copies: a save that doesn't decode never comes here.
72    sources: HashMap<ImageId, &'static [u8]>,
73    /// The size of each image made again since the start, for what its
74    /// sprites may show ([`src_fits`]).
75    remade: HashMap<ImageId, (u16, u16)>,
76    /// The textures of the frame's painted pictures.
77    painted: PaintedTextures<Texture2D>,
78}
79
80impl Renderer {
81    /// Uploads the atlas image (`png`) and every image in `images` (read
82    /// with `bytes`, the game's embedded files by path). `clear` is the
83    /// console background (the palette's `black`). An image that can't be
84    /// decoded is logged and left without a texture.
85    pub fn new(
86        atlas: FontAtlasDef,
87        png: &[u8],
88        clear: Rgb,
89        images: &ImageTable,
90        bytes: fn(&str) -> Option<&'static [u8]>,
91    ) -> Result<Self, String> {
92        Self::with_files(atlas, png, clear, images, bytes)
93    }
94
95    /// [`new`](Self::new), with `bytes` any way of reading the files.
96    pub(crate) fn with_files(
97        atlas: FontAtlasDef,
98        png: &[u8],
99        clear: Rgb,
100        images: &ImageTable,
101        bytes: impl Fn(&str) -> Option<&'static [u8]>,
102    ) -> Result<Self, String> {
103        let texture = upload(png).map_err(|e| format!("font atlas image: {e}"))?;
104        let mut textures = HashMap::new();
105        let mut sources = HashMap::new();
106        for id in images.ids() {
107            let source = bytes(id.path()).unwrap_or_default();
108            match upload(source) {
109                Ok(texture) => {
110                    textures.insert(id, texture);
111                    sources.insert(id, source);
112                }
113                Err(e) => {
114                    warn!("image {}: {}", id.path(), e);
115                }
116            }
117        }
118        Ok(Self {
119            texture,
120            atlas,
121            clear,
122            warned: HashSet::new(),
123            images: textures,
124            recoloured: HashMap::new(),
125            warned_images: HashSet::new(),
126            sources,
127            remade: HashMap::new(),
128            painted: PaintedTextures::new(),
129        })
130    }
131
132    /// Makes the textures of `fresh` again, from pictures that decoded. A
133    /// font's picture takes `font`, the game's atlas now. An image's
134    /// recoloured copies are made again as sprites next need them.
135    pub(crate) fn remake(&mut self, fresh: Vec<Fresh<Image>>, font: &FontAtlasDef) {
136        for Fresh {
137            remake,
138            decoded,
139            bytes,
140        } in fresh
141        {
142            let texture = texture_of(&decoded);
143            match remake {
144                Remake::Font => {
145                    self.texture = texture;
146                    self.atlas = font.clone();
147                    self.warned.clear();
148                }
149                Remake::Image(id) => {
150                    self.images.insert(id, texture);
151                    self.sources.insert(id, bytes);
152                    self.remade.insert(id, (decoded.width, decoded.height));
153                    self.recoloured.retain(|(image, _), _| *image != id);
154                    self.warned_images.remove(&id);
155                }
156            }
157        }
158    }
159
160    /// Sets the console background (the palette's `black`), which a
161    /// colour theme changes.
162    pub fn set_clear(&mut self, clear: Rgb) {
163        self.clear = clear;
164    }
165
166    /// Draws `buf` scaled to the current window size.
167    ///
168    /// The layout is computed in physical framebuffer pixels (the window is
169    /// `high_dpi`), so the integer scale holds on scaled Windows displays;
170    /// coordinates are divided by the DPI factor only because macroquad's
171    /// default camera works in logical units.
172    ///
173    /// With a backdrop (game ADR-0048) the order is: the clear colour, the
174    /// scene inside its window, then the console over it, whose
175    /// see-through cells have no background.
176    pub fn draw(&mut self, buf: &GlyphBuffer) {
177        let dpi = screen_dpi_scale();
178        // Rounded: logical size × DPI can land a hair under the real pixel
179        // count (913.714 × 1.75 = 1599.99…), which would drop a whole scale step.
180        let window_h = (screen_height() * dpi).round();
181        let fit = layout(buf.spec(), (screen_width() * dpi).round(), window_h);
182        #[allow(clippy::cast_precision_loss)] // scale is small
183        let scale = fit.scale as f32 / dpi;
184        let (offset_x, offset_y) = (fit.offset_x / dpi, fit.offset_y / dpi);
185        clear_background(BLACK);
186        draw_rectangle(
187            offset_x,
188            offset_y,
189            f32::from(buf.width()) * f32::from(buf.spec().cell_w) * scale,
190            f32::from(buf.height()) * f32::from(buf.spec().cell_h) * scale,
191            color(self.clear),
192        );
193        let pictures = buf.pictures();
194        if let Some(backdrop) = buf.backdrop() {
195            self.draw_backdrop(backdrop, pictures, &fit, window_h);
196        }
197        // Over a scene, a cell in the clear colour must hide it.
198        let fills = if buf.backdrop().is_some() {
199            Fills::All
200        } else {
201            Fills::NotClear
202        };
203        self.draw_layers((buf, pictures), (offset_x, offset_y), scale, fills);
204        self.painted.end_frame();
205    }
206
207    /// Draws `backdrop`'s scene in its window: every scene pixel
208    /// `zoom × fit.scale` physical pixels big, placed on whole physical
209    /// pixels ([`Backdrop::scene_offset`]).
210    ///
211    /// The window clips it with a camera whose view is exactly the window,
212    /// one unit per physical pixel: what falls outside a camera's view
213    /// isn't drawn. (macroquad's scissor is only reachable through an
214    /// `unsafe` call, which this workspace forbids.) `window_h` is the
215    /// window's height in physical pixels: a viewport counts rows from the
216    /// bottom. Its painted items show the frame's `pictures`.
217    #[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)] // whole pixels
218    fn draw_backdrop(
219        &mut self,
220        backdrop: &Backdrop,
221        pictures: &PaintedTable,
222        fit: &Layout,
223        window_h: f32,
224    ) {
225        let scale = fit.scale as f32;
226        let clip = backdrop.clip_px();
227        let (x, y) = (
228            fit.offset_x + clip.x as f32 * scale,
229            fit.offset_y + clip.y as f32 * scale,
230        );
231        let (w, h) = (clip.w as f32 * scale, clip.h as f32 * scale);
232        set_camera(&Camera2D {
233            target: vec2(w / 2.0, h / 2.0),
234            zoom: vec2(2.0 / w, 2.0 / h),
235            viewport: Some((x as i32, (window_h - y - h) as i32, w as i32, h as i32)),
236            ..Default::default()
237        });
238        let (dx, dy) = backdrop.scene_offset(fit.scale);
239        let scene_scale = f32::from(backdrop.zoom()) * scale;
240        self.draw_layers(
241            (backdrop.scene(), pictures),
242            (dx as f32, dy as f32),
243            scene_scale,
244            Fills::NotClear,
245        );
246        set_default_camera();
247    }
248
249    /// Draws `buf`'s cells and items with its top-left corner at `origin`
250    /// and each of its pixels `scale` big: cell backgrounds (`fills` says
251    /// which; never a see-through cell's), `Under` items, glyphs, `Over`
252    /// items. Its painted items show `pictures`, the frame's.
253    fn draw_layers(
254        &mut self,
255        (buf, pictures): (&GlyphBuffer, &PaintedTable),
256        origin: (f32, f32),
257        scale: f32,
258        fills: Fills,
259    ) {
260        let (offset_x, offset_y) = origin;
261        let cell_w = f32::from(buf.spec().cell_w) * scale;
262        let cell_h = f32::from(buf.spec().cell_h) * scale;
263        let cells = || {
264            (0..i32::from(buf.height())).flat_map(move |y| {
265                (0..i32::from(buf.width())).filter_map(move |x| {
266                    #[allow(clippy::cast_precision_loss)] // cell coords < 2^16
267                    let at = (offset_x + x as f32 * cell_w, offset_y + y as f32 * cell_h);
268                    buf.get(x, y).map(|cell| (at, cell))
269                })
270            })
271        };
272        for ((px, py), cell) in cells() {
273            if !cell.see_through && (fills == Fills::All || cell.bg != self.clear) {
274                draw_rectangle(px, py, cell_w, cell_h, color(cell.bg));
275            }
276        }
277        self.draw_items((buf, pictures), Layer::Under, origin, scale);
278        for (x, y, cell, wide) in buf.glyphs() {
279            if cell.glyph == ' ' {
280                continue;
281            }
282            let set = buf.widths().wide_font();
283            let Some((rect, fg, cells)) = self.atlas_cell(cell.glyph, cell.fg, wide, set) else {
284                continue;
285            };
286            #[allow(clippy::cast_precision_loss)] // cell coords < 2^16
287            let (px, py) = (offset_x + x as f32 * cell_w, offset_y + y as f32 * cell_h);
288            draw_texture_ex(
289                &self.texture,
290                px,
291                py,
292                fg,
293                DrawTextureParams {
294                    dest_size: Some(vec2(cell_w * cells, cell_h)),
295                    source: Some(source_rect(rect)),
296                    ..Default::default()
297                },
298            );
299        }
300        self.draw_items((buf, pictures), Layer::Over, origin, scale);
301    }
302
303    /// Draws `buf`'s items of `layer`, in order, its painted items from
304    /// `pictures`. `origin` is the buffer's top-left corner and `scale` the
305    /// size of one of its pixels.
306    fn draw_items(
307        &mut self,
308        (buf, pictures): (&GlyphBuffer, &PaintedTable),
309        layer: Layer,
310        origin: (f32, f32),
311        scale: f32,
312    ) {
313        for item in buf.items().iter().filter(|item| item.layer() == layer) {
314            let visible = item.visible();
315            #[allow(clippy::cast_precision_loss)] // console pixels < 2^21
316            let (x, y, size) = (
317                origin.0 + visible.x as f32 * scale,
318                origin.1 + visible.y as f32 * scale,
319                vec2(visible.w as f32, visible.h as f32) * scale,
320            );
321            match item {
322                Item::Rect(rect) => draw_rectangle(x, y, size.x, size.y, color(rect.color)),
323                Item::Sprite(sprite) => self.draw_sprite(sprite, x, y, size),
324                Item::Painted(painted) => self.draw_painted(painted, pictures, x, y, size),
325                // One rectangle a run of whole console pixels, by
326                // `frame::shape`'s rules, so the window equals `frame-png`.
327                #[allow(clippy::cast_precision_loss)] // console pixels < 2^21
328                Item::Gizmo(gizmo) => gizmo.for_each_run(|row, x0, x1| {
329                    draw_rectangle(
330                        origin.0 + x0 as f32 * scale,
331                        origin.1 + row as f32 * scale,
332                        (x1 - x0) as f32 * scale,
333                        scale,
334                        color(gizmo.color),
335                    );
336                }),
337                // A kind of item this backend doesn't know yet draws nothing.
338                _ => {}
339            }
340        }
341    }
342
343    /// Draws the visible part of `sprite` (its `clip`) at `(x, y)`, `size`
344    /// big: the matching part of its image, or a [`MISSING_COLOR`]
345    /// rectangle if the image has no texture, or was saved too small for
346    /// the sprite since the start (logged once per image).
347    fn draw_sprite(&mut self, sprite: &Sprite, x: f32, y: f32, size: Vec2) {
348        if let Some(&picture) = self.remade.get(&sprite.image)
349            && !src_fits(sprite.src, picture)
350        {
351            if self.warned_images.insert(sprite.image) {
352                warn!(
353                    "image {} is {} by {} now: a sprite's source is past its edge",
354                    sprite.image.path(),
355                    picture.0,
356                    picture.1
357                );
358            }
359            draw_rectangle(x, y, size.x, size.y, MISSING_COLOR);
360            return;
361        }
362        let (recolour, tint) = match sprite.paint {
363            Paint::Image => (None, Rgb::new(255, 255, 255)),
364            Paint::Solid(color) => (Some(Recolour::Silhouette), color),
365            Paint::Dimmed => (Some(Recolour::Dimmed), Rgb::new(255, 255, 255)),
366            Paint::Shaded(light) => (Some(Recolour::Shaded(light)), Rgb::new(255, 255, 255)),
367        };
368        let source = self.sources.get(&sprite.image).copied();
369        let texture = match recolour {
370            None => self.images.get(&sprite.image),
371            Some(recolour) => self
372                .recoloured
373                .entry((sprite.image, recolour))
374                .or_insert_with(|| recoloured(source?, recolour))
375                .as_ref(),
376        };
377        let Some(texture) = texture else {
378            if self.warned_images.insert(sprite.image) {
379                warn!("image {} has no texture", sprite.image.path());
380            }
381            draw_rectangle(x, y, size.x, size.y, MISSING_COLOR);
382            return;
383        };
384        let [sx, sy, sw, sh] = sprite.clipped_src();
385        // Darker as the sprite's brightness has it (game ADR-0075).
386        let tint = tint.scale(f32::from(sprite.brightness) / 255.0);
387        draw_texture_ex(
388            texture,
389            x,
390            y,
391            Color::from_rgba(tint.r, tint.g, tint.b, sprite.opacity),
392            DrawTextureParams {
393                dest_size: Some(size),
394                source: Some(Rect::new(sx, sy, sw, sh)),
395                flip_x: sprite.flip_x,
396                ..Default::default()
397            },
398        );
399    }
400
401    /// Draws the visible part of `painted` (its `clip`) at `(x, y)`, `size`
402    /// big, from its picture's texture, uploaded only when the picture's
403    /// revision moved; a [`MISSING_COLOR`] rectangle if `pictures` lacks
404    /// it (ADR-0078).
405    fn draw_painted(
406        &mut self,
407        painted: &PaintedSprite,
408        pictures: &PaintedTable,
409        x: f32,
410        y: f32,
411        size: Vec2,
412    ) {
413        let Some(picture) = pictures.get(painted.picture) else {
414            draw_rectangle(x, y, size.x, size.y, MISSING_COLOR);
415            return;
416        };
417        let (width, height) = picture.size();
418        if width == 0 || height == 0 {
419            return;
420        }
421        let make = |rgba: &[u8]| {
422            let texture = Texture2D::from_rgba8(width, height, rgba);
423            texture.set_filter(FilterMode::Nearest);
424            texture
425        };
426        let update = |texture: &Texture2D, rgba: &[u8]| {
427            texture.update_from_bytes(width.into(), height.into(), rgba);
428        };
429        let texture = self.painted.texture(painted.picture, picture, make, update);
430        let [sx, sy, sw, sh] = painted.clipped_src();
431        let bright = painted.brightness;
432        draw_texture_ex(
433            texture,
434            x,
435            y,
436            Color::from_rgba(bright, bright, bright, 255),
437            DrawTextureParams {
438                dest_size: Some(size),
439                source: Some(Rect::new(sx, sy, sw, sh)),
440                ..Default::default()
441            },
442        );
443    }
444
445    /// Where to find `glyph` in the atlas, what colour to tint it and how
446    /// many cells it covers: `fg` over both cells for a wide glyph whose
447    /// continuation follows it (`wide`), from the language's wide set `set`
448    /// first ([`FontAtlasDef::wide_cell`]), over one for a narrow glyph, or
449    /// the fallback glyph in [`MISSING_COLOR`] over one (logged once per
450    /// glyph). As the raster's painter does.
451    fn atlas_cell(
452        &mut self,
453        glyph: char,
454        fg: Rgb,
455        wide: bool,
456        set: Option<&str>,
457    ) -> Option<(AtlasRect, Color, f32)> {
458        if let Some(rect) = self.atlas.wide_rect_in(set, glyph).filter(|_| wide) {
459            return Some((rect, color(fg), 2.0));
460        }
461        if let Some(rect) = self.atlas.glyph_rect(glyph) {
462            return Some((rect, color(fg), 1.0));
463        }
464        if self.warned.insert(glyph) {
465            warn!(
466                "glyph {:?} (U+{:04X}) not in font atlas",
467                glyph,
468                u32::from(glyph)
469            );
470        }
471        Some((self.atlas.glyph_rect(FALLBACK_GLYPH)?, MISSING_COLOR, 1.0))
472    }
473}
474
475/// The picture of PNG file `png`, or why it can't be decoded.
476pub(crate) fn decode(png: &[u8]) -> Result<Image, String> {
477    Image::from_file_with_format(png, Some(ImageFormat::Png)).map_err(|e| e.to_string())
478}
479
480/// `image` as a texture drawn with nearest-pixel sampling.
481fn texture_of(image: &Image) -> Texture2D {
482    let texture = Texture2D::from_image(image);
483    texture.set_filter(FilterMode::Nearest);
484    texture
485}
486
487/// A texture of PNG file `png`, or why it can't be decoded.
488pub(crate) fn upload(png: &[u8]) -> Result<Texture2D, String> {
489    decode(png).map(|image| texture_of(&image))
490}
491
492/// A texture of PNG file `png` recoloured as `recolour`, or `None` if it
493/// can't be decoded.
494fn recoloured(png: &[u8], recolour: Recolour) -> Option<Texture2D> {
495    let mut picture = Image::from_file_with_format(png, Some(ImageFormat::Png)).ok()?;
496    for pixel in picture.bytes.as_chunks_mut::<4>().0 {
497        let own = Rgb::new(pixel[0], pixel[1], pixel[2]);
498        let rgb = match recolour {
499            Recolour::Silhouette => Rgb::new(255, 255, 255),
500            Recolour::Dimmed => Paint::Dimmed.apply(own),
501            Recolour::Shaded(light) => Paint::Shaded(light).apply(own),
502        };
503        pixel[..3].copy_from_slice(&[rgb.r, rgb.g, rgb.b]);
504    }
505    let texture = Texture2D::from_image(&picture);
506    texture.set_filter(FilterMode::Nearest);
507    Some(texture)
508}
509
510/// `c` as macroquad's colour.
511pub(crate) fn color(c: Rgb) -> Color {
512    Color::from_rgba(c.r, c.g, c.b, 255)
513}
514
515#[allow(clippy::cast_precision_loss)] // atlas coordinates are small
516fn source_rect(r: AtlasRect) -> Rect {
517    Rect::new(r.x as f32, r.y as f32, r.w as f32, r.h as f32)
518}