feat: batched skin rendering - remove vzge references (apart from capes, wip)

This commit is contained in:
Calum H.
2025-05-15 22:46:06 +01:00
committed by Alejandro González
parent d7cf1d232b
commit c5813a9435
6 changed files with 346 additions and 221 deletions

View File

@@ -0,0 +1,287 @@
import * as THREE from 'three';
import { GLTFLoader, GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js';
import {Skin, determineModelType, get_available_skins} from '../skins';
import {reactive} from "vue";
interface RenderResult {
forwards: string;
backwards: string;
}
class BatchSkinRenderer {
private renderer: THREE.WebGLRenderer;
private readonly scene: THREE.Scene;
private readonly camera: THREE.PerspectiveCamera;
private modelCache: Map<string, GLTF> = new Map();
private textureCache: Map<string, THREE.Texture> = new Map();
private readonly width: number;
private readonly height: number;
private currentModel: THREE.Group | null = null;
constructor(width: number = 215, height: number = 645) {
this.width = width;
this.height = height;
// Create canvas and renderer
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
this.renderer = new THREE.WebGLRenderer({
canvas: canvas,
antialias: true,
alpha: true,
preserveDrawingBuffer: true
});
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
this.renderer.toneMapping = THREE.NoToneMapping;
this.renderer.setClearColor(0x000000, 0);
this.renderer.setSize(width, height);
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(40, width/height, 0.1, 1000);
const ambientLight = new THREE.AmbientLight(0xffffff, 2);
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)!;
}
const loader = new GLTFLoader();
return new Promise<GLTF>((resolve, reject) => {
loader.load(
modelUrl,
(gltf) => {
this.modelCache.set(modelUrl, gltf);
resolve(gltf);
},
undefined,
reject
);
});
}
/**
* Loads a texture with caching
*/
private async loadTexture(textureUrl: string): Promise<THREE.Texture> {
if (this.textureCache.has(textureUrl)) {
return this.textureCache.get(textureUrl)!;
}
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;
texture.minFilter = THREE.NearestFilter;
this.textureCache.set(textureUrl, texture);
resolve(texture);
});
});
}
/**
* 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;
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.needsUpdate = true;
}
});
}
});
}
/**
* Renders both forward and backward views of a skin
*/
public async renderSkin(textureUrl: string, modelUrl: string): Promise<RenderResult> {
await this.setupModel(modelUrl, textureUrl);
// Create a bounding box for the entire model
const bbox = new THREE.Box3().setFromObject(this.currentModel!);
// Calculate model dimensions
const boxCenter = bbox.getCenter(new THREE.Vector3());
const boxSize = bbox.getSize(new THREE.Vector3());
// Calculate optimal camera distance based on model height and canvas aspect ratio
const aspectRatio = this.height / this.width;
const verticalFOVRadians = this.camera.fov * Math.PI / 180;
const cameraDistance = (boxSize.y * 1.5) / Math.tan(verticalFOVRadians / 2);
// Position camera perfectly front and back, with no side angle
const frontCameraPos: [number, number, number] = [
0, // No x-offset for straight-on view
boxCenter.y + (boxSize.y * 0.1), // Slightly above center to frame face better
boxCenter.z - cameraDistance // Front view (negative z)
];
const backCameraPos: [number, number, number] = [
0, // No x-offset for straight-on view
boxCenter.y + (boxSize.y * 0.1), // Same height as front
boxCenter.z + cameraDistance // Back view (positive z)
];
// Look at the center of the model (vertically centered on face/upper torso)
const lookAtPos: [number, number, number] = [
boxCenter.x,
boxCenter.y - (boxSize.y * 0.7),
boxCenter.z
];
// Pass these positions to renderView with the lookAt target
const [forwards, backwards] = await Promise.all([
this.renderView(frontCameraPos, lookAtPos),
this.renderView(backCameraPos, lookAtPos)
]);
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);
this.renderer.render(this.scene, this.camera);
return new Promise<string>((resolve, reject) => {
this.renderer.domElement.toBlob((blob) => {
if (blob) {
const url = URL.createObjectURL(blob);
resolve(url);
} else {
reject(new Error("Failed to create blob from canvas"));
}
}, 'image/png');
});
}
/**
* Sets up a model with texture in the scene
*/
private async setupModel(modelUrl: string, textureUrl: string): Promise<void> {
// Clean up previous model if it exists
if (this.currentModel) {
this.scene.remove(this.currentModel);
}
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
const group = new THREE.Group();
group.add(model);
group.position.set(0, -0.5, 1.95);
group.scale.set(0.8, 0.8, 0.8);
this.scene.add(group);
this.currentModel = group;
}
/**
* Cleanup resources
*/
public dispose(): void {
Array.from(this.textureCache.values()).forEach(texture => {
texture.dispose();
});
this.renderer.dispose();
this.textureCache.clear();
this.modelCache.clear();
}
}
/**
* Gets the appropriate model URL based on skin variant
*/
function getModelUrlForVariant(variant: string): string {
switch (variant) {
case 'SLIM':
return '/src/assets/models/slim_player.gltf';
case 'CLASSIC':
case 'UNKNOWN':
default:
return '/src/assets/models/classic_player.gltf';
}
}
export const map = reactive(new Map<string, RenderResult>());
/**
* 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> {
const renderer = new BatchSkinRenderer(215, 645);
try {
// Process each skin
for (const skin of skins) {
// Skip if already in result map
if (map.has(skin.texture_key)) {
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
}
}
const modelUrl = getModelUrlForVariant(variant);
// Render the skin
const renderResult = await renderer.renderSkin(skin.texture, modelUrl);
// Store in result map and cache
map.set(skin.texture_key, renderResult);
}
} finally {
// Clean up renderer resources
renderer.dispose();
}
}

View File

@@ -1,171 +0,0 @@
import * as THREE from 'three';
import { GLTFLoader, GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js';
interface CameraSettings {
fov: number;
position: THREE.Vector3 | [number, number, number];
lookAt?: THREE.Vector3 | [number, number, number];
}
/**
* Creates a screenshot of a 3D skin model with the given texture and returns a blob URL
* Uses OffscreenCanvas for rendering and specifically handles GLTF models
*
* @param skinTexture - URL to the skin texture
* @param gltfModelUrl - URL to the GLTF/GLB model
* @param cameraSettings - Camera settings including fov and position
* @param width - Width of the output image
* @param height - Height of the output image
* @returns Promise that resolves to a blob URL of the screenshot
*/
export async function bakeSkinRender(
skinTexture: string,
gltfModelUrl: string,
cameraSettings: CameraSettings,
width: number = 512,
height: number = 512
): Promise<string> {
if (typeof OffscreenCanvas === 'undefined') {
throw new Error('OffscreenCanvas is not supported in this browser');
}
let canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const renderer = new THREE.WebGLRenderer({
canvas: canvas,
antialias: false,
alpha: true,
preserveDrawingBuffer: true
});
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.NoToneMapping;
renderer.setSize(width, height);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
cameraSettings.fov,
width / height,
0.1,
1000
);
if (Array.isArray(cameraSettings.position)) {
camera.position.set(...cameraSettings.position);
} else {
camera.position.copy(cameraSettings.position);
}
if (cameraSettings.lookAt) {
if (Array.isArray(cameraSettings.lookAt)) {
camera.lookAt(new THREE.Vector3(...cameraSettings.lookAt));
} else {
camera.lookAt(cameraSettings.lookAt);
}
} else {
camera.lookAt(0, 0, 0);
}
const ambientLight = new THREE.AmbientLight(0xffffff, 2);
scene.add(ambientLight);
const [gltfResult, texture] = await Promise.all([
loadGLTFModel(gltfModelUrl),
loadTexture(skinTexture)
]);
const model = gltfResult.scene;
applyTextureSettings(texture);
applyTextureToModel(model, texture);
const group = new THREE.Group();
group.add(model);
group.position.set(0, -0.05, 1.95);
group.scale.set(0.8, 0.8, 0.8);
scene.add(group);
renderer.render(scene, camera);
return new Promise<string>((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
const url = URL.createObjectURL(blob);
resolve(url);
} else {
canvas.remove();
reject(new Error("Failed to make blob."));
}
}, 'image/png');
});
}
async function loadGLTFModel(url: string): Promise<GLTF> {
return new Promise((resolve, reject) => {
const loader = new GLTFLoader();
loader.load(
url,
(gltf) => resolve(gltf),
undefined,
(error) => reject(error)
);
});
}
async function loadTexture(url: string): Promise<THREE.Texture> {
return new Promise((resolve) => {
const textureLoader = new THREE.TextureLoader();
textureLoader.load(url, resolve);
});
}
function applyTextureSettings(texture: THREE.Texture): void {
texture.colorSpace = THREE.SRGBColorSpace;
texture.flipY = false;
texture.magFilter = THREE.NearestFilter;
texture.minFilter = THREE.NearestFilter;
}
function applyTextureToModel(root: THREE.Object3D, texture: THREE.Texture): void {
if (!root) return;
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];
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.needsUpdate = true;
}
});
}
});
}
// TODO: temp remove
export async function testWithSteve() {
const result = await bakeSkinRender(
"/src/assets/skins/steve.png",
"/src/assets/models/classic_player.gltf",
{
fov: 40,
position: [0, 0, -3.25]
},
800, 600
);
console.log(result);
return result;
}

