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>
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 238 B |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 460 B |
|
After Width: | Height: | Size: 190 B |
|
After Width: | Height: | Size: 388 B |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 266 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 978 B |
|
After Width: | Height: | Size: 902 B |
|
After Width: | Height: | Size: 178 B |
@@ -3448,6 +3448,574 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'fabric-linen',
|
||||||
|
label: 'Linen',
|
||||||
|
category: 'fabric',
|
||||||
|
surfaces: ['furniture', 'wall'],
|
||||||
|
description: 'Natural linen weave',
|
||||||
|
previewThumbnailUrl: '/material/fabric/linen_hikari_fabric/linen_hikari_fabric_thumb.webp',
|
||||||
|
preset: {
|
||||||
|
maps: {
|
||||||
|
albedoMap: '/material/fabric/linen_hikari_fabric/linen_hikari_fabric_basecolor_512.ktx2',
|
||||||
|
normalMap: '/material/fabric/linen_hikari_fabric/linen_hikari_fabric_normal_512.ktx2',
|
||||||
|
},
|
||||||
|
mapProperties: {
|
||||||
|
color: '#ffffff',
|
||||||
|
roughness: 0.85,
|
||||||
|
metalness: 0,
|
||||||
|
repeatX: 2,
|
||||||
|
repeatY: 2,
|
||||||
|
rotation: 0,
|
||||||
|
wrapS: 'Repeat',
|
||||||
|
wrapT: 'Repeat',
|
||||||
|
normalScaleX: 1,
|
||||||
|
normalScaleY: 1,
|
||||||
|
emissiveIntensity: 1,
|
||||||
|
displacementScale: 0,
|
||||||
|
transparent: false,
|
||||||
|
flipY: false,
|
||||||
|
bumpScale: 1,
|
||||||
|
emissiveColor: '#000000',
|
||||||
|
aoMapIntensity: 1,
|
||||||
|
side: 0,
|
||||||
|
opacity: 1,
|
||||||
|
lightMapIntensity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fabric-cotton',
|
||||||
|
label: 'Cotton',
|
||||||
|
category: 'fabric',
|
||||||
|
surfaces: ['furniture', 'wall'],
|
||||||
|
description: 'Plain cotton weave',
|
||||||
|
previewThumbnailUrl: '/material/fabric/blue_cotton/blue_cotton_thumb.webp',
|
||||||
|
preset: {
|
||||||
|
maps: {
|
||||||
|
albedoMap: '/material/fabric/blue_cotton/blue_cotton_basecolor_512.ktx2',
|
||||||
|
normalMap: '/material/fabric/blue_cotton/blue_cotton_normal_512.ktx2',
|
||||||
|
roughnessMap: '/material/fabric/blue_cotton/blue_cotton_roughness_512.ktx2',
|
||||||
|
aoMap: '/material/fabric/blue_cotton/blue_cotton_ao_512.ktx2',
|
||||||
|
},
|
||||||
|
mapProperties: {
|
||||||
|
color: '#ffffff',
|
||||||
|
roughness: 0.85,
|
||||||
|
metalness: 0,
|
||||||
|
repeatX: 2,
|
||||||
|
repeatY: 2,
|
||||||
|
rotation: 0,
|
||||||
|
wrapS: 'Repeat',
|
||||||
|
wrapT: 'Repeat',
|
||||||
|
normalScaleX: 1,
|
||||||
|
normalScaleY: 1,
|
||||||
|
emissiveIntensity: 1,
|
||||||
|
displacementScale: 0,
|
||||||
|
transparent: false,
|
||||||
|
flipY: false,
|
||||||
|
bumpScale: 1,
|
||||||
|
emissiveColor: '#000000',
|
||||||
|
aoMapIntensity: 1,
|
||||||
|
side: 0,
|
||||||
|
opacity: 1,
|
||||||
|
lightMapIntensity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fabric-velvet',
|
||||||
|
label: 'Velvet',
|
||||||
|
category: 'fabric',
|
||||||
|
surfaces: ['furniture'],
|
||||||
|
description: 'Velvet with a soft sheen',
|
||||||
|
previewThumbnailUrl: '/material/fabric/red_velvet/red_velvet_thumb.webp',
|
||||||
|
preset: {
|
||||||
|
maps: {
|
||||||
|
albedoMap: '/material/fabric/red_velvet/red_velvet_basecolor_512.ktx2',
|
||||||
|
normalMap: '/material/fabric/red_velvet/red_velvet_normal_512.ktx2',
|
||||||
|
roughnessMap: '/material/fabric/red_velvet/red_velvet_roughness_512.ktx2',
|
||||||
|
aoMap: '/material/fabric/red_velvet/red_velvet_ao_512.ktx2',
|
||||||
|
},
|
||||||
|
mapProperties: {
|
||||||
|
color: '#ffffff',
|
||||||
|
roughness: 0.6,
|
||||||
|
metalness: 0,
|
||||||
|
repeatX: 2.5,
|
||||||
|
repeatY: 2.5,
|
||||||
|
rotation: 0,
|
||||||
|
wrapS: 'Repeat',
|
||||||
|
wrapT: 'Repeat',
|
||||||
|
normalScaleX: 1,
|
||||||
|
normalScaleY: 1,
|
||||||
|
emissiveIntensity: 1,
|
||||||
|
displacementScale: 0,
|
||||||
|
transparent: false,
|
||||||
|
flipY: false,
|
||||||
|
bumpScale: 1,
|
||||||
|
emissiveColor: '#000000',
|
||||||
|
aoMapIntensity: 1,
|
||||||
|
side: 0,
|
||||||
|
opacity: 1,
|
||||||
|
lightMapIntensity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fabric-wool',
|
||||||
|
label: 'Wool',
|
||||||
|
category: 'fabric',
|
||||||
|
surfaces: ['furniture'],
|
||||||
|
description: 'Matte wool felt',
|
||||||
|
previewThumbnailUrl: '/material/fabric/white_wool/white_wool_thumb.webp',
|
||||||
|
preset: {
|
||||||
|
maps: {
|
||||||
|
albedoMap: '/material/fabric/white_wool/white_wool_basecolor_512.ktx2',
|
||||||
|
normalMap: '/material/fabric/white_wool/white_wool_normal_512.ktx2',
|
||||||
|
roughnessMap: '/material/fabric/white_wool/white_wool_roughness_512.ktx2',
|
||||||
|
aoMap: '/material/fabric/white_wool/white_wool_ao_512.ktx2',
|
||||||
|
},
|
||||||
|
mapProperties: {
|
||||||
|
color: '#ffffff',
|
||||||
|
roughness: 0.9,
|
||||||
|
metalness: 0,
|
||||||
|
repeatX: 2.5,
|
||||||
|
repeatY: 2.5,
|
||||||
|
rotation: 0,
|
||||||
|
wrapS: 'Repeat',
|
||||||
|
wrapT: 'Repeat',
|
||||||
|
normalScaleX: 1,
|
||||||
|
normalScaleY: 1,
|
||||||
|
emissiveIntensity: 1,
|
||||||
|
displacementScale: 0,
|
||||||
|
transparent: false,
|
||||||
|
flipY: false,
|
||||||
|
bumpScale: 1,
|
||||||
|
emissiveColor: '#000000',
|
||||||
|
aoMapIntensity: 1,
|
||||||
|
side: 0,
|
||||||
|
opacity: 1,
|
||||||
|
lightMapIntensity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fabric-suede',
|
||||||
|
label: 'Suede',
|
||||||
|
category: 'fabric',
|
||||||
|
surfaces: ['furniture'],
|
||||||
|
description: 'Matte suede nap',
|
||||||
|
previewThumbnailUrl: '/material/fabric/suede/suede_thumb.webp',
|
||||||
|
preset: {
|
||||||
|
maps: {
|
||||||
|
albedoMap: '/material/fabric/suede/suede_basecolor_512.ktx2',
|
||||||
|
normalMap: '/material/fabric/suede/suede_normal_512.ktx2',
|
||||||
|
roughnessMap: '/material/fabric/suede/suede_roughness_512.ktx2',
|
||||||
|
aoMap: '/material/fabric/suede/suede_ao_512.ktx2',
|
||||||
|
},
|
||||||
|
mapProperties: {
|
||||||
|
color: '#ffffff',
|
||||||
|
roughness: 0.9,
|
||||||
|
metalness: 0,
|
||||||
|
repeatX: 2,
|
||||||
|
repeatY: 2,
|
||||||
|
rotation: 0,
|
||||||
|
wrapS: 'Repeat',
|
||||||
|
wrapT: 'Repeat',
|
||||||
|
normalScaleX: 1,
|
||||||
|
normalScaleY: 1,
|
||||||
|
emissiveIntensity: 1,
|
||||||
|
displacementScale: 0,
|
||||||
|
transparent: false,
|
||||||
|
flipY: false,
|
||||||
|
bumpScale: 1,
|
||||||
|
emissiveColor: '#000000',
|
||||||
|
aoMapIntensity: 1,
|
||||||
|
side: 0,
|
||||||
|
opacity: 1,
|
||||||
|
lightMapIntensity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fabric-boucle',
|
||||||
|
label: 'Bouclé',
|
||||||
|
category: 'fabric',
|
||||||
|
surfaces: ['furniture'],
|
||||||
|
description: 'Looped bouclé upholstery',
|
||||||
|
previewThumbnailUrl: '/material/fabric/wool_boucle/wool_boucle_thumb.webp',
|
||||||
|
preset: {
|
||||||
|
maps: {
|
||||||
|
albedoMap: '/material/fabric/wool_boucle/wool_boucle_basecolor_512.ktx2',
|
||||||
|
normalMap: '/material/fabric/wool_boucle/wool_boucle_normal_512.ktx2',
|
||||||
|
},
|
||||||
|
mapProperties: {
|
||||||
|
color: '#ffffff',
|
||||||
|
roughness: 0.9,
|
||||||
|
metalness: 0,
|
||||||
|
repeatX: 3.3,
|
||||||
|
repeatY: 3.3,
|
||||||
|
rotation: 0,
|
||||||
|
wrapS: 'Repeat',
|
||||||
|
wrapT: 'Repeat',
|
||||||
|
normalScaleX: 1,
|
||||||
|
normalScaleY: 1,
|
||||||
|
emissiveIntensity: 1,
|
||||||
|
displacementScale: 0,
|
||||||
|
transparent: false,
|
||||||
|
flipY: false,
|
||||||
|
bumpScale: 1,
|
||||||
|
emissiveColor: '#000000',
|
||||||
|
aoMapIntensity: 1,
|
||||||
|
side: 0,
|
||||||
|
opacity: 1,
|
||||||
|
lightMapIntensity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'leather-black',
|
||||||
|
label: 'Black Leather',
|
||||||
|
category: 'leather',
|
||||||
|
surfaces: ['furniture'],
|
||||||
|
description: 'Smooth black leather',
|
||||||
|
previewThumbnailUrl: '/material/leather/black_leather/black_leather_thumb.webp',
|
||||||
|
preset: {
|
||||||
|
maps: {
|
||||||
|
albedoMap: '/material/leather/black_leather/black_leather_basecolor_512.ktx2',
|
||||||
|
normalMap: '/material/leather/black_leather/black_leather_normal_512.ktx2',
|
||||||
|
roughnessMap: '/material/leather/black_leather/black_leather_roughness_512.ktx2',
|
||||||
|
},
|
||||||
|
mapProperties: {
|
||||||
|
color: '#ffffff',
|
||||||
|
roughness: 0.5,
|
||||||
|
metalness: 0,
|
||||||
|
repeatX: 1.67,
|
||||||
|
repeatY: 1.67,
|
||||||
|
rotation: 0,
|
||||||
|
wrapS: 'Repeat',
|
||||||
|
wrapT: 'Repeat',
|
||||||
|
normalScaleX: 1,
|
||||||
|
normalScaleY: 1,
|
||||||
|
emissiveIntensity: 1,
|
||||||
|
displacementScale: 0,
|
||||||
|
transparent: false,
|
||||||
|
flipY: false,
|
||||||
|
bumpScale: 1,
|
||||||
|
emissiveColor: '#000000',
|
||||||
|
aoMapIntensity: 1,
|
||||||
|
side: 0,
|
||||||
|
opacity: 1,
|
||||||
|
lightMapIntensity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'leather-calf',
|
||||||
|
label: 'Calf Leather',
|
||||||
|
category: 'leather',
|
||||||
|
surfaces: ['furniture'],
|
||||||
|
description: 'Pebbled calf leather',
|
||||||
|
previewThumbnailUrl: '/material/leather/calf_leather/calf_leather_thumb.webp',
|
||||||
|
preset: {
|
||||||
|
maps: {
|
||||||
|
albedoMap: '/material/leather/calf_leather/calf_leather_basecolor_512.ktx2',
|
||||||
|
normalMap: '/material/leather/calf_leather/calf_leather_normal_512.ktx2',
|
||||||
|
roughnessMap: '/material/leather/calf_leather/calf_leather_roughness_512.ktx2',
|
||||||
|
aoMap: '/material/leather/calf_leather/calf_leather_ao_512.ktx2',
|
||||||
|
},
|
||||||
|
mapProperties: {
|
||||||
|
color: '#ffffff',
|
||||||
|
roughness: 0.5,
|
||||||
|
metalness: 0,
|
||||||
|
repeatX: 2,
|
||||||
|
repeatY: 2,
|
||||||
|
rotation: 0,
|
||||||
|
wrapS: 'Repeat',
|
||||||
|
wrapT: 'Repeat',
|
||||||
|
normalScaleX: 1,
|
||||||
|
normalScaleY: 1,
|
||||||
|
emissiveIntensity: 1,
|
||||||
|
displacementScale: 0,
|
||||||
|
transparent: false,
|
||||||
|
flipY: false,
|
||||||
|
bumpScale: 1,
|
||||||
|
emissiveColor: '#000000',
|
||||||
|
aoMapIntensity: 1,
|
||||||
|
side: 0,
|
||||||
|
opacity: 1,
|
||||||
|
lightMapIntensity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'concrete-plaster',
|
||||||
|
label: 'Painted Plaster',
|
||||||
|
category: 'concrete',
|
||||||
|
surfaces: ['wall', 'ceiling'],
|
||||||
|
description: 'Smooth painted plaster wall',
|
||||||
|
previewThumbnailUrl: '/material/concrete/plaster_painted/plaster_painted_thumb.webp',
|
||||||
|
preset: {
|
||||||
|
maps: {
|
||||||
|
albedoMap: '/material/concrete/plaster_painted/plaster_painted_basecolor_512.ktx2',
|
||||||
|
normalMap: '/material/concrete/plaster_painted/plaster_painted_normal_512.ktx2',
|
||||||
|
roughnessMap: '/material/concrete/plaster_painted/plaster_painted_roughness_512.ktx2',
|
||||||
|
aoMap: '/material/concrete/plaster_painted/plaster_painted_ao_512.ktx2',
|
||||||
|
},
|
||||||
|
mapProperties: {
|
||||||
|
color: '#ffffff',
|
||||||
|
roughness: 0.85,
|
||||||
|
metalness: 0,
|
||||||
|
repeatX: 0.67,
|
||||||
|
repeatY: 0.67,
|
||||||
|
rotation: 0,
|
||||||
|
wrapS: 'Repeat',
|
||||||
|
wrapT: 'Repeat',
|
||||||
|
normalScaleX: 1,
|
||||||
|
normalScaleY: 1,
|
||||||
|
emissiveIntensity: 1,
|
||||||
|
displacementScale: 0,
|
||||||
|
transparent: false,
|
||||||
|
flipY: false,
|
||||||
|
bumpScale: 1,
|
||||||
|
emissiveColor: '#000000',
|
||||||
|
aoMapIntensity: 1,
|
||||||
|
side: 0,
|
||||||
|
opacity: 1,
|
||||||
|
lightMapIntensity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'concrete-polished',
|
||||||
|
label: 'Polished Concrete',
|
||||||
|
category: 'concrete',
|
||||||
|
surfaces: ['floor', 'wall'],
|
||||||
|
description: 'Polished concrete floor',
|
||||||
|
previewThumbnailUrl: '/material/concrete/concrete_polished/concrete_polished_thumb.webp',
|
||||||
|
preset: {
|
||||||
|
maps: {
|
||||||
|
albedoMap: '/material/concrete/concrete_polished/concrete_polished_basecolor_512.ktx2',
|
||||||
|
normalMap: '/material/concrete/concrete_polished/concrete_polished_normal_512.ktx2',
|
||||||
|
roughnessMap: '/material/concrete/concrete_polished/concrete_polished_roughness_512.ktx2',
|
||||||
|
},
|
||||||
|
mapProperties: {
|
||||||
|
color: '#ffffff',
|
||||||
|
roughness: 0.5,
|
||||||
|
metalness: 0,
|
||||||
|
repeatX: 0.5,
|
||||||
|
repeatY: 0.5,
|
||||||
|
rotation: 0,
|
||||||
|
wrapS: 'Repeat',
|
||||||
|
wrapT: 'Repeat',
|
||||||
|
normalScaleX: 1,
|
||||||
|
normalScaleY: 1,
|
||||||
|
emissiveIntensity: 1,
|
||||||
|
displacementScale: 0,
|
||||||
|
transparent: false,
|
||||||
|
flipY: false,
|
||||||
|
bumpScale: 1,
|
||||||
|
emissiveColor: '#000000',
|
||||||
|
aoMapIntensity: 1,
|
||||||
|
side: 0,
|
||||||
|
opacity: 1,
|
||||||
|
lightMapIntensity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'concrete-raw',
|
||||||
|
label: 'Raw Concrete',
|
||||||
|
category: 'concrete',
|
||||||
|
surfaces: ['wall', 'floor', 'outdoor'],
|
||||||
|
description: 'Board-formed raw concrete',
|
||||||
|
previewThumbnailUrl: '/material/concrete/concrete_raw/concrete_raw_thumb.webp',
|
||||||
|
preset: {
|
||||||
|
maps: {
|
||||||
|
albedoMap: '/material/concrete/concrete_raw/concrete_raw_basecolor_512.ktx2',
|
||||||
|
normalMap: '/material/concrete/concrete_raw/concrete_raw_normal_512.ktx2',
|
||||||
|
roughnessMap: '/material/concrete/concrete_raw/concrete_raw_roughness_512.ktx2',
|
||||||
|
aoMap: '/material/concrete/concrete_raw/concrete_raw_ao_512.ktx2',
|
||||||
|
},
|
||||||
|
mapProperties: {
|
||||||
|
color: '#ffffff',
|
||||||
|
roughness: 0.8,
|
||||||
|
metalness: 0,
|
||||||
|
repeatX: 0.5,
|
||||||
|
repeatY: 0.5,
|
||||||
|
rotation: 0,
|
||||||
|
wrapS: 'Repeat',
|
||||||
|
wrapT: 'Repeat',
|
||||||
|
normalScaleX: 1,
|
||||||
|
normalScaleY: 1,
|
||||||
|
emissiveIntensity: 1,
|
||||||
|
displacementScale: 0,
|
||||||
|
transparent: false,
|
||||||
|
flipY: false,
|
||||||
|
bumpScale: 1,
|
||||||
|
emissiveColor: '#000000',
|
||||||
|
aoMapIntensity: 1,
|
||||||
|
side: 0,
|
||||||
|
opacity: 1,
|
||||||
|
lightMapIntensity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'concrete-stucco',
|
||||||
|
label: 'White Stucco',
|
||||||
|
category: 'concrete',
|
||||||
|
surfaces: ['wall', 'outdoor'],
|
||||||
|
description: 'Exterior stucco render',
|
||||||
|
previewThumbnailUrl: '/material/concrete/white_stucco/white_stucco_thumb.webp',
|
||||||
|
preset: {
|
||||||
|
maps: {
|
||||||
|
albedoMap: '/material/concrete/white_stucco/white_stucco_basecolor_512.ktx2',
|
||||||
|
normalMap: '/material/concrete/white_stucco/white_stucco_normal_512.ktx2',
|
||||||
|
roughnessMap: '/material/concrete/white_stucco/white_stucco_roughness_512.ktx2',
|
||||||
|
aoMap: '/material/concrete/white_stucco/white_stucco_ao_512.ktx2',
|
||||||
|
},
|
||||||
|
mapProperties: {
|
||||||
|
color: '#ffffff',
|
||||||
|
roughness: 0.9,
|
||||||
|
metalness: 0,
|
||||||
|
repeatX: 1,
|
||||||
|
repeatY: 1,
|
||||||
|
rotation: 0,
|
||||||
|
wrapS: 'Repeat',
|
||||||
|
wrapT: 'Repeat',
|
||||||
|
normalScaleX: 1,
|
||||||
|
normalScaleY: 1,
|
||||||
|
emissiveIntensity: 1,
|
||||||
|
displacementScale: 0,
|
||||||
|
transparent: false,
|
||||||
|
flipY: false,
|
||||||
|
bumpScale: 1,
|
||||||
|
emissiveColor: '#000000',
|
||||||
|
aoMapIntensity: 1,
|
||||||
|
side: 0,
|
||||||
|
opacity: 1,
|
||||||
|
lightMapIntensity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'metal-copper',
|
||||||
|
label: 'Copper',
|
||||||
|
category: 'metal',
|
||||||
|
surfaces: ['furniture', 'wall'],
|
||||||
|
description: 'Warm copper',
|
||||||
|
previewThumbnailUrl: '/material/metal/copper_metal/copper_metal_thumb.webp',
|
||||||
|
preset: {
|
||||||
|
maps: {
|
||||||
|
albedoMap: '/material/metal/copper_metal/copper_metal_basecolor_512.ktx2',
|
||||||
|
normalMap: '/material/metal/copper_metal/copper_metal_normal_512.ktx2',
|
||||||
|
roughnessMap: '/material/metal/copper_metal/copper_metal_roughness_512.ktx2',
|
||||||
|
metalnessMap: '/material/metal/copper_metal/copper_metal_metallic_512.ktx2',
|
||||||
|
},
|
||||||
|
mapProperties: {
|
||||||
|
color: '#ffffff',
|
||||||
|
roughness: 0.4,
|
||||||
|
metalness: 1,
|
||||||
|
repeatX: 1,
|
||||||
|
repeatY: 1,
|
||||||
|
rotation: 0,
|
||||||
|
wrapS: 'Repeat',
|
||||||
|
wrapT: 'Repeat',
|
||||||
|
normalScaleX: 1,
|
||||||
|
normalScaleY: 1,
|
||||||
|
emissiveIntensity: 1,
|
||||||
|
displacementScale: 0,
|
||||||
|
transparent: false,
|
||||||
|
flipY: false,
|
||||||
|
bumpScale: 1,
|
||||||
|
emissiveColor: '#000000',
|
||||||
|
aoMapIntensity: 1,
|
||||||
|
side: 0,
|
||||||
|
opacity: 1,
|
||||||
|
lightMapIntensity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'metal-polished',
|
||||||
|
label: 'Polished Metal',
|
||||||
|
category: 'metal',
|
||||||
|
surfaces: ['furniture', 'wall'],
|
||||||
|
description: 'Polished stainless steel',
|
||||||
|
previewThumbnailUrl: '/material/metal/polished_metal/polished_metal_thumb.webp',
|
||||||
|
preset: {
|
||||||
|
maps: {
|
||||||
|
albedoMap: '/material/metal/polished_metal/polished_metal_basecolor_512.ktx2',
|
||||||
|
normalMap: '/material/metal/polished_metal/polished_metal_normal_512.ktx2',
|
||||||
|
roughnessMap: '/material/metal/polished_metal/polished_metal_roughness_512.ktx2',
|
||||||
|
aoMap: '/material/metal/polished_metal/polished_metal_ao_512.ktx2',
|
||||||
|
metalnessMap: '/material/metal/polished_metal/polished_metal_metallic_512.ktx2',
|
||||||
|
},
|
||||||
|
mapProperties: {
|
||||||
|
color: '#ffffff',
|
||||||
|
roughness: 0.3,
|
||||||
|
metalness: 1,
|
||||||
|
repeatX: 1,
|
||||||
|
repeatY: 1,
|
||||||
|
rotation: 0,
|
||||||
|
wrapS: 'Repeat',
|
||||||
|
wrapT: 'Repeat',
|
||||||
|
normalScaleX: 1,
|
||||||
|
normalScaleY: 1,
|
||||||
|
emissiveIntensity: 1,
|
||||||
|
displacementScale: 0,
|
||||||
|
transparent: false,
|
||||||
|
flipY: false,
|
||||||
|
bumpScale: 1,
|
||||||
|
emissiveColor: '#000000',
|
||||||
|
aoMapIntensity: 1,
|
||||||
|
side: 0,
|
||||||
|
opacity: 1,
|
||||||
|
lightMapIntensity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'metal-steel',
|
||||||
|
label: 'Brushed Steel',
|
||||||
|
category: 'metal',
|
||||||
|
surfaces: ['furniture', 'wall'],
|
||||||
|
description: 'Brushed stainless steel',
|
||||||
|
previewThumbnailUrl:
|
||||||
|
'/material/metal/stainless_steel_brushed/stainless_steel_brushed_thumb.webp',
|
||||||
|
preset: {
|
||||||
|
maps: {
|
||||||
|
albedoMap:
|
||||||
|
'/material/metal/stainless_steel_brushed/stainless_steel_brushed_basecolor_512.ktx2',
|
||||||
|
normalMap:
|
||||||
|
'/material/metal/stainless_steel_brushed/stainless_steel_brushed_normal_512.ktx2',
|
||||||
|
roughnessMap:
|
||||||
|
'/material/metal/stainless_steel_brushed/stainless_steel_brushed_roughness_512.ktx2',
|
||||||
|
},
|
||||||
|
mapProperties: {
|
||||||
|
color: '#ffffff',
|
||||||
|
roughness: 0.45,
|
||||||
|
metalness: 1,
|
||||||
|
repeatX: 1,
|
||||||
|
repeatY: 1,
|
||||||
|
rotation: 0,
|
||||||
|
wrapS: 'Repeat',
|
||||||
|
wrapT: 'Repeat',
|
||||||
|
normalScaleX: 1,
|
||||||
|
normalScaleY: 1,
|
||||||
|
emissiveIntensity: 1,
|
||||||
|
displacementScale: 0,
|
||||||
|
transparent: false,
|
||||||
|
flipY: false,
|
||||||
|
bumpScale: 1,
|
||||||
|
emissiveColor: '#000000',
|
||||||
|
aoMapIntensity: 1,
|
||||||
|
side: 0,
|
||||||
|
opacity: 1,
|
||||||
|
lightMapIntensity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function getMaterialsForCategory(category: MaterialCategory): MaterialCatalogItem[] {
|
export function getMaterialsForCategory(category: MaterialCategory): MaterialCatalogItem[] {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import * as THREE from 'three/webgpu'
|
|||||||
import { hasDrawableGeometry } from '../../lib/drawable-geometry'
|
import { hasDrawableGeometry } from '../../lib/drawable-geometry'
|
||||||
import { PERF_OVERLAY_ENABLED, pushGpuSample } from '../../lib/gpu-perf'
|
import { PERF_OVERLAY_ENABLED, pushGpuSample } from '../../lib/gpu-perf'
|
||||||
import { applyIsolation, clearIsolation } from '../../lib/isolation'
|
import { applyIsolation, clearIsolation } from '../../lib/isolation'
|
||||||
|
import { ensureKtx2Support } from '../../lib/ktx2-loader'
|
||||||
import type { ColorPreset, RenderShading } from '../../lib/materials'
|
import type { ColorPreset, RenderShading } from '../../lib/materials'
|
||||||
import { getSceneTheme } from '../../lib/scene-themes'
|
import { getSceneTheme } from '../../lib/scene-themes'
|
||||||
import useViewer, { type RenderContext } from '../../store/use-viewer'
|
import useViewer, { type RenderContext } from '../../store/use-viewer'
|
||||||
@@ -144,6 +145,11 @@ function GPUDeviceWatcher() {
|
|||||||
const gl = useThree((s) => s.gl)
|
const gl = useThree((s) => s.gl)
|
||||||
|
|
||||||
useEffect(() => {
|
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 backend = (gl as any).backend
|
||||||
const device = backend?.device as WebGPUDeviceLike | undefined
|
const device = backend?.device as WebGPUDeviceLike | undefined
|
||||||
|
|
||||||
|
|||||||
@@ -1,38 +1,16 @@
|
|||||||
import { useGLTF } from '@react-three/drei'
|
import { useGLTF } from '@react-three/drei'
|
||||||
import { useThree } from '@react-three/fiber'
|
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'
|
import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js'
|
||||||
|
import { ensureKtx2Support, ktx2Loader } from '../lib/ktx2-loader'
|
||||||
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>()
|
|
||||||
|
|
||||||
const useGLTFKTX2 = (path: string): ReturnType<typeof useGLTF> => {
|
const useGLTFKTX2 = (path: string): ReturnType<typeof useGLTF> => {
|
||||||
const gl = useThree((state) => state.gl)
|
const gl = useThree((state) => state.gl)
|
||||||
|
|
||||||
return useGLTF(path, true, true, (loader) => {
|
return useGLTF(path, true, true, (loader) => {
|
||||||
const renderer = gl as unknown as object
|
if (ensureKtx2Support(gl)) {
|
||||||
|
|
||||||
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)) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
loader.setKTX2Loader(ktx2LoaderInstance as any)
|
loader.setKTX2Loader(ktx2Loader as any)
|
||||||
}
|
}
|
||||||
|
|
||||||
loader.setMeshoptDecoder(MeshoptDecoder)
|
loader.setMeshoptDecoder(MeshoptDecoder)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import * as THREE from 'three'
|
|||||||
import { MeshLambertNodeMaterial, MeshStandardNodeMaterial } from 'three/webgpu'
|
import { MeshLambertNodeMaterial, MeshStandardNodeMaterial } from 'three/webgpu'
|
||||||
|
|
||||||
import { resolveCdnUrl } from './asset-url'
|
import { resolveCdnUrl } from './asset-url'
|
||||||
|
import { isKtx2Url, ktx2Loader } from './ktx2-loader'
|
||||||
import { getSceneTheme } from './scene-themes'
|
import { getSceneTheme } from './scene-themes'
|
||||||
|
|
||||||
export type RenderShading = 'solid' | 'rendered'
|
export type RenderShading = 'solid' | 'rendered'
|
||||||
@@ -105,6 +106,14 @@ const surfaceRoleMaterialCache = new Map<string, THREE.Material>()
|
|||||||
const textureCache = new Map<string, THREE.Texture>()
|
const textureCache = new Map<string, THREE.Texture>()
|
||||||
const textureLoadPromises = new Map<string, Promise<THREE.Texture | null>>()
|
const textureLoadPromises = new Map<string, Promise<THREE.Texture | null>>()
|
||||||
const textureLoader = new THREE.TextureLoader()
|
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 = {
|
const wrapMap = {
|
||||||
Repeat: THREE.RepeatWrapping,
|
Repeat: THREE.RepeatWrapping,
|
||||||
ClampToEdge: THREE.ClampToEdgeWrapping,
|
ClampToEdge: THREE.ClampToEdgeWrapping,
|
||||||
@@ -183,7 +192,7 @@ function getTexture(material?: MaterialSchema): THREE.Texture | undefined {
|
|||||||
const cached = textureCache.get(cacheKey)
|
const cached = textureCache.get(cacheKey)
|
||||||
if (cached) return cached
|
if (cached) return cached
|
||||||
|
|
||||||
const texture = textureLoader.load(textureConfig.url)
|
const texture = pickTextureLoader(textureConfig.url).load(textureConfig.url)
|
||||||
texture.wrapS = THREE.RepeatWrapping
|
texture.wrapS = THREE.RepeatWrapping
|
||||||
texture.wrapT = THREE.RepeatWrapping
|
texture.wrapT = THREE.RepeatWrapping
|
||||||
|
|
||||||
@@ -251,7 +260,7 @@ function getPresetTexture(
|
|||||||
const cached = textureCache.get(cacheKey)
|
const cached = textureCache.get(cacheKey)
|
||||||
if (cached) return cached
|
if (cached) return cached
|
||||||
|
|
||||||
const texture = textureLoader.load(resolvedPath)
|
const texture = pickTextureLoader(resolvedPath).load(resolvedPath)
|
||||||
applyTextureProperties(texture, props, slot)
|
applyTextureProperties(texture, props, slot)
|
||||||
setTextureCacheKey(texture, cacheKey)
|
setTextureCacheKey(texture, cacheKey)
|
||||||
textureCache.set(cacheKey, texture)
|
textureCache.set(cacheKey, texture)
|
||||||
@@ -295,7 +304,7 @@ async function loadPresetTexture(
|
|||||||
const existingPromise = textureLoadPromises.get(cacheKey)
|
const existingPromise = textureLoadPromises.get(cacheKey)
|
||||||
if (existingPromise) return existingPromise
|
if (existingPromise) return existingPromise
|
||||||
|
|
||||||
const promise = textureLoader
|
const promise = pickTextureLoader(resolvedPath)
|
||||||
.loadAsync(resolvedPath)
|
.loadAsync(resolvedPath)
|
||||||
.then((texture) => {
|
.then((texture) => {
|
||||||
applyTextureProperties(texture, props, slot)
|
applyTextureProperties(texture, props, slot)
|
||||||
|
|||||||