GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
refactor(draw): what a renderer draws moves out of vk/
Batch2D, PlatePush, Frame2D, MAX_PLATE_FEATURES, the 32-float parameter
block layout (PUSH_CONSTANT_FLOATS, batch_push_constants), TextSpan,
ImageQuad, PixelFormat and the image-id queue (upload_rgba and its kin,
update_pixels, recycle_buffer, free_image, renderer_epoch) are plain
data and bookkeeping with nothing Vulkan in them. They lived in vk/
because it was the only renderer; backend::frame builds them for any
renderer, and the WebGPU one the browser port needs will draw from the
same types. src/draw/ holds them now.
The Vulkan side keeps what is Vulkan's: the 128-byte push-constant
assertion, PixelFormat::vk, and ImageStage, which drains the queue
through draw::images::take_pending and registers its table with
image_table_built (the renderer epoch). vk re-exports every moved item
at its old path, so cce_ui::vk::upload_rgba, vk::Frame2D and the rest
are unchanged for clients.
Two smaller paths stop going through the Wayland runner: widgets call
the shaping helpers at backend::text, heightfield takes DlBatch from
backend::tessellate, and the cursor an app returns is cursor-icon's
CursorIcon, depended on directly (smithay-client-toolkit re-exports the
same type, so nothing converts).
Verified: test results unchanged (523 pass, the same 2 pre-existing
failures, vk::image's tests and the plate probe on lavapipe among the
passes), the plate golden byte-identical, the demo identical to the
pixel over the 24 scripted steps (its two image views exercise the
moved queue).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjL3pejMNY95NHv9BcmXaZ
CLAUDE.md | 8 ++
Cargo.lock | 1 +
Cargo.toml | 4 +
src/backend/app.rs | 3 +-
src/backend/driver.rs | 2 +-
src/backend/frame.rs | 2 +-
src/backend/tessellate.rs | 38 ++++----
src/backend/text.rs | 2 +-
src/draw/images.rs | 169 ++++++++++++++++++++++++++++++++++
src/draw/mod.rs | 167 +++++++++++++++++++++++++++++++++
src/icon.rs | 4 +-
src/lib.rs | 5 +-
src/scene/heightfield.rs | 4 +-
src/vk/image.rs | 160 ++------------------------------
src/vk/renderer.rs | 124 +------------------------
src/vk/text.rs | 28 +-----
src/widget/container/parameters_bg.rs | 2 +-
src/widget/core.rs | 4 +-
src/widget/display/image_view.rs | 4 +-
src/widget/display/label.rs | 2 +-
src/widget/display/list_item.rs | 2 +-
src/widget/display/text_label.rs | 2 +-
src/widget/input/button.rs | 6 +-
src/widget/input/color_selector.rs | 2 +-
src/widget/input/dropdown.rs | 2 +-
src/widget/input/spinbox.rs | 2 +-
src/widget/input/text_box.rs | 10 +-
src/widget/shaping.rs | 8 +-
28 files changed, 415 insertions(+), 352 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 07e0c1b..70231ad 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1110,6 +1110,14 @@ cce-system-interface) to confirm behavior, not just the test suite.
what a frame contains in `frame.rs` and a pacing change in `shell.rs`, never in the
Wayland code. A second shell implements `Shell` and calls `Pacer::turn` from its own
loop (an animation frame, a run-loop observer), sleeping or scheduling for the `Step`. `menu_popup.rs` and `dnd.rs` are Wayland-only.
+- `draw/` — what a renderer draws, with no renderer in it (since 2026-10-04): `Frame2D`,
+ `Batch2D`, `PlatePush` and `batch_push_constants` (the one layout of a batch's 32-float
+ parameter block — Vulkan pushes it, a renderer without push constants puts it in a
+ uniform), `TextSpan`, `ImageQuad`, and `draw::images`, the image-id queue
+ (`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.
- `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/Cargo.lock b/Cargo.lock
index 8be2f74..27631e3 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -359,6 +359,7 @@ dependencies = [
"calloop-wayland-source",
"cce-vault",
"cosmic-text",
+ "cursor-icon",
"glam",
"gpu-allocator",
"kdl",
diff --git a/Cargo.toml b/Cargo.toml
index ec760ab..99eff0b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -41,6 +41,10 @@ wayland-protocols = { version = "0.31", features = ["client", "unstable"] }
wayland-backend = "0.3"
wayland-scanner = "0.31"
bitflags = "2"
+# The cursor names an app returns. smithay-client-toolkit re-exports this same
+# crate's type, so the Wayland shell hands it over unconverted; depending on it
+# directly keeps the client contract free of the toolkit.
+cursor-icon = "1.2"
xkeysym = "0.2"
resvg = "0.41.0"
# App/tray icon themes are overwhelmingly PNG; resvg covers only the SVG half.
diff --git a/src/backend/app.rs b/src/backend/app.rs
index 2720ad7..2257387 100644
--- a/src/backend/app.rs
+++ b/src/backend/app.rs
@@ -8,7 +8,8 @@ use wayland_client::QueueHandle;
use cosmic_text::FontSystem;
use crate::widget::{MouseButton, ElementState, MouseScrollDelta, KeyEvent};
use crate::vk::VkRenderer;
-use super::window_runner::{EngineState, PointerCursorIcon as CursorIcon};
+use super::window_runner::EngineState;
+use cursor_icon::CursorIcon;
use super::tessellate::Vertex;
#[derive(Debug, Clone)]
diff --git a/src/backend/driver.rs b/src/backend/driver.rs
index f213d3a..cf9074b 100644
--- a/src/backend/driver.rs
+++ b/src/backend/driver.rs
@@ -16,7 +16,7 @@
use std::time::Instant;
use super::app::{Application, LogicalPosition, LogicalSize};
-use super::window_runner::PointerCursorIcon as CursorIcon;
+use cursor_icon::CursorIcon;
use crate::widget::{ElementState, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey, Position};
/// A key held down, for the runner's own key repeat.
diff --git a/src/backend/frame.rs b/src/backend/frame.rs
index 2dd9359..d0dd673 100644
--- a/src/backend/frame.rs
+++ b/src/backend/frame.rs
@@ -17,7 +17,7 @@ use cosmic_text::FontSystem;
use super::app::{Application, LogicalSize};
use super::tessellate::{quad_vertices, tessellate_display_list, DlBatch, Vertex};
use super::text::{collect_dl_text, dl_text_spans, TextBounds};
-use crate::vk::{Batch2D, Frame2D, ImageQuad, TextSpan};
+use crate::draw::{Batch2D, Frame2D, ImageQuad, TextSpan};
use crate::widget::TextItem;
/// One frame, built and owned: the renderer's [`Frame2D`] is a borrow of it
diff --git a/src/backend/tessellate.rs b/src/backend/tessellate.rs
index e887492..f3ddac5 100644
--- a/src/backend/tessellate.rs
+++ b/src/backend/tessellate.rs
@@ -5,7 +5,7 @@
//! `window_runner` so another shell can share it.
use crate::widget::WidgetHost;
-use crate::vk::Batch2D;
+use crate::draw::Batch2D;
/// A droplet spec resolved against a concrete rect: the push-constant fields
/// that define its SILHOUETTE, in logical px.
@@ -1276,8 +1276,8 @@ pub struct DlBatch {
pub start: u32,
pub end: u32,
/// When set, this batch is one SDF-lit plate cover quad (see
- /// [`crate::vk::PlatePush`]; already in physical px). Never merged.
- pub plate: Option<crate::vk::PlatePush>,
+ /// [`crate::draw::PlatePush`]; already in physical px). Never merged.
+ pub plate: Option<crate::draw::PlatePush>,
/// A blur-behind plate (negative-alpha color): before drawing this batch
/// the renderer snapshots the swapchain-so-far into its snapshot image, so
/// the blur samples everything painted beneath the plate — not just the 3D
@@ -1462,7 +1462,7 @@ pub fn tessellate_display_list(
for item in &dl.items {
let mut start = verts.len() as u32;
- let mut plate: Option<crate::vk::PlatePush> = None;
+ let mut plate: Option<crate::draw::PlatePush> = None;
// A frosted flat fill promoted to a zero-depth plate batch (below):
// it carries a recipe like any plate, but it is ordinary geometry to
// the carve grouping — it opens no host and closes the open ones.
@@ -1701,7 +1701,7 @@ pub fn tessellate_display_list(
// A tinted carve also never groups: a CSG feature is geometry only,
// so the tint could only land on the whole plate's specular.
let full_ring = *edges == (true, true, true, true);
- let host_plate = if mode < 3.5 && full_ring && tint.is_none() && features.len() < crate::vk::MAX_PLATE_FEATURES {
+ let host_plate = if mode < 3.5 && full_ring && tint.is_none() && features.len() < crate::draw::MAX_PLATE_FEATURES {
// The carve's shaded region, for the occlusion test below
// (the overlay path's cover-quad inflation).
let infl = *depth * 0.5 + 2.0;
@@ -1755,7 +1755,7 @@ pub fn tessellate_display_list(
let roll = batches[bi].plate.as_ref().map_or(0.0, |p| p.light[3]) / scale;
let later: Vec<crate::scene::layout::Rect> =
plate_stack[si + 1..].iter().map(|&(_, r)| r).collect();
- let budget_full = features.len() >= crate::vk::MAX_PLATE_FEATURES;
+ let budget_full = features.len() >= crate::draw::MAX_PLATE_FEATURES;
if let Some(why) =
near_roll_fallback_reason(rect, *depth, &prect, roll, &later, budget_full)
{
@@ -1807,7 +1807,7 @@ pub fn tessellate_display_list(
format!("edge-suppressed {edges:?} — the extended wall would smear across the host")
} else if tint.is_some() {
"tinted — a CSG feature is geometry only, it carries no color".into()
- } else if features.len() >= crate::vk::MAX_PLATE_FEATURES {
+ } else if features.len() >= crate::draw::MAX_PLATE_FEATURES {
format!("feature budget full ({} used)", features.len())
} else if enclosing.is_empty() {
format!("no enclosing plate ({} open)", plate_stack.len())
@@ -2093,7 +2093,7 @@ pub fn tessellate_display_list(
// the disc by 1px for the shader's silhouette anti-aliasing.
let d = *radius + 1.0;
verts.extend(quad_vertices(cx - d, cy - d, 2.0 * d, 2.0 * d, sw, sh, color));
- plate = Some(crate::vk::PlatePush {
+ plate = Some(crate::draw::PlatePush {
// Center + radius in physical px; the SDF box machinery is
// unused in this mode, so .w is free.
rect: [cx * scale, cy * scale, radius * scale, 0.0],
@@ -2120,7 +2120,7 @@ pub fn tessellate_display_list(
// outside the silhouette.
let g = droplet_geom(rect, spec);
verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, color));
- plate = Some(crate::vk::PlatePush {
+ plate = Some(crate::draw::PlatePush {
rect: [
(rect.x + rect.width * 0.5) * scale,
(rect.y + rect.height * 0.5) * scale,
@@ -2160,7 +2160,7 @@ pub fn tessellate_display_list(
rect.height + sh_reach,
sw, sh, color,
));
- plate = Some(crate::vk::PlatePush {
+ plate = Some(crate::draw::PlatePush {
rect: [
(rect.x + rect.width * 0.5) * scale,
(rect.y + rect.height * 0.5) * scale,
@@ -2221,7 +2221,7 @@ pub fn tessellate_display_list(
let m = *depth * 0.5 + 2.0;
let r = *radius + m;
verts.extend(quad_vertices(cx - r, cy - r, 2.0 * r, 2.0 * r, sw, sh, [0.0; 4]));
- plate = Some(crate::vk::PlatePush {
+ plate = Some(crate::draw::PlatePush {
rect: [cx * scale, cy * scale, *radius * scale, 0.0],
radii: [*a0, 0.0, 0.0, 0.0],
light: [plate_light[0], plate_light[1], plate_light[2], *depth * scale],
@@ -2251,7 +2251,7 @@ pub fn tessellate_display_list(
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
let len = (dx * dx + dy * dy).sqrt();
let n = if len > 1e-4 { (-dy / len, dx / len) } else { (1.0, 0.0) };
- plate = Some(crate::vk::PlatePush {
+ plate = Some(crate::draw::PlatePush {
// Centre + slab half-width in physical px; .w unused.
rect: [
(a.0 + b.0) * 0.5 * scale,
@@ -2333,7 +2333,7 @@ pub fn tessellate_display_list(
// fades against a host — its own rect bounds it).
let (pw, ph) = (period.0.max(1e-3), period.1.max(1e-3));
verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, [0.0; 4]));
- plate = Some(crate::vk::PlatePush {
+ plate = Some(crate::draw::PlatePush {
rect: [origin.0 * scale, origin.1 * scale, cell.0 * 0.5 * scale, cell.1 * 0.5 * scale],
radii: [*radius * scale; 4],
light: [plate_light[0], plate_light[1], plate_light[2], *depth * scale],
@@ -2351,7 +2351,7 @@ pub fn tessellate_display_list(
// carried but unread.
let (pw, ph) = (period.0.max(1e-3), period.1.max(1e-3));
verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, *color));
- plate = Some(crate::vk::PlatePush {
+ plate = Some(crate::draw::PlatePush {
rect: [origin.0 * scale, origin.1 * scale, cell.0 * 0.5 * scale, cell.1 * 0.5 * scale],
radii: [*radius * scale; 4],
light: [plate_light[0], plate_light[1], plate_light[2], 0.0],
@@ -2371,7 +2371,7 @@ pub fn tessellate_display_list(
// and the shader takes the nearest one per pixel. The cover
// quad is the union's bounding box grown by the wall's reach;
// off-shape corners of it sit at the plateau and shade nothing.
- let budget = crate::vk::MAX_PLATE_FEATURES.saturating_sub(features.len());
+ let budget = crate::draw::MAX_PLATE_FEATURES.saturating_sub(features.len());
let take = boxes.len().min(budget);
if take < boxes.len() && plate_debug() {
eprintln!(
@@ -2417,7 +2417,7 @@ pub fn tessellate_display_list(
// not append past it (its features would no longer be
// contiguous), so it is closed here like any other appender.
last_feature_plate = None;
- plate = Some(crate::vk::PlatePush {
+ plate = Some(crate::draw::PlatePush {
rect: [
(x0 + x1) * 0.5 * scale,
(y0 + y1) * 0.5 * scale,
@@ -2537,7 +2537,7 @@ fn flat_frost_push(
scale: f32,
light: [f32; 3],
material: [f32; 4],
-) -> crate::vk::PlatePush {
+) -> crate::draw::PlatePush {
let mut p = plate_push_raised(rect, radii, 0.0, scale, light, material, false, None);
let [fz, fw] = crate::scene::material::Material::from_fill(fill).frost.pack(scale);
p.host[2] = fz;
@@ -2554,7 +2554,7 @@ fn plate_push_raised(
material: [f32; 4],
scale_corners: bool,
shape: Option<f32>,
-) -> crate::vk::PlatePush {
+) -> crate::draw::PlatePush {
// Floored: a rect already shrunk past its padding (a window dragged
// below what its layout can hold) has a NEGATIVE extent here, and
// `clamp(0.0, cap)` with a negative cap is a panic, not a zero radius.
@@ -2570,7 +2570,7 @@ fn plate_push_raised(
// the nominal-radius squircles of the widget silhouettes around them, and
// at their few-px roll widths the offset crease is subpixel.
let rscale = if scale_corners { crate::layout::corner_span_factor_for(shape) } else { 1.0 };
- crate::vk::PlatePush {
+ crate::draw::PlatePush {
rect: [
(rect.x + rect.width * 0.5) * scale,
(rect.y + rect.height * 0.5) * scale,
diff --git a/src/backend/text.rs b/src/backend/text.rs
index 9d98bda..e57aad0 100644
--- a/src/backend/text.rs
+++ b/src/backend/text.rs
@@ -5,7 +5,7 @@
use cosmic_text::{FontSystem, Buffer, Attrs, Metrics};
use crate::widget::TextItem;
-use crate::vk::TextSpan;
+use crate::draw::TextSpan;
#[derive(Hash, PartialEq, Eq, Clone)]
struct BufferCacheKey {
diff --git a/src/draw/images.rs b/src/draw/images.rs
new file mode 100644
index 0000000..96f4ed3
--- /dev/null
+++ b/src/draw/images.rs
@@ -0,0 +1,169 @@
+//! The image-id queue. [`upload_rgba`] and its kin queue pixels from any code
+//! and hand back an id that is usable in [`ImageQuad`]s at once; whichever
+//! renderer the process has drains the queue at its next frame
+//! ([`take_pending`]) and frees what [`free_image`] queued. See `vk::image`
+//! for the Vulkan side and the reasoning behind the streaming path.
+
+use std::sync::atomic::{AtomicU32, Ordering};
+use std::sync::Mutex;
+
+/// One image draw in a 2D frame.
+pub struct ImageQuad {
+ /// Id from [`upload_rgba`].
+ pub image: u32,
+ /// Destination rect (x, y, w, h) in physical pixels.
+ pub rect: (f32, f32, f32, f32),
+ pub alpha: f32,
+ /// Draw order: this quad renders before the vertex at this index of
+ /// `Frame2D::verts` (so vertices below it stay below, later ones cover it).
+ /// Use `u32::MAX` to draw on top of all display-list geometry.
+ pub z_before: u32,
+ /// Optional scissor (x, y, w, h) in physical pixels.
+ pub clip: Option<(u32, u32, u32, u32)>,
+}
+
+/// Byte order of the pixels handed over.
+///
+/// Both are sRGB-encoded 8-bit-per-channel; the difference is only which
+/// channel comes first in memory, and the hardware handles it on sample. A
+/// caller whose source is already BGRA (Wayland's and WebKit's usual order)
+/// should say so rather than swizzle on the CPU: at a fullscreen 3840x2400
+/// that swizzle measured 7.4 ms per frame, which is most of a frame budget
+/// spent rearranging bytes the sampler can read either way.
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub enum PixelFormat {
+ Rgba,
+ Bgra,
+}
+
+/// One queued change to the image table, drained by the renderer.
+pub enum Pending {
+ Upload { id: u32, pixels: Vec<u8>, width: u32, height: u32, format: PixelFormat, mips: bool },
+ /// Replace the contents of an image that already exists, keeping its
+ /// id, its `VkImage` and its descriptor set.
+ Update { id: u32, pixels: Vec<u8>, width: u32, height: u32, format: PixelFormat },
+ Free { id: u32 },
+}
+
+static PENDING: Mutex<Vec<Pending>> = Mutex::new(Vec::new());
+static NEXT_ID: AtomicU32 = AtomicU32::new(1);
+/// How many image tables have been built in this process. See
+/// [`renderer_epoch`].
+static STAGES_BUILT: AtomicU32 = AtomicU32::new(0);
+/// Pixel buffers the renderer has finished with, waiting to be refilled.
+/// Bounded: a streaming caller needs one or two in flight, and holding more
+/// frame-sized buffers than that is just memory.
+static RECYCLED: Mutex<Vec<Vec<u8>>> = Mutex::new(Vec::new());
+const MAX_RECYCLED: usize = 3;
+
+/// Queue an RGBA8 image for upload; the id is usable in [`ImageQuad`]s right
+/// away (draws before the upload lands are skipped, not errors).
+pub fn upload_rgba(pixels: Vec<u8>, width: u32, height: u32) -> u32 {
+ upload_pixels(pixels, width, height, PixelFormat::Rgba)
+}
+
+/// Queue an image whose bytes are in `format`. [`upload_rgba`] is this with
+/// [`PixelFormat::Rgba`].
+pub fn upload_pixels(pixels: Vec<u8>, width: u32, height: u32, format: PixelFormat) -> u32 {
+ queue_upload(pixels, width, height, format, false)
+}
+
+/// [`upload_rgba`] for an image that will be drawn much smaller than it is,
+/// or at a slant: the image gets a full mip chain, built on the GPU, and is
+/// sampled from the level that matches the size it is drawn at. An update
+/// ([`update_pixels`]) rebuilds the chain.
+///
+/// On a device that cannot build one by blitting the image is uploaded
+/// without, as [`upload_rgba`] would have.
+pub fn upload_rgba_mipmapped(pixels: Vec<u8>, width: u32, height: u32) -> u32 {
+ queue_upload(pixels, width, height, PixelFormat::Rgba, true)
+}
+
+fn queue_upload(pixels: Vec<u8>, width: u32, height: u32, format: PixelFormat, mips: bool) -> u32 {
+ assert_eq!(pixels.len(), (width * height * 4) as usize, "8888 size mismatch");
+ let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
+ PENDING.lock().unwrap().push(Pending::Upload { id, pixels, width, height, format, mips });
+ id
+}
+
+/// Replace what `id` holds, keeping the image itself.
+///
+/// For a caller that redraws the same picture over and over — a page, a video
+/// frame, a live preview. Nothing is allocated, no descriptor is rewritten and
+/// no image is destroyed, so none of the per-frame `device_wait_idle` that
+/// freeing one costs. The size or format changing is allowed and simply falls
+/// back to a fresh image under the same id, which is what a window resize
+/// does.
+///
+/// An `id` that does not exist yet is treated as an upload, so a caller can
+/// take an id from [`upload_pixels`] and update it from the next frame on
+/// without sequencing the two.
+pub fn update_pixels(id: u32, pixels: Vec<u8>, width: u32, height: u32, format: PixelFormat) {
+ assert_eq!(pixels.len(), (width * height * 4) as usize, "8888 size mismatch");
+ PENDING.lock().unwrap().push(Pending::Update { id, pixels, width, height, format });
+}
+
+/// A pixel buffer to fill, reusing one the renderer has finished with when
+/// there is one of at least `len` bytes.
+///
+/// The returned buffer is exactly `len` long and its contents are unspecified
+/// — a caller is expected to overwrite every byte, which a full-frame readback
+/// does by definition. Allocating a fresh frame-sized `Vec` instead measured
+/// 7.4 ms against 2.9 ms at 3840x2400: the cost is not the copy, it is the
+/// zeroing and the page faults on newly mapped memory.
+pub fn recycle_buffer(len: usize) -> Vec<u8> {
+ let mut pool = RECYCLED.lock().unwrap();
+ if let Some(index) = pool.iter().position(|b| b.capacity() >= len) {
+ let mut buf = pool.swap_remove(index);
+ buf.clear();
+ buf.resize(len, 0);
+ return buf;
+ }
+ vec![0u8; len]
+}
+
+/// Hand a finished buffer back to the pool (the renderer, once it has
+/// uploaded what a [`Pending`] carried).
+pub fn retire_buffer(mut buf: Vec<u8>) {
+ let mut pool = RECYCLED.lock().unwrap();
+ if pool.len() < MAX_RECYCLED {
+ buf.clear();
+ pool.push(buf);
+ }
+}
+
+/// Which renderer's image table the ids handed out right now belong to.
+///
+/// `0` until the first renderer exists, and again for the whole life of that
+/// first renderer: uploads queued before it was built (from `Application::new`
+/// and from anything the app did on the way to its first frame) are drained
+/// into it, so they are that epoch's images, not a previous one's.
+/// Every later renderer — `window_runner` builds one per session, and a lost
+/// Wayland transport starts a new session around the same `Application` —
+/// counts as the next epoch.
+///
+/// This is what lets a long-lived cache of image ids notice that its ids have
+/// stopped naming anything. It is the cheap half of the contract; the other
+/// half is the app's, because only the app knows how to produce the pixels
+/// again (see [`Application::renderer_init`], and `upload_icon` for the
+/// toolkit's own use of this).
+///
+/// [`Application::renderer_init`]: crate::engine::Application::renderer_init
+pub fn renderer_epoch() -> u32 {
+ STAGES_BUILT.load(Ordering::Relaxed).saturating_sub(1)
+}
+
+/// Queue an image's GPU resources for destruction.
+pub fn free_image(id: u32) {
+ PENDING.lock().unwrap().push(Pending::Free { id });
+}
+
+/// Everything queued since the last call, in order. The renderer's to drain.
+pub fn take_pending() -> Vec<Pending> {
+ std::mem::take(&mut *PENDING.lock().unwrap())
+}
+
+/// A renderer built its image table: from here on [`renderer_epoch`] names it.
+pub fn image_table_built() {
+ STAGES_BUILT.fetch_add(1, Ordering::Relaxed);
+}
diff --git a/src/draw/mod.rs b/src/draw/mod.rs
new file mode 100644
index 0000000..8bf7990
--- /dev/null
+++ b/src/draw/mod.rs
@@ -0,0 +1,167 @@
+//! What a renderer draws, with no renderer in it: the frame
+//! ([`Frame2D`] and its [`Batch2D`]s, [`PlatePush`] parameter blocks and the
+//! one way to lay a batch's block out, [`batch_push_constants`]), text runs
+//! ([`TextSpan`]), image draws ([`ImageQuad`]) and the image-id queue apps
+//! upload through ([`images`]).
+//!
+//! These lived in `vk/` because the Vulkan renderer was the only one. They
+//! are plain data, and `backend::frame` builds them for any renderer — the
+//! Vulkan one on Linux, a WebGPU one in the browser — so they live here, and
+//! `vk` re-exports every one at its old path.
+
+use crate::backend::tessellate::Vertex;
+use cosmic_text::Buffer as TextBuffer;
+
+pub mod images;
+
+pub use images::{
+ free_image, recycle_buffer, renderer_epoch, update_pixels, upload_pixels, upload_rgba,
+ upload_rgba_mipmapped, ImageQuad, PixelFormat,
+};
+
+/// One scissored draw range of a 2D frame. `scissor` is (x, y, w, h) in
+/// physical pixels; None draws with the full-surface scissor. `clip_rrect` is an
+/// optional rounded-rect clip `[cx, cy, bx, by, r]` (center, SDF half-extents, corner
+/// radius; physical px) applied via push constants — fragments outside it discard, so a
+/// plate's children cut off at its rounded corners.
+pub struct Batch2D {
+ pub scissor: Option<(u32, u32, u32, u32)>,
+ pub clip_rrect: Option<[f32; 5]>,
+ pub start: u32,
+ pub end: u32,
+ /// When set, this batch is a single SDF-lit plate cover quad: the params go
+ /// out as push constants and shader2d's plate branch lights it per pixel.
+ pub plate: Option<PlatePush>,
+ /// A blur-behind plate (negative-alpha color): the renderer suspends the UI
+ /// pass, copies the swapchain-so-far into its snapshot image, and resumes —
+ /// so the plate's blur samples everything painted beneath it (background,
+ /// widgets, wires), not just the 3D scene backdrop.
+ pub blur_behind: bool,
+}
+
+/// Floats in the fragment push-constant block: the rounded-rect clip (`rect0`,
+/// `rect1` — 6 clip/flag floats plus the plate mode and corner shape) followed
+/// by [`PlatePush`]'s six vec4s. Field for field, this is shader2d's `RRectClip`.
+pub const PUSH_CONSTANT_FLOATS: usize = 32;
+
+/// The fragment push-constant block for one batch: its rounded-rect clip,
+/// and its plate block when it is a plate cover quad. `feature_base` is the
+/// frame slot's first entry in the feature UBO, added to a plate's (or a
+/// union carve's) feature offset. Shared by every renderer and the offscreen
+/// test harness (`vk::plate_probe`), so none can push a different block. (Vulkan
+/// carries it as push constants; a renderer without them puts it in a uniform.)
+pub fn batch_push_constants(batch: &Batch2D, clip_shape: f32, feature_base: usize) -> [f32; PUSH_CONSTANT_FLOATS] {
+ let rr = batch.clip_rrect.unwrap_or([0.0; 5]);
+ let enabled = if batch.clip_rrect.is_some() { 1.0f32 } else { 0.0 };
+ let mut pc = [0.0f32; PUSH_CONSTANT_FLOATS];
+ pc[..5].copy_from_slice(&rr);
+ pc[5] = enabled;
+ pc[7] = clip_shape;
+ if let Some(p) = &batch.plate {
+ pc[6] = p.mode;
+ pc[7] = p.shape;
+ pc[8..12].copy_from_slice(&p.rect);
+ pc[12..16].copy_from_slice(&p.radii);
+ pc[16..20].copy_from_slice(&p.light);
+ pc[20..24].copy_from_slice(&p.material);
+ pc[24..28].copy_from_slice(&p.host);
+ pc[28..32].copy_from_slice(&p.specular_tint);
+ if p.mode == 1.0 || p.mode == 14.0 {
+ // Rebase the feature offset onto this frame's UBO slot (a plate's
+ // CSG carves, or a union carve's boxes).
+ pc[24] += feature_base as f32;
+ }
+ }
+ pc
+}
+
+/// Push-constant block for one SDF-lit plate batch (physical px throughout).
+/// Mirrors the `p_*` fields of shader2d's `RRectClip`.
+#[derive(Clone, Copy, PartialEq, Debug)]
+pub struct PlatePush {
+ /// SDF box: center + half-extents. May extend past the cover quad — that is
+ /// how a recess suppresses a wall.
+ pub rect: [f32; 4],
+ /// Per-corner radii [tl, tr, br, bl].
+ pub radii: [f32; 4],
+ /// xyz = unit vector toward the light (+z out of the screen), w = roll width px.
+ pub light: [f32; 4],
+ /// [shading strength, specular strength, shininess, curvature/AO strength].
+ pub material: [f32; 4],
+ /// Mode 1: `[feature offset, feature count, frost z, frost w]` — xy into
+ /// the frame's `plate_features`, the carves CSG'd out of this plate (the
+ /// renderer adds the frame slot's base offset at record time); zw the
+ /// plate's frost recipe, `scene::material::Frost::pack` (compression and
+ /// refraction packed in z, the blur sigma in physical px in w). Mode 14 uses the same
+ /// `[offset, count]` for the union's boxes. Mode 2: the host-plate box
+ /// (center + half-extents) a free recess fades out against; far-away sides
+ /// (±1e5) disable the fade.
+ pub host: [f32; 4],
+ /// RGB multiplies the roll's specular color (w unused). Neutral white
+ /// normally; the focused-pane bevel carries the highlight color here.
+ pub specular_tint: [f32; 4],
+ /// 1.0 = raised lit plate, 2.0 = recess overlay, 3.0 = boss, 4.0 = ridge,
+ /// 5.0 = sphere, 6.0/7.0 = concave fillet (recessed/raised), 8.0 = groove
+ /// (slab carve about a line: `rect` = [cx, cy, half-width, _], `radii.xy` =
+ /// the line's unit normal, `host` = the surface it is engraved into),
+ /// 9.0 = trough, 10.0 = droplet (`radii` = [sag, belly r, belly half-w,
+ /// blend k] px, `host` = [sheet corner r px, clarity, dome amplitude,
+ /// attach r px], `material.w` = fresnel rim, `specular_tint` = [core
+ /// density, _, _, bottom-bow rise px] — droplet glints are always white,
+ /// so the tint RGB is repurposed; see shader2d's MODE_DROPLET).
+ pub mode: f32,
+ /// Corner shape exponent: 2.0 = circular arcs, > 2 = superellipse
+ /// (continuous-curvature) corners — see shader2d's `plate_sdf_grad`.
+ pub shape: f32,
+}
+
+/// A full 2D frame: the display-list vertices (optionally split into scissored
+/// batches), overlay vertices drawn after text, and the clear color (linear;
+/// only used on frames without a backdrop copy).
+pub struct Frame2D<'a> {
+ pub verts: &'a [Vertex],
+ pub batches: &'a [Batch2D],
+ pub overlay_verts: &'a [Vertex],
+ /// User images drawn interleaved with `verts` by each quad's `z_before`.
+ pub images: &'a [ImageQuad],
+ /// Carves CSG'd into this frame's SDF-lit plates, 12 floats each (rect
+ /// center+half-extents, per-corner radii, [width px, depth px, 0, 0]).
+ /// Plate batches reference them by offset+count in `PlatePush::host`.
+ pub plate_features: &'a [[f32; 12]],
+ pub clear_color: [f32; 4],
+ /// The only part of the surface that differs from the previous frame,
+ /// (x, y, w, h) in physical pixels; None = all of it. With a rect the
+ /// renderer keeps the pixels outside it (Vulkan's `ImageAge`) and tells the
+ /// compositor that only the rect changed. The caller vouches for it: a
+ /// pixel that changed outside the rect stays as it was.
+ pub damage: Option<(u32, u32, u32, u32)>,
+}
+
+/// Max plate-carve features per frame; the shader's UBO holds one slot of this
+/// size per frame in flight.
+pub const MAX_PLATE_FEATURES: usize = 64;
+
+/// One shaped text run to draw. `left`/`top` are physical pixels and `scale`
+/// multiplies the shaped (logical) glyph positions — the same contract as
+/// the old glyphon::TextArea, where callers pass `label.x * scale`.
+pub struct TextSpan<'a> {
+ pub buffer: &'a TextBuffer,
+ pub left: f32,
+ pub top: f32,
+ pub scale: f32,
+ /// Physical-pixel clip rect (left, top, right, bottom); None = whole surface.
+ pub bounds: Option<[i32; 4]>,
+ /// 0..=1 sRGB + alpha, applied to glyphs without their own color.
+ pub default_color: [f32; 4],
+ /// Rotate the span's glyph quads by (radians, center_x, center_y) in
+ /// physical pixels — the circular network pane's curved rim labels.
+ pub rotation: Option<(f32, f32, f32)>,
+ /// Fragment circle clip (center_x, center_y, radius) in physical pixels;
+ /// zero radius disables (matches shader.wgsl's clip_circle).
+ pub clip_circle: [f32; 3],
+ /// Rounded-rect clip half-extents (physical px). Zero keeps `clip_circle` a plain
+ /// circle; non-zero reinterprets it as a rounded-rect SDF clip — center
+ /// `clip_circle.xy`, corner radius `clip_circle.z`, inner box half-size
+ /// `clip_extents` — so plate children (labels included) cut off at rounded corners.
+ pub clip_extents: [f32; 2],
+}
diff --git a/src/icon.rs b/src/icon.rs
index 6653175..6f9f51b 100644
--- a/src/icon.rs
+++ b/src/icon.rs
@@ -126,7 +126,7 @@ pub fn lookup_in(name: &str, contexts: &[&str]) -> Option<PathBuf> {
/// still skips the disk read and the rasterizer, which is where the time goes.
pub fn upload_themed(name: &str, px: u32) -> Option<(u32, u32, u32)> {
let (pixels, w, h) = decode(name, px)?;
- Some((crate::vk::upload_rgba(pixels, w, h), w, h))
+ Some((crate::draw::upload_rgba(pixels, w, h), w, h))
}
/// [`upload_themed`]'s cached half: name → straight RGBA8 pixels + dimensions.
@@ -159,7 +159,7 @@ fn decode(name: &str, px: u32) -> Option<(Vec<u8>, u32, u32)> {
}
/// Decode a PNG to straight (un-premultiplied) RGBA8, the layout
-/// [`crate::vk::upload_rgba`] takes. `EXPAND` folds palette, sub-byte grayscale
+/// [`crate::draw::upload_rgba`] takes. `EXPAND` folds palette, sub-byte grayscale
/// and `tRNS` into plain channels, which leaves only the four color types below;
/// 16-bit samples are truncated to their high byte.
fn decode_png(data: &[u8]) -> Option<(Vec<u8>, u32, u32)> {
diff --git a/src/lib.rs b/src/lib.rs
index 6c60d1a..43d8a5a 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -12,6 +12,7 @@ pub mod scale;
pub mod units;
pub mod backend;
pub mod context;
+pub mod draw;
pub mod scene;
pub mod file_dialog;
pub mod icon;
@@ -99,7 +100,7 @@ pub fn upload_icon(name: &str, px: u32) -> Option<(u32, u32, u32)> {
/// The cached ids, and the renderer epoch they were uploaded to.
static CACHE: Mutex<Option<(u32, HashMap<(String, u32), Option<(u32, u32, u32)>>)>> =
Mutex::new(None);
- let epoch = crate::vk::renderer_epoch();
+ let epoch = crate::draw::renderer_epoch();
let key = (name.to_string(), px);
let mut guard = CACHE.lock().unwrap();
let (cached_epoch, cache) = guard.get_or_insert_with(|| (epoch, HashMap::new()));
@@ -116,7 +117,7 @@ pub fn upload_icon(name: &str, px: u32) -> Option<(u32, u32, u32)> {
let path = format!("{}/{name}.svg", icons_dir());
let data = std::fs::read(&path).ok()?;
let (rgba, w, h) = rasterize_svg(&data, px)?;
- Some((crate::vk::upload_rgba(rgba, w, h), w, h))
+ Some((crate::draw::upload_rgba(rgba, w, h), w, h))
})();
cache.insert(key, loaded);
loaded
diff --git a/src/scene/heightfield.rs b/src/scene/heightfield.rs
index fba5a8a..e4216b1 100644
--- a/src/scene/heightfield.rs
+++ b/src/scene/heightfield.rs
@@ -31,7 +31,7 @@ use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Mutex;
-use crate::backend::window_runner::DlBatch;
+use crate::backend::tessellate::DlBatch;
use crate::scene::relief_shade::{RECESS_DEPTH, ROLL_CUT};
use crate::units::MetricSource;
@@ -502,7 +502,7 @@ pub fn export_png(hf: &HeightField, path: &Path, mm_per_sample: Option<f32>) ->
#[cfg(test)]
mod tests {
use super::*;
- use crate::vk::PlatePush;
+ use crate::draw::PlatePush;
fn plate(rect: [f32; 4], t: f32, host: [f32; 4]) -> DlBatch {
DlBatch {
diff --git a/src/vk/image.rs b/src/vk/image.rs
index d228362..bc34f22 100644
--- a/src/vk/image.rs
+++ b/src/vk/image.rs
@@ -32,43 +32,17 @@
//! are drawn at their own size.
use std::collections::HashMap;
-use std::sync::atomic::{AtomicU32, Ordering};
-use std::sync::Mutex;
use ash::vk;
use gpu_allocator::vulkan::{Allocation, AllocationCreateDesc, AllocationScheme, Allocator};
use gpu_allocator::MemoryLocation;
use super::renderer::{create_cpu_buffer, destroy_cpu_buffer, AllocatedBuffer};
-
-/// One image draw in a 2D frame.
-pub struct ImageQuad {
- /// Id from [`upload_rgba`].
- pub image: u32,
- /// Destination rect (x, y, w, h) in physical pixels.
- pub rect: (f32, f32, f32, f32),
- pub alpha: f32,
- /// Draw order: this quad renders before the vertex at this index of
- /// `Frame2D::verts` (so vertices below it stay below, later ones cover it).
- /// Use `u32::MAX` to draw on top of all display-list geometry.
- pub z_before: u32,
- /// Optional scissor (x, y, w, h) in physical pixels.
- pub clip: Option<(u32, u32, u32, u32)>,
-}
-
-/// Byte order of the pixels handed over.
-///
-/// Both are sRGB-encoded 8-bit-per-channel; the difference is only which
-/// channel comes first in memory, and the hardware handles it on sample. A
-/// caller whose source is already BGRA (Wayland's and WebKit's usual order)
-/// should say so rather than swizzle on the CPU: at a fullscreen 3840x2400
-/// that swizzle measured 7.4 ms per frame, which is most of a frame budget
-/// spent rearranging bytes the sampler can read either way.
-#[derive(Clone, Copy, PartialEq, Eq, Debug)]
-pub enum PixelFormat {
- Rgba,
- Bgra,
-}
+use crate::draw::images::{image_table_built, retire_buffer, take_pending, Pending};
+pub use crate::draw::images::{
+ free_image, recycle_buffer, renderer_epoch, update_pixels, upload_pixels, upload_rgba,
+ upload_rgba_mipmapped, ImageQuad, PixelFormat,
+};
impl PixelFormat {
fn vk(self) -> vk::Format {
@@ -80,132 +54,12 @@ impl PixelFormat {
}
}
-enum Pending {
- Upload { id: u32, pixels: Vec<u8>, width: u32, height: u32, format: PixelFormat, mips: bool },
- /// Replace the contents of an image that already exists, keeping its
- /// id, its `VkImage` and its descriptor set.
- Update { id: u32, pixels: Vec<u8>, width: u32, height: u32, format: PixelFormat },
- Free { id: u32 },
-}
-
-static PENDING: Mutex<Vec<Pending>> = Mutex::new(Vec::new());
-static NEXT_ID: AtomicU32 = AtomicU32::new(1);
-/// How many image tables have been built in this process. See
-/// [`renderer_epoch`].
-static STAGES_BUILT: AtomicU32 = AtomicU32::new(0);
-/// Pixel buffers the renderer has finished with, waiting to be refilled.
-/// Bounded: a streaming caller needs one or two in flight, and holding more
-/// frame-sized buffers than that is just memory.
-static RECYCLED: Mutex<Vec<Vec<u8>>> = Mutex::new(Vec::new());
-const MAX_RECYCLED: usize = 3;
-
-/// Queue an RGBA8 image for upload; the id is usable in [`ImageQuad`]s right
-/// away (draws before the upload lands are skipped, not errors).
-pub fn upload_rgba(pixels: Vec<u8>, width: u32, height: u32) -> u32 {
- upload_pixels(pixels, width, height, PixelFormat::Rgba)
-}
-
-/// Queue an image whose bytes are in `format`. [`upload_rgba`] is this with
-/// [`PixelFormat::Rgba`].
-pub fn upload_pixels(pixels: Vec<u8>, width: u32, height: u32, format: PixelFormat) -> u32 {
- queue_upload(pixels, width, height, format, false)
-}
-
-/// [`upload_rgba`] for an image that will be drawn much smaller than it is,
-/// or at a slant: the image gets a full mip chain, built on the GPU, and is
-/// sampled from the level that matches the size it is drawn at. An update
-/// ([`update_pixels`]) rebuilds the chain.
-///
-/// On a device that cannot build one by blitting the image is uploaded
-/// without, as [`upload_rgba`] would have.
-pub fn upload_rgba_mipmapped(pixels: Vec<u8>, width: u32, height: u32) -> u32 {
- queue_upload(pixels, width, height, PixelFormat::Rgba, true)
-}
-
-fn queue_upload(pixels: Vec<u8>, width: u32, height: u32, format: PixelFormat, mips: bool) -> u32 {
- assert_eq!(pixels.len(), (width * height * 4) as usize, "8888 size mismatch");
- let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
- PENDING.lock().unwrap().push(Pending::Upload { id, pixels, width, height, format, mips });
- id
-}
-
/// How many levels a full mip chain of an image has: halved until the
/// longer side is one texel.
pub(crate) fn mip_level_count(width: u32, height: u32) -> u32 {
32 - width.max(height).max(1).leading_zeros()
}
-/// Replace what `id` holds, keeping the image itself.
-///
-/// For a caller that redraws the same picture over and over — a page, a video
-/// frame, a live preview. Nothing is allocated, no descriptor is rewritten and
-/// no image is destroyed, so none of the per-frame `device_wait_idle` that
-/// freeing one costs. The size or format changing is allowed and simply falls
-/// back to a fresh image under the same id, which is what a window resize
-/// does.
-///
-/// An `id` that does not exist yet is treated as an upload, so a caller can
-/// take an id from [`upload_pixels`] and update it from the next frame on
-/// without sequencing the two.
-pub fn update_pixels(id: u32, pixels: Vec<u8>, width: u32, height: u32, format: PixelFormat) {
- assert_eq!(pixels.len(), (width * height * 4) as usize, "8888 size mismatch");
- PENDING.lock().unwrap().push(Pending::Update { id, pixels, width, height, format });
-}
-
-/// A pixel buffer to fill, reusing one the renderer has finished with when
-/// there is one of at least `len` bytes.
-///
-/// The returned buffer is exactly `len` long and its contents are unspecified
-/// — a caller is expected to overwrite every byte, which a full-frame readback
-/// does by definition. Allocating a fresh frame-sized `Vec` instead measured
-/// 7.4 ms against 2.9 ms at 3840x2400: the cost is not the copy, it is the
-/// zeroing and the page faults on newly mapped memory.
-pub fn recycle_buffer(len: usize) -> Vec<u8> {
- let mut pool = RECYCLED.lock().unwrap();
- if let Some(index) = pool.iter().position(|b| b.capacity() >= len) {
- let mut buf = pool.swap_remove(index);
- buf.clear();
- buf.resize(len, 0);
- return buf;
- }
- vec![0u8; len]
-}
-
-/// Hand a finished buffer back to the pool.
-fn retire_buffer(mut buf: Vec<u8>) {
- let mut pool = RECYCLED.lock().unwrap();
- if pool.len() < MAX_RECYCLED {
- buf.clear();
- pool.push(buf);
- }
-}
-
-/// Which renderer's image table the ids handed out right now belong to.
-///
-/// `0` until the first renderer exists, and again for the whole life of that
-/// first renderer: uploads queued before it was built (from `Application::new`
-/// and from anything the app did on the way to its first frame) are drained
-/// into it, so they are that epoch's images, not a previous one's.
-/// Every later renderer — `window_runner` builds one per session, and a lost
-/// Wayland transport starts a new session around the same `Application` —
-/// counts as the next epoch.
-///
-/// This is what lets a long-lived cache of image ids notice that its ids have
-/// stopped naming anything. It is the cheap half of the contract; the other
-/// half is the app's, because only the app knows how to produce the pixels
-/// again (see [`Application::renderer_init`], and `upload_icon` for the
-/// toolkit's own use of this).
-///
-/// [`Application::renderer_init`]: crate::engine::Application::renderer_init
-pub fn renderer_epoch() -> u32 {
- STAGES_BUILT.load(Ordering::Relaxed).saturating_sub(1)
-}
-
-/// Queue an image's GPU resources for destruction.
-pub fn free_image(id: u32) {
- PENDING.lock().unwrap().push(Pending::Free { id });
-}
-
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
/// Must match `GlyphVertex` field-for-field: both pipelines are fed by the same
@@ -316,7 +170,7 @@ impl ImageStage {
// One image table per renderer, so this is the renderer count — see
// `renderer_epoch`, which is what tells a cache of ids that its
// renderer is gone.
- STAGES_BUILT.fetch_add(1, Ordering::Relaxed);
+ image_table_built();
unsafe {
let bindings = image_set_bindings();
let descriptor_set_layout = device
@@ -509,7 +363,7 @@ impl ImageStage {
queue: vk::Queue,
command_pool: vk::CommandPool,
) {
- let pending: Vec<Pending> = std::mem::take(&mut *PENDING.lock().unwrap());
+ let pending: Vec<Pending> = take_pending();
if pending.is_empty() {
self.staging_idle = self.staging_idle.saturating_add(1);
if self.staging_idle > STAGING_IDLE_FRAMES {
diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
index d646c34..cb96ba8 100644
--- a/src/vk/renderer.rs
+++ b/src/vk/renderer.rs
@@ -18,36 +18,13 @@ use gpu_allocator::MemoryLocation;
use crate::engine::Vertex;
use super::core::SurfaceLost;
-use super::image::{ImageQuad, ImageStage};
+use super::image::ImageStage;
+pub use crate::draw::{Batch2D, Frame2D, PlatePush, MAX_PLATE_FEATURES};
+pub(crate) use crate::draw::{batch_push_constants, PUSH_CONSTANT_FLOATS};
use super::rt::{RtCamera, RtEnvironment, RtImage, RtImageSource, RtMaterial, RtStage, RtTriangle};
use super::scene::{MeshId, SceneDraw, SceneImage, SceneStage, Vertex3D};
use super::text::{TextSpan, TextStage};
-/// One scissored draw range of a 2D frame. `scissor` is (x, y, w, h) in
-/// physical pixels; None draws with the full-surface scissor. `clip_rrect` is an
-/// optional rounded-rect clip `[cx, cy, bx, by, r]` (center, SDF half-extents, corner
-/// radius; physical px) applied via push constants — fragments outside it discard, so a
-/// plate's children cut off at its rounded corners.
-pub struct Batch2D {
- pub scissor: Option<(u32, u32, u32, u32)>,
- pub clip_rrect: Option<[f32; 5]>,
- pub start: u32,
- pub end: u32,
- /// When set, this batch is a single SDF-lit plate cover quad: the params go
- /// out as push constants and shader2d's plate branch lights it per pixel.
- pub plate: Option<PlatePush>,
- /// A blur-behind plate (negative-alpha color): the renderer suspends the UI
- /// pass, copies the swapchain-so-far into its snapshot image, and resumes —
- /// so the plate's blur samples everything painted beneath it (background,
- /// widgets, wires), not just the 3D scene backdrop.
- pub blur_behind: bool,
-}
-
-/// Floats in the fragment push-constant block: the rounded-rect clip (`rect0`,
-/// `rect1` — 6 clip/flag floats plus the plate mode and corner shape) followed
-/// by [`PlatePush`]'s six vec4s. Field for field, this is shader2d's `RRectClip`.
-pub(crate) const PUSH_CONSTANT_FLOATS: usize = 32;
-
/// The block in bytes. **This is exactly `maxPushConstantsSize`'s
/// Vulkan-guaranteed minimum, so the budget is full** — every one of the 32
/// slots is written. That is why a new SDF mode reinterprets existing fields per
@@ -71,98 +48,6 @@ const _: () = assert!(
before raising PUSH_CONSTANT_FLOATS"
);
-/// The fragment push-constant block for one batch: its rounded-rect clip,
-/// and its plate block when it is a plate cover quad. `feature_base` is the
-/// frame slot's first entry in the feature UBO, added to a plate's (or a
-/// union carve's) feature offset. Shared with the offscreen test harness
-/// (`vk::plate_probe`), so the two cannot push different blocks.
-pub(crate) fn batch_push_constants(batch: &Batch2D, clip_shape: f32, feature_base: usize) -> [f32; PUSH_CONSTANT_FLOATS] {
- let rr = batch.clip_rrect.unwrap_or([0.0; 5]);
- let enabled = if batch.clip_rrect.is_some() { 1.0f32 } else { 0.0 };
- let mut pc = [0.0f32; PUSH_CONSTANT_FLOATS];
- pc[..5].copy_from_slice(&rr);
- pc[5] = enabled;
- pc[7] = clip_shape;
- if let Some(p) = &batch.plate {
- pc[6] = p.mode;
- pc[7] = p.shape;
- pc[8..12].copy_from_slice(&p.rect);
- pc[12..16].copy_from_slice(&p.radii);
- pc[16..20].copy_from_slice(&p.light);
- pc[20..24].copy_from_slice(&p.material);
- pc[24..28].copy_from_slice(&p.host);
- pc[28..32].copy_from_slice(&p.specular_tint);
- if p.mode == 1.0 || p.mode == 14.0 {
- // Rebase the feature offset onto this frame's UBO slot (a plate's
- // CSG carves, or a union carve's boxes).
- pc[24] += feature_base as f32;
- }
- }
- pc
-}
-
-/// Push-constant block for one SDF-lit plate batch (physical px throughout).
-/// Mirrors the `p_*` fields of shader2d's `RRectClip`.
-#[derive(Clone, Copy, PartialEq, Debug)]
-pub struct PlatePush {
- /// SDF box: center + half-extents. May extend past the cover quad — that is
- /// how a recess suppresses a wall.
- pub rect: [f32; 4],
- /// Per-corner radii [tl, tr, br, bl].
- pub radii: [f32; 4],
- /// xyz = unit vector toward the light (+z out of the screen), w = roll width px.
- pub light: [f32; 4],
- /// [shading strength, specular strength, shininess, curvature/AO strength].
- pub material: [f32; 4],
- /// Mode 1: `[feature offset, feature count, frost z, frost w]` — xy into
- /// the frame's `plate_features`, the carves CSG'd out of this plate (the
- /// renderer adds the frame slot's base offset at record time); zw the
- /// plate's frost recipe, `scene::material::Frost::pack` (compression and
- /// refraction packed in z, the blur sigma in physical px in w). Mode 14 uses the same
- /// `[offset, count]` for the union's boxes. Mode 2: the host-plate box
- /// (center + half-extents) a free recess fades out against; far-away sides
- /// (±1e5) disable the fade.
- pub host: [f32; 4],
- /// RGB multiplies the roll's specular color (w unused). Neutral white
- /// normally; the focused-pane bevel carries the highlight color here.
- pub specular_tint: [f32; 4],
- /// 1.0 = raised lit plate, 2.0 = recess overlay, 3.0 = boss, 4.0 = ridge,
- /// 5.0 = sphere, 6.0/7.0 = concave fillet (recessed/raised), 8.0 = groove
- /// (slab carve about a line: `rect` = [cx, cy, half-width, _], `radii.xy` =
- /// the line's unit normal, `host` = the surface it is engraved into),
- /// 9.0 = trough, 10.0 = droplet (`radii` = [sag, belly r, belly half-w,
- /// blend k] px, `host` = [sheet corner r px, clarity, dome amplitude,
- /// attach r px], `material.w` = fresnel rim, `specular_tint` = [core
- /// density, _, _, bottom-bow rise px] — droplet glints are always white,
- /// so the tint RGB is repurposed; see shader2d's MODE_DROPLET).
- pub mode: f32,
- /// Corner shape exponent: 2.0 = circular arcs, > 2 = superellipse
- /// (continuous-curvature) corners — see shader2d's `plate_sdf_grad`.
- pub shape: f32,
-}
-
-/// A full 2D frame: the display-list vertices (optionally split into scissored
-/// batches), overlay vertices drawn after text, and the clear color (linear;
-/// only used on frames without a backdrop copy).
-pub struct Frame2D<'a> {
- pub verts: &'a [Vertex],
- pub batches: &'a [Batch2D],
- pub overlay_verts: &'a [Vertex],
- /// User images drawn interleaved with `verts` by each quad's `z_before`.
- pub images: &'a [ImageQuad],
- /// Carves CSG'd into this frame's SDF-lit plates, 12 floats each (rect
- /// center+half-extents, per-corner radii, [width px, depth px, 0, 0]).
- /// Plate batches reference them by offset+count in `PlatePush::host`.
- pub plate_features: &'a [[f32; 12]],
- pub clear_color: [f32; 4],
- /// The only part of the surface that differs from the previous frame,
- /// (x, y, w, h) in physical pixels; None = all of it. With a rect the
- /// renderer keeps the pixels outside it (see [`ImageAge`]) and tells the
- /// compositor that only the rect changed. The caller vouches for it: a
- /// pixel that changed outside the rect stays as it was.
- pub damage: Option<(u32, u32, u32, u32)>,
-}
-
/// How far a swapchain image's pixels are behind the latest frame. A frame
/// with [`Frame2D::damage`] repaints only what the image it acquired is
/// missing — the frame's own damage plus whatever frames that went to the
@@ -211,9 +96,6 @@ fn rect_intersect(a: vk::Rect2D, b: vk::Rect2D) -> vk::Rect2D {
}
pub(crate) const FRAMES_IN_FLIGHT: usize = 2;
-/// Max plate-carve features per frame; the shader's UBO holds one slot of this
-/// size per frame in flight.
-pub const MAX_PLATE_FEATURES: usize = 64;
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].
diff --git a/src/vk/text.rs b/src/vk/text.rs
index 2210cb9..eddbf99 100644
--- a/src/vk/text.rs
+++ b/src/vk/text.rs
@@ -22,39 +22,15 @@ use gpu_allocator::vulkan::{
};
use gpu_allocator::MemoryLocation;
-use cosmic_text::{Buffer as TextBuffer, CacheKey, SwashContent};
+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;
-/// One shaped text run to draw. `left`/`top` are physical pixels and `scale`
-/// multiplies the shaped (logical) glyph positions — the same contract as
-/// the old glyphon::TextArea, where callers pass `label.x * scale`.
-pub struct TextSpan<'a> {
- pub buffer: &'a TextBuffer,
- pub left: f32,
- pub top: f32,
- pub scale: f32,
- /// Physical-pixel clip rect (left, top, right, bottom); None = whole surface.
- pub bounds: Option<[i32; 4]>,
- /// 0..=1 sRGB + alpha, applied to glyphs without their own color.
- pub default_color: [f32; 4],
- /// Rotate the span's glyph quads by (radians, center_x, center_y) in
- /// physical pixels — the circular network pane's curved rim labels.
- pub rotation: Option<(f32, f32, f32)>,
- /// Fragment circle clip (center_x, center_y, radius) in physical pixels;
- /// zero radius disables (matches shader.wgsl's clip_circle).
- pub clip_circle: [f32; 3],
- /// Rounded-rect clip half-extents (physical px). Zero keeps `clip_circle` a plain
- /// circle; non-zero reinterprets it as a rounded-rect SDF clip — center
- /// `clip_circle.xy`, corner radius `clip_circle.z`, inner box half-size
- /// `clip_extents` — so plate children (labels included) cut off at rounded corners.
- pub clip_extents: [f32; 2],
-}
-
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct GlyphVertex {
diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index dc3c0b3..481d664 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -2031,7 +2031,7 @@ impl Paint for ParametersBg {
for c in self.colors.iter_mut().flatten() {
c.prepare_text(fs);
}
- let clusters = crate::backend::window_runner::shaped_cluster_offsets(
+ let clusters = crate::backend::text::shaped_cluster_offsets(
fs,
"MMMMMMMM",
12.0,
diff --git a/src/widget/core.rs b/src/widget/core.rs
index a357049..4a8cbe6 100644
--- a/src/widget/core.rs
+++ b/src/widget/core.rs
@@ -618,7 +618,7 @@ pub mod context_menu {
.lock()
.ok()
.and_then(|mut fs| {
- crate::backend::window_runner::shaped_cluster_offsets(&mut fs, s, size, Some(&family))
+ crate::backend::text::shaped_cluster_offsets(&mut fs, s, size, Some(&family))
.last()
.map(|&(_, total)| total)
})
@@ -2131,7 +2131,7 @@ mod context_menu_padding_tests {
let (family, size) = super::context_menu::label_font();
let drawn = {
let mut fs = crate::geometry_font_system().lock().unwrap();
- crate::backend::window_runner::shaped_cluster_offsets(&mut fs, "● Follow Active Editor", size, Some(&family))
+ crate::backend::text::shaped_cluster_offsets(&mut fs, "● Follow Active Editor", size, Some(&family))
.last()
.map(|&(_, t)| t)
.unwrap()
diff --git a/src/widget/display/image_view.rs b/src/widget/display/image_view.rs
index 986ff0a..db98f05 100644
--- a/src/widget/display/image_view.rs
+++ b/src/widget/display/image_view.rs
@@ -1,7 +1,7 @@
//! `ImageView` — a GPU-textured image fitted into the widget's rect via
//! [`fit_rect`]. The view BORROWS its image id: ids come from
-//! [`crate::vk::upload_rgba`] and stay owned by the app, which frees them with
-//! [`crate::vk::free_image`] when done — the widget never uploads or frees GPU
+//! [`crate::draw::upload_rgba`] and stay owned by the app, which frees them with
+//! [`crate::draw::free_image`] when done — the widget never uploads or frees GPU
//! resources, so one id can back several views and a dropped view leaks
//! nothing. `image: None` paints only the optional letterbox floor.
//!
diff --git a/src/widget/display/label.rs b/src/widget/display/label.rs
index d41ec1c..fca7442 100644
--- a/src/widget/display/label.rs
+++ b/src/widget/display/label.rs
@@ -168,7 +168,7 @@ impl StyledLabel {
if is_vert {
final_text = text.chars().map(|c| c.to_string()).collect::<Vec<_>>().join("\n");
}
- let mut buffer = crate::backend::window_runner::get_text_buffer(fs, &final_text, size, Some(family));
+ let mut buffer = crate::backend::text::get_text_buffer(fs, &final_text, size, Some(family));
if is_vert {
let bar_thickness = crate::BAR_THICKNESS.load(std::sync::atomic::Ordering::Relaxed) as f32;
buffer.set_size(fs, Some(bar_thickness * scale as f32), None);
diff --git a/src/widget/display/list_item.rs b/src/widget/display/list_item.rs
index 6bbb820..d3bf581 100644
--- a/src/widget/display/list_item.rs
+++ b/src/widget/display/list_item.rs
@@ -33,7 +33,7 @@ impl TextItem {
font: Option<&str>,
bounds: Option<[f32; 4]>,
) -> Self {
- let buffer = crate::backend::window_runner::get_text_buffer(fs, text, size, font);
+ let buffer = crate::backend::text::get_text_buffer(fs, text, size, font);
Self { buffer, x, y, color, bounds, clip_circle: None, clip_rrect: None }
}
}
diff --git a/src/widget/display/text_label.rs b/src/widget/display/text_label.rs
index 245c18d..50579e4 100644
--- a/src/widget/display/text_label.rs
+++ b/src/widget/display/text_label.rs
@@ -75,5 +75,5 @@ impl TextLabel {
}
pub(crate) fn make_widget_text_buffer(fs: &mut cosmic_text::FontSystem, text: &str, size: f32, font_family: &str) -> cosmic_text::Buffer {
- crate::backend::window_runner::get_text_buffer(fs, text, size, Some(font_family))
+ crate::backend::text::get_text_buffer(fs, text, size, Some(font_family))
}
diff --git a/src/widget/input/button.rs b/src/widget/input/button.rs
index 3d76745..bf5b17e 100644
--- a/src/widget/input/button.rs
+++ b/src/widget/input/button.rs
@@ -267,7 +267,7 @@ impl Button {
.lock()
.ok()
.and_then(|mut fs| {
- crate::backend::window_runner::shaped_cluster_offsets(&mut fs, label, size, font.as_deref())
+ crate::backend::text::shaped_cluster_offsets(&mut fs, label, size, font.as_deref())
.last()
.map(|&(_, total)| total)
})
@@ -732,7 +732,7 @@ mod tests {
for label in ["Attach...", "Load Images", "Cancel"] {
let drawn = {
let mut fs = crate::geometry_font_system().lock().unwrap();
- crate::backend::window_runner::shaped_cluster_offsets(&mut fs, label, size, font.as_deref())
+ crate::backend::text::shaped_cluster_offsets(&mut fs, label, size, font.as_deref())
.last()
.map(|&(_, t)| t)
.unwrap()
@@ -775,7 +775,7 @@ mod tests {
// 8px in from the left.
let drawn = {
let mut fs = crate::geometry_font_system().lock().unwrap();
- crate::backend::window_runner::shaped_cluster_offsets(&mut fs, &text.0, text.2, text.3.as_deref())
+ crate::backend::text::shaped_cluster_offsets(&mut fs, &text.0, text.2, text.3.as_deref())
.last()
.map(|&(_, t)| t)
.unwrap()
diff --git a/src/widget/input/color_selector.rs b/src/widget/input/color_selector.rs
index bc063f3..56b8fff 100644
--- a/src/widget/input/color_selector.rs
+++ b/src/widget/input/color_selector.rs
@@ -190,7 +190,7 @@ impl Paint for ColorSelector {
// default family) and record char-index → x for the caret.
let text = if self.editing { self.edit_buffer.clone() } else { self.value_hex() };
let clusters =
- crate::backend::window_runner::shaped_cluster_offsets(fs, &text, 12.0, None);
+ crate::backend::text::shaped_cluster_offsets(fs, &text, 12.0, None);
let mut offsets = vec![0.0f32; text.chars().count() + 1];
for (byte, x) in clusters {
let ci = text[..byte.min(text.len())].chars().count();
diff --git a/src/widget/input/dropdown.rs b/src/widget/input/dropdown.rs
index eb0372d..0dbb62a 100644
--- a/src/widget/input/dropdown.rs
+++ b/src/widget/input/dropdown.rs
@@ -103,7 +103,7 @@ fn shaped_clusters(text: &str, font_size: f32) -> Option<Vec<(usize, f32)>> {
let font = crate::layout::control_label_font_detached();
let mut fs = crate::geometry_font_system().lock().ok()?;
let clusters =
- crate::backend::window_runner::shaped_cluster_offsets(&mut fs, text, font_size, Some(&font));
+ crate::backend::text::shaped_cluster_offsets(&mut fs, text, font_size, Some(&font));
(clusters.len() > 1 && clusters.last().is_some_and(|&(_, total)| total > 0.0)).then_some(clusters)
}
diff --git a/src/widget/input/spinbox.rs b/src/widget/input/spinbox.rs
index 77ced27..7c1a1a1 100644
--- a/src/widget/input/spinbox.rs
+++ b/src/widget/input/spinbox.rs
@@ -315,7 +315,7 @@ impl Paint for Spinbox {
// keyed by byte; the editor state is char-indexed.
let text = self.value_text();
let clusters =
- crate::backend::window_runner::shaped_cluster_offsets(fs, &text, 14.0, None);
+ crate::backend::text::shaped_cluster_offsets(fs, &text, 14.0, None);
let mut offsets = vec![0.0f32; text.chars().count() + 1];
for (byte, x) in clusters {
let ci = text[..byte.min(text.len())].chars().count();
diff --git a/src/widget/input/text_box.rs b/src/widget/input/text_box.rs
index 485f71a..0fb4647 100644
--- a/src/widget/input/text_box.rs
+++ b/src/widget/input/text_box.rs
@@ -1422,7 +1422,7 @@ impl Paint for TextBox {
display_text.to_string()
};
- let buffer = crate::backend::window_runner::get_text_buffer(fs, &render_text, self.font_size, font_fam);
+ let buffer = crate::backend::text::get_text_buffer(fs, &render_text, self.font_size, font_fam);
let char_count = render_text.chars().count();
let mut x_offsets = vec![0.0; char_count + 1];
@@ -1431,14 +1431,14 @@ impl Paint for TextBox {
// One column's advance, from the same shaping path as the labels (buffer-cached,
// so this is a lookup after the first frame per family/size).
- let probe = crate::backend::window_runner::get_text_buffer(fs, "MMMMMMMM", self.font_size, font_fam);
+ let probe = crate::backend::text::get_text_buffer(fs, "MMMMMMMM", self.font_size, font_fam);
self.shaped_char_advance = probe
.layout_runs()
.next()
.and_then(|run| run.glyphs.last().map(|g| (g.x + g.w) / scale / 8.0))
.unwrap_or(0.0);
- for (start, gx, gw) in crate::backend::window_runner::normalized_glyph_starts(&buffer, &render_text) {
+ for (start, gx, gw) in crate::backend::text::normalized_glyph_starts(&buffer, &render_text) {
let c_idx = render_text[..start.min(render_text.len())].chars().count();
if c_idx < x_offsets.len() {
x_offsets[c_idx] = gx / scale;
@@ -1480,11 +1480,11 @@ impl Paint for TextBox {
};
let (lines, _) = self.wrap_text(max_chars);
for line in &lines {
- let line_buffer = crate::backend::window_runner::get_text_buffer(fs, line, self.font_size, font_fam);
+ let line_buffer = crate::backend::text::get_text_buffer(fs, line, self.font_size, font_fam);
let n = line.chars().count();
let mut offs = vec![0.0f32; n + 1];
let mut line_total: f32 = 0.0;
- for (start, gx, gw) in crate::backend::window_runner::normalized_glyph_starts(&line_buffer, line) {
+ for (start, gx, gw) in crate::backend::text::normalized_glyph_starts(&line_buffer, line) {
let c_idx = line[..start.min(line.len())].chars().count();
if c_idx < offs.len() {
offs[c_idx] = gx / scale;
diff --git a/src/widget/shaping.rs b/src/widget/shaping.rs
index 8b8860d..c4062c4 100644
--- a/src/widget/shaping.rs
+++ b/src/widget/shaping.rs
@@ -36,12 +36,12 @@ impl Measure for ShapingMeasure {
if let Some(w) = self.cache.get(&key) {
return *w;
}
- let buf = crate::backend::window_runner::get_text_buffer_attrs(&mut self.fs, text, size, Some(font), attrs);
+ let buf = crate::backend::text::get_text_buffer_attrs(&mut self.fs, text, size, Some(font), attrs);
// The glyphs' extent, trailing spaces included — the same measure
// `offsets` ends on. (A layout run's `line_w` leaves trailing
// whitespace out, so a width taken from it disagreed with where
// the next run was placed by a space.)
- let w = crate::backend::window_runner::normalized_glyph_starts(&buf, text)
+ let w = crate::backend::text::normalized_glyph_starts(&buf, text)
.into_iter()
.map(|(_, x, w)| x + w)
.fold(0.0, f32::max)
@@ -58,8 +58,8 @@ impl ShapingMeasure {
/// combining mark) takes the cluster's start.
pub fn offsets(&mut self, text: &str, size: f32, font: &str, attrs: TextAttrs) -> Vec<(usize, f32)> {
let scale = crate::scale::scale_factor().max(0.01);
- let buf = crate::backend::window_runner::get_text_buffer_attrs(&mut self.fs, text, size, Some(font), attrs);
- let glyphs = crate::backend::window_runner::normalized_glyph_starts(&buf, text);
+ let buf = crate::backend::text::get_text_buffer_attrs(&mut self.fs, text, size, Some(font), attrs);
+ let glyphs = crate::backend::text::normalized_glyph_starts(&buf, text);
let mut starts: Vec<(usize, f32)> = Vec::with_capacity(glyphs.len() + 1);
let mut width = 0.0f32;
for (start, x, w) in glyphs {