Apply a camera profile's HueSatMap and LookTable after exposure

The second half of D20: a camera_profile scene operation at order 25
that converts working colour into linear ProPhoto, runs the DNG SDK's
HSV lookup through the HueSatMap and then the LookTable, and converts
back. Hue and saturation do not change under the uniform gains before
it, so a 2.5-D HueSatMap gives the same answer as straight after the
matrix, and the look sees the photographer's exposure as it does in
the SDK. Two departures for scene-referred values: value is not
clamped on the way out, and a colour outside ProPhoto passes through.

The operation holds only the switch (on by default) and a look
strength of 0-200 %. It is composed while the switch is on — a new
Operation::composes() separates "does something" from "moved from the
defaults", so an untouched raw renders through its profile and still
writes nothing. The tables come from the source: dr-gpu uploads the
ones DemosaicedImage carries into a storage buffer at @binding(8),
whose two-entry header tells the fragment whether there is anything to
apply, and binds a header of zeros for every other source.

apply_reference is the lookup on the CPU. The GPU test holds the
shader to it over 256 colours, through synthetic tables strong enough
that a wrong index shows, and through the library's real Adobe
Standard tables when the 6D DNG is present.
This commit is contained in:
2026-10-02 22:38:06 -04:00
parent f6a3f3f4e2
commit c02b401a9a
13 changed files with 1117 additions and 51 deletions
+70
View File
@@ -71,6 +71,14 @@ pub struct AdjustPass {
empty_film_lut: wgpu::TextureView,
/// The loaded stock's tables, once uploaded. See [`Self::set_film`].
film: Option<FilmTextures>,
/// TRACES: FR-DEV-3e
/// Bound at `@binding(8)` for a source with no camera profile tables: the
/// two-entry header of zeros that tells the fragment there is nothing to
/// apply (D20).
empty_profile: wgpu::Buffer,
/// The current source's tables, uploaded, keyed by
/// [`DemosaicedImage::id`] — one upload per source rather than per frame.
profile: Option<(u64, wgpu::Buffer)>,
/// 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
@@ -512,6 +520,37 @@ impl AdjustPass {
}
/// The curve texture to bind: the loaded stock's, or the placeholder.
/// TRACES: FR-DEV-3e
/// A source's camera profile tables as the storage buffer
/// `@binding(8)` reads, laid out by `dr_pipeline`'s `profile_buffer`.
fn upload_profile(ctx: &GpuContext, tables: Option<&dr_types::ProfileTables>) -> wgpu::Buffer {
let data = dr_pipeline::ops::camera_profile::profile_buffer(tables);
ctx.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("adjust-profile-tables"),
contents: bytemuck::cast_slice(&data),
usage: wgpu::BufferUsages::STORAGE,
})
}
/// TRACES: FR-DEV-3e
/// The buffer to bind for `source`: its tables, uploaded once per source,
/// or the empty header. A cheap handle, cloned out so a caller holding
/// other borrows of `self` can bind it.
fn profile_buffer(&mut self, source: &DemosaicedImage) -> wgpu::Buffer {
let Some(tables) = source.profile_tables() else {
return self.empty_profile.clone();
};
if let Some((id, buffer)) = &self.profile {
if *id == source.id() {
return buffer.clone();
}
}
let buffer = Self::upload_profile(&self.ctx, Some(tables));
self.profile = Some((source.id(), buffer.clone()));
buffer
}
fn film_curves_view(&self) -> &wgpu::TextureView {
self.film
.as_ref()
@@ -645,6 +684,8 @@ impl AdjustPass {
empty_film_curves,
empty_film_lut,
film: None,
empty_profile: Self::upload_profile(ctx, None),
profile: None,
detail: DetailRunner::new(ctx),
linear_bind_group_layout,
linear_pipeline_layout,
@@ -775,6 +816,17 @@ impl AdjustPass {
},
count: None,
},
// The camera profile's tables (FR-DEV-3e, D20).
wgpu::BindGroupLayoutEntry {
binding: 8,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
})
}
@@ -973,6 +1025,7 @@ impl AdjustPass {
usage: wgpu::BufferUsages::UNIFORM,
});
let profile = self.profile_buffer(source);
let pipeline = self
.cache
.get(&shader.structure_hash)
@@ -1020,6 +1073,10 @@ impl AdjustPass {
binding: 7,
resource: wgpu::BindingResource::TextureView(&sample_out),
},
wgpu::BindGroupEntry {
binding: 8,
resource: profile.as_entire_binding(),
},
],
});
@@ -1139,6 +1196,7 @@ impl AdjustPass {
// 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 profile = self.profile_buffer(source);
let mut enc = self
.ctx
@@ -1202,6 +1260,10 @@ impl AdjustPass {
binding: 7,
resource: wgpu::BindingResource::TextureView(&sample_out),
},
wgpu::BindGroupEntry {
binding: 8,
resource: profile.as_entire_binding(),
},
],
});
let pipeline = self
@@ -1298,6 +1360,10 @@ impl AdjustPass {
binding: 7,
resource: wgpu::BindingResource::TextureView(&no_sample_out),
},
wgpu::BindGroupEntry {
binding: 8,
resource: profile.as_entire_binding(),
},
],
});
{
@@ -1609,6 +1675,10 @@ impl AdjustPass {
binding: 7,
resource: wgpu::BindingResource::TextureView(&self.sample.no_sample_out),
},
wgpu::BindGroupEntry {
binding: 8,
resource: self.empty_profile.as_entire_binding(),
},
],
});