From f6c73c17921fa88ba60e4f2e1db0ce8be59bb21a Mon Sep 17 00:00:00 2001 From: Danila Khodjaef Date: Sat, 25 Jul 2026 15:32:10 +0300 Subject: [PATCH] Smooth 3D hall boot: disable shadows and warm door shaders under the loading overlay. Keep Loading gallery until Environment and gl.compileAsync finish (with timeouts), GPU-upload painting textures before dismiss, and drop Canvas shadow maps. Co-authored-by: Cursor --- Documentation/basics.md | 11 +- Documentation/setup.md | 1 + client/src/components/GalleryWindows.tsx | 2 - client/src/components/HallPassage.tsx | 4 +- client/src/components/MovementHallDetails.tsx | 2 +- client/src/components/VirtualGallery.tsx | 184 ++++++++++++++---- 6 files changed, 157 insertions(+), 47 deletions(-) diff --git a/Documentation/basics.md b/Documentation/basics.md index a0c7148..bc9eee6 100644 --- a/Documentation/basics.md +++ b/Documentation/basics.md @@ -210,8 +210,8 @@ Pan, zoom, and era/event click-to-zoom only update **local** `viewStart` / `view | Overlay **“Loading art history…”** | Until the first catalog fetch (`bounds` + `timeline` + `artists`) completes | | Bottom banner **“Loading portraits…”** | While artist portrait thumbnails are still downloading on the movement flow (timeline stays interactive) | | Overlay **“Opening artist/movement gallery…”** | Between clicking a portrait/movement and the 3D hall data being ready | -| Overlay **“Loading gallery…”** | While the 3D canvas initializes after the hall opens (center area; header and controls stay visible) | -| Overlay **“Loading paintings…”** | While wall textures are still downloading in the 3D hall | +| Overlay **“Loading gallery…”** | While the 3D canvas initializes, HDR Environment settles, or door/hall shaders warm up after the hall opens | +| Overlay **“Loading paintings…”** | While wall painting textures are still downloading / uploading to the GPU | | Overlay **“Restoring gallery…”** | Briefly after WebGL context loss while the canvas remounts | View updates are **batched to one commit per animation frame** via `createViewChangeScheduler()` in `timelineView.ts` (`HomePage.tsx` → `handleViewChange`), so rapid scroll-wheel events do not flood React with separate renders. @@ -303,6 +303,7 @@ Each artist has **exactly one hall**. The hall is a rectangular room sized to fi | Eye-level viewing | Frame centres sit at **eye height (~1.65 m)**; the camera stays **level with the floor** (no pitch up/down) | | Open centre | Floor and ceiling only — no columns, pedestals, or other centre objects | | Museum exit | Front-wall **double doors** with transom, brass hardware, sconces, marble threshold, and warm vestibule glow | +| Shadows | **Disabled** — no Canvas shadow maps / `castShadow` (performance; flat lighting only) | | Hall-to-hall travel | Exit opens a panel: **Predecessors** (left) and **Successors** (right), each grouped by art movement | | Missing images | Works without a local file show a **draped canvas cover** in the frame (not a blank white rectangle) | | Detail view return | Opening a painting close-up **keeps the 3D hall mounted** in the background so position and view direction are preserved when you go back | @@ -371,7 +372,9 @@ Full guide: [tours.md](tours.md). ### Shared 3D behaviour -**3D images** use locally cached files only (`galleryImageUrl` in `client/src/api/client.ts`). Remote fetches are too slow for realtime WebGL textures; the client calls `POST /api/artists/:id/preload-images` automatically when entering an **artist** hall (public route — links disk files only). Movement galleries load painting lists from the API without a separate preload step. While a texture is loading, the frame shows the canvas cover instead of a white placeholder. The center of the hall shows **“Loading gallery…”** until the WebGL canvas is ready, then **“Loading paintings…”** until every wall texture has resolved (tracked through `GalleryTextureLoadContext`). The 3D hall stays mounted while painting detail or bio overlays are open; returning remounts the canvas when the hall becomes active again. +**3D images** use locally cached files only (`galleryImageUrl` in `client/src/api/client.ts`). Remote fetches are too slow for realtime WebGL textures; the client calls `POST /api/artists/:id/preload-images` automatically when entering an **artist** hall (public route — links disk files only). Movement galleries load painting lists from the API without a separate preload step. While a texture is loading, the frame shows the canvas cover instead of a white placeholder. + +**Boot overlay** (`VirtualGallery.tsx`): the center shows **“Loading gallery…”** / **“Loading paintings…”** until all of the following settle — WebGL canvas ready, painting textures downloaded and GPU-uploaded (`gl.initTexture` via `GalleryTextureLoadContext`), HDR `Environment` ready (or timed out / failed), and a one-shot `gl.compileAsync` warm-up so entrance doors / passages (often frustum-culled at spawn) do not hitch on the first turn. Env and shader warm-up have short timeouts so a slow CDN cannot leave the overlay up forever. The 3D hall stays mounted while painting detail or bio overlays are open; returning remounts the canvas when the hall becomes active again. **WebGL context-loss recovery:** on some GPUs/drivers (notably certain Chrome setups) the browser can drop the WebGL context right after entering a hall, which would otherwise leave a permanent dark window. `VirtualGallery.tsx` listens for `webglcontextlost` / `webglcontextrestored`, calls `preventDefault()` so the browser can restore the context, and remounts the `` with a fresh context (a **“Restoring gallery…”** overlay shows briefly). The network-loaded HDR `Environment` map is wrapped in an error boundary so, if it fails to load, the hall still renders without reflections instead of unmounting the whole scene. @@ -430,7 +433,7 @@ Next to the toggle, **Show more** (checkbox, persisted in `localStorage`) opens - **3D gallery images** use locally cached files only; slow remote fetches would break realtime rendering. The client calls `POST /api/artists/:id/preload-images` automatically when entering an **artist** hall (public route — links disk files only). - **3D gallery session** stays mounted while painting detail or bio overlays are open; returning to the hall remounts the WebGL canvas when it becomes active again. - **3D gallery resilience:** a lost WebGL context is recovered by remounting the canvas with a fresh context (rather than showing a dark window), and the HDR environment map is isolated behind an error boundary so its failure never blanks the scene. -- **Loading feedback:** `GalleryLoadingMarker` surfaces catalog load, portrait download, gallery entry, painting-texture load, and context-restore states so the user always knows work is still in progress. +- **Loading feedback:** `GalleryLoadingMarker` surfaces catalog load, portrait download, gallery entry, painting-texture / GPU warm-up, Environment settle, and context-restore states so the user always knows work is still in progress. - **Influence data** is stored in **`painting_influence_sources`** (directed links from paintings to source paintings, artists, or movements), with optional period fields and citation metadata. Sources include curated scholarship (`art-influences-data.js`) and **PainterPalette** (`discovered_via = painter-palette`). Legacy `painting_influences` mirrors painting-to-painting edges for scripts only. ## User roles and access diff --git a/Documentation/setup.md b/Documentation/setup.md index 17d83fb..3720956 100644 --- a/Documentation/setup.md +++ b/Documentation/setup.md @@ -288,4 +288,5 @@ After clone: copy `.env.example` → `.env`, install dependencies, run [one-time | Debug works in UI but API rejects | Stale server without auth middleware | Restart `npm run dev:web` or `npm run dev:server` after pulling auth changes | | **Empty screen** entering 3D hall (header missing) | Stale client before gallery-session fix | Hard-refresh; pull latest client — hall renders from `view` state, not only `gallerySession` | | **Dark center** entering 3D hall (header visible, no spinner) | Stale client before gallery loading overlay fix | Hard-refresh; latest client shows **Loading gallery…** / **Loading paintings…** in the canvas area until ready | +| 3D hall loading overlay never clears | Stuck texture counter or hung HDR | Hard-refresh; current client times out Environment / shader warm-up so the overlay cannot stay forever | | 3D hall black after returning from painting detail | WebGL context lost while hall was hidden | Hard-refresh; latest client remounts canvas when hall becomes active again | diff --git a/client/src/components/GalleryWindows.tsx b/client/src/components/GalleryWindows.tsx index 283dc65..d05e1ac 100644 --- a/client/src/components/GalleryWindows.tsx +++ b/client/src/components/GalleryWindows.tsx @@ -181,7 +181,6 @@ function SingleWindow({ intensity={spec.lightIntensity} distance={14} color={spec.lightColor} - castShadow={false} /> ))} diff --git a/client/src/components/HallPassage.tsx b/client/src/components/HallPassage.tsx index d55076b..30dfd29 100644 --- a/client/src/components/HallPassage.tsx +++ b/client/src/components/HallPassage.tsx @@ -46,14 +46,14 @@ export default function HallPassage({ {/* Side jambs */} {([-1, 1] as const).map((sign) => ( - + ))} {/* Arch header */} - + diff --git a/client/src/components/MovementHallDetails.tsx b/client/src/components/MovementHallDetails.tsx index 88c714a..065d3f8 100644 --- a/client/src/components/MovementHallDetails.tsx +++ b/client/src/components/MovementHallDetails.tsx @@ -26,7 +26,7 @@ function PalazzoDetails({ width, depth, halfW, halfD, trim }: Props & { trim: st {pilasterPositions.map(([x, z], i) => ( - + diff --git a/client/src/components/VirtualGallery.tsx b/client/src/components/VirtualGallery.tsx index 5d813cc..753723c 100644 --- a/client/src/components/VirtualGallery.tsx +++ b/client/src/components/VirtualGallery.tsx @@ -38,7 +38,7 @@ const GalleryTextureLoadContext = createContext<{ * from unmounting the whole 3D scene and leaving a dark window. */ class SceneErrorBoundary extends Component< - { children: ReactNode; fallback?: ReactNode }, + { children: ReactNode; fallback?: ReactNode; onError?: () => void }, { hasError: boolean } > { state = { hasError: false }; @@ -49,6 +49,7 @@ class SceneErrorBoundary extends Component< componentDidCatch(error: unknown) { console.warn('Gallery scene subtree failed, continuing without it.', error); + this.props.onError?.(); } render() { @@ -578,6 +579,7 @@ function usePaintingTexture(url: string | null) { const [texture, setTexture] = useState(null); const [failed, setFailed] = useState(!url); const textureLoad = useContext(GalleryTextureLoadContext); + const { gl } = useThree(); useEffect(() => { if (!url) { @@ -603,21 +605,29 @@ function usePaintingTexture(url: string | null) { loader.load( url, (tex) => { - finish(); if (disposed) { tex.dispose(); + finish(); return; } const img = tex.image as HTMLImageElement | undefined; if (!img || img.width < 4 || img.height < 4) { tex.dispose(); setFailed(true); + finish(); return; } loaded = tex; tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 4; - setTexture(tex); + // Upload under "Loading paintings…" so first turn into culled walls does not hitch. + try { + gl.initTexture(tex); + } catch { + // Still release the load counter if GPU upload fails. + } + finish(); + if (!disposed) setTexture(tex); }, undefined, () => { @@ -632,7 +642,7 @@ function usePaintingTexture(url: string | null) { loaded?.dispose(); setTexture(null); }; - }, [url, textureLoad]); + }, [url, textureLoad, gl]); return { texture, failed }; } @@ -654,11 +664,11 @@ function InfluencePictureLamp({ return ( - + - + @@ -697,7 +707,6 @@ function InfluencePictureLamp({ intensity={2.2 * glow} distance={5} color="#fff0c8" - castShadow={false} /> @@ -732,11 +741,11 @@ function CuratorNotesPlate({ return ( {/* Slightly darker rim so the plate reads as a cast metal plaque */} - + - + { e.stopPropagation(); @@ -923,7 +931,7 @@ function GalleryWall({ }) { const [w, h] = size; return ( - + @@ -982,7 +990,7 @@ function ExitPortal({ const panelInset = 0.06; return ( - + @@ -1036,7 +1044,7 @@ function ExitPortal({ {/* Marble threshold */} - + @@ -1063,14 +1071,14 @@ function ExitPortal({ {/* Side jambs */} {([-1, 1] as const).map((sign) => ( - + ))} {/* Header lintel */} - + @@ -1101,13 +1109,12 @@ function ExitPortal({ ))} - + @@ -1220,7 +1227,7 @@ function TexturedWall({ const [w, h, d] = size; const mat = useTexturedMaterial(kind, tint, Math.max(w, d), h); return ( - + ); @@ -1239,7 +1246,7 @@ function TexturedCeiling({ }) { const mat = useTexturedMaterial(kind, tint, width + 0.4, depth + 0.4); return ( - + ); @@ -1271,7 +1278,7 @@ function GalleryFloor({ ); return ( - + + ); @@ -1396,15 +1403,15 @@ function ArtistHall({ ) : ( <> - + {wallMaterial(walls.main)} - + {wallMaterial(walls.side)} - + {wallMaterial(walls.side)} @@ -1607,6 +1614,67 @@ function FrameloopSync({ active }: { active: boolean }) { return null; } +/** Fires onReady once Environment (inside Suspense) has resolved and mounted. */ +function EnvironmentGate({ + onReady, + children, +}: { + onReady: () => void; + children: ReactNode; +}) { + useEffect(() => { + onReady(); + }, [onReady]); + return <>{children}; +} + +const SHADER_WARM_TIMEOUT_MS = 4000; + +/** Compile all scene materials (incl. culled doors) before dismissing the loading overlay. */ +function WarmHallGpu({ + enabled, + onDone, +}: { + enabled: boolean; + onDone: () => void; +}) { + const { gl, scene, camera } = useThree(); + + useEffect(() => { + if (!enabled) return; + let cancelled = false; + let settled = false; + const done = () => { + if (cancelled || settled) return; + settled = true; + onDone(); + }; + + const run = async () => { + try { + const compile = typeof gl.compileAsync === 'function' + ? gl.compileAsync(scene, camera) + : Promise.resolve(gl.compile(scene, camera)); + await Promise.race([ + compile, + new Promise((resolve) => { + window.setTimeout(resolve, SHADER_WARM_TIMEOUT_MS); + }), + ]); + } catch { + // Still release the overlay if compile fails — better than hanging forever. + } + done(); + }; + void run(); + return () => { + cancelled = true; + }; + }, [enabled, gl, scene, camera, onDone]); + + return null; +} + function CameraController({ position, target, @@ -1801,6 +1869,8 @@ export default function VirtualGallery(props: Props) { const [isLooking, setIsLooking] = useState(false); const [texturesPending, setTexturesPending] = useState(0); const [canvasReady, setCanvasReady] = useState(false); + const [envReady, setEnvReady] = useState(false); + const [shadersWarmed, setShadersWarmed] = useState(false); const [glEpoch, setGlEpoch] = useState(0); const [glLost, setGlLost] = useState(false); @@ -1812,6 +1882,14 @@ export default function VirtualGallery(props: Props) { [] ); + const handleEnvReady = useCallback(() => { + setEnvReady(true); + }, []); + + const handleShadersWarmed = useCallback(() => { + setShadersWarmed(true); + }, []); + const handleCanvasCreated = useCallback((state: { gl: THREE.WebGLRenderer }) => { const canvas = state.gl.domElement; // A freshly created canvas has a healthy context, so clear any lingering @@ -1878,6 +1956,29 @@ export default function VirtualGallery(props: Props) { setHallIndex(0); }, [hallKey]); + useEffect(() => { + setEnvReady(false); + setShadersWarmed(false); + }, [hallKey, glEpoch]); + + useEffect(() => { + setShadersWarmed(false); + }, [hallIndex]); + + // HDR Environment can hang or fail (CDN / Suspense). Never block the hall forever. + useEffect(() => { + if (envReady) return; + const t = window.setTimeout(() => setEnvReady(true), 5000); + return () => window.clearTimeout(t); + }, [envReady, hallKey, glEpoch]); + + // If shader warm-up never settles, dismiss the overlay anyway. + useEffect(() => { + if (shadersWarmed || !canvasReady || !envReady || texturesPending > 0) return; + const t = window.setTimeout(() => setShadersWarmed(true), SHADER_WARM_TIMEOUT_MS + 1000); + return () => window.clearTimeout(t); + }, [shadersWarmed, canvasReady, envReady, texturesPending, hallKey, glEpoch, hallIndex]); + const layout = useMemo(() => { if (isWingedHall && movementHalls.length > 0) { return movementHalls[Math.min(hallIndex, movementHalls.length - 1)]; @@ -1885,13 +1986,16 @@ export default function VirtualGallery(props: Props) { return buildHallLayout(paintings, periods); }, [isWingedHall, movementHalls, hallIndex, paintings, periods]); - const gallerySceneLoading = active && !glLost && (!canvasReady || texturesPending > 0); + const gallerySceneLoading = + active && + !glLost && + (!canvasReady || !envReady || texturesPending > 0 || !shadersWarmed); - const galleryLoadingMessage = !canvasReady - ? 'Loading gallery…' - : texturesPending > 0 - ? 'Loading paintings…' - : 'Loading gallery…'; + const galleryLoadingMessage = + canvasReady && texturesPending > 0 ? 'Loading paintings…' : 'Loading gallery…'; + + const warmGpuEnabled = + canvasReady && envReady && texturesPending === 0 && !shadersWarmed; const computedWindows = useMemo(() => { if (!isMovement || !interiorStyle || !('hallIndex' in layout)) return undefined; @@ -2188,7 +2292,6 @@ export default function VirtualGallery(props: Props) { )} - + - + + + @@ -2232,6 +2339,7 @@ export default function VirtualGallery(props: Props) { nearPassage={nearPassage} /> +