BareGit
/** Fetch one text resource synchronously during demo initialization. */
function loadTextResource(url)
{
    const request = new XMLHttpRequest();
    request.open("GET", url, false);
    request.send(null);
    if(request.status != 200 && request.status != 0)
    {
        throw(new Error(`Failed to load ${url}: HTTP ${request.status}`));
    }
    return request.responseText;
}

/** Insert validated compile-time numeric definitions after GLSL #version. */
function defineShaderConstants(source, definitions)
{
    const version_end = source.indexOf("\n");
    if(!source.startsWith("#version ") || version_end < 0)
    {
        throw(new Error("Shader source does not begin with #version."));
    }

    let definition_source = "";
    for(const [name, value] of Object.entries(definitions))
    {
        if(!/^[A-Z][A-Z0-9_]*$/.test(name) ||
           !Number.isFinite(value) || value < 0.0)
        {
            throw(new Error(`Invalid shader definition: ${name}=${value}`));
        }
        definition_source += `#define ${name} ${value}\n`;
    }
    return source.slice(0, version_end + 1) + definition_source
         + source.slice(version_end + 1);
}

/** Compile one shader and include its URL in any diagnostic. */
function compileShader(gl, source, shader_type, url)
{
    const shader = gl.createShader(shader_type);
    gl.shaderSource(shader, source);
    gl.compileShader(shader);
    if(!gl.getShaderParameter(shader, gl.COMPILE_STATUS))
    {
        const message = gl.getShaderInfoLog(shader);
        gl.deleteShader(shader);
        throw(new Error(`Failed to compile ${url}: ${message}`));
    }
    return shader;
}

/** Link two compiled shaders into one WebGL program. */
function linkProgram(gl, vertex_shader, fragment_shader,
                     vertex_url, fragment_url)
{
    const program = gl.createProgram();
    gl.attachShader(program, vertex_shader);
    gl.attachShader(program, fragment_shader);
    gl.linkProgram(program);
    if(!gl.getProgramParameter(program, gl.LINK_STATUS))
    {
        const message = gl.getProgramInfoLog(program);
        gl.deleteProgram(program);
        throw(new Error(
            `Failed to link ${vertex_url} with ${fragment_url}: ${message}`));
    }
    return program;
}

/** Own a linked shader program and cache required variable locations. */
class ShaderProgram
{
    /** Compile and link a program from vertex and fragment source URLs. */
    constructor(gl, vertex_url, fragment_url, fragment_definitions = {})
    {
        const vertex_shader = compileShader(
            gl, loadTextResource(vertex_url), gl.VERTEX_SHADER, vertex_url);
        const fragment_source = defineShaderConstants(
            loadTextResource(fragment_url), fragment_definitions);
        const fragment_shader = compileShader(
            gl, fragment_source, gl.FRAGMENT_SHADER, fragment_url);
        this.program = linkProgram(
            gl, vertex_shader, fragment_shader, vertex_url, fragment_url);
        gl.deleteShader(vertex_shader);
        gl.deleteShader(fragment_shader);

        this.attribute_locations = new Map();
        this.uniform_locations = new Map();
        this.gl = gl;
    }

    /** Bind this program for subsequent drawing. */
    use()
    {
        this.gl.useProgram(this.program);
    }

    /** Return a required cached uniform location or throw. */
    uniform(name)
    {
        if(!this.uniform_locations.has(name))
        {
            const location = this.gl.getUniformLocation(this.program, name);
            this.uniform_locations.set(name, location);
        }
        const location = this.uniform_locations.get(name);
        if(location == null)
        {
            throw(new Error(`Required uniform not found: ${name}`));
        }
        return location;
    }

    /** Return a cached uniform location, or null when it is not active. */
    optionalUniform(name)
    {
        if(!this.uniform_locations.has(name))
        {
            const location = this.gl.getUniformLocation(this.program, name);
            this.uniform_locations.set(name, location);
        }
        return this.uniform_locations.get(name);
    }

    /** Return a required cached attribute location or throw. */
    attribute(name)
    {
        if(!this.attribute_locations.has(name))
        {
            const location = this.gl.getAttribLocation(this.program, name);
            if(location < 0)
            {
                throw(new Error(`Required attribute not found: ${name}`));
            }
            this.attribute_locations.set(name, location);
        }
        return this.attribute_locations.get(name);
    }
}

