From a71428b313d214bf740560e5a009ade05ee809de Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Thu, 25 Jun 2026 21:06:17 +0100 Subject: [PATCH 1/3] Max buffer size --- src/Scene.tsx | 44 +++++++++++++++++++++++++++++++++++++++----- src/gpuProbe.ts | 24 ++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/src/Scene.tsx b/src/Scene.tsx index 1b25538..2067009 100644 --- a/src/Scene.tsx +++ b/src/Scene.tsx @@ -68,7 +68,7 @@ import { resolveWaypoint, type ModelConfig, } from './data/componentTree' -import { detectSoftwareRendering } from './gpuProbe' +import { detectSoftwareRendering, probeMaxBufferSize } from './gpuProbe' import { eventsWithoutHover } from './perf/eventsWithoutHover' import { markInit, parsePerfFlags, type PerfFlags } from './perf/harness' @@ -1004,6 +1004,10 @@ export const Scene = () => { // options (powerPreference, antialias, precision) can be tier-aware — these // can't be changed after the WebGL context is created. Detection is ~30ms. const [detectedConfig, setDetectedConfig] = useState(null) + // Largest render-target edge the GPU allows, queried once. Null = unknown + // (treated as no cap). Used to clamp DPR so post-processing buffers can't + // exceed Firefox's renderbuffer limit and take down the context. + const [maxBufferSize] = useState(() => probeMaxBufferSize()) useEffect(() => { let cancelled = false @@ -1025,10 +1029,16 @@ export const Scene = () => { }, []) if (!detectedConfig) return null - return + return } -const SceneCanvas = ({ detectedConfig }: { detectedConfig: GPUConfig }) => { +const SceneCanvas = ({ + detectedConfig, + maxBufferSize, +}: { + detectedConfig: GPUConfig + maxBufferSize: number | null +}) => { const lastMissTime = useRef(0) const postSetting = useValue(postProcessingSetting) const dprSetting = useValue(resolutionSetting) @@ -1039,6 +1049,25 @@ const SceneCanvas = ({ detectedConfig }: { detectedConfig: GPUConfig }) => { // are immutable after WebGL context creation, so we can't track lowTier here. const [tierAtMount] = useState(() => (detectedConfig.tier ?? 1) < 2) + // Longest viewport edge in CSS px (canvas is fixed full-screen). Drives the + // DPR cap below; tracked so the cap re-tightens when the window grows. + const [viewportEdge, setViewportEdge] = useState(() => + typeof window === 'undefined' ? 0 : Math.max(window.innerWidth, window.innerHeight), + ) + useEffect(() => { + const onResize = () => + setViewportEdge(Math.max(window.innerWidth, window.innerHeight)) + window.addEventListener('resize', onResize) + return () => window.removeEventListener('resize', onResize) + }, []) + // Highest DPR that keeps every render-target edge within the GPU's limit. + // The 0.98 margin leaves headroom for rounding in derived (e.g. half-res) + // passes. Infinity when the limit is unknown or the viewport is unmeasured. + const bufferDprCap = + maxBufferSize && viewportEdge > 0 + ? Math.max(1, (maxBufferSize * 0.98) / viewportEdge) + : Infinity + // Tier promotion via sustained factor. detect-gpu's database lags new GPUs // and some browsers farble the renderer string, so capable GPUs can land on // tier 1 even after the renderer-string regex fallback. After @@ -1081,12 +1110,17 @@ const SceneCanvas = ({ detectedConfig }: { detectedConfig: GPUConfig }) => { const [perfFactor, setPerfFactor] = useState(1) // Derived DPR: manual overrides pin to maxDpr/1; 'auto' scales with perfFactor. - const dpr = + // Clamped to bufferDprCap so post-processing render targets never exceed the + // GPU's MAX_RENDERBUFFER_SIZE (Firefox fails the allocation and loses the + // context rather than clamping like Chrome/ANGLE does). + const dpr = Math.min( + bufferDprCap, dprSetting === 'high' ? maxDpr : dprSetting === 'low' ? 1 - : Math.max(1, 1 + (maxDpr - 1) * perfFactor) + : Math.max(1, 1 + (maxDpr - 1) * perfFactor), + ) // Promotion timer: factor sustained above PROMOTION_FACTOR_THRESHOLD for the // full duration → bump tier. The first-crossed timestamp lives in a ref so diff --git a/src/gpuProbe.ts b/src/gpuProbe.ts index fff062d..91ed42a 100644 --- a/src/gpuProbe.ts +++ b/src/gpuProbe.ts @@ -34,3 +34,27 @@ export function detectSoftwareRendering(): boolean { return false } } + +/** The largest square edge (in device pixels) the GPU will allocate a render + * target at, taken as the min of MAX_RENDERBUFFER_SIZE and MAX_TEXTURE_SIZE. + * + * Post-processing (EffectComposer, N8AO normal pass, MSAA) allocates offscreen + * targets at `canvasSize × DPR`. Chrome/ANGLE silently clamps oversized buffers; + * Firefox fails the allocation, yielding an incomplete framebuffer that takes + * the whole context down ("Width or height exceeds maximum renderbuffer size"). + * Callers clamp DPR so device dimensions stay within this. Null if unknown. */ +export function probeMaxBufferSize(): number | null { + if (typeof document === 'undefined') return null + try { + const canvas = document.createElement('canvas') + const gl = canvas.getContext('webgl2') as WebGL2RenderingContext | null + if (!gl) return null + const maxRb = gl.getParameter(gl.MAX_RENDERBUFFER_SIZE) as number + const maxTex = gl.getParameter(gl.MAX_TEXTURE_SIZE) as number + gl.getExtension('WEBGL_lose_context')?.loseContext() + const max = Math.min(maxRb, maxTex) + return Number.isFinite(max) && max > 0 ? max : null + } catch { + return null + } +} From 5df20f75853994a4941e55d8a7870ea4ab883da4 Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Fri, 26 Jun 2026 10:11:38 +0100 Subject: [PATCH 2/3] Less verbose comments --- src/Scene.tsx | 17 ++++++----------- src/gpuProbe.ts | 11 ++++------- 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/src/Scene.tsx b/src/Scene.tsx index 2067009..b9748e4 100644 --- a/src/Scene.tsx +++ b/src/Scene.tsx @@ -1004,9 +1004,7 @@ export const Scene = () => { // options (powerPreference, antialias, precision) can be tier-aware — these // can't be changed after the WebGL context is created. Detection is ~30ms. const [detectedConfig, setDetectedConfig] = useState(null) - // Largest render-target edge the GPU allows, queried once. Null = unknown - // (treated as no cap). Used to clamp DPR so post-processing buffers can't - // exceed Firefox's renderbuffer limit and take down the context. + // Largest render-target edge the GPU allows; clamps DPR below. Null = no cap. const [maxBufferSize] = useState(() => probeMaxBufferSize()) useEffect(() => { @@ -1049,8 +1047,7 @@ const SceneCanvas = ({ // are immutable after WebGL context creation, so we can't track lowTier here. const [tierAtMount] = useState(() => (detectedConfig.tier ?? 1) < 2) - // Longest viewport edge in CSS px (canvas is fixed full-screen). Drives the - // DPR cap below; tracked so the cap re-tightens when the window grows. + // Longest viewport edge in CSS px; tracked so the DPR cap re-tightens on resize. const [viewportEdge, setViewportEdge] = useState(() => typeof window === 'undefined' ? 0 : Math.max(window.innerWidth, window.innerHeight), ) @@ -1060,9 +1057,8 @@ const SceneCanvas = ({ window.addEventListener('resize', onResize) return () => window.removeEventListener('resize', onResize) }, []) - // Highest DPR that keeps every render-target edge within the GPU's limit. - // The 0.98 margin leaves headroom for rounding in derived (e.g. half-res) - // passes. Infinity when the limit is unknown or the viewport is unmeasured. + // Highest DPR that keeps render-target edges within the GPU's limit; 0.98 + // leaves headroom for rounding in derived (half-res) passes. Infinity = no cap. const bufferDprCap = maxBufferSize && viewportEdge > 0 ? Math.max(1, (maxBufferSize * 0.98) / viewportEdge) @@ -1110,9 +1106,8 @@ const SceneCanvas = ({ const [perfFactor, setPerfFactor] = useState(1) // Derived DPR: manual overrides pin to maxDpr/1; 'auto' scales with perfFactor. - // Clamped to bufferDprCap so post-processing render targets never exceed the - // GPU's MAX_RENDERBUFFER_SIZE (Firefox fails the allocation and loses the - // context rather than clamping like Chrome/ANGLE does). + // bufferDprCap keeps render targets within the GPU limit — Firefox loses the + // context on overflow rather than clamping like Chrome/ANGLE. const dpr = Math.min( bufferDprCap, dprSetting === 'high' diff --git a/src/gpuProbe.ts b/src/gpuProbe.ts index 91ed42a..f0ab6ec 100644 --- a/src/gpuProbe.ts +++ b/src/gpuProbe.ts @@ -35,14 +35,11 @@ export function detectSoftwareRendering(): boolean { } } -/** The largest square edge (in device pixels) the GPU will allocate a render - * target at, taken as the min of MAX_RENDERBUFFER_SIZE and MAX_TEXTURE_SIZE. +/** Largest render-target edge (device px) the GPU allows: min of + * MAX_RENDERBUFFER_SIZE and MAX_TEXTURE_SIZE. Null if unknown. * - * Post-processing (EffectComposer, N8AO normal pass, MSAA) allocates offscreen - * targets at `canvasSize × DPR`. Chrome/ANGLE silently clamps oversized buffers; - * Firefox fails the allocation, yielding an incomplete framebuffer that takes - * the whole context down ("Width or height exceeds maximum renderbuffer size"). - * Callers clamp DPR so device dimensions stay within this. Null if unknown. */ + * Post-processing targets are sized at `canvasSize × DPR`; on overflow Firefox + * loses the context (Chrome/ANGLE silently clamps), so callers clamp DPR to fit. */ export function probeMaxBufferSize(): number | null { if (typeof document === 'undefined') return null try { From e6d3e5c3e67cc13b286ae74028d1f65cff270409 Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Fri, 26 Jun 2026 10:12:02 +0100 Subject: [PATCH 3/3] Fmt --- src/Scene.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Scene.tsx b/src/Scene.tsx index b9748e4..d7d295d 100644 --- a/src/Scene.tsx +++ b/src/Scene.tsx @@ -1052,8 +1052,7 @@ const SceneCanvas = ({ typeof window === 'undefined' ? 0 : Math.max(window.innerWidth, window.innerHeight), ) useEffect(() => { - const onResize = () => - setViewportEdge(Math.max(window.innerWidth, window.innerHeight)) + const onResize = () => setViewportEdge(Math.max(window.innerWidth, window.innerHeight)) window.addEventListener('resize', onResize) return () => window.removeEventListener('resize', onResize) }, [])