diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml index 563f126d..28369f14 100644 --- a/.github/workflows/push.yaml +++ b/.github/workflows/push.yaml @@ -38,6 +38,7 @@ jobs: key: cargo-cache-${{ env.CARGO_GPU_COMMITSH }}-${{ runner.os }} - uses: moonrepo/setup-rust@v1 - run: rustup default stable + - run: rustup component add --toolchain nightly rustfmt - run: rustup update - run: | cargo install --git https://github.com/rust-gpu/cargo-gpu --rev $CARGO_GPU_COMMITSH cargo-gpu @@ -83,6 +84,7 @@ jobs: - run: cargo shaders - run: cargo linkage - run: cargo build -p renderling + - run: cargo +nightly fmt - run: git diff --exit-code --no-ext-diff # BAU clippy lints diff --git a/Cargo.lock b/Cargo.lock index 124b80a9..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", @@ -3746,6 +3745,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/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 e768debc..e083550a 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,34 @@ 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 +110,10 @@ 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 +167,10 @@ impl App { }) .unwrap(); - let ui = Ui::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, &ui); + let (fps_text, fps_background) = AppUi::make_fps_widget(&fps_counter, &mut ui); Self { ui: AppUi { @@ -199,7 +206,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()); @@ -223,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); @@ -249,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); @@ -260,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}"); @@ -286,29 +311,8 @@ impl App { let scene = doc.default_scene.unwrap_or(0); log::info!("Displaying scene {scene}"); - fn get_children(doc: &GltfDocument, n: usize) -> Vec { - let mut children = vec![]; - if let Some(parent) = doc.nodes.get(n) { - children.extend(parent.children.iter().copied()); - let descendants = parent - .children - .iter() - .copied() - .flat_map(|n| get_children(doc, n)); - children.extend(descendants); - } - children - } - let nodes = doc.nodes_in_scene(scene).flat_map(|n| { - let mut all_nodes = vec![n]; - for child_index in get_children(&doc, n.index) { - if let Some(child_node) = doc.nodes.get(child_index) { - all_nodes.push(child_node); - } - } - all_nodes - }); + let nodes = doc.recursive_nodes_in_scene(scene); log::trace!(" nodes:"); for node in nodes { let tfrm = Mat4::from(node.global_transform()); @@ -380,13 +384,13 @@ impl App { // self.lighting // .shadow_map - // .update(&self.lighting.lighting, doc.primitives.values().flatten()); - // self.lighting.light = light.light.clone(); - // self.lighting.light_details = dir.clone(); - // } + // .update(&self.lighting.lighting, + // doc.primitives.values().flatten()); self.lighting.light = + // light.light.clone(); self.lighting.light_details = + // 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/Cargo.toml b/crates/renderling-ui/Cargo.toml new file mode 100644 index 00000000..c40ca235 --- /dev/null +++ b/crates/renderling-ui/Cargo.toml @@ -0,0 +1,39 @@ +[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:image", "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 } +image = { 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..f18bf9ea --- /dev/null +++ b/crates/renderling-ui/src/lib.rs @@ -0,0 +1,59 @@ +//! 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::{ + atlas::{AtlasImage, AtlasTexture}, + context::Context, + glam, + ui_slab::{ + GradientDescriptor, GradientType, UiDrawCallDescriptor, UiElementType, UiVertex, UiViewport, + }, +}; + +// 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}; + +// 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 new file mode 100644 index 00000000..43cef458 --- /dev/null +++ b/crates/renderling-ui/src/renderer.rs @@ -0,0 +1,2423 @@ +//! 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::{ + atlas::{Atlas, AtlasImage, AtlasTexture}, + compositor::Compositor, + 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 + } + + // --- 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. +/// +/// 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 + } + + // --- 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. +/// +/// 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 + } + + // --- 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. +/// +/// 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 + } + + // --- 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 + } +} + +// --------------------------------------------------------------------------- +// 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::{atlas::shader::AtlasTextureDescriptor, 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, + /// Atlas texture descriptor ID for image-filled paths. + fill_image_id: Id, + } + + 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(), + fill_image_id: Id::NONE, + } + } + + // --- 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 + } + + /// 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. + 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 fill_image_id = self.fill_image_id; + 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"); + + // 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(); + + 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"); + + // 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, + atlas_texture_id: Id, + ) -> 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_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(), + 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() + } + + /// 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); + 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 + } + + // --- 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 + } + } +} + +#[cfg(feature = "path")] +pub use path::{StrokeConfig, UiPath, UiPathBuilder}; + +// --------------------------------------------------------------------------- +// 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 + } + + // --- 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) + } + } +} + +#[cfg(feature = "text")] +use text::GlyphCache; +#[cfg(feature = "text")] +pub use text::{FontArc, FontId, Section, Text, UiText}; + +// --------------------------------------------------------------------------- +// 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, + 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. + 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, + /// 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")] + 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 { + 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(); + let size = ctx.get_size(); + 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 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, + )); + let overlay_texture = Some(Self::create_overlay_texture(device, format, size)); + let compositor = Compositor::new(device, format); + + 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: default_msaa, + format, + msaa_texture, + overlay_texture, + compositor, + #[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, + } + } + + /// 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 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, + )); + self.overlay_texture = Some(Self::create_overlay_texture( + device, + self.format, + self.viewport_size, + )); + } else { + self.msaa_texture = None; + self.overlay_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 { + 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, + )); + self.overlay_texture = Some(Self::create_overlay_texture( + self.slab.device(), + self.format, + size, + )); + } + } + } + + /// 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 + } + + /// 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(); + 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 + } + + /// 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 + /// [`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(); + + 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()); + } + + /// 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 an image element by its handle. + pub fn remove_image(&mut self, element: &UiImage) { + 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()); + } + } + + /// 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(); + // 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) + }); + + // 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(); + + // 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, &atlas_tex)); + self.bindgroup_atlas_texture_id = Some(atlas_tex_id); + } + drop(atlas_tex); + 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 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, + g: bg.y as f64, + 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 use_msaa { + if let Some(msaa_view) = &self.msaa_texture { + 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) + } + } 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())); + + // 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 --- + + 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: Id::NONE, + atlas_descriptor_id: Id::NONE, + 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: Some(vertex_linkage.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 { + count: msaa_sample_count, + mask: !0, + alpha_to_coverage_enabled: false, + }, + fragment: Some(wgpu::FragmentState { + module: &fragment_linkage.module, + entry_point: Some(fragment_linkage.entry_point), + compilation_options: wgpu::PipelineCompilationOptions::default(), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: Some(wgpu::BlendState::PREMULTIPLIED_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 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( + &self, + buffer: &SlabBuffer, + atlas_tex: &renderling::texture::Texture, + ) -> 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(&atlas_tex.view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&atlas_tex.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..be733d54 --- /dev/null +++ b/crates/renderling-ui/src/test.rs @@ -0,0 +1,433 @@ +//! 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_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(); + 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); + } + + #[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 = "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() { + 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); + } + + /// 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/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/shaders/compositor-compositor_fragment.spv b/crates/renderling/shaders/compositor-compositor_fragment.spv new file mode 100644 index 00000000..24ff6b8e Binary files /dev/null and b/crates/renderling/shaders/compositor-compositor_fragment.spv differ diff --git a/crates/renderling/shaders/compositor-compositor_vertex.spv b/crates/renderling/shaders/compositor-compositor_vertex.spv new file mode 100644 index 00000000..974db32e Binary files /dev/null and b/crates/renderling/shaders/compositor-compositor_vertex.spv differ diff --git a/crates/renderling/shaders/manifest.json b/crates/renderling/shaders/manifest.json index b2297abe..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", @@ -203,5 +213,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 00000000..c02e6e6a Binary files /dev/null and b/crates/renderling/shaders/ui_slab-shader-ui_fragment.spv differ 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 00000000..1269cdb0 Binary files /dev/null and b/crates/renderling/shaders/ui_slab-shader-ui_vertex.spv differ 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..09748c2b --- /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::PREMULTIPLIED_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/context.rs b/crates/renderling/src/context.rs index 63969c45..af426a20 100644 --- a/crates/renderling/src/context.rs +++ b/crates/renderling/src/context.rs @@ -15,9 +15,7 @@ use snafu::prelude::*; use crate::{ stage::Stage, texture::{BufferDimensions, CopiedTextureBuffer, Texture, TextureError}, - ui::Ui, }; - pub use craballoc::runtime::WgpuRuntime; /// Represents the internal structure of a render target, which can either be a surface or a texture. @@ -612,8 +610,4 @@ impl Context { Stage::new(self) } - /// Creates and returns a new [`Ui`] renderer. - pub fn new_ui(&self) -> Ui { - Ui::new(self) - } } diff --git a/crates/renderling/src/gltf.rs b/crates/renderling/src/gltf.rs index 980e67fa..2d9706ee 100644 --- a/crates/renderling/src/gltf.rs +++ b/crates/renderling/src/gltf.rs @@ -2,7 +2,8 @@ //! //! # Loading GLTF files //! -//! Loading GLTF files is accomplished through [`Stage::load_gltf_document_from_path`] +//! Loading GLTF files is accomplished through +//! [`Stage::load_gltf_document_from_path`] //! and [`Stage::load_gltf_document_from_bytes`]. use std::{collections::HashMap, sync::Arc}; @@ -458,8 +459,8 @@ impl GltfPrimitive { // https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html#morph-targets // // TODO: Generate morph target normals and tangents if absent. - // Although the spec says we have to generate normals or tangents if not specified, - // we are explicitly *not* doing that here. + // Although the spec says we have to generate normals or tangents if not + // specified, we are explicitly *not* doing that here. let morph_targets: Vec> = reader .read_morph_targets() .map(|(may_ps, may_ns, may_ts)| { @@ -1236,7 +1237,24 @@ where self.primitives.iter().flat_map(|(_, rs)| rs.iter()) } - pub fn nodes_in_scene(&self, scene_index: usize) -> impl Iterator { + fn collect_nodes_recursive<'a>(&'a self, node_index: usize, nodes: &mut Vec<&'a GltfNode>) { + if let Some(node) = self.nodes.get(node_index) { + nodes.push(node); + for child_index in node.children.iter() { + self.collect_nodes_recursive(*child_index, nodes); + } + } + } + + /// Returns the root (top-level) nodes in the given scene. + /// + /// This roughly follows [`gltf::Scene::nodes`](https://docs.rs/gltf/latest/gltf/scene/struct.Scene.html#method.nodes), + /// returning only the nodes directly referenced by the scene — not + /// their children. + /// + /// Use [`recursive_nodes_in_scene`](Self::recursive_nodes_in_scene) + /// if you need all nodes (including descendants). + pub fn root_nodes_in_scene(&self, scene_index: usize) -> impl Iterator { let scene = self.scenes.get(scene_index); let mut nodes = vec![]; if let Some(indices) = scene { @@ -1249,9 +1267,26 @@ where nodes.into_iter() } + /// Returns all nodes in the given scene, recursively including + /// children. + /// + /// Root nodes are visited first, followed by their descendants in + /// depth-first order. + pub fn recursive_nodes_in_scene(&self, scene_index: usize) -> impl Iterator { + let scene = self.scenes.get(scene_index); + let mut nodes = vec![]; + if let Some(indices) = scene { + for node_index in indices { + self.collect_nodes_recursive(*node_index, &mut nodes); + } + } + nodes.into_iter() + } + /// Returns the bounding volume of this document, if possible. /// - /// This function will return `None` if this document does not contain meshes. + /// This function will return `None` if this document does not contain + /// meshes. pub fn bounding_volume(&self) -> Option { let mut aabbs = vec![]; for node in self.nodes.iter() { @@ -1505,8 +1540,8 @@ mod test { // .get(0) // .unwrap() // .clone() - // .into_animator(doc.nodes.iter().map(|n| (n.index, n.transform.clone()))); - // animator.progress(0.0).unwrap(); + // .into_animator(doc.nodes.iter().map(|n| (n.index, + // n.transform.clone()))); animator.progress(0.0).unwrap(); // let frame = ctx.get_next_frame().unwrap(); // stage.render(&frame.view()); // let img = frame.read_image().unwrap(); diff --git a/crates/renderling/src/gltf/anime.rs b/crates/renderling/src/gltf/anime.rs index ddaf90e4..939f8688 100644 --- a/crates/renderling/src/gltf/anime.rs +++ b/crates/renderling/src/gltf/anime.rs @@ -787,7 +787,7 @@ mod test { .unwrap(); let nodes = doc - .nodes_in_scene(doc.default_scene.unwrap_or_default()) + .recursive_nodes_in_scene(doc.default_scene.unwrap_or_default()) .collect::>(); let mut animator = Animator::new(nodes, doc.animations.first().unwrap().clone()); diff --git a/crates/renderling/src/lib.rs b/crates/renderling/src/lib.rs index c4249de4..93acee83 100644 --- a/crates/renderling/src/lib.rs +++ b/crates/renderling/src/lib.rs @@ -205,6 +205,7 @@ pub mod bloom; pub mod bvol; pub mod camera; pub mod color; +pub mod compositor; #[cfg(cpu)] pub mod context; pub mod convolution; @@ -235,8 +236,7 @@ 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/linkage.rs b/crates/renderling/src/linkage.rs index f12c7a3e..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; @@ -44,6 +46,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/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) +} diff --git a/crates/renderling/src/linkage/light_tiling_compute_tile_min_and_max_depth_multisampled.rs b/crates/renderling/src/linkage/light_tiling_compute_tile_min_and_max_depth_multisampled.rs index 9c1af43d..8fa577ac 100644 --- a/crates/renderling/src/linkage/light_tiling_compute_tile_min_and_max_depth_multisampled.rs +++ b/crates/renderling/src/linkage/light_tiling_compute_tile_min_and_max_depth_multisampled.rs @@ -6,7 +6,10 @@ mod target { pub const ENTRY_POINT: &str = "light::shader::light_tiling_compute_tile_min_and_max_depth_multisampled"; pub fn descriptor() -> wgpu::ShaderModuleDescriptor<'static> { - wgpu :: include_spirv ! ("../../shaders/light-shader-light_tiling_compute_tile_min_and_max_depth_multisampled.spv") + wgpu::include_spirv!( + "../../shaders/light-shader-light_tiling_compute_tile_min_and_max_depth_multisampled.\ + spv" + ) } pub fn linkage(device: &wgpu::Device) -> super::ShaderLinkage { log::debug!( @@ -24,7 +27,10 @@ mod target { pub const ENTRY_POINT: &str = "lightshaderlight_tiling_compute_tile_min_and_max_depth_multisampled"; pub fn descriptor() -> wgpu::ShaderModuleDescriptor<'static> { - wgpu :: include_wgsl ! ("../../shaders/light-shader-light_tiling_compute_tile_min_and_max_depth_multisampled.wgsl") + wgpu::include_wgsl!( + "../../shaders/light-shader-light_tiling_compute_tile_min_and_max_depth_multisampled.\ + wgsl" + ) } pub fn linkage(device: &wgpu::Device) -> super::ShaderLinkage { log::debug!( 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.rs b/crates/renderling/src/ui.rs deleted file mode 100644 index a654450c..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 renderling::ui::prelude::*; -//! use glam::Vec2; -//! -//! 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 4e224382..00000000 --- a/crates/renderling/src/ui/cpu.rs +++ /dev/null @@ -1,369 +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 0ccad0ba..00000000 --- a/crates/renderling/src/ui/sdf.rs +++ /dev/null @@ -1,23 +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/crates/renderling/src/ui_slab/mod.rs b/crates/renderling/src/ui_slab/mod.rs new file mode 100644 index 00000000..987ce34a --- /dev/null +++ b/crates/renderling/src/ui_slab/mod.rs @@ -0,0 +1,190 @@ +//! 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::{Id, SlabItem}; +use glam::{Mat4, UVec2, Vec2, Vec4}; + +use crate::atlas::shader::{AtlasDescriptor, AtlasTextureDescriptor}; + +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` 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: 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: Id, + /// Scissor/clip rectangle (x, y, width, height). + /// 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. + 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, 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 new file mode 100644 index 00000000..2e789de8 --- /dev/null +++ b/crates/renderling/src/ui_slab/shader.rs @@ -0,0 +1,286 @@ +//! 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, Vec4Swizzles}; +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 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_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); + *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: 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: 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 = draw_call.fill_color; + color.w *= sample.w; + } + UiElementType::Image => { + // Textured quad: sample the atlas texture. + 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); + // 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); + // 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; + } + } + } + + // 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/ui/path/fill_image.png b/test_img/ui/path/fill_image.png deleted file mode 100644 index d244016d..00000000 Binary files a/test_img/ui/path/fill_image.png and /dev/null differ diff --git a/test_img/ui/path/sanity.png b/test_img/ui/path/sanity.png deleted file mode 100644 index 4782893d..00000000 Binary files a/test_img/ui/path/sanity.png and /dev/null differ diff --git a/test_img/ui/path/shapes.png b/test_img/ui/path/shapes.png deleted file mode 100644 index 95298e9c..00000000 Binary files a/test_img/ui/path/shapes.png and /dev/null differ diff --git a/test_img/ui/text/can_display.png b/test_img/ui/text/can_display.png deleted file mode 100644 index 6738bcd4..00000000 Binary files a/test_img/ui/text/can_display.png and /dev/null differ diff --git a/test_img/ui/text/can_recreate_0.png b/test_img/ui/text/can_recreate_0.png deleted file mode 100644 index f205e423..00000000 Binary files a/test_img/ui/text/can_recreate_0.png and /dev/null differ diff --git a/test_img/ui/text/can_recreate_1.png b/test_img/ui/text/can_recreate_1.png deleted file mode 100644 index c8c44d5a..00000000 Binary files a/test_img/ui/text/can_recreate_1.png and /dev/null differ diff --git a/test_img/ui/text/overlay.png b/test_img/ui/text/overlay.png deleted file mode 100644 index 1fe89e29..00000000 Binary files a/test_img/ui/text/overlay.png and /dev/null differ diff --git a/test_img/ui/text/overlay_depth.png b/test_img/ui/text/overlay_depth.png deleted file mode 100644 index fa72dbb5..00000000 Binary files a/test_img/ui/text/overlay_depth.png and /dev/null differ diff --git a/test_img/ui2d/bordered_rect.png b/test_img/ui2d/bordered_rect.png new file mode 100644 index 00000000..6e51a106 Binary files /dev/null and b/test_img/ui2d/bordered_rect.png differ diff --git a/test_img/ui2d/circle.png b/test_img/ui2d/circle.png new file mode 100644 index 00000000..39f95931 Binary files /dev/null and b/test_img/ui2d/circle.png differ diff --git a/test_img/ui2d/filled_path.png b/test_img/ui2d/filled_path.png new file mode 100644 index 00000000..174796b5 Binary files /dev/null and b/test_img/ui2d/filled_path.png differ diff --git a/test_img/ui2d/gradient_rect.png b/test_img/ui2d/gradient_rect.png new file mode 100644 index 00000000..b9aab8d8 Binary files /dev/null and b/test_img/ui2d/gradient_rect.png differ diff --git a/test_img/ui2d/image.png b/test_img/ui2d/image.png new file mode 100644 index 00000000..fb661a75 Binary files /dev/null and b/test_img/ui2d/image.png differ diff --git a/test_img/ui2d/image_tint.png b/test_img/ui2d/image_tint.png new file mode 100644 index 00000000..2f9a6b47 Binary files /dev/null and b/test_img/ui2d/image_tint.png differ diff --git a/test_img/ui2d/multiple_shapes.png b/test_img/ui2d/multiple_shapes.png new file mode 100644 index 00000000..99180772 Binary files /dev/null and b/test_img/ui2d/multiple_shapes.png differ diff --git a/test_img/ui2d/path_image_fill.png b/test_img/ui2d/path_image_fill.png new file mode 100644 index 00000000..0cc81ad4 Binary files /dev/null and b/test_img/ui2d/path_image_fill.png differ diff --git a/test_img/ui2d/path_shapes.png b/test_img/ui2d/path_shapes.png new file mode 100644 index 00000000..41c7aef6 Binary files /dev/null and b/test_img/ui2d/path_shapes.png differ diff --git a/test_img/ui2d/rect.png b/test_img/ui2d/rect.png new file mode 100644 index 00000000..21ca8556 Binary files /dev/null and b/test_img/ui2d/rect.png differ diff --git a/test_img/ui2d/rounded_rect.png b/test_img/ui2d/rounded_rect.png new file mode 100644 index 00000000..f31f0295 Binary files /dev/null and b/test_img/ui2d/rounded_rect.png differ diff --git a/test_img/ui2d/stroked_path.png b/test_img/ui2d/stroked_path.png new file mode 100644 index 00000000..4d9591ac Binary files /dev/null and b/test_img/ui2d/stroked_path.png differ diff --git a/test_img/ui2d/text.png b/test_img/ui2d/text.png new file mode 100644 index 00000000..f1ca7d1e Binary files /dev/null and b/test_img/ui2d/text.png differ diff --git a/test_img/ui2d/text_with_shapes.png b/test_img/ui2d/text_with_shapes.png new file mode 100644 index 00000000..52ed938d Binary files /dev/null and b/test_img/ui2d/text_with_shapes.png differ