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:
parent
3c79607d1f
commit
cb72d2ac80
@ -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 />
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
229
apps/app-frontend/src/helpers/storage/head-storage.ts
Normal file
229
apps/app-frontend/src/helpers/storage/head-storage.ts
Normal 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()
|
||||||
@ -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()
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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'
|
||||||
|
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
BIN
packages/assets/models/classic-player.fbx
Normal file
BIN
packages/assets/models/classic-player.fbx
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
BIN
packages/assets/models/slim-player.fbx
Normal file
BIN
packages/assets/models/slim-player.fbx
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
@ -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",
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
46
pnpm-lock.yaml
generated
@ -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: {}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user