systems/render.system.js

import {Shader, Sprite, Stage, System, Vector2, Vector3} from '../index.js';

/**
 * Creates render systems.
 *
 * @example
 *
 * const system = new SystemRender({$color, $container, $resolution});
 * system.initiate();
 * system.tick($stage);
 */
class SystemRender extends System {

    /**
     * Stores the texture unit for the textures to preload.
     * @type {0}
     * @public
     * @readonly
     * @static
     */
    static UNITTEXTURE0 = 0;

    /**
     * Stores the texture unit for the color textures.
     * @type {1}
     * @public
     * @readonly
     * @static
     */
    static UNITTEXTURE1 = 1;

    /**
     * Stores the texture unit for the opacity textures.
     * @type {2}
     * @public
     * @readonly
     * @static
     */
    static UNITTEXTURE2 = 2;

    /**
     * Stores the common vertices positions of the sprites.
     * @type {WebGLBuffer}
     * @private
     */
    $bufferPosition;

    /**
     * Stores the cache of the texture assets.
     * @type {Map<string, WebGLTexture>}
     * @private
     */
    $cache;

    /**
     * Stores the canvas element.
     * @type {HTMLCanvasElement}
     * @private
     */
    $canvas;

    /**
     * Stores the background color.
     * @type {Vector3}
     * @private
     */
    $color;

    /**
     * Stores the container.
     * @type {HTMLElement}
     * @private
     */
    $container;

    /**
     * Stores the canvas context.
     * @type {WebGL2RenderingContext}
     * @private
     */
    $context;

    /**
     * Stores the number of indices of the vertices positions of the sprites.
     * @type {number}
     * @private
     */
    $indices;

    /**
     * Stores the shader program attribute locations.
     * @type {Object<string, number>}
     * @private
     */
    $locationsAttribute;

    /**
     * Stores the shader program uniform locations.
     * @type {Object<string, WebGLUniformLocation>}
     * @private
     */
    $locationsUniform;

    /**
     * Stores the mapping between the texture sources and their uvmappings.
     * @type {Object<string, WebGLBuffer>}
     * @private
     */
    $mappingBuffersUv;

    /**
     * Stores the shader program.
     * @type {WebGLProgram}
     * @private
     */
    $program;

    /**
     * Stores the ResizeObserver.
     * @type {ResizeObserver}
     * @private
     */
    $resizeOberver;

    /**
     * Stores the resolution.
     * @type {Vector2}
     * @private
     */
    $resolution;

    /**
     * Stores the fragment shader.
     * @type {WebGLShader}
     * @private
     */
    $shaderFragment;

    /**
     * Stores the vertex shader.
     * @type {WebGLShader}
     * @private
     */
    $shaderVertex;

    /**
     * Stores the texture of the default color texture source.
     * @type {WebGLTexture}
     * @private
     */
    $textureColorDefault;

    /**
     * Stores the texture of the default opacity texture source.
     * @type {WebGLTexture}
     * @private
     */
    $textureOpacityDefault;

    /**
     * Creates a new render system.
     * @param {Object} $parameters The given parameters.
     * @param {Vector3} [$parameters.$color] The rendering background color to use.
     * @param {HTMLElement} $parameters.$container The container on which to attach the canvas.
     * @param {Vector2} $parameters.$resolution The rendering resolution to use.
     */
    constructor({$color = new Vector3(0, 0, 0), $container, $resolution}) {

        super();

        this.$color = $color;
        this.$container = $container;
        this.$resolution = $resolution;
    }

    /**
     * Creates the common vertices positions of the sprites.
     * @private
     */
    $createBufferPositions() {

        const positions = [

            -0.5, -0.5,
            -0.5, 0.5,
            0.5, 0.5,
            0.5, -0.5
        ];

        const bufferPosition = this.$context.createBuffer();
        this.$context.bindBuffer(this.$context.ARRAY_BUFFER, bufferPosition);
        this.$context.bufferData(this.$context.ARRAY_BUFFER, new Float32Array(positions), this.$context.STATIC_DRAW);

        this.$bufferPosition = bufferPosition;
    }

