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
+10 -14
View File
@@ -173,22 +173,16 @@ function SingleWindow({
<meshBasicMaterial color="#ffffff" transparent opacity={0.25} toneMapped={false} />
</mesh>
{/* Daylight into room */}
{/* One fill light per window — keep total scene lights well under WebGL limits. */}
<spotLight
position={[0, 0, 0.15]}
angle={Math.min(1.2, (spec.width / Math.max(spec.height, 0.5)) * 0.55)}
penumbra={0.95}
intensity={spec.lightIntensity}
intensity={spec.lightIntensity * Math.PI * 0.85}
distance={14}
decay={0}
color={spec.lightColor}
/>
<pointLight
position={[0, 0, 0.25]}
intensity={spec.lightIntensity * 0.45}
distance={10}
color={glassColor}
decay={2}
/>
</group>
);
}
@@ -222,8 +216,9 @@ export function GalleryTrackLights({
}) {
const positions = useMemo(() => {
const pts: [number, number, number][] = [];
const cols = Math.max(2, Math.min(5, Math.floor(width / 4)));
const rows = Math.max(1, Math.min(3, Math.floor(depth / 6)));
// Cap fixtures so MeshStandard hall materials stay within WebGL light limits.
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 r = 0; r < rows; r++) {
const x = -width / 2 + (width / (cols + 1)) * (c + 1);
@@ -244,10 +239,11 @@ export function GalleryTrackLights({
</mesh>
<spotLight
position={[0, -0.04, 0]}
angle={0.55}
penumbra={0.85}
angle={0.7}
penumbra={0.9}
intensity={intensity}
distance={8}
distance={12}
decay={0}
color={color}
/>
</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 }) {
const columns = [
[-halfW + 0.45, -halfD + 0.6],
[halfW - 0.45, -halfD + 0.6],
[-halfW + 0.45, halfD - 0.6],
[halfW - 0.45, halfD - 0.6],
] as [number, number][];
// Shallow engaged corner pilasters only. Freestanding columns (~0.5m into the
// room) overlap the outermost frames — hang margin is only ~0.7m from each end.
const pilasters = 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 (
<group>
{columns.map(([x, z], i) => (
{pilasters.map(([x, z], i) => (
<group key={i} position={[x, 0, z]}>
<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} />
</mesh>
<mesh position={[0, 3.85, 0]}>
<boxGeometry args={[0.42, 0.12, 0.42]} />
<mesh position={[0, 3.78, 0]}>
<boxGeometry args={[0.18, 0.1, 0.18]} />
<meshStandardMaterial color={trim} roughness={0.4} metalness={0.35} />
</mesh>
</group>
))}
<mesh position={[0, 3.88, -halfD + 0.12]}>
<boxGeometry args={[2.8, 0.14, 0.2]} />
{/* Cornice above wall title (title sits ~y 3.75) */}
<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} />
</mesh>
</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 (
<group>
{[
[-halfW + 0.5, 0],
[halfW - 0.5, 0],
].map(([x, z], i) => (
{columns.map(([x, z], i) => (
<group key={i} position={[x, 0, z]}>
<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} />
</mesh>
<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} />
</mesh>
</group>
@@ -213,7 +229,7 @@ export default function MovementHallDetails({ style, width, depth, halfW, halfD
case 'neoclassical':
return <NeoclassicalDetails halfW={halfW} halfD={halfD} trim={trim} />;
case 'classical':
return <ClassicalDetails halfW={halfW} trim={trim} />;
return <ClassicalDetails halfW={halfW} halfD={halfD} trim={trim} />;
case 'salon':
return <SalonDetails trim={trim} />;
default:
+66 -46
View File
@@ -844,25 +844,11 @@ function InfluencePictureLamp({
<meshStandardMaterial
color="#fff8e0"
emissive="#ffcc55"
emissiveIntensity={1.4 * glow}
emissiveIntensity={1.8 * glow}
toneMapped={false}
/>
</mesh>
<pointLight
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"
/>
{/* No per-painting lights — too many MeshStandard lights break hall shaders. */}
</group>
</group>
);
@@ -970,17 +956,12 @@ function PaintingFrame({
const captionY =
-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 (
<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
position={[0, 0, frameDepth / 2]}
renderOrder={1}
@@ -996,8 +977,8 @@ function PaintingFrame({
color={finish.color}
roughness={finish.roughness}
metalness={finish.metalness}
emissive={finish.emissive}
emissiveIntensity={finish.emissiveIntensity}
emissive={reviewed ? finish.emissive : '#d8c090'}
emissiveIntensity={finish.emissiveIntensity + frameEmissiveBoost}
/>
</mesh>
@@ -1441,7 +1422,7 @@ function GalleryFloor({
normalScale={new THREE.Vector2(texture.normalScale, texture.normalScale)}
roughness={texture.roughness}
metalness={texture.metalness}
envMapIntensity={0.65}
envMapIntensity={0.3}
color={interiorStyle.tints.floor}
/>
</mesh>
@@ -1661,18 +1642,21 @@ function ArtistHall({
)
) : null}
{interiorStyle && computedWindows && computedWindows.length > 0 && (
{interiorStyle && (
<>
<GalleryWindows
windows={computedWindows}
halfW={halfW}
halfD={halfD}
trimColor={interiorStyle.tints.trim}
/>
{computedWindows && computedWindows.length > 0 && (
<GalleryWindows
windows={computedWindows}
halfW={halfW}
halfD={halfD}
trimColor={interiorStyle.tints.trim}
/>
)}
{/* Always light the hall — do not gate track lights on window gaps. */}
<GalleryTrackLights
width={width}
depth={depth}
intensity={interiorStyle.trackLights}
intensity={Math.max(1.8, interiorStyle.trackLights * 2.8) * Math.PI}
color={interiorStyle.warmLight}
/>
</>
@@ -1750,15 +1734,24 @@ function ArtistHall({
</group>
))}
<pointLight position={[0, WALL_HEIGHT - 0.4, 0]} intensity={interiorStyle ? interiorStyle.ambient * 1.2 : 0.5} color={warmLight} distance={width + depth} />
<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)} />
{depth > 14 && (
<pointLight position={[0, WALL_HEIGHT - 0.4, -halfD + 1.2]} intensity={interiorStyle ? interiorStyle.ambient * 0.7 : 0.3} color={warmLight} distance={14} />
)}
<pointLight
position={[0, WALL_HEIGHT - 0.4, 0]}
intensity={(interiorStyle ? Math.max(1.0, interiorStyle.ambient * 1.8) : 0.7) * Math.PI}
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 && (
<directionalLight
position={[0, 6, -halfD - 2]}
intensity={interiorStyle.ambient * 0.9}
intensity={Math.max(0.55, interiorStyle.ambient * 0.85) * Math.PI}
color={interiorStyle.sunLight}
/>
)}
@@ -2043,6 +2036,10 @@ export default function VirtualGallery(props: Props) {
const handleCanvasCreated = useCallback((state: { gl: THREE.WebGLRenderer }) => {
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
// "restoring" state from a previous loss/remount.
setGlLost(false);
@@ -2432,7 +2429,9 @@ export default function VirtualGallery(props: Props) {
const sceneBackground = interiorStyle?.background ?? '#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>) => {
if (!active || showExitNav || e.button !== 0) return;
@@ -2539,13 +2538,34 @@ export default function VirtualGallery(props: Props) {
>
<FrameloopSync active={active} />
<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} />
<hemisphereLight
color={interiorStyle?.warmLight ?? '#fff8f0'}
groundColor={sceneFog}
intensity={0.55 * Math.PI}
/>
<directionalLight
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'}
/>
<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
key={`env-${hallKey}-${glEpoch}`}
fallback={null}
@@ -2553,7 +2573,7 @@ export default function VirtualGallery(props: Props) {
<Suspense fallback={null}>
<Environment
preset={interiorStyle?.details === 'modern' || interiorStyle?.details === 'industrial' ? 'city' : 'warehouse'}
environmentIntensity={interiorStyle ? 0.35 : 0.15}
environmentIntensity={interiorStyle ? 0.55 : 0.25}
/>
</Suspense>
</SceneErrorBoundary>
+5 -3
View File
@@ -144,17 +144,19 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
{
details: 'medieval',
titleColor: '#e8dcc8',
ambient: 0.52,
ambient: 0.72,
warmLight: '#e8d8c0',
sunLight: '#d0e8ff',
fog: '#0c0c10',
// Keep atmosphere dark, but not pure void (walls must stay readable).
fog: '#1a1816',
background: '#141210',
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: '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 }
),
trackLights: 0.3,
trackLights: 0.85,
}
),
30: mk(
+2 -1
View File
@@ -22,7 +22,8 @@ export function useTexturedMaterial(
color: tint,
roughness: surf.roughness,
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]);
@@ -583,7 +583,7 @@ function imageDataToNormalMap(data: ImageData, size: number, strength = 2.5): Im
return out;
}
function imageDataToTexture(data: ImageData): THREE.CanvasTexture {
function imageDataToTexture(data: ImageData, colorSpace: THREE.ColorSpace): THREE.CanvasTexture {
const canvas = document.createElement('canvas');
canvas.width = data.width;
canvas.height = data.height;
@@ -591,8 +591,8 @@ function imageDataToTexture(data: ImageData): THREE.CanvasTexture {
const tex = new THREE.CanvasTexture(canvas);
tex.wrapS = THREE.RepeatWrapping;
tex.wrapT = THREE.RepeatWrapping;
tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = 16;
tex.colorSpace = colorSpace;
tex.anisotropy = 8;
return tex;
}
@@ -686,8 +686,9 @@ export function getSurfaceTexture(kind: SurfaceTextureKind): SurfaceTextureSet {
const normalData = imageDataToNormalMap(colorData, TEXTURE_SIZE, normalStrengthFor(kind));
const set: SurfaceTextureSet = {
map: imageDataToTexture(colorData),
normalMap: imageDataToTexture(normalData),
map: imageDataToTexture(colorData, THREE.SRGBColorSpace),
// Normal maps must stay linear — sRGB encoding breaks lighting on walls.
normalMap: imageDataToTexture(normalData, THREE.NoColorSpace),
roughness: ROUGHNESS[kind] ?? 0.75,
metalness: METALNESS[kind] ?? 0.04,
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 };
}
/** 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(
layout: MovementHallLayout,
interiorStyle: MovementInteriorStyle
@@ -364,6 +364,25 @@ export function computeSideWallWindows(
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;