Files
DarkRoom/core/dr-pipeline/ops/README.md
T
dtourolle 92afaebd34 Make the view transform an operation the photographer can set
FR-DEV-3j gives the view transform two controls, contrast and the white
point in stops above middle grey, persisted and held per mask layer like
any other setting. A scene-referred pipeline whose white point cannot be
moved hands the photographer a shoulder they cannot place.

`view_transform` is a hand-written node in `Stage::View`, a new stage
the composer emits last and emits whatever the node's state: a neutral
operation is otherwise left out of the shader, but a photograph with no
view transform is a scan. "Active" keeps meaning "moved from the
defaults", so an untouched photograph writes nothing for it and
`every_node_starts_neutral` still holds. A caller whose chain holds no
view operation gets a default one.

The composer's loop becomes an ordered list of steps — camera nodes, the
matrix, scene nodes, layer-only nodes, the view — so each is emitted in
exactly one place. The base curve could not be a node because it
belonged to the camera; the ops README records why that argument went
with it (D19). The panel shows it in the Light group as "Tone Mapping".

Tests that counted the blocks of a neutral graph now count one, the
view transform, and the two chain-wide tests that load a film expect the
view transform to be absent, since a stock replaces it.
2026-09-27 16:52:54 -04:00

338 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Develop nodes
One file per operation. Adding a node to the pipeline is adding a file to this
directory — there is no list to extend, no shader to edit, and no UI change.
`../build.rs` compiles each declaration into Rust implementing
[`Operation`](../src/operation.rs), generated into `OUT_DIR`. The result is
indistinguishable downstream from a hand-written operation: the same
`Arc<OpDescriptor>`, the same fused-shader composition, the same sidecar
round-trip.
**The same declaration also runs without being compiled** (FR-PLG-2).
[`DeclaredOp`](../src/declared/mod.rs) reads this format at *load* time and
implements `Operation` from it directly — one interpreter over many
declarations, where `build.rs` emits generated code per node. Both paths exist
on purpose: the built-ins stay compiled, because a generated `match` is faster
than an interpreted one and because the `tests:` blocks below have to run under
`cargo test`.
The reading half is one file, [`src/declared/decl.rs`](../src/declared/decl.rs),
shared by both — so everything documented here means exactly one thing, and
`tests/declared_parity.rs` asserts the two backends compose byte-identical WGSL
for every node in this directory. Nothing in this document is specific to the
build-time path.
## `attributes:` — what the operation is about
Required, one or more of `tone`, `colour`, `detail`, `optics`, `geometry`,
`effect`. The build fails without it, and that is deliberate: an operation with
no attribute is invisible to an interface that groups by them, and a control
that silently does not exist is a worse failure than a build that stops.
**Say what the operation is, not where you want it drawn.** The core declares
capabilities; the frontend composes (ARCH §4.3a). `tone` means "this is about
lightness", not "put this in the second tab" — the interface is free to render
attributes as tabs, as headings, or not at all, and that choice may differ
between a tablet and a desktop without this file changing.
**Plural is normal.** The tone curve is `[tone, colour]` because an RGB curve
is tonal and the per-channel curves are chromatic; it appears wherever the
frontend decides that means. Reach for a second attribute when an operation
genuinely answers two questions, not to make it easier to find.
**The vocabulary is closed.** A typo is a build error rather than a new
category containing exactly one operation — which looks identical to a
deliberate new category until somebody notices the group with one control in
it.
## What you get for free
A node dropped in here arrives with:
- **Controls.** The develop panel builds them from the parameters' declared
kinds (FR-DEV-3c). It never learns the node's name.
- **A place in the chain**, from `order:`.
- **A place in the panel**, from `attributes:`. The interface groups by these
and names no operation, so a node arrives in the right group by saying what
it is rather than by anyone editing a list in `ui/`.
- **Persistence.** Parameters are ordinary scalars, so the sidecar writes them
with everything else.
- **Its tests**, compiled from `tests:` and run with `cargo test`.
- **Neutral-means-absent.** At its defaults the node contributes no code, no
uniform, and no branch to the generated shader.
## The shape of a file
```yaml
id: exposure # must match the filename
label: op.exposure # a localisation key, never a display string
order: 20 # where it sits in the chain
attributes: [tone] # what it is about; one or more, and required
doc: | # becomes the generated module's documentation
Exposure — a linear gain, expressed in stops.
placement: | # why it sits where it does; appears above the
A correction to capture. # entry in the generated chain
params:
exposure:
label: param.exposure
kind: stops
min: -5
max: 5
doc: | # optional prose, kept with the descriptor
±5 stops.
uniforms:
gain: exp2(exposure) # shorthand, or a mapping with `value:` and `doc:`
helpers: [luminance] # names from _helpers.yaml
define: # helpers this node alone needs
my_curve: |
fn my_curve(x: f32) -> f32 { return x; }
wgsl: | # `c` is linear RGB in and out
c = c * gain;
presentation: # optional; omit for one control per parameter
widgets: [tone_curve] # a hint, most preferred first
params: [exposure] # what that widget owns
tests:
- name: one_stop_is_a_doubling
why: The definition of a stop.
set: { exposure: 1 }
expect: { gain: 2.0 }
```
### Parameter kinds
| `kind` | Shape | Extra keys |
|---|---|---|
| `amount` | −100…100, neutral at 0 — the familiar photographic control | — |
| `stops` | exposure-like, in stops | `min`, `max` |
| `fraction` | 0…1 | `default` |
| `switch` | a toggle, neutral off | — |
| `enum` | one of a short, fixed list | `variants` |
| `scalar` | anything else | `min`, `max`, `default`, `unit`, `scale`, `precision` |
Reach for `amount` first. Writing its range out by hand in each node is how one
of them comes to disagree with the rest.
An `enum` names its choices as localisation keys, most-neutral first:
```yaml
params:
method:
label: param.method
kind: enum
variants: [param.method.fast, param.method.exact]
```
The value is the chosen **index**, carried as an `f32` like every other
parameter — so a uniform expression can use it (`method` is 0 or 1), the
sidecar stores it unchanged, and nothing along the way needed a second kind of
value. The default is always the first variant, so `reset` means what it means
everywhere else; if your neutral choice is not first, reorder the list rather
than reaching for a `default:`.
Two variants is the minimum. A one-entry list is a control the user cannot
change, and the build rejects it.
### Uniform expressions
Arithmetic (`+ - * /`), parentheses, numbers, this node's parameters, and:
`exp2` `log2` `exp` `sqrt` `abs` `floor` `ceil` `round` `pow` `min` `max`
`clamp` `mix`.
Compiled to Rust, so an unknown name or a wrong arity is a build error naming
the file and the key. Deliberately small: a node is a description, and letting
it name arbitrary Rust would make this a second, worse place to write code.
### Activity
By default a node is active exactly when some parameter has moved off its
default, which is the honest rule and the right one for nearly everything. A
node whose neutral is something else declares `active:` as an expression —
non-zero means active.
### Presentation
Optional, and absent from nearly every node: one control per parameter is the
right answer for a list of unrelated sliders, which is what most operations
are. Declare it when several parameters form **one** conceptual control.
```yaml
presentation:
widgets: [colour_wheel] # most preferred first
demand:
two_dimensional: true # dragged in x and y at once
precise_pointing: false # needs accuracy finer than a fingertip
params: [hue, strength] # what the widget owns, in its order
```
Widgets: `tone_curve`, `colour_wheel`, `crop_overlay`, `gradient_handle`,
`brush_mask`, `white_point`.
**It is a hint and only a hint.** The frontend walks `widgets` and takes the
first it both implements and can afford; if it implements none of them, or
cannot meet the `demand`, it renders the parameters as ordinary sliders and the
edit still works. So it is safe to name a widget nothing draws yet — the node
degrades to sliders rather than breaking. That fallback is also why every
parameter must stay an ordinary scalar: it is what there is to fall back *to*.
`demand` says what the widget inherently needs, never what the screen has.
There is deliberately no way to write a pixel width, a breakpoint or a platform
name here — those are the frontend's to decide, and a node that reasoned about
them would eventually be wrong about a display it never saw (ARCH §4.3a).
Parameters named in `params:` are claimed by the widget and not drawn
separately, so a typo would take a parameter out of both. The build checks each
name against the node's own `params:` and fails if it does not match.
### Tests
| Key | Asserts |
|---|---|
| `set` | parameter values to apply first; build fails if out of range |
| `expect` | uniform values, within 1e-5 |
| `expect_range` | a uniform lies in `[low, high]` |
| `expect_active` | whether the node reaches the shader |
| `expect_wgsl` | substrings the fragment must contain |
| `expect_helper_wgsl` | substrings a named helper must contain |
`why:` becomes a comment in the generated test. Use it — a test named for a
property should say what breaks without it.
## Hand-written nodes
Some operations are not four facts, and forcing them into this schema would
produce a worse language aimed at one caller. Those stay in
[`../src/ops/`](../src/ops/) and declare only their position here:
```yaml
id: tone_curve
order: 60
rust: ToneCurve
why_rust: |
Its neutral is a relationship between five points, not a set of values.
```
They still belong in this directory, because the pipeline's **order** is the
one thing a reader comes here to learn, and an order written half in YAML and
half in Rust would be worse than either alone.
Currently hand-written: `tone_curve` (one widget over four curves of five
interpolated points — master, red, green, blue — each reaching the shader only
when it has been moved), `colour_mixer` (thirty-six faceted parameters from
twelve computed hue bands), `film_sim` (a stock's measured tables, which are
not parameters, and the one node that declares `Operation::renders` — see
below), `capture_sharpen` (a separable convolution) and `noise_reduction` (a
kernel, and one that decides how many dispatches to emit at each resolution) —
the last two for the reason the next section gives. `vignetting` is
hand-written too but is not in the develop chain — it carries lens-profile
coefficients that are not parameters.
## The node that renders
`film_sim` is the only operation that returns `true` from
`Operation::renders`, and it is worth knowing why before writing a second one.
Every other node *adjusts* a picture. That one *makes* it: a film stock's
characteristic curve does the camera profile's base curve's job, from
measurements rather than from a curve somebody drew. Running both renders the
scene twice — the camera's rendering, and then a film's rendering of *that* —
which looks like neither and reads as a colour-management bug with no
colour-management bug to find.
So a node declaring `renders` hands back display-referred linear sRGB, and in
exchange the composer does not emit the base curve. It is handed working-space
colour like every other node: the conversion out of camera space is no longer
the rendering's to take over, because since D19 it runs before every node but
white balance (see "Stages" below). `distortion` and
`aberration` are `Warp`s rather than operations: they rewrite coordinates
before sampling rather than transforming a colour after it.
## Nodes that read their neighbours
`wgsl:` above is handed `c`, a colour, and no coordinate. That is what makes
the fused dispatch possible and it is also a wall: sharpening, noise reduction,
clarity, texture, dehaze and spot removal are all defined by what the
*neighbouring* pixels are doing, and none of them can be written as a function
of `c` at any price.
They go in the **detail stage**, which runs after the fused pass, in linear
light, at render resolution, before the output transform — see
[`../src/detail.rs`](../src/detail.rs) for why each of those is a decision
rather than a convenience. A node of this kind:
- is declared here with `rust:`, like any other hand-written node, because a
kernel is not four facts and stretching this schema to cover one would
produce a worse language than Rust;
- implements `Operation` as usual — descriptor, parameters, `is_active` — so
the panel, the sidecar, the history and the presets all work unchanged;
- returns `Affects::Detail` from `affects()` and `Some(self)` from `detail()`;
- implements `DetailStage::passes`, returning one `DetailPass` per dispatch,
each with a WGSL body, its uniforms, and **its kernel radius in render
pixels**, which the tile scheduler needs and nothing can infer.
`capture_sharpen` is the worked example: two passes, one per axis, and a radius
converted from source pixels once per render.
The `order:` still belongs here, and still orders the node — among the other
detail nodes. Detail runs as a group after every point operation, so an `order:`
that interleaves one with exposure would be a lie the chain cannot tell.
The one thing to get right is the **unit of a radius**. Never store pixels: a
length is either a fraction of the frame's shorter edge (`RenderScale::
frame_fraction` — clarity, texture, the unit a mask feather already uses) or a
count of source pixels (`RenderScale::source_pixels` — capture sharpening,
luminance NR). `passes()` is given the scale and converts on the CPU. A radius
in raw pixels is a different photograph on screen and in the exported file.
## What is not a node, and why
Two things act on every pixel and are deliberately not in this directory: the
as-shot white balance and the camera matrix. They are emitted by
[`../src/operation.rs`](../src/operation.rs) into the composed shader around the
block of nodes. They are properties of the *file*, at the same standing as the
masked-photosite crop (FR-RAW-3) and the stored orientation (FR-DEV-3h): nobody
chose the sensor's green sensitivity, and reading the file correctly means
undoing it.
The **view transform** (FR-DEV-3j) *is* a node — `view_transform.yaml`, a
`rust:` one — and that is a change of mind worth knowing about. It replaced the
per-body base curve, which was kept out of this directory because it belonged
to the camera: as a node it would have carried one body's rendering onto
another body's file through a shared sidecar. D19 retired the per-body curves,
and with them the argument. One view transform serves every body, so its
settings are a decision about the picture like any other. What is still
special about it is `Stage::View`: the composer emits it at the end of the
chain *whatever its state*, because a photograph with no view transform is a
scan and not a picture. Its neutral is its defaults, like every other node's,
so an untouched photograph writes nothing for it.
## Stages
`stage: camera` puts a node in camera RGB, ahead of the camera matrix; the
default, `stage: scene`, hands it working-space colour — linear sRGB
primaries, scene-referred and unbounded. White balance is the only camera
node, because its multipliers scale the sensor's own channels. Everything else
belongs in the scene, where a hue or a luminance weight means the same thing
whichever body took the frame (D19). The composer emits the camera nodes, then
the matrix, then the scene nodes, each group in `order:`, and the view
transform last. `stage: view` is not offered to a declaration: a node that
maps into a display range is exactly what ARCH §6.14 forbids of everything
before the end, and the one that is allowed to is hand-written.
## Errors
The build script reports failures by naming the key you got wrong, and exits
rather than panicking, so the message is the message and not a backtrace. It
refuses: a duplicate `order:`, a filename disagreeing with its `id:`, a default
outside its own range, a test value the graph would clamp before the node saw
it, a helper that does not define the function it names, an expression naming
something that is not a parameter, and a declared node whose name collides with
a file in `../src/ops/`.