Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion src/Scene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -623,13 +623,15 @@ function SceneContent({
gpuTier,
perfFactor,
lowTier,
multisampling,
}: {
aoQuality: AOQuality
postMode: PerfFlags['postOverride']
instancing: PerfFlags['instancing']
gpuTier: number | undefined
perfFactor: number
lowTier: boolean
multisampling: number
}) {
const cameraControlsRef = useRef<CameraControls>(null)
const currentSelectedId = useValue(selectedId)
Expand Down Expand Up @@ -734,6 +736,7 @@ function SceneContent({
: aoQuality
}
enableOutline={postMode !== 'ao'}
multisampling={multisampling}
/>
)}
{postMode === 'none' && perfFlags.enabled && <ManualRenderer />}
Expand Down Expand Up @@ -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=<n>` 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.
Expand Down Expand Up @@ -1272,6 +1287,7 @@ const SceneCanvas = ({
gpuTier={effectiveConfig.tier}
perfFactor={perfFactor}
lowTier={lowTier}
multisampling={safeMultisampling}
/>
<PerformanceMonitor
ms={500}
Expand Down
11 changes: 10 additions & 1 deletion src/components/PostProcessing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,13 @@ const setSharedComposer = (composer: PPEffectComposer | null) => {
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<OutlineEffect>(null)
const patched = useRef(false)
Expand Down Expand Up @@ -82,7 +86,12 @@ export const PostProcessing = ({
}

return (
<EffectComposer ref={setSharedComposer} enableNormalPass={aoEnabled} autoClear={false}>
<EffectComposer
ref={setSharedComposer}
enableNormalPass={aoEnabled}
autoClear={false}
multisampling={multisampling}
>
{effects}
</EffectComposer>
)
Expand Down
57 changes: 57 additions & 0 deletions src/gpuProbe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
35 changes: 35 additions & 0 deletions src/perf/PERF.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ DevTools poking. `download=1` also saves `perf-<timestamp>.json`.
| `dpr=<n>` | Override devicePixelRatio (e.g. `1`, `2`, `4`). Bypasses GPU-tier config. |
| `canvas=<px>` | Pin canvas to a fixed square size. |
| `post=<mode>` | `none`, `outline`, `ao`, `outline+ao`. Isolates post-processing passes. |
| `msaa=<n>` | Force EffectComposer MSAA samples (`0`, `2`, `4`, `8`), bypassing the probe. For sample-count ablation. |
| `instancing=<mode>` | `instanced` (default) or `cloned` — unrolls instances into individual meshes. |
| `perforations=<mode>` | `on` (default) or `off` — strips alpha-tested perforation GLBs (sled / switch / power-shelf) to isolate their fillrate cost. |

Expand Down Expand Up @@ -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=<n>` 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
Expand Down
1 change: 1 addition & 0 deletions src/perf/PerfHarness.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
12 changes: 12 additions & 0 deletions src/perf/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
// ?dpr=<n> — override DPR (e.g. 0.25, 0.5, 1, 2)
// ?canvas=<px|full> — square canvas size (e.g. 256, 512, 1024) or full
// ?post=<mode> — none | outline | outline+ao
// ?msaa=<n> — force EffectComposer MSAA samples (0, 2, 4, 8),
// bypassing the probe — for sample-count ablation
// ?instancing=<mode> — instanced (default) | cloned (for ablation)
// ?perforations=<mode> — on (default) | off — strips alpha-tested perforation
// geometry to measure its overdraw cost
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'

Expand All @@ -104,6 +114,7 @@ export function parsePerfFlags(search: string = window.location.search): PerfFla
dprOverride,
canvasSize,
postOverride,
msaaOverride,
instancing,
perforations,
download,
Expand Down Expand Up @@ -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
}
Expand Down
Loading