Skip to content

Commit ae9517f

Browse files
sktbrdclaude
andcommitted
fix(webgl): degrade gracefully when the browser has no WebGL
The MiniTV renders from the root layout, so its three.js canvas mounts on every route. On a browser with hardware acceleration off (also: sandboxed contexts, blocklisted drivers, embedded webviews) getContext returns null, WebGLRenderer throws, and with nothing catching it React unwound the whole tree into Next.js' "This page couldn't load" screen. A decorative 120px television was taking the site down for those users. Adds a cached support probe and a guard component pairing that probe with an error boundary — the probe catches the common case before three.js/ogl construct anything, the boundary catches context loss, driver crashes, exhausted context slots and shaders that won't compile on a given machine. Applied to all four WebGL mount points. The fallback is silent: the scene does not render, everything around it does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 5b5cbf4 commit ae9517f

8 files changed

Lines changed: 376 additions & 107 deletions

File tree

src/components/lootbox/AnimatedChest3D.tsx

Lines changed: 59 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@
66
the particle buffers, useFrame mutates those buffers + reads refs each RAF frame
77
(outside React render), and loaded textures get their wrap/colorSpace set once.
88
None of this runs in React's render path. */
9-
109
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
1110
import { Environment, OrbitControls, PerspectiveCamera, useGLTF } from "@react-three/drei";
1211
import { Canvas, useFrame, useLoader } from "@react-three/fiber";
1312
import { Group, PointLight, TextureLoader } from "three";
1413
import * as THREE from "three";
14+
import { WebGLGuard } from "@/components/webgl/WebGLGuard";
1515

