UNPKG

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
/* 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