Skins improvements/fixes (#3943)

* feat: only initialize batch renderer if needed & head storage

* feat: support webp storage of skin renders if supported (falls back to png if not)

* fix: performance improvements with cache loading+saving

* fix: mirrored skins + remove cape model for embedded cape

* feat: antialiasing

* fix: leg jumping & store fbx's for reference

* fix: lint issues

* fix: lint issues

* feat: tweaks to radial spotlight

* fix: app nav btn colors
This commit is contained in:
IMB11
2025-07-09 22:41:36 +01:00
committed by GitHub
parent 3c79607d1f
commit cb72d2ac80
15 changed files with 4882 additions and 1355 deletions

View File

@@ -83,7 +83,7 @@ export const TwitterIcon = _TwitterIcon
export const WindowsIcon = _WindowsIcon
export const YouTubeIcon = _YouTubeIcon
export { default as CapeModel } from './models/cape.gltf?url'
// Skin Models
export { default as ClassicPlayerModel } from './models/classic-player.gltf?url'
export { default as SlimPlayerModel } from './models/slim-player.gltf?url'

View File

@@ -1,92 +0,0 @@
{
"asset": { "version": "2.0", "generator": "Blockbench 4.12.4 glTF exporter" },
"scenes": [{ "nodes": [1], "name": "blockbench_export" }],
"scene": 0,
"nodes": [
{
"rotation": [0, 0, 0.19509032201612825, 0.9807852804032304],
"translation": [0.15625, 1, 0],
"name": "Cape",
"mesh": 0
},
{ "children": [0] }
],
"bufferViews": [
{ "buffer": 0, "byteOffset": 0, "byteLength": 288, "target": 34962, "byteStride": 12 },
{ "buffer": 0, "byteOffset": 288, "byteLength": 288, "target": 34962, "byteStride": 12 },
{ "buffer": 0, "byteOffset": 576, "byteLength": 192, "target": 34962, "byteStride": 8 },
{ "buffer": 0, "byteOffset": 768, "byteLength": 72, "target": 34963 }
],
"buffers": [
{
"byteLength": 840,
"uri": "data:application/octet-stream;base64,AAAAPQAAAAAAAKA+AAAAPQAAAAAAAKC+AAAAPQAAgL8AAKA+AAAAPQAAgL8AAKC+AAAAvQAAAAAAAKC+AAAAvQAAAAAAAKA+AAAAvQAAgL8AAKC+AAAAvQAAgL8AAKA+AAAAvQAAAAAAAKC+AAAAPQAAAAAAAKC+AAAAvQAAAAAAAKA+AAAAPQAAAAAAAKA+AAAAvQAAgL8AAKA+AAAAPQAAgL8AAKA+AAAAvQAAgL8AAKC+AAAAPQAAgL8AAKC+AAAAvQAAAAAAAKA+AAAAPQAAAAAAAKA+AAAAvQAAgL8AAKA+AAAAPQAAgL8AAKA+AAAAPQAAAAAAAKC+AAAAvQAAAAAAAKC+AAAAPQAAgL8AAKC+AAAAvQAAgL8AAKC+AACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AACAPAAAgD0AADA+AACAPQAAgDwAAAg/AAAwPgAACD8AAEA+AACAPQAAsD4AAIA9AABAPgAACD8AALA+AAAIPwAAgDwAAAA9AACAPAAAgD0AADA+AAAAPQAAMD4AAIA9AAAwPgAAAD0AAKg+AAAAPQAAMD4AAAAAAACoPgAAAAAAAEA+AACAPQAAMD4AAIA9AABAPgAACD8AADA+AAAIPwAAAAAAAIA9AACAPAAAgD0AAAAAAAAIPwAAgDwAAAg/AAACAAEAAgADAAEABAAGAAUABgAHAAUACAAKAAkACgALAAkADAAOAA0ADgAPAA0AEAASABEAEgATABEAFAAWABUAFgAXABUA"
}
],
"accessors": [
{
"bufferView": 0,
"componentType": 5126,
"count": 24,
"max": [0.03125, 0, 0.3125],
"min": [-0.03125, -1, -0.3125],
"type": "VEC3"
},
{
"bufferView": 1,
"componentType": 5126,
"count": 24,
"max": [1, 1, 1],
"min": [-1, -1, -1],
"type": "VEC3"
},
{
"bufferView": 2,
"componentType": 5126,
"count": 24,
"max": [0.34375, 0.53125],
"min": [0, 0],
"type": "VEC2"
},
{
"bufferView": 3,
"componentType": 5123,
"count": 36,
"max": [23],
"min": [0],
"type": "SCALAR"
}
],
"materials": [
{
"pbrMetallicRoughness": {
"metallicFactor": 0,
"roughnessFactor": 1,
"baseColorTexture": { "index": 0 }
},
"alphaMode": "MASK",
"alphaCutoff": 0.05,
"doubleSided": true
}
],
"textures": [{ "sampler": 0, "source": 0, "name": "cape.png" }],
"samplers": [{ "magFilter": 9728, "minFilter": 9728, "wrapS": 33071, "wrapT": 33071 }],
"images": [
{
"mimeType": "image/png",
"uri": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAAAgCAYAAACinX6EAAAAAXNSR0IArs4c6QAABCRJREFUaEPtlktoE1EUhm/sIzFpYsBgHyn2oRZt0C5ESxWFYutKNyKIWBBR6kpBd3Ur2J0FRdCiuKmIIG4qLnzgQqTUXSlVqdoXTU0lljRpYtOHI/8dznBnOm1mphMwJWeTzJ0795zznf/cex2MMXam2S/ht38siJ8V1lgd5mOBARerc3rZnmK37rwvCyk2nE6wezMRh+4EncH9B1ql+fkU64rPsaBz5bqh4T7Daxn1Kc5zVNeEePJIci0Aq71bzenY6JChwAnA0OBHQ/OtJLnWNwqAy61R1tO3k891ueRKoDKwtqbv7MGbgCnfRgHQoqG9hyX4hc/yiho+/HNqVPFtdj2jwTpQgd/RKT7/0ZUku/o4qAJw50KYXbzr4e+3BioYzc3kwGzAUCLWFw2+UBiCb3bNTDHiPQeAP3CGNmg/6VcSBpDu3hhvDQpOCwABwrQKsRIsqYAChy/EQAXwlPiZ3a3igNPkXIyTjr8o5L7PPdzGf59c+sV/faeWeIIIAHPJ8M3kc7l1K09LKghWAIgqoPZ7djPFTlxb4D6yAgBOQfntrUVWVeRk44tplXJorOVGkVIJTBCTpw9ECFYBIEkyMfmsAXh3u1qi5MlxbbGX/x1ZSCjBAAwgfPr6h49R5UVavk0FXC2wju5p07s6iqEF0PtqSlEf+bKzDRwdgaDU7AkoySJ5Oo/D6ZRq/H0yyuJ/lxkSheG/FgCNm7kL0BoEAG32sqtY1YIHd2/mGzTMVgD3y2slqjgW9xY6ma9AThAARIMiBtOpjADwTWc0bEkB+FZsSTxTW0KBsGPXx0yvrUpEeHAAQIM7wBJLcu8DwHaP/H8i6VSND6SiSjBlRW4WWUypVIBbIgzjVgB0tpdKqLReS0KVPTMTfH0ra2cEgAmAAEd+l1z52LybqwBQYAQAyZPh6gtDW4jjuC4fHx8wXSm0JDbeI95SRYHiFZlUaWVtPQiKAugl5E8ASAUYiy8vc0C474uGasPE5PHc4g0wK/f4obom6UNimol7kTZwQLANwOuqBokqDEeQf4lfvvnNxZJcBTBAGZplWQcQ3tcgwY9oWlXinRW4ugoAgNAWWe6ocn1QvgyRTUb4RZFVljlY/3hSBYCqb6cCZo8ekuATVRZPI/gQW8FWAI1VHganVgDQUajdA6y2gAgAcZFBjTBSpG0AcAqc3VVmCMDTbxGWZvIRCaMNkJ7pFMCzVQD4liCQ8kRFUlvaBkCvL+wYw2ZmNUgCgLajFhRPJlv3ADuS1VtjPQCk823S574ffN/x1dQqy8dHR5SN2Spcbaymz2mjwNYDAD4IQn3TDpVLQIAqNjwAANRrAdoI/3sAOF7Xe1khCNQGVH1bL0JGJb1R52VtD8gVYHkAuVKpbMWZV0C2yObKunkF5EqlshVnXgHZIpsr6/4DlbxcPydnT74AAAAASUVORK5CYII="
}
],
"meshes": [
{
"primitives": [
{
"mode": 4,
"attributes": { "POSITION": 0, "NORMAL": 1, "TEXCOORD_0": 2 },
"indices": 3,
"material": 0
}
]
}
]
}

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -32,6 +32,7 @@
"@modrinth/utils": "workspace:*",
"@tresjs/cientos": "^4.3.0",
"@tresjs/core": "^4.3.4",
"@tresjs/post-processing": "^2.4.0",
"@types/markdown-it": "^14.1.1",
"@types/three": "^0.172.0",
"@vintl/how-ago": "^3.0.1",
@@ -41,6 +42,7 @@
"floating-vue": "^5.2.2",
"highlight.js": "^11.9.0",
"markdown-it": "^13.0.2",
"postprocessing": "^6.37.6",
"qrcode.vue": "^3.4.1",
"three": "^0.172.0",
"vue-multiselect": "3.0.0",

View File

@@ -23,7 +23,7 @@
<TresCanvas
shadows
alpha
:antialias="antialias"
:antialias="true"
:renderer-options="{
outputColorSpace: THREE.SRGBColorSpace,
toneMapping: THREE.NoToneMapping,
@@ -46,36 +46,39 @@
<primitive v-if="scene" :object="scene" />
</Group>
<TresMesh
<!-- <TresMesh
:position="[0, -0.095 * scale, 2]"
:rotation="[-Math.PI / 2, 0, 0]"
:scale="[0.5 * 0.75 * scale, 0.5 * 0.75 * scale, 0.5 * 0.75 * scale]"
:scale="[0.4 * 0.75 * scale, 0.4 * 0.75 * scale, 0.4 * 0.75 * scale]"
>
<TresCircleGeometry :args="[1, 128]" />
<TresMeshBasicMaterial
color="#000000"
:opacity="0.2"
:opacity="0.5"
transparent
:depth-write="false"
/>
</TresMesh>
<TresMesh
:position="[0, -0.1 * scale, 2]"
:rotation="[-Math.PI / 2, 0, 0]"
:scale="[0.75 * scale, 0.75 * scale, 0.75 * scale]"
>
<TresCircleGeometry :args="[1, 128]" />
<TresMeshBasicMaterial
:map="radialTexture"
transparent
:depth-write="false"
:blending="THREE.AdditiveBlending"
/>
</TresMesh>
</TresMesh> -->
</Group>
</Suspense>
<Suspense>
<EffectComposerPmndrs>
<FXAAPmndrs />
</EffectComposerPmndrs>
</Suspense>
<Suspense>
<TresMesh
:position="[0, -0.1 * scale, 2]"
:rotation="[-Math.PI / 2, 0, 0]"
:scale="[0.75 * scale, 0.75 * scale, 0.75 * scale]"
>
<TresCircleGeometry :args="[1, 128]" />
<TresShaderMaterial v-bind="radialSpotlightShader" />
</TresMesh>
</Suspense>
<TresPerspectiveCamera
:make-default.camel="true"
:fov="fov"
@@ -101,6 +104,7 @@
import * as THREE from 'three'
import { useGLTF } from '@tresjs/cientos'
import { useTexture, TresCanvas, useRenderLoop } from '@tresjs/core'
import { EffectComposerPmndrs, FXAAPmndrs } from '@tresjs/post-processing'
import {
shallowRef,
ref,
@@ -115,13 +119,11 @@ import {
import {
applyTexture,
applyCapeTexture,
attachCapeToBody,
findBodyNode,
createTransparentTexture,
loadTexture as loadSkinTexture,
} from '@modrinth/utils'
import { useDynamicFontSize } from '../../composables'
import { CapeModel, ClassicPlayerModel, SlimPlayerModel } from '@modrinth/assets'
import { ClassicPlayerModel, SlimPlayerModel } from '@modrinth/assets'
interface AnimationConfig {
baseAnimation: string
@@ -136,7 +138,6 @@ const props = withDefaults(
capeSrc?: string
variant?: 'SLIM' | 'CLASSIC' | 'UNKNOWN'
nametag?: string
antialias?: boolean
scale?: number
fov?: number
initialRotation?: number
@@ -144,7 +145,6 @@ const props = withDefaults(
}>(),
{
variant: 'CLASSIC',
antialias: false,
scale: 1,
fov: 40,
capeSrc: undefined,
@@ -177,9 +177,6 @@ const selectedModelSrc = computed(() =>
)
const scene = shallowRef<THREE.Object3D | null>(null)
const capeScene = shallowRef<THREE.Object3D | null>(null)
const bodyNode = shallowRef<THREE.Object3D | null>(null)
const capeAttached = ref(false)
const lastCapeSrc = ref<string | undefined>(undefined)
const texture = shallowRef<THREE.Texture | null>(null)
const capeTexture = shallowRef<THREE.Texture | null>(null)
@@ -196,6 +193,54 @@ const currentAnimation = ref<string>('')
const randomAnimationTimer = ref<number | null>(null)
const lastRandomAnimation = ref<string>('')
const radialSpotlightShader = computed(() => ({
uniforms: {
innerColor: { value: new THREE.Color(0x000000) },
outerColor: { value: new THREE.Color(0xffffff) },
innerOpacity: { value: 0.3 },
outerOpacity: { value: 0.0 },
falloffPower: { value: 1.2 },
shadowRadius: { value: 7 },
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform vec3 innerColor;
uniform vec3 outerColor;
uniform float innerOpacity;
uniform float outerOpacity;
uniform float falloffPower;
uniform float shadowRadius;
varying vec2 vUv;
void main() {
vec2 center = vec2(0.5, 0.5);
float dist = distance(vUv, center) * 2.0;
// Create shadow in the center
float shadowFalloff = 1.0 - smoothstep(0.0, shadowRadius, dist);
// Create overall spotlight falloff
float spotlightFalloff = 1.0 - smoothstep(0.0, 1.0, pow(dist, falloffPower));
// Combine both effects
vec3 color = mix(outerColor, innerColor, shadowFalloff);
float opacity = mix(outerOpacity, innerOpacity * shadowFalloff, spotlightFalloff);
gl_FragColor = vec4(color, opacity);
}
`,
transparent: true,
depthWrite: false,
depthTest: false,
}))
const { baseAnimation, randomAnimations } = toRefs(props.animationConfig)
function initializeAnimations(loadedScene: THREE.Object3D, clips: THREE.AnimationClip[]) {
@@ -400,11 +445,9 @@ async function loadModel(src: string) {
if (texture.value) {
applyTexture(scene.value, texture.value)
texture.value.needsUpdate = true
}
bodyNode.value = findBodyNode(loadedScene)
capeAttached.value = false
applyCapeTexture(scene.value, capeTexture.value, transparentTexture)
if (animations && animations.length > 0) {
initializeAnimations(loadedScene, animations)
@@ -418,22 +461,6 @@ async function loadModel(src: string) {
}
}
async function loadCape() {
try {
const { scene: loadedCape } = await useGLTF(CapeModel)
capeScene.value = markRaw(loadedCape)
applyCapeTexture(capeScene.value, capeTexture.value, transparentTexture)
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
@@ -465,25 +492,9 @@ async function loadAndApplyCapeTexture(src: string | undefined) {
capeTexture.value = null
}
if (capeScene.value) {
applyCapeTexture(capeScene.value, capeTexture.value, transparentTexture)
if (scene.value) {
applyCapeTexture(scene.value, capeTexture.value, transparentTexture)
}
if (capeScene.value && bodyNode.value) {
if (!src && capeAttached.value && capeScene.value.parent) {
capeScene.value.parent.remove(capeScene.value)
capeAttached.value = false
} else if (src && !capeAttached.value) {
attachCapeToBodyWrapper()
}
}
}
function attachCapeToBodyWrapper() {
if (!bodyNode.value || !capeScene.value || capeAttached.value) return
attachCapeToBody(bodyNode.value, capeScene.value)
capeAttached.value = true
}
const centre = ref<[number, number, number]>([0, 1, 0])
@@ -539,39 +550,6 @@ function onCanvasClick() {
hasDragged.value = false
}
const radialTexture = createRadialTexture(512)
radialTexture.minFilter = THREE.LinearFilter
radialTexture.magFilter = THREE.LinearFilter
radialTexture.wrapS = radialTexture.wrapT = THREE.ClampToEdgeWrapping
function createRadialTexture(size: number): THREE.CanvasTexture {
const canvas = document.createElement('canvas')
canvas.width = canvas.height = size
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D
const grad = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2)
grad.addColorStop(0, 'rgba(119,119,119,0.1)')
grad.addColorStop(0.9, 'rgba(255,255,255,0)')
ctx.fillStyle = grad
ctx.fillRect(0, 0, size, size)
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.textureSrc,
@@ -587,7 +565,6 @@ watch(
watch(
() => props.capeSrc,
async (newCapeSrc) => {
await loadCape()
await loadAndApplyCapeTexture(newCapeSrc)
},
)
@@ -619,8 +596,6 @@ onBeforeMount(async () => {
if (props.capeSrc) {
await loadAndApplyCapeTexture(props.capeSrc)
}
await loadCape()
} catch (error) {
console.error('Failed to initialize skin preview:', error)
}

View File

@@ -59,25 +59,23 @@ export function applyTexture(model: THREE.Object3D, texture: THREE.Texture): voi
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.flatShading = true
mat.roughness = 1
mat.needsUpdate = true
mat.depthTest = true
mat.side = THREE.DoubleSide
mat.alphaTest = 0.1
mat.depthWrite = true
if (mat.name !== 'cape') {
mat.map = texture
mat.metalness = 0
mat.color.set(0xffffff)
mat.toneMapped = false
mat.flatShading = true
mat.roughness = 1
mat.needsUpdate = true
mat.depthTest = true
mat.side = THREE.DoubleSide
mat.alphaTest = 0.1
mat.depthWrite = true
}
}
})
}
@@ -96,41 +94,27 @@ export function applyCapeTexture(
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.flatShading = true
mat.roughness = 1
mat.needsUpdate = true
mat.depthTest = true
mat.depthWrite = true
mat.side = THREE.DoubleSide
mat.alphaTest = 0.1
if (mat.name === 'cape') {
mat.map = texture || transparentTexture || null
mat.transparent = !texture || transparentTexture ? true : false
mat.metalness = 0
mat.color.set(0xffffff)
mat.toneMapped = false
mat.flatShading = true
mat.roughness = 1
mat.needsUpdate = true
mat.depthTest = true
mat.depthWrite = true
mat.side = THREE.DoubleSide
mat.alphaTest = 0.1
mat.visible = !!texture
}
}
})
}
})
}
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
if (capeModel.parent) {
capeModel.parent.remove(capeModel)
}
capeModel.position.set(position.x, position.y, position.z)
capeModel.rotation.set(rotation.x, rotation.y, rotation.z)
bodyNode.add(capeModel)
}
export function findBodyNode(model: THREE.Object3D): THREE.Object3D | null {
let bodyNode: THREE.Object3D | null = null
@@ -162,39 +146,25 @@ export function createTransparentTexture(): THREE.Texture {
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)
}
if (capeTextureUrl) {
const capeTexture = await loadTexture(capeTextureUrl, config)
applyCapeTexture(model, capeTexture)
}
return { model, bodyNode, capeModel }
const bodyNode = findBodyNode(model)
return { model, bodyNode }
}
export function disposeCaches(): void {