/** Resize the drawing buffer and viewport to the displayed canvas size. */
function resizeCanvas(canvas, gl)
{
    const width = canvas.clientWidth;
    const height = canvas.clientHeight;
    if(canvas.width != width || canvas.height != height)
    {
        canvas.width = width;
        canvas.height = height;
    }
    gl.viewport(0, 0, width, height);
}

/** Own a complete set of buffered vertex inputs for a drawable. */
class VertexArray
{
    /** Create an initially empty vertex array. */
    constructor(gl)
    {
        this.vertex_array = gl.createVertexArray();
        this.buffers = [];
        this.gl = gl;
    }

    /** Add one buffered attribute to this vertex array. */
    addAttribute(program, attribute_name, data, {
        component_count = 3,
        num_type = this.gl.FLOAT,
        normalize = false,
        stride = 0,
        offset = 0,
    })
    {
        const buffer = this.gl.createBuffer();
        const attribute_ref = program.attribute(attribute_name);

        this.gl.bindVertexArray(this.vertex_array);
        this.gl.bindBuffer(this.gl.ARRAY_BUFFER, buffer);
        this.gl.bufferData(this.gl.ARRAY_BUFFER, data, this.gl.STATIC_DRAW);
        this.gl.enableVertexAttribArray(attribute_ref);
        this.gl.vertexAttribPointer(attribute_ref, component_count, num_type,
                                    normalize, stride, offset);
        this.buffers.push(buffer);
    }

    /** Bind this vertex array for drawing. */
    use()
    {
        this.gl.bindVertexArray(this.vertex_array);
    }
}

/** Color-space behavior for image texture resources. */
const TEXTURE_ROLE = Object.freeze({
    COLOR: "color",
    DATA: "data",
});

/** Return decode transforms for an uploaded browser image. */
function bitmapDecodeOptions(flip_y, role)
{
    return {
        imageOrientation: flip_y ? "flipY" : "none",
        premultiplyAlpha: "none",
        colorSpaceConversion:
            role == TEXTURE_ROLE.DATA ? "none" : "default",
    };
}

/** Shader material modes shared by every declarative material. */
const MATERIAL_KIND = Object.freeze({
    PHYSICAL_FOIL: 0,
    SOLID_COLOR: 1,
});

/** Maximum decoded pixels accepted for one browser image texture. */
const MAX_CARD_TEXTURE_PIXELS = 24 * 1024 * 1024;

/** Own one asynchronously loaded image texture. */
class Texture
{
    /** Create a texture and start loading its image. */
    constructor(gl, url, {
        cross_origin = null,
        flip_y = false,
        placeholder_color = null,
        role = TEXTURE_ROLE.COLOR,
    } = {})
    {
        if(!Object.values(TEXTURE_ROLE).includes(role))
        {
            throw(new Error(`Unknown texture role: ${role}`));
        }
        if(placeholder_color == null)
        {
            placeholder_color = role == TEXTURE_ROLE.DATA
                ? [0, 0, 0, 0]
                : [0, 0, 255, 255];
        }

        this.texture = gl.createTexture();
        this.load_listeners = [];
        this.width = 0;
        this.height = 0;
        this.flip_y = flip_y;
        this.role = role;
        this.url = url;
        this.cross_origin = cross_origin;
        this.gl = gl;
        this.disposed = false;
        this.is_constant = false;
        this.ready = new Promise(function storeTextureCompletion(
            resolve, reject)
        {
            this.resolve_load = resolve;
            this.reject_load = reject;
        }.bind(this));

        gl.activeTexture(gl.TEXTURE0);
        gl.bindTexture(gl.TEXTURE_2D, this.texture);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, 1, 1, 0, gl.RGBA,
                      gl.UNSIGNED_BYTE,
                      new Uint8Array(placeholder_color));

