FR-CULL-3's focus peaking. One compute dispatch measures local contrast in WGSL and writes an overlay texture; on desktop it reaches Slint through the same zero-copy wgpu import the canvas uses, so nothing per-pixel touches the CPU on the frame path. With peaking off the cost is zero and structurally so: focus_overlay opens with `let settings = self.peaking?;` before the frame is touched, and clearing drops both overlay textures, so no VRAM is held either. NFR-P14 is met by construction rather than by measurement -- one dispatch, no second render, no pipeline compile after session open, and a test asserting allocations stay at 2 over eight frames. The budget test asserts 50ms at 4K rather than a tight bound, deliberately: a tight bound fails on a loaded machine and gets deleted, which is worse than a loose one that still catches the regression that matters. TD-1 is amended rather than joined by a TD-6: on Android the overlay rides the readback that already exists there, roughly doubling that transfer while peaking is on, and TD-1's own "Done when" removes both because both are the same missing capability. Verified: cargo fmt clean; clippy --workspace --all-targets -D warnings green, which also compiles peaking.slint through dr-ui's build.rs; 11 focus GPU tests and 79 baseline dr-gpu tests pass; 511 dr-ui tests pass. Not verified: the cfg(target_os = "android") arm, which the host-target clippy never compiled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
306 lines
15 KiB
Markdown
306 lines
15 KiB
Markdown
# DarkRoom — Technical debt
|
||
|
||
**Status:** Living document · first written 2026-08-26
|
||
**Companion to:** [architecture.md](architecture.md)
|
||
|
||
Deliberate compromises: things the code does knowing they are wrong, because the alternative was
|
||
worse at the time. Each entry says what the debt is, what it cost to take on, what it would take to
|
||
pay off, and how you would know it had been paid.
|
||
|
||
Not a bug list. A bug is something nobody chose. Everything here was chosen, and the point of
|
||
writing it down is that the reasoning outlives whoever chose it — so the next person can tell a
|
||
constraint from an accident, and does not "fix" something load-bearing or preserve something that
|
||
has quietly stopped being necessary.
|
||
|
||
---
|
||
|
||
## TD-1 — The Android develop view reads pixels back through the CPU
|
||
|
||
**Breaks:** [architecture.md §12 / 6.1](architecture.md) — GPU results never round-trip through the
|
||
CPU — and AC-8, on Android only. Desktop is unaffected and keeps the zero-copy path.
|
||
|
||
### What it does
|
||
|
||
`DevelopSession::render` on Android runs the compute passes on the GPU as usual, then calls
|
||
`AdjustPass::export_pixels` and hands the frame to Slint as a `SharedPixelBuffer`. That is exactly
|
||
the GPU→CPU→GPU transfer §6.1 exists to forbid, and it is on the frame path.
|
||
|
||
### Why
|
||
|
||
Zero-copy needs Slint to draw with wgpu. On Android that means wgpu's Vulkan swapchain, which
|
||
hardcodes `preTransform = VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR`
|
||
([gfx-rs/wgpu#3345](https://github.com/gfx-rs/wgpu/issues/3345)) — wgpu-hal says so in a comment
|
||
beside the line.
|
||
|
||
On a tablet whose panel is mounted landscape, a portrait window then hands Android an unrotated
|
||
buffer, every present returns `VK_SUBOPTIMAL_KHR`, and frames arrive torn. Measured on the device,
|
||
same build, only the tablet rotated:
|
||
|
||
| orientation | `bufferTransform` | composition | result |
|
||
|---|---|---|---|
|
||
| landscape | `ROT_180` | `DEVICE (2)` | clean |
|
||
| portrait | `ROT_270` | `CLIENT (1)` | torn |
|
||
|
||
Setting `preTransform` is not a fix available to us: the field is a *promise* that the content is
|
||
already rotated, so honouring it needs the renderer to rotate what it draws, which wgpu cannot do
|
||
on Skia's behalf.
|
||
|
||
So the choice was never fast-develop against slow-develop. It was a develop view that costs a
|
||
readback against a grid that tears in the orientation a tablet is mostly held in.
|
||
|
||
### What it costs
|
||
|
||
Less than §6.1's headline numbers, because `render` fits the pass to the canvas before it runs — the
|
||
readback is at viewport resolution, not sensor resolution. The 7.43 ms at 4K in §12 is the ceiling,
|
||
not the bill. **It has not been measured on the device**, which is the first thing to do if the
|
||
develop view feels heavy on the tablet; do not assume this is the cause without a number.
|
||
|
||
### And a second transfer, while focus peaking is on
|
||
|
||
Added 2026-08-29 with FR-CULL-3. The focus-peaking overlay is a compute pass writing its own
|
||
`Rgba8Unorm` texture, which on desktop reaches the compositor with no copy — but on Android there is
|
||
no more a path for *that* texture than for the frame it belongs to, and an overlay that stayed on
|
||
the device while the picture underneath it did not would simply never be seen. So
|
||
`FocusPeakPass::read_overlay` follows the frame back through memory, and the Android frame path
|
||
carries **two** full-resolution `copy_texture_to_buffer` transfers instead of one.
|
||
|
||
This is recorded under TD-1 rather than as its own entry because it is not an independent choice.
|
||
It exists only because TD-1 exists, it is bounded by the same thing — `render` fits the pass to the
|
||
canvas, so both transfers are at viewport resolution — and TD-1's "Done when" already covers it:
|
||
whichever of the three fixes above lands removes the readback for the frame and the overlay
|
||
together, because both are the same missing capability.
|
||
|
||
Two things worth saying plainly. The doubling is **reasoned, not measured on the device** — the same
|
||
gap TD-1 admits about its own cost, and the reason neither number should be quoted as a measurement.
|
||
And it is paid only while the photographer has the overlay switched on: `DevelopSession::focus_overlay`
|
||
returns on its first line when peaking is off, so with it off there is no dispatch and no transfer,
|
||
and the Android frame path is exactly what it was before this feature existed.
|
||
|
||
### Paying it off
|
||
|
||
Any one of these removes it:
|
||
|
||
- wgpu implements pre-rotation (#3345), and Android goes back on `unstable-wgpu-29`.
|
||
- Slint's Skia Vulkan surface handles `preTransform` and Android uses that instead of OpenGL.
|
||
- Skia over OpenGL grows a way to sample an external texture that wgpu can write.
|
||
|
||
**Done when:** `ui/dr-ui/Cargo.toml` no longer scopes `renderer-femtovg-wgpu` and
|
||
`unstable-wgpu-29` to non-Android, the `#[cfg(target_os = "android")]` arm of
|
||
`DevelopSession::render` is gone, and the tablet is clean in portrait.
|
||
|
||
---
|
||
|
||
## TD-2 — Thumbnails are fetched one at a time
|
||
|
||
**Where:** `library::spawn_thumbnails` — the `for req in to_fetch` loop.
|
||
|
||
### What it does
|
||
|
||
The interactive thumbnail batch fetches serially: one image at a time, and two HTTP round trips
|
||
each (a header read, then the preview's byte range). A window of a few hundred cells is that many
|
||
sequential round trips against the server.
|
||
|
||
### Why it is debt rather than a bug
|
||
|
||
It is correct, and it was fast enough when a window was one screenful. It is the *ordering* that
|
||
kept it survivable: since `fetch_rank`, on-screen cells are requested first, so the cells a person
|
||
is looking at arrive first even though the queue as a whole is slow.
|
||
|
||
Portrait makes it worse by construction — a narrow window means smaller cells, more rows, and two
|
||
to three times as many cells on screen at once, all of them ahead of the ones below in a queue that
|
||
never runs more than one request.
|
||
|
||
### Paying it off
|
||
|
||
`spawn_thumbnail_sweep` already has the pattern: `SWEEP_LANES` disjoint lanes over a chunk, joined,
|
||
with the store written on the one thread that owns it. Striping a *priority-ordered* chunk across
|
||
lanes keeps `fetch_rank`'s ordering while running several requests at once.
|
||
|
||
Not done yet because it multiplies concurrent requests against the user's Nextcloud during a
|
||
scroll, and that is a behaviour change worth deciding on deliberately rather than inheriting from a
|
||
performance fix.
|
||
|
||
**Done when:** the interactive batch runs on more than one lane, priority order is preserved
|
||
across the lanes, and a slow server still cannot stall the visible cells behind offscreen ones.
|
||
|
||
---
|
||
|
||
## TD-3 — The thumbnail drain applies an unbounded batch on the UI thread
|
||
|
||
**Where:** `library_ui::drain_thumbnails` — the `loop` inside the timer callback.
|
||
|
||
### What it does
|
||
|
||
Every message queued when the timer fires is applied in that one callback, with no ceiling. On a
|
||
library whose thumbnails are already in the store, the worker delivers a whole window at once, so a
|
||
single callback can do hundreds of `to_slint_image` calls back to back — each an allocation and a
|
||
full RGBA copy — while the grid is mid-flick.
|
||
|
||
The copy cannot move off the UI thread: `slint::SharedPixelBuffer` is not `Send`, so decoded bytes
|
||
can only become an `Image` on the thread that draws. Only the *amount done per wake* is ours to
|
||
choose, and right now it is "all of it".
|
||
|
||
### Cost
|
||
|
||
Measured with a temporary probe, **debug build**, so treat the shape rather than the size:
|
||
|
||
| class | per thumbnail | × a 280-cell window |
|
||
|---|---|---|
|
||
| grid, 256 px | 1.93 ms | 539 ms |
|
||
| large, 512 px | 7.78 ms | 2.18 s |
|
||
|
||
A release measurement was started and never completed — do not quote these as release figures.
|
||
|
||
### Paying it off
|
||
|
||
A time budget per wake and a shorter interval: apply for a few milliseconds, return without
|
||
stopping the timer, and finish on the next tick. A batch then lands in frame-sized slices rather
|
||
than one lump between two frames. Draft written and discarded during the investigation; it is a
|
||
small change.
|
||
|
||
**Done when:** one wake of the drain cannot exceed a frame, and a fully-cached window still fills
|
||
in well under a second.
|
||
|
||
---
|
||
|
||
## TD-4 — The local-contrast base is computed at full render resolution ✅ PAID OFF
|
||
|
||
**Where:** `dr_pipeline::ops::local_contrast::LocalContrast::passes` — the `base` and `combine`
|
||
passes, and the stage that dispatches them, `dr_pipeline::detail`.
|
||
|
||
Breaks **FR-DSP-3** at large viewports. Measured, and the numbers are in
|
||
[frame-budget.md](frame-budget.md) §M3.
|
||
|
||
### What it does
|
||
|
||
Clarity's Gaussian σ is 1.2% of the frame's shorter edge, truncated at 2σ, so its kernel radius is
|
||
a property of the *viewport*: 29 px at 1920 × 1200, 38 px at 2560 × 1600, **52 px at 4K**. The two
|
||
separable passes therefore run 105 taps each over 8.3 M pixels at 4K, which is 1.7 billion texture
|
||
reads for one control.
|
||
|
||
| viewport | radius | clarity alone, p99 |
|
||
|---|---:|---:|
|
||
| 1920 × 1200 | 29 | 5.99 ms |
|
||
| 2560 × 1600 | 38 | 12.44 ms |
|
||
| 3840 × 2160 | 52 | **33.89 ms** |
|
||
|
||
RTX 3050 laptop, `examples/frame_budget`, fused dispatch reused so this is the convolutions alone.
|
||
Clarity is 97% of the cost of all four neighbourhood operations together at every size.
|
||
|
||
For scale: the entire fused chain — every point operation active, film stock included — costs
|
||
4.5 ms at the same 4K viewport. **A single slider is seven times the rest of the pipeline.**
|
||
|
||
### Why
|
||
|
||
Because the stage cannot do otherwise yet. `dr_pipeline::detail` dispatches every pass at the
|
||
render size; there is no way to express "read this target and write a smaller one". The module's own
|
||
documentation has said so since it was written:
|
||
|
||
> The right optimisation is a base computed at reduced resolution, which needs a detail stage that
|
||
> can write a smaller target than it reads; that is a change to `crate::detail`, not to this file.
|
||
|
||
It was the right call to ship the correct answer slowly rather than a fast approximation nobody had
|
||
checked — the halo behaviour is the hard part of this operation and it is tested.
|
||
|
||
### Not a tiling problem
|
||
|
||
Worth saying because ARCH §5.3 offers a tile cache and this is the stage that looks like it wants
|
||
one. It does not: a tiled convolution reads a halo per tile, so at a 52-pixel radius, 256-pixel
|
||
tiles would read (256 + 104)² instead of 256² — very nearly **twice** the taps.
|
||
[display-and-extension.md](display-and-extension.md) §2's decision rule was resolved on this
|
||
evidence; see [frame-budget.md](frame-budget.md).
|
||
|
||
### Paying it off
|
||
|
||
A detail pass that declares an output scale, so the base can be computed at a quarter resolution and
|
||
sampled back up in `combine`. A quarter-resolution base is 1/16 the pixels at 1/4 the radius —
|
||
about **1/64 of the work** — and is visually identical, because a base at σ = 26 px holds no content
|
||
above the quarter-resolution Nyquist to lose. Texture's σ is a decade finer and must stay at full
|
||
resolution; the scale therefore belongs on the `DetailPass`, not on the stage.
|
||
|
||
**Done when:** clarity at 100% is inside the frame budget at 3840 × 2160, the halo tests in
|
||
`tests/local_contrast.rs` still pass unchanged, and `examples/frame_budget`'s M3 table in
|
||
[frame-budget.md](frame-budget.md) has been rerun and committed.
|
||
|
||
### Paid off
|
||
|
||
A `DetailPass` now declares `output_scale`, and clarity's base is computed on a grid a quarter the
|
||
size on each axis. Measured before and after on the same machine, same adapter, same build profile,
|
||
with only the change between them — see [frame-budget.md](frame-budget.md) §"The reduced base,
|
||
measured":
|
||
|
||
| viewport, fit | before p50 | after p50 | | before p99 | after p99 |
|
||
|---|---:|---:|---:|---:|---:|
|
||
| 1920 × 1200 | 2.30 ms | 1.56 ms | 1.5× | 3.94 ms | 1.97 ms |
|
||
| 2560 × 1600 | 4.41 ms | 1.95 ms | 2.3× | 10.40 ms | 2.37 ms |
|
||
| 3840 × 2160 | **10.94 ms** | **3.88 ms** | **2.8×** | 25.05 ms | 4.17 ms |
|
||
|
||
**Quote the p50 column.** The baseline run's p99 figures are contaminated — its `fit` rows spread
|
||
2.3× between median and 99th percentile where the after run spreads 1.1×, and
|
||
[frame-budget.md](frame-budget.md)'s own independent measurement of the same baseline on the same
|
||
card reports 4.67 ms p99 at 2560 × 1600 against the 10.40 ms here. The p99 improvement is real and
|
||
larger than 2.8×; this run cannot say by how much.
|
||
|
||
Clarity is no longer the stage that misses the budget, and no longer dominates the neighbourhood
|
||
stage: at 4K it is 4.17 ms against 4.61 ms for all four neighbourhood operations together, where it
|
||
was 97% of that total at every size. That comparison is within one run, so the contention does not
|
||
touch it.
|
||
|
||
**Two things worth recording, because neither is visible in the table.**
|
||
|
||
The declared halo is now quantised to multiples of `output_scale`. The kernel truncates at 2σ and
|
||
that rounding now happens on the reduced grid, so 1920 × 1200 reports 28 render pixels where it
|
||
reported 29, and 2560 × 1600 reports 40 where it reported 38. At 2σ the Gaussian is already down to
|
||
`e⁻²` of its peak, and the cross-form test holds the difference to 0.03 stops of peak excursion and
|
||
2% of frame reach — but it is a real change in reach, not a pure speed-up, and a tile scheduler
|
||
would see it.
|
||
|
||
The measurement was taken on an AMD RX 5700 XT, not the RTX 3050 the M1/M2/M3 tables above were
|
||
measured on, so the *absolute* figures are not comparable with those. The before/after is, because
|
||
both halves of it were measured on the same card minutes apart.
|
||
|
||
---
|
||
|
||
## TD-5 — The fused shader is reassembled from strings on every frame
|
||
|
||
**Where:** `dr_pipeline::operation::compose_full`, called from `DevelopSession::render`.
|
||
|
||
### What it does
|
||
|
||
`EditGraph::compose` walks the active operations and formats a WGSL source string, per frame, on
|
||
the UI thread. On a full chain that is **2.8–5.2 ms** — at 1920 × 1200 it is larger than the entire
|
||
fused dispatch it precedes, and on the chains that also carry a detail stage it is a third of what
|
||
is left of the 16 ms budget after the GPU has taken its share. It does not vary with resolution,
|
||
because it is not pixel work.
|
||
|
||
### Why
|
||
|
||
Because it was free until the chain got long. Composition was written when an edit was two or three
|
||
operations, and the cost is roughly linear in generated source: the colour mixer emits twelve hue bands
|
||
and the tone curve emits a spline evaluator, so a full chain is a large string built from scratch
|
||
sixty times a second.
|
||
|
||
### Paying it off
|
||
|
||
The generated source depends only on the *structure* of the graph — which is precisely what
|
||
`ComposedShader::structure_hash` already identifies, and precisely what does not change while a
|
||
slider is being dragged. `AdjustPass` relies on that already: it caches compiled pipelines against
|
||
that hash and does not recompile during a drag. Caching the source string against the same hash and
|
||
rebuilding only the uniforms — a handful of floats per operation — takes this to approximately
|
||
nothing on the path that needs it most.
|
||
|
||
The care needed is in what the hash covers. It deliberately excludes parameter *magnitudes*, so a
|
||
cache keyed on it is sound for the source and would be wrong for anything else in `ComposedShader`.
|
||
|
||
**Done when:** the `shader` column of [frame-budget.md](frame-budget.md)'s M1 table is under a
|
||
millisecond for the `point` and `all` chains, and the codegen tests still pass byte for byte.
|
||
|
||
---
|
||
|
||
## Related, and deliberately not here
|
||
|
||
The window-move rule, the grid's ordering index and the whole-library readout cache were *fixed*
|
||
rather than deferred — see the commits around `6d6ef8d`. They are mentioned only so that a reader
|
||
looking for "why was the grid slow" finds the answer in the code and its comments rather than
|
||
assuming it is still outstanding.
|