plugins_render3d.js

/**
 * LittleJS 3D Rendering Plugin
 * - Adds a 3D scene that draws into the same WebGL canvas as the 2D game
 * - Call new Render3DPlugin() in gameInit, then move render3D.camera and make EngineObject3D objects
 * - EngineObject3D is an EngineObject with a 3D position, rotation and mesh
 * - The 3D scene draws under the 2D sprites, so HUD and text land on top
 * - Lighting is the sun plus ambient, with optional extra lights, fog and shadows
 * - Any object or draw can bring its own Shader, a mainImage snippet the lighting then applies to
 * - Build shapes with buildBox, buildSphere, buildGrid and buildLathe; the other builders, terrain, particles,
 *   camera controls and the OBJ loader are in the Render3D Extras plugin, which goes after this one
 * - Requires the Math3D plugin
 * @namespace Render3D
 */

'use strict';

///////////////////////////////////////////////////////////////////////////////

/** Global Render3D plugin object
 *  @type {Render3DPlugin}
 *  @memberof Render3D */
let render3D;

// vertex format: position xyz, normal xyz, uv, rgba bytes
const RENDER3D_VERTEX_FLOATS = 9;
const RENDER3D_VERTEX_BYTES = RENDER3D_VERTEX_FLOATS * 4;

// per draw values the shaders read as vertex attributes: the model matrix columns (4-7), the tint (11) and the
// uv rect (12); constants for one draw, one per instance for a batch; the shader derives the normal matrix
const RENDER3D_INSTANCE_FLOATS = 24;
const RENDER3D_INSTANCE_BYTES = RENDER3D_INSTANCE_FLOATS * 4;
const RENDER3D_INSTANCE_ATTRIBS = [[4, 4, 0], [5, 4, 16], [6, 4, 32], [7, 4, 48], [11, 4, 64], [12, 4, 80]];
const RENDER3D_VERTEX_INPUTS =
    'layout(location=0) in vec3 p;layout(location=1) in vec3 n;layout(location=2) in vec2 t;layout(location=3) in vec4 c;' +
    'layout(location=4) in vec4 m0;layout(location=5) in vec4 m1;layout(location=6) in vec4 m2;layout(location=7) in vec4 m3;' +
    'layout(location=11) in vec4 tint;layout(location=12) in vec4 uvRect;';
const RENDER3D_MAX_STREAM_VERTS = 32768;
const RENDER3D_MAX_LIGHTS = 8; // Light3D objects per frame, the shader loops over this many
// strip order, frozen, and typed as the plain array the uv parameters take
const RENDER3D_QUAD_UVS = /** @type {Array<Vector2>} */
    (Object.freeze([vec2(0, 0), vec2(0, 1), vec2(1, 0), vec2(1, 1)].map(uv=> Object.freeze(uv))));
const RENDER3D_FULL_UV_RECT = Object.freeze({x:0, y:0, w:1, h:1});
const RENDER3D_DEFAULT_NORMAL = Object.freeze(vec3(0, 1, 0));
const RENDER3D_DEFAULT_UV = Object.freeze(vec2());
const RENDER3D_SHADOW_COLOR = Object.freeze(hsl(0, 0, 0, .5));
const RENDER3D_IDENTITY = new Matrix4; // never modified
let render3DShadowCut; // the cut last sent to the shadow shader, see render3DSetDrawUniforms
///////////////////////////////////////////////////////////////////////////////
// Private helpers

// outward normal of a triangle or a quad given its corners in loop order,
// from the diagonals so a collapsed corner still works
function render3DFaceNormal(a, b, c, d=a)
{
    const n = c.subtract(a).cross(d.subtract(b));
    return n.lengthSquared() ? n.normalize() : RENDER3D_DEFAULT_NORMAL;
}

// a quad's corners in loop order as a strip, the one place that knows the order
function render3DQuadStrip(a, b, c, d) { return [a, b, d, c]; }

// per corner values (colors, uvs) into strip order, a single value passes through
function render3DQuadValues(v) { return isArray(v) ? render3DQuadStrip(...v) : v; }

// 3D draws are only valid during the pass with a live shader
function render3DCanDraw()
{
    if (!render3D.program) return false;
    ASSERT(render3D.isRendering,
        '3D draws are only valid during the 3D pass, draw from an EngineObject3D or render3D.onRenderOpaque');
    return render3D.isRendering;
}

// the draw state fields a batch is drawn under; lights and fog are not captured, they are read live at flush
// the three functions below write them out by hand for speed, so a new field goes in all four; emissiveMapColor is
// compared by its values, so two objects with equal colors batch
const RENDER3D_STATE_FIELDS = ['blend', 'additive', 'depthTest', 'depthWrite', 'cullBackFaces', 'mirrored', 'lighting',
    'emissive', 'receiveShadow', 'specular', 'pixelated', 'shader', 'normalMap', 'normalScale', 'shininess',
    'reflectivity', 'emissiveMap', 'emissiveMapColor'];

// a copy of the draw state in one fixed shape, the fields of RENDER3D_STATE_FIELDS written out so the
// compare below stays a handful of direct reads, it runs for every instance drawn
function render3DCaptureBatchState()
{
    const r = render3D;
    return {blend: r.blend, additive: r.additive, depthTest: r.depthTest, depthWrite: r.depthWrite,
        cullBackFaces: r.cullBackFaces, mirrored: r.mirrored, lighting: r.lighting, emissive: r.emissive,
        receiveShadow: r.receiveShadow, specular: r.specular, pixelated: r.pixelated, shader: r.shader,
        normalMap: r.normalMap, normalScale: r.normalScale, shininess: r.shininess, reflectivity: r.reflectivity,
        emissiveMap: r.emissiveMap, emissiveMapColor: (r.emissiveMapColor || WHITE).copy()}; // the caller may change it
}

// put a captured draw state back, written out the same way; the transparent stage does this for every queued draw
function render3DApplyBatchState(s)
{
    const r = render3D;
    r.blend = s.blend, r.additive = s.additive, r.depthTest = s.depthTest, r.depthWrite = s.depthWrite,
    r.cullBackFaces = s.cullBackFaces, r.mirrored = s.mirrored, r.lighting = s.lighting, r.emissive = s.emissive,
    r.receiveShadow = s.receiveShadow, r.specular = s.specular, r.pixelated = s.pixelated, r.shader = s.shader,
    r.normalMap = s.normalMap, r.normalScale = s.normalScale, r.shininess = s.shininess,
    r.reflectivity = s.reflectivity, r.emissiveMap = s.emissiveMap, r.emissiveMapColor = s.emissiveMapColor;
}

// true when the current draw state differs from a captured one, so a pending batch must flush first
function render3DStateChanged(s)
{
    const r = render3D;
    return r.blend !== s.blend || r.additive !== s.additive || r.depthTest !== s.depthTest
        || r.depthWrite !== s.depthWrite || r.cullBackFaces !== s.cullBackFaces || r.mirrored !== s.mirrored
        || r.lighting !== s.lighting || r.emissive !== s.emissive || r.receiveShadow !== s.receiveShadow
        || r.specular !== s.specular || r.pixelated !== s.pixelated || r.shader !== s.shader
        || r.normalMap !== s.normalMap || r.normalScale !== s.normalScale || r.shininess !== s.shininess
        || r.reflectivity !== s.reflectivity || r.emissiveMap !== s.emissiveMap
        || render3DColorChanged(r.emissiveMapColor || WHITE, s.emissiveMapColor);
}

// whether two colors differ in their rgb, for the batch key's emissiveMapColor
function render3DColorChanged(a, b) { return a.r !== b.r || a.g !== b.g || a.b !== b.b;
}

// whether a sphere is inside the view, or the shadow map's box during the shadow pass, without a vector
function render3DSphereVisible(x, y, z, radius)
{
    const planes = render3D.shadowPass && !render3D.depthPass ? render3D.shadowPlanes : render3D.frustumPlanes;
    for (let i = 0; i < planes.length; ++i)
    {
        const p = planes[i];
        if (p[0]*x + p[1]*y + p[2]*z + p[3] < -radius)
            return false;
    }
    return true;
}

// run a function with some draw state fields overridden, restored afterward even on a throw
function render3DWithState(fields, fn)
{
    const r = render3D, saved = {};
    for (const key in fields)
        saved[key] = r[key], r[key] = fields[key];
    try { return fn(); }
    finally { Object.assign(r, saved); }
}

// which side of the 2D scene an object draws on, its own flag or the plugin default
function render3DIsAfter2D(o) { return !!(o.renderAfter2D ?? render3D.renderAfter2D); }

// a size given as a number or a vec3
/** @param {Vector3|number} size
 *  @return {Vector3} */
function render3DSize3(size)
{ return isNumber(size) ? vec3(/** @type {number} */ (size)) : /** @type {Vector3} */ (size); }

// a size given as a number or a vec2
/** @param {Vector2|number} size
 *  @return {Vector2} */
function render3DSize2(size)
{ return isNumber(size) ? vec2(/** @type {number} */ (size)) : /** @type {Vector2} */ (size); }

// a transform given as a matrix, or as a vec3 for one that only moves there
/** @param {Matrix4|Vector3} matrix
 *  @return {Matrix4} */
function render3DMatrix(matrix)
{
    if (matrix instanceof Vector3)
        return buildMatrix(matrix);
    ASSERT(matrix instanceof Matrix4, 'takes a Matrix4, or a Vector3 for a position');
    return matrix;
}

// the matrix that keeps normals pointing out when an object is scaled unevenly
function render3DNormalMatrix(matrix) { return matrix.copy().invert().transpose(); }

// whether a matrix mirrors, its determinant negative, so what it moves reads the other way round
function render3DMirrors(matrix) { return matrix.determinant() < 0; }


// a column of a matrix as a direction: 0 is the right axis, 4 up, 8 back
function render3DAxis(m, i) { return vec3(m[i], m[i+1], m[i+2]); }

// a matrix turning by angle around a unit axis through the origin, counter clockwise when the axis points at you
function render3DAxisRotation(axis, angle)
{
    const x = vec3(1, 0, 0).rotate(axis, angle), y = vec3(0, 1, 0).rotate(axis, angle), z = vec3(0, 0, 1).rotate(axis, angle);
    return new Matrix4([x.x, x.y, x.z, 0, y.x, y.y, y.z, 0, z.x, z.y, z.z, 0, 0, 0, 0, 1]);
}

// the largest axis scale of a matrix, how much it grows a bounding sphere
function render3DMaxScale(m)
{
    return max(m[0]*m[0] + m[1]*m[1] + m[2]*m[2], m[4]*m[4] + m[5]*m[5] + m[6]*m[6], m[8]*m[8] + m[9]*m[9] + m[10]*m[10]) ** .5;
}

// how far a matrix at offset k can move a point that is one unit from its origin, for a bounding sphere: the
// longest axis when the axes are square to each other, more when they are not, as a turned child under a parent
// scaled on one axis leaves them; the axes' dot products bound the largest stretch by their largest row sum
function render3DMaxStretch(m, k=0)
{
    const xx = m[k]*m[k] + m[k+1]*m[k+1] + m[k+2]*m[k+2];
    const yy = m[k+4]*m[k+4] + m[k+5]*m[k+5] + m[k+6]*m[k+6];
    const zz = m[k+8]*m[k+8] + m[k+9]*m[k+9] + m[k+10]*m[k+10];
    const xy = abs(m[k]*m[k+4] + m[k+1]*m[k+5] + m[k+2]*m[k+6]);
    const xz = abs(m[k]*m[k+8] + m[k+1]*m[k+9] + m[k+2]*m[k+10]);
    const yz = abs(m[k+4]*m[k+8] + m[k+5]*m[k+9] + m[k+6]*m[k+10]);
    return max(xx + xy + xz, yy + xy + yz, zz + xz + yz) ** .5;
}

// a quad as a strip from its center and half axes, the same corner order as render3DQuadStrip
function render3DQuadAxes(center, right, up)
{
    return [center.subtract(right).add(up), center.subtract(right).subtract(up),
        center.add(right).add(up), center.add(right).subtract(up)];
}

// set the draw state for an object's render3D, or the defaults for the stage callbacks
function render3DSetObjectState(o)
{
    const r = render3D;
    const emissive = o?.emissive || 0;
    ASSERT(isNumber(emissive) && emissive >= 0, 'emissive must be a number, 0 or more', emissive);
    r.lighting = true;
    r.emissive = emissive;
    r.additive = !!o?.additive;
    r.specular = o?.specular || 0;
    const shininess = o?.shininess ?? 16, reflectivity = o?.reflectivity || 0;
    ASSERT(isNumber(shininess) && shininess > 0, 'shininess must be a number above 0', shininess);
    ASSERT(isNumber(reflectivity) && reflectivity >= 0 && reflectivity <= 1, 'reflectivity must be 0 to 1', reflectivity);
    r.shininess = shininess;
    r.reflectivity = reflectivity;
    r.normalMap = o?.normalMap || undefined;
    r.normalScale = o?.normalScale ?? 1;
    r.emissiveMap = o?.emissiveMap || undefined;
    r.emissiveMapColor = o?.emissiveMapColor || WHITE;
    r.receiveShadow = !o || o.receiveShadow;
    r.cullBackFaces = r.mirrored = false; // each mesh sets these as it draws
    r.pixelated = !!o?.pixelated;
    ASSERT(!o?.shader || o.shader instanceof Shader, 'shader must be a Shader, not the snippet itself');
    r.shader = o?.shader || undefined; // null is no shader too, so it batches with none
    r.depthTest = true;
    if (r.shadowPass)
        r.blend = !!o?.transparent; // the depth shader cuts a see through caster by the alpha it would blend with
}

// draw objects each with the draw state set from its own flags, then reset to the defaults
function render3DDrawObjects(objects)
{
    for (const o of objects)
    {
        render3DSetObjectState(o);
        o.render3D();
    }
    render3DSetObjectState();
}

// add a draw of a mesh to its batch; a batch is one mesh under one texture and draw state, so a change flushes it
function render3DInstance(mesh, matrix, tileInfo, color)
{
    const k = render3DInstanceSlot(mesh, render3DTextureOf(tileInfo)), data = mesh.instanceData;
    data.set(matrix.m, k);
    // the tint; this batch is never blended, so in the shadow pass the depth shader cuts it by its texture alpha
    // alone, a see through caster draws blended instead and is cut by its tint alpha too
    data[k+16] = color.r, data[k+17] = color.g, data[k+18] = color.b, data[k+19] = color.a;
    const uv = render3DGetTileUVs(tileInfo);
    data[k+20] = uv.x; data[k+21] = uv.y; data[k+22] = uv.w; data[k+23] = uv.h;
}

// make room for one more instance of a mesh under a texture and the current draw state, flushing a batch that
// differs first, and return where its 24 floats go in mesh.instanceData: the matrix, the tint and the uv rect
function render3DInstanceSlot(mesh, textureInfo)
{
    const r = render3D;
    if (mesh.instanceCount && (mesh.instanceTextureInfo !== textureInfo || render3DStateChanged(mesh.instanceState)))
        render3DFlushInstances(mesh);
    if (!mesh.instanceCount)
    {
        mesh.instanceTextureInfo = textureInfo;
        mesh.instanceState = render3DCaptureBatchState();
        r.instanceMeshes.push(mesh);
    }

    // room for one more, doubling as the batch grows
    const data = mesh.instanceData, k = mesh.instanceCount++ * RENDER3D_INSTANCE_FLOATS;
    if (!data || data.length < k + RENDER3D_INSTANCE_FLOATS)
    {
        const grown = new Float32Array(max(64 * RENDER3D_INSTANCE_FLOATS, data ? data.length * 2 : 0));
        data && grown.set(data);
        mesh.instanceData = grown;
    }
    return k;
}

// draw the pending batches, or just one mesh's, each as a single instanced call
function render3DFlushInstances(only)
{
    const r = render3D, gl = glContext;
    for (const mesh of only ? [only] : r.instanceMeshes)
    {
        const count = mesh.instanceCount;
        mesh.instanceCount = 0;
        if (!count || !mesh.buffer) continue;

        // the per instance values on top of the constant attributes, then the mesh under them
        // a ring of buffers with fresh storage each time, so the driver never waits for a draw still reading one
        const buffers = r.instanceBuffers, buffer = buffers[r.instanceBufferIndex = (r.instanceBufferIndex + 1) % buffers.length];
        gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
        gl.bufferData(gl.ARRAY_BUFFER, mesh.instanceData, gl.DYNAMIC_DRAW, 0, count * RENDER3D_INSTANCE_FLOATS);
        render3DDrawInstanced(mesh, buffer, count, mesh.instanceTextureInfo, mesh.instanceState);
    }
    if (!only)
        r.instanceMeshes.length = 0;
    else
    {
        const i = r.instanceMeshes.indexOf(only);
        i < 0 || r.instanceMeshes.splice(i, 1);
    }
}

// draw a mesh count times from a buffer that holds the per instance values, under a draw state
// the arrays are turned on with their instance divisor for this one call and both are turned off after: a single
// draw reads these slots as constant attributes, and in Firefox a draw that reads a constant through a slot whose
// divisor is set leaves the next batch on that slot reading the wrong values
function render3DDrawInstanced(mesh, buffer, count, textureInfo, state)
{
    const gl = glContext;
    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
    for (const [location, size, offset] of RENDER3D_INSTANCE_ATTRIBS)
    {
        gl.vertexAttribPointer(location, size, gl.FLOAT, false, RENDER3D_INSTANCE_BYTES, offset);
        gl.enableVertexAttribArray(location);
        gl.vertexAttribDivisor(location, 1);
    }
    render3DSetDrawUniforms(RENDER3D_IDENTITY, textureInfo, WHITE, RENDER3D_FULL_UV_RECT, state);
    render3DBindMesh(mesh);
    gl.drawElementsInstanced(gl.TRIANGLES, mesh.bufferCount, mesh.indexType, 0, count);
    for (const [location] of RENDER3D_INSTANCE_ATTRIBS)
    {
        gl.disableVertexAttribArray(location);
        gl.vertexAttribDivisor(location, 0);
    }
    ++drawCount;
    primitiveCount += mesh.bufferCount / 3 * count;
}

// forget the pending batches, for a frame that threw or a lost context
function render3DClearInstances()
{
    for (const mesh of render3D.instanceMeshes)
        mesh.instanceCount = 0;
    render3D.instanceMeshes.length = 0;
}

// the live objects drawn on one side of the 2D scene
function render3DLayerObjects(after2D)
{
    return /** @type {Array<EngineObject3D>} */ (engineObjects.filter(o=>
        !o.destroyed && o instanceof EngineObject3D && render3DIsAfter2D(o) === after2D));
}

// the Light3D objects the shader gets this frame: directional lights light the whole scene so they come first,
// then the point lights nearest the camera
function render3DCollectLights()
{
    // a light switched off by its radius or its alpha is left out, so it cannot take one of the few slots
    const lights = /** @type {Array<Light3D>} */ (engineObjects.filter(o=> !o.destroyed && o instanceof Light3D &&
        o.color.a > 0 && o.intensity > 0 && (o.directional || o.radius > 0)));
    if (lights.length > RENDER3D_MAX_LIGHTS)
    {
        // distances cached once, getWorldPos3D walks the parent chain and the sort asks many times
        const cameraPos = render3D.camera.pos, distances = new Map;
        for (const light of lights)
            distances.set(light, light.directional ? -1 : light.getWorldPos3D().distanceSquared(cameraPos));
        lights.sort((a, b)=> distances.get(a) - distances.get(b));
        lights.length = RENDER3D_MAX_LIGHTS;
    }
    return lights;
}

// unit circle directions for a number of sides, [cos, sin, cos, sin, ...] including the closing point, cached
const render3DCircleCache = new Map;
function render3DCircle(sides)
{
    sides |= 0;
    let circle = render3DCircleCache.get(sides);
    if (!circle)
    {
        circle = new Float32Array(sides * 2 + 2);
        for (let i = 0; i <= sides; ++i)
        {
            const a = i / sides * 2 * PI;
            circle[i*2] = cos(a), circle[i*2 + 1] = sin(a);
        }
        render3DCircleCache.set(sides, circle);
    }
    return circle;
}

// the rotation that points -Z along a direction, as vec3(pitch, yaw, 0); a zero direction keeps the current one
function render3DLookRotation(direction, current)
{
    const d = direction.normalize();
    if (!d.lengthSquared()) return current;
    if (abs(d.x) + abs(d.z) < 1e-9) // straight up or down has no yaw of its own
        return vec3(d.y > 0 ? PI / 2 : -PI / 2, current.y, 0);
    return vec3(Math.asin(clamp(d.y, -1, 1)), atan2(-d.x, -d.z), 0);
}

///////////////////////////////////////////////////////////////////////////////
/**
 * Render3D Plugin - The 3D renderer, camera, lights, shadows and fog
 * - There is one of these, in the global render3D
 * - It draws the 3D scene before gameRender, so 2D drawing lands on top
 * - Set renderAfter2D to draw the 3D scene over the 2D scene instead
 * - Settings like lighting and specular are read as each thing draws
 * - Every object sets them from its own flags, so you rarely touch them
 * @memberof Render3D
 * @example
 * new Render3DPlugin;
 * render3D.camera.pos = vec3(0, 5, 10);
 * render3D.camera.lookAt(vec3());
 * new EngineObject3D(vec3(), buildBox());
 */
class Render3DPlugin
{
    /** Create the global 3D renderer, call in gameInit */
    constructor()
    {
        ASSERT(!render3D, 'Render3D plugin already initialized');
        render3D = this;
        ASSERT(Object.keys(render3DCaptureBatchState()).join() === RENDER3D_STATE_FIELDS.join(),
            'the batch state functions must list RENDER3D_STATE_FIELDS');

        /** @property {Camera3D} - The camera */
        this.camera = new Camera3D;

        // lights and fog
        /** @property {Vector3} - Direction toward the sun, where its light comes from, like a directional Light3D;
         *  read at each draw, and any length will do, the shading and the shadows normalize it themselves;
         *  the sun is the one light that casts shadows */
        this.sunDirection = vec3(-.3, 1, .5);
        /** @property {Color} - Sunlight color */
        this.sunColor = WHITE.copy();
        /** @property {Color} - Ambient light color, from above when ambientGroundColor is set */
        this.ambientColor = hsl(0, 0, .3);
        /** @property {Color|undefined} - Ambient light from below: set, the ambient blends from this on faces pointing
         *  down to ambientColor on faces pointing up, the way a sky and a ground light a scene; setSky sets both from
         *  its colors
         *  @type {Color|undefined} */
        this.ambientGroundColor = undefined;
        /** @property {Color|undefined} - Fog color, uses canvasClearColor when undefined
         *  @type {Color|undefined} */
        this.fogColor = undefined;
        /** @property {number} - Distance from the camera where fog starts */
        this.fogStart = 0;
        /** @property {number} - Distance from the camera where fog is total, 0 disables fog */
        this.fogEnd = 0;
        /** @property {Vector3} - Added to the velocity3D of every object with a mass each frame, scaled by its
         *  gravityScale; sync2D objects use the 2D gravity */
        this.gravity = vec3();
        /** @property {number|HeightMap|function(number, number): number} - Floor for objects with a softShadow: a
         *  height, a HeightMap, or (x, z) => y
         *  @type {number|HeightMap|function(number, number): number} */
        this.softShadowHeight = 0;
        /** @property {boolean} - Default for every builder's smooth argument: true for smooth vertex normals, false for
         *  flat faces */
        this.smoothShading = false;

        /** @property {boolean} - Draw the camera's depth into a texture each frame for post processing:
         *  PostProcessPlugin hands it to its shader as iChannel2, read with sceneDepth(uv); off by default and free
         *  when off, on it draws the solid objects of the default layer once more, depth only */
        this.depthTexture = false;

        // shadows
        /** @property {boolean} - Cast real shadows from the sun, off by default and free when off */
        this.shadows = false;
        /** @property {number} - Size of the shadow map in pixels, bigger is sharper and slower */
        this.shadowMapSize = 1024;
        /** @property {number} - World size the shadow map covers around shadowCenter, smaller is sharper; it is a
         *  square facing the light, so it turns as the light does, and about 1.5 times an area's width covers it from
         *  any angle */
        this.shadowRange = 40;
        /** @property {Vector3|undefined} - Center of the shadowed area, read each frame, undefined follows the camera
         *  @type {Vector3|undefined} */
        this.shadowCenter = undefined;
        /** @property {number} - Stops surfaces shadowing themselves, raise for speckles, lower if shadows drift off */
        this.shadowBias = .003;
        /** @property {number} - How much to blur the shadow edges */
        this.shadowSoftness = 1;

        // draw state, read at each draw
        /** @property {boolean} - Apply lighting, when false draws plain vertex color times texture and casts no shadow;
         *  off for billboards, lines, ribbons and soft discs, an object sets emissive instead */
        this.lighting = true;
        /** @property {number} - How much a surface lights itself, set per object by its emissive */
        this.emissive = 0;
        /** @property {boolean} - Additive blending instead of alpha, in the transparent stage */
        this.additive = false;
        /** @property {boolean} - Test against the depth buffer, reset to true before each object and callback; a draw
         *  with it off goes over what was drawn before it and under what is drawn after, by render order, which
         *  ends the batch of meshes before it, so one per object costs a draw per object */
        this.depthTest = true;
        /** @property {boolean} - Write to the depth buffer, owned by the stages: on for opaque, off for transparent */
        this.depthWrite = true;
        // batch state set by drawMesh from each mesh: whether its back faces are skipped, off for strips so they
        // show from both sides, and whether its transform mirrors it so the other winding is the front
        this.cullBackFaces = false;
        this.mirrored = false;
        /** @property {number} - Strength of the highlight where the sun and the Light3D objects reflect, 0 is none and
         *  1 adds a light's full color at its brightest; shininess sets its size */
        this.specular = 0;
        /** @property {number} - The highlight's exponent, how small and sharp it is: 4 is broad like rubber, 100 sharp
         *  like polished metal; set from each object's shininess */
        this.shininess = 16;
        /** @property {TextureInfo|undefined} - Normal map for the next draws, set from each object's normalMap
         *  @type {TextureInfo|undefined} */
        this.normalMap = undefined;
        /** @property {number} - How strongly the normal map bends the surface, set from each object's normalScale */
        this.normalScale = 1;
        /** @property {number} - How much the surface reflects the sky, 0 to 1, set from each object's reflectivity */
        this.reflectivity = 0;
        /** @property {TextureInfo|undefined} - Emissive map for the next draws, set from each object's emissiveMap
         *  @type {TextureInfo|undefined} */
        this.emissiveMap = undefined;
        /** @property {Color} - Multiplies the emissive map, set from each object's emissiveMapColor */
        this.emissiveMapColor = WHITE;
        /** @property {Shader|undefined} - Custom Shader for the next draws, set from each object's shader; undefined
         *  draws with the plugin's own
         *  @type {Shader|undefined} */
        this.shader = undefined;
        /** @property {boolean} - Darken by the shadow map when shadows are on, turn it off for things that should stay
         *  lit inside a shadow */
        this.receiveShadow = true;

        // the pass
        /** @property {Function|undefined} - Draw solid world here, it runs again for shadows so only draw in it
         *  @type {Function|undefined} */
        this.onRenderOpaque = undefined;
        /** @property {Function|undefined} - Draw see through things here, like glows, billboards and soft shadows
         *  @type {Function|undefined} */
        this.onRenderTransparent = undefined;
        /** @property {Mesh|undefined} - Sky dome from buildSky or setSky, drawn around the camera behind everything
         *  @type {Mesh|undefined} */
        this.sky = undefined;
        /** @property {boolean} - Draw the 3D scene on top of the 2D scene instead of under it */
        this.renderAfter2D = false;
        /** @property {boolean} - Draw see through things far to near so they blend correctly */
        this.sortTransparent = true;
        /** @property {boolean} - Skip meshes whose bounding sphere is outside the view */
        this.frustumCulling = true;
        /** @property {boolean} - Draw every use of a mesh in the opaque stage as one instanced call, mesh.instanced
         *  overrides it per mesh */
        this.instancing = true;
        /** @property {boolean} - Sample textures through mipmaps so they do not shimmer in the distance, false uses
         *  each texture's own filtering like 2D */
        this.mipmaps = true;
        /** @property {boolean} - Draw state: keep texture pixels hard edged, no mipmaps and no blending between them,
         *  set per object by pixelated */
        this.pixelated = false;
        /** @property {number} - Anisotropic filtering for textures seen at an angle, 1 to 16, 1 is off; needs mipmaps */
        this.anisotropy = 4;

        // shared meshes, every object using one draws in the same batch
        /** @property {Mesh} - A box of size 1 that drawBox uses, for any object that is a box; set the object's scale3D
         *  and color instead of editing the mesh, which would change every box that uses it */
        this.boxMesh = buildBox();
        /** @property {Mesh} - A smooth sphere of diameter 1 that drawSphere uses, shared the same way as boxMesh */
        this.sphereMesh = buildSphere(1, 16, 8, true);
        /** @property {Mesh} - A flat square of size 1 facing +Y, seen from above only, for floors, water and decals;
         *  stand it up with the object's rotation3D, and size it with scale3D */
        this.planeMesh = buildGrid();
        this.planeMesh.doubleSided = false;
        /** @property {Mesh} - The same square seen and lit from both sides, for signs, cards and leaves */
        this.planeMeshDoubleSided = buildGrid();
        /** @property {Mesh} - A square of size 1 facing +Z with the tile across it, the corners in the order
         *  drawBillboard writes them; a ParticleEmitter3D draws its particles as instances of it, each with its own matrix */
        this.billboardMesh = new Mesh().addStrip([vec3(-.5, .5, 0), vec3(-.5, -.5, 0), vec3(.5, .5, 0), vec3(.5, -.5, 0)],
            vec3(0, 0, 1), RENDER3D_QUAD_UVS);
        this.billboardMesh.doubleSided = true;

        // read only
        /** @property {boolean} - True while the 3D pass is running, 3D draws are only valid then */
        this.isRendering = false;
        /** @property {boolean} - True while the shadow map is being drawn, draws go to the depth only shader */
        this.shadowPass = false;
        /** @property {Matrix4} - This frame's view matrix */
        this.viewMatrix = new Matrix4;
        /** @property {Matrix4} - This frame's projection matrix */
        this.projectionMatrix = new Matrix4;
        /** @property {Matrix4} - This frame's combined view projection */
        this.viewProjection = new Matrix4;
        /** @property {Matrix4} - This frame's light view projection for the shadow map */
        this.shadowMatrix = new Matrix4;
        /** @property {Vector3} - Camera right axis this frame */
        this.cameraRight = vec3(1, 0, 0);
        /** @property {Vector3} - Camera up axis this frame */
        this.cameraUp = vec3(0, 1, 0);
        /** @property {Vector3} - Camera forward axis this frame */
        this.cameraForward = vec3(0, 0, -1);
        this.cameraBack = vec3(0, 0, 1); // its opposite, the normal of camera facing draws

        // internal state
        this.blend = false;          // blending on, set by the stages
        /** @type {Array<Array<number>>} */
        this.frustumPlanes = [];     // the view as six inward planes [x, y, z, w]
        /** @type {Array<Array<number>>} */
        this.shadowPlanes = [];      // the shadow map's box as six planes
        /** @type {WebGLProgram|undefined} */
        this.program = undefined;    // the main program, undefined when not available
        /** @type {WebGLProgram|undefined} */
        this.currentProgram = undefined; // the program in use during a pass, a Shader's or the main one
        this.lightCount = 0;         // Light3D objects sent this pass
        /** @type {WebGLProgram|undefined} */
        this.shadowShader = undefined;
        /** @type {WebGLVertexArrayObject|undefined} */
        this.vao = undefined;
        /** @type {WebGLTexture|undefined} */
        this.whiteTexture = undefined; // 1x1 white for untextured draws
        /** @type {Map<number, WebGLSampler>} */
        this.samplers = new Map;      // how textures are filtered in 3D, by wrap and hard edge, see render3DSampler
        /** @type {string|undefined} */
        this.samplerKey = undefined;   // the settings the samplers were made for, they are rebuilt when it changes
        /** @type {WebGLTexture|undefined} */
        this.shadowTexture = undefined;
        /** @type {WebGLFramebuffer|undefined} */
        this.shadowFramebuffer = undefined;
        this.shadowTextureSize = 0;
        /** @type {WebGLTexture|undefined} */
        this.cameraDepthTexture = undefined; // the camera's depth, drawn when depthTexture is on
        /** @type {WebGLFramebuffer|undefined} */
        this.cameraDepthFramebuffer = undefined;
        this.cameraDepthWidth = 0;
        this.cameraDepthHeight = 0;
        this.depthPass = false; // drawing the camera's depth, a shadow pass seen from the camera
        this.contextGeneration = 0;  // counts context losses, a mesh uploaded under an older one uploads again
        this.uniforms = new Map;     // uniform locations by program
        /** @type {Object<string, Array<number>>} */
        this.uniformValues = {};     // last values sent for the cached vec4 uniforms
        this.shadowMapDrawn = false; // the shadow map is drawn by the first pass of the frame
        this.passIsDefault = true;   // the running pass is the default layer, the only one shadowed
        this.lightPositions = new Float32Array(RENDER3D_MAX_LIGHTS * 4); // Light3D uniforms, filled each pass
        this.lightColors = new Float32Array(RENDER3D_MAX_LIGHTS * 4);

        // the stream of immediate mode draws
        /** @type {WebGLBuffer|undefined} */
        this.streamBuffer = undefined;
        /** @type {Array<WebGLBuffer>} */
        this.instanceBuffers = [];       // the per instance values of the batches being drawn, used in turn
        this.instanceBufferIndex = 0;
        /** @type {Array<Mesh>} */
        this.instanceMeshes = [];        // meshes with a batch pending this stage
        /** @type {Array<Array<number>>} */
        this.attribValues = [];          // last values sent for the cached constant attributes
        this.streamData = new ArrayBuffer(RENDER3D_MAX_STREAM_VERTS * RENDER3D_VERTEX_BYTES);
        this.streamFloats = new Float32Array(this.streamData);
        this.streamInts = new Uint32Array(this.streamData);
        this.streamCount = 0;
        /** @type {TextureInfo|undefined} */
        this.streamTileInfo = undefined;
        this.streamState = undefined; // captured state the pending batch was drawn under
        /** @type {Mesh|undefined} */
        this.capture = undefined;     // the mesh a bake is filling
        /** @type {Array<{distance: number, state: Object, draw: function(): void}>|undefined} */
        this.transparentQueue = undefined; // draws queued during the transparent stage, replayed far to near

        render3DInitGL();
        engineAddPlugin(undefined, render3DRender, render3DContextLost, render3DContextRestored, render3DPreRender);
    }

