Compare commits

..

No commits in common. "master" and "v2.0.2" have entirely different histories.

13 changed files with 901 additions and 1200 deletions

View File

@ -1,7 +1,7 @@
MIT License
Copyright (c) 2014-2018 Kent Rasmussen
Copyright (c) 2017-2022 Haowei Wen, Sean Boult and contributors
Copyright (c) 2017-2021 Haowei Wen, Sean Boult and contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@ -12,7 +12,6 @@ Three.js powered Minecraft skin viewer.
* 1.8 Skins
* HD Skins
* Capes
* Ears
* Elytras
* Slim Arms
* Automatic model detection (Slim / Default)
@ -39,27 +38,12 @@ Three.js powered Minecraft skin viewer.
// Load a cape
skinViewer.loadCape("img/cape.png");
// Load an elytra (from a cape texture)
// Load a elytra (from a cape texture)
skinViewer.loadCape("img/cape.png", { backEquipment: "elytra" });
// Unload(hide) the cape / elytra
skinViewer.loadCape(null);
// Set the background color
skinViewer.background = 0x5a76f3;
// Set the background to a normal image
skinViewer.loadBackground("img/background.png");
// Set the background to a panoramic image
skinViewer.loadPanorama("img/panorama1.png");
// Change camera FOV
skinViewer.fov = 70;
// Zoom out
skinViewer.zoom = 0.5;
// Control objects with your mouse!
let control = skinview3d.createOrbitControls(skinViewer);
control.enableRotate = true;
@ -90,48 +74,15 @@ Three.js powered Minecraft skin viewer.
skinview3d supports FXAA (fast approximate anti-aliasing).
To enable it, you need to replace `SkinViewer` with `FXAASkinViewer`.
Note that FXAA is incompatible with transparent backgrounds.
So when FXAA is enabled, the default background color will be white instead of transparent.
You must use an **opaque** background when FXAA is enabled,
because FXAA is incompatible with transparent backgrounds.
## Lighting
By default, there are two lights on the scene. One is an ambient light, and the other is a point light from the camera.
To change the light intensity:
```js
skinViewer.cameraLight.intensity = 0.9;
skinViewer.globalLight.intensity = 0.1;
```
Setting `globalLight.intensity` to `1.0` and `cameraLight.intensity` to `0.0`
will completely disable shadows.
## Ears
skinview3d supports two types of ear texture:
* `standalone`: 14x7 image that contains the ear ([example](https://github.com/bs-community/skinview3d/blob/master/examples/img/ears.png))
* `skin`: Skin texture that contains the ear (e.g. [deadmau5's skin](https://minecraft.fandom.com/wiki/Easter_eggs#Deadmau5.27s_ears))
Usage:
```js
// You can specify ears in the constructor:
new skinview3d.SkinViewer({
skin: "img/deadmau5.png",
// Use ears drawn on the current skin (img/deadmau5.png)
ears: "current-skin",
// Or use ears from other textures
ears: {
textureType: "standalone", // "standalone" or "skin"
source: "img/ears.png"
}
});
// Show ears when loading skins:
skinViewer.loadSkin("img/deadmau5.png", { ears: true });
// Use ears from other textures:
skinViewer.loadEars("img/ears.png", { textureType: "standalone" });
skinViewer.loadEars("img/deadmau5.png", { textureType: "skin" });
By default, the background color is white.
To use a different color:
```javascript
let skinViewer = new skinview3d.FXAASkinViewer(...);
// Set the background color to blue
skinViewer.renderer.setClearColor(0x5a76f3);
```
# Build

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 MiB

View File

@ -22,11 +22,17 @@
h1,
h2 {
margin: 5px 0 0 0;
margin-bottom: 0;
}
input[type="number"] {
max-width: 60px;
}
input[type="text"] {
box-sizing: border-box;
max-width: 250px;
width: calc(100% - 100px);
}
.control {
@ -95,10 +101,6 @@
margin-top: 0;
padding-left: 20px;
}
.hidden {
display: none;
}
</style>
</head>
@ -111,42 +113,29 @@
<button id="reset_all" type="button" class="control">Reset All</button>
<div class="control-section">
<h1>Viewport</h1>
<div>
<label class="control">Width: <input id="canvas_width" type="number" value="300" size="4"></label>
<label class="control">Height: <input id="canvas_height" type="number" value="300" size="4"></label>
</div>
<div>
<label class="control">FOV: <input id="fov" type="number" value="70" step="1" min="1" max="179" size="2"></label>
<label class="control">Zoom: <input id="zoom" type="number" value="0.90" step="0.01" min="0.01" max="2.00" size="4"></label>
</div>
</div>
<div class="control-section">
<h1>Light</h1>
<label class="control">Global: <input id="global_light" type="number" value="0.40" step="0.01" min="0.00" max="2.00" size="4"></label>
<label class="control">Camera: <input id="camera_light" type="number" value="0.60" step="0.01" min="0.00" max="2.00" size="4"></label>
<h1>Canvas Size</h1>
<label class="control">Width: <input id="canvas_width" type="number" value="300"></label>
<label class="control">Height: <input id="canvas_height" type="number" value="300"></label>
</div>
<div class="control-section">
<h1>Animation</h1>
<label class="control">Global Speed: <input id="global_animation_speed" type="number" value="1" step="0.1" size="3"></label>
<label class="control">Global Speed: <input id="global_animation_speed" type="number" value="1" step="0.1"></label>
<button id="animation_pause_resume" type="button" class="control">Pause / Resume</button>
<div>
<h2>Rotate</h2>
<label class="control"><input id="rotate_animation" type="checkbox"> Enable</label>
<label class="control">Speed: <input id="rotate_animation_speed" type="number" value="1" step="0.1" size="3"></label>
<label class="control">Speed: <input id="rotate_animation_speed" type="number" value="1" step="0.1"></label>
</div>
<div>
<h2>Walk / Run / Fly</h2>
<div class="control">
<label><input type="radio" id="primary_animation_none" name="primary_animation" value="" checked> None</label>
<label><input type="radio" id="primary_animation_idle" name="primary_animation" value="idle"> Idle</label>
<label><input type="radio" id="primary_animation_walk" name="primary_animation" value="walk"> Walk</label>
<label><input type="radio" id="primary_animation_run" name="primary_animation" value="run"> Run</label>
<label><input type="radio" id="primary_animation_fly" name="primary_animation" value="fly"> Fly</label>
</div>
<label class="control">Speed: <input id="primary_animation_speed" type="number" value="1" step="0.1" size="3"></label>
<label class="control">Speed: <input id="primary_animation_speed" type="number" value="1" step="0.1"></label>
</div>
</div>
@ -204,10 +193,10 @@
</div>
<div class="control-section">
<h1>Skin</h1>
<h1>Textures</h1>
<div>
<div class="control">
<label>URL: <input id="skin_url" type="text" value="img/hatsune_miku.png" placeholder="none" list="default_skins" size="20"></label>
<label>Skin URL: <input id="skin_url" type="text" value="img/1_8_texturemap_redux.png" placeholder="none" list="default_skins"></label>
<datalist id="default_skins">
<option value="img/1_8_texturemap_redux.png">
<option value="img/hacksore.png">
@ -215,15 +204,11 @@
<option value="img/hatsune_miku.png">
<option value="img/ironman_hd.png">
<option value="img/sethbling.png">
<option value="img/deadmau5.png">
</datalist>
<input id="skin_url_upload" type="file" class="hidden" accept="image/*">
<button id="skin_url_unset" type="button" class="control hidden">Unset</button>
<input id="skin_url_upload" type="file" accept="image/*" style="display: none;">
<button type="button" class="control"
onclick="document.getElementById('skin_url_upload').click();">Browse...</button>
</div>
</div>
<div>
<label class="control">Model:
<select id="skin_model">
<option value="auto-detect" selected>Auto detect</option>
@ -232,64 +217,20 @@
</select>
</label>
</div>
</div>
<div class="control-section">
<h1>Cape</h1>
<div>
<div class="control">
<label>URL: <input id="cape_url" type="text" value="img/mojang_cape.png" placeholder="none" list="default_capes" size="20"></label>
<label>Cape URL: <input id="cape_url" type="text" value="" placeholder="none" list="default_capes"></label>
<datalist id="default_capes">
<option value="">
<option value="img/mojang_cape.png">
<option value="img/legacy_cape.png">
<option value="img/hd_cape.png">
</datalist>
<input id="cape_url_upload" type="file" class="hidden" accept="image/*">
<button id="cape_url_unset" type="button" class="control hidden">Unset</button>
<input id="cape_url_upload" type="file" accept="image/*" style="display: none;">
<button type="button" class="control"
onclick="document.getElementById('cape_url_upload').click();">Browse...</button>
</div>
</div>
<div class="control-section">
<h1>Ears</h1>
<div>
<label class="control">Source:
<select id="ears_source">
<option value="none">None</option>
<option value="current_skin">Current skin</option>
<option value="skin">Skin texture</option>
<option value="standalone">Standalone texture</option>
</select>
</label>
</div>
<div id="ears_texture_input">
<label class="control">URL: <input id="ears_url" type="text" value="" placeholder="none" list="default_ears" size="20"></label>
<datalist id="default_ears">
<option value="">
<option value="img/ears.png" data-texture-type="standalone">
<option value="img/deadmau5.png" data-texture-type="skin">
</datalist>
<input id="ears_url_upload" type="file" class="hidden" accept="image/*">
<button id="ears_url_unset" type="button" class="control hidden">Unset</button>
<button type="button" class="control"
onclick="document.getElementById('ears_url_upload').click();">Browse...</button>
</div>
</div>
<div class="control-section">
<h1>Panorama</h1>
<div class="control">
<label>URL: <input id="panorama_url" type="text" value="img/panorama.png" placeholder="none" list="default_panorama" size="20"></label>
<datalist id="default_panorama">
<option value="">
<option value="img/panorama.png">
</datalist>
<input id="panorama_url_upload" type="file" class="hidden" accept="image/*">
<button id="panorama_url_unset" type="button" class="control hidden">Unset</button>
<button type="button" class="control"
onclick="document.getElementById('panorama_url_upload').click();">Browse...</button>
</div>
</div>
<div class="control-section">
@ -319,7 +260,6 @@
const skinParts = ["head", "body", "rightArm", "leftArm", "rightLeg", "leftLeg"];
const skinLayers = ["innerLayer", "outerLayer"];
const availableAnimations = {
idle: skinview3d.IdleAnimation,
walk: skinview3d.WalkingAnimation,
run: skinview3d.RunningAnimation,
fly: skinview3d.FlyingAnimation
@ -330,35 +270,14 @@
let rotateAnimation;
let primaryAnimation;
function obtainTextureUrl(id) {
const urlInput = document.getElementById(id);
const fileInput = document.getElementById(id + "_upload");
const unsetButton = document.getElementById(id + "_unset");
const file = fileInput.files[0];
if (file === undefined) {
if (!unsetButton.classList.contains("hidden")) {
unsetButton.classList.add("hidden");
}
return urlInput.value;
} else {
unsetButton.classList.remove("hidden");
urlInput.value = `Local file: ${file.name}`;
urlInput.readOnly = true;
return URL.createObjectURL(file);
}
}
function reloadSkin() {
const input = document.getElementById("skin_url");
const url = obtainTextureUrl("skin_url");
const url = input.value;
if (url === "") {
skinViewer.loadSkin(null);
input.setCustomValidity("");
} else {
skinViewer.loadSkin(url, {
model: document.getElementById("skin_model").value,
ears: document.getElementById("ears_source").value === "current_skin"
})
skinViewer.loadSkin(url, document.getElementById("skin_model").value)
.then(() => input.setCustomValidity(""))
.catch(e => {
input.setCustomValidity("Image can't be loaded.");
@ -369,7 +288,7 @@
function reloadCape() {
const input = document.getElementById("cape_url");
const url = obtainTextureUrl("cape_url");
const url = input.value;
if (url === "") {
skinViewer.loadCape(null);
input.setCustomValidity("");
@ -384,69 +303,9 @@
}
}
function reloadEars(skipSkinReload = false) {
const sourceType = document.getElementById("ears_source").value;
let hideInput = true;
if (sourceType === "none") {
skinViewer.loadEars(null);
} else if (sourceType === "current_skin") {
if (!skipSkinReload){
reloadSkin();
}
} else {
hideInput = false;
document.querySelectorAll("#default_ears option[data-texture-type]").forEach(opt => {
opt.disabled = opt.dataset.textureType !== sourceType;
});
const input = document.getElementById("ears_url");
const url = obtainTextureUrl("ears_url");
if (url === "") {
skinViewer.loadEars(null);
input.setCustomValidity("");
} else {
skinViewer.loadEars(url, { textureType: sourceType })
.then(() => input.setCustomValidity(""))
.catch(e => {
input.setCustomValidity("Image can't be loaded.");
console.error(e);
});
}
}
const el = document.getElementById("ears_texture_input");
if (hideInput) {
if (!(el.classList.contains("hidden"))){
el.classList.add("hidden");
}
} else {
el.classList.remove("hidden");
}
}
function reloadPanorama() {
const input = document.getElementById("panorama_url");
const url = obtainTextureUrl("panorama_url");
if (url === "") {
skinViewer.background = "white";
input.setCustomValidity("");
} else {
skinViewer.loadPanorama(url)
.then(() => input.setCustomValidity(""))
.catch(e => {
input.setCustomValidity("Image can't be loaded.");
console.error(e);
});
}
}
function initializeControls() {
document.getElementById("canvas_width").addEventListener("change", e => skinViewer.width = e.target.value);
document.getElementById("canvas_height").addEventListener("change", e => skinViewer.height = e.target.value);
document.getElementById("fov").addEventListener("change", e => skinViewer.fov = e.target.value);
document.getElementById("zoom").addEventListener("change", e => skinViewer.zoom = e.target.value);
document.getElementById("global_light").addEventListener("change", e => skinViewer.globalLight.intensity = e.target.value);
document.getElementById("camera_light").addEventListener("change", e => skinViewer.cameraLight.intensity = e.target.value);
document.getElementById("global_animation_speed").addEventListener("change", e => skinViewer.animations.speed = e.target.value);
document.getElementById("animation_pause_resume").addEventListener("click", () => skinViewer.animations.paused = !skinViewer.animations.paused);
document.getElementById("rotate_animation").addEventListener("change", e => {
@ -489,36 +348,31 @@
.addEventListener("change", e => skinViewer.playerObject.skin[part][layer].visible = e.target.checked);
}
}
const initializeUploadButton = (id, callback) => {
const urlInput = document.getElementById(id);
const fileInput = document.getElementById(id + "_upload");
const unsetButton = document.getElementById(id + "_unset");
const unsetAction = () => {
urlInput.readOnly = false;
urlInput.value = "";
fileInput.value = fileInput.defaultValue;
callback();
};
fileInput.addEventListener("change", e => callback());
urlInput.addEventListener("keydown", e => {
if (e.key === "Backspace" && urlInput.readOnly) {
unsetAction();
const skinReader = new FileReader();
skinReader.addEventListener("load", e => {
document.getElementById("skin_url").value = skinReader.result;
reloadSkin();
});
document.getElementById("skin_url_upload").addEventListener("change", e => {
const file = e.target.files[0];
if (file !== undefined) {
skinReader.readAsDataURL(file);
}
});
const capeReader = new FileReader();
capeReader.addEventListener("load", e => {
document.getElementById("cape_url").value = capeReader.result;
reloadCape();
});
document.getElementById("cape_url_upload").addEventListener("change", e => {
const file = e.target.files[0];
if (file !== undefined) {
capeReader.readAsDataURL(file);
}
});
unsetButton.addEventListener("click", e => unsetAction());
};
initializeUploadButton("skin_url", reloadSkin);
initializeUploadButton("cape_url", reloadCape);
initializeUploadButton("ears_url", reloadEars);
initializeUploadButton("panorama_url", reloadPanorama);
document.getElementById("skin_url").addEventListener("change", () => reloadSkin());
document.getElementById("skin_model").addEventListener("change", () => reloadSkin());
document.getElementById("cape_url").addEventListener("change", () => reloadCape());
document.getElementById("ears_source").addEventListener("change", () => reloadEars());
document.getElementById("ears_url").addEventListener("change", () => reloadEars());
document.getElementById("panorama_url").addEventListener("change", () => reloadPanorama());
for (const el of document.querySelectorAll('input[type="radio"][name="back_equipment"]')) {
el.addEventListener("change", e => {
@ -542,16 +396,13 @@
skinViewer = new skinview3d.FXAASkinViewer({
canvas: document.getElementById("skin_container")
});
skinViewer.renderer.setClearColor(0x5a76f3);
orbitControl = skinview3d.createOrbitControls(skinViewer);
rotateAnimation = null;
primaryAnimation = null;
skinViewer.width = document.getElementById("canvas_width").value;
skinViewer.height = document.getElementById("canvas_height").value;
skinViewer.fov = document.getElementById("fov").value;
skinViewer.zoom = document.getElementById("zoom").value;
skinViewer.globalLight.intensity = document.getElementById("global_light").value;
skinViewer.cameraLight.intensity = document.getElementById("camera_light").value;
skinViewer.animations.speed = document.getElementById("global_animation_speed").value;
if (document.getElementById("rotate_animation").checked) {
rotateAnimation = skinViewer.animations.add(skinview3d.RotatingAnimation);
@ -573,8 +424,6 @@
}
reloadSkin();
reloadCape();
reloadEars(true);
reloadPanorama();
}
initializeControls();

1364
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "skinview3d",
"version": "2.2.1",
"version": "2.0.2",
"description": "Three.js powered Minecraft skin viewer",
"main": "libs/skinview3d.js",
"type": "module",
@ -38,22 +38,22 @@
"bundles"
],
"dependencies": {
"@types/three": "^0.136.1",
"skinview-utils": "^0.7.0",
"three": "^0.136.0"
"@types/three": "^0.131.0",
"skinview-utils": "^0.6.0",
"three": "^0.131.3"
},
"devDependencies": {
"@rollup/plugin-node-resolve": "^13.1.3",
"@rollup/plugin-typescript": "^8.3.0",
"@typescript-eslint/eslint-plugin": "^5.9.0",
"@typescript-eslint/parser": "^5.9.0",
"@yushijinhun/three-minifier-rollup": "^0.3.1",
"eslint": "^8.6.0",
"local-web-server": "^5.1.1",
"@rollup/plugin-node-resolve": "^13.0.4",
"@rollup/plugin-typescript": "^8.2.5",
"@typescript-eslint/eslint-plugin": "^4.29.3",
"@typescript-eslint/parser": "^4.29.3",
"@yushijinhun/three-minifier-rollup": "^0.3.0",
"eslint": "^7.32.0",
"local-web-server": "^5.1.0",
"npm-run-all": "^4.1.5",
"rimraf": "^3.0.2",
"rollup": "^2.63.0",
"rollup": "^2.56.3",
"rollup-plugin-terser": "^7.0.2",
"typescript": "^4.5.4"
"typescript": "^4.3.5"
}
}

View File

@ -83,9 +83,9 @@ class AnimationWrapper implements SubAnimationHandle, IAnimation {
export class CompositeAnimation implements IAnimation {
readonly handles: Set<SubAnimationHandle & IAnimation> = new Set();
readonly handles: Set<AnimationHandle & IAnimation> = new Set();
add(animation: Animation): SubAnimationHandle {
add(animation: Animation): AnimationHandle {
const handle = new AnimationWrapper(animation);
handle.remove = (): void => {
this.handles.delete(handle);
@ -133,22 +133,6 @@ export class RootAnimation extends CompositeAnimation implements AnimationHandle
}
}
export const IdleAnimation: Animation = (player, time) => {
const skin = player.skin;
// Multiply by animation's natural speed
time *= 2;
// Arm swing
const basicArmRotationZ = Math.PI * 0.02;
skin.leftArm.rotation.z = Math.cos(time) * 0.03 + basicArmRotationZ;
skin.rightArm.rotation.z = Math.cos(time + Math.PI) * 0.03 - basicArmRotationZ;
// Always add an angle for cape around the x axis
const basicCapeRotationX = Math.PI * 0.06;
player.cape.rotation.x = Math.sin(time) * 0.01 + basicCapeRotationX;
};
export const WalkingAnimation: Animation = (player, time) => {
const skin = player.skin;

View File

@ -14,12 +14,9 @@ export class FXAASkinViewer extends SkinViewer {
constructor(options?: SkinViewerOptions) {
// Force options.alpha to false, because FXAA is incompatible with transparent backgrounds
if (options === undefined) {
options = { alpha: false, background: "white" };
options = { alpha: false };
} else {
options.alpha = false;
if (options.background === undefined) {
options.background = "white";
}
}
super(options);

View File

@ -1,5 +1,5 @@
import { ModelType } from "skinview-utils";
import { BoxGeometry, BufferAttribute, DoubleSide, FrontSide, Group, Mesh, MeshStandardMaterial, Object3D, Texture, Vector2 } from "three";
import { BoxGeometry, BufferAttribute, DoubleSide, FrontSide, Group, Mesh, MeshBasicMaterial, Object3D, Texture, Vector2 } from "three";
function setUVs(box: BoxGeometry, u: number, v: number, width: number, height: number, depth: number, textureWidth: number, textureHeight: number): void {
const toFaceVertices = (x1: number, y1: number, x2: number, y2: number) => [
@ -66,11 +66,11 @@ export class SkinObject extends Group {
constructor(texture: Texture) {
super();
const layer1Material = new MeshStandardMaterial({
const layer1Material = new MeshBasicMaterial({
map: texture,
side: FrontSide
});
const layer2Material = new MeshStandardMaterial({
const layer2Material = new MeshBasicMaterial({
map: texture,
side: DoubleSide,
transparent: true,
@ -99,8 +99,7 @@ export class SkinObject extends Group {
this.head = new BodyPart(headMesh, head2Mesh);
this.head.name = "head";
this.head.add(headMesh, head2Mesh);
headMesh.position.y = 4;
head2Mesh.position.y = 4;
this.head.position.y = 4;
this.add(this.head);
// Body
@ -258,7 +257,7 @@ export class CapeObject extends Group {
constructor(texture: Texture) {
super();
const capeMaterial = new MeshStandardMaterial({
const capeMaterial = new MeshBasicMaterial({
map: texture,
side: DoubleSide,
transparent: true,
@ -284,7 +283,7 @@ export class ElytraObject extends Group {
constructor(texture: Texture) {
super();
const elytraMaterial = new MeshStandardMaterial({
const elytraMaterial = new MeshBasicMaterial({
map: texture,
side: DoubleSide,
transparent: true,
@ -332,33 +331,6 @@ export class ElytraObject extends Group {
}
}
export class EarsObject extends Group {
readonly rightEar: Mesh;
readonly leftEar: Mesh;
constructor(texture: Texture) {
super();
const material = new MeshStandardMaterial({
map: texture,
side: FrontSide
});
const earBox = new BoxGeometry(8, 8, 4 / 3);
setUVs(earBox, 0, 0, 6, 6, 1, 14, 7);
this.rightEar = new Mesh(earBox, material);
this.rightEar.name = "rightEar";
this.rightEar.position.x = -6;
this.add(this.rightEar);
this.leftEar = new Mesh(earBox, material);
this.leftEar.name = "leftEar";
this.leftEar.position.x = 6;
this.add(this.leftEar);
}
}
export type BackEquipment = "cape" | "elytra";
export class PlayerObject extends Group {
@ -366,19 +338,16 @@ export class PlayerObject extends Group {
readonly skin: SkinObject;
readonly cape: CapeObject;
readonly elytra: ElytraObject;
readonly ears: EarsObject;
constructor(skinTexture: Texture, capeTexture: Texture, earsTexture: Texture) {
constructor(skinTexture: Texture, capeTexture: Texture) {
super();
this.skin = new SkinObject(skinTexture);
this.skin.name = "skin";
this.skin.position.y = 8;
this.add(this.skin);
this.cape = new CapeObject(capeTexture);
this.cape.name = "cape";
this.cape.position.y = 8;
this.cape.position.z = -2;
this.cape.rotation.x = 10.8 * Math.PI / 180;
this.cape.rotation.y = Math.PI;
@ -386,17 +355,9 @@ export class PlayerObject extends Group {
this.elytra = new ElytraObject(capeTexture);
this.elytra.name = "elytra";
this.elytra.position.y = 8;
this.elytra.position.z = -2;
this.elytra.visible = false;
this.add(this.elytra);
this.ears = new EarsObject(earsTexture);
this.ears.name = "ears";
this.ears.position.y = 10;
this.ears.position.z = 2 / 3;
this.ears.visible = false;
this.skin.head.add(this.ears);
}
get backEquipment(): BackEquipment | null {

View File

@ -7,7 +7,7 @@ export function createOrbitControls(skinViewer: SkinViewer): OrbitControls {
// default configuration
control.enablePan = false;
control.target = new Vector3(0, 0, 0);
control.target = new Vector3(0, -8, 0);
control.minDistance = 10;
control.maxDistance = 256;
control.update();

View File

@ -1,5 +1,5 @@
import { inferModelType, isTextureSource, loadCapeToCanvas, loadEarsToCanvas, loadEarsToCanvasFromSkin, loadImage, loadSkinToCanvas, ModelType, RemoteImage, TextureSource } from "skinview-utils";
import { Color, ColorRepresentation, PointLight, EquirectangularReflectionMapping, Group, NearestFilter, PerspectiveCamera, Scene, Texture, Vector2, WebGLRenderer, AmbientLight, Mapping } from "three";
import { inferModelType, isTextureSource, loadCapeToCanvas, loadImage, loadSkinToCanvas, ModelType, RemoteImage, TextureSource } from "skinview-utils";
import { NearestFilter, PerspectiveCamera, Scene, Texture, Vector2, WebGLRenderer } from "three";
import { RootAnimation } from "./animation.js";
import { BackEquipment, PlayerObject } from "./model.js";
@ -10,21 +10,6 @@ export interface LoadOptions {
makeVisible?: boolean;
}
export interface SkinLoadOptions extends LoadOptions {
/**
* The model type of skin. Default is "auto-detect".
*/
model?: ModelType | "auto-detect";
/**
* true: Loads the ears drawn on the skin texture, and show it.
* "load-only": Loads the ears drawn on the skin texture, but do not make it visible.
* false: Do not load ears from the skin texture.
* Default is false.
*/
ears?: boolean | "load-only";
}
export interface CapeLoadOptions extends LoadOptions {
/**
* The equipment (cape or elytra) to show, defaults to "cape".
@ -33,15 +18,6 @@ export interface CapeLoadOptions extends LoadOptions {
backEquipment?: BackEquipment;
}
export interface EarsLoadOptions extends LoadOptions {
/**
* "standalone": The texture is a 14x7 image that only contains the ears;
* "skin": The texture is a skin that contains ears, and we only show its ear part.
* Default is "standalone".
*/
textureType?: "standalone" | "skin";
}
export interface SkinViewerOptions {
width?: number;
height?: number;
@ -49,17 +25,6 @@ export interface SkinViewerOptions {
model?: ModelType | "auto-detect";
cape?: RemoteImage | TextureSource;
/**
* If you want to show the ears drawn on the current skin, set this to "current-skin".
* To show ears that come from a separate texture, you have to specify 'textureType' ("standalone" or "skin") and 'source'.
* "standalone" means the provided texture is a 14x7 image that only contains the ears.
* "skin" means the provided texture is a skin that contains ears, and we only show its ear part.
*/
ears?: "current-skin" | {
textureType: "standalone" | "skin",
source: RemoteImage | TextureSource
}
/**
* Whether the canvas contains an alpha buffer. Default is true.
* This option can be turned off if you use an opaque background.
@ -82,29 +47,6 @@ export interface SkinViewerOptions {
* If this option is true, rendering and animation loops will not start.
*/
renderPaused?: boolean;
/**
* The background of the scene. Default is transparent.
*/
background?: ColorRepresentation | Texture;
/**
* The panorama background to use. This option overrides 'background' option.
*/
panorama?: RemoteImage | TextureSource;
/**
* Camera vertical field of view, in degrees. Default is 50.
* The distance between the object and the camera is automatically computed.
*/
fov?: number;
/**
* Zoom ratio of the player. Default is 0.9.
* This value affects the distance between the object and the camera.
* When set to 1.0, the top edge of the player's head coincides with the edge of the view.
*/
zoom?: number;
}
export class SkinViewer {
@ -113,26 +55,15 @@ export class SkinViewer {
readonly camera: PerspectiveCamera;
readonly renderer: WebGLRenderer;
readonly playerObject: PlayerObject;
readonly playerWrapper: Group;
readonly animations: RootAnimation = new RootAnimation();
readonly globalLight: AmbientLight = new AmbientLight(0xffffff, 0.4);
readonly cameraLight: PointLight = new PointLight(0xffffff, 0.6);
readonly skinCanvas: HTMLCanvasElement;
readonly capeCanvas: HTMLCanvasElement;
readonly earsCanvas: HTMLCanvasElement;
private readonly skinTexture: Texture;
private readonly capeTexture: Texture;
private readonly earsTexture: Texture;
private backgroundTexture: Texture | null = null;
private _disposed: boolean = false;
private _renderPaused: boolean = false;
private _zoom: number;
private animationID: number | null;
private onContextLost: (event: Event) => void;
private onContextRestored: () => void;
constructor(options: SkinViewerOptions = {}) {
this.canvas = options.canvas === undefined ? document.createElement("canvas") : options.canvas;
@ -148,17 +79,12 @@ export class SkinViewer {
this.capeTexture.magFilter = NearestFilter;
this.capeTexture.minFilter = NearestFilter;
this.earsCanvas = document.createElement("canvas");
this.earsTexture = new Texture(this.earsCanvas);
this.earsTexture.magFilter = NearestFilter;
this.earsTexture.minFilter = NearestFilter;
this.scene = new Scene();
this.camera = new PerspectiveCamera();
this.camera.add(this.cameraLight);
this.scene.add(this.camera);
this.scene.add(this.globalLight);
// Use smaller fov to avoid distortion
this.camera = new PerspectiveCamera(40);
this.camera.position.y = -8;
this.camera.position.z = 60;
this.renderer = new WebGLRenderer({
canvas: this.canvas,
@ -168,106 +94,56 @@ export class SkinViewer {
});
this.renderer.setPixelRatio(window.devicePixelRatio);
this.playerObject = new PlayerObject(this.skinTexture, this.capeTexture, this.earsTexture);
this.playerObject = new PlayerObject(this.skinTexture, this.capeTexture);
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.scene.add(this.playerObject);
if (options.skin !== undefined) {
this.loadSkin(options.skin, {
model: options.model,
ears: options.ears === "current-skin"
});
this.loadSkin(options.skin, options.model);
}
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);
}
this.camera.position.z = 1;
this._zoom = options.zoom === undefined ? 0.9 : options.zoom;
this.fov = options.fov === undefined ? 50 : options.fov;
if (options.renderPaused === true) {
this._renderPaused = true;
this.animationID = null;
} else {
this.animationID = window.requestAnimationFrame(() => this.draw());
window.requestAnimationFrame(() => this.draw());
}
this.onContextLost = (event: Event) => {
event.preventDefault();
if (this.animationID !== null) {
window.cancelAnimationFrame(this.animationID);
this.animationID = null;
}
};
this.onContextRestored = () => {
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);
}
loadSkin(empty: null): void;
loadSkin<S extends TextureSource | RemoteImage>(
source: S,
options?: SkinLoadOptions
model?: ModelType | "auto-detect",
options?: LoadOptions
): S extends TextureSource ? void : Promise<void>;
loadSkin(
source: TextureSource | RemoteImage | null,
options: SkinLoadOptions = {}
model: ModelType | "auto-detect" = "auto-detect",
options: LoadOptions = {}
): void | Promise<void> {
if (source === null) {
this.resetSkin();
} else if (isTextureSource(source)) {
loadSkinToCanvas(this.skinCanvas, source);
const actualModel = model === "auto-detect" ? inferModelType(this.skinCanvas) : model;
this.skinTexture.needsUpdate = true;
if (options.model === undefined || options.model === "auto-detect") {
this.playerObject.skin.modelType = inferModelType(this.skinCanvas);
} else {
this.playerObject.skin.modelType = options.model;
}
this.playerObject.skin.modelType = actualModel;
if (options.makeVisible !== false) {
this.playerObject.skin.visible = true;
}
if (options.ears === true || options.ears == "load-only") {
loadEarsToCanvasFromSkin(this.earsCanvas, source);
this.earsTexture.needsUpdate = true;
if (options.ears === true) {
this.playerObject.ears.visible = true;
}
}
} else {
return loadImage(source).then(image => this.loadSkin(image, options));
return loadImage(source).then(image => this.loadSkin(image, model, options));
}
}
@ -287,15 +163,12 @@ export class SkinViewer {
): void | Promise<void> {
if (source === null) {
this.resetCape();
} else if (isTextureSource(source)) {
loadCapeToCanvas(this.capeCanvas, source);
this.capeTexture.needsUpdate = true;
if (options.makeVisible !== false) {
this.playerObject.backEquipment = options.backEquipment === undefined ? "cape" : options.backEquipment;
}
} else {
return loadImage(source).then(image => this.loadCape(image, options));
}
@ -305,75 +178,13 @@ export class SkinViewer {
this.playerObject.backEquipment = null;
}
loadEars(empty: null): void;
loadEars<S extends TextureSource | RemoteImage>(
source: S,
options?: EarsLoadOptions
): S extends TextureSource ? void : Promise<void>;
loadEars(
source: TextureSource | RemoteImage | null,
options: EarsLoadOptions = {}
): void | Promise<void> {
if (source === null) {
this.resetEars();
} else if (isTextureSource(source)) {
if (options.textureType === "skin") {
loadEarsToCanvasFromSkin(this.earsCanvas, source);
} else {
loadEarsToCanvas(this.earsCanvas, source);
}
this.earsTexture.needsUpdate = true;
if (options.makeVisible !== false) {
this.playerObject.ears.visible = true;
}
} else {
return loadImage(source).then(image => this.loadEars(image, options));
}
}
resetEars(): void {
this.playerObject.ears.visible = false;
}
loadPanorama<S extends TextureSource | RemoteImage>(
source: S
): S extends TextureSource ? void : Promise<void> {
return this.loadBackground(source, EquirectangularReflectionMapping);
}
loadBackground<S extends TextureSource | RemoteImage>(
source: S,
mapping?: Mapping
): S extends TextureSource ? void : Promise<void>;
loadBackground<S extends TextureSource | RemoteImage>(
source: S,
mapping?: Mapping
): void | Promise<void> {
if (isTextureSource(source)) {
if (this.backgroundTexture !== null) {
this.backgroundTexture.dispose();
}
this.backgroundTexture = new Texture();
this.backgroundTexture.image = source;
if (mapping !== undefined) {
this.backgroundTexture.mapping = mapping;
}
this.backgroundTexture.needsUpdate = true;
this.scene.background = this.backgroundTexture;
} else {
return loadImage(source).then(image => this.loadBackground(image, mapping));
}
}
private draw(): void {
if (this.disposed || this._renderPaused) {
return;
}
this.animations.runAnimationLoop(this.playerObject);
this.render();
this.animationID = window.requestAnimationFrame(() => this.draw());
window.requestAnimationFrame(() => this.draw());
}
/**
@ -392,22 +203,9 @@ export class SkinViewer {
dispose(): void {
this._disposed = true;
this.canvas.removeEventListener("webglcontextlost", this.onContextLost, false);
this.canvas.removeEventListener("webglcontextrestored", this.onContextRestored, false);
if (this.animationID !== null) {
window.cancelAnimationFrame(this.animationID);
this.animationID = null;
}
this.renderer.dispose();
this.skinTexture.dispose();
this.capeTexture.dispose();
if (this.backgroundTexture !== null) {
this.backgroundTexture.dispose();
this.backgroundTexture = null;
}
}
get disposed(): boolean {
@ -424,13 +222,10 @@ export class SkinViewer {
}
set renderPaused(value: boolean) {
const toResume = !this.disposed && !value && this._renderPaused;
this._renderPaused = value;
if (this._renderPaused && this.animationID !== null) {
window.cancelAnimationFrame(this.animationID);
this.animationID = null;
} else if (!this._renderPaused && !this._disposed && !this.renderer.getContext().isContextLost() && this.animationID == null) {
this.animationID = window.requestAnimationFrame(() => this.draw());
if (toResume) {
window.requestAnimationFrame(() => this.draw());
}
}
@ -449,52 +244,4 @@ export class SkinViewer {
set height(newHeight: number) {
this.setSize(this.width, newHeight);
}
get background(): null | Color | Texture {
return this.scene.background;
}
set background(value: null | ColorRepresentation | Texture) {
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;
}
}
adjustCameraDistance(): void {
let distance = 4.5 + 16.5 / Math.tan(this.fov / 180 * Math.PI / 2) / this.zoom;
// limit distance between 10 ~ 256 (default min / max distance of OrbitControls)
if (distance < 10) {
distance = 10;
} else if (distance > 256) {
distance = 256;
}
this.camera.position.multiplyScalar(distance / this.camera.position.length());
this.camera.updateProjectionMatrix();
}
get fov(): number {
return this.camera.fov;
}
set fov(value: number) {
this.camera.fov = value;
this.adjustCameraDistance();
}
get zoom(): number {
return this._zoom;
}
set zoom(value: number) {
this._zoom = value;
this.adjustCameraDistance();
}
}