Reference Source

src/skin/index.js

import * as THREE from "three";

import texturePositions from "./texturePositions";
import Render, { defaultOptions } from "../renderBase";

/**
 * @see defaultOptions
 */
let defOptions = {
    camera: {
        type: "perspective",
        x: 20,
        y: 35,
        z: 20,
        target: [0, 18, 0]
    }
};

/**
 * A renderer for Minecraft player models/skins
 */
class SkinRender extends Render {

    /**
     * @param {Object} [options] The options for this renderer, see {@link defaultOptions}
     * @param {HTMLElement} [element=document.body] DOM Element to attach the renderer to - defaults to document.body
     * @constructor
     */
    constructor(options, element) {
        super(options, defOptions, element);

        this.renderType = "SkinRender";
        this._animId = -1;

        // bind this renderer to the element
        this.element.skinRender = this;
        this.attached = false;

    }


    /**
     * Does the actual rendering
     *
     * @param {(string|Object)} texture The texture to render - May be a string with the playername/URL/Base64 or an Object
     * @param {string} texture.url URL to the texture image
     * @param {string} texture.data Base64 encoded image data of the texture
     * @param {string} texture.username Player username
     * @param {string} texture.uuid Player UUID
     * @param {number} texture.mineskin ID of a MineSkin.org skin
     * @param {boolean} [texture.slim=false] Whether the provided texture uses the slim skin format
     *
     * @param {string} [texture.cape=latest] Cape to render using capes.dev - Either a direct link to the cape data (api.capes.dev/get/...) OR a specific cape type
     * @param {string} [texture.capeUser] Specify this to use a different user for the cape texture than the skin
     * @param {string} [texture.capeUrl] URL to a cape texture
     * @param {string} [texture.capeData] Base64 encoded image data of the cape texture
     * @param {string} [texture.mineskin] deprecated; ID of a MineSkin.org skin with a cape
     * @param {boolean} [texture.optifine=false] deprecated; Whether the provided cape texture is an optifine cape
     *
     * @param {function} [cb] Callback when rendering finished
     */
    render(texture, cb) {
        let skinRender = this;

        let renderStarted = false;

        let imagesLoaded = (skinTexture, capeTexture) => {
            renderStarted = true;
            skinTexture.needsUpdate = true;
            if (capeTexture) capeTexture.needsUpdate = true;

            let textureVersion = -1;
            if (skinTexture.image.height === 32) {
                textureVersion = 0;
            } else if (skinTexture.image.height === 64) {
                textureVersion = 1;
            } else {
                console.error("Couldn't detect texture version. Invalid dimensions: " + skinTexture.image.width + "x" + skinTexture.image.height)
            }
            console.log("Skin Texture Version: " + textureVersion)

            // To keep the pixelated texture
            skinTexture.magFilter = THREE.NearestFilter;
            skinTexture.minFilter = THREE.NearestFilter;
            skinTexture.anisotropy = 0;
            if (capeTexture) {
                capeTexture.magFilter = THREE.NearestFilter;
                capeTexture.minFilter = THREE.NearestFilter;
                capeTexture.anisotropy = 0;
            }

            if (!skinRender.attached && !skinRender._scene) {// Don't init scene if attached, since we already have an available scene
                super.initScene(function () {
                    skinRender.element.dispatchEvent(new CustomEvent("skinRender", {detail: {playerModel: skinRender.playerModel}}));
                });
            } else {
                console.log("[SkinRender] is attached - skipping scene init");
            }

            console.log("Slim: " + slim)
            let playerModel = createPlayerModel(skinTexture, capeTexture, textureVersion, slim, texture._capeType ? texture._capeType : texture.optifine ? "optifine" : "minecraft");
            skinRender.addToScene(playerModel);
            // console.log(playerModel);
            skinRender.playerModel = playerModel;

            if (typeof cb === "function") cb();
        }

        skinRender._skinImage = new Image();
        skinRender._skinImage.crossOrigin = "anonymous";
        skinRender._capeImage = new Image();
        skinRender._capeImage.crossOrigin = "anonymous";
        let hasCape = texture.cape !== undefined || texture.capeUrl !== undefined || texture.capeData !== undefined || texture.mineskin !== undefined;
        let slim = false;
        let skinLoaded = false;
        let capeLoaded = false;

        let skinTexture = new THREE.Texture();
        let capeTexture = new THREE.Texture();
        skinTexture.image = skinRender._skinImage;
        skinRender._skinImage.onload = function () {
            if (!skinRender._skinImage) return;

            skinLoaded = true;
            console.log("Skin Image Loaded");

            if (texture.slim === undefined) {
                if(skinRender._skinImage.height !== 32) {

                    let detectCanvas = document.createElement("canvas");
                    let detectCtx = detectCanvas.getContext("2d");
                    // detectCanvas.style.display = "none";
                    detectCanvas.width = skinRender._skinImage.width;
                    detectCanvas.height = skinRender._skinImage.height;
                    detectCtx.drawImage(skinRender._skinImage, 0, 0);

                    console.log("Slim Detection:")

                    // Check the 2 columns that should be transparent on slim skins
                    let px1 = detectCtx.getImageData(46, 52, 1, 12).data;
                    let px2 = detectCtx.getImageData(54, 20, 1, 12).data;
                    let allTransparent = true;
                    for (let i = 3; i < 12 * 4; i += 4) {
                        if (px1[i] === 255) {
                            allTransparent = false;
                            break;
                        }
                        if (px2[i] === 255) {
                            allTransparent = false;
                            break;
                        }
                    }
                    console.log(allTransparent)

                    if (allTransparent) slim = true;
                }
            }

            if (skinLoaded && (capeLoaded || !hasCape)) {
                if (!renderStarted) imagesLoaded(skinTexture, capeTexture);
            }
        };
        skinRender._skinImage.onerror = function (e) {
            console.warn("Skin Image Error")
            console.warn(e)
        }
        console.log("Has Cape: " + hasCape)
        if (hasCape) {
            capeTexture.image = skinRender._capeImage;
            skinRender._capeImage.onload = function () {
                if (!skinRender._capeImage) return;

                capeLoaded = true;
                console.log("Cape Image Loaded");

                if (capeLoaded && skinLoaded) {
                    if (!renderStarted) imagesLoaded(skinTexture, capeTexture);
                }
            }
            skinRender._capeImage.onerror = function (e) {
                console.warn("Cape Image Error")
                console.warn(e);

                // Continue anyway, just without the cape
                capeLoaded = true;
                if (skinLoaded) {
                    if (!renderStarted) imagesLoaded(skinTexture);
                }
            }
        } else {
            capeTexture = null;
            skinRender._capeImage = null;
        }

        if (typeof texture === "string") {
            // console.log(texture)
            if (texture.indexOf("http") === 0) {// URL
                skinRender._skinImage.src = texture
            } else if (texture.length <= 16) {// Probably a Minecraft username
                getJSON("https://minerender.org/nameToUuid.php?name=" + texture, function (err, data) {
                    if (err) return console.log(err);
                    console.log(data);
                    skinRender._skinImage.src = "https://crafatar.com/skins/" + (data.id ? data.id : texture);
                });
            } else if (texture.length <= 36) {// Probably player UUID
                image.src = "https://crafatar.com/skins/" + texture + "?overlay";
            } else {// taking a guess that it's a Base64 image
                skinRender._skinImage.src = texture;
            }
        } else if (typeof texture === "object") {
            if (texture.url) {
                skinRender._skinImage.src = texture.url;
            } else if (texture.data) {
                skinRender._skinImage.src = texture.data;
            } else if (texture.username) {
                getJSON("https://minerender.org/nameToUuid.php?name=" + texture.username, function (err, data) {
                    if (err) return console.log(err);
                    skinRender._skinImage.src = "https://crafatar.com/skins/" + (data.id ? data.id : texture.username) + "?overlay";
                });
            } else if (texture.uuid) {
                skinRender._skinImage.src = "https://crafatar.com/skins/" + texture.uuid + "?overlay";
            } else if (texture.mineskin) {
                skinRender._skinImage.src = "https://api.mineskin.org/render/texture/" + texture.mineskin;
            }
            if (texture.cape) {
                if (texture.cape.length > 36) { // Likely either a cape ID or URL
                    let capeDataUrl = texture.cape.startsWith("http") ? texture.cape : "https://api.capes.dev/get/" + texture.cape;
                    getJSON(capeDataUrl, function (err, data) {
                        if (err) return console.log(err);
                        if (data.exists) {
                            texture._capeType = data.type;
                            skinRender._capeImage.src = data.imageUrls.base.full;
                        }
                    })
                } else { // Type
                    let capeLoadUrl = "https://api.capes.dev/load/";
                    if(texture.capeUser) {// Try to find a player to use
                        capeLoadUrl+=texture.capeUser;
                    }else if (texture.username){
                        capeLoadUrl+=texture.username;
                    }else if(texture.uuid){
                        capeLoadUrl+=texture.uuid;
                    } else {
                        console.warn("Couldn't find a user to get a cape from");
                    }
                    capeLoadUrl += "/" + texture.cape; // append type

                    getJSON(capeLoadUrl, function (err, data) {
                        if (err) return console.log(err);
                         // Should be a single object of the requested type
                        if (data.exists) {
                            texture._capeType = data.type;
                            skinRender._capeImage.src = data.imageUrls.base.full;
                        }
                    })
                }
            } else if (texture.capeUrl) {
                skinRender._capeImage.src = texture.capeUrl;
            } else if (texture.capeData) {
                skinRender._capeImage.src = texture.capeData;
            } else if (texture.mineskin) {
                skinRender._capeImage.src = "https://api.mineskin.org/render/texture/" + texture.mineskin + "/cape";
            }

            slim = texture.slim;
        } else {
            throw new Error("Invalid texture value")
        }
    };