    /**
     * Creates the uvmapping from the given sprite.
     * @param {Sprite} $sprite The sprite.
     * @private
     */
    $createBufferUvsOnce($sprite) {

        if (typeof this.$mappingBuffersUv[$sprite.frameSourceSerialized] !== 'undefined') {

            return;
        }

        const frame = $sprite.frameSource;

        const uvs = [

            frame.minimum.x, frame.maximum.y,
            frame.minimum.x, frame.minimum.y,
            frame.maximum.x, frame.minimum.y,
            frame.maximum.x, frame.maximum.y
        ];

        const bufferUv = this.$context.createBuffer();
        this.$context.bindBuffer(this.$context.ARRAY_BUFFER, bufferUv);
        this.$context.bufferData(this.$context.ARRAY_BUFFER, new Float32Array(uvs), this.$context.STATIC_DRAW);

        this.$mappingBuffersUv[$sprite.frameSourceSerialized] = bufferUv;
    }

    /**
     * Creates the indices of the vertices positions of the sprites.
     * @private
     */
    $createIndices() {

        const indices = [

            0,
            1,
            2,
            3
        ];

        const bufferIndex = this.$context.createBuffer();
        this.$context.bindBuffer(this.$context.ELEMENT_ARRAY_BUFFER, bufferIndex);
        this.$context.bufferData(this.$context.ELEMENT_ARRAY_BUFFER, new Uint32Array(indices), this.$context.STATIC_DRAW);

        this.$indices = indices.length;
    }

    /**
     * Creates the attributes locations to use by the shader program.
     * @param {WebGLProgram} $program The shader program.
     * @param {typeof Shader} $shader The representation of the shader.
     * @private
     */
    $createLocationsAttribute($program, $shader) {

        Object.keys($shader.attributes).forEach(($name) => {

            this.$locationsAttribute[$name] = this.$context.getAttribLocation($program, $name);
        });
    }

    /**
     * Creates the uniform locations to use by the shader program.
     * @param {WebGLProgram} $program The shader program.
     * @param {typeof Shader} $shader The representation of the shader.
     * @private
     */
    $createLocationsUniform($program, $shader) {

        Object.keys($shader.uniforms).forEach(($name) => {

            this.$locationsUniform[$name] = this.$context.getUniformLocation($program, $name);
        });
    }

    /**
     * Creates the shader program.
     * @param {typeof Shader} $shader The representation of the shader.
     * @private
     */
    $createProgram($shader) {

        this.$shaderVertex = this.$context.createShader(this.$context.VERTEX_SHADER);
        this.$context.shaderSource(this.$shaderVertex, $shader.sourceVertex);
        this.$context.compileShader(this.$shaderVertex);

        this.$shaderFragment = this.$context.createShader(this.$context.FRAGMENT_SHADER);
        this.$context.shaderSource(this.$shaderFragment, $shader.sourceFragment);
        this.$context.compileShader(this.$shaderFragment);

        this.$program = this.$context.createProgram();
        this.$context.attachShader(this.$program, this.$shaderVertex);
        this.$context.attachShader(this.$program, this.$shaderFragment);
        this.$context.linkProgram(this.$program);
    }

    /**
     * Creates a texture from the given bitmap texture data.
     * @param {ImageBitmap} $textureBitmap The bitmap texture data.
     * @param {number} $unitTexture The target texture unit.
     * @returns {WebGLTexture}
     * @private
     */
    $createTexture($textureBitmap, $unitTexture) {

        const texture = this.$context.createTexture();

        this.$context.activeTexture($unitTexture);
        this.$context.bindTexture(this.$context.TEXTURE_2D, texture);

        this.$context.texParameteri(this.$context.TEXTURE_2D, this.$context.TEXTURE_MIN_FILTER, this.$context.NEAREST);
        this.$context.texParameteri(this.$context.TEXTURE_2D, this.$context.TEXTURE_MAG_FILTER, this.$context.NEAREST);
        this.$context.texParameteri(this.$context.TEXTURE_2D, this.$context.TEXTURE_WRAP_S, this.$context.CLAMP_TO_EDGE);
        this.$context.texParameteri(this.$context.TEXTURE_2D, this.$context.TEXTURE_WRAP_T, this.$context.CLAMP_TO_EDGE);

        this.$context.texImage2D(this.$context.TEXTURE_2D, 0, this.$context.RGBA, this.$context.RGBA, this.$context.UNSIGNED_BYTE, $textureBitmap);

        return texture;
    }

