Compare commits

...

73 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
Haowei Wen 15bf945590
2.0.2 2021-08-25 02:18:19 +08:00
Haowei Wen 2174a0ee12
Force opaque background when using FXAA 2021-08-25 02:07:24 +08:00
Haowei Wen 7fabf579eb
update dependencies 2021-08-25 01:09:32 +08:00
Haowei Wen 825c2311ee
update dependencies 2021-06-26 02:33:44 +08:00
Pig Fang 22412938d7
tweak ESLint 2021-06-12 16:07:21 +08:00
Pig Fang 45b6737a44
update Rollup 2021-06-12 16:01:49 +08:00
Pig Fang 8b92012349
update TypeScript 2021-06-12 15:58:46 +08:00
Haowei Wen 5dca37f0b4
2.0.1 2021-05-18 22:47:15 +08:00
Haowei Wen 3a808713e8
update three.js to r128 2021-05-18 22:43:52 +08:00
Haowei Wen 3f7ff52930
update devDependencies 2021-05-18 22:30:00 +08:00
Haowei Wen 32cc449cc9
add 'model' param to SkinViewerOptions (#84) 2021-05-18 22:17:10 +08:00
Haowei Wen 3de8b09730
2.0.0 2021-02-18 04:39:47 +08:00
Haowei Wen 4c4acf9501
change license year to 2021 2021-02-18 04:09:35 +08:00
Haowei Wen 00414b6fff
update dev dependencies 2021-02-18 04:06:34 +08:00
Haowei Wen 4dcca770ca
upgrade three.js to r125 2021-02-18 01:12:15 +08:00
Haowei Wen f5c7692c8d
integrate viewer-mixins.ts into viewer.ts 2020-12-25 02:56:59 +08:00
Haowei Wen 4cdd21661b
update dependencies 2020-11-01 14:57:12 +08:00
Haowei Wen 968f803caa
2.0.0-beta.1 2020-10-11 12:07:08 +08:00
Haowei Wen 84d0180dca
update dependencies 2020-10-11 12:05:30 +08:00
Haowei Wen 6c0bc016ed
Merge pull request #77 from bs-community/elytra
Add elytra support
2020-10-11 11:10:34 +08:00
Haowei Wen cc90328881
fix z-fighting between body and arms when flying 2020-10-10 19:26:25 +08:00
Haowei Wen 3c39d912e7
Add FlyingAnimation 2020-10-10 17:32:54 +08:00
Haowei Wen 61aa9753af
Add PlayerObject.backEquipment property 2020-10-10 10:26:58 +08:00
yushijinhun 61572b6824
Add elytra model 2020-10-10 09:05:59 +08:00
Haowei Wen 52e809138d
remove renderOrder 2020-10-10 09:05:21 +08:00
Haowei Wen e7c9399875
refactor setUVs 2020-10-09 22:24:02 +08:00
Haowei Wen 400eecb9e9
refactor uvs 2020-10-09 16:45:08 +08:00
Haowei Wen d3f3cb422c
use inside of cape as its front face 2020-10-09 16:12:13 +08:00
Haowei Wen 7df054c7ec
2.0.0-alpha.11 2020-10-05 17:59:16 +08:00
Haowei Wen 4df5bc8c72
update dependencies (three=>0.121.1) 2020-10-05 17:58:29 +08:00
Haowei Wen d6a41acc0b
update skinview-utils to 0.5.9 2020-10-04 23:30:01 +08:00
Haowei Wen bd9bc29684
fix pivot of arms & legs 2020-10-04 00:43:21 +08:00
Haowei Wen a79d5b4ba9
move camera up by 4m 2020-10-03 23:09:55 +08:00
Haowei Wen a579f7bf26
move player up by 4m
so we have the same pivot as it's in real Minecraft
2020-10-03 22:59:59 +08:00
Haowei Wen eff80c1f02
fix skin layer2 has wrong size 2020-10-03 20:42:22 +08:00
Haowei Wen a5a70d13c7
change LoadOptions & SkinViewerOptions to interface 2020-09-16 22:39:42 +08:00
Haowei Wen 798a111ade
fix memory leak
see also https://github.com/mrdoob/three.js/issues/20346
2020-09-16 22:34:22 +08:00
Haowei Wen c8ddc277eb
examples: dispose OrbitControls 2020-09-14 16:48:51 +08:00
15 changed files with 5778 additions and 999 deletions

View File

@ -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

View File

@ -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

View File

@ -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

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,
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();

View File

@ -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();

5478
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}

View File

@ -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();
};

View File

@ -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();
}
}

View File

@ -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";
}
}

View File

@ -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();

View File

@ -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 };