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:
@@ -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)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user