    /**
     * Creates a default texture (1 pixel texture).
     * @param {Vector3} $color The target texture unit.
     * @param {number} $unitTexture The target texture unit.
     * @returns {WebGLTexture}
     * @private
     */
    $createTextureDefault($color, $unitTexture) {

        const texture = this.$context.createTexture();

        this.$context.activeTexture(this.$context.TEXTURE0 + $unitTexture);
        this.$context.bindTexture(this.$context.TEXTURE_2D, texture);
        this.$context.texImage2D(this.$context.TEXTURE_2D, 0, this.$context.RGBA, 1, 1, 0, this.$context.RGBA, this.$context.UNSIGNED_BYTE, new Uint8Array([$color.x, $color.y, $color.z, 255]));

        return texture;
    }

    /**
     * Initiates the canvas element.
     * @private
     */
    $initiateCanvas() {

        this.$canvas = document.createElement('canvas');
        this.$canvas.style.width = '100%';
        this.$canvas.style.height = '100%';
        this.$canvas.style.display = 'block';
        this.$canvas.style.outline = '0';
        this.$canvas.style.imageRendering = 'pixelated';

        this.$container.appendChild(this.$canvas);

        this.$resize();
    }

    /**
     * Initiates the canvas context.
     * @private
     */
    $initiateContext() {

        this.$context = this.$canvas.getContext('webgl2', {

            'antialias': false
        });

        this.$context.frontFace(this.$context.CW);
        this.$context.enable(this.$context.CULL_FACE);
        this.$context.cullFace(this.$context.BACK);

        this.$context.enable(this.$context.BLEND);
        this.$context.blendFunc(this.$context.SRC_ALPHA, this.$context.ONE_MINUS_SRC_ALPHA);

        this.$createProgram(Shader);

        this.$context.useProgram(this.$program);

        this.$createLocationsUniform(this.$program, Shader);
        this.$createLocationsAttribute(this.$program, Shader);

        this.$createBufferPositions();
        this.$createIndices();

        this.$textureColorDefault = this.$createTextureDefault(new Vector3(127, 127, 127), SystemRender.UNITTEXTURE1);
        this.$textureOpacityDefault = this.$createTextureDefault(new Vector3(255, 255, 255), SystemRender.UNITTEXTURE2);

        window.addEventListener('beforeunload', this.$onBeforeUnload.bind(this));
    }

    /**
     * Loads the texture from the given texture file content.
     * @param {Response} $content The texture file content.
     * @param {number} $unitTexture The target texture unit.
     * @returns {Promise<WebGLTexture>}
     * @private
     */
    $loadTexture($content, $unitTexture) {

        const promise = new Promise(($resolve) => {

            $content.blob()
            .then(($blob) => (createImageBitmap($blob)))
            .then(($textureBitmap) => {

                const texture = this.$createTexture($textureBitmap, $unitTexture);

                this.$cache.set($content.url, texture);

                $resolve(texture);
            });
        });

        return promise;
    }

    /**
     * Called when the scope is about to be unloaded.
     * @private
     */
    $onBeforeUnload() {

        if (this.$context instanceof WebGL2RenderingContext === false) {

            return;
        }

        if (this.$context.getExtension('WEBGL_lose_context') === null) {

            return;
        }

        this.$context.getExtension('WEBGL_lose_context').loseContext();
    }

    /**
     * Prepares the texture from the given texture source.
     * @param {string} $texture The texture source.
     * @param {number} $unitTexture The target texture unit.
     * @private
     */
    $prepareTexture($texture, $unitTexture) {

        if (this.$cache.has($texture) === true) {

            return;
        }

        this.$cache.set($texture, undefined);

        fetch($texture)
        .then(($content) => (this.$loadTexture($content, $unitTexture)));
    }

    /**
     * Resets the canvas.
     * @param {number} $width The context viewport width.
     * @param {number} $height The context viewport height.
     * @private
     */
    $resetCanvas($width, $height) {

        this.$context.clearColor(this.$color.x, this.$color.y, this.$color.z, 1);
        this.$context.clearDepth(1);

        this.$context.viewport(0, 0, $width, $height);
        this.$context.clear(this.$context.COLOR_BUFFER_BIT | this.$context.DEPTH_BUFFER_BIT);
    }

    /**
     * Resizes the rendering context.
     * @private
     */
    $resize() {

        const width = this.$resolution.x;
        const height = this.$resolution.y;

        const widthContext = Math.max(width, Math.floor(height * this.$canvas.clientWidth / this.$canvas.clientHeight));
        const heightContext = Math.max(height, Math.floor(width * this.$canvas.clientHeight / this.$canvas.clientWidth));

        this.$canvas.setAttribute('width', '' + Math.floor(widthContext / 2) * 2);
        this.$canvas.setAttribute('height', '' + Math.floor(heightContext / 2) * 2);
    }