    ///////////////////////////////////////////////////////////////////////////
    // Matrices and picking

    /** Rebuild the view and projection matrices from the camera, called automatically each frame
     *  @param {number} [aspect] - Width over height, defaults to the main canvas */
    updateMatrices(aspect=mainCanvasSize.y ? mainCanvasSize.x / mainCanvasSize.y : 1)
    {
        const camera = this.camera;
        if (camera.align2D)
            camera.update2D();
        const cameraMatrix = camera.getMatrix();
        this.viewMatrix = cameraMatrix.copy().invert();
        this.projectionMatrix = camera.getProjectionMatrix(aspect);
        this.viewProjection = this.projectionMatrix.copy().multiply(this.viewMatrix);
        const m = cameraMatrix.m;
        this.cameraRight = render3DAxis(m, 0);
        this.cameraUp = render3DAxis(m, 4);
        this.cameraBack = render3DAxis(m, 8); // the normal of anything facing the camera
        this.cameraForward = this.cameraBack.scale(-1);
        this.frustumPlanes = render3DFrustumPlanes(this.viewProjection);
    }

    /** Where a world point lands on screen as -1 to 1 across and up, with z as depth
     *  - Uses this frame's camera, call updateMatrices first if the camera just moved
     *  @param {Vector3} pos
     *  @return {Vector3|undefined} - undefined when behind the camera or closer than the near plane */
    worldToClip(pos)
    {
        const m = this.viewProjection.m;
        const w = m[3]*pos.x + m[7]*pos.y + m[11]*pos.z + m[15];
        const z = (m[2]*pos.x + m[6]*pos.y + m[10]*pos.z + m[14]) / w;
        if (w <= 0 || z < -1)
            return; // behind the camera, or in front of the near plane
        return vec3(
            (m[0]*pos.x + m[4]*pos.y + m[8]*pos.z  + m[12]) / w,
            (m[1]*pos.x + m[5]*pos.y + m[9]*pos.z  + m[13]) / w, z);
    }

    /** Project a world point to screen space pixels, same space as mousePosScreen
     *  - The opposite of screenToRay, and it takes the same canvas so the pair agree
     *  @param {Vector3} pos
     *  @param {Vector2} [canvasSize] - Defaults to the main canvas size, as in screenToRay;
     *    the projection is whatever updateMatrices last built, which screenToRay does for its canvas
     *  @return {Vector2|undefined} - undefined when behind the camera or closer than the near plane */
    worldToScreen(pos, canvasSize=mainCanvasSize)
    {
        const clip = this.worldToClip(pos);
        if (!clip)
            return;
        return vec2((clip.x + 1) / 2 * canvasSize.x, (1 - clip.y) / 2 * canvasSize.y);
    }

    /** Get the world ray under a screen position, for clicking on things in 3D
     *  - Uses the camera where it is right now, so it is fine to call from gameUpdate
     *  - It brings the view matrices up to date for that canvas, so worldToScreen stays its exact opposite
     *  @param {Vector2} screenPos - Same space as mousePosScreen
     *  @param {Vector2} [canvasSize] - Defaults to the main canvas size
     *  @return {Ray3D} - Starts at the camera with a unit direction, or on the near plane when orthographic */
    screenToRay(screenPos, canvasSize=mainCanvasSize)
    {
        // a canvas with no size stands in as 1x1, rather than dividing by zero
        const width = canvasSize.x || 1, height = canvasSize.y || 1;
        const aspect = width / height, camera = this.camera;
        // bring the matrices up to date for this canvas, so worldToScreen and this agree on where things are
        this.updateMatrices(aspect);
        const clipX = screenPos.x / width * 2 - 1;
        const clipY = 1 - screenPos.y / height * 2;
        // the screen offset moves a parallel ray's origin, or bends a perspective ray's direction
        const h = camera.orthographic ? camera.orthographic / 2 : tan(camera.fov / 2);
        const offset = this.cameraRight.scale(clipX * h * aspect).add(this.cameraUp.scale(clipY * h));
        // a parallel ray starts on the near plane, which an orthographic camera may put behind it, like three.js
        return camera.orthographic
            ? new Ray3D(camera.pos.add(offset).add(this.cameraForward.scale(camera.near)), this.cameraForward.copy())
            : new Ray3D(camera.pos.copy(), this.cameraForward.add(offset).normalize());
    }

    /** Where a screen position lands on a flat ground plane, for top down games; use HeightMap.raycast for terrain
     *  @param {Vector2} screenPos - Same space as mousePosScreen
     *  @param {number} [groundHeight] - World height of the ground plane
     *  @param {Vector2} [canvasSize] - Defaults to the main canvas size, as in screenToRay
     *  @return {Vector3|undefined} - undefined when the ray misses the plane */
    screenToGround(screenPos, groundHeight=0, canvasSize=mainCanvasSize)
    {
        const ray = this.screenToRay(screenPos, canvasSize);
        const t = raycastPlane(ray, vec3(0, groundHeight, 0), RENDER3D_DEFAULT_NORMAL);
        return t === undefined ? undefined : ray.getPosition(t);
    }

    /** Find the nearest object under a screen position or along a ray, for clicking on things
     *  - Each object is tested as the box around its mesh in its own space, or a sprite as the quad it draws,
     *    not triangle by triangle
     *  - engineObjectsRaycast3D is the other half of this, every object along a ray instead of the nearest
     *  @param {Vector2|Ray3D} from - A screen position like mousePosScreen, or a ray to look along
     *  @param {Array<EngineObject>} [objects] - Defaults to every object; only those with a mesh or a sprite count
     *  @return {{object: EngineObject3D, distance: number}|undefined} */
    pick(from, objects=engineObjects)
    {
        const ray = from instanceof Ray3D ? from : this.screenToRay(from);
        let nearest;
        for (const o of objects)
        {
            const distance = render3DRaycastObject(ray, o);
            if (distance !== undefined && (!nearest || distance < nearest.distance))
                nearest = {object: /** @type {EngineObject3D} */ (o), distance}; // only a 3D object has a distance
        }
        return nearest;
    }

    /** Play a sound at a 3D position, quieter with distance from the camera and panned by its side, like Sound.play
     *  with a 2D position
     *  @param {Sound} sound
     *  @param {Vector3} pos3D
     *  @param {number} [volume]
     *  @param {number} [pitch]
     *  @param {number} [randomnessScale] - How much to scale pitch randomness
     *  @param {boolean} [loop]
     *  @param {boolean} [paused] - Start it paused
     *  @return {SoundInstance|undefined} - undefined when out of range or sound is off */
    playSound(sound, pos3D, volume=1, pitch=1, randomnessScale=1, loop=false, paused=false)
    {
        // the 3D range fade and pan, Sound.play with no position does the rest
        ASSERT(sound instanceof Sound, 'sound must be a Sound');
        ASSERT(isVector3(pos3D), 'pos3D must be a vec3');
        const offset = pos3D.subtract(this.camera.pos), range = sound.range;
        if (range)
        {
            const distance = offset.length();
            if (distance > range)
                return; // out of range
            const taperRange = range * sound.taper;
            if (distance > taperRange)
                volume *= percent(distance, range, taperRange);
        }
        const instance = sound.play(undefined, volume, pitch, randomnessScale, loop, paused);
        instance?.setPan(offset.normalize().dot(this.cameraRight));
        return instance;
    }

    /** Play a sound on a loop at a 3D position, the same as playSound with loop on
     *  - Its volume and pan are set when it starts, change or stop it through the SoundInstance returned
     *  @param {Sound} sound
     *  @param {Vector3} pos3D
     *  @param {number} [volume]
     *  @param {number} [pitch]
     *  @param {number} [randomnessScale] - How much to scale pitch randomness
     *  @return {SoundInstance|undefined} - undefined when out of range or sound is off */
    playSoundLoop(sound, pos3D, volume=1, pitch=1, randomnessScale=1)
    { return this.playSound(sound, pos3D, volume, pitch, randomnessScale, true); }

    /** Is any part of a sphere on screen this frame, the test that skips meshes the camera cannot see
     *  - While the shadow map is drawing it tests the shadow area instead
     *  @param {Vector3} center
     *  @param {number} radius
     *  @return {boolean} */
    isSphereVisible(center, radius) { return render3DSphereVisible(center.x, center.y, center.z, radius); }

    ///////////////////////////////////////////////////////////////////////////
    // Meshes and the stream

    /** Draw a mesh with the current draw state, batched with its other uses in the opaque stage when instancing is on
     *  @param {Mesh} mesh
     *  @param {Matrix4|Vector3} [matrix] - Object transform, or just a position to draw it at
     *  @param {TileInfo|TextureInfo} [tileInfo] - Texture, mesh uvs map across the tile or the whole texture
     *  @param {Color} [color] - Tint
     *  @return {void} */
    drawMesh(mesh, matrix=RENDER3D_IDENTITY, tileInfo, color=WHITE)
    {
        matrix = render3DMatrix(matrix);
        ASSERT(!tileInfo || tileInfo instanceof TileInfo || tileInfo instanceof TextureInfo,
            'tileInfo must be a TileInfo or TextureInfo, it comes before color');
        ASSERT(isColor(color), 'color must be a Color');
        if (this.capture)
            return void this.capture.combine(mesh, matrix, color);
        if (this.transparentQueue)
        {
            // the queue replays later, so it keeps copies of what a caller may reuse, like a scratch matrix
            const m = matrix.copy(), c = color.copy();
            return this.queueTransparent(m.getTranslation(), ()=> this.drawMesh(mesh, m, tileInfo, c));
        }
        if (!render3DCanDraw()) return;
        if (this.shadowPass && !this.lighting) return; // unlit things cast no shadow
        render3DMeshUpload(mesh);
        if (!mesh.bufferCount) return;
        const m = matrix.m;
        if (this.frustumCulling && !render3DSphereVisible(m[12], m[13], m[14], mesh.radius * render3DMaxStretch(m)))
            return;
        // the mesh says whether its back faces can be skipped, and a mirroring transform, one with a negative
        // determinant, turns the winding around so the other one is its front
        const cullBackFaces = this.cullBackFaces, mirrored = this.mirrored;
        this.cullBackFaces = !mesh.doubleSided;
        this.mirrored = render3DMirrors(matrix);
        // the stage draws the batch at its end
        if (!this.blend && this.depthTest && (mesh.instanced ?? this.instancing))
            render3DInstance(mesh, matrix, tileInfo, color);
        else
        {
            this.flush();
            render3DFlushBeforeOverlay();
            render3DSetDrawUniforms(matrix, tileInfo, color);
            render3DBindMesh(mesh);
            glContext.drawElements(glContext.TRIANGLES, mesh.bufferCount, mesh.indexType, 0);
            ++drawCount;
            primitiveCount += mesh.bufferCount / 3;
        }
        this.cullBackFaces = cullBackFaces, this.mirrored = mirrored;
    }

    /** Draw a triangle strip, batched into the stream with the current draw state
     *  - Strip order: the first three points make a triangle, then each point makes another with the two before it
     *  - List the first three points counter clockwise as seen from the front, or the face points away
     *    and may vanish when back faces are culled
     *  - inside a bake the strip goes into the mesh instead, in the transparent stage it is queued for sorting
     *    and the arrays are read when the queue replays, so leave them unchanged until the stage ends
     *  @param {Array<Vector3>} points - In strip order
     *  @param {Vector3|Array<Vector3>} [normals] - One for all or one per point, default up
     *  @param {Vector2|Array<Vector2>} [uvs] - One for all or one per point, 0-1 across the tile
     *  @param {Color|Array<Color>} [colors] - One for all or one per point, vertex colors come before the texture
     *  @param {TileInfo|TextureInfo} [tileInfo] - Texture for this strip
     *  @return {void} */
    drawStrip(points, normals, uvs, colors, tileInfo)
    {
        if (this.capture)
        {
            this.capture.addStrip(points, normals, uvs, colors);
            return;
        }
        if (this.transparentQueue)
        {
            // sort by the center of the strip
            let x = 0, y = 0, z = 0;
            for (const p of points)
                x += p.x, y += p.y, z += p.z;
            return this.queueTransparent(vec3(x, y, z).scale(1 / points.length),
                ()=> this.drawStrip(points, normals, uvs, colors, tileInfo));
        }
        ASSERT(isArray(points) && points.length > 2, 'strip needs at least 3 points');
        const n = points.length, count = render3DStripCount(n);
        const uvRect = render3DBeginStrip(count, tileInfo);
        if (!uvRect) return;

        // the tile rect is applied to each uv now, so the whole texture maps at flush
        const floats = this.streamFloats, ints = this.streamInts;
        const normalArray = isArray(normals), uvArray = isArray(uvs), colorArray = isArray(colors);
        const rgba = colorArray ? 0 : (colors || WHITE).rgbaInt();
        for (let k = 0; k < count; ++k)
        {
            const i = render3DStripIndex(k, n), p = points[i];
            const uv = uvArray ? uvs[i] : uvs || RENDER3D_DEFAULT_UV;
            render3DWriteVertex(floats, ints, this.streamCount++ * RENDER3D_VERTEX_FLOATS, p.x, p.y, p.z,
                normalArray ? normals[i] : normals || RENDER3D_DEFAULT_NORMAL,
                uvRect.x + uv.x * uvRect.w, uvRect.y + uv.y * uvRect.h, colorArray ? colors[i].rgbaInt() : rgba);
        }
    }

    /** Draw a strip with lighting off, for camera facing shapes where the light direction means nothing
     *  @param {Array<Vector3>} points - Strip order
     *  @param {Vector3|Array<Vector3>} [normals]
     *  @param {Vector2|Array<Vector2>} [uvs]
     *  @param {Color|Array<Color>} [colors]
     *  @param {TileInfo|TextureInfo} [tileInfo] */
    drawStripUnlit(points, normals, uvs, colors, tileInfo)
    { render3DWithState({lighting: false}, ()=> this.drawStrip(points, normals, uvs, colors, tileInfo)); }

