feat(export): texture references — stamp sources at load, reference mode in GLB export (#532)

Bake v2 exporter side: library/preset, legacy scene-material, and item-GLB
textures get a validated pascalTextureRef stamped at load (origin-checked,
env-derived). Material-ish sources resolve to 'library-material' (storage
bucket) or 'app-material' (static catalog on the assets CDN) by URL shape.
exportSceneToGlb grows a textures: 'embed' | 'reference' option — reference
mode swaps stamped textures for 1x1 canvas-backed placeholders (skipping the
WebGPU blit) and writes the ref to both texture and image extras via a
GLTFExporter writeTexture plugin. Download GLB stays embed; the bake page
passes reference.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-07-22 08:33:38 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent cb6fadbc28
commit 0fba611a05
8 changed files with 657 additions and 12 deletions
+77 -2
View File
@@ -29,6 +29,7 @@ import {
type RenderShading,
resolveCdnUrl,
resolveMaterialRef,
stampPascalTextureRef,
useItemLightPool,
useNodeEvents,
useViewer,
@@ -38,7 +39,7 @@ import { Clone } from '@react-three/drei/core/Clone'
import { useFrame, useLoader, useThree } from '@react-three/fiber'
import { Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { AnimationAction, Group, Material, Mesh, Object3D } from 'three'
import { MathUtils } from 'three'
import { MathUtils, Texture } from 'three'
import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js'
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js'
@@ -225,11 +226,85 @@ type LoadedItemGltf = GLTF & {
nodes: Record<string, Object3D>
}
const ITEM_TEXTURE_SLOTS = [
'map',
'normalMap',
'roughnessMap',
'metalnessMap',
'emissiveMap',
'aoMap',
'alphaMap',
'lightMap',
'bumpMap',
'displacementMap',
'clearcoatMap',
'clearcoatNormalMap',
'clearcoatRoughnessMap',
'iridescenceMap',
'iridescenceThicknessMap',
'transmissionMap',
'thicknessMap',
'specularIntensityMap',
'specularColorMap',
'sheenRoughnessMap',
'sheenColorMap',
'anisotropyMap',
] as const
function getItemTextureImageIndex(gltf: LoadedItemGltf, texture: Texture): number | null {
const association = gltf.parser?.associations.get(texture)
const textureIndex = association?.textures
if (!Number.isInteger(textureIndex)) return null
const textureDef = gltf.parser.json.textures?.[textureIndex as number]
const imageIndex =
textureDef?.extensions?.KHR_texture_basisu?.source ??
textureDef?.extensions?.EXT_texture_webp?.source ??
textureDef?.extensions?.EXT_texture_avif?.source ??
textureDef?.source
return Number.isInteger(imageIndex) && imageIndex >= 0 ? imageIndex : null
}
function stampItemTextureReferences(gltf: LoadedItemGltf, src: string) {
if (!gltf.parser?.associations) return
const stamped = new Set<Texture>()
gltf.scene.traverse((object) => {
const mesh = object as Mesh
if (!mesh.isMesh) return
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
for (const material of materials) {
const textureMaterial = material as Material & Record<string, unknown>
for (const slot of ITEM_TEXTURE_SLOTS) {
const texture = textureMaterial[slot]
if (!(texture instanceof Texture)) continue
if (stamped.has(texture)) continue
const imageIndex = getItemTextureImageIndex(gltf, texture)
if (imageIndex === null) continue
if (
stampPascalTextureRef(texture, {
kind: 'item-glb',
src,
slot,
imageIndex,
})
) {
stamped.add(texture)
}
}
}
})
}
const useItemGltf = (url: string): LoadedItemGltf => {
const renderer = useThree((state) => state.gl)
return useLoader(ItemGLTFLoader, url, (loader) =>
const gltf = useLoader(ItemGLTFLoader, url, (loader) =>
configureItemModelLoader(loader, renderer),
) as LoadedItemGltf
return useMemo(() => {
stampItemTextureReferences(gltf, url)
return gltf
}, [gltf, url])
}
type DeferredUnavailableCleanup = {