GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
refactor(draw): the glyph atlas, the window-info block and the 2D shaders are shared
The web renderer (W2) will draw the same frames as the Vulkan one, so
what both need moves into draw/ instead of being copied:
- draw::glyphs: the CPU half of the Vulkan text stage, moved verbatim:
the shelf packer, the swash rasterization into an RGBA8 atlas, and the
glyph quads (GlyphAtlas::prepare, pixels, generation, vertices), plus
image_quad_vertices for the image pass, which feeds the same shader
with the same 52-byte vertex. TextStage keeps the upload and the draw
around a GlyphAtlas; ImageStage builds its quads through the shared
function.
- draw::window_info_data / relief_px_at / WINDOW_INFO_BYTES /
PLATE_FEATURE_BYTES: shader2d's WindowInfo block and the feature
stride, by width and height rather than a vk::Extent2D (vk keeps a
thin wrapper for its own callers and the plate probe).
- draw::shaders: shader2d.wgsl and glyph.wgsl move from vk/ to draw/,
one source for both renderers. shader2d_for_webgpu() swaps the one
push-constant line for a @group(1) uniform, since WebGPU has none;
WEBGPU_BLOCK_STRIDE is the 256-byte offset step every device allows.
- shader2d.wgsl samples the backdrop with textureSampleLevel(.., 0.0).
WebGPU rejects implicit-LOD sampling in non-uniform control flow (the
blur branch is), and the backdrop has one level, so the texel is the
one an implicit LOD picks.
A new test validates the WebGPU variants with naga and no capabilities
at all, checks the uniform block's size against PUSH_CONSTANT_FLOATS and
the stride, and catches a substitution that stopped applying.
Verified natively: test results unchanged (524 pass, the same 2
pre-existing failures), the plate golden byte-identical, the demo
identical to the pixel over the 24 scripted steps (text and image
views through the moved atlas and quad code), and examples/frost_pair
-- three frosted plates, the one path the level-0 sampling touches --
identical to the pixel between the previous commit and this one under
headless sway.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjL3pejMNY95NHv9BcmXaZ
CLAUDE.md | 11 +-
src/{vk => draw}/glyph.wgsl | 0
src/draw/glyphs.rs | 356 +++++++++++++++++++++++++++++++++++++++++
src/draw/mod.rs | 50 ++++++
src/{vk => draw}/shader2d.wgsl | 11 +-
src/draw/shaders.rs | 29 ++++
src/scene/material.rs | 2 +-
src/scene/relief_shade.rs | 2 +-
src/vk/image.rs | 48 +-----
src/vk/renderer.rs | 81 +++++-----
src/vk/text.rs | 291 ++-------------------------------
11 files changed, 509 insertions(+), 372 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 35fbd44..76e6aa6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1130,7 +1130,16 @@ cce-system-interface) to confirm behavior, not just the test suite.
(`upload_rgba`, `update_pixels`, `free_image`, `renderer_epoch`, …) that a renderer
drains with `take_pending`. They lived in `vk/` while Vulkan was the only renderer;
`vk` re-exports every one at its old path, so `cce_ui::vk::upload_rgba` and the rest
- are unchanged for clients.
+ are unchanged for clients. Also here, shared by every renderer: `draw::glyphs`
+ (`GlyphAtlas` — rasterizing, packing and the glyph quads; a renderer uploads
+ `pixels()` when `generation()` moves — and `image_quad_vertices`), `window_info_data`
+ (shader2d's `WindowInfo` block), and `draw::shaders`: `shader2d.wgsl` and `glyph.wgsl`
+ live in `src/draw/` now, one source for both renderers. WebGPU has no push constants,
+ so `shader2d_for_webgpu()` swaps the one push-block line for a `@group(1)` uniform read
+ at a per-batch dynamic offset (`WEBGPU_BLOCK_STRIDE`); the backdrop is sampled with
+ `textureSampleLevel(…, 0.0)` because WebGPU rejects implicit-LOD sampling in the
+ non-uniform blur branch (the backdrop has one level, so the texel is the same —
+ `frost_pair` is identical to the pixel either way).
- `protocol.rs` — inline-generated Wayland protocol bindings.
- `ipc.rs` — the `/tmp/<prefix>-<WAYLAND_DISPLAY>.sock` helpers (`socket_path`, `send_command`,
the bounded `read_request_line`, `focus_window`), and `ipc::instance`: single-instance
diff --git a/src/vk/glyph.wgsl b/src/draw/glyph.wgsl
similarity index 100%
rename from src/vk/glyph.wgsl
rename to src/draw/glyph.wgsl
diff --git a/src/draw/glyphs.rs b/src/draw/glyphs.rs
new file mode 100644
index 0000000..595332e
--- /dev/null
+++ b/src/draw/glyphs.rs
@@ -0,0 +1,356 @@
+//! The glyph atlas, CPU side: shaped runs ([`TextSpan`]s) rasterized through
+//! swash into an RGBA8 atlas (a shelf packer, a cache keyed by cosmic-text's
+//! glyph key) and turned into glyph quads. A renderer uploads [`pixels`]
+//! whenever [`generation`] moves and draws [`vertices`] with `glyph.wgsl`.
+//! Image quads use the same vertex and shader ([`image_quad_vertices`]).
+//!
+//! It was half of the Vulkan text stage; the other half — the upload and
+//! the draw — is what each renderer keeps.
+//!
+//! [`pixels`]: GlyphAtlas::pixels
+//! [`generation`]: GlyphAtlas::generation
+//! [`vertices`]: GlyphAtlas::vertices
+
+use std::collections::HashMap;
+
+use cosmic_text::{CacheKey, FontSystem, SwashCache, SwashContent};
+
+use super::{ImageQuad, TextSpan};
+
+/// The atlas is one ATLAS_SIZE² RGBA8 texture (unorm, sampled nearest).
+pub const ATLAS_SIZE: u32 = 1024;
+const ATLAS_PAD: u32 = 1;
+
+#[repr(C)]
+#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
+pub struct GlyphVertex {
+ pub position: [f32; 2],
+ pub uv: [f32; 2],
+ pub color: [f32; 4],
+ pub clip_circle: [f32; 3],
+ pub clip_extents: [f32; 2],
+}
+
+// The glyph shader's vertex (locations 0..=4) for text AND image quads: both
+// pipelines feed `glyph.wgsl`, and every renderer describes this exact layout.
+const _: () = {
+ assert!(std::mem::size_of::<GlyphVertex>() == 52);
+ assert!(std::mem::offset_of!(GlyphVertex, position) == 0);
+ assert!(std::mem::offset_of!(GlyphVertex, uv) == 8);
+ assert!(std::mem::offset_of!(GlyphVertex, color) == 16);
+ assert!(std::mem::offset_of!(GlyphVertex, clip_circle) == 32);
+ assert!(std::mem::offset_of!(GlyphVertex, clip_extents) == 44);
+};
+
+#[derive(Clone, Copy)]
+struct GlyphEntry {
+ /// Atlas texel rect.
+ u: u32,
+ v: u32,
+ w: u32,
+ h: u32,
+ /// Raster placement offsets (from swash).
+ left: i32,
+ top: i32,
+ is_color: bool,
+ /// Zero-sized raster (spaces): nothing to draw, but cached to skip re-rastering.
+ empty: bool,
+}
+
+struct Shelf {
+ cursor_x: u32,
+ cursor_y: u32,
+ row_height: u32,
+}
+
+impl Shelf {
+ fn new() -> Self {
+ Shelf { cursor_x: ATLAS_PAD, cursor_y: ATLAS_PAD, row_height: 0 }
+ }
+
+ fn insert(&mut self, w: u32, h: u32) -> Option<(u32, u32)> {
+ if w > ATLAS_SIZE - 2 * ATLAS_PAD || h > ATLAS_SIZE - 2 * ATLAS_PAD {
+ return None;
+ }
+ if self.cursor_x + w + ATLAS_PAD > ATLAS_SIZE {
+ self.cursor_x = ATLAS_PAD;
+ self.cursor_y += self.row_height + ATLAS_PAD;
+ self.row_height = 0;
+ }
+ if self.cursor_y + h + ATLAS_PAD > ATLAS_SIZE {
+ return None;
+ }
+ let pos = (self.cursor_x, self.cursor_y);
+ self.cursor_x += w + ATLAS_PAD;
+ self.row_height = self.row_height.max(h);
+ Some(pos)
+ }
+}
+
+/// The atlas and this frame's glyph quads.
+pub struct GlyphAtlas {
+ /// RGBA8, ATLAS_SIZE², the texture's contents.
+ pixels: Vec<u8>,
+ /// Bumped whenever `pixels` changes; a renderer re-uploads on a change.
+ generation: u64,
+ glyphs: HashMap<CacheKey, GlyphEntry>,
+ shelf: Shelf,
+ /// The staged text: replaced only by the next [`prepare`](Self::prepare),
+ /// so a frame drawn without a re-prepare keeps its text.
+ vertices: Vec<GlyphVertex>,
+}
+
+impl Default for GlyphAtlas {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl GlyphAtlas {
+ pub fn new() -> Self {
+ Self {
+ pixels: vec![0u8; (ATLAS_SIZE * ATLAS_SIZE * 4) as usize],
+ generation: 1,
+ glyphs: HashMap::new(),
+ shelf: Shelf::new(),
+ vertices: Vec::new(),
+ }
+ }
+
+ /// The atlas texture's contents (RGBA8, `ATLAS_SIZE`²).
+ pub fn pixels(&self) -> &[u8] {
+ &self.pixels
+ }
+
+ /// Moves whenever [`pixels`](Self::pixels) changed.
+ pub fn generation(&self) -> u64 {
+ self.generation
+ }
+
+ /// The glyph quads the last [`prepare`](Self::prepare) built, six vertices each.
+ pub fn vertices(&self) -> &[GlyphVertex] {
+ &self.vertices
+ }
+
+ /// Rasterize (on miss) and cache one glyph. Returns None when the atlas is full.
+ fn ensure_glyph(
+ &mut self,
+ font_system: &mut FontSystem,
+ swash_cache: &mut SwashCache,
+ key: CacheKey,
+ ) -> Option<GlyphEntry> {
+ if let Some(entry) = self.glyphs.get(&key) {
+ return Some(*entry);
+ }
+ let image = swash_cache.get_image_uncached(font_system, key)?;
+ let w = image.placement.width;
+ let h = image.placement.height;
+ if w == 0 || h == 0 || image.data.is_empty() {
+ let entry = GlyphEntry {
+ u: 0, v: 0, w: 0, h: 0, left: 0, top: 0, is_color: false, empty: true,
+ };
+ self.glyphs.insert(key, entry);
+ return Some(entry);
+ }
+ let (u, v) = self.shelf.insert(w, h)?;
+
+ let is_color = !matches!(image.content, SwashContent::Mask);
+ for row in 0..h {
+ for col in 0..w {
+ let dst = (((v + row) * ATLAS_SIZE + (u + col)) * 4) as usize;
+ let texel = match image.content {
+ SwashContent::Mask => {
+ let a = image.data[(row * w + col) as usize];
+ [255, 255, 255, a]
+ }
+ // Color and SubpixelMask rasters are RGBA.
+ _ => {
+ let src = ((row * w + col) * 4) as usize;
+ [
+ image.data[src],
+ image.data[src + 1],
+ image.data[src + 2],
+ image.data[src + 3],
+ ]
+ }
+ };
+ self.pixels[dst..dst + 4].copy_from_slice(&texel);
+ }
+ }
+ self.generation += 1;
+
+ let entry = GlyphEntry {
+ u,
+ v,
+ w,
+ h,
+ left: image.placement.left,
+ top: image.placement.top,
+ is_color,
+ empty: false,
+ };
+ self.glyphs.insert(key, entry);
+ Some(entry)
+ }
+
+ /// Build this frame's glyph vertices. Positions/bounds in physical pixels,
+ /// NDC computed against the target's
+ /// `width` x `height` (Y up; the Vulkan renderer flips with its viewport).
+ pub fn prepare(
+ &mut self,
+ font_system: &mut FontSystem,
+ swash_cache: &mut SwashCache,
+ spans: &[TextSpan<'_>],
+ width: u32,
+ height: u32,
+ ) {
+ self.vertices.clear();
+ if !self.try_prepare(font_system, swash_cache, spans, width, height) {
+ // Atlas full: clear and repack with only the glyphs this frame needs.
+ log::info!("glyph atlas full — clearing and repacking");
+ self.glyphs.clear();
+ self.shelf = Shelf::new();
+ self.pixels.fill(0);
+ self.generation += 1;
+ self.vertices.clear();
+ if !self.try_prepare(font_system, swash_cache, spans, width, height) {
+ log::error!("glyph atlas full even after repack; text truncated this frame");
+ }
+ }
+ }
+
+ fn try_prepare(
+ &mut self,
+ font_system: &mut FontSystem,
+ swash_cache: &mut SwashCache,
+ spans: &[TextSpan<'_>],
+ width: u32,
+ height: u32,
+ ) -> bool {
+ let sw = width as f32;
+ let sh = height as f32;
+ for span in spans {
+ for run in span.buffer.layout_runs() {
+ let line_y = (run.line_y * span.scale).round() as i32;
+ for glyph in run.glyphs.iter() {
+ let physical = glyph.physical((span.left, span.top), span.scale);
+ let Some(entry) =
+ self.ensure_glyph(font_system, swash_cache, physical.cache_key)
+ else {
+ // Distinguish "atlas full" (retryable) from "unrasterizable"
+ // (skip): a missing swash image caches as empty above, so a
+ // None here means the shelf rejected it.
+ if swash_cache
+ .get_image_uncached(font_system, physical.cache_key)
+ .is_some()
+ {
+ return false;
+ }
+ continue;
+ };
+ if entry.empty {
+ continue;
+ }
+
+ // glyphon's placement formula (kept verbatim), physical pixels.
+ let mut x0 = (physical.x + entry.left) as f32;
+ let mut y0 = (line_y + physical.y - entry.top) as f32;
+ let mut x1 = x0 + entry.w as f32;
+ let mut y1 = y0 + entry.h as f32;
+ let mut u0 = entry.u as f32;
+ let mut v0 = entry.v as f32;
+ let mut u1 = u0 + entry.w as f32;
+ let mut v1 = v0 + entry.h as f32;
+
+ // CPU clip to span bounds, shrinking UVs proportionally.
+ if let Some([bl, bt, br, bb]) = span.bounds {
+ let (bl, bt, br, bb) = (bl as f32, bt as f32, br as f32, bb as f32);
+ if x0 >= br || x1 <= bl || y0 >= bb || y1 <= bt {
+ continue;
+ }
+ if x0 < bl {
+ u0 += bl - x0;
+ x0 = bl;
+ }
+ if x1 > br {
+ u1 -= x1 - br;
+ x1 = br;
+ }
+ if y0 < bt {
+ v0 += bt - y0;
+ y0 = bt;
+ }
+ if y1 > bb {
+ v1 -= y1 - bb;
+ y1 = bb;
+ }
+ }
+
+ let color = if entry.is_color {
+ [1.0, 1.0, 1.0, 1.0]
+ } else if let Some(c) = glyph.color_opt {
+ [
+ c.r() as f32 / 255.0,
+ c.g() as f32 / 255.0,
+ c.b() as f32 / 255.0,
+ c.a() as f32 / 255.0,
+ ]
+ } else {
+ span.default_color
+ };
+
+ // Corner positions, optionally rotated about the span's center
+ // (physical px) before the NDC mapping.
+ let corners = match span.rotation {
+ None => [[x0, y0], [x1, y0], [x0, y1], [x1, y1]],
+ Some((angle, cx, cy)) => {
+ let (sin_a, cos_a) = angle.sin_cos();
+ let rot = |px: f32, py: f32| {
+ let (dx, dy) = (px - cx, py - cy);
+ [cx + dx * cos_a - dy * sin_a, cy + dx * sin_a + dy * cos_a]
+ };
+ [rot(x0, y0), rot(x1, y0), rot(x0, y1), rot(x1, y1)]
+ }
+ };
+ let ndc = |p: [f32; 2]| {
+ [(p[0] / sw) * 2.0 - 1.0, 1.0 - (p[1] / sh) * 2.0]
+ };
+ let uv = |u: f32, v: f32| [u / ATLAS_SIZE as f32, v / ATLAS_SIZE as f32];
+ let clip_circle = span.clip_circle;
+ let clip_extents = span.clip_extents;
+ let tl = GlyphVertex { position: ndc(corners[0]), uv: uv(u0, v0), color, clip_circle, clip_extents };
+ let tr = GlyphVertex { position: ndc(corners[1]), uv: uv(u1, v0), color, clip_circle, clip_extents };
+ let bl = GlyphVertex { position: ndc(corners[2]), uv: uv(u0, v1), color, clip_circle, clip_extents };
+ let br = GlyphVertex { position: ndc(corners[3]), uv: uv(u1, v1), color, clip_circle, clip_extents };
+ self.vertices.extend([tl, tr, bl, tr, br, bl]);
+ }
+ }
+ }
+ true
+ }
+}
+
+/// Image quads as the glyph shader's vertices, six per image in `images`
+/// order, NDC against a `width` x `height` target. A renderer draws image `i`
+/// as vertices `6i..6i+6` with that image's texture bound.
+pub fn image_quad_vertices(images: &[ImageQuad], width: u32, height: u32) -> Vec<GlyphVertex> {
+ let sw = width as f32;
+ let sh = height as f32;
+ let mut verts: Vec<GlyphVertex> = Vec::with_capacity(images.len() * 6);
+ for q in images {
+ let (x, y, w, h) = q.rect;
+ let ndc = |px: f32, py: f32| [(px / sw) * 2.0 - 1.0, 1.0 - (py / sh) * 2.0];
+ let color = [1.0, 1.0, 1.0, q.alpha];
+ let clip_circle = [0.0; 3];
+ // Zero extents = the shader's plain-circle clip degenerate case. Inert
+ // while clip_circle.z is 0 (the clip branch never runs), but it must be
+ // a defined value, not whatever a missing attribute would read.
+ let clip_extents = [0.0; 2];
+ let tl = GlyphVertex { position: ndc(x, y), uv: [0.0, 0.0], color, clip_circle, clip_extents };
+ let tr = GlyphVertex { position: ndc(x + w, y), uv: [1.0, 0.0], color, clip_circle, clip_extents };
+ let bl = GlyphVertex { position: ndc(x, y + h), uv: [0.0, 1.0], color, clip_circle, clip_extents };
+ let br = GlyphVertex { position: ndc(x + w, y + h), uv: [1.0, 1.0], color, clip_circle, clip_extents };
+ verts.extend([tl, tr, bl, tr, br, bl]);
+ }
+ verts
+}
diff --git a/src/draw/mod.rs b/src/draw/mod.rs
index 8bf7990..b788995 100644
--- a/src/draw/mod.rs
+++ b/src/draw/mod.rs
@@ -12,7 +12,9 @@
use crate::backend::tessellate::Vertex;
use cosmic_text::Buffer as TextBuffer;
+pub mod glyphs;
pub mod images;
+pub mod shaders;
pub use images::{
free_image, recycle_buffer, renderer_epoch, update_pixels, upload_pixels, upload_rgba,
@@ -165,3 +167,51 @@ pub struct TextSpan<'a> {
/// `clip_extents` — so plate children (labels included) cut off at rounded corners.
pub clip_extents: [f32; 2],
}
+
+/// The bytes of one plate feature (a carve CSG'd into a plate): three vec4s,
+/// `[f32; 12]` in `Frame2D::plate_features`.
+pub const PLATE_FEATURE_BYTES: usize = 48;
+
+/// shader2d's WindowInfo UBO: [size/clip vec4][bevel-profile meta vec4]
+/// [8 vec4 of profile slope samples].
+// [size/clip vec4][carve profile meta + 8 vec4][roll profile meta + 8 vec4].
+// [size/clip vec4][carve profile meta][8 carve slopes][roll profile meta]
+// [8 roll slopes][relief heights][backdrop meta] = 21 vec4. Grows only at the
+// END — every offset above is addressed by index from both sides.
+pub const WINDOW_INFO_BYTES: usize = 320;
+
+/// The pinned relief heights (carve, roll) in physical px at `scale`, 0 =
+/// follow the width.
+pub fn relief_px_at(scale: f32) -> (f32, f32) {
+ let s = scale.max(0.001);
+ (
+ crate::layout::bevel_height().map_or(0.0, |h| h * s),
+ crate::layout::roll_height().map_or(0.0, |h| h * s),
+ )
+}
+
+/// shader2d's `WindowInfo` block for a `width` x `height` target whose corners clip
+/// at `clip_corner_radius` (physical px), with the pinned relief heights
+/// `relief` (carve, roll; physical px, 0 = unpinned) — the profiles and the
+/// corner shape from the live style. Shared by every renderer and the offscreen
+/// test harness.
+pub fn window_info_data(width: u32, height: u32, clip_corner_radius: f32, relief: (f32, f32)) -> [f32; WINDOW_INFO_BYTES / 4] {
+ let mut data = [0.0f32; WINDOW_INFO_BYTES / 4];
+ data[0] = width as f32;
+ data[1] = height as f32;
+ data[2] = clip_corner_radius;
+ data[3] = crate::layout::corner_shape();
+ if let Some(slopes) = crate::layout::bevel_profile_slopes() {
+ data[4] = 1.0;
+ data[5] = crate::layout::BEVEL_PROFILE_SAMPLES as f32;
+ data[8..8 + slopes.len()].copy_from_slice(&slopes);
+ }
+ if let Some(slopes) = crate::layout::roll_profile_slopes() {
+ data[40] = 1.0;
+ data[41] = crate::layout::BEVEL_PROFILE_SAMPLES as f32;
+ data[44..44 + slopes.len()].copy_from_slice(&slopes);
+ }
+ data[76] = relief.0;
+ data[77] = relief.1;
+ data
+}
diff --git a/src/vk/shader2d.wgsl b/src/draw/shader2d.wgsl
similarity index 99%
rename from src/vk/shader2d.wgsl
rename to src/draw/shader2d.wgsl
index 77a9423..925de88 100644
--- a/src/vk/shader2d.wgsl
+++ b/src/draw/shader2d.wgsl
@@ -1202,12 +1202,17 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4f {
// spacing in physical px (sigma = 2 taps); both come from the plate's own
// push block (MODE_PLATE), or are the no-recipe defaults (droplet, raw
// vertices). A stride of 0 is a CLEAR plate: one clean sample, tinted.
+//
+// The backdrop is sampled at level 0 explicitly: it has one level, so that is
+// the texel an implicit LOD would pick, and this runs under non-uniform control
+// flow (the blur branch), where WebGPU forbids the derivatives an implicit LOD
+// takes.
fn resolve_blur(pos: vec2f, color: vec4f, refract: vec2f, clarity: f32, k_in: f32, stride: f32) -> vec4f {
let tex_size = vec2f(textureDimensions(t_backdrop));
var backdrop_color = vec4f(0.0);
if (stride <= 0.0) {
- backdrop_color = textureSample(t_backdrop, s_backdrop, (pos + refract) / tex_size);
+ backdrop_color = textureSampleLevel(t_backdrop, s_backdrop, (pos + refract) / tex_size, 0.0);
} else {
var blurred = vec4f(0.0);
var total_weight = 0.0;
@@ -1221,7 +1226,7 @@ fn resolve_blur(pos: vec2f, color: vec4f, refract: vec2f, clarity: f32, k_in: f3
let offset = vec2f(x, y) * stride;
let sample_uv = (pos + offset) / tex_size;
let weight = exp(-(x*x + y*y) / (2.0 * 2.0 * 2.0));
- blurred += textureSample(t_backdrop, s_backdrop, sample_uv) * weight;
+ blurred += textureSampleLevel(t_backdrop, s_backdrop, sample_uv, 0.0) * weight;
total_weight += weight;
}
}
@@ -1242,7 +1247,7 @@ fn resolve_blur(pos: vec2f, color: vec4f, refract: vec2f, clarity: f32, k_in: f3
// narrow is invisible once the body blur is 49 taps, and paying for it
// would triple the most expensive path in this shader to be erased.
if (clarity > 0.001) {
- let clean = textureSample(t_backdrop, s_backdrop, (pos + refract) / tex_size);
+ let clean = textureSampleLevel(t_backdrop, s_backdrop, (pos + refract) / tex_size, 0.0);
backdrop_color = mix(backdrop_color, clean, clamp(clarity, 0.0, 1.0));
}
diff --git a/src/draw/shaders.rs b/src/draw/shaders.rs
new file mode 100644
index 0000000..3fc8d33
--- /dev/null
+++ b/src/draw/shaders.rs
@@ -0,0 +1,29 @@
+//! The 2D and glyph shaders, shared by every renderer: one WGSL source each.
+//!
+//! The Vulkan renderer compiles them to SPIR-V through naga. WebGPU takes WGSL
+//! as it is, with one difference: it has no push constants, so the per-batch
+//! parameter block (`RRectClip`, [`super::PUSH_CONSTANT_FLOATS`] floats laid
+//! out by [`super::batch_push_constants`]) is a uniform there, bound at
+//! `@group(1) @binding(0)` with a dynamic offset per batch
+//! ([`shader2d_for_webgpu`]).
+
+/// The 2D pipeline's shader: geometry, the SDF-lit plates, blur-behind.
+pub const SHADER2D: &str = include_str!("shader2d.wgsl");
+
+/// Text and image quads.
+pub const GLYPH: &str = include_str!("glyph.wgsl");
+
+/// The one line that differs between the Vulkan and the WebGPU 2D shader.
+const PUSH_BLOCK: &str = "var<push_constant> rrect_clip: RRectClip;";
+const UNIFORM_BLOCK: &str = "@group(1) @binding(0) var<uniform> rrect_clip: RRectClip;";
+
+/// Where a batch's block sits in WebGPU's uniform buffer: each batch's
+/// [`super::PUSH_CONSTANT_FLOATS`] floats at a multiple of this, the
+/// `minUniformBufferOffsetAlignment` every WebGPU device supports.
+pub const WEBGPU_BLOCK_STRIDE: usize = 256;
+
+/// [`SHADER2D`] for WebGPU: the push-constant block declared as a uniform.
+pub fn shader2d_for_webgpu() -> String {
+ assert_eq!(SHADER2D.matches(PUSH_BLOCK).count(), 1, "shader2d.wgsl must declare its push block exactly once");
+ SHADER2D.replace(PUSH_BLOCK, UNIFORM_BLOCK)
+}
diff --git a/src/scene/material.rs b/src/scene/material.rs
index 9398af9..a24718c 100644
--- a/src/scene/material.rs
+++ b/src/scene/material.rs
@@ -620,7 +620,7 @@ mod tests {
assert_eq!(Frost::Unfrosted.pack(2.0), [0.0, 0.0]);
assert_eq!(Frost::Frosted { compression: 0.0, refraction: 0.0, radius: 0.0 }.pack(2.0), [0.0, 0.0]);
- let wgsl = include_str!("../vk/shader2d.wgsl");
+ let wgsl = include_str!("../draw/shader2d.wgsl");
let lit = |name: &str| -> f32 {
let rest = wgsl.split(&format!("const {name}: f32 = ")).nth(1).unwrap_or_else(|| panic!("{name} missing"));
rest.split(';').next().unwrap().trim().parse().unwrap()
diff --git a/src/scene/relief_shade.rs b/src/scene/relief_shade.rs
index f0e25dd..4d53b10 100644
--- a/src/scene/relief_shade.rs
+++ b/src/scene/relief_shade.rs
@@ -253,7 +253,7 @@ mod tests {
/// The shader's own source, so the constants below are checked against the
/// thing they mirror rather than against a comment.
- const WGSL: &str = include_str!("../vk/shader2d.wgsl");
+ const WGSL: &str = include_str!("../draw/shader2d.wgsl");
fn wgsl_const(name: &str) -> f32 {
let needle = format!("const {name}: f32 = ");
diff --git a/src/vk/image.rs b/src/vk/image.rs
index bc34f22..10178be 100644
--- a/src/vk/image.rs
+++ b/src/vk/image.rs
@@ -60,33 +60,8 @@ pub(crate) fn mip_level_count(width: u32, height: u32) -> u32 {
32 - width.max(height).max(1).leading_zeros()
}
-#[repr(C)]
-#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
-/// Must match `GlyphVertex` field-for-field: both pipelines are fed by the same
-/// glyph shader, whose vertex entry point declares locations 0..=4. Omitting
-/// `clip_extents` here left location 4 with no `VkVertexInputAttributeDescription`,
-/// which the validation layer flags (VUID-VkGraphicsPipelineCreateInfo-Input-07904)
-/// and which reads undefined data without `vertexAttributeRobustness`.
-struct ImageVertex {
- position: [f32; 2],
- uv: [f32; 2],
- color: [f32; 4],
- clip_circle: [f32; 3],
- clip_extents: [f32; 2],
-}
-
-// The pipeline below hardcodes one offset per shader location. Pin the struct to
-// them so adding, reordering, or resizing a field fails the build instead of
-// silently feeding the shader mis-aligned attributes — the drift that left
-// location 4 undescribed. `text.rs` pins `GlyphVertex` to the same layout.
-const _: () = {
- assert!(std::mem::size_of::<ImageVertex>() == 52);
- assert!(std::mem::offset_of!(ImageVertex, position) == 0);
- assert!(std::mem::offset_of!(ImageVertex, uv) == 8);
- assert!(std::mem::offset_of!(ImageVertex, color) == 16);
- assert!(std::mem::offset_of!(ImageVertex, clip_circle) == 32);
- assert!(std::mem::offset_of!(ImageVertex, clip_extents) == 44);
-};
+/// An image quad's vertex: the glyph shader's, shared with the text stage.
+type ImageVertex = crate::draw::glyphs::GlyphVertex;
struct GpuImage {
image: vk::Image,
@@ -769,24 +744,7 @@ impl ImageStage {
images: &[ImageQuad],
extent: vk::Extent2D,
) {
- let sw = extent.width as f32;
- let sh = extent.height as f32;
- let mut verts: Vec<ImageVertex> = Vec::with_capacity(images.len() * 6);
- for q in images {
- let (x, y, w, h) = q.rect;
- let ndc = |px: f32, py: f32| [(px / sw) * 2.0 - 1.0, 1.0 - (py / sh) * 2.0];
- let color = [1.0, 1.0, 1.0, q.alpha];
- let clip_circle = [0.0; 3];
- // Zero extents = the shader's plain-circle clip degenerate case. Inert
- // while clip_circle.z is 0 (the clip branch never runs), but it must be
- // a defined value, not whatever the missing attribute used to read.
- let clip_extents = [0.0; 2];
- let tl = ImageVertex { position: ndc(x, y), uv: [0.0, 0.0], color, clip_circle, clip_extents };
- let tr = ImageVertex { position: ndc(x + w, y), uv: [1.0, 0.0], color, clip_circle, clip_extents };
- let bl = ImageVertex { position: ndc(x, y + h), uv: [0.0, 1.0], color, clip_circle, clip_extents };
- let br = ImageVertex { position: ndc(x + w, y + h), uv: [1.0, 1.0], color, clip_circle, clip_extents };
- verts.extend([tl, tr, bl, tr, br, bl]);
- }
+ let verts = crate::draw::glyphs::image_quad_vertices(images, extent.width, extent.height);
let bytes: &[u8] = bytemuck::cast_slice(&verts);
let buf = &mut self.frame_buffers[frame_index];
if bytes.len() as vk::DeviceSize > buf.size {
diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
index cb96ba8..9a1c917 100644
--- a/src/vk/renderer.rs
+++ b/src/vk/renderer.rs
@@ -96,48 +96,14 @@ fn rect_intersect(a: vk::Rect2D, b: vk::Rect2D) -> vk::Rect2D {
}
pub(crate) const FRAMES_IN_FLIGHT: usize = 2;
-pub(crate) const PLATE_FEATURE_BYTES: usize = 48;
-/// shader2d's WindowInfo UBO: [size/clip vec4][bevel-profile meta vec4]
-/// [8 vec4 of profile slope samples].
-// [size/clip vec4][carve profile meta + 8 vec4][roll profile meta + 8 vec4].
-// [size/clip vec4][carve profile meta][8 carve slopes][roll profile meta]
-// [8 roll slopes][relief heights][backdrop meta] = 21 vec4. Grows only at the
-// END — every offset above is addressed by index from both sides.
-pub(crate) const WINDOW_INFO_BYTES: vk::DeviceSize = 320;
-
-/// The pinned relief heights (carve, roll) in physical px at `scale`, 0 =
-/// follow the width.
-pub(crate) fn relief_px_at(scale: f32) -> (f32, f32) {
- let s = scale.max(0.001);
- (
- crate::layout::bevel_height().map_or(0.0, |h| h * s),
- crate::layout::roll_height().map_or(0.0, |h| h * s),
- )
-}
-
-/// shader2d's `WindowInfo` block for a target of `extent` whose corners clip
-/// at `clip_corner_radius` (physical px), with the pinned relief heights
-/// `relief` (carve, roll; physical px, 0 = unpinned) — the profiles and the
-/// corner shape from the live style. Shared with the offscreen test harness.
-pub(crate) fn window_info_data(extent: vk::Extent2D, clip_corner_radius: f32, relief: (f32, f32)) -> [f32; WINDOW_INFO_BYTES as usize / 4] {
- let mut data = [0.0f32; WINDOW_INFO_BYTES as usize / 4];
- data[0] = extent.width as f32;
- data[1] = extent.height as f32;
- data[2] = clip_corner_radius;
- data[3] = crate::layout::corner_shape();
- if let Some(slopes) = crate::layout::bevel_profile_slopes() {
- data[4] = 1.0;
- data[5] = crate::layout::BEVEL_PROFILE_SAMPLES as f32;
- data[8..8 + slopes.len()].copy_from_slice(&slopes);
- }
- if let Some(slopes) = crate::layout::roll_profile_slopes() {
- data[40] = 1.0;
- data[41] = crate::layout::BEVEL_PROFILE_SAMPLES as f32;
- data[44..44 + slopes.len()].copy_from_slice(&slopes);
- }
- data[76] = relief.0;
- data[77] = relief.1;
- data
+pub(crate) use crate::draw::PLATE_FEATURE_BYTES;
+/// shader2d's WindowInfo uniform, in bytes (layout in `draw::window_info_data`).
+pub(crate) const WINDOW_INFO_BYTES: vk::DeviceSize = crate::draw::WINDOW_INFO_BYTES as vk::DeviceSize;
+pub(crate) use crate::draw::relief_px_at;
+
+/// [`crate::draw::window_info_data`] for a Vulkan extent.
+pub(crate) fn window_info_data(extent: vk::Extent2D, clip_corner_radius: f32, relief: (f32, f32)) -> [f32; crate::draw::WINDOW_INFO_BYTES / 4] {
+ crate::draw::window_info_data(extent.width, extent.height, clip_corner_radius, relief)
}
pub(crate) struct AllocatedBuffer {
@@ -474,12 +440,12 @@ pub(crate) fn compile_wgsl(source: &str) -> Vec<u32> {
/// so compile each shader once per process.
pub(crate) fn shader2d_spirv() -> &'static [u32] {
static SPIRV: std::sync::OnceLock<Vec<u32>> = std::sync::OnceLock::new();
- SPIRV.get_or_init(|| compile_wgsl(include_str!("shader2d.wgsl")))
+ SPIRV.get_or_init(|| compile_wgsl(crate::draw::shaders::SHADER2D))
}
pub(crate) fn glyph_spirv() -> &'static [u32] {
static SPIRV: std::sync::OnceLock<Vec<u32>> = std::sync::OnceLock::new();
- SPIRV.get_or_init(|| compile_wgsl(include_str!("glyph.wgsl")))
+ SPIRV.get_or_init(|| compile_wgsl(crate::draw::shaders::GLYPH))
}
pub(crate) fn scene3d_spirv() -> &'static [u32] {
@@ -2436,6 +2402,31 @@ mod tests {
assert!(!super::shader2d_spirv().is_empty());
}
+ /// The WebGPU variants validate with no capabilities at all — WebGPU has
+ /// no push constants — and the 2D one carries its block as the uniform
+ /// the web renderer binds. naga does not see everything a browser's
+ /// compiler rejects (its derivative-uniformity analysis does not follow
+ /// calls), so the browser probe is the last word; this catches a
+ /// substitution that silently stopped applying.
+ #[test]
+ fn the_webgpu_shaders_validate_without_push_constants() {
+ let web2d = crate::draw::shaders::shader2d_for_webgpu();
+ assert!(!web2d.contains("var<push_constant>"));
+ assert!(web2d.contains("@group(1) @binding(0) var<uniform> rrect_clip: RRectClip;"));
+ for (name, src) in [("shader2d (web)", web2d.as_str()), ("glyph", crate::draw::shaders::GLYPH)] {
+ let module = naga::front::wgsl::parse_str(src).unwrap_or_else(|e| panic!("{name}: {}", e.emit_to_string(src)));
+ naga::valid::Validator::new(naga::valid::ValidationFlags::all(), naga::valid::Capabilities::empty())
+ .validate(&module)
+ .unwrap_or_else(|e| panic!("{name} does not validate for WebGPU: {e:?}"));
+ }
+ // And the block's size is what the renderers lay out.
+ let module = naga::front::wgsl::parse_str(&web2d).unwrap();
+ let block = module.types.iter().find(|(_, t)| t.name.as_deref() == Some("RRectClip")).expect("RRectClip").1;
+ let naga::TypeInner::Struct { span, .. } = block.inner else { panic!("RRectClip is a struct") };
+ assert_eq!(span as usize, crate::draw::PUSH_CONSTANT_FLOATS * 4);
+ assert!(span as usize <= crate::draw::shaders::WEBGPU_BLOCK_STRIDE);
+ }
+
#[test]
fn scene3d_compiles() {
assert!(!super::scene3d_spirv().is_empty());
@@ -2457,7 +2448,7 @@ mod tests {
/// shape here, so it measures the thing it is guarding.
#[test]
fn window_info_layout_matches_the_uniform_size() {
- let src = include_str!("shader2d.wgsl");
+ let src = crate::draw::shaders::SHADER2D;
let body = src
.split_once("struct WindowInfo {")
.expect("WindowInfo moved; this test scans for it")
diff --git a/src/vk/text.rs b/src/vk/text.rs
index eddbf99..79eded2 100644
--- a/src/vk/text.rs
+++ b/src/vk/text.rs
@@ -14,7 +14,6 @@
//! stored white-with-alpha, color (emoji) glyphs as-is drawn with a white vertex
//! color — glyph.wgsl multiplies either by the vertex color.
-use std::collections::HashMap;
use ash::vk;
use gpu_allocator::vulkan::{
@@ -22,80 +21,12 @@ use gpu_allocator::vulkan::{
};
use gpu_allocator::MemoryLocation;
-use cosmic_text::{CacheKey, SwashContent};
+
use cosmic_text::{FontSystem, SwashCache};
use super::renderer::{create_cpu_buffer, destroy_cpu_buffer, AllocatedBuffer};
pub use crate::draw::TextSpan;
-
-const ATLAS_SIZE: u32 = 1024;
-const ATLAS_PAD: u32 = 1;
-
-#[repr(C)]
-#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
-struct GlyphVertex {
- position: [f32; 2],
- uv: [f32; 2],
- color: [f32; 4],
- clip_circle: [f32; 3],
- clip_extents: [f32; 2],
-}
-
-// See the matching block in `image.rs`: both pipelines feed the same glyph
-// shader (locations 0..=4), so both vertex structs must hold this exact layout.
-const _: () = {
- assert!(std::mem::size_of::<GlyphVertex>() == 52);
- assert!(std::mem::offset_of!(GlyphVertex, position) == 0);
- assert!(std::mem::offset_of!(GlyphVertex, uv) == 8);
- assert!(std::mem::offset_of!(GlyphVertex, color) == 16);
- assert!(std::mem::offset_of!(GlyphVertex, clip_circle) == 32);
- assert!(std::mem::offset_of!(GlyphVertex, clip_extents) == 44);
-};
-
-#[derive(Clone, Copy)]
-struct GlyphEntry {
- /// Atlas texel rect.
- u: u32,
- v: u32,
- w: u32,
- h: u32,
- /// Raster placement offsets (from swash).
- left: i32,
- top: i32,
- is_color: bool,
- /// Zero-sized raster (spaces): nothing to draw, but cached to skip re-rastering.
- empty: bool,
-}
-
-struct Shelf {
- cursor_x: u32,
- cursor_y: u32,
- row_height: u32,
-}
-
-impl Shelf {
- fn new() -> Self {
- Shelf { cursor_x: ATLAS_PAD, cursor_y: ATLAS_PAD, row_height: 0 }
- }
-
- fn insert(&mut self, w: u32, h: u32) -> Option<(u32, u32)> {
- if w > ATLAS_SIZE - 2 * ATLAS_PAD || h > ATLAS_SIZE - 2 * ATLAS_PAD {
- return None;
- }
- if self.cursor_x + w + ATLAS_PAD > ATLAS_SIZE {
- self.cursor_x = ATLAS_PAD;
- self.cursor_y += self.row_height + ATLAS_PAD;
- self.row_height = 0;
- }
- if self.cursor_y + h + ATLAS_PAD > ATLAS_SIZE {
- return None;
- }
- let pos = (self.cursor_x, self.cursor_y);
- self.cursor_x += w + ATLAS_PAD;
- self.row_height = self.row_height.max(h);
- Some(pos)
- }
-}
+use crate::draw::glyphs::{GlyphAtlas, GlyphVertex, ATLAS_SIZE};
struct TextFrame {
vertex: AllocatedBuffer,
@@ -117,15 +48,9 @@ pub(crate) struct TextStage {
atlas_image: vk::Image,
atlas_view: vk::ImageView,
atlas_allocation: Option<Allocation>,
- /// CPU mirror of the atlas (RGBA8, ATLAS_SIZE²).
- atlas_cpu: Vec<u8>,
+ /// The atlas and glyph quads (CPU side, shared with every renderer).
+ atlas: GlyphAtlas,
atlas_initialized: bool,
- generation: u64,
-
- glyphs: HashMap<CacheKey, GlyphEntry>,
- shelf: Shelf,
-
- pending_vertices: Vec<GlyphVertex>,
frames: Vec<TextFrame>,
}
@@ -391,80 +316,14 @@ impl TextStage {
atlas_image,
atlas_view,
atlas_allocation: Some(atlas_allocation),
- atlas_cpu: vec![0u8; (ATLAS_SIZE * ATLAS_SIZE * 4) as usize],
+ atlas: GlyphAtlas::new(),
atlas_initialized: false,
- generation: 1,
- glyphs: HashMap::new(),
- shelf: Shelf::new(),
- pending_vertices: Vec::new(),
frames,
}
}
}
- /// Rasterize (on miss) and cache one glyph. Returns None when the atlas is full.
- fn ensure_glyph(
- &mut self,
- font_system: &mut FontSystem,
- swash_cache: &mut SwashCache,
- key: CacheKey,
- ) -> Option<GlyphEntry> {
- if let Some(entry) = self.glyphs.get(&key) {
- return Some(*entry);
- }
- let image = swash_cache.get_image_uncached(font_system, key)?;
- let w = image.placement.width;
- let h = image.placement.height;
- if w == 0 || h == 0 || image.data.is_empty() {
- let entry = GlyphEntry {
- u: 0, v: 0, w: 0, h: 0, left: 0, top: 0, is_color: false, empty: true,
- };
- self.glyphs.insert(key, entry);
- return Some(entry);
- }
- let (u, v) = self.shelf.insert(w, h)?;
-
- let is_color = !matches!(image.content, SwashContent::Mask);
- for row in 0..h {
- for col in 0..w {
- let dst = (((v + row) * ATLAS_SIZE + (u + col)) * 4) as usize;
- let texel = match image.content {
- SwashContent::Mask => {
- let a = image.data[(row * w + col) as usize];
- [255, 255, 255, a]
- }
- // Color and SubpixelMask rasters are RGBA.
- _ => {
- let src = ((row * w + col) * 4) as usize;
- [
- image.data[src],
- image.data[src + 1],
- image.data[src + 2],
- image.data[src + 3],
- ]
- }
- };
- self.atlas_cpu[dst..dst + 4].copy_from_slice(&texel);
- }
- }
- self.generation += 1;
-
- let entry = GlyphEntry {
- u,
- v,
- w,
- h,
- left: image.placement.left,
- top: image.placement.top,
- is_color,
- empty: false,
- };
- self.glyphs.insert(key, entry);
- Some(entry)
- }
-
- /// Build this frame's glyph vertices. Positions/bounds in physical pixels,
- /// NDC computed against `extent` (wgpu convention; the shader flips for Vulkan).
+ /// Build this frame's glyph quads against `extent` (see [`GlyphAtlas::prepare`]).
pub(crate) fn prepare(
&mut self,
font_system: &mut FontSystem,
@@ -472,128 +331,7 @@ impl TextStage {
spans: &[TextSpan<'_>],
extent: vk::Extent2D,
) {
- self.pending_vertices.clear();
- if !self.try_prepare(font_system, swash_cache, spans, extent) {
- // Atlas full: clear and repack with only the glyphs this frame needs.
- log::info!("glyph atlas full — clearing and repacking");
- self.glyphs.clear();
- self.shelf = Shelf::new();
- self.atlas_cpu.fill(0);
- self.generation += 1;
- self.pending_vertices.clear();
- if !self.try_prepare(font_system, swash_cache, spans, extent) {
- log::error!("glyph atlas full even after repack; text truncated this frame");
- }
- }
- }
-
- fn try_prepare(
- &mut self,
- font_system: &mut FontSystem,
- swash_cache: &mut SwashCache,
- spans: &[TextSpan<'_>],
- extent: vk::Extent2D,
- ) -> bool {
- let sw = extent.width as f32;
- let sh = extent.height as f32;
- for span in spans {
- for run in span.buffer.layout_runs() {
- let line_y = (run.line_y * span.scale).round() as i32;
- for glyph in run.glyphs.iter() {
- let physical = glyph.physical((span.left, span.top), span.scale);
- let Some(entry) =
- self.ensure_glyph(font_system, swash_cache, physical.cache_key)
- else {
- // Distinguish "atlas full" (retryable) from "unrasterizable"
- // (skip): a missing swash image caches as empty above, so a
- // None here means the shelf rejected it.
- if swash_cache
- .get_image_uncached(font_system, physical.cache_key)
- .is_some()
- {
- return false;
- }
- continue;
- };
- if entry.empty {
- continue;
- }
-
- // glyphon's placement formula (kept verbatim), physical pixels.
- let mut x0 = (physical.x + entry.left) as f32;
- let mut y0 = (line_y + physical.y - entry.top) as f32;
- let mut x1 = x0 + entry.w as f32;
- let mut y1 = y0 + entry.h as f32;
- let mut u0 = entry.u as f32;
- let mut v0 = entry.v as f32;
- let mut u1 = u0 + entry.w as f32;
- let mut v1 = v0 + entry.h as f32;
-
- // CPU clip to span bounds, shrinking UVs proportionally.
- if let Some([bl, bt, br, bb]) = span.bounds {
- let (bl, bt, br, bb) = (bl as f32, bt as f32, br as f32, bb as f32);
- if x0 >= br || x1 <= bl || y0 >= bb || y1 <= bt {
- continue;
- }
- if x0 < bl {
- u0 += bl - x0;
- x0 = bl;
- }
- if x1 > br {
- u1 -= x1 - br;
- x1 = br;
- }
- if y0 < bt {
- v0 += bt - y0;
- y0 = bt;
- }
- if y1 > bb {
- v1 -= y1 - bb;
- y1 = bb;
- }
- }
-
- let color = if entry.is_color {
- [1.0, 1.0, 1.0, 1.0]
- } else if let Some(c) = glyph.color_opt {
- [
- c.r() as f32 / 255.0,
- c.g() as f32 / 255.0,
- c.b() as f32 / 255.0,
- c.a() as f32 / 255.0,
- ]
- } else {
- span.default_color
- };
-
- // Corner positions, optionally rotated about the span's center
- // (physical px) before the NDC mapping.
- let corners = match span.rotation {
- None => [[x0, y0], [x1, y0], [x0, y1], [x1, y1]],
- Some((angle, cx, cy)) => {
- let (sin_a, cos_a) = angle.sin_cos();
- let rot = |px: f32, py: f32| {
- let (dx, dy) = (px - cx, py - cy);
- [cx + dx * cos_a - dy * sin_a, cy + dx * sin_a + dy * cos_a]
- };
- [rot(x0, y0), rot(x1, y0), rot(x0, y1), rot(x1, y1)]
- }
- };
- let ndc = |p: [f32; 2]| {
- [(p[0] / sw) * 2.0 - 1.0, 1.0 - (p[1] / sh) * 2.0]
- };
- let uv = |u: f32, v: f32| [u / ATLAS_SIZE as f32, v / ATLAS_SIZE as f32];
- let clip_circle = span.clip_circle;
- let clip_extents = span.clip_extents;
- let tl = GlyphVertex { position: ndc(corners[0]), uv: uv(u0, v0), color, clip_circle, clip_extents };
- let tr = GlyphVertex { position: ndc(corners[1]), uv: uv(u1, v0), color, clip_circle, clip_extents };
- let bl = GlyphVertex { position: ndc(corners[2]), uv: uv(u0, v1), color, clip_circle, clip_extents };
- let br = GlyphVertex { position: ndc(corners[3]), uv: uv(u1, v1), color, clip_circle, clip_extents };
- self.pending_vertices.extend([tl, tr, bl, tr, br, bl]);
- }
- }
- }
- true
+ self.atlas.prepare(font_system, swash_cache, spans, extent.width, extent.height);
}
/// Called after this frame's fence has been waited: copy the current text
@@ -610,7 +348,7 @@ impl TextStage {
) {
let frame = &mut self.frames[frame_index];
- let bytes: &[u8] = bytemuck::cast_slice(&self.pending_vertices);
+ let bytes: &[u8] = bytemuck::cast_slice(self.atlas.vertices());
let needed = bytes.len() as vk::DeviceSize;
if needed > frame.vertex.size {
let mut old = std::mem::replace(&mut frame.vertex, AllocatedBuffer::null());
@@ -628,13 +366,14 @@ impl TextStage {
[..bytes.len()]
.copy_from_slice(bytes);
}
- frame.vertex_count = self.pending_vertices.len() as u32;
+ frame.vertex_count = self.atlas.vertices().len() as u32;
let frame = &mut self.frames[frame_index];
- if frame.uploaded_generation != self.generation {
+ if frame.uploaded_generation != self.atlas.generation() {
+ let pixels = self.atlas.pixels();
frame.staging.allocation.as_mut().unwrap().mapped_slice_mut().unwrap()
- [..self.atlas_cpu.len()]
- .copy_from_slice(&self.atlas_cpu);
+ [..pixels.len()]
+ .copy_from_slice(pixels);
}
}
@@ -642,10 +381,10 @@ impl TextStage {
/// Must be called outside a render pass.
pub(crate) fn record_upload(&mut self, device: &ash::Device, cmd: vk::CommandBuffer, frame_index: usize) {
let frame = &mut self.frames[frame_index];
- if frame.uploaded_generation == self.generation {
+ if frame.uploaded_generation == self.atlas.generation() {
return;
}
- frame.uploaded_generation = self.generation;
+ frame.uploaded_generation = self.atlas.generation();
let range = vk::ImageSubresourceRange::default()
.aspect_mask(vk::ImageAspectFlags::COLOR)