        this.image = null;
        if(url != null)
        {
            this.image = new Image();
            this.handle_load = this.handleLoad.bind(this);
            this.handle_error = this.handleError.bind(this);
            this.image.addEventListener("load", this.handle_load);
            this.image.addEventListener("error", this.handle_error);
            if(cross_origin != null)
            {
                this.image.crossOrigin = cross_origin;
            }
            this.image.src = url;
        }
    }

    /** Decode and upload one ephemeral browser file without creating a URL. */
    static async fromFile(gl, file, options = {})
    {
        // WebGL ignores UNPACK_FLIP_Y_WEBGL for ImageBitmap sources. Apply
        // the requested orientation while creating the bitmap, then prevent
        // Texture.upload() from attempting the same transformation again.
        const upload_options = Object.assign({}, options, {flip_y: false});
        const texture = new Texture(gl, null, upload_options);
        let bitmap = null;
        try
        {
            bitmap = await createImageBitmap(
                file, bitmapDecodeOptions(options.flip_y, options.role));
            texture.upload(bitmap, bitmap.width, bitmap.height);
            return texture;
        }
        catch(error)
        {
            const wrapped_error = new Error(
                `Failed to process ${file.name}: ${error.message}`);
            texture.fail(wrapped_error);
            texture.ready.catch(function consumeFailedUpload() {});
            texture.dispose();
            throw(wrapped_error);
        }
        finally
        {
            if(bitmap != null)
            {
                bitmap.close();
            }
        }
    }

    /** Create a ready one-pixel texture from an explicit RGBA value. */
    static constant(gl, color, options = {})
    {
        const is_valid = Array.isArray(color) && color.length == 4
            && color.every(function validateTextureByte(value)
            {
                return Number.isInteger(value) && value >= 0 && value <= 255;
            });
        if(!is_valid)
        {
            throw(new Error(
                "Constant texture color must contain four bytes."));
        }
        const texture_options = Object.assign(
            {}, options, {placeholder_color: color});
        const texture = new Texture(gl, null, texture_options);
        texture.is_constant = true;
        texture.width = 1;
        texture.height = 1;
        texture.resolve_load(texture);
        return texture;
    }

    /** Replace a ready constant texture texel without reallocating it. */
    setConstantColor(color)
    {
        const is_valid = Array.isArray(color) && color.length == 4
            && color.every(function validateTextureByte(value)
            {
                return Number.isInteger(value)
                    && value >= 0 && value <= 255;
            });
        if(!this.is_constant || !is_valid || this.disposed)
        {
            throw(new Error(
                "Only a live constant texture accepts four color bytes."));
        }

        const gl = this.gl;
        const previous_active_texture = gl.getParameter(gl.ACTIVE_TEXTURE);
        gl.activeTexture(gl.TEXTURE0);
        const previous_texture = gl.getParameter(gl.TEXTURE_BINDING_2D);
        try
        {
            gl.bindTexture(gl.TEXTURE_2D, this.texture);
            gl.texSubImage2D(
                gl.TEXTURE_2D, 0, 0, 0, 1, 1, gl.RGBA,
                gl.UNSIGNED_BYTE, new Uint8Array(color));
        }
        finally
        {
            gl.bindTexture(gl.TEXTURE_2D, previous_texture);
            gl.activeTexture(previous_active_texture);
        }
    }

    /** Upload one decoded source while preserving global pixel-unpack state. */
    upload(source, width, height)
    {
        const gl = this.gl;
        const max_texture_size = gl.getParameter(gl.MAX_TEXTURE_SIZE);
        if(!Number.isInteger(width) || !Number.isInteger(height)
           || width <= 0 || height <= 0
           || width > max_texture_size || height > max_texture_size
           || width * height > MAX_CARD_TEXTURE_PIXELS)
        {
            throw(new Error(
                `Texture ${this.url || "upload"} has invalid dimensions `
                + `${width}x`
                + `${height}; maximum dimension is ${max_texture_size} and `
                + `maximum area is ${MAX_CARD_TEXTURE_PIXELS} pixels.`));
        }
        const previous_flip = gl.getParameter(gl.UNPACK_FLIP_Y_WEBGL);
        const previous_premultiply = gl.getParameter(
            gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL);
        const previous_color_space = gl.getParameter(
            gl.UNPACK_COLORSPACE_CONVERSION_WEBGL);
        const previous_active_texture = gl.getParameter(gl.ACTIVE_TEXTURE);
        gl.activeTexture(gl.TEXTURE0);
        const previous_texture = gl.getParameter(gl.TEXTURE_BINDING_2D);

        try
        {
            gl.bindTexture(gl.TEXTURE_2D, this.texture);
            gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, this.flip_y);
            gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
            if(this.role == TEXTURE_ROLE.DATA)
            {
                gl.pixelStorei(
                    gl.UNPACK_COLORSPACE_CONVERSION_WEBGL, gl.NONE);
            }
            gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, gl.RGBA,
                          gl.UNSIGNED_BYTE, source);

            if(this.role == TEXTURE_ROLE.COLOR)
            {
                gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER,
                                 gl.LINEAR_MIPMAP_LINEAR);
                gl.generateMipmap(gl.TEXTURE_2D);
            }
        }
        finally
        {
            gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, previous_flip);
            gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL,
                           previous_premultiply);
            gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL,
                           previous_color_space);
            gl.bindTexture(gl.TEXTURE_2D, previous_texture);
            gl.activeTexture(previous_active_texture);
        }

        this.width = width;
        this.height = height;
        this.resolve_load(this);
        for(const listener of this.load_listeners)
        {
            listener(this);
        }
    }

    /** Upload a successfully loaded URL image or reject its ready promise. */
    handleLoad(event)
    {
        try
        {
            this.upload(event.target, event.target.naturalWidth,
                        event.target.naturalHeight);
        }
        catch(error)
        {
            this.fail(error);
        }
    }

    /** Report an image-load failure with its complete URL. */
    handleError()
    {
        const cors_hint = this.cross_origin == null
            ? ""
            : " The image server must allow cross-origin access (CORS).";
        this.fail(new Error(
            `Failed to load texture: ${this.url}.${cors_hint}`));
    }

    /** Reject this texture's completion promise with one load error. */
    fail(error)
    {
        this.reject_load(error);
    }

    /** Run a callback after this texture has loaded. */
    onLoad(listener)
    {
        this.load_listeners.push(listener);
        if(this.width > 0 && this.height > 0)
        {
            listener(this);
        }
    }

    /** Bind this texture to a required sampler uniform. */
    use(program, uniform_name, unit)
    {
        this.gl.activeTexture(this.gl.TEXTURE0 + unit);
        this.gl.bindTexture(this.gl.TEXTURE_2D, this.texture);
        this.gl.uniform1i(program.uniform(uniform_name), unit);
    }

    /** Release this texture's GPU allocation exactly once. */
    dispose()
    {
        if(this.image != null)
        {
            this.image.removeEventListener("load", this.handle_load);
            this.image.removeEventListener("error", this.handle_error);
        }
        if(!this.disposed)
        {
            this.gl.deleteTexture(this.texture);
            this.disposed = true;
        }
        this.image = null;
    }
}

