feat(web): lighten tabletop scene and tune contact shadows

Replace the dark surface plane with a grey background and a spotlight,
size the contact shadow to the table, and scale its blur and fade with
the shadow plane. Disable depth test on the stacking curve overlay.
This commit is contained in:
2026-08-10 00:57:32 +08:00
parent 50c6df48b5
commit 5a3a9c1fcc
6 changed files with 32 additions and 39 deletions
@@ -8,6 +8,7 @@ import {
resolveMountTree, resolveMountTree,
serializedToPackage, serializedToPackage,
useTabletopStore, useTabletopStore,
MM_TO_WORLD,
} from '@tts/tabletop'; } from '@tts/tabletop';
import Scene from '../viewers/Scene'; import Scene from '../viewers/Scene';
@@ -35,11 +36,26 @@ export default function TabletopScene({
[packageData, surfaces], [packageData, surfaces],
); );
// Size the contact shadow to cover 2x the largest enabled surface, so the
// shadow plane always extends past the table. Surface sizes are in mm;
// `MM_TO_WORLD` converts to world units.
const shadowScale = useMemo(() => {
let maxMm = 0;
for (const [id, enabled] of Object.entries(surfaces)) {
if (!enabled) continue;
const surface = packageData.surfaces.get(id);
if (!surface?.size) continue;
maxMm = Math.max(maxMm, ...surface.size);
}
return maxMm > 0 ? maxMm * 2 * MM_TO_WORLD : 22;
}, [packageData, surfaces]);
return ( return (
<Scene <Scene
autoRotate={false} autoRotate={false}
enablePan enablePan
fullscreen fullscreen
shadowScale={shadowScale}
overlay={ overlay={
<button <button
onClick={() => setShowSurface((v) => !v)} onClick={() => setShowSurface((v) => !v)}
+13 -10
View File
@@ -2,7 +2,7 @@ import { Suspense, useEffect, useRef, useState, type ReactNode } from 'react';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { Canvas } from '@react-three/fiber'; import { Canvas } from '@react-three/fiber';
import { Bounds, ContactShadows, OrbitControls, useProgress } from '@react-three/drei'; import { Bounds, ContactShadows, OrbitControls, useProgress } from '@react-three/drei';
import { Bloom, EffectComposer, Vignette } from '@react-three/postprocessing'; import { EffectComposer, Vignette } from '@react-three/postprocessing';
/** /**
* Shared 3D scene wrapper for object viewers. Provides a consistent camera, * Shared 3D scene wrapper for object viewers. Provides a consistent camera,
@@ -24,6 +24,7 @@ export default function Scene({
enablePan = false, enablePan = false,
fullscreen = false, fullscreen = false,
overlay, overlay,
shadowScale = 22,
}: { }: {
children: ReactNode; children: ReactNode;
autoRotate?: boolean; autoRotate?: boolean;
@@ -31,6 +32,8 @@ export default function Scene({
fullscreen?: boolean; fullscreen?: boolean;
/** HTML rendered inside the scene container (e.g. controls), shown in fullscreen. */ /** HTML rendered inside the scene container (e.g. controls), shown in fullscreen. */
overlay?: ReactNode; overlay?: ReactNode;
/** Contact shadow plane size in world units; defaults to a generous 22. */
shadowScale?: number;
}) { }) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
@@ -49,7 +52,7 @@ export default function Scene({
return ( return (
<div <div
ref={containerRef} ref={containerRef}
className="relative h-80 w-full overflow-hidden rounded-lg bg-linear-to-b from-zinc-900 to-zinc-950" className="relative h-80 w-full overflow-hidden rounded-lg bg-linear-to-b from-zinc-300 to-zinc-400"
> >
<LoadingOverlay /> <LoadingOverlay />
{fullscreen && ( {fullscreen && (
@@ -66,7 +69,7 @@ export default function Scene({
<Canvas <Canvas
camera={{ position: [2.2, 1.8, 2.6], fov: 40 }} camera={{ position: [2.2, 1.8, 2.6], fov: 40 }}
dpr={[1, 2]} dpr={[1, 2]}
gl={{ antialias: true }} gl={{ antialias: true, alpha: true }}
> >
<ambientLight intensity={0.5} /> <ambientLight intensity={0.5} />
<directionalLight position={[4, 6, 3]} intensity={1.4} /> <directionalLight position={[4, 6, 3]} intensity={1.4} />
@@ -78,12 +81,13 @@ export default function Scene({
</Suspense> </Suspense>
<ContactShadows <ContactShadows
position={[0, -0.5, 0]} position={[0, -0.01, 0]}
opacity={0.55} opacity={0.2}
scale={8} scale={shadowScale}
blur={2.4} blur={shadowScale * 0.005}
far={3} far={shadowScale * 0.01}
resolution={256} resolution={1024}
color="#000000"
/> />
<OrbitControls <OrbitControls
enablePan={enablePan} enablePan={enablePan}
@@ -94,7 +98,6 @@ export default function Scene({
/> />
<EffectComposer> <EffectComposer>
<Bloom intensity={0.25} luminanceThreshold={0.85} mipmapBlur />
<Vignette eskil={false} offset={0.25} darkness={0.6} /> <Vignette eskil={false} offset={0.25} darkness={0.6} />
</EffectComposer> </EffectComposer>
</Canvas> </Canvas>
+1 -1
View File
@@ -8,6 +8,7 @@ export {
fallbackShape, fallbackShape,
traceToShape, traceToShape,
traceToUvBounds, traceToUvBounds,
MM_TO_WORLD,
} from './part.js'; } from './part.js';
export { resolveAssetUrl, assetUrl, traceImage } from './http.js'; export { resolveAssetUrl, assetUrl, traceImage } from './http.js';
@@ -34,7 +35,6 @@ export type { MountNode, MountTree } from './mount.js';
export { WorldSurfaceView, SurfaceNode } from './surfaces/WorldSurfaceView.js'; export { WorldSurfaceView, SurfaceNode } from './surfaces/WorldSurfaceView.js';
export { HudSurfaceView } from './surfaces/HudSurfaceView.js'; export { HudSurfaceView } from './surfaces/HudSurfaceView.js';
export { SurfaceBounds } from './surfaces/SurfaceBounds.js'; export { SurfaceBounds } from './surfaces/SurfaceBounds.js';
export { SurfacePlane } from './surfaces/SurfacePlane.js';
// Part placement. // Part placement.
export { PartPlacement } from './placement.js'; export { PartPlacement } from './placement.js';
@@ -29,11 +29,11 @@ export function StackingCurve({ route }: { route: Route }) {
return ( return (
<group> <group>
<Line points={points} color="#f59e0b" lineWidth={2} /> <Line points={points} color="#f59e0b" lineWidth={2} depthTest={false} />
{/* Marker at the curve's start, showing its direction. */} {/* Marker at the curve's start, showing its direction. */}
<mesh position={[start[0], 0.01, start[2]]}> <mesh position={[start[0], 0.01, start[2]]}>
<sphereGeometry args={[0.01, 8, 8]} /> <sphereGeometry args={[0.01, 8, 8]} />
<meshBasicMaterial color="#f59e0b" /> <meshBasicMaterial color="#f59e0b" depthTest={false} />
</mesh> </mesh>
</group> </group>
); );
@@ -1,24 +0,0 @@
/**
* `SurfacePlane` — a visible table surface.
*
* A flat rectangle at the surface's reference `size` (mm, converted to world
* units), centered on the mount anchor. Gives the parts a scale reference and
* reads as the table the game is played on. Rendered only when `showSurface`
* is enabled.
*/
import type { Surface } from '@tts/bgm';
import { MM_TO_WORLD } from '../part.js';
export function SurfacePlane({ surface }: { surface: Surface }) {
if (!surface.size) return null;
const [w, h] = surface.size;
const width = w * MM_TO_WORLD;
const height = h * MM_TO_WORLD;
return (
<mesh rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<planeGeometry args={[width, height]} />
<meshStandardMaterial color="#1c1f26" roughness={0.9} metalness={0} />
</mesh>
);
}
@@ -12,7 +12,6 @@ import { PartPlacement } from '../placement.js';
import type { MountNode } from '../mount.js'; import type { MountNode } from '../mount.js';
import { MM_TO_WORLD, DEG_TO_RAD } from '../part.js'; import { MM_TO_WORLD, DEG_TO_RAD } from '../part.js';
import { SurfaceBounds } from './SurfaceBounds.js'; import { SurfaceBounds } from './SurfaceBounds.js';
import { SurfacePlane } from './SurfacePlane.js';
import { StackingCurve } from './StackingCurve.js'; import { StackingCurve } from './StackingCurve.js';
export function WorldSurfaceView({ export function WorldSurfaceView({
@@ -47,7 +46,6 @@ export function SurfaceNode({
> >
{showSurface && ( {showSurface && (
<> <>
<SurfacePlane surface={node.surface} />
<SurfaceBounds surface={node.surface} /> <SurfaceBounds surface={node.surface} />
{node.surface.layout.map((route, i) => {node.surface.layout.map((route, i) =>
route.stacking ? <StackingCurve key={i} route={route} /> : null, route.stacking ? <StackingCurve key={i} route={route} /> : null,