Skip to content

Stop the outline breathing during camera moves (#121), and the primitive fitter re-sampling the mesh field every trial (#123) - #140

Open
kmatzen wants to merge 2 commits into
mainfrom
claude/existing-prs-issues-sjhcn1
Open

Stop the outline breathing during camera moves (#121), and the primitive fitter re-sampling the mesh field every trial (#123)#140
kmatzen wants to merge 2 commits into
mainfrom
claude/existing-prs-issues-sjhcn1

Conversation

@kmatzen

@kmatzen kmatzen commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Two independent fixes on this branch.


1. Silhouette outline breathes while the camera moves — fixes #121

Orbit, pan or zoom and the white silhouette outline visibly thickened (~1.5×) for the duration of the drag, snapping back ~180 ms after release. Most obvious on a Retina display against a dark background; invisible on a 1× display, which is why it survived.

Why. ThreeEngine drops the device-pixel ratio while the camera moves — 1.5 idle, 1.0 interactive (a deliberate perceived-latency win). OUTLINE_FRAG measured its kernel in texels of the depth texture (const float u_radius = 3.0), and 3 texels is 2.0 CSS px at ratio 1.5 but 3.0 CSS px at ratio 1.0 — so the outline was 1.5× wider on screen exactly while moving.

Fix. Turn the kernel radius back into a uniform in both shaders and drive it by the current pixel ratio each frame: u_radius = cssRadius × pixelRatio. Pinned to the idle-ratio width (2.0 CSS px for the shape outline, ~1.667 for the gizmo), on-screen thickness is now constant across the switch.

  • R stays a compile-time 3; cssRadius × 1.5 lands exactly on it, so the loop is never overrun. A Math.min clamp guards the bound if MAX_DPR is ever raised.
  • The alpha response is a ratio (outline / total), so opacity is preserved — only the width scales.
  • GIZMO_OUTLINE_FRAG had the same drift (r > 2.5) and gets the same treatment.
  • Deliberate consequence: 1× displays (previously a fixed 3.0 CSS px) now render at the same 2.0 CSS px as Retina idle — one consistent CSS width everywhere.

outlineTexelRadius is a pure function with a GPU-free unit test (OutlinePass.test.ts) covering CSS-width constancy across the two ratios, the preserved idle appearance, linear scaling, and the clamp.


2. fitPrimitive costs seconds per fit, independent of grid resolution — fixes #123

A single fit took ~2–3 s here (5–9 s on a contended machine) and did not fall with grid resolution — it was dominated by a fixed-iteration coordinate descent, not the field size. It is also the suite's chronic timeout: fitPrimitive.test.ts runs ten fits and repeatedly ran out the clock.

Why. volumeCost is called thousands of times per fit — every coordinate-descent trial of every candidate — always over the same fixed sample grid, with only the candidate node changing. Each call re-sampled the mesh field (sampleMeshField, a trilinear interpolation with a square root) at those identical coordinates and allocated a fresh [x,y,z] tuple per sample. A single fit ran to millions of redundant interpolations and short-lived arrays.

Fix. Precompute the sample coordinates and the mesh-field target once per fit (sampleVolume); volumeCost reads the target from a Float64Array and evaluates the candidate with evalAt's loose scalars — no re-interpolation, no per-sample allocation. The sample order is unchanged, so the summation is bit-identical and the fit stays deterministic.

Measured (res 40), before → after: sphere 2638 → 342 ms, box 2352 → 298 ms, cylinder 2255 → 284 ms, tilted cylinder 3085 → 400 ms~8×, with kind / surfaceRms / surfaceMax identical. fitPrimitive.test.ts drops from tens of seconds to ~17 s, now bounded by field baking rather than fitting.

The existing gates (recover sphere/box/cylinder, refuse non-primitives with a reported residual, tilted-axis recovery, and is deterministic) are exactly the invariants #123 asks any speedup to keep; no timing test, per the repo's stated "nothing asserts elapsed time" policy.


tsc --noEmit clean; full unit suite 557 passed / 3 skipped for both changes.

🤖 Generated with Claude Code

https://claude.ai/code/session_01ToZMaeRwS2FKB8xESMuktQ

…hen the camera moves

The outline kernel was a fixed count of depth-texture texels, and ThreeEngine
drops the device-pixel ratio from 1.5 to 1.0 for the duration of a camera
drag. A texel is a different size on screen at each ratio, so the outline
rendered 1.5x wider while moving and snapped back ~180ms after release — most
visible on a Retina display against a dark background (#121).

Turn the shaders' kernel radius back into a uniform and drive it by the current
pixel ratio: u_radius = cssRadius * pixelRatio. Pinned to the idle-ratio width
(2.0 CSS px for the shape outline, ~1.667 for the gizmo), the on-screen
thickness is now constant across the interactive/idle ratio switch. R stays a
compile-time 3, and the CSS radius times the 1.5 ceiling lands exactly on it, so
the loop is never overrun; a clamp guards the bound if MAX_DPR is ever raised.
The GIZMO_OUTLINE_FRAG kernel had the same texel-measured drift and gets the
same treatment.

outlineTexelRadius is a pure function with a unit test covering CSS-width
constancy across the two ratios, the preserved idle appearance, and the clamp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ToZMaeRwS2FKB8xESMuktQ
@github-actions

Copy link
Copy Markdown

🚀 Preview deployed: https://claude-existing-prs-issues-s.sinter.pages.dev

(updates on every push to this PR)

fitPrimitive ran ~2-3s per fit here (5-9s on a contended machine), and the cost
did not fall with grid resolution — it was dominated by a fixed-iteration
coordinate descent rather than by the field's size. It is also the suite's
chronic timeout: the test file runs ten fits and repeatedly ran out the clock.

volumeCost is called thousands of times per fit — every coordinate-descent
trial of every candidate — always over the same fixed sample grid, with only
the candidate node changing. Each call re-sampled the mesh field
(sampleMeshField: a trilinear interpolation with a square root) at those
identical coordinates and allocated a fresh [x,y,z] tuple per sample, so a
single fit ran to millions of redundant interpolations and short-lived arrays.

Precompute the sample coordinates and the mesh-field target once per fit
(sampleVolume), and have volumeCost read the target from a Float64Array and
evaluate the candidate with evalAt's loose scalars — no re-interpolation, no
per-sample allocation. The sample order is unchanged, so the summation is
bit-identical and the fit stays deterministic.

Measured (res 40): sphere 2638->342ms, box 2352->298ms, cylinder 2255->284ms,
tilted cylinder 3085->400ms — ~8x, with kind/surfaceRms/surfaceMax identical.
fitPrimitive.test.ts drops from tens of seconds to ~17s, now bounded by field
baking rather than fitting. All existing gates pass, determinism included.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ToZMaeRwS2FKB8xESMuktQ
@kmatzen kmatzen changed the title Measure the silhouette outline in CSS pixels, so it stops breathing when the camera moves Stop the outline breathing during camera moves (#121), and the primitive fitter re-sampling the mesh field every trial (#123) Aug 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants