import * as THREE from 'three' import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js' import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js' import { createSolidSkinLayerGeometry } from './solid-skin-layer' export interface SkinRendererConfig { textureColorSpace?: THREE.ColorSpace textureFlipY?: boolean textureMagFilter?: THREE.MagnificationTextureFilter textureMinFilter?: THREE.MinificationTextureFilter } const ENABLE_VOXEL_LAYER_GEOMETRY = true const MODEL_PIXEL_SIZE = 1 / 16 const NON_LEG_VERTICAL_OFFSET = -MODEL_PIXEL_SIZE / 2 /** Aligns the torso, arms, head, and cape with the stationary legs. */ function offsetNonLegModelParts(model: THREE.Object3D): void { if (model.userData.nonLegPartsOffsetApplied) return const nonLegRoots = new Set(['Head', 'Right_Arm', 'Left_Arm', 'Body_2', 'Body_Layer', 'Cape']) model.traverse((node) => { if (nonLegRoots.has(node.name)) node.position.y += NON_LEG_VERTICAL_OFFSET }) model.userData.nonLegPartsOffsetApplied = true } /** * Rebuilds the outer layer as solid, textured voxels. This follows 3D Skin * Layers' SolidPixelWrapper: every non-transparent outer-layer pixel becomes * a real cube with six faces instead of a zero-thickness quad. */ function getSkinLayerDefinition(name: string): { width: number height: number depth: number u: number v: number } | null { if (name === 'Hat_Layer') return { width: 8, height: 8, depth: 8, u: 32, v: 0 } if (name === 'Body_Layer') return { width: 8, height: 12, depth: 4, u: 16, v: 32 } if (name === 'Right_Leg_Layer') return { width: 4, height: 12, depth: 4, u: 0, v: 32 } if (name === 'Left_Leg_Layer') return { width: 4, height: 12, depth: 4, u: 0, v: 48 } if (name === 'Right_Arm_Layer') return { width: 4, height: 12, depth: 4, u: 40, v: 32 } if (name === 'Left_Arm_Layer') return { width: 4, height: 12, depth: 4, u: 48, v: 48 } return null } function readSkinPixels(texture: THREE.Texture): Uint8ClampedArray | null { const image = texture.image as CanvasImageSource | undefined if (!image) return null try { const canvas = document.createElement('canvas') canvas.width = canvas.height = 64 const context = canvas.getContext('2d') if (!context) return null context.drawImage(image, 0, 0, 64, 64) return context.getImageData(0, 0, 64, 64).data } catch { // Cross-origin images may not be readable. The regular GLTF layer remains // as a graceful fallback in that case. return null } } function scaleLayerGeometry(geometry: THREE.BufferGeometry, name: string): void { geometry.computeBoundingBox() const bounds = geometry.boundingBox if (!bounds) return const isHead = name === 'Hat_Layer' const isBody = name === 'Body_Layer' // The authored GLTF outer shells already include most of the mod's offset. // Apply only the remaining per-pixel correction; multiplying by the full // config values would make the head pixels noticeably oversized. const scaleX = isHead ? 1.05 : isBody ? 1 : 1.02 const scaleY = isHead ? 1.05 : 1 const scaleZ = isHead ? 1.05 : 1.02 const center = bounds.getCenter(new THREE.Vector3()) const position = geometry.getAttribute('position') const vertex = new THREE.Vector3() for (let i = 0; i < position.count; i++) { vertex.fromBufferAttribute(position, i) vertex.sub(center) vertex.set(vertex.x * scaleX, vertex.y * scaleY, vertex.z * scaleZ) vertex.add(center) position.setXYZ(i, vertex.x, vertex.y, vertex.z) } position.needsUpdate = true geometry.computeBoundingBox() geometry.computeBoundingSphere() } export function applyThreeDSkinLayers(model: THREE.Object3D, texture?: THREE.Texture): void { offsetNonLegModelParts(model) const pixels = texture ? readSkinPixels(texture) : null if (!pixels || !texture) return model.traverse((child) => { const mesh = child as THREE.Mesh if ( !mesh.isMesh || !mesh.name.endsWith('_Layer') || !mesh.geometry || mesh.userData.threeDSkinLayersApplied ) return // GLTF clones share BufferGeometry objects. Clone before changing vertex data // so one preview (or cached model) cannot affect another. mesh.geometry = mesh.geometry.clone() const definition = getSkinLayerDefinition(mesh.name) if (ENABLE_VOXEL_LAYER_GEOMETRY && pixels && definition) { const meshBounds = new THREE.Box3().setFromBufferAttribute( mesh.geometry.getAttribute('position') as THREE.BufferAttribute, ) const isSlimArm = mesh.name.includes('Arm') && meshBounds.getSize(new THREE.Vector3()).x < 0.25 const voxelDefinition = isSlimArm ? { ...definition, width: 3 } : definition const voxelGeometry = createSolidSkinLayerGeometry(mesh, texture!, pixels, voxelDefinition) if (voxelGeometry) { scaleLayerGeometry(voxelGeometry, mesh.name) const voxelMaterials = Array.isArray(mesh.material) ? mesh.material : [mesh.material] voxelMaterials.forEach((material) => { if (!(material instanceof THREE.MeshStandardMaterial)) return // The mod renders these as alpha-tested cutouts. Keeping depth writes // deterministic avoids transparent-surface sorting cracks between voxels. material.transparent = false material.alphaTest = 0.1 material.depthWrite = true material.alphaToCoverage = true material.polygonOffset = false material.polygonOffsetFactor = 0 material.polygonOffsetUnits = 0 material.needsUpdate = true }) mesh.geometry.dispose() mesh.geometry = voxelGeometry mesh.userData.threeDSkinLayersApplied = true return } } const geometry = mesh.geometry geometry.computeBoundingBox() const bounds = geometry.boundingBox if (!bounds) return scaleLayerGeometry(geometry, mesh.name) mesh.userData.threeDSkinLayersApplied = true }) } const modelCache: Map = new Map() const modelPromiseCache: Map> = new Map() const textureCache: Map = new Map() const texturePromiseCache: Map> = new Map() export async function loadModel(modelUrl: string): Promise { if (modelCache.has(modelUrl)) { return modelCache.get(modelUrl)! } if (modelPromiseCache.has(modelUrl)) { return modelPromiseCache.get(modelUrl)! } const loader = new GLTFLoader() const promise = new Promise((resolve, reject) => { loader.load( modelUrl, (gltf) => { modelCache.set(modelUrl, gltf) resolve(gltf) }, undefined, reject, ) }).finally(() => { modelPromiseCache.delete(modelUrl) }) modelPromiseCache.set(modelUrl, promise) return promise } export async function loadTexture( textureUrl: string, config: SkinRendererConfig = {}, ): Promise { const cacheKey = `${textureUrl}_${JSON.stringify(config)}` if (textureCache.has(cacheKey)) { return textureCache.get(cacheKey)! } if (texturePromiseCache.has(cacheKey)) { return texturePromiseCache.get(cacheKey)! } const textureLoader = new THREE.TextureLoader() const promise = new Promise((resolve, reject) => { textureLoader.load( textureUrl, (texture) => { texture.colorSpace = config.textureColorSpace ?? THREE.SRGBColorSpace texture.flipY = config.textureFlipY ?? false texture.magFilter = config.textureMagFilter ?? THREE.NearestFilter texture.minFilter = config.textureMinFilter ?? THREE.NearestFilter textureCache.set(cacheKey, texture) resolve(texture) }, undefined, reject, ) }).finally(() => { texturePromiseCache.delete(cacheKey) }) texturePromiseCache.set(cacheKey, promise) return promise } function applyMap(mat: THREE.MeshStandardMaterial, texture: THREE.Texture | null): boolean { const hadMap = mat.map !== null const hasMap = texture !== null if (mat.map !== texture) { mat.map = texture } return hadMap !== hasMap } function setShaderMaterialProperties( mat: THREE.MeshStandardMaterial, properties: { alphaTest: number flatShading: boolean side: THREE.Side toneMapped: boolean transparent?: boolean }, ): boolean { let needsUpdate = false if (mat.alphaTest !== properties.alphaTest) { mat.alphaTest = properties.alphaTest needsUpdate = true } if (mat.flatShading !== properties.flatShading) { mat.flatShading = properties.flatShading needsUpdate = true } if (mat.side !== properties.side) { mat.side = properties.side needsUpdate = true } if (mat.toneMapped !== properties.toneMapped) { mat.toneMapped = properties.toneMapped needsUpdate = true } if (properties.transparent !== undefined && mat.transparent !== properties.transparent) { mat.transparent = properties.transparent needsUpdate = true } return needsUpdate } function setCommonMaterialProperties(mat: THREE.MeshStandardMaterial): void { if (mat.metalness !== 0) { mat.metalness = 0 } if (mat.color.getHex() !== 0xffffff) { mat.color.set(0xffffff) } if (mat.roughness !== 1) { mat.roughness = 1 } if (!mat.depthTest) { mat.depthTest = true } if (!mat.depthWrite) { mat.depthWrite = true } } export function applyTexture(model: THREE.Object3D, texture: THREE.Texture): void { model.traverse((child) => { if ((child as THREE.Mesh).isMesh) { const mesh = child as THREE.Mesh const isSkinLayer = mesh.name.endsWith('_Layer') const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material] materials.forEach((mat: THREE.Material) => { if (mat instanceof THREE.MeshStandardMaterial) { if (mat.name !== 'cape') { const mapNeedsUpdate = applyMap(mat, texture) const propertiesNeedUpdate = setShaderMaterialProperties(mat, { alphaTest: 0.1, flatShading: true, side: THREE.FrontSide, toneMapped: false, transparent: isSkinLayer, }) if (mat.alphaToCoverage !== isSkinLayer) { mat.alphaToCoverage = isSkinLayer mat.needsUpdate = true } setCommonMaterialProperties(mat) if (mapNeedsUpdate || propertiesNeedUpdate) { mat.needsUpdate = true } } } }) } }) } export function applyCapeTexture( model: THREE.Object3D, texture: THREE.Texture | null, transparentTexture?: THREE.Texture, ): void { model.traverse((child) => { if ((child as THREE.Mesh).isMesh) { const mesh = child as THREE.Mesh const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material] materials.forEach((mat: THREE.Material) => { if (mat instanceof THREE.MeshStandardMaterial) { if (mat.name === 'cape') { const nextMap = texture || transparentTexture || null const mapNeedsUpdate = applyMap(mat, nextMap) const propertiesNeedUpdate = setShaderMaterialProperties(mat, { alphaTest: 0.1, flatShading: true, side: THREE.DoubleSide, toneMapped: false, transparent: !texture || !!transparentTexture, }) setCommonMaterialProperties(mat) if (mapNeedsUpdate || propertiesNeedUpdate) { mat.needsUpdate = true } mat.visible = !!texture } } }) } }) } export function findBodyNode(model: THREE.Object3D): THREE.Object3D | null { let bodyNode: THREE.Object3D | null = null model.traverse((node) => { if (node.name === 'Body') { bodyNode = node } }) return bodyNode } export function createTransparentTexture(): THREE.Texture { const canvas = document.createElement('canvas') canvas.width = canvas.height = 1 const ctx = canvas.getContext('2d') as CanvasRenderingContext2D ctx.clearRect(0, 0, 1, 1) const texture = new THREE.CanvasTexture(canvas) texture.needsUpdate = true texture.colorSpace = THREE.SRGBColorSpace texture.flipY = false texture.magFilter = THREE.NearestFilter texture.minFilter = THREE.NearestFilter return texture } export async function setupSkinModel( modelUrl: string, textureUrl: string, capeTextureUrl?: string, config: SkinRendererConfig = {}, ): Promise<{ model: THREE.Object3D bodyNode: THREE.Object3D | null }> { const [gltf, texture] = await Promise.all([loadModel(modelUrl), loadTexture(textureUrl, config)]) const model = gltf.scene.clone() applyTexture(model, texture) applyThreeDSkinLayers(model, texture) if (capeTextureUrl) { const capeTexture = await loadTexture(capeTextureUrl, config) applyCapeTexture(model, capeTexture) } const bodyNode = findBodyNode(model) return { model, bodyNode } } export function disposeCaches(): void { Array.from(textureCache.values()).forEach((texture) => { texture.dispose() }) textureCache.clear() texturePromiseCache.clear() modelCache.clear() modelPromiseCache.clear() }