    /** Draw the pending stream vertices as one strip with the state they were drawn under, called automatically when needed */
    flush()
    {
        if (!this.streamCount || !render3DCanDraw()) return;
        const gl = glContext;
        render3DSetDrawUniforms(RENDER3D_IDENTITY, this.streamTileInfo, WHITE, RENDER3D_FULL_UV_RECT, this.streamState);
        render3DBindVertexBuffer(this.streamBuffer);
        gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.streamFloats, 0, this.streamCount * RENDER3D_VERTEX_FLOATS);
        gl.drawArrays(gl.TRIANGLE_STRIP, 0, this.streamCount);
        ++drawCount;
        primitiveCount += this.streamCount;
        this.streamCount = 0;
    }

    /** Build a mesh once out of draw calls, instead of redrawing the shapes every frame
     *  - Call the same drawStrip, drawQuad and drawBox calls inside, and get a mesh back
     *  - Strips inside a bake ignore their tileInfo, the finished mesh picks the texture when it draws
     *  - drawMesh, drawBox and drawSphere copy their mesh in, moved and tinted, their tileInfo dropped too
     *  - The mesh skips its back faces like any, set doubleSided when what was drawn is open
     *  @param {Function} drawFunction
     *  @return {Mesh} */
    bake(drawFunction)
    {
        this.flush();
        ASSERT(!this.capture, 'bake cannot be nested');
        const mesh = this.capture = new Mesh;
        try { drawFunction(); }
        finally { this.capture = undefined; }
        return mesh;
    }

    ///////////////////////////////////////////////////////////////////////////
    // The stages, run by the pass

    /** Draw a layer's objects, solid ones first and see through ones after, called automatically
     *  - The main layer also draws the sky, the render callbacks and the debug shapes
     *  @param {Array<EngineObject3D>} objects
     *  @param {boolean} [isDefault] */
    renderStages(objects, isDefault=true)
    {
        const opaque = [], transparent = [];
        for (const o of objects)
            (o.transparent || o.additive ? transparent : opaque).push(o);

        isDefault && this.sky && this.drawSky();

        // opaque: no blending, depth writes on, by render order
        this.blend = false;
        this.depthWrite = true;
        const byOrder = (a, b)=> a.renderOrder - b.renderOrder;
        opaque.sort(byOrder);
        transparent.sort(byOrder);
        render3DDrawObjects(opaque);
        isDefault && this.onRenderOpaque?.();
        this.flush();
        render3DFlushInstances();

        // transparent: blending on, depth writes off, every draw queued then replayed far to near
        this.blend = true;
        this.depthWrite = false;
        this.transparentQueue = this.sortTransparent ? [] : undefined;
        try
        {
            render3DDrawObjects(transparent);
            for (const o of objects)
                if (o.softShadow)
                {
                    // the shadow grows with the object, by the same scale picking and culling
                    // measure it at, so one size set once holds however the object is scaled
                    const m = render3DObjectMatrix(o);
                    this.drawSoftShadow(m.getTranslation(), o.softShadow * render3DMaxScale(m.m), this.softShadowHeight);
                }
            isDefault && this.onRenderTransparent?.();
        }
        finally { this.flushTransparentQueue(); }
        isDefault && render3DRenderDebug();
        this.flush();

        // leave the fields at the opaque defaults for anything reading them outside the pass
        render3DSetObjectState();
        this.blend = false;
        this.depthWrite = true;
    }

    /** Queue a draw for the transparent stage, replayed far to near with the current draw state, or draw it now when
     *  sorting is off
     *  - The draw runs later, so it should hold copies of any values the caller may change before then
     *  @param {Vector3} pos - Where the draw is, for sorting
     *  @param {function(): void} draw
     *  @return {void} */
    queueTransparent(pos, draw)
    {
        if (!this.transparentQueue)
        {
            draw();
            return;
        }
        // sort by depth along the view, not distance, so an orthographic camera orders them right too
        const f = this.cameraForward, c = this.camera.pos;
        const distance = (pos.x - c.x)*f.x + (pos.y - c.y)*f.y + (pos.z - c.z)*f.z;
        this.transparentQueue.push({distance, state: render3DCaptureBatchState(), draw});
    }

    /** Draw the queued transparent draws far to near with the state each was drawn under, called automatically at the
     *  end of the transparent stage */
    flushTransparentQueue()
    {
        const queue = this.transparentQueue;
        if (!queue) return;
        this.transparentQueue = undefined;
        queue.sort((a, b)=> b.distance - a.distance);
        // each draw under the state it was queued with, and the state left as it was found
        const state = render3DCaptureBatchState();
        try
        {
            for (const item of queue)
                render3DApplyBatchState(item.state), item.draw();
        }
        finally { render3DApplyBatchState(state); }
    }

    /** Draw render3D.sky around the camera, unlit, unfogged and behind everything, called automatically by the pass */
    drawSky()
    {
        this.flush();
        // the dome only has to sit between the clip planes, the pass draws it first with no depth test;
        // a far plane at Infinity has no midpoint, so put it a long way out instead
        const {near, far} = this.camera;
        const radius = far == Infinity ? near * 1e4 : (near + far) / 2;
        render3DWithState({lighting: false, blend: false, depthTest: false, depthWrite: false, fogEnd: 0, shader: undefined,
            emissiveMap: undefined}, ()=>
            this.drawMesh(this.sky, buildMatrix(this.camera.pos, undefined, vec3(radius))));
    }

    /** Rebuild the light's view projection around the shadow center, called automatically each frame shadows are on */
    updateShadowMatrix()
    {
        ASSERT(this.shadowRange > 0, 'shadowRange must be positive');
        const range = this.shadowRange > 0 ? this.shadowRange : 1, half = range / 2;
        const toSun = this.sunDirection.normalize();
        const center = this.shadowCenter || this.camera.pos.add(this.cameraForward.scale(half * .8));
        const view = Matrix4.lookAt(center.add(toSun.scale(range)), center).invert();
        // move the light's view in whole pixel steps so shadow edges do not crawl as the camera moves
        const texel = range / (this.shadowTextureSize || this.shadowMapSize), m = view.m; // no texture in headless mode
        m[12] = round(m[12] / texel) * texel;
        m[13] = round(m[13] / texel) * texel;
        this.shadowMatrix = Matrix4.orthographic(-half, half, -half, half, 0, range * 2).multiply(view);
        this.shadowPlanes = render3DFrustumPlanes(this.shadowMatrix);
    }

    /** Build a sky dome, set it as the sky, and light the scene by it: the fog takes the horizon color, and the
     *  ambient light comes from the top color above and the bottom color below, both at the ambient strength
     *  @param {Color} [topColor] - Straight up
     *  @param {Color} [horizonColor] - Level with the camera
     *  @param {Color} [bottomColor] - Straight down, defaults to the horizon color
     *  @param {number} [ambient] - How much of the sky colors lights the scene as ambient, 0 for none
     *  @return {Mesh} - The dome, also in render3D.sky */
    setSky(topColor=hsl(.6, .8, .55), horizonColor=hsl(.6, 1, .9), bottomColor=horizonColor, ambient=.5)
    {
        this.sky?.dispose();
        this.sky = buildSky(topColor, horizonColor, bottomColor);
        this.fogColor = horizonColor.copy();
        this.ambientColor = topColor.scale(ambient, 1);
        this.ambientGroundColor = bottomColor.scale(ambient, 1);
        return this.sky;
    }

    /** Set where fog starts and ends, and its color
     *  @param {number} fogStart - Distance from the camera where fog starts
     *  @param {number} fogEnd - Distance where fog is total, 0 disables fog
     *  @param {Color} [fogColor] - Leaves the color alone when not passed, setSky sets it to the horizon */
    setFog(fogStart, fogEnd, fogColor)
    {
        this.fogStart = fogStart;
        this.fogEnd = fogEnd;
        if (fogColor)
            this.fogColor = fogColor.copy();
    }

    ///////////////////////////////////////////////////////////////////////////
    // Immediate mode shapes

    /** Draw a box, untextured, for blocking out a scene without meshes or objects
     *  @param {Vector3} pos - Center
     *  @param {Vector3|number} [size] - Full size, a number for a cube
     *  @param {Color} [color]
     *  @param {Vector3} [rotation] - vec3(pitch, yaw, roll) */
    drawBox(pos, size=1, color=WHITE, rotation)
    {
        this.drawMesh(this.boxMesh, buildMatrix(pos, rotation, render3DSize3(size)), undefined, color);
    }

    /** Draw a sphere, untextured and smooth shaded
     *  @param {Vector3} pos - Center
     *  @param {number} [size] - Diameter
     *  @param {Color} [color] */
    drawSphere(pos, size=1, color=WHITE)
    {
        this.drawMesh(this.sphereMesh, buildMatrix(pos, undefined, vec3(size)), undefined, color);
    }

    /** Draw a flat square that always faces the camera, unlit so it keeps its own colors
     *  - Draw it from onRenderTransparent or a transparent object so it can fade
     *  @param {Vector3} pos - Center
     *  @param {Vector2} [size] - World units
     *  @param {TileInfo|TextureInfo} [tileInfo]
     *  @param {Color} [color]
     *  @param {number} [angle] - Rotation in the camera plane, counter clockwise
     *  @param {boolean} [upright] - Stand on world up and only turn to face the camera, for sprites on the ground
     *  @return {void} */
    drawBillboard(pos, size=vec2(1), tileInfo, color=WHITE, angle=0, upright=false)
    {
        if (this.capture) // the mesh keeps the color, so it gets its own, the particles reuse theirs
            return this.drawStripUnlit(render3DBillboardCorners(pos, size, angle, upright), this.cameraBack,
                RENDER3D_QUAD_UVS, color.copy(), tileInfo);
        if (this.transparentQueue) // sort by the exact position, a shadow under it sorts by the floor
        {
            const p = pos.copy(), s = size.copy(), c = color.copy(); // copies, the queue replays later
            return this.queueTransparent(p, ()=> this.drawBillboard(p, s, tileInfo, c, angle, upright));
        }

        // the quad's six stream vertices written straight in with no vectors made, unlit, for sprites, and for
        // particles when instancing is off
        const lit = this.lighting;
        this.lighting = this.shadowPass && lit; // unlit on screen, in the shadow map the object's flag decides
        let uvRect;
        try { uvRect = render3DBeginStrip(6, tileInfo); }
        finally { this.lighting = lit; }
        if (!uvRect) return;
        const a = render3DBillboardAxes(size, angle, upright);
        const rx = a[0], ry = a[1], rz = a[2], ux = a[3], uy = a[4], uz = a[5];
        const x = pos.x, y = pos.y, z = pos.z, n = this.cameraBack, rgba = color.rgbaInt();
        const u0 = uvRect.x, v0 = uvRect.y, u1 = u0 + uvRect.w, v1 = v0 + uvRect.h;
        const floats = this.streamFloats, ints = this.streamInts, stride = RENDER3D_VERTEX_FLOATS;
        let j = this.streamCount * stride;
        this.streamCount += 6;
        // the corners in strip order with the repeats: top left twice, bottom left, top right, bottom right twice
        render3DWriteVertex(floats, ints, j, x - rx + ux, y - ry + uy, z - rz + uz, n, u0, v0, rgba);
        render3DWriteVertex(floats, ints, j += stride, x - rx + ux, y - ry + uy, z - rz + uz, n, u0, v0, rgba);
        render3DWriteVertex(floats, ints, j += stride, x - rx - ux, y - ry - uy, z - rz - uz, n, u0, v1, rgba);
        render3DWriteVertex(floats, ints, j += stride, x + rx + ux, y + ry + uy, z + rz + uz, n, u1, v0, rgba);
        render3DWriteVertex(floats, ints, j += stride, x + rx - ux, y + ry - uy, z + rz - uz, n, u1, v1, rgba);
        render3DWriteVertex(floats, ints, j += stride, x + rx - ux, y + ry - uy, z + rz - uz, n, u1, v1, rgba);
    }

    /** Draw a quad from four corners in loop order, counter clockwise seen from the front, a is the top left of the texture
     *  @param {Vector3} a
     *  @param {Vector3} b
     *  @param {Vector3} c
     *  @param {Vector3} d
     *  @param {TileInfo|TextureInfo} [tileInfo]
     *  @param {Color|Array<Color>} [color] - One for all or one per corner */
    drawQuad(a, b, c, d, tileInfo, color=WHITE)
    {
        if (this.transparentQueue) // the queue replays later, so it keeps copies of what a caller may reuse
        {
            a = a.copy(), b = b.copy(), c = c.copy(), d = d.copy();
            color = isArray(color) ? color.map(k=> k.copy()) : color.copy();
        }
        this.drawStrip(render3DQuadStrip(a, b, c, d), render3DFaceNormal(a, b, c, d), RENDER3D_QUAD_UVS,
            render3DQuadValues(color), tileInfo);
    }

    /** Draw a triangle, counter clockwise from outside is the front
     *  @param {Vector3} a
     *  @param {Vector3} b
     *  @param {Vector3} c
     *  @param {Color} [color] */
    drawTriangle(a, b, c, color=WHITE)
    {
        if (this.transparentQueue) // the queue replays later, so it keeps copies of what a caller may reuse
            a = a.copy(), b = b.copy(), c = c.copy(), color = color.copy();
        this.drawStrip([a, b, c], render3DFaceNormal(a, b, c), undefined, color);
    }

    /** Draw a line as a camera facing ribbon, unlit
     *  @param {Vector3} posA
     *  @param {Vector3} posB
     *  @param {number} [width]
     *  @param {Color} [color] */
    drawLine(posA, posB, width=.1, color=WHITE)
    {
        this.drawRibbon([posA, posB], width, undefined, color);
    }

    /** Draw a ribbon along a path, unlit and visible from both sides; width and color can change along it
     *  - The texture runs along the length, u from the first point to the last
     *  - A path that ends where it starts is a loop, and joins with no seam
     *  @param {Array<Vector3>} points - Center line in order, at least two
     *  @param {number|Array<number>} [width] - Full width, one for all or one per point
     *  @param {TileInfo|TextureInfo} [tileInfo]
     *  @param {Color|Array<Color>} [color] - One for all or one per point
     *  @param {Vector3|Array<Vector3>} [side] - Direction across the ribbon, one for all or one per point, default
     *    faces the camera */
    drawRibbon(points, width=.1, tileInfo, color=WHITE, side)
    {
        const count = points.length;
        ASSERT(count > 1, 'a ribbon needs at least two points');
        ASSERT(!tileInfo || tileInfo instanceof TileInfo || tileInfo instanceof TextureInfo,
            'tileInfo must be a TileInfo or TextureInfo, it comes before color');
        const strip = [], uvs = tileInfo ? [] : undefined, colors = [], forward = this.cameraForward;
        let across = vec3(1, 0, 0); // kept from the last point where the direction vanishes
        // a loop's two ends take their direction across the join, so they meet edge to edge
        const loop = count > 2 && points[0].distanceSquared(points[count - 1]) < 1e-12;
        for (let i = 0; i < count; ++i)
        {
            const p = points[i];
            const w = isArray(width) ? width[i] : width;
            let c = isArray(color) ? color[i] : color;
            if (this.transparentQueue) // the queue replays later, so it keeps a copy of a color a caller may reuse
                c = c.copy();
            const s = side && (isArray(side) ? side[i] : side);
            // across the path in the camera plane unless a side is given
            const next = points[i < count - 1 ? i + 1 : loop ? 1 : i];
            const last = points[i > 0 ? i - 1 : loop ? count - 2 : i];
            const dir = s || next.subtract(last).cross(forward);
            if (dir.lengthSquared() > 1e-12)
                across = dir.normalize();
            const half = across.scale(w / 2);
            strip.push(p.add(half), p.subtract(half));
            uvs?.push(vec2(i / (count - 1), 0), vec2(i / (count - 1), 1));
            colors.push(c, c);
        }
        render3DWithState({lighting: false, cullBackFaces: false},
            ()=> this.drawStrip(strip, forward.scale(-1), uvs, colors, tileInfo));
    }

    /** Draw a disc that fades to transparent at the rim, unlit, for glows, puffs and sky dots
     *  @param {Vector3} pos - Center
     *  @param {number} [size] - Diameter
     *  @param {Color} [color]
     *  @param {Vector3} [normal] - Facing direction, faces the camera by default
     *  @param {number} [sides]
     *  @return {void} */
    drawSoftDisc(pos, size=1, color=WHITE, normal=this.cameraBack, sides=16)
    {
        render3DAssertBlending();
        if (this.transparentQueue && !this.capture)
        {
            const p = pos.copy(), c = color.copy(), n = normal.copy(); // copies, the queue replays later
            return this.queueTransparent(p, ()=> this.drawSoftDisc(p, size, c, n, sides));
        }
        // basis in the disc's plane
        const n = normal.normalize();
        const helper = abs(n.y) < .9 ? vec3(0, 1, 0) : vec3(1, 0, 0);
        const u = helper.cross(n).normalize(), w = u.cross(n);
        render3DDrawSoftDisc(size / 2, color, sides, n, (c, s, r)=>
            vec3(pos.x + (u.x * c + w.x * s) * r, pos.y + (u.y * c + w.y * s) * r, pos.z + (u.z * c + w.z * s) * r));
    }

    /** Draw a soft round shadow on the ground under something, much cheaper than a real shadow
     *  - Draw it from onRenderTransparent or from a transparent object
     *  @param {Vector3} pos - Position of the thing casting the shadow
     *  @param {number} [size] - Diameter
     *  @param {number|HeightMap|function(number, number): number} [floorHeight] - Height of the ground, a HeightMap, or
     *    (x, z) => y to follow terrain
     *  @param {Color} [color]
     *  @param {number} [lift] - How far above the ground to draw, raise it if the shadow cuts into rough ground
     *  @return {void} */
    drawSoftShadow(pos, size=1, floorHeight=0, color=RENDER3D_SHADOW_COLOR, lift=.02)
    {
        render3DAssertBlending();
        // a HeightMap is in the extras plugin, so it is known by its getHeight rather than its class
        const heightMap = /** @type {HeightMap} */ (floorHeight);
        const height = /** @type {function(number, number): number} */ (isNumber(floorHeight) ? ()=> floorHeight
            : heightMap.getHeight ? (x, z)=> heightMap.getHeight(x, z) : floorHeight);
        if (this.transparentQueue && !this.capture) // sort from the floor, under whatever casts it
        {
            const p = pos.copy(), c = color.copy(); // copies, the queue replays later
            return this.queueTransparent(vec3(p.x, height(p.x, p.z) + lift, p.z),
                ()=> this.drawSoftShadow(p, size, floorHeight, c, lift));
        }
        render3DDrawSoftDisc(size / 2, color, 16, RENDER3D_DEFAULT_NORMAL, (c, s, r)=>
        {
            const x = pos.x + c * r, z = pos.z + s * r;
            return vec3(x, height(x, z) + lift, z);
        });
    }
}

function render3DAssertBlending()
{
    const r = render3D;
    ASSERT(r.blend || r.capture || r.shadowPass || !r.isRendering,
        'soft discs and shadows need blending: set the object transparent or draw from onRenderTransparent');
}

// draw the three rings of a soft disc as unlit strips, pointAt(cos, sin, radius) gives the world point
function render3DDrawSoftDisc(radius, color, sides, normal, pointAt)
{
    const alpha = [1, .9, .7, 0], circle = render3DCircle(sides); // alpha by ring, center to rim
    for (let k = 0; k < 3; ++k)
    {
        const points = [], colors = [];
        const c0 = color.withAlpha(color.a * alpha[k]), c1 = color.withAlpha(color.a * alpha[k+1]);
        const r0 = radius * k / 3, r1 = radius * (k + 1) / 3;
        for (let i = 0; i <= sides; ++i)
        {
            const c = circle[i*2], s = circle[i*2 + 1];
            points.push(pointAt(c, s, r1), pointAt(c, s, r0));
            colors.push(c1, c0);
        }
        render3D.drawStripUnlit(points, normal, undefined, colors);
    }
}

///////////////////////////////////////////////////////////////////////////////
/**
 * Camera3D - Position, rotation and lens for the 3D view
 * - Looks down its -Z axis, rotation is vec3(pitch, yaw, roll)
 * @memberof Render3D
 */
class Camera3D
{
    /** Create a camera, looking down -Z from z=10 by default */
    constructor()
    {
        /** @property {Vector3} - World position */
        this.pos = vec3(0, 0, 10);
        /** @property {Vector3} - Euler rotation, vec3(pitch, yaw, roll) in radians */
        this.rotation = vec3();
        /** @property {number} - Vertical field of view in radians */
        this.fov = PI/3;
        /** @property {number} - Near clip distance */
        this.near = .1;
        /** @property {number} - Far clip distance, Infinity is allowed for a perspective view */
        this.far = 1e3;
        /** @property {number} - Visible height in world units for an orthographic view, 0 is perspective */
        this.orthographic = 0;
        /** @property {boolean} - Line the 3D camera up with the 2D camera, so 3D things at z=0 sit on the 2D sprites */
        this.align2D = false;
    }

    /** Returns the camera's world transform
     *  @return {Matrix4} */
    getMatrix() { return buildMatrix(this.pos, this.rotation); }

    /** Returns the view matrix, world to camera space
     *  @return {Matrix4} */
    getViewMatrix() { return this.getMatrix().invert(); }

    /** Returns the projection matrix
     *  @param {number} aspect - Width over height
     *  @return {Matrix4} */
    getProjectionMatrix(aspect)
    {
        const h = this.orthographic / 2, w = h * aspect;
        return h ? Matrix4.orthographic(-w, w, -h, h, this.near, this.far) :
            Matrix4.perspective(this.fov, aspect, this.near, this.far);
    }

    /** Returns the direction the camera looks
     *  @return {Vector3} */
    getForward() { return render3DAxis(this.getMatrix().m, 8).scale(-1); }

    /** Returns the camera's right axis
     *  @return {Vector3} */
    getRight() { return render3DAxis(this.getMatrix().m, 0); }

    /** Returns the camera's up axis
     *  @return {Vector3} */
    getUp() { return render3DAxis(this.getMatrix().m, 4); }

    /** Point the camera at a target, sets pitch and yaw and clears roll
     *  @param {Vector3} target */
    lookAt(target) { this.rotation = render3DLookRotation(target.subtract(this.pos), this.rotation); }

    /** Put the camera on an orbit around a target, looking at it
     *  @param {Vector3} target
     *  @param {number} distance
     *  @param {number} yaw - Radians around Y
     *  @param {number} [pitch] - Radians above the horizon */
    orbit(target, distance, yaw, pitch=.5)
    {
        const r = cos(pitch) * distance;
        this.pos = target.add(vec3(sin(yaw) * r, sin(pitch) * distance, cos(yaw) * r));
        // straight down has no yaw of its own to look along, so the orbit's yaw is used, and a top down view turns
        this.rotation = render3DLookRotation(target.subtract(this.pos), vec3(0, yaw, 0));
    }

    /** Chase a target from an offset, easing toward it, and look at it
     *  @param {Vector3} target
     *  @param {Vector3} offset - Where to sit relative to the target
     *  @param {number} [percent] - How far to move toward the spot each call, 1 snaps */
    follow(target, offset, percent=1)
    {
        this.pos = this.pos.lerp(target.add(offset), percent);
        this.lookAt(target);
    }

    /** Line the 3D camera up with the 2D camera, called automatically when align2D is set
     *  @param {number} [canvasHeight] - Defaults to the main canvas height */
    update2D(canvasHeight=mainCanvasSize.y)
    {
        const halfHeight = canvasHeight / 2 / cameraScale; // half visible height in world units
        const distance = halfHeight / tan(this.fov/2);
        // a zoomed out 2D camera sits a long way back, far enough to fall past the far plane and
        // clip the whole scene away, which looks like nothing rendering at all
        ASSERT(!canvasHeight || distance < this.far,
            'align2D needs this camera distance to match the 2D view, raise camera.far past it', distance);
        this.orthographic &&= halfHeight * 2; // an orthographic camera stays orthographic and shows the same height
        this.pos = vec3(cameraPos.x, cameraPos.y, distance);
        this.rotation = vec3(0, 0, -cameraAngle); // 2D angles turn the other way
    }
}

///////////////////////////////////////////////////////////////////////////////
// GL setup, shaders and the frame hooks

// the four attributes of a 36 byte vertex at the locations the shaders declare: location, size, type, normalize, byte offset
/** @type {Array<[number, number, number, boolean, number]>} */
const RENDER3D_ATTRIBS = [[0, 3, 5126, false, 0], [1, 3, 5126, false, 12], [2, 2, 5126, false, 24], [3, 4, 5121, true, 32]];

// the vertex shader, shared by the plugin's program and every Shader's
// attributes: p position, n normal, t uv, c color, at fixed slots the depth shader also uses
// uniforms: viewProj, lightViewProj; the model matrix, the tint and the uv rect are vertex attributes, see
// RENDER3D_VERTEX_INPUTS; L is the mesh's own uv for a Shader's localUV
// the normal matrix comes from the model matrix here: each column over its squared length, which is the inverse
// transpose of any rotation and scale, mirrored or not, and skips a 3x3 inverse per draw on the CPU; a sheared
// matrix, one built by multiplying rotations with scales between them, gets normals that are only close
const RENDER3D_VERTEX_SOURCE =
    '#version 300 es\n' +
    'precision highp float;' +
    'uniform mat4 viewProj,lightViewProj;' +
    RENDER3D_VERTEX_INPUTS +
    'out vec3 P,N;out vec2 T,L;out vec4 C,S;' +
    'void main(){' +
    'vec4 w=mat4(m0,m1,m2,m3)*vec4(p,1.);' +
    'gl_Position=viewProj*w;' +
    'P=w.xyz;' +
    'vec3 c0=m0.xyz,c1=m1.xyz,c2=m2.xyz;' +
    'N=mat3(c0/dot(c0,c0),c1/dot(c1,c1),c2/dot(c2,c2))*n;' +
    'T=uvRect.xy+t*uvRect.zw;' +
    'L=t;' +
    'C=c*tint;' +
    'S=lightViewProj*w;' +
    '}';

// the names a Shader's snippet can use in 3D, over the plugin's own uniforms and varyings
const RENDER3D_SNIPPET_NAMES =
    'uniform float iTime;uniform vec3 iResolution;\n' +
    '#define iChannel0 tex\n' +
    '#define localUV L\n' +
    '#define worldPos P\n' +
    '#define worldNormal N\n' +
    '#define sunDirection (-lightDir.xyz)\n' +
    '#define sunColor lightColor.rgb\n' +
    '#define ambientColor ambientFog.rgb\n' +
    '#define ambientGroundColor ambientGround.rgb\n' +
    '#define lightCount extraLightCount\n' +
    '#define lights extraLights\n' +
    '#define lightColors extraLightColors\n';

// the fragment shader; given a Shader's snippet, its mainImage replaces the texture sample and all else is the same
// uniforms: lightDir (xyz the way the sunlight travels, w = emissive, 1 or more skips the lighting),
//   lightColor (the sun's rgb, a = specular), ambientFog (rgb, a = fogEnd), fogColor (rgb, a = fogStart),
//   cameraPos, tex, shadowMap, shadowParams (x = shadows on, y = bias, z = blur step in texture space,
//   w = how the draw finishes: 1 opaque and alpha tested, 0 blended, -1 additive),
//   materialParams (x = normal map scale, 0 skips it, y = shininess, z = reflectivity), emissiveTint (rgb, a = the
//   emissive map is on), skyTop, skyHorizon, skyBottom (what a reflection shows), normalTex, emissiveTex
function render3DFragmentSource(fragmentCode)
{
    return '#version 300 es\n' +
        'precision highp float;' +
        'uniform vec4 lightDir,lightColor,ambientFog,ambientGround,fogColor,shadowParams;' +
        'uniform vec4 extraLights[' + RENDER3D_MAX_LIGHTS + '],extraLightColors[' + RENDER3D_MAX_LIGHTS + '];' +
        'uniform int extraLightCount;' +
        'uniform vec3 cameraPos;' +
        'uniform vec4 materialParams,emissiveTint,skyTop,skyHorizon,skyBottom;' +
        'uniform sampler2D tex,normalTex,emissiveTex;' +
        'uniform bool premultipliedTexture;' + // is the texture a render target, which holds premultiplied color
        'uniform highp sampler2DShadow shadowMap;' +
        'in vec3 P,N;in vec2 T,L;in vec4 C,S;' +
        'out vec4 o;' +
        // the sun shadow at this fragment, 0 to 1: the light's depth map with a 3x3 blur, outside the map is lit
        'float shadow(){' +
        'if(shadowParams.x<=0.)return 1.;' +
        'vec3 q=S.xyz/S.w*.5+.5;' +
        'if(any(greaterThanEqual(abs(q-.5),vec3(.5))))return 1.;' +
        'q.z-=shadowParams.y;' +
        'float s=0.;' +
        'for(int x=-1;x<=1;++x)for(int y=-1;y<=1;++y)' +
        's+=texture(shadowMap,vec3(q.xy+vec2(x,y)*shadowParams.z,q.z));' +
        'return s/9.;}' +
        // the normal map's normal here, in the frame that the position and texture coordinate change along across the
        // screen, so a mesh needs no tangents; green points up the image and v runs down it, so up is -v;
        // a mesh with no texture coordinates has no frame and keeps its own normal
        'vec3 normalMapNormal(vec3 n){' +
        'vec3 p1=dFdx(P),p2=dFdy(P);vec2 t1=dFdx(T),t2=dFdy(T);' +
        'vec3 a=cross(p2,n),b=cross(n,p1),u=a*t1.x+b*t2.x,v=a*t1.y+b*t2.y;' +
        'float k=max(dot(u,u),dot(v,v));' +
        'if(k<=0.)return n;' +
        'vec3 m=texture(normalTex,T).xyz*2.-1.;' +
        'm.xy*=materialParams.x;' +
        'return normalize((u*m.x-v*m.y)*inversesqrt(k)+n*m.z);}' +
        (fragmentCode ? RENDER3D_SNIPPET_NAMES + fragmentCode + '\n' : '') +
        'void main(){' +
        (fragmentCode ? 'vec4 t;mainImage(t,T);' : 'vec4 t=texture(tex,T);') +
        'if(premultipliedTexture&&t.a>0.)t.rgb/=t.a;' + // back to straight color, what the lighting and blend expect
        'if(shadowParams.w>0.&&t.a<.5)discard;' + // an opaque draw drops see through texels, as the shadow map does
        'vec4 c=C*t;' +
        'float e=lightDir.w;' +
        'if(e<1.){' +
        'vec3 n=dot(N,N)>0.?normalize(N):vec3(0,1,0);' +
        'if(!gl_FrontFacing)n=-n;' + // only a double sided mesh shows a back face, light it on the side that is seen
        'if(materialParams.x!=0.)n=normalMapNormal(n);' +
        'float nl=dot(n,-lightDir.xyz);' +
        'float s=shadow();' +
        // the ambient: one color, or blended from the ground color below to the sky color above by the way the face points
        'vec3 l=(ambientGround.a>0.?mix(ambientGround.rgb,ambientFog.rgb,n.y*.5+.5):ambientFog.rgb)+lightColor.rgb*max(nl,0.)*s;' +
        // the Light3D objects: a point light falls off with distance, a directional one does not and carries the
        // direction toward it in xyz, marked by a negative radius; each adds its own highlight when there is a strength
        'vec3 eye=lightColor.a>0.?normalize(cameraPos-P):vec3(0),sp=vec3(0);' +
        'for(int i=0;i<' + RENDER3D_MAX_LIGHTS + ';++i){' +
        'if(i>=extraLightCount)break;' +
        'vec4 L=extraLights[i];' +
        'bool directional=L.w<0.;' +
        'vec3 v=directional?L.xyz:L.xyz-P;' +
        'float d=length(v);' +
        'float a=directional?1.:max(0.,1.-d/L.w);' +
        'v/=max(d,1e-6);' +
        'float ln=dot(n,v);' +
        'vec3 lc=extraLightColors[i].rgb*extraLightColors[i].a*a*a;' +
        'l+=lc*max(0.,ln);' +
        'if(lightColor.a>0.)sp+=lc*pow(max(dot(reflect(-v,n),eye),0.),materialParams.y)*step(0.,ln);' +
        '}' +
        'c.rgb*=l*(1.-e)+e;' + // lit, blended toward its own color by how emissive it is
        // specular: the sun's only where its light hits and out of shadow, then the Light3D highlights,
        // skipped entirely when the strength is zero
        'if(lightColor.a>0.){' +
        'vec3 r=reflect(lightDir.xyz,n);' +
        'c.rgb+=lightColor.rgb*pow(max(dot(r,eye),0.),materialParams.y)*lightColor.a*step(0.,nl)*s*(1.-e)+sp*lightColor.a*(1.-e);' +
        '}' +
        // the sky along the reflected view, more at a glancing angle (Schlick's Fresnel); sky light, so the sun's
        // shadow does not dim it
        'if(materialParams.z>0.){' +
        'vec3 w=normalize(P-cameraPos),q=reflect(w,n);' +
        'float f=materialParams.z+(1.-materialParams.z)*pow(1.-max(dot(n,-w),0.),5.);' +
        'c.rgb=mix(c.rgb,q.y>0.?mix(skyHorizon.rgb,skyTop.rgb,q.y):mix(skyHorizon.rgb,skyBottom.rgb,-q.y),f);' +
        '}}else c.rgb*=e;' + // fully emissive: its own color, or brighter, with no lighting to work out
        // the emissive map adds its light on top, lit or not
        'if(emissiveTint.a>0.)c.rgb+=texture(emissiveTex,T).rgb*emissiveTint.rgb;' +
        'if(ambientFog.a>0.){' +
        'float z=distance(cameraPos,P);' +
        'c.rgb=mix(c.rgb,shadowParams.w<0.?vec3(0):fogColor.rgb,smoothstep(fogColor.a,ambientFog.a,z));' +
        '}' +
        'o=vec4(c.rgb,shadowParams.w>0.?1.:c.a);' + // an opaque draw stays opaque whatever the tint alpha says
        '}';
}

// a Shader's 3D program, compiled the first time a draw needs it
function render3DShaderProgram(shader)
{
    ASSERT(shader instanceof Shader, 'render3D.shader must be a Shader, not the snippet itself');
    return shader.program3D ||= glCreateProgram(RENDER3D_VERTEX_SOURCE, render3DFragmentSource(shader.fragmentCode));
}

// make a program current for the pass and send it the pass uniforms: the matrices, the camera and the lights,
// plus the time and canvas size for a Shader's program; the per draw uniform cache starts over
function render3DUseProgram(program)
{
    const gl = glContext, r = render3D;
    gl.useProgram(r.currentProgram = program);
    r.uniformValues = {};
    gl.uniformMatrix4fv(render3DUniform('viewProj'), false, r.viewProjection.m);
    gl.uniformMatrix4fv(render3DUniform('lightViewProj'), false, r.shadowMatrix.m);
    gl.uniform1i(render3DUniform('tex'), 0);
    gl.uniform1i(render3DUniform('shadowMap'), 1);
    gl.uniform1i(render3DUniform('normalTex'), 2);
    gl.uniform1i(render3DUniform('emissiveTex'), 3);
    const c = r.camera.pos;
    gl.uniform3f(render3DUniform('cameraPos'), c.x, c.y, c.z);
    gl.uniform1i(render3DUniform('extraLightCount'), r.lightCount);
    if (r.lightCount)
    {
        gl.uniform4fv(render3DUniform('extraLights'), r.lightPositions, 0, r.lightCount * 4);
        gl.uniform4fv(render3DUniform('extraLightColors'), r.lightColors, 0, r.lightCount * 4);
    }
    if (program !== r.program)
    {
        gl.uniform1f(render3DUniform('iTime'), time);
        gl.uniform3f(render3DUniform('iResolution'), glCanvas.width, glCanvas.height, 1);
    }
}

