Skip to main content

max / shop

Glyph on screen: swash + guillotiere + wgpu text pipeline crates/shop-render: font-agnostic text renderer. GlyphAtlas over R8Unorm wgpu texture with guillotiere allocator; Shaper wraps swash ScaleContext + ShapeContext (GSUB/GPOS runs internally, so ligatures fall out on capable fonts); TextRenderer batches all glyph quads into one instanced draw call per frame with premultiplied alpha blending. crates/shop: loads DejaVu Sans Mono from the standard Debian/Ubuntu path, draws a hardcoded headline over the clear color on each configure. Verified on fw13/Wayland: builds and runs to steady-state without error; visual confirmation pending eyeball on fw13's display.
Co-Authored-By
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-23 19:04 UTC
Signed with PGP, not checked
Commit: 5eceb2f6b7f561070aba94563e5b0504c2dca627
Parent: 05ac03e
10 files changed, +765 insertions, -16 deletions
M Cargo.lock +135
@@ -74,6 +74,15 @@
74 74 source = "registry+https://github.com/rust-lang/crates.io-index"
75 75 checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
76 76
77 + [[package]]
78 + name = "block2"
79 + version = "0.6.2"
80 + source = "registry+https://github.com/rust-lang/crates.io-index"
81 + checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
82 + dependencies = [
83 + "objc2",
84 + ]
85 +
77 86 [[package]]
78 87 name = "bytemuck"
79 88 version = "1.25.2"
@@ -227,6 +236,15 @@
227 236 "windows-sys",
228 237 ]
229 238
239 + [[package]]
240 + name = "euclid"
241 + version = "0.22.14"
242 + source = "registry+https://github.com/rust-lang/crates.io-index"
243 + checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06"
244 + dependencies = [
245 + "num-traits",
246 + ]
247 +
230 248 [[package]]
231 249 name = "find-msvc-tools"
232 250 version = "0.1.9"
@@ -239,6 +257,15 @@
239 257 source = "registry+https://github.com/rust-lang/crates.io-index"
240 258 checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
241 259
260 + [[package]]
261 + name = "font-types"
262 + version = "0.12.1"
263 + source = "registry+https://github.com/rust-lang/crates.io-index"
264 + checksum = "ad67eced03f5504d9cbd3a879b5958b5c54d4e5fd794361c6eb21b05fb703411"
265 + dependencies = [
266 + "bytemuck",
267 + ]
268 +
242 269 [[package]]
243 270 name = "gpu-allocator"
244 271 version = "0.28.0"
@@ -252,6 +279,15 @@
252 279 "thiserror",
253 280 ]
254 281
282 + [[package]]
283 + name = "guillotiere"
284 + version = "0.7.0"
285 + source = "registry+https://github.com/rust-lang/crates.io-index"
286 + checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab"
287 + dependencies = [
288 + "euclid",
289 + ]
290 +
255 291 [[package]]
256 292 name = "half"
257 293 version = "2.7.1"
@@ -460,6 +496,18 @@
460 496 "objc2",
461 497 ]
462 498
499 + [[package]]
500 + name = "objc2-core-graphics"
501 + version = "0.3.2"
502 + source = "registry+https://github.com/rust-lang/crates.io-index"
503 + checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
504 + dependencies = [
505 + "dispatch2",
506 + "objc2",
507 + "objc2-core-foundation",
508 + "objc2-io-surface",
509 + ]
510 +
463 511 [[package]]
464 512 name = "objc2-encode"
465 513 version = "4.1.0"
@@ -477,6 +525,17 @@
477 525 "objc2-core-foundation",
478 526 ]
479 527
528 + [[package]]
529 + name = "objc2-io-surface"
530 + version = "0.3.2"
531 + source = "registry+https://github.com/rust-lang/crates.io-index"
532 + checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
533 + dependencies = [
534 + "bitflags",
535 + "objc2",
536 + "objc2-core-foundation",
537 + ]
538 +
480 539 [[package]]
481 540 name = "objc2-metal"
482 541 version = "0.3.2"
@@ -484,6 +543,7 @@
484 543 checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794"
485 544 dependencies = [
486 545 "bitflags",
546 + "block2",
487 547 "objc2",
488 548 "objc2-foundation",
489 549 ]
@@ -497,6 +557,7 @@
497 557 "bitflags",
498 558 "objc2",
499 559 "objc2-core-foundation",
560 + "objc2-core-graphics",
500 561 "objc2-foundation",
501 562 "objc2-metal",
502 563 ]
@@ -643,6 +704,17 @@
643 704 "objc2-quartz-core",
644 705 ]
645 706
707 + [[package]]
708 + name = "read-fonts"
709 + version = "0.41.0"
710 + source = "registry+https://github.com/rust-lang/crates.io-index"
711 + checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709"
712 + dependencies = [
713 + "bytemuck",
714 + "font-types",
715 + "once_cell",
716 + ]
717 +
646 718 [[package]]
647 719 name = "redox_syscall"
648 720 version = "0.5.18"
@@ -727,12 +799,25 @@
727 799 dependencies = [
728 800 "anyhow",
729 801 "pollster",
802 + "shop-render",
730 803 "shop-wayland",
731 804 "tracing",
732 805 "tracing-subscriber",
733 806 "wgpu",
734 807 ]
735 808
809 + [[package]]
810 + name = "shop-render"
811 + version = "0.0.0"
812 + dependencies = [
813 + "anyhow",
814 + "bytemuck",
815 + "guillotiere",
816 + "swash",
817 + "tracing",
818 + "wgpu",
819 + ]
820 +
736 821 [[package]]
737 822 name = "shop-wayland"
738 823 version = "0.0.0"
@@ -744,6 +829,16 @@
744 829 "wayland-client",
745 830 ]
746 831
832 + [[package]]
833 + name = "skrifa"
834 + version = "0.44.0"
835 + source = "registry+https://github.com/rust-lang/crates.io-index"
836 + checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68"
837 + dependencies = [
838 + "bytemuck",
839 + "read-fonts",
840 + ]
841 +
747 842 [[package]]
748 843 name = "slab"
749 844 version = "0.4.12"
@@ -799,6 +894,17 @@
799 894 source = "registry+https://github.com/rust-lang/crates.io-index"
800 895 checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
801 896
897 + [[package]]
898 + name = "swash"
899 + version = "0.2.10"
900 + source = "registry+https://github.com/rust-lang/crates.io-index"
901 + checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e"
902 + dependencies = [
903 + "skrifa",
904 + "yazi",
905 + "zeno",
906 + ]
907 +
802 908 [[package]]
803 909 name = "syn"
804 910 version = "2.0.119"
@@ -1101,12 +1207,22 @@
1101 1207 "rustc-hash",
1102 1208 "smallvec",
1103 1209 "thiserror",
1210 + "wgpu-core-deps-apple",
1104 1211 "wgpu-core-deps-windows-linux-android",
1105 1212 "wgpu-hal",
1106 1213 "wgpu-naga-bridge",
1107 1214 "wgpu-types",
1108 1215 ]
1109 1216
1217 + [[package]]
1218 + name = "wgpu-core-deps-apple"
1219 + version = "30.0.0"
1220 + source = "registry+https://github.com/rust-lang/crates.io-index"
1221 + checksum = "3283b6da70d7fc892de95840215aa36381b5d0cbc479e88ee2b173c7b992b225"
1222 + dependencies = [
1223 + "wgpu-hal",
1224 + ]
1225 +
1110 1226 [[package]]
1111 1227 name = "wgpu-core-deps-windows-linux-android"
1112 1228 version = "30.0.0"
@@ -1126,6 +1242,7 @@
1126 1242 "arrayvec",
1127 1243 "ash",
1128 1244 "bitflags",
1245 + "block2",
1129 1246 "bytemuck",
1130 1247 "cfg-if",
1131 1248 "cfg_aliases",
@@ -1136,6 +1253,12 @@
1136 1253 "log",
1137 1254 "naga",
1138 1255 "naga-types",
1256 + "objc2",
1257 + "objc2-core-foundation",
1258 + "objc2-core-graphics",
1259 + "objc2-foundation",
1260 + "objc2-metal",
1261 + "objc2-quartz-core",
1139 1262 "ordered-float",
1140 1263 "parking_lot",
1141 1264 "portable-atomic",
@@ -1321,6 +1444,18 @@
1321 1444 "bytemuck",
1322 1445 ]
1323 1446
1447 + [[package]]
1448 + name = "yazi"
1449 + version = "0.2.1"
1450 + source = "registry+https://github.com/rust-lang/crates.io-index"
1451 + checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5"
1452 +
1453 + [[package]]
1454 + name = "zeno"
1455 + version = "0.3.3"
1456 + source = "registry+https://github.com/rust-lang/crates.io-index"
1457 + checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524"
1458 +
1324 1459 [[package]]
1325 1460 name = "zerocopy"
1326 1461 version = "0.8.55"
M Cargo.toml +5 -1
@@ -4,6 +4,7 @@
4 4 "crates/shop",
5 5 "crates/kitty-graphics",
6 6 "crates/shop-wayland",
7 + "crates/shop-render",
7 8 ]
8 9
9 10 [workspace.dependencies]
@@ -11,12 +12,15 @@
11 12 wayland-client = { version = "0.31", features = ["system"] }
12 13 calloop = "0.14"
13 14 calloop-wayland-source = "0.4"
14 - wgpu = { version = "30", default-features = false, features = ["wgsl", "vulkan"] }
15 + wgpu = { version = "30", default-features = false, features = ["wgsl", "vulkan", "metal"] }
15 16 raw-window-handle = "0.6"
16 17 pollster = "1"
17 18 anyhow = "1"
18 19 tracing = "0.1"
19 20 tracing-subscriber = { version = "0.3", features = ["env-filter"] }
21 + swash = "0.2"
22 + guillotiere = "0.7"
23 + bytemuck = { version = "1", features = ["derive"] }
20 24
21 25 [workspace.package]
22 26 edition = "2024"
@@ -17,6 +17,7 @@
17 17
18 18 [dependencies]
19 19 shop-wayland = { path = "../shop-wayland" }
20 + shop-render = { path = "../shop-render" }
20 21 wgpu.workspace = true
21 22 pollster.workspace = true
22 23 anyhow.workspace = true
@@ -85,8 +85,7 @@
85 85 let xdg_shell = XdgShell::bind(&globals, &qh)?;
86 86
87 87 let surface = compositor.create_surface(&qh);
88 - let xdg_window =
89 - xdg_shell.create_window(surface, WindowDecorations::ServerDefault, &qh);
88 + let xdg_window = xdg_shell.create_window(surface, WindowDecorations::ServerDefault, &qh);
90 89 xdg_window.set_title(spec.title.to_string());
91 90 xdg_window.set_app_id(spec.app_id.to_string());
92 91 xdg_window.set_min_size(Some(spec.min_size));
@@ -157,8 +156,7 @@
157 156 ) {
158 157 }
159 158
160 - fn frame(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &wl_surface::WlSurface, _: u32) {
161 - }
159 + fn frame(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &wl_surface::WlSurface, _: u32) {}
162 160
163 161 fn surface_enter(
164 162 &mut self,
@@ -1,24 +1,29 @@
1 1 //! shop — Wayland terminal emulator.
2 2 //!
3 - //! First-pixel milestone: SCTK window + wgpu clear color driven by the
4 - //! shop-wayland event queue. No PTY, no grid, no text yet.
3 + //! Milestone: glyph on screen. Loads DejaVu Sans Mono, shapes a hardcoded
4 + //! string with swash (ligatures on), rasterizes into a guillotiere-managed
5 + //! wgpu atlas, draws it with an instanced text pipeline.
5 6 //!
6 - //! Run on a Wayland compositor (Sway on fw13):
7 + //! Run on a Wayland compositor:
7 8 //! ```text
8 9 //! RUST_LOG=info cargo run -p shop
9 10 //! ```
10 11
12 + use shop_render::TextRenderer;
11 13 use shop_wayland::{Pending, Window, WindowSpec};
12 14 use tracing::{info, warn};
13 15
14 16 const INITIAL: (u32, u32) = (960, 540);
17 + const FONT_PATH: &str = "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf";
18 + const FONT_PX: f32 = 42.0;
19 + const HEADLINE: &str = "shop — first glyphs";
15 20 const CLEAR: wgpu::Color = wgpu::Color {
16 - // Alloy Akari palette — placeholder mid-value blue-grey until TOKENS is wired in.
17 21 r: 0.043,
18 22 g: 0.055,
19 23 b: 0.086,
20 24 a: 1.0,
21 25 };
26 + const TEXT_COLOR: [f32; 4] = [0.90, 0.92, 0.96, 1.0];
22 27
23 28 fn main() -> anyhow::Result<()> {
24 29 tracing_subscriber::fmt()
@@ -28,6 +33,9 @@
28 33 )
29 34 .init();
30 35
36 + let font_data = std::fs::read(FONT_PATH)
37 + .map_err(|e| anyhow::anyhow!("read font {FONT_PATH}: {e}"))?;
38 +
31 39 let mut window = Window::new(WindowSpec {
32 40 title: "shop",
33 41 app_id: "dev.makecreative.shop",
@@ -74,6 +82,8 @@
74 82 present_mode: wgpu::PresentMode::Mailbox,
75 83 };
76 84
85 + let mut text = TextRenderer::new(&device, format, font_data, FONT_PX)?;
86 +
77 87 info!("entering event loop");
78 88 while !window.should_exit() {
79 89 for change in window.dispatch()? {
@@ -82,13 +92,12 @@
82 92 config.width = width;
83 93 config.height = height;
84 94 surface.configure(&device, &config);
85 - if let Err(e) = draw(&surface, &device, &queue) {
95 + text.resize(&queue, width, height);
96 + if let Err(e) = draw(&surface, &device, &queue, &mut text) {
86 97 warn!("draw failed: {e:?}");
87 98 }
88 99 }
89 - Pending::CloseRequested => {
90 - info!("close requested");
91 - }
100 + Pending::CloseRequested => info!("close requested"),
92 101 }
93 102 }
94 103 }
@@ -98,7 +107,12 @@
98 107 Ok(())
99 108 }
100 109
101 - fn draw(surface: &wgpu::Surface<'_>, device: &wgpu::Device, queue: &wgpu::Queue) -> anyhow::Result<()> {
110 + fn draw(
111 + surface: &wgpu::Surface<'_>,
112 + device: &wgpu::Device,
113 + queue: &wgpu::Queue,
114 + text: &mut TextRenderer,
115 + ) -> anyhow::Result<()> {
102 116 let frame = match surface.get_current_texture() {
103 117 wgpu::CurrentSurfaceTexture::Success(t) | wgpu::CurrentSurfaceTexture::Suboptimal(t) => t,
104 118 other => anyhow::bail!("surface acquire: {other:?}"),
@@ -107,10 +121,10 @@
107 121 .texture
108 122 .create_view(&wgpu::TextureViewDescriptor::default());
109 123 let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
110 - label: Some("shop.clear"),
124 + label: Some("shop.frame"),
111 125 });
112 126 {
113 - let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
127 + let _clear = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
114 128 label: Some("shop.clear"),
115 129 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
116 130 view: &view,
@@ -124,6 +138,7 @@
124 138 ..Default::default()
125 139 });
126 140 }
141 + text.draw(device, queue, &view, &mut encoder, HEADLINE, 48.0, 40.0, TEXT_COLOR);
127 142 queue.submit(std::iter::once(encoder.finish()));
128 143 queue.present(frame);
129 144 Ok(())
@@ -1,0 +1,21 @@
1 + [package]
2 + name = "shop-render"
3 + version = "0.0.0"
4 + description = "wgpu text/quad renderer for shop (font-agnostic; owns atlas + pipeline)"
5 + edition.workspace = true
6 + rust-version.workspace = true
7 + authors.workspace = true
8 + repository.workspace = true
9 + license.workspace = true
10 + publish = false
11 +
12 + [lints]
13 + workspace = true
14 +
15 + [dependencies]
16 + wgpu.workspace = true
17 + swash.workspace = true
18 + guillotiere.workspace = true
19 + bytemuck.workspace = true
20 + anyhow.workspace = true
21 + tracing.workspace = true
@@ -1,0 +1,97 @@
1 + //! Glyph atlas: guillotiere allocator over a single R8Unorm wgpu texture.
2 +
3 + use guillotiere::{AllocId, Allocation, AtlasAllocator, size2};
4 +
5 + pub(crate) struct GlyphAtlas {
6 + pub(crate) texture: wgpu::Texture,
7 + pub(crate) view: wgpu::TextureView,
8 + pub(crate) size: u32,
9 + allocator: AtlasAllocator,
10 + }
11 +
12 + #[derive(Copy, Clone)]
13 + pub(crate) struct AtlasSlot {
14 + pub(crate) _id: AllocId,
15 + pub(crate) uv_min: [f32; 2],
16 + pub(crate) uv_max: [f32; 2],
17 + pub(crate) px: [u32; 2],
18 + }
19 +
20 + impl GlyphAtlas {
21 + pub(crate) fn new(device: &wgpu::Device, size: u32) -> Self {
22 + let texture = device.create_texture(&wgpu::TextureDescriptor {
23 + label: Some("shop.glyph_atlas"),
24 + size: wgpu::Extent3d {
25 + width: size,
26 + height: size,
27 + depth_or_array_layers: 1,
28 + },
29 + mip_level_count: 1,
30 + sample_count: 1,
31 + dimension: wgpu::TextureDimension::D2,
32 + format: wgpu::TextureFormat::R8Unorm,
33 + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
34 + view_formats: &[],
35 + });
36 + let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
37 + let allocator = AtlasAllocator::new(size2(size as i32, size as i32));
38 + Self {
39 + texture,
40 + view,
41 + size,
42 + allocator,
43 + }
44 + }
45 +
46 + /// Reserve a rectangle and upload the coverage bitmap. Returns `None` if
47 + /// the atlas is full (caller should evict or grow — MVP just skips glyph).
48 + pub(crate) fn upload(
49 + &mut self,
50 + queue: &wgpu::Queue,
51 + width: u32,
52 + height: u32,
53 + bitmap: &[u8],
54 + ) -> Option<AtlasSlot> {
55 + if width == 0 || height == 0 {
56 + return Some(AtlasSlot {
57 + _id: AllocId::deserialize(0),
58 + uv_min: [0.0, 0.0],
59 + uv_max: [0.0, 0.0],
60 + px: [0, 0],
61 + });
62 + }
63 + let Allocation { id, rectangle } = self
64 + .allocator
65 + .allocate(size2(width as i32 + 1, height as i32 + 1))?;
66 + let x = rectangle.min.x as u32;
67 + let y = rectangle.min.y as u32;
68 +
69 + queue.write_texture(
70 + wgpu::TexelCopyTextureInfo {
71 + texture: &self.texture,
72 + mip_level: 0,
73 + origin: wgpu::Origin3d { x, y, z: 0 },
74 + aspect: wgpu::TextureAspect::All,
75 + },
76 + bitmap,
77 + wgpu::TexelCopyBufferLayout {
78 + offset: 0,
79 + bytes_per_row: Some(width),
80 + rows_per_image: Some(height),
81 + },
82 + wgpu::Extent3d {
83 + width,
84 + height,
85 + depth_or_array_layers: 1,
86 + },
87 + );
88 +
89 + let s = self.size as f32;
90 + Some(AtlasSlot {
91 + _id: id,
92 + uv_min: [x as f32 / s, y as f32 / s],
93 + uv_max: [(x + width) as f32 / s, (y + height) as f32 / s],
94 + px: [width, height],
95 + })
96 + }
97 + }
@@ -1,0 +1,15 @@
1 + //! wgpu text renderer for shop.
2 + //!
3 + //! Owns the glyph atlas (guillotiere-allocated R8Unorm wgpu texture) and the
4 + //! text pipeline (single draw call per frame batching all glyph quads). Uses
5 + //! [swash] for shaping and rasterization — GSUB/GPOS, so ligatures fall out
6 + //! naturally when the font supports them.
7 + //!
8 + //! MVP scope: one font, one size, one pass, no fallback. Multi-font fallback
9 + //! and cluster tracking arrive with the grid milestone.
10 +
11 + mod atlas;
12 + mod pipeline;
13 + mod shaper;
14 +
15 + pub use pipeline::TextRenderer;
@@ -1,0 +1,359 @@
1 + //! wgpu text pipeline: one instanced draw call per frame.
2 +
3 + use std::collections::HashMap;
4 +
5 + use bytemuck::{Pod, Zeroable};
6 + use swash::GlyphId;
7 + use wgpu::util::DeviceExt;
8 +
9 + use crate::{
10 + atlas::{AtlasSlot, GlyphAtlas},
11 + shaper::Shaper,
12 + };
13 +
14 + const ATLAS_SIZE: u32 = 1024;
15 +
16 + #[repr(C)]
17 + #[derive(Clone, Copy, Pod, Zeroable)]
18 + struct Instance {
19 + pos: [f32; 2],
20 + size: [f32; 2],
21 + uv_min: [f32; 2],
22 + uv_max: [f32; 2],
23 + color: [f32; 4],
24 + }
25 +
26 + #[repr(C)]
27 + #[derive(Clone, Copy, Pod, Zeroable)]
28 + struct Uniforms {
29 + screen: [f32; 2],
30 + _pad: [f32; 2],
31 + }
32 +
33 + const QUAD: [[f32; 2]; 4] = [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]];
34 + const INDICES: [u16; 6] = [0, 1, 2, 2, 1, 3];
35 +
36 + const SHADER: &str = r#"
37 + struct Uniforms {
38 + screen: vec2<f32>,
39 + _pad: vec2<f32>,
40 + };
41 +
42 + @group(0) @binding(0) var<uniform> u: Uniforms;
43 + @group(0) @binding(1) var atlas: texture_2d<f32>;
44 + @group(0) @binding(2) var samp: sampler;
45 +
46 + struct VOut {
47 + @builtin(position) pos: vec4<f32>,
48 + @location(0) uv: vec2<f32>,
49 + @location(1) color: vec4<f32>,
50 + };
51 +
52 + @vertex
53 + fn vs(
54 + @location(0) corner: vec2<f32>,
55 + @location(1) inst_pos: vec2<f32>,
56 + @location(2) inst_size: vec2<f32>,
57 + @location(3) uv_min: vec2<f32>,
58 + @location(4) uv_max: vec2<f32>,
59 + @location(5) color: vec4<f32>,
60 + ) -> VOut {
61 + let px = inst_pos + corner * inst_size;
62 + let ndc = vec2<f32>(
63 + (px.x / u.screen.x) * 2.0 - 1.0,
64 + 1.0 - (px.y / u.screen.y) * 2.0,
65 + );
66 + var out: VOut;
67 + out.pos = vec4<f32>(ndc, 0.0, 1.0);
68 + out.uv = mix(uv_min, uv_max, corner);
69 + out.color = color;
70 + return out;
71 + }
72 +
73 + @fragment
74 + fn fs(in: VOut) -> @location(0) vec4<f32> {
75 + let a = textureSample(atlas, samp, in.uv).r;
76 + return vec4<f32>(in.color.rgb, in.color.a * a);
77 + }
78 + "#;
79 +
80 + pub struct TextRenderer {
81 + pipeline: wgpu::RenderPipeline,
82 + bind_group: wgpu::BindGroup,
83 + uniforms_buf: wgpu::Buffer,
84 + quad_buf: wgpu::Buffer,
85 + index_buf: wgpu::Buffer,
86 + instance_buf: wgpu::Buffer,
87 + instance_cap: u64,
88 + atlas: GlyphAtlas,
89 + shaper: Shaper,
90 + cache: HashMap<GlyphId, Option<CachedGlyph>>,
91 + }
92 +
93 + #[derive(Clone, Copy)]
94 + struct CachedGlyph {
95 + slot: AtlasSlot,
96 + left: i32,
97 + top: i32,
98 + }
99 +
100 + impl TextRenderer {
101 + pub fn new(
102 + device: &wgpu::Device,
103 + format: wgpu::TextureFormat,
104 + font_data: Vec<u8>,
105 + font_px: f32,
106 + ) -> anyhow::Result<Self> {
107 + let atlas = GlyphAtlas::new(device, ATLAS_SIZE);
108 + let shaper = Shaper::new(font_data, font_px)?;
109 +
110 + let uniforms_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
111 + label: Some("shop.text.uniforms"),
112 + contents: bytemuck::bytes_of(&Uniforms {
113 + screen: [1.0, 1.0],
114 + _pad: [0.0, 0.0],
115 + }),
116 + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
117 + });
118 + let quad_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
119 + label: Some("shop.text.quad"),
120 + contents: bytemuck::cast_slice(&QUAD),
121 + usage: wgpu::BufferUsages::VERTEX,
122 + });
123 + let index_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
124 + label: Some("shop.text.index"),
125 + contents: bytemuck::cast_slice(&INDICES),
126 + usage: wgpu::BufferUsages::INDEX,
127 + });
128 + let instance_cap: u64 = 256;
129 + let instance_buf = device.create_buffer(&wgpu::BufferDescriptor {
130 + label: Some("shop.text.instances"),
131 + size: instance_cap * std::mem::size_of::<Instance>() as u64,
132 + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
133 + mapped_at_creation: false,
134 + });
135 +
136 + let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
137 + label: Some("shop.text.sampler"),
138 + mag_filter: wgpu::FilterMode::Linear,
139 + min_filter: wgpu::FilterMode::Linear,
140 + ..Default::default()
141 + });
142 +
143 + let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
144 + label: Some("shop.text.bgl"),
145 + entries: &[
146 + wgpu::BindGroupLayoutEntry {
147 + binding: 0,
148 + visibility: wgpu::ShaderStages::VERTEX,
149 + ty: wgpu::BindingType::Buffer {
150 + ty: wgpu::BufferBindingType::Uniform,
151 + has_dynamic_offset: false,
152 + min_binding_size: None,
153 + },
154 + count: None,
155 + },
156 + wgpu::BindGroupLayoutEntry {
157 + binding: 1,
158 + visibility: wgpu::ShaderStages::FRAGMENT,
159 + ty: wgpu::BindingType::Texture {
160 + sample_type: wgpu::TextureSampleType::Float { filterable: true },
161 + view_dimension: wgpu::TextureViewDimension::D2,
162 + multisampled: false,
163 + },
164 + count: None,
165 + },
166 + wgpu::BindGroupLayoutEntry {
167 + binding: 2,
168 + visibility: wgpu::ShaderStages::FRAGMENT,
169 + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
170 + count: None,
171 + },
172 + ],
173 + });
174 +
175 + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
176 + label: Some("shop.text.bg"),
177 + layout: &bind_group_layout,
178 + entries: &[
179 + wgpu::BindGroupEntry {
180 + binding: 0,
181 + resource: uniforms_buf.as_entire_binding(),
182 + },
183 + wgpu::BindGroupEntry {
184 + binding: 1,
185 + resource: wgpu::BindingResource::TextureView(&atlas.view),
186 + },
187 + wgpu::BindGroupEntry {
188 + binding: 2,
189 + resource: wgpu::BindingResource::Sampler(&sampler),
190 + },
191 + ],
192 + });
193 +
194 + let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
195 + label: Some("shop.text.shader"),
196 + source: wgpu::ShaderSource::Wgsl(SHADER.into()),
197 + });
198 + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
199 + label: Some("shop.text.pl"),
200 + bind_group_layouts: &[Some(&bind_group_layout)],
201 + immediate_size: 0,
202 + });
203 + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
204 + label: Some("shop.text.pipeline"),
205 + layout: Some(&pipeline_layout),
206 + vertex: wgpu::VertexState {
207 + module: &module,
208 + entry_point: Some("vs"),
209 + compilation_options: Default::default(),
210 + buffers: &[
211 + Some(wgpu::VertexBufferLayout {
212 + array_stride: 8,
213 + step_mode: wgpu::VertexStepMode::Vertex,
214 + attributes: &wgpu::vertex_attr_array![0 => Float32x2],
215 + }),
216 + Some(wgpu::VertexBufferLayout {
217 + array_stride: std::mem::size_of::<Instance>() as u64,
218 + step_mode: wgpu::VertexStepMode::Instance,
219 + attributes: &wgpu::vertex_attr_array![
220 + 1 => Float32x2,
221 + 2 => Float32x2,
222 + 3 => Float32x2,
223 + 4 => Float32x2,
224 + 5 => Float32x4,
225 + ],
226 + }),
227 + ],
228 + },
229 + fragment: Some(wgpu::FragmentState {
230 + module: &module,
231 + entry_point: Some("fs"),
232 + compilation_options: Default::default(),
233 + targets: &[Some(wgpu::ColorTargetState {
234 + format,
235 + blend: Some(wgpu::BlendState::ALPHA_BLENDING),
236 + write_mask: wgpu::ColorWrites::ALL,
237 + })],
238 + }),
239 + primitive: wgpu::PrimitiveState::default(),
240 + depth_stencil: None,
241 + multisample: wgpu::MultisampleState::default(),
242 + multiview_mask: None,
243 + cache: None,
244 + });
245 +
246 + Ok(Self {
247 + pipeline,
248 + bind_group,
249 + uniforms_buf,
250 + quad_buf,
251 + index_buf,
252 + instance_buf,
253 + instance_cap,
254 + atlas,
255 + shaper,
256 + cache: HashMap::new(),
257 + })
258 + }
259 +
260 + pub fn resize(&self, queue: &wgpu::Queue, width: u32, height: u32) {
261 + let u = Uniforms {
262 + screen: [width as f32, height as f32],
263 + _pad: [0.0, 0.0],
264 + };
265 + queue.write_buffer(&self.uniforms_buf, 0, bytemuck::bytes_of(&u));
266 + }
267 +
268 + /// Shape `text`, rasterize any missing glyphs, and draw the resulting
269 + /// quads. `x`/`y` is the pen origin in pixels (top-left convention, with
270 + /// the baseline placed at `y + ascent`).
271 + pub fn draw(
272 + &mut self,
273 + device: &wgpu::Device,
274 + queue: &wgpu::Queue,
275 + view: &wgpu::TextureView,
276 + encoder: &mut wgpu::CommandEncoder,
277 + text: &str,
278 + x: f32,
279 + y: f32,
280 + color: [f32; 4],
281 + ) {
282 + let ascent = self.shaper.ascent();
283 + let baseline_y = y + ascent;
284 + let glyphs = self.shaper.shape(text);
285 + let mut pen_x = x;
286 + let mut instances: Vec<Instance> = Vec::with_capacity(glyphs.len());
287 +
288 + for g in glyphs {
289 + let cached = match self.cache.get(&g.id).copied() {
290 + Some(v) => v,
291 + None => {
292 + let entry = self.shaper.rasterize(g.id).and_then(|r| {
293 + self.atlas
294 + .upload(queue, r.width, r.height, &r.bitmap)
295 + .map(|slot| CachedGlyph {
296 + slot,
297 + left: r.placement_left,
298 + top: r.placement_top,
299 + })
300 + });
301 + self.cache.insert(g.id, entry);
302 + entry
303 + }
304 + };
305 + if let Some(c) = cached
306 + && c.slot.px[0] > 0
307 + && c.slot.px[1] > 0
308 + {
309 + let gx = pen_x + g.x_offset + c.left as f32;
310 + let gy = baseline_y - g.y_offset - c.top as f32;
311 + instances.push(Instance {
312 + pos: [gx, gy],
313 + size: [c.slot.px[0] as f32, c.slot.px[1] as f32],
314 + uv_min: c.slot.uv_min,
315 + uv_max: c.slot.uv_max,
316 + color,
317 + });
318 + }
319 + pen_x += g.advance;
320 + }
321 +
322 + if instances.is_empty() {
323 + return;
324 + }
325 +
326 + let needed = instances.len() as u64;
327 + if needed > self.instance_cap {
328 + let new_cap = needed.next_power_of_two();
329 + self.instance_buf = device.create_buffer(&wgpu::BufferDescriptor {
330 + label: Some("shop.text.instances"),
331 + size: new_cap * std::mem::size_of::<Instance>() as u64,
332 + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
333 + mapped_at_creation: false,
334 + });
335 + self.instance_cap = new_cap;
336 + }
337 + queue.write_buffer(&self.instance_buf, 0, bytemuck::cast_slice(&instances));
338 +
339 + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
340 + label: Some("shop.text.pass"),
341 + color_attachments: &[Some(wgpu::RenderPassColorAttachment {
342 + view,
343 + resolve_target: None,
344 + ops: wgpu::Operations {
345 + load: wgpu::LoadOp::Load,
346 + store: wgpu::StoreOp::Store,
347 + },
348 + depth_slice: None,
349 + })],
350 + ..Default::default()
351 + });
352 + pass.set_pipeline(&self.pipeline);
353 + pass.set_bind_group(0, &self.bind_group, &[]);
354 + pass.set_vertex_buffer(0, self.quad_buf.slice(..));
355 + pass.set_vertex_buffer(1, self.instance_buf.slice(..));
356 + pass.set_index_buffer(self.index_buf.slice(..), wgpu::IndexFormat::Uint16);
357 + pass.draw_indexed(0..6, 0, 0..instances.len() as u32);
358 + }
359 + }
@@ -1,0 +1,104 @@
1 + //! Swash wrapper: parse a font, shape a string, rasterize a glyph.
2 +
3 + use swash::{
4 + FontRef, GlyphId,
5 + scale::{Render, ScaleContext, Source, image::Content},
6 + shape::ShapeContext,
7 + zeno::Format,
8 + };
9 +
10 + pub(crate) struct Shaper {
11 + font_data: Vec<u8>,
12 + font_offset: u32,
13 + scale_ctx: ScaleContext,
14 + shape_ctx: ShapeContext,
15 + px: f32,
16 + }
17 +
18 + pub(crate) struct ShapedGlyph {
19 + pub(crate) id: GlyphId,
20 + /// Pen advance in pixels after this glyph.
21 + pub(crate) advance: f32,
22 + /// Position offsets relative to the pen origin.
23 + pub(crate) x_offset: f32,
24 + pub(crate) y_offset: f32,
25 + }
26 +
27 + pub(crate) struct Raster {
28 + pub(crate) bitmap: Vec<u8>,
29 + pub(crate) width: u32,
30 + pub(crate) height: u32,
31 + /// Left edge relative to the pen origin (positive = right of origin).
32 + pub(crate) placement_left: i32,
33 + /// Top edge relative to the baseline (positive = above baseline).
34 + pub(crate) placement_top: i32,
35 + }
36 +
37 + impl Shaper {
38 + pub(crate) fn new(font_data: Vec<u8>, px: f32) -> anyhow::Result<Self> {
39 + let font = FontRef::from_index(&font_data, 0)
40 + .ok_or_else(|| anyhow::anyhow!("swash: not a font"))?;
41 + let font_offset = font.offset;
42 + Ok(Self {
43 + font_data,
44 + font_offset,
45 + scale_ctx: ScaleContext::new(),
46 + shape_ctx: ShapeContext::new(),
47 + px,
48 + })
49 + }
50 +
51 + pub(crate) fn shape(&mut self, text: &str) -> Vec<ShapedGlyph> {
52 + let font = FontRef {
53 + data: &self.font_data,
54 + offset: self.font_offset,
55 + key: swash::CacheKey::new(),
56 + };
57 + let mut shaper = self.shape_ctx.builder(font).size(self.px).build();
58 + shaper.add_str(text);
59 + let mut out = Vec::new();
60 + shaper.shape_with(|cluster| {
61 + for g in cluster.glyphs {
62 + out.push(ShapedGlyph {
63 + id: g.id,
64 + advance: g.advance,
65 + x_offset: g.x,
66 + y_offset: g.y,
67 + });
68 + }
69 + });
70 + out
71 + }
72 +
73 + pub(crate) fn rasterize(&mut self, id: GlyphId) -> Option<Raster> {
74 + let font = FontRef {
75 + data: &self.font_data,
76 + offset: self.font_offset,
77 + key: swash::CacheKey::new(),
78 + };
79 + let mut scaler = self.scale_ctx.builder(font).size(self.px).hint(true).build();
80 + let image = Render::new(&[Source::Outline])
81 + .format(Format::Alpha)
82 + .render(&mut scaler, id)?;
83 + if image.content != Content::Mask {
84 + return None;
85 + }
86 + Some(Raster {
87 + bitmap: image.data,
88 + width: image.placement.width,
89 + height: image.placement.height,
90 + placement_left: image.placement.left,
91 + placement_top: image.placement.top,
92 + })
93 + }
94 +
95 + pub(crate) fn ascent(&self) -> f32 {
96 + let font = FontRef {
97 + data: &self.font_data,
98 + offset: self.font_offset,
99 + key: swash::CacheKey::new(),
100 + };
101 + let metrics = font.metrics(&[]).scale(self.px);
102 + metrics.ascent
103 + }
104 + }