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