fix(web): size contact shadows to content bounds
Lay flat card/tile/token meshes so the scene-level contact shadow has a surface to project onto, and size the shadow plane to the measured content bounds instead of a fixed 22-unit default that left small objects shadowless. Give deck carousel cards their own soft radial shadow so the fanned arc reads as floating over a floor.
This commit is contained in:
@@ -28,7 +28,12 @@ import type { ViewerProps } from '../viewers';
|
||||
export default function CardViewer({ object, fill }: ViewerProps) {
|
||||
return (
|
||||
<Scene fill={fill}>
|
||||
<CardObjectMesh object={object} />
|
||||
{/* Cards are authored standing (face in XY, thickness along Z); lay them
|
||||
flat on the ground so the face points up like a card on a table, which
|
||||
is also what the contact shadow needs to project. */}
|
||||
<group rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<CardObjectMesh object={object} />
|
||||
</group>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ export default function DeckViewer({ object, fill }: ViewerProps) {
|
||||
fit={false}
|
||||
autoRotate={false}
|
||||
fill={fill}
|
||||
shadow={false}
|
||||
overlay={
|
||||
hasCards ? (
|
||||
<CarouselControls active={active} count={count} onStep={step} />
|
||||
@@ -90,7 +91,11 @@ export default function DeckViewer({ object, fill }: ViewerProps) {
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<CardObjectMesh object={object} />
|
||||
// No contained cards: show the deck object itself, laid flat so the
|
||||
// shared scene's contact shadow has a surface to project onto.
|
||||
<group rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<CardObjectMesh object={object} />
|
||||
</group>
|
||||
)}
|
||||
</Scene>
|
||||
);
|
||||
@@ -187,10 +192,69 @@ function CarouselCard({
|
||||
return (
|
||||
<group ref={group}>
|
||||
<CardObjectMesh object={card} />
|
||||
{/* A soft radial shadow under this card; it inherits the card group's
|
||||
position/rotation/scale, so it tweens with the card. Card meshes are
|
||||
laid flat (face up), so the plane sits just below the card's bottom. */}
|
||||
<CardShadow />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// A soft elliptical drop shadow under a single carousel card. The card mesh is
|
||||
// ~2 world-units tall (centered), so its bottom sits at local y = -1; we put a
|
||||
// radial-gradient plane just below it. I can't use `ContactShadows` here
|
||||
// because the card is a thin vertical panel — a top-down capture would only
|
||||
// catch a line. Instead we fake a soft ambient contact shadow with a gradient.
|
||||
const CARD_HALF_HEIGHT = 1;
|
||||
const SHADOW_SCALE = 2.6;
|
||||
|
||||
function CardShadow() {
|
||||
// A soft radial gradient, dark at the center fading to transparent — reads as
|
||||
// a soft blob of shadow rather than a hard cast shadow.
|
||||
const texture = useMemo(makeSoftShadowTexture, []);
|
||||
return (
|
||||
<mesh
|
||||
position={[0, -CARD_HALF_HEIGHT, 0]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
scale={[SHADOW_SCALE, SHADOW_SCALE, 1]}
|
||||
>
|
||||
<planeGeometry args={[1, 1]} />
|
||||
<meshBasicMaterial map={texture} transparent depthWrite={false} />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
/** Build a radial-gradient texture: dark center fading out to transparent. */
|
||||
function makeSoftShadowTexture(): THREE.Texture {
|
||||
const size = 256;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return new THREE.Texture();
|
||||
const g = ctx.createRadialGradient(
|
||||
size / 2,
|
||||
size / 2,
|
||||
0,
|
||||
size / 2,
|
||||
size / 2,
|
||||
size / 2,
|
||||
);
|
||||
const stops: [number, number][] = [
|
||||
[0, 0.55],
|
||||
[0.45, 0.35],
|
||||
[0.75, 0.12],
|
||||
[1, 0],
|
||||
];
|
||||
for (const [t, a] of stops) {
|
||||
g.addColorStop(t, `rgba(0, 0, 0, ${a})`);
|
||||
}
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
}
|
||||
|
||||
interface Slots {
|
||||
x: number;
|
||||
z: number;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Suspense, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { Suspense, useEffect, useRef, useState, type RefObject, type ReactNode } from 'react';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { Canvas } from '@react-three/fiber';
|
||||
import { Canvas, useFrame } from '@react-three/fiber';
|
||||
import { Bounds, ContactShadows, Environment, Lightformer, OrbitControls, useProgress } from '@react-three/drei';
|
||||
import { BrightnessContrast, EffectComposer, ToneMapping, Vignette } from '@react-three/postprocessing';
|
||||
import { ToneMappingMode } from 'postprocessing';
|
||||
import * as THREE from 'three';
|
||||
|
||||
/**
|
||||
* Shared 3D scene wrapper for object viewers. Provides a consistent camera,
|
||||
@@ -39,6 +40,8 @@ export default function Scene({
|
||||
overlay,
|
||||
shadowScale = 22,
|
||||
maxPolarAngle = Math.PI,
|
||||
shadowBlur = 0.012,
|
||||
shadow = true,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
autoRotate?: boolean;
|
||||
@@ -52,11 +55,18 @@ export default function Scene({
|
||||
overlay?: ReactNode;
|
||||
/** Contact shadow plane size in world units; defaults to a generous 22. */
|
||||
shadowScale?: number;
|
||||
/** Contact shadow blur as a fraction of the plane size; a larger value is softer. */
|
||||
shadowBlur?: number;
|
||||
/** Whether to render a scene-level contact shadow under the content (default true). */
|
||||
shadow?: boolean;
|
||||
/** Max camera polar angle in radians; defaults to unrestricted (π). */
|
||||
maxPolarAngle?: number;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
// Points at the wrapped children so the shadow can measure the real content
|
||||
// bounding box (independent of drei's `Bounds` camera fit).
|
||||
const contentRef = useRef<THREE.Group>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
@@ -100,19 +110,15 @@ export default function Scene({
|
||||
<Lightformer intensity={1} color="#b3c7ff" position={[-5, 2, -1]} scale={[3, 3, 1]} />
|
||||
</Environment>
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<Bounds fit={fit} observe={fit} clip={fit}>{children}</Bounds>
|
||||
</Suspense>
|
||||
{shadow && (
|
||||
<AdaptiveContactShadow shadowScale={shadowScale} shadowBlur={shadowBlur} contentRef={contentRef} />
|
||||
)}
|
||||
|
||||
<ContactShadows
|
||||
position={[0, -0.01, 0]}
|
||||
opacity={0.2}
|
||||
scale={shadowScale}
|
||||
blur={shadowScale * 0.005}
|
||||
far={shadowScale * 0.01}
|
||||
resolution={1024}
|
||||
color="#000000"
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<Bounds fit={fit} observe={fit} clip={fit}>
|
||||
<group ref={contentRef}>{children}</group>
|
||||
</Bounds>
|
||||
</Suspense>
|
||||
<OrbitControls
|
||||
enablePan={enablePan}
|
||||
minDistance={0.01}
|
||||
@@ -152,3 +158,84 @@ function LoadingOverlay() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A contact shadow sized and placed to the scene's actual content bounds.
|
||||
*
|
||||
* A fixed-size shadow plane baked for a 2-unit token is invisible to a whole
|
||||
* card or model ten times larger, so the plane is (re)sized to the bounding
|
||||
* box of whatever is wrapped in the scene, measured each frame from a ref.
|
||||
* Keeping it scene-level (instead of per mesh) keeps it world-horizontal at
|
||||
* the table and costs one shadow render per frame even when there are many
|
||||
* meshes, which per-mesh planes would multiply.
|
||||
*
|
||||
* The plane sits on the very bottom (y minimum) of the content, centered on
|
||||
* its footprint, and spans a bit wider so the shadow edge doesn't fall inside
|
||||
* the object. `far` covers the full content height so tall objects aren't
|
||||
* clipped. Falls back to `shadowScale` while the bounds are still empty.
|
||||
*/
|
||||
function AdaptiveContactShadow({
|
||||
shadowScale,
|
||||
shadowBlur,
|
||||
contentRef,
|
||||
}: {
|
||||
shadowScale: number;
|
||||
shadowBlur: number;
|
||||
contentRef: RefObject<THREE.Group | null>;
|
||||
}) {
|
||||
// Current computed shadow config, kept in a ref so it's stable between frames
|
||||
// and only recomputed when the content bounds actually change.
|
||||
const state = useRef({ scale: 0, posX: 0, posY: 0, posZ: 0, far: 0 });
|
||||
// Scratch box, reused each frame to avoid allocations.
|
||||
const box = useRef(new THREE.Box3()).current;
|
||||
|
||||
useFrame(() => {
|
||||
const content = contentRef.current;
|
||||
if (!content) return;
|
||||
content.updateWorldMatrix(true, true);
|
||||
box.setFromObject(content);
|
||||
if (box.isEmpty()) return;
|
||||
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
// The shadow plane rests on the very bottom of the content, centered on its
|
||||
// footprint. `far` covers the full content height (plus margin) so the
|
||||
// capture doesn't clip tall objects; `scale` covers the footprint.
|
||||
const scale = Math.max(size.x, size.z) * SHADOW_PAD;
|
||||
const posY = box.min.y;
|
||||
const far = size.y + SHADOW_HEIGHT_MARGIN;
|
||||
const s = state.current;
|
||||
if (
|
||||
scale !== s.scale ||
|
||||
center.x !== s.posX ||
|
||||
posY !== s.posY ||
|
||||
center.z !== s.posZ ||
|
||||
far !== s.far
|
||||
) {
|
||||
Object.assign(s, { scale, posX: center.x, posY, posZ: center.z, far });
|
||||
}
|
||||
});
|
||||
|
||||
const s = state.current;
|
||||
const scale = s.scale || shadowScale;
|
||||
// Sit just below the lowest point of the content so it rests on it without
|
||||
// intersecting; the plane uses a (fixed) world-horizontal orientation.
|
||||
const position = [s.posX, s.posY - 0.01, s.posZ] as [number, number, number];
|
||||
|
||||
return (
|
||||
<ContactShadows
|
||||
position={position}
|
||||
opacity={0.2}
|
||||
scale={scale}
|
||||
blur={scale * shadowBlur}
|
||||
far={s.far || scale * 0.01}
|
||||
resolution={1024}
|
||||
color="#000000"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** How far the shadow plane extends past the content footprint, as a multiple. */
|
||||
const SHADOW_PAD = 1.1;
|
||||
/** Extra capture depth beyond the content height, so the falloff edge isn't clipped. */
|
||||
const SHADOW_HEIGHT_MARGIN = 0.5;
|
||||
|
||||
@@ -14,7 +14,10 @@ import type { ViewerProps } from '../viewers';
|
||||
export default function TileViewer({ object, fill }: ViewerProps) {
|
||||
return (
|
||||
<Scene fill={fill}>
|
||||
<TileObjectMesh object={object} />
|
||||
{/* Lay the tile flat on the ground (the mesh is authored standing). */}
|
||||
<group rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<TileObjectMesh object={object} />
|
||||
</group>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,10 @@ import type { ViewerProps } from '../viewers';
|
||||
export default function TokenViewer({ object, fill }: ViewerProps) {
|
||||
return (
|
||||
<Scene fill={fill}>
|
||||
<TokenObjectMesh object={object} />
|
||||
{/* Lay the token flat on the ground (see CardViewer). */}
|
||||
<group rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<TokenObjectMesh object={object} />
|
||||
</group>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user