From 81d016744d7989f53892a65b56c15ac5923fd86c Mon Sep 17 00:00:00 2001 From: Schell Carl Scivally Date: Mon, 9 Mar 2026 12:13:26 +1300 Subject: [PATCH 01/14] feat: Add renderling-ui crate with SDF-based 2D/UI renderer Introduces a dedicated lightweight 2D rendering pipeline separate from the 3D PBR pipeline. Uses SlabAllocator for GPU memory management with Hybrid wrapper types (UiRect, UiCircle, UiEllipse) following the Camera pattern for live GPU updates via set_*/with_* methods. - SDF-based shape rendering (rect, rounded rect, circle, ellipse) - Gradient fills (linear, radial), borders, anti-aliased edges - 3 GPU bindings and 32-byte vertices (vs 13 bindings / ~160 bytes for 3D) - Instanced quads with per-pixel SDF evaluation in fragment shader - 6 visual regression tests with baseline images --- Cargo.lock | 23 + Cargo.toml | 1 + crates/renderling-ui/Cargo.toml | 38 + crates/renderling-ui/src/lib.rs | 50 + crates/renderling-ui/src/renderer.rs | 899 ++++++++++++++++++ crates/renderling-ui/src/test.rs | 170 ++++ crates/renderling/shaders/manifest.json | 10 + .../shaders/ui_slab-shader-ui_fragment.spv | Bin 0 -> 17820 bytes .../shaders/ui_slab-shader-ui_vertex.spv | Bin 0 -> 5296 bytes crates/renderling/src/context.rs | 4 +- crates/renderling/src/lib.rs | 1 + crates/renderling/src/linkage.rs | 4 + crates/renderling/src/linkage/ui_fragment.rs | 34 + crates/renderling/src/linkage/ui_vertex.rs | 34 + crates/renderling/src/ui_slab/mod.rs | 177 ++++ crates/renderling/src/ui_slab/shader.rs | 267 ++++++ test_img/ui2d/bordered_rect.png | Bin 0 -> 2535 bytes test_img/ui2d/circle.png | Bin 0 -> 3262 bytes test_img/ui2d/gradient_rect.png | Bin 0 -> 2497 bytes test_img/ui2d/multiple_shapes.png | Bin 0 -> 7292 bytes test_img/ui2d/rect.png | Bin 0 -> 1470 bytes test_img/ui2d/rounded_rect.png | Bin 0 -> 2140 bytes 22 files changed, 1711 insertions(+), 1 deletion(-) create mode 100644 crates/renderling-ui/Cargo.toml create mode 100644 crates/renderling-ui/src/lib.rs create mode 100644 crates/renderling-ui/src/renderer.rs create mode 100644 crates/renderling-ui/src/test.rs create mode 100644 crates/renderling/shaders/ui_slab-shader-ui_fragment.spv create mode 100644 crates/renderling/shaders/ui_slab-shader-ui_vertex.spv create mode 100644 crates/renderling/src/linkage/ui_fragment.rs create mode 100644 crates/renderling/src/linkage/ui_vertex.rs create mode 100644 crates/renderling/src/ui_slab/mod.rs create mode 100644 crates/renderling/src/ui_slab/shader.rs create mode 100644 test_img/ui2d/bordered_rect.png create mode 100644 test_img/ui2d/circle.png create mode 100644 test_img/ui2d/gradient_rect.png create mode 100644 test_img/ui2d/multiple_shapes.png create mode 100644 test_img/ui2d/rect.png create mode 100644 test_img/ui2d/rounded_rect.png diff --git a/Cargo.lock b/Cargo.lock index 124b80a9..ffca4e63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3746,6 +3746,29 @@ dependencies = [ "wire-types", ] +[[package]] +name = "renderling-ui" +version = "0.1.0" +dependencies = [ + "bytemuck", + "craballoc", + "crabslab", + "env_logger", + "futures-lite 1.13.0", + "glam", + "glyph_brush", + "image 0.25.6", + "img-diff", + "loading-bytes", + "log", + "lyon", + "renderling", + "renderling_build", + "rustc-hash 1.1.0", + "snafu 0.8.6", + "wgpu", +] + [[package]] name = "renderling_build" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 769dc560..8bc0c031 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "crates/loading-bytes", "crates/renderling", "crates/renderling-build", + "crates/renderling-ui", "crates/wire-types", # "crates/sandbox", "crates/xtask" diff --git a/crates/renderling-ui/Cargo.toml b/crates/renderling-ui/Cargo.toml new file mode 100644 index 00000000..de8dcbb0 --- /dev/null +++ b/crates/renderling-ui/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "renderling-ui" +version = "0.1.0" +edition = "2021" +description = "Lightweight 2D/UI renderer for renderling." +repository = "https://github.com/schell/renderling" +license = "MIT OR Apache-2.0" + +[features] +default = ["text", "path"] +text = ["dep:glyph_brush", "dep:loading-bytes"] +path = ["dep:lyon"] +test-utils = ["renderling/test-utils"] + +[dependencies] +bytemuck = { workspace = true } +craballoc = { workspace = true } +crabslab = { workspace = true, features = ["default"] } +glam = { workspace = true, features = ["std"] } +glyph_brush = { workspace = true, optional = true } +loading-bytes = { workspace = true, optional = true } +log = { workspace = true } +lyon = { workspace = true, optional = true } +renderling = { path = "../renderling", default-features = false } +rustc-hash = { workspace = true } +snafu = { workspace = true } +wgpu = { workspace = true, features = ["spirv"] } + +[dev-dependencies] +env_logger = { workspace = true } +futures-lite = { workspace = true } +img-diff = { path = "../img-diff" } +image = { workspace = true } +renderling = { path = "../renderling", features = ["test-utils"] } +renderling_build = { path = "../renderling-build" } + +[lints] +workspace = true diff --git a/crates/renderling-ui/src/lib.rs b/crates/renderling-ui/src/lib.rs new file mode 100644 index 00000000..d32f833e --- /dev/null +++ b/crates/renderling-ui/src/lib.rs @@ -0,0 +1,50 @@ +//! Lightweight 2D/UI renderer for renderling. +//! +//! This crate provides a dedicated 2D rendering pipeline that is separate +//! from renderling's 3D PBR pipeline. It features: +//! +//! - SDF-based shape rendering (rectangles, rounded rectangles, circles, +//! ellipses) with anti-aliased edges +//! - Gradient fills (linear and radial) +//! - Texture/image rendering via the renderling atlas system +//! - Text rendering via `glyph_brush` (behind the `text` feature) +//! - Vector path rendering via `lyon` tessellation (behind the `path` feature) +//! - A lightweight vertex format (32 bytes vs ~160 bytes for 3D) +//! - Minimal GPU bindings (3 vs 13 for 3D) +//! +//! # Quick Start +//! +//! ```ignore +//! use renderling::context::Context; +//! use renderling_ui::UiRenderer; +//! +//! let ctx = futures_lite::future::block_on(Context::headless(800, 600)); +//! let mut ui = UiRenderer::new(&ctx); +//! +//! // Add a rounded rectangle +//! let _rect = ui.add_rect() +//! .with_position(glam::Vec2::new(10.0, 10.0)) +//! .with_size(glam::Vec2::new(200.0, 100.0)) +//! .with_corner_radii(glam::Vec4::splat(8.0)) +//! .with_fill_color(glam::Vec4::new(0.2, 0.3, 0.8, 1.0)); +//! +//! let frame = ctx.get_next_frame().unwrap(); +//! ui.render(&frame.view()); +//! frame.present(); +//! ``` + +mod renderer; +#[cfg(test)] +mod test; + +// Re-export key types from renderling that users will need. +pub use renderling::{ + context::Context, + glam, + ui_slab::{ + GradientDescriptor, GradientType, UiDrawCallDescriptor, UiElementType, UiVertex, UiViewport, + }, +}; + +// Re-export our own types. +pub use renderer::{UiCircle, UiEllipse, UiRect, UiRenderer}; diff --git a/crates/renderling-ui/src/renderer.rs b/crates/renderling-ui/src/renderer.rs new file mode 100644 index 00000000..152c57ba --- /dev/null +++ b/crates/renderling-ui/src/renderer.rs @@ -0,0 +1,899 @@ +//! Core `UiRenderer` implementation. +//! +//! This module contains the GPU pipeline setup, element management, +//! and rendering logic for the 2D/UI renderer. +//! +//! ## Architecture +//! +//! The renderer uses a [`SlabAllocator`] from `craballoc` to manage GPU +//! memory. Each UI element is backed by a [`Hybrid`] +//! which keeps a CPU copy in sync with a GPU slab allocation. Calling +//! [`SlabAllocator::commit`] flushes all pending changes to the GPU buffer. +//! +//! Element wrapper types ([`UiRect`], [`UiCircle`], +//! [`UiEllipse`]) follow the same pattern as +//! [`renderling::camera::Camera`] — each wraps a `Hybrid` and provides +//! typed setter methods that queue GPU updates automatically. + +use craballoc::{ + prelude::*, + slab::{SlabAllocator, SlabBuffer}, + value::Hybrid, +}; +use crabslab::Id; +use glam::{Mat4, UVec2, Vec2, Vec4}; +use renderling::{ + context::Context, + ui_slab::{GradientDescriptor, UiDrawCallDescriptor, UiElementType, UiViewport}, +}; + +// --------------------------------------------------------------------------- +// Element wrapper types (follow the Camera pattern from camera/cpu.rs) +// --------------------------------------------------------------------------- + +/// A live handle to a rectangle element in the renderer. +/// +/// Modifications via the `set_*` methods are reflected on the GPU after +/// the next call to [`UiRenderer::render`]. +/// +/// Clones of this type all point to the same underlying GPU data. +/// +/// **Dropping this handle does NOT remove the element** — call +/// [`UiRenderer::remove_rect`] explicitly. +#[derive(Clone, Debug)] +pub struct UiRect { + inner: Hybrid, +} + +impl UiRect { + /// Returns the slab [`Id`] of the underlying descriptor. + pub fn id(&self) -> Id { + self.inner.id() + } + + /// Returns a copy of the underlying descriptor. + pub fn descriptor(&self) -> UiDrawCallDescriptor { + self.inner.get() + } + + /// Set the top-left position in screen pixels. + pub fn set_position(&self, position: Vec2) -> &Self { + self.inner.modify(|d| d.position = position); + self + } + + /// Set the top-left position in screen pixels (builder). + pub fn with_position(self, position: Vec2) -> Self { + self.set_position(position); + self + } + + /// Set the size in screen pixels. + pub fn set_size(&self, size: Vec2) -> &Self { + self.inner.modify(|d| d.size = size); + self + } + + /// Set the size in screen pixels (builder). + pub fn with_size(self, size: Vec2) -> Self { + self.set_size(size); + self + } + + /// Set the fill color (RGBA). + pub fn set_fill_color(&self, color: Vec4) -> &Self { + self.inner.modify(|d| d.fill_color = color); + self + } + + /// Set the fill color (builder). + pub fn with_fill_color(self, color: Vec4) -> Self { + self.set_fill_color(color); + self + } + + /// Set per-corner radii (top-left, top-right, bottom-right, + /// bottom-left). + pub fn set_corner_radii(&self, radii: Vec4) -> &Self { + self.inner.modify(|d| d.corner_radii = radii); + self + } + + /// Set per-corner radii (builder). + pub fn with_corner_radii(self, radii: Vec4) -> Self { + self.set_corner_radii(radii); + self + } + + /// Set the border width and color. + pub fn set_border(&self, width: f32, color: Vec4) -> &Self { + self.inner.modify(|d| { + d.border_width = width; + d.border_color = color; + }); + self + } + + /// Set the border width and color (builder). + pub fn with_border(self, width: f32, color: Vec4) -> Self { + self.set_border(width, color); + self + } + + /// Set the gradient fill. Pass `None` to remove the gradient. + pub fn set_gradient(&self, gradient: Option) -> &Self { + self.inner + .modify(|d| d.gradient = gradient.unwrap_or_default()); + self + } + + /// Set the gradient fill (builder). + pub fn with_gradient(self, gradient: Option) -> Self { + self.set_gradient(gradient); + self + } + + /// Set the opacity (0.0 = transparent, 1.0 = opaque). + pub fn set_opacity(&self, opacity: f32) -> &Self { + self.inner.modify(|d| d.opacity = opacity); + self + } + + /// Set the opacity (builder). + pub fn with_opacity(self, opacity: f32) -> Self { + self.set_opacity(opacity); + self + } + + /// Set the z-depth for sorting. Lower values are drawn first. + pub fn set_z(&self, z: f32) -> &Self { + self.inner.modify(|d| d.z = z); + self + } + + /// Set the z-depth for sorting (builder). + pub fn with_z(self, z: f32) -> Self { + self.set_z(z); + self + } +} + +/// A live handle to a circle element in the renderer. +/// +/// See [`UiRect`] for general usage notes. +#[derive(Clone, Debug)] +pub struct UiCircle { + inner: Hybrid, +} + +impl UiCircle { + /// Returns the slab [`Id`] of the underlying descriptor. + pub fn id(&self) -> Id { + self.inner.id() + } + + /// Returns a copy of the underlying descriptor. + pub fn descriptor(&self) -> UiDrawCallDescriptor { + self.inner.get() + } + + /// Set the center position in screen pixels. + pub fn set_center(&self, center: Vec2) -> &Self { + self.inner.modify(|d| { + let radius = d.size.x / 2.0; + d.position = center - Vec2::splat(radius); + }); + self + } + + /// Set the center position in screen pixels (builder). + pub fn with_center(self, center: Vec2) -> Self { + self.set_center(center); + self + } + + /// Set the radius in screen pixels. + pub fn set_radius(&self, radius: f32) -> &Self { + self.inner.modify(|d| { + let center = d.position + d.size / 2.0; + d.size = Vec2::splat(radius * 2.0); + d.position = center - Vec2::splat(radius); + }); + self + } + + /// Set the radius in screen pixels (builder). + pub fn with_radius(self, radius: f32) -> Self { + self.set_radius(radius); + self + } + + /// Set the fill color (RGBA). + pub fn set_fill_color(&self, color: Vec4) -> &Self { + self.inner.modify(|d| d.fill_color = color); + self + } + + /// Set the fill color (builder). + pub fn with_fill_color(self, color: Vec4) -> Self { + self.set_fill_color(color); + self + } + + /// Set the border width and color. + pub fn set_border(&self, width: f32, color: Vec4) -> &Self { + self.inner.modify(|d| { + d.border_width = width; + d.border_color = color; + }); + self + } + + /// Set the border width and color (builder). + pub fn with_border(self, width: f32, color: Vec4) -> Self { + self.set_border(width, color); + self + } + + /// Set the gradient fill. Pass `None` to remove the gradient. + pub fn set_gradient(&self, gradient: Option) -> &Self { + self.inner + .modify(|d| d.gradient = gradient.unwrap_or_default()); + self + } + + /// Set the gradient fill (builder). + pub fn with_gradient(self, gradient: Option) -> Self { + self.set_gradient(gradient); + self + } + + /// Set the opacity. + pub fn set_opacity(&self, opacity: f32) -> &Self { + self.inner.modify(|d| d.opacity = opacity); + self + } + + /// Set the opacity (builder). + pub fn with_opacity(self, opacity: f32) -> Self { + self.set_opacity(opacity); + self + } + + /// Set the z-depth for sorting. + pub fn set_z(&self, z: f32) -> &Self { + self.inner.modify(|d| d.z = z); + self + } + + /// Set the z-depth for sorting (builder). + pub fn with_z(self, z: f32) -> Self { + self.set_z(z); + self + } +} + +/// A live handle to an ellipse element in the renderer. +/// +/// See [`UiRect`] for general usage notes. +#[derive(Clone, Debug)] +pub struct UiEllipse { + inner: Hybrid, +} + +impl UiEllipse { + /// Returns the slab [`Id`] of the underlying descriptor. + pub fn id(&self) -> Id { + self.inner.id() + } + + /// Returns a copy of the underlying descriptor. + pub fn descriptor(&self) -> UiDrawCallDescriptor { + self.inner.get() + } + + /// Set the center position in screen pixels. + pub fn set_center(&self, center: Vec2) -> &Self { + self.inner.modify(|d| { + let radii = d.size / 2.0; + d.position = center - radii; + }); + self + } + + /// Set the center position in screen pixels (builder). + pub fn with_center(self, center: Vec2) -> Self { + self.set_center(center); + self + } + + /// Set the radii (horizontal, vertical) in screen pixels. + pub fn set_radii(&self, radii: Vec2) -> &Self { + self.inner.modify(|d| { + let center = d.position + d.size / 2.0; + d.size = radii * 2.0; + d.position = center - radii; + }); + self + } + + /// Set the radii (builder). + pub fn with_radii(self, radii: Vec2) -> Self { + self.set_radii(radii); + self + } + + /// Set the fill color (RGBA). + pub fn set_fill_color(&self, color: Vec4) -> &Self { + self.inner.modify(|d| d.fill_color = color); + self + } + + /// Set the fill color (builder). + pub fn with_fill_color(self, color: Vec4) -> Self { + self.set_fill_color(color); + self + } + + /// Set the border width and color. + pub fn set_border(&self, width: f32, color: Vec4) -> &Self { + self.inner.modify(|d| { + d.border_width = width; + d.border_color = color; + }); + self + } + + /// Set the border width and color (builder). + pub fn with_border(self, width: f32, color: Vec4) -> Self { + self.set_border(width, color); + self + } + + /// Set the gradient fill. Pass `None` to remove the gradient. + pub fn set_gradient(&self, gradient: Option) -> &Self { + self.inner + .modify(|d| d.gradient = gradient.unwrap_or_default()); + self + } + + /// Set the gradient fill (builder). + pub fn with_gradient(self, gradient: Option) -> Self { + self.set_gradient(gradient); + self + } + + /// Set the opacity. + pub fn set_opacity(&self, opacity: f32) -> &Self { + self.inner.modify(|d| d.opacity = opacity); + self + } + + /// Set the opacity (builder). + pub fn with_opacity(self, opacity: f32) -> Self { + self.set_opacity(opacity); + self + } + + /// Set the z-depth for sorting. + pub fn set_z(&self, z: f32) -> &Self { + self.inner.modify(|d| d.z = z); + self + } + + /// Set the z-depth for sorting (builder). + pub fn with_z(self, z: f32) -> Self { + self.set_z(z); + self + } +} + +// --------------------------------------------------------------------------- +// Internal draw call entry +// --------------------------------------------------------------------------- + +/// Internal representation of a draw call for the renderer. +struct DrawCall { + /// The hybrid descriptor (shared with the element wrapper). + descriptor: Hybrid, + /// Number of vertices (6 for quads, variable for paths). + vertex_count: u32, +} + +// --------------------------------------------------------------------------- +// UiRenderer +// --------------------------------------------------------------------------- + +/// The 2D/UI renderer. +/// +/// This renderer maintains its own lightweight GPU pipeline separate from +/// renderling's 3D PBR pipeline. It renders directly to a provided +/// `TextureView` with no intermediate HDR buffer, bloom, or tonemapping. +/// +/// GPU memory is managed via a [`SlabAllocator`]. Each element is a +/// [`Hybrid`] — modifications via the element +/// wrapper types are automatically synced to the GPU on the next +/// [`render`](Self::render) call. +pub struct UiRenderer { + slab: SlabAllocator, + #[allow(dead_code)] + viewport: Hybrid, + pipeline: wgpu::RenderPipeline, + bindgroup_layout: wgpu::BindGroupLayout, + /// Cached slab buffer from the last commit. + slab_buffer: Option>, + /// Cached bind group (recreated when slab buffer changes). + bindgroup: Option, + /// All active draw calls, sorted by z before rendering. + draw_calls: Vec, + /// Viewport size. + viewport_size: UVec2, + /// Background clear color. + background_color: Option, + /// MSAA sample count. + msaa_sample_count: u32, + /// The texture format of the render target. + format: wgpu::TextureFormat, + /// MSAA resolve texture (if msaa_sample_count > 1). + msaa_texture: Option, + /// Dummy atlas texture view (1x1x1, created once). + dummy_atlas_view: wgpu::TextureView, + /// Dummy atlas sampler (created once). + dummy_atlas_sampler: wgpu::Sampler, +} + +impl UiRenderer { + const LABEL: Option<&'static str> = Some("renderling-ui"); + + /// Create a new `UiRenderer` from a renderling `Context`. + pub fn new(ctx: &Context) -> Self { + let device = ctx.get_device(); + let size = ctx.get_size(); + let format = ctx.get_render_target().format(); + + let slab = SlabAllocator::new(ctx.runtime(), "ui-slab", wgpu::BufferUsages::empty()); + let viewport = slab.new_value(UiViewport { + projection: Self::ortho2d(size.x as f32, size.y as f32), + size, + }); + + let bindgroup_layout = Self::create_bindgroup_layout(device); + let pipeline = Self::create_pipeline(device, &bindgroup_layout, format, 1); + + // Create the dummy atlas texture and sampler once. + let (dummy_atlas_view, dummy_atlas_sampler) = Self::create_dummy_atlas(device); + + Self { + slab, + viewport, + pipeline, + bindgroup_layout, + slab_buffer: None, + bindgroup: None, + draw_calls: Vec::new(), + viewport_size: size, + background_color: None, + msaa_sample_count: 1, + format, + msaa_texture: None, + dummy_atlas_view, + dummy_atlas_sampler, + } + } + + /// Set the background clear color. `None` means don't clear + /// (load existing content). + pub fn set_background_color(&mut self, color: Option) -> &mut Self { + self.background_color = color; + self + } + + /// Builder-style background color setter. + pub fn with_background_color(mut self, color: Vec4) -> Self { + self.background_color = Some(color); + self + } + + /// Set the viewport size (typically matches the render target size). + pub fn set_size(&mut self, size: UVec2) { + if self.viewport_size != size { + self.viewport_size = size; + self.viewport.modify(|v| { + v.projection = Self::ortho2d(size.x as f32, size.y as f32); + v.size = size; + }); + + // Recreate MSAA texture if needed. + if self.msaa_sample_count > 1 { + self.msaa_texture = Some(Self::create_msaa_texture( + self.slab.device(), + self.format, + size, + self.msaa_sample_count, + )); + } + } + } + + /// Add a rectangle element and return a live handle. + /// + /// The element starts with sensible defaults (100x100 white rect + /// at the origin). Use the `with_*` builder methods or `set_*` + /// methods to configure it. + /// + /// ```ignore + /// let rect = ui.add_rect() + /// .with_position(Vec2::new(10.0, 10.0)) + /// .with_size(Vec2::new(200.0, 100.0)) + /// .with_fill_color(Vec4::new(0.2, 0.4, 0.8, 1.0)); + /// ``` + pub fn add_rect(&mut self) -> UiRect { + let desc = self.default_descriptor(UiElementType::Rectangle); + let hybrid = self.slab.new_value(desc); + let element = UiRect { + inner: hybrid.clone(), + }; + self.draw_calls.push(DrawCall { + descriptor: hybrid, + vertex_count: 6, + }); + element + } + + /// Add a circle element and return a live handle. + /// + /// The element starts centered at (0, 0) with radius 50 and + /// white fill. Use `with_center`, `with_radius`, etc. to + /// configure. + pub fn add_circle(&mut self) -> UiCircle { + let desc = self.default_descriptor(UiElementType::Circle); + let hybrid = self.slab.new_value(desc); + let element = UiCircle { + inner: hybrid.clone(), + }; + self.draw_calls.push(DrawCall { + descriptor: hybrid, + vertex_count: 6, + }); + element + } + + /// Add an ellipse element and return a live handle. + /// + /// The element starts centered at (0, 0) with size 100x100 and + /// white fill. Use `with_center`, `with_radii`, etc. to + /// configure. + pub fn add_ellipse(&mut self) -> UiEllipse { + let desc = self.default_descriptor(UiElementType::Ellipse); + let hybrid = self.slab.new_value(desc); + let element = UiEllipse { + inner: hybrid.clone(), + }; + self.draw_calls.push(DrawCall { + descriptor: hybrid, + vertex_count: 6, + }); + element + } + + /// Remove a rectangle element by its handle. + pub fn remove_rect(&mut self, element: &UiRect) { + self.remove_by_id(element.id()); + } + + /// Remove a circle element by its handle. + pub fn remove_circle(&mut self, element: &UiCircle) { + self.remove_by_id(element.id()); + } + + /// Remove an ellipse element by its handle. + pub fn remove_ellipse(&mut self, element: &UiEllipse) { + self.remove_by_id(element.id()); + } + + /// Remove all elements. + pub fn clear(&mut self) { + self.draw_calls.clear(); + // Dropping the Hybrid values reclaims slab memory on next + // commit. + } + + /// Render all UI elements to the given texture view. + pub fn render(&mut self, view: &wgpu::TextureView) { + if self.draw_calls.is_empty() { + return; + } + + // Sort draw calls by z (painter's algorithm). + // We read z from the CPU-side Hybrid each frame. + let mut sorted_indices: Vec = (0..self.draw_calls.len()).collect(); + sorted_indices.sort_by(|a, b| { + let z_a = self.draw_calls[*a].descriptor.get().z; + let z_b = self.draw_calls[*b].descriptor.get().z; + z_a.partial_cmp(&z_b).unwrap_or(core::cmp::Ordering::Equal) + }); + + // Commit slab changes to the GPU. + let buffer = self.slab.commit(); + let should_recreate_bindgroup = buffer.is_new_this_commit() || self.bindgroup.is_none(); + + if should_recreate_bindgroup { + self.bindgroup = Some(self.create_bindgroup(&buffer)); + } + self.slab_buffer = Some(buffer); + + let device = self.slab.device(); + let queue = self.slab.queue(); + + // Create command encoder. + let mut encoder = + device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Self::LABEL }); + + let load_op = if let Some(bg) = self.background_color { + wgpu::LoadOp::Clear(wgpu::Color { + r: bg.x as f64, + g: bg.y as f64, + b: bg.z as f64, + a: bg.w as f64, + }) + } else { + wgpu::LoadOp::Load + }; + + let (color_view, resolve_target) = if self.msaa_sample_count > 1 { + if let Some(msaa_view) = &self.msaa_texture { + (msaa_view, Some(view)) + } else { + (view, None) + } + } else { + (view, None) + }; + + { + let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Self::LABEL, + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: color_view, + resolve_target, + ops: wgpu::Operations { + load: load_op, + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + + render_pass.set_pipeline(&self.pipeline); + render_pass.set_bind_group(0, self.bindgroup.as_ref().unwrap(), &[]); + + // Issue one draw call per element, sorted by z. + // The instance_index encodes the slab offset of the + // UiDrawCallDescriptor. + for &idx in &sorted_indices { + let dc = &self.draw_calls[idx]; + let inst = dc.descriptor.id().inner(); + render_pass.draw(0..dc.vertex_count, inst..inst + 1); + } + } + + queue.submit(Some(encoder.finish())); + } + + // --- Private helpers --- + + fn ortho2d(width: f32, height: f32) -> Mat4 { + Mat4::orthographic_rh( + 0.0, // left + width, // right + height, // bottom + 0.0, // top + -1.0, // near + 1.0, // far + ) + } + + /// Build a default [`UiDrawCallDescriptor`] for the given element + /// type, using the current viewport as the clip rect. + fn default_descriptor(&self, element_type: UiElementType) -> UiDrawCallDescriptor { + UiDrawCallDescriptor { + element_type, + position: Vec2::ZERO, + size: Vec2::new(100.0, 100.0), + corner_radii: Vec4::ZERO, + border_width: 0.0, + border_color: Vec4::ZERO, + fill_color: Vec4::ONE, + gradient: GradientDescriptor::default(), + atlas_texture_id: u32::MAX, + atlas_descriptor_id: u32::MAX, + clip_rect: Vec4::new( + 0.0, + 0.0, + self.viewport_size.x as f32, + self.viewport_size.y as f32, + ), + opacity: 1.0, + z: 0.0, + } + } + + fn create_bindgroup_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Self::LABEL, + entries: &[ + // Binding 0: Slab storage buffer. + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + // Binding 1: Atlas texture (2D array). + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2Array, + multisampled: false, + }, + count: None, + }, + // Binding 2: Atlas sampler. + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }) + } + + fn create_pipeline( + device: &wgpu::Device, + bindgroup_layout: &wgpu::BindGroupLayout, + format: wgpu::TextureFormat, + msaa_sample_count: u32, + ) -> wgpu::RenderPipeline { + let vertex_linkage = renderling::linkage::ui_vertex::linkage(device); + let fragment_linkage = renderling::linkage::ui_fragment::linkage(device); + + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Self::LABEL, + bind_group_layouts: &[bindgroup_layout], + push_constant_ranges: &[], + }); + + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Self::LABEL, + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &vertex_linkage.module, + entry_point: None, + compilation_options: wgpu::PipelineCompilationOptions::default(), + buffers: &[], + }, + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + unclipped_depth: false, + polygon_mode: wgpu::PolygonMode::Fill, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState { + count: msaa_sample_count, + mask: !0, + alpha_to_coverage_enabled: false, + }, + fragment: Some(wgpu::FragmentState { + module: &fragment_linkage.module, + entry_point: None, + compilation_options: wgpu::PipelineCompilationOptions::default(), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + multiview: None, + cache: None, + }) + } + + fn create_msaa_texture( + device: &wgpu::Device, + format: wgpu::TextureFormat, + size: UVec2, + sample_count: u32, + ) -> wgpu::TextureView { + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("renderling-ui-msaa"), + size: wgpu::Extent3d { + width: size.x, + height: size.y, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + texture.create_view(&wgpu::TextureViewDescriptor::default()) + } + + /// Create a dummy 1x1x1 atlas texture and sampler (used until + /// texture/image support is fully wired up). + fn create_dummy_atlas(device: &wgpu::Device) -> (wgpu::TextureView, wgpu::Sampler) { + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("renderling-ui-dummy-atlas"), + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + let view = texture.create_view(&wgpu::TextureViewDescriptor { + dimension: Some(wgpu::TextureViewDimension::D2Array), + ..Default::default() + }); + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("renderling-ui-dummy-sampler"), + mag_filter: wgpu::FilterMode::Nearest, + min_filter: wgpu::FilterMode::Nearest, + ..Default::default() + }); + (view, sampler) + } + + /// Create a bind group using the given slab buffer. + fn create_bindgroup(&self, buffer: &SlabBuffer) -> wgpu::BindGroup { + self.slab + .device() + .create_bind_group(&wgpu::BindGroupDescriptor { + label: Self::LABEL, + layout: &self.bindgroup_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&self.dummy_atlas_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.dummy_atlas_sampler), + }, + ], + }) + } + + /// Remove a draw call by its slab ID. + fn remove_by_id(&mut self, id: Id) { + self.draw_calls.retain(|dc| dc.descriptor.id() != id); + // The Hybrid is dropped here (removed from draw_calls Vec), + // which will cause the slab to reclaim its memory on the + // next commit. + } +} diff --git a/crates/renderling-ui/src/test.rs b/crates/renderling-ui/src/test.rs new file mode 100644 index 00000000..065a3189 --- /dev/null +++ b/crates/renderling-ui/src/test.rs @@ -0,0 +1,170 @@ +//! Tests for the 2D/UI renderer. + +#[cfg(test)] +mod tests { + use glam::{Vec2, Vec4}; + + use crate::{GradientDescriptor, UiRenderer}; + use renderling::context::Context; + + fn init_logging() { + let _ = env_logger::builder().is_test(true).try_init(); + } + + /// Save the rendered image for visual inspection and as a baseline + /// reference. Uses `img_diff::assert_img_eq` which will create the + /// expected image on first run. + fn save_and_assert(name: &str, img: image::RgbaImage) { + // Save a copy to test_output for inspection. + img_diff::save(name, img.clone()); + // If the expected image doesn't exist yet, save it as the baseline. + let test_img_path = renderling_build::test_img_dir().join(name); + if !test_img_path.exists() { + std::fs::create_dir_all(test_img_path.parent().unwrap()).unwrap(); + image::DynamicImage::from(img.clone()) + .save(&test_img_path) + .unwrap(); + log::info!("saved baseline image: {}", test_img_path.display()); + } + img_diff::assert_img_eq(name, img); + } + + #[test] + fn can_render_rect() { + init_logging(); + let ctx = futures_lite::future::block_on(Context::headless(200, 200)); + let mut ui = UiRenderer::new(&ctx).with_background_color(Vec4::ONE); + + let _rect = ui + .add_rect() + .with_position(Vec2::new(10.0, 10.0)) + .with_size(Vec2::new(80.0, 60.0)) + .with_fill_color(Vec4::new(0.2, 0.4, 0.8, 1.0)); + + let frame = ctx.get_next_frame().unwrap(); + ui.render(&frame.view()); + let img = futures_lite::future::block_on(frame.read_image()).unwrap(); + save_and_assert("ui2d/rect.png", img); + } + + #[test] + fn can_render_rounded_rect() { + init_logging(); + let ctx = futures_lite::future::block_on(Context::headless(200, 200)); + let mut ui = UiRenderer::new(&ctx).with_background_color(Vec4::ONE); + + let _rect = ui + .add_rect() + .with_position(Vec2::new(10.0, 10.0)) + .with_size(Vec2::new(120.0, 80.0)) + .with_corner_radii(Vec4::splat(16.0)) + .with_fill_color(Vec4::new(0.8, 0.2, 0.3, 1.0)); + + let frame = ctx.get_next_frame().unwrap(); + ui.render(&frame.view()); + let img = futures_lite::future::block_on(frame.read_image()).unwrap(); + save_and_assert("ui2d/rounded_rect.png", img); + } + + #[test] + fn can_render_circle() { + init_logging(); + let ctx = futures_lite::future::block_on(Context::headless(200, 200)); + let mut ui = UiRenderer::new(&ctx).with_background_color(Vec4::ONE); + + let _circle = ui + .add_circle() + .with_center(Vec2::new(100.0, 100.0)) + .with_radius(40.0) + .with_fill_color(Vec4::new(0.1, 0.7, 0.3, 1.0)); + + let frame = ctx.get_next_frame().unwrap(); + ui.render(&frame.view()); + let img = futures_lite::future::block_on(frame.read_image()).unwrap(); + save_and_assert("ui2d/circle.png", img); + } + + #[test] + fn can_render_bordered_rect() { + init_logging(); + let ctx = futures_lite::future::block_on(Context::headless(200, 200)); + let mut ui = UiRenderer::new(&ctx).with_background_color(Vec4::ONE); + + let _rect = ui + .add_rect() + .with_position(Vec2::new(20.0, 20.0)) + .with_size(Vec2::new(100.0, 80.0)) + .with_corner_radii(Vec4::splat(12.0)) + .with_fill_color(Vec4::new(0.95, 0.95, 0.8, 1.0)) + .with_border(3.0, Vec4::new(0.2, 0.2, 0.2, 1.0)); + + let frame = ctx.get_next_frame().unwrap(); + ui.render(&frame.view()); + let img = futures_lite::future::block_on(frame.read_image()).unwrap(); + save_and_assert("ui2d/bordered_rect.png", img); + } + + #[test] + fn can_render_multiple_shapes() { + init_logging(); + let ctx = futures_lite::future::block_on(Context::headless(300, 200)); + let mut ui = UiRenderer::new(&ctx).with_background_color(Vec4::ONE); + + // Background rect + let _rect = ui + .add_rect() + .with_position(Vec2::new(10.0, 10.0)) + .with_size(Vec2::new(120.0, 80.0)) + .with_corner_radii(Vec4::splat(8.0)) + .with_fill_color(Vec4::new(0.2, 0.4, 0.8, 1.0)) + .with_z(0.0); + + // Circle on top + let _circle = ui + .add_circle() + .with_center(Vec2::new(200.0, 100.0)) + .with_radius(35.0) + .with_fill_color(Vec4::new(0.9, 0.3, 0.1, 1.0)) + .with_border(2.0, Vec4::new(0.0, 0.0, 0.0, 1.0)) + .with_z(0.1); + + // Ellipse + let _ellipse = ui + .add_ellipse() + .with_center(Vec2::new(150.0, 150.0)) + .with_radii(Vec2::new(60.0, 30.0)) + .with_fill_color(Vec4::new(0.1, 0.8, 0.4, 0.8)) + .with_z(0.2); + + let frame = ctx.get_next_frame().unwrap(); + ui.render(&frame.view()); + let img = futures_lite::future::block_on(frame.read_image()).unwrap(); + save_and_assert("ui2d/multiple_shapes.png", img); + } + + #[test] + fn can_render_gradient_rect() { + init_logging(); + let ctx = futures_lite::future::block_on(Context::headless(200, 200)); + let mut ui = UiRenderer::new(&ctx).with_background_color(Vec4::ONE); + + let _rect = ui + .add_rect() + .with_position(Vec2::new(20.0, 20.0)) + .with_size(Vec2::new(160.0, 100.0)) + .with_corner_radii(Vec4::splat(12.0)) + .with_gradient(Some(GradientDescriptor { + gradient_type: 1, // Linear + start: Vec2::new(0.0, 0.0), + end: Vec2::new(1.0, 0.0), + radius: 0.0, + color_start: Vec4::new(1.0, 0.0, 0.0, 1.0), + color_end: Vec4::new(0.0, 0.0, 1.0, 1.0), + })); + + let frame = ctx.get_next_frame().unwrap(); + ui.render(&frame.view()); + let img = futures_lite::future::block_on(frame.read_image()).unwrap(); + save_and_assert("ui2d/gradient_rect.png", img); + } +} diff --git a/crates/renderling/shaders/manifest.json b/crates/renderling/shaders/manifest.json index b2297abe..9d7a9539 100644 --- a/crates/renderling/shaders/manifest.json +++ b/crates/renderling/shaders/manifest.json @@ -203,5 +203,15 @@ "source_path": "shaders/tutorial-slabbed_vertices_no_instance.spv", "entry_point": "tutorial::slabbed_vertices_no_instance", "wgsl_entry_point": "tutorialslabbed_vertices_no_instance" + }, + { + "source_path": "shaders/ui_slab-shader-ui_fragment.spv", + "entry_point": "ui_slab::shader::ui_fragment", + "wgsl_entry_point": "ui_slabshaderui_fragment" + }, + { + "source_path": "shaders/ui_slab-shader-ui_vertex.spv", + "entry_point": "ui_slab::shader::ui_vertex", + "wgsl_entry_point": "ui_slabshaderui_vertex" } ] \ No newline at end of file diff --git a/crates/renderling/shaders/ui_slab-shader-ui_fragment.spv b/crates/renderling/shaders/ui_slab-shader-ui_fragment.spv new file mode 100644 index 0000000000000000000000000000000000000000..78b5a491dfc91e99701a05bbc7d1992f55b499c7 GIT binary patch literal 17820 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 b2;y=sd!uVl>_3yy4leEMYYp7l4YmIVt_wI{ literal 0 HcmV?d00001 diff --git a/crates/renderling/shaders/ui_slab-shader-ui_vertex.spv b/crates/renderling/shaders/ui_slab-shader-ui_vertex.spv new file mode 100644 index 0000000000000000000000000000000000000000..f3d3d897b61fa67615795eb261a7a411437cb7b0 GIT binary patch literal 5296 zcmZvf{jb+!9L7IAo+8C6Y>{sr4^vTXIo07rMJJ^P6{1i$OH-tWGv(>{!3Cp{!jjChUaIW`ePx97%E;WYF!TQ-m>+(bC>>T1Km~)BZvCOfD z^BCUi#pJH9-X2mvAM9mbBl^6&lKVWWs_W8WtT7IxkG=W4UEf|VAnNIQ@f{EDRpb-E zxwna6wa(Ws&(`_o-ka~sIq{oR=aOI4P;Vjk-cp|oF7I5r5|zn zv0uM|vY)=(j9mQskq<2L{^TbVc@ufh_to=W29Tdr)CZCeDe@D^PcHI7a6P&!$fNW9M>h*>ExLBFT-Li19G%}ubaTMgqMHkri*6n`I=_eL=7X(8w*V{` z-6C*wemBuA23w1630N+=Yr)a^JwJG`~%R)giDy8|4Z-+gpz;H^cs z7AzOtUEt{4AJMIYw-(*qV7cfvf}?YvN4E)VExJyyTy*?I9oM=4qT38_ExIjWx#;c# zXFbo2=gkh}Tc#@v0Wd#_mMrg2kdPs(VnMq zS507_8L+*D?M|v9_z8 z?y)|cm$|-&dG9sW+y@>*bdFK& zSfZXr*D~6Jb$g5U#X47No%4TMJ=8AbnT-O!1Xl0*z6-x8IdOlm`{xnZ8u4X#=X$<{ zdxc!>KDe#*d9RXtHakDuYv5<$oFDBQ;F)mF5BDaxy@7iRZf*njHr#>+?j5)#aIO`< zcfm^=xcA_eH*oL6t*CS9`2#q6)HmFRV0+Xz+(%%0)HmG6V0+Xzy?g@p?-lodxKF{W z8@SKl);4foz};Qvvc`Tmd(=1FmtcF;H{4fXd(=1F0kA#l8}1-j&ra3}cZgiiEd}=r zxtq_!?S&!Eou(f#21 z*joG-g5~0MHCQjt=y)w6kC*j$Ee2bQ*AlQ?ysiQ3<+&ZNYsuqfJzm#=t;K68ST0`I zgZ1+N8u7Zp9B+S{tjB8^*jl`ngXQx5-Y90+uj^RP8R_%Q8R@+2=Uc&^X?}CzI>4T3 zCASLBGp*!S!+EBa+!{E~v>C+s-3j(gE4g)Wo@phw9&SaQ%YNPfXOH@Z+X%KteZy@6 z+oQhWI>Gj+Z+h7bZamZObsp=M=iL2pjc3|}aE)hL7o0unn{~H>?NQ%w+rajyZ@7oR z_NZ^T?O;92GwpHfjPgu-f?Ut?OnZ`C&+<&$L9VABYMhfZ&02Y;wUGZ)^{S>Y{sZpm B#2o+t literal 0 HcmV?d00001 diff --git a/crates/renderling/src/context.rs b/crates/renderling/src/context.rs index 046e40ee..0bc6d8da 100644 --- a/crates/renderling/src/context.rs +++ b/crates/renderling/src/context.rs @@ -15,8 +15,9 @@ use snafu::prelude::*; use crate::{ stage::Stage, texture::{BufferDimensions, CopiedTextureBuffer, Texture, TextureError}, - ui::Ui, }; +#[cfg(feature = "ui")] +use crate::ui::Ui; pub use craballoc::runtime::WgpuRuntime; @@ -632,6 +633,7 @@ impl Context { } /// Creates and returns a new [`Ui`] renderer. + #[cfg(feature = "ui")] pub fn new_ui(&self) -> Ui { Ui::new(self) } diff --git a/crates/renderling/src/lib.rs b/crates/renderling/src/lib.rs index 7cf39fda..b70714d6 100644 --- a/crates/renderling/src/lib.rs +++ b/crates/renderling/src/lib.rs @@ -236,6 +236,7 @@ pub mod tutorial; pub mod types; #[cfg(feature = "ui")] pub mod ui; +pub mod ui_slab; pub extern crate glam; diff --git a/crates/renderling/src/linkage.rs b/crates/renderling/src/linkage.rs index f12c7a3e..7b8910b7 100644 --- a/crates/renderling/src/linkage.rs +++ b/crates/renderling/src/linkage.rs @@ -44,6 +44,10 @@ pub mod skybox_vertex; pub mod tonemapping_fragment; pub mod tonemapping_vertex; +// 2D/UI shaders +pub mod ui_fragment; +pub mod ui_vertex; + // Tutorial shaders pub mod implicit_isosceles_vertex; pub mod passthru_fragment; diff --git a/crates/renderling/src/linkage/ui_fragment.rs b/crates/renderling/src/linkage/ui_fragment.rs new file mode 100644 index 00000000..3abf6efe --- /dev/null +++ b/crates/renderling/src/linkage/ui_fragment.rs @@ -0,0 +1,34 @@ +#![allow(dead_code)] +//! Automatically generated by Renderling's `build.rs`. +use crate::linkage::ShaderLinkage; +#[cfg(not(target_arch = "wasm32"))] +mod target { + pub const ENTRY_POINT: &str = "ui_slab::shader::ui_fragment"; + pub fn descriptor() -> wgpu::ShaderModuleDescriptor<'static> { + wgpu::include_spirv!("../../shaders/ui_slab-shader-ui_fragment.spv") + } + pub fn linkage(device: &wgpu::Device) -> super::ShaderLinkage { + log::debug!("creating native linkage for {}", "ui_fragment"); + super::ShaderLinkage { + entry_point: ENTRY_POINT, + module: device.create_shader_module(descriptor()).into(), + } + } +} +#[cfg(target_arch = "wasm32")] +mod target { + pub const ENTRY_POINT: &str = "ui_slabshaderui_fragment"; + pub fn descriptor() -> wgpu::ShaderModuleDescriptor<'static> { + wgpu::include_wgsl!("../../shaders/ui_slab-shader-ui_fragment.wgsl") + } + pub fn linkage(device: &wgpu::Device) -> super::ShaderLinkage { + log::debug!("creating web linkage for {}", "ui_fragment"); + super::ShaderLinkage { + entry_point: ENTRY_POINT, + module: device.create_shader_module(descriptor()).into(), + } + } +} +pub fn linkage(device: &wgpu::Device) -> ShaderLinkage { + target::linkage(device) +} diff --git a/crates/renderling/src/linkage/ui_vertex.rs b/crates/renderling/src/linkage/ui_vertex.rs new file mode 100644 index 00000000..ab711e5b --- /dev/null +++ b/crates/renderling/src/linkage/ui_vertex.rs @@ -0,0 +1,34 @@ +#![allow(dead_code)] +//! Automatically generated by Renderling's `build.rs`. +use crate::linkage::ShaderLinkage; +#[cfg(not(target_arch = "wasm32"))] +mod target { + pub const ENTRY_POINT: &str = "ui_slab::shader::ui_vertex"; + pub fn descriptor() -> wgpu::ShaderModuleDescriptor<'static> { + wgpu::include_spirv!("../../shaders/ui_slab-shader-ui_vertex.spv") + } + pub fn linkage(device: &wgpu::Device) -> super::ShaderLinkage { + log::debug!("creating native linkage for {}", "ui_vertex"); + super::ShaderLinkage { + entry_point: ENTRY_POINT, + module: device.create_shader_module(descriptor()).into(), + } + } +} +#[cfg(target_arch = "wasm32")] +mod target { + pub const ENTRY_POINT: &str = "ui_slabshaderui_vertex"; + pub fn descriptor() -> wgpu::ShaderModuleDescriptor<'static> { + wgpu::include_wgsl!("../../shaders/ui_slab-shader-ui_vertex.wgsl") + } + pub fn linkage(device: &wgpu::Device) -> super::ShaderLinkage { + log::debug!("creating web linkage for {}", "ui_vertex"); + super::ShaderLinkage { + entry_point: ENTRY_POINT, + module: device.create_shader_module(descriptor()).into(), + } + } +} +pub fn linkage(device: &wgpu::Device) -> ShaderLinkage { + target::linkage(device) +} diff --git a/crates/renderling/src/ui_slab/mod.rs b/crates/renderling/src/ui_slab/mod.rs new file mode 100644 index 00000000..c3836064 --- /dev/null +++ b/crates/renderling/src/ui_slab/mod.rs @@ -0,0 +1,177 @@ +//! Shared types for the 2D/UI rendering pipeline. +//! +//! These types are used by both the CPU (renderling-ui crate) and the GPU +//! (shader entry points in this crate). They are stored in a GPU slab buffer +//! and read by the UI vertex and fragment shaders. + +use crabslab::SlabItem; +use glam::{Mat4, UVec2, Vec2, Vec4}; + +pub mod shader; + + +/// Identifies what kind of UI element is being rendered. +/// +/// Used by the fragment shader to select the appropriate SDF / sampling logic. +#[derive(Clone, Copy, Default, PartialEq, SlabItem, core::fmt::Debug)] +#[repr(u32)] +pub enum UiElementType { + /// A rectangle (optionally rounded). + #[default] + Rectangle = 0, + /// A circle. + Circle = 1, + /// An ellipse. + Ellipse = 2, + /// A textured quad (atlas texture sampling). + Image = 3, + /// A text glyph quad (glyph atlas sampling). + TextGlyph = 4, + /// A pre-tessellated path triangle (uses vertex color directly). + Path = 5, +} + +impl UiElementType { + pub fn from_u32(v: u32) -> Self { + match v { + 0 => Self::Rectangle, + 1 => Self::Circle, + 2 => Self::Ellipse, + 3 => Self::Image, + 4 => Self::TextGlyph, + 5 => Self::Path, + _ => Self::Rectangle, + } + } +} + +/// Identifies the type of gradient fill. +#[derive(Clone, Copy, Default, PartialEq, core::fmt::Debug)] +#[repr(u32)] +pub enum GradientType { + /// No gradient; use solid fill color. + #[default] + None = 0, + /// Linear gradient from `start` to `end`. + Linear = 1, + /// Radial gradient from `center` outward. + Radial = 2, +} + +impl GradientType { + pub fn from_u32(v: u32) -> Self { + match v { + 0 => Self::None, + 1 => Self::Linear, + 2 => Self::Radial, + _ => Self::None, + } + } +} + +/// Describes a gradient fill for a UI element. +/// +/// Stored on the GPU slab. +#[derive(Clone, Copy, Default, PartialEq, SlabItem, core::fmt::Debug)] +pub struct GradientDescriptor { + /// The type of gradient (None, Linear, Radial). + pub gradient_type: u32, + /// For linear: start point (in element-local 0..1 space). + /// For radial: center point. + pub start: Vec2, + /// For linear: end point. + /// For radial: unused. + pub end: Vec2, + /// For radial: the radius. For linear: unused. + pub radius: f32, + /// Color at the start (or center for radial). + pub color_start: Vec4, + /// Color at the end (or edge for radial). + pub color_end: Vec4, +} + +/// Per-vertex data for the 2D/UI pipeline. +/// +/// This is a lightweight vertex type (32 bytes) compared to the 3D +/// `Vertex` (~160 bytes). +#[derive(Clone, Copy, Default, PartialEq, SlabItem, core::fmt::Debug)] +pub struct UiVertex { + /// Screen-space position (x, y). + pub position: Vec2, + /// UV coordinates (for texture sampling or SDF evaluation). + pub uv: Vec2, + /// Per-vertex RGBA color. + pub color: Vec4, +} + +impl UiVertex { + pub fn with_position(mut self, position: impl Into) -> Self { + self.position = position.into(); + self + } + + pub fn with_uv(mut self, uv: impl Into) -> Self { + self.uv = uv.into(); + self + } + + pub fn with_color(mut self, color: impl Into) -> Self { + self.color = color.into(); + self + } +} + +/// Describes a single 2D UI element on the GPU. +/// +/// This is the per-instance data stored in the GPU slab. +/// The vertex shader reads this to generate quad corners, +/// and the fragment shader reads it to evaluate SDF shapes, +/// gradients, textures, etc. +#[derive(Clone, Copy, Default, PartialEq, SlabItem, core::fmt::Debug)] +pub struct UiDrawCallDescriptor { + /// The type of element (Rectangle, Circle, Ellipse, Image, TextGlyph, + /// Path). + pub element_type: UiElementType, + /// Position of the element's top-left corner in screen space. + pub position: Vec2, + /// Size of the element in screen pixels (width, height). + pub size: Vec2, + /// Per-corner radii for rounded rectangles (top-left, top-right, + /// bottom-right, bottom-left). + pub corner_radii: Vec4, + /// Border width in pixels. 0 means no border. + pub border_width: f32, + /// Border color (RGBA). + pub border_color: Vec4, + /// Fill color (RGBA). Used when gradient_type is None. + pub fill_color: Vec4, + /// Gradient fill descriptor. + pub gradient: GradientDescriptor, + /// ID of the atlas texture descriptor on the slab (for Image elements). + /// Set to `Id::NONE` when unused. + pub atlas_texture_id: u32, + /// ID of the atlas descriptor on the slab. + /// Set to `Id::NONE` when unused. + pub atlas_descriptor_id: u32, + /// Scissor/clip rectangle (x, y, width, height). + /// Elements outside this rect are clipped. Set to (0,0, viewport_w, + /// viewport_h) for no clipping. + pub clip_rect: Vec4, + /// Element opacity (0.0 = fully transparent, 1.0 = fully opaque). + /// Multiplied with the final alpha. + pub opacity: f32, + /// Z-depth for sorting (painter's algorithm). Lower values are drawn + /// first (further back). + pub z: f32, +} + +/// Camera/viewport descriptor for the 2D UI pipeline. +/// +/// Contains the orthographic projection matrix and viewport dimensions. +#[derive(Clone, Copy, Default, PartialEq, SlabItem, core::fmt::Debug)] +pub struct UiViewport { + /// Orthographic projection matrix (typically top-left origin, +Y down). + pub projection: Mat4, + /// Viewport size in pixels. + pub size: UVec2, +} diff --git a/crates/renderling/src/ui_slab/shader.rs b/crates/renderling/src/ui_slab/shader.rs new file mode 100644 index 00000000..093daa8f --- /dev/null +++ b/crates/renderling/src/ui_slab/shader.rs @@ -0,0 +1,267 @@ +//! GPU shader entry points for the 2D/UI rendering pipeline. +//! +//! These shaders are compiled via rust-gpu and used by the `renderling-ui` +//! crate's `UiRenderer`. + +use crabslab::{Id, Slab, SlabItem}; +use glam::{Vec2, Vec4}; +use spirv_std::{image::Image2dArray, spirv, Sampler}; + +use super::{GradientType, UiDrawCallDescriptor, UiElementType, UiVertex, UiViewport}; +use crate::atlas::shader::AtlasTextureDescriptor; + +/// SDF for a rounded rectangle. +/// +/// `p` is the point relative to the rectangle center. +/// `half_ext` is the half-extents of the rectangle. +/// `radii` are the corner radii: (top-left, top-right, bottom-right, +/// bottom-left). +fn sdf_rounded_rect(p: Vec2, half_ext: Vec2, radii: Vec4) -> f32 { + // Select the appropriate corner radius based on quadrant. + let r = if p.x > 0.0 { + if p.y > 0.0 { + // bottom-right (in screen coords, +Y is down) + radii.z + } else { + // top-right + radii.y + } + } else if p.y > 0.0 { + // bottom-left + radii.w + } else { + // top-left + radii.x + }; + let q = p.abs() - half_ext + Vec2::splat(r); + let outside = q.max(Vec2::ZERO).length(); + let inside = q.x.max(q.y).min(0.0); + outside + inside - r +} + +/// SDF for a circle. +fn sdf_circle(p: Vec2, radius: f32) -> f32 { + p.length() - radius +} + +/// SDF for an ellipse (approximation using the Iq formula). +fn sdf_ellipse(p: Vec2, radii: Vec2) -> f32 { + // Simplified ellipse SDF (not exact but good for UI). + let p_norm = p / radii; + let d = p_norm.length() - 1.0; + d * radii.x.min(radii.y) +} + +/// Evaluate a gradient at the given local UV coordinate. +fn eval_gradient( + gradient_type: u32, + start: Vec2, + end: Vec2, + radius: f32, + color_start: Vec4, + color_end: Vec4, + local_uv: Vec2, +) -> Vec4 { + let gt = GradientType::from_u32(gradient_type); + match gt { + GradientType::None => color_start, + GradientType::Linear => { + let dir = end - start; + let len_sq = dir.dot(dir); + let t = if len_sq > 0.0 { + let t = (local_uv - start).dot(dir) / len_sq; + t.clamp(0.0, 1.0) + } else { + 0.0 + }; + color_start + (color_end - color_start) * t + } + GradientType::Radial => { + let d = (local_uv - start).length(); + let t = if radius > 0.0 { + (d / radius).clamp(0.0, 1.0) + } else { + 0.0 + }; + color_start + (color_end - color_start) * t + } + } +} + +/// 2D UI vertex shader. +/// +/// For SDF-based elements (Rectangle, Circle, Ellipse), this generates +/// 6 vertices (2 triangles) per instance from the element's position and +/// size, reading from the slab. The vertex index (0..5) selects which +/// corner of the quad. +/// +/// For Path and TextGlyph elements, the vertex data is read directly +/// from the slab (pre-tessellated vertices). +#[spirv(vertex)] +pub fn ui_vertex( + #[spirv(vertex_index)] vertex_index: u32, + #[spirv(instance_index)] draw_call_id: Id, + #[spirv(storage_buffer, descriptor_set = 0, binding = 0)] slab: &[u32], + out_uv: &mut Vec2, + out_color: &mut Vec4, + #[spirv(flat)] out_draw_call_id: &mut u32, + #[spirv(position)] out_clip_pos: &mut Vec4, +) { + let viewport: UiViewport = slab.read_unchecked(Id::new(0)); + let draw_call: UiDrawCallDescriptor = slab.read_unchecked(draw_call_id); + + *out_draw_call_id = draw_call_id.inner(); + + match draw_call.element_type { + UiElementType::Path => { + // For path elements, the draw_call stores an offset into the + // slab where UiVertex data lives. We read the vertex directly. + // The atlas_texture_id field is repurposed as vertex_offset for + // paths. + let vertex_offset = draw_call.atlas_texture_id; + let vertex_id = + Id::::new(vertex_offset + vertex_index * UiVertex::SLAB_SIZE as u32); + let vertex: UiVertex = slab.read_unchecked(vertex_id); + *out_uv = vertex.uv; + *out_color = vertex.color; + + let pos4 = viewport.projection + * Vec4::new(vertex.position.x, vertex.position.y, draw_call.z, 1.0); + *out_clip_pos = pos4; + } + _ => { + // SDF-based element: generate quad vertices. + // Quad corners in CCW order for two triangles: + // 0: top-left, 1: bottom-left, 2: bottom-right, + // 3: bottom-right, 4: top-right, 5: top-left + let vi = vertex_index % 6; + let (corner_x, corner_y) = match vi { + 0 => (0.0f32, 0.0f32), // top-left + 1 => (0.0, 1.0), // bottom-left + 2 => (1.0, 1.0), // bottom-right + 3 => (1.0, 1.0), // bottom-right + 4 => (1.0, 0.0), // top-right + _ => (0.0, 0.0), // top-left + }; + + let local_uv = Vec2::new(corner_x, corner_y); + *out_uv = local_uv; + *out_color = draw_call.fill_color; + + let screen_pos = draw_call.position + + Vec2::new(corner_x * draw_call.size.x, corner_y * draw_call.size.y); + + let pos4 = + viewport.projection * Vec4::new(screen_pos.x, screen_pos.y, draw_call.z, 1.0); + *out_clip_pos = pos4; + } + } +} + +/// 2D UI fragment shader. +/// +/// Evaluates SDF shapes, gradients, textures, and text glyphs depending +/// on the element type. +#[spirv(fragment)] +pub fn ui_fragment( + #[spirv(storage_buffer, descriptor_set = 0, binding = 0)] slab: &[u32], + #[spirv(descriptor_set = 0, binding = 1)] atlas: &Image2dArray, + #[spirv(descriptor_set = 0, binding = 2)] atlas_sampler: &Sampler, + in_uv: Vec2, + in_color: Vec4, + #[spirv(flat)] in_draw_call_id: u32, + frag_color: &mut Vec4, +) { + let draw_call_id = Id::::new(in_draw_call_id); + let draw_call: UiDrawCallDescriptor = slab.read_unchecked(draw_call_id); + #[allow(unused_assignments)] + let mut color = Vec4::ZERO; + + match draw_call.element_type { + UiElementType::Path => { + // Pre-tessellated path: just use vertex color. + color = in_color; + } + UiElementType::TextGlyph => { + // Text glyph: sample the glyph texture and multiply by color. + let atlas_tex_id = Id::::new(draw_call.atlas_texture_id); + let atlas_tex: AtlasTextureDescriptor = slab.read_unchecked(atlas_tex_id); + let viewport: UiViewport = slab.read_unchecked(Id::new(0)); + let atlas_uv = atlas_tex.uv(in_uv, viewport.size); + let sample: Vec4 = atlas.sample_by_lod(*atlas_sampler, atlas_uv, 0.0); + color = draw_call.fill_color; + color.w *= sample.w; + } + UiElementType::Image => { + // Textured quad: sample the atlas texture. + let atlas_tex_id = Id::::new(draw_call.atlas_texture_id); + let atlas_tex: AtlasTextureDescriptor = slab.read_unchecked(atlas_tex_id); + let viewport: UiViewport = slab.read_unchecked(Id::new(0)); + let atlas_uv = atlas_tex.uv(in_uv, viewport.size); + color = atlas.sample_by_lod(*atlas_sampler, atlas_uv, 0.0); + // Modulate with fill color (tint). + color *= draw_call.fill_color; + } + _ => { + // SDF-based element (Rectangle, Circle, Ellipse). + let half_size = draw_call.size * 0.5; + // Convert UV (0..1) to element-local coords centered on element + // center. + let local_pos = (in_uv - Vec2::splat(0.5)) * draw_call.size; + + let distance = match draw_call.element_type { + UiElementType::Rectangle => { + sdf_rounded_rect(local_pos, half_size, draw_call.corner_radii) + } + UiElementType::Circle => { + let radius = half_size.x.min(half_size.y); + sdf_circle(local_pos, radius) + } + UiElementType::Ellipse => sdf_ellipse(local_pos, half_size), + _ => 0.0, + }; + + // Evaluate fill color (possibly gradient). + let fill = eval_gradient( + draw_call.gradient.gradient_type, + draw_call.gradient.start, + draw_call.gradient.end, + draw_call.gradient.radius, + draw_call.gradient.color_start, + draw_call.gradient.color_end, + in_uv, + ); + // If gradient is None, use the solid fill color. + let fill = if draw_call.gradient.gradient_type == 0 { + draw_call.fill_color + } else { + fill + }; + + // Anti-aliased edge using smoothstep. + let aa_width = 1.0; // 1 pixel of anti-aliasing + let fill_alpha = 1.0 - crate::math::smoothstep(-aa_width, aa_width, distance); + + if draw_call.border_width > 0.0 { + // Border: the border region is between the outer edge and + // (outer edge - border_width). + let inner_distance = distance + draw_call.border_width; + let border_alpha = + 1.0 - crate::math::smoothstep(-aa_width, aa_width, inner_distance); + // Inside the border but outside the fill = border color. + let in_border = fill_alpha; + let in_fill = border_alpha; + color = draw_call.border_color * (in_border - in_fill) + fill * in_fill; + color.w = draw_call.border_color.w * (in_border - in_fill) + fill.w * in_fill; + } else { + color = fill; + color.w *= fill_alpha; + } + } + } + + // Apply element opacity. + color.w *= draw_call.opacity; + + *frag_color = color; +} diff --git a/test_img/ui2d/bordered_rect.png b/test_img/ui2d/bordered_rect.png new file mode 100644 index 0000000000000000000000000000000000000000..6c350558d7b97aa0339ff95f54cb8642cbc9afac GIT binary patch literal 2535 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 E1r%$cD*ylh literal 0 HcmV?d00001 diff --git a/test_img/ui2d/multiple_shapes.png b/test_img/ui2d/multiple_shapes.png new file mode 100644 index 0000000000000000000000000000000000000000..99180772923f18a4d1c92abe43f7c442225ba4bf GIT binary patch literal 7292 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 yIX2~jV)A)Ao|qgBP{IL*4> literal 0 HcmV?d00001 diff --git a/test_img/ui2d/rounded_rect.png b/test_img/ui2d/rounded_rect.png new file mode 100644 index 0000000000000000000000000000000000000000..f31f029540432f6791e1b6e4ae7b66cdae6e5bf5 GIT binary patch literal 2140 zcmeAS@N?(olHy`uVBq!ia0vp^CqS5k4M?tyST~P>fkWBT#WAFU@$CWT1uBu#j(z-J z{;v1LndL>^$?I}&Z-b)Do_W)}*3UCCR(kidYg~yg&{p|d7)#`Os zjw@nHuZHS#OkKTh*DKbAMOQ<^_bP+{*{j4`PKAcYer*ca`D)ebH~}q?CT=IFra$aO zYPsd}Kfb9vR(<~Rxp*c1z7JI_vseEU_RP2@7%e>|`1p%;wyx#RM4tXI>w2)NURdit z(~xY&Rcx9RIXn*{O|X`a82_$i|U`~o{|5kuq)etF~_=J;f~wR?svXz zo$zzV@(q7Zt6zAx??CAP&r@FeZ>qTIzPaMfVNL7kn6FCCKxG#TzSqk9`*?rTzt`%{ zvsV9O=QgZ<>AvCTDSglSzotM%AhQns+P2%`$6WjLnoF`VU-!FzKQa48)!OOlWv6*# zzUnLeFqe3@ufO^J&*Ee1cMpdC-`(TCs~N;?+x`CWs(QhL`-5&airZl%{BjjodlYG``7yUXFe7BADkKUl}YfAcF*^D$v>z2 zH<*O~)t{aQWF_qRE)%}jfkj^_eN)vZ|Gt$45bnh5%Heg<|!XWOHxw`AB7IOUK{q=f%;_Iz(^WIG~2PP@KyUO`VAnw0k**jjv zx8%&OU;OahZ;L;V_ox27?%xJX_WWW=AR9hUjh_TotV(Y@-vtCQy+1m%PBxyK(L3H@JtB@`GwFMy%& z+1;iC=u4o7|AkJ-{VacE=9as)z+58v-qzo`zSG9y&}+e*b-(;o7Dl?~8t9^VG(@tu%c3@q|&?FkooBP>DVF8fCVf|8n(ZlU+XUEkXAitPJ$p!NfUr>mdK II;Vst02}{lod5s; literal 0 HcmV?d00001 From 97c11ef066228ea186872cdfccd36a55e1f31ca4 Mon Sep 17 00:00:00 2001 From: Schell Carl Scivally Date: Tue, 10 Mar 2026 11:43:33 +1300 Subject: [PATCH 02/14] feat: Add atlas-based image rendering and fix viewport slab ordering Add UiImage element type with atlas texture support, enabling texture/image rendering in the 2D UI pipeline. Fix critical bug where Atlas::new() was allocated before UiViewport on the slab, causing the shader (which reads viewport at offset 0) to read garbage data and render blank white output. - Add UiImage wrapper with set/with builders for position, size, tint, opacity, z-depth - Replace dummy atlas with real Atlas integration (512x512x2) - Add atlas_size field to UiViewport for correct UV mapping - Fix shader to use viewport.atlas_size instead of viewport.size for AtlasTextureDescriptor::uv() calls - Add AtlasImage, AtlasTexture, UiImage to public re-exports - Recompile shaders with updated UiViewport layout - Add 2 image rendering tests (checkerboard + tint) - Regenerate baseline images with correct rendering - 8 renderling-ui tests + 101 renderling tests pass --- crates/renderling-ui/src/lib.rs | 3 +- crates/renderling-ui/src/renderer.rs | 246 ++++++++++++++---- crates/renderling-ui/src/test.rs | 49 ++++ .../shaders/ui_slab-shader-ui_fragment.spv | Bin 17820 -> 17820 bytes .../shaders/ui_slab-shader-ui_vertex.spv | Bin 5296 -> 5296 bytes crates/renderling/src/ui_slab/mod.rs | 5 +- crates/renderling/src/ui_slab/shader.rs | 4 +- test_img/ui2d/image.png | Bin 0 -> 1851 bytes test_img/ui2d/image_tint.png | Bin 0 -> 1450 bytes 9 files changed, 257 insertions(+), 50 deletions(-) create mode 100644 test_img/ui2d/image.png create mode 100644 test_img/ui2d/image_tint.png diff --git a/crates/renderling-ui/src/lib.rs b/crates/renderling-ui/src/lib.rs index d32f833e..91559dd1 100644 --- a/crates/renderling-ui/src/lib.rs +++ b/crates/renderling-ui/src/lib.rs @@ -39,6 +39,7 @@ mod test; // Re-export key types from renderling that users will need. pub use renderling::{ + atlas::{AtlasImage, AtlasTexture}, context::Context, glam, ui_slab::{ @@ -47,4 +48,4 @@ pub use renderling::{ }; // Re-export our own types. -pub use renderer::{UiCircle, UiEllipse, UiRect, UiRenderer}; +pub use renderer::{UiCircle, UiEllipse, UiImage, UiRect, UiRenderer}; diff --git a/crates/renderling-ui/src/renderer.rs b/crates/renderling-ui/src/renderer.rs index 152c57ba..80c993ff 100644 --- a/crates/renderling-ui/src/renderer.rs +++ b/crates/renderling-ui/src/renderer.rs @@ -23,6 +23,7 @@ use craballoc::{ use crabslab::Id; use glam::{Mat4, UVec2, Vec2, Vec4}; use renderling::{ + atlas::{Atlas, AtlasImage, AtlasTexture}, context::Context, ui_slab::{GradientDescriptor, UiDrawCallDescriptor, UiElementType, UiViewport}, }; @@ -388,6 +389,98 @@ impl UiEllipse { } } +/// A live handle to an image element in the renderer. +/// +/// See [`UiRect`] for general usage notes. +#[derive(Clone)] +pub struct UiImage { + inner: Hybrid, + /// Kept alive to prevent the atlas from garbage-collecting the texture. + #[allow(dead_code)] + atlas_texture: AtlasTexture, +} + +impl std::fmt::Debug for UiImage { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("UiImage") + .field("inner", &self.inner) + .finish_non_exhaustive() + } +} + +impl UiImage { + /// Returns the slab [`Id`] of the underlying descriptor. + pub fn id(&self) -> Id { + self.inner.id() + } + + /// Returns a copy of the underlying descriptor. + pub fn descriptor(&self) -> UiDrawCallDescriptor { + self.inner.get() + } + + /// Set the top-left position in screen pixels. + pub fn set_position(&self, position: Vec2) -> &Self { + self.inner.modify(|d| d.position = position); + self + } + + /// Set the top-left position in screen pixels (builder). + pub fn with_position(self, position: Vec2) -> Self { + self.set_position(position); + self + } + + /// Set the size in screen pixels. + pub fn set_size(&self, size: Vec2) -> &Self { + self.inner.modify(|d| d.size = size); + self + } + + /// Set the size in screen pixels (builder). + pub fn with_size(self, size: Vec2) -> Self { + self.set_size(size); + self + } + + /// Set a tint color (multiplied with the texture color). + /// Use `Vec4::ONE` for no tint. + pub fn set_tint(&self, color: Vec4) -> &Self { + self.inner.modify(|d| d.fill_color = color); + self + } + + /// Set a tint color (builder). + pub fn with_tint(self, color: Vec4) -> Self { + self.set_tint(color); + self + } + + /// Set the opacity (0.0 = transparent, 1.0 = opaque). + pub fn set_opacity(&self, opacity: f32) -> &Self { + self.inner.modify(|d| d.opacity = opacity); + self + } + + /// Set the opacity (builder). + pub fn with_opacity(self, opacity: f32) -> Self { + self.set_opacity(opacity); + self + } + + /// Set the z-depth for sorting. + pub fn set_z(&self, z: f32) -> &Self { + self.inner.modify(|d| d.z = z); + self + } + + /// Set the z-depth for sorting (builder). + pub fn with_z(self, z: f32) -> Self { + self.set_z(z); + self + } +} + // --------------------------------------------------------------------------- // Internal draw call entry // --------------------------------------------------------------------------- @@ -416,14 +509,18 @@ struct DrawCall { /// [`render`](Self::render) call. pub struct UiRenderer { slab: SlabAllocator, - #[allow(dead_code)] viewport: Hybrid, + atlas: Atlas, pipeline: wgpu::RenderPipeline, bindgroup_layout: wgpu::BindGroupLayout, /// Cached slab buffer from the last commit. slab_buffer: Option>, /// Cached bind group (recreated when slab buffer changes). bindgroup: Option, + /// ID of the atlas texture at the time the bind group was created. + /// Used to detect when the atlas is recreated and the bind group + /// needs rebuilding. + bindgroup_atlas_texture_id: Option, /// All active draw calls, sorted by z before rendering. draw_calls: Vec, /// Viewport size. @@ -436,15 +533,18 @@ pub struct UiRenderer { format: wgpu::TextureFormat, /// MSAA resolve texture (if msaa_sample_count > 1). msaa_texture: Option, - /// Dummy atlas texture view (1x1x1, created once). - dummy_atlas_view: wgpu::TextureView, - /// Dummy atlas sampler (created once). - dummy_atlas_sampler: wgpu::Sampler, } impl UiRenderer { const LABEL: Option<&'static str> = Some("renderling-ui"); + /// Default atlas texture size. + const DEFAULT_ATLAS_SIZE: wgpu::Extent3d = wgpu::Extent3d { + width: 512, + height: 512, + depth_or_array_layers: 2, + }; + /// Create a new `UiRenderer` from a renderling `Context`. pub fn new(ctx: &Context) -> Self { let device = ctx.get_device(); @@ -452,32 +552,45 @@ impl UiRenderer { let format = ctx.get_render_target().format(); let slab = SlabAllocator::new(ctx.runtime(), "ui-slab", wgpu::BufferUsages::empty()); + + // IMPORTANT: The viewport must be the first slab allocation so it + // lands at offset 0. The vertex/fragment shaders read UiViewport + // via `Id::new(0)`. let viewport = slab.new_value(UiViewport { projection: Self::ortho2d(size.x as f32, size.y as f32), size, + atlas_size: UVec2::new( + Self::DEFAULT_ATLAS_SIZE.width, + Self::DEFAULT_ATLAS_SIZE.height, + ), }); + let atlas = Atlas::new( + &slab, + Self::DEFAULT_ATLAS_SIZE, + None, + Some("ui-atlas"), + None, + ); + let bindgroup_layout = Self::create_bindgroup_layout(device); let pipeline = Self::create_pipeline(device, &bindgroup_layout, format, 1); - // Create the dummy atlas texture and sampler once. - let (dummy_atlas_view, dummy_atlas_sampler) = Self::create_dummy_atlas(device); - Self { slab, viewport, + atlas, pipeline, bindgroup_layout, slab_buffer: None, bindgroup: None, + bindgroup_atlas_texture_id: None, draw_calls: Vec::new(), viewport_size: size, background_color: None, msaa_sample_count: 1, format, msaa_texture: None, - dummy_atlas_view, - dummy_atlas_sampler, } } @@ -576,6 +689,48 @@ impl UiRenderer { element } + /// Add an image element and return a live handle. + /// + /// The image is loaded into the atlas from an [`AtlasImage`] + /// (CPU-side pixel data). The element is sized to match the + /// image dimensions by default. + /// + /// ```ignore + /// let img = image::open("icon.png").unwrap(); + /// let _icon = ui.add_image(img.into()) + /// .with_position(Vec2::new(10.0, 10.0)); + /// ``` + pub fn add_image(&mut self, image: impl Into) -> UiImage { + let image = image.into(); + let image_size = image.size; + let atlas_texture = self + .atlas + .add_image(&image) + .expect("failed to add image to atlas"); + + // Update the viewport with the (possibly new) atlas size. + let atlas_extent = self.atlas.get_size(); + self.viewport.modify(|v| { + v.atlas_size = UVec2::new(atlas_extent.width, atlas_extent.height); + }); + + let mut desc = self.default_descriptor(UiElementType::Image); + desc.size = Vec2::new(image_size.x as f32, image_size.y as f32); + desc.atlas_texture_id = atlas_texture.id().inner(); + desc.fill_color = Vec4::ONE; // no tint + + let hybrid = self.slab.new_value(desc); + let element = UiImage { + inner: hybrid.clone(), + atlas_texture, + }; + self.draw_calls.push(DrawCall { + descriptor: hybrid, + vertex_count: 6, + }); + element + } + /// Remove a rectangle element by its handle. pub fn remove_rect(&mut self, element: &UiRect) { self.remove_by_id(element.id()); @@ -591,6 +746,11 @@ impl UiRenderer { self.remove_by_id(element.id()); } + /// Remove an image element by its handle. + pub fn remove_image(&mut self, element: &UiImage) { + self.remove_by_id(element.id()); + } + /// Remove all elements. pub fn clear(&mut self) { self.draw_calls.clear(); @@ -613,13 +773,32 @@ impl UiRenderer { z_a.partial_cmp(&z_b).unwrap_or(core::cmp::Ordering::Equal) }); + // Run atlas upkeep (garbage-collect dropped textures). + let atlas_texture_recreated = self.atlas.upkeep(self.slab.runtime()); + if atlas_texture_recreated { + // Update viewport with new atlas size. + let extent = self.atlas.get_size(); + self.viewport.modify(|v| { + v.atlas_size = UVec2::new(extent.width, extent.height); + }); + } + // Commit slab changes to the GPU. let buffer = self.slab.commit(); - let should_recreate_bindgroup = buffer.is_new_this_commit() || self.bindgroup.is_none(); + + // Check if bind group needs recreation: slab buffer changed, + // atlas texture changed, or first render. + let atlas_tex = self.atlas.get_texture(); + let atlas_tex_id = atlas_tex.id(); + let atlas_changed = self.bindgroup_atlas_texture_id != Some(atlas_tex_id); + let should_recreate_bindgroup = + buffer.is_new_this_commit() || atlas_changed || self.bindgroup.is_none(); if should_recreate_bindgroup { - self.bindgroup = Some(self.create_bindgroup(&buffer)); + self.bindgroup = Some(self.create_bindgroup(&buffer, &atlas_tex)); + self.bindgroup_atlas_texture_id = Some(atlas_tex_id); } + drop(atlas_tex); self.slab_buffer = Some(buffer); let device = self.slab.device(); @@ -835,38 +1014,13 @@ impl UiRenderer { texture.create_view(&wgpu::TextureViewDescriptor::default()) } - /// Create a dummy 1x1x1 atlas texture and sampler (used until - /// texture/image support is fully wired up). - fn create_dummy_atlas(device: &wgpu::Device) -> (wgpu::TextureView, wgpu::Sampler) { - let texture = device.create_texture(&wgpu::TextureDescriptor { - label: Some("renderling-ui-dummy-atlas"), - size: wgpu::Extent3d { - width: 1, - height: 1, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8Unorm, - usage: wgpu::TextureUsages::TEXTURE_BINDING, - view_formats: &[], - }); - let view = texture.create_view(&wgpu::TextureViewDescriptor { - dimension: Some(wgpu::TextureViewDimension::D2Array), - ..Default::default() - }); - let sampler = device.create_sampler(&wgpu::SamplerDescriptor { - label: Some("renderling-ui-dummy-sampler"), - mag_filter: wgpu::FilterMode::Nearest, - min_filter: wgpu::FilterMode::Nearest, - ..Default::default() - }); - (view, sampler) - } - - /// Create a bind group using the given slab buffer. - fn create_bindgroup(&self, buffer: &SlabBuffer) -> wgpu::BindGroup { + /// Create a bind group using the given slab buffer and atlas + /// texture. + fn create_bindgroup( + &self, + buffer: &SlabBuffer, + atlas_tex: &renderling::texture::Texture, + ) -> wgpu::BindGroup { self.slab .device() .create_bind_group(&wgpu::BindGroupDescriptor { @@ -879,11 +1033,11 @@ impl UiRenderer { }, wgpu::BindGroupEntry { binding: 1, - resource: wgpu::BindingResource::TextureView(&self.dummy_atlas_view), + resource: wgpu::BindingResource::TextureView(&atlas_tex.view), }, wgpu::BindGroupEntry { binding: 2, - resource: wgpu::BindingResource::Sampler(&self.dummy_atlas_sampler), + resource: wgpu::BindingResource::Sampler(&atlas_tex.sampler), }, ], }) diff --git a/crates/renderling-ui/src/test.rs b/crates/renderling-ui/src/test.rs index 065a3189..c77ffb46 100644 --- a/crates/renderling-ui/src/test.rs +++ b/crates/renderling-ui/src/test.rs @@ -142,6 +142,55 @@ mod tests { save_and_assert("ui2d/multiple_shapes.png", img); } + #[test] + fn can_render_image() { + init_logging(); + let ctx = futures_lite::future::block_on(Context::headless(200, 200)); + let mut ui = UiRenderer::new(&ctx).with_background_color(Vec4::ONE); + + // Create a programmatic 64x64 checkerboard image. + let size = 64u32; + let mut img = image::RgbaImage::new(size, size); + for y in 0..size { + for x in 0..size { + let checker = ((x / 8) + (y / 8)) % 2 == 0; + let c = if checker { 255 } else { 80 }; + img.put_pixel(x, y, image::Rgba([c, c, c, 255])); + } + } + + let atlas_img: renderling::atlas::AtlasImage = image::DynamicImage::ImageRgba8(img).into(); + let _image_el = ui.add_image(atlas_img).with_position(Vec2::new(20.0, 20.0)); + + let frame = ctx.get_next_frame().unwrap(); + ui.render(&frame.view()); + let output = futures_lite::future::block_on(frame.read_image()).unwrap(); + save_and_assert("ui2d/image.png", output); + } + + #[test] + fn can_render_image_with_tint() { + init_logging(); + let ctx = futures_lite::future::block_on(Context::headless(200, 200)); + let mut ui = UiRenderer::new(&ctx).with_background_color(Vec4::ONE); + + // Create a 64x64 solid white image. + let size = 64u32; + let img = image::RgbaImage::from_pixel(size, size, image::Rgba([255, 255, 255, 255])); + let atlas_img: renderling::atlas::AtlasImage = image::DynamicImage::ImageRgba8(img).into(); + + // Apply a red tint. + let _image_el = ui + .add_image(atlas_img) + .with_position(Vec2::new(50.0, 50.0)) + .with_tint(Vec4::new(1.0, 0.0, 0.0, 1.0)); + + let frame = ctx.get_next_frame().unwrap(); + ui.render(&frame.view()); + let output = futures_lite::future::block_on(frame.read_image()).unwrap(); + save_and_assert("ui2d/image_tint.png", output); + } + #[test] fn can_render_gradient_rect() { init_logging(); diff --git a/crates/renderling/shaders/ui_slab-shader-ui_fragment.spv b/crates/renderling/shaders/ui_slab-shader-ui_fragment.spv index 78b5a491dfc91e99701a05bbc7d1992f55b499c7..f9051761a7077f981832842b5eaea9239b46d659 100644 GIT binary patch literal 17820 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 b2;y=sd!uVl>_3yy4leEMYYp7l4YmIVt_wI{ diff --git a/crates/renderling/shaders/ui_slab-shader-ui_vertex.spv b/crates/renderling/shaders/ui_slab-shader-ui_vertex.spv index f3d3d897b61fa67615795eb261a7a411437cb7b0..ef23f384a2b32aaf5bb454506f411db1c6536a1a 100644 GIT binary patch literal 5296 zcmZvfiK~`H7{%Y~)oi!W7AoyCEh&u()bfg&soly_Q7cn(zo}`oxKv`IW<*5$zFV1V zMMMM<5ky2AM1)(bR@Pr2tyaI^z2A}HqZh_G&w1vYdEWVE=Djv|>eIFA(o|Jdx9a3h zbUepaohljoy*;Yx=&HG$_up=?KIWG#oVjH2>^UthOBT$Y*LF)w3&iraTb8!nR-vrg zRh{X(&<>$>r5#FZtvXjH*E#d8j68*2%_WQ$`}FDP+VhS(k)~GM`(UmkKCOtIuU8T4 zdj#z)ntE$6_A$0L$NNskzNh*A?QwH^-AC7RzJp`+JdUwF>1#Bf8gYE&d*bAKihI@H z(b#dP(jQ6lU5_VMqqm(vu6G?fhhu#=Ymztby3}Z{gZaMn=H=5vjlFZGC1 zkM+6-%6k4LDHp#3#D^8}Kg5R@@xR0!`>W%3aWRf6@=e6uiny8hm?G{>>?zEA-HB^O zd=jz0^sU_2b`yNzV0_*;&oxt8t?TXYwJ&8;L` zqaQ`<;5u|;h@}Bi*77fF1jng(YYU@8wWNQ-FUEE zbQ8hRxo@JI1U47lWUyRxSAnB*KSg&n*j#i|z;e+|1xM#^6x}qix#*^Y<)WJbj?VoS z-As6M(ai$OMK>25o#!UHd0=zVwSncLn-7l8{T$r_cyrM$1j|LY2ppaJKDx#5=Ayd+ zEEnC4;OIOb(cJ`ZF1nk+a?vdXN9Q?@ZW-8Ibj!hV(ea5osPh0scRRef=vIK`qFV{h zeBK+;-2pZi-72tLbgRM9dA_4t18*+6wP3mE9tKC}*^KTHu({tjt&h_C{Kc&y|5$re zRgcrNl+_b7rmUW%dA6UT>G?FR3+)-2_k#T&^!gc_FJpCOtX~;BPZ>LZ8CzEwJI{V@ zGVh^hY2Gi6u~+N8p{MtNa}AmW$VRuwL%tcl?7S zczp|&i`RExz5MRQ>wDsOnUB{GU~}=>1D1=|Ua($%GvoE6G0lBuK3+e8&Bg0yu-pW` z?tSR@(R^>~-9s~3F4g*`G-|c9J!$oU8+7XVWrN#@v z?Hnh&D<1l6^x=c{AH}~e(t^+bG`~b zi{3HzYWvgcY42S2)?nV+qJ6E-m0HLApH>gG6Iip};Mc+Goqss1;Jqp*J_PLExeK;N z{|3BcJ=@{lBv$JgT~iB~H`p5W4fhjR&*jV$ z?q^~>R}|bnVm((C+%Lp>u7%6J^DD8QdZ=-X`|41}nDZ=*ruS@(p^UAt z%UyE`Twj{G+%=bhbJv*9T{9MJE_co4V7Yi*0d{=(y&Xp!FZ1yl4>lLC31GQ+O$6&z zes3oc$IE=YCWFnz>q@X(ysiT4Reo=;CXSc+cufJDi`O+^xt!~%VEyIeKMkyZ`3;^< z9DnoizZPsR{xiUG@tO(N%R4&1$1LJ_nUB|Ou(^260n5c}E?6(`?Rd>2j+gm(wSmpW z>pHMpyyk=T^8Xs~S^$oh`FJe^n~T@=V7ctyMPT3CdY#97?nu`+cckNTo^J+wr@80C zEdhI{mE1Bo@3fM;70x@YoPcTL-pAeN)Tb;Kn=c9=OIk?OwRXJMBKW#yjnPIBV25^F9E!Mt#FQ2)0Ij z!#xDHMt#FQ1J<*=)1D>Pv%J%uBi6IL)1D{Rv%J$@Al6e4HIB)hX0E)`MiU>XnyL$E F{{g$?(xCtV literal 5296 zcmZvf{jb+!9L7IAo+8C6Y>{sr4^vTXIo07rMJJ^P6{1i$OH-tWGv(>{!3Cp{!jjChUaIW`ePx97%E;WYF!TQ-m>+(bC>>T1Km~)BZvCOfD z^BCUi#pJH9-X2mvAM9mbBl^6&lKVWWs_W8WtT7IxkG=W4UEf|VAnNIQ@f{EDRpb-E zxwna6wa(Ws&(`_o-ka~sIq{oR=aOI4P;Vjk-cp|oF7I5r5|zn zv0uM|vY)=(j9mQskq<2L{^TbVc@ufh_to=W29Tdr)CZCeDe@D^PcHI7a6P&!$fNW9M>h*>ExLBFT-Li19G%}ubaTMgqMHkri*6n`I=_eL=7X(8w*V{` z-6C*wemBuA23w1630N+=Yr)a^JwJG`~%R)giDy8|4Z-+gpz;H^cs z7AzOtUEt{4AJMIYw-(*qV7cfvf}?YvN4E)VExJyyTy*?I9oM=4qT38_ExIjWx#;c# zXFbo2=gkh}Tc#@v0Wd#_mMrg2kdPs(VnMq zS507_8L+*D?M|v9_z8 z?y)|cm$|-&dG9sW+y@>*bdFK& zSfZXr*D~6Jb$g5U#X47No%4TMJ=8AbnT-O!1Xl0*z6-x8IdOlm`{xnZ8u4X#=X$<{ zdxc!>KDe#*d9RXtHakDuYv5<$oFDBQ;F)mF5BDaxy@7iRZf*njHr#>+?j5)#aIO`< zcfm^=xcA_eH*oL6t*CS9`2#q6)HmFRV0+Xz+(%%0)HmG6V0+Xzy?g@p?-lodxKF{W z8@SKl);4foz};Qvvc`Tmd(=1FmtcF;H{4fXd(=1F0kA#l8}1-j&ra3}cZgiiEd}=r zxtq_!?S&!Eou(f#21 z*joG-g5~0MHCQjt=y)w6kC*j$Ee2bQ*AlQ?ysiQ3<+&ZNYsuqfJzm#=t;K68ST0`I zgZ1+N8u7Zp9B+S{tjB8^*jl`ngXQx5-Y90+uj^RP8R_%Q8R@+2=Uc&^X?}CzI>4T3 zCASLBGp*!S!+EBa+!{E~v>C+s-3j(gE4g)Wo@phw9&SaQ%YNPfXOH@Z+X%KteZy@6 z+oQhWI>Gj+Z+h7bZamZObsp=M=iL2pjc3|}aE)hL7o0unn{~H>?NQ%w+rajyZ@7oR z_NZ^T?O;92GwpHfjPgu-f?Ut?OnZ`C&+<&$L9VABYMhfZ&02Y;wUGZ)^{S>Y{sZpm B#2o+t diff --git a/crates/renderling/src/ui_slab/mod.rs b/crates/renderling/src/ui_slab/mod.rs index c3836064..50b52267 100644 --- a/crates/renderling/src/ui_slab/mod.rs +++ b/crates/renderling/src/ui_slab/mod.rs @@ -167,11 +167,14 @@ pub struct UiDrawCallDescriptor { /// Camera/viewport descriptor for the 2D UI pipeline. /// -/// Contains the orthographic projection matrix and viewport dimensions. +/// Contains the orthographic projection matrix, viewport dimensions, +/// and atlas texture dimensions. #[derive(Clone, Copy, Default, PartialEq, SlabItem, core::fmt::Debug)] pub struct UiViewport { /// Orthographic projection matrix (typically top-left origin, +Y down). pub projection: Mat4, /// Viewport size in pixels. pub size: UVec2, + /// Atlas texture size in pixels. + pub atlas_size: UVec2, } diff --git a/crates/renderling/src/ui_slab/shader.rs b/crates/renderling/src/ui_slab/shader.rs index 093daa8f..af2ea757 100644 --- a/crates/renderling/src/ui_slab/shader.rs +++ b/crates/renderling/src/ui_slab/shader.rs @@ -187,7 +187,7 @@ pub fn ui_fragment( let atlas_tex_id = Id::::new(draw_call.atlas_texture_id); let atlas_tex: AtlasTextureDescriptor = slab.read_unchecked(atlas_tex_id); let viewport: UiViewport = slab.read_unchecked(Id::new(0)); - let atlas_uv = atlas_tex.uv(in_uv, viewport.size); + let atlas_uv = atlas_tex.uv(in_uv, viewport.atlas_size); let sample: Vec4 = atlas.sample_by_lod(*atlas_sampler, atlas_uv, 0.0); color = draw_call.fill_color; color.w *= sample.w; @@ -197,7 +197,7 @@ pub fn ui_fragment( let atlas_tex_id = Id::::new(draw_call.atlas_texture_id); let atlas_tex: AtlasTextureDescriptor = slab.read_unchecked(atlas_tex_id); let viewport: UiViewport = slab.read_unchecked(Id::new(0)); - let atlas_uv = atlas_tex.uv(in_uv, viewport.size); + let atlas_uv = atlas_tex.uv(in_uv, viewport.atlas_size); color = atlas.sample_by_lod(*atlas_sampler, atlas_uv, 0.0); // Modulate with fill color (tint). color *= draw_call.fill_color; diff --git a/test_img/ui2d/image.png b/test_img/ui2d/image.png new file mode 100644 index 0000000000000000000000000000000000000000..fb661a75ac408e900407144629be85090a042796 GIT binary patch literal 1851 zcmeAS@N?(olHy`uVBq!ia0vp^CqS5k4M?tyST~P>ft|_I#WAFU@$CWT1uBu#j(z-J z{;v1LndL>^$?I}&Z-b)Do_W)}*3UCCR(kidYg~yg&{p|d7)#`Os zjw@nHuZHS#OkKTh*DKbAMOQ<^_bP+{*{j4`PKAcYer*ca`D)ebH~}q?CT=IHY5ElZ zM)&?F-O9weZ|U{Z#GetxS^e+)0ur70KOz6;wEUCYpEuk8-n_?vC{E1R|7@fM$b{U_ z@h^z-0*X&Z{WEnmetvHzC%qK|bE>U9J#s3E8IIls$*A*4E2;*nz^8IN{S&;FU(Q$) SEe@(C89ZJ6T-G@yGywpBW5CP+ literal 0 HcmV?d00001 diff --git a/test_img/ui2d/image_tint.png b/test_img/ui2d/image_tint.png new file mode 100644 index 0000000000000000000000000000000000000000..2f9a6b47ab3f36d3991121d3efe6916a76d8aaf8 GIT binary patch literal 1450 zcmeAS@N?(olHy`uVBq!ia0vp^CqS5k4M?tyST~P>fwj=n#WAFU@$CWT1uBu#j(z-J z{;v1LndL>^$?I}&Z-b)Do_W)}*3UCCR(kidYg~yg&{p|d7)#`Os zjw@nHuZHS#OkKTh*DKbAMOQ<^_bP+{*{j4`PKAcYer*ca`D)ebH~}q?CT^!uO{f9! zCtmU6|5;~4|34fJ$U&bt{&US Date: Tue, 10 Mar 2026 11:58:54 +1300 Subject: [PATCH 03/14] feat: Add text rendering via glyph_brush to renderling-ui Port text rendering from the old UI module to the new lightweight 2D pipeline. Glyph rasterization uses glyph_brush to produce a Luma8 cache image, which is converted to RGBA (white + alpha) and uploaded to the atlas. Each visible glyph gets its own AtlasTextureDescriptor pointing to its sub-region in the cache, and a UiDrawCallDescriptor with element_type TextGlyph. The existing fragment shader samples glyph coverage from the atlas alpha channel and multiplies by fill_color. - Add GlyphCache wrapping GlyphBrush with Luma8 cache image - Add UiText handle type with set_z/set_opacity methods - Add add_font(), add_text(), remove_text() to UiRenderer - Add image as optional dep behind 'text' feature - Re-export FontArc, FontId, Section, Text, UiText - Add 2 text tests (plain text + text overlaid on a rounded rect) - 10 renderling-ui tests + 101 renderling tests pass, 0 clippy warnings --- crates/renderling-ui/Cargo.toml | 3 +- crates/renderling-ui/src/lib.rs | 4 + crates/renderling-ui/src/renderer.rs | 435 +++++++++++++++++++++++++++ crates/renderling-ui/src/test.rs | 72 +++++ test_img/ui2d/text.png | Bin 0 -> 6726 bytes test_img/ui2d/text_with_shapes.png | Bin 0 -> 4363 bytes 6 files changed, 513 insertions(+), 1 deletion(-) create mode 100644 test_img/ui2d/text.png create mode 100644 test_img/ui2d/text_with_shapes.png diff --git a/crates/renderling-ui/Cargo.toml b/crates/renderling-ui/Cargo.toml index de8dcbb0..c40ca235 100644 --- a/crates/renderling-ui/Cargo.toml +++ b/crates/renderling-ui/Cargo.toml @@ -8,7 +8,7 @@ license = "MIT OR Apache-2.0" [features] default = ["text", "path"] -text = ["dep:glyph_brush", "dep:loading-bytes"] +text = ["dep:glyph_brush", "dep:image", "dep:loading-bytes"] path = ["dep:lyon"] test-utils = ["renderling/test-utils"] @@ -18,6 +18,7 @@ craballoc = { workspace = true } crabslab = { workspace = true, features = ["default"] } glam = { workspace = true, features = ["std"] } glyph_brush = { workspace = true, optional = true } +image = { workspace = true, optional = true } loading-bytes = { workspace = true, optional = true } log = { workspace = true } lyon = { workspace = true, optional = true } diff --git a/crates/renderling-ui/src/lib.rs b/crates/renderling-ui/src/lib.rs index 91559dd1..3fb18a5a 100644 --- a/crates/renderling-ui/src/lib.rs +++ b/crates/renderling-ui/src/lib.rs @@ -49,3 +49,7 @@ pub use renderling::{ // Re-export our own types. pub use renderer::{UiCircle, UiEllipse, UiImage, UiRect, UiRenderer}; + +// Re-export text types (behind "text" feature). +#[cfg(feature = "text")] +pub use renderer::{FontArc, FontId, Section, Text, UiText}; diff --git a/crates/renderling-ui/src/renderer.rs b/crates/renderling-ui/src/renderer.rs index 80c993ff..cf7be524 100644 --- a/crates/renderling-ui/src/renderer.rs +++ b/crates/renderling-ui/src/renderer.rs @@ -481,6 +481,286 @@ impl UiImage { } } +// --------------------------------------------------------------------------- +// Text types (behind "text" feature) +// --------------------------------------------------------------------------- + +#[cfg(feature = "text")] +mod text { + use super::*; + use glyph_brush::ab_glyph; + + /// Re-export common glyph_brush types for convenience. + pub use ab_glyph::FontArc; + use glyph_brush::GlyphCruncher as _; + pub use glyph_brush::{FontId, Section, Text}; + + /// A CPU-side glyph rasterization cache. + /// + /// Wraps a `GlyphBrush` and maintains a single-channel (Luma8) image + /// that accumulates rasterized glyph bitmaps. + pub(crate) struct GlyphCache { + brush: glyph_brush::GlyphBrush, + cache_img: image::ImageBuffer, Vec>, + /// Cached dimensions (updated whenever cache_img is replaced). + cache_w: f32, + cache_h: f32, + dirty: bool, + } + + /// Intermediate representation of one glyph quad produced by the brush. + #[derive(Clone, Debug)] + pub(crate) struct GlyphQuad { + /// Top-left position in screen pixels. + pub position: Vec2, + /// Size in screen pixels. + pub size: Vec2, + /// UV rect within the glyph cache image (in pixels). + pub tex_offset_px: UVec2, + /// UV rect size within the glyph cache image (in pixels). + pub tex_size_px: UVec2, + /// Text color from the section. + pub color: Vec4, + } + + impl GlyphCache { + /// Create a new glyph cache with the given fonts. + pub fn new(fonts: Vec) -> Self { + let brush = glyph_brush::GlyphBrushBuilder::using_fonts(fonts).build(); + let (w, h) = brush.texture_dimensions(); + Self { + brush, + cache_img: image::ImageBuffer::from_pixel(w, h, image::Luma([0])), + cache_w: w as f32, + cache_h: h as f32, + dirty: false, + } + } + + /// Rebuild the brush with the current font set (after adding fonts). + pub fn rebuild_with_fonts(&mut self, fonts: Vec) { + self.brush = self.brush.to_builder().replace_fonts(|_| fonts).build(); + let (w, h) = self.brush.texture_dimensions(); + self.cache_img = image::ImageBuffer::from_pixel(w, h, image::Luma([0])); + self.cache_w = w as f32; + self.cache_h = h as f32; + self.dirty = false; + } + + /// Queue a section for layout and rasterization. + pub fn queue<'a>(&mut self, section: impl Into>>) { + self.brush.queue(section); + } + + /// Compute the bounding rectangle for a section. + pub fn glyph_bounds<'a>( + &mut self, + section: impl Into>>, + ) -> Option { + self.brush.glyph_bounds(section) + } + + /// Process queued sections, rasterizing glyphs and producing quad + /// data. Returns `Some(quads)` if new vertices need to be drawn, + /// or `None` if the previous frame's data can be reused. + /// + /// Also marks whether the cache image is dirty (needs re-upload). + pub fn process(&mut self) -> Option> { + let cache_img = &mut self.cache_img; + let dirty = &mut self.dirty; + + let mut result; + loop { + // Capture dimensions each iteration (they change on resize). + let cw = cache_img.width() as f32; + let ch = cache_img.height() as f32; + result = self.brush.process_queued( + // Callback: write rasterized glyph data into cache image. + |rect, tex_data| { + let src = image::ImageBuffer::, Vec>::from_vec( + rect.width(), + rect.height(), + tex_data.to_vec(), + ) + .expect("glyph rasterization buffer size mismatch"); + image::imageops::replace( + cache_img, + &src, + rect.min[0] as i64, + rect.min[1] as i64, + ); + *dirty = true; + }, + // Callback: convert GlyphVertex -> GlyphQuad. + |gv| { + let mut tex_coords = gv.tex_coords; + let pixel_coords = gv.pixel_coords; + let bounds = gv.bounds; + + // Clip glyph rect to section bounds. + let mut gl_rect = ab_glyph::Rect { + min: ab_glyph::point(pixel_coords.min.x, pixel_coords.min.y), + max: ab_glyph::point(pixel_coords.max.x, pixel_coords.max.y), + }; + + if gl_rect.max.x > bounds.max.x { + let old_width = gl_rect.width(); + gl_rect.max.x = bounds.max.x; + tex_coords.max.x = + tex_coords.min.x + tex_coords.width() * gl_rect.width() / old_width; + } + if gl_rect.min.x < bounds.min.x { + let old_width = gl_rect.width(); + gl_rect.min.x = bounds.min.x; + tex_coords.min.x = + tex_coords.max.x - tex_coords.width() * gl_rect.width() / old_width; + } + if gl_rect.max.y > bounds.max.y { + let old_height = gl_rect.height(); + gl_rect.max.y = bounds.max.y; + tex_coords.max.y = tex_coords.min.y + + tex_coords.height() * gl_rect.height() / old_height; + } + if gl_rect.min.y < bounds.min.y { + let old_height = gl_rect.height(); + gl_rect.min.y = bounds.min.y; + tex_coords.min.y = tex_coords.max.y + - tex_coords.height() * gl_rect.height() / old_height; + } + + // tex_coords are in normalized 0..1 space of the + // glyph cache image. Convert to pixel coordinates. + let tex_offset_px = UVec2::new( + (tex_coords.min.x * cw) as u32, + (tex_coords.min.y * ch) as u32, + ); + let tex_size_px = UVec2::new( + ((tex_coords.max.x - tex_coords.min.x) * cw) as u32, + ((tex_coords.max.y - tex_coords.min.y) * ch) as u32, + ); + + GlyphQuad { + position: Vec2::new(gl_rect.min.x, gl_rect.min.y), + size: Vec2::new(gl_rect.width(), gl_rect.height()), + tex_offset_px, + tex_size_px, + color: Vec4::new( + gv.extra.color[0], + gv.extra.color[1], + gv.extra.color[2], + gv.extra.color[3], + ), + } + }, + ); + + match &result { + Err(glyph_brush::BrushError::TextureTooSmall { suggested, .. }) => { + let (new_w, new_h) = *suggested; + let max_dim = 2048; + let (new_w, new_h) = if (new_w > max_dim || new_h > max_dim) + && (cache_img.width() < max_dim || cache_img.height() < max_dim) + { + (max_dim, max_dim) + } else { + (new_w, new_h) + }; + *cache_img = image::ImageBuffer::from_pixel(new_w, new_h, image::Luma([0])); + self.brush.resize_texture(new_w, new_h); + *dirty = true; + } + Ok(_) => break, + } + } + + match result.unwrap() { + glyph_brush::BrushAction::Draw(quads) => Some(quads), + glyph_brush::BrushAction::ReDraw => None, + } + } + + /// Returns the cache image if it has been modified since the last + /// call to `take_image()`, converting from Luma8 to RGBA8 (white + + /// alpha). + pub fn take_image(&mut self) -> Option { + if !self.dirty { + return None; + } + self.dirty = false; + let (w, h) = (self.cache_img.width(), self.cache_img.height()); + let rgba = image::RgbaImage::from_fn(w, h, |x, y| { + let luma = self.cache_img.get_pixel(x, y).0[0]; + image::Rgba([255, 255, 255, luma]) + }); + Some(rgba) + } + } + + /// A live handle to a text element in the renderer. + /// + /// This represents a block of text rendered as a set of glyph quads. + /// Each glyph is a separate draw call internally, but they are all + /// managed as a single logical element. + /// + /// **Dropping this handle does NOT remove the text** — call + /// [`UiRenderer::remove_text`] explicitly. + #[derive(Clone, Debug)] + pub struct UiText { + /// The descriptors for each glyph quad (one per visible glyph). + pub(crate) glyph_descriptors: Vec>, + /// Per-glyph atlas texture descriptors (kept alive for slab lifetime). + #[allow(dead_code)] + pub(crate) glyph_atlas_descriptors: + Vec>, + /// Bounding box of the text (min, max) in screen pixels. + pub(crate) bounds: (Vec2, Vec2), + /// Unique identifier for this text block. + #[allow(dead_code)] + pub(crate) text_id: u64, + } + + impl UiText { + /// Returns the bounding box of the laid-out text (min, max) in + /// screen pixels. + pub fn bounds(&self) -> (Vec2, Vec2) { + self.bounds + } + + /// Set the z-depth for all glyphs in this text block. + pub fn set_z(&self, z: f32) -> &Self { + for desc in &self.glyph_descriptors { + desc.modify(|d| d.z = z); + } + self + } + + /// Set the z-depth for all glyphs (builder). + pub fn with_z(self, z: f32) -> Self { + self.set_z(z); + self + } + + /// Set the opacity for all glyphs in this text block. + pub fn set_opacity(&self, opacity: f32) -> &Self { + for desc in &self.glyph_descriptors { + desc.modify(|d| d.opacity = opacity); + } + self + } + + /// Set the opacity for all glyphs (builder). + pub fn with_opacity(self, opacity: f32) -> Self { + self.set_opacity(opacity); + self + } + } +} + +#[cfg(feature = "text")] +use text::GlyphCache; +#[cfg(feature = "text")] +pub use text::{FontArc, FontId, Section, Text, UiText}; + // --------------------------------------------------------------------------- // Internal draw call entry // --------------------------------------------------------------------------- @@ -533,6 +813,19 @@ pub struct UiRenderer { format: wgpu::TextureFormat, /// MSAA resolve texture (if msaa_sample_count > 1). msaa_texture: Option, + + // --- Text support (behind "text" feature) --- + #[cfg(feature = "text")] + fonts: Vec, + #[cfg(feature = "text")] + glyph_cache: GlyphCache, + /// Atlas texture entry for the glyph cache image. Replaced when the + /// cache image is re-uploaded. + #[cfg(feature = "text")] + glyph_cache_atlas_texture: Option, + /// Monotonic counter for assigning unique text block IDs. + #[cfg(feature = "text")] + next_text_id: u64, } impl UiRenderer { @@ -591,6 +884,14 @@ impl UiRenderer { msaa_sample_count: 1, format, msaa_texture: None, + #[cfg(feature = "text")] + fonts: Vec::new(), + #[cfg(feature = "text")] + glyph_cache: GlyphCache::new(Vec::new()), + #[cfg(feature = "text")] + glyph_cache_atlas_texture: None, + #[cfg(feature = "text")] + next_text_id: 0, } } @@ -731,6 +1032,132 @@ impl UiRenderer { element } + /// Register a font and return its [`FontId`]. + /// + /// Fonts must be registered before they can be used in + /// [`Section`]/[`Text`] for [`add_text`](Self::add_text). + /// + /// ```ignore + /// let bytes = std::fs::read("fonts/MyFont.ttf").unwrap(); + /// let font = FontArc::try_from_vec(bytes).unwrap(); + /// let font_id = ui.add_font(font); + /// ``` + #[cfg(feature = "text")] + pub fn add_font(&mut self, font: FontArc) -> FontId { + let id = self.fonts.len(); + self.fonts.push(font); + self.glyph_cache.rebuild_with_fonts(self.fonts.clone()); + FontId(id) + } + + /// Add a text element from a glyph_brush [`Section`]. + /// + /// This rasterizes the glyphs, uploads the cache image to the atlas, + /// and creates one draw call per visible glyph. + /// + /// ```ignore + /// use glyph_brush::{Section, Text}; + /// let font_id = ui.add_font(my_font); + /// let _text = ui.add_text( + /// Section::default() + /// .add_text( + /// Text::new("Hello, UI!") + /// .with_scale(32.0) + /// .with_color([0.0, 0.0, 0.0, 1.0]) + /// ) + /// .with_screen_position((10.0, 10.0)) + /// ); + /// ``` + #[cfg(feature = "text")] + pub fn add_text<'a>( + &mut self, + section: impl Into>>, + ) -> UiText { + use renderling::atlas::shader::AtlasTextureDescriptor; + + let section = section.into(); + + // Compute text bounds. + let bounds = self + .glyph_cache + .glyph_bounds(section.clone()) + .map(|r| (Vec2::new(r.min.x, r.min.y), Vec2::new(r.max.x, r.max.y))) + .unwrap_or((Vec2::ZERO, Vec2::ZERO)); + + // Queue and process. + self.glyph_cache.queue(section); + let quads = self.glyph_cache.process().unwrap_or_default(); + + // Upload the glyph cache image to the atlas (if dirty). + if let Some(rgba_img) = self.glyph_cache.take_image() { + // Drop old atlas entry (if any) so the atlas can reclaim space. + self.glyph_cache_atlas_texture = None; + + let atlas_img = AtlasImage::from(image::DynamicImage::ImageRgba8(rgba_img)); + let atlas_tex = self + .atlas + .add_image(&atlas_img) + .expect("failed to upload glyph cache to atlas"); + + // Update the viewport with the (possibly new) atlas size. + let atlas_extent = self.atlas.get_size(); + self.viewport.modify(|v| { + v.atlas_size = UVec2::new(atlas_extent.width, atlas_extent.height); + }); + + self.glyph_cache_atlas_texture = Some(atlas_tex); + } + + // Get the atlas placement of the glyph cache image. + let cache_atlas_desc = self + .glyph_cache_atlas_texture + .as_ref() + .expect("glyph cache not uploaded") + .descriptor(); + + let text_id = self.next_text_id; + self.next_text_id += 1; + + let mut glyph_descriptors = Vec::with_capacity(quads.len()); + let mut glyph_atlas_descriptors = Vec::with_capacity(quads.len()); + + for quad in &quads { + // Create an AtlasTextureDescriptor for this specific glyph's + // sub-region within the glyph cache, which itself is a sub- + // region of the atlas. + let glyph_atlas_desc = AtlasTextureDescriptor { + offset_px: cache_atlas_desc.offset_px + quad.tex_offset_px, + size_px: quad.tex_size_px, + layer_index: cache_atlas_desc.layer_index, + frame_index: 0, + ..Default::default() + }; + let glyph_atlas_hybrid = self.slab.new_value(glyph_atlas_desc); + + let mut desc = self.default_descriptor(UiElementType::TextGlyph); + desc.position = quad.position; + desc.size = quad.size; + desc.fill_color = quad.color; + desc.atlas_texture_id = glyph_atlas_hybrid.id().inner(); + + let hybrid = self.slab.new_value(desc); + self.draw_calls.push(DrawCall { + descriptor: hybrid.clone(), + vertex_count: 6, + }); + + glyph_descriptors.push(hybrid); + glyph_atlas_descriptors.push(glyph_atlas_hybrid); + } + + UiText { + glyph_descriptors, + glyph_atlas_descriptors, + bounds, + text_id, + } + } + /// Remove a rectangle element by its handle. pub fn remove_rect(&mut self, element: &UiRect) { self.remove_by_id(element.id()); @@ -751,6 +1178,14 @@ impl UiRenderer { self.remove_by_id(element.id()); } + /// Remove a text element by its handle. + #[cfg(feature = "text")] + pub fn remove_text(&mut self, element: &UiText) { + for desc in &element.glyph_descriptors { + self.remove_by_id(desc.id()); + } + } + /// Remove all elements. pub fn clear(&mut self) { self.draw_calls.clear(); diff --git a/crates/renderling-ui/src/test.rs b/crates/renderling-ui/src/test.rs index c77ffb46..b915b727 100644 --- a/crates/renderling-ui/src/test.rs +++ b/crates/renderling-ui/src/test.rs @@ -216,4 +216,76 @@ mod tests { let img = futures_lite::future::block_on(frame.read_image()).unwrap(); save_and_assert("ui2d/gradient_rect.png", img); } + + #[cfg(feature = "text")] + #[test] + fn can_render_text() { + use crate::{FontArc, Section, Text}; + + init_logging(); + let ctx = futures_lite::future::block_on(Context::headless(400, 100)); + let mut ui = UiRenderer::new(&ctx).with_background_color(Vec4::ONE); + + let font_bytes = + std::fs::read("../../fonts/Recursive Mn Lnr St Med Nerd Font Complete.ttf").unwrap(); + let font = FontArc::try_from_vec(font_bytes).unwrap(); + let _font_id = ui.add_font(font); + + let _text = ui.add_text( + Section::default() + .add_text( + Text::new("Hello, renderling-ui!") + .with_scale(32.0) + .with_color([0.0, 0.0, 0.0, 1.0]), + ) + .with_screen_position((10.0, 10.0)), + ); + + let frame = ctx.get_next_frame().unwrap(); + ui.render(&frame.view()); + let img = futures_lite::future::block_on(frame.read_image()).unwrap(); + save_and_assert("ui2d/text.png", img); + } + + #[cfg(feature = "text")] + #[test] + fn can_render_text_with_shapes() { + use crate::{FontArc, Section, Text}; + + init_logging(); + let ctx = futures_lite::future::block_on(Context::headless(400, 100)); + let mut ui = UiRenderer::new(&ctx).with_background_color(Vec4::ONE); + + let font_bytes = + std::fs::read("../../fonts/Recursive Mn Lnr St Med Nerd Font Complete.ttf").unwrap(); + let font = FontArc::try_from_vec(font_bytes).unwrap(); + let _font_id = ui.add_font(font); + + // Background rounded rect behind the text. + let _bg = ui + .add_rect() + .with_position(Vec2::new(5.0, 5.0)) + .with_size(Vec2::new(350.0, 50.0)) + .with_corner_radii(Vec4::splat(8.0)) + .with_fill_color(Vec4::new(0.2, 0.4, 0.8, 1.0)) + .with_z(0.0); + + // Text on top. + let _text = ui + .add_text( + Section::default() + .add_text( + Text::new("Text on a rect!") + .with_scale(28.0) + .with_color([1.0, 1.0, 1.0, 1.0]), + ) + .with_screen_position((15.0, 15.0)), + ) + .with_z(0.1); + + let frame = ctx.get_next_frame().unwrap(); + ui.render(&frame.view()); + let img = futures_lite::future::block_on(frame.read_image()).unwrap(); + save_and_assert("ui2d/text_with_shapes.png", img); + } } diff --git a/test_img/ui2d/text.png b/test_img/ui2d/text.png new file mode 100644 index 0000000000000000000000000000000000000000..f1ca7d1e8de8ea93cc0d1628827136f37dfef2a4 GIT binary patch literal 6726 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 Nci!K0_s@Sz|2Gelf#U!G literal 0 HcmV?d00001 diff --git a/test_img/ui2d/text_with_shapes.png b/test_img/ui2d/text_with_shapes.png new file mode 100644 index 0000000000000000000000000000000000000000..52ed938dd6329e2240e4ce4c8525df679151f21d GIT binary patch literal 4363 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 Date: Sun, 15 Mar 2026 09:51:43 +1300 Subject: [PATCH 04/14] feat: Add lyon-based vector path rendering to renderling-ui Port path/vector rendering from the old UI module to the new lightweight 2D pipeline. Uses lyon for tessellation (fill and stroke), de-indexes the triangle mesh, and writes UiVertex arrays to the slab. The existing vertex shader's Path branch reads pre-tessellated vertices from the slab, and the fragment shader passes through the interpolated vertex color. - Add UiPathBuilder with lyon path commands (begin, line_to, quadratic/cubic bezier, add_rectangle, add_circle, etc.) - Add fill() and stroke() methods for tessellation - Add StrokeConfig (line width, cap, join) configuration - Add UiPath handle type with z-depth and opacity setters - Add path_builder() and remove_path() to UiRenderer - Re-export StrokeConfig, UiPath, UiPathBuilder behind 'path' feature - Add 3 path tests (filled triangle, stroked circle, mixed shapes) - 13 renderling-ui tests + 101 renderling tests pass, 0 clippy warnings --- crates/renderling-ui/src/lib.rs | 4 + crates/renderling-ui/src/renderer.rs | 526 +++++++++++++++++++++++++++ crates/renderling-ui/src/test.rs | 81 +++++ test_img/ui2d/filled_path.png | Bin 0 -> 1821 bytes test_img/ui2d/path_shapes.png | Bin 0 -> 3433 bytes test_img/ui2d/stroked_path.png | Bin 0 -> 2187 bytes 6 files changed, 611 insertions(+) create mode 100644 test_img/ui2d/filled_path.png create mode 100644 test_img/ui2d/path_shapes.png create mode 100644 test_img/ui2d/stroked_path.png diff --git a/crates/renderling-ui/src/lib.rs b/crates/renderling-ui/src/lib.rs index 3fb18a5a..f18bf9ea 100644 --- a/crates/renderling-ui/src/lib.rs +++ b/crates/renderling-ui/src/lib.rs @@ -53,3 +53,7 @@ pub use renderer::{UiCircle, UiEllipse, UiImage, UiRect, UiRenderer}; // Re-export text types (behind "text" feature). #[cfg(feature = "text")] pub use renderer::{FontArc, FontId, Section, Text, UiText}; + +// Re-export path types (behind "path" feature). +#[cfg(feature = "path")] +pub use renderer::{StrokeConfig, UiPath, UiPathBuilder}; diff --git a/crates/renderling-ui/src/renderer.rs b/crates/renderling-ui/src/renderer.rs index cf7be524..ff799a40 100644 --- a/crates/renderling-ui/src/renderer.rs +++ b/crates/renderling-ui/src/renderer.rs @@ -481,6 +481,510 @@ impl UiImage { } } +// --------------------------------------------------------------------------- +// Path types (behind "path" feature) +// --------------------------------------------------------------------------- + +#[cfg(feature = "path")] +mod path { + use super::*; + use craballoc::value::HybridArray; + use lyon::{ + geom, + math::Angle, + path::{builder::BorderRadii, traits::PathBuilder, Winding}, + tessellation::{ + BuffersBuilder, FillTessellator, FillVertex, LineCap, LineJoin, StrokeTessellator, + StrokeVertex, VertexBuffers, + }, + }; + use renderling::ui_slab::UiVertex; + + fn vec2_to_point(v: impl Into) -> geom::Point { + let v = v.into(); + geom::point(v.x, v.y) + } + + fn vec2_to_vec(v: impl Into) -> geom::Vector { + let v = v.into(); + geom::Vector::new(v.x, v.y) + } + + /// Number of per-vertex attributes (stroke_color[4] + fill_color[4]). + const NUM_ATTRIBUTES: usize = 8; + + /// Per-vertex attributes passed through lyon's attribute system. + #[derive(Clone, Copy)] + struct PathAttributes { + stroke_color: Vec4, + fill_color: Vec4, + } + + impl PathAttributes { + fn to_array(self) -> [f32; NUM_ATTRIBUTES] { + let s = self.stroke_color; + let f = self.fill_color; + [s.x, s.y, s.z, s.w, f.x, f.y, f.z, f.w] + } + + fn from_slice(s: &[f32]) -> Self { + Self { + stroke_color: Vec4::new(s[0], s[1], s[2], s[3]), + fill_color: Vec4::new(s[4], s[5], s[6], s[7]), + } + } + } + + /// Stroke rendering options. + pub struct StrokeConfig { + /// Line width in pixels. + pub line_width: f32, + /// Line cap style. + pub line_cap: LineCap, + /// Line join style. + pub line_join: LineJoin, + } + + impl Default for StrokeConfig { + fn default() -> Self { + Self { + line_width: 2.0, + line_cap: LineCap::Round, + line_join: LineJoin::Round, + } + } + } + + /// A builder for constructing 2D vector paths. + /// + /// Uses lyon for tessellation. Build a path with `begin`/`line_to`/ + /// `end` commands (or convenience methods like `add_rectangle`, + /// `add_circle`, etc.), then call `fill()` or `stroke()` to + /// tessellate and register the result with the renderer. + /// + /// ```ignore + /// let path = ui.path_builder() + /// .with_fill_color(Vec4::new(1.0, 0.0, 0.0, 1.0)) + /// .with_begin(Vec2::new(10.0, 10.0)) + /// .with_line_to(Vec2::new(100.0, 10.0)) + /// .with_line_to(Vec2::new(55.0, 80.0)) + /// .with_end(true) + /// .fill(&mut ui); + /// ``` + pub struct UiPathBuilder { + inner: lyon::path::BuilderWithAttributes, + attrs: PathAttributes, + stroke_config: StrokeConfig, + } + + impl UiPathBuilder { + pub(crate) fn new() -> Self { + Self { + inner: lyon::path::Path::builder_with_attributes(NUM_ATTRIBUTES), + attrs: PathAttributes { + stroke_color: Vec4::ZERO, + fill_color: Vec4::ONE, + }, + stroke_config: StrokeConfig::default(), + } + } + + // --- Color setters --- + + /// Set the fill color for subsequent path commands. + pub fn set_fill_color(&mut self, color: impl Into) -> &mut Self { + self.attrs.fill_color = color.into(); + self + } + + /// Set the fill color (builder). + pub fn with_fill_color(mut self, color: impl Into) -> Self { + self.set_fill_color(color); + self + } + + /// Set the stroke color for subsequent path commands. + pub fn set_stroke_color(&mut self, color: impl Into) -> &mut Self { + self.attrs.stroke_color = color.into(); + self + } + + /// Set the stroke color (builder). + pub fn with_stroke_color(mut self, color: impl Into) -> Self { + self.set_stroke_color(color); + self + } + + /// Set stroke options. + pub fn set_stroke_config(&mut self, config: StrokeConfig) -> &mut Self { + self.stroke_config = config; + self + } + + /// Set stroke options (builder). + pub fn with_stroke_config(mut self, config: StrokeConfig) -> Self { + self.stroke_config = config; + self + } + + // --- Path commands --- + + /// Begin a new sub-path at the given point. + pub fn begin(&mut self, at: impl Into) -> &mut Self { + let _ = self.inner.begin(vec2_to_point(at), &self.attrs.to_array()); + self + } + + /// Begin a new sub-path (builder). + pub fn with_begin(mut self, at: impl Into) -> Self { + self.begin(at); + self + } + + /// End the current sub-path, optionally closing it. + pub fn end(&mut self, close: bool) -> &mut Self { + self.inner.end(close); + self + } + + /// End the current sub-path (builder). + pub fn with_end(mut self, close: bool) -> Self { + self.end(close); + self + } + + /// Add a line segment to the given point. + pub fn line_to(&mut self, to: impl Into) -> &mut Self { + let _ = self + .inner + .line_to(vec2_to_point(to), &self.attrs.to_array()); + self + } + + /// Add a line segment (builder). + pub fn with_line_to(mut self, to: impl Into) -> Self { + self.line_to(to); + self + } + + /// Add a quadratic Bezier curve. + pub fn quadratic_bezier_to( + &mut self, + ctrl: impl Into, + to: impl Into, + ) -> &mut Self { + let _ = self.inner.quadratic_bezier_to( + vec2_to_point(ctrl), + vec2_to_point(to), + &self.attrs.to_array(), + ); + self + } + + /// Add a quadratic Bezier curve (builder). + pub fn with_quadratic_bezier_to( + mut self, + ctrl: impl Into, + to: impl Into, + ) -> Self { + self.quadratic_bezier_to(ctrl, to); + self + } + + /// Add a cubic Bezier curve. + pub fn cubic_bezier_to( + &mut self, + ctrl1: impl Into, + ctrl2: impl Into, + to: impl Into, + ) -> &mut Self { + let _ = self.inner.cubic_bezier_to( + vec2_to_point(ctrl1), + vec2_to_point(ctrl2), + vec2_to_point(to), + &self.attrs.to_array(), + ); + self + } + + /// Add a cubic Bezier curve (builder). + pub fn with_cubic_bezier_to( + mut self, + ctrl1: impl Into, + ctrl2: impl Into, + to: impl Into, + ) -> Self { + self.cubic_bezier_to(ctrl1, ctrl2, to); + self + } + + // --- Convenience shapes --- + + /// Add an axis-aligned rectangle. + pub fn add_rectangle(&mut self, min: impl Into, max: impl Into) -> &mut Self { + let min = min.into(); + let max = max.into(); + let rect = lyon::geom::Box2D::new(vec2_to_point(min), vec2_to_point(max)); + self.inner + .add_rectangle(&rect, Winding::Positive, &self.attrs.to_array()); + self + } + + /// Add a rectangle (builder). + pub fn with_rectangle(mut self, min: impl Into, max: impl Into) -> Self { + self.add_rectangle(min, max); + self + } + + /// Add a rounded rectangle. + pub fn add_rounded_rectangle( + &mut self, + min: impl Into, + max: impl Into, + top_left: f32, + top_right: f32, + bottom_left: f32, + bottom_right: f32, + ) -> &mut Self { + let min = min.into(); + let max = max.into(); + let rect = lyon::geom::Box2D::new(vec2_to_point(min), vec2_to_point(max)); + let radii = BorderRadii { + top_left, + top_right, + bottom_left, + bottom_right, + }; + self.inner.add_rounded_rectangle( + &rect, + &radii, + Winding::Positive, + &self.attrs.to_array(), + ); + self + } + + /// Add a rounded rectangle (builder). + pub fn with_rounded_rectangle( + mut self, + min: impl Into, + max: impl Into, + top_left: f32, + top_right: f32, + bottom_left: f32, + bottom_right: f32, + ) -> Self { + self.add_rounded_rectangle(min, max, top_left, top_right, bottom_left, bottom_right); + self + } + + /// Add a circle. + pub fn add_circle(&mut self, center: impl Into, radius: f32) -> &mut Self { + self.inner.add_circle( + vec2_to_point(center), + radius, + Winding::Positive, + &self.attrs.to_array(), + ); + self + } + + /// Add a circle (builder). + pub fn with_circle(mut self, center: impl Into, radius: f32) -> Self { + self.add_circle(center, radius); + self + } + + /// Add an ellipse. + pub fn add_ellipse( + &mut self, + center: impl Into, + radii: impl Into, + rotation: f32, + ) -> &mut Self { + let radii = radii.into(); + self.inner.add_ellipse( + vec2_to_point(center), + vec2_to_vec(radii), + Angle::radians(rotation), + Winding::Positive, + &self.attrs.to_array(), + ); + self + } + + /// Add an ellipse (builder). + pub fn with_ellipse( + mut self, + center: impl Into, + radii: impl Into, + rotation: f32, + ) -> Self { + self.add_ellipse(center, radii, rotation); + self + } + + /// Add a closed polygon from a series of points. + pub fn add_polygon(&mut self, points: &[Vec2]) -> &mut Self { + let pts: Vec> = points.iter().map(|p| vec2_to_point(*p)).collect(); + self.inner.add_polygon( + lyon::path::Polygon { + points: &pts, + closed: true, + }, + &self.attrs.to_array(), + ); + self + } + + /// Add a polygon (builder). + pub fn with_polygon(mut self, points: &[Vec2]) -> Self { + self.add_polygon(points); + self + } + + // --- Tessellation --- + + /// Tessellate the path as a filled shape and register it with the + /// renderer. Consumes the builder. + pub fn fill(self, renderer: &mut UiRenderer) -> UiPath { + let path = self.inner.build(); + let mut geometry = VertexBuffers::::new(); + let mut tessellator = FillTessellator::new(); + + tessellator + .tessellate_path( + path.as_slice(), + &Default::default(), + &mut BuffersBuilder::new(&mut geometry, |mut vertex: FillVertex| { + let p = vertex.position(); + let attrs = PathAttributes::from_slice(vertex.interpolated_attributes()); + UiVertex { + position: Vec2::new(p.x, p.y), + uv: Vec2::ZERO, + color: attrs.fill_color, + } + }), + ) + .expect("fill tessellation failed"); + + Self::upload(renderer, &geometry) + } + + /// Tessellate the path as a stroked outline and register it with + /// the renderer. Consumes the builder. + pub fn stroke(self, renderer: &mut UiRenderer) -> UiPath { + let path = self.inner.build(); + let mut geometry = VertexBuffers::::new(); + let mut tessellator = StrokeTessellator::new(); + + let opts = lyon::tessellation::StrokeOptions::default() + .with_line_width(self.stroke_config.line_width) + .with_line_cap(self.stroke_config.line_cap) + .with_line_join(self.stroke_config.line_join); + + tessellator + .tessellate_path( + path.as_slice(), + &opts, + &mut BuffersBuilder::new(&mut geometry, |mut vertex: StrokeVertex| { + let p = vertex.position(); + let attrs = PathAttributes::from_slice(vertex.interpolated_attributes()); + UiVertex { + position: Vec2::new(p.x, p.y), + uv: Vec2::ZERO, + color: attrs.stroke_color, + } + }), + ) + .expect("stroke tessellation failed"); + + Self::upload(renderer, &geometry) + } + + /// De-index the tessellated geometry, write vertices to the slab, + /// and create a draw call. + fn upload(renderer: &mut UiRenderer, geometry: &VertexBuffers) -> UiPath { + // De-index: expand indexed triangles to flat vertex list. + let expanded: Vec = geometry + .indices + .iter() + .map(|&i| geometry.vertices[i as usize]) + .collect(); + + let vertex_count = expanded.len() as u32; + let vertex_array = renderer.slab.new_array(expanded); + let vertex_offset = vertex_array.array().starting_index() as u32; + + let mut desc = renderer.default_descriptor(UiElementType::Path); + desc.atlas_texture_id = vertex_offset; // repurposed as vertex + // slab offset + let hybrid = renderer.slab.new_value(desc); + renderer.draw_calls.push(DrawCall { + descriptor: hybrid.clone(), + vertex_count, + }); + + UiPath { + inner: hybrid, + _vertices: vertex_array, + } + } + } + + /// A live handle to a tessellated path element in the renderer. + /// + /// **Dropping this handle does NOT remove the path** — call + /// [`UiRenderer::remove_path`] explicitly. + pub struct UiPath { + inner: Hybrid, + /// Kept alive so the slab doesn't reclaim the vertex data. + _vertices: HybridArray, + } + + impl std::fmt::Debug for UiPath { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("UiPath") + .field("inner", &self.inner) + .finish_non_exhaustive() + } + } + + impl UiPath { + /// Returns the slab [`Id`] of the underlying descriptor. + pub fn id(&self) -> Id { + self.inner.id() + } + + /// Set the z-depth for sorting. + pub fn set_z(&self, z: f32) -> &Self { + self.inner.modify(|d| d.z = z); + self + } + + /// Set the z-depth for sorting (builder). + pub fn with_z(self, z: f32) -> Self { + self.set_z(z); + self + } + + /// Set the opacity. + pub fn set_opacity(&self, opacity: f32) -> &Self { + self.inner.modify(|d| d.opacity = opacity); + self + } + + /// Set the opacity (builder). + pub fn with_opacity(self, opacity: f32) -> Self { + self.set_opacity(opacity); + self + } + } +} + +#[cfg(feature = "path")] +pub use path::{StrokeConfig, UiPath, UiPathBuilder}; + // --------------------------------------------------------------------------- // Text types (behind "text" feature) // --------------------------------------------------------------------------- @@ -1186,6 +1690,28 @@ impl UiRenderer { } } + /// Create a new path builder for constructing vector paths. + /// + /// Use the builder's methods to define shapes, then call `.fill()` + /// or `.stroke()` to tessellate and register the path. + /// + /// ```ignore + /// let path = ui.path_builder() + /// .with_fill_color(Vec4::new(1.0, 0.0, 0.0, 1.0)) + /// .with_circle(Vec2::new(50.0, 50.0), 30.0) + /// .fill(&mut ui); + /// ``` + #[cfg(feature = "path")] + pub fn path_builder(&self) -> UiPathBuilder { + UiPathBuilder::new() + } + + /// Remove a path element by its handle. + #[cfg(feature = "path")] + pub fn remove_path(&mut self, element: &UiPath) { + self.remove_by_id(element.id()); + } + /// Remove all elements. pub fn clear(&mut self) { self.draw_calls.clear(); diff --git a/crates/renderling-ui/src/test.rs b/crates/renderling-ui/src/test.rs index b915b727..db513fbd 100644 --- a/crates/renderling-ui/src/test.rs +++ b/crates/renderling-ui/src/test.rs @@ -247,6 +247,87 @@ mod tests { save_and_assert("ui2d/text.png", img); } + #[cfg(feature = "path")] + #[test] + fn can_render_filled_path() { + init_logging(); + let ctx = futures_lite::future::block_on(Context::headless(200, 200)); + let mut ui = UiRenderer::new(&ctx).with_background_color(Vec4::ONE); + + // Filled red triangle. + let _path = ui + .path_builder() + .with_fill_color(Vec4::new(1.0, 0.0, 0.0, 1.0)) + .with_begin(Vec2::new(100.0, 20.0)) + .with_line_to(Vec2::new(180.0, 160.0)) + .with_line_to(Vec2::new(20.0, 160.0)) + .with_end(true) + .fill(&mut ui); + + let frame = ctx.get_next_frame().unwrap(); + ui.render(&frame.view()); + let img = futures_lite::future::block_on(frame.read_image()).unwrap(); + save_and_assert("ui2d/filled_path.png", img); + } + + #[cfg(feature = "path")] + #[test] + fn can_render_stroked_path() { + init_logging(); + let ctx = futures_lite::future::block_on(Context::headless(200, 200)); + let mut ui = UiRenderer::new(&ctx).with_background_color(Vec4::ONE); + + // Blue stroked circle. + let _path = ui + .path_builder() + .with_stroke_color(Vec4::new(0.0, 0.0, 1.0, 1.0)) + .with_circle(Vec2::new(100.0, 100.0), 60.0) + .stroke(&mut ui); + + let frame = ctx.get_next_frame().unwrap(); + ui.render(&frame.view()); + let img = futures_lite::future::block_on(frame.read_image()).unwrap(); + save_and_assert("ui2d/stroked_path.png", img); + } + + #[cfg(feature = "path")] + #[test] + fn can_render_path_shapes() { + init_logging(); + let ctx = futures_lite::future::block_on(Context::headless(300, 200)); + let mut ui = UiRenderer::new(&ctx).with_background_color(Vec4::ONE); + + // Filled rounded rectangle. + let _rect = ui + .path_builder() + .with_fill_color(Vec4::new(0.2, 0.6, 0.3, 1.0)) + .with_rounded_rectangle( + Vec2::new(10.0, 10.0), + Vec2::new(140.0, 90.0), + 12.0, + 12.0, + 12.0, + 12.0, + ) + .fill(&mut ui); + + // Stroked ellipse. + let _ellipse = ui + .path_builder() + .with_stroke_color(Vec4::new(0.8, 0.2, 0.1, 1.0)) + .with_stroke_config(crate::StrokeConfig { + line_width: 3.0, + ..Default::default() + }) + .with_ellipse(Vec2::new(220.0, 100.0), Vec2::new(60.0, 40.0), 0.0) + .stroke(&mut ui); + + let frame = ctx.get_next_frame().unwrap(); + ui.render(&frame.view()); + let img = futures_lite::future::block_on(frame.read_image()).unwrap(); + save_and_assert("ui2d/path_shapes.png", img); + } + #[cfg(feature = "text")] #[test] fn can_render_text_with_shapes() { diff --git a/test_img/ui2d/filled_path.png b/test_img/ui2d/filled_path.png new file mode 100644 index 0000000000000000000000000000000000000000..a10342f4832f0b81b10efbecc22800e6cc65de56 GIT binary patch literal 1821 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 LR90xuKc@c|9>$Cb literal 0 HcmV?d00001 From e43755cf9ca3c7bd3062a9ca8dc30b05ff9d7842 Mon Sep 17 00:00:00 2001 From: Schell Carl Scivally Date: Sun, 15 Mar 2026 13:11:47 +1300 Subject: [PATCH 05/14] Remove old ui module from renderling, migrate example crate to renderling-ui Delete the old renderling::ui module (cpu.rs, sdf.rs, text.rs, path.rs) and its test baselines, remove the 'ui' feature flag and its optional deps (glyph_brush, lyon, loading-bytes). Move loading-bytes to dev-dependencies since it's still needed by wasm tests. Update the example crate to use renderling-ui's UiRenderer, UiRect, UiText, and Section APIs instead of the removed Ui type. --- Cargo.lock | 3 +- crates/example/Cargo.toml | 1 + crates/example/src/lib.rs | 51 +- crates/renderling/Cargo.toml | 7 +- crates/renderling/src/context.rs | 8 - crates/renderling/src/lib.rs | 2 - crates/renderling/src/ui.rs | 29 -- crates/renderling/src/ui/cpu.rs | 378 -------------- crates/renderling/src/ui/cpu/path.rs | 705 --------------------------- crates/renderling/src/ui/cpu/text.rs | 517 -------------------- crates/renderling/src/ui/sdf.rs | 24 - test_img/ui/path/fill_image.png | Bin 34450 -> 0 bytes test_img/ui/path/sanity.png | Bin 751 -> 0 bytes test_img/ui/path/shapes.png | Bin 6352 -> 0 bytes test_img/ui/text/can_display.png | Bin 22151 -> 0 bytes test_img/ui/text/can_recreate_0.png | Bin 1952 -> 0 bytes test_img/ui/text/can_recreate_1.png | Bin 874 -> 0 bytes test_img/ui/text/overlay.png | Bin 34826 -> 0 bytes test_img/ui/text/overlay_depth.png | Bin 5579 -> 0 bytes 19 files changed, 35 insertions(+), 1690 deletions(-) delete mode 100644 crates/renderling/src/ui.rs delete mode 100644 crates/renderling/src/ui/cpu.rs delete mode 100644 crates/renderling/src/ui/cpu/path.rs delete mode 100644 crates/renderling/src/ui/cpu/text.rs delete mode 100644 crates/renderling/src/ui/sdf.rs delete mode 100644 test_img/ui/path/fill_image.png delete mode 100644 test_img/ui/path/sanity.png delete mode 100644 test_img/ui/path/shapes.png delete mode 100644 test_img/ui/text/can_display.png delete mode 100644 test_img/ui/text/can_recreate_0.png delete mode 100644 test_img/ui/text/can_recreate_1.png delete mode 100644 test_img/ui/text/overlay.png delete mode 100644 test_img/ui/text/overlay_depth.png diff --git a/Cargo.lock b/Cargo.lock index ffca4e63..d73ec651 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1167,6 +1167,7 @@ dependencies = [ "loading-bytes", "log", "renderling", + "renderling-ui", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -3714,7 +3715,6 @@ dependencies = [ "futures-lite 1.13.0", "glam", "gltf", - "glyph_brush", "half", "human-repr", "icosahedron", @@ -3722,7 +3722,6 @@ dependencies = [ "img-diff", "loading-bytes", "log", - "lyon", "metal", "naga", "pathdiff", diff --git a/crates/example/Cargo.toml b/crates/example/Cargo.toml index 61dd5a72..29a736f3 100644 --- a/crates/example/Cargo.toml +++ b/crates/example/Cargo.toml @@ -22,6 +22,7 @@ lazy_static = "1.4.0" loading-bytes = { path = "../loading-bytes" } log = { workspace = true } renderling = { path = "../renderling" } +renderling-ui = { path = "../renderling-ui", features = ["text", "path"] } wasm-bindgen = { workspace = true } wasm-bindgen-futures = "^0.4" web-sys = { workspace = true, features = ["Performance", "Window"] } diff --git a/crates/example/src/lib.rs b/crates/example/src/lib.rs index 36d4ebf6..b74a96d6 100644 --- a/crates/example/src/lib.rs +++ b/crates/example/src/lib.rs @@ -18,8 +18,8 @@ use renderling::{ primitive::Primitive, skybox::Skybox, stage::Stage, - ui::{FontArc, Section, Text, Ui, UiPath, UiText}, }; +use renderling_ui::{FontArc, Section, Text, UiRect, UiRenderer, UiText}; pub mod camera; use camera::{ @@ -75,30 +75,37 @@ fn now() -> f64 { } struct AppUi { - ui: Ui, + ui: UiRenderer, fps_text: UiText, fps_counter: FPSCounter, - fps_background: UiPath, + fps_background: UiRect, last_fps_display: f64, } impl AppUi { - fn make_fps_widget(fps_counter: &FPSCounter, ui: &Ui) -> (UiText, UiPath) { - let translation = Vec2::new(2.0, 2.0); + fn make_fps_widget( + fps_counter: &FPSCounter, + ui: &mut UiRenderer, + ) -> (UiText, UiRect) { + let offset = Vec2::new(2.0, 2.0); let text = format!("{}fps", fps_counter.current_fps_string()); - let fps_text = ui - .text_builder() - .with_color(Vec3::ZERO.extend(1.0)) - .with_section(Section::new().add_text(Text::new(&text).with_scale(32.0))) - .build(); - fps_text.transform().set_translation(translation); + let fps_text = ui.add_text( + Section::default() + .add_text( + Text::new(&text) + .with_scale(32.0) + .with_color([0.0, 0.0, 0.0, 1.0]), + ) + .with_screen_position((offset.x, offset.y)), + ); + let (min, max) = fps_text.bounds(); + let size = max - min; let background = ui - .path_builder() + .add_rect() + .with_position(min) + .with_size(size) .with_fill_color(Vec4::ONE) - .with_rectangle(fps_text.bounds().0, fps_text.bounds().1) - .fill(); - background.transform.set_translation(translation); - background.transform.set_z(-0.9); + .with_z(-0.9); (fps_text, background) } @@ -106,7 +113,11 @@ impl AppUi { self.fps_counter.next_frame(); let now = now(); if now - self.last_fps_display >= 1.0 { - let (fps_text, background) = Self::make_fps_widget(&self.fps_counter, &self.ui); + // Remove old text and background before recreating. + self.ui.remove_text(&self.fps_text); + self.ui.remove_rect(&self.fps_background); + let (fps_text, background) = + Self::make_fps_widget(&self.fps_counter, &mut self.ui); self.fps_text = fps_text; self.fps_background = background; self.last_fps_display = now; @@ -160,10 +171,10 @@ impl App { }) .unwrap(); - let ui = Ui::new(ctx).with_background_color(Vec4::ZERO); + let mut ui = UiRenderer::new(ctx).with_background_color(Vec4::ZERO); let _ = ui.add_font(FontArc::try_from_slice(FONT_BYTES).unwrap()); let fps_counter = FPSCounter::default(); - let (fps_text, fps_background) = AppUi::make_fps_widget(&fps_counter, &ui); + let (fps_text, fps_background) = AppUi::make_fps_widget(&fps_counter, &mut ui); Self { ui: AppUi { @@ -199,7 +210,7 @@ impl App { self.ui.tick(); } - pub fn render(&self, ctx: &Context) { + pub fn render(&mut self, ctx: &Context) { log::info!("render"); let frame = ctx.get_next_frame().unwrap(); self.stage.render(&frame.view()); diff --git a/crates/renderling/Cargo.toml b/crates/renderling/Cargo.toml index c94d1faa..50030b18 100644 --- a/crates/renderling/Cargo.toml +++ b/crates/renderling/Cargo.toml @@ -23,11 +23,10 @@ multimodule = true crate-type = ["rlib", "cdylib"] [features] -default = ["gltf", "ui", "winit"] +default = ["gltf", "winit"] gltf = ["dep:gltf", "dep:serde_json"] test_i8_i16_extraction = [] test-utils = ["dep:metal", "dep:wgpu-core", "dep:futures-lite"] -ui = ["dep:glyph_brush", "dep:loading-bytes", "dep:lyon"] wasm = ["wgpu/fragile-send-sync-non-atomic-wasm"] debug-slab = [] light-tiling-stats = [ "dep:plotters" ] @@ -62,12 +61,9 @@ dagga = {workspace=true} futures-lite = { workspace = true, optional = true } glam = { workspace = true, features = ["std"] } gltf = {workspace = true, optional = true} -glyph_brush = {workspace = true, optional = true} half = "2.3" image = {workspace = true, features = ["hdr"]} -loading-bytes = { workspace = true, optional = true } log = {workspace = true} -lyon = {workspace = true, optional = true} plotters = { workspace = true, optional = true } pretty_assertions.workspace = true rustc-hash = {workspace = true} @@ -91,6 +87,7 @@ futures-lite.workspace = true human-repr = "1.1.0" icosahedron = "0.1" img-diff = { path = "../img-diff" } +loading-bytes = { workspace = true } naga.workspace = true renderling_build = { path = "../renderling-build" } ttf-parser = "0.20.0" diff --git a/crates/renderling/src/context.rs b/crates/renderling/src/context.rs index 0bc6d8da..9cf7965e 100644 --- a/crates/renderling/src/context.rs +++ b/crates/renderling/src/context.rs @@ -16,9 +16,6 @@ use crate::{ stage::Stage, texture::{BufferDimensions, CopiedTextureBuffer, Texture, TextureError}, }; -#[cfg(feature = "ui")] -use crate::ui::Ui; - pub use craballoc::runtime::WgpuRuntime; /// Represents the internal structure of a render target, which can either be a @@ -632,9 +629,4 @@ impl Context { Stage::new(self) } - /// Creates and returns a new [`Ui`] renderer. - #[cfg(feature = "ui")] - pub fn new_ui(&self) -> Ui { - Ui::new(self) - } } diff --git a/crates/renderling/src/lib.rs b/crates/renderling/src/lib.rs index b70714d6..bc042b5c 100644 --- a/crates/renderling/src/lib.rs +++ b/crates/renderling/src/lib.rs @@ -234,8 +234,6 @@ pub mod transform; pub mod tutorial; #[cfg(cpu)] pub mod types; -#[cfg(feature = "ui")] -pub mod ui; pub mod ui_slab; pub extern crate glam; diff --git a/crates/renderling/src/ui.rs b/crates/renderling/src/ui.rs deleted file mode 100644 index e734e44d..00000000 --- a/crates/renderling/src/ui.rs +++ /dev/null @@ -1,29 +0,0 @@ -//! User interface rendering. -//! -//! # Getting Started -//! First we create a context, then we create a [`Ui`], which we can use to -//! "stage" our paths, text, etc: -//! -//! ```rust -//! use glam::Vec2; -//! use renderling::ui::prelude::*; -//! -//! let ctx = futures_lite::future::block_on(Context::headless(100, 100)); -//! let mut ui = Ui::new(&ctx); -//! -//! let _path = ui -//! .path_builder() -//! .with_stroke_color([1.0, 1.0, 0.0, 1.0]) -//! .with_rectangle(Vec2::splat(10.0), Vec2::splat(60.0)) -//! .stroke(); -//! -//! let frame = ctx.get_next_frame().unwrap(); -//! ui.render(&frame.view()); -//! frame.present(); -//! ``` -#[cfg(cpu)] -mod cpu; -#[cfg(cpu)] -pub use cpu::*; - -pub mod sdf; diff --git a/crates/renderling/src/ui/cpu.rs b/crates/renderling/src/ui/cpu.rs deleted file mode 100644 index 0b3259af..00000000 --- a/crates/renderling/src/ui/cpu.rs +++ /dev/null @@ -1,378 +0,0 @@ -//! CPU part of ui. - -use core::sync::atomic::AtomicBool; -use std::sync::{Arc, RwLock}; - -use crate::{ - atlas::{shader::AtlasTextureDescriptor, AtlasTexture, TextureAddressMode, TextureModes}, - camera::Camera, - context::Context, - stage::Stage, - transform::NestedTransform, -}; -use crabslab::Id; -use glam::{Quat, UVec2, Vec2, Vec3Swizzles, Vec4}; -use glyph_brush::ab_glyph; -use rustc_hash::FxHashMap; -use snafu::{prelude::*, ResultExt}; - -pub use glyph_brush::FontId; - -mod path; -pub use path::*; - -mod text; -pub use text::*; - -pub mod prelude { - //! A prelude for user interface development, meant to be glob-imported. - - #[cfg(cpu)] - pub extern crate craballoc; - pub extern crate glam; - - #[cfg(cpu)] - pub use craballoc::prelude::*; - pub use crabslab::{Array, Id}; - - #[cfg(cpu)] - pub use crate::context::*; - - pub use super::*; -} - -#[derive(Debug, Snafu)] -pub enum UiError { - #[snafu(display("{source}"))] - Loading { - source: loading_bytes::LoadingBytesError, - }, - - #[snafu(display("{source}"))] - InvalidFont { source: ab_glyph::InvalidFont }, - - #[snafu(display("{source}"))] - Image { source: image::ImageError }, - - #[snafu(display("{source}"))] - Stage { source: crate::stage::StageError }, -} - -/// An image identifier. -/// -/// This locates the image within a [`Ui`]. -/// -/// `ImageId` can be created with [`Ui::load_image`]. -#[repr(transparent)] -#[derive(Clone, Copy, Debug)] -pub struct ImageId(Id); - -/// A two dimensional transformation. -/// -/// Clones of `UiTransform` all point to the same data. -#[derive(Clone, Debug)] -pub struct UiTransform { - should_reorder: Arc, - transform: NestedTransform, -} - -impl UiTransform { - fn mark_should_reorder(&self) { - self.should_reorder - .store(true, std::sync::atomic::Ordering::Relaxed); - } - - pub fn set_translation(&self, t: Vec2) { - self.mark_should_reorder(); - self.transform.modify_local_translation(|a| { - a.x = t.x; - a.y = t.y; - }); - } - - pub fn get_translation(&self) -> Vec2 { - self.transform.local_translation().xy() - } - - pub fn set_rotation(&self, radians: f32) { - self.mark_should_reorder(); - let rotation = Quat::from_rotation_z(radians); - // TODO: check to see if *= rotation makes sense here - self.transform.modify_local_rotation(|t| { - *t *= rotation; - }); - } - - pub fn get_rotation(&self) -> f32 { - self.transform - .local_rotation() - .to_euler(glam::EulerRot::XYZ) - .2 - } - - pub fn set_z(&self, z: f32) { - self.mark_should_reorder(); - self.transform.modify_local_translation(|t| { - t.z = z; - }); - } - - pub fn get_z(&self) -> f32 { - self.transform.local_translation().z - } -} - -#[derive(Clone)] -#[repr(transparent)] -pub struct UiImage(AtlasTexture); - -/// A 2d user interface renderer. -/// -/// Clones of `Ui` all point to the same data. -#[derive(Clone)] -pub struct Ui { - camera: Camera, - stage: Stage, - should_reorder: Arc, - images: Arc, UiImage>>>, - fonts: Arc>>, - default_stroke_options: Arc>, - default_fill_options: Arc>, -} - -impl Ui { - pub fn new(ctx: &Context) -> Self { - let UVec2 { x, y } = ctx.get_size(); - let stage = ctx - .new_stage() - .with_background_color(Vec4::ONE) - .with_lighting(false) - .with_bloom(false) - .with_msaa_sample_count(4) - .with_frustum_culling(false); - let (proj, view) = crate::camera::default_ortho2d(x as f32, y as f32); - let camera = stage.new_camera().with_projection_and_view(proj, view); - Ui { - camera, - stage, - should_reorder: AtomicBool::new(true).into(), - images: Default::default(), - fonts: Default::default(), - default_stroke_options: Default::default(), - default_fill_options: Default::default(), - } - } - - pub fn set_clear_color_attachments(&self, should_clear: bool) { - self.stage.set_clear_color_attachments(should_clear); - } - - pub fn with_clear_color_attachments(self, should_clear: bool) -> Self { - self.set_clear_color_attachments(should_clear); - self - } - - pub fn set_clear_depth_attachments(&self, should_clear: bool) { - self.stage.set_clear_depth_attachments(should_clear); - } - - pub fn with_clear_depth_attachments(self, should_clear: bool) -> Self { - self.set_clear_depth_attachments(should_clear); - self - } - - pub fn set_background_color(&self, color: impl Into) -> &Self { - self.stage.set_background_color(color); - self - } - - pub fn with_background_color(self, color: impl Into) -> Self { - self.set_background_color(color); - self - } - - pub fn set_antialiasing(&self, antialiasing_is_on: bool) -> &Self { - let sample_count = if antialiasing_is_on { 4 } else { 1 }; - self.stage.set_msaa_sample_count(sample_count); - self - } - - pub fn with_antialiasing(self, antialiasing_is_on: bool) -> Self { - self.set_antialiasing(antialiasing_is_on); - self - } - - pub fn set_default_stroke_options(&self, options: StrokeOptions) -> &Self { - *self - .default_stroke_options - .write() - .expect("default_stroke_options write") = options; - self - } - - pub fn with_default_stroke_options(self, options: StrokeOptions) -> Self { - self.set_default_stroke_options(options); - self - } - - pub fn set_default_fill_options(&self, options: FillOptions) -> &Self { - *self - .default_fill_options - .write() - .expect("default_fill_options write") = options; - self - } - - pub fn with_default_fill_options(self, options: FillOptions) -> Self { - self.set_default_fill_options(options); - self - } - - fn new_transform(&self) -> UiTransform { - self.mark_should_reorder(); - let transform = self.stage.new_nested_transform(); - UiTransform { - transform, - should_reorder: self.should_reorder.clone(), - } - } - - fn mark_should_reorder(&self) { - self.should_reorder - .store(true, std::sync::atomic::Ordering::Relaxed) - } - - pub fn path_builder(&self) -> UiPathBuilder { - self.mark_should_reorder(); - UiPathBuilder::new(self) - } - - /// Remove the `path` from the [`Ui`]. - /// - /// The given `path` must have been created with this [`Ui`], otherwise this - /// function is a noop. - pub fn remove_path(&self, path: &UiPath) { - self.stage.remove_primitive(&path.primitive); - } - - pub fn text_builder(&self) -> UiTextBuilder { - self.mark_should_reorder(); - UiTextBuilder::new(self) - } - - /// Remove the text from the [`Ui`]. - /// - /// The given `text` must have been created with this [`Ui`], otherwise this - /// function is a noop. - pub fn remove_text(&self, text: &UiText) { - self.stage.remove_primitive(&text.renderlet); - } - - pub async fn load_font(&self, path: impl AsRef) -> Result { - let path_s = path.as_ref(); - let bytes = loading_bytes::load(path_s).await.context(LoadingSnafu)?; - let font = FontArc::try_from_vec(bytes).context(InvalidFontSnafu)?; - Ok(self.add_font(font)) - } - - pub fn add_font(&self, font: FontArc) -> FontId { - // UNWRAP: panic on purpose - let mut fonts = self.fonts.write().expect("fonts write"); - let id = fonts.len(); - fonts.push(font); - FontId(id) - } - - pub fn get_fonts(&self) -> Vec { - // UNWRAP: panic on purpose - self.fonts.read().expect("fonts read").clone() - } - - pub fn get_camera(&self) -> &Camera { - &self.camera - } - - pub async fn load_image(&self, path: impl AsRef) -> Result { - let path_s = path.as_ref(); - let bytes = loading_bytes::load(path_s).await.context(LoadingSnafu)?; - let img = image::load_from_memory_with_format( - bytes.as_slice(), - image::ImageFormat::from_path(path_s).context(ImageSnafu)?, - ) - .context(ImageSnafu)?; - let entry = self - .stage - .add_images(Some(img)) - .context(StageSnafu)? - .pop() - .unwrap(); - entry.set_modes(TextureModes { - s: TextureAddressMode::Repeat, - t: TextureAddressMode::Repeat, - }); - let mut guard = self.images.write().expect("images write"); - let id = entry.id(); - guard.insert(id, UiImage(entry)); - Ok(ImageId(id)) - } - - /// Remove an image previously loaded with [`Ui::load_image`]. - pub fn remove_image(&self, image_id: &ImageId) -> Option { - self.images - .write() - .expect("images write") - .remove(&image_id.0) - } - - fn reorder_renderlets(&self) { - self.stage.sort_primitive(|a, b| { - let za = a - .transform() - .as_ref() - .map(|t| t.translation().z) - .unwrap_or_default(); - let zb = b - .transform() - .as_ref() - .map(|t| t.translation().z) - .unwrap_or_default(); - za.total_cmp(&zb) - }); - } - - pub fn render(&self, view: &wgpu::TextureView) { - if self - .should_reorder - .swap(false, std::sync::atomic::Ordering::Relaxed) - { - self.reorder_renderlets(); - } - self.stage.render(view); - } -} - -#[cfg(test)] -pub(crate) mod test { - use crate::{color::rgb_hex_color, glam::Vec4}; - - pub struct Colors(std::iter::Cycle>); - - pub fn cute_beach_palette() -> [Vec4; 4] { - [ - rgb_hex_color(0x6DC5D1), - rgb_hex_color(0xFDE49E), - rgb_hex_color(0xFEB941), - rgb_hex_color(0xDD761C), - ] - } - - impl Colors { - pub fn from_array(colors: [Vec4; N]) -> Self { - Colors(colors.into_iter().cycle()) - } - - pub fn next_color(&mut self) -> Vec4 { - self.0.next().unwrap() - } - } -} diff --git a/crates/renderling/src/ui/cpu/path.rs b/crates/renderling/src/ui/cpu/path.rs deleted file mode 100644 index b518c023..00000000 --- a/crates/renderling/src/ui/cpu/path.rs +++ /dev/null @@ -1,705 +0,0 @@ -//! Path and builder. -//! -//! Path colors are sRGB. -use crate::{geometry::Vertex, material::Material, primitive::Primitive}; -use glam::{Vec2, Vec3, Vec3Swizzles, Vec4}; -use lyon::{ - path::traits::PathBuilder, - tessellation::{ - BuffersBuilder, FillTessellator, FillVertex, StrokeTessellator, StrokeVertex, VertexBuffers, - }, -}; - -use super::{ImageId, Ui, UiTransform}; -pub use lyon::tessellation::{LineCap, LineJoin}; - -pub struct UiPath { - pub transform: UiTransform, - pub material: Material, - pub primitive: Primitive, -} - -#[derive(Clone, Copy)] -struct PathAttributes { - stroke_color: Vec4, - fill_color: Vec4, -} - -impl Default for PathAttributes { - fn default() -> Self { - Self { - stroke_color: Vec4::ONE, - fill_color: Vec4::new(0.2, 0.2, 0.2, 1.0), - } - } -} - -impl PathAttributes { - const NUM_ATTRIBUTES: usize = 8; - - fn to_array(self) -> [f32; Self::NUM_ATTRIBUTES] { - [ - self.stroke_color.x, - self.stroke_color.y, - self.stroke_color.z, - self.stroke_color.w, - self.fill_color.x, - self.fill_color.y, - self.fill_color.z, - self.fill_color.w, - ] - } - - fn from_slice(s: &[f32]) -> Self { - Self { - stroke_color: Vec4::new(s[0], s[1], s[2], s[3]), - fill_color: Vec4::new(s[4], s[5], s[6], s[7]), - } - } -} - -#[derive(Clone, Copy, Debug)] -pub struct StrokeOptions { - pub line_width: f32, - pub line_cap: LineCap, - pub line_join: LineJoin, - pub image_id: Option, -} - -impl Default for StrokeOptions { - fn default() -> Self { - StrokeOptions { - line_width: 2.0, - line_cap: LineCap::Round, - line_join: LineJoin::Round, - image_id: None, - } - } -} - -#[derive(Clone, Copy, Debug, Default)] -pub struct FillOptions { - pub image_id: Option, -} - -#[derive(Clone)] -pub struct UiPathBuilder { - ui: Ui, - attributes: PathAttributes, - inner: lyon::path::BuilderWithAttributes, - default_stroke_options: StrokeOptions, - default_fill_options: FillOptions, -} - -fn vec2_to_point(v: impl Into) -> lyon::geom::Point { - let Vec2 { x, y } = v.into(); - lyon::geom::point(x, y) -} - -fn vec2_to_vec(v: impl Into) -> lyon::geom::Vector { - let Vec2 { x, y } = v.into(); - lyon::geom::Vector::new(x, y) -} - -impl UiPathBuilder { - pub fn new(ui: &Ui) -> Self { - Self { - ui: ui.clone(), - attributes: PathAttributes::default(), - inner: lyon::path::Path::builder_with_attributes(PathAttributes::NUM_ATTRIBUTES), - default_stroke_options: *ui - .default_stroke_options - .read() - .expect("default_stroke_options read"), - default_fill_options: *ui - .default_fill_options - .read() - .expect("default_fill_options read"), - } - } - - pub fn begin(&mut self, at: impl Into) -> &mut Self { - self.inner - .begin(vec2_to_point(at), &self.attributes.to_array()); - self - } - - pub fn with_begin(mut self, at: impl Into) -> Self { - self.begin(at); - self - } - - pub fn end(&mut self, close: bool) -> &mut Self { - self.inner.end(close); - self - } - - pub fn with_end(mut self, close: bool) -> Self { - self.end(close); - self - } - - pub fn line_to(&mut self, to: impl Into) -> &mut Self { - self.inner - .line_to(vec2_to_point(to), &self.attributes.to_array()); - self - } - - pub fn with_line_to(mut self, to: impl Into) -> Self { - self.line_to(to); - self - } - - pub fn quadratic_bezier_to(&mut self, ctrl: impl Into, to: impl Into) -> &mut Self { - self.inner.quadratic_bezier_to( - vec2_to_point(ctrl), - vec2_to_point(to), - &self.attributes.to_array(), - ); - self - } - - pub fn with_quadratic_bezier_to(mut self, ctrl: impl Into, to: impl Into) -> Self { - self.quadratic_bezier_to(ctrl, to); - self - } - - pub fn cubic_bezier_to( - &mut self, - ctrl1: impl Into, - ctrl2: impl Into, - to: impl Into, - ) -> &mut Self { - self.inner.cubic_bezier_to( - vec2_to_point(ctrl1), - vec2_to_point(ctrl2), - vec2_to_point(to), - &self.attributes.to_array(), - ); - self - } - - pub fn with_cubic_bezier_to( - mut self, - ctrl1: impl Into, - ctrl2: impl Into, - to: impl Into, - ) -> Self { - self.cubic_bezier_to(ctrl1, ctrl2, to); - self - } - - pub fn add_rectangle( - &mut self, - box_min: impl Into, - box_max: impl Into, - ) -> &mut Self { - let bx = lyon::geom::Box2D::new(vec2_to_point(box_min), vec2_to_point(box_max)); - self.inner.add_rectangle( - &bx, - lyon::path::Winding::Positive, - &self.attributes.to_array(), - ); - self - } - - pub fn with_rectangle(mut self, box_min: impl Into, box_max: impl Into) -> Self { - self.add_rectangle(box_min, box_max); - self - } - - pub fn add_rounded_rectangle( - &mut self, - box_min: impl Into, - box_max: impl Into, - top_left_radius: f32, - top_right_radius: f32, - bottom_left_radius: f32, - bottom_right_radius: f32, - ) -> &mut Self { - let rect = lyon::geom::Box2D { - min: vec2_to_point(box_min), - max: vec2_to_point(box_max), - }; - let radii = lyon::path::builder::BorderRadii { - top_left: top_left_radius, - top_right: top_right_radius, - bottom_left: bottom_left_radius, - bottom_right: bottom_right_radius, - }; - self.inner.add_rounded_rectangle( - &rect, - &radii, - lyon::path::Winding::Positive, - &self.attributes.to_array(), - ); - self - } - - pub fn with_rounded_rectangle( - mut self, - box_min: impl Into, - box_max: impl Into, - top_left_radius: f32, - top_right_radius: f32, - bottom_left_radius: f32, - bottom_right_radius: f32, - ) -> Self { - self.add_rounded_rectangle( - box_min, - box_max, - top_left_radius, - top_right_radius, - bottom_left_radius, - bottom_right_radius, - ); - self - } - - pub fn add_ellipse( - &mut self, - center: impl Into, - radii: impl Into, - rotation: f32, - ) -> &mut Self { - self.inner.add_ellipse( - vec2_to_point(center), - vec2_to_vec(radii), - lyon::path::math::Angle { radians: rotation }, - lyon::path::Winding::Positive, - &self.attributes.to_array(), - ); - self - } - - pub fn with_ellipse( - mut self, - center: impl Into, - radii: impl Into, - rotation: f32, - ) -> Self { - self.add_ellipse(center, radii, rotation); - self - } - - pub fn add_circle(&mut self, center: impl Into, radius: f32) -> &mut Self { - self.inner.add_circle( - vec2_to_point(center), - radius, - lyon::path::Winding::Positive, - &self.attributes.to_array(), - ); - self - } - - pub fn with_circle(mut self, center: impl Into, radius: f32) -> Self { - self.add_circle(center, radius); - self - } - - pub fn add_polygon( - &mut self, - is_closed: bool, - polygon: impl IntoIterator, - ) -> &mut Self { - let points = polygon.into_iter().map(vec2_to_point).collect::>(); - let polygon = lyon::path::Polygon { - points: points.as_slice(), - closed: is_closed, - }; - self.inner.add_polygon(polygon, &self.attributes.to_array()); - self - } - - pub fn with_polygon( - mut self, - is_closed: bool, - polygon: impl IntoIterator, - ) -> Self { - self.add_polygon(is_closed, polygon); - self - } - - pub fn set_fill_color(&mut self, color: impl Into) -> &mut Self { - let mut color = color.into(); - crate::color::linear_xfer_vec4(&mut color); - self.attributes.fill_color = color; - self - } - - pub fn with_fill_color(mut self, color: impl Into) -> Self { - self.set_fill_color(color); - self - } - - pub fn set_stroke_color(&mut self, color: impl Into) -> &mut Self { - let mut color = color.into(); - crate::color::linear_xfer_vec4(&mut color); - self.attributes.stroke_color = color; - self - } - - pub fn with_stroke_color(mut self, color: impl Into) -> Self { - self.set_stroke_color(color); - self - } - - pub fn fill_with_options(self, options: FillOptions) -> UiPath { - let l_path = self.inner.build(); - let mut geometry = VertexBuffers::::new(); - let mut tesselator = FillTessellator::new(); - let material = self.ui.stage.new_material(); - let mut size = Vec2::ONE; - // If we have an image use it in the material - if let Some(ImageId(id)) = &options.image_id { - let guard = self.ui.images.read().expect("images read"); - if let Some(image) = guard.get(id) { - let size_px = image.0.descriptor().size_px; - log::debug!("size: {}", size_px); - size.x = size_px.x as f32; - size.y = size_px.y as f32; - material.set_albedo_texture(&image.0); - } - } - tesselator - .tessellate_path( - l_path.as_slice(), - &Default::default(), - &mut BuffersBuilder::new(&mut geometry, |mut vertex: FillVertex| { - let p = vertex.position(); - let PathAttributes { - stroke_color: _, - fill_color, - } = PathAttributes::from_slice(vertex.interpolated_attributes()); - let position = Vec3::new(p.x, p.y, 0.0); - Vertex { - position, - uv0: position.xy() / size, - color: fill_color, - ..Default::default() - } - }), - ) - .unwrap(); - let vertices = self - .ui - .stage - .new_vertices(std::mem::take(&mut geometry.vertices)); - let indices = self.ui.stage.new_indices( - std::mem::take(&mut geometry.indices) - .into_iter() - .map(|u| u as u32), - ); - - let transform = self.ui.new_transform(); - let primitive = self - .ui - .stage - .new_primitive() - .with_vertices(&vertices) - .with_indices(&indices) - .with_material(&material) - .with_transform(&transform.transform); - - UiPath { - transform, - material, - primitive, - } - } - - pub fn fill(self) -> UiPath { - let options = self.default_fill_options; - self.fill_with_options(options) - } - - pub fn stroke_with_options(self, options: StrokeOptions) -> UiPath { - let l_path = self.inner.build(); - let mut geometry = VertexBuffers::::new(); - let mut tesselator = StrokeTessellator::new(); - let StrokeOptions { - line_width, - line_cap, - line_join, - image_id, - } = options; - let tesselator_options = lyon::tessellation::StrokeOptions::default() - .with_line_cap(line_cap) - .with_line_join(line_join) - .with_line_width(line_width); - let material = self.ui.stage.new_material(); - let mut size = Vec2::ONE; - // If we have an image, use it in the material - if let Some(ImageId(id)) = &image_id { - let guard = self.ui.images.read().expect("images read"); - if let Some(image) = guard.get(id) { - let size_px = image.0.descriptor.get().size_px; - log::debug!("size: {}", size_px); - size.x = size_px.x as f32; - size.y = size_px.y as f32; - material.set_albedo_texture(&image.0); - } - } - tesselator - .tessellate_path( - l_path.as_slice(), - &tesselator_options, - &mut BuffersBuilder::new(&mut geometry, |mut vertex: StrokeVertex| { - let p = vertex.position(); - let PathAttributes { - stroke_color, - fill_color: _, - } = PathAttributes::from_slice(vertex.interpolated_attributes()); - let position = Vec3::new(p.x, p.y, 0.0); - Vertex { - position, - uv0: position.xy() / size, - color: stroke_color, - ..Default::default() - } - }), - ) - .unwrap(); - let vertices = self - .ui - .stage - .new_vertices(std::mem::take(&mut geometry.vertices)); - let indices = self.ui.stage.new_indices( - std::mem::take(&mut geometry.indices) - .into_iter() - .map(|u| u as u32), - ); - let transform = self.ui.new_transform(); - let renderlet = self - .ui - .stage - .new_primitive() - .with_vertices(vertices) - .with_indices(indices) - .with_transform(&transform.transform) - .with_material(&material); - UiPath { - transform, - material, - primitive: renderlet, - } - } - - pub fn stroke(self) -> UiPath { - let options = self.default_stroke_options; - self.stroke_with_options(options) - } - - pub fn fill_and_stroke_with_options( - self, - fill_options: FillOptions, - stroke_options: StrokeOptions, - ) -> (UiPath, UiPath) { - ( - self.clone().fill_with_options(fill_options), - self.stroke_with_options(stroke_options), - ) - } - - pub fn fill_and_stroke(self) -> (UiPath, UiPath) { - let fill_options = self.default_fill_options; - let stroke_options = self.default_stroke_options; - self.fill_and_stroke_with_options(fill_options, stroke_options) - } -} - -#[cfg(test)] -mod test { - use crate::{ - context::Context, - math::hex_to_vec4, - test::BlockOnFuture, - ui::{ - test::{cute_beach_palette, Colors}, - Ui, - }, - }; - use glam::Vec2; - - use super::*; - - /// Generates points for a star shape. - /// `num_points` specifies the number of points (tips) the star will - /// have. `radius` specifies the radius of the circle in which - /// the star is inscribed. - fn star_points(num_points: usize, outer_radius: f32, inner_radius: f32) -> Vec { - let mut points = Vec::with_capacity(num_points * 2); - let angle_step = std::f32::consts::PI / num_points as f32; - for i in 0..num_points * 2 { - let angle = angle_step * i as f32; - let radius = if i % 2 == 0 { - outer_radius - } else { - inner_radius - }; - points.push(Vec2::new(radius * angle.cos(), radius * angle.sin())); - } - points - } - - #[test] - fn can_build_path_sanity() { - let ctx = Context::headless(100, 100).block(); - let ui = Ui::new(&ctx).with_antialiasing(false); - let builder = ui - .path_builder() - .with_fill_color([1.0, 1.0, 0.0, 1.0]) - .with_stroke_color([0.0, 1.0, 1.0, 1.0]) - .with_rectangle(Vec2::splat(10.0), Vec2::splat(60.0)) - .with_circle(Vec2::splat(100.0), 20.0); - { - let _fill = builder.clone().fill(); - let _stroke = builder.clone().stroke(); - - let frame = ctx.get_next_frame().unwrap(); - ui.render(&frame.view()); - let img = frame.read_image().block().unwrap(); - img_diff::assert_img_eq("ui/path/sanity.png", img); - } - - let frame = ctx.get_next_frame().unwrap(); - ui.render(&frame.view()); - frame.present(); - - { - let _resources = builder.fill_and_stroke(); - let frame = ctx.get_next_frame().unwrap(); - ui.render(&frame.view()); - let img = frame.read_image().block().unwrap(); - img_diff::assert_img_eq_cfg( - "ui/path/sanity.png", - img, - img_diff::DiffCfg { - test_name: Some("ui/path/sanity - separate path and stroke same as together"), - ..Default::default() - }, - ); - } - } - - #[test] - fn can_draw_shapes() { - let ctx = Context::headless(256, 48).block(); - let ui = Ui::new(&ctx).with_default_stroke_options(StrokeOptions { - line_width: 4.0, - ..Default::default() - }); - let mut colors = Colors::from_array(cute_beach_palette()); - - // rectangle - let fill = colors.next_color(); - let _rect = ui - .path_builder() - .with_fill_color(fill) - .with_stroke_color(hex_to_vec4(0x333333FF)) - .with_rectangle(Vec2::splat(2.0), Vec2::splat(42.0)) - .fill_and_stroke(); - - // circle - let fill = colors.next_color(); - let _circ = ui - .path_builder() - .with_fill_color(fill) - .with_stroke_color(hex_to_vec4(0x333333FF)) - .with_circle([64.0, 22.0], 20.0) - .fill_and_stroke(); - - // ellipse - let fill = colors.next_color(); - let _elli = ui - .path_builder() - .with_fill_color(fill) - .with_stroke_color(hex_to_vec4(0x333333FF)) - .with_ellipse([104.0, 22.0], [20.0, 15.0], std::f32::consts::FRAC_PI_4) - .fill_and_stroke(); - - // various polygons - fn circle_points(num_points: usize, radius: f32) -> Vec { - let mut points = Vec::with_capacity(num_points); - for i in 0..num_points { - let angle = 2.0 * std::f32::consts::PI * i as f32 / num_points as f32; - points.push(Vec2::new(radius * angle.cos(), radius * angle.sin())); - } - points - } - - let fill = colors.next_color(); - let center = Vec2::new(144.0, 22.0); - let _penta = ui - .path_builder() - .with_fill_color(fill) - .with_stroke_color(hex_to_vec4(0x333333FF)) - .with_polygon(true, circle_points(5, 20.0).into_iter().map(|p| p + center)) - .fill_and_stroke(); - - let fill = colors.next_color(); - let center = Vec2::new(184.0, 22.0); - let _star = ui - .path_builder() - .with_fill_color(fill) - .with_stroke_color(hex_to_vec4(0x333333FF)) - .with_polygon( - true, - star_points(5, 20.0, 10.0).into_iter().map(|p| p + center), - ) - .fill_and_stroke(); - - let fill = colors.next_color(); - let tl = Vec2::new(210.0, 4.0); - let _rrect = ui - .path_builder() - .with_fill_color(fill) - .with_stroke_color(hex_to_vec4(0x333333FF)) - .with_rounded_rectangle(tl, tl + Vec2::new(40.0, 40.0), 5.0, 0.0, 0.0, 10.0) - .fill_and_stroke(); - - let frame = ctx.get_next_frame().unwrap(); - ui.render(&frame.view()); - let img = frame.read_image().block().unwrap(); - img_diff::assert_img_eq("ui/path/shapes.png", img); - } - - #[test] - fn can_fill_image() { - let w = 150.0; - let ctx = Context::headless(w as u32, w as u32).block(); - let ui = Ui::new(&ctx); - let image_id = futures_lite::future::block_on(ui.load_image("../../img/dirt.jpg")).unwrap(); - let center = Vec2::splat(w / 2.0); - let _path = ui - .path_builder() - .with_polygon( - true, - star_points(7, w / 2.0, w / 3.0) - .into_iter() - .map(|p| center + p), - ) - .with_fill_color([1.0, 1.0, 1.0, 1.0]) - .with_stroke_color([1.0, 0.0, 0.0, 1.0]) - .fill_and_stroke_with_options( - FillOptions { - image_id: Some(image_id), - }, - StrokeOptions { - line_width: 5.0, - image_id: Some(image_id), - ..Default::default() - }, - ); - - let frame = ctx.get_next_frame().unwrap(); - ui.render(&frame.view()); - let mut img = frame.read_srgb_image().block().unwrap(); - img.pixels_mut().for_each(|p| { - crate::color::opto_xfer_u8(&mut p.0[0]); - crate::color::opto_xfer_u8(&mut p.0[1]); - crate::color::opto_xfer_u8(&mut p.0[2]); - }); - img_diff::assert_img_eq("ui/path/fill_image.png", img); - } -} diff --git a/crates/renderling/src/ui/cpu/text.rs b/crates/renderling/src/ui/cpu/text.rs deleted file mode 100644 index b1820149..00000000 --- a/crates/renderling/src/ui/cpu/text.rs +++ /dev/null @@ -1,517 +0,0 @@ -//! Text rendering capabilities for `Renderling`. -//! -//! This module is only enabled with the `text` cargo feature. - -use std::{ - borrow::Cow, - ops::{Deref, DerefMut}, -}; - -use ab_glyph::Rect; -use glam::{Vec2, Vec4}; -use glyph_brush::*; - -pub use ab_glyph::FontArc; -pub use glyph_brush::{Section, Text}; - -use crate::{atlas::AtlasTexture, geometry::Vertex, material::Material, primitive::Primitive}; -use image::{DynamicImage, GenericImage, ImageBuffer, Luma, Rgba}; - -use super::{Ui, UiTransform}; - -pub struct UiTextBuilder { - ui: Ui, - material: Material, - bounds: (Vec2, Vec2), - brush: GlyphBrush>, -} - -impl UiTextBuilder { - pub fn new(ui: &Ui) -> Self { - Self { - ui: ui.clone(), - material: ui.stage.new_material(), - brush: GlyphBrushBuilder::using_fonts(ui.get_fonts()).build(), - bounds: (Vec2::ZERO, Vec2::ZERO), - } - } - - pub fn set_color(&mut self, color: impl Into) -> &mut Self { - self.material.set_albedo_factor(color.into()); - self - } - - pub fn with_color(mut self, color: impl Into) -> Self { - self.set_color(color); - self - } - - pub fn set_section<'a>( - &mut self, - section: impl Into>>, - ) -> &mut Self { - self.brush = self.brush.to_builder().build(); - let section: Cow<'a, Section<'a, Extra>> = section.into(); - if let Some(bounds) = self.brush.glyph_bounds(section.clone()) { - let min = Vec2::new(bounds.min.x, bounds.min.y); - let max = Vec2::new(bounds.max.x, bounds.max.y); - self.bounds = (min, max); - } - self.brush.queue(section); - self - } - - pub fn with_section<'a>(mut self, section: impl Into>>) -> Self { - self.set_section(section); - self - } - - pub fn build(self) -> UiText { - let UiTextBuilder { - ui, - material, - bounds, - brush, - } = self; - let mut cache = GlyphCache { cache: None, brush }; - - let (maybe_mesh, maybe_img) = cache.get_updated(); - let mesh = maybe_mesh.unwrap_or_default(); - let luma_img = maybe_img.unwrap_or_default(); - let img = DynamicImage::from(ImageBuffer::from_fn( - luma_img.width(), - luma_img.height(), - |x, y| { - let luma = luma_img.get_pixel(x, y); - Rgba([255, 255, 255, luma.0[0]]) - }, - )); - - // UNWRAP: panic on purpose - let entry = ui.stage.add_images(Some(img)).unwrap().pop().unwrap(); - material.set_albedo_texture(&entry); - let vertices = ui.stage.new_vertices(mesh); - let transform = ui.new_transform(); - let renderlet = ui - .stage - .new_primitive() - .with_vertices(vertices) - .with_transform(&transform.transform) - .with_material(&material); - UiText { - _cache: cache, - bounds, - transform, - _texture: entry, - _material: material, - renderlet, - } - } -} - -pub struct UiText { - pub(crate) transform: UiTransform, - pub(crate) renderlet: Primitive, - pub(crate) bounds: (Vec2, Vec2), - - pub(crate) _cache: GlyphCache, - pub(crate) _texture: AtlasTexture, - pub(crate) _material: Material, -} - -impl UiText { - /// Returns the bounds of this text. - pub fn bounds(&self) -> (Vec2, Vec2) { - self.bounds - } - - /// Returns the transform of this text. - pub fn transform(&self) -> &UiTransform { - &self.transform - } -} - -/// A text cache maintained mostly by ab_glyph. -pub struct Cache { - img: image::ImageBuffer, Vec>, - dirty: bool, -} - -impl core::fmt::Debug for Cache { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Cache") - .field("img", &(self.img.width(), self.img.height())) - .field("dirty", &self.dirty) - .finish() - } -} - -impl Cache { - pub fn new(width: u32, height: u32) -> Cache { - Cache { - img: image::ImageBuffer::from_pixel(width, height, image::Luma([0])), - dirty: false, - } - } - - pub fn update(&mut self, offset: [u16; 2], size: [u16; 2], data: &[u8]) { - let width = size[0] as u32; - let height = size[1] as u32; - let x = offset[0] as u32; - let y = offset[1] as u32; - - // UNWRAP: panic on purpose - let source = - image::ImageBuffer::, Vec>::from_vec(width, height, data.to_vec()) - .unwrap(); - self.img.copy_from(&source, x, y).unwrap(); - self.dirty = true; - } -} - -/// A cache of glyphs. -#[derive(Debug)] -pub struct GlyphCache { - /// Image on the CPU or GPU used as our texture cache - cache: Option, - brush: GlyphBrush>, -} - -impl Deref for GlyphCache { - type Target = GlyphBrush>; - - fn deref(&self) -> &Self::Target { - &self.brush - } -} - -impl DerefMut for GlyphCache { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.brush - } -} - -#[inline] -fn to_vertex( - glyph_brush::GlyphVertex { - mut tex_coords, - pixel_coords, - bounds, - extra, - }: glyph_brush::GlyphVertex, -) -> Vec { - let gl_bounds = bounds; - - let mut gl_rect = Rect { - min: ab_glyph::point(pixel_coords.min.x, pixel_coords.min.y), - max: ab_glyph::point(pixel_coords.max.x, pixel_coords.max.y), - }; - - // handle overlapping bounds, modify uv_rect to preserve texture aspect - if gl_rect.max.x > gl_bounds.max.x { - let old_width = gl_rect.width(); - gl_rect.max.x = gl_bounds.max.x; - tex_coords.max.x = tex_coords.min.x + tex_coords.width() * gl_rect.width() / old_width; - } - if gl_rect.min.x < gl_bounds.min.x { - let old_width = gl_rect.width(); - gl_rect.min.x = gl_bounds.min.x; - tex_coords.min.x = tex_coords.max.x - tex_coords.width() * gl_rect.width() / old_width; - } - if gl_rect.max.y > gl_bounds.max.y { - let old_height = gl_rect.height(); - gl_rect.max.y = gl_bounds.max.y; - tex_coords.max.y = tex_coords.min.y + tex_coords.height() * gl_rect.height() / old_height; - } - if gl_rect.min.y < gl_bounds.min.y { - let old_height = gl_rect.height(); - gl_rect.min.y = gl_bounds.min.y; - tex_coords.min.y = tex_coords.max.y - tex_coords.height() * gl_rect.height() / old_height; - } - let tl = Vertex::default() - .with_position([gl_rect.min.x, gl_rect.min.y, 0.0]) - .with_uv0([tex_coords.min.x, tex_coords.min.y]) - .with_color(extra.color); - let tr = Vertex::default() - .with_position([gl_rect.max.x, gl_rect.min.y, 0.0]) - .with_uv0([tex_coords.max.x, tex_coords.min.y]) - .with_color(extra.color); - let br = Vertex::default() - .with_position([gl_rect.max.x, gl_rect.max.y, 0.0]) - .with_uv0([tex_coords.max.x, tex_coords.max.y]) - .with_color(extra.color); - let bl = Vertex::default() - .with_position([gl_rect.min.x, gl_rect.max.y, 0.0]) - .with_uv0([tex_coords.min.x, tex_coords.max.y]) - .with_color(extra.color); - - // Draw as two tris - let data = vec![tl, br, tr, tl, bl, br]; - data -} - -impl GlyphCache { - /// Process any brushes, updating textures, etc. - /// - /// Returns a new mesh if the mesh needs to be updated. - /// Returns a new texture if the texture needs to be updated. - /// - /// The texture and mesh are meant to be used to build or update a - /// `Renderlet` to display. - #[allow(clippy::type_complexity)] - pub fn get_updated(&mut self) -> (Option>, Option, Vec>>) { - let mut may_mesh: Option> = None; - let mut cache = self.cache.take().unwrap_or_else(|| { - let (width, height) = self.brush.texture_dimensions(); - Cache::new(width, height) - }); - - let mut brush_action; - loop { - brush_action = self.brush.process_queued( - |rect, tex_data| { - let offset = [rect.min[0] as u16, rect.min[1] as u16]; - let size = [rect.width() as u16, rect.height() as u16]; - cache.update(offset, size, tex_data) - }, - to_vertex, - ); - - match brush_action { - Ok(_) => break, - Err(BrushError::TextureTooSmall { suggested, .. }) => { - let max_image_dimension = 2048; - - let (new_width, new_height) = if (suggested.0 > max_image_dimension - || suggested.1 > max_image_dimension) - && (self.brush.texture_dimensions().0 < max_image_dimension - || self.brush.texture_dimensions().1 < max_image_dimension) - { - (max_image_dimension, max_image_dimension) - } else { - suggested - }; - - log::warn!( - "Increasing glyph texture size {old:?} -> {new:?}. Consider building with \ - `.initial_cache_size({new:?})` to avoid resizing", - old = self.brush.texture_dimensions(), - new = (new_width, new_height), - ); - - cache = Cache::new(new_width, new_height); - self.brush.resize_texture(new_width, new_height); - } - } - } - - match brush_action.unwrap() { - BrushAction::Draw(all_vertices) => { - if !all_vertices.is_empty() { - may_mesh = Some( - all_vertices - .into_iter() - .flat_map(|vs| vs.into_iter()) - .collect(), - ); - } - } - BrushAction::ReDraw => {} - } - let may_texture = if cache.dirty { - Some(cache.img.clone()) - } else { - None - }; - self.cache = Some(cache); - - (may_mesh, may_texture) - } -} - -#[cfg(test)] -mod test { - use crate::{context::Context, test::BlockOnFuture, ui::Ui}; - use glyph_brush::Section; - - use super::*; - - #[test] - fn can_display_uitext() { - log::info!("{:#?}", std::env::current_dir()); - let bytes = - std::fs::read("../../fonts/Recursive Mn Lnr St Med Nerd Font Complete.ttf").unwrap(); - let font = FontArc::try_from_vec(bytes).unwrap(); - - let ctx = Context::headless(455, 145).block(); - let ui = Ui::new(&ctx); - let _font_id = ui.add_font(font); - let _text = ui - .text_builder() - .with_section( - Section::default() - .add_text( - Text::new("Here is some text.\n") - .with_scale(32.0) - .with_color([0.0, 0.0, 0.0, 1.0]), - ) - .add_text( - Text::new("Here is text in a new color\n") - .with_scale(32.0) - .with_color([1.0, 1.0, 0.0, 1.0]), - ) - .add_text( - Text::new("(and variable size)\n") - .with_scale(16.0) - .with_color([1.0, 0.0, 1.0, 1.0]), - ) - .add_text( - Text::new("...and variable transparency\n...and word wrap") - .with_scale(32.0) - .with_color([0.2, 0.2, 0.2, 0.5]), - ), - ) - .build(); - - let frame = ctx.get_next_frame().unwrap(); - ui.render(&frame.view()); - let img = frame.read_image().block().unwrap(); - img_diff::assert_img_eq("ui/text/can_display.png", img); - } - - #[test] - /// Tests that if we overlay text (which has transparency) on top of other - /// objects, it renders the transparency correctly. - fn text_overlayed() { - log::info!("{:#?}", std::env::current_dir()); - - let ctx = Context::headless(500, 253).block(); - let ui = Ui::new(&ctx).with_antialiasing(false); - let font_id = futures_lite::future::block_on( - ui.load_font("../../fonts/Recursive Mn Lnr St Med Nerd Font Complete.ttf"), - ) - .unwrap(); - log::info!("loaded font"); - - let text1 = "Voluptas magnam sint et incidunt. Aliquam praesentium voluptas ut nemo \ - laboriosam. Dicta qui et dicta."; - let text2 = "Inventore impedit quo ratione ullam blanditiis soluta aliquid. Enim \ - molestiae eaque ab commodi et.\nQuidem ex tempore ipsam. Incidunt suscipit \ - aut commodi cum atque voluptate est."; - let text = ui - .text_builder() - .with_section( - Section::default().add_text( - Text::new(text1) - .with_scale(24.0) - .with_color([0.0, 0.0, 0.0, 1.0]) - .with_font_id(font_id), - ), - ) - .with_section( - Section::default() - .add_text( - Text::new(text2) - .with_scale(24.0) - .with_color([0.0, 0.0, 0.0, 1.0]), - ) - .with_bounds((400.0, f32::INFINITY)), - ) - .build(); - log::info!("created text"); - - let (fill, stroke) = ui - .path_builder() - .with_fill_color([1.0, 1.0, 0.0, 1.0]) - .with_stroke_color([1.0, 0.0, 1.0, 1.0]) - .with_rectangle(text.bounds.0, text.bounds.1) - .fill_and_stroke(); - log::info!("filled and stroked"); - - for (i, path) in [&fill, &stroke].into_iter().enumerate() { - log::info!("for {i}"); - // move the path to (50, 50) - path.transform.set_translation(Vec2::new(51.0, 53.0)); - log::info!("translated"); - // move it to the back - path.transform.set_z(0.1); - log::info!("z'd"); - } - log::info!("transformed"); - - let frame = ctx.get_next_frame().unwrap(); - ui.render(&frame.view()); - log::info!("rendered"); - let img = frame.read_image().block().unwrap(); - if let Err(e) = - img_diff::assert_img_eq_cfg_result("ui/text/overlay.png", img, Default::default()) - { - let depth_img = ui - .stage - .get_depth_texture() - .read_image() - .block() - .unwrap() - .unwrap(); - let e2 = img_diff::assert_img_eq_cfg_result( - "ui/text/overlay_depth.png", - depth_img, - Default::default(), - ) - .err() - .unwrap_or_default(); - panic!("{e}\n{e2}"); - } - } - - #[test] - fn recreate_text() { - let ctx = Context::headless(50, 50).block(); - let ui = Ui::new(&ctx).with_antialiasing(true); - let _font_id = futures_lite::future::block_on( - ui.load_font("../../fonts/Recursive Mn Lnr St Med Nerd Font Complete.ttf"), - ) - .unwrap(); - log::info!("loaded font"); - let text = ui - .text_builder() - .with_section( - Section::default() - .add_text( - Text::new("60.0 fps") - .with_scale(24.0) - .with_color([1.0, 0.0, 0.0, 1.0]), - ) - .with_bounds((50.0, 50.0)), - ) - .build(); - - let frame = ctx.get_next_frame().unwrap(); - ui.render(&frame.view()); - let img = frame.read_image().block().unwrap(); - frame.present(); - img_diff::assert_img_eq("ui/text/can_recreate_0.png", img); - - log::info!("replacing text"); - ui.remove_text(&text); - - let _ = ui - .text_builder() - .with_section( - Section::default() - .add_text( - Text::new(":)-|<") - .with_scale(24.0) - .with_color([1.0, 0.0, 0.0, 1.0]), - ) - .with_bounds((50.0, 50.0)), - ) - .build(); - - let frame = ctx.get_next_frame().unwrap(); - ui.render(&frame.view()); - let img = frame.read_image().block().unwrap(); - frame.present(); - img_diff::assert_img_eq("ui/text/can_recreate_1.png", img); - } -} diff --git a/crates/renderling/src/ui/sdf.rs b/crates/renderling/src/ui/sdf.rs deleted file mode 100644 index dff94468..00000000 --- a/crates/renderling/src/ui/sdf.rs +++ /dev/null @@ -1,24 +0,0 @@ -//! 2d signed distance fields. -use glam::Vec2; - -/// Returns the distance to the edge of a circle of radius `r` with center at -/// `p`. -fn distance_circle(p: Vec2, r: f32) -> f32 { - p.length() - r -} - -pub struct Circle { - origin: Vec2, - radius: f32, -} - -impl Circle { - pub fn distance(&self) -> f32 { - distance_circle(self.origin, self.radius) - } -} - -// #[spirv_std::spirv(vertex)] -// pub fn vertex_circle( - -// ) diff --git a/test_img/ui/path/fill_image.png b/test_img/ui/path/fill_image.png deleted file mode 100644 index d244016d4fab0dc8f20e3e176fec51fc605efc72..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 34450 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 VGxMxMX9E*5gQu&X%Q~loCICAZnNk1% diff --git a/test_img/ui/path/shapes.png b/test_img/ui/path/shapes.png deleted file mode 100644 index 95298e9cd0386c095474871adcfe82321eebd4f2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6352 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 uVBUhRnGt3GnnR935ha=rei0Mn)bxYzXb+ql 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 US^A;d7HaS@S)VdtPx5@zU*(uo)&Kwi diff --git a/test_img/ui/text/overlay_depth.png b/test_img/ui/text/overlay_depth.png deleted file mode 100644 index fa72dbb5f98bae5c2bdd8f0f8177c22927669e12..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5579 zcmeHLeN0nV6n{7w1|m+Quow)>;x^;Qh%ruy8Qo0LF^$LuREOHd7|28hhzN6#2YyZ) zrXg7f!K4$H$v`2~flQ5sEY(e>FN*<`x=?XUhjWUoT1xxw&bjx!_7-@gTl9}5_K&vd zJNMr6@%#PGx!u2d)$)m<(?bP8n3(X6{yjkm`WwD`COi-C@7C{mBnZ>W6ZCH<7Y5z4 z2OXN3P(CwZimA5p>~g(6Jv}`&1zrXCdi?X#cx}V24F)_VJ$>0S{qD0>m7&tgbVEUY zK|!K~N3Pu1m{otKueJ`~$mN=K`Ryr&_3%b)N#83!*U6`kZfz%@m#oayS zwAl0FZs!#y6?J43M;JW!;n6_sxmoZi&Qw?L5_expF?c3D_WKo2I94N!+{Rg<#tgxDVJQwr5}iU-o*VdE^V`YxWM4t=X(nu| zQfGrFdwDM_Kh&-JbIk)b~UyePsTF=G*)meiSBwb#Vl-v5)E0 zFeT<1Z`sKTv4EOjL@%5~EOy`IphcpK5d%u1sh{1NN!Vob!MAD{e3iV7jP}h6F8S^> zuC9L71OIMti0L=-Uc$qMw|cwzYWu<5e1{9k?@0rPJX4)K#79ex@}3#}Esa3Q6tap! z%0g4#+$Zj(3ycpDx|j-5x7we?zAOYY08(OD#XWSPu8o#QcbV}7Sm&C{$4q_+gg_?I z#QUvew3af(c$X!Dq2fBXIZr`UkVJhzsQeeOCtp#wN)>UDsso~cxQjW?#6+U0bteum zaiAHJ8Gs4HxIzZT6Sv6hGnnC`A*wt~Oh^KA1VSy@QpKqJI#nB{R#p_yrCLd#ObI}> zeFg8e@bXvhx<6c;b)8&wLBh)}k;%sXX~tGEH6$eD+^nXiCTI7bbI$k7Y3?KEckOy^ z!_>62v~`Y{C5so^KbQ<-6O3X9T6k~!>+-wrR^j3M|<-}+z!Au z@#Uv4!&-o6N(`DiLZnwhmE0M&uC5-j9J8R0 zXYwno&nQbA#+{nG`jNpq0OdZpeo3qpv>}xG-i|Cr1_Tj^2c^QhjLbijH6XSydNEFu z)ab{#(HF_aF;mQlhVvVz1X}H$jN>L0gh-~!u-tYMr-}eR&?hTX5{xh2Daor_FrkoF zIdd_1w!vdA@Uh?3?PZz4(*}n)D}csuNTFshN`;j(l!irB2k1?vBgQjljO6e%Zv=Qc zVpa7^p!xui4sKBeLQsvNyQ?Z7_}S~)5bak-5ex4@Oj|&Upq(ck2`KLY&vBmDVnNhO z71qe_Yj$_Pwjq{M-4v+?TCM|0l`v2`bw6Q5X~=KgW&BE0nb9?j%4GZ|8v{oloAU0k zMj-A}71S+mQ$*5)@#wB7J-}~Dr9)({7mE$?;HYeZ!*koinm|n`NG((R)@bqH9j#16 zs6D6EYGT{`R0RTF8qV}pQc{y|)augn9oaBokQ}J4dj)P_?!&Hv$-qgp6+wk+3CqP; zP0I44oHEgmylDvBl;i7ECbFOUgR!f5BcXm^^P(Ho)f*qtk8Z?K0E=h5J34~trjVmK zfni5cjVF8EASD!#h#;qQ(%VF;5QLU?1fk9M^(s|{6f)^0+wY{L!Fiy;9-psf49iO@ zR0ytfh;5^xjz^FjjoG2N7(^-n{vx%<5Uub@FjrI`p6ikZ#XBmFNU9fHmLBy-=8ET- zrZBkZ4LFlth&eTOp62Ft^&*S?wi~yOq)Gcg1%{6eK5c_ULC~(~Qq7LYXNg(kg?-E$ zBq>7-f5~`)+ZzIEG!7~u(j(eC5)lAws?f25;Nc|o$EHE!Uu=G42%!E+>S(DY5wJ{W zXWB%ITr1Wc9BMvzOPsICVt$a&x-R(%sun8#Lk1N*1b7F3b^t^17=KgzUs4o){SBY1 o>6&BDGd!|k;R*h$MJ8 Date: Sun, 15 Mar 2026 22:03:08 +1300 Subject: [PATCH 06/14] Add image-filled paths, enable 4x MSAA by default, use typed slab Ids Add UiPathBuilder::with_fill_image() for filling tessellated paths with atlas textures. The shader samples the atlas when atlas_texture_id is set on Path elements, using bounding-box-derived UVs. Add UiRenderer::upload_image() to load images without creating a draw call. Enable 4x MSAA by default (matching the old Ui behavior) and expose with_msaa_sample_count() for configuration. Replace raw u32 fields in UiDrawCallDescriptor with typed Ids: atlas_texture_id becomes Id and atlas_descriptor_id becomes Id, using Id::NONE as the unset sentinel. Update shader and CPU code accordingly. --- crates/renderling-ui/src/renderer.rs | 147 ++++++++++++++++-- crates/renderling-ui/src/test.rs | 61 ++++++++ .../shaders/ui_slab-shader-ui_fragment.spv | Bin 17820 -> 22340 bytes .../shaders/ui_slab-shader-ui_vertex.spv | Bin 5296 -> 5296 bytes crates/renderling/src/ui_slab/mod.rs | 18 ++- crates/renderling/src/ui_slab/shader.rs | 22 ++- test_img/ui2d/filled_path.png | Bin 1821 -> 2914 bytes test_img/ui2d/path_image_fill.png | Bin 0 -> 36445 bytes test_img/ui2d/path_shapes.png | Bin 3433 -> 5131 bytes test_img/ui2d/stroked_path.png | Bin 2187 -> 4260 bytes 10 files changed, 223 insertions(+), 25 deletions(-) create mode 100644 test_img/ui2d/path_image_fill.png diff --git a/crates/renderling-ui/src/renderer.rs b/crates/renderling-ui/src/renderer.rs index ff799a40..21f3776e 100644 --- a/crates/renderling-ui/src/renderer.rs +++ b/crates/renderling-ui/src/renderer.rs @@ -498,7 +498,7 @@ mod path { StrokeVertex, VertexBuffers, }, }; - use renderling::ui_slab::UiVertex; + use renderling::{atlas::shader::AtlasTextureDescriptor, ui_slab::UiVertex}; fn vec2_to_point(v: impl Into) -> geom::Point { let v = v.into(); @@ -575,6 +575,8 @@ mod path { inner: lyon::path::BuilderWithAttributes, attrs: PathAttributes, stroke_config: StrokeConfig, + /// Atlas texture descriptor ID for image-filled paths. + fill_image_id: Id, } impl UiPathBuilder { @@ -586,6 +588,7 @@ mod path { fill_color: Vec4::ONE, }, stroke_config: StrokeConfig::default(), + fill_image_id: Id::NONE, } } @@ -627,6 +630,25 @@ mod path { self } + /// Set an image to fill the path with. + /// + /// The image is sampled using UVs computed from each vertex's + /// position relative to the path's bounding box (0..1 range). + /// The vertex color acts as a tint/modulator. + /// + /// The `AtlasTexture` should be obtained from + /// [`UiRenderer::upload_image`]. + pub fn set_fill_image(&mut self, texture: &AtlasTexture) -> &mut Self { + self.fill_image_id = texture.id(); + self + } + + /// Set an image to fill the path with (builder). + pub fn with_fill_image(mut self, texture: &AtlasTexture) -> Self { + self.set_fill_image(texture); + self + } + // --- Path commands --- /// Begin a new sub-path at the given point. @@ -848,6 +870,7 @@ mod path { /// Tessellate the path as a filled shape and register it with the /// renderer. Consumes the builder. pub fn fill(self, renderer: &mut UiRenderer) -> UiPath { + let fill_image_id = self.fill_image_id; let path = self.inner.build(); let mut geometry = VertexBuffers::::new(); let mut tessellator = FillTessellator::new(); @@ -868,12 +891,18 @@ mod path { ) .expect("fill tessellation failed"); - Self::upload(renderer, &geometry) + // If an image fill is set, compute UVs from the bounding box. + if !fill_image_id.is_none() { + Self::compute_bounding_box_uvs(&mut geometry); + } + + Self::upload(renderer, &geometry, fill_image_id) } /// Tessellate the path as a stroked outline and register it with /// the renderer. Consumes the builder. pub fn stroke(self, renderer: &mut UiRenderer) -> UiPath { + let fill_image_id = self.fill_image_id; let path = self.inner.build(); let mut geometry = VertexBuffers::::new(); let mut tessellator = StrokeTessellator::new(); @@ -899,12 +928,45 @@ mod path { ) .expect("stroke tessellation failed"); - Self::upload(renderer, &geometry) + // If an image fill is set, compute UVs from the bounding box. + if !fill_image_id.is_none() { + Self::compute_bounding_box_uvs(&mut geometry); + } + + Self::upload(renderer, &geometry, fill_image_id) + } + + /// Compute UVs from the bounding box of the tessellated vertices. + /// + /// Maps each vertex position into 0..1 UV space relative to the + /// axis-aligned bounding box of all vertices. + fn compute_bounding_box_uvs(geometry: &mut VertexBuffers) { + if geometry.vertices.is_empty() { + return; + } + let mut min = Vec2::splat(f32::INFINITY); + let mut max = Vec2::splat(f32::NEG_INFINITY); + for v in &geometry.vertices { + min = min.min(v.position); + max = max.max(v.position); + } + let extent = max - min; + let inv_extent = Vec2::new( + if extent.x > 0.0 { 1.0 / extent.x } else { 0.0 }, + if extent.y > 0.0 { 1.0 / extent.y } else { 0.0 }, + ); + for v in &mut geometry.vertices { + v.uv = (v.position - min) * inv_extent; + } } /// De-index the tessellated geometry, write vertices to the slab, /// and create a draw call. - fn upload(renderer: &mut UiRenderer, geometry: &VertexBuffers) -> UiPath { + fn upload( + renderer: &mut UiRenderer, + geometry: &VertexBuffers, + atlas_texture_id: Id, + ) -> UiPath { // De-index: expand indexed triangles to flat vertex list. let expanded: Vec = geometry .indices @@ -917,8 +979,8 @@ mod path { let vertex_offset = vertex_array.array().starting_index() as u32; let mut desc = renderer.default_descriptor(UiElementType::Path); - desc.atlas_texture_id = vertex_offset; // repurposed as vertex - // slab offset + desc.atlas_descriptor_id = Id::new(vertex_offset); + desc.atlas_texture_id = atlas_texture_id; let hybrid = renderer.slab.new_value(desc); renderer.draw_calls.push(DrawCall { descriptor: hybrid.clone(), @@ -1371,7 +1433,14 @@ impl UiRenderer { ); let bindgroup_layout = Self::create_bindgroup_layout(device); - let pipeline = Self::create_pipeline(device, &bindgroup_layout, format, 1); + let default_msaa = 4; + let pipeline = Self::create_pipeline(device, &bindgroup_layout, format, default_msaa); + let msaa_texture = Some(Self::create_msaa_texture( + device, + format, + size, + default_msaa, + )); Self { slab, @@ -1385,9 +1454,9 @@ impl UiRenderer { draw_calls: Vec::new(), viewport_size: size, background_color: None, - msaa_sample_count: 1, + msaa_sample_count: default_msaa, format, - msaa_texture: None, + msaa_texture, #[cfg(feature = "text")] fonts: Vec::new(), #[cfg(feature = "text")] @@ -1412,6 +1481,27 @@ impl UiRenderer { self } + /// Set the MSAA sample count (builder). + /// + /// Higher values produce smoother edges. Common values are 1 (off) + /// and 4 (default). The pipeline and MSAA texture are recreated. + pub fn with_msaa_sample_count(mut self, count: u32) -> Self { + self.msaa_sample_count = count; + let device = self.slab.device(); + self.pipeline = Self::create_pipeline(device, &self.bindgroup_layout, self.format, count); + if count > 1 { + self.msaa_texture = Some(Self::create_msaa_texture( + device, + self.format, + self.viewport_size, + count, + )); + } else { + self.msaa_texture = None; + } + self + } + /// Set the viewport size (typically matches the render target size). pub fn set_size(&mut self, size: UVec2) { if self.viewport_size != size { @@ -1521,7 +1611,7 @@ impl UiRenderer { let mut desc = self.default_descriptor(UiElementType::Image); desc.size = Vec2::new(image_size.x as f32, image_size.y as f32); - desc.atlas_texture_id = atlas_texture.id().inner(); + desc.atlas_texture_id = atlas_texture.id(); desc.fill_color = Vec4::ONE; // no tint let hybrid = self.slab.new_value(desc); @@ -1536,6 +1626,37 @@ impl UiRenderer { element } + /// Upload an image to the atlas without creating a draw call. + /// + /// Returns the [`AtlasTexture`] handle, which can be passed to + /// [`UiPathBuilder::with_fill_image`] for image-filled paths + /// or used for other custom purposes. + /// + /// ```ignore + /// let atlas_img = AtlasImage::from_path("icon.png").unwrap(); + /// let tex = ui.upload_image(atlas_img); + /// let _path = ui.path_builder() + /// .with_fill_image(&tex) + /// .with_fill_color(Vec4::ONE) + /// .with_circle(Vec2::new(50.0, 50.0), 30.0) + /// .fill(&mut ui); + /// ``` + pub fn upload_image(&mut self, image: impl Into) -> AtlasTexture { + let image = image.into(); + let atlas_texture = self + .atlas + .add_image(&image) + .expect("failed to add image to atlas"); + + // Update the viewport with the (possibly new) atlas size. + let atlas_extent = self.atlas.get_size(); + self.viewport.modify(|v| { + v.atlas_size = UVec2::new(atlas_extent.width, atlas_extent.height); + }); + + atlas_texture + } + /// Register a font and return its [`FontId`]. /// /// Fonts must be registered before they can be used in @@ -1642,7 +1763,7 @@ impl UiRenderer { desc.position = quad.position; desc.size = quad.size; desc.fill_color = quad.color; - desc.atlas_texture_id = glyph_atlas_hybrid.id().inner(); + desc.atlas_texture_id = glyph_atlas_hybrid.id(); let hybrid = self.slab.new_value(desc); self.draw_calls.push(DrawCall { @@ -1848,8 +1969,8 @@ impl UiRenderer { border_color: Vec4::ZERO, fill_color: Vec4::ONE, gradient: GradientDescriptor::default(), - atlas_texture_id: u32::MAX, - atlas_descriptor_id: u32::MAX, + atlas_texture_id: Id::NONE, + atlas_descriptor_id: Id::NONE, clip_rect: Vec4::new( 0.0, 0.0, diff --git a/crates/renderling-ui/src/test.rs b/crates/renderling-ui/src/test.rs index db513fbd..be733d54 100644 --- a/crates/renderling-ui/src/test.rs +++ b/crates/renderling-ui/src/test.rs @@ -369,4 +369,65 @@ mod tests { let img = futures_lite::future::block_on(frame.read_image()).unwrap(); save_and_assert("ui2d/text_with_shapes.png", img); } + + /// Generates points for a star shape. + /// + /// `num_points` is the number of tips. Points alternate between + /// `outer_radius` and `inner_radius`. + fn star_points(num_points: usize, outer_radius: f32, inner_radius: f32) -> Vec { + let mut points = Vec::with_capacity(num_points * 2); + let angle_step = std::f32::consts::PI / num_points as f32; + for i in 0..num_points * 2 { + let angle = angle_step * i as f32; + let radius = if i % 2 == 0 { + outer_radius + } else { + inner_radius + }; + points.push(Vec2::new(radius * angle.cos(), radius * angle.sin())); + } + points + } + + #[cfg(feature = "path")] + #[test] + fn can_render_path_with_image_fill() { + use renderling::atlas::AtlasImage; + + init_logging(); + let w = 150.0; + let ctx = futures_lite::future::block_on(Context::headless(w as u32, w as u32)); + let mut ui = UiRenderer::new(&ctx).with_background_color(Vec4::ONE); + + // Load dirt texture into the atlas. + let atlas_img = AtlasImage::from_path("../../img/dirt.jpg").unwrap(); + let tex = ui.upload_image(atlas_img); + + // Build a 7-pointed star polygon centered in the viewport, filled + // with the dirt image and stroked in red. + let center = Vec2::splat(w / 2.0); + let star = star_points(7, w / 2.0, w / 3.0); + + let _fill = ui + .path_builder() + .with_fill_color(Vec4::ONE) // white tint = unmodified image + .with_fill_image(&tex) + .with_polygon(&star.iter().map(|p| *p + center).collect::>()) + .fill(&mut ui); + + let _stroke = ui + .path_builder() + .with_stroke_color(Vec4::new(1.0, 0.0, 0.0, 1.0)) + .with_stroke_config(crate::StrokeConfig { + line_width: 3.0, + ..Default::default() + }) + .with_polygon(&star.iter().map(|p| *p + center).collect::>()) + .stroke(&mut ui); + + let frame = ctx.get_next_frame().unwrap(); + ui.render(&frame.view()); + let img = futures_lite::future::block_on(frame.read_image()).unwrap(); + save_and_assert("ui2d/path_image_fill.png", img); + } } diff --git a/crates/renderling/shaders/ui_slab-shader-ui_fragment.spv b/crates/renderling/shaders/ui_slab-shader-ui_fragment.spv index f9051761a7077f981832842b5eaea9239b46d659..2f54c61bb332e789129efe142136147a3b67e41f 100644 GIT binary patch literal 22340 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 WYftP9GS;Ln?eDj`@c%&U()vH1JsMR2 literal 17820 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 literal 5296 zcmZvfiK~`H7{%Y~)oi!W7AoyCEh&u()bfg&soly_Q7cn(zo}`oxKv`IW<*5$zFV1V zMMMM<5ky2AM1)(bR@Pr2tyaI^z2A}HqZh_G&w1vYdEWVE=Djv|>eIFA(o|Jdx9a3h zbUepaohljoy*;Yx=&HG$_up=?KIWG#oVjH2>^UthOBT$Y*LF)w3&iraTb8!nR-vrg zRh{X(&<>$>r5#FZtvXjH*E#d8j68*2%_WQ$`}FDP+VhS(k)~GM`(UmkKCOtIuU8T4 zdj#z)ntE$6_A$0L$NNskzNh*A?QwH^-AC7RzJp`+JdUwF>1#Bf8gYE&d*bAKihI@H z(b#dP(jQ6lU5_VMqqm(vu6G?fhhu#=Ymztby3}Z{gZaMn=H=5vjlFZGC1 zkM+6-%6k4LDHp#3#D^8}Kg5R@@xR0!`>W%3aWRf6@=e6uiny8hm?G{>>?zEA-HB^O zd=jz0^sU_2b`yNzV0_*;&oxt8t?TXYwJ&8;L` zqaQ`<;5u|;h@}Bi*77fF1jng(YYU@8wWNQ-FUEE zbQ8hRxo@JI1U47lWUyRxSAnB*KSg&n*j#i|z;e+|1xM#^6x}qix#*^Y<)WJbj?VoS z-As6M(ai$OMK>25o#!UHd0=zVwSncLn-7l8{T$r_cyrM$1j|LY2ppaJKDx#5=Ayd+ zEEnC4;OIOb(cJ`ZF1nk+a?vdXN9Q?@ZW-8Ibj!hV(ea5osPh0scRRef=vIK`qFV{h zeBK+;-2pZi-72tLbgRM9dA_4t18*+6wP3mE9tKC}*^KTHu({tjt&h_C{Kc&y|5$re zRgcrNl+_b7rmUW%dA6UT>G?FR3+)-2_k#T&^!gc_FJpCOtX~;BPZ>LZ8CzEwJI{V@ zGVh^hY2Gi6u~+N8p{MtNa}AmW$VRuwL%tcl?7S zczp|&i`RExz5MRQ>wDsOnUB{GU~}=>1D1=|Ua($%GvoE6G0lBuK3+e8&Bg0yu-pW` z?tSR@(R^>~-9s~3F4g*`G-|c9J!$oU8+7XVWrN#@v z?Hnh&D<1l6^x=c{AH}~e(t^+bG`~b zi{3HzYWvgcY42S2)?nV+qJ6E-m0HLApH>gG6Iip};Mc+Goqss1;Jqp*J_PLExeK;N z{|3BcJ=@{lBv$JgT~iB~H`p5W4fhjR&*jV$ z?q^~>R}|bnVm((C+%Lp>u7%6J^DD8QdZ=-X`|41}nDZ=*ruS@(p^UAt z%UyE`Twj{G+%=bhbJv*9T{9MJE_co4V7Yi*0d{=(y&Xp!FZ1yl4>lLC31GQ+O$6&z zes3oc$IE=YCWFnz>q@X(ysiT4Reo=;CXSc+cufJDi`O+^xt!~%VEyIeKMkyZ`3;^< z9DnoizZPsR{xiUG@tO(N%R4&1$1LJ_nUB|Ou(^260n5c}E?6(`?Rd>2j+gm(wSmpW z>pHMpyyk=T^8Xs~S^$oh`FJe^n~T@=V7ctyMPT3CdY#97?nu`+cckNTo^J+wr@80C zEdhI{mE1Bo@3fM;70x@YoPcTL-pAeN)Tb;Kn=c9=OIk?OwRXJMBKW#yjnPIBV25^F9E!Mt#FQ2)0Ij z!#xDHMt#FQ1J<*=)1D>Pv%J%uBi6IL)1D{Rv%J$@Al6e4HIB)hX0E)`MiU>XnyL$E F{{g$?(xCtV diff --git a/crates/renderling/src/ui_slab/mod.rs b/crates/renderling/src/ui_slab/mod.rs index 50b52267..f36c4808 100644 --- a/crates/renderling/src/ui_slab/mod.rs +++ b/crates/renderling/src/ui_slab/mod.rs @@ -4,9 +4,11 @@ //! (shader entry points in this crate). They are stored in a GPU slab buffer //! and read by the UI vertex and fragment shaders. -use crabslab::SlabItem; +use crabslab::{Id, SlabItem}; use glam::{Mat4, UVec2, Vec2, Vec4}; +use crate::atlas::shader::{AtlasDescriptor, AtlasTextureDescriptor}; + pub mod shader; @@ -147,12 +149,20 @@ pub struct UiDrawCallDescriptor { pub fill_color: Vec4, /// Gradient fill descriptor. pub gradient: GradientDescriptor, - /// ID of the atlas texture descriptor on the slab (for Image elements). + /// ID of the atlas texture descriptor on the slab. + /// + /// For `Image` and `TextGlyph` elements: points to an + /// `AtlasTextureDescriptor`. + /// For `Path` elements: when not `Id::NONE`, points to an + /// `AtlasTextureDescriptor` for image-filled paths. /// Set to `Id::NONE` when unused. - pub atlas_texture_id: u32, + pub atlas_texture_id: Id, /// ID of the atlas descriptor on the slab. + /// + /// For `Path` elements: repurposed to store the slab offset of + /// the first `UiVertex` (via `Id::new(offset)`). /// Set to `Id::NONE` when unused. - pub atlas_descriptor_id: u32, + pub atlas_descriptor_id: Id, /// Scissor/clip rectangle (x, y, width, height). /// Elements outside this rect are clipped. Set to (0,0, viewport_w, /// viewport_h) for no clipping. diff --git a/crates/renderling/src/ui_slab/shader.rs b/crates/renderling/src/ui_slab/shader.rs index af2ea757..e355a096 100644 --- a/crates/renderling/src/ui_slab/shader.rs +++ b/crates/renderling/src/ui_slab/shader.rs @@ -116,9 +116,8 @@ pub fn ui_vertex( UiElementType::Path => { // For path elements, the draw_call stores an offset into the // slab where UiVertex data lives. We read the vertex directly. - // The atlas_texture_id field is repurposed as vertex_offset for - // paths. - let vertex_offset = draw_call.atlas_texture_id; + // The atlas_descriptor_id field stores the vertex slab offset. + let vertex_offset = draw_call.atlas_descriptor_id.inner(); let vertex_id = Id::::new(vertex_offset + vertex_index * UiVertex::SLAB_SIZE as u32); let vertex: UiVertex = slab.read_unchecked(vertex_id); @@ -179,13 +178,21 @@ pub fn ui_fragment( match draw_call.element_type { UiElementType::Path => { - // Pre-tessellated path: just use vertex color. + // Pre-tessellated path: start with vertex color. color = in_color; + // If an atlas texture is set, sample it and multiply. + if !draw_call.atlas_texture_id.is_none() { + let atlas_tex: AtlasTextureDescriptor = + slab.read_unchecked(draw_call.atlas_texture_id); + let viewport: UiViewport = slab.read_unchecked(Id::new(0)); + let atlas_uv = atlas_tex.uv(in_uv, viewport.atlas_size); + let sample: Vec4 = atlas.sample_by_lod(*atlas_sampler, atlas_uv, 0.0); + color *= sample; + } } UiElementType::TextGlyph => { // Text glyph: sample the glyph texture and multiply by color. - let atlas_tex_id = Id::::new(draw_call.atlas_texture_id); - let atlas_tex: AtlasTextureDescriptor = slab.read_unchecked(atlas_tex_id); + let atlas_tex: AtlasTextureDescriptor = slab.read_unchecked(draw_call.atlas_texture_id); let viewport: UiViewport = slab.read_unchecked(Id::new(0)); let atlas_uv = atlas_tex.uv(in_uv, viewport.atlas_size); let sample: Vec4 = atlas.sample_by_lod(*atlas_sampler, atlas_uv, 0.0); @@ -194,8 +201,7 @@ pub fn ui_fragment( } UiElementType::Image => { // Textured quad: sample the atlas texture. - let atlas_tex_id = Id::::new(draw_call.atlas_texture_id); - let atlas_tex: AtlasTextureDescriptor = slab.read_unchecked(atlas_tex_id); + let atlas_tex: AtlasTextureDescriptor = slab.read_unchecked(draw_call.atlas_texture_id); let viewport: UiViewport = slab.read_unchecked(Id::new(0)); let atlas_uv = atlas_tex.uv(in_uv, viewport.atlas_size); color = atlas.sample_by_lod(*atlas_sampler, atlas_uv, 0.0); diff --git a/test_img/ui2d/filled_path.png b/test_img/ui2d/filled_path.png index a10342f4832f0b81b10efbecc22800e6cc65de56..174796b5190095751d8fbde109ccbd265dfbbe0b 100644 GIT binary patch literal 2914 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 b>{g$-^}p`V({xn$zXjhco*Vl0y8ZtbRP86V literal 0 HcmV?d00001 diff --git a/test_img/ui2d/path_shapes.png b/test_img/ui2d/path_shapes.png index d0dbdcd8b67cf33873019b42c1180c43ecf9bedb..41c7aef655ce764fb9103ea1881debb261a347a2 100644 GIT binary patch literal 5131 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 PK6u2(Zrq?=_o3o{=eyGY literal 2187 zcmds3ZA_b06b8mzg)m5s!-=LX6f|{0tO`ysrF^y%VJ^JB5<5CZfatm~AP5qZcN8+H zxIkrF#V*BZRMN)cXcRD6N~PNf&^2ah1owjvGt`>RlBP)^YHaJ!@dXPcrd+Ai z**C_%YVtWh$5X+@pH~m6a(ms5c}-{p$C^Co4t~Eby1rT|wG^$$&9qy8jtFyN+W91kc^yxAO#!E@2MImEfPc-~-EXaNAsTG>kn!09R^OsFNhl-rOxt(Yi zkNf2$56*a+`^WupGVoF?)4de%4o4IjYX`E~=xoHv=}6xGrS3U_PQ%2ArQ^bPA)z0q!(Lr!b1S=iIf~>}FzZ^Pn5v-rD&kPD04p z5ZxeMo!wH03*@v{XBUzJx3(mqhazdFW5Unv$8qsFJH@laJxYPJGX&`Q9Ws-IwRwbX z-sG4lUEy}!Pg^cHMN8r}u;k6R9;8A^SVT97@4HMe<@dw<|00it&IL_IqYqxd<|5_A z543|N?iC!4eFG_!o8hk=W7ry4EX2=K%qD`_khcTV1wyjDAxaj!yVHyU&K?{NyP1{N zIOu_lZ3+n=_6;p9d2*Y=dRLi&@_s&5Vkn6#% z^XTXo);Izk~(u5kMD0vViMHJPj7IyPFqCZHk;6=C>G1Pb@6fs2)-4ro757osh z&kZ zo$g@znrH^udkRmN*91RON6h%Wwimy@jgnvNc&LBy{`*IKSrq-|Q0ZjKpNBm7CnHK- LR90xuKc@c|9>$Cb From 98748c515208d5e58fdcaf958decd79862040a48 Mon Sep 17 00:00:00 2001 From: Schell Carl Scivally Date: Wed, 18 Mar 2026 16:59:24 +1300 Subject: [PATCH 07/14] Add compositor for UI overlay, property getters for all element types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Compositor to the renderling crate (compositor_vertex + compositor_fragment shaders) that alpha-blends a source texture onto a target framebuffer via a fullscreen quad. This fixes the black screen when overlaying the UI renderer on a 3D stage with MSAA enabled: the UI now renders to an intermediate texture, resolves MSAA there, and the compositor blends it onto the final view preserving the scene beneath. Add property getters to all UI element types (UiRect, UiCircle, UiEllipse, UiImage, UiPath, UiText) so current values can be read back — a prerequisite for the upcoming tweening/animation system. Fix the example app's UI renderer clearing the 3D scene by removing the erroneous .with_background_color(Vec4::ZERO) call, which was causing a LoadOp::Clear that wiped the stage output. --- crates/example/src/lib.rs | 34 +- crates/renderling-ui/src/renderer.rs | 292 +++++++++++++++++- .../compositor-compositor_fragment.spv | Bin 0 -> 600 bytes .../shaders/compositor-compositor_vertex.spv | Bin 0 -> 1200 bytes crates/renderling/shaders/manifest.json | 10 + crates/renderling/src/compositor.rs | 41 +++ crates/renderling/src/compositor/cpu.rs | 161 ++++++++++ crates/renderling/src/lib.rs | 1 + crates/renderling/src/linkage.rs | 2 + .../src/linkage/compositor_fragment.rs | 34 ++ .../src/linkage/compositor_vertex.rs | 34 ++ 11 files changed, 597 insertions(+), 12 deletions(-) create mode 100644 crates/renderling/shaders/compositor-compositor_fragment.spv create mode 100644 crates/renderling/shaders/compositor-compositor_vertex.spv create mode 100644 crates/renderling/src/compositor.rs create mode 100644 crates/renderling/src/compositor/cpu.rs create mode 100644 crates/renderling/src/linkage/compositor_fragment.rs create mode 100644 crates/renderling/src/linkage/compositor_vertex.rs diff --git a/crates/example/src/lib.rs b/crates/example/src/lib.rs index b74a96d6..e083550a 100644 --- a/crates/example/src/lib.rs +++ b/crates/example/src/lib.rs @@ -83,10 +83,7 @@ struct AppUi { } impl AppUi { - fn make_fps_widget( - fps_counter: &FPSCounter, - ui: &mut UiRenderer, - ) -> (UiText, UiRect) { + fn make_fps_widget(fps_counter: &FPSCounter, ui: &mut UiRenderer) -> (UiText, UiRect) { let offset = Vec2::new(2.0, 2.0); let text = format!("{}fps", fps_counter.current_fps_string()); let fps_text = ui.add_text( @@ -116,8 +113,7 @@ impl AppUi { // Remove old text and background before recreating. self.ui.remove_text(&self.fps_text); self.ui.remove_rect(&self.fps_background); - let (fps_text, background) = - Self::make_fps_widget(&self.fps_counter, &mut self.ui); + let (fps_text, background) = Self::make_fps_widget(&self.fps_counter, &mut self.ui); self.fps_text = fps_text; self.fps_background = background; self.last_fps_display = now; @@ -171,7 +167,7 @@ impl App { }) .unwrap(); - let mut ui = UiRenderer::new(ctx).with_background_color(Vec4::ZERO); + let mut ui = UiRenderer::new(ctx); let _ = ui.add_font(FontArc::try_from_slice(FONT_BYTES).unwrap()); let fps_counter = FPSCounter::default(); let (fps_text, fps_background) = AppUi::make_fps_widget(&fps_counter, &mut ui); @@ -234,6 +230,24 @@ impl App { self.stage.use_ibl(&ibl); } + fn set_model(&mut self, model: Model) { + match std::mem::replace(&mut self.model, model) { + Model::Gltf(gltf_document) => { + // Remove all the things that was loaded by the document + for prim in gltf_document.primitives.values().flatten() { + self.stage.remove_primitive(prim); + } + for light in gltf_document.lights.iter() { + self.stage.remove_light(light); + } + } + Model::Default(primitive) => { + self.stage.remove_primitive(&primitive); + } + Model::None => {} + } + } + pub fn load_default_model(&mut self) { log::info!("loading default model"); let mut min = Vec3::splat(f32::INFINITY); @@ -260,7 +274,8 @@ impl App { BoundingSphere::from((min, max)) }); - self.model = Model::Default(primitive); + self.set_model(Model::Default(primitive)); + self.camera_controller.reset(Aabb::new(min, max)); self.camera_controller .update_camera(self.stage.get_size(), &self.camera); @@ -271,7 +286,6 @@ impl App { self.camera_controller .reset(Aabb::new(Vec3::NEG_ONE, Vec3::ONE)); self.stage.clear_images().unwrap(); - self.model = Model::None; let doc = match self.stage.load_gltf_document_from_bytes(bytes) { Err(e) => { log::error!("gltf loading error: {e}"); @@ -376,7 +390,7 @@ impl App { // dir.clone(); } // } - self.model = Model::Gltf(Box::new(doc)); + self.set_model(Model::Gltf(Box::new(doc))); } pub fn tick_loads(&mut self) { diff --git a/crates/renderling-ui/src/renderer.rs b/crates/renderling-ui/src/renderer.rs index 21f3776e..32edf5c5 100644 --- a/crates/renderling-ui/src/renderer.rs +++ b/crates/renderling-ui/src/renderer.rs @@ -24,6 +24,7 @@ use crabslab::Id; use glam::{Mat4, UVec2, Vec2, Vec4}; use renderling::{ atlas::{Atlas, AtlasImage, AtlasTexture}, + compositor::Compositor, context::Context, ui_slab::{GradientDescriptor, UiDrawCallDescriptor, UiElementType, UiViewport}, }; @@ -157,6 +158,53 @@ impl UiRect { self.set_z(z); self } + + // --- Getters --- + + /// Returns the top-left position in screen pixels. + pub fn position(&self) -> Vec2 { + self.inner.get().position + } + + /// Returns the size in screen pixels. + pub fn size(&self) -> Vec2 { + self.inner.get().size + } + + /// Returns the fill color (RGBA). + pub fn fill_color(&self) -> Vec4 { + self.inner.get().fill_color + } + + /// Returns the per-corner radii. + pub fn corner_radii(&self) -> Vec4 { + self.inner.get().corner_radii + } + + /// Returns the border width in pixels. + pub fn border_width(&self) -> f32 { + self.inner.get().border_width + } + + /// Returns the border color (RGBA). + pub fn border_color(&self) -> Vec4 { + self.inner.get().border_color + } + + /// Returns the gradient descriptor. + pub fn gradient(&self) -> GradientDescriptor { + self.inner.get().gradient + } + + /// Returns the opacity. + pub fn opacity(&self) -> f32 { + self.inner.get().opacity + } + + /// Returns the z-depth. + pub fn z(&self) -> f32 { + self.inner.get().z + } } /// A live handle to a circle element in the renderer. @@ -272,6 +320,49 @@ impl UiCircle { self.set_z(z); self } + + // --- Getters --- + + /// Returns the center position in screen pixels. + pub fn center(&self) -> Vec2 { + let d = self.inner.get(); + d.position + d.size / 2.0 + } + + /// Returns the radius in screen pixels. + pub fn radius(&self) -> f32 { + self.inner.get().size.x / 2.0 + } + + /// Returns the fill color (RGBA). + pub fn fill_color(&self) -> Vec4 { + self.inner.get().fill_color + } + + /// Returns the border width in pixels. + pub fn border_width(&self) -> f32 { + self.inner.get().border_width + } + + /// Returns the border color (RGBA). + pub fn border_color(&self) -> Vec4 { + self.inner.get().border_color + } + + /// Returns the gradient descriptor. + pub fn gradient(&self) -> GradientDescriptor { + self.inner.get().gradient + } + + /// Returns the opacity. + pub fn opacity(&self) -> f32 { + self.inner.get().opacity + } + + /// Returns the z-depth. + pub fn z(&self) -> f32 { + self.inner.get().z + } } /// A live handle to an ellipse element in the renderer. @@ -387,6 +478,49 @@ impl UiEllipse { self.set_z(z); self } + + // --- Getters --- + + /// Returns the center position in screen pixels. + pub fn center(&self) -> Vec2 { + let d = self.inner.get(); + d.position + d.size / 2.0 + } + + /// Returns the radii (horizontal, vertical) in screen pixels. + pub fn radii(&self) -> Vec2 { + self.inner.get().size / 2.0 + } + + /// Returns the fill color (RGBA). + pub fn fill_color(&self) -> Vec4 { + self.inner.get().fill_color + } + + /// Returns the border width in pixels. + pub fn border_width(&self) -> f32 { + self.inner.get().border_width + } + + /// Returns the border color (RGBA). + pub fn border_color(&self) -> Vec4 { + self.inner.get().border_color + } + + /// Returns the gradient descriptor. + pub fn gradient(&self) -> GradientDescriptor { + self.inner.get().gradient + } + + /// Returns the opacity. + pub fn opacity(&self) -> f32 { + self.inner.get().opacity + } + + /// Returns the z-depth. + pub fn z(&self) -> f32 { + self.inner.get().z + } } /// A live handle to an image element in the renderer. @@ -479,6 +613,33 @@ impl UiImage { self.set_z(z); self } + + // --- Getters --- + + /// Returns the top-left position in screen pixels. + pub fn position(&self) -> Vec2 { + self.inner.get().position + } + + /// Returns the size in screen pixels. + pub fn size(&self) -> Vec2 { + self.inner.get().size + } + + /// Returns the tint color (RGBA). + pub fn tint(&self) -> Vec4 { + self.inner.get().fill_color + } + + /// Returns the opacity. + pub fn opacity(&self) -> f32 { + self.inner.get().opacity + } + + /// Returns the z-depth. + pub fn z(&self) -> f32 { + self.inner.get().z + } } // --------------------------------------------------------------------------- @@ -1018,6 +1179,11 @@ mod path { self.inner.id() } + /// Returns a copy of the underlying descriptor. + pub fn descriptor(&self) -> UiDrawCallDescriptor { + self.inner.get() + } + /// Set the z-depth for sorting. pub fn set_z(&self, z: f32) -> &Self { self.inner.modify(|d| d.z = z); @@ -1041,6 +1207,18 @@ mod path { self.set_opacity(opacity); self } + + // --- Getters --- + + /// Returns the opacity. + pub fn opacity(&self) -> f32 { + self.inner.get().opacity + } + + /// Returns the z-depth. + pub fn z(&self) -> f32 { + self.inner.get().z + } } } @@ -1319,6 +1497,26 @@ mod text { self.set_opacity(opacity); self } + + // --- Getters --- + + /// Returns the opacity (reads from the first glyph, or 1.0 if + /// empty). + pub fn opacity(&self) -> f32 { + self.glyph_descriptors + .first() + .map(|h| h.get().opacity) + .unwrap_or(1.0) + } + + /// Returns the z-depth (reads from the first glyph, or 0.0 if + /// empty). + pub fn z(&self) -> f32 { + self.glyph_descriptors + .first() + .map(|h| h.get().z) + .unwrap_or(0.0) + } } } @@ -1379,6 +1577,14 @@ pub struct UiRenderer { format: wgpu::TextureFormat, /// MSAA resolve texture (if msaa_sample_count > 1). msaa_texture: Option, + /// Non-MSAA intermediate texture for overlay compositing. + /// Used when `background_color` is `None` and MSAA is active: + /// the MSAA texture resolves here, then the compositor blends + /// this onto the caller's target view. + overlay_texture: Option, + /// Compositor for alpha-blending the overlay texture onto the + /// final target. + compositor: Compositor, // --- Text support (behind "text" feature) --- #[cfg(feature = "text")] @@ -1441,6 +1647,8 @@ impl UiRenderer { size, default_msaa, )); + let overlay_texture = Some(Self::create_overlay_texture(device, format, size)); + let compositor = Compositor::new(device, format); Self { slab, @@ -1457,6 +1665,8 @@ impl UiRenderer { msaa_sample_count: default_msaa, format, msaa_texture, + overlay_texture, + compositor, #[cfg(feature = "text")] fonts: Vec::new(), #[cfg(feature = "text")] @@ -1496,8 +1706,14 @@ impl UiRenderer { self.viewport_size, count, )); + self.overlay_texture = Some(Self::create_overlay_texture( + device, + self.format, + self.viewport_size, + )); } else { self.msaa_texture = None; + self.overlay_texture = None; } self } @@ -1519,6 +1735,11 @@ impl UiRenderer { size, self.msaa_sample_count, )); + self.overlay_texture = Some(Self::create_overlay_texture( + self.slab.device(), + self.format, + size, + )); } } } @@ -1890,6 +2111,18 @@ impl UiRenderer { let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Self::LABEL }); + let is_overlay = self.background_color.is_none(); + let use_msaa = self.msaa_sample_count > 1; + + // Determine load op, color attachment, and resolve target. + // + // Overlay + MSAA: clear MSAA to transparent, resolve to + // intermediate overlay texture, then compositor blends onto + // the caller's view. + // Overlay + no MSAA: load existing view content, render + // directly (alpha blending preserves the scene). + // Standalone: clear to background color, resolve (or render) + // directly to the caller's view. let load_op = if let Some(bg) = self.background_color { wgpu::LoadOp::Clear(wgpu::Color { r: bg.x as f64, @@ -1897,13 +2130,32 @@ impl UiRenderer { b: bg.z as f64, a: bg.w as f64, }) + } else if use_msaa { + // Overlay + MSAA: clear the MSAA texture to transparent + // so non-UI pixels resolve as fully transparent. + wgpu::LoadOp::Clear(wgpu::Color { + r: 0.0, + g: 0.0, + b: 0.0, + a: 0.0, + }) } else { wgpu::LoadOp::Load }; - let (color_view, resolve_target) = if self.msaa_sample_count > 1 { + let (color_view, resolve_target) = if use_msaa { if let Some(msaa_view) = &self.msaa_texture { - (msaa_view, Some(view)) + if is_overlay { + // Overlay: resolve to intermediate texture + // (NOT the caller's view, which would overwrite + // the 3D scene). + let resolve = self.overlay_texture.as_ref().unwrap(); + (msaa_view as &wgpu::TextureView, Some(resolve)) + } else { + // Standalone: resolve directly to the caller's + // view. + (msaa_view as &wgpu::TextureView, Some(view)) + } } else { (view, None) } @@ -1942,6 +2194,14 @@ impl UiRenderer { } queue.submit(Some(encoder.finish())); + + // Overlay + MSAA: alpha-blend the resolved UI texture onto + // the caller's view, preserving the 3D scene underneath. + if is_overlay && use_msaa { + if let Some(overlay) = &self.overlay_texture { + self.compositor.composite(device, queue, overlay, view); + } + } } // --- Private helpers --- @@ -2096,6 +2356,34 @@ impl UiRenderer { texture.create_view(&wgpu::TextureViewDescriptor::default()) } + /// Create a non-MSAA intermediate texture for overlay compositing. + /// + /// When the UI is rendered as an overlay (no background clear) with + /// MSAA enabled, the MSAA texture resolves into this intermediate + /// texture, which is then alpha-blended onto the final target by + /// the compositor. + fn create_overlay_texture( + device: &wgpu::Device, + format: wgpu::TextureFormat, + size: UVec2, + ) -> wgpu::TextureView { + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("renderling-ui-overlay"), + size: wgpu::Extent3d { + width: size.x, + height: size.y, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + texture.create_view(&wgpu::TextureViewDescriptor::default()) + } + /// Create a bind group using the given slab buffer and atlas /// texture. fn create_bindgroup( diff --git a/crates/renderling/shaders/compositor-compositor_fragment.spv b/crates/renderling/shaders/compositor-compositor_fragment.spv new file mode 100644 index 0000000000000000000000000000000000000000..24ff6b8e38e044bfc178be89754b65547a972407 GIT binary patch literal 600 zcmYk2-AY1H5QQgABr8w;U{nyA-eg@%7(3H-j}-qgsP*=x<; za_Y5lGa6eFQN$%*trNA7F@E4?F{Cp?Mq0ArNu~`xC{s4U=E}OnK*Pw?!dLU3R@(e=l`ps ziAvu3>izbws@hBSa_DReAyh+y-Bk;vpho#H7{brcp6lJKFqihz=J`eQ>fd$qveEcB zpZq_&ygs|ZUINB+fMfO_NYNE}Ir3kT>)!*GBR6&mxQsWCcGnQhJeh=*!7E7nQa?=P z?pzXX@2$E`;gkLq1`F9s~;e$ikwDcubx-%u}%#*(*#^;K6U2kN2VFI zdp2LP4@A5AeeCvcf6kEdbN%^}+uzU^KAiL0!`09kXDNKtC*3G#d8=+La`Pp>anABW zSIOu34IcsKFh=O7Zjf##kj^@ZbPnftmUO?~Vd}kj%iH_J|6pU$dpW3clJ`1`J_F`9htNOE(Glj5>CK$UKjHsyrfu%qf6xY8{);E> zV;-rmZ@&Q2+xpgvkw21Qm$U1Yvv0fyj8`5I@84Pi{)fN#n9H}gqpo+p47B@~%(sek zy%4KSz6Q+c`G`2*XdRf#SnK#+8$i8voK5c8e!U~}Zz5eOzkCar-#X7oXOe5Pp7*y6 b%#-T*|97I^8s4Y8HQv0f+`Mnx^e%V@#{@l9 literal 0 HcmV?d00001 diff --git a/crates/renderling/shaders/manifest.json b/crates/renderling/shaders/manifest.json index 9d7a9539..e5ffbc09 100644 --- a/crates/renderling/shaders/manifest.json +++ b/crates/renderling/shaders/manifest.json @@ -29,6 +29,16 @@ "entry_point": "bloom::shader::bloom_vertex", "wgsl_entry_point": "bloomshaderbloom_vertex" }, + { + "source_path": "shaders/compositor-compositor_fragment.spv", + "entry_point": "compositor::compositor_fragment", + "wgsl_entry_point": "compositorcompositor_fragment" + }, + { + "source_path": "shaders/compositor-compositor_vertex.spv", + "entry_point": "compositor::compositor_vertex", + "wgsl_entry_point": "compositorcompositor_vertex" + }, { "source_path": "shaders/convolution-shader-brdf_lut_convolution_fragment.spv", "entry_point": "convolution::shader::brdf_lut_convolution_fragment", diff --git a/crates/renderling/src/compositor.rs b/crates/renderling/src/compositor.rs new file mode 100644 index 00000000..34d10e07 --- /dev/null +++ b/crates/renderling/src/compositor.rs @@ -0,0 +1,41 @@ +//! Compositor for alpha-blending a source texture onto a target framebuffer. +//! +//! This is used by the `renderling-ui` crate to overlay MSAA-resolved UI +//! content onto a 3D scene without overwriting existing framebuffer content. + +use glam::{Vec2, Vec4}; +use spirv_std::{image::Image2d, spirv, Sampler}; + +/// Fullscreen quad vertex shader for compositing. +/// +/// Generates 6 vertices (two triangles) covering the full clip-space quad +/// and passes through UV coordinates for texture sampling. +#[spirv(vertex)] +pub fn compositor_vertex( + #[spirv(vertex_index)] vertex_id: u32, + out_uv: &mut Vec2, + #[spirv(position)] out_pos: &mut Vec4, +) { + let i = vertex_id as usize; + *out_uv = crate::math::UV_COORD_QUAD_CCW[i]; + *out_pos = crate::math::CLIP_SPACE_COORD_QUAD_CCW[i]; +} + +/// Passthrough fragment shader for compositing. +/// +/// Samples the source texture at the given UV and outputs the color. +/// Alpha blending is handled by the pipeline's blend state, not the shader. +#[spirv(fragment)] +pub fn compositor_fragment( + #[spirv(descriptor_set = 0, binding = 0)] texture: &Image2d, + #[spirv(descriptor_set = 0, binding = 1)] sampler: &Sampler, + in_uv: Vec2, + frag_color: &mut Vec4, +) { + *frag_color = texture.sample(*sampler, in_uv); +} + +#[cfg(not(target_arch = "spirv"))] +mod cpu; +#[cfg(not(target_arch = "spirv"))] +pub use cpu::*; diff --git a/crates/renderling/src/compositor/cpu.rs b/crates/renderling/src/compositor/cpu.rs new file mode 100644 index 00000000..dd03dcf9 --- /dev/null +++ b/crates/renderling/src/compositor/cpu.rs @@ -0,0 +1,161 @@ +//! CPU-side compositor for alpha-blending a source texture onto a target. + +/// Alpha-blends a source texture onto a target framebuffer using a +/// fullscreen quad. +/// +/// This is useful for overlaying MSAA-resolved UI content on top of an +/// existing 3D scene without overwriting the scene's pixels. +/// +/// ```ignore +/// let compositor = Compositor::new(device, format); +/// // ... render 3D scene to `view` ... +/// // ... render UI to `ui_texture` ... +/// compositor.composite(device, queue, &ui_texture_view, &view); +/// ``` +pub struct Compositor { + pipeline: wgpu::RenderPipeline, + bindgroup_layout: wgpu::BindGroupLayout, + sampler: wgpu::Sampler, +} + +impl Compositor { + /// Create a new compositor targeting the given texture format. + pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self { + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("compositor"), + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + address_mode_w: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + ..Default::default() + }); + + let bindgroup_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("compositor"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("compositor"), + bind_group_layouts: &[&bindgroup_layout], + push_constant_ranges: &[], + }); + + let vertex = crate::linkage::compositor_vertex::linkage(device); + let fragment = crate::linkage::compositor_fragment::linkage(device); + + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("compositor"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &vertex.module, + entry_point: Some(vertex.entry_point), + compilation_options: wgpu::PipelineCompilationOptions::default(), + buffers: &[], + }, + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + unclipped_depth: false, + polygon_mode: wgpu::PolygonMode::Fill, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + fragment: Some(wgpu::FragmentState { + module: &fragment.module, + entry_point: Some(fragment.entry_point), + compilation_options: wgpu::PipelineCompilationOptions::default(), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + multiview: None, + cache: None, + }); + + Self { + pipeline, + bindgroup_layout, + sampler, + } + } + + /// Alpha-blend the `source` texture onto the `target` framebuffer. + /// + /// The existing content of `target` is preserved (`LoadOp::Load`) + /// and the source is drawn on top using the pipeline's alpha blend + /// state. + pub fn composite( + &self, + device: &wgpu::Device, + queue: &wgpu::Queue, + source: &wgpu::TextureView, + target: &wgpu::TextureView, + ) { + let bindgroup = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("compositor"), + layout: &self.bindgroup_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(source), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + ], + }); + + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("compositor"), + }); + + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("compositor"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: target, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, Some(&bindgroup), &[]); + pass.draw(0..6, 0..1); + } + + queue.submit(std::iter::once(encoder.finish())); + } +} diff --git a/crates/renderling/src/lib.rs b/crates/renderling/src/lib.rs index bc042b5c..5e09c9cb 100644 --- a/crates/renderling/src/lib.rs +++ b/crates/renderling/src/lib.rs @@ -204,6 +204,7 @@ pub mod bloom; pub mod bvol; pub mod camera; pub mod color; +pub mod compositor; #[cfg(cpu)] pub mod context; pub mod convolution; diff --git a/crates/renderling/src/linkage.rs b/crates/renderling/src/linkage.rs index 7b8910b7..ca1b3ef5 100644 --- a/crates/renderling/src/linkage.rs +++ b/crates/renderling/src/linkage.rs @@ -15,6 +15,8 @@ pub mod bloom_upsample_fragment; pub mod bloom_vertex; pub mod brdf_lut_convolution_fragment; pub mod brdf_lut_convolution_vertex; +pub mod compositor_fragment; +pub mod compositor_vertex; pub mod compute_copy_depth_to_pyramid; pub mod compute_copy_depth_to_pyramid_multisampled; pub mod compute_culling; diff --git a/crates/renderling/src/linkage/compositor_fragment.rs b/crates/renderling/src/linkage/compositor_fragment.rs new file mode 100644 index 00000000..095d2690 --- /dev/null +++ b/crates/renderling/src/linkage/compositor_fragment.rs @@ -0,0 +1,34 @@ +#![allow(dead_code)] +//! Automatically generated by Renderling's `build.rs`. +use crate::linkage::ShaderLinkage; +#[cfg(not(target_arch = "wasm32"))] +mod target { + pub const ENTRY_POINT: &str = "compositor::compositor_fragment"; + pub fn descriptor() -> wgpu::ShaderModuleDescriptor<'static> { + wgpu::include_spirv!("../../shaders/compositor-compositor_fragment.spv") + } + pub fn linkage(device: &wgpu::Device) -> super::ShaderLinkage { + log::debug!("creating native linkage for {}", "compositor_fragment"); + super::ShaderLinkage { + entry_point: ENTRY_POINT, + module: device.create_shader_module(descriptor()).into(), + } + } +} +#[cfg(target_arch = "wasm32")] +mod target { + pub const ENTRY_POINT: &str = "compositorcompositor_fragment"; + pub fn descriptor() -> wgpu::ShaderModuleDescriptor<'static> { + wgpu::include_wgsl!("../../shaders/compositor-compositor_fragment.wgsl") + } + pub fn linkage(device: &wgpu::Device) -> super::ShaderLinkage { + log::debug!("creating web linkage for {}", "compositor_fragment"); + super::ShaderLinkage { + entry_point: ENTRY_POINT, + module: device.create_shader_module(descriptor()).into(), + } + } +} +pub fn linkage(device: &wgpu::Device) -> ShaderLinkage { + target::linkage(device) +} diff --git a/crates/renderling/src/linkage/compositor_vertex.rs b/crates/renderling/src/linkage/compositor_vertex.rs new file mode 100644 index 00000000..e1d399d1 --- /dev/null +++ b/crates/renderling/src/linkage/compositor_vertex.rs @@ -0,0 +1,34 @@ +#![allow(dead_code)] +//! Automatically generated by Renderling's `build.rs`. +use crate::linkage::ShaderLinkage; +#[cfg(not(target_arch = "wasm32"))] +mod target { + pub const ENTRY_POINT: &str = "compositor::compositor_vertex"; + pub fn descriptor() -> wgpu::ShaderModuleDescriptor<'static> { + wgpu::include_spirv!("../../shaders/compositor-compositor_vertex.spv") + } + pub fn linkage(device: &wgpu::Device) -> super::ShaderLinkage { + log::debug!("creating native linkage for {}", "compositor_vertex"); + super::ShaderLinkage { + entry_point: ENTRY_POINT, + module: device.create_shader_module(descriptor()).into(), + } + } +} +#[cfg(target_arch = "wasm32")] +mod target { + pub const ENTRY_POINT: &str = "compositorcompositor_vertex"; + pub fn descriptor() -> wgpu::ShaderModuleDescriptor<'static> { + wgpu::include_wgsl!("../../shaders/compositor-compositor_vertex.wgsl") + } + pub fn linkage(device: &wgpu::Device) -> super::ShaderLinkage { + log::debug!("creating web linkage for {}", "compositor_vertex"); + super::ShaderLinkage { + entry_point: ENTRY_POINT, + module: device.create_shader_module(descriptor()).into(), + } + } +} +pub fn linkage(device: &wgpu::Device) -> ShaderLinkage { + target::linkage(device) +} From 4d526a961efb149b22ff81999d69c1b621899a78 Mon Sep 17 00:00:00 2001 From: Schell Carl Scivally Date: Thu, 19 Mar 2026 07:45:35 +1300 Subject: [PATCH 08/14] Fix alpha blending, entry points, and doc comments (PR review) Address all 5 Copilot review comments from PR #223: - Switch entire UI pipeline and compositor to premultiplied-alpha blending. The fragment shader now premultiplies RGB by final alpha before output, and both the UI pipeline and compositor use PREMULTIPLIED_ALPHA_BLENDING. This fixes edge darkening on borders and correct compositing of MSAA-resolved overlay textures. - Fix border coverage in the fragment shader: compute straight-alpha weighted blend of border and fill colors, then premultiply at the end, avoiding the previous double-application of alpha at AA edges. - Use explicit entry points (Some(linkage.entry_point)) in the UI render pipeline for consistency with the rest of the codebase. - Update clip_rect doc to note it is reserved for future use (not currently enforced by the shader or renderer). - Fix ui_vertex doc comment to only mention Path elements reading from the slab (TextGlyph uses the standard quad generation path). --- crates/renderling-ui/src/renderer.rs | 6 ++-- .../shaders/ui_slab-shader-ui_fragment.spv | Bin 22340 -> 22668 bytes crates/renderling/src/compositor/cpu.rs | 2 +- crates/renderling/src/ui_slab/mod.rs | 4 +-- crates/renderling/src/ui_slab/shader.rs | 29 +++++++++++++----- test_img/ui2d/bordered_rect.png | Bin 2535 -> 2542 bytes 6 files changed, 27 insertions(+), 14 deletions(-) diff --git a/crates/renderling-ui/src/renderer.rs b/crates/renderling-ui/src/renderer.rs index 32edf5c5..43cef458 100644 --- a/crates/renderling-ui/src/renderer.rs +++ b/crates/renderling-ui/src/renderer.rs @@ -2299,7 +2299,7 @@ impl UiRenderer { layout: Some(&pipeline_layout), vertex: wgpu::VertexState { module: &vertex_linkage.module, - entry_point: None, + entry_point: Some(vertex_linkage.entry_point), compilation_options: wgpu::PipelineCompilationOptions::default(), buffers: &[], }, @@ -2320,11 +2320,11 @@ impl UiRenderer { }, fragment: Some(wgpu::FragmentState { module: &fragment_linkage.module, - entry_point: None, + entry_point: Some(fragment_linkage.entry_point), compilation_options: wgpu::PipelineCompilationOptions::default(), targets: &[Some(wgpu::ColorTargetState { format, - blend: Some(wgpu::BlendState::ALPHA_BLENDING), + blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING), write_mask: wgpu::ColorWrites::ALL, })], }), diff --git a/crates/renderling/shaders/ui_slab-shader-ui_fragment.spv b/crates/renderling/shaders/ui_slab-shader-ui_fragment.spv index 2f54c61bb332e789129efe142136147a3b67e41f..c02e6e6a108f73bb163dddd9a40adea9499f976f 100644 GIT binary patch literal 22668 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 WYftP9GS;Ln?eDj`@c%&U()vH1JsMR2 diff --git a/crates/renderling/src/compositor/cpu.rs b/crates/renderling/src/compositor/cpu.rs index dd03dcf9..09748c2b 100644 --- a/crates/renderling/src/compositor/cpu.rs +++ b/crates/renderling/src/compositor/cpu.rs @@ -88,7 +88,7 @@ impl Compositor { compilation_options: wgpu::PipelineCompilationOptions::default(), targets: &[Some(wgpu::ColorTargetState { format, - blend: Some(wgpu::BlendState::ALPHA_BLENDING), + blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING), write_mask: wgpu::ColorWrites::ALL, })], }), diff --git a/crates/renderling/src/ui_slab/mod.rs b/crates/renderling/src/ui_slab/mod.rs index f36c4808..987ce34a 100644 --- a/crates/renderling/src/ui_slab/mod.rs +++ b/crates/renderling/src/ui_slab/mod.rs @@ -164,8 +164,8 @@ pub struct UiDrawCallDescriptor { /// Set to `Id::NONE` when unused. pub atlas_descriptor_id: Id, /// Scissor/clip rectangle (x, y, width, height). - /// Elements outside this rect are clipped. Set to (0,0, viewport_w, - /// viewport_h) for no clipping. + /// Reserved for future use — not currently enforced by the shader + /// or renderer. Set to (0, 0, viewport_w, viewport_h) by default. pub clip_rect: Vec4, /// Element opacity (0.0 = fully transparent, 1.0 = fully opaque). /// Multiplied with the final alpha. diff --git a/crates/renderling/src/ui_slab/shader.rs b/crates/renderling/src/ui_slab/shader.rs index e355a096..2e789de8 100644 --- a/crates/renderling/src/ui_slab/shader.rs +++ b/crates/renderling/src/ui_slab/shader.rs @@ -4,7 +4,7 @@ //! crate's `UiRenderer`. use crabslab::{Id, Slab, SlabItem}; -use glam::{Vec2, Vec4}; +use glam::{Vec2, Vec4, Vec4Swizzles}; use spirv_std::{image::Image2dArray, spirv, Sampler}; use super::{GradientType, UiDrawCallDescriptor, UiElementType, UiVertex, UiViewport}; @@ -95,8 +95,8 @@ fn eval_gradient( /// size, reading from the slab. The vertex index (0..5) selects which /// corner of the quad. /// -/// For Path and TextGlyph elements, the vertex data is read directly -/// from the slab (pre-tessellated vertices). +/// For Path elements, the vertex data is read directly from the slab +/// (pre-tessellated vertices). #[spirv(vertex)] pub fn ui_vertex( #[spirv(vertex_index)] vertex_index: u32, @@ -254,11 +254,21 @@ pub fn ui_fragment( let inner_distance = distance + draw_call.border_width; let border_alpha = 1.0 - crate::math::smoothstep(-aa_width, aa_width, inner_distance); - // Inside the border but outside the fill = border color. - let in_border = fill_alpha; - let in_fill = border_alpha; - color = draw_call.border_color * (in_border - in_fill) + fill * in_fill; - color.w = draw_call.border_color.w * (in_border - in_fill) + fill.w * in_fill; + // Coverage weights. + let border_weight = fill_alpha - border_alpha; + let fill_weight = border_alpha; + let total = fill_alpha; + // Straight-alpha RGB: weighted blend of border and fill. + if total > 0.0 { + let rgb = (draw_call.border_color.xyz() * border_weight + + fill.xyz() * fill_weight) + / total; + let a = + (draw_call.border_color.w * border_weight + fill.w * fill_weight) / total; + color = rgb.extend(a * total); + } else { + color = Vec4::ZERO; + } } else { color = fill; color.w *= fill_alpha; @@ -269,5 +279,8 @@ pub fn ui_fragment( // Apply element opacity. color.w *= draw_call.opacity; + // Premultiply RGB by final alpha for premultiplied-alpha blending. + color = (color.xyz() * color.w).extend(color.w); + *frag_color = color; } diff --git a/test_img/ui2d/bordered_rect.png b/test_img/ui2d/bordered_rect.png index 6c350558d7b97aa0339ff95f54cb8642cbc9afac..6e51a10683f2b1957d763cf5da8d57fa4c5b975d 100644 GIT binary patch delta 1293 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 LnN!Hg>aKqQ{} Date: Sat, 21 Mar 2026 10:32:22 +1300 Subject: [PATCH 09/14] fmt --- crates/renderling/src/context.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/renderling/src/context.rs b/crates/renderling/src/context.rs index 9cf7965e..a2da72c5 100644 --- a/crates/renderling/src/context.rs +++ b/crates/renderling/src/context.rs @@ -628,5 +628,4 @@ impl Context { pub fn new_stage(&self) -> Stage { Stage::new(self) } - } From a8035a4113db04205ecc5c21cede7850b339c4e6 Mon Sep 17 00:00:00 2001 From: Schell Carl Scivally Date: Sat, 21 Mar 2026 16:32:52 +1300 Subject: [PATCH 10/14] cache busting --- .github/workflows/push.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml index 494f9daa..4b51845a 100644 --- a/.github/workflows/push.yaml +++ b/.github/workflows/push.yaml @@ -17,7 +17,7 @@ jobs: install-cargo-gpu: strategy: matrix: - os: [ubuntu-latest, ubuntu-24.04, macos-latest] + os: [ubuntu-24.04, macos-latest] runs-on: ${{ matrix.os }} defaults: run: @@ -35,7 +35,7 @@ jobs: with: path: ~/.cargo # THIS KEY MUST MATCH BELOW - key: cargo-cache-${{ env.CARGO_GPU_COMMITSH }}-${{ matrix.os }} + key: cargo-cache-1-${{ env.CARGO_GPU_COMMITSH }}-${{ matrix.os }} - uses: moonrepo/setup-rust@v1 - run: rustup default stable - run: rustup update @@ -56,7 +56,7 @@ jobs: matrix: # temporarily skip windows, revisit after a fix for this error is found: # https://github.com/rust-lang/cc-rs/issues/1331 - os: [ubuntu-latest, ubuntu-24.04, macos-latest] #, windows-latest] + os: [ubuntu-24.04, macos-latest] #, windows-latest] runs-on: ${{ matrix.os }} defaults: run: @@ -73,7 +73,7 @@ jobs: with: path: ~/.cargo # THIS KEY MUST MATCH ABOVE - key: cargo-cache-${{ env.CARGO_GPU_COMMITSH }}-${{ matrix.os }} + key: cargo-cache-1-${{ env.CARGO_GPU_COMMITSH }}-${{ matrix.os }} - uses: actions/cache@v4 with: path: | @@ -99,7 +99,7 @@ jobs: with: path: ~/.cargo # THIS KEY MUST MATCH ABOVE - key: cargo-cache-${{ env.CARGO_GPU_COMMITSH }}-ubuntu-latest + key: cargo-cache-1-${{ env.CARGO_GPU_COMMITSH }}-ubuntu-24.04 - run: mkdir -p $HOME/.cargo/bin - uses: moonrepo/setup-rust@v1 - run: rustup install nightly From fd49008fd3a31c5814c02f87b859cc79c8dd8c17 Mon Sep 17 00:00:00 2001 From: Schell Carl Scivally Date: Sat, 21 Mar 2026 21:15:52 +1300 Subject: [PATCH 11/14] cache busting --- .github/workflows/push.yaml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml index 4b51845a..7aa2e6fc 100644 --- a/.github/workflows/push.yaml +++ b/.github/workflows/push.yaml @@ -37,6 +37,8 @@ jobs: # THIS KEY MUST MATCH BELOW key: cargo-cache-1-${{ env.CARGO_GPU_COMMITSH }}-${{ matrix.os }} - uses: moonrepo/setup-rust@v1 + with: + cache: false - run: rustup default stable - run: rustup update - run: | @@ -65,10 +67,11 @@ jobs: RUST_LOG: debug steps: - uses: actions/checkout@v2 - # Run moonrepo/setup-rust BEFORE restoring ~/.cargo cache, because - # moonrepo restores its own ~/.cargo cache which would overwrite - # the cargo-gpu binary installed by the install-cargo-gpu job. + # Disable moonrepo's built-in cache so it doesn't overwrite ~/.cargo + # (which contains the cargo-gpu binary from the install-cargo-gpu job). - uses: moonrepo/setup-rust@v1 + with: + cache: false - uses: actions/cache@v4 with: path: ~/.cargo From 7ade4fda87d8298c6ed8e86f2bf7d33a6bd8a14a Mon Sep 17 00:00:00 2001 From: Schell Carl Scivally Date: Sun, 22 Mar 2026 08:31:42 +1300 Subject: [PATCH 12/14] actually install cargo-gpu for ubuntu-latest --- .github/workflows/push.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml index 7aa2e6fc..be685ba8 100644 --- a/.github/workflows/push.yaml +++ b/.github/workflows/push.yaml @@ -17,7 +17,7 @@ jobs: install-cargo-gpu: strategy: matrix: - os: [ubuntu-24.04, macos-latest] + os: [ubuntu-latest, ubuntu-24.04, macos-latest] runs-on: ${{ matrix.os }} defaults: run: From 8412ef185541c815f98783c782fd9487e6d4bd70 Mon Sep 17 00:00:00 2001 From: Schell Carl Scivally Date: Sun, 22 Mar 2026 08:45:06 +1300 Subject: [PATCH 13/14] cache bust --- .github/workflows/push.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml index be685ba8..c012e0d3 100644 --- a/.github/workflows/push.yaml +++ b/.github/workflows/push.yaml @@ -17,7 +17,7 @@ jobs: install-cargo-gpu: strategy: matrix: - os: [ubuntu-latest, ubuntu-24.04, macos-latest] + os: [ubuntu-24.04, macos-latest] runs-on: ${{ matrix.os }} defaults: run: @@ -83,7 +83,7 @@ jobs: ${{ needs.install-cargo-gpu.outputs.cachepath-macOS }} ${{ needs.install-cargo-gpu.outputs.cachepath-Linux }} ${{ needs.install-cargo-gpu.outputs.cachepath-Windows }} - key: rust-gpu-cache-0-${{ matrix.os }} + key: rust-gpu-cache-1-${{ matrix.os }} - run: rustup install nightly - run: rustup component add --toolchain nightly rustfmt - run: cargo gpu show commitsh From 5ea881c5b380ac001fc2d5fd6fcef64a8c41207c Mon Sep 17 00:00:00 2001 From: Schell Carl Scivally Date: Sun, 22 Mar 2026 08:52:27 +1300 Subject: [PATCH 14/14] bust --- .github/workflows/push.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml index c012e0d3..c7b6b68e 100644 --- a/.github/workflows/push.yaml +++ b/.github/workflows/push.yaml @@ -35,7 +35,7 @@ jobs: with: path: ~/.cargo # THIS KEY MUST MATCH BELOW - key: cargo-cache-1-${{ env.CARGO_GPU_COMMITSH }}-${{ matrix.os }} + key: cargo-cache-2-${{ env.CARGO_GPU_COMMITSH }}-${{ matrix.os }} - uses: moonrepo/setup-rust@v1 with: cache: false @@ -76,7 +76,7 @@ jobs: with: path: ~/.cargo # THIS KEY MUST MATCH ABOVE - key: cargo-cache-1-${{ env.CARGO_GPU_COMMITSH }}-${{ matrix.os }} + key: cargo-cache-2-${{ env.CARGO_GPU_COMMITSH }}-${{ matrix.os }} - uses: actions/cache@v4 with: path: | @@ -102,7 +102,7 @@ jobs: with: path: ~/.cargo # THIS KEY MUST MATCH ABOVE - key: cargo-cache-1-${{ env.CARGO_GPU_COMMITSH }}-ubuntu-24.04 + key: renderling-fmt-${{ env.CARGO_GPU_COMMITSH }}-ubuntu-24.04 - run: mkdir -p $HOME/.cargo/bin - uses: moonrepo/setup-rust@v1 - run: rustup install nightly