From 5837dedc890254df195aee4360ced854aa9d3d02 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 01:09:37 +0000 Subject: [PATCH 1/4] Measure the silhouette outline in CSS pixels, so it stops breathing when the camera moves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01ToZMaeRwS2FKB8xESMuktQ --- src/engine/OutlinePass.test.ts | 57 +++++++++++++++++++++++++++ src/engine/OutlinePass.ts | 71 +++++++++++++++++++++++++++++++--- 2 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 src/engine/OutlinePass.test.ts diff --git a/src/engine/OutlinePass.test.ts b/src/engine/OutlinePass.test.ts new file mode 100644 index 0000000..2b93d84 --- /dev/null +++ b/src/engine/OutlinePass.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest'; +import { + outlineTexelRadius, + OUTLINE_CSS_RADIUS, + OUTLINE_MAX_TEXELS, + GIZMO_CSS_RADIUS, + GIZMO_MAX_TEXELS, +} from './OutlinePass'; + +/** + * Issue #121: the outline kernel was measured in depth-texture texels, so a + * fixed texel count spanned a different on-screen width at each device-pixel + * ratio. `ThreeEngine` uses 1.5 idle and 1.0 while the camera moves, so the + * outline thickened for the duration of a drag. `outlineTexelRadius` converts a + * CSS-pixel radius to texels per frame so the on-screen width stays constant. + */ +describe('outlineTexelRadius', () => { + // The two device-pixel ratios ThreeEngine actually renders at. + const IDLE_DPR = 1.5; + const INTERACTIVE_DPR = 1.0; + + it('holds a constant CSS width across the idle/interactive ratio switch', () => { + for (const [css, max] of [ + [OUTLINE_CSS_RADIUS, OUTLINE_MAX_TEXELS], + [GIZMO_CSS_RADIUS, GIZMO_MAX_TEXELS], + ] as const) { + const idleTexels = outlineTexelRadius(IDLE_DPR, css, max); + const movingTexels = outlineTexelRadius(INTERACTIVE_DPR, css, max); + // Texel count differs by ratio... + expect(idleTexels).toBeGreaterThan(movingTexels); + // ...but texels / ratio — the CSS-pixel width on screen — does not. + expect(idleTexels / IDLE_DPR).toBeCloseTo(css, 10); + expect(movingTexels / INTERACTIVE_DPR).toBeCloseTo(css, 10); + } + }); + + it('reproduces the old 3-texel kernel at the idle ratio', () => { + // The kernel was authored as `const float u_radius = 3.0` and looked right + // at 1.5; the fix must not change that idle appearance. + expect(outlineTexelRadius(IDLE_DPR, OUTLINE_CSS_RADIUS, OUTLINE_MAX_TEXELS)).toBeCloseTo(3.0, 10); + // The old gizmo cull was `r > 2.5`. + expect(outlineTexelRadius(IDLE_DPR, GIZMO_CSS_RADIUS, GIZMO_MAX_TEXELS)).toBeCloseTo(2.5, 10); + }); + + it('never exceeds the shader loop bound, even above the DPR ceiling', () => { + // R is a compile-time constant in the shader; a texel radius past it would + // silently request taps the loop never visits. The clamp guarantees it can't. + expect(outlineTexelRadius(2, OUTLINE_CSS_RADIUS, OUTLINE_MAX_TEXELS)).toBe(OUTLINE_MAX_TEXELS); + expect(outlineTexelRadius(4, OUTLINE_CSS_RADIUS, OUTLINE_MAX_TEXELS)).toBe(OUTLINE_MAX_TEXELS); + expect(outlineTexelRadius(2, GIZMO_CSS_RADIUS, GIZMO_MAX_TEXELS)).toBe(GIZMO_MAX_TEXELS); + }); + + it('scales linearly with the pixel ratio below the clamp', () => { + expect(outlineTexelRadius(1.0, OUTLINE_CSS_RADIUS, OUTLINE_MAX_TEXELS)).toBeCloseTo(2.0, 10); + expect(outlineTexelRadius(1.25, OUTLINE_CSS_RADIUS, OUTLINE_MAX_TEXELS)).toBeCloseTo(2.5, 10); + }); +}); diff --git a/src/engine/OutlinePass.ts b/src/engine/OutlinePass.ts index bc58d47..1e572f5 100644 --- a/src/engine/OutlinePass.ts +++ b/src/engine/OutlinePass.ts @@ -20,6 +20,7 @@ const GIZMO_OUTLINE_FRAG = ` precision highp float; uniform sampler2D u_gizmo; uniform vec2 u_resolution; +uniform float u_radius; varying vec2 vUv; void main() { @@ -33,7 +34,7 @@ void main() { for (int y = -2; y <= 2; y++) { if (x == 0 && y == 0) continue; float r = sqrt(float(x*x + y*y)); - if (r > 2.5) continue; + if (r > u_radius) continue; vec2 offset = vec2(float(x) * px, float(y) * py); float a = texture2D(u_gizmo, vUv + offset).a; maxAlpha = max(maxAlpha, a); @@ -81,13 +82,16 @@ uniform sampler2D u_depth; uniform vec2 u_resolution; uniform float u_near; uniform float u_far; +uniform float u_radius; varying vec2 vUv; -// Kernel radius, and the loop bound that matches it. It used to sweep -4..4 — -// 81 taps — and cull everything past r = 3.0, so 32 of them did a sqrt and two -// depth linearisations only to be thrown away. -3..3 is the smallest box that -// contains the disc, so the culled set is now just the four corners. -const float u_radius = 3.0; +// The loop bound that matches the widest kernel we ever request. It used to +// sweep -4..4 — 81 taps — and cull everything past r = 3.0, so 32 of them did a +// sqrt and two depth linearisations only to be thrown away. -3..3 is the +// smallest box that contains the disc, so the culled set is now just the four +// corners. u_radius is a uniform (see OUTLINE_CSS_RADIUS) rather than the old +// const 3.0: the kernel is measured in CSS pixels and converted to texels per +// frame, so it never exceeds 3.0 and never overruns this bound. const int R = 3; void main() { @@ -137,6 +141,43 @@ void main() { } `; +/** + * Device-pixel ratio the outline kernels were authored against. + * + * `ThreeEngine` renders at `MAX_DPR = 1.5` when idle and drops to `1.0` while + * the camera moves. Both outline shaders originally measured their kernel in + * *texels of the depth texture*, so a fixed texel count spanned a different + * number of CSS pixels at each ratio — the outline visibly thickened for the + * duration of a drag and snapped back on settle (issue #121). The kernels look + * right at the idle ratio, so that is the width we pin: the CSS-pixel radius is + * `texelRadius / 1.5`, and we convert back to texels per frame at whatever + * ratio is current. + */ +const OUTLINE_DESIGN_DPR = 1.5; + +/** Compile-time loop bound `R` in `OUTLINE_FRAG`; the kernel can never exceed this many texels. */ +export const OUTLINE_MAX_TEXELS = 3; +/** The `if (r > 2.5)` cull the `-2..2` box in `GIZMO_OUTLINE_FRAG` enforces. */ +export const GIZMO_MAX_TEXELS = 2.5; + +/** Outline half-width in CSS pixels — the idle-ratio appearance, held across DPR changes. */ +export const OUTLINE_CSS_RADIUS = OUTLINE_MAX_TEXELS / OUTLINE_DESIGN_DPR; // 2.0 +export const GIZMO_CSS_RADIUS = GIZMO_MAX_TEXELS / OUTLINE_DESIGN_DPR; // ~1.667 + +/** + * Convert a CSS-pixel kernel radius to depth-texture texels at a given pixel + * ratio, clamped to the shader's compile-time loop bound. + * + * A CSS-pixel radius times the pixel ratio is the texel radius, so the on-screen + * width stays constant as the ratio changes: at ratio 1.5 the outline spans + * `2.0 * 1.5 = 3.0` texels, at ratio 1.0 it spans `2.0` texels — both 2.0 CSS + * px. The clamp guards the loop bound: the ratio never exceeds `MAX_DPR = 1.5`, + * so this only bites if that ceiling is ever raised past the kernel width. + */ +export function outlineTexelRadius(pixelRatio: number, cssRadius: number, maxTexels: number): number { + return Math.min(cssRadius * pixelRatio, maxTexels); +} + export class OutlinePass { private engine: OutlinePassEngine; /** Colour *and* depth from the one scene render. */ @@ -182,6 +223,7 @@ export class OutlinePass { u_resolution: { value: new THREE.Vector2(w, h) }, u_near: { value: 0.01 }, u_far: { value: 5000 }, + u_radius: { value: outlineTexelRadius(dpr, OUTLINE_CSS_RADIUS, OUTLINE_MAX_TEXELS) }, }, transparent: true, depthTest: false, @@ -206,6 +248,7 @@ export class OutlinePass { uniforms: { u_gizmo: { value: this.gizmoTarget.texture }, u_resolution: { value: new THREE.Vector2(w, h) }, + u_radius: { value: outlineTexelRadius(dpr, GIZMO_CSS_RADIUS, GIZMO_MAX_TEXELS) }, }, transparent: true, depthTest: false, @@ -293,6 +336,22 @@ export class OutlinePass { this.material.uniforms.u_near.value = camera.near; this.material.uniforms.u_far.value = camera.far; + // Pin both kernels to a constant CSS-pixel width. `getPixelRatio()` reports + // the interactive ratio (1.0) mid-drag and the idle ratio (1.5) otherwise; + // scaling the texel radius by it keeps the on-screen thickness fixed instead + // of letting the outline "breathe" every time the camera moves (issue #121). + const pr = renderer.getPixelRatio(); + this.material.uniforms.u_radius.value = outlineTexelRadius( + pr, + OUTLINE_CSS_RADIUS, + OUTLINE_MAX_TEXELS, + ); + this.gizmoMaterial.uniforms.u_radius.value = outlineTexelRadius( + pr, + GIZMO_CSS_RADIUS, + GIZMO_MAX_TEXELS, + ); + // 1. The one scene render. Colour and depth come out of the same pass. // The gizmo goes into the same target immediately after, so it composites // exactly where it did when it lived in `scene` — it is only stored From 5ad2f602b0adfd568a46a06dd2bd013978ade163 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 02:09:33 +0000 Subject: [PATCH 2/4] Bake the fit objective's mesh-field target once instead of per trial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01ToZMaeRwS2FKB8xESMuktQ --- src/worker/sdf/fitPrimitive.ts | 66 ++++++++++++++++++++++++++++------ 1 file changed, 55 insertions(+), 11 deletions(-) diff --git a/src/worker/sdf/fitPrimitive.ts b/src/worker/sdf/fitPrimitive.ts index 86a31e9..2680fe1 100644 --- a/src/worker/sdf/fitPrimitive.ts +++ b/src/worker/sdf/fitPrimitive.ts @@ -1,5 +1,5 @@ import type { SDFNode, MeshFieldData, Vec3 } from './types'; -import { evaluateSDF } from './evaluate'; +import { evaluateSDF, evalAt } from './evaluate'; import { sampleMeshField } from './meshField'; /** @@ -127,24 +127,67 @@ function surfacePoints(field: MeshFieldData): Vec3[] { return pts.filter((_, i) => i % stride === 0); } -/** Squared field difference over a volume of samples — the optimiser's objective. */ -function volumeCost(field: MeshFieldData, node: SDFNode): number { +/** + * The volume objective's sample points and the mesh field's value at each, + * baked once per fit. + * + * `volumeCost` is called thousands of times — every coordinate-descent trial of + * every candidate — always over this same fixed grid, and the only thing that + * changes between calls is the candidate node. The mesh side of the difference + * (`sampleMeshField`, a trilinear interpolation with a square root) was being + * recomputed at identical coordinates on every one of those calls; for a single + * fit that ran to millions of redundant interpolations. Precomputing the target + * once removes that half of the inner loop wholesale (#123), leaving only the + * candidate's own `evalAt`. The `xs/ys/zs` arrays let the loop call `evalAt` + * with loose scalars, so it also allocates no per-sample tuple. + */ +interface VolumeSamples { + xs: Float64Array; + ys: Float64Array; + zs: Float64Array; + /** `sampleMeshField` at each point — the fixed side of the difference. */ + target: Float64Array; +} + +function sampleVolume(field: MeshFieldData): VolumeSamples { const { bbox } = field; - let sum = 0; + const per = VOLUME_STEPS + 1; + const count = per * per * per; + const xs = new Float64Array(count); + const ys = new Float64Array(count); + const zs = new Float64Array(count); + const target = new Float64Array(count); let n = 0; + // Same i/j/k nesting and order as the objective always used, so the sum below + // accumulates in the identical sequence — a reproducible fit must not depend + // on this being a no-op reordering, and it is not one. for (let i = 0; i <= VOLUME_STEPS; i++) { const x = bbox.min[0] + ((bbox.max[0] - bbox.min[0]) * i) / VOLUME_STEPS; for (let j = 0; j <= VOLUME_STEPS; j++) { const y = bbox.min[1] + ((bbox.max[1] - bbox.min[1]) * j) / VOLUME_STEPS; for (let k = 0; k <= VOLUME_STEPS; k++) { const z = bbox.min[2] + ((bbox.max[2] - bbox.min[2]) * k) / VOLUME_STEPS; - const d = evaluateSDF(node, [x, y, z]) - sampleMeshField(field, x, y, z); - sum += d * d; + xs[n] = x; + ys[n] = y; + zs[n] = z; + target[n] = sampleMeshField(field, x, y, z); n++; } } } - return sum / n; + return { xs, ys, zs, target }; +} + +/** Squared field difference over the precomputed volume samples — the optimiser's objective. */ +function volumeCost(vol: VolumeSamples, node: SDFNode): number { + const { xs, ys, zs, target } = vol; + const count = target.length; + let sum = 0; + for (let n = 0; n < count; n++) { + const d = evalAt(node, xs[n], ys[n], zs[n]) - target[n]; + sum += d * d; + } + return sum / count; } /** Surface residual, in millimetres, of `node` against the mesh's own surface. */ @@ -368,9 +411,9 @@ function extentAlong(pts: Vec3[], centre: Vec3, axis: Vec3): { radius: number; h * much as just trying the step. Coordinate descent needs no derivative, cannot * diverge, and the parameter count is six at most. */ -function refine(field: MeshFieldData, c: Candidate, scale: number): number[] { +function refine(vol: VolumeSamples, c: Candidate, scale: number): number[] { let params = [...c.params]; - let best = volumeCost(field, c.build(params)); + let best = volumeCost(vol, c.build(params)); let step = scale * 0.08; for (let pass = 0; pass < 24 && step > scale * 1e-4; pass++) { let improved = false; @@ -378,7 +421,7 @@ function refine(field: MeshFieldData, c: Candidate, scale: number): number[] { for (const dir of [1, -1]) { const trial = [...params]; trial[i] += dir * step; - const cost = volumeCost(field, c.build(trial)); + const cost = volumeCost(vol, c.build(trial)); if (cost < best) { best = cost; params = trial; improved = true; break; } } } @@ -430,10 +473,11 @@ export function fitPrimitive(field: MeshFieldData): FitResult | null { if (!(diag > 0)) return null; const pts = surfacePoints(field); + const vol = sampleVolume(field); let best: FitResult | null = null; for (const c of candidates(extent, pts)) { - const params = refine(field, c, diag); + const params = refine(vol, c, diag); if (c.degenerate?.(params)) continue; const node = c.build(params); const { rms, max } = surfaceResidual(node, pts); From b0372cfff51452e0e29a343d9a5375a48d40c4aa Mon Sep 17 00:00:00 2001 From: Kevin Blackburn-Matzen Date: Sat, 5 Sep 2026 17:05:00 -0700 Subject: [PATCH 3/4] Update outline golden for constant CSS width --- e2e/golden-snapshots/linear-pattern.png | Bin 7514 -> 8073 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/e2e/golden-snapshots/linear-pattern.png b/e2e/golden-snapshots/linear-pattern.png index bd9ae87bb875b3fd8e141124973b733ce84969f8..555fc26c83ee89398b33c8dc93bc992e29a00e1e 100644 GIT binary patch literal 8073 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 literal 7514 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 From 9ce252034a3582e6d33dacea4670b7cd95abe398 Mon Sep 17 00:00:00 2001 From: Kevin Blackburn-Matzen Date: Sat, 5 Sep 2026 17:14:05 -0700 Subject: [PATCH 4/4] Allow accumulated ulps in interval property test --- src/worker/sdf/interval.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/worker/sdf/interval.test.ts b/src/worker/sdf/interval.test.ts index 1526f04..90c33b4 100644 --- a/src/worker/sdf/interval.test.ts +++ b/src/worker/sdf/interval.test.ts @@ -63,7 +63,10 @@ describe('the interval enclosure contains the pointwise field', () => { const iv = evaluateInterval(tree, box); if (!isFinite(iv.lo) && !isFinite(iv.hi)) return true; const scale = Math.max(...[0, 1, 2].map((k) => bb.max[k] - bb.min[k])); - const tol = scale * 1e-6 + 1e-9; + // Nested transforms and repetition can accumulate a few ulps beyond + // the scale-relative allowance (the CI counterexample missed by + // 9.8e-10 after six patterned, non-uniformly scaled copies). + const tol = scale * 1e-6 + 1e-8; for (const p of samplesIn(box, 120, seed)) { const v = evaluateSDF(tree, p); if (!isFinite(v)) continue;