/** Own the D65-weighted CIE XYZ floating-point lookup texture. */
class SpectralLut
{
    /** Load, validate, and upload the binary spectral table. */
    constructor(gl, url)
    {
        this.texture = gl.createTexture();
        this.load_listeners = [];
        this.loaded = false;
        this.failed = false;
        this.pending_reported = false;
        this.url = url;
        this.gl = gl;
        this.ready = new Promise(function storeCompletion(resolve, reject)
        {
            this.resolve_load = resolve;
            this.reject_load = reject;
        }.bind(this));

        gl.activeTexture(gl.TEXTURE0);
        gl.bindTexture(gl.TEXTURE_2D, this.texture);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, 1, 1, 0, gl.RGBA,
                      gl.FLOAT, new Float32Array([0.0, 0.0, 0.0, 0.0]));

        const request = new XMLHttpRequest();
        request.open("GET", url, true);
        request.responseType = "arraybuffer";
        request.addEventListener("load", this.handleLoad.bind(this));
        request.addEventListener("error", this.handleError.bind(this));
        request.send(null);
        this.request = request;
    }

    /** Validate and upload a completed binary request. */
    handleLoad(event)
    {
        const request = event.target;
        if(request.status != 200 && request.status != 0)
        {
            this.failed = true;
            this.reject_load(new Error(
                `Failed to load ${this.url}: HTTP ${request.status}`));
            return;
        }
        if(request.response == null || request.response.byteLength != 6416)
        {
            this.failed = true;
            const actual_size = request.response == null
                ? 0
                : request.response.byteLength;
            this.reject_load(new Error(
                `Malformed ${this.url}: expected 6416 bytes, got `
                + actual_size));
            return;
        }

        const gl = this.gl;
        gl.bindTexture(gl.TEXTURE_2D, this.texture);
        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, 401, 1, 0,
                      gl.RGBA, gl.FLOAT,
                      new Float32Array(request.response));
        this.loaded = true;
        this.resolve_load(this);
        for(const listener of this.load_listeners)
        {
            listener(this);
        }
    }

    /** Report a binary-resource request failure. */
    handleError()
    {
        this.failed = true;
        this.reject_load(new Error(
            `Failed to load spectral table: ${this.url}`));
    }

    /** Run a callback after successful validation and upload. */
    onLoad(listener)
    {
        this.load_listeners.push(listener);
        if(this.loaded)
        {
            listener(this);
        }
    }

    /** Bind the spectral table to a required sampler uniform. */
    use(program, texture_unit)
    {
        if(!this.loaded && !this.failed && !this.pending_reported)
        {
            console.info(`Spectral table pending: ${this.url}`);
            this.pending_reported = true;
        }
        this.gl.activeTexture(this.gl.TEXTURE0 + texture_unit);
        this.gl.bindTexture(this.gl.TEXTURE_2D, this.texture);
        this.gl.uniform1i(program.uniform("u_spectral_xyz"), texture_unit);
    }

    /** Release the lookup texture and any pending resource request. */
    dispose()
    {
        if(this.request != null && !this.loaded && !this.failed)
        {
            this.request.abort();
        }
        this.gl.deleteTexture(this.texture);
        this.request = null;
    }
}

