2 Commits

Author SHA1 Message Date
Tom Boullay a3f611e227 fix(webgl): auto-restore context after loss
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
The Canvas onCreated callback used to log Context Lost but never asked
the GPU to restore it, which left the page on a frozen black canvas
until the user reloaded. We now grab the WEBGL_lose_context extension
on mount and call restoreContext() 500ms after a loss, giving the GPU
time to free memory before we ask for a new context. The existing
webglcontextrestored handler reinstates the shadow map settings, so
recovery is transparent to the user.

This does not prevent context loss itself — frequent losses still
indicate VRAM pressure or HMR-driven context churn — but it removes
the need to reload manually when the GPU recycles us.
2026-05-30 20:58:58 +02:00
Tom Boullay b578e68c2e Update SiteTransitionOverlay.tsx 2026-05-30 20:55:51 +02:00
2 changed files with 17 additions and 26 deletions
+3 -23
View File
@@ -16,16 +16,15 @@ const DIALOGUE_FALLBACK_TIMEOUT_MS = 12000;
const NO_DIALOGUE_FALLBACK_MS = 3000;
/**
* Transition overlay: black screen, logo fade-in, transition dialogue
* with subtitles, then redirect to /. A safety timeout guarantees the
* redirect happens even if the dialogue audio fails to fire `ended`.
* Transition overlay: black screen with transition dialogue and subtitles,
* then redirect to /. A safety timeout guarantees the redirect happens even if
* the dialogue audio fails to fire `ended`.
*/
export function SiteTransitionOverlay(): React.JSX.Element {
const navigate = useNavigate();
const reset = useSiteStore((state) => state.reset);
const prefersReducedMotion = usePrefersReducedMotion();
const [screenOpacity, setScreenOpacity] = useState(0);
const [logoOpacity, setLogoOpacity] = useState(0);
useEffect(() => {
setSiteVisited();
@@ -37,13 +36,11 @@ export function SiteTransitionOverlay(): React.JSX.Element {
// initial frame at opacity 0 before flipping to 1.
const fadeInId = window.setTimeout(() => {
setScreenOpacity(1);
setLogoOpacity(1);
}, 0);
timeoutIds.push(fadeInId);
const redirectToGame = (): void => {
if (isCancelled) return;
setLogoOpacity(0);
const id = window.setTimeout(() => {
if (isCancelled) return;
reset();
@@ -119,23 +116,6 @@ export function SiteTransitionOverlay(): React.JSX.Element {
transition: fadeTransition,
}}
/>
<img
src="/assets/logo/logo.jpg"
alt="Logo Altera"
style={{
position: "relative",
zIndex: 1,
width: "min(300px, 45vw)",
height: "auto",
objectFit: "contain",
opacity: logoOpacity,
transition: fadeTransition,
transitionDelay:
!prefersReducedMotion && logoOpacity === 1
? `${FADE_DURATION_MS}ms`
: "0ms",
}}
/>
{/* Subtitles must live inside this overlay's stacking context
(z-index 1000) so they render above the black screen. The
<Subtitles /> in SiteLayout sits behind this overlay. */}
+14 -3
View File
@@ -96,9 +96,16 @@ export function HomePage(): React.JSX.Element | null {
gl.shadowMap.type = THREE.PCFShadowMap;
gl.shadowMap.autoUpdate = true;
// The browser hands us a WEBGL_lose_context extension we can use to
// ask the GPU to restore the context after a loss. Without this the
// page stays frozen on a black canvas until the user reloads.
const loseContextExt = gl.getContext().getExtension("WEBGL_lose_context");
const handleContextLost = (event: Event) => {
event.preventDefault();
logger.error("WebGL", "Context lost - GPU resources exhausted");
logger.error("WebGL", "Context lost - attempting auto-restore");
// Give the GPU a moment to free resources before asking it back.
window.setTimeout(() => loseContextExt?.restoreContext(), 500);
};
const handleContextRestored = () => {
@@ -121,10 +128,14 @@ export function HomePage(): React.JSX.Element | null {
// all hooks (rules of hooks) but BEFORE any expensive render.
if (!hasSiteBeenVisitedToday()) return null;
const showFadeToVideoOverlay =
introStep === "fade-to-video" ||
(introStep === "loading-map" && sceneLoadingState.status === "ready");
const renderIntroOverlay = () => {
if (showFadeToVideoOverlay) return <FadeToVideoOverlay />;
switch (introStep) {
case "fade-to-video":
return <FadeToVideoOverlay />;
case "video":
return <IntroVideoPlayer />;
case "dialogue-intro":