Compare commits

..

35 Commits

Author SHA1 Message Date
mochaaP cb5cfae82c
Merge pull request #115 from bs-community/dependabot/npm_and_yarn/shell-quote-1.7.3
Bump shell-quote from 1.7.2 to 1.7.3
2022-07-04 19:38:25 +08:00
dependabot[bot] 79971a79be
Bump shell-quote from 1.7.2 to 1.7.3
Bumps [shell-quote](https://github.com/substack/node-shell-quote) from 1.7.2 to 1.7.3.
- [Release notes](https://github.com/substack/node-shell-quote/releases)
- [Changelog](https://github.com/substack/node-shell-quote/blob/master/CHANGELOG.md)
- [Commits](https://github.com/substack/node-shell-quote/compare/v1.7.2...1.7.3)

---
updated-dependencies:
- dependency-name: shell-quote
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-07-04 11:29:10 +00:00
Haowei Wen be9651b9d6 Merge branch 'quickfix-2.2.1' 2022-02-03 23:15:14 +08:00
Haowei Wen 4afe4f0876 2.2.1 2022-02-03 23:12:59 +08:00
Haowei Wen c26471b104 fix return type of CompositeAnimation.add 2022-02-03 23:12:14 +08:00
Haowei Wen 7f7c32d16a polish demo 2022-01-09 05:02:33 +08:00
Haowei Wen 3758035bf8 support ears (thanks @james090500), close #85 2022-01-09 05:00:15 +08:00
Haowei Wen 47f34b856c add SkinLoadOptions 2022-01-09 02:57:23 +08:00
Haowei Wen 9f975c3354 happy new year 2022 2022-01-08 21:37:44 +08:00
Haowei Wen 403ea13c1a 2.2.0 2022-01-08 06:06:56 +08:00
Haowei Wen 03a6a03449 add SkinViewer.loadBackground to set plain background image 2022-01-08 06:03:43 +08:00
Haowei Wen 35fe0ecc8c polish demo 2022-01-08 05:42:15 +08:00
Haowei Wen e989c6c8cf update dependencies 2022-01-08 05:36:58 +08:00
Haowei Wen 5fbf497002 add shadows, close #99 2022-01-08 04:58:38 +08:00
Haowei Wen b16194be6a add SkinViewer.zoom property, close #102 2022-01-08 04:07:52 +08:00
Haowei Wen 4c066091b0 change player pivot to its center 2022-01-08 03:18:23 +08:00
Haowei Wen 9b0bdc46eb change head pivot to baseline, close #97 2022-01-08 01:18:48 +08:00
Haowei Wen 819fdd5c3c
Merge pull request #100 from LeaPhant/add-idle-animation
Add idle animation
2021-11-01 00:42:03 +08:00
LeaPhant af85724b2d Add idle animation to example 2021-10-10 14:45:34 +02:00
LeaPhant 5da30d2157 Add idle animation 2021-10-10 14:36:54 +02:00
Haowei Wen 4355d13c0b
2.1.0 2021-09-24 02:55:57 +08:00
Haowei Wen f22604cdb4
examples: add panorama image (thanks Sunny_GYL) 2021-09-24 02:51:10 +08:00
Haowei Wen 7e9f229820
update dependencies 2021-09-23 14:35:33 +08:00
Haowei Wen f273ee8b56
add 'fov' option 2021-09-23 12:02:41 +08:00
Haowei Wen 7114f93c7c
update readme 2021-09-23 10:13:59 +08:00
Haowei Wen 5c2bcacf7c
add 'panorama' to SkinViewerOptions 2021-09-23 00:47:05 +08:00
Haowei Wen 0fac73356c
examples: adjust page layout 2021-09-23 00:40:03 +08:00
Haowei Wen cdfde336d9
examples: improve local file loading performance 2021-09-22 23:47:40 +08:00
Haowei Wen 02c520e421
add panorama support, close #86 2021-09-22 17:30:41 +08:00
Haowei Wen ff510a9ad0
add SkinViewer.background property 2021-09-19 13:54:27 +08:00
Haowei Wen 7e3c0025e3
handle webgl context loss/restore 2021-09-19 13:17:52 +08:00
Haowei Wen f60abb91da
update dependencies 2021-09-19 12:31:33 +08:00
Haowei Wen db01d41b4c
2.0.3 2021-09-07 17:30:22 +08:00
Haowei Wen 0dc8e42c30
update dependencies 2021-09-07 17:29:37 +08:00
Haowei Wen 43ccf00163
update skinview-utils to 0.6.1, closing #93 2021-09-07 17:15:59 +08:00
13 changed files with 1204 additions and 905 deletions

View File

@ -1,7 +1,7 @@
MIT License MIT License
Copyright (c) 2014-2018 Kent Rasmussen Copyright (c) 2014-2018 Kent Rasmussen
Copyright (c) 2017-2021 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 Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@ -12,6 +12,7 @@ Three.js powered Minecraft skin viewer.
* 1.8 Skins * 1.8 Skins
* HD Skins * HD Skins
* Capes * Capes
* Ears
* Elytras * Elytras
* Slim Arms * Slim Arms
* Automatic model detection (Slim / Default) * Automatic model detection (Slim / Default)
@ -38,12 +39,27 @@ Three.js powered Minecraft skin viewer.
// Load a cape // Load a cape
skinViewer.loadCape("img/cape.png"); skinViewer.loadCape("img/cape.png");
// Load a elytra (from a cape texture) // Load an elytra (from a cape texture)
skinViewer.loadCape("img/cape.png", { backEquipment: "elytra" }); skinViewer.loadCape("img/cape.png", { backEquipment: "elytra" });
// Unload(hide) the cape / elytra // Unload(hide) the cape / elytra
skinViewer.loadCape(null); 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! // Control objects with your mouse!
let control = skinview3d.createOrbitControls(skinViewer); let control = skinview3d.createOrbitControls(skinViewer);
control.enableRotate = true; control.enableRotate = true;
@ -74,15 +90,48 @@ Three.js powered Minecraft skin viewer.
skinview3d supports FXAA (fast approximate anti-aliasing). skinview3d supports FXAA (fast approximate anti-aliasing).
To enable it, you need to replace `SkinViewer` with `FXAASkinViewer`. To enable it, you need to replace `SkinViewer` with `FXAASkinViewer`.
You must use an **opaque** background when FXAA is enabled, Note that FXAA is incompatible with transparent backgrounds.
because FXAA is incompatible with transparent backgrounds. So when FXAA is enabled, the default background color will be white instead of transparent.
By default, the background color is white. ## Lighting
To use a different color: By default, there are two lights on the scene. One is an ambient light, and the other is a point light from the camera.
```javascript
let skinViewer = new skinview3d.FXAASkinViewer(...); To change the light intensity:
// Set the background color to blue ```js
skinViewer.renderer.setClearColor(0x5a76f3); 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" });
``` ```
# Build # Build

BIN
examples/img/deadmau5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

BIN
examples/img/ears.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 B

BIN
examples/img/panorama.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 MiB

View File

@ -22,17 +22,11 @@
h1, h1,
h2 { h2 {
margin-bottom: 0; margin: 5px 0 0 0;
}
input[type="number"] {
max-width: 60px;
} }
input[type="text"] { input[type="text"] {
box-sizing: border-box; box-sizing: border-box;
max-width: 250px;
width: calc(100% - 100px);
} }
.control { .control {
@ -101,6 +95,10 @@
margin-top: 0; margin-top: 0;
padding-left: 20px; padding-left: 20px;
} }
.hidden {
display: none;
}
</style> </style>
</head> </head>
@ -113,29 +111,42 @@
<button id="reset_all" type="button" class="control">Reset All</button> <button id="reset_all" type="button" class="control">Reset All</button>
<div class="control-section"> <div class="control-section">
<h1>Canvas Size</h1> <h1>Viewport</h1>
<label class="control">Width: <input id="canvas_width" type="number" value="300"></label> <div>
<label class="control">Height: <input id="canvas_height" type="number" value="300"></label> <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>
<div class="control-section"> <div class="control-section">
<h1>Animation</h1> <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> <button id="animation_pause_resume" type="button" class="control">Pause / Resume</button>
<div> <div>
<h2>Rotate</h2> <h2>Rotate</h2>
<label class="control"><input id="rotate_animation" type="checkbox"> Enable</label> <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>
<div> <div>
<h2>Walk / Run / Fly</h2> <h2>Walk / Run / Fly</h2>
<div class="control"> <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_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_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_run" name="primary_animation" value="run"> Run</label>
<label><input type="radio" id="primary_animation_fly" name="primary_animation" value="fly"> Fly</label> <label><input type="radio" id="primary_animation_fly" name="primary_animation" value="fly"> Fly</label>
</div> </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>
</div> </div>
@ -193,10 +204,10 @@
</div> </div>
<div class="control-section"> <div class="control-section">
<h1>Textures</h1> <h1>Skin</h1>
<div> <div>
<div class="control"> <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"> <datalist id="default_skins">
<option value="img/1_8_texturemap_redux.png"> <option value="img/1_8_texturemap_redux.png">
<option value="img/hacksore.png"> <option value="img/hacksore.png">
@ -204,11 +215,15 @@
<option value="img/hatsune_miku.png"> <option value="img/hatsune_miku.png">
<option value="img/ironman_hd.png"> <option value="img/ironman_hd.png">
<option value="img/sethbling.png"> <option value="img/sethbling.png">
<option value="img/deadmau5.png">
</datalist> </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" <button type="button" class="control"
onclick="document.getElementById('skin_url_upload').click();">Browse...</button> onclick="document.getElementById('skin_url_upload').click();">Browse...</button>
</div> </div>
</div>
<div>
<label class="control">Model: <label class="control">Model:
<select id="skin_model"> <select id="skin_model">
<option value="auto-detect" selected>Auto detect</option> <option value="auto-detect" selected>Auto detect</option>
@ -217,20 +232,64 @@
</select> </select>
</label> </label>
</div> </div>
<div> </div>
<div class="control-section">
<h1>Cape</h1>
<div class="control"> <div class="control">
<label>Cape URL: <input id="cape_url" type="text" value="" placeholder="none" list="default_capes"></label> <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"> <datalist id="default_capes">
<option value=""> <option value="">
<option value="img/mojang_cape.png"> <option value="img/mojang_cape.png">
<option value="img/legacy_cape.png"> <option value="img/legacy_cape.png">
<option value="img/hd_cape.png"> <option value="img/hd_cape.png">
</datalist> </datalist>
<input id="cape_url_upload" type="file" accept="image/*" style="display: none;"> <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" <button type="button" class="control"
onclick="document.getElementById('cape_url_upload').click();">Browse...</button> onclick="document.getElementById('cape_url_upload').click();">Browse...</button>
</div> </div>
</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>
<div class="control-section"> <div class="control-section">
@ -260,6 +319,7 @@
const skinParts = ["head", "body", "rightArm", "leftArm", "rightLeg", "leftLeg"]; const skinParts = ["head", "body", "rightArm", "leftArm", "rightLeg", "leftLeg"];
const skinLayers = ["innerLayer", "outerLayer"]; const skinLayers = ["innerLayer", "outerLayer"];
const availableAnimations = { const availableAnimations = {
idle: skinview3d.IdleAnimation,
walk: skinview3d.WalkingAnimation, walk: skinview3d.WalkingAnimation,
run: skinview3d.RunningAnimation, run: skinview3d.RunningAnimation,
fly: skinview3d.FlyingAnimation fly: skinview3d.FlyingAnimation
@ -270,14 +330,35 @@
let rotateAnimation; let rotateAnimation;
let primaryAnimation; 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() { function reloadSkin() {
const input = document.getElementById("skin_url"); const input = document.getElementById("skin_url");
const url = input.value; const url = obtainTextureUrl("skin_url");
if (url === "") { if (url === "") {
skinViewer.loadSkin(null); skinViewer.loadSkin(null);
input.setCustomValidity(""); input.setCustomValidity("");
} else { } 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("")) .then(() => input.setCustomValidity(""))
.catch(e => { .catch(e => {
input.setCustomValidity("Image can't be loaded."); input.setCustomValidity("Image can't be loaded.");
@ -288,7 +369,7 @@
function reloadCape() { function reloadCape() {
const input = document.getElementById("cape_url"); const input = document.getElementById("cape_url");
const url = input.value; const url = obtainTextureUrl("cape_url");
if (url === "") { if (url === "") {
skinViewer.loadCape(null); skinViewer.loadCape(null);
input.setCustomValidity(""); input.setCustomValidity("");
@ -303,9 +384,69 @@
} }
} }
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() { function initializeControls() {
document.getElementById("canvas_width").addEventListener("change", e => skinViewer.width = e.target.value); 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("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("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("animation_pause_resume").addEventListener("click", () => skinViewer.animations.paused = !skinViewer.animations.paused);
document.getElementById("rotate_animation").addEventListener("change", e => { document.getElementById("rotate_animation").addEventListener("change", e => {
@ -348,31 +489,36 @@
.addEventListener("change", e => skinViewer.playerObject.skin[part][layer].visible = e.target.checked); .addEventListener("change", e => skinViewer.playerObject.skin[part][layer].visible = e.target.checked);
} }
} }
const skinReader = new FileReader();
skinReader.addEventListener("load", e => { const initializeUploadButton = (id, callback) => {
document.getElementById("skin_url").value = skinReader.result; const urlInput = document.getElementById(id);
reloadSkin(); const fileInput = document.getElementById(id + "_upload");
}); const unsetButton = document.getElementById(id + "_unset");
document.getElementById("skin_url_upload").addEventListener("change", e => { const unsetAction = () => {
const file = e.target.files[0]; urlInput.readOnly = false;
if (file !== undefined) { urlInput.value = "";
skinReader.readAsDataURL(file); fileInput.value = fileInput.defaultValue;
} callback();
}); };
const capeReader = new FileReader(); fileInput.addEventListener("change", e => callback());
capeReader.addEventListener("load", e => { urlInput.addEventListener("keydown", e => {
document.getElementById("cape_url").value = capeReader.result; if (e.key === "Backspace" && urlInput.readOnly) {
reloadCape(); unsetAction();
});
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_url").addEventListener("change", () => reloadSkin());
document.getElementById("skin_model").addEventListener("change", () => reloadSkin()); document.getElementById("skin_model").addEventListener("change", () => reloadSkin());
document.getElementById("cape_url").addEventListener("change", () => reloadCape()); 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"]')) { for (const el of document.querySelectorAll('input[type="radio"][name="back_equipment"]')) {
el.addEventListener("change", e => { el.addEventListener("change", e => {
@ -396,13 +542,16 @@
skinViewer = new skinview3d.FXAASkinViewer({ skinViewer = new skinview3d.FXAASkinViewer({
canvas: document.getElementById("skin_container") canvas: document.getElementById("skin_container")
}); });
skinViewer.renderer.setClearColor(0x5a76f3);
orbitControl = skinview3d.createOrbitControls(skinViewer); orbitControl = skinview3d.createOrbitControls(skinViewer);
rotateAnimation = null; rotateAnimation = null;
primaryAnimation = null; primaryAnimation = null;
skinViewer.width = document.getElementById("canvas_width").value; skinViewer.width = document.getElementById("canvas_width").value;
skinViewer.height = document.getElementById("canvas_height").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; skinViewer.animations.speed = document.getElementById("global_animation_speed").value;
if (document.getElementById("rotate_animation").checked) { if (document.getElementById("rotate_animation").checked) {
rotateAnimation = skinViewer.animations.add(skinview3d.RotatingAnimation); rotateAnimation = skinViewer.animations.add(skinview3d.RotatingAnimation);
@ -424,6 +573,8 @@
} }
reloadSkin(); reloadSkin();
reloadCape(); reloadCape();
reloadEars(true);
reloadPanorama();
} }
initializeControls(); initializeControls();

1372
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -83,9 +83,9 @@ class AnimationWrapper implements SubAnimationHandle, IAnimation {
export class CompositeAnimation implements 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); const handle = new AnimationWrapper(animation);
handle.remove = (): void => { handle.remove = (): void => {
this.handles.delete(handle); 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) => { export const WalkingAnimation: Animation = (player, time) => {
const skin = player.skin; const skin = player.skin;

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import { inferModelType, isTextureSource, loadCapeToCanvas, loadImage, loadSkinToCanvas, ModelType, RemoteImage, TextureSource } from "skinview-utils"; import { inferModelType, isTextureSource, loadCapeToCanvas, loadEarsToCanvas, loadEarsToCanvasFromSkin, loadImage, loadSkinToCanvas, ModelType, RemoteImage, TextureSource } from "skinview-utils";
import { NearestFilter, PerspectiveCamera, Scene, Texture, Vector2, WebGLRenderer } from "three"; import { Color, ColorRepresentation, PointLight, EquirectangularReflectionMapping, Group, NearestFilter, PerspectiveCamera, Scene, Texture, Vector2, WebGLRenderer, AmbientLight, Mapping } from "three";
import { RootAnimation } from "./animation.js"; import { RootAnimation } from "./animation.js";
import { BackEquipment, PlayerObject } from "./model.js"; import { BackEquipment, PlayerObject } from "./model.js";
@ -10,6 +10,21 @@ export interface LoadOptions {
makeVisible?: boolean; 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 { export interface CapeLoadOptions extends LoadOptions {
/** /**
* The equipment (cape or elytra) to show, defaults to "cape". * The equipment (cape or elytra) to show, defaults to "cape".
@ -18,6 +33,15 @@ export interface CapeLoadOptions extends LoadOptions {
backEquipment?: BackEquipment; 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 { export interface SkinViewerOptions {
width?: number; width?: number;
height?: number; height?: number;
@ -25,6 +49,17 @@ export interface SkinViewerOptions {
model?: ModelType | "auto-detect"; model?: ModelType | "auto-detect";
cape?: RemoteImage | TextureSource; 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. * Whether the canvas contains an alpha buffer. Default is true.
* This option can be turned off if you use an opaque background. * This option can be turned off if you use an opaque background.
@ -47,6 +82,29 @@ export interface SkinViewerOptions {
* If this option is true, rendering and animation loops will not start. * If this option is true, rendering and animation loops will not start.
*/ */
renderPaused?: boolean; 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 { export class SkinViewer {
@ -55,15 +113,26 @@ export class SkinViewer {
readonly camera: PerspectiveCamera; readonly camera: PerspectiveCamera;
readonly renderer: WebGLRenderer; readonly renderer: WebGLRenderer;
readonly playerObject: PlayerObject; readonly playerObject: PlayerObject;
readonly playerWrapper: Group;
readonly animations: RootAnimation = new RootAnimation(); 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 skinCanvas: HTMLCanvasElement;
readonly capeCanvas: HTMLCanvasElement; readonly capeCanvas: HTMLCanvasElement;
readonly earsCanvas: HTMLCanvasElement;
private readonly skinTexture: Texture; private readonly skinTexture: Texture;
private readonly capeTexture: Texture; private readonly capeTexture: Texture;
private readonly earsTexture: Texture;
private backgroundTexture: Texture | null = null;
private _disposed: boolean = false; private _disposed: boolean = false;
private _renderPaused: boolean = false; private _renderPaused: boolean = false;
private _zoom: number;
private animationID: number | null;
private onContextLost: (event: Event) => void;
private onContextRestored: () => void;
constructor(options: SkinViewerOptions = {}) { constructor(options: SkinViewerOptions = {}) {
this.canvas = options.canvas === undefined ? document.createElement("canvas") : options.canvas; this.canvas = options.canvas === undefined ? document.createElement("canvas") : options.canvas;
@ -79,12 +148,17 @@ export class SkinViewer {
this.capeTexture.magFilter = NearestFilter; this.capeTexture.magFilter = NearestFilter;
this.capeTexture.minFilter = 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.scene = new Scene();
// Use smaller fov to avoid distortion this.camera = new PerspectiveCamera();
this.camera = new PerspectiveCamera(40); this.camera.add(this.cameraLight);
this.camera.position.y = -8; this.scene.add(this.camera);
this.camera.position.z = 60; this.scene.add(this.globalLight);
this.renderer = new WebGLRenderer({ this.renderer = new WebGLRenderer({
canvas: this.canvas, canvas: this.canvas,
@ -94,56 +168,106 @@ export class SkinViewer {
}); });
this.renderer.setPixelRatio(window.devicePixelRatio); 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.name = "player";
this.playerObject.skin.visible = false; this.playerObject.skin.visible = false;
this.playerObject.cape.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) { if (options.skin !== undefined) {
this.loadSkin(options.skin, options.model); this.loadSkin(options.skin, {
model: options.model,
ears: options.ears === "current-skin"
});
} }
if (options.cape !== undefined) { if (options.cape !== undefined) {
this.loadCape(options.cape); 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) { if (options.width !== undefined) {
this.width = options.width; this.width = options.width;
} }
if (options.height !== undefined) { if (options.height !== undefined) {
this.height = options.height; 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) { if (options.renderPaused === true) {
this._renderPaused = true; this._renderPaused = true;
this.animationID = null;
} else { } 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(empty: null): void;
loadSkin<S extends TextureSource | RemoteImage>( loadSkin<S extends TextureSource | RemoteImage>(
source: S, source: S,
model?: ModelType | "auto-detect", options?: SkinLoadOptions
options?: LoadOptions
): S extends TextureSource ? void : Promise<void>; ): S extends TextureSource ? void : Promise<void>;
loadSkin( loadSkin(
source: TextureSource | RemoteImage | null, source: TextureSource | RemoteImage | null,
model: ModelType | "auto-detect" = "auto-detect", options: SkinLoadOptions = {}
options: LoadOptions = {}
): void | Promise<void> { ): void | Promise<void> {
if (source === null) { if (source === null) {
this.resetSkin(); this.resetSkin();
} else if (isTextureSource(source)) { } else if (isTextureSource(source)) {
loadSkinToCanvas(this.skinCanvas, source); loadSkinToCanvas(this.skinCanvas, source);
const actualModel = model === "auto-detect" ? inferModelType(this.skinCanvas) : model;
this.skinTexture.needsUpdate = true; this.skinTexture.needsUpdate = true;
this.playerObject.skin.modelType = actualModel;
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) { if (options.makeVisible !== false) {
this.playerObject.skin.visible = true; 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 { } else {
return loadImage(source).then(image => this.loadSkin(image, model, options)); return loadImage(source).then(image => this.loadSkin(image, options));
} }
} }
@ -163,12 +287,15 @@ export class SkinViewer {
): void | Promise<void> { ): void | Promise<void> {
if (source === null) { if (source === null) {
this.resetCape(); this.resetCape();
} else if (isTextureSource(source)) { } else if (isTextureSource(source)) {
loadCapeToCanvas(this.capeCanvas, source); loadCapeToCanvas(this.capeCanvas, source);
this.capeTexture.needsUpdate = true; this.capeTexture.needsUpdate = true;
if (options.makeVisible !== false) { if (options.makeVisible !== false) {
this.playerObject.backEquipment = options.backEquipment === undefined ? "cape" : options.backEquipment; this.playerObject.backEquipment = options.backEquipment === undefined ? "cape" : options.backEquipment;
} }
} else { } else {
return loadImage(source).then(image => this.loadCape(image, options)); return loadImage(source).then(image => this.loadCape(image, options));
} }
@ -178,13 +305,75 @@ export class SkinViewer {
this.playerObject.backEquipment = null; this.playerObject.backEquipment = null;
} }
private draw(): void { loadEars(empty: null): void;
if (this.disposed || this._renderPaused) { loadEars<S extends TextureSource | RemoteImage>(
return; 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 {
this.animations.runAnimationLoop(this.playerObject); this.animations.runAnimationLoop(this.playerObject);
this.render(); this.render();
window.requestAnimationFrame(() => this.draw()); this.animationID = window.requestAnimationFrame(() => this.draw());
} }
/** /**
@ -203,9 +392,22 @@ export class SkinViewer {
dispose(): void { dispose(): void {
this._disposed = true; 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.renderer.dispose();
this.skinTexture.dispose(); this.skinTexture.dispose();
this.capeTexture.dispose(); this.capeTexture.dispose();
if (this.backgroundTexture !== null) {
this.backgroundTexture.dispose();
this.backgroundTexture = null;
}
} }
get disposed(): boolean { get disposed(): boolean {
@ -222,10 +424,13 @@ export class SkinViewer {
} }
set renderPaused(value: boolean) { set renderPaused(value: boolean) {
const toResume = !this.disposed && !value && this._renderPaused;
this._renderPaused = value; 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());
} }
} }
@ -244,4 +449,52 @@ export class SkinViewer {
set height(newHeight: number) { set height(newHeight: number) {
this.setSize(this.width, newHeight); 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();
}
} }