    /**
     * Sends an attribute to the shader program.
     * @param {typeof Shader} $shader The representation of the shader.
     * @param {string} $name The name of the attribute.
     * @param {any} $value The value of the attribute.
     * @private
     */
    $sendAttribute($shader, $name, $value) {

        if (typeof $shader.attributes[$name] === 'undefined') {

            return;
        }

        const type = $shader.attributes[$name];

        switch (type) {

            case 'vec2': {

                this.$context.bindBuffer(this.$context.ARRAY_BUFFER, $value);
                const location = this.$locationsAttribute[$name];
                this.$context.vertexAttribPointer(location, 2, this.$context.FLOAT, false, 0, 0);
                this.$context.enableVertexAttribArray(location);

                break;
            }

            case 'vec3': {

                this.$context.bindBuffer(this.$context.ARRAY_BUFFER, $value);
                const location = this.$locationsAttribute[$name];
                this.$context.vertexAttribPointer(location, 3, this.$context.FLOAT, false, 0, 0);
                this.$context.enableVertexAttribArray(location);

                break;
            }
        }
    }

    /**
     * Sends a uniform to the shader program.
     * @param {typeof Shader} $shader The representation of the shader.
     * @param {string} $name The name of the uniform.
     * @param {any} $value The value of the uniform.
     * @private
     */
    $sendUniform($shader, $name, $value) {

        if (typeof $shader.uniforms[$name] === 'undefined') {

            return;
        }

        const type = $shader.uniforms[$name];

        switch (type) {

            case 'bool':
            case 'int':
            case 'sampler2D': {

                this.$context.uniform1i(this.$locationsUniform[$name], $value);

                break;
            }

            case 'bool[]':
            case 'int[]': {

                this.$context.uniform1iv(this.$locationsUniform[$name], $value);

                break;
            }

            case 'float': {

                this.$context.uniform1f(this.$locationsUniform[$name], $value);

                break;
            }

            case 'float[]': {

                this.$context.uniform1fv(this.$locationsUniform[$name], $value);

                break;
            }

            case 'mat4':
            case 'mat4[]': {

                this.$context.uniformMatrix4fv(this.$locationsUniform[$name], false, $value);

                break;
            }

            case 'vec2':
            case 'vec2[]': {

                this.$context.uniform2fv(this.$locationsUniform[$name], $value);

                break;
            }

            case 'vec3':
            case 'vec3[]': {

                this.$context.uniform3fv(this.$locationsUniform[$name], $value);

                break;
            }
        }
    }

    /**
     * Terminates the canvas context.
     * @private
     */
    $terminateContext() {

        this.$context.deleteBuffer(this.$bufferPosition);

        Object.values(this.$mappingBuffersUv).forEach(($buffer) => {

            this.$context.deleteBuffer($buffer);
        });

        this.$context.deleteTexture(this.$textureColorDefault);
        this.$context.deleteTexture(this.$textureOpacityDefault);

        this.$cache.forEach(($texture) => {

            this.$context.deleteTexture($texture);
        });

        this.$context.deleteShader(this.$shaderFragment);
        this.$context.deleteShader(this.$shaderVertex);

        this.$context.deleteProgram(this.$program);

        this.$context = undefined;
    }

    /**
     * Checks if the system has loaded the given asset.
     * @param {string} $asset The asset source.
     * @returns {boolean}
     * @public
     */
    hasAssetLoaded($asset) {

        if (this.$initiated === false) {

            this.initiate();
        }

        return this.$cache.has($asset) === true;
    }

    /**
     * Loads the texture from the given texture file content.
     * @param {Response} $content The texture file content.
     * @returns {Promise<WebGLTexture>}
     * @public
     */
    loadTexture($content) {

        if (this.$initiated === false) {

            this.initiate();
        }

        if (this.$cache.has($content.url) === true) {

            const promise = new Promise(($resolve) => {

                const texture = this.$cache.get($content.url);

                $resolve(texture);
            });

            return promise;
        }

        this.$cache.set($content.url, undefined);

        return this.$loadTexture($content, this.$context.TEXTURE0 + SystemRender.UNITTEXTURE0);
    }

