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,
})