diff --git a/.cargo/config.toml b/.cargo/config.toml index ebaf696b8..c9b58d263 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -9,6 +9,11 @@ ci-test = "ci-nextest --profile ci" ci-test-default = "nextest run --workspace --profile ci" # The nightly instruction-driven clock tests, which need their own build. ci-clock = "ci-nextest --profile ci-clock --features ci_clock,idle-pause" +# The wgpu raster unit, scan-out and presenter on a GPU, or on lavapipe where +# there is none. Run with VPRO_GPU=1 so the VPro test boards draw there; the +# ci-gpu profile picks the tests. --bins builds iris itself with the feature, +# which no other check does. +ci-gpu = "nextest run -p iris --lib --bins --features wgpu --profile ci-gpu" # The loom concurrency models, built under --cfg loom. A flag, not RUSTFLAGS, so # no environment is needed; cargo keeps the loom build beside the normal one in # the same target directory. diff --git a/.config/nextest.toml b/.config/nextest.toml index a3f68b481..ff885dcd4 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -18,6 +18,22 @@ default-filter = 'test(/::ci_clock_/)' fail-fast = false slow-timeout = { period = "60s", terminate-after = 20 } +# The tests `cargo ci-gpu` runs on a GPU (.github/workflows/wgpu.yml): the +# shared wgpu layer and presenter, and the VPro display through the GPU +# scan-out and the offscreen wgpu renderer. The VPro golden scenes stay out: +# two of them differ from their goldens on the GPU unit by one colour step. +[profile.ci-gpu] +default-filter = 'test(/^wgpu::|^dev::odyssey::tests::display::/)' +# Each test opens its own device. lavapipe refuses some ("parent device is +# lost") when dozens open at once, as on a many-core host; four, a hosted +# runner's count, pass. +test-threads = 4 +fail-fast = false +slow-timeout = { period = "60s", terminate-after = 20 } + +[profile.ci-gpu.junit] +path = "junit.xml" + # The threaded device tests that the nightly ThreadSanitizer run covers # (.github/workflows/tsan.yml). Add a test to a device module with one of these # names and it joins the run; the workflow lists nothing. diff --git a/.github/workflows/wgpu.yml b/.github/workflows/wgpu.yml new file mode 100644 index 000000000..337a4c3a7 --- /dev/null +++ b/.github/workflows/wgpu.yml @@ -0,0 +1,74 @@ +name: wgpu + +# The wgpu path on lavapipe, Mesa's software Vulkan driver: hosted runners +# have no GPU. It runs the shared wgpu layer, the presenter drawing into an +# offscreen texture, and the VPro display composing on the GPU scan-out and +# presenting through the offscreen wgpu renderer, the window's path. The +# display tests assert the display's frame counts (composed, left unchanged, +# presented from the GPU), so a display that composes every frame again, or +# stops presenting from the GPU, fails here without a window or a screenshot. +# The rest of CI builds no wgpu feature, so pull requests run this only when +# they touch the code it covers. +on: + push: + branches: [ "main" ] + paths: &paths + - 'src/wgpu/**' + - 'src/dev/odyssey/**' + - 'src/ui.rs' + - 'src/main.rs' + - 'src/gfx_display.rs' + - 'Cargo.toml' + - 'Cargo.lock' + - '.cargo/config.toml' + - '.config/nextest.toml' + - '.github/workflows/wgpu.yml' + pull_request: + branches: [ "main" ] + types: [ opened, synchronize, reopened, ready_for_review ] + paths: *paths + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + # libchdman-rs fetches a prebuilt archive at build time; compile from source + # when that download fails. + LIBCHDMAN_PREBUILT_FALLBACK: "1" + +jobs: + lavapipe: + name: wgpu on lavapipe + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - uses: actions/checkout@v5 + - name: Install pinned toolchain + run: rustup show + # mesa-vulkan-drivers carries lavapipe; with no other driver installed it + # is the adapter wgpu opens. + - name: Install dependencies + run: sudo apt-get update && sudo apt-get install -y pkg-config libasound2-dev mesa-vulkan-drivers libvulkan1 + # The wgpu feature changes the iris crate's build, so this job has its own + # key. Pull requests restore main's cache but never save one. + - uses: Swatinem/rust-cache@v2 + with: + key: wgpu + save-if: ${{ github.ref == 'refs/heads/main' }} + - uses: taiki-e/install-action@cfdb7ed4ccc7c7dce37759eae89dc5674b469b39 # v2.90.3 + with: + tool: nextest + # VPRO_GPU=1 puts the VPro test boards on the GPU raster unit. A board + # that cannot open the adapter fails its test, so a run with no usable + # lavapipe goes red even though the wgpu layer's own tests skip. + - name: Run the wgpu tests + run: VPRO_GPU=1 WGPU_BACKEND=vulkan cargo ci-gpu + - uses: mikepenz/action-junit-report@a16be3a69023e9bd7e748ede768b3282ea7b30ce # v6.7.1 + if: ${{ !cancelled() }} + with: + report_paths: target/nextest/ci-gpu/junit.xml + annotate_only: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fae8cf01..52a43f11c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,15 @@ hashes are given where a change is easiest to understand by reading the commit. ## November 2026 +### Display counters + +- `vpro frames [reset]` reports the VPro display thread's frames: how many it + composed, how many it skipped because nothing on screen changed, how many it + presented and from where, and how long a frame took. +- In `ci` mode with no window, `renderer = "wgpu"` now presents each frame into + an offscreen texture the way the window would, so the windowed display path + runs and can be measured on a machine with no display. + ### Serial logs append on every machine - New `serial_log_overwrite` (`--serial-log-overwrite`) starts `serial_log`, `serial_log_a` and `serial_log_b` over each run instead of adding to them. diff --git a/HELP.md b/HELP.md index 1f6a871cc..620cb37fd 100644 --- a/HELP.md +++ b/HELP.md @@ -528,7 +528,7 @@ standalone `iris` window if you need them. The window is freely resizable. At 1× and 2× scale the display is rendered with nearest-neighbour pixel-perfect sampling; at all other sizes trilinear filtering with mipmaps is used. `lock_aspect_ratio = false` in `iris.toml` allows free resizing with letterboxing. When the guest changes video mode (PROM, then IRIX during boot) the window snaps to the new resolution times the configured `scale` / `--2x`. -`renderer = "wgpu"` (or `--renderer wgpu`, in a build with the `wgpu` feature) draws the window through wgpu instead of OpenGL, with the same scaling rules. With a VPro or IMPACT whose raster unit is on the GPU (`raster = "wgpu"`) the window shares that GPU, and each frame goes from the board's memory to the screen without passing through main memory; screenshots and VNC still read frames back when they ask for them. Newport's display (Indy, Indigo2 XL) is composed on the GPU as well. Its legacy `IRIS_REX3_GPU` prototype shares the device in a `rex-gpu` build but still synchronizes through the CPU framebuffer. A separate GPU-resident Newport path is internal to tests and cannot be selected; Newport rejects `raster = "wgpu"` until M3 (complete REX3 command coverage). If wgpu cannot open the window, iris says so and draws with OpenGL. +`renderer = "wgpu"` (or `--renderer wgpu`, in a build with the `wgpu` feature) draws the window through wgpu instead of OpenGL, with the same scaling rules. With a VPro or IMPACT whose raster unit is on the GPU (`raster = "wgpu"`) the window shares that GPU, and each frame goes from the board's memory to the screen without passing through main memory; screenshots and VNC still read frames back when they ask for them. Newport's display (Indy, Indigo2 XL) is composed on the GPU as well. Its legacy `IRIS_REX3_GPU` prototype shares the device in a `rex-gpu` build but still synchronizes through the CPU framebuffer. A separate GPU-resident Newport path is internal to tests and cannot be selected; Newport rejects `raster = "wgpu"` until M3 (complete REX3 command coverage). If wgpu cannot open the window, iris says so and draws with OpenGL. In `ci` mode with no window (`ci_display` off), `renderer = "wgpu"` presents each frame into an offscreen texture the same way, so the window's path runs and can be measured (`vpro frames`) on a machine with no display; nothing is read back. ## VNC diff --git a/TESTING.md b/TESTING.md index e37d75498..a8c003f21 100644 --- a/TESTING.md +++ b/TESTING.md @@ -378,6 +378,7 @@ That single-file run is not corpus coverage. | `rust.yml` | every pull request and push to `main` | `cargo nextest run` on the workspace, a build of the release feature set, the interpreter's hot paths | | `loom.yml` | pull requests that change a file holding loom models (`cfg(loom)`), nightly on `main` at 04:30 UTC, and by hand from Actions | the `loom_tests` concurrency models, e.g. `intx::loom_tests`; locally `cargo loom` | | `tsan.yml` | nightly on `main` at 03:30 UTC, and by hand from Actions; gates nothing | the threaded device tests under ThreadSanitizer, to catch data races; the `tsan` nextest profile selects them; locally `RUSTFLAGS="-Zsanitizer=thread" CARGO_PROFILE_RELEASE_PANIC=unwind cargo nextest run -Zbuild-std --target x86_64-unknown-linux-gnu --release --lib --profile tsan` | +| `wgpu.yml` | pull requests and pushes to `main` that touch `src/wgpu/`, `src/dev/odyssey/`, the window or the test configuration, and by hand from Actions | `cargo ci-gpu` with `VPRO_GPU=1` on lavapipe (Mesa's software Vulkan): the wgpu layer and presenter, and the VPro display through the GPU scan-out and the offscreen wgpu renderer, checked by its frame counts (`vpro frames`) rather than by pictures | | `suites.yml` | pull requests and pushes that touch the CPU, `cpu-tests/`, `bench/` or the test code | the two bare-metal suites on each engine | | `nightly.yml` | every day at 05:00 UTC when `main` has moved, and by hand from Actions | the `build.yml` packages and IRIX boot, then publishes; a job beside them runs the default-feature tests and the `ci_clock` tests (`cargo ci-clock`) the pull-request run leaves out. It gates no publish | | `build.yml` | nightly and release | packages for every OS, then boots every IRIX image to the login prompt on each CLI build | diff --git a/debug.md b/debug.md index 9c31aa1cf..50384bed7 100644 --- a/debug.md +++ b/debug.md @@ -500,6 +500,7 @@ guest. `y = 0` is the top display line throughout. | `vpro unimp` | Offsets and command words the board reported as not modelled | | `vpro refusals [reset]` | Bus accesses answered busy, by reason, offset and width, with the longest time one access was kept retrying | | `vpro gpu [trace on\|off]` | With `[graphics] raster = "wgpu"`: the GPU raster unit's counters (records and batches, how the batches were submitted, bytes uploaded and read back, waits, readbacks and copies the copy kernel took) and the GPU scan-out's (frames composed, bytes read back; with `renderer = "wgpu"` frames are presented from the GPU and only screenshots, frame requests and VNC read them back); `trace on` counts readbacks by the board function that caused them from then on, shown under the counters. The same lines appear in `vpro status`. On the GPU unit the commands that read board memory (`pix`, `raw`, `peek`, `shot`, `fbdump`, `hash`, `mem` and the state commands) first read the unit's memory back into the board's copy, when the board can be quiesced | +| `vpro frames [reset]` | The display thread's frames: how many ran, their average and longest time, how many were composed again (and of those, how many only to settle the frame after a store), how many were left as they were because nothing the scan-out reads had changed, and how many were presented (and of those, from a buffer on the GPU). An idle screen composes nothing and presents only every sixth frame. The frame time is the display thread's whole frame; with the offscreen wgpu renderer in `ci` mode it includes waiting for the GPU to finish the frame before last. `reset` zeroes them. | | `vpro trace [fifo,draw,cpu,dma\|all]` / `vpro trace flush\|off` / `vpro trace` | Annotated trace (`gputrace` builds; also `IRIS_ODSY_TRACE=` from power-on): `FIFO` lines are command words as the parser completes them, named, with their operands as words and as floats where the word carries floats, plus copy rects, pattern and matrix loads, image boxes and bursts, queries and unknown words; `DRAW` lines each assembled primitive sent to the raster unit (`draw TRIANGLES n=3 ctx=1 textured depth queued cost 96`) and each clear; `CPU` lines every bus access that is not a command store, repeats collapsed; `DMA` lines the page-table descriptors. `flush` writes the buffer out (it is written at most twice a second otherwise); alone, the trace's state | | `vpro rec ` / `vpro rec mark\|off` / `vpro rec` | Replayable recording (record.rs; also `IRIS_ODSY_REC=` from power-on): every bus store and load and the DMA reads, gzip. A start, a `mark` and `off` wait for the board to go idle and write the memory hash (`vpro hash`) into the file, so a replay can check board memory at those points; a recording started mid-session cannot reproduce the live memory and says so when replayed, but still replays deterministically. Alone, the recording's state. Replay: `VPRO_REPLAY= cargo test --release --lib odyssey::golden::replay -- --nocapture` (see `src/dev/odyssey/testdata/README.md`) | diff --git a/src/dev/odyssey/debug.rs b/src/dev/odyssey/debug.rs index 87e786124..928b77fb5 100644 --- a/src/dev/odyssey/debug.rs +++ b/src/dev/odyssey/debug.rs @@ -93,6 +93,7 @@ pub(super) const HELP: &[&str] = &[ "vpro unimp offsets and command words reported as not modelled", "vpro refusals [reset] bus refusals by reason and register", "vpro gpu [trace on|off] the GPU raster unit's counters; trace counts readbacks by cause", + "vpro frames [reset] display frames: composed, unchanged, presented, frame time", "vpro trace [fifo,draw,cpu,dma|all] annotated trace (gputrace builds); alone: its state", "vpro trace flush|off write the trace out / close it", "vpro rec [|mark|off] replayable recording: start, mark, stop; alone: its state", @@ -280,6 +281,12 @@ impl Odyssey { self.gpu_run(|g| g.trace_waits(on)).ok_or("the raster unit is the CPU's (gpu off)")?; format!("gpu readback tracing {}\n", if on { "on" } else { "off" }) } + ["frames"] => self.frames_text(), + ["frames", "reset"] => { + self.display.reader.lock().counts = Default::default(); + self.display.stats.reset(); + String::new() + } ["refusals"] => { let mut v = Vec::new(); self.state.refusals.report(&mut v).map_err(|e| e.to_string())?; @@ -318,6 +325,23 @@ impl Odyssey { Some(out) } + fn frames_text(&self) -> String { + use std::sync::atomic::Ordering::Relaxed; + let c = self.display.reader.lock().counts; + let st = &self.display.stats; + let frames = st.frames.load(Relaxed); + format!( + "display: {frames} frames, {} us average, {} us longest\n composed {} ({} to settle), unchanged {}\n presented {} ({} from the GPU)\n", + st.busy_ns.load(Relaxed) / frames.max(1) / 1000, + st.worst_ns.load(Relaxed) / 1000, + c.composed, + c.settled, + c.unchanged, + st.presented.load(Relaxed) + st.presented_gpu.load(Relaxed), + st.presented_gpu.load(Relaxed), + ) + } + fn status_text(&self) -> Result { let mut out = String::new(); let fifo = &self.state.fifo; diff --git a/src/dev/odyssey/display.rs b/src/dev/odyssey/display.rs index 8aba33ce0..1b0cb217a 100644 --- a/src/dev/odyssey/display.rs +++ b/src/dev/odyssey/display.rs @@ -9,7 +9,7 @@ use super::consts::{ }; use super::fifo::display_fifo_pending; use super::reader::{DisplayRegs, DisplayShared, ScanoutReader}; -use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::{Arc, OnceLock}; use parking_lot::Mutex; @@ -47,6 +47,37 @@ pub(super) struct Display { /// Consumers (VNC) that read `screen.rgba` every frame /// (`GfxDisplay::set_capture`): while any does, frames are read back. capture: AtomicU32, + /// What the display thread did, for the monitor (`vpro frames`). + pub stats: DisplayStats, +} + +/// Frames the display thread ran and presented, and the time they took. +/// Relaxed counts: only the display thread adds to them, and the monitor +/// resets them (`vpro frames reset`). +#[derive(Default)] +pub(super) struct DisplayStats { + /// Frames presented from `screen`, and from a buffer on the GPU. + pub presented: AtomicU64, + pub presented_gpu: AtomicU64, + /// Frames run, and their time in all and the longest, in nanoseconds. + pub frames: AtomicU64, + pub busy_ns: AtomicU64, + pub worst_ns: AtomicU64, +} + +impl DisplayStats { + /// A frame that took `ns`. + pub(super) fn frame_took(&self, ns: u64) { + self.frames.fetch_add(1, Ordering::Relaxed); + self.busy_ns.fetch_add(ns, Ordering::Relaxed); + self.worst_ns.fetch_max(ns, Ordering::Relaxed); + } + + pub(super) fn reset(&self) { + for c in [&self.presented, &self.presented_gpu, &self.frames, &self.busy_ns, &self.worst_ns] { + c.store(0, Ordering::Relaxed); + } + } } struct Local { @@ -72,6 +103,7 @@ impl Display { status, shared: OnceLock::new(), capture: AtomicU32::new(0), + stats: DisplayStats::default(), } } @@ -111,6 +143,7 @@ impl Display { local.size = (w, h); } let stats = self.status.snapshot(std::time::Instant::now(), display_fifo_pending()); + self.stats.presented_gpu.fetch_add(1, Ordering::Relaxed); renderer.present_gpu_frame(frame, width, height, &mut screen, &mut local.overlay, &mut local.status, &mut local.texture, &stats); } } @@ -166,6 +199,7 @@ impl Display { } } *self.shown.lock() = (width, height); + self.stats.presented.fetch_add(1, Ordering::Relaxed); screen.frame_seq = screen.frame_seq.wrapping_add(1); // RCtrl+PrintScreen (ui.rs): save the screen as it is presented. if self.screenshot.swap(false, Ordering::AcqRel) { @@ -243,6 +277,13 @@ impl Board { pub fn scanout_update(&mut self) -> bool { self.test_reader().update() } + + /// As `scanout_update` on the GPU scan-out: None when it composes + /// nothing, else whether the frame was composed again. + #[cfg(feature = "wgpu")] + pub fn scanout_update_on_gpu(&mut self) -> Option { + self.test_reader().update_on_gpu().map(|(_, _, _, changed)| changed) + } } /// What one main drawable id shows, decided once per update instead of per diff --git a/src/dev/odyssey/mod.rs b/src/dev/odyssey/mod.rs index 3489ce969..36fde1f20 100644 --- a/src/dev/odyssey/mod.rs +++ b/src/dev/odyssey/mod.rs @@ -285,11 +285,13 @@ impl Odyssey { crate::pf_begin!(crate::perfetto::events::ODSY_FRAME_PLANES); let composed = reader.update_on_gpu(); crate::pf_end!(crate::perfetto::events::ODSY_FRAME_PLANES); - if let Some((buffer, w, h)) = composed { + if let Some((buffer, w, h, changed)) = composed { set_display_fifo_pending(state.fifo.level() as usize + state.raster.pending() as usize); crate::pf_end!(crate::perfetto::events::ODSY_CAPTURE); drop(reader); - if display.due(true) { + // An unchanged frame is the last buffer, presented only at + // the idle refresh. + if display.due(changed) { crate::pf_begin!(crate::perfetto::events::ODSY_PRESENT); display.present_gpu(&buffer, w, h); crate::pf_end!(crate::perfetto::events::ODSY_PRESENT); @@ -353,7 +355,7 @@ impl Device for Odyssey { fn register_commands(&self) -> Vec<(String, String)> { vec![( "vpro".to_string(), - "VPro (Odyssey) graphics: status|regs|fifo|state|geom|tex|win|mem|backend|lut|dispmode|cursor|pix|raw|peek|shot|fbdump|hash|unimp|refusals|gpu|trace|rec (vpro help)".to_string(), + "VPro (Odyssey) graphics: status|regs|fifo|state|geom|tex|win|mem|backend|lut|dispmode|cursor|pix|raw|peek|shot|fbdump|hash|unimp|refusals|gpu|frames|trace|rec (vpro help)".to_string(), )] } @@ -378,9 +380,11 @@ impl Device for Odyssey { .spawn(move || { let mut next = std::time::Instant::now(); while running.load(Ordering::Acquire) { + let began = std::time::Instant::now(); Odyssey::frame(&state, &display); - next += FRAME_PERIOD; let now = std::time::Instant::now(); + display.stats.frame_took((now - began).as_nanos() as u64); + next += FRAME_PERIOD; if next > now { std::thread::sleep(next - now); } else { diff --git a/src/dev/odyssey/reader.rs b/src/dev/odyssey/reader.rs index 700e54820..66eada1c8 100644 --- a/src/dev/odyssey/reader.rs +++ b/src/dev/odyssey/reader.rs @@ -400,6 +400,17 @@ impl DisplayShared { } } +/// What the reader did with the frames asked of it. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(super) struct FrameCounts { + /// Composed again: something the scan-out reads had changed. + pub composed: u64, + /// Of those, composed only to settle the frame after a store's mark. + pub settled: u64, + /// Not composed: nothing it reads had changed. + pub unchanged: u64, +} + /// The display's own state: the presented frame and the retrace clock. /// Only the display thread (or a test standing in for it) uses it. #[derive(Default)] @@ -424,8 +435,14 @@ pub(super) struct ScanoutReader { /// mark is not ordered against its data, so the frame may have missed /// the data; one more is composed to catch it. settling: bool, + /// The last frame composed on the GPU (`update_on_gpu`), what it was + /// composed from, and its size. + #[cfg(feature = "wgpu")] + gpu_composed: Option<((u64, u64, u32), wgpu::Buffer, u32, u32)>, /// The GPU scan-out failed once (reported), and the CPU composes. gpu_failed: bool, + /// For the monitor (`vpro frames`). + pub counts: FrameCounts, } impl ScanoutReader { @@ -440,6 +457,10 @@ impl ScanoutReader { self.snap_gen = u64::MAX; self.composed = None; self.settling = false; + #[cfg(feature = "wgpu")] + { + self.gpu_composed = None; + } self.frame = Vec::new(); self.frame_width = 0; self.frame_height = 0; @@ -489,26 +510,49 @@ impl ScanoutReader { /// As `update`, on the GPU scan-out with no readback: the frame stays /// in the scan-out's buffer, returned with its size, for a renderer on - /// the same device. None when there is no GPU scan-out, no frame, or it - /// fails (the caller then takes the `update` path). + /// the same device, and whether it was composed again (false: nothing + /// it reads has changed, and the buffer is the last frame's). None when + /// there is no GPU scan-out, no frame, or it fails (the caller then + /// takes the `update` path). A frame composed for a mark is followed by + /// one more, as in `update`. #[cfg(feature = "wgpu")] - pub fn update_on_gpu(&mut self) -> Option<(wgpu::Buffer, u32, u32)> { + pub fn update_on_gpu(&mut self) -> Option<(wgpu::Buffer, u32, u32, bool)> { let shared = self.shared(); let scanout = shared.gpu_scanout.get()?; if self.gpu_failed { return None; } + // Taken before anything is read, as in `update`. The GPU raster + // unit marks it after each submit and upload. + let stored = shared.vram.get().is_none_or(|mem| mem.take_written()); self.sync(&shared); if self.frame.is_empty() { return None; } let displayed = shared.front.lock().displayed_buffer; self.snap_gen = shared.regs.snapshot(&mut self.snap, self.snap_gen); + let now = (self.mode_gen, self.snap_gen, displayed); + if let Some((was, buffer, w, h)) = &self.gpu_composed { + if !stored && !self.settling && *was == now { + self.counts.unchanged += 1; + return Some((buffer.clone(), *w, *h, false)); + } + } + let same = self.gpu_composed.as_ref().is_some_and(|(was, ..)| *was == now); + // `frame` is not this one: the CPU path composes afresh if taken. + self.composed = None; + self.gpu_composed = None; + self.settling = stored; let frame = Frame::snapshot(&self.snap, displayed)?; let (w, h) = (frame.width.min(self.frame_width), frame.height.min(self.frame_height)); let composed = scanout.lock().compose(&frame, &self.snap, w, h); match composed { - Ok(buffer) => Some((buffer, w, h)), + Ok(buffer) => { + self.gpu_composed = Some((now, buffer.clone(), w, h)); + self.counts.composed += 1; + self.counts.settled += u64::from(!stored && same); + Some((buffer, w, h, true)) + } Err(e) => { self.gpu_failed = true; crate::dlog!(crate::devlog::LogModule::Odyssey, "sgi-odyssey: gpu scanout: {e}; composing on the CPU"); @@ -527,9 +571,6 @@ impl ScanoutReader { /// /// A frame composed for a store is followed by one more, since a store's /// mark can be seen before its data. - /// - /// The scan-out on the GPU draws into memory with no store the CPU - /// sees, so there every frame is composed. pub fn update(&mut self) -> bool { let shared = self.shared(); // Taken before anything is read: a store that lands from here on @@ -543,11 +584,18 @@ impl ScanoutReader { let displayed = shared.front.lock().displayed_buffer; self.snap_gen = shared.regs.snapshot(&mut self.snap, self.snap_gen); let now = Some((self.mode_gen, self.snap_gen, displayed)); - if !stored && !self.settling && self.composed == now && shared.gpu_scanout.get().is_none() { + if !stored && !self.settling && self.composed == now { + self.counts.unchanged += 1; return false; } + self.counts.composed += 1; + self.counts.settled += u64::from(!stored && self.composed == now); self.composed = now; self.settling = stored; + #[cfg(feature = "wgpu")] + { + self.gpu_composed = None; + } let (Some(frame), Some(mem)) = (Frame::snapshot(&self.snap, displayed), shared.vram.get()) else { // No scan-out programmed (out of reset, before the PROM sets // it up): nothing is shown. diff --git a/src/dev/odyssey/tests/display.rs b/src/dev/odyssey/tests/display.rs index 077c955e7..b363d57d6 100644 --- a/src/dev/odyssey/tests/display.rs +++ b/src/dev/odyssey/tests/display.rs @@ -868,9 +868,16 @@ fn an_unchanged_frame_is_not_composed_again() { #[test] fn every_change_to_what_the_scanout_reads_composes_a_new_frame() { type Change = fn(&mut Board); - let changes: [(&str, Change); 11] = [ + let changes: [(&str, Change); 12] = [ ("a draw", |s| rgb_fill(s, 0xff, 0, 0, 100, 100, 110, 110)), ("a pixel write", |s| pixel_write(s, 5, 5, 0x1122_3344)), + ("a copy", |s| { + // Four id-plane bytes copied to another row. + cmd(s, crate::dev::odyssey::consts::cmd::COPY_RECT_TRIGGER); + for w in [0x14_2000, 40 << 16 | 30, 0x0050_0050, 1 << 16 | 4, 0x8000_1000, 0x14_2000, 41 << 16 | 60, 0x3a00_0041] { + cmd(s, w); + } + }), ("a gamma entry", |s| backend_entry(s, crate::dev::odyssey::consts::ODSY_GAMMA_BASE + 0x100, 0x3ff)), ("a colour-map entry", |s| backend_entry(s, 7, LUT_RED)), ("a display mode", |s| dispmode(s, 3, DISPMODE_GROUP17)), @@ -887,11 +894,115 @@ fn every_change_to_what_the_scanout_reads_composes_a_new_frame() { for (what, change) in changes { let mut s = composed_board(); change(&mut s); - let stores = ["a draw", "a pixel write", "a reset"].contains(&what); + let stores = ["a draw", "a pixel write", "a copy", "a reset"].contains(&what); assert_eq!(composes(&mut s), if stores { 2 } else { 1 }, "{what} composes a new frame, once more for a store"); } } +/// The GPU scan-out keeps its last frame while nothing it reads has +/// changed, and composes a new one after each kind of change the display +/// can see, a draw, a copy and a pixel write among them (it needs +/// `VPRO_GPU=1`). +#[cfg(feature = "wgpu")] +#[test] +fn the_gpu_scanout_composes_only_a_changed_frame() { + if !gpu_unit_tests() { + return; + } + type Change = fn(&mut Board); + let changes: [(&str, Change); 4] = [ + ("a draw", |s| rgb_fill(s, 0xff, 0, 0, 100, 100, 110, 110)), + ("a pixel write", |s| pixel_write(s, 5, 5, 0x1122_3344)), + ("a colour-map entry", |s| backend_entry(s, 7, LUT_RED)), + ("the video mode", |s| s.set_scanout_size(1024, 768)), + ]; + for (what, change) in changes { + let (mut s, _) = board("v12"); + cursor_load(&mut s, 2, 3); + cursor_entry(&mut s, cursor::CONTROL, 1); + assert_eq!(s.scanout_update_on_gpu(), Some(true), "the first frame is composed"); + let settled = |s: &mut Board| { + let mut n = 0; + while s.scanout_update_on_gpu() == Some(true) { + n += 1; + assert!(n < 4, "frames keep being composed"); + } + n + }; + settled(&mut s); + for _ in 0..3 { + assert_eq!(s.scanout_update_on_gpu(), Some(false), "an idle frame is not composed"); + } + change(&mut s); + // A mark's frame is followed by one more, which catches data the + // mark was seen ahead of. + let marked = ["a draw", "a pixel write"].contains(&what); + assert_eq!(settled(&mut s), if marked { 2 } else { 1 }, "{what} composes a new frame, once more for a mark"); + } +} + +/// The display thread's frame as the wgpu window runs it, with the +/// renderer presenting into a texture instead (`ci` mode with +/// `renderer = "wgpu"`): each frame is composed on the GPU and presented +/// from there, an idle one is neither composed nor presented except at the +/// idle refresh, and a change composes exactly the frames it needs (it needs +/// `VPRO_GPU=1`). +#[cfg(feature = "wgpu")] +#[test] +fn the_offscreen_wgpu_renderer_presents_on_the_gpu_only_what_changed() { + use crate::dev::odyssey::display::Display; + use crate::dev::odyssey::mmio::BoardLocks; + use crate::dev::odyssey::reader::FrameCounts; + use crate::dev::odyssey::Odyssey; + use std::sync::atomic::Ordering::Relaxed; + use std::sync::Arc; + + if !gpu_unit_tests() { + return; + } + let (mut s, _) = board("v12"); + cursor_load(&mut s, 2, 3); + cursor_entry(&mut s, cursor::CONTROL, 1); + let locks = BoardLocks::new(s); + let display = Arc::new(Display::new(crate::disp::StatusSource::detached())); + display.attach_registers(locks.shared.clone()); + let gfx: Arc = display.clone(); + crate::ui::draw_offscreen(&gfx).expect("an offscreen presenter on the board's device"); + + // Twelve idle frames hold exactly two idle refreshes, whatever the + // phase; a presented change starts the count again. + let run = |change: fn(&mut Board)| { + change(locks.test_board()); + locks.test_board().raster_wait(); + display.reader.lock().counts = FrameCounts::default(); + display.stats.reset(); + for _ in 0..12 { + Odyssey::frame(&locks, &display); + } + let st = &display.stats; + (display.reader.lock().counts, st.presented_gpu.load(Relaxed), st.presented.load(Relaxed)) + }; + let idle = |c: FrameCounts| FrameCounts { unchanged: 12 - c.composed, ..c }; + let (first, _, _) = run(|_| {}); + assert!(first.composed >= 1, "the first frames are composed: {first:?}"); + assert_eq!(run(|_| {}), (idle(FrameCounts::default()), 2, 0), "an idle screen presents only at the idle refresh, from the GPU"); + + let (counts, on_gpu, from_screen) = run(|s| cursor_entry(s, cursor::POSITION, 9 << 16 | 4)); + assert_eq!(counts, idle(FrameCounts { composed: 1, settled: 0, unchanged: 0 }), "a register change composes one frame"); + assert_eq!((on_gpu, from_screen), (2, 0), "the changed frame and the idle refresh six frames on, both from the GPU"); + + let (counts, on_gpu, from_screen) = run(|s| rgb_fill(s, 0xff, 0, 0, 100, 100, 110, 110)); + assert_eq!(counts, idle(FrameCounts { composed: 2, settled: 1, unchanged: 0 }), "a draw composes its frame and one to settle"); + assert_eq!((on_gpu, from_screen), (3, 0), "both composed frames and the idle refresh after them, from the GPU"); + + // A new video mode: the renderer resizes its target, as it snaps the + // window, and the frame is composed once and presented from the GPU. + let (counts, on_gpu, from_screen) = run(|s| s.set_scanout_size(1024, 768)); + assert_eq!(counts, idle(FrameCounts { composed: 1, settled: 0, unchanged: 0 }), "a new mode composes one frame"); + assert_eq!((on_gpu, from_screen), (2, 0), "the new mode's frame and the idle refresh, from the GPU"); + display.stop(); +} + /// A store through any pointer to the memory counts, including a writer /// that uploads what it wrote itself (`untracked`), and a view of the /// memory that is cloned or copied around. diff --git a/src/dev/odyssey/tests/monitor.rs b/src/dev/odyssey/tests/monitor.rs index 9fdbae56f..ee0cd918c 100644 --- a/src/dev/odyssey/tests/monitor.rs +++ b/src/dev/odyssey/tests/monitor.rs @@ -90,7 +90,7 @@ fn temp_dir(name: &str) -> std::path::PathBuf { fn help_names_every_subcommand_and_a_bad_one_gets_the_usage() { let v = board(); let help = ok(&v, "help"); - for sub in ["status", "regs", "fifo", "state", "geom", "tex", "win", "mem", "backend", "lut", "dispmode", "cursor", "pix", "raw", "peek", "shot", "fbdump", "hash", "unimp", "refusals", "trace", "rec"] { + for sub in ["status", "regs", "fifo", "state", "geom", "tex", "win", "mem", "backend", "lut", "dispmode", "cursor", "pix", "raw", "peek", "shot", "fbdump", "hash", "unimp", "refusals", "gpu", "frames", "trace", "rec"] { assert!(help.lines().any(|l| l.starts_with(&format!("vpro {sub}")) || l.starts_with(&format!("vpro [{sub}]"))), "help lacks {sub}:\n{help}"); } let err = vpro(&v, "bogus").expect_err("an unknown subcommand fails"); @@ -99,6 +99,32 @@ fn help_names_every_subcommand_and_a_bad_one_gets_the_usage() { assert!(v.register_commands()[0].1.contains("vpro help")); } +/// `vpro frames` says what the display did with each frame, and `reset` +/// zeroes the counts. +#[test] +fn frames_counts_what_the_display_did_with_each_frame() { + let v = board(); + for _ in 0..12 { + Odyssey::frame(&v.state, &v.display); + } + let text = ok(&v, "frames"); + // The test screen's stores mark the first frame, so one more settles + // it; ten are unchanged. Both composed frames are presented, and the + // idle refresh six frames after them. + assert!(text.contains("composed 2 (1 to settle), unchanged 10"), "{text}"); + assert!(text.contains("presented 3 (0 from the GPU)"), "{text}"); + assert_eq!(ok(&v, "frames reset"), ""); + let text = ok(&v, "frames"); + assert!(text.contains("composed 0 (0 to settle), unchanged 0") && text.contains("presented 0"), "{text}"); + // A store composes its frame, and one more to settle it. + pixel_write(v.state.test_board(), 5, 5, 0x1122_3344); + for _ in 0..12 { + Odyssey::frame(&v.state, &v.display); + } + let text = ok(&v, "frames"); + assert!(text.contains("composed 2 (1 to settle), unchanged 10"), "{text}"); +} + #[test] fn status_reads_the_front_and_the_idle_board() { let v = board(); diff --git a/src/dev/odyssey/tests/raster.rs b/src/dev/odyssey/tests/raster.rs index 29685f59d..a44f2cc1b 100644 --- a/src/dev/odyssey/tests/raster.rs +++ b/src/dev/odyssey/tests/raster.rs @@ -1037,10 +1037,6 @@ fn the_sphere_map_reflects_the_eye_vector_about_the_normal() { /// the display's refresh frames, and a change shows on the next frame. #[test] fn an_idle_display_presents_only_its_refresh_frames() { - if super::gpu_unit_tests() { - // The GPU scan-out composes every frame. - return; - } let (h, _st, display) = display_rig(); let seq = |d: &Display| d.screen.lock().frame_seq; diff --git a/src/dev/odyssey/vram.rs b/src/dev/odyssey/vram.rs index a43d31743..10f51575a 100644 --- a/src/dev/odyssey/vram.rs +++ b/src/dev/odyssey/vram.rs @@ -184,7 +184,8 @@ pub(super) struct Vram { words: Vec, /// The dirty map, once the memory mirrors the GPU's. dirty: Option>, - /// Set by every store: the display composes again when it finds it set + /// Set by every store, and by the GPU unit after each write of its own: + /// the display composes again when it finds it set /// (`VramPtr::take_written`). written: Arc, } @@ -369,9 +370,8 @@ impl VramPtr { /// marking the dirty map: a readback from the GPU, which the GPU /// already holds. /// - /// It does not mark the memory written: the GPU unit's board always - /// composes every frame (`DisplayShared::gpu_scanout`), so no flag is - /// read there. + /// It does not mark the memory written: the bytes are the GPU's own, so + /// the picture they make was marked when the GPU drew them. #[cfg_attr(not(feature = "wgpu"), allow(dead_code, reason = "used by the GPU raster unit"))] pub fn write_words_untracked(&self, off: u64, words: &[u64]) { debug_assert!(off % 8 == 0 && off + 8 * words.len() as u64 <= self.size()); diff --git a/src/dev/odyssey/wgpu/mod.rs b/src/dev/odyssey/wgpu/mod.rs index 037a04aa4..746e2d3ac 100644 --- a/src/dev/odyssey/wgpu/mod.rs +++ b/src/dev/odyssey/wgpu/mod.rs @@ -1070,6 +1070,9 @@ impl GpuRaster { self.vram.write(lo, bytemuck::cast_slice(self.mirror.words_at(lo, hi - lo))); self.uploaded += hi - lo; self.uploaded_tiles += hi - lo; + // The display may have taken the store's mark before this + // reached the memory it composes from. + self.mirror.mark_written(); } } @@ -1153,6 +1156,8 @@ impl GpuRaster { fn upload_dirty(&mut self) { for (off, len) in self.mirror.take_dirty_runs() { self.vram.write(off, bytemuck::cast_slice(self.mirror.words_at(off, len))); + // As upload_tiles: the store's mark may already be taken. + self.mirror.mark_written(); self.uploaded += len; self.uploaded_blocks += len; } @@ -1277,6 +1282,9 @@ impl GpuRaster { pass.dispatch_workgroups((w * h).div_ceil(64), 1, 1); } self.pending = Some(self.gpu.submit(enc)); + // The GPU stores with no CPU store to mark: the display composes + // again (on the same queue, so after the copy). + self.mirror.mark_written(); let mut s = Slot::zeroed(); s.views[0] = u.dst; let (x, y) = op.dst_xy; @@ -1591,6 +1599,8 @@ impl GpuRaster { } if let Some(sub) = self.batch.flush(&self.pipelines, &[&self.sets[k].group]) { self.pending = Some(sub); + // As in copy: the batch's stores are the GPU's alone. + self.mirror.mark_written(); } self.last = None; self.opened = None; @@ -1630,6 +1640,7 @@ impl GpuRaster { self.flush(); self.vram.write(offset, bytemuck::cast_slice(words)); self.uploaded += 8 * words.len() as u64; + self.mirror.mark_written(); } /// Zero all of board memory (the mirror has been zeroed by the caller). @@ -1641,6 +1652,7 @@ impl GpuRaster { } self.gpu_dirty_any = false; self.mirror.clear_dirty(); + self.mirror.mark_written(); } /// Board memory as the model's words, after a wait. diff --git a/src/dev/odyssey/wgpu/tests.rs b/src/dev/odyssey/wgpu/tests.rs index 82f3f8d41..1359736b2 100644 --- a/src/dev/odyssey/wgpu/tests.rs +++ b/src/dev/odyssey/wgpu/tests.rs @@ -907,3 +907,62 @@ fn scanout_composes_as_the_cpu_frame() { } } } + +/// The display composes again only when the memory's store flag is set +/// (`VramPtr::take_written`). The GPU unit's stores and uploads reach the +/// memory the scan-out reads with no CPU store, so each must set it, or +/// the screen stays stale until something else is drawn. The display may +/// also take a CPU store's mark before the upload lands; the upload marks +/// again. +#[test] +fn every_store_to_gpu_memory_marks_the_display() { + let Some(gpu) = gpu() else { return }; + let mut rng = Lcg(9); + let mut p = Pair::new(&gpu, &mut rng, &RGBA_MODE); + let mut mirror = Vram::new(VRAM_BYTES); + mirror.track_dirty(); + let m = mirror.ptr(); + p.gpu.attach_mirror(m); + let shown = |what: &str| assert!(m.take_written(), "{what}: the display was not told"); + m.take_written(); + + // A batch: recorded, then submitted. + let c = ClearDraw { + planes: p.planes(), + targets: [true, false], + colour: Some(0x1234_5678), + colour_mask: u32::MAX, + depth: None, + stencil: None, + id_value: 0, + id_mask: 0, + }; + clear(&mut p, &c, 0, 0, 40, 40); + p.gpu.flush(); + shown("a submitted batch"); + + // A store into the mirror whose mark the display takes before the + // upload: the upload at the next flush. + m.store(0x1000, 8, 0xdead_beef); + assert!(m.take_written()); + p.gpu.flush(); + shown("the upload of the mirror's dirty blocks"); + + // A writer's own upload of a region it stored untracked. + let v = p.dv.colour[0]; + m.take_written(); + let u = m.untracked(); + u.store_quiet(0x1000, 8, 0x1111); + p.gpu.upload_region(&v, 0, 0, 15, 15); + shown("a region upload"); + + p.gpu.write_words(0x2000, &[1, 2, 3]); + shown("written words"); + + p.gpu.clear_all(); + shown("a memory clear"); + + // Nothing new: nothing to show. + p.gpu.flush(); + assert!(!m.take_written(), "an empty flush marked the display"); +} diff --git a/src/main.rs b/src/main.rs index 1b85d396e..d13ac42c0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -199,6 +199,14 @@ fn main() { eprintln!("iris: no graphics board fitted: running on the serial console, no window"); } else if ci_enabled { eprintln!("iris: --ci mode (REX3 rendering to offscreen buffer, no window)"); + // The wgpu renderer presents into a texture instead, so the + // board takes the path it takes for the window. + #[cfg(feature = "wgpu")] + if let (iris::config::UiRenderer::Wgpu, Some(display)) = (ui_renderer, machine.get_display()) { + if let Err(e) = iris::ui::draw_offscreen(&display) { + eprintln!("iris: the wgpu renderer is unavailable ({e}); presenting nothing"); + } + } } // Park the main thread so background threads (CPU, REX3 refresh, // CI socket) keep running. `quit` via the CI socket calls diff --git a/src/ui.rs b/src/ui.rs index 0404ccd6b..1ebbe34ee 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -765,11 +765,9 @@ impl Renderer for GlRenderer { #[cfg(feature = "wgpu")] struct WgpuRenderer { presenter: crate::wgpu::Presenter, - window_size: Arc>>, - resize_request: Arc>>, - event_proxy: EventLoopProxy<()>, - scale: u32, - display_res: Arc>, + /// The window, or None when the presenter draws into a texture + /// instead (`draw_offscreen`). + window: Option, compositor: SwCompositor, /// Newport's planes composed on the presenter's device; None until the /// first such frame, or when it cannot be made (then `compositor`). @@ -783,8 +781,45 @@ struct WgpuRenderer { board_device: bool, } +/// What the wgpu renderer shares with its window's event thread. +#[cfg(feature = "wgpu")] +struct WindowLink { + /// The window's new size, set by the event thread. + size: Arc>>, + resize_request: Arc>>, + event_proxy: EventLoopProxy<()>, + scale: u32, + display_res: Arc>, +} + #[cfg(feature = "wgpu")] impl WgpuRenderer { + /// A renderer presenting through `presenter` for `display`, in `window` + /// or offscreen. + fn new(display: &Arc, presenter: crate::wgpu::Presenter, window: Option) -> WgpuRenderer { + let board_device = display.gpu().is_some_and(|g| Arc::ptr_eq(&g, presenter.gpu())); + WgpuRenderer { presenter, window, compositor: SwCompositor::new(), ng1: None, readback: Vec::new(), failed: false, board_device } + } + + /// The device to present on: the board's when it draws on one, so a + /// frame it composes there stays on it, else one of its own. + fn device(display: &Arc) -> Result, String> { + match display.gpu() { + Some(g) => Ok(g), + None => crate::wgpu::Gpu::new(&crate::wgpu::GpuOptions::default()), + } + } + + /// Take the window's new size, and give it the frame's. + fn follow_window(&mut self, width: u32, height: u32) { + if let Some(window) = &self.window { + if let Some((w, h)) = window.size.lock().take() { + self.presenter.resize(w, h); + } + *window.display_res.lock() = (width, height); + } + } + fn report(&mut self, r: Result<(), String>) { if let Err(e) = r { if !self.failed { @@ -812,10 +847,7 @@ impl Renderer for WgpuRenderer { if width == 0 || height == 0 { return; } - if let Some((w, h)) = self.window_size.lock().take() { - self.presenter.resize(w, h); - } - *self.display_res.lock() = (width as u32, height as u32); + self.follow_window(width as u32, height as u32); let prebuilt = screen.prebuilt; // Newport's planes: composed on the GPU, presented from there, read // back only for screenshots and VNC (`need_readback`). @@ -877,11 +909,18 @@ impl Renderer for WgpuRenderer { } fn resize(&mut self, width: usize, height: usize) { - // As the GL renderer: publish the resolution, ask the event thread - // to snap the window (never touch the window from here). - *self.display_res.lock() = (width as u32, height as u32); - *self.resize_request.lock() = Some(ResizeRequest::Scale(self.scale)); - let _ = self.event_proxy.send_event(()); + match &self.window { + // As the GL renderer: publish the resolution, ask the event thread + // to snap the window (never touch the window from here). + Some(window) => { + *window.display_res.lock() = (width as u32, height as u32); + *window.resize_request.lock() = Some(ResizeRequest::Scale(window.scale)); + let _ = window.event_proxy.send_event(()); + } + // Offscreen, the texture takes the size the window snaps to at + // scale 1. + None => self.presenter.resize(width as u32, (height + STATUS_BAR_HEIGHT) as u32), + } } fn compositor_status(&self) -> String { @@ -907,10 +946,7 @@ impl Renderer for WgpuRenderer { if width == 0 || height == 0 { return; } - if let Some((w, h)) = self.window_size.lock().take() { - self.presenter.resize(w, h); - } - *self.display_res.lock() = (width, height); + self.follow_window(width, height); let ov = if overlay.active() { Some(overlay.render_pixels(&screen.overlay_source())) } else { None }; let sb = sbtex.render_pixels(status, stats, width as usize); let r = self.presenter.present(crate::wgpu::Frame::Gpu { buffer: frame, width, height }, ov, sb, crate::disp::FB_STRIDE); @@ -939,10 +975,7 @@ impl Renderer for WgpuRenderer { if overlay.active() { return Err("resident debug overlays require a raw-plane observation".into()); } - if let Some((w, h)) = self.window_size.lock().take() { - self.presenter.resize(w, h); - } - *self.display_res.lock() = (width, height); + self.follow_window(width, height); let sb = sbtex.render_pixels(status, stats, width as usize); self.presenter.present( crate::wgpu::Frame::Gpu { buffer: frame, width, height }, @@ -953,6 +986,25 @@ impl Renderer for WgpuRenderer { } } +/// Present `display`'s frames through the wgpu renderer with no window, +/// into a texture the window's size: `ci` mode with `renderer = "wgpu"`. +/// The board composes and presents as it would for the window, on the GPU +/// when it draws there, so that path runs and can be measured on a machine +/// with no display; nothing is read back. +#[cfg(feature = "wgpu")] +pub fn draw_offscreen(display: &Arc) -> Result<(), String> { + let gpu = WgpuRenderer::device(display)?; + let presenter = crate::wgpu::Presenter::offscreen( + &gpu, + (1280, 1024 + STATUS_BAR_HEIGHT as u32), + (crate::disp::FB_STRIDE as u32, crate::disp::FB_MAX_H as u32), + (crate::disp::FB_STRIDE as u32, STATUS_BAR_HEIGHT as u32), + )?; + eprintln!("iris: presenting offscreen with wgpu on {}", gpu.caps.adapter); + *display.renderer_slot().lock() = Some(Box::new(WgpuRenderer::new(display, presenter, None))); + Ok(()) +} + struct MouseDelta { accum: (f64, f64), wheel: f64, @@ -1163,10 +1215,7 @@ impl Ui { attrs: WindowAttributes, scale: u32, ) -> Result<(Arc, Arc>>, Arc>>, Arc>), String> { - let gpu = match display.gpu() { - Some(g) => g, - None => crate::wgpu::Gpu::new(&crate::wgpu::GpuOptions::default())?, - }; + let gpu = WgpuRenderer::device(display)?; #[allow(deprecated)] let window = Arc::new(event_loop.create_window(attrs).map_err(|e| format!("create the window: {e}"))?); let size = window.inner_size(); @@ -1181,20 +1230,14 @@ impl Ui { let window_size = Arc::new(Mutex::new(None)); let resize_request = Arc::new(Mutex::new(None)); let display_res = Arc::new(Mutex::new((1280u32, 1024u32))); - let renderer = WgpuRenderer { - presenter, - window_size: window_size.clone(), + let link = WindowLink { + size: window_size.clone(), resize_request: resize_request.clone(), event_proxy: event_loop.create_proxy(), scale, display_res: display_res.clone(), - compositor: SwCompositor::new(), - ng1: None, - readback: Vec::new(), - failed: false, - board_device: display.gpu().is_some_and(|g| Arc::ptr_eq(&g, &gpu)), }; - *display.renderer_slot().lock() = Some(Box::new(renderer)); + *display.renderer_slot().lock() = Some(Box::new(WgpuRenderer::new(display, presenter, Some(link)))); Ok((window, window_size, resize_request, display_res)) } diff --git a/src/wgpu/present.rs b/src/wgpu/present.rs index 06425eab3..56ebede9e 100644 --- a/src/wgpu/present.rs +++ b/src/wgpu/present.rs @@ -14,6 +14,11 @@ //! (`GfxDisplay::gpu`). Threading follows the GL renderer's rules: make the //! presenter on the thread that owns the window, present from the board's //! display thread. +//! +//! A run with no window (`ci` mode) presents into a texture instead +//! ([`Presenter::offscreen`]): the same layers, the same passes, with only +//! the surface's acquire and present left out, so what the window costs +//! and does can be run and counted without one. use std::sync::Arc; @@ -55,11 +60,48 @@ struct Layer { last: Quad, } -/// The window's surface and what draws into it. +/// What a presenter draws into. +enum Target { + /// A window's surface. + Window { surface: wgpu::Surface<'static>, config: wgpu::SurfaceConfiguration }, + /// A texture the window's size, for a run with no window, and the last + /// frame's submission: it is waited on after the next is submitted, so + /// at most two frames are in flight, as the window's frame latency + /// allows. + Offscreen { texture: wgpu::Texture, last: Option }, +} + +impl Target { + fn size(&self) -> (u32, u32) { + match self { + Target::Window { config, .. } => (config.width, config.height), + Target::Offscreen { texture, .. } => (texture.width(), texture.height()), + } + } +} + +/// The format of an offscreen target: plain, as the window's is chosen. +const OFFSCREEN_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +fn offscreen_texture(gpu: &Gpu, width: u32, height: u32) -> wgpu::Texture { + gpu.device.create_texture(&wgpu::TextureDescriptor { + label: Some("present offscreen"), + size: wgpu::Extent3d { width: width.max(1), height: height.max(1), depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: OFFSCREEN_FORMAT, + // Copied from only by a test or a tool that wants the pixels. + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }) +} + +/// The window's surface (or the texture standing in for it) and what +/// draws into it. pub struct Presenter { gpu: Arc, - surface: wgpu::Surface<'static>, - config: wgpu::SurfaceConfiguration, + target: Target, opaque: wgpu::RenderPipeline, blended: wgpu::RenderPipeline, frame: Layer, @@ -137,7 +179,28 @@ impl Presenter { color_space: Default::default(), }; surface.configure(&gpu.device, &config); + Self::with_target(gpu, Target::Window { surface, config }, format, (frame_w, frame_h), (status_w, status_rows)) + } + /// A presenter with no window, drawing into a texture `width` x + /// `height` pixels; otherwise as `new`. + pub fn offscreen( + gpu: &Arc, + (width, height): (u32, u32), + frame: (u32, u32), + status: (u32, u32), + ) -> Result { + let target = Target::Offscreen { texture: offscreen_texture(gpu, width, height), last: None }; + Self::with_target(gpu, target, OFFSCREEN_FORMAT, frame, status) + } + + fn with_target( + gpu: &Arc, + target: Target, + format: wgpu::TextureFormat, + (frame_w, frame_h): (u32, u32), + (status_w, status_rows): (u32, u32), + ) -> Result { let device = &gpu.device; let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("present layer"), @@ -271,8 +334,7 @@ impl Presenter { gpu.check()?; Ok(Presenter { gpu: Arc::clone(gpu), - surface, - config, + target, opaque, blended, frame, @@ -295,10 +357,16 @@ impl Presenter { /// The window's new size in pixels. pub fn resize(&mut self, width: u32, height: u32) { let (w, h) = (width.max(1), height.max(1)); - if (w, h) != (self.config.width, self.config.height) { - self.config.width = w; - self.config.height = h; - self.surface.configure(&self.gpu.device, &self.config); + if (w, h) == self.target.size() { + return; + } + match &mut self.target { + Target::Window { surface, config } => { + config.width = w; + config.height = h; + surface.configure(&self.gpu.device, config); + } + Target::Offscreen { texture, .. } => *texture = offscreen_texture(&self.gpu, w, h), } } @@ -322,38 +390,45 @@ impl Presenter { if fw == 0 || fh == 0 { return Ok(()); } - // A texture the surface calls suboptimal (the window changed size - // under it, as while its border is dragged) is given back before the - // surface is configured again: wgpu refuses to configure a surface - // with a texture of its outstanding, and that refusal left the - // surface stale for every frame after. One retry on the fresh - // configuration; a frame that still cannot be had is skipped. - let mut target = None; - for attempt in 0..2 { - match self.surface.get_current_texture() { - wgpu::CurrentSurfaceTexture::Success(t) => { - target = Some(t); - break; - } - wgpu::CurrentSurfaceTexture::Suboptimal(t) => { - if attempt == 1 { - // Still suboptimal on the fresh configuration: the - // window moved again; draw it, the next frame catches up. - target = Some(t); - break; + let (surface_texture, view) = match &self.target { + Target::Window { surface, config } => { + // A texture the surface calls suboptimal (the window changed size + // under it, as while its border is dragged) is given back before the + // surface is configured again: wgpu refuses to configure a surface + // with a texture of its outstanding, and that refusal left the + // surface stale for every frame after. One retry on the fresh + // configuration; a frame that still cannot be had is skipped. + let mut target = None; + for attempt in 0..2 { + match surface.get_current_texture() { + wgpu::CurrentSurfaceTexture::Success(t) => { + target = Some(t); + break; + } + wgpu::CurrentSurfaceTexture::Suboptimal(t) => { + if attempt == 1 { + // Still suboptimal on the fresh configuration: the + // window moved again; draw it, the next frame catches up. + target = Some(t); + break; + } + drop(t); + surface.configure(&self.gpu.device, config); + } + wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => { + surface.configure(&self.gpu.device, config); + return Ok(()); + } + wgpu::CurrentSurfaceTexture::Timeout | wgpu::CurrentSurfaceTexture::Occluded => return Ok(()), + wgpu::CurrentSurfaceTexture::Validation => return self.gpu.check(), } - drop(t); - self.surface.configure(&self.gpu.device, &self.config); - } - wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => { - self.surface.configure(&self.gpu.device, &self.config); - return Ok(()); } - wgpu::CurrentSurfaceTexture::Timeout | wgpu::CurrentSurfaceTexture::Occluded => return Ok(()), - wgpu::CurrentSurfaceTexture::Validation => return self.gpu.check(), + let Some(target) = target else { return Ok(()) }; + let view = target.texture.create_view(&Default::default()); + (Some(target), view) } - } - let Some(target) = target else { return Ok(()) }; + Target::Offscreen { texture, .. } => (None, texture.create_view(&Default::default())), + }; let mut enc = self.gpu.encoder("present"); match frame { @@ -384,16 +459,16 @@ impl Presenter { } self.upload(&self.status, status, stride, fw, self.status_rows); - let p = Placement::letterbox(fw as f32, fh as f32, self.status_rows as f32, self.config.width as f32, self.config.height as f32); + let (ww, wh) = self.target.size(); + let p = Placement::letterbox(fw as f32, fh as f32, self.status_rows as f32, ww as f32, wh as f32); let integer = p.scale == p.scale.round() && p.scale >= 1.0; let r = p.frame; - let win = (self.config.width as f32, self.config.height as f32); + let win = (ww as f32, wh as f32); let q = &*self.gpu; place(q, win, &mut self.frame, r, fw, fh); place(q, win, &mut self.overlay, r, fw, fh); place(q, win, &mut self.status, [r[0], r[3], r[2], r[3] + p.status], fw, self.status_rows); - let view = target.texture.create_view(&Default::default()); { let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("present"), @@ -420,9 +495,15 @@ impl Presenter { pass.set_bind_group(0, group(&self.status, integer), &[]); pass.draw(0..6, 0..1); } - self.gpu.submit(enc); - self.gpu.queue.present(target); + let submitted = self.gpu.submit(enc); self.frames += 1; + if let Some(t) = surface_texture { + self.gpu.queue.present(t); + } else if let Target::Offscreen { last, .. } = &mut self.target { + if let Some(previous) = last.replace(submitted) { + return self.gpu.wait(Some(previous)); + } + } self.gpu.check() } } @@ -445,7 +526,48 @@ fn place(gpu: &Gpu, (ww, wh): (f32, f32), layer: &mut Layer, r: [f32; 4], w: u32 #[cfg(test)] mod tests { - use super::Placement; + use super::{Frame, Placement, Presenter, Target}; + use crate::wgpu::{Gpu, GpuOptions}; + + /// The offscreen target is drawn as the window's surface is: a 4 x 4 + /// frame from a buffer on the device at the top of a target that fits + /// it at 1x, the status bar's row under it. Needs a GPU (or lavapipe); + /// skips without one. + #[test] + fn an_offscreen_presenter_draws_the_frame_and_the_status_bar() { + let gpu = match Gpu::new(&GpuOptions::default()) { + Ok(g) => g, + Err(e) => return eprintln!("skipped: {e}"), + }; + let mut p = Presenter::offscreen(&gpu, (4, 5), (4, 4), (4, 1)).expect("an offscreen presenter"); + let words: Vec = (0..16).map(|i| 0x0011_2200 | i).collect(); + let frame = gpu.buffer("test frame", 64, wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST); + gpu.write(&frame, 0, bytemuck::cast_slice(&words)); + let status = [0xff66_5544u32; 4]; + for _ in 0..3 { + p.present(Frame::Gpu { buffer: &frame, width: 4, height: 4 }, None, &status, 4).expect("present"); + } + assert_eq!(p.frames, 3); + + let Target::Offscreen { texture, .. } = &p.target else { unreachable!("an offscreen presenter") }; + let out = gpu.buffer("test readback", 256 * 5, wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST); + let mut enc = gpu.encoder("test readback"); + enc.copy_texture_to_buffer( + texture.as_image_copy(), + wgpu::TexelCopyBufferInfo { buffer: &out, layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(256), rows_per_image: Some(5) } }, + wgpu::Extent3d { width: 4, height: 5, depth_or_array_layers: 1 }, + ); + gpu.submit(enc); + out.slice(..).map_async(wgpu::MapMode::Read, |r| r.expect("map")); + gpu.wait(None).expect("readback"); + let bytes = out.slice(..).get_mapped_range().expect("mapped").to_vec(); + let texel = |x: usize, y: usize| <[u8; 4]>::try_from(&bytes[y * 256 + x * 4..][..4]).unwrap(); + for (i, &w) in words.iter().enumerate() { + let (x, y) = (i % 4, i / 4); + assert_eq!(texel(x, y), [0x11, 0x22, w as u8, 0xff], "frame texel ({x}, {y})"); + } + assert_eq!(texel(2, 4), [0x44, 0x55, 0x66, 0xff], "the status bar"); + } /// The GL renderer's letterbox: a 1280 x 1024 frame with a 16-row bar in /// a window of exactly that size is drawn at 1x from the top left, and in