diff --git a/src/Scene.tsx b/src/Scene.tsx index 1b25538..d7d295d 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,8 @@ 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; clamps DPR below. Null = no cap. + const [maxBufferSize] = useState(() => probeMaxBufferSize()) useEffect(() => { let cancelled = false @@ -1025,10 +1027,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 +1047,22 @@ 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; tracked so the DPR cap re-tightens on resize. + 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 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) + : 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 +1105,16 @@ const SceneCanvas = ({ detectedConfig }: { detectedConfig: GPUConfig }) => { const [perfFactor, setPerfFactor] = useState(1) // Derived DPR: manual overrides pin to maxDpr/1; 'auto' scales with perfFactor. - const dpr = + // 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' ? 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..f0ab6ec 100644 --- a/src/gpuProbe.ts +++ b/src/gpuProbe.ts @@ -34,3 +34,24 @@ export function detectSoftwareRendering(): boolean { return false } } + +/** Largest render-target edge (device px) the GPU allows: min of + * MAX_RENDERBUFFER_SIZE and MAX_TEXTURE_SIZE. 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 { + 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 + } +}