Compare commits
73 Commits
v2.0.0-alp
...
master
Author | SHA1 | Date |
---|---|---|
|
cb5cfae82c | |
|
79971a79be | |
|
be9651b9d6 | |
|
4afe4f0876 | |
|
c26471b104 | |
|
7f7c32d16a | |
|
3758035bf8 | |
|
47f34b856c | |
|
9f975c3354 | |
|
403ea13c1a | |
|
03a6a03449 | |
|
35fe0ecc8c | |
|
e989c6c8cf | |
|
5fbf497002 | |
|
b16194be6a | |
|
4c066091b0 | |
|
9b0bdc46eb | |
|
819fdd5c3c | |
|
af85724b2d | |
|
5da30d2157 | |
|
4355d13c0b | |
|
f22604cdb4 | |
|
7e9f229820 | |
|
f273ee8b56 | |
|
7114f93c7c | |
|
5c2bcacf7c | |
|
0fac73356c | |
|
cdfde336d9 | |
|
02c520e421 | |
|
ff510a9ad0 | |
|
7e3c0025e3 | |
|
f60abb91da | |
|
db01d41b4c | |
|
0dc8e42c30 | |
|
43ccf00163 | |
|
15bf945590 | |
|
2174a0ee12 | |
|
7fabf579eb | |
|
825c2311ee | |
|
22412938d7 | |
|
45b6737a44 | |
|
8b92012349 | |
|
5dca37f0b4 | |
|
3a808713e8 | |
|
3f7ff52930 | |
|
32cc449cc9 | |
|
3de8b09730 | |
|
4c4acf9501 | |
|
00414b6fff | |
|
4dcca770ca | |
|
f5c7692c8d | |
|
4cdd21661b | |
|
968f803caa | |
|
84d0180dca | |
|
6c0bc016ed | |
|
cc90328881 | |
|
3c39d912e7 | |
|
61aa9753af | |
|
61572b6824 | |
|
52e809138d | |
|
e7c9399875 | |
|
400eecb9e9 | |
|
d3f3cb422c | |
|
7df054c7ec | |
|
4df5bc8c72 | |
|
d6a41acc0b | |
|
bd9bc29684 | |
|
a79d5b4ba9 | |
|
a579f7bf26 | |
|
eff80c1f02 | |
|
a5a70d13c7 | |
|
798a111ade | |
|
c8ddc277eb |
|
@ -6,6 +6,9 @@ extends:
|
|||
- 'eslint:recommended'
|
||||
- 'plugin:@typescript-eslint/eslint-recommended'
|
||||
- 'plugin:@typescript-eslint/recommended'
|
||||
ignorePatterns:
|
||||
- bundles/**/*
|
||||
- libs/**/*
|
||||
rules:
|
||||
'@typescript-eslint/no-inferrable-types': off
|
||||
'@typescript-eslint/interface-name-prefix': off
|
||||
|
|
2
LICENSE
2
LICENSE
|
@ -1,7 +1,7 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2014-2018 Kent Rasmussen
|
||||
Copyright (c) 2017-2020 Haowei Wen, Sean Boult and contributors
|
||||
Copyright (c) 2017-2022 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
|
||||
|
|
71
README.md
71
README.md
|
@ -12,6 +12,8 @@ Three.js powered Minecraft skin viewer.
|
|||
* 1.8 Skins
|
||||
* HD Skins
|
||||
* Capes
|
||||
* Ears
|
||||
* Elytras
|
||||
* Slim Arms
|
||||
* Automatic model detection (Slim / Default)
|
||||
|
||||
|
@ -37,9 +39,27 @@ Three.js powered Minecraft skin viewer.
|
|||
// Load a cape
|
||||
skinViewer.loadCape("img/cape.png");
|
||||
|
||||
// Unload(hide) the cape
|
||||
// Load an 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;
|
||||
|
@ -70,17 +90,48 @@ Three.js powered Minecraft skin viewer.
|
|||
skinview3d supports FXAA (fast approximate anti-aliasing).
|
||||
To enable it, you need to replace `SkinViewer` with `FXAASkinViewer`.
|
||||
|
||||
It's recommended to use an opaque background when FXAA is enabled,
|
||||
as transparent background may look buggy.
|
||||
Note that FXAA is incompatible with transparent backgrounds.
|
||||
So when FXAA is enabled, the default background color will be white instead of transparent.
|
||||
|
||||
```javascript
|
||||
let skinViewer = new skinview3d.FXAASkinViewer({
|
||||
// we do not use transparent background, so disable alpha to improve performance
|
||||
alpha: false,
|
||||
...
|
||||
## 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"
|
||||
}
|
||||
});
|
||||
// set the background color
|
||||
skinViewer.renderer.setClearColor(0x5a76f3);
|
||||
|
||||
// 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" });
|
||||
```
|
||||
|
||||
# Build
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 3.5 KiB |
Binary file not shown.
After Width: | Height: | Size: 233 B |
Binary file not shown.
After Width: | Height: | Size: 11 MiB |
|
@ -22,17 +22,11 @@
|
|||
|
||||
h1,
|
||||
h2 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
max-width: 60px;
|
||||
margin: 5px 0 0 0;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
box-sizing: border-box;
|
||||
max-width: 250px;
|
||||
width: calc(100% - 100px);
|
||||
}
|
||||
|
||||
.control {
|
||||
|
@ -101,6 +95,10 @@
|
|||
margin-top: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
|
@ -113,28 +111,42 @@
|
|||
<button id="reset_all" type="button" class="control">Reset All</button>
|
||||
|
||||
<div class="control-section">
|
||||
<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>
|
||||
<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>
|
||||
</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"></label>
|
||||
<label class="control">Global Speed: <input id="global_animation_speed" type="number" value="1" step="0.1" size="3"></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"></label>
|
||||
<label class="control">Speed: <input id="rotate_animation_speed" type="number" value="1" step="0.1" size="3"></label>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Walk / Run</h2>
|
||||
<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"></label>
|
||||
<label class="control">Speed: <input id="primary_animation_speed" type="number" value="1" step="0.1" size="3"></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -182,13 +194,20 @@
|
|||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div>
|
||||
<h2>Back Equipment</h2>
|
||||
<div class="control">
|
||||
<label><input type="radio" id="back_equipment_cape" name="back_equipment" value="cape" checked> Cape</label>
|
||||
<label><input type="radio" id="back_equipment_elytra" name="back_equipment" value="elytra"> Elytra</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-section">
|
||||
<h1>Textures</h1>
|
||||
<h1>Skin</h1>
|
||||
<div>
|
||||
<div class="control">
|
||||
<label>Skin URL: <input id="skin_url" type="text" value="img/1_8_texturemap_redux.png" placeholder="none" list="default_skins"></label>
|
||||
<label>URL: <input id="skin_url" type="text" value="img/hatsune_miku.png" placeholder="none" list="default_skins" size="20"></label>
|
||||
<datalist id="default_skins">
|
||||
<option value="img/1_8_texturemap_redux.png">
|
||||
<option value="img/hacksore.png">
|
||||
|
@ -196,11 +215,15 @@
|
|||
<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" accept="image/*" style="display: none;">
|
||||
<input id="skin_url_upload" type="file" class="hidden" accept="image/*">
|
||||
<button id="skin_url_unset" type="button" class="control hidden">Unset</button>
|
||||
<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>
|
||||
|
@ -209,19 +232,63 @@
|
|||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-section">
|
||||
<h1>Cape</h1>
|
||||
<div class="control">
|
||||
<label>URL: <input id="cape_url" type="text" value="img/mojang_cape.png" placeholder="none" list="default_capes" size="20"></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>
|
||||
<button type="button" class="control"
|
||||
onclick="document.getElementById('cape_url_upload').click();">Browse...</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-section">
|
||||
<h1>Ears</h1>
|
||||
<div>
|
||||
<div class="control">
|
||||
<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" accept="image/*" style="display: none;">
|
||||
<button type="button" class="control"
|
||||
onclick="document.getElementById('cape_url_upload').click();">Browse...</button>
|
||||
</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>
|
||||
|
||||
|
@ -252,23 +319,46 @@
|
|||
const skinParts = ["head", "body", "rightArm", "leftArm", "rightLeg", "leftLeg"];
|
||||
const skinLayers = ["innerLayer", "outerLayer"];
|
||||
const availableAnimations = {
|
||||
idle: skinview3d.IdleAnimation,
|
||||
walk: skinview3d.WalkingAnimation,
|
||||
run: skinview3d.RunningAnimation
|
||||
run: skinview3d.RunningAnimation,
|
||||
fly: skinview3d.FlyingAnimation
|
||||
};
|
||||
|
||||
let skinViewer;
|
||||
let oribitControl;
|
||||
let orbitControl;
|
||||
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 = input.value;
|
||||
const url = obtainTextureUrl("skin_url");
|
||||
if (url === "") {
|
||||
skinViewer.loadSkin(null);
|
||||
input.setCustomValidity("");
|
||||
} else {
|
||||
skinViewer.loadSkin(url, document.getElementById("skin_model").value)
|
||||
skinViewer.loadSkin(url, {
|
||||
model: document.getElementById("skin_model").value,
|
||||
ears: document.getElementById("ears_source").value === "current_skin"
|
||||
})
|
||||
.then(() => input.setCustomValidity(""))
|
||||
.catch(e => {
|
||||
input.setCustomValidity("Image can't be loaded.");
|
||||
|
@ -279,12 +369,69 @@
|
|||
|
||||
function reloadCape() {
|
||||
const input = document.getElementById("cape_url");
|
||||
const url = input.value;
|
||||
const url = obtainTextureUrl("cape_url");
|
||||
if (url === "") {
|
||||
skinViewer.loadCape(null);
|
||||
input.setCustomValidity("");
|
||||
} else {
|
||||
skinViewer.loadCape(url)
|
||||
const selectedBackEquipment = document.querySelector('input[type="radio"][name="back_equipment"]:checked');
|
||||
skinViewer.loadCape(url, { backEquipment: selectedBackEquipment.value })
|
||||
.then(() => input.setCustomValidity(""))
|
||||
.catch(e => {
|
||||
input.setCustomValidity("Image can't be loaded.");
|
||||
console.error(e);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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.");
|
||||
|
@ -296,6 +443,10 @@
|
|||
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 => {
|
||||
|
@ -329,58 +480,78 @@
|
|||
primaryAnimation.speed = e.target.value;
|
||||
}
|
||||
});
|
||||
document.getElementById("control_rotate").addEventListener("change", e => oribitControl.enableRotate = e.target.checked);
|
||||
document.getElementById("control_zoom").addEventListener("change", e => oribitControl.enableZoom = e.target.checked);
|
||||
document.getElementById("control_pan").addEventListener("change", e => oribitControl.enablePan = e.target.checked);
|
||||
document.getElementById("control_rotate").addEventListener("change", e => orbitControl.enableRotate = e.target.checked);
|
||||
document.getElementById("control_zoom").addEventListener("change", e => orbitControl.enableZoom = e.target.checked);
|
||||
document.getElementById("control_pan").addEventListener("change", e => orbitControl.enablePan = e.target.checked);
|
||||
for (const part of skinParts) {
|
||||
for (const layer of skinLayers) {
|
||||
document.querySelector(`#layers_table input[type="checkbox"][data-part="${part}"][data-layer="${layer}"]`)
|
||||
.addEventListener("change", e => skinViewer.playerObject.skin[part][layer].visible = e.target.checked);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
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 => {
|
||||
if (skinViewer.playerObject.backEquipment === null) {
|
||||
// cape texture hasn't been loaded yet
|
||||
// this option will be processed on texture loading
|
||||
} else {
|
||||
skinViewer.playerObject.backEquipment = e.target.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("reset_all").addEventListener("click", () => {
|
||||
skinViewer.dispose();
|
||||
orbitControl.dispose();
|
||||
initializeViewer();
|
||||
});
|
||||
}
|
||||
|
||||
function initializeViewer() {
|
||||
skinViewer = new skinview3d.FXAASkinViewer({
|
||||
canvas: document.getElementById("skin_container"),
|
||||
alpha: false
|
||||
canvas: document.getElementById("skin_container")
|
||||
});
|
||||
skinViewer.renderer.setClearColor(0x5a76f3);
|
||||
oribitControl = skinview3d.createOrbitControls(skinViewer);
|
||||
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);
|
||||
|
@ -391,9 +562,9 @@
|
|||
primaryAnimation = skinViewer.animations.add(availableAnimations[primaryAnimationName]);
|
||||
primaryAnimation.speed = document.getElementById("primary_animation_speed").value;
|
||||
}
|
||||
oribitControl.enableRotate = document.getElementById("control_rotate").checked;
|
||||
oribitControl.enableZoom = document.getElementById("control_zoom").checked;
|
||||
oribitControl.enablePan = document.getElementById("control_pan").checked;
|
||||
orbitControl.enableRotate = document.getElementById("control_rotate").checked;
|
||||
orbitControl.enableZoom = document.getElementById("control_zoom").checked;
|
||||
orbitControl.enablePan = document.getElementById("control_pan").checked;
|
||||
for (const part of skinParts) {
|
||||
for (const layer of skinLayers) {
|
||||
skinViewer.playerObject.skin[part][layer].visible =
|
||||
|
@ -402,6 +573,8 @@
|
|||
}
|
||||
reloadSkin();
|
||||
reloadCape();
|
||||
reloadEars(true);
|
||||
reloadPanorama();
|
||||
}
|
||||
|
||||
initializeControls();
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
<div id="rendered_imgs"></div>
|
||||
<script src="../bundles/skinview3d.bundle.js"></script>
|
||||
<script>
|
||||
const textures = [
|
||||
const configurations = [
|
||||
{
|
||||
skin: "img/1_8_texturemap_redux.png",
|
||||
cape: null
|
||||
|
@ -23,11 +23,12 @@
|
|||
},
|
||||
{
|
||||
skin: "img/haka.png",
|
||||
cape: null
|
||||
cape: "img/mojang_cape.png"
|
||||
},
|
||||
{
|
||||
skin: "img/hatsune_miku.png",
|
||||
cape: "img/mojang_cape.png"
|
||||
cape: "img/mojang_cape.png",
|
||||
backEquipment: "elytra"
|
||||
},
|
||||
{
|
||||
skin: "img/ironman_hd.png",
|
||||
|
@ -43,19 +44,20 @@
|
|||
const skinViewer = new skinview3d.FXAASkinViewer({
|
||||
width: 200,
|
||||
height: 300,
|
||||
alpha: false,
|
||||
renderPaused: true
|
||||
});
|
||||
skinViewer.renderer.setClearColor(0x5a76f3);
|
||||
skinViewer.camera.rotation.x = -0.620;
|
||||
skinViewer.camera.rotation.y = 0.534;
|
||||
skinViewer.camera.rotation.z = 0.348;
|
||||
skinViewer.camera.position.x = 30.5;
|
||||
skinViewer.camera.position.y = 18.0;
|
||||
skinViewer.camera.position.y = 22.0;
|
||||
skinViewer.camera.position.z = 42.0;
|
||||
|
||||
for (const { skin, cape } of textures) {
|
||||
await Promise.all([skinViewer.loadSkin(skin), skinViewer.loadCape(cape)]);
|
||||
for (const config of configurations) {
|
||||
await Promise.all([
|
||||
skinViewer.loadSkin(config.skin),
|
||||
skinViewer.loadCape(config.cape, { backEquipment: config.backEquipment })
|
||||
]);
|
||||
skinViewer.render();
|
||||
const image = skinViewer.canvas.toDataURL();
|
||||
|
||||
|
|
File diff suppressed because it is too large
Load Diff
25
package.json
25
package.json
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "skinview3d",
|
||||
"version": "2.0.0-alpha.10",
|
||||
"version": "2.2.1",
|
||||
"description": "Three.js powered Minecraft skin viewer",
|
||||
"main": "libs/skinview3d.js",
|
||||
"type": "module",
|
||||
|
@ -38,21 +38,22 @@
|
|||
"bundles"
|
||||
],
|
||||
"dependencies": {
|
||||
"skinview-utils": "^0.5.8",
|
||||
"three": "^0.120.1"
|
||||
"@types/three": "^0.136.1",
|
||||
"skinview-utils": "^0.7.0",
|
||||
"three": "^0.136.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-node-resolve": "^9.0.0",
|
||||
"@rollup/plugin-typescript": "^6.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^4.1.0",
|
||||
"@typescript-eslint/parser": "^4.1.0",
|
||||
"@yushijinhun/three-minifier-rollup": "^0.2.0-alpha.2",
|
||||
"eslint": "^7.8.1",
|
||||
"local-web-server": "^4.2.1",
|
||||
"@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",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"rimraf": "^3.0.2",
|
||||
"rollup": "^2.26.11",
|
||||
"rollup": "^2.63.0",
|
||||
"rollup-plugin-terser": "^7.0.2",
|
||||
"typescript": "^4.0.2"
|
||||
"typescript": "^4.5.4"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -83,9 +83,9 @@ class AnimationWrapper implements SubAnimationHandle, IAnimation {
|
|||
|
||||
export class CompositeAnimation implements IAnimation {
|
||||
|
||||
readonly handles: Set<AnimationHandle & IAnimation> = new Set();
|
||||
readonly handles: Set<SubAnimationHandle & IAnimation> = new Set();
|
||||
|
||||
add(animation: Animation): AnimationHandle {
|
||||
add(animation: Animation): SubAnimationHandle {
|
||||
const handle = new AnimationWrapper(animation);
|
||||
handle.remove = (): void => {
|
||||
this.handles.delete(handle);
|
||||
|
@ -133,6 +133,22 @@ 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;
|
||||
|
||||
|
@ -195,3 +211,30 @@ export const RunningAnimation: Animation = (player, time) => {
|
|||
export const RotatingAnimation: Animation = (player, time) => {
|
||||
player.rotation.y = time;
|
||||
};
|
||||
|
||||
function clamp(num: number, min: number, max: number): number {
|
||||
return num <= min ? min : num >= max ? max : num;
|
||||
}
|
||||
|
||||
export const FlyingAnimation: Animation = (player, time) => {
|
||||
// body rotation finishes in 0.5s
|
||||
// elytra expansion finishes in 3.3s
|
||||
|
||||
if (time < 0) time = 0;
|
||||
time *= 20;
|
||||
const startProgress = clamp((time * time) / 100, 0, 1);
|
||||
|
||||
player.rotation.x = startProgress * Math.PI / 2;
|
||||
player.skin.head.rotation.x = startProgress > .5 ? Math.PI / 4 - player.rotation.x : 0;
|
||||
|
||||
const basicArmRotationZ = Math.PI * .25 * startProgress;
|
||||
player.skin.leftArm.rotation.z = basicArmRotationZ;
|
||||
player.skin.rightArm.rotation.z = -basicArmRotationZ;
|
||||
|
||||
const elytraRotationX = .34906584;
|
||||
const elytraRotationZ = Math.PI / 2;
|
||||
const interpolation = Math.pow(.9, time);
|
||||
player.elytra.leftWing.rotation.x = elytraRotationX + interpolation * (.2617994 - elytraRotationX);
|
||||
player.elytra.leftWing.rotation.z = elytraRotationZ + interpolation * (.2617994 - elytraRotationZ);
|
||||
player.elytra.updateRightWing();
|
||||
};
|
||||
|
|
25
src/fxaa.ts
25
src/fxaa.ts
|
@ -1,4 +1,5 @@
|
|||
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";
|
||||
|
@ -10,11 +11,17 @@ export class FXAASkinViewer extends SkinViewer {
|
|||
readonly renderPass: RenderPass;
|
||||
readonly fxaaPass: ShaderPass;
|
||||
|
||||
/**
|
||||
* Note: FXAA doesn't work well with transparent backgrounds.
|
||||
* It's recommended to use an opaque background and set `options.alpha` to false.
|
||||
*/
|
||||
constructor(options: SkinViewerOptions = {}) {
|
||||
constructor(options?: SkinViewerOptions) {
|
||||
// Force options.alpha to false, because FXAA is incompatible with transparent backgrounds
|
||||
if (options === undefined) {
|
||||
options = { alpha: false, background: "white" };
|
||||
} else {
|
||||
options.alpha = false;
|
||||
if (options.background === undefined) {
|
||||
options.background = "white";
|
||||
}
|
||||
}
|
||||
|
||||
super(options);
|
||||
this.composer = new EffectComposer(this.renderer);
|
||||
this.renderPass = new RenderPass(this.scene, this.camera);
|
||||
|
@ -22,6 +29,9 @@ export class FXAASkinViewer extends SkinViewer {
|
|||
this.composer.addPass(this.renderPass);
|
||||
this.composer.addPass(this.fxaaPass);
|
||||
this.updateComposerSize();
|
||||
|
||||
// Default background: white
|
||||
this.renderer.setClearColor("white");
|
||||
}
|
||||
|
||||
setSize(width: number, height: number): void {
|
||||
|
@ -42,4 +52,9 @@ export class FXAASkinViewer extends SkinViewer {
|
|||
render(): void {
|
||||
this.composer.render();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
super.dispose();
|
||||
(this.fxaaPass.fsQuad as FullScreenQuad).dispose();
|
||||
}
|
||||
}
|
||||
|
|
414
src/model.ts
414
src/model.ts
|
@ -1,37 +1,39 @@
|
|||
import { ModelType } from "skinview-utils";
|
||||
import { BoxGeometry, DoubleSide, FrontSide, Group, Mesh, MeshBasicMaterial, Object3D, Texture, Vector2 } from "three";
|
||||
import { BoxGeometry, BufferAttribute, DoubleSide, FrontSide, Group, Mesh, MeshStandardMaterial, Object3D, Texture, Vector2 } from "three";
|
||||
|
||||
function toFaceVertices(x1: number, y1: number, x2: number, y2: number, w: number, h: number): Array<Vector2> {
|
||||
return [
|
||||
new Vector2(x1 / w, 1.0 - y2 / h),
|
||||
new Vector2(x2 / w, 1.0 - y2 / h),
|
||||
new Vector2(x2 / w, 1.0 - y1 / h),
|
||||
new Vector2(x1 / w, 1.0 - y1 / h)
|
||||
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) => [
|
||||
new Vector2(x1 / textureWidth, 1.0 - y2 / textureHeight),
|
||||
new Vector2(x2 / textureWidth, 1.0 - y2 / textureHeight),
|
||||
new Vector2(x2 / textureWidth, 1.0 - y1 / textureHeight),
|
||||
new Vector2(x1 / textureWidth, 1.0 - y1 / textureHeight)
|
||||
];
|
||||
|
||||
const top = toFaceVertices(u + depth, v, u + width + depth, v + depth);
|
||||
const bottom = toFaceVertices(u + width + depth, v, u + width * 2 + depth, v + depth);
|
||||
const left = toFaceVertices(u, v + depth, u + depth, v + depth + height);
|
||||
const front = toFaceVertices(u + depth, v + depth, u + width + depth, v + depth + height);
|
||||
const right = toFaceVertices(u + width + depth, v + depth, u + width + depth * 2, v + height + depth);
|
||||
const back = toFaceVertices(u + width + depth * 2, v + depth, u + width * 2 + depth * 2, v + height + depth);
|
||||
|
||||
const uvAttr = box.attributes.uv as BufferAttribute;
|
||||
uvAttr.copyVector2sArray([
|
||||
right[3], right[2], right[0], right[1],
|
||||
left[3], left[2], left[0], left[1],
|
||||
top[3], top[2], top[0], top[1],
|
||||
bottom[0], bottom[1], bottom[3], bottom[2],
|
||||
front[3], front[2], front[0], front[1],
|
||||
back[3], back[2], back[0], back[1]
|
||||
]);
|
||||
uvAttr.needsUpdate = true;
|
||||
}
|
||||
|
||||
function toSkinVertices(x1: number, y1: number, x2: number, y2: number): Array<Vector2> {
|
||||
return toFaceVertices(x1, y1, x2, y2, 64.0, 64.0);
|
||||
function setSkinUVs(box: BoxGeometry, u: number, v: number, width: number, height: number, depth: number): void {
|
||||
setUVs(box, u, v, width, height, depth, 64, 64);
|
||||
}
|
||||
|
||||
function toCapeVertices(x1: number, y1: number, x2: number, y2: number): Array<Vector2> {
|
||||
return toFaceVertices(x1, y1, x2, y2, 64.0, 32.0);
|
||||
}
|
||||
|
||||
function setVertices(box: BoxGeometry, top: Array<Vector2>, bottom: Array<Vector2>, left: Array<Vector2>, front: Array<Vector2>, right: Array<Vector2>, back: Array<Vector2>): void {
|
||||
box.faceVertexUvs[0] = [];
|
||||
box.faceVertexUvs[0][0] = [right[3], right[0], right[2]];
|
||||
box.faceVertexUvs[0][1] = [right[0], right[1], right[2]];
|
||||
box.faceVertexUvs[0][2] = [left[3], left[0], left[2]];
|
||||
box.faceVertexUvs[0][3] = [left[0], left[1], left[2]];
|
||||
box.faceVertexUvs[0][4] = [top[3], top[0], top[2]];
|
||||
box.faceVertexUvs[0][5] = [top[0], top[1], top[2]];
|
||||
box.faceVertexUvs[0][6] = [bottom[0], bottom[3], bottom[1]];
|
||||
box.faceVertexUvs[0][7] = [bottom[3], bottom[2], bottom[1]];
|
||||
box.faceVertexUvs[0][8] = [front[3], front[0], front[2]];
|
||||
box.faceVertexUvs[0][9] = [front[0], front[1], front[2]];
|
||||
box.faceVertexUvs[0][10] = [back[3], back[0], back[2]];
|
||||
box.faceVertexUvs[0][11] = [back[0], back[1], back[2]];
|
||||
function setCapeUVs(box: BoxGeometry, u: number, v: number, width: number, height: number, depth: number): void {
|
||||
setUVs(box, u, v, width, height, depth, 64, 32);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -64,11 +66,11 @@ export class SkinObject extends Group {
|
|||
constructor(texture: Texture) {
|
||||
super();
|
||||
|
||||
const layer1Material = new MeshBasicMaterial({
|
||||
const layer1Material = new MeshStandardMaterial({
|
||||
map: texture,
|
||||
side: FrontSide
|
||||
});
|
||||
const layer2Material = new MeshBasicMaterial({
|
||||
const layer2Material = new MeshStandardMaterial({
|
||||
map: texture,
|
||||
side: DoubleSide,
|
||||
transparent: true,
|
||||
|
@ -87,231 +89,109 @@ export class SkinObject extends Group {
|
|||
|
||||
// Head
|
||||
const headBox = new BoxGeometry(8, 8, 8);
|
||||
setVertices(headBox,
|
||||
toSkinVertices(8, 0, 16, 8),
|
||||
toSkinVertices(16, 0, 24, 8),
|
||||
toSkinVertices(0, 8, 8, 16),
|
||||
toSkinVertices(8, 8, 16, 16),
|
||||
toSkinVertices(16, 8, 24, 16),
|
||||
toSkinVertices(24, 8, 32, 16)
|
||||
);
|
||||
setSkinUVs(headBox, 0, 0, 8, 8, 8);
|
||||
const headMesh = new Mesh(headBox, layer1Material);
|
||||
|
||||
const head2Box = new BoxGeometry(9, 9, 9);
|
||||
setVertices(head2Box,
|
||||
toSkinVertices(40, 0, 48, 8),
|
||||
toSkinVertices(48, 0, 56, 8),
|
||||
toSkinVertices(32, 8, 40, 16),
|
||||
toSkinVertices(40, 8, 48, 16),
|
||||
toSkinVertices(48, 8, 56, 16),
|
||||
toSkinVertices(56, 8, 64, 16)
|
||||
);
|
||||
setSkinUVs(head2Box, 32, 0, 8, 8, 8);
|
||||
const head2Mesh = new Mesh(head2Box, layer2Material);
|
||||
head2Mesh.renderOrder = -1;
|
||||
|
||||
this.head = new BodyPart(headMesh, head2Mesh);
|
||||
this.head.name = "head";
|
||||
this.head.add(headMesh, head2Mesh);
|
||||
headMesh.position.y = 4;
|
||||
head2Mesh.position.y = 4;
|
||||
this.add(this.head);
|
||||
|
||||
// Body
|
||||
const bodyBox = new BoxGeometry(8, 12, 4);
|
||||
setVertices(bodyBox,
|
||||
toSkinVertices(20, 16, 28, 20),
|
||||
toSkinVertices(28, 16, 36, 20),
|
||||
toSkinVertices(16, 20, 20, 32),
|
||||
toSkinVertices(20, 20, 28, 32),
|
||||
toSkinVertices(28, 20, 32, 32),
|
||||
toSkinVertices(32, 20, 40, 32)
|
||||
);
|
||||
setSkinUVs(bodyBox, 16, 16, 8, 12, 4);
|
||||
const bodyMesh = new Mesh(bodyBox, layer1Material);
|
||||
|
||||
const body2Box = new BoxGeometry(9, 13.5, 4.5);
|
||||
setVertices(body2Box,
|
||||
toSkinVertices(20, 32, 28, 36),
|
||||
toSkinVertices(28, 32, 36, 36),
|
||||
toSkinVertices(16, 36, 20, 48),
|
||||
toSkinVertices(20, 36, 28, 48),
|
||||
toSkinVertices(28, 36, 32, 48),
|
||||
toSkinVertices(32, 36, 40, 48)
|
||||
);
|
||||
const body2Box = new BoxGeometry(8.5, 12.5, 4.5);
|
||||
setSkinUVs(body2Box, 16, 32, 8, 12, 4);
|
||||
const body2Mesh = new Mesh(body2Box, layer2Material);
|
||||
|
||||
this.body = new BodyPart(bodyMesh, body2Mesh);
|
||||
this.body.name = "body";
|
||||
this.body.add(bodyMesh, body2Mesh);
|
||||
this.body.position.y = -10;
|
||||
this.body.position.y = -6;
|
||||
this.add(this.body);
|
||||
|
||||
// Right Arm
|
||||
const rightArmBox = new BoxGeometry();
|
||||
const rightArmMesh = new Mesh(rightArmBox, layer1Material);
|
||||
const rightArmMesh = new Mesh(rightArmBox, layer1MaterialBiased);
|
||||
this.modelListeners.push(() => {
|
||||
rightArmMesh.scale.x = this.slim ? 3 : 4;
|
||||
rightArmMesh.scale.y = 12;
|
||||
rightArmMesh.scale.z = 4;
|
||||
if (this.slim) {
|
||||
setVertices(rightArmBox,
|
||||
toSkinVertices(44, 16, 47, 20),
|
||||
toSkinVertices(47, 16, 50, 20),
|
||||
toSkinVertices(40, 20, 44, 32),
|
||||
toSkinVertices(44, 20, 47, 32),
|
||||
toSkinVertices(47, 20, 51, 32),
|
||||
toSkinVertices(51, 20, 54, 32)
|
||||
);
|
||||
} else {
|
||||
setVertices(rightArmBox,
|
||||
toSkinVertices(44, 16, 48, 20),
|
||||
toSkinVertices(48, 16, 52, 20),
|
||||
toSkinVertices(40, 20, 44, 32),
|
||||
toSkinVertices(44, 20, 48, 32),
|
||||
toSkinVertices(48, 20, 52, 32),
|
||||
toSkinVertices(52, 20, 56, 32)
|
||||
);
|
||||
}
|
||||
rightArmBox.uvsNeedUpdate = true;
|
||||
rightArmBox.elementsNeedUpdate = true;
|
||||
setSkinUVs(rightArmBox, 40, 16, this.slim ? 3 : 4, 12, 4);
|
||||
});
|
||||
|
||||
const rightArm2Box = new BoxGeometry();
|
||||
const rightArm2Mesh = new Mesh(rightArm2Box, layer2MaterialBiased);
|
||||
rightArm2Mesh.renderOrder = 1;
|
||||
this.modelListeners.push(() => {
|
||||
rightArm2Mesh.scale.x = this.slim ? 3.375 : 4.5;
|
||||
rightArm2Mesh.scale.y = 13.5;
|
||||
rightArm2Mesh.scale.x = this.slim ? 3.5 : 4.5;
|
||||
rightArm2Mesh.scale.y = 12.5;
|
||||
rightArm2Mesh.scale.z = 4.5;
|
||||
if (this.slim) {
|
||||
setVertices(rightArm2Box,
|
||||
toSkinVertices(44, 32, 47, 36),
|
||||
toSkinVertices(47, 32, 50, 36),
|
||||
toSkinVertices(40, 36, 44, 48),
|
||||
toSkinVertices(44, 36, 47, 48),
|
||||
toSkinVertices(47, 36, 51, 48),
|
||||
toSkinVertices(51, 36, 54, 48)
|
||||
);
|
||||
} else {
|
||||
setVertices(rightArm2Box,
|
||||
toSkinVertices(44, 32, 48, 36),
|
||||
toSkinVertices(48, 32, 52, 36),
|
||||
toSkinVertices(40, 36, 44, 48),
|
||||
toSkinVertices(44, 36, 48, 48),
|
||||
toSkinVertices(48, 36, 52, 48),
|
||||
toSkinVertices(52, 36, 56, 48)
|
||||
);
|
||||
}
|
||||
rightArm2Box.uvsNeedUpdate = true;
|
||||
rightArm2Box.elementsNeedUpdate = true;
|
||||
setSkinUVs(rightArm2Box, 40, 32, this.slim ? 3 : 4, 12, 4);
|
||||
});
|
||||
|
||||
const rightArmPivot = new Group();
|
||||
rightArmPivot.add(rightArmMesh, rightArm2Mesh);
|
||||
this.modelListeners.push(() => {
|
||||
rightArmPivot.position.x = this.slim ? -.5 : -1;
|
||||
});
|
||||
rightArmPivot.position.y = -4;
|
||||
|
||||
this.rightArm = new BodyPart(rightArmMesh, rightArm2Mesh);
|
||||
this.rightArm.name = "rightArm";
|
||||
this.rightArm.add(rightArmPivot);
|
||||
this.rightArm.position.y = -6;
|
||||
this.modelListeners.push(() => {
|
||||
this.rightArm.position.x = this.slim ? -5.5 : -6;
|
||||
});
|
||||
this.rightArm.position.x = -5;
|
||||
this.rightArm.position.y = -2;
|
||||
this.add(this.rightArm);
|
||||
|
||||
// Left Arm
|
||||
const leftArmBox = new BoxGeometry();
|
||||
const leftArmMesh = new Mesh(leftArmBox, layer1Material);
|
||||
const leftArmMesh = new Mesh(leftArmBox, layer1MaterialBiased);
|
||||
this.modelListeners.push(() => {
|
||||
leftArmMesh.scale.x = this.slim ? 3 : 4;
|
||||
leftArmMesh.scale.y = 12;
|
||||
leftArmMesh.scale.z = 4;
|
||||
if (this.slim) {
|
||||
setVertices(leftArmBox,
|
||||
toSkinVertices(36, 48, 39, 52),
|
||||
toSkinVertices(39, 48, 42, 52),
|
||||
toSkinVertices(32, 52, 36, 64),
|
||||
toSkinVertices(36, 52, 39, 64),
|
||||
toSkinVertices(39, 52, 43, 64),
|
||||
toSkinVertices(43, 52, 46, 64)
|
||||
);
|
||||
} else {
|
||||
setVertices(leftArmBox,
|
||||
toSkinVertices(36, 48, 40, 52),
|
||||
toSkinVertices(40, 48, 44, 52),
|
||||
toSkinVertices(32, 52, 36, 64),
|
||||
toSkinVertices(36, 52, 40, 64),
|
||||
toSkinVertices(40, 52, 44, 64),
|
||||
toSkinVertices(44, 52, 48, 64)
|
||||
);
|
||||
}
|
||||
leftArmBox.uvsNeedUpdate = true;
|
||||
leftArmBox.elementsNeedUpdate = true;
|
||||
setSkinUVs(leftArmBox, 32, 48, this.slim ? 3 : 4, 12, 4);
|
||||
});
|
||||
|
||||
const leftArm2Box = new BoxGeometry();
|
||||
const leftArm2Mesh = new Mesh(leftArm2Box, layer2MaterialBiased);
|
||||
leftArm2Mesh.renderOrder = 1;
|
||||
this.modelListeners.push(() => {
|
||||
leftArm2Mesh.scale.x = this.slim ? 3.375 : 4.5;
|
||||
leftArm2Mesh.scale.y = 13.5;
|
||||
leftArm2Mesh.scale.x = this.slim ? 3.5 : 4.5;
|
||||
leftArm2Mesh.scale.y = 12.5;
|
||||
leftArm2Mesh.scale.z = 4.5;
|
||||
if (this.slim) {
|
||||
setVertices(leftArm2Box,
|
||||
toSkinVertices(52, 48, 55, 52),
|
||||
toSkinVertices(55, 48, 58, 52),
|
||||
toSkinVertices(48, 52, 52, 64),
|
||||
toSkinVertices(52, 52, 55, 64),
|
||||
toSkinVertices(55, 52, 59, 64),
|
||||
toSkinVertices(59, 52, 62, 64)
|
||||
);
|
||||
} else {
|
||||
setVertices(leftArm2Box,
|
||||
toSkinVertices(52, 48, 56, 52),
|
||||
toSkinVertices(56, 48, 60, 52),
|
||||
toSkinVertices(48, 52, 52, 64),
|
||||
toSkinVertices(52, 52, 56, 64),
|
||||
toSkinVertices(56, 52, 60, 64),
|
||||
toSkinVertices(60, 52, 64, 64)
|
||||
);
|
||||
}
|
||||
leftArm2Box.uvsNeedUpdate = true;
|
||||
leftArm2Box.elementsNeedUpdate = true;
|
||||
setSkinUVs(leftArm2Box, 48, 48, this.slim ? 3 : 4, 12, 4);
|
||||
});
|
||||
|
||||
const leftArmPivot = new Group();
|
||||
leftArmPivot.add(leftArmMesh, leftArm2Mesh);
|
||||
this.modelListeners.push(() => {
|
||||
leftArmPivot.position.x = this.slim ? 0.5 : 1;
|
||||
});
|
||||
leftArmPivot.position.y = -4;
|
||||
|
||||
this.leftArm = new BodyPart(leftArmMesh, leftArm2Mesh);
|
||||
this.leftArm.name = "leftArm";
|
||||
this.leftArm.add(leftArmPivot);
|
||||
this.leftArm.position.y = -6;
|
||||
this.modelListeners.push(() => {
|
||||
this.leftArm.position.x = this.slim ? 5.5 : 6;
|
||||
});
|
||||
this.leftArm.position.x = 5;
|
||||
this.leftArm.position.y = -2;
|
||||
this.add(this.leftArm);
|
||||
|
||||
// Right Leg
|
||||
const rightLegBox = new BoxGeometry(4, 12, 4);
|
||||
setVertices(rightLegBox,
|
||||
toSkinVertices(4, 16, 8, 20),
|
||||
toSkinVertices(8, 16, 12, 20),
|
||||
toSkinVertices(0, 20, 4, 32),
|
||||
toSkinVertices(4, 20, 8, 32),
|
||||
toSkinVertices(8, 20, 12, 32),
|
||||
toSkinVertices(12, 20, 16, 32)
|
||||
);
|
||||
setSkinUVs(rightLegBox, 0, 16, 4, 12, 4);
|
||||
const rightLegMesh = new Mesh(rightLegBox, layer1MaterialBiased);
|
||||
|
||||
const rightLeg2Box = new BoxGeometry(4.5, 13.5, 4.5);
|
||||
setVertices(rightLeg2Box,
|
||||
toSkinVertices(4, 32, 8, 36),
|
||||
toSkinVertices(8, 32, 12, 36),
|
||||
toSkinVertices(0, 36, 4, 48),
|
||||
toSkinVertices(4, 36, 8, 48),
|
||||
toSkinVertices(8, 36, 12, 48),
|
||||
toSkinVertices(12, 36, 16, 48)
|
||||
);
|
||||
const rightLeg2Box = new BoxGeometry(4.5, 12.5, 4.5);
|
||||
setSkinUVs(rightLeg2Box, 0, 32, 4, 12, 4);
|
||||
const rightLeg2Mesh = new Mesh(rightLeg2Box, layer2MaterialBiased);
|
||||
rightLeg2Mesh.renderOrder = 1;
|
||||
|
||||
const rightLegPivot = new Group();
|
||||
rightLegPivot.add(rightLegMesh, rightLeg2Mesh);
|
||||
|
@ -320,33 +200,19 @@ export class SkinObject extends Group {
|
|||
this.rightLeg = new BodyPart(rightLegMesh, rightLeg2Mesh);
|
||||
this.rightLeg.name = "rightLeg";
|
||||
this.rightLeg.add(rightLegPivot);
|
||||
this.rightLeg.position.y = -16;
|
||||
this.rightLeg.position.x = -2;
|
||||
this.rightLeg.position.x = -1.9;
|
||||
this.rightLeg.position.y = -12;
|
||||
this.rightLeg.position.z = -.1;
|
||||
this.add(this.rightLeg);
|
||||
|
||||
// Left Leg
|
||||
const leftLegBox = new BoxGeometry(4, 12, 4);
|
||||
setVertices(leftLegBox,
|
||||
toSkinVertices(20, 48, 24, 52),
|
||||
toSkinVertices(24, 48, 28, 52),
|
||||
toSkinVertices(16, 52, 20, 64),
|
||||
toSkinVertices(20, 52, 24, 64),
|
||||
toSkinVertices(24, 52, 28, 64),
|
||||
toSkinVertices(28, 52, 32, 64)
|
||||
);
|
||||
setSkinUVs(leftLegBox, 16, 48, 4, 12, 4);
|
||||
const leftLegMesh = new Mesh(leftLegBox, layer1MaterialBiased);
|
||||
|
||||
const leftLeg2Box = new BoxGeometry(4.5, 13.5, 4.5);
|
||||
setVertices(leftLeg2Box,
|
||||
toSkinVertices(4, 48, 8, 52),
|
||||
toSkinVertices(8, 48, 12, 52),
|
||||
toSkinVertices(0, 52, 4, 64),
|
||||
toSkinVertices(4, 52, 8, 64),
|
||||
toSkinVertices(8, 52, 12, 64),
|
||||
toSkinVertices(12, 52, 16, 64)
|
||||
);
|
||||
const leftLeg2Box = new BoxGeometry(4.5, 12.5, 4.5);
|
||||
setSkinUVs(leftLeg2Box, 0, 48, 4, 12, 4);
|
||||
const leftLeg2Mesh = new Mesh(leftLeg2Box, layer2MaterialBiased);
|
||||
leftLeg2Mesh.renderOrder = 1;
|
||||
|
||||
const leftLegPivot = new Group();
|
||||
leftLegPivot.add(leftLegMesh, leftLeg2Mesh);
|
||||
|
@ -355,8 +221,9 @@ export class SkinObject extends Group {
|
|||
this.leftLeg = new BodyPart(leftLegMesh, leftLeg2Mesh);
|
||||
this.leftLeg.name = "leftLeg";
|
||||
this.leftLeg.add(leftLegPivot);
|
||||
this.leftLeg.position.y = -16;
|
||||
this.leftLeg.position.x = 2;
|
||||
this.leftLeg.position.x = 1.9;
|
||||
this.leftLeg.position.y = -12;
|
||||
this.leftLeg.position.z = -.1;
|
||||
this.add(this.leftLeg);
|
||||
|
||||
this.modelType = "default";
|
||||
|
@ -391,48 +258,159 @@ export class CapeObject extends Group {
|
|||
constructor(texture: Texture) {
|
||||
super();
|
||||
|
||||
const capeMaterial = new MeshBasicMaterial({
|
||||
const capeMaterial = new MeshStandardMaterial({
|
||||
map: texture,
|
||||
side: DoubleSide,
|
||||
transparent: true,
|
||||
alphaTest: 1e-5
|
||||
});
|
||||
|
||||
// back = outside
|
||||
// front = inside
|
||||
// +z (front) - inside of cape
|
||||
// -z (back) - outside of cape
|
||||
const capeBox = new BoxGeometry(10, 16, 1);
|
||||
setVertices(capeBox,
|
||||
toCapeVertices(11, 1, 1, 0),
|
||||
toCapeVertices(21, 1, 11, 0),
|
||||
toCapeVertices(11, 1, 12, 17),
|
||||
toCapeVertices(12, 1, 22, 17),
|
||||
toCapeVertices(0, 1, 1, 17),
|
||||
toCapeVertices(1, 1, 11, 17)
|
||||
);
|
||||
setCapeUVs(capeBox, 0, 0, 10, 16, 1);
|
||||
this.cape = new Mesh(capeBox, capeMaterial);
|
||||
this.cape.position.y = -8;
|
||||
this.cape.position.z = -0.5;
|
||||
this.cape.position.z = .5;
|
||||
this.add(this.cape);
|
||||
}
|
||||
}
|
||||
|
||||
export class ElytraObject extends Group {
|
||||
|
||||
readonly leftWing: Group;
|
||||
readonly rightWing: Group;
|
||||
|
||||
constructor(texture: Texture) {
|
||||
super();
|
||||
|
||||
const elytraMaterial = new MeshStandardMaterial({
|
||||
map: texture,
|
||||
side: DoubleSide,
|
||||
transparent: true,
|
||||
alphaTest: 1e-5
|
||||
});
|
||||
|
||||
const leftWingBox = new BoxGeometry(12, 22, 4);
|
||||
setCapeUVs(leftWingBox, 22, 0, 10, 20, 2);
|
||||
const leftWingMesh = new Mesh(leftWingBox, elytraMaterial);
|
||||
leftWingMesh.position.x = -5;
|
||||
leftWingMesh.position.y = -10;
|
||||
leftWingMesh.position.z = -1;
|
||||
this.leftWing = new Group();
|
||||
this.leftWing.add(leftWingMesh);
|
||||
this.add(this.leftWing);
|
||||
|
||||
const rightWingBox = new BoxGeometry(12, 22, 4);
|
||||
setCapeUVs(rightWingBox, 22, 0, 10, 20, 2);
|
||||
const rightWingMesh = new Mesh(rightWingBox, elytraMaterial);
|
||||
rightWingMesh.scale.x = -1;
|
||||
rightWingMesh.position.x = 5;
|
||||
rightWingMesh.position.y = -10;
|
||||
rightWingMesh.position.z = -1;
|
||||
this.rightWing = new Group();
|
||||
this.rightWing.add(rightWingMesh);
|
||||
this.add(this.rightWing);
|
||||
|
||||
this.leftWing.position.x = 5;
|
||||
this.leftWing.rotation.x = .2617994;
|
||||
this.leftWing.rotation.y = .01; // to avoid z-fighting
|
||||
this.leftWing.rotation.z = .2617994;
|
||||
this.updateRightWing();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the position & rotation of left wing,
|
||||
* and apply them to the right wing.
|
||||
*/
|
||||
updateRightWing(): void {
|
||||
this.rightWing.position.x = -this.leftWing.position.x;
|
||||
this.rightWing.position.y = this.leftWing.position.y;
|
||||
this.rightWing.rotation.x = this.leftWing.rotation.x;
|
||||
this.rightWing.rotation.y = -this.leftWing.rotation.y;
|
||||
this.rightWing.rotation.z = -this.leftWing.rotation.z;
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
readonly skin: SkinObject;
|
||||
readonly cape: CapeObject;
|
||||
readonly elytra: ElytraObject;
|
||||
readonly ears: EarsObject;
|
||||
|
||||
constructor(skinTexture: Texture, capeTexture: Texture) {
|
||||
constructor(skinTexture: Texture, capeTexture: Texture, earsTexture: 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.position.y = -4;
|
||||
this.cape.rotation.x = 10.8 * Math.PI / 180;
|
||||
this.cape.rotation.y = Math.PI;
|
||||
this.add(this.cape);
|
||||
|
||||
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 {
|
||||
if (this.cape.visible) {
|
||||
return "cape";
|
||||
} else if (this.elytra.visible) {
|
||||
return "elytra";
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
set backEquipment(value: BackEquipment | null) {
|
||||
this.cape.visible = value === "cape";
|
||||
this.elytra.visible = value === "elytra";
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ export function createOrbitControls(skinViewer: SkinViewer): OrbitControls {
|
|||
|
||||
// default configuration
|
||||
control.enablePan = false;
|
||||
control.target = new Vector3(0, -12, 0);
|
||||
control.target = new Vector3(0, 0, 0);
|
||||
control.minDistance = 10;
|
||||
control.maxDistance = 256;
|
||||
control.update();
|
||||
|
|
385
src/viewer.ts
385
src/viewer.ts
|
@ -1,21 +1,65 @@
|
|||
import { applyMixins, CapeContainer, ModelType, SkinContainer, RemoteImage, TextureSource } from "skinview-utils";
|
||||
import { NearestFilter, PerspectiveCamera, Scene, Texture, Vector2, WebGLRenderer } from "three";
|
||||
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 { RootAnimation } from "./animation.js";
|
||||
import { PlayerObject } from "./model.js";
|
||||
import { BackEquipment, PlayerObject } from "./model.js";
|
||||
|
||||
export type LoadOptions = {
|
||||
export interface LoadOptions {
|
||||
/**
|
||||
* Whether to make the object visible after the texture is loaded. Default is true.
|
||||
*/
|
||||
makeVisible?: boolean;
|
||||
}
|
||||
|
||||
export type SkinViewerOptions = {
|
||||
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".
|
||||
* If makeVisible is set to false, this option will have no effect.
|
||||
*/
|
||||
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;
|
||||
skin?: RemoteImage | TextureSource;
|
||||
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.
|
||||
|
@ -38,30 +82,57 @@ export type 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;
|
||||
}
|
||||
|
||||
function toMakeVisible(options?: LoadOptions): boolean {
|
||||
if (options && options.makeVisible === false) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
class SkinViewer {
|
||||
export class SkinViewer {
|
||||
readonly canvas: HTMLCanvasElement;
|
||||
readonly scene: Scene;
|
||||
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;
|
||||
|
@ -77,12 +148,17 @@ 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();
|
||||
|
||||
// Use smaller fov to avoid distortion
|
||||
this.camera = new PerspectiveCamera(40);
|
||||
this.camera.position.y = -12;
|
||||
this.camera.position.z = 60;
|
||||
this.camera = new PerspectiveCamera();
|
||||
this.camera.add(this.cameraLight);
|
||||
this.scene.add(this.camera);
|
||||
this.scene.add(this.globalLight);
|
||||
|
||||
this.renderer = new WebGLRenderer({
|
||||
canvas: this.canvas,
|
||||
|
@ -92,62 +168,212 @@ class SkinViewer {
|
|||
});
|
||||
this.renderer.setPixelRatio(window.devicePixelRatio);
|
||||
|
||||
this.playerObject = new PlayerObject(this.skinTexture, this.capeTexture);
|
||||
this.playerObject = new PlayerObject(this.skinTexture, this.capeTexture, this.earsTexture);
|
||||
this.playerObject.name = "player";
|
||||
this.playerObject.skin.visible = false;
|
||||
this.playerObject.cape.visible = false;
|
||||
this.scene.add(this.playerObject);
|
||||
this.playerWrapper = new Group();
|
||||
this.playerWrapper.add(this.playerObject);
|
||||
this.scene.add(this.playerWrapper);
|
||||
|
||||
if (options.skin !== undefined) {
|
||||
this.loadSkin(options.skin);
|
||||
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);
|
||||
}
|
||||
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 {
|
||||
window.requestAnimationFrame(() => this.draw());
|
||||
this.animationID = 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
|
||||
): S extends TextureSource ? void : Promise<void>;
|
||||
|
||||
loadSkin(
|
||||
source: TextureSource | RemoteImage | null,
|
||||
options: SkinLoadOptions = {}
|
||||
): void | Promise<void> {
|
||||
if (source === null) {
|
||||
this.resetSkin();
|
||||
|
||||
} else if (isTextureSource(source)) {
|
||||
loadSkinToCanvas(this.skinCanvas, source);
|
||||
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;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
protected skinLoaded(model: ModelType, options?: LoadOptions): void {
|
||||
this.skinTexture.needsUpdate = true;
|
||||
this.playerObject.skin.modelType = model;
|
||||
if (toMakeVisible(options)) {
|
||||
this.playerObject.skin.visible = true;
|
||||
}
|
||||
}
|
||||
|
||||
protected capeLoaded(options?: LoadOptions): void {
|
||||
this.capeTexture.needsUpdate = true;
|
||||
if (toMakeVisible(options)) {
|
||||
this.playerObject.cape.visible = true;
|
||||
}
|
||||
}
|
||||
|
||||
protected resetSkin(): void {
|
||||
resetSkin(): void {
|
||||
this.playerObject.skin.visible = false;
|
||||
}
|
||||
|
||||
protected resetCape(): void {
|
||||
this.playerObject.cape.visible = false;
|
||||
loadCape(empty: null): void;
|
||||
loadCape<S extends TextureSource | RemoteImage>(
|
||||
source: S,
|
||||
options?: CapeLoadOptions
|
||||
): S extends TextureSource ? void : Promise<void>;
|
||||
|
||||
loadCape(
|
||||
source: TextureSource | RemoteImage | null,
|
||||
options: CapeLoadOptions = {}
|
||||
): 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));
|
||||
}
|
||||
}
|
||||
|
||||
resetCape(): void {
|
||||
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();
|
||||
window.requestAnimationFrame(() => this.draw());
|
||||
this.animationID = window.requestAnimationFrame(() => this.draw());
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -166,9 +392,22 @@ 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 {
|
||||
|
@ -185,10 +424,13 @@ class SkinViewer {
|
|||
}
|
||||
|
||||
set renderPaused(value: boolean) {
|
||||
const toResume = !this.disposed && !value && this._renderPaused;
|
||||
this._renderPaused = value;
|
||||
if (toResume) {
|
||||
window.requestAnimationFrame(() => this.draw());
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -207,7 +449,52 @@ 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();
|
||||
}
|
||||
}
|
||||
interface SkinViewer extends SkinContainer<LoadOptions>, CapeContainer<LoadOptions> { }
|
||||
applyMixins(SkinViewer, [SkinContainer, CapeContainer]);
|
||||
export { SkinViewer };
|
||||
|
|
Loading…
Reference in New Issue