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).