feat: add Ears mod support to skin selector (#6775)

* feat: add Ears mod support to skin selector

* fix: lint

* feat: ears mod notice + toggle + fix uv issues

* fix: lint
This commit is contained in:
Calum H.
2026-07-20 18:01:04 +00:00
committed by GitHub
parent ee2ad5f05b
commit 8b6f0ec695
12 changed files with 2119 additions and 45 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

@@ -11,6 +11,7 @@
<SkinPreviewRenderer
:variant="variant"
:texture-src="previewSkin || ''"
:ears-texture-src="uploadedTextureUrl?.original ?? currentSkin?.texture"
:cape-src="selectedCapeTexture"
framing="modal"
:initial-rotation="Math.PI / 8"
@@ -1,8 +1,11 @@
import { ClassicPlayerModel, SlimPlayerModel } from '@modrinth/assets'
import {
applyCapeTexture,
applyEarsMod,
createTransparentTexture,
disposeCaches,
loadTexture,
removeEarsMod,
setupSkinModel,
} from '@modrinth/ui'
import * as THREE from 'three'
@@ -71,12 +74,13 @@ class BatchSkinRenderer {
textureUrl: string,
modelUrl: string,
capeUrl?: string,
earsTextureUrl?: string,
): Promise<RawRenderResult> {
this.initializeRenderer()
this.clearScene()
await this.setupModel(modelUrl, textureUrl, capeUrl)
await this.setupModel(modelUrl, textureUrl, capeUrl, earsTextureUrl)
const headPart = this.currentModel!.getObjectByName('Head')
let lookAtTarget: [number, number, number]
@@ -123,17 +127,29 @@ class BatchSkinRenderer {
})
}
private async setupModel(modelUrl: string, textureUrl: string, capeUrl?: string): Promise<void> {
private async setupModel(
modelUrl: string,
textureUrl: string,
capeUrl?: string,
earsTextureUrl?: string,
): Promise<void> {
if (!this.scene) {
throw new Error('Renderer not initialized')
}
const { model } = await setupSkinModel(modelUrl, textureUrl, capeUrl)
const [{ model }, earsTexture] = await Promise.all([
setupSkinModel(modelUrl, textureUrl, capeUrl),
earsTextureUrl ? loadTexture(earsTextureUrl) : Promise.resolve(null),
])
if (!capeUrl) {
applyCapeTexture(model, null, this.getTransparentTexture())
}
if (earsTexture) {
applyEarsMod(model, earsTexture)
}
const group = new THREE.Group()
group.add(model)
group.position.set(0, 0.3, 1.95)
@@ -154,6 +170,7 @@ class BatchSkinRenderer {
private clearScene(): void {
if (!this.scene || !this.currentModel) return
removeEarsMod(this.currentModel)
this.scene.remove(this.currentModel)
this.currentModel.clear()
this.currentModel = null
@@ -193,6 +210,7 @@ function getModelUrlForVariant(variant: string): string {
export const skinBlobUrlMap = reactive(new Map<string, RenderResult>())
export const headBlobUrlMap = reactive(new Map<string, string>())
const DEBUG_MODE = false
const SKIN_PREVIEW_RENDER_VERSION = 'ears-1'
let sharedRenderer: BatchSkinRenderer | null = null
let latestPreviewGeneration = 0
@@ -205,6 +223,10 @@ function getSharedRenderer(): BatchSkinRenderer {
return sharedRenderer
}
export function getSkinPreviewKey(skin: Skin): string {
return `${SKIN_PREVIEW_RENDER_VERSION}+${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
}
export function disposeSharedRenderer(): void {
if (sharedRenderer) {
sharedRenderer.dispose()
@@ -217,7 +239,7 @@ export async function cleanupUnusedPreviews(skins: Skin[]): Promise<void> {
const validHeadKeys = new Set<string>()
for (const skin of skins) {
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
const key = getSkinPreviewKey(skin)
const headKey = `${skin.texture_key}-head`
validKeys.add(key)
validHeadKeys.add(headKey)
@@ -382,9 +404,7 @@ async function generateSkinPreviewsForGeneration(
const isCurrentGeneration = () => generation === latestPreviewGeneration
try {
const skinKeys = skins.map(
(skin) => `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`,
)
const skinKeys = skins.map(getSkinPreviewKey)
const headKeys = skins.map((skin) => `${skin.texture_key}-head`)
const [cachedSkinPreviews, cachedHeadPreviews] = await Promise.all([
@@ -415,7 +435,7 @@ async function generateSkinPreviewsForGeneration(
for (const skin of skins) {
if (!isCurrentGeneration()) return
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
const key = getSkinPreviewKey(skin)
if (skinBlobUrlMap.has(key)) {
if (DEBUG_MODE) {
@@ -443,6 +463,7 @@ async function generateSkinPreviewsForGeneration(
await get_normalized_skin_texture(skin),
modelUrl,
cape?.texture,
skin.texture,
)
if (!isCurrentGeneration()) return
@@ -542,6 +542,9 @@
"app.skins.dropped-file-error.title": {
"message": "Error processing file"
},
"app.skins.ears-feature-notice": {
"message": "This skin uses features from the {ears} mod"
},
"app.skins.edit-button": {
"message": "Edit skin"
},
@@ -668,6 +671,12 @@
"app.skins.title": {
"message": "Skin selector"
},
"app.skins.toggle-ears-features-off": {
"message": "Toggle off"
},
"app.skins.toggle-ears-features-on": {
"message": "Toggle on"
},
"app.update-popup.body.download-complete": {
"message": "Modrinth App v{version} has finished downloading. Reload to update now, or automatically when you close Modrinth App."
},
+209 -27
View File
@@ -17,6 +17,7 @@ import {
injectModrinthClient,
injectNotificationManager,
SkinPreviewRenderer,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { arrayBufferToBase64 } from '@modrinth/utils'
@@ -26,13 +27,18 @@ import { computedAsync } from '@vueuse/core'
import type { Ref } from 'vue'
import { computed, inject, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'
import EarsModIcon from '@/assets/skins/ears-mod.png'
import type AccountsCard from '@/components/ui/AccountsCard.vue'
import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue'
import VirtualSkinSectionList from '@/components/ui/skin/VirtualSkinSectionList.vue'
import { trackEvent } from '@/helpers/analytics'
import { check_reachable, get_default_user, login as login_flow, users } from '@/helpers/auth'
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
import { generateSkinPreviews, skinBlobUrlMap } from '@/helpers/rendering/batch-skin-renderer.ts'
import {
generateSkinPreviews,
getSkinPreviewKey,
skinBlobUrlMap,
} from '@/helpers/rendering/batch-skin-renderer.ts'
import type { Cape, Skin, SkinTextureUrl } from '@/helpers/skins.ts'
import {
equip_skin,
@@ -60,6 +66,7 @@ type VirtualSkinSectionListExpose = {
const PENDING_SKIN_REFRESH_DELAY_MS = 11_000
const DEFAULT_SKIN_SECTION_SORT_ORDER = ['Default skins', 'Modrinth Pride']
const EARS_NOTICE_PLACEHOLDER = '__EARS_MOD_NAME__'
const messages = defineMessages({
skinSelectorTitle: {
id: 'app.skins.title',
@@ -163,6 +170,18 @@ const messages = defineMessages({
id: 'app.skins.preview.edit-button',
defaultMessage: 'Edit skin',
},
earsFeatureNotice: {
id: 'app.skins.ears-feature-notice',
defaultMessage: 'This skin uses features from the {ears} mod',
},
toggleEarsFeaturesOff: {
id: 'app.skins.toggle-ears-features-off',
defaultMessage: 'Toggle off',
},
toggleEarsFeaturesOn: {
id: 'app.skins.toggle-ears-features-on',
defaultMessage: 'Toggle on',
},
excitedRinthbotAlt: {
id: 'app.skins.sign-in.rinthbot-alt',
defaultMessage: 'Excited Modrinth Bot',
@@ -204,8 +223,30 @@ const currentUserId = ref<string | undefined>(undefined)
const username = computed(() => currentUser.value?.profile?.name ?? undefined)
const selectedSkin = ref<Skin | null>(null)
const isApplyingSkin = ref(false)
const earsFeaturesEnabled = ref(true)
const selectedSkinHasEarsFeatures = ref(false)
const originalSelectedSkin = ref<Skin | null>(null)
const earsFeatureNoticeParts = computed(() => {
const notice = formatMessage(messages.earsFeatureNotice, {
ears: EARS_NOTICE_PLACEHOLDER,
})
const placeholderIndex = notice.indexOf(EARS_NOTICE_PLACEHOLDER)
if (placeholderIndex === -1) {
return {
before: notice,
after: '',
hasEarsLink: false,
}
}
return {
before: notice.slice(0, placeholderIndex),
after: notice.slice(placeholderIndex + EARS_NOTICE_PLACEHOLDER.length),
hasEarsLink: true,
}
})
const savedSkins = computed(() => {
try {
@@ -755,8 +796,7 @@ async function loadCurrentUser() {
}
function getBakedSkinTextures(skin: Skin): RenderResult | undefined {
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
return skinBlobUrlMap.get(key)
return skinBlobUrlMap.get(getSkinPreviewKey(skin))
}
async function login() {
@@ -940,6 +980,10 @@ watch(
() => {},
)
watch(selectedSkin, () => {
earsFeaturesEnabled.value = true
})
watch(isSkinManagementReadOnly, (readOnly) => {
if (readOnly) {
isDraggingSkinFile.value = false
@@ -1035,9 +1079,12 @@ await loadSkins()
<SkinPreviewRenderer
:cape-src="capeTexture"
:texture-src="skinTexture || ''"
:ears-texture-src="selectedSkin?.texture"
:variant="skinVariant"
:nametag="skinNametag"
:initial-rotation="Math.PI / 8"
:ears-enabled="earsFeaturesEnabled"
@ears-features-detected="selectedSkinHasEarsFeatures = $event"
>
<template v-if="hasPendingSkinChange" #nametag-badge>
<div
@@ -1049,36 +1096,126 @@ await loadSkins()
</template>
<template #subtitle>
<div
v-if="hasPendingSkinChange"
class="flex max-w-[calc(100vw-2rem)] flex-wrap items-center justify-center gap-2 px-2"
class="skin-preview-subtitle flex w-full flex-col items-center gap-6"
:class="{ 'has-ears-features': selectedSkinHasEarsFeatures }"
>
<button
class="flex h-10 min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[14px] border-0 bg-surface-4 px-4 py-2.5 text-base font-semibold leading-5 text-contrast shadow-md transition-[filter,transform] duration-200 enabled:hover:brightness-[--hover-brightness] enabled:focus-visible:brightness-[--hover-brightness] enabled:active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:size-5 [&>svg]:shrink-0"
:disabled="isApplyingSkin || isSkinManagementReadOnly"
@click="resetSelectedSkin"
<div
v-if="hasPendingSkinChange"
class="skin-preview-actions flex w-full items-center justify-center gap-1.5"
:class="selectedSkinHasEarsFeatures ? 'flex-nowrap' : 'flex-wrap'"
>
<RotateCounterClockwiseIcon />
{{ formatMessage(commonMessages.resetButton) }}
</button>
<button
v-tooltip="
selectedSkinHasEarsFeatures
? formatMessage(commonMessages.resetButton)
: undefined
"
class="skin-preview-action-button flex h-10 min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[14px] border-0 bg-surface-4 px-4 py-2.5 text-base font-semibold leading-5 text-contrast shadow-md transition-[filter,transform] duration-200 enabled:hover:brightness-[--hover-brightness] enabled:focus-visible:brightness-[--hover-brightness] enabled:active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:size-5 [&>svg]:shrink-0"
:disabled="isApplyingSkin || isSkinManagementReadOnly"
:aria-label="formatMessage(commonMessages.resetButton)"
@click="resetSelectedSkin"
>
<RotateCounterClockwiseIcon />
<span class="skin-preview-action-label">
{{ formatMessage(commonMessages.resetButton) }}
</span>
</button>
<button
v-tooltip="
selectedSkinHasEarsFeatures ? formatMessage(messages.applyButton) : undefined
"
class="skin-preview-action-button flex h-10 min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[14px] border-0 bg-brand px-4 py-2.5 text-base font-semibold leading-5 text-[rgba(0,0,0,0.9)] shadow-md transition-[filter,transform] duration-200 enabled:hover:brightness-[--hover-brightness] enabled:focus-visible:brightness-[--hover-brightness] enabled:active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:size-5 [&>svg]:shrink-0"
:disabled="isApplyingSkin || isSkinManagementReadOnly"
:aria-label="formatMessage(messages.applyButton)"
@click="applySelectedSkin"
>
<SpinnerIcon v-if="isApplyingSkin" class="animate-spin" />
<CheckIcon v-else />
<span class="skin-preview-action-label">
{{ formatMessage(messages.applyButton) }}
</span>
</button>
</div>
<button
class="flex h-10 min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[14px] border-0 bg-brand px-4 py-2.5 text-base font-semibold leading-5 text-[rgba(0,0,0,0.9)] shadow-md transition-[filter,transform] duration-200 enabled:hover:brightness-[--hover-brightness] enabled:focus-visible:brightness-[--hover-brightness] enabled:active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:size-5 [&>svg]:shrink-0"
:disabled="isApplyingSkin || isSkinManagementReadOnly"
@click="applySelectedSkin"
v-else
class="flex h-10 min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[14px] border-0 bg-surface-4 px-4 py-2.5 text-base font-semibold leading-5 shadow-md transition-[filter,transform] duration-200 enabled:hover:brightness-[--hover-brightness] enabled:focus-visible:brightness-[--hover-brightness] enabled:active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:size-5 [&>svg]:shrink-0"
:disabled="!selectedSkin || isSkinManagementReadOnly"
@click="(e: MouseEvent) => selectedSkin && editSkinModal?.show(e, selectedSkin)"
>
<SpinnerIcon v-if="isApplyingSkin" class="animate-spin" />
<CheckIcon v-else />
{{ formatMessage(messages.applyButton) }}
<EditIcon />
{{ formatMessage(messages.editSkinButton) }}
</button>
<div
v-if="selectedSkinHasEarsFeatures"
class="ears-feature-notice box-border flex w-full max-w-[340px] items-center justify-center gap-1.5 px-2"
>
<div class="ears-feature-copy flex min-w-0 flex-1 items-center gap-1.5">
<img
:src="EarsModIcon"
alt=""
class="size-10 shrink-0 rounded-[7px] border border-solid border-surface-5 object-cover"
/>
<p
class="ears-feature-description m-0 min-w-0 flex-1 text-sm font-medium leading-5 text-primary"
>
{{ earsFeatureNoticeParts.before
}}<router-link
v-if="earsFeatureNoticeParts.hasEarsLink"
to="/project/mfzaZK3Z"
class="text-inherit underline"
>Ears</router-link
>{{ earsFeatureNoticeParts.after }}
</p>
<router-link
to="/project/mfzaZK3Z"
class="ears-feature-compact-label hidden min-w-0 flex-1 text-sm font-medium leading-5 text-primary underline"
>Ears</router-link
>
</div>
<ButtonStyled type="outlined">
<button
class="ears-feature-toggle-button !h-10 !rounded-[14px] !px-4 shadow-md"
:aria-pressed="earsFeaturesEnabled"
:aria-label="
formatMessage(
earsFeaturesEnabled
? messages.toggleEarsFeaturesOff
: messages.toggleEarsFeaturesOn,
)
"
@click="earsFeaturesEnabled = !earsFeaturesEnabled"
>
{{
formatMessage(
earsFeaturesEnabled
? messages.toggleEarsFeaturesOff
: messages.toggleEarsFeaturesOn,
)
}}
</button>
</ButtonStyled>
<Toggle
v-model="earsFeaturesEnabled"
v-tooltip="
formatMessage(
earsFeaturesEnabled
? messages.toggleEarsFeaturesOff
: messages.toggleEarsFeaturesOn,
)
"
small
class="ears-feature-toggle-switch"
:aria-label="
formatMessage(
earsFeaturesEnabled
? messages.toggleEarsFeaturesOff
: messages.toggleEarsFeaturesOn,
)
"
/>
</div>
</div>
<button
v-else
class="flex h-10 min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[14px] border-0 bg-surface-4 px-4 py-2.5 text-base font-semibold leading-5 shadow-md transition-[filter,transform] duration-200 enabled:hover:brightness-[--hover-brightness] enabled:focus-visible:brightness-[--hover-brightness] enabled:active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:size-5 [&>svg]:shrink-0"
:disabled="!selectedSkin || isSkinManagementReadOnly"
@click="(e: MouseEvent) => selectedSkin && editSkinModal?.show(e, selectedSkin)"
>
<EditIcon />
{{ formatMessage(messages.editSkinButton) }}
</button>
</template>
</SkinPreviewRenderer>
</div>
@@ -1147,6 +1284,51 @@ await loadSkins()
</template>
<style lang="scss" scoped>
.skin-preview-subtitle {
container-type: inline-size;
}
.ears-feature-toggle-switch {
display: none;
}
@container (max-width: 300px) {
.skin-preview-subtitle {
gap: 0.75rem;
}
.has-ears-features .skin-preview-action-button {
padding-left: 0 !important;
padding-right: 0 !important;
width: 2.5rem;
}
.has-ears-features .skin-preview-action-label {
display: none;
}
.ears-feature-toggle-button {
display: none !important;
}
.ears-feature-notice {
padding-left: 0;
padding-right: 0;
}
.ears-feature-description {
display: none;
}
.ears-feature-compact-label {
display: block;
}
.ears-feature-toggle-switch {
display: inline-flex;
}
}
.skin-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 2.5fr);
+4 -1
View File
@@ -1,8 +1,11 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import type { StorybookConfig } from '@storybook/vue3-vite'
import { mergeConfig } from 'vite'
const storybookDirectory = path.dirname(fileURLToPath(import.meta.url))
const config: StorybookConfig = {
framework: {
name: '@storybook/vue3-vite',
@@ -16,7 +19,7 @@ const config: StorybookConfig = {
mergeConfig(config, {
resolve: {
alias: {
'@modrinth/api-client': path.resolve(__dirname, '../../api-client/src/index.ts'),
'@modrinth/api-client': path.resolve(storybookDirectory, '../../api-client/src/index.ts'),
},
},
}),
@@ -21,7 +21,7 @@
class="absolute left-0 right-0 z-10 flex items-center justify-center pointer-events-none"
:style="subtitlePositionStyle"
>
<div ref="subtitleElement" class="pointer-events-auto" @click="ignoreControlClick">
<div ref="subtitleElement" class="w-full pointer-events-auto" @click="ignoreControlClick">
<slot name="subtitle" />
</div>
</div>
@@ -133,6 +133,7 @@ import { createRadialSpotlightShader, syncDamageFlashShader } from './skin-previ
const props = withDefaults(
defineProps<{
textureSrc: string
earsTextureSrc?: string
capeSrc?: string
variant?: 'SLIM' | 'CLASSIC' | 'UNKNOWN'
nametag?: string
@@ -147,9 +148,11 @@ const props = withDefaults(
fov?: number
initialRotation?: number
animationConfig?: SkinPreviewAnimationConfig
earsEnabled?: boolean
}>(),
{
variant: 'CLASSIC',
earsTextureSrc: undefined,
capeSrc: undefined,
initialRotation: 15.75,
nametag: undefined,
@@ -157,6 +160,7 @@ const props = withDefaults(
lockFit: true,
framing: 'page',
fitZoom: 1,
earsEnabled: true,
animationConfig: () => ({
baseAnimation: 'idle',
randomAnimations: ['idle_sub_1', 'idle_sub_2', 'idle_sub_3'],
@@ -166,6 +170,10 @@ const props = withDefaults(
},
)
const emit = defineEmits<{
earsFeaturesDetected: [detected: boolean]
}>()
const skinPreviewContainer = useTemplateRef<HTMLElement>('skinPreviewContainer')
const subtitleElement = useTemplateRef<HTMLElement>('subtitleElement')
const slots = useSlots()
@@ -259,13 +267,16 @@ const {
},
})
const { isModelLoaded, isTextureLoaded, modelCenter, modelSize, scene } = useSkinPreviewScene({
selectedModelSrc,
textureSrc: toRef(props, 'textureSrc'),
capeSrc: toRef(props, 'capeSrc'),
initializeAnimations,
cleanupAnimationState,
})
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,
})
function syncDamageFlashShaderMaterials() {
syncDamageFlashShader(scene.value, damageFlashIntensity.value)
@@ -310,6 +321,13 @@ const { isPreviewVisible, showLoading } = useSkinPreviewLoading(isReady)
onMounted(observeSubtitleElement)
watch(hasSubtitle, () => nextTick(observeSubtitleElement), { flush: 'post' })
watch(
hasEarsFeatures,
(detected) => {
emit('earsFeaturesDetected', detected)
},
{ immediate: true },
)
watch(scene, syncDamageFlashShaderMaterials, { immediate: true })
watch(damageFlashIntensity, syncDamageFlashShaderMaterials)
+1
View File
@@ -16,6 +16,7 @@ export * from './server-backups-queue'
export * from './server-console'
export * from './server-manage-core-runtime'
export * from './server-permissions'
export { applyEarsMod, removeEarsMod } from './skin-rendering/use-ears-mod-features'
export * from './sticky-observer'
export * from './terminal'
export * from './use-loading-bar-token'
File diff suppressed because it is too large Load Diff
@@ -21,6 +21,7 @@ import {
} from '#ui/utils/webgl/skin-rendering.ts'
import type { SkinPreviewTuple } from './types'
import { applyEarsMod, removeEarsMod } from './use-ears-mod-features'
const SKIN_LAYER_DEPTH_BIAS = -1
@@ -109,13 +110,17 @@ type MaybeReadonlyRef<T> = Ref<T> | ComputedRef<T>
export function useSkinPreviewScene({
selectedModelSrc,
textureSrc,
earsTextureSrc,
capeSrc,
earsEnabled,
initializeAnimations,
cleanupAnimationState,
}: {
selectedModelSrc: MaybeReadonlyRef<string>
textureSrc: MaybeReadonlyRef<string>
earsTextureSrc: MaybeReadonlyRef<string | undefined>
capeSrc: MaybeReadonlyRef<string | undefined>
earsEnabled: MaybeReadonlyRef<boolean>
initializeAnimations: (loadedScene: THREE.Object3D, clips: THREE.AnimationClip[]) => void
cleanupAnimationState: (root: THREE.Object3D | null) => void
}) {
@@ -123,16 +128,20 @@ export function useSkinPreviewScene({
const lastCapeSrc = ref<string | undefined>(undefined)
const loadedModelSrc = ref<string | undefined>(undefined)
const loadedTextureSrc = ref<string | undefined>(undefined)
const loadedEarsTextureSrc = ref<string | undefined>(undefined)
const loadedCapeSrc = ref<string | undefined>(undefined)
const texture = shallowRef<THREE.Texture | null>(null)
const earsTexture = 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)
const hasEarsFeatures = ref(false)
let modelLoadVersion = 0
let textureLoadVersion = 0
let earsTextureLoadVersion = 0
let capeLoadVersion = 0
let isUnmounted = false
@@ -147,6 +156,18 @@ export function useSkinPreviewScene({
}
applyTexture(scene.value, texture.value)
const featureTextureSrc = earsTextureSrc.value
const featureTexture = featureTextureSrc ? earsTexture.value : texture.value
if (
!featureTexture ||
(featureTextureSrc && loadedEarsTextureSrc.value !== featureTextureSrc)
) {
removeEarsMod(scene.value)
hasEarsFeatures.value = false
return
}
hasEarsFeatures.value = applyEarsMod(scene.value, featureTexture, earsEnabled.value)
}
function applyCapeTextureToLoadedModel() {
@@ -173,6 +194,7 @@ export function useSkinPreviewScene({
const previousScene = scene.value
cleanupAnimationState(previousScene)
removeEarsMod(previousScene)
disposeSceneMaterials(previousScene)
scene.value = clonedScene
loadedModelSrc.value = src
@@ -232,6 +254,19 @@ export function useSkinPreviewScene({
applyCapeTextureToLoadedModel()
}
async function loadAndApplyEarsTexture(src: string | undefined) {
const loadVersion = ++earsTextureLoadVersion
hasEarsFeatures.value = false
const loadedEarsTexture = src ? await loadAndApplyTexture(src) : null
if (isUnmounted || loadVersion !== earsTextureLoadVersion) return
earsTexture.value = loadedEarsTexture
loadedEarsTextureSrc.value = src
applyTextureToLoadedModel()
updateModelInfo()
}
function updateModelInfo() {
const box = scene.value ? getVisibleMeshBox(scene.value) : null
@@ -261,6 +296,7 @@ export function useSkinPreviewScene({
const loadVersion = ++textureLoadVersion
isTextureLoaded.value = false
hasEarsFeatures.value = false
const loadedTexture = await loadAndApplyTexture(newSrc)
if (isUnmounted || loadVersion !== textureLoadVersion) return
@@ -270,6 +306,19 @@ export function useSkinPreviewScene({
isTextureLoaded.value = true
},
)
watch(
() => earsTextureSrc.value,
async (newEarsTextureSrc) => {
await loadAndApplyEarsTexture(newEarsTextureSrc)
},
)
watch(
() => earsEnabled.value,
() => {
applyTextureToLoadedModel()
updateModelInfo()
},
)
watch(
() => capeSrc.value,
async (newCapeSrc) => {
@@ -284,6 +333,7 @@ export function useSkinPreviewScene({
loadedTextureSrc.value = textureSrc.value
isTextureLoaded.value = true
await loadAndApplyEarsTexture(earsTextureSrc.value)
await loadModel(selectedModelSrc.value)
if (capeSrc.value) {
@@ -298,15 +348,18 @@ export function useSkinPreviewScene({
isUnmounted = true
modelLoadVersion++
textureLoadVersion++
earsTextureLoadVersion++
capeLoadVersion++
cleanupAnimationState(scene.value)
removeEarsMod(scene.value)
disposeSceneMaterials(scene.value)
scene.value = null
transparentTexture.dispose()
})
return {
hasEarsFeatures,
isModelLoaded,
isTextureLoaded,
modelCenter,
@@ -1,6 +1,6 @@
import { CheckIcon, EyeIcon, RotateCounterClockwiseIcon } from '@modrinth/assets'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import { onBeforeUnmount, ref } from 'vue'
import SkinPreviewRenderer from '../../components/skin/SkinPreviewRenderer.vue'
@@ -60,6 +60,76 @@ type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const FileUpload: Story = {
render: () => ({
components: { SkinPreviewRenderer },
setup() {
const textureSrc = ref(steveSkin)
const fileName = ref('Steve skin')
let objectUrl: string | undefined
function revokeObjectUrl() {
if (!objectUrl) return
URL.revokeObjectURL(objectUrl)
objectUrl = undefined
}
function uploadSkin(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
revokeObjectUrl()
objectUrl = URL.createObjectURL(file)
textureSrc.value = objectUrl
fileName.value = file.name
}
function resetSkin() {
revokeObjectUrl()
textureSrc.value = steveSkin
fileName.value = 'Steve skin'
}
onBeforeUnmount(revokeObjectUrl)
return {
fileName,
resetSkin,
textureSrc,
uploadSkin,
}
},
template: /* html */ `
<div class="flex flex-wrap items-start gap-6">
<div class="h-[80vh] min-h-[32rem] max-h-[48rem] w-[22rem]">
<SkinPreviewRenderer :texture-src="textureSrc" variant="CLASSIC" />
</div>
<div class="flex w-72 flex-col gap-3 rounded-xl bg-surface-2 p-4">
<label class="text-sm font-semibold text-contrast" for="skin-preview-upload">
Upload skin
</label>
<input
id="skin-preview-upload"
class="text-sm text-primary file:mr-3 file:rounded-lg file:border-0 file:bg-surface-4 file:px-3 file:py-2 file:text-primary"
type="file"
accept="image/png"
@change="uploadSkin"
/>
<div class="truncate text-sm text-secondary">{{ fileName }}</div>
<button
class="rounded-lg border-0 bg-surface-4 px-4 py-2 text-primary"
type="button"
@click="resetSkin"
>
Reset
</button>
</div>
</div>
`,
}),
}
export const RepeatInteract: Story = {
render: (args) => ({
components: { SkinPreviewRenderer },
@@ -13,6 +13,8 @@ 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()
const SKIN_TEXTURE_SIZE = 64
const SKIN_UV_INSET_VERSION = 1
export async function loadModel(modelUrl: string): Promise<GLTF> {
if (modelCache.has(modelUrl)) {
@@ -153,12 +155,58 @@ function setCommonMaterialProperties(mat: THREE.MeshStandardMaterial): void {
}
}
function insetSkinGeometryUvs(geometry: THREE.BufferGeometry): void {
if (geometry.userData.skinUvInsetVersion === SKIN_UV_INSET_VERSION) return
const uv = geometry.getAttribute('uv')
if (!uv || uv.itemSize < 2 || uv.count % 4 !== 0) return
for (let faceStart = 0; faceStart < uv.count; faceStart += 4) {
let minU = Infinity
let maxU = -Infinity
let minV = Infinity
let maxV = -Infinity
for (let vertex = faceStart; vertex < faceStart + 4; vertex++) {
const u = uv.getX(vertex)
const v = uv.getY(vertex)
minU = Math.min(minU, u)
maxU = Math.max(maxU, u)
minV = Math.min(minV, v)
maxV = Math.max(maxV, v)
}
const insetMinU = (Math.round(minU * SKIN_TEXTURE_SIZE) + 0.5) / SKIN_TEXTURE_SIZE
const insetMaxU = (Math.round(maxU * SKIN_TEXTURE_SIZE) - 0.5) / SKIN_TEXTURE_SIZE
const insetMinV = (Math.round(minV * SKIN_TEXTURE_SIZE) + 0.5) / SKIN_TEXTURE_SIZE
const insetMaxV = (Math.round(maxV * SKIN_TEXTURE_SIZE) - 0.5) / SKIN_TEXTURE_SIZE
for (let vertex = faceStart; vertex < faceStart + 4; vertex++) {
const u = uv.getX(vertex)
const v = uv.getY(vertex)
const insetU = u === minU ? insetMinU : u === maxU ? insetMaxU : u
const insetV = v === minV ? insetMinV : v === maxV ? insetMaxV : v
uv.setXY(vertex, insetU, insetV)
}
}
uv.needsUpdate = true
geometry.userData.skinUvInsetVersion = SKIN_UV_INSET_VERSION
}
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]
const usesSkinTexture = materials.some(
(mat) => mat instanceof THREE.MeshStandardMaterial && mat.name !== 'cape',
)
if (usesSkinTexture) {
insetSkinGeometryUvs(mesh.geometry)
}
materials.forEach((mat: THREE.Material) => {
if (mat instanceof THREE.MeshStandardMaterial) {