Run the view transform after the detail stage, in a pass of its own
The fused pass stops at "linear working values" when a sharpener, a blur or a repair follows, and the detail passes convolve what it hands on. Until now it handed on the rendering: the base curve, and since the last commit the view transform, ran before the store. So every kernel worked on display-referred values while its comments promised the opposite — D19's second finding. A fused pass composed for a detail stage now stops before the view transform, and carries a second shader, `ComposedShader::view`, composed from the same inputs. It runs the same prologue, for the positions a fragment reads (a film's grain seeds from `source_px`) and the corners it blacks out, takes its colour from the detail stage's result bound where the sample cache would be, and runs the view transform, the output transform and the mask reveal. `render_detailed` dispatches it after the last detail pass, in the same encoder. So no detail pass encodes any more. Every pass writes an intermediate, the last one included, which retires three things that existed only to make the last pass encode: `writes_output` and the runner's second layout, the body-less resolve pass for an active kernel with nothing to draw at this scale, and capture sharpening's pass-through, which now emits no pass at all. An empty chain is a whole render: the view pass reads the fused result directly. The detail stage no longer takes an output space either, so `compose_detail_for` folds into `compose_detail` and the space is named once, on the fused half. The cost is one full-render read and write per frame when a detail stage exists, and a third intermediate for a one-pass chain.
This commit is contained in:
@@ -182,8 +182,7 @@ fn render_to(
|
|||||||
// and the example never has to know which it was handed.
|
// and the example never has to know which it was handed.
|
||||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||||
let scale = graph.render_scale(image.size(), (w, h));
|
let scale = graph.render_scale(image.size(), (w, h));
|
||||||
let detail =
|
let detail = graph.compose_detail(scale.full_size(), scale.render_size());
|
||||||
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
|
|
||||||
let key = graph.invalidation().through(Affects::Colour);
|
let key = graph.invalidation().through(Affects::Colour);
|
||||||
adjust
|
adjust
|
||||||
.render_detailed(image, &shader, w, h, None, &detail, key)
|
.render_detailed(image, &shader, w, h, None, &detail, key)
|
||||||
|
|||||||
+117
-25
@@ -122,6 +122,8 @@ pub struct AdjustPass {
|
|||||||
colour_dispatches: usize,
|
colour_dispatches: usize,
|
||||||
/// Detail dispatches encoded.
|
/// Detail dispatches encoded.
|
||||||
detail_dispatches: usize,
|
detail_dispatches: usize,
|
||||||
|
/// View passes encoded — one per render with a detail stage (D19).
|
||||||
|
view_dispatches: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Target {
|
struct Target {
|
||||||
@@ -653,6 +655,7 @@ impl AdjustPass {
|
|||||||
sample: SampleCache::new(ctx),
|
sample: SampleCache::new(ctx),
|
||||||
colour_dispatches: 0,
|
colour_dispatches: 0,
|
||||||
detail_dispatches: 0,
|
detail_dispatches: 0,
|
||||||
|
view_dispatches: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1048,13 +1051,14 @@ impl AdjustPass {
|
|||||||
/// Render one frame with a neighbourhood stage.
|
/// Render one frame with a neighbourhood stage.
|
||||||
///
|
///
|
||||||
/// `shader` and `detail` must be the two halves of **one** composition —
|
/// `shader` and `detail` must be the two halves of **one** composition —
|
||||||
/// `EditGraph::compose_for` and `EditGraph::compose_detail_for` on the same
|
/// `EditGraph::compose_for` and `EditGraph::compose_detail` on the same
|
||||||
/// graph, at the same output space. The fused pass stops at linear working
|
/// graph. The fused pass stops at linear working values when a detail
|
||||||
/// values when a detail stage exists and the last detail pass performs the
|
/// stage exists, and its view pass ([`ComposedShader::view`]) performs the
|
||||||
/// output transform, so a mismatched pair either encodes twice or not at
|
/// view transform and the output transform after the last detail pass
|
||||||
/// all.
|
/// (D19), so a mismatched pair either encodes twice or not at all.
|
||||||
///
|
///
|
||||||
/// An empty `detail` falls through to [`Self::render_masked`], which is
|
/// An encoded shader with an empty `detail` falls through to
|
||||||
|
/// [`Self::render_masked`], which is
|
||||||
/// the honest thing to do rather than an optimisation: an edit with no
|
/// the honest thing to do rather than an optimisation: an edit with no
|
||||||
/// active sharpening *is* an ordinary edit, and it should cost exactly
|
/// active sharpening *is* an ordinary edit, and it should cost exactly
|
||||||
/// what one costs.
|
/// what one costs.
|
||||||
@@ -1094,17 +1098,25 @@ impl AdjustPass {
|
|||||||
detail: &ComposedDetail,
|
detail: &ComposedDetail,
|
||||||
colour_key: u64,
|
colour_key: u64,
|
||||||
) -> Result<&wgpu::Texture, GpuError> {
|
) -> Result<&wgpu::Texture, GpuError> {
|
||||||
if detail.is_empty() {
|
// An edit with no detail stage encodes in the fused pass, and is an
|
||||||
|
// ordinary render. Decided from the shader rather than from the chain:
|
||||||
|
// an active kernel too fine for this render emits no pass, and its
|
||||||
|
// fused pass has still stopped at linear values for the view pass.
|
||||||
|
if shader.output_mode == OutputMode::Encoded && detail.is_empty() {
|
||||||
return self.render_masked(source, shader, width, height, masks);
|
return self.render_masked(source, shader, width, height, masks);
|
||||||
}
|
}
|
||||||
if shader.output_mode != OutputMode::LinearWorking {
|
let view = match (shader.output_mode, shader.view.as_deref()) {
|
||||||
|
(OutputMode::LinearWorking, Some(view)) => view,
|
||||||
|
_ => {
|
||||||
return Err(GpuError::ShaderCompilation(
|
return Err(GpuError::ShaderCompilation(
|
||||||
"this detail chain expects a fused pass composed to hand on \
|
"this detail chain expects a fused pass composed to hand on \
|
||||||
linear working values, but the shader given encodes its own \
|
linear working values, with its view pass, but the shader \
|
||||||
output; compose both halves from the same graph"
|
given encodes its own output; compose both halves from the \
|
||||||
|
same graph"
|
||||||
.into(),
|
.into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let (width, height) = (width.max(1), height.max(1));
|
let (width, height) = (width.max(1), height.max(1));
|
||||||
self.ensure_target(width, height);
|
self.ensure_target(width, height);
|
||||||
@@ -1117,6 +1129,7 @@ impl AdjustPass {
|
|||||||
// both want `&mut self`, and the second holds its borrow across the
|
// both want `&mut self`, and the second holds its borrow across the
|
||||||
// encode below.
|
// encode below.
|
||||||
self.pipeline(shader)?;
|
self.pipeline(shader)?;
|
||||||
|
self.pipeline(view)?;
|
||||||
let colour_view = self
|
let colour_view = self
|
||||||
.detail
|
.detail
|
||||||
.colour_target(detail.len(), width, height)
|
.colour_target(detail.len(), width, height)
|
||||||
@@ -1207,20 +1220,12 @@ impl AdjustPass {
|
|||||||
self.colour_dispatches += 1;
|
self.colour_dispatches += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// One encoder for the colour pass and every detail pass, submitted
|
// One encoder for the colour pass, every detail pass and the view pass,
|
||||||
// once — the shape `MaskPass::render` established. Submission order is
|
// submitted once — the shape `MaskPass::render` established.
|
||||||
// the whole of the synchronisation: each pass reads what the previous
|
// Submission order is the whole of the synchronisation: each pass
|
||||||
// one wrote, through the same queue.
|
// reads what the previous one wrote, through the same queue.
|
||||||
let target_view = self.targets[self.current]
|
let (ran, result) = match self.detail.encode(&mut enc, detail, width, height) {
|
||||||
.as_ref()
|
Ok(done) => done,
|
||||||
.expect("ensured above")
|
|
||||||
.view
|
|
||||||
.clone();
|
|
||||||
let ran = match self
|
|
||||||
.detail
|
|
||||||
.encode(&mut enc, detail, &target_view, width, height)
|
|
||||||
{
|
|
||||||
Ok(ran) => ran,
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Nothing is submitted, so a cache this frame was to write
|
// Nothing is submitted, so a cache this frame was to write
|
||||||
// holds nothing, and must not be read as though it did.
|
// holds nothing, and must not be read as though it did.
|
||||||
@@ -1230,6 +1235,86 @@ impl AdjustPass {
|
|||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// TRACES: FR-DEV-3j
|
||||||
|
// The view pass: the view transform and the output transform, after
|
||||||
|
// every kernel (D19). Its own uniform block, filled from the source
|
||||||
|
// like the fused pass's — it reads the non-linear flag and the film
|
||||||
|
// settings there — with the sample cache off, because the colour it
|
||||||
|
// reads is the detail stage's result, bound where the cache would be.
|
||||||
|
let view_uniforms = Self::fused_uniforms(source, view);
|
||||||
|
let view_params = self
|
||||||
|
.ctx
|
||||||
|
.device
|
||||||
|
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||||
|
label: Some("adjust-view-params"),
|
||||||
|
contents: bytemuck::cast_slice(&view_uniforms),
|
||||||
|
usage: wgpu::BufferUsages::UNIFORM,
|
||||||
|
});
|
||||||
|
let (_, no_sample_out) = self.sample.views(SampleUse::Direct);
|
||||||
|
let target_view = self.targets[self.current]
|
||||||
|
.as_ref()
|
||||||
|
.expect("ensured above")
|
||||||
|
.view
|
||||||
|
.clone();
|
||||||
|
let view_bind_group = self
|
||||||
|
.ctx
|
||||||
|
.device
|
||||||
|
.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
label: Some("adjust-view-bg"),
|
||||||
|
layout: &self.bind_group_layout,
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 0,
|
||||||
|
resource: wgpu::BindingResource::TextureView(source.view()),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 1,
|
||||||
|
resource: view_params.as_entire_binding(),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 2,
|
||||||
|
resource: wgpu::BindingResource::TextureView(&target_view),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 3,
|
||||||
|
resource: wgpu::BindingResource::TextureView(
|
||||||
|
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),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 6,
|
||||||
|
resource: wgpu::BindingResource::TextureView(&result),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 7,
|
||||||
|
resource: wgpu::BindingResource::TextureView(&no_sample_out),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
{
|
||||||
|
let pipeline = self
|
||||||
|
.cache
|
||||||
|
.get(&view.structure_hash)
|
||||||
|
.expect("compiled above");
|
||||||
|
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||||
|
label: Some("adjust-view-pass"),
|
||||||
|
timestamp_writes: None,
|
||||||
|
});
|
||||||
|
pass.set_pipeline(pipeline);
|
||||||
|
pass.set_bind_group(0, &view_bind_group, &[]);
|
||||||
|
pass.dispatch_workgroups(width.div_ceil(8), height.div_ceil(8), 1);
|
||||||
|
}
|
||||||
|
self.view_dispatches += 1;
|
||||||
|
|
||||||
self.ctx.queue.submit(Some(enc.finish()));
|
self.ctx.queue.submit(Some(enc.finish()));
|
||||||
self.detail_dispatches += ran;
|
self.detail_dispatches += ran;
|
||||||
self.colour_key = Some((key, width, height));
|
self.colour_key = Some((key, width, height));
|
||||||
@@ -1378,6 +1463,13 @@ impl AdjustPass {
|
|||||||
self.detail_dispatches
|
self.detail_dispatches
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3j
|
||||||
|
/// View passes encoded since this pass was created: one for every render
|
||||||
|
/// that had a detail stage, since the view transform follows it.
|
||||||
|
pub fn view_dispatches(&self) -> usize {
|
||||||
|
self.view_dispatches
|
||||||
|
}
|
||||||
|
|
||||||
/// How many linear intermediates have been allocated. For tests: see
|
/// How many linear intermediates have been allocated. For tests: see
|
||||||
/// [`crate::MaskPass::allocations`] for the regression this catches.
|
/// [`crate::MaskPass::allocations`] for the regression this catches.
|
||||||
pub fn detail_allocations(&self) -> usize {
|
pub fn detail_allocations(&self) -> usize {
|
||||||
@@ -2351,7 +2443,7 @@ mod tests {
|
|||||||
// find those operations in neither stage and fail for a reason that is
|
// find those operations in neither stage and fail for a reason that is
|
||||||
// not a defect. Shadows the smaller size deliberately.
|
// not a defect. Shadows the smaller size deliberately.
|
||||||
let (w, h) = g.output_size(512, 512);
|
let (w, h) = g.output_size(512, 512);
|
||||||
let detail = g.compose_detail_for((512, 512), (w, h), dr_types::ColourSpace::Srgb);
|
let detail = g.compose_detail((512, 512), (w, h));
|
||||||
assert!(
|
assert!(
|
||||||
!detail.is_empty(),
|
!detail.is_empty(),
|
||||||
"the detail half composed nothing, so nothing of it was compiled"
|
"the detail half composed nothing, so nothing of it was compiled"
|
||||||
|
|||||||
+32
-46
@@ -43,12 +43,12 @@
|
|||||||
//! dispatch is skipped, and dragging a sharpening slider costs the detail
|
//! dispatch is skipped, and dragging a sharpening slider costs the detail
|
||||||
//! passes alone (FR-DEV-3d).
|
//! passes alone (FR-DEV-3d).
|
||||||
//!
|
//!
|
||||||
//! The remaining passes alternate between slots 1 and 2, and the last one
|
//! The passes alternate between slots 1 and 2, the last one included: since
|
||||||
//! writes the display texture directly rather than an intermediate — so a
|
//! D19 it hands its result to the adjust pass's **view pass**, which performs
|
||||||
//! chain of *N* passes costs *N* dispatches and not *N* + 1, and there is no
|
//! the view transform and the output transform after every kernel, so no
|
||||||
//! resolve pass to pay for. That leaves the allocation at `1 + min(N-1, 2)`
|
//! detail pass writes the display texture. A chain of *N* passes costs *N*
|
||||||
//! textures: one for a single-pass operation, two for a separable blur, three
|
//! dispatches plus that one, and the allocation is `1 + min(N, 2)` textures.
|
||||||
//! however long the chain gets after that.
|
//! An empty chain costs the view pass alone, reading slot 0.
|
||||||
//!
|
//!
|
||||||
//! # The reduced chain, and why a second one was needed
|
//! # The reduced chain, and why a second one was needed
|
||||||
//!
|
//!
|
||||||
@@ -186,10 +186,9 @@ impl Intermediates {
|
|||||||
/// intermediate against a fresh colour result and never be told.
|
/// intermediate against a fresh colour result and never be told.
|
||||||
pub(crate) struct DetailRunner {
|
pub(crate) struct DetailRunner {
|
||||||
ctx: GpuContext,
|
ctx: GpuContext,
|
||||||
/// Layout for a pass writing another linear intermediate.
|
/// Layout for every pass: each writes a linear intermediate, the last
|
||||||
|
/// one included, and the adjust pass's view pass reads the last (D19).
|
||||||
to_linear: Layout,
|
to_linear: Layout,
|
||||||
/// Layout for the last pass, which writes the display texture.
|
|
||||||
to_output: Layout,
|
|
||||||
/// Compiled pipelines by pass structure hash.
|
/// Compiled pipelines by pass structure hash.
|
||||||
cache: HashMap<u64, wgpu::ComputePipeline>,
|
cache: HashMap<u64, wgpu::ComputePipeline>,
|
||||||
pool: Intermediates,
|
pool: Intermediates,
|
||||||
@@ -255,7 +254,6 @@ impl DetailRunner {
|
|||||||
Self {
|
Self {
|
||||||
ctx: ctx.clone(),
|
ctx: ctx.clone(),
|
||||||
to_linear: Layout::new(ctx, INTERMEDIATE_FORMAT, "detail-linear"),
|
to_linear: Layout::new(ctx, INTERMEDIATE_FORMAT, "detail-linear"),
|
||||||
to_output: Layout::new(ctx, crate::AdjustPass::FORMAT, "detail-output"),
|
|
||||||
cache: HashMap::new(),
|
cache: HashMap::new(),
|
||||||
pool: Intermediates::new(),
|
pool: Intermediates::new(),
|
||||||
reduced: Intermediates::new(),
|
reduced: Intermediates::new(),
|
||||||
@@ -274,27 +272,30 @@ impl DetailRunner {
|
|||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
) -> &wgpu::TextureView {
|
) -> &wgpu::TextureView {
|
||||||
// One for the colour pass's result, then one per hand-off between
|
// One for the colour pass's result, then one per pass, capped at two
|
||||||
// detail passes, capped at two because a ping-pong needs no more: the
|
// because a ping-pong needs no more. The last pass writes an
|
||||||
// last pass writes the display texture rather than an intermediate.
|
// intermediate like the others since D19 — the view pass reads it —
|
||||||
let needed = 1 + passes.saturating_sub(1).min(2);
|
// so a one-pass chain needs two slots where it used to need one.
|
||||||
|
let needed = 1 + passes.min(2);
|
||||||
self.pool.ensure(&self.ctx, needed, width, height);
|
self.pool.ensure(&self.ctx, needed, width, height);
|
||||||
&self.pool.slots[0].view
|
&self.pool.slots[0].view
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Encode every pass of `chain`, the last one writing `output`.
|
/// Encode every pass of `chain`, and return how many ran and the view
|
||||||
|
/// the last one wrote — slot 0, the colour pass's own result, for an
|
||||||
|
/// empty chain.
|
||||||
///
|
///
|
||||||
/// The caller must already have run the fused colour pass into
|
/// The caller must already have run the fused colour pass into
|
||||||
/// [`Self::colour_target`] — or established that a previous frame's is
|
/// [`Self::colour_target`] — or established that a previous frame's is
|
||||||
/// still valid, which is the whole point of keeping slot 0.
|
/// still valid, which is the whole point of keeping slot 0 — and reads the
|
||||||
|
/// returned view in the view pass that finishes the render (D19).
|
||||||
pub(crate) fn encode(
|
pub(crate) fn encode(
|
||||||
&mut self,
|
&mut self,
|
||||||
encoder: &mut wgpu::CommandEncoder,
|
encoder: &mut wgpu::CommandEncoder,
|
||||||
chain: &ComposedDetail,
|
chain: &ComposedDetail,
|
||||||
output: &wgpu::TextureView,
|
|
||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
) -> Result<usize, GpuError> {
|
) -> Result<(usize, wgpu::TextureView), GpuError> {
|
||||||
for pass in &chain.passes {
|
for pass in &chain.passes {
|
||||||
self.compile(pass)?;
|
self.compile(pass)?;
|
||||||
}
|
}
|
||||||
@@ -340,17 +341,15 @@ impl DetailRunner {
|
|||||||
for pass in chain.passes.iter() {
|
for pass in chain.passes.iter() {
|
||||||
let scaled = pass.output_scale > 1;
|
let scaled = pass.output_scale > 1;
|
||||||
|
|
||||||
// The last pass carries the output transform into the display
|
// The last pass hands the view pass its input, which is read at
|
||||||
// texture, which is the render size by definition. A scaled pass
|
// the render size by definition. A scaled pass there would leave
|
||||||
// there would bind a shader dispatching over a quarter-size grid
|
// the result in the reduced chain and the view pass would read the
|
||||||
// to a full-size target and write a quarter of the picture — a
|
// full-size slot before it — a wrong image rather than a
|
||||||
// wrong image rather than a validation failure, so it is caught
|
// validation failure, so it is caught here and named.
|
||||||
// here and named.
|
if scaled && std::ptr::eq(pass, chain.passes.last().expect("iterating")) {
|
||||||
if scaled && pass.writes_output {
|
|
||||||
return Err(GpuError::ShaderCompilation(format!(
|
return Err(GpuError::ShaderCompilation(format!(
|
||||||
"detail pass {} declares output_scale {} and is last in \
|
"detail pass {} declares output_scale {} and is last in \
|
||||||
the chain; the output transform is written at the render \
|
the chain; the view pass reads the render size",
|
||||||
size",
|
|
||||||
pass.label, pass.output_scale
|
pass.label, pass.output_scale
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
@@ -365,17 +364,14 @@ impl DetailRunner {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Read what the previous pass in *this pass's own chain* wrote;
|
// Read what the previous pass in *this pass's own chain* wrote;
|
||||||
// write the next slot of it, or the display texture if this is the
|
// write the next slot of it. Alternating slots is what stops a pass reading the
|
||||||
// last pass. Alternating slots is what stops a pass reading the
|
|
||||||
// texture it is writing — on a compute pass that is not an error
|
// texture it is writing — on a compute pass that is not an error
|
||||||
// the driver reports, merely a picture that depends on scheduling.
|
// the driver reports, merely a picture that depends on scheduling.
|
||||||
let source = match (scaled, carried) {
|
let source = match (scaled, carried) {
|
||||||
(true, Some(slot)) => &self.reduced.slots[slot].view,
|
(true, Some(slot)) => &self.reduced.slots[slot].view,
|
||||||
_ => &self.pool.slots[full].view,
|
_ => &self.pool.slots[full].view,
|
||||||
};
|
};
|
||||||
let destination = if pass.writes_output {
|
let destination = if scaled {
|
||||||
output
|
|
||||||
} else if scaled {
|
|
||||||
&self.reduced.slots[reduced_writes % 2].view
|
&self.reduced.slots[reduced_writes % 2].view
|
||||||
} else {
|
} else {
|
||||||
&self.pool.slots[1 + (full_writes % 2)].view
|
&self.pool.slots[1 + (full_writes % 2)].view
|
||||||
@@ -387,11 +383,7 @@ impl DetailRunner {
|
|||||||
Some(slot) => &self.reduced.slots[slot].view,
|
Some(slot) => &self.reduced.slots[slot].view,
|
||||||
None => &self.no_reduced,
|
None => &self.no_reduced,
|
||||||
};
|
};
|
||||||
let layout = if pass.writes_output {
|
let layout = &self.to_linear;
|
||||||
&self.to_output
|
|
||||||
} else {
|
|
||||||
&self.to_linear
|
|
||||||
};
|
|
||||||
|
|
||||||
let params = self
|
let params = self
|
||||||
.ctx
|
.ctx
|
||||||
@@ -464,9 +456,7 @@ impl DetailRunner {
|
|||||||
compute.dispatch_workgroups(dispatch_w.div_ceil(8), dispatch_h.div_ceil(8), 1);
|
compute.dispatch_workgroups(dispatch_w.div_ceil(8), dispatch_h.div_ceil(8), 1);
|
||||||
drop(compute);
|
drop(compute);
|
||||||
|
|
||||||
if pass.writes_output {
|
if scaled {
|
||||||
// Nothing downstream to hand anything to.
|
|
||||||
} else if scaled {
|
|
||||||
carried = Some(reduced_writes % 2);
|
carried = Some(reduced_writes % 2);
|
||||||
reduced_writes += 1;
|
reduced_writes += 1;
|
||||||
} else {
|
} else {
|
||||||
@@ -480,7 +470,7 @@ impl DetailRunner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(chain.passes.len())
|
Ok((chain.passes.len(), self.pool.slots[full].view.clone()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compile one pass, or leave the cached pipeline in place.
|
/// Compile one pass, or leave the cached pipeline in place.
|
||||||
@@ -508,11 +498,7 @@ impl DetailRunner {
|
|||||||
source: wgpu::ShaderSource::Wgsl(pass.source.as_str().into()),
|
source: wgpu::ShaderSource::Wgsl(pass.source.as_str().into()),
|
||||||
});
|
});
|
||||||
|
|
||||||
let layout = if pass.writes_output {
|
let layout = &self.to_linear;
|
||||||
&self.to_output
|
|
||||||
} else {
|
|
||||||
&self.to_linear
|
|
||||||
};
|
|
||||||
|
|
||||||
let pipeline = self
|
let pipeline = self
|
||||||
.ctx
|
.ctx
|
||||||
|
|||||||
@@ -87,8 +87,7 @@ fn sharpened(amount: f32, radius: f32, threshold: f32) -> EditGraph {
|
|||||||
fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec<u8> {
|
fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec<u8> {
|
||||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||||
let scale = graph.render_scale(source.size(), (out, out));
|
let scale = graph.render_scale(source.size(), (out, out));
|
||||||
let detail =
|
let detail = graph.compose_detail(scale.full_size(), scale.render_size());
|
||||||
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
|
|
||||||
let key = graph.invalidation().through(Affects::Colour);
|
let key = graph.invalidation().through(Affects::Colour);
|
||||||
pass.render_detailed(source, &shader, out, out, None, &detail, key)
|
pass.render_detailed(source, &shader, out, out, None, &detail, key)
|
||||||
.expect("render");
|
.expect("render");
|
||||||
@@ -413,7 +412,7 @@ fn dragging_the_amount_recompiles_nothing_and_reallocates_nothing() {
|
|||||||
let pipelines = pass.cached_detail_pipelines();
|
let pipelines = pass.cached_detail_pipelines();
|
||||||
let allocations = pass.detail_allocations();
|
let allocations = pass.detail_allocations();
|
||||||
assert_eq!(pipelines, 2, "one per axis of the separable mask");
|
assert_eq!(pipelines, 2, "one per axis of the separable mask");
|
||||||
assert_eq!(allocations, 2, "the colour result, and one hand-off");
|
assert_eq!(allocations, 3, "the colour result, and the ping-pong pair");
|
||||||
assert_eq!(pass.detail_dispatches(), 2);
|
assert_eq!(pass.detail_dispatches(), 2);
|
||||||
assert_eq!(pass.colour_dispatches(), 1);
|
assert_eq!(pass.colour_dispatches(), 1);
|
||||||
|
|
||||||
@@ -453,13 +452,12 @@ fn dragging_the_amount_recompiles_nothing_and_reallocates_nothing() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_render_too_coarse_for_the_radius_still_reaches_the_screen() {
|
fn a_render_too_coarse_for_the_radius_still_reaches_the_screen() {
|
||||||
// The failure mode that the pass-through exists to prevent, proved on a
|
// Proved on a device rather than argued about. With the radius finer than
|
||||||
// device rather than argued about. With the radius finer than a render
|
// a render pixel the operation declines to sharpen — but it is still
|
||||||
// pixel the operation declines to sharpen — but it is still active, so the
|
// active, so the fused pass has already been composed to hand on
|
||||||
// fused pass has already been composed to hand on unclipped linear values,
|
// unclipped linear values, and something must still perform the output
|
||||||
// and something must still perform the output transform. An empty chain
|
// transform. Since D19 that is the view pass, whatever the chain holds:
|
||||||
// here would not be a soft preview: it would be a hard error out of
|
// the chain is empty and the frame is still whole.
|
||||||
// `render_detailed`, on the most ordinary develop view there is.
|
|
||||||
let Some(ctx) = ctx() else { return };
|
let Some(ctx) = ctx() else { return };
|
||||||
const SOURCE: u32 = 128;
|
const SOURCE: u32 = 128;
|
||||||
const RENDER: u32 = 32; // a quarter scale, as a fit view of a large frame
|
const RENDER: u32 = 32; // a quarter scale, as a fit view of a large frame
|
||||||
@@ -471,7 +469,16 @@ fn a_render_too_coarse_for_the_radius_still_reaches_the_screen() {
|
|||||||
|
|
||||||
let mut pass = AdjustPass::new(&ctx);
|
let mut pass = AdjustPass::new(&ctx);
|
||||||
let sharp = render(&mut pass, &graph, &source, RENDER);
|
let sharp = render(&mut pass, &graph, &source, RENDER);
|
||||||
assert_eq!(pass.detail_dispatches(), 1, "one pass, and it only encodes");
|
assert_eq!(
|
||||||
|
pass.detail_dispatches(),
|
||||||
|
0,
|
||||||
|
"nothing to sharpen at this scale"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
pass.view_dispatches(),
|
||||||
|
1,
|
||||||
|
"and the view pass finishes the frame"
|
||||||
|
);
|
||||||
|
|
||||||
// And what reaches the screen is the unsharpened picture, not a black
|
// And what reaches the screen is the unsharpened picture, not a black
|
||||||
// frame, a linear one, or a guess.
|
// frame, a linear one, or a guess.
|
||||||
|
|||||||
@@ -38,15 +38,17 @@ fn grey(ctx: &GpuContext) -> DemosaicedImage {
|
|||||||
DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload")
|
DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A pass that sums the instance list into the red channel and writes the
|
/// A pass that sums the instance list into the red channel and writes a
|
||||||
/// output. Deliberately trivial: the value on screen is then a direct readout
|
/// linear intermediate, which the view pass then encodes (D19). Deliberately
|
||||||
/// of what arrived in the buffer.
|
/// trivial: the value on screen is then a direct readout of what arrived in the
|
||||||
|
/// buffer, through the sRGB encode — the source is an 8-bit upload, so the
|
||||||
|
/// view transform is skipped for it and the encode is the only thing between.
|
||||||
fn summing_pass(storage: Vec<[f32; 4]>, structure: u64) -> ComposedDetailPass {
|
fn summing_pass(storage: Vec<[f32; 4]>, structure: u64) -> ComposedDetailPass {
|
||||||
let source = "
|
let source = "
|
||||||
@group(0) @binding(0) var source: texture_2d<f32>;
|
@group(0) @binding(0) var source: texture_2d<f32>;
|
||||||
struct Params { detail_base: vec4<f32> }
|
struct Params { detail_base: vec4<f32> }
|
||||||
@group(0) @binding(1) var<uniform> u: Params;
|
@group(0) @binding(1) var<uniform> u: Params;
|
||||||
@group(0) @binding(2) var output: texture_storage_2d<rgba8unorm, write>;
|
@group(0) @binding(2) var output: texture_storage_2d<rgba16float, write>;
|
||||||
@group(0) @binding(3) var<storage, read> instances: array<vec4<f32>>;
|
@group(0) @binding(3) var<storage, read> instances: array<vec4<f32>>;
|
||||||
|
|
||||||
@compute @workgroup_size(8, 8, 1)
|
@compute @workgroup_size(8, 8, 1)
|
||||||
@@ -61,7 +63,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|||||||
for (var i = 0u; i < n; i = i + 1u) {
|
for (var i = 0u; i < n; i = i + 1u) {
|
||||||
total = total + instances[i].x * f32(i + 1u);
|
total = total + instances[i].x * f32(i + 1u);
|
||||||
}
|
}
|
||||||
textureStore(output, vec2<i32>(gid.xy), vec4<f32>(total, f32(n) / 255.0, 0.0, 1.0));
|
textureStore(output, vec2<i32>(gid.xy), vec4<f32>(total, f32(n) * 0.1, 0.0, 1.0));
|
||||||
}
|
}
|
||||||
"
|
"
|
||||||
.to_string();
|
.to_string();
|
||||||
@@ -73,13 +75,17 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|||||||
uniforms: vec![SIZE as f32, SIZE as f32, 1.0, 0.0],
|
uniforms: vec![SIZE as f32, SIZE as f32, 1.0, 0.0],
|
||||||
storage,
|
storage,
|
||||||
radius: 0,
|
radius: 0,
|
||||||
writes_output: true,
|
|
||||||
// Any distinct number: the hash is a cache key, and these tests are
|
// Any distinct number: the hash is a cache key, and these tests are
|
||||||
// what decide whether two chains share a pipeline.
|
// what decide whether two chains share a pipeline.
|
||||||
structure_hash: structure,
|
structure_hash: structure,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A linear value as the view pass leaves it in the 8-bit output.
|
||||||
|
fn encoded(linear: f32) -> u8 {
|
||||||
|
(dr_types::Transfer::Srgb.encode(linear) * 255.0).round() as u8
|
||||||
|
}
|
||||||
|
|
||||||
fn render(pass: &mut AdjustPass, source: &DemosaicedImage, chain: &ComposedDetail) -> Vec<u8> {
|
fn render(pass: &mut AdjustPass, source: &DemosaicedImage, chain: &ComposedDetail) -> Vec<u8> {
|
||||||
// The fused half has to be composed knowing a detail stage follows it, or
|
// The fused half has to be composed knowing a detail stage follows it, or
|
||||||
// it encodes its own output and the chain would quantise twice — a mismatch
|
// it encodes its own output and the chain would quantise twice — a mismatch
|
||||||
@@ -118,12 +124,15 @@ fn a_pass_reads_the_list_it_was_given() {
|
|||||||
let pixels = render(&mut pass, &source, &chain);
|
let pixels = render(&mut pass, &source, &chain);
|
||||||
let (red, green) = (pixels[0], pixels[1]);
|
let (red, green) = (pixels[0], pixels[1]);
|
||||||
|
|
||||||
// 0.05·1 + 0.1·2 = 0.25, written straight to an rgba8 target.
|
// 0.05·1 + 0.1·2 = 0.25.
|
||||||
assert!(
|
assert!(
|
||||||
red.abs_diff((0.25 * 255.0) as u8) <= 1,
|
red.abs_diff(encoded(0.25)) <= 1,
|
||||||
"the shader summed {red}, not the list it was handed"
|
"the shader summed {red}, not the list it was handed"
|
||||||
);
|
);
|
||||||
assert_eq!(green, 2, "arrayLength saw both entries");
|
assert!(
|
||||||
|
green.abs_diff(encoded(0.2)) <= 1,
|
||||||
|
"arrayLength saw both entries"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A convolution declares no list and must still run: it is bound to the
|
/// A convolution declares no list and must still run: it is bound to the
|
||||||
@@ -142,7 +151,10 @@ fn a_pass_with_no_list_still_runs() {
|
|||||||
|
|
||||||
let pixels = render(&mut pass, &source, &chain);
|
let pixels = render(&mut pass, &source, &chain);
|
||||||
assert_eq!(pixels[0], 0, "the placeholder is zeroed");
|
assert_eq!(pixels[0], 0, "the placeholder is zeroed");
|
||||||
assert_eq!(pixels[1], 1, "and is exactly one element long");
|
assert!(
|
||||||
|
pixels[1].abs_diff(encoded(0.1)) <= 1,
|
||||||
|
"and is exactly one element long"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The property that makes placing the tenth spot as cheap as moving a slider:
|
/// The property that makes placing the tenth spot as cheap as moving a slider:
|
||||||
|
|||||||
@@ -101,8 +101,7 @@ fn render(
|
|||||||
let _ = ctx;
|
let _ = ctx;
|
||||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||||
let scale = graph.render_scale(source.size(), (out, out));
|
let scale = graph.render_scale(source.size(), (out, out));
|
||||||
let detail =
|
let detail = graph.compose_detail(scale.full_size(), scale.render_size());
|
||||||
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
|
|
||||||
let key = graph.invalidation().through(Affects::Colour);
|
let key = graph.invalidation().through(Affects::Colour);
|
||||||
pass.render_detailed(source, &shader, out, out, None, &detail, key)
|
pass.render_detailed(source, &shader, out, out, None, &detail, key)
|
||||||
.expect("render");
|
.expect("render");
|
||||||
@@ -301,7 +300,7 @@ fn dragging_a_slider_recompiles_nothing_and_reallocates_nothing() {
|
|||||||
let pipelines = pass.cached_detail_pipelines();
|
let pipelines = pass.cached_detail_pipelines();
|
||||||
let allocations = pass.detail_allocations();
|
let allocations = pass.detail_allocations();
|
||||||
assert_eq!(pipelines, 2, "one per pass of the separable blur");
|
assert_eq!(pipelines, 2, "one per pass of the separable blur");
|
||||||
assert_eq!(allocations, 2, "the colour result, and one hand-off");
|
assert_eq!(allocations, 3, "the colour result, and the ping-pong pair");
|
||||||
|
|
||||||
for radius in [0.06, 0.07, 0.08, 0.09] {
|
for radius in [0.06, 0.07, 0.08, 0.09] {
|
||||||
graph.set_param(PROBE, RADIUS, radius);
|
graph.set_param(PROBE, RADIUS, radius);
|
||||||
@@ -403,8 +402,7 @@ fn an_empty_chain_falls_through_to_the_ordinary_render() {
|
|||||||
|
|
||||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||||
let scale = graph.render_scale((SIZE, SIZE), (SIZE, SIZE));
|
let scale = graph.render_scale((SIZE, SIZE), (SIZE, SIZE));
|
||||||
let detail =
|
let detail = graph.compose_detail(scale.full_size(), scale.render_size());
|
||||||
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
|
|
||||||
assert!(detail.is_empty());
|
assert!(detail.is_empty());
|
||||||
|
|
||||||
pass.render_detailed(&source, &shader, SIZE, SIZE, None, &detail, 0)
|
pass.render_detailed(&source, &shader, SIZE, SIZE, None, &detail, 0)
|
||||||
|
|||||||
@@ -109,8 +109,7 @@ fn row_rgb(pixels: &[u8], size: u32, y: u32) -> Vec<[u8; 3]> {
|
|||||||
fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec<u8> {
|
fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec<u8> {
|
||||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||||
let scale = graph.render_scale(source.size(), (out, out));
|
let scale = graph.render_scale(source.size(), (out, out));
|
||||||
let detail =
|
let detail = graph.compose_detail(scale.full_size(), scale.render_size());
|
||||||
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
|
|
||||||
let key = graph.invalidation().through(Affects::Colour);
|
let key = graph.invalidation().through(Affects::Colour);
|
||||||
pass.render_detailed(source, &shader, out, out, None, &detail, key)
|
pass.render_detailed(source, &shader, out, out, None, &detail, key)
|
||||||
.expect("render");
|
.expect("render");
|
||||||
@@ -683,28 +682,21 @@ fn texture_contributes_nothing_where_its_scale_does_not_exist() {
|
|||||||
// `render_masked`, and was rejected for handing a linear-working shader
|
// `render_masked`, and was rejected for handing a linear-working shader
|
||||||
// to the plain path — so texture alone on a thumbnail did not render.
|
// to the plain path — so texture alone on a thumbnail did not render.
|
||||||
//
|
//
|
||||||
// The seam was closed where that note said it would have to be, at the
|
// The seam was closed at the composition boundary, and closed again,
|
||||||
// composition boundary: `compose_detail` now emits a bodyless
|
// more simply, by D19: no detail pass encodes any more, the fused pass's
|
||||||
// `detail/resolve` pass in exactly this case, which reads only the pixel
|
// view pass performs the output transform whatever the chain holds, and
|
||||||
// it writes and performs the output transform the fused pass declined to
|
// so the empty chain is a whole render. That is the honest description of
|
||||||
// do. So the chain is no longer empty — it carries precisely the one pass
|
// "a two-pixel surface structure is not present in a 128-pixel
|
||||||
// that finishes the render and no kernel at all, which is the honest
|
// rendering".
|
||||||
// description of "a two-pixel surface structure is not present in a
|
|
||||||
// 128-pixel rendering".
|
|
||||||
let scale = graph.render_scale(source.size(), (128, 128));
|
let scale = graph.render_scale(source.size(), (128, 128));
|
||||||
let composed =
|
let composed = graph.compose_detail(scale.full_size(), scale.render_size());
|
||||||
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
|
assert!(
|
||||||
assert_eq!(
|
composed.is_empty(),
|
||||||
composed.len(),
|
|
||||||
1,
|
|
||||||
"the chain must carry the resolve pass and nothing else"
|
|
||||||
);
|
|
||||||
assert_eq!(composed.passes[0].label, "detail/resolve");
|
|
||||||
assert_eq!(
|
|
||||||
composed.radius(),
|
|
||||||
0,
|
|
||||||
"texture claimed a kernel it cannot draw"
|
"texture claimed a kernel it cannot draw"
|
||||||
);
|
);
|
||||||
|
let mut pass = AdjustPass::new(&ctx);
|
||||||
|
render(&mut pass, &graph, &source, 128);
|
||||||
|
assert_eq!(pass.view_dispatches(), 1, "the view pass still finishes it");
|
||||||
|
|
||||||
// With clarity on as well the edit is renderable again, and the dispatch
|
// With clarity on as well the edit is renderable again, and the dispatch
|
||||||
// count says what the assertion above says: two passes, not four. Texture
|
// count says what the assertion above says: two passes, not four. Texture
|
||||||
|
|||||||
@@ -73,8 +73,7 @@ fn render_at(
|
|||||||
scale: RenderScale,
|
scale: RenderScale,
|
||||||
) -> Vec<u8> {
|
) -> Vec<u8> {
|
||||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||||
let detail =
|
let detail = graph.compose_detail(scale.full_size(), scale.render_size());
|
||||||
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
|
|
||||||
let key = graph.invalidation().through(Affects::Colour);
|
let key = graph.invalidation().through(Affects::Colour);
|
||||||
pass.render_detailed(source, &shader, out.0, out.1, None, &detail, key)
|
pass.render_detailed(source, &shader, out.0, out.1, None, &detail, key)
|
||||||
.expect("render");
|
.expect("render");
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, ou
|
|||||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||||
let (w, h) = graph.output_size(source.size().0, source.size().1);
|
let (w, h) = graph.output_size(source.size().0, source.size().1);
|
||||||
let (w, h) = (w.min(out), h.min(out));
|
let (w, h) = (w.min(out), h.min(out));
|
||||||
let detail = graph.compose_detail_for(source.size(), (w, h), ColourSpace::Srgb);
|
let detail = graph.compose_detail(source.size(), (w, h));
|
||||||
let key = graph.invalidation().through(Affects::Colour);
|
let key = graph.invalidation().through(Affects::Colour);
|
||||||
pass.render_detailed(source, &shader, w, h, None, &detail, key)
|
pass.render_detailed(source, &shader, w, h, None, &detail, key)
|
||||||
.expect("render");
|
.expect("render");
|
||||||
|
|||||||
@@ -127,3 +127,52 @@ fn the_rendering_is_monotone_through_the_whole_range() {
|
|||||||
last = got;
|
last = got;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Render `graph` over a flat frame through `render_detailed`, the path every
|
||||||
|
/// frontend takes, and return the centre pixel's red.
|
||||||
|
fn rendered_detailed(ctx: &GpuContext, level: u16, graph: &EditGraph) -> (u8, AdjustPass) {
|
||||||
|
let source = Demosaicer::new(ctx)
|
||||||
|
.expect("demosaicer")
|
||||||
|
.run(&flat_raw(level))
|
||||||
|
.expect("demosaic");
|
||||||
|
let shader = graph.compose_for(dr_types::ColourSpace::Srgb);
|
||||||
|
let detail = graph.compose_detail(source.size(), (SIZE, SIZE));
|
||||||
|
let key = graph.invalidation().through(dr_pipeline::Affects::Colour);
|
||||||
|
let mut adjust = AdjustPass::new(ctx);
|
||||||
|
adjust
|
||||||
|
.render_detailed(&source, &shader, SIZE, SIZE, None, &detail, key)
|
||||||
|
.expect("render");
|
||||||
|
let (pixels, _, _) = adjust.export_pixels().expect("readback");
|
||||||
|
let centre = ((SIZE / 2) * SIZE + SIZE / 2) * 4;
|
||||||
|
(pixels[centre as usize], adjust)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_detail_stage_renders_through_the_view_pass_unchanged() {
|
||||||
|
// TRACES: FR-DEV-3j | FR-DEV-2
|
||||||
|
// With a detail stage the view transform is a dispatch of its own after
|
||||||
|
// it (D19). Sharpening a flat field changes nothing, so the same frame
|
||||||
|
// with and without it must render the same: the view pass read the detail
|
||||||
|
// stage's result, applied the view transform once, and encoded once.
|
||||||
|
let Some(ctx) = ctx() else {
|
||||||
|
eprintln!("skipping: no GPU adapter");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let plain = rendered(&ctx, 8_520, &EditGraph::default_chain());
|
||||||
|
|
||||||
|
let mut sharpened = EditGraph::default_chain();
|
||||||
|
let id = dr_pipeline::ops::capture_sharpen::ID;
|
||||||
|
sharpened.set_param(id, dr_pipeline::ops::capture_sharpen::AMOUNT, 100.0);
|
||||||
|
sharpened.set_param(id, dr_pipeline::ops::capture_sharpen::RADIUS, 1.0);
|
||||||
|
let (detailed, pass) = rendered_detailed(&ctx, 8_520, &sharpened);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
pass.detail_dispatches() > 0,
|
||||||
|
"the premise: a detail stage ran"
|
||||||
|
);
|
||||||
|
assert_eq!(pass.view_dispatches(), 1);
|
||||||
|
assert!(
|
||||||
|
detailed.abs_diff(plain) <= 1,
|
||||||
|
"with a detail stage {detailed}, without {plain}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+101
-237
@@ -122,8 +122,6 @@
|
|||||||
|
|
||||||
use std::fmt::Write as _;
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
use dr_types::ColourSpace;
|
|
||||||
|
|
||||||
use crate::operation::{Helper, Operation, Uniform};
|
use crate::operation::{Helper, Operation, Uniform};
|
||||||
|
|
||||||
/// Floats the generated detail uniform block always carries, before an
|
/// Floats the generated detail uniform block always carries, before an
|
||||||
@@ -491,15 +489,6 @@ pub struct ComposedDetailPass {
|
|||||||
pub radius: u32,
|
pub radius: u32,
|
||||||
/// See [`DetailPass::output_scale`].
|
/// See [`DetailPass::output_scale`].
|
||||||
pub output_scale: u32,
|
pub output_scale: u32,
|
||||||
/// Whether this pass writes the display/export texture rather than another
|
|
||||||
/// linear intermediate.
|
|
||||||
///
|
|
||||||
/// True for exactly the last pass in the chain, which carries the output
|
|
||||||
/// transform — the primaries conversion, the clip and the encode that the
|
|
||||||
/// fused pass performs when there is no detail stage at all. Folding them
|
|
||||||
/// into the last pass rather than adding a resolve dispatch keeps the cost
|
|
||||||
/// of the stage at one dispatch per pass, not one plus one.
|
|
||||||
pub writes_output: bool,
|
|
||||||
/// Identifies this pass's *structure*, for the pipeline cache. Covers the
|
/// Identifies this pass's *structure*, for the pipeline cache. Covers the
|
||||||
/// generated source, not the uniform values — so moving a slider uploads a
|
/// generated source, not the uniform values — so moving a slider uploads a
|
||||||
/// buffer and reuses the compiled pipeline, exactly as the fused pass does.
|
/// buffer and reuses the compiled pipeline, exactly as the fused pass does.
|
||||||
@@ -547,10 +536,11 @@ impl ComposedDetail {
|
|||||||
/// an edit with no sharpening produces an empty chain and `dr-gpu` runs the
|
/// an edit with no sharpening produces an empty chain and `dr-gpu` runs the
|
||||||
/// single dispatch it always did.
|
/// single dispatch it always did.
|
||||||
///
|
///
|
||||||
/// `output` is the space the **last** pass encodes into, and it is a parameter
|
/// No pass encodes. Every pass writes a linear intermediate, the last one
|
||||||
/// for the same reason it is a parameter to [`crate::compose_with_framing`]: a
|
/// included, and the fused pass's view pass ([`crate::ComposedShader::view`])
|
||||||
/// screen render and a Display P3 export are the same edit and different
|
/// reads the last and performs the view transform and the output transform
|
||||||
/// shaders, and neither is more authoritative than the other.
|
/// (D19). So the output space is not a parameter here: a screen render and a
|
||||||
|
/// Display P3 export share one detail stage.
|
||||||
///
|
///
|
||||||
/// # The generated uniform block
|
/// # The generated uniform block
|
||||||
///
|
///
|
||||||
@@ -562,12 +552,8 @@ impl ComposedDetail {
|
|||||||
/// is there because a two-pass operation emitting one body for both directions
|
/// is there because a two-pass operation emitting one body for both directions
|
||||||
/// is a reasonable thing to want, and would otherwise need a uniform of its
|
/// is a reasonable thing to want, and would otherwise need a uniform of its
|
||||||
/// own purely to say which half it is in.
|
/// own purely to say which half it is in.
|
||||||
pub fn compose_detail(
|
pub fn compose_detail(ops: &[Box<dyn Operation>], scale: RenderScale) -> ComposedDetail {
|
||||||
ops: &[Box<dyn Operation>],
|
compose_detail_with(ops, &[], scale)
|
||||||
scale: RenderScale,
|
|
||||||
output: ColourSpace,
|
|
||||||
) -> ComposedDetail {
|
|
||||||
compose_detail_with(ops, &[], scale, output)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TRACES: FR-DEV-8
|
/// TRACES: FR-DEV-8
|
||||||
@@ -592,7 +578,6 @@ pub fn compose_detail_with(
|
|||||||
ops: &[Box<dyn Operation>],
|
ops: &[Box<dyn Operation>],
|
||||||
spots: &[DetailPass],
|
spots: &[DetailPass],
|
||||||
scale: RenderScale,
|
scale: RenderScale,
|
||||||
output: ColourSpace,
|
|
||||||
) -> ComposedDetail {
|
) -> ComposedDetail {
|
||||||
// Every pass of every active detail operation, flattened, carrying the
|
// Every pass of every active detail operation, flattened, carrying the
|
||||||
// operation it came from for the uniform prefix and the helper set.
|
// operation it came from for the uniform prefix and the helper set.
|
||||||
@@ -624,45 +609,13 @@ pub fn compose_detail_with(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// An active detail operation that emitted nothing at this scale.
|
// An active detail operation may emit nothing at this scale — an
|
||||||
//
|
// acutance operation on a heavy proxy, whose one-source-pixel radius is a
|
||||||
// Legal, and the honest answer for an acutance operation on a heavy proxy
|
// third of a render pixel (see [`RenderScale`]). The chain is then empty
|
||||||
// — a one-source-pixel radius is a third of a render pixel there and no
|
// while the fused pass has stopped at linear working values, and that is
|
||||||
// kernel represents a third of a pixel (see [`RenderScale`]). But it opens
|
// fine: the fused pass's view pass reads the fused result directly and
|
||||||
// a hole between the two halves of the composition: [`compose_full`]
|
// performs the output transform. Before D19 the last detail pass encoded,
|
||||||
// decides to hand on linear working values from the *operations*, which it
|
// and this case needed a body-less resolve pass to do it.
|
||||||
// must, having no scale to consult, so the fused pass has already stopped
|
|
||||||
// short of the output transform. Returning an empty chain here would leave
|
|
||||||
// that transform undone and bind an `rgba16float` shader to an
|
|
||||||
// `rgba8unorm` target, which surfaces as a wgpu validation failure a long
|
|
||||||
// way from the cause.
|
|
||||||
//
|
|
||||||
// So the chain is never empty when the fused pass is expecting one: a
|
|
||||||
// single pass with no body, which reads the intermediate and performs the
|
|
||||||
// output transform the fused pass skipped. One dispatch, in the uncommon
|
|
||||||
// case where a photographer has a kernel switched on at a scale that
|
|
||||||
// cannot draw it — against the alternative of the preview failing outright
|
|
||||||
// or `compose_full` growing a resolution argument it has no other use for.
|
|
||||||
if planned.is_empty() && ops.iter().any(|o| o.is_active() && o.detail().is_some()) {
|
|
||||||
return ComposedDetail {
|
|
||||||
passes: vec![compose_one(
|
|
||||||
RESOLVE_ID,
|
|
||||||
&[],
|
|
||||||
&DetailPass {
|
|
||||||
output_scale: 1,
|
|
||||||
label: "resolve",
|
|
||||||
radius: 0,
|
|
||||||
wgsl: String::new(),
|
|
||||||
uniforms: Vec::new(),
|
|
||||||
storage: Vec::new(),
|
|
||||||
},
|
|
||||||
0,
|
|
||||||
scale,
|
|
||||||
output,
|
|
||||||
true,
|
|
||||||
)],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// TRACES: NFR-P5
|
// TRACES: NFR-P5
|
||||||
// A pass whose body is empty changes nothing but where the pixels are: it
|
// A pass whose body is empty changes nothing but where the pixels are: it
|
||||||
@@ -674,12 +627,10 @@ pub fn compose_detail_with(
|
|||||||
// 2560 x 1600 frame on the reference laptop with its clocks held down.
|
// 2560 x 1600 frame on the reference laptop with its clocks held down.
|
||||||
//
|
//
|
||||||
// Dropped here, where the chain is still a list, and only where dropping
|
// Dropped here, where the chain is still a list, and only where dropping
|
||||||
// it is exact:
|
// it is exact. The last pass is no exception since D19: it writes an
|
||||||
|
// `rgba16float` intermediate like the others, and the view pass reads
|
||||||
|
// whichever one the chain last wrote.
|
||||||
//
|
//
|
||||||
// - **Not the last pass.** The last pass performs the output transform on
|
|
||||||
// what it read from an `rgba16float` intermediate. Moving that transform
|
|
||||||
// onto the pass before would apply it to that pass's `f32` result
|
|
||||||
// instead, which is a different rounding of the same picture.
|
|
||||||
// - **Not after a reduced pass.** A full-resolution pass ends the reduced
|
// - **Not after a reduced pass.** A full-resolution pass ends the reduced
|
||||||
// chain (see `DetailRunner::encode`), so one that follows a scaled pass
|
// chain (see `DetailRunner::encode`), so one that follows a scaled pass
|
||||||
// is what stops the next operation reading the last one's base. None of
|
// is what stops the next operation reading the last one's base. None of
|
||||||
@@ -688,45 +639,29 @@ pub fn compose_detail_with(
|
|||||||
// Everywhere else the pass before and the pass after exchange the same
|
// Everywhere else the pass before and the pass after exchange the same
|
||||||
// `rgba16float` texels either way, `aux` included.
|
// `rgba16float` texels either way, `aux` included.
|
||||||
let mut kept: Vec<(&str, &[Helper], DetailPass, usize)> = Vec::with_capacity(planned.len());
|
let mut kept: Vec<(&str, &[Helper], DetailPass, usize)> = Vec::with_capacity(planned.len());
|
||||||
let total = planned.len();
|
for entry in planned {
|
||||||
for (position, entry) in planned.into_iter().enumerate() {
|
|
||||||
let after_full = kept.last().is_none_or(|(_, _, p, _)| p.output_scale <= 1);
|
let after_full = kept.last().is_none_or(|(_, _, p, _)| p.output_scale <= 1);
|
||||||
let droppable = position + 1 < total && after_full && entry.2.is_identity();
|
let droppable = after_full && entry.2.is_identity();
|
||||||
if !droppable {
|
if !droppable {
|
||||||
kept.push(entry);
|
kept.push(entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let planned = kept;
|
let planned = kept;
|
||||||
|
|
||||||
let last = planned.len().saturating_sub(1);
|
|
||||||
let passes = planned
|
let passes = planned
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.enumerate()
|
.map(|(id, helpers, pass, index)| compose_one(id, helpers, &pass, index, scale))
|
||||||
.map(|(position, (id, helpers, pass, index))| {
|
|
||||||
compose_one(id, helpers, &pass, index, scale, output, position == last)
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
ComposedDetail { passes }
|
ComposedDetail { passes }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The operation id the resolve pass is labelled with.
|
|
||||||
///
|
|
||||||
/// Not an operation: no `ops/*.yaml` declares it and nothing in the chain
|
|
||||||
/// answers to it. It exists so the generated label reads `detail/resolve`
|
|
||||||
/// rather than borrowing the id of whichever operation happened to fall
|
|
||||||
/// through, which would send a reader looking for a bug in that operation.
|
|
||||||
const RESOLVE_ID: &str = "detail";
|
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
fn compose_one(
|
fn compose_one(
|
||||||
id: &str,
|
id: &str,
|
||||||
helpers: &[Helper],
|
helpers: &[Helper],
|
||||||
pass: &DetailPass,
|
pass: &DetailPass,
|
||||||
index: usize,
|
index: usize,
|
||||||
scale: RenderScale,
|
scale: RenderScale,
|
||||||
output: ColourSpace,
|
|
||||||
writes_output: bool,
|
|
||||||
) -> ComposedDetailPass {
|
) -> ComposedDetailPass {
|
||||||
let prefix = format!("{}_{index}", crate::operation::sanitise(id));
|
let prefix = format!("{}_{index}", crate::operation::sanitise(id));
|
||||||
|
|
||||||
@@ -772,41 +707,13 @@ fn compose_one(
|
|||||||
let _ = writeln!(helper_src, "{}\n", h.source.trim_end());
|
let _ = writeln!(helper_src, "{}\n", h.source.trim_end());
|
||||||
}
|
}
|
||||||
|
|
||||||
// The storage format and the tail are the *only* difference between an
|
// Every pass writes another linear intermediate: no clip and no encode,
|
||||||
// intermediate pass and the final one. Everything above — the taps, the
|
// because the view pass after the last one still has to read real values
|
||||||
// uniforms, the body — is identical, which is what lets an operation write
|
// (D19). `aux` rides in alpha. A pass that never touches it hands on
|
||||||
// one kernel without knowing whether it happens to be last in the chain.
|
// whatever it was given, so the lane costs an operation that does not want
|
||||||
let (store_format, tail) = if writes_output {
|
// it exactly one copy of a value it already read.
|
||||||
(
|
let store_format = "rgba16float";
|
||||||
"rgba8unorm",
|
let tail = " textureStore(output, coord, vec4<f32>(c, aux));";
|
||||||
format!(
|
|
||||||
"{} // Clip to the output gamut and encode. The one quantisation\n\
|
|
||||||
\x20 // the pipeline performs (FR-DEV-2), and it is here rather than\n\
|
|
||||||
\x20 // in the fused pass because this is now the last thing to run.\n\
|
|
||||||
\x20 c = clamp(c, vec3<f32>(0.0), vec3<f32>(1.0));\n\
|
|
||||||
\x20 textureStore(output, coord, vec4<f32>(encode_output(c), 1.0));",
|
|
||||||
crate::operation::primaries_conversion(output)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
(
|
|
||||||
"rgba16float",
|
|
||||||
" // Another linear intermediate: no clip and no encode, because\n\
|
|
||||||
\x20 // the pass after this one still has to read real values.\n\
|
|
||||||
\x20 //\n\
|
|
||||||
\x20 // `aux` rides in alpha. A pass that never touches it hands on\n\
|
|
||||||
\x20 // whatever it was given, so the lane costs an operation that\n\
|
|
||||||
\x20 // does not want it exactly one copy of a value it already read.\n\
|
|
||||||
\x20 textureStore(output, coord, vec4<f32>(c, aux));"
|
|
||||||
.to_string(),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
let encode_fn = if writes_output {
|
|
||||||
crate::operation::encode_output_fn(output)
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
let label = format!("{id}/{}", pass.label);
|
let label = format!("{id}/{}", pass.label);
|
||||||
let indented = body
|
let indented = body
|
||||||
@@ -823,7 +730,7 @@ fn compose_one(
|
|||||||
// way back to a coordinate.
|
// way back to a coordinate.
|
||||||
//
|
//
|
||||||
// In: linear sRGB, scene-referred, **unclipped**, at render resolution.
|
// In: linear sRGB, scene-referred, **unclipped**, at render resolution.
|
||||||
// Out: {}
|
// Out: the same, for the next pass or for the view pass after the last.
|
||||||
|
|
||||||
struct Params {{
|
struct Params {{
|
||||||
{uniform_fields}}}
|
{uniform_fields}}}
|
||||||
@@ -907,7 +814,7 @@ fn reduced_at(coord: vec2<i32>) -> f32 {{
|
|||||||
return mix(mix(s00, s10, f.x), mix(s01, s11, f.x), f.y);
|
return mix(mix(s00, s10, f.x), mix(s01, s11, f.x), f.y);
|
||||||
}}
|
}}
|
||||||
|
|
||||||
{helper_src}{encode_fn}
|
{helper_src}
|
||||||
@compute @workgroup_size(8, 8, 1)
|
@compute @workgroup_size(8, 8, 1)
|
||||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
|
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
|
||||||
let dims = textureDimensions(output);
|
let dims = textureDimensions(output);
|
||||||
@@ -936,12 +843,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
|
|||||||
|
|
||||||
{tail}
|
{tail}
|
||||||
}}
|
}}
|
||||||
",
|
"
|
||||||
if writes_output {
|
|
||||||
"display-encoded, in the output space."
|
|
||||||
} else {
|
|
||||||
"linear sRGB, for the next pass."
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let structure_hash = crate::operation::hash_source(&source);
|
let structure_hash = crate::operation::hash_source(&source);
|
||||||
@@ -956,7 +858,6 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
|
|||||||
// dispatch size and a declaration is data, which since FR-PLG-2 can
|
// dispatch size and a declaration is data, which since FR-PLG-2 can
|
||||||
// come from a file this build did not write.
|
// come from a file this build did not write.
|
||||||
output_scale: pass.output_scale.max(1),
|
output_scale: pass.output_scale.max(1),
|
||||||
writes_output,
|
|
||||||
structure_hash,
|
structure_hash,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1095,27 +996,19 @@ mod tests {
|
|||||||
// dispatch. An unedited photograph must not pay for a sharpener it is
|
// dispatch. An unedited photograph must not pay for a sharpener it is
|
||||||
// not using.
|
// not using.
|
||||||
let ops = with_blur(0.0);
|
let ops = with_blur(0.0);
|
||||||
let composed = compose_detail(
|
let composed = compose_detail(&ops, RenderScale::full((512, 512)));
|
||||||
&ops,
|
|
||||||
RenderScale::full((512, 512)),
|
|
||||||
dr_types::ColourSpace::Srgb,
|
|
||||||
);
|
|
||||||
assert!(composed.is_empty());
|
assert!(composed.is_empty());
|
||||||
assert_eq!(fused(&ops).output_mode, OutputMode::Encoded);
|
assert_eq!(fused(&ops).output_mode, OutputMode::Encoded);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_separable_blur_becomes_two_passes_and_only_the_last_encodes() {
|
fn a_separable_blur_becomes_two_passes_and_neither_encodes() {
|
||||||
// The multi-pass case, which is the one the ping-pong exists for. The
|
// The multi-pass case, which is the one the ping-pong exists for. Both
|
||||||
// first pass writes a linear intermediate and the second writes the
|
// passes write linear intermediates, and the fused pass's view pass
|
||||||
// display texture — so the output transform happens exactly once, at
|
// reads the second and performs the view transform and the output
|
||||||
// the end, wherever the end happens to be.
|
// transform — so those happen exactly once, after every kernel (D19).
|
||||||
let ops = with_blur(0.05);
|
let ops = with_blur(0.05);
|
||||||
let composed = compose_detail(
|
let composed = compose_detail(&ops, RenderScale::full((512, 512)));
|
||||||
&ops,
|
|
||||||
RenderScale::full((512, 512)),
|
|
||||||
dr_types::ColourSpace::Srgb,
|
|
||||||
);
|
|
||||||
assert_eq!(composed.len(), 2);
|
assert_eq!(composed.len(), 2);
|
||||||
|
|
||||||
let first = &composed.passes[0];
|
let first = &composed.passes[0];
|
||||||
@@ -1123,13 +1016,16 @@ mod tests {
|
|||||||
assert_eq!(first.label, "detail_probe/horizontal");
|
assert_eq!(first.label, "detail_probe/horizontal");
|
||||||
assert_eq!(last.label, "detail_probe/vertical");
|
assert_eq!(last.label, "detail_probe/vertical");
|
||||||
|
|
||||||
assert!(!first.writes_output);
|
for pass in [first, last] {
|
||||||
assert!(first.source.contains("texture_storage_2d<rgba16float"));
|
assert!(pass.source.contains("texture_storage_2d<rgba16float"));
|
||||||
assert!(!first.source.contains("fn encode_output"));
|
assert!(!pass.source.contains("fn encode_output"));
|
||||||
|
assert!(!pass.source.contains("view_sigmoid"));
|
||||||
assert!(last.writes_output);
|
}
|
||||||
assert!(last.source.contains("texture_storage_2d<rgba8unorm"));
|
let view = fused(&ops)
|
||||||
assert!(last.source.contains("fn encode_output"));
|
.view
|
||||||
|
.expect("a view pass follows the detail stage");
|
||||||
|
assert!(view.source.contains("fn encode_output"));
|
||||||
|
assert!(view.source.contains("c = view_sigmoid("));
|
||||||
|
|
||||||
// Two passes of one operation are two shaders, so they must not share
|
// Two passes of one operation are two shaders, so they must not share
|
||||||
// a pipeline-cache entry — the classic way a second pass silently runs
|
// a pipeline-cache entry — the classic way a second pass silently runs
|
||||||
@@ -1143,11 +1039,7 @@ mod tests {
|
|||||||
// and the composer rewrites it to a prefixed struct field, so two
|
// and the composer rewrites it to a prefixed struct field, so two
|
||||||
// operations may both call a uniform `radius` and neither has to know.
|
// operations may both call a uniform `radius` and neither has to know.
|
||||||
let ops = with_blur(0.05);
|
let ops = with_blur(0.05);
|
||||||
let composed = compose_detail(
|
let composed = compose_detail(&ops, RenderScale::full((512, 512)));
|
||||||
&ops,
|
|
||||||
RenderScale::full((512, 512)),
|
|
||||||
dr_types::ColourSpace::Srgb,
|
|
||||||
);
|
|
||||||
let src = &composed.passes[0].source;
|
let src = &composed.passes[0].source;
|
||||||
assert!(src.contains("detail_probe_0_radius: f32,"));
|
assert!(src.contains("detail_probe_0_radius: f32,"));
|
||||||
assert!(src.contains("let r = i32(u.detail_probe_0_radius);"));
|
assert!(src.contains("let r = i32(u.detail_probe_0_radius);"));
|
||||||
@@ -1164,13 +1056,7 @@ mod tests {
|
|||||||
// outright by the WGSL uniform address space rules, and the failure
|
// outright by the WGSL uniform address space rules, and the failure
|
||||||
// arrives as a shader compilation error against generated source.
|
// arrives as a shader compilation error against generated source.
|
||||||
let ops = with_blur(0.05);
|
let ops = with_blur(0.05);
|
||||||
for pass in compose_detail(
|
for pass in compose_detail(&ops, RenderScale::full((512, 512))).passes {
|
||||||
&ops,
|
|
||||||
RenderScale::full((512, 512)),
|
|
||||||
dr_types::ColourSpace::Srgb,
|
|
||||||
)
|
|
||||||
.passes
|
|
||||||
{
|
|
||||||
assert_eq!(pass.uniforms.len() % 4, 0, "{}", pass.label);
|
assert_eq!(pass.uniforms.len() % 4, 0, "{}", pass.label);
|
||||||
assert!(pass.uniforms.iter().all(|v| v.is_finite()));
|
assert!(pass.uniforms.iter().all(|v| v.is_finite()));
|
||||||
// The base block is first and fixed, so a pass never addresses a
|
// The base block is first and fixed, so a pass never addresses a
|
||||||
@@ -1189,7 +1075,7 @@ mod tests {
|
|||||||
// the truth rather than zero.
|
// the truth rather than zero.
|
||||||
let ops = with_blur(0.05);
|
let ops = with_blur(0.05);
|
||||||
let scale = RenderScale::full((400, 400));
|
let scale = RenderScale::full((400, 400));
|
||||||
let composed = compose_detail(&ops, scale, dr_types::ColourSpace::Srgb);
|
let composed = compose_detail(&ops, scale);
|
||||||
let expected = BoxBlur::with_radius(0.05).kernel(scale);
|
let expected = BoxBlur::with_radius(0.05).kernel(scale);
|
||||||
assert_eq!(expected, 20, "5% of a 400px edge");
|
assert_eq!(expected, 20, "5% of a 400px edge");
|
||||||
assert_eq!(composed.radius(), expected);
|
assert_eq!(composed.radius(), expected);
|
||||||
@@ -1208,7 +1094,7 @@ mod tests {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|&(w, h)| {
|
.map(|&(w, h)| {
|
||||||
let scale = RenderScale::full((w, h));
|
let scale = RenderScale::full((w, h));
|
||||||
let composed = compose_detail(&ops, scale, dr_types::ColourSpace::Srgb);
|
let composed = compose_detail(&ops, scale);
|
||||||
composed.radius() as f32 / w.min(h) as f32
|
composed.radius() as f32 / w.min(h) as f32
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -1226,14 +1112,14 @@ mod tests {
|
|||||||
// cannot see each other. `compose_full` decides to hand on linear
|
// cannot see each other. `compose_full` decides to hand on linear
|
||||||
// working values from the *operations* — it has no resolution to
|
// working values from the *operations* — it has no resolution to
|
||||||
// consult — while this composer converts a radius and can legitimately
|
// consult — while this composer converts a radius and can legitimately
|
||||||
// decide there is nothing to draw at this size. An empty chain would
|
// decide there is nothing to draw at this size.
|
||||||
// then leave the output transform undone: the fused pass writes
|
|
||||||
// `rgba16float` and the frontend binds an `rgba8unorm` target to it.
|
|
||||||
//
|
//
|
||||||
// A photographer meets this by turning on capture sharpening or
|
// A photographer meets this by turning on capture sharpening or
|
||||||
// luminance noise reduction while the develop view is fitted to a
|
// luminance noise reduction while the develop view is fitted to a
|
||||||
// large file, which is the normal way to work, so it is not an edge
|
// large file, which is the normal way to work. Before D19 an empty
|
||||||
// case that can be left to fail.
|
// chain left the output transform undone and needed a resolve pass;
|
||||||
|
// now the view pass does the output transform whatever the chain
|
||||||
|
// holds.
|
||||||
let ops = with_blur(0.001);
|
let ops = with_blur(0.001);
|
||||||
let scale = RenderScale::full((400, 400));
|
let scale = RenderScale::full((400, 400));
|
||||||
assert!(ops.last().expect("the blur").is_active());
|
assert!(ops.last().expect("the blur").is_active());
|
||||||
@@ -1243,74 +1129,59 @@ mod tests {
|
|||||||
"the premise: a radius too small to draw emits no pass"
|
"the premise: a radius too small to draw emits no pass"
|
||||||
);
|
);
|
||||||
|
|
||||||
let composed = compose_detail(&ops, scale, dr_types::ColourSpace::Srgb);
|
// Empty, and that is fine since D19: nothing in the chain encodes, so
|
||||||
assert_eq!(composed.len(), 1, "the chain must not be empty here");
|
// there is no output transform for an empty chain to leave undone.
|
||||||
assert_eq!(composed.radius(), 0, "it reads only the pixel it writes");
|
// The fused pass stopped at linear values and its view pass reads
|
||||||
|
// them directly.
|
||||||
let resolve = &composed.passes[0];
|
let composed = compose_detail(&ops, scale);
|
||||||
assert_eq!(resolve.label, "detail/resolve");
|
assert!(composed.is_empty());
|
||||||
assert!(resolve.writes_output);
|
let fused = fused(&ops);
|
||||||
assert!(resolve.source.contains("texture_storage_2d<rgba8unorm"));
|
assert_eq!(fused.output_mode, OutputMode::LinearWorking);
|
||||||
assert!(resolve.source.contains("fn encode_output"));
|
let view = fused
|
||||||
// Exactly the fixed base block and no more: a pass with no body has
|
.view
|
||||||
// nothing of its own to upload, and the block still has to be a
|
.expect("the view pass performs the output transform");
|
||||||
// multiple of sixteen bytes.
|
assert_eq!(view.output_mode, OutputMode::Encoded);
|
||||||
assert_eq!(resolve.uniforms.len(), DETAIL_BASE_UNIFORM_FIELDS);
|
assert!(view.source.contains("fn encode_output"));
|
||||||
assert_eq!(resolve.uniforms.len() % 4, 0);
|
|
||||||
|
|
||||||
// And it really is a copy: the fused pass composed alongside it is the
|
|
||||||
// one that stopped short, so the two agree about who encodes.
|
|
||||||
assert_eq!(fused(&ops).output_mode, OutputMode::LinearWorking);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_pass_that_changes_nothing_is_dropped_where_that_is_exact() {
|
fn a_pass_that_changes_nothing_is_dropped_where_that_is_exact() {
|
||||||
// TRACES: NFR-P5
|
// TRACES: NFR-P5
|
||||||
// Capture sharpening at a scale too coarse to draw its radius emits a
|
// A pass with an empty body costs a render-sized read and write and
|
||||||
// pass with an empty body. Between two other passes it costs a
|
// changes no texel, so it goes — wherever it falls since D19, the last
|
||||||
// render-sized read and write and changes no texel, so it goes; as the
|
// position included, because the last pass writes an intermediate like
|
||||||
// last pass it performs the output transform on the intermediate, and
|
// every other and the view pass reads whichever the chain last wrote.
|
||||||
// moving that onto the pass before would round differently, so it
|
// Built by hand, and run as a repair so it goes first: no operation
|
||||||
// stays.
|
// emits one any more (capture sharpening at a scale too coarse to draw
|
||||||
use crate::ops::{capture_sharpen, CaptureSharpen, NoiseReduction};
|
// its radius used to, and now emits nothing).
|
||||||
let sharpen = || -> Box<dyn Operation> {
|
use crate::ops::NoiseReduction;
|
||||||
let mut op = CaptureSharpen::new();
|
|
||||||
op.set_param(capture_sharpen::AMOUNT, 60.0);
|
|
||||||
Box::new(op)
|
|
||||||
};
|
|
||||||
let chroma = || -> Box<dyn Operation> { Box::new(NoiseReduction::with_amounts(0.0, 60.0)) };
|
let chroma = || -> Box<dyn Operation> { Box::new(NoiseReduction::with_amounts(0.0, 60.0)) };
|
||||||
// A 24 MP frame fitted to a panel: a one-source-pixel radius is a
|
|
||||||
// quarter of a render pixel.
|
|
||||||
let scale = RenderScale::new((1500, 1000), (6000, 4000));
|
let scale = RenderScale::new((1500, 1000), (6000, 4000));
|
||||||
let unresolved = sharpen().detail().expect("a detail stage").passes(scale);
|
let nothing = DetailPass {
|
||||||
assert!(
|
output_scale: 1,
|
||||||
unresolved.len() == 1 && unresolved[0].is_identity(),
|
label: "nothing",
|
||||||
"the premise: sharpening at this scale is one pass that does nothing"
|
radius: 0,
|
||||||
);
|
wgsl: "// `c` already holds this pixel.".to_string(),
|
||||||
let labels = |ops: &[Box<dyn Operation>]| -> Vec<String> {
|
uniforms: Vec::new(),
|
||||||
compose_detail(ops, scale, dr_types::ColourSpace::Srgb)
|
storage: Vec::new(),
|
||||||
|
};
|
||||||
|
assert!(nothing.is_identity(), "the premise");
|
||||||
|
let labels: Vec<String> =
|
||||||
|
compose_detail_with(&[chroma()], std::slice::from_ref(¬hing), scale)
|
||||||
.passes
|
.passes
|
||||||
.iter()
|
.iter()
|
||||||
.map(|p| p.label.clone())
|
.map(|p| p.label.clone())
|
||||||
.collect()
|
.collect();
|
||||||
};
|
|
||||||
|
|
||||||
// First, ahead of the chroma passes: dropped.
|
|
||||||
let first = labels(&[sharpen(), chroma()]);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
first,
|
labels,
|
||||||
[
|
[
|
||||||
"noise_reduction/chroma-horizontal",
|
"noise_reduction/chroma-horizontal",
|
||||||
"noise_reduction/chroma-vertical"
|
"noise_reduction/chroma-vertical"
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
// Last, after them: kept, and it is the pass that encodes.
|
// Alone: dropped too, and the chain is empty — the view pass
|
||||||
let last = labels(&[chroma(), sharpen()]);
|
// finishes the frame.
|
||||||
assert_eq!(last.len(), 3);
|
assert!(compose_detail_with(&[], &[nothing], scale).is_empty());
|
||||||
assert_eq!(last[2], "capture_sharpen/unresolved");
|
|
||||||
// Alone: kept, because the fused pass stopped short and something has
|
|
||||||
// to finish the frame.
|
|
||||||
assert_eq!(labels(&[sharpen()]), ["capture_sharpen/unresolved"]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1325,11 +1196,7 @@ mod tests {
|
|||||||
// A pass that says nothing about `aux` hands on what it was given,
|
// A pass that says nothing about `aux` hands on what it was given,
|
||||||
// which is why the box blur below needs no knowledge of it.
|
// which is why the box blur below needs no knowledge of it.
|
||||||
let ops = with_blur(0.05);
|
let ops = with_blur(0.05);
|
||||||
let composed = compose_detail(
|
let composed = compose_detail(&ops, RenderScale::full((512, 512)));
|
||||||
&ops,
|
|
||||||
RenderScale::full((512, 512)),
|
|
||||||
dr_types::ColourSpace::Srgb,
|
|
||||||
);
|
|
||||||
|
|
||||||
for pass in &composed.passes {
|
for pass in &composed.passes {
|
||||||
assert!(
|
assert!(
|
||||||
@@ -1344,11 +1211,12 @@ mod tests {
|
|||||||
.contains("textureStore(output, coord, vec4<f32>(c, aux));"),
|
.contains("textureStore(output, coord, vec4<f32>(c, aux));"),
|
||||||
"an intermediate must carry the lane to the pass after it"
|
"an intermediate must carry the lane to the pass after it"
|
||||||
);
|
);
|
||||||
// The last pass writes the display texture, whose alpha is opacity and
|
// The last pass carries it too: since D19 it writes an intermediate
|
||||||
// not scratch space. Readable there, not written — which is the right
|
// for the view pass rather than the display texture, whose alpha is
|
||||||
// way round, because the combining pass is the one that reads it.
|
// opacity. The view pass reads only the colour.
|
||||||
assert!(composed.passes[1].writes_output);
|
assert!(composed.passes[1]
|
||||||
assert!(!composed.passes[1].source.contains("vec4<f32>(c, aux)"));
|
.source
|
||||||
|
.contains("textureStore(output, coord, vec4<f32>(c, aux));"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1357,11 +1225,7 @@ mod tests {
|
|||||||
// overwhelmingly common edit: no sharpening means no chain, which
|
// overwhelmingly common edit: no sharpening means no chain, which
|
||||||
// means `dr-gpu` runs the single fused dispatch it always did.
|
// means `dr-gpu` runs the single fused dispatch it always did.
|
||||||
let ops = crate::ops::chain();
|
let ops = crate::ops::chain();
|
||||||
let composed = compose_detail(
|
let composed = compose_detail(&ops, RenderScale::full((64, 64)));
|
||||||
&ops,
|
|
||||||
RenderScale::full((64, 64)),
|
|
||||||
dr_types::ColourSpace::Srgb,
|
|
||||||
);
|
|
||||||
assert!(composed.is_empty());
|
assert!(composed.is_empty());
|
||||||
assert_eq!(composed.radius(), 0);
|
assert_eq!(composed.radius(), 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -956,42 +956,31 @@ impl EditGraph {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// TRACES: FR-DEV-3 | FR-DSP-1
|
/// TRACES: FR-DEV-3 | FR-DSP-1
|
||||||
/// Generate the detail stage for this edit at one resolution, to sRGB.
|
/// Generate the detail stage for this edit at one resolution.
|
||||||
///
|
///
|
||||||
/// Empty for every edit with no active neighbourhood operation, which is
|
/// Empty for every edit with no active neighbourhood operation, which is
|
||||||
/// almost all of them — and in that case [`Self::compose`] emits the
|
/// almost all of them — and in that case [`Self::compose`] emits the
|
||||||
/// single encoded dispatch it always has.
|
/// single encoded dispatch it always has.
|
||||||
pub fn compose_detail(
|
|
||||||
&self,
|
|
||||||
source: (u32, u32),
|
|
||||||
render: (u32, u32),
|
|
||||||
) -> crate::detail::ComposedDetail {
|
|
||||||
self.compose_detail_for(source, render, dr_types::ColourSpace::Srgb)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// TRACES: FR-EXP-2
|
|
||||||
/// The detail stage, encoded into a chosen output space.
|
|
||||||
///
|
///
|
||||||
/// The space belongs here as well as on [`Self::compose_for`] because when
|
/// No output space: since D19 no detail pass encodes. The fused pass's
|
||||||
/// a detail stage exists it is the *last* pass that performs the output
|
/// view pass reads what the last one wrote and performs the view transform
|
||||||
/// transform — the fused pass stops at linear working values. Composing
|
/// and the output transform, so it is [`Self::compose_for`] alone that
|
||||||
/// the two halves for different spaces would encode the edit twice, or
|
/// names the space.
|
||||||
/// not at all.
|
///
|
||||||
/// `source` is the demosaiced image's size and `render` the size being
|
/// `source` is the demosaiced image's size and `render` the size being
|
||||||
/// drawn. The scale is worked out here rather than handed in, because the
|
/// drawn. The scale is worked out here rather than handed in, because the
|
||||||
/// repairs need the *source* size as well — a spot is stored in normalised
|
/// repairs need the *source* size as well — a spot is stored in normalised
|
||||||
/// source coordinates and has to be put through the framing to find out
|
/// source coordinates and has to be put through the framing to find out
|
||||||
/// where it lands on this render, and a [`crate::detail::RenderScale`]
|
/// where it lands on this render, and a [`crate::detail::RenderScale`]
|
||||||
/// describes the region on screen rather than the photograph.
|
/// describes the region on screen rather than the photograph.
|
||||||
pub fn compose_detail_for(
|
pub fn compose_detail(
|
||||||
&self,
|
&self,
|
||||||
source: (u32, u32),
|
source: (u32, u32),
|
||||||
render: (u32, u32),
|
render: (u32, u32),
|
||||||
output: dr_types::ColourSpace,
|
|
||||||
) -> crate::detail::ComposedDetail {
|
) -> crate::detail::ComposedDetail {
|
||||||
let scale = self.render_scale(source, render);
|
let scale = self.render_scale(source, render);
|
||||||
let spots = self.spots.passes(&self.framing, source, scale);
|
let spots = self.spots.passes(&self.framing, source, scale);
|
||||||
crate::detail::compose_detail_with(&self.ops, &spots, scale, output)
|
crate::detail::compose_detail_with(&self.ops, &spots, scale)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TRACES: FR-DEV-3d
|
/// TRACES: FR-DEV-3d
|
||||||
|
|||||||
@@ -562,6 +562,17 @@ pub struct ComposedShader {
|
|||||||
/// the interpolating paths, whose sample is a blend of four texels and
|
/// the interpolating paths, whose sample is a blend of four texels and
|
||||||
/// not representable exactly in the source's own format.
|
/// not representable exactly in the source's own format.
|
||||||
pub sample_key: Option<u64>,
|
pub sample_key: Option<u64>,
|
||||||
|
/// TRACES: FR-DEV-3j
|
||||||
|
/// The view pass, for a shader that stops at [`OutputMode::LinearWorking`].
|
||||||
|
///
|
||||||
|
/// The view transform runs after the detail stage (D19): a sharpener or
|
||||||
|
/// a blur must convolve scene-linear values, not a display rendering. So
|
||||||
|
/// when a detail stage follows, this shader stops short of the view
|
||||||
|
/// transform, and this second shader — the same prologue for the
|
||||||
|
/// positions a fragment reads, the colour taken from the detail stage's
|
||||||
|
/// result bound as `sampled`, then the view transform and the output
|
||||||
|
/// transform — writes the display texture. `None` for every other mode.
|
||||||
|
pub view: Option<Box<ComposedShader>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fields the generated uniform struct always carries, before op uniforms.
|
/// Fields the generated uniform struct always carries, before op uniforms.
|
||||||
@@ -700,7 +711,7 @@ pub fn compose_full_revealing(
|
|||||||
reveal: Option<&crate::mask::Reveal>,
|
reveal: Option<&crate::mask::Reveal>,
|
||||||
) -> ComposedShader {
|
) -> ComposedShader {
|
||||||
compose_inner(
|
compose_inner(
|
||||||
ops, framing, output, masks, spots, warps, reveal, None, false,
|
ops, framing, output, masks, spots, warps, reveal, None, false, false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -770,6 +781,7 @@ fn compose_camera_tap(
|
|||||||
None,
|
None,
|
||||||
Some(OutputMode::CameraLinear),
|
Some(OutputMode::CameraLinear),
|
||||||
smooth,
|
smooth,
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -784,6 +796,7 @@ fn compose_inner(
|
|||||||
reveal: Option<&crate::mask::Reveal>,
|
reveal: Option<&crate::mask::Reveal>,
|
||||||
forced: Option<OutputMode>,
|
forced: Option<OutputMode>,
|
||||||
smooth: bool,
|
smooth: bool,
|
||||||
|
view_pass: bool,
|
||||||
) -> ComposedShader {
|
) -> ComposedShader {
|
||||||
// The lens corrections, composed into one coordinate transform. Beside
|
// The lens corrections, composed into one coordinate transform. Beside
|
||||||
// `framing` because they are the other half of the same stage: framing
|
// `framing` because they are the other half of the same stage: framing
|
||||||
@@ -819,13 +832,22 @@ fn compose_inner(
|
|||||||
// sense above: `compose_camera_probe` is the only function that passes
|
// sense above: `compose_camera_probe` is the only function that passes
|
||||||
// it, with an empty operation list, and the mode it forces has its own
|
// it, with an empty operation list, and the mode it forces has its own
|
||||||
// storage format and its own render entry on the GPU side.
|
// storage format and its own render entry on the GPU side.
|
||||||
let output_mode = forced.unwrap_or(
|
//
|
||||||
|
// TRACES: FR-DEV-3j
|
||||||
|
// The view pass is the other exception. It follows the detail stage and
|
||||||
|
// writes the display texture, so it is `Encoded` whatever the chain holds:
|
||||||
|
// it exists only because a detail stage does.
|
||||||
|
let output_mode = if view_pass {
|
||||||
|
OutputMode::Encoded
|
||||||
|
} else {
|
||||||
|
forced.unwrap_or(
|
||||||
if ops.iter().any(|o| o.is_active() && o.detail().is_some()) || !spots.is_neutral() {
|
if ops.iter().any(|o| o.is_active() && o.detail().is_some()) || !spots.is_neutral() {
|
||||||
OutputMode::LinearWorking
|
OutputMode::LinearWorking
|
||||||
} else {
|
} else {
|
||||||
OutputMode::Encoded
|
OutputMode::Encoded
|
||||||
},
|
},
|
||||||
);
|
)
|
||||||
|
};
|
||||||
|
|
||||||
// Whether an operation has taken over the rendering. Decided from the
|
// Whether an operation has taken over the rendering. Decided from the
|
||||||
// operations for the same reason `output_mode` is: a caller that got it
|
// operations for the same reason `output_mode` is: a caller that got it
|
||||||
@@ -862,6 +884,11 @@ fn compose_inner(
|
|||||||
// Whether the composer emits a view transform — which is every render but
|
// Whether the composer emits a view transform — which is every render but
|
||||||
// the camera-space tap and one a rendering operation has taken over.
|
// the camera-space tap and one a rendering operation has taken over.
|
||||||
let views = !op_renders && output_mode != OutputMode::CameraLinear;
|
let views = !op_renders && output_mode != OutputMode::CameraLinear;
|
||||||
|
// And whether *this* shader is where it goes. Not the fused pass when a
|
||||||
|
// detail stage follows: the view transform maps into a display range, and
|
||||||
|
// a sharpener handed a display range is what D19 set out to stop. The
|
||||||
|
// view pass after the detail stage carries it instead.
|
||||||
|
let views_here = views && output_mode == OutputMode::Encoded;
|
||||||
|
|
||||||
// Framing's block follows the base one at a fixed offset, for the same
|
// Framing's block follows the base one at a fixed offset, for the same
|
||||||
// reason: the prologue is emitted whether or not any operation is active,
|
// reason: the prologue is emitted whether or not any operation is active,
|
||||||
@@ -917,7 +944,7 @@ fn compose_inner(
|
|||||||
// none — `compose(&[])` in a test, or a probe. Absent altogether where
|
// none — `compose(&[])` in a test, or a probe. Absent altogether where
|
||||||
// nothing is to be rendered: see `views`.
|
// nothing is to be rendered: see `views`.
|
||||||
let default_view = crate::ops::ViewTransform::new();
|
let default_view = crate::ops::ViewTransform::new();
|
||||||
let view: Option<&dyn Operation> = views.then(|| {
|
let view: Option<&dyn Operation> = views_here.then(|| {
|
||||||
point(Stage::View)
|
point(Stage::View)
|
||||||
.next()
|
.next()
|
||||||
.unwrap_or(&default_view as &dyn Operation)
|
.unwrap_or(&default_view as &dyn Operation)
|
||||||
@@ -930,12 +957,17 @@ fn compose_inner(
|
|||||||
Matrix,
|
Matrix,
|
||||||
Orphans,
|
Orphans,
|
||||||
}
|
}
|
||||||
let steps = point(Stage::Camera)
|
//
|
||||||
.map(Step::Op)
|
// The view pass holds the view transform and nothing else: everything
|
||||||
.chain(std::iter::once(Step::Matrix))
|
// before it has already run, in the fused pass and the detail stage.
|
||||||
.chain(point(Stage::Scene).map(Step::Op))
|
let mut steps: Vec<Step> = Vec::new();
|
||||||
.chain(std::iter::once(Step::Orphans))
|
if !view_pass {
|
||||||
.chain(view.map(Step::Op));
|
steps.extend(point(Stage::Camera).map(Step::Op));
|
||||||
|
steps.push(Step::Matrix);
|
||||||
|
steps.extend(point(Stage::Scene).map(Step::Op));
|
||||||
|
steps.push(Step::Orphans);
|
||||||
|
}
|
||||||
|
steps.extend(view.map(Step::Op));
|
||||||
|
|
||||||
for step in steps {
|
for step in steps {
|
||||||
let op = match step {
|
let op = match step {
|
||||||
@@ -1021,7 +1053,15 @@ fn compose_inner(
|
|||||||
// rather than among the operations — see `mask::LayerShader::reveal`.
|
// rather than among the operations — see `mask::LayerShader::reveal`.
|
||||||
// Empty for every composition nobody is looking at a mask through, which
|
// Empty for every composition nobody is looking at a mask through, which
|
||||||
// is all of them but the screen's.
|
// is all of them but the screen's.
|
||||||
let reveal_block = layers.reveal.clone();
|
//
|
||||||
|
// And after the detail stage, when there is one: on the linear
|
||||||
|
// intermediate a flat tint would be sharpened and then rendered as some
|
||||||
|
// other colour. So it rides on whichever shader writes the display.
|
||||||
|
let reveal_block = if output_mode == OutputMode::Encoded {
|
||||||
|
layers.reveal.clone()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
uniform_fields.push_str(&layers.uniform_fields);
|
uniform_fields.push_str(&layers.uniform_fields);
|
||||||
uniform_values.extend_from_slice(&layers.uniform_values);
|
uniform_values.extend_from_slice(&layers.uniform_values);
|
||||||
for h in &layers.helpers {
|
for h in &layers.helpers {
|
||||||
@@ -1157,6 +1197,70 @@ fn compose_inner(
|
|||||||
// Formatted with Rust's `Display` so the shader reads the same threshold
|
// Formatted with Rust's `Display` so the shader reads the same threshold
|
||||||
// the probe checks against; see `CLIP_ONSET`.
|
// the probe checks against; see `CLIP_ONSET`.
|
||||||
let clip_onset = CLIP_ONSET;
|
let clip_onset = CLIP_ONSET;
|
||||||
|
|
||||||
|
// What the operations are handed. For the fused pass, the source texel
|
||||||
|
// made linear and balanced as shot; for the view pass, the scene as the
|
||||||
|
// detail stage left it, which is already all of that.
|
||||||
|
let head = if view_pass {
|
||||||
|
" // The view pass (FR-DEV-3j): everything up to the view transform ran
|
||||||
|
// in the fused pass and the detail stage, and what they left is in the
|
||||||
|
// texture bound as `sampled`, at this pixel. The prologue above ran only
|
||||||
|
// for the positions it publishes — `source_px` for a film's grain, and
|
||||||
|
// the corners it blacks out — and its colour is discarded.
|
||||||
|
let non_linear = u.as_shot_wb.w > 0.5;
|
||||||
|
c = textureLoad(sampled, vec2<i32>(gid.xy), 0).rgb;
|
||||||
|
"
|
||||||
|
.to_string()
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
" // A non-linear source is already display-encoded; undo that so the
|
||||||
|
// operations below see linear colour whatever the source was.
|
||||||
|
let non_linear = u.as_shot_wb.w > 0.5;
|
||||||
|
if (non_linear) {{
|
||||||
|
c = decode_srgb(c);
|
||||||
|
}}
|
||||||
|
|
||||||
|
// As-shot white balance. Applied unconditionally, before any operation,
|
||||||
|
// because it is part of *interpreting* the sensor rather than an edit: a
|
||||||
|
// Bayer sensor's green photosites collect far more signal than its red
|
||||||
|
// and blue, so raw camera-space values are strongly green and no amount
|
||||||
|
// of later correction recovers a neutral image from them. The white
|
||||||
|
// balance operation, when active, applies its own offset on top of this.
|
||||||
|
//
|
||||||
|
// A non-linear source has already had this applied in-camera; the uniform
|
||||||
|
// is neutral there, so this is a multiply by one rather than a branch.
|
||||||
|
// How close this pixel was to saturation before any balance was applied.
|
||||||
|
// A photosite at its white level carries no colour information — every
|
||||||
|
// channel simply stopped counting — so the balance below must not be
|
||||||
|
// allowed to tint it.
|
||||||
|
let clipped = smoothstep({clip_onset}, 1.0, max(c.r, max(c.g, c.b)));
|
||||||
|
|
||||||
|
c = c * u.as_shot_wb.rgb;
|
||||||
|
|
||||||
|
// **Highlight desaturation, and without it every blown sky is magenta.**
|
||||||
|
//
|
||||||
|
// A fully clipped pixel arrives as (1, 1, 1). The as-shot multipliers are
|
||||||
|
// not neutral — on a Canon 6D they are (1.93, 1.00, 1.68) — so balancing
|
||||||
|
// sends it to exactly that, and the camera matrix then produces R 2.88,
|
||||||
|
// G 0.51, B 2.03. Red and blue clip at one and green does not, which is
|
||||||
|
// magenta. The balance is correct; the input was not a colour.
|
||||||
|
//
|
||||||
|
// So a saturated pixel is pulled back toward the neutral its raw values
|
||||||
|
// actually represent, fading in over the last 1.5% of range. Smoothly,
|
||||||
|
// because a hard switch puts a visible edge around every highlight where
|
||||||
|
// the two treatments meet — a rim light on skin is the worst case, and it
|
||||||
|
// is the one people notice.
|
||||||
|
//
|
||||||
|
// The neutral chosen is the balanced grey of the same brightness, so the
|
||||||
|
// highlight keeps its luminance and loses only the cast.
|
||||||
|
if (clipped > 0.0) {{
|
||||||
|
let neutral = vec3<f32>(max(c.r, max(c.g, c.b)));
|
||||||
|
c = mix(c, neutral, clipped);
|
||||||
|
}}
|
||||||
|
"
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
let source = format!(
|
let source = format!(
|
||||||
"// GENERATED — do not edit.
|
"// GENERATED — do not edit.
|
||||||
//
|
//
|
||||||
@@ -1213,51 +1317,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
|
|||||||
}}
|
}}
|
||||||
|
|
||||||
{prologue}
|
{prologue}
|
||||||
// A non-linear source is already display-encoded; undo that so the
|
{head}{body}
|
||||||
// operations below see linear colour whatever the source was.
|
|
||||||
let non_linear = u.as_shot_wb.w > 0.5;
|
|
||||||
if (non_linear) {{
|
|
||||||
c = decode_srgb(c);
|
|
||||||
}}
|
|
||||||
|
|
||||||
// As-shot white balance. Applied unconditionally, before any operation,
|
|
||||||
// because it is part of *interpreting* the sensor rather than an edit: a
|
|
||||||
// Bayer sensor's green photosites collect far more signal than its red
|
|
||||||
// and blue, so raw camera-space values are strongly green and no amount
|
|
||||||
// of later correction recovers a neutral image from them. The white
|
|
||||||
// balance operation, when active, applies its own offset on top of this.
|
|
||||||
//
|
|
||||||
// A non-linear source has already had this applied in-camera; the uniform
|
|
||||||
// is neutral there, so this is a multiply by one rather than a branch.
|
|
||||||
// How close this pixel was to saturation before any balance was applied.
|
|
||||||
// A photosite at its white level carries no colour information — every
|
|
||||||
// channel simply stopped counting — so the balance below must not be
|
|
||||||
// allowed to tint it.
|
|
||||||
let clipped = smoothstep({clip_onset}, 1.0, max(c.r, max(c.g, c.b)));
|
|
||||||
|
|
||||||
c = c * u.as_shot_wb.rgb;
|
|
||||||
|
|
||||||
// **Highlight desaturation, and without it every blown sky is magenta.**
|
|
||||||
//
|
|
||||||
// A fully clipped pixel arrives as (1, 1, 1). The as-shot multipliers are
|
|
||||||
// not neutral — on a Canon 6D they are (1.93, 1.00, 1.68) — so balancing
|
|
||||||
// sends it to exactly that, and the camera matrix then produces R 2.88,
|
|
||||||
// G 0.51, B 2.03. Red and blue clip at one and green does not, which is
|
|
||||||
// magenta. The balance is correct; the input was not a colour.
|
|
||||||
//
|
|
||||||
// So a saturated pixel is pulled back toward the neutral its raw values
|
|
||||||
// actually represent, fading in over the last 1.5% of range. Smoothly,
|
|
||||||
// because a hard switch puts a visible edge around every highlight where
|
|
||||||
// the two treatments meet — a rim light on skin is the worst case, and it
|
|
||||||
// is the one people notice.
|
|
||||||
//
|
|
||||||
// The neutral chosen is the balanced grey of the same brightness, so the
|
|
||||||
// highlight keeps its luminance and loses only the cast.
|
|
||||||
if (clipped > 0.0) {{
|
|
||||||
let neutral = vec3<f32>(max(c.r, max(c.g, c.b)));
|
|
||||||
c = mix(c, neutral, clipped);
|
|
||||||
}}
|
|
||||||
{body}
|
|
||||||
{rendering_tail}{to_output}{reveal_block}
|
{rendering_tail}{to_output}{reveal_block}
|
||||||
{store}
|
{store}
|
||||||
}}
|
}}
|
||||||
@@ -1294,12 +1354,25 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
|
|||||||
output as u64,
|
output as u64,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// TRACES: FR-DEV-3j
|
||||||
|
// The fused pass that stops for a detail stage hands its view transform
|
||||||
|
// to a pass of its own, composed here from the same inputs so the two
|
||||||
|
// halves cannot come from different edits.
|
||||||
|
let view = (output_mode == OutputMode::LinearWorking).then(|| {
|
||||||
|
Box::new(compose_inner(
|
||||||
|
ops, framing, output, masks, spots, warps, reveal, None, smooth, true,
|
||||||
|
))
|
||||||
|
});
|
||||||
|
|
||||||
ComposedShader {
|
ComposedShader {
|
||||||
source,
|
source,
|
||||||
uniforms: uniform_values,
|
uniforms: uniform_values,
|
||||||
structure_hash,
|
structure_hash,
|
||||||
output_mode,
|
output_mode,
|
||||||
sample_key,
|
// The view pass reads the intermediate, not the source, so the
|
||||||
|
// sample cache has nothing to say about it.
|
||||||
|
sample_key: if view_pass { None } else { sample_key },
|
||||||
|
view,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2512,6 +2585,54 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_detail_stage_puts_the_view_transform_after_it() {
|
||||||
|
// TRACES: FR-DEV-3j | FR-DEV-2
|
||||||
|
// D19's second finding: the fused pass stopped at "linear working
|
||||||
|
// values" for a detail stage, but after the base curve, so every
|
||||||
|
// sharpener convolved a display rendering. The fused pass must now
|
||||||
|
// stop *before* the view transform, and the view pass — which reads
|
||||||
|
// the detail stage's result — carries it, the output transform and the
|
||||||
|
// mask reveal, and nothing else.
|
||||||
|
let mut ops = crate::ops::chain();
|
||||||
|
ops.push(Box::new(crate::detail::probe::BoxBlur::with_radius(0.05)));
|
||||||
|
let mut exposure = crate::ops::Exposure::new();
|
||||||
|
exposure.set_param(crate::ops::exposure::EXPOSURE, 1.0);
|
||||||
|
ops.push(Box::new(exposure));
|
||||||
|
|
||||||
|
let fused = compose(&ops);
|
||||||
|
assert_eq!(fused.output_mode, OutputMode::LinearWorking);
|
||||||
|
assert!(!fused.source.contains("view_sigmoid"));
|
||||||
|
assert!(fused.source.contains("---- exposure ----"));
|
||||||
|
|
||||||
|
let view = fused.view.as_deref().expect("a view pass");
|
||||||
|
assert_eq!(view.output_mode, OutputMode::Encoded);
|
||||||
|
assert!(view.view.is_none());
|
||||||
|
assert!(view
|
||||||
|
.source
|
||||||
|
.contains("c = textureLoad(sampled, vec2<i32>(gid.xy), 0).rgb;"));
|
||||||
|
assert!(view.source.contains("c = view_sigmoid("));
|
||||||
|
assert!(view.source.contains("fn encode_output"));
|
||||||
|
assert_eq!(
|
||||||
|
view.source.matches("---- ").count(),
|
||||||
|
1,
|
||||||
|
"the view pass must not run an operation the fused pass already ran"
|
||||||
|
);
|
||||||
|
assert!(!view.source.contains("u.as_shot_wb.rgb"));
|
||||||
|
assert!(!view.source.contains("camera profile: the matrix"));
|
||||||
|
assert!(view.sample_key.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_edit_with_no_detail_stage_has_no_view_pass() {
|
||||||
|
// TRACES: FR-DEV-3j
|
||||||
|
// The common case costs what it always did: one dispatch.
|
||||||
|
let fused = compose(&crate::ops::chain());
|
||||||
|
assert_eq!(fused.output_mode, OutputMode::Encoded);
|
||||||
|
assert!(fused.view.is_none());
|
||||||
|
assert!(fused.source.contains("c = view_sigmoid("));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_detail_operation_never_contributes_a_fused_uniform() {
|
fn a_detail_operation_never_contributes_a_fused_uniform() {
|
||||||
// Slot order in the generated block is emission order, and nothing
|
// Slot order in the generated block is emission order, and nothing
|
||||||
@@ -2520,9 +2641,15 @@ mod tests {
|
|||||||
// every later operation's uniforms out from under its shader. The
|
// every later operation's uniforms out from under its shader. The
|
||||||
// filter in `compose_full` prevents it; this is the assertion that the
|
// filter in `compose_full` prevents it; this is the assertion that the
|
||||||
// filter is on the right side of the loop.
|
// filter is on the right side of the loop.
|
||||||
|
//
|
||||||
|
// Asserted on the field names rather than on a count: since D19 the
|
||||||
|
// edit with a detail operation also moves the view transform's
|
||||||
|
// uniforms out of this shader and into its view pass, so the two
|
||||||
|
// blocks differ in size for a reason that is not this one.
|
||||||
let mut ops = crate::ops::chain();
|
let mut ops = crate::ops::chain();
|
||||||
ops.push(Box::new(crate::detail::probe::BoxBlur::with_radius(0.05)));
|
ops.push(Box::new(crate::detail::probe::BoxBlur::with_radius(0.05)));
|
||||||
let before = compose(&crate::ops::chain()).uniforms.len();
|
let fused = compose(&ops);
|
||||||
assert_eq!(compose(&ops).uniforms.len(), before);
|
assert!(!fused.source.contains("detail_probe_"), "{}", fused.source);
|
||||||
|
assert!(fused.uniforms.len() <= compose(&crate::ops::chain()).uniforms.len());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -347,8 +347,15 @@ impl Operation for CaptureSharpen {
|
|||||||
|
|
||||||
impl DetailStage for CaptureSharpen {
|
impl DetailStage for CaptureSharpen {
|
||||||
fn passes(&self, scale: RenderScale) -> Vec<DetailPass> {
|
fn passes(&self, scale: RenderScale) -> Vec<DetailPass> {
|
||||||
|
// A radius finer than one pixel of this render: the detail it would
|
||||||
|
// act on is not in this texture — it was lost to the downscale before
|
||||||
|
// this stage ran (FR-DSP-1). Guessing at it would put sharpening on
|
||||||
|
// screen that the exported file will not contain, so there is no
|
||||||
|
// pass, and the interface is free to say `zoom to 1:1`. An empty
|
||||||
|
// chain is a whole render since D19: the fused pass's view pass
|
||||||
|
// performs the output transform whatever the chain holds.
|
||||||
if !self.resolves(scale) {
|
if !self.resolves(scale) {
|
||||||
return vec![nothing_to_sharpen()];
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
let extent = self.kernel(scale);
|
let extent = self.kernel(scale);
|
||||||
@@ -396,41 +403,6 @@ impl DetailStage for CaptureSharpen {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The pass emitted when the radius is finer than a render pixel.
|
|
||||||
///
|
|
||||||
/// One dispatch that changes nothing, rather than an empty chain, and the
|
|
||||||
/// difference is not stylistic. [`crate::operation::compose_full`] decides
|
|
||||||
/// from the *operations* — before any resolution is known — that an active
|
|
||||||
/// detail operation means the fused pass hands on unclipped linear values
|
|
||||||
/// instead of encoding its own output. If this returned no passes at all,
|
|
||||||
/// that decision would still stand and nothing downstream would ever perform
|
|
||||||
/// the output transform: `dr-gpu` would be handed a linear-working shader
|
|
||||||
/// with an empty chain and refuse it.
|
|
||||||
///
|
|
||||||
/// So the honest "nothing survives at this scale" still has to carry the
|
|
||||||
/// encode, and one pass that does only that is exactly the resolve step the
|
|
||||||
/// stage would otherwise need. It costs a single copy of a proxy-sized
|
|
||||||
/// texture, which is a rounding error against the dispatches around it.
|
|
||||||
fn nothing_to_sharpen() -> DetailPass {
|
|
||||||
DetailPass {
|
|
||||||
output_scale: 1,
|
|
||||||
label: "unresolved",
|
|
||||||
// Reads only the pixel it writes, so a tile needs no halo at all.
|
|
||||||
radius: 0,
|
|
||||||
// A convolution, not a list: nothing to bind at binding 3.
|
|
||||||
storage: Vec::new(),
|
|
||||||
uniforms: Vec::new(),
|
|
||||||
wgsl: "// The chosen radius is finer than one pixel of this render, so the detail
|
|
||||||
// it would act on is not in this texture — it was lost to the downscale
|
|
||||||
// before this stage ran (FR-DSP-1). Guessing at it would put sharpening on
|
|
||||||
// screen that the exported file will not contain, so this pass passes the
|
|
||||||
// colour through unchanged and the interface is free to say `zoom to 1:1`.
|
|
||||||
//
|
|
||||||
// `c` already holds this pixel; leaving it alone is the whole body."
|
|
||||||
.to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One axis of the separable unsharp mask.
|
/// One axis of the separable unsharp mask.
|
||||||
///
|
///
|
||||||
/// Emitted verbatim for both passes — see [`DetailStage::passes`] for why the
|
/// Emitted verbatim for both passes — see [`DetailStage::passes`] for why the
|
||||||
@@ -528,7 +500,6 @@ c = select(c, scaled, centre > 1e-5);"#;
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::EditGraph;
|
use crate::EditGraph;
|
||||||
use dr_types::ColourSpace;
|
|
||||||
|
|
||||||
/// The develop chain with the sharpener turned up.
|
/// The develop chain with the sharpener turned up.
|
||||||
///
|
///
|
||||||
@@ -549,7 +520,7 @@ mod tests {
|
|||||||
// The scale is what these tests vary, so it is rebuilt into the two
|
// The scale is what these tests vary, so it is rebuilt into the two
|
||||||
// sizes it stands for rather than handed over: a render of the full
|
// sizes it stands for rather than handed over: a render of the full
|
||||||
// frame at `render_size`, from a source of `full_size`.
|
// frame at `render_size`, from a source of `full_size`.
|
||||||
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb)
|
graph.compose_detail(scale.full_size(), scale.render_size())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -591,13 +562,11 @@ mod tests {
|
|||||||
assert_eq!(first.label, "capture_sharpen/horizontal");
|
assert_eq!(first.label, "capture_sharpen/horizontal");
|
||||||
assert_eq!(last.label, "capture_sharpen/vertical");
|
assert_eq!(last.label, "capture_sharpen/vertical");
|
||||||
|
|
||||||
assert!(!first.writes_output);
|
// Neither encodes: the view pass after the detail stage does (D19).
|
||||||
assert!(first.source.contains("texture_storage_2d<rgba16float"));
|
for pass in [first, last] {
|
||||||
assert!(!first.source.contains("fn encode_output"));
|
assert!(pass.source.contains("texture_storage_2d<rgba16float"));
|
||||||
|
assert!(!pass.source.contains("fn encode_output"));
|
||||||
assert!(last.writes_output);
|
}
|
||||||
assert!(last.source.contains("texture_storage_2d<rgba8unorm"));
|
|
||||||
assert!(last.source.contains("fn encode_output"));
|
|
||||||
|
|
||||||
// Two shaders, so two pipeline-cache entries. Sharing one would run
|
// Two shaders, so two pipeline-cache entries. Sharing one would run
|
||||||
// the horizontal pass's uniforms through the vertical pass's slots.
|
// the horizontal pass's uniforms through the vertical pass's slots.
|
||||||
@@ -723,20 +692,11 @@ mod tests {
|
|||||||
let proxy = RenderScale::new((1000, 1000), (4000, 4000));
|
let proxy = RenderScale::new((1000, 1000), (4000, 4000));
|
||||||
assert!(!proxy.resolves(1.0));
|
assert!(!proxy.resolves(1.0));
|
||||||
|
|
||||||
|
// Empty: since D19 nothing in the detail stage encodes, so a chain
|
||||||
|
// with nothing to draw is a whole render — the fused pass's view pass
|
||||||
|
// finishes it.
|
||||||
let composed = chain_at(&graph, proxy);
|
let composed = chain_at(&graph, proxy);
|
||||||
// Not empty, though. See `nothing_to_sharpen`: the fused pass has
|
assert!(composed.is_empty());
|
||||||
// already been composed to hand on linear values, so *something* must
|
|
||||||
// still perform the output transform.
|
|
||||||
assert_eq!(composed.len(), 1);
|
|
||||||
assert_eq!(composed.radius(), 0, "it reads no neighbours");
|
|
||||||
let pass = &composed.passes[0];
|
|
||||||
assert_eq!(pass.label, "capture_sharpen/unresolved");
|
|
||||||
assert!(pass.writes_output);
|
|
||||||
assert!(pass.source.contains("fn encode_output"));
|
|
||||||
assert!(
|
|
||||||
!pass.source.contains("for (var i ="),
|
|
||||||
"the pass-through must not walk a kernel it has decided not to run"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Zooming to 1:1 is what brings it back — the view rect shrinks while
|
// Zooming to 1:1 is what brings it back — the view rect shrinks while
|
||||||
// the render target keeps its size — so there is no separate
|
// the render target keeps its size — so there is no separate
|
||||||
|
|||||||
@@ -541,14 +541,13 @@ c = (c - lifted) / t;";
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::detail::compose_detail;
|
use crate::detail::compose_detail;
|
||||||
use dr_types::ColourSpace;
|
|
||||||
|
|
||||||
fn ops(amount: f32) -> Vec<Box<dyn Operation>> {
|
fn ops(amount: f32) -> Vec<Box<dyn Operation>> {
|
||||||
vec![Box::new(Dehaze::with_amount(amount))]
|
vec![Box::new(Dehaze::with_amount(amount))]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn composed(amount: f32, scale: RenderScale) -> crate::ComposedDetail {
|
fn composed(amount: f32, scale: RenderScale) -> crate::ComposedDetail {
|
||||||
compose_detail(&ops(amount), scale, ColourSpace::Srgb)
|
compose_detail(&ops(amount), scale)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -658,11 +657,6 @@ mod tests {
|
|||||||
let split = Split::of(Dehaze::with_amount(60.0).patch(RenderScale::full((2000, 1500))));
|
let split = Split::of(Dehaze::with_amount(60.0).patch(RenderScale::full((2000, 1500))));
|
||||||
assert!(composed.passes.iter().all(|p| p.radius == split.extent()));
|
assert!(composed.passes.iter().all(|p| p.radius == split.extent()));
|
||||||
|
|
||||||
// Only the last writes the display texture, so the output transform
|
|
||||||
// happens exactly once (FR-DEV-2).
|
|
||||||
assert!(!composed.passes[0].writes_output);
|
|
||||||
assert!(composed.passes[1].writes_output);
|
|
||||||
|
|
||||||
// Nothing here uses the reduced chain — see the module documentation
|
// Nothing here uses the reduced chain — see the module documentation
|
||||||
// for why a second operation cannot pick its own `output_scale` while
|
// for why a second operation cannot pick its own `output_scale` while
|
||||||
// the runner holds one reduced buffer.
|
// the runner holds one reduced buffer.
|
||||||
|
|||||||
@@ -878,7 +878,6 @@ c = c * exp2(stops);"
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::detail::compose_detail;
|
use crate::detail::compose_detail;
|
||||||
use dr_types::ColourSpace;
|
|
||||||
|
|
||||||
/// The two controls, as the graph would hold them.
|
/// The two controls, as the graph would hold them.
|
||||||
fn ops(clarity: f32, texture: f32) -> Vec<Box<dyn Operation>> {
|
fn ops(clarity: f32, texture: f32) -> Vec<Box<dyn Operation>> {
|
||||||
@@ -889,7 +888,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn composed(clarity: f32, texture: f32, scale: RenderScale) -> crate::ComposedDetail {
|
fn composed(clarity: f32, texture: f32, scale: RenderScale) -> crate::ComposedDetail {
|
||||||
compose_detail(&ops(clarity, texture), scale, ColourSpace::Srgb)
|
compose_detail(&ops(clarity, texture), scale)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -615,7 +615,6 @@ c = vec3<f32>(y0) + chroma_sum / weight_sum;";
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use dr_types::ColourSpace;
|
|
||||||
|
|
||||||
/// A 24 MP frame, and the panel a develop view might show it in.
|
/// A 24 MP frame, and the panel a develop view might show it in.
|
||||||
const FULL: (u32, u32) = (6000, 4000);
|
const FULL: (u32, u32) = (6000, 4000);
|
||||||
@@ -629,7 +628,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn compose(op: NoiseReduction, scale: RenderScale) -> crate::detail::ComposedDetail {
|
fn compose(op: NoiseReduction, scale: RenderScale) -> crate::detail::ComposedDetail {
|
||||||
crate::detail::compose_detail(&chain_with(op), scale, ColourSpace::Srgb)
|
crate::detail::compose_detail(&chain_with(op), scale)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -674,11 +673,6 @@ mod tests {
|
|||||||
// The luminance pass runs first, so the chroma guide is the denoised
|
// The luminance pass runs first, so the chroma guide is the denoised
|
||||||
// luminance rather than the raw one.
|
// luminance rather than the raw one.
|
||||||
assert_eq!(both.passes[0].label, "noise_reduction/luminance");
|
assert_eq!(both.passes[0].label, "noise_reduction/luminance");
|
||||||
// And only the last pass in the whole chain performs the output
|
|
||||||
// transform, whichever pass that happens to be.
|
|
||||||
assert!(!both.passes[0].writes_output);
|
|
||||||
assert!(!both.passes[1].writes_output);
|
|
||||||
assert!(both.passes[2].writes_output);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+28
-28
File diff suppressed because one or more lines are too long
@@ -139,17 +139,15 @@ impl DevelopSession {
|
|||||||
/// almost every edit, so this costs nothing to the frames that do not
|
/// almost every edit, so this costs nothing to the frames that do not
|
||||||
/// need it.
|
/// need it.
|
||||||
///
|
///
|
||||||
/// `space` has to be the space `shader` was composed for. It is the last
|
/// The output space is `shader`'s alone: since D19 no detail pass encodes,
|
||||||
/// pass of the detail chain that performs the output transform when there
|
/// and the view pass `shader` carries performs the output transform, so
|
||||||
/// is one, so the two would otherwise be free to disagree about which
|
/// the two halves cannot disagree about which primaries the file is in
|
||||||
/// primaries the file is in — and the result would be a correctly
|
/// (FR-EXP-2).
|
||||||
/// labelled file with the wrong colours in it (FR-EXP-2).
|
|
||||||
pub(super) fn render_with_masks(
|
pub(super) fn render_with_masks(
|
||||||
&mut self,
|
&mut self,
|
||||||
shader: &dr_pipeline::operation::ComposedShader,
|
shader: &dr_pipeline::operation::ComposedShader,
|
||||||
w: u32,
|
w: u32,
|
||||||
h: u32,
|
h: u32,
|
||||||
space: dr_types::ColourSpace,
|
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let ctx = self.ctx.clone();
|
let ctx = self.ctx.clone();
|
||||||
self.ensure_subject_fields(&ctx);
|
self.ensure_subject_fields(&ctx);
|
||||||
@@ -173,9 +171,7 @@ impl DevelopSession {
|
|||||||
// Empty for every edit with no active neighbourhood operation — which
|
// Empty for every edit with no active neighbourhood operation — which
|
||||||
// is almost all of them — and `render_detailed` then falls straight
|
// is almost all of them — and `render_detailed` then falls straight
|
||||||
// through to the single masked dispatch this used to call.
|
// through to the single masked dispatch this used to call.
|
||||||
let detail = self
|
let detail = self.graph.compose_detail(self.demosaiced.size(), (w, h));
|
||||||
.graph
|
|
||||||
.compose_detail_for(self.demosaiced.size(), (w, h), space);
|
|
||||||
// Detail passes read what the colour pass wrote, so the key they are
|
// Detail passes read what the colour pass wrote, so the key they are
|
||||||
// cached against is the colour key: moving a sharpening slider re-runs
|
// cached against is the colour key: moving a sharpening slider re-runs
|
||||||
// this stage and not the fused one (FR-DEV-3d).
|
// this stage and not the fused one (FR-DEV-3d).
|
||||||
@@ -266,12 +262,7 @@ impl DevelopSession {
|
|||||||
|
|
||||||
// Rasterise the masks first: the shader addresses array slices by
|
// Rasterise the masks first: the shader addresses array slices by
|
||||||
// index, so the array has to describe *this* stack before it is bound.
|
// index, so the array has to describe *this* stack before it is bound.
|
||||||
//
|
self.render_with_masks(&shader, w, h)?;
|
||||||
// The same `space` to both, necessarily: where a detail stage exists
|
|
||||||
// it is the *last* pass that performs the output transform, and two
|
|
||||||
// halves composed for different spaces would encode the frame twice
|
|
||||||
// or not at all.
|
|
||||||
self.render_with_masks(&shader, w, h, space)?;
|
|
||||||
let texture = self.adjust.output().ok_or("nothing was rendered")?;
|
let texture = self.adjust.output().ok_or("nothing was rendered")?;
|
||||||
|
|
||||||
// The import is fallible on format and usage only, and both are fixed
|
// The import is fallible on format and usage only, and both are fixed
|
||||||
@@ -658,7 +649,7 @@ impl DevelopSession {
|
|||||||
// uniform baked at composition, so composing before this point would
|
// uniform baked at composition, so composing before this point would
|
||||||
// restore the framing and export the zoom anyway.
|
// restore the framing and export the zoom anyway.
|
||||||
let shader = self.graph.compose_for(space);
|
let shader = self.graph.compose_for(space);
|
||||||
let rendered = self.render_with_masks(&shader, w, h, space);
|
let rendered = self.render_with_masks(&shader, w, h);
|
||||||
|
|
||||||
self.graph.framing_mut().set_view(saved_view);
|
self.graph.framing_mut().set_view(saved_view);
|
||||||
rendered?;
|
rendered?;
|
||||||
|
|||||||
Reference in New Issue
Block a user