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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 4882 additions and 1355 deletions

View File

@ -485,13 +485,13 @@ function handleAuxClick(e) {
<ModrinthAppLogo class="h-full w-auto text-contrast pointer-events-none" /> <ModrinthAppLogo class="h-full w-auto text-contrast pointer-events-none" />
<div class="flex items-center gap-1 ml-3"> <div class="flex items-center gap-1 ml-3">
<button <button
class="cursor-pointer p-0 m-0 border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all" class="cursor-pointer p-0 m-0 text-contrast border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
@click="router.back()" @click="router.back()"
> >
<LeftArrowIcon /> <LeftArrowIcon />
</button> </button>
<button <button
class="cursor-pointer p-0 m-0 border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all" class="cursor-pointer p-0 m-0 text-contrast border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
@click="router.forward()" @click="router.forward()"
> >
<RightArrowIcon /> <RightArrowIcon />

View File

@ -2,25 +2,40 @@ import * as THREE from 'three'
import type { Skin, Cape } from '../skins' import type { Skin, Cape } from '../skins'
import { get_normalized_skin_texture, determineModelType } from '../skins' import { get_normalized_skin_texture, determineModelType } from '../skins'
import { reactive } from 'vue' import { reactive } from 'vue'
import { setupSkinModel, disposeCaches } from '@modrinth/utils' import { setupSkinModel, disposeCaches, loadTexture, applyCapeTexture } from '@modrinth/utils'
import { skinPreviewStorage } from '../storage/skin-preview-storage' import { skinPreviewStorage } from '../storage/skin-preview-storage'
import { CapeModel, ClassicPlayerModel, SlimPlayerModel } from '@modrinth/assets' import { headStorage } from '../storage/head-storage'
import { ClassicPlayerModel, SlimPlayerModel } from '@modrinth/assets'
export interface RenderResult { export interface RenderResult {
forwards: string forwards: string
backwards: string backwards: string
} }
export interface RawRenderResult {
forwards: Blob
backwards: Blob
}
class BatchSkinRenderer { class BatchSkinRenderer {
private renderer: THREE.WebGLRenderer private renderer: THREE.WebGLRenderer | null = null
private readonly scene: THREE.Scene private scene: THREE.Scene | null = null
private readonly camera: THREE.PerspectiveCamera private camera: THREE.PerspectiveCamera | null = null
private currentModel: THREE.Group | null = null private currentModel: THREE.Group | null = null
private readonly width: number
private readonly height: number
constructor(width: number = 360, height: number = 504) { constructor(width: number = 360, height: number = 504) {
this.width = width
this.height = height
}
private initializeRenderer(): void {
if (this.renderer) return
const canvas = document.createElement('canvas') const canvas = document.createElement('canvas')
canvas.width = width canvas.width = this.width
canvas.height = height canvas.height = this.height
this.renderer = new THREE.WebGLRenderer({ this.renderer = new THREE.WebGLRenderer({
canvas: canvas, canvas: canvas,
@ -33,10 +48,10 @@ class BatchSkinRenderer {
this.renderer.toneMapping = THREE.NoToneMapping this.renderer.toneMapping = THREE.NoToneMapping
this.renderer.toneMappingExposure = 10.0 this.renderer.toneMappingExposure = 10.0
this.renderer.setClearColor(0x000000, 0) this.renderer.setClearColor(0x000000, 0)
this.renderer.setSize(width, height) this.renderer.setSize(this.width, this.height)
this.scene = new THREE.Scene() this.scene = new THREE.Scene()
this.camera = new THREE.PerspectiveCamera(20, width / height, 0.4, 1000) this.camera = new THREE.PerspectiveCamera(20, this.width / this.height, 0.4, 1000)
const ambientLight = new THREE.AmbientLight(0xffffff, 2) const ambientLight = new THREE.AmbientLight(0xffffff, 2)
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2) const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2)
@ -50,9 +65,12 @@ class BatchSkinRenderer {
textureUrl: string, textureUrl: string,
modelUrl: string, modelUrl: string,
capeUrl?: string, capeUrl?: string,
capeModelUrl?: string, ): Promise<RawRenderResult> {
): Promise<RenderResult> { this.initializeRenderer()
await this.setupModel(modelUrl, textureUrl, capeModelUrl, capeUrl)
this.clearScene()
await this.setupModel(modelUrl, textureUrl, capeUrl)
const headPart = this.currentModel!.getObjectByName('Head') const headPart = this.currentModel!.getObjectByName('Head')
let lookAtTarget: [number, number, number] let lookAtTarget: [number, number, number]
@ -77,35 +95,32 @@ class BatchSkinRenderer {
private async renderView( private async renderView(
cameraPosition: [number, number, number], cameraPosition: [number, number, number],
lookAtPosition: [number, number, number], lookAtPosition: [number, number, number],
): Promise<string> { ): Promise<Blob> {
if (!this.camera || !this.renderer || !this.scene) {
throw new Error('Renderer not initialized')
}
this.camera.position.set(...cameraPosition) this.camera.position.set(...cameraPosition)
this.camera.lookAt(...lookAtPosition) this.camera.lookAt(...lookAtPosition)
this.renderer.render(this.scene, this.camera) this.renderer.render(this.scene, this.camera)
return new Promise<string>((resolve, reject) => { const dataUrl = this.renderer.domElement.toDataURL('image/webp', 0.9)
this.renderer.domElement.toBlob((blob) => { const response = await fetch(dataUrl)
if (blob) { return await response.blob()
const url = URL.createObjectURL(blob)
resolve(url)
} else {
reject(new Error('Failed to create blob from canvas'))
}
}, 'image/png')
})
} }
private async setupModel( private async setupModel(modelUrl: string, textureUrl: string, capeUrl?: string): Promise<void> {
modelUrl: string, if (!this.scene) {
textureUrl: string, throw new Error('Renderer not initialized')
capeModelUrl?: string,
capeUrl?: string,
): Promise<void> {
if (this.currentModel) {
this.scene.remove(this.currentModel)
} }
const { model } = await setupSkinModel(modelUrl, textureUrl, capeModelUrl, capeUrl) const { model } = await setupSkinModel(modelUrl, textureUrl)
if (capeUrl) {
const capeTexture = await loadTexture(capeUrl)
applyCapeTexture(model, capeTexture)
}
const group = new THREE.Group() const group = new THREE.Group()
group.add(model) group.add(model)
@ -116,8 +131,39 @@ class BatchSkinRenderer {
this.currentModel = group this.currentModel = group
} }
private clearScene(): void {
if (!this.scene) return
while (this.scene.children.length > 0) {
const child = this.scene.children[0]
this.scene.remove(child)
if (child instanceof THREE.Mesh) {
if (child.geometry) child.geometry.dispose()
if (child.material) {
if (Array.isArray(child.material)) {
child.material.forEach((material) => material.dispose())
} else {
child.material.dispose()
}
}
}
}
const ambientLight = new THREE.AmbientLight(0xffffff, 2)
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2)
directionalLight.castShadow = true
directionalLight.position.set(2, 4, 3)
this.scene.add(ambientLight)
this.scene.add(directionalLight)
this.currentModel = null
}
public dispose(): void { public dispose(): void {
this.renderer.dispose() if (this.renderer) {
this.renderer.dispose()
}
disposeCaches() disposeCaches()
} }
} }
@ -133,10 +179,25 @@ function getModelUrlForVariant(variant: string): string {
} }
} }
export const map = reactive(new Map<string, RenderResult>()) export const skinBlobUrlMap = reactive(new Map<string, RenderResult>())
export const headMap = reactive(new Map<string, string>()) export const headBlobUrlMap = reactive(new Map<string, string>())
const DEBUG_MODE = false const DEBUG_MODE = false
let sharedRenderer: BatchSkinRenderer | null = null
function getSharedRenderer(): BatchSkinRenderer {
if (!sharedRenderer) {
sharedRenderer = new BatchSkinRenderer()
}
return sharedRenderer
}
export function disposeSharedRenderer(): void {
if (sharedRenderer) {
sharedRenderer.dispose()
sharedRenderer = null
}
}
export async function cleanupUnusedPreviews(skins: Skin[]): Promise<void> { export async function cleanupUnusedPreviews(skins: Skin[]): Promise<void> {
const validKeys = new Set<string>() const validKeys = new Set<string>()
const validHeadKeys = new Set<string>() const validHeadKeys = new Set<string>()
@ -150,7 +211,7 @@ export async function cleanupUnusedPreviews(skins: Skin[]): Promise<void> {
try { try {
await skinPreviewStorage.cleanupInvalidKeys(validKeys) await skinPreviewStorage.cleanupInvalidKeys(validKeys)
await skinPreviewStorage.cleanupInvalidKeys(validHeadKeys) await headStorage.cleanupInvalidKeys(validHeadKeys)
} catch (error) { } catch (error) {
console.warn('Failed to cleanup unused skin previews:', error) console.warn('Failed to cleanup unused skin previews:', error)
} }
@ -229,13 +290,17 @@ export async function generatePlayerHeadBlob(skinUrl: string, size: number = 64)
outputCtx.drawImage(hatCanvas, 0, 0, 8, 8, 0, 0, size, size) outputCtx.drawImage(hatCanvas, 0, 0, 8, 8, 0, 0, size, size)
} }
outputCanvas.toBlob((blob) => { outputCanvas.toBlob(
if (blob) { (blob) => {
resolve(blob) if (blob) {
} else { resolve(blob)
reject(new Error('Failed to create blob from canvas')) } else {
} reject(new Error('Failed to create blob from canvas'))
}, 'image/png') }
},
'image/webp',
0.9,
)
} catch (error) { } catch (error) {
reject(error) reject(error)
} }
@ -252,35 +317,24 @@ export async function generatePlayerHeadBlob(skinUrl: string, size: number = 64)
async function generateHeadRender(skin: Skin): Promise<string> { async function generateHeadRender(skin: Skin): Promise<string> {
const headKey = `${skin.texture_key}-head` const headKey = `${skin.texture_key}-head`
if (headMap.has(headKey)) { if (headBlobUrlMap.has(headKey)) {
if (DEBUG_MODE) { if (DEBUG_MODE) {
const url = headMap.get(headKey)! const url = headBlobUrlMap.get(headKey)!
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
headMap.delete(headKey) headBlobUrlMap.delete(headKey)
} else { } else {
return headMap.get(headKey)! return headBlobUrlMap.get(headKey)!
} }
} }
try {
const cached = await skinPreviewStorage.retrieve(headKey)
if (cached && typeof cached === 'string') {
headMap.set(headKey, cached)
return cached
}
} catch (error) {
console.warn('Failed to retrieve cached head render:', error)
}
const skinUrl = await get_normalized_skin_texture(skin) const skinUrl = await get_normalized_skin_texture(skin)
const headBlob = await generatePlayerHeadBlob(skinUrl, 64) const headBlob = await generatePlayerHeadBlob(skinUrl, 64)
const headUrl = URL.createObjectURL(headBlob) const headUrl = URL.createObjectURL(headBlob)
headMap.set(headKey, headUrl) headBlobUrlMap.set(headKey, headUrl)
try { try {
// @ts-expect-error - skinPreviewStorage.store expects a RenderResult, but we are storing a string url. await headStorage.store(headKey, headBlob)
await skinPreviewStorage.store(headKey, headUrl)
} catch (error) { } catch (error) {
console.warn('Failed to store head render in persistent storage:', error) console.warn('Failed to store head render in persistent storage:', error)
} }
@ -293,30 +347,49 @@ export async function getPlayerHeadUrl(skin: Skin): Promise<string> {
} }
export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise<void> { export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise<void> {
const renderer = new BatchSkinRenderer()
try { try {
const skinKeys = skins.map(
(skin) => `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`,
)
const headKeys = skins.map((skin) => `${skin.texture_key}-head`)
const [cachedSkinPreviews, cachedHeadPreviews] = await Promise.all([
skinPreviewStorage.batchRetrieve(skinKeys),
headStorage.batchRetrieve(headKeys),
])
for (let i = 0; i < skins.length; i++) {
const skinKey = skinKeys[i]
const headKey = headKeys[i]
const rawCached = cachedSkinPreviews[skinKey]
if (rawCached) {
const cached: RenderResult = {
forwards: URL.createObjectURL(rawCached.forwards),
backwards: URL.createObjectURL(rawCached.backwards),
}
skinBlobUrlMap.set(skinKey, cached)
}
const cachedHead = cachedHeadPreviews[headKey]
if (cachedHead) {
headBlobUrlMap.set(headKey, URL.createObjectURL(cachedHead))
}
}
for (const skin of skins) { for (const skin of skins) {
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}` const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
if (map.has(key)) { if (skinBlobUrlMap.has(key)) {
if (DEBUG_MODE) { if (DEBUG_MODE) {
const result = map.get(key)! const result = skinBlobUrlMap.get(key)!
URL.revokeObjectURL(result.forwards) URL.revokeObjectURL(result.forwards)
URL.revokeObjectURL(result.backwards) URL.revokeObjectURL(result.backwards)
map.delete(key) skinBlobUrlMap.delete(key)
} else continue } else continue
} }
try { const renderer = getSharedRenderer()
const cached = await skinPreviewStorage.retrieve(key)
if (cached) {
map.set(key, cached)
continue
}
} catch (error) {
console.warn('Failed to retrieve cached skin preview:', error)
}
let variant = skin.variant let variant = skin.variant
if (variant === 'UNKNOWN') { if (variant === 'UNKNOWN') {
@ -330,25 +403,35 @@ export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promis
const modelUrl = getModelUrlForVariant(variant) const modelUrl = getModelUrlForVariant(variant)
const cape: Cape | undefined = capes.find((_cape) => _cape.id === skin.cape_id) const cape: Cape | undefined = capes.find((_cape) => _cape.id === skin.cape_id)
const renderResult = await renderer.renderSkin( const rawRenderResult = await renderer.renderSkin(
await get_normalized_skin_texture(skin), await get_normalized_skin_texture(skin),
modelUrl, modelUrl,
cape?.texture, cape?.texture,
CapeModel,
) )
map.set(key, renderResult) const renderResult: RenderResult = {
forwards: URL.createObjectURL(rawRenderResult.forwards),
backwards: URL.createObjectURL(rawRenderResult.backwards),
}
skinBlobUrlMap.set(key, renderResult)
try { try {
await skinPreviewStorage.store(key, renderResult) await skinPreviewStorage.store(key, rawRenderResult)
} catch (error) { } catch (error) {
console.warn('Failed to store skin preview in persistent storage:', error) console.warn('Failed to store skin preview in persistent storage:', error)
} }
await generateHeadRender(skin) const headKey = `${skin.texture_key}-head`
if (!headBlobUrlMap.has(headKey)) {
await generateHeadRender(skin)
}
} }
} finally { } finally {
renderer.dispose() disposeSharedRenderer()
await cleanupUnusedPreviews(skins) await cleanupUnusedPreviews(skins)
await skinPreviewStorage.debugCalculateStorage()
await headStorage.debugCalculateStorage()
} }
} }

View File

@ -0,0 +1,229 @@
interface StoredHead {
blob: Blob
timestamp: number
}
export class HeadStorage {
private dbName = 'head-storage'
private version = 1
private db: IDBDatabase | null = null
async init(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version)
request.onerror = () => reject(request.error)
request.onsuccess = () => {
this.db = request.result
resolve()
}
request.onupgradeneeded = () => {
const db = request.result
if (!db.objectStoreNames.contains('heads')) {
db.createObjectStore('heads')
}
}
})
}
async store(key: string, blob: Blob): Promise<void> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readwrite')
const store = transaction.objectStore('heads')
const storedHead: StoredHead = {
blob,
timestamp: Date.now(),
}
return new Promise((resolve, reject) => {
const request = store.put(storedHead, key)
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
})
}
async retrieve(key: string): Promise<string | null> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readonly')
const store = transaction.objectStore('heads')
return new Promise((resolve, reject) => {
const request = store.get(key)
request.onsuccess = () => {
const result = request.result as StoredHead | undefined
if (!result) {
resolve(null)
return
}
const url = URL.createObjectURL(result.blob)
resolve(url)
}
request.onerror = () => reject(request.error)
})
}
async batchRetrieve(keys: string[]): Promise<Record<string, Blob | null>> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readonly')
const store = transaction.objectStore('heads')
const results: Record<string, Blob | null> = {}
return new Promise((resolve, _reject) => {
let completedRequests = 0
if (keys.length === 0) {
resolve(results)
return
}
for (const key of keys) {
const request = store.get(key)
request.onsuccess = () => {
const result = request.result as StoredHead | undefined
if (result) {
results[key] = result.blob
} else {
results[key] = null
}
completedRequests++
if (completedRequests === keys.length) {
resolve(results)
}
}
request.onerror = () => {
results[key] = null
completedRequests++
if (completedRequests === keys.length) {
resolve(results)
}
}
}
})
}
async cleanupInvalidKeys(validKeys: Set<string>): Promise<number> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readwrite')
const store = transaction.objectStore('heads')
let deletedCount = 0
return new Promise((resolve, reject) => {
const request = store.openCursor()
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result
if (cursor) {
const key = cursor.primaryKey as string
if (!validKeys.has(key)) {
const deleteRequest = cursor.delete()
deleteRequest.onsuccess = () => {
deletedCount++
}
deleteRequest.onerror = () => {
console.warn('Failed to delete invalid head entry:', key)
}
}
cursor.continue()
} else {
resolve(deletedCount)
}
}
request.onerror = () => reject(request.error)
})
}
async debugCalculateStorage(): Promise<void> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readonly')
const store = transaction.objectStore('heads')
let totalSize = 0
let count = 0
const entries: Array<{ key: string; size: number }> = []
return new Promise((resolve, reject) => {
const request = store.openCursor()
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result
if (cursor) {
const key = cursor.primaryKey as string
const value = cursor.value as StoredHead
const entrySize = value.blob.size
totalSize += entrySize
count++
entries.push({
key,
size: entrySize,
})
cursor.continue()
} else {
console.group('🗄️ Head Storage Debug Info')
console.log(`Total entries: ${count}`)
console.log(`Total size: ${(totalSize / 1024 / 1024).toFixed(2)} MB`)
console.log(
`Average size per entry: ${count > 0 ? (totalSize / count / 1024).toFixed(2) : 0} KB`,
)
if (entries.length > 0) {
const sortedEntries = entries.sort((a, b) => b.size - a.size)
console.log(
'Largest entry:',
sortedEntries[0].key,
'(' + (sortedEntries[0].size / 1024).toFixed(2) + ' KB)',
)
console.log(
'Smallest entry:',
sortedEntries[sortedEntries.length - 1].key,
'(' + (sortedEntries[sortedEntries.length - 1].size / 1024).toFixed(2) + ' KB)',
)
}
console.groupEnd()
resolve()
}
}
request.onerror = () => reject(request.error)
})
}
async clearAll(): Promise<void> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readwrite')
const store = transaction.objectStore('heads')
return new Promise((resolve, reject) => {
const request = store.clear()
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
})
}
}
export const headStorage = new HeadStorage()

View File

@ -1,4 +1,4 @@
import type { RenderResult } from '../rendering/batch-skin-renderer' import type { RawRenderResult } from '../rendering/batch-skin-renderer'
interface StoredPreview { interface StoredPreview {
forwards: Blob forwards: Blob
@ -30,18 +30,15 @@ export class SkinPreviewStorage {
}) })
} }
async store(key: string, result: RenderResult): Promise<void> { async store(key: string, result: RawRenderResult): Promise<void> {
if (!this.db) await this.init() if (!this.db) await this.init()
const forwardsBlob = await fetch(result.forwards).then((r) => r.blob())
const backwardsBlob = await fetch(result.backwards).then((r) => r.blob())
const transaction = this.db!.transaction(['previews'], 'readwrite') const transaction = this.db!.transaction(['previews'], 'readwrite')
const store = transaction.objectStore('previews') const store = transaction.objectStore('previews')
const storedPreview: StoredPreview = { const storedPreview: StoredPreview = {
forwards: forwardsBlob, forwards: result.forwards,
backwards: backwardsBlob, backwards: result.backwards,
timestamp: Date.now(), timestamp: Date.now(),
} }
@ -53,7 +50,7 @@ export class SkinPreviewStorage {
}) })
} }
async retrieve(key: string): Promise<RenderResult | null> { async retrieve(key: string): Promise<RawRenderResult | null> {
if (!this.db) await this.init() if (!this.db) await this.init()
const transaction = this.db!.transaction(['previews'], 'readonly') const transaction = this.db!.transaction(['previews'], 'readonly')
@ -70,14 +67,56 @@ export class SkinPreviewStorage {
return return
} }
const forwards = URL.createObjectURL(result.forwards) resolve({ forwards: result.forwards, backwards: result.backwards })
const backwards = URL.createObjectURL(result.backwards)
resolve({ forwards, backwards })
} }
request.onerror = () => reject(request.error) request.onerror = () => reject(request.error)
}) })
} }
async batchRetrieve(keys: string[]): Promise<Record<string, RawRenderResult | null>> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['previews'], 'readonly')
const store = transaction.objectStore('previews')
const results: Record<string, RawRenderResult | null> = {}
return new Promise((resolve, _reject) => {
let completedRequests = 0
if (keys.length === 0) {
resolve(results)
return
}
for (const key of keys) {
const request = store.get(key)
request.onsuccess = () => {
const result = request.result as StoredPreview | undefined
if (result) {
results[key] = { forwards: result.forwards, backwards: result.backwards }
} else {
results[key] = null
}
completedRequests++
if (completedRequests === keys.length) {
resolve(results)
}
}
request.onerror = () => {
results[key] = null
completedRequests++
if (completedRequests === keys.length) {
resolve(results)
}
}
}
})
}
async cleanupInvalidKeys(validKeys: Set<string>): Promise<number> { async cleanupInvalidKeys(validKeys: Set<string>): Promise<number> {
if (!this.db) await this.init() if (!this.db) await this.init()
@ -113,6 +152,67 @@ export class SkinPreviewStorage {
request.onerror = () => reject(request.error) request.onerror = () => reject(request.error)
}) })
} }
async debugCalculateStorage(): Promise<void> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['previews'], 'readonly')
const store = transaction.objectStore('previews')
let totalSize = 0
let count = 0
const entries: Array<{ key: string; size: number }> = []
return new Promise((resolve, reject) => {
const request = store.openCursor()
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result
if (cursor) {
const key = cursor.primaryKey as string
const value = cursor.value as StoredPreview
const entrySize = value.forwards.size + value.backwards.size
totalSize += entrySize
count++
entries.push({
key,
size: entrySize,
})
cursor.continue()
} else {
console.group('🗄️ Skin Preview Storage Debug Info')
console.log(`Total entries: ${count}`)
console.log(`Total size: ${(totalSize / 1024 / 1024).toFixed(2)} MB`)
console.log(
`Average size per entry: ${count > 0 ? (totalSize / count / 1024).toFixed(2) : 0} KB`,
)
if (entries.length > 0) {
const sortedEntries = entries.sort((a, b) => b.size - a.size)
console.log(
'Largest entry:',
sortedEntries[0].key,
'(' + (sortedEntries[0].size / 1024).toFixed(2) + ' KB)',
)
console.log(
'Smallest entry:',
sortedEntries[sortedEntries.length - 1].key,
'(' + (sortedEntries[sortedEntries.length - 1].size / 1024).toFixed(2) + ' KB)',
)
}
console.groupEnd()
resolve()
}
}
request.onerror = () => reject(request.error)
})
}
} }
export const skinPreviewStorage = new SkinPreviewStorage() export const skinPreviewStorage = new SkinPreviewStorage()

View File

@ -38,7 +38,7 @@ import {
import { get as getSettings } from '@/helpers/settings.ts' import { get as getSettings } from '@/helpers/settings.ts'
import { get_default_user, login as login_flow, users } from '@/helpers/auth' import { get_default_user, login as login_flow, users } from '@/helpers/auth'
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts' import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
import { generateSkinPreviews, map } from '@/helpers/rendering/batch-skin-renderer.ts' import { generateSkinPreviews, skinBlobUrlMap } from '@/helpers/rendering/batch-skin-renderer.ts'
import { handleSevereError } from '@/store/error' import { handleSevereError } from '@/store/error'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
import type AccountsCard from '@/components/ui/AccountsCard.vue' import type AccountsCard from '@/components/ui/AccountsCard.vue'
@ -215,7 +215,7 @@ async function loadCurrentUser() {
function getBakedSkinTextures(skin: Skin): RenderResult | undefined { function getBakedSkinTextures(skin: Skin): RenderResult | undefined {
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}` const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
return map.get(key) return skinBlobUrlMap.get(key)
} }
async function login() { async function login() {

View File

@ -83,7 +83,7 @@ export const TwitterIcon = _TwitterIcon
export const WindowsIcon = _WindowsIcon export const WindowsIcon = _WindowsIcon
export const YouTubeIcon = _YouTubeIcon 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 ClassicPlayerModel } from './models/classic-player.gltf?url'
export { default as SlimPlayerModel } from './models/slim-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:*", "@modrinth/utils": "workspace:*",
"@tresjs/cientos": "^4.3.0", "@tresjs/cientos": "^4.3.0",
"@tresjs/core": "^4.3.4", "@tresjs/core": "^4.3.4",
"@tresjs/post-processing": "^2.4.0",
"@types/markdown-it": "^14.1.1", "@types/markdown-it": "^14.1.1",
"@types/three": "^0.172.0", "@types/three": "^0.172.0",
"@vintl/how-ago": "^3.0.1", "@vintl/how-ago": "^3.0.1",
@ -41,6 +42,7 @@
"floating-vue": "^5.2.2", "floating-vue": "^5.2.2",
"highlight.js": "^11.9.0", "highlight.js": "^11.9.0",
"markdown-it": "^13.0.2", "markdown-it": "^13.0.2",
"postprocessing": "^6.37.6",
"qrcode.vue": "^3.4.1", "qrcode.vue": "^3.4.1",
"three": "^0.172.0", "three": "^0.172.0",
"vue-multiselect": "3.0.0", "vue-multiselect": "3.0.0",

View File

@ -23,7 +23,7 @@
<TresCanvas <TresCanvas
shadows shadows
alpha alpha
:antialias="antialias" :antialias="true"
:renderer-options="{ :renderer-options="{
outputColorSpace: THREE.SRGBColorSpace, outputColorSpace: THREE.SRGBColorSpace,
toneMapping: THREE.NoToneMapping, toneMapping: THREE.NoToneMapping,
@ -46,36 +46,39 @@
<primitive v-if="scene" :object="scene" /> <primitive v-if="scene" :object="scene" />
</Group> </Group>
<TresMesh <!-- <TresMesh
:position="[0, -0.095 * scale, 2]" :position="[0, -0.095 * scale, 2]"
:rotation="[-Math.PI / 2, 0, 0]" :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]" /> <TresCircleGeometry :args="[1, 128]" />
<TresMeshBasicMaterial <TresMeshBasicMaterial
color="#000000" color="#000000"
:opacity="0.2" :opacity="0.5"
transparent transparent
:depth-write="false" :depth-write="false"
/> />
</TresMesh> </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>
</Group> </Group>
</Suspense> </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 <TresPerspectiveCamera
:make-default.camel="true" :make-default.camel="true"
:fov="fov" :fov="fov"
@ -101,6 +104,7 @@
import * as THREE from 'three' import * as THREE from 'three'
import { useGLTF } from '@tresjs/cientos' import { useGLTF } from '@tresjs/cientos'
import { useTexture, TresCanvas, useRenderLoop } from '@tresjs/core' import { useTexture, TresCanvas, useRenderLoop } from '@tresjs/core'
import { EffectComposerPmndrs, FXAAPmndrs } from '@tresjs/post-processing'
import { import {
shallowRef, shallowRef,
ref, ref,
@ -115,13 +119,11 @@ import {
import { import {
applyTexture, applyTexture,
applyCapeTexture, applyCapeTexture,
attachCapeToBody,
findBodyNode,
createTransparentTexture, createTransparentTexture,
loadTexture as loadSkinTexture, loadTexture as loadSkinTexture,
} from '@modrinth/utils' } from '@modrinth/utils'
import { useDynamicFontSize } from '../../composables' import { useDynamicFontSize } from '../../composables'
import { CapeModel, ClassicPlayerModel, SlimPlayerModel } from '@modrinth/assets' import { ClassicPlayerModel, SlimPlayerModel } from '@modrinth/assets'
interface AnimationConfig { interface AnimationConfig {
baseAnimation: string baseAnimation: string
@ -136,7 +138,6 @@ const props = withDefaults(
capeSrc?: string capeSrc?: string
variant?: 'SLIM' | 'CLASSIC' | 'UNKNOWN' variant?: 'SLIM' | 'CLASSIC' | 'UNKNOWN'
nametag?: string nametag?: string
antialias?: boolean
scale?: number scale?: number
fov?: number fov?: number
initialRotation?: number initialRotation?: number
@ -144,7 +145,6 @@ const props = withDefaults(
}>(), }>(),
{ {
variant: 'CLASSIC', variant: 'CLASSIC',
antialias: false,
scale: 1, scale: 1,
fov: 40, fov: 40,
capeSrc: undefined, capeSrc: undefined,
@ -177,9 +177,6 @@ const selectedModelSrc = computed(() =>
) )
const scene = shallowRef<THREE.Object3D | null>(null) 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 lastCapeSrc = ref<string | undefined>(undefined)
const texture = shallowRef<THREE.Texture | null>(null) const texture = shallowRef<THREE.Texture | null>(null)
const capeTexture = 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 randomAnimationTimer = ref<number | null>(null)
const lastRandomAnimation = ref<string>('') 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) const { baseAnimation, randomAnimations } = toRefs(props.animationConfig)
function initializeAnimations(loadedScene: THREE.Object3D, clips: THREE.AnimationClip[]) { function initializeAnimations(loadedScene: THREE.Object3D, clips: THREE.AnimationClip[]) {
@ -400,11 +445,9 @@ async function loadModel(src: string) {
if (texture.value) { if (texture.value) {
applyTexture(scene.value, texture.value) applyTexture(scene.value, texture.value)
texture.value.needsUpdate = true
} }
bodyNode.value = findBodyNode(loadedScene) applyCapeTexture(scene.value, capeTexture.value, transparentTexture)
capeAttached.value = false
if (animations && animations.length > 0) { if (animations && animations.length > 0) {
initializeAnimations(loadedScene, animations) 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) { async function loadAndApplyTexture(src: string) {
if (!src) return null if (!src) return null
@ -465,25 +492,9 @@ async function loadAndApplyCapeTexture(src: string | undefined) {
capeTexture.value = null capeTexture.value = null
} }
if (capeScene.value) { if (scene.value) {
applyCapeTexture(capeScene.value, capeTexture.value, transparentTexture) 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]) const centre = ref<[number, number, number]>([0, 1, 0])
@ -539,39 +550,6 @@ function onCanvasClick() {
hasDragged.value = false 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(selectedModelSrc, (src) => loadModel(src))
watch( watch(
() => props.textureSrc, () => props.textureSrc,
@ -587,7 +565,6 @@ watch(
watch( watch(
() => props.capeSrc, () => props.capeSrc,
async (newCapeSrc) => { async (newCapeSrc) => {
await loadCape()
await loadAndApplyCapeTexture(newCapeSrc) await loadAndApplyCapeTexture(newCapeSrc)
}, },
) )
@ -619,8 +596,6 @@ onBeforeMount(async () => {
if (props.capeSrc) { if (props.capeSrc) {
await loadAndApplyCapeTexture(props.capeSrc) await loadAndApplyCapeTexture(props.capeSrc)
} }
await loadCape()
} catch (error) { } catch (error) {
console.error('Failed to initialize skin preview:', 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) => { model.traverse((child) => {
if ((child as THREE.Mesh).isMesh) { if ((child as THREE.Mesh).isMesh) {
const mesh = child as THREE.Mesh const mesh = child as THREE.Mesh
// Skip cape meshes
if (mesh.name === 'Cape') return
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material] const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
materials.forEach((mat: THREE.Material) => { materials.forEach((mat: THREE.Material) => {
if (mat instanceof THREE.MeshStandardMaterial) { if (mat instanceof THREE.MeshStandardMaterial) {
mat.map = texture if (mat.name !== 'cape') {
mat.metalness = 0 mat.map = texture
mat.color.set(0xffffff) mat.metalness = 0
mat.toneMapped = false mat.color.set(0xffffff)
mat.flatShading = true mat.toneMapped = false
mat.roughness = 1 mat.flatShading = true
mat.needsUpdate = true mat.roughness = 1
mat.depthTest = true mat.needsUpdate = true
mat.side = THREE.DoubleSide mat.depthTest = true
mat.alphaTest = 0.1 mat.side = THREE.DoubleSide
mat.depthWrite = true mat.alphaTest = 0.1
mat.depthWrite = true
}
} }
}) })
} }
@ -96,41 +94,27 @@ export function applyCapeTexture(
materials.forEach((mat: THREE.Material) => { materials.forEach((mat: THREE.Material) => {
if (mat instanceof THREE.MeshStandardMaterial) { if (mat instanceof THREE.MeshStandardMaterial) {
mat.map = texture || transparentTexture || null if (mat.name === 'cape') {
mat.transparent = transparentTexture ? true : false mat.map = texture || transparentTexture || null
mat.metalness = 0 mat.transparent = !texture || transparentTexture ? true : false
mat.color.set(0xffffff) mat.metalness = 0
mat.toneMapped = false mat.color.set(0xffffff)
mat.flatShading = true mat.toneMapped = false
mat.roughness = 1 mat.flatShading = true
mat.needsUpdate = true mat.roughness = 1
mat.depthTest = true mat.needsUpdate = true
mat.depthWrite = true mat.depthTest = true
mat.side = THREE.DoubleSide mat.depthWrite = true
mat.alphaTest = 0.1 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 { export function findBodyNode(model: THREE.Object3D): THREE.Object3D | null {
let bodyNode: THREE.Object3D | null = null let bodyNode: THREE.Object3D | null = null
@ -162,39 +146,25 @@ export function createTransparentTexture(): THREE.Texture {
export async function setupSkinModel( export async function setupSkinModel(
modelUrl: string, modelUrl: string,
textureUrl: string, textureUrl: string,
capeModelUrl?: string,
capeTextureUrl?: string, capeTextureUrl?: string,
config: SkinRendererConfig = {}, config: SkinRendererConfig = {},
): Promise<{ ): Promise<{
model: THREE.Object3D model: THREE.Object3D
bodyNode: THREE.Object3D | null 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 [gltf, texture] = await Promise.all([loadModel(modelUrl), loadTexture(textureUrl, config)])
const model = gltf.scene.clone() const model = gltf.scene.clone()
applyTexture(model, texture) applyTexture(model, texture)
const bodyNode = findBodyNode(model) if (capeTextureUrl) {
let capeModel: THREE.Object3D | null = null const capeTexture = await loadTexture(capeTextureUrl, config)
applyCapeTexture(model, capeTexture)
// 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 } const bodyNode = findBodyNode(model)
return { model, bodyNode }
} }
export function disposeCaches(): void { export function disposeCaches(): void {

46
pnpm-lock.yaml generated
View File

@ -487,6 +487,9 @@ importers:
'@tresjs/core': '@tresjs/core':
specifier: ^4.3.4 specifier: ^4.3.4
version: 4.3.6(three@0.172.0)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4)) version: 4.3.6(three@0.172.0)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4))
'@tresjs/post-processing':
specifier: ^2.4.0
version: 2.4.0(@tresjs/core@4.3.6(three@0.172.0)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4)))(three@0.172.0)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4))
'@types/markdown-it': '@types/markdown-it':
specifier: ^14.1.1 specifier: ^14.1.1
version: 14.1.1 version: 14.1.1
@ -514,6 +517,9 @@ importers:
markdown-it: markdown-it:
specifier: ^13.0.2 specifier: ^13.0.2
version: 13.0.2 version: 13.0.2
postprocessing:
specifier: ^6.37.6
version: 6.37.6(three@0.172.0)
qrcode.vue: qrcode.vue:
specifier: ^3.4.1 specifier: ^3.4.1
version: 3.4.1(vue@3.5.13(typescript@5.5.4)) version: 3.4.1(vue@3.5.13(typescript@5.5.4))
@ -2562,6 +2568,13 @@ packages:
three: '>=0.133' three: '>=0.133'
vue: '>=3.4' vue: '>=3.4'
'@tresjs/post-processing@2.4.0':
resolution: {integrity: sha512-4l18DTLkn0Y/abyn+FD/gSJ6/SC01oXn+/qPgUxMgxZ8zGaw4PZbOi4yorhbSbOTp0gO4D1X7lNOvNUokqJwFw==}
peerDependencies:
'@tresjs/core': '>=4.0'
three: '>=0.169'
vue: '>=3.4'
'@trysound/sax@0.2.0': '@trysound/sax@0.2.0':
resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
@ -6569,6 +6582,11 @@ packages:
posthog-js@1.158.2: posthog-js@1.158.2:
resolution: {integrity: sha512-ovb7GHHRNDf6vmuL+8lbDukewzDzQlLZXg3d475hrfHSBgidYeTxtLGtoBcUz4x6558BLDFjnSip+f3m4rV9LA==} resolution: {integrity: sha512-ovb7GHHRNDf6vmuL+8lbDukewzDzQlLZXg3d475hrfHSBgidYeTxtLGtoBcUz4x6558BLDFjnSip+f3m4rV9LA==}
postprocessing@6.37.6:
resolution: {integrity: sha512-KrdKLf1257RkoIk3z3nhRS0aToKrX2xJgtR0lbnOQUjd+1I4GVNv1gQYsQlfRglvEXjpzrwqOA5fXfoDBimadg==}
peerDependencies:
three: '>= 0.157.0 < 0.179.0'
potpack@1.0.2: potpack@1.0.2:
resolution: {integrity: sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==} resolution: {integrity: sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==}
@ -9963,7 +9981,7 @@ snapshots:
'@nuxtjs/eslint-config-typescript@12.1.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4)': '@nuxtjs/eslint-config-typescript@12.1.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4)':
dependencies: dependencies:
'@nuxtjs/eslint-config': 12.0.0(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-plugin-import@2.29.1)(eslint@9.13.0(jiti@2.4.2)))(eslint@9.13.0(jiti@2.4.2)) '@nuxtjs/eslint-config': 12.0.0(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.2))
'@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4) '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4)
'@typescript-eslint/parser': 6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4) '@typescript-eslint/parser': 6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4)
eslint: 9.13.0(jiti@2.4.2) eslint: 9.13.0(jiti@2.4.2)
@ -9976,10 +9994,10 @@ snapshots:
- supports-color - supports-color
- typescript - typescript
'@nuxtjs/eslint-config@12.0.0(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-plugin-import@2.29.1)(eslint@9.13.0(jiti@2.4.2)))(eslint@9.13.0(jiti@2.4.2))': '@nuxtjs/eslint-config@12.0.0(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.2))':
dependencies: dependencies:
eslint: 9.13.0(jiti@2.4.2) eslint: 9.13.0(jiti@2.4.2)
eslint-config-standard: 17.1.0(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.2)))(eslint-plugin-n@15.7.0(eslint@9.13.0(jiti@2.4.2)))(eslint-plugin-promise@6.4.0(eslint@9.13.0(jiti@2.4.2)))(eslint@9.13.0(jiti@2.4.2)) eslint-config-standard: 17.1.0(eslint-plugin-import@2.29.1)(eslint-plugin-n@15.7.0(eslint@9.13.0(jiti@2.4.2)))(eslint-plugin-promise@6.4.0(eslint@9.13.0(jiti@2.4.2)))(eslint@9.13.0(jiti@2.4.2))
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.2)) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.2))
eslint-plugin-n: 15.7.0(eslint@9.13.0(jiti@2.4.2)) eslint-plugin-n: 15.7.0(eslint@9.13.0(jiti@2.4.2))
eslint-plugin-node: 11.1.0(eslint@9.13.0(jiti@2.4.2)) eslint-plugin-node: 11.1.0(eslint@9.13.0(jiti@2.4.2))
@ -10553,6 +10571,16 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- typescript - typescript
'@tresjs/post-processing@2.4.0(@tresjs/core@4.3.6(three@0.172.0)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4)))(three@0.172.0)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4))':
dependencies:
'@tresjs/core': 4.3.6(three@0.172.0)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4))
'@vueuse/core': 12.8.2(typescript@5.5.4)
postprocessing: 6.37.6(three@0.172.0)
three: 0.172.0
vue: 3.5.13(typescript@5.5.4)
transitivePeerDependencies:
- typescript
'@trysound/sax@0.2.0': {} '@trysound/sax@0.2.0': {}
'@tweenjs/tween.js@23.1.3': {} '@tweenjs/tween.js@23.1.3': {}
@ -12753,10 +12781,10 @@ snapshots:
dependencies: dependencies:
eslint: 9.13.0(jiti@2.4.2) eslint: 9.13.0(jiti@2.4.2)
eslint-config-standard@17.1.0(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.2)))(eslint-plugin-n@15.7.0(eslint@9.13.0(jiti@2.4.2)))(eslint-plugin-promise@6.4.0(eslint@9.13.0(jiti@2.4.2)))(eslint@9.13.0(jiti@2.4.2)): eslint-config-standard@17.1.0(eslint-plugin-import@2.29.1)(eslint-plugin-n@15.7.0(eslint@9.13.0(jiti@2.4.2)))(eslint-plugin-promise@6.4.0(eslint@9.13.0(jiti@2.4.2)))(eslint@9.13.0(jiti@2.4.2)):
dependencies: dependencies:
eslint: 9.13.0(jiti@2.4.2) eslint: 9.13.0(jiti@2.4.2)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.2)) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.16.1(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint@9.13.0(jiti@2.4.2))
eslint-plugin-n: 15.7.0(eslint@9.13.0(jiti@2.4.2)) eslint-plugin-n: 15.7.0(eslint@9.13.0(jiti@2.4.2))
eslint-plugin-promise: 6.4.0(eslint@9.13.0(jiti@2.4.2)) eslint-plugin-promise: 6.4.0(eslint@9.13.0(jiti@2.4.2))
@ -12782,7 +12810,7 @@ snapshots:
debug: 4.4.0(supports-color@9.4.0) debug: 4.4.0(supports-color@9.4.0)
enhanced-resolve: 5.17.1 enhanced-resolve: 5.17.1
eslint: 9.13.0(jiti@2.4.2) eslint: 9.13.0(jiti@2.4.2)
eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-plugin-import@2.29.1)(eslint@9.13.0(jiti@2.4.2)))(eslint@9.13.0(jiti@2.4.2)) eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.2))
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.16.1(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint@9.13.0(jiti@2.4.2)) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.16.1(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint@9.13.0(jiti@2.4.2))
fast-glob: 3.3.2 fast-glob: 3.3.2
get-tsconfig: 4.7.5 get-tsconfig: 4.7.5
@ -12794,7 +12822,7 @@ snapshots:
- eslint-import-resolver-webpack - eslint-import-resolver-webpack
- supports-color - supports-color
eslint-module-utils@2.8.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-plugin-import@2.29.1)(eslint@9.13.0(jiti@2.4.2)))(eslint@9.13.0(jiti@2.4.2)): eslint-module-utils@2.8.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.2)):
dependencies: dependencies:
debug: 3.2.7 debug: 3.2.7
optionalDependencies: optionalDependencies:
@ -15885,6 +15913,10 @@ snapshots:
preact: 10.23.2 preact: 10.23.2
web-vitals: 4.2.3 web-vitals: 4.2.3
postprocessing@6.37.6(three@0.172.0):
dependencies:
three: 0.172.0
potpack@1.0.2: {} potpack@1.0.2: {}
preact@10.23.2: {} preact@10.23.2: {}