feat:移除了弹窗,服务器添加sls
This commit is contained in:
commit
6a295f9a7a
4082 files changed
+1322534
No files matched your search
@@ -0,0 +1,167 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
import type { Skin } from '../skins'
|
||||
import { get_normalized_skin_texture } from '../skins'
|
||||
import { headStorage } from '../storage/head-storage'
|
||||
import { skinPreviewStorage } from '../storage/skin-preview-storage'
|
||||
|
||||
export interface RenderResult {
|
||||
forwards: string
|
||||
}
|
||||
|
||||
export interface RawRenderResult {
|
||||
forwards: Blob
|
||||
}
|
||||
|
||||
export const skinBlobUrlMap = reactive(new Map<string, RenderResult>())
|
||||
export const headBlobUrlMap = reactive(new Map<string, string>())
|
||||
const headRenderPromises = new Map<string, Promise<string>>()
|
||||
|
||||
const DEBUG_MODE = false
|
||||
const HEAD_RENDER_VERSION = 8
|
||||
|
||||
export function getHeadRenderKey(textureKey: string): string {
|
||||
return `${textureKey}-head-v${HEAD_RENDER_VERSION}`
|
||||
}
|
||||
|
||||
export async function cleanupUnusedPreviews(skins: Skin[]): Promise<void> {
|
||||
const validKeys = new Set<string>()
|
||||
const validHeadKeys = new Set<string>()
|
||||
|
||||
for (const skin of skins) {
|
||||
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
|
||||
const headKey = getHeadRenderKey(skin.texture_key)
|
||||
validKeys.add(key)
|
||||
validHeadKeys.add(headKey)
|
||||
}
|
||||
|
||||
try {
|
||||
await skinPreviewStorage.cleanupInvalidKeys(validKeys)
|
||||
await headStorage.cleanupInvalidKeys(validHeadKeys)
|
||||
} catch (error) {
|
||||
console.warn('Failed to cleanup unused skin previews:', error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function generatePlayerHeadBlob(skinUrl: string): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
|
||||
img.onload = () => {
|
||||
try {
|
||||
if (img.width !== 64 || img.height !== 64) {
|
||||
throw new Error(`Expected normalized 64x64 skin texture, got ${img.width}x${img.height}`)
|
||||
}
|
||||
|
||||
const outerLayerCanvas = document.createElement('canvas')
|
||||
outerLayerCanvas.width = 8
|
||||
outerLayerCanvas.height = 8
|
||||
const outerLayerCtx = outerLayerCanvas.getContext('2d')
|
||||
|
||||
if (!outerLayerCtx) {
|
||||
throw new Error('Could not get 2D context for outer skin layer')
|
||||
}
|
||||
|
||||
outerLayerCtx.drawImage(img, 40, 8, 8, 8, 0, 0, 8, 8)
|
||||
const hasOuterLayer = outerLayerCtx
|
||||
.getImageData(0, 0, 8, 8)
|
||||
.data.some((channel, index) => index % 4 === 3 && channel > 0)
|
||||
const outputSize = hasOuterLayer ? 72 : 64
|
||||
const baseOffset = hasOuterLayer ? 4 : 0
|
||||
const outputCanvas = document.createElement('canvas')
|
||||
outputCanvas.width = outputSize
|
||||
outputCanvas.height = outputSize
|
||||
const outputCtx = outputCanvas.getContext('2d')
|
||||
|
||||
if (!outputCtx) {
|
||||
throw new Error('Could not get 2D context from output canvas')
|
||||
}
|
||||
|
||||
outputCtx.imageSmoothingEnabled = false
|
||||
|
||||
outputCtx.drawImage(img, 8, 8, 8, 8, baseOffset, baseOffset, 64, 64)
|
||||
|
||||
if (hasOuterLayer) {
|
||||
outputCtx.drawImage(outerLayerCanvas, 0, 0, 8, 8, 0, 0, outputSize, outputSize)
|
||||
}
|
||||
|
||||
outputCanvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
resolve(blob)
|
||||
} else {
|
||||
reject(new Error('Failed to create blob from canvas'))
|
||||
}
|
||||
}, 'image/png')
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
img.onerror = () => {
|
||||
reject(new Error('Failed to load skin texture image'))
|
||||
}
|
||||
|
||||
img.src = skinUrl
|
||||
})
|
||||
}
|
||||
|
||||
export async function generateHeadRender(skin: Skin): Promise<string> {
|
||||
const headKey = getHeadRenderKey(skin.texture_key)
|
||||
|
||||
if (headBlobUrlMap.has(headKey)) {
|
||||
if (DEBUG_MODE) {
|
||||
const url = headBlobUrlMap.get(headKey)!
|
||||
URL.revokeObjectURL(url)
|
||||
headBlobUrlMap.delete(headKey)
|
||||
} else {
|
||||
return headBlobUrlMap.get(headKey)!
|
||||
}
|
||||
}
|
||||
|
||||
const pendingRender = headRenderPromises.get(headKey)
|
||||
if (pendingRender) return await pendingRender
|
||||
|
||||
const renderPromise = loadHeadRender(skin, headKey)
|
||||
headRenderPromises.set(headKey, renderPromise)
|
||||
|
||||
try {
|
||||
return await renderPromise
|
||||
} finally {
|
||||
if (headRenderPromises.get(headKey) === renderPromise) {
|
||||
headRenderPromises.delete(headKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHeadRender(skin: Skin, headKey: string): Promise<string> {
|
||||
if (!DEBUG_MODE) {
|
||||
try {
|
||||
const cachedHeadUrl = await headStorage.retrieve(headKey)
|
||||
if (cachedHeadUrl) {
|
||||
headBlobUrlMap.set(headKey, cachedHeadUrl)
|
||||
return cachedHeadUrl
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to retrieve cached head render:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const skinUrl = await get_normalized_skin_texture(skin)
|
||||
const headBlob = await generatePlayerHeadBlob(skinUrl)
|
||||
const headUrl = URL.createObjectURL(headBlob)
|
||||
|
||||
headBlobUrlMap.set(headKey, headUrl)
|
||||
|
||||
try {
|
||||
await headStorage.store(headKey, headBlob)
|
||||
} catch (error) {
|
||||
console.warn('Failed to store head render in persistent storage:', error)
|
||||
}
|
||||
|
||||
return headUrl
|
||||
}
|
||||
|
||||
export async function getPlayerHeadUrl(skin: Skin): Promise<string> {
|
||||
return await generateHeadRender(skin)
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import { ClassicPlayerModel, SlimPlayerModel } from '@modrinth/assets'
|
||||
import {
|
||||
applyCapeTexture,
|
||||
createTransparentTexture,
|
||||
disposeCaches,
|
||||
setupSkinModel,
|
||||
} from '@modrinth/ui/src/utils/webgl/skin-rendering'
|
||||
import * as THREE from 'three'
|
||||
|
||||
import type { Cape, Skin } from '../skins'
|
||||
import { determineModelType, get_normalized_skin_texture } from '../skins'
|
||||
import { headStorage } from '../storage/head-storage'
|
||||
import { skinPreviewStorage } from '../storage/skin-preview-storage'
|
||||
import {
|
||||
cleanupUnusedPreviews,
|
||||
generateHeadRender,
|
||||
getHeadRenderKey,
|
||||
headBlobUrlMap,
|
||||
type RawRenderResult,
|
||||
type RenderResult,
|
||||
skinBlobUrlMap,
|
||||
} from './batch-skin-renderer'
|
||||
|
||||
class BatchSkinRenderer {
|
||||
private renderer: THREE.WebGLRenderer | null = null
|
||||
private scene: THREE.Scene | null = null
|
||||
private camera: THREE.PerspectiveCamera | null = null
|
||||
private currentModel: THREE.Group | null = null
|
||||
private transparentTexture: THREE.Texture | null = null
|
||||
private readonly width: number
|
||||
private readonly height: number
|
||||
|
||||
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')
|
||||
canvas.width = this.width
|
||||
canvas.height = this.height
|
||||
|
||||
this.renderer = new THREE.WebGLRenderer({
|
||||
canvas: canvas,
|
||||
antialias: true,
|
||||
alpha: true,
|
||||
preserveDrawingBuffer: true,
|
||||
})
|
||||
|
||||
this.renderer.outputColorSpace = THREE.SRGBColorSpace
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
|
||||
this.renderer.shadowMap.enabled = false
|
||||
this.renderer.toneMapping = THREE.NoToneMapping
|
||||
this.renderer.toneMappingExposure = 10.0
|
||||
this.renderer.setClearColor(0x000000, 0)
|
||||
this.renderer.setSize(this.width, this.height)
|
||||
|
||||
this.scene = new THREE.Scene()
|
||||
this.camera = new THREE.PerspectiveCamera(20, this.width / this.height, 0.4, 1000)
|
||||
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 2)
|
||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2)
|
||||
directionalLight.castShadow = false
|
||||
directionalLight.position.set(2, 4, 3)
|
||||
this.scene.add(ambientLight)
|
||||
this.scene.add(directionalLight)
|
||||
}
|
||||
|
||||
public async renderSkin(
|
||||
textureUrl: string,
|
||||
modelUrl: string,
|
||||
capeUrl?: string,
|
||||
): Promise<RawRenderResult> {
|
||||
this.initializeRenderer()
|
||||
|
||||
this.clearScene()
|
||||
|
||||
await this.setupModel(modelUrl, textureUrl, capeUrl)
|
||||
|
||||
const headPart = this.currentModel!.getObjectByName('Head')
|
||||
let lookAtTarget: [number, number, number]
|
||||
|
||||
if (headPart) {
|
||||
const headPosition = new THREE.Vector3()
|
||||
headPart.getWorldPosition(headPosition)
|
||||
lookAtTarget = [headPosition.x, headPosition.y - 0.3, headPosition.z]
|
||||
} else {
|
||||
throw new Error("Failed to find 'Head' object in model.")
|
||||
}
|
||||
|
||||
const frontCameraPos: [number, number, number] = [-1.3, 1, 6.3]
|
||||
const forwards = await this.renderView(frontCameraPos, lookAtTarget)
|
||||
|
||||
return { forwards }
|
||||
}
|
||||
|
||||
private async renderView(
|
||||
cameraPosition: [number, number, number],
|
||||
lookAtPosition: [number, number, number],
|
||||
): Promise<Blob> {
|
||||
if (!this.camera || !this.renderer || !this.scene) {
|
||||
throw new Error('Renderer not initialized')
|
||||
}
|
||||
|
||||
this.camera.position.set(...cameraPosition)
|
||||
this.camera.lookAt(...lookAtPosition)
|
||||
|
||||
this.renderer.render(this.scene, this.camera)
|
||||
|
||||
return await new Promise<Blob>((resolve, reject) => {
|
||||
this.renderer!.domElement.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
resolve(blob)
|
||||
} else {
|
||||
reject(new Error('Failed to create blob from rendered canvas'))
|
||||
}
|
||||
},
|
||||
'image/webp',
|
||||
0.9,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private async setupModel(modelUrl: string, textureUrl: string, capeUrl?: string): Promise<void> {
|
||||
if (!this.scene) {
|
||||
throw new Error('Renderer not initialized')
|
||||
}
|
||||
|
||||
const { model } = await setupSkinModel(modelUrl, textureUrl, capeUrl)
|
||||
|
||||
if (!capeUrl) {
|
||||
applyCapeTexture(model, null, this.getTransparentTexture())
|
||||
}
|
||||
|
||||
const group = new THREE.Group()
|
||||
group.add(model)
|
||||
group.position.set(0, 0.3, 1.95)
|
||||
group.scale.set(0.8, 0.8, 0.8)
|
||||
|
||||
this.scene.add(group)
|
||||
this.currentModel = group
|
||||
}
|
||||
|
||||
private getTransparentTexture(): THREE.Texture {
|
||||
if (!this.transparentTexture) {
|
||||
this.transparentTexture = createTransparentTexture()
|
||||
}
|
||||
|
||||
return this.transparentTexture
|
||||
}
|
||||
|
||||
private clearScene(): void {
|
||||
if (!this.scene || !this.currentModel) return
|
||||
|
||||
this.currentModel.traverse((object) => {
|
||||
const mesh = object as THREE.Mesh
|
||||
if (mesh.isMesh && mesh.userData.threeDSkinLayersApplied) {
|
||||
mesh.geometry.dispose()
|
||||
}
|
||||
})
|
||||
this.scene.remove(this.currentModel)
|
||||
this.currentModel.clear()
|
||||
this.currentModel = null
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.clearScene()
|
||||
|
||||
if (this.transparentTexture) {
|
||||
this.transparentTexture.dispose()
|
||||
this.transparentTexture = null
|
||||
}
|
||||
|
||||
if (this.renderer) {
|
||||
this.renderer.dispose()
|
||||
}
|
||||
|
||||
this.renderer = null
|
||||
this.scene = null
|
||||
this.camera = null
|
||||
|
||||
disposeCaches()
|
||||
}
|
||||
}
|
||||
|
||||
function getModelUrlForVariant(variant: string): string {
|
||||
switch (variant) {
|
||||
case 'SLIM':
|
||||
return SlimPlayerModel
|
||||
case 'CLASSIC':
|
||||
case 'UNKNOWN':
|
||||
default:
|
||||
return ClassicPlayerModel
|
||||
}
|
||||
}
|
||||
|
||||
const DEBUG_MODE = false
|
||||
|
||||
let sharedRenderer: BatchSkinRenderer | null = null
|
||||
let latestPreviewGeneration = 0
|
||||
let previewGenerationQueue: Promise<void> = Promise.resolve()
|
||||
|
||||
function getSharedRenderer(): BatchSkinRenderer {
|
||||
if (!sharedRenderer) {
|
||||
sharedRenderer = new BatchSkinRenderer()
|
||||
}
|
||||
return sharedRenderer
|
||||
}
|
||||
|
||||
export function disposeSharedRenderer(): void {
|
||||
if (sharedRenderer) {
|
||||
sharedRenderer.dispose()
|
||||
sharedRenderer = null
|
||||
}
|
||||
}
|
||||
|
||||
export function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise<void> {
|
||||
const generation = ++latestPreviewGeneration
|
||||
const skinsSnapshot = [...skins]
|
||||
const capesSnapshot = [...capes]
|
||||
|
||||
const generationPromise = previewGenerationQueue.then(() =>
|
||||
generateSkinPreviewsForGeneration(skinsSnapshot, capesSnapshot, generation),
|
||||
)
|
||||
|
||||
previewGenerationQueue = generationPromise.catch(() => {})
|
||||
|
||||
return generationPromise
|
||||
}
|
||||
|
||||
async function generateSkinPreviewsForGeneration(
|
||||
skins: Skin[],
|
||||
capes: Cape[],
|
||||
generation: number,
|
||||
): Promise<void> {
|
||||
const isCurrentGeneration = () => generation === latestPreviewGeneration
|
||||
|
||||
try {
|
||||
const skinKeys = skins.map(
|
||||
(skin) => `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`,
|
||||
)
|
||||
const headKeys = skins.map((skin) => getHeadRenderKey(skin.texture_key))
|
||||
|
||||
const [cachedSkinPreviews, cachedHeadPreviews] = await Promise.all([
|
||||
skinPreviewStorage.batchRetrieve(skinKeys),
|
||||
headStorage.batchRetrieve(headKeys),
|
||||
])
|
||||
|
||||
if (!isCurrentGeneration()) return
|
||||
|
||||
for (let i = 0; i < skins.length; i++) {
|
||||
const skinKey = skinKeys[i]
|
||||
const headKey = headKeys[i]
|
||||
|
||||
const rawCached = cachedSkinPreviews[skinKey]
|
||||
if (rawCached && !skinBlobUrlMap.has(skinKey)) {
|
||||
const cached: RenderResult = {
|
||||
forwards: URL.createObjectURL(rawCached.forwards),
|
||||
}
|
||||
skinBlobUrlMap.set(skinKey, cached)
|
||||
}
|
||||
|
||||
const cachedHead = cachedHeadPreviews[headKey]
|
||||
if (cachedHead && !headBlobUrlMap.has(headKey)) {
|
||||
headBlobUrlMap.set(headKey, URL.createObjectURL(cachedHead))
|
||||
}
|
||||
}
|
||||
|
||||
for (const skin of skins) {
|
||||
if (!isCurrentGeneration()) return
|
||||
|
||||
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
|
||||
|
||||
if (skinBlobUrlMap.has(key)) {
|
||||
if (DEBUG_MODE) {
|
||||
const result = skinBlobUrlMap.get(key)!
|
||||
URL.revokeObjectURL(result.forwards)
|
||||
skinBlobUrlMap.delete(key)
|
||||
} else continue
|
||||
}
|
||||
|
||||
const renderer = getSharedRenderer()
|
||||
|
||||
let variant = skin.variant
|
||||
if (variant === 'UNKNOWN') {
|
||||
try {
|
||||
variant = await determineModelType(skin.texture)
|
||||
} catch (error) {
|
||||
console.error(`Failed to determine model type for skin ${key}:`, error)
|
||||
variant = 'CLASSIC'
|
||||
}
|
||||
}
|
||||
|
||||
const modelUrl = getModelUrlForVariant(variant)
|
||||
const cape: Cape | undefined = capes.find((_cape) => _cape.id === skin.cape_id)
|
||||
const rawRenderResult = await renderer.renderSkin(
|
||||
await get_normalized_skin_texture(skin),
|
||||
modelUrl,
|
||||
cape?.texture,
|
||||
)
|
||||
|
||||
if (!isCurrentGeneration()) return
|
||||
|
||||
const renderResult: RenderResult = {
|
||||
forwards: URL.createObjectURL(rawRenderResult.forwards),
|
||||
}
|
||||
|
||||
skinBlobUrlMap.set(key, renderResult)
|
||||
|
||||
try {
|
||||
await skinPreviewStorage.store(key, rawRenderResult)
|
||||
} catch (error) {
|
||||
console.warn('Failed to store skin preview in persistent storage:', error)
|
||||
}
|
||||
|
||||
const headKey = getHeadRenderKey(skin.texture_key)
|
||||
if (!headBlobUrlMap.has(headKey)) {
|
||||
await generateHeadRender(skin)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
disposeSharedRenderer()
|
||||
|
||||
if (isCurrentGeneration()) {
|
||||
await cleanupUnusedPreviews(skins)
|
||||
|
||||
await skinPreviewStorage.debugCalculateStorage()
|
||||
await headStorage.debugCalculateStorage()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user