Files
LCE-Hub-LCE_Emerald_Launcher/src/components/common/SkinModel3D.tsx
T

615 lines
16 KiB
TypeScript

import { useEffect, useRef, memo } from "react";
import * as THREE from "three";
type UVSet = Record<string, number[]>;
interface SkinModel3DProps {
src: string;
slim?: boolean;
capeUrl?: string | null;
animate?: boolean;
className?: string;
}
const clamp = (v: number, min: number, max: number) =>
Math.min(max, Math.max(min, v));
let snapshotChain: Promise<void> = Promise.resolve();
const snapshotCache = new Map<string, string>();
function createFaceMaterial(
tex: THREE.Texture,
x: number,
y: number,
w: number,
h: number,
flipX = false,
flipY = false,
) {
const img = tex.image as { width: number; height: number } | undefined;
const imgH = img && img.height ? img.height : 64;
const matTex = tex.clone();
matTex.repeat.set((flipX ? -w : w) / 64, (flipY ? -h : h) / imgH);
matTex.offset.set((flipX ? x + w : x) / 64, 1 - (flipY ? y : y + h) / imgH);
matTex.needsUpdate = true;
return new THREE.MeshLambertMaterial({
map: matTex,
transparent: true,
alphaTest: 0.5,
side: THREE.FrontSide,
});
}
function createPart(
tex: THREE.Texture,
w: number,
h: number,
d: number,
uv: UVSet,
overlayUv?: UVSet,
swapMats = false,
isLegacyMirror = false,
) {
const group = new THREE.Group();
const geo = new THREE.BoxGeometry(w, h, d);
const getMats = (uvSet: UVSet) => {
const flipX = isLegacyMirror;
return [
createFaceMaterial(
tex,
swapMats ? uvSet.right[0] : uvSet.left[0],
uvSet.left[1],
uvSet.left[2],
uvSet.left[3],
flipX,
),
createFaceMaterial(
tex,
swapMats ? uvSet.left[0] : uvSet.right[0],
uvSet.right[1],
uvSet.right[2],
uvSet.right[3],
flipX,
),
createFaceMaterial(
tex,
uvSet.top[0],
uvSet.top[1],
uvSet.top[2],
uvSet.top[3],
flipX,
true,
),
createFaceMaterial(
tex,
uvSet.bottom[0],
uvSet.bottom[1],
uvSet.bottom[2],
uvSet.bottom[3],
flipX,
true,
),
createFaceMaterial(
tex,
uvSet.front[0],
uvSet.front[1],
uvSet.front[2],
uvSet.front[3],
flipX,
),
createFaceMaterial(
tex,
uvSet.back[0],
uvSet.back[1],
uvSet.back[2],
uvSet.back[3],
!flipX,
),
];
};
group.add(new THREE.Mesh(geo, getMats(uv)));
if (overlayUv) {
const oGeo = new THREE.BoxGeometry(w + 0.5, h + 0.5, d + 0.5);
group.add(new THREE.Mesh(oGeo, getMats(overlayUv)));
}
return group;
}
const limbUv = (x: number, y: number, w = 4): UVSet => ({
top: [x + 4, y, w, 4],
bottom: [x + 4 + w, y, w, 4],
right: [x, y + 4, 4, 12],
front: [x + 4, y + 4, w, 12],
left: [x + 4 + w, y + 4, 4, 12],
back: [x + 8 + w, y + 4, w, 12],
});
function buildPlayer(
playerGroup: THREE.Group,
texture: THREE.Texture,
slimOpt?: boolean,
) {
const img = texture.image as HTMLImageElement;
const isLegacy = (img?.height || 64) === 32;
const isSlim =
slimOpt !== undefined
? slimOpt
: !isLegacy &&
(() => {
const canvas = document.createElement("canvas");
canvas.width = img?.width || 64;
canvas.height = img?.height || 64;
const ctx = canvas.getContext("2d");
if (!ctx) return false;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
try {
return ctx.getImageData(42, 48, 1, 1).data[3] === 0;
} catch {
return false;
}
})();
const armW = isSlim ? 3 : 4;
const headUv: UVSet = {
top: [8, 0, 8, 8],
bottom: [16, 0, 8, 8],
right: [0, 8, 8, 8],
left: [16, 8, 8, 8],
front: [8, 8, 8, 8],
back: [24, 8, 8, 8],
};
const hatUv: UVSet = {
top: [40, 0, 8, 8],
bottom: [48, 0, 8, 8],
right: [32, 8, 8, 8],
left: [48, 8, 8, 8],
front: [40, 8, 8, 8],
back: [56, 8, 8, 8],
};
const head = createPart(texture, 8, 8, 8, headUv, hatUv);
head.position.y = 10;
playerGroup.add(head);
const bodyUv: UVSet = {
top: [20, 16, 8, 4],
bottom: [28, 16, 8, 4],
right: [16, 20, 4, 12],
left: [28, 20, 4, 12],
front: [20, 20, 8, 12],
back: [32, 20, 8, 12],
};
const jacketUv: UVSet = {
top: [20, 32, 8, 4],
bottom: [28, 32, 8, 4],
right: [16, 36, 4, 12],
left: [28, 36, 4, 12],
front: [20, 36, 8, 12],
back: [32, 36, 8, 12],
};
playerGroup.add(
createPart(texture, 8, 12, 4, bodyUv, isLegacy ? undefined : jacketUv),
);
const rightArm = createPart(
texture,
armW,
12,
4,
limbUv(40, 16, armW),
isLegacy ? undefined : limbUv(40, 32, armW),
);
rightArm.position.set(isSlim ? -5.5 : -6, 0, 0);
playerGroup.add(rightArm);
const leftArm = createPart(
texture,
armW,
12,
4,
isLegacy ? limbUv(40, 16, armW) : limbUv(32, 48, armW),
isLegacy ? undefined : limbUv(48, 48, armW),
isLegacy,
isLegacy,
);
leftArm.position.set(isSlim ? 5.5 : 6, 0, 0);
playerGroup.add(leftArm);
const rightLeg = createPart(
texture,
4,
12,
4,
limbUv(0, 16),
isLegacy ? undefined : limbUv(0, 32),
);
rightLeg.position.set(-2, -12, 0);
playerGroup.add(rightLeg);
const leftLeg = createPart(
texture,
4,
12,
4,
isLegacy ? limbUv(0, 16) : limbUv(16, 48),
isLegacy ? undefined : limbUv(0, 48),
isLegacy,
isLegacy,
);
leftLeg.position.set(2, -12, 0);
playerGroup.add(leftLeg);
}
function addCape(playerGroup: THREE.Group, texture: THREE.Texture) {
const capeUv: UVSet = {
top: [1, 0, 10, 1],
bottom: [11, 0, 10, 1],
right: [0, 1, 1, 16],
front: [1, 1, 10, 16],
left: [11, 1, 1, 16],
back: [12, 1, 10, 16],
};
const capeGroup = new THREE.Group();
const capeGeo = new THREE.BoxGeometry(10, 16, 1);
const capeMats = [
createFaceMaterial(
texture,
capeUv.left[0],
capeUv.left[1],
capeUv.left[2],
capeUv.left[3],
),
createFaceMaterial(
texture,
capeUv.right[0],
capeUv.right[1],
capeUv.right[2],
capeUv.right[3],
),
createFaceMaterial(
texture,
capeUv.top[0],
capeUv.top[1],
capeUv.top[2],
capeUv.top[3],
false,
true,
),
createFaceMaterial(
texture,
capeUv.bottom[0],
capeUv.bottom[1],
capeUv.bottom[2],
capeUv.bottom[3],
false,
true,
),
createFaceMaterial(
texture,
capeUv.back[0],
capeUv.back[1],
capeUv.back[2],
capeUv.back[3],
),
createFaceMaterial(
texture,
capeUv.front[0],
capeUv.front[1],
capeUv.front[2],
capeUv.front[3],
),
];
const capeMesh = new THREE.Mesh(capeGeo, capeMats);
capeMesh.position.set(0, -8, -0.5);
capeGroup.add(capeMesh);
capeGroup.position.set(0, 6, -2.35);
capeGroup.rotation.x = 0.15;
playerGroup.add(capeGroup);
}
function fitCamera(camera: THREE.PerspectiveCamera, target: THREE.Object3D) {
const box = new THREE.Box3().setFromObject(target);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z) || 1;
const vFov = THREE.MathUtils.degToRad(camera.fov);
const distance = maxDim / (2 * Math.tan(vFov / 2) * 0.72);
camera.position.set(center.x, center.y, center.z + distance);
camera.lookAt(center);
}
function addLights(scene: THREE.Scene) {
scene.add(new THREE.AmbientLight(0xffffff, 0.4));
scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 0.6));
const dl = new THREE.DirectionalLight(0xffffff, 0.8);
dl.position.set(10, 20, 10);
scene.add(dl);
}
function disposeScene(
scene: THREE.Scene,
renderer: THREE.WebGLRenderer,
extraTextures: THREE.Texture[],
) {
scene.traverse((object) => {
if (object instanceof THREE.Mesh) {
if (object.geometry) object.geometry.dispose();
const mats = Array.isArray(object.material)
? object.material
: [object.material];
mats.forEach((m) => {
if (m.map) m.map.dispose();
m.dispose();
});
}
});
extraTextures.forEach((t) => t.dispose());
renderer.dispose();
}
function renderSkinSnapshot(opts: {
src: string;
slim?: boolean;
capeUrl?: string | null;
width: number;
height: number;
}): Promise<string> {
return new Promise<string>((resolve, reject) => {
const canvas = document.createElement("canvas");
canvas.width = opts.width;
canvas.height = opts.height;
const ctx = canvas.getContext("2d");
if (!ctx) {
reject(new Error("no 2d context"));
return;
}
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
35,
opts.width / opts.height,
0.1,
1000,
);
const renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
preserveDrawingBuffer: true,
});
renderer.setPixelRatio(1);
renderer.setSize(opts.width, opts.height);
addLights(scene);
const playerGroup = new THREE.Group();
scene.add(playerGroup);
const extraTextures: THREE.Texture[] = [];
let active = true;
let skinDone = false;
let capeDone = !opts.capeUrl;
const finish = () => {
if (!active || !skinDone || !capeDone) return;
fitCamera(camera, playerGroup);
renderer.render(scene, camera);
ctx.drawImage(renderer.domElement, 0, 0);
const dataUrl = canvas.toDataURL("image/png");
disposeScene(scene, renderer, extraTextures);
active = false;
resolve(dataUrl);
};
const fail = (err: unknown) => {
if (!active) return;
active = false;
disposeScene(scene, renderer, extraTextures);
reject(err);
};
const loader = new THREE.TextureLoader();
loader.load(
opts.src,
(tex) => {
if (!active) return;
tex.magFilter = THREE.NearestFilter;
tex.minFilter = THREE.NearestFilter;
tex.colorSpace = THREE.SRGBColorSpace;
buildPlayer(playerGroup, tex, opts.slim);
extraTextures.push(tex);
skinDone = true;
finish();
},
undefined,
fail,
);
if (opts.capeUrl) {
loader.load(
opts.capeUrl,
(tex) => {
if (!active) return;
tex.magFilter = THREE.NearestFilter;
tex.minFilter = THREE.NearestFilter;
tex.colorSpace = THREE.SRGBColorSpace;
addCape(playerGroup, tex);
extraTextures.push(tex);
capeDone = true;
finish();
},
undefined,
fail,
);
}
});
}
export function getSkinModelSnapshot(opts: {
src: string;
slim?: boolean;
capeUrl?: string | null;
width: number;
height: number;
}): Promise<string> {
const key = `${opts.src}\u0000${opts.slim ? 1 : 0}\u0000${opts.capeUrl || ""}`;
const hit = snapshotCache.get(key);
if (hit) return Promise.resolve(hit);
const result = snapshotChain.then(() => renderSkinSnapshot(opts));
snapshotChain = result.then(
() => undefined,
() => undefined,
);
result.then((dataUrl) => snapshotCache.set(key, dataUrl)).catch(() => {});
return result;
}
const SkinModel3D = memo(function SkinModel3D({
src,
slim,
capeUrl,
animate,
className,
}: SkinModel3DProps) {
const mountRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = mountRef.current;
if (!el) return;
const width = el.clientWidth || 240;
const height = el.clientHeight || 320;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(35, width / height, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(width, height);
el.innerHTML = "";
el.appendChild(renderer.domElement);
addLights(scene);
const playerGroup = new THREE.Group();
scene.add(playerGroup);
const extraTextures: THREE.Texture[] = [];
let built = false;
let disposed = false;
let rotY = -0.35;
let rotX = 0.06;
let dragging = false;
let raf = 0;
let lastX = 0;
let lastY = 0;
const render = () => {
if (!disposed) renderer.render(scene, camera);
};
const onMouseDown = (e: MouseEvent) => {
dragging = true;
lastX = e.clientX;
lastY = e.clientY;
};
const onMouseMove = (e: MouseEvent) => {
if (!dragging || disposed) return;
rotY += (e.clientX - lastX) * 0.01;
rotX = clamp(rotX + (e.clientY - lastY) * 0.01, -0.6, 0.6);
lastX = e.clientX;
lastY = e.clientY;
};
const onMouseUp = () => {
dragging = false;
};
const onTouchStart = (e: TouchEvent) => {
const t = e.touches[0];
if (!t) return;
dragging = true;
lastX = t.clientX;
lastY = t.clientY;
};
const onTouchMove = (e: TouchEvent) => {
if (!dragging || disposed) return;
const t = e.touches[0];
if (!t) return;
rotY += (t.clientX - lastX) * 0.01;
rotX = clamp(rotX + (t.clientY - lastY) * 0.01, -0.6, 0.6);
lastX = t.clientX;
lastY = t.clientY;
};
const onTouchEnd = () => {
dragging = false;
};
const loop = () => {
raf = requestAnimationFrame(loop);
if (animate && !dragging) rotY += 0.004;
if (animate || dragging) {
playerGroup.rotation.y = rotY;
playerGroup.rotation.x = rotX;
render();
}
};
const handleResize = () => {
if (!mountRef.current || disposed) return;
const w = mountRef.current.clientWidth || width;
const h = mountRef.current.clientHeight || height;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
if (built) fitCamera(camera, playerGroup);
render();
};
const dispose = () => {
if (disposed) return;
disposed = true;
cancelAnimationFrame(raf);
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
window.removeEventListener("touchmove", onTouchMove);
window.removeEventListener("touchend", onTouchEnd);
window.removeEventListener("resize", handleResize);
disposeScene(scene, renderer, extraTextures);
};
const loader = new THREE.TextureLoader();
let skinLoaded = false;
let capeLoaded = !capeUrl;
const tryFinish = () => {
if (!skinLoaded || !capeLoaded || disposed) return;
built = true;
fitCamera(camera, playerGroup);
render();
loop();
};
loader.load(
src,
(tex) => {
if (disposed) return;
tex.magFilter = THREE.NearestFilter;
tex.minFilter = THREE.NearestFilter;
tex.colorSpace = THREE.SRGBColorSpace;
buildPlayer(playerGroup, tex, slim);
extraTextures.push(tex);
skinLoaded = true;
tryFinish();
},
undefined,
() => dispose(),
);
if (capeUrl) {
loader.load(
capeUrl,
(tex) => {
if (disposed) return;
tex.magFilter = THREE.NearestFilter;
tex.minFilter = THREE.NearestFilter;
tex.colorSpace = THREE.SRGBColorSpace;
addCape(playerGroup, tex);
extraTextures.push(tex);
capeLoaded = true;
tryFinish();
},
undefined,
() => dispose(),
);
}
renderer.domElement.addEventListener("mousedown", onMouseDown);
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
renderer.domElement.addEventListener("touchstart", onTouchStart, {
passive: true,
});
window.addEventListener("touchmove", onTouchMove, { passive: false });
window.addEventListener("touchend", onTouchEnd);
window.addEventListener("resize", handleResize);
return dispose;
}, [src, slim, capeUrl, animate]);
return <div ref={mountRef} className={className} />;
});
export default SkinModel3D;