    resize(width, height) {
        return this._resize(width, height);
    };

    reset() {
        this._skinImage = null;
        this._capeImage = null;

        if (this._animId) {
            cancelAnimationFrame(this._animId);
        }
        if (this._canvas) {
            this._canvas.remove();
        }
    };

    getPlayerModel() {
        return this.playerModel;
    };


    getModelByName(name) {
        return this._scene.getObjectByName(name, true);
    };

    toggleSkinPart(name, visible) {
        this._scene.getObjectByName(name, true).visible = visible;
    };


}

function createCube(texture, width, height, depth, textures, slim, name, transparent) {
    let textureWidth = texture.image.width;
    let textureHeight = texture.image.height;

    let geometry = new THREE.BoxGeometry(width, height, depth);
    let material = new THREE.MeshBasicMaterial({
        /*color: 0x00ff00,*/map: texture, transparent: transparent || false, alphaTest: 0.5, side: transparent ? THREE.DoubleSide : THREE.FrontSide//TODO: double sided not working properly
    });

    geometry.computeBoundingBox();

    geometry.faceVertexUvs[0] = [];

    let faceNames = ["right", "left", "top", "bottom", "front", "back"];
    let faceUvs = [];
    for (let i = 0; i < faceNames.length; i++) {
        let face = textures[faceNames[i]];
        if (faceNames[i] === "back") {
            //     console.log(face)
            // console.log("X: " + (slim && face.sx ? face.sx : face.x))
            // console.log("W: " + (slim && face.sw ? face.sw : face.w))
        }
        let w = textureWidth;
        let h = textureHeight;
        let tx1 = ((slim && face.sx ? face.sx : face.x) / w);
        let ty1 = (face.y / h);
        let tx2 = (((slim && face.sx ? face.sx : face.x) + (slim && face.sw ? face.sw : face.w)) / w);
        let ty2 = ((face.y + face.h) / h);

        faceUvs[i] = [
            new THREE.Vector2(tx1, ty2),
            new THREE.Vector2(tx1, ty1),
            new THREE.Vector2(tx2, ty1),
            new THREE.Vector2(tx2, ty2)
        ];
        // console.log(faceUvs[i])

        let flipX = face.flipX;
        let flipY = face.flipY;

        let temp;
        if (flipY) {
            temp = faceUvs[i].slice(0);
            faceUvs[i][0] = temp[2];
            faceUvs[i][1] = temp[3];
            faceUvs[i][2] = temp[0];
            faceUvs[i][3] = temp[1]
        }
        if (flipX) {//flip x
            temp = faceUvs[i].slice(0);
            faceUvs[i][0] = temp[3];
            faceUvs[i][1] = temp[2];
            faceUvs[i][2] = temp[1];
            faceUvs[i][3] = temp[0]
        }
    }

    let j = 0;
    for (let i = 0; i < faceUvs.length; i++) {
        geometry.faceVertexUvs[0][j] = [faceUvs[i][0], faceUvs[i][1], faceUvs[i][3]];
        geometry.faceVertexUvs[0][j + 1] = [faceUvs[i][1], faceUvs[i][2], faceUvs[i][3]];
        j += 2;
    }
    geometry.uvsNeedUpdate = true;

    let cube = new THREE.Mesh(geometry, material);
    cube.name = name;
    // cube.position.set(x, y, z);
    cube.castShadow = true;
    cube.receiveShadow = false;

    return cube;
};


