From a5f20e71756685c4084caf42df744be35135e6cd Mon Sep 17 00:00:00 2001 From: Vatten <38135652+Vatten@users.noreply.github.com> Date: Sat, 11 Oct 2025 03:54:35 +0200 Subject: [PATCH 1/4] Add armor Adds armor models, with the exact dimensions as in minecraft. Also adds a way to load armor textures in the vanilla minecraft way, which allows for all armor from both vanilla minecraft and from any resource pack (even high resolution ones) to be loaded and rendered. --- examples/offscreen-renderer.ts | 25 ++- .../public/img/armor/chainmail_layer_1.png | Bin 0 -> 299 bytes .../public/img/armor/chainmail_layer_2.png | Bin 0 -> 180 bytes .../public/img/armor/crusader_layer_1.png | Bin 0 -> 7974 bytes .../public/img/armor/crusader_layer_2.png | Bin 0 -> 4847 bytes examples/public/img/armor/diamond_layer_1.png | Bin 0 -> 600 bytes examples/public/img/armor/diamond_layer_2.png | Bin 0 -> 346 bytes examples/public/img/armor/gold_layer_1.png | Bin 0 -> 602 bytes examples/public/img/armor/gold_layer_2.png | Bin 0 -> 347 bytes examples/public/img/armor/iron_layer_1.png | Bin 0 -> 560 bytes examples/public/img/armor/iron_layer_2.png | Bin 0 -> 307 bytes examples/public/img/armor/leather_layer_1.png | Bin 0 -> 552 bytes .../img/armor/leather_layer_1_overlay.png | Bin 0 -> 189 bytes examples/public/img/armor/leather_layer_2.png | Bin 0 -> 322 bytes .../img/armor/leather_layer_2_overlay.png | Bin 0 -> 181 bytes .../public/img/armor/netherite_layer_1.png | Bin 0 -> 641 bytes .../public/img/armor/netherite_layer_2.png | Bin 0 -> 331 bytes examples/public/img/armor/turtle_layer_1.png | Bin 0 -> 320 bytes src/model.ts | 151 +++++++++++++++++- src/viewer.ts | 123 ++++++++++++++ 20 files changed, 291 insertions(+), 8 deletions(-) create mode 100644 examples/public/img/armor/chainmail_layer_1.png create mode 100644 examples/public/img/armor/chainmail_layer_2.png create mode 100644 examples/public/img/armor/crusader_layer_1.png create mode 100644 examples/public/img/armor/crusader_layer_2.png create mode 100644 examples/public/img/armor/diamond_layer_1.png create mode 100644 examples/public/img/armor/diamond_layer_2.png create mode 100644 examples/public/img/armor/gold_layer_1.png create mode 100644 examples/public/img/armor/gold_layer_2.png create mode 100644 examples/public/img/armor/iron_layer_1.png create mode 100644 examples/public/img/armor/iron_layer_2.png create mode 100644 examples/public/img/armor/leather_layer_1.png create mode 100644 examples/public/img/armor/leather_layer_1_overlay.png create mode 100644 examples/public/img/armor/leather_layer_2.png create mode 100644 examples/public/img/armor/leather_layer_2_overlay.png create mode 100644 examples/public/img/armor/netherite_layer_1.png create mode 100644 examples/public/img/armor/netherite_layer_2.png create mode 100644 examples/public/img/armor/turtle_layer_1.png diff --git a/examples/offscreen-renderer.ts b/examples/offscreen-renderer.ts index ab0efa98..1075ade5 100644 --- a/examples/offscreen-renderer.ts +++ b/examples/offscreen-renderer.ts @@ -1,6 +1,11 @@ import * as skinview3d from "../src/skinview3d"; import "./style.css"; +let netheriteArmor = new skinview3d.ArmorType("./img/armor/netherite_layer_1.png", "./img/armor/netherite_layer_2.png"); +let diamondArmor = new skinview3d.ArmorType("./img/armor/diamond_layer_1.png", "./img/armor/diamond_layer_2.png"); +let turtleHelmet = new skinview3d.ArmorType("./img/armor/turtle_layer_1.png"); +let crusaderArmor = new skinview3d.ArmorType("./img/armor/crusader_layer_1.png", "./img/armor/crusader_layer_2.png"); + const configurations = [ { skin: "img/1_8_texturemap_redux.png", @@ -9,15 +14,27 @@ const configurations = [ { skin: "img/hacksore.png", cape: "img/legacy_cape.png", + armor: { + leggings: crusaderArmor, + helmet: crusaderArmor + }, }, { skin: "img/haka.png", cape: "img/mojang_cape.png", + armor: { + boots: netheriteArmor, + leggings: netheriteArmor, + chestplate: netheriteArmor + }, }, { skin: "img/hatsune_miku.png", cape: "img/mojang_cape.png", backEquipment: "elytra", + armor: { + boots: diamondArmor + }, }, { skin: "img/ironman_hd.png", @@ -26,7 +43,10 @@ const configurations = [ { skin: "img/sethbling.png", cape: null, - }, + armor: { + helmet: turtleHelmet + }, + } ]; (async function () { @@ -36,7 +56,7 @@ const configurations = [ renderPaused: true, }); - skinViewer.camera.rotation.x = -0.62; + skinViewer.camera.rotation.x = -0.620; skinViewer.camera.rotation.y = 0.534; skinViewer.camera.rotation.z = 0.348; skinViewer.camera.position.x = 30.5; @@ -47,6 +67,7 @@ const configurations = [ await Promise.all([ skinViewer.loadSkin(config.skin), skinViewer.loadCape(config.cape, { backEquipment: config.backEquipment }), + skinViewer.loadArmor(config.armor), ]); skinViewer.render(); const image = skinViewer.canvas.toDataURL(); diff --git a/examples/public/img/armor/chainmail_layer_1.png b/examples/public/img/armor/chainmail_layer_1.png new file mode 100644 index 0000000000000000000000000000000000000000..c28e0a3b98625b55e06ad9fb1d98a4ce21698c53 GIT binary patch literal 299 zcmV+`0o4A9P)7-1%Q6-&#yjA^L>7-)Sqiz?h!yV z^nqla@E3Q_Fgs4_z7qfOTK`IXhy0#c5h}@}GEM?{>heP+lk1m?6Ls0*Cf6FPj+Rms xL88@DIQNgykmSjYtPqD5Vd$r9(Od6#aeZ*uuAk4ni>80F bzkh~RN_4`%s^dR|LB@Kz`njxgN@xNAZ_7mt literal 0 HcmV?d00001 diff --git a/examples/public/img/armor/crusader_layer_1.png b/examples/public/img/armor/crusader_layer_1.png new file mode 100644 index 0000000000000000000000000000000000000000..1952e47dfce75b90a3ad8003afbb7c9908f2d5be GIT binary patch literal 7974 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 TL>+bEFDaYtj-;#RUQz!88oBel literal 0 HcmV?d00001 diff --git a/examples/public/img/armor/diamond_layer_1.png b/examples/public/img/armor/diamond_layer_1.png new file mode 100644 index 0000000000000000000000000000000000000000..1c5e912f9c6ee2ac5d501810ba6b296b63c2cce7 GIT binary patch literal 600 zcmV-e0;m0nP)232>xoEzpAncvx+w0C$5P6yVftEm8B`L-}j9My-Zvq99UTTtt-q zNgD07LGtIp#p9_od%~~b`}sQaW*|u|;l&dN@M4N}=PwmwtfPRO%m2?m-u@J0z7A+N z!-zZp0wavK(2}3q&Po&kh(fMlJujWBS6d!!QiP zFbu;m48tfM^TW1<&Gzf=TciM&_R%iy=T3y;1ORD}ubX4J0;m>2D^{V%jnk2qah~tR}6PfAc3HKx&a9K_#Qls1np68}lbI sW9r@N^XGAXn-WgN)z`fv=8sVUFM@t02!BP`AAjCI<((>`h6emqSZ zF*fRv5O=llwtc{=amcG+dmh|L4X-lg>M@F$}HVAnjK`mfOj z0>tJ+0)XppG6V;hk=bAYz(5&>1Inq-z7H}SCJK0=@U#Z1v0FfR>z-~mVUFWFt~)lF z2V7tl;{zB2*I<6I--My|T>gV}yWo&kn6qd2IE(}tvf)_wz7GHxIFM;K0Px0IR~foU zi1@pDK`T_i{y0Kj221OQE8djNo?+gif&-J|+mt>M)$Apn(BcTR0h)IX6%b8Vpd zx^dGfwFdb-^le~{b=406fNKFRp177kF@<&3Zxv%$he0|w|6dq9XO}z=<%ntns2*Vq zwa`=_r>&JJ3@BM711xjyQSy423vhYMC)VmB@6qRKQ7g~2_Y=wiVI;}L4wua>(c(w} zY3YZBmA$Om`mAc8Nb`hmKvQ2%KeSwC5>gUVlHvx3l2Fwl;x5q&NfnY5{||lfQm!De zGUjE&D<`}btE;z2a^+QDPe1g57Hv3U9Tvn3?&sk0euJSsqTfN;Jf0c^z|a olij7Pdm9z4u;YcI|6cCj009eT?MtL?J^%m!07*qoM6N<$f~A-WlK=n! literal 0 HcmV?d00001 diff --git a/examples/public/img/armor/gold_layer_2.png b/examples/public/img/armor/gold_layer_2.png new file mode 100644 index 0000000000000000000000000000000000000000..5a5e207520fc6015e0d7d9640f61cfc4153dacf6 GIT binary patch literal 347 zcmV-h0i^zkP)wfkSbE1?<6aEc`wDC->>TgK@bE% z5ClOG1VLyXkMlOzxt(_L*`xrUa1G&kh$1v6fRQ%xugj)g0r)gi^Qe2&KKFq!p9_JE zjHP?H?=wMruh}4P?`}%ONI-y0u(Nf}E;4{`I1-%502aiL0CdBg;+*mz zTngf<1Ry}PxdbYLz;;stAXM%FoB;!Yl3?z700^=mKTWde5mRHb9I52oy=AEI~F6rpnCtRR~aBq!K|U!()^Q ta+59NSJ7iY2u+js2qw(_x$#S6z!wJ=5(b%`v~r-L=UWdL{`zkTD7k@oUma5&tw;)g|Yp9N`;~!7_$dXs%CyNVYOSRU`rSIqxXBUKRpuZ+T*^TjV?X>=wQA zJo`T398gA*Y6d zSmIb0%~+8W{CP$76}#UDa&%iA6oRw&avuAg!S^Y`Talr|;NXV_2?6%vBgd>C1*b5~ zH#jmQm4SPb8-rb`5fkQcJGC=nne=(fL;I~kNo-zxk2gy0G8Dz0QeTR2LLGD_7YX@zN=sL8dVR| z1R&+4wzeA6DV-^B-p6jZZ>6)|;%wXm0QLejp1lMecKxgw)4cZ*Y`zGP`V9Ra$4w$Y zFQmaDO*-ZVJ3u5`_9aISX8-pm0IDk1Z4WY`c2zqXHk+^#;VykJ0ao+6giyf6EL1SH9Ig- z2$E#n;B1Ad?@^%@C2ia4%LKP}`efr*W;P{}&k(_|QiVHk#C7=~dO#_(8{ z<;0lh`T7niz_IV=wr!UP!wCS=Am3UWR{$Nu^gR2Xy{|d&X|IGpAOpLv>-r{$dA25b z%*(V0NKgP0LqR3t$&07*+Ys1m5g4aN>D>K);}U+VrJZBN|rIb`d|P57t+vV U)x6I!H2?qr07*qoM6N<$g2$hYH~;_u literal 0 HcmV?d00001 diff --git a/examples/public/img/armor/leather_layer_2_overlay.png b/examples/public/img/armor/leather_layer_2_overlay.png new file mode 100644 index 0000000000000000000000000000000000000000..4faea6d65b021018577bab2b0b24edb8cc59df91 GIT binary patch literal 181 zcmeAS@N?(olHy`uVBq!ia0vp^4nVBH!3-p)I`?e@QlbGqA+A8$OGhHeL?OyTA<0G| z%R#f)L-Tr$Cnr#Xu_VYZn8D%MjWi&~&(p;*#Dn+kK|?NQLjh;Ux0kiP+ZQEGR$6+H zrEa6KRC3Qpi*4(Eh`w$Ue%-b9`GdtbxHR^abg^tQS)wRy6`JRve_@@M{tJl46$)D^KIhAu2DNGxo90Un^QA9RsE^yAYxX^L>IIFePzcd>lm{dae} zh3DyT7-JkCU&iy-dHi^NpXOTMZT*q(Z_mf^^m&>_%mu>c0eHB(|78IIfP@cPHvq&C z0DA8N0GWUMhVY63TI&XY7_tDpcL6|{c?cVt7XUGjwJuyBd_4q?p)8>HO=JO@xaRV6 z4GAv`U@RaH_s~47L__;;Ol%+~BXeyt*WETLgAkwM;q=PvKnI%zB=l5QN{PcM*xg7v6 z>8o1+=iYIO=KSn7*({O9_xM(QNCrUAO6j{=JIzD8#+_euYM3`q6r2~bB^TgD?oFDh zQu~}WOH2G&yG^h%0LW9_1*laX+%4o%erc0Eg|B6O$5mO2n_t?oxki^vI^!>0xv9~j zIqN)g=UUZedjHSBl-&=M-hItAy2PLPdKDkeqOxCSc>wc(C bYnAffkFLSQ!d}Uy00000NkvXXu0mjf;NC1N literal 0 HcmV?d00001 diff --git a/examples/public/img/armor/netherite_layer_2.png b/examples/public/img/armor/netherite_layer_2.png new file mode 100644 index 0000000000000000000000000000000000000000..963f0826f18ff06e168ad3bbebe6370ea5bbf5a3 GIT binary patch literal 331 zcmV-R0kr;!P)&-3v#1pw}Ka0YP(Jox}{wQ;h;+*Xcxfbze~^;mtWeiA^q+u!E4at=uU;vAZv zbOgyGXA5gd>@b?(vEUq;Gen*JcCOg((0mfttT#0m!kl?>>?FHxb!bTp(Y50ebBy@e d7qoA`{{wO;$x151y?p=x002ovPDHLkV1kXLjH&J8v|w=3tPI0IO4 zcm|AO1at<_FV2A{qKX-}*BQ?@-uDoh8Uf_t{{z%wdxzf{i~-y**V?Ol6VE2tz1&&| z4$a|OaRW>Q8nqn3&Eqo*gZSK9%jf4+Zz4ZgxL4c&Ya$F513}-^Vtb|bM7_8H)8n#l z#`04;NjBxZ?<0n-4fo@S)dzPdUfj%=SStgDLT&_pxmE}vgb+dqA%qa(U%UW4%Ce8H Sy65-+0000; + + constructor(layer1Src?: RemoteImage | TextureSource, layer2Src?: RemoteImage | TextureSource) { + this.promise = new Promise(async (resolve) => { + let layer1 = await this.loadLayer(layer1Src); + let layer2 = await this.loadLayer(layer2Src); + loadArmorToCanvas(this.canvas, layer1, layer2); + this.recreateArmorTexture(); + this.canvas.remove(); + resolve(); + }); + } + + private loadLayer(src: RemoteImage | TextureSource | undefined): Promise { + return new Promise((resolve) => { + if (src === undefined || src === null) { + resolve(undefined); + } else if (isTextureSource(src)) { + resolve(src); + } else { + loadImage(src).then(image => { + resolve(image); + }); + } + }); + } + + private recreateArmorTexture() { + this.texture = new CanvasTexture(this.canvas); + this.texture.magFilter = NearestFilter; + this.texture.minFilter = NearestFilter; + } +} + /** * The SkinViewer renders the player on a canvas. */ @@ -643,6 +708,64 @@ export class SkinViewer { } } + async loadArmor( + slots?: ArmorSlots + ): Promise { + this.resetArmor(); + if (slots) { + for (let slot of Object.entries(slots)) { + if (!slot[1]) continue; + await slot[1].promise; + this.addTextureToSlot(slot[0], slot[1].texture); + } + } + } + + private addTextureToSlot( + slot: string, + texture: Texture + ): void { + switch (slot) { + case "helmet": + this.playerObject.armor.helmetMat.map = texture; + this.playerObject.armor.helmet.visible = true; + break; + case "chestplate": + this.playerObject.armor.chestplateMat.map = texture; + this.playerObject.armor.chestplate.visible = true; + this.playerObject.armor.chestplateRight.visible = true; + this.playerObject.armor.chestplateLeft.visible = true; + break; + case "leggings": + this.playerObject.armor.leggingsMat.map = texture; + this.playerObject.armor.leggingsTop.visible = true; + this.playerObject.armor.leggingsRight.visible = true; + this.playerObject.armor.leggingsLeft.visible = true; + break; + case "boots": + this.playerObject.armor.bootsMat.map = texture; + this.playerObject.armor.bootsRight.visible = true; + this.playerObject.armor.bootsLeft.visible = true; + break; + } + } + + resetArmor(): void { + this.playerObject.armor.helmetMat.dispose(); + this.playerObject.armor.chestplateMat.dispose(); + this.playerObject.armor.leggingsMat.dispose(); + this.playerObject.armor.bootsMat.dispose(); + this.playerObject.armor.helmet.visible = false; + this.playerObject.armor.chestplate.visible = false; + this.playerObject.armor.chestplateRight.visible = false; + this.playerObject.armor.chestplateLeft.visible = false; + this.playerObject.armor.leggingsTop.visible = false; + this.playerObject.armor.leggingsRight.visible = false; + this.playerObject.armor.leggingsLeft.visible = false; + this.playerObject.armor.bootsRight.visible = false; + this.playerObject.armor.bootsLeft.visible = false; + } + loadPanorama(source: S): S extends TextureSource ? void : Promise { return this.loadBackground(source, EquirectangularReflectionMapping); } From ad61223fa90027ee140ab394a6019be014cc61ad Mon Sep 17 00:00:00 2001 From: Vatten <38135652+Vatten@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:38:03 +0100 Subject: [PATCH 2/4] Add armor examples This adds configurable armor examples to the examples page as well as some examples on the offscreen render. --- README.md | 15 +++++ examples/index.html | 54 +++++++++++++++ examples/main.ts | 157 +++++++++++++++++++++++++++++++++++++------- src/model.ts | 4 +- src/viewer.ts | 4 +- 5 files changed, 206 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index c5b0a65e..779d4c87 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Three.js powered Minecraft skin viewer. * Capes * Ears * Elytras +* Armor * Slim Arms * Automatic model detection (Slim / Default) * FXAA (fast approximate anti-aliasing) @@ -49,6 +50,20 @@ Three.js powered Minecraft skin viewer. // Unload(hide) the cape / elytra skinViewer.loadCape(null); + // Initialize armor types using their layer textures (as found in resource packs) + let diamondArmor = new skinview3d.ArmorType("img/armor/diamond_layer_1.png", "img/armor/diamond_layer_2.png"); + let turtleHelmet = new skinview3d.ArmorType("img/armor/turtle_layer_1.png"); + // Load the armor types on armor slots (in this case, a turtle helmet with diamond armor) + skinViewer.loadArmor({ + helmet: turtleHelmet, + chestplate: diamondArmor, + leggings: diamondArmor, + boots, diamondArmor + }); + + // Unload(hide) the armor + skinViewer.loadArmor(); + // Set the background color skinViewer.background = 0x5a76f3; diff --git a/examples/index.html b/examples/index.html index 46ff0d32..a688433b 100644 --- a/examples/index.html +++ b/examples/index.html @@ -318,6 +318,60 @@

Ears

+
+

Armor

+ + + + + + + + + + + + + + + + + + + + +
helmet + +
chestplate + +
leggings + +
boots + +
+ +
+ + + + +
+ +
+

Background

diff --git a/examples/main.ts b/examples/main.ts index c52cbad4..542acfb3 100644 --- a/examples/main.ts +++ b/examples/main.ts @@ -3,6 +3,12 @@ import type { ModelType } from "skinview-utils"; import type { BackEquipment } from "../src/model"; import "./style.css"; +declare global { + interface Window { + newArmorBuilder: (name: String, layer1: String, layer2: String) => void; + } +}; + const skinParts = ["head", "body", "rightArm", "leftArm", "rightLeg", "leftLeg"]; const skinLayers = ["innerLayer", "outerLayer"]; const availableAnimations = { @@ -16,6 +22,10 @@ const availableAnimations = { swim: new skinview3d.SwimAnimation(), }; +var armorTypes: { + [key: string]: skinview3d.ArmorType; +} = {}; + let skinViewer: skinview3d.SkinViewer; function obtainTextureUrl(id: string): string { @@ -128,6 +138,91 @@ function reloadEars(skipSkinReload = false): void { } } +function reloadArmor(): void { + const helmet = document.getElementById("armor-helmet") as HTMLInputElement; + const chestplate = document.getElementById("armor-chestplate") as HTMLInputElement; + const leggings = document.getElementById("armor-leggings") as HTMLInputElement; + const boots = document.getElementById("armor-boots") as HTMLInputElement; + skinViewer.loadArmor({ + helmet: armorTypes[helmet.value] || null, + chestplate: armorTypes[chestplate.value] || null, + leggings: armorTypes[leggings.value] || null, + boots: armorTypes[boots.value] || null, + }); +} + +function buildArmorTypes(): void { + const datalist = document.getElementById("armor-slots") as HTMLDataListElement; + datalist.innerHTML = ""; + armorTypes = {}; + + const armorTypesElement = document.getElementById("armor-types") as HTMLDivElement; + for(const armorTypeElement of armorTypesElement.querySelectorAll(".armor-type")) { + const index = armorTypeElement.dataset.index; + const nameInput = armorTypeElement.querySelector(".armor-type-name"); + const layer1Input = armorTypeElement.querySelector(".armor-type-layer1"); + const layer2Input = armorTypeElement.querySelector(".armor-type-layer2"); + const layer1Texture = obtainTextureUrl(`armor${index}-layer1`) || null; + const layer2Texture = obtainTextureUrl(`armor${index}-layer2`) || null; + console.log(nameInput?.value, layer1Texture, layer2Texture); + if(nameInput?.value && ((layer1Texture && layer1Texture !== "") || (layer2Texture && layer2Texture !== ""))) { + armorTypes[nameInput.value] = new skinview3d.ArmorType(layer1Texture, layer2Texture); + } + const summaryElement = armorTypeElement.querySelector("summary") as HTMLElement; + if (summaryElement) { + summaryElement.innerText = nameInput?.value || `armor_${index}`; + } + } + + for(const name in armorTypes) { + const optionElement = document.createElement("option"); + optionElement.value = name || ""; + datalist.appendChild(optionElement); + } +} + +function newArmorBuilder(name: String, layer1: String | null, layer2: String | null): void { + const armorTypesElement = document.getElementById("armor-types") as HTMLDivElement; + const index = armorTypesElement.querySelectorAll(".armor-type").length + 1; + name = name || `armor_${index}`; + layer1 = layer1 || ""; + layer2 = layer2 || ""; + const armorTypeElement = document.createElement("details"); + armorTypeElement.dataset.index = index.toString(); + armorTypeElement.classList.add("controls", "armor-type"); + armorTypeElement.innerHTML = ` + ${name} + + + + + + + + + + + + `; + armorTypesElement.appendChild(armorTypeElement); + + initializeUploadButton(`armor${index}-layer1`, () => { + buildArmorTypes(); + reloadArmor(); + }); + initializeUploadButton(`armor${index}-layer2`, () => { + buildArmorTypes(); + reloadArmor(); + }); + armorTypeElement.addEventListener("change", () => { + buildArmorTypes(); + reloadArmor(); + }); +} +window.newArmorBuilder = newArmorBuilder; + function reloadPanorama(): void { const input = document.getElementById("panorama_url") as HTMLInputElement; const backgroundTypeInput = document.getElementById("background_type") as HTMLSelectElement; @@ -177,6 +272,31 @@ function reloadNameTag(): void { } } +function initializeUploadButton(id: string, callback: () => void) { + const urlInput = document.getElementById(id) as HTMLInputElement; + const fileInput = document.getElementById(`${id}_upload`) as HTMLInputElement; + const unsetButton = document.getElementById(`${id}_unset`); + + const unsetAction = () => { + if (urlInput) { + urlInput.readOnly = false; + urlInput.value = ""; + } + if (fileInput) { + fileInput.value = fileInput.defaultValue; + } + callback(); + }; + + fileInput?.addEventListener("change", () => callback()); + urlInput?.addEventListener("keydown", e => { + if (e.key === "Backspace" && urlInput?.readOnly) { + unsetAction(); + } + }); + unsetButton?.addEventListener("click", () => unsetAction()); +}; + function initializeControls(): void { const canvasWidth = document.getElementById("canvas_width") as HTMLInputElement; const canvasHeight = document.getElementById("canvas_height") as HTMLInputElement; @@ -371,31 +491,6 @@ function initializeControls(): void { } } - const initializeUploadButton = (id: string, callback: () => void) => { - const urlInput = document.getElementById(id) as HTMLInputElement; - const fileInput = document.getElementById(`${id}_upload`) as HTMLInputElement; - const unsetButton = document.getElementById(`${id}_unset`); - - const unsetAction = () => { - if (urlInput) { - urlInput.readOnly = false; - urlInput.value = ""; - } - if (fileInput) { - fileInput.value = fileInput.defaultValue; - } - callback(); - }; - - fileInput?.addEventListener("change", () => callback()); - urlInput?.addEventListener("keydown", e => { - if (e.key === "Backspace" && urlInput?.readOnly) { - unsetAction(); - } - }); - unsetButton?.addEventListener("click", () => unsetAction()); - }; - initializeUploadButton("skin_url", reloadSkin); initializeUploadButton("cape_url", reloadCape); initializeUploadButton("ears_url", reloadEars); @@ -407,12 +502,14 @@ function initializeControls(): void { const earsSource = document.getElementById("ears_source") as HTMLSelectElement; const earsUrl = document.getElementById("ears_url") as HTMLInputElement; const panoramaUrl = document.getElementById("panorama_url") as HTMLInputElement; + const armorTable = document.getElementById("armor-table") as HTMLTableElement; skinUrl?.addEventListener("change", reloadSkin); skinModel?.addEventListener("change", reloadSkin); capeUrl?.addEventListener("change", reloadCape); earsSource?.addEventListener("change", () => reloadEars()); earsUrl?.addEventListener("change", () => reloadEars()); + armorTable?.addEventListener("change", reloadArmor); panoramaUrl?.addEventListener("change", reloadPanorama); const backEquipmentRadios = document.querySelectorAll('input[type="radio"][name="back_equipment"]'); @@ -509,9 +606,19 @@ function initializeViewer(): void { } } + newArmorBuilder("netherite_armor", "img/armor/netherite_layer_1.png", "img/armor/netherite_layer_2.png"); + newArmorBuilder("diamond_armor", "img/armor/diamond_layer_1.png", "img/armor/diamond_layer_2.png"); + newArmorBuilder("gold_armor", "img/armor/gold_layer_1.png", "img/armor/gold_layer_2.png"); + newArmorBuilder("iron_armor", "img/armor/iron_layer_1.png", "img/armor/iron_layer_2.png"); + newArmorBuilder("leather_armor", "img/armor/leather_layer_1.png", "img/armor/leather_layer_2.png"); + newArmorBuilder("chainmail_armor", "img/armor/chainmail_layer_1.png", "img/armor/chainmail_layer_2.png"); + newArmorBuilder("turtle_armor", "img/armor/turtle_layer_1.png", null); + buildArmorTypes(); + reloadSkin(); reloadCape(); reloadEars(true); + reloadArmor(); reloadPanorama(); reloadNameTag(); } diff --git a/src/model.ts b/src/model.ts index cea2f268..689efa1b 100644 --- a/src/model.ts +++ b/src/model.ts @@ -132,7 +132,8 @@ export class SkinObject extends Group { this.head = new BodyPart(headMesh, head2Mesh); this.head.name = "head"; this.head.add(headMesh, head2Mesh); - this.head.position.y = 4; + headMesh.position.y = 4; + head2Mesh.position.y = 4; this.add(this.head); // Body @@ -500,6 +501,7 @@ export class ArmorObject { const helmetBox = new BoxGeometry(10, 10, 10); setUVs(helmetBox, 32, 0, 8, 8, 8, 64, 64); const helmetMesh = new Mesh(helmetBox, this.helmetMat); + helmetMesh.position.y = 4; this.helmet = new Group(); this.helmet.name = "helmet"; diff --git a/src/viewer.ts b/src/viewer.ts index 97fa9f75..dfbe935a 100644 --- a/src/viewer.ts +++ b/src/viewer.ts @@ -282,7 +282,7 @@ export class ArmorType { texture?: Texture; promise?: Promise; - constructor(layer1Src?: RemoteImage | TextureSource, layer2Src?: RemoteImage | TextureSource) { + constructor(layer1Src?: RemoteImage | TextureSource | null, layer2Src?: RemoteImage | TextureSource | null) { this.promise = new Promise(async (resolve) => { let layer1 = await this.loadLayer(layer1Src); let layer2 = await this.loadLayer(layer2Src); @@ -293,7 +293,7 @@ export class ArmorType { }); } - private loadLayer(src: RemoteImage | TextureSource | undefined): Promise { + private loadLayer(src: RemoteImage | TextureSource | undefined | null): Promise { return new Promise((resolve) => { if (src === undefined || src === null) { resolve(undefined); From 4164dd377c8f9abdba66d09919dffd6d862e7c16 Mon Sep 17 00:00:00 2001 From: Vatten <38135652+Vatten@users.noreply.github.com> Date: Sun, 8 Mar 2026 01:12:04 +0100 Subject: [PATCH 3/4] Armor: Fallback, short hand, minor improvements --- README.md | 13 +++++++++++- examples/index.html | 6 ++++++ examples/main.ts | 7 ++++--- src/viewer.ts | 51 +++++++++++++++++++++++++++++++++------------ 4 files changed, 60 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 779d4c87..165b8049 100644 --- a/README.md +++ b/README.md @@ -58,12 +58,23 @@ Three.js powered Minecraft skin viewer. helmet: turtleHelmet, chestplate: diamondArmor, leggings: diamondArmor, - boots, diamondArmor + boots: diamondArmor }); + // Short-hand for applying an armor type on all slots + skinViewer.loadArmor(diamondArmor); + // Unload(hide) the armor skinViewer.loadArmor(); + // Specify fallback armor that will always be applied if there is no other armor set + skinViewer.fallbackArmor(diamondArmor); + // Or you can specify slots + skinViewer.fallbackArmor({ + helmet: diamondArmor, + chestplate: diamondArmor + }); + // Set the background color skinViewer.background = 0x5a76f3; diff --git a/examples/index.html b/examples/index.html index a688433b..eff920cb 100644 --- a/examples/index.html +++ b/examples/index.html @@ -347,6 +347,12 @@

Armor

+ + fallback + + + + diff --git a/examples/main.ts b/examples/main.ts index 542acfb3..eea77bd4 100644 --- a/examples/main.ts +++ b/examples/main.ts @@ -138,12 +138,14 @@ function reloadEars(skipSkinReload = false): void { } } -function reloadArmor(): void { +async function reloadArmor(): Promise { + const fallbackInput = document.getElementById("armor-fallback") as HTMLInputElement; + await skinViewer.fallbackArmor(armorTypes[fallbackInput.value] || null); const helmet = document.getElementById("armor-helmet") as HTMLInputElement; const chestplate = document.getElementById("armor-chestplate") as HTMLInputElement; const leggings = document.getElementById("armor-leggings") as HTMLInputElement; const boots = document.getElementById("armor-boots") as HTMLInputElement; - skinViewer.loadArmor({ + await skinViewer.loadArmor({ helmet: armorTypes[helmet.value] || null, chestplate: armorTypes[chestplate.value] || null, leggings: armorTypes[leggings.value] || null, @@ -164,7 +166,6 @@ function buildArmorTypes(): void { const layer2Input = armorTypeElement.querySelector(".armor-type-layer2"); const layer1Texture = obtainTextureUrl(`armor${index}-layer1`) || null; const layer2Texture = obtainTextureUrl(`armor${index}-layer2`) || null; - console.log(nameInput?.value, layer1Texture, layer2Texture); if(nameInput?.value && ((layer1Texture && layer1Texture !== "") || (layer2Texture && layer2Texture !== ""))) { armorTypes[nameInput.value] = new skinview3d.ArmorType(layer1Texture, layer2Texture); } diff --git a/src/viewer.ts b/src/viewer.ts index dfbe935a..b2d02f5c 100644 --- a/src/viewer.ts +++ b/src/viewer.ts @@ -254,16 +254,16 @@ export interface ArmorSlots { /** * The types of armor to be used in each slot. * - * @defaultValue Unspecified slot causes said slot to be invisible. + * @defaultValue Slots with a value of null will be invisible. */ - helmet?: ArmorType; + helmet: ArmorType | null; - chestplate?: ArmorType; + chestplate: ArmorType | null; - leggings?: ArmorType; + leggings: ArmorType | null; - boots?: ArmorType; + boots: ArmorType | null; } export class ArmorType { @@ -271,7 +271,8 @@ export class ArmorType { * Initializes an armor type. * The textures work the same as in Resource Packs which means that the textures are split into 2 layers, * with Layer 1 being the Helmet, Chestplate and Boots, and Layer 2 being the leggings. - * This is because older skins didn't have outer layers and so there was no space in the texture to include the hip part of leggings, so they were split into 2 different textures. + * + * This *could* be because older skins didn't have outer layers and so there was no image format to support having overlapping textures (leggings overlap with the boots and chestplate) so they were split into 2 different textures. But hey, that's just a theory, a game theory. * * Armor types such as Turtle Shell use only one layer. * @@ -361,6 +362,8 @@ export class SkinViewer { private capeTexture: Texture | null = null; private earsTexture: Texture | null = null; private backgroundTexture: Texture | null = null; + private armor: ArmorSlots | null = null; + private _fallbackArmor: ArmorSlots | null = null; private _disposed: boolean = false; private _renderPaused: boolean = false; @@ -722,16 +725,38 @@ export class SkinViewer { } } + private toArmorSlots(armor: ArmorType | Partial | null): ArmorSlots { + return { + helmet: armor instanceof ArmorType ? armor : (armor?.helmet ?? null), + chestplate: armor instanceof ArmorType ? armor : (armor?.chestplate ?? null), + leggings: armor instanceof ArmorType ? armor : (armor?.leggings ?? null), + boots: armor instanceof ArmorType ? armor : (armor?.boots ?? null), + }; + } + + async fallbackArmor( + armor: ArmorType | Partial | null + ): Promise { + this._fallbackArmor = this.toArmorSlots(armor); + await this.loadArmor(this.armor); + } + async loadArmor( - slots?: ArmorSlots + armor?: ArmorType | Partial | null ): Promise { this.resetArmor(); - if (slots) { - for (let slot of Object.entries(slots)) { - if (!slot[1]) continue; - await slot[1].promise; - this.addTextureToSlot(slot[0], slot[1].texture); - } + armor = this.toArmorSlots(armor ?? null); + let slots: ArmorSlots = { + helmet: armor.helmet ?? this._fallbackArmor?.helmet ?? null, + chestplate: armor.chestplate ?? this._fallbackArmor?.chestplate ?? null, + leggings: armor.leggings ?? this._fallbackArmor?.leggings ?? null, + boots: armor.boots ?? this._fallbackArmor?.boots ?? null, + }; + this.armor = slots; + for (let [slot, slotArmorType] of Object.entries(slots)) { + if (!slotArmorType) continue; + await slotArmorType.promise; + this.addTextureToSlot(slot, slotArmorType.texture); } } From 4d06414c666f6693314d6bbe6541ef8ca8aacb58 Mon Sep 17 00:00:00 2001 From: Vatten <38135652+Vatten@users.noreply.github.com> Date: Tue, 10 Mar 2026 00:23:46 +0100 Subject: [PATCH 4/4] Dispose of armor texture --- src/viewer.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/viewer.ts b/src/viewer.ts index b2d02f5c..129e623d 100644 --- a/src/viewer.ts +++ b/src/viewer.ts @@ -309,6 +309,9 @@ export class ArmorType { } private recreateArmorTexture() { + if (this.texture) { + this.texture.dispose(); + } this.texture = new CanvasTexture(this.canvas); this.texture.magFilter = NearestFilter; this.texture.minFilter = NearestFilter;