feat: capes in preview renderer & baked renders

This commit is contained in:
Calum H.
2025-05-16 22:54:23 +01:00
committed by Alejandro González
parent 4000777c8d
commit b558a0f0e6
7 changed files with 364 additions and 134 deletions

View File

@@ -0,0 +1 @@
{"asset":{"version":"2.0","generator":"Blockbench 4.12.4 glTF exporter"},"scenes":[{"nodes":[1],"name":"blockbench_export"}],"scene":0,"nodes":[{"rotation":[0,0,0.19509032201612825,0.9807852804032304],"translation":[0.15625,1,0],"name":"Cape","mesh":0},{"children":[0]}],"bufferViews":[{"buffer":0,"byteOffset":0,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":288,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":576,"byteLength":192,"target":34962,"byteStride":8},{"buffer":0,"byteOffset":768,"byteLength":72,"target":34963}],"buffers":[{"byteLength":840,"uri":"data:application/octet-stream;base64,AAAAPQAAAAAAAKA+AAAAPQAAAAAAAKC+AAAAPQAAgL8AAKA+AAAAPQAAgL8AAKC+AAAAvQAAAAAAAKC+AAAAvQAAAAAAAKA+AAAAvQAAgL8AAKC+AAAAvQAAgL8AAKA+AAAAvQAAAAAAAKC+AAAAPQAAAAAAAKC+AAAAvQAAAAAAAKA+AAAAPQAAAAAAAKA+AAAAvQAAgL8AAKA+AAAAPQAAgL8AAKA+AAAAvQAAgL8AAKC+AAAAPQAAgL8AAKC+AAAAvQAAAAAAAKA+AAAAPQAAAAAAAKA+AAAAvQAAgL8AAKA+AAAAPQAAgL8AAKA+AAAAPQAAAAAAAKC+AAAAvQAAAAAAAKC+AAAAPQAAgL8AAKC+AAAAvQAAgL8AAKC+AACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AACAPAAAgD0AADA+AACAPQAAgDwAAAg/AAAwPgAACD8AAEA+AACAPQAAsD4AAIA9AABAPgAACD8AALA+AAAIPwAAgDwAAAA9AACAPAAAgD0AADA+AAAAPQAAMD4AAIA9AAAwPgAAAD0AAKg+AAAAPQAAMD4AAAAAAACoPgAAAAAAAEA+AACAPQAAMD4AAIA9AABAPgAACD8AADA+AAAIPwAAAAAAAIA9AACAPAAAgD0AAAAAAAAIPwAAgDwAAAg/AAACAAEAAgADAAEABAAGAAUABgAHAAUACAAKAAkACgALAAkADAAOAA0ADgAPAA0AEAASABEAEgATABEAFAAWABUAFgAXABUA"}],"accessors":[{"bufferView":0,"componentType":5126,"count":24,"max":[0.03125,0,0.3125],"min":[-0.03125,-1,-0.3125],"type":"VEC3"},{"bufferView":1,"componentType":5126,"count":24,"max":[1,1,1],"min":[-1,-1,-1],"type":"VEC3"},{"bufferView":2,"componentType":5126,"count":24,"max":[0.34375,0.53125],"min":[0,0],"type":"VEC2"},{"bufferView":3,"componentType":5123,"count":36,"max":[23],"min":[0],"type":"SCALAR"}],"materials":[{"pbrMetallicRoughness":{"metallicFactor":0,"roughnessFactor":1,"baseColorTexture":{"index":0}},"alphaMode":"MASK","alphaCutoff":0.05,"doubleSided":true}],"textures":[{"sampler":0,"source":0,"name":"cape.png"}],"samplers":[{"magFilter":9728,"minFilter":9728,"wrapS":33071,"wrapT":33071}],"images":[{"mimeType":"image/png","uri":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAAAgCAYAAACinX6EAAAAAXNSR0IArs4c6QAABCRJREFUaEPtlktoE1EUhm/sIzFpYsBgHyn2oRZt0C5ESxWFYutKNyKIWBBR6kpBd3Ur2J0FRdCiuKmIIG4qLnzgQqTUXSlVqdoXTU0lljRpYtOHI/8dznBnOm1mphMwJWeTzJ0795zznf/cex2MMXam2S/ht38siJ8V1lgd5mOBARerc3rZnmK37rwvCyk2nE6wezMRh+4EncH9B1ql+fkU64rPsaBz5bqh4T7Daxn1Kc5zVNeEePJIci0Aq71bzenY6JChwAnA0OBHQ/OtJLnWNwqAy61R1tO3k891ueRKoDKwtqbv7MGbgCnfRgHQoqG9hyX4hc/yiho+/HNqVPFtdj2jwTpQgd/RKT7/0ZUku/o4qAJw50KYXbzr4e+3BioYzc3kwGzAUCLWFw2+UBiCb3bNTDHiPQeAP3CGNmg/6VcSBpDu3hhvDQpOCwABwrQKsRIsqYAChy/EQAXwlPiZ3a3igNPkXIyTjr8o5L7PPdzGf59c+sV/faeWeIIIAHPJ8M3kc7l1K09LKghWAIgqoPZ7djPFTlxb4D6yAgBOQfntrUVWVeRk44tplXJorOVGkVIJTBCTpw9ECFYBIEkyMfmsAXh3u1qi5MlxbbGX/x1ZSCjBAAwgfPr6h49R5UVavk0FXC2wju5p07s6iqEF0PtqSlEf+bKzDRwdgaDU7AkoySJ5Oo/D6ZRq/H0yyuJ/lxkSheG/FgCNm7kL0BoEAG32sqtY1YIHd2/mGzTMVgD3y2slqjgW9xY6ma9AThAARIMiBtOpjADwTWc0bEkB+FZsSTxTW0KBsGPXx0yvrUpEeHAAQIM7wBJLcu8DwHaP/H8i6VSND6SiSjBlRW4WWUypVIBbIgzjVgB0tpdKqLReS0KVPTMTfH0ra2cEgAmAAEd+l1z52LybqwBQYAQAyZPh6gtDW4jjuC4fHx8wXSm0JDbeI95SRYHiFZlUaWVtPQiKAugl5E8ASAUYiy8vc0C474uGasPE5PHc4g0wK/f4obom6UNimol7kTZwQLANwOuqBokqDEeQf4lfvvnNxZJcBTBAGZplWQcQ3tcgwY9oWlXinRW4ugoAgNAWWe6ocn1QvgyRTUb4RZFVljlY/3hSBYCqb6cCZo8ekuATVRZPI/gQW8FWAI1VHganVgDQUajdA6y2gAgAcZFBjTBSpG0AcAqc3VVmCMDTbxGWZvIRCaMNkJ7pFMCzVQD4liCQ8kRFUlvaBkCvL+wYw2ZmNUgCgLajFhRPJlv3ADuS1VtjPQCk823S574ffN/x1dQqy8dHR5SN2Spcbaymz2mjwNYDAD4IQn3TDpVLQIAqNjwAANRrAdoI/3sAOF7Xe1khCNQGVH1bL0JGJb1R52VtD8gVYHkAuVKpbMWZV0C2yObKunkF5EqlshVnXgHZIpsr6/4DlbxcPydnT74AAAAASUVORK5CYII="}],"meshes":[{"primitives":[{"mode":4,"attributes":{"POSITION":0,"NORMAL":1,"TEXCOORD_0":2},"indices":3,"material":0}]}]}

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { useTemplateRef, ref, computed } from 'vue'
import type { Cape } from '@/helpers/skins.ts'
import {ButtonStyled, ScrollablePanel, CapeButton, CapeLikeTextButton} from '@modrinth/ui'
import type { Cape, SkinModel } from '@/helpers/skins.ts'
import {ButtonStyled, ScrollablePanel, CapeButton, CapeLikeTextButton, SkinPreviewRenderer} from '@modrinth/ui'
import { CheckIcon, XIcon} from '@modrinth/assets'
const modal = useTemplateRef('modal')
@@ -11,39 +11,20 @@ const emit = defineEmits<{
(e: 'select', cape: Cape | undefined): void
}>()
const props = defineProps<{
defineProps<{
capes: Cape[];
}>()
const currentSkin = ref<string | undefined>()
const currentSkinId = ref<string | undefined>()
const currentSkinTexture = ref<string | undefined>()
const currentSkinVariant = ref<SkinModel>('CLASSIC')
const currentCapeTexture = computed<string | undefined>(() => currentCape.value?.texture)
const currentCape = ref<Cape | undefined>()
const mcUrlRegex = /texture\/([a-fA-F0-9]+)$/
const textureId = computed(() => {
if (!currentCape.value) {
return undefined
}
const mcTextureMatch = currentCape.value.texture.match(mcUrlRegex)
if (mcTextureMatch) {
return mcTextureMatch[1]
} else {
return undefined;
}
})
const capeParams = computed(() => {
return textureId.value ? `&replacecape=${textureId.value}` : ``
})
const previewSkin = computed(() => currentSkin.value ? `https://vzge.me/full/350/${currentSkin.value}.png?no=ears&y=180${capeParams.value}` : undefined)
function show(e: MouseEvent, skin?: string, selected?: Cape) {
currentSkin.value = skin
function show(e: MouseEvent, skinId?: string, selected?: Cape, skinTexture?: string, variant?: SkinModel) {
currentSkinId.value = skinId
currentSkinTexture.value = skinTexture || (skinId ? `https://vzge.me/processedskin/${skinId}.png` : '')
currentSkinVariant.value = variant || 'CLASSIC'
currentCape.value = selected
modal.value?.show(e)
}
@@ -57,6 +38,10 @@ function hide() {
modal.value?.hide()
}
function updateSelectedCape(cape: Cape | undefined) {
currentCape.value = cape
}
defineExpose({
show,
hide,
@@ -67,20 +52,47 @@ defineExpose({
<template #title>
<span class="text-lg font-extrabold text-contrast">Selecting a cape</span>
</template>
<div class="grid grid-cols-[auto_1fr] gap-6 mb-5">
<div class="flex">
<img :src="previewSkin" alt="" class="w-auto my-auto h-60 object-contain" />
<div class="flex flex-col md:flex-row gap-6">
<div class="max-h-[25rem] h-[25rem] w-[16rem] min-w-[16rem] overflow-hidden relative">
<div class="absolute top-[-4rem] left-0 h-[32rem] w-[16rem] flex-shrink-0">
<SkinPreviewRenderer
v-if="currentSkinTexture"
slim-model-src="/src/assets/models/slim_player.gltf"
wide-model-src="/src/assets/models/classic_player.gltf"
cape-model-src="/src/assets/models/cape.gltf"
:cape-src="currentCapeTexture"
:texture-src="currentSkinTexture"
:variant="currentSkinVariant"
:scale="1.4"
:fov="50"
:initial-rotation="180"
class="h-full w-full"
/>
</div>
</div>
<div>
<div class="flex flex-col gap-4 w-full my-auto">
<ScrollablePanel class="max-h-[20rem] max-w-[30rem] mb-5 h-full">
<div class="flex flex-wrap gap-2 justify-center content-start overflow-y-auto h-full">
<CapeLikeTextButton tooltip="No Cape" :highlighted="!currentCape" @click="currentCape = undefined">
<CapeLikeTextButton
tooltip="No Cape"
:highlighted="!currentCape"
@click="updateSelectedCape(undefined)"
>
<template #icon>
<XIcon />
</template>
<span>No Cape</span>
</CapeLikeTextButton>
<CapeButton v-for="cape in capes" :key="cape.id" :name="cape.name" :texture="cape.texture" :id="cape.id" :selected="currentCape?.id === cape.id" @select="currentCape = cape" />
<CapeButton
v-for="cape in capes"
:key="cape.id"
:name="cape.name"
:texture="cape.texture"
:id="cape.id"
:selected="currentCape?.id === cape.id"
@select="updateSelectedCape(cape)"
/>
</div>
</ScrollablePanel>
</div>

View File

@@ -1,9 +1,9 @@
import * as THREE from 'three';
import { GLTFLoader, GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { Skin, determineModelType } from '../skins';
import {Skin, determineModelType, Cape} from '../skins';
import { reactive } from "vue";
interface RenderResult {
export interface RenderResult {
forwards: string;
backwards: string;
}
@@ -15,9 +15,11 @@ class BatchSkinRenderer {
private modelCache: Map<string, GLTF> = new Map();
private textureCache: Map<string, THREE.Texture> = new Map();
private currentModel: THREE.Group | null = null;
private capeModel: THREE.Object3D | null = null;
private bodyNode: THREE.Object3D | null = null;
private capeAttached: boolean = false;
constructor(width: number = 215, height: number = 215*2) {
// Create canvas and renderer
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
@@ -41,9 +43,6 @@ class BatchSkinRenderer {
this.scene.add(ambientLight);
}
/**
* Loads a GLTF model with caching
*/
private async loadModel(modelUrl: string): Promise<GLTF> {
if (this.modelCache.has(modelUrl)) {
return this.modelCache.get(modelUrl)!;
@@ -63,9 +62,6 @@ class BatchSkinRenderer {
});
}
/**
* Loads a texture with caching
*/
private async loadTexture(textureUrl: string): Promise<THREE.Texture> {
if (this.textureCache.has(textureUrl)) {
return this.textureCache.get(textureUrl)!;
@@ -74,7 +70,6 @@ class BatchSkinRenderer {
return new Promise<THREE.Texture>((resolve) => {
const textureLoader = new THREE.TextureLoader();
textureLoader.load(textureUrl, (texture) => {
// Apply texture settings
texture.colorSpace = THREE.SRGBColorSpace;
texture.flipY = false;
texture.magFilter = THREE.NearestFilter;
@@ -86,13 +81,13 @@ class BatchSkinRenderer {
});
}
/**
* Applies a texture to all meshes in a model
*/
private applyTexture(model: THREE.Object3D, texture: THREE.Texture): void {
model.traverse(child => {
if ((child as THREE.Mesh).isMesh) {
const mesh = child as THREE.Mesh;
if (mesh.name === "Cape") return;
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
materials.forEach((mat: THREE.Material) => {
@@ -109,18 +104,48 @@ class BatchSkinRenderer {
});
}
/**
* Renders both forward and backward views of a skin
*/
public async renderSkin(textureUrl: string, modelUrl: string): Promise<RenderResult> {
await this.setupModel(modelUrl, textureUrl);
private applyCapeTexture(model: THREE.Object3D, texture: THREE.Texture): void {
model.traverse(child => {
if ((child as THREE.Mesh).isMesh) {
const mesh = child as THREE.Mesh;
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
materials.forEach((mat: THREE.Material) => {
if (mat instanceof THREE.MeshStandardMaterial) {
mat.map = texture;
mat.metalness = 0;
mat.color.set(0xffffff);
mat.toneMapped = false;
mat.roughness = 1;
mat.side = THREE.DoubleSide;
mat.needsUpdate = true;
}
});
}
});
}
private attachCapeToBody(): void {
if (!this.bodyNode || !this.capeModel || this.capeAttached) return;
if (this.capeModel.parent) {
this.capeModel.parent.remove(this.capeModel);
}
this.capeModel.position.set(0, -1, -0.01);
this.capeModel.rotation.set(0, -Math.PI / 2, 0);
this.bodyNode.add(this.capeModel);
this.capeAttached = true;
}
public async renderSkin(textureUrl: string, modelUrl: string, capeUrl?: string, capeModelUrl?: string): Promise<RenderResult> {
await this.setupModel(modelUrl, textureUrl, capeModelUrl, capeUrl);
// Find the Head part of the model for lookAt target
const headPart = this.currentModel!.getObjectByName('Head');
let lookAtTarget: [number, number, number];
if (headPart) {
// Get the world position of the head
const headPosition = new THREE.Vector3();
headPart.getWorldPosition(headPosition);
lookAtTarget = [headPosition.x, headPosition.y, headPosition.z];
@@ -128,23 +153,15 @@ class BatchSkinRenderer {
throw new Error("Failed to find 'Head' object in model.")
}
// Front view (looking at the player's face)
const frontCameraPos: [number, number, number] = [2, 1, -2.5];
// Back view (looking at the player's back)
const backCameraPos: [number, number, number] = [2, 1, 6.0];
// Render both views
const forwards = await this.renderView(frontCameraPos, lookAtTarget);
const backwards = await this.renderView(backCameraPos, lookAtTarget);
return { forwards, backwards };
}
/**
* Renders a view of the model and returns a blob URL
* Updated to accept lookAt position
*/
private async renderView(cameraPosition: [number, number, number], lookAtPosition: [number, number, number]): Promise<string> {
this.camera.position.set(...cameraPosition);
this.camera.lookAt(...lookAtPosition);
@@ -163,26 +180,28 @@ class BatchSkinRenderer {
});
}
/**
* Sets up a model with texture in the scene
* TODO: Cape
*/
private async setupModel(modelUrl: string, textureUrl: string): Promise<void> {
// Clean up previous model if it exists
private async setupModel(modelUrl: string, textureUrl: string, capeModelUrl?: string, capeUrl?: string): Promise<void> {
if (this.currentModel) {
this.scene.remove(this.currentModel);
}
this.bodyNode = null;
this.capeAttached = false;
const [gltf, texture] = await Promise.all([
this.loadModel(modelUrl),
this.loadTexture(textureUrl)
]);
// Clone the model to avoid modifying the cached one
const model = gltf.scene.clone();
this.applyTexture(model, texture);
// Setup group and positioning
model.traverse(node => {
if (node.name === 'Body') {
this.bodyNode = node;
}
});
const group = new THREE.Group();
group.add(model);
group.position.set(0, -0.5, 1.95);
@@ -190,11 +209,22 @@ class BatchSkinRenderer {
this.scene.add(group);
this.currentModel = group;
if (capeModelUrl && capeUrl) {
const [capeGltf, capeTexture] = await Promise.all([
this.loadModel(capeModelUrl),
this.loadTexture(capeUrl)
]);
this.capeModel = capeGltf.scene.clone();
this.applyCapeTexture(this.capeModel, capeTexture);
if (this.bodyNode && this.capeModel) {
this.attachCapeToBody();
}
}
}
/**
* Cleanup resources
*/
public dispose(): void {
Array.from(this.textureCache.values()).forEach(texture => {
texture.dispose();
@@ -206,9 +236,6 @@ class BatchSkinRenderer {
}
}
/**
* Gets the appropriate model URL based on skin variant
*/
function getModelUrlForVariant(variant: string): string {
switch (variant) {
case 'SLIM':
@@ -223,53 +250,45 @@ function getModelUrlForVariant(variant: string): string {
export const map = reactive(new Map<string, RenderResult>());
const DEBUG_MODE = false;
/**
* Generates skin previews for an array of skins
* Renders both front and back views for each skin
*
* @param skins - Array of Skin objects to render
* @returns A map of skin texture keys to their rendered front and back views
*/
export async function generateSkinPreviews(skins: Skin[]): Promise<void> {
export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise<void> {
const renderer = new BatchSkinRenderer();
const capeModelUrl = '/src/assets/models/cape.gltf';
try {
// Process each skin
for (const skin of skins) {
// Skip if already in result map
if (map.has(skin.texture_key)) {
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
if (map.has(key)) {
if (DEBUG_MODE) {
const result = map.get(skin.texture_key)!
const result = map.get(key)!
URL.revokeObjectURL(result.forwards)
URL.revokeObjectURL(result.backwards)
map.delete(skin.texture_key)
map.delete(key)
} else continue;
}
// Determine model variant if unknown
let variant = skin.variant;
if (variant === 'UNKNOWN') {
try {
variant = await determineModelType(skin.texture);
} catch (error) {
console.error(`Failed to determine model type for skin ${skin.texture_key}:`, error);
variant = 'CLASSIC'; // Fall back to classic
console.error(`Failed to determine model type for skin ${key}:`, error);
variant = 'CLASSIC';
}
}
const modelUrl = getModelUrlForVariant(variant);
const cape: Cape | undefined = capes.find(_cape => _cape.id === skin.cape_id);
const renderResult = await renderer.renderSkin(
skin.texture,
modelUrl,
cape?.texture,
capeModelUrl
);
// Render the skin
const renderResult = await renderer.renderSkin(skin.texture, modelUrl);
console.log(renderResult.forwards);
// Store in result map and cache
map.set(skin.texture_key, renderResult);
map.set(key, renderResult);
}
} finally {
// Clean up renderer resources
renderer.dispose();
}
}

View File

@@ -38,7 +38,6 @@ export const DEFAULT_MODELS: Record<string, SkinModel> = {
export function filterSavedSkins(list: Skin[]) {
const customSkins = list.filter((s) => s.source !== 'default');
console.log(customSkins[0]);
fixUnknownSkins(customSkins).catch(handleError);
return customSkins;
}
@@ -91,13 +90,11 @@ export async function determineModelType(texture: string): Promise<'SLIM' | 'CLA
export async function fixUnknownSkins(list: Skin[]) {
const unknownSkins = list.filter((s) => s.variant === "UNKNOWN");
for (let unknownSkin of unknownSkins) {
console.log(unknownSkin.texture);
unknownSkin.variant = await determineModelType(unknownSkin.texture);
}
}
export function filterDefaultSkins(list: Skin[]) {
console.log(list);
return list
.filter(
(s) =>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { UpdatedIcon, PlusIcon } from '@modrinth/assets'
import { ButtonStyled, SkinPreviewRenderer, SkinButton, SkinLikeTextButton } from '@modrinth/ui'
import {ref, computed, useTemplateRef, onMounted} from 'vue'
import {ref, computed, useTemplateRef, watch} from 'vue'
import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue'
import SelectCapeModal from '@/components/ui/skin/SelectCapeModal.vue'
import { handleError } from '@/store/notifications'
@@ -9,14 +9,12 @@ import {
get_available_skins,
get_available_capes,
filterSavedSkins,
filterDefaultSkins, equip_skin, remove_custom_skin, set_default_cape, add_and_equip_custom_skin,
filterDefaultSkins, equip_skin, set_default_cape
} from '@/helpers/skins.ts'
import { get as getSettings } from '@/helpers/settings.ts'
import { get as getCreds } from '@/helpers/mr_auth'
import { get_user } from '@/helpers/cache'
import type { Cape, Skin } from '@/helpers/skins.ts'
import {get_default_user, users} from "@/helpers/auth";
import {generateSkinPreviews, map} from "@/helpers/rendering/batchSkinRenderer.ts";
import {generateSkinPreviews, map, RenderResult} from "@/helpers/rendering/batchSkinRenderer.ts";
const editSkinModal = useTemplateRef('editSkinModal')
const selectCapeModal = useTemplateRef('selectCapeModal')
@@ -27,6 +25,9 @@ const capes = ref<Cape[]>([])
const username = ref<string | undefined>(undefined)
const selectedSkin = ref<Skin | null>(null)
const defaultCape = ref<Cape>()
const forceSkinRefreshKey = ref(0)
const previewSkin = computed(() =>
selectedSkin.value ? `https://vzge.me/processedskin/${selectedSkin.value.texture_key}.png` : ''
@@ -35,19 +36,34 @@ const previewSkin = computed(() =>
const savedSkins = computed(() => filterSavedSkins(skins.value))
const defaultSkins = computed(() => filterDefaultSkins(skins.value))
const currentCape = ref<Cape>()
const currentCape = computed(() => {
if (selectedSkin.value?.cape_id) {
const overrideCape = capes.value.find(c => c.id === selectedSkin.value?.cape_id)
if (overrideCape) {
return overrideCape
}
}
return defaultCape.value
})
await Promise.all([loadCapes(), loadSkins(), loadUsername()])
async function loadCapes() {
capes.value = (await get_available_capes().catch(handleError)) ?? []
currentCape.value = capes.value.find((c) => c.is_equipped)
defaultCape.value = capes.value.find((c) => c.is_equipped)
}
async function loadSkins() {
skins.value = (await get_available_skins().catch(handleError)) ?? []
generateSkinPreviews(skins.value);
generateSkinPreviews(skins.value, capes.value);
const previousSkinId = selectedSkin.value?.texture_key
selectedSkin.value = skins.value.find((s) => s.is_equipped) ?? null;
if (previousSkinId && selectedSkin.value?.texture_key === previousSkinId) {
forceSkinRefreshKey.value++
}
}
async function changeSkin(newSkin: Skin) {
@@ -56,12 +72,15 @@ async function changeSkin(newSkin: Skin) {
}
async function handleCapeSelected(cape: Cape | undefined) {
currentCape.value = cape;
await set_default_cape(currentCape.value).catch(handleError);
await set_default_cape(cape).catch(handleError);
await loadSkins();
await loadCapes();
}
async function onSkinSaved() {
await Promise.all([loadCapes(), loadSkins()]);
}
async function loadUsername() {
try {
const defaultId = await get_default_user()
@@ -73,13 +92,22 @@ async function loadUsername() {
username.value = undefined
}
}
function getBakedSkinTextures(skin: Skin): RenderResult | undefined {
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`;
return map.get(key);
}
watch(() => selectedSkin.value?.cape_id, () => {
forceSkinRefreshKey.value++
})
</script>
<template>
<EditSkinModal
ref="editSkinModal"
:capes="capes"
@saved="() => loadSkins()"
@saved="onSkinSaved"
@deleted="() => loadSkins()"
/>
<SelectCapeModal ref="selectCapeModal" :capes="capes" @select="handleCapeSelected"/>
@@ -87,8 +115,10 @@ async function loadUsername() {
<div class="sticky top-6 self-start">
<div class="flex justify-between gap-4">
<h1 class="m-0 text-2xl font-bold">Skins</h1>
<ButtonStyled>
<ButtonStyled :disabled="!!selectedSkin?.cape_id">
<button
v-tooltip="selectedSkin?.cape_id ? 'The equipped skin is overriding the default cape.' : undefined"
:disabled="!!selectedSkin?.cape_id"
@click="(e: MouseEvent) =>
selectCapeModal?.show(e, selectedSkin?.texture_key, currentCape)"
>
@@ -99,11 +129,14 @@ async function loadUsername() {
</div>
<div class="h-[80vh] flex items-center justify-center">
<SkinPreviewRenderer
:key="forceSkinRefreshKey"
wide-model-src="/src/assets/models/classic_player.gltf"
slim-model-src="/src/assets/models/slim_player.gltf"
:nametag="settings.hide_nametag_skins_page ? undefined : username"
cape-model-src="/src/assets/models/cape.gltf"
:cape-src="currentCape?.texture"
:texture-src="previewSkin"
:variant="selectedSkin?.variant"
:nametag="settings.hide_nametag_skins_page ? undefined : username"
/>
</div>
</div>
@@ -124,8 +157,8 @@ async function loadUsername() {
class="flex-none w-[8vw] h-[15vw] max-w-[8rem] max-h-[15rem]"
:key="`saved-skin-${skin.texture_key}`"
editable
:forward-image-src="map.get(skin.texture_key)?.forwards ?? ''"
:backward-image-src="map.get(skin.texture_key)?.backwards ?? ''"
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
:selected="selectedSkin === skin"
@select="changeSkin(skin)"
@edit="e => editSkinModal?.show(e, skin)"
@@ -140,8 +173,8 @@ async function loadUsername() {
v-for="skin in defaultSkins"
class="flex-none w-[8vw] h-[15vw] max-w-[8rem] max-h-[15rem]"
:key="`default-skin-${skin.texture_key}`"
:forward-image-src="map.get(skin.texture_key)?.forwards ?? ''"
:backward-image-src="map.get(skin.texture_key)?.backwards ?? ''"
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
:selected="selectedSkin === skin"
:tooltip="skin.name"
@select="changeSkin(skin)"

View File

@@ -12,8 +12,8 @@ const emit = defineEmits<{
}>()
const props = withDefaults(defineProps<{
forwardImageSrc: string
backwardImageSrc: string
forwardImageSrc?: string
backwardImageSrc?: string
selected: boolean
editable?: boolean
tooltip?: string
@@ -22,6 +22,14 @@ const props = withDefaults(defineProps<{
})
const pressed = ref(false)
const imagesLoaded = ref({
forward: Boolean(props.forwardImageSrc),
backward: Boolean(props.backwardImageSrc)
})
function onImageLoad(type: 'forward' | 'backward') {
imagesLoaded.value[type] = true
}
</script>
<template>
@@ -43,18 +51,26 @@ const pressed = ref(false)
@mouseleave="pressed = false"
@click="emit('select')"
></button>
<span class="skin-button__image-parent pointer-events-none w-full h-full flex flex-col justify-end">
<div v-if="!forwardImageSrc || !backwardImageSrc" class="skeleton-loader w-full h-full rounded-xl">
<div class="absolute inset-0 rounded-xl skeleton"></div>
</div>
<span v-else class="skin-button__image-parent pointer-events-none w-full h-full flex flex-col justify-end">
<img
alt=""
:src="forwardImageSrc"
class="skin-button__image-facing rounded-xl object-contain w-full h-auto mx-auto mb-0"
@load="onImageLoad('forward')"
/>
<img
alt=""
:src="backwardImageSrc"
class="skin-button__image-away rounded-xl object-contain w-full h-auto mx-auto mb-0"
@load="onImageLoad('backward')"
/>
</span>
<span
v-if="editable"
class="absolute pointer-events-none inset-0 flex items-end justify-center p-2 translate-y-4 scale-75 opacity-0 transition-all group-hover:opacity-100 group-hover:scale-100 group-hover:translate-y-0 group-hover:translate-x-0"
@@ -78,6 +94,21 @@ const pressed = ref(false)
.skin-button__parent {
perspective: 1000px;
.skeleton-loader {
aspect-ratio: 1 / 1;
}
.skeleton {
background: linear-gradient(
90deg,
var(--color-bg, #f0f0f0) 25%,
var(--color-raised-bg, #e0e0e0) 50%,
var(--color-bg, #f0f0f0) 75%
);
background-size: 200% 100%;
animation: wave 1500ms infinite linear;
}
.skin-button__image-parent {
transform-style: preserve-3d;
display: grid;
@@ -127,6 +158,15 @@ const pressed = ref(false)
}
}
@keyframes wave {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
@keyframes vanish-halfway {
0% {
opacity: 1;

View File

@@ -24,12 +24,10 @@
>
<Suspense>
<Group>
<!-- Apply the scale prop to the model group -->
<Group :rotation="[0, modelRotation, 0]" :position="[0, -0.05 * scale, 1.95]" :scale="[0.8 * scale, 0.8 * scale, 0.8 * scale]">
<primitive v-if="scene" :object="scene" />
</Group>
<!-- Scale the shadow accordingly -->
<TresMesh
:position="[0, -0.095 * scale, 2]"
:rotation="[-Math.PI / 2, 0, 0]"
@@ -44,7 +42,6 @@
/>
</TresMesh>
<!-- Scale the radial gradient effect -->
<TresMesh :position="[0, -0.1 * scale, 2]" :rotation="[-Math.PI / 2, 0, 0]" :scale="[0.75 * scale, 0.75 * scale, 0.75 * scale]">
<TresPlaneGeometry :args="[2, 2]" />
<TresMeshBasicMaterial
@@ -57,7 +54,6 @@
</Group>
</Suspense>
<!-- Use the fov prop for the camera -->
<TresPerspectiveCamera
:makeDefault="true"
:fov="fov"
@@ -74,24 +70,30 @@
import * as THREE from 'three'
import { useGLTF } from '@tresjs/cientos'
import { useTexture, TresCanvas } from '@tresjs/core'
import {shallowRef, ref, computed, watch, markRaw} from 'vue'
import { shallowRef, ref, computed, watch, markRaw } from 'vue'
const props = withDefaults(
defineProps<{
textureSrc: string
slimModelSrc: string
wideModelSrc: string
capeModelSrc?: string
capeSrc?: string
variant?: 'SLIM' | 'CLASSIC' | 'UNKNOWN'
nametag?: string
antialias?: boolean
scale?: number
fov?: number
fov?: number,
initialRotation?: number
}>(),
{
variant: 'CLASSIC',
antialias: false,
scale: 1,
fov: 40
fov: 40,
capeModelSrc: '',
capeSrc: undefined,
initialRotation: 15.75,
}
)
@@ -100,17 +102,76 @@ const selectedModelSrc = computed(() =>
)
const scene = shallowRef<THREE.Object3D | null>(null)
const capeScene = shallowRef<THREE.Object3D | null>(null)
const bodyNode = shallowRef<THREE.Object3D | null>(null)
const capeAttached = ref(false)
const lastCapeSrc = ref<string | undefined>(undefined)
async function loadModel(src: string) {
const { scene: loadedScene } = await useGLTF(src)
scene.value = markRaw(loadedScene)
applyTextureToScene(scene.value, texture.value)
bodyNode.value = null
loadedScene.traverse(node => {
if (node.name === 'Body') {
bodyNode.value = node
}
})
capeAttached.value = false
if (capeScene.value && bodyNode.value && props.capeSrc) {
attachCapeToBody()
}
updateModelInfo()
}
async function loadCape(src: string) {
if (!src) {
capeScene.value = null
return
}
const { scene: loadedCape } = await useGLTF(src)
capeScene.value = markRaw(loadedCape)
if (props.capeSrc) {
const tex = await useTexture([props.capeSrc])
capeTexture.value = tex
applyCapeTexture(capeScene.value, tex)
}
if (bodyNode.value && capeScene.value && !capeAttached.value) {
attachCapeToBody()
}
}
function attachCapeToBody() {
if (!bodyNode.value || !capeScene.value || capeAttached.value) return
if (capeScene.value.parent) {
capeScene.value.parent.remove(capeScene.value)
}
capeScene.value.position.set(0, -1, -0.01)
capeScene.value.rotation.set(0, -Math.PI / 2, 0)
bodyNode.value.add(capeScene.value)
capeAttached.value = true
}
watch(selectedModelSrc, src => loadModel(src), { immediate: true })
watch(
() => props.capeModelSrc,
src => src && loadCape(src),
{ immediate: true }
)
const texture = ref<THREE.Texture>(await useTexture([props.textureSrc]))
const capeTexture = shallowRef<THREE.Texture | null>(null)
watch(
() => props.textureSrc,
@@ -120,6 +181,37 @@ watch(
}
)
watch(
() => props.capeSrc,
async newCapeSrc => {
if (newCapeSrc === lastCapeSrc.value) return;
lastCapeSrc.value = newCapeSrc;
if (newCapeSrc) {
const newTexture = await useTexture([newCapeSrc]);
capeTexture.value = newTexture;
if (capeScene.value && bodyNode.value && !capeAttached.value) {
attachCapeToBody();
}
if (capeScene.value) {
applyCapeTexture(capeScene.value, newTexture);
}
} else {
capeTexture.value = null;
if (capeAttached.value && capeScene.value && capeScene.value.parent) {
capeScene.value.parent.remove(capeScene.value);
capeAttached.value = false;
}
}
},
{ immediate: true }
)
function applyTextureToScene(root: THREE.Object3D | null, tex: THREE.Texture) {
if (!root) return
tex.colorSpace = THREE.SRGBColorSpace
@@ -129,7 +221,11 @@ function applyTextureToScene(root: THREE.Object3D | null, tex: THREE.Texture) {
root.traverse(child => {
if ((child as THREE.Mesh).isMesh) {
const mesh = child as THREE.Mesh
if (mesh.name == "Cape") return;
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
// @ts-ignore
materials.forEach((mat: THREE.MeshStandardMaterial) => {
mat.map = tex
mat.metalness = 0
@@ -142,6 +238,35 @@ function applyTextureToScene(root: THREE.Object3D | null, tex: THREE.Texture) {
})
}
function applyCapeTexture(root: THREE.Object3D | null, tex: THREE.Texture | null) {
if (!root || !tex) return
// Force texture settings
tex.colorSpace = THREE.SRGBColorSpace
tex.flipY = false
tex.magFilter = THREE.NearestFilter
tex.minFilter = THREE.NearestFilter
tex.needsUpdate = true
root.traverse(child => {
if ((child as THREE.Mesh).isMesh) {
const mesh = child as THREE.Mesh
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
// @ts-ignore
materials.forEach((mat: THREE.MeshStandardMaterial) => {
mat.map = tex
mat.metalness = 0
mat.color.set(0xffffff)
mat.toneMapped = false
mat.roughness = 1
mat.side = THREE.DoubleSide
mat.needsUpdate = true
})
}
})
}
const centre = ref<[number, number, number]>([0, 1, 0])
const modelHeight = ref(1.4)
@@ -156,7 +281,7 @@ function updateModelInfo() {
const target = computed(() => centre.value)
const modelRotation = ref(0)
const modelRotation = ref(props.initialRotation)
const isDragging = ref(false)
const previousX = ref(0)
@@ -197,4 +322,7 @@ function createRadialTexture(size: number): THREE.CanvasTexture {
applyTextureToScene(scene.value, texture.value)
loadModel(selectedModelSrc.value)
if (props.capeModelSrc) {
loadCape(props.capeModelSrc)
}
</script>