polish: deduplicate model+cape stuff and fix layout
This commit is contained in:
committed by
Alejandro González
parent
187e892c18
commit
fc929bfca3
@@ -1,9 +1,11 @@
|
||||
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 type { Skin, Cape } from '../skins'
|
||||
import { determineModelType } from '../skins'
|
||||
import { reactive } from 'vue'
|
||||
import {
|
||||
setupSkinModel,
|
||||
disposeCaches
|
||||
} from '@modrinth/utils'
|
||||
|
||||
export interface RenderResult {
|
||||
forwards: string
|
||||
@@ -14,12 +16,7 @@ class BatchSkinRenderer {
|
||||
private renderer: THREE.WebGLRenderer
|
||||
private readonly scene: THREE.Scene
|
||||
private readonly camera: THREE.PerspectiveCamera
|
||||
private modelCache: Map<string, GLTF> = new Map()
|
||||
private textureCache: Map<string, THREE.Texture> = new Map()
|
||||
private currentModel: THREE.Group | null = null
|
||||
private capeModel: THREE.Object3D | null = null
|
||||
private bodyNode: THREE.Object3D | null = null
|
||||
private capeAttached: boolean = false
|
||||
|
||||
constructor(width: number = 360, height: number = 504) {
|
||||
const canvas = document.createElement('canvas')
|
||||
@@ -45,102 +42,6 @@ class BatchSkinRenderer {
|
||||
this.scene.add(ambientLight)
|
||||
}
|
||||
|
||||
private async loadModel(modelUrl: string): Promise<GLTF> {
|
||||
if (this.modelCache.has(modelUrl)) {
|
||||
return this.modelCache.get(modelUrl)!
|
||||
}
|
||||
|
||||
const loader = new GLTFLoader()
|
||||
return new Promise<GLTF>((resolve, reject) => {
|
||||
loader.load(
|
||||
modelUrl,
|
||||
(gltf) => {
|
||||
this.modelCache.set(modelUrl, gltf)
|
||||
resolve(gltf)
|
||||
},
|
||||
undefined,
|
||||
reject,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private async loadTexture(textureUrl: string): Promise<THREE.Texture> {
|
||||
if (this.textureCache.has(textureUrl)) {
|
||||
return this.textureCache.get(textureUrl)!
|
||||
}
|
||||
|
||||
return new Promise<THREE.Texture>((resolve) => {
|
||||
const textureLoader = new THREE.TextureLoader()
|
||||
textureLoader.load(textureUrl, (texture) => {
|
||||
texture.colorSpace = THREE.SRGBColorSpace
|
||||
texture.flipY = false
|
||||
texture.magFilter = THREE.NearestFilter
|
||||
texture.minFilter = THREE.NearestFilter
|
||||
|
||||
this.textureCache.set(textureUrl, texture)
|
||||
resolve(texture)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private applyTexture(model: THREE.Object3D, texture: THREE.Texture): void {
|
||||
model.traverse((child) => {
|
||||
if ((child as THREE.Mesh).isMesh) {
|
||||
const mesh = child as THREE.Mesh
|
||||
|
||||
if (mesh.name === 'Cape') return
|
||||
|
||||
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
||||
|
||||
materials.forEach((mat: THREE.Material) => {
|
||||
if (mat instanceof THREE.MeshStandardMaterial) {
|
||||
mat.map = texture
|
||||
mat.metalness = 0
|
||||
mat.color.set(0xffffff)
|
||||
mat.toneMapped = false
|
||||
mat.roughness = 1
|
||||
mat.needsUpdate = true
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private applyCapeTexture(model: THREE.Object3D, texture: 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) {
|
||||
mat.map = texture
|
||||
mat.metalness = 0
|
||||
mat.color.set(0xffffff)
|
||||
mat.toneMapped = false
|
||||
mat.roughness = 1
|
||||
mat.side = THREE.DoubleSide
|
||||
mat.needsUpdate = true
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private attachCapeToBody(): void {
|
||||
if (!this.bodyNode || !this.capeModel || this.capeAttached) return
|
||||
|
||||
if (this.capeModel.parent) {
|
||||
this.capeModel.parent.remove(this.capeModel)
|
||||
}
|
||||
|
||||
this.capeModel.position.set(0, -1, -0.01)
|
||||
this.capeModel.rotation.set(0, -Math.PI / 2, 0)
|
||||
|
||||
this.bodyNode.add(this.capeModel)
|
||||
this.capeAttached = true
|
||||
}
|
||||
|
||||
public async renderSkin(
|
||||
textureUrl: string,
|
||||
modelUrl: string,
|
||||
@@ -200,22 +101,13 @@ class BatchSkinRenderer {
|
||||
this.scene.remove(this.currentModel)
|
||||
}
|
||||
|
||||
this.bodyNode = null
|
||||
this.capeAttached = false
|
||||
|
||||
const [gltf, texture] = await Promise.all([
|
||||
this.loadModel(modelUrl),
|
||||
this.loadTexture(textureUrl),
|
||||
])
|
||||
|
||||
const model = gltf.scene.clone()
|
||||
this.applyTexture(model, texture)
|
||||
|
||||
model.traverse((node) => {
|
||||
if (node.name === 'Body') {
|
||||
this.bodyNode = node
|
||||
}
|
||||
})
|
||||
// Use the utility function to setup the skin model
|
||||
const { model } = await setupSkinModel(
|
||||
modelUrl,
|
||||
textureUrl,
|
||||
capeModelUrl,
|
||||
capeUrl
|
||||
)
|
||||
|
||||
const group = new THREE.Group()
|
||||
group.add(model)
|
||||
@@ -224,30 +116,11 @@ class BatchSkinRenderer {
|
||||
|
||||
this.scene.add(group)
|
||||
this.currentModel = group
|
||||
|
||||
if (capeModelUrl && capeUrl) {
|
||||
const [capeGltf, capeTexture] = await Promise.all([
|
||||
this.loadModel(capeModelUrl),
|
||||
this.loadTexture(capeUrl),
|
||||
])
|
||||
|
||||
this.capeModel = capeGltf.scene.clone()
|
||||
this.applyCapeTexture(this.capeModel, capeTexture)
|
||||
|
||||
if (this.bodyNode && this.capeModel) {
|
||||
this.attachCapeToBody()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
Array.from(this.textureCache.values()).forEach((texture) => {
|
||||
texture.dispose()
|
||||
})
|
||||
|
||||
this.renderer.dispose()
|
||||
this.textureCache.clear()
|
||||
this.modelCache.clear()
|
||||
disposeCaches()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -418,6 +418,7 @@ $skin-card-gap: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: calc((2.5rem / 2));
|
||||
|
||||
@media (max-width: 700px) {
|
||||
height: 50vh;
|
||||
@@ -430,7 +431,7 @@ $skin-card-gap: 4px;
|
||||
|
||||
.skin-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: $skin-card-gap;
|
||||
width: 100%;
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
</div>
|
||||
|
||||
<TresCanvas
|
||||
v-if="isReady"
|
||||
shadows
|
||||
alpha
|
||||
:antialias="antialias"
|
||||
@@ -67,8 +68,9 @@
|
||||
</Group>
|
||||
</Suspense>
|
||||
|
||||
<!-- eslint-disable-next-line vue/attribute-hyphenation -->
|
||||
<TresPerspectiveCamera
|
||||
:make-default="true"
|
||||
:makeDefault="true"
|
||||
:fov="fov"
|
||||
:position="[0, 1.5, -3.25]"
|
||||
:look-at="target"
|
||||
@@ -76,6 +78,10 @@
|
||||
|
||||
<TresAmbientLight :intensity="2" />
|
||||
</TresCanvas>
|
||||
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<div class="text-primary">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -84,6 +90,14 @@ import * as THREE from 'three'
|
||||
import { useGLTF } from '@tresjs/cientos'
|
||||
import { useTexture, TresCanvas } from '@tresjs/core'
|
||||
import { shallowRef, ref, computed, watch, markRaw, onBeforeMount } from 'vue'
|
||||
import {
|
||||
applyTexture,
|
||||
applyCapeTexture,
|
||||
attachCapeToBody,
|
||||
findBodyNode,
|
||||
createTransparentTexture,
|
||||
loadTexture as loadSkinTexture
|
||||
} from '@modrinth/utils'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -123,44 +137,29 @@ const texture = shallowRef<THREE.Texture | null>(null)
|
||||
const capeTexture = shallowRef<THREE.Texture | null>(null)
|
||||
const transparentTexture = createTransparentTexture()
|
||||
|
||||
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
|
||||
}
|
||||
const isModelLoaded = ref(false)
|
||||
const isTextureLoaded = ref(false)
|
||||
const isReady = computed(() => isModelLoaded.value && isTextureLoaded.value)
|
||||
|
||||
async function loadModel(src: string) {
|
||||
const { scene: loadedScene } = await useGLTF(src)
|
||||
scene.value = markRaw(loadedScene)
|
||||
try {
|
||||
isModelLoaded.value = false
|
||||
const { scene: loadedScene } = await useGLTF(src)
|
||||
scene.value = markRaw(loadedScene)
|
||||
|
||||
if (texture.value) {
|
||||
applyTextureToScene(scene.value, texture.value)
|
||||
}
|
||||
|
||||
bodyNode.value = null
|
||||
loadedScene.traverse((node) => {
|
||||
if (node.name === 'Body') {
|
||||
bodyNode.value = node
|
||||
if (texture.value) {
|
||||
applyTexture(scene.value, texture.value)
|
||||
}
|
||||
})
|
||||
|
||||
capeAttached.value = false
|
||||
bodyNode.value = findBodyNode(loadedScene)
|
||||
capeAttached.value = false
|
||||
|
||||
if (capeScene.value && bodyNode.value) {
|
||||
attachCapeToBody()
|
||||
updateModelInfo()
|
||||
isModelLoaded.value = true
|
||||
} catch (error) {
|
||||
console.error('Failed to load model:', error)
|
||||
isModelLoaded.value = false
|
||||
}
|
||||
|
||||
updateModelInfo()
|
||||
}
|
||||
|
||||
async function loadCape(src: string) {
|
||||
@@ -169,24 +168,39 @@ async function loadCape(src: string) {
|
||||
return
|
||||
}
|
||||
|
||||
const { scene: loadedCape } = await useGLTF(src)
|
||||
capeScene.value = markRaw(loadedCape)
|
||||
try {
|
||||
const { scene: loadedCape } = await useGLTF(src)
|
||||
capeScene.value = markRaw(loadedCape)
|
||||
|
||||
applyCapeTexture(capeScene.value, capeTexture.value)
|
||||
applyCapeTexture(capeScene.value, capeTexture.value, transparentTexture)
|
||||
|
||||
if (bodyNode.value && !capeAttached.value) {
|
||||
attachCapeToBody()
|
||||
if (bodyNode.value && !capeAttached.value) {
|
||||
attachCapeToBodyWrapper()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load cape:', error)
|
||||
capeScene.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAndApplyTexture(src: string) {
|
||||
if (!src) return null
|
||||
const tex = await useTexture([src])
|
||||
tex.colorSpace = THREE.SRGBColorSpace
|
||||
tex.flipY = false
|
||||
tex.magFilter = THREE.NearestFilter
|
||||
tex.minFilter = THREE.NearestFilter
|
||||
return tex
|
||||
|
||||
try {
|
||||
try {
|
||||
return await loadSkinTexture(src)
|
||||
} catch {
|
||||
const tex = await useTexture([src])
|
||||
tex.colorSpace = THREE.SRGBColorSpace
|
||||
tex.flipY = false
|
||||
tex.magFilter = THREE.NearestFilter
|
||||
tex.minFilter = THREE.NearestFilter
|
||||
return tex
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load texture:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAndApplyCapeTexture(src: string | undefined) {
|
||||
@@ -201,7 +215,7 @@ async function loadAndApplyCapeTexture(src: string | undefined) {
|
||||
}
|
||||
|
||||
if (capeScene.value) {
|
||||
applyCapeTexture(capeScene.value, capeTexture.value)
|
||||
applyCapeTexture(capeScene.value, capeTexture.value, transparentTexture)
|
||||
}
|
||||
|
||||
if (capeScene.value && bodyNode.value) {
|
||||
@@ -209,83 +223,32 @@ async function loadAndApplyCapeTexture(src: string | undefined) {
|
||||
capeScene.value.parent.remove(capeScene.value)
|
||||
capeAttached.value = false
|
||||
} else if (src && !capeAttached.value) {
|
||||
attachCapeToBody()
|
||||
attachCapeToBodyWrapper()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function attachCapeToBody() {
|
||||
function attachCapeToBodyWrapper() {
|
||||
if (!bodyNode.value || !capeScene.value || capeAttached.value) return
|
||||
|
||||
if (capeScene.value.parent) {
|
||||
capeScene.value.parent.remove(capeScene.value)
|
||||
}
|
||||
|
||||
capeScene.value.position.set(0, -1, -0.01)
|
||||
capeScene.value.rotation.set(0, -Math.PI / 2, 0)
|
||||
|
||||
bodyNode.value.add(capeScene.value)
|
||||
attachCapeToBody(bodyNode.value, capeScene.value)
|
||||
capeAttached.value = true
|
||||
}
|
||||
|
||||
function applyTextureToScene(root: THREE.Object3D | null, tex: THREE.Texture | null) {
|
||||
if (!root || !tex) return
|
||||
|
||||
root.traverse((child) => {
|
||||
if ((child as THREE.Mesh).isMesh) {
|
||||
const mesh = child as THREE.Mesh
|
||||
|
||||
if (mesh.name === 'Cape') return
|
||||
|
||||
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
||||
|
||||
materials.forEach((mat, _index, _array) => {
|
||||
const standardMat = mat as THREE.MeshStandardMaterial
|
||||
standardMat.map = tex
|
||||
standardMat.metalness = 0
|
||||
standardMat.color.set(0xffffff)
|
||||
standardMat.toneMapped = false
|
||||
standardMat.roughness = 1
|
||||
standardMat.needsUpdate = true
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function applyCapeTexture(root: THREE.Object3D | null, tex: THREE.Texture | null) {
|
||||
if (!root) return
|
||||
|
||||
root.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, _index, _array) => {
|
||||
const standardMat = mat as THREE.MeshStandardMaterial
|
||||
standardMat.map = tex || transparentTexture
|
||||
standardMat.transparent = true
|
||||
standardMat.metalness = 0
|
||||
standardMat.color.set(0xffffff)
|
||||
standardMat.toneMapped = false
|
||||
standardMat.roughness = 1
|
||||
standardMat.side = THREE.DoubleSide
|
||||
standardMat.needsUpdate = true
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const centre = ref<[number, number, number]>([0, 1, 0])
|
||||
const modelHeight = ref(1.4)
|
||||
|
||||
function updateModelInfo() {
|
||||
if (!scene.value) return
|
||||
const bbox = new THREE.Box3().setFromObject(scene.value)
|
||||
const mid = new THREE.Vector3()
|
||||
bbox.getCenter(mid)
|
||||
centre.value = [mid.x, mid.y, mid.z]
|
||||
modelHeight.value = bbox.max.y - bbox.min.y
|
||||
try {
|
||||
const bbox = new THREE.Box3().setFromObject(scene.value)
|
||||
const mid = new THREE.Vector3()
|
||||
bbox.getCenter(mid)
|
||||
centre.value = [mid.x, mid.y, mid.z]
|
||||
modelHeight.value = bbox.max.y - bbox.min.y
|
||||
} catch (error) {
|
||||
console.error('Failed to update model info:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const target = computed(() => centre.value)
|
||||
@@ -329,6 +292,25 @@ function createRadialTexture(size: number): THREE.CanvasTexture {
|
||||
return new THREE.CanvasTexture(canvas)
|
||||
}
|
||||
|
||||
watch(
|
||||
[bodyNode, capeScene, isModelLoaded],
|
||||
([newBodyNode, newCapeScene, modelLoaded]) => {
|
||||
if (newBodyNode && newCapeScene && modelLoaded && !capeAttached.value) {
|
||||
attachCapeToBodyWrapper()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
capeScene,
|
||||
(newCapeScene) => {
|
||||
if (newCapeScene && bodyNode.value && isModelLoaded.value && !capeAttached.value) {
|
||||
attachCapeToBodyWrapper()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(selectedModelSrc, (src) => loadModel(src))
|
||||
watch(
|
||||
() => props.capeModelSrc,
|
||||
@@ -337,10 +319,12 @@ watch(
|
||||
watch(
|
||||
() => props.textureSrc,
|
||||
async (newSrc) => {
|
||||
isTextureLoaded.value = false
|
||||
texture.value = await loadAndApplyTexture(newSrc)
|
||||
if (scene.value && texture.value) {
|
||||
applyTextureToScene(scene.value, texture.value)
|
||||
applyTexture(scene.value, texture.value)
|
||||
}
|
||||
isTextureLoaded.value = true
|
||||
},
|
||||
)
|
||||
watch(
|
||||
@@ -351,16 +335,22 @@ watch(
|
||||
)
|
||||
|
||||
onBeforeMount(async () => {
|
||||
texture.value = await loadAndApplyTexture(props.textureSrc)
|
||||
try {
|
||||
isTextureLoaded.value = false
|
||||
texture.value = await loadAndApplyTexture(props.textureSrc)
|
||||
isTextureLoaded.value = true
|
||||
|
||||
await loadModel(selectedModelSrc.value)
|
||||
await loadModel(selectedModelSrc.value)
|
||||
|
||||
if (props.capeSrc) {
|
||||
await loadAndApplyCapeTexture(props.capeSrc)
|
||||
}
|
||||
if (props.capeSrc) {
|
||||
await loadAndApplyCapeTexture(props.capeSrc)
|
||||
}
|
||||
|
||||
if (props.capeModelSrc) {
|
||||
await loadCape(props.capeModelSrc)
|
||||
if (props.capeModelSrc) {
|
||||
await loadCape(props.capeModelSrc)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize skin preview:', error)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -368,7 +358,7 @@ onBeforeMount(async () => {
|
||||
<style scoped lang="scss">
|
||||
.nametag-bg {
|
||||
background: linear-gradient(308.68deg, rgba(0, 0, 0, 0) -52.46%, rgba(100, 100, 100, 0.1) 94.75%),
|
||||
rgba(0, 0, 0, 0.2);
|
||||
rgba(0, 0, 0, 0.2);
|
||||
box-shadow:
|
||||
inset -0.5px -0.5px 0px rgba(0, 0, 0, 0.25),
|
||||
inset 0.5px 0.5px 0px rgba(255, 255, 255, 0.05);
|
||||
|
||||
@@ -8,3 +8,4 @@ export * from './types'
|
||||
export * from './users'
|
||||
export * from './utils'
|
||||
export * from './servers'
|
||||
export * from './three/skin-rendering'
|
||||
|
||||
@@ -20,10 +20,12 @@
|
||||
"@codemirror/state": "^6.3.2",
|
||||
"@codemirror/view": "^6.22.1",
|
||||
"@types/markdown-it": "^14.1.1",
|
||||
"@types/three": "^0.172.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"highlight.js": "^11.9.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"ofetch": "^1.3.4",
|
||||
"three": "^0.172.0",
|
||||
"xss": "^1.0.14"
|
||||
}
|
||||
}
|
||||
|
||||
233
packages/utils/three/skin-rendering.ts
Normal file
233
packages/utils/three/skin-rendering.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import * as THREE from 'three'
|
||||
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js'
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
|
||||
|
||||
export interface SkinRendererConfig {
|
||||
textureColorSpace?: THREE.ColorSpace
|
||||
textureFlipY?: boolean
|
||||
textureMagFilter?: THREE.MagnificationTextureFilter
|
||||
textureMinFilter?: THREE.MinificationTextureFilter
|
||||
}
|
||||
|
||||
// Private caches
|
||||
const modelCache: Map<string, GLTF> = new Map()
|
||||
const textureCache: Map<string, THREE.Texture> = new Map()
|
||||
|
||||
/**
|
||||
* Load and cache a GLTF model
|
||||
*/
|
||||
export async function loadModel(modelUrl: string): Promise<GLTF> {
|
||||
if (modelCache.has(modelUrl)) {
|
||||
return modelCache.get(modelUrl)!
|
||||
}
|
||||
|
||||
const loader = new GLTFLoader()
|
||||
return new Promise<GLTF>((resolve, reject) => {
|
||||
loader.load(
|
||||
modelUrl,
|
||||
(gltf) => {
|
||||
modelCache.set(modelUrl, gltf)
|
||||
resolve(gltf)
|
||||
},
|
||||
undefined,
|
||||
reject,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and configure a texture with skin-specific settings
|
||||
*/
|
||||
export async function loadTexture(
|
||||
textureUrl: string,
|
||||
config: SkinRendererConfig = {}
|
||||
): Promise<THREE.Texture> {
|
||||
const cacheKey = `${textureUrl}_${JSON.stringify(config)}`
|
||||
|
||||
if (textureCache.has(cacheKey)) {
|
||||
return textureCache.get(cacheKey)!
|
||||
}
|
||||
|
||||
return new Promise<THREE.Texture>((resolve) => {
|
||||
const textureLoader = new THREE.TextureLoader()
|
||||
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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply skin texture to a model, excluding cape meshes
|
||||
*/
|
||||
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
|
||||
|
||||
// Skip cape meshes
|
||||
if (mesh.name === 'Cape') return
|
||||
|
||||
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
||||
|
||||
materials.forEach((mat: THREE.Material) => {
|
||||
if (mat instanceof THREE.MeshStandardMaterial) {
|
||||
mat.map = texture
|
||||
mat.metalness = 0
|
||||
mat.color.set(0xffffff)
|
||||
mat.toneMapped = false
|
||||
mat.roughness = 1
|
||||
mat.needsUpdate = true
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply cape texture with specific settings for cape materials
|
||||
*/
|
||||
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) {
|
||||
mat.map = texture || transparentTexture || null
|
||||
mat.transparent = transparentTexture ? true : false
|
||||
mat.metalness = 0
|
||||
mat.color.set(0xffffff)
|
||||
mat.toneMapped = false
|
||||
mat.roughness = 1
|
||||
mat.side = THREE.DoubleSide
|
||||
mat.needsUpdate = true
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach cape model to the body node with standard positioning
|
||||
*/
|
||||
export function attachCapeToBody(
|
||||
bodyNode: THREE.Object3D,
|
||||
capeModel: THREE.Object3D,
|
||||
position = { x: 0, y: -1, z: -0.01 },
|
||||
rotation = { x: 0, y: -Math.PI / 2, z: 0 }
|
||||
): void {
|
||||
if (!bodyNode || !capeModel) return
|
||||
|
||||
// Remove cape from current parent if it exists
|
||||
if (capeModel.parent) {
|
||||
capeModel.parent.remove(capeModel)
|
||||
}
|
||||
|
||||
// Set cape position and rotation
|
||||
capeModel.position.set(position.x, position.y, position.z)
|
||||
capeModel.rotation.set(rotation.x, rotation.y, rotation.z)
|
||||
|
||||
// Attach to body
|
||||
bodyNode.add(capeModel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the body node in a model hierarchy
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a transparent texture for use as fallback
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup a complete skin model with optional cape
|
||||
*/
|
||||
export async function setupSkinModel(
|
||||
modelUrl: string,
|
||||
textureUrl: string,
|
||||
capeModelUrl?: string,
|
||||
capeTextureUrl?: string,
|
||||
config: SkinRendererConfig = {}
|
||||
): Promise<{
|
||||
model: THREE.Object3D
|
||||
bodyNode: THREE.Object3D | null
|
||||
capeModel: THREE.Object3D | null
|
||||
}> {
|
||||
// Load model and texture in parallel
|
||||
const [gltf, texture] = await Promise.all([
|
||||
loadModel(modelUrl),
|
||||
loadTexture(textureUrl, config),
|
||||
])
|
||||
|
||||
const model = gltf.scene.clone()
|
||||
applyTexture(model, texture)
|
||||
|
||||
const bodyNode = findBodyNode(model)
|
||||
let capeModel: THREE.Object3D | null = null
|
||||
|
||||
// Load cape if provided
|
||||
if (capeModelUrl && capeTextureUrl) {
|
||||
const [capeGltf, capeTexture] = await Promise.all([
|
||||
loadModel(capeModelUrl),
|
||||
loadTexture(capeTextureUrl, config),
|
||||
])
|
||||
|
||||
capeModel = capeGltf.scene.clone()
|
||||
applyCapeTexture(capeModel, capeTexture)
|
||||
|
||||
if (bodyNode && capeModel) {
|
||||
attachCapeToBody(bodyNode, capeModel)
|
||||
}
|
||||
}
|
||||
|
||||
return { model, bodyNode, capeModel }
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all caches and dispose of cached resources
|
||||
*/
|
||||
export function disposeCaches(): void {
|
||||
Array.from(textureCache.values()).forEach((texture) => {
|
||||
texture.dispose()
|
||||
})
|
||||
|
||||
textureCache.clear()
|
||||
modelCache.clear()
|
||||
}
|
||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -529,6 +529,9 @@ importers:
|
||||
'@types/markdown-it':
|
||||
specifier: ^14.1.1
|
||||
version: 14.1.1
|
||||
'@types/three':
|
||||
specifier: ^0.172.0
|
||||
version: 0.172.0
|
||||
dayjs:
|
||||
specifier: ^1.11.10
|
||||
version: 1.11.11
|
||||
@@ -541,6 +544,9 @@ importers:
|
||||
ofetch:
|
||||
specifier: ^1.3.4
|
||||
version: 1.4.1
|
||||
three:
|
||||
specifier: ^0.172.0
|
||||
version: 0.172.0
|
||||
xss:
|
||||
specifier: ^1.0.14
|
||||
version: 1.0.15
|
||||
|
||||
Reference in New Issue
Block a user