ui: ui.render draws on the window's GPU

The app keeps a capturer on its own device; ui.render draws with the
viewport's pipelines there, on the CPU with the reason otherwise, and
the reply names the renderer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-16 08:27:21 -04:00
1 parent b655af305a
commit 6136770c1c
2 files changed
+87 -15

No files matched your search

+4 -3
View File
@@ -631,12 +631,12 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) ->
Ok(s) => s,
Err(e) => return err(e),
};
let png = match app.render_png(w, h, f64::from(scale)) {
Ok(png) => png,
let (png, renderer) = match app.render_png(w, h, f64::from(scale)) {
Ok(drawn) => drawn,
Err(e) => return err(e),
};
match std::fs::write(path, &png) {
Ok(()) => ok(json!({"path": path, "width": w, "height": h, "scale": scale, "bytes": png.len()})),
Ok(()) => ok(json!({"path": path, "width": w, "height": h, "scale": scale, "bytes": png.len(), "renderer": renderer.report()})),
Err(e) => err(format!("{path}: {e}")),
}
}
@@ -996,6 +996,7 @@ mod tests {
let path = std::env::temp_dir().join(format!("fieldcraft-render-{}.png", std::process::id()));
let r = call(&mut app, "ui.render", json!({"path": path.to_string_lossy(), "width": 120, "height": 80}));
assert_eq!(r["ok"], true, "{r}");
assert_eq!(r["result"]["renderer"]["kind"], "cpu", "{r}");
let bytes = std::fs::read(&path).unwrap();
assert!(bytes.starts_with(&[0x89, b'P', b'N', b'G']));
let _ = std::fs::remove_file(&path);
+83 -12
View File
@@ -377,6 +377,11 @@ pub struct ModelApp {
pub toolbar_rects: Vec<(&'static str, egui::Rect, toolbar::Fit)>,
/// The GPU adapter, for the status bar and diagnostics.
pub gpu_info: Option<String>,
/// Draws `ui.render`'s pictures on the window's device with the viewport's pipelines; the
/// CPU rasterizer draws them without it (no GPU, the browser, or a failed set-up, kept in
/// [`App::render_png`]'s report).
#[cfg(not(target_arch = "wasm32"))]
capturer: Result<Option<fieldcraft_gpu::capture::Capturer>, String>,
/// Bumped whenever the model may have changed.
revision: u64,
display: (Option<u64>, DisplayMesh),
@@ -515,6 +520,8 @@ impl ModelApp {
icons: icons::IconCache::default(),
toolbar_rects: Vec::new(),
gpu_info: None,
#[cfg(not(target_arch = "wasm32"))]
capturer: Ok(None),
revision: 0,
display: (None, DisplayMesh::default()),
glyphs: Glyphs::default(),
@@ -601,6 +608,15 @@ impl ModelApp {
self.viewport.gpu = Some(gpu::install(rs, depth_bits, samples, self.viewport.gpu_errors.clone(), ctx.clone()));
let info = rs.adapter.get_info();
self.gpu_info = Some(format!("{} ({:?})", info.name.trim(), info.backend));
#[cfg(not(target_arch = "wasm32"))]
{
let tier = fieldcraft_gpu::select::rank(info.device_type).0;
self.capturer =
fieldcraft_gpu::capture::Capturer::on_device(&rs.adapter, rs.device.clone(), rs.queue.clone(), tier).map(Some).map_err(|e| {
log::warn!("ui.render draws on the CPU: {e}");
e.to_string()
});
}
}
/// Run a command with JSON parameters: what every panel, the control channel and scripts
@@ -1360,22 +1376,47 @@ impl ModelApp {
bounds
}
/// A CPU render of the viewport's scene with the current camera and the selection, as PNG
/// A render of the viewport's scene with the current camera and the selection, as PNG
/// bytes, `w` × `h` pixels at `pixels_per_point`: symbols, lines and nodes are as large as
/// in a window of that scale (#285).
pub fn render_png(&mut self, w: usize, h: usize, pixels_per_point: f64) -> Result<Vec<u8>, fieldcraft_render::RenderError> {
/// in a window of that scale (#285). Drawn with the viewport's pipelines on the window's
/// device when there is one, else (or when the GPU fails) by the CPU rasterizer, with why;
/// the second value says which.
pub fn render_png(&mut self, w: usize, h: usize, pixels_per_point: f64) -> Result<(Vec<u8>, fieldcraft_gpu::Renderer), String> {
let cam = self.cam;
let (_, overlay) = self.overlay();
self.symbols_for(h as f64, pixels_per_point);
self.scene();
if pixels_per_point == 1.0 {
return fieldcraft_render::render_layers_png(&self.scene.1, &[&self.symbols.1, &overlay], &cam, w, h);
}
let (mut scene, mut symbols, mut overlay) = (self.scene.1.clone(), self.symbols.1.clone(), (*overlay).clone());
for s in [&mut scene, &mut symbols, &mut overlay] {
s.scale_marks(pixels_per_point);
}
fieldcraft_render::render_layers_png(&scene, &[&symbols, &overlay], &cam, w, h)
let scaled;
let (scene, symbols, overlay) = if pixels_per_point == 1.0 {
(&self.scene.1, &self.symbols.1, &*overlay)
} else {
let (mut scene, mut symbols, mut overlay) = (self.scene.1.clone(), self.symbols.1.clone(), (*overlay).clone());
for s in [&mut scene, &mut symbols, &mut overlay] {
s.scale_marks(pixels_per_point);
}
scaled = (scene, symbols, overlay);
(&scaled.0, &scaled.1, &scaled.2)
};
#[cfg(not(target_arch = "wasm32"))]
let why = match &self.capturer {
Ok(Some(c)) => {
let (pw, ph) = (u32::try_from(w).unwrap_or(u32::MAX), u32::try_from(h).unwrap_or(u32::MAX));
match c.rgba(scene, &[symbols, overlay], &cam, pw, ph) {
Ok(px) => {
let png = fieldcraft_render::png(w, h, &px).ok_or("the picture could not be encoded")?;
return Ok((png, fieldcraft_gpu::Renderer::Gpu { adapter: c.adapter.clone(), tier: c.tier, samples: c.samples() }));
}
Err(e @ fieldcraft_gpu::GpuError::Size(_)) => return Err(e.to_string()),
Err(e) => Some(e.to_string()),
}
}
Ok(None) => None,
Err(e) => Some(e.clone()),
};
#[cfg(target_arch = "wasm32")]
let why = None;
let png = fieldcraft_render::render_layers_png(scene, &[symbols, overlay], &cam, w, h).map_err(|e| e.to_string())?;
Ok((png, fieldcraft_gpu::Renderer::Cpu { why }))
}
/// The geometry tags behind the drawn mesh, read again when the model changed.
@@ -2670,10 +2711,40 @@ mod tests {
// Framed when the viewport first draws.
draw(&mut app, &egui::Context::default(), 1280.0, 800.0);
assert!(app.framed(), "{:?}", app.cam);
let png = app.render_png(64, 48, 1.0).unwrap();
let png = app.render_png(64, 48, 1.0).unwrap().0;
assert!(png.starts_with(&[0x89, b'P', b'N', b'G']));
}
/// `ui.render` draws with the viewport's pipelines on the window's device when there is one
/// (here a device the `FIELDCRAFT_GPU` policy opens, as the app's), says so, and draws on
/// the CPU with the reason when the GPU could not be set up.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn renders_on_the_windows_gpu() {
use fieldcraft_gpu::{Renderer, capture::Capturer, select};
let mut app = ModelApp::new(Session::default());
app.run_script(&fieldcraft_engine::sample::script()).unwrap();
draw(&mut app, &egui::Context::default(), 1280.0, 800.0);
let size = |png: &[u8]| (u32::from_be_bytes(png[16..20].try_into().unwrap()), u32::from_be_bytes(png[20..24].try_into().unwrap()));
match Capturer::open(select::Policy::from_env().unwrap()) {
Ok(Some(c)) => {
app.capturer = Ok(Some(c));
let (png, drawn) = app.render_png(160, 100, 2.0).unwrap();
assert!(matches!(drawn, Renderer::Gpu { .. }), "{drawn:?}");
assert_eq!(size(&png), (160, 100));
let (gpu, _) = app.render_png(160, 100, 1.0).unwrap();
assert_ne!(png, gpu, "the scale changes the marks");
assert!(app.render_png(0, 100, 1.0).is_err());
}
Ok(None) | Err(_) if select::gpu_required() => panic!("FIELDCRAFT_REQUIRE_GPU=1 but no GPU"),
Ok(None) | Err(_) => eprintln!("no GPU adapter: the GPU half skipped"),
}
app.capturer = Err("no device".into());
let (png, drawn) = app.render_png(160, 100, 1.0).unwrap();
assert_eq!(drawn, Renderer::Cpu { why: Some("no device".into()) });
assert_eq!(size(&png), (160, 100));
}
#[test]
fn the_open_design_and_imported_surfaces_are_drawn() {
let dir = std::env::temp_dir().join(format!("fieldcraft-ui-design-{}", std::process::id()));