TextureLoader leaves colorSpace as NoColorSpace, so card, tile, token, model, and part art is uploaded as linear and then double-decoded in the shader, washing out contrast. Mark each texture sRGB so the GPU decodes it once, correctly.
148 lines
5.5 KiB
TypeScript
148 lines
5.5 KiB
TypeScript
import { useTexture } from '@react-three/drei';
|
|
import { useMemo } from 'react';
|
|
import * as THREE from 'three';
|
|
import type { TTSObject } from '@tts/shared';
|
|
import {
|
|
circleShape,
|
|
extrudeShapeParts,
|
|
traceToShape,
|
|
traceToUvBounds,
|
|
type ExtrudedGeometry,
|
|
} from '@tts/mesh';
|
|
import { traceImage } from '@tts/http';
|
|
import { assetUrl } from '@tts/http';
|
|
import {
|
|
getSharedGeometry,
|
|
getSharedMaterial,
|
|
objectTint,
|
|
tintKey,
|
|
tintedColor,
|
|
} from './sharedResources';
|
|
|
|
const TOKEN_SIZE = 1.8;
|
|
|
|
/** How far (in trace pixels) the token silhouette is inset from the artwork. */
|
|
const TRACE_INSET = 2;
|
|
|
|
/**
|
|
* The token mesh for an object, exported so the full-setup view can compose it
|
|
* into a shared scene.
|
|
*/
|
|
export function TokenObjectMesh({ object }: { object: TTSObject }) {
|
|
const url = object.CustomImage?.ImageURL ?? object.CustomImage?.ImageSecondaryURL;
|
|
const thickness = object.CustomImage?.CustomToken?.Thickness ?? 0.1;
|
|
|
|
return <TokenMesh url={url} thickness={thickness} tint={objectTint(object)} />;
|
|
}
|
|
|
|
// Rendered inside the Canvas so `useTexture` can access the R3F store.
|
|
// Exported so the full-setup view can compose it into a shared scene.
|
|
export function TokenMesh({
|
|
url,
|
|
thickness,
|
|
tint,
|
|
}: {
|
|
url?: string;
|
|
thickness: number;
|
|
tint: THREE.Color;
|
|
}) {
|
|
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
|
|
// 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
|
|
// image or the trace fails.
|
|
const trace = useTrace(url);
|
|
|
|
const { front, back, walls } = useMemo(() => {
|
|
// The traced shape and its UV framing share the same transform, so the
|
|
// full image rectangle maps to the same bounds in mesh coordinates.
|
|
const scale = TOKEN_SIZE / Math.max(trace?.width ?? 0, trace?.height ?? 0);
|
|
const shape = trace ? traceToShape(trace, scale) : circleShape(TOKEN_SIZE / 2);
|
|
const uvBounds = trace ? traceToUvBounds(trace, scale) : undefined;
|
|
const parts = extrudeShapeParts(shape, { height: thickness, uvBounds });
|
|
// Shared across tokens with the same source image (the trace is cached per
|
|
// URL, so the silhouette is deterministic) so the full-setup view reuses
|
|
// geometry.
|
|
const key = `token:${url ?? 'none'}:${thickness}`;
|
|
return {
|
|
front: getSharedGeometry(key + ':front', () => toGeometry(parts.front)),
|
|
back: getSharedGeometry(key + ':back', () => toGeometry(parts.back)),
|
|
walls: getSharedGeometry(key + ':walls', () => toGeometry(parts.walls)),
|
|
};
|
|
}, [trace, thickness, url]);
|
|
|
|
// A token is solid: front, back, and walls all carry the texture (projected
|
|
// UV), unlike tiles/cards where only the faces are textured. The tint is
|
|
// baked into the color and cache key so tinted variants don't collide.
|
|
const material = getSharedMaterial(`token:${url ?? 'none'}:${tintKey(tint)}`, {
|
|
color: tintedColor(texture ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint),
|
|
map: texture ?? undefined,
|
|
roughness: 0.8,
|
|
});
|
|
|
|
return (
|
|
<group>
|
|
<mesh geometry={front} material={material} />
|
|
<mesh geometry={back} material={material} />
|
|
<mesh geometry={walls} material={material} />
|
|
</group>
|
|
);
|
|
}
|
|
|
|
interface TraceData {
|
|
shape: { outline: number[][]; holes?: number[][][] };
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
// Cache traces by URL so Suspense doesn't re-issue the request on every render
|
|
// while the boundary is held open. A URL maps to either a pending promise (while
|
|
// loading) or the resolved value (once loaded).
|
|
const traceCache = new Map<string, TraceData | null | Promise<TraceData | null>>();
|
|
|
|
/**
|
|
* Suspend on the alpha trace for `url`, resolving to the traced shape (or null
|
|
* when there's no URL / the trace fails). Throws the cached promise only while
|
|
* it's pending; once resolved, the value is returned directly so the retry
|
|
* render completes instead of suspending forever.
|
|
*/
|
|
function useTrace(url: string | undefined): TraceData | null {
|
|
if (!url) return null;
|
|
const cached = traceCache.get(url);
|
|
if (cached === undefined) {
|
|
const promise = traceImage(url, 'alpha', -TRACE_INSET).then((result) => {
|
|
const value: TraceData | null = result.shape
|
|
? {
|
|
shape: result.shape,
|
|
width: result.width,
|
|
height: result.height,
|
|
}
|
|
: null;
|
|
// Replace the pending promise with the resolved value so later renders
|
|
// return it instead of re-suspending on a settled promise.
|
|
traceCache.set(url, value);
|
|
return value;
|
|
});
|
|
traceCache.set(url, promise);
|
|
throw promise;
|
|
}
|
|
if (cached instanceof Promise) throw cached;
|
|
return cached;
|
|
}
|
|
|
|
/** Convert raw extruded arrays into a three.js `BufferGeometry`. */
|
|
function toGeometry(extruded: ExtrudedGeometry) {
|
|
const { positions, normals, uvs, indices } = extruded;
|
|
const geo = new THREE.BufferGeometry();
|
|
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
|
geo.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
|
|
geo.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
|
|
geo.setIndex(new THREE.BufferAttribute(indices, 1));
|
|
return geo;
|
|
} |