polish: deduplicate model+cape stuff and fix layout

This commit is contained in:
Calum H.
2025-05-24 16:49:07 +01:00
committed by Alejandro González
parent 187e892c18
commit fc929bfca3
7 changed files with 362 additions and 256 deletions

View File

@@ -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()
}
}

View File

@@ -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%;

View File

@@ -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);

View File

@@ -8,3 +8,4 @@ export * from './types'
export * from './users'
export * from './utils'
export * from './servers'
export * from './three/skin-rendering'

View File

@@ -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"
}
}

View 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
View File

@@ -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