The oldest open item in the project (ARCH §6.1, spike S1, AC-8). Every frame
in develop was read off the GPU into a `SharedPixelBuffer` and handed back to
Slint to upload again: ~7 ms at 4K against a 0.28 ms compute pass, 96% of the
frame spent carrying pixels to the CPU and back so they could be drawn where
they already were.
Slint 1.17 will adopt a `wgpu::Texture` directly, and the whole of what that
needs is arrangement rather than code.
**One device, made before the window.** A texture belongs to the device that
allocated it, so the compute passes and the compositor cannot each open their
own. `GpuContext::new_shared` opens one and hands back the instance and
adapter alongside it; `dr_ui::shared_gpu` gives all four to
`BackendSelector::require_wgpu_29(WGPUConfiguration::Manual { .. })`. That
call has to come before the first window, because creating one selects a
backend for you — which is why the GPU is now opened at the top of `run`
rather than two hundred lines down beside the other controllers.
dr-gpu still names no UI type. It hands out raw wgpu and does not ask who is
compositing (ARCH §6.5a).
**Vulkan only on the shared path**, where headless keeps its GL fallback.
wgpu's GL backend reaches its display through EGL at instance creation, and
before a window exists there is no display handle to give it — so a GL
instance cannot later produce the window surface Slint needs from it. A
machine with no Vulkan gets no shared device and browses without develop,
which is the same degradation as no adapter at all.
**`renderer-femtovg` becomes `renderer-femtovg-wgpu`.** The old one is FemtoVG
over OpenGL and cannot be handed a wgpu texture at all. It is not kept
alongside as a fallback: FemtoVG-over-GL has no branch for an imported
texture, falls through to "render this image into a buffer", gets nothing, and
draws nothing — a blank canvas with no error, which is worse than the failure
it would be papering over. The consequence is stated plainly in the manifest:
the desktop app now needs a working wgpu adapter to open a window.
**Two output textures, not one, and this is the part that is not obvious.**
Slint repaints when the image property *changes*, and it decides that with
`PartialEq` — which for two images over the same `wgpu::Texture` says
"unchanged". A pass that reused a single target would have rendered every
slider move correctly on the GPU and shown none of them: right, and invisible.
`AdjustPass` alternates between two targets, so consecutive frames are
genuinely different values. It also settles the read-while-write question that
one queue was already answering.
`RENDER_ATTACHMENT` is added to both render targets. Neither pass uses it;
Slint rejects an imported texture without it, on the reasoning that a
compositor handed a texture may need to draw into it.
**`AdjustPass::read_output` is deleted rather than gated.** It and
`export_pixels` were the same transfer under two names, and the comments
explaining why they were separate are the point of the whole criterion:
reading pixels back to *display* them is the defect, reading them back to
*encode a file* is the only way a file is made. The display twin is now gone
outright, which is stronger than a feature flag — it cannot be turned back on.
`export_pixels` is untouched and still ungated. The `readback` feature comes
off dr-ui, darkroom-desktop and darkroom-android; it stays in dr-gpu, where it
still gates `RenderTarget::read_pixels` and the segmentation field readback.
`examples/develop` moves to `export_pixels`, which is honest — it writes a
PPM — and so no longer needs the feature.
Four tests, each named for what it protects and each of which fails without a
screen if the property it guards breaks:
- the adjust target satisfies every condition Slint's import checks, asserted
in the crate that owns the descriptor, because a descriptor that drifts
fails at runtime on a real display and nothing else would notice;
- consecutive renders are different textures, and the third is the first
again, so the alternation is a rotation and not an allocation per frame;
- the develop canvas has no CPU pixel buffer and does have a wgpu texture —
AC-8 itself, in the terms Slint uses;
- consecutive frames compare unequal as `slint::Image`, which is the property
the repaint actually depends on.
The zoom test's readback moves into the test module. It has to: there is no
library function that copies a displayed frame to the CPU any more, and that
is the point — the round-trip now exists in the test binary and nowhere a
shipping build can reach.
**What is not proven.** No GUI was run. What is verified is that the texture
satisfies the import contract, that the import succeeds, that the canvas is a
texture rather than a buffer, and that consecutive frames are distinguishable.
What is unverified is everything that needs a display: that Slint's FemtoVG
wgpu renderer adopts the Manual configuration on a real surface, that the
picture appears the right way up and the right colour, and the frame timing
that motivated the whole exercise. Android is untouched by testing — the
android backend routes a WGPU29 request to Skia, whose wgpu surface does
handle imported textures, but that is read from the source, not observed.
56 dr-gpu tests and 255 dr-ui tests pass, clippy clean under `-D warnings`,
fmt clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
188 lines
7.2 KiB
Rust
188 lines
7.2 KiB
Rust
//! Render a RAW file through the full pipeline and write a PPM.
|
||
//!
|
||
//! The end-to-end check: decode → demosaic → adjust → display encode, on a
|
||
//! real file rather than a synthetic fixture. Unit tests prove each stage in
|
||
//! isolation; this proves they compose into an image a person would accept.
|
||
//!
|
||
//! ```sh
|
||
//! cargo run -p dr-gpu --example develop -- IMG.CR2 out.ppm
|
||
//! ```
|
||
//!
|
||
//! PPM because it needs no encoder dependency and every image viewer reads
|
||
//! it. This is a diagnostic, not the export path (FR-EXP-*).
|
||
|
||
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
|
||
use dr_pipeline::ops::{
|
||
blacks_whites, brilliance, colour_mixer, contrast, curve, exposure, highlights_shadows,
|
||
vibrance, white_balance,
|
||
};
|
||
use dr_pipeline::{EditGraph, ParamId};
|
||
|
||
fn main() {
|
||
env_logger::init();
|
||
|
||
let mut args = std::env::args().skip(1);
|
||
let Some(input) = args.next() else {
|
||
eprintln!("usage: develop <file.cr2> [out.ppm] [preset]");
|
||
eprintln!(" preset: neutral (default) | punchy | recover");
|
||
std::process::exit(2);
|
||
};
|
||
let output = args.next().unwrap_or_else(|| "develop.ppm".into());
|
||
let preset = args.next().unwrap_or_else(|| "neutral".into());
|
||
|
||
let bytes = std::fs::read(&input).expect("read file");
|
||
|
||
let t0 = std::time::Instant::now();
|
||
let raw = dr_decode::decode(&bytes).expect("decode");
|
||
let decode_ms = t0.elapsed().as_secs_f32() * 1000.0;
|
||
println!(
|
||
"decoded {} × {} ({:?}), {decode_ms:.0} ms",
|
||
raw.crop.width, raw.crop.height, raw.cfa_pattern
|
||
);
|
||
|
||
let ctx = pollster::block_on(GpuContext::new_headless()).expect("gpu");
|
||
println!("adapter {} ({:?})", ctx.adapter_name(), ctx.backend());
|
||
|
||
let t1 = std::time::Instant::now();
|
||
let demosaicer = Demosaicer::new(&ctx).expect("demosaicer");
|
||
let image = demosaicer.run(&raw).expect("demosaic");
|
||
ctx.device
|
||
.poll(wgpu::PollType::wait_indefinitely())
|
||
.expect("poll");
|
||
println!("demosaiced {:.0} ms", t1.elapsed().as_secs_f32() * 1000.0);
|
||
|
||
// Build an edit. The presets exist so the output can be eyeballed for
|
||
// each operation actually doing something, not merely compiling.
|
||
let mut graph = EditGraph::default_chain();
|
||
match preset.as_str() {
|
||
"punchy" => {
|
||
graph.set_param(exposure::ID, exposure::EXPOSURE, 0.3);
|
||
graph.set_param(
|
||
highlights_shadows::ID,
|
||
highlights_shadows::HIGHLIGHTS,
|
||
-40.0,
|
||
);
|
||
graph.set_param(highlights_shadows::ID, highlights_shadows::SHADOWS, 30.0);
|
||
graph.set_param(blacks_whites::ID, blacks_whites::BLACKS, -20.0);
|
||
graph.set_param(blacks_whites::ID, blacks_whites::WHITES, 25.0);
|
||
graph.set_param(vibrance::ID, vibrance::VIBRANCE, 35.0);
|
||
}
|
||
"recover" => {
|
||
graph.set_param(exposure::ID, exposure::EXPOSURE, -0.5);
|
||
graph.set_param(
|
||
highlights_shadows::ID,
|
||
highlights_shadows::HIGHLIGHTS,
|
||
-80.0,
|
||
);
|
||
graph.set_param(highlights_shadows::ID, highlights_shadows::SHADOWS, 60.0);
|
||
graph.set_param(brilliance::ID, brilliance::BRILLIANCE, 40.0);
|
||
graph.set_param(white_balance::ID, white_balance::TEMPERATURE, 15.0);
|
||
}
|
||
// Contrast alone, so its effect can be judged without anything else
|
||
// moving.
|
||
"contrast" => {
|
||
graph.set_param(contrast::ID, contrast::CONTRAST, 60.0);
|
||
}
|
||
"flat" => {
|
||
graph.set_param(contrast::ID, contrast::CONTRAST, -60.0);
|
||
}
|
||
// The mixer, pushed hard on the two things this scene actually has:
|
||
// green vegetation and grey-blue rock.
|
||
"mixer" => {
|
||
graph.set_param(colour_mixer::ID, ParamId("green_sat"), 80.0);
|
||
graph.set_param(colour_mixer::ID, ParamId("green_hue"), -40.0);
|
||
graph.set_param(colour_mixer::ID, ParamId("chartreuse_sat"), 60.0);
|
||
graph.set_param(colour_mixer::ID, ParamId("azure_lum"), -50.0);
|
||
}
|
||
// One band only, to check the weighting really is selective rather
|
||
// than affecting the whole image.
|
||
"mixer_one" => {
|
||
graph.set_param(colour_mixer::ID, ParamId("green_sat"), 100.0);
|
||
}
|
||
// A classic S-curve: shadows down, highlights up, mid held.
|
||
"curve_s" => {
|
||
graph.set_param(curve::ID, curve::P1_Y, 0.15);
|
||
graph.set_param(curve::ID, curve::P3_Y, 0.85);
|
||
}
|
||
// The inverse, a film-like lifted-shadow look.
|
||
"curve_lift" => {
|
||
graph.set_param(curve::ID, curve::P0_Y, 0.12);
|
||
graph.set_param(curve::ID, curve::P1_Y, 0.32);
|
||
}
|
||
_ => {}
|
||
}
|
||
|
||
let shader = graph.compose();
|
||
println!(
|
||
"shader {} active op(s), {} uniform floats, structure {:016x}",
|
||
shader.source.matches("---- ").count(),
|
||
shader.uniforms.len(),
|
||
shader.structure_hash
|
||
);
|
||
|
||
let mut adjust = AdjustPass::new(&ctx);
|
||
let (w, h) = image.size();
|
||
|
||
let t2 = std::time::Instant::now();
|
||
adjust.render(&image, &shader, w, h).expect("adjust");
|
||
ctx.device
|
||
.poll(wgpu::PollType::wait_indefinitely())
|
||
.expect("poll");
|
||
println!("adjusted {:.2} ms", t2.elapsed().as_secs_f32() * 1000.0);
|
||
|
||
// Time a second render with only a value changed: this is the slider
|
||
// path, and it must not recompile.
|
||
graph.set_param(exposure::ID, exposure::EXPOSURE, 0.31);
|
||
let again = graph.compose();
|
||
let t3 = std::time::Instant::now();
|
||
adjust.render(&image, &again, w, h).expect("adjust");
|
||
ctx.device
|
||
.poll(wgpu::PollType::wait_indefinitely())
|
||
.expect("poll");
|
||
println!(
|
||
"re-render {:.2} ms ({} pipeline(s) compiled)",
|
||
t3.elapsed().as_secs_f32() * 1000.0,
|
||
adjust.cached_pipelines()
|
||
);
|
||
|
||
// `export_pixels`, because that is honestly what this is: the frame is
|
||
// going into a PPM, not onto a screen. See the note on that method for
|
||
// why the two readbacks were never the same thing (AC-8).
|
||
let (pixels, pw, ph) = adjust.export_pixels().expect("readback");
|
||
|
||
// Sanity: an all-black or all-white result means something upstream
|
||
// failed silently, and it is far easier to see here than in a viewer.
|
||
let mut sum = 0u64;
|
||
let mut min = 255u8;
|
||
let mut max = 0u8;
|
||
for px in pixels.chunks_exact(4) {
|
||
let l = px[0].max(px[1]).max(px[2]);
|
||
sum += u64::from(l);
|
||
min = min.min(l);
|
||
max = max.max(l);
|
||
}
|
||
let mean = sum as f64 / (pixels.len() / 4) as f64;
|
||
println!("levels min {min}, mean {mean:.1}, max {max}");
|
||
if max == 0 {
|
||
eprintln!("WARNING: the image is entirely black");
|
||
}
|
||
|
||
write_ppm(&output, &pixels, pw, ph);
|
||
println!("wrote {output} ({pw} × {ph})");
|
||
}
|
||
|
||
/// Write binary PPM (P6): a three-line header then RGB triples.
|
||
fn write_ppm(path: &str, rgba: &[u8], w: u32, h: u32) {
|
||
use std::io::Write;
|
||
|
||
let mut out = Vec::with_capacity((w * h * 3) as usize + 32);
|
||
out.extend_from_slice(format!("P6\n{w} {h}\n255\n").as_bytes());
|
||
for px in rgba.chunks_exact(4) {
|
||
out.extend_from_slice(&px[..3]);
|
||
}
|
||
std::fs::File::create(path)
|
||
.expect("create output")
|
||
.write_all(&out)
|
||
.expect("write output");
|
||
}
|