The stock model landed in dr-film with no way to see it. This is the pipeline node, the two texture bindings it reads, and the end-to-end test that proves the shader agrees with the model. The design point is that a film simulation is not an adjustment. Every other node changes a picture; this one makes it. A stock's characteristic curve does the camera profile's base curve's job -- from measurements rather than from a curve somebody drew -- so running both renders the scene twice: the camera's rendering, and then a film's rendering of that. It looks like neither, and it reads as a colour-management bug with no colour-management bug to find. So `Operation::renders` is new. A node declaring it takes camera RGB and hands back linear sRGB, and the composer emits neither the base curve nor the conversion out of camera space. Both halves move together, and the composer keeps them as one string precisely so that getting half of it right is impossible. The tables are not parameters, for the reason vignetting's coefficients are not: they are measurements. dr-pipeline declares the layout as a plain struct and keeps its no-dependency property; the two crates share no types on purpose. `EditGraph::set_film_tables` offers them to every node rather than to the one that wants them, because knowing which concrete type is which is what the graph is organised not to know. Bindings 4 and 5 follow the masks precedent: declared unconditionally so one bind group layout serves every generated shader, bound to 1x1 placeholders when no stock is loaded. Both are interpolated by hand with textureLoad -- this pipeline binds no sampler, and adding one for two lookups would cost a binding in every shader. Uploads are keyed on content so an unchanged stock does not push half a megabyte across the bus per frame. The end-to-end test earned its place immediately: it found the density lookup being filled z-fastest while a 3D texture upload wants x-fastest, so the red and blue axes were transposed. Green matched exactly, which is what that bug looks like -- a plausible photograph of the wrong colour, and one that every unit test on either side of the seam passes. dr-film now pins the layout in a test that needs no device, and states it where the field is declared. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
328 lines
15 KiB
Markdown
328 lines
15 KiB
Markdown
# 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
|
||
`&'static OpDescriptor`, the same fused-shader composition, the same sidecar
|
||
round-trip.
|
||
|
||
## `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` takes camera RGB and hands back linear sRGB, and
|
||
in exchange the composer emits neither the base curve nor the conversion out of
|
||
camera space. Both halves move to the node, together: the base curve is defined
|
||
in camera RGB and the matrix is what leaves it, so a node replacing one has
|
||
necessarily replaced the other. `compose_full` keeps them as a single string
|
||
for exactly that reason — it is what makes getting half of it right impossible. `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
|
||
|
||
Three things act on every pixel and are deliberately not in this directory:
|
||
the as-shot white balance, the camera matrix, and the **base curve**
|
||
(FR-DEV-3e). They are emitted by [`../src/operation.rs`](../src/operation.rs)
|
||
into the composed shader's fixed preamble, around the block of nodes.
|
||
|
||
The test is not "does it transform a colour" — all three do. It is **whose
|
||
decision is it**. A node is something a photographer chose: it has parameters,
|
||
it moves off a neutral, it lands in the sidecar, it can be undone. These three
|
||
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 or the body's rendering; they are what reading the file
|
||
correctly means.
|
||
|
||
Making the base curve a node would have said the opposite in four places at
|
||
once. It would have appeared in the develop panel as a control, so an
|
||
unprofiled body would show a slider that does nothing. Its values would have
|
||
gone into the sidecar, and sidecars are shared between devices and bodies
|
||
(FR-NC-9) — one camera's rendering would follow an edit onto another camera's
|
||
file. Its neutral would have had to be "the identity", so a profiled body would
|
||
open reporting itself modified. And there is no seam through which a node could
|
||
learn which camera took the frame: the profile arrives on the decoded image,
|
||
travels through `DemosaicedImage` beside the matrix it belongs with, and is
|
||
written into the uniform block by the same three lines in `dr-gpu` — which is
|
||
exactly the path the matrix already took, because it is exactly the same kind
|
||
of thing.
|
||
|
||
What it *does* share with the tone curve node is the spline. The composer asks
|
||
`ToneCurve` for its `curve_span`/`curve_eval` helpers rather than emitting a
|
||
second copy, so a profile author placing a control point and a photographer
|
||
dragging one mean the same thing by it.
|
||
|
||
The order still reads correctly from this directory: the base curve runs after
|
||
every node in the chain and before the conversion out of camera space. That is
|
||
the same reasoning `exposure` records under `placement:` — corrections to
|
||
capture are only meaningful on linear values, so the rendering goes last.
|
||
|
||
## 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/`.
|