function render3DInitGL()
{
    if (headlessMode) return;
    if (!glEnable || !glContext)
    {
        console.warn('Render3DPlugin: WebGL not enabled, construct the plugin in gameInit with glEnable set');
        return;
    }
    const gl = glContext, r = render3D;
    glFlush(); // a pending 2D batch draws now, while the engine's own buffer, vertex array and program are bound
    r.uniforms = new Map;
    r.uniformValues = {};
    render3DShadowCut = undefined; // the shadow shader is new too
    r.attribValues = []; // a fresh context has its own attribute defaults, so nothing sent before it counts

    // the shader, see RENDER3D_VERTEX_SOURCE and render3DFragmentSource
    r.program = glCreateProgram(RENDER3D_VERTEX_SOURCE, render3DFragmentSource());

    // the depth only shader for the shadow map, same vertex layout; see through pixels cast nothing, so sprites and
    // cut out textures cast their outline, and a blended object faded below half its alpha casts nothing, as it
    // draws; an opaque one draws solid whatever its tint alpha, and casts so
    r.shadowShader = glCreateProgram(
        '#version 300 es\n' +
        'precision highp float;' +
        'uniform mat4 viewProj;' +
        RENDER3D_VERTEX_INPUTS +
        'out vec2 T;out float A;' +
        'void main(){T=uvRect.xy+t*uvRect.zw;A=c.a*tint.a;gl_Position=viewProj*mat4(m0,m1,m2,m3)*vec4(p,1.);}'
        ,
        '#version 300 es\n' +
        'precision highp float;' +
        'uniform sampler2D tex;' +
        'uniform float cut;' + // 1 for a blended batch, cut by its tint and vertex alpha too, 0 by the texture only
        'in vec2 T;in float A;' +
        'void main(){if(texture(tex,T).a*mix(1.,A,cut)<.5)discard;}'
    );

    // the vertex array object with the attributes enabled once, pointers are set per buffer by render3DBindVertexBuffer
    // the per instance attributes get their divisor only while a batch has them on, see render3DDrawInstanced
    r.vao = gl.createVertexArray();
    gl.bindVertexArray(r.vao);
    for (const [location] of RENDER3D_ATTRIBS)
        gl.enableVertexAttribArray(location);

    // the stream buffer
    r.streamBuffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, r.streamBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, r.streamData.byteLength, gl.DYNAMIC_DRAW);
    r.streamCount = 0;
    r.instanceBuffers = [gl.createBuffer(), gl.createBuffer(), gl.createBuffer()];

    // white texture for untextured draws, and a one texel shadow map that keeps the shadow sampler valid until shadows
    // are on
    r.whiteTexture = glCreateTexture();

    r.samplers = new Map;
    r.samplerKey = undefined;
    render3DUpdateShadowMap(1);

    // hand the engine back its own buffer, vertex array and program, the 2D batch was flushed before they changed
    gl.bindBuffer(gl.ARRAY_BUFFER, glArrayBuffer);
    glSetInstancedMode(true);
}

function render3DContextLost()
{
    const r = render3D;
    r.program = r.currentProgram = r.shadowShader = r.vao = r.streamBuffer = r.whiteTexture = undefined;
    for (const shader of glShaderObjects)
        shader.program3D = undefined; // compiled again by the next draw
    r.lightCount = 0;
    r.instanceBuffers = [];
    r.samplers = new Map;
    r.samplerKey = undefined;
    render3DClearInstances();
    r.shadowFramebuffer = r.shadowTexture = undefined;
    r.shadowTextureSize = 0;
    r.cameraDepthFramebuffer = r.cameraDepthTexture = undefined;
    r.cameraDepthWidth = r.cameraDepthHeight = 0;
    r.streamCount = 0;
    ++r.contextGeneration; // every uploaded mesh is stale now, the soft dot is a TextureInfo the engine restores
}

function render3DContextRestored()
{
    render3DInitGL();
}

// a uniform location, looked up once per program
function render3DUniform(name, program=render3D.currentProgram)
{
    const u = render3D.uniforms;
    let cache = u.get(program);
    cache || u.set(program, cache = {});
    return cache[name] ??= glContext.getUniformLocation(program, name);
}

// the model matrix, the tint and the uv rect as constant attributes for one draw
function render3DDrawAttribs(m, tint, uvRect)
{
    const gl = glContext;
    gl.vertexAttrib4f(4, m[0], m[1], m[2], m[3]);
    gl.vertexAttrib4f(5, m[4], m[5], m[6], m[7]);
    gl.vertexAttrib4f(6, m[8], m[9], m[10], m[11]);
    gl.vertexAttrib4f(7, m[12], m[13], m[14], m[15]);
    render3DAttrib4f(11, tint.r, tint.g, tint.b, tint.a);
    render3DAttrib4f(12, uvRect.x, uvRect.y, uvRect.w, uvRect.h);
}

// set a constant vec4 attribute only when its value changed since the last time
function render3DAttrib4f(location, x, y, z, w)
{
    const values = render3D.attribValues, last = values[location] ||= [NaN, NaN, NaN, NaN]; // kept, not made per draw
    if (last[0] === x && last[1] === y && last[2] === z && last[3] === w)
        return;
    last[0] = x, last[1] = y, last[2] = z, last[3] = w;
    glContext.vertexAttrib4f(location, x, y, z, w);
}

// textures in 3D shrink into the distance far more than sprites do, so the pass samples them through their mipmaps;
// a sampler sets the filtering for the 3D pass only and leaves the engine's textures as they are for 2D; they are
// made as textures need them, and all dropped when the settings change
function render3DUpdateSamplers()
{
    const r = render3D, key = tilesPixelated + ' ' + r.anisotropy;
    if (r.samplerKey === key) return;
    r.samplerKey = key;
    for (const sampler of r.samplers.values())
        glContext.deleteSampler(sampler); // the set being replaced, a lost context empties this first
    r.samplers.clear();
}

// the sampler for a texture's wrap modes, smooth or hard edged, made the first time it is needed
function render3DSampler(wrap, pixelated)
{
    const gl = glContext, r = render3D, [wrapS, wrapT] = glWrapModes(wrap);
    const key = wrapS * 1e5 + wrapT * 2 + (pixelated ? 1 : 0); // the modes are 5 digit numbers
    let sampler = r.samplers.get(key);
    if (sampler) return sampler;
    sampler = gl.createSampler();
    const sharp = pixelated || tilesPixelated;
    gl.samplerParameteri(sampler, gl.TEXTURE_MAG_FILTER, sharp ? gl.NEAREST : gl.LINEAR);
    gl.samplerParameteri(sampler, gl.TEXTURE_MIN_FILTER, pixelated ? gl.NEAREST
        : tilesPixelated ? gl.NEAREST_MIPMAP_LINEAR : gl.LINEAR_MIPMAP_LINEAR);
    gl.samplerParameteri(sampler, gl.TEXTURE_WRAP_S, wrapS);
    gl.samplerParameteri(sampler, gl.TEXTURE_WRAP_T, wrapT);
    const anisotropy = gl.getExtension('EXT_texture_filter_anisotropic');
    if (anisotropy && !pixelated)
    {
        const most = gl.getParameter(anisotropy.MAX_TEXTURE_MAX_ANISOTROPY_EXT);
        gl.samplerParameterf(sampler, anisotropy.TEXTURE_MAX_ANISOTROPY_EXT, clamp(r.anisotropy, 1, most));
    }
    r.samplers.set(key, sampler);
    return sampler;
}

// bind the texture of a tile or texture, white when there is none or it is not loaded, with the 3D sampler that
// matches its wrap mode; the first time a texture is used in 3D it gets its mipmaps; on unit 0, the color texture,
// or another unit, the material maps, after which unit 0 is active again
function render3DBindTexture(tileInfo, state=render3D, unit=0)
{
    const gl = glContext, r = render3D;
    const textureInfo = render3DTextureOf(tileInfo);
    const texture = textureInfo?.glTexture || r.whiteTexture;
    unit && gl.activeTexture(gl.TEXTURE0 + unit);
    gl.bindTexture(gl.TEXTURE_2D, texture);
    if (texture === r.whiteTexture || !r.mipmaps && !state.pixelated)
        gl.bindSampler(unit, null); // the texture's own filtering, as in 2D; the white texel needs no mipmaps
                                    // or anisotropy, and filtering it that way costs every untextured fragment
    else
    {
        gl.bindSampler(unit, render3DSampler(textureInfo?.wrap, state.pixelated));
        glUpdateMipmaps(texture); // drawn into since its mipmaps were made
        if (!state.pixelated && !glMipmappedTextures.has(texture)) // a hard edged draw never reads them
        {
            glMipmappedTextures.add(texture); // the core makes them again when the texture changes
            gl.generateMipmap(gl.TEXTURE_2D);
        }
    }
    unit && gl.activeTexture(gl.TEXTURE0);
}

// the material's uniforms and maps for a draw: the normal map on unit 2 and the emissive map on unit 3, white when
// unused and bound only when they change, and the sky a reflection shows, the colors setSky was given or the
// ambient ones; with nothing set every part of it is skipped in the shader, and a map with no GL texture yet, or
// one freed, counts as none, since the white texture in its place would bend every normal or light the surface
function render3DSetMaterialUniforms(state)
{
    const r = render3D, loaded = (map)=> render3DTextureOf(map)?.glTexture ? map : undefined;
    const normalMap = state.normalScale ? loaded(state.normalMap) : undefined, emissiveMap = loaded(state.emissiveMap);
    render3DUniform4f('materialParams', normalMap ? state.normalScale : 0, state.shininess, state.reflectivity, 0);
    const ec = state.emissiveMapColor || WHITE;
    emissiveMap ? render3DUniform4f('emissiveTint', ec.r, ec.g, ec.b, 1) : render3DUniform4f('emissiveTint', 0, 0, 0, 0);
    render3DBindMap(2, normalMap, state);
    render3DBindMap(3, emissiveMap, state);
    if (state.reflectivity > 0)
    {
        const sky = r.sky && render3DSkyColors.get(r.sky), a = r.ambientColor, g = r.ambientGroundColor || a;
        const top = sky ? sky[0] : a, bottom = sky ? sky[2] : g;
        render3DUniform4f('skyTop', top.r, top.g, top.b, 1);
        render3DUniform4f('skyBottom', bottom.r, bottom.g, bottom.b, 1);
        sky ? render3DUniform4f('skyHorizon', sky[1].r, sky[1].g, sky[1].b, 1) :
            render3DUniform4f('skyHorizon', (a.r + g.r) / 2, (a.g + g.g) / 2, (a.b + g.b) / 2, 1);
    }
}

// what each material map unit has bound, index 2 and 3 the maps and 4 and 5 whether each is pixelated, forgotten at
// the start and end of each pass
let render3DBoundMaps = [];

// bind a material map to its unit, or white for none, only when it or its filtering changed since the last draw
function render3DBindMap(unit, map, state)
{
    const bound = render3DBoundMaps, pixelated = !!map && state.pixelated;
    if (bound[unit] === map && bound[unit + 2] === pixelated) return;
    bound[unit] = map, bound[unit + 2] = pixelated;
    render3DBindTexture(map, state, unit);
}

// send a vec4 uniform of the main shader only when its value changed since the last send
function render3DUniform4f(name, x, y, z, w)
{
    const values = render3D.uniformValues, last = values[name] ||= [NaN, NaN, NaN, NaN]; // kept, not made per draw
    if (last[0] === x && last[1] === y && last[2] === z && last[3] === w)
        return;
    last[0] = x, last[1] = y, last[2] = z, last[3] = w;
    glContext.uniform4f(render3DUniform(name), x, y, z, w);
}

// send an int uniform of the main shader only when its value changed since the last send
function render3DUniform1i(name, x)
{
    const values = render3D.uniformValues;
    if (values[name] === x)
        return;
    values[name] = x;
    glContext.uniform1i(render3DUniform(name), x);
}

// bind a vertex buffer and point the attributes at it
function render3DBindVertexBuffer(buffer)
{
    const gl = glContext;
    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
    for (const a of RENDER3D_ATTRIBS)
        gl.vertexAttribPointer(a[0], a[1], a[2], a[3], RENDER3D_VERTEX_BYTES, a[4]);
}

// a mesh's vertices and its triangle indices, ready for drawElements
function render3DBindMesh(mesh)
{
    render3DBindVertexBuffer(mesh.buffer);
    glContext.bindBuffer(glContext.ELEMENT_ARRAY_BUFFER, mesh.indexBuffer);
}

// the texture a draw samples: a tile's texture, a TextureInfo as it is, or undefined for white
function render3DTextureOf(tileInfo) { return tileInfo instanceof TileInfo ? tileInfo.textureInfo : tileInfo; }

// where a tile sits in its texture, pulled in slightly at the edges so neighbors do not bleed in
// the level's solid geometry in 3D, the height maps and voxel maps that objects with collideLevel collide with, as
// tileCollisionLayers is in 2D; each joins when made and leaves when destroyed
const render3DLevel = [];

// take one of the level's parts out of it, when it is destroyed
function render3DLevelLeave(o)
{
    const i = render3DLevel.indexOf(o);
    i >= 0 && render3DLevel.splice(i, 1);
}

// the level's parts are looked up in world space, so they stay upright and unscaled at the root
function render3DLevelAssertPlaced(o)
{
    ASSERT(!o.parent && !o.rotation3D.lengthSquared() && o.scale3D.x === 1 && o.scale3D.y === 1 && o.scale3D.z === 1,
        'a height map or voxel map stays upright and unscaled at the root, its lookups are in world space');
}

// this returns one shared object, so read it before calling again
const render3DTileUVRect = {x:0, y:0, w:1, h:1};
// the tiles of objects made from a whole TextureInfo, they cover all of it even after the texture is resized
const render3DWholeTiles = new WeakSet;
function render3DGetTileUVs(tileInfo)
{
    // a headless tile has no texture, and a whole texture's tile covers it at any size
    if (!(tileInfo instanceof TileInfo) || !tileInfo.textureInfo || render3DWholeTiles.has(tileInfo))
        return RENDER3D_FULL_UV_RECT;
    const inv = tileInfo.textureInfo.sizeInverse, rect = render3DTileUVRect;
    const bleedX = inv.x * tileInfo.bleed, bleedY = inv.y * tileInfo.bleed;
    rect.x = tileInfo.pos.x * inv.x + bleedX;
    rect.y = tileInfo.pos.y * inv.y + bleedY;
    rect.w = tileInfo.size.x * inv.x - 2*bleedX;
    rect.h = tileInfo.size.y * inv.y - 2*bleedY;
    return rect;
}

// set the per draw uniforms and gl state for a draw, in the shadow pass only the per draw attributes, the texture
// and the depth shader's cut
// tileInfo may be a TileInfo, a TextureInfo, or undefined for the white texture
// state is the plugin's current fields, or the captured state of a stream batch
function render3DSetDrawUniforms(matrix, tileInfo, tint, uvRect, state=render3D)
{
    const gl = glContext, r = render3D;

    // the per draw values are constant vertex attributes, a batch turns on a per instance array over them
    uvRect ||= render3DGetTileUVs(tileInfo);
    render3DDrawAttribs(matrix.m, tint, uvRect);
    render3DBindTexture(tileInfo, state);
    if (r.shadowPass)
    {
        // the shadow map needs only how to cut: by the alpha it blends with, or the texture's as an opaque draw
        const cut = state.blend ? 1 : 0;
        if (render3DShadowCut !== cut)
            glContext.uniform1f(render3DUniform('cut', r.shadowShader), render3DShadowCut = cut);
        return;
    }

    // the program: a Shader's own, compiled by its first draw, or the plugin's; switching sends the pass uniforms
    const program = state.shader ? render3DShaderProgram(state.shader) : r.program;
    program === r.currentProgram || render3DUseProgram(program);

    // blending, matches the engine's 2D blend functions
    if (state.blend)
    {
        gl.enable(gl.BLEND);
        const destBlend = state.additive ? gl.ONE : gl.ONE_MINUS_SRC_ALPHA;
        gl.blendFuncSeparate(gl.SRC_ALPHA, destBlend, gl.ONE, destBlend);
    }
    else
        gl.disable(gl.BLEND);

    // depth and culling
    state.depthTest ? gl.enable(gl.DEPTH_TEST) : gl.disable(gl.DEPTH_TEST);
    gl.depthMask(state.depthWrite);
    state.cullBackFaces ? gl.enable(gl.CULL_FACE) : gl.disable(gl.CULL_FACE);
    gl.frontFace(state.mirrored ? gl.CCW : gl.CW); // the pass's strips read clockwise, a mirror turns that around

    // lights, fog and shadows are scene state read at draw time, sent only when they change
    // the shader takes the way the sunlight travels, away from the sun
    const s = r.sunDirection, sl = -(s.length() || 1), lc = r.sunColor, ac = r.ambientColor, fc = r.fogColor || canvasClearColor;
    render3DUniform4f('lightDir', s.x / sl, s.y / sl, s.z / sl, state.lighting ? state.emissive : 1);
    render3DUniform4f('lightColor', lc.r, lc.g, lc.b, state.specular);
    render3DUniform4f('ambientFog', ac.r, ac.g, ac.b, r.fogEnd);
    const gc = r.ambientGroundColor;
    // its alpha says whether the ground color is on
    gc ? render3DUniform4f('ambientGround', gc.r, gc.g, gc.b, 1) : render3DUniform4f('ambientGround', 0, 0, 0, 0);

    render3DUniform4f('fogColor', fc.r, fc.g, fc.b, r.fogStart);
    // how the fragment shader finishes: 1 drops see through texels and keeps the draw opaque,
    // 0 blends them away instead, and -1 is additive, which has to fade into fog differently
    const blendMode = state.blend ? (state.additive ? -1 : 0) : 1;
    const receives = r.shadows && r.passIsDefault && state.receiveShadow ? 1 : 0;
    render3DUniform4f('shadowParams', receives, r.shadowBias, r.shadowSoftness / r.shadowTextureSize, blendMode);

    // a render target's texture holds premultiplied color, the blend writes it that way, so the shader undoes it
    const textureInfo = render3DTextureOf(tileInfo);
    render3DUniform1i('premultipliedTexture', +!!(textureInfo?.glTexture && glPremultipliedTextures.has(textureInfo.glTexture)));
    render3DSetMaterialUniforms(state);
}

// the six flat sides of the camera's visible box, each as [x, y, z, w] facing inward
// a point is inside when x*px + y*py + z*pz + w is zero or more
function render3DFrustumPlanes(matrix)
{
    const m = matrix.m, planes = [];
    for (let i = 0; i < 3; ++i)
    for (const sign of [1, -1])
    {
        const p = [m[3] + sign * m[i], m[7] + sign * m[4+i], m[11] + sign * m[8+i], m[15] + sign * m[12+i]];
        const l = hypot(p[0], p[1], p[2]) || 1;
        planes.push(p.map(v=> v / l));
    }
    return planes;
}

// the preRender hook, before gameRender: the layer under the 2D scene
function render3DPreRender()
{
    const r = render3D;
    editor3DCameraBegin(); // debug builds draw with the free camera or the 3D editor's, the game's is back after
    r.updateMatrices();
    r.shadowMapDrawn = false;
    render3DRenderPass(false);
}

// the render hook, after gameRenderPost: the layer on top of the 2D scene
function render3DRender()
{
    render3DRenderPass(true);
    editor3DCameraEnd();
}

// one 3D pass for the objects of a layer: take over the gl state, draw the shadow map once a frame and the stages, hand
// the state back
// the layer matching render3D.renderAfter2D is the default and always runs, the other only when an object asks for it
function render3DRenderPass(after2D)
{
    const gl = glContext, r = render3D;
    if (!r.program || !glEnable) return; // headless, gl disabled, or context lost
    render3DUpdateSamplers();
    ASSERT(!r.fogEnd || r.fogStart < r.fogEnd, 'fogStart must be less than fogEnd');
    ASSERT(!glRenderTarget, 'the 3D pass needs the canvas depth buffer, it can not draw into a render target');
    const isDefault = after2D === !!r.renderAfter2D, objects = render3DLayerObjects(after2D);
    if (!isDefault && !objects.length) return;
    r.passIsDefault = isDefault;
    // the 2D sprites drawn so far go under this layer, and a batch a plugin left pending before the layer under
    // the 2D scene draws now, with the engine's own gl state, not after the pass with its own
    glFlush();

    // a previous frame that threw must not leave anything pending
    r.streamCount = 0;
    r.capture = r.transparentQueue = undefined;
    render3DClearInstances();

    // the Light3D objects, a directional one sends the direction toward it, from the origin, and a negative radius
    // gathered before the gl state is taken over, so an assert here leaves nothing to hand back
    const lights = render3DCollectLights();
    r.lightCount = lights.length;
    const positions = r.lightPositions, colors = r.lightColors;
    lights.forEach((light, i)=>
    {
        const p = light.directional ? light.getWorldPos3D().normalize() : light.getWorldPos3D();
        ASSERT(!light.directional || p.lengthSquared(),
            'a directional light shines from its position toward the origin, so it cannot sit on the origin');
        const c = light.color, k = i * 4;
        positions[k] = p.x, positions[k+1] = p.y, positions[k+2] = p.z;
        positions[k+3] = light.directional ? -1 : max(0, light.radius); // a negative radius marks a direction
        colors[k] = c.r, colors[k+1] = c.g, colors[k+2] = c.b, colors[k+3] = c.a * light.intensity;
    });

    // take over the gl state
    gl.bindVertexArray(r.vao);
    // the leading repeat on every strip shifts the triangles by one, which flips
    // which way they read, so tell WebGL that clockwise is the front here
    gl.frontFace(gl.CW);
    // every program's shadow sampler is on unit 1, where a 2D plugin like PostProcessPlugin or LightSystemPlugin
    // may have left its own texture, which fails every draw; the shadow map goes back there even with shadows off
    gl.activeTexture(gl.TEXTURE1);
    gl.bindTexture(gl.TEXTURE_2D, r.shadowTexture || null);
    // the material maps' units start white, a 2D plugin may have left its own textures there
    render3DSetMapUnits(r.whiteTexture);
    gl.depthMask(true);
    gl.clear(gl.DEPTH_BUFFER_BIT);

    r.isRendering = true;
    try
    {
        // the shadow map from the light once a frame, then the stages sample it
        if (r.shadows && !r.shadowMapDrawn)
        {
            render3DRenderShadowMap();
            r.shadowMapDrawn = true;
        }
        // the camera's depth for post processing, from the default layer
        r.depthTexture && isDefault && render3DRenderDepth();
        render3DUseProgram(r.program); // after the shadow map, so the light matrix it sends is this frame's
        r.renderStages(objects, isDefault);
    }
    finally
    {
        // hand the state back to the engine's 2D batching, even when a draw threw
        r.isRendering = false;
        r.currentProgram = undefined; // the engine's 2D program takes over below
        r.streamCount = 0;
        r.capture = r.transparentQueue = undefined;
        gl.disable(gl.DEPTH_TEST);
        gl.disable(gl.CULL_FACE);
        gl.depthMask(true);
        gl.frontFace(gl.CCW);
        gl.bindSampler(0, null); // back to the textures' own filtering for 2D
        render3DSetMapUnits(null); // the material maps leave no texture or sampler behind for 2D
        if (glActiveTexture)
            gl.bindTexture(gl.TEXTURE_2D, glActiveTexture);
        // ARRAY_BUFFER is not part of VAO state in WebGL2, so bindVertexArray alone would not restore it
        gl.bindBuffer(gl.ARRAY_BUFFER, glArrayBuffer);
        glSetInstancedMode(true);
    }
}

// put a texture on both material map units with no sampler, white at the start of the pass and none at its end, and
// forget what the map cache thought was bound; unit 0 is active after
function render3DSetMapUnits(texture)
{
    const gl = glContext;
    for (const unit of [2, 3])
    {
        gl.activeTexture(gl.TEXTURE0 + unit);
        gl.bindTexture(gl.TEXTURE_2D, texture);
        gl.bindSampler(unit, null);
    }
    gl.activeTexture(gl.TEXTURE0);
    render3DBoundMaps = [];
}

// create the shadow map depth texture and framebuffer at a size, or keep them when the size matches
function render3DUpdateShadowMap(size)
{
    const gl = glContext, r = render3D;
    ASSERT(size > 0, 'shadowMapSize must be positive');
    if (r.shadowTexture && r.shadowTextureSize === size) return;
    r.shadowTexture && gl.deleteTexture(r.shadowTexture);
    r.shadowFramebuffer && gl.deleteFramebuffer(r.shadowFramebuffer);
    const texture = r.shadowTexture = gl.createTexture();
    gl.activeTexture(gl.TEXTURE1);
    gl.bindTexture(gl.TEXTURE_2D, texture);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.DEPTH_COMPONENT24, size, size, 0, gl.DEPTH_COMPONENT, gl.UNSIGNED_INT, null);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); // smooth filtering softens shadow edges for free
    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.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_COMPARE_MODE, gl.COMPARE_REF_TO_TEXTURE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_COMPARE_FUNC, gl.LEQUAL);
    gl.activeTexture(gl.TEXTURE0);
    const framebuffer = r.shadowFramebuffer = gl.createFramebuffer();
    gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
    gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, texture, 0);
    gl.drawBuffers([gl.NONE]); // depth only
    gl.readBuffer(gl.NONE);
    ASSERT(gl.checkFramebufferStatus(gl.FRAMEBUFFER) == gl.FRAMEBUFFER_COMPLETE,
        'shadow map framebuffer is incomplete, try a smaller shadowMapSize');
    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
    r.shadowTextureSize = size;
}

// draw the lit opaque casters from the light into the shadow map with the depth only shader
function render3DRenderShadowMap()
{
    const gl = glContext, r = render3D;
    render3DUpdateShadowMap(r.shadowMapSize | 0);
    r.updateShadowMatrix();

    // the map can not be read while it is drawn
    gl.activeTexture(gl.TEXTURE1);
    gl.bindTexture(gl.TEXTURE_2D, null);
    gl.activeTexture(gl.TEXTURE0);
    gl.bindFramebuffer(gl.FRAMEBUFFER, r.shadowFramebuffer);
    gl.viewport(0, 0, r.shadowTextureSize, r.shadowTextureSize);
    gl.clear(gl.DEPTH_BUFFER_BIT);
    gl.useProgram(r.shadowShader);
    gl.uniformMatrix4fv(render3DUniform('viewProj', r.shadowShader), false, r.shadowMatrix.m);
    gl.enable(gl.DEPTH_TEST);
    gl.depthMask(true);
    gl.disable(gl.BLEND);
    gl.disable(gl.CULL_FACE);

    r.shadowPass = true;
    try
    {
        // see through objects cast only when textured, their alpha cuts the shadow out
        const casters = render3DLayerObjects(!!r.renderAfter2D).filter(o=>
            o.castShadow && !o.additive && (!o.transparent || o.tileInfo));
        render3DDrawObjects(casters);
        r.onRenderOpaque?.();
        r.flush();
        render3DFlushInstances();
    }
    finally
    {
        // back to the frame with the map ready to sample
        r.shadowPass = false;
        gl.bindFramebuffer(gl.FRAMEBUFFER, null);

        // backing store size, mainCanvasSize is css pixels
        gl.viewport(0, 0, glCanvas.width, glCanvas.height);
        gl.activeTexture(gl.TEXTURE1);
        gl.bindTexture(gl.TEXTURE_2D, r.shadowTexture);
        gl.activeTexture(gl.TEXTURE0);
    }
}

// the camera's depth texture and its framebuffer at a size, made again when the canvas changes size; depth values
// are not filtered, so it is read texel by texel
function render3DUpdateCameraDepth(width, height)
{
    const gl = glContext, r = render3D;
    if (r.cameraDepthTexture && r.cameraDepthWidth === width && r.cameraDepthHeight === height) return;
    r.cameraDepthTexture && gl.deleteTexture(r.cameraDepthTexture);
    r.cameraDepthFramebuffer && gl.deleteFramebuffer(r.cameraDepthFramebuffer);
    const texture = r.cameraDepthTexture = gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D, texture);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.DEPTH_COMPONENT24, width, height, 0, gl.DEPTH_COMPONENT, gl.UNSIGNED_INT, null);
    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.bindTexture(gl.TEXTURE_2D, null);
    const framebuffer = r.cameraDepthFramebuffer = gl.createFramebuffer();
    gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
    gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, texture, 0);
    gl.drawBuffers([gl.NONE]); // depth only
    gl.readBuffer(gl.NONE);
    ASSERT(gl.checkFramebufferStatus(gl.FRAMEBUFFER) == gl.FRAMEBUFFER_COMPLETE, 'camera depth framebuffer is incomplete');
    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
    r.cameraDepthWidth = width, r.cameraDepthHeight = height;
}

// draw the camera's depth for post processing: the solid objects from the camera with the shadow map's depth only
// shader, into a texture the size of the canvas; what is see through is cut by its alpha as it is for shadows
function render3DRenderDepth()
{
    const gl = glContext, r = render3D, width = glCanvas.width, height = glCanvas.height;
    render3DUpdateCameraDepth(width, height);
    gl.bindFramebuffer(gl.FRAMEBUFFER, r.cameraDepthFramebuffer);
    gl.viewport(0, 0, width, height);
    gl.clear(gl.DEPTH_BUFFER_BIT);
    gl.useProgram(r.shadowShader);
    gl.uniformMatrix4fv(render3DUniform('viewProj', r.shadowShader), false, r.viewProjection.m);
    gl.enable(gl.DEPTH_TEST);
    gl.depthMask(true);
    gl.disable(gl.BLEND);
    gl.disable(gl.CULL_FACE);

    r.shadowPass = r.depthPass = true;
    try
    {
        const solids = render3DLayerObjects(!!r.renderAfter2D).filter(o=> !o.additive && (!o.transparent || o.tileInfo));
        render3DDrawObjects(solids);
        r.onRenderOpaque?.();
        r.flush();
        render3DFlushInstances();
    }
    finally
    {
        r.shadowPass = r.depthPass = false;
        gl.bindFramebuffer(gl.FRAMEBUFFER, null);
        gl.viewport(0, 0, width, height);
    }
}

