/**
* 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 one directional light plus ambient, with optional extra lights, fog and shadows
* - Build shapes with buildBox, buildSphere and friends, or load a model with loadOBJ
* - 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 normal matrix columns
// (8-10), the tint (11) and the uv rect (12); constants for one draw, one per instance for a batch
const RENDER3D_INSTANCE_FLOATS = 33;
const RENDER3D_INSTANCE_BYTES = RENDER3D_INSTANCE_FLOATS * 4;
const RENDER3D_INSTANCE_ATTRIBS = [[4, 4, 0], [5, 4, 16], [6, 4, 32], [7, 4, 48], [8, 3, 64], [9, 3, 76], [10, 3, 88], [11, 4, 100], [12, 4, 116]];
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=8) in vec3 n0;layout(location=9) in vec3 n1;layout(location=10) in vec3 n2;' +
'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
const RENDER3D_QUAD_UVS = Object.freeze([vec2(0, 0), vec2(0, 1), vec2(1, 0), vec2(1, 1)].map(uv=> Object.freeze(uv))); // strip order
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
const RENDER3D_DEBUG_WIDTH = .05; // line width of the debug primitives
// gap between lines of 3D text, as a share of the character height; flat text can let lines touch
// the way the 2D font does, but extruded glyphs seen from an angle then overlap the line below
const RENDER3D_TEXT_LEADING = 1.3;
///////////////////////////////////////////////////////////////////////////////
// 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.shader) 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
const RENDER3D_STATE_FIELDS = ['blend', 'additive', 'depthTest', 'depthWrite', 'cullBackFaces', 'lighting', 'emissive', 'receiveShadow', 'specular', 'pixelated'];
// a copy of the current draw state
function render3DCaptureBatchState()
{
const state = {};
for (const field of RENDER3D_STATE_FIELDS)
state[field] = render3D[field];
return state;
}
// true when the current draw state differs from a captured one, so a pending batch must flush first
function render3DStateChanged(state)
{
for (const field of RENDER3D_STATE_FIELDS)
if (render3D[field] !== state[field])
return true;
return false;
}
// 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
function render3DSize3(size) { return isNumber(size) ? vec3(size) : size; }
// a transform given as a matrix, or as a vec3 for one that only moves there
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(); }
// 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]); }
// surface normal from the slope of a height function, sampled half a cell each way but kept inside the half sizes
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);
return vec3(-dx, 1, -dz).normalize();
}
// 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;
}
// 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;
r.receiveShadow = !o || o.receiveShadow;
r.cullBackFaces = !!o?.cullBackFaces;
r.pixelated = !!o?.pixelated;
r.depthTest = true;
}
// 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();
}
// let go of the parent but stay where the object was in the world; a destroyed parent has already let go, so the
// position remembered by the last update stands in
function render3DDetach(o)
{
if (o.parent)
o.pos3D = o.getWorldPos3D(), o.parent.removeChild(o);
else if (o.worldPos3D)
o.pos3D = o.worldPos3D;
}
// 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 r = render3D, textureInfo = tileInfo instanceof TileInfo ? tileInfo.textureInfo : tileInfo;
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
let data = mesh.instanceData;
const 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 = data = grown;
}
data.set(matrix.m, k);
render3DNormalMatrix3(matrix.m, data, k + 16);
data[k+25] = color.r; data[k+26] = color.g; data[k+27] = color.b; data[k+28] = color.a;
const uv = render3DGetTileUVs(tileInfo);
data[k+29] = uv.x; data[k+30] = uv.y; data[k+31] = uv.w; data[k+32] = uv.h;
}
// 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;
gl.bindBuffer(gl.ARRAY_BUFFER, buffers[r.instanceBufferIndex = (r.instanceBufferIndex + 1) % buffers.length]);
gl.bufferData(gl.ARRAY_BUFFER, mesh.instanceData, gl.DYNAMIC_DRAW, 0, count * RENDER3D_INSTANCE_FLOATS);
for (const [location, size, offset] of RENDER3D_INSTANCE_ATTRIBS)
{
gl.vertexAttribPointer(location, size, gl.FLOAT, false, RENDER3D_INSTANCE_BYTES, offset);
gl.enableVertexAttribArray(location);
}
render3DSetDrawUniforms(RENDER3D_IDENTITY, mesh.instanceTextureInfo, WHITE, RENDER3D_FULL_UV_RECT, mesh.instanceState);
render3DBindVertexBuffer(mesh.buffer);
gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, mesh.bufferCount, count);
for (const [location] of RENDER3D_INSTANCE_ATTRIBS)
gl.disableVertexAttribArray(location);
++drawCount;
primitiveCount += mesh.bufferCount * count;
}
if (!only)
r.instanceMeshes.length = 0;
else
{
const i = r.instanceMeshes.indexOf(only);
i < 0 || r.instanceMeshes.splice(i, 1);
}
}
// 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 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 = engineObjects.filter(o=> !o.destroyed && o instanceof Light3D &&
o.color.a > 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;
}
// a soft white dot for untextured particles, made once from a canvas, undefined headless or without a canvas
let render3DSoftDotTexture;
function render3DSoftDot()
{
if (render3DSoftDotTexture || !glContext || typeof OffscreenCanvas == 'undefined') return render3DSoftDotTexture;
const size = 32, context = createCanvasContext(size);
const gradient = context.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
for (const [stop, alpha] of [[0, 1], [.33, .9], [.67, .7], [1, 0]]) // the same falloff as a soft disc
gradient.addColorStop(stop, 'rgba(255,255,255,' + alpha + ')');
context.fillStyle = gradient;
context.fillRect(0, 0, size, size);
return render3DSoftDotTexture = new TextureInfo(context.canvas);
}
// 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;
/** @property {Camera3D} - The camera */
this.camera = new Camera3D;
// lights and fog
/** @property {Vector3} - Direction the directional light travels, read at each draw;
* any length will do, both the shading and the shadows normalize it themselves */
this.lightDirection = vec3(.3, -1, -.5);
/** @property {Color} - Directional light color */
this.lightColor = WHITE.copy();
/** @property {Color} - Ambient light color */
this.ambientColor = hsl(0, 0, .3);
/** @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} - Floor for objects with a softShadow: a height, a HeightMap, or (x, z) => y
* @type {number|HeightMap|Function} */
this.softShadowHeight = 0;
/** @property {boolean} - Default for every builder's smooth argument: true for smooth vertex normals, false for flat faces */
this.smoothShading = false;
// shadows
/** @property {boolean} - Cast real shadows from the directional light, 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 */
this.depthTest = true;
/** @property {boolean} - Write to the depth buffer, owned by the stages: on for opaque, off for transparent */
this.depthWrite = true;
/** @property {boolean} - Skip faces that point away from the camera, set per object with its cullBackFaces flag */
this.cullBackFaces = false;
/** @property {number} - Strength of the highlight where the directional light reflects, 0 is none and 1 adds the light's full color at its brightest; its size is fixed */
this.specular = 0;
/** @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);
// 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
this.frustumPlanes = []; // the view as six inward planes [x, y, z, w]
this.shadowPlanes = []; // the shadow map's box as six planes
this.shader = undefined; // the main shader, undefined when not available
this.shadowShader = undefined;
this.vao = undefined;
this.whiteTexture = undefined; // 1x1 white for untextured draws
this.samplers = []; // how textures are filtered in 3D, clamped and wrapping, see render3DInitGL
this.samplerKey = undefined; // the settings the samplers were made for, they are rebuilt when it changes
this.mipmapped = new WeakSet; // textures given mipmaps for the 3D pass
this.shadowTexture = undefined;
this.shadowFramebuffer = undefined;
this.shadowTextureSize = 0;
this.contextGeneration = 0; // counts context losses, a mesh uploaded under an older one uploads again
this.uniforms = new Map; // uniform locations by program
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
this.streamBuffer = undefined;
this.instanceBuffers = []; // the per instance values of the batches being drawn, used in turn
this.instanceBufferIndex = 0;
this.instanceMeshes = []; // meshes with a batch pending this stage
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;
this.streamTileInfo = undefined;
this.streamState = undefined; // captured state the pending batch was drawn under
this.capture = undefined; // the mesh a bake is filling
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 camera plane when orthographic */
screenToRay(screenPos, canvasSize=mainCanvasSize)
{
const width = canvasSize.x || 1, height = canvasSize.y || 1; // a canvas with no size stands in as 1x1, rather than dividing by zero
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));
return camera.orthographic
? new Ray3D(camera.pos.add(offset), 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 a sphere around its mesh, or around a sprite's size3D, 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: o, 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]
* @return {SoundInstance|undefined} - undefined when out of range or sound is off */
playSound(sound, pos3D, volume=1, pitch=1, randomnessScale=1, loop=false)
{
// keep in step with Sound.play, only the pan differs
ASSERT(sound instanceof Sound, 'sound must be a Sound');
ASSERT(isVector3(pos3D), 'pos3D must be a vec3');
if (!soundEnable || headlessMode) return;
if (!sound.sampleBuffer && !sound._sampleChannels) return; // still loading
const offset = pos3D.subtract(this.camera.pos), range = sound.range;
if (range)
{
const distance = offset.length();
if (distance > range)
return; // out of range
volume *= percent(distance, range, range * sound.taper);
}
const pan = offset.normalize().dot(this.cameraRight);
const rate = pitch + pitch * sound.randomness * randomnessScale * rand(-1, 1);
return new SoundInstance(sound, volume, rate, pan, loop);
}
/** 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)
{
for (const p of this.shadowPass ? this.shadowPlanes : this.frustumPlanes)
if (p[0] * center.x + p[1] * center.y + p[2] * center.z + p[3] < -radius)
return false;
return true;
}
///////////////////////////////////////////////////////////////////////////
// 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 */
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)
return this.queueTransparent(matrix.getTranslation(), ()=> this.drawMesh(mesh, matrix, tileInfo, color));
if (!render3DCanDraw()) return;
if (this.shadowPass && !this.lighting) return; // unlit things cast no shadow
if (!mesh.buffer || mesh.dirty || mesh.contextGeneration !== this.contextGeneration)
mesh.upload();
if (!mesh.bufferCount) return;
if (this.frustumCulling && !this.isSphereVisible(matrix.getTranslation(), mesh.radius * render3DMaxScale(matrix.m)))
return;
if (!this.blend && this.depthTest && (mesh.instanced ?? this.instancing)) // the stage draws the batch at its end
return render3DInstance(mesh, matrix, tileInfo, color);
this.flush();
render3DSetDrawUniforms(matrix, tileInfo, color);
render3DBindVertexBuffer(mesh.buffer);
glContext.drawArrays(glContext.TRIANGLE_STRIP, 0, mesh.bufferCount);
++drawCount;
primitiveCount += mesh.bufferCount;
}
/** 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
* @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 */
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);
const uv = uvArray ? uvs[i] : uvs || RENDER3D_DEFAULT_UV;
render3DWriteVertex(floats, ints, this.streamCount++ * RENDER3D_VERTEX_FLOATS, points[i],
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
* @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 = o.getMatrix();
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
* @param {Vector3} pos - Where the draw is, for sorting
* @param {Function} draw */
queueTransparent(pos, draw)
{
if (!this.transparentQueue)
return draw();
this.transparentQueue.push({distance: pos.distanceSquared(this.camera.pos), 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);
for (const item of queue)
render3DWithState(item.state, item.draw); // each under the state it was queued with
}
/** 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}, ()=>
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 direction = this.lightDirection.normalize();
const center = this.shadowCenter || this.camera.pos.add(this.cameraForward.scale(half * .8));
const view = Matrix4.lookAt(center.subtract(direction.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 set the fog color to the horizon color
* @param {Color} [topColor] - Straight up
* @param {Color} [horizonColor] - Level with the camera
* @param {Color} [bottomColor] - Straight down, defaults to the horizon color
* @return {Mesh} - The dome, also in render3D.sky */
setSky(topColor, horizonColor=hsl(.6, 1, .9), bottomColor)
{
this.sky?.dispose();
this.sky = buildSky(topColor, horizonColor, bottomColor);
this.fogColor = horizonColor.copy();
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 */
drawBillboard(pos, size=vec2(1), tileInfo, color=WHITE, angle=0, upright=false)
{
if (this.capture)
return this.drawStripUnlit(render3DBillboardCorners(pos, size, angle, upright), this.cameraBack, RENDER3D_QUAD_UVS, color, tileInfo);
if (this.transparentQueue) // sort by the exact position, a shadow under it sorts by the floor
return this.queueTransparent(pos, ()=> this.drawBillboard(pos, size, tileInfo, color, angle, upright));
// the particle path: the quad's six stream vertices written straight in, unlit
const count = render3DStripCount(4);
const lighting = this.shadowPass && this.lighting; // unlit on screen, in the shadow map the object's flag decides
const uvRect = render3DWithState({lighting}, ()=> render3DBeginStrip(count, tileInfo));
if (!uvRect) return;
const corners = render3DBillboardCorners(pos, size, angle, upright), rgba = color.rgbaInt();
const floats = this.streamFloats, ints = this.streamInts;
for (let k = 0; k < count; ++k)
{
const i = render3DStripIndex(k, 4), uv = RENDER3D_QUAD_UVS[i];
render3DWriteVertex(floats, ints, this.streamCount++ * RENDER3D_VERTEX_FLOATS, corners[i], this.cameraBack,
uvRect.x + uv.x * uvRect.w, uvRect.y + uv.y * uvRect.h, 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)
{
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)
{
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, 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 {Color|Array<Color>} [color] - One for all or one per point
* @param {TileInfo|TextureInfo} [tileInfo]
* @param {Vector3|Array<Vector3>} [side] - Direction across the ribbon, one for all or one per point, default faces the camera */
drawRibbon(points, width=.1, color=WHITE, tileInfo, side)
{
const count = points.length;
ASSERT(count > 1, 'a ribbon needs at least two points');
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;
const c = isArray(color) ? color[i] : color;
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] */
drawSoftDisc(pos, size=1, color=WHITE, normal=this.cameraBack, sides=16)
{
render3DAssertBlending();
if (this.transparentQueue && !this.capture)
return this.queueTransparent(pos, ()=> this.drawSoftDisc(pos, size, color, normal, 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} [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 */
drawSoftShadow(pos, size=1, floorHeight=0, color=RENDER3D_SHADOW_COLOR, lift=.02)
{
render3DAssertBlending();
const height = isNumber(floorHeight) ? ()=> floorHeight
: floorHeight instanceof HeightMap ? (x, z)=> floorHeight.getHeight(x, z) : floorHeight;
if (this.transparentQueue && !this.capture) // sort from the floor, under whatever casts it
return this.queueTransparent(vec3(pos.x, height(pos.x, pos.z) + lift, pos.z), ()=> this.drawSoftShadow(pos, size, floorHeight, color, 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);
}
}
///////////////////////////////////////////////////////////////////////////////
// Debug primitives, drawn on top of the 3D scene like the 2D debug functions, only in debug builds
let render3DDebugPrimitives = [];
// draw the live debug primitives with depth test off so they show through walls, drop the expired ones
function render3DRenderDebug()
{
if (!render3DDebugPrimitives.length) return;
render3DWithState({lighting: false, depthTest: false, receiveShadow: false, additive: false}, ()=>
{
for (const p of render3DDebugPrimitives)
p.draw();
});
render3DDebugPrimitives = render3DDebugPrimitives.filter(p=> p.timer < 0); // a Timer compares as negative until it elapses
}
// record a debug draw for a time
function render3DDebugPush(duration, draw)
{
ASSERT(isNumber(duration), 'duration must be a number');
debug && render3D?.shader && render3DDebugPrimitives.push({timer: new Timer(duration), draw});
}
/** Draw a debug wireframe box
* @param {Vector3} pos - Center
* @param {Vector3|number} [size] - Full size, a number for a cube
* @param {Color} [color]
* @param {number} [time] - How long to show it, 0 is one frame
* @param {Vector3} [rotation] - vec3(pitch, yaw, roll)
* @memberof Render3D */
function debugBox3D(pos, size=1, color=WHITE, time=0, rotation)
{
const matrix = buildMatrix(pos, rotation, render3DSize3(size));
const corner = (i)=> matrix.transformPoint(vec3(i & 1 ? .5 : -.5, i & 2 ? .5 : -.5, i & 4 ? .5 : -.5));
render3DDebugPush(time, ()=>
{
for (let i = 0; i < 8; ++i)
for (const bit of [1, 2, 4])
if (!(i & bit))
render3D.drawLine(corner(i), corner(i | bit), RENDER3D_DEBUG_WIDTH, color);
});
}
/** Draw a debug wireframe sphere as three rings
* @param {Vector3} pos - Center
* @param {number} [size] - Diameter
* @param {Color} [color]
* @param {number} [time] - How long to show it, 0 is one frame
* @memberof Render3D */
function debugSphere3D(pos, size=1, color=WHITE, time=0)
{
const circle = render3DCircle(24), r = size / 2;
render3DDebugPush(time, ()=>
{
for (const ring of [(c, s)=> vec3(c, s, 0), (c, s)=> vec3(c, 0, s), (c, s)=> vec3(0, c, s)])
{
const points = [];
for (let i = 0; i <= 24; ++i)
points.push(pos.add(ring(circle[i*2], circle[i*2 + 1]).scale(r)));
render3D.drawRibbon(points, RENDER3D_DEBUG_WIDTH, color);
}
});
}
/** Draw a debug line
* @param {Vector3} posA
* @param {Vector3} posB
* @param {Color} [color]
* @param {number} [width]
* @param {number} [time] - How long to show it, 0 is one frame
* @memberof Render3D */
function debugLine3D(posA, posB, color=WHITE, width=RENDER3D_DEBUG_WIDTH, time=0)
{
render3DDebugPush(time, ()=> render3D.drawLine(posA, posB, width, color));
}
/** Draw a debug point as a small cross of three lines
* @param {Vector3} pos
* @param {Color} [color]
* @param {number} [time] - How long to show it, 0 is one frame
* @param {number} [size] - Length of the cross
* @memberof Render3D */
function debugPoint3D(pos, color=WHITE, time=0, size=.2)
{
render3DDebugPush(time, ()=>
{
for (const axis of [vec3(size / 2, 0, 0), vec3(0, size / 2, 0), vec3(0, 0, size / 2)])
render3D.drawLine(pos.subtract(axis), pos.add(axis), RENDER3D_DEBUG_WIDTH, color);
});
}
///////////////////////////////////////////////////////////////////////////////
/**
* 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} */
forward() { return render3DAxis(this.getMatrix().m, 8).scale(-1); }
/** Returns the camera's right axis
* @return {Vector3} */
right() { return render3DAxis(this.getMatrix().m, 0); }
/** Returns the camera's up axis
* @return {Vector3} */
up() { 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));
this.lookAt(target);
}
/** 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
const RENDER3D_ATTRIBS = [[0, 3, 5126, false, 0], [1, 3, 5126, false, 12], [2, 2, 5126, false, 24], [3, 4, 5121, true, 32]];
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;
r.uniforms = new Map;
r.uniformValues = {};
r.attribValues = []; // a fresh context has its own attribute defaults, so nothing sent before it counts
// the shader
// attributes: p position, n normal, t uv, c color, at fixed slots the depth shader also uses
// vertex uniforms: viewProj, lightViewProj; the model matrix, the normal matrix (the fixed up one for normals),
// the tint and the uv rect are vertex attributes, see RENDER3D_VERTEX_INPUTS
// fragment uniforms: lightDir (xyz, w = emissive, 1 or more skips the lighting), lightColor (rgb, a = specular),
// ambientColor (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)
r.shader = glCreateProgram(
'#version 300 es\n' +
'precision highp float;' +
'uniform mat4 viewProj,lightViewProj;' +
RENDER3D_VERTEX_INPUTS +
'out vec3 P,N;out vec2 T;out vec4 C,S;' +
'void main(){' +
'vec4 w=mat4(m0,m1,m2,m3)*vec4(p,1.);' +
'gl_Position=viewProj*w;' +
'P=w.xyz;' +
'N=mat3(n0,n1,n2)*n;' +
'T=uvRect.xy+t*uvRect.zw;' +
'C=c*tint;' +
'S=lightViewProj*w;' +
'}'
,
'#version 300 es\n' +
'precision highp float;' +
'uniform vec4 lightDir,lightColor,ambientColor,fogColor,shadowParams;' +
'uniform vec4 extraLights[' + RENDER3D_MAX_LIGHTS + '],extraLightColors[' + RENDER3D_MAX_LIGHTS + '];' +
'uniform int extraLightCount;' +
'uniform vec3 cameraPos;' +
'uniform sampler2D tex;' +
'uniform highp sampler2DShadow shadowMap;' +
'in vec3 P,N;in vec2 T;in vec4 C,S;' +
'out vec4 o;' +
'void main(){' +
'vec4 t=texture(tex,T);' +
'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);' +
'float nl=dot(n,-lightDir.xyz);' +
// shadow: compare against the light's depth map with a 3x3 blur, outside the map is lit
'float s=1.;' +
'if(shadowParams.x>0.){' +
'vec3 q=S.xyz/S.w*.5+.5;' +
'if(all(lessThan(abs(q-.5),vec3(.5)))){' +
'q.z-=shadowParams.y;' +
'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));' +
's/=9.;' +
'}}' +
'vec3 l=ambientColor.rgb+lightColor.rgb*max(nl,0.)*s;' +
// the Light3D objects, diffuse only: 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
'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);' +
'l+=extraLightColors[i].rgb*extraLightColors[i].a*a*a*max(0.,dot(n,v/max(d,1e-6)));' +
'}' +
'c.rgb*=l*(1.-e)+e;' + // lit, blended toward its own color by how emissive it is
// specular: only where the light hits, skipped entirely when the strength is zero
'if(lightColor.a>0.){' +
'vec3 v=normalize(cameraPos-P);' +
'vec3 r=reflect(lightDir.xyz,n);' +
'c.rgb+=lightColor.rgb*pow(max(dot(r,v),0.),16.)*lightColor.a*step(0.,nl)*s*(1.-e);' +
'}}else c.rgb*=e;' + // fully emissive: its own color, or brighter, with no lighting to work out
'if(ambientColor.a>0.){' +
'float z=distance(cameraPos,P);' +
'c.rgb=mix(c.rgb,shadowParams.w<0.?vec3(0):fogColor.rgb,smoothstep(fogColor.a,ambientColor.a,z));' +
'}' +
'o=vec4(c.rgb,shadowParams.w>0.?1.:c.a);' + // an opaque draw stays opaque whatever the tint alpha says
'}'
);
// 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
r.shadowShader = glCreateProgram(
'#version 300 es\n' +
'precision highp float;' +
'uniform mat4 viewProj;' +
RENDER3D_VERTEX_INPUTS +
'out vec2 T;' +
'void main(){T=uvRect.xy+t*uvRect.zw;gl_Position=viewProj*mat4(m0,m1,m2,m3)*vec4(p,1.);}'
,
'#version 300 es\n' +
'precision highp float;' +
'uniform sampler2D tex;' +
'in vec2 T;' +
'void main(){if(texture(tex,T).a<.5)discard;}'
);
// the vertex array object with the attributes enabled once, pointers are set per buffer by render3DBindVertexBuffer
r.vao = gl.createVertexArray();
gl.bindVertexArray(r.vao);
for (const [location] of RENDER3D_ATTRIBS)
gl.enableVertexAttribArray(location);
for (const [location] of RENDER3D_INSTANCE_ATTRIBS)
gl.vertexAttribDivisor(location, 1); // one value per instance whenever a batch turns these arrays on
// 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.mipmapped = new WeakSet;
r.samplers = [];
r.samplerKey = undefined;
render3DUpdateShadowMap(1);
// hand the engine back its own buffer and vertex array, in that order so a pending 2D batch flushes right
gl.bindBuffer(gl.ARRAY_BUFFER, glArrayBuffer);
glSetInstancedMode(true);
}
function render3DContextLost()
{
const r = render3D;
r.shader = r.shadowShader = r.vao = r.streamBuffer = r.whiteTexture = undefined;
r.instanceBuffers = r.samplers = [];
r.samplerKey = undefined;
render3DClearInstances();
r.shadowFramebuffer = r.shadowTexture = undefined;
r.shadowTextureSize = 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.shader)
{
const u = render3D.uniforms;
let cache = u.get(program);
cache || u.set(program, cache = {});
return cache[name] ??= glContext.getUniformLocation(program, name);
}
// the model matrix, its normal matrix, the tint and the uv rect as constant attributes for one draw
const render3DNormalScratch = new Float32Array(9);
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]);
if (!render3D.shadowPass) // the shadow map has no lighting
{
const n = render3DNormalMatrix3(m, render3DNormalScratch, 0);
gl.vertexAttrib3f(8, n[0], n[1], n[2]);
gl.vertexAttrib3f(9, n[3], n[4], n[5]);
gl.vertexAttrib3f(10, n[6], n[7], n[8]);
}
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];
if (last && last[0] === x && last[1] === y && last[2] === z && last[3] === w)
return;
values[location] = [x, y, z, w];
glContext.vertexAttrib4f(location, x, y, z, w);
}
// write the 3x3 matrix that keeps normals pointing out when the model matrix scales unevenly, the inverse transpose
// of its top left 3x3 by cofactors; a flat model with no inverse keeps its own axes
function render3DNormalMatrix3(m, out, offset)
{
const a = m[0], b = m[1], c = m[2], d = m[4], e = m[5], f = m[6], g = m[8], h = m[9], i = m[10];
const c00 = e*i - h*f, c01 = h*c - b*i, c02 = b*f - e*c;
const det = a*c00 + d*c01 + g*c02;
if (abs(det) < 1e-12)
{
out[offset] = a; out[offset+1] = b; out[offset+2] = c;
out[offset+3] = d; out[offset+4] = e; out[offset+5] = f;
out[offset+6] = g; out[offset+7] = h; out[offset+8] = i;
return out;
}
const s = 1 / det;
out[offset] = c00 * s; out[offset+1] = (g*f - d*i) * s; out[offset+2] = (d*h - g*e) * s;
out[offset+3] = c01 * s; out[offset+4] = (a*i - g*c) * s; out[offset+5] = (g*b - a*h) * s;
out[offset+6] = c02 * s; out[offset+7] = (d*c - a*f) * s; out[offset+8] = (a*e - d*b) * s;
return out;
}
// 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, one for
// clamped textures and one for wrapping ones, rebuilt when the settings change
function render3DUpdateSamplers()
{
const gl = glContext, r = render3D, key = tilesPixelated + ' ' + r.anisotropy;
if (r.samplerKey === key) return;
r.samplerKey = key;
for (const sampler of r.samplers)
gl.deleteSampler(sampler); // the set being replaced, a lost context empties this first
const anisotropy = gl.getExtension('EXT_texture_filter_anisotropic');
// four samplers: clamped and wrapping, each smooth or hard edged
r.samplers = [false, true].flatMap(pixelated=> [gl.CLAMP_TO_EDGE, gl.REPEAT].map(wrap=>
{
const 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, wrap);
gl.samplerParameteri(sampler, gl.TEXTURE_WRAP_T, wrap);
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));
}
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
function render3DBindTexture(tileInfo, state=render3D)
{
const gl = glContext, r = render3D;
const textureInfo = tileInfo instanceof TileInfo ? tileInfo.textureInfo : tileInfo;
const texture = textureInfo?.glTexture || r.whiteTexture;
gl.bindTexture(gl.TEXTURE_2D, texture);
if (!r.mipmaps && !state.pixelated)
return gl.bindSampler(0, null); // the texture's own filtering, as in 2D
gl.bindSampler(0, r.samplers[(textureInfo?.wrap ? 1 : 0) + (state.pixelated ? 2 : 0)]);
if (!state.pixelated && !r.mipmapped.has(texture)) // a hard edged draw never reads them
{
r.mipmapped.add(texture);
gl.generateMipmap(gl.TEXTURE_2D);
}
}
// 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];
if (last && last[0] === x && last[1] === y && last[2] === z && last[3] === w)
return;
values[name] = [x, y, z, w];
glContext.uniform4f(render3DUniform(name), x, y, z, w);
}
// 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]);
}
// where a tile sits in its texture, pulled in slightly at the edges so neighbors do not bleed in
// this returns one shared object, so read it before calling again
const render3DTileUVRect = {x:0, y:0, w:1, h:1};
function render3DGetTileUVs(tileInfo)
{
if (!(tileInfo instanceof 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 model matrix of the depth shader
// 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) return; // the shadow map needs nothing else
// 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);
// lights, fog and shadows are scene state read at draw time, sent only when they change
const l = r.lightDirection, ll = l.length() || 1, lc = r.lightColor, ac = r.ambientColor, fc = r.fogColor || canvasClearColor;
render3DUniform4f('lightDir', l.x / ll, l.y / ll, l.z / ll, state.lighting ? state.emissive : 1);
render3DUniform4f('lightColor', lc.r, lc.g, lc.b, state.specular);
render3DUniform4f('ambientColor', ac.r, ac.g, ac.b, r.fogEnd);
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;
render3DUniform4f('shadowParams', r.shadows && r.passIsDefault && state.receiveShadow ? 1 : 0, r.shadowBias, r.shadowSoftness / r.shadowTextureSize, blendMode);
}
// 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;
r.updateMatrices();
r.shadowMapDrawn = false;
render3DRenderPass(false);
}
// the render hook, after gameRenderPost: the layer on top of the 2D scene
function render3DRender()
{
render3DRenderPass(true);
}
// 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.shader) 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;
after2D && glFlush(); // the 2D sprites drawn so far go under this layer
// a previous frame that threw must not leave anything pending
r.streamCount = 0;
r.capture = r.transparentQueue = undefined;
render3DClearInstances();
// take over the gl state
gl.useProgram(r.shader);
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);
gl.activeTexture(gl.TEXTURE0);
gl.depthMask(true);
gl.clear(gl.DEPTH_BUFFER_BIT);
gl.uniformMatrix4fv(render3DUniform('viewProj'), false, r.viewProjection.m);
gl.uniform1i(render3DUniform('tex'), 0);
gl.uniform1i(render3DUniform('shadowMap'), 1);
const c = r.camera.pos;
gl.uniform3f(render3DUniform('cameraPos'), c.x, c.y, c.z);
// the Light3D objects, a directional one sends the direction toward it and a negative radius
const lights = render3DCollectLights();
gl.uniform1i(render3DUniform('extraLightCount'), lights.length);
if (lights.length)
{
const positions = r.lightPositions, colors = r.lightColors;
lights.forEach((light, i)=>
{
const p = light.directional ? light.getForward3D().scale(-1) : light.getWorldPos3D();
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;
});
gl.uniform4fv(render3DUniform('extraLights'), positions, 0, lights.length * 4);
gl.uniform4fv(render3DUniform('extraLightColors'), colors, 0, lights.length * 4);
}
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;
}
gl.uniformMatrix4fv(render3DUniform('lightViewProj'), false, r.shadowMatrix.m);
r.renderStages(objects, isDefault);
}
finally
{
// hand the state back to the engine's 2D batching, even when a draw threw
r.isRendering = false;
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
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);
}
}
// 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.useProgram(r.shader);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, r.shadowTexture);
gl.activeTexture(gl.TEXTURE0);
}
}
///////////////////////////////////////////////////////////////////////////////
// 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 = tileInfo instanceof TileInfo ? tileInfo.textureInfo : tileInfo;
if (r.streamCount && (textureInfo !== r.streamTileInfo || render3DStateChanged(r.streamState)
|| r.streamCount + count > RENDER3D_MAX_STREAM_VERTS))
r.flush();
if (!r.streamCount)
r.streamState = render3DCaptureBatchState();
r.streamTileInfo = textureInfo;
return render3DGetTileUVs(tileInfo);
}
// the four corners of a camera facing quad in strip order
function render3DBillboardCorners(pos, size, angle, upright)
{
// an upright quad stands on world up and only turns to face the camera
let r = render3D.cameraRight, u = render3D.cameraUp;
if (upright)
{
const flat = vec3(r.x, 0, r.z);
r = flat.lengthSquared() ? flat.normalize() : vec3(1, 0, 0); // a rolled camera has no flat right
u = RENDER3D_DEFAULT_NORMAL;
}
const c = cos(angle), s = sin(angle), w = size.x / 2, h = size.y / 2;
const rx = (r.x * c + u.x * s) * w, ry = (r.y * c + u.y * s) * w, rz = (r.z * c + u.z * s) * w;
const ux = (u.x * c - r.x * s) * h, uy = (u.y * c - r.y * s) * h, uz = (u.z * c - r.z * s) * h;
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, p, n, u, v, rgba)
{
floats[j] = p.x; floats[j+1] = p.y; floats[j+2] = p.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;
}
// 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;
}
///////////////////////////////////////////////////////////////////////////////
/**
* Mesh - A triangle strip 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
* - The GPU buffer is created lazily on first render and dropped by dispose
* @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
* @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 buffer, created by upload
* @type {WebGLBuffer|undefined} */
this.buffer = undefined;
/** @property {number} - Vertices in the GPU buffer */
this.bufferCount = 0;
/** @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;
this.instanceCount = 0; // draws waiting in this mesh's batch, with their values, texture and draw state
this.instanceData = undefined;
/** @property {number} - Bounding sphere radius around the origin, for culling and picking, computed by upload */
this.radius = 0;
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)
{
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.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);
for (let i = 0; i < mesh.points.length; ++i)
{
this.points.push(matrix.transformPoint(mesh.points[i]));
this.normals.push(normalMatrix.transformDirection(mesh.normals[i] || RENDER3D_DEFAULT_NORMAL).normalize());
this.uvs.push((mesh.uvs[i] || RENDER3D_DEFAULT_UV).copy());
this.colors.push((mesh.colors[i] || WHITE).multiply(color));
}
this.dirty = true;
return this;
}
/** 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 = isNumber(scale) ? vec2(scale) : scale;
this.uvs = this.uvs.map(uv=> vec2(uv.x * s.x, uv.y * s.y)); // new vectors, builders share uv objects between faces
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();
}
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()
{
// one extra point at each end flips which way every triangle faces, and keeps the count even
for (const key of ['points', 'normals', 'uvs', 'colors'])
{
const a = this[key];
if (a.length)
a.unshift(a[0]), a.push(a[a.length - 1]);
}
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());
return this.radius = r ** .5;
}
/** Derive normals from the strip's triangles
* @param {boolean} [smooth] - Round the lighting across faces instead of giving each face a hard edge
* @return {Mesh} */
computeNormals(smooth=false)
{
// 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;
const key = (p)=> `${round(p.x * 1e5)},${round(p.y * 1e5)},${round(p.z * 1e5)}`;
faceNormals.forEach((f, i)=> f && [0, 1, 2].forEach(j=>
{
const a = points[i + j], u = points[i + (j + 1) % 3].subtract(a), v = points[i + (j + 2) % 3].subtract(a);
const angle = Math.acos(clamp(u.dot(v) / (u.length() * v.length() || 1), -1, 1));
const k = key(a);
sums.set(k, (sums.get(k) || vec3()).add(f.scale(angle)));
}));
for (let i = 0; i < n; ++i)
normals[i] = (sums.get(key(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.dirty = true;
return this;
}
/** Pack the vertices and create the GPU buffer, called automatically by render
* @return {Mesh} */
upload()
{
this.computeRadius();
if (!render3D?.shader) return this;
this.dispose();
const count = this.points.length;
const data = new ArrayBuffer(count * RENDER3D_VERTEX_BYTES);
const floats = new Float32Array(data), ints = new Uint32Array(data);
for (let i = 0; i < count; ++i)
{
const uv = this.uvs[i] || RENDER3D_DEFAULT_UV; // a hand built mesh may leave normals, uvs and colors empty
render3DWriteVertex(floats, ints, i * RENDER3D_VERTEX_FLOATS, this.points[i],
this.normals[i] || RENDER3D_DEFAULT_NORMAL, uv.x, uv.y, (this.colors[i] || WHITE).rgbaInt());
}
const gl = glContext;
this.buffer = gl.createBuffer();
this.bufferCount = count;
this.dirty = false;
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
this.contextGeneration = render3D.contextGeneration;
gl.bindBuffer(gl.ARRAY_BUFFER, glArrayBuffer); // the engine's 2D batch writes through this binding
return this;
}
/** 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, the CPU arrays stay so the mesh can be rendered again */
dispose()
{
if (!this.buffer) return;
// 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);
this.buffer = 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
* @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
if (!closed && (!i || i == rings - 1) && abs(profile[i][0]) < 1e-9)
return vec2(0, i ? 1 : -1);
// 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);
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), normal3D(n0, a));
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 [[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));
}
return mesh;
}
/**
* Build a cylinder standing on the Y axis, centered on the origin
* @param {number} [size] - Diameter
* @param {number} [height]
* @param {number} [sides] - Around
* @param {boolean} [smooth] - Defaults to render3D.smoothShading
* @param {boolean} [capped] - Close the ends
* @return {Mesh}
* @memberof Render3D
*/
function buildCylinder(size=1, height=1, sides=16, smooth=render3D?.smoothShading, capped=true)
{
return buildLathe([[size / 2, -height / 2], [size / 2, height / 2]], sides, smooth, capped);
}
/**
* Build a cone standing on the Y axis, centered on the origin, the point up
* @param {number} [size] - Diameter of the base
* @param {number} [height]
* @param {number} [sides] - Around
* @param {boolean} [smooth] - Defaults to render3D.smoothShading
* @param {boolean} [capped] - Close the base
* @return {Mesh}
* @memberof Render3D
*/
function buildCone(size=1, height=1, sides=16, smooth=render3D?.smoothShading, capped=true)
{
return buildLathe([[size / 2, -height / 2], [0, height / 2]], sides, smooth, capped);
}
/**
* 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);
}
/**
* Build a capsule standing on the Y axis, centered on the origin: a cylinder with a half sphere on each end
* @param {number} [size] - Diameter
* @param {number} [height] - Total height including the rounded ends, at least the size
* @param {number} [sides] - Around
* @param {number} [rings] - On each end
* @param {boolean} [smooth] - Defaults to render3D.smoothShading
* @return {Mesh}
* @memberof Render3D
*/
function buildCapsule(size=1, height=1, sides=16, rings=4, smooth=render3D?.smoothShading)
{
// the rounded ends alone are already the size tall, so a shorter capsule is only a sphere
ASSERT(height >= size, 'a capsule is at least as tall as it is wide, the ends take up the size', size, height);
const profile = [], r = size / 2, straight = max(0, height - size) / 2;
for (let i = 0; i <= rings; ++i)
{
const a = i / rings * PI / 2;
profile.push([r * sin(a), -straight - r * cos(a)]);
}
for (let i = 0; i <= rings; ++i)
{
const a = i / rings * PI / 2;
profile.push([r * cos(a), straight + r * sin(a)]);
}
return buildLathe(profile, sides, smooth);
}
/**
* Build a donut lying flat around the Y axis
* @param {number} [size] - Diameter of the whole donut, outside edge to outside edge
* @param {number} [tubeSize] - Diameter of the tube
* @param {number} [sides] - Around the ring
* @param {number} [tubeSides] - Around the tube
* @param {boolean} [smooth] - Defaults to render3D.smoothShading
* @return {Mesh}
* @memberof Render3D
*/
function buildTorus(size=1, tubeSize=.3, sides=16, tubeSides=8, smooth=render3D?.smoothShading)
{
ASSERT(tubeSize <= size, 'the tube must fit inside the torus');
const profile = [], radius = (size - tubeSize) / 2, tubeRadius = tubeSize / 2;
for (let i = 0; i <= tubeSides; ++i)
{
const a = i / tubeSides * 2 * PI;
profile.push([radius + tubeRadius * cos(a), tubeRadius * sin(a)]);
}
return buildLathe(profile, sides, smooth);
}
/**
* Build a box centered on the origin, six flat faces with uvs covering each face
* @param {Vector3|number} [size] - Full size, a number for a cube
* @return {Mesh}
* @memberof Render3D
*/
function buildBox(size=1)
{
const mesh = new Mesh;
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)],
];
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 lit ribbon along a path, for roads, tracks and walls
* - Each segment is a flat quad, the sides are across the path in the plane of the up vector
* @param {Array<Vector3>} points - Center line in order
* @param {number|Array<number>} [width] - Full width, one for all or one per point
* @param {Color|Array<Color>} [color] - One for all or one per point
* @param {boolean} [closed] - Join the last point back to the first
* @param {Vector3} [up] - Which way the ribbon faces
* @return {Mesh}
* @memberof Render3D
* @example
* const road = buildRibbon(trackPoints, 8, GRAY, true); // a loop of road
*/
function buildRibbon(points, width=1, color=WHITE, closed=false, up=vec3(0, 1, 0))
{
ASSERT(isArray(points) && points.length > 1, 'ribbon needs at least 2 points');
const mesh = new Mesh, count = points.length, edges = [];
let across = (abs(up.y) < .9 ? vec3(0, 1, 0) : vec3(1, 0, 0)).cross(up).normalize(); // anything across up
for (let i = 0; i < count; ++i)
{
// across the path, from the tangent through this point; a step along up keeps the last across
const next = points[closed ? (i + 1) % count : min(i + 1, count - 1)];
const last = points[closed ? (i + count - 1) % count : max(i - 1, 0)];
const dir = next.subtract(last).cross(up);
if (dir.lengthSquared() > 1e-12)
across = dir.normalize();
const half = across.scale((isArray(width) ? width[i] : width) / 2);
edges.push([points[i].subtract(half), points[i].add(half)]);
}
for (let i = 0; i + 1 < count + (closed ? 1 : 0); ++i)
{
const j = (i + 1) % count, a = edges[i], b = edges[j];
const c = isArray(color) ? [color[i], color[i], color[j], color[j]] : color;
mesh.addQuad(a[0], a[1], b[1], b[0], c); // counter clockwise seen from above
}
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
* @param {Vector2} [size] - World size along X and Z
* @param {Vector2|number} [segments] - Cells along X and Z, a number for both
* @param {Color|Function} [color] - One Color for the whole grid, or (x, z) => Color
* @param {Function} [heightFunction] - (x, z) => y, default flat
* @param {boolean} [smooth] - Defaults to render3D.smoothShading
* @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=()=>0, smooth=render3D?.smoothShading)
{
if (isNumber(segments))
segments = vec2(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 px = (i)=> i * cellX - size.x / 2, pz = (j)=> j * cellZ - size.y / 2;
const point = (i, j)=> { const x = px(i), z = pz(j); return vec3(x, heightFunction(x, z), z); };
const normal = (i, j)=> render3DSlopeNormal(heightFunction, px(i), pz(j), cellX / 2, cellZ / 2, size.x / 2, size.y / 2);
const uv = (i, j)=> vec2(i / segmentsX, j / segmentsZ);
const cellColor = (i, j)=> !color ? WHITE : isColor(color) ? color : color(px(i), pz(j));
for (let j = 0; j < segmentsZ; ++j)
{
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(point(i, j), point(i, j + 1));
normals.push(normal(i, j), normal(i, j + 1));
uvs.push(uv(i, j), uv(i, j + 1));
colors.push(cellColor(i, j), cellColor(i, j + 1));
}
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(point(i, j), point(i, j + 1), point(i + 1, j + 1), point(i + 1, j), cellColor(i + .5, j + .5),
[uv(i, j), uv(i, j + 1), uv(i + 1, j + 1), uv(i + 1, j)]);
}
}
return mesh;
}
/**
* Build a hull from a row of diamond shaped slices along Z, for ships, planes and cars
* - Each slice is [z, width, top, bottom, sideHeight]
* - sideHeight is 0 to 1 and puts the side corners between the bottom and the top
* - List the slices nose first, with the nose at the largest z
* @param {Array<Array<number>>} stations
* @return {Mesh}
* @memberof Render3D
* @example
* const hull = buildLoft([[1.2, .4, .2, -.1], [0, 1.4, .5, -.4], [-1, 1, .3, -.3]]);
*/
function buildLoft(stations)
{
ASSERT(isArray(stations) && stations.length > 1, 'loft needs at least 2 stations');
// the caps and the winding both assume the nose leads, so the other order turns the hull inside out
ASSERT(stations[0][0] > stations[stations.length-1][0], 'loft stations go nose first, from the largest z to the smallest');
const mesh = new Mesh;
// section points: left, top, right, bottom, wound clockwise seen from +z
const section = ([z, w, t, b, m=.5])=>
[vec3(-w / 2, lerp(b, t, m), z), vec3(0, t, z), vec3(w / 2, lerp(b, t, m), z), vec3(0, b, z)];
for (let i = 0; i + 1 < stations.length; ++i)
{
const s1 = section(stations[i]), s2 = section(stations[i + 1]);
for (let k = 0; k < 4; ++k)
mesh.addQuad(s1[k], s1[(k + 1) % 4], s2[(k + 1) % 4], s2[k]);
}
const tail = section(stations[stations.length - 1]), nose = section(stations[0]);
mesh.addQuad(tail[0], tail[1], tail[2], tail[3]);
mesh.addQuad(nose[3], nose[2], nose[1], nose[0]);
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)
{
const mesh = new Mesh;
const point = (i, a)=>
{
const e = i / rings * PI - PI/2;
return vec3(sin(a) * cos(e), sin(e), cos(a) * cos(e));
};
const color = (i)=>
{
const y = point(i, 0).y;
return y < 0 ? horizonColor.lerp(bottomColor, -y) : horizonColor.lerp(topColor, y);
};
for (let i = 0; i < rings; ++i)
{
// bottom point then top point per column, the reverse of the lathe, so the front faces inward
const points = [], colors = [];
for (let j = 0; j <= sides; ++j)
{
const a = j / sides * 2 * PI;
points.push(point(i, a), point(i + 1, a));
colors.push(color(i), color(i + 1));
}
mesh.addStrip(points, undefined, undefined, colors);
}
return mesh;
}
/**
* Turn a sprite into a 3D block model by giving its pixels thickness
* - A pixel counts as solid when it is more than half opaque
* - Each pixel keeps its own color, so white art takes the object's tint
* - Runs of matching pixels merge into one face, and side walls appear only at the sprite's edges
* - A texture's pixels are read once and kept, so redrawing a canvas texture will not change what this builds
* - Pixels can also be an array of rows, each a Color, a truthy value for white, or a falsy value for empty
* @param {TileInfo|Array<Array<Color|number|boolean>>} pixels - A tile from a loaded texture, or rows of pixels,
* each a Color (empty when see through), a truthy value for white or a falsy value for empty
* @param {Vector2} [size] - World width and height of the whole tile, centered like buildBox
* @param {number} [depth] - Thickness along Z
* @return {Mesh}
* @memberof Render3D
* @example
* new EngineObject3D(vec3(), buildExtrude(tile(3, 16), vec2(2), .5)); // a chunky version of tile 3
*/
function buildExtrude(pixels, size=vec2(1), depth=1)
{
let rows = pixels, width, height;
if (pixels instanceof TileInfo)
{
// colors for the tile's pixels only, undefined where alpha is half or less
const image = render3DReadPixels(pixels.textureInfo), data = image.data;
const x0 = pixels.pos.x | 0, y0 = pixels.pos.y | 0;
width = pixels.size.x | 0, height = pixels.size.y | 0;
rows = [];
for (let y = 0; y < height; ++y)
{
const row = rows[y] = [];
for (let x = 0; x < width; ++x)
{
const k = ((y0 + y) * image.width + x0 + x) * 4;
row.push(data[k + 3] > 127 ? rgb(data[k] / 255, data[k + 1] / 255, data[k + 2] / 255) : undefined);
}
}
}
else
{
ASSERT(isArray(pixels) && pixels.length, 'pixels must be a TileInfo or rows of pixels');
height = rows.length, width = rows[0].length;
}
// the color of a solid pixel, undefined outside or where it is empty
const solid = (x, y)=>
{
if (x < 0 || y < 0 || x >= width || y >= height) return;
const c = rows[y] && rows[y][x];
if (!c) return;
return isColor(c) ? (c.a > .5 ? c : undefined) : WHITE; // a see through Color is empty too
};
const same = (a, b)=> a === b || !!a && !!b && a.rgbaInt() === b.rgbaInt();
// call emit(start, end, color) for each run of same colored pixels, colorAt(i) undefined breaks the run
const runs = (count, colorAt, emit)=>
{
let start = 0, color;
for (let i = 0; i <= count; ++i)
{
const c = i < count ? colorAt(i) : undefined;
if (same(c, color)) continue;
if (color) emit(start, i, color);
start = i, color = c;
}
};
// pixel edges in world space, y runs down the image
const mesh = new Mesh, sx = size.x / width, sy = size.y / height, hz = depth / 2;
const px = x=> x * sx - size.x / 2, py = y=> size.y / 2 - y * sy;
const quad = (origin, right, up, normal, color)=>
mesh.addStrip(render3DQuadAxes(origin.add(right.scale(.5)).add(up.scale(.5)), right.scale(.5), up.scale(.5)), normal, RENDER3D_QUAD_UVS, color);
const X = vec3(1, 0, 0), Y = vec3(0, 1, 0), Z = vec3(0, 0, 1);
for (let y = 0; y < height; ++y)
{
// front and back faces along each row
runs(width, x=> solid(x, y), (a, b, c)=>
{
const w = X.scale((b - a) * sx), h = Y.scale(sy);
quad(vec3(px(a), py(y + 1), hz), w, h, Z, c);
quad(vec3(px(b), py(y + 1), -hz), w.scale(-1), h, Z.scale(-1), c);
});
// walls facing up and down where the pixel above or below is empty
runs(width, x=> solid(x, y - 1) ? undefined : solid(x, y), (a, b, c)=>
quad(vec3(px(a), py(y), hz), X.scale((b - a) * sx), Z.scale(-depth), Y, c));
runs(width, x=> solid(x, y + 1) ? undefined : solid(x, y), (a, b, c)=>
quad(vec3(px(a), py(y + 1), -hz), X.scale((b - a) * sx), Z.scale(depth), Y.scale(-1), c));
}
for (let x = 0; x < width; ++x)
{
// walls facing left and right where the pixel beside is empty
runs(height, y=> solid(x - 1, y) ? undefined : solid(x, y), (a, b, c)=>
quad(vec3(px(x), py(b), -hz), Z.scale(depth), Y.scale((b - a) * sy), X.scale(-1), c));
runs(height, y=> solid(x + 1, y) ? undefined : solid(x, y), (a, b, c)=>
quad(vec3(px(x + 1), py(b), hz), Z.scale(-depth), Y.scale((b - a) * sy), X, c));
}
return mesh;
}
/**
* Build a mesh of extruded text from an image font, the engine font by default so it needs no assets
* - Each glyph is extruded once per font and reused, the block is centered and faces +Z
* - Newlines stack downward, spaced a little wider than the character height so the sides do not collide
* - Every call builds a new mesh, dispose the old one when text changes often
* - Glyphs are white in the engine font, so the object's color tints the text
* @param {string|number} text
* @param {number} [size] - Character height in world units
* @param {number} [depth] - Thickness along Z
* @param {ImageFont} [font] - Defaults to engineImageFont
* @return {Mesh}
* @memberof Render3D
* @example
* new EngineObject3D(vec3(0, 2, 0), buildText3D('HELLO'), undefined, YELLOW);
*/
function buildText3D(text, size=1, depth=.2, font=engineImageFont)
{
ASSERT(font instanceof ImageFont, 'font must be an ImageFont, the engine font loads before gameInit');
const tileInfo = font.tileInfo, padding = tileInfo.padding;
const paddedX = tileInfo.size.x + padding * 2, paddedY = tileInfo.size.y + padding * 2;
const columns = tileInfo.textureInfo.size.x / paddedX | 0;
let glyphs = render3DGlyphCache.get(font); // unit sized, scaled when combined
glyphs || render3DGlyphCache.set(font, glyphs = new Map);
const charSize = vec2(size * tileInfo.size.x / tileInfo.size.y, size);
const mesh = new Mesh, lines = (text + '').split('\n');
lines.forEach((line, j)=>
{
const y = ((lines.length - 1) / 2 - j) * charSize.y * RENDER3D_TEXT_LEADING;
for (let i = 0; i < line.length; ++i)
{
const charCode = line.charCodeAt(i);
const index = charCode < 32 || charCode > 127 ? 95 : charCode - 32; // like ImageFont
if (!index) continue; // space
let glyph = glyphs.get(index);
if (!glyph)
{
const pos = vec2(index % columns * paddedX + padding, (index / columns | 0) * paddedY + padding);
glyphs.set(index, glyph = buildExtrude(new TileInfo(pos, tileInfo.size, tileInfo.textureInfo)));
}
const x = (i - (line.length - 1) / 2) * charSize.x;
mesh.combine(glyph, buildMatrix(vec3(x, y, 0), undefined, vec3(charSize.x, charSize.y, depth)));
}
});
return mesh;
}
///////////////////////////////////////////////////////////////////////////////
/**
* HeightMap - Terrain built from a grid of heights, with a mesh, a height lookup and a raycast
* - heights is a 2D array [row][column] of 0 to 1 values
* - Row 0 is the far edge at -Z and column 0 is the left edge at -X
* - It can be an image instead, where the red channel is the height
* - colors is an optional 2D array of Colors or an image, sampled per vertex
* - images are read through a canvas, so they must be same origin or loaded with crossOrigin set
* @memberof Render3D
* @example
* const terrain = new HeightMap(heightImage, vec2(100, 100), 10, colorImage);
* new EngineObject3D(vec3(), terrain.buildMesh());
* const y = terrain.getHeight(x, z); // stand things on it
*/
class HeightMap
{
/** Create a height map from an array or an image
* @param {Array<Array<number>>|HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|TextureInfo} heights
* @param {Vector2} [size] - World size along X and Z
* @param {number} [height] - World height of a full value
* @param {Array<Array<Color>>|HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|TextureInfo} [colors] */
constructor(heights, size=vec2(1), height=1, colors)
{
if (!isArray(heights))
heights = render3DImageToArray(heights, (r)=> r / 255);
if (colors && !isArray(colors))
colors = render3DImageToArray(colors, (r, g, b, a)=> rgb(r / 255, g / 255, b / 255, a / 255));
ASSERT(isArray(heights) && heights.length > 1 && isArray(heights[0]) && heights[0].length > 1, 'height map needs at least 2 rows and 2 columns');
ASSERT(size.x > 0 && size.y > 0, 'height map size must be positive, a zero size has nowhere to look things up');
/** @property {Array<Array<number>>} - Heights 0-1 as [row][column], rows along Z */
this.heights = heights;
/** @property {Array<Array<Color>>|undefined} - Vertex colors as [row][column], undefined for white
* @type {Array<Array<Color>>|undefined} */
this.colors = colors;
/** @property {Vector2} - World size along X and Z */
this.size = size.copy();
/** @property {number} - World height of a full value */
this.height = height;
}
/** Number of rows, along Z
* @return {number} */
get rows() { return this.heights.length; }
/** Number of columns, along X
* @return {number} */
get columns() { return this.heights[0].length; }
/** World height at a position, exactly the height of the mesh buildMesh draws there, clamped at the edges
* @param {number|Vector3} x - X, or a position to take X and Z from
* @param {number} [z]
* @return {number} */
getHeight(x, z)
{
if (x instanceof Vector3)
z = x.z, x = x.x; // a position works as well as its two numbers, its own y is ignored
const columns = this.columns, rows = this.rows, h = this.heights;
const u = clamp((x / this.size.x + .5) * (columns - 1), 0, columns - 1);
const v = clamp((z / this.size.y + .5) * (rows - 1), 0, rows - 1);
const i = min(floor(u), columns - 2), j = min(floor(v), rows - 2);
const fu = u - i, fv = v - j;
// each cell is two triangles split from (i, j+1) to (i+1, j), the same split buildGrid's quads use
const a = h[j][i], b = h[j+1][i], c = h[j+1][i+1], d = h[j][i+1];
const height = fu + fv <= 1 ? a + fu * (d - a) + fv * (b - a) : c + (1 - fu) * (b - c) + (1 - fv) * (d - c);
return height * this.height;
}
/** Surface normal at a position, from the slope across a sample
* @param {number|Vector3} x - X, or a position to take X and Z from
* @param {number} [z]
* @return {Vector3} */
getNormal(x, z)
{
if (x instanceof Vector3)
z = x.z, x = x.x;
const ex = this.size.x / (this.columns - 1) / 2, ez = this.size.y / (this.rows - 1) / 2;
return render3DSlopeNormal((x, z)=> this.getHeight(x, z), x, z, ex, ez, this.size.x / 2, this.size.y / 2);
}
/** Color of the nearest sample to a position, white when there are no colors
* @param {number|Vector3} x - X, or a position to take X and Z from
* @param {number} [z]
* @return {Color} */
getColor(x, z)
{
if (x instanceof Vector3)
z = x.z, x = x.x;
const c = this.colors;
if (!c) return WHITE;
const columns = c[0].length, rows = c.length;
const i = clamp(round((x / this.size.x + .5) * (columns - 1)), 0, columns - 1);
const j = clamp(round((z / this.size.y + .5) * (rows - 1)), 0, rows - 1);
return c[j][i];
}
/** Distance along a ray to where it crosses the terrain surface, or undefined for a miss
* - Steps along the ray half a cell at a time, then narrows in on the exact spot
* - A ray that starts under the ground crosses on its way out, so the hit is still on the surface
* @param {Ray3D} ray - From screenToRay, or any ray
* @return {number|undefined} */
raycast(ray)
{
const {origin, direction} = ray;
const size = this.size, height = this.height, length = direction.length();
if (!length) return;
// clip to the box around the terrain, and walk it in half cell steps from there
let t = raycastBox(ray, vec3(0, height / 2, 0), vec3(size.x, abs(height) + 1e-3, size.y));
if (t === undefined) return;
const cell = min(size.x / (this.columns - 1), size.y / (this.rows - 1));
const step = cell / 2 / length, end = t + hypot(size.x, size.y, height) / length;
if (!(step > 0)) return; // a zero size
// is the ray below the ground this far along, or undefined where it is off the map
const under = (at)=>
{
const p = origin.add(direction.scale(at));
if (abs(p.x) > size.x / 2 || abs(p.z) > size.y / 2) return;
return p.y <= this.getHeight(p.x, p.z);
};
// look for where the ray changes sides, so one coming up from under the ground
// lands on the surface it breaks through instead of wherever it entered the box
const startUnder = under(t);
if (startUnder === undefined) return; // it meets the box outside the map itself
for (; t < end; t += step)
{
const u = under(t + step);
if (u === undefined) return; // it left the map before crossing
if (u === startUnder) continue;
// it crossed between the last two samples, halve the gap until it is exact
let a = t, b = t + step;
for (let i = 0; i < 16; ++i)
{
const mid = (a + b) / 2;
under(mid) === startUnder ? a = mid : b = mid;
}
return b;
}
}
/** Build the terrain mesh, one vertex per sample, centered on the origin
* @param {boolean} [smooth] - Defaults to render3D.smoothShading
* @return {Mesh} */
buildMesh(smooth=render3D?.smoothShading)
{
return buildGrid(this.size, vec2(this.columns - 1, this.rows - 1),
this.colors && ((x, z)=> this.getColor(x, z)), (x, z)=> this.getHeight(x, z), smooth);
}
}
// read an image's pixel bytes through the engine's work canvas, as {data, width, height}
function render3DImageData(image)
{
if (image instanceof TextureInfo)
image = image.image;
ASSERT(image && image.width && image.height, 'image is not loaded');
ASSERT(workReadCanvas, 'reading an image needs a canvas, pass arrays in headless mode');
const width = image.width, height = image.height;
workReadCanvas.width = width;
workReadCanvas.height = height;
workReadContext.drawImage(image, 0, 0);
return workReadContext.getImageData(0, 0, width, height);
}
// read an image into a 2D array [row][column], sample is called with (r, g, b, a) bytes for each pixel
function render3DImageToArray(image, sample)
{
const {data, width, height} = render3DImageData(image);
const rows = [];
for (let y = 0; y < height; ++y)
{
const row = rows[y] = [];
for (let x = 0; x < width; ++x)
{
const k = (y * width + x) * 4;
row.push(sample(data[k], data[k+1], data[k+2], data[k+3]));
}
}
return rows;
}
// extruded glyph meshes by font, and the pixel bytes of a texture, read once per image
const render3DGlyphCache = new WeakMap, render3DPixelCache = new WeakMap;
function render3DReadPixels(textureInfo)
{
const image = textureInfo.image;
let pixels = render3DPixelCache.get(image);
if (!pixels)
render3DPixelCache.set(image, pixels = render3DImageData(image));
return pixels;
}
///////////////////////////////////////////////////////////////////////////////
/**
* 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, along with render3D.gravity and damping 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, angleDamping,
* additiveColor, drawSize, mirror, clampSpeed, friction and groundObject
* - 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
* - 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
* - 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)
tileInfo = new TileInfo(vec2(), tileInfo.size, tileInfo, 0, 0);
super(vec2(), 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, angleDamping is 2D only */
this.angleVelocity3D = vec3();
/** @property {Mesh|undefined} - Mesh to draw
* @type {Mesh|undefined} */
this.mesh = mesh;
/** @property {Vector3} - Size for the collect and callback helpers, and of the sprite when there is a tileInfo
* and no mesh; scale3D and any parent's scale grow it, so drawing and picking agree */
this.size3D = 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 directional light reflects, 0 is none and 1 adds the light's full color at its brightest; its size is fixed */
this.specular = 0;
/** @property {boolean} - Draw into the shadow map when render3D.shadows is on; sprites and cut out textures cast their outline, 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 {boolean} - Darkened by the shadow map when render3D.shadows is on */
this.receiveShadow = true;
/** @property {boolean} - Skip faces that point away from the camera, faster for closed meshes */
this.cullBackFaces = false;
/** @property {boolean|undefined} - Draw this object over the 2D scene, undefined uses render3D.renderAfter2D
* @type {boolean|undefined} */
this.renderAfter2D = undefined;
}
/** 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();
render3DMove(this);
// 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 */
updateTransforms()
{
if (!paused)
{
// a child is never given updatePhysics, so it moves here, as an offset from its parent
this.parent && render3DMove(this);
if (this.sync2D)
this.pos3D.x = this.pos.x, this.pos3D.y = this.pos.y, this.rotation3D.z = -this.angle;
}
super.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
* - 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} [collideTiles] - Tile collision, 2D only so it needs sync2D
* @param {boolean} [collideRaycast] - Raycasts, 2D only; 3D has render3D.pick and engineObjectsRaycast3D */
setCollision(collideSolidObjects=true, isSolid=true, collideTiles=false, collideRaycast=false)
{ super.setCollision(collideSolidObjects, isSolid, collideTiles, collideRaycast); }
/** Returns the world position
* @return {Vector3} */
getWorldPos3D() { return this.getMatrix().getTranslation(); }
/** Returns the direction the object faces, its -Z axis in the world
* @return {Vector3} */
getForward3D() { return render3DAxis(this.getMatrix().m, 8).normalize(-1); }
/** Returns the object's right axis in the world
* @return {Vector3} */
getRight3D() { return render3DAxis(this.getMatrix().m, 0).normalize(); }
/** Returns the object's up axis in the world
* @return {Vector3} */
getUp3D() { return render3DAxis(this.getMatrix().m, 4).normalize(); }
/** Returns the object's world transform, relative to the parent's when attached to an EngineObject3D
* @return {Matrix4} */
getMatrix()
{
const matrix = buildMatrix(this.pos3D, this.rotation3D, this.scale3D);
return this.parent instanceof EngineObject3D ? this.parent.getMatrix().multiply(matrix) : matrix;
}
/** 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);
}
/** 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=> 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 */
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);
if (this.mesh)
render3D.drawMesh(this.mesh, this.getMatrix(), this.tileInfo, this.color);
else if (this.tileInfo)
{
// a sprite: size3D grown by its own scale and its parents', the same world size the
// collect, pick and solid collision helpers measure it at
const m = this.getMatrix().m;
render3D.drawBillboard(vec3(m[12], m[13], m[14]),
vec2(this.size3D.x * hypot(m[0], m[1], m[2]), this.size3D.y * hypot(m[4], m[5], m[6])),
this.tileInfo, this.color, this.rotation3D.z, this.upright);
}
}
}
// move an object by its 3D velocities, an object with mass falling with render3D.gravity and slowing by its damping
function render3DMove(o)
{
if (o.mass && !o.sync2D) // a 2D driven object gets the 2D gravity instead
{
// damped first and gravity added after, the order EngineObject.updatePhysics uses,
// so the same mass, damping and gravity fall the same way in both
const v = o.velocity3D, g = render3D.gravity, s = o.gravityScale, d = o.damping;
o.velocity3D = vec3(v.x * d + g.x * s, v.y * d + g.y * s, v.z * d + g.z * s);
}
o.pos3D = o.pos3D.add(o.velocity3D);
o.rotation3D = o.rotation3D.add(o.angleVelocity3D);
}
// 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)};
return {pos: o.pos3D.copy(), size: vec3(s.x * kx, s.y * ky, s.z * kz)};
}
// 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)
{
if (!a.size) // a is a sphere
return b.size ? collideSphereBox(a.pos, a.radius, b.pos, b.size)
: 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 = collideSphereBox(b.pos, b.radius, a.pos, a.size);
return push && push.scale(-1);
}
return collideBoxBox3D(a.pos, a.size, b.pos, b.size);
}
// 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;
if (b.destroyed || b.parent || b.sync2D || !(b instanceof EngineObject3D)) continue; // a child is part of its parent
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 push = render3DSolidPush(shapeA, render3DSolidShape(b));
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;
// 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
const normal = push.normalize();
if (a.velocity3D.dot(normal) < 0)
a.velocity3D = a.velocity3D.reflect(normal, a.restitution);
if (b.velocity3D.dot(normal) > 0)
b.velocity3D = b.velocity3D.reflect(normal, b.restitution);
}
}
/**
* Collect the EngineObject3D objects whose boxes overlap a box, sizes are full sizes
* - Boxes are axis aligned around the world position, rotation3D is ignored; lights, emitters and trails have no size
* @param {Vector3} pos - Center of the box
* @param {Vector3|number} size - Full size of the box, a number for a cube
* @param {Array<EngineObject>} [objects] - Defaults to every object
* @return {Array<EngineObject3D>}
* @memberof Render3D
*/
function engineObjectsCollect3D(pos, size, objects=engineObjects)
{
size = render3DSize3(size);
const collected = [];
for (const o of objects)
{
if (!(o instanceof EngineObject3D) || o.destroyed) continue;
const m = o.getMatrix().m, s = o.size3D; // the box in world space, scaled by the object and its parents
if (!(s.x || s.y || s.z)) continue;
const worldSize = 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]));
if (isOverlapping3D(pos, size, vec3(m[12], m[13], m[14]), worldSize))
collected.push(o);
}
return collected;
}
// how far along a ray an object is hit, or undefined for a miss; each one is tested as a sphere
// around its mesh, or around a sprite's size3D, not triangle by triangle
function render3DRaycastObject(ray, o)
{
if (o.destroyed || !(o instanceof EngineObject3D) || !(o.mesh || o.tileInfo)) return;
const matrix = o.getMatrix(), mesh = o.mesh; // a sprite is picked by its size3D
const radius = (mesh ? mesh.radius || mesh.computeRadius() : hypot(o.size3D.x, o.size3D.y) / 2) * render3DMaxScale(matrix.m);
if (!(radius > 0)) return; // nothing to hit
return raycastSphere(ray, matrix.getTranslation(), radius);
}
/**
* 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)
hits.push({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 box
* @param {Vector3} pos - Center of the box
* @param {Vector3|number} size - Full size of the box, a number for a cube
* @param {Function} callback
* @param {Array<EngineObject>} [objects] - Defaults to every object
* @memberof Render3D
*/
function engineObjectsCallback3D(pos, size, callback, objects=engineObjects)
{ engineObjectsCollect3D(pos, size, objects).forEach(callback); }
///////////////////////////////////////////////////////////////////////////////
/**
* Light3D - A light that is an EngineObject3D, so it can move, follow a parent or be destroyed like anything else
* - A point light by default: it lights what is near it and fades out by its radius
* - Set directional to shine from far away along the light's forward axis instead, aim it with lookAt or rotation3D
* - A directional light shines from no particular place, so only its facing counts and moving it does nothing
* - Only render3D.lightDirection casts shadows, these light without shadowing
* - 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 bright color
* - radius is a world distance, so scale3D does not change it
* - An alpha 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, set directional to make it shine from far away instead
* @param {Vector3} [pos3D]
* @param {number} [radius] - Distance where the light fades to nothing, ignored when directional
* @param {Color} [color] - Light color, alpha scales the brightness */
constructor(pos3D=vec3(), radius=5, color=WHITE)
{
super(pos3D, undefined, undefined, color);
ASSERT(radius >= 0, 'light radius cannot be negative, 0 is an off switch like an alpha of 0');
this.size3D = vec3(); // not a solid thing to pick or collect
/** @property {number} - Distance where the light fades to nothing */
this.radius = radius;
/** @property {boolean} - Shine along the light's forward axis from far away instead of out from its position, with no falloff */
this.directional = false;
}
/** Lights draw nothing */
render3D() {}
}
///////////////////////////////////////////////////////////////////////////////
/**
* CameraControl3D - Drag to turn the camera around a point, roll the wheel to zoom
* - An EngineObject3D, so move its pos3D to follow something, or parent it to an object
* - Destroy it to hand the camera back, and it stops driving the camera
* - Set persistent to keep it when engineObjectsDestroy clears out a level
* - Every part of it is a field, so a game can change the buttons, speeds and limits
* @extends EngineObject3D
* @memberof Render3D
* @example
* new CameraControl3D(vec3(0, 1, 0), 15); // look at a point from 15 units away
*/
class CameraControl3D extends EngineObject3D
{
/** Create a camera control, it drives render3D.camera every frame
* @param {Vector3} [target] - The point to look at, its pos3D
* @param {number} [distance] - How far the camera sits from the target
* @param {number} [pitch] - Angle above the horizon, PI/2 looks straight down
* @param {number} [idleSpin] - Turned each frame while not dragging, 0 holds still */
constructor(target=vec3(), distance=10, pitch=.4, idleSpin=0)
{
super(target);
this.size3D = vec3(); // not a solid thing to pick or collect
/** @property {number} - How far the camera sits from the target */
this.distance = distance;
/** @property {number} - Angle above the horizon */
this.pitch = pitch;
/** @property {number} - Turned each frame while not dragging */
this.idleSpin = idleSpin;
/** @property {number} - Angle around the target, dragging changes it */
this.yaw = 0;
/** @property {number} - Mouse button that turns the camera, 0 is left and 2 is right */
this.dragButton = 0;
/** @property {number} - How far dragging a pixel turns the camera */
this.dragSpeed = .01;
/** @property {number} - How much one wheel notch zooms, 0 turns zooming off */
this.zoomSpeed = .1;
/** @property {Vector2} - Closest and furthest the wheel can zoom to */
this.zoomRange = vec2(distance/4, distance*3);
/** @property {Vector2} - Lowest and highest pitch, so it cannot tip over the top */
this.pitchRange = vec2(-.2, 1.4);
}
/** Read the mouse and put the camera on its orbit, called automatically each frame */
update()
{
if (mouseIsDown(this.dragButton))
{
// the scene follows the drag
this.yaw -= mouseDeltaScreen.x * this.dragSpeed;
this.pitch += mouseDeltaScreen.y * this.dragSpeed;
}
else
this.yaw += this.idleSpin;
this.pitch = clamp(this.pitch, this.pitchRange.x, this.pitchRange.y);
if (this.zoomSpeed && mouseWheel)
this.distance = clamp(this.distance * (1 + sign(mouseWheel) * this.zoomSpeed), this.zoomRange.x, this.zoomRange.y);
render3D.camera.orbit(this.getWorldPos3D(), this.distance, this.yaw, this.pitch);
}
/** Camera controls draw nothing */
render3D() {}
}
///////////////////////////////////////////////////////////////////////////////
/**
* ParticleEmitter3D - Spawns camera facing particles, the 3D twin of ParticleEmitter
* - Each particle is a flat square facing the camera, with a soft round dot when no tile is given
* - Set trailTime to draw each particle as a streak along where it has been, for sparks
* - Set angleSpeed to tumble them in the camera plane, which the 2D emitter takes as an argument
* - Particles shoot out along the emitter's own up axis, turned by rotation3D
* - emitConeAngle spreads them, PI sprays in every direction
* - Speeds are per frame and sizes are world units, the same as the 2D emitter
* - scale3D, its own or a parent's, grows the whole effect: the spawn area, the sizes, the speed and the fall
* - gravity here is its own number added to velocity y each frame: it is neither the engine's 2D
* gravity nor render3D.gravity, so an effect keeps its own fall wherever it is used
* - An emitter with an emitTime destroys itself once its last particle is gone, like the 2D emitter
* @extends EngineObject3D
* @memberof Render3D
* @example
* // fire: a stream upward, yellow fading to transparent red, additive
* new ParticleEmitter3D(vec3(), .5, 0, 100, .3, undefined, hsl(.12, 1, .6), hsl(.08, 1, .5), hsl(0, 1, .5, 0), hsl(0, 1, .25, 0), 1, .5, 1.5, .05, .95, 0, .3, .2, true);
*/
class ParticleEmitter3D extends EngineObject3D
{
/** Create a particle emitter
* @param {Vector3} [pos3D] - World space position of the emitter
* @param {number|Vector3} [emitSize] - Spawn area, a number for a sphere diameter or a vec3 for a box
* @param {number} [emitTime] - How long to keep emitting, 0 is forever
* @param {number} [emitRate] - Particles per second, 0 does not emit
* @param {number} [emitConeAngle] - Half angle around the emit direction, PI is every direction
* @param {TileInfo|TextureInfo} [tileInfo] - Tile to render particles with, or a whole texture, undefined is untextured
* @param {Color} [colorStartA] - Color at start of life, randomized between the start colors
* @param {Color} [colorStartB]
* @param {Color} [colorEndA] - Color at end of life, randomized between the end colors
* @param {Color} [colorEndB]
* @param {number} [particleTime] - How long particles live in seconds
* @param {number} [sizeStart] - Particle size at start of life
* @param {number} [sizeEnd] - Particle size at end of life
* @param {number} [speed] - Spawn speed in world units per frame
* @param {number} [damping] - Per frame velocity multiplier, 1 is none
* @param {number} [gravity] - Per frame change to velocity y, negative pulls down; its own number,
* not render3D.gravity, so the 2D emitter's gravityScale has no equivalent here
* @param {number} [fadeRate] - Fraction of life spent fading, half in and half out
* @param {number} [randomness] - Extra randomness applied to speed, size and life
* @param {boolean} [additive] - Additive blending */
constructor(pos3D=vec3(), emitSize=0, emitTime=0, emitRate=100, emitConeAngle=PI, tileInfo,
colorStartA=WHITE, colorStartB=WHITE, colorEndA=CLEAR_WHITE, colorEndB=CLEAR_WHITE,
particleTime=.5, sizeStart=.1, sizeEnd=1, speed=.1, damping=1, gravity=0, fadeRate=.1, randomness=.2, additive=false)
{
super(pos3D, undefined, tileInfo);
this.transparent = true;
this.castShadow = false;
this.size3D = vec3(); // not a solid thing to pick or collect
/** @property {number|Vector3} - Spawn area, a number for a sphere diameter or a vec3 for a box */
this.emitSize = emitSize;
/** @property {number} - How long to keep emitting, 0 is forever */
this.emitTime = emitTime;
/** @property {number} - Particles per second, 0 does not emit */
this.emitRate = emitRate;
/** @property {number} - Half angle around the emit direction, PI is every direction */
this.emitConeAngle = emitConeAngle;
/** @property {Color} - Color at start of life, randomized between the start colors */
this.colorStartA = colorStartA.copy();
/** @property {Color} - Color at start of life, randomized between the start colors */
this.colorStartB = colorStartB.copy();
/** @property {Color} - Color at end of life, randomized between the end colors */
this.colorEndA = colorEndA.copy();
/** @property {Color} - Color at end of life, randomized between the end colors */
this.colorEndB = colorEndB.copy();
/** @property {number} - How long particles live in seconds */
this.particleTime = particleTime;
/** @property {number} - Particle size at start of life */
this.sizeStart = sizeStart;
/** @property {number} - Particle size at end of life */
this.sizeEnd = sizeEnd;
/** @property {number} - Spawn speed in world units per frame */
this.speed = speed;
/** @property {number} - Per frame velocity multiplier */
this.damping = damping;
/** @property {number} - Per frame change to velocity y, its own number and not render3D.gravity */
this.gravity = gravity;
/** @property {number} - Fraction of life spent fading, half in and half out */
this.fadeRate = fadeRate;
/** @property {number} - Extra randomness applied to speed, size and life */
this.randomness = randomness;
/** @property {boolean} - Additive blending */
this.additive = additive;
/** @property {number} - Seconds of each particle's path to draw as a ribbon behind it, 0 draws billboards */
this.trailTime = 0;
/** @property {number} - Radians per frame each particle turns in the camera plane, either way; 0 is no spin */
this.angleSpeed = 0;
/** @property {number} - Per frame multiplier on that spin, 1 keeps it */
this.angleDamping = 1;
/** @property {Array<Object>} - Live particles
* @type {Array<Object>} */
this.particles = [];
this.emitTimeBuffer = 0;
}
/** Spawn new particles, move the live ones, and go away when done */
update()
{
// one transform for the frame: where the emitter is, and how big the effect it makes is
const matrix = this.getMatrix();
this.worldPos3D = matrix.getTranslation(); // remembered for when the parent is destroyed
const scale = render3DMaxScale(matrix.m);
// emit until the emit time is up, then wait for the last particle and go away
if (!this.emitTime || this.getAliveTime() <= this.emitTime)
{
// a rate of zero is an emitter fed by hand, and the global scale only quiets it,
// neither is a reason to stop counting down the emit time
if (this.emitRate && particleEmitRateScale)
{
this.emitTimeBuffer += this.emitRate * particleEmitRateScale * timeDelta;
for (; this.emitTimeBuffer >= 1; --this.emitTimeBuffer)
this.emitParticle();
}
}
else if (!this.particles.length)
this.destroy();
// move the particles and drop the dead ones
const particles = this.particles;
for (let i = particles.length; i--;)
{
// damped first and gravity added after, the order the 2D particle uses, so the same
// damping and gravity give the same arc in both
const p = particles[i], v = p.velocity;
v.x *= this.damping, v.y *= this.damping, v.z *= this.damping; // in place, this runs per particle
v.y += this.gravity * scale; // a bigger effect has to fall faster to keep the same arc
p.pos = p.pos.add(v);
p.angle += p.angleVelocity *= this.angleDamping;
if (this.trailTime)
{
// remember where it has been, oldest first
const trail = p.trail || (p.trail = []);
trail.push(p.pos);
const extra = trail.length - this.trailTime / timeDelta;
extra > 0 && trail.splice(0, extra);
}
if ((p.age += timeDelta) >= p.life)
particles[i] = particles[particles.length - 1], particles.pop(); // swap with the last, order does not matter
}
}
/** Stop emitting, and go away once the particles already out have finished like the 2D emitter's do
* @param {boolean} [immediate] */
destroy(immediate)
{
if (immediate || !this.particles.length || this.destroyed)
return super.destroy(immediate);
this.emitTime = -1; // stops emitting, and update destroys it once the particles are gone
render3DDetach(this); // the particles are in world space, they no longer need the parent
}
/** Spawn one particle now */
emitParticle()
{
const random = ()=> rand(1 - this.randomness, 1 + this.randomness);
const matrix = this.getMatrix();
// the whole effect grows with the emitter, not just the area the particles start in
const scale = render3DMaxScale(matrix.m);
// spawn offset: inside a box or a sphere
const size = this.emitSize;
const offset = isVector3(size) ? vec3(rand(-.5, .5) * size.x, rand(-.5, .5) * size.y, rand(-.5, .5) * size.z)
: randInSphere(size / 2);
// direction inside the cone around local +Y
const direction = matrix.transformDirection(randVector3(1, this.emitConeAngle)).normalize();
this.particles.push({
pos: matrix.transformPoint(offset),
velocity: direction.scale(this.speed * random() * scale),
colorStart: randColor(this.colorStartA, this.colorStartB, true),
colorEnd: randColor(this.colorEndA, this.colorEndB, true),
sizeStart: this.sizeStart * random() * scale,
sizeEnd: this.sizeEnd * random() * scale,
life: this.particleTime * random(),
// a spinning particle starts anywhere and turns either way, one that is not stays at zero
angle: this.angleSpeed ? rand(2*PI) : 0,
angleVelocity: this.angleSpeed ? this.angleSpeed * random() * randSign() : 0,
age: 0 });
}
/** Draw the particles, as flat squares or as streaks when trailTime is set
* - The whole emitter sorts as one thing, its particles are not sorted against each other */
render3D()
{
if (render3D.transparentQueue)
return render3D.queueTransparent(this.getWorldPos3D(), ()=> this.render3D());
const fade = this.fadeRate / 2, texture = this.tileInfo || render3DSoftDot(); // no dot headless
for (const p of this.particles)
{
const t = p.age / p.life;
const alpha = t < fade ? t / fade : t > 1 - fade ? (1 - t) / fade : 1;
const color = p.colorStart.lerp(p.colorEnd, t), size = lerp(p.sizeStart, p.sizeEnd, t);
color.a *= alpha;
const trail = p.trail;
if (trail && trail.length > 1)
{
// a ribbon from the tail to the head, the tail thins and fades out
const widths = [], colors = [];
for (let i = 0; i < trail.length; ++i)
{
const s = (i + 1) / trail.length;
widths.push(size * s);
colors.push(color.scale(1, s));
}
render3D.drawRibbon(trail, widths, colors, this.tileInfo);
}
else if (texture)
render3D.drawBillboard(p.pos, vec2(size), texture, color, p.angle);
else
render3D.drawSoftDisc(p.pos, size, color, undefined, 8); // no canvas for the dot, headless
}
}
}
///////////////////////////////////////////////////////////////////////////////
/**
* Trail3D - A ribbon through where the object has been, thinning and fading with age
* - Records its world position each frame it moves, so parent it to something that moves or set pos3D yourself
* - The samples are world space, so width is a world width and scale3D does nothing to the ribbon
* - Drawn unlit in the transparent stage, dies down on its own once the object stops
* @extends EngineObject3D
* @memberof Render3D
* @example
* const trail = new Trail3D(vec3(), 1, .3, undefined, hsl(.08, 1, .5), hsl(0, 1, .5, 0), true);
* ball.addChild(trail); // follows the ball
*/
class Trail3D extends EngineObject3D
{
/** Create a trail
* @param {Vector3} [pos3D]
* @param {number} [lifeTime] - Seconds the ribbon takes to thin and fade from head to tail,
* Infinity keeps every sample at full width and never drops one, so it grows as long as the object moves
* @param {number} [width] - Width at the head, it thins to nothing at the tail
* @param {TileInfo|TextureInfo} [tileInfo] - Tile or whole texture stretched along the trail, undefined is untextured
* @param {Color} [color] - Color at the head
* @param {Color} [colorEnd] - Color at the tail
* @param {boolean} [additive] - Additive blending */
constructor(pos3D=vec3(), lifeTime=1, width=.2, tileInfo, color=WHITE, colorEnd=CLEAR_WHITE, additive=false)
{
super(pos3D, undefined, tileInfo, color);
this.transparent = true;
this.additive = additive;
this.castShadow = false;
this.size3D = vec3(); // not a solid thing to pick or collect
this.finishing = false; // set by destroy, the ribbon fades out then goes away
/** @property {number} - Seconds the ribbon takes to thin and fade from head to tail, Infinity never drops a sample */
this.lifeTime = lifeTime;
/** @property {number} - Width at the head */
this.width = width;
/** @property {Color} - Color at the tail */
this.colorEnd = colorEnd.copy();
/** @property {Vector3|undefined} - Direction across the ribbon, recorded with each sample, undefined faces the camera
* @type {Vector3|undefined} */
this.side = undefined;
/** @property {Array<Object>} - Recorded samples, oldest first
* @type {Array<Object>} */
this.samples = [];
}
/** Forget the trail so far, for when the object teleports */
clear() { this.samples.length = 0; }
/** Stop recording, and go away once the ribbon has faded
* @param {boolean} [immediate] */
destroy(immediate)
{
if (immediate || !this.samples.length || this.destroyed || this.lifeTime == Infinity)
return super.destroy(immediate);
this.finishing = true;
render3DDetach(this); // the samples are in world space, they no longer need the parent
}
/** Record the position when it moved and drop old samples, called automatically each frame */
update()
{
const samples = this.samples;
if (!this.finishing)
{
const pos = this.worldPos3D = this.getWorldPos3D(), last = samples[samples.length - 1];
if (!last || pos.distanceSquared(last.pos) > 1e-8)
samples.push({pos, side: this.side?.copy(), time});
}
while (samples.length && time - samples[0].time > this.lifeTime)
samples.shift();
this.finishing && !samples.length && this.destroy();
}
/** Draw the ribbon */
render3D()
{
const samples = this.samples;
if (samples.length < 2) return;
const points = [], widths = [], colors = [], sides = this.side ? [] : undefined;
for (const s of samples)
{
const age = clamp((time - s.time) / this.lifeTime);
points.push(s.pos);
widths.push(this.width * (1 - age));
colors.push(this.color.lerp(this.colorEnd, age));
sides?.push(s.side);
}
render3D.drawRibbon(points, widths, colors, this.tileInfo, sides);
}
}
///////////////////////////////////////////////////////////////////////////////
// OBJ meshes
/**
* Parse Wavefront OBJ text into a Mesh
* - Reads v, vt, vn and f lines with convex polygons of any size, materials and groups are ignored
* - Normals come from the file when every corner of a face has one, otherwise from the face
* - Use mesh.center() and mesh.fit(size) to bring a model of unknown units to the origin
* @param {string} text
* @param {boolean} [smooth] - Compute smooth normals when the file has none, defaults to render3D.smoothShading
* @return {Mesh}
* @memberof Render3D
* @example
* new EngineObject3D(vec3(), parseOBJ(objText).center().fit(4));
*/
function parseOBJ(text, smooth=render3D?.smoothShading)
{
const positions = [], normals = [], uvs = [], mesh = new Mesh;
let fileNormals = false;
// OBJ indices count from 1, and a negative one counts back from the end of the list so far
const lookup = (s, list)=> { const i = parseInt(s); return list[i < 0 ? list.length + i : i - 1]; };
for (const line of text.split('\n'))
{
const parts = line.trim().split(/\s+/);
switch (parts[0])
{
case 'v': positions.push(vec3(+parts[1], +parts[2], +parts[3])); break;
case 'vn': normals.push(vec3(+parts[1], +parts[2], +parts[3])); break;
case 'vt': uvs.push(vec2(+parts[1], 1 - +parts[2])); break; // OBJ v runs up, tiles run down
case 'f':
{
const corners = parts.slice(1).map(c=> c.split('/'));
if (corners.length < 3) break;
const points = corners.map(c=> lookup(c[0], positions));
ASSERT(points.every(isVector3), 'OBJ face uses a vertex index the file does not have', line);
const uv = corners.map(c=> c[1] ? lookup(c[1], uvs) : RENDER3D_DEFAULT_UV);
const hasNormals = corners.every(c=> c[2]);
fileNormals ||= hasNormals;
const n = hasNormals ? render3DPolygonStrip(corners.map(c=> lookup(c[2], normals)))
: render3DFaceNormal(points[0], points[1], points[2], points[3]);
mesh.addStrip(render3DPolygonStrip(points), n, render3DPolygonStrip(uv));
}
}
}
if (!fileNormals && smooth)
mesh.computeNormals(true);
return mesh;
}
/**
* Fetch and parse an OBJ file
* @param {string} url
* @param {boolean} [smooth] - Compute smooth normals when the file has none, defaults to render3D.smoothShading
* @return {Promise<Mesh>}
* @memberof Render3D
* @example
* const mesh = await loadOBJ('ship.obj'); // in an async gameInit
*/
async function loadOBJ(url, smooth=render3D?.smoothShading)
{
const response = await fetch(url);
if (!response.ok)
throw new Error('loadOBJ failed: ' + url);
return parseOBJ(await response.text(), smooth);
}