Skip to content

Thin-feature locks carried by their own depth, and spared only at the lamp (ROADMAP § 1a) - #67

Merged
yujiaxu-ops merged 2 commits into
mainfrom
lock-depth
Nov 13, 2026
Merged

yujiaxu-ops merged 2 commits into
mainfrom
lock-depth

Conversation

@yujiaxu-ops

@yujiaxu-ops yujiaxu-ops commented Nov 13, 2026 •

Copy link
Copy Markdown
Collaborator

The fix planned after #60. When no sample hit a sub-pixel lamp in a frame, the lamp moved with the background behind it, which produced the trail when dragging the view. Tier R2: it changes what the live window shows in motion.

The fault

A lamp finer than a pixel that no sample hit this frame went with what was behind it:

  • its lock moved with the background;
  • its history was read where the background had been;
  • the sky a lamp passed over kept its light. The history's pixels around a sparse lamp are dark too, so no sample there contradicted that light. The test for something gone from in front read the g-buffer, and the samples had mostly missed the lamp there.

The clamp in motion bounded the copies: it was spared by 0.75 over the nine traced samples around a lock (LOCK_RELAX, #60).

The change (live.wgsl, Lock)

  • The lock carries its own depth and position. It keeps the view depth of what it is for and where in its traced pixel that is (up_state z, w).
  • A new pass, lock_carry, moves the locks. It runs before compose, over the previous frame's traced grid. It takes each lock to where its point is now, using the two cameras, onto the traced sample it falls in.
    • lock_fwd holds the result: an atomic max per traced sample, so the longest-lived lock wins.
    • In the merged layout, lock_fwd and the exposure histogram share a new atomic slab, so the layout is still 8 storage buffers.
  • judge reads the history where the lamp was. A live lock nearer than the samples counts as a candidate for the nearest surface among the nine, with the motion of its own depth.
  • The gone test sees the lamp. lock_carry writes the previous locks' depths into the previous g-buffer half; by then only the gone test reads it. The test is now barred by any live lock among the nine. Before, it was barred only by a lock at half strength, which let a still lamp's own history go at the end of its lock.
  • The clamp is spared only at the lamp. In motion it spares the history in full within LOCK_REACH (1 output pixel, the reconstruction's reach) of the locked thing, and nowhere else. LOCK_RELAX is gone.
  • The pass grid sizes come from one function (pass_grid); the profile path had a copy of the expression.

Merge gate (R2)

check result
cargo test --release 124 passed
npm run typecheck, npm test pass, 159 passed
test:render native 189 / 189 pass after the test changes below; no other test changed verdict
test:render, THREERT_MERGED=1 the 24 known software-traversal failures only (ROADMAP Known issues); the new atomic slab works
bench-window (main / branch) see below
bench-ghosting --quick --check, Bistro and Kabukichō both ok. Today's main and the branch are the same to the last digit or so (Bistro turn 0.85 dE / 6 px, strafe 1.27 / 1.25; Kabukichō 1.98 / 1.97, 26 px)
test:layer-b pass rate 0.500
perf +0.10 ms (Bistro 1440p at 0.375: compose + judge + upscale + lock_carry, main 1.73–1.80 ms, branch 1.84–1.90; alternated, three runs per build). The perf smoke said FAIL (+1.2 ms still), then +1.2 / +2.4 with the drive 1.3–4.3 ms faster. Main is measured first, then the branch; its reads fall at two levels on this Mac. Alternated, the unchanged trace reads the same on both builds.

bench-window, the tower region at the last moving frame (ΔE / SSIM / luminance against offline):

main branch
orbit (the play window's left drag) 4.30 / 0.843 / 0.99 3.90 / 0.865 / 0.97
turn 2.12 / 0.914 / 0.84 2.01 / 0.926 / 0.92
whole frame, orbit / turn ΔE 2.40 / 2.55 2.32 / 2.44
  • Better at every moment until the view has settled; equal once settled.
  • The reach brackets the truth: 0.5 pixel gives 4.31 / 0.73 (the lamp's own light is cut), 1.5 pixels gives 4.17 / 1.17 (the halo kept).
  • Unchanged: still-bistro, still-kabukicho, the slice, and Tokyo at dusk. Dusk's 6 s skyline reads 1.70 or 2.31 on either build from one session to the next; main read both levels.

Tests

  • New: upscale/lamps-parallax-live. Lamps slide before the black sky, which the slide does not move; the test measures the light beside the field's edge. Main reads 0.357 of the field's mean, the branch 0.075. Tolerance 0.1: the rest is the gone test's narrow window, explained in the test's note.
  • Removed: upscale/lamps-in-motion-live. Main passed it only because of its trail: the light the lamps left on the sky made up the mean. With the trail gone it reads 0.066 against 0.1. The real problem, lamps in motion keeping half their light (0.045 on main, 0.050 here, with a wall behind them), is in ROADMAP Known issues. Lock life is ruled out (two and four sequences: 0.052, 0.053), and the test comes with that fix.

ROADMAP § 1a records the step.

🤖 Generated with Claude Code


Note

Medium Risk
Changes core temporal upscaler behavior (compose/judge/upscale) and adds a new GPU pass plus binding layout changes that affect WebGPU merged mode.

Overview
Fixes sub-pixel lamp trails when the camera moves by reworking thin-feature locks in the live upscaler: locks now store view depth and sub-pixel position, are reprojected via a new lock_carry pass (before compose), and history/motion uses the lamp’s depth instead of drifting with the background.

Shader / temporal behavior: lock_fwd (atomic) forwards carried locks into compose; judge treats live locks as nearer-surface candidates and blocks the “gone” test when any lock is alive; motion clamping drops LOCK_RELAX for spatial LOCK_REACH (full unclamp only within 1 output pixel of the locked feature). G-buffer previous-half depths are patched from carried locks for the gone test.

Runtime wiring: Live pass count and merged layout go to 25 storage buffers; exposure histogram and lock_fwd share a new atomic slab; dispatch grids are centralized in pass_grid (including previous traced size for lock_carry).

Tests / docs: Replaces upscale/lamps-in-motion-live with upscale/lamps-parallax-live; documents remaining “half light” lamp issue; ROADMAP §1a updated with benchmarks (~+0.10 ms on Bistro).

Reviewed by Cursor Bugbot for commit 25c7ecf. Bugbot is set up for automated code reviews on this repo. Configure here.

… lamp (live.wgsl Lock)

A lamp finer than a pixel that no sample hit this frame went with what was behind it: its lock moved with the
background, its history was read where the background had been, and the sky a lamp passed over kept its light (the
history's pixels around a sparse lamp are dark too, so no sample there contradicted it, and the test for something gone
from in front read the g-buffer, where the samples had mostly missed the lamp). The clamp in motion, spared by 0.75
over the nine traced samples around a lock (LOCK_RELAX), bounded the copies.

- The lock keeps the view depth of what it is for and where in its traced pixel that is (up_state z, w). A pass before
  compose, lock_carry, over the previous frame's traced grid, takes each lock to where its point is now by the two
  cameras, onto the traced sample it falls in (lock_fwd, an atomic max per traced sample; in the merged layout it and
  the exposure histogram share a new atomic slab, MA).
- judge: a live lock nearer than the samples is a candidate for the nearest surface among the nine, with the motion of
  its own depth.
- lock_carry writes the previous locks' depths into the previous g-buffer half (only judge's gone test reads it by
  then); the gone test is barred by any live lock among the nine.
- In motion the clamp spares the history in full within LOCK_REACH (1 output pixel) of the locked thing and nowhere
  else; LOCK_RELAX is gone.
- The passes' grid sizes from one function (pass_grid), the profile and the normal path alike.

Against offline (bench-window, the tower at the last moving frame, dE / SSIM / luminance): the play window's orbit
4.30 / 0.843 / 0.99 -> 3.90 / 0.865 / 0.97, a turn 2.12 / 0.914 / 0.84 -> 2.01 / 0.926 / 0.92. The reach brackets the
truth (0.5 pixel 4.31 / 0.73, 1.5 pixels 4.17 / 1.17). New test upscale/lamps-parallax-live: main 0.357, now 0.075
(tolerance 0.1). upscale/lamps-in-motion-live removed: main passed it by its trail; lamps in motion keeping half their
light is in Known issues. Unchanged: still shots, the slice, bench-ghosting, Tokyo at dusk. Cost +0.10 ms (Bistro
1440p at 0.375, alternated).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit 86fef33. Configure here.

at = L.jit.xy;
}
life *= 1.0 - reactive;
up_state[this_half(q)] = vec4<u32>(pack2x16float(vec2<f32>(now, select(now, f.last.x, f.n >= 1.0))), pack2x16float(vec2<f32>(sd * still, min(max(f.n + still, 1.0), 9.0))), pack2x16float(vec2<f32>(life, min(lz, 6.0e4))), pack2x16float(at));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Carried lock depth never updates

Medium Severity

A carried lock stores the originating camera’s view depth (was.depth) instead of the current camera’s (cur_pixel’s z). The next lock_carry then rebuilds the world point from a new screen position plus that stale depth, and lock_vel / judge treat it as this frame’s depth. After the first miss the lock drifts whenever view depth changes (orbit, dolly, a turn), over the full jitter sequence.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 86fef33. Configure here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 25c7ecf: Carried now has was (the previous view's depth, which the previous g-buffer half is in: what lock_carry writes there) and depth (this frame's, which the carried lock keeps). Rerun: test-render 190 / 190, the merged layout the 24 known failures; bench-window the tower in the orbit 3.90 (unchanged), the whole frame 2.32 -> 2.30.

Bugbot on #67: `carry` returned the lock's view depth in the previous frame's view, and `compose` stored it as this
frame's. The next `lock_carry` rebuilt the point from the new place and that stale depth, and `judge` (`lock_vel`)
took it as this frame's: a lock drifted wherever the view depth of what it was for changed (an orbit, a dolly). Now
`Carried` has both: `was` (the previous view's, which the previous g-buffer half is in: what `lock_carry` writes there)
and `depth` (this frame's, which the lock keeps).

Rerun on the fix: cargo test 124 passed; test-render 190 / 190 (parallax 0.075, still 0.1057), merged the 24 known;
bench-window the tower in the orbit 3.90 / 0.865 / 0.97 (as before), the whole frame 2.32 -> 2.30; the turn equal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@yujiaxu-ops
yujiaxu-ops merged commit 807e060 into main Nov 13, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant