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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-15 20:50:40 -04:00
1 parent fee978c7e4
commit c9163e98ad
5 files changed
+134 -20

No files matched your search

+47 -4
View File
@@ -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<Vec<u8>> {
@@ -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<Option<Capturer>> {
static SHARED: OnceLock<Result<Option<Capturer>>> = 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<Option<Capturer>>,
policy: Policy,
scene: &Scene,
layers: &[&Scene],
cam: &Camera,
w: u32,
h: u32,
) -> Result<(Vec<u8>, 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;
+41
View File
@@ -29,3 +29,44 @@ pub enum GpuError {
}
pub type Result<T> = std::result::Result<T, GpuError>;
/// 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<String>,
},
}
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",
}
}
}
+1 -15
View File
@@ -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) {
+1 -1
View File
@@ -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};
+44
View File
@@ -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<Vec<u8>, 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<u8> = (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();