function createPlayerModel(skinTexture, capeTexture, v, slim, capeType) {
    console.log("capeType: " + capeType);

    let headGroup = new THREE.Object3D();
    headGroup.name = "headGroup";
    headGroup.position.x = 0;
    headGroup.position.y = 28;
    headGroup.position.z = 0;
    headGroup.translateOnAxis(new THREE.Vector3(0, 1, 0), -4);
    let head = createCube(skinTexture,
        8, 8, 8,
        texturePositions.head[v],
        slim,
        "head"
    );
    head.translateOnAxis(new THREE.Vector3(0, 1, 0), 4);
    headGroup.add(head);
    if (v >= 1) {
        let hat = createCube(skinTexture,
            8.504, 8.504, 8.504,
            texturePositions.hat,
            slim,
            "hat",
            true
        );
        hat.translateOnAxis(new THREE.Vector3(0, 1, 0), 4);
        headGroup.add(hat);
    }

    let bodyGroup = new THREE.Object3D();
    bodyGroup.name = "bodyGroup";
    bodyGroup.position.x = 0;
    bodyGroup.position.y = 18;
    bodyGroup.position.z = 0;
    let body = createCube(skinTexture,
        8, 12, 4,
        texturePositions.body[v],
        slim,
        "body"
    );
    bodyGroup.add(body);
    if (v >= 1) {
        let jacket = createCube(skinTexture,
            8.504, 12.504, 4.504,
            texturePositions.jacket,
            slim,
            "jacket",
            true
        );
        bodyGroup.add(jacket);
    }

    let leftArmGroup = new THREE.Object3D();
    leftArmGroup.name = "leftArmGroup";
    leftArmGroup.position.x = slim ? -5.5 : -6;
    leftArmGroup.position.y = 18;
    leftArmGroup.position.z = 0;
    leftArmGroup.translateOnAxis(new THREE.Vector3(0, 1, 0), 4);
    let leftArm = createCube(skinTexture,
        slim ? 3 : 4, 12, 4,
        texturePositions.leftArm[v],
        slim,
        "leftArm"
    );
    leftArm.translateOnAxis(new THREE.Vector3(0, 1, 0), -4);
    leftArmGroup.add(leftArm);
    if (v >= 1) {
        let leftSleeve = createCube(skinTexture,
            slim ? 3.504 : 4.504, 12.504, 4.504,
            texturePositions.leftSleeve,
            slim,
            "leftSleeve",
            true
        );
        leftSleeve.translateOnAxis(new THREE.Vector3(0, 1, 0), -4);
        leftArmGroup.add(leftSleeve);
    }

    let rightArmGroup = new THREE.Object3D();
    rightArmGroup.name = "rightArmGroup";
    rightArmGroup.position.x = slim ? 5.5 : 6;
    rightArmGroup.position.y = 18;
    rightArmGroup.position.z = 0;
    rightArmGroup.translateOnAxis(new THREE.Vector3(0, 1, 0), 4);
    let rightArm = createCube(skinTexture,
        slim ? 3 : 4, 12, 4,
        texturePositions.rightArm[v],
        slim,
        "rightArm"
    );
    rightArm.translateOnAxis(new THREE.Vector3(0, 1, 0), -4);
    rightArmGroup.add(rightArm);
    if (v >= 1) {
        let rightSleeve = createCube(skinTexture,
            slim ? 3.504 : 4.504, 12.504, 4.504,
            texturePositions.rightSleeve,
            slim,
            "rightSleeve",
            true
        );
        rightSleeve.translateOnAxis(new THREE.Vector3(0, 1, 0), -4);
        rightArmGroup.add(rightSleeve);
    }

    let leftLegGroup = new THREE.Object3D();
    leftLegGroup.name = "leftLegGroup";
    leftLegGroup.position.x = -2;
    leftLegGroup.position.y = 6;
    leftLegGroup.position.z = 0;
    leftLegGroup.translateOnAxis(new THREE.Vector3(0, 1, 0), 4);
    let leftLeg = createCube(skinTexture,
        4, 12, 4,
        texturePositions.leftLeg[v],
        slim,
        "leftLeg"
    );
    leftLeg.translateOnAxis(new THREE.Vector3(0, 1, 0), -4);
    leftLegGroup.add(leftLeg);
    if (v >= 1) {
        let leftTrousers = createCube(skinTexture,
            4.504, 12.504, 4.504,
            texturePositions.leftTrousers,
            slim,
            "leftTrousers",
            true
        );
        leftTrousers.translateOnAxis(new THREE.Vector3(0, 1, 0), -4);
        leftLegGroup.add(leftTrousers);
    }

    let rightLegGroup = new THREE.Object3D();
    rightLegGroup.name = "rightLegGroup";
    rightLegGroup.position.x = 2;
    rightLegGroup.position.y = 6;
    rightLegGroup.position.z = 0;
    rightLegGroup.translateOnAxis(new THREE.Vector3(0, 1, 0), 4);
    let rightLeg = createCube(skinTexture,
        4, 12, 4,
        texturePositions.rightLeg[v],
        slim,
        "rightLeg"
    );
    rightLeg.translateOnAxis(new THREE.Vector3(0, 1, 0), -4);
    rightLegGroup.add(rightLeg);
    if (v >= 1) {
        let rightTrousers = createCube(skinTexture,
            4.504, 12.504, 4.504,
            texturePositions.rightTrousers,
            slim,
            "rightTrousers",
            true
        );
        rightTrousers.translateOnAxis(new THREE.Vector3(0, 1, 0), -4);
        rightLegGroup.add(rightTrousers);
    }

    let playerGroup = new THREE.Object3D();
    playerGroup.add(headGroup);
    playerGroup.add(bodyGroup);
    playerGroup.add(leftArmGroup);
    playerGroup.add(rightArmGroup);
    playerGroup.add(leftLegGroup);
    playerGroup.add(rightLegGroup);

    if (capeTexture) {
        console.log(texturePositions);
        let capeTextureCoordinates = texturePositions.capeRelative;
        if (capeType === "optifine" && capeTexture.image.height > 24) { // 'classic' OF capes are the same size as the official capes, just the custom ones are double sized
            capeTextureCoordinates = texturePositions.capeOptifineRelative;
        }
        if (capeType === "labymod") {
            capeTextureCoordinates = texturePositions.capeLabymodRelative;
        }
        capeTextureCoordinates = JSON.parse(JSON.stringify(capeTextureCoordinates)); // bad clone to keep the below scaling from affecting everything

        console.log(capeTextureCoordinates);

        // Multiply coordinates by image dimensions
        for (let cord in capeTextureCoordinates) {
            capeTextureCoordinates[cord].x *= capeTexture.image.width;
            capeTextureCoordinates[cord].w *= capeTexture.image.width;
            capeTextureCoordinates[cord].y *= capeTexture.image.height;
            capeTextureCoordinates[cord].h *= capeTexture.image.height;
        }

        console.log(capeTextureCoordinates);

        let capeGroup = new THREE.Object3D();
        capeGroup.name = "capeGroup";
        capeGroup.position.x = 0;
        capeGroup.position.y = 16;
        capeGroup.position.z = -2.5;
        capeGroup.translateOnAxis(new THREE.Vector3(0, 1, 0), 8);
        capeGroup.translateOnAxis(new THREE.Vector3(0, 0, 1), 0.5);
        let cape = createCube(capeTexture,
            10, 16, 1,
            capeTextureCoordinates,
            false,
            "cape");
        cape.rotation.x = toRadians(10); // slight backward angle
        cape.translateOnAxis(new THREE.Vector3(0, 1, 0), -8);
        cape.translateOnAxis(new THREE.Vector3(0, 0, 1), -0.5);
        cape.rotation.y = toRadians(180); // flip front&back to be correct
        capeGroup.add(cape)

        playerGroup.add(capeGroup);
    }

    return playerGroup;
};

