Render a film stock on the GPU, and let it take over the rendering

The stock model landed in dr-film with no way to see it. This is the
pipeline node, the two texture bindings it reads, and the end-to-end test
that proves the shader agrees with the model.

The design point is that a film simulation is not an adjustment. Every
other node changes a picture; this one makes it. A stock's characteristic
curve does the camera profile's base curve's job -- from measurements
rather than from a curve somebody drew -- so running both renders the
scene twice: the camera's rendering, and then a film's rendering of that.
It looks like neither, and it reads as a colour-management bug with no
colour-management bug to find.

So `Operation::renders` is new. A node declaring it takes camera RGB and
hands back linear sRGB, and the composer emits neither the base curve nor
the conversion out of camera space. Both halves move together, and the
composer keeps them as one string precisely so that getting half of it
right is impossible.

The tables are not parameters, for the reason vignetting's coefficients
are not: they are measurements. dr-pipeline declares the layout as a plain
struct and keeps its no-dependency property; the two crates share no types
on purpose. `EditGraph::set_film_tables` offers them to every node rather
than to the one that wants them, because knowing which concrete type is
which is what the graph is organised not to know.

Bindings 4 and 5 follow the masks precedent: declared unconditionally so
one bind group layout serves every generated shader, bound to 1x1
placeholders when no stock is loaded. Both are interpolated by hand with
textureLoad -- this pipeline binds no sampler, and adding one for two
lookups would cost a binding in every shader. Uploads are keyed on content
so an unchanged stock does not push half a megabyte across the bus per
frame.

