Skip to content

Commit 7a89e4e

Browse files
committed
feat: batched skin rendering - remove vzge references (apart from capes, wip)
1 parent d195ae3 commit 7a89e4e

File tree

6 files changed

+346
-221
lines changed

6 files changed

+346
-221
lines changed
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
import * as THREE from 'three';
2+
import { GLTFLoader, GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js';
3+
import {Skin, determineModelType, get_available_skins} from '../skins';
4+
import {reactive} from "vue";
5+
6+
interface RenderResult {
7+
forwards: string;
8+
backwards: string;
9+
}
10+
11+
class BatchSkinRenderer {
12+
private renderer: THREE.WebGLRenderer;
13+
private readonly scene: THREE.Scene;
14+
private readonly camera: THREE.PerspectiveCamera;
15+
private modelCache: Map<string, GLTF> = new Map();
16+
private textureCache: Map<string, THREE.Texture> = new Map();
17+
private readonly width: number;
18+
private readonly height: number;
19+
private currentModel: THREE.Group | null = null;
20+
21+
constructor(width: number = 215, height: number = 645) {
22+
this.width = width;
23+
this.height = height;
24+
25+
// Create canvas and renderer
26+
const canvas = document.createElement('canvas');
27+
canvas.width = width;
28+
canvas.height = height;
29+
30+
this.renderer = new THREE.WebGLRenderer({
31+
canvas: canvas,
32+
antialias: true,
33+
alpha: true,
34+
preserveDrawingBuffer: true
35+
});
36+
37+
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
38+
this.renderer.toneMapping = THREE.NoToneMapping;
39+
this.renderer.setClearColor(0x000000, 0);
40+
this.renderer.setSize(width, height);
41+
42+
this.scene = new THREE.Scene();
43+
this.camera = new THREE.PerspectiveCamera(40, width/height, 0.1, 1000);
44+
45+
const ambientLight = new THREE.AmbientLight(0xffffff, 2);
46+
this.scene.add(ambientLight);
47+
}
48+
49+
/**
50+
* Loads a GLTF model with caching
51+
*/
52+
private async loadModel(modelUrl: string): Promise<GLTF> {
53+
if (this.modelCache.has(modelUrl)) {
54+
return this.modelCache.get(modelUrl)!;
55+
}
56+
57+
const loader = new GLTFLoader();
58+
return new Promise<GLTF>((resolve, reject) => {
59+
loader.load(
60+
modelUrl,
61+
(gltf) => {
62+
this.modelCache.set(modelUrl, gltf);
63+
resolve(gltf);
64+
},
65+
undefined,
66+
reject
67+
);
68+
});
69+
}
70+
71+
/**
72+
* Loads a texture with caching
73+
*/
74+
private async loadTexture(textureUrl: string): Promise<THREE.Texture> {
75+
if (this.textureCache.has(textureUrl)) {
76+
return this.textureCache.get(textureUrl)!;
77+
}
78+
79+
return new Promise<THREE.Texture>((resolve) => {
80+
const textureLoader = new THREE.TextureLoader();
81+
textureLoader.load(textureUrl, (texture) => {
82+
// Apply texture settings
83+
texture.colorSpace = THREE.SRGBColorSpace;
84+
texture.flipY = false;
85+
texture.magFilter = THREE.NearestFilter;
86+
texture.minFilter = THREE.NearestFilter;
87+
88+
this.textureCache.set(textureUrl, texture);
89+
resolve(texture);
90+
});
91+
});
92+
}
93+
94+
/**
95+
* Applies a texture to all meshes in a model
96+
*/
97+
private applyTexture(model: THREE.Object3D, texture: THREE.Texture): void {
98+
model.traverse(child => {
99+
if ((child as THREE.Mesh).isMesh) {
100+
const mesh = child as THREE.Mesh;
101+
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
102+
103+
materials.forEach((mat: THREE.Material) => {
104+
if (mat instanceof THREE.MeshStandardMaterial) {
105+
mat.map = texture;
106+
mat.metalness = 0;
107+
mat.color.set(0xffffff);
108+
mat.toneMapped = false;
109+
mat.roughness = 1;
110+
mat.needsUpdate = true;
111+
}
112+
});
113+
}
114+
});
115+
}
116+
117+
/**
118+
* Renders both forward and backward views of a skin
119+
*/
120+
public async renderSkin(textureUrl: string, modelUrl: string): Promise<RenderResult> {
121+
await this.setupModel(modelUrl, textureUrl);
122+
123+
// Create a bounding box for the entire model
124+
const bbox = new THREE.Box3().setFromObject(this.currentModel!);
125+
126+
// Calculate model dimensions
127+
const boxCenter = bbox.getCenter(new THREE.Vector3());
128+
const boxSize = bbox.getSize(new THREE.Vector3());
129+
130+
// Calculate optimal camera distance based on model height and canvas aspect ratio
131+
const aspectRatio = this.height / this.width;
132+
const verticalFOVRadians = this.camera.fov * Math.PI / 180;
133+
const cameraDistance = (boxSize.y * 1.5) / Math.tan(verticalFOVRadians / 2);
134+
135+
// Position camera perfectly front and back, with no side angle
136+
const frontCameraPos: [number, number, number] = [
137+
0, // No x-offset for straight-on view
138+
boxCenter.y + (boxSize.y * 0.1), // Slightly above center to frame face better
139+
boxCenter.z - cameraDistance // Front view (negative z)
140+
];
141+
142+
const backCameraPos: [number, number, number] = [
143+
0, // No x-offset for straight-on view
144+
boxCenter.y + (boxSize.y * 0.1), // Same height as front
145+
boxCenter.z + cameraDistance // Back view (positive z)
146+
];
147+
148+
// Look at the center of the model (vertically centered on face/upper torso)
149+
const lookAtPos: [number, number, number] = [
150+
boxCenter.x,
151+
boxCenter.y - (boxSize.y * 0.7),
152+
boxCenter.z
153+
];
154+
155+
// Pass these positions to renderView with the lookAt target
156+
const [forwards, backwards] = await Promise.all([
157+
this.renderView(frontCameraPos, lookAtPos),
158+
this.renderView(backCameraPos, lookAtPos)
159+
]);
160+
161+
return { forwards, backwards };
162+
}
163+
164+
/**
165+
* Renders a view of the model and returns a blob URL
166+
* Updated to accept lookAt position
167+
*/
168+
private async renderView(cameraPosition: [number, number, number], lookAtPosition: [number, number, number]): Promise<string> {
169+
this.camera.position.set(...cameraPosition);
170+
this.camera.lookAt(...lookAtPosition);
171+
172+
this.renderer.render(this.scene, this.camera);
173+
174+
return new Promise<string>((resolve, reject) => {
175+
this.renderer.domElement.toBlob((blob) => {
176+
if (blob) {
177+
const url = URL.createObjectURL(blob);
178+
resolve(url);
179+
} else {
180+
reject(new Error("Failed to create blob from canvas"));
181+
}
182+
}, 'image/png');
183+
});
184+
}
185+
186+
/**
187+
* Sets up a model with texture in the scene
188+
*/
189+
private async setupModel(modelUrl: string, textureUrl: string): Promise<void> {
190+
// Clean up previous model if it exists
191+
if (this.currentModel) {
192+
this.scene.remove(this.currentModel);
193+
}
194+
195+
const [gltf, texture] = await Promise.all([
196+
this.loadModel(modelUrl),
197+
this.loadTexture(textureUrl)
198+
]);
199+
200+
// Clone the model to avoid modifying the cached one
201+
const model = gltf.scene.clone();
202+
this.applyTexture(model, texture);
203+
204+
// Setup group and positioning
205+
const group = new THREE.Group();
206+
group.add(model);
207+
group.position.set(0, -0.5, 1.95);
208+
group.scale.set(0.8, 0.8, 0.8);
209+
210+
this.scene.add(group);
211+
this.currentModel = group;
212+
}
213+
214+
/**
215+
* Cleanup resources
216+
*/
217+
public dispose(): void {
218+
Array.from(this.textureCache.values()).forEach(texture => {
219+
texture.dispose();
220+
});
221+
222+
this.renderer.dispose();
223+
this.textureCache.clear();
224+
this.modelCache.clear();
225+
}
226+
}
227+
228+
/**
229+
* Gets the appropriate model URL based on skin variant
230+
*/
231+
function getModelUrlForVariant(variant: string): string {
232+
switch (variant) {
233+
case 'SLIM':
234+
return '/src/assets/models/slim_player.gltf';
235+
case 'CLASSIC':
236+
case 'UNKNOWN':
237+
default:
238+
return '/src/assets/models/classic_player.gltf';
239+
}
240+
}
241+
242+
export const map = reactive(new Map<string, RenderResult>());
243+
244+
/**
245+
* Generates skin previews for an array of skins
246+
* Renders both front and back views for each skin
247+
*
248+
* @param skins - Array of Skin objects to render
249+
* @returns A map of skin texture keys to their rendered front and back views
250+
*/
251+
export async function generateSkinPreviews(skins: Skin[]): Promise<void> {
252+
const renderer = new BatchSkinRenderer(215, 645);
253+
254+
try {
255+
// Process each skin
256+
for (const skin of skins) {
257+
// Skip if already in result map
258+
if (map.has(skin.texture_key)) {
259+
continue;
260+
}
261+
262+
// Determine model variant if unknown
263+
let variant = skin.variant;
264+
if (variant === 'UNKNOWN') {
265+
try {
266+
variant = await determineModelType(skin.texture);
267+
} catch (error) {
268+
console.error(`Failed to determine model type for skin ${skin.texture_key}:`, error);
269+
variant = 'CLASSIC'; // Fall back to classic
270+
}
271+
}
272+
273+
const modelUrl = getModelUrlForVariant(variant);
274+
275+
// Render the skin
276+
const renderResult = await renderer.renderSkin(skin.texture, modelUrl);
277+
278+
// Store in result map and cache
279+
map.set(skin.texture_key, renderResult);
280+
}
281+
} finally {
282+
// Clean up renderer resources
283+
renderer.dispose();
284+
}
285+
}
286+
287+

0 commit comments

Comments
 (0)