// From https://soledadpenades.com/articles/three-js-tutorials/drawing-the-coordinate-axes/
function buildAxes(length) {
    let axes = new THREE.Object3D();

    axes.add(buildAxis(new THREE.Vector3(0, 0, 0), new THREE.Vector3(length, 0, 0), 0xFF0000, false)); // +X
    axes.add(buildAxis(new THREE.Vector3(0, 0, 0), new THREE.Vector3(-length, 0, 0), 0xFF0000, true)); // -X
    axes.add(buildAxis(new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, length, 0), 0x00FF00, false)); // +Y
    axes.add(buildAxis(new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, -length, 0), 0x00FF00, true)); // -Y
    axes.add(buildAxis(new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, length), 0x0000FF, false)); // +Z
    axes.add(buildAxis(new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, -length), 0x0000FF, true)); // -Z

    return axes;

};

function buildAxis(src, dst, colorHex, dashed) {
    let geom = new THREE.Geometry(),
        mat;

    if (dashed) {
        mat = new THREE.LineDashedMaterial({linewidth: 3, color: colorHex, dashSize: 3, gapSize: 3});
    } else {
        mat = new THREE.LineBasicMaterial({linewidth: 3, color: colorHex});
    }

    geom.vertices.push(src.clone());
    geom.vertices.push(dst.clone());
    geom.computeLineDistances(); // This one is SUPER important, otherwise dashed lines will appear as simple plain lines

    return new THREE.Line(geom, mat, THREE.LinePieces);
};

function toRadians(angle) {
    return angle * (Math.PI / 180);
}

function getJSON(url, callback) {
    let xhr = new XMLHttpRequest();
    xhr.open('GET', url, true);
    xhr.responseType = 'json';
    xhr.onload = function () {
        let status = xhr.status;
        let r = xhr.response || xhr.responseText;
        if (typeof r === "string") {
            r = JSON.parse(r);
        }
        if (status === 200) {
            callback(null, r);
        } else {
            callback(xhr.statusText, r);
        }
    };
    xhr.send();
}

if (typeof window !== "undefined")
    window.SkinRender = SkinRender;
if (typeof global !== "undefined")
    global.SkinRender = SkinRender;

export default SkinRender;