The end-to-end test earned its place immediately: it found the density
lookup being filled z-fastest while a 3D texture upload wants x-fastest,
so the red and blue axes were transposed. Green matched exactly, which is
what that bug looks like -- a plausible photograph of the wrong colour,
and one that every unit test on either side of the seam passes. dr-film
now pins the layout in a test that needs no device, and states it where
the field is declared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 15:12:56 +02:00
co-authored by Claude Opus 5
parent b6a95e1965
commit 4a2fcb6d22
14 changed files with 1222 additions and 93 deletions
+4
View File
@@ -31,6 +31,10 @@ env_logger.workspace = true
# encode once, and scale between a proxy and an export. A dev-dependency, so a
# shipping `dr-gpu` does not carry it.
dr-pipeline = { workspace = true, features = ["detail-probe"] }
# The film stocks, for the end-to-end test only. The library half deliberately
# does not link them: `dr-gpu` binds tables it is handed and has no opinion on
# where a stock comes from.
dr-film.workspace = true
# The local-adjustment example needs the model, which the library half of this
# crate deliberately does not: `dr-gpu` holds the shaders, and the inference
# runtime belongs to whoever is asking a question about the picture.
+286
View File
@@ -74,6 +74,13 @@ pub struct AdjustPass {
current: usize,
/// Bound at `@binding(3)` when the edit carries no mask layers.
empty_masks: wgpu::TextureView,
/// TRACES: FR-DEV-3f
/// Bound at `@binding(4)` and `@binding(5)` when no film stock is loaded,
/// which is the state of every photograph in the catalogue by default.
empty_film_curves: wgpu::TextureView,
empty_film_lut: wgpu::TextureView,
/// The loaded stock's tables, once uploaded. See [`Self::set_film`].
film: Option<FilmTextures>,
/// TRACES: FR-DEV-3 | FR-DEV-3d
/// The neighbourhood stage — sharpening, noise reduction, clarity and the
/// rest of FR-DEV-3's detail set, which cannot be fused into the shader
@@ -121,7 +128,168 @@ struct Target {
height: u32,
}
/// The texture format both film tables are uploaded in.
///
/// 32-bit float, and not the half-float the rest of the pipeline prefers: the
/// LUT is half a megabyte either way at the size it is baked at, and a density
/// carries its precision straight into a colour. Halving a table this small
/// would trade the one thing it is for the one thing it is not short of.
const FILM_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float;
/// TRACES: FR-DEV-3f
/// A baked film stock, resident on the GPU.
struct FilmTextures {
curves: wgpu::TextureView,
lut: wgpu::TextureView,
/// What the resident tables were built from, so an unchanged stock is not
/// re-uploaded. Every frame would otherwise push half a megabyte across
/// the bus to arrive at the bytes already there.
key: u64,
}
/// A cheap content key for a set of tables.
///
/// Not a cryptographic hash and not trying to be: it decides whether to skip an
/// upload, and the cost of a collision is a stale lookup on a stock the user
/// just changed. Every field that *shapes* the tables goes in whole; the tables
/// themselves are sampled, because two stocks agreeing on the matrix, both
/// domains and every eighth entry are the same stock.
fn film_key(t: &dr_pipeline::ops::FilmTables) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
let mut mix = |bits: u32| {
h ^= u64::from(bits);
h = h.wrapping_mul(0x1000_0000_01b3);
};
for row in &t.exposure_matrix {
for v in row {
mix(v.to_bits());
}
}
for v in [t.curve_log_min, t.curve_log_max, t.density_max, t.lut_size as f32] {
mix(v.to_bits());
}
for e in t.lut.iter().step_by(8).chain(t.curves.iter().step_by(8)) {
mix(e[0].to_bits() ^ e[1].to_bits().rotate_left(11) ^ e[2].to_bits().rotate_left(22));
}
h
}
/// Pad RGB triples to the RGBA the upload wants.
///
/// The alpha is never read — the shader takes `.rgb` from the lookup and
/// indexes the curve by channel — so it is written as one rather than left
/// undefined, which keeps a dump of the texture legible if anyone has to look.
fn to_rgba(triples: &[[f32; 3]]) -> Vec<f32> {
let mut out = Vec::with_capacity(triples.len() * 4);
for t in triples {
out.extend_from_slice(&[t[0], t[1], t[2], 1.0]);
}
out
}
impl AdjustPass {
/// TRACES: FR-DEV-3f
/// Make a baked film stock current, or clear it.
///
/// Separate from `render` rather than another argument to it, because a
/// stock changes when a person picks one and a frame is rendered sixty
/// times a second. Threading half a megabyte through the render path would
/// invite exactly the per-frame upload the key below exists to avoid.
pub fn set_film(&mut self, tables: Option<&dr_pipeline::ops::FilmTables>) {
let Some(t) = tables else {
self.film = None;
return;
};
let key = film_key(t);
if self.film.as_ref().is_some_and(|f| f.key == key) {
return;
}
if !t.is_well_formed() {
// Refused here as well as in the operation, because this is the
// last point before a shader indexes the result. The two checks
// are cheap and the failure they prevent is a driver-dependent
// read past the end of a texture.
log::error!("adjust: refusing malformed film tables");
self.film = None;
return;
}
let curves = self.upload_film(
"adjust-film-curves",
wgpu::TextureDimension::D2,
wgpu::Extent3d {
width: t.curves.len() as u32,
height: 1,
depth_or_array_layers: 1,
},
&to_rgba(&t.curves),
);
let n = t.lut_size as u32;
let lut = self.upload_film(
"adjust-film-lut",
wgpu::TextureDimension::D3,
wgpu::Extent3d { width: n, height: n, depth_or_array_layers: n },
&to_rgba(&t.lut),
);
self.film = Some(FilmTextures { curves, lut, key });
}
/// Create a texture and write `data` into it in one go.
fn upload_film(
&self,
label: &str,
dimension: wgpu::TextureDimension,
size: wgpu::Extent3d,
data: &[f32],
) -> wgpu::TextureView {
let texture = self.ctx.device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size,
mip_level_count: 1,
sample_count: 1,
dimension,
format: FILM_FORMAT,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
self.ctx.queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
bytemuck::cast_slice(data),
wgpu::TexelCopyBufferLayout {
offset: 0,
// Four channels of four bytes. Stated from the format rather
// than from the data's length, so a short upload is a wgpu
// error naming the texture instead of a skewed lookup.
bytes_per_row: Some(size.width * 16),
rows_per_image: Some(size.height),
},
size,
);
let mut descriptor = wgpu::TextureViewDescriptor {
label: Some(label),
..Default::default()
};
if dimension == wgpu::TextureDimension::D3 {
descriptor.dimension = Some(wgpu::TextureViewDimension::D3);
}
texture.create_view(&descriptor)
}
/// The curve texture to bind: the loaded stock's, or the placeholder.
fn film_curves_view(&self) -> &wgpu::TextureView {
self.film.as_ref().map_or(&self.empty_film_curves, |f| &f.curves)
}
/// The density lookup to bind: the loaded stock's, or the placeholder.
fn film_lut_view(&self) -> &wgpu::TextureView {
self.film.as_ref().map_or(&self.empty_film_lut, |f| &f.lut)
}
pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
pub fn new(ctx: &GpuContext) -> Self {
@@ -175,6 +343,42 @@ impl AdjustPass {
..Default::default()
});
// TRACES: FR-DEV-3f
// What binds to the film slots when no stock is loaded, which is the
// state of every photograph in the catalogue by default. The shader
// declares both unconditionally so that one bind group layout serves
// every generated shader; these cost sixteen bytes each and no branch.
let empty_film_curves = ctx
.device
.create_texture(&wgpu::TextureDescriptor {
label: Some("adjust-empty-film-curves"),
size: wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FILM_FORMAT,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
.create_view(&Default::default());
let empty_film_lut = ctx
.device
.create_texture(&wgpu::TextureDescriptor {
label: Some("adjust-empty-film-lut"),
size: wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D3,
format: FILM_FORMAT,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
.create_view(&wgpu::TextureViewDescriptor {
label: Some("adjust-empty-film-lut-view"),
dimension: Some(wgpu::TextureViewDimension::D3),
..Default::default()
});
Self {
ctx: ctx.clone(),
bind_group_layout,
@@ -183,6 +387,9 @@ impl AdjustPass {
targets: [None, None],
current: 0,
empty_masks,
empty_film_curves,
empty_film_lut,
film: None,
detail: DetailRunner::new(ctx),
linear_bind_group_layout,
linear_pipeline_layout,
@@ -254,6 +461,36 @@ impl AdjustPass {
},
count: None,
},
// TRACES: FR-DEV-3f
// A film stock's characteristic curves, and the density
// lookup carrying everything downstream of them. Present
// in every layout for the reason the masks above are, and
// bound to placeholders when no stock is loaded.
//
// Declared unfilterable, and correctly: the generated
// shader interpolates both by hand with `textureLoad`,
// because this pipeline binds no sampler and adding one
// for two lookups would cost a binding in every shader.
wgpu::BindGroupLayoutEntry {
binding: 4,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 5,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D3,
multisampled: false,
},
count: None,
},
],
})
}
@@ -476,6 +713,14 @@ impl AdjustPass {
masks.map_or(&self.empty_masks, |m| m.view()),
),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::TextureView(self.film_curves_view()),
},
wgpu::BindGroupEntry {
binding: 5,
resource: wgpu::BindingResource::TextureView(self.film_lut_view()),
},
],
});
@@ -580,6 +825,11 @@ impl AdjustPass {
.detail
.colour_target(detail.len(), width, height)
.clone();
// Cloned for the same reason `colour_view` is: `self.detail` is
// borrowed mutably across the encode below, so the film views cannot
// be read off `self` at the point the bind group is built.
let film_curves = self.film_curves_view().clone();
let film_lut = self.film_lut_view().clone();
let mut enc = self
.ctx
@@ -622,6 +872,14 @@ impl AdjustPass {
masks.map_or(&self.empty_masks, |m| m.view()),
),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::TextureView(&film_curves),
},
wgpu::BindGroupEntry {
binding: 5,
resource: wgpu::BindingResource::TextureView(&film_lut),
},
],
});
let pipeline = self
@@ -1424,6 +1682,25 @@ mod tests {
);
}
/// Tables shaped like a real stock's, with values that are not.
///
/// This test is about whether the largest possible shader compiles and
/// dispatches, not about what it renders — `tests/film_sim.rs` is where
/// the pixels are checked against the model. Flat values keep the two
/// concerns apart.
fn film_test_tables() -> dr_pipeline::ops::FilmTables {
const N: usize = 32;
dr_pipeline::ops::FilmTables {
exposure_matrix: [[5.0, 0.5, 0.2], [0.1, 5.0, 0.3], [0.2, 0.5, 4.0]],
curves: vec![[0.5, 0.5, 0.5]; dr_pipeline::ops::film_sim::CURVE_SAMPLES],
curve_log_min: -3.0,
curve_log_max: 4.0,
lut: vec![[0.5, 0.5, 0.5]; N * N * N],
density_max: 3.0,
lut_size: N,
}
}
#[test]
fn the_whole_chain_at_once_compiles() {
// Individually-valid fragments can still collide when combined —
@@ -1454,6 +1731,15 @@ mod tests {
}
}
// `film_sim` is the one operation no parameter can activate: it needs
// a stock's measured tables. Loaded here so that "every operation at
// once" means what it says — and so that this test compiles the
// largest shader the pipeline can actually generate, which is the one
// with a film in it.
let tables = film_test_tables();
g.set_film_tables(Some(tables.clone()));
pass.set_film(Some(&tables));
// Cropped, so the render is against an output size that is not the
// source size — the case where a wrong dispatch or a wrong texture
// allocation would show up.
+157
View File
@@ -0,0 +1,157 @@
//! TRACES: FR-DEV-3f
//! A film stock, end to end on a device.
//!
//! The tests either side of this one check halves, and neither would catch the
//! failure that matters. `dr-film` asserts the spectral model reproduces a
//! reference implementation written in another language; `dr-pipeline` asserts
//! the generated WGSL evaluates a film in the right place and suppresses the
//! camera profile's rendering. Both pass if the tables are uploaded
//! transposed, or the density lookup is indexed in the wrong axis order, or
//! the curve texture is read a channel out — every one of which renders a
//! plausible photograph with the wrong colours in it.
//!
//! So this renders real pixels through the real shader and compares them
//! against the same stock evaluated on the CPU. That closes the chain: the CPU
//! model is checked against the reference, and the shader is checked against
//! the CPU model.
use dr_decode::{BaseCurve, CfaPattern, CropRect, RawImage};
use dr_film::bake::{bake, Recipe};
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
use dr_pipeline::ops::FilmTables;
use dr_pipeline::EditGraph;
const SIZE: u32 = 16;
fn ctx() -> Option<GpuContext> {
pollster::block_on(GpuContext::new_headless()).ok()
}
/// A flat RGGB frame at `level` out of 65535.
///
/// Identity matrix and neutral balance, so the only thing that can move a
/// pixel is the film. A real body's matrix would make every assertion below a
/// statement about that body instead.
fn flat_raw(level: u16) -> RawImage {
RawImage {
width: SIZE,
height: SIZE,
data: vec![level; (SIZE * SIZE) as usize],
cfa_pattern: CfaPattern::Rggb,
black_level: [0; 4],
white_level: u16::MAX,
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
// Off deliberately: a film replaces the camera's rendering, and
// leaving a curve here would test the suppression rather than the
// film. `dr-pipeline` asserts the suppression on the generated source.
base_curve: BaseCurve::IDENTITY,
crop: CropRect { x: 0, y: 0, width: SIZE, height: SIZE },
}
}
/// `dr-film`'s baked output in the layout `dr-pipeline` binds.
///
/// The conversion is spelled out rather than derived, because it is exactly
/// the seam this test exists to check: the two crates share no types on
/// purpose, and a field pasted into the wrong slot here is invisible until
/// pixels come back wrong.
fn tables(baked: &dr_film::Baked) -> FilmTables {
FilmTables {
exposure_matrix: baked.exposure_matrix,
curves: baked.curves.clone(),
curve_log_min: baked.curve_log_min,
curve_log_max: baked.curve_log_max,
lut: baked.lut.clone(),
density_max: baked.density_max,
lut_size: baked.lut_size,
}
}
/// Render a flat frame through a stock and return the centre pixel, 0..1.
///
/// The centre rather than a corner: a demosaic invents its edges, and the
/// border of a 16x16 frame is not where anyone should read a tone off.
fn rendered(ctx: &GpuContext, level: u16, baked: &dr_film::Baked) -> [f32; 3] {
let source = Demosaicer::new(ctx)
.expect("demosaicer")
.run(&flat_raw(level))
.expect("demosaic");
let mut graph = EditGraph::default_chain();
graph.set_film_tables(Some(tables(baked)));
let shader = graph.compose();
let mut adjust = AdjustPass::new(ctx);
adjust.set_film(Some(&tables(baked)));
adjust.render(&source, &shader, SIZE, SIZE).expect("render");
let (pixels, _, _) = adjust.export_pixels().expect("readback");
let c = (((SIZE / 2) * SIZE + SIZE / 2) * 4) as usize;
// Undo the sRGB encode the fused pass applies on the way out, so the
// comparison happens in the linear space the CPU model works in.
[0, 1, 2].map(|i| srgb_to_linear(f32::from(pixels[c + i]) / 255.0))
}
fn srgb_to_linear(v: f32) -> f32 {
if v <= 0.04045 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
}
#[test]
fn a_stock_renders_on_the_gpu_the_way_it_does_on_the_cpu() {
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let film = dr_film::find("kodak_kodachrome_64").expect("stock");
let baked = bake(&Recipe::new(film, None));
for level in [4_000u16, 12_000, 30_000, 50_000] {
// What the shader was handed, expressed the way the CPU model reads
// it: a flat RGGB frame at `level` demosaics to that fraction of full
// scale in all three channels, and the identity matrix leaves it there.
let input = f32::from(level) / f32::from(u16::MAX);
let expected = baked.apply([input; 3]);
let got = rendered(&ctx, level, &baked);
for c in 0..3 {
assert!(
(got[c] - expected[c]).abs() < 0.02,
"level {level}, channel {c}: GPU gave {:.4}, the model says {:.4}\n\
got {got:?}\n want {expected:?}",
got[c],
expected[c]
);
}
}
}
#[test]
fn a_negative_and_its_print_are_not_the_same_picture() {
// The print stage is where the orange mask goes and where the picture
// turns the right way up. If the paper profile were being ignored -- a
// plausible wiring mistake, since both are just "a stock" -- the two
// renders would agree, and a scanned negative would be offered as a
// photograph.
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let film = dr_film::find("kodak_portra_400").expect("stock");
let paper = dr_film::default_print(film).expect("paper");
let scanned = rendered(&ctx, 12_000, &bake(&Recipe::new(film, None)));
let printed = rendered(&ctx, 12_000, &bake(&Recipe::new(film, Some(paper))));
assert!(
scanned[0] > scanned[2] * 3.0,
"the scanned negative has lost its orange mask: {scanned:?}"
);
let spread = printed.iter().cloned().fold(f32::MIN, f32::max)
- printed.iter().cloned().fold(f32::MAX, f32::min);
assert!(spread < 0.06, "the print of a neutral is not neutral: {printed:?}");
}