///////////////////////////////////////////////////////////////////////////////
// Strips: every strip repeats its first point once at the start and its last
// point once at the end. Those repeats make flat triangles with no area, which
// are invisible, and they let one strip run straight into the next.
// An odd count gets one more repeat at the end. Triangles in a strip alternate
// which way they face, so keeping the count even keeps every strip facing out.

// make room in the stream for a strip of count vertices under the current state and texture, flushing a batch that
// differs first; returns the uv rect to map the vertices with, or undefined when nothing can be drawn
function render3DBeginStrip(count, tileInfo)
{
    const r = render3D;
    if (!render3DCanDraw()) return;
    if (r.shadowPass && !r.lighting) return; // unlit things cast no shadow
    ASSERT(count <= RENDER3D_MAX_STREAM_VERTS, 'strip is too large for the stream, bake it into a mesh');
    if (count > RENDER3D_MAX_STREAM_VERTS) return;
    const textureInfo = render3DTextureOf(tileInfo);
    if (r.streamCount && (textureInfo !== r.streamTileInfo || render3DStateChanged(r.streamState)
        || r.streamCount + count > RENDER3D_MAX_STREAM_VERTS))
        r.flush();
    render3DFlushBeforeOverlay();
    if (!r.streamCount)
        r.streamState = render3DCaptureBatchState();
    r.streamTileInfo = textureInfo;
    return render3DGetTileUVs(tileInfo);
}

// a draw that is not depth tested goes over what is already drawn, so the batches drawn before it go first, or they
// would draw at the end of the stage and cover it whatever its render order; a stream open with the same state is
// drawn too, so each such draw splits the batches: one draw per object for meshes each with an overlay
function render3DFlushBeforeOverlay()
{
    const r = render3D;
    if (r.depthTest || r.shadowPass || !r.instanceMeshes.length) return;
    r.flush();
    render3DFlushInstances();
}

// upload a mesh that is new, changed, or from before the context was lost
function render3DMeshUpload(mesh)
{
    if (!mesh.buffer || mesh.dirty || mesh.contextGeneration !== render3D.contextGeneration)
        mesh.upload();
}

// an object's size3D in the world, grown by its scale and its parents', the size its sprite is drawn at and that
// collecting, picking and collision measure it at
function render3DWorldSize(o, m)
{
    const s = o.size3D;
    return vec3(s.x * hypot(m[0], m[1], m[2]), s.y * hypot(m[4], m[5], m[6]), s.z * hypot(m[8], m[9], m[10]));
}

// the surface normal of a height function at x, z, from the slope across a cell each way, clamped to the edges so a
// border sample leans the same as its neighbor; buildGrid and HeightMap both light their slopes by it, and it makes
// one vector, a big terrain has millions of vertices
function render3DSlopeNormal(heightFunction, x, z, ex, ez, halfX, halfZ)
{
    const x0 = max(x - ex, -halfX), x1 = min(x + ex, halfX), z0 = max(z - ez, -halfZ), z1 = min(z + ez, halfZ);
    const dx = (heightFunction(x1, z) - heightFunction(x0, z)) / (x1 - x0 || 1);
    const dz = (heightFunction(x, z1) - heightFunction(x, z0)) / (z1 - z0 || 1);
    const s = 1 / hypot(dx, 1, dz);
    return vec3(-dx * s, s, -dz * s);
}

// the half axes of a camera facing quad of a size turned by an angle, right then up, in one shared array
// so a particle costs no vectors; read it before calling again
const render3DBillboardAxesScratch = new Float64Array(6);
function render3DBillboardAxes(size, angle, upright)
{
    const r = render3D.cameraRight, u = render3D.cameraUp;
    let rx = r.x, ry = r.y, rz = r.z, ux = u.x, uy = u.y, uz = u.z;
    if (upright)
    {
        // an upright quad stands on world up and only turns to face the camera
        const l = hypot(rx, rz); // a rolled camera has no flat right
        rx = l ? rx / l : 1, ry = 0, rz = l ? rz / l : 0;
        ux = 0, uy = 1, uz = 0;
    }
    const c = cos(angle), s = sin(angle), w = size.x / 2, h = size.y / 2, a = render3DBillboardAxesScratch;
    a[0] = (rx * c + ux * s) * w, a[1] = (ry * c + uy * s) * w, a[2] = (rz * c + uz * s) * w;
    a[3] = (ux * c - rx * s) * h, a[4] = (uy * c - ry * s) * h, a[5] = (uz * c - rz * s) * h;
    return a;
}

// the four corners of a camera facing quad in strip order
function render3DBillboardCorners(pos, size, angle, upright)
{
    const a = render3DBillboardAxes(size, angle, upright);
    const rx = a[0], ry = a[1], rz = a[2], ux = a[3], uy = a[4], uz = a[5];
    return [
        vec3(pos.x - rx + ux, pos.y - ry + uy, pos.z - rz + uz), vec3(pos.x - rx - ux, pos.y - ry - uy, pos.z - rz - uz),
        vec3(pos.x + rx + ux, pos.y + ry + uy, pos.z + rz + uz), vec3(pos.x + rx - ux, pos.y + ry - uy, pos.z + rz - uz)];
}

// how many vertices a strip of n points takes with its repeats, and which point vertex k of it is
function render3DStripCount(n) { return n + 2 + (n & 1); }
function render3DStripIndex(k, n) { return k < 1 ? 0 : k <= n ? k - 1 : n - 1; }

// walk a strip's vertices with the repeats applied, calling back with (point, normal, uv, color)
// normals, uvs and colors may be one value for all points, an array per point, or undefined
function render3DForEachStripVertex(points, normals, uvs, colors, callback)
{
    ASSERT(isArray(points) && points.length > 2, 'strip needs at least 3 points');
    const n = points.length, count = render3DStripCount(n);
    const normalArray = isArray(normals), uvArray = isArray(uvs), colorArray = isArray(colors);
    for (let k = 0; k < count; ++k)
    {
        const i = render3DStripIndex(k, n);
        callback(points[i],
            normalArray ? normals[i] : normals || RENDER3D_DEFAULT_NORMAL,
            uvArray ? uvs[i] : uvs || RENDER3D_DEFAULT_UV,
            colorArray ? colors[i] : colors || WHITE);
    }
}

// write one vertex into a packed buffer at float index j
function render3DWriteVertex(floats, ints, j, x, y, z, n, u, v, rgba)
{
    floats[j]   = x;   floats[j+1] = y;   floats[j+2] = z;
    floats[j+3] = n.x; floats[j+4] = n.y; floats[j+5] = n.z;
    floats[j+6] = u;   floats[j+7] = v;
    ints[j+8] = rgba;
}

// turn every triangle of an index list the other way round, in place: the authoring form reads counter clockwise
// from the front and the pass draws clockwise, as a strip's triangles come out after its leading repeat
function render3DFlipTriangles(indices)
{
    for (let t = 0; t < indices.length; t += 3)
    {
        const b = indices[t+1];
        indices[t+1] = indices[t+2], indices[t+2] = b;
    }
    return indices;
}

// the triangles of a strip of count entries as an index list, given which vertex each entry maps to and which place
// it is at: the strip's triangle i is (i-2, i-1, i), its odd ones read the other way as the GPU reads a strip, and a
// triangle with two corners in one place has no area, it is a join between pieces or a sliver at a pole, and is left out
function render3DStripTriangles(count, remap, place)
{
    const indices = [];
    for (let i = 2; i < count; ++i)
    {
        const a = i & 1 ? i - 1 : i - 2, b = i & 1 ? i - 2 : i - 1;
        if (place[a] != place[b] && place[b] != place[i] && place[a] != place[i])
            indices.push(remap[a], remap[b], remap[i]);
    }
    return indices;
}

// a mesh's packed vertex data for the GPU, one vertex per entry of a layout, which is the strip index of each,
// written into data when given, the buffer an earlier call returned for the same layout; it measures the mesh's
// radius and box as it goes, as computeRadius does, over the vertices once instead of every strip entry
function render3DMeshVertexData(mesh, vertices, data=new ArrayBuffer(vertices.length * RENDER3D_VERTEX_BYTES))
{
    const count = vertices.length;
    const floats = new Float32Array(data), ints = new Uint32Array(data);
    let r = 0, x0 = Infinity, y0 = Infinity, z0 = Infinity, x1 = -Infinity, y1 = -Infinity, z1 = -Infinity;
    for (let j = 0; j < count; ++j)
    {
        // a hand built mesh may leave normals, uvs and colors empty
        const i = vertices[j], p = mesh.points[i], uv = mesh.uvs[i] || RENDER3D_DEFAULT_UV;
        const x = p.x, y = p.y, z = p.z;
        render3DWriteVertex(floats, ints, j * RENDER3D_VERTEX_FLOATS, x, y, z,
            mesh.normals[i] || RENDER3D_DEFAULT_NORMAL, uv.x, uv.y, (mesh.colors[i] || WHITE).rgbaInt());
        r = max(r, x*x + y*y + z*z);
        x0 = min(x0, x), y0 = min(y0, y), z0 = min(z0, z);
        x1 = max(x1, x), y1 = max(y1, y), z1 = max(z1, z);
    }
    mesh.radius = r ** .5;
    mesh.bounds = count ? {min: vec3(x0, y0, z0), max: vec3(x1, y1, z1)} : {min: vec3(), max: vec3()};
    return data;
}

// reorder a convex polygon's points, counter clockwise from outside, into one triangle strip
function render3DPolygonStrip(points)
{
    const strip = [points[0]];
    for (let i = 1, j = points.length - 1; i <= j; ++i, --j)
    {
        strip.push(points[i]);
        if (i !== j)
            strip.push(points[j]);
    }
    return strip;
}

// turn every triangle of a mesh the other way round, the normals left alone: an index list reads each one in the
// other order, and a strip gets one extra point at each end, which flips every triangle and keeps the count even
function render3DFlipWinding(mesh)
{
    if (mesh.indices)
        return void render3DFlipTriangles(mesh.indices);
    for (const key of ['points', 'normals', 'uvs', 'colors'])
    {
        const a = mesh[key];
        if (a.length)
            a.unshift(a[0]), a.push(a[a.length - 1]);
    }
    mesh.vertexKeys = undefined;
}

///////////////////////////////////////////////////////////////////////////////

// frees the GPU buffer of a mesh that is garbage collected without dispose, some time after it goes; it holds the
// buffer and its context, never the mesh, or the mesh could not be collected, and dispose unregisters the mesh
const render3DMeshBuffers = typeof FinalizationRegistry == 'undefined' ? undefined :
    new FinalizationRegistry(({buffer, indexBuffer, generation})=>
    {
        if (generation !== render3D?.contextGeneration || !glContext) return;
        glContext.deleteBuffer(buffer);
        glContext.deleteBuffer(indexBuffer);
    });

// the place a point is at, to a hundred thousandth, which smooth normals are summed by
function render3DPlaceKey(p) { return `${round(p.x * 1e5)},${round(p.y * 1e5)},${round(p.z * 1e5)}`; }

// add a face normal to the sum at a triangle's corner a, weighted by the angle there, so a cube corner averages its
// three faces evenly however it is cut into triangles
function render3DAddCornerNormal(sums, a, b, c, normal)
{
    const u = b.subtract(a), v = c.subtract(a);
    const angle = Math.acos(clamp(u.dot(v) / (u.length() * v.length() || 1), -1, 1));
    const k = render3DPlaceKey(a);
    sums.set(k, (sums.get(k) || vec3()).add(normal.scale(angle)));
}

/**
 * Mesh - Triangles with positions, normals, uvs and colors, uploaded once and drawn by matrix
 * - Build with addStrip, addQuad, combine or the shape builders, then render each frame
 * - Its back faces are skipped unless doubleSided is set, which the open builders like buildGrid do for you
 * - Two forms: a triangle strip, what the builders make, or an indexed list of triangles over their own vertices,
 *   what addTriangles and the model loaders make; upload sends the GPU an indexed list either way, see getTriangles,
 *   so a strip's joins between its pieces cost nothing to draw, and toIndexed turns a strip mesh into the list form
 * - The GPU buffer is created lazily on first render and dropped by dispose, or freed once the mesh is garbage
 *   collected, so dispose is only needed to free it right away, like for a mesh rebuilt often
 * @memberof Render3D
 * @example
 * const mesh = buildLathe([[0, -1], [1, 0], [0, 1]], 4); // octahedron
 * mesh.render(buildMatrix(vec3(0, 1, 0)), undefined, RED);
 */
class Mesh
{
    /** Create an empty mesh */
    constructor()
    {
        /** @property {Array<Vector3>} - Vertex positions, in strip order or one per vertex of an indexed mesh
         *  @type {Array<Vector3>} */
        this.points = [];
        /** @property {Array<Vector3>} - Vertex normals
         *  @type {Array<Vector3>} */
        this.normals = [];
        /** @property {Array<Vector2>} - Vertex texture coords, 0-1 across the tile
         *  @type {Array<Vector2>} */
        this.uvs = [];
        /** @property {Array<Color>} - Vertex colors
         *  @type {Array<Color>} */
        this.colors = [];
        /** @property {WebGLBuffer|undefined} - GPU vertex buffer, created by upload
         *  @type {WebGLBuffer|undefined} */
        this.buffer = undefined;
        /** @property {WebGLBuffer|undefined} - GPU index buffer, the triangles, created by upload
         *  @type {WebGLBuffer|undefined} */
        this.indexBuffer = undefined;
        /** @property {number} - Indices in the GPU index buffer, three per triangle */
        this.bufferCount = 0;
        this.indexType = 0; // gl.UNSIGNED_SHORT, or UNSIGNED_INT past 65535 vertices
        /** @property {boolean} - The mesh changed and needs uploading again, set it yourself if you edit the arrays */
        this.dirty = false;
        /** @property {boolean|undefined} - Draw every use of this mesh in the opaque stage as one instanced call,
         *  undefined follows render3D.instancing
         *  @type {boolean|undefined} */
        this.instanced = undefined;
        /** @property {boolean} - Draw both sides, each lit as the side that is seen; off skips the faces pointing away,
         *  which is faster and right for closed shapes, the open builders like buildGrid and buildRibbon turn it on */
        this.doubleSided = false;
        /** @property {boolean} - The values change often but the shape never does, for a water surface or a cloth: set
         *  once, the mesh keeps its GPU layout and a dirty upload only rewrites the vertices into the buffer it has;
         *  the strip must keep the same points in the same order, a new point count asserts; the layout is decided by
         *  the first upload, so strip entries equal then stay one vertex and triangles with no area then stay dropped,
         *  set vertexKeys or give it distinct values at the start, not a flat grid of one color or points all in one place */
        this.dynamicDraw = false;
        /** @property {Array<number>|undefined} - The mesh as an indexed triangle list instead of a strip: the arrays
         *  hold each vertex once and this says how they join, three vertex numbers per triangle, counter clockwise seen
         *  from the front like a strip's first triangle; addTriangles and the loaders fill it, toIndexed turns a strip
         *  mesh into this form
         *  @type {Array<number>|undefined} */
        this.indices = undefined;
        /** @property {Int32Array|undefined} - Which strip entries are one vertex, set by a builder that knows, one
         *  whole number per entry with equal numbers meaning the same vertex; upload skips its search for them, then
         *  drops the keys, since an edit after that may tell the entries apart; adding geometry or recomputing normals
         *  drops them too
         *  @type {Int32Array|undefined} */
        this.vertexKeys = undefined;
        /** @type {{vertices: Array<number>, pointCount: number, data: ArrayBuffer}|undefined} */
        // the strip index of each GPU vertex, the point count and packed data of the last upload, for a dynamicDraw mesh
        this.vertexLayout = undefined;
        this.instanceCount = 0; // draws waiting in this mesh's batch, with their values, texture and draw state
        /** @type {Float32Array|undefined} */
        this.instanceData = undefined;
        /** @property {number} - Bounding sphere radius around the origin, for culling and picking, computed by upload */
        this.radius = 0;
        /** @property {{min: Vector3, max: Vector3}|undefined} - Bounding box, for picking, measured with the radius
         *  @type {{min: Vector3, max: Vector3}|undefined} */
        this.bounds = undefined;
        this.contextGeneration = 0; // the context the buffer belongs to, see render3D.contextGeneration
    }

    /** Number of vertices in the mesh
     *  @return {number} */
    get vertexCount() { return this.points.length; }

    /** Add a triangle strip, joined to the previous one by invisible flat triangles so one mesh holds many strips
     *  - Strip order: the first three points make a triangle, then each point makes another with the two before it
     *  - List the first three points counter clockwise as seen from the front, or the face points away
     *    and may vanish when back faces are culled
     *  @param {Array<Vector3>} points - Strip order
     *  @param {Vector3|Array<Vector3>} [normals] - One for all or one per point, default up
     *  @param {Vector2|Array<Vector2>} [uvs] - One for all or one per point, default zero
     *  @param {Color|Array<Color>} [colors] - One for all or one per point, default white
     *  @return {Mesh} */
    addStrip(points, normals, uvs, colors)
    {
        if (this.indices)
        {
            // an indexed mesh takes the strip as the triangles it makes
            const part = new Mesh().addStrip(points, normals, uvs, colors).toIndexed();
            return this.addTriangles(part.points, part.indices, part.normals, part.uvs, part.colors);
        }
        render3DForEachStripVertex(points, normals, uvs, colors, (p, n, uv, c)=>
        {
            this.points.push(p);
            this.normals.push(n);
            this.uvs.push(uv);
            this.colors.push(c);
        });
        this.vertexKeys = undefined; // the new entries have no keys
        this.dirty = true;
        return this;
    }

    /** Add triangles over their own vertices, the indexed form a model file comes in
     *  - The mesh becomes indexed: a strip mesh is turned into triangles first, and strips added later join as triangles
     *  - List each triangle counter clockwise as seen from the front, like a strip's first triangle
     *  @param {Array<Vector3>} points - Each vertex once
     *  @param {Array<number>} indices - Three vertex numbers per triangle, into points
     *  @param {Vector3|Array<Vector3>} [normals] - One for all or one per point, default up
     *  @param {Vector2|Array<Vector2>} [uvs] - One for all or one per point, default zero
     *  @param {Color|Array<Color>} [colors] - One for all or one per point, default white
     *  @return {Mesh} */
    addTriangles(points, indices, normals, uvs, colors)
    {
        ASSERT(isArray(points) && isArray(indices) && indices.length % 3 === 0,
            'addTriangles takes points and three indices per triangle');
        ASSERT(indices.every(i=> i >= 0 && i < points.length && i % 1 === 0), 'an index points past the vertices given');
        this.toIndexed();
        const offset = this.points.length, normalArray = isArray(normals), uvArray = isArray(uvs), colorArray = isArray(colors);
        for (let i = 0; i < points.length; ++i)
        {
            this.points.push(points[i]);
            this.normals.push(normalArray ? normals[i] : normals || RENDER3D_DEFAULT_NORMAL);
            this.uvs.push(uvArray ? uvs[i] : uvs || RENDER3D_DEFAULT_UV);
            this.colors.push(colorArray ? colors[i] : colors || WHITE);
        }
        for (const i of indices)
            this.indices.push(i + offset);
        this.dirty = true;
        return this;
    }

    /** Turn a strip mesh into the indexed form, each distinct vertex once and the real triangles over them, in place
     *  - An indexed mesh is left as it is; the builders make strips and a loader makes this, and either draws the same
     *  @return {Mesh} */
    toIndexed()
    {
        if (this.indices) return this;
        const {vertices, indices} = this.getTriangles();
        this.points = vertices.map(i=> this.points[i]);
        this.normals = vertices.map(i=> this.normals[i] || RENDER3D_DEFAULT_NORMAL);
        this.uvs = vertices.map(i=> this.uvs[i] || RENDER3D_DEFAULT_UV);
        this.colors = vertices.map(i=> this.colors[i] || WHITE);
        // the list upload sends reads clockwise, the pass draws it that way; the authoring form is counter clockwise
        this.indices = render3DFlipTriangles(indices);
        this.vertexKeys = undefined;
        this.dirty = true;
        return this;
    }

    /** Add a flat quad from four corners in loop order, counter clockwise seen from the front, a is the top left of the texture
     *  @param {Vector3} a
     *  @param {Vector3} b
     *  @param {Vector3} c
     *  @param {Vector3} d
     *  @param {Color|Array<Color>} [color] - One for all or one per corner
     *  @param {Array<Vector2>} [uvs] - One per corner, default across the tile
     *  @return {Mesh} */
    addQuad(a, b, c, d, color, uvs)
    {
        return this.addStrip(render3DQuadStrip(a, b, c, d), render3DFaceNormal(a, b, c, d),
            uvs ? render3DQuadValues(uvs) : RENDER3D_QUAD_UVS, render3DQuadValues(color));
    }

    /** Append another mesh transformed by a matrix, for building one shape out of several
     *  @param {Mesh} mesh
     *  @param {Matrix4|Vector3} [matrix] - Transform, or just a position to move it to
     *  @param {Color} [color] - Multiplies the appended vertex colors
     *  @return {Mesh} */
    combine(mesh, matrix=RENDER3D_IDENTITY, color=WHITE)
    {
        matrix = render3DMatrix(matrix); // most parts only need moving into place
        const normalMatrix = render3DNormalMatrix(matrix), mirrors = render3DMirrors(matrix);
        let part = mesh, order;
        if (this.indices || mesh.indices)
        {
            // one of them is indexed, so both are: the part as a copy if it is a strip
            this.toIndexed();
            part = mesh.indices ? mesh : new Mesh().combine(mesh).toIndexed();
            const offset = this.points.length, indices = part.indices;
            // the count read once, a mesh combined with itself grows as it is read; a mirror turns every triangle the
            // other way round
            for (let t = 0, n = indices.length; t < n; t += 3)
                this.indices.push(indices[t] + offset, indices[t + (mirrors ? 2 : 1)] + offset,
                    indices[t + (mirrors ? 1 : 2)] + offset);
        }
        else if (mirrors && part.points.length)
        {
            // a strip reads the other way round with one more point at each end, as render3DFlipWinding does
            const last = part.points.length - 1;
            order = [0, ...part.points.keys(), last];
        }
        for (let j = 0, count = order ? order.length : part.points.length; j < count; ++j)
        {
            const i = order ? order[j] : j;
            this.points.push(matrix.transformPoint(part.points[i]));
            this.normals.push(normalMatrix.transformDirection(part.normals[i] || RENDER3D_DEFAULT_NORMAL).normalize());
            this.uvs.push((part.uvs[i] || RENDER3D_DEFAULT_UV).copy());
            this.colors.push((part.colors[i] || WHITE).multiply(color));
        }
        this.doubleSided ||= mesh.doubleSided; // an open part leaves the whole mesh open
        this.vertexKeys = undefined; // the new entries have no keys
        this.dirty = true;
        return this;
    }

    /** Returns a new mesh: this one and its mirror image across the plane through the origin facing axis
     *  - For modeling half a shape against that plane, a part that crosses it overlaps its image
     *  @param {Vector3} [axis] - Faces the mirror plane, vec3(1,0,0) mirrors across x
     *  @return {Mesh} */
    mirror(axis=vec3(1, 0, 0))
    {
        ASSERT(isVector3(axis) && axis.lengthSquared() > 0, 'mirror needs an axis');
        const n = axis.normalize(), a = -2 * n.x, b = -2 * n.y, c = -2 * n.z;
        const reflect = new Matrix4([1 + a * n.x, b * n.x, c * n.x, 0, a * n.y, 1 + b * n.y, c * n.y, 0,
            a * n.z, b * n.z, 1 + c * n.z, 0, 0, 0, 0, 1]);
        return new Mesh().combine(this).combine(this, reflect); // combine turns the image's faces the right way out
    }

    /** Returns a new mesh of count copies of this one, each turned further around an axis through the origin
     *  @param {number} count - Copies, spaced evenly around the whole turn
     *  @param {Vector3} [axis] - Up by default
     *  @return {Mesh} */
    spin(count, axis=vec3(0, 1, 0))
    {
        ASSERT(count >= 1 && count % 1 === 0, 'spin count must be a whole number, 1 or more');
        ASSERT(isVector3(axis) && axis.lengthSquared() > 0, 'spin needs an axis');
        const mesh = new Mesh, unit = axis.normalize();
        for (let i = 0; i < count; ++i)
            mesh.combine(this, render3DAxisRotation(unit, i / count * 2 * PI));
        return mesh;
    }

    /** Returns a new mesh of everything in this mesh or the other, see subtract
     *  @param {Mesh} mesh - Closed, as the builders make them apart from the open ones like buildGrid
     *  @param {Matrix4|Vector3} [matrix] - Places the other mesh, or just a position to move it to
     *  @return {Mesh} */
    union(mesh, matrix) { return render3DMeshCSG(this, mesh, matrix, 0); }

    /** Returns a new mesh of this one with the other cut out of it, CSG with BSP trees
     *  - Both must be closed, every edge shared by two triangles, as the builders make them apart from the open
     *    ones like buildGrid and buildRibbon; the result is closed and indexed, and neither mesh changes
     *  - The faces a cut makes come from the other mesh's surface, turned to face out, with its normals, uvs and
     *    colors, so a smooth cylinder drills a round hole
     *  - Parts that overlap must be joined with union to be one solid, not with combine, mirror or spin, which
     *    leave them overlapping, and CSG then gives a wrong shape
     *  - Cuts split the triangles near them, so the result has more: a few thousand triangles take tens to a few
     *    hundred milliseconds, so build shapes this way at load time, not every frame; joining several cutters
     *    with union and cutting once is quicker than cutting with each in turn
     *  - Details closer than about 1e-4 are made one, so build a very small part larger and scale it after
     *  @param {Mesh} mesh - Closed, as the builders make them apart from the open ones like buildGrid
     *  @param {Matrix4|Vector3} [matrix] - Places the other mesh, or just a position to move it to
     *  @return {Mesh}
     *  @example
     *  const wall = buildBox(vec3(4, 3, .5)).subtract(buildBox(vec3(1, 2, 1)), vec3(0, -.5, 0)); // a doorway */
    subtract(mesh, matrix) { return render3DMeshCSG(this, mesh, matrix, 1); }

    /** Returns a new mesh of only what is in both this mesh and the other, see subtract
     *  @param {Mesh} mesh - Closed, as the builders make them apart from the open ones like buildGrid
     *  @param {Matrix4|Vector3} [matrix] - Places the other mesh, or just a position to move it to
     *  @return {Mesh} */
    intersect(mesh, matrix) { return render3DMeshCSG(this, mesh, matrix, 2); }

    /** Scale every uv, so a whole texture repeats across the mesh when its TextureInfo wraps
     *  @param {Vector2|number} scale - Repeats across and up, a number for both
     *  @return {Mesh} */
    scaleUVs(scale)
    {
        const s = render3DSize2(scale);
        // new vectors, builders share uv objects between faces
        this.uvs = this.uvs.map(uv=> vec2(uv.x * s.x, uv.y * s.y));
        this.dirty = true;
        return this;
    }

    /** Move, turn or scale every vertex in place, normals follow along
     *  @param {Matrix4|Vector3} matrix - Transform, or just an offset to move by
     *  @return {Mesh} */
    transform(matrix)
    {
        matrix = render3DMatrix(matrix);
        const normalMatrix = render3DNormalMatrix(matrix);
        for (let i = 0; i < this.points.length; ++i)
        {
            this.points[i] = matrix.transformPoint(this.points[i]);
            // a mesh built by hand may have no normals yet, and then there is nothing to turn
            this.normals[i] &&= normalMatrix.transformDirection(this.normals[i]).normalize();
        }
        render3DMirrors(matrix) && render3DFlipWinding(this); // a mirror would leave the faces pointing in
        this.dirty = true;
        return this;
    }

    /** Turn the mesh inside out so it is lit and drawn from within, for rooms and domes
     *  @return {Mesh} */
    flipNormals()
    {
        render3DFlipWinding(this);
        this.normals = this.normals.map(n=> n.scale(-1));
        this.dirty = true;
        return this;
    }

