skin3d
Version:
A fast, customizable Minecraft skin viewer powered by Three.js. Easily render and preview Minecraft skins in 3D for your projects.
672 lines • 25.2 kB
JavaScript
/* eslint-disable tsdoc/syntax */
import { inferModelType, isTextureSource, loadCapeToCanvas, loadEarsToCanvas, loadEarsToCanvasFromSkin, loadImage, loadSkinToCanvas, } from "skinview-utils";
import { Color, PointLight, EquirectangularReflectionMapping, Group, NearestFilter, PerspectiveCamera, Scene, Texture, Vector2, WebGLRenderer, AmbientLight, CanvasTexture, WebGLRenderTarget, FloatType, DepthTexture, Clock, Object3D, ColorManagement, } from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js";
import { FullScreenQuad } from "three/examples/jsm/postprocessing/Pass.js";
import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js";
import { ShaderPass } from "three/examples/jsm/postprocessing/ShaderPass.js";
import { FXAAShader } from "three/examples/jsm/shaders/FXAAShader.js";
import { PlayerAnimation } from "./animation.js";
import { PlayerObject } from "./model.js";
import { NameTagObject } from "./nametag.js";
/**
* The View renders the player on a canvas.
*/
export class View {
constructor(options = {}) {
/** The canvas where the renderer draws its output. */
Object.defineProperty(this, "canvas", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "scene", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "camera", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "renderer", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
/** Mouse control component (OrbitControls). */
Object.defineProperty(this, "controls", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
/** The player object (skin, cape, elytra, ears). */
Object.defineProperty(this, "playerObject", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
/** Group that wraps the player object for centering. */
Object.defineProperty(this, "playerWrapper", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "globalLight", {
enumerable: true,
configurable: true,
writable: true,
value: new AmbientLight(0xffffff, 3)
});
Object.defineProperty(this, "cameraLight", {
enumerable: true,
configurable: true,
writable: true,
value: new PointLight(0xffffff, 0.6)
});
Object.defineProperty(this, "composer", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "renderPass", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "fxaaPass", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "skinCanvas", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "capeCanvas", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "earsCanvas", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "skinTexture", {
enumerable: true,
configurable: true,
writable: true,
value: null
});
Object.defineProperty(this, "capeTexture", {
enumerable: true,
configurable: true,
writable: true,
value: null
});
Object.defineProperty(this, "earsTexture", {
enumerable: true,
configurable: true,
writable: true,
value: null
});
Object.defineProperty(this, "backgroundTexture", {
enumerable: true,
configurable: true,
writable: true,
value: null
});
Object.defineProperty(this, "_disposed", {
enumerable: true,
configurable: true,
writable: true,
value: false
});
Object.defineProperty(this, "_renderPaused", {
enumerable: true,
configurable: true,
writable: true,
value: false
});
Object.defineProperty(this, "_zoom", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "isUserRotating", {
enumerable: true,
configurable: true,
writable: true,
value: false
});
/**
* Whether to rotate the player along the y axis.
*
* @defaultValue `false`
*/
Object.defineProperty(this, "autoRotate", {
enumerable: true,
configurable: true,
writable: true,
value: false
});
/**
* The angular velocity of the player, in rad/s.
*
* @defaultValue `1.0`
* @see {@link autoRotate}
*/
Object.defineProperty(this, "autoRotateSpeed", {
enumerable: true,
configurable: true,
writable: true,
value: 1.0
});
Object.defineProperty(this, "_animation", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "clock", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "animationID", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "onContextLost", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "onContextRestored", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_pixelRatio", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "devicePixelRatioQuery", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "onDevicePixelRatioChange", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_nameTag", {
enumerable: true,
configurable: true,
writable: true,
value: null
});
this.canvas = options.canvas ?? document.createElement("canvas");
this.skinCanvas = document.createElement("canvas");
this.capeCanvas = document.createElement("canvas");
this.earsCanvas = document.createElement("canvas");
this.scene = new Scene();
this.camera = new PerspectiveCamera();
this.camera.add(this.cameraLight);
this.scene.add(this.camera, this.globalLight);
ColorManagement.enabled = false;
this.renderer = new WebGLRenderer({
canvas: this.canvas,
preserveDrawingBuffer: options.preserveDrawingBuffer === true,
});
this.onDevicePixelRatioChange = () => {
this.renderer.setPixelRatio(window.devicePixelRatio);
this.updateComposerSize();
if (this._pixelRatio === "match-device") {
this.devicePixelRatioQuery = matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
this.devicePixelRatioQuery.addEventListener("change", this.onDevicePixelRatioChange, { once: true });
}
};
if (options.pixelRatio === undefined || options.pixelRatio === "match-device") {
this._pixelRatio = "match-device";
this.devicePixelRatioQuery = matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
this.devicePixelRatioQuery.addEventListener("change", this.onDevicePixelRatioChange, { once: true });
this.renderer.setPixelRatio(window.devicePixelRatio);
}
else {
this._pixelRatio = options.pixelRatio;
this.devicePixelRatioQuery = null;
this.renderer.setPixelRatio(options.pixelRatio);
}
this.renderer.setClearColor(0, 0);
let renderTarget;
if (this.renderer.capabilities.isWebGL2) {
renderTarget = new WebGLRenderTarget(0, 0, {
depthTexture: new DepthTexture(0, 0, FloatType),
});
}
this.composer = new EffectComposer(this.renderer, renderTarget);
this.renderPass = new RenderPass(this.scene, this.camera);
this.fxaaPass = new ShaderPass(FXAAShader);
this.composer.addPass(this.renderPass);
this.composer.addPass(this.fxaaPass);
this.playerObject = new PlayerObject();
this.playerObject.name = "player";
this.playerObject.skin.visible = false;
this.playerObject.cape.visible = false;
this.playerWrapper = new Group();
this.playerWrapper.add(this.playerObject);
this.scene.add(this.playerWrapper);
this.controls = new OrbitControls(this.camera, this.canvas);
// Restrict rotation and zoom based on options
this.controls.enableRotate = options.enableRotation !== false;
this.controls.enableZoom = options.allowZoom !== false;
// Apply initial options
if (options.skin !== undefined) {
this.loadSkin(options.skin, {
model: options.model,
ears: options.ears === "current-skin",
});
}
if (options.cape !== undefined)
this.loadCape(options.cape);
if (options.ears !== undefined && options.ears !== "current-skin") {
this.loadEars(options.ears.source, { textureType: options.ears.textureType });
}
if (options.width !== undefined)
this.width = options.width;
if (options.height !== undefined)
this.height = options.height;
if (options.background !== undefined)
this.background = options.background;
if (options.panorama !== undefined)
this.loadPanorama(options.panorama);
if (options.nameTag !== undefined)
this.nameTag = options.nameTag;
this.camera.position.z = 1;
this._zoom = options.zoom ?? 0.9;
this.fov = options.fov ?? 50;
this._animation = options.animation ?? null;
this.clock = new Clock();
if (options.renderPaused === true) {
this._renderPaused = true;
this.animationID = null;
}
else {
this.animationID = window.requestAnimationFrame(() => this.draw());
}
this.onContextLost = (event) => {
event.preventDefault();
if (this.animationID !== null) {
window.cancelAnimationFrame(this.animationID);
this.animationID = null;
}
};
this.onContextRestored = () => {
this.renderer.setClearColor(0, 0);
if (!this._renderPaused && !this._disposed && this.animationID === null) {
this.animationID = window.requestAnimationFrame(() => this.draw());
}
};
this.canvas.addEventListener("webglcontextlost", this.onContextLost, false);
this.canvas.addEventListener("webglcontextrestored", this.onContextRestored, false);
this.canvas.addEventListener("mousedown", () => {
this.isUserRotating = true;
}, false);
this.canvas.addEventListener("mouseup", () => {
this.isUserRotating = false;
}, false);
this.canvas.addEventListener("touchmove", e => {
this.isUserRotating = e.touches.length === 1;
}, false);
this.canvas.addEventListener("touchend", () => {
this.isUserRotating = false;
}, false);
}
/** Update the size and pixel ratio of the composer and FXAA pass. */
updateComposerSize() {
this.composer.setSize(this.width, this.height);
const pixelRatio = this.renderer.getPixelRatio();
this.composer.setPixelRatio(pixelRatio);
this.fxaaPass.material.uniforms["resolution"].value.x = 1 / (this.width * pixelRatio);
this.fxaaPass.material.uniforms["resolution"].value.y = 1 / (this.height * pixelRatio);
}
/** Create or update the skin texture from the skin canvas. */
recreateSkinTexture() {
this.skinTexture?.dispose();
this.skinTexture = new CanvasTexture(this.skinCanvas);
this.skinTexture.magFilter = NearestFilter;
this.skinTexture.minFilter = NearestFilter;
this.playerObject.skin.map = this.skinTexture;
}
/** Create or update the cape texture from the cape canvas. */
recreateCapeTexture() {
this.capeTexture?.dispose();
this.capeTexture = new CanvasTexture(this.capeCanvas);
this.capeTexture.magFilter = NearestFilter;
this.capeTexture.minFilter = NearestFilter;
this.playerObject.cape.map = this.capeTexture;
this.playerObject.elytra.map = this.capeTexture;
}
/** Create or update the ears texture from the ears canvas. */
recreateEarsTexture() {
this.earsTexture?.dispose();
this.earsTexture = new CanvasTexture(this.earsCanvas);
this.earsTexture.magFilter = NearestFilter;
this.earsTexture.minFilter = NearestFilter;
this.playerObject.ears.map = this.earsTexture;
}
loadSkin(source, options = {}) {
if (source === null) {
this.resetSkin();
}
else if (isTextureSource(source)) {
loadSkinToCanvas(this.skinCanvas, source);
this.recreateSkinTexture();
this.playerObject.skin.modelType =
options.model === undefined || options.model === "auto-detect"
? inferModelType(this.skinCanvas)
: options.model;
if (options.makeVisible !== false)
this.playerObject.skin.visible = true;
if (options.ears === true || options.ears === "load-only") {
loadEarsToCanvasFromSkin(this.earsCanvas, source);
this.recreateEarsTexture();
if (options.ears === true)
this.playerObject.ears.visible = true;
}
}
else {
return loadImage(source).then(image => this.loadSkin(image, options));
}
}
/** Hide and dispose the current skin texture. */
resetSkin() {
this.playerObject.skin.visible = false;
this.playerObject.skin.map = null;
this.skinTexture?.dispose();
this.skinTexture = null;
}
loadCape(source, options = {}) {
if (source === null) {
this.resetCape();
}
else if (isTextureSource(source)) {
loadCapeToCanvas(this.capeCanvas, source);
this.recreateCapeTexture();
if (options.makeVisible !== false) {
this.playerObject.backEquipment = options.backEquipment ?? "cape";
}
}
else {
return loadImage(source).then(image => this.loadCape(image, options));
}
}
/** Hide and dispose the current cape texture. */
resetCape() {
this.playerObject.backEquipment = null;
this.playerObject.cape.map = null;
this.playerObject.elytra.map = null;
this.capeTexture?.dispose();
this.capeTexture = null;
}
loadEars(source, options = {}) {
if (source === null) {
this.resetEars();
}
else if (isTextureSource(source)) {
if (options.textureType === "skin") {
loadEarsToCanvasFromSkin(this.earsCanvas, source);
}
else {
loadEarsToCanvas(this.earsCanvas, source);
}
this.recreateEarsTexture();
if (options.makeVisible !== false)
this.playerObject.ears.visible = true;
}
else {
return loadImage(source).then(image => this.loadEars(image, options));
}
}
/** Hide and dispose the current ears texture. */
resetEars() {
this.playerObject.ears.visible = false;
this.playerObject.ears.map = null;
this.earsTexture?.dispose();
this.earsTexture = null;
}
/** Load a panorama background. */
loadPanorama(source) {
return this.loadBackground(source, EquirectangularReflectionMapping);
}
loadBackground(source, mapping) {
if (isTextureSource(source)) {
this.backgroundTexture?.dispose();
this.backgroundTexture = new Texture();
this.backgroundTexture.image = source;
if (mapping)
this.backgroundTexture.mapping = mapping;
this.backgroundTexture.needsUpdate = true;
this.scene.background = this.backgroundTexture;
}
else {
return loadImage(source).then(image => this.loadBackground(image, mapping));
}
}
/** Animation and rendering loop. */
draw() {
const dt = this.clock.getDelta();
this._animation?.update(this.playerObject, dt);
if (this.autoRotate && !(this.controls.enableRotate && this.isUserRotating)) {
this.playerWrapper.rotation.y += dt * this.autoRotateSpeed;
}
this.controls.update();
this.render();
this.animationID = window.requestAnimationFrame(() => this.draw());
}
/** Render the scene to the canvas (does not advance animation). */
render() {
this.composer.render();
}
/** Set the viewer size in pixels. */
setSize(width, height) {
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.renderer.setSize(width, height);
this.updateComposerSize();
}
/** Dispose all resources and event listeners. */
dispose() {
this._disposed = true;
this.canvas.removeEventListener("webglcontextlost", this.onContextLost, false);
this.canvas.removeEventListener("webglcontextrestored", this.onContextRestored, false);
this.devicePixelRatioQuery?.removeEventListener("change", this.onDevicePixelRatioChange);
this.devicePixelRatioQuery = null;
if (this.animationID !== null) {
window.cancelAnimationFrame(this.animationID);
this.animationID = null;
}
this.controls.dispose();
this.renderer.dispose();
this.resetSkin();
this.resetCape();
this.resetEars();
this.background = null;
this.fxaaPass.fsQuad.dispose();
}
get disposed() {
return this._disposed;
}
/** Whether rendering and animations are paused. */
get renderPaused() {
return this._renderPaused;
}
set renderPaused(value) {
this._renderPaused = value;
if (value && this.animationID !== null) {
window.cancelAnimationFrame(this.animationID);
this.animationID = null;
this.clock.stop();
this.clock.autoStart = true;
}
else if (!value && !this._disposed && !this.renderer.getContext().isContextLost() && this.animationID == null) {
this.animationID = window.requestAnimationFrame(() => this.draw());
}
}
get width() {
return this.renderer.getSize(new Vector2()).width;
}
set width(newWidth) {
this.setSize(newWidth, this.height);
}
get height() {
return this.renderer.getSize(new Vector2()).height;
}
set height(newHeight) {
this.setSize(this.width, newHeight);
}
get background() {
return this.scene.background;
}
set background(value) {
if (value === null || value instanceof Color || value instanceof Texture) {
this.scene.background = value;
}
else {
this.scene.background = new Color(value);
}
if (this.backgroundTexture !== null && value !== this.backgroundTexture) {
this.backgroundTexture.dispose();
this.backgroundTexture = null;
}
}
/** Adjust camera distance based on FOV and zoom. */
adjustCameraDistance() {
let distance = 4.5 + 16.5 / Math.tan(((this.fov / 180) * Math.PI) / 2) / this.zoom;
distance = Math.max(10, Math.min(distance, 256));
this.camera.position.multiplyScalar(distance / this.camera.position.length());
this.camera.updateProjectionMatrix();
}
/** Reset camera to default pose and distance. */
resetCameraPose() {
this.camera.position.set(0, 0, 1);
this.camera.rotation.set(0, 0, 0);
this.adjustCameraDistance();
}
get fov() {
return this.camera.fov;
}
set fov(value) {
this.camera.fov = value;
this.adjustCameraDistance();
}
get zoom() {
return this._zoom;
}
set zoom(value) {
this._zoom = value;
this.adjustCameraDistance();
}
get pixelRatio() {
return this._pixelRatio;
}
set pixelRatio(newValue) {
if (newValue === "match-device") {
if (this._pixelRatio !== "match-device") {
this._pixelRatio = newValue;
this.onDevicePixelRatioChange();
}
}
else {
if (this._pixelRatio === "match-device" && this.devicePixelRatioQuery !== null) {
this.devicePixelRatioQuery.removeEventListener("change", this.onDevicePixelRatioChange);
this.devicePixelRatioQuery = null;
}
this._pixelRatio = newValue;
this.renderer.setPixelRatio(newValue);
this.updateComposerSize();
}
}
/**
* The animation that is currently playing, or `null` if no animation is playing.
* Setting this property to a different value will change the current animation.
* The player's pose and the progress of the new animation will be reset before playing.
* Setting this property to `null` will stop the current animation and reset the player's pose.
*/
get animation() {
return this._animation;
}
set animation(animation) {
if (this._animation !== animation) {
this.playerObject.resetJoints();
this.playerObject.position.set(0, 0, 0);
this.playerObject.rotation.set(0, 0, 0);
this.clock.stop();
this.clock.autoStart = true;
}
if (animation !== null)
animation.progress = 0;
this._animation = animation;
}
/**
* The name tag to display above the player, or `null` if there is none.
* When setting this property to a `string` value, a {@link NameTagObject}
* will be automatically created with default options.
*
* @example
* view.nameTag = "Norch";
* view.nameTag = new NameTagObject("hello", { textStyle: "yellow" });
* view.nameTag = null;
*/
get nameTag() {
return this._nameTag;
}
set nameTag(newVal) {
if (this._nameTag !== null)
this.playerWrapper.remove(this._nameTag);
if (newVal === null) {
this._nameTag = null;
return;
}
if (!(newVal instanceof Object3D))
newVal = new NameTagObject(newVal);
this.playerWrapper.add(newVal);
newVal.position.y = 20;
this._nameTag = newVal;
}
/** Reset the model's rotation to default. */
resetModelRotation() {
this.playerWrapper.rotation.set(0, 0, 0);
this.controls.reset();
}
}
//# sourceMappingURL=view.js.map