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 <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
bc8369e373
commit
f6c73c1792
@@ -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 |
|
| 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) |
|
| 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 **“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 gallery…”** | While the 3D canvas initializes, HDR Environment settles, or door/hall shaders warm up after the hall opens |
|
||||||
| Overlay **“Loading paintings…”** | While wall textures are still downloading in the 3D hall |
|
| 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 |
|
| 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.
|
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) |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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) |
|
| 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 |
|
| 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
|
### 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 `<Canvas>` 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.
|
**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 `<Canvas>` 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 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 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.
|
- **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.
|
- **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
|
## User roles and access
|
||||||
|
|||||||
@@ -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 |
|
| 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` |
|
| **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 |
|
| **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 |
|
| 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 |
|
||||||
|
|||||||
@@ -181,7 +181,6 @@ function SingleWindow({
|
|||||||
intensity={spec.lightIntensity}
|
intensity={spec.lightIntensity}
|
||||||
distance={14}
|
distance={14}
|
||||||
color={spec.lightColor}
|
color={spec.lightColor}
|
||||||
castShadow={false}
|
|
||||||
/>
|
/>
|
||||||
<pointLight
|
<pointLight
|
||||||
position={[0, 0, 0.25]}
|
position={[0, 0, 0.25]}
|
||||||
@@ -250,7 +249,6 @@ export function GalleryTrackLights({
|
|||||||
intensity={intensity}
|
intensity={intensity}
|
||||||
distance={8}
|
distance={8}
|
||||||
color={color}
|
color={color}
|
||||||
castShadow={false}
|
|
||||||
/>
|
/>
|
||||||
</group>
|
</group>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -46,14 +46,14 @@ export default function HallPassage({
|
|||||||
|
|
||||||
{/* Side jambs */}
|
{/* Side jambs */}
|
||||||
{([-1, 1] as const).map((sign) => (
|
{([-1, 1] as const).map((sign) => (
|
||||||
<mesh key={sign} position={[sign * (openingW / 2 + jamb / 2), openingH / 2, faceZ]} castShadow>
|
<mesh key={sign} position={[sign * (openingW / 2 + jamb / 2), openingH / 2, faceZ]}>
|
||||||
<boxGeometry args={[jamb, openingH + 0.1, 0.1]} />
|
<boxGeometry args={[jamb, openingH + 0.1, 0.1]} />
|
||||||
<meshStandardMaterial color={trimColor} roughness={0.45} metalness={0.25} />
|
<meshStandardMaterial color={trimColor} roughness={0.45} metalness={0.25} />
|
||||||
</mesh>
|
</mesh>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Arch header */}
|
{/* Arch header */}
|
||||||
<mesh position={[0, openingH + 0.06, faceZ]} castShadow>
|
<mesh position={[0, openingH + 0.06, faceZ]}>
|
||||||
<boxGeometry args={[openingW + jamb * 2, 0.14, 0.1]} />
|
<boxGeometry args={[openingW + jamb * 2, 0.14, 0.1]} />
|
||||||
<meshStandardMaterial color={trimColor} roughness={0.4} metalness={0.3} />
|
<meshStandardMaterial color={trimColor} roughness={0.4} metalness={0.3} />
|
||||||
</mesh>
|
</mesh>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ function PalazzoDetails({ width, depth, halfW, halfD, trim }: Props & { trim: st
|
|||||||
<group>
|
<group>
|
||||||
{pilasterPositions.map(([x, z], i) => (
|
{pilasterPositions.map(([x, z], i) => (
|
||||||
<group key={i} position={[x, 0, z]}>
|
<group key={i} position={[x, 0, z]}>
|
||||||
<mesh position={[0, 1.8, 0]} castShadow>
|
<mesh position={[0, 1.8, 0]}>
|
||||||
<boxGeometry args={[0.22, 3.6, 0.22]} />
|
<boxGeometry args={[0.22, 3.6, 0.22]} />
|
||||||
<meshStandardMaterial color="#e8dcc8" roughness={0.85} metalness={0.05} />
|
<meshStandardMaterial color="#e8dcc8" roughness={0.85} metalness={0.05} />
|
||||||
</mesh>
|
</mesh>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const GalleryTextureLoadContext = createContext<{
|
|||||||
* from unmounting the whole 3D scene and leaving a dark window.
|
* from unmounting the whole 3D scene and leaving a dark window.
|
||||||
*/
|
*/
|
||||||
class SceneErrorBoundary extends Component<
|
class SceneErrorBoundary extends Component<
|
||||||
{ children: ReactNode; fallback?: ReactNode },
|
{ children: ReactNode; fallback?: ReactNode; onError?: () => void },
|
||||||
{ hasError: boolean }
|
{ hasError: boolean }
|
||||||
> {
|
> {
|
||||||
state = { hasError: false };
|
state = { hasError: false };
|
||||||
@@ -49,6 +49,7 @@ class SceneErrorBoundary extends Component<
|
|||||||
|
|
||||||
componentDidCatch(error: unknown) {
|
componentDidCatch(error: unknown) {
|
||||||
console.warn('Gallery scene subtree failed, continuing without it.', error);
|
console.warn('Gallery scene subtree failed, continuing without it.', error);
|
||||||
|
this.props.onError?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
@@ -578,6 +579,7 @@ function usePaintingTexture(url: string | null) {
|
|||||||
const [texture, setTexture] = useState<THREE.Texture | null>(null);
|
const [texture, setTexture] = useState<THREE.Texture | null>(null);
|
||||||
const [failed, setFailed] = useState(!url);
|
const [failed, setFailed] = useState(!url);
|
||||||
const textureLoad = useContext(GalleryTextureLoadContext);
|
const textureLoad = useContext(GalleryTextureLoadContext);
|
||||||
|
const { gl } = useThree();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!url) {
|
if (!url) {
|
||||||
@@ -603,21 +605,29 @@ function usePaintingTexture(url: string | null) {
|
|||||||
loader.load(
|
loader.load(
|
||||||
url,
|
url,
|
||||||
(tex) => {
|
(tex) => {
|
||||||
finish();
|
|
||||||
if (disposed) {
|
if (disposed) {
|
||||||
tex.dispose();
|
tex.dispose();
|
||||||
|
finish();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const img = tex.image as HTMLImageElement | undefined;
|
const img = tex.image as HTMLImageElement | undefined;
|
||||||
if (!img || img.width < 4 || img.height < 4) {
|
if (!img || img.width < 4 || img.height < 4) {
|
||||||
tex.dispose();
|
tex.dispose();
|
||||||
setFailed(true);
|
setFailed(true);
|
||||||
|
finish();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loaded = tex;
|
loaded = tex;
|
||||||
tex.colorSpace = THREE.SRGBColorSpace;
|
tex.colorSpace = THREE.SRGBColorSpace;
|
||||||
tex.anisotropy = 4;
|
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,
|
undefined,
|
||||||
() => {
|
() => {
|
||||||
@@ -632,7 +642,7 @@ function usePaintingTexture(url: string | null) {
|
|||||||
loaded?.dispose();
|
loaded?.dispose();
|
||||||
setTexture(null);
|
setTexture(null);
|
||||||
};
|
};
|
||||||
}, [url, textureLoad]);
|
}, [url, textureLoad, gl]);
|
||||||
|
|
||||||
return { texture, failed };
|
return { texture, failed };
|
||||||
}
|
}
|
||||||
@@ -654,11 +664,11 @@ function InfluencePictureLamp({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<group position={[0, mountY, frameDepth * 0.55]} scale={scale}>
|
<group position={[0, mountY, frameDepth * 0.55]} scale={scale}>
|
||||||
<mesh position={[0, 0.04, -0.05]} castShadow renderOrder={30}>
|
<mesh position={[0, 0.04, -0.05]} renderOrder={30}>
|
||||||
<boxGeometry args={[0.11, 0.05, 0.05]} />
|
<boxGeometry args={[0.11, 0.05, 0.05]} />
|
||||||
<meshStandardMaterial color="#2f2f2f" metalness={0.9} roughness={0.2} />
|
<meshStandardMaterial color="#2f2f2f" metalness={0.9} roughness={0.2} />
|
||||||
</mesh>
|
</mesh>
|
||||||
<mesh position={[0, 0.01, -0.01]} rotation={[0.25, 0, 0]} castShadow renderOrder={30}>
|
<mesh position={[0, 0.01, -0.01]} rotation={[0.25, 0, 0]} renderOrder={30}>
|
||||||
<boxGeometry args={[0.04, 0.08, 0.035]} />
|
<boxGeometry args={[0.04, 0.08, 0.035]} />
|
||||||
<meshStandardMaterial color="#555555" metalness={0.8} roughness={0.3} />
|
<meshStandardMaterial color="#555555" metalness={0.8} roughness={0.3} />
|
||||||
</mesh>
|
</mesh>
|
||||||
@@ -697,7 +707,6 @@ function InfluencePictureLamp({
|
|||||||
intensity={2.2 * glow}
|
intensity={2.2 * glow}
|
||||||
distance={5}
|
distance={5}
|
||||||
color="#fff0c8"
|
color="#fff0c8"
|
||||||
castShadow={false}
|
|
||||||
/>
|
/>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
@@ -732,11 +741,11 @@ function CuratorNotesPlate({
|
|||||||
return (
|
return (
|
||||||
<group position={[0, y, z]}>
|
<group position={[0, y, z]}>
|
||||||
{/* Slightly darker rim so the plate reads as a cast metal plaque */}
|
{/* Slightly darker rim so the plate reads as a cast metal plaque */}
|
||||||
<mesh position={[0, 0, -0.001]} castShadow renderOrder={28}>
|
<mesh position={[0, 0, -0.001]} renderOrder={28}>
|
||||||
<boxGeometry args={[plateW + 0.012, plateH + 0.01, plateD]} />
|
<boxGeometry args={[plateW + 0.012, plateH + 0.01, plateD]} />
|
||||||
<meshStandardMaterial color={rim} metalness={0.85} roughness={0.28} />
|
<meshStandardMaterial color={rim} metalness={0.85} roughness={0.28} />
|
||||||
</mesh>
|
</mesh>
|
||||||
<mesh castShadow renderOrder={29}>
|
<mesh renderOrder={29}>
|
||||||
<boxGeometry args={[plateW, plateH, plateD]} />
|
<boxGeometry args={[plateW, plateH, plateD]} />
|
||||||
<meshStandardMaterial
|
<meshStandardMaterial
|
||||||
color={brass}
|
color={brass}
|
||||||
@@ -819,7 +828,6 @@ function PaintingFrame({
|
|||||||
|
|
||||||
<mesh
|
<mesh
|
||||||
position={[0, 0, frameDepth / 2]}
|
position={[0, 0, frameDepth / 2]}
|
||||||
castShadow
|
|
||||||
renderOrder={1}
|
renderOrder={1}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -923,7 +931,7 @@ function GalleryWall({
|
|||||||
}) {
|
}) {
|
||||||
const [w, h] = size;
|
const [w, h] = size;
|
||||||
return (
|
return (
|
||||||
<mesh position={position} rotation={rotation} receiveShadow renderOrder={0}>
|
<mesh position={position} rotation={rotation} renderOrder={0}>
|
||||||
<boxGeometry args={[w, h, WALL_THICKNESS]} />
|
<boxGeometry args={[w, h, WALL_THICKNESS]} />
|
||||||
<meshStandardMaterial color={color} roughness={0.92} />
|
<meshStandardMaterial color={color} roughness={0.92} />
|
||||||
</mesh>
|
</mesh>
|
||||||
@@ -982,7 +990,7 @@ function ExitPortal({
|
|||||||
const panelInset = 0.06;
|
const panelInset = 0.06;
|
||||||
return (
|
return (
|
||||||
<group key={side} position={[x, leafY, faceZ]}>
|
<group key={side} position={[x, leafY, faceZ]}>
|
||||||
<mesh castShadow>
|
<mesh>
|
||||||
<boxGeometry args={[leafW, leafH, 0.055]} />
|
<boxGeometry args={[leafW, leafH, 0.055]} />
|
||||||
<meshStandardMaterial color={woodMid} roughness={0.62} metalness={0.04} />
|
<meshStandardMaterial color={woodMid} roughness={0.62} metalness={0.04} />
|
||||||
</mesh>
|
</mesh>
|
||||||
@@ -1036,7 +1044,7 @@ function ExitPortal({
|
|||||||
</mesh>
|
</mesh>
|
||||||
|
|
||||||
{/* Marble threshold */}
|
{/* Marble threshold */}
|
||||||
<mesh position={[0, 0.025, faceZ + 0.04]} receiveShadow>
|
<mesh position={[0, 0.025, faceZ + 0.04]}>
|
||||||
<boxGeometry args={[frameW + 0.12, 0.05, 0.14]} />
|
<boxGeometry args={[frameW + 0.12, 0.05, 0.14]} />
|
||||||
<meshStandardMaterial color="#e8e4dc" roughness={0.28} metalness={0.08} />
|
<meshStandardMaterial color="#e8e4dc" roughness={0.28} metalness={0.08} />
|
||||||
</mesh>
|
</mesh>
|
||||||
@@ -1063,14 +1071,14 @@ function ExitPortal({
|
|||||||
|
|
||||||
{/* Side jambs */}
|
{/* Side jambs */}
|
||||||
{([-1, 1] as const).map((sign) => (
|
{([-1, 1] as const).map((sign) => (
|
||||||
<mesh key={sign} position={[sign * (openingW / 2 + jamb / 2), frameFullH / 2, faceZ + 0.01]} castShadow>
|
<mesh key={sign} position={[sign * (openingW / 2 + jamb / 2), frameFullH / 2, faceZ + 0.01]}>
|
||||||
<boxGeometry args={[jamb, frameFullH, 0.1]} />
|
<boxGeometry args={[jamb, frameFullH, 0.1]} />
|
||||||
<meshStandardMaterial color={woodDark} roughness={0.55} metalness={0.06} />
|
<meshStandardMaterial color={woodDark} roughness={0.55} metalness={0.06} />
|
||||||
</mesh>
|
</mesh>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Header lintel */}
|
{/* Header lintel */}
|
||||||
<mesh position={[0, openingH + header / 2, faceZ + 0.01]} castShadow>
|
<mesh position={[0, openingH + header / 2, faceZ + 0.01]}>
|
||||||
<boxGeometry args={[frameW, header, 0.1]} />
|
<boxGeometry args={[frameW, header, 0.1]} />
|
||||||
<meshStandardMaterial color={woodMid} roughness={0.58} metalness={0.05} />
|
<meshStandardMaterial color={woodMid} roughness={0.58} metalness={0.05} />
|
||||||
</mesh>
|
</mesh>
|
||||||
@@ -1101,13 +1109,12 @@ function ExitPortal({
|
|||||||
<mesh
|
<mesh
|
||||||
key={`sur-${sign}`}
|
key={`sur-${sign}`}
|
||||||
position={[sign * (openingW / 2 + jamb + surround / 2), frameFullH / 2, faceZ - 0.01]}
|
position={[sign * (openingW / 2 + jamb + surround / 2), frameFullH / 2, faceZ - 0.01]}
|
||||||
castShadow
|
|
||||||
>
|
>
|
||||||
<boxGeometry args={[surround, frameFullH + 0.08, 0.08]} />
|
<boxGeometry args={[surround, frameFullH + 0.08, 0.08]} />
|
||||||
<meshStandardMaterial color={stone} roughness={0.88} />
|
<meshStandardMaterial color={stone} roughness={0.88} />
|
||||||
</mesh>
|
</mesh>
|
||||||
))}
|
))}
|
||||||
<mesh position={[0, frameFullH + 0.04, faceZ - 0.01]} castShadow>
|
<mesh position={[0, frameFullH + 0.04, faceZ - 0.01]}>
|
||||||
<boxGeometry args={[frameW + surround * 2, 0.12, 0.08]} />
|
<boxGeometry args={[frameW + surround * 2, 0.12, 0.08]} />
|
||||||
<meshStandardMaterial color={stoneDark} roughness={0.82} metalness={0.08} />
|
<meshStandardMaterial color={stoneDark} roughness={0.82} metalness={0.08} />
|
||||||
</mesh>
|
</mesh>
|
||||||
@@ -1220,7 +1227,7 @@ function TexturedWall({
|
|||||||
const [w, h, d] = size;
|
const [w, h, d] = size;
|
||||||
const mat = useTexturedMaterial(kind, tint, Math.max(w, d), h);
|
const mat = useTexturedMaterial(kind, tint, Math.max(w, d), h);
|
||||||
return (
|
return (
|
||||||
<mesh position={position} rotation={rotation} receiveShadow material={mat} renderOrder={0}>
|
<mesh position={position} rotation={rotation} material={mat} renderOrder={0}>
|
||||||
<boxGeometry args={size} />
|
<boxGeometry args={size} />
|
||||||
</mesh>
|
</mesh>
|
||||||
);
|
);
|
||||||
@@ -1239,7 +1246,7 @@ function TexturedCeiling({
|
|||||||
}) {
|
}) {
|
||||||
const mat = useTexturedMaterial(kind, tint, width + 0.4, depth + 0.4);
|
const mat = useTexturedMaterial(kind, tint, width + 0.4, depth + 0.4);
|
||||||
return (
|
return (
|
||||||
<mesh rotation={[Math.PI / 2, 0, 0]} position={[0, WALL_HEIGHT, 0]} receiveShadow material={mat}>
|
<mesh rotation={[Math.PI / 2, 0, 0]} position={[0, WALL_HEIGHT, 0]} material={mat}>
|
||||||
<planeGeometry args={[width + 0.4, depth + 0.4]} />
|
<planeGeometry args={[width + 0.4, depth + 0.4]} />
|
||||||
</mesh>
|
</mesh>
|
||||||
);
|
);
|
||||||
@@ -1271,7 +1278,7 @@ function GalleryFloor({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.002, 0]} receiveShadow>
|
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.002, 0]}>
|
||||||
<planeGeometry args={[floorW, floorD]} />
|
<planeGeometry args={[floorW, floorD]} />
|
||||||
<meshStandardMaterial
|
<meshStandardMaterial
|
||||||
map={texture.map}
|
map={texture.map}
|
||||||
@@ -1289,7 +1296,7 @@ function GalleryFloor({
|
|||||||
function ParquetFloor({ width, depth }: { width: number; depth: number }) {
|
function ParquetFloor({ width, depth }: { width: number; depth: number }) {
|
||||||
const mat = useTexturedMaterial('parquet-herringbone', '#c8a882', width + 0.4, depth + 0.4);
|
const mat = useTexturedMaterial('parquet-herringbone', '#c8a882', width + 0.4, depth + 0.4);
|
||||||
return (
|
return (
|
||||||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.002, 0]} receiveShadow material={mat}>
|
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.002, 0]} material={mat}>
|
||||||
<planeGeometry args={[width + 0.4, depth + 0.4]} />
|
<planeGeometry args={[width + 0.4, depth + 0.4]} />
|
||||||
</mesh>
|
</mesh>
|
||||||
);
|
);
|
||||||
@@ -1396,15 +1403,15 @@ function ArtistHall({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<mesh position={[0, WALL_HEIGHT / 2, -halfD]} receiveShadow renderOrder={0}>
|
<mesh position={[0, WALL_HEIGHT / 2, -halfD]} renderOrder={0}>
|
||||||
<boxGeometry args={[width, WALL_HEIGHT, WALL_THICKNESS]} />
|
<boxGeometry args={[width, WALL_HEIGHT, WALL_THICKNESS]} />
|
||||||
{wallMaterial(walls.main)}
|
{wallMaterial(walls.main)}
|
||||||
</mesh>
|
</mesh>
|
||||||
<mesh position={[-halfW, WALL_HEIGHT / 2, 0]} rotation={[0, Math.PI / 2, 0]} receiveShadow renderOrder={0}>
|
<mesh position={[-halfW, WALL_HEIGHT / 2, 0]} rotation={[0, Math.PI / 2, 0]} renderOrder={0}>
|
||||||
<boxGeometry args={[depth, WALL_HEIGHT, WALL_THICKNESS]} />
|
<boxGeometry args={[depth, WALL_HEIGHT, WALL_THICKNESS]} />
|
||||||
{wallMaterial(walls.side)}
|
{wallMaterial(walls.side)}
|
||||||
</mesh>
|
</mesh>
|
||||||
<mesh position={[halfW, WALL_HEIGHT / 2, 0]} rotation={[0, Math.PI / 2, 0]} receiveShadow renderOrder={0}>
|
<mesh position={[halfW, WALL_HEIGHT / 2, 0]} rotation={[0, Math.PI / 2, 0]} renderOrder={0}>
|
||||||
<boxGeometry args={[depth, WALL_HEIGHT, WALL_THICKNESS]} />
|
<boxGeometry args={[depth, WALL_HEIGHT, WALL_THICKNESS]} />
|
||||||
{wallMaterial(walls.side)}
|
{wallMaterial(walls.side)}
|
||||||
</mesh>
|
</mesh>
|
||||||
@@ -1607,6 +1614,67 @@ function FrameloopSync({ active }: { active: boolean }) {
|
|||||||
return null;
|
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<void>((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({
|
function CameraController({
|
||||||
position,
|
position,
|
||||||
target,
|
target,
|
||||||
@@ -1801,6 +1869,8 @@ export default function VirtualGallery(props: Props) {
|
|||||||
const [isLooking, setIsLooking] = useState(false);
|
const [isLooking, setIsLooking] = useState(false);
|
||||||
const [texturesPending, setTexturesPending] = useState(0);
|
const [texturesPending, setTexturesPending] = useState(0);
|
||||||
const [canvasReady, setCanvasReady] = useState(false);
|
const [canvasReady, setCanvasReady] = useState(false);
|
||||||
|
const [envReady, setEnvReady] = useState(false);
|
||||||
|
const [shadersWarmed, setShadersWarmed] = useState(false);
|
||||||
const [glEpoch, setGlEpoch] = useState(0);
|
const [glEpoch, setGlEpoch] = useState(0);
|
||||||
const [glLost, setGlLost] = useState(false);
|
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 handleCanvasCreated = useCallback((state: { gl: THREE.WebGLRenderer }) => {
|
||||||
const canvas = state.gl.domElement;
|
const canvas = state.gl.domElement;
|
||||||
// A freshly created canvas has a healthy context, so clear any lingering
|
// A freshly created canvas has a healthy context, so clear any lingering
|
||||||
@@ -1878,6 +1956,29 @@ export default function VirtualGallery(props: Props) {
|
|||||||
setHallIndex(0);
|
setHallIndex(0);
|
||||||
}, [hallKey]);
|
}, [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(() => {
|
const layout = useMemo(() => {
|
||||||
if (isWingedHall && movementHalls.length > 0) {
|
if (isWingedHall && movementHalls.length > 0) {
|
||||||
return movementHalls[Math.min(hallIndex, movementHalls.length - 1)];
|
return movementHalls[Math.min(hallIndex, movementHalls.length - 1)];
|
||||||
@@ -1885,13 +1986,16 @@ export default function VirtualGallery(props: Props) {
|
|||||||
return buildHallLayout(paintings, periods);
|
return buildHallLayout(paintings, periods);
|
||||||
}, [isWingedHall, movementHalls, hallIndex, 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
|
const galleryLoadingMessage =
|
||||||
? 'Loading gallery…'
|
canvasReady && texturesPending > 0 ? 'Loading paintings…' : 'Loading gallery…';
|
||||||
: texturesPending > 0
|
|
||||||
? 'Loading paintings…'
|
const warmGpuEnabled =
|
||||||
: 'Loading gallery…';
|
canvasReady && envReady && texturesPending === 0 && !shadersWarmed;
|
||||||
|
|
||||||
const computedWindows = useMemo(() => {
|
const computedWindows = useMemo(() => {
|
||||||
if (!isMovement || !interiorStyle || !('hallIndex' in layout)) return undefined;
|
if (!isMovement || !interiorStyle || !('hallIndex' in layout)) return undefined;
|
||||||
@@ -2188,7 +2292,6 @@ export default function VirtualGallery(props: Props) {
|
|||||||
)}
|
)}
|
||||||
<Canvas
|
<Canvas
|
||||||
key={`${hallKey}-${glEpoch}`}
|
key={`${hallKey}-${glEpoch}`}
|
||||||
shadows
|
|
||||||
frameloop={active ? 'always' : 'never'}
|
frameloop={active ? 'always' : 'never'}
|
||||||
gl={{ preserveDrawingBuffer: true, powerPreference: 'high-performance' }}
|
gl={{ preserveDrawingBuffer: true, powerPreference: 'high-performance' }}
|
||||||
camera={{ fov: 58, position: [0, EYE_HEIGHT, 2], near: 0.1, far: 80 }}
|
camera={{ fov: 58, position: [0, EYE_HEIGHT, 2], near: 0.1, far: 80 }}
|
||||||
@@ -2202,15 +2305,19 @@ export default function VirtualGallery(props: Props) {
|
|||||||
position={interiorStyle ? [2, 10, -4] : [3, 9, 4]}
|
position={interiorStyle ? [2, 10, -4] : [3, 9, 4]}
|
||||||
intensity={interiorStyle ? 0.85 : 0.65}
|
intensity={interiorStyle ? 0.85 : 0.65}
|
||||||
color={interiorStyle?.sunLight ?? '#fff8f0'}
|
color={interiorStyle?.sunLight ?? '#fff8f0'}
|
||||||
castShadow
|
|
||||||
shadow-mapSize={[1024, 1024]}
|
|
||||||
/>
|
/>
|
||||||
<SceneErrorBoundary fallback={null}>
|
<SceneErrorBoundary
|
||||||
|
key={`env-${hallKey}-${glEpoch}`}
|
||||||
|
fallback={null}
|
||||||
|
onError={handleEnvReady}
|
||||||
|
>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
|
<EnvironmentGate onReady={handleEnvReady}>
|
||||||
<Environment
|
<Environment
|
||||||
preset={interiorStyle?.details === 'modern' || interiorStyle?.details === 'industrial' ? 'city' : 'warehouse'}
|
preset={interiorStyle?.details === 'modern' || interiorStyle?.details === 'industrial' ? 'city' : 'warehouse'}
|
||||||
environmentIntensity={interiorStyle ? 0.35 : 0.15}
|
environmentIntensity={interiorStyle ? 0.35 : 0.15}
|
||||||
/>
|
/>
|
||||||
|
</EnvironmentGate>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</SceneErrorBoundary>
|
</SceneErrorBoundary>
|
||||||
<GalleryTextureLoadContext.Provider value={textureLoad}>
|
<GalleryTextureLoadContext.Provider value={textureLoad}>
|
||||||
@@ -2232,6 +2339,7 @@ export default function VirtualGallery(props: Props) {
|
|||||||
nearPassage={nearPassage}
|
nearPassage={nearPassage}
|
||||||
/>
|
/>
|
||||||
</GalleryTextureLoadContext.Provider>
|
</GalleryTextureLoadContext.Provider>
|
||||||
|
<WarmHallGpu enabled={warmGpuEnabled} onDone={handleShadersWarmed} />
|
||||||
<CameraController position={camPos} target={camTarget} />
|
<CameraController position={camPos} target={camTarget} />
|
||||||
</Canvas>
|
</Canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user