feat(paint-slots): KTX2 finish library — fabric/leather/concrete/metal (phase 4)

Wire KTX2 into the catalog finish path and add 15 textured finishes across four
new families, all as GPU-compressed KTX2 (512px) + a webp picker thumbnail.

- Shared `ktx2-loader.ts`: one KTX2Loader (Basis transcoder) used by both the
  GLB loader and catalog textures; `ensureKtx2Support(renderer)` runs once at
  viewer init (GPUDeviceWatcher) so `.ktx2` finishes load even with no GLB in
  the scene. `use-gltf-ktx2` now reuses the shared instance.
- `materials.ts`: texture loaders pick the KTX2 loader for `.ktx2` urls, the
  image loader otherwise (all three load paths).
- `material-library.ts`: 15 entries (fabric ×6, leather ×2, concrete ×4,
  metal ×3). Neutral albedo for tinting, `flipY: false` (compressed textures
  can't flip), `repeat` per real-world tile size, metals `metalness: 1`.
  Normals encoded UASTC, data maps ETC1S/linear.

Assets generated from raw sources via the new community
`scripts/build-material-textures.ts`. 1024/256 tiers + raws kept out of git.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-16 14:37:23 -04:00
co-authored by Claude Opus 4.8
parent 0e8a8c3d14
commit 042f855586
74 changed files with 629 additions and 28 deletions
@@ -13,6 +13,7 @@ import * as THREE from 'three/webgpu'
import { hasDrawableGeometry } from '../../lib/drawable-geometry'
import { PERF_OVERLAY_ENABLED, pushGpuSample } from '../../lib/gpu-perf'
import { applyIsolation, clearIsolation } from '../../lib/isolation'
import { ensureKtx2Support } from '../../lib/ktx2-loader'
import type { ColorPreset, RenderShading } from '../../lib/materials'
import { getSceneTheme } from '../../lib/scene-themes'
import useViewer, { type RenderContext } from '../../store/use-viewer'
@@ -144,6 +145,11 @@ function GPUDeviceWatcher() {
const gl = useThree((s) => s.gl)
useEffect(() => {
// Detect KTX2 transcode support as soon as the renderer exists, so catalog
// `.ktx2` finish textures load even in scenes with no GLB items (whose
// loader would otherwise be the only thing to call this).
ensureKtx2Support(gl)
const backend = (gl as any).backend
const device = backend?.device as WebGPUDeviceLike | undefined
+3 -25
View File
@@ -1,38 +1,16 @@
import { useGLTF } from '@react-three/drei'
import { useThree } from '@react-three/fiber'
import { KTX2Loader } from 'three/examples/jsm/Addons.js'
import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js'
const ktx2LoaderInstance = new KTX2Loader()
ktx2LoaderInstance.setTranscoderPath('https://cdn.jsdelivr.net/gh/pmndrs/drei-assets@master/basis/')
const ktx2ConfiguredRenderers = new WeakSet<object>()
const ktx2WarningLoggedRenderers = new WeakSet<object>()
import { ensureKtx2Support, ktx2Loader } from '../lib/ktx2-loader'
const useGLTFKTX2 = (path: string): ReturnType<typeof useGLTF> => {
const gl = useThree((state) => state.gl)
return useGLTF(path, true, true, (loader) => {
const renderer = gl as unknown as object
if (!ktx2ConfiguredRenderers.has(renderer)) {
try {
ktx2LoaderInstance.detectSupport(gl)
ktx2ConfiguredRenderers.add(renderer)
} catch (error) {
// Some WebGPU flows can transiently call this before backend init.
// Avoid crashing the whole scene; scans may render without KTX2 on this pass.
if (!ktx2WarningLoggedRenderers.has(renderer)) {
console.warn('[viewer] Skipping KTX2 support detection for now.', error)
ktx2WarningLoggedRenderers.add(renderer)
}
}
}
if (ktx2ConfiguredRenderers.has(renderer)) {
if (ensureKtx2Support(gl)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
loader.setKTX2Loader(ktx2LoaderInstance as any)
loader.setKTX2Loader(ktx2Loader as any)
}
loader.setMeshoptDecoder(MeshoptDecoder)
})
}
+40
View File
@@ -0,0 +1,40 @@
import { KTX2Loader } from 'three/examples/jsm/Addons.js'
/**
* Single shared KTX2 loader for the whole viewer — used both by the GLB loader
* (`use-gltf-ktx2`) and by catalog finish textures (`materials.ts`). KTX2 must
* be transcoded at load via the Basis WASM, and `detectSupport(renderer)` has to
* run once before any `.ktx2` is loaded so the loader picks a GPU format the
* device supports. `ensureKtx2Support` is idempotent per renderer and is called
* from the viewer root the moment the renderer is ready (even when no GLB is in
* the scene, so catalog `.ktx2` finishes still load).
*/
export const ktx2Loader = new KTX2Loader()
ktx2Loader.setTranscoderPath('https://cdn.jsdelivr.net/gh/pmndrs/drei-assets@master/basis/')
const configuredRenderers = new WeakSet<object>()
const warnedRenderers = new WeakSet<object>()
/** Returns true once support has been detected for this renderer (KTX2 safe to load). */
export function ensureKtx2Support(renderer: unknown): boolean {
const key = renderer as object | null
if (!key) return false
if (configuredRenderers.has(key)) return true
try {
;(ktx2Loader as unknown as { detectSupport: (r: unknown) => void }).detectSupport(renderer)
configuredRenderers.add(key)
return true
} catch (error) {
// Some WebGPU flows can transiently call this before backend init; don't
// crash the scene — a later call (or the next render) retries.
if (!warnedRenderers.has(key)) {
console.warn('[viewer] Skipping KTX2 support detection for now.', error)
warnedRenderers.add(key)
}
return false
}
}
export function isKtx2Url(url: string): boolean {
return url.toLowerCase().endsWith('.ktx2')
}
+12 -3
View File
@@ -14,6 +14,7 @@ import * as THREE from 'three'
import { MeshLambertNodeMaterial, MeshStandardNodeMaterial } from 'three/webgpu'
import { resolveCdnUrl } from './asset-url'
import { isKtx2Url, ktx2Loader } from './ktx2-loader'
import { getSceneTheme } from './scene-themes'
export type RenderShading = 'solid' | 'rendered'
@@ -105,6 +106,14 @@ const surfaceRoleMaterialCache = new Map<string, THREE.Material>()
const textureCache = new Map<string, THREE.Texture>()
const textureLoadPromises = new Map<string, Promise<THREE.Texture | null>>()
const textureLoader = new THREE.TextureLoader()
// `.ktx2` finish maps transcode through the shared KTX2 loader (support is
// detected once at viewer init); everything else loads as a normal image.
function pickTextureLoader(url: string): THREE.TextureLoader {
// KTX2Loader's load/loadAsync are call-compatible with TextureLoader (url →
// Texture / Promise<Texture>); cast for typing.
return isKtx2Url(url) ? (ktx2Loader as unknown as THREE.TextureLoader) : textureLoader
}
const wrapMap = {
Repeat: THREE.RepeatWrapping,
ClampToEdge: THREE.ClampToEdgeWrapping,
@@ -183,7 +192,7 @@ function getTexture(material?: MaterialSchema): THREE.Texture | undefined {
const cached = textureCache.get(cacheKey)
if (cached) return cached
const texture = textureLoader.load(textureConfig.url)
const texture = pickTextureLoader(textureConfig.url).load(textureConfig.url)
texture.wrapS = THREE.RepeatWrapping
texture.wrapT = THREE.RepeatWrapping
@@ -251,7 +260,7 @@ function getPresetTexture(
const cached = textureCache.get(cacheKey)
if (cached) return cached
const texture = textureLoader.load(resolvedPath)
const texture = pickTextureLoader(resolvedPath).load(resolvedPath)
applyTextureProperties(texture, props, slot)
setTextureCacheKey(texture, cacheKey)
textureCache.set(cacheKey, texture)
@@ -295,7 +304,7 @@ async function loadPresetTexture(
const existingPromise = textureLoadPromises.get(cacheKey)
if (existingPromise) return existingPromise
const promise = textureLoader
const promise = pickTextureLoader(resolvedPath)
.loadAsync(resolvedPath)
.then((texture) => {
applyTextureProperties(texture, props, slot)