diff --git a/README.md b/README.md
index c5b0a65e..4bfc2616 100644
--- a/README.md
+++ b/README.md
@@ -46,9 +46,20 @@ Three.js powered Minecraft skin viewer.
// Load an elytra (from a cape texture)
skinViewer.loadCape("img/cape.png", { backEquipment: "elytra" });
- // Unload(hide) the cape / elytra
+ // Unload (hide) the cape / elytra
skinViewer.loadCape(null);
+ // Load armors
+ skinViewer.loadArmors(
+ "img/turtle_layer_1.png", // helmet (main)
+ "img/diamond_layer_1.png", // chestplate (main)
+ "img/gold_layer_2.png", // leggings (legs)
+ "img/iron_layer_1.png" // boots (main)
+ );
+
+ // Unload (hide) the armors
+ skinViewer.loadArmors(null);
+
// Set the background color
skinViewer.background = 0x5a76f3;
@@ -93,6 +104,84 @@ skinViewer.globalLight.intensity = 3;
Setting `globalLight.intensity` to `3.0` and `cameraLight.intensity` to `0.0`
will completely disable shadows.
+
+## Armors
+
+skinview3d supports loading armor textures for the player. Armor textures can be specified as an object with the following optional properties:
+
+- `helmet`, `chestplate`, `leggings`, `boots`: textures for the corresponding pieces.
+- `main`: a texture that will be used for helmet, chestplate, and boots if their specific textures are not provided.
+- `legs`: a texture that will be used for leggings if `leggings` is not provided.
+
+Each texture can be a `RemoteImage` (URL string), a `TextureSource` (HTML image element or canvas), or `null` to hide that piece.
+
+### Loading Armors
+
+You can load armors using the `loadArmors` method. It accepts an object conforming to the structure above, or `null` to hide all armor.
+
+Examples:
+
+```js
+// Hide all armor
+skinViewer.loadArmors(null);
+
+// Equip only helmet, chestplate, and boots using the same "main" texture
+skinViewer.loadArmors({ main: "img/diamond_layer_1.png" });
+
+// Equip only leggings using a "legs" texture
+skinViewer.loadArmors({ legs: "img/diamond_layer_2.png" });
+
+// Equip both main and legs textures
+skinViewer.loadArmors({
+ main: "img/diamond_layer_1.png",
+ legs: "img/diamond_layer_2.png"
+});
+
+// Equip all four pieces individually (helmet, chestplate, leggings, boots)
+skinViewer.loadArmors({
+ helmet: "img/turtle_layer_1.png",
+ chestplate: "img/diamond_layer_1.png",
+ leggings: "img/gold_layer_2.png",
+ boots: "img/iron_layer_1.png"
+});
+
+// Mix specific pieces with fallback textures
+skinViewer.loadArmors({
+ helmet: "img/turtle_layer_1.png",
+ main: "img/diamond_layer_1.png", // used for chestplate and boots
+ leggings: "img/gold_layer_2.png"
+});
+```
+
+### Using Armors in the Constructor
+
+You can specify armors directly in the `SkinViewer` options via the `armors` property. It accepts the same object format as `loadArmors`.
+
+```js
+new skinview3d.SkinViewer({
+ skin: "img/skin.png",
+ armors: {
+ helmet: "img/turtle_layer_1.png",
+ chestplate: "img/diamond_layer_1.png",
+ leggings: "img/gold_layer_2.png",
+ boots: "img/iron_layer_1.png"
+ }
+});
+```
+
+### Loading Armors Together with a Skin
+
+The `loadSkin` method also accepts an `armors` option, which behaves identically to the constructor option.
+
+```js
+skinViewer.loadSkin("img/skin.png", {
+ armors: {
+ main: "img/diamond_layer_1.png",
+ legs: "img/diamond_layer_2.png"
+ }
+});
+```
+
## 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/public/img/ears.png))
diff --git a/examples/index.html b/examples/index.html
index 46ff0d32..aaacf9c8 100644
--- a/examples/index.html
+++ b/examples/index.html
@@ -288,6 +288,80 @@
Ears
diff --git a/examples/main.ts b/examples/main.ts
index c52cbad4..02750493 100644
--- a/examples/main.ts
+++ b/examples/main.ts
@@ -17,7 +17,6 @@ const availableAnimations = {
};
let skinViewer: skinview3d.SkinViewer;
-
function obtainTextureUrl(id: string): string {
const urlInput = document.getElementById(id) as HTMLInputElement;
const fileInput = document.getElementById(`${id}_upload`) as HTMLInputElement;
@@ -42,6 +41,7 @@ function obtainTextureUrl(id: string): string {
}
function reloadSkin(): void {
+ window.skinViewer = skinViewer; //Set window object to debug in console.
const input = document.getElementById("skin_url") as HTMLInputElement;
const url = obtainTextureUrl("skin_url");
if (url === "") {
@@ -50,7 +50,6 @@ function reloadSkin(): void {
} else {
const skinModel = document.getElementById("skin_model") as HTMLSelectElement;
const earsSource = document.getElementById("ears_source") as HTMLSelectElement;
-
skinViewer
.loadSkin(url, {
model: skinModel?.value as ModelType,
@@ -84,6 +83,41 @@ function reloadCape(): void {
}
}
+function reloadArmors(): void {
+ const input1 = document.getElementById("helmet_url") as HTMLInputElement;
+ const url1 = obtainTextureUrl("helmet_url");
+ const input2 = document.getElementById("chestplate_url") as HTMLInputElement;
+ const url2 = obtainTextureUrl("chestplate_url");
+ const input3 = document.getElementById("leggings_url") as HTMLInputElement;
+ const url3 = obtainTextureUrl("leggings_url");
+ const input4 = document.getElementById("boots_url") as HTMLInputElement;
+ const url4 = obtainTextureUrl("boots_url");
+ const textures = {
+ helmet: url1,
+ chestplate: url2,
+ leggings: url3,
+ boots: url4,
+ };
+ const inputs = [input1.input2, input3, input4];
+ Object.keys(textures).forEach((key, index) => {
+ if (textures[key] === "") {
+ inputs[index]?.setCustomValidity("");
+ textures[key] = null;
+ }
+ });
+ skinViewer
+ .loadArmors(textures)
+ ?.then(() => {
+ inputs.forEach(input => {
+ input?.setCustomValidity("");
+ });
+ })
+ ?.catch(e => {
+ input4?.setCustomValidity("One of the 4 images can't be loaded.");
+ console.error(e);
+ });
+}
+
function reloadEars(skipSkinReload = false): void {
const earsSource = document.getElementById("ears_source") as HTMLSelectElement;
const sourceType = earsSource?.value;
@@ -398,12 +432,20 @@ function initializeControls(): void {
initializeUploadButton("skin_url", reloadSkin);
initializeUploadButton("cape_url", reloadCape);
+ initializeUploadButton("helmet_url", reloadArmors);
+ initializeUploadButton("chestplate_url", reloadArmors);
+ initializeUploadButton("leggings_url", reloadArmors);
+ initializeUploadButton("boots_url", reloadArmors);
initializeUploadButton("ears_url", reloadEars);
initializeUploadButton("panorama_url", reloadPanorama);
const skinUrl = document.getElementById("skin_url") as HTMLInputElement;
const skinModel = document.getElementById("skin_model") as HTMLSelectElement;
const capeUrl = document.getElementById("cape_url") as HTMLInputElement;
+ const helmetUrl = document.getElementById("helmet_url") as HTMLInputElement;
+ const chestplateUrl = document.getElementById("chestplate_url") as HTMLInputElement;
+ const leggingsUrl = document.getElementById("leggings_url") as HTMLInputElement;
+ const bootsUrl = document.getElementById("boots_url") as HTMLInputElement;
const earsSource = document.getElementById("ears_source") as HTMLSelectElement;
const earsUrl = document.getElementById("ears_url") as HTMLInputElement;
const panoramaUrl = document.getElementById("panorama_url") as HTMLInputElement;
@@ -411,6 +453,10 @@ function initializeControls(): void {
skinUrl?.addEventListener("change", reloadSkin);
skinModel?.addEventListener("change", reloadSkin);
capeUrl?.addEventListener("change", reloadCape);
+ helmetUrl?.addEventListener("change", reloadArmors);
+ chestplateUrl?.addEventListener("change", reloadArmors);
+ leggingsUrl?.addEventListener("change", reloadArmors);
+ bootsUrl?.addEventListener("change", reloadArmors);
earsSource?.addEventListener("change", () => reloadEars());
earsUrl?.addEventListener("change", () => reloadEars());
panoramaUrl?.addEventListener("change", reloadPanorama);
@@ -511,6 +557,7 @@ function initializeViewer(): void {
reloadSkin();
reloadCape();
+ reloadArmors();
reloadEars(true);
reloadPanorama();
reloadNameTag();
diff --git a/examples/public/img/chainmail_layer_1.png b/examples/public/img/chainmail_layer_1.png
new file mode 100644
index 00000000..cd9ed23e
Binary files /dev/null and b/examples/public/img/chainmail_layer_1.png differ
diff --git a/examples/public/img/chainmail_layer_2.png b/examples/public/img/chainmail_layer_2.png
new file mode 100644
index 00000000..0d47920d
Binary files /dev/null and b/examples/public/img/chainmail_layer_2.png differ
diff --git a/examples/public/img/copper_layer_1.png b/examples/public/img/copper_layer_1.png
new file mode 100644
index 00000000..70272f3c
Binary files /dev/null and b/examples/public/img/copper_layer_1.png differ
diff --git a/examples/public/img/copper_layer_2.png b/examples/public/img/copper_layer_2.png
new file mode 100644
index 00000000..3cd59c1e
Binary files /dev/null and b/examples/public/img/copper_layer_2.png differ
diff --git a/examples/public/img/diamond_layer_1.png b/examples/public/img/diamond_layer_1.png
new file mode 100644
index 00000000..750b61d9
Binary files /dev/null and b/examples/public/img/diamond_layer_1.png differ
diff --git a/examples/public/img/diamond_layer_2.png b/examples/public/img/diamond_layer_2.png
new file mode 100644
index 00000000..a9d69e8b
Binary files /dev/null and b/examples/public/img/diamond_layer_2.png differ
diff --git a/examples/public/img/gold_layer_1.png b/examples/public/img/gold_layer_1.png
new file mode 100644
index 00000000..bbd30114
Binary files /dev/null and b/examples/public/img/gold_layer_1.png differ
diff --git a/examples/public/img/gold_layer_2.png b/examples/public/img/gold_layer_2.png
new file mode 100644
index 00000000..0d1032e9
Binary files /dev/null and b/examples/public/img/gold_layer_2.png differ
diff --git a/examples/public/img/iron_layer_1.png b/examples/public/img/iron_layer_1.png
new file mode 100644
index 00000000..9c54e7e2
Binary files /dev/null and b/examples/public/img/iron_layer_1.png differ
diff --git a/examples/public/img/iron_layer_2.png b/examples/public/img/iron_layer_2.png
new file mode 100644
index 00000000..f45fb536
Binary files /dev/null and b/examples/public/img/iron_layer_2.png differ
diff --git a/examples/public/img/netherite_layer_1.png b/examples/public/img/netherite_layer_1.png
new file mode 100644
index 00000000..11c961e8
Binary files /dev/null and b/examples/public/img/netherite_layer_1.png differ
diff --git a/examples/public/img/netherite_layer_2.png b/examples/public/img/netherite_layer_2.png
new file mode 100644
index 00000000..526c7be1
Binary files /dev/null and b/examples/public/img/netherite_layer_2.png differ
diff --git a/examples/public/img/turtle_layer_1.png b/examples/public/img/turtle_layer_1.png
new file mode 100644
index 00000000..1ad88308
Binary files /dev/null and b/examples/public/img/turtle_layer_1.png differ
diff --git a/src/model.ts b/src/model.ts
index c103fbd3..851424bc 100644
--- a/src/model.ts
+++ b/src/model.ts
@@ -57,6 +57,43 @@ function setUVs(
uvAttr.set(new Float32Array(newUVData));
uvAttr.needsUpdate = true;
}
+function mirrorUVs(geometry: BoxGeometry) {
+ const uvAttr = geometry.attributes.uv;
+ const uvArray = uvAttr.array; // Float32Array
+ const floatsPerFace = 8; // 4 vertices * 2
+ const faceCount = 6;
+ const mirroredFaces = [];
+ for (let faceIdx = 0; faceIdx < faceCount; faceIdx++) {
+ const start = faceIdx * floatsPerFace;
+ const uValues = [];
+ for (let i = 0; i < 4; i++) {
+ uValues.push(uvArray[start + i * 2]);
+ }
+ const minU = Math.min(...uValues);
+ const maxU = Math.max(...uValues);
+ const faceUVs = [];
+ for (let i = 0; i < 4; i++) {
+ const oldU = uvArray[start + i * 2];
+ const oldV = uvArray[start + i * 2 + 1];
+ const newU = minU + maxU - oldU;
+ faceUVs.push(newU, oldV);
+ }
+ mirroredFaces.push(faceUVs);
+ }
+
+ [mirroredFaces[0], mirroredFaces[1]] = [mirroredFaces[1], mirroredFaces[0]];
+
+ for (let faceIdx = 0; faceIdx < faceCount; faceIdx++) {
+ const start = faceIdx * floatsPerFace;
+ const faceData = mirroredFaces[faceIdx];
+ for (let i = 0; i < 4; i++) {
+ uvArray[start + i * 2] = faceData[i * 2];
+ uvArray[start + i * 2 + 1] = faceData[i * 2 + 1];
+ }
+ }
+
+ uvAttr.needsUpdate = true;
+}
function setSkinUVs(box: BoxGeometry, u: number, v: number, width: number, height: number, depth: number): void {
setUVs(box, u, v, width, height, depth, 64, 64);
@@ -467,6 +504,131 @@ export class EarsObject extends Group {
}
}
+export class ArmorsObject extends Group {
+ readonly headArmor: Mesh;
+ readonly leftArmArmor: Mesh;
+ readonly rightArmArmor: Mesh;
+ readonly bodyArmor: Mesh;
+ readonly bodyArmor2: Mesh;
+ readonly leftLegArmor: Mesh;
+ readonly leftLegArmor2: Mesh;
+ readonly rightLegArmor: Mesh;
+ readonly rightLegArmor2: Mesh;
+ private armorHelmetMaterial: MeshStandardMaterial;
+ private armorChestplateMaterial: MeshStandardMaterial;
+ private armorLeggingsMaterial: MeshStandardMaterial;
+ private armorBootsMaterial: MeshStandardMaterial;
+ constructor() {
+ super();
+ this.armorHelmetMaterial = new MeshStandardMaterial({
+ side: DoubleSide,
+ transparent: true,
+ alphaTest: 1e-5,
+ });
+ this.armorChestplateMaterial = new MeshStandardMaterial({
+ side: DoubleSide,
+ transparent: true,
+ alphaTest: 1e-5,
+ });
+ this.armorLeggingsMaterial = new MeshStandardMaterial({
+ side: DoubleSide,
+ transparent: true,
+ alphaTest: 1e-5,
+ });
+ this.armorBootsMaterial = new MeshStandardMaterial({
+ side: DoubleSide,
+ transparent: true,
+ alphaTest: 1e-5,
+ });
+ const headArmorBox = new BoxGeometry(10, 10, 10);
+ setSkinUVs(headArmorBox, 0, 0, 8, 8, 8);
+ this.headArmor = new Mesh(headArmorBox, this.armorHelmetMaterial);
+ this.headArmor.name = "headArmor";
+ this.headArmor.position.y = 4;
+
+ const bodyArmorBox = new BoxGeometry(10, 14, 6);
+ setSkinUVs(bodyArmorBox, 16, 16, 8, 12, 4);
+ this.bodyArmor = new Mesh(bodyArmorBox, this.armorChestplateMaterial);
+ const bodyArmor2Box = new BoxGeometry(9.3, 13.3, 5.3);
+ setSkinUVs(bodyArmor2Box, 16, 16, 8, 12, 4);
+ this.bodyArmor2 = new Mesh(bodyArmor2Box, this.armorLeggingsMaterial);
+ this.bodyArmor.name = "bodyArmor";
+ this.bodyArmor2.name = "bodyArmor2";
+
+ const rightArmArmorBox = new BoxGeometry(6.5, 14.5, 6.5);
+ this.rightArmArmor = new Mesh(rightArmArmorBox, this.armorChestplateMaterial);
+ setSkinUVs(rightArmArmorBox, 40, 16, 4, 12, 4);
+ this.rightArmArmor.name = "rightArmArmor";
+ this.rightArmArmor.position.x = -1;
+ this.rightArmArmor.position.y = -4;
+
+ const leftArmArmorBox = new BoxGeometry(6.5, 14.5, 6.5);
+ this.leftArmArmor = new Mesh(leftArmArmorBox, this.armorChestplateMaterial);
+ setSkinUVs(leftArmArmorBox, 40, 16, 4, 12, 4);
+ mirrorUVs(leftArmArmorBox);
+ this.leftArmArmor.name = "leftArmArmor";
+ this.leftArmArmor.position.x = 1;
+ this.leftArmArmor.position.y = -4;
+
+ const rightLegArmorBox = new BoxGeometry(5, 13, 5);
+ setSkinUVs(rightLegArmorBox, 0, 16, 4, 12, 4);
+ this.rightLegArmor = new Mesh(rightLegArmorBox, this.armorLeggingsMaterial);
+
+ const rightLegArmor2Box = new BoxGeometry(5.5, 14.5, 6.5);
+ setSkinUVs(rightLegArmor2Box, 0, 16, 4, 12, 4);
+ this.rightLegArmor2 = new Mesh(rightLegArmor2Box, this.armorBootsMaterial);
+
+ this.rightLegArmor.name = "rightLegArmor";
+ this.rightLegArmor2.name = "rightLegArmor2";
+ this.rightLegArmor.position.y = -6;
+ this.rightLegArmor2.position.y = -6;
+
+ const leftLegArmorBox = new BoxGeometry(5, 13, 5);
+ setSkinUVs(leftLegArmorBox, 0, 16, 4, 12, 4);
+ this.leftLegArmor = new Mesh(leftLegArmorBox, this.armorLeggingsMaterial);
+ mirrorUVs(leftLegArmorBox);
+
+ const leftLegArmor2Box = new BoxGeometry(5.5, 14.5, 6.5);
+ setSkinUVs(leftLegArmor2Box, 0, 16, 4, 12, 4);
+ this.leftLegArmor2 = new Mesh(leftLegArmor2Box, this.armorBootsMaterial);
+ mirrorUVs(leftLegArmor2Box);
+ this.leftLegArmor.name = "leftLegArmor";
+ this.leftLegArmor2.name = "leftLegArmor2";
+ this.leftLegArmor.position.y = -6;
+ this.leftLegArmor2.position.y = -6;
+
+ this.add(
+ this.headArmor,
+ this.bodyArmor,
+ this.bodyArmor2,
+ this.rightArmArmor,
+ this.leftArmArmor,
+ this.rightLegArmor,
+ this.rightLegArmor2,
+ this.leftLegArmor,
+ this.leftLegArmor2
+ );
+ }
+ public setArmorMaps(map1: Texture | null, map2: Texture | null, map3: Texture | null, map4: Texture | null): void {
+ this.armorHelmetMaterial.map = map1;
+ this.armorHelmetMaterial.needsUpdate = true;
+ this.armorChestplateMaterial.map = map2;
+ this.armorChestplateMaterial.needsUpdate = true;
+ this.armorLeggingsMaterial.map = map3;
+ this.armorLeggingsMaterial.needsUpdate = true;
+ this.armorBootsMaterial.map = map4;
+ this.armorBootsMaterial.needsUpdate = true;
+ }
+ get maps(): (Texture | null)[] {
+ return [
+ this.armorHelmetMaterial.map,
+ this.armorChestplateMaterial.map,
+ this.armorLeggingsMaterial.map,
+ this.armorBootsMaterial.map,
+ ];
+ }
+}
+
export type BackEquipment = "cape" | "elytra";
const CapeDefaultAngle = (10.8 * Math.PI) / 180;
@@ -476,6 +638,7 @@ export class PlayerObject extends Group {
readonly cape: CapeObject;
readonly elytra: ElytraObject;
readonly ears: EarsObject;
+ readonly armors: ArmorsObject;
constructor() {
super();
@@ -506,6 +669,27 @@ export class PlayerObject extends Group {
this.ears.position.z = 2 / 3;
this.ears.visible = false;
this.skin.head.add(this.ears);
+
+ this.armors = new ArmorsObject();
+ this.armors.name = "armors";
+ this.armors.headArmor.visible = false;
+ this.armors.bodyArmor.visible = false;
+ this.armors.bodyArmor2.visible = false;
+ this.armors.leftArmArmor.visible = false;
+ this.armors.rightArmArmor.visible = false;
+ this.armors.rightLegArmor.visible = false;
+ this.armors.rightLegArmor2.visible = false;
+ this.armors.leftLegArmor.visible = false;
+ this.armors.leftLegArmor2.visible = false;
+ this.skin.head.add(this.armors.headArmor);
+ this.skin.body.add(this.armors.bodyArmor);
+ this.skin.body.add(this.armors.bodyArmor2);
+ this.skin.leftArm.add(this.armors.leftArmArmor);
+ this.skin.rightArm.add(this.armors.rightArmArmor);
+ this.skin.rightLeg.add(this.armors.rightLegArmor);
+ this.skin.rightLeg.add(this.armors.rightLegArmor2);
+ this.skin.leftLeg.add(this.armors.leftLegArmor);
+ this.skin.leftLeg.add(this.armors.leftLegArmor2);
}
get backEquipment(): BackEquipment | null {
diff --git a/src/viewer.ts b/src/viewer.ts
index eb3af670..2e61f30a 100644
--- a/src/viewer.ts
+++ b/src/viewer.ts
@@ -32,6 +32,7 @@ import {
Clock,
Object3D,
ColorManagement,
+ MeshStandardMaterial,
} from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js";
@@ -73,6 +74,20 @@ export interface SkinLoadOptions extends LoadOptions {
* @defaultValue `false`
*/
ears?: boolean | "load-only";
+
+ /**
+ * The armor textures to load along with the skin.
+ *
+ * This option behaves exactly the same as the `armors` option in {@link SkinViewerOptions}.
+ * You can specify an object containing textures for specific armor pieces.
+ * The object may include any of the following optional properties:
+ * - `helmet`, `chestplate`, `leggings`, `boots`: direct textures for each piece.
+ * - `main`: a texture that will be used for helmet, chestplate, and boots if their specific textures are not provided.
+ * - `legs`: a texture that will be used for leggings if not provided.
+ *
+ * If the option is omitted, all armors will be removed.
+ */
+ armors?: ArmorTexture;
}
export interface CapeLoadOptions extends LoadOptions {
@@ -97,7 +112,20 @@ export interface EarsLoadOptions extends LoadOptions {
*/
textureType?: "standalone" | "skin";
}
-
+type ArmorTexture = Partial<{
+ helmet: TextureSource | RemoteImage | null;
+ chestplate: TextureSource | RemoteImage | null;
+ leggings: TextureSource | RemoteImage | null;
+ boots: TextureSource | RemoteImage | null;
+ main: TextureSource | RemoteImage | null;
+ legs: TextureSource | RemoteImage | null;
+}>;
+type NormalizedArmor = {
+ helmet: TextureSource | RemoteImage | null;
+ chestplate: TextureSource | RemoteImage | null;
+ leggings: TextureSource | RemoteImage | null;
+ boots: TextureSource | RemoteImage | null;
+};
export interface SkinViewerOptions {
/**
* The canvas where the renderer draws its output.
@@ -169,6 +197,24 @@ export interface SkinViewerOptions {
textureType: "standalone" | "skin";
source: RemoteImage | TextureSource;
};
+ /**
+ * The armor textures of the player.
+ *
+ * You can specify an object containing textures for different armor pieces.
+ * The object may include any of the following optional properties:
+ * - `helmet`, `chestplate`, `leggings`, `boots`: textures for individual pieces.
+ * - `main`: a texture that will be applied to helmet, chestplate, and boots when their specific properties are absent.
+ * - `legs`: a texture that will be applied to leggings when `leggings` is absent.
+ *
+ * Each texture can be a `RemoteImage` (URL string), a `TextureSource` (HTML image element), or `null` to hide that piece.
+ *
+ * If a property is omitted, it defaults to `null` (no texture for that piece), but may be overridden by `main` or `legs` as described above.
+ *
+ * If the option is omitted, set to `null`, or an empty object, all armors will be removed.
+ *
+ * @defaultValue `undefined` (no armor)
+ */
+ armors?: ArmorTexture;
/**
* Whether to preserve the buffers until manually cleared or overwritten.
@@ -290,9 +336,20 @@ export class SkinViewer {
readonly fxaaPass: ShaderPass;
readonly skinCanvas: HTMLCanvasElement;
+
+ readonly armorHelmetCanvas: HTMLCanvasElement;
+ readonly armorChestplateCanvas: HTMLCanvasElement;
+ readonly armorLeggingsCanvas: HTMLCanvasElement;
+ readonly armorBootsCanvas: HTMLCanvasElement;
+ private contexts: (CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D)[] = [];
+
readonly capeCanvas: HTMLCanvasElement;
readonly earsCanvas: HTMLCanvasElement;
private skinTexture: Texture | null = null;
+ private armorHelmetTexture: Texture | null = null;
+ private armorChestplateTexture: Texture | null = null;
+ private armorLeggingsTexture: Texture | null = null;
+ private armorBootsTexture: Texture | null = null;
private capeTexture: Texture | null = null;
private earsTexture: Texture | null = null;
private backgroundTexture: Texture | null = null;
@@ -335,6 +392,10 @@ export class SkinViewer {
this.canvas = options.canvas === undefined ? document.createElement("canvas") : options.canvas;
this.skinCanvas = document.createElement("canvas");
+ this.armorHelmetCanvas = document.createElement("canvas");
+ this.armorChestplateCanvas = document.createElement("canvas");
+ this.armorLeggingsCanvas = document.createElement("canvas");
+ this.armorBootsCanvas = document.createElement("canvas");
this.capeCanvas = document.createElement("canvas");
this.earsCanvas = document.createElement("canvas");
@@ -390,6 +451,7 @@ export class SkinViewer {
this.playerObject.name = "player";
this.playerObject.skin.visible = false;
this.playerObject.cape.visible = false;
+ this.playerObject.armors.visible = false;
this.playerWrapper = new Group();
this.playerWrapper.add(this.playerObject);
this.scene.add(this.playerWrapper);
@@ -409,6 +471,9 @@ export class SkinViewer {
ears: options.ears === "current-skin",
});
}
+ if (options.armors !== undefined) {
+ this.loadArmors(options.armors);
+ }
if (options.cape !== undefined) {
this.loadCape(options.cape);
}
@@ -505,6 +570,34 @@ export class SkinViewer {
this.fxaaPass.material.uniforms["resolution"].value.y = 1 / (this.height * pixelRatio);
}
+ private recreateArmorTexture(): void {
+ [this.armorHelmetTexture, this.armorChestplateTexture, this.armorLeggingsTexture, this.armorBootsTexture].forEach(
+ texture => {
+ if (texture !== null) {
+ texture.dispose();
+ }
+ }
+ );
+ this.armorHelmetTexture = new CanvasTexture(this.armorHelmetCanvas);
+ this.armorHelmetTexture.magFilter = NearestFilter;
+ this.armorHelmetTexture.minFilter = NearestFilter;
+ this.armorChestplateTexture = new CanvasTexture(this.armorChestplateCanvas);
+ this.armorChestplateTexture.magFilter = NearestFilter;
+ this.armorChestplateTexture.minFilter = NearestFilter;
+ this.armorLeggingsTexture = new CanvasTexture(this.armorLeggingsCanvas);
+ this.armorLeggingsTexture.magFilter = NearestFilter;
+ this.armorLeggingsTexture.minFilter = NearestFilter;
+ this.armorBootsTexture = new CanvasTexture(this.armorBootsCanvas);
+ this.armorBootsTexture.magFilter = NearestFilter;
+ this.armorBootsTexture.minFilter = NearestFilter;
+ this.playerObject.armors.setArmorMaps(
+ this.armorHelmetTexture,
+ this.armorChestplateTexture,
+ this.armorLeggingsTexture,
+ this.armorBootsTexture
+ );
+ }
+
private recreateSkinTexture(): void {
if (this.skinTexture !== null) {
this.skinTexture.dispose();
@@ -535,6 +628,147 @@ export class SkinViewer {
this.earsTexture.minFilter = NearestFilter;
this.playerObject.ears.map = this.earsTexture;
}
+ loadArmors(empty: null): void | Promise;
+ loadArmors(textures: ArmorTexture | null): void | Promise;
+ loadArmors(textures: ArmorTexture | null) {
+ if (!textures) {
+ this.resetArmors();
+ return;
+ }
+
+ let texturesCopy: NormalizedArmor = {
+ helmet: textures.helmet ?? textures.main ?? null,
+ chestplate: textures.chestplate ?? textures.main ?? null,
+ leggings: textures.leggings ?? textures.legs ?? null,
+ boots: textures.boots ?? textures.main ?? null,
+ };
+ const syncImages: ArmorTexture = {};
+ const asyncPromises: Promise[] = [];
+
+ (Object.keys(texturesCopy) as Array).forEach(key => {
+ if (texturesCopy[key] === null) {
+ syncImages[key] = null;
+ } else if (isTextureSource(texturesCopy[key])) {
+ syncImages[key] = texturesCopy[key] as TextureSource;
+ } else {
+ asyncPromises.push(
+ loadImage(texturesCopy[key] as RemoteImage).then(img => {
+ syncImages[key] = img;
+ })
+ );
+ }
+ });
+
+ if (asyncPromises.length > 0) {
+ return Promise.all(asyncPromises).then(() => {
+ this._applyArmorTextures(syncImages as NormalizedArmor);
+ });
+ } else {
+ this._applyArmorTextures(syncImages as NormalizedArmor);
+ }
+ }
+
+ private inferArmorType(texture: TextureSource): "legs" | "main" {
+ const canvas = document.createElement("canvas");
+ canvas.width = texture.width;
+ canvas.height = texture.height;
+ const ctx = canvas.getContext("2d", { willReadFrequently: true }) as CanvasRenderingContext2D;
+ ctx.drawImage(texture, 0, 0, texture.width, texture.height);
+
+ const scale = canvas.width / 64;
+ const x = 0;
+ const y = 0;
+ const w = Math.floor(32 * scale);
+ const h = Math.floor(16 * scale);
+
+ const imgData = ctx.getImageData(x, y, w, h);
+ const data = imgData.data;
+ const pixelCount = w * h;
+
+ let allTransparent = true;
+ let allBlack = true;
+ let allWhite = true;
+
+ for (let i = 0; i < pixelCount; i++) {
+ const offset = i * 4;
+ const r = data[offset];
+ const g = data[offset + 1];
+ const b = data[offset + 2];
+ const a = data[offset + 3];
+
+ if (a !== 0) allTransparent = false;
+ if (!(r === 0 && g === 0 && b === 0 && a === 255)) allBlack = false;
+ if (!(r === 255 && g === 255 && b === 255 && a === 255)) allWhite = false;
+
+ if (!allTransparent && !allBlack && !allWhite) {
+ return "main";
+ }
+ }
+
+ return allTransparent || allBlack || allWhite ? "legs" : "main";
+ }
+
+ private _applyArmorTextures(textures: NormalizedArmor): void {
+ const helmet = textures.helmet as TextureSource;
+ const chestplate = textures.chestplate as TextureSource;
+ const leggings = textures.leggings as TextureSource;
+ const boots = textures.boots as TextureSource;
+ const inferArmorType = this.inferArmorType;
+ const armorTypeMap: Record = {
+ helmet: "main",
+ chestplate: "main",
+ leggings: "legs",
+ boots: "main",
+ };
+
+ const armorNames = ["helmet", "chestplate", "leggings", "boots"];
+ function setInvalidTextureWarn(type: keyof NormalizedArmor) {
+ if (textures[type] && inferArmorType(textures[type] as TextureSource) != armorTypeMap[type]) {
+ console.warn(`Invalid texture , from ${type}`);
+ }
+ }
+ armorNames.forEach(key => {
+ setInvalidTextureWarn(key as keyof NormalizedArmor);
+ });
+ if (this.contexts.length === 0) {
+ this.contexts = [
+ this.armorHelmetCanvas.getContext("2d", { willReadFrequently: true }) as CanvasRenderingContext2D,
+ this.armorChestplateCanvas.getContext("2d", { willReadFrequently: true }) as CanvasRenderingContext2D,
+ this.armorLeggingsCanvas.getContext("2d", { willReadFrequently: true }) as CanvasRenderingContext2D,
+ this.armorBootsCanvas.getContext("2d", { willReadFrequently: true }) as CanvasRenderingContext2D,
+ ];
+ }
+
+ [this.armorHelmetCanvas, this.armorChestplateCanvas, this.armorLeggingsCanvas, this.armorBootsCanvas].forEach(
+ (canvas, index) => {
+ if (textures[armorNames[index] as keyof NormalizedArmor]) {
+ const sideLength = (textures[armorNames[index] as keyof NormalizedArmor] as TextureSource).width;
+ canvas.width = sideLength;
+ canvas.height = sideLength;
+ }
+ (canvas.getContext("2d") as CanvasRenderingContext2D).clearRect(0, 0, canvas.width, canvas.height);
+ }
+ );
+
+ if (helmet) this.contexts[0].drawImage(helmet as CanvasImageSource, 0, 0, helmet.width, helmet.width / 2);
+ if (chestplate)
+ this.contexts[1].drawImage(chestplate as CanvasImageSource, 0, 0, chestplate.width, chestplate.width / 2);
+ if (leggings) this.contexts[2].drawImage(leggings as CanvasImageSource, 0, 0, leggings.width, leggings.width / 2);
+ if (boots) this.contexts[3].drawImage(boots as CanvasImageSource, 0, 0, boots.width, boots.width / 2);
+
+ this.recreateArmorTexture();
+ this.playerObject.armors.visible = true;
+ this.playerObject.armors.headArmor && (this.playerObject.armors.headArmor.visible = !!helmet);
+ this.playerObject.armors.bodyArmor && (this.playerObject.armors.bodyArmor.visible = !!chestplate || !!leggings);
+ this.playerObject.armors.leftArmArmor && (this.playerObject.armors.leftArmArmor.visible = !!chestplate);
+ this.playerObject.armors.rightArmArmor && (this.playerObject.armors.rightArmArmor.visible = !!chestplate);
+ this.playerObject.armors.leftLegArmor && (this.playerObject.armors.leftLegArmor.visible = !!leggings || !!boots);
+ this.playerObject.armors.rightLegArmor && (this.playerObject.armors.rightLegArmor.visible = !!leggings || !!boots);
+ this.playerObject.armors.bodyArmor2 && (this.playerObject.armors.bodyArmor2.visible = !!chestplate || !!leggings);
+ this.playerObject.armors.leftLegArmor2 && (this.playerObject.armors.leftLegArmor2.visible = !!leggings || !!boots);
+ this.playerObject.armors.rightLegArmor2 &&
+ (this.playerObject.armors.rightLegArmor2.visible = !!leggings || !!boots);
+ }
loadSkin(empty: null): void;
loadSkin(
@@ -554,7 +788,9 @@ export class SkinViewer {
} else {
this.playerObject.skin.modelType = options.model;
}
-
+ if (options.armors !== undefined) {
+ this.loadArmors(options.armors);
+ }
if (options.makeVisible !== false) {
this.playerObject.skin.visible = true;
}
@@ -574,10 +810,48 @@ export class SkinViewer {
return loadImage(source).then(image => this.loadSkin(image, options));
}
}
-
+ resetArmors(): void {
+ this.playerObject.armors.visible = false;
+ this.playerObject.armors.headArmor && (this.playerObject.armors.headArmor.visible = false);
+ this.playerObject.armors.bodyArmor && (this.playerObject.armors.bodyArmor.visible = false);
+ this.playerObject.armors.leftArmArmor && (this.playerObject.armors.leftArmArmor.visible = false);
+ this.playerObject.armors.rightArmArmor && (this.playerObject.armors.rightArmArmor.visible = false);
+ this.playerObject.armors.leftLegArmor && (this.playerObject.armors.leftLegArmor.visible = false);
+ this.playerObject.armors.rightLegArmor && (this.playerObject.armors.rightLegArmor.visible = false);
+ this.playerObject.armors.bodyArmor2 && (this.playerObject.armors.bodyArmor2.visible = false);
+ this.playerObject.armors.leftLegArmor2 && (this.playerObject.armors.leftLegArmor2.visible = false);
+ this.playerObject.armors.rightLegArmor2 && (this.playerObject.armors.rightLegArmor2.visible = false);
+ this.playerObject.armors.headArmor &&
+ ((this.playerObject.armors.headArmor.material as MeshStandardMaterial).map = null);
+ this.playerObject.armors.bodyArmor &&
+ ((this.playerObject.armors.bodyArmor.material as MeshStandardMaterial).map = null);
+ this.playerObject.armors.leftArmArmor &&
+ ((this.playerObject.armors.leftArmArmor.material as MeshStandardMaterial).map = null);
+ this.playerObject.armors.rightArmArmor &&
+ ((this.playerObject.armors.rightArmArmor.material as MeshStandardMaterial).map = null);
+ this.playerObject.armors.leftLegArmor &&
+ ((this.playerObject.armors.leftLegArmor.material as MeshStandardMaterial).map = null);
+ this.playerObject.armors.rightLegArmor &&
+ ((this.playerObject.armors.rightLegArmor.material as MeshStandardMaterial).map = null);
+ this.playerObject.armors.bodyArmor2 &&
+ ((this.playerObject.armors.bodyArmor2.material as MeshStandardMaterial).map = null);
+ this.playerObject.armors.leftLegArmor2 &&
+ ((this.playerObject.armors.leftLegArmor2.material as MeshStandardMaterial).map = null);
+ this.playerObject.armors.rightLegArmor2 &&
+ ((this.playerObject.armors.rightLegArmor2.material as MeshStandardMaterial).map = null);
+ [this.armorHelmetTexture, this.armorChestplateTexture, this.armorLeggingsTexture, this.armorBootsTexture].forEach(
+ texture => {
+ if (texture !== null) {
+ texture.dispose();
+ texture = null;
+ }
+ }
+ );
+ }
resetSkin(): void {
this.playerObject.skin.visible = false;
this.playerObject.skin.map = null;
+ this.resetArmors();
if (this.skinTexture !== null) {
this.skinTexture.dispose();
this.skinTexture = null;