Skip to content

Commit a7b44f7

Browse files
authored
feat: 2d UI revamp (#223)
* feat: Add renderling-ui crate with SDF-based 2D/UI renderer Introduces a dedicated lightweight 2D rendering pipeline separate from the 3D PBR pipeline. Uses SlabAllocator for GPU memory management with Hybrid<UiDrawCallDescriptor> wrapper types (UiRect, UiCircle, UiEllipse) following the Camera pattern for live GPU updates via set_*/with_* methods. - SDF-based shape rendering (rect, rounded rect, circle, ellipse) - Gradient fills (linear, radial), borders, anti-aliased edges - 3 GPU bindings and 32-byte vertices (vs 13 bindings / ~160 bytes for 3D) - Instanced quads with per-pixel SDF evaluation in fragment shader - 6 visual regression tests with baseline images * feat: Add atlas-based image rendering and fix viewport slab ordering Add UiImage element type with atlas texture support, enabling texture/image rendering in the 2D UI pipeline. Fix critical bug where Atlas::new() was allocated before UiViewport on the slab, causing the shader (which reads viewport at offset 0) to read garbage data and render blank white output. - Add UiImage wrapper with set/with builders for position, size, tint, opacity, z-depth - Replace dummy atlas with real Atlas integration (512x512x2) - Add atlas_size field to UiViewport for correct UV mapping - Fix shader to use viewport.atlas_size instead of viewport.size for AtlasTextureDescriptor::uv() calls - Add AtlasImage, AtlasTexture, UiImage to public re-exports - Recompile shaders with updated UiViewport layout - Add 2 image rendering tests (checkerboard + tint) - Regenerate baseline images with correct rendering - 8 renderling-ui tests + 101 renderling tests pass * feat: Add text rendering via glyph_brush to renderling-ui Port text rendering from the old UI module to the new lightweight 2D pipeline. Glyph rasterization uses glyph_brush to produce a Luma8 cache image, which is converted to RGBA (white + alpha) and uploaded to the atlas. Each visible glyph gets its own AtlasTextureDescriptor pointing to its sub-region in the cache, and a UiDrawCallDescriptor with element_type TextGlyph. The existing fragment shader samples glyph coverage from the atlas alpha channel and multiplies by fill_color. - Add GlyphCache wrapping GlyphBrush with Luma8 cache image - Add UiText handle type with set_z/set_opacity methods - Add add_font(), add_text(), remove_text() to UiRenderer - Add image as optional dep behind 'text' feature - Re-export FontArc, FontId, Section, Text, UiText - Add 2 text tests (plain text + text overlaid on a rounded rect) - 10 renderling-ui tests + 101 renderling tests pass, 0 clippy warnings * feat: Add lyon-based vector path rendering to renderling-ui Port path/vector rendering from the old UI module to the new lightweight 2D pipeline. Uses lyon for tessellation (fill and stroke), de-indexes the triangle mesh, and writes UiVertex arrays to the slab. The existing vertex shader's Path branch reads pre-tessellated vertices from the slab, and the fragment shader passes through the interpolated vertex color. - Add UiPathBuilder with lyon path commands (begin, line_to, quadratic/cubic bezier, add_rectangle, add_circle, etc.) - Add fill() and stroke() methods for tessellation - Add StrokeConfig (line width, cap, join) configuration - Add UiPath handle type with z-depth and opacity setters - Add path_builder() and remove_path() to UiRenderer - Re-export StrokeConfig, UiPath, UiPathBuilder behind 'path' feature - Add 3 path tests (filled triangle, stroked circle, mixed shapes) - 13 renderling-ui tests + 101 renderling tests pass, 0 clippy warnings * Remove old ui module from renderling, migrate example crate to renderling-ui Delete the old renderling::ui module (cpu.rs, sdf.rs, text.rs, path.rs) and its test baselines, remove the 'ui' feature flag and its optional deps (glyph_brush, lyon, loading-bytes). Move loading-bytes to dev-dependencies since it's still needed by wasm tests. Update the example crate to use renderling-ui's UiRenderer, UiRect, UiText, and Section APIs instead of the removed Ui type. * Add image-filled paths, enable 4x MSAA by default, use typed slab Ids Add UiPathBuilder::with_fill_image() for filling tessellated paths with atlas textures. The shader samples the atlas when atlas_texture_id is set on Path elements, using bounding-box-derived UVs. Add UiRenderer::upload_image() to load images without creating a draw call. Enable 4x MSAA by default (matching the old Ui behavior) and expose with_msaa_sample_count() for configuration. Replace raw u32 fields in UiDrawCallDescriptor with typed Ids: atlas_texture_id becomes Id<AtlasTextureDescriptor> and atlas_descriptor_id becomes Id<AtlasDescriptor>, using Id::NONE as the unset sentinel. Update shader and CPU code accordingly. * Add compositor for UI overlay, property getters for all element types Add a Compositor to the renderling crate (compositor_vertex + compositor_fragment shaders) that alpha-blends a source texture onto a target framebuffer via a fullscreen quad. This fixes the black screen when overlaying the UI renderer on a 3D stage with MSAA enabled: the UI now renders to an intermediate texture, resolves MSAA there, and the compositor blends it onto the final view preserving the scene beneath. Add property getters to all UI element types (UiRect, UiCircle, UiEllipse, UiImage, UiPath, UiText) so current values can be read back — a prerequisite for the upcoming tweening/animation system. Fix the example app's UI renderer clearing the 3D scene by removing the erroneous .with_background_color(Vec4::ZERO) call, which was causing a LoadOp::Clear that wiped the stage output. * Fix alpha blending, entry points, and doc comments (PR review) Address all 5 Copilot review comments from PR #223: - Switch entire UI pipeline and compositor to premultiplied-alpha blending. The fragment shader now premultiplies RGB by final alpha before output, and both the UI pipeline and compositor use PREMULTIPLIED_ALPHA_BLENDING. This fixes edge darkening on borders and correct compositing of MSAA-resolved overlay textures. - Fix border coverage in the fragment shader: compute straight-alpha weighted blend of border and fill colors, then premultiply at the end, avoiding the previous double-application of alpha at AA edges. - Use explicit entry points (Some(linkage.entry_point)) in the UI render pipeline for consistency with the rest of the codebase. - Update clip_rect doc to note it is reserved for future use (not currently enforced by the shader or renderer). - Fix ui_vertex doc comment to only mention Path elements reading from the slab (TextGlyph uses the standard quad generation path).
1 parent 7d668ed commit a7b44f7

53 files changed

Lines changed: 3884 additions & 1701 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/push.yaml

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ jobs:
1717
install-cargo-gpu:
1818
strategy:
1919
matrix:
20-
os: [ubuntu-latest, ubuntu-24.04, macos-latest]
20+
os: [ubuntu-24.04, macos-latest]
2121
runs-on: ${{ matrix.os }}
2222
defaults:
2323
run:
@@ -35,8 +35,10 @@ jobs:
3535
with:
3636
path: ~/.cargo
3737
# THIS KEY MUST MATCH BELOW
38-
key: cargo-cache-${{ env.CARGO_GPU_COMMITSH }}-${{ matrix.os }}
38+
key: cargo-cache-2-${{ env.CARGO_GPU_COMMITSH }}-${{ matrix.os }}
3939
- uses: moonrepo/setup-rust@v1
40+
with:
41+
cache: false
4042
- run: rustup default stable
4143
- run: rustup update
4244
- run: |
@@ -56,7 +58,7 @@ jobs:
5658
matrix:
5759
# temporarily skip windows, revisit after a fix for this error is found:
5860
# https://github.com/rust-lang/cc-rs/issues/1331
59-
os: [ubuntu-latest, ubuntu-24.04, macos-latest] #, windows-latest]
61+
os: [ubuntu-24.04, macos-latest] #, windows-latest]
6062
runs-on: ${{ matrix.os }}
6163
defaults:
6264
run:
@@ -65,22 +67,23 @@ jobs:
6567
RUST_LOG: debug
6668
steps:
6769
- uses: actions/checkout@v2
68-
# Run moonrepo/setup-rust BEFORE restoring ~/.cargo cache, because
69-
# moonrepo restores its own ~/.cargo cache which would overwrite
70-
# the cargo-gpu binary installed by the install-cargo-gpu job.
70+
# Disable moonrepo's built-in cache so it doesn't overwrite ~/.cargo
71+
# (which contains the cargo-gpu binary from the install-cargo-gpu job).
7172
- uses: moonrepo/setup-rust@v1
73+
with:
74+
cache: false
7275
- uses: actions/cache@v4
7376
with:
7477
path: ~/.cargo
7578
# THIS KEY MUST MATCH ABOVE
76-
key: cargo-cache-${{ env.CARGO_GPU_COMMITSH }}-${{ matrix.os }}
79+
key: cargo-cache-2-${{ env.CARGO_GPU_COMMITSH }}-${{ matrix.os }}
7780
- uses: actions/cache@v4
7881
with:
7982
path: |
8083
${{ needs.install-cargo-gpu.outputs.cachepath-macOS }}
8184
${{ needs.install-cargo-gpu.outputs.cachepath-Linux }}
8285
${{ needs.install-cargo-gpu.outputs.cachepath-Windows }}
83-
key: rust-gpu-cache-0-${{ matrix.os }}
86+
key: rust-gpu-cache-1-${{ matrix.os }}
8487
- run: rustup install nightly
8588
- run: rustup component add --toolchain nightly rustfmt
8689
- run: cargo gpu show commitsh
@@ -99,7 +102,7 @@ jobs:
99102
with:
100103
path: ~/.cargo
101104
# THIS KEY MUST MATCH ABOVE
102-
key: cargo-cache-${{ env.CARGO_GPU_COMMITSH }}-ubuntu-latest
105+
key: renderling-fmt-${{ env.CARGO_GPU_COMMITSH }}-ubuntu-24.04
103106
- run: mkdir -p $HOME/.cargo/bin
104107
- uses: moonrepo/setup-rust@v1
105108
- run: rustup install nightly

Cargo.lock

Lines changed: 24 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ members = [
77
"crates/loading-bytes",
88
"crates/renderling",
99
"crates/renderling-build",
10+
"crates/renderling-ui",
1011
"crates/wire-types",
1112
# "crates/sandbox",
1213
"crates/xtask"

crates/example/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ lazy_static = "1.4.0"
2222
loading-bytes = { path = "../loading-bytes" }
2323
log = { workspace = true }
2424
renderling = { path = "../renderling" }
25+
renderling-ui = { path = "../renderling-ui", features = ["text", "path"] }
2526
wasm-bindgen = { workspace = true }
2627
wasm-bindgen-futures = "^0.4"
2728
web-sys = { workspace = true, features = ["Performance", "Window"] }

crates/example/src/lib.rs

Lines changed: 48 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ use renderling::{
1818
primitive::Primitive,
1919
skybox::Skybox,
2020
stage::Stage,
21-
ui::{FontArc, Section, Text, Ui, UiPath, UiText},
2221
};
22+
use renderling_ui::{FontArc, Section, Text, UiRect, UiRenderer, UiText};
2323

2424
pub mod camera;
2525
use camera::{
@@ -75,38 +75,45 @@ fn now() -> f64 {
7575
}
7676

7777
struct AppUi {
78-
ui: Ui,
78+
ui: UiRenderer,
7979
fps_text: UiText,
8080
fps_counter: FPSCounter,
81-
fps_background: UiPath,
81+
fps_background: UiRect,
8282
last_fps_display: f64,
8383
}
8484

8585
impl AppUi {
86-
fn make_fps_widget(fps_counter: &FPSCounter, ui: &Ui) -> (UiText, UiPath) {
87-
let translation = Vec2::new(2.0, 2.0);
86+
fn make_fps_widget(fps_counter: &FPSCounter, ui: &mut UiRenderer) -> (UiText, UiRect) {
87+
let offset = Vec2::new(2.0, 2.0);
8888
let text = format!("{}fps", fps_counter.current_fps_string());
89-
let fps_text = ui
90-
.text_builder()
91-
.with_color(Vec3::ZERO.extend(1.0))
92-
.with_section(Section::new().add_text(Text::new(&text).with_scale(32.0)))
93-
.build();
94-
fps_text.transform().set_translation(translation);
89+
let fps_text = ui.add_text(
90+
Section::default()
91+
.add_text(
92+
Text::new(&text)
93+
.with_scale(32.0)
94+
.with_color([0.0, 0.0, 0.0, 1.0]),
95+
)
96+
.with_screen_position((offset.x, offset.y)),
97+
);
98+
let (min, max) = fps_text.bounds();
99+
let size = max - min;
95100
let background = ui
96-
.path_builder()
101+
.add_rect()
102+
.with_position(min)
103+
.with_size(size)
97104
.with_fill_color(Vec4::ONE)
98-
.with_rectangle(fps_text.bounds().0, fps_text.bounds().1)
99-
.fill();
100-
background.transform.set_translation(translation);
101-
background.transform.set_z(-0.9);
105+
.with_z(-0.9);
102106
(fps_text, background)
103107
}
104108

105109
fn tick(&mut self) {
106110
self.fps_counter.next_frame();
107111
let now = now();
108112
if now - self.last_fps_display >= 1.0 {
109-
let (fps_text, background) = Self::make_fps_widget(&self.fps_counter, &self.ui);
113+
// Remove old text and background before recreating.
114+
self.ui.remove_text(&self.fps_text);
115+
self.ui.remove_rect(&self.fps_background);
116+
let (fps_text, background) = Self::make_fps_widget(&self.fps_counter, &mut self.ui);
110117
self.fps_text = fps_text;
111118
self.fps_background = background;
112119
self.last_fps_display = now;
@@ -160,10 +167,10 @@ impl App {
160167
})
161168
.unwrap();
162169

163-
let ui = Ui::new(ctx).with_background_color(Vec4::ZERO);
170+
let mut ui = UiRenderer::new(ctx);
164171
let _ = ui.add_font(FontArc::try_from_slice(FONT_BYTES).unwrap());
165172
let fps_counter = FPSCounter::default();
166-
let (fps_text, fps_background) = AppUi::make_fps_widget(&fps_counter, &ui);
173+
let (fps_text, fps_background) = AppUi::make_fps_widget(&fps_counter, &mut ui);
167174

168175
Self {
169176
ui: AppUi {
@@ -199,7 +206,7 @@ impl App {
199206
self.ui.tick();
200207
}
201208

202-
pub fn render(&self, ctx: &Context) {
209+
pub fn render(&mut self, ctx: &Context) {
203210
log::info!("render");
204211
let frame = ctx.get_next_frame().unwrap();
205212
self.stage.render(&frame.view());
@@ -223,6 +230,24 @@ impl App {
223230
self.stage.use_ibl(&ibl);
224231
}
225232

233+
fn set_model(&mut self, model: Model) {
234+
match std::mem::replace(&mut self.model, model) {
235+
Model::Gltf(gltf_document) => {
236+
// Remove all the things that was loaded by the document
237+
for prim in gltf_document.primitives.values().flatten() {
238+
self.stage.remove_primitive(prim);
239+
}
240+
for light in gltf_document.lights.iter() {
241+
self.stage.remove_light(light);
242+
}
243+
}
244+
Model::Default(primitive) => {
245+
self.stage.remove_primitive(&primitive);
246+
}
247+
Model::None => {}
248+
}
249+
}
250+
226251
pub fn load_default_model(&mut self) {
227252
log::info!("loading default model");
228253
let mut min = Vec3::splat(f32::INFINITY);
@@ -249,7 +274,8 @@ impl App {
249274
BoundingSphere::from((min, max))
250275
});
251276

252-
self.model = Model::Default(primitive);
277+
self.set_model(Model::Default(primitive));
278+
253279
self.camera_controller.reset(Aabb::new(min, max));
254280
self.camera_controller
255281
.update_camera(self.stage.get_size(), &self.camera);
@@ -260,7 +286,6 @@ impl App {
260286
self.camera_controller
261287
.reset(Aabb::new(Vec3::NEG_ONE, Vec3::ONE));
262288
self.stage.clear_images().unwrap();
263-
self.model = Model::None;
264289
let doc = match self.stage.load_gltf_document_from_bytes(bytes) {
265290
Err(e) => {
266291
log::error!("gltf loading error: {e}");
@@ -365,7 +390,7 @@ impl App {
365390
// dir.clone(); }
366391
// }
367392

368-
self.model = Model::Gltf(Box::new(doc));
393+
self.set_model(Model::Gltf(Box::new(doc)));
369394
}
370395

371396
pub fn tick_loads(&mut self) {

crates/renderling-ui/Cargo.toml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
[package]
2+
name = "renderling-ui"
3+
version = "0.1.0"
4+
edition = "2021"
5+
description = "Lightweight 2D/UI renderer for renderling."
6+
repository = "https://github.com/schell/renderling"
7+
license = "MIT OR Apache-2.0"
8+
9+
[features]
10+
default = ["text", "path"]
11+
text = ["dep:glyph_brush", "dep:image", "dep:loading-bytes"]
12+
path = ["dep:lyon"]
13+
test-utils = ["renderling/test-utils"]
14+
15+
[dependencies]
16+
bytemuck = { workspace = true }
17+
craballoc = { workspace = true }
18+
crabslab = { workspace = true, features = ["default"] }
19+
glam = { workspace = true, features = ["std"] }
20+
glyph_brush = { workspace = true, optional = true }
21+
image = { workspace = true, optional = true }
22+
loading-bytes = { workspace = true, optional = true }
23+
log = { workspace = true }
24+
lyon = { workspace = true, optional = true }
25+
renderling = { path = "../renderling", default-features = false }
26+
rustc-hash = { workspace = true }
27+
snafu = { workspace = true }
28+
wgpu = { workspace = true, features = ["spirv"] }
29+
30+
[dev-dependencies]
31+
env_logger = { workspace = true }
32+
futures-lite = { workspace = true }
33+
img-diff = { path = "../img-diff" }
34+
image = { workspace = true }
35+
renderling = { path = "../renderling", features = ["test-utils"] }
36+
renderling_build = { path = "../renderling-build" }
37+
38+
[lints]
39+
workspace = true

crates/renderling-ui/src/lib.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
//! Lightweight 2D/UI renderer for renderling.
2+
//!
3+
//! This crate provides a dedicated 2D rendering pipeline that is separate
4+
//! from renderling's 3D PBR pipeline. It features:
5+
//!
6+
//! - SDF-based shape rendering (rectangles, rounded rectangles, circles,
7+
//! ellipses) with anti-aliased edges
8+
//! - Gradient fills (linear and radial)
9+
//! - Texture/image rendering via the renderling atlas system
10+
//! - Text rendering via `glyph_brush` (behind the `text` feature)
11+
//! - Vector path rendering via `lyon` tessellation (behind the `path` feature)
12+
//! - A lightweight vertex format (32 bytes vs ~160 bytes for 3D)
13+
//! - Minimal GPU bindings (3 vs 13 for 3D)
14+
//!
15+
//! # Quick Start
16+
//!
17+
//! ```ignore
18+
//! use renderling::context::Context;
19+
//! use renderling_ui::UiRenderer;
20+
//!
21+
//! let ctx = futures_lite::future::block_on(Context::headless(800, 600));
22+
//! let mut ui = UiRenderer::new(&ctx);
23+
//!
24+
//! // Add a rounded rectangle
25+
//! let _rect = ui.add_rect()
26+
//! .with_position(glam::Vec2::new(10.0, 10.0))
27+
//! .with_size(glam::Vec2::new(200.0, 100.0))
28+
//! .with_corner_radii(glam::Vec4::splat(8.0))
29+
//! .with_fill_color(glam::Vec4::new(0.2, 0.3, 0.8, 1.0));
30+
//!
31+
//! let frame = ctx.get_next_frame().unwrap();
32+
//! ui.render(&frame.view());
33+
//! frame.present();
34+
//! ```
35+
36+
mod renderer;
37+
#[cfg(test)]
38+
mod test;
39+
40+
// Re-export key types from renderling that users will need.
41+
pub use renderling::{
42+
atlas::{AtlasImage, AtlasTexture},
43+
context::Context,
44+
glam,
45+
ui_slab::{
46+
GradientDescriptor, GradientType, UiDrawCallDescriptor, UiElementType, UiVertex, UiViewport,
47+
},
48+
};
49+
50+
// Re-export our own types.
51+
pub use renderer::{UiCircle, UiEllipse, UiImage, UiRect, UiRenderer};
52+
53+
// Re-export text types (behind "text" feature).
54+
#[cfg(feature = "text")]
55+
pub use renderer::{FontArc, FontId, Section, Text, UiText};
56+
57+
// Re-export path types (behind "path" feature).
58+
#[cfg(feature = "path")]
59+
pub use renderer::{StrokeConfig, UiPath, UiPathBuilder};

0 commit comments

Comments
 (0)