diff --git a/.config/nextest.toml b/.config/nextest.toml index 6de4a858..91b38370 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -1,2 +1,3 @@ [profile.default] test-threads = 6 +slow-timeout = { period = "60s", terminate-after = 2 } diff --git a/CHANGELOG.md b/CHANGELOG.md index fe9da6c1..a7d83cee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,33 @@ that do not use them. ### Added +- Added retained GPU canvases for editor, visualization, game/map and scientific + viewports. `Element::gpu_canvas`, `GpuCanvasSpec` and the renderer-neutral + `CustomPaintContext::gpu_canvas` helper preserve normal Argui layout, + transforms, clipping, rounded corners, opacity, effects, overlays, + interaction, accessibility, multi-window surfaces and native popups. +- Added `GpuCanvasRegistration`, `GpuCanvasRegistry`, `GpuCanvasFactory`, + `GpuCanvasRenderer` and private-field device/render contexts, plus the exact + `argui::render::wgpu` re-export. Callbacks can create resources from Argui's + selected device, perform queue writes, and encode compute/render/copy work + into a borrowed offscreen target. Argui remains the owner of backend + selection, surface acquisition, command submission and presentation. +- Added explicit content-revision and resize/DPI caching with a configurable, + bounded per-surface texture budget. Paused or unchanged canvases avoid custom + GPU callbacks. Required/optional WGPU features and direction-aware limits are + negotiated before device creation, including every Windows fallback attempt; + incompatible shared devices fail clearly. Recoverable frame errors use a + visible placeholder and deduplicated failure/recovery runtime events instead + of aborting surrounding UI. Profiles, inspector traces and DevTools report + canvas cache, render, hit, failure, byte and CPU-encode statistics. +- Added native and WebAssembly/WebGPU support plus the product-shaped + **GPU Canvas Lab** in `app_examples/gpu-canvas`. It demonstrates an Argui + toolbar and inspector around custom WGPU compute and render passes, pan/zoom, + pause/resume, keyboard alternatives, overlays, an effect layer, resize/HiDPI, + bounded particles and in-app simulated failure recovery. +- Published a release-built WebAssembly version of the GPU Canvas Lab on the + website's App Examples page, with build/copy validation and a responsive + compact layout for narrow viewports. - Added inherited text-selection highlight styling with solid or gradient fills, per-corner radii and an interactive Widget Gallery page. - Added default-on Windows renderer fallback from DirectX 12 DirectComposition @@ -21,6 +48,19 @@ that do not use them. ### Fixed +- Cleared workspace crate artifacts and the temporary crates.io registry before + archive checks, preventing same-version caches from masking coordinated + workspace changes and breaking dependent archive verification. +- Used an operating-system lock for coverage runs so a cached lock file from a + cancelled CI job cannot block the next quality check. +- Limited individual Nextest cases to two minutes so a stalled native test is + reported by name instead of consuming the full CI job timeout. +- Stopped hidden or minimized GTK windows from keeping the event loop in a + permanent redraw poll, and queued synthetic lifecycle input through its GDK + window instead of re-entering the renderer from a GTK signal callback. +- Kept embedded GPU canvases renderable beneath the website loading overlay so + Chromium can initialize WebGPU and emit its ready signal instead of stalling + a hidden iframe. - Prevented Web canvases from taking focus and moving an embedding page while they load; full-page apps can opt in through `WindowConfig::focus_on_launch`. - Kept Winit's AppKit content view attached when enabling the macOS desktop @@ -34,9 +74,25 @@ that do not use them. ### Changed +- Made vertical canvas dragging follow the content by default, added an + in-app natural/inverted direction toggle, aligned keyboard panning with the + selected direction and smoothed wheel/pinch zoom interaction. +- Advanced strict DevTools GPU-trace JSON to `argui-gpu-trace-v4` so exported + frames include GPU-canvas cache, byte, render, hit, failure and CPU-encode + metrics; older strict trace versions remain rejected on import. - Made the base release gallery the recommended local command. Optional native integrations can still be enabled individually or together when needed. +### Known limitations + +- GPU canvases use Argui-owned WGPU instances, devices, queues and surfaces; + the high-level runtime still does not accept externally owned GPU objects. +- Canvas pixels have no automatic semantic meaning. Applications must provide + labels, keyboard controls and semantic Argui overlays for important actions. +- Canvas profiling measures cache behavior and CPU encoding time, not the + duration of application-authored GPU passes; applications may encode their + own supported timestamp queries when needed. + ## [0.2.1] - 2026-09-14 ### Fixed diff --git a/README.md b/README.md index 735748b9..14102d99 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ WebAssembly and the opt-in Android/iOS shells. - **Responsive by design.** Flex and grid layout, light and dark themes, animation and virtualized lists. - **Accessible controls.** Keyboard navigation, focus, text editing, native AccessKit integration and browser semantics. - **GPU effects.** Custom WGSL shaders, gradients, shadows, blur and liquid glass. +- **Retained GPU canvases.** Embed bounded application WGPU compute/render viewports while Argui preserves layout, clipping, effects, input and presentation. - **Native integrations.** Multiple windows, file pickers, trays, WebViews and popovers that can extend beyond the window on supported backends. - **Optional updates.** A signed update engine with a separate, reusable progress dialog. - **Inspect as you build.** Element inspection, live styles, theme editing and profiling through optional DevTools. @@ -80,6 +81,7 @@ for workloads, memory figures and reproduction commands. - [x] Text shaping, editing, selection, bidirectional text and IME input - [x] Mouse, touch, keyboard, focus and accessible semantics - [x] Light/dark themes, animation, images, SVG and custom WGSL effects +- [x] Retained custom WGPU canvases with native and WebAssembly/WebGPU support - [x] Fluent localization through the optional `i18n` feature - [x] State-preserving Subsecond patches through the optional `hot-reload` feature - [x] Optional DevTools, file picker, updater, WebView, tray, native popovers and desktop backdrop diff --git a/app_examples/Cargo.lock b/app_examples/Cargo.lock index a1741403..ee55fb00 100644 --- a/app_examples/Cargo.lock +++ b/app_examples/Cargo.lock @@ -261,6 +261,14 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "argui-example-gpu-canvas" +version = "0.1.0" +dependencies = [ + "argui", + "wasm-bindgen", +] + [[package]] name = "argui-i18n" version = "0.2.1" diff --git a/app_examples/Cargo.toml b/app_examples/Cargo.toml index 0536d1f5..ab5278a6 100644 --- a/app_examples/Cargo.toml +++ b/app_examples/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["docs-examples", "fake-ai-harness"] +members = ["docs-examples", "fake-ai-harness", "gpu-canvas"] resolver = "3" [workspace.package] diff --git a/app_examples/README.md b/app_examples/README.md index eaba39ef..5ac04ce1 100644 --- a/app_examples/README.md +++ b/app_examples/README.md @@ -8,9 +8,13 @@ packages and dependency graphs do not include them. | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | [Fake AI Harness](fake-ai-harness/) | A bundled adaptation of Wikipedia's Large language model article streams 6,000 simulated tokens at 1,000 tokens/s through a responsive VList. | | [Documentation examples](docs-examples/) | Seventeen exact-source applications compiled into the interactive learning site. Each guide displays the Rust module it actually runs. | +| [GPU Canvas Lab](gpu-canvas/) | A retained WGPU compute/render viewport with Argui controls, pan/zoom, pause, overlays, effects, diagnostics, native and WebAssembly builds. | Run an application from the repository root: ```sh cargo run --manifest-path app_examples/Cargo.toml -p argui-example-ai-harness ``` + +GPU Canvas Lab has its own native and browser commands in its +[README](gpu-canvas/README.md). diff --git a/app_examples/gpu-canvas/Cargo.toml b/app_examples/gpu-canvas/Cargo.toml new file mode 100644 index 00000000..1beac6bc --- /dev/null +++ b/app_examples/gpu-canvas/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "argui-example-gpu-canvas" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish = false +description = "A retained custom WGPU canvas inside an accessible Argui application shell" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +argui = { workspace = true, features = ["widget-button"] } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen.workspace = true + +[lints] +workspace = true diff --git a/app_examples/gpu-canvas/README.md b/app_examples/gpu-canvas/README.md new file mode 100644 index 00000000..749df407 --- /dev/null +++ b/app_examples/gpu-canvas/README.md @@ -0,0 +1,53 @@ +# GPU Canvas Lab + +GPU Canvas Lab is a complete Argui application shell around one retained custom +WGPU viewport. The toolbar, inspector, focus handling, gestures, keyboard +commands, rounded clipping, effect layer, and status overlay are ordinary Argui +UI. The canvas callback uses Argui's exact WGPU re-export to run a bounded +256-particle compute pass and then a grid/particle render pass into the +Argui-owned offscreen target. + +The example exercises the public API boundary directly. Its factory receives a +`GpuCanvasDeviceContext` and uses the exposed `wgpu::Device`, target format, +limits, and device generation to allocate buffers and pipelines. Every render +callback receives a `GpuCanvasRenderContext`, writes through its queue, and +encodes compute and render passes through its command encoder and target view. +The application deliberately never submits or presents; Argui owns those steps. + +The model and renderer share a short-lived `RwLock` snapshot. Each pan, zoom, +or reset updates a camera destination, then animation ticks smoothly interpolate +the visible view and advance an explicit content revision. The callback copies +the snapshot, releases the lock, writes its uniform buffer, and encodes work. +While paused and with the camera settled, the model requests no animation +frames, the revision stays fixed, and unrelated Argui redraws reuse the retained +texture without invoking the callback. + +Run the native release build from the repository root: + +```sh +cargo run --release --manifest-path app_examples/Cargo.toml \ + -p argui-example-gpu-canvas +``` + +Check the WebAssembly target: + +```sh +cargo check --manifest-path app_examples/Cargo.toml \ + -p argui-example-gpu-canvas --target wasm32-unknown-unknown +``` + +Build the browser package with the repository-pinned `wasm-pack` 0.15.0, copy +the generated package beside the supplied page, and serve it over HTTP: + +```sh +wasm-pack build app_examples/gpu-canvas --target web --release \ + --out-dir web/pkg +python3 -m http.server --directory app_examples/gpu-canvas/web 8000 +``` + +Open `http://localhost:8000` in a WebGPU-capable browser. Drag or use arrow keys +to pan; use the wheel, pinch, or `+`/`-` to zoom; press `0` to reset and Space to +pause/resume. Vertical dragging follows the pointer by default; “Drag Y” switches +between natural and inverted modes. “Test recovery” exercises the visible +placeholder and runtime diagnostic path; “Recover canvas” advances the revision +and retries. diff --git a/app_examples/gpu-canvas/src/app.rs b/app_examples/gpu-canvas/src/app.rs new file mode 100644 index 00000000..bb5d6f57 --- /dev/null +++ b/app_examples/gpu-canvas/src/app.rs @@ -0,0 +1,562 @@ +use std::sync::Arc; + +use argui::{ + animation::Frame, + core::{Color, Key, KeyState, ScrollDelta}, + paint::{Border, CornerRadii, Filter, LayerMask, LayerStyle}, + runtime::{Context, LayoutSnapshot, Render}, + text::{TextColor, TextStyle, TextWrap}, + ui::{ + Axes, CursorIcon, Element, EventType, FocusPolicy, GestureCapture, GestureDelivery, + GestureKind, GesturePhase, GestureSet, GpuCanvasSpec, HitTestStyle, Interaction, Overflow, + PanGesture, PinchGesture, PointerEvents, Position, Role, Semantics, Sides, UiEvent, + UiEventKind, auto, length, percent, + }, + widgets::{Button, WidgetTheme, shadcn}, +}; + +use crate::state::SharedLab; + +/// Argui application shell surrounding the retained GPU canvas. +pub(crate) struct GpuCanvasLab { + canvas: argui::paint::GpuCanvasId, + shared: Arc, + compact: bool, + layout_pending: bool, +} + +impl GpuCanvasLab { + /// Creates the UI model for `canvas` and its shared scene state. + pub(crate) fn new(canvas: argui::paint::GpuCanvasId, shared: Arc) -> Self { + Self { + canvas, + shared, + compact: true, + layout_pending: false, + } + } + + /// Applies one toolbar action selected by its stable element key. + fn click(&mut self, event: &UiEvent, cx: &mut Context) { + match event.target_key() { + Some("pause") => self.shared.update(|state| { + state.toggle_paused(); + }), + Some("reset") => self.shared.update(crate::state::LabState::reset_view), + Some("zoom-in") => self.shared.update(|state| state.zoom_by(1.2)), + Some("zoom-out") => self.shared.update(|state| state.zoom_by(1.0 / 1.2)), + Some("error") => self.shared.update(|state| { + state.toggle_error(); + }), + Some("drag-y") => self.shared.update(|state| { + state.toggle_vertical_drag(); + }), + _ => return, + } + cx.notify(); + } + + /// Applies pointer pan and pinch gestures emitted by the canvas leaf. + fn gesture(&mut self, event: &UiEvent, cx: &mut Context) { + let UiEventKind::Gesture(gesture) = event.kind else { + return; + }; + if gesture.phase != GesturePhase::Changed { + return; + } + match gesture.kind { + GestureKind::Pan { delta, .. } => { + self.shared.update(|state| { + let y = if state.natural_vertical_drag() { + -delta.y + } else { + delta.y + }; + state.pan_by(delta.x, y); + }); + } + GestureKind::Pinch { scale } => { + self.shared.update(|state| state.zoom_by(scale)); + } + GestureKind::Tap { .. } | GestureKind::Rotation { .. } => return, + } + cx.notify(); + } + + /// Converts wheel movement over the canvas into bounded zoom changes. + fn wheel(&mut self, event: &UiEvent, cx: &mut Context) { + let UiEventKind::Wheel { delta, .. } = event.kind else { + return; + }; + let notches = match delta { + ScrollDelta::Lines(point) => point.y, + ScrollDelta::Pixels(point) => point.y / 100.0, + }; + let zoom = (notches * 0.18).clamp(-0.6, 0.6).exp(); + self.shared.update(|state| state.zoom_by(zoom)); + let _ = event.prevent_default(); + cx.notify(); + } + + /// Provides keyboard alternatives for every canvas navigation action. + fn key(&mut self, event: &UiEvent, cx: &mut Context) { + let UiEventKind::KeyInput(input) = &event.kind else { + return; + }; + if input.state != KeyState::Pressed { + return; + } + match &input.key { + Key::ArrowLeft => self.shared.update(|state| state.pan_by(-16.0, 0.0)), + Key::ArrowRight => self.shared.update(|state| state.pan_by(16.0, 0.0)), + Key::ArrowUp => self.shared.update(|state| state.pan_by(0.0, 16.0)), + Key::ArrowDown => self.shared.update(|state| state.pan_by(0.0, -16.0)), + Key::Character(value) if value == "+" || value == "=" => { + self.shared.update(|state| state.zoom_by(1.2)); + } + Key::Character(value) if value == "-" => { + self.shared.update(|state| state.zoom_by(1.0 / 1.2)); + } + Key::Character(value) if value == "0" => { + self.shared.update(crate::state::LabState::reset_view); + } + Key::Character(value) if value == " " => self.shared.update(|state| { + state.toggle_paused(); + }), + _ => return, + } + let _ = event.prevent_default(); + cx.notify(); + } + + /// Builds the top toolbar with mouse, touch and keyboard-accessible actions. + fn toolbar(&self, theme: &WidgetTheme) -> Element { + let state = self.shared.state(); + let button = |key, label| Button::new(key, label, theme.outline_button()).build(); + let direction = if state.natural_vertical_drag() { + "Drag Y: natural" + } else { + "Drag Y: inverted" + }; + let heading = if self.compact { + Element::row([ + text("GPU Canvas Lab", 20.0, theme.foreground, 750).grow(1.0), + badge("GPU API", Color::srgb(0.2, 0.78, 1.0), theme), + ]) + .width(percent(1.0)) + .gap(8.0) + } else { + Element::row([ + Element::column([ + text("GPU Canvas Lab", 24.0, theme.foreground, 750), + text( + "App-owned WGPU passes inside an Argui-managed frame", + 12.0, + theme.muted_foreground, + 500, + ), + ]) + .gap(1.0) + .grow(1.0), + badge("NEW GPU CANVAS API", Color::srgb(0.2, 0.78, 1.0), theme), + ]) + .width(percent(1.0)) + .gap(12.0) + }; + let controls = if self.compact { + Element::column([ + control_bar( + Element::row([ + button("zoom-out", "Zoom −"), + button("zoom-in", "Zoom +"), + button("reset", "Reset"), + ]) + .width(percent(1.0)) + .gap(6.0), + theme, + ), + control_bar( + Element::row([ + button("drag-y", direction), + button("pause", if state.paused() { "Resume" } else { "Pause" }), + button( + "error", + if state.force_error() { + "Recover canvas" + } else { + "Test recovery" + }, + ), + ]) + .width(percent(1.0)) + .gap(6.0), + theme, + ), + ]) + .gap(6.0) + } else { + control_bar( + Element::row([ + text("Explore the scene", 11.0, theme.muted_foreground, 650).grow(1.0), + button("zoom-out", "Zoom −"), + button("zoom-in", "Zoom +"), + button("reset", "Reset view"), + button("drag-y", direction), + button( + "pause", + if state.paused() { + "Resume animation" + } else { + "Pause animation" + }, + ), + button( + "error", + if state.force_error() { + "Recover canvas" + } else { + "Test recovery" + }, + ), + ]) + .width(percent(1.0)) + .gap(7.0), + theme, + ) + }; + Element::column([heading, controls]) + .width(percent(1.0)) + .gap(if self.compact { 7.0 } else { 10.0 }) + } + + /// Builds the clipped canvas viewport and a normal Argui overlay above it. + fn canvas_panel(&self, theme: &WidgetTheme, cx: &mut Context) -> Element { + let state = self.shared.state(); + let minimum_height = if self.compact { 240.0 } else { 320.0 }; + let gestures = GestureSet::EMPTY + .pan( + PanGesture::default() + .immediate() + .capture(GestureCapture::OnPress) + .delivery(GestureDelivery::FrameCoalesced), + ) + .pinch(PinchGesture::default().delivery(GestureDelivery::FrameCoalesced)); + let canvas = Element::gpu_canvas( + GpuCanvasSpec::new(self.canvas) + .content_revision(state.revision()) + .resolution_scale(1.0), + ) + .keyed("lab-canvas") + .width(percent(1.0)) + .height(percent(1.0)) + .min_height(length(minimum_height)) + .radius(CornerRadii::all(14.0)) + .interaction( + Interaction::default() + .focus_policy(FocusPolicy::TabStop) + .cursor(CursorIcon::Grab) + .gestures(gestures), + ) + .semantics( + Semantics::new(Role::Image) + .label("Interactive GPU particle canvas") + .description( + "Drag or use arrow keys to pan. Drag Y switches vertical direction. Wheel, pinch, plus and minus change zoom. Space pauses animation and zero resets the view.", + ), + ) + .on(cx.listener(EventType::Gesture, Self::gesture)) + .on(cx.listener(EventType::Wheel, Self::wheel)) + .on(cx.listener(EventType::Key, Self::key)) + .layer( + LayerStyle::new(Default::default()) + .filter(Filter::Brightness(1.02)) + .mask(LayerMask::Rounded(CornerRadii::all(14.0))), + ); + let overlay = Element::column([ + text( + if state.paused() { + "PAUSED · CAMERA STILL INTERACTIVE" + } else { + "LIVE · COMPUTE + RENDER" + }, + 11.0, + Color::WHITE, + 800, + ), + text( + format!( + "zoom {:.2}× · pan {:.0}, {:.0}", + state.zoom_factor(), + state.pan_offset()[0], + state.pan_offset()[1] + ), + 12.0, + Color::WHITE, + 600, + ), + ]) + .gap(2.0) + .padding(Sides::length(10.0)) + .background(Color::BLACK.with_alpha(0.58)) + .border(Border::all(1.0, Color::WHITE.with_alpha(0.18))) + .radius(CornerRadii::all(9.0)) + .position(Position::Absolute) + .absolute(Sides { + left: length(14.0), + top: length(14.0), + right: auto(), + bottom: auto(), + }) + .z_index(2) + .hit_test(HitTestStyle::default().pointer_events(PointerEvents::None)) + .semantic_hidden(true); + let help = text( + if self.compact { + "Drag to pan · Wheel or pinch to zoom" + } else { + "Drag to pan · Scroll to zoom · Pinch on touch · Select canvas for keyboard" + }, + 11.0, + Color::WHITE, + 600, + ) + .padding(Sides::length(9.0)) + .background(Color::BLACK.with_alpha(0.58)) + .border(Border::all(1.0, Color::WHITE.with_alpha(0.18))) + .radius(CornerRadii::all(9.0)) + .position(Position::Absolute) + .absolute(Sides { + left: length(14.0), + top: auto(), + right: auto(), + bottom: length(14.0), + }) + .z_index(2) + .hit_test(HitTestStyle::default().pointer_events(PointerEvents::None)) + .semantic_hidden(true); + Element::container([canvas, overlay, help]) + .position(Position::Relative) + .width(percent(1.0)) + .height(percent(1.0)) + .min_width(length(0.0)) + .min_height(length(minimum_height)) + .grow(1.0) + .overflow(Axes { + x: Overflow::Hidden, + y: Overflow::Hidden, + }) + .background(theme.card) + .radius(CornerRadii::all(14.0)) + } + + /// Builds the Argui inspector from retained scene and renderer event state. + fn inspector(&self, theme: &WidgetTheme) -> Element { + let state = self.shared.state(); + let (capability, diagnostic, callbacks) = self.shared.status(); + panel( + "Live API inspector", + Element::column([ + metric("Revision", state.revision().to_string(), theme), + metric("Renderer calls", callbacks.to_string(), theme), + metric("Zoom", format!("{:.2}×", state.zoom_factor()), theme), + metric( + "Pan", + format!("{:.0}, {:.0}", state.pan_offset()[0], state.pan_offset()[1]), + theme, + ), + metric( + "Drag Y", + if state.natural_vertical_drag() { + "Natural".into() + } else { + "Inverted".into() + }, + theme, + ), + divider(theme), + text("WHAT THIS PROVES", 10.0, theme.muted_foreground, 750), + proof( + "1", + "Device context", + "The factory receives Device, limits and target format.", + theme, + ), + proof( + "2", + "App GPU work", + "The callback writes the Queue and encodes compute + render passes.", + theme, + ), + proof( + "3", + "Argui lifecycle", + "Argui owns texture retention, submission and presentation.", + theme, + ), + divider(theme), + text("NEGOTIATED CONTEXT", 10.0, theme.muted_foreground, 750), + text(capability, 11.0, theme.foreground, 450), + divider(theme), + text("LAST GPU CANVAS EVENT", 10.0, theme.muted_foreground, 700), + text( + diagnostic, + 11.0, + if state.force_error() { + Color::srgb(1.0, 0.45, 0.45) + } else { + theme.foreground + }, + 500, + ), + ]) + .gap(9.0), + theme, + ) + .width(length(280.0)) + .shrink(0.0) + } +} + +impl Render for GpuCanvasLab { + /// Renders the complete toolbar, canvas, overlay and inspector shell. + fn render(&mut self, cx: &mut Context) -> Element { + let environment = cx.environment(); + let themes = shadcn(&environment); + let theme = themes.resolve(environment.color_scheme); + let body = if self.compact { + self.canvas_panel(theme, cx) + } else { + Element::row([ + self.canvas_panel(theme, cx) + .grow(1.0) + .min_width(length(0.0)), + self.inspector(theme), + ]) + .width(percent(1.0)) + .height(percent(1.0)) + .min_height(length(0.0)) + .gap(14.0) + }; + Element::column([self.toolbar(theme), body]) + .width(percent(1.0)) + .height(percent(1.0)) + .min_width(length(0.0)) + .min_height(length(0.0)) + .padding(Sides::length(if self.compact { 10.0 } else { 18.0 })) + .gap(if self.compact { 8.0 } else { 14.0 }) + .background(theme.background) + .on(cx.listener(EventType::Click, Self::click)) + } + + /// Returns whether the running scene needs another animation frame. + fn wants_animation_frame(&self) -> bool { + let state = self.shared.state(); + self.layout_pending || !state.paused() || state.view_is_settling() + } + + /// Advances the scene from `frame` timing and notifies `cx` when it changed. + fn animation_frame(&mut self, frame: Frame, cx: &mut Context) { + let seconds = frame.elapsed.as_secs_f64() as f32; + let mut changed = self.layout_pending; + self.layout_pending = false; + self.shared + .update(|state| changed |= state.advance(seconds)); + if changed { + cx.notify(); + } + } + + /// Switches to the narrow embedded layout when the viewport becomes compact. + fn layout_changed(&mut self, layout: &LayoutSnapshot, _cx: &mut Context) { + let compact = layout.viewport_size().width < 760.0; + if self.compact != compact { + self.compact = compact; + self.layout_pending = true; + } + } +} + +/// Styles one row of related canvas controls. +fn control_bar(controls: Element, theme: &WidgetTheme) -> Element { + controls + .padding(Sides::length(8.0)) + .background(theme.card) + .border(Border::all(1.0, theme.border)) + .radius(CornerRadii::all(10.0)) +} + +/// Builds a compact accent badge for the active GPU-canvas API. +fn badge(label: &str, accent: Color, theme: &WidgetTheme) -> Element { + Element::row([ + Element::container([]) + .width(length(7.0)) + .height(length(7.0)) + .background(accent) + .radius(CornerRadii::all(4.0)), + text(label, 10.0, theme.foreground, 750), + ]) + .padding(Sides::length(8.0)) + .gap(7.0) + .background(theme.card) + .border(Border::all(1.0, accent.with_alpha(0.34))) + .radius(CornerRadii::all(9.0)) +} + +/// Builds one numbered API-boundary explanation for the live inspector. +fn proof(number: &str, title: &str, description: &str, theme: &WidgetTheme) -> Element { + Element::row([ + text(number, 10.0, Color::srgb(0.32, 0.82, 1.0), 800) + .padding(Sides::length(5.0)) + .background(Color::srgb(0.08, 0.3, 0.4)) + .radius(CornerRadii::all(6.0)), + Element::column([ + text(title, 11.0, theme.foreground, 700), + text(description, 10.0, theme.muted_foreground, 450), + ]) + .gap(1.0) + .min_width(length(0.0)) + .grow(1.0), + ]) + .width(percent(1.0)) + .min_width(length(0.0)) + .gap(8.0) +} + +/// Builds a bordered inspector panel with a visible heading. +fn panel(title: &str, content: Element, theme: &WidgetTheme) -> Element { + Element::column([text(title, 15.0, theme.foreground, 700), content]) + .padding(Sides::length(14.0)) + .gap(12.0) + .background(theme.card) + .border(Border::all(1.0, theme.border)) + .radius(CornerRadii::all(12.0)) +} + +/// Builds one compact name/value inspector row. +fn metric(label: &str, value: String, theme: &WidgetTheme) -> Element { + Element::row([ + text(label, 11.0, theme.muted_foreground, 500).grow(1.0), + text(value, 11.0, theme.foreground, 650), + ]) + .gap(8.0) +} + +/// Builds a one-pixel separator using the active widget theme. +fn divider(theme: &WidgetTheme) -> Element { + Element::container([]) + .width(percent(1.0)) + .height(length(1.0)) + .background(theme.border) +} + +/// Builds consistently wrapped application text. +fn text(value: impl Into, size: f32, color: TextColor, weight: u16) -> Element { + Element::text(value.into()).text_style(TextStyle { + font_size: size, + line_height: size * 1.35, + color, + weight, + wrap: TextWrap::Word, + ..TextStyle::default() + }) +} diff --git a/app_examples/gpu-canvas/src/gpu.rs b/app_examples/gpu-canvas/src/gpu.rs new file mode 100644 index 00000000..4772c84f --- /dev/null +++ b/app_examples/gpu-canvas/src/gpu.rs @@ -0,0 +1,373 @@ +use std::sync::Arc; + +use argui::render::{ + GpuCanvasDeviceContext, GpuCanvasError, GpuCanvasFactory, GpuCanvasRenderContext, + GpuCanvasRenderer, wgpu, +}; + +use crate::state::SharedLab; + +const PARTICLES: u32 = 256; +const COMPUTE_SHADER: &str = r#" +struct Params { scene: vec4, viewport: vec4 }; +@group(0) @binding(0) var params: Params; +@group(0) @binding(1) var particles: array, 256>; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) invocation: vec3) { + let index = invocation.x; + if index >= 256u { return; } + let phase = f32(index) / 256.0; + let ring = f32(index % 23u) / 23.0; + let angle = phase * 6.2831853 + params.scene.w * (0.16 + ring * 0.32); + let radius = 0.12 + ring * 0.72; + particles[index] = vec4(cos(angle) * radius, sin(angle) * radius, phase, 1.0); +} +"#; +const RENDER_SHADER: &str = r#" +struct Params { scene: vec4, viewport: vec4 }; +@group(0) @binding(0) var params: Params; +@group(0) @binding(1) var particles: array, 256>; + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) uv: vec2, + @location(1) color: vec3, +}; + +@vertex +fn grid_vertex(@builtin(vertex_index) index: u32) -> VertexOutput { + let positions = array, 3>( + vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0)); + let position = positions[index]; + var output: VertexOutput; + output.position = vec4(position, 0.0, 1.0); + output.uv = position * 0.5 + 0.5; + output.color = vec3(0.0); + return output; +} + +@fragment +fn grid_fragment(input: VertexOutput) -> @location(0) vec4 { + let aspect = params.viewport.x / max(params.viewport.y, 1.0); + let pan = params.scene.xy / max(params.viewport.xy, vec2(1.0)); + let world = (input.uv - 0.5 - pan) * vec2(aspect, 1.0) / params.scene.z; + let cell = abs(fract(world * 18.0 + 0.5) - 0.5) / fwidth(world * 18.0); + let line = 1.0 - min(min(cell.x, cell.y), 1.0); + let base = vec3(0.025, 0.04, 0.075); + return vec4(base + line * vec3(0.045, 0.12, 0.18), 1.0); +} + +@vertex +fn particle_vertex( + @builtin(vertex_index) vertex: u32, + @builtin(instance_index) instance: u32, +) -> VertexOutput { + let corners = array, 6>( + vec2(-1.0, -1.0), vec2(1.0, -1.0), vec2(1.0, 1.0), + vec2(-1.0, -1.0), vec2(1.0, 1.0), vec2(-1.0, 1.0)); + let particle = particles[instance]; + let pan = params.scene.xy / max(params.viewport.xy, vec2(1.0)) * 2.0; + let center = particle.xy * params.scene.z + pan; + let size = vec2(5.0) / max(params.viewport.xy, vec2(1.0)) * 2.0; + var output: VertexOutput; + output.position = vec4(center + corners[vertex] * size, 0.0, 1.0); + output.uv = corners[vertex]; + output.color = vec3(0.25 + particle.z * 0.55, 0.72, 1.0 - particle.z * 0.35); + return output; +} + +@fragment +fn particle_fragment(input: VertexOutput) -> @location(0) vec4 { + let distance = length(input.uv); + let alpha = 1.0 - smoothstep(0.55, 1.0, distance); + return vec4(input.color, alpha * 0.92); +} +"#; + +/// Creates the example's per-surface compute and render pipelines. +pub(crate) struct LabCanvasFactory { + shared: Arc, +} + +impl LabCanvasFactory { + /// Creates a factory that reads short-lived scene snapshots from `shared`. + pub(crate) fn new(shared: Arc) -> Self { + Self { shared } + } +} + +impl GpuCanvasFactory for LabCanvasFactory { + /// Creates one surface-local renderer from Argui's selected device context. + fn create( + &self, + context: &GpuCanvasDeviceContext<'_>, + ) -> Result, GpuCanvasError> { + self.shared.set_capability(format!( + "WebGPU baseline · {:?} · device generation {} · max texture {}", + context.target_format(), + context.device_generation(), + context.limits().max_texture_dimension_2d + )); + let renderer = LabCanvasRenderer::new(context, Arc::clone(&self.shared)); + #[cfg(target_arch = "wasm32")] + crate::renderer_state("ready"); + Ok(Box::new(renderer)) + } +} + +struct LabCanvasRenderer { + shared: Arc, + uniform: wgpu::Buffer, + _particles: wgpu::Buffer, + compute_group: wgpu::BindGroup, + render_group: wgpu::BindGroup, + compute: wgpu::ComputePipeline, + grid: wgpu::RenderPipeline, + particles: wgpu::RenderPipeline, +} + +impl LabCanvasRenderer { + /// Allocates baseline WebGPU resources for one Argui surface renderer. + fn new(context: &GpuCanvasDeviceContext<'_>, shared: Arc) -> Self { + let device = context.device(); + let uniform = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("gpu-canvas-lab-uniform"), + size: 32, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let particles = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("gpu-canvas-lab-particles"), + size: u64::from(PARTICLES) * 16, + usage: wgpu::BufferUsages::STORAGE, + mapped_at_creation: false, + }); + let compute_layout = bind_group_layout(device, false); + let render_layout = bind_group_layout(device, true); + let compute_group = bind_group(device, &compute_layout, &uniform, &particles, "compute"); + let render_group = bind_group(device, &render_layout, &uniform, &particles, "render"); + let compute_module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("gpu-canvas-lab-compute-shader"), + source: wgpu::ShaderSource::Wgsl(COMPUTE_SHADER.into()), + }); + let compute_pipeline_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("gpu-canvas-lab-compute-pipeline-layout"), + bind_group_layouts: &[Some(&compute_layout)], + immediate_size: 0, + }); + let compute = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some("gpu-canvas-lab-compute-pipeline"), + layout: Some(&compute_pipeline_layout), + module: &compute_module, + entry_point: Some("main"), + compilation_options: Default::default(), + cache: None, + }); + let render_module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("gpu-canvas-lab-render-shader"), + source: wgpu::ShaderSource::Wgsl(RENDER_SHADER.into()), + }); + let render_pipeline_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("gpu-canvas-lab-render-pipeline-layout"), + bind_group_layouts: &[Some(&render_layout)], + immediate_size: 0, + }); + let grid = render_pipeline( + device, + &render_module, + &render_pipeline_layout, + context.target_format(), + "grid", + "grid_vertex", + "grid_fragment", + None, + ); + let particles_pipeline = render_pipeline( + device, + &render_module, + &render_pipeline_layout, + context.target_format(), + "particles", + "particle_vertex", + "particle_fragment", + Some(wgpu::BlendState::ALPHA_BLENDING), + ); + Self { + shared, + uniform, + _particles: particles, + compute_group, + render_group, + compute, + grid, + particles: particles_pipeline, + } + } +} + +impl GpuCanvasRenderer for LabCanvasRenderer { + /// Encodes the bounded compute and render passes into the supplied context. + fn render(&mut self, context: &mut GpuCanvasRenderContext<'_>) -> Result<(), GpuCanvasError> { + let state = self.shared.state(); + self.shared.record_callback(); + if state.force_error() { + return Err(GpuCanvasError::new( + "simulated failure; choose Recover canvas to retry", + )); + } + let [width, height] = context.physical_extent(); + let words = [ + state.pan_offset()[0], + state.pan_offset()[1], + state.zoom_factor(), + state.elapsed_seconds(), + width as f32, + height as f32, + context.scale_factor(), + context.resolution_scale(), + ]; + context + .queue() + .write_buffer(&self.uniform, 0, &float_bytes(words)); + let (encoder, target) = context.encoder_and_target(); + { + let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { + label: Some("gpu-canvas-lab-compute-pass"), + ..Default::default() + }); + pass.set_pipeline(&self.compute); + pass.set_bind_group(0, &self.compute_group, &[]); + pass.dispatch_workgroups(PARTICLES.div_ceil(64), 1, 1); + } + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("gpu-canvas-lab-render-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: target, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + })], + ..Default::default() + }); + pass.set_bind_group(0, &self.render_group, &[]); + pass.set_pipeline(&self.grid); + pass.draw(0..3, 0..1); + pass.set_pipeline(&self.particles); + pass.draw(0..6, 0..PARTICLES); + Ok(()) + } +} + +/// Creates a two-binding layout for the shared uniform and particle buffers. +fn bind_group_layout(device: &wgpu::Device, read_only: bool) -> wgpu::BindGroupLayout { + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("gpu-canvas-lab-bind-group-layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: if read_only { + wgpu::ShaderStages::VERTEX_FRAGMENT + } else { + wgpu::ShaderStages::COMPUTE + }, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: if read_only { + wgpu::ShaderStages::VERTEX + } else { + wgpu::ShaderStages::COMPUTE + }, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + ], + }) +} + +/// Creates a bind group over the resources shared by compute and render pipelines. +fn bind_group( + device: &wgpu::Device, + layout: &wgpu::BindGroupLayout, + uniform: &wgpu::Buffer, + particles: &wgpu::Buffer, + label: &str, +) -> wgpu::BindGroup { + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some(label), + layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: uniform.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: particles.as_entire_binding(), + }, + ], + }) +} + +/// Creates one fullscreen or instanced render pipeline for the canvas target. +#[allow(clippy::too_many_arguments)] +fn render_pipeline( + device: &wgpu::Device, + module: &wgpu::ShaderModule, + layout: &wgpu::PipelineLayout, + format: wgpu::TextureFormat, + label: &str, + vertex: &str, + fragment: &str, + blend: Option, +) -> wgpu::RenderPipeline { + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(label), + layout: Some(layout), + vertex: wgpu::VertexState { + module, + entry_point: Some(vertex), + compilation_options: Default::default(), + buffers: &[], + }, + primitive: Default::default(), + depth_stencil: None, + multisample: Default::default(), + fragment: Some(wgpu::FragmentState { + module, + entry_point: Some(fragment), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format, + blend, + write_mask: wgpu::ColorWrites::ALL, + })], + }), + multiview_mask: None, + cache: None, + }) +} + +/// Encodes eight `f32` values as native-endian bytes for a WGPU uniform write. +fn float_bytes(values: [f32; N]) -> Vec { + values + .into_iter() + .flat_map(f32::to_ne_bytes) + .collect::>() +} diff --git a/app_examples/gpu-canvas/src/lib.rs b/app_examples/gpu-canvas/src/lib.rs new file mode 100644 index 00000000..3954c92c --- /dev/null +++ b/app_examples/gpu-canvas/src/lib.rs @@ -0,0 +1,128 @@ +//! Native and WebAssembly entry points for the Argui GPU Canvas Lab. + +mod app; +mod gpu; +pub mod state; + +use std::sync::Arc; + +use app::GpuCanvasLab; +use argui::{ + platform::{ApplicationConfig, ApplicationIdentity, WindowConfig}, + render::{GpuCanvasDiagnosticKind, GpuCanvasRegistration, GpuCanvasRegistry, RendererConfig}, + runtime::{ + RuntimeEvent, SingleWindowModel, WindowRuntimeEvent, run_application_with_text_engine, + }, + text::TextEngine, +}; +use gpu::LabCanvasFactory; +use state::SharedLab; + +const NOTO_SANS: &[u8] = + include_bytes!("../../../crates/argui-web-demo/assets/fonts/NotoSans-Regular.ttf"); + +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen::prelude::wasm_bindgen( + inline_js = "export function renderer_state(state) { window.dispatchEvent(new CustomEvent('argui:renderer-state', { detail: { state } })); }" +)] +extern "C" { + fn renderer_state(state: &str); +} + +/// Records renderer and GPU-canvas events for the in-app diagnostic panel. +fn handle_event(shared: &SharedLab, event: RuntimeEvent) { + #[cfg(target_arch = "wasm32")] + if matches!( + &event, + RuntimeEvent::RendererReady + | RuntimeEvent::Window { + event: WindowRuntimeEvent::RendererReady, + .. + } + ) { + renderer_state("ready"); + } + let message = match event { + RuntimeEvent::GpuCanvasFailed(diagnostic) + | RuntimeEvent::Window { + event: WindowRuntimeEvent::GpuCanvasFailed(diagnostic), + .. + } => { + debug_assert_eq!(diagnostic.kind, GpuCanvasDiagnosticKind::Failed); + Some(diagnostic.message) + } + RuntimeEvent::GpuCanvasRecovered(diagnostic) + | RuntimeEvent::Window { + event: WindowRuntimeEvent::GpuCanvasRecovered(diagnostic), + .. + } => { + debug_assert_eq!(diagnostic.kind, GpuCanvasDiagnosticKind::Recovered); + Some(diagnostic.message) + } + RuntimeEvent::RendererFallback(message) => Some(format!("Renderer fallback: {message}")), + RuntimeEvent::RendererFailed(message) + | RuntimeEvent::LayoutFailed(message) + | RuntimeEvent::CommandFailed(message) + | RuntimeEvent::Window { + event: + WindowRuntimeEvent::RendererFailed(message) | WindowRuntimeEvent::LayoutFailed(message), + .. + } => { + #[cfg(target_arch = "wasm32")] + renderer_state("error"); + Some(message) + } + _ => None, + }; + if let Some(message) = message { + eprintln!("{message}"); + shared.set_diagnostic(message); + } +} + +/// Starts the browser build and reports initialization failures to JavaScript. +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() -> Result<(), wasm_bindgen::JsValue> { + std::panic::set_hook(Box::new(|info| { + renderer_state("error"); + eprintln!("{info}"); + })); + launch().map_err(|error| wasm_bindgen::JsValue::from_str(&error.to_string())) +} + +/// Launches the GPU Canvas Lab with one immutable canvas registration. +/// +/// # Errors +/// +/// Returns registry validation or platform/runtime startup failures. +pub fn launch() -> Result<(), Box> { + let shared = Arc::new(SharedLab::default()); + let registration = GpuCanvasRegistration::new( + "example.gpu-canvas-lab", + LabCanvasFactory::new(Arc::clone(&shared)), + ); + let canvas = registration.id(); + let registry = GpuCanvasRegistry::new([registration])?; + let renderer = RendererConfig::default() + .gpu_canvas_cache_bytes(96 * 1024 * 1024) + .gpu_canvases(registry); + let text = TextEngine::from_embedded_fonts([NOTO_SANS], "Noto Sans", "Noto Sans", "Noto Sans"); + let event_state = Arc::clone(&shared); + run_application_with_text_engine( + ApplicationConfig::new( + ApplicationIdentity::development("Argui GPU Canvas Lab"), + WindowConfig { + title: "Argui GPU Canvas Lab".into(), + width: 1280.0, + height: 780.0, + ..WindowConfig::default() + }, + ), + renderer, + text, + SingleWindowModel::new(GpuCanvasLab::new(canvas, shared)), + move |event| handle_event(&event_state, event), + )?; + Ok(()) +} diff --git a/app_examples/gpu-canvas/src/main.rs b/app_examples/gpu-canvas/src/main.rs new file mode 100644 index 00000000..dfaeb340 --- /dev/null +++ b/app_examples/gpu-canvas/src/main.rs @@ -0,0 +1,4 @@ +/// Launches the native GPU Canvas Lab and propagates startup failures. +fn main() -> Result<(), Box> { + argui_example_gpu_canvas::launch() +} diff --git a/app_examples/gpu-canvas/src/state.rs b/app_examples/gpu-canvas/src/state.rs new file mode 100644 index 00000000..422bbfea --- /dev/null +++ b/app_examples/gpu-canvas/src/state.rs @@ -0,0 +1,250 @@ +use std::sync::{ + RwLock, + atomic::{AtomicU64, Ordering}, +}; + +/// Interactive scene state shared by the Argui model and GPU-canvas renderer. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LabState { + pan: [f32; 2], + target_pan: [f32; 2], + zoom: f32, + target_zoom: f32, + elapsed: f32, + revision: u64, + paused: bool, + force_error: bool, + natural_vertical_drag: bool, +} + +impl Default for LabState { + /// Creates a running scene at its neutral pan and zoom. + fn default() -> Self { + Self { + pan: [0.0, 0.0], + target_pan: [0.0, 0.0], + zoom: 1.0, + target_zoom: 1.0, + elapsed: 0.0, + revision: 1, + paused: false, + force_error: false, + natural_vertical_drag: true, + } + } +} + +impl LabState { + /// Returns the scene's logical pan offset. + #[must_use] + pub const fn pan_offset(&self) -> [f32; 2] { + self.pan + } + + /// Returns the clamped scene zoom factor. + #[must_use] + pub const fn zoom_factor(&self) -> f32 { + self.zoom + } + + /// Returns the destination zoom used by the smoothed camera animation. + #[must_use] + pub const fn target_zoom_factor(&self) -> f32 { + self.target_zoom + } + + /// Returns elapsed animation time in seconds. + #[must_use] + pub const fn elapsed_seconds(&self) -> f32 { + self.elapsed + } + + /// Returns the explicit revision supplied to `GpuCanvasSpec`. + #[must_use] + pub const fn revision(&self) -> u64 { + self.revision + } + + /// Returns whether animation is paused. + #[must_use] + pub const fn paused(&self) -> bool { + self.paused + } + + /// Returns whether the demo renderer should emit a recoverable error. + #[must_use] + pub const fn force_error(&self) -> bool { + self.force_error + } + + /// Returns whether vertical pointer drags move the scene with the pointer. + #[must_use] + pub const fn natural_vertical_drag(&self) -> bool { + self.natural_vertical_drag + } + + /// Moves the camera destination by logical-pixel deltas. + pub fn pan_by(&mut self, x: f32, y: f32) { + if x.is_finite() && y.is_finite() { + self.target_pan[0] += x; + self.target_pan[1] += y; + } + } + + /// Multiplies the camera destination zoom by `factor` and clamps it to 0.2–8.0. + pub fn zoom_by(&mut self, factor: f32) { + if factor.is_finite() && factor > 0.0 { + self.target_zoom = (self.target_zoom * factor).clamp(0.2, 8.0); + } + } + + /// Smoothly restores pan and zoom and immediately resets animation time. + pub fn reset_view(&mut self) { + self.target_pan = [0.0, 0.0]; + self.target_zoom = 1.0; + self.elapsed = 0.0; + self.bump(); + } + + /// Toggles pause state and returns the new value. + pub fn toggle_paused(&mut self) -> bool { + self.paused = !self.paused; + self.bump(); + self.paused + } + + /// Toggles the recoverable demo failure and returns the new value. + pub fn toggle_error(&mut self) -> bool { + self.force_error = !self.force_error; + self.bump(); + self.force_error + } + + /// Toggles vertical drag direction and returns whether natural mode is active. + pub fn toggle_vertical_drag(&mut self) -> bool { + self.natural_vertical_drag = !self.natural_vertical_drag; + self.natural_vertical_drag + } + + /// Returns whether the camera is still moving toward a requested view. + #[must_use] + pub fn view_is_settling(&self) -> bool { + self.pan != self.target_pan || self.zoom != self.target_zoom + } + + /// Advances animation and camera smoothing by finite non-negative `seconds`. + /// + /// Camera motion continues while particle animation is paused. Returns whether + /// the visible scene and its revision changed. + pub fn advance(&mut self, seconds: f32) -> bool { + if !seconds.is_finite() || seconds <= 0.0 { + return false; + } + let seconds = seconds.min(0.1); + let blend = 1.0 - (-14.0 * seconds).exp(); + let mut changed = false; + for axis in 0..2 { + changed |= smooth_value(&mut self.pan[axis], self.target_pan[axis], blend, 0.01); + } + changed |= smooth_value(&mut self.zoom, self.target_zoom, blend, 0.0005); + if !self.paused { + self.elapsed += seconds; + changed = true; + } + if changed { + self.bump(); + } + changed + } + + /// Advances the explicit content revision after a visible scene mutation. + fn bump(&mut self) { + self.revision = self.revision.wrapping_add(1); + } +} + +/// Interpolates `current` toward `target` and snaps values within `epsilon`. +fn smooth_value(current: &mut f32, target: f32, blend: f32, epsilon: f32) -> bool { + let difference = target - *current; + if difference.abs() <= epsilon { + if *current == target { + return false; + } + *current = target; + return true; + } + *current += difference * blend; + true +} + +pub(crate) struct SharedLab { + state: RwLock, + diagnostic: RwLock, + capability: RwLock, + callback_count: AtomicU64, +} + +impl Default for SharedLab { + /// Creates synchronized scene, capability and diagnostic state. + fn default() -> Self { + Self { + state: RwLock::new(LabState::default()), + diagnostic: RwLock::new("No GPU canvas errors".into()), + capability: RwLock::new("Waiting for renderer…".into()), + callback_count: AtomicU64::new(0), + } + } +} + +impl SharedLab { + /// Copies and returns the scene under a short-lived read lock. + pub(crate) fn state(&self) -> LabState { + *self.state.read().unwrap_or_else(|error| error.into_inner()) + } + + /// Applies `update` under the scene write lock and releases it immediately. + pub(crate) fn update(&self, update: impl FnOnce(&mut LabState)) { + update( + &mut self + .state + .write() + .unwrap_or_else(|error| error.into_inner()), + ); + } + + /// Returns cloned capability/diagnostic text and the callback counter. + pub(crate) fn status(&self) -> (String, String, u64) { + ( + self.capability + .read() + .unwrap_or_else(|error| error.into_inner()) + .clone(), + self.diagnostic + .read() + .unwrap_or_else(|error| error.into_inner()) + .clone(), + self.callback_count.load(Ordering::Relaxed), + ) + } + + /// Replaces the latest runtime diagnostic with `message`. + pub(crate) fn set_diagnostic(&self, message: String) { + *self + .diagnostic + .write() + .unwrap_or_else(|error| error.into_inner()) = message; + } + + /// Replaces the selected renderer capability summary with `message`. + pub(crate) fn set_capability(&self, message: String) { + *self + .capability + .write() + .unwrap_or_else(|error| error.into_inner()) = message; + } + + /// Increments the observable GPU-canvas callback count. + pub(crate) fn record_callback(&self) { + self.callback_count.fetch_add(1, Ordering::Relaxed); + } +} diff --git a/app_examples/gpu-canvas/tests/browser.mjs b/app_examples/gpu-canvas/tests/browser.mjs new file mode 100644 index 00000000..10a95936 --- /dev/null +++ b/app_examples/gpu-canvas/tests/browser.mjs @@ -0,0 +1,145 @@ +import assert from 'node:assert/strict'; +import { mkdir } from 'node:fs/promises'; + +assert.equal(process.env.ARGUI_HIDDEN_DISPLAY, '1', 'Run with scripts/linux-hidden-display.sh'); +assert.equal(process.env.DISPLAY, undefined, 'Do not inherit the desktop X11 display'); +const imported = await import(process.env.PUPPETEER_MODULE ?? 'puppeteer'); +const puppeteer = imported.puppeteer ?? imported.default; +const output = process.env.SCREENSHOT_DIR ?? 'target/gpu-canvas-browser'; +await mkdir(output, { recursive: true }); + +const browser = await puppeteer.launch({ + executablePath: process.env.CHROME_PATH, + headless: false, + args: [ + '--ozone-platform=wayland', + '--enable-unsafe-webgpu', + '--ignore-gpu-blocklist', + '--enable-features=Vulkan', + '--use-angle=vulkan', + ], +}); + +try { + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', error => errors.push(String(error))); + page.on('console', message => { + if (message.type() === 'error' && !message.text().includes('simulated failure')) { + errors.push(message.text()); + } + }); + await page.setViewport({ width: 1280, height: 780, deviceScaleFactor: 1 }); + await page.evaluateOnNewDocument(() => { + window.arguiRendererState = 'loading'; + window.arguiFrameCount = 0; + window.addEventListener('argui:renderer-state', event => { + window.arguiRendererState = event.detail.state; + }); + const raf = window.requestAnimationFrame.bind(window); + window.requestAnimationFrame = callback => raf(time => { + window.arguiFrameCount++; + callback(time); + }); + }); + await page.goto(process.env.GPU_CANVAS_URL ?? 'http://127.0.0.1:8794/', { + waitUntil: 'networkidle0', + }); + await page.waitForFunction(() => window.arguiRendererState === 'ready'); + await page.waitForSelector('[role="img"][aria-label="Interactive GPU particle canvas"]'); + + const capture = async name => { + const png = await page.screenshot({ path: `${output}/${name}.png` }); + const populated = await page.evaluate(async base64 => { + const image = new Image(); + image.src = `data:image/png;base64,${base64}`; + await image.decode(); + const canvas = document.createElement('canvas'); + canvas.width = image.width; + canvas.height = image.height; + const context = canvas.getContext('2d'); + context.drawImage(image, 0, 0); + const pixels = context.getImageData(0, 0, image.width, image.height).data; + const colors = new Set(); + for (let index = 0; index < pixels.length; index += 64) { + colors.add((pixels[index] << 16) | (pixels[index + 1] << 8) | pixels[index + 2]); + if (colors.size > 48) return true; + } + return false; + }, Buffer.from(png).toString('base64')); + assert.ok(populated, `Blank GPU Canvas Lab capture: ${name}`); + }; + const button = label => `button[aria-label="${label}"]`; + const click = async label => { + await page.waitForSelector(button(label)); + await page.$eval(button(label), element => element.click()); + await new Promise(resolve => setTimeout(resolve, 250)); + }; + const waitForAccessibleText = value => page.waitForFunction( + expected => [...document.querySelectorAll('[aria-label]')] + .some(element => element.getAttribute('aria-label')?.includes(expected)), + {}, + value, + ); + const canvas = await page.$('[role="img"][aria-label="Interactive GPU particle canvas"]'); + const rect = await canvas.boundingBox(); + assert.ok(rect && rect.width > 400 && rect.height > 300, 'Canvas viewport is visible'); + const waitForPanY = direction => page.waitForFunction( + expected => [...document.querySelectorAll('[aria-label]')] + .map(element => element.getAttribute('aria-label')?.match(/^(-?\d+), (-?\d+)$/)) + .some(match => match && Math.sign(Number.parseInt(match[2], 10)) === expected), + {}, + direction, + ); + const dragUp = async () => { + await page.mouse.move(rect.x + rect.width * 0.55, rect.y + rect.height * 0.55); + await page.mouse.down(); + await page.mouse.move(rect.x + rect.width * 0.68, rect.y + rect.height * 0.44, { steps: 8 }); + await page.mouse.up(); + }; + + await capture('initial'); + await dragUp(); + await waitForPanY(1); + await click('Drag Y: natural'); + await click('Reset view'); + await page.waitForFunction(() => [...document.querySelectorAll('[aria-label]')] + .map(element => element.getAttribute('aria-label')) + .some(label => label === '0, 0')); + await dragUp(); + await waitForPanY(-1); + await click('Drag Y: inverted'); + await page.mouse.move(rect.x + rect.width * 0.55, rect.y + rect.height * 0.55); + await page.mouse.wheel({ deltaY: -240 }); + await page.waitForFunction(() => [...document.querySelectorAll('[aria-label]')] + .map(element => element.getAttribute('aria-label')) + .some(label => { + const match = label?.match(/(\d+\.\d{2})×/); + return match && Number.parseFloat(match[1]) > 1.05; + }), + { timeout: 10000 }); + await click('Zoom +'); + await capture('panned-zoomed'); + + await click('Test recovery'); + await waitForAccessibleText('simulated failure'); + await capture('diagnostic-placeholder'); + await click('Recover canvas'); + await waitForAccessibleText('recovered'); + + await click('Pause animation'); + await new Promise(resolve => setTimeout(resolve, 400)); + const before = await page.evaluate(() => window.arguiFrameCount); + await new Promise(resolve => setTimeout(resolve, 700)); + const after = await page.evaluate(() => window.arguiFrameCount); + assert.equal(after, before, 'Paused canvas requests no animation frames'); + + await page.setViewport({ width: 980, height: 700, deviceScaleFactor: 2 }); + await click('Resume animation'); + await new Promise(resolve => setTimeout(resolve, 350)); + await capture('hidpi-resized-resumed'); + assert.deepEqual(errors, []); + console.log(JSON.stringify({ screenshots: output, pausedFrames: after - before }, null, 2)); +} finally { + await browser.close(); +} diff --git a/app_examples/gpu-canvas/tests/state.rs b/app_examples/gpu-canvas/tests/state.rs new file mode 100644 index 00000000..f21d58e9 --- /dev/null +++ b/app_examples/gpu-canvas/tests/state.rs @@ -0,0 +1,74 @@ +use argui_example_gpu_canvas::state::LabState; + +#[test] +fn revisions_track_scene_mutations_and_pause_stops_animation_work() { + let mut state = LabState::default(); + let initial = state.revision(); + assert!(state.advance(0.016)); + assert_eq!(state.revision(), initial + 1); + + state.pan_by(12.0, -4.0); + state.zoom_by(2.0); + assert_eq!(state.pan_offset(), [0.0, 0.0]); + assert_eq!(state.zoom_factor(), 1.0); + assert_eq!(state.target_zoom_factor(), 2.0); + assert!(state.view_is_settling()); + assert!(state.advance(0.016)); + assert!(state.pan_offset()[0] > 0.0 && state.pan_offset()[0] < 12.0); + assert!(state.zoom_factor() > 1.0 && state.zoom_factor() < 2.0); + settle(&mut state); + + assert!(state.toggle_paused()); + let paused_revision = state.revision(); + assert!(!state.advance(1.0)); + assert_eq!(state.revision(), paused_revision); + assert!(!state.toggle_paused()); + assert!(state.advance(0.016)); +} + +#[test] +fn zoom_reset_and_error_controls_are_bounded_and_explicit() { + let mut state = LabState::default(); + state.zoom_by(100.0); + assert_eq!(state.target_zoom_factor(), 8.0); + state.zoom_by(0.0001); + assert_eq!(state.target_zoom_factor(), 0.2); + assert!(state.toggle_error()); + assert!(state.force_error()); + state.pan_by(9.0, 8.0); + settle(&mut state); + state.reset_view(); + assert!(state.view_is_settling()); + settle(&mut state); + assert_eq!(state.pan_offset(), [0.0, 0.0]); + assert_eq!(state.zoom_factor(), 1.0); + assert!(state.force_error()); +} + +#[test] +fn paused_scene_still_settles_camera_motion() { + let mut state = LabState::default(); + assert!(state.natural_vertical_drag()); + assert!(!state.toggle_vertical_drag()); + assert!(!state.natural_vertical_drag()); + assert!(state.toggle_vertical_drag()); + assert!(state.toggle_paused()); + state.pan_by(40.0, -12.0); + state.zoom_by(1.8); + assert!(state.view_is_settling()); + assert!(state.advance(0.016)); + settle(&mut state); + assert_eq!(state.pan_offset(), [40.0, -12.0]); + assert_eq!(state.zoom_factor(), 1.8); + assert!(!state.advance(0.016)); +} + +fn settle(state: &mut LabState) { + for _ in 0..120 { + if !state.view_is_settling() { + return; + } + assert!(state.advance(0.016)); + } + panic!("camera did not settle"); +} diff --git a/app_examples/gpu-canvas/web/index.html b/app_examples/gpu-canvas/web/index.html new file mode 100644 index 00000000..f972e075 --- /dev/null +++ b/app_examples/gpu-canvas/web/index.html @@ -0,0 +1,39 @@ + + + + + + + Argui GPU Canvas Lab + + + + + + diff --git a/crates/argui-devtools/src/view/profiling.rs b/crates/argui-devtools/src/view/profiling.rs index 25d1bf90..ca09d5db 100644 --- a/crates/argui-devtools/src/view/profiling.rs +++ b/crates/argui-devtools/src/view/profiling.rs @@ -83,6 +83,23 @@ pub(super) fn details(frame: &FrameRecord, theme: &WidgetTheme) -> Element { frame.vector_rasterizations ), ), + ( + "GPU canvases cached / rendered / hits / failed", + format!( + "{} / {} / {} / {}", + frame.gpu_canvas_entries, + frame.gpu_canvas_renders, + frame.gpu_canvas_hits, + frame.gpu_canvas_failures + ), + ), + ( + "GPU canvas encode CPU", + format!( + "{:.2} ms", + frame.gpu_canvas_encode_cpu.as_secs_f64() * 1_000.0 + ), + ), ], theme, ), diff --git a/crates/argui-devtools/src/view/resources.rs b/crates/argui-devtools/src/view/resources.rs index 41acb787..e5a64f6b 100644 --- a/crates/argui-devtools/src/view/resources.rs +++ b/crates/argui-devtools/src/view/resources.rs @@ -111,8 +111,9 @@ pub(super) fn panel( } rows.extend([ title("Argui GPU allocations", theme), - row("Texture pool", bytes(frame.texture_bytes.saturating_sub(frame.vector_atlas_bytes)), theme), + row("Texture pool", bytes(frame.texture_bytes.saturating_sub(frame.vector_atlas_bytes).saturating_sub(frame.gpu_canvas_bytes)), theme), row("Vector atlas", bytes(frame.vector_atlas_bytes), theme), + row("GPU canvases", bytes(frame.gpu_canvas_bytes), theme), row("Tracked GPU total", bytes(frame.texture_bytes), theme), note("Renderer allocations are separate from process RAM and device-wide sensor usage. Driver allocations and other GPU resources are not included in this tracked total.", theme), title("Hardware sensors", theme), diff --git a/crates/argui-devtools/tests/host.rs b/crates/argui-devtools/tests/host.rs index 418f7d0e..d782cf1c 100644 --- a/crates/argui-devtools/tests/host.rs +++ b/crates/argui-devtools/tests/host.rs @@ -142,7 +142,7 @@ fn dock_controls_cover_filter_scroll_pause_clear_and_resize() { assert!(matches!( change_tools(&host, |tools| tools.take_clipboard_request()), Some(argui_ui::ClipboardRequest::Write(trace)) - if trace.contains("argui-gpu-trace-v3") + if trace.contains("argui-gpu-trace-v4") )); assert_eq!( change_tools(&host, |tools| tools.take_clipboard_request()), diff --git a/crates/argui-devtools/tests/host/interaction.rs b/crates/argui-devtools/tests/host/interaction.rs index e50fa30d..0477251e 100644 --- a/crates/argui-devtools/tests/host/interaction.rs +++ b/crates/argui-devtools/tests/host/interaction.rs @@ -422,7 +422,7 @@ fn render_listener_forwards_clipboard_focus_and_scroll_effects() { dispatch_model_event(&mut model, &mut tree, "__devtools-copy", click_count(1)); assert!(matches!( argui_runtime::AppModel::take_clipboard_request(&mut model, &window), - Some(argui_ui::ClipboardRequest::Write(trace)) if trace.contains("argui-gpu-trace-v3") + Some(argui_ui::ClipboardRequest::Write(trace)) if trace.contains("argui-gpu-trace-v4") )); dispatch_model_event(&mut model, &mut tree, "__devtools-elements", click_count(1)); diff --git a/crates/argui-inspect/src/lib.rs b/crates/argui-inspect/src/lib.rs index 707ae06b..4456412b 100644 --- a/crates/argui-inspect/src/lib.rs +++ b/crates/argui-inspect/src/lib.rs @@ -283,6 +283,12 @@ impl InspectorHandle { frame.vector_atlas_entries = record.vector_atlas_entries; frame.vector_atlas_hits = record.vector_atlas_hits; frame.vector_rasterizations = record.vector_rasterizations; + frame.gpu_canvas_entries = record.gpu_canvas_entries; + frame.gpu_canvas_bytes = record.gpu_canvas_bytes; + frame.gpu_canvas_renders = record.gpu_canvas_renders; + frame.gpu_canvas_hits = record.gpu_canvas_hits; + frame.gpu_canvas_failures = record.gpu_canvas_failures; + frame.gpu_canvas_encode_cpu = record.gpu_canvas_encode_cpu; frame.adapter = record.adapter; frame.gpu = record.gpu; } else { diff --git a/crates/argui-inspect/src/records.rs b/crates/argui-inspect/src/records.rs index ea0051bd..b013d41b 100644 --- a/crates/argui-inspect/src/records.rs +++ b/crates/argui-inspect/src/records.rs @@ -112,6 +112,12 @@ pub struct FrameRecord { pub vector_atlas_entries: usize, pub vector_atlas_hits: usize, pub vector_rasterizations: usize, + pub gpu_canvas_entries: usize, + pub gpu_canvas_bytes: u64, + pub gpu_canvas_renders: usize, + pub gpu_canvas_hits: usize, + pub gpu_canvas_failures: usize, + pub gpu_canvas_encode_cpu: Duration, pub adapter: AdapterRecord, pub gpu: Option, } diff --git a/crates/argui-inspect/src/trace.rs b/crates/argui-inspect/src/trace.rs index b1937e3c..cdfddcf1 100644 --- a/crates/argui-inspect/src/trace.rs +++ b/crates/argui-inspect/src/trace.rs @@ -7,7 +7,7 @@ use crate::{ }; /// Version identifier written to and required by serialized inspector traces. -pub const TRACE_VERSION: &str = "argui-gpu-trace-v3"; +pub const TRACE_VERSION: &str = "argui-gpu-trace-v4"; #[derive(Clone, Debug, Eq, PartialEq)] /// Failure encountered while serializing or importing a trace. @@ -84,6 +84,12 @@ struct TraceFrame { vector_atlas_entries: usize, vector_atlas_hits: usize, vector_rasterizations: usize, + gpu_canvas_entries: usize, + gpu_canvas_bytes: u64, + gpu_canvas_renders: usize, + gpu_canvas_hits: usize, + gpu_canvas_failures: usize, + gpu_canvas_encode_cpu_ns: u64, gpu: Option, } @@ -218,6 +224,12 @@ impl From<&FrameRecord> for TraceFrame { vector_atlas_entries: value.vector_atlas_entries, vector_atlas_hits: value.vector_atlas_hits, vector_rasterizations: value.vector_rasterizations, + gpu_canvas_entries: value.gpu_canvas_entries, + gpu_canvas_bytes: value.gpu_canvas_bytes, + gpu_canvas_renders: value.gpu_canvas_renders, + gpu_canvas_hits: value.gpu_canvas_hits, + gpu_canvas_failures: value.gpu_canvas_failures, + gpu_canvas_encode_cpu_ns: nanos(value.gpu_canvas_encode_cpu), gpu: value.gpu.as_ref().map(TraceGpuFrame::from), } } @@ -247,6 +259,12 @@ impl TraceFrame { vector_atlas_entries: self.vector_atlas_entries, vector_atlas_hits: self.vector_atlas_hits, vector_rasterizations: self.vector_rasterizations, + gpu_canvas_entries: self.gpu_canvas_entries, + gpu_canvas_bytes: self.gpu_canvas_bytes, + gpu_canvas_renders: self.gpu_canvas_renders, + gpu_canvas_hits: self.gpu_canvas_hits, + gpu_canvas_failures: self.gpu_canvas_failures, + gpu_canvas_encode_cpu: Duration::from_nanos(self.gpu_canvas_encode_cpu_ns), adapter, gpu: self.gpu.map(TraceGpuFrame::into_record), } diff --git a/crates/argui-inspect/tests/lib.rs b/crates/argui-inspect/tests/lib.rs index 7cf605e9..1f21dea5 100644 --- a/crates/argui-inspect/tests/lib.rs +++ b/crates/argui-inspect/tests/lib.rs @@ -366,6 +366,12 @@ fn gpu_trace_round_trip_preserves_strict_timeline_data() { vector_atlas_entries: 5, vector_atlas_hits: 8, vector_rasterizations: 1, + gpu_canvas_entries: 3, + gpu_canvas_bytes: 65_536, + gpu_canvas_renders: 2, + gpu_canvas_hits: 1, + gpu_canvas_failures: 1, + gpu_canvas_encode_cpu: Duration::from_nanos(450), adapter: AdapterRecord { name: "Test GPU".into(), backend: "Vulkan".into(), @@ -398,6 +404,12 @@ fn gpu_trace_round_trip_preserves_strict_timeline_data() { assert_eq!(frame.vector_atlas_entries, 5); assert_eq!(frame.vector_atlas_hits, 8); assert_eq!(frame.vector_rasterizations, 1); + assert_eq!(frame.gpu_canvas_entries, 3); + assert_eq!(frame.gpu_canvas_bytes, 65_536); + assert_eq!(frame.gpu_canvas_renders, 2); + assert_eq!(frame.gpu_canvas_hits, 1); + assert_eq!(frame.gpu_canvas_failures, 1); + assert_eq!(frame.gpu_canvas_encode_cpu, Duration::from_nanos(450)); assert_eq!(pass.start, Duration::from_nanos(100)); assert_eq!(pass.duration, Duration::from_nanos(300)); assert_eq!(imported.selected(), Some(InspectNodeId(9))); @@ -409,7 +421,7 @@ fn gpu_trace_rejects_unknown_versions_fields_and_enum_values() { inspector.record_ui(FrameRecord::default()); let json = inspector.trace_json().unwrap(); - let wrong_version = json.replace("argui-gpu-trace-v3", "argui-gpu-trace-v1"); + let wrong_version = json.replace("argui-gpu-trace-v4", "argui-gpu-trace-v1"); assert!(inspector.import_trace_json(&wrong_version).is_err()); let unknown_field = json.replacen("{", "{\"unknown\":true,", 1); @@ -452,6 +464,12 @@ fn inspector_state_gates_recording_and_updates_the_current_render_sample() { vector_atlas_entries: 11, vector_atlas_hits: 12, vector_rasterizations: 13, + gpu_canvas_entries: 20, + gpu_canvas_bytes: 21, + gpu_canvas_renders: 22, + gpu_canvas_hits: 23, + gpu_canvas_failures: 24, + gpu_canvas_encode_cpu: Duration::from_nanos(25), adapter: AdapterRecord { name: "adapter".into(), vendor: 1, @@ -489,6 +507,12 @@ fn inspector_state_gates_recording_and_updates_the_current_render_sample() { assert_eq!(frame.resize_events, 0, "resize counts belong to UI samples"); assert_eq!(frame.layers, 3); assert_eq!(frame.vector_rasterizations, 13); + assert_eq!(frame.gpu_canvas_entries, 20); + assert_eq!(frame.gpu_canvas_bytes, 21); + assert_eq!(frame.gpu_canvas_renders, 22); + assert_eq!(frame.gpu_canvas_hits, 23); + assert_eq!(frame.gpu_canvas_failures, 24); + assert_eq!(frame.gpu_canvas_encode_cpu, Duration::from_nanos(25)); assert_eq!(frame.adapter.max_bind_groups, 17); assert_eq!(frame.gpu.as_ref().unwrap().passes[0].pixels, 19); @@ -556,7 +580,7 @@ fn trace_errors_display_and_duration_saturation_are_stable() { let invalid = imported.import_trace_json("not json").unwrap_err(); assert!(invalid.to_string().contains("invalid Argui GPU trace JSON")); - let wrong = json.replace("argui-gpu-trace-v3", "other"); + let wrong = json.replace("argui-gpu-trace-v4", "other"); let unsupported = imported.import_trace_json(&wrong).unwrap_err(); assert!( unsupported diff --git a/crates/argui-layout/src/assets.rs b/crates/argui-layout/src/assets.rs index a93d7f1c..3e620afe 100644 --- a/crates/argui-layout/src/assets.rs +++ b/crates/argui-layout/src/assets.rs @@ -31,6 +31,7 @@ impl AssetMetrics { ElementKind::Image { image, .. } => self.images.get(image).copied(), ElementKind::Vector { vector, .. } => self.vectors.get(vector).copied(), ElementKind::Custom(_) + | ElementKind::GpuCanvas(_) | ElementKind::Container | ElementKind::Text { .. } | ElementKind::TextEditor { .. } => None, diff --git a/crates/argui-layout/src/custom.rs b/crates/argui-layout/src/custom.rs index 1f0754fd..2216217a 100644 --- a/crates/argui-layout/src/custom.rs +++ b/crates/argui-layout/src/custom.rs @@ -102,6 +102,7 @@ pub(crate) fn paint( output: &mut crate::LayoutOutput, transform: argui_core::Affine2D, clips: &argui_paint::ClipChain, + style: argui_paint::QuadStyle, ) { if let (ElementKind::Custom(custom), Some(state)) = (&element.kind, &map.custom_state) { custom.paint( @@ -111,6 +112,12 @@ pub(crate) fn paint( transform, clips, display_list: &mut output.display_list, + object: argui_paint::RenderObjectId::new( + argui_paint::ProfileDomain::Ui, + node.node.get(), + ), + opacity: style.opacity, + radii: style.radii, }, ); } diff --git a/crates/argui-layout/src/engine.rs b/crates/argui-layout/src/engine.rs index 4ab88bb9..0e2c3997 100644 --- a/crates/argui-layout/src/engine.rs +++ b/crates/argui-layout/src/engine.rs @@ -217,6 +217,7 @@ fn build_node( let id = match element.kind { ElementKind::Text { .. } | ElementKind::TextEditor { .. } + | ElementKind::GpuCanvas(_) | ElementKind::Image { .. } | ElementKind::Vector { .. } => tree.new_leaf_with_context(style, index)?, ElementKind::Custom(_) | ElementKind::Container => { diff --git a/crates/argui-layout/src/paint.rs b/crates/argui-layout/src/paint.rs index 358f8142..f83aa398 100644 --- a/crates/argui-layout/src/paint.rs +++ b/crates/argui-layout/src/paint.rs @@ -12,6 +12,7 @@ use cache::CachedFragment; pub(crate) use cache::PaintCache; mod effects; mod geometry; +mod gpu_canvas; mod portal; mod sync; use effects::{begin_layer, begin_scope, end_layers, scope_count}; @@ -202,7 +203,15 @@ pub(super) fn paint_node( ), active_portal: context.active_portal, }; - crate::custom::paint(map, element, node, output, transform, &child_context.clips); + crate::custom::paint( + map, + element, + node, + output, + transform, + &child_context.clips, + ui.resolved_quad(node.node, element), + ); if (map.style.overflow.x.scrolls() || map.style.overflow.y.scrolls()) && let Some(region) = output .scroll_regions @@ -360,6 +369,7 @@ fn paint_enter( context, ); push_image(ui, element, node, output, context); + gpu_canvas::push(ui, element, node, output, context); push_vector(ui, element, node, output, context); begin_scope( ui, diff --git a/crates/argui-layout/src/paint/gpu_canvas.rs b/crates/argui-layout/src/paint/gpu_canvas.rs new file mode 100644 index 00000000..a92d62e4 --- /dev/null +++ b/crates/argui-layout/src/paint/gpu_canvas.rs @@ -0,0 +1,33 @@ +use argui_paint::{GpuCanvasPrimitive, ProfileDomain, RenderObjectId}; +use argui_ui::{Element, ElementKind, UiTree}; + +use crate::{LayoutNode, LayoutOutput}; + +use super::PaintContext; + +/// Lowers one retained GPU-canvas leaf into the display list using `context` geometry. +pub(super) fn push( + ui: &UiTree, + element: &Element, + node: LayoutNode, + output: &mut LayoutOutput, + context: &PaintContext, +) { + let ElementKind::GpuCanvas(spec) = element.kind else { + return; + }; + let style = ui.resolved_quad(node.node, element); + output.display_list.push_gpu_canvas(GpuCanvasPrimitive { + canvas: spec.canvas(), + object: RenderObjectId::new(ProfileDomain::Ui, node.node.get()), + slot: 0, + bounds: node.bounds, + content_revision: spec.revision(), + resolution_scale: spec.scale(), + sampling: spec.image_sampling(), + opacity: style.opacity, + radii: style.radii, + transform: context.transform, + clips: context.clips.clone(), + }); +} diff --git a/crates/argui-layout/src/paint/sync.rs b/crates/argui-layout/src/paint/sync.rs index 257ce864..04a50c44 100644 --- a/crates/argui-layout/src/paint/sync.rs +++ b/crates/argui-layout/src/paint/sync.rs @@ -57,6 +57,7 @@ pub(super) fn text_colors(elements: &[&Element], ui: &UiTree, output: &mut Layou } } ElementKind::Custom(_) + | ElementKind::GpuCanvas(_) | ElementKind::Container | ElementKind::Image { .. } | ElementKind::Vector { .. } => { diff --git a/crates/argui-layout/src/surface.rs b/crates/argui-layout/src/surface.rs index e20252a0..c286e746 100644 --- a/crates/argui-layout/src/surface.rs +++ b/crates/argui-layout/src/surface.rs @@ -46,6 +46,10 @@ impl NativeSurfacePaint { translate(&mut item.transform); clips(&mut item.clips); } + DisplayCommand::GpuCanvas(item) => { + translate(&mut item.transform); + clips(&mut item.clips); + } DisplayCommand::Vector(item) => { translate(&mut item.transform); clips(&mut item.clips); diff --git a/crates/argui-layout/src/text.rs b/crates/argui-layout/src/text.rs index a5c8a7b5..1e0b7d84 100644 --- a/crates/argui-layout/src/text.rs +++ b/crates/argui-layout/src/text.rs @@ -36,6 +36,7 @@ pub(super) fn content<'a>( } } ElementKind::Custom(_) + | ElementKind::GpuCanvas(_) | ElementKind::Container | ElementKind::Image { .. } | ElementKind::Vector { .. } => None, diff --git a/crates/argui-layout/tests/custom.rs b/crates/argui-layout/tests/custom.rs index e9501bf2..f64b3823 100644 --- a/crates/argui-layout/tests/custom.rs +++ b/crates/argui-layout/tests/custom.rs @@ -1,10 +1,10 @@ use argui_core::{Point, Rect, Size}; use argui_layout::LayoutEngine; -use argui_paint::{Color, DisplayCommand, QuadStyle}; +use argui_paint::{Color, DisplayCommand, GpuCanvasId, QuadStyle}; use argui_text::TextEngine; use argui_ui::{ - CustomElement, CustomLayoutContext, CustomMeasurement, CustomPaintContext, Element, TreeUpdate, - UiTree, + CustomElement, CustomLayoutContext, CustomMeasurement, CustomPaintContext, Element, + GpuCanvasSpec, TreeUpdate, UiTree, }; use std::{cell::Cell, rc::Rc}; @@ -67,6 +67,82 @@ fn tile(counts: &Rc, width: f32, color: Color) -> Element { }) .keyed("tile") } + +#[derive(Debug)] +struct CanvasTile { + canvas: GpuCanvasId, +} + +impl CustomElement for CanvasTile { + type State = (); + + fn create_state(&self) {} + + fn layout_revision(&self) -> u64 { + 0 + } + + fn paint_revision(&self) -> u64 { + 3 + } + + fn prepare(&self, _: &mut (), _: Size) {} + + fn layout( + &self, + _: &mut (), + _: &mut dyn CustomLayoutContext, + ) -> Result { + Ok(CustomMeasurement { + size: Size::new(80.0, 40.0), + baseline: None, + }) + } + + fn paint(&self, _: &mut (), context: &mut CustomPaintContext<'_>) { + context.gpu_canvas( + 2, + Rect::new(Point::new(4.0, 6.0), Size::new(30.0, 20.0)), + GpuCanvasSpec::new(self.canvas).content_revision(9), + ); + context.gpu_canvas( + 7, + Rect::new(Point::new(40.0, 6.0), Size::new(30.0, 20.0)), + GpuCanvasSpec::new(self.canvas).content_revision(10), + ); + } +} + +#[test] +fn custom_paint_gpu_canvases_share_retained_object_and_keep_local_slots() { + let canvas = GpuCanvasId::fresh(); + let element = Element::custom(CanvasTile { canvas }) + .width(argui_ui::length(80.0)) + .height(argui_ui::length(40.0)) + .paint_opacity(0.5) + .radius(argui_ui::CornerRadii::all(5.0)); + let mut ui = UiTree::new(element); + let object = ui.node_ids()[0]; + let output = LayoutEngine::new() + .compute(&mut ui, &mut TextEngine::new(), Size::new(100.0, 60.0)) + .unwrap(); + let canvases = output + .display_list + .commands() + .iter() + .filter_map(|command| match command { + DisplayCommand::GpuCanvas(canvas) => Some(canvas), + _ => None, + }) + .collect::>(); + assert_eq!(canvases.len(), 2); + assert_eq!([canvases[0].slot, canvases[1].slot], [2, 7]); + assert_eq!(canvases[0].object.value, object.get()); + assert_eq!(canvases[1].object, canvases[0].object); + assert_eq!(canvases[0].bounds.origin, Point::new(4.0, 6.0)); + assert_eq!(canvases[0].opacity, 0.5); + assert_eq!(canvases[0].radii, argui_ui::CornerRadii::all(5.0)); +} #[test] fn custom_measurement_paint_invalidation_and_state_lifetime() { let counts = Rc::new(Counts::default()); diff --git a/crates/argui-layout/tests/paint.rs b/crates/argui-layout/tests/paint.rs index ab811ee5..995def4b 100644 --- a/crates/argui-layout/tests/paint.rs +++ b/crates/argui-layout/tests/paint.rs @@ -4,12 +4,12 @@ mod effects; #[path = "paint/geometry.rs"] mod geometry; use argui_layout::LayoutEngine; -use argui_paint::{Border, Color, DisplayCommand, ImageFit, LayerStyle, VectorId}; +use argui_paint::{Border, Color, DisplayCommand, GpuCanvasId, ImageFit, LayerStyle, VectorId}; use argui_text::TextEngine; use argui_ui::{ - Axes, CornerRadii, CursorIcon, Element, HitTestStyle, ImageId, Interaction, Overflow, - PointerEvents, StylePatch, Transform2D, TransformOrigin, UiTree, UserSelect, VisualState, - length, property, + Axes, CornerRadii, CursorIcon, Element, FocusPolicy, GpuCanvasSpec, HitTestStyle, ImageId, + Interaction, Overflow, PointerEvents, Role, Semantics, StylePatch, Transform2D, + TransformOrigin, UiTree, UserSelect, VisualState, length, property, }; const NOTO_SANS: &[u8] = include_bytes!("../../argui-web-demo/assets/fonts/NotoSans-Regular.ttf"); @@ -22,6 +22,69 @@ fn effect() -> LayerStyle { LayerStyle::new(Default::default()).opacity(0.8) } +#[test] +fn gpu_canvas_lowers_with_retained_identity_and_normal_visual_geometry() { + let id = GpuCanvasId::fresh(); + let canvas = Element::gpu_canvas( + GpuCanvasSpec::new(id) + .content_revision(8) + .resolution_scale(1.5) + .sampling(argui_ui::ImageSampling::Nearest), + ) + .width(length(80.0)) + .height(length(40.0)) + .paint_opacity(0.6) + .radius(CornerRadii::all(7.0)) + .transform(Transform2D::IDENTITY.translate(9.0, 11.0)); + let mut ui = UiTree::new( + Element::container([canvas]) + .width(length(120.0)) + .height(length(80.0)) + .overflow(Axes { + x: Overflow::Hidden, + y: Overflow::Hidden, + }), + ); + let retained = ui.node_ids()[1]; + let output = LayoutEngine::new() + .compute(&mut ui, &mut text_engine(), Size::new(120.0, 80.0)) + .unwrap(); + let DisplayCommand::GpuCanvas(canvas) = &output.display_list.commands()[0] else { + panic!("GPU canvas element must lower to a canvas command"); + }; + assert_eq!(canvas.canvas, id); + assert_eq!(canvas.object.value, retained.get()); + assert_eq!(canvas.slot, 0); + assert_eq!(canvas.content_revision, 8); + assert_eq!(canvas.resolution_scale, 1.5); + assert_eq!(canvas.opacity, 0.6); + assert_eq!(canvas.radii, CornerRadii::all(7.0)); + assert_eq!(canvas.clips.regions().len(), 2); + assert_eq!(output.display_list.gpu_canvas_count(), 1); +} + +#[test] +fn gpu_canvas_is_an_opaque_focusable_hit_target_without_executing_gpu_code() { + let element = Element::gpu_canvas(GpuCanvasSpec::new(GpuCanvasId::fresh())) + .width(length(90.0)) + .height(length(45.0)) + .interaction(Interaction::default().focus_policy(FocusPolicy::TabStop)) + .semantics(Semantics::new(Role::Image).label("Data viewport")); + let mut ui = UiTree::new(element); + let output = LayoutEngine::new() + .compute(&mut ui, &mut text_engine(), Size::new(120.0, 80.0)) + .unwrap(); + + assert_eq!(output.hit_regions.len(), 1); + assert_eq!(output.hit_regions[0].focus_policy, FocusPolicy::TabStop); + assert!(output.hit_regions[0].contains(Point::new(30.0, 20.0))); + let semantics = ui.semantic_tree(&output.semantic_bounds, 1.0); + assert_eq!( + semantics.nodes[0].semantics.label.as_deref(), + Some("Data viewport") + ); +} + #[test] fn plain_elements_keep_one_combined_quad_and_no_layers() { let root = Element::text("Fast path") diff --git a/crates/argui-layout/tests/surface.rs b/crates/argui-layout/tests/surface.rs index 1ce6a921..c8a3c3b3 100644 --- a/crates/argui-layout/tests/surface.rs +++ b/crates/argui-layout/tests/surface.rs @@ -1,10 +1,10 @@ use argui_core::{Color, Point, Rect, Size}; use argui_layout::LayoutEngine; -use argui_paint::{DisplayCommand, Fill, LayerStyle}; +use argui_paint::{DisplayCommand, Fill, GpuCanvasId, LayerStyle}; use argui_text::TextEngine; use argui_ui::{ - Axes, Element, FloatingPlacement, Interaction, NodeId, Overflow, OverlaySurface, Placement, - ScrollConfig, UiTree, WindowLayer, length, + Axes, Element, FloatingPlacement, GpuCanvasSpec, Interaction, NodeId, Overflow, OverlaySurface, + Placement, ScrollConfig, UiTree, WindowLayer, length, }; fn node(ui: &UiTree, key: &str) -> NodeId { @@ -222,6 +222,9 @@ fn native_image_vector_and_effect_clips_share_the_same_local_coordinates() { Element::vector(VectorId::fresh()) .width(length(32.0)) .height(length(32.0)), + Element::gpu_canvas(GpuCanvasSpec::new(GpuCanvasId::fresh())) + .width(length(28.0)) + .height(length(24.0)), ], ) .layer(LayerStyle::new(Rect::default()).opacity(0.8)), @@ -244,6 +247,10 @@ fn native_image_vector_and_effect_clips_share_the_same_local_coordinates() { kinds.push("vector"); (vector.transform, &vector.clips) } + DisplayCommand::GpuCanvas(canvas) => { + kinds.push("gpu-canvas"); + (canvas.transform, &canvas.clips) + } DisplayCommand::BeginLayer(layer) => { assert_eq!(layer.bounds.origin, Point::default()); continue; @@ -255,6 +262,6 @@ fn native_image_vector_and_effect_clips_share_the_same_local_coordinates() { assert_eq!(clip.transform.translation, transform.translation); assert_eq!(clip.bounds, bounds); } - assert_eq!(kinds, ["image", "vector"]); + assert_eq!(kinds, ["image", "vector", "gpu-canvas"]); assert!(output.native_surfaces[0].display_list.validate().is_ok()); } diff --git a/crates/argui-paint/src/display_list.rs b/crates/argui-paint/src/display_list.rs index 3c9b800b..99cef84a 100644 --- a/crates/argui-paint/src/display_list.rs +++ b/crates/argui-paint/src/display_list.rs @@ -2,13 +2,14 @@ use core::fmt; use argui_core::Affine2D; -use crate::{ClipChain, ImagePrimitive, LayerStyle, Quad, VectorPrimitive}; +use crate::{ClipChain, GpuCanvasPrimitive, ImagePrimitive, LayerStyle, Quad, VectorPrimitive}; #[derive(Clone, Debug, PartialEq)] /// Ordered drawing commands submitted to a renderer. pub enum DisplayCommand { Quad(Quad), Image(ImagePrimitive), + GpuCanvas(GpuCanvasPrimitive), Vector(VectorPrimitive), Text { block: usize, @@ -49,6 +50,7 @@ impl std::error::Error for DisplayListError {} pub struct DisplayList { commands: Vec, quad_count: usize, + gpu_canvas_count: usize, } impl DisplayList { @@ -63,6 +65,7 @@ impl DisplayList { Self { commands: Vec::new(), quad_count: 0, + gpu_canvas_count: 0, } } @@ -77,6 +80,12 @@ impl DisplayList { self.commands.push(DisplayCommand::Image(image)); } + /// Appends a retained GPU-canvas primitive. + pub fn push_gpu_canvas(&mut self, canvas: GpuCanvasPrimitive) { + self.commands.push(DisplayCommand::GpuCanvas(canvas)); + self.gpu_canvas_count += 1; + } + /// Appends a vector primitive. pub fn push_vector(&mut self, vector: VectorPrimitive) { self.commands.push(DisplayCommand::Vector(vector)); @@ -111,6 +120,7 @@ impl DisplayList { pub fn clear(&mut self) { self.commands.clear(); self.quad_count = 0; + self.gpu_canvas_count = 0; } /// Appends commands from an iterator and updates the quad count. @@ -119,6 +129,9 @@ impl DisplayList { if matches!(command, DisplayCommand::Quad(_)) { self.quad_count += 1; } + if matches!(command, DisplayCommand::GpuCanvas(_)) { + self.gpu_canvas_count += 1; + } self.commands.push(command); } } @@ -147,6 +160,12 @@ impl DisplayList { self.quad_count } + /// Returns the number of GPU-canvas commands. + #[must_use] + pub const fn gpu_canvas_count(&self) -> usize { + self.gpu_canvas_count + } + /// Checks that every layer end has a matching begin. /// /// # Errors @@ -163,6 +182,7 @@ impl DisplayList { DisplayCommand::EndLayer => depth -= 1, DisplayCommand::Quad(_) | DisplayCommand::Image(_) + | DisplayCommand::GpuCanvas(_) | DisplayCommand::Vector(_) | DisplayCommand::Text { .. } => {} } diff --git a/crates/argui-paint/src/gpu_canvas.rs b/crates/argui-paint/src/gpu_canvas.rs new file mode 100644 index 00000000..bf3c1478 --- /dev/null +++ b/crates/argui-paint/src/gpu_canvas.rs @@ -0,0 +1,60 @@ +use std::sync::atomic::{AtomicU64, Ordering}; + +use argui_core::{Affine2D, Rect}; + +use crate::{ClipChain, CornerRadii, ImageSampling, RenderObjectId}; + +/// Process-local identity of a registered GPU-canvas factory. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct GpuCanvasId(u64); + +static NEXT_GPU_CANVAS_ID: AtomicU64 = AtomicU64::new(1); + +impl GpuCanvasId { + /// Allocates a process-local opaque GPU-canvas identity. + /// + /// Registrations normally call this on behalf of applications. + /// + /// # Panics + /// + /// Panics if the process-local identity space is exhausted. + #[must_use] + pub fn fresh() -> Self { + let id = NEXT_GPU_CANVAS_ID.fetch_add(1, Ordering::Relaxed); + assert_ne!(id, u64::MAX, "GPU-canvas identity space exhausted"); + Self(id) + } + + /// Returns the process-local numeric identity for diagnostics. + #[must_use] + pub const fn get(self) -> u64 { + self.0 + } +} + +/// Renderer-neutral description of one retained GPU-canvas viewport. +#[derive(Clone, Debug, PartialEq)] +pub struct GpuCanvasPrimitive { + /// Registration selecting the application-owned renderer factory. + pub canvas: GpuCanvasId, + /// Retained UI object identity used to own cached GPU resources. + pub object: RenderObjectId, + /// Application-selected sub-identity within one retained object. + pub slot: u32, + /// Logical viewport bounds before `transform` is applied. + pub bounds: Rect, + /// Application-authored revision of the canvas pixels. + pub content_revision: u64, + /// Explicit multiplier applied after the window scale factor. + pub resolution_scale: f32, + /// Sampling used while composing the retained canvas texture. + pub sampling: ImageSampling, + /// Straight-alpha opacity applied during composition. + pub opacity: f32, + /// Rounded corners applied during composition. + pub radii: CornerRadii, + /// Transform from logical canvas coordinates to the target surface. + pub transform: Affine2D, + /// Ancestor clipping regions in target-surface coordinates. + pub clips: ClipChain, +} diff --git a/crates/argui-paint/src/lib.rs b/crates/argui-paint/src/lib.rs index fa9eef95..d1839761 100644 --- a/crates/argui-paint/src/lib.rs +++ b/crates/argui-paint/src/lib.rs @@ -2,6 +2,7 @@ mod display_list; mod effect; +mod gpu_canvas; mod style; mod vector; mod visual; @@ -12,6 +13,7 @@ pub use effect::{ BlendMode, EffectArgument, EffectId, EffectInstance, EffectValue, Filter, LayerMask, LayerStyle, ProfileDomain, Refraction, RenderObjectId, Shadow, }; +pub use gpu_canvas::{GpuCanvasId, GpuCanvasPrimitive}; pub use style::{ Border, BorderWidths, CornerRadii, Fill, ImagePrimitive, PaintStyle, Quad, QuadStyle, }; diff --git a/crates/argui-paint/tests/display_list.rs b/crates/argui-paint/tests/display_list.rs index b1a8b319..ae010d9a 100644 --- a/crates/argui-paint/tests/display_list.rs +++ b/crates/argui-paint/tests/display_list.rs @@ -1,6 +1,7 @@ use argui_core::{Affine2D, Point, Rect, Size}; use argui_paint::{ - Border, ClipChain, Color, CornerRadii, DisplayCommand, DisplayList, Fill, LayerStyle, Quad, + Border, ClipChain, Color, CornerRadii, DisplayCommand, DisplayList, Fill, GpuCanvasId, + GpuCanvasPrimitive, ImageSampling, LayerStyle, ProfileDomain, Quad, RenderObjectId, }; #[test] @@ -18,8 +19,23 @@ fn display_lists_preserve_cross_primitive_order() { let mut list = DisplayList::new(); list.push_quad(quad.clone()); list.push_text(3); + let canvas = GpuCanvasPrimitive { + canvas: GpuCanvasId::fresh(), + object: RenderObjectId::new(ProfileDomain::Ui, 9), + slot: 2, + bounds, + content_revision: 7, + resolution_scale: 1.5, + sampling: ImageSampling::Nearest, + opacity: 0.75, + radii: CornerRadii::all(4.0), + transform: Affine2D::IDENTITY, + clips: ClipChain::default(), + }; + list.push_gpu_canvas(canvas.clone()); assert_eq!(list.quad_count(), 1); + assert_eq!(list.gpu_canvas_count(), 1); assert_eq!( list.commands(), &[ @@ -29,14 +45,25 @@ fn display_lists_preserve_cross_primitive_order() { transform: Affine2D::IDENTITY, clips: ClipChain::default(), }, + DisplayCommand::GpuCanvas(canvas.clone()), ] ); list.begin_layer(LayerStyle::new(Default::default())); list.end_layer(); assert_eq!(list.quad_count(), 1); + assert_eq!(list.gpu_canvas_count(), 1); + + list.extend([DisplayCommand::GpuCanvas(canvas)]); + assert_eq!(list.gpu_canvas_count(), 2); list.clear(); assert_eq!(list.quad_count(), 0); + assert_eq!(list.gpu_canvas_count(), 0); assert!(list.commands().is_empty()); } + +#[test] +fn gpu_canvas_ids_are_process_local_and_unique() { + assert_ne!(GpuCanvasId::fresh(), GpuCanvasId::fresh()); +} diff --git a/crates/argui-render/Cargo.toml b/crates/argui-render/Cargo.toml index fd2b344a..af2dc655 100644 --- a/crates/argui-render/Cargo.toml +++ b/crates/argui-render/Cargo.toml @@ -26,5 +26,9 @@ pollster.workspace = true # Creates a Wayland window for the native surface integration test. winit.workspace = true +[[test]] +name = "gpu_canvas" +path = "tests/gpu_canvas/mod.rs" + [lints] workspace = true diff --git a/crates/argui-render/src/batch.rs b/crates/argui-render/src/batch.rs index 02d62326..7c1f3830 100644 --- a/crates/argui-render/src/batch.rs +++ b/crates/argui-render/src/batch.rs @@ -7,6 +7,7 @@ pub(crate) enum DrawKind { Quad, Text, Image(ImageId, ImageSampling), + GpuCanvas(u32), Vector, } @@ -25,6 +26,7 @@ pub(crate) fn build_batches( let mut quad = 0; let mut image = 0; let mut vector = 0; + let mut gpu_canvas = 0; for command in display_list.commands() { let draw = match command { DisplayCommand::Quad(_) => { @@ -41,6 +43,12 @@ pub(crate) fn build_batches( image += 1; Some((DrawKind::Image(item.image, item.sampling), instances)) } + DisplayCommand::GpuCanvas(_) => { + let instances = gpu_canvas..gpu_canvas + 1; + let kind = DrawKind::GpuCanvas(gpu_canvas); + gpu_canvas += 1; + Some((kind, instances)) + } DisplayCommand::Vector(_) => { let instances = vector..vector + 1; vector += 1; diff --git a/crates/argui-render/src/config.rs b/crates/argui-render/src/config.rs index 7f01cfba..b1537411 100644 --- a/crates/argui-render/src/config.rs +++ b/crates/argui-render/src/config.rs @@ -1,4 +1,4 @@ -use crate::EffectRegistry; +use crate::{EffectRegistry, GpuCanvasRegistry}; use argui_core::Color; #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] @@ -59,8 +59,12 @@ pub struct RendererConfig { pub surface_alpha: SurfaceAlphaMode, pub profiling: bool, pub image_cache_bytes: usize, + /// Maximum retained GPU-canvas texture bytes per surface renderer. + pub gpu_canvas_cache_bytes: usize, pub gradient_stop_capacity: usize, pub effects: EffectRegistry, + /// Immutable factories and device requirements available to GPU canvases. + pub gpu_canvases: GpuCanvasRegistry, pub effect_quality: EffectQuality, } @@ -75,8 +79,10 @@ impl Default for RendererConfig { surface_alpha: SurfaceAlphaMode::Opaque, profiling: false, image_cache_bytes: 64 * 1024 * 1024, + gpu_canvas_cache_bytes: 128 * 1024 * 1024, gradient_stop_capacity: 65_536, effects: EffectRegistry::default(), + gpu_canvases: GpuCanvasRegistry::default(), effect_quality: EffectQuality::Normal, } } @@ -136,6 +142,16 @@ impl RendererConfig { self } + /// Sets the retained GPU-canvas texture budget in bytes. + /// + /// Requests that cannot fit this budget display a recoverable placeholder + /// without allocating the oversized texture. + #[must_use] + pub fn gpu_canvas_cache_bytes(mut self, bytes: usize) -> Self { + self.gpu_canvas_cache_bytes = bytes; + self + } + /// Sets capacity reserved for gradient stops. /// * `stops` — reserved number of gradient stops. #[must_use] @@ -152,6 +168,13 @@ impl RendererConfig { self } + /// Sets the immutable GPU-canvas registrations known before device creation. + #[must_use] + pub fn gpu_canvases(mut self, gpu_canvases: GpuCanvasRegistry) -> Self { + self.gpu_canvases = gpu_canvases; + self + } + /// Sets the rendering quality preset used for effects. /// * `quality` — quality preset controlling effect resolution and cost. #[must_use] diff --git a/crates/argui-render/src/effect_graph.rs b/crates/argui-render/src/effect_graph.rs index 213d7444..580d10ec 100644 --- a/crates/argui-render/src/effect_graph.rs +++ b/crates/argui-render/src/effect_graph.rs @@ -93,6 +93,7 @@ impl EffectGraph { let mut quad = 0_u32; let mut image = 0_u32; let mut vector = 0_u32; + let mut gpu_canvas = 0_u32; for command in display_list.commands() { match command { DisplayCommand::Quad(_) => { @@ -114,6 +115,15 @@ impl EffectGraph { ); image += 1; } + DisplayCommand::GpuCanvas(_) => { + push_draw( + &mut roots, + &mut stack, + DrawKind::GpuCanvas(gpu_canvas), + gpu_canvas..gpu_canvas + 1, + ); + gpu_canvas += 1; + } DisplayCommand::Vector(_) => { push_draw(&mut roots, &mut stack, DrawKind::Vector, vector..vector + 1); vector += 1; diff --git a/crates/argui-render/src/error.rs b/crates/argui-render/src/error.rs index 09c28a8e..a6627bff 100644 --- a/crates/argui-render/src/error.rs +++ b/crates/argui-render/src/error.rs @@ -34,6 +34,14 @@ pub enum RendererError { provided: usize, maximum: usize, }, + GpuCanvasCapability { + canvas: String, + message: String, + }, + IncompatibleGpuCanvasDevice { + canvas: String, + message: String, + }, MissingImage(u64), MissingVector(u64), VectorAtlasFull, @@ -105,6 +113,14 @@ impl fmt::Display for RendererError { formatter, "effect parameters need {provided} bytes but this adapter allows {maximum}" ), + Self::GpuCanvasCapability { canvas, message } => write!( + formatter, + "GPU canvas '{canvas}' cannot use this adapter: {message}" + ), + Self::IncompatibleGpuCanvasDevice { canvas, message } => write!( + formatter, + "GPU canvas '{canvas}' is incompatible with the shared renderer device: {message}" + ), Self::MissingImage(id) => write!(formatter, "image {id} is not registered"), Self::MissingVector(id) => write!(formatter, "vector {id} is not registered"), Self::VectorAtlasFull => formatter.write_str("the bounded vector atlas is full"), diff --git a/crates/argui-render/src/gpu_canvas/cache.rs b/crates/argui-render/src/gpu_canvas/cache.rs new file mode 100644 index 00000000..a3777c0e --- /dev/null +++ b/crates/argui-render/src/gpu_canvas/cache.rs @@ -0,0 +1,270 @@ +use std::collections::HashMap; + +use argui_paint::{GpuCanvasId, GpuCanvasPrimitive, ImageSampling, RenderObjectId}; + +use crate::image::pipeline::ImagePipeline; + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub(crate) struct CanvasKey { + pub canvas: GpuCanvasId, + pub object: RenderObjectId, + pub slot: u32, +} + +impl From<&GpuCanvasPrimitive> for CanvasKey { + /// Derives the persistent cache key from a renderer-neutral `primitive`. + fn from(primitive: &GpuCanvasPrimitive) -> Self { + Self { + canvas: primitive.canvas, + object: primitive.object, + slot: primitive.slot, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct CanvasExtent { + pub size: [u32; 2], + pub bytes: u64, +} + +pub(crate) struct CacheEntry { + _texture: wgpu::Texture, + view: wgpu::TextureView, + linear: wgpu::BindGroup, + nearest: wgpu::BindGroup, + extent: CanvasExtent, + last_used: u64, + attempted_revision: Option, + rendered_revision: Option, +} + +impl CacheEntry { + /// Returns the offscreen view supplied to the application renderer. + pub fn target(&self) -> &wgpu::TextureView { + &self.view + } + + /// Returns the compositor bind group for the requested `sampling` mode. + pub fn group(&self, sampling: ImageSampling) -> &wgpu::BindGroup { + match sampling { + ImageSampling::Nearest => &self.nearest, + ImageSampling::Linear => &self.linear, + } + } + + /// Returns whether `revision` has not yet been attempted for this exact target. + pub fn needs_render(&self, revision: u64) -> bool { + self.attempted_revision != Some(revision) + } + + /// Records `revision` as the valid pixels currently stored in the target. + pub fn mark_success(&mut self, revision: u64) { + self.attempted_revision = Some(revision); + self.rendered_revision = Some(revision); + } + + /// Records a failed `revision` so an unchanged frame does not retry it. + pub fn mark_failure(&mut self, revision: u64) { + self.attempted_revision = Some(revision); + self.rendered_revision = None; + } + + /// Returns whether the target contains a successfully rendered revision. + pub fn valid(&self) -> bool { + self.rendered_revision.is_some() + } + + /// Marks the entry as used by `frame` for least-recently-used eviction. + fn touch(&mut self, frame: u64) { + self.last_used = frame; + } +} + +pub(crate) struct CanvasCache { + entries: HashMap, + budget: u64, + format: wgpu::TextureFormat, +} + +impl CanvasCache { + /// Creates an empty persistent cache for `format`, bounded to `budget` bytes. + pub fn new(format: wgpu::TextureFormat, budget: usize) -> Self { + Self { + entries: HashMap::new(), + budget: budget as u64, + format, + } + } + + /// Returns the configured maximum retained texture bytes. + pub fn budget(&self) -> u64 { + self.budget + } + + /// Validates and calculates the exact physical target extent for `primitive`. + /// + /// `scale_factor` converts logical bounds to physical pixels and + /// `max_dimension` enforces the active device limit. A zero-sized viewport + /// returns `Ok(None)`; invalid or over-budget requests return a message. + pub fn extent( + &self, + primitive: &GpuCanvasPrimitive, + scale_factor: f32, + max_dimension: u32, + ) -> Result, String> { + if !scale_factor.is_finite() || scale_factor <= 0.0 { + return Err(format!("invalid window scale factor {scale_factor:?}")); + } + if !primitive.resolution_scale.is_finite() || primitive.resolution_scale <= 0.0 { + return Err(format!( + "invalid resolution scale {:?}", + primitive.resolution_scale + )); + } + let logical = primitive.bounds.size; + if !logical.width.is_finite() + || !logical.height.is_finite() + || logical.width < 0.0 + || logical.height < 0.0 + { + return Err(format!("invalid logical extent {logical:?}")); + } + if logical.width == 0.0 || logical.height == 0.0 { + return Ok(None); + } + let scale = f64::from(scale_factor) * f64::from(primitive.resolution_scale); + let width = f64::from(logical.width).mul_add(scale, 0.0).ceil(); + let height = f64::from(logical.height).mul_add(scale, 0.0).ceil(); + if !width.is_finite() + || !height.is_finite() + || width < 1.0 + || height < 1.0 + || width > f64::from(u32::MAX) + || height > f64::from(u32::MAX) + { + return Err(format!( + "physical extent {width:?} by {height:?} cannot be represented" + )); + } + let size = [width as u32, height as u32]; + if size[0] > max_dimension || size[1] > max_dimension { + return Err(format!( + "physical extent {}x{} exceeds max_texture_dimension_2d {max_dimension}", + size[0], size[1] + )); + } + let bytes_per_block = u64::from(self.format.block_copy_size(None).unwrap_or(4)); + let bytes = u64::from(size[0]) + .checked_mul(u64::from(size[1])) + .and_then(|pixels| pixels.checked_mul(bytes_per_block)) + .ok_or_else(|| { + format!( + "physical extent {}x{} overflows byte size", + size[0], size[1] + ) + })?; + if bytes > self.budget { + return Err(format!( + "canvas texture needs {bytes} bytes but the cache budget is {} bytes", + self.budget + )); + } + Ok(Some(CanvasExtent { size, bytes })) + } + + /// Keeps exact required targets and evicts unused LRU entries until they fit. + pub fn reserve(&mut self, required: &HashMap) { + self.entries.retain(|key, entry| { + required + .get(key) + .is_none_or(|extent| entry.extent == *extent) + }); + let missing = required + .iter() + .filter(|(key, _)| !self.entries.contains_key(key)) + .map(|(_, extent)| extent.bytes) + .sum::(); + let mut needed = self.allocated_bytes().saturating_add(missing); + while needed > self.budget { + let Some(oldest) = self + .entries + .iter() + .filter(|(key, _)| !required.contains_key(key)) + .min_by_key(|(_, entry)| entry.last_used) + .map(|(key, _)| *key) + else { + break; + }; + if let Some(entry) = self.entries.remove(&oldest) { + needed = needed.saturating_sub(entry.extent.bytes); + } + } + } + + /// Returns the exact target for `key`, allocating and touching it when absent. + pub fn ensure( + &mut self, + device: &wgpu::Device, + pipeline: &ImagePipeline, + key: CanvasKey, + extent: CanvasExtent, + frame: u64, + ) -> &mut CacheEntry { + self.entries.entry(key).or_insert_with(|| { + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("argui-gpu-canvas-target"), + size: wgpu::Extent3d { + width: extent.size[0], + height: extent.size[1], + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: self.format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_SRC + | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + let linear = pipeline.texture_group(device, &view, ImageSampling::Linear); + let nearest = pipeline.texture_group(device, &view, ImageSampling::Nearest); + CacheEntry { + _texture: texture, + view, + linear, + nearest, + extent, + last_used: frame, + attempted_revision: None, + rendered_revision: None, + } + }); + let entry = self.entries.get_mut(&key).expect("inserted cache entry"); + entry.touch(frame); + entry + } + + /// Returns the retained entry for `key`, if one exists. + pub fn get(&self, key: CanvasKey) -> Option<&CacheEntry> { + self.entries.get(&key) + } + + /// Returns the mutable retained entry for `key`, if one exists. + pub fn get_mut(&mut self, key: CanvasKey) -> Option<&mut CacheEntry> { + self.entries.get_mut(&key) + } + + /// Returns the number of retained targets. + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Returns exact texture bytes retained by all entries. + pub fn allocated_bytes(&self) -> u64 { + self.entries.values().map(|entry| entry.extent.bytes).sum() + } +} diff --git a/crates/argui-render/src/gpu_canvas/mod.rs b/crates/argui-render/src/gpu_canvas/mod.rs new file mode 100644 index 00000000..1c943bf8 --- /dev/null +++ b/crates/argui-render/src/gpu_canvas/mod.rs @@ -0,0 +1,317 @@ +mod cache; +mod pipeline; +mod registry; +mod target; + +pub(crate) use pipeline::CanvasGpu; + +use std::{fmt, time::Duration}; + +use argui_core::Rect; +use argui_paint::{GpuCanvasId, RenderObjectId}; + +pub use registry::{ + GpuCanvasRegistration, GpuCanvasRegistry, GpuCanvasRegistryError, GpuCanvasRequirements, +}; + +/// Error explicitly returned by an application GPU-canvas factory or renderer. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GpuCanvasError { + message: String, +} + +impl GpuCanvasError { + /// Creates an application error with a concise actionable English message. + #[must_use] + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } + + /// Returns the application-provided error message. + #[must_use] + pub fn message(&self) -> &str { + &self.message + } +} + +impl fmt::Display for GpuCanvasError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl std::error::Error for GpuCanvasError {} + +/// Creates one application-owned renderer for each surface renderer that uses a registration. +pub trait GpuCanvasFactory: Send + Sync + 'static { + /// Declares required and optional device capabilities before device creation. + fn requirements(&self) -> GpuCanvasRequirements { + GpuCanvasRequirements::default() + } + + /// Creates the retained renderer using Argui's selected device and queue. + /// + /// Device resources created here may be retained by the returned renderer. + /// + /// # Errors + /// + /// Returns an application error when renderer resources cannot be created. + fn create( + &self, + context: &GpuCanvasDeviceContext<'_>, + ) -> Result, GpuCanvasError>; +} + +/// Encodes application GPU work for one retained canvas texture. +/// +/// Native renderers are `Send + Sync` so [`crate::SurfaceRenderer`] preserves +/// its native thread traits. WebGPU handles are intentionally thread-local on +/// `wasm32`, so this trait has no thread bound there. +#[cfg(not(target_arch = "wasm32"))] +pub trait GpuCanvasRenderer: Send + Sync + 'static { + /// Encodes all compute, render and copy work for a dirty canvas. + /// + /// # Errors + /// + /// Returns an application error to discard this canvas encoder and display + /// a recoverable placeholder without aborting the surrounding UI frame. + fn render(&mut self, context: &mut GpuCanvasRenderContext<'_>) -> Result<(), GpuCanvasError>; +} + +/// Encodes application GPU work for one retained canvas texture. +/// +/// WebGPU handles are thread-local in browsers. The methods and behavior are +/// otherwise identical to the native `Send + Sync` trait. +#[cfg(target_arch = "wasm32")] +pub trait GpuCanvasRenderer: 'static { + /// Encodes all compute, render and copy work for a dirty canvas. + /// + /// # Errors + /// + /// Returns an application error to discard this canvas encoder and display + /// a recoverable placeholder without aborting the surrounding UI frame. + fn render(&mut self, context: &mut GpuCanvasRenderContext<'_>) -> Result<(), GpuCanvasError>; +} + +/// Read-only device information supplied while a canvas renderer is created. +pub struct GpuCanvasDeviceContext<'a> { + pub(crate) device: &'a wgpu::Device, + pub(crate) queue: &'a wgpu::Queue, + pub(crate) features: wgpu::Features, + pub(crate) limits: wgpu::Limits, + pub(crate) format: wgpu::TextureFormat, + pub(crate) generation: u64, +} + +impl GpuCanvasDeviceContext<'_> { + /// Returns Argui's selected WGPU device. + /// + /// Application-created resources may retain their normal owned WGPU handles. + #[must_use] + pub const fn device(&self) -> &wgpu::Device { + self.device + } + + /// Returns Argui's selected WGPU queue. + /// + /// `write_buffer` and `write_texture` are supported. Calling + /// [`wgpu::Queue::submit`] is outside the GPU-canvas contract. + #[must_use] + pub const fn queue(&self) -> &wgpu::Queue { + self.queue + } + + /// Returns the feature set enabled on the selected device. + #[must_use] + pub const fn features(&self) -> wgpu::Features { + self.features + } + + /// Returns the effective limits enabled on the selected device. + #[must_use] + pub const fn limits(&self) -> &wgpu::Limits { + &self.limits + } + + /// Returns the sRGB texture format used by this surface renderer. + #[must_use] + pub const fn target_format(&self) -> wgpu::TextureFormat { + self.format + } + + /// Returns an opaque identity shared by surfaces using the same device. + #[must_use] + pub const fn device_generation(&self) -> u64 { + self.generation + } +} + +/// Borrowed WGPU state supplied while a dirty retained canvas is encoded. +pub struct GpuCanvasRenderContext<'a> { + pub(crate) device: &'a wgpu::Device, + pub(crate) queue: &'a wgpu::Queue, + pub(crate) encoder: &'a mut wgpu::CommandEncoder, + pub(crate) target: &'a wgpu::TextureView, + pub(crate) format: wgpu::TextureFormat, + pub(crate) extent: [u32; 2], + pub(crate) logical_bounds: Rect, + pub(crate) scale_factor: f32, + pub(crate) resolution_scale: f32, + pub(crate) canvas: GpuCanvasId, + pub(crate) object: RenderObjectId, + pub(crate) slot: u32, + pub(crate) frame: u64, +} + +impl GpuCanvasRenderContext<'_> { + /// Returns Argui's selected device; owned resources created from it may be retained. + #[must_use] + pub const fn device(&self) -> &wgpu::Device { + self.device + } + + /// Returns Argui's queue for resource writes. + /// + /// Calling `submit` or blocking device polling is unsupported. Encode frame + /// work through [`Self::encoder`] so Argui preserves submission ordering. + #[must_use] + pub const fn queue(&self) -> &wgpu::Queue { + self.queue + } + + /// Returns the borrowed encoder for this dirty canvas. + /// + /// The reference must not be retained after [`GpuCanvasRenderer::render`]. + pub fn encoder(&mut self) -> &mut wgpu::CommandEncoder { + self.encoder + } + + /// Returns the Argui-owned offscreen target view. + /// + /// The reference must not be retained after [`GpuCanvasRenderer::render`]. + #[must_use] + pub const fn target_view(&self) -> &wgpu::TextureView { + self.target + } + + /// Borrows the encoder and target together for beginning a render pass. + /// + /// Neither reference may be retained after [`GpuCanvasRenderer::render`]. + pub fn encoder_and_target(&mut self) -> (&mut wgpu::CommandEncoder, &wgpu::TextureView) { + (self.encoder, self.target) + } + + /// Returns the sRGB target texture format. + #[must_use] + pub const fn target_format(&self) -> wgpu::TextureFormat { + self.format + } + + /// Returns the exact physical target width and height. + #[must_use] + pub const fn physical_extent(&self) -> [u32; 2] { + self.extent + } + + /// Returns the logical canvas viewport bounds. + #[must_use] + pub const fn logical_bounds(&self) -> Rect { + self.logical_bounds + } + + /// Returns the window logical-to-physical scale factor. + #[must_use] + pub const fn scale_factor(&self) -> f32 { + self.scale_factor + } + + /// Returns the explicit application resolution multiplier. + #[must_use] + pub const fn resolution_scale(&self) -> f32 { + self.resolution_scale + } + + /// Returns the canvas registration identity. + #[must_use] + pub const fn canvas_id(&self) -> GpuCanvasId { + self.canvas + } + + /// Returns the retained UI object identity. + #[must_use] + pub const fn object_id(&self) -> RenderObjectId { + self.object + } + + /// Returns the local slot within the retained UI object. + #[must_use] + pub const fn slot(&self) -> u32 { + self.slot + } + + /// Returns the monotonically wrapping surface-renderer frame number. + #[must_use] + pub const fn frame_number(&self) -> u64 { + self.frame + } +} + +/// Stage at which a recoverable GPU-canvas failure occurred. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GpuCanvasFailureStage { + /// Display-list registration lookup or descriptor validation. + Validation, + /// Retained texture allocation or memory-budget reservation. + Allocation, + /// Application factory creation. + Creation, + /// Application command encoding. + Render, +} + +/// Change in the recoverable state of one retained GPU canvas. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GpuCanvasDiagnosticKind { + /// A new or changed failure was observed. + Failed, + /// A previously failing canvas rendered successfully. + Recovered, +} + +/// Window-local renderer diagnostic for one retained GPU-canvas instance. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GpuCanvasDiagnostic { + /// Whether this record reports failure or recovery. + pub kind: GpuCanvasDiagnosticKind, + /// Stage associated with the failure or recovery. + pub stage: GpuCanvasFailureStage, + /// Stable registration label used in logs and UI. + pub label: String, + /// Registration identity referenced by the display list. + pub canvas: GpuCanvasId, + /// Retained UI object identity. + pub object: RenderObjectId, + /// Local slot within the retained object. + pub slot: u32, + /// Concise actionable English message. + pub message: String, +} + +/// Per-frame and retained-cache GPU-canvas statistics. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct GpuCanvasStats { + /// Number of retained canvas textures. + pub entries: usize, + /// Exact retained texture bytes, excluding allocator overhead. + pub allocated_bytes: u64, + /// Dirty callbacks encoded during the frame. + pub renders_this_frame: usize, + /// Retained textures reused without invoking callbacks. + pub hits_this_frame: usize, + /// Canvas instances displaying a placeholder this frame. + pub failures_this_frame: usize, + /// CPU time spent preparing and invoking canvas callbacks. + pub encode_time: Duration, +} diff --git a/crates/argui-render/src/gpu_canvas/pipeline.rs b/crates/argui-render/src/gpu_canvas/pipeline.rs new file mode 100644 index 00000000..250402e4 --- /dev/null +++ b/crates/argui-render/src/gpu_canvas/pipeline.rs @@ -0,0 +1,556 @@ +use std::collections::{HashMap, HashSet}; + +use argui_paint::{DisplayCommand, DisplayList, GpuCanvasId, GpuCanvasPrimitive, ImageSampling}; +use web_time::Instant; + +use crate::image::pipeline::{ImageInstance, ImagePipeline}; + +use super::{ + GpuCanvasDeviceContext, GpuCanvasDiagnostic, GpuCanvasDiagnosticKind, GpuCanvasFailureStage, + GpuCanvasRegistry, GpuCanvasRenderContext, GpuCanvasRenderer, GpuCanvasStats, + cache::{CanvasCache, CanvasExtent, CanvasKey}, + target::{clear_target, placeholder_texture}, +}; + +enum RendererSlot { + Ready(Box), + Failed { revision: u64, message: String }, +} + +#[derive(Clone, Debug)] +struct FailureRecord { + stage: GpuCanvasFailureStage, + label: String, + message: String, +} + +#[derive(Clone, Copy, Eq, PartialEq)] +struct DrawSource { + key: Option, + sampling: ImageSampling, +} + +enum CommandPlan { + Selected { + key: CanvasKey, + extent: CanvasExtent, + label: String, + }, + Placeholder { + key: CanvasKey, + label: String, + stage: GpuCanvasFailureStage, + message: String, + }, + Empty, +} + +pub(crate) struct CanvasPreparation { + pub changed: bool, + pub command_buffers: Vec, +} + +pub(crate) struct CanvasGpu { + registry: GpuCanvasRegistry, + renderers: HashMap, + cache: CanvasCache, + pipeline: ImagePipeline, + _placeholder_texture: wgpu::Texture, + placeholder_linear: wgpu::BindGroup, + placeholder_nearest: wgpu::BindGroup, + draws: Vec, + failures: HashMap, + diagnostics: Vec, + stats: GpuCanvasStats, + format: wgpu::TextureFormat, + generation: u64, + max_dimension: u32, + frame: u64, +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl CanvasGpu { + /// Creates a surface-local canvas manager and deterministic fallback texture. + pub fn new( + device: &wgpu::Device, + queue: &wgpu::Queue, + format: wgpu::TextureFormat, + generation: u64, + registry: GpuCanvasRegistry, + budget: usize, + ) -> Self { + let pipeline = ImagePipeline::new(device, format); + let placeholder_texture = placeholder_texture(device, queue, format); + let placeholder_view = placeholder_texture.create_view(&Default::default()); + let placeholder_linear = + pipeline.texture_group(device, &placeholder_view, ImageSampling::Linear); + let placeholder_nearest = + pipeline.texture_group(device, &placeholder_view, ImageSampling::Nearest); + Self { + registry, + renderers: HashMap::new(), + cache: CanvasCache::new(format, budget), + pipeline, + _placeholder_texture: placeholder_texture, + placeholder_linear, + placeholder_nearest, + draws: Vec::new(), + failures: HashMap::new(), + diagnostics: Vec::new(), + stats: GpuCanvasStats::default(), + format, + generation, + max_dimension: device.limits().max_texture_dimension_2d, + frame: 0, + } + } + + /// Plans, renders and prepares compositor data for `display_list` canvases. + /// + /// The returned command buffers contain only successful canvas work and + /// must be submitted before the main compositor buffer. + pub fn prepare( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + display_list: &DisplayList, + scale_factor: f32, + ) -> CanvasPreparation { + let started = Instant::now(); + self.frame = self.frame.wrapping_add(1); + self.stats = GpuCanvasStats::default(); + let previous_draws = std::mem::take(&mut self.draws); + let canvases = display_list + .commands() + .iter() + .filter_map(|command| match command { + DisplayCommand::GpuCanvas(canvas) => Some(canvas), + _ => None, + }) + .collect::>(); + let (plans, required) = self.plan(&canvases, scale_factor); + self.cache.reserve(&required); + for (key, extent) in &required { + self.cache + .ensure(device, &self.pipeline, *key, *extent, self.frame); + } + + let mut changed = false; + let mut command_buffers = Vec::new(); + for (canvas, plan) in canvases.iter().zip(plans) { + match plan { + CommandPlan::Selected { key, extent, label } => { + let source = self.prepare_selected( + device, + queue, + canvas, + key, + extent, + &label, + scale_factor, + &mut command_buffers, + &mut changed, + ); + self.draws.push(source); + } + CommandPlan::Placeholder { + key, + label, + stage, + message, + } => { + self.stats.failures_this_frame += 1; + self.record_failure(key, canvas, label, stage, message); + self.draws.push(DrawSource { + key: None, + sampling: canvas.sampling, + }); + } + CommandPlan::Empty => self.draws.push(DrawSource { + key: None, + sampling: canvas.sampling, + }), + } + } + + let mut instances = Vec::with_capacity(canvases.len()); + let mut clips = Vec::new(); + for canvas in canvases { + instances.push(ImageInstance::from_gpu_canvas( + canvas, + scale_factor, + &mut clips, + )); + } + changed |= self.draws != previous_draws; + changed |= self.pipeline.write(device, queue, &instances, &clips); + self.stats.entries = self.cache.len(); + self.stats.allocated_bytes = self.cache.allocated_bytes(); + self.stats.encode_time = started.elapsed(); + CanvasPreparation { + changed, + command_buffers, + } + } + + /// Selects a deterministic within-budget subset of visible `canvases`. + fn plan( + &self, + canvases: &[&GpuCanvasPrimitive], + scale_factor: f32, + ) -> (Vec, HashMap) { + let mut plans = Vec::with_capacity(canvases.len()); + let mut required = HashMap::new(); + let mut seen = HashSet::new(); + let mut reserved = 0_u64; + for canvas in canvases { + let key = CanvasKey::from(*canvas); + let label = self.registry.get(canvas.canvas).map_or_else( + || format!("unregistered-{}", canvas.canvas.get()), + |registration| registration.label().to_owned(), + ); + if !seen.insert(key) { + plans.push(CommandPlan::Placeholder { + key, + label, + stage: GpuCanvasFailureStage::Validation, + message: format!( + "retained object {} slot {} appears more than once in the frame", + key.object.value, key.slot + ), + }); + continue; + } + if self.registry.get(canvas.canvas).is_none() { + plans.push(CommandPlan::Placeholder { + key, + label, + stage: GpuCanvasFailureStage::Validation, + message: "the display list references a missing registration".into(), + }); + continue; + } + let extent = match self.cache.extent(canvas, scale_factor, self.max_dimension) { + Ok(Some(extent)) => extent, + Ok(None) => { + plans.push(CommandPlan::Empty); + continue; + } + Err(message) => { + plans.push(CommandPlan::Placeholder { + key, + label, + stage: GpuCanvasFailureStage::Allocation, + message, + }); + continue; + } + }; + let Some(next) = reserved.checked_add(extent.bytes) else { + plans.push(CommandPlan::Placeholder { + key, + label, + stage: GpuCanvasFailureStage::Allocation, + message: "visible canvas byte demand overflowed".into(), + }); + continue; + }; + if next > self.cache.budget() { + plans.push(CommandPlan::Placeholder { + key, + label, + stage: GpuCanvasFailureStage::Allocation, + message: format!( + "visible canvases need more than the {} byte cache budget", + self.cache.budget() + ), + }); + continue; + } + reserved = next; + required.insert(key, extent); + plans.push(CommandPlan::Selected { key, extent, label }); + } + (plans, required) + } + + #[allow(clippy::too_many_arguments)] + /// Prepares one validated canvas and returns the compositor texture source. + fn prepare_selected( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + canvas: &GpuCanvasPrimitive, + key: CanvasKey, + extent: CanvasExtent, + label: &str, + scale_factor: f32, + command_buffers: &mut Vec, + changed: &mut bool, + ) -> DrawSource { + self.ensure_renderer(device, queue, canvas.canvas, canvas.content_revision); + let renderer_error = match self.renderers.get(&canvas.canvas) { + Some(RendererSlot::Failed { message, .. }) => Some(message.clone()), + _ => None, + }; + if let Some(message) = renderer_error { + self.stats.failures_this_frame += 1; + self.record_failure( + key, + canvas, + label.into(), + GpuCanvasFailureStage::Creation, + message, + ); + return DrawSource { + key: None, + sampling: canvas.sampling, + }; + } + + let needs_render = self + .cache + .get(key) + .is_some_and(|entry| entry.needs_render(canvas.content_revision)); + if !needs_render { + let valid = self + .cache + .get(key) + .is_some_and(super::cache::CacheEntry::valid); + if valid { + self.stats.hits_this_frame += 1; + self.record_recovery(key, canvas, label); + return DrawSource { + key: Some(key), + sampling: canvas.sampling, + }; + } + self.stats.failures_this_frame += 1; + return DrawSource { + key: None, + sampling: canvas.sampling, + }; + } + + self.stats.renders_this_frame += 1; + *changed = true; + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("argui-gpu-canvas-encoder"), + }); + let target = self + .cache + .get(key) + .expect("reserved canvas target") + .target(); + clear_target(&mut encoder, target); + let result = { + let renderer = match self.renderers.get_mut(&canvas.canvas) { + Some(RendererSlot::Ready(renderer)) => renderer, + _ => unreachable!("renderer creation was checked"), + }; + renderer.render(&mut GpuCanvasRenderContext { + device, + queue, + encoder: &mut encoder, + target, + format: self.format, + extent: extent.size, + logical_bounds: canvas.bounds, + scale_factor, + resolution_scale: canvas.resolution_scale, + canvas: canvas.canvas, + object: canvas.object, + slot: canvas.slot, + frame: self.frame, + }) + }; + match result { + Ok(()) => { + command_buffers.push(encoder.finish()); + self.cache + .get_mut(key) + .expect("reserved canvas target") + .mark_success(canvas.content_revision); + self.record_recovery(key, canvas, label); + DrawSource { + key: Some(key), + sampling: canvas.sampling, + } + } + Err(error) => { + self.cache + .get_mut(key) + .expect("reserved canvas target") + .mark_failure(canvas.content_revision); + self.stats.failures_this_frame += 1; + self.record_failure( + key, + canvas, + label.into(), + GpuCanvasFailureStage::Render, + error.to_string(), + ); + DrawSource { + key: None, + sampling: canvas.sampling, + } + } + } + } + + /// Lazily creates `id`, retrying a failed factory only for a new `revision`. + fn ensure_renderer( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + id: GpuCanvasId, + revision: u64, + ) { + let reusable = match self.renderers.get(&id) { + Some(RendererSlot::Ready(_)) => true, + Some(RendererSlot::Failed { + revision: attempted, + .. + }) => *attempted == revision, + None => false, + }; + if reusable { + return; + } + let registration = self.registry.get(id).expect("validated registration"); + let context = GpuCanvasDeviceContext { + device, + queue, + features: device.features(), + limits: device.limits(), + format: self.format, + generation: self.generation, + }; + let renderer = match registration.factory().create(&context) { + Ok(renderer) => RendererSlot::Ready(renderer), + Err(error) => RendererSlot::Failed { + revision, + message: error.to_string(), + }, + }; + self.renderers.insert(id, renderer); + } + + /// Records and emits a changed failure for retained `key` exactly once. + fn record_failure( + &mut self, + key: CanvasKey, + canvas: &GpuCanvasPrimitive, + label: String, + stage: GpuCanvasFailureStage, + message: String, + ) { + if self + .failures + .get(&key) + .is_some_and(|current| current.stage == stage && current.message == message) + { + return; + } + let readable = format!("GPU canvas '{label}' failed during {stage:?}: {message}"); + eprintln!("{readable}"); + self.failures.insert( + key, + FailureRecord { + stage, + label: label.clone(), + message: message.clone(), + }, + ); + self.diagnostics.push(GpuCanvasDiagnostic { + kind: GpuCanvasDiagnosticKind::Failed, + stage, + label, + canvas: canvas.canvas, + object: canvas.object, + slot: canvas.slot, + message: readable, + }); + } + + /// Emits one recovery when `key` previously had a recorded failure. + fn record_recovery( + &mut self, + key: CanvasKey, + canvas: &GpuCanvasPrimitive, + fallback_label: &str, + ) { + let Some(previous) = self.failures.remove(&key) else { + return; + }; + let label = if previous.label.is_empty() { + fallback_label.to_owned() + } else { + previous.label + }; + self.diagnostics.push(GpuCanvasDiagnostic { + kind: GpuCanvasDiagnosticKind::Recovered, + stage: previous.stage, + label: label.clone(), + canvas: canvas.canvas, + object: canvas.object, + slot: canvas.slot, + message: format!("GPU canvas '{label}' recovered"), + }); + } + + /// Starts the shared textured-quad compositor's frame state. + pub fn begin_frame(&mut self) { + self.pipeline.begin_frame(); + } + + /// Clears transient draw state while retaining cache totals for a skipped frame. + pub fn clear_frame_stats(&mut self) { + self.draws.clear(); + self.stats = GpuCanvasStats { + entries: self.cache.len(), + allocated_bytes: self.cache.allocated_bytes(), + ..GpuCanvasStats::default() + }; + } + + /// Stores `region` and returns its dynamic uniform offset for effect rendering. + pub fn target_offset(&mut self, queue: &wgpu::Queue, region: [f32; 4]) -> u32 { + self.pipeline.target_offset(queue, region) + } + + /// Draws prepared `instances` for `canvas_index` into `pass`. + pub fn draw<'pass>( + &'pass self, + pass: &mut wgpu::RenderPass<'pass>, + canvas_index: u32, + instances: std::ops::Range, + viewport_offset: u32, + ) { + let Some(source) = self.draws.get(canvas_index as usize) else { + return; + }; + let group = source + .key + .and_then(|key| { + self.cache + .get(key) + .map(|entry| entry.group(source.sampling)) + }) + .unwrap_or(match source.sampling { + ImageSampling::Nearest => &self.placeholder_nearest, + ImageSampling::Linear => &self.placeholder_linear, + }); + self.pipeline.draw(pass, group, instances, viewport_offset); + } + + /// Returns the current frame and retained-cache statistics. + pub fn stats(&self) -> GpuCanvasStats { + self.stats + } + + /// Drains failure and recovery diagnostics accumulated since the last call. + pub fn take_diagnostics(&mut self) -> Vec { + std::mem::take(&mut self.diagnostics) + } +} diff --git a/crates/argui-render/src/gpu_canvas/registry.rs b/crates/argui-render/src/gpu_canvas/registry.rs new file mode 100644 index 00000000..19dde985 --- /dev/null +++ b/crates/argui-render/src/gpu_canvas/registry.rs @@ -0,0 +1,344 @@ +use std::{collections::HashMap, fmt, sync::Arc}; + +use argui_paint::GpuCanvasId; + +use super::GpuCanvasFactory; + +/// Device capabilities declared by one GPU-canvas factory. +#[derive(Clone, Debug, PartialEq)] +pub struct GpuCanvasRequirements { + /// Features that must be enabled or renderer initialization fails. + pub required_features: wgpu::Features, + /// Features enabled when supported without forcing renderer fallback. + pub optional_features: wgpu::Features, + /// Minimum effective limits needed by the factory. + pub required_limits: wgpu::Limits, + /// Human-readable reason for any non-baseline capability. + pub reason: Option, +} + +impl Default for GpuCanvasRequirements { + fn default() -> Self { + Self { + required_features: wgpu::Features::empty(), + optional_features: wgpu::Features::empty(), + required_limits: wgpu::Limits::default(), + reason: None, + } + } +} + +impl GpuCanvasRequirements { + /// Adds features that must be enabled on the selected device. + #[must_use] + pub fn required_features(mut self, features: wgpu::Features) -> Self { + self.required_features |= features; + self + } + + /// Adds features that should be enabled only when the adapter supports them. + #[must_use] + pub fn optional_features(mut self, features: wgpu::Features) -> Self { + self.optional_features |= features; + self + } + + /// Merges required limits using WGPU's direction-aware limit ordering. + #[must_use] + pub fn required_limits(mut self, limits: wgpu::Limits) -> Self { + self.required_limits = self.required_limits.or_better_values_from(&limits); + self + } + + /// Explains why the factory needs capabilities beyond the WebGPU baseline. + #[must_use] + pub fn reason(mut self, reason: impl Into) -> Self { + self.reason = Some(reason.into()); + self + } + + /// Returns whether the declaration needs only WGPU baseline capabilities. + fn is_baseline(&self) -> bool { + self.required_features.is_empty() + && self.optional_features.is_empty() + && self.required_limits == wgpu::Limits::default() + } +} + +struct RegistrationInner { + id: GpuCanvasId, + label: String, + factory: Arc, + requirements: GpuCanvasRequirements, +} + +/// Cloneable GPU-canvas registration with stable process-local identity. +#[derive(Clone)] +pub struct GpuCanvasRegistration(Arc); + +impl fmt::Debug for GpuCanvasRegistration { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("GpuCanvasRegistration") + .field("id", &self.id()) + .field("label", &self.label()) + .field("requirements", self.requirements()) + .finish_non_exhaustive() + } +} + +impl GpuCanvasRegistration { + /// Creates a registration for `factory` with a stable diagnostic `label`. + /// + /// Label and requirement validation happens when constructing a + /// [`GpuCanvasRegistry`]. Cloning this value preserves its identity. + #[must_use] + pub fn new(label: impl Into, factory: impl GpuCanvasFactory) -> Self { + let requirements = factory.requirements(); + Self(Arc::new(RegistrationInner { + id: GpuCanvasId::fresh(), + label: label.into(), + factory: Arc::new(factory), + requirements, + })) + } + + /// Returns the opaque identity referenced by [`argui_paint::GpuCanvasPrimitive`]. + #[must_use] + pub fn id(&self) -> GpuCanvasId { + self.0.id + } + + /// Returns the stable non-empty diagnostic label. + #[must_use] + pub fn label(&self) -> &str { + &self.0.label + } + + /// Returns the capabilities captured when the registration was created. + #[must_use] + pub fn requirements(&self) -> &GpuCanvasRequirements { + &self.0.requirements + } + + /// Returns the immutable application factory owned by this registration. + pub(crate) fn factory(&self) -> &dyn GpuCanvasFactory { + self.0.factory.as_ref() + } + + /// Returns whether `other` is a clone of this exact registration. + fn same_registration(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.0, &other.0) + } +} + +/// Validation error returned while building an immutable canvas registry. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum GpuCanvasRegistryError { + /// A label was empty, untrimmed or contained control characters. + InvalidLabel(String), + /// Two distinct registrations used the same diagnostic label. + DuplicateLabel(String), + /// A repeated identity did not refer to the same cloned registration. + InconsistentDuplicate(GpuCanvasId), + /// Non-baseline capabilities did not include an explanatory reason. + MissingRequirementReason(String), +} + +impl fmt::Display for GpuCanvasRegistryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidLabel(label) => write!( + formatter, + "GPU-canvas registration label must be non-empty, trimmed and free of control characters: {label:?}" + ), + Self::DuplicateLabel(label) => { + write!( + formatter, + "GPU-canvas registration label '{label}' is duplicated" + ) + } + Self::InconsistentDuplicate(id) => write!( + formatter, + "GPU-canvas registration {} has inconsistent duplicate definitions", + id.get() + ), + Self::MissingRequirementReason(label) => write!( + formatter, + "GPU-canvas registration '{label}' must explain its non-baseline device requirements" + ), + } + } +} + +impl std::error::Error for GpuCanvasRegistryError {} + +/// Immutable validated collection of application GPU-canvas registrations. +#[derive(Clone, Debug, Default)] +pub struct GpuCanvasRegistry { + registrations: Arc<[GpuCanvasRegistration]>, +} + +impl GpuCanvasRegistry { + /// Validates, deduplicates cloned registrations and builds a registry. + /// + /// # Errors + /// + /// Returns an error for invalid labels, duplicate labels, inconsistent + /// identities or unexplained non-baseline requirements. + pub fn new( + registrations: impl IntoIterator, + ) -> Result { + let mut by_id: HashMap = HashMap::new(); + let mut labels: HashMap = HashMap::new(); + for registration in registrations { + let label = registration.label(); + if label.is_empty() || label.trim() != label || label.chars().any(char::is_control) { + return Err(GpuCanvasRegistryError::InvalidLabel(label.into())); + } + if let Some(existing) = by_id.get(®istration.id()) { + if !existing.same_registration(®istration) { + return Err(GpuCanvasRegistryError::InconsistentDuplicate( + registration.id(), + )); + } + continue; + } + if labels.insert(label.into(), registration.id()).is_some() { + return Err(GpuCanvasRegistryError::DuplicateLabel(label.into())); + } + if !registration.requirements().is_baseline() + && registration + .requirements() + .reason + .as_deref() + .is_none_or(|reason| reason.trim().is_empty()) + { + return Err(GpuCanvasRegistryError::MissingRequirementReason( + label.into(), + )); + } + by_id.insert(registration.id(), registration); + } + let mut registrations = by_id.into_values().collect::>(); + registrations.sort_by_key(GpuCanvasRegistration::id); + Ok(Self { + registrations: registrations.into(), + }) + } + + /// Returns whether no factories are registered. + #[must_use] + pub fn is_empty(&self) -> bool { + self.registrations.is_empty() + } + + /// Returns the number of distinct registrations. + #[must_use] + pub fn len(&self) -> usize { + self.registrations.len() + } + + /// Returns registrations in stable identity order. + #[must_use] + pub fn registrations(&self) -> &[GpuCanvasRegistration] { + &self.registrations + } + + /// Finds the registration for opaque `id`, if the registry contains it. + pub(crate) fn get(&self, id: GpuCanvasId) -> Option<&GpuCanvasRegistration> { + self.registrations + .binary_search_by_key(&id, GpuCanvasRegistration::id) + .ok() + .map(|index| &self.registrations[index]) + } + + /// Aggregates and validates requirements against one adapter attempt. + /// + /// Optional features are intersected with `adapter_features`; `profiling` + /// requests timestamp queries when available. + pub(crate) fn device_requirements( + &self, + adapter_features: wgpu::Features, + adapter_limits: &wgpu::Limits, + profiling: bool, + ) -> Result { + let mut required_features = wgpu::Features::empty(); + let mut optional_features = wgpu::Features::empty(); + let mut limits = wgpu::Limits::default(); + for registration in self.registrations() { + let requirements = registration.requirements(); + let missing = requirements.required_features - adapter_features; + if !missing.is_empty() { + return Err(crate::RendererError::GpuCanvasCapability { + canvas: registration.label().into(), + message: format!("required features {missing:?} are unavailable"), + }); + } + let mut failure = None; + requirements.required_limits.check_limits_with_fail_fn( + adapter_limits, + true, + |name, requested, available| failure = Some((name, requested, available)), + ); + if let Some((name, requested, available)) = failure { + return Err(crate::RendererError::GpuCanvasCapability { + canvas: registration.label().into(), + message: format!( + "required limit {name}={requested} is unsupported; adapter provides {available}" + ), + }); + } + required_features |= requirements.required_features; + optional_features |= requirements.optional_features; + limits = limits.or_better_values_from(&requirements.required_limits); + } + if profiling && adapter_features.contains(wgpu::Features::TIMESTAMP_QUERY) { + optional_features |= wgpu::Features::TIMESTAMP_QUERY; + } + Ok(DeviceRequirements { + features: required_features | (optional_features & adapter_features), + limits: limits + .using_resolution(adapter_limits.clone()) + .using_alignment(adapter_limits.clone()), + }) + } + + /// Validates that an already-created shared device satisfies the registry. + pub(crate) fn validate_device( + &self, + features: wgpu::Features, + limits: &wgpu::Limits, + ) -> Result<(), crate::RendererError> { + for registration in self.registrations() { + let requirements = registration.requirements(); + let missing = requirements.required_features - features; + if !missing.is_empty() { + return Err(crate::RendererError::IncompatibleGpuCanvasDevice { + canvas: registration.label().into(), + message: format!("shared device did not enable required features {missing:?}"), + }); + } + let mut failure = None; + requirements.required_limits.check_limits_with_fail_fn( + limits, + true, + |name, requested, available| failure = Some((name, requested, available)), + ); + if let Some((name, requested, available)) = failure { + return Err(crate::RendererError::IncompatibleGpuCanvasDevice { + canvas: registration.label().into(), + message: format!( + "shared device limit {name}={available} does not satisfy {requested}" + ), + }); + } + } + Ok(()) + } +} + +pub(crate) struct DeviceRequirements { + pub features: wgpu::Features, + pub limits: wgpu::Limits, +} diff --git a/crates/argui-render/src/gpu_canvas/target.rs b/crates/argui-render/src/gpu_canvas/target.rs new file mode 100644 index 00000000..f2d9b864 --- /dev/null +++ b/crates/argui-render/src/gpu_canvas/target.rs @@ -0,0 +1,70 @@ +/// Clears a dirty canvas target to the defined transparent initial value. +pub(super) fn clear_target(encoder: &mut wgpu::CommandEncoder, target: &wgpu::TextureView) { + drop(encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("argui-gpu-canvas-clear"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: target, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + })], + ..Default::default() + })); +} + +/// Creates the deterministic checkerboard shown for a failed canvas. +pub(super) fn placeholder_texture( + device: &wgpu::Device, + queue: &wgpu::Queue, + format: wgpu::TextureFormat, +) -> wgpu::Texture { + const SIDE: u32 = 8; + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("argui-gpu-canvas-placeholder"), + size: wgpu::Extent3d { + width: SIDE, + height: SIDE, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + let mut pixels = Vec::with_capacity((SIDE * SIDE * 4) as usize); + for y in 0..SIDE { + for x in 0..SIDE { + let pixel = if (x / 2 + y / 2) % 2 == 0 { + [255, 0, 255, 255] + } else { + [28, 28, 32, 255] + }; + pixels.extend_from_slice(&pixel); + } + } + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &pixels, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(SIDE * 4), + rows_per_image: Some(SIDE), + }, + wgpu::Extent3d { + width: SIDE, + height: SIDE, + depth_or_array_layers: 1, + }, + ); + texture +} diff --git a/crates/argui-render/src/image/mod.rs b/crates/argui-render/src/image/mod.rs index a440db7a..78373234 100644 --- a/crates/argui-render/src/image/mod.rs +++ b/crates/argui-render/src/image/mod.rs @@ -1,4 +1,4 @@ -mod pipeline; +pub(crate) mod pipeline; use std::collections::HashMap; diff --git a/crates/argui-render/src/image/pipeline.rs b/crates/argui-render/src/image/pipeline.rs index 87a0a793..9eb1f3ff 100644 --- a/crates/argui-render/src/image/pipeline.rs +++ b/crates/argui-render/src/image/pipeline.rs @@ -1,11 +1,11 @@ use std::{mem::size_of, ops::Range}; -use argui_paint::{ImageFit, ImagePrimitive, ImageSampling}; +use argui_paint::{GpuCanvasPrimitive, ImageFit, ImagePrimitive, ImageSampling}; use bytemuck::{Pod, Zeroable}; #[repr(C)] #[derive(Clone, Copy, Pod, Zeroable)] -pub(super) struct ImageClip { +pub(crate) struct ImageClip { inverse_a: [f32; 4], inverse_b: [f32; 4], bounds: [f32; 4], @@ -14,7 +14,7 @@ pub(super) struct ImageClip { #[repr(C)] #[derive(Clone, Copy, Pod, Zeroable)] -pub(super) struct ImageInstance { +pub(crate) struct ImageInstance { rect: [f32; 4], uv: [f32; 4], radii: [f32; 4], @@ -65,6 +65,44 @@ impl ImageInstance { clip_meta: [start, clips.len() as u32 - start, 0, 0], } } + + /// Builds compositor geometry for `canvas` at physical `scale` and appends its clips. + pub(crate) fn from_gpu_canvas( + canvas: &GpuCanvasPrimitive, + scale: f32, + clips: &mut Vec, + ) -> Self { + let start = clips.len() as u32; + clips.extend(canvas.clips.regions().iter().filter_map(|clip| { + let inverse = clip.transform.scaled(scale).inverse()?; + Some(ImageClip { + inverse_a: inverse.matrix, + inverse_b: [inverse.translation.x, inverse.translation.y, 0.0, 0.0], + bounds: [ + clip.bounds.origin.x * scale, + clip.bounds.origin.y * scale, + clip.bounds.size.width * scale, + clip.bounds.size.height * scale, + ], + radii: clip.radii.as_array().map(|radius| radius * scale), + }) + })); + let transform = canvas.transform.scaled(scale); + Self { + rect: [ + canvas.bounds.origin.x * scale, + canvas.bounds.origin.y * scale, + canvas.bounds.size.width * scale, + canvas.bounds.size.height * scale, + ], + uv: [0.0, 0.0, 1.0, 1.0], + radii: canvas.radii.as_array().map(|value| value * scale), + transform_a: transform.matrix, + transform_b: [transform.translation.x, transform.translation.y, 0.0, 0.0], + params: [canvas.opacity, 0.0, 0.0, 0.0], + clip_meta: [start, clips.len() as u32 - start, 0, 0], + } + } } fn fit(fit: ImageFit, image: [u32; 2], rect: &mut [f32; 4], uv: &mut [f32; 4]) { @@ -107,7 +145,7 @@ struct Viewport { const VIEWPORT_STRIDE: u64 = 256; const VIEWPORT_CAPACITY: u64 = 1024; -pub(super) struct ImagePipeline { +pub(crate) struct ImagePipeline { pipeline: wgpu::RenderPipeline, scene_layout: wgpu::BindGroupLayout, texture_layout: wgpu::BindGroupLayout, @@ -124,7 +162,8 @@ pub(super) struct ImagePipeline { #[cfg_attr(coverage_nightly, coverage(off))] impl ImagePipeline { - pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self { + /// Creates the shared textured-quad pipeline for `format`. + pub(crate) fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("argui-image-shader"), source: wgpu::ShaderSource::Wgsl( @@ -228,7 +267,8 @@ impl ImagePipeline { } } - pub fn texture_group( + /// Creates a sampled-texture bind group for `view` and `sampling`. + pub(crate) fn texture_group( &self, device: &wgpu::Device, view: &wgpu::TextureView, @@ -259,7 +299,8 @@ impl ImagePipeline { }) } - pub fn write( + /// Uploads changed compositor `instances` and `clips`, returning whether data changed. + pub(crate) fn write( &mut self, device: &wgpu::Device, queue: &wgpu::Queue, @@ -304,10 +345,12 @@ impl ImagePipeline { instances_changed || clips_changed } - pub fn begin_frame(&mut self) { + /// Resets transient target-region uniform offsets for a new frame. + pub(crate) fn begin_frame(&mut self) { self.next_viewport = 0; } - pub fn target_offset(&mut self, queue: &wgpu::Queue, region: [f32; 4]) -> u32 { + /// Uploads `region` and returns its dynamic uniform offset. + pub(crate) fn target_offset(&mut self, queue: &wgpu::Queue, region: [f32; 4]) -> u32 { let offset = self.next_viewport % VIEWPORT_CAPACITY * VIEWPORT_STRIDE; self.next_viewport += 1; queue.write_buffer( @@ -320,7 +363,8 @@ impl ImagePipeline { ); offset as u32 } - pub fn draw<'a>( + /// Draws `instances` with `texture_group` and one target-region offset. + pub(crate) fn draw<'a>( &'a self, pass: &mut wgpu::RenderPass<'a>, texture: &'a wgpu::BindGroup, diff --git a/crates/argui-render/src/lib.rs b/crates/argui-render/src/lib.rs index f9bec4ad..3417d994 100644 --- a/crates/argui-render/src/lib.rs +++ b/crates/argui-render/src/lib.rs @@ -7,6 +7,7 @@ mod effect; mod effect_graph; mod effect_plan; mod error; +mod gpu_canvas; mod gpu_profile; mod image; mod offscreen; @@ -22,6 +23,12 @@ mod vector; pub use config::{EffectQuality, EffectQualitySettings, RendererConfig, SurfaceAlphaMode}; pub use effect_graph::{EffectGraphAnalysis, EffectGraphStats, analyze_display_list}; pub use error::{RendererAttemptFailure, RendererError}; +pub use gpu_canvas::{ + GpuCanvasDeviceContext, GpuCanvasDiagnostic, GpuCanvasDiagnosticKind, GpuCanvasError, + GpuCanvasFactory, GpuCanvasFailureStage, GpuCanvasRegistration, GpuCanvasRegistry, + GpuCanvasRegistryError, GpuCanvasRenderContext, GpuCanvasRenderer, GpuCanvasRequirements, + GpuCanvasStats, +}; pub use offscreen::TexturePoolStats; pub use profile::{ AdapterProfile, GpuFrameProfile, GpuPassProfile, RenderProfile, VectorAtlasStats, @@ -31,3 +38,5 @@ pub use registry::{ EffectRegistry, }; pub use surface::{RenderStatus, RendererDevice, SurfaceRenderer}; +/// Exact WGPU version used by Argui's public GPU-canvas contexts. +pub use wgpu; diff --git a/crates/argui-render/src/profile.rs b/crates/argui-render/src/profile.rs index b7355324..72ae3dd6 100644 --- a/crates/argui-render/src/profile.rs +++ b/crates/argui-render/src/profile.rs @@ -2,7 +2,7 @@ use std::time::Duration; use web_time::Instant; -use crate::{EffectGraphStats, TexturePoolStats}; +use crate::{EffectGraphStats, GpuCanvasStats, TexturePoolStats}; use argui_paint::RenderObjectId; @@ -55,6 +55,7 @@ pub struct RenderProfile { pub effects: EffectGraphStats, pub texture_pool: TexturePoolStats, pub vector_atlas: VectorAtlasStats, + pub gpu_canvases: GpuCanvasStats, pub direct_surface: bool, pub adapter: AdapterProfile, pub gpu: Option, diff --git a/crates/argui-render/src/surface.rs b/crates/argui-render/src/surface.rs index c8516d14..3f319f9e 100644 --- a/crates/argui-render/src/surface.rs +++ b/crates/argui-render/src/surface.rs @@ -1,6 +1,13 @@ use argui_paint::{DisplayList, ImageAsset, VectorAsset}; use argui_text::{PreparedText, TextEngine}; -use std::{collections::HashMap, mem::size_of, sync::Arc}; +use std::{ + collections::HashMap, + mem::size_of, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, +}; use wgpu::{ CurrentSurfaceTexture, LoadOp, Operations, RenderPassColorAttachment, RenderPassDescriptor, StoreOp, SurfaceTarget, TextureFormat, TextureViewDescriptor, @@ -11,6 +18,7 @@ use crate::{ batch::{DrawBatch, DrawKind, build_batches}, effect::EffectGpu, effect_graph::EffectGraph, + gpu_canvas::CanvasGpu, gpu_profile::GpuProfiler, image::ImageGpu, offscreen::{TexturePool, TexturePoolStats}, @@ -59,6 +67,20 @@ struct RendererDeviceInner { device: wgpu::Device, queue: wgpu::Queue, initialization_fallback: Option, + generation: u64, +} + +static NEXT_DEVICE_GENERATION: AtomicU64 = AtomicU64::new(1); + +/// Allocates an opaque process-local device generation for cache diagnostics. +/// +/// # Panics +/// +/// Panics if the generation identity space is exhausted. +fn next_device_generation() -> u64 { + let generation = NEXT_DEVICE_GENERATION.fetch_add(1, Ordering::Relaxed); + assert_ne!(generation, u64::MAX, "renderer device generation exhausted"); + generation } pub struct SurfaceRenderer { @@ -75,6 +97,7 @@ pub struct SurfaceRenderer { text: TextGpu, image: ImageGpu, vector: VectorGpu, + gpu_canvas: CanvasGpu, effect: EffectGpu, offscreen: TexturePool, gpu_profiler: GpuProfiler, @@ -119,6 +142,9 @@ impl SurfaceRenderer { height: u32, renderer_config: RendererConfig, ) -> Result { + renderer_config + .gpu_canvases + .validate_device(device.features(), &device.limits())?; let mut surface_config = surface .get_default_config(&adapter, width.max(1), height.max(1)) .ok_or(RendererError::UnsupportedSurface)?; @@ -141,6 +167,14 @@ impl SurfaceRenderer { let text = TextGpu::new(&device, target_format); let image = ImageGpu::new(&device, target_format, renderer_config.image_cache_bytes); let vector = VectorGpu::new(&device, target_format); + let gpu_canvas = CanvasGpu::new( + &device, + &queue, + target_format, + device_handle.0.generation, + renderer_config.gpu_canvases.clone(), + renderer_config.gpu_canvas_cache_bytes, + ); let maximum_parameter_words = renderer_config .effects .definitions() @@ -175,6 +209,7 @@ impl SurfaceRenderer { text, image, vector, + gpu_canvas, effect, offscreen, gpu_profiler, @@ -210,13 +245,16 @@ impl SurfaceRenderer { let gpu_capture = self.gpu_profiler.begin_frame(self.profiling_active); let mut graph_stats = EffectGraphStats::default(); let mut effect_graph = None; + let mut canvas_commands = Vec::new(); match content { FrameContent::None => { self.vector.clear_frame_stats(); + self.gpu_canvas.clear_frame_stats(); self.batches.clear(); } FrameContent::Text { engine, text } => { self.vector.clear_frame_stats(); + self.gpu_canvas.clear_frame_stats(); let draw = self.text.prepare(&self.device, &self.queue, engine, text)?; let range = draw.all(); self.batches.clear(); @@ -242,6 +280,10 @@ impl SurfaceRenderer { let vector_changed = self.vector .prepare(&self.device, &self.queue, display_list, scale_factor)?; + let canvas = + self.gpu_canvas + .prepare(&self.device, &self.queue, display_list, scale_factor); + canvas_commands = canvas.command_buffers; let draw = self.text.prepare_ui( &self.device, &self.queue, @@ -250,7 +292,12 @@ impl SurfaceRenderer { display_list, scale_factor, )?; - if quad_changed || image_changed || vector_changed || draw.changed() { + if quad_changed + || image_changed + || vector_changed + || canvas.changed + || draw.changed() + { self.content_revision = self.content_revision.wrapping_add(1); } build_batches(display_list, draw.ranges(), &mut self.batches); @@ -286,6 +333,7 @@ impl SurfaceRenderer { self.text.begin_frame(); self.image.begin_frame(); self.vector.begin_frame(); + self.gpu_canvas.begin_frame(); if let Some(graph) = effect_graph && graph.needs_offscreen_root() { @@ -302,7 +350,8 @@ impl SurfaceRenderer { if let Some(capture) = gpu_capture { capture.finish(&mut encoder); } - self.queue.submit([encoder.finish()]); + canvas_commands.push(encoder.finish()); + self.queue.submit(canvas_commands); let _ = self.device.poll(wgpu::PollType::Poll); self.finish_profile(profiler, viewport, graph_stats); self.queue.present(frame); @@ -313,6 +362,7 @@ impl SurfaceRenderer { let text_offset = self.text.target_offset(&self.queue, target.as_f32()); let image_offset = self.image.target_offset(&self.queue, target.as_f32()); let vector_offset = self.vector.target_offset(&self.queue, target.as_f32()); + let canvas_offset = self.gpu_canvas.target_offset(&self.queue, target.as_f32()); { let timestamp_writes = gpu_capture.as_ref().and_then(|capture| { capture.timestamp_writes( @@ -344,6 +394,12 @@ impl SurfaceRenderer { batch.instances.clone(), image_offset, ), + DrawKind::GpuCanvas(index) => self.gpu_canvas.draw( + &mut pass, + index, + batch.instances.clone(), + canvas_offset, + ), DrawKind::Vector => { self.vector .draw(&mut pass, batch.instances.clone(), vector_offset) @@ -355,7 +411,8 @@ impl SurfaceRenderer { if let Some(capture) = gpu_capture { capture.finish(&mut encoder); } - self.queue.submit([encoder.finish()]); + canvas_commands.push(encoder.finish()); + self.queue.submit(canvas_commands); let _ = self.device.poll(wgpu::PollType::Poll); self.finish_profile(profiler, viewport, graph_stats); self.queue.present(frame); @@ -374,6 +431,7 @@ impl SurfaceRenderer { effects, texture_pool: self.offscreen.stats(), vector_atlas: self.vector.stats(), + gpu_canvases: self.gpu_canvas.stats(), direct_surface: effects.offscreen_layers == 0, adapter: self.gpu_profiler.adapter().clone(), gpu: self.gpu_profiler.take_latest(), diff --git a/crates/argui-render/src/surface/api.rs b/crates/argui-render/src/surface/api.rs index f2c41730..149654f6 100644 --- a/crates/argui-render/src/surface/api.rs +++ b/crates/argui-render/src/surface/api.rs @@ -116,17 +116,16 @@ impl SurfaceRenderer { }) .await .map_err(|error| RendererError::AdapterRequest(error.to_string()))?; - let required_features = if renderer_config.profiling - && adapter.features().contains(wgpu::Features::TIMESTAMP_QUERY) - { - wgpu::Features::TIMESTAMP_QUERY - } else { - wgpu::Features::empty() - }; + let requirements = renderer_config.gpu_canvases.device_requirements( + adapter.features(), + &adapter.limits(), + renderer_config.profiling, + )?; let (device, queue) = adapter .request_device(&wgpu::DeviceDescriptor { label: Some("argui-device"), - required_features, + required_features: requirements.features, + required_limits: requirements.limits, memory_hints: wgpu::MemoryHints::MemoryUsage, ..Default::default() }) @@ -138,6 +137,7 @@ impl SurfaceRenderer { device, queue, initialization_fallback, + generation: next_device_generation(), })); Self::from_existing_device( instance, @@ -324,6 +324,17 @@ impl SurfaceRenderer { self.offscreen.stats() } + /// Returns retained and per-frame GPU-canvas cache statistics. + #[must_use] + pub fn gpu_canvas_stats(&self) -> crate::GpuCanvasStats { + self.gpu_canvas.stats() + } + + /// Drains recoverable GPU-canvas failure and recovery diagnostics. + pub fn take_gpu_canvas_diagnostics(&mut self) -> Vec { + self.gpu_canvas.take_diagnostics() + } + /// Returns profiling data from the most recently rendered frame. #[must_use] pub fn last_profile(&self) -> RenderProfile { diff --git a/crates/argui-render/src/surface/effects.rs b/crates/argui-render/src/surface/effects.rs index 5b08e9f3..b61b8883 100644 --- a/crates/argui-render/src/surface/effects.rs +++ b/crates/argui-render/src/surface/effects.rs @@ -253,6 +253,7 @@ impl SurfaceRenderer { let text_offset = self.text.target_offset(&self.queue, region); let image_offset = self.image.target_offset(&self.queue, region); let vector_offset = self.vector.target_offset(&self.queue, region); + let canvas_offset = self.gpu_canvas.target_offset(&self.queue, region); let attachment = Some(RenderPassColorAttachment { view: self.offscreen.view(target.texture), depth_slice: None, @@ -302,6 +303,10 @@ impl SurfaceRenderer { batch.instances.clone(), image_offset, ), + DrawKind::GpuCanvas(index) => { + self.gpu_canvas + .draw(&mut pass, index, batch.instances.clone(), canvas_offset) + } DrawKind::Vector => { self.vector .draw(&mut pass, batch.instances.clone(), vector_offset); diff --git a/crates/argui-render/tests/config.rs b/crates/argui-render/tests/config.rs index 2c7da29b..286fe615 100644 --- a/crates/argui-render/tests/config.rs +++ b/crates/argui-render/tests/config.rs @@ -34,6 +34,7 @@ fn renderer_defaults_to_vsync_and_a_discrete_gpu() { 3 ); assert_eq!(config.image_cache_bytes, 64 * 1024 * 1024); + assert_eq!(config.gpu_canvas_cache_bytes, 128 * 1024 * 1024); assert_eq!(config.gradient_stop_capacity, 65_536); assert_eq!( config.clone().image_cache_bytes(1024).image_cache_bytes, @@ -85,10 +86,12 @@ fn effect_quality_and_configuration_builders_are_composable() { let config = RendererConfig::default() .clear_color(argui_core::Color::BLACK) .image_cache_bytes(4096) + .gpu_canvas_cache_bytes(8192) .gradient_stop_capacity(32) .effect_quality(EffectQuality::Performance); assert_eq!(config.clear_color, argui_core::Color::BLACK); assert_eq!(config.image_cache_bytes, 4096); + assert_eq!(config.gpu_canvas_cache_bytes, 8192); assert_eq!(config.gradient_stop_capacity, 32); assert_eq!(config.effect_quality, EffectQuality::Performance); } diff --git a/crates/argui-render/tests/effect_graph.rs b/crates/argui-render/tests/effect_graph.rs index 62537a31..894602bf 100644 --- a/crates/argui-render/tests/effect_graph.rs +++ b/crates/argui-render/tests/effect_graph.rs @@ -1,8 +1,8 @@ use argui_core::{Affine2D, Color, Point, Rect, Size}; use argui_paint::{ - Border, ClipChain, CornerRadii, DisplayList, EffectId, EffectInstance, Fill, Filter, ImageFit, - ImageId, ImagePrimitive, ImageSampling, LayerMask, LayerStyle, Quad, Refraction, Shadow, - VectorId, VectorPrimitive, + Border, ClipChain, CornerRadii, DisplayList, EffectId, EffectInstance, Fill, Filter, + GpuCanvasId, GpuCanvasPrimitive, ImageFit, ImageId, ImagePrimitive, ImageSampling, LayerMask, + LayerStyle, ProfileDomain, Quad, Refraction, RenderObjectId, Shadow, VectorId, VectorPrimitive, }; use argui_render::analyze_display_list; @@ -47,6 +47,22 @@ fn vector() -> VectorPrimitive { } } +fn canvas(revision: u64) -> GpuCanvasPrimitive { + GpuCanvasPrimitive { + canvas: GpuCanvasId::fresh(), + object: RenderObjectId::new(ProfileDomain::Ui, 17), + slot: 0, + bounds: bounds(), + content_revision: revision, + resolution_scale: 1.0, + sampling: ImageSampling::Linear, + opacity: 0.75, + radii: CornerRadii::all(4.0), + transform: Affine2D::IDENTITY, + clips: ClipChain::default(), + } +} + #[test] fn analysis_counts_merged_draws_nested_layers_filters_and_custom_effects() { let custom = EffectInstance::new( @@ -126,3 +142,19 @@ fn analysis_handles_empty_clipped_and_malformed_display_lists() { unclosed.begin_layer(LayerStyle::new(bounds())); assert!(analyze_display_list(&unclosed, &[], [1.0, 1.0], 1.0, 0).is_err()); } + +#[test] +fn canvas_draws_keep_display_order_and_participate_in_effect_layers() { + let mut list = DisplayList::new(); + list.push_quad(quad()); + list.push_gpu_canvas(canvas(1)); + list.push_quad(quad()); + list.begin_layer(LayerStyle::new(bounds()).filter(Filter::Blur(3.0))); + list.push_gpu_canvas(canvas(2)); + list.end_layer(); + + let analysis = analyze_display_list(&list, &[], [320.0, 240.0], 1.0, 19).unwrap(); + assert_eq!(analysis.stats.draw_batches, 4); + assert_eq!(analysis.stats.offscreen_layers, 1); + assert_eq!(analysis.stats.filter_passes, 1); +} diff --git a/crates/argui-render/tests/gpu_canvas/mod.rs b/crates/argui-render/tests/gpu_canvas/mod.rs new file mode 100644 index 00000000..c07f0cd9 --- /dev/null +++ b/crates/argui-render/tests/gpu_canvas/mod.rs @@ -0,0 +1,193 @@ +#[cfg(not(target_arch = "wasm32"))] +use argui_render::SurfaceRenderer; +use argui_render::{ + GpuCanvasDeviceContext, GpuCanvasError, GpuCanvasFactory, GpuCanvasRegistration, + GpuCanvasRegistry, GpuCanvasRegistryError, GpuCanvasRenderContext, GpuCanvasRenderer, + GpuCanvasRequirements, RendererConfig, wgpu, +}; + +#[derive(Clone, Copy)] +struct Factory { + requirements: Option, +} + +impl GpuCanvasFactory for Factory { + fn requirements(&self) -> GpuCanvasRequirements { + self.requirements + .map_or_else(GpuCanvasRequirements::default, |features| { + GpuCanvasRequirements::default() + .optional_features(features) + .reason("uses an optional test capability") + }) + } + + fn create( + &self, + _context: &GpuCanvasDeviceContext<'_>, + ) -> Result, GpuCanvasError> { + Ok(Box::new(Renderer)) + } +} + +struct Renderer; + +impl GpuCanvasRenderer for Renderer { + fn render(&mut self, context: &mut GpuCanvasRenderContext<'_>) -> Result<(), GpuCanvasError> { + let _ = context.device(); + let _ = context.queue(); + let _ = context.encoder_and_target(); + Ok(()) + } +} + +fn registration(label: &str) -> GpuCanvasRegistration { + GpuCanvasRegistration::new(label, Factory { requirements: None }) +} + +#[test] +fn registration_clones_preserve_identity_and_registry_deduplicates_them() { + let registration = registration("test.canvas"); + let clone = registration.clone(); + let registry = GpuCanvasRegistry::new([registration.clone(), clone]).unwrap(); + + assert_eq!(registry.len(), 1); + assert_eq!(registry.registrations()[0].id(), registration.id()); + assert_eq!(registry.registrations()[0].label(), "test.canvas"); + assert!(!registry.is_empty()); +} + +#[test] +fn registry_rejects_invalid_duplicate_and_unexplained_capabilities() { + assert!(matches!( + GpuCanvasRegistry::new([registration("")]), + Err(GpuCanvasRegistryError::InvalidLabel(label)) if label.is_empty() + )); + assert!(matches!( + GpuCanvasRegistry::new([registration(" padded")]), + Err(GpuCanvasRegistryError::InvalidLabel(label)) if label == " padded" + )); + assert!(matches!( + GpuCanvasRegistry::new([registration("line\nbreak")]), + Err(GpuCanvasRegistryError::InvalidLabel(label)) if label == "line\nbreak" + )); + assert!(matches!( + GpuCanvasRegistry::new([registration("same"), registration("same")]), + Err(GpuCanvasRegistryError::DuplicateLabel(label)) if label == "same" + )); + + struct Unexplained; + impl GpuCanvasFactory for Unexplained { + fn requirements(&self) -> GpuCanvasRequirements { + GpuCanvasRequirements::default().optional_features(wgpu::Features::TIMESTAMP_QUERY) + } + + fn create( + &self, + _context: &GpuCanvasDeviceContext<'_>, + ) -> Result, GpuCanvasError> { + Ok(Box::new(Renderer)) + } + } + let unexplained = GpuCanvasRegistration::new("unexplained", Unexplained); + assert!(matches!( + GpuCanvasRegistry::new([unexplained]), + Err(GpuCanvasRegistryError::MissingRequirementReason(label)) if label == "unexplained" + )); + + struct WhitespaceReason; + impl GpuCanvasFactory for WhitespaceReason { + fn requirements(&self) -> GpuCanvasRequirements { + GpuCanvasRequirements::default() + .optional_features(wgpu::Features::TIMESTAMP_QUERY) + .reason(" ") + } + + fn create( + &self, + _context: &GpuCanvasDeviceContext<'_>, + ) -> Result, GpuCanvasError> { + Ok(Box::new(Renderer)) + } + } + assert!(matches!( + GpuCanvasRegistry::new([GpuCanvasRegistration::new( + "whitespace-reason", + WhitespaceReason, + )]), + Err(GpuCanvasRegistryError::MissingRequirementReason(label)) + if label == "whitespace-reason" + )); +} + +#[test] +fn requirement_builder_merges_capacity_and_alignment_limits_directionally() { + let first = wgpu::Limits { + max_bind_groups: 9, + min_uniform_buffer_offset_alignment: 128, + ..wgpu::Limits::default() + }; + let second = wgpu::Limits { + max_bind_groups: 7, + min_uniform_buffer_offset_alignment: 64, + ..wgpu::Limits::default() + }; + let requirements = GpuCanvasRequirements::default() + .required_features(wgpu::Features::TEXTURE_COMPRESSION_BC) + .optional_features(wgpu::Features::TIMESTAMP_QUERY) + .required_limits(first) + .required_limits(second) + .reason("tests direction-aware WGPU limit resolution"); + + assert!( + requirements + .required_features + .contains(wgpu::Features::TEXTURE_COMPRESSION_BC) + ); + assert!( + requirements + .optional_features + .contains(wgpu::Features::TIMESTAMP_QUERY) + ); + assert_eq!(requirements.required_limits.max_bind_groups, 9); + assert_eq!( + requirements + .required_limits + .min_uniform_buffer_offset_alignment, + 64 + ); + assert!(requirements.reason.is_some()); +} + +#[test] +fn config_owns_an_immutable_registry_and_a_bounded_default_budget() { + let registry = GpuCanvasRegistry::new([registration("configured")]).unwrap(); + let config = RendererConfig::default() + .gpu_canvas_cache_bytes(4096) + .gpu_canvases(registry.clone()); + + assert_eq!(config.gpu_canvas_cache_bytes, 4096); + assert_eq!(config.gpu_canvases.len(), 1); + assert_eq!( + RendererConfig::default().gpu_canvas_cache_bytes, + 128 * 1024 * 1024 + ); + assert_eq!(registry.len(), 1); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn public_canvas_types_preserve_renderer_thread_traits() { + fn assert_send_sync() {} + + assert_send_sync::(); + assert_send_sync::(); + assert_send_sync::(); + assert_send_sync::(); +} + +#[test] +fn application_errors_preserve_their_actionable_message() { + let error = GpuCanvasError::new("pipeline creation failed"); + assert_eq!(error.message(), "pipeline creation failed"); + assert_eq!(error.to_string(), "pipeline creation failed"); +} diff --git a/crates/argui-render/tests/gpu_canvas/pipeline.rs b/crates/argui-render/tests/gpu_canvas/pipeline.rs new file mode 100644 index 00000000..bdaf43c3 --- /dev/null +++ b/crates/argui-render/tests/gpu_canvas/pipeline.rs @@ -0,0 +1,204 @@ +use super::*; + +/// Exercises retained GPU-canvas callback, extent, diagnostics, effect and surface behavior. +pub(super) fn exercise( + renderer: &mut SurfaceRenderer, + canvas_id: GpuCanvasId, + probe: &Arc, + retry_id: GpuCanvasId, + retry_probe: &Arc, + window: &Arc, + render: &mut impl FnMut(&mut SurfaceRenderer, &DisplayList) -> Result<(), RendererError>, +) { + let first = canvas_list(canvas_id, 1, Size::new(48.0, 32.0), 0, false); + render(renderer, &first).unwrap(); + assert_eq!(probe.creates.load(Ordering::Relaxed), 1); + assert_eq!(probe.renders.load(Ordering::Relaxed), 1); + assert_eq!(probe.extent.load(Ordering::Relaxed), (48_u64 << 32) | 32); + assert_eq!(renderer.gpu_canvas_stats().renders_this_frame, 1); + assert_eq!(renderer.gpu_canvas_stats().entries, 1); + assert_eq!(renderer.gpu_canvas_stats().allocated_bytes, 48 * 32 * 4); + + render(renderer, &first).unwrap(); + assert_eq!(probe.renders.load(Ordering::Relaxed), 1); + assert_eq!(renderer.gpu_canvas_stats().hits_this_frame, 1); + + let changed = canvas_list(canvas_id, 2, Size::new(48.0, 32.0), 0, false); + render(renderer, &changed).unwrap(); + assert_eq!(probe.renders.load(Ordering::Relaxed), 2); + + let resized = canvas_list(canvas_id, 2, Size::new(64.0, 40.0), 0, false); + render(renderer, &resized).unwrap(); + assert_eq!(probe.renders.load(Ordering::Relaxed), 3); + assert_eq!(probe.extent.load(Ordering::Relaxed), (64_u64 << 32) | 40); + + let mut supersampled = canvas_primitive(canvas_id, 2, Size::new(32.0, 20.0), 1); + supersampled.resolution_scale = 2.0; + let mut supersampled_list = DisplayList::new(); + supersampled_list.push_gpu_canvas(supersampled); + render(renderer, &supersampled_list).unwrap(); + assert_eq!(probe.extent.load(Ordering::Relaxed), (64_u64 << 32) | 40); + assert!(renderer.gpu_canvas_stats().allocated_bytes <= 16 * 1024); + + let before_lru = probe.renders.load(Ordering::Relaxed); + render(renderer, &changed).unwrap(); + assert_eq!(probe.renders.load(Ordering::Relaxed), before_lru + 1); + + let before_empty = probe.renders.load(Ordering::Relaxed); + render( + renderer, + &canvas_list(canvas_id, 2, Size::new(0.0, 0.0), 2, false), + ) + .unwrap(); + assert_eq!(probe.renders.load(Ordering::Relaxed), before_empty); + + let mut invalid = canvas_primitive(canvas_id, 3, Size::new(32.0, 20.0), 3); + invalid.resolution_scale = f32::NAN; + assert!(failure_message(renderer, invalid, render).contains("invalid resolution scale")); + + let mut negative_scale = canvas_primitive(canvas_id, 3, Size::new(32.0, 20.0), 31); + negative_scale.resolution_scale = -1.0; + assert!(failure_message(renderer, negative_scale, render).contains("invalid resolution scale")); + + let mut invalid_extent = canvas_primitive(canvas_id, 3, Size::new(-1.0, 20.0), 4); + assert!(failure_message(renderer, invalid_extent.clone(), render).contains("invalid logical")); + invalid_extent.slot = 5; + invalid_extent.bounds.size.width = f32::NAN; + assert!(failure_message(renderer, invalid_extent, render).contains("invalid logical")); + + let invalid_height = canvas_primitive(canvas_id, 3, Size::new(20.0, f32::NAN), 32); + assert!(failure_message(renderer, invalid_height, render).contains("invalid logical")); + let negative_height = canvas_primitive(canvas_id, 3, Size::new(20.0, -1.0), 33); + assert!(failure_message(renderer, negative_height, render).contains("invalid logical")); + + let before_zero_height = probe.renders.load(Ordering::Relaxed); + render( + renderer, + &canvas_list(canvas_id, 3, Size::new(20.0, 0.0), 34, false), + ) + .unwrap(); + assert_eq!(probe.renders.load(Ordering::Relaxed), before_zero_height); + + let mut overflow = canvas_primitive(canvas_id, 3, Size::new(32.0, 20.0), 6); + overflow.resolution_scale = f32::MAX; + assert!(failure_message(renderer, overflow, render).contains("cannot be represented")); + + let excessive = canvas_primitive(canvas_id, 3, Size::new(20_000.0, 1.0), 7); + assert!(failure_message(renderer, excessive, render).contains("max_texture_dimension_2d")); + + let oversized = canvas_primitive(canvas_id, 3, Size::new(10_000.0, 10_000.0), 8); + assert!(failure_message(renderer, oversized, render).contains("cache budget")); + + let missing = canvas_primitive(GpuCanvasId::fresh(), 1, Size::new(32.0, 20.0), 9); + assert!(failure_message(renderer, missing, render).contains("missing registration")); + + let mut constrained = DisplayList::new(); + constrained.push_gpu_canvas(canvas_primitive(canvas_id, 3, Size::new(64.0, 40.0), 10)); + constrained.push_gpu_canvas(canvas_primitive(canvas_id, 3, Size::new(64.0, 40.0), 11)); + let before_constrained = probe.renders.load(Ordering::Relaxed); + render(renderer, &constrained).unwrap(); + assert_eq!( + probe.renders.load(Ordering::Relaxed), + before_constrained + 1 + ); + assert_eq!(renderer.gpu_canvas_stats().failures_this_frame, 1); + assert!(renderer.gpu_canvas_stats().allocated_bytes <= 16 * 1024); + assert!( + renderer.take_gpu_canvas_diagnostics()[0] + .message + .contains("visible canvases") + ); + + probe.fail.store(true, Ordering::Relaxed); + let before_failure = probe.renders.load(Ordering::Relaxed); + let failing = canvas_list(canvas_id, 3, Size::new(64.0, 40.0), 0, false); + render(renderer, &failing).unwrap(); + assert_eq!(renderer.gpu_canvas_stats().failures_this_frame, 1); + let failed = renderer.take_gpu_canvas_diagnostics(); + assert_eq!(failed.len(), 1); + assert_eq!(failed[0].kind, GpuCanvasDiagnosticKind::Failed); + assert!( + failed[0] + .message + .contains("intentional integration-test failure") + ); + + render(renderer, &failing).unwrap(); + assert!(renderer.take_gpu_canvas_diagnostics().is_empty()); + assert_eq!(probe.renders.load(Ordering::Relaxed), before_failure + 1); + + probe.fail.store(false, Ordering::Relaxed); + let recovered = canvas_list(canvas_id, 4, Size::new(64.0, 40.0), 0, false); + render(renderer, &recovered).unwrap(); + let recovered = renderer.take_gpu_canvas_diagnostics(); + assert_eq!(recovered.len(), 1); + assert_eq!(recovered[0].kind, GpuCanvasDiagnosticKind::Recovered); + + let effected = canvas_list(canvas_id, 5, Size::new(64.0, 40.0), 0, true); + render(renderer, &effected).unwrap(); + assert!(renderer.last_profile().effects.offscreen_layers >= 1); + assert!(renderer.last_profile().effects.filter_passes >= 1); + + let mut duplicate = canvas_list(canvas_id, 6, Size::new(64.0, 40.0), 0, false); + duplicate.push_gpu_canvas(canvas_primitive(canvas_id, 6, Size::new(64.0, 40.0), 0)); + render(renderer, &duplicate).unwrap(); + assert!( + renderer + .take_gpu_canvas_diagnostics() + .iter() + .any(|diagnostic| diagnostic.message.contains("appears more than once")) + ); + + let resolved = canvas_list(canvas_id, 6, Size::new(64.0, 40.0), 0, false); + let before_resolved = probe.renders.load(Ordering::Relaxed); + render(renderer, &resolved).unwrap(); + assert_eq!(probe.renders.load(Ordering::Relaxed), before_resolved); + assert_eq!( + renderer.take_gpu_canvas_diagnostics()[0].kind, + GpuCanvasDiagnosticKind::Recovered + ); + + let retained = canvas_list(canvas_id, 7, Size::new(64.0, 40.0), 0, false); + render(renderer, &retained).unwrap(); + let before_recreate = probe.renders.load(Ordering::Relaxed); + renderer.recreate_surface(window.clone()).unwrap(); + render(renderer, &retained).unwrap(); + assert_eq!(probe.renders.load(Ordering::Relaxed), before_recreate); + assert_eq!(renderer.gpu_canvas_stats().hits_this_frame, 1); + + let creation_failure = canvas_list(retry_id, 1, Size::new(32.0, 20.0), 0, false); + render(renderer, &creation_failure).unwrap(); + assert_eq!(retry_probe.creates.load(Ordering::Relaxed), 1); + assert!( + renderer.take_gpu_canvas_diagnostics()[0] + .message + .contains("factory failure") + ); + render(renderer, &creation_failure).unwrap(); + assert_eq!(retry_probe.creates.load(Ordering::Relaxed), 1); + assert!(renderer.take_gpu_canvas_diagnostics().is_empty()); + + retry_probe.create_fail.store(false, Ordering::Relaxed); + let creation_recovery = canvas_list(retry_id, 2, Size::new(32.0, 20.0), 0, false); + render(renderer, &creation_recovery).unwrap(); + assert_eq!(retry_probe.creates.load(Ordering::Relaxed), 2); + assert_eq!(retry_probe.renders.load(Ordering::Relaxed), 1); + assert_eq!( + renderer.take_gpu_canvas_diagnostics()[0].kind, + GpuCanvasDiagnosticKind::Recovered + ); +} + +/// Renders one invalid `primitive` and returns its single diagnostic message. +fn failure_message( + renderer: &mut SurfaceRenderer, + primitive: GpuCanvasPrimitive, + render: &mut impl FnMut(&mut SurfaceRenderer, &DisplayList) -> Result<(), RendererError>, +) -> String { + let mut list = DisplayList::new(); + list.push_gpu_canvas(primitive); + render(renderer, &list).unwrap(); + let diagnostics = renderer.take_gpu_canvas_diagnostics(); + assert_eq!(diagnostics.len(), 1); + diagnostics[0].message.clone() +} diff --git a/crates/argui-render/tests/surface.rs b/crates/argui-render/tests/surface.rs index 622cdd1f..5a723062 100644 --- a/crates/argui-render/tests/surface.rs +++ b/crates/argui-render/tests/surface.rs @@ -1,15 +1,24 @@ #![cfg(target_os = "linux")] -use std::sync::Arc; +#[path = "gpu_canvas/pipeline.rs"] +mod gpu_canvas; + +use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}, +}; use argui_core::{Affine2D, Color, Point, Rect, Size}; use argui_paint::{ - ClipChain, DisplayList, EffectId, EffectInstance, Filter, ImageFit, LayerStyle, VectorAsset, - VectorId, VectorPrimitive, + Border, ClipChain, CornerRadii, DisplayList, EffectId, EffectInstance, Fill, Filter, + GpuCanvasId, GpuCanvasPrimitive, ImageFit, ImageSampling, LayerStyle, ProfileDomain, Quad, + RenderObjectId, VectorAsset, VectorId, VectorPrimitive, }; use argui_render::{ - EffectDefinition, EffectPassDefinition, EffectRegistry, RenderStatus, RendererConfig, - RendererError, SurfaceRenderer, + EffectDefinition, EffectPassDefinition, EffectRegistry, GpuCanvasDeviceContext, + GpuCanvasDiagnosticKind, GpuCanvasError, GpuCanvasFactory, GpuCanvasRegistration, + GpuCanvasRegistry, GpuCanvasRenderContext, GpuCanvasRenderer, GpuCanvasRequirements, + RenderStatus, RendererConfig, RendererError, SurfaceRenderer, }; use argui_text::{PreparedText, TextEngine}; use winit::{ @@ -27,6 +36,226 @@ const PASSES: &[EffectPassDefinition] = &[ EffectPassDefinition::fragment("second", SHADER), ]; +const COMPUTE_SHADER: &str = r#" +@group(0) @binding(0) var output: array, 1>; +@compute @workgroup_size(1) +fn main() { output[0] = vec4(0.08, 0.6, 0.25, 0.8); } +"#; +const CANVAS_SHADER: &str = r#" +@group(0) @binding(0) var color: array, 1>; +@vertex +fn vertex(@builtin(vertex_index) index: u32) -> @builtin(position) vec4 { + let positions = array, 3>( + vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0)); + return vec4(positions[index], 0.0, 1.0); +} +@fragment +fn fragment() -> @location(0) vec4 { return color[0]; } +"#; + +#[derive(Default)] +struct CanvasProbe { + creates: AtomicUsize, + renders: AtomicUsize, + extent: AtomicU64, + create_fail: AtomicBool, + fail: AtomicBool, +} + +struct ComputeFactory(Arc); + +struct RequiredFeatureFactory; + +impl GpuCanvasFactory for RequiredFeatureFactory { + fn requirements(&self) -> GpuCanvasRequirements { + GpuCanvasRequirements::default() + .required_features(wgpu::Features::TEXTURE_COMPRESSION_BC) + .reason("validates shared-device capability rejection") + } + + fn create( + &self, + _context: &GpuCanvasDeviceContext<'_>, + ) -> Result, GpuCanvasError> { + Err(GpuCanvasError::new( + "shared device validation should run before factory creation", + )) + } +} + +impl GpuCanvasFactory for ComputeFactory { + fn create( + &self, + context: &GpuCanvasDeviceContext<'_>, + ) -> Result, GpuCanvasError> { + self.0.creates.fetch_add(1, Ordering::Relaxed); + if self.0.create_fail.load(Ordering::Relaxed) { + return Err(GpuCanvasError::new( + "intentional integration-test factory failure", + )); + } + Ok(Box::new(ComputeRenderer::new(context, self.0.clone()))) + } +} + +struct ComputeRenderer { + probe: Arc, + compute: wgpu::ComputePipeline, + compute_group: wgpu::BindGroup, + render: wgpu::RenderPipeline, + render_group: wgpu::BindGroup, + _color: wgpu::Buffer, +} + +impl ComputeRenderer { + fn new(context: &GpuCanvasDeviceContext<'_>, probe: Arc) -> Self { + let device = context.device(); + let color = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("argui-test-canvas-color"), + size: 16, + usage: wgpu::BufferUsages::STORAGE, + mapped_at_creation: false, + }); + let compute_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("argui-test-canvas-compute-layout"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: false }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + }); + let render_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("argui-test-canvas-render-layout"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + }); + let group = |label, layout: &wgpu::BindGroupLayout| { + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some(label), + layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: color.as_entire_binding(), + }], + }) + }; + let compute_group = group("argui-test-canvas-compute-group", &compute_layout); + let render_group = group("argui-test-canvas-render-group", &render_layout); + let compute_module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("argui-test-canvas-compute"), + source: wgpu::ShaderSource::Wgsl(COMPUTE_SHADER.into()), + }); + let compute_pipeline_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("argui-test-canvas-compute-pipeline-layout"), + bind_group_layouts: &[Some(&compute_layout)], + immediate_size: 0, + }); + let compute = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some("argui-test-canvas-compute-pipeline"), + layout: Some(&compute_pipeline_layout), + module: &compute_module, + entry_point: Some("main"), + compilation_options: Default::default(), + cache: None, + }); + let render_module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("argui-test-canvas-render"), + source: wgpu::ShaderSource::Wgsl(CANVAS_SHADER.into()), + }); + let render_pipeline_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("argui-test-canvas-render-pipeline-layout"), + bind_group_layouts: &[Some(&render_layout)], + immediate_size: 0, + }); + let render = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("argui-test-canvas-render-pipeline"), + layout: Some(&render_pipeline_layout), + vertex: wgpu::VertexState { + module: &render_module, + entry_point: Some("vertex"), + compilation_options: Default::default(), + buffers: &[], + }, + primitive: Default::default(), + depth_stencil: None, + multisample: Default::default(), + fragment: Some(wgpu::FragmentState { + module: &render_module, + entry_point: Some("fragment"), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format: context.target_format(), + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + multiview_mask: None, + cache: None, + }); + Self { + probe, + compute, + compute_group, + render, + render_group, + _color: color, + } + } +} + +impl GpuCanvasRenderer for ComputeRenderer { + fn render(&mut self, context: &mut GpuCanvasRenderContext<'_>) -> Result<(), GpuCanvasError> { + self.probe.renders.fetch_add(1, Ordering::Relaxed); + let [width, height] = context.physical_extent(); + self.probe.extent.store( + (u64::from(width) << 32) | u64::from(height), + Ordering::Relaxed, + ); + if self.probe.fail.load(Ordering::Relaxed) { + return Err(GpuCanvasError::new("intentional integration-test failure")); + } + let (encoder, target) = context.encoder_and_target(); + { + let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor::default()); + pass.set_pipeline(&self.compute); + pass.set_bind_group(0, &self.compute_group, &[]); + pass.dispatch_workgroups(1, 1, 1); + } + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("argui-test-canvas-render-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: target, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + })], + ..Default::default() + }); + pass.set_pipeline(&self.render); + pass.set_bind_group(0, &self.render_group, &[]); + pass.draw(0..3, 0..1); + Ok(()) + } +} + #[test] #[ignore = "Native surface integration: requires a dedicated Wayland test display"] fn native_surface_grows_its_atlas_recovers_from_capacity_and_uses_custom_effects() { @@ -78,8 +307,23 @@ fn exercise(window: Arc, mut pump: impl FnMut()) { EffectDefinition::new(unused, &[], PASSES), ]) .unwrap(); + let probe = Arc::new(CanvasProbe::default()); + let canvas_registration = + GpuCanvasRegistration::new("test.compute-canvas", ComputeFactory(probe.clone())); + let canvas_id = canvas_registration.id(); + let retry_probe = Arc::new(CanvasProbe::default()); + retry_probe.create_fail.store(true, Ordering::Relaxed); + let retry_registration = + GpuCanvasRegistration::new("test.creation-retry", ComputeFactory(retry_probe.clone())); + let retry_id = retry_registration.id(); + let canvas_registry = + GpuCanvasRegistry::new([canvas_registration, retry_registration]).unwrap(); let size = window.inner_size(); - let mut config = RendererConfig::default().profiling(true).effects(registry); + let mut config = RendererConfig::default() + .profiling(true) + .effects(registry) + .gpu_canvas_cache_bytes(16 * 1024) + .gpu_canvases(canvas_registry); // This test submits many frames without application work between them. An // automatic non-vsync mode avoids depending on compositor frame throttling. config.present_mode = wgpu::PresentMode::AutoNoVsync; @@ -90,6 +334,19 @@ fn exercise(window: Arc, mut pump: impl FnMut()) { config, )) .unwrap(); + let required = GpuCanvasRegistration::new("test.required-feature", RequiredFeatureFactory); + let incompatible = pollster::block_on(SurfaceRenderer::new_with_device( + window.clone(), + size.width, + size.height, + RendererConfig::default().gpu_canvases(GpuCanvasRegistry::new([required]).unwrap()), + renderer.device_handle(), + )); + assert!(matches!( + incompatible, + Err(RendererError::IncompatibleGpuCanvasDevice { canvas, .. }) + if canvas == "test.required-feature" + )); let mut render = |renderer: &mut SurfaceRenderer, list: &DisplayList| { render(renderer, list, &window, &mut pump) }; @@ -162,6 +419,69 @@ fn exercise(window: Arc, mut pump: impl FnMut()) { render(&mut renderer, &missing), Err(RendererError::MissingEffect("test.missing")) )); + + gpu_canvas::exercise( + &mut renderer, + canvas_id, + &probe, + retry_id, + &retry_probe, + &window, + &mut render, + ); +} + +fn canvas_list(id: GpuCanvasId, revision: u64, size: Size, slot: u32, effect: bool) -> DisplayList { + let mut list = DisplayList::new(); + list.push_quad(Quad { + bounds: bounds(size.width), + background: Some(Fill::Solid(Color::BLACK)), + border: Border::all(0.0, Color::TRANSPARENT), + radii: CornerRadii::default(), + opacity: 1.0, + transform: Affine2D::IDENTITY, + clips: ClipChain::default(), + }); + if effect { + list.begin_layer(LayerStyle::new(bounds(size.width)).filter(Filter::Blur(2.0))); + } + list.push_gpu_canvas(canvas_primitive(id, revision, size, slot)); + if effect { + list.end_layer(); + } + list.push_quad(Quad { + bounds: Rect::new(Point::new(8.0, 8.0), Size::new(10.0, 10.0)), + background: Some(Fill::Solid(Color::WHITE)), + border: Border::all(0.0, Color::TRANSPARENT), + radii: CornerRadii::all(2.0), + opacity: 0.5, + transform: Affine2D::IDENTITY, + clips: ClipChain::default(), + }); + list +} + +fn canvas_primitive(id: GpuCanvasId, revision: u64, size: Size, slot: u32) -> GpuCanvasPrimitive { + GpuCanvasPrimitive { + canvas: id, + object: RenderObjectId::new(ProfileDomain::Ui, 777), + slot, + bounds: Rect::new(Point::new(0.0, 0.0), size), + content_revision: revision, + resolution_scale: 1.0, + sampling: if revision.is_multiple_of(2) { + ImageSampling::Nearest + } else { + ImageSampling::Linear + }, + opacity: 0.9, + radii: CornerRadii::all(3.0), + transform: Affine2D::IDENTITY, + clips: ClipChain::from_regions([argui_paint::ClipRegion::new( + Rect::new(Point::default(), size), + Affine2D::IDENTITY, + )]), + } } fn render( diff --git a/crates/argui-runtime/src/app/inspect.rs b/crates/argui-runtime/src/app/inspect.rs index 375e5512..19a11503 100644 --- a/crates/argui-runtime/src/app/inspect.rs +++ b/crates/argui-runtime/src/app/inspect.rs @@ -262,6 +262,7 @@ fn descendant_count(element: &Element) -> usize { fn kind_name(kind: &ElementKind) -> &'static str { match kind { ElementKind::Custom(_) => "custom", + ElementKind::GpuCanvas(_) => "gpu-canvas", ElementKind::Container => "container", ElementKind::Text { .. } => "text", ElementKind::TextEditor { multiline, .. } => { @@ -279,6 +280,11 @@ fn kind_name(kind: &ElementKind) -> &'static str { fn summary(kind: &ElementKind, children: usize) -> Option { match kind { ElementKind::Custom(custom) => Some(custom.type_name().to_owned()), + ElementKind::GpuCanvas(canvas) => Some(format!( + "id={} · revision={}", + canvas.canvas().get(), + canvas.revision() + )), ElementKind::Container => Some(format!("{children} children")), ElementKind::Text { content, .. } => Some(short(content.as_str())), ElementKind::TextEditor { diff --git a/crates/argui-runtime/src/app/popups.rs b/crates/argui-runtime/src/app/popups.rs index 26aa44e8..aea6aa31 100644 --- a/crates/argui-runtime/src/app/popups.rs +++ b/crates/argui-runtime/src/app/popups.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use argui_core::Rect; use argui_platform::popup::{NativePopup, PopupEnvironment, PopupKind, PopupUnavailable}; -use argui_render::{RenderStatus, SurfaceRenderer}; +use argui_render::{GpuCanvasDiagnosticKind, RenderStatus, SurfaceRenderer}; use argui_ui::{InteractionUpdate, NodeId, OverlaySurface, Role, UiEventKind}; use winit::window::WindowId; @@ -269,6 +269,17 @@ impl Application { self.scale_factor, || window.pre_present_notify(), ); + for diagnostic in popup.renderer.take_gpu_canvas_diagnostics() { + let event = match diagnostic.kind { + GpuCanvasDiagnosticKind::Failed => { + crate::RuntimeEvent::GpuCanvasFailed(diagnostic) + } + GpuCanvasDiagnosticKind::Recovered => { + crate::RuntimeEvent::GpuCanvasRecovered(diagnostic) + } + }; + (self.on_event)(event); + } match result { Ok(RenderStatus::Presented) => { if !popup.shown { diff --git a/crates/argui-runtime/src/app/renderer.rs b/crates/argui-runtime/src/app/renderer.rs index ffe35692..fed5f8b6 100644 --- a/crates/argui-runtime/src/app/renderer.rs +++ b/crates/argui-runtime/src/app/renderer.rs @@ -5,7 +5,8 @@ use std::rc::Rc; use argui_inspect::{AdapterRecord, FrameRecord, GpuFrameRecord, GpuPassRecord, Invalidation}; use argui_render::{ - AdapterProfile, GpuFrameProfile, RenderStatus, SurfaceAlphaMode, SurfaceRenderer, + AdapterProfile, GpuCanvasDiagnosticKind, GpuFrameProfile, RenderStatus, SurfaceAlphaMode, + SurfaceRenderer, }; use winit::{event_loop::ActiveEventLoop, window::Window}; @@ -219,6 +220,13 @@ impl Application { } (None, _) => renderer.render_notified(|| window.pre_present_notify()), }; + for diagnostic in renderer.take_gpu_canvas_diagnostics() { + let event = match diagnostic.kind { + GpuCanvasDiagnosticKind::Failed => RuntimeEvent::GpuCanvasFailed(diagnostic), + GpuCanvasDiagnosticKind::Recovered => RuntimeEvent::GpuCanvasRecovered(diagnostic), + }; + (self.on_event)(event); + } let result = match rendered { Ok(RenderStatus::Presented | RenderStatus::Skipped) => { if let Some(inspector) = &self.inspector { @@ -230,10 +238,18 @@ impl Application { offscreen_pixels: profile.effects.offscreen_pixels, cached_layers: profile.effects.cached_layers, damaged_pixels: profile.effects.damaged_pixels, - textures: profile.texture_pool.textures + 1, - reused_textures: profile.texture_pool.reused_this_frame, + textures: profile.texture_pool.textures + profile.gpu_canvases.entries + 1, + reused_textures: profile.texture_pool.reused_this_frame + + profile.gpu_canvases.hits_this_frame, texture_bytes: profile.texture_pool.allocated_bytes - + profile.vector_atlas.allocated_bytes, + + profile.vector_atlas.allocated_bytes + + profile.gpu_canvases.allocated_bytes, + gpu_canvas_entries: profile.gpu_canvases.entries, + gpu_canvas_bytes: profile.gpu_canvases.allocated_bytes, + gpu_canvas_renders: profile.gpu_canvases.renders_this_frame, + gpu_canvas_hits: profile.gpu_canvases.hits_this_frame, + gpu_canvas_failures: profile.gpu_canvases.failures_this_frame, + gpu_canvas_encode_cpu: profile.gpu_canvases.encode_time, vector_atlas_entries: profile.vector_atlas.entries, vector_atlas_bytes: profile.vector_atlas.allocated_bytes, vector_atlas_hits: profile.vector_atlas.hits_this_frame, diff --git a/crates/argui-runtime/src/app/scroll.rs b/crates/argui-runtime/src/app/scroll.rs index ae1a5ab1..888fd2ce 100644 --- a/crates/argui-runtime/src/app/scroll.rs +++ b/crates/argui-runtime/src/app/scroll.rs @@ -349,9 +349,16 @@ impl Application { let point = pending.point; let delta = pending.delta; if pending.dispatch_wheel { + // Deliver the event to the pointer hit target; default scrolling still + // uses the independently latched scroll-region target below. let wheel_update = match (&self.ui_layout, &mut self.ui_tree) { - (Some(_), Some(ui)) => pending - .target + (Some(layout), Some(ui)) => layout + .hit_regions + .iter() + .rev() + .find(|region| region.enabled && region.contains(point)) + .map(|region| region.node) + .or(pending.target) .or_else(|| ui.node_id_at(0)) .map_or_else(argui_ui::InteractionUpdate::default, |target| { ui.wheel_event_from(target, point, delta) diff --git a/crates/argui-runtime/src/event.rs b/crates/argui-runtime/src/event.rs index 73a253bf..311efba7 100644 --- a/crates/argui-runtime/src/event.rs +++ b/crates/argui-runtime/src/event.rs @@ -24,6 +24,10 @@ pub enum RuntimeEvent { /// Renderer initialization succeeded after selecting a compatibility fallback. RendererFallback(String), RendererFailed(String), + /// One retained GPU canvas entered a recoverable failure state. + GpuCanvasFailed(argui_render::GpuCanvasDiagnostic), + /// One retained GPU canvas recovered after a later successful render. + GpuCanvasRecovered(argui_render::GpuCanvasDiagnostic), LayoutFailed(String), DesktopBackdropUnavailable(String), /// A requested native overlay was retained in its parent surface. @@ -56,6 +60,10 @@ pub enum WindowRuntimeEvent { /// Renderer initialization succeeded after selecting a compatibility fallback. RendererFallback(String), RendererFailed(String), + /// One retained GPU canvas entered a recoverable failure state. + GpuCanvasFailed(argui_render::GpuCanvasDiagnostic), + /// One retained GPU canvas recovered after a later successful render. + GpuCanvasRecovered(argui_render::GpuCanvasDiagnostic), LayoutFailed(String), DesktopBackdropUnavailable(String), /// A requested native overlay was retained in its parent surface. @@ -76,6 +84,8 @@ impl RuntimeEvent { Self::AnimationProfile(event) => WindowRuntimeEvent::AnimationProfile(event), Self::RendererFallback(event) => WindowRuntimeEvent::RendererFallback(event), Self::RendererFailed(event) => WindowRuntimeEvent::RendererFailed(event), + Self::GpuCanvasFailed(event) => WindowRuntimeEvent::GpuCanvasFailed(event), + Self::GpuCanvasRecovered(event) => WindowRuntimeEvent::GpuCanvasRecovered(event), Self::LayoutFailed(event) => WindowRuntimeEvent::LayoutFailed(event), Self::DesktopBackdropUnavailable(reason) => { WindowRuntimeEvent::DesktopBackdropUnavailable(reason) diff --git a/crates/argui-runtime/src/host/gtk.rs b/crates/argui-runtime/src/host/gtk.rs index f16fa2fd..d0280164 100644 --- a/crates/argui-runtime/src/host/gtk.rs +++ b/crates/argui-runtime/src/host/gtk.rs @@ -20,6 +20,8 @@ impl GtkHost { pub(crate) fn redraw_pending(&self) -> bool { self.redraw_requested.get() + && self.platform.native().is_visible() + && !self.platform.native().is_minimized() } } @@ -28,6 +30,9 @@ impl WindowHost for GtkHost { HostId::Gtk(self.platform.native().id()) } fn set_visible(&self, visible: bool) { + if !visible { + self.redraw_requested.set(false); + } self.platform.native().set_visible(visible); } fn is_visible(&self) -> Option { @@ -43,6 +48,9 @@ impl WindowHost for GtkHost { Some(self.platform.native().is_minimized()) } fn set_minimized(&self, minimized: bool) { + if minimized { + self.redraw_requested.set(false); + } self.platform.native().set_minimized(minimized); } fn set_window_level(&self, level: WindowLevel) { diff --git a/crates/argui-runtime/tests/app/gtk.rs b/crates/argui-runtime/tests/app/gtk.rs index 8ed1e486..9ce914a4 100644 --- a/crates/argui-runtime/tests/app/gtk.rs +++ b/crates/argui-runtime/tests/app/gtk.rs @@ -73,22 +73,21 @@ pub fn keys() -> Vec<(gtk::gdk::keys::Key, argui_core::Key)> { keys } pub fn send() { - use gtk::{gdk, prelude::*}; + use gtk::{gdk, glib::translate::ToGlibPtr, prelude::*}; let window = gtk::Window::list_toplevels() .into_iter() .filter_map(|widget| widget.downcast::().ok()) .find(|window| window.title().as_deref() == Some("Argui updated lifecycle check")) .expect("the lifecycle window is mounted"); + let native_window = window.window().expect("the lifecycle window is realized"); send_pointer(&window); for (key, _) in keys() { - for (kind, signal) in [ - (gdk::EventType::KeyPress, "key-press-event"), - (gdk::EventType::KeyRelease, "key-release-event"), - ] { + for kind in [gdk::EventType::KeyPress, gdk::EventType::KeyRelease] { let mut event = gdk::Event::new(kind).downcast::().unwrap(); + event.as_mut().window = native_window.to_glib_full(); event.as_mut().keyval = *key; event.as_mut().send_event = 1; - window.emit_by_name::(signal, &[&*event]); + event.put(); } } } @@ -105,35 +104,36 @@ fn buttons() -> [(u32, argui_core::PointerButton, u16); 5] { } fn send_pointer(window: >k::Window) { - use gtk::{gdk, prelude::*}; + use gtk::{gdk, glib::translate::ToGlibPtr, prelude::*}; + let native_window = window.window().expect("the lifecycle window is realized"); let mut motion = gdk::Event::new(gdk::EventType::MotionNotify) .downcast::() .unwrap(); + motion.as_mut().window = native_window.to_glib_full(); motion.as_mut().send_event = 1; motion.as_mut().time = 100; motion.set_device(window.display().default_seat().unwrap().pointer().as_ref()); - window.emit_by_name::("motion-notify-event", &[&*motion]); + motion.put(); for (button, _, _) in buttons() { - for (kind, signal) in [ - (gdk::EventType::ButtonPress, "button-press-event"), - (gdk::EventType::ButtonRelease, "button-release-event"), - ] { + for kind in [gdk::EventType::ButtonPress, gdk::EventType::ButtonRelease] { let mut event = gdk::Event::new(kind) .downcast::() .unwrap(); + event.as_mut().window = native_window.to_glib_full(); event.as_mut().button = button; event.as_mut().time = button; event.as_mut().send_event = 1; - window.emit_by_name::(signal, &[&*event]); + event.put(); } } let mut event = gdk::Event::new(gdk::EventType::Scroll) .downcast::() .unwrap(); + event.as_mut().window = native_window.to_glib_full(); event.as_mut().direction = gdk::ffi::GDK_SCROLL_SMOOTH; event.as_mut().delta_x = 2.0; event.as_mut().delta_y = -3.0; - window.emit_by_name::("scroll-event", &[&*event]); + event.put(); } pub fn assert_pointer(pointer: &[argui_core::PointerEvent], wheel: &[argui_core::ScrollDelta]) { diff --git a/crates/argui-ui/src/custom.rs b/crates/argui-ui/src/custom.rs index 2bfcfba9..42c4fe9c 100644 --- a/crates/argui-ui/src/custom.rs +++ b/crates/argui-ui/src/custom.rs @@ -1,5 +1,5 @@ use argui_core::{Affine2D, Rect, Size}; -use argui_paint::{ClipChain, DisplayList, Quad, QuadStyle}; +use argui_paint::{ClipChain, DisplayList, GpuCanvasPrimitive, Quad, QuadStyle, RenderObjectId}; use std::{ any::{Any, TypeId}, cell::{Cell, RefCell}, @@ -107,6 +107,12 @@ pub struct CustomPaintContext<'a> { pub transform: Affine2D, pub clips: &'a ClipChain, pub display_list: &'a mut DisplayList, + /// Retained identity shared by primitives emitted from this custom node. + pub object: RenderObjectId, + /// Resolved paint opacity of the custom node. + pub opacity: f32, + /// Resolved rounded corners of the custom node. + pub radii: argui_paint::CornerRadii, } impl CustomPaintContext<'_> { @@ -130,6 +136,29 @@ impl CustomPaintContext<'_> { clips: self.clips.clone(), }); } + + /// Adds a retained GPU canvas in element-local coordinates. + /// + /// `slot` distinguishes multiple canvases emitted by the same retained + /// custom node. Reusing a slot in one frame is diagnosed by the renderer. + /// `bounds` is translated by the custom element's content-box origin. + pub fn gpu_canvas(&mut self, slot: u32, mut bounds: Rect, spec: crate::GpuCanvasSpec) { + bounds.origin.x += self.bounds.origin.x; + bounds.origin.y += self.bounds.origin.y; + self.display_list.push_gpu_canvas(GpuCanvasPrimitive { + canvas: spec.canvas(), + object: self.object, + slot, + bounds, + content_revision: spec.revision(), + resolution_scale: spec.scale(), + sampling: spec.image_sampling(), + opacity: self.opacity, + radii: self.radii, + transform: self.transform, + clips: self.clips.clone(), + }); + } } trait Erased: fmt::Debug { @@ -274,6 +303,9 @@ impl CustomDescription { && cached.bounds == context.bounds && cached.transform == context.transform && cached.clips == *context.clips + && cached.object == context.object + && cached.opacity == context.opacity + && cached.radii == context.radii { context .display_list @@ -303,6 +335,9 @@ impl CustomDescription { transform: context.transform, clips: context.clips, display_list: &mut commands, + object: context.object, + opacity: context.opacity, + radii: context.radii, }, ); context @@ -313,6 +348,9 @@ impl CustomDescription { bounds: context.bounds, transform: context.transform, clips: context.clips.clone(), + object: context.object, + opacity: context.opacity, + radii: context.radii, commands, }); } @@ -324,6 +362,9 @@ struct CachedCustomPaint { bounds: Rect, transform: Affine2D, clips: ClipChain, + object: RenderObjectId, + opacity: f32, + radii: argui_paint::CornerRadii, commands: DisplayList, } diff --git a/crates/argui-ui/src/element/kind.rs b/crates/argui-ui/src/element/kind.rs index 81bdf55e..1f567257 100644 --- a/crates/argui-ui/src/element/kind.rs +++ b/crates/argui-ui/src/element/kind.rs @@ -65,6 +65,7 @@ pub struct ElementNode { #[derive(Clone, Debug, PartialEq)] pub enum ElementKind { Custom(crate::CustomDescription), + GpuCanvas(crate::GpuCanvasSpec), Container, Text { content: TextContent, diff --git a/crates/argui-ui/src/gpu_canvas.rs b/crates/argui-ui/src/gpu_canvas.rs new file mode 100644 index 00000000..8e43d473 --- /dev/null +++ b/crates/argui-ui/src/gpu_canvas.rs @@ -0,0 +1,96 @@ +use argui_paint::{GpuCanvasId, ImageSampling}; + +/// Renderer-independent options for a retained GPU-canvas element. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GpuCanvasSpec { + canvas: GpuCanvasId, + content_revision: u64, + resolution_scale: f32, + sampling: ImageSampling, +} + +impl GpuCanvasSpec { + /// Smallest supported explicit canvas resolution multiplier. + pub const MIN_RESOLUTION_SCALE: f32 = 0.125; + /// Largest supported explicit canvas resolution multiplier. + pub const MAX_RESOLUTION_SCALE: f32 = 4.0; + + /// Creates a canvas specification for `canvas` at revision zero. + #[must_use] + pub const fn new(canvas: GpuCanvasId) -> Self { + Self { + canvas, + content_revision: 0, + resolution_scale: 1.0, + sampling: ImageSampling::Linear, + } + } + + /// Sets the application-authored pixel-content revision. + /// + /// Increment this value whenever application data used by the GPU callback + /// changes. Geometry-only changes do not require a new revision. + #[must_use] + pub const fn content_revision(mut self, revision: u64) -> Self { + self.content_revision = revision; + self + } + + /// Sets the sampling mode used to compose the retained texture. + #[must_use] + pub const fn sampling(mut self, sampling: ImageSampling) -> Self { + self.sampling = sampling; + self + } + + /// Sets the texture-resolution multiplier after normal DPI scaling. + /// + /// Finite positive values are clamped to the supported range. Non-finite + /// and non-positive values normalize to the default of `1.0`. + #[must_use] + pub fn resolution_scale(mut self, scale: f32) -> Self { + self.resolution_scale = if scale.is_finite() && scale > 0.0 { + scale.clamp(Self::MIN_RESOLUTION_SCALE, Self::MAX_RESOLUTION_SCALE) + } else { + 1.0 + }; + self + } + + /// Returns the registered canvas factory identity. + #[must_use] + pub const fn canvas(&self) -> GpuCanvasId { + self.canvas + } + + /// Returns the application-authored pixel-content revision. + #[must_use] + pub const fn revision(&self) -> u64 { + self.content_revision + } + + /// Returns the validated explicit texture-resolution multiplier. + #[must_use] + pub const fn scale(&self) -> f32 { + self.resolution_scale + } + + /// Returns the composition sampling mode. + #[must_use] + pub const fn image_sampling(&self) -> ImageSampling { + self.sampling + } +} + +impl crate::Element { + /// Creates a retained GPU-canvas leaf using renderer-independent `spec`. + /// + /// Layout, interaction, semantics, transforms, clipping, opacity, rounded + /// corners and effects use the normal [`crate::Element`] APIs. + #[must_use] + pub fn gpu_canvas(spec: GpuCanvasSpec) -> Self { + let mut element = Self::container([]); + element.kind = crate::ElementKind::GpuCanvas(spec); + element + } +} diff --git a/crates/argui-ui/src/identity.rs b/crates/argui-ui/src/identity.rs index 5b53c73b..b3f83ea1 100644 --- a/crates/argui-ui/src/identity.rs +++ b/crates/argui-ui/src/identity.rs @@ -88,6 +88,7 @@ fn compatible(old: &Element, new: &Element) -> bool { && matches!( (&old.kind, &new.kind), (ElementKind::Container, ElementKind::Container) + | (ElementKind::GpuCanvas(_), ElementKind::GpuCanvas(_)) | (ElementKind::Text { .. }, ElementKind::Text { .. }) | ( ElementKind::TextEditor { .. }, diff --git a/crates/argui-ui/src/layout_builders.rs b/crates/argui-ui/src/layout_builders.rs index 3a2e896b..cc843a68 100644 --- a/crates/argui-ui/src/layout_builders.rs +++ b/crates/argui-ui/src/layout_builders.rs @@ -178,6 +178,7 @@ impl Element { placeholder_text.align = align; } ElementKind::Custom(_) + | ElementKind::GpuCanvas(_) | ElementKind::Container | ElementKind::Image { .. } | ElementKind::Vector { .. } => {} @@ -199,6 +200,7 @@ impl Element { placeholder_text.overflow = overflow; } ElementKind::Custom(_) + | ElementKind::GpuCanvas(_) | ElementKind::Container | ElementKind::Image { .. } | ElementKind::Vector { .. } => {} diff --git a/crates/argui-ui/src/lib.rs b/crates/argui-ui/src/lib.rs index 6b00b8b5..0e4afa94 100644 --- a/crates/argui-ui/src/lib.rs +++ b/crates/argui-ui/src/lib.rs @@ -25,6 +25,7 @@ mod event; mod focus; pub use argui_accessibility::FocusPolicy; mod gesture; +mod gpu_canvas; mod identity; mod interaction; mod layout_builders; @@ -55,9 +56,9 @@ pub use argui_animation::{Motion, MotionBinding, MotionState, Transition, Tween} pub use argui_core::{Insets, Transform2D, TransformOrigin}; pub use argui_paint::{ BlendMode, Border, BorderWidths, Color, CornerRadii, EffectArgument, EffectId, EffectInstance, - EffectValue, Fill, Filter, GradientStop, ImageAsset, ImageFit, ImageId, ImageSampling, - LayerMask, LayerStyle, LinearGradient, PaintStyle, ProfileDomain, QuadStyle, RadialGradient, - Refraction, RenderObjectId, Shadow, VectorAsset, VectorId, + EffectValue, Fill, Filter, GpuCanvasId, GradientStop, ImageAsset, ImageFit, ImageId, + ImageSampling, LayerMask, LayerStyle, LinearGradient, PaintStyle, ProfileDomain, QuadStyle, + RadialGradient, Refraction, RenderObjectId, Shadow, VectorAsset, VectorId, }; pub use binding::{BindingImpact, MotionProperty, PropertyBinding, property}; pub use caret::{ @@ -75,6 +76,7 @@ pub use gesture::{ GestureArena, GestureCapture, GestureDelivery, GestureEvent, GestureKind, GesturePhase, GestureSet, PanAxis, PanGesture, PinchGesture, RotationGesture, TapGesture, }; +pub use gpu_canvas::GpuCanvasSpec; pub use interaction::{ HitRegion, HitShape, HitTestStyle, Interaction, InteractionUpdate, KeyboardActivation, NodeId, PointerEvents, WindowDragBehavior, diff --git a/crates/argui-ui/src/tree/transition/style.rs b/crates/argui-ui/src/tree/transition/style.rs index 4171e37d..80243895 100644 --- a/crates/argui-ui/src/tree/transition/style.rs +++ b/crates/argui-ui/src/tree/transition/style.rs @@ -222,6 +222,7 @@ fn base_values(element: &Element, scroll: Point) -> Vec { values.push(value(PropertyKey::VectorColor, StateValue::Color(*color))); } crate::ElementKind::Custom(_) + | crate::ElementKind::GpuCanvas(_) | crate::ElementKind::Container | crate::ElementKind::Image { .. } => {} } diff --git a/crates/argui-ui/src/update.rs b/crates/argui-ui/src/update.rs index dabf2f0f..e61bf140 100644 --- a/crates/argui-ui/src/update.rs +++ b/crates/argui-ui/src/update.rs @@ -155,6 +155,7 @@ fn kind_changes_layout(old: &Element, new: &Element) -> bool { match (&old.kind, &new.kind) { (ElementKind::Custom(old), ElementKind::Custom(new)) => !old.same_layout(new), (ElementKind::Container, ElementKind::Container) + | (ElementKind::GpuCanvas(_), ElementKind::GpuCanvas(_)) | (ElementKind::Image { .. }, ElementKind::Image { .. }) | (ElementKind::Vector { .. }, ElementKind::Vector { .. }) => false, ( diff --git a/crates/argui-ui/tests/element.rs b/crates/argui-ui/tests/element.rs index ae5fee88..bf1ae843 100644 --- a/crates/argui-ui/tests/element.rs +++ b/crates/argui-ui/tests/element.rs @@ -3,8 +3,8 @@ mod direction; use argui_paint::CornerRadii; use argui_text::{TextOverflow, TextStyle}; use argui_ui::{ - Color, Element, ElementKind, ImageFit, ImageId, ImageSampling, Insets, StateName, StylePatch, - UiTree, VectorId, property, + Color, Element, ElementKind, GpuCanvasId, GpuCanvasSpec, ImageFit, ImageId, ImageSampling, + Insets, StateName, StylePatch, UiTree, VectorId, property, }; #[path = "element/overrides.rs"] @@ -15,6 +15,32 @@ mod portal; const HOVERED: StateName = StateName::new("hovered-test"); +#[test] +fn gpu_canvas_spec_builds_an_opaque_leaf_and_normalizes_resolution() { + let id = GpuCanvasId::fresh(); + let spec = GpuCanvasSpec::new(id) + .content_revision(41) + .sampling(ImageSampling::Nearest) + .resolution_scale(f32::INFINITY); + let element = Element::gpu_canvas(spec); + let ElementKind::GpuCanvas(actual) = element.kind else { + panic!("GPU canvas constructor must create the dedicated leaf kind"); + }; + assert_eq!(actual.canvas(), id); + assert_eq!(actual.revision(), 41); + assert_eq!(actual.scale(), 1.0); + assert_eq!(actual.image_sampling(), ImageSampling::Nearest); + assert!(element.children.is_empty()); + assert_eq!( + GpuCanvasSpec::new(id).resolution_scale(99.0).scale(), + GpuCanvasSpec::MAX_RESOLUTION_SCALE + ); + assert_eq!( + GpuCanvasSpec::new(id).resolution_scale(0.01).scale(), + GpuCanvasSpec::MIN_RESOLUTION_SCALE + ); +} + #[test] fn final_radius_is_inherited_by_existing_visual_states() { let final_radius = CornerRadii::all(19.0); diff --git a/crates/argui-ui/tests/update.rs b/crates/argui-ui/tests/update.rs index 7866827d..63dd5433 100644 --- a/crates/argui-ui/tests/update.rs +++ b/crates/argui-ui/tests/update.rs @@ -1,4 +1,6 @@ -use argui_ui::{Color, Element, TreeUpdate, UiTree, UserSelect, length}; +use argui_ui::{ + Color, Element, GpuCanvasId, GpuCanvasSpec, TreeUpdate, UiTree, UserSelect, length, +}; fn screen(color: Color) -> Element { Element::row([ @@ -97,3 +99,25 @@ fn text_color_and_alpha_repaint_but_text_and_font_metrics_still_relayout() { TreeUpdate::Layout ); } + +#[test] +fn gpu_canvas_revision_retains_identity_while_remount_allocates_a_new_node() { + let canvas = GpuCanvasId::fresh(); + let view = |revision| { + Element::gpu_canvas(GpuCanvasSpec::new(canvas).content_revision(revision)).keyed("canvas") + }; + let mut tree = UiTree::new(Element::container([view(1)])); + let retained = tree.node_ids()[1]; + assert_eq!( + tree.update(Element::container([view(2)])), + TreeUpdate::Paint + ); + assert_eq!(tree.node_ids()[1], retained); + + assert_eq!(tree.update(Element::container([])), TreeUpdate::Layout); + assert_eq!( + tree.update(Element::container([view(2)])), + TreeUpdate::Layout + ); + assert_ne!(tree.node_ids()[1], retained); +} diff --git a/docs/README.md b/docs/README.md index 5d7562bc..a040183e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,7 +19,7 @@ Start with: | Models and lifetime | [Models](runtime/models.md) · [Tasks](runtime/tasks.md) | | Layout and input | [Styling](ui/styling.md) · [Interaction](ui/interaction.md) · [Scroll](ui/scroll.md) | | UI behavior | [Editing](ui/editing.md) · [Animation](ui/animation.md) · [Custom elements](ui/custom-elements.md) | -| Rendering | [Primitives](rendering/primitives.md) · [Effects](rendering/effects.md) | +| Rendering | [Primitives](rendering/primitives.md) · [Effects](rendering/effects.md) · [GPU canvases](rendering/gpu-canvas.md) | | Optional capabilities | [Localization](i18n.md) · [Hot reload](hot-reload.md) | | Widgets | [Catalogue](widgets/shadcn.md) · [Lists and tables](widgets/lists-tables.md) · [Overlays](widgets/overlays.md) | diff --git a/docs/architecture.md b/docs/architecture.md index c0f80b48..cba696ee 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -58,6 +58,13 @@ Painting preserves tree order. Compatible adjacent commands may batch, but batching never moves later content behind earlier content. Hit testing follows the same paint and clip order in reverse. +Retained GPU canvases preserve the same boundary. UI and paint store only an +opaque registration ID, retained object/slot, revision and composition data. +`argui-render` owns each bounded target texture, invokes application WGPU code +only when that target is dirty, then samples it in ordinary display/effect +order. Argui remains the sole owner of command submission and presentation. +See the [GPU canvas guide](rendering/gpu-canvas.md). + ## State and identity `Entity` owns shared model data. A renderable entity can have several diff --git a/docs/contributing/devtools.md b/docs/contributing/devtools.md index 179dff16..03c98c65 100644 --- a/docs/contributing/devtools.md +++ b/docs/contributing/devtools.md @@ -47,7 +47,7 @@ processed pixels, offscreen textures, and vector-atlas activity. GPU durations require timestamp-query support and remain separate from CPU durations. Pause freezes collection; Clear releases history. Export writes -strict `argui-gpu-trace-v3` JSON; import rejects unknown versions and fields. +strict `argui-gpu-trace-v4` JSON; import rejects unknown versions and fields. Detailed GPU profiling runs only while the Profiling pane is open and unpaused. Closing the tools stops recording. Selecting a frame freezes its details; Live diff --git a/docs/rendering/gpu-canvas.md b/docs/rendering/gpu-canvas.md new file mode 100644 index 00000000..351425da --- /dev/null +++ b/docs/rendering/gpu-canvas.md @@ -0,0 +1,346 @@ +# GPU canvases + +A GPU canvas embeds application-authored WGPU compute and render commands in +the normal retained Argui scene. It is intended for editor viewports, maps, +games, scientific visualization and other bounded views whose pixels are more +naturally produced on the GPU than as ordinary UI primitives. + +Use the smallest rendering boundary that fits the content: + +| Need | API | +| --- | --- | +| Text, fills, borders, images or vectors | normal `Element` primitives | +| New renderer-independent layout or paint behavior | `CustomElement` | +| Post-process an Argui layer | a registered custom effect | +| Encode application compute/render work into a retained texture | `Element::gpu_canvas` | + +A canvas does not give the application a second window surface. Argui still +selects the backend and adapter, creates the device, acquires the surface, +submits command buffers in order and presents. The application receives a +borrowed encoder and an offscreen target view only while a dirty canvas is +rendered. Externally owned WGPU instances, devices, queues and surfaces are not +accepted by the high-level runtime. + +## Mental model + +The UI and paint layers stay renderer-independent: + +```text +GpuCanvasSpec + retained NodeId + | + v +GpuCanvasPrimitive in the ordered display list + | + v +bounded retained sRGB texture ---- normal textured-quad composition + | (transform, clip, radii, opacity, effects) + v +application callback encodes only when revision or physical extent changes +``` + +The texture covers the visible local viewport. Ancestor transforms affect its +composition but do not silently increase its allocation. `resolution_scale` is +the explicit opt-in for lower-resolution or supersampled content. + +## Register a renderer before launch + +Every canvas is backed by an immutable startup registration. Cloning a +`GpuCanvasRegistration` preserves its process-local identity and factory. +Registry labels are stable diagnostic names; they are not cache keys. + +```rust,ignore +use std::sync::{Arc, RwLock}; +use argui::render::{ + GpuCanvasDeviceContext, GpuCanvasError, GpuCanvasFactory, + GpuCanvasRegistration, GpuCanvasRegistry, GpuCanvasRenderContext, + GpuCanvasRenderer, RendererConfig, wgpu, +}; + +#[derive(Default)] +struct Scene { revision: u64 } + +struct Factory { scene: Arc> } + +impl GpuCanvasFactory for Factory { + fn create( + &self, + context: &GpuCanvasDeviceContext<'_>, + ) -> Result, GpuCanvasError> { + let pipeline = create_pipeline(context.device(), context.target_format()); + Ok(Box::new(CanvasRenderer { + scene: Arc::clone(&self.scene), + pipeline, + })) + } +} + +struct CanvasRenderer { + scene: Arc>, + pipeline: wgpu::RenderPipeline, +} + +impl GpuCanvasRenderer for CanvasRenderer { + fn render( + &mut self, + context: &mut GpuCanvasRenderContext<'_>, + ) -> Result<(), GpuCanvasError> { + // Copy application data, then release the lock before submission/present. + let _revision = self.scene.read().map_err(|_| { + GpuCanvasError::new("scene state is unavailable") + })?.revision; + + let pipeline = &self.pipeline; + let (encoder, target) = context.encoder_and_target(); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("document-canvas"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: target, + depth_slice: None, + resolve_target: None, + // Argui already cleared dirty targets to transparent. + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + })], + ..Default::default() + }); + pass.set_pipeline(pipeline); + pass.draw(0..3, 0..1); + Ok(()) + } +} + +let scene = Arc::new(RwLock::new(Scene::default())); +let registration = GpuCanvasRegistration::new( + "editor.document", + Factory { scene: Arc::clone(&scene) }, +); +let canvas_id = registration.id(); +let registry = GpuCanvasRegistry::new([registration])?; +let renderer = RendererConfig::default() + .gpu_canvas_cache_bytes(128 * 1024 * 1024) + .gpu_canvases(registry); +``` + +Use `argui::render::wgpu`, not a separately versioned direct WGPU dependency. +The re-export is the exact version used by the public contexts and descriptor +types. + +Factories are lazy and surface-local. One registration can be used in several +windows, but every `SurfaceRenderer` creates its own `GpuCanvasRenderer` and +retained textures. A recreated surface keeps resources when the device and +format are unchanged; a new device creates factories and textures again. + +## Place the retained element + +Pass the registration ID and the scene's current pixel-content revision: + +```rust,ignore +use argui::ui::{Element, GpuCanvasSpec}; + +let viewport = Element::gpu_canvas( + GpuCanvasSpec::new(canvas_id) + .content_revision(scene_revision) + .resolution_scale(1.0), +) +.keyed("document-viewport") +.width(argui::ui::percent(1.0)) +.height(argui::ui::percent(1.0)); +``` + +It is an ordinary leaf. Width, height, transforms, opacity, rounded corners, +overflow clips, effect layers, hit testing, gestures, focus and semantics use +the same APIs as other elements. Later siblings and higher `z_index` content +compose above it. Canvas pixels do not generate semantics: give the leaf a +label and focus policy, and provide accessible Argui controls or semantic +overlays for important actions. + +`CustomPaintContext::gpu_canvas(slot, bounds, spec)` is the advanced path for a +custom element that emits several primitives. The local `slot` distinguishes +canvases owned by the same retained node. Reusing a slot in one frame is a +recoverable descriptor conflict. The helper deliberately remains WGPU-free so +`argui-ui`, custom layout, tests and paint caching do not depend on renderer +handles or device lifetime. + +## Context ownership and submission + +`GpuCanvasDeviceContext` exposes the selected device, queue, enabled features, +effective limits, sRGB target format and opaque device generation. A renderer +may keep owned buffers, textures, bind groups and pipelines created from the +device. + +`GpuCanvasRenderContext` additionally exposes the dedicated command encoder, +offscreen target view, exact physical extent, logical bounds, DPI scale, +requested resolution scale, registration ID, retained object/slot and frame +number. + +The lifetime boundary is strict: + +- Do not retain the encoder or target view after `render` returns. +- Do not call `Queue::submit`, acquire a surface, present, or block in device + polling. Argui submits successful canvas command buffers before the + compositor in one ordered submission. +- Queue `write_buffer` and `write_texture` calls are supported. Release model + locks before returning so submission and presentation never wait on user + synchronization. +- Use the supplied encoder for compute, render and copies. If `render` returns + `GpuCanvasError`, that entire encoder is discarded and other canvases and UI + still render. + +Each dirty canvas gets a separately labelled encoder. Argui clears a newly +allocated or dirty target to transparent before calling the renderer. + +On native targets a canvas renderer is `Send + Sync`, preserving the native +thread traits of `SurfaceRenderer`. Browser WGPU handles are thread-local, so +the same renderer trait has no thread bound on `wasm32`; application code and +method names are otherwise identical. + +## Revisions, resize and animation + +`content_revision` is application-owned. Increment it whenever data read by the +callback changes. Keep it fixed for an unchanged scene. Argui invokes the +callback when any pixel-producing input changes: + +- no successful texture exists yet; +- `content_revision` changes; +- logical size, DPI scale or `resolution_scale` changes; +- output format or device changes. + +Composition-only changes such as transform, clip, rounded corners and opacity +update the textured quad without rerunning the callback. Cached effect layers +are invalidated when canvas content or composition changes. + +Animation remains model-driven. While running, request animation frames, +advance the scene and revision, then notify the model. While paused, request no +frames and keep the revision fixed. Unrelated Argui redraws then count as cache +hits and do not invoke custom GPU work. The [GPU Canvas Lab](../../app_examples/gpu-canvas/) +demonstrates this pattern. + +## Texture size and memory + +Physical size is: + +```text +ceil(logical width × window scale × resolution scale) +ceil(logical height × window scale × resolution scale) +``` + +Zero-sized canvases allocate and draw nothing. Non-finite or negative geometry, +unrepresentable sizes, dimensions above `max_texture_dimension_2d` and a single +texture above the budget produce a placeholder instead of an allocation. + +The per-`SurfaceRenderer` cache has a 128 MiB default budget, configurable with +`RendererConfig::gpu_canvas_cache_bytes`. It pre-scans a frame, reserves a +deterministic display-order subset and never evicts a texture still needed in +that frame. Unreferenced entries become least-recently-used candidates. Cache +keys include registration, retained node identity and custom slot, so remounts +do not accidentally reuse another node's pixels. + +`RenderProfile::gpu_canvases` reports retained entries and bytes, dirty renders, +cache hits, failures and callback CPU encode time. Inspector records, strict GPU +trace JSON and DevTools expose the same values. This is CPU timing, not a claim +about the application's custom GPU pass duration; use WGPU timestamp queries +when that measurement is required. + +## Color, alpha and sampling + +The target uses the renderer surface's sRGB view format and is sampled with +`ImageSampling::Linear` by default. `Nearest` is available for pixel art and +discrete data. Shader outputs follow Argui's primitive contract: straight +linear RGBA, with normal alpha blending into premultiplied linear intermediate +targets. Return transparent colors with RGB already describing their straight +color; do not pre-darken edges by premultiplying them in the shader. + +Storage buffers and compute-intermediate textures may use other formats. The +final render pass must write the supplied target view using +`context.target_format()`. Pipelines are created per surface format, so do not +hard-code `Bgra8UnormSrgb` or `Rgba8UnormSrgb`. + +## Capabilities and fallback + +Factories that need more than the WebGPU baseline declare requirements before +device creation: + +```rust,ignore +fn requirements(&self) -> GpuCanvasRequirements { + GpuCanvasRequirements::default() + .required_features(wgpu::Features::TEXTURE_COMPRESSION_BC) + .optional_features(wgpu::Features::TIMESTAMP_QUERY) + .required_limits(custom_limits) + .reason("loads BC-compressed document tiles and optionally profiles them") +} +``` + +Required features are unioned. Optional features are enabled only when the +adapter supports them. Limits are merged using WGPU's direction-aware helpers, +including alignment limits where smaller values are better. Non-baseline +requirements must include a human-readable reason. + +Adapter validation names the registration and missing feature or insufficient +limit. `SurfaceRenderer::new_with_device` rejects a shared device that did not +enable required capabilities. On Windows, required capabilities are evaluated +for every configured backend attempt and their errors remain in the existing +fallback report. Optional capabilities alone do not force fallback. The same +baseline API works with native WGPU and browser WebGPU; unsupported browser +capabilities produce an initialization error instead of a blank canvas. + +## Errors and recovery + +Invalid registrations fail when the registry is built. Frame-local problems — +a missing ID, invalid extent, budget pressure, factory error, callback error or +duplicate retained key — display a deterministic magenta checkerboard while +surrounding UI remains valid. + +Drain structured state changes with +`SurfaceRenderer::take_gpu_canvas_diagnostics`. The high-level runtime does this +after main-window and native-popup renders and emits +`RuntimeEvent::GpuCanvasFailed` / `GpuCanvasRecovered`, or the corresponding +window-scoped variants. Identical failures are not repeated every frame. A +later successful revision emits one recovery event. Applications can use that +event to place a normal Argui error panel above the canvas. + +Native popups localize canvas transforms and clips like images and vectors. +Each popup owns its surface-local renderer/cache and reports its diagnostic +through the runtime rather than silently dropping the canvas. + +## Input, accessibility and testing + +A GPU canvas introduces no private input system. Attach normal Argui pointer, +wheel, gesture, focus, keyboard and semantic handlers. Always provide keyboard +alternatives for visible navigation controls. Expose important scene actions +as Argui buttons or semantic overlays; pixels alone have no accessible meaning. + +GPU-free UI/layout tests treat the element as an opaque leaf and can verify +layout, hit testing, focus, handlers and semantics without a renderer. +Downstream GPU-free harnesses, including `argui-testing` integrations, should +keep that boundary rather than execute WGPU callbacks. Renderer pixel, +blending, effect and callback-order correctness belongs in `argui-render` GPU +tests. On Linux, run graphical checks through +`scripts/linux-hidden-display.sh` and inspect saved captures. + +## Acceptance measurements + +The 0.3 implementation was exercised on Linux/Wayland with the Intel `i915` +WGPU adapter. These are single-run acceptance observations, not portable +benchmarks: + +| Transition or output | Observed result | +| --- | --- | +| First 48×32 canvas creation and callback, debug GPU test | 7.182 ms CPU prepare/encode | +| Same canvas after a content-revision change, pipelines already warm | 0.517 ms CPU prepare/encode | +| Unchanged Argui redraw | callback count stayed at 1; one cache hit and zero dirty renders | +| Retained 48×32 BGRA target | 6,144 bytes, unchanged across the cache hit | +| Browser pause after pending frames settled | zero new `requestAnimationFrame` callbacks over 700 ms | +| Native release executable | 29,784,472 bytes | +| Optimized WebAssembly release module | 6,343,348 bytes | + +The native hidden-display capture and browser captures covered initial render, +pan/zoom, the clipped effect layer and overlay, failure placeholder, recovery, +pause, resize and a 2× device scale. The exact timings and artifact sizes vary +by toolchain and target; the required invariants are the zero unchanged +callback, bounded cache bytes and stopped paused animation loop. + +For a native and WebAssembly reference including compute, render, pan, zoom, +pause/resume, clipping, an effect, overlays and in-app recovery diagnostics, +see [GPU Canvas Lab](../../app_examples/gpu-canvas/README.md). diff --git a/docs/rendering/primitives.md b/docs/rendering/primitives.md index 16f38533..c0f4d577 100644 --- a/docs/rendering/primitives.md +++ b/docs/rendering/primitives.md @@ -108,3 +108,16 @@ crossfade or transform animation between assets. `RenderProfile::vector_atlas` reports entries, hits, rasterizations, and allocated bytes. DevTools records the same values in GPU traces. + +## GPU canvases + +`Element::gpu_canvas` composes application-authored WGPU output as a retained +offscreen texture. It participates in normal display order, transforms, clips, +rounded corners, opacity and effects while Argui continues to own the device, +submission, surface and presentation. An explicit content revision prevents +unchanged or paused canvases from rerunning their callback. + +Use it for bounded editor, visualization, map, game or scientific viewports, +not for text and controls that ordinary Argui primitives already render and +make accessible. Registration, memory, alpha, capability, diagnostics and +native/WebAssembly contracts are in the [GPU canvas guide](gpu-canvas.md). diff --git a/docs/roadmap.md b/docs/roadmap.md index ccd2611b..40efc72e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -23,4 +23,9 @@ work that remains open; it is not a release schedule. - Geometry transitions for insertion, removal and reordering. - A documented compatibility policy as the public API stabilizes. +Retained custom WGPU viewports are complete for Argui-owned devices and +surfaces; see [GPU canvases](rendering/gpu-canvas.md). A lower-level API for +embedding externally owned WGPU instances, devices or surfaces remains outside +the high-level runtime and would require a separate ownership design. + Performance work follows [measured workloads](performance/optimizations.md). diff --git a/scripts/check-coverage.sh b/scripts/check-coverage.sh index a8cc068d..8bbdf7c1 100755 --- a/scripts/check-coverage.sh +++ b/scripts/check-coverage.sh @@ -3,7 +3,7 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" report="$repo_root/target/coverage-report.json" -coverage_lock="$repo_root/target/.argui-coverage-lock" +coverage_lock="$repo_root/target/.argui-coverage.flock" coverage_target="$repo_root/target/coverage" minimum=85 coverage_toolchain="${ARGUI_COVERAGE_TOOLCHAIN:-nightly}" @@ -18,16 +18,20 @@ command -v cargo-nextest >/dev/null || { echo "error: cargo-nextest is required to run the coverage suite" >&2 exit 1 } +command -v flock >/dev/null || { + echo "error: flock is required to serialize coverage runs" >&2 + exit 1 +} cd "$repo_root" python3 -m unittest discover -s tests/scripts mkdir -p "$repo_root/target" -if ! mkdir "$coverage_lock" 2>/dev/null; then +exec 9>"$coverage_lock" +if ! flock -n 9; then echo "error: another Argui coverage run is already active" >&2 exit 1 fi cleanup() { - rmdir "$coverage_lock" 2>/dev/null || true if [[ -z "${ARGUI_KEEP_COVERAGE_ARTIFACTS:-}" ]]; then cargo clean --target-dir "$coverage_target" >/dev/null 2>&1 || true fi diff --git a/scripts/release.py b/scripts/release.py index 533cc734..311e887d 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -7,6 +7,7 @@ import os from pathlib import Path import re +import shutil import subprocess import sys import tempfile @@ -101,6 +102,15 @@ def workspace(): def package_archives(): current, names = workspace() + metadata = json.loads(run('cargo', 'metadata', '--locked', '--no-deps', + '--format-version', '1', capture=True)) + package_directory = Path(metadata['target_directory']) / 'package' + if package_directory.exists(): + shutil.rmtree(package_directory) + clean = ['cargo', 'clean', '--locked', '--profile', 'dev'] + for name in names: + clean.extend(['--package', name]) + run(*clean) args = ['cargo', 'package', '--locked', '--all-features', '--allow-dirty'] for name in names: args.extend(['--package', name]) diff --git a/tests/scripts/test_release.py b/tests/scripts/test_release.py index 240362d0..7b89af56 100644 --- a/tests/scripts/test_release.py +++ b/tests/scripts/test_release.py @@ -206,10 +206,20 @@ def test_publication_order_rejects_cycles_and_facade_dependents(self): def test_package_verifies_every_archive_in_publication_order(self): names = ['argui-core', 'argui-render', 'argui'] - with patch.object(release, 'workspace', return_value=('0.1.0', names)), \ - patch.object(release, 'run') as run: - release.package_archives() - self.assertEqual(run.call_args.args, ( + with tempfile.TemporaryDirectory() as directory: + package_directory = Path(directory) / 'package' + package_directory.mkdir() + (package_directory / 'stale-registry').write_text('old archive') + metadata = json.dumps({'target_directory': directory}) + with patch.object(release, 'workspace', return_value=('0.1.0', names)), \ + patch.object(release, 'run', side_effect=[metadata, None, None]) as run: + release.package_archives() + self.assertFalse(package_directory.exists()) + self.assertEqual(run.call_args_list[1].args, ( + 'cargo', 'clean', '--locked', '--profile', 'dev', + '--package', 'argui-core', '--package', 'argui-render', '--package', 'argui', + )) + self.assertEqual(run.call_args_list[2].args, ( 'cargo', 'package', '--locked', '--all-features', '--allow-dirty', '--package', 'argui-core', '--package', 'argui-render', '--package', 'argui', )) diff --git a/website/README.md b/website/README.md index ec9873dc..38dab284 100644 --- a/website/README.md +++ b/website/README.md @@ -15,9 +15,10 @@ pnpm dev ``` `gallery:build` needs `wasm-pack` and the `wasm32-unknown-unknown` Rust target. -It builds the real gallery, the AI harness and the exact-source documentation examples, and -at most six Cargo jobs and six WebAssembly optimization workers. If `web/widgets/pkg` is already built, use -`pnpm gallery:copy` instead. The generated binaries are ignored by Git. +It builds the real gallery, the AI harness, the GPU Canvas Lab and the exact-source +documentation examples, using at most six Cargo jobs and six WebAssembly optimization +workers. If the packages under `web/` are already built, use `pnpm gallery:copy` instead. +The generated binaries are ignored by Git. The rest of the website can be developed without building WASM. Component previews start automatically after hydration and open their matching diff --git a/website/app/assets/css/components.css b/website/app/assets/css/components.css index 2e59ea95..20e15ee6 100644 --- a/website/app/assets/css/components.css +++ b/website/app/assets/css/components.css @@ -348,7 +348,7 @@ } .app-example-list { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); margin-bottom: 14px; border-top: 1px solid var(--border); border-left: 1px solid var(--border); diff --git a/website/app/components/GalleryFrame.vue b/website/app/components/GalleryFrame.vue index 4bc37160..471c144a 100644 --- a/website/app/components/GalleryFrame.vue +++ b/website/app/components/GalleryFrame.vue @@ -1,7 +1,7 @@ @@ -50,8 +57,8 @@ const selectedSource = computed(() => +