    /** Set every vertex color
     *  @param {Color} color
     *  @return {Mesh} */
    setColor(color)
    {
        // one per point, not one per color already there, so a mesh built by hand with no
        // colors gets them instead of quietly staying white
        this.colors = this.points.map(()=> color);
        this.dirty = true;
        return this;
    }

    /** Measure the axis aligned box around the vertices
     *  @return {{min: Vector3, max: Vector3}} */
    getBounds()
    {
        if (!this.points.length)
            return {min: vec3(), max: vec3()};
        const lo = vec3(Infinity), hi = vec3(-Infinity);
        for (const p of this.points)
        {
            lo.x = min(lo.x, p.x); lo.y = min(lo.y, p.y); lo.z = min(lo.z, p.z);
            hi.x = max(hi.x, p.x); hi.y = max(hi.y, p.y); hi.z = max(hi.z, p.z);
        }
        return {min: lo, max: hi};
    }

    /** Move the mesh so the center of its bounds is on the origin
     *  @return {Mesh} */
    center()
    {
        const bounds = this.getBounds();
        return this.transform(bounds.min.add(bounds.max).scale(-.5));
    }

    /** Scale the mesh evenly so its largest extent is a size, for loaded models of unknown units
     *  @param {number} [size]
     *  @return {Mesh} */
    fit(size=1)
    {
        const bounds = this.getBounds();
        const extent = bounds.max.subtract(bounds.min);
        const scale = size / (max(extent.x, extent.y, extent.z) || 1);
        return this.transform(Matrix4.scaling(vec3(scale)));
    }

    /** Measure the bounding sphere around the origin into radius, called by upload
     *  @return {number} */
    computeRadius()
    {
        let r = 0;
        for (const p of this.points)
            r = max(r, p.lengthSquared());
        this.bounds = this.getBounds(); // measured with it, a pick tests a mesh's box after its sphere
        return this.radius = r ** .5;
    }

    /** Derive normals from the triangles, of the strip or of the index list
     *  @param {boolean} [smooth] - Round the lighting across faces instead of giving each face a hard edge; flat
     *    normals on an indexed mesh give every corner its own vertex
     *  @return {Mesh} */
    computeNormals(smooth=false)
    {
        if (this.indices)
        {
            // the triangles are listed: smooth normals add up around each position, weighted by the corner angle
            // like the strip's, so vertices split apart at one place smooth back together and a mesh can go flat and
            // smooth again; flat ones need a vertex per corner, so the vertices are split up first
            if (!smooth)
            {
                const split = (a)=> this.indices.map(i=> a[i]);
                this.points = split(this.points), this.normals = split(this.normals);
                this.uvs = split(this.uvs), this.colors = split(this.colors);
                this.indices = this.indices.map((_, i)=> i);
            }
            const points = this.points, indices = this.indices, sums = new Map;
            const normals = points.map(()=> RENDER3D_DEFAULT_NORMAL);
            for (let t = 0; t < indices.length; t += 3)
            {
                const a = points[indices[t]], b = points[indices[t+1]], c = points[indices[t+2]];
                const cross = b.subtract(a).cross(c.subtract(a));
                if (!cross.lengthSquared()) continue;
                const normal = cross.normalize();
                if (!smooth)
                {
                    // its own three corners
                    normals[indices[t]] = normals[indices[t+1]] = normals[indices[t+2]] = normal;
                    continue;
                }
                for (let j = 0; j < 3; ++j)
                    render3DAddCornerNormal(sums, points[indices[t+j]], points[indices[t+(j+1)%3]],
                        points[indices[t+(j+2)%3]], normal);
            }
            this.normals = !smooth ? normals : points.map(p=>
            {
                const s = sums.get(render3DPlaceKey(p));
                return s && s.lengthSquared() ? s.normalize() : RENDER3D_DEFAULT_NORMAL;
            });
            this.dirty = true;
            return this;
        }

        // the outward normal of each triangle in the strip
        const points = this.points, n = points.length;
        const faceNormals = [];
        for (let i = 0; i + 2 < n; ++i)
        {
            const a = points[i], b = points[i+1], c = points[i+2];
            const normal = b.subtract(a).cross(c.subtract(a));
            // triangles in a strip alternate which way they wind, so every other one is flipped back
            // a zero normal means a flat triangle joining two strips, so skip it
            faceNormals.push(normal.lengthSquared() ? normal.normalize(i & 1 ? 1 : -1) : undefined);
        }

        // then hand those to the vertices, shared around a position or kept per face
        const normals = points.map(()=> RENDER3D_DEFAULT_NORMAL);
        if (smooth)
        {
            // add up the face normals meeting at each position, each weighted by its corner angle so a cube
            // corner averages its three faces evenly however the strips cut them, then normalize
            const sums = new Map;
            faceNormals.forEach((f, i)=> f && [0, 1, 2].forEach(j=>
                render3DAddCornerNormal(sums, points[i+j], points[i+(j+1)%3], points[i+(j+2)%3], f)));
            for (let i = 0; i < n; ++i)
                normals[i] = (sums.get(render3DPlaceKey(points[i])) || RENDER3D_DEFAULT_NORMAL).normalize();
        }
        else
            // every triangle writes its own three corners, so the only vertices left with the default
            // are the repeats at the ends of a strip, which no triangle with any area uses
            faceNormals.forEach((f, i)=> f && (normals[i] = normals[i+1] = normals[i+2] = f));

        this.normals = normals;
        this.vertexKeys = undefined; // flat normals tell entries at one place apart
        this.dirty = true;
        return this;
    }

    /** Pack the vertices and create the GPU buffer, called automatically by render
     *  @return {Mesh} */
    upload()
    {
        // the packing below measures the radius and box, with nothing to pack to they are measured here
        if (!render3D?.program || !glContext) return this.computeRadius(), this;
        const gl = glContext, layout = this.vertexLayout;
        // no layout when dynamicDraw was turned on after an upload, the next upload makes one
        if (this.dynamicDraw && layout && this.buffer && this.contextGeneration === render3D.contextGeneration)
        {
            // the layout of the last upload stands, only the values are written again into the buffer it has,
            // packed into the same memory each time so a mesh uploaded every frame makes no garbage
            ASSERT(layout.pointCount === this.points.length,
                'a dynamicDraw mesh keeps its shape, the same points in the same order; ' +
                'for a new shape make a new mesh or turn dynamicDraw off', this.points.length);
            if (layout.pointCount === this.points.length)
            {
                gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
                gl.bufferSubData(gl.ARRAY_BUFFER, 0, render3DMeshVertexData(this, layout.vertices, layout.data));
                gl.bindBuffer(gl.ARRAY_BUFFER, glArrayBuffer);
                this.dirty = false;
                return this;
            }
        }
        this.dispose();
        const {vertices, indices} = this.getTriangles(), count = vertices.length, wide = count > 65535;
        const data = render3DMeshVertexData(this, vertices);
        this.vertexLayout = this.dynamicDraw ? {vertices, pointCount: this.points.length, data} : undefined;
        this.vertexKeys = undefined; // used once: an edit after this may tell entries apart
        this.buffer = gl.createBuffer();
        this.indexBuffer = gl.createBuffer();
        this.bufferCount = indices.length;
        this.indexType = wide ? gl.UNSIGNED_INT : gl.UNSIGNED_SHORT;
        this.dirty = false;
        gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
        gl.bufferData(gl.ARRAY_BUFFER, data, this.dynamicDraw ? gl.DYNAMIC_DRAW : gl.STATIC_DRAW);
        gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer);
        gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, wide ? new Uint32Array(indices) : new Uint16Array(indices), gl.STATIC_DRAW);
        this.contextGeneration = render3D.contextGeneration;
        const buffers = {buffer: this.buffer, indexBuffer: this.indexBuffer, generation: this.contextGeneration};
        render3DMeshBuffers?.register(this, buffers, this);
        gl.bindBuffer(gl.ARRAY_BUFFER, glArrayBuffer); // the engine's 2D batch writes through this binding
        return this;
    }

    /** The mesh as an indexed triangle list, what upload sends to the GPU: the strip's real triangles over its
     *  distinct vertices, the joins between its pieces dropped and every triangle facing the way it did in the strip
     *  - Vertices are compared to a millionth, so two at one place with the same normal, uv and color are one
     *  @return {{vertices: Array<number>, indices: Array<number>}} - vertices are strip indices, one per distinct
     *    vertex; indices are the triangles, three per triangle, into vertices */
    getTriangles()
    {
        const count = this.points.length, vertices = [];
        if (this.indices)
        {
            // already a list: every vertex as it is, and the triangles read the way the pass draws, clockwise
            for (let i = 0; i < count; ++i)
                vertices.push(i);
            return {vertices, indices: render3DFlipTriangles(this.indices.slice())};
        }
        const remap = new Int32Array(count), place = new Int32Array(count), keys = this.vertexKeys;
        if (keys && keys.length === count)
        {
            // a builder said which entries are one vertex: the first entry with a key stands for every entry with it,
            // and is their place, so nothing is searched
            const first = new Int32Array(count).fill(-1);
            for (let i = 0; i < count; ++i)
            {
                const key = keys[i];
                ASSERT(key >= 0 && key < count, 'vertexKeys must be whole numbers below the entry count', key);
                const j = first[key];
                if (j < 0)
                    first[key] = i, remap[i] = vertices.length, vertices.push(i);
                else
                    remap[i] = remap[j];
                place[i] = first[key];
            }
            return {vertices, indices: render3DStripTriangles(count, remap, place)};
        }

        // each vertex as nine whole numbers, its values in millionths and its color, so vertices hash and compare as
        // numbers; a hash table over all nine finds the distinct vertices and one over the first three the places,
        // each slot holding a vertex index plus one and a taken slot moving on to the next
        const values = new Float64Array(count * 9);
        let size = 1;
        while (size < count * 2) size *= 2;
        const mask = size - 1, seen = new Int32Array(size), places = new Int32Array(size);
        const mix = (h, v)=> Math.imul(h ^ v, 0x9e3779b1) >>> 0;
        for (let i = 0; i < count; ++i)
        {
            const p = this.points[i], n = this.normals[i] || RENDER3D_DEFAULT_NORMAL;
            const uv = this.uvs[i] || RENDER3D_DEFAULT_UV, k = i * 9;
            const x = values[k] = round(p.x * 1e6), y = values[k+1] = round(p.y * 1e6), z = values[k+2] = round(p.z * 1e6);
            values[k+3] = round(n.x * 1e6), values[k+4] = round(n.y * 1e6), values[k+5] = round(n.z * 1e6);
            values[k+6] = round(uv.x * 1e6), values[k+7] = round(uv.y * 1e6);
            values[k+8] = (this.colors[i] || WHITE).rgbaInt();
            let h = mix(mix(mix(0x811c9dc5, x), y), z);

            // the place, shared with a vertex there that has another normal or uv, as at a lathe's poles
            for (let slot = h & mask;; slot = (slot + 1) & mask)
            {
                const o = places[slot] - 1;
                if (o < 0) { places[slot] = i + 1, place[i] = i; break; }
                if (values[o*9] === x && values[o*9+1] === y && values[o*9+2] === z) { place[i] = o; break; }
            }

            // the whole vertex
            for (let m = 3; m < 9; ++m)
                h = mix(h, values[k+m]);
            for (let slot = h & mask;; slot = (slot + 1) & mask)
            {
                const o = seen[slot] - 1;
                if (o < 0) { seen[slot] = i + 1, remap[i] = vertices.length, vertices.push(i); break; }
                let same = true;
                for (let m = 0; same && m < 9; ++m)
                    same = values[o*9+m] === values[k+m];
                if (same) { remap[i] = remap[o]; break; }
            }
        }
        return {vertices, indices: render3DStripTriangles(count, remap, place)};
    }

    /** Draw the mesh with the current draw state, batched with its other uses in the opaque stage
     *  @param {Matrix4|Vector3} [matrix] - Object transform, or just a position to draw it at
     *  @param {TileInfo|TextureInfo} [tileInfo] - Texture, mesh uvs map across the tile or the whole texture
     *  @param {Color} [color] - Tint */
    render(matrix, tileInfo, color) { render3D?.drawMesh(this, matrix, tileInfo, color); }

    /** Delete the GPU buffer now, the CPU arrays stay so the mesh can be rendered again
     *  - Optional, the buffer is freed anyway once the mesh is garbage collected, this frees it right away */
    dispose()
    {
        if (!this.buffer) return;
        render3DMeshBuffers?.unregister(this); // freed here, so not again when the mesh is collected
        // a buffer from a context that was lost is gone with it, and the new context refuses to delete it
        if (this.contextGeneration === render3D?.contextGeneration)
            glContext?.deleteBuffer(this.buffer), glContext?.deleteBuffer(this.indexBuffer);
        this.buffer = this.indexBuffer = undefined;
        this.bufferCount = 0;
    }
}

///////////////////////////////////////////////////////////////////////////////
// Shape builders, all centered on the origin so buildMatrix does placement
// Sizes are full sizes like buildBox and the 2D drawCircle, sides go around an axis and rings along it

/**
 * Spin a flat outline around the Y axis to make a round shape, like a vase or a wheel
 * - profile is [[radius, y], ...] from bottom to top
 * - A profile that ends where it starts makes a closed ring like a donut
 * - An end left open, with a radius and no cap, makes the mesh doubleSided so its inside shows
 * - An end on the axis smooth shades as a round pole like a sphere's when its segment is within 45 degrees of
 *   level, and as a point like a cone's tip when it is steeper
 * @param {Array<Array<number>>} profile
 * @param {number} [sides] - Around the axis
 * @param {boolean} [smooth] - Defaults to render3D.smoothShading
 * @param {boolean} [capped] - Close the ends that have a radius with flat discs
 * @return {Mesh}
 * @memberof Render3D
 * @example
 * const vase = buildLathe([[0, -1], [.8, -.3], [.9, .2], [.4, .6], [0, 1]], 12);
 */
function buildLathe(profile, sides=16, smooth=render3D?.smoothShading, capped=true)
{
    ASSERT(isArray(profile) && profile.length > 1, 'lathe profile needs at least 2 points');
    sides |= 0;
    ASSERT(sides > 2, 'lathe needs at least 3 sides');
    const mesh = new Mesh;
    const rings = profile.length;
    const point = (i, a)=> vec3(sin(a) * profile[i][0], profile[i][1], cos(a) * profile[i][0]);

    // 2D outward normal of each profile segment, in (radius, y) space
    const segmentNormal = (i)=>
    {
        const [r0, y0] = profile[i], [r1, y1] = profile[i+1];
        const n = vec2(y1 - y0, r0 - r1);
        return n.length() ? n.normalize() : vec2(1, 0);
    };
    // vertex normal: average of the adjacent segment normals, across the seam when the profile is closed,
    // and a closed profile needs no caps
    const closed = rings > 2 && abs(profile[0][0] - profile[rings-1][0]) < 1e-9 &&
        abs(profile[0][1] - profile[rings-1][1]) < 1e-9;
    const segmentLength = (i)=> hypot(profile[i+1][0] - profile[i][0], profile[i+1][1] - profile[i][1]);
    const vertexNormal = (i)=>
    {
        // an open end on the axis is a pole and points along it, like a sphere's, when the surface there is
        // within 45 degrees of level; a steeper one is a point like a cone's tip and takes its side's normal
        if (!closed && (!i || i == rings - 1) && abs(profile[i][0]) < 1e-9)
        {
            const up = i ? 1 : -1;
            if (segmentNormal(i ? i - 1 : 0).y * up > Math.SQRT1_2 - 1e-9)
                return vec2(0, up);
        }
        // otherwise the neighbors weighted by their length, so a short band does not tilt a long wall
        let n = vec2();
        const add = (s)=> n = n.add(segmentNormal(s).scale(segmentLength(s)));
        if (i > 0) add(i - 1);
        else if (closed) add(rings - 2);
        if (i < rings - 1) add(i);
        else if (closed) add(0);
        return n.length() ? n.normalize() : vec2(1, 0);
    };
    const normal3D = (n, a)=> vec3(sin(a) * n.x, n.y, cos(a) * n.x);

    // v runs along the profile by arc length
    const lengths = [0];
    for (let i = 1; i < rings; ++i)
        lengths[i] = lengths[i-1] + hypot(profile[i][0] - profile[i-1][0], profile[i][1] - profile[i-1][1]);
    const total = lengths[rings - 1] || 1;
    const v = (i)=> 1 - lengths[i] / total;

    for (let i = 0; i + 1 < rings; ++i)
    {
        if (smooth)
        {
            // one ribbon around the ring pair, top point then bottom point per column
            const points = [], normals = [], uvs = [];
            const n0 = vertexNormal(i), n1 = vertexNormal(i + 1);
            // a point on the axis is one per column, each drawn by the face beside it, so its normal turns half
            // a side toward the middle of that face; a pole's points along the axis and does not turn
            const half = PI / sides;
            const turn1 = abs(profile[i + 1][0]) < 1e-9 ? -half : 0, turn0 = abs(profile[i][0]) < 1e-9 ? half : 0;
            for (let j = 0; j <= sides; ++j)
            {
                const a = j / sides * 2 * PI, u = j / sides;
                points.push(point(i + 1, a), point(i, a));
                normals.push(normal3D(n1, a + turn1), normal3D(n0, a + turn0));
                uvs.push(vec2(u, v(i + 1)), vec2(u, v(i)));
            }
            mesh.addStrip(points, normals, uvs);
        }
        else
        {
            // one quad per side with its face normal
            const n = segmentNormal(i);
            for (let j = 0; j < sides; ++j)
            {
                const a0 = j / sides * 2 * PI, a1 = (j + 1) / sides * 2 * PI;
                const u0 = j / sides, u1 = (j + 1) / sides;
                mesh.addStrip(
                    [point(i + 1, a0), point(i, a0), point(i + 1, a1), point(i, a1)],
                    normal3D(n, (a0 + a1) / 2),
                    [vec2(u0, v(i + 1)), vec2(u0, v(i)), vec2(u1, v(i + 1)), vec2(u1, v(i))]);
            }
        }
    }

    // flat discs close the ends that have a radius, a hard edge even when the sides are smooth
    if (capped && !closed)
        for (const [i, up] of /** @type {Array<[number, boolean]>} */ ([[0, false], [rings - 1, true]]))
        {
            if (abs(profile[i][0]) < 1e-9) continue; // a pole has no cap
            const points = [], uvs = [];
            for (let j = 0; j < sides; ++j)
            {
                const a = (up ? j : -j) / sides * 2 * PI; // counter clockwise seen from outside
                points.push(point(i, a));
                uvs.push(vec2(sin(a) * .5 + .5, cos(a) * .5 + .5));
            }
            mesh.addStrip(render3DPolygonStrip(points), vec3(0, up ? 1 : -1, 0), render3DPolygonStrip(uvs));
        }

    // an end left open shows the inside, so it is seen from both sides
    mesh.doubleSided = !closed && !capped && (abs(profile[0][0]) > 1e-9 || abs(profile[rings-1][0]) > 1e-9);
    return mesh;
}

/**
 * Build a sphere centered on the origin
 * @param {number} [size] - Diameter
 * @param {number} [sides] - Around
 * @param {number} [rings] - Top to bottom
 * @param {boolean} [smooth] - Defaults to render3D.smoothShading
 * @return {Mesh}
 * @memberof Render3D
 */
function buildSphere(size=1, sides=16, rings=8, smooth=render3D?.smoothShading)
{
    ASSERT(rings > 1, 'sphere needs at least 2 rings');
    const profile = [];
    for (let i = 0; i <= rings; ++i)
    {
        const a = i / rings * PI - PI/2;
        profile.push([cos(a) * size / 2, sin(a) * size / 2]);
    }
    return buildLathe(profile, sides, smooth);
}

// a box with its edges cut by a bevel of size t: each face shrunk by t, a strip along each edge around a quarter
// circle, and an eighth of a sphere at each corner; one segment makes them flat, a chamfer
function render3DBevelBox(half, t, segments, faces)
{
    const mesh = new Mesh, inner = half.subtract(vec3(t)), n = max(1, segments | 0);

    // a triangle facing out, since the box is convex around the origin, flat shaded or with the normals of the
    // round, and uvs from the face it faces most, as a plain box maps that face
    const triangle = (a, b, c, na, nb, nc)=>
    {
        let normal = b.subtract(a).cross(c.subtract(a));
        if (normal.lengthSquared() < 1e-20) return; // an edge or face with no size
        if (normal.dot(a.add(b).add(c)) < 0)
            [b, c, nb, nc, normal] = [c, b, nc, nb, normal.scale(-1)];
        normal = normal.normalize();
        let face = faces[0];
        for (const f of faces)
            if (f[0].dot(normal) > face[0].dot(normal))
                face = f;
        const [, r, u] = face, hr = abs(half.dot(r)), hu = abs(half.dot(u));
        const uv = (p)=> vec2(.5 + p.dot(r) / (2 * hr), .5 - p.dot(u) / (2 * hu));
        mesh.addStrip([a, b, c], n > 1 && na ? [na, nb, nc] : normal, [uv(a), uv(b), uv(c)]);
    };

    // the faces, shrunk by the bevel
    for (const [normal, r, u] of faces)
    {
        const [a, b, c, d] = render3DQuadAxes(normal.multiply(half), r.multiply(inner), u.multiply(inner));
        triangle(a, b, c);
        triangle(c, b, d);
    }

    // the edges, each a strip around a quarter circle from one face to the next
    const axes = [vec3(1, 0, 0), vec3(0, 1, 0), vec3(0, 0, 1)];
    for (let k = 0; k < 3; ++k)
    for (const si of [-1, 1])
    for (const sj of [-1, 1])
    {
        const n1 = axes[(k + 1) % 3].scale(si), n2 = axes[(k + 2) % 3].scale(sj);
        const middle = n1.multiply(inner).add(n2.multiply(inner)), along = axes[k].multiply(inner);
        const start = middle.subtract(along), end = middle.add(along);
        const dir = (m)=> n1.scale(cos(m / n * PI / 2)).add(n2.scale(sin(m / n * PI / 2)));
        for (let m = 0; m < n; ++m)
        {
            const d0 = dir(m), d1 = dir(m + 1);
            const a = start.add(d0.scale(t)), b = end.add(d0.scale(t));
            const c = start.add(d1.scale(t)), d = end.add(d1.scale(t));
            triangle(a, b, c, d0, d0, d1);
            triangle(c, b, d, d1, d0, d1);
        }
    }

    // the corners, an eighth of a sphere in rows from one face's axis down to the edge between the other two
    for (const sx of [-1, 1])
    for (const sy of [-1, 1])
    for (const sz of [-1, 1])
    {
        const nx = vec3(sx, 0, 0), ny = vec3(0, sy, 0), nz = vec3(0, 0, sz), corner = inner.multiply(vec3(sx, sy, sz));
        const dir = (i, j)=>
        {
            const polar = i / n * PI / 2, around = i ? j / i * PI / 2 : 0;
            return nz.scale(cos(polar)).add(nx.scale(cos(around) * sin(polar))).add(ny.scale(sin(around) * sin(polar)));
        };
        const point = (i, j)=> corner.add(dir(i, j).scale(t));
        for (let i = 0; i < n; ++i)
        {
            for (let j = 0; j <= i; ++j)
                triangle(point(i, j), point(i + 1, j), point(i + 1, j + 1), dir(i, j), dir(i + 1, j), dir(i + 1, j + 1));
            for (let j = 0; j < i; ++j)
                triangle(point(i, j), point(i + 1, j + 1), point(i, j + 1), dir(i, j), dir(i + 1, j + 1), dir(i, j + 1));
        }
    }
    return mesh;
}

/**
 * Build a box centered on the origin, six flat faces with uvs covering each face
 * - bevel cuts its edges and corners: 1 segment is a flat chamfer, more round them, and the biggest bevel, half
 *   the smallest side, rounds a cube into a ball
 * @param {Vector3|number} [size] - Full size, a number for a cube
 * @param {number} [bevel] - Size of the cut on each edge, clamped to half the smallest side
 * @param {number} [bevelSegments] - Steps around each edge, 1 for a flat chamfer
 * @return {Mesh}
 * @memberof Render3D
 */
function buildBox(size=1, bevel=0, bevelSegments=1)
{
    ASSERT(isNumber(bevel) && bevel >= 0, 'bevel must be a number, 0 or more');
    const half = render3DSize3(size).scale(.5);
    // each face: normal, right axis, up axis (right cross up = normal)
    const faces = [
        [vec3(0, 0, 1),  vec3(1, 0, 0),  vec3(0, 1, 0)],
        [vec3(0, 0, -1), vec3(-1, 0, 0), vec3(0, 1, 0)],
        [vec3(1, 0, 0),  vec3(0, 0, -1), vec3(0, 1, 0)],
        [vec3(-1, 0, 0), vec3(0, 0, 1),  vec3(0, 1, 0)],
        [vec3(0, 1, 0),  vec3(1, 0, 0),  vec3(0, 0, -1)],
        [vec3(0, -1, 0), vec3(1, 0, 0),  vec3(0, 0, 1)],
    ];
    const t = min(bevel, half.x, half.y, half.z);
    if (t > 0)
        return render3DBevelBox(half, t, bevelSegments, faces);
    const mesh = new Mesh;
    for (const [n, r, u] of faces)
    {
        const center = n.multiply(half);
        const right = r.multiply(half), up = u.multiply(half);
        mesh.addStrip(render3DQuadAxes(center, right, up), n, RENDER3D_QUAD_UVS);
    }
    return mesh;
}

/**
 * Build a heightfield grid in the XZ plane centered on the origin
 * - smooth rounds the lighting across cells and colors each corner
 * - flat lights and colors each cell on its own, so a checkerboard stays crisp
 * - doubleSided, a sheet seen from both sides; turn it off for ground only ever seen from above
 * - One cell is a plain square, render3D.planeMesh and planeMeshDoubleSided are shared ones
 * @param {Vector2|number} [size] - World size along X and Z, a number for a square
 * @param {Vector2|number} [segments] - Cells along X and Z, a number for both
 * @param {Color|function(number, number): Color} [color] - One Color for the whole grid, or (x, z) => Color
 * @param {function(number, number): number} [heightFunction] - (x, z) => y, default flat
 * @param {boolean} [smooth] - Defaults to render3D.smoothShading with a heightFunction; a flat grid is flat shaded,
 *   since its light is even anyway and smoothing would only blend its cell colors; pass smooth to blend a gradient
 * @return {Mesh}
 * @memberof Render3D
 * @example
 * const ground = buildGrid(vec2(20), 10, (x, z)=> (floor(x / 2) + floor(z / 2)) & 1 ? GRAY : WHITE); // 2 unit checks
 */
function buildGrid(size=vec2(1), segments=1, color, heightFunction, smooth=heightFunction && render3D?.smoothShading)
{
    heightFunction ||= ()=> 0;
    size = render3DSize2(size);
    segments = render3DSize2(segments);
    ASSERT(segments.x > 0 && segments.y > 0 && segments.x % 1 === 0 && segments.y % 1 === 0,
        'grid segments must be whole numbers above zero');
    const mesh = new Mesh;
    const segmentsX = segments.x, segmentsZ = segments.y;
    const cellX = size.x / segmentsX, cellZ = size.y / segmentsZ;
    const halfX = size.x / 2, halfZ = size.y / 2, ex = cellX / 2, ez = cellZ / 2;
    const px = (i)=> i * cellX - halfX, pz = (j)=> j * cellZ - halfZ;
    const colorAt = /** @type {function(number, number): Color} */ (color);
    const cellColor = (i, j)=> !color ? WHITE : isColor(color) ? /** @type {Color} */ (color) : colorAt(px(i), pz(j));
    // a big terrain has millions of vertices, so each one is made once, shared by the rows above and below it
    const row = (j)=>
    {
        const points = [], normals = [], uvs = [], colors = [], z = pz(j);
        for (let i = 0; i <= segmentsX; ++i)
        {
            const x = px(i);
            points.push(vec3(x, heightFunction(x, z), z));
            uvs.push(vec2(i / segmentsX, j / segmentsZ));
            if (!smooth) continue;
            normals.push(render3DSlopeNormal(heightFunction, x, z, ex, ez, halfX, halfZ));
            colors.push(cellColor(i, j));
        }
        return {points, normals, uvs, colors};
    };
    let above = row(0);
    for (let j = 0; j < segmentsZ; ++j)
    {
        const below = row(j + 1);
        if (smooth)
        {
            // one ribbon per row with vertex normals from the slope
            const points = [], normals = [], uvs = [], colors = [];
            for (let i = 0; i <= segmentsX; ++i)
            {
                points.push(above.points[i], below.points[i]);
                normals.push(above.normals[i], below.normals[i]);
                uvs.push(above.uvs[i], below.uvs[i]);
                colors.push(above.colors[i], below.colors[i]);
            }
            mesh.addStrip(points, normals, uvs, colors);
        }
        else
        {
            // one quad per cell with its face normal and one color sampled at its center
            for (let i = 0; i < segmentsX; ++i)
                mesh.addQuad(above.points[i], below.points[i], below.points[i+1], above.points[i+1], cellColor(i + .5, j + .5),
                    [above.uvs[i], below.uvs[i], below.uvs[i+1], above.uvs[i+1]]);
        }
        above = below;
    }
    if (smooth)
    {
        // the grid knows which strip entries are one vertex, each shared by the rows above and below it and by the
        // repeats at the ends of its ribbons, so the upload of a big terrain skips searching millions of entries
        const n = 2 * (segmentsX + 1) + 2, keys = mesh.vertexKeys = new Int32Array(mesh.points.length);
        const id = (i, j)=> j * (segmentsX + 1) + i;
        for (let j = 0; j < segmentsZ; ++j)
        {
            const start = j * n;
            keys[start] = id(0, j); // the leading repeat
            for (let i = 0; i <= segmentsX; ++i)
                keys[start + 1 + 2*i] = id(i, j), keys[start + 2 + 2*i] = id(i, j + 1);
            keys[start + n - 1] = id(segmentsX, j + 1); // the trailing repeat
        }
    }
    mesh.doubleSided = true; // a sheet, seen from both sides; terrain seen only from above can turn it off
    return mesh;
}

