Develop longer, from the measurements rather than from a contrast slider

Pushing was not a thing to simulate. It was measured data being thrown
away: Double-X and 2302 each ship five characteristic curves, one per
development time, and this shipped the 6.5-minute column and discarded
four. All five now ship and interpolate.

The axis is real. Double-X runs 4 to 12 minutes, and across it the average
gradient goes 0.472 to 1.034 while Dmax goes 1.19 to 2.56.

The control is in stops, because that is what a photographer means, and one
stop is a factor of about 1.41 in time. That mapping is checked rather than
assumed: against Double-X's own axis it lands within 2% of the 9-minute
column for +1, and near 12 minutes for +2, which are the times the datasheet
gives for exactly that. There is a test.

**Pushing must not recover shadow detail, and this does not.** Across the
whole measured range the speed point moves about a third of a stop while the
gradient doubles; three stops under mid-grey, density goes from 0.008 to
0.035, which is still nothing. Developing longer multiplies what was already
recorded and cannot record what never hit the film. A push built as added
exposure or global contrast brightens those shadows instead and looks
convincing until someone who shoots film sees it, so that property has a
test of its own.

Interpolated in *log* time, because development is multiplicative: 4 to 5
minutes is the same amount of push as 9 to 12, and interpolating linearly
would bunch the control at one end. Clamped at both ends, because past the
published range there is no data and extrapolating a contrast curve invents
an emulsion nobody tested. A stock measured at one process ignores the
control entirely rather than inventing a curve for it -- Portra 800's pushes
are separate *measured* profiles, which is the honest way to offer those.

Costs nothing per pixel and changes no shader. The curves are a per-stock
table, so the interpolation happens on the CPU at bake time, where choosing a
stock and moving its sliders already rebakes. The Vulkan shader is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 17:35:25 +02:00
co-authored by Claude Opus 5
parent 8f863ec56f
commit ce6458547a
7 changed files with 281 additions and 7 deletions
+12
View File
@@ -2408,6 +2408,13 @@ impl DevelopSession {
dr_pipeline::ops::film_sim::EXPOSURE,
)
.unwrap_or(0.0),
push_stops: self
.graph
.param(
dr_pipeline::ops::film_sim::ID,
dr_pipeline::ops::film_sim::PUSH,
)
.unwrap_or(0.0),
print_exposure_ev: self
.graph
.param(
@@ -2471,6 +2478,11 @@ impl DevelopSession {
/// how the negative was exposed. So a slider drag has to rebuild the
/// lookup, which is why the bake is measured in milliseconds and not in
/// frames.
/// The stock, the paper, and how far it was developed.
///
/// Push rides with the other two through every path that re-bakes, because
/// it is the same kind of fact: a decision about the material rather than
/// an adjustment to the picture it produced.
pub fn rebake_film(&mut self) {
if let Some((stock, print)) = self.film().map(|(s, p)| (s.to_string(), p)) {
self.choose_film(Some(&stock), print);