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
38 changes: 33 additions & 5 deletions 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 } from './gpuProbe'
import { detectSoftwareRendering, probeMaxBufferSize } from './gpuProbe'
import { eventsWithoutHover } from './perf/eventsWithoutHover'
import { markInit, parsePerfFlags, type PerfFlags } from './perf/harness'

Expand Down Expand Up @@ -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<GPUConfig | null>(null)
// Largest render-target edge the GPU allows; clamps DPR below. Null = no cap.
const [maxBufferSize] = useState(() => probeMaxBufferSize())

useEffect(() => {
let cancelled = false
Expand All @@ -1025,10 +1027,16 @@ export const Scene = () => {
}, [])

if (!detectedConfig) return null
return <SceneCanvas detectedConfig={detectedConfig} />
return <SceneCanvas detectedConfig={detectedConfig} maxBufferSize={maxBufferSize} />
}

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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions src/gpuProbe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Loading