/** Own artwork, physical foil controls, and the spectral lookup. */
class PhysicalFoilMaterial
{
    /** Load or adopt every resource required by a physical foil card. */
    constructor(gl, artwork, foil_control, spectral_lut)
    {
        this.artwork = artwork instanceof Texture
            ? artwork
            : new Texture(gl, artwork, {flip_y: true});
        this.foil_control = foil_control instanceof Texture
            ? foil_control
            : new Texture(gl, foil_control, {
                flip_y: true,
                role: TEXTURE_ROLE.DATA,
            });
        this.owns_spectral_lut = !(spectral_lut instanceof SpectralLut);
        this.spectral_lut = this.owns_spectral_lut
            ? new SpectralLut(gl, spectral_lut)
            : spectral_lut;
        this.gl = gl;
        this.ready = Promise.all([
            this.artwork.ready,
            this.foil_control.ready,
            this.spectral_lut.ready,
        ]).then(function finishLoadedMaterial()
        {
            return this;
        }.bind(this));
    }

    /** Create a file material, using uniform controls when no map exists. */
    static async fromFiles(gl, artwork_file, control_file, control_color,
                           spectral_lut)
    {
        let artwork = null;
        let foil_control = null;
        try
        {
            artwork = await Texture.fromFile(gl, artwork_file, {
                flip_y: true,
            });
            foil_control = control_file == null
                ? Texture.constant(gl, control_color, {
                    role: TEXTURE_ROLE.DATA,
                })
                : await Texture.fromFile(gl, control_file, {
                    flip_y: true,
                    role: TEXTURE_ROLE.DATA,
                });
            const material = new PhysicalFoilMaterial(
                gl, artwork, foil_control, spectral_lut);
            await material.ready;
            return material;
        }
        catch(error)
        {
            if(artwork != null)
            {
                artwork.dispose();
            }
            if(foil_control != null)
            {
                foil_control.dispose();
            }
            throw(error);
        }
    }

    /** Create a URL material, using uniform controls when no map exists. */
    static async fromUrls(gl, artwork_url, control_url, control_color,
                          spectral_lut)
    {
        const artwork = new Texture(gl, artwork_url, {
            cross_origin: "anonymous",
            flip_y: true,
        });
        const foil_control = control_url == null
            ? Texture.constant(gl, control_color, {
                role: TEXTURE_ROLE.DATA,
            })
            : new Texture(gl, control_url, {
                cross_origin: "anonymous",
                flip_y: true,
                role: TEXTURE_ROLE.DATA,
            });
        const material = new PhysicalFoilMaterial(
            gl, artwork, foil_control, spectral_lut);
        try
        {
            await material.ready;
            return material;
        }
        catch(error)
        {
            material.dispose();
            throw(error);
        }
    }

