feat: capes in preview renderer & baked renders
This commit is contained in:
committed by
Alejandro González
parent
4000777c8d
commit
b558a0f0e6
1
apps/app-frontend/src/assets/models/cape.gltf
Normal file
1
apps/app-frontend/src/assets/models/cape.gltf
Normal 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}]}]}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user