From 3dd3db5643d338396be43ddea0df6c79208aaa6a Mon Sep 17 00:00:00 2001 From: hypercross Date: Sat, 15 Aug 2026 12:25:21 +0800 Subject: [PATCH] fix(web): decode textures as sRGB in 3D viewers 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. --- apps/web/src/components/viewers/CardMesh.tsx | 7 +++++++ apps/web/src/components/viewers/CustomModelMesh.tsx | 5 +++++ apps/web/src/components/viewers/TileMesh.tsx | 5 +++++ apps/web/src/components/viewers/TokenMesh.tsx | 5 +++++ packages/tabletop/src/partView.tsx | 7 +++++++ 5 files changed, 29 insertions(+) diff --git a/apps/web/src/components/viewers/CardMesh.tsx b/apps/web/src/components/viewers/CardMesh.tsx index 75f1184..bfb199d 100644 --- a/apps/web/src/components/viewers/CardMesh.tsx +++ b/apps/web/src/components/viewers/CardMesh.tsx @@ -78,6 +78,13 @@ export function CardMesh({ const face = useTexture(faceUrl ? assetUrl(faceUrl) : 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 // 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 diff --git a/apps/web/src/components/viewers/CustomModelMesh.tsx b/apps/web/src/components/viewers/CustomModelMesh.tsx index c9adfec..e409f0e 100644 --- a/apps/web/src/components/viewers/CustomModelMesh.tsx +++ b/apps/web/src/components/viewers/CustomModelMesh.tsx @@ -83,6 +83,11 @@ function Tint({ root, tint }: { root: Object3D; tint: THREE.Color }) { function DiffuseTexture({ root, url }: { root: Object3D; url: string }) { 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. useLayoutEffect(() => { root.traverse((child) => { diff --git a/apps/web/src/components/viewers/TileMesh.tsx b/apps/web/src/components/viewers/TileMesh.tsx index 0e8eec5..d515808 100644 --- a/apps/web/src/components/viewers/TileMesh.tsx +++ b/apps/web/src/components/viewers/TileMesh.tsx @@ -69,6 +69,11 @@ export function TileMesh({ }) { 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 diff --git a/apps/web/src/components/viewers/TokenMesh.tsx b/apps/web/src/components/viewers/TokenMesh.tsx index cec6cc7..4d55d5c 100644 --- a/apps/web/src/components/viewers/TokenMesh.tsx +++ b/apps/web/src/components/viewers/TokenMesh.tsx @@ -48,6 +48,11 @@ export function TokenMesh({ }) { 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 diff --git a/packages/tabletop/src/partView.tsx b/packages/tabletop/src/partView.tsx index b2e34aa..040a1ee 100644 --- a/packages/tabletop/src/partView.tsx +++ b/packages/tabletop/src/partView.tsx @@ -86,6 +86,13 @@ export function PartMesh({ part, baseUrl }: { part: Part; baseUrl?: string }) { const face = useTexture(faceUrl ? assetUrl(faceUrl) : 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 { frontGeo, backGeo, wallsGeo } = useMemo(() => {