fix: skins QA problems + flow change (#6216)

* fix: skins backend bugs + apply flow

* fix: caching structure

* feat: collapse already duplicated skins + fix moj api spam

* fix: doc

* fix: flatten migrations

* feat: remove default cape/cape override concept

* fix: fmt + lint

* feat: remove SelectCapeModal for inline cape list

* feat: qa

* feat: virtualisation of skins sections + fix texture/model cache

* fix: lint

* fix: virt bugs + renderer fixes

* fix: qa bugs

* fix: doc

* fix: re-add click impulse anim from prototypes + re-add interact anim length cap

* fix: regressions

* devex: split up SkinPreviewrenderer

* fix: lint

* fix: introduce dynamic mode in virtual-scroll.ts

* feat: qa

* fix: nametag bug + remove minecon skin pack suffix

* feat: pain (literally)

* feat: user agent on moj reqs

* feat: impl per account flush queue for operations

* fix: breadcrumb

* chore: i18n pass

* fix: lint + prep + check

* fix: misalignments
This commit is contained in:
Calum H.
2026-05-27 23:22:24 +01:00
committed by GitHub
parent 64edf2ddeb
commit 84b91f32f8
55 changed files with 5651 additions and 2138 deletions
@@ -0,0 +1,6 @@
export * from './types'
export * from './use-skin-preview-animation'
export * from './use-skin-preview-controls'
export * from './use-skin-preview-fit'
export * from './use-skin-preview-loading'
export * from './use-skin-preview-scene'
@@ -0,0 +1,28 @@
export interface SkinPreviewAnimationConfig {
baseAnimation: string
randomAnimations: string[]
randomAnimationInterval?: number
transitionDuration?: number
}
export type SkinPreviewFraming = 'page' | 'modal'
export interface SkinPreviewFitPadding {
top: number
right: number
bottom: number
left: number
}
export interface SkinPreviewFitLock {
containerSize: {
width: number
height: number
}
modelCenter: SkinPreviewTuple
modelSize: SkinPreviewTuple
padding: SkinPreviewFitPadding
rotation: number
}
export type SkinPreviewTuple = [number, number, number]
@@ -0,0 +1,406 @@
import { useRenderLoop } from '@tresjs/core'
import * as THREE from 'three'
import { computed, type ComputedRef, type Ref, ref, watch } from 'vue'
import type { SkinPreviewAnimationConfig } from './types'
type AnimationFinishedListener = (
event: THREE.AnimationMixerEventMap['finished'] & {
readonly type: 'finished'
readonly target: THREE.AnimationMixer
},
) => void
export const INTERACT_ANIMATION_NAME = 'interact'
const INTERACT_VISIBLE_DURATION_SECONDS = 0.5
const CLICK_IMPULSE_MAX_ENERGY = 5
const CLICK_IMPULSE_ENERGY_PER_CLICK = 1
const CLICK_IMPULSE_DECAY_PER_SECOND = 6
const CLICK_IMPULSE_BASE_SPEED = 18
const CLICK_IMPULSE_SPEED_BOOST = 7
const CLICK_IMPULSE_OFFSET_X = 0.035
const CLICK_IMPULSE_ROTATION_Z = 0.055
const CLICK_IMPULSE_SCALE_X = 0.018
const CLICK_IMPULSE_SCALE_Y = 0.025
const DAMAGE_FLASH_DURATION_SECONDS = 0.2
const DAMAGE_FLASH_REPEAT_DELAY_SECONDS = 0.5
const DAMAGE_FLASH_MAX_INTENSITY = 0.7
type MaybeReadonlyRef<T> = Ref<T> | ComputedRef<T>
export function useSkinPreviewAnimation(
animationConfig: MaybeReadonlyRef<SkinPreviewAnimationConfig | undefined>,
) {
const mixer = ref<THREE.AnimationMixer | null>(null)
const actions = ref<Record<string, THREE.AnimationAction>>({})
const clock = new THREE.Clock()
const currentAnimation = ref<string>('')
const randomAnimationTimer = ref<number | null>(null)
const lastRandomAnimation = ref<string>('')
const animationFinishedListeners: AnimationFinishedListener[] = []
const clickImpulseEnergy = ref(0)
const clickImpulsePhase = ref(0)
const clickImpulseOffsetX = ref(0)
const clickImpulseRotationZ = ref(0)
const clickImpulseScaleX = ref(1)
const clickImpulseScaleY = ref(1)
const damageFlashIntensity = ref(0)
let damageFlashRemainingSeconds = 0
let damageFlashCooldownSeconds = 0
const baseAnimation = computed(() => animationConfig.value?.baseAnimation ?? '')
const randomAnimations = computed(() => animationConfig.value?.randomAnimations ?? [])
const transitionDuration = computed(() => animationConfig.value?.transitionDuration || 0.3)
function initializeAnimations(loadedScene: THREE.Object3D, clips: THREE.AnimationClip[]) {
if (!clips || clips.length === 0) {
console.warn('No animation clips found in the model')
return
}
mixer.value = new THREE.AnimationMixer(loadedScene)
clock.start()
actions.value = {}
clips.forEach((clip) => {
if (clip.name === INTERACT_ANIMATION_NAME) {
clip.duration = INTERACT_VISIBLE_DURATION_SECONDS
}
const action = mixer.value!.clipAction(clip)
action.setLoop(THREE.LoopOnce, 1)
action.clampWhenFinished = true
actions.value[clip.name] = action
})
if (baseAnimation.value && actions.value[baseAnimation.value]) {
actions.value[baseAnimation.value].setLoop(THREE.LoopRepeat, Infinity)
playAnimation(baseAnimation.value, true)
setupRandomAnimationLoop()
} else {
console.warn(`Base animation "${baseAnimation.value}" not found`)
const firstAnimationName = Object.keys(actions.value)[0]
if (firstAnimationName) {
actions.value[firstAnimationName].setLoop(THREE.LoopRepeat, Infinity)
playAnimation(firstAnimationName, true)
}
}
}
function playAnimation(name: string, immediate = false) {
if (!mixer.value || !actions.value[name]) {
console.warn(`Animation "${name}" not found!`)
return false
}
const action = actions.value[name]
if (currentAnimation.value === name && action.isRunning() && name !== baseAnimation.value) {
console.log(`Animation "${name}" is already running, ignoring request`)
return false
}
Object.entries(actions.value).forEach(([actionName, actionInstance]) => {
if (actionName !== name && actionInstance.isRunning()) {
actionInstance.fadeOut(transitionDuration.value)
}
})
action.reset()
if (name === baseAnimation.value) {
action.setLoop(THREE.LoopRepeat, Infinity)
} else {
action.setLoop(THREE.LoopOnce, 1)
action.clampWhenFinished = true
const onFinished: AnimationFinishedListener = (event) => {
if (event.action === action) {
removeAnimationFinishedListener(onFinished)
if (currentAnimation.value === name && baseAnimation.value) {
action.fadeOut(transitionDuration.value)
const baseAction = actions.value[baseAnimation.value]
if (baseAction) {
baseAction.reset()
baseAction.fadeIn(transitionDuration.value)
baseAction.play()
currentAnimation.value = baseAnimation.value
}
}
}
}
addAnimationFinishedListener(onFinished)
}
if (immediate) {
action.setEffectiveWeight(1)
} else {
action.fadeIn(transitionDuration.value)
}
action.play()
if (immediate) {
mixer.value.update(0)
}
currentAnimation.value = name
return true
}
function setupRandomAnimationLoop() {
const interval = animationConfig.value?.randomAnimationInterval || 10000
function scheduleNextAnimation() {
if (randomAnimationTimer.value) {
clearTimeout(randomAnimationTimer.value)
}
randomAnimationTimer.value = window.setTimeout(() => {
if (randomAnimations.value.length > 0 && currentAnimation.value === baseAnimation.value) {
const availableAnimations = randomAnimations.value.filter(
(anim) => anim !== lastRandomAnimation.value,
)
const animationsToChooseFrom =
availableAnimations.length > 0 ? availableAnimations : randomAnimations.value
const randomIndex = Math.floor(Math.random() * animationsToChooseFrom.length)
const randomAnimationName = animationsToChooseFrom[randomIndex]
if (actions.value[randomAnimationName]) {
lastRandomAnimation.value = randomAnimationName
playRandomAnimation(randomAnimationName)
}
} else {
scheduleNextAnimation()
}
}, interval)
}
scheduleNextAnimation()
}
function playRandomAnimation(name: string) {
if (!mixer.value || !actions.value[name]) {
console.warn(`Animation "${name}" not found!`)
return
}
const action = actions.value[name]
if (currentAnimation.value === name && action.isRunning()) {
console.log(`Animation "${name}" is already running, ignoring request`)
return
}
const baseAction = baseAnimation.value ? actions.value[baseAnimation.value] : undefined
if (baseAction?.isRunning()) {
baseAction.fadeOut(transitionDuration.value)
}
action.reset()
action.setLoop(THREE.LoopOnce, 1)
action.clampWhenFinished = true
action.setEffectiveTimeScale(1)
action.fadeIn(transitionDuration.value)
action.play()
currentAnimation.value = name
const onFinished: AnimationFinishedListener = (event) => {
if (event.action === action) {
removeAnimationFinishedListener(onFinished)
if (currentAnimation.value === name && baseAnimation.value) {
action.fadeOut(transitionDuration.value)
const nextBaseAction = actions.value[baseAnimation.value]
if (nextBaseAction) {
nextBaseAction.reset()
nextBaseAction.setEffectiveTimeScale(1)
nextBaseAction.fadeIn(transitionDuration.value)
nextBaseAction.play()
currentAnimation.value = baseAnimation.value
setupRandomAnimationLoop()
}
}
}
}
addAnimationFinishedListener(onFinished)
}
function playInteractAnimation() {
if (actions.value[INTERACT_ANIMATION_NAME]) {
playRandomAnimation(INTERACT_ANIMATION_NAME)
}
}
function playClickInteraction() {
addClickImpulse()
playInteractAnimation()
}
function addClickImpulse() {
clickImpulseEnergy.value = Math.min(
CLICK_IMPULSE_MAX_ENERGY,
clickImpulseEnergy.value + CLICK_IMPULSE_ENERGY_PER_CLICK,
)
if (clickImpulseEnergy.value >= CLICK_IMPULSE_MAX_ENERGY && damageFlashCooldownSeconds <= 0) {
triggerDamageFlash()
}
}
function updateClickImpulse(delta: number) {
const energy = Math.max(0, clickImpulseEnergy.value - CLICK_IMPULSE_DECAY_PER_SECOND * delta)
clickImpulseEnergy.value = energy
if (energy <= 0) {
clickImpulseOffsetX.value = 0
clickImpulseRotationZ.value = 0
clickImpulseScaleX.value = 1
clickImpulseScaleY.value = 1
return
}
const intensity = energy / CLICK_IMPULSE_MAX_ENERGY
clickImpulsePhase.value +=
delta * (CLICK_IMPULSE_BASE_SPEED + energy * CLICK_IMPULSE_SPEED_BOOST)
const shake = Math.sin(clickImpulsePhase.value) * intensity
const squash = Math.abs(Math.sin(clickImpulsePhase.value * 1.7)) * intensity
clickImpulseOffsetX.value = shake * CLICK_IMPULSE_OFFSET_X
clickImpulseRotationZ.value = shake * CLICK_IMPULSE_ROTATION_Z
clickImpulseScaleX.value = 1 + squash * CLICK_IMPULSE_SCALE_X
clickImpulseScaleY.value = 1 - squash * CLICK_IMPULSE_SCALE_Y
}
function triggerDamageFlash() {
damageFlashRemainingSeconds = DAMAGE_FLASH_DURATION_SECONDS
damageFlashCooldownSeconds = DAMAGE_FLASH_DURATION_SECONDS + DAMAGE_FLASH_REPEAT_DELAY_SECONDS
damageFlashIntensity.value = DAMAGE_FLASH_MAX_INTENSITY
}
function updateDamageFlash(delta: number) {
damageFlashCooldownSeconds = Math.max(0, damageFlashCooldownSeconds - delta)
if (damageFlashRemainingSeconds <= 0) {
damageFlashIntensity.value = 0
return
}
damageFlashRemainingSeconds = Math.max(0, damageFlashRemainingSeconds - delta)
damageFlashIntensity.value =
DAMAGE_FLASH_MAX_INTENSITY * (damageFlashRemainingSeconds / DAMAGE_FLASH_DURATION_SECONDS)
}
function stopAnimations() {
if (mixer.value) {
mixer.value.stopAllAction()
}
currentAnimation.value = ''
}
function getAvailableAnimations(): string[] {
return Object.keys(actions.value)
}
function clearRandomAnimationTimer() {
if (randomAnimationTimer.value) {
clearTimeout(randomAnimationTimer.value)
randomAnimationTimer.value = null
}
}
function addAnimationFinishedListener(listener: AnimationFinishedListener) {
mixer.value?.addEventListener('finished', listener)
animationFinishedListeners.push(listener)
}
function removeAnimationFinishedListener(
listener: AnimationFinishedListener,
targetMixer = mixer.value,
) {
targetMixer?.removeEventListener('finished', listener)
const index = animationFinishedListeners.indexOf(listener)
if (index !== -1) {
animationFinishedListeners.splice(index, 1)
}
}
function clearAnimationFinishedListeners(targetMixer = mixer.value) {
animationFinishedListeners.forEach((listener) => {
targetMixer?.removeEventListener('finished', listener)
})
animationFinishedListeners.length = 0
}
function cleanupAnimationState(root: THREE.Object3D | null) {
clearRandomAnimationTimer()
const currentMixer = mixer.value
if (currentMixer) {
clearAnimationFinishedListeners(currentMixer)
currentMixer.stopAllAction()
if (root) {
currentMixer.uncacheRoot(root)
}
}
mixer.value = null
actions.value = {}
currentAnimation.value = ''
lastRandomAnimation.value = ''
damageFlashRemainingSeconds = 0
damageFlashCooldownSeconds = 0
damageFlashIntensity.value = 0
}
watch(
() => animationConfig.value,
(newConfig) => {
clearRandomAnimationTimer()
if (mixer.value && newConfig?.baseAnimation && actions.value[newConfig.baseAnimation]) {
playAnimation(newConfig.baseAnimation)
setupRandomAnimationLoop()
}
},
{ deep: true },
)
const { onLoop } = useRenderLoop()
onLoop(() => {
const delta = clock.getDelta()
if (mixer.value) {
mixer.value.update(delta)
}
updateClickImpulse(delta)
updateDamageFlash(delta)
})
return {
clickImpulseOffsetX,
clickImpulseRotationZ,
clickImpulseScaleX,
clickImpulseScaleY,
cleanupAnimationState,
currentAnimation,
damageFlashIntensity,
getAvailableAnimations,
initializeAnimations,
playAnimation,
playClickInteraction,
stopAnimations,
}
}
@@ -0,0 +1,61 @@
import { type ComputedRef, type Ref, ref } from 'vue'
type MaybeReadonlyRef<T> = Ref<T> | ComputedRef<T>
export function useSkinPreviewControls({
initialRotation,
onClickWithoutDrag,
}: {
initialRotation: MaybeReadonlyRef<number | undefined>
onClickWithoutDrag: () => void
}) {
const modelRotation = ref((initialRotation.value ?? 15.75) + Math.PI)
const isDragging = ref(false)
const previousX = ref(0)
const hasDragged = ref(false)
function onPointerDown(event: PointerEvent) {
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
isDragging.value = true
previousX.value = event.clientX
hasDragged.value = false
}
function onPointerMove(event: PointerEvent) {
if (!isDragging.value) return
const deltaX = event.clientX - previousX.value
modelRotation.value += deltaX * 0.01
previousX.value = event.clientX
hasDragged.value = true
}
function onPointerUp(event: PointerEvent) {
isDragging.value = false
const target = event.currentTarget as HTMLElement
if (target.hasPointerCapture(event.pointerId)) {
target.releasePointerCapture(event.pointerId)
}
}
function onCanvasClick() {
if (!hasDragged.value) {
onClickWithoutDrag()
}
hasDragged.value = false
}
function ignoreControlClick(event: MouseEvent) {
event.stopPropagation()
}
return {
ignoreControlClick,
modelRotation,
onCanvasClick,
onPointerDown,
onPointerMove,
onPointerUp,
}
}
@@ -0,0 +1,380 @@
import * as THREE from 'three'
import {
computed,
type ComputedRef,
type CSSProperties,
onMounted,
onUnmounted,
type Ref,
ref,
watch,
} from 'vue'
import type {
SkinPreviewFitLock,
SkinPreviewFitPadding,
SkinPreviewFraming,
SkinPreviewTuple,
} from './types'
const FRAMING_PRESETS = {
page: {
fov: 35,
zoom: 0.96,
padding: { top: 0.2, right: 0.14, bottom: 0.3, left: 0.14 },
},
modal: {
fov: 35,
zoom: 1,
padding: { top: 0.1, right: 0.1, bottom: 0.18, left: 0.1 },
},
} satisfies Record<
SkinPreviewFraming,
{ fov: number; zoom: number; padding: SkinPreviewFitPadding }
>
const PREVIEW_CONTROLS_FOOT_OFFSET = 64
const SUBTITLE_CONTROLS_OFFSET = 48
const NAMETAG_HEAD_OFFSET = 16
function cloneModelTuple(tuple: SkinPreviewTuple): SkinPreviewTuple {
return [tuple[0], tuple[1], tuple[2]]
}
type MaybeReadonlyRef<T> = Ref<T> | ComputedRef<T>
export function useSkinPreviewFit({
containerElement,
fit,
lockFit,
framing,
fitZoom,
fitPadding,
scale,
fov,
modelRotation,
nametag,
hasSubtitle,
hasNametagBadge,
subtitleWrapped,
modelCenter,
modelSize,
isModelLoaded,
}: {
containerElement: MaybeReadonlyRef<HTMLElement | null>
fit: MaybeReadonlyRef<boolean | undefined>
lockFit: MaybeReadonlyRef<boolean | undefined>
framing: MaybeReadonlyRef<SkinPreviewFraming | undefined>
fitZoom: MaybeReadonlyRef<number | undefined>
fitPadding: MaybeReadonlyRef<Partial<SkinPreviewFitPadding> | undefined>
scale: MaybeReadonlyRef<number | undefined>
fov: MaybeReadonlyRef<number | undefined>
modelRotation: MaybeReadonlyRef<number>
nametag: MaybeReadonlyRef<string | undefined>
hasSubtitle: MaybeReadonlyRef<boolean>
hasNametagBadge: MaybeReadonlyRef<boolean>
subtitleWrapped: MaybeReadonlyRef<boolean>
modelCenter: MaybeReadonlyRef<SkinPreviewTuple>
modelSize: MaybeReadonlyRef<SkinPreviewTuple>
isModelLoaded: MaybeReadonlyRef<boolean>
}) {
const containerSize = ref({ width: 1, height: 1 })
const fitLock = ref<SkinPreviewFitLock | null>(null)
let resizeObserver: ResizeObserver | undefined
const fitEnabled = computed(() => {
if (fit.value !== undefined) return fit.value
return scale.value === undefined && fov.value === undefined
})
const currentFraming = computed<SkinPreviewFraming>(() => framing.value ?? 'page')
const lockFitEnabled = computed(() => currentFraming.value === 'page' || (lockFit.value ?? true))
const legacyScale = computed(() => scale.value ?? 1)
const legacyFov = computed(() => fov.value ?? 40)
const hasUsableFitSize = computed(
() => containerSize.value.width > 1 && containerSize.value.height > 1,
)
const hasResolvedFit = computed(
() =>
!fitEnabled.value || (lockFitEnabled.value ? fitLock.value !== null : hasUsableFitSize.value),
)
const fitContainerSize = computed(() =>
lockFitEnabled.value
? (fitLock.value?.containerSize ?? containerSize.value)
: containerSize.value,
)
const fitModelCenter = computed(() =>
lockFitEnabled.value ? (fitLock.value?.modelCenter ?? modelCenter.value) : modelCenter.value,
)
const fitModelSize = computed(() =>
lockFitEnabled.value ? (fitLock.value?.modelSize ?? modelSize.value) : modelSize.value,
)
const fitModelRotation = computed(() =>
lockFitEnabled.value ? (fitLock.value?.rotation ?? modelRotation.value) : modelRotation.value,
)
const resolvedFitPadding = computed<SkinPreviewFitPadding>(() => {
const preset = FRAMING_PRESETS[currentFraming.value].padding
return {
top: Math.max(preset.top, hasNametagBadge.value ? 0.28 : nametag.value ? 0.2 : 0),
right: preset.right,
bottom: Math.max(preset.bottom, hasSubtitle.value ? 0.28 : preset.bottom),
left: preset.left,
...(fitPadding.value ?? {}),
}
})
const fitResolvedPadding = computed(() =>
lockFitEnabled.value
? (fitLock.value?.padding ?? resolvedFitPadding.value)
: resolvedFitPadding.value,
)
const modelOffset = computed<SkinPreviewTuple>(() => {
if (!fitEnabled.value) return [0, 0, 0]
const [x, y, z] = fitModelCenter.value
return [-x, -y, -z]
})
const modelGroupPosition = computed<SkinPreviewTuple>(() => {
if (fitEnabled.value) return [0, 0, 0]
return [0, -0.05 * legacyScale.value, 1.95]
})
const modelGroupScale = computed<SkinPreviewTuple>(() => {
if (fitEnabled.value) return [1, 1, 1]
const resolvedScale = 0.8 * legacyScale.value
return [resolvedScale, resolvedScale, resolvedScale]
})
const fittedCamera = computed(() => {
const width = Math.max(fitContainerSize.value.width, 1)
const height = Math.max(fitContainerSize.value.height, 1)
const aspect = width / height
const preset = FRAMING_PRESETS[currentFraming.value]
const padding = fitResolvedPadding.value
const usableWidth = Math.max(width * (1 - padding.left - padding.right), 1)
const usableHeight = Math.max(height * (1 - padding.top - padding.bottom), 1)
const [sizeX, sizeY, sizeZ] = fitModelSize.value
const halfWidth = Math.sqrt((sizeX / 2) ** 2 + (sizeZ / 2) ** 2)
const halfHeight = sizeY / 2
const resolvedFov = fov.value ?? preset.fov
const verticalFov = THREE.MathUtils.degToRad(resolvedFov)
const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * aspect)
const paddedHalfWidth = halfWidth * (width / usableWidth)
const paddedHalfHeight = halfHeight * (height / usableHeight)
const zoom = Math.max((fitZoom.value ?? 1) * preset.zoom, 0.01)
const distance =
Math.max(
paddedHalfHeight / Math.tan(verticalFov / 2),
paddedHalfWidth / Math.tan(horizontalFov / 2),
) / zoom
const visibleHalfHeight = distance * Math.tan(verticalFov / 2)
const targetY = -(padding.bottom - padding.top) * visibleHalfHeight
return {
fov: resolvedFov,
position: [0, targetY, -distance] as SkinPreviewTuple,
target: [0, targetY, 0] as SkinPreviewTuple,
}
})
const cameraConfig = computed(() => {
if (fitEnabled.value) return fittedCamera.value
return {
fov: legacyFov.value,
position: [0, 1.5, -3.25] as SkinPreviewTuple,
target: modelCenter.value,
}
})
const modelFeetTop = computed(() => {
if (!fitEnabled.value) return null
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)
return (topPercent / 100) * height
})
const previewControlsTop = computed(() =>
modelFeetTop.value === null ? null : modelFeetTop.value + PREVIEW_CONTROLS_FOOT_OFFSET,
)
const previewControlsPositionStyle = computed<CSSProperties>(() => {
if (!fitEnabled.value || currentFraming.value !== 'page' || previewControlsTop.value === null) {
return {
bottom: currentFraming.value === 'modal' ? '6%' : 'calc(15% + 64px)',
}
}
return {
top: `${previewControlsTop.value}px`,
}
})
const subtitlePositionStyle = computed<CSSProperties>(() => {
if (!fitEnabled.value || currentFraming.value !== 'page' || previewControlsTop.value === null) {
return {
bottom:
currentFraming.value === 'modal'
? '6%'
: subtitleWrapped.value
? 'calc(15% - 32px)'
: '15%',
}
}
return {
top: `${previewControlsTop.value + SUBTITLE_CONTROLS_OFFSET}px`,
}
})
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 spotlightY = computed(() => {
if (!fitEnabled.value) return -0.1 * legacyScale.value
const [, sizeY] = fitModelSize.value
return -sizeY / 2 - 0.02
})
const spotlightPosition = computed<SkinPreviewTuple>(() => [
0,
spotlightY.value,
fitEnabled.value ? 0 : 2,
])
const spotlightScale = computed<SkinPreviewTuple>(() => {
if (!fitEnabled.value) {
const resolvedScale = 0.75 * legacyScale.value
return [resolvedScale, resolvedScale, resolvedScale]
}
const [sizeX, , sizeZ] = fitModelSize.value
const radius = Math.max(sizeX, sizeZ, 1) * 0.8
return [radius, radius, radius]
})
function lockFitState() {
if (!fitEnabled.value || !lockFitEnabled.value || fitLock.value || !isModelLoaded.value) return
const { width, height } = containerSize.value
if (width <= 1 || height <= 1) return
fitLock.value = {
containerSize: { width, height },
modelCenter: cloneModelTuple(modelCenter.value),
modelSize: cloneModelTuple(modelSize.value),
padding: { ...resolvedFitPadding.value },
rotation: modelRotation.value,
}
}
function resetFitLockForLayoutChange() {
if (!fitEnabled.value || !lockFitEnabled.value) return
fitLock.value = null
lockFitState()
}
onMounted(() => {
const el = containerElement.value
if (!el) return
resizeObserver = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect
const nextContainerSize = {
width: Math.max(width, 1),
height: Math.max(height, 1),
}
const didContainerSizeChange =
nextContainerSize.width !== containerSize.value.width ||
nextContainerSize.height !== containerSize.value.height
containerSize.value = nextContainerSize
if (didContainerSizeChange) {
resetFitLockForLayoutChange()
}
})
resizeObserver.observe(el)
})
watch(
() => isModelLoaded.value,
(loaded) => {
if (loaded) lockFitState()
},
)
watch(
() => lockFitEnabled.value,
() => {
fitLock.value = null
lockFitState()
},
)
watch(fitEnabled, () => {
fitLock.value = null
lockFitState()
})
onUnmounted(() => {
resizeObserver?.disconnect()
})
return {
cameraConfig,
currentFraming,
fitEnabled,
hasResolvedFit,
legacyScale,
modelGroupPosition,
modelGroupScale,
modelOffset,
nametagTop,
previewControlsPositionStyle,
spotlightPosition,
spotlightScale,
subtitlePositionStyle,
}
}
@@ -0,0 +1,87 @@
import { computed, type ComputedRef, onUnmounted, type Ref, ref, watch } from 'vue'
const LOADING_INDICATOR_DELAY_MS = 200
const LOADING_INDICATOR_MIN_MS = 250
type MaybeReadonlyRef<T> = Ref<T> | ComputedRef<T>
export function useSkinPreviewLoading(isReady: MaybeReadonlyRef<boolean>) {
const showLoading = ref(false)
const isPreviewVisible = computed(() => isReady.value && !showLoading.value)
let loadingIndicatorDelayTimer: number | null = null
let loadingIndicatorMinTimer: number | null = null
let loadingIndicatorShownAt = 0
function clearLoadingIndicatorDelayTimer() {
if (loadingIndicatorDelayTimer !== null) {
clearTimeout(loadingIndicatorDelayTimer)
loadingIndicatorDelayTimer = null
}
}
function clearLoadingIndicatorMinTimer() {
if (loadingIndicatorMinTimer !== null) {
clearTimeout(loadingIndicatorMinTimer)
loadingIndicatorMinTimer = null
}
}
function hideLoadingIndicatorAfterMinimum() {
const visibleFor = Date.now() - loadingIndicatorShownAt
const remaining = LOADING_INDICATOR_MIN_MS - visibleFor
if (remaining <= 0) {
showLoading.value = false
return
}
loadingIndicatorMinTimer = window.setTimeout(() => {
showLoading.value = false
loadingIndicatorMinTimer = null
}, remaining)
}
watch(
() => isReady.value,
(ready) => {
clearLoadingIndicatorDelayTimer()
if (ready) {
if (showLoading.value) {
clearLoadingIndicatorMinTimer()
hideLoadingIndicatorAfterMinimum()
}
return
}
clearLoadingIndicatorMinTimer()
if (showLoading.value || typeof window === 'undefined') {
return
}
loadingIndicatorDelayTimer = window.setTimeout(() => {
loadingIndicatorDelayTimer = null
if (isReady.value) {
return
}
showLoading.value = true
loadingIndicatorShownAt = Date.now()
}, LOADING_INDICATOR_DELAY_MS)
},
{ immediate: true },
)
onUnmounted(() => {
clearLoadingIndicatorDelayTimer()
clearLoadingIndicatorMinTimer()
})
return {
isPreviewVisible,
showLoading,
}
}
@@ -0,0 +1,316 @@
import { useGLTF } from '@tresjs/cientos'
import { useTexture } from '@tresjs/core'
import * as THREE from 'three'
import { clone as cloneSkeleton } from 'three/examples/jsm/utils/SkeletonUtils.js'
import {
type ComputedRef,
markRaw,
onBeforeMount,
onUnmounted,
type Ref,
ref,
shallowRef,
watch,
} from 'vue'
import {
applyCapeTexture,
applyTexture,
createTransparentTexture,
loadTexture as loadSkinTexture,
} from '#ui/utils/webgl/skin-rendering.ts'
import type { SkinPreviewTuple } from './types'
const SKIN_LAYER_DEPTH_BIAS = -1
function configureSkinPreviewMesh(mesh: THREE.Mesh) {
const isSkinLayer = mesh.name.endsWith('_Layer')
mesh.renderOrder = 0
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
materials.forEach((material) => {
if (!(material instanceof THREE.MeshStandardMaterial) || material.name === 'cape') return
material.transparent = false
material.alphaTest = 0.1
material.depthTest = true
material.depthWrite = true
material.polygonOffset = isSkinLayer
material.polygonOffsetFactor = isSkinLayer ? SKIN_LAYER_DEPTH_BIAS : 0
material.polygonOffsetUnits = isSkinLayer ? SKIN_LAYER_DEPTH_BIAS : 0
material.needsUpdate = true
})
}
function cloneSceneForRenderer(source: THREE.Object3D) {
const cloned = cloneSkeleton(source)
cloned.traverse((object) => {
const mesh = object as THREE.Mesh
if (!mesh.isMesh || !mesh.material) return
mesh.material = Array.isArray(mesh.material)
? mesh.material.map((material) => material.clone())
: mesh.material.clone()
configureSkinPreviewMesh(mesh)
})
return markRaw(cloned)
}
function disposeSceneMaterials(root: THREE.Object3D | null) {
if (!root) return
const materials = new Set<THREE.Material>()
root.traverse((object) => {
const mesh = object as THREE.Mesh
if (!mesh.isMesh || !mesh.material) return
const meshMaterials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
meshMaterials.forEach((material) => materials.add(material))
})
materials.forEach((material) => material.dispose())
}
function getVisibleMeshBox(root: THREE.Object3D): THREE.Box3 | null {
root.updateWorldMatrix(true, true)
const result = new THREE.Box3()
const meshBox = new THREE.Box3()
let found = false
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
meshBox.copy(mesh.geometry.boundingBox).applyMatrix4(mesh.matrixWorld)
result.union(meshBox)
found = true
})
return found && !result.isEmpty() ? result.clone() : null
}
type MaybeReadonlyRef<T> = Ref<T> | ComputedRef<T>
export function useSkinPreviewScene({
selectedModelSrc,
textureSrc,
capeSrc,
initializeAnimations,
cleanupAnimationState,
}: {
selectedModelSrc: MaybeReadonlyRef<string>
textureSrc: MaybeReadonlyRef<string>
capeSrc: MaybeReadonlyRef<string | undefined>
initializeAnimations: (loadedScene: THREE.Object3D, clips: THREE.AnimationClip[]) => void
cleanupAnimationState: (root: THREE.Object3D | null) => void
}) {
const scene = shallowRef<THREE.Object3D | null>(null)
const lastCapeSrc = ref<string | undefined>(undefined)
const loadedModelSrc = ref<string | undefined>(undefined)
const loadedTextureSrc = ref<string | undefined>(undefined)
const loadedCapeSrc = ref<string | undefined>(undefined)
const texture = shallowRef<THREE.Texture | null>(null)
const capeTexture = shallowRef<THREE.Texture | null>(null)
const transparentTexture = createTransparentTexture()
const modelCenter = ref<SkinPreviewTuple>([0, 1, 0])
const modelSize = ref<SkinPreviewTuple>([1, 2, 1])
const isModelLoaded = ref(false)
const isTextureLoaded = ref(false)
let modelLoadVersion = 0
let textureLoadVersion = 0
let capeLoadVersion = 0
let isUnmounted = false
function applyTextureToLoadedModel() {
if (
!scene.value ||
!texture.value ||
loadedModelSrc.value !== selectedModelSrc.value ||
loadedTextureSrc.value !== textureSrc.value
) {
return
}
applyTexture(scene.value, texture.value)
}
function applyCapeTextureToLoadedModel() {
if (!scene.value || loadedModelSrc.value !== selectedModelSrc.value) return
applyCapeTexture(
scene.value,
loadedCapeSrc.value === capeSrc.value ? capeTexture.value : null,
transparentTexture,
)
}
async function loadModel(src: string) {
const loadVersion = ++modelLoadVersion
try {
isModelLoaded.value = false
const { scene: loadedScene, animations } = await useGLTF(src)
const clonedScene = cloneSceneForRenderer(loadedScene)
if (isUnmounted || loadVersion !== modelLoadVersion) {
disposeSceneMaterials(clonedScene)
return
}
const previousScene = scene.value
cleanupAnimationState(previousScene)
disposeSceneMaterials(previousScene)
scene.value = clonedScene
loadedModelSrc.value = src
applyTextureToLoadedModel()
applyCapeTextureToLoadedModel()
if (animations && animations.length > 0) {
initializeAnimations(clonedScene, animations)
}
updateModelInfo()
isModelLoaded.value = true
} catch (error) {
console.error('Failed to load model:', error)
if (!isUnmounted && loadVersion === modelLoadVersion) {
isModelLoaded.value = false
}
}
}
async function loadAndApplyTexture(src: string) {
if (!src) return null
try {
try {
return await loadSkinTexture(src)
} catch {
const tex = await useTexture([src])
tex.colorSpace = THREE.SRGBColorSpace
tex.flipY = false
tex.magFilter = THREE.NearestFilter
tex.minFilter = THREE.NearestFilter
return tex
}
} catch (error) {
console.error('Failed to load texture:', error)
return null
}
}
async function loadAndApplyCapeTexture(src: string | undefined) {
if (src === lastCapeSrc.value) return
const loadVersion = ++capeLoadVersion
lastCapeSrc.value = src
let loadedCapeTexture: THREE.Texture | null = null
if (src) {
loadedCapeTexture = await loadAndApplyTexture(src)
}
if (isUnmounted || loadVersion !== capeLoadVersion) return
capeTexture.value = loadedCapeTexture
loadedCapeSrc.value = src
applyCapeTextureToLoadedModel()
}
function updateModelInfo() {
const box = scene.value ? getVisibleMeshBox(scene.value) : null
if (!box) {
modelCenter.value = [0, 1, 0]
modelSize.value = [1, 2, 1]
return
}
const center = new THREE.Vector3()
const size = new THREE.Vector3()
box.getCenter(center)
box.getSize(size)
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)]
}
watch(
() => selectedModelSrc.value,
(src) => loadModel(src),
)
watch(
() => textureSrc.value,
async (newSrc) => {
const loadVersion = ++textureLoadVersion
isTextureLoaded.value = false
const loadedTexture = await loadAndApplyTexture(newSrc)
if (isUnmounted || loadVersion !== textureLoadVersion) return
texture.value = loadedTexture
loadedTextureSrc.value = newSrc
applyTextureToLoadedModel()
isTextureLoaded.value = true
},
)
watch(
() => capeSrc.value,
async (newCapeSrc) => {
await loadAndApplyCapeTexture(newCapeSrc)
},
)
onBeforeMount(async () => {
try {
isTextureLoaded.value = false
texture.value = await loadAndApplyTexture(textureSrc.value)
loadedTextureSrc.value = textureSrc.value
isTextureLoaded.value = true
await loadModel(selectedModelSrc.value)
if (capeSrc.value) {
await loadAndApplyCapeTexture(capeSrc.value)
}
} catch (error) {
console.error('Failed to initialize skin preview:', error)
}
})
onUnmounted(() => {
isUnmounted = true
modelLoadVersion++
textureLoadVersion++
capeLoadVersion++
cleanupAnimationState(scene.value)
disposeSceneMaterials(scene.value)
scene.value = null
transparentTexture.dispose()
})
return {
isModelLoaded,
isTextureLoaded,
modelCenter,
modelSize,
scene,
}
}
+100 -78
View File
@@ -1,6 +1,11 @@
import type { Ref } from 'vue'
import { computed, ref, watch, watchEffect } from 'vue'
export interface ScrollViewportOptions {
onScroll?: () => void
onResize?: () => void
}
export interface VirtualScrollOptions {
itemHeight: number
bufferSize?: number
@@ -10,45 +15,35 @@ export interface VirtualScrollOptions {
nearEndThreshold?: number
}
export function useVirtualScroll<T>(items: Ref<T[]>, options: VirtualScrollOptions) {
const {
itemHeight,
bufferSize = 5,
initialItemCount = 20,
enabled,
onNearEnd,
nearEndThreshold = 0.2,
} = options
export function findScrollableAncestor(element: HTMLElement | null): HTMLElement | Window {
if (!element) return window
let current: HTMLElement | null = element.parentElement
while (current) {
const { overflowY } = getComputedStyle(current)
if (overflowY === 'auto' || overflowY === 'scroll') {
return current
}
current = current.parentElement
}
return window
}
export function getScrollTop(container: HTMLElement | Window): number {
return container instanceof Window ? window.scrollY : container.scrollTop
}
export function getViewportHeight(container: HTMLElement | Window): number {
return container instanceof Window ? window.innerHeight : container.clientHeight
}
export function useScrollViewport(options: ScrollViewportOptions = {}) {
const listContainer = ref<HTMLElement | null>(null)
const scrollContainer = ref<HTMLElement | Window | null>(null)
const scrollTop = ref(0)
const viewportHeight = ref(0)
const containerOffset = ref(0)
const totalHeight = computed(() => items.value.length * itemHeight)
function findScrollableAncestor(element: HTMLElement | null): HTMLElement | Window {
if (!element) return window
let current: HTMLElement | null = element.parentElement
while (current) {
const { overflowY } = getComputedStyle(current)
if (overflowY === 'auto' || overflowY === 'scroll') {
return current
}
current = current.parentElement
}
return window
}
function getScrollTop(container: HTMLElement | Window): number {
return container instanceof Window ? window.scrollY : container.scrollTop
}
function getViewportHeight(container: HTMLElement | Window): number {
return container instanceof Window ? window.innerHeight : container.clientHeight
}
const relativeScrollTop = computed(() => Math.max(0, scrollTop.value - containerOffset.value))
function updateContainerOffset() {
const listEl = listContainer.value
@@ -71,6 +66,77 @@ export function useVirtualScroll<T>(items: Ref<T[]>, options: VirtualScrollOptio
updateContainerOffset()
}
function handleScroll() {
if (scrollContainer.value) {
scrollTop.value = getScrollTop(scrollContainer.value)
updateContainerOffset()
}
options.onScroll?.()
}
function handleResize() {
syncScrollState()
options.onResize?.()
}
watchEffect((onCleanup) => {
if (typeof window === 'undefined') return
const listEl = listContainer.value
if (!listEl) return
const container = findScrollableAncestor(listEl)
scrollContainer.value = container
syncScrollState()
container.addEventListener('scroll', handleScroll, { passive: true })
window.addEventListener('resize', handleResize, { passive: true })
let resizeObserver: ResizeObserver | undefined
if (!(container instanceof Window)) {
resizeObserver = new ResizeObserver(() => {
syncScrollState()
})
resizeObserver.observe(container)
}
onCleanup(() => {
container.removeEventListener('scroll', handleScroll)
window.removeEventListener('resize', handleResize)
resizeObserver?.disconnect()
})
})
return {
containerOffset,
listContainer,
relativeScrollTop,
scrollContainer,
scrollTop,
syncScrollState,
updateContainerOffset,
viewportHeight,
}
}
export function useVirtualScroll<T>(items: Ref<T[]>, options: VirtualScrollOptions) {
const {
itemHeight,
bufferSize = 5,
initialItemCount = 20,
enabled,
onNearEnd,
nearEndThreshold = 0.2,
} = options
const { listContainer, relativeScrollTop, scrollContainer, syncScrollState, viewportHeight } =
useScrollViewport({
onScroll: checkNearEnd,
})
const totalHeight = computed(() => items.value.length * itemHeight)
const visibleRange = computed(() => {
if (enabled && !enabled.value) {
return { start: 0, end: items.value.length }
@@ -80,9 +146,7 @@ export function useVirtualScroll<T>(items: Ref<T[]>, options: VirtualScrollOptio
return { start: 0, end: Math.min(items.value.length, initialItemCount) }
}
const relativeScrollTop = Math.max(0, scrollTop.value - containerOffset.value)
const start = Math.floor(relativeScrollTop / itemHeight)
const start = Math.floor(relativeScrollTop.value / itemHeight)
const visibleCount = Math.ceil(viewportHeight.value / itemHeight)
const rangeSize = visibleCount + bufferSize * 2
@@ -117,52 +181,10 @@ export function useVirtualScroll<T>(items: Ref<T[]>, options: VirtualScrollOptio
}
}
function handleScroll() {
if (scrollContainer.value) {
scrollTop.value = getScrollTop(scrollContainer.value)
updateContainerOffset()
}
checkNearEnd()
}
function handleResize() {
syncScrollState()
}
// Re-sync scroll state when items change to avoid stale scrollTop/offset
watch(items, () => {
syncScrollState()
})
watchEffect((onCleanup) => {
if (typeof window === 'undefined') return
const listEl = listContainer.value
if (!listEl) return
const container = findScrollableAncestor(listEl)
scrollContainer.value = container
syncScrollState()
container.addEventListener('scroll', handleScroll, { passive: true })
window.addEventListener('resize', handleResize, { passive: true })
// Use ResizeObserver for element scroll containers
let resizeObserver: ResizeObserver | undefined
if (!(container instanceof Window)) {
resizeObserver = new ResizeObserver(() => {
syncScrollState()
})
resizeObserver.observe(container)
}
onCleanup(() => {
container.removeEventListener('scroll', handleScroll)
window.removeEventListener('resize', handleResize)
resizeObserver?.disconnect()
})
})
return {
listContainer,
totalHeight,