From fc929bfca34a90e1c15b85b2f380ddda8d676008 Mon Sep 17 00:00:00 2001 From: "Calum H." Date: Sat, 24 May 2025 16:49:07 +0100 Subject: [PATCH] polish: deduplicate model+cape stuff and fix layout --- .../helpers/rendering/batchSkinRenderer.ts | 151 +----------- apps/app-frontend/src/pages/Skins.vue | 3 +- .../components/skin/SkinPreviewRenderer.vue | 222 ++++++++--------- packages/utils/index.ts | 1 + packages/utils/package.json | 2 + packages/utils/three/skin-rendering.ts | 233 ++++++++++++++++++ pnpm-lock.yaml | 6 + 7 files changed, 362 insertions(+), 256 deletions(-) create mode 100644 packages/utils/three/skin-rendering.ts diff --git a/apps/app-frontend/src/helpers/rendering/batchSkinRenderer.ts b/apps/app-frontend/src/helpers/rendering/batchSkinRenderer.ts index e3cc5ab16..9f8f3d952 100644 --- a/apps/app-frontend/src/helpers/rendering/batchSkinRenderer.ts +++ b/apps/app-frontend/src/helpers/rendering/batchSkinRenderer.ts @@ -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 = new Map() - private textureCache: Map = 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 { - if (this.modelCache.has(modelUrl)) { - return this.modelCache.get(modelUrl)! - } - - const loader = new GLTFLoader() - return new Promise((resolve, reject) => { - loader.load( - modelUrl, - (gltf) => { - this.modelCache.set(modelUrl, gltf) - resolve(gltf) - }, - undefined, - reject, - ) - }) - } - - private async loadTexture(textureUrl: string): Promise { - if (this.textureCache.has(textureUrl)) { - return this.textureCache.get(textureUrl)! - } - - return new Promise((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() } } diff --git a/apps/app-frontend/src/pages/Skins.vue b/apps/app-frontend/src/pages/Skins.vue index eabb8ab8e..348c97d3c 100644 --- a/apps/app-frontend/src/pages/Skins.vue +++ b/apps/app-frontend/src/pages/Skins.vue @@ -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%; diff --git a/packages/ui/src/components/skin/SkinPreviewRenderer.vue b/packages/ui/src/components/skin/SkinPreviewRenderer.vue index bc65254c2..9f44640ed 100644 --- a/packages/ui/src/components/skin/SkinPreviewRenderer.vue +++ b/packages/ui/src/components/skin/SkinPreviewRenderer.vue @@ -15,6 +15,7 @@ + + +
+
Loading...
+
@@ -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(null) const capeTexture = shallowRef(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) } }) @@ -368,7 +358,7 @@ onBeforeMount(async () => {