Compare commits
6
Commits
82a4f9b645
...
498cf1b633
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
498cf1b633 | ||
|
|
a9297d8b2b | ||
|
|
7d9b5e49ad | ||
|
|
3dd3db5643 | ||
|
|
fc9f756d52 | ||
|
|
7ff8e3c51d |
@@ -25,6 +25,7 @@
|
|||||||
"@tts/http": "workspace:*",
|
"@tts/http": "workspace:*",
|
||||||
"@tts/tabletop": "workspace:*",
|
"@tts/tabletop": "workspace:*",
|
||||||
"bson": "^7.3.1",
|
"bson": "^7.3.1",
|
||||||
|
"postprocessing": "^6.36.6",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-router-dom": "^7.18.2",
|
"react-router-dom": "^7.18.2",
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ import {
|
|||||||
const CARD_LENGTH = 2;
|
const CARD_LENGTH = 2;
|
||||||
/** Corner radius as a fraction of the shorter card edge. */
|
/** Corner radius as a fraction of the shorter card edge. */
|
||||||
const CORNER_RADIUS = 0.05;
|
const CORNER_RADIUS = 0.05;
|
||||||
/** Thickness of the card. */
|
/** Thickness of the card, as a fraction of its length (real cards are ~0.3%). */
|
||||||
const CARD_THICKNESS = 0.06;
|
const CARD_THICKNESS = 0.01;
|
||||||
|
|
||||||
// A 1x1 transparent placeholder so `useTexture` always receives a valid URL.
|
// A 1x1 transparent placeholder so `useTexture` always receives a valid URL.
|
||||||
// Without it, the face/back hooks would be called conditionally, which breaks
|
// Without it, the face/back hooks would be called conditionally, which breaks
|
||||||
@@ -78,6 +78,13 @@ export function CardMesh({
|
|||||||
const face = useTexture(faceUrl ? assetUrl(faceUrl) : FALLBACK_URL);
|
const face = useTexture(faceUrl ? assetUrl(faceUrl) : FALLBACK_URL);
|
||||||
const back = useTexture(backUrl ? assetUrl(backUrl) : FALLBACK_URL);
|
const back = useTexture(backUrl ? assetUrl(backUrl) : FALLBACK_URL);
|
||||||
|
|
||||||
|
// Card art is sRGB-encoded. `TextureLoader` leaves `colorSpace` as
|
||||||
|
// `NoColorSpace`, which uploads the texture as linear and then double-decodes
|
||||||
|
// it in the shader, washing out contrast. Mark it sRGB so the GPU decodes it
|
||||||
|
// once, correctly. Clones (e.g. `flipTexture`) inherit this from the source.
|
||||||
|
face.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
back.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
|
||||||
// The face/back textures are shared (drei caches them by URL); each card's
|
// The face/back textures are shared (drei caches them by URL); each card's
|
||||||
// sprite cell is selected via a per-material UV transform injected into the
|
// sprite cell is selected via a per-material UV transform injected into the
|
||||||
// shader, so no per-card texture clone (and no re-upload) is needed. The
|
// shader, so no per-card texture clone (and no re-upload) is needed. The
|
||||||
|
|||||||
@@ -28,7 +28,12 @@ import type { ViewerProps } from '../viewers';
|
|||||||
export default function CardViewer({ object, fill }: ViewerProps) {
|
export default function CardViewer({ object, fill }: ViewerProps) {
|
||||||
return (
|
return (
|
||||||
<Scene fill={fill}>
|
<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>
|
</Scene>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,11 @@ function Tint({ root, tint }: { root: Object3D; tint: THREE.Color }) {
|
|||||||
function DiffuseTexture({ root, url }: { root: Object3D; url: string }) {
|
function DiffuseTexture({ root, url }: { root: Object3D; url: string }) {
|
||||||
const texture = useTexture(assetUrl(url));
|
const texture = useTexture(assetUrl(url));
|
||||||
|
|
||||||
|
// Diffuse art is sRGB-encoded; `TextureLoader` leaves `colorSpace` as
|
||||||
|
// `NoColorSpace`, which double-decodes it in the shader and washes out
|
||||||
|
// contrast. Mark it sRGB so the GPU decodes it once, correctly.
|
||||||
|
texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
|
||||||
// Apply the diffuse texture to every mesh material on the loaded model.
|
// Apply the diffuse texture to every mesh material on the loaded model.
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
root.traverse((child) => {
|
root.traverse((child) => {
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export default function DeckViewer({ object, fill }: ViewerProps) {
|
|||||||
fit={false}
|
fit={false}
|
||||||
autoRotate={false}
|
autoRotate={false}
|
||||||
fill={fill}
|
fill={fill}
|
||||||
|
shadow={false}
|
||||||
overlay={
|
overlay={
|
||||||
hasCards ? (
|
hasCards ? (
|
||||||
<CarouselControls active={active} count={count} onStep={step} />
|
<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>
|
</Scene>
|
||||||
);
|
);
|
||||||
@@ -187,10 +192,69 @@ function CarouselCard({
|
|||||||
return (
|
return (
|
||||||
<group ref={group}>
|
<group ref={group}>
|
||||||
<CardObjectMesh object={card} />
|
<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>
|
</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 {
|
interface Slots {
|
||||||
x: number;
|
x: number;
|
||||||
z: number;
|
z: number;
|
||||||
|
|||||||
@@ -1,8 +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 { Icon } from '@iconify/react';
|
||||||
import { Canvas } from '@react-three/fiber';
|
import { Canvas, useFrame } from '@react-three/fiber';
|
||||||
import { Bounds, ContactShadows, OrbitControls, useProgress } from '@react-three/drei';
|
import { Bounds, ContactShadows, Environment, Lightformer, OrbitControls, useProgress } from '@react-three/drei';
|
||||||
import { EffectComposer, Vignette } from '@react-three/postprocessing';
|
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,
|
* Shared 3D scene wrapper for object viewers. Provides a consistent camera,
|
||||||
@@ -38,6 +40,8 @@ export default function Scene({
|
|||||||
overlay,
|
overlay,
|
||||||
shadowScale = 22,
|
shadowScale = 22,
|
||||||
maxPolarAngle = Math.PI,
|
maxPolarAngle = Math.PI,
|
||||||
|
shadowBlur = 0.012,
|
||||||
|
shadow = true,
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
autoRotate?: boolean;
|
autoRotate?: boolean;
|
||||||
@@ -51,11 +55,18 @@ export default function Scene({
|
|||||||
overlay?: ReactNode;
|
overlay?: ReactNode;
|
||||||
/** Contact shadow plane size in world units; defaults to a generous 22. */
|
/** Contact shadow plane size in world units; defaults to a generous 22. */
|
||||||
shadowScale?: number;
|
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 (π). */
|
/** Max camera polar angle in radians; defaults to unrestricted (π). */
|
||||||
maxPolarAngle?: number;
|
maxPolarAngle?: number;
|
||||||
}) {
|
}) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
|
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||||
@@ -72,7 +83,7 @@ export default function Scene({
|
|||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className={`relative w-full overflow-hidden rounded-lg bg-linear-to-b from-zinc-300 to-zinc-400 ${
|
className={`relative w-full overflow-hidden rounded-lg bg-linear-to-b from-zinc-300 to-zinc-400 ${
|
||||||
fill ? 'h-full min-h-0' : 'aspect-[3/4]'
|
fill ? 'h-full min-h-0' : 'aspect-3/4'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<LoadingOverlay />
|
<LoadingOverlay />
|
||||||
@@ -92,24 +103,26 @@ export default function Scene({
|
|||||||
dpr={[1, 2]}
|
dpr={[1, 2]}
|
||||||
gl={{ antialias: true, alpha: true }}
|
gl={{ antialias: true, alpha: true }}
|
||||||
>
|
>
|
||||||
<ambientLight intensity={0.5} />
|
<ambientLight intensity={0.1} />
|
||||||
<directionalLight position={[4, 6, 3]} intensity={1.4} />
|
|
||||||
<directionalLight position={[-4, 2, -3]} intensity={0.4} color="#b3c7ff" />
|
<Environment resolution={256}>
|
||||||
<pointLight position={[0, 3, 0]} intensity={0.3} />
|
<Lightformer intensity={.5} position={[0, 5, 0]} scale={[10, 10, 1]} />
|
||||||
|
<Lightformer intensity={.5} position={[0, 0, 5]} scale={[10, 10, 1]} />
|
||||||
|
<Lightformer intensity={.5} position={[0, 0, -5]} scale={[10, 10, 1]} />
|
||||||
|
<Lightformer intensity={.5} position={[-5, 0, 0]} scale={[10, 10, 1]} />
|
||||||
|
<Lightformer intensity={.5} position={[5, 0, -0]} scale={[10, 10, 1]} />
|
||||||
|
<Lightformer intensity={0.6} color="#fff1d6" position={[0, -3, 0]} scale={[6, 6, 1]} />
|
||||||
|
</Environment>
|
||||||
|
|
||||||
|
{shadow && (
|
||||||
|
<AdaptiveContactShadow shadowScale={shadowScale} shadowBlur={shadowBlur} contentRef={contentRef} />
|
||||||
|
)}
|
||||||
|
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<Bounds fit={fit} observe={fit} clip={fit}>{children}</Bounds>
|
<Bounds fit={fit} observe={fit} clip={fit}>
|
||||||
|
<group ref={contentRef}>{children}</group>
|
||||||
|
</Bounds>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
<ContactShadows
|
|
||||||
position={[0, -0.01, 0]}
|
|
||||||
opacity={0.2}
|
|
||||||
scale={shadowScale}
|
|
||||||
blur={shadowScale * 0.005}
|
|
||||||
far={shadowScale * 0.01}
|
|
||||||
resolution={1024}
|
|
||||||
color="#000000"
|
|
||||||
/>
|
|
||||||
<OrbitControls
|
<OrbitControls
|
||||||
enablePan={enablePan}
|
enablePan={enablePan}
|
||||||
minDistance={0.01}
|
minDistance={0.01}
|
||||||
@@ -120,6 +133,8 @@ export default function Scene({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<EffectComposer>
|
<EffectComposer>
|
||||||
|
<ToneMapping mode={ToneMappingMode.AGX} />
|
||||||
|
<BrightnessContrast brightness={0.12} contrast={.5} />
|
||||||
<Vignette eskil={false} offset={0.25} darkness={0.6} />
|
<Vignette eskil={false} offset={0.25} darkness={0.6} />
|
||||||
</EffectComposer>
|
</EffectComposer>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
@@ -147,3 +162,84 @@ function LoadingOverlay() {
|
|||||||
</div>
|
</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;
|
||||||
|
|||||||
@@ -69,6 +69,11 @@ export function TileMesh({
|
|||||||
}) {
|
}) {
|
||||||
const texture: THREE.Texture | null = url ? useTexture(assetUrl(url)) : null;
|
const texture: THREE.Texture | null = url ? useTexture(assetUrl(url)) : null;
|
||||||
|
|
||||||
|
// Tile art is sRGB-encoded; `TextureLoader` leaves `colorSpace` as
|
||||||
|
// `NoColorSpace`, which double-decodes it in the shader and washes out
|
||||||
|
// contrast. Mark it sRGB so the GPU decodes it once, correctly.
|
||||||
|
if (texture) texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
|
||||||
// Build the extruded geometry from the tile shape. When `stretch` is false
|
// Build the extruded geometry from the tile shape. When `stretch` is false
|
||||||
// and a texture is available, scale the shape to the image's aspect ratio so
|
// and a texture is available, scale the shape to the image's aspect ratio so
|
||||||
// the tile matches the source proportions instead of being square. Shared
|
// the tile matches the source proportions instead of being square. Shared
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ import type { ViewerProps } from '../viewers';
|
|||||||
export default function TileViewer({ object, fill }: ViewerProps) {
|
export default function TileViewer({ object, fill }: ViewerProps) {
|
||||||
return (
|
return (
|
||||||
<Scene fill={fill}>
|
<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>
|
</Scene>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ export function TokenMesh({
|
|||||||
}) {
|
}) {
|
||||||
const texture: THREE.Texture | null = url ? useTexture(assetUrl(url)) : null;
|
const texture: THREE.Texture | null = url ? useTexture(assetUrl(url)) : null;
|
||||||
|
|
||||||
|
// Token art is sRGB-encoded; `TextureLoader` leaves `colorSpace` as
|
||||||
|
// `NoColorSpace`, which double-decodes it in the shader and washes out
|
||||||
|
// contrast. Mark it sRGB so the GPU decodes it once, correctly.
|
||||||
|
if (texture) texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
|
||||||
// Trace the image's alpha channel into a shape. Suspends until the trace
|
// Trace the image's alpha channel into a shape. Suspends until the trace
|
||||||
// resolves so the surrounding Suspense boundary (and `Bounds`) only mounts
|
// resolves so the surrounding Suspense boundary (and `Bounds`) only mounts
|
||||||
// once the token geometry is present. Falls back to a circle when there's no
|
// once the token geometry is present. Falls back to a circle when there's no
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ import type { ViewerProps } from '../viewers';
|
|||||||
export default function TokenViewer({ object, fill }: ViewerProps) {
|
export default function TokenViewer({ object, fill }: ViewerProps) {
|
||||||
return (
|
return (
|
||||||
<Scene fill={fill}>
|
<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>
|
</Scene>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,13 @@ export function PartMesh({ part, baseUrl }: { part: Part; baseUrl?: string }) {
|
|||||||
const face = useTexture(faceUrl ? assetUrl(faceUrl) : FALLBACK_URL);
|
const face = useTexture(faceUrl ? assetUrl(faceUrl) : FALLBACK_URL);
|
||||||
const back = useTexture(backUrl ? assetUrl(backUrl) : FALLBACK_URL);
|
const back = useTexture(backUrl ? assetUrl(backUrl) : FALLBACK_URL);
|
||||||
|
|
||||||
|
// Part art is sRGB-encoded; `TextureLoader` leaves `colorSpace` as
|
||||||
|
// `NoColorSpace`, which double-decodes it in the shader and washes out
|
||||||
|
// contrast. Mark it sRGB so the GPU decodes it once, correctly. The sprite
|
||||||
|
// clones below inherit this from the source.
|
||||||
|
face.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
back.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
|
||||||
const trace = useTrace(shapeUrl, traceImage);
|
const trace = useTrace(shapeUrl, traceImage);
|
||||||
|
|
||||||
const { frontGeo, backGeo, wallsGeo } = useMemo(() => {
|
const { frontGeo, backGeo, wallsGeo } = useMemo(() => {
|
||||||
|
|||||||
Generated
+3
@@ -102,6 +102,9 @@ importers:
|
|||||||
bson:
|
bson:
|
||||||
specifier: ^7.3.1
|
specifier: ^7.3.1
|
||||||
version: 7.3.1
|
version: 7.3.1
|
||||||
|
postprocessing:
|
||||||
|
specifier: ^6.36.6
|
||||||
|
version: 6.39.4(three@0.185.1)
|
||||||
react:
|
react:
|
||||||
specifier: ^19.2.8
|
specifier: ^19.2.8
|
||||||
version: 19.2.8
|
version: 19.2.8
|
||||||
|
|||||||
Reference in New Issue
Block a user