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