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
@@ -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) {