support ears (thanks @james090500), close #85

This commit is contained in:
Haowei Wen 2022-01-09 05:00:15 +08:00
parent 47f34b856c
commit 3758035bf8
8 changed files with 236 additions and 13 deletions

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)
@ -104,5 +105,34 @@ skinViewer.globalLight.intensity = 0.1;
Setting `globalLight.intensity` to `1.0` and `cameraLight.intensity` to `0.0` Setting `globalLight.intensity` to `1.0` and `cameraLight.intensity` to `0.0`
will completely disable shadows. 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
`npm run build` `npm run 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

View File

@ -215,6 +215,7 @@
<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" class="hidden" accept="image/*"> <input id="skin_url_upload" type="file" class="hidden" accept="image/*">
<button id="skin_url_unset" type="button" class="control hidden">Unset</button> <button id="skin_url_unset" type="button" class="control hidden">Unset</button>
@ -250,6 +251,32 @@
</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"> <div class="control-section">
<h1>Panorama</h1> <h1>Panorama</h1>
<div class="control"> <div class="control">
@ -329,7 +356,8 @@
input.setCustomValidity(""); input.setCustomValidity("");
} else { } else {
skinViewer.loadSkin(url, { skinViewer.loadSkin(url, {
model: document.getElementById("skin_model").value model: document.getElementById("skin_model").value,
ears: document.getElementById("ears_source").value === "current_skin"
}) })
.then(() => input.setCustomValidity("")) .then(() => input.setCustomValidity(""))
.catch(e => { .catch(e => {
@ -356,6 +384,46 @@
} }
} }
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() { function reloadPanorama() {
const input = document.getElementById("panorama_url"); const input = document.getElementById("panorama_url");
const url = obtainTextureUrl("panorama_url"); const url = obtainTextureUrl("panorama_url");
@ -442,11 +510,14 @@
}; };
initializeUploadButton("skin_url", reloadSkin); initializeUploadButton("skin_url", reloadSkin);
initializeUploadButton("cape_url", reloadCape); initializeUploadButton("cape_url", reloadCape);
initializeUploadButton("ears_url", reloadEars);
initializeUploadButton("panorama_url", reloadPanorama); 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()); 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"]')) {
@ -502,6 +573,7 @@
} }
reloadSkin(); reloadSkin();
reloadCape(); reloadCape();
reloadEars(true);
reloadPanorama(); reloadPanorama();
} }

14
package-lock.json generated
View File

@ -10,7 +10,7 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/three": "^0.136.1", "@types/three": "^0.136.1",
"skinview-utils": "^0.6.2", "skinview-utils": "^0.7.0",
"three": "^0.136.0" "three": "^0.136.0"
}, },
"devDependencies": { "devDependencies": {
@ -3647,9 +3647,9 @@
"dev": true "dev": true
}, },
"node_modules/skinview-utils": { "node_modules/skinview-utils": {
"version": "0.6.2", "version": "0.7.0",
"resolved": "https://registry.npmjs.org/skinview-utils/-/skinview-utils-0.6.2.tgz", "resolved": "https://registry.npmjs.org/skinview-utils/-/skinview-utils-0.7.0.tgz",
"integrity": "sha512-UdjWwXCVZobtG+dc7ilvMRbtXYSqPJtWKPFdgWc44Gs4aoOmZML2lErr77h7uussXo9zrcR+fPizGshGpdETvQ==", "integrity": "sha512-ecbbUp0AuvZX5fCIOOwbNPxr/JIbrAGhCcdLcww0Ov9PbvAbeYjNDSIu1ebCJBe4CWc6ZYYn8MFNp68DDta0iQ==",
"dependencies": { "dependencies": {
"@types/offscreencanvas": "^2019.6.4" "@types/offscreencanvas": "^2019.6.4"
} }
@ -7038,9 +7038,9 @@
"dev": true "dev": true
}, },
"skinview-utils": { "skinview-utils": {
"version": "0.6.2", "version": "0.7.0",
"resolved": "https://registry.npmjs.org/skinview-utils/-/skinview-utils-0.6.2.tgz", "resolved": "https://registry.npmjs.org/skinview-utils/-/skinview-utils-0.7.0.tgz",
"integrity": "sha512-UdjWwXCVZobtG+dc7ilvMRbtXYSqPJtWKPFdgWc44Gs4aoOmZML2lErr77h7uussXo9zrcR+fPizGshGpdETvQ==", "integrity": "sha512-ecbbUp0AuvZX5fCIOOwbNPxr/JIbrAGhCcdLcww0Ov9PbvAbeYjNDSIu1ebCJBe4CWc6ZYYn8MFNp68DDta0iQ==",
"requires": { "requires": {
"@types/offscreencanvas": "^2019.6.4" "@types/offscreencanvas": "^2019.6.4"
} }

View File

@ -39,7 +39,7 @@
], ],
"dependencies": { "dependencies": {
"@types/three": "^0.136.1", "@types/three": "^0.136.1",
"skinview-utils": "^0.6.2", "skinview-utils": "^0.7.0",
"three": "^0.136.0" "three": "^0.136.0"
}, },
"devDependencies": { "devDependencies": {

View File

@ -332,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 {
@ -339,8 +366,9 @@ 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);
@ -362,6 +390,13 @@ export class PlayerObject extends Group {
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

@ -1,4 +1,4 @@
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 { Color, ColorRepresentation, PointLight, EquirectangularReflectionMapping, Group, NearestFilter, PerspectiveCamera, Scene, Texture, Vector2, WebGLRenderer, AmbientLight, Mapping } 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";
@ -15,6 +15,14 @@ export interface SkinLoadOptions extends LoadOptions {
* The model type of skin. Default is "auto-detect". * The model type of skin. Default is "auto-detect".
*/ */
model?: ModelType | "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 {
@ -25,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;
@ -32,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.
@ -92,8 +120,10 @@ export class SkinViewer {
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 backgroundTexture: Texture | null = null;
private _disposed: boolean = false; private _disposed: boolean = false;
@ -118,6 +148,11 @@ 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();
this.camera = new PerspectiveCamera(); this.camera = new PerspectiveCamera();
@ -133,7 +168,7 @@ 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;
@ -143,12 +178,18 @@ export class SkinViewer {
if (options.skin !== undefined) { if (options.skin !== undefined) {
this.loadSkin(options.skin, { this.loadSkin(options.skin, {
model: options.model 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;
} }
@ -217,6 +258,14 @@ export class SkinViewer {
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, options)); return loadImage(source).then(image => this.loadSkin(image, options));
} }
@ -238,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));
} }
@ -253,6 +305,40 @@ export class SkinViewer {
this.playerObject.backEquipment = null; 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>( loadPanorama<S extends TextureSource | RemoteImage>(
source: S source: S
): S extends TextureSource ? void : Promise<void> { ): S extends TextureSource ? void : Promise<void> {