feat:移除了弹窗,服务器添加sls
This commit is contained in:
434
packages/ui/src/utils/webgl/skin-rendering.ts
Normal file
434
packages/ui/src/utils/webgl/skin-rendering.ts
Normal file
@ -0,0 +1,434 @@
|
||||
import * as THREE from 'three'
|
||||
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js'
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
|
||||
|
||||
import { createSolidSkinLayerGeometry } from './solid-skin-layer'
|
||||
|
||||
export interface SkinRendererConfig {
|
||||
textureColorSpace?: THREE.ColorSpace
|
||||
textureFlipY?: boolean
|
||||
textureMagFilter?: THREE.MagnificationTextureFilter
|
||||
textureMinFilter?: THREE.MinificationTextureFilter
|
||||
}
|
||||
|
||||
const ENABLE_VOXEL_LAYER_GEOMETRY = true
|
||||
const MODEL_PIXEL_SIZE = 1 / 16
|
||||
const NON_LEG_VERTICAL_OFFSET = -MODEL_PIXEL_SIZE / 2
|
||||
|
||||
/** Aligns the torso, arms, head, and cape with the stationary legs. */
|
||||
function offsetNonLegModelParts(model: THREE.Object3D): void {
|
||||
if (model.userData.nonLegPartsOffsetApplied) return
|
||||
|
||||
const nonLegRoots = new Set(['Head', 'Right_Arm', 'Left_Arm', 'Body_2', 'Body_Layer', 'Cape'])
|
||||
model.traverse((node) => {
|
||||
if (nonLegRoots.has(node.name)) node.position.y += NON_LEG_VERTICAL_OFFSET
|
||||
})
|
||||
model.userData.nonLegPartsOffsetApplied = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds the outer layer as solid, textured voxels. This follows 3D Skin
|
||||
* Layers' SolidPixelWrapper: every non-transparent outer-layer pixel becomes
|
||||
* a real cube with six faces instead of a zero-thickness quad.
|
||||
*/
|
||||
function getSkinLayerDefinition(name: string): {
|
||||
width: number
|
||||
height: number
|
||||
depth: number
|
||||
u: number
|
||||
v: number
|
||||
} | null {
|
||||
if (name === 'Hat_Layer') return { width: 8, height: 8, depth: 8, u: 32, v: 0 }
|
||||
if (name === 'Body_Layer') return { width: 8, height: 12, depth: 4, u: 16, v: 32 }
|
||||
if (name === 'Right_Leg_Layer') return { width: 4, height: 12, depth: 4, u: 0, v: 32 }
|
||||
if (name === 'Left_Leg_Layer') return { width: 4, height: 12, depth: 4, u: 0, v: 48 }
|
||||
if (name === 'Right_Arm_Layer') return { width: 4, height: 12, depth: 4, u: 40, v: 32 }
|
||||
if (name === 'Left_Arm_Layer') return { width: 4, height: 12, depth: 4, u: 48, v: 48 }
|
||||
return null
|
||||
}
|
||||
|
||||
function readSkinPixels(texture: THREE.Texture): Uint8ClampedArray | null {
|
||||
const image = texture.image as CanvasImageSource | undefined
|
||||
if (!image) return null
|
||||
|
||||
try {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = canvas.height = 64
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) return null
|
||||
context.drawImage(image, 0, 0, 64, 64)
|
||||
return context.getImageData(0, 0, 64, 64).data
|
||||
} catch {
|
||||
// Cross-origin images may not be readable. The regular GLTF layer remains
|
||||
// as a graceful fallback in that case.
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function scaleLayerGeometry(geometry: THREE.BufferGeometry, name: string): void {
|
||||
geometry.computeBoundingBox()
|
||||
const bounds = geometry.boundingBox
|
||||
if (!bounds) return
|
||||
|
||||
const isHead = name === 'Hat_Layer'
|
||||
const isBody = name === 'Body_Layer'
|
||||
// The authored GLTF outer shells already include most of the mod's offset.
|
||||
// Apply only the remaining per-pixel correction; multiplying by the full
|
||||
// config values would make the head pixels noticeably oversized.
|
||||
const scaleX = isHead ? 1.05 : isBody ? 1 : 1.02
|
||||
const scaleY = isHead ? 1.05 : 1
|
||||
const scaleZ = isHead ? 1.05 : 1.02
|
||||
const center = bounds.getCenter(new THREE.Vector3())
|
||||
const position = geometry.getAttribute('position')
|
||||
const vertex = new THREE.Vector3()
|
||||
|
||||
for (let i = 0; i < position.count; i++) {
|
||||
vertex.fromBufferAttribute(position, i)
|
||||
vertex.sub(center)
|
||||
vertex.set(vertex.x * scaleX, vertex.y * scaleY, vertex.z * scaleZ)
|
||||
vertex.add(center)
|
||||
position.setXYZ(i, vertex.x, vertex.y, vertex.z)
|
||||
}
|
||||
|
||||
position.needsUpdate = true
|
||||
geometry.computeBoundingBox()
|
||||
geometry.computeBoundingSphere()
|
||||
}
|
||||
|
||||
export function applyThreeDSkinLayers(model: THREE.Object3D, texture?: THREE.Texture): void {
|
||||
offsetNonLegModelParts(model)
|
||||
const pixels = texture ? readSkinPixels(texture) : null
|
||||
if (!pixels || !texture) return
|
||||
model.traverse((child) => {
|
||||
const mesh = child as THREE.Mesh
|
||||
if (
|
||||
!mesh.isMesh ||
|
||||
!mesh.name.endsWith('_Layer') ||
|
||||
!mesh.geometry ||
|
||||
mesh.userData.threeDSkinLayersApplied
|
||||
)
|
||||
return
|
||||
|
||||
// GLTF clones share BufferGeometry objects. Clone before changing vertex data
|
||||
// so one preview (or cached model) cannot affect another.
|
||||
mesh.geometry = mesh.geometry.clone()
|
||||
const definition = getSkinLayerDefinition(mesh.name)
|
||||
if (ENABLE_VOXEL_LAYER_GEOMETRY && pixels && definition) {
|
||||
const meshBounds = new THREE.Box3().setFromBufferAttribute(
|
||||
mesh.geometry.getAttribute('position') as THREE.BufferAttribute,
|
||||
)
|
||||
const isSlimArm =
|
||||
mesh.name.includes('Arm') && meshBounds.getSize(new THREE.Vector3()).x < 0.25
|
||||
const voxelDefinition = isSlimArm ? { ...definition, width: 3 } : definition
|
||||
const voxelGeometry = createSolidSkinLayerGeometry(mesh, texture!, pixels, voxelDefinition)
|
||||
if (voxelGeometry) {
|
||||
scaleLayerGeometry(voxelGeometry, mesh.name)
|
||||
const voxelMaterials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
||||
voxelMaterials.forEach((material) => {
|
||||
if (!(material instanceof THREE.MeshStandardMaterial)) return
|
||||
// The mod renders these as alpha-tested cutouts. Keeping depth writes
|
||||
// deterministic avoids transparent-surface sorting cracks between voxels.
|
||||
material.transparent = false
|
||||
material.alphaTest = 0.1
|
||||
material.depthWrite = true
|
||||
material.alphaToCoverage = true
|
||||
material.polygonOffset = false
|
||||
material.polygonOffsetFactor = 0
|
||||
material.polygonOffsetUnits = 0
|
||||
material.needsUpdate = true
|
||||
})
|
||||
mesh.geometry.dispose()
|
||||
mesh.geometry = voxelGeometry
|
||||
mesh.userData.threeDSkinLayersApplied = true
|
||||
return
|
||||
}
|
||||
}
|
||||
const geometry = mesh.geometry
|
||||
geometry.computeBoundingBox()
|
||||
const bounds = geometry.boundingBox
|
||||
if (!bounds) return
|
||||
|
||||
scaleLayerGeometry(geometry, mesh.name)
|
||||
mesh.userData.threeDSkinLayersApplied = true
|
||||
})
|
||||
}
|
||||
|
||||
const modelCache: Map<string, GLTF> = new Map()
|
||||
const modelPromiseCache: Map<string, Promise<GLTF>> = new Map()
|
||||
const textureCache: Map<string, THREE.Texture> = new Map()
|
||||
const texturePromiseCache: Map<string, Promise<THREE.Texture>> = new Map()
|
||||
|
||||
export async function loadModel(modelUrl: string): Promise<GLTF> {
|
||||
if (modelCache.has(modelUrl)) {
|
||||
return modelCache.get(modelUrl)!
|
||||
}
|
||||
|
||||
if (modelPromiseCache.has(modelUrl)) {
|
||||
return modelPromiseCache.get(modelUrl)!
|
||||
}
|
||||
|
||||
const loader = new GLTFLoader()
|
||||
const promise = new Promise<GLTF>((resolve, reject) => {
|
||||
loader.load(
|
||||
modelUrl,
|
||||
(gltf) => {
|
||||
modelCache.set(modelUrl, gltf)
|
||||
resolve(gltf)
|
||||
},
|
||||
undefined,
|
||||
reject,
|
||||
)
|
||||
}).finally(() => {
|
||||
modelPromiseCache.delete(modelUrl)
|
||||
})
|
||||
|
||||
modelPromiseCache.set(modelUrl, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
export async function loadTexture(
|
||||
textureUrl: string,
|
||||
config: SkinRendererConfig = {},
|
||||
): Promise<THREE.Texture> {
|
||||
const cacheKey = `${textureUrl}_${JSON.stringify(config)}`
|
||||
|
||||
if (textureCache.has(cacheKey)) {
|
||||
return textureCache.get(cacheKey)!
|
||||
}
|
||||
|
||||
if (texturePromiseCache.has(cacheKey)) {
|
||||
return texturePromiseCache.get(cacheKey)!
|
||||
}
|
||||
|
||||
const textureLoader = new THREE.TextureLoader()
|
||||
const promise = new Promise<THREE.Texture>((resolve, reject) => {
|
||||
textureLoader.load(
|
||||
textureUrl,
|
||||
(texture) => {
|
||||
texture.colorSpace = config.textureColorSpace ?? THREE.SRGBColorSpace
|
||||
texture.flipY = config.textureFlipY ?? false
|
||||
texture.magFilter = config.textureMagFilter ?? THREE.NearestFilter
|
||||
texture.minFilter = config.textureMinFilter ?? THREE.NearestFilter
|
||||
|
||||
textureCache.set(cacheKey, texture)
|
||||
resolve(texture)
|
||||
},
|
||||
undefined,
|
||||
reject,
|
||||
)
|
||||
}).finally(() => {
|
||||
texturePromiseCache.delete(cacheKey)
|
||||
})
|
||||
|
||||
texturePromiseCache.set(cacheKey, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
function applyMap(mat: THREE.MeshStandardMaterial, texture: THREE.Texture | null): boolean {
|
||||
const hadMap = mat.map !== null
|
||||
const hasMap = texture !== null
|
||||
|
||||
if (mat.map !== texture) {
|
||||
mat.map = texture
|
||||
}
|
||||
|
||||
return hadMap !== hasMap
|
||||
}
|
||||
|
||||
function setShaderMaterialProperties(
|
||||
mat: THREE.MeshStandardMaterial,
|
||||
properties: {
|
||||
alphaTest: number
|
||||
flatShading: boolean
|
||||
side: THREE.Side
|
||||
toneMapped: boolean
|
||||
transparent?: boolean
|
||||
},
|
||||
): boolean {
|
||||
let needsUpdate = false
|
||||
|
||||
if (mat.alphaTest !== properties.alphaTest) {
|
||||
mat.alphaTest = properties.alphaTest
|
||||
needsUpdate = true
|
||||
}
|
||||
|
||||
if (mat.flatShading !== properties.flatShading) {
|
||||
mat.flatShading = properties.flatShading
|
||||
needsUpdate = true
|
||||
}
|
||||
|
||||
if (mat.side !== properties.side) {
|
||||
mat.side = properties.side
|
||||
needsUpdate = true
|
||||
}
|
||||
|
||||
if (mat.toneMapped !== properties.toneMapped) {
|
||||
mat.toneMapped = properties.toneMapped
|
||||
needsUpdate = true
|
||||
}
|
||||
|
||||
if (properties.transparent !== undefined && mat.transparent !== properties.transparent) {
|
||||
mat.transparent = properties.transparent
|
||||
needsUpdate = true
|
||||
}
|
||||
|
||||
return needsUpdate
|
||||
}
|
||||
|
||||
function setCommonMaterialProperties(mat: THREE.MeshStandardMaterial): void {
|
||||
if (mat.metalness !== 0) {
|
||||
mat.metalness = 0
|
||||
}
|
||||
|
||||
if (mat.color.getHex() !== 0xffffff) {
|
||||
mat.color.set(0xffffff)
|
||||
}
|
||||
|
||||
if (mat.roughness !== 1) {
|
||||
mat.roughness = 1
|
||||
}
|
||||
|
||||
if (!mat.depthTest) {
|
||||
mat.depthTest = true
|
||||
}
|
||||
|
||||
if (!mat.depthWrite) {
|
||||
mat.depthWrite = true
|
||||
}
|
||||
}
|
||||
|
||||
export function applyTexture(model: THREE.Object3D, texture: THREE.Texture): void {
|
||||
model.traverse((child) => {
|
||||
if ((child as THREE.Mesh).isMesh) {
|
||||
const mesh = child as THREE.Mesh
|
||||
const isSkinLayer = mesh.name.endsWith('_Layer')
|
||||
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
||||
|
||||
materials.forEach((mat: THREE.Material) => {
|
||||
if (mat instanceof THREE.MeshStandardMaterial) {
|
||||
if (mat.name !== 'cape') {
|
||||
const mapNeedsUpdate = applyMap(mat, texture)
|
||||
const propertiesNeedUpdate = setShaderMaterialProperties(mat, {
|
||||
alphaTest: 0.1,
|
||||
flatShading: true,
|
||||
side: THREE.FrontSide,
|
||||
toneMapped: false,
|
||||
transparent: isSkinLayer,
|
||||
})
|
||||
if (mat.alphaToCoverage !== isSkinLayer) {
|
||||
mat.alphaToCoverage = isSkinLayer
|
||||
mat.needsUpdate = true
|
||||
}
|
||||
|
||||
setCommonMaterialProperties(mat)
|
||||
|
||||
if (mapNeedsUpdate || propertiesNeedUpdate) {
|
||||
mat.needsUpdate = true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function applyCapeTexture(
|
||||
model: THREE.Object3D,
|
||||
texture: THREE.Texture | null,
|
||||
transparentTexture?: THREE.Texture,
|
||||
): void {
|
||||
model.traverse((child) => {
|
||||
if ((child as THREE.Mesh).isMesh) {
|
||||
const mesh = child as THREE.Mesh
|
||||
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
||||
|
||||
materials.forEach((mat: THREE.Material) => {
|
||||
if (mat instanceof THREE.MeshStandardMaterial) {
|
||||
if (mat.name === 'cape') {
|
||||
const nextMap = texture || transparentTexture || null
|
||||
const mapNeedsUpdate = applyMap(mat, nextMap)
|
||||
const propertiesNeedUpdate = setShaderMaterialProperties(mat, {
|
||||
alphaTest: 0.1,
|
||||
flatShading: true,
|
||||
side: THREE.DoubleSide,
|
||||
toneMapped: false,
|
||||
transparent: !texture || !!transparentTexture,
|
||||
})
|
||||
|
||||
setCommonMaterialProperties(mat)
|
||||
|
||||
if (mapNeedsUpdate || propertiesNeedUpdate) {
|
||||
mat.needsUpdate = true
|
||||
}
|
||||
|
||||
mat.visible = !!texture
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function findBodyNode(model: THREE.Object3D): THREE.Object3D | null {
|
||||
let bodyNode: THREE.Object3D | null = null
|
||||
|
||||
model.traverse((node) => {
|
||||
if (node.name === 'Body') {
|
||||
bodyNode = node
|
||||
}
|
||||
})
|
||||
|
||||
return bodyNode
|
||||
}
|
||||
|
||||
export function createTransparentTexture(): THREE.Texture {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = canvas.height = 1
|
||||
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D
|
||||
ctx.clearRect(0, 0, 1, 1)
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas)
|
||||
texture.needsUpdate = true
|
||||
texture.colorSpace = THREE.SRGBColorSpace
|
||||
texture.flipY = false
|
||||
texture.magFilter = THREE.NearestFilter
|
||||
texture.minFilter = THREE.NearestFilter
|
||||
|
||||
return texture
|
||||
}
|
||||
|
||||
export async function setupSkinModel(
|
||||
modelUrl: string,
|
||||
textureUrl: string,
|
||||
capeTextureUrl?: string,
|
||||
config: SkinRendererConfig = {},
|
||||
): Promise<{
|
||||
model: THREE.Object3D
|
||||
bodyNode: THREE.Object3D | null
|
||||
}> {
|
||||
const [gltf, texture] = await Promise.all([loadModel(modelUrl), loadTexture(textureUrl, config)])
|
||||
|
||||
const model = gltf.scene.clone()
|
||||
applyTexture(model, texture)
|
||||
applyThreeDSkinLayers(model, texture)
|
||||
|
||||
if (capeTextureUrl) {
|
||||
const capeTexture = await loadTexture(capeTextureUrl, config)
|
||||
applyCapeTexture(model, capeTexture)
|
||||
}
|
||||
|
||||
const bodyNode = findBodyNode(model)
|
||||
|
||||
return { model, bodyNode }
|
||||
}
|
||||
|
||||
export function disposeCaches(): void {
|
||||
Array.from(textureCache.values()).forEach((texture) => {
|
||||
texture.dispose()
|
||||
})
|
||||
|
||||
textureCache.clear()
|
||||
texturePromiseCache.clear()
|
||||
modelCache.clear()
|
||||
modelPromiseCache.clear()
|
||||
}
|
||||
Reference in New Issue
Block a user