retroglyph_wgpu/lib.rs
1//! GPU rendering backend for retroglyph: Vulkan, Metal, and D3D12 from a single codebase via
2//! [`wgpu`].
3//!
4//! # Architecture
5//!
6//! [`WgpuBackendBuilder`](config::WgpuBackendBuilder) holds configuration (fonts, grid size,
7//! integer scale) and [`build`](config::WgpuBackendBuilder::build)s a [`WgpuRenderer`]. The glyph
8//! source is a static [`FontChain`] (a single [`BitmapFont`] is a chain of one); every font in the
9//! chain is grid-packed into one `R8` array-texture atlas and addressed by a flat slot id (see
10//! [`retroglyph_window::atlas`]). The renderer keeps one CPU-side instance array per grid layer and
11//! creates its device lazily, when the windowing loop calls [`Presenter::init_surface`]:
12//!
13//! ```text
14//! WgpuBackendBuilder (font, grid size, scale)
15//! | .build()
16//! v
17//! WgpuRenderer
18//! implements retroglyph_window::presenter::Presenter (an Output supertrait)
19//! wrapped by retroglyph_window::backend::WindowBackend to become a full Backend
20//! (WindowBackend owns the input event queue and the no-op Cursor)
21//! |
22//! | init_surface(window) -> wgpu Device + Queue + Surface, then GpuResources
23//! v
24//! one render pass per present(): every grid layer back to front, each drawn as
25//! backgrounds then coverage-blended glyphs (then sprites), from one instance
26//! buffer uploaded once per frame.
27//! ```
28//!
29//! A cell costs 16 bytes and no index buffer: the vertex shader derives the quad's corners from the
30//! vertex index and the cell's `(column, row)` from the instance index, so the only per-instance
31//! data is the glyph slot, two colors, the sub-cell offset, and the compositing flags.
32//!
33//! This backend composites grid layers itself on the GPU
34//! ([`compositing`](Output::compositing) returns
35//! [`retroglyph_core::backend::Compositing::PixelLayered`]): it receives the raw layered
36//! stream from the core `Terminal` and draws each layer back to front, so an empty cell in a higher
37//! layer lets the layer beneath show through while an occupied cell is opaque, matching
38//! `retroglyph-software`'s per-pixel occlusion. It requests full frames
39//! (`needs_full_frame: true`) and redraws every cell of every
40//! layer each frame, so there is no orphaned-pixel problem from sub-cell glyph spill.
41//!
42//! # Choosing between this and `retroglyph-gl`
43//!
44//! Both are GPU backends drawing the same instanced-quad pipeline, and both produce pixel-identical
45//! output (each is checked against the `retroglyph-software` CPU rasterizer). They differ in which
46//! driver stack they reach and what they cost to depend on:
47//!
48//! | | `retroglyph-wgpu` | `retroglyph-gl` |
49//! | --- | --- | --- |
50//! | APIs | Vulkan, Metal, D3D12 | OpenGL 3.3, WebGL2 |
51//! | Browser | yes, WebGPU (see [Platform support](#platform-support)) | yes, WebGL2 |
52//! | `unsafe` in the backend | none | unavoidable (every GL call) |
53//! | Direct dependencies, transitively | 85 crates | 54 crates |
54//! | Clean debug build of the crate | 15s | 7s |
55//! | Offscreen render tests | every platform | Linux/EGL only |
56//!
57//! The dependency and build-time figures are for one host (macOS, `--all-features`); the ratio is
58//! what matters, not the absolute numbers. The difference is `naga` (the shader front end that
59//! compiles this crate's WGSL) plus `wgpu-core` (the validation and state-tracking layer that
60//! makes the API safe), not the three backend APIs: only one hardware abstraction layer compiles
61//! per platform, since `wgpu-hal`'s backends are target-gated. A macOS build pulls the Metal
62//! stack and no Vulkan; a Linux build pulls `ash` and no Metal.
63//!
64//! Both cover the browser today (WebGPU here, WebGL2 there); pick `retroglyph-gl` when a smaller
65//! dependency tree matters more than the rest, or this one for validation-layer diagnostics, a
66//! modern driver path, and a backend with no `unsafe` in it. On wasm32 this backend's device is
67//! ready asynchronously (see [Platform support](#platform-support)), where `retroglyph-gl`'s WebGL2
68//! context is ready synchronously; that is the one real difference the browser adds to the choice.
69//!
70//! # Performance
71//!
72//! A 200x60 grid with three layers (36000 cells, 562 KiB of instance data) costs 266us per frame
73//! end to end: flattening the layers, uploading, encoding six draws, submitting, and waiting for
74//! the GPU to finish. That is roughly 4% of a 60 Hz frame budget, on an M-series laptop at
75//! `--release`, and it is larger than any grid the examples use.
76//!
77//! The number is here to set expectations, not to invite tuning: a character grid is a trivial
78//! workload for a modern GPU, and the design choices above (deriving position from the instance
79//! index, packing every layer into one upload) are about keeping the per-frame *bandwidth* small
80//! rather than about winning a benchmark. Emitting four real vertices per cell instead, with
81//! explicit positions and UVs, is the other common shape for this renderer and would cost roughly
82//! six times the per-frame bytes.
83//!
84//! # Platform support
85//!
86//! Native (Vulkan, Metal, D3D12) and the browser (WebGPU) both work.
87//!
88//! [`Presenter::init_surface`] is synchronous, but `request_adapter` and `request_device` are not,
89//! and a browser's main thread has no way to block on a future the way native does. So the two
90//! targets take different paths through the same call: `Instance::create_surface` *is* synchronous
91//! everywhere, so on wasm32 `init_surface` creates the surface from the window's canvas, spawns the
92//! adapter and device request with `wasm_bindgen_futures::spawn_local`, and returns immediately;
93//! [`present`](Presenter::present) polls the result each frame and draws nothing until it lands.
94//! Native, by contrast, blocks on the same requests with `pollster::block_on` inside
95//! `init_surface` itself, so the device is always ready by the time it returns. See
96//! `crate::gpu::PendingGpu` for the shared-state cell this deferral is built on.
97//!
98//! The one user-visible consequence is on wasm32 only: the first frames after startup are blank
99//! (whatever was drawn before the device exists is not shown) until the adapter and device resolve,
100//! typically well under a second. Native has no such gap.
101//!
102//! `retroglyph-gl` also covers the browser, through WebGL2, whose context creation is synchronous
103//! and so needs no equivalent deferral. Pick between the two using the table above and the
104//! dependency/build-time comparison it links to.
105//!
106//! # Environment variables
107//!
108//! `wgpu` reads its own configuration from the environment, and this crate passes it through
109//! rather than overriding it:
110//!
111//! | Variable | Effect |
112//! | --- | --- |
113//! | `WGPU_BACKEND` | Restricts the backends to try (`vulkan`, `metal`, `dx12`). |
114//! | `WGPU_POWER_PREF` | `low` or `high`; defaults to `low`, since a character grid is a handful of draw calls per frame. |
115//! | `WGPU_VALIDATION` / `WGPU_DEBUG` | Toggle the driver's validation and debug layers. |
116//!
117//! # Features
118//!
119//! <!-- gen-features:start -->
120//! This crate has no default features; every feature below is optional and off unless enabled.
121//!
122//! ### `default-font`
123//!
124//! ⚪ Optional.
125//!
126//! Embeds the Unscii 16 default font so a caller can build a renderer with no font of its own.
127//!
128//! Forwards to `retroglyph-window`'s `default-font` feature.
129//!
130//! ### `dev`
131//!
132//! ⚪ Optional.
133//!
134//! Forwards `retroglyph-core`'s `dev` feature, which forces development diagnostics on in a build
135//! that would otherwise compile them out (see [`retroglyph_core::dev`]).
136//!
137//! ### `tilesets`
138//!
139//! ⚪ Optional.
140//!
141//! PNG sprite/tileset support: decodes sprite sheets into an RGBA array-texture atlas and draws
142//! them in a third, source-over blended pass per grid layer.
143//!
144//! Forwards to `retroglyph-window`'s shared tileset decode, and (where it's a dependency at all) to
145//! the `retroglyph-software` dev-dependency's own `tilesets`, so the two stay in lockstep: without
146//! this, `cargo test -p retroglyph-wgpu` (this feature off) still pulls in
147//! `retroglyph-window/tilesets` transitively through that dev-dependency's forced-on `tilesets`
148//! below, and the `PresenterBuilder` impl's `tileset` method (gated on this crate's own `tilesets`
149//! feature, matching every other tileset-gated item in this crate) would then be missing an item
150//! the trait requires whenever `retroglyph-window/tilesets` is on, regardless of this crate's own
151//! flag (retroglyph#1192). Harmless outside a test build: `retroglyph-software` is dev-only, so
152//! this half of the forward is a no-op for a plain `cargo build`/`check`.
153//! <!-- gen-features:end -->
154
155#![doc(
156 html_logo_url = "https://raw.githubusercontent.com/crates-lurey-io/retroglyph/main/docs/public/assets/logo.svg"
157)]
158#![doc(
159 html_favicon_url = "https://raw.githubusercontent.com/crates-lurey-io/retroglyph/main/docs/public/assets/logo.svg"
160)]
161#![cfg_attr(docsrs, feature(doc_cfg))]
162// Every module already has a real, public path (`config`, `error`), so an intra-doc link to one of
163// their items written from this root module has to name that path explicitly to be correct; the
164// lint that flags an explicit link as "redundant" doesn't know the item isn't actually in this
165// module's own namespace. See retroglyph-core's matching crate-wide allow for the same reasoning.
166#![allow(rustdoc::redundant_explicit_links)]
167
168pub mod config;
169pub mod error;
170
171mod gpu;
172mod instance;
173mod renderer;
174mod shaders;
175#[cfg(feature = "tilesets")]
176mod sprite_set;
177
178// Offscreen render tests: create a device with no surface at all, run the real pipeline into a
179// texture, and read the pixels back to assert on them. Unlike `retroglyph-gl`'s EGL-surfaceless
180// equivalent these need no platform-specific setup, so they run wherever wgpu finds an adapter.
181#[cfg(all(test, feature = "default-font"))]
182mod headless;
183
184// Kept at the root, unlike `config`/`error` above: these are `retroglyph-window` types, not this
185// crate's own, so re-exporting them spares a consumer building a custom atlas from adding
186// `retroglyph-window` as an explicit direct dependency just for these two types (the same
187// reasoning as `retroglyph-core`'s kept `HasSize` exception, retroglyph#1035).
188pub use retroglyph_window::font::{self as font, BitmapFont, FontChain};
189
190use error::SurfaceError;
191use gpu::{GpuContext, PendingGpu, WindowSurface, WindowedResult};
192use instance::{Cell, FLAG_HAS_BG, FLAG_HAS_GLYPH};
193use renderer::{GpuResources, LayerRange};
194use retroglyph_core::backend::{Compositing, DrawCell, Output};
195use retroglyph_core::color::Color;
196use retroglyph_core::grid::HasSize;
197use retroglyph_core::grid::Size;
198use retroglyph_core::tile::Tile;
199use retroglyph_window::atlas::GlyphAtlas;
200use retroglyph_window::diagnostics::DiagnosticLog;
201use retroglyph_window::geometry::CellGeometry;
202use retroglyph_window::palette::{DEFAULT_BG, DEFAULT_FG};
203use retroglyph_window::presenter::{Presenter, WindowHandle, cell_art_glyph};
204#[cfg(feature = "tilesets")]
205use retroglyph_window::sprite_cache::SpriteTint;
206#[cfg(feature = "tilesets")]
207use sprite_set::{SpriteInstance, SpriteSet, SpriteSlot};
208use std::sync::Arc;
209
210// Compile the crate README's code blocks as doctests so the quick start can't silently rot.
211#[cfg(doctest)]
212#[doc = include_str!("../README.md")]
213struct ReadmeDoctests;
214
215/// One grid layer's GPU-facing data: its cell instances and (with `tilesets`) its sprite
216/// instances, rebuilt together each frame by [`Output::draw_layers`] so the two can never drift
217/// out of lockstep the way two parallel `Vec`s could (a stale, longer sprite vector surviving a
218/// `clear`/`resize` and getting redrawn by `present`, retroglyph#727).
219#[derive(Clone)]
220struct Layer {
221 /// Row-major per-cell instances, `cols * rows` long.
222 cells: Vec<Cell>,
223 /// Sprite instances dispatched from this layer's cells. Empty for a layer with no sprite
224 /// cells, or when no tileset was loaded.
225 #[cfg(feature = "tilesets")]
226 sprites: Vec<SpriteInstance>,
227}
228
229impl Layer {
230 /// A new layer with every cell set to `fill`, and no sprites.
231 fn blank(fill: Cell, count: usize) -> Self {
232 Self {
233 cells: vec![fill; count],
234 #[cfg(feature = "tilesets")]
235 sprites: Vec::new(),
236 }
237 }
238
239 /// Resets this layer for reuse: `cells` becomes `count` copies of `fill` (rebuilt only if the
240 /// length changed, filled in place otherwise) and `sprites` is cleared. Used by
241 /// `draw_layers`/`clear`/`resize` so the sprite-lockstep reset (retroglyph#727) is one call
242 /// instead of a hand-copied `truncate`/`is_empty`/`push`/`clear` dance.
243 fn reset(&mut self, fill: Cell, count: usize) {
244 if self.cells.len() == count {
245 self.cells.fill(fill);
246 } else {
247 self.cells = vec![fill; count];
248 }
249 #[cfg(feature = "tilesets")]
250 self.sprites.clear();
251 }
252}
253
254/// The live wgpu renderer: a [`Presenter`], wrapped in
255/// [`WindowBackend`](retroglyph_window::backend::WindowBackend) to form a full
256/// [`Backend`](retroglyph_core::backend::Backend) for the windowing loop.
257///
258/// It does not implement [`Input`](retroglyph_core::backend::Input) or
259/// [`Cursor`](retroglyph_core::backend::Cursor) itself: a GPU renderer cannot present without a
260/// live device, so there is no headless-with-input use for a bare `Terminal<WgpuRenderer>`. In
261/// windowed use `WindowBackend` owns the input queue (with its `Mouse(Moved)` coalescing) and the
262/// no-op cursor, so a duplicate queue here would only ever be dead. See the sub-cell offset note on
263/// [`Presenter`] for the shared rendering contract.
264///
265/// Build one with [`WgpuBackendBuilder`](config::WgpuBackendBuilder). Before the windowing loop
266/// calls [`init_surface`](Presenter::init_surface) there is no device; drawing updates only the
267/// CPU-side instance arrays, and [`present`](Presenter::present) is a no-op. Once the device
268/// exists, `present` uploads the frame and encodes one render pass.
269pub struct WgpuRenderer {
270 /// Character-to-atlas-slot map for the bitmap font chain.
271 glyphs: GlyphAtlas,
272 cols: u16,
273 rows: u16,
274 /// Cell/surface pixel geometry (glyph size x scale); the single source of the `cell_size`
275 /// contract, delegated to by [`Presenter::cell_size`].
276 geometry: CellGeometry,
277 /// Atlas slot for the space glyph, used to initialize blank cells.
278 space_glyph: u16,
279 /// Per-layer state (index = grid layer id): each layer's cell instances (each `cols * rows`
280 /// in row-major order) and, with `tilesets`, its sprite instances. `layers[0]` is the
281 /// always-opaque base; higher layers composite over it back to front. Rebuilt each frame by
282 /// [`Output::draw_layers`], since this backend requests full frames. There is always at least
283 /// the base layer.
284 layers: Vec<Layer>,
285 /// Scratch buffer holding every layer's cells back to back for the per-frame upload. Kept as a
286 /// field so a steady-state frame reuses one allocation instead of building a new one.
287 upload: Vec<Cell>,
288 /// Per-layer slices of `upload`, parallel to `layers`.
289 ranges: Vec<LayerRange>,
290 /// The decoded sprite atlas, if a tileset was loaded. Retained so the GPU atlas can be rebuilt
291 /// if the device is recreated.
292 #[cfg(feature = "tilesets")]
293 sprite_set: Option<SpriteSet>,
294 /// Sprite equivalents of `upload`/`ranges`.
295 #[cfg(feature = "tilesets")]
296 sprite_upload: Vec<SpriteInstance>,
297 #[cfg(feature = "tilesets")]
298 sprite_ranges: Vec<LayerRange>,
299 /// The oversized-sprite and dropped-tint dedup state, so a redraw loop logs each offending
300 /// glyph once instead of every frame. See `retroglyph_window::diagnostics`.
301 ///
302 /// Unlike the software and gl backends, this one never calls
303 /// [`DiagnosticLog::notdef_glyph`](retroglyph_window::diagnostics::DiagnosticLog::notdef_glyph)
304 /// (a known gap, fixed separately), so without `tilesets` -- the only feature that reaches
305 /// this field's other two methods -- nothing here is ever read. `allow` rather than removing
306 /// the field: it exists unconditionally on every other backend, and calling `notdef_glyph`
307 /// here is the fix, not deleting the state that call would use.
308 #[cfg_attr(not(feature = "tilesets"), allow(dead_code))]
309 diagnostics: DiagnosticLog,
310 /// The current surface size in physical pixels (set by
311 /// [`resize_surface`](Presenter::resize_surface)).
312 surface_size: (u32, u32),
313 /// Device, surface, and GPU resources. `None` until the device is ready: before
314 /// [`init_surface`](Presenter::init_surface) is ever called, and on wasm32 for every frame
315 /// while [`pending`](Self::pending) is still resolving.
316 gpu: Option<Gpu>,
317 /// wasm32 only in practice: the deferred adapter/device request started by
318 /// [`init_surface`](Presenter::init_surface), polled by [`present`](Presenter::present) each
319 /// frame until it resolves. Always `None` on native, where `init_surface` never returns
320 /// without the device already installed in `Self::gpu`; `PendingGpu` is uninhabited there, so
321 /// this field costs nothing to poll.
322 pending: Option<PendingGpu>,
323}
324
325/// The live device, surface, and resources, present only after
326/// [`init_surface`](Presenter::init_surface).
327struct Gpu {
328 context: GpuContext,
329 /// The window's swap chain. `None` for the offscreen render tests, which render into a texture
330 /// they own rather than a frame they acquire.
331 surface: Option<WindowSurface>,
332 resources: GpuResources,
333}
334
335// `Gpu`'s wgpu handles (`Device`, `Queue`, ...) don't implement `Debug`, so this only reports the
336// CPU-side state; good enough for `Result::unwrap_err` in tests, the only place this is needed.
337impl std::fmt::Debug for WgpuRenderer {
338 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
339 f.debug_struct("WgpuRenderer")
340 .field("cols", &self.cols)
341 .field("rows", &self.rows)
342 .field("surface_size", &self.surface_size)
343 .field("has_gpu", &self.gpu.is_some())
344 .finish_non_exhaustive()
345 }
346}
347
348impl WgpuRenderer {
349 /// Builds a renderer for the given glyph atlas, grid size, and scale. Called by
350 /// [`WgpuBackendBuilder::build`](config::WgpuBackendBuilder::build).
351 ///
352 pub(crate) fn new(glyphs: GlyphAtlas, cols: u16, rows: u16, scale: u16) -> Self {
353 let (cell_w, cell_h) = glyphs.cell_size();
354 let geometry = CellGeometry::new(
355 u16::try_from(cell_w).unwrap_or(u16::MAX),
356 u16::try_from(cell_h).unwrap_or(u16::MAX),
357 scale,
358 );
359 let space_glyph = glyphs.space_slot();
360 let count = usize::from(cols) * usize::from(rows);
361 Self {
362 glyphs,
363 cols,
364 rows,
365 geometry,
366 space_glyph,
367 layers: vec![Layer::blank(base_blank(space_glyph), count)],
368 upload: Vec::new(),
369 ranges: Vec::new(),
370 #[cfg(feature = "tilesets")]
371 sprite_set: None,
372 #[cfg(feature = "tilesets")]
373 sprite_upload: Vec::new(),
374 #[cfg(feature = "tilesets")]
375 sprite_ranges: Vec::new(),
376 diagnostics: DiagnosticLog::default(),
377 surface_size: geometry.surface_size(cols, rows),
378 gpu: None,
379 pending: None,
380 }
381 }
382
383 /// Attaches a decoded sprite atlas. Called by
384 /// [`WgpuBackendBuilder::build`](config::WgpuBackendBuilder::build) when a tileset was
385 /// registered; the GPU atlas is built later, in [`build_resources`](Self::build_resources).
386 #[cfg(feature = "tilesets")]
387 pub(crate) fn set_sprites(&mut self, set: SpriteSet) {
388 self.sprite_set = Some(set);
389 }
390
391 /// The base-layer blank instance: space glyph, default colors, opaque default background, no
392 /// glyph drawn. Layer 0 always paints its background (the opaque base), so an untouched base
393 /// cell is the default background.
394 const fn base_blank(&self) -> Cell {
395 base_blank(self.space_glyph)
396 }
397
398 /// Total cell count for the current grid.
399 fn cell_count(&self) -> usize {
400 usize::from(self.cols) * usize::from(self.rows)
401 }
402
403 /// Reports a sprite drawn larger than one cell without a span to reserve the cells it covers.
404 ///
405 /// Shares `retroglyph-window`'s diagnostic with the other backends so all of them name the same
406 /// fix. A tile that already declares a span is fine and says nothing.
407 #[cfg(feature = "tilesets")]
408 fn warn_if_sprite_needs_span(&mut self, tile: &Tile, sprite: SpriteSlot) {
409 if tile.is_span_anchor() {
410 return;
411 }
412 self.diagnostics.sprite_needs_span(
413 tile.glyph(),
414 (u32::from(sprite.w), u32::from(sprite.h)),
415 (
416 u32::from(self.geometry.glyph_w),
417 u32::from(self.geometry.glyph_h),
418 ),
419 );
420 }
421
422 /// Reports a tint set on a cell whose glyph resolved to a bitmap font rather than a sprite, so
423 /// the tint was silently dropped (retroglyph#564).
424 #[cfg(feature = "tilesets")]
425 fn warn_if_tint_needs_sprite(&mut self, glyph: char, tint: retroglyph_core::color::Tint) {
426 self.diagnostics.tint_needs_sprite(glyph, tint);
427 }
428
429 /// Pushes one sprite instance for `tile` on layer `l` at cell `(cx, cy)`: aligns it within its
430 /// span box, warns once if it needed a span but didn't declare one, and resolves its tint
431 /// against `sprite`'s sheet color. Shared verbatim between the layer-0 and higher-layer sprite
432 /// dispatch branches in `draw_layers` (retroglyph#1374): the CPU/GPU parity contract (span
433 /// alignment, oversize warning, `SpriteTint::resolve` argument order) lives in exactly one
434 /// place, so a fix here reaches every layer instead of needing to land twice.
435 ///
436 /// The caller still owns which `Cell` is written and how `inherited_bg`/`sprite_bg` update;
437 /// this only appends to `self.layers[l].sprites`.
438 #[cfg(feature = "tilesets")]
439 fn emit_sprite(
440 &mut self,
441 l: usize,
442 cx: u16,
443 cy: u16,
444 tile: &Tile,
445 sprite: SpriteSlot,
446 tint: retroglyph_core::color::Tint,
447 ) {
448 let (span_w, span_h) = tile.span();
449 let align =
450 sprite.align_offset(span_w, span_h, self.geometry.glyph_w, self.geometry.glyph_h);
451 self.warn_if_sprite_needs_span(tile, sprite);
452 self.layers[l].sprites.push(SpriteInstance::new(
453 cx,
454 cy,
455 sprite.layer,
456 sprite.w,
457 sprite.h,
458 tile.dx() + align.0,
459 tile.dy() + align.1,
460 SpriteTint::resolve(sprite.color, tile.style().foreground(), tint, DEFAULT_FG),
461 ));
462 }
463
464 /// Builds the GPU resources for the current grid on an existing device: compiles the pipelines,
465 /// uploads the glyph and sprite atlases, and allocates the instance buffers.
466 ///
467 /// Shared by [`Presenter::init_surface`] (windowed) and the offscreen render tests, so both
468 /// exercise the same setup: the point of those tests is to catch a break in exactly this
469 /// pipeline, so it must not diverge from the real one.
470 ///
471 /// # Errors
472 ///
473 /// Returns [`SurfaceError::Init`](error::SurfaceError::Init) if either atlas exceeds the
474 /// device's texture limits.
475 pub(crate) fn build_resources(
476 &self,
477 context: &GpuContext,
478 target_format: wgpu::TextureFormat,
479 ) -> Result<GpuResources, SurfaceError> {
480 let atlas = self.glyphs.data();
481 let capacity = u32::try_from(self.cell_count()).unwrap_or(u32::MAX);
482 #[cfg_attr(not(feature = "tilesets"), allow(unused_mut))]
483 let mut resources = GpuResources::new(
484 &context.device,
485 &context.queue,
486 target_format,
487 &atlas,
488 capacity,
489 )?;
490 #[cfg(feature = "tilesets")]
491 if let Some(set) = &self.sprite_set {
492 resources.attach_sprites(&context.device, &context.queue, target_format, set)?;
493 }
494 Ok(resources)
495 }
496
497 /// Flattens the per-layer instance arrays into [`Self::upload`] and records each layer's slice
498 /// in [`Self::ranges`].
499 ///
500 /// One contiguous buffer per frame is what lets the whole frame be a single `write_buffer` and
501 /// a single render pass; see [`renderer`]'s module docs for why re-uploading between layers
502 /// would be wrong rather than merely slower.
503 fn flatten_layers(&mut self) {
504 self.upload.clear();
505 self.ranges.clear();
506 #[cfg(feature = "tilesets")]
507 {
508 self.sprite_upload.clear();
509 self.sprite_ranges.clear();
510 }
511 for layer in &self.layers {
512 let start = u32::try_from(self.upload.len()).unwrap_or(u32::MAX);
513 let count = u32::try_from(layer.cells.len()).unwrap_or(u32::MAX);
514 self.upload.extend_from_slice(&layer.cells);
515 self.ranges.push(LayerRange { start, count });
516
517 #[cfg(feature = "tilesets")]
518 {
519 let start = u32::try_from(self.sprite_upload.len()).unwrap_or(u32::MAX);
520 let count = u32::try_from(layer.sprites.len()).unwrap_or(u32::MAX);
521 self.sprite_upload.extend_from_slice(&layer.sprites);
522 self.sprite_ranges.push(LayerRange { start, count });
523 }
524 }
525 }
526
527 /// Installs a device and resources without a surface, for the offscreen render tests.
528 ///
529 /// The windowed path installs the same pair in [`Presenter::init_surface`]; splitting this out
530 /// is what lets `headless` drive [`encode_frame`](Self::encode_frame) (and therefore the whole
531 /// production render path) against a texture it owns.
532 #[cfg(all(test, feature = "default-font"))]
533 fn install_offscreen(&mut self, context: GpuContext, resources: GpuResources) {
534 self.gpu = Some(Gpu {
535 context,
536 surface: None,
537 resources,
538 });
539 }
540
541 /// The installed device, for the offscreen render tests' readback.
542 #[cfg(all(test, feature = "default-font"))]
543 fn offscreen_context(&self) -> Option<&GpuContext> {
544 self.gpu.as_ref().map(|gpu| &gpu.context)
545 }
546
547 /// Builds resources for a newly ready device and surface, and installs both as [`Self::gpu`].
548 ///
549 /// Shared by [`Presenter::init_surface`] (native: the device is always ready by the time it
550 /// returns) and [`Presenter::present`] (wasm32: called once the deferred adapter/device
551 /// request resolves).
552 ///
553 /// # Errors
554 ///
555 /// Returns [`SurfaceError::Init`](error::SurfaceError::Init) if either atlas exceeds the
556 /// device's texture limits.
557 fn install_device(
558 &mut self,
559 context: GpuContext,
560 surface: WindowSurface,
561 ) -> Result<(), SurfaceError> {
562 log::info!("retroglyph-wgpu: {}", context.describe());
563 let resources = self.build_resources(&context, surface.view_format)?;
564 self.gpu = Some(Gpu {
565 context,
566 surface: Some(surface),
567 resources,
568 });
569 Ok(())
570 }
571
572 /// The sprite atlas layer size in texels, or `(0, 0)` without a tileset.
573 #[cfg_attr(
574 not(feature = "tilesets"),
575 allow(clippy::unused_self, clippy::missing_const_for_fn)
576 )]
577 fn sprite_tex_size(&self) -> (u32, u32) {
578 #[cfg(feature = "tilesets")]
579 {
580 self.sprite_set.as_ref().map_or((0, 0), SpriteSet::tex_size)
581 }
582 #[cfg(not(feature = "tilesets"))]
583 {
584 (0, 0)
585 }
586 }
587
588 /// Uploads the current frame and encodes it into `view`, without acquiring or presenting a
589 /// surface frame.
590 ///
591 /// Split out from [`present`](Presenter::present) so the offscreen render tests can point the
592 /// same code at a texture they own.
593 fn encode_frame(&mut self, view: &wgpu::TextureView) {
594 self.flatten_layers();
595 let (screen, cell, glyph, cols, sprite_tex) = (
596 self.surface_size,
597 self.geometry.cell_size(),
598 self.glyphs.cell_size(),
599 self.cols,
600 self.sprite_tex_size(),
601 );
602 let Some(gpu) = self.gpu.as_mut() else {
603 return;
604 };
605 gpu.resources
606 .set_uniforms(&gpu.context.queue, screen, cell, glyph, cols, sprite_tex);
607 gpu.resources
608 .upload_cells(&gpu.context.device, &gpu.context.queue, &self.upload);
609 #[cfg(feature = "tilesets")]
610 gpu.resources
611 .upload_sprites(&gpu.context.device, &gpu.context.queue, &self.sprite_upload);
612
613 let mut encoder =
614 gpu.context
615 .device
616 .create_command_encoder(&wgpu::CommandEncoderDescriptor {
617 label: Some("retroglyph frame"),
618 });
619 gpu.resources.render(
620 &mut encoder,
621 view,
622 &self.ranges,
623 #[cfg(feature = "tilesets")]
624 &self.sprite_ranges,
625 );
626 gpu.context.queue.submit(Some(encoder.finish()));
627 }
628}
629
630/// `(u8, u8, u8)` -> `[u8; 3]`, for packing resolved colors into a [`Cell`].
631const fn to_arr(rgb: (u8, u8, u8)) -> [u8; 3] {
632 [rgb.0, rgb.1, rgb.2]
633}
634
635/// The base-layer blank instance for `space_glyph`: opaque default background, no glyph. A free
636/// function so [`WgpuRenderer::new`] can build it before `self` exists.
637const fn base_blank(space_glyph: u16) -> Cell {
638 Cell::new(
639 space_glyph,
640 to_arr(DEFAULT_FG),
641 to_arr(DEFAULT_BG),
642 0,
643 0,
644 FLAG_HAS_BG,
645 )
646}
647
648/// Builds the base-layer (layer 0) [`Cell`] for `tile` at the already-resolved atlas `slot`: the
649/// background is always opaque (default-substituted), and the glyph is drawn only when
650/// [`cell_art_glyph`] says this tile draws art (see its docs for the blank/span-covered rules).
651///
652/// A `slot` of `None` is a character no font in the chain can draw, not even as the substituted
653/// solid block; the cell keeps its background and draws no glyph, matching `retroglyph-software`.
654const fn base_instance(slot: Option<u16>, tile: &Tile) -> Cell {
655 let fg = to_arr(tile.style().foreground().resolve_rgb(DEFAULT_FG));
656 let bg = to_arr(tile.style().background().resolve_rgb(DEFAULT_BG));
657 let (slot, drawable) = match slot {
658 Some(slot) => (slot, FLAG_HAS_GLYPH),
659 None => (0, 0),
660 };
661 let flags = FLAG_HAS_BG
662 | if cell_art_glyph(tile).is_none() {
663 0
664 } else {
665 drawable
666 };
667 Cell::new(slot, fg, bg, tile.dx(), tile.dy(), flags)
668}
669
670// ── Output ───────────────────────────────────────────────────────────────────
671
672impl Output for WgpuRenderer {
673 // Drawing only touches CPU memory (the instance arrays); it never fails. Device failures
674 // surface through `Presenter::present`'s `SurfaceError` instead.
675 type Error = core::convert::Infallible;
676
677 // No `draw` override: this backend always composites (`compositing` returns `PixelLayered`
678 // below), so `Terminal::present` never calls single-layer `draw` and the default implementation
679 // (which forwards to `draw_layers`) is exactly right. See retroglyph#561 for why a second,
680 // hand-maintained body is worse than none.
681 #[allow(clippy::too_many_lines)]
682 fn draw_layers<'a, I>(&mut self, content: I) -> Result<(), Self::Error>
683 where
684 I: Iterator<Item = DrawCell<'a>>,
685 {
686 // This backend requests full frames, so `content` is every cell of every allocated layer in
687 // layer-major (0..=max) then row-major order (see `Grid::layers`). Rebuild the per-layer
688 // arrays from scratch: reset the base to blanks and drop higher layers, growing them back
689 // as the stream references them. Cells a layer doesn't stream stay transparent (flags == 0).
690 let base = self.base_blank();
691 let cell_count = self.cell_count();
692 self.layers.truncate(1);
693 self.layers[0].reset(base, cell_count);
694
695 // Per-cell running background, updated bottom-up as layers are processed. An occupied
696 // higher-layer tile with a `Color::Default` background inherits this instead of being
697 // transparent: matching `retroglyph-software`'s `resolve_bg_fill`, an occupied tile is
698 // opaque and erases the glyph beneath it, repainting whichever background a lower layer
699 // last established (down to layer 0's default). This relies on the layer-major stream order
700 // above, so a layer's lower neighbours are always processed first.
701 let mut inherited_bg = vec![to_arr(DEFAULT_BG); cell_count];
702
703 // Per-cell record of whether the occupant drawn at that index dispatched to a sprite, keyed
704 // the same way as `inherited_bg`. A span's covered cells hold only a text-fallback glyph
705 // that never has a sprite of its own, so the covered-cell branch below consults this at the
706 // *anchor's* index to answer "does this span dispatch to a sprite", matching
707 // `retroglyph-software`'s `resolve_cell_bg` (retroglyph#726). Reused across layers: a lower
708 // layer's `true` is always overwritten before a higher layer's covered cell can read it,
709 // because the anchor of any span is written before its covered cells (row-major stream).
710 let mut sprite_bg = vec![false; cell_count];
711
712 let cols = usize::from(self.cols);
713 let rows = usize::from(self.rows);
714 for draw_cell in content {
715 let (layer_id, pos, tile) = (draw_cell.layer, draw_cell.pos, draw_cell.tile);
716 let (x, y) = (usize::from(pos.x), usize::from(pos.y));
717 if x >= cols || y >= rows {
718 continue;
719 }
720 let l = usize::from(layer_id);
721 while self.layers.len() <= l {
722 // Higher layers default to fully transparent cells (flags == 0).
723 self.layers.push(Layer::blank(
724 Cell::transparent(self.space_glyph),
725 cell_count,
726 ));
727 }
728 let idx = y * cols + x;
729
730 #[cfg(feature = "tilesets")]
731 #[allow(clippy::cast_possible_truncation)]
732 let (cx, cy) = (x as u16, y as u16);
733
734 // A cell covered by a multi-cell span (retroglyph#412) draws no glyph of its own: the
735 // span's anchor emitted one sprite across the whole footprint, and this cell's glyph is
736 // that sprite's text fallback, for backends that can't draw it. Every cell of a span
737 // shares one `Style` (see `Grid::write_span_cells`), so this cell's own tile already
738 // carries the same colors as the anchor; the anchor is consulted only to answer "does
739 // this span dispatch to a sprite" (via `sprite_bg`), the same split
740 // `retroglyph-software`'s `resolve_cell_bg` documents. Resolving the running inherited
741 // background at this cell's own index, not the anchor's, keeps a span from smearing one
742 // column's inheritance across the whole footprint (retroglyph#726).
743 if tile.span_offset().is_some() {
744 let anchor_idx = tile
745 .span_anchor_index(idx, cols)
746 .filter(|&anchor_idx| anchor_idx < cell_count);
747 if let Some(anchor_idx) = anchor_idx {
748 let has_sprite = sprite_bg[anchor_idx];
749 let fg = to_arr(tile.style().foreground().resolve_rgb(DEFAULT_FG));
750 let bg_color = tile.style().background();
751 let (bg, has_bg) = if l == 0 || bg_color != Color::Default {
752 (to_arr(bg_color.resolve_rgb(DEFAULT_BG)), FLAG_HAS_BG)
753 } else if has_sprite {
754 (inherited_bg[idx], 0)
755 } else {
756 (inherited_bg[idx], FLAG_HAS_BG)
757 };
758 if has_bg != 0 {
759 inherited_bg[idx] = bg;
760 }
761 self.layers[l].cells[idx] = Cell::new(self.space_glyph, fg, bg, 0, 0, has_bg);
762 continue;
763 }
764 }
765
766 if layer_id == 0 {
767 let slot = self.glyphs.resolve(tile.glyph());
768 let inst = base_instance(slot, tile);
769 // Sprite dispatch is gated on `cell_art_glyph`, not the raw `tile.glyph()`: a blank
770 // layer-0 cell (`is_empty()`, e.g. an untouched grid cell) draws no art at all,
771 // even if its glyph happens to have a registered sprite (retroglyph#762).
772 #[cfg(feature = "tilesets")]
773 {
774 let art_glyph = cell_art_glyph(tile);
775 if let Some(sprite) =
776 art_glyph.and_then(|g| self.sprite_set.as_ref().and_then(|s| s.slot(g)))
777 {
778 // Keep layer 0's opaque background; drop the glyph, the sprite covers it.
779 let sprite_inst = Cell::new(
780 inst.glyph,
781 [inst.fg[0], inst.fg[1], inst.fg[2]],
782 [inst.bg[0], inst.bg[1], inst.bg[2]],
783 0,
784 0,
785 inst.flags & FLAG_HAS_BG,
786 );
787 inherited_bg[idx] = [inst.bg[0], inst.bg[1], inst.bg[2]];
788 sprite_bg[idx] = true;
789 self.layers[0].cells[idx] = sprite_inst;
790 self.emit_sprite(0, cx, cy, tile, sprite, draw_cell.tint);
791 continue;
792 }
793 if let Some(g) = art_glyph {
794 self.warn_if_tint_needs_sprite(g, draw_cell.tint);
795 }
796 }
797 inherited_bg[idx] = [inst.bg[0], inst.bg[1], inst.bg[2]];
798 self.layers[0].cells[idx] = inst;
799 continue;
800 }
801 if cell_art_glyph(tile).is_none() {
802 // Transparent: nothing drawn, and the running background is unchanged. This branch
803 // runs after the span-covered `continue` above, so a `None` here always means
804 // blank, never span-covered.
805 self.layers[l].cells[idx] = Cell::transparent(self.space_glyph);
806 continue;
807 }
808 // Occupied higher-layer tile: opaque background (its own color, or the inherited one
809 // when the tile's background is `Default`) plus its glyph, unless no font in the chain
810 // can draw that character at all (see `base_instance`).
811 let resolved = self.glyphs.resolve(tile.glyph());
812 let glyph = resolved.unwrap_or(0);
813 let has_glyph = if resolved.is_some() {
814 FLAG_HAS_GLYPH
815 } else {
816 0
817 };
818 let fg = to_arr(tile.style().foreground().resolve_rgb(DEFAULT_FG));
819 let bg_color = tile.style().background();
820 let bg = if bg_color == Color::Default {
821 inherited_bg[idx]
822 } else {
823 let resolved = to_arr(bg_color.resolve_rgb(DEFAULT_BG));
824 inherited_bg[idx] = resolved;
825 resolved
826 };
827 #[cfg(feature = "tilesets")]
828 if let Some(sprite) = self.sprite_set.as_ref().and_then(|s| s.slot(tile.glyph())) {
829 // No bitmap glyph. An occupied higher-layer sprite cell with a `Default` background
830 // paints no background (the sprite's own alpha provides coverage, so lower layers
831 // show through its transparent pixels), matching `resolve_bg_fill`'s has_sprite
832 // rule; an explicit background is still painted opaque.
833 let has_bg = if bg_color == Color::Default {
834 0
835 } else {
836 FLAG_HAS_BG
837 };
838 sprite_bg[idx] = true;
839 self.layers[l].cells[idx] = Cell::new(glyph, fg, bg, 0, 0, has_bg);
840 self.emit_sprite(l, cx, cy, tile, sprite, draw_cell.tint);
841 continue;
842 }
843 #[cfg(feature = "tilesets")]
844 self.warn_if_tint_needs_sprite(tile.glyph(), draw_cell.tint);
845 sprite_bg[idx] = false;
846 self.layers[l].cells[idx] =
847 Cell::new(glyph, fg, bg, tile.dx(), tile.dy(), FLAG_HAS_BG | has_glyph);
848 }
849 Ok(())
850 }
851
852 fn compositing(&self) -> Compositing {
853 // Draw the raw layered stream back to front on the GPU instead of letting the core flatten
854 // it, so per-layer transparency works the same as on `retroglyph-software`. Composited
855 // layers plus sub-cell glyph spill mean a partial redraw could leave orphaned pixels;
856 // redraw every cell of every layer each frame.
857 Compositing::PixelLayered {
858 needs_full_frame: true,
859 }
860 }
861
862 fn flush(&mut self) -> Result<(), Self::Error> {
863 // Upload is deferred to `present`, which owns the device.
864 Ok(())
865 }
866
867 fn size(&self) -> Size {
868 Size::new(self.cols, self.rows)
869 }
870
871 fn clear(&mut self) -> Result<(), Self::Error> {
872 let base = self.base_blank();
873 let cell_count = self.cell_count();
874 // A `Layer` owns its cells and sprites together, so this one `reset` is what used to be a
875 // hand-copied `truncate`/`is_empty`/`push`/`clear` dance keeping a second, parallel sprite
876 // vector from surviving the clear and getting redrawn by `present` (retroglyph#727).
877 self.layers.truncate(1);
878 self.layers[0].reset(base, cell_count);
879 Ok(())
880 }
881
882 fn resize(&mut self, size: Size) {
883 self.cols = size.width();
884 self.rows = size.height();
885 let base = self.base_blank();
886 // See the comment in `clear`: rebuilding as a single blank `Layer` can't leave a stale,
887 // larger sprite vector behind for `present` to redraw after the resize (retroglyph#727).
888 self.layers = vec![Layer::blank(base, self.cell_count())];
889 }
890}
891
892// WgpuRenderer implements neither `Input` nor `Cursor`: `WindowBackend<WgpuRenderer>` supplies both
893// for windowed use (its input queue coalesces `Mouse(Moved)`; its cursor is a no-op), and a GPU
894// renderer has no headless-with-input path that would need its own. See the type-level docs.
895
896// ── Presenter ────────────────────────────────────────────────────────────────
897
898impl Presenter for WgpuRenderer {
899 type SurfaceError = SurfaceError;
900
901 fn init_surface(&mut self, window: Arc<dyn WindowHandle>) -> Result<(), SurfaceError> {
902 // Re-entry (surface-loss recovery, retroglyph#728): a previous device may still be
903 // installed, e.g. from the winit driver re-calling this after repeated present failures.
904 // Drop it before building the replacement, so the old surface releases the window it owns
905 // before a new surface asks for it. A previous deferred request (wasm32) is dropped too:
906 // its result, once it lands, would otherwise install a device for a window that is no
907 // longer current.
908 self.gpu = None;
909 self.pending = None;
910
911 let (w, h) = self.surface_size;
912 match GpuContext::windowed(window, w, h)? {
913 WindowedResult::Ready(context, surface) => self.install_device(context, surface)?,
914 // wasm32 only in practice (see `Self::pending`'s docs): the device isn't ready yet.
915 // `present` polls `self.pending` each frame until it resolves, drawing nothing in the
916 // meantime; drawing itself still updates the CPU-side arrays normally.
917 WindowedResult::Pending(pending) => self.pending = Some(pending),
918 }
919 Ok(())
920 }
921
922 fn resize_surface(&mut self, width: u32, height: u32) {
923 self.surface_size = (width, height);
924 if let Some(gpu) = &mut self.gpu
925 && let Some(surface) = gpu.surface.as_mut()
926 {
927 surface.resize(&gpu.context, width, height);
928 }
929 }
930
931 fn present(&mut self) -> Result<(), SurfaceError> {
932 // wasm32 only in practice: poll the deferred adapter/device request. While it is still in
933 // flight, drop through to the no-device branch below and draw nothing this frame; once it
934 // resolves, install the device (reconciling any resize that landed while it was pending)
935 // and fall through to render the very same frame.
936 if let Some(pending) = self.pending.as_ref() {
937 match pending.poll() {
938 None => {}
939 Some(Ok((context, mut surface))) => {
940 self.pending = None;
941 let (w, h) = self.surface_size;
942 surface.resize(&context, w, h);
943 self.install_device(context, surface)?;
944 }
945 Some(Err(err)) => {
946 self.pending = None;
947 return Err(err);
948 }
949 }
950 }
951
952 // No device yet: nothing to present. Drawing has still updated the CPU-side arrays, so the
953 // first frame after the device becomes ready shows the current grid rather than a blank
954 // one. On wasm32 this is also the ordinary state for every frame before the deferred
955 // request above resolves.
956 let Some(gpu) = self.gpu.as_mut() else {
957 return Ok(());
958 };
959 let Some(surface) = gpu.surface.as_ref() else {
960 return Ok(());
961 };
962 let frame = surface.acquire(&gpu.context)?;
963 self.encode_frame(&frame.view);
964 // Reborrow: `encode_frame` needs `&mut self`, so the earlier borrow can't survive it.
965 let gpu = self.gpu.as_ref().expect("context installed above");
966 frame.present(&gpu.context);
967 Ok(())
968 }
969
970 fn geometry(&self) -> CellGeometry {
971 self.geometry
972 }
973}
974
975#[cfg(all(test, feature = "default-font"))]
976mod compositing_tests {
977 use super::config::WgpuBackendBuilder;
978 use super::{FLAG_HAS_BG, FLAG_HAS_GLYPH};
979 use retroglyph_core::backend::{Compositing, DrawCell, Output};
980 use retroglyph_core::color::{Color, Style};
981 use retroglyph_core::grid::Pos;
982 use retroglyph_core::tile::Tile;
983
984 const RED: Color = Color::rgb(255, 0, 0);
985
986 #[test]
987 fn draw_records_sub_cell_offset_and_flags_in_the_base_layer() {
988 let mut r = WgpuBackendBuilder::new()
989 .grid_size(4, 2)
990 .build()
991 .expect("default-font builds");
992 let tile = Tile::new('A', Style::new()).with_offset(-3, 5);
993 // `Output::draw` has no override on this backend: this exercises the trait's default, which
994 // forwards to `draw_layers` tagged onto layer 0.
995 r.draw(core::iter::once(DrawCell::new(Pos::new(1, 0), &tile)))
996 .expect("draw is infallible");
997
998 let inst = r.layers[0].cells[1];
999 assert_eq!((inst.dx, inst.dy), (-3, 5));
1000 let a_slot = r.glyphs.resolve('A').expect("'A' is in CP437");
1001 assert_eq!(inst.glyph, a_slot);
1002 // A non-empty tile on the base layer draws both its glyph and its (base) background.
1003 assert_eq!(inst.flags, FLAG_HAS_BG | FLAG_HAS_GLYPH);
1004 }
1005
1006 #[test]
1007 fn base_layer_blank_cells_are_opaque_background_only() {
1008 let r = WgpuBackendBuilder::new()
1009 .grid_size(3, 3)
1010 .build()
1011 .expect("default-font builds");
1012 assert!(
1013 r.layers[0]
1014 .cells
1015 .iter()
1016 .all(|i| i.dx == 0 && i.dy == 0 && i.flags == FLAG_HAS_BG)
1017 );
1018 }
1019
1020 #[test]
1021 fn composites_layers_and_requests_full_frames() {
1022 let r = WgpuBackendBuilder::new()
1023 .grid_size(2, 1)
1024 .build()
1025 .expect("default-font builds");
1026 assert_eq!(
1027 r.compositing(),
1028 Compositing::PixelLayered {
1029 needs_full_frame: true
1030 }
1031 );
1032 }
1033
1034 #[test]
1035 fn draw_layers_encodes_the_occlusion_rule_per_layer() {
1036 let mut r = WgpuBackendBuilder::new()
1037 .grid_size(3, 1)
1038 .build()
1039 .expect("default-font builds");
1040
1041 // Layer 0: an opaque glyph with a real background at (0,0).
1042 let base = Tile::new('X', Style::new().bg(RED));
1043 // Layer 1: (0,0) empty (transparent), (1,0) glyph with default bg (transparent bg),
1044 // (2,0) glyph with a real bg (opaque).
1045 let empty = Tile::default();
1046 let glyph_default_bg = Tile::new('Y', Style::new());
1047 let glyph_real_bg = Tile::new('Z', Style::new().bg(RED));
1048 let stream = [
1049 DrawCell::on_layer(0, Pos::new(0, 0), &base),
1050 DrawCell::on_layer(1, Pos::new(0, 0), &empty),
1051 DrawCell::on_layer(1, Pos::new(1, 0), &glyph_default_bg),
1052 DrawCell::on_layer(1, Pos::new(2, 0), &glyph_real_bg),
1053 ];
1054 r.draw_layers(stream.iter().copied())
1055 .expect("draw_layers is infallible");
1056
1057 // Base layer cell 0 draws both.
1058 assert_eq!(r.layers[0].cells[0].flags, FLAG_HAS_BG | FLAG_HAS_GLYPH);
1059 // A second layer was allocated.
1060 assert_eq!(r.layers.len(), 2);
1061 // Higher-layer empty cell: fully transparent, so the lower layer shows.
1062 assert_eq!(r.layers[1].cells[0].flags, 0);
1063 // Higher-layer occupied cell with a Default background is opaque (it erases the glyph
1064 // beneath), inheriting the background from below: here the untouched base cell.
1065 assert_eq!(r.layers[1].cells[1].flags, FLAG_HAS_BG | FLAG_HAS_GLYPH);
1066 assert_eq!(r.layers[1].cells[1].bg, r.layers[0].cells[1].bg);
1067 // Higher-layer glyph with a real background: both, with its own color.
1068 assert_eq!(r.layers[1].cells[2].flags, FLAG_HAS_BG | FLAG_HAS_GLYPH);
1069 assert_eq!(r.layers[1].cells[2].bg, [255, 0, 0, 0]);
1070 }
1071
1072 #[test]
1073 fn draw_layers_full_frame_drops_a_removed_higher_layer() {
1074 let mut r = WgpuBackendBuilder::new()
1075 .grid_size(2, 1)
1076 .build()
1077 .expect("default-font builds");
1078 let tile = Tile::new('Q', Style::new());
1079 r.draw_layers(core::iter::once(DrawCell::on_layer(
1080 1,
1081 Pos::new(0, 0),
1082 &tile,
1083 )))
1084 .expect("draw_layers");
1085 assert_eq!(r.layers.len(), 2);
1086 // Frame 2: only the base layer is streamed, so the higher layer must not linger.
1087 r.draw_layers(core::iter::once(DrawCell::on_layer(
1088 0,
1089 Pos::new(0, 0),
1090 &tile,
1091 )))
1092 .expect("draw_layers");
1093 assert_eq!(r.layers.len(), 1);
1094 }
1095
1096 /// A span's covered cells draw no glyph of their own and take the anchor's background, so one
1097 /// piece of artwork sits on one uniform backdrop (retroglyph#412).
1098 #[test]
1099 fn draw_layers_gives_span_covered_cells_the_anchors_background_and_no_glyph() {
1100 use retroglyph_core::grid::Grid;
1101
1102 let mut r = WgpuBackendBuilder::new()
1103 .grid_size(3, 1)
1104 .build()
1105 .expect("default-font builds");
1106
1107 let mut grid = Grid::new(3, 1);
1108 grid.write_span(0, 0, 0, &["C="], Style::new().bg(RED))
1109 .expect("2x1 span fits");
1110 let anchor = *grid.tile(0, (0, 0)).expect("anchor");
1111 let covered = *grid.tile(0, (1, 0)).expect("covered");
1112 assert!(covered.span_offset().is_some());
1113
1114 r.draw_layers(
1115 [
1116 DrawCell::on_layer(0, Pos::new(0, 0), &anchor),
1117 DrawCell::on_layer(0, Pos::new(1, 0), &covered),
1118 ]
1119 .into_iter(),
1120 )
1121 .expect("draw_layers");
1122
1123 // The covered cell paints the span's background and no glyph of its own.
1124 assert_eq!(r.layers[0].cells[1].flags, FLAG_HAS_BG);
1125 assert_eq!(r.layers[0].cells[1].bg, [255, 0, 0, 0]);
1126 }
1127
1128 #[test]
1129 fn flatten_packs_every_layer_into_one_contiguous_upload() {
1130 let mut r = WgpuBackendBuilder::new()
1131 .grid_size(2, 2)
1132 .build()
1133 .expect("default-font builds");
1134 let tile = Tile::new('#', Style::new());
1135 r.draw_layers(core::iter::once(DrawCell::on_layer(
1136 1,
1137 Pos::new(0, 0),
1138 &tile,
1139 )))
1140 .expect("draw_layers");
1141
1142 r.flatten_layers();
1143 assert_eq!(r.upload.len(), 8, "two layers of four cells");
1144 assert_eq!(r.ranges.len(), 2);
1145 assert_eq!((r.ranges[0].start, r.ranges[0].count), (0, 4));
1146 assert_eq!((r.ranges[1].start, r.ranges[1].count), (4, 4));
1147 // Each layer's slice must hold that layer's own cells, in order.
1148 assert_eq!(&r.upload[0..4], r.layers[0].cells.as_slice());
1149 assert_eq!(&r.upload[4..8], r.layers[1].cells.as_slice());
1150 }
1151
1152 #[test]
1153 fn resize_rebuilds_the_grid_and_reports_the_new_size() {
1154 use retroglyph_core::grid::Size;
1155 let mut r = WgpuBackendBuilder::new()
1156 .grid_size(4, 4)
1157 .build()
1158 .expect("default-font builds");
1159 r.resize(Size::new(10, 3));
1160 assert_eq!(r.size(), Size::new(10, 3));
1161 assert_eq!(r.layers.len(), 1);
1162 assert_eq!(r.layers[0].cells.len(), 30);
1163 }
1164
1165 #[test]
1166 fn present_without_a_device_is_a_no_op() {
1167 use retroglyph_window::presenter::Presenter as _;
1168 let mut r = WgpuBackendBuilder::new()
1169 .grid_size(2, 1)
1170 .build()
1171 .expect("default-font builds");
1172 assert!(r.present().is_ok(), "no device is not an error");
1173 }
1174
1175 /// retroglyph#726: a `Color::Default`-background span on a higher layer must not smear the
1176 /// anchor's column across the whole footprint. Layer 0 has a different background under each
1177 /// half of the span (red under the anchor, blue under the covered cell); the covered cell's
1178 /// `Default` background must inherit from *its own* column (blue), matching
1179 /// `retroglyph-software`'s `resolve_cell_bg`, not the anchor's (red).
1180 #[test]
1181 fn draw_layers_resolves_a_span_covered_cells_default_background_at_its_own_column() {
1182 use retroglyph_core::grid::Grid;
1183
1184 const BLUE: Color = Color::rgb(0, 0, 255);
1185
1186 let mut r = WgpuBackendBuilder::new()
1187 .grid_size(2, 1)
1188 .build()
1189 .expect("default-font builds");
1190
1191 let mut grid = Grid::new(2, 1);
1192 grid.put_tile(0, (0, 0), Tile::new(' ', Style::new().bg(RED)));
1193 grid.put_tile(0, (1, 0), Tile::new(' ', Style::new().bg(BLUE)));
1194 grid.write_span(1, 0, 0, &["C="], Style::new())
1195 .expect("2x1 span fits");
1196
1197 let mut tiles: Vec<(u8, Pos, Tile)> = (0..2)
1198 .map(|x| (0u8, Pos::new(x, 0), *grid.tile(0, (x, 0)).unwrap()))
1199 .collect();
1200 tiles.extend((0..2).map(|x| (1u8, Pos::new(x, 0), *grid.tile(1, (x, 0)).unwrap())));
1201 r.draw_layers(
1202 tiles
1203 .iter()
1204 .map(|(l, pos, t)| DrawCell::on_layer(*l, *pos, t)),
1205 )
1206 .expect("draw_layers is infallible");
1207
1208 let covered = r.layers[1].cells[1];
1209 assert_eq!(covered.flags, FLAG_HAS_BG, "covered cell draws no glyph");
1210 assert_eq!(
1211 covered.bg,
1212 [0, 0, 255, 0],
1213 "covered cell inherits its own column's background, not the anchor's"
1214 );
1215 }
1216
1217 /// Covered-cell suppression is grid state, not a tileset feature, so it holds with the
1218 /// `tilesets` feature off too: a span with no sprite behind it renders as its anchor glyph
1219 /// alone, the same on every pixel backend.
1220 #[test]
1221 fn draw_layers_suppresses_covered_glyphs_without_a_sprite() {
1222 use retroglyph_core::grid::Grid;
1223
1224 let mut r = WgpuBackendBuilder::new()
1225 .grid_size(2, 1)
1226 .build()
1227 .expect("default-font builds");
1228 let mut grid = Grid::new(2, 1);
1229 grid.write_span(0, 0, 0, &["AB"], Style::new()).unwrap();
1230 let tiles: Vec<(u8, Pos, Tile)> = (0..2)
1231 .map(|x| (0u8, Pos::new(x, 0), *grid.tile(0, (x, 0)).unwrap()))
1232 .collect();
1233 r.draw_layers(
1234 tiles
1235 .iter()
1236 .map(|(l, pos, t)| DrawCell::on_layer(*l, *pos, t)),
1237 )
1238 .expect("draw_layers is infallible");
1239
1240 assert_eq!(r.layers[0].cells[0].flags & FLAG_HAS_GLYPH, FLAG_HAS_GLYPH);
1241 assert_eq!(r.layers[0].cells[1].flags & FLAG_HAS_GLYPH, 0);
1242 }
1243}
1244
1245/// Sprite bookkeeping that has to stay in lockstep with the glyph layers (retroglyph#727) and the
1246/// diagnostic for a tint that had no sprite to land on (retroglyph#564).
1247#[cfg(all(test, feature = "default-font", feature = "tilesets"))]
1248mod sprite_layer_tests {
1249 use crate::WgpuRenderer;
1250 use crate::config::WgpuBackendBuilder;
1251 use retroglyph_core::backend::{DrawCell, Output};
1252 use retroglyph_core::color::{Style, Tint};
1253 use retroglyph_core::grid::{Pos, Size};
1254 use retroglyph_core::tile::Tile;
1255 use retroglyph_window::tileset::{Codepage, TilesetOptions};
1256
1257 /// A one-tile 8x16 opaque PNG mapped to `'S'`, encoded once at test time.
1258 fn renderer_with_sprite(cols: u16, rows: u16) -> WgpuRenderer {
1259 let mut img = image::RgbaImage::new(8, 16);
1260 for px in img.pixels_mut() {
1261 *px = image::Rgba([0xFF, 0xFF, 0xFF, 0xFF]);
1262 }
1263 let mut png = Vec::new();
1264 image::DynamicImage::ImageRgba8(img)
1265 .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
1266 .expect("encode png");
1267 let opts = TilesetOptions::builder(png)
1268 .tile_size(8, 16)
1269 .columns(1)
1270 .codepage(Codepage::Custom(vec!['S']))
1271 .build()
1272 .expect("valid one-tile tileset");
1273 WgpuBackendBuilder::new()
1274 .grid_size(cols, rows)
1275 .tileset(opts)
1276 .build()
1277 .expect("tileset builds")
1278 }
1279
1280 /// Draws a sprite on two layers, so `layers` has more than the (always present) base layer's
1281 /// sprites to be reset.
1282 fn renderer_with_a_sprite_on_two_layers() -> WgpuRenderer {
1283 let mut r = renderer_with_sprite(1, 1);
1284 let sprite = Tile::new('S', Style::new());
1285 r.draw_layers(
1286 [
1287 DrawCell::on_layer(0, Pos::new(0, 0), &sprite),
1288 DrawCell::on_layer(1, Pos::new(0, 0), &sprite),
1289 ]
1290 .into_iter(),
1291 )
1292 .expect("draw_layers is infallible");
1293 r
1294 }
1295
1296 #[test]
1297 fn clear_resets_sprite_layers_to_a_single_empty_layer() {
1298 let mut r = renderer_with_a_sprite_on_two_layers();
1299 assert_eq!(r.layers.len(), 2);
1300 assert!(!r.layers[0].sprites.is_empty());
1301
1302 r.clear().expect("clear is infallible");
1303
1304 assert_eq!(r.layers.len(), 1);
1305 assert!(r.layers[0].sprites.is_empty());
1306 }
1307
1308 #[test]
1309 fn resize_resets_sprite_layers_to_a_single_empty_layer() {
1310 let mut r = renderer_with_a_sprite_on_two_layers();
1311 assert_eq!(r.layers.len(), 2);
1312 assert!(!r.layers[0].sprites.is_empty());
1313
1314 r.resize(Size::new(2, 2));
1315
1316 assert_eq!(r.layers.len(), 1);
1317 assert!(r.layers[0].sprites.is_empty());
1318 }
1319
1320 #[test]
1321 fn flatten_packs_sprite_layers_in_lockstep_with_the_glyph_layers() {
1322 let mut r = renderer_with_a_sprite_on_two_layers();
1323 r.flatten_layers();
1324 assert_eq!(r.sprite_ranges.len(), r.ranges.len());
1325 assert_eq!(r.sprite_upload.len(), 2, "one sprite per layer");
1326 assert_eq!((r.sprite_ranges[0].start, r.sprite_ranges[0].count), (0, 1));
1327 assert_eq!((r.sprite_ranges[1].start, r.sprite_ranges[1].count), (1, 1));
1328 }
1329
1330 /// retroglyph#564: a tint on a cell whose glyph resolved to a bitmap font rather than a sprite
1331 /// is silently dropped, so it is reported once per glyph.
1332 #[test]
1333 fn a_tint_on_a_font_glyph_is_reported_once() {
1334 let mut r = renderer_with_sprite(1, 1);
1335 let tile = Tile::new('X', Style::new());
1336 for _ in 0..3 {
1337 r.draw_layers(core::iter::once(
1338 DrawCell::on_layer(0, Pos::new(0, 0), &tile).with_tint(Tint::multiply(1, 2, 3)),
1339 ))
1340 .expect("draw_layers is infallible");
1341 }
1342 assert!(r.diagnostics.has_reported_dropped_tint('X'));
1343 assert_eq!(
1344 r.diagnostics.dropped_tint_report_count(),
1345 1,
1346 "reported once, not per frame"
1347 );
1348 }
1349
1350 #[test]
1351 fn a_tint_on_a_sprite_cell_is_not_reported() {
1352 let mut r = renderer_with_sprite(1, 1);
1353 let tile = Tile::new('S', Style::new());
1354 r.draw_layers(core::iter::once(
1355 DrawCell::on_layer(0, Pos::new(0, 0), &tile).with_tint(Tint::multiply(1, 2, 3)),
1356 ))
1357 .expect("draw_layers is infallible");
1358 assert_eq!(
1359 r.diagnostics.dropped_tint_report_count(),
1360 0,
1361 "the tint was applied"
1362 );
1363 }
1364
1365 #[test]
1366 fn tint_none_is_never_reported() {
1367 let mut r = renderer_with_sprite(1, 1);
1368 let tile = Tile::new('X', Style::new());
1369 r.draw_layers(core::iter::once(DrawCell::on_layer(
1370 0,
1371 Pos::new(0, 0),
1372 &tile,
1373 )))
1374 .expect("draw_layers is infallible");
1375 assert_eq!(r.diagnostics.dropped_tint_report_count(), 0);
1376 }
1377}
1378
1379/// Cross-backend `Output` conformance (retroglyph#763).
1380///
1381/// `WgpuRenderer` implements neither `Input` nor `Cursor`, so only
1382/// [`assert_output_contract`](retroglyph_core::testing::conformance::assert_output_contract)
1383/// applies; the other two harnesses have no facet here to check.
1384#[cfg(all(test, feature = "default-font"))]
1385mod conformance {
1386 use crate::config::WgpuBackendBuilder;
1387 use crate::{Cell, WgpuRenderer};
1388 use retroglyph_core::backend::{Compositing, DrawCell, Output};
1389 use retroglyph_core::grid::HasSize as _;
1390 use retroglyph_core::grid::Size;
1391 use retroglyph_core::testing::conformance::{Observable, SlotDelta, fnv1a};
1392
1393 /// A renderer plus the instance data as of the previous [`snapshot`](Observable::snapshot), so
1394 /// each call can hash what changed rather than the whole frame.
1395 ///
1396 /// `WgpuRenderer` has no CPU-readable framebuffer without a device (see `headless`'s pixel
1397 /// readback tests), but its `layers` field is the exact per-cell data every draw uploads
1398 /// verbatim on the next present, so hashing that is equivalent to hashing the frame for
1399 /// everything this contract checks: clear, resize, and out-of-range handling never reach the
1400 /// GPU at all.
1401 struct WgpuObserver {
1402 renderer: WgpuRenderer,
1403 delta: SlotDelta,
1404 }
1405
1406 impl WgpuObserver {
1407 fn new(size: Size) -> Self {
1408 let renderer = WgpuBackendBuilder::new()
1409 .grid_size(size.width().max(1), size.height().max(1))
1410 .build()
1411 .expect("default-font builds");
1412 let mut delta = SlotDelta::new();
1413 // Seed `delta` with the blank initial layers so the first real `snapshot()` call
1414 // diffs against "what a freshly made backend looks like", not against nothing (an
1415 // empty `previous` would make every cell of the first draw look changed, even the
1416 // ones the caller never touched).
1417 delta.digest(
1418 b"wgpu-diff",
1419 layer_cells(&renderer)
1420 .into_iter()
1421 .flatten()
1422 .map(|c| fnv1a(bytemuck::bytes_of(&c))),
1423 );
1424 Self { renderer, delta }
1425 }
1426 }
1427
1428 /// Each layer's cell instances, as a plain `Vec<Vec<Cell>>` snapshot: `Layer` is private to
1429 /// the crate root and also carries a `sprites` field this hash doesn't need, so the
1430 /// conformance snapshot copies out just the part it hashes.
1431 fn layer_cells(renderer: &WgpuRenderer) -> Vec<Vec<Cell>> {
1432 renderer.layers.iter().map(|l| l.cells.clone()).collect()
1433 }
1434
1435 impl Output for WgpuObserver {
1436 type Error = core::convert::Infallible;
1437
1438 fn draw_layers<'a, I>(&mut self, content: I) -> Result<(), Self::Error>
1439 where
1440 I: Iterator<Item = DrawCell<'a>>,
1441 {
1442 self.renderer.draw_layers(content)
1443 }
1444
1445 fn compositing(&self) -> Compositing {
1446 self.renderer.compositing()
1447 }
1448
1449 fn flush(&mut self) -> Result<(), Self::Error> {
1450 self.renderer.flush()
1451 }
1452
1453 fn size(&self) -> Size {
1454 self.renderer.size()
1455 }
1456
1457 fn clear(&mut self) -> Result<(), Self::Error> {
1458 self.renderer.clear()
1459 }
1460
1461 fn resize(&mut self, size: Size) {
1462 self.renderer.resize(size);
1463 }
1464 }
1465
1466 impl Observable for WgpuObserver {
1467 fn snapshot(&mut self) -> u64 {
1468 self.delta.digest(
1469 b"wgpu-diff",
1470 layer_cells(&self.renderer)
1471 .into_iter()
1472 .flatten()
1473 .map(|c| fnv1a(bytemuck::bytes_of(&c))),
1474 )
1475 }
1476 }
1477
1478 #[test]
1479 fn output_contract() {
1480 retroglyph_core::testing::conformance::assert_output_contract(WgpuObserver::new);
1481 }
1482
1483 #[test]
1484 fn compositing_forwards_to_the_inner_renderer() {
1485 // `assert_output_contract` above never calls `compositing()` (see its own docs on what
1486 // it does not cover), so this pins the forwarding directly: `WgpuObserver` must report
1487 // the same value the real `WgpuRenderer` does, not the trait's `CellFlattened` default.
1488 let observer = WgpuObserver::new(Size::new(2, 1));
1489 assert_eq!(
1490 observer.compositing(),
1491 Compositing::PixelLayered {
1492 needs_full_frame: true
1493 }
1494 );
1495 }
1496}