1616
interface ChestProps {
1717
onClick: () => void;
@@ -249,7 +249,7 @@ const FuturisticCrate = memo(({ onClick, isOpening, isPending, tier = "bronze" }
249249
const floatingLogoRef = useRef<Group>(null);
250250
const interiorGlowRef = useRef<THREE.MeshBasicMaterial>(null);
251251
const logoGlowRef = useRef<THREE.MeshBasicMaterial>(null);
252-
252+
253253
// Get tier colors
254254
const tierColors = TIER_COLORS[tier];
255255

@@ -259,7 +259,7 @@ const FuturisticCrate = memo(({ onClick, isOpening, isPending, tier = "bronze" }
259259

260260
// Load Gnars logo 3D model for button
261261
const gnarsLogoModel = useGLTF("/models/gnars-logo.glb");
262-
262+
263263
// Clone the model scene to allow multiple instances
264264
const clonedLogoScene = useMemo(() => {
265265
return gnarsLogoModel.scene.clone();
@@ -1445,51 +1445,65 @@ export default function AnimatedChest3D({
14451445

14461446
return (
14471447
<div className="relative w-full h-full rounded-lg overflow-hidden">
1448-
<Canvas
1449-
shadows
1450-
dpr={[1, 2]}
1451-
performance={{ min: 0.5 }}
1452-
gl={{ alpha: true, antialias: true }}
1453-
style={{ background: "transparent" }}
1454-
>
1455-
<PerspectiveCamera makeDefault position={[0, 1.5, 6]} fov={45} />
1456-
<OrbitControls
1457-
enableZoom={true}
1458-
enablePan={false}
1459-
minDistance={3}
1460-
maxDistance={8}
1461-
minPolarAngle={Math.PI / 6}
1462-
maxPolarAngle={Math.PI / 2}
1463-
enableDamping
1464-
dampingFactor={0.05}
1465-
/>
1448+
{/* No context, no crate — but the panel around it keeps working. */}
1449+
<WebGLGuard label="lootbox-chest">
1450+
<Canvas
1451+
shadows
1452+
dpr={[1, 2]}
1453+
performance={{ min: 0.5 }}
1454+
gl={{ alpha: true, antialias: true }}
1455+
style={{ background: "transparent" }}
1456+
>
1457+
<PerspectiveCamera makeDefault position={[0, 1.5, 6]} fov={45} />
1458+
<OrbitControls
1459+
enableZoom={true}
1460+
enablePan={false}
1461+
minDistance={3}
1462+
maxDistance={8}
1463+
minPolarAngle={Math.PI / 6}
1464+
maxPolarAngle={Math.PI / 2}
1465+
enableDamping
1466+
dampingFactor={0.05}
1467+
/>
14661468

1467-
{/* Lighting */}
1468-
<ambientLight intensity={0.4} />
1469-
<directionalLight
1470-
position={[5, 8, 5]}
1471-
intensity={1.5}
1472-
castShadow
1473-
shadow-mapSize-width={1024}
1474-
shadow-mapSize-height={1024}
1475-
/>
1476-
<directionalLight position={[-3, 5, -3]} intensity={0.6} color="#88aaff" />
1477-
<spotLight
1478-
position={[0, 3, 4]}
1479-
angle={0.5}
1480-
penumbra={1}
1481-
intensity={1.2}
1482-
color="#ffffff"
1483-
castShadow
1484-
/>
1485-
<spotLight position={[-4, 2, 2]} angle={0.4} penumbra={1} intensity={0.4} color="#6699ff" />
1469+
{/* Lighting */}
1470+
<ambientLight intensity={0.4} />
1471+
<directionalLight
1472+
position={[5, 8, 5]}
1473+
intensity={1.5}
1474+
castShadow
1475+
shadow-mapSize-width={1024}
1476+
shadow-mapSize-height={1024}
1477+
/>
1478+
<directionalLight position={[-3, 5, -3]} intensity={0.6} color="#88aaff" />
1479+
<spotLight
1480+
position={[0, 3, 4]}
1481+
angle={0.5}
1482+
penumbra={1}
1483+
intensity={1.2}
1484+
color="#ffffff"
1485+
castShadow
1486+
/>
1487+
<spotLight
1488+
position={[-4, 2, 2]}
1489+
angle={0.4}
1490+
penumbra={1}
1491+
intensity={0.4}
1492+
color="#6699ff"
1493+
/>
14861494

1487-
{/* Environment for reflections */}
1488-
<Environment preset="warehouse" background={false} />
1495+
{/* Environment for reflections */}
1496+
<Environment preset="warehouse" background={false} />
14891497

1490-
{/* The Futuristic Crate */}
1491-
<FuturisticCrate onClick={handleChestClick} isOpening={isOpening} isPending={isPending} tier={tier} />
1492-
</Canvas>
1498+
{/* The Futuristic Crate */}
1499+
<FuturisticCrate
1500+
onClick={handleChestClick}
1501+
isOpening={isOpening}
1502+
isPending={isPending}
1503+
tier={tier}
1504+
/>
1505+
</Canvas>
1506+
</WebGLGuard>
14931507

14941508
{/* Sci-fi corner decorations — status is shown by the surrounding panel's
14951509
stepper (translated), so no text overlay here. */}

src/components/nogglesrails/NogglesRailsGlobe.tsx

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
44
import dynamic from "next/dynamic";
55
import { MapLocationDrawer, type LocationData } from "@/components/map-location-drawer";
6+
import { WebGLGuard } from "@/components/webgl/WebGLGuard";
67
import { NOGGLES_RAILS, type NogglesRailLocation } from "@/content/nogglesrails";
78
import { toLocationData } from "./NogglesRailsMap";
89

@@ -156,22 +157,26 @@ export function NogglesRailsGlobe({
156157
className={className ?? "h-[60vh] min-h-[350px] overflow-hidden rounded-lg"}
157158
>
158159
{dimensions.width > 0 && (
159-
<Globe
160-
ref={globeRef}
161-
width={dimensions.width}
162-
height={dimensions.height}
163-
globeImageUrl="https://unpkg.com/three-globe/example/img/earth-blue-marble.jpg"
164-
bumpImageUrl="https://unpkg.com/three-globe/example/img/earth-topology.png"
165-
onGlobeReady={() => setReady(true)}
166-
backgroundColor="rgba(0,0,0,0)"
167-
atmosphereColor="#6699cc"
168-
atmosphereAltitude={0.15}
169-
htmlElementsData={points}
170-
htmlLat="lat"
171-
htmlLng="lng"
172-
htmlAltitude={0.01}
173-
htmlElement={markerHtmlElement}
174-
/>
160+
// three-globe builds a WebGLRenderer on mount; without a context that
161+
// throws through React and takes the whole page down with it.
162+
<WebGLGuard label="noggles-rails-globe">
163+
<Globe
164+
ref={globeRef}
165+
width={dimensions.width}
166+
height={dimensions.height}
167+
globeImageUrl="https://unpkg.com/three-globe/example/img/earth-blue-marble.jpg"
168+
bumpImageUrl="https://unpkg.com/three-globe/example/img/earth-topology.png"
169+
onGlobeReady={() => setReady(true)}
170+
backgroundColor="rgba(0,0,0,0)"
171+
atmosphereColor="#6699cc"
172+
atmosphereAltitude={0.15}
173+
htmlElementsData={points}
174+
htmlLat="lat"
175+
htmlLng="lng"
176+
htmlAltitude={0.01}
177+
htmlElement={markerHtmlElement}
178+
/>
179+
</WebGLGuard>
175180
)}
176181
</div>
177182
<MapLocationDrawer location={selected} open={drawerOpen} onOpenChange={setDrawerOpen} />

src/components/tv/FaultyTerminal.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
/* eslint-disable react-hooks/purity -- OGL WebGL imperative API requires render-time allocation */
44
import { useCallback, useEffect, useMemo, useRef } from "react";
55
import { Color, Mesh, Program, Renderer, Triangle } from "ogl";
6+
import { hasWebGLSupport } from "@/lib/webgl";
67

78
const vertexShader = `
89
attribute vec2 position;
@@ -295,6 +296,10 @@ export function FaultyTerminal({
295296
useEffect(() => {
296297
const ctn = containerRef.current;
297298
if (!ctn) return;
299+
// ogl's Renderer throws when it cannot get a context (GPU disabled,
300+
// sandboxed, blocklisted driver). This is a decorative background, so a
301+
// machine without WebGL simply gets the empty container.
302+
if (!hasWebGLSupport()) return;
298303

299304
const renderer = new Renderer({ dpr });
300305
rendererRef.current = renderer;

src/components/tv/Gnar3DTVScene.tsx

Lines changed: 52 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Suspense, useCallback, useEffect, useRef, useState } from "react";
44
import { OrbitControls } from "@react-three/drei";
55
import { Canvas } from "@react-three/fiber";
66
import type { WebGLRenderer } from "three";
7+
import { WebGLGuard } from "@/components/webgl/WebGLGuard";
78
import { TV3DModel } from "./TV3DModel";
89
import { useTVTextureControls } from "./TVTextureControls";
910
import type { CreatorCoinImage } from "./useTVFeed";
@@ -60,54 +61,59 @@ export function Gnar3DTVScene({
6061

6162
return (
6263
<div ref={containerRef} className="relative h-full w-full">
63-
<Canvas
64-
camera={{ position: [0, 0.5, 4], fov: 60 }}
65-
frameloop="demand"
66-
gl={{
67-
antialias: false,
68-
alpha: true,
69-
powerPreference: "low-power",
70-
failIfMajorPerformanceCaveat: false,
71-
// Don't preserve drawing buffer - allows GPU to discard after compositing
72-
preserveDrawingBuffer: false,
73-
// Prefer low power GPU on multi-GPU systems
74-
precision: "lowp",
75-
// Limit pixel ratio for memory savings
76-
depth: true,
77-
stencil: false,
78-
}}
79-
onCreated={handleCreated}
80-
style={{ background: "transparent" }}
81-
dpr={dpr}
82-
>
83-
{/* Lighting - simplified for better performance */}
84-
<ambientLight intensity={3.5} />
85-
<directionalLight position={[5, 5, 5]} intensity={2.5} />
86-
<directionalLight position={[-3, 3, -3]} intensity={1.5} />
64+
{/* The MiniTV that renders this scene sits in the root layout, so a
65+
machine without WebGL used to lose every page to the global error
66+
screen rather than just the television. */}
67+
<WebGLGuard label="gnars-tv">
68+
<Canvas
69+
camera={{ position: [0, 0.5, 4], fov: 60 }}
70+
frameloop="demand"
71+
gl={{
72+
antialias: false,
73+
alpha: true,
74+
powerPreference: "low-power",
75+
failIfMajorPerformanceCaveat: false,
76+
// Don't preserve drawing buffer - allows GPU to discard after compositing
77+
preserveDrawingBuffer: false,
78+
// Prefer low power GPU on multi-GPU systems
79+
precision: "lowp",
80+
// Limit pixel ratio for memory savings
81+
depth: true,
82+
stencil: false,
83+
}}
84+
onCreated={handleCreated}
85+
style={{ background: "transparent" }}
86+
dpr={dpr}
87+
>
88+
{/* Lighting - simplified for better performance */}
89+
<ambientLight intensity={3.5} />
90+
<directionalLight position={[5, 5, 5]} intensity={2.5} />
91+
<directionalLight position={[-3, 3, -3]} intensity={1.5} />
8792

88-
{/* TV Model */}
89-
<Suspense fallback={null}>
90-
<TV3DModel
91-
videoUrl={videoUrl}
92-
autoRotate={autoRotate}
93-
onNextVideo={onNextVideo}
94-
textureConfig={config}
95-
creatorCoinImages={creatorCoinImages}
96-
isVisible={isVisible}
97-
/>
98-
</Suspense>
93+
{/* TV Model */}
94+
<Suspense fallback={null}>
95+
<TV3DModel
96+
videoUrl={videoUrl}
97+
autoRotate={autoRotate}
98+
onNextVideo={onNextVideo}
99+
textureConfig={config}
100+
creatorCoinImages={creatorCoinImages}
101+
isVisible={isVisible}
102+
/>
103+
</Suspense>
99104

100-
{/* Controls */}
101-
{enableOrbitControls && (
102-
<OrbitControls
103-
enableDamping={false}
104-
minDistance={2}
105-
maxDistance={8}
106-
enablePan={false}
107-
enableZoom={false}
108-
/>
109-
)}
110-
</Canvas>
105+
{/* Controls */}
106+
{enableOrbitControls && (
107+
<OrbitControls
108+
enableDamping={false}
109+
minDistance={2}
110+
maxDistance={8}
111+
enablePan={false}
112+
enableZoom={false}
113+
/>
114+
)}
115+
</Canvas>
116+
</WebGLGuard>
111117
</div>
112118
);
113119
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"use client";
2+
3+
import { Component, useEffect, useState, type ReactNode } from "react";
4+
import { hasWebGLSupport } from "@/lib/webgl";
5+
6+
interface WebGLGuardProps {
7+
children: ReactNode;
8+
/** Rendered instead of `children` when WebGL is unavailable or the scene throws. */
9+
fallback?: ReactNode;
10+
/** Label used in the console warning so a report names the scene that fell back. */
11+
label?: string;
12+
}
13+
14+
interface BoundaryState {
15+
failed: boolean;
16+
}
17+
18+
class WebGLErrorBoundary extends Component<
19+
{ children: ReactNode; fallback: ReactNode; label: string },
20+
BoundaryState
21+
> {
22+
state: BoundaryState = { failed: false };
23+
24+
static getDerivedStateFromError(): BoundaryState {
25+
return { failed: true };
26+
}
27+
28+
componentDidCatch(error: unknown) {
29+
// Not a crash we want reported as one: the page is expected to keep working
30+
// without the scene. Log it so a support report still carries the reason.
31+
console.warn(`[webgl] ${this.props.label} scene failed, falling back`, error);
32+
}
33+
34+
render() {
35+
return this.state.failed ? this.props.fallback : this.props.children;
36+
}
37+
}
38+
39+
/**
40+
* Renders a WebGL scene only where WebGL actually works.
41+
*
42+
* Two layers, because the failure has two shapes:
43+
* - support probe — the common case (GPU disabled, sandboxed, blocklisted
44+
* driver). Caught before three.js/ogl ever construct a renderer.
45+
* - error boundary — everything else: context creation that fails despite a
46+
* passing probe, a driver crash, too many live contexts, a shader that will
47+
* not compile on that machine.
48+
*
49+
* Without this, `new WebGLRenderer()` throws through React and, since the
50+
* MiniTV lives in the root layout, replaces every page with the global error
51+
* screen. See `src/lib/webgl.ts`.
52+
*/
53+
export function WebGLGuard({ children, fallback = null, label = "webgl" }: WebGLGuardProps) {
54+
// Probing needs a DOM, so the answer is only known after mount. `null` keeps
55+
// the server markup and the first client render identical.
56+
const [supported, setSupported] = useState<boolean | null>(null);
57+
58+
useEffect(() => {
59+
setSupported(hasWebGLSupport());
60+
}, []);
61+
62+
if (supported === null) return <>{fallback}</>;
63+
if (!supported) return <>{fallback}</>;
64+
65+
return (
66+
<WebGLErrorBoundary fallback={fallback} label={label}>
67+
{children}
68+
</WebGLErrorBoundary>
69+
);
70+
}

0 commit comments

Comments
 (0)