{{ t('appExamples.startupEvidence') }} @@ -84,7 +103,7 @@ const selectedSource = computed(() => - +

diff --git a/website/i18n/locales/en.json b/website/i18n/locales/en.json index abdf1294..ff87f83a 100644 --- a/website/i18n/locales/en.json +++ b/website/i18n/locales/en.json @@ -148,8 +148,14 @@ "intro": "Complete, product-shaped Argui applications that run directly in the browser.", "harnessTitle": "AI streaming harness", "harnessBody": "A bundled adaptation of Wikipedia's Large language model article streams at 1,000 tokens per second through a responsive, virtualized conversation.", - "launch": "Launch AI harness", - "label": "Argui AI streaming harness", + "harnessLaunch": "Launch AI harness", + "harnessLabel": "Argui AI streaming harness", + "gpuTitle": "GPU Canvas Lab", + "gpuBody": "A retained custom WGPU canvas where application code creates pipelines from Argui's Device, writes through the Queue, and encodes compute and render passes into an Argui-owned target.", + "gpuLaunch": "Launch GPU Canvas Lab", + "gpuLabel": "Argui GPU Canvas Lab", + "gpuCategory": "Custom WGPU rendering", + "gpuProof": "Live compute and render passes", "source": "View the application source", "category": "Streaming and performance", "available": "Available Argui applications", diff --git a/website/public/examples/gpu-canvas/index.html b/website/public/examples/gpu-canvas/index.html new file mode 100644 index 00000000..8e7acb5f --- /dev/null +++ b/website/public/examples/gpu-canvas/index.html @@ -0,0 +1,81 @@ + + + + + + + Argui GPU Canvas Lab + + + + +
Loading the retained GPU canvas…
+ + + diff --git a/website/scripts/catalogue.mjs b/website/scripts/catalogue.mjs index 93970be6..61e72b80 100644 --- a/website/scripts/catalogue.mjs +++ b/website/scripts/catalogue.mjs @@ -36,12 +36,6 @@ const extraWidgets = [ 'Resizable panels with limits, pointer gestures and keyboard control.', null, ], - [ - 'text-selection', - 'Text selection', - 'Selection surfaces and contextual actions for retained text.', - 'Typography & selection', - ], [ 'tree-view', 'Tree view', diff --git a/website/scripts/gallery.mjs b/website/scripts/gallery.mjs index 3b9e065b..51b48719 100644 --- a/website/scripts/gallery.mjs +++ b/website/scripts/gallery.mjs @@ -6,6 +6,7 @@ import { resolve } from 'node:path' const root = fileURLToPath(new URL('../../', import.meta.url)) const destination = resolve(root, 'website/public/gallery') const aiDestination = resolve(root, 'website/public/examples/ai-harness') +const gpuDestination = resolve(root, 'website/public/examples/gpu-canvas') const docsDestination = resolve(root, 'website/public/examples/docs') const wasmEnvironment = { ...process.env, @@ -16,6 +17,7 @@ if (process.argv.includes('--check')) { try { await access(resolve(destination, 'pkg/argui_widget_gallery_bg.wasm')) await access(resolve(aiDestination, 'pkg/argui_example_ai_harness_bg.wasm')) + await access(resolve(gpuDestination, 'pkg/argui_example_gpu_canvas_bg.wasm')) await access(resolve(docsDestination, 'pkg/argui_example_docs_bg.wasm')) await access(resolve(root, 'website/public/browser-shortcuts.js')) } catch { @@ -58,6 +60,20 @@ if (process.argv.includes('--check')) { { cwd: root, stdio: 'inherit', env: wasmEnvironment }, ) if (exampleBuild.status !== 0) process.exit(exampleBuild.status ?? 1) + const gpuBuild = spawnSync( + 'wasm-pack', + [ + 'build', + 'app_examples/gpu-canvas', + '--target', + 'web', + '--release', + '--out-dir', + '../../web/examples/gpu-canvas/pkg', + ], + { cwd: root, stdio: 'inherit', env: wasmEnvironment }, + ) + if (gpuBuild.status !== 0) process.exit(gpuBuild.status ?? 1) const docsBuild = spawnSync( 'wasm-pack', [ @@ -75,16 +91,22 @@ if (process.argv.includes('--check')) { } await access(resolve(root, 'web/widgets/pkg/argui_widget_gallery_bg.wasm')) await access(resolve(root, 'web/examples/ai-harness/pkg/argui_example_ai_harness_bg.wasm')) + await access(resolve(root, 'web/examples/gpu-canvas/pkg/argui_example_gpu_canvas_bg.wasm')) await mkdir(destination, { recursive: true }) await mkdir(aiDestination, { recursive: true }) + await mkdir(gpuDestination, { recursive: true }) await mkdir(docsDestination, { recursive: true }) await rm(resolve(destination, 'pkg'), { recursive: true, force: true }) await rm(resolve(aiDestination, 'pkg'), { recursive: true, force: true }) + await rm(resolve(gpuDestination, 'pkg'), { recursive: true, force: true }) await rm(resolve(docsDestination, 'pkg'), { recursive: true, force: true }) await cp(resolve(root, 'web/widgets/pkg'), resolve(destination, 'pkg'), { recursive: true }) await cp(resolve(root, 'web/examples/ai-harness/pkg'), resolve(aiDestination, 'pkg'), { recursive: true, }) + await cp(resolve(root, 'web/examples/gpu-canvas/pkg'), resolve(gpuDestination, 'pkg'), { + recursive: true, + }) await cp(resolve(root, 'web/examples/docs/pkg'), resolve(docsDestination, 'pkg'), { recursive: true, }) diff --git a/website/server/routes/robots.txt.ts b/website/server/routes/robots.txt.ts index f0f06d9e..39508119 100644 --- a/website/server/routes/robots.txt.ts +++ b/website/server/routes/robots.txt.ts @@ -2,5 +2,5 @@ export default defineEventHandler((event) => { const origin = useRuntimeConfig(event).public.siteUrl.replace(/\/$/, '') const base = useRuntimeConfig(event).app.baseURL setHeader(event, 'content-type', 'text/plain; charset=utf-8') - return `User-agent: *\nAllow: /\nDisallow: ${base}gallery/\nDisallow: ${base}examples/ai-harness/\nDisallow: ${base}examples/docs/\n${origin ? `Sitemap: ${origin}/sitemap.xml\n` : ''}` + return `User-agent: *\nAllow: /\nDisallow: ${base}gallery/\nDisallow: ${base}examples/ai-harness/\nDisallow: ${base}examples/gpu-canvas/\nDisallow: ${base}examples/docs/\n${origin ? `Sitemap: ${origin}/sitemap.xml\n` : ''}` }) diff --git a/website/tests/browser.mjs b/website/tests/browser.mjs index 07021b00..d1a14bcb 100644 --- a/website/tests/browser.mjs +++ b/website/tests/browser.mjs @@ -127,7 +127,7 @@ try { await page.waitForSelector('.copy-button[aria-label="Copied"]') assert.match( await page.evaluate(() => navigator.clipboard.readText()), - /git clone https:\/\/github.com\/ExtraBinoss\/argui/, + /argui = \{ version = "0\.2\.1", features = \["widget-button"\] \}/, ) await page.goto(`${origin}/docs`, { waitUntil: 'networkidle0' }) @@ -264,10 +264,29 @@ try { await page.setViewport({ width: 1000, height: 820 }) await page.goto(`${origin}/examples`, { waitUntil: 'networkidle0' }) - assert.equal(await page.$$('.app-example-list button').then((items) => items.length), 2) + assert.equal(await page.$$('.app-example-list button').then((items) => items.length), 3) assert.equal(await page.$$('.more-example-grid a').then((items) => items.length), 4) assert.match(await page.$eval('.app-example-list', (element) => element.textContent), /0\.30 s/) assert.match(await page.$eval('.app-example-list', (element) => element.textContent), /0\.79 s/) + await page.setViewport({ width: 1000, height: 1050 }) + await page.click('.app-example-list button:nth-child(3)') + await page.waitForFunction(() => + document.querySelector('iframe')?.src.includes('/examples/gpu-canvas/index.html'), + ) + await page.waitForSelector('.status-dot.live', { timeout: 90_000 }) + const gpuIframe = await page.$('iframe') + const gpuFrame = await gpuIframe.contentFrame() + const gpuCanvas = await gpuFrame.waitForSelector( + '[role="img"][aria-label="Interactive GPU particle canvas"]', + ) + const gpuCanvasBounds = await gpuCanvas.evaluate((element) => + element.getBoundingClientRect().toJSON(), + ) + assert.ok(gpuCanvasBounds && gpuCanvasBounds.width > 400 && gpuCanvasBounds.height > 300) + await gpuFrame.$eval('button[aria-label="Drag Y: natural"]', (element) => element.click()) + await gpuFrame.waitForSelector('button[aria-label="Drag Y: inverted"]') + await screenshot('app-example-gpu-canvas') + await page.setViewport({ width: 1000, height: 820 }) await page.click('.app-example-list button:nth-child(2)') await page.waitForFunction(() => document.querySelector('.gallery-status')?.textContent.includes('Widget Gallery'), @@ -449,6 +468,25 @@ try { ) await screenshot('app-example-mobile-live') + await page.setViewport({ width: 390, height: 844, deviceScaleFactor: 2 }) + await page.goto(`${origin}/examples`, { waitUntil: 'networkidle0' }) + await page.click('.app-example-list button:nth-child(3)') + await page.waitForFunction(() => + document.querySelector('iframe')?.src.includes('/examples/gpu-canvas/index.html'), + ) + await page.$eval('iframe', (element) => element.scrollIntoView({ block: 'center' })) + await page.waitForSelector('.status-dot.live', { timeout: 90_000 }) + const mobileGpuFrame = await (await page.$('iframe')).contentFrame() + await mobileGpuFrame.waitForSelector('button[aria-label="Drag Y: natural"]') + const mobileGpuLayout = await mobileGpuFrame.$eval('canvas', (canvas) => ({ + canvas: canvas.clientWidth, + viewport: innerWidth, + overflow: document.documentElement.scrollWidth > innerWidth, + })) + assert.ok(mobileGpuLayout.canvas <= mobileGpuLayout.viewport) + assert.equal(mobileGpuLayout.overflow, false) + await screenshot('app-example-gpu-canvas-mobile') + const response = await page.goto(`${origin}/components/does-not-exist`, { waitUntil: 'networkidle0', }) diff --git a/website/tests/generated.mjs b/website/tests/generated.mjs index 2f3a5e53..dfa85b13 100644 --- a/website/tests/generated.mjs +++ b/website/tests/generated.mjs @@ -53,10 +53,12 @@ const sitemap = await readFile(resolve(output, 'sitemap.xml'), 'utf8') assert.equal((sitemap.match(//g) ?? []).length, origin ? routes.length : 0) assert.ok(!sitemap.includes('/gallery/')) assert.ok(!sitemap.includes('/examples/ai-harness/')) +assert.ok(!sitemap.includes('/examples/gpu-canvas/')) assert.ok(!sitemap.includes('/examples/docs/')) const robots = await readFile(resolve(output, 'robots.txt'), 'utf8') assert.ok(robots.includes(`Disallow: ${base}gallery/`)) assert.ok(robots.includes(`Disallow: ${base}examples/ai-harness/`)) +assert.ok(robots.includes(`Disallow: ${base}examples/gpu-canvas/`)) assert.ok(robots.includes(`Disallow: ${base}examples/docs/`)) assert.equal(robots.includes('Sitemap:'), Boolean(origin)) for (const asset of [ @@ -67,6 +69,8 @@ for (const asset of [ 'gallery/pkg/argui_widget_gallery_bg.wasm', 'examples/ai-harness/index.html', 'examples/ai-harness/pkg/argui_example_ai_harness_bg.wasm', + 'examples/gpu-canvas/index.html', + 'examples/gpu-canvas/pkg/argui_example_gpu_canvas_bg.wasm', 'examples/docs/index.html', 'examples/docs/pkg/argui_example_docs_bg.wasm', 'gallery-preview.webp',