/**
 * Build a sky dome: a sphere colored by direction, wound to be seen from inside
 * - set it as render3D.sky and the pass draws it around the camera behind everything
 * @param {Color} [topColor] - Straight up
 * @param {Color} [horizonColor] - Level with the camera
 * @param {Color} [bottomColor] - Straight down, what a camera looking at the ground sees past its edge; defaults to the
 *   horizon color
 * @param {number} [sides] - Around
 * @param {number} [rings] - Top to bottom
 * @return {Mesh}
 * @memberof Render3D
 */
function buildSky(topColor=hsl(.6, .8, .55), horizonColor=hsl(.6, 1, .9), bottomColor=horizonColor, sides=16, rings=8)
{
    // a sphere turned inside out so it is seen from within, each point colored by how high it is
    const mesh = buildSphere(2, sides, rings, true).flipNormals();
    mesh.colors = mesh.points.map(p=> p.y < 0 ? horizonColor.lerp(bottomColor, -p.y) : horizonColor.lerp(topColor, p.y));
    render3DSkyColors.set(mesh, [topColor.copy(), horizonColor.copy(), bottomColor.copy()]);
    return mesh;
}

// the top, horizon and bottom colors of each sky dome buildSky made, which a reflection shows while that dome is
// render3D.sky, whether setSky set it or the game did
const render3DSkyColors = new WeakMap;

///////////////////////////////////////////////////////////////////////////////
/**
 * EngineObject3D - An EngineObject with a 3D transform and a mesh
 * - Set pos3D, rotation3D and scale3D instead of the 2D pos, size and angle
 * - Gets update, children, timers, destroy and renderOrder from EngineObject
 * - velocity3D is added to pos3D each frame, slowed by damping, and render3D.gravity pulls it once it has a mass
 * - Objects face -Z, the same way the camera does, so lookAt turns them to face a point
 * - The 2D pos and velocity are still there but nothing draws them
 * - These inherited fields are 2D only and do nothing here: angle, angleVelocity, additiveColor, drawSize and mirror;
 *   damping, angleDamping, clampSpeed, friction and groundObject work as in 2D, on velocity3D and angleVelocity3D
 * - The inherited shader works here as in 2D, and with emissive at 1 its snippet does its own lighting
 * - Set sync2D for a 2D game with 3D looks, pos and angle then drive pos3D and rotation3D,
 *   which is the one way those 2D fields reach a 3D object
 * - setCollision takes the same flags as in 2D, but the solid collision happens in 3D against size3D
 * - The solid box turns with rotation3D, so a turned wall or a ramp collides as it looks; resting on one no steeper
 *   than groundAngle stands there
 * - Its tile and raycast halves are 2D only so they default off here, and a child sits solid collision out
 * - A sync2D object collides in 2D instead, which needs the 2D size set as well as size3D
 * - setMesh swaps the mesh and frees the old one, for text and terrain that get built again
 * - addChild attaches the 3D transform, and pos3D becomes an offset from the parent; attach keeps the child where
 *   it is and works the offset out, and removeChild leaves it where it was in the world
 * - The 2D offset arguments of addChild do nothing here, set the child's pos3D
 * @extends EngineObject
 * @memberof Render3D
 * @example
 * class Spinner extends EngineObject3D
 * {
 *     constructor(pos) { super(pos, buildBox(), undefined, RED); }
 *     update() { this.rotation3D.y += .02; }
 * }
 */
class EngineObject3D extends EngineObject
{
    /** Create a 3D object and add it to the object list
     *  @param {Vector3} [pos3D] - World space position
     *  @param {Mesh} [mesh] - Mesh to draw, undefined draws nothing
     *  @param {TileInfo|TextureInfo} [tileInfo] - Texture, mesh uvs map across the tile; a whole TextureInfo becomes
     *    the tile that covers it
     *  @param {Color} [color] - Tint */
    constructor(pos3D=vec3(), mesh, tileInfo, color=WHITE)
    {
        ASSERT(!tileInfo || tileInfo instanceof TileInfo || tileInfo instanceof TextureInfo,
            'tileInfo must be a TileInfo or TextureInfo, it comes before color');
        // a whole texture is stored as the tile that covers it, with no padding or bleed to trim
        // the edges, so this is always a TileInfo like the 2D one and the object stays an EngineObject
        if (tileInfo instanceof TextureInfo)
            render3DWholeTiles.add(tileInfo = new TileInfo(vec2(), tileInfo.size, tileInfo, 0, 0));
        // the 2D pos starts where the object is, so turning on sync2D keeps it there
        super(vec2(pos3D.x, pos3D.y), vec2(), tileInfo, 0, color);
        ASSERT(isVector3(pos3D), 'pos3D must be a vec3');
        ASSERT(!mesh || mesh instanceof Mesh, 'mesh must be a Mesh or undefined');
        this.mass = 0; // static: no 2D physics, and no 3D gravity until a mass is set

        /** @property {Vector3} - World space position, local to the parent when attached to an EngineObject3D */
        this.pos3D = pos3D.copy();
        /** @property {Vector3} - Rotation vec3(pitch, yaw, roll) in radians, local to the parent when attached to an
         *  EngineObject3D */
        this.rotation3D = vec3();
        /** @property {Vector3} - Scale, local to the parent when attached to an EngineObject3D */
        this.scale3D = vec3(1);
        /** @property {Vector3} - Added to pos3D each frame by the engine before update, like the 2D velocity, no super
         *  call needed; damping and render3D.gravity act on it once the object has a mass */
        this.velocity3D = vec3();
        /** @property {Vector3} - Added to rotation3D each frame by the engine before update, slowed by angleDamping */
        this.angleVelocity3D = vec3();
        /** @property {Mesh|undefined} - Mesh to draw
         *  @type {Mesh|undefined} */
        this.mesh = mesh;
        /** @property {Vector3} - Size for solid collision and the collect and callback helpers, and of the sprite when
         *  there is a tileInfo and no mesh; starts at the size of the mesh's box, or 1 with no mesh, and setMesh leaves
         *  it as it is; the box is centered on pos3D, so center() a mesh whose origin is not its middle, like a model
         *  standing on its feet, or set size3D; scale3D and any parent's scale grow it, so drawing and picking agree */
        // a shared mesh measured since it last changed is not walked again for each object made from it
        const bounds = mesh && mesh.points.length ? !mesh.dirty && mesh.bounds || mesh.getBounds() : undefined;
        this.size3D = bounds ? bounds.max.subtract(bounds.min) : vec3(1);
        /** @property {number} - Diameter of a soft shadow drawn under the object on render3D.softShadowHeight, 0 for
         *  none; scale3D and a parent's scale grow it, so set it once for the unscaled object */
        this.softShadow = 0;
        /** @property {boolean} - A sprite stands on world up instead of tilting toward the camera */
        this.upright = false;
        /** @property {boolean} - Keep this object's texture pixels hard edged, for pixel art that should not blur or bleed */
        this.pixelated = false;
        /** @property {boolean} - Copy the 2D pos and angle into pos3D and rotation3D each frame, for 2D games with 3D
         *  looks; set mass to use 2D physics, and pos3D.z stays yours to set or move with velocity3D.z */
        this.sync2D = false;
        /** @property {boolean} - Draw in the transparent stage, blended and sorted far to near with depth writes off;
         *  on for a sprite */
        this.transparent = !mesh && !!tileInfo;
        /** @property {boolean} - Additive blending, in the transparent stage */
        this.additive = false;
        /** @property {number} - How much it lights itself: 0 is lit as normal, 1 is its own color with no shading, for
         *  lamps and glowing things, between is partly self lit, and above 1 is brighter than its color, for bloom */
        this.emissive = 0;
        /** @property {number} - Strength of the highlight where the sun and the Light3D objects reflect, 0 is none and
         *  1 adds a light's full color at its brightest; shininess sets its size */
        this.specular = 0;
        /** @property {number} - The highlight's exponent, how small and sharp it is: 4 is broad like rubber, 16 the
         *  default, 100 sharp like polished metal; shows only with specular above 0 */
        this.shininess = 16;
        /** @property {TextureInfo|undefined} - A normal map that bends the surface at each texel so it catches the
         *  light like bumps and grooves, green pointing up the image as OpenGL and glTF have it; read at the color
         *  texture's coordinates, see normalMapFromHeight to make one in code
         *  @type {TextureInfo|undefined} */
        this.normalMap = undefined;
        /** @property {number} - How strongly the normal map bends the surface, 0 turns it off, as glTF's scale */
        this.normalScale = 1;
        /** @property {number} - How much it reflects the sky, 0 none and 1 a mirror of it; the edges seen at a
         *  glancing angle reflect more either way, as water and glass do */
        this.reflectivity = 0;
        /** @property {TextureInfo|undefined} - A texture of where it glows, added on top of the lit surface so it
         *  shows in the dark, like lit windows; read at the color texture's coordinates
         *  @type {TextureInfo|undefined} */
        this.emissiveMap = undefined;
        /** @property {Color} - Multiplies the emissive map, as glTF's emissiveFactor */
        this.emissiveMapColor = WHITE;
        /** @property {boolean} - Draw into the shadow map when render3D.shadows is on; sprites and cut out textures
         *  cast their outline, an object faded below half its alpha casts nothing, a see through one casts only when
         *  textured, and additive objects never cast */
        this.castShadow = true;
        /** @property {boolean} - Collide as the sphere that fits size3D instead of as the size3D box, so it rolls
         *  around corners */
        this.collideAsSphere3D = false;
        /** @property {number} - The steepest slope it stands on, in radians from level, PI/4 by default: resting on
         *  a solid within this of flat sets groundObject and holds it still, steeper it slides down */
        this.groundAngle = PI / 4;
        /** @property {boolean} - Darkened by the shadow map when render3D.shadows is on */
        this.receiveShadow = true;
        /** @property {boolean|undefined} - Draw this object over the 2D scene, undefined uses render3D.renderAfter2D
         *  @type {boolean|undefined} */
        this.renderAfter2D = undefined;
        /** @property {Matrix4|undefined} - The transform from its parent, used in place of pos3D, rotation3D and
         *  scale3D when set, for one they cannot hold like a glTF pose with shear; read every frame it is set
         *  @type {Matrix4|undefined} */
        this.localMatrix = undefined;
        // the world transform, kept up to date by render3DObjectMatrix; getMatrix returns a copy
        this.worldMatrix = new Matrix4;
        this.matrixBuilt = new Float64Array(9).fill(NaN); // the position, rotation and scale it was built from
        this.matrixVersion = 0;          // counts the rebuilds, so a child knows when its parent's changed
        /** @type {EngineObject3D|undefined} */
        this.matrixParent = undefined;   // the parent it was built under, and that parent's version then
        this.matrixParentVersion = 0;
        this.movePass = engineObjectsUpdateCount; // the engine pass a child last moved in, so a refresh is not a step
    }

    /** Move by the 3D velocities and push out of solids, called automatically each frame before update, like the 2D physics
     *  - update runs once every object has moved and collided, so bounce off anything else there, it lands before the draw
     *  - Override this and call super to change how the object moves itself
     *  - A sync2D object runs the 2D physics as well, and collides there instead */
    updatePhysics()
    {
        // a sync2D object collides in 2D, which measures the 2D size, and that starts at zero on a 3D object
        ASSERT(!this.sync2D || !this.collideSolidObjects || (this.size.x && this.size.y),
            'a sync2D object collides in 2D, so give it a 2D size as well as a size3D', this.size);
        if (this.sync2D)
            super.updatePhysics();
        ASSERT(isNumber(this.groundAngle) && this.groundAngle >= 0 && this.groundAngle < PI / 2,
            'groundAngle must be 0 to less than PI/2, a slope from level', this.groundAngle);
        // what it stands on is found again each frame, by the level and by the solids it rests on; a sync2D
        // object's is the 2D physics'
        const ground = this.groundObject;
        this.sync2D || (this.groundObject = undefined);
        if (this.clampSpeed && !this.sync2D && (this.collideSolidObjects || this.collideLevel && this.mass))
        {
            // each axis within objectMaxSpeed, as in 2D, so a fast object does not pass through a thin wall; only
            // for what collides, anything else moves as fast as it is told
            const v = this.velocity3D, s = objectMaxSpeed;
            v.x = clamp(v.x, -s, s), v.y = clamp(v.y, -s, s), v.z = clamp(v.z, -s, s);
        }
        // a moving object keeps out of the level, the height maps and voxel maps, from where it was before it moved
        const oldPos = this.collideLevel && this.mass && !this.sync2D ? this.pos3D.copy() : undefined;
        render3DMove(this);
        if (ground && this.mass && !this.sync2D)
        {
            // sliding on what it stood on slows by friction, the less grippy of the two, relative to that one's own
            // speed so a moving platform carries it, as in 2D
            const friction = max(this.friction, ground.friction), v = this.velocity3D;
            const moving = ground instanceof EngineObject3D ? ground.velocity3D : undefined;
            const gx = moving?.x ?? 0, gz = moving?.z ?? 0;
            v.x = gx + (v.x - gx) * friction, v.z = gz + (v.z - gz) * friction;
        }
        oldPos && render3DCollideLevel(this, oldPos, ground);
        // the engine only runs this for objects that own where they are, a child rides along with its parent
        if (this.collideSolidObjects && !this.sync2D)
            render3DCollideSolid(this);
    }

    /** Move a child by its own velocities, bring a sync2D object's pos3D up to its 2D pos, then update the children,
     *  called automatically each frame
     *  @param {boolean} [updateChildren] - Also update the children's transforms */
    updateTransforms(updateChildren=true)
    {
        if (!paused)
        {
            // a child is never given updatePhysics, so it moves here, as an offset from its parent: once per
            // engine pass, since addChild, attach and a game bring the transforms up to date too
            if (this.parent && this.movePass !== engineObjectsUpdateCount)
            {
                this.movePass = engineObjectsUpdateCount;
                render3DMove(this);
            }
        }

        // placed from its parent first, so a sync2D object copies where it is now, then its children from it
        super.updateTransforms(false);
        if (!paused && this.sync2D)
            this.pos3D.x = this.pos.x, this.pos3D.y = this.pos.y, this.rotation3D.z = -this.angle;
        if (updateChildren)
            for (const child of this.children)
                child.updateTransforms();
    }

    /** Set how this object collides, the same flags as in 2D
     *  - Solid collision happens in 3D here, against size3D boxes or spheres; a child sits it out
     *  - The boxes turn with rotation3D, against height maps and voxel maps an object is still its upright box
     *  - A sync2D object collides in 2D instead, against the 2D size, so set that as well as size3D
     *  @param {boolean} [collideSolidObjects] - Take part in solid collision
     *  @param {boolean} [isSolid] - Block other objects, a pair where neither one blocks passes through;
     *    blocking needs collideSolidObjects, so isSolid on its own is not allowed
     *  @param {boolean} [collideLevel] - Collide with the level, the height maps and voxel maps, or the 2D tile layers
     *    for a sync2D object
     *  @param {boolean} [collideRaycast] - Raycasts, 2D only; 3D has render3D.pick and engineObjectsRaycast3D */
    setCollision(collideSolidObjects=true, isSolid=true, collideLevel=true, collideRaycast=false)
    { super.setCollision(collideSolidObjects, isSolid, collideLevel, collideRaycast); }

    /** Called by a VoxelMap to ask whether a block stops this object, a hook to let one through or react to it
     *  @param {number} type - The block's type, 1 to 255
     *  @param {Vector3} cell - The block's cell in the map
     *  @return {boolean} - true to be stopped by it, every block stops it by default */
    collideWithVoxel(type, cell) { return true; }

    /** Returns the world position
     *  @return {Vector3} */
    getWorldPos3D() { return render3DObjectMatrix(this).getTranslation(); }

    /** Returns the direction the object faces, its -Z axis in the world
     *  @return {Vector3} */
    getForward3D() { return render3DAxis(render3DObjectMatrix(this).m, 8).normalize(-1); }

    /** Returns the object's right axis in the world
     *  @return {Vector3} */
    getRight3D() { return render3DAxis(render3DObjectMatrix(this).m, 0).normalize(); }

    /** Returns the object's up axis in the world
     *  @return {Vector3} */
    getUp3D() { return render3DAxis(render3DObjectMatrix(this).m, 4).normalize(); }

    /** Returns a copy of the object's world transform, the parent's included when attached to an EngineObject3D
     *  - The object keeps its matrix and rebuilds it only when its position, rotation or scale changed, so this is
     *    cheap to call
     *  @return {Matrix4} */
    getMatrix() { return render3DObjectMatrix(this).copy(); }

    /** Turn the object so its -Z axis points at a world space target, sets pitch and yaw and clears roll
     *  @param {Vector3} target */
    lookAt(target)
    {
        // rotation3D is local to the parent, so a child has to aim at the target from the parent's point of view
        const parent = this.parent instanceof EngineObject3D ? this.parent : undefined;
        const local = parent ? parent.getMatrix().invert().transformPoint(target) : target;
        this.rotation3D = render3DLookRotation(local.subtract(this.pos3D), this.rotation3D);
    }

    /** Attaches a child, its pos3D, rotation3D and scale3D taken as the offset from this one; returns child for chaining
     *  - The 2D offset arguments do nothing for an EngineObject3D child, set its pos3D
     *  @param {EngineObject} child
     *  @param {Vector2} [localPos]
     *  @param {number} [localAngle]
     *  @return {EngineObject} The child object attached */
    addChild(child, localPos, localAngle)
    {
        // addChild brings the child's transform up to date, which is not a step: it already moved this pass as
        // a root, or moves in the next one, so it must not move by its velocity again here
        if (child instanceof EngineObject3D)
            child.movePass = engineObjectsUpdateCount;
        return super.addChild(child, localPos, localAngle);
    }

    /** Attaches a child without moving it: its pos3D, rotation3D and scale3D become what they have to be under this
     *  parent to keep its world transform, where addChild takes them as the offset; returns child for chaining
     *  - A parent scaled unevenly and a child turned under it make a shear, which those three values cannot hold,
     *    so the child comes out as close as they can get; a uniform scale is exact
     *  @param {EngineObject} child
     *  @return {EngineObject} The child object attached */
    attach(child)
    {
        if (!(child instanceof EngineObject3D))
            return super.attach(child); // a 2D child only has the 2D transform to keep
        child.parent?.removeChild(child); // keeps its world values, so its own matrix is its world matrix
        const local = render3DObjectMatrix(this).copy().invert().multiply(render3DObjectMatrix(child));
        super.attach(child);
        child.pos3D = local.getTranslation();
        child.rotation3D = local.getRotation();
        child.scale3D = local.getScale();
        if (child.localMatrix)
            child.localMatrix = local; // a matrix given whole stays whole, shear and all
        return child;
    }

    /** Removes a child from this one, it stays where it is in the world: its pos3D, rotation3D and scale3D become
     *  its world values, with the same shear caveat as attach; a child being destroyed is let go as it is
     *  @param {EngineObject} child */
    removeChild(child)
    {
        if (child instanceof EngineObject3D && !child.destroyed)
        {
            const world = render3DObjectMatrix(child);
            child.pos3D = world.getTranslation();
            child.rotation3D = world.getRotation();
            child.scale3D = world.getScale();
            if (child.localMatrix)
                child.localMatrix = world.copy(); // a matrix given whole stays whole, shear and all
        }
        super.removeChild(child);
    }

    /** Draw a different mesh and free the GPU buffer of the one it replaces
     *  - For a mesh built again when something changes, like a score, a rebuilt terrain or a loaded model
     *  - A mesh another object is still drawing is left alone, since builders are often shared
     *  - Freeing one held somewhere else only costs it an upload, the points it was built from stay
     *  @param {Mesh} [mesh] - The mesh to draw from now on, undefined to draw nothing
     *  @return {Mesh|undefined} - The mesh passed in */
    setMesh(mesh)
    {
        ASSERT(!mesh || mesh instanceof Mesh, 'mesh must be a Mesh or undefined');
        const old = this.mesh;
        this.mesh = mesh;
        // nothing to free and nothing to look for when it was never uploaded
        if (old && old !== mesh && old.buffer && !engineObjects.some(o=> /** @type {EngineObject3D} */ (o).mesh === old))
            old.dispose();
        return mesh;
    }

    /** 2D rendering is skipped, the mesh is drawn by render3D during the 3D pass */
    render() {}

    /** Draw the object in 3D, called by the 3D pass with the draw state set from this object's flags, draws the mesh by default
     *  @return {void} */
    render3D()
    {
        // an opaque draw comes out solid however low its alpha is, so a fade with no flag looks like nothing happened
        ASSERT(this.transparent || this.additive || this.color.a >= 1,
            'an object that fades needs its transparent flag, an opaque draw ignores the color alpha', this.color);
        // the matrix the object keeps, rebuilt only when it moved, the same one for the shadow pass and the main pass
        const matrix = render3DObjectMatrix(this);
        if (this.mesh)
            render3D.drawMesh(this.mesh, matrix, this.tileInfo, this.color);
        else if (this.tileInfo)
        {
            // a sprite, at the world size the collect, pick and solid collision helpers measure it at
            const m = matrix.m, size = render3DWorldSize(this, m);
            render3D.drawBillboard(vec3(m[12], m[13], m[14]), vec2(size.x, size.y), this.tileInfo, this.color,
                this.rotation3D.z, this.upright);
        }
    }
}

// an object's world matrix, the one it keeps: rebuilt only when its position, rotation or scale changed since the
// last build, or its parent's matrix did, so an object that stands still costs nine compares a frame instead of
// the trig and a new matrix; the matrix returned is the object's own, read it and never change it
const render3DLocalMatrix = new Matrix4;
function render3DObjectMatrix(o)
{
    const parent = o.parent instanceof EngineObject3D ? o.parent : undefined;
    const parentMatrix = parent && render3DObjectMatrix(parent); // the parent first, so its version is current
    const p = o.pos3D, r = o.rotation3D, s = o.scale3D, k = o.matrixBuilt, local = o.localMatrix;
    if (local)
    {
        // a matrix given whole is taken as it is each time, it can change in place
        if (parent)
        {
            o.worldMatrix.m.set(parentMatrix.m);
            o.worldMatrix.multiply(local);
            o.matrixParentVersion = parent.matrixVersion;
        }
        else
            o.worldMatrix.m.set(local.m);
        k[0] = NaN; // built from the matrix, so going back to pos3D builds again
        o.matrixParent = parent;
        ++o.matrixVersion;
    }
    else if (k[0] !== p.x || k[1] !== p.y || k[2] !== p.z || k[3] !== r.x || k[4] !== r.y || k[5] !== r.z
        || k[6] !== s.x || k[7] !== s.y || k[8] !== s.z || o.matrixParent !== parent
        || parent && o.matrixParentVersion !== parent.matrixVersion)
    {
        k[0] = p.x, k[1] = p.y, k[2] = p.z, k[3] = r.x, k[4] = r.y, k[5] = r.z, k[6] = s.x, k[7] = s.y, k[8] = s.z;
        if (parent)
        {
            // the parent's world matrix times the local one
            buildMatrix(p, r, s, render3DLocalMatrix);
            o.worldMatrix.m.set(parentMatrix.m);
            o.worldMatrix.multiply(render3DLocalMatrix);
            o.matrixParentVersion = parent.matrixVersion;
        }
        else
            buildMatrix(p, r, s, o.worldMatrix);
        o.matrixParent = parent;
        ++o.matrixVersion;
    }
    return o.worldMatrix;
}

// move an object by its 3D velocities, each slowed by its damping, an object with mass falling with render3D.gravity
function render3DMove(o)
{
    // the vectors change in place, as the 2D object's do: this runs for every object every frame
    // a sync2D object's damping is the 2D physics', its 3D velocities are its own to set
    const p = o.pos3D, v = o.velocity3D, r = o.rotation3D, a = o.angleVelocity3D;
    const d = o.sync2D ? 1 : o.damping, e = o.sync2D ? 1 : o.angleDamping;
    // damped first and gravity added after, the order EngineObject.updatePhysics uses,
    // so the same mass, damping and gravity fall the same way in both
    v.x *= d, v.y *= d, v.z *= d;
    if (o.mass && !o.sync2D) // a 2D driven object gets the 2D gravity instead
    {
        const g = render3D.gravity, s = o.gravityScale;
        v.x += g.x * s, v.y += g.y * s, v.z += g.z * s;
    }
    p.x += v.x, p.y += v.y, p.z += v.z;
    r.x += a.x *= e, r.y += a.y *= e, r.z += a.z *= e;
}

// keep an object that moved out of the level's solid geometry, clearing what it stood on for the level to set again
// ground is what it stood on last frame, which a height map keeps it on going down a slope
function render3DCollideLevel(o, oldPos, ground)
{
    for (const level of render3DLevel)
        level.destroyed || level.levelCollide3D(o, oldPos, level === ground);
}

// where a solid object is in the world and what it collides as: the sphere that fits size3D, or the size3D box,
// each grown by the object's scale
// only objects that own where they are take part, so pos3D is already world space, and however the object is
// turned its axes come out as long as its scale makes them; building the transform to read that back off it
// costs six trig calls and a matrix for every pair tested, which is the whole cost of a crowded scene
function render3DSolidShape(o)
{
    ASSERT(!o.parent, 'a child rides along with its parent, it has no world pos3D of its own to collide with');
    const s = o.size3D, k = o.scale3D;
    const kx = abs(k.x), ky = abs(k.y), kz = abs(k.z);
    if (o.collideAsSphere3D)
        return {pos: o.pos3D.copy(), radius: max(s.x, s.y, s.z) / 2 * max(kx, ky, kz)};
    // a turned box has its axes, a solid is never a child so its rotation is the world's; an upright one has none, and
    // neither does a sprite, whose rotation turns how it faces the camera, not its box
    const axes = !render3DIsSprite(o) && isTurned3D(o.rotation3D) ? render3DSolidAxes(o) : undefined;
    return {pos: o.pos3D.copy(), size: vec3(s.x * kx, s.y * ky, s.z * kz), axes};
}

// a turned solid's three axes, worked out when its rotation changes and kept until then, so the pairs it is tested
// against each frame do not each work them out again
const render3DSolidAxesCache = new WeakMap;
function render3DSolidAxes(o)
{
    const r = o.rotation3D, kept = render3DSolidAxesCache.get(o);
    if (kept && kept.x === r.x && kept.y === r.y && kept.z === r.z)
        return kept.axes;
    const axes = boxAxes3D(r);
    render3DSolidAxesCache.set(o, {x: r.x, y: r.y, z: r.z, axes});
    return axes;
}

// how far a solid shape can reach from its own center, for a quick reject before the exact test
// it has to be the shape's own radius, or a wider one: a box reaches to its corner, and a sphere
// takes the largest scale the same way render3DSolidShape does, or the reject would skip real touches
function render3DSolidReach(o)
{
    const s = o.size3D, k = o.scale3D;
    const kx = abs(k.x), ky = abs(k.y), kz = abs(k.z);
    if (o.collideAsSphere3D)
        return max(s.x, s.y, s.z) / 2 * max(kx, ky, kz);
    return hypot(s.x * kx, s.y * ky, s.z * kz) / 2;
}