    /** Bind all physical card material resources. */
    use(program)
    {
        this.gl.uniform1i(
            program.uniform("u_material_kind"), MATERIAL_KIND.PHYSICAL_FOIL);
        this.artwork.use(program, "u_artwork", 0);
        this.foil_control.use(program, "u_foil_control", 1);
        this.spectral_lut.use(program, 2);
    }

    /** Update uniform controls, returning false for image-backed controls. */
    setUniformControl(control_color)
    {
        if(!this.foil_control.is_constant)
        {
            return false;
        }
        this.foil_control.setConstantColor(control_color);
        return true;
    }

    /** Release owned artwork, controls, and any unshared spectral table. */
    dispose()
    {
        this.artwork.dispose();
        this.foil_control.dispose();
        if(this.owns_spectral_lut)
        {
            this.spectral_lut.dispose();
        }
    }
}

/** Shade geometry with one opaque, non-foil sRGB color. */
class SolidColorMaterial
{
    /** Create a validated solid-color material. */
    constructor(gl, color_srgb)
    {
        const is_valid = Array.isArray(color_srgb)
            && color_srgb.length == 3
            && Number.isFinite(color_srgb[0])
            && Number.isFinite(color_srgb[1])
            && Number.isFinite(color_srgb[2])
            && color_srgb[0] >= 0.0 && color_srgb[0] <= 1.0
            && color_srgb[1] >= 0.0 && color_srgb[1] <= 1.0
            && color_srgb[2] >= 0.0 && color_srgb[2] <= 1.0;
        if(!is_valid)
        {
            throw(new Error("Invalid solid material color."));
        }
        this.color_srgb = color_srgb.slice();
        this.gl = gl;
    }

    /** Select solid shading and upload the encoded sRGB color. */
    use(program)
    {
        this.gl.uniform1i(
            program.uniform("u_material_kind"), MATERIAL_KIND.SOLID_COLOR);
        this.gl.uniform3fv(
            program.uniform("u_solid_color_srgb"), this.color_srgb);
    }
}

/** Return the cross product of two three-dimensional arrays. */
function crossVector(left, right)
{
    return [
        left[1] * right[2] - left[2] * right[1],
        left[2] * right[0] - left[0] * right[2],
        left[0] * right[1] - left[1] * right[0],
    ];
}

/** Return a normalized vector or a caller-provided fallback. */
function normalizeVector(value, fallback)
{
    const length = Math.hypot(value[0], value[1], value[2]);
    if(length < 1e-8)
    {
        return fallback.slice();
    }
    return value.map(function divideVectorComponent(component)
    {
        return component / length;
    });
}

/** Return whether a value is a finite three-dimensional numeric array. */
function isFiniteVector3(value)
{
    return (Array.isArray(value) || ArrayBuffer.isView(value))
        && value.length == 3
        && value.every(Number.isFinite);
}

