1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2026-present FERS Contributors (see AUTHORS.md).
4export type WebGLContextMode = 'webgl2' | 'webgl' | 'experimental-webgl';
6interface NavigatorWithUserAgentData extends Navigator {
12export interface WebGLProbeResult {
13 mode: WebGLContextMode;
16 lostAtCreation: boolean;
17 lostAfterExercise: boolean;
18 clearError: number | null;
19 version: string | null;
20 renderer: string | null;
21 vendor: string | null;
22 shaderCreated: boolean;
26export interface WebGLPlatformInfo {
33export interface WebGLSupportReport {
34 rendererSupported: boolean;
35 supportedMode: WebGLContextMode | null;
37 platform: WebGLPlatformInfo;
38 probes: WebGLProbeResult[];
41type WebGLProbeContext = WebGLRenderingContext | WebGL2RenderingContext;
43const CONTEXT_MODES: readonly WebGLContextMode[] = [
49let cachedReportPromise: Promise<WebGLSupportReport> | null = null;
51function getPlatformInfo(): WebGLPlatformInfo {
52 if (typeof navigator === 'undefined') {
61 const nav = navigator as NavigatorWithUserAgentData;
63 nav.userAgentData?.platform ?? navigator.platform ?? 'unknown';
64 const userAgent = navigator.userAgent ?? 'unknown';
65 const platformText = `${platform} ${userAgent}`;
66 const isMac = /mac/i.test(platformText);
67 const isIntelMac = isMac && /(intel|x86_64|macintel)/i.test(platformText);
77function safeIsContextLost(gl: WebGLProbeContext): boolean {
79 return typeof gl.isContextLost === 'function'
87function safeGetParameter(
88 gl: WebGLProbeContext,
92 const value = gl.getParameter(parameter);
94 if (value === null || value === undefined) {
104function releaseContext(gl: WebGLProbeContext): void {
106 gl.getExtension('WEBGL_lose_context')?.loseContext();
108 // Best-effort cleanup only.
112function buildFailureReason(result: {
114 lostAtCreation: boolean;
115 lostAfterExercise: boolean;
116 clearError: number | null;
117 shaderCreated: boolean;
118 version: string | null;
120 if (!result.available) {
121 return 'Context acquisition returned null.';
124 if (result.lostAtCreation) {
125 return 'Context was already lost at creation time.';
128 if (result.clearError === 37442) {
129 return 'Basic GL commands returned CONTEXT_LOST_WEBGL.';
132 if (result.lostAfterExercise) {
133 return 'Context was lost during the startup probe.';
136 if (!result.shaderCreated) {
137 return 'Shader creation failed during the startup probe.';
140 if (result.version === null) {
141 return 'Capability queries returned null.';
144 return 'Renderer startup checks failed.';
147function probeContext(mode: WebGLContextMode): WebGLProbeResult {
148 if (typeof document === 'undefined') {
153 lostAtCreation: false,
154 lostAfterExercise: false,
159 shaderCreated: false,
160 reason: 'Document is unavailable in the current runtime.',
164 const canvas = document.createElement('canvas');
168 let gl: WebGLProbeContext | null = null;
171 gl = canvas.getContext(mode, {
176 powerPreference: 'default',
177 }) as WebGLProbeContext | null;
184 lostAtCreation: false,
185 lostAfterExercise: false,
190 shaderCreated: false,
191 reason: 'Context acquisition returned null.',
195 const lostAtCreation = safeIsContextLost(gl);
196 const version = safeGetParameter(gl, gl.VERSION);
197 const renderer = safeGetParameter(gl, gl.RENDERER);
198 const vendor = safeGetParameter(gl, gl.VENDOR);
200 let clearError: number | null = null;
201 let lostAfterExercise = lostAtCreation;
202 let shaderCreated = false;
205 gl.viewport(0, 0, 1, 1);
206 gl.clearColor(0, 0, 0, 1);
207 gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
208 clearError = gl.getError();
209 lostAfterExercise = safeIsContextLost(gl);
211 const shader = gl.createShader(gl.VERTEX_SHADER);
212 shaderCreated = shader !== null;
215 gl.deleteShader(shader);
218 lostAfterExercise = safeIsContextLost(gl);
223 !lostAfterExercise &&
224 clearError !== gl.CONTEXT_LOST_WEBGL &&
240 ? 'Context passed startup checks.'
241 : buildFailureReason({
253 available: gl !== null,
255 lostAtCreation: gl ? safeIsContextLost(gl) : false,
256 lostAfterExercise: gl ? safeIsContextLost(gl) : false,
261 shaderCreated: false,
263 error instanceof Error
265 : 'Unknown WebGL startup error.',
277function buildSummary(
278 webgl2Probe: WebGLProbeResult | undefined,
279 fallbackProbe: WebGLProbeResult | undefined,
280 platform: WebGLPlatformInfo
283 return 'WebGL2 startup probe did not run.';
286 if (!webgl2Probe.available) {
287 return fallbackProbe?.usable
288 ? 'Only legacy WebGL is usable on this system. The current 3D renderer requires a working WebGL2 context.'
289 : 'No usable WebGL context was detected during startup.';
292 if (webgl2Probe.lostAtCreation || webgl2Probe.clearError === 37442) {
293 return platform.isIntelMac
294 ? 'The system WebKit/WebGL stack reported a lost WebGL context during startup on this Intel Mac.'
295 : 'The system WebGL stack reported a lost WebGL context during startup.';
298 return webgl2Probe.reason;
301function evaluateWebGLSupport(): WebGLSupportReport {
302 const platform = getPlatformInfo();
303 const probes = CONTEXT_MODES.map(probeContext);
304 const webgl2Probe = probes.find((probe) => probe.mode === 'webgl2');
305 const fallbackProbe = probes.find(
306 (probe) => probe.mode !== 'webgl2' && probe.usable
309 if (webgl2Probe?.usable) {
311 rendererSupported: true,
312 supportedMode: 'webgl2',
313 summary: 'WebGL2 startup probe succeeded.',
320 rendererSupported: false,
322 summary: buildSummary(webgl2Probe, fallbackProbe, platform),
328export function getWebGLSupportReport(): Promise<WebGLSupportReport> {
329 if (!cachedReportPromise) {
330 cachedReportPromise = Promise.resolve().then(evaluateWebGLSupport);
333 return cachedReportPromise;
336export function resetWebGLSupportReportCache(): void {
337 cachedReportPromise = null;