diff --git a/apps/web/src/components/viewers/CardMesh.tsx b/apps/web/src/components/viewers/CardMesh.tsx
index 1d40b21..ef00228 100644
--- a/apps/web/src/components/viewers/CardMesh.tsx
+++ b/apps/web/src/components/viewers/CardMesh.tsx
@@ -9,8 +9,14 @@ import {
} from '@tts/mesh';
import { assetUrl } from '@tts/http';
import { cardAspect, resolveCardConfig, spriteUv } from './cardResolution';
-import { flipTexture } from './flipTexture';
-import { getSharedGeometry, objectTint, tintedColor } from './sharedResources';
+import { applyMapTransform } from './cardMaterial';
+import {
+ getSharedGeometry,
+ getSharedMaterial,
+ objectTint,
+ tintKey,
+ tintedColor,
+} from './sharedResources';
/** Longer card dimension, in world units. */
const CARD_LENGTH = 2;
@@ -72,37 +78,61 @@ export function CardMesh({
const face = useTexture(faceUrl ? assetUrl(faceUrl) : FALLBACK_URL);
const back = useTexture(backUrl ? assetUrl(backUrl) : FALLBACK_URL);
- // Front texture: the sprite cell from the sheet (or the full image when there
- // is no grid). Cloned so the sprite offset/repeat don't leak into other cards
- // that share the same sheet URL (drei caches textures globally by URL).
- const faceMap = useMemo(() => {
- if (!faceUrl) return null;
- const tex = face.clone();
- const { repeatX, repeatY, offsetX, offsetY } = spriteUv(cardId, numWidth, numHeight);
- tex.repeat.set(repeatX, repeatY);
- tex.offset.set(offsetX, offsetY);
- return tex;
- }, [faceUrl, face, cardId, numWidth, numHeight]);
+ // 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
+ // shader, so no per-card texture clone (and no re-upload) is needed. The
+ // transform is baked into the material's shader, so it must be keyed into the
+ // shared-material cache to avoid mutating a material used by another card.
+ const faceMap = faceUrl ? face : null;
+ const backMap = backUrl ? back : null;
- // Back texture: a single full image (tile) unless the deck has unique backs,
- // in which case it's a sheet too. Flipped left/right so it reads correctly
- // instead of being mirrored on the back face.
- const backMap = useMemo(() => {
- if (!backUrl) return null;
- const tex = back.clone();
- const { repeatX, repeatY, offsetX, offsetY } = uniqueBack
+ const tintK = tintKey(tint);
+ const faceUv = faceUrl ? spriteUv(cardId, numWidth, numHeight) : null;
+ const backUv = backUrl
+ ? uniqueBack
? spriteUv(cardId, numWidth, numHeight)
- : { repeatX: 1, repeatY: 1, offsetX: 0, offsetY: 0 };
- tex.repeat.set(repeatX, repeatY);
- tex.offset.set(offsetX, offsetY);
- return flipTexture(tex);
- }, [backUrl, back, uniqueBack, cardId, numWidth, numHeight]);
+ : { repeatX: 1, repeatY: 1, offsetX: 0, offsetY: 0 }
+ : null;
+
+ // Key by URL + card id + tint: the URL disambiguates different sheets (and
+ // `CardCustom` objects, which have no `CardID`), the card id selects the
+ // sprite cell, and the tint bakes the per-object color in.
+ const faceMat = getSharedMaterial(`card-face:${faceUrl ?? 'none'}:${cardId ?? 'none'}:${tintK}`, {
+ color: tintedColor(faceMap ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint),
+ map: faceMap ?? undefined,
+ roughness: 0.6,
+ });
+ if (faceUv) {
+ applyMapTransform(faceMat, new THREE.Vector2(faceUv.repeatX, faceUv.repeatY), new THREE.Vector2(faceUv.offsetX, faceUv.offsetY));
+ }
+
+ const backMat = getSharedMaterial(`card-back:${backUrl ?? 'none'}:${cardId ?? 'none'}:${tintK}`, {
+ color: tintedColor(backMap ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint),
+ map: backMap ?? undefined,
+ roughness: 0.6,
+ });
+ if (backUv) {
+ // The back cap maps with the same planar UVs as the front, so mirror the
+ // sprite cell left/right to read correctly instead of appearing mirrored.
+ // Negating repeat.x and shifting offset.x by one repeat keeps the visible
+ // region in place while mirrored (see `flipTexture`).
+ applyMapTransform(
+ backMat,
+ new THREE.Vector2(-backUv.repeatX, backUv.repeatY),
+ new THREE.Vector2(backUv.offsetX + backUv.repeatX, backUv.offsetY),
+ );
+ }
+
+ const wallMat = getSharedMaterial(`card-wall:${tintK}`, {
+ color: tintedColor(new THREE.Color('#ffffff'), tint),
+ roughness: 0.6,
+ });
// Build the rounded-rect geometry from the card sprite's aspect ratio. The
// front and back faces each get their own material; the walls are a solid
// white, matching TTS card tinting. Geometry is shared across cards of the
- // same size so the full-setup view reuses it; the face/back materials stay
- // per-card because each card clones its texture for sprite UVs.
+ // same size so the full-setup view reuses it; the face/back materials are
+ // shared per card (keyed by card id + tint) and carry the sprite UV transform.
const { frontGeo, backGeo, wallsGeo } = useMemo(() => {
const img = (faceUrl ? face.image : backUrl ? back.image : undefined) as
| HTMLImageElement
@@ -124,23 +154,9 @@ export function CardMesh({
return (
-
-
-
-
-
-
-
-
-
+
+
+
);
}
diff --git a/apps/web/src/components/viewers/cardMaterial.test.ts b/apps/web/src/components/viewers/cardMaterial.test.ts
new file mode 100644
index 0000000..be71c51
--- /dev/null
+++ b/apps/web/src/components/viewers/cardMaterial.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from 'vitest';
+import * as THREE from 'three';
+import { applyMapTransform } from './cardMaterial';
+
+describe('applyMapTransform', () => {
+ it('injects the repeat/offset uniforms and UV transform into the shader', () => {
+ const mat = new THREE.MeshStandardMaterial();
+ applyMapTransform(mat, new THREE.Vector2(0.5, 0.25), new THREE.Vector2(0.1, 0.2));
+
+ expect(mat.onBeforeCompile).toBeTypeOf('function');
+
+ const shader = {
+ uniforms: {} as Record,
+ vertexShader: '#include \nvoid main() {}',
+ };
+ mat.onBeforeCompile!(shader as never, {} as never);
+
+ // Uniforms are copied, so the caller's vectors stay reusable.
+ expect(shader.uniforms.uMapRepeat!.value).toEqual(new THREE.Vector2(0.5, 0.25));
+ expect(shader.uniforms.uMapOffset!.value).toEqual(new THREE.Vector2(0.1, 0.2));
+ // The override is injected right after the chunk include, which stays in
+ // place (it declares `vMapUv`/`uv`).
+ expect(shader.vertexShader).toContain('#include \n\tvMapUv = uv * uMapRepeat + uMapOffset;');
+ });
+
+ it('copies the vectors so later mutation of the inputs has no effect', () => {
+ const mat = new THREE.MeshStandardMaterial();
+ const repeat = new THREE.Vector2(1, 1);
+ const offset = new THREE.Vector2(0, 0);
+ applyMapTransform(mat, repeat, offset);
+
+ repeat.set(9, 9);
+ offset.set(9, 9);
+
+ const shader = { uniforms: {} as Record, vertexShader: '' };
+ mat.onBeforeCompile!(shader as never, {} as never);
+ expect(shader.uniforms.uMapRepeat!.value).toEqual(new THREE.Vector2(1, 1));
+ expect(shader.uniforms.uMapOffset!.value).toEqual(new THREE.Vector2(0, 0));
+ });
+});
\ No newline at end of file
diff --git a/apps/web/src/components/viewers/cardMaterial.ts b/apps/web/src/components/viewers/cardMaterial.ts
new file mode 100644
index 0000000..d2af5fd
--- /dev/null
+++ b/apps/web/src/components/viewers/cardMaterial.ts
@@ -0,0 +1,44 @@
+import * as THREE from 'three';
+
+/**
+ * Per-card UV transform injected into a `MeshStandardMaterial` shader.
+ *
+ * Cards share one texture (the deck's sprite sheet, cached by drei) and one
+ * geometry, but each card samples a different sprite cell. Rather than cloning
+ * the texture per card (which re-uploads the sheet on every GPU bind), the
+ * repeat/offset is pushed into the material as a uniform. The shader source is
+ * identical across cards, so three.js still compiles a single shared program.
+ *
+ * We inject our own uniform instead of setting `texture.repeat`/`offset`
+ * because three r185 derives the map UVs from a `mapTransform` matrix that is
+ * refreshed from `map.matrix` every frame, overwriting any per-material
+ * transform we set on the shared texture.
+ */
+const VERTEX_INJECT = /* glsl */ `
+ #include
+ vMapUv = uv * uMapRepeat + uMapOffset;
+`;
+
+/**
+ * Apply a repeat/offset to a card material's map sampling. Call once per
+ * material (the transform is baked into the shader). `repeat`/`offset` are
+ * copied, so the caller may reuse the vectors.
+ */
+export function applyMapTransform(
+ material: THREE.MeshStandardMaterial,
+ repeat: THREE.Vector2,
+ offset: THREE.Vector2,
+): void {
+ // Clone eagerly so later mutation of the caller's vectors can't leak into
+ // the uniform once the material is compiled.
+ const r = repeat.clone();
+ const o = offset.clone();
+ material.onBeforeCompile = (shader) => {
+ shader.uniforms.uMapRepeat = { value: r };
+ shader.uniforms.uMapOffset = { value: o };
+ shader.vertexShader = shader.vertexShader.replace(
+ '#include ',
+ VERTEX_INJECT,
+ );
+ };
+}
\ No newline at end of file
diff --git a/docs/decisions.md b/docs/decisions.md
index c3f74c4..0722373 100644
--- a/docs/decisions.md
+++ b/docs/decisions.md
@@ -299,4 +299,28 @@ flips.
**Alternatives considered:** Reporting only a hit and silently dropping
misses. Rejected — a command that needs to react to a wrong tap has no way to
-do so. World-space trigger points. Rejected — they break when the part moves.
\ No newline at end of file
+do so. World-space trigger points. Rejected — they break when the part moves.
+
+## D21 — Card sprite UVs live in the material shader, not the texture
+
+**Decision:** A card's sprite cell is selected by a repeat/offset injected into
+the material's shader (`cardMaterial.ts` extends `MeshStandardMaterial` via
+`onBeforeCompile`) rather than by cloning the texture and setting its
+`repeat`/`offset`.
+
+**Context:** Cards in a deck share one sprite sheet (drei caches the texture by
+URL), but each card samples a different cell. The previous approach cloned the
+texture per card to set its UVs; each clone gets its own WebGL texture binding,
+so navigating a deck re-uploaded the whole sheet on every step. Moving the
+transform into a per-material uniform lets cards share the texture (one GPU
+upload), the shader (identical injected source → one program), and the geometry,
+with only the material uniforms differing.
+
+We inject our own uniform rather than setting `texture.repeat`/`offset` because
+three r185 derives map UVs from a `mapTransform` matrix refreshed from
+`map.matrix` every frame, which would overwrite a per-material transform set on
+the shared texture.
+
+**Alternatives considered:** Cloning the texture per card (previous approach).
+Rejected — re-uploads the sheet per card. A module-level cache of per-card
+clones. Rejected — still one upload per unique card instead of one per sheet.
\ No newline at end of file
diff --git a/docs/full-setup-view.md b/docs/full-setup-view.md
index 73b979a..f00e437 100644
--- a/docs/full-setup-view.md
+++ b/docs/full-setup-view.md
@@ -81,9 +81,10 @@ view and the full-setup view.
`textureUrl + color + roughness`. drei already caches textures by URL
globally, so sharing the material on top avoids per-object material
allocation for tiles/tokens with the same image.
-- **Cards are the exception:** each card clones its texture for sprite UVs, so
- its face material cannot be shared — but its geometry still can (same card
- size).
+- **Cards:** the face/back textures are shared (drei caches them by URL) and
+ the sprite cell is selected via a per-material UV transform injected into the
+ shader (`cardMaterial.ts`), so cards share texture, shader, and geometry —
+ only the material uniforms differ. Materials are cached per card id + tint.
- Dispose shared resources on page unmount, or accept a module-level cache for
the session (see Open decisions).