mirror of
https://github.com/modrinth/code.git
synced 2026-08-25 00:55:25 +00:00
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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user