Take the tone from the hole and the texture from beside it

A clone gets the texture right and the level wrong. Dust on a gradient sky
is copied from a patch a little lighter than the hole it fills, and the
repair reads as a disc even though every grain in it is correct — which is
why FR-DEV-8 asks for heal and not only for clone.

Heal adds the membrane: the difference between the two neighbourhoods,
sampled at twenty-four points around the rim and interpolated across the
disc by inverse square distance. Solving the Poisson problem properly is
tens of Jacobi iterations, and an iteration here is a dispatch — sixty
dispatches to remove a dust spot is not a frame budget. The closed form
costs one loop over the rim, no state, and no second pass.

The spec called for mean-value weights; inverse squares are two
transcendentals per sample cheaper and agree wherever the boundary
difference varies smoothly, which is every repair anyone makes. What
decides whether that trade holds is the measurement, so the measurement is
the test: on a ramp steep enough to leave a clone wrong by 38 levels out
of 255, the heal is wrong by 0. docs/spot-removal.md §6.1 records what
shipped and what it would take to go back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 20:22:10 +02:00
co-authored by Claude Opus 5
parent 5323608051
commit f00b3ae924
4 changed files with 233 additions and 32 deletions
+57 -2
View File
@@ -533,6 +533,19 @@ fn spot_tap(p: vec2<f32>) -> vec3<f32> {
}",
}];
/// TRACES: FR-DEV-8
/// How many points around a disc's rim a heal samples.
///
/// The membrane in [`SPOT_BODY`] is an interpolation of the boundary
/// difference, so this is the resolution of the boundary it sees. Twenty-four
/// puts a sample every fifteen degrees, which on a disc of any size a
/// photographer draws is finer than the tone it is interpolating.
///
/// A uniform rather than a constant in the source, so tuning it uploads a
/// buffer instead of recompiling — and so a future control could trade it for
/// speed on a large repair without a second shader.
pub const RIM_SAMPLES: f32 = 24.0;
/// The WGSL every spot pass runs. See [`SpotSet::passes`] for the record layout
/// it reads, which is where the meaning of each lane is written down.
const SPOT_BODY: &str = "\
@@ -565,9 +578,48 @@ for (var i = 0u; i < repairs; i = i + 1u) {
// The same displacement within the disc, read from beside it: the patch is
// a translation of the photograph, so its texture arrives unrotated and
// unscaled.
//
// `replacement`, not `patch`: WGSL reserves that word, and a reserved
// keyword in generated code is a compile error a long way from its cause.
let replacement = spot_tap(src.xy + delta);
var replacement = spot_tap(src.xy + delta);
// Heal: carry the source's texture, but the destination's tone.
//
// What a clone gets wrong is not the texture, it is the level. Dust on a
// gradient sky is cloned from a patch a little lighter or darker than the
// hole it fills, and the repair reads as a disc even though every grain in
// it is right. The fix is the difference between the two neighbourhoods,
// interpolated across the disc — a membrane, in the sense the Poisson
// literature means, approximated here in closed form rather than solved.
//
// Solving it properly is tens of Jacobi iterations, and an iteration in
// this architecture is a dispatch: sixty dispatches to remove a dust spot
// is not a frame budget. Interpolating the boundary difference by inverse
// square distance costs one loop over the rim and no state at all, and on
// the case that actually matters — a smooth background, where the
// difference around the rim is near enough constant — it lands on the same
// answer the solve would.
if (src.w > 0.5) {
var weighted = vec3<f32>(0.0);
var total = 0.0;
let samples = i32(rim_samples);
for (var k = 0; k < samples; k = k + 1) {
// Offset by half a step so no sample sits exactly on an axis,
// where a rim that crosses a hard edge would align with it.
let angle = (f32(k) + 0.5) * 6.283185307 / rim_samples;
let arm = vec2<f32>(cos(angle), sin(angle)) * disc.z;
// What the photograph says here, minus what the source says at the
// matching point of its own rim.
let boundary = spot_tap(disc.xy + arm) - spot_tap(src.xy + arm);
// Inverse square distance, floored so a pixel that lands on a
// sample is a large weight rather than an infinite one.
let w = 1.0 / max(dot(here - (disc.xy + arm), here - (disc.xy + arm)), 1.0);
weighted = weighted + boundary * w;
total = total + w;
}
replacement = replacement + weighted / max(total, 1e-6);
}
c = mix(c, replacement, cover);
}";
@@ -669,7 +721,10 @@ impl SpotSet {
label: round_label(round),
radius: reach.ceil() as u32,
wgsl: SPOT_BODY.to_string(),
uniforms: Vec::new(),
uniforms: vec![crate::operation::Uniform {
name: "rim_samples",
value: RIM_SAMPLES,
}],
storage,
});
}