Skip to main content

yog_cli/design/
raster.rs

1//! The designer's stand-in for the game's `DrawContext`: a display list of
2//! `draw2d_*` calls (what yog-ui's renderer — or a mod's own render code —
3//! issues) rasterized the way the Yog hosts draw them in game: coordinates
4//! truncated to whole GUI pixels (`NativeDraw` casts with `(int)`), alpha
5//! blending, Minecraft's font (see [`font`](super::font)) and textures.
6//! Output is RGBA at `scale` physical pixels per GUI pixel, for the canvas
7//! to show with nearest-neighbour scaling.
8
9use std::collections::HashMap;
10use std::sync::Arc;
11
12use serde::{Deserialize, Serialize};
13
14use yog_gfx::Color;
15use yog_ui::text::LINE_H;
16
17use super::assets::{Image, Resources};
18use super::font::{Font, Glyph, styled};
19
20/// One `draw2d_*` call (GUI pixels, colors `0xAARRGGBB`).
21#[derive(Debug, Clone, Serialize, Deserialize)]
22#[serde(tag = "op", rename_all = "snake_case")]
23pub enum DrawOp {
24    Rect {
25        x1: f32,
26        y1: f32,
27        x2: f32,
28        y2: f32,
29        color: u32,
30    },
31    Gradient {
32        x1: f32,
33        y1: f32,
34        x2: f32,
35        y2: f32,
36        top: u32,
37        bottom: u32,
38    },
39    Text {
40        text: String,
41        font: String,
42        x: f32,
43        y: f32,
44        color: u32,
45        shadow: bool,
46    },
47    /// `draw2d_text_scaled`: text `scale` times its size.
48    TextScaled {
49        text: String,
50        font: String,
51        x: f32,
52        y: f32,
53        scale: f32,
54        color: u32,
55        shadow: bool,
56    },
57    Item {
58        id: String,
59        x: f32,
60        y: f32,
61        size: f32,
62    },
63    /// `draw2d_blocks`: blocks in 3D (`x y z state` lines) as big as fits
64    /// in `x, y, w, h`, turned `yaw`, tilted `pitch` (degrees).
65    Blocks {
66        list: String,
67        x: f32,
68        y: f32,
69        w: f32,
70        h: f32,
71        yaw: f32,
72        pitch: f32,
73    },
74    /// `draw2d_image`: a Yog image source into `x, y, w, h`; `region` `u v w h` (see [`images`](super::images)).
75    Image {
76        source: String,
77        x: f32,
78        y: f32,
79        w: f32,
80        h: f32,
81        region: [f32; 4],
82        tint: u32,
83    },
84    /// `draw2d_svg`: an SVG document into `x, y, w, h`.
85    Svg {
86        data: String,
87        x: f32,
88        y: f32,
89        w: f32,
90        h: f32,
91    },
92    /// `draw2d_entity`: a living entity standing in `x, y, w, h` — drawn
93    /// here as its spawn egg (the designer has no entity models).
94    Entity {
95        entity: String,
96        x: f32,
97        y: f32,
98        w: f32,
99        h: f32,
100    },
101    Scissor {
102        x: i32,
103        y: i32,
104        w: i32,
105        h: i32,
106    },
107    ClearScissor,
108}
109
110/// An RGBA canvas `w × h` physical pixels.
111pub struct Canvas<'r> {
112    pub w: u32,
113    pub h: u32,
114    pub rgba: Vec<u8>,
115    scale: f32,
116    clip: Option<(i32, i32, i32, i32)>,
117    res: &'r Resources,
118    fonts: HashMap<String, Arc<Font>>,
119}
120
121/// Straight-alpha "over".
122fn blend(dst: &mut [u8], src: [f32; 4]) {
123    let sa = src[3];
124    if sa <= 0.0 {
125        return;
126    }
127    let d = Color::from_rgba8([dst[0], dst[1], dst[2], dst[3]]).to_f32();
128    let oa = sa + d[3] * (1.0 - sa);
129    let mut o = [0.0, 0.0, 0.0, oa];
130    for i in 0..3 {
131        o[i] = (src[i] * sa + d[i] * d[3] * (1.0 - sa)) / oa;
132    }
133    dst[..4].copy_from_slice(&Color::from_f32(o).to_rgba8());
134}
135
136fn argb(c: u32) -> [f32; 4] {
137    Color(c).to_f32()
138}
139
140/// Text whose alpha is below this the game draws fully opaque (`Font.adjustColor`: `(c & 0xFC000000) == 0`).
141const TEXT_MIN_ALPHA: u8 = 4;
142
143/// The game draws text whose alpha is (almost) 0 fully opaque.
144fn text_color(c: u32) -> u32 {
145    let c = Color(c);
146    if c.a() < TEXT_MIN_ALPHA { c.with_alpha(u8::MAX).0 } else { c.0 }
147}
148
149/// The game's text shadow: each channel a quarter of the text's (`rgb & 0xFCFCFC >> 2`).
150fn shadow_color(c: u32) -> u32 {
151    const QUARTER_MASK: u32 = 0x00FC_FCFC;
152    const QUARTER_SHIFT: u32 = 2;
153    (c & Color::BLACK.0) | ((c & QUARTER_MASK) >> QUARTER_SHIFT)
154}
155
156/// Where the game draws a line's underline and strikethrough: one pixel
157/// above the line's bottom, and one through its middle.
158const UNDERLINE: (f32, f32) = (LINE_H, LINE_H - 1.0);
159const STRIKE: (f32, f32) = (LINE_H / 2.0, LINE_H / 2.0 - 1.0);
160
161impl<'r> Canvas<'r> {
162    pub fn new(gui_w: u32, gui_h: u32, scale: u32, res: &'r Resources) -> Self {
163        let (w, h) = (gui_w * scale, gui_h * scale);
164        Canvas {
165            w,
166            h,
167            rgba: vec![0; w as usize * h as usize * 4],
168            scale: scale as f32,
169            clip: None,
170            res,
171            fonts: HashMap::new(),
172        }
173    }
174
175    pub fn font(&mut self, id: &str) -> Arc<Font> {
176        let res = self.res;
177        self.fonts
178            .entry(id.to_string())
179            .or_insert_with(|| Font::load(res, id))
180            .clone()
181    }
182
183    /// Physical pixel span of GUI `[a, b)`, inside the canvas (and the clip).
184    fn span(&self, a: f32, b: f32, vertical: bool) -> (i32, i32) {
185        let max = if vertical { self.h } else { self.w } as i32;
186        let (mut lo, mut hi) = (
187            (a * self.scale).floor() as i32,
188            (b * self.scale).floor() as i32,
189        );
190        if let Some((cx, cy, cw, ch)) = self.clip {
191            let (c0, c1) = if vertical {
192                (cy, cy + ch)
193            } else {
194                (cx, cx + cw)
195            };
196            lo = lo.max((c0 as f32 * self.scale) as i32);
197            hi = hi.min((c1 as f32 * self.scale) as i32);
198        }
199        (lo.max(0), hi.min(max))
200    }
201
202    fn px(&mut self, x: i32, y: i32) -> &mut [u8] {
203        let i = ((y as u32 * self.w + x as u32) * 4) as usize;
204        &mut self.rgba[i..i + 4]
205    }
206
207    /// `img` mapped onto the parallelogram `o`, `o + a`, `o + a + b`, `o + b`
208    /// (GUI pixels; `a` runs along the image's x, `b` along its y), times
209    /// `shade`.
210    pub(super) fn quad(&mut self, img: &Image, o: [f32; 2], a: [f32; 2], b: [f32; 2], shade: f32) {
211        let det = a[0] * b[1] - a[1] * b[0];
212        if det.abs() < 1e-6 {
213            return;
214        }
215        let xs = [o[0], o[0] + a[0], o[0] + b[0], o[0] + a[0] + b[0]];
216        let ys = [o[1], o[1] + a[1], o[1] + b[1], o[1] + a[1] + b[1]];
217        let min = |v: [f32; 4]| v.iter().copied().fold(f32::MAX, f32::min);
218        let max = |v: [f32; 4]| v.iter().copied().fold(f32::MIN, f32::max);
219        let (xa, xb) = self.span(min(xs), max(xs) + 1.0 / self.scale, false);
220        let (ya, yb) = self.span(min(ys), max(ys) + 1.0 / self.scale, true);
221        for py in ya..yb {
222            let gy = (py as f32 + 0.5) / self.scale - o[1];
223            for px in xa..xb {
224                let gx = (px as f32 + 0.5) / self.scale - o[0];
225                let u = (gx * b[1] - gy * b[0]) / det;
226                let v = (a[0] * gy - a[1] * gx) / det;
227                if !(0.0..1.0).contains(&u) || !(0.0..1.0).contains(&v) {
228                    continue;
229                }
230                let t = img.pixel((u * img.width as f32) as u32, (v * img.width.min(img.height) as f32) as u32).to_rgba8();
231                if t[3] == 0 {
232                    continue;
233                }
234                let [r, g, b, a] = Color::from_rgba8(t).to_f32();
235                let c = [r * shade, g * shade, b * shade, a];
236                blend(self.px(px, py), c);
237            }
238        }
239    }
240
241    pub(super) fn resources(&self) -> &Resources {
242        self.res
243    }
244
245    pub fn fill(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, top: u32, bottom: u32) {
246        let (x1, x2) = (
247            (x1 as i32).min(x2 as i32) as f32,
248            (x1 as i32).max(x2 as i32) as f32,
249        );
250        let (y1, y2) = (
251            (y1 as i32).min(y2 as i32) as f32,
252            (y1 as i32).max(y2 as i32) as f32,
253        );
254        let (xa, xb) = self.span(x1, x2, false);
255        let (ya, yb) = self.span(y1, y2, true);
256        let (t, b) = (argb(top), argb(bottom));
257        let height = ((y2 - y1) * self.scale).max(1.0);
258        for py in ya..yb {
259            let f = ((py as f32 + 0.5 - y1 * self.scale) / height).clamp(0.0, 1.0);
260            let c = [0, 1, 2, 3].map(|i| t[i] + (b[i] - t[i]) * f);
261            for px in xa..xb {
262                blend(self.px(px, py), c);
263            }
264        }
265    }
266
267    /// Draws `img` texels `(u, v)`–`(u + w·tw/…)` into GUI box `x, y, w, h`
268    /// — UVs normalized against the declared texture size `tw × th`.
269    #[allow(clippy::too_many_arguments)]
270    pub(super) fn blit(
271        &mut self,
272        img: &Image,
273        x: f32,
274        y: f32,
275        w: f32,
276        h: f32,
277        uv: [f32; 4],
278        tint: [f32; 4],
279    ) {
280        let (xa, xb) = self.span(x, x + w, false);
281        let (ya, yb) = self.span(y, y + h, true);
282        let [u0, v0, u1, v1] = uv;
283        for py in ya..yb {
284            let fy = ((py as f32 + 0.5) / self.scale - y) / h;
285            let ty = ((v0 + (v1 - v0) * fy) * img.height as f32).floor() as i64;
286            if ty < 0 || ty >= img.height as i64 {
287                continue;
288            }
289            for px in xa..xb {
290                let fx = ((px as f32 + 0.5) / self.scale - x) / w;
291                let tx = ((u0 + (u1 - u0) * fx) * img.width as f32).floor() as i64;
292                if tx < 0 || tx >= img.width as i64 {
293                    continue;
294                }
295                let t = img.pixel(tx as u32, ty as u32).to_rgba8();
296                let t = Color::from_rgba8(t).to_f32();
297                let c = [0, 1, 2, 3].map(|i| t[i] * tint[i]);
298                blend(self.px(px, py), c);
299            }
300        }
301    }
302
303    fn glyph(&mut self, g: &Glyph, x: f32, y: f32, k: f32, color: u32) {
304        let Some(b) = &g.bitmap else { return };
305        let img = Image {
306            width: b.w,
307            height: b.h,
308            rgba: b.rgba.clone(),
309        };
310        self.blit(
311            &img,
312            x,
313            y + b.top * k,
314            b.w as f32 / b.oversample * k,
315            b.h as f32 / b.oversample * k,
316            [0.0, 0.0, 1.0, 1.0],
317            argb(color),
318        );
319    }
320
321    /// `Font.drawInBatch`: the shadow pass (offset, color / 4), then the text.
322    pub fn text(&mut self, text: &str, font: &str, x: f32, y: f32, color: u32, shadow: bool) {
323        self.text_scaled(text, font, (x as i32) as f32, (y as i32) as f32, 1.0, color, shadow);
324    }
325
326    /// [`Self::text`] `k` times its size (the host scales the pose: no
327    /// rounding to whole pixels).
328    #[allow(clippy::too_many_arguments)]
329    pub fn text_scaled(&mut self, text: &str, font: &str, x: f32, y: f32, k: f32, color: u32, shadow: bool) {
330        let font = self.font(font);
331        let chars = styled(text, text_color(color));
332        let passes: &[bool] = if shadow { &[true, false] } else { &[false] };
333        for &is_shadow in passes {
334            let mut pen = x;
335            for s in &chars {
336                let g = font.glyph(s.ch);
337                let c = if is_shadow {
338                    shadow_color(s.color)
339                } else {
340                    s.color
341                };
342                let off = if is_shadow { g.shadow_offset * k } else { 0.0 };
343                self.glyph(&g, pen + off, y + off, k, c);
344                let mut adv = g.advance * k;
345                if s.bold {
346                    self.glyph(&g, pen + off + g.bold_offset * k, y + off, k, c);
347                    adv += g.bold_offset * k;
348                }
349                if s.underline {
350                    self.fill(
351                        pen + off - 1.0,
352                        y + off + UNDERLINE.0,
353                        pen + off + adv,
354                        y + off + UNDERLINE.1,
355                        c,
356                        c,
357                    );
358                }
359                if s.strike {
360                    self.fill(
361                        pen + off - 1.0,
362                        y + off + STRIKE.0,
363                        pen + off + adv,
364                        y + off + STRIKE.1,
365                        c,
366                        c,
367                    );
368                }
369                pen += adv;
370            }
371        }
372    }
373
374    pub fn draw(&mut self, op: &DrawOp) {
375        match op {
376            DrawOp::Rect {
377                x1,
378                y1,
379                x2,
380                y2,
381                color,
382            } => self.fill(*x1, *y1, *x2, *y2, *color, *color),
383            DrawOp::Gradient {
384                x1,
385                y1,
386                x2,
387                y2,
388                top,
389                bottom,
390            } => self.fill(*x1, *y1, *x2, *y2, *top, *bottom),
391            DrawOp::Text {
392                text,
393                font,
394                x,
395                y,
396                color,
397                shadow,
398            } => self.text(text, font, *x, *y, *color, *shadow),
399            DrawOp::TextScaled { text, font, x, y, scale, color, shadow } => {
400                self.text_scaled(text, font, *x, *y, *scale, *color, *shadow)
401            }
402            DrawOp::Blocks { list, x, y, w, h, yaw, pitch } => {
403                super::blocks3d::draw(self, list, [*x, *y, *w, *h], *yaw, *pitch)
404            }
405            DrawOp::Item { id, x, y, size } => {
406                // As the game shows it in a slot (blocks and heads in 3D;
407                // mod items: from their `content/` file).
408                let source = format!("item:{id}");
409                super::images::draw(self, &source, [*x, *y, *size, *size], [0.0; 4], Color::WHITE.0);
410            }
411            DrawOp::Image {
412                source,
413                x,
414                y,
415                w,
416                h,
417                region,
418                tint,
419            } => super::images::draw(self, source, [*x, *y, *w, *h], *region, *tint),
420            DrawOp::Svg { data, x, y, w, h } => super::images::draw_svg(self, data, [*x, *y, *w, *h], Color::WHITE.0),
421            DrawOp::Entity { entity, x, y, w, h } => super::images::draw_entity(self, entity, [*x, *y, *w, *h]),
422            DrawOp::Scissor { x, y, w, h } => self.clip = Some((*x, *y, *w, *h)),
423            DrawOp::ClearScissor => self.clip = None,
424        }
425    }
426
427    pub fn png(&self) -> Result<Vec<u8>, String> {
428        let mut out = Vec::new();
429        let mut enc = png::Encoder::new(&mut out, self.w, self.h);
430        enc.set_color(png::ColorType::Rgba);
431        enc.set_depth(png::BitDepth::Eight);
432        enc.set_compression(png::Compression::Fast);
433        let mut writer = enc.write_header().map_err(|e| e.to_string())?;
434        writer
435            .write_image_data(&self.rgba)
436            .map_err(|e| e.to_string())?;
437        writer.finish().map_err(|e| e.to_string())?;
438        Ok(out)
439    }
440}
441
442/// The most physical pixels a canvas gets (256 MiB of RGBA).
443const MAX_PIXELS: u64 = 1 << 26;
444
445/// `scale` clamped to 1..=8, lowered until a `gui_w`×`gui_h` canvas fits [`MAX_PIXELS`].
446pub fn fit_scale(gui_w: u32, gui_h: u32, scale: u32) -> u32 {
447    let gui = u64::from(gui_w) * u64::from(gui_h);
448    let mut scale = scale.clamp(1, 8);
449    while scale > 1 && gui.saturating_mul(u64::from(scale * scale)) > MAX_PIXELS {
450        scale -= 1;
451    }
452    scale
453}
454
455/// Rasterizes a display list.
456pub fn rasterize<'r>(
457    ops: &[DrawOp],
458    gui_w: u32,
459    gui_h: u32,
460    scale: u32,
461    res: &'r Resources,
462) -> Canvas<'r> {
463    let mut canvas = Canvas::new(gui_w, gui_h, fit_scale(gui_w, gui_h, scale), res);
464    for op in ops {
465        canvas.draw(op);
466    }
467    canvas
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473
474    fn res() -> Resources {
475        Resources {
476            mod_root: None,
477            vanilla: None,
478        }
479    }
480
481    #[test]
482    fn huge_canvases_lower_their_scale() {
483        assert_eq!(fit_scale(400, 300, 3), 3);
484        assert_eq!(fit_scale(400, 300, 0), 1);
485        assert_eq!(fit_scale(4096, 4096, 8), 2);
486        assert_eq!(fit_scale(u32::MAX, u32::MAX, 8), 1);
487    }
488
489    #[test]
490    fn rects_truncate_to_gui_pixels_and_blend() {
491        let r = res();
492        let c = rasterize(
493            &[DrawOp::Rect {
494                x1: 1.7,
495                y1: 0.0,
496                x2: 3.2,
497                y2: 1.0,
498                color: 0xFF_FF0000,
499            }],
500            4,
501            1,
502            2,
503            &r,
504        );
505        // x 1..3 → physical 2..6
506        let px = |x: usize| &c.rgba[x * 4..x * 4 + 4];
507        assert_eq!(px(1), &[0, 0, 0, 0]);
508        assert_eq!(px(2), &[255, 0, 0, 255]);
509        assert_eq!(px(5), &[255, 0, 0, 255]);
510        assert_eq!(px(6), &[0, 0, 0, 0]);
511    }
512
513    #[test]
514    fn scissor_clips() {
515        let r = res();
516        let ops = [
517            DrawOp::Scissor {
518                x: 0,
519                y: 0,
520                w: 1,
521                h: 1,
522            },
523            DrawOp::Rect {
524                x1: 0.0,
525                y1: 0.0,
526                x2: 2.0,
527                y2: 1.0,
528                color: 0xFF_FFFFFF,
529            },
530        ];
531        let c = rasterize(&ops, 2, 1, 1, &r);
532        assert_eq!(c.rgba[3], 255);
533        assert_eq!(c.rgba[7], 0);
534    }
535
536    #[test]
537    fn missing_font_draws_boxes_with_the_shadow_first() {
538        let r = res();
539        let mut c = Canvas::new(16, 10, 1, &r);
540        c.text("A", "", 0.0, 0.0, 0xFF_FFFFFF, true);
541        // Missing glyph box: top-left texel white, shadow at (1,1) under the text's own (1,1)? (1,1) is the box's inside → shadow shows.
542        assert_eq!(&c.rgba[0..4], &[255, 255, 255, 255]);
543        let i = (16 + 1) * 4;
544        assert_eq!(&c.rgba[i..i + 4], &[63, 63, 63, 255]);
545    }
546}