// what it takes to move shape a clear of shape b, whichever pair of shapes they are, or undefined for no touch
function render3DSolidPush(a, b)
{
    // a turned box is tested by its kept axes, the upright pairs as they always were
    const sphereBox = (sphere, box)=> box.axes ? collideSphereOrientedBox3D(sphere.pos, sphere.radius, box.pos,
        box.size, box.axes) : collideSphereBox(sphere.pos, sphere.radius, box.pos, box.size);
    if (!a.size) // a is a sphere
        return b.size ? sphereBox(a, b) : collideSphereSphere(a.pos, a.radius, b.pos, b.radius);
    if (!b.size) // only b is, so push b out of a and turn it around
    {
        const push = sphereBox(b, a);
        return push && push.scale(-1);
    }
    return a.axes || b.axes ? collideOrientedBoxes3D(a.pos, a.size, a.axes ?? BOX_WORLD_AXES, b.pos, b.size,
        b.axes ?? BOX_WORLD_AXES) : collideBoxBox3D(a.pos, a.size, b.pos, b.size);
}

// a sprite, a tile with no mesh, which faces the camera however it is turned
const render3DIsSprite = (o)=> !o.mesh && !!o.tileInfo;

// whether a push leaves a box by one of its faces, along one of its axes, not by an edge or a corner; a sphere has
// no face to stand on
function render3DOnFace(push, shape)
{
    if (!shape.size) return false;
    const n = push.normalize(), axes = shape.axes ?? BOX_WORLD_AXES;
    return max(abs(n.dot(axes[0])), abs(n.dot(axes[1])), abs(n.dot(axes[2]))) > 1 - 1e-6;
}

// push a solid object out of the solids before it in the engine's list of them, so each pair is resolved once:
// the ones after it update later and test against it then, and an object that is not in the list yet, because it
// turned collision on this frame, tests them all itself and is not tested back
// one pair per test is half the work of the 2D solver, which tests both directions; the difference only shows
// when a collideWithObject destroys some third object, whose own turn then finds the pair already gone
function render3DCollideSolid(a)
{
    let shapeA = render3DSolidShape(a);
    const reachA = render3DSolidReach(a);
    for (const b of engineObjectsCollide)
    {
        if (b === a) break;
        // a child is part of its parent
        if (b.destroyed || !(b instanceof EngineObject3D) || b.parent || b.sync2D) continue;
        if (!a.isSolid && !b.isSolid) continue; // neither one blocks, so they pass through each other

        // the pairs nowhere near each other are almost all of them in a scene of any size, so
        // settle those with one distance check instead of building a shape for each
        const p = shapeA.pos, q = b.pos3D, reach = reachA + render3DSolidReach(b);
        const dx = p.x - q.x, dy = p.y - q.y, dz = p.z - q.z;
        if (dx*dx + dy*dy + dz*dz > reach*reach)
            continue;

        const shapeB = render3DSolidShape(b);
        let push = render3DSolidPush(shapeA, shapeB);
        if (!push) continue;

        // both objects hear about it, and either one can take the touch over
        const resolveA = a.collideWithObject(b, push);
        const resolveB = b.collideWithObject(a, push.scale(-1));
        if (!resolveA || !resolveB) continue;

        // standing: resting on a box's face within the upper one's groundAngle of level holds it there, the push
        // turned straight up, as far as it takes to leave the surface, so it does not creep down a ramp; only what
        // moves stands, and a sphere, an edge or a corner is nothing to stand on, what rests there rolls off
        const lengthSquared = push.lengthSquared(), up = push.y / lengthSquared ** .5;
        const aStands = up > 0 && a.mass && up >= cos(a.groundAngle) && render3DOnFace(push, shapeB);
        const bStands = up < 0 && b.mass && -up >= cos(b.groundAngle) && render3DOnFace(push, shapeA);
        if (aStands || bStands)
        {
            aStands ? a.groundObject = b : b.groundObject = a;
            if (push.x || push.z) // one straight up already is used as it is
                push = vec3(0, lengthSquared / push.y, 0);
        }

        // heavier objects move less, mass 0 stays put; then bounce apart when moving toward each other
        const total = a.mass + b.mass;
        const weightA = !a.mass ? 0 : !b.mass ? 1 : b.mass / total;
        const weightB = !b.mass ? 0 : !a.mass ? 1 : a.mass / total;
        a.pos3D = a.pos3D.add(push.scale(weightA));
        b.pos3D = b.pos3D.subtract(push.scale(weightB));
        if (weightA)
            shapeA = render3DSolidShape(a); // it moved, so the next solid must be tested against where it is now
        // mass 0 keeps its velocity too, so a moving platform keeps moving, and what hits it bounces by its own
        // restitution as it would off a static wall
        const normal = push.normalize();
        if (weightA && a.velocity3D.dot(normal) < 0)
            a.velocity3D = a.velocity3D.reflect(normal, a.restitution);
        if (weightB && b.velocity3D.dot(normal) > 0)
            b.velocity3D = b.velocity3D.reflect(normal, b.restitution);
    }
}

/**
 * Collect the EngineObject3D objects whose boxes overlap a sphere or a box, the 3D twin of engineObjectsCollect
 * - Each object is its size3D box at its world position, turned as it is turned; lights, emitters and trails have no
 *   size and are never collected
 * @param {Vector3} pos - Center of the area
 * @param {Vector3|number} size - Diameter of a sphere if a number, 0 for a point, full size of a box if a Vector3
 * @param {Array<EngineObject>} [objects] - Defaults to every object
 * @param {boolean} [testCenters] - Test only each object's center, a little faster, and ignores object sizes
 * @return {Array<EngineObject3D>}
 * @memberof Render3D
 */
function engineObjectsCollect3D(pos, size, objects=engineObjects, testCenters=false)
{
    // a size of 0 is a point, tested against each box, since a sphere of no size could never hit
    const radiusSquared = typeof size === 'number' && size > 0 ? (size/2)**2 : undefined;
    const box = radiusSquared ? undefined : typeof size === 'number' ? vec3() : render3DSize3(size);
    const collected = [];
    for (const o of objects)
    {
        if (!(o instanceof EngineObject3D) || o.destroyed) continue;
        // the box in world space, scaled by the object and its parents
        const m = render3DObjectMatrix(o).m, s = o.size3D;
        if (!(s.x || s.y || s.z)) continue;
        const center = vec3(m[12], m[13], m[14]);
        const worldSize = testCenters ? vec3() : render3DWorldSize(o, m);
        // a turned box is tested as turned, its axes read off the matrix, its columns with the scale taken out
        const turned = !testCenters && !render3DIsSprite(o) && (m[1] || m[2] || m[4] || m[6] || m[8] || m[9]);
        const axis = (i)=> vec3(m[i], m[i+1], m[i+2]).normalize();
        let axes = turned ? [axis(0), axis(4), axis(8)] : undefined;
        if (axes && (abs(axes[0].dot(axes[1])) > 1e-6 || abs(axes[0].dot(axes[2])) > 1e-6 ||
            abs(axes[1].dot(axes[2])) > 1e-6))
        {
            // a turned child of an unevenly scaled parent is sheared, its edges no longer square, which the box
            // tests do not take; the upright box around it stands in, which may take a little more but never misses
            const e = axes.map((a, i)=> a.scale(i ? i > 1 ? worldSize.z / 2 : worldSize.y / 2 : worldSize.x / 2));
            const reach = (c)=> 2 * (abs(e[0][c]) + abs(e[1][c]) + abs(e[2][c]));
            worldSize.set(reach('x'), reach('y'), reach('z'));
            axes = undefined;
        }
        let hit;
        if (box && axes)
            hit = !!collideOrientedBoxes3D(pos, box, BOX_WORLD_AXES, center, worldSize, axes);
        else if (box)
            hit = isOverlapping3D(pos, box, center, worldSize);
        else if (axes)
            hit = !!collideSphereOrientedBox3D(pos, radiusSquared ** .5, center, worldSize, axes);
        else
        {
            // a sphere against the nearest point of the box
            const dx = max(abs(pos.x - center.x) - worldSize.x/2, 0);
            const dy = max(abs(pos.y - center.y) - worldSize.y/2, 0);
            const dz = max(abs(pos.z - center.z) - worldSize.z/2, 0);
            hit = dx*dx + dy*dy + dz*dz < radiusSquared;
        }
        hit && collected.push(o);
    }
    return collected;
}

// how far along a ray an object is hit, or undefined for a miss; each one is tested as the box around its mesh in
// its own space, or a sprite as the quad it draws, not triangle by triangle
function render3DRaycastObject(ray, o)
{
    if (o.destroyed || !(o instanceof EngineObject3D)) return;
    // a height map or voxel map is hit on its surface, not its box
    if (o instanceof HeightMap || o instanceof VoxelMap) return o.levelRaycast3D(ray);
    if (!(o.mesh || o.tileInfo)) return;
    if (o instanceof InstancedMesh3D) return; // its instances are not objects, and its one sphere is not a thing to hit
    const matrix = render3DObjectMatrix(o), mesh = o.mesh;
    if (!mesh) return render3DRaycastSprite(ray, o, matrix);
    // a mesh that changed since it was measured is measured again, an upload may not have come yet
    const radius = (mesh.dirty || !mesh.radius ? mesh.computeRadius() : mesh.radius) * render3DMaxStretch(matrix.m);
    if (!(radius > 0)) return; // nothing to hit
    const center = matrix.getTranslation();
    const distance = raycastSphere(ray, center, radius);
    if (distance === undefined) return;

    // the sphere is a quick reject, a mesh is hit where the ray meets its box in its own space, since a wide floor's
    // sphere reaches far above it; the direction is not made unit length, so the distance holds in the world;
    // a mesh flattened to nothing on an axis has no inverse, it is hit as a disc like a sprite
    if (!matrix.determinant()) return render3DRaycastDisc(ray, center, radius);
    const inverse = matrix.copy().invert(), bounds = mesh.bounds || mesh.getBounds();
    const local = new Ray3D(inverse.transformPoint(ray.origin), inverse.transformDirection(ray.direction));
    const hit = raycastBox(local, bounds.min.add(bounds.max).scale(.5), bounds.max.subtract(bounds.min));
    if (hit !== 0) return hit;

    // it starts inside the box, like a camera on terrain or in a room, so it is hit where it leaves, and what stands
    // inside comes first
    let exit = Infinity;
    for (const k of ['x', 'y', 'z'])
    {
        const d = local.direction[k];
        if (d)
            exit = min(exit, ((d > 0 ? bounds.max[k] : bounds.min[k]) - local.origin[k]) / d);
    }
    return exit === Infinity ? 0 : exit; // a ray of no length is where it starts
}

// a sprite is the quad drawBillboard draws, its size3D grown by the x and y scale as the draw does, facing the camera
// as it was last drawn; where the ray meets its plane, inside its half axes
function render3DRaycastSprite(ray, o, matrix)
{
    const center = matrix.getTranslation(), worldSize = render3DWorldSize(o, matrix.m), size = vec2(worldSize.x, worldSize.y);
    if (!(size.x > 0 && size.y > 0)) return; // nothing to hit
    if (!ray.direction.lengthSquared()) // a ray of no length is where it starts
        return render3DRaycastDisc(ray, center, hypot(size.x, size.y) / 2);
    const a = render3DBillboardAxes(size, o.rotation3D.z, o.upright);
    const right = vec3(a[0], a[1], a[2]), up = vec3(a[3], a[4], a[5]);
    const t = raycastPlane(ray, center, right.cross(up));
    if (t === undefined) return; // behind, or seen edge on
    const p = ray.getPosition(t).subtract(center);
    return abs(p.dot(right)) <= right.lengthSquared() && abs(p.dot(up)) <= up.lengthSquared() ? t : undefined;
}

// a mesh flattened to nothing, or a sprite for a ray of no length: a disc facing the ray at its center's depth
function render3DRaycastDisc(ray, center, radius)
{
    const d = ray.direction, oc = center.subtract(ray.origin), dd = d.dot(d);
    const t = dd ? oc.dot(d) / dd : 0; // its depth along the ray, in the ray's own units
    return t >= 0 && oc.subtract(d.scale(t)).lengthSquared() <= radius*radius ? t : undefined;
}

/**
 * Collect every EngineObject3D a ray passes through, nearest first, the 3D twin of engineObjectsRaycast
 * - The ray has no end, so everything along it counts however far away it is
 * - Use render3D.pick for the nearest one on its own, with the distance to it
 * @param {Ray3D} ray - From render3D.screenToRay, or any ray
 * @param {Array<EngineObject>} [objects] - Defaults to every object; only those with a mesh or a sprite count
 * @return {Array<EngineObject3D>}
 * @memberof Render3D
 */
function engineObjectsRaycast3D(ray, objects=engineObjects)
{
    const hits = [];
    for (const o of objects)
    {
        const distance = render3DRaycastObject(ray, o);
        if (distance !== undefined) // only a 3D object has a distance
            hits.push({o: /** @type {EngineObject3D} */ (o), distance});
    }
    return hits.sort((a, b)=> a.distance - b.distance).map(hit=> hit.o);
}

/**
 * Call a function for each EngineObject3D whose box overlaps a sphere or a box
 * - An object destroyed by an earlier callback is skipped
 * @param {Vector3} pos - Center of the area
 * @param {Vector3|number} size - Diameter of a sphere if a number, 0 for a point, full size of a box if a Vector3
 * @param {function(EngineObject3D): void} callback
 * @param {Array<EngineObject>} [objects] - Defaults to every object
 * @param {boolean} [testCenters] - Test only each object's center, see engineObjectsCollect3D
 * @memberof Render3D
 */
function engineObjectsCallback3D(pos, size, callback, objects=engineObjects, testCenters=false)
{
    for (const o of engineObjectsCollect3D(pos, size, objects, testCenters))
        o.destroyed || callback(o);
}

///////////////////////////////////////////////////////////////////////////////
/**
 * InstancedMesh3D - Many copies of one mesh drawn as one call, with their transforms kept on the GPU
 * - For big sets that mostly stay put: an instance costs nothing per frame until it changes, so a hundred thousand
 *   trees cost what one tree does; objects and drawMesh batch by themselves too, but rebuild their batch every frame
 * - setMatrixAt and setColorAt change one instance, and only the changed range uploads before the next draw
 * - The instances are in world space; the object's own pos3D, rotation3D and scale3D do not move them
 * - The whole set is culled by one bounding sphere around the origin, and casts and receives shadows like any object
 * - The object's flags cover the whole set, one emissive, one tileInfo, one shader; only the colors are per instance
 * - A mirrored instance, one with a negative scale, shows its inside unless the mesh is doubleSided
 * - A transparent set draws in one go in the transparent stage, its instances are not sorted against each other;
 *   the set sorts against other transparent draws by the object's position, so put pos3D at its middle
 * - pick, the raycast and the collect helpers do not see the instances, test them yourself from instanceData
 * @extends EngineObject3D
 * @memberof Render3D
 * @example
 * const forest = new InstancedMesh3D(treeMesh, 1000);
 * for (let i = 0; i < 1000; ++i)
 *     forest.setMatrixAt(i, buildMatrix(randomGroundPos(), vec3(0, rand(2*PI), 0)));
 */
class InstancedMesh3D extends EngineObject3D
{
    /** Create a set of instances of a mesh, each at the origin in the object's color until it is set
     *  @param {Mesh} mesh
     *  @param {number} count - How many instances there is room for, all of them draw until count is lowered
     *  @param {TileInfo|TextureInfo} [tileInfo] - Texture for all of them
     *  @param {Color} [color] - The color they start with */
    constructor(mesh, count, tileInfo, color=WHITE)
    {
        super(vec3(), mesh, tileInfo, color);
        ASSERT(mesh instanceof Mesh, 'an InstancedMesh3D needs a Mesh');
        ASSERT(count >= 1, 'an InstancedMesh3D needs room for at least one instance');
        this.size3D = vec3(); // not a solid thing to pick or collect
        /** @property {number} - How many instances draw, the first ones, up to the count it was made with */
        this.count = count;
        /** @property {number} - How many instances it was made with */
        this.maxCount = count;
        /** @property {Float32Array} - The per instance values the shader reads, 24 floats each: the matrix, the color
         *  and the uv rect; edit it directly and call markDirty for the instances changed */
        this.instanceData = new Float32Array(count * RENDER3D_INSTANCE_FLOATS);
        /** @property {number} - Radius of the sphere around the origin that holds every instance set so far, for
         *  culling; from the farthest instance and the largest scale, and the mesh's size when it draws */
        this.radius = 0;
        this.reach = 0;    // the farthest any instance's position has been from the origin
        this.maxScale = 0; // and the largest scale any instance has had
        /** @property {number} - First instance to upload before the next draw */
        this.dirtyStart = 0;
        /** @property {number} - One past the last instance to upload, so nothing uploads when it is not past dirtyStart */
        this.dirtyEnd = count;
        this.buffer = undefined;    // the GPU copy of instanceData
        this.bufferGeneration = -1; // the context it was made under
        this.uvTileInfo = tileInfo; // the tile the uv rects were written for
        const uv = render3DGetTileUVs(tileInfo), data = this.instanceData;
        for (let i = 0; i < count; ++i)
        {
            this.setMatrixAt(i, RENDER3D_IDENTITY);
            const k = i * RENDER3D_INSTANCE_FLOATS;
            data[k+16] = color.r; data[k+17] = color.g; data[k+18] = color.b; data[k+19] = color.a;
            data[k+20] = uv.x; data[k+21] = uv.y; data[k+22] = uv.w; data[k+23] = uv.h;
        }
    }

    /** Place an instance, in world space
     *  @param {number} i
     *  @param {Matrix4} matrix */
    setMatrixAt(i, matrix)
    {
        ASSERT(i >= 0 && i < this.maxCount, 'instance index out of range');
        const data = this.instanceData, k = i * RENDER3D_INSTANCE_FLOATS, m = matrix.m;
        data.set(m, k);
        this.markDirty(i);
    }

    /** The matrix of an instance
     *  @param {number} i
     *  @return {Matrix4} */
    getMatrixAt(i)
    {
        ASSERT(i >= 0 && i < this.maxCount, 'instance index out of range');
        const k = i * RENDER3D_INSTANCE_FLOATS, matrix = new Matrix4;
        matrix.m.set(this.instanceData.subarray(k, k + 16));
        return matrix;
    }

    /** Color an instance
     *  @param {number} i
     *  @param {Color} color */
    setColorAt(i, color)
    {
        ASSERT(i >= 0 && i < this.maxCount, 'instance index out of range');
        ASSERT(isColor(color), 'color must be a Color');
        const data = this.instanceData, k = i * RENDER3D_INSTANCE_FLOATS;
        data[k+16] = color.r; data[k+17] = color.g; data[k+18] = color.b; data[k+19] = color.a;
        this.markDirty(i);
    }

    /** Note that an instance changed, so it uploads before the next draw and the bounds hold it; setMatrixAt and
     *  setColorAt call this, and so must an edit made straight to instanceData
     *  @param {number} i */
    markDirty(i)
    {
        this.dirtyStart = min(this.dirtyStart, i);
        this.dirtyEnd = max(this.dirtyEnd, i + 1);

        // the bounds grow to hold where it is now and how big, read back from the matrix it has
        const d = this.instanceData, k = i * RENDER3D_INSTANCE_FLOATS;
        this.reach = max(this.reach, hypot(d[k+12], d[k+13], d[k+14]));
        this.maxScale = max(this.maxScale, render3DMaxStretch(d, k));
        const meshRadius = this.mesh.radius || this.mesh.computeRadius(); // measured once, the draw takes a new size up
        this.radius = this.reach + meshRadius * this.maxScale;
    }

    /** Draws every instance as one call, uploading the ones that changed first
     *  @return {void} */
    render3D()
    {
        const r = render3D, gl = glContext, mesh = this.mesh;
        ASSERT(this.count >= 0 && this.count <= this.maxCount, 'count must be within the count it was made with');
        if (r.transparentQueue) // sorted as one thing with the other transparent draws
            return r.queueTransparent(this.getWorldPos3D(), ()=> this.render3D());
        if (!mesh || !this.count || !render3DCanDraw()) return;
        if (r.shadowPass && !r.lighting) return; // unlit things cast no shadow
        render3DMeshUpload(mesh);
        if (!mesh.bufferCount) return;
        this.radius = this.reach + mesh.radius * this.maxScale; // the mesh may have grown since
        if (r.frustumCulling && !render3DSphereVisible(0, 0, 0, this.radius)) return;

        // the uv rect is the object's tile for every instance, rewritten when the tile changes
        if (this.uvTileInfo !== this.tileInfo)
        {
            const uv = render3DGetTileUVs(this.tileInfo), data = this.instanceData;
            for (let k = 20; k < data.length; k += RENDER3D_INSTANCE_FLOATS)
                data[k] = uv.x, data[k+1] = uv.y, data[k+2] = uv.w, data[k+3] = uv.h;
            this.uvTileInfo = this.tileInfo;
            this.dirtyStart = 0, this.dirtyEnd = this.maxCount;
        }

        // the GPU copy: all of it under a fresh context, otherwise just the changed range
        if (!this.buffer || this.bufferGeneration !== r.contextGeneration)
        {
            this.buffer = gl.createBuffer();
            this.bufferGeneration = r.contextGeneration;
            gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
            gl.bufferData(gl.ARRAY_BUFFER, this.instanceData, gl.DYNAMIC_DRAW);
        }
        else if (this.dirtyEnd > this.dirtyStart)
        {
            const start = this.dirtyStart * RENDER3D_INSTANCE_FLOATS, end = this.dirtyEnd * RENDER3D_INSTANCE_FLOATS;
            gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
            gl.bufferSubData(gl.ARRAY_BUFFER, start * 4, this.instanceData, start, end - start);
        }
        this.dirtyStart = Infinity, this.dirtyEnd = 0;

        // one draw under the object's state, the mesh setting the culling as drawMesh does
        r.flush();
        render3DFlushBeforeOverlay(); // as in drawMesh, what was drawn before goes under it
        const cullBackFaces = r.cullBackFaces, tileInfo = this.tileInfo;
        r.cullBackFaces = !mesh.doubleSided;
        render3DDrawInstanced(mesh, this.buffer, this.count, render3DTextureOf(tileInfo), r);
        r.cullBackFaces = cullBackFaces;
    }

    /** Destroy the set and free its GPU buffer
     *  @param {boolean} [immediate] */
    destroy(immediate)
    {
        if (this.buffer && this.bufferGeneration === render3D?.contextGeneration)
            glContext?.deleteBuffer(this.buffer);
        this.buffer = undefined;
        super.destroy(immediate);
    }
}

///////////////////////////////////////////////////////////////////////////////
/**
 * Light3D - A light that is an EngineObject3D, so it can move, follow a parent or be destroyed like anything else
 * - A point light: it lights what is near it and fades out by its radius, DirectionalLight3D shines from far away
 * - Only the sun, render3D.sunDirection, casts shadows; these light and make highlights without one
 * - Only the 8 lights nearest the camera are used each frame
 * - radius is where the light fades out, and it fades fast, so a small radius wants a higher intensity
 * - intensity multiplies the color, above 1 for a light brighter than white
 * - radius is a world distance, so scale3D does not change it
 * - An alpha, an intensity or a radius of 0 switches it off, and a light that is off takes none of those slots
 * - Draws nothing itself, add a glow with drawSoftDisc or a small emissive mesh if it should be seen
 * @extends EngineObject3D
 * @memberof Render3D
 * @example
 * const torch = new Light3D(vec3(0, 3, 0), 10, hsl(.1, 1, .65));
 */
class Light3D extends EngineObject3D
{
    /** Create a point light
     *  @param {Vector3} [pos3D] - Where it is
     *  @param {number} [radius] - Distance where the light fades to nothing
     *  @param {Color} [color] - Light color, its alpha fades it
     *  @param {number} [intensity] - Brightness, multiplies the color, above 1 is brighter than white */
    constructor(pos3D=vec3(), radius=5, color=WHITE, intensity=1)
    {
        super(pos3D, undefined, undefined, color);
        ASSERT(radius >= 0, 'light radius cannot be negative, 0 is an off switch like an alpha of 0');
        ASSERT(intensity >= 0, 'light intensity cannot be negative, 0 is an off switch');
        this.size3D = vec3(); // not a solid thing to pick or collect
        /** @property {number} - Distance where the light fades to nothing */
        this.radius = radius;
        /** @property {number} - Brightness, multiplies the color, above 1 is brighter than white */
        this.intensity = intensity;
        /** @property {boolean} - Shine from far away, from its position toward the origin, instead of out from its
         *  position with a falloff; DirectionalLight3D sets it */
        this.directional = false;
        /** @property {number} - Size of a soft hazy glow drawn over the light, like a lamp at night, 0 for none; it
         *  is added onto what is behind it, and what is in front of the light hides it */
        this.glow = 0;
        /** @property {number} - How fast the glow fades from its middle: 1 by default, .5 a wide haze, 2 a tight
         *  bright core */
        this.glowFalloff = 1;
        this.additive = true; // the glow is added on, in the transparent stage; a light with none draws nothing
    }

    /** Draw the glow, a quad facing the camera with a soft round glow on it, pulled toward the camera by half its
     *  size so a lamp at the light does not cut into it, but never past the camera */
    render3D()
    {
        if (!(this.glow > 0) || this.directional) return;
        const r = render3D, c = this.color, pos = render3DObjectMatrix(this).getTranslation();
        const toCamera = r.camera.pos.subtract(pos), distance = toCamera.length();
        const at = distance ? pos.add(toCamera.scale(min(this.glow, distance) / 2 / distance)) : pos;
        const color = rgb(c.r, c.g, c.b, c.a * min(this.intensity, 1));
        r.drawBillboard(at, vec2(this.glow), render3DGlow(this.glowFalloff), color);
    }
}

// how strong a light's glow is at a distance from its middle, 0 there to 1 at the edge: a bell, full in the middle
// and nothing at the edge, fading faster the higher the falloff
function render3DGlowAlpha(r, falloff)
{
    const k = 3.5 * falloff, edge = Math.exp(-k);
    return (Math.exp(-k * r * r) - edge) / (1 - edge);
}

// the soft round glow of the lights, one texture for each falloff, rounded to a tenth so a changing falloff makes
// only a few, each made once from a canvas; undefined headless or without a canvas
const render3DGlowTextures = new Map;
function render3DGlow(falloff=1)
{
    ASSERT(isNumber(falloff) && falloff > 0, 'glowFalloff must be a number above 0');
    const key = max(round(falloff * 10), 1) / 10;
    let texture = render3DGlowTextures.get(key);
    if (texture || !glContext || typeof OffscreenCanvas == 'undefined') return texture;
    const size = 64, context = createCanvasContext(size), steps = 16;
    const gradient = context.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
    for (let i = 0; i <= steps; ++i)
        gradient.addColorStop(i / steps, 'rgba(255,255,255,' + render3DGlowAlpha(i / steps, key).toFixed(4) + ')');
    context.fillStyle = gradient;
    context.fillRect(0, 0, size, size);
    render3DGlowTextures.set(key, texture = new TextureInfo(context.canvas));
    return texture;
}

///////////////////////////////////////////////////////////////////////////////
/**
 * DirectionalLight3D - A Light3D that shines from far away with no falloff, like sunlight
 * - It shines from its position toward the origin, like a three.js DirectionalLight: only the direction to it
 *   counts, so moving it or its parent swings the light around; parent it to a sun in the sky and it follows
 * - It cannot sit on the origin, since that leaves no direction
 * - Like every Light3D it casts no shadow, only the sun, render3D.sunDirection, does
 * @extends Light3D
 * @memberof Render3D
 * @example
 * const fill = new DirectionalLight3D(vec3(-1, 1, 1), hsl(.6, .5, .3)); // from the back left and above
 */
class DirectionalLight3D extends Light3D
{
    /** Create a directional light
     *  @param {Vector3} [pos3D] - Where it shines from, toward the origin
     *  @param {Color} [color] - Light color, its alpha fades it
     *  @param {number} [intensity] - Brightness, multiplies the color, above 1 is brighter than white */
    constructor(pos3D=vec3(0, 1, 0), color=WHITE, intensity=1)
    {
        super(pos3D, 0, color, intensity);
        this.directional = true;
    }
}