/** Calculate UV-aligned normals and tangent vectors for triangle data. */
function calculateTangentFrames(positions, texcoords, source_normals,
                                geometry_name)
{
    const normals = [];
    const tangents = [];
    let warned_degenerate_uv = false;

    for(let vertex = 0; vertex < positions.length / 3; vertex += 3)
    {
        const position = [];
        const uv = [];
        const normal = [];
        for(let local_vertex = 0; local_vertex < 3; ++local_vertex)
        {
            const position_offset = 3 * (vertex + local_vertex);
            const uv_offset = 2 * (vertex + local_vertex);
            position.push(positions.slice(position_offset,
                                          position_offset + 3));
            uv.push(texcoords.slice(uv_offset, uv_offset + 2));
            if(source_normals != null)
            {
                normal.push(source_normals.slice(position_offset,
                                                 position_offset + 3));
            }
        }

        const edge_1 = position[1].map(
            function subtractFirstPosition(value, index)
            {
                return value - position[0][index];
            });
        const edge_2 = position[2].map(
            function subtractSecondPosition(value, index)
            {
                return value - position[0][index];
            });
        const face_normal = normalizeVector(crossVector(edge_1, edge_2),
                                            [0.0, 1.0, 0.0]);
        const uv_1 = [uv[1][0] - uv[0][0], uv[1][1] - uv[0][1]];
        const uv_2 = [uv[2][0] - uv[0][0], uv[2][1] - uv[0][1]];
        const determinant = uv_1[0] * uv_2[1] - uv_1[1] * uv_2[0];

        let raw_tangent;
        let raw_bitangent;
        if(Math.abs(determinant) < 1e-8)
        {
            if(!warned_degenerate_uv)
            {
                console.warn(
                    `Degenerate UV triangle in geometry ${geometry_name}`);
                warned_degenerate_uv = true;
            }
            const reference = Math.abs(face_normal[2]) < 0.999
                ? [0.0, 0.0, 1.0]
                : [0.0, 1.0, 0.0];
            raw_tangent = normalizeVector(
                crossVector(reference, face_normal), [1.0, 0.0, 0.0]);
            raw_bitangent = crossVector(face_normal, raw_tangent);
        }
        else
        {
            const inverse = 1.0 / determinant;
            raw_tangent = edge_1.map(
                function calculateTangent(value, index)
                {
                    return inverse
                         * (uv_2[1] * value - uv_1[1] * edge_2[index]);
                });
            raw_bitangent = edge_1.map(
                function calculateBitangent(value, index)
                {
                    return inverse
                         * (-uv_2[0] * value + uv_1[0] * edge_2[index]);
                });
        }

        for(let local_vertex = 0; local_vertex < 3; ++local_vertex)
        {
            const vertex_normal = normal.length > 0
                ? normalizeVector(normal[local_vertex], face_normal)
                : face_normal;
            const normal_tangent = vertex_normal[0] * raw_tangent[0]
                                 + vertex_normal[1] * raw_tangent[1]
                                 + vertex_normal[2] * raw_tangent[2];
            const orthogonal_tangent = raw_tangent.map(
                function removeNormal(value, index)
                {
                    return value - normal_tangent * vertex_normal[index];
                });
            const tangent = normalizeVector(orthogonal_tangent,
                                            [1.0, 0.0, 0.0]);
            const handedness = crossVector(vertex_normal, tangent)
                .reduce(function dotBitangent(total, value, index)
                {
                    return total + value * raw_bitangent[index];
                }, 0.0) < 0.0 ? -1.0 : 1.0;
            normals.push(...vertex_normal);
            tangents.push(...tangent, handedness);
        }
    }
    return {normals, tangents};
}

/** Own one drawable geometry and its material. */
class Model
{
    /** Create a tangent-aware textured model from parsed OBJ geometry. */
    constructor(gl, program, geometry, material)
    {
        const data = geometry.data;
        const frame = calculateTangentFrames(
            data.position, data.texcoord, data.normal, geometry.object);

        this.vertex_array = new VertexArray(gl);
        this.vertex_array.addAttribute(
            program, "a_position", new Float32Array(data.position), {});
        this.vertex_array.addAttribute(
            program, "a_texcoord", new Float32Array(data.texcoord),
            {component_count: 2});
        this.vertex_array.addAttribute(
            program, "a_normal", new Float32Array(frame.normals), {});
        this.vertex_array.addAttribute(
            program, "a_tangent", new Float32Array(frame.tangents),
            {component_count: 4});
        this.vertex_count = data.position.length / 3;
        this.material = material;
        this.gl = gl;
    }
}

/** Store the drawable models in one scene. */
class Scene
{
    /** Create a scene from an ordered model list. */
    constructor(models)
    {
        this.models = models;
    }
}

