diff --git a/packages/ui/src/components/skin/SkinPreviewRenderer.vue b/packages/ui/src/components/skin/SkinPreviewRenderer.vue index 54d94619b3..7c9f4cacbd 100644 --- a/packages/ui/src/components/skin/SkinPreviewRenderer.vue +++ b/packages/ui/src/components/skin/SkinPreviewRenderer.vue @@ -6,6 +6,7 @@ @click="onCanvasClick" >
@@ -18,6 +19,7 @@
@@ -27,6 +29,7 @@
@@ -267,16 +270,23 @@ const { }, }) -const { hasEarsFeatures, isModelLoaded, isTextureLoaded, modelCenter, modelSize, scene } = - useSkinPreviewScene({ - selectedModelSrc, - textureSrc: toRef(props, 'textureSrc'), - earsTextureSrc: toRef(props, 'earsTextureSrc'), - capeSrc: toRef(props, 'capeSrc'), - earsEnabled: toRef(props, 'earsEnabled'), - initializeAnimations, - cleanupAnimationState, - }) +const { + hasEarsFeatures, + isModelLoaded, + isTextureLoaded, + modelCenter, + modelSize, + scene, + visibleBounds, +} = useSkinPreviewScene({ + selectedModelSrc, + textureSrc: toRef(props, 'textureSrc'), + earsTextureSrc: toRef(props, 'earsTextureSrc'), + capeSrc: toRef(props, 'capeSrc'), + earsEnabled: toRef(props, 'earsEnabled'), + initializeAnimations, + cleanupAnimationState, +}) function syncDamageFlashShaderMaterials() { syncDamageFlashShader(scene.value, damageFlashIntensity.value) @@ -310,6 +320,8 @@ const { subtitleWrapped: isSubtitleWrapped, modelCenter, modelSize, + scene, + visibleBounds, isModelLoaded, }) diff --git a/packages/ui/src/components/skin/skin-preview-shader.ts b/packages/ui/src/components/skin/skin-preview-shader.ts index 5f9ed8db98..10adae4ab7 100644 --- a/packages/ui/src/components/skin/skin-preview-shader.ts +++ b/packages/ui/src/components/skin/skin-preview-shader.ts @@ -52,7 +52,7 @@ export function createRadialSpotlightShader() { `, transparent: true, depthWrite: false, - depthTest: false, + depthTest: true, } } diff --git a/packages/ui/src/composables/skin-rendering/types.ts b/packages/ui/src/composables/skin-rendering/types.ts index d7d4d38971..db4733e9f3 100644 --- a/packages/ui/src/composables/skin-rendering/types.ts +++ b/packages/ui/src/composables/skin-rendering/types.ts @@ -14,6 +14,11 @@ export interface SkinPreviewFitPadding { left: number } +export interface SkinPreviewBounds { + min: SkinPreviewTuple + max: SkinPreviewTuple +} + export interface SkinPreviewFitLock { containerSize: { width: number @@ -23,6 +28,7 @@ export interface SkinPreviewFitLock { modelSize: SkinPreviewTuple padding: SkinPreviewFitPadding rotation: number + visibleBounds: SkinPreviewBounds } export type SkinPreviewTuple = [number, number, number] diff --git a/packages/ui/src/composables/skin-rendering/use-ears-mod-features.ts b/packages/ui/src/composables/skin-rendering/use-ears-mod-features.ts index 5feb30018c..bd6ca7c944 100644 --- a/packages/ui/src/composables/skin-rendering/use-ears-mod-features.ts +++ b/packages/ui/src/composables/skin-rendering/use-ears-mod-features.ts @@ -1939,6 +1939,10 @@ export function removeEarsMod(model: THREE.Object3D | null) { registry.removeFromParent() } +export function isEarsModFeature(object: THREE.Object3D) { + return object.name.startsWith(EARS_FEATURE_PREFIX) +} + export function applyEarsMod(model: THREE.Object3D, sourceTexture: THREE.Texture, enabled = true) { removeEarsMod(model) diff --git a/packages/ui/src/composables/skin-rendering/use-skin-preview-fit.ts b/packages/ui/src/composables/skin-rendering/use-skin-preview-fit.ts index f62c9c3c2e..7eaa9b3150 100644 --- a/packages/ui/src/composables/skin-rendering/use-skin-preview-fit.ts +++ b/packages/ui/src/composables/skin-rendering/use-skin-preview-fit.ts @@ -11,6 +11,7 @@ import { } from 'vue' import type { + SkinPreviewBounds, SkinPreviewFitLock, SkinPreviewFitPadding, SkinPreviewFraming, @@ -41,8 +42,88 @@ function cloneModelTuple(tuple: SkinPreviewTuple): SkinPreviewTuple { return [tuple[0], tuple[1], tuple[2]] } +function cloneBounds(bounds: SkinPreviewBounds): SkinPreviewBounds { + return { + min: cloneModelTuple(bounds.min), + max: cloneModelTuple(bounds.max), + } +} + +const MODEL_ROTATION_AXIS = new THREE.Vector3(0, 1, 0) + type MaybeReadonlyRef = Ref | ComputedRef +type SkinPreviewDebugEntry = Record + +type SkinPreviewDebugWindow = Window & { + __SKIN_PREVIEW_DEBUG__?: SkinPreviewDebugEntry[] +} + +function serializeRect(rect: DOMRect) { + return { + bottom: rect.bottom, + height: rect.height, + left: rect.left, + right: rect.right, + top: rect.top, + width: rect.width, + x: rect.x, + y: rect.y, + } +} + +function serializeElement(element: Element | null) { + if (!(element instanceof HTMLElement)) return null + + const style = window.getComputedStyle(element) + return { + rect: serializeRect(element.getBoundingClientRect()), + style: { + bottom: style.bottom, + height: style.height, + left: style.left, + position: style.position, + top: style.top, + transform: style.transform, + width: style.width, + }, + } +} + +function getVisibleMeshDebugBounds(root: THREE.Object3D | null) { + if (!root) return [] + + root.updateWorldMatrix(true, true) + const entries: SkinPreviewDebugEntry[] = [] + + root.traverse((object) => { + const mesh = object as THREE.Mesh + if (!mesh.isMesh || !mesh.geometry || mesh.visible === false) return + + const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material] + if (materials.length && materials.every((material) => material.visible === false)) return + + if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox() + if (!mesh.geometry.boundingBox) return + + const box = mesh.geometry.boundingBox.clone().applyMatrix4(mesh.matrixWorld) + entries.push({ + materials: materials.map((material) => ({ + name: material.name, + side: material.side, + visible: material.visible, + })), + max: box.max.toArray(), + min: box.min.toArray(), + name: mesh.name, + parent: mesh.parent?.name, + uuid: mesh.uuid, + }) + }) + + return entries +} + export function useSkinPreviewFit({ containerElement, fit, @@ -59,6 +140,8 @@ export function useSkinPreviewFit({ subtitleWrapped, modelCenter, modelSize, + scene, + visibleBounds, isModelLoaded, }: { containerElement: MaybeReadonlyRef @@ -76,11 +159,15 @@ export function useSkinPreviewFit({ subtitleWrapped: MaybeReadonlyRef modelCenter: MaybeReadonlyRef modelSize: MaybeReadonlyRef + scene: MaybeReadonlyRef + visibleBounds: MaybeReadonlyRef isModelLoaded: MaybeReadonlyRef }) { const containerSize = ref({ width: 1, height: 1 }) const fitLock = ref(null) let resizeObserver: ResizeObserver | undefined + let debugAnimationFrame: number | null = null + const pendingDebugReasons = new Set() const fitEnabled = computed(() => { if (fit.value !== undefined) return fit.value @@ -113,6 +200,11 @@ export function useSkinPreviewFit({ const fitModelRotation = computed(() => lockFitEnabled.value ? (fitLock.value?.rotation ?? modelRotation.value) : modelRotation.value, ) + const fitVisibleBounds = computed(() => + lockFitEnabled.value + ? (fitLock.value?.visibleBounds ?? visibleBounds.value) + : visibleBounds.value, + ) const resolvedFitPadding = computed(() => { const preset = FRAMING_PRESETS[currentFraming.value].padding @@ -198,22 +290,42 @@ export function useSkinPreviewFit({ } }) - const modelFeetTop = computed(() => { + const projectedVisibleBounds = computed(() => { if (!fitEnabled.value) return null + const width = Math.max(containerSize.value.width, 1) const height = Math.max(containerSize.value.height, 1) - const [, sizeY] = fitModelSize.value const { fov: resolvedFov, position, target } = cameraConfig.value - const distance = Math.max(Math.abs(position[2] - target[2]), 0.001) - const verticalFov = THREE.MathUtils.degToRad(resolvedFov) - const modelFeetY = -sizeY / 2 - const projectedY = - (modelFeetY - target[1]) / distance / Math.max(Math.tan(verticalFov / 2), 0.001) - const topPercent = THREE.MathUtils.clamp(((1 - projectedY) / 2) * 100, 0, 100) + const camera = new THREE.PerspectiveCamera(resolvedFov, width / height, 0.1, 1000) + camera.position.set(...position) + camera.lookAt(...target) + camera.updateProjectionMatrix() + camera.updateMatrixWorld(true) - return (topPercent / 100) * height + const bounds = fitVisibleBounds.value + const [offsetX, offsetY, offsetZ] = modelOffset.value + let top = Number.POSITIVE_INFINITY + let bottom = Number.NEGATIVE_INFINITY + + for (const x of [bounds.min[0], bounds.max[0]]) { + for (const y of [bounds.min[1], bounds.max[1]]) { + for (const z of [bounds.min[2], bounds.max[2]]) { + const point = new THREE.Vector3(x + offsetX, y + offsetY, z + offsetZ) + point.applyAxisAngle(MODEL_ROTATION_AXIS, fitModelRotation.value) + point.project(camera) + + const screenY = ((1 - point.y) / 2) * height + top = Math.min(top, screenY) + bottom = Math.max(bottom, screenY) + } + } + } + + return { bottom, top } }) + const modelFeetTop = computed(() => projectedVisibleBounds.value?.bottom ?? null) + const previewControlsTop = computed(() => modelFeetTop.value === null ? null : modelFeetTop.value + PREVIEW_CONTROLS_FOOT_OFFSET, ) @@ -250,22 +362,8 @@ export function useSkinPreviewFit({ const nametagTop = computed(() => { if (!fitEnabled.value) return '18%' - const height = Math.max(containerSize.value.height, 1) - const [sizeX, sizeY, sizeZ] = fitModelSize.value - const { fov: resolvedFov, position, target } = cameraConfig.value - const verticalFov = THREE.MathUtils.degToRad(resolvedFov) - const modelTopY = sizeY / 2 - const halfX = sizeX / 2 - const halfZ = sizeZ / 2 - const sinRotation = Math.sin(fitModelRotation.value) - const cosRotation = Math.cos(fitModelRotation.value) - const modelTopZ = -Math.abs(halfX * sinRotation) - Math.abs(halfZ * cosRotation) - const distance = Math.max(Math.abs(position[2] - target[2]) + modelTopZ, 0.001) - const projectedY = - (modelTopY - target[1]) / distance / Math.max(Math.tan(verticalFov / 2), 0.001) - const topPercent = ((1 - projectedY) / 2) * 100 - - return `${(topPercent / 100) * height - NAMETAG_HEAD_OFFSET}px` + const top = projectedVisibleBounds.value?.top + return top === undefined ? '18%' : `${top - NAMETAG_HEAD_OFFSET}px` }) const spotlightY = computed(() => { @@ -292,6 +390,89 @@ export function useSkinPreviewFit({ return [radius, radius, radius] }) + function captureDebugSnapshot(reasons: string[]) { + if (typeof window === 'undefined') return + + const container = containerElement.value + const canvas = container?.querySelector('canvas') ?? null + const debugWindow = window as SkinPreviewDebugWindow + const entries = (debugWindow.__SKIN_PREVIEW_DEBUG__ ??= []) + const snapshot = JSON.parse( + JSON.stringify({ + camera: cameraConfig.value, + currentInputs: { + isModelLoaded: isModelLoaded.value, + modelCenter: modelCenter.value, + modelSize: modelSize.value, + padding: resolvedFitPadding.value, + rotation: modelRotation.value, + visibleBounds: visibleBounds.value, + }, + dom: { + canvas: { + ...serializeElement(canvas), + bufferHeight: canvas instanceof HTMLCanvasElement ? canvas.height : null, + bufferWidth: canvas instanceof HTMLCanvasElement ? canvas.width : null, + }, + container: serializeElement(container), + controls: serializeElement( + container?.querySelector('[data-skin-preview-debug="controls"]') ?? null, + ), + nametag: serializeElement( + container?.querySelector('[data-skin-preview-debug="nametag"]') ?? null, + ), + subtitle: serializeElement( + container?.querySelector('[data-skin-preview-debug="subtitle"]') ?? null, + ), + }, + effectiveFit: { + containerSize: fitContainerSize.value, + modelCenter: fitModelCenter.value, + modelOffset: modelOffset.value, + modelSize: fitModelSize.value, + padding: fitResolvedPadding.value, + rotation: fitModelRotation.value, + visibleBounds: fitVisibleBounds.value, + }, + fitEnabled: fitEnabled.value, + fitLock: fitLock.value, + lockFitEnabled: lockFitEnabled.value, + meshBounds: getVisibleMeshDebugBounds(scene.value), + overlayCalculations: { + nametagTop: nametagTop.value, + previewControlsPositionStyle: previewControlsPositionStyle.value, + projectedVisibleBounds: projectedVisibleBounds.value, + subtitlePositionStyle: subtitlePositionStyle.value, + }, + reasons, + timestamp: new Date().toISOString(), + window: { + devicePixelRatio: window.devicePixelRatio, + innerHeight: window.innerHeight, + innerWidth: window.innerWidth, + }, + }), + ) as SkinPreviewDebugEntry + + entries.push(snapshot) + if (entries.length > 100) entries.splice(0, entries.length - 100) + console.log('[SkinPreviewDebug]', snapshot) + } + + function queueDebugSnapshot(reason: string) { + if (typeof window === 'undefined') return + + pendingDebugReasons.add(reason) + if (debugAnimationFrame !== null) return + + debugAnimationFrame = window.requestAnimationFrame(() => { + debugAnimationFrame = null + const reasons = Array.from(pendingDebugReasons) + pendingDebugReasons.clear() + captureDebugSnapshot(reasons) + }) + } + function lockFitState() { if (!fitEnabled.value || !lockFitEnabled.value || fitLock.value || !isModelLoaded.value) return @@ -304,7 +485,9 @@ export function useSkinPreviewFit({ modelSize: cloneModelTuple(modelSize.value), padding: { ...resolvedFitPadding.value }, rotation: modelRotation.value, + visibleBounds: cloneBounds(visibleBounds.value), } + queueDebugSnapshot('fit-lock-created') } function resetFitLockForLayoutChange() { @@ -312,6 +495,7 @@ export function useSkinPreviewFit({ fitLock.value = null lockFitState() + queueDebugSnapshot('fit-lock-reset') } onMounted(() => { @@ -332,10 +516,12 @@ export function useSkinPreviewFit({ if (didContainerSizeChange) { resetFitLockForLayoutChange() + queueDebugSnapshot('container-resized') } }) resizeObserver.observe(el) + queueDebugSnapshot('mounted') }) watch( @@ -358,8 +544,22 @@ export function useSkinPreviewFit({ lockFitState() }) + watch([modelCenter, modelSize, visibleBounds, resolvedFitPadding], () => { + resetFitLockForLayoutChange() + queueDebugSnapshot('model-inputs-changed') + }) + + watch( + projectedVisibleBounds, + () => { + queueDebugSnapshot('projection-changed') + }, + { deep: true }, + ) + onUnmounted(() => { resizeObserver?.disconnect() + if (debugAnimationFrame !== null) window.cancelAnimationFrame(debugAnimationFrame) }) return { diff --git a/packages/ui/src/composables/skin-rendering/use-skin-preview-scene.ts b/packages/ui/src/composables/skin-rendering/use-skin-preview-scene.ts index 19884b7968..0812fa1ef2 100644 --- a/packages/ui/src/composables/skin-rendering/use-skin-preview-scene.ts +++ b/packages/ui/src/composables/skin-rendering/use-skin-preview-scene.ts @@ -20,8 +20,8 @@ import { loadTexture as loadSkinTexture, } from '#ui/utils/webgl/skin-rendering.ts' -import type { SkinPreviewTuple } from './types' -import { applyEarsMod, removeEarsMod } from './use-ears-mod-features' +import type { SkinPreviewBounds, SkinPreviewTuple } from './types' +import { applyEarsMod, isEarsModFeature, removeEarsMod } from './use-ears-mod-features' const SKIN_LAYER_DEPTH_BIAS = -1 @@ -77,16 +77,25 @@ function disposeSceneMaterials(root: THREE.Object3D | null) { materials.forEach((material) => material.dispose()) } -function getVisibleMeshBox(root: THREE.Object3D): THREE.Box3 | null { +function getVisibleMeshBox( + root: THREE.Object3D, + includeMesh: (mesh: THREE.Mesh) => boolean = () => true, +): THREE.Box3 | null { root.updateWorldMatrix(true, true) const result = new THREE.Box3() const meshBox = new THREE.Box3() + const rootParentInverse = new THREE.Matrix4() + const meshToRootParent = new THREE.Matrix4() let found = false + if (root.parent) { + rootParentInverse.copy(root.parent.matrixWorld).invert() + } + root.traverse((object) => { const mesh = object as THREE.Mesh - if (!mesh.isMesh || !mesh.geometry || mesh.visible === false) return + if (!mesh.isMesh || !mesh.geometry || mesh.visible === false || !includeMesh(mesh)) return const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material] if (materials.length && materials.every((material) => material.visible === false)) return @@ -97,7 +106,8 @@ function getVisibleMeshBox(root: THREE.Object3D): THREE.Box3 | null { if (!mesh.geometry.boundingBox) return - meshBox.copy(mesh.geometry.boundingBox).applyMatrix4(mesh.matrixWorld) + meshToRootParent.multiplyMatrices(rootParentInverse, mesh.matrixWorld) + meshBox.copy(mesh.geometry.boundingBox).applyMatrix4(meshToRootParent) result.union(meshBox) found = true }) @@ -105,6 +115,13 @@ function getVisibleMeshBox(root: THREE.Object3D): THREE.Box3 | null { return found && !result.isEmpty() ? result.clone() : null } +function isPlayerMesh(mesh: THREE.Mesh) { + if (isEarsModFeature(mesh)) return false + + const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material] + return materials.some((material) => material.name !== 'cape') +} + type MaybeReadonlyRef = Ref | ComputedRef export function useSkinPreviewScene({ @@ -136,6 +153,10 @@ export function useSkinPreviewScene({ const transparentTexture = createTransparentTexture() const modelCenter = ref([0, 1, 0]) const modelSize = ref([1, 2, 1]) + const visibleBounds = ref({ + min: [-0.5, 0, -0.5], + max: [0.5, 2, 0.5], + }) const isModelLoaded = ref(false) const isTextureLoaded = ref(false) const hasEarsFeatures = ref(false) @@ -252,6 +273,7 @@ export function useSkinPreviewScene({ capeTexture.value = loadedCapeTexture loadedCapeSrc.value = src applyCapeTextureToLoadedModel() + updateModelInfo() } async function loadAndApplyEarsTexture(src: string | undefined) { @@ -269,21 +291,44 @@ export function useSkinPreviewScene({ function updateModelInfo() { const box = scene.value ? getVisibleMeshBox(scene.value) : null + const playerBox = scene.value ? getVisibleMeshBox(scene.value, isPlayerMesh) : null if (!box) { modelCenter.value = [0, 1, 0] modelSize.value = [1, 2, 1] + visibleBounds.value = { + min: [-0.5, 0, -0.5], + max: [0.5, 2, 0.5], + } return } const center = new THREE.Vector3() - const size = new THREE.Vector3() + const playerCenter = new THREE.Vector3() + const rotationCenterBox = playerBox ?? box box.getCenter(center) - box.getSize(size) + rotationCenterBox.getCenter(playerCenter) - modelCenter.value = [center.x, center.y, center.z] - modelSize.value = [Math.max(size.x, 0.001), Math.max(size.y, 0.001), Math.max(size.z, 0.001)] + const halfWidth = Math.max( + Math.abs(box.min.x - playerCenter.x), + Math.abs(box.max.x - playerCenter.x), + ) + const halfDepth = Math.max( + Math.abs(box.min.z - playerCenter.z), + Math.abs(box.max.z - playerCenter.z), + ) + + modelCenter.value = [playerCenter.x, center.y, playerCenter.z] + modelSize.value = [ + Math.max(halfWidth * 2, 0.001), + Math.max(box.max.y - box.min.y, 0.001), + Math.max(halfDepth * 2, 0.001), + ] + visibleBounds.value = { + min: [box.min.x, box.min.y, box.min.z], + max: [box.max.x, box.max.y, box.max.z], + } } watch( @@ -303,6 +348,7 @@ export function useSkinPreviewScene({ texture.value = loadedTexture loadedTextureSrc.value = newSrc applyTextureToLoadedModel() + updateModelInfo() isTextureLoaded.value = true }, ) @@ -365,5 +411,6 @@ export function useSkinPreviewScene({ modelCenter, modelSize, scene, + visibleBounds, } } diff --git a/packages/ui/src/utils/webgl/skin-rendering.ts b/packages/ui/src/utils/webgl/skin-rendering.ts index 9505f6e00f..cead47d9a4 100644 --- a/packages/ui/src/utils/webgl/skin-rendering.ts +++ b/packages/ui/src/utils/webgl/skin-rendering.ts @@ -167,7 +167,7 @@ export function applyTexture(model: THREE.Object3D, texture: THREE.Texture): voi const propertiesNeedUpdate = setShaderMaterialProperties(mat, { alphaTest: 0.1, flatShading: true, - side: THREE.FrontSide, + side: isSkinLayer ? THREE.DoubleSide : THREE.FrontSide, toneMapped: false, transparent: isSkinLayer, })