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.
159 lines
5.5 KiB
TypeScript
159 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,
|
|
hexShape,
|
|
rectShape,
|
|
roundedRectShape,
|
|
scaleShape,
|
|
type ExtrudedGeometry,
|
|
} from '@tts/mesh';
|
|
import { assetUrl } from '@tts/http';
|
|
import { flipTexture } from './flipTexture';
|
|
import {
|
|
getSharedGeometry,
|
|
getSharedMaterial,
|
|
objectTint,
|
|
tintKey,
|
|
tintedColor,
|
|
} from './sharedResources';
|
|
|
|
/** `CustomTile.Type` enum from Tabletop Simulator. */
|
|
const TileType = {
|
|
Box: 0,
|
|
Hex: 1,
|
|
Circle: 2,
|
|
Rounded: 3,
|
|
} as const;
|
|
|
|
const TILE_SIZE = 2;
|
|
|
|
/**
|
|
* The tile mesh for an object, exported so the full-setup view can compose it
|
|
* into a shared scene.
|
|
*/
|
|
export function TileObjectMesh({ object }: { object: TTSObject }) {
|
|
const url = object.CustomImage?.ImageURL ?? object.CustomImage?.ImageSecondaryURL;
|
|
const thickness = object.CustomImage?.CustomTile?.Thickness ?? 0.2;
|
|
const type = object.CustomImage?.CustomTile?.Type ?? TileType.Box;
|
|
const stretch = object.CustomImage?.CustomTile?.Stretch ?? true;
|
|
|
|
return (
|
|
<TileMesh
|
|
url={url}
|
|
thickness={thickness}
|
|
type={type}
|
|
stretch={stretch}
|
|
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 TileMesh({
|
|
url,
|
|
thickness,
|
|
type,
|
|
stretch,
|
|
tint,
|
|
}: {
|
|
url?: string;
|
|
thickness: number;
|
|
type: number;
|
|
stretch: boolean;
|
|
tint: THREE.Color;
|
|
}) {
|
|
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
|
|
// 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
|
|
// across tiles with the same shape so the full-setup view reuses geometry.
|
|
const { front, back, walls } = useMemo(() => {
|
|
const img = texture?.image as HTMLImageElement;
|
|
const aspect = stretch ? img.width / img.height : 1;
|
|
const shape = tileShape(type, aspect);
|
|
const parts = extrudeShapeParts(shape, { height: thickness });
|
|
const key = `tile:${type}:${aspect}:${thickness}`;
|
|
return {
|
|
front: getSharedGeometry(key + ':front', () => toGeometry(parts.front)),
|
|
back: getSharedGeometry(key + ':back', () => toGeometry(parts.back)),
|
|
walls: getSharedGeometry(key + ':walls', () => toGeometry(parts.walls)),
|
|
};
|
|
}, [type, thickness, stretch, texture]);
|
|
|
|
// The back face maps with the same planar UVs as the front, so flip it
|
|
// left/right to avoid a mirrored texture when viewed from behind.
|
|
const backMap = useMemo(() => (texture ? flipTexture(texture) : null), [texture]);
|
|
|
|
// Shared materials: the front/back carry the tile texture (or a neutral
|
|
// color when absent); the walls are a solid white, matching TTS tinting.
|
|
// The tint is baked into the color and the cache key so tinted variants
|
|
// don't collide.
|
|
const tintK = tintKey(tint);
|
|
const faceKey = `tile-face:${url ?? 'none'}:${tintK}`;
|
|
const faceMat = getSharedMaterial(faceKey, {
|
|
color: tintedColor(texture ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint),
|
|
map: texture ?? undefined,
|
|
roughness: 0.8,
|
|
});
|
|
const backMat = getSharedMaterial(faceKey + ':back', {
|
|
color: tintedColor(texture ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint),
|
|
map: backMap ?? undefined,
|
|
roughness: 0.8,
|
|
});
|
|
const wallMat = getSharedMaterial(`tile-wall:${tintK}`, {
|
|
color: tintedColor(new THREE.Color('#ffffff'), tint),
|
|
roughness: 0.8,
|
|
});
|
|
|
|
return (
|
|
<group>
|
|
{/* Front face carries the tile texture. */}
|
|
<mesh geometry={front} material={faceMat} />
|
|
{/* Back face, flipped so it isn't mirrored. */}
|
|
<mesh geometry={back} material={backMat} />
|
|
{/* Sides are a solid white, matching TTS tile tinting. */}
|
|
<mesh geometry={walls} material={wallMat} />
|
|
</group>
|
|
);
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
|
|
/** Build the 2D footprint for a tile type, scaled to a target aspect ratio. */
|
|
function tileShape(type: number, aspect: number) {
|
|
// Base shape is square (1x1); scale x to the aspect ratio so the tile is
|
|
// `aspect` wide and 1 tall (or keep 1x1 when the aspect is 1).
|
|
const sx = aspect;
|
|
const sy = 1;
|
|
switch (type) {
|
|
case TileType.Hex:
|
|
return scaleShape(hexShape(TILE_SIZE / 2), sx, sy);
|
|
case TileType.Circle:
|
|
return scaleShape(circleShape(TILE_SIZE / 2), sx, sy);
|
|
case TileType.Rounded:
|
|
return scaleShape(roundedRectShape(TILE_SIZE, TILE_SIZE, 0.08), sx, sy);
|
|
case TileType.Box:
|
|
default:
|
|
return scaleShape(rectShape(TILE_SIZE, TILE_SIZE), sx, sy);
|
|
}
|
|
} |