Fix movement-hall black walls and column clipping.

Cap shared hall lights (no per-painting spots) so MeshStandard walls stay within WebGL limits, tuck classical/neoclassical details into engaged corner pilasters, and document the light budget.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Danila Khodjaef
2026-07-31 00:26:14 +03:00
co-authored by Cursor
parent 710d262516
commit 1853222e01
9 changed files with 159 additions and 97 deletions
+12 -5
View File
@@ -299,10 +299,10 @@ Each artist has **exactly one hall**. The hall is a rectangular room sized to fi
| Floor | Herringbone **parquet** procedural texture (`parquetFloorTexture.ts`) | | Floor | Herringbone **parquet** procedural texture (`parquetFloorTexture.ts`) |
| Wall tint | Gallery walls blend the artists **movement colour** into cream plaster tones | | Wall tint | Gallery walls blend the artists **movement colour** into cream plaster tones |
| Frame finish | **Unchecked** works: black moulding; **Reviewed** (`checkup_checked`): bright gold moulding at **double width** | | Frame finish | **Unchecked** works: black moulding; **Reviewed** (`checkup_checked`): bright gold moulding at **double width** |
| Influence lamps | A golden picture light appears **above frames** whose work has any influence-graph edge (`has_influence_links` from the API); fixture is mounted **upside down** (`InfluencePictureLamp` in `VirtualGallery.tsx`) | | Influence lamps | A golden picture light appears **above frames** whose work has any influence-graph edge (`has_influence_links` from the API); fixture is mounted **upside down** and is **emissive-only** (no per-frame lights — see light budget below) |
| Curator-note plates | A small brass plate hangs **beneath frames** that have non-empty `curator_notes` | | Curator-note plates | A small brass plate hangs **beneath frames** that have non-empty `curator_notes` |
| 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 freestanding columns or pedestals in the walkway |
| 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) | | 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 |
@@ -338,11 +338,12 @@ Enter from the home page by clicking a **movement name** on the movement flow. F
| Period interior | Each of the 26 seeded movements maps to a unique style in `movement-interior-styles.ts` (Italian palazzo, Baroque palace, NYC loft, white cube, etc.). Style keys use a legacy id map (`DB_ID_TO_STYLE_KEY`: gallery ids **126** → authored keys **2752**, with Northern/High Renaissance swapped) so Byzantine gets the chapel, not Gothic stone | | Period interior | Each of the 26 seeded movements maps to a unique style in `movement-interior-styles.ts` (Italian palazzo, Baroque palace, NYC loft, white cube, etc.). Style keys use a legacy id map (`DB_ID_TO_STYLE_KEY`: gallery ids **126** → authored keys **2752**, with Northern/High Renaissance swapped) so Byzantine gets the chapel, not Gothic stone |
| Wall materials | Hi-res **procedural textures** with normal maps (`galleryProceduralTextures.ts`): real-world stone, marble, wood panelling, brick, velvet, stucco — plus **single-colour painted walls** (`painted-lime`, `painted-oil-matte`, `painted-oil-satin`, `painted-emulsion`, `painted-flat`) tinted per movement for Renaissance salons through modern white cubes | | Wall materials | Hi-res **procedural textures** with normal maps (`galleryProceduralTextures.ts`): real-world stone, marble, wood panelling, brick, velvet, stucco — plus **single-colour painted walls** (`painted-lime`, `painted-oil-matte`, `painted-oil-satin`, `painted-emulsion`, `painted-flat`) tinted per movement for Renaissance salons through modern white cubes |
| Textures | Wall/floor/ceiling maps applied via `useTexturedMaterial`; movement **tints** drive painted-wall hue | | Textures | Wall/floor/ceiling maps applied via `useTexturedMaterial`; movement **tints** drive painted-wall hue |
| Windows | **Side walls only** — placed in gaps between frames (high on the wall, no overlap with paintings); style matches the movement era | | Windows | **Side walls only** — placed in gaps between frames when possible; if a wall is packed, high **clerestory** windows are still added so the hall keeps daylight (`computeSideWallWindows`) |
| Lighting | Daylight from windows + ceiling track lights + ambient/sun fill | | Lighting | Shared hall lights only (ambient / hemisphere / directional + capped ceiling track spots + one fill per window). See **light budget** below |
| Period details | `MovementHallDetails.tsx` — classical / neoclassical use **shallow engaged corner pilasters** (never freestanding mid-hall or proud corner shafts that cover frames) |
| Back wall | Single wing: solid display wall. Multi-wing: **Exit double doors****Wing navigator** or **Exit to Timeline** | | Back wall | Single wing: solid display wall. Multi-wing: **Exit double doors****Wing navigator** or **Exit to Timeline** |
| Front / entrance wall | Single wing: **Exit double doors** (leave the way you entered). Multi-wing: **“Next wing →”** archway when a later wing exists | | Front / entrance wall | Single wing: **Exit double doors** (leave the way you entered). Multi-wing: **“Next wing →”** archway when a later wing exists |
| Influence lamps | Same golden upside-down lamps as artist halls when `has_influence_links` is true | | Influence lamps | Same golden upside-down emissive fixtures as artist halls when `has_influence_links` is true |
| Missing images | Draped canvas cover in frame | | Missing images | Draped canvas cover in frame |
| Detail return | **Back to Gallery** from painting detail returns to the same wing with camera preserved; **Back to Timeline** exits the hall entirely | | Detail return | **Back to Gallery** from painting detail returns to the same wing with camera preserved; **Back to Timeline** exits the hall entirely |
@@ -373,6 +374,12 @@ Full guide: [tours.md](tours.md).
### Shared 3D behaviour ### Shared 3D behaviour
**Light budget (critical):** Three.js forward shading cannot compile `MeshStandardMaterial` with dozens of dynamic lights. Large wings used to add **one `spotLight` per painting** (plus influence lamps), which pushed the scene to ~100200 lights — walls, floor, and ceiling then rendered as a black void while painting canvases (`MeshBasicMaterial`) still showed. Halls therefore use **shared lighting only**: ambient / hemisphere / directional fill, a small set of ceiling track spots (`GalleryTrackLights`, capped), and **one** window spot each. Painting canvases stay unlit (`MeshBasicMaterial`); frames and influence fixtures use **emissive** accents instead of per-frame lights. Intensities are scaled for three.js physical lights (post-r155).
**Period architecture:** Freestanding columns must not sit in the walkway or in the corner pocket in front of frames (hang margin is only ~0.7 m from each wall end). Classical / neoclassical details use **engaged corner pilasters** flush to the walls (`MovementHallDetails.tsx`).
**Surface textures:** Procedural colour maps use sRGB; **normal maps use `NoColorSpace`** (`galleryProceduralTextures.ts`). Wrong colour space on normals breaks wall shading.
**3D images** prefer local **thumbnail** files (`galleryImageUrlCandidates` in `client/src/api/client.ts`: thumb → `GET /api/paintings/:id/image?size=thumb`; full originals are **not** used for hall frames — they can be tens of MB and made large halls take ~1 minute). Remote Wikipedia fetches are too slow for realtime WebGL textures. Entering an **artist** or **movement** hall fires `POST /api/artists/:id/preload-images` or `POST /api/movements/:id/preload-images` in the **background** (does not block hall open). Texture downloads are **queued** (max 8 parallel). While a texture is loading, the frame shows the canvas cover instead of a white placeholder. A per-image deadline only releases the boot overlay counter — it does **not** permanently blank the frame if the download finishes later. **3D images** prefer local **thumbnail** files (`galleryImageUrlCandidates` in `client/src/api/client.ts`: thumb → `GET /api/paintings/:id/image?size=thumb`; full originals are **not** used for hall frames — they can be tens of MB and made large halls take ~1 minute). Remote Wikipedia fetches are too slow for realtime WebGL textures. Entering an **artist** or **movement** hall fires `POST /api/artists/:id/preload-images` or `POST /api/movements/:id/preload-images` in the **background** (does not block hall open). Texture downloads are **queued** (max 8 parallel). While a texture is loading, the frame shows the canvas cover instead of a white placeholder. A per-image deadline only releases the boot overlay counter — it does **not** permanently blank the frame if the download finishes later.
**Boot overlay** (`VirtualGallery.tsx`): the center shows **“Loading gallery…”** until the WebGL canvas is ready and a short `gl.compileAsync` warm-up finishes (so entrance doors / passages do not hitch on the first turn). HDR `Environment` loads in a Suspense boundary **without** blocking the overlay. Painting textures keep loading in the background. The 3D hall stays mounted while painting detail or bio overlays are open; returning remounts the canvas when the hall becomes active again. **Boot overlay** (`VirtualGallery.tsx`): the center shows **“Loading gallery…”** until the WebGL canvas is ready and a short `gl.compileAsync` warm-up finishes (so entrance doors / passages do not hitch on the first turn). HDR `Environment` loads in a Suspense boundary **without** blocking the overlay. Painting textures keep loading in the background. The 3D hall stays mounted while painting detail or bio overlays are open; returning remounts the canvas when the hall becomes active again.
+2 -2
View File
@@ -278,9 +278,9 @@ To add or fix a branch, edit `MOVEMENT_LINEAGE` in that file and rebuild the cli
## Movement gallery interiors (frontend) ## Movement gallery interiors (frontend)
Separate from movement lineage layout, `client/src/data/movement-interior-styles.ts` defines a **unique 3D interior** for each seeded art movement (26 styles): wall/floor/ceiling textures, trim colours, window style, and architectural details (columns, coffered ceilings, etc.). Textures are generated procedurally in `client/src/utils/galleryProceduralTextures.ts`. Separate from movement lineage layout, `client/src/data/movement-interior-styles.ts` defines a **unique 3D interior** for each seeded art movement (26 styles): wall/floor/ceiling textures, trim colours, window style, and architectural details (pilasters, coffered ceilings, etc.). Textures are generated procedurally in `client/src/utils/galleryProceduralTextures.ts` (colour maps in sRGB, normal maps in linear/`NoColorSpace`). Period mesh details live in `client/src/components/MovementHallDetails.tsx` — classical / neoclassical use **shallow engaged corner pilasters** so shafts never cover frames.
Wing layout (up to 55 works per wing, U-shaped hang, window gap placement) lives in `client/src/utils/movementHallLayout.ts`. Visit order fills the **left wall** (first work at the entrance), then the **far/end wall**, then the **right** (last work at the entrance). The same hang applies to artist halls and guided tours. To change a movements look, edit its entry in `movement-interior-styles.ts` and rebuild the client. Wing layout (up to 55 works per wing, U-shaped hang, window gap placement with packed-wall **clerestory** fallback) lives in `client/src/utils/movementHallLayout.ts`. Visit order fills the **left wall** (first work at the entrance), then the **far/end wall**, then the **right** (last work at the entrance). The same hang applies to artist halls and guided tours. Hall lighting is **shared** (no per-painting spotlights) so `MeshStandardMaterial` walls stay within WebGL light limits — see [basics.md](basics.md) § Shared 3D behaviour. To change a movements look, edit its entry in `movement-interior-styles.ts` and rebuild the client.
## Historical event markers (frontend timeline) ## Historical event markers (frontend timeline)
+10 -14
View File
@@ -173,22 +173,16 @@ function SingleWindow({
<meshBasicMaterial color="#ffffff" transparent opacity={0.25} toneMapped={false} /> <meshBasicMaterial color="#ffffff" transparent opacity={0.25} toneMapped={false} />
</mesh> </mesh>
{/* Daylight into room */} {/* One fill light per window — keep total scene lights well under WebGL limits. */}
<spotLight <spotLight
position={[0, 0, 0.15]} position={[0, 0, 0.15]}
angle={Math.min(1.2, (spec.width / Math.max(spec.height, 0.5)) * 0.55)} angle={Math.min(1.2, (spec.width / Math.max(spec.height, 0.5)) * 0.55)}
penumbra={0.95} penumbra={0.95}
intensity={spec.lightIntensity} intensity={spec.lightIntensity * Math.PI * 0.85}
distance={14} distance={14}
decay={0}
color={spec.lightColor} color={spec.lightColor}
/> />
<pointLight
position={[0, 0, 0.25]}
intensity={spec.lightIntensity * 0.45}
distance={10}
color={glassColor}
decay={2}
/>
</group> </group>
); );
} }
@@ -222,8 +216,9 @@ export function GalleryTrackLights({
}) { }) {
const positions = useMemo(() => { const positions = useMemo(() => {
const pts: [number, number, number][] = []; const pts: [number, number, number][] = [];
const cols = Math.max(2, Math.min(5, Math.floor(width / 4))); // Cap fixtures so MeshStandard hall materials stay within WebGL light limits.
const rows = Math.max(1, Math.min(3, Math.floor(depth / 6))); const cols = Math.max(2, Math.min(3, Math.floor(width / 5)));
const rows = Math.max(1, Math.min(2, Math.floor(depth / 8)));
for (let c = 0; c < cols; c++) { for (let c = 0; c < cols; c++) {
for (let r = 0; r < rows; r++) { for (let r = 0; r < rows; r++) {
const x = -width / 2 + (width / (cols + 1)) * (c + 1); const x = -width / 2 + (width / (cols + 1)) * (c + 1);
@@ -244,10 +239,11 @@ export function GalleryTrackLights({
</mesh> </mesh>
<spotLight <spotLight
position={[0, -0.04, 0]} position={[0, -0.04, 0]}
angle={0.55} angle={0.7}
penumbra={0.85} penumbra={0.9}
intensity={intensity} intensity={intensity}
distance={8} distance={12}
decay={0}
color={color} color={color}
/> />
</group> </group>
+36 -20
View File
@@ -140,49 +140,65 @@ function MedievalDetails({ halfW, halfD }: Pick<Props, 'halfW' | 'halfD'>) {
} }
function NeoclassicalDetails({ halfW, halfD, trim }: Pick<Props, 'halfW' | 'halfD'> & { trim: string }) { function NeoclassicalDetails({ halfW, halfD, trim }: Pick<Props, 'halfW' | 'halfD'> & { trim: string }) {
const columns = [ // Shallow engaged corner pilasters only. Freestanding columns (~0.5m into the
[-halfW + 0.45, -halfD + 0.6], // room) overlap the outermost frames — hang margin is only ~0.7m from each end.
[halfW - 0.45, -halfD + 0.6], const pilasters = useMemo(
[-halfW + 0.45, halfD - 0.6], () =>
[halfW - 0.45, halfD - 0.6], [
] as [number, number][]; [-halfW + 0.06, -halfD + 0.06],
[halfW - 0.06, -halfD + 0.06],
[-halfW + 0.06, halfD - 0.06],
[halfW - 0.06, halfD - 0.06],
] as [number, number][],
[halfW, halfD]
);
return ( return (
<group> <group>
{columns.map(([x, z], i) => ( {pilasters.map(([x, z], i) => (
<group key={i} position={[x, 0, z]}> <group key={i} position={[x, 0, z]}>
<mesh position={[0, 1.85, 0]}> <mesh position={[0, 1.85, 0]}>
<cylinderGeometry args={[0.18, 0.2, 3.7, 12]} /> <boxGeometry args={[0.12, 3.7, 0.12]} />
<meshStandardMaterial color="#f0ece8" roughness={0.72} metalness={0.12} /> <meshStandardMaterial color="#f0ece8" roughness={0.72} metalness={0.12} />
</mesh> </mesh>
<mesh position={[0, 3.85, 0]}> <mesh position={[0, 3.78, 0]}>
<boxGeometry args={[0.42, 0.12, 0.42]} /> <boxGeometry args={[0.18, 0.1, 0.18]} />
<meshStandardMaterial color={trim} roughness={0.4} metalness={0.35} /> <meshStandardMaterial color={trim} roughness={0.4} metalness={0.35} />
</mesh> </mesh>
</group> </group>
))} ))}
<mesh position={[0, 3.88, -halfD + 0.12]}> {/* Cornice above wall title (title sits ~y 3.75) */}
<boxGeometry args={[2.8, 0.14, 0.2]} /> <mesh position={[0, 4.08, -halfD + 0.1]}>
<boxGeometry args={[Math.max(2.4, halfW * 2 - 0.5), 0.1, 0.16]} />
<meshStandardMaterial color={trim} roughness={0.45} metalness={0.3} /> <meshStandardMaterial color={trim} roughness={0.45} metalness={0.3} />
</mesh> </mesh>
</group> </group>
); );
} }
function ClassicalDetails({ halfW, trim }: Pick<Props, 'halfW'> & { trim: string }) { function ClassicalDetails({ halfW, halfD, trim }: Pick<Props, 'halfW' | 'halfD'> & { trim: string }) {
// Engaged corner shafts only — anything proud of the corner pocket covers frames.
const columns = useMemo(
() =>
[
[-halfW + 0.06, -halfD + 0.06],
[halfW - 0.06, -halfD + 0.06],
[-halfW + 0.06, halfD - 0.06],
[halfW - 0.06, halfD - 0.06],
] as [number, number][],
[halfW, halfD]
);
return ( return (
<group> <group>
{[ {columns.map(([x, z], i) => (
[-halfW + 0.5, 0],
[halfW - 0.5, 0],
].map(([x, z], i) => (
<group key={i} position={[x, 0, z]}> <group key={i} position={[x, 0, z]}>
<mesh position={[0, 2, 0]}> <mesh position={[0, 2, 0]}>
<cylinderGeometry args={[0.16, 0.18, 4, 10]} /> <cylinderGeometry args={[0.08, 0.09, 4, 10]} />
<meshStandardMaterial color="#e0d8c8" roughness={0.88} /> <meshStandardMaterial color="#e0d8c8" roughness={0.88} />
</mesh> </mesh>
<mesh position={[0, 4.05, 0]}> <mesh position={[0, 4.05, 0]}>
<boxGeometry args={[0.36, 0.1, 0.36]} /> <boxGeometry args={[0.18, 0.1, 0.18]} />
<meshStandardMaterial color={trim} roughness={0.5} metalness={0.25} /> <meshStandardMaterial color={trim} roughness={0.5} metalness={0.25} />
</mesh> </mesh>
</group> </group>
@@ -213,7 +229,7 @@ export default function MovementHallDetails({ style, width, depth, halfW, halfD
case 'neoclassical': case 'neoclassical':
return <NeoclassicalDetails halfW={halfW} halfD={halfD} trim={trim} />; return <NeoclassicalDetails halfW={halfW} halfD={halfD} trim={trim} />;
case 'classical': case 'classical':
return <ClassicalDetails halfW={halfW} trim={trim} />; return <ClassicalDetails halfW={halfW} halfD={halfD} trim={trim} />;
case 'salon': case 'salon':
return <SalonDetails trim={trim} />; return <SalonDetails trim={trim} />;
default: default:
+66 -46
View File
@@ -844,25 +844,11 @@ function InfluencePictureLamp({
<meshStandardMaterial <meshStandardMaterial
color="#fff8e0" color="#fff8e0"
emissive="#ffcc55" emissive="#ffcc55"
emissiveIntensity={1.4 * glow} emissiveIntensity={1.8 * glow}
toneMapped={false} toneMapped={false}
/> />
</mesh> </mesh>
<pointLight {/* No per-painting lights — too many MeshStandard lights break hall shaders. */}
position={[0, -0.03, 0.05]}
intensity={1.8 * glow}
distance={4}
color="#ffe4a8"
decay={2}
/>
<spotLight
position={[0, -0.03, 0.05]}
angle={0.75}
penumbra={0.95}
intensity={2.2 * glow}
distance={5}
color="#fff0c8"
/>
</group> </group>
</group> </group>
); );
@@ -970,17 +956,12 @@ function PaintingFrame({
const captionY = const captionY =
-height / 2 - matBorder - rail - (hasCuratorNotes ? 0.18 : 0.1); -height / 2 - matBorder - rail - (hasCuratorNotes ? 0.18 : 0.1);
// Hall lighting is shared (track/ambient). Per-frame spotLights (× dozens of
// paintings) exceed WebGL light limits and make MeshStandard walls vanish.
const frameEmissiveBoost = hovered ? 0.35 : showCanvas ? 0.08 : 0.12;
return ( return (
<group position={position} rotation={[0, rotationY, 0]}> <group position={position} rotation={[0, rotationY, 0]}>
<spotLight
position={[0, height / 2 + 0.25, 0.3]}
angle={0.5}
penumbra={0.75}
intensity={showCanvas ? (hovered ? 0.9 : 0.55) : hovered ? 2.6 : 1.9}
distance={4.5}
color={showCanvas ? '#e8dcc8' : '#fff8ee'}
/>
<mesh <mesh
position={[0, 0, frameDepth / 2]} position={[0, 0, frameDepth / 2]}
renderOrder={1} renderOrder={1}
@@ -996,8 +977,8 @@ function PaintingFrame({
color={finish.color} color={finish.color}
roughness={finish.roughness} roughness={finish.roughness}
metalness={finish.metalness} metalness={finish.metalness}
emissive={finish.emissive} emissive={reviewed ? finish.emissive : '#d8c090'}
emissiveIntensity={finish.emissiveIntensity} emissiveIntensity={finish.emissiveIntensity + frameEmissiveBoost}
/> />
</mesh> </mesh>
@@ -1441,7 +1422,7 @@ function GalleryFloor({
normalScale={new THREE.Vector2(texture.normalScale, texture.normalScale)} normalScale={new THREE.Vector2(texture.normalScale, texture.normalScale)}
roughness={texture.roughness} roughness={texture.roughness}
metalness={texture.metalness} metalness={texture.metalness}
envMapIntensity={0.65} envMapIntensity={0.3}
color={interiorStyle.tints.floor} color={interiorStyle.tints.floor}
/> />
</mesh> </mesh>
@@ -1661,18 +1642,21 @@ function ArtistHall({
) )
) : null} ) : null}
{interiorStyle && computedWindows && computedWindows.length > 0 && ( {interiorStyle && (
<> <>
<GalleryWindows {computedWindows && computedWindows.length > 0 && (
windows={computedWindows} <GalleryWindows
halfW={halfW} windows={computedWindows}
halfD={halfD} halfW={halfW}
trimColor={interiorStyle.tints.trim} halfD={halfD}
/> trimColor={interiorStyle.tints.trim}
/>
)}
{/* Always light the hall — do not gate track lights on window gaps. */}
<GalleryTrackLights <GalleryTrackLights
width={width} width={width}
depth={depth} depth={depth}
intensity={interiorStyle.trackLights} intensity={Math.max(1.8, interiorStyle.trackLights * 2.8) * Math.PI}
color={interiorStyle.warmLight} color={interiorStyle.warmLight}
/> />
</> </>
@@ -1750,15 +1734,24 @@ function ArtistHall({
</group> </group>
))} ))}
<pointLight position={[0, WALL_HEIGHT - 0.4, 0]} intensity={interiorStyle ? interiorStyle.ambient * 1.2 : 0.5} color={warmLight} distance={width + depth} /> <pointLight
<pointLight position={[0, WALL_HEIGHT - 0.4, -halfD / 2]} intensity={interiorStyle ? interiorStyle.ambient * 0.85 : 0.35} color={warmLight} distance={Math.max(12, depth * 0.75)} /> position={[0, WALL_HEIGHT - 0.4, 0]}
{depth > 14 && ( intensity={(interiorStyle ? Math.max(1.0, interiorStyle.ambient * 1.8) : 0.7) * Math.PI}
<pointLight position={[0, WALL_HEIGHT - 0.4, -halfD + 1.2]} intensity={interiorStyle ? interiorStyle.ambient * 0.7 : 0.3} color={warmLight} distance={14} /> distance={width + depth}
)} decay={0}
color={warmLight}
/>
<pointLight
position={[0, WALL_HEIGHT - 0.4, -halfD / 2]}
intensity={(interiorStyle ? Math.max(0.7, interiorStyle.ambient * 1.2) : 0.5) * Math.PI}
distance={Math.max(12, depth * 0.75)}
decay={0}
color={warmLight}
/>
{interiorStyle && ( {interiorStyle && (
<directionalLight <directionalLight
position={[0, 6, -halfD - 2]} position={[0, 6, -halfD - 2]}
intensity={interiorStyle.ambient * 0.9} intensity={Math.max(0.55, interiorStyle.ambient * 0.85) * Math.PI}
color={interiorStyle.sunLight} color={interiorStyle.sunLight}
/> />
)} )}
@@ -2043,6 +2036,10 @@ export default function VirtualGallery(props: Props) {
const handleCanvasCreated = useCallback((state: { gl: THREE.WebGLRenderer }) => { const handleCanvasCreated = useCallback((state: { gl: THREE.WebGLRenderer }) => {
const canvas = state.gl.domElement; const canvas = state.gl.domElement;
// Physically correct lights (three r155+) need higher exposure so stone/dark
// period halls stay readable without HDR IBL.
state.gl.toneMapping = THREE.ACESFilmicToneMapping;
state.gl.toneMappingExposure = 1.25;
// A freshly created canvas has a healthy context, so clear any lingering // A freshly created canvas has a healthy context, so clear any lingering
// "restoring" state from a previous loss/remount. // "restoring" state from a previous loss/remount.
setGlLost(false); setGlLost(false);
@@ -2432,7 +2429,9 @@ export default function VirtualGallery(props: Props) {
const sceneBackground = interiorStyle?.background ?? '#0d0906'; const sceneBackground = interiorStyle?.background ?? '#0d0906';
const sceneFog = interiorStyle?.fog ?? '#0d0906'; const sceneFog = interiorStyle?.fog ?? '#0d0906';
const ambientIntensity = interiorStyle?.ambient ?? 0.42; // Floor ambient so dark period styles (Gothic, Byzantine) stay readable without HDR IBL.
// three r155+ physical lights need ~π× legacy intensity for similar brightness.
const ambientIntensity = Math.max(0.85, interiorStyle?.ambient ?? 0.55) * Math.PI;
const handleCanvasPointerDown = (e: React.PointerEvent<HTMLDivElement>) => { const handleCanvasPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (!active || showExitNav || e.button !== 0) return; if (!active || showExitNav || e.button !== 0) return;
@@ -2539,13 +2538,34 @@ export default function VirtualGallery(props: Props) {
> >
<FrameloopSync active={active} /> <FrameloopSync active={active} />
<color attach="background" args={[sceneBackground]} /> <color attach="background" args={[sceneBackground]} />
<fog attach="fog" args={[sceneFog, 18, fogFar]} /> <fog
attach="fog"
args={[sceneFog, Math.max(45, layout.depth * 1.15), Math.max(fogFar, layout.depth + 55)]}
/>
<ambientLight intensity={ambientIntensity} /> <ambientLight intensity={ambientIntensity} />
<hemisphereLight
color={interiorStyle?.warmLight ?? '#fff8f0'}
groundColor={sceneFog}
intensity={0.55 * Math.PI}
/>
<directionalLight <directionalLight
position={interiorStyle ? [2, 10, -4] : [3, 9, 4]} position={interiorStyle ? [2, 10, -4] : [3, 9, 4]}
intensity={interiorStyle ? 0.85 : 0.65} intensity={(interiorStyle ? 1.4 : 0.9) * Math.PI}
color={interiorStyle?.sunLight ?? '#fff8f0'} color={interiorStyle?.sunLight ?? '#fff8f0'}
/> />
<directionalLight
position={[-4, 6, 5]}
intensity={0.55 * Math.PI}
color={interiorStyle?.warmLight ?? '#fff5e8'}
/>
{/* Guaranteed fill so walls never disappear if HDR Environment fails. */}
<pointLight
position={[0, 3.2, 0]}
intensity={2.2 * Math.PI}
distance={40}
decay={0}
color={interiorStyle?.warmLight ?? '#fff5e8'}
/>
<SceneErrorBoundary <SceneErrorBoundary
key={`env-${hallKey}-${glEpoch}`} key={`env-${hallKey}-${glEpoch}`}
fallback={null} fallback={null}
@@ -2553,7 +2573,7 @@ export default function VirtualGallery(props: Props) {
<Suspense fallback={null}> <Suspense fallback={null}>
<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.55 : 0.25}
/> />
</Suspense> </Suspense>
</SceneErrorBoundary> </SceneErrorBoundary>
+5 -3
View File
@@ -144,17 +144,19 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
{ {
details: 'medieval', details: 'medieval',
titleColor: '#e8dcc8', titleColor: '#e8dcc8',
ambient: 0.52, ambient: 0.72,
warmLight: '#e8d8c0', warmLight: '#e8d8c0',
sunLight: '#d0e8ff', sunLight: '#d0e8ff',
fog: '#0c0c10', // Keep atmosphere dark, but not pure void (walls must stay readable).
fog: '#1a1816',
background: '#141210',
windows: windows( windows: windows(
{ wall: 'back', x: -2, y: 2.8, width: 0.8, height: 2.8, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.5 }, { wall: 'back', x: -2, y: 2.8, width: 0.8, height: 2.8, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.5 },
{ wall: 'back', x: 2, y: 2.8, width: 0.8, height: 2.8, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.5 }, { wall: 'back', x: 2, y: 2.8, width: 0.8, height: 2.8, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.5 },
{ wall: 'left', x: 0, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 2.8 }, { wall: 'left', x: 0, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 2.8 },
{ wall: 'right', x: 0, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 2.8 } { wall: 'right', x: 0, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 2.8 }
), ),
trackLights: 0.3, trackLights: 0.85,
} }
), ),
30: mk( 30: mk(
+2 -1
View File
@@ -22,7 +22,8 @@ export function useTexturedMaterial(
color: tint, color: tint,
roughness: surf.roughness, roughness: surf.roughness,
metalness: surf.metalness, metalness: surf.metalness,
envMapIntensity: kind.startsWith('painted-') ? 0.45 : 0.65, // Keep walls readable without HDR Environment IBL (CDN may be slow/offline).
envMapIntensity: kind.startsWith('painted-') ? 0.2 : 0.3,
}); });
}, [kind, tint, spanW, spanH]); }, [kind, tint, spanW, spanH]);
@@ -583,7 +583,7 @@ function imageDataToNormalMap(data: ImageData, size: number, strength = 2.5): Im
return out; return out;
} }
function imageDataToTexture(data: ImageData): THREE.CanvasTexture { function imageDataToTexture(data: ImageData, colorSpace: THREE.ColorSpace): THREE.CanvasTexture {
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = data.width; canvas.width = data.width;
canvas.height = data.height; canvas.height = data.height;
@@ -591,8 +591,8 @@ function imageDataToTexture(data: ImageData): THREE.CanvasTexture {
const tex = new THREE.CanvasTexture(canvas); const tex = new THREE.CanvasTexture(canvas);
tex.wrapS = THREE.RepeatWrapping; tex.wrapS = THREE.RepeatWrapping;
tex.wrapT = THREE.RepeatWrapping; tex.wrapT = THREE.RepeatWrapping;
tex.colorSpace = THREE.SRGBColorSpace; tex.colorSpace = colorSpace;
tex.anisotropy = 16; tex.anisotropy = 8;
return tex; return tex;
} }
@@ -686,8 +686,9 @@ export function getSurfaceTexture(kind: SurfaceTextureKind): SurfaceTextureSet {
const normalData = imageDataToNormalMap(colorData, TEXTURE_SIZE, normalStrengthFor(kind)); const normalData = imageDataToNormalMap(colorData, TEXTURE_SIZE, normalStrengthFor(kind));
const set: SurfaceTextureSet = { const set: SurfaceTextureSet = {
map: imageDataToTexture(colorData), map: imageDataToTexture(colorData, THREE.SRGBColorSpace),
normalMap: imageDataToTexture(normalData), // Normal maps must stay linear — sRGB encoding breaks lighting on walls.
normalMap: imageDataToTexture(normalData, THREE.NoColorSpace),
roughness: ROUGHNESS[kind] ?? 0.75, roughness: ROUGHNESS[kind] ?? 0.75,
metalness: METALNESS[kind] ?? 0.04, metalness: METALNESS[kind] ?? 0.04,
metersPerRepeat: METERS[kind] ?? 2.8, metersPerRepeat: METERS[kind] ?? 2.8,
+20 -1
View File
@@ -326,7 +326,7 @@ function windowTemplate(style: MovementInteriorStyle): Pick<GalleryWindowSpec, '
return { style: 'sash', lightColor: style.warmLight, lightIntensity: 2.8, width: 1.4, height: 1.4 }; return { style: 'sash', lightColor: style.warmLight, lightIntensity: 2.8, width: 1.4, height: 1.4 };
} }
/** Place windows on side walls only, in gaps between painting frames. */ /** Place windows on side walls in gaps between frames; fall back to high clerestory when packed. */
export function computeSideWallWindows( export function computeSideWallWindows(
layout: MovementHallLayout, layout: MovementHallLayout,
interiorStyle: MovementInteriorStyle interiorStyle: MovementInteriorStyle
@@ -364,6 +364,25 @@ export function computeSideWallWindows(
lightIntensity: tmpl.lightIntensity, lightIntensity: tmpl.lightIntensity,
}); });
} }
// Packed walls: still add high clerestory windows so the hall gets daylight + style.
if (!specs.some((s) => s.wall === side)) {
const span = Math.max(2.4, halfD * 1.2);
const count = span > 8 ? 2 : 1;
for (let i = 0; i < count; i++) {
const t = count === 1 ? 0 : (i === 0 ? -0.35 : 0.35);
specs.push({
wall: side,
x: halfD * t,
y: 3.45,
width: Math.min(tmpl.width, 1.15),
height: Math.min(tmpl.height, 1.05),
style: tmpl.style,
lightColor: tmpl.lightColor,
lightIntensity: tmpl.lightIntensity * 0.85,
});
}
}
} }
return specs; return specs;