    /**
     * Called when the system is being initiated.
     * @public
     */
    onInitiate() {

        this.$cache = new Map();
        this.$indices = 0;
        this.$locationsAttribute = {};
        this.$locationsUniform = {};
        this.$mappingBuffersUv = {};

        this.$initiateCanvas();
        this.$initiateContext();

        this.$resizeOberver = new ResizeObserver(this.$resize.bind(this));
        this.$resizeOberver.observe(this.$container);
    }

    /**
     * Called when the system is being terminated.
     * @returns {(void | Promise<void>)}
     * @public
     */
    onTerminate() {

        this.$terminateContext();

        this.$resizeOberver.disconnect();
        this.$container.removeChild(this.$canvas);

        window.removeEventListener('beforeunload', this.$onBeforeUnload.bind(this));
    }

    /**
     * Called when the system is being updated by one tick update.
     * @param {Object} $parameters The given parameters.
     * @param {Stage} $parameters.$stage The stage on which to execute the system.
     * @param {number} $parameters.$timetick The tick duration (in ms).
     * @public
     */
    onTick({$stage}) {

        this.$resetCanvas(this.$canvas.width, this.$canvas.height);

        this.$sendUniform(Shader, 'uniformAspect', [this.$canvas.width, this.$canvas.height]);
        this.$sendUniform(Shader, 'uniformTranslationPointOfView', [Math.floor($stage.pointOfView.translation.x), Math.floor($stage.pointOfView.translation.y)]);

        this.$sendAttribute(Shader, 'attributePosition', this.$bufferPosition);

        const actors = [...$stage.actors];

        actors.sort(($a, $b) => {

            return $a.zIndex - $b.zIndex;
        });

        actors.forEach(($actor) => {

            if ($actor.hasSprite() === false) {

                return;
            }

            if ($actor.visible === false) {

                return;
            }

            let textureColor = this.$textureColorDefault;

            this.$prepareTexture($actor.sprite.textureColor, this.$context.TEXTURE0 + SystemRender.UNITTEXTURE1);

            if (typeof this.$cache.get($actor.sprite.textureColor) !== 'undefined') {

                textureColor = this.$cache.get($actor.sprite.textureColor);
            }

            this.$context.activeTexture(this.$context.TEXTURE0 + SystemRender.UNITTEXTURE1);
            this.$context.bindTexture(this.$context.TEXTURE_2D, textureColor);
            this.$sendUniform(Shader, 'uniformTextureColor', SystemRender.UNITTEXTURE1);

            let textureOpacity = this.$textureOpacityDefault;

            if (typeof $actor.sprite.textureOpacity !== 'undefined') {

                this.$prepareTexture($actor.sprite.textureOpacity, this.$context.TEXTURE0 + SystemRender.UNITTEXTURE2);

                if (typeof this.$cache.get($actor.sprite.textureOpacity) !== 'undefined') {

                    textureOpacity = this.$cache.get($actor.sprite.textureOpacity);
                }
            }

            this.$context.activeTexture(this.$context.TEXTURE0 + SystemRender.UNITTEXTURE2);
            this.$context.bindTexture(this.$context.TEXTURE_2D, textureOpacity);
            this.$sendUniform(Shader, 'uniformTextureOpacity', SystemRender.UNITTEXTURE2);

            this.$sendUniform(Shader, 'uniformSize', [$actor.sprite.sizeTarget.x, $actor.sprite.sizeTarget.y]);
            this.$sendUniform(Shader, 'uniformTranslation', [Math.floor($actor.translation.x), Math.floor($actor.translation.y)]);

            this.$createBufferUvsOnce($actor.sprite);
            this.$sendAttribute(Shader, 'attributeUvmapping', this.$mappingBuffersUv[$actor.sprite.frameSourceSerialized]);

            this.$context.drawElements(this.$context.TRIANGLE_FAN, this.$indices, this.$context.UNSIGNED_INT, 0);
        });
    }

    /**
     * Sets the rendering background color.
     * @param {Vector3} $color The rendering background color to set.
     * @public
     */
    setColor($color) {

        this.$color = $color;
    }

    /**
     * Sets the rendering resolution.
     * @param {Vector2} $resolution The rendering resolution to set.
     * @public
     */
    setResolution($resolution) {

        this.$resolution = $resolution.clone();

        this.$resize();
    }
}

export {

    SystemRender
};

export default SystemRender;