View File

@@ -1,8 +1,7 @@
<script setup lang="ts">
import { UpdatedIcon, PlusIcon } from '@modrinth/assets'
import { ButtonStyled, SkinPreviewRenderer } from '@modrinth/ui'
import { ButtonStyled, SkinPreviewRenderer, SkinButton, SkinLikeTextButton } from '@modrinth/ui'
import {ref, computed, useTemplateRef, onMounted} from 'vue'
import SkinButton from '@/components/ui/skin/SkinButton.vue'
import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue'
import SelectCapeModal from '@/components/ui/skin/SelectCapeModal.vue'
import { handleError } from '@/store/notifications'
@@ -17,6 +16,7 @@ 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";
const editSkinModal = useTemplateRef('editSkinModal')
const selectCapeModal = useTemplateRef('selectCapeModal')
@@ -45,9 +45,8 @@ async function loadCapes() {
}
async function loadSkins() {
console.log(skins.value)
skins.value = (await get_available_skins().catch(handleError)) ?? []
console.log(skins.value)
generateSkinPreviews(skins.value);
selectedSkin.value = skins.value.find((s) => s.is_equipped) ?? null;
}
@@ -68,10 +67,10 @@ async function loadUsername() {
const defaultId = await get_default_user()
const allAccounts = await users();
const current = allAccounts.find(acc => acc.profile.id === defaultId)
username.value = current?.profile.name ?? null
username.value = current?.profile?.name ?? undefined
} catch (e) {
handleError(e)
username.value = null
username.value = undefined
}
}
</script>
@@ -112,20 +111,21 @@ async function loadUsername() {
<div class="flex flex-col gap-6 add-perspective">
<section class="flex flex-col gap-3">
<h2 class="text-lg font-bold m-0 text-primary">Saved skins</h2>
<div class="grid grid-cols-3 gap-2">
<button
class="flex flex-col gap-3 active:scale-95 hover:brightness-125 font-medium text-primary items-center justify-center border-2 border-transparent border-solid cursor-pointer h-40 bg-button-bg rounded-xl"
@click="editSkinModal?.show"
>
<PlusIcon class="w-6 h-6" />
<div class="flex flex-row flex-wrap gap-2">
<SkinLikeTextButton @click="editSkinModal?.show" class="max-w-[9vw]" tooltip="Add a skin">
<template #icon>
<PlusIcon />
</template>
Add a skin
</button>
</SkinLikeTextButton>
<SkinButton
v-for="skin in savedSkins"
class="max-w-[9vw]"
:key="`saved-skin-${skin.texture_key}`"
editable
:skin="skin"
:forward-image-src="map.get(skin.texture_key)?.forwards ?? ''"
:backward-image-src="map.get(skin.texture_key)?.backwards ?? ''"
:selected="selectedSkin === skin"
@select="changeSkin(skin)"
@edit="e => editSkinModal?.show(e, skin)"
@@ -135,12 +135,15 @@ async function loadUsername() {
<section class="flex flex-col gap-3">
<h2 class="text-lg font-bold m-0 text-primary">Default skins</h2>
<div class="grid grid-cols-3 gap-2">
<div class="flex flex-row flex-wrap gap-2">
<SkinButton
v-for="skin in defaultSkins"
class="max-w-[9vw]"
:key="`default-skin-${skin.texture_key}`"
:skin="skin"
:forward-image-src="map.get(skin.texture_key)?.forwards ?? ''"
:backward-image-src="map.get(skin.texture_key)?.backwards ?? ''"
:selected="selectedSkin === skin"
:tooltip="skin.name"
@select="changeSkin(skin)"
@edit="e => editSkinModal?.show(e, skin)"
/>

View File

@@ -103,6 +103,8 @@ export { default as ModrinthServersPurchaseModal } from './billing/ModrinthServe
export { default as SkinPreviewRenderer } from "./skin/SkinPreviewRenderer.vue"
export { default as CapeButton } from "./skin/CapeButton.vue"
export { default as CapeLikeTextButton } from "./skin/CapeLikeTextButton.vue"
export { default as SkinButton } from "./skin/SkinButton.vue"
export { default as SkinLikeTextButton } from "./skin/SkinLikeTextButton.vue"
// Version
export { default as VersionChannelIndicator } from './version/VersionChannelIndicator.vue'

View File

@@ -1,9 +1,8 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref } from 'vue'
import { ButtonStyled, commonMessages } from '@modrinth/ui'
import { EditIcon } from '@modrinth/assets'
import { useVIntl } from '@vintl/vintl'
import type { Cape, Skin } from '@/helpers/skins.ts'
const { formatMessage } = useVIntl()
@@ -13,49 +12,28 @@ const emit = defineEmits<{
}>()
const props = withDefaults(defineProps<{
skin: Skin
forwardImageSrc: string
backwardImageSrc: string
selected: boolean
defaultCape?: Cape
editable?: boolean
tooltip?: string
}>(), {
defaultCape: undefined,
editable: false,
})
const base64Prefix = 'data:image/png;base64,'
const mcUrlRegex = /texture\/([a-fA-F0-9]+)$/
const texture = computed(() => {
const mcTextureMatch = props.skin.texture.match(mcUrlRegex)
if (mcTextureMatch) {
return mcTextureMatch[1]
} else if (props.skin.texture.startsWith(base64Prefix)) {
return props.skin.texture.split(base64Prefix)[1]
} else {
return props.skin.texture
}
})
const slim = computed(() => props.skin.variant === 'Slim')
const skinUrl = computed(
() => `https://vzge.me/bust/${texture.value}.png?no=ears${slim.value ? '&slim' : ''}`,
)
const backUrl = computed(() => `${skinUrl.value}&y=130`)
const pressed = ref(false)
</script>
<template>
<div
class="skin-button__parent group flex relative border-2 border-solid transform-3d rotate-y-90 transition-all h-40 p-0 bg-transparent rounded-xl overflow-hidden"
class="skin-button__parent group flex relative border-2 border-solid transform-3d rotate-y-90 transition-all p-0 bg-transparent rounded-xl overflow-hidden w-full aspect-[2/3]"
:class="[
selected ? `border-brand` : 'border-transparent',
{
'scale-95': pressed,
},
]"
v-tooltip="tooltip ?? undefined"
>
<button
class="absolute inset-0 rounded-xl cursor-pointer p-0 border-none group-hover:brightness-125"
@@ -65,21 +43,21 @@ const pressed = ref(false)
@mouseleave="pressed = false"
@click="emit('select')"
></button>
<span class="skin-button__image-parent pointer-events-none w-full h-full">
<span class="skin-button__image-parent pointer-events-none w-full h-full flex flex-col justify-end">
<img
alt=""
:src="skinUrl"
class="skin-button__image-facing rounded-xl object-contain object-bottom w-full h-full mt-auto mx-auto"
:src="forwardImageSrc"
class="skin-button__image-facing rounded-xl object-contain w-full h-auto mx-auto mb-0"
/>
<img
alt=""
:src="backUrl"
class="skin-button__image-away rounded-xl object-contain object-bottom w-full h-full mt-auto mx-auto"
:src="backwardImageSrc"
class="skin-button__image-away rounded-xl object-contain w-full h-auto mx-auto mb-0"
/>
</span>
<span
v-if="editable"
class="absolute pointer-events-none inset-0 flex items-end p-2 translate-y-4 -translate-x-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"
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"
>
<ButtonStyled color="brand">
<button

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import { ref } from 'vue'
const pressed = ref(false)
</script>
<template>
<div
class="relative w-full aspect-[2/3] border-2 border-transparent rounded-xl overflow-hidden"
:class="{ 'scale-95': pressed }"
>
<button
class="absolute inset-0 bg-button-bg rounded-xl cursor-pointer p-0 border-none hover:brightness-125"
@mousedown="pressed = true"
@mouseup="pressed = false"
@mouseleave="pressed = false"
></button>
<div class="relative w-full h-full flex flex-col items-center justify-center pointer-events-none">
<div class="mb-2">
<slot name="icon"></slot>
</div>
<span class="text-md text-center px-2">
<slot></slot>
</span>
</div>
</div>
</template>