diff --git a/src/Scene.tsx b/src/Scene.tsx index d7d295d..d7c9e09 100644 --- a/src/Scene.tsx +++ b/src/Scene.tsx @@ -68,7 +68,7 @@ import { resolveWaypoint, type ModelConfig, } from './data/componentTree' -import { detectSoftwareRendering, probeMaxBufferSize } from './gpuProbe' +import { detectSoftwareRendering, probeMaxBufferSize, probeMaxSamples } from './gpuProbe' import { eventsWithoutHover } from './perf/eventsWithoutHover' import { markInit, parsePerfFlags, type PerfFlags } from './perf/harness' @@ -623,6 +623,7 @@ function SceneContent({ gpuTier, perfFactor, lowTier, + multisampling, }: { aoQuality: AOQuality postMode: PerfFlags['postOverride'] @@ -630,6 +631,7 @@ function SceneContent({ gpuTier: number | undefined perfFactor: number lowTier: boolean + multisampling: number }) { const cameraControlsRef = useRef(null) const currentSelectedId = useValue(selectedId) @@ -734,6 +736,7 @@ function SceneContent({ : aoQuality } enableOutline={postMode !== 'ao'} + multisampling={multisampling} /> )} {postMode === 'none' && perfFlags.enabled && } @@ -1098,6 +1101,18 @@ const SceneCanvas = ({ const dprConfig = perfFlags.dprOverride ?? effectiveConfig.dpr const maxDpr = Array.isArray(dprConfig) ? dprConfig[1] : dprConfig + // Highest MSAA level EffectComposer can allocate here. bufferDprCap bounds the + // edge but not total allocation, so probe the worst case (viewport × max DPR, + // squared to over-estimate); bucketed to 256px to avoid re-probing per pixel. + const effectiveMaxDpr = Math.min(maxDpr, bufferDprCap) + const probeEdge = Math.ceil((viewportEdge * effectiveMaxDpr) / 256) * 256 + const probedMultisampling = useMemo( + () => probeMaxSamples(probeEdge, probeEdge), + [probeEdge], + ) + // `?msaa=` forces a count for ablation, bypassing the probe. + const safeMultisampling = perfFlags.msaaOverride ?? probedMultisampling + // Adaptive AO quality: 'full' → 'off' as perf factor drops. Hysteresis // prevents flicker. Only takes effect when postSetting === 'auto' AND the // tier supports AO; otherwise the manual or tier-disabled value wins. @@ -1272,6 +1287,7 @@ const SceneCanvas = ({ gpuTier={effectiveConfig.tier} perfFactor={perfFactor} lowTier={lowTier} + multisampling={safeMultisampling} /> { export const PostProcessing = ({ aoQuality, enableOutline = true, + multisampling = 0, }: { aoQuality: AOQuality enableOutline?: boolean + // Composer MSAA samples; 0 disables it. Probed by the caller (probeMaxSamples) + // so it never exceeds what the GPU can allocate at this resolution. + multisampling?: number }) => { const outlineRef = useRef(null) const patched = useRef(false) @@ -82,7 +86,12 @@ export const PostProcessing = ({ } return ( - + {effects} ) diff --git a/src/gpuProbe.ts b/src/gpuProbe.ts index f0ab6ec..71afde7 100644 --- a/src/gpuProbe.ts +++ b/src/gpuProbe.ts @@ -55,3 +55,60 @@ export function probeMaxBufferSize(): number | null { return null } } + +/** Highest MSAA count (4→2, else 0) the GPU can allocate at `width × height`. + * + * MAX_RENDERBUFFER_SIZE only bounds the edge, but a multisampled renderbuffer + * must also fit `w × h × samples × bytesPerPixel` in one allocation — on large + * displays at high DPR this overflows ANGLE/Metal's per-resource limit, leaving + * EffectComposer a zero-size framebuffer → black scene. Not predictable from any + * GL param, so we try each count against an off-screen RGBA16F FBO (matching + * EffectComposer's HalfFloat buffers) and take the first that's COMPLETE. + * Capped at 4× — 8× is visually indistinguishable but doubles the allocation. */ +export function probeMaxSamples(width: number, height: number): number { + if (typeof document === 'undefined') return 0 + if (!(width > 0) || !(height > 0)) return 0 + let gl: WebGL2RenderingContext | null = null + try { + const canvas = document.createElement('canvas') + gl = canvas.getContext('webgl2') as WebGL2RenderingContext | null + if (!gl) return 0 + const maxSamples = gl.getParameter(gl.MAX_SAMPLES) as number + const fb = gl.createFramebuffer() + gl.bindFramebuffer(gl.FRAMEBUFFER, fb) + + let result = 0 + for (const samples of [4, 2]) { + if (samples > maxSamples) continue + const rb = gl.createRenderbuffer() + gl.bindRenderbuffer(gl.RENDERBUFFER, rb) + // Clear stale errors so getError() reflects only this allocation. + while (gl.getError() !== gl.NO_ERROR) { + /* drain */ + } + gl.renderbufferStorageMultisample(gl.RENDERBUFFER, samples, gl.RGBA16F, width, height) + const allocError = gl.getError() + gl.framebufferRenderbuffer( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.RENDERBUFFER, + rb, + ) + const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER) + gl.deleteRenderbuffer(rb) + if (allocError === gl.NO_ERROR && status === gl.FRAMEBUFFER_COMPLETE) { + result = samples + break + } + } + + gl.deleteFramebuffer(fb) + return result + } catch { + return 0 + } finally { + // Free the probe context so it doesn't evict the real canvas's (browsers + // cap simultaneous contexts at ~16). + gl?.getExtension('WEBGL_lose_context')?.loseContext() + } +} diff --git a/src/perf/PERF.md b/src/perf/PERF.md index c06590a..db8e83f 100644 --- a/src/perf/PERF.md +++ b/src/perf/PERF.md @@ -35,6 +35,7 @@ DevTools poking. `download=1` also saves `perf-.json`. | `dpr=` | Override devicePixelRatio (e.g. `1`, `2`, `4`). Bypasses GPU-tier config. | | `canvas=` | Pin canvas to a fixed square size. | | `post=` | `none`, `outline`, `ao`, `outline+ao`. Isolates post-processing passes. | +| `msaa=` | Force EffectComposer MSAA samples (`0`, `2`, `4`, `8`), bypassing the probe. For sample-count ablation. | | `instancing=` | `instanced` (default) or `cloned` — unrolls instances into individual meshes. | | `perforations=` | `on` (default) or `off` — strips alpha-tested perforation GLBs (sled / switch / power-shelf) to isolate their fillrate cost. | @@ -358,6 +359,40 @@ Lesson: for a static hardware visualizer, the eye tracks the shading continuity motion. Adaptive DPR is the same shape of idea and would likely have the same problem. If we revisit, cross-fade via tweened `N8AO.intensity` rather than a hard toggle. +## MSAA sample-count cap: 8× → 4× (probed, never hardcoded) + +EffectComposer defaulted to 8× MSAA. On large displays the 8× multisampled +renderbuffer (`w×h×samples×bytesPerPixel`) overflowed ANGLE/Metal's per-resource +allocation limit → zero-size incomplete framebuffer → black scene. Fix: +`probeMaxSamples()` (src/gpuProbe.ts) empirically picks the highest count that +allocates (4→2→0), and we capped the ceiling at 4×. `?msaa=` forces a count +for ablation. + +Ran `?perf=all&msaa={8,4,2}` on M4 Max / Chrome 149, default tier-3 config (DPR +`[1,2]`, post=auto). Frame time is v-sync-locked at 16.6ms — median gpuMs is the +signal: + +| Scenario | gpu p50 @8× | @4× | @2× | 8→4 | 4→2 | +| --------------- | ----------- | ----- | ----- | ----- | ---- | +| idle-rack | 10.43 | 10.29 | 8.65 | −1.3% | −16% | +| showcase-rack | 10.49 | 9.98 | 8.14 | −4.9% | −18% | +| drilled-sled | 10.20 | 10.05 | 8.23 | −1.5% | −18% | +| rapid-selection | 10.98 | 10.85 | 10.05 | −1.2% | −7% | +| orbit-stress | 10.10 | 10.27 | 8.86 | noise | −14% | + +p99 mirrors it; rapid-selection p99 is the extreme: 20.2 → 20.4 → **13.4**. + +- **8→4 is free** (within ±2% noise everywhere) and visually indistinguishable on + edges. The 8× default was pure waste — hence the cap. +- **4→2 is a real ~15–18% GPU win** (~2ms p50) but a genuine quality regression: + 2× under-resolves the near-axis-aligned edges this app is full of. Not taken — + GPU budget is comfortable (~10ms p50 vs 16.6ms at the shipping max DPR of 2), so + there's no frame-rate to buy at this resolution. Weaker-GPU / huge-display cases + are already covered by adaptive DPR and the probe's own 4→2→0 fallback. + +Caveat: default-DPR runs (not the `dpr=4` danger zone), and M4 Max is best-case — +the relative MSAA cost grows with pixel count, but production caps DPR at 2. + --- # Priority of follow-ups diff --git a/src/perf/PerfHarness.tsx b/src/perf/PerfHarness.tsx index d6a2903..dc19eee 100644 --- a/src/perf/PerfHarness.tsx +++ b/src/perf/PerfHarness.tsx @@ -160,6 +160,7 @@ export function PerfHarness({ dpr: flags.dprOverride, canvas: flags.canvasSize, post: flags.postOverride, + msaa: flags.msaaOverride, instancing: flags.instancing, perforations: flags.perforations, }, diff --git a/src/perf/harness.ts b/src/perf/harness.ts index d62ced7..c0998f7 100644 --- a/src/perf/harness.ts +++ b/src/perf/harness.ts @@ -16,6 +16,8 @@ // ?dpr= — override DPR (e.g. 0.25, 0.5, 1, 2) // ?canvas= — square canvas size (e.g. 256, 512, 1024) or full // ?post= — none | outline | outline+ao +// ?msaa= — force EffectComposer MSAA samples (0, 2, 4, 8), +// bypassing the probe — for sample-count ablation // ?instancing= — instanced (default) | cloned (for ablation) // ?perforations= — on (default) | off — strips alpha-tested perforation // geometry to measure its overdraw cost @@ -48,6 +50,7 @@ export interface PerfFlags { dprOverride: number | [number, number] | null canvasSize: number | 'full' postOverride: PostMode | null + msaaOverride: number | null instancing: InstancingMode perforations: PerforationsMode download: boolean @@ -88,6 +91,13 @@ export function parsePerfFlags(search: string = window.location.search): PerfFla postOverride = post } + let msaaOverride: number | null = null + const msaa = params.get('msaa') + if (msaa !== null) { + const n = Number(msaa) + if (Number.isFinite(n) && n >= 0) msaaOverride = Math.floor(n) + } + const instancingRaw = params.get('instancing') const instancing: InstancingMode = instancingRaw === 'cloned' ? 'cloned' : 'instanced' @@ -104,6 +114,7 @@ export function parsePerfFlags(search: string = window.location.search): PerfFla dprOverride, canvasSize, postOverride, + msaaOverride, instancing, perforations, download, @@ -148,6 +159,7 @@ export interface ScenarioResult { dpr: number | [number, number] | null canvas: number | 'full' post: PostMode | null + msaa: number | null instancing: InstancingMode perforations: PerforationsMode }