Files
DarkRoom/core/dr-gpu/examples/develop.rs
dtourolleandClaude Opus 5 cf8f5b632f Show the develop frame itself, instead of a photocopy of it
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>
2026-08-17 09:54:00 +02:00

188 lines
7.2 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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");
}