/** Represent a one-sided disk emitter with D65 spectral radiance. */
class DiskLight
{
    /** Create a validated disk emitter in world space. */
    constructor(position, normal, radius, radiance)
    {
        const normal_length = isFiniteVector3(normal)
            ? Math.hypot(...normal)
            : 0.0;
        if(!isFiniteVector3(position) || normal_length < 1e-8
           || !Number.isFinite(radius) || !Number.isFinite(radiance)
           || radius <= 0.0 || radiance < 0.0)
        {
            throw(new Error("Disk-light parameters are invalid."));
        }
        this.position = position.slice();
        this.normal = normalizeVector(normal, [0.0, 0.0, -1.0]);
        const reference = Math.abs(this.normal[2]) < 0.999
            ? [0.0, 0.0, 1.0]
            : [0.0, 1.0, 0.0];
        this.axis_x = normalizeVector(crossVector(reference, this.normal),
                                      [1.0, 0.0, 0.0]);
        this.axis_y = normalizeVector(crossVector(this.normal, this.axis_x),
                                      [0.0, 1.0, 0.0]);
        this.radius = radius;
        this.radiance = radiance;
    }

    /** Transform and upload this disk light for the current view. */
    use(program, view_matrix)
    {
        const vec3 = glMatrix.vec3;
        const view_position = vec3.transformMat4(
            vec3.create(), this.position, view_matrix);
        const view_normal = vec3.transformMat3(
            vec3.create(), this.normal, glMatrix.mat3.fromMat4(
                glMatrix.mat3.create(), view_matrix));
        const view_axis_x = vec3.transformMat3(
            vec3.create(), this.axis_x, glMatrix.mat3.fromMat4(
                glMatrix.mat3.create(), view_matrix));
        const view_axis_y = vec3.transformMat3(
            vec3.create(), this.axis_y, glMatrix.mat3.fromMat4(
                glMatrix.mat3.create(), view_matrix));
        const gl = program.gl;
        gl.uniform3fv(program.uniform("u_light_position"), view_position);
        gl.uniform3fv(program.uniform("u_light_normal"), view_normal);
        gl.uniform3fv(program.uniform("u_light_axis_x"), view_axis_x);
        gl.uniform3fv(program.uniform("u_light_axis_y"), view_axis_y);
        gl.uniform1f(program.uniform("u_light_radius"), this.radius);
        gl.uniform1f(program.uniform("u_light_radiance"), this.radiance);
    }
}

/** Own frame-level WebGL state and draw scenes declaratively. */
class Renderer
{
    /** Create a renderer for one WebGL context and shader program. */
    constructor(gl, program)
    {
        this.canvas = gl.canvas;
        this.program = program;
        this.gl = gl;

        gl.enable(gl.DEPTH_TEST);
        gl.depthFunc(gl.LEQUAL);
        gl.enable(gl.CULL_FACE);
        gl.clearColor(0.0, 0.0, 0.0, 0.0);
    }

    /** Draw a scene under one disk light from supplied camera matrices. */
    draw(scene, camera, light)
    {
        const mat3 = glMatrix.mat3;
        const mat4 = glMatrix.mat4;
        const model_view_matrix = mat4.multiply(
            mat4.create(), camera.view_matrix, camera.model_matrix);
        const normal_matrix = mat3.normalFromMat4(
            mat3.create(), model_view_matrix);

        resizeCanvas(this.canvas, this.gl);
        this.gl.clear(this.gl.COLOR_BUFFER_BIT | this.gl.DEPTH_BUFFER_BIT);
        this.program.use();
        this.gl.uniformMatrix4fv(
            this.program.uniform("u_projection"), false,
            camera.projection_matrix);
        this.gl.uniformMatrix4fv(
            this.program.uniform("u_model_view"), false,
            model_view_matrix);
        this.gl.uniformMatrix3fv(
            this.program.uniform("u_normal_matrix"), false, normal_matrix);
        light.use(this.program, camera.view_matrix);

        for(const model of scene.models)
        {
            model.vertex_array.use();
            model.material.use(this.program);
            this.gl.drawArrays(this.gl.TRIANGLES, 0, model.vertex_count);
        }
    }
}

if(typeof module != "undefined")
{
    module.exports = {
        DiskLight,
        MAX_CARD_TEXTURE_PIXELS,
        MATERIAL_KIND,
        PhysicalFoilMaterial,
        ShaderProgram,
        SolidColorMaterial,
        TEXTURE_ROLE,
        Texture,
        bitmapDecodeOptions,
        calculateTangentFrames,
        defineShaderConstants,
    };
}