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:
Danila Khodjaef
2026-07-25 15:32:10 +03:00
co-authored by Cursor
parent bc8369e373
commit f6c73c1792
6 changed files with 157 additions and 47 deletions
-2
View File
@@ -181,7 +181,6 @@ function SingleWindow({
intensity={spec.lightIntensity}
distance={14}
color={spec.lightColor}
castShadow={false}
/>
<pointLight
position={[0, 0, 0.25]}
@@ -250,7 +249,6 @@ export function GalleryTrackLights({
intensity={intensity}
distance={8}
color={color}
castShadow={false}
/>
</group>
))}
+2 -2
View File
@@ -46,14 +46,14 @@ export default function HallPassage({
{/* Side jambs */}
{([-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]} />
<meshStandardMaterial color={trimColor} roughness={0.45} metalness={0.25} />
</mesh>
))}
{/* 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]} />
<meshStandardMaterial color={trimColor} roughness={0.4} metalness={0.3} />
</mesh>
@@ -26,7 +26,7 @@ function PalazzoDetails({ width, depth, halfW, halfD, trim }: Props & { trim: st
<group>
{pilasterPositions.map(([x, z], i) => (
<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]} />
<meshStandardMaterial color="#e8dcc8" roughness={0.85} metalness={0.05} />
</mesh>
+146 -38
View File
@@ -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<THREE.Texture | null>(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 (
<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]} />
<meshStandardMaterial color="#2f2f2f" metalness={0.9} roughness={0.2} />
</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]} />
<meshStandardMaterial color="#555555" metalness={0.8} roughness={0.3} />
</mesh>
@@ -697,7 +707,6 @@ function InfluencePictureLamp({
intensity={2.2 * glow}
distance={5}
color="#fff0c8"
castShadow={false}
/>
</group>
</group>
@@ -732,11 +741,11 @@ function CuratorNotesPlate({
return (
<group position={[0, y, z]}>
{/* 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]} />
<meshStandardMaterial color={rim} metalness={0.85} roughness={0.28} />
</mesh>
<mesh castShadow renderOrder={29}>
<mesh renderOrder={29}>
<boxGeometry args={[plateW, plateH, plateD]} />
<meshStandardMaterial
color={brass}
@@ -819,7 +828,6 @@ function PaintingFrame({
<mesh
position={[0, 0, frameDepth / 2]}
castShadow
renderOrder={1}
onClick={(e) => {
e.stopPropagation();
@@ -923,7 +931,7 @@ function GalleryWall({
}) {
const [w, h] = size;
return (
<mesh position={position} rotation={rotation} receiveShadow renderOrder={0}>
<mesh position={position} rotation={rotation} renderOrder={0}>
<boxGeometry args={[w, h, WALL_THICKNESS]} />
<meshStandardMaterial color={color} roughness={0.92} />
</mesh>
@@ -982,7 +990,7 @@ function ExitPortal({
const panelInset = 0.06;
return (
<group key={side} position={[x, leafY, faceZ]}>
<mesh castShadow>
<mesh>
<boxGeometry args={[leafW, leafH, 0.055]} />
<meshStandardMaterial color={woodMid} roughness={0.62} metalness={0.04} />
</mesh>
@@ -1036,7 +1044,7 @@ function ExitPortal({
</mesh>
{/* 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]} />
<meshStandardMaterial color="#e8e4dc" roughness={0.28} metalness={0.08} />
</mesh>
@@ -1063,14 +1071,14 @@ function ExitPortal({
{/* Side jambs */}
{([-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]} />
<meshStandardMaterial color={woodDark} roughness={0.55} metalness={0.06} />
</mesh>
))}
{/* 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]} />
<meshStandardMaterial color={woodMid} roughness={0.58} metalness={0.05} />
</mesh>
@@ -1101,13 +1109,12 @@ function ExitPortal({
<mesh
key={`sur-${sign}`}
position={[sign * (openingW / 2 + jamb + surround / 2), frameFullH / 2, faceZ - 0.01]}
castShadow
>
<boxGeometry args={[surround, frameFullH + 0.08, 0.08]} />
<meshStandardMaterial color={stone} roughness={0.88} />
</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]} />
<meshStandardMaterial color={stoneDark} roughness={0.82} metalness={0.08} />
</mesh>
@@ -1220,7 +1227,7 @@ function TexturedWall({
const [w, h, d] = size;
const mat = useTexturedMaterial(kind, tint, Math.max(w, d), h);
return (
<mesh position={position} rotation={rotation} receiveShadow material={mat} renderOrder={0}>
<mesh position={position} rotation={rotation} material={mat} renderOrder={0}>
<boxGeometry args={size} />
</mesh>
);
@@ -1239,7 +1246,7 @@ function TexturedCeiling({
}) {
const mat = useTexturedMaterial(kind, tint, width + 0.4, depth + 0.4);
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]} />
</mesh>
);
@@ -1271,7 +1278,7 @@ function GalleryFloor({
);
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]} />
<meshStandardMaterial
map={texture.map}
@@ -1289,7 +1296,7 @@ function GalleryFloor({
function ParquetFloor({ width, depth }: { width: number; depth: number }) {
const mat = useTexturedMaterial('parquet-herringbone', '#c8a882', width + 0.4, depth + 0.4);
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]} />
</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]} />
{wallMaterial(walls.main)}
</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]} />
{wallMaterial(walls.side)}
</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]} />
{wallMaterial(walls.side)}
</mesh>
@@ -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<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({
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) {
)}
<Canvas
key={`${hallKey}-${glEpoch}`}
shadows
frameloop={active ? 'always' : 'never'}
gl={{ preserveDrawingBuffer: true, powerPreference: 'high-performance' }}
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]}
intensity={interiorStyle ? 0.85 : 0.65}
color={interiorStyle?.sunLight ?? '#fff8f0'}
castShadow
shadow-mapSize={[1024, 1024]}
/>
<SceneErrorBoundary fallback={null}>
<SceneErrorBoundary
key={`env-${hallKey}-${glEpoch}`}
fallback={null}
onError={handleEnvReady}
>
<Suspense fallback={null}>
<Environment
preset={interiorStyle?.details === 'modern' || interiorStyle?.details === 'industrial' ? 'city' : 'warehouse'}
environmentIntensity={interiorStyle ? 0.35 : 0.15}
/>
<EnvironmentGate onReady={handleEnvReady}>
<Environment
preset={interiorStyle?.details === 'modern' || interiorStyle?.details === 'industrial' ? 'city' : 'warehouse'}
environmentIntensity={interiorStyle ? 0.35 : 0.15}
/>
</EnvironmentGate>
</Suspense>
</SceneErrorBoundary>
<GalleryTextureLoadContext.Provider value={textureLoad}>
@@ -2232,6 +2339,7 @@ export default function VirtualGallery(props: Props) {
nearPassage={nearPassage}
/>
</GalleryTextureLoadContext.Provider>
<WarmHallGpu enabled={warmGpuEnabled} onDone={handleShadersWarmed} />
<CameraController position={camPos} target={camTarget} />
</Canvas>
</div>