From c9163e98ad2215f6fc17341a44e096b700cba1e3 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:31:18 -0400 Subject: [PATCH] gpu: captures draw name labels; a shared capturer and a PNG with the CPU as last tier render::draw_labels draws a scene's labels over finished pixels in the CPU rasterizer's font. capture::shared opens the process's device once by the FIELDCRAFT_GPU policy; capture::png draws on it or on the CPU and reports which (Renderer). Refs #422 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/gpu/src/capture.rs | 51 ++++++++++++++++++++++++++++++++++--- crates/gpu/src/lib.rs | 41 +++++++++++++++++++++++++++++ crates/gpu/src/select.rs | 16 +----------- crates/render/src/lib.rs | 2 +- crates/render/src/raster.rs | 44 ++++++++++++++++++++++++++++++++ 5 files changed, 134 insertions(+), 20 deletions(-) diff --git a/crates/gpu/src/capture.rs b/crates/gpu/src/capture.rs index 7601821..ae385f2 100644 --- a/crates/gpu/src/capture.rs +++ b/crates/gpu/src/capture.rs @@ -3,13 +3,14 @@ //! and laid over the viewport's background gradient. Every GPU call sits inside error scopes, //! so a device error (out of memory, validation) returns as an error, never a panic. -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; use fieldcraft_render::{Camera, Rgb, Scene}; -use crate::select::{self, Policy, Tier}; +use crate::select::{self, Policy}; use crate::view::{Frame, GpuScene, GpuTarget, Layer, Pipelines}; use crate::{GpuError, Result}; +use crate::{Renderer, Tier}; /// Largest picture side and pixel count drawn (the CPU rasterizer's limits). pub const MAX_SIDE: u32 = fieldcraft_render::MAX_DIM as u32; @@ -96,8 +97,10 @@ impl Capturer { if let Some(e) = caught { return Err(GpuError::Failed(e.to_string())); } - let pixels = drawn?; - Ok(over_background(&pixels, w as usize, h as usize, scene.background.unwrap_or(BACKGROUND))) + let pixels = over_background(&drawn?, w as usize, h as usize, scene.background.unwrap_or(BACKGROUND)); + // Labels in the CPU rasterizer's font (the window draws them with the interface's). + let layers: Vec<&Scene> = std::iter::once(scene).chain(layers.iter().copied()).collect(); + fieldcraft_render::draw_labels(&pixels, w as usize, h as usize, &layers, cam, scene.radius).map_err(|e| GpuError::Failed(e.to_string())) } fn draw(&self, scene: &Scene, layers: &[&Scene], cam: &Camera, w: u32, h: u32) -> Result> { @@ -184,6 +187,46 @@ impl Capturer { } } +/// The process's capturer, opened on first use by the `FIELDCRAFT_GPU` policy: `Ok(None)` for +/// the CPU rasterizer. Its error is kept, so a missing device is not looked for again. +pub fn shared() -> &'static Result> { + static SHARED: OnceLock>> = OnceLock::new(); + SHARED.get_or_init(|| Policy::from_env().and_then(Capturer::open)) +} + +/// A PNG of `scene` with `layers` seen by `cam`, `w` × `h` pixels, drawn by `capturer` (a +/// [`shared`] result): on its GPU, or by the CPU rasterizer when it is `Ok(None)`. Under the +/// `auto` policy a GPU failure falls back to the CPU and the reason is reported; under a strict +/// one it is the error. +pub fn png( + capturer: &Result>, + policy: Policy, + scene: &Scene, + layers: &[&Scene], + cam: &Camera, + w: u32, + h: u32, +) -> Result<(Vec, Renderer)> { + let gpu = match capturer { + Ok(Some(c)) => c.rgba(scene, layers, cam, w, h).map(|px| Some((px, c))), + Ok(None) => Ok(None), + Err(e) => Err(e.clone()), + }; + let why = match gpu { + Ok(Some((px, c))) => { + let png = + fieldcraft_render::png(w as usize, h as usize, &px).ok_or_else(|| GpuError::Failed("the picture could not be encoded".into()))?; + return Ok((png, Renderer::Gpu { adapter: c.adapter.clone(), tier: c.tier, samples: c.samples() })); + } + Ok(None) => (policy == Policy::Auto).then(|| "no GPU adapter".to_string()), + Err(e @ GpuError::Size(_)) => return Err(e), + Err(e) if policy == Policy::Auto => Some(e.to_string()), + Err(e) => return Err(e), + }; + let png = fieldcraft_render::render_layers_png(scene, layers, cam, w as usize, h as usize).map_err(|e| GpuError::Size(e.to_string()))?; + Ok((png, Renderer::Cpu { why })) +} + /// Bytes per row of a `w` pixel RGBA copy, padded to wgpu's 256-byte alignment. pub fn padded_row(w: u32) -> u32 { let a = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 9263b36..9ae3c50 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -29,3 +29,44 @@ pub enum GpuError { } pub type Result = std::result::Result; + +/// The kind of device a picture was drawn on. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Tier { + Hardware, + Software, +} + +impl Tier { + pub fn label(self) -> &'static str { + match self { + Tier::Hardware => "hardware GPU", + Tier::Software => "software GPU", + } + } +} + +/// What drew a picture. +#[derive(Clone, Debug, PartialEq)] +pub enum Renderer { + Gpu { + adapter: String, + tier: Tier, + samples: u32, + }, + /// The CPU rasterizer, and why when a GPU was wanted (`auto` with no usable GPU). + Cpu { + why: Option, + }, +} + +impl Renderer { + /// `"hardware"`, `"software"` or `"cpu"`. + pub fn kind(&self) -> &'static str { + match self { + Renderer::Gpu { tier: Tier::Hardware, .. } => "hardware", + Renderer::Gpu { tier: Tier::Software, .. } => "software", + Renderer::Cpu { .. } => "cpu", + } + } +} diff --git a/crates/gpu/src/select.rs b/crates/gpu/src/select.rs index bd0f706..8fc9ab7 100644 --- a/crates/gpu/src/select.rs +++ b/crates/gpu/src/select.rs @@ -44,21 +44,7 @@ impl Policy { } } -/// The kind of device a picture was drawn on. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Tier { - Hardware, - Software, -} - -impl Tier { - pub fn label(self) -> &'static str { - match self { - Tier::Hardware => "hardware GPU", - Tier::Software => "software GPU", - } - } -} +pub use crate::Tier; /// An adapter's tier and its rank within it (lower is better). pub fn rank(t: wgpu::DeviceType) -> (Tier, u8) { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index dd4680f..4301f79 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -19,6 +19,6 @@ pub use camera::{Camera, FIT_MARGIN, FULL_VIEW, Mat4, Orientation, StandardView} pub use display::{DisplayEdge, DisplayLine, DisplayMesh, DisplayNode, DisplayTri, SurfaceCurve, SurfaceTri}; pub use raster::{ Canvas, LABEL_OFFSET, MAX_DIM, MAX_PIXELS, MAX_PRIMITIVES, RenderError, Rgb, Scene, SceneFill, SceneLabel, SceneLine, ScenePoint, SceneTri, - fit_size, png, render, render_layers, render_layers_png, render_png, shade, + draw_labels, fit_size, png, render, render_layers, render_layers_png, render_png, shade, }; pub use vec::{Aabb3, Vec3}; diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 5c6b9a5..803dd3c 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -336,6 +336,36 @@ pub fn render_layers(scene: &Scene, overlays: &[&Scene], cam: &Camera, w: usize, downsample(&c, w, h) } +/// `layers`' labels drawn over finished `w × h` RGBA pixels (a GPU picture), as [`render_layers`] +/// draws them: the pixels are drawn at the supersampled size, so pixels away from a label come +/// back unchanged. `radius` is the scene's clipping radius. +pub fn draw_labels(rgba: &[u8], w: usize, h: usize, layers: &[&Scene], cam: &Camera, radius: f64) -> Result, RenderError> { + if !size_ok(w, h) || rgba.len() != w * h * 4 { + return Err(RenderError::Size { w, h }); + } + let labels: Vec<&SceneLabel> = layers.iter().flat_map(|s| &s.labels).take(MAX_LABELS).collect(); + if labels.is_empty() { + return Ok(rgba.to_vec()); + } + let (sw, sh) = (w * SS, h * SS); + let mut c = Canvas::new(sw, sh)?; + for y in 0..sh { + for x in 0..sw { + let (i, o) = (((y / SS) * w + x / SS) * 4, (y * sw + x) * 4); + if let (Some(src), Some(dst)) = (rgba.get(i..i + 4), c.rgba.get_mut(o..o + 4)) { + dst.copy_from_slice(src); + } + } + } + let proj = Projector::new(cam, sw as f64, sh as f64, radius); + for l in labels { + if let Some(q) = proj.screen(l.p) { + label(&mut c, q, &l.text, l.color.f()); + } + } + Ok(downsample(&c, w, h)?.rgba) +} + /// Cover a triangle with screen corners `a`, `b`, `c` (x, y, depth) on a `w × h` pixel grid: /// `pixel(x, y, depth)` for every pixel whose centre is inside it (its edges included), the depth /// interpolated. Degenerate and off-grid triangles cover nothing. The renderer and the picking @@ -614,6 +644,20 @@ mod tests { let _ = render(&wild, &cam, 32, 32); } + #[test] + fn labels_draw_over_finished_pixels() { + let (w, h) = (64, 32); + let px: Vec = (0..w * h).flat_map(|i| [(i % 251) as u8, 40, 200, 255]).collect(); + let cam = Camera::default(); + let mut scene = Scene::default(); + assert_eq!(draw_labels(&px, w, h, &[&scene], &cam, 10.0).unwrap(), px); + scene.labels.push(SceneLabel { p: Vec3::ZERO, text: "RP".into(), color: Rgb(0, 0, 0) }); + let out = draw_labels(&px, w, h, &[&scene], &cam, 10.0).unwrap(); + let changed = out.chunks(4).zip(px.chunks(4)).filter(|(a, b)| a != b).count(); + assert!(changed > 10 && changed < w * h / 2, "{changed}"); + assert!(draw_labels(&px, w + 1, h, &[&scene], &cam, 10.0).is_err()); + } + #[test] fn labels_draw_over_everything_beside_their_point() { let mut scene = red_triangle();