littlejsengine 1.18.28 → 1.19.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4006 @@
1
+ /**
2
+ * LittleJS 3D Rendering Plugin
3
+ * - Adds a 3D scene that draws into the same WebGL canvas as the 2D game
4
+ * - Call new Render3DPlugin() in gameInit, then move render3D.camera and make EngineObject3D objects
5
+ * - EngineObject3D is an EngineObject with a 3D position, rotation and mesh
6
+ * - The 3D scene draws under the 2D sprites, so HUD and text land on top
7
+ * - Lighting is the sun plus ambient, with optional extra lights, fog and shadows
8
+ * - Any object or draw can bring its own Shader, a mainImage snippet the lighting then applies to
9
+ * - Build shapes with buildBox, buildSphere and friends, or load a model with loadOBJ
10
+ * - Requires the Math3D plugin
11
+ * @namespace Render3D
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ ///////////////////////////////////////////////////////////////////////////////
17
+
18
+ /** Global Render3D plugin object
19
+ * @type {Render3DPlugin}
20
+ * @memberof Render3D */
21
+ let render3D;
22
+
23
+ // vertex format: position xyz, normal xyz, uv, rgba bytes
24
+ const RENDER3D_VERTEX_FLOATS = 9;
25
+ const RENDER3D_VERTEX_BYTES = RENDER3D_VERTEX_FLOATS * 4;
26
+
27
+ // per draw values the shaders read as vertex attributes: the model matrix columns (4-7), the normal matrix columns
28
+ // (8-10), the tint (11) and the uv rect (12); constants for one draw, one per instance for a batch
29
+ const RENDER3D_INSTANCE_FLOATS = 33;
30
+ const RENDER3D_INSTANCE_BYTES = RENDER3D_INSTANCE_FLOATS * 4;
31
+ 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]];
32
+ const RENDER3D_VERTEX_INPUTS =
33
+ 'layout(location=0) in vec3 p;layout(location=1) in vec3 n;layout(location=2) in vec2 t;layout(location=3) in vec4 c;' +
34
+ 'layout(location=4) in vec4 m0;layout(location=5) in vec4 m1;layout(location=6) in vec4 m2;layout(location=7) in vec4 m3;' +
35
+ 'layout(location=8) in vec3 n0;layout(location=9) in vec3 n1;layout(location=10) in vec3 n2;' +
36
+ 'layout(location=11) in vec4 tint;layout(location=12) in vec4 uvRect;';
37
+ const RENDER3D_MAX_STREAM_VERTS = 32768;
38
+ const RENDER3D_MAX_LIGHTS = 8; // Light3D objects per frame, the shader loops over this many
39
+ 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
40
+ const RENDER3D_FULL_UV_RECT = Object.freeze({x:0, y:0, w:1, h:1});
41
+ const RENDER3D_DEFAULT_NORMAL = Object.freeze(vec3(0, 1, 0));
42
+ const RENDER3D_DEFAULT_UV = Object.freeze(vec2());
43
+ const RENDER3D_SHADOW_COLOR = Object.freeze(hsl(0, 0, 0, .5));
44
+ const RENDER3D_IDENTITY = new Matrix4; // never modified
45
+ const RENDER3D_DEBUG_WIDTH = .05; // line width of the debug primitives
46
+ // gap between lines of 3D text, as a share of the character height; flat text can let lines touch
47
+ // the way the 2D font does, but extruded glyphs seen from an angle then overlap the line below
48
+ const RENDER3D_TEXT_LEADING = 1.3;
49
+
50
+ ///////////////////////////////////////////////////////////////////////////////
51
+ // Private helpers
52
+
53
+ // outward normal of a triangle or a quad given its corners in loop order,
54
+ // from the diagonals so a collapsed corner still works
55
+ function render3DFaceNormal(a, b, c, d=a)
56
+ {
57
+ const n = c.subtract(a).cross(d.subtract(b));
58
+ return n.lengthSquared() ? n.normalize() : RENDER3D_DEFAULT_NORMAL;
59
+ }
60
+
61
+ // a quad's corners in loop order as a strip, the one place that knows the order
62
+ function render3DQuadStrip(a, b, c, d) { return [a, b, d, c]; }
63
+
64
+ // per corner values (colors, uvs) into strip order, a single value passes through
65
+ function render3DQuadValues(v) { return isArray(v) ? render3DQuadStrip(...v) : v; }
66
+
67
+ // 3D draws are only valid during the pass with a live shader
68
+ function render3DCanDraw()
69
+ {
70
+ if (!render3D.program) return false;
71
+ ASSERT(render3D.isRendering, '3D draws are only valid during the 3D pass, draw from an EngineObject3D or render3D.onRenderOpaque');
72
+ return render3D.isRendering;
73
+ }
74
+
75
+ // the draw state fields a batch is drawn under; lights and fog are not captured, they are read live at flush
76
+ const RENDER3D_STATE_FIELDS = ['blend', 'additive', 'depthTest', 'depthWrite', 'cullBackFaces', 'mirrored', 'lighting', 'emissive', 'receiveShadow', 'specular', 'pixelated', 'shader'];
77
+
78
+ // a copy of the current draw state
79
+ function render3DCaptureBatchState()
80
+ {
81
+ const state = {};
82
+ for (const field of RENDER3D_STATE_FIELDS)
83
+ state[field] = render3D[field];
84
+ return state;
85
+ }
86
+
87
+ // true when the current draw state differs from a captured one, so a pending batch must flush first
88
+ function render3DStateChanged(state)
89
+ {
90
+ for (const field of RENDER3D_STATE_FIELDS)
91
+ if (render3D[field] !== state[field])
92
+ return true;
93
+ return false;
94
+ }
95
+
96
+ // run a function with some draw state fields overridden, restored afterward even on a throw
97
+ function render3DWithState(fields, fn)
98
+ {
99
+ const r = render3D, saved = {};
100
+ for (const key in fields)
101
+ saved[key] = r[key], r[key] = fields[key];
102
+ try { return fn(); }
103
+ finally { Object.assign(r, saved); }
104
+ }
105
+
106
+ // which side of the 2D scene an object draws on, its own flag or the plugin default
107
+ function render3DIsAfter2D(o) { return !!(o.renderAfter2D ?? render3D.renderAfter2D); }
108
+
109
+ // a size given as a number or a vec3
110
+ function render3DSize3(size) { return isNumber(size) ? vec3(size) : size; }
111
+
112
+ // a transform given as a matrix, or as a vec3 for one that only moves there
113
+ function render3DMatrix(matrix)
114
+ {
115
+ if (matrix instanceof Vector3)
116
+ return buildMatrix(matrix);
117
+ ASSERT(matrix instanceof Matrix4, 'takes a Matrix4, or a Vector3 for a position');
118
+ return matrix;
119
+ }
120
+
121
+ // the matrix that keeps normals pointing out when an object is scaled unevenly
122
+ function render3DNormalMatrix(matrix) { return matrix.copy().invert().transpose(); }
123
+
124
+ // a column of a matrix as a direction: 0 is the right axis, 4 up, 8 back
125
+ function render3DAxis(m, i) { return vec3(m[i], m[i+1], m[i+2]); }
126
+
127
+ // surface normal from the slope of a height function, sampled half a cell each way but kept inside the half sizes
128
+ function render3DSlopeNormal(heightFunction, x, z, ex, ez, halfX, halfZ)
129
+ {
130
+ const x0 = max(x - ex, -halfX), x1 = min(x + ex, halfX), z0 = max(z - ez, -halfZ), z1 = min(z + ez, halfZ);
131
+ const dx = (heightFunction(x1, z) - heightFunction(x0, z)) / (x1 - x0 || 1);
132
+ const dz = (heightFunction(x, z1) - heightFunction(x, z0)) / (z1 - z0 || 1);
133
+ return vec3(-dx, 1, -dz).normalize();
134
+ }
135
+
136
+ // the largest axis scale of a matrix, how much it grows a bounding sphere
137
+ function render3DMaxScale(m)
138
+ {
139
+ 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;
140
+ }
141
+
142
+ // a quad as a strip from its center and half axes, the same corner order as render3DQuadStrip
143
+ function render3DQuadAxes(center, right, up)
144
+ {
145
+ return [center.subtract(right).add(up), center.subtract(right).subtract(up),
146
+ center.add(right).add(up), center.add(right).subtract(up)];
147
+ }
148
+
149
+ // set the draw state for an object's render3D, or the defaults for the stage callbacks
150
+ function render3DSetObjectState(o)
151
+ {
152
+ const r = render3D;
153
+ const emissive = o?.emissive || 0;
154
+ ASSERT(isNumber(emissive) && emissive >= 0, 'emissive must be a number, 0 or more', emissive);
155
+ r.lighting = true;
156
+ r.emissive = emissive;
157
+ r.additive = !!o?.additive;
158
+ r.specular = o?.specular || 0;
159
+ r.receiveShadow = !o || o.receiveShadow;
160
+ r.cullBackFaces = r.mirrored = false; // each mesh sets these as it draws
161
+ r.pixelated = !!o?.pixelated;
162
+ ASSERT(!o?.shader || o.shader instanceof Shader, 'shader must be a Shader, not the snippet itself');
163
+ r.shader = o?.shader || undefined; // null is no shader too, so it batches with none
164
+ r.depthTest = true;
165
+ }
166
+
167
+ // draw objects each with the draw state set from its own flags, then reset to the defaults
168
+ function render3DDrawObjects(objects)
169
+ {
170
+ for (const o of objects)
171
+ {
172
+ render3DSetObjectState(o);
173
+ o.render3D();
174
+ }
175
+ render3DSetObjectState();
176
+ }
177
+
178
+ // let go of the parent but stay where the object was in the world; a destroyed parent has already let go, so the
179
+ // position remembered by the last update stands in
180
+ function render3DDetach(o)
181
+ {
182
+ if (o.parent)
183
+ o.pos3D = o.getWorldPos3D(), o.parent.removeChild(o);
184
+ else if (o.worldPos3D)
185
+ o.pos3D = o.worldPos3D;
186
+ }
187
+
188
+ // 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
189
+ function render3DInstance(mesh, matrix, tileInfo, color)
190
+ {
191
+ const r = render3D, textureInfo = tileInfo instanceof TileInfo ? tileInfo.textureInfo : tileInfo;
192
+ if (mesh.instanceCount && (mesh.instanceTextureInfo !== textureInfo || render3DStateChanged(mesh.instanceState)))
193
+ render3DFlushInstances(mesh);
194
+ if (!mesh.instanceCount)
195
+ {
196
+ mesh.instanceTextureInfo = textureInfo;
197
+ mesh.instanceState = render3DCaptureBatchState();
198
+ r.instanceMeshes.push(mesh);
199
+ }
200
+
201
+ // room for one more, doubling as the batch grows
202
+ let data = mesh.instanceData;
203
+ const k = mesh.instanceCount++ * RENDER3D_INSTANCE_FLOATS;
204
+ if (!data || data.length < k + RENDER3D_INSTANCE_FLOATS)
205
+ {
206
+ const grown = new Float32Array(max(64 * RENDER3D_INSTANCE_FLOATS, data ? data.length * 2 : 0));
207
+ data && grown.set(data);
208
+ mesh.instanceData = data = grown;
209
+ }
210
+ data.set(matrix.m, k);
211
+ render3DNormalMatrix3(matrix.m, data, k + 16);
212
+ data[k+25] = color.r; data[k+26] = color.g; data[k+27] = color.b; data[k+28] = color.a;
213
+ const uv = render3DGetTileUVs(tileInfo);
214
+ data[k+29] = uv.x; data[k+30] = uv.y; data[k+31] = uv.w; data[k+32] = uv.h;
215
+ }
216
+
217
+ // draw the pending batches, or just one mesh's, each as a single instanced call
218
+ function render3DFlushInstances(only)
219
+ {
220
+ const r = render3D, gl = glContext;
221
+ for (const mesh of only ? [only] : r.instanceMeshes)
222
+ {
223
+ const count = mesh.instanceCount;
224
+ mesh.instanceCount = 0;
225
+ if (!count || !mesh.buffer) continue;
226
+
227
+ // the per instance values on top of the constant attributes, then the mesh under them
228
+ // a ring of buffers with fresh storage each time, so the driver never waits for a draw still reading one
229
+ const buffers = r.instanceBuffers;
230
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffers[r.instanceBufferIndex = (r.instanceBufferIndex + 1) % buffers.length]);
231
+ gl.bufferData(gl.ARRAY_BUFFER, mesh.instanceData, gl.DYNAMIC_DRAW, 0, count * RENDER3D_INSTANCE_FLOATS);
232
+ for (const [location, size, offset] of RENDER3D_INSTANCE_ATTRIBS)
233
+ {
234
+ gl.vertexAttribPointer(location, size, gl.FLOAT, false, RENDER3D_INSTANCE_BYTES, offset);
235
+ gl.enableVertexAttribArray(location);
236
+ }
237
+ render3DSetDrawUniforms(RENDER3D_IDENTITY, mesh.instanceTextureInfo, WHITE, RENDER3D_FULL_UV_RECT, mesh.instanceState);
238
+ render3DBindVertexBuffer(mesh.buffer);
239
+ gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, mesh.bufferCount, count);
240
+ for (const [location] of RENDER3D_INSTANCE_ATTRIBS)
241
+ gl.disableVertexAttribArray(location);
242
+ ++drawCount;
243
+ primitiveCount += mesh.bufferCount * count;
244
+ }
245
+ if (!only)
246
+ r.instanceMeshes.length = 0;
247
+ else
248
+ {
249
+ const i = r.instanceMeshes.indexOf(only);
250
+ i < 0 || r.instanceMeshes.splice(i, 1);
251
+ }
252
+ }
253
+
254
+ // forget the pending batches, for a frame that threw or a lost context
255
+ function render3DClearInstances()
256
+ {
257
+ for (const mesh of render3D.instanceMeshes)
258
+ mesh.instanceCount = 0;
259
+ render3D.instanceMeshes.length = 0;
260
+ }
261
+
262
+ // the live objects drawn on one side of the 2D scene
263
+ function render3DLayerObjects(after2D)
264
+ { return engineObjects.filter(o=> !o.destroyed && o instanceof EngineObject3D && render3DIsAfter2D(o) === after2D); }
265
+
266
+ // the Light3D objects the shader gets this frame: directional lights light the whole scene so they come first,
267
+ // then the point lights nearest the camera
268
+ function render3DCollectLights()
269
+ {
270
+ // a light switched off by its radius or its alpha is left out, so it cannot take one of the few slots
271
+ const lights = engineObjects.filter(o=> !o.destroyed && o instanceof Light3D &&
272
+ o.color.a > 0 && o.intensity > 0 && (o.directional || o.radius > 0));
273
+ if (lights.length > RENDER3D_MAX_LIGHTS)
274
+ {
275
+ // distances cached once, getWorldPos3D walks the parent chain and the sort asks many times
276
+ const cameraPos = render3D.camera.pos, distances = new Map;
277
+ for (const light of lights)
278
+ distances.set(light, light.directional ? -1 : light.getWorldPos3D().distanceSquared(cameraPos));
279
+ lights.sort((a, b)=> distances.get(a) - distances.get(b));
280
+ lights.length = RENDER3D_MAX_LIGHTS;
281
+ }
282
+ return lights;
283
+ }
284
+
285
+ // unit circle directions for a number of sides, [cos, sin, cos, sin, ...] including the closing point, cached
286
+ const render3DCircleCache = new Map;
287
+ function render3DCircle(sides)
288
+ {
289
+ sides |= 0;
290
+ let circle = render3DCircleCache.get(sides);
291
+ if (!circle)
292
+ {
293
+ circle = new Float32Array(sides * 2 + 2);
294
+ for (let i = 0; i <= sides; ++i)
295
+ {
296
+ const a = i / sides * 2 * PI;
297
+ circle[i*2] = cos(a), circle[i*2 + 1] = sin(a);
298
+ }
299
+ render3DCircleCache.set(sides, circle);
300
+ }
301
+ return circle;
302
+ }
303
+
304
+ // a soft white dot for untextured particles, made once from a canvas, undefined headless or without a canvas
305
+ let render3DSoftDotTexture;
306
+ function render3DSoftDot()
307
+ {
308
+ if (render3DSoftDotTexture || !glContext || typeof OffscreenCanvas == 'undefined') return render3DSoftDotTexture;
309
+ const size = 32, context = createCanvasContext(size);
310
+ const gradient = context.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
311
+ for (const [stop, alpha] of [[0, 1], [.33, .9], [.67, .7], [1, 0]]) // the same falloff as a soft disc
312
+ gradient.addColorStop(stop, 'rgba(255,255,255,' + alpha + ')');
313
+ context.fillStyle = gradient;
314
+ context.fillRect(0, 0, size, size);
315
+ return render3DSoftDotTexture = new TextureInfo(context.canvas);
316
+ }
317
+
318
+ // the rotation that points -Z along a direction, as vec3(pitch, yaw, 0); a zero direction keeps the current one
319
+ function render3DLookRotation(direction, current)
320
+ {
321
+ const d = direction.normalize();
322
+ if (!d.lengthSquared()) return current;
323
+ if (abs(d.x) + abs(d.z) < 1e-9) // straight up or down has no yaw of its own
324
+ return vec3(d.y > 0 ? PI / 2 : -PI / 2, current.y, 0);
325
+ return vec3(Math.asin(clamp(d.y, -1, 1)), atan2(-d.x, -d.z), 0);
326
+ }
327
+
328
+ ///////////////////////////////////////////////////////////////////////////////
329
+ /**
330
+ * Render3D Plugin - The 3D renderer, camera, lights, shadows and fog
331
+ * - There is one of these, in the global render3D
332
+ * - It draws the 3D scene before gameRender, so 2D drawing lands on top
333
+ * - Set renderAfter2D to draw the 3D scene over the 2D scene instead
334
+ * - Settings like lighting and specular are read as each thing draws
335
+ * - Every object sets them from its own flags, so you rarely touch them
336
+ * @memberof Render3D
337
+ * @example
338
+ * new Render3DPlugin;
339
+ * render3D.camera.pos = vec3(0, 5, 10);
340
+ * render3D.camera.lookAt(vec3());
341
+ * new EngineObject3D(vec3(), buildBox());
342
+ */
343
+ class Render3DPlugin
344
+ {
345
+ /** Create the global 3D renderer, call in gameInit */
346
+ constructor()
347
+ {
348
+ ASSERT(!render3D, 'Render3D plugin already initialized');
349
+ render3D = this;
350
+
351
+ /** @property {Camera3D} - The camera */
352
+ this.camera = new Camera3D;
353
+
354
+ // lights and fog
355
+ /** @property {Vector3} - Direction toward the sun, where its light comes from, like a directional Light3D;
356
+ * read at each draw, and any length will do, the shading and the shadows normalize it themselves;
357
+ * the sun is the one light that casts shadows and makes specular highlights */
358
+ this.sunDirection = vec3(-.3, 1, .5);
359
+ /** @property {Color} - Sunlight color */
360
+ this.sunColor = WHITE.copy();
361
+ /** @property {Color} - Ambient light color */
362
+ this.ambientColor = hsl(0, 0, .3);
363
+ /** @property {Color|undefined} - Fog color, uses canvasClearColor when undefined
364
+ * @type {Color|undefined} */
365
+ this.fogColor = undefined;
366
+ /** @property {number} - Distance from the camera where fog starts */
367
+ this.fogStart = 0;
368
+ /** @property {number} - Distance from the camera where fog is total, 0 disables fog */
369
+ this.fogEnd = 0;
370
+ /** @property {Vector3} - Added to the velocity3D of every object with a mass each frame, scaled by its gravityScale; sync2D objects use the 2D gravity */
371
+ this.gravity = vec3();
372
+ /** @property {number|HeightMap|Function} - Floor for objects with a softShadow: a height, a HeightMap, or (x, z) => y
373
+ * @type {number|HeightMap|Function} */
374
+ this.softShadowHeight = 0;
375
+ /** @property {boolean} - Default for every builder's smooth argument: true for smooth vertex normals, false for flat faces */
376
+ this.smoothShading = false;
377
+
378
+ // shadows
379
+ /** @property {boolean} - Cast real shadows from the sun, off by default and free when off */
380
+ this.shadows = false;
381
+ /** @property {number} - Size of the shadow map in pixels, bigger is sharper and slower */
382
+ this.shadowMapSize = 1024;
383
+ /** @property {number} - World size the shadow map covers around shadowCenter, smaller is sharper; it is a square
384
+ * facing the light, so it turns as the light does, and about 1.5 times an area's width covers it from any angle */
385
+ this.shadowRange = 40;
386
+ /** @property {Vector3|undefined} - Center of the shadowed area, read each frame, undefined follows the camera
387
+ * @type {Vector3|undefined} */
388
+ this.shadowCenter = undefined;
389
+ /** @property {number} - Stops surfaces shadowing themselves, raise for speckles, lower if shadows drift off */
390
+ this.shadowBias = .003;
391
+ /** @property {number} - How much to blur the shadow edges */
392
+ this.shadowSoftness = 1;
393
+
394
+ // draw state, read at each draw
395
+ /** @property {boolean} - Apply lighting, when false draws plain vertex color times texture and casts no shadow;
396
+ * off for billboards, lines, ribbons and soft discs, an object sets emissive instead */
397
+ this.lighting = true;
398
+ /** @property {number} - How much a surface lights itself, set per object by its emissive */
399
+ this.emissive = 0;
400
+ /** @property {boolean} - Additive blending instead of alpha, in the transparent stage */
401
+ this.additive = false;
402
+ /** @property {boolean} - Test against the depth buffer, reset to true before each object and callback */
403
+ this.depthTest = true;
404
+ /** @property {boolean} - Write to the depth buffer, owned by the stages: on for opaque, off for transparent */
405
+ this.depthWrite = true;
406
+ // batch state set by drawMesh from each mesh: whether its back faces are skipped, off for strips so they
407
+ // show from both sides, and whether its transform mirrors it so the other winding is the front
408
+ this.cullBackFaces = false;
409
+ this.mirrored = false;
410
+ /** @property {number} - Strength of the highlight where the sunlight reflects, 0 is none and 1 adds the sun's full color at its brightest; its size is fixed */
411
+ this.specular = 0;
412
+ /** @property {Shader|undefined} - Custom Shader for the next draws, set from each object's shader; undefined draws with the plugin's own
413
+ * @type {Shader|undefined} */
414
+ this.shader = undefined;
415
+ /** @property {boolean} - Darken by the shadow map when shadows are on, turn it off for things that should stay lit inside a shadow */
416
+ this.receiveShadow = true;
417
+
418
+ // the pass
419
+ /** @property {Function|undefined} - Draw solid world here, it runs again for shadows so only draw in it
420
+ * @type {Function|undefined} */
421
+ this.onRenderOpaque = undefined;
422
+ /** @property {Function|undefined} - Draw see through things here, like glows, billboards and soft shadows
423
+ * @type {Function|undefined} */
424
+ this.onRenderTransparent = undefined;
425
+ /** @property {Mesh|undefined} - Sky dome from buildSky or setSky, drawn around the camera behind everything
426
+ * @type {Mesh|undefined} */
427
+ this.sky = undefined;
428
+ /** @property {boolean} - Draw the 3D scene on top of the 2D scene instead of under it */
429
+ this.renderAfter2D = false;
430
+ /** @property {boolean} - Draw see through things far to near so they blend correctly */
431
+ this.sortTransparent = true;
432
+ /** @property {boolean} - Skip meshes whose bounding sphere is outside the view */
433
+ this.frustumCulling = true;
434
+ /** @property {boolean} - Draw every use of a mesh in the opaque stage as one instanced call, mesh.instanced overrides it per mesh */
435
+ this.instancing = true;
436
+ /** @property {boolean} - Sample textures through mipmaps so they do not shimmer in the distance, false uses each texture's own filtering like 2D */
437
+ this.mipmaps = true;
438
+ /** @property {boolean} - Draw state: keep texture pixels hard edged, no mipmaps and no blending between them, set per object by pixelated */
439
+ this.pixelated = false;
440
+ /** @property {number} - Anisotropic filtering for textures seen at an angle, 1 to 16, 1 is off; needs mipmaps */
441
+ this.anisotropy = 4;
442
+
443
+ // shared meshes, every object using one draws in the same batch
444
+ /** @property {Mesh} - A box of size 1 that drawBox uses, for any object that is a box; set the object's scale3D
445
+ * and color instead of editing the mesh, which would change every box that uses it */
446
+ this.boxMesh = buildBox();
447
+ /** @property {Mesh} - A smooth sphere of diameter 1 that drawSphere uses, shared the same way as boxMesh */
448
+ this.sphereMesh = buildSphere(1, 16, 8, true);
449
+ /** @property {Mesh} - A flat square of size 1 facing +Y, seen from above only, for floors, water and decals;
450
+ * stand it up with the object's rotation3D, and size it with scale3D */
451
+ this.planeMesh = buildGrid();
452
+ this.planeMesh.doubleSided = false;
453
+ /** @property {Mesh} - The same square seen and lit from both sides, for signs, cards and leaves */
454
+ this.planeMeshDoubleSided = buildGrid();
455
+
456
+ // read only
457
+ /** @property {boolean} - True while the 3D pass is running, 3D draws are only valid then */
458
+ this.isRendering = false;
459
+ /** @property {boolean} - True while the shadow map is being drawn, draws go to the depth only shader */
460
+ this.shadowPass = false;
461
+ /** @property {Matrix4} - This frame's view matrix */
462
+ this.viewMatrix = new Matrix4;
463
+ /** @property {Matrix4} - This frame's projection matrix */
464
+ this.projectionMatrix = new Matrix4;
465
+ /** @property {Matrix4} - This frame's combined view projection */
466
+ this.viewProjection = new Matrix4;
467
+ /** @property {Matrix4} - This frame's light view projection for the shadow map */
468
+ this.shadowMatrix = new Matrix4;
469
+ /** @property {Vector3} - Camera right axis this frame */
470
+ this.cameraRight = vec3(1, 0, 0);
471
+ /** @property {Vector3} - Camera up axis this frame */
472
+ this.cameraUp = vec3(0, 1, 0);
473
+ /** @property {Vector3} - Camera forward axis this frame */
474
+ this.cameraForward = vec3(0, 0, -1);
475
+ this.cameraBack = vec3(0, 0, 1); // its opposite, the normal of camera facing draws
476
+
477
+ // internal state
478
+ this.blend = false; // blending on, set by the stages
479
+ this.frustumPlanes = []; // the view as six inward planes [x, y, z, w]
480
+ this.shadowPlanes = []; // the shadow map's box as six planes
481
+ this.program = undefined; // the main program, undefined when not available
482
+ this.currentProgram = undefined; // the program in use during a pass, a Shader's or the main one
483
+ this.lightCount = 0; // Light3D objects sent this pass
484
+ this.shadowShader = undefined;
485
+ this.vao = undefined;
486
+ this.whiteTexture = undefined; // 1x1 white for untextured draws
487
+ this.samplers = []; // how textures are filtered in 3D, clamped and wrapping, see render3DInitGL
488
+ this.samplerKey = undefined; // the settings the samplers were made for, they are rebuilt when it changes
489
+ this.mipmapped = new WeakSet; // textures given mipmaps for the 3D pass
490
+ this.shadowTexture = undefined;
491
+ this.shadowFramebuffer = undefined;
492
+ this.shadowTextureSize = 0;
493
+ this.contextGeneration = 0; // counts context losses, a mesh uploaded under an older one uploads again
494
+ this.uniforms = new Map; // uniform locations by program
495
+ this.uniformValues = {}; // last values sent for the cached vec4 uniforms
496
+ this.shadowMapDrawn = false; // the shadow map is drawn by the first pass of the frame
497
+ this.passIsDefault = true; // the running pass is the default layer, the only one shadowed
498
+ this.lightPositions = new Float32Array(RENDER3D_MAX_LIGHTS * 4); // Light3D uniforms, filled each pass
499
+ this.lightColors = new Float32Array(RENDER3D_MAX_LIGHTS * 4);
500
+
501
+ // the stream of immediate mode draws
502
+ this.streamBuffer = undefined;
503
+ this.instanceBuffers = []; // the per instance values of the batches being drawn, used in turn
504
+ this.instanceBufferIndex = 0;
505
+ this.instanceMeshes = []; // meshes with a batch pending this stage
506
+ this.attribValues = []; // last values sent for the cached constant attributes
507
+ this.streamData = new ArrayBuffer(RENDER3D_MAX_STREAM_VERTS * RENDER3D_VERTEX_BYTES);
508
+ this.streamFloats = new Float32Array(this.streamData);
509
+ this.streamInts = new Uint32Array(this.streamData);
510
+ this.streamCount = 0;
511
+ this.streamTileInfo = undefined;
512
+ this.streamState = undefined; // captured state the pending batch was drawn under
513
+ this.capture = undefined; // the mesh a bake is filling
514
+ this.transparentQueue = undefined; // draws queued during the transparent stage, replayed far to near
515
+
516
+ render3DInitGL();
517
+ engineAddPlugin(undefined, render3DRender, render3DContextLost, render3DContextRestored, render3DPreRender);
518
+ }
519
+
520
+ ///////////////////////////////////////////////////////////////////////////
521
+ // Matrices and picking
522
+
523
+ /** Rebuild the view and projection matrices from the camera, called automatically each frame
524
+ * @param {number} [aspect] - Width over height, defaults to the main canvas */
525
+ updateMatrices(aspect=mainCanvasSize.y ? mainCanvasSize.x / mainCanvasSize.y : 1)
526
+ {
527
+ const camera = this.camera;
528
+ if (camera.align2D)
529
+ camera.update2D();
530
+ const cameraMatrix = camera.getMatrix();
531
+ this.viewMatrix = cameraMatrix.copy().invert();
532
+ this.projectionMatrix = camera.getProjectionMatrix(aspect);
533
+ this.viewProjection = this.projectionMatrix.copy().multiply(this.viewMatrix);
534
+ const m = cameraMatrix.m;
535
+ this.cameraRight = render3DAxis(m, 0);
536
+ this.cameraUp = render3DAxis(m, 4);
537
+ this.cameraBack = render3DAxis(m, 8); // the normal of anything facing the camera
538
+ this.cameraForward = this.cameraBack.scale(-1);
539
+ this.frustumPlanes = render3DFrustumPlanes(this.viewProjection);
540
+ }
541
+
542
+ /** Where a world point lands on screen as -1 to 1 across and up, with z as depth
543
+ * - Uses this frame's camera, call updateMatrices first if the camera just moved
544
+ * @param {Vector3} pos
545
+ * @return {Vector3|undefined} - undefined when behind the camera or closer than the near plane */
546
+ worldToClip(pos)
547
+ {
548
+ const m = this.viewProjection.m;
549
+ const w = m[3]*pos.x + m[7]*pos.y + m[11]*pos.z + m[15];
550
+ const z = (m[2]*pos.x + m[6]*pos.y + m[10]*pos.z + m[14]) / w;
551
+ if (w <= 0 || z < -1)
552
+ return; // behind the camera, or in front of the near plane
553
+ return vec3(
554
+ (m[0]*pos.x + m[4]*pos.y + m[8]*pos.z + m[12]) / w,
555
+ (m[1]*pos.x + m[5]*pos.y + m[9]*pos.z + m[13]) / w, z);
556
+ }
557
+
558
+ /** Project a world point to screen space pixels, same space as mousePosScreen
559
+ * - The opposite of screenToRay, and it takes the same canvas so the pair agree
560
+ * @param {Vector3} pos
561
+ * @param {Vector2} [canvasSize] - Defaults to the main canvas size, as in screenToRay;
562
+ * the projection is whatever updateMatrices last built, which screenToRay does for its canvas
563
+ * @return {Vector2|undefined} - undefined when behind the camera or closer than the near plane */
564
+ worldToScreen(pos, canvasSize=mainCanvasSize)
565
+ {
566
+ const clip = this.worldToClip(pos);
567
+ if (!clip)
568
+ return;
569
+ return vec2((clip.x + 1) / 2 * canvasSize.x, (1 - clip.y) / 2 * canvasSize.y);
570
+ }
571
+
572
+ /** Get the world ray under a screen position, for clicking on things in 3D
573
+ * - Uses the camera where it is right now, so it is fine to call from gameUpdate
574
+ * - It brings the view matrices up to date for that canvas, so worldToScreen stays its exact opposite
575
+ * @param {Vector2} screenPos - Same space as mousePosScreen
576
+ * @param {Vector2} [canvasSize] - Defaults to the main canvas size
577
+ * @return {Ray3D} - Starts at the camera with a unit direction, or on the camera plane when orthographic */
578
+ screenToRay(screenPos, canvasSize=mainCanvasSize)
579
+ {
580
+ const width = canvasSize.x || 1, height = canvasSize.y || 1; // a canvas with no size stands in as 1x1, rather than dividing by zero
581
+ const aspect = width / height, camera = this.camera;
582
+ // bring the matrices up to date for this canvas, so worldToScreen and this agree on where things are
583
+ this.updateMatrices(aspect);
584
+ const clipX = screenPos.x / width * 2 - 1;
585
+ const clipY = 1 - screenPos.y / height * 2;
586
+ // the screen offset moves a parallel ray's origin, or bends a perspective ray's direction
587
+ const h = camera.orthographic ? camera.orthographic / 2 : tan(camera.fov / 2);
588
+ const offset = this.cameraRight.scale(clipX * h * aspect).add(this.cameraUp.scale(clipY * h));
589
+ return camera.orthographic
590
+ ? new Ray3D(camera.pos.add(offset), this.cameraForward.copy())
591
+ : new Ray3D(camera.pos.copy(), this.cameraForward.add(offset).normalize());
592
+ }
593
+
594
+ /** Where a screen position lands on a flat ground plane, for top down games; use HeightMap.raycast for terrain
595
+ * @param {Vector2} screenPos - Same space as mousePosScreen
596
+ * @param {number} [groundHeight] - World height of the ground plane
597
+ * @param {Vector2} [canvasSize] - Defaults to the main canvas size, as in screenToRay
598
+ * @return {Vector3|undefined} - undefined when the ray misses the plane */
599
+ screenToGround(screenPos, groundHeight=0, canvasSize=mainCanvasSize)
600
+ {
601
+ const ray = this.screenToRay(screenPos, canvasSize);
602
+ const t = raycastPlane(ray, vec3(0, groundHeight, 0), RENDER3D_DEFAULT_NORMAL);
603
+ return t === undefined ? undefined : ray.getPosition(t);
604
+ }
605
+
606
+ /** Find the nearest object under a screen position or along a ray, for clicking on things
607
+ * - Each object is tested as a sphere around its mesh, or around a sprite's size3D, not triangle by triangle
608
+ * - engineObjectsRaycast3D is the other half of this, every object along a ray instead of the nearest
609
+ * @param {Vector2|Ray3D} from - A screen position like mousePosScreen, or a ray to look along
610
+ * @param {Array<EngineObject>} [objects] - Defaults to every object; only those with a mesh or a sprite count
611
+ * @return {{object: EngineObject3D, distance: number}|undefined} */
612
+ pick(from, objects=engineObjects)
613
+ {
614
+ const ray = from instanceof Ray3D ? from : this.screenToRay(from);
615
+ let nearest;
616
+ for (const o of objects)
617
+ {
618
+ const distance = render3DRaycastObject(ray, o);
619
+ if (distance !== undefined && (!nearest || distance < nearest.distance))
620
+ nearest = {object: o, distance};
621
+ }
622
+ return nearest;
623
+ }
624
+
625
+ /** 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
626
+ * @param {Sound} sound
627
+ * @param {Vector3} pos3D
628
+ * @param {number} [volume]
629
+ * @param {number} [pitch]
630
+ * @param {number} [randomnessScale] - How much to scale pitch randomness
631
+ * @param {boolean} [loop]
632
+ * @return {SoundInstance|undefined} - undefined when out of range or sound is off */
633
+ playSound(sound, pos3D, volume=1, pitch=1, randomnessScale=1, loop=false)
634
+ {
635
+ // keep in step with Sound.play, only the pan differs
636
+ ASSERT(sound instanceof Sound, 'sound must be a Sound');
637
+ ASSERT(isVector3(pos3D), 'pos3D must be a vec3');
638
+ if (!soundEnable || headlessMode) return;
639
+ if (!sound.sampleBuffer && !sound._sampleChannels) return; // still loading
640
+ const offset = pos3D.subtract(this.camera.pos), range = sound.range;
641
+ if (range)
642
+ {
643
+ const distance = offset.length();
644
+ if (distance > range)
645
+ return; // out of range
646
+ volume *= percent(distance, range, range * sound.taper);
647
+ }
648
+ const pan = offset.normalize().dot(this.cameraRight);
649
+ const rate = pitch + pitch * sound.randomness * randomnessScale * rand(-1, 1);
650
+ return new SoundInstance(sound, volume, rate, pan, loop);
651
+ }
652
+
653
+ /** Play a sound on a loop at a 3D position, the same as playSound with loop on
654
+ * - Its volume and pan are set when it starts, change or stop it through the SoundInstance returned
655
+ * @param {Sound} sound
656
+ * @param {Vector3} pos3D
657
+ * @param {number} [volume]
658
+ * @param {number} [pitch]
659
+ * @param {number} [randomnessScale] - How much to scale pitch randomness
660
+ * @return {SoundInstance|undefined} - undefined when out of range or sound is off */
661
+ playSoundLoop(sound, pos3D, volume=1, pitch=1, randomnessScale=1)
662
+ { return this.playSound(sound, pos3D, volume, pitch, randomnessScale, true); }
663
+
664
+ /** Is any part of a sphere on screen this frame, the test that skips meshes the camera cannot see
665
+ * - While the shadow map is drawing it tests the shadow area instead
666
+ * @param {Vector3} center
667
+ * @param {number} radius
668
+ * @return {boolean} */
669
+ isSphereVisible(center, radius)
670
+ {
671
+ for (const p of this.shadowPass ? this.shadowPlanes : this.frustumPlanes)
672
+ if (p[0] * center.x + p[1] * center.y + p[2] * center.z + p[3] < -radius)
673
+ return false;
674
+ return true;
675
+ }
676
+
677
+ ///////////////////////////////////////////////////////////////////////////
678
+ // Meshes and the stream
679
+
680
+ /** Draw a mesh with the current draw state, batched with its other uses in the opaque stage when instancing is on
681
+ * @param {Mesh} mesh
682
+ * @param {Matrix4|Vector3} [matrix] - Object transform, or just a position to draw it at
683
+ * @param {TileInfo|TextureInfo} [tileInfo] - Texture, mesh uvs map across the tile or the whole texture
684
+ * @param {Color} [color] - Tint */
685
+ drawMesh(mesh, matrix=RENDER3D_IDENTITY, tileInfo, color=WHITE)
686
+ {
687
+ matrix = render3DMatrix(matrix);
688
+ ASSERT(!tileInfo || tileInfo instanceof TileInfo || tileInfo instanceof TextureInfo, 'tileInfo must be a TileInfo or TextureInfo, it comes before color');
689
+ ASSERT(isColor(color), 'color must be a Color');
690
+ if (this.capture)
691
+ return void this.capture.combine(mesh, matrix, color);
692
+ if (this.transparentQueue)
693
+ return this.queueTransparent(matrix.getTranslation(), ()=> this.drawMesh(mesh, matrix, tileInfo, color));
694
+ if (!render3DCanDraw()) return;
695
+ if (this.shadowPass && !this.lighting) return; // unlit things cast no shadow
696
+ if (!mesh.buffer || mesh.dirty || mesh.contextGeneration !== this.contextGeneration)
697
+ mesh.upload();
698
+ if (!mesh.bufferCount) return;
699
+ if (this.frustumCulling && !this.isSphereVisible(matrix.getTranslation(), mesh.radius * render3DMaxScale(matrix.m)))
700
+ return;
701
+ // the mesh says whether its back faces can be skipped, and a mirroring transform, one with a negative
702
+ // determinant, turns the winding around so the other one is its front
703
+ const m = matrix.m, cullBackFaces = this.cullBackFaces, mirrored = this.mirrored;
704
+ this.cullBackFaces = !mesh.doubleSided;
705
+ this.mirrored = m[0]*(m[5]*m[10] - m[6]*m[9]) - m[4]*(m[1]*m[10] - m[2]*m[9]) + m[8]*(m[1]*m[6] - m[2]*m[5]) < 0;
706
+ if (!this.blend && this.depthTest && (mesh.instanced ?? this.instancing)) // the stage draws the batch at its end
707
+ render3DInstance(mesh, matrix, tileInfo, color);
708
+ else
709
+ {
710
+ this.flush();
711
+ render3DSetDrawUniforms(matrix, tileInfo, color);
712
+ render3DBindVertexBuffer(mesh.buffer);
713
+ glContext.drawArrays(glContext.TRIANGLE_STRIP, 0, mesh.bufferCount);
714
+ ++drawCount;
715
+ primitiveCount += mesh.bufferCount;
716
+ }
717
+ this.cullBackFaces = cullBackFaces, this.mirrored = mirrored;
718
+ }
719
+
720
+ /** Draw a triangle strip, batched into the stream with the current draw state
721
+ * - Strip order: the first three points make a triangle, then each point makes another with the two before it
722
+ * - List the first three points counter clockwise as seen from the front, or the face points away
723
+ * and may vanish when back faces are culled
724
+ * - inside a bake the strip goes into the mesh instead, in the transparent stage it is queued for sorting
725
+ * @param {Array<Vector3>} points - In strip order
726
+ * @param {Vector3|Array<Vector3>} [normals] - One for all or one per point, default up
727
+ * @param {Vector2|Array<Vector2>} [uvs] - One for all or one per point, 0-1 across the tile
728
+ * @param {Color|Array<Color>} [colors] - One for all or one per point, vertex colors come before the texture
729
+ * @param {TileInfo|TextureInfo} [tileInfo] - Texture for this strip */
730
+ drawStrip(points, normals, uvs, colors, tileInfo)
731
+ {
732
+ if (this.capture)
733
+ {
734
+ this.capture.addStrip(points, normals, uvs, colors);
735
+ return;
736
+ }
737
+ if (this.transparentQueue)
738
+ {
739
+ // sort by the center of the strip
740
+ let x = 0, y = 0, z = 0;
741
+ for (const p of points)
742
+ x += p.x, y += p.y, z += p.z;
743
+ return this.queueTransparent(vec3(x, y, z).scale(1 / points.length), ()=> this.drawStrip(points, normals, uvs, colors, tileInfo));
744
+ }
745
+ ASSERT(isArray(points) && points.length > 2, 'strip needs at least 3 points');
746
+ const n = points.length, count = render3DStripCount(n);
747
+ const uvRect = render3DBeginStrip(count, tileInfo);
748
+ if (!uvRect) return;
749
+
750
+ // the tile rect is applied to each uv now, so the whole texture maps at flush
751
+ const floats = this.streamFloats, ints = this.streamInts;
752
+ const normalArray = isArray(normals), uvArray = isArray(uvs), colorArray = isArray(colors);
753
+ const rgba = colorArray ? 0 : (colors || WHITE).rgbaInt();
754
+ for (let k = 0; k < count; ++k)
755
+ {
756
+ const i = render3DStripIndex(k, n);
757
+ const uv = uvArray ? uvs[i] : uvs || RENDER3D_DEFAULT_UV;
758
+ render3DWriteVertex(floats, ints, this.streamCount++ * RENDER3D_VERTEX_FLOATS, points[i],
759
+ normalArray ? normals[i] : normals || RENDER3D_DEFAULT_NORMAL,
760
+ uvRect.x + uv.x * uvRect.w, uvRect.y + uv.y * uvRect.h, colorArray ? colors[i].rgbaInt() : rgba);
761
+ }
762
+ }
763
+
764
+ /** Draw a strip with lighting off, for camera facing shapes where the light direction means nothing
765
+ * @param {Array<Vector3>} points - Strip order
766
+ * @param {Vector3|Array<Vector3>} [normals]
767
+ * @param {Vector2|Array<Vector2>} [uvs]
768
+ * @param {Color|Array<Color>} [colors]
769
+ * @param {TileInfo|TextureInfo} [tileInfo] */
770
+ drawStripUnlit(points, normals, uvs, colors, tileInfo)
771
+ { render3DWithState({lighting: false}, ()=> this.drawStrip(points, normals, uvs, colors, tileInfo)); }
772
+
773
+ /** Draw the pending stream vertices as one strip with the state they were drawn under, called automatically when needed */
774
+ flush()
775
+ {
776
+ if (!this.streamCount || !render3DCanDraw()) return;
777
+ const gl = glContext;
778
+ render3DSetDrawUniforms(RENDER3D_IDENTITY, this.streamTileInfo, WHITE, RENDER3D_FULL_UV_RECT, this.streamState);
779
+ render3DBindVertexBuffer(this.streamBuffer);
780
+ gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.streamFloats, 0, this.streamCount * RENDER3D_VERTEX_FLOATS);
781
+ gl.drawArrays(gl.TRIANGLE_STRIP, 0, this.streamCount);
782
+ ++drawCount;
783
+ primitiveCount += this.streamCount;
784
+ this.streamCount = 0;
785
+ }
786
+
787
+ /** Build a mesh once out of draw calls, instead of redrawing the shapes every frame
788
+ * - Call the same drawStrip, drawQuad and drawBox calls inside, and get a mesh back
789
+ * - Strips inside a bake ignore their tileInfo, the finished mesh picks the texture when it draws
790
+ * - drawMesh, drawBox and drawSphere copy their mesh in, moved and tinted, their tileInfo dropped too
791
+ * - The mesh skips its back faces like any, set doubleSided when what was drawn is open
792
+ * @param {Function} drawFunction
793
+ * @return {Mesh} */
794
+ bake(drawFunction)
795
+ {
796
+ this.flush();
797
+ ASSERT(!this.capture, 'bake cannot be nested');
798
+ const mesh = this.capture = new Mesh;
799
+ try { drawFunction(); }
800
+ finally { this.capture = undefined; }
801
+ return mesh;
802
+ }
803
+
804
+ ///////////////////////////////////////////////////////////////////////////
805
+ // The stages, run by the pass
806
+
807
+ /** Draw a layer's objects, solid ones first and see through ones after, called automatically
808
+ * - The main layer also draws the sky, the render callbacks and the debug shapes
809
+ * @param {Array<EngineObject3D>} objects
810
+ * @param {boolean} [isDefault] */
811
+ renderStages(objects, isDefault=true)
812
+ {
813
+ const opaque = [], transparent = [];
814
+ for (const o of objects)
815
+ (o.transparent || o.additive ? transparent : opaque).push(o);
816
+
817
+ isDefault && this.sky && this.drawSky();
818
+
819
+ // opaque: no blending, depth writes on, by render order
820
+ this.blend = false;
821
+ this.depthWrite = true;
822
+ const byOrder = (a, b)=> a.renderOrder - b.renderOrder;
823
+ opaque.sort(byOrder);
824
+ transparent.sort(byOrder);
825
+ render3DDrawObjects(opaque);
826
+ isDefault && this.onRenderOpaque?.();
827
+ this.flush();
828
+ render3DFlushInstances();
829
+
830
+ // transparent: blending on, depth writes off, every draw queued then replayed far to near
831
+ this.blend = true;
832
+ this.depthWrite = false;
833
+ this.transparentQueue = this.sortTransparent ? [] : undefined;
834
+ try
835
+ {
836
+ render3DDrawObjects(transparent);
837
+ for (const o of objects)
838
+ if (o.softShadow)
839
+ {
840
+ // the shadow grows with the object, by the same scale picking and culling
841
+ // measure it at, so one size set once holds however the object is scaled
842
+ const m = o.getMatrix();
843
+ this.drawSoftShadow(m.getTranslation(), o.softShadow * render3DMaxScale(m.m), this.softShadowHeight);
844
+ }
845
+ isDefault && this.onRenderTransparent?.();
846
+ }
847
+ finally { this.flushTransparentQueue(); }
848
+ isDefault && render3DRenderDebug();
849
+ this.flush();
850
+
851
+ // leave the fields at the opaque defaults for anything reading them outside the pass
852
+ render3DSetObjectState();
853
+ this.blend = false;
854
+ this.depthWrite = true;
855
+ }
856
+
857
+ /** Queue a draw for the transparent stage, replayed far to near with the current draw state, or draw it now when sorting is off
858
+ * @param {Vector3} pos - Where the draw is, for sorting
859
+ * @param {Function} draw */
860
+ queueTransparent(pos, draw)
861
+ {
862
+ if (!this.transparentQueue)
863
+ return draw();
864
+ this.transparentQueue.push({distance: pos.distanceSquared(this.camera.pos), state: render3DCaptureBatchState(), draw});
865
+ }
866
+
867
+ /** Draw the queued transparent draws far to near with the state each was drawn under, called automatically at the end of the transparent stage */
868
+ flushTransparentQueue()
869
+ {
870
+ const queue = this.transparentQueue;
871
+ if (!queue) return;
872
+ this.transparentQueue = undefined;
873
+ queue.sort((a, b)=> b.distance - a.distance);
874
+ for (const item of queue)
875
+ render3DWithState(item.state, item.draw); // each under the state it was queued with
876
+ }
877
+
878
+ /** Draw render3D.sky around the camera, unlit, unfogged and behind everything, called automatically by the pass */
879
+ drawSky()
880
+ {
881
+ this.flush();
882
+ // the dome only has to sit between the clip planes, the pass draws it first with no depth test;
883
+ // a far plane at Infinity has no midpoint, so put it a long way out instead
884
+ const {near, far} = this.camera;
885
+ const radius = far == Infinity ? near * 1e4 : (near + far) / 2;
886
+ render3DWithState({lighting: false, blend: false, depthTest: false, depthWrite: false, fogEnd: 0, shader: undefined}, ()=>
887
+ this.drawMesh(this.sky, buildMatrix(this.camera.pos, undefined, vec3(radius))));
888
+ }
889
+
890
+ /** Rebuild the light's view projection around the shadow center, called automatically each frame shadows are on */
891
+ updateShadowMatrix()
892
+ {
893
+ ASSERT(this.shadowRange > 0, 'shadowRange must be positive');
894
+ const range = this.shadowRange > 0 ? this.shadowRange : 1, half = range / 2;
895
+ const toSun = this.sunDirection.normalize();
896
+ const center = this.shadowCenter || this.camera.pos.add(this.cameraForward.scale(half * .8));
897
+ const view = Matrix4.lookAt(center.add(toSun.scale(range)), center).invert();
898
+ // move the light's view in whole pixel steps so shadow edges do not crawl as the camera moves
899
+ const texel = range / (this.shadowTextureSize || this.shadowMapSize), m = view.m; // no texture in headless mode
900
+ m[12] = round(m[12] / texel) * texel;
901
+ m[13] = round(m[13] / texel) * texel;
902
+ this.shadowMatrix = Matrix4.orthographic(-half, half, -half, half, 0, range * 2).multiply(view);
903
+ this.shadowPlanes = render3DFrustumPlanes(this.shadowMatrix);
904
+ }
905
+
906
+ /** Build a sky dome, set it as the sky and set the fog color to the horizon color
907
+ * @param {Color} [topColor] - Straight up
908
+ * @param {Color} [horizonColor] - Level with the camera
909
+ * @param {Color} [bottomColor] - Straight down, defaults to the horizon color
910
+ * @return {Mesh} - The dome, also in render3D.sky */
911
+ setSky(topColor, horizonColor=hsl(.6, 1, .9), bottomColor)
912
+ {
913
+ this.sky?.dispose();
914
+ this.sky = buildSky(topColor, horizonColor, bottomColor);
915
+ this.fogColor = horizonColor.copy();
916
+ return this.sky;
917
+ }
918
+
919
+ /** Set where fog starts and ends, and its color
920
+ * @param {number} fogStart - Distance from the camera where fog starts
921
+ * @param {number} fogEnd - Distance where fog is total, 0 disables fog
922
+ * @param {Color} [fogColor] - Leaves the color alone when not passed, setSky sets it to the horizon */
923
+ setFog(fogStart, fogEnd, fogColor)
924
+ {
925
+ this.fogStart = fogStart;
926
+ this.fogEnd = fogEnd;
927
+ if (fogColor)
928
+ this.fogColor = fogColor.copy();
929
+ }
930
+
931
+ ///////////////////////////////////////////////////////////////////////////
932
+ // Immediate mode shapes
933
+
934
+ /** Draw a box, untextured, for blocking out a scene without meshes or objects
935
+ * @param {Vector3} pos - Center
936
+ * @param {Vector3|number} [size] - Full size, a number for a cube
937
+ * @param {Color} [color]
938
+ * @param {Vector3} [rotation] - vec3(pitch, yaw, roll) */
939
+ drawBox(pos, size=1, color=WHITE, rotation)
940
+ {
941
+ this.drawMesh(this.boxMesh, buildMatrix(pos, rotation, render3DSize3(size)), undefined, color);
942
+ }
943
+
944
+ /** Draw a sphere, untextured and smooth shaded
945
+ * @param {Vector3} pos - Center
946
+ * @param {number} [size] - Diameter
947
+ * @param {Color} [color] */
948
+ drawSphere(pos, size=1, color=WHITE)
949
+ {
950
+ this.drawMesh(this.sphereMesh, buildMatrix(pos, undefined, vec3(size)), undefined, color);
951
+ }
952
+
953
+ /** Draw a flat square that always faces the camera, unlit so it keeps its own colors
954
+ * - Draw it from onRenderTransparent or a transparent object so it can fade
955
+ * @param {Vector3} pos - Center
956
+ * @param {Vector2} [size] - World units
957
+ * @param {TileInfo|TextureInfo} [tileInfo]
958
+ * @param {Color} [color]
959
+ * @param {number} [angle] - Rotation in the camera plane, counter clockwise
960
+ * @param {boolean} [upright] - Stand on world up and only turn to face the camera, for sprites on the ground */
961
+ drawBillboard(pos, size=vec2(1), tileInfo, color=WHITE, angle=0, upright=false)
962
+ {
963
+ if (this.capture)
964
+ return this.drawStripUnlit(render3DBillboardCorners(pos, size, angle, upright), this.cameraBack, RENDER3D_QUAD_UVS, color, tileInfo);
965
+ if (this.transparentQueue) // sort by the exact position, a shadow under it sorts by the floor
966
+ return this.queueTransparent(pos, ()=> this.drawBillboard(pos, size, tileInfo, color, angle, upright));
967
+
968
+ // the particle path: the quad's six stream vertices written straight in, unlit
969
+ const count = render3DStripCount(4);
970
+ const lighting = this.shadowPass && this.lighting; // unlit on screen, in the shadow map the object's flag decides
971
+ const uvRect = render3DWithState({lighting}, ()=> render3DBeginStrip(count, tileInfo));
972
+ if (!uvRect) return;
973
+ const corners = render3DBillboardCorners(pos, size, angle, upright), rgba = color.rgbaInt();
974
+ const floats = this.streamFloats, ints = this.streamInts;
975
+ for (let k = 0; k < count; ++k)
976
+ {
977
+ const i = render3DStripIndex(k, 4), uv = RENDER3D_QUAD_UVS[i];
978
+ render3DWriteVertex(floats, ints, this.streamCount++ * RENDER3D_VERTEX_FLOATS, corners[i], this.cameraBack,
979
+ uvRect.x + uv.x * uvRect.w, uvRect.y + uv.y * uvRect.h, rgba);
980
+ }
981
+ }
982
+
983
+ /** Draw a quad from four corners in loop order, counter clockwise seen from the front, a is the top left of the texture
984
+ * @param {Vector3} a
985
+ * @param {Vector3} b
986
+ * @param {Vector3} c
987
+ * @param {Vector3} d
988
+ * @param {TileInfo|TextureInfo} [tileInfo]
989
+ * @param {Color|Array<Color>} [color] - One for all or one per corner */
990
+ drawQuad(a, b, c, d, tileInfo, color=WHITE)
991
+ {
992
+ this.drawStrip(render3DQuadStrip(a, b, c, d), render3DFaceNormal(a, b, c, d), RENDER3D_QUAD_UVS, render3DQuadValues(color), tileInfo);
993
+ }
994
+
995
+ /** Draw a triangle, counter clockwise from outside is the front
996
+ * @param {Vector3} a
997
+ * @param {Vector3} b
998
+ * @param {Vector3} c
999
+ * @param {Color} [color] */
1000
+ drawTriangle(a, b, c, color=WHITE)
1001
+ {
1002
+ this.drawStrip([a, b, c], render3DFaceNormal(a, b, c), undefined, color);
1003
+ }
1004
+
1005
+ /** Draw a line as a camera facing ribbon, unlit
1006
+ * @param {Vector3} posA
1007
+ * @param {Vector3} posB
1008
+ * @param {number} [width]
1009
+ * @param {Color} [color] */
1010
+ drawLine(posA, posB, width=.1, color=WHITE)
1011
+ {
1012
+ this.drawRibbon([posA, posB], width, undefined, color);
1013
+ }
1014
+
1015
+ /** Draw a ribbon along a path, unlit and visible from both sides; width and color can change along it
1016
+ * - The texture runs along the length, u from the first point to the last
1017
+ * - A path that ends where it starts is a loop, and joins with no seam
1018
+ * @param {Array<Vector3>} points - Center line in order, at least two
1019
+ * @param {number|Array<number>} [width] - Full width, one for all or one per point
1020
+ * @param {TileInfo|TextureInfo} [tileInfo]
1021
+ * @param {Color|Array<Color>} [color] - One for all or one per point
1022
+ * @param {Vector3|Array<Vector3>} [side] - Direction across the ribbon, one for all or one per point, default faces the camera */
1023
+ drawRibbon(points, width=.1, tileInfo, color=WHITE, side)
1024
+ {
1025
+ const count = points.length;
1026
+ ASSERT(count > 1, 'a ribbon needs at least two points');
1027
+ ASSERT(!tileInfo || tileInfo instanceof TileInfo || tileInfo instanceof TextureInfo, 'tileInfo must be a TileInfo or TextureInfo, it comes before color');
1028
+ const strip = [], uvs = tileInfo ? [] : undefined, colors = [], forward = this.cameraForward;
1029
+ let across = vec3(1, 0, 0); // kept from the last point where the direction vanishes
1030
+ // a loop's two ends take their direction across the join, so they meet edge to edge
1031
+ const loop = count > 2 && points[0].distanceSquared(points[count - 1]) < 1e-12;
1032
+ for (let i = 0; i < count; ++i)
1033
+ {
1034
+ const p = points[i];
1035
+ const w = isArray(width) ? width[i] : width;
1036
+ const c = isArray(color) ? color[i] : color;
1037
+ const s = side && (isArray(side) ? side[i] : side);
1038
+ // across the path in the camera plane unless a side is given
1039
+ const next = points[i < count - 1 ? i + 1 : loop ? 1 : i];
1040
+ const last = points[i > 0 ? i - 1 : loop ? count - 2 : i];
1041
+ const dir = s || next.subtract(last).cross(forward);
1042
+ if (dir.lengthSquared() > 1e-12)
1043
+ across = dir.normalize();
1044
+ const half = across.scale(w / 2);
1045
+ strip.push(p.add(half), p.subtract(half));
1046
+ uvs?.push(vec2(i / (count - 1), 0), vec2(i / (count - 1), 1));
1047
+ colors.push(c, c);
1048
+ }
1049
+ render3DWithState({lighting: false, cullBackFaces: false}, ()=> this.drawStrip(strip, forward.scale(-1), uvs, colors, tileInfo));
1050
+ }
1051
+
1052
+ /** Draw a disc that fades to transparent at the rim, unlit, for glows, puffs and sky dots
1053
+ * @param {Vector3} pos - Center
1054
+ * @param {number} [size] - Diameter
1055
+ * @param {Color} [color]
1056
+ * @param {Vector3} [normal] - Facing direction, faces the camera by default
1057
+ * @param {number} [sides] */
1058
+ drawSoftDisc(pos, size=1, color=WHITE, normal=this.cameraBack, sides=16)
1059
+ {
1060
+ render3DAssertBlending();
1061
+ if (this.transparentQueue && !this.capture)
1062
+ return this.queueTransparent(pos, ()=> this.drawSoftDisc(pos, size, color, normal, sides));
1063
+ // basis in the disc's plane
1064
+ const n = normal.normalize();
1065
+ const helper = abs(n.y) < .9 ? vec3(0, 1, 0) : vec3(1, 0, 0);
1066
+ const u = helper.cross(n).normalize(), w = u.cross(n);
1067
+ render3DDrawSoftDisc(size / 2, color, sides, n, (c, s, r)=>
1068
+ 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));
1069
+ }
1070
+
1071
+ /** Draw a soft round shadow on the ground under something, much cheaper than a real shadow
1072
+ * - Draw it from onRenderTransparent or from a transparent object
1073
+ * @param {Vector3} pos - Position of the thing casting the shadow
1074
+ * @param {number} [size] - Diameter
1075
+ * @param {number|HeightMap|Function} [floorHeight] - Height of the ground, a HeightMap, or (x, z) => y to follow terrain
1076
+ * @param {Color} [color]
1077
+ * @param {number} [lift] - How far above the ground to draw, raise it if the shadow cuts into rough ground */
1078
+ drawSoftShadow(pos, size=1, floorHeight=0, color=RENDER3D_SHADOW_COLOR, lift=.02)
1079
+ {
1080
+ render3DAssertBlending();
1081
+ const height = isNumber(floorHeight) ? ()=> floorHeight
1082
+ : floorHeight instanceof HeightMap ? (x, z)=> floorHeight.getHeight(x, z) : floorHeight;
1083
+ if (this.transparentQueue && !this.capture) // sort from the floor, under whatever casts it
1084
+ return this.queueTransparent(vec3(pos.x, height(pos.x, pos.z) + lift, pos.z), ()=> this.drawSoftShadow(pos, size, floorHeight, color, lift));
1085
+ render3DDrawSoftDisc(size / 2, color, 16, RENDER3D_DEFAULT_NORMAL, (c, s, r)=>
1086
+ {
1087
+ const x = pos.x + c * r, z = pos.z + s * r;
1088
+ return vec3(x, height(x, z) + lift, z);
1089
+ });
1090
+ }
1091
+ }
1092
+
1093
+ function render3DAssertBlending()
1094
+ {
1095
+ const r = render3D;
1096
+ ASSERT(r.blend || r.capture || r.shadowPass || !r.isRendering, 'soft discs and shadows need blending: set the object transparent or draw from onRenderTransparent');
1097
+ }
1098
+
1099
+ // draw the three rings of a soft disc as unlit strips, pointAt(cos, sin, radius) gives the world point
1100
+ function render3DDrawSoftDisc(radius, color, sides, normal, pointAt)
1101
+ {
1102
+ const alpha = [1, .9, .7, 0], circle = render3DCircle(sides); // alpha by ring, center to rim
1103
+ for (let k = 0; k < 3; ++k)
1104
+ {
1105
+ const points = [], colors = [];
1106
+ const c0 = color.withAlpha(color.a * alpha[k]), c1 = color.withAlpha(color.a * alpha[k+1]);
1107
+ const r0 = radius * k / 3, r1 = radius * (k + 1) / 3;
1108
+ for (let i = 0; i <= sides; ++i)
1109
+ {
1110
+ const c = circle[i*2], s = circle[i*2 + 1];
1111
+ points.push(pointAt(c, s, r1), pointAt(c, s, r0));
1112
+ colors.push(c1, c0);
1113
+ }
1114
+ render3D.drawStripUnlit(points, normal, undefined, colors);
1115
+ }
1116
+ }
1117
+
1118
+ ///////////////////////////////////////////////////////////////////////////////
1119
+ // Debug primitives, drawn on top of the 3D scene like the 2D debug functions, only in debug builds
1120
+
1121
+ let render3DDebugPrimitives = [];
1122
+
1123
+ // draw the live debug primitives with depth test off so they show through walls, drop the expired ones
1124
+ function render3DRenderDebug()
1125
+ {
1126
+ if (!render3DDebugPrimitives.length) return;
1127
+ render3DWithState({lighting: false, depthTest: false, receiveShadow: false, additive: false, shader: undefined}, ()=>
1128
+ {
1129
+ for (const p of render3DDebugPrimitives)
1130
+ p.draw();
1131
+ });
1132
+ render3DDebugPrimitives = render3DDebugPrimitives.filter(p=> p.timer < 0); // a Timer compares as negative until it elapses
1133
+ }
1134
+
1135
+ // record a debug draw for a time
1136
+ function render3DDebugPush(duration, draw)
1137
+ {
1138
+ ASSERT(isNumber(duration), 'duration must be a number');
1139
+ debug && render3D?.program && render3DDebugPrimitives.push({timer: new Timer(duration), draw});
1140
+ }
1141
+
1142
+ /** Draw a debug wireframe box
1143
+ * @param {Vector3} pos - Center
1144
+ * @param {Vector3|number} [size] - Full size, a number for a cube
1145
+ * @param {Color} [color]
1146
+ * @param {number} [time] - How long to show it, 0 is one frame
1147
+ * @param {Vector3} [rotation] - vec3(pitch, yaw, roll)
1148
+ * @memberof Render3D */
1149
+ function debugBox3D(pos, size=1, color=WHITE, time=0, rotation)
1150
+ {
1151
+ const matrix = buildMatrix(pos, rotation, render3DSize3(size));
1152
+ const corner = (i)=> matrix.transformPoint(vec3(i & 1 ? .5 : -.5, i & 2 ? .5 : -.5, i & 4 ? .5 : -.5));
1153
+ render3DDebugPush(time, ()=>
1154
+ {
1155
+ for (let i = 0; i < 8; ++i)
1156
+ for (const bit of [1, 2, 4])
1157
+ if (!(i & bit))
1158
+ render3D.drawLine(corner(i), corner(i | bit), RENDER3D_DEBUG_WIDTH, color);
1159
+ });
1160
+ }
1161
+
1162
+ /** Draw a debug wireframe sphere as three rings
1163
+ * @param {Vector3} pos - Center
1164
+ * @param {number} [size] - Diameter
1165
+ * @param {Color} [color]
1166
+ * @param {number} [time] - How long to show it, 0 is one frame
1167
+ * @memberof Render3D */
1168
+ function debugSphere3D(pos, size=1, color=WHITE, time=0)
1169
+ {
1170
+ const circle = render3DCircle(24), r = size / 2;
1171
+ render3DDebugPush(time, ()=>
1172
+ {
1173
+ for (const ring of [(c, s)=> vec3(c, s, 0), (c, s)=> vec3(c, 0, s), (c, s)=> vec3(0, c, s)])
1174
+ {
1175
+ const points = [];
1176
+ for (let i = 0; i <= 24; ++i)
1177
+ points.push(pos.add(ring(circle[i*2], circle[i*2 + 1]).scale(r)));
1178
+ render3D.drawRibbon(points, RENDER3D_DEBUG_WIDTH, undefined, color);
1179
+ }
1180
+ });
1181
+ }
1182
+
1183
+ /** Draw a debug line
1184
+ * @param {Vector3} posA
1185
+ * @param {Vector3} posB
1186
+ * @param {Color} [color]
1187
+ * @param {number} [width]
1188
+ * @param {number} [time] - How long to show it, 0 is one frame
1189
+ * @memberof Render3D */
1190
+ function debugLine3D(posA, posB, color=WHITE, width=RENDER3D_DEBUG_WIDTH, time=0)
1191
+ {
1192
+ render3DDebugPush(time, ()=> render3D.drawLine(posA, posB, width, color));
1193
+ }
1194
+
1195
+ /** Draw a debug point as a small cross of three lines
1196
+ * @param {Vector3} pos
1197
+ * @param {Color} [color]
1198
+ * @param {number} [time] - How long to show it, 0 is one frame
1199
+ * @param {number} [size] - Length of the cross
1200
+ * @memberof Render3D */
1201
+ function debugPoint3D(pos, color=WHITE, time=0, size=.2)
1202
+ {
1203
+ render3DDebugPush(time, ()=>
1204
+ {
1205
+ for (const axis of [vec3(size / 2, 0, 0), vec3(0, size / 2, 0), vec3(0, 0, size / 2)])
1206
+ render3D.drawLine(pos.subtract(axis), pos.add(axis), RENDER3D_DEBUG_WIDTH, color);
1207
+ });
1208
+ }
1209
+
1210
+ ///////////////////////////////////////////////////////////////////////////////
1211
+ /**
1212
+ * Camera3D - Position, rotation and lens for the 3D view
1213
+ * - Looks down its -Z axis, rotation is vec3(pitch, yaw, roll)
1214
+ * @memberof Render3D
1215
+ */
1216
+ class Camera3D
1217
+ {
1218
+ /** Create a camera, looking down -Z from z=10 by default */
1219
+ constructor()
1220
+ {
1221
+ /** @property {Vector3} - World position */
1222
+ this.pos = vec3(0, 0, 10);
1223
+ /** @property {Vector3} - Euler rotation, vec3(pitch, yaw, roll) in radians */
1224
+ this.rotation = vec3();
1225
+ /** @property {number} - Vertical field of view in radians */
1226
+ this.fov = PI/3;
1227
+ /** @property {number} - Near clip distance */
1228
+ this.near = .1;
1229
+ /** @property {number} - Far clip distance, Infinity is allowed for a perspective view */
1230
+ this.far = 1e3;
1231
+ /** @property {number} - Visible height in world units for an orthographic view, 0 is perspective */
1232
+ this.orthographic = 0;
1233
+ /** @property {boolean} - Line the 3D camera up with the 2D camera, so 3D things at z=0 sit on the 2D sprites */
1234
+ this.align2D = false;
1235
+ }
1236
+
1237
+ /** Returns the camera's world transform
1238
+ * @return {Matrix4} */
1239
+ getMatrix() { return buildMatrix(this.pos, this.rotation); }
1240
+
1241
+ /** Returns the view matrix, world to camera space
1242
+ * @return {Matrix4} */
1243
+ getViewMatrix() { return this.getMatrix().invert(); }
1244
+
1245
+ /** Returns the projection matrix
1246
+ * @param {number} aspect - Width over height
1247
+ * @return {Matrix4} */
1248
+ getProjectionMatrix(aspect)
1249
+ {
1250
+ const h = this.orthographic / 2, w = h * aspect;
1251
+ return h ? Matrix4.orthographic(-w, w, -h, h, this.near, this.far) : Matrix4.perspective(this.fov, aspect, this.near, this.far);
1252
+ }
1253
+
1254
+ /** Returns the direction the camera looks
1255
+ * @return {Vector3} */
1256
+ getForward() { return render3DAxis(this.getMatrix().m, 8).scale(-1); }
1257
+
1258
+ /** Returns the camera's right axis
1259
+ * @return {Vector3} */
1260
+ getRight() { return render3DAxis(this.getMatrix().m, 0); }
1261
+
1262
+ /** Returns the camera's up axis
1263
+ * @return {Vector3} */
1264
+ getUp() { return render3DAxis(this.getMatrix().m, 4); }
1265
+
1266
+ /** Point the camera at a target, sets pitch and yaw and clears roll
1267
+ * @param {Vector3} target */
1268
+ lookAt(target) { this.rotation = render3DLookRotation(target.subtract(this.pos), this.rotation); }
1269
+
1270
+ /** Put the camera on an orbit around a target, looking at it
1271
+ * @param {Vector3} target
1272
+ * @param {number} distance
1273
+ * @param {number} yaw - Radians around Y
1274
+ * @param {number} [pitch] - Radians above the horizon */
1275
+ orbit(target, distance, yaw, pitch=.5)
1276
+ {
1277
+ const r = cos(pitch) * distance;
1278
+ this.pos = target.add(vec3(sin(yaw) * r, sin(pitch) * distance, cos(yaw) * r));
1279
+ this.lookAt(target);
1280
+ }
1281
+
1282
+ /** Chase a target from an offset, easing toward it, and look at it
1283
+ * @param {Vector3} target
1284
+ * @param {Vector3} offset - Where to sit relative to the target
1285
+ * @param {number} [percent] - How far to move toward the spot each call, 1 snaps */
1286
+ follow(target, offset, percent=1)
1287
+ {
1288
+ this.pos = this.pos.lerp(target.add(offset), percent);
1289
+ this.lookAt(target);
1290
+ }
1291
+
1292
+ /** Line the 3D camera up with the 2D camera, called automatically when align2D is set
1293
+ * @param {number} [canvasHeight] - Defaults to the main canvas height */
1294
+ update2D(canvasHeight=mainCanvasSize.y)
1295
+ {
1296
+ const halfHeight = canvasHeight / 2 / cameraScale; // half visible height in world units
1297
+ const distance = halfHeight / tan(this.fov/2);
1298
+ // a zoomed out 2D camera sits a long way back, far enough to fall past the far plane and
1299
+ // clip the whole scene away, which looks like nothing rendering at all
1300
+ ASSERT(!canvasHeight || distance < this.far,
1301
+ 'align2D needs this camera distance to match the 2D view, raise camera.far past it', distance);
1302
+ this.orthographic &&= halfHeight * 2; // an orthographic camera stays orthographic and shows the same height
1303
+ this.pos = vec3(cameraPos.x, cameraPos.y, distance);
1304
+ this.rotation = vec3(0, 0, -cameraAngle); // 2D angles turn the other way
1305
+ }
1306
+ }
1307
+
1308
+ ///////////////////////////////////////////////////////////////////////////////
1309
+ // GL setup, shaders and the frame hooks
1310
+
1311
+ // the four attributes of a 36 byte vertex at the locations the shaders declare: location, size, type, normalize, byte offset
1312
+ const RENDER3D_ATTRIBS = [[0, 3, 5126, false, 0], [1, 3, 5126, false, 12], [2, 2, 5126, false, 24], [3, 4, 5121, true, 32]];
1313
+
1314
+ // the vertex shader, shared by the plugin's program and every Shader's
1315
+ // attributes: p position, n normal, t uv, c color, at fixed slots the depth shader also uses
1316
+ // uniforms: viewProj, lightViewProj; the model matrix, the normal matrix, the tint and the uv rect are vertex
1317
+ // attributes, see RENDER3D_VERTEX_INPUTS; L is the mesh's own uv for a Shader's localUV
1318
+ const RENDER3D_VERTEX_SOURCE =
1319
+ '#version 300 es\n' +
1320
+ 'precision highp float;' +
1321
+ 'uniform mat4 viewProj,lightViewProj;' +
1322
+ RENDER3D_VERTEX_INPUTS +
1323
+ 'out vec3 P,N;out vec2 T,L;out vec4 C,S;' +
1324
+ 'void main(){' +
1325
+ 'vec4 w=mat4(m0,m1,m2,m3)*vec4(p,1.);' +
1326
+ 'gl_Position=viewProj*w;' +
1327
+ 'P=w.xyz;' +
1328
+ 'N=mat3(n0,n1,n2)*n;' +
1329
+ 'T=uvRect.xy+t*uvRect.zw;' +
1330
+ 'L=t;' +
1331
+ 'C=c*tint;' +
1332
+ 'S=lightViewProj*w;' +
1333
+ '}';
1334
+
1335
+ // the names a Shader's snippet can use in 3D, over the plugin's own uniforms and varyings
1336
+ const RENDER3D_SNIPPET_NAMES =
1337
+ 'uniform float iTime;uniform vec3 iResolution;\n' +
1338
+ '#define iChannel0 tex\n' +
1339
+ '#define localUV L\n' +
1340
+ '#define worldPos P\n' +
1341
+ '#define worldNormal N\n' +
1342
+ '#define sunDirection (-lightDir.xyz)\n' +
1343
+ '#define sunColor lightColor.rgb\n' +
1344
+ '#define ambientColor ambientFog.rgb\n' +
1345
+ '#define lightCount extraLightCount\n' +
1346
+ '#define lights extraLights\n' +
1347
+ '#define lightColors extraLightColors\n';
1348
+
1349
+ // the fragment shader; given a Shader's snippet, its mainImage replaces the texture sample and all else is the same
1350
+ // uniforms: lightDir (xyz the way the sunlight travels, w = emissive, 1 or more skips the lighting),
1351
+ // lightColor (the sun's rgb, a = specular), ambientFog (rgb, a = fogEnd), fogColor (rgb, a = fogStart),
1352
+ // cameraPos, tex, shadowMap, shadowParams (x = shadows on, y = bias, z = blur step in texture space,
1353
+ // w = how the draw finishes: 1 opaque and alpha tested, 0 blended, -1 additive)
1354
+ function render3DFragmentSource(fragmentCode)
1355
+ {
1356
+ return '#version 300 es\n' +
1357
+ 'precision highp float;' +
1358
+ 'uniform vec4 lightDir,lightColor,ambientFog,fogColor,shadowParams;' +
1359
+ 'uniform vec4 extraLights[' + RENDER3D_MAX_LIGHTS + '],extraLightColors[' + RENDER3D_MAX_LIGHTS + '];' +
1360
+ 'uniform int extraLightCount;' +
1361
+ 'uniform vec3 cameraPos;' +
1362
+ 'uniform sampler2D tex;' +
1363
+ 'uniform highp sampler2DShadow shadowMap;' +
1364
+ 'in vec3 P,N;in vec2 T,L;in vec4 C,S;' +
1365
+ 'out vec4 o;' +
1366
+ // the sun shadow at this fragment, 0 to 1: the light's depth map with a 3x3 blur, outside the map is lit
1367
+ 'float shadow(){' +
1368
+ 'if(shadowParams.x<=0.)return 1.;' +
1369
+ 'vec3 q=S.xyz/S.w*.5+.5;' +
1370
+ 'if(any(greaterThanEqual(abs(q-.5),vec3(.5))))return 1.;' +
1371
+ 'q.z-=shadowParams.y;' +
1372
+ 'float s=0.;' +
1373
+ 'for(int x=-1;x<=1;++x)for(int y=-1;y<=1;++y)' +
1374
+ 's+=texture(shadowMap,vec3(q.xy+vec2(x,y)*shadowParams.z,q.z));' +
1375
+ 'return s/9.;}' +
1376
+ (fragmentCode ? RENDER3D_SNIPPET_NAMES + fragmentCode + '\n' : '') +
1377
+ 'void main(){' +
1378
+ (fragmentCode ? 'vec4 t;mainImage(t,T);' : 'vec4 t=texture(tex,T);') +
1379
+ 'if(shadowParams.w>0.&&t.a<.5)discard;' + // an opaque draw drops see through texels, as the shadow map does
1380
+ 'vec4 c=C*t;' +
1381
+ 'float e=lightDir.w;' +
1382
+ 'if(e<1.){' +
1383
+ 'vec3 n=dot(N,N)>0.?normalize(N):vec3(0,1,0);' +
1384
+ 'if(!gl_FrontFacing)n=-n;' + // only a double sided mesh shows a back face, light it on the side that is seen
1385
+ 'float nl=dot(n,-lightDir.xyz);' +
1386
+ 'float s=shadow();' +
1387
+ 'vec3 l=ambientFog.rgb+lightColor.rgb*max(nl,0.)*s;' +
1388
+ // the Light3D objects, diffuse only: a point light falls off with distance, a directional one does not and
1389
+ // carries the direction toward it in xyz, marked by a negative radius
1390
+ 'for(int i=0;i<' + RENDER3D_MAX_LIGHTS + ';++i){' +
1391
+ 'if(i>=extraLightCount)break;' +
1392
+ 'vec4 L=extraLights[i];' +
1393
+ 'bool directional=L.w<0.;' +
1394
+ 'vec3 v=directional?L.xyz:L.xyz-P;' +
1395
+ 'float d=length(v);' +
1396
+ 'float a=directional?1.:max(0.,1.-d/L.w);' +
1397
+ 'l+=extraLightColors[i].rgb*extraLightColors[i].a*a*a*max(0.,dot(n,v/max(d,1e-6)));' +
1398
+ '}' +
1399
+ 'c.rgb*=l*(1.-e)+e;' + // lit, blended toward its own color by how emissive it is
1400
+ // specular: only where the light hits, skipped entirely when the strength is zero
1401
+ 'if(lightColor.a>0.){' +
1402
+ 'vec3 v=normalize(cameraPos-P);' +
1403
+ 'vec3 r=reflect(lightDir.xyz,n);' +
1404
+ 'c.rgb+=lightColor.rgb*pow(max(dot(r,v),0.),16.)*lightColor.a*step(0.,nl)*s*(1.-e);' +
1405
+ '}}else c.rgb*=e;' + // fully emissive: its own color, or brighter, with no lighting to work out
1406
+ 'if(ambientFog.a>0.){' +
1407
+ 'float z=distance(cameraPos,P);' +
1408
+ 'c.rgb=mix(c.rgb,shadowParams.w<0.?vec3(0):fogColor.rgb,smoothstep(fogColor.a,ambientFog.a,z));' +
1409
+ '}' +
1410
+ 'o=vec4(c.rgb,shadowParams.w>0.?1.:c.a);' + // an opaque draw stays opaque whatever the tint alpha says
1411
+ '}';
1412
+ }
1413
+
1414
+ // a Shader's 3D program, compiled the first time a draw needs it
1415
+ function render3DShaderProgram(shader)
1416
+ {
1417
+ ASSERT(shader instanceof Shader, 'render3D.shader must be a Shader, not the snippet itself');
1418
+ return shader.program3D ||= glCreateProgram(RENDER3D_VERTEX_SOURCE, render3DFragmentSource(shader.fragmentCode));
1419
+ }
1420
+
1421
+ // make a program current for the pass and send it the pass uniforms: the matrices, the camera and the lights,
1422
+ // plus the time and canvas size for a Shader's program; the per draw uniform cache starts over
1423
+ function render3DUseProgram(program)
1424
+ {
1425
+ const gl = glContext, r = render3D;
1426
+ gl.useProgram(r.currentProgram = program);
1427
+ r.uniformValues = {};
1428
+ gl.uniformMatrix4fv(render3DUniform('viewProj'), false, r.viewProjection.m);
1429
+ gl.uniformMatrix4fv(render3DUniform('lightViewProj'), false, r.shadowMatrix.m);
1430
+ gl.uniform1i(render3DUniform('tex'), 0);
1431
+ gl.uniform1i(render3DUniform('shadowMap'), 1);
1432
+ const c = r.camera.pos;
1433
+ gl.uniform3f(render3DUniform('cameraPos'), c.x, c.y, c.z);
1434
+ gl.uniform1i(render3DUniform('extraLightCount'), r.lightCount);
1435
+ if (r.lightCount)
1436
+ {
1437
+ gl.uniform4fv(render3DUniform('extraLights'), r.lightPositions, 0, r.lightCount * 4);
1438
+ gl.uniform4fv(render3DUniform('extraLightColors'), r.lightColors, 0, r.lightCount * 4);
1439
+ }
1440
+ if (program !== r.program)
1441
+ {
1442
+ gl.uniform1f(render3DUniform('iTime'), time);
1443
+ gl.uniform3f(render3DUniform('iResolution'), glCanvas.width, glCanvas.height, 1);
1444
+ }
1445
+ }
1446
+
1447
+ function render3DInitGL()
1448
+ {
1449
+ if (headlessMode) return;
1450
+ if (!glEnable || !glContext)
1451
+ {
1452
+ console.warn('Render3DPlugin: WebGL not enabled, construct the plugin in gameInit with glEnable set');
1453
+ return;
1454
+ }
1455
+ const gl = glContext, r = render3D;
1456
+ r.uniforms = new Map;
1457
+ r.uniformValues = {};
1458
+ r.attribValues = []; // a fresh context has its own attribute defaults, so nothing sent before it counts
1459
+
1460
+ // the shader, see RENDER3D_VERTEX_SOURCE and render3DFragmentSource
1461
+ r.program = glCreateProgram(RENDER3D_VERTEX_SOURCE, render3DFragmentSource());
1462
+
1463
+ // the depth only shader for the shadow map, same vertex layout; see through pixels cast nothing,
1464
+ // so sprites and cut out textures cast their outline
1465
+ r.shadowShader = glCreateProgram(
1466
+ '#version 300 es\n' +
1467
+ 'precision highp float;' +
1468
+ 'uniform mat4 viewProj;' +
1469
+ RENDER3D_VERTEX_INPUTS +
1470
+ 'out vec2 T;' +
1471
+ 'void main(){T=uvRect.xy+t*uvRect.zw;gl_Position=viewProj*mat4(m0,m1,m2,m3)*vec4(p,1.);}'
1472
+ ,
1473
+ '#version 300 es\n' +
1474
+ 'precision highp float;' +
1475
+ 'uniform sampler2D tex;' +
1476
+ 'in vec2 T;' +
1477
+ 'void main(){if(texture(tex,T).a<.5)discard;}'
1478
+ );
1479
+
1480
+ // the vertex array object with the attributes enabled once, pointers are set per buffer by render3DBindVertexBuffer
1481
+ r.vao = gl.createVertexArray();
1482
+ gl.bindVertexArray(r.vao);
1483
+ for (const [location] of RENDER3D_ATTRIBS)
1484
+ gl.enableVertexAttribArray(location);
1485
+ for (const [location] of RENDER3D_INSTANCE_ATTRIBS)
1486
+ gl.vertexAttribDivisor(location, 1); // one value per instance whenever a batch turns these arrays on
1487
+
1488
+ // the stream buffer
1489
+ r.streamBuffer = gl.createBuffer();
1490
+ gl.bindBuffer(gl.ARRAY_BUFFER, r.streamBuffer);
1491
+ gl.bufferData(gl.ARRAY_BUFFER, r.streamData.byteLength, gl.DYNAMIC_DRAW);
1492
+ r.streamCount = 0;
1493
+ r.instanceBuffers = [gl.createBuffer(), gl.createBuffer(), gl.createBuffer()];
1494
+
1495
+ // white texture for untextured draws, and a one texel shadow map that keeps the shadow sampler valid until shadows are on
1496
+ r.whiteTexture = glCreateTexture();
1497
+ r.mipmapped = new WeakSet;
1498
+
1499
+ r.samplers = [];
1500
+ r.samplerKey = undefined;
1501
+ render3DUpdateShadowMap(1);
1502
+
1503
+ // hand the engine back its own buffer and vertex array, in that order so a pending 2D batch flushes right
1504
+ gl.bindBuffer(gl.ARRAY_BUFFER, glArrayBuffer);
1505
+ glSetInstancedMode(true);
1506
+ }
1507
+
1508
+ function render3DContextLost()
1509
+ {
1510
+ const r = render3D;
1511
+ r.program = r.currentProgram = r.shadowShader = r.vao = r.streamBuffer = r.whiteTexture = undefined;
1512
+ for (const shader of glShaderObjects)
1513
+ shader.program3D = undefined; // compiled again by the next draw
1514
+ r.lightCount = 0;
1515
+ r.instanceBuffers = r.samplers = [];
1516
+ r.samplerKey = undefined;
1517
+ render3DClearInstances();
1518
+ r.shadowFramebuffer = r.shadowTexture = undefined;
1519
+ r.shadowTextureSize = 0;
1520
+ r.streamCount = 0;
1521
+ ++r.contextGeneration; // every uploaded mesh is stale now, the soft dot is a TextureInfo the engine restores
1522
+ }
1523
+
1524
+ function render3DContextRestored()
1525
+ {
1526
+ render3DInitGL();
1527
+ }
1528
+
1529
+ // a uniform location, looked up once per program
1530
+ function render3DUniform(name, program=render3D.currentProgram)
1531
+ {
1532
+ const u = render3D.uniforms;
1533
+ let cache = u.get(program);
1534
+ cache || u.set(program, cache = {});
1535
+ return cache[name] ??= glContext.getUniformLocation(program, name);
1536
+ }
1537
+
1538
+ // the model matrix, its normal matrix, the tint and the uv rect as constant attributes for one draw
1539
+ const render3DNormalScratch = new Float32Array(9);
1540
+ function render3DDrawAttribs(m, tint, uvRect)
1541
+ {
1542
+ const gl = glContext;
1543
+ gl.vertexAttrib4f(4, m[0], m[1], m[2], m[3]);
1544
+ gl.vertexAttrib4f(5, m[4], m[5], m[6], m[7]);
1545
+ gl.vertexAttrib4f(6, m[8], m[9], m[10], m[11]);
1546
+ gl.vertexAttrib4f(7, m[12], m[13], m[14], m[15]);
1547
+ if (!render3D.shadowPass) // the shadow map has no lighting
1548
+ {
1549
+ const n = render3DNormalMatrix3(m, render3DNormalScratch, 0);
1550
+ gl.vertexAttrib3f(8, n[0], n[1], n[2]);
1551
+ gl.vertexAttrib3f(9, n[3], n[4], n[5]);
1552
+ gl.vertexAttrib3f(10, n[6], n[7], n[8]);
1553
+ }
1554
+ render3DAttrib4f(11, tint.r, tint.g, tint.b, tint.a);
1555
+ render3DAttrib4f(12, uvRect.x, uvRect.y, uvRect.w, uvRect.h);
1556
+ }
1557
+
1558
+ // set a constant vec4 attribute only when its value changed since the last time
1559
+ function render3DAttrib4f(location, x, y, z, w)
1560
+ {
1561
+ const values = render3D.attribValues, last = values[location];
1562
+ if (last && last[0] === x && last[1] === y && last[2] === z && last[3] === w)
1563
+ return;
1564
+ values[location] = [x, y, z, w];
1565
+ glContext.vertexAttrib4f(location, x, y, z, w);
1566
+ }
1567
+
1568
+ // write the 3x3 matrix that keeps normals pointing out when the model matrix scales unevenly, the inverse transpose
1569
+ // of its top left 3x3 by cofactors; a flat model with no inverse keeps its own axes
1570
+ function render3DNormalMatrix3(m, out, offset)
1571
+ {
1572
+ 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];
1573
+ const c00 = e*i - h*f, c01 = h*c - b*i, c02 = b*f - e*c;
1574
+ const det = a*c00 + d*c01 + g*c02;
1575
+ if (abs(det) < 1e-12)
1576
+ {
1577
+ out[offset] = a; out[offset+1] = b; out[offset+2] = c;
1578
+ out[offset+3] = d; out[offset+4] = e; out[offset+5] = f;
1579
+ out[offset+6] = g; out[offset+7] = h; out[offset+8] = i;
1580
+ return out;
1581
+ }
1582
+ const s = 1 / det;
1583
+ out[offset] = c00 * s; out[offset+1] = (g*f - d*i) * s; out[offset+2] = (d*h - g*e) * s;
1584
+ out[offset+3] = c01 * s; out[offset+4] = (a*i - g*c) * s; out[offset+5] = (g*b - a*h) * s;
1585
+ out[offset+6] = c02 * s; out[offset+7] = (d*c - a*f) * s; out[offset+8] = (a*e - d*b) * s;
1586
+ return out;
1587
+ }
1588
+
1589
+ // textures in 3D shrink into the distance far more than sprites do, so the pass samples them through their mipmaps;
1590
+ // a sampler sets the filtering for the 3D pass only and leaves the engine's textures as they are for 2D, one for
1591
+ // clamped textures and one for wrapping ones, rebuilt when the settings change
1592
+ function render3DUpdateSamplers()
1593
+ {
1594
+ const gl = glContext, r = render3D, key = tilesPixelated + ' ' + r.anisotropy;
1595
+ if (r.samplerKey === key) return;
1596
+ r.samplerKey = key;
1597
+ for (const sampler of r.samplers)
1598
+ gl.deleteSampler(sampler); // the set being replaced, a lost context empties this first
1599
+ const anisotropy = gl.getExtension('EXT_texture_filter_anisotropic');
1600
+ // four samplers: clamped and wrapping, each smooth or hard edged
1601
+ r.samplers = [false, true].flatMap(pixelated=> [gl.CLAMP_TO_EDGE, gl.REPEAT].map(wrap=>
1602
+ {
1603
+ const sampler = gl.createSampler();
1604
+ const sharp = pixelated || tilesPixelated;
1605
+ gl.samplerParameteri(sampler, gl.TEXTURE_MAG_FILTER, sharp ? gl.NEAREST : gl.LINEAR);
1606
+ gl.samplerParameteri(sampler, gl.TEXTURE_MIN_FILTER, pixelated ? gl.NEAREST
1607
+ : tilesPixelated ? gl.NEAREST_MIPMAP_LINEAR : gl.LINEAR_MIPMAP_LINEAR);
1608
+ gl.samplerParameteri(sampler, gl.TEXTURE_WRAP_S, wrap);
1609
+ gl.samplerParameteri(sampler, gl.TEXTURE_WRAP_T, wrap);
1610
+ if (anisotropy && !pixelated)
1611
+ {
1612
+ const most = gl.getParameter(anisotropy.MAX_TEXTURE_MAX_ANISOTROPY_EXT);
1613
+ gl.samplerParameterf(sampler, anisotropy.TEXTURE_MAX_ANISOTROPY_EXT, clamp(r.anisotropy, 1, most));
1614
+ }
1615
+ return sampler;
1616
+ }));
1617
+ }
1618
+
1619
+ // bind the texture of a tile or texture, white when there is none or it is not loaded, with the 3D sampler that
1620
+ // matches its wrap mode; the first time a texture is used in 3D it gets its mipmaps
1621
+ function render3DBindTexture(tileInfo, state=render3D)
1622
+ {
1623
+ const gl = glContext, r = render3D;
1624
+ const textureInfo = tileInfo instanceof TileInfo ? tileInfo.textureInfo : tileInfo;
1625
+ const texture = textureInfo?.glTexture || r.whiteTexture;
1626
+ gl.bindTexture(gl.TEXTURE_2D, texture);
1627
+ if (!r.mipmaps && !state.pixelated)
1628
+ return gl.bindSampler(0, null); // the texture's own filtering, as in 2D
1629
+ gl.bindSampler(0, r.samplers[(textureInfo?.wrap ? 1 : 0) + (state.pixelated ? 2 : 0)]);
1630
+ if (!state.pixelated && !r.mipmapped.has(texture)) // a hard edged draw never reads them
1631
+ {
1632
+ r.mipmapped.add(texture);
1633
+ gl.generateMipmap(gl.TEXTURE_2D);
1634
+ }
1635
+ }
1636
+
1637
+ // send a vec4 uniform of the main shader only when its value changed since the last send
1638
+ function render3DUniform4f(name, x, y, z, w)
1639
+ {
1640
+ const values = render3D.uniformValues, last = values[name];
1641
+ if (last && last[0] === x && last[1] === y && last[2] === z && last[3] === w)
1642
+ return;
1643
+ values[name] = [x, y, z, w];
1644
+ glContext.uniform4f(render3DUniform(name), x, y, z, w);
1645
+ }
1646
+
1647
+ // bind a vertex buffer and point the attributes at it
1648
+ function render3DBindVertexBuffer(buffer)
1649
+ {
1650
+ const gl = glContext;
1651
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
1652
+ for (const a of RENDER3D_ATTRIBS)
1653
+ gl.vertexAttribPointer(a[0], a[1], a[2], a[3], RENDER3D_VERTEX_BYTES, a[4]);
1654
+ }
1655
+
1656
+ // where a tile sits in its texture, pulled in slightly at the edges so neighbors do not bleed in
1657
+ // this returns one shared object, so read it before calling again
1658
+ const render3DTileUVRect = {x:0, y:0, w:1, h:1};
1659
+ function render3DGetTileUVs(tileInfo)
1660
+ {
1661
+ if (!(tileInfo instanceof TileInfo))
1662
+ return RENDER3D_FULL_UV_RECT;
1663
+ const inv = tileInfo.textureInfo.sizeInverse, rect = render3DTileUVRect;
1664
+ const bleedX = inv.x * tileInfo.bleed, bleedY = inv.y * tileInfo.bleed;
1665
+ rect.x = tileInfo.pos.x * inv.x + bleedX;
1666
+ rect.y = tileInfo.pos.y * inv.y + bleedY;
1667
+ rect.w = tileInfo.size.x * inv.x - 2*bleedX;
1668
+ rect.h = tileInfo.size.y * inv.y - 2*bleedY;
1669
+ return rect;
1670
+ }
1671
+
1672
+ // set the per draw uniforms and gl state for a draw, in the shadow pass only the model matrix of the depth shader
1673
+ // tileInfo may be a TileInfo, a TextureInfo, or undefined for the white texture
1674
+ // state is the plugin's current fields, or the captured state of a stream batch
1675
+ function render3DSetDrawUniforms(matrix, tileInfo, tint, uvRect, state=render3D)
1676
+ {
1677
+ const gl = glContext, r = render3D;
1678
+
1679
+ // the per draw values are constant vertex attributes, a batch turns on a per instance array over them
1680
+ uvRect ||= render3DGetTileUVs(tileInfo);
1681
+ render3DDrawAttribs(matrix.m, tint, uvRect);
1682
+ render3DBindTexture(tileInfo, state);
1683
+ if (r.shadowPass) return; // the shadow map needs nothing else
1684
+
1685
+ // the program: a Shader's own, compiled by its first draw, or the plugin's; switching sends the pass uniforms
1686
+ const program = state.shader ? render3DShaderProgram(state.shader) : r.program;
1687
+ program === r.currentProgram || render3DUseProgram(program);
1688
+
1689
+ // blending, matches the engine's 2D blend functions
1690
+ if (state.blend)
1691
+ {
1692
+ gl.enable(gl.BLEND);
1693
+ const destBlend = state.additive ? gl.ONE : gl.ONE_MINUS_SRC_ALPHA;
1694
+ gl.blendFuncSeparate(gl.SRC_ALPHA, destBlend, gl.ONE, destBlend);
1695
+ }
1696
+ else
1697
+ gl.disable(gl.BLEND);
1698
+
1699
+ // depth and culling
1700
+ state.depthTest ? gl.enable(gl.DEPTH_TEST) : gl.disable(gl.DEPTH_TEST);
1701
+ gl.depthMask(state.depthWrite);
1702
+ state.cullBackFaces ? gl.enable(gl.CULL_FACE) : gl.disable(gl.CULL_FACE);
1703
+ gl.frontFace(state.mirrored ? gl.CCW : gl.CW); // the pass's strips read clockwise, a mirror turns that around
1704
+
1705
+ // lights, fog and shadows are scene state read at draw time, sent only when they change
1706
+ // the shader takes the way the sunlight travels, away from the sun
1707
+ const s = r.sunDirection, sl = -(s.length() || 1), lc = r.sunColor, ac = r.ambientColor, fc = r.fogColor || canvasClearColor;
1708
+ render3DUniform4f('lightDir', s.x / sl, s.y / sl, s.z / sl, state.lighting ? state.emissive : 1);
1709
+ render3DUniform4f('lightColor', lc.r, lc.g, lc.b, state.specular);
1710
+ render3DUniform4f('ambientFog', ac.r, ac.g, ac.b, r.fogEnd);
1711
+ render3DUniform4f('fogColor', fc.r, fc.g, fc.b, r.fogStart);
1712
+ // how the fragment shader finishes: 1 drops see through texels and keeps the draw opaque,
1713
+ // 0 blends them away instead, and -1 is additive, which has to fade into fog differently
1714
+ const blendMode = state.blend ? (state.additive ? -1 : 0) : 1;
1715
+ render3DUniform4f('shadowParams', r.shadows && r.passIsDefault && state.receiveShadow ? 1 : 0, r.shadowBias, r.shadowSoftness / r.shadowTextureSize, blendMode);
1716
+ }
1717
+
1718
+ // the six flat sides of the camera's visible box, each as [x, y, z, w] facing inward
1719
+ // a point is inside when x*px + y*py + z*pz + w is zero or more
1720
+ function render3DFrustumPlanes(matrix)
1721
+ {
1722
+ const m = matrix.m, planes = [];
1723
+ for (let i = 0; i < 3; ++i)
1724
+ for (const sign of [1, -1])
1725
+ {
1726
+ 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]];
1727
+ const l = hypot(p[0], p[1], p[2]) || 1;
1728
+ planes.push(p.map(v=> v / l));
1729
+ }
1730
+ return planes;
1731
+ }
1732
+
1733
+ // the preRender hook, before gameRender: the layer under the 2D scene
1734
+ function render3DPreRender()
1735
+ {
1736
+ const r = render3D;
1737
+ r.updateMatrices();
1738
+ r.shadowMapDrawn = false;
1739
+ render3DRenderPass(false);
1740
+ }
1741
+
1742
+ // the render hook, after gameRenderPost: the layer on top of the 2D scene
1743
+ function render3DRender()
1744
+ {
1745
+ render3DRenderPass(true);
1746
+ }
1747
+
1748
+ // 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
1749
+ // the layer matching render3D.renderAfter2D is the default and always runs, the other only when an object asks for it
1750
+ function render3DRenderPass(after2D)
1751
+ {
1752
+ const gl = glContext, r = render3D;
1753
+ if (!r.program) return; // headless, gl disabled, or context lost
1754
+ render3DUpdateSamplers();
1755
+ ASSERT(!r.fogEnd || r.fogStart < r.fogEnd, 'fogStart must be less than fogEnd');
1756
+ ASSERT(!glRenderTarget, 'the 3D pass needs the canvas depth buffer, it can not draw into a render target');
1757
+ const isDefault = after2D === !!r.renderAfter2D, objects = render3DLayerObjects(after2D);
1758
+ if (!isDefault && !objects.length) return;
1759
+ r.passIsDefault = isDefault;
1760
+ after2D && glFlush(); // the 2D sprites drawn so far go under this layer
1761
+
1762
+ // a previous frame that threw must not leave anything pending
1763
+ r.streamCount = 0;
1764
+ r.capture = r.transparentQueue = undefined;
1765
+ render3DClearInstances();
1766
+
1767
+ // take over the gl state
1768
+ gl.bindVertexArray(r.vao);
1769
+ // the leading repeat on every strip shifts the triangles by one, which flips
1770
+ // which way they read, so tell WebGL that clockwise is the front here
1771
+ gl.frontFace(gl.CW);
1772
+ gl.activeTexture(gl.TEXTURE0);
1773
+ gl.depthMask(true);
1774
+ gl.clear(gl.DEPTH_BUFFER_BIT);
1775
+
1776
+ // the Light3D objects, a directional one sends the direction toward it, from the origin, and a negative radius
1777
+ const lights = render3DCollectLights();
1778
+ r.lightCount = lights.length;
1779
+ const positions = r.lightPositions, colors = r.lightColors;
1780
+ lights.forEach((light, i)=>
1781
+ {
1782
+ const p = light.directional ? light.getWorldPos3D().normalize() : light.getWorldPos3D();
1783
+ ASSERT(!light.directional || p.lengthSquared(), 'a directional light shines from its position toward the origin, so it cannot sit on the origin');
1784
+ const c = light.color, k = i * 4;
1785
+ positions[k] = p.x, positions[k+1] = p.y, positions[k+2] = p.z;
1786
+ positions[k+3] = light.directional ? -1 : max(0, light.radius); // a negative radius marks a direction
1787
+ colors[k] = c.r, colors[k+1] = c.g, colors[k+2] = c.b, colors[k+3] = c.a * light.intensity;
1788
+ });
1789
+
1790
+ r.isRendering = true;
1791
+ try
1792
+ {
1793
+ // the shadow map from the light once a frame, then the stages sample it
1794
+ if (r.shadows && !r.shadowMapDrawn)
1795
+ {
1796
+ render3DRenderShadowMap();
1797
+ r.shadowMapDrawn = true;
1798
+ }
1799
+ render3DUseProgram(r.program); // after the shadow map, so the light matrix it sends is this frame's
1800
+ r.renderStages(objects, isDefault);
1801
+ }
1802
+ finally
1803
+ {
1804
+ // hand the state back to the engine's 2D batching, even when a draw threw
1805
+ r.isRendering = false;
1806
+ r.currentProgram = undefined; // the engine's 2D program takes over below
1807
+ r.streamCount = 0;
1808
+ r.capture = r.transparentQueue = undefined;
1809
+ gl.disable(gl.DEPTH_TEST);
1810
+ gl.disable(gl.CULL_FACE);
1811
+ gl.depthMask(true);
1812
+ gl.frontFace(gl.CCW);
1813
+ gl.bindSampler(0, null); // back to the textures' own filtering for 2D
1814
+ if (glActiveTexture)
1815
+ gl.bindTexture(gl.TEXTURE_2D, glActiveTexture);
1816
+ // ARRAY_BUFFER is not part of VAO state in WebGL2, so bindVertexArray alone would not restore it
1817
+ gl.bindBuffer(gl.ARRAY_BUFFER, glArrayBuffer);
1818
+ glSetInstancedMode(true);
1819
+ }
1820
+ }
1821
+
1822
+ // create the shadow map depth texture and framebuffer at a size, or keep them when the size matches
1823
+ function render3DUpdateShadowMap(size)
1824
+ {
1825
+ const gl = glContext, r = render3D;
1826
+ ASSERT(size > 0, 'shadowMapSize must be positive');
1827
+ if (r.shadowTexture && r.shadowTextureSize === size) return;
1828
+ r.shadowTexture && gl.deleteTexture(r.shadowTexture);
1829
+ r.shadowFramebuffer && gl.deleteFramebuffer(r.shadowFramebuffer);
1830
+ const texture = r.shadowTexture = gl.createTexture();
1831
+ gl.activeTexture(gl.TEXTURE1);
1832
+ gl.bindTexture(gl.TEXTURE_2D, texture);
1833
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.DEPTH_COMPONENT24, size, size, 0, gl.DEPTH_COMPONENT, gl.UNSIGNED_INT, null);
1834
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); // smooth filtering softens shadow edges for free
1835
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
1836
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
1837
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
1838
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_COMPARE_MODE, gl.COMPARE_REF_TO_TEXTURE);
1839
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_COMPARE_FUNC, gl.LEQUAL);
1840
+ gl.activeTexture(gl.TEXTURE0);
1841
+ const framebuffer = r.shadowFramebuffer = gl.createFramebuffer();
1842
+ gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
1843
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, texture, 0);
1844
+ gl.drawBuffers([gl.NONE]); // depth only
1845
+ gl.readBuffer(gl.NONE);
1846
+ ASSERT(gl.checkFramebufferStatus(gl.FRAMEBUFFER) == gl.FRAMEBUFFER_COMPLETE, 'shadow map framebuffer is incomplete, try a smaller shadowMapSize');
1847
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
1848
+ r.shadowTextureSize = size;
1849
+ }
1850
+
1851
+ // draw the lit opaque casters from the light into the shadow map with the depth only shader
1852
+ function render3DRenderShadowMap()
1853
+ {
1854
+ const gl = glContext, r = render3D;
1855
+ render3DUpdateShadowMap(r.shadowMapSize | 0);
1856
+ r.updateShadowMatrix();
1857
+
1858
+ // the map can not be read while it is drawn
1859
+ gl.activeTexture(gl.TEXTURE1);
1860
+ gl.bindTexture(gl.TEXTURE_2D, null);
1861
+ gl.activeTexture(gl.TEXTURE0);
1862
+ gl.bindFramebuffer(gl.FRAMEBUFFER, r.shadowFramebuffer);
1863
+ gl.viewport(0, 0, r.shadowTextureSize, r.shadowTextureSize);
1864
+ gl.clear(gl.DEPTH_BUFFER_BIT);
1865
+ gl.useProgram(r.shadowShader);
1866
+ gl.uniformMatrix4fv(render3DUniform('viewProj', r.shadowShader), false, r.shadowMatrix.m);
1867
+ gl.enable(gl.DEPTH_TEST);
1868
+ gl.depthMask(true);
1869
+ gl.disable(gl.BLEND);
1870
+ gl.disable(gl.CULL_FACE);
1871
+
1872
+ r.shadowPass = true;
1873
+ try
1874
+ {
1875
+ // see through objects cast only when textured, their alpha cuts the shadow out
1876
+ const casters = render3DLayerObjects(!!r.renderAfter2D).filter(o=> o.castShadow && !o.additive && (!o.transparent || o.tileInfo));
1877
+ render3DDrawObjects(casters);
1878
+ r.onRenderOpaque?.();
1879
+ r.flush();
1880
+ render3DFlushInstances();
1881
+ }
1882
+ finally
1883
+ {
1884
+ // back to the frame with the map ready to sample
1885
+ r.shadowPass = false;
1886
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
1887
+
1888
+ // backing store size, mainCanvasSize is css pixels
1889
+ gl.viewport(0, 0, glCanvas.width, glCanvas.height);
1890
+ gl.activeTexture(gl.TEXTURE1);
1891
+ gl.bindTexture(gl.TEXTURE_2D, r.shadowTexture);
1892
+ gl.activeTexture(gl.TEXTURE0);
1893
+ }
1894
+ }
1895
+
1896
+ ///////////////////////////////////////////////////////////////////////////////
1897
+ // Strips: every strip repeats its first point once at the start and its last
1898
+ // point once at the end. Those repeats make flat triangles with no area, which
1899
+ // are invisible, and they let one strip run straight into the next.
1900
+ // An odd count gets one more repeat at the end. Triangles in a strip alternate
1901
+ // which way they face, so keeping the count even keeps every strip facing out.
1902
+
1903
+ // make room in the stream for a strip of count vertices under the current state and texture, flushing a batch that
1904
+ // differs first; returns the uv rect to map the vertices with, or undefined when nothing can be drawn
1905
+ function render3DBeginStrip(count, tileInfo)
1906
+ {
1907
+ const r = render3D;
1908
+ if (!render3DCanDraw()) return;
1909
+ if (r.shadowPass && !r.lighting) return; // unlit things cast no shadow
1910
+ ASSERT(count <= RENDER3D_MAX_STREAM_VERTS, 'strip is too large for the stream, bake it into a mesh');
1911
+ if (count > RENDER3D_MAX_STREAM_VERTS) return;
1912
+ const textureInfo = tileInfo instanceof TileInfo ? tileInfo.textureInfo : tileInfo;
1913
+ if (r.streamCount && (textureInfo !== r.streamTileInfo || render3DStateChanged(r.streamState)
1914
+ || r.streamCount + count > RENDER3D_MAX_STREAM_VERTS))
1915
+ r.flush();
1916
+ if (!r.streamCount)
1917
+ r.streamState = render3DCaptureBatchState();
1918
+ r.streamTileInfo = textureInfo;
1919
+ return render3DGetTileUVs(tileInfo);
1920
+ }
1921
+
1922
+ // the four corners of a camera facing quad in strip order
1923
+ function render3DBillboardCorners(pos, size, angle, upright)
1924
+ {
1925
+ // an upright quad stands on world up and only turns to face the camera
1926
+ let r = render3D.cameraRight, u = render3D.cameraUp;
1927
+ if (upright)
1928
+ {
1929
+ const flat = vec3(r.x, 0, r.z);
1930
+ r = flat.lengthSquared() ? flat.normalize() : vec3(1, 0, 0); // a rolled camera has no flat right
1931
+ u = RENDER3D_DEFAULT_NORMAL;
1932
+ }
1933
+ const c = cos(angle), s = sin(angle), w = size.x / 2, h = size.y / 2;
1934
+ 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;
1935
+ 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;
1936
+ return [
1937
+ 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),
1938
+ 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)];
1939
+ }
1940
+
1941
+ // how many vertices a strip of n points takes with its repeats, and which point vertex k of it is
1942
+ function render3DStripCount(n) { return n + 2 + (n & 1); }
1943
+ function render3DStripIndex(k, n) { return k < 1 ? 0 : k <= n ? k - 1 : n - 1; }
1944
+
1945
+ // walk a strip's vertices with the repeats applied, calling back with (point, normal, uv, color)
1946
+ // normals, uvs and colors may be one value for all points, an array per point, or undefined
1947
+ function render3DForEachStripVertex(points, normals, uvs, colors, callback)
1948
+ {
1949
+ ASSERT(isArray(points) && points.length > 2, 'strip needs at least 3 points');
1950
+ const n = points.length, count = render3DStripCount(n);
1951
+ const normalArray = isArray(normals), uvArray = isArray(uvs), colorArray = isArray(colors);
1952
+ for (let k = 0; k < count; ++k)
1953
+ {
1954
+ const i = render3DStripIndex(k, n);
1955
+ callback(points[i],
1956
+ normalArray ? normals[i] : normals || RENDER3D_DEFAULT_NORMAL,
1957
+ uvArray ? uvs[i] : uvs || RENDER3D_DEFAULT_UV,
1958
+ colorArray ? colors[i] : colors || WHITE);
1959
+ }
1960
+ }
1961
+
1962
+ // write one vertex into a packed buffer at float index j
1963
+ function render3DWriteVertex(floats, ints, j, p, n, u, v, rgba)
1964
+ {
1965
+ floats[j] = p.x; floats[j+1] = p.y; floats[j+2] = p.z;
1966
+ floats[j+3] = n.x; floats[j+4] = n.y; floats[j+5] = n.z;
1967
+ floats[j+6] = u; floats[j+7] = v;
1968
+ ints[j+8] = rgba;
1969
+ }
1970
+
1971
+ // reorder a convex polygon's points, counter clockwise from outside, into one triangle strip
1972
+ function render3DPolygonStrip(points)
1973
+ {
1974
+ const strip = [points[0]];
1975
+ for (let i = 1, j = points.length - 1; i <= j; ++i, --j)
1976
+ {
1977
+ strip.push(points[i]);
1978
+ if (i !== j)
1979
+ strip.push(points[j]);
1980
+ }
1981
+ return strip;
1982
+ }
1983
+
1984
+ ///////////////////////////////////////////////////////////////////////////////
1985
+
1986
+ // frees the GPU buffer of a mesh that is garbage collected without dispose, some time after it goes; it holds the
1987
+ // buffer and its context, never the mesh, or the mesh could not be collected, and dispose unregisters the mesh
1988
+ const render3DMeshBuffers = typeof FinalizationRegistry == 'undefined' ? undefined :
1989
+ new FinalizationRegistry(({buffer, generation})=>
1990
+ generation === render3D?.contextGeneration && glContext?.deleteBuffer(buffer));
1991
+
1992
+ /**
1993
+ * Mesh - A triangle strip with positions, normals, uvs and colors, uploaded once and drawn by matrix
1994
+ * - Build with addStrip, addQuad, combine or the shape builders, then render each frame
1995
+ * - Its back faces are skipped unless doubleSided is set, which the open builders like buildGrid do for you
1996
+ * - The GPU buffer is created lazily on first render and dropped by dispose, or freed once the mesh is garbage
1997
+ * collected, so dispose is only needed to free it right away, like for a mesh rebuilt often
1998
+ * @memberof Render3D
1999
+ * @example
2000
+ * const mesh = buildLathe([[0, -1], [1, 0], [0, 1]], 4); // octahedron
2001
+ * mesh.render(buildMatrix(vec3(0, 1, 0)), undefined, RED);
2002
+ */
2003
+ class Mesh
2004
+ {
2005
+ /** Create an empty mesh */
2006
+ constructor()
2007
+ {
2008
+ /** @property {Array<Vector3>} - Vertex positions in strip order
2009
+ * @type {Array<Vector3>} */
2010
+ this.points = [];
2011
+ /** @property {Array<Vector3>} - Vertex normals
2012
+ * @type {Array<Vector3>} */
2013
+ this.normals = [];
2014
+ /** @property {Array<Vector2>} - Vertex texture coords, 0-1 across the tile
2015
+ * @type {Array<Vector2>} */
2016
+ this.uvs = [];
2017
+ /** @property {Array<Color>} - Vertex colors
2018
+ * @type {Array<Color>} */
2019
+ this.colors = [];
2020
+ /** @property {WebGLBuffer|undefined} - GPU buffer, created by upload
2021
+ * @type {WebGLBuffer|undefined} */
2022
+ this.buffer = undefined;
2023
+ /** @property {number} - Vertices in the GPU buffer */
2024
+ this.bufferCount = 0;
2025
+ /** @property {boolean} - The mesh changed and needs uploading again, set it yourself if you edit the arrays */
2026
+ this.dirty = false;
2027
+ /** @property {boolean|undefined} - Draw every use of this mesh in the opaque stage as one instanced call, undefined follows render3D.instancing
2028
+ * @type {boolean|undefined} */
2029
+ this.instanced = undefined;
2030
+ /** @property {boolean} - Draw both sides, each lit as the side that is seen; off skips the faces pointing away,
2031
+ * which is faster and right for closed shapes, the open builders like buildGrid and buildRibbon turn it on */
2032
+ this.doubleSided = false;
2033
+ this.instanceCount = 0; // draws waiting in this mesh's batch, with their values, texture and draw state
2034
+ this.instanceData = undefined;
2035
+ /** @property {number} - Bounding sphere radius around the origin, for culling and picking, computed by upload */
2036
+ this.radius = 0;
2037
+ this.contextGeneration = 0; // the context the buffer belongs to, see render3D.contextGeneration
2038
+ }
2039
+
2040
+ /** Number of vertices in the mesh
2041
+ * @return {number} */
2042
+ get vertexCount() { return this.points.length; }
2043
+
2044
+ /** Add a triangle strip, joined to the previous one by invisible flat triangles so one mesh holds many strips
2045
+ * - Strip order: the first three points make a triangle, then each point makes another with the two before it
2046
+ * - List the first three points counter clockwise as seen from the front, or the face points away
2047
+ * and may vanish when back faces are culled
2048
+ * @param {Array<Vector3>} points - Strip order
2049
+ * @param {Vector3|Array<Vector3>} [normals] - One for all or one per point, default up
2050
+ * @param {Vector2|Array<Vector2>} [uvs] - One for all or one per point, default zero
2051
+ * @param {Color|Array<Color>} [colors] - One for all or one per point, default white
2052
+ * @return {Mesh} */
2053
+ addStrip(points, normals, uvs, colors)
2054
+ {
2055
+ render3DForEachStripVertex(points, normals, uvs, colors, (p, n, uv, c)=>
2056
+ {
2057
+ this.points.push(p);
2058
+ this.normals.push(n);
2059
+ this.uvs.push(uv);
2060
+ this.colors.push(c);
2061
+ });
2062
+ this.dirty = true;
2063
+ return this;
2064
+ }
2065
+
2066
+ /** Add a flat quad from four corners in loop order, counter clockwise seen from the front, a is the top left of the texture
2067
+ * @param {Vector3} a
2068
+ * @param {Vector3} b
2069
+ * @param {Vector3} c
2070
+ * @param {Vector3} d
2071
+ * @param {Color|Array<Color>} [color] - One for all or one per corner
2072
+ * @param {Array<Vector2>} [uvs] - One per corner, default across the tile
2073
+ * @return {Mesh} */
2074
+ addQuad(a, b, c, d, color, uvs)
2075
+ {
2076
+ return this.addStrip(render3DQuadStrip(a, b, c, d), render3DFaceNormal(a, b, c, d),
2077
+ uvs ? render3DQuadValues(uvs) : RENDER3D_QUAD_UVS, render3DQuadValues(color));
2078
+ }
2079
+
2080
+ /** Append another mesh transformed by a matrix, for building one shape out of several
2081
+ * @param {Mesh} mesh
2082
+ * @param {Matrix4|Vector3} [matrix] - Transform, or just a position to move it to
2083
+ * @param {Color} [color] - Multiplies the appended vertex colors
2084
+ * @return {Mesh} */
2085
+ combine(mesh, matrix=RENDER3D_IDENTITY, color=WHITE)
2086
+ {
2087
+ matrix = render3DMatrix(matrix); // most parts only need moving into place
2088
+ const normalMatrix = render3DNormalMatrix(matrix);
2089
+ for (let i = 0; i < mesh.points.length; ++i)
2090
+ {
2091
+ this.points.push(matrix.transformPoint(mesh.points[i]));
2092
+ this.normals.push(normalMatrix.transformDirection(mesh.normals[i] || RENDER3D_DEFAULT_NORMAL).normalize());
2093
+ this.uvs.push((mesh.uvs[i] || RENDER3D_DEFAULT_UV).copy());
2094
+ this.colors.push((mesh.colors[i] || WHITE).multiply(color));
2095
+ }
2096
+ this.doubleSided ||= mesh.doubleSided; // an open part leaves the whole mesh open
2097
+ this.dirty = true;
2098
+ return this;
2099
+ }
2100
+
2101
+ /** Scale every uv, so a whole texture repeats across the mesh when its TextureInfo wraps
2102
+ * @param {Vector2|number} scale - Repeats across and up, a number for both
2103
+ * @return {Mesh} */
2104
+ scaleUVs(scale)
2105
+ {
2106
+ const s = isNumber(scale) ? vec2(scale) : scale;
2107
+ this.uvs = this.uvs.map(uv=> vec2(uv.x * s.x, uv.y * s.y)); // new vectors, builders share uv objects between faces
2108
+ this.dirty = true;
2109
+ return this;
2110
+ }
2111
+
2112
+ /** Move, turn or scale every vertex in place, normals follow along
2113
+ * @param {Matrix4|Vector3} matrix - Transform, or just an offset to move by
2114
+ * @return {Mesh} */
2115
+ transform(matrix)
2116
+ {
2117
+ matrix = render3DMatrix(matrix);
2118
+ const normalMatrix = render3DNormalMatrix(matrix);
2119
+ for (let i = 0; i < this.points.length; ++i)
2120
+ {
2121
+ this.points[i] = matrix.transformPoint(this.points[i]);
2122
+ // a mesh built by hand may have no normals yet, and then there is nothing to turn
2123
+ this.normals[i] &&= normalMatrix.transformDirection(this.normals[i]).normalize();
2124
+ }
2125
+ this.dirty = true;
2126
+ return this;
2127
+ }
2128
+
2129
+ /** Turn the mesh inside out so it is lit and drawn from within, for rooms and domes
2130
+ * @return {Mesh} */
2131
+ flipNormals()
2132
+ {
2133
+ // one extra point at each end flips which way every triangle faces, and keeps the count even
2134
+ for (const key of ['points', 'normals', 'uvs', 'colors'])
2135
+ {
2136
+ const a = this[key];
2137
+ if (a.length)
2138
+ a.unshift(a[0]), a.push(a[a.length - 1]);
2139
+ }
2140
+ this.normals = this.normals.map(n=> n.scale(-1));
2141
+ this.dirty = true;
2142
+ return this;
2143
+ }
2144
+
2145
+ /** Set every vertex color
2146
+ * @param {Color} color
2147
+ * @return {Mesh} */
2148
+ setColor(color)
2149
+ {
2150
+ // one per point, not one per color already there, so a mesh built by hand with no
2151
+ // colors gets them instead of quietly staying white
2152
+ this.colors = this.points.map(()=> color);
2153
+ this.dirty = true;
2154
+ return this;
2155
+ }
2156
+
2157
+ /** Measure the axis aligned box around the vertices
2158
+ * @return {{min: Vector3, max: Vector3}} */
2159
+ getBounds()
2160
+ {
2161
+ if (!this.points.length)
2162
+ return {min: vec3(), max: vec3()};
2163
+ const lo = vec3(Infinity), hi = vec3(-Infinity);
2164
+ for (const p of this.points)
2165
+ {
2166
+ lo.x = min(lo.x, p.x); lo.y = min(lo.y, p.y); lo.z = min(lo.z, p.z);
2167
+ hi.x = max(hi.x, p.x); hi.y = max(hi.y, p.y); hi.z = max(hi.z, p.z);
2168
+ }
2169
+ return {min: lo, max: hi};
2170
+ }
2171
+
2172
+ /** Move the mesh so the center of its bounds is on the origin
2173
+ * @return {Mesh} */
2174
+ center()
2175
+ {
2176
+ const bounds = this.getBounds();
2177
+ return this.transform(bounds.min.add(bounds.max).scale(-.5));
2178
+ }
2179
+
2180
+ /** Scale the mesh evenly so its largest extent is a size, for loaded models of unknown units
2181
+ * @param {number} [size]
2182
+ * @return {Mesh} */
2183
+ fit(size=1)
2184
+ {
2185
+ const bounds = this.getBounds();
2186
+ const extent = bounds.max.subtract(bounds.min);
2187
+ const scale = size / (max(extent.x, extent.y, extent.z) || 1);
2188
+ return this.transform(Matrix4.scaling(vec3(scale)));
2189
+ }
2190
+
2191
+ /** Measure the bounding sphere around the origin into radius, called by upload
2192
+ * @return {number} */
2193
+ computeRadius()
2194
+ {
2195
+ let r = 0;
2196
+ for (const p of this.points)
2197
+ r = max(r, p.lengthSquared());
2198
+ return this.radius = r ** .5;
2199
+ }
2200
+
2201
+ /** Derive normals from the strip's triangles
2202
+ * @param {boolean} [smooth] - Round the lighting across faces instead of giving each face a hard edge
2203
+ * @return {Mesh} */
2204
+ computeNormals(smooth=false)
2205
+ {
2206
+ // the outward normal of each triangle in the strip
2207
+ const points = this.points, n = points.length;
2208
+ const faceNormals = [];
2209
+ for (let i = 0; i + 2 < n; ++i)
2210
+ {
2211
+ const a = points[i], b = points[i+1], c = points[i+2];
2212
+ const normal = b.subtract(a).cross(c.subtract(a));
2213
+ // triangles in a strip alternate which way they wind, so every other one is flipped back
2214
+ // a zero normal means a flat triangle joining two strips, so skip it
2215
+ faceNormals.push(normal.lengthSquared() ? normal.normalize(i & 1 ? 1 : -1) : undefined);
2216
+ }
2217
+
2218
+ // then hand those to the vertices, shared around a position or kept per face
2219
+ const normals = points.map(()=> RENDER3D_DEFAULT_NORMAL);
2220
+ if (smooth)
2221
+ {
2222
+ // add up the face normals meeting at each position, each weighted by its corner angle so a cube
2223
+ // corner averages its three faces evenly however the strips cut them, then normalize
2224
+ const sums = new Map;
2225
+ const key = (p)=> `${round(p.x * 1e5)},${round(p.y * 1e5)},${round(p.z * 1e5)}`;
2226
+ faceNormals.forEach((f, i)=> f && [0, 1, 2].forEach(j=>
2227
+ {
2228
+ const a = points[i + j], u = points[i + (j + 1) % 3].subtract(a), v = points[i + (j + 2) % 3].subtract(a);
2229
+ const angle = Math.acos(clamp(u.dot(v) / (u.length() * v.length() || 1), -1, 1));
2230
+ const k = key(a);
2231
+ sums.set(k, (sums.get(k) || vec3()).add(f.scale(angle)));
2232
+ }));
2233
+ for (let i = 0; i < n; ++i)
2234
+ normals[i] = (sums.get(key(points[i])) || RENDER3D_DEFAULT_NORMAL).normalize();
2235
+ }
2236
+ else
2237
+ // every triangle writes its own three corners, so the only vertices left with the default
2238
+ // are the repeats at the ends of a strip, which no triangle with any area uses
2239
+ faceNormals.forEach((f, i)=> f && (normals[i] = normals[i+1] = normals[i+2] = f));
2240
+
2241
+ this.normals = normals;
2242
+ this.dirty = true;
2243
+ return this;
2244
+ }
2245
+
2246
+ /** Pack the vertices and create the GPU buffer, called automatically by render
2247
+ * @return {Mesh} */
2248
+ upload()
2249
+ {
2250
+ this.computeRadius();
2251
+ if (!render3D?.program) return this;
2252
+ this.dispose();
2253
+ const count = this.points.length;
2254
+ const data = new ArrayBuffer(count * RENDER3D_VERTEX_BYTES);
2255
+ const floats = new Float32Array(data), ints = new Uint32Array(data);
2256
+ for (let i = 0; i < count; ++i)
2257
+ {
2258
+ const uv = this.uvs[i] || RENDER3D_DEFAULT_UV; // a hand built mesh may leave normals, uvs and colors empty
2259
+ render3DWriteVertex(floats, ints, i * RENDER3D_VERTEX_FLOATS, this.points[i],
2260
+ this.normals[i] || RENDER3D_DEFAULT_NORMAL, uv.x, uv.y, (this.colors[i] || WHITE).rgbaInt());
2261
+ }
2262
+ const gl = glContext;
2263
+ this.buffer = gl.createBuffer();
2264
+ this.bufferCount = count;
2265
+ this.dirty = false;
2266
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
2267
+ gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
2268
+ this.contextGeneration = render3D.contextGeneration;
2269
+ render3DMeshBuffers?.register(this, {buffer: this.buffer, generation: this.contextGeneration}, this);
2270
+ gl.bindBuffer(gl.ARRAY_BUFFER, glArrayBuffer); // the engine's 2D batch writes through this binding
2271
+ return this;
2272
+ }
2273
+
2274
+ /** Draw the mesh with the current draw state, batched with its other uses in the opaque stage
2275
+ * @param {Matrix4|Vector3} [matrix] - Object transform, or just a position to draw it at
2276
+ * @param {TileInfo|TextureInfo} [tileInfo] - Texture, mesh uvs map across the tile or the whole texture
2277
+ * @param {Color} [color] - Tint */
2278
+ render(matrix, tileInfo, color) { render3D?.drawMesh(this, matrix, tileInfo, color); }
2279
+
2280
+ /** Delete the GPU buffer now, the CPU arrays stay so the mesh can be rendered again
2281
+ * - Optional, the buffer is freed anyway once the mesh is garbage collected, this frees it right away */
2282
+ dispose()
2283
+ {
2284
+ if (!this.buffer) return;
2285
+ render3DMeshBuffers?.unregister(this); // freed here, so not again when the mesh is collected
2286
+ // a buffer from a context that was lost is gone with it, and the new context refuses to delete it
2287
+ if (this.contextGeneration === render3D?.contextGeneration)
2288
+ glContext?.deleteBuffer(this.buffer);
2289
+ this.buffer = undefined;
2290
+ this.bufferCount = 0;
2291
+ }
2292
+ }
2293
+
2294
+ ///////////////////////////////////////////////////////////////////////////////
2295
+ // Shape builders, all centered on the origin so buildMatrix does placement
2296
+ // Sizes are full sizes like buildBox and the 2D drawCircle, sides go around an axis and rings along it
2297
+
2298
+ /**
2299
+ * Spin a flat outline around the Y axis to make a round shape, like a vase or a wheel
2300
+ * - profile is [[radius, y], ...] from bottom to top
2301
+ * - A profile that ends where it starts makes a closed ring like a donut
2302
+ * - An end left open, with a radius and no cap, makes the mesh doubleSided so its inside shows
2303
+ * @param {Array<Array<number>>} profile
2304
+ * @param {number} [sides] - Around the axis
2305
+ * @param {boolean} [smooth] - Defaults to render3D.smoothShading
2306
+ * @param {boolean} [capped] - Close the ends that have a radius with flat discs
2307
+ * @return {Mesh}
2308
+ * @memberof Render3D
2309
+ * @example
2310
+ * const vase = buildLathe([[0, -1], [.8, -.3], [.9, .2], [.4, .6], [0, 1]], 12);
2311
+ */
2312
+ function buildLathe(profile, sides=16, smooth=render3D?.smoothShading, capped=true)
2313
+ {
2314
+ ASSERT(isArray(profile) && profile.length > 1, 'lathe profile needs at least 2 points');
2315
+ sides |= 0;
2316
+ ASSERT(sides > 2, 'lathe needs at least 3 sides');
2317
+ const mesh = new Mesh;
2318
+ const rings = profile.length;
2319
+ const point = (i, a)=> vec3(sin(a) * profile[i][0], profile[i][1], cos(a) * profile[i][0]);
2320
+
2321
+ // 2D outward normal of each profile segment, in (radius, y) space
2322
+ const segmentNormal = (i)=>
2323
+ {
2324
+ const [r0, y0] = profile[i], [r1, y1] = profile[i+1];
2325
+ const n = vec2(y1 - y0, r0 - r1);
2326
+ return n.length() ? n.normalize() : vec2(1, 0);
2327
+ };
2328
+ // vertex normal: average of the adjacent segment normals, across the seam when the profile is closed,
2329
+ // and a closed profile needs no caps
2330
+ const closed = rings > 2 && abs(profile[0][0] - profile[rings-1][0]) < 1e-9 && abs(profile[0][1] - profile[rings-1][1]) < 1e-9;
2331
+ const segmentLength = (i)=> hypot(profile[i+1][0] - profile[i][0], profile[i+1][1] - profile[i][1]);
2332
+ const vertexNormal = (i)=>
2333
+ {
2334
+ // an open end on the axis is a pole and points along it
2335
+ if (!closed && (!i || i == rings - 1) && abs(profile[i][0]) < 1e-9)
2336
+ return vec2(0, i ? 1 : -1);
2337
+ // otherwise the neighbors weighted by their length, so a short band does not tilt a long wall
2338
+ let n = vec2();
2339
+ const add = (s)=> n = n.add(segmentNormal(s).scale(segmentLength(s)));
2340
+ if (i > 0) add(i - 1);
2341
+ else if (closed) add(rings - 2);
2342
+ if (i < rings - 1) add(i);
2343
+ else if (closed) add(0);
2344
+ return n.length() ? n.normalize() : vec2(1, 0);
2345
+ };
2346
+ const normal3D = (n, a)=> vec3(sin(a) * n.x, n.y, cos(a) * n.x);
2347
+
2348
+ // v runs along the profile by arc length
2349
+ const lengths = [0];
2350
+ for (let i = 1; i < rings; ++i)
2351
+ lengths[i] = lengths[i-1] + hypot(profile[i][0] - profile[i-1][0], profile[i][1] - profile[i-1][1]);
2352
+ const total = lengths[rings - 1] || 1;
2353
+ const v = (i)=> 1 - lengths[i] / total;
2354
+
2355
+ for (let i = 0; i + 1 < rings; ++i)
2356
+ {
2357
+ if (smooth)
2358
+ {
2359
+ // one ribbon around the ring pair, top point then bottom point per column
2360
+ const points = [], normals = [], uvs = [];
2361
+ const n0 = vertexNormal(i), n1 = vertexNormal(i + 1);
2362
+ for (let j = 0; j <= sides; ++j)
2363
+ {
2364
+ const a = j / sides * 2 * PI, u = j / sides;
2365
+ points.push(point(i + 1, a), point(i, a));
2366
+ normals.push(normal3D(n1, a), normal3D(n0, a));
2367
+ uvs.push(vec2(u, v(i + 1)), vec2(u, v(i)));
2368
+ }
2369
+ mesh.addStrip(points, normals, uvs);
2370
+ }
2371
+ else
2372
+ {
2373
+ // one quad per side with its face normal
2374
+ const n = segmentNormal(i);
2375
+ for (let j = 0; j < sides; ++j)
2376
+ {
2377
+ const a0 = j / sides * 2 * PI, a1 = (j + 1) / sides * 2 * PI;
2378
+ const u0 = j / sides, u1 = (j + 1) / sides;
2379
+ mesh.addStrip(
2380
+ [point(i + 1, a0), point(i, a0), point(i + 1, a1), point(i, a1)],
2381
+ normal3D(n, (a0 + a1) / 2),
2382
+ [vec2(u0, v(i + 1)), vec2(u0, v(i)), vec2(u1, v(i + 1)), vec2(u1, v(i))]);
2383
+ }
2384
+ }
2385
+ }
2386
+
2387
+ // flat discs close the ends that have a radius, a hard edge even when the sides are smooth
2388
+ if (capped && !closed)
2389
+ for (const [i, up] of [[0, false], [rings - 1, true]])
2390
+ {
2391
+ if (abs(profile[i][0]) < 1e-9) continue; // a pole has no cap
2392
+ const points = [], uvs = [];
2393
+ for (let j = 0; j < sides; ++j)
2394
+ {
2395
+ const a = (up ? j : -j) / sides * 2 * PI; // counter clockwise seen from outside
2396
+ points.push(point(i, a));
2397
+ uvs.push(vec2(sin(a) * .5 + .5, cos(a) * .5 + .5));
2398
+ }
2399
+ mesh.addStrip(render3DPolygonStrip(points), vec3(0, up ? 1 : -1, 0), render3DPolygonStrip(uvs));
2400
+ }
2401
+
2402
+ // an end left open shows the inside, so it is seen from both sides
2403
+ mesh.doubleSided = !closed && !capped && (abs(profile[0][0]) > 1e-9 || abs(profile[rings-1][0]) > 1e-9);
2404
+ return mesh;
2405
+ }
2406
+
2407
+ /**
2408
+ * Build a cylinder standing on the Y axis, centered on the origin
2409
+ * @param {number} [size] - Diameter
2410
+ * @param {number} [height]
2411
+ * @param {number} [sides] - Around
2412
+ * @param {boolean} [smooth] - Defaults to render3D.smoothShading
2413
+ * @param {boolean} [capped] - Close the ends
2414
+ * @return {Mesh}
2415
+ * @memberof Render3D
2416
+ */
2417
+ function buildCylinder(size=1, height=1, sides=16, smooth=render3D?.smoothShading, capped=true)
2418
+ {
2419
+ return buildLathe([[size / 2, -height / 2], [size / 2, height / 2]], sides, smooth, capped);
2420
+ }
2421
+
2422
+ /**
2423
+ * Build a cone standing on the Y axis, centered on the origin, the point up
2424
+ * @param {number} [size] - Diameter of the base
2425
+ * @param {number} [height]
2426
+ * @param {number} [sides] - Around
2427
+ * @param {boolean} [smooth] - Defaults to render3D.smoothShading
2428
+ * @param {boolean} [capped] - Close the base
2429
+ * @return {Mesh}
2430
+ * @memberof Render3D
2431
+ */
2432
+ function buildCone(size=1, height=1, sides=16, smooth=render3D?.smoothShading, capped=true)
2433
+ {
2434
+ return buildLathe([[size / 2, -height / 2], [0, height / 2]], sides, smooth, capped);
2435
+ }
2436
+
2437
+ /**
2438
+ * Build a sphere centered on the origin
2439
+ * @param {number} [size] - Diameter
2440
+ * @param {number} [sides] - Around
2441
+ * @param {number} [rings] - Top to bottom
2442
+ * @param {boolean} [smooth] - Defaults to render3D.smoothShading
2443
+ * @return {Mesh}
2444
+ * @memberof Render3D
2445
+ */
2446
+ function buildSphere(size=1, sides=16, rings=8, smooth=render3D?.smoothShading)
2447
+ {
2448
+ ASSERT(rings > 1, 'sphere needs at least 2 rings');
2449
+ const profile = [];
2450
+ for (let i = 0; i <= rings; ++i)
2451
+ {
2452
+ const a = i / rings * PI - PI/2;
2453
+ profile.push([cos(a) * size / 2, sin(a) * size / 2]);
2454
+ }
2455
+ return buildLathe(profile, sides, smooth);
2456
+ }
2457
+
2458
+ /**
2459
+ * Build a capsule standing on the Y axis, centered on the origin: a cylinder with a half sphere on each end
2460
+ * @param {number} [size] - Diameter
2461
+ * @param {number} [height] - Total height including the rounded ends, at least the size
2462
+ * @param {number} [sides] - Around
2463
+ * @param {number} [rings] - On each end
2464
+ * @param {boolean} [smooth] - Defaults to render3D.smoothShading
2465
+ * @return {Mesh}
2466
+ * @memberof Render3D
2467
+ */
2468
+ function buildCapsule(size=1, height=1, sides=16, rings=4, smooth=render3D?.smoothShading)
2469
+ {
2470
+ // the rounded ends alone are already the size tall, so a shorter capsule is only a sphere
2471
+ ASSERT(height >= size, 'a capsule is at least as tall as it is wide, the ends take up the size', size, height);
2472
+ const profile = [], r = size / 2, straight = max(0, height - size) / 2;
2473
+ for (let i = 0; i <= rings; ++i)
2474
+ {
2475
+ const a = i / rings * PI / 2;
2476
+ profile.push([r * sin(a), -straight - r * cos(a)]);
2477
+ }
2478
+ for (let i = 0; i <= rings; ++i)
2479
+ {
2480
+ const a = i / rings * PI / 2;
2481
+ profile.push([r * cos(a), straight + r * sin(a)]);
2482
+ }
2483
+ return buildLathe(profile, sides, smooth);
2484
+ }
2485
+
2486
+ /**
2487
+ * Build a donut lying flat around the Y axis
2488
+ * @param {number} [size] - Diameter of the whole donut, outside edge to outside edge
2489
+ * @param {number} [tubeSize] - Diameter of the tube
2490
+ * @param {number} [sides] - Around the ring
2491
+ * @param {number} [tubeSides] - Around the tube
2492
+ * @param {boolean} [smooth] - Defaults to render3D.smoothShading
2493
+ * @return {Mesh}
2494
+ * @memberof Render3D
2495
+ */
2496
+ function buildTorus(size=1, tubeSize=.3, sides=16, tubeSides=8, smooth=render3D?.smoothShading)
2497
+ {
2498
+ ASSERT(tubeSize <= size, 'the tube must fit inside the torus');
2499
+ const profile = [], radius = (size - tubeSize) / 2, tubeRadius = tubeSize / 2;
2500
+ for (let i = 0; i <= tubeSides; ++i)
2501
+ {
2502
+ const a = i / tubeSides * 2 * PI;
2503
+ profile.push([radius + tubeRadius * cos(a), tubeRadius * sin(a)]);
2504
+ }
2505
+ return buildLathe(profile, sides, smooth);
2506
+ }
2507
+
2508
+ /**
2509
+ * Build a box centered on the origin, six flat faces with uvs covering each face
2510
+ * @param {Vector3|number} [size] - Full size, a number for a cube
2511
+ * @return {Mesh}
2512
+ * @memberof Render3D
2513
+ */
2514
+ function buildBox(size=1)
2515
+ {
2516
+ const mesh = new Mesh;
2517
+ const half = render3DSize3(size).scale(.5);
2518
+ // each face: normal, right axis, up axis (right cross up = normal)
2519
+ const faces = [
2520
+ [vec3(0, 0, 1), vec3(1, 0, 0), vec3(0, 1, 0)],
2521
+ [vec3(0, 0, -1), vec3(-1, 0, 0), vec3(0, 1, 0)],
2522
+ [vec3(1, 0, 0), vec3(0, 0, -1), vec3(0, 1, 0)],
2523
+ [vec3(-1, 0, 0), vec3(0, 0, 1), vec3(0, 1, 0)],
2524
+ [vec3(0, 1, 0), vec3(1, 0, 0), vec3(0, 0, -1)],
2525
+ [vec3(0, -1, 0), vec3(1, 0, 0), vec3(0, 0, 1)],
2526
+ ];
2527
+ for (const [n, r, u] of faces)
2528
+ {
2529
+ const center = n.multiply(half);
2530
+ const right = r.multiply(half), up = u.multiply(half);
2531
+ mesh.addStrip(render3DQuadAxes(center, right, up), n, RENDER3D_QUAD_UVS);
2532
+ }
2533
+ return mesh;
2534
+ }
2535
+
2536
+ /**
2537
+ * Build a lit ribbon along a path, for roads, tracks and walls
2538
+ * - Each segment is a flat quad, the sides are across the path in the plane of the up vector
2539
+ * - doubleSided, so it is seen and lit from below as well
2540
+ * @param {Array<Vector3>} points - Center line in order
2541
+ * @param {number|Array<number>} [width] - Full width, one for all or one per point
2542
+ * @param {Color|Array<Color>} [color] - One for all or one per point
2543
+ * @param {boolean} [closed] - Join the last point back to the first
2544
+ * @param {Vector3} [up] - Which way the ribbon faces
2545
+ * @return {Mesh}
2546
+ * @memberof Render3D
2547
+ * @example
2548
+ * const road = buildRibbon(trackPoints, 8, GRAY, true); // a loop of road
2549
+ */
2550
+ function buildRibbon(points, width=1, color=WHITE, closed=false, up=vec3(0, 1, 0))
2551
+ {
2552
+ ASSERT(isArray(points) && points.length > 1, 'ribbon needs at least 2 points');
2553
+ const mesh = new Mesh, count = points.length, edges = [];
2554
+ let across = (abs(up.y) < .9 ? vec3(0, 1, 0) : vec3(1, 0, 0)).cross(up).normalize(); // anything across up
2555
+ for (let i = 0; i < count; ++i)
2556
+ {
2557
+ // across the path, from the tangent through this point; a step along up keeps the last across
2558
+ const next = points[closed ? (i + 1) % count : min(i + 1, count - 1)];
2559
+ const last = points[closed ? (i + count - 1) % count : max(i - 1, 0)];
2560
+ const dir = next.subtract(last).cross(up);
2561
+ if (dir.lengthSquared() > 1e-12)
2562
+ across = dir.normalize();
2563
+ const half = across.scale((isArray(width) ? width[i] : width) / 2);
2564
+ edges.push([points[i].subtract(half), points[i].add(half)]);
2565
+ }
2566
+ for (let i = 0; i + 1 < count + (closed ? 1 : 0); ++i)
2567
+ {
2568
+ const j = (i + 1) % count, a = edges[i], b = edges[j];
2569
+ const c = isArray(color) ? [color[i], color[i], color[j], color[j]] : color;
2570
+ mesh.addQuad(a[0], a[1], b[1], b[0], c); // counter clockwise seen from above
2571
+ }
2572
+ mesh.doubleSided = true; // a flat strip, seen from both sides
2573
+ return mesh;
2574
+ }
2575
+
2576
+ /**
2577
+ * Build a heightfield grid in the XZ plane centered on the origin
2578
+ * - smooth rounds the lighting across cells and colors each corner
2579
+ * - flat lights and colors each cell on its own, so a checkerboard stays crisp
2580
+ * - doubleSided, a sheet seen from both sides; turn it off for ground only ever seen from above
2581
+ * - One cell is a plain square, render3D.planeMesh and planeMeshDoubleSided are shared ones
2582
+ * @param {Vector2} [size] - World size along X and Z
2583
+ * @param {Vector2|number} [segments] - Cells along X and Z, a number for both
2584
+ * @param {Color|Function} [color] - One Color for the whole grid, or (x, z) => Color
2585
+ * @param {Function} [heightFunction] - (x, z) => y, default flat
2586
+ * @param {boolean} [smooth] - Defaults to render3D.smoothShading
2587
+ * @return {Mesh}
2588
+ * @memberof Render3D
2589
+ * @example
2590
+ * const ground = buildGrid(vec2(20), 10, (x, z)=> (floor(x / 2) + floor(z / 2)) & 1 ? GRAY : WHITE); // 2 unit checks
2591
+ */
2592
+ function buildGrid(size=vec2(1), segments=1, color, heightFunction=()=>0, smooth=render3D?.smoothShading)
2593
+ {
2594
+ if (isNumber(segments))
2595
+ segments = vec2(segments);
2596
+ ASSERT(segments.x > 0 && segments.y > 0 && segments.x % 1 === 0 && segments.y % 1 === 0, 'grid segments must be whole numbers above zero');
2597
+ const mesh = new Mesh;
2598
+ const segmentsX = segments.x, segmentsZ = segments.y;
2599
+ const cellX = size.x / segmentsX, cellZ = size.y / segmentsZ;
2600
+ const px = (i)=> i * cellX - size.x / 2, pz = (j)=> j * cellZ - size.y / 2;
2601
+ const point = (i, j)=> { const x = px(i), z = pz(j); return vec3(x, heightFunction(x, z), z); };
2602
+ const normal = (i, j)=> render3DSlopeNormal(heightFunction, px(i), pz(j), cellX / 2, cellZ / 2, size.x / 2, size.y / 2);
2603
+ const uv = (i, j)=> vec2(i / segmentsX, j / segmentsZ);
2604
+ const cellColor = (i, j)=> !color ? WHITE : isColor(color) ? color : color(px(i), pz(j));
2605
+ for (let j = 0; j < segmentsZ; ++j)
2606
+ {
2607
+ if (smooth)
2608
+ {
2609
+ // one ribbon per row with vertex normals from the slope
2610
+ const points = [], normals = [], uvs = [], colors = [];
2611
+ for (let i = 0; i <= segmentsX; ++i)
2612
+ {
2613
+ points.push(point(i, j), point(i, j + 1));
2614
+ normals.push(normal(i, j), normal(i, j + 1));
2615
+ uvs.push(uv(i, j), uv(i, j + 1));
2616
+ colors.push(cellColor(i, j), cellColor(i, j + 1));
2617
+ }
2618
+ mesh.addStrip(points, normals, uvs, colors);
2619
+ }
2620
+ else
2621
+ {
2622
+ // one quad per cell with its face normal and one color sampled at its center
2623
+ for (let i = 0; i < segmentsX; ++i)
2624
+ mesh.addQuad(point(i, j), point(i, j + 1), point(i + 1, j + 1), point(i + 1, j), cellColor(i + .5, j + .5),
2625
+ [uv(i, j), uv(i, j + 1), uv(i + 1, j + 1), uv(i + 1, j)]);
2626
+ }
2627
+ }
2628
+ mesh.doubleSided = true; // a sheet, seen from both sides; terrain seen only from above can turn it off
2629
+ return mesh;
2630
+ }
2631
+
2632
+ /**
2633
+ * Build a hull from a row of diamond shaped slices along Z, for ships, planes and cars
2634
+ * - Each slice is [z, width, top, bottom, sideHeight]
2635
+ * - sideHeight is 0 to 1 and puts the side corners between the bottom and the top
2636
+ * - List the slices nose first, with the nose at the largest z
2637
+ * @param {Array<Array<number>>} stations
2638
+ * @return {Mesh}
2639
+ * @memberof Render3D
2640
+ * @example
2641
+ * const hull = buildLoft([[1.2, .4, .2, -.1], [0, 1.4, .5, -.4], [-1, 1, .3, -.3]]);
2642
+ */
2643
+ function buildLoft(stations)
2644
+ {
2645
+ ASSERT(isArray(stations) && stations.length > 1, 'loft needs at least 2 stations');
2646
+ // the caps and the winding both assume the nose leads, so the other order turns the hull inside out
2647
+ ASSERT(stations[0][0] > stations[stations.length-1][0], 'loft stations go nose first, from the largest z to the smallest');
2648
+ const mesh = new Mesh;
2649
+ // section points: left, top, right, bottom, wound clockwise seen from +z
2650
+ const section = ([z, w, t, b, m=.5])=>
2651
+ [vec3(-w / 2, lerp(b, t, m), z), vec3(0, t, z), vec3(w / 2, lerp(b, t, m), z), vec3(0, b, z)];
2652
+ for (let i = 0; i + 1 < stations.length; ++i)
2653
+ {
2654
+ const s1 = section(stations[i]), s2 = section(stations[i + 1]);
2655
+ for (let k = 0; k < 4; ++k)
2656
+ mesh.addQuad(s1[k], s1[(k + 1) % 4], s2[(k + 1) % 4], s2[k]);
2657
+ }
2658
+ const tail = section(stations[stations.length - 1]), nose = section(stations[0]);
2659
+ mesh.addQuad(tail[0], tail[1], tail[2], tail[3]);
2660
+ mesh.addQuad(nose[3], nose[2], nose[1], nose[0]);
2661
+ return mesh;
2662
+ }
2663
+
2664
+ /**
2665
+ * Build a sky dome: a sphere colored by direction, wound to be seen from inside
2666
+ * - set it as render3D.sky and the pass draws it around the camera behind everything
2667
+ * @param {Color} [topColor] - Straight up
2668
+ * @param {Color} [horizonColor] - Level with the camera
2669
+ * @param {Color} [bottomColor] - Straight down, what a camera looking at the ground sees past its edge; defaults to the horizon color
2670
+ * @param {number} [sides] - Around
2671
+ * @param {number} [rings] - Top to bottom
2672
+ * @return {Mesh}
2673
+ * @memberof Render3D
2674
+ */
2675
+ function buildSky(topColor=hsl(.6, .8, .55), horizonColor=hsl(.6, 1, .9), bottomColor=horizonColor, sides=16, rings=8)
2676
+ {
2677
+ const mesh = new Mesh;
2678
+ const point = (i, a)=>
2679
+ {
2680
+ const e = i / rings * PI - PI/2;
2681
+ return vec3(sin(a) * cos(e), sin(e), cos(a) * cos(e));
2682
+ };
2683
+ const color = (i)=>
2684
+ {
2685
+ const y = point(i, 0).y;
2686
+ return y < 0 ? horizonColor.lerp(bottomColor, -y) : horizonColor.lerp(topColor, y);
2687
+ };
2688
+ for (let i = 0; i < rings; ++i)
2689
+ {
2690
+ // bottom point then top point per column, the reverse of the lathe, so the front faces inward
2691
+ const points = [], colors = [];
2692
+ for (let j = 0; j <= sides; ++j)
2693
+ {
2694
+ const a = j / sides * 2 * PI;
2695
+ points.push(point(i, a), point(i + 1, a));
2696
+ colors.push(color(i), color(i + 1));
2697
+ }
2698
+ mesh.addStrip(points, undefined, undefined, colors);
2699
+ }
2700
+ return mesh;
2701
+ }
2702
+
2703
+ /**
2704
+ * Turn a sprite into a 3D block model by giving its pixels thickness
2705
+ * - A pixel counts as solid when it is more than half opaque
2706
+ * - Each pixel keeps its own color, so white art takes the object's tint
2707
+ * - Runs of matching pixels merge into one face, and side walls appear only at the sprite's edges
2708
+ * - A texture's pixels are read once and kept, so redrawing a canvas texture will not change what this builds
2709
+ * - Pixels can also be an array of rows, each a Color, a truthy value for white, or a falsy value for empty
2710
+ * @param {TileInfo|Array<Array<Color|number|boolean>>} pixels - A tile from a loaded texture, or rows of pixels,
2711
+ * each a Color (empty when see through), a truthy value for white or a falsy value for empty
2712
+ * @param {Vector2} [size] - World width and height of the whole tile, centered like buildBox
2713
+ * @param {number} [depth] - Thickness along Z
2714
+ * @return {Mesh}
2715
+ * @memberof Render3D
2716
+ * @example
2717
+ * new EngineObject3D(vec3(), buildExtrude(tile(3, 16), vec2(2), .5)); // a chunky version of tile 3
2718
+ */
2719
+ function buildExtrude(pixels, size=vec2(1), depth=1)
2720
+ {
2721
+ let rows = pixels, width, height;
2722
+ if (pixels instanceof TileInfo)
2723
+ {
2724
+ // colors for the tile's pixels only, undefined where alpha is half or less
2725
+ const image = render3DReadPixels(pixels.textureInfo), data = image.data;
2726
+ const x0 = pixels.pos.x | 0, y0 = pixels.pos.y | 0;
2727
+ width = pixels.size.x | 0, height = pixels.size.y | 0;
2728
+ rows = [];
2729
+ for (let y = 0; y < height; ++y)
2730
+ {
2731
+ const row = rows[y] = [];
2732
+ for (let x = 0; x < width; ++x)
2733
+ {
2734
+ const k = ((y0 + y) * image.width + x0 + x) * 4;
2735
+ row.push(data[k + 3] > 127 ? rgb(data[k] / 255, data[k + 1] / 255, data[k + 2] / 255) : undefined);
2736
+ }
2737
+ }
2738
+ }
2739
+ else
2740
+ {
2741
+ ASSERT(isArray(pixels) && pixels.length, 'pixels must be a TileInfo or rows of pixels');
2742
+ height = rows.length, width = rows[0].length;
2743
+ }
2744
+
2745
+ // the color of a solid pixel, undefined outside or where it is empty
2746
+ const solid = (x, y)=>
2747
+ {
2748
+ if (x < 0 || y < 0 || x >= width || y >= height) return;
2749
+ const c = rows[y] && rows[y][x];
2750
+ if (!c) return;
2751
+ return isColor(c) ? (c.a > .5 ? c : undefined) : WHITE; // a see through Color is empty too
2752
+ };
2753
+ const same = (a, b)=> a === b || !!a && !!b && a.rgbaInt() === b.rgbaInt();
2754
+
2755
+ // call emit(start, end, color) for each run of same colored pixels, colorAt(i) undefined breaks the run
2756
+ const runs = (count, colorAt, emit)=>
2757
+ {
2758
+ let start = 0, color;
2759
+ for (let i = 0; i <= count; ++i)
2760
+ {
2761
+ const c = i < count ? colorAt(i) : undefined;
2762
+ if (same(c, color)) continue;
2763
+ if (color) emit(start, i, color);
2764
+ start = i, color = c;
2765
+ }
2766
+ };
2767
+
2768
+ // pixel edges in world space, y runs down the image
2769
+ const mesh = new Mesh, sx = size.x / width, sy = size.y / height, hz = depth / 2;
2770
+ const px = x=> x * sx - size.x / 2, py = y=> size.y / 2 - y * sy;
2771
+ const quad = (origin, right, up, normal, color)=>
2772
+ mesh.addStrip(render3DQuadAxes(origin.add(right.scale(.5)).add(up.scale(.5)), right.scale(.5), up.scale(.5)), normal, RENDER3D_QUAD_UVS, color);
2773
+ const X = vec3(1, 0, 0), Y = vec3(0, 1, 0), Z = vec3(0, 0, 1);
2774
+ for (let y = 0; y < height; ++y)
2775
+ {
2776
+ // front and back faces along each row
2777
+ runs(width, x=> solid(x, y), (a, b, c)=>
2778
+ {
2779
+ const w = X.scale((b - a) * sx), h = Y.scale(sy);
2780
+ quad(vec3(px(a), py(y + 1), hz), w, h, Z, c);
2781
+ quad(vec3(px(b), py(y + 1), -hz), w.scale(-1), h, Z.scale(-1), c);
2782
+ });
2783
+ // walls facing up and down where the pixel above or below is empty
2784
+ runs(width, x=> solid(x, y - 1) ? undefined : solid(x, y), (a, b, c)=>
2785
+ quad(vec3(px(a), py(y), hz), X.scale((b - a) * sx), Z.scale(-depth), Y, c));
2786
+ runs(width, x=> solid(x, y + 1) ? undefined : solid(x, y), (a, b, c)=>
2787
+ quad(vec3(px(a), py(y + 1), -hz), X.scale((b - a) * sx), Z.scale(depth), Y.scale(-1), c));
2788
+ }
2789
+ for (let x = 0; x < width; ++x)
2790
+ {
2791
+ // walls facing left and right where the pixel beside is empty
2792
+ runs(height, y=> solid(x - 1, y) ? undefined : solid(x, y), (a, b, c)=>
2793
+ quad(vec3(px(x), py(b), -hz), Z.scale(depth), Y.scale((b - a) * sy), X.scale(-1), c));
2794
+ runs(height, y=> solid(x + 1, y) ? undefined : solid(x, y), (a, b, c)=>
2795
+ quad(vec3(px(x + 1), py(b), hz), Z.scale(-depth), Y.scale((b - a) * sy), X, c));
2796
+ }
2797
+ return mesh;
2798
+ }
2799
+
2800
+ /**
2801
+ * Build a mesh of extruded text from an image font, the engine font by default so it needs no assets
2802
+ * - Each glyph is extruded once per font and reused, the block is centered and faces +Z
2803
+ * - Newlines stack downward, spaced a little wider than the character height so the sides do not collide
2804
+ * - Every call builds a new mesh, dispose the old one when text changes often
2805
+ * - Glyphs are white in the engine font, so the object's color tints the text
2806
+ * @param {string|number} text
2807
+ * @param {number} [size] - Character height in world units
2808
+ * @param {number} [depth] - Thickness along Z
2809
+ * @param {ImageFont} [font] - Defaults to engineImageFont
2810
+ * @return {Mesh}
2811
+ * @memberof Render3D
2812
+ * @example
2813
+ * new EngineObject3D(vec3(0, 2, 0), buildText3D('HELLO'), undefined, YELLOW);
2814
+ */
2815
+ function buildText3D(text, size=1, depth=.2, font=engineImageFont)
2816
+ {
2817
+ ASSERT(font instanceof ImageFont, 'font must be an ImageFont, the engine font loads before gameInit');
2818
+ const tileInfo = font.tileInfo, padding = tileInfo.padding;
2819
+ const paddedX = tileInfo.size.x + padding * 2, paddedY = tileInfo.size.y + padding * 2;
2820
+ const columns = tileInfo.textureInfo.size.x / paddedX | 0;
2821
+ let glyphs = render3DGlyphCache.get(font); // unit sized, scaled when combined
2822
+ glyphs || render3DGlyphCache.set(font, glyphs = new Map);
2823
+ const charSize = vec2(size * tileInfo.size.x / tileInfo.size.y, size);
2824
+ const mesh = new Mesh, lines = (text + '').split('\n');
2825
+ lines.forEach((line, j)=>
2826
+ {
2827
+ const y = ((lines.length - 1) / 2 - j) * charSize.y * RENDER3D_TEXT_LEADING;
2828
+ for (let i = 0; i < line.length; ++i)
2829
+ {
2830
+ const charCode = line.charCodeAt(i);
2831
+ const index = charCode < 32 || charCode > 127 ? 95 : charCode - 32; // like ImageFont
2832
+ if (!index) continue; // space
2833
+ let glyph = glyphs.get(index);
2834
+ if (!glyph)
2835
+ {
2836
+ const pos = vec2(index % columns * paddedX + padding, (index / columns | 0) * paddedY + padding);
2837
+ glyphs.set(index, glyph = buildExtrude(new TileInfo(pos, tileInfo.size, tileInfo.textureInfo)));
2838
+ }
2839
+ const x = (i - (line.length - 1) / 2) * charSize.x;
2840
+ mesh.combine(glyph, buildMatrix(vec3(x, y, 0), undefined, vec3(charSize.x, charSize.y, depth)));
2841
+ }
2842
+ });
2843
+ return mesh;
2844
+ }
2845
+
2846
+ ///////////////////////////////////////////////////////////////////////////////
2847
+ /**
2848
+ * HeightMap - Terrain built from a grid of heights, with a mesh, a height lookup and a raycast
2849
+ * - heights is a 2D array [row][column] of 0 to 1 values
2850
+ * - Row 0 is the far edge at -Z and column 0 is the left edge at -X
2851
+ * - It can be an image instead, where the red channel is the height
2852
+ * - colors is an optional 2D array of Colors or an image, sampled per vertex
2853
+ * - images are read through a canvas, so they must be same origin or loaded with crossOrigin set
2854
+ * @memberof Render3D
2855
+ * @example
2856
+ * const terrain = new HeightMap(heightImage, vec2(100, 100), 10, colorImage);
2857
+ * new EngineObject3D(vec3(), terrain.buildMesh());
2858
+ * const y = terrain.getHeight(x, z); // stand things on it
2859
+ */
2860
+ class HeightMap
2861
+ {
2862
+ /** Create a height map from an array or an image
2863
+ * @param {Array<Array<number>>|HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|TextureInfo} heights
2864
+ * @param {Vector2} [size] - World size along X and Z
2865
+ * @param {number} [height] - World height of a full value
2866
+ * @param {Array<Array<Color>>|HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|TextureInfo} [colors] */
2867
+ constructor(heights, size=vec2(1), height=1, colors)
2868
+ {
2869
+ if (!isArray(heights))
2870
+ heights = render3DImageToArray(heights, (r)=> r / 255);
2871
+ if (colors && !isArray(colors))
2872
+ colors = render3DImageToArray(colors, (r, g, b, a)=> rgb(r / 255, g / 255, b / 255, a / 255));
2873
+ ASSERT(isArray(heights) && heights.length > 1 && isArray(heights[0]) && heights[0].length > 1, 'height map needs at least 2 rows and 2 columns');
2874
+ ASSERT(size.x > 0 && size.y > 0, 'height map size must be positive, a zero size has nowhere to look things up');
2875
+
2876
+ /** @property {Array<Array<number>>} - Heights 0-1 as [row][column], rows along Z */
2877
+ this.heights = heights;
2878
+ /** @property {Array<Array<Color>>|undefined} - Vertex colors as [row][column], undefined for white
2879
+ * @type {Array<Array<Color>>|undefined} */
2880
+ this.colors = colors;
2881
+ /** @property {Vector2} - World size along X and Z */
2882
+ this.size = size.copy();
2883
+ /** @property {number} - World height of a full value */
2884
+ this.height = height;
2885
+ }
2886
+
2887
+ /** Number of rows, along Z
2888
+ * @return {number} */
2889
+ get rows() { return this.heights.length; }
2890
+
2891
+ /** Number of columns, along X
2892
+ * @return {number} */
2893
+ get columns() { return this.heights[0].length; }
2894
+
2895
+ /** World height at a position, exactly the height of the mesh buildMesh draws there, clamped at the edges
2896
+ * @param {number|Vector3} x - X, or a position to take X and Z from
2897
+ * @param {number} [z]
2898
+ * @return {number} */
2899
+ getHeight(x, z)
2900
+ {
2901
+ if (x instanceof Vector3)
2902
+ z = x.z, x = x.x; // a position works as well as its two numbers, its own y is ignored
2903
+ const columns = this.columns, rows = this.rows, h = this.heights;
2904
+ const u = clamp((x / this.size.x + .5) * (columns - 1), 0, columns - 1);
2905
+ const v = clamp((z / this.size.y + .5) * (rows - 1), 0, rows - 1);
2906
+ const i = min(floor(u), columns - 2), j = min(floor(v), rows - 2);
2907
+ const fu = u - i, fv = v - j;
2908
+ // each cell is two triangles split from (i, j+1) to (i+1, j), the same split buildGrid's quads use
2909
+ const a = h[j][i], b = h[j+1][i], c = h[j+1][i+1], d = h[j][i+1];
2910
+ const height = fu + fv <= 1 ? a + fu * (d - a) + fv * (b - a) : c + (1 - fu) * (b - c) + (1 - fv) * (d - c);
2911
+ return height * this.height;
2912
+ }
2913
+
2914
+ /** Surface normal at a position, from the slope across a sample
2915
+ * @param {number|Vector3} x - X, or a position to take X and Z from
2916
+ * @param {number} [z]
2917
+ * @return {Vector3} */
2918
+ getNormal(x, z)
2919
+ {
2920
+ if (x instanceof Vector3)
2921
+ z = x.z, x = x.x;
2922
+ const ex = this.size.x / (this.columns - 1) / 2, ez = this.size.y / (this.rows - 1) / 2;
2923
+ return render3DSlopeNormal((x, z)=> this.getHeight(x, z), x, z, ex, ez, this.size.x / 2, this.size.y / 2);
2924
+ }
2925
+
2926
+ /** Color of the nearest sample to a position, white when there are no colors
2927
+ * @param {number|Vector3} x - X, or a position to take X and Z from
2928
+ * @param {number} [z]
2929
+ * @return {Color} */
2930
+ getColor(x, z)
2931
+ {
2932
+ if (x instanceof Vector3)
2933
+ z = x.z, x = x.x;
2934
+ const c = this.colors;
2935
+ if (!c) return WHITE;
2936
+ const columns = c[0].length, rows = c.length;
2937
+ const i = clamp(round((x / this.size.x + .5) * (columns - 1)), 0, columns - 1);
2938
+ const j = clamp(round((z / this.size.y + .5) * (rows - 1)), 0, rows - 1);
2939
+ return c[j][i];
2940
+ }
2941
+
2942
+ /** Distance along a ray to where it crosses the terrain surface, or undefined for a miss
2943
+ * - Steps along the ray half a cell at a time, then narrows in on the exact spot
2944
+ * - A ray that starts under the ground crosses on its way out, so the hit is still on the surface
2945
+ * @param {Ray3D} ray - From screenToRay, or any ray
2946
+ * @return {number|undefined} */
2947
+ raycast(ray)
2948
+ {
2949
+ const {origin, direction} = ray;
2950
+ const size = this.size, height = this.height, length = direction.length();
2951
+ if (!length) return;
2952
+
2953
+ // clip to the box around the terrain, and walk it in half cell steps from there
2954
+ let t = raycastBox(ray, vec3(0, height / 2, 0), vec3(size.x, abs(height) + 1e-3, size.y));
2955
+ if (t === undefined) return;
2956
+ const cell = min(size.x / (this.columns - 1), size.y / (this.rows - 1));
2957
+ const step = cell / 2 / length, end = t + hypot(size.x, size.y, height) / length;
2958
+ if (!(step > 0)) return; // a zero size
2959
+
2960
+ // is the ray below the ground this far along, or undefined where it is off the map
2961
+ const under = (at)=>
2962
+ {
2963
+ const p = origin.add(direction.scale(at));
2964
+ if (abs(p.x) > size.x / 2 || abs(p.z) > size.y / 2) return;
2965
+ return p.y <= this.getHeight(p.x, p.z);
2966
+ };
2967
+
2968
+ // look for where the ray changes sides, so one coming up from under the ground
2969
+ // lands on the surface it breaks through instead of wherever it entered the box
2970
+ const startUnder = under(t);
2971
+ if (startUnder === undefined) return; // it meets the box outside the map itself
2972
+ for (; t < end; t += step)
2973
+ {
2974
+ const u = under(t + step);
2975
+ if (u === undefined) return; // it left the map before crossing
2976
+ if (u === startUnder) continue;
2977
+
2978
+ // it crossed between the last two samples, halve the gap until it is exact
2979
+ let a = t, b = t + step;
2980
+ for (let i = 0; i < 16; ++i)
2981
+ {
2982
+ const mid = (a + b) / 2;
2983
+ under(mid) === startUnder ? a = mid : b = mid;
2984
+ }
2985
+ return b;
2986
+ }
2987
+ }
2988
+
2989
+ /** Build the terrain mesh, one vertex per sample, centered on the origin
2990
+ * @param {boolean} [smooth] - Defaults to render3D.smoothShading
2991
+ * @return {Mesh} */
2992
+ buildMesh(smooth=render3D?.smoothShading)
2993
+ {
2994
+ return buildGrid(this.size, vec2(this.columns - 1, this.rows - 1),
2995
+ this.colors && ((x, z)=> this.getColor(x, z)), (x, z)=> this.getHeight(x, z), smooth);
2996
+ }
2997
+ }
2998
+
2999
+ // read an image's pixel bytes through the engine's work canvas, as {data, width, height}
3000
+ function render3DImageData(image)
3001
+ {
3002
+ if (image instanceof TextureInfo)
3003
+ image = image.image;
3004
+ ASSERT(image && image.width && image.height, 'image is not loaded');
3005
+ ASSERT(workReadCanvas, 'reading an image needs a canvas, pass arrays in headless mode');
3006
+ const width = image.width, height = image.height;
3007
+ workReadCanvas.width = width;
3008
+ workReadCanvas.height = height;
3009
+ workReadContext.drawImage(image, 0, 0);
3010
+ return workReadContext.getImageData(0, 0, width, height);
3011
+ }
3012
+
3013
+ // read an image into a 2D array [row][column], sample is called with (r, g, b, a) bytes for each pixel
3014
+ function render3DImageToArray(image, sample)
3015
+ {
3016
+ const {data, width, height} = render3DImageData(image);
3017
+ const rows = [];
3018
+ for (let y = 0; y < height; ++y)
3019
+ {
3020
+ const row = rows[y] = [];
3021
+ for (let x = 0; x < width; ++x)
3022
+ {
3023
+ const k = (y * width + x) * 4;
3024
+ row.push(sample(data[k], data[k+1], data[k+2], data[k+3]));
3025
+ }
3026
+ }
3027
+ return rows;
3028
+ }
3029
+
3030
+ // extruded glyph meshes by font, and the pixel bytes of a texture, read once per image
3031
+ const render3DGlyphCache = new WeakMap, render3DPixelCache = new WeakMap;
3032
+ function render3DReadPixels(textureInfo)
3033
+ {
3034
+ const image = textureInfo.image;
3035
+ let pixels = render3DPixelCache.get(image);
3036
+ if (!pixels)
3037
+ render3DPixelCache.set(image, pixels = render3DImageData(image));
3038
+ return pixels;
3039
+ }
3040
+
3041
+ ///////////////////////////////////////////////////////////////////////////////
3042
+ /**
3043
+ * EngineObject3D - An EngineObject with a 3D transform and a mesh
3044
+ * - Set pos3D, rotation3D and scale3D instead of the 2D pos, size and angle
3045
+ * - Gets update, children, timers, destroy and renderOrder from EngineObject
3046
+ * - velocity3D is added to pos3D each frame, along with render3D.gravity and damping once it has a mass
3047
+ * - Objects face -Z, the same way the camera does, so lookAt turns them to face a point
3048
+ * - The 2D pos and velocity are still there but nothing draws them
3049
+ * - These inherited fields are 2D only and do nothing here: angle, angleVelocity, angleDamping,
3050
+ * additiveColor, drawSize, mirror, clampSpeed, friction and groundObject
3051
+ * - The inherited shader works here as in 2D, and with emissive at 1 its snippet does its own lighting
3052
+ * - Set sync2D for a 2D game with 3D looks, pos and angle then drive pos3D and rotation3D,
3053
+ * which is the one way those 2D fields reach a 3D object
3054
+ * - setCollision takes the same flags as in 2D, but the solid collision happens in 3D against size3D
3055
+ * - Its tile and raycast halves are 2D only so they default off here, and a child sits solid collision out
3056
+ * - A sync2D object collides in 2D instead, which needs the 2D size set as well as size3D
3057
+ * - setMesh swaps the mesh and frees the old one, for text and terrain that get built again
3058
+ * - addChild attaches the 3D transform, and pos3D becomes an offset from the parent
3059
+ * - The 2D offset arguments of addChild do nothing here, set the child's pos3D
3060
+ * @extends EngineObject
3061
+ * @memberof Render3D
3062
+ * @example
3063
+ * class Spinner extends EngineObject3D
3064
+ * {
3065
+ * constructor(pos) { super(pos, buildBox(), undefined, RED); }
3066
+ * update() { this.rotation3D.y += .02; }
3067
+ * }
3068
+ */
3069
+ class EngineObject3D extends EngineObject
3070
+ {
3071
+ /** Create a 3D object and add it to the object list
3072
+ * @param {Vector3} [pos3D] - World space position
3073
+ * @param {Mesh} [mesh] - Mesh to draw, undefined draws nothing
3074
+ * @param {TileInfo|TextureInfo} [tileInfo] - Texture, mesh uvs map across the tile; a whole TextureInfo becomes the tile that covers it
3075
+ * @param {Color} [color] - Tint */
3076
+ constructor(pos3D=vec3(), mesh, tileInfo, color=WHITE)
3077
+ {
3078
+ ASSERT(!tileInfo || tileInfo instanceof TileInfo || tileInfo instanceof TextureInfo, 'tileInfo must be a TileInfo or TextureInfo, it comes before color');
3079
+ // a whole texture is stored as the tile that covers it, with no padding or bleed to trim
3080
+ // the edges, so this is always a TileInfo like the 2D one and the object stays an EngineObject
3081
+ if (tileInfo instanceof TextureInfo)
3082
+ tileInfo = new TileInfo(vec2(), tileInfo.size, tileInfo, 0, 0);
3083
+ super(vec2(), vec2(), tileInfo, 0, color);
3084
+ ASSERT(isVector3(pos3D), 'pos3D must be a vec3');
3085
+ ASSERT(!mesh || mesh instanceof Mesh, 'mesh must be a Mesh or undefined');
3086
+ this.mass = 0; // static: no 2D physics, and no 3D gravity until a mass is set
3087
+
3088
+ /** @property {Vector3} - World space position, local to the parent when attached to an EngineObject3D */
3089
+ this.pos3D = pos3D.copy();
3090
+ /** @property {Vector3} - Rotation vec3(pitch, yaw, roll) in radians, local to the parent when attached to an EngineObject3D */
3091
+ this.rotation3D = vec3();
3092
+ /** @property {Vector3} - Scale, local to the parent when attached to an EngineObject3D */
3093
+ this.scale3D = vec3(1);
3094
+ /** @property {Vector3} - Added to pos3D each frame by the engine before update, like the 2D velocity, no super call needed;
3095
+ * damping and render3D.gravity act on it once the object has a mass */
3096
+ this.velocity3D = vec3();
3097
+ /** @property {Vector3} - Added to rotation3D each frame by the engine before update, angleDamping is 2D only */
3098
+ this.angleVelocity3D = vec3();
3099
+ /** @property {Mesh|undefined} - Mesh to draw
3100
+ * @type {Mesh|undefined} */
3101
+ this.mesh = mesh;
3102
+ /** @property {Vector3} - Size for the collect and callback helpers, and of the sprite when there is a tileInfo
3103
+ * and no mesh; scale3D and any parent's scale grow it, so drawing and picking agree */
3104
+ this.size3D = vec3(1);
3105
+ /** @property {number} - Diameter of a soft shadow drawn under the object on render3D.softShadowHeight, 0 for none;
3106
+ * scale3D and a parent's scale grow it, so set it once for the unscaled object */
3107
+ this.softShadow = 0;
3108
+ /** @property {boolean} - A sprite stands on world up instead of tilting toward the camera */
3109
+ this.upright = false;
3110
+ /** @property {boolean} - Keep this object's texture pixels hard edged, for pixel art that should not blur or bleed */
3111
+ this.pixelated = false;
3112
+ /** @property {boolean} - Copy the 2D pos and angle into pos3D and rotation3D each frame, for 2D games with 3D looks;
3113
+ * set mass to use 2D physics, and pos3D.z stays yours to set or move with velocity3D.z */
3114
+ this.sync2D = false;
3115
+ /** @property {boolean} - Draw in the transparent stage, blended and sorted far to near with depth writes off; on for a sprite */
3116
+ this.transparent = !mesh && !!tileInfo;
3117
+ /** @property {boolean} - Additive blending, in the transparent stage */
3118
+ this.additive = false;
3119
+ /** @property {number} - How much it lights itself: 0 is lit as normal, 1 is its own color with no shading, for
3120
+ * lamps and glowing things, between is partly self lit, and above 1 is brighter than its color, for bloom */
3121
+ this.emissive = 0;
3122
+ /** @property {number} - Strength of the highlight where the sunlight reflects, 0 is none and 1 adds the sun's full color at its brightest; its size is fixed */
3123
+ this.specular = 0;
3124
+ /** @property {boolean} - Draw into the shadow map when render3D.shadows is on; sprites and cut out textures cast their outline, additive objects never cast */
3125
+ this.castShadow = true;
3126
+ /** @property {boolean} - Collide as the sphere that fits size3D instead of as the size3D box, so it rolls around corners */
3127
+ this.collideAsSphere3D = false;
3128
+ /** @property {boolean} - Darkened by the shadow map when render3D.shadows is on */
3129
+ this.receiveShadow = true;
3130
+ /** @property {boolean|undefined} - Draw this object over the 2D scene, undefined uses render3D.renderAfter2D
3131
+ * @type {boolean|undefined} */
3132
+ this.renderAfter2D = undefined;
3133
+ }
3134
+
3135
+ /** Move by the 3D velocities and push out of solids, called automatically each frame before update, like the 2D physics
3136
+ * - update runs once every object has moved and collided, so bounce off anything else there, it lands before the draw
3137
+ * - Override this and call super to change how the object moves itself
3138
+ * - A sync2D object runs the 2D physics as well, and collides there instead */
3139
+ updatePhysics()
3140
+ {
3141
+ // a sync2D object collides in 2D, which measures the 2D size, and that starts at zero on a 3D object
3142
+ ASSERT(!this.sync2D || !this.collideSolidObjects || (this.size.x && this.size.y),
3143
+ 'a sync2D object collides in 2D, so give it a 2D size as well as a size3D', this.size);
3144
+ if (this.sync2D)
3145
+ super.updatePhysics();
3146
+ render3DMove(this);
3147
+ // the engine only runs this for objects that own where they are, a child rides along with its parent
3148
+ if (this.collideSolidObjects && !this.sync2D)
3149
+ render3DCollideSolid(this);
3150
+ }
3151
+
3152
+ /** Move a child by its own velocities, bring a sync2D object's pos3D up to its 2D pos, then update the children,
3153
+ * called automatically each frame */
3154
+ updateTransforms()
3155
+ {
3156
+ if (!paused)
3157
+ {
3158
+ // a child is never given updatePhysics, so it moves here, as an offset from its parent
3159
+ this.parent && render3DMove(this);
3160
+ if (this.sync2D)
3161
+ this.pos3D.x = this.pos.x, this.pos3D.y = this.pos.y, this.rotation3D.z = -this.angle;
3162
+ }
3163
+ super.updateTransforms();
3164
+ }
3165
+
3166
+ /** Set how this object collides, the same flags as in 2D
3167
+ * - Solid collision happens in 3D here, against size3D boxes or spheres; a child sits it out
3168
+ * - A sync2D object collides in 2D instead, against the 2D size, so set that as well as size3D
3169
+ * @param {boolean} [collideSolidObjects] - Take part in solid collision
3170
+ * @param {boolean} [isSolid] - Block other objects, a pair where neither one blocks passes through;
3171
+ * blocking needs collideSolidObjects, so isSolid on its own is not allowed
3172
+ * @param {boolean} [collideTiles] - Tile collision, 2D only so it needs sync2D
3173
+ * @param {boolean} [collideRaycast] - Raycasts, 2D only; 3D has render3D.pick and engineObjectsRaycast3D */
3174
+ setCollision(collideSolidObjects=true, isSolid=true, collideTiles=false, collideRaycast=false)
3175
+ { super.setCollision(collideSolidObjects, isSolid, collideTiles, collideRaycast); }
3176
+
3177
+ /** Returns the world position
3178
+ * @return {Vector3} */
3179
+ getWorldPos3D() { return this.getMatrix().getTranslation(); }
3180
+
3181
+ /** Returns the direction the object faces, its -Z axis in the world
3182
+ * @return {Vector3} */
3183
+ getForward3D() { return render3DAxis(this.getMatrix().m, 8).normalize(-1); }
3184
+
3185
+ /** Returns the object's right axis in the world
3186
+ * @return {Vector3} */
3187
+ getRight3D() { return render3DAxis(this.getMatrix().m, 0).normalize(); }
3188
+
3189
+ /** Returns the object's up axis in the world
3190
+ * @return {Vector3} */
3191
+ getUp3D() { return render3DAxis(this.getMatrix().m, 4).normalize(); }
3192
+
3193
+ /** Returns the object's world transform, relative to the parent's when attached to an EngineObject3D
3194
+ * @return {Matrix4} */
3195
+ getMatrix()
3196
+ {
3197
+ const matrix = buildMatrix(this.pos3D, this.rotation3D, this.scale3D);
3198
+ return this.parent instanceof EngineObject3D ? this.parent.getMatrix().multiply(matrix) : matrix;
3199
+ }
3200
+
3201
+ /** Turn the object so its -Z axis points at a world space target, sets pitch and yaw and clears roll
3202
+ * @param {Vector3} target */
3203
+ lookAt(target)
3204
+ {
3205
+ // rotation3D is local to the parent, so a child has to aim at the target from the parent's point of view
3206
+ const parent = this.parent instanceof EngineObject3D ? this.parent : undefined;
3207
+ const local = parent ? parent.getMatrix().invert().transformPoint(target) : target;
3208
+ this.rotation3D = render3DLookRotation(local.subtract(this.pos3D), this.rotation3D);
3209
+ }
3210
+
3211
+ /** Draw a different mesh and free the GPU buffer of the one it replaces
3212
+ * - For a mesh built again when something changes, like a score, a rebuilt terrain or a loaded model
3213
+ * - A mesh another object is still drawing is left alone, since builders are often shared
3214
+ * - Freeing one held somewhere else only costs it an upload, the points it was built from stay
3215
+ * @param {Mesh} [mesh] - The mesh to draw from now on, undefined to draw nothing
3216
+ * @return {Mesh|undefined} - The mesh passed in */
3217
+ setMesh(mesh)
3218
+ {
3219
+ ASSERT(!mesh || mesh instanceof Mesh, 'mesh must be a Mesh or undefined');
3220
+ const old = this.mesh;
3221
+ this.mesh = mesh;
3222
+ // nothing to free and nothing to look for when it was never uploaded
3223
+ if (old && old !== mesh && old.buffer && !engineObjects.some(o=> o.mesh === old))
3224
+ old.dispose();
3225
+ return mesh;
3226
+ }
3227
+
3228
+ /** 2D rendering is skipped, the mesh is drawn by render3D during the 3D pass */
3229
+ render() {}
3230
+
3231
+ /** 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 */
3232
+ render3D()
3233
+ {
3234
+ // an opaque draw comes out solid however low its alpha is, so a fade with no flag looks like nothing happened
3235
+ 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);
3236
+ if (this.mesh)
3237
+ render3D.drawMesh(this.mesh, this.getMatrix(), this.tileInfo, this.color);
3238
+ else if (this.tileInfo)
3239
+ {
3240
+ // a sprite: size3D grown by its own scale and its parents', the same world size the
3241
+ // collect, pick and solid collision helpers measure it at
3242
+ const m = this.getMatrix().m;
3243
+ render3D.drawBillboard(vec3(m[12], m[13], m[14]),
3244
+ vec2(this.size3D.x * hypot(m[0], m[1], m[2]), this.size3D.y * hypot(m[4], m[5], m[6])),
3245
+ this.tileInfo, this.color, this.rotation3D.z, this.upright);
3246
+ }
3247
+ }
3248
+ }
3249
+
3250
+ // move an object by its 3D velocities, an object with mass falling with render3D.gravity and slowing by its damping
3251
+ function render3DMove(o)
3252
+ {
3253
+ if (o.mass && !o.sync2D) // a 2D driven object gets the 2D gravity instead
3254
+ {
3255
+ // damped first and gravity added after, the order EngineObject.updatePhysics uses,
3256
+ // so the same mass, damping and gravity fall the same way in both
3257
+ const v = o.velocity3D, g = render3D.gravity, s = o.gravityScale, d = o.damping;
3258
+ o.velocity3D = vec3(v.x * d + g.x * s, v.y * d + g.y * s, v.z * d + g.z * s);
3259
+ }
3260
+ o.pos3D = o.pos3D.add(o.velocity3D);
3261
+ o.rotation3D = o.rotation3D.add(o.angleVelocity3D);
3262
+ }
3263
+
3264
+ // where a solid object is in the world and what it collides as: the sphere that fits size3D, or the size3D box,
3265
+ // each grown by the object's scale
3266
+ // only objects that own where they are take part, so pos3D is already world space, and however the object is
3267
+ // turned its axes come out as long as its scale makes them; building the transform to read that back off it
3268
+ // costs six trig calls and a matrix for every pair tested, which is the whole cost of a crowded scene
3269
+ function render3DSolidShape(o)
3270
+ {
3271
+ ASSERT(!o.parent, 'a child rides along with its parent, it has no world pos3D of its own to collide with');
3272
+ const s = o.size3D, k = o.scale3D;
3273
+ const kx = abs(k.x), ky = abs(k.y), kz = abs(k.z);
3274
+ if (o.collideAsSphere3D)
3275
+ return {pos: o.pos3D.copy(), radius: max(s.x, s.y, s.z) / 2 * max(kx, ky, kz)};
3276
+ return {pos: o.pos3D.copy(), size: vec3(s.x * kx, s.y * ky, s.z * kz)};
3277
+ }
3278
+
3279
+ // how far a solid shape can reach from its own center, for a quick reject before the exact test
3280
+ // it has to be the shape's own radius, or a wider one: a box reaches to its corner, and a sphere
3281
+ // takes the largest scale the same way render3DSolidShape does, or the reject would skip real touches
3282
+ function render3DSolidReach(o)
3283
+ {
3284
+ const s = o.size3D, k = o.scale3D;
3285
+ const kx = abs(k.x), ky = abs(k.y), kz = abs(k.z);
3286
+ if (o.collideAsSphere3D)
3287
+ return max(s.x, s.y, s.z) / 2 * max(kx, ky, kz);
3288
+ return hypot(s.x * kx, s.y * ky, s.z * kz) / 2;
3289
+ }
3290
+
3291
+ // what it takes to move shape a clear of shape b, whichever pair of shapes they are, or undefined for no touch
3292
+ function render3DSolidPush(a, b)
3293
+ {
3294
+ if (!a.size) // a is a sphere
3295
+ return b.size ? collideSphereBox(a.pos, a.radius, b.pos, b.size)
3296
+ : collideSphereSphere(a.pos, a.radius, b.pos, b.radius);
3297
+ if (!b.size) // only b is, so push b out of a and turn it around
3298
+ {
3299
+ const push = collideSphereBox(b.pos, b.radius, a.pos, a.size);
3300
+ return push && push.scale(-1);
3301
+ }
3302
+ return collideBoxBox3D(a.pos, a.size, b.pos, b.size);
3303
+ }
3304
+
3305
+ // push a solid object out of the solids before it in the engine's list of them, so each pair is resolved once:
3306
+ // the ones after it update later and test against it then, and an object that is not in the list yet, because it
3307
+ // turned collision on this frame, tests them all itself and is not tested back
3308
+ // one pair per test is half the work of the 2D solver, which tests both directions; the difference only shows
3309
+ // when a collideWithObject destroys some third object, whose own turn then finds the pair already gone
3310
+ function render3DCollideSolid(a)
3311
+ {
3312
+ let shapeA = render3DSolidShape(a);
3313
+ const reachA = render3DSolidReach(a);
3314
+ for (const b of engineObjectsCollide)
3315
+ {
3316
+ if (b === a) break;
3317
+ if (b.destroyed || b.parent || b.sync2D || !(b instanceof EngineObject3D)) continue; // a child is part of its parent
3318
+ if (!a.isSolid && !b.isSolid) continue; // neither one blocks, so they pass through each other
3319
+
3320
+ // the pairs nowhere near each other are almost all of them in a scene of any size, so
3321
+ // settle those with one distance check instead of building a shape for each
3322
+ const p = shapeA.pos, q = b.pos3D, reach = reachA + render3DSolidReach(b);
3323
+ const dx = p.x - q.x, dy = p.y - q.y, dz = p.z - q.z;
3324
+ if (dx*dx + dy*dy + dz*dz > reach*reach)
3325
+ continue;
3326
+
3327
+ const push = render3DSolidPush(shapeA, render3DSolidShape(b));
3328
+ if (!push) continue;
3329
+
3330
+ // both objects hear about it, and either one can take the touch over
3331
+ const resolveA = a.collideWithObject(b, push);
3332
+ const resolveB = b.collideWithObject(a, push.scale(-1));
3333
+ if (!resolveA || !resolveB) continue;
3334
+
3335
+ // heavier objects move less, mass 0 stays put; then bounce apart when moving toward each other
3336
+ const total = a.mass + b.mass;
3337
+ const weightA = !a.mass ? 0 : !b.mass ? 1 : b.mass / total;
3338
+ const weightB = !b.mass ? 0 : !a.mass ? 1 : a.mass / total;
3339
+ a.pos3D = a.pos3D.add(push.scale(weightA));
3340
+ b.pos3D = b.pos3D.subtract(push.scale(weightB));
3341
+ if (weightA)
3342
+ shapeA = render3DSolidShape(a); // it moved, so the next solid must be tested against where it is now
3343
+ const normal = push.normalize();
3344
+ if (a.velocity3D.dot(normal) < 0)
3345
+ a.velocity3D = a.velocity3D.reflect(normal, a.restitution);
3346
+ if (b.velocity3D.dot(normal) > 0)
3347
+ b.velocity3D = b.velocity3D.reflect(normal, b.restitution);
3348
+ }
3349
+ }
3350
+
3351
+ /**
3352
+ * Collect the EngineObject3D objects whose boxes overlap a box, sizes are full sizes
3353
+ * - Boxes are axis aligned around the world position, rotation3D is ignored; lights, emitters and trails have no size
3354
+ * @param {Vector3} pos - Center of the box
3355
+ * @param {Vector3|number} size - Full size of the box, a number for a cube
3356
+ * @param {Array<EngineObject>} [objects] - Defaults to every object
3357
+ * @return {Array<EngineObject3D>}
3358
+ * @memberof Render3D
3359
+ */
3360
+ function engineObjectsCollect3D(pos, size, objects=engineObjects)
3361
+ {
3362
+ size = render3DSize3(size);
3363
+ const collected = [];
3364
+ for (const o of objects)
3365
+ {
3366
+ if (!(o instanceof EngineObject3D) || o.destroyed) continue;
3367
+ const m = o.getMatrix().m, s = o.size3D; // the box in world space, scaled by the object and its parents
3368
+ if (!(s.x || s.y || s.z)) continue;
3369
+ 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]));
3370
+ if (isOverlapping3D(pos, size, vec3(m[12], m[13], m[14]), worldSize))
3371
+ collected.push(o);
3372
+ }
3373
+ return collected;
3374
+ }
3375
+
3376
+ // how far along a ray an object is hit, or undefined for a miss; each one is tested as a sphere
3377
+ // around its mesh, or around a sprite's size3D, not triangle by triangle
3378
+ function render3DRaycastObject(ray, o)
3379
+ {
3380
+ if (o.destroyed || !(o instanceof EngineObject3D) || !(o.mesh || o.tileInfo)) return;
3381
+ const matrix = o.getMatrix(), mesh = o.mesh; // a sprite is picked by its size3D
3382
+ const radius = (mesh ? mesh.radius || mesh.computeRadius() : hypot(o.size3D.x, o.size3D.y) / 2) * render3DMaxScale(matrix.m);
3383
+ if (!(radius > 0)) return; // nothing to hit
3384
+ return raycastSphere(ray, matrix.getTranslation(), radius);
3385
+ }
3386
+
3387
+ /**
3388
+ * Collect every EngineObject3D a ray passes through, nearest first, the 3D twin of engineObjectsRaycast
3389
+ * - The ray has no end, so everything along it counts however far away it is
3390
+ * - Use render3D.pick for the nearest one on its own, with the distance to it
3391
+ * @param {Ray3D} ray - From render3D.screenToRay, or any ray
3392
+ * @param {Array<EngineObject>} [objects] - Defaults to every object; only those with a mesh or a sprite count
3393
+ * @return {Array<EngineObject3D>}
3394
+ * @memberof Render3D
3395
+ */
3396
+ function engineObjectsRaycast3D(ray, objects=engineObjects)
3397
+ {
3398
+ const hits = [];
3399
+ for (const o of objects)
3400
+ {
3401
+ const distance = render3DRaycastObject(ray, o);
3402
+ if (distance !== undefined)
3403
+ hits.push({o, distance});
3404
+ }
3405
+ return hits.sort((a, b)=> a.distance - b.distance).map(hit=> hit.o);
3406
+ }
3407
+
3408
+ /**
3409
+ * Call a function for each EngineObject3D whose box overlaps a box
3410
+ * @param {Vector3} pos - Center of the box
3411
+ * @param {Vector3|number} size - Full size of the box, a number for a cube
3412
+ * @param {Function} callback
3413
+ * @param {Array<EngineObject>} [objects] - Defaults to every object
3414
+ * @memberof Render3D
3415
+ */
3416
+ function engineObjectsCallback3D(pos, size, callback, objects=engineObjects)
3417
+ { engineObjectsCollect3D(pos, size, objects).forEach(callback); }
3418
+
3419
+ ///////////////////////////////////////////////////////////////////////////////
3420
+ /**
3421
+ * Light3D - A light that is an EngineObject3D, so it can move, follow a parent or be destroyed like anything else
3422
+ * - A point light: it lights what is near it and fades out by its radius, DirectionalLight3D shines from far away
3423
+ * - Only the sun, render3D.sunDirection, casts shadows and makes highlights, these light without either
3424
+ * - Only the 8 lights nearest the camera are used each frame
3425
+ * - radius is where the light fades out, and it fades fast, so a small radius wants a higher intensity
3426
+ * - intensity multiplies the color, above 1 for a light brighter than white
3427
+ * - radius is a world distance, so scale3D does not change it
3428
+ * - An alpha, an intensity or a radius of 0 switches it off, and a light that is off takes none of those slots
3429
+ * - Draws nothing itself, add a glow with drawSoftDisc or a small emissive mesh if it should be seen
3430
+ * @extends EngineObject3D
3431
+ * @memberof Render3D
3432
+ * @example
3433
+ * const torch = new Light3D(vec3(0, 3, 0), 10, hsl(.1, 1, .65));
3434
+ */
3435
+ class Light3D extends EngineObject3D
3436
+ {
3437
+ /** Create a point light
3438
+ * @param {Vector3} [pos3D] - Where it is
3439
+ * @param {number} [radius] - Distance where the light fades to nothing
3440
+ * @param {Color} [color] - Light color, its alpha fades it
3441
+ * @param {number} [intensity] - Brightness, multiplies the color, above 1 is brighter than white */
3442
+ constructor(pos3D=vec3(), radius=5, color=WHITE, intensity=1)
3443
+ {
3444
+ super(pos3D, undefined, undefined, color);
3445
+ ASSERT(radius >= 0, 'light radius cannot be negative, 0 is an off switch like an alpha of 0');
3446
+ ASSERT(intensity >= 0, 'light intensity cannot be negative, 0 is an off switch');
3447
+ this.size3D = vec3(); // not a solid thing to pick or collect
3448
+ /** @property {number} - Distance where the light fades to nothing */
3449
+ this.radius = radius;
3450
+ /** @property {number} - Brightness, multiplies the color, above 1 is brighter than white */
3451
+ this.intensity = intensity;
3452
+ /** @property {boolean} - Shine from far away, from its position toward the origin, instead of out from its
3453
+ * position with a falloff; DirectionalLight3D sets it */
3454
+ this.directional = false;
3455
+ }
3456
+
3457
+ /** Lights draw nothing */
3458
+ render3D() {}
3459
+ }
3460
+
3461
+ ///////////////////////////////////////////////////////////////////////////////
3462
+ /**
3463
+ * DirectionalLight3D - A Light3D that shines from far away with no falloff, like sunlight
3464
+ * - It shines from its position toward the origin, like a three.js DirectionalLight: only the direction to it
3465
+ * counts, so moving it or its parent swings the light around; parent it to a sun in the sky and it follows
3466
+ * - It cannot sit on the origin, since that leaves no direction
3467
+ * - Like every Light3D it casts no shadow and makes no highlight, only the sun, render3D.sunDirection, does
3468
+ * @extends Light3D
3469
+ * @memberof Render3D
3470
+ * @example
3471
+ * const fill = new DirectionalLight3D(vec3(-1, 1, 1), hsl(.6, .5, .3)); // from the back left and above
3472
+ */
3473
+ class DirectionalLight3D extends Light3D
3474
+ {
3475
+ /** Create a directional light
3476
+ * @param {Vector3} [pos3D] - Where it shines from, toward the origin
3477
+ * @param {Color} [color] - Light color, its alpha fades it
3478
+ * @param {number} [intensity] - Brightness, multiplies the color, above 1 is brighter than white */
3479
+ constructor(pos3D=vec3(0, 1, 0), color=WHITE, intensity=1)
3480
+ {
3481
+ super(pos3D, 0, color, intensity);
3482
+ this.directional = true;
3483
+ }
3484
+ }
3485
+
3486
+ ///////////////////////////////////////////////////////////////////////////////
3487
+ /**
3488
+ * CameraControl3D - Drag to turn the camera around a point, roll the wheel to zoom
3489
+ * - An EngineObject3D, so move its pos3D to follow something, or parent it to an object
3490
+ * - Destroy it to hand the camera back, and it stops driving the camera
3491
+ * - Set persistent to keep it when engineObjectsDestroy clears out a level
3492
+ * - Every part of it is a field, so a game can change the buttons, speeds and limits
3493
+ * @extends EngineObject3D
3494
+ * @memberof Render3D
3495
+ * @example
3496
+ * new CameraControl3D(vec3(0, 1, 0), 15); // look at a point from 15 units away
3497
+ */
3498
+ class CameraControl3D extends EngineObject3D
3499
+ {
3500
+ /** Create a camera control, it drives render3D.camera every frame
3501
+ * @param {Vector3} [target] - The point to look at, its pos3D
3502
+ * @param {number} [distance] - How far the camera sits from the target
3503
+ * @param {number} [pitch] - Angle above the horizon, PI/2 looks straight down
3504
+ * @param {number} [idleSpin] - Turned each frame while not dragging, 0 holds still */
3505
+ constructor(target=vec3(), distance=10, pitch=.4, idleSpin=0)
3506
+ {
3507
+ super(target);
3508
+ this.size3D = vec3(); // not a solid thing to pick or collect
3509
+ /** @property {number} - How far the camera sits from the target */
3510
+ this.distance = distance;
3511
+ /** @property {number} - Angle above the horizon */
3512
+ this.pitch = pitch;
3513
+ /** @property {number} - Turned each frame while not dragging */
3514
+ this.idleSpin = idleSpin;
3515
+ /** @property {number} - Angle around the target, dragging changes it */
3516
+ this.yaw = 0;
3517
+ /** @property {number} - Mouse button that turns the camera, 0 is left and 2 is right */
3518
+ this.dragButton = 0;
3519
+ /** @property {number} - How far dragging a pixel turns the camera */
3520
+ this.dragSpeed = .01;
3521
+ /** @property {number} - How much one wheel notch zooms, 0 turns zooming off */
3522
+ this.zoomSpeed = .1;
3523
+ /** @property {Vector2} - Closest and furthest the wheel can zoom to */
3524
+ this.zoomRange = vec2(distance/4, distance*3);
3525
+ /** @property {Vector2} - Lowest and highest pitch, so it cannot tip over the top */
3526
+ this.pitchRange = vec2(-.2, 1.4);
3527
+ }
3528
+
3529
+ /** Read the mouse and put the camera on its orbit, called automatically each frame */
3530
+ update()
3531
+ {
3532
+ if (mouseIsDown(this.dragButton))
3533
+ {
3534
+ // the scene follows the drag
3535
+ this.yaw -= mouseDeltaScreen.x * this.dragSpeed;
3536
+ this.pitch += mouseDeltaScreen.y * this.dragSpeed;
3537
+ }
3538
+ else
3539
+ this.yaw += this.idleSpin;
3540
+ this.pitch = clamp(this.pitch, this.pitchRange.x, this.pitchRange.y);
3541
+ if (this.zoomSpeed && mouseWheel)
3542
+ this.distance = clamp(this.distance * (1 + sign(mouseWheel) * this.zoomSpeed), this.zoomRange.x, this.zoomRange.y);
3543
+ render3D.camera.orbit(this.getWorldPos3D(), this.distance, this.yaw, this.pitch);
3544
+ }
3545
+
3546
+ /** Camera controls draw nothing */
3547
+ render3D() {}
3548
+ }
3549
+
3550
+ ///////////////////////////////////////////////////////////////////////////////
3551
+ /**
3552
+ * FirstPersonCamera3D - Look around with the mouse and move with the keys, with the camera at its position
3553
+ * - Click to capture the mouse so looking needs no button held, Esc lets it go; holding the button looks too, for touch
3554
+ * - WASD or the arrow keys walk level, or move the way it looks when fly is set
3555
+ * - An EngineObject3D that moves by velocity3D, so give it a size3D and call setCollision to walk into solid
3556
+ * objects instead of through them; walking keeps velocity3D.y, so render3D.gravity can pull it down
3557
+ * - Starts from wherever render3D.camera is, so it can take over from another camera without a jump
3558
+ * - Destroy it to hand the camera back
3559
+ * @extends EngineObject3D
3560
+ * @memberof Render3D
3561
+ * @example
3562
+ * const player = new FirstPersonCamera3D(vec3(0, 1.5, 5));
3563
+ * player.size3D = vec3(1); // bump into solid objects
3564
+ * player.collideAsSphere3D = true;
3565
+ * player.setCollision();
3566
+ */
3567
+ class FirstPersonCamera3D extends EngineObject3D
3568
+ {
3569
+ /** Create a first person camera, it drives render3D.camera every frame
3570
+ * @param {Vector3} [pos3D] - Where the eye is, defaults to where the camera is now
3571
+ * @param {number} [yaw] - Radians around Y, defaults to the camera's
3572
+ * @param {number} [pitch] - Radians up from level, defaults to the camera's */
3573
+ constructor(pos3D=render3D.camera.pos, yaw=render3D.camera.rotation.y, pitch=render3D.camera.rotation.x)
3574
+ {
3575
+ super(pos3D);
3576
+ this.size3D = vec3(); // not a solid thing to pick or collect until it is given a size
3577
+ this.mass = 1; // so solids push it out, and render3D.gravity pulls on it
3578
+ /** @property {number} - Angle around Y, the mouse turns it */
3579
+ this.yaw = yaw;
3580
+ /** @property {number} - Angle up from level, the mouse tilts it */
3581
+ this.pitch = pitch;
3582
+ /** @property {number} - World units per frame at full speed */
3583
+ this.moveSpeed = .1;
3584
+ /** @property {number} - How far a pixel of mouse movement turns the view */
3585
+ this.lookSpeed = .003;
3586
+ /** @property {Vector2} - Lowest and highest pitch */
3587
+ this.pitchRange = vec2(-1.5, 1.5);
3588
+ /** @property {boolean} - Move the way it looks, up and down included, instead of walking level */
3589
+ this.fly = false;
3590
+ /** @property {boolean} - Capture the mouse on a click, so looking needs no button held */
3591
+ this.lockPointer = true;
3592
+ }
3593
+
3594
+ /** Read the mouse and keys and put the camera at the eye, called automatically each frame */
3595
+ update()
3596
+ {
3597
+ // a click captures the mouse, then it looks around while captured or while a button is held
3598
+ if (this.lockPointer && mouseWasPressed(0))
3599
+ pointerLockRequest();
3600
+ if (pointerLockIsActive() || mouseIsDown(0))
3601
+ {
3602
+ this.yaw -= mouseDeltaScreen.x * this.lookSpeed;
3603
+ this.pitch -= mouseDeltaScreen.y * this.lookSpeed;
3604
+ }
3605
+ this.pitch = clamp(this.pitch, this.pitchRange.x, this.pitchRange.y);
3606
+
3607
+ // the keys move it level, or the way it looks when flying, and walking keeps its fall
3608
+ const input = keyDirection();
3609
+ const move = vec3(input.x, 0, -input.y).clampLength(1).scale(this.moveSpeed)
3610
+ .rotateX(this.fly ? this.pitch : 0).rotateY(this.yaw);
3611
+ this.velocity3D = this.fly ? move : vec3(move.x, this.velocity3D.y, move.z);
3612
+
3613
+ // the camera sits at the eye, where this frame's physics left it
3614
+ render3D.camera.pos = this.getWorldPos3D();
3615
+ render3D.camera.rotation = vec3(this.pitch, this.yaw, 0);
3616
+ }
3617
+
3618
+ /** Let go of the mouse and stop driving the camera
3619
+ * @param {boolean} [immediate] */
3620
+ destroy(immediate)
3621
+ {
3622
+ this.lockPointer && pointerLockIsActive() && pointerLockExit();
3623
+ super.destroy(immediate);
3624
+ }
3625
+
3626
+ /** Camera controls draw nothing */
3627
+ render3D() {}
3628
+ }
3629
+
3630
+ ///////////////////////////////////////////////////////////////////////////////
3631
+ /**
3632
+ * ParticleEmitter3D - Spawns camera facing particles, the 3D twin of ParticleEmitter
3633
+ * - Each particle is a flat square facing the camera, with a soft round dot when no tile is given
3634
+ * - Set trailTime to draw each particle as a streak along where it has been, for sparks
3635
+ * - Set angleSpeed to tumble them in the camera plane, which the 2D emitter takes as an argument
3636
+ * - Particles shoot out along the emitter's own up axis, turned by rotation3D
3637
+ * - emitConeAngle spreads them, PI sprays in every direction
3638
+ * - Speeds are per frame and sizes are world units, the same as the 2D emitter
3639
+ * - scale3D, its own or a parent's, grows the whole effect: the spawn area, the sizes, the speed and the fall
3640
+ * - gravity here is its own number added to velocity y each frame: it is neither the engine's 2D
3641
+ * gravity nor render3D.gravity, so an effect keeps its own fall wherever it is used
3642
+ * - An emitter with an emitTime destroys itself once its last particle is gone, like the 2D emitter
3643
+ * @extends EngineObject3D
3644
+ * @memberof Render3D
3645
+ * @example
3646
+ * // fire: a stream upward, yellow fading to transparent red, additive
3647
+ * 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);
3648
+ */
3649
+ class ParticleEmitter3D extends EngineObject3D
3650
+ {
3651
+ /** Create a particle emitter
3652
+ * @param {Vector3} [pos3D] - World space position of the emitter
3653
+ * @param {number|Vector3} [emitSize] - Spawn area, a number for a sphere diameter or a vec3 for a box
3654
+ * @param {number} [emitTime] - How long to keep emitting, 0 is forever
3655
+ * @param {number} [emitRate] - Particles per second, 0 does not emit
3656
+ * @param {number} [emitConeAngle] - Half angle around the emit direction, PI is every direction
3657
+ * @param {TileInfo|TextureInfo} [tileInfo] - Tile to render particles with, or a whole texture, undefined is untextured
3658
+ * @param {Color} [colorStartA] - Color at start of life, randomized between the start colors
3659
+ * @param {Color} [colorStartB]
3660
+ * @param {Color} [colorEndA] - Color at end of life, randomized between the end colors
3661
+ * @param {Color} [colorEndB]
3662
+ * @param {number} [particleTime] - How long particles live in seconds
3663
+ * @param {number} [sizeStart] - Particle size at start of life
3664
+ * @param {number} [sizeEnd] - Particle size at end of life
3665
+ * @param {number} [speed] - Spawn speed in world units per frame
3666
+ * @param {number} [damping] - Per frame velocity multiplier, 1 is none
3667
+ * @param {number} [gravity] - Per frame change to velocity y, negative pulls down; its own number,
3668
+ * not render3D.gravity, so the 2D emitter's gravityScale has no equivalent here
3669
+ * @param {number} [fadeRate] - Fraction of life spent fading, half in and half out
3670
+ * @param {number} [randomness] - Extra randomness applied to speed, size and life
3671
+ * @param {boolean} [additive] - Additive blending */
3672
+ constructor(pos3D=vec3(), emitSize=0, emitTime=0, emitRate=100, emitConeAngle=PI, tileInfo,
3673
+ colorStartA=WHITE, colorStartB=WHITE, colorEndA=CLEAR_WHITE, colorEndB=CLEAR_WHITE,
3674
+ particleTime=.5, sizeStart=.1, sizeEnd=1, speed=.1, damping=1, gravity=0, fadeRate=.1, randomness=.2, additive=false)
3675
+ {
3676
+ super(pos3D, undefined, tileInfo);
3677
+ this.transparent = true;
3678
+ this.castShadow = false;
3679
+ this.size3D = vec3(); // not a solid thing to pick or collect
3680
+
3681
+ /** @property {number|Vector3} - Spawn area, a number for a sphere diameter or a vec3 for a box */
3682
+ this.emitSize = emitSize;
3683
+ /** @property {number} - How long to keep emitting, 0 is forever */
3684
+ this.emitTime = emitTime;
3685
+ /** @property {number} - Particles per second, 0 does not emit */
3686
+ this.emitRate = emitRate;
3687
+ /** @property {number} - Half angle around the emit direction, PI is every direction */
3688
+ this.emitConeAngle = emitConeAngle;
3689
+ /** @property {Color} - Color at start of life, randomized between the start colors */
3690
+ this.colorStartA = colorStartA.copy();
3691
+ /** @property {Color} - Color at start of life, randomized between the start colors */
3692
+ this.colorStartB = colorStartB.copy();
3693
+ /** @property {Color} - Color at end of life, randomized between the end colors */
3694
+ this.colorEndA = colorEndA.copy();
3695
+ /** @property {Color} - Color at end of life, randomized between the end colors */
3696
+ this.colorEndB = colorEndB.copy();
3697
+ /** @property {number} - How long particles live in seconds */
3698
+ this.particleTime = particleTime;
3699
+ /** @property {number} - Particle size at start of life */
3700
+ this.sizeStart = sizeStart;
3701
+ /** @property {number} - Particle size at end of life */
3702
+ this.sizeEnd = sizeEnd;
3703
+ /** @property {number} - Spawn speed in world units per frame */
3704
+ this.speed = speed;
3705
+ /** @property {number} - Per frame velocity multiplier */
3706
+ this.damping = damping;
3707
+ /** @property {number} - Per frame change to velocity y, its own number and not render3D.gravity */
3708
+ this.gravity = gravity;
3709
+ /** @property {number} - Fraction of life spent fading, half in and half out */
3710
+ this.fadeRate = fadeRate;
3711
+ /** @property {number} - Extra randomness applied to speed, size and life */
3712
+ this.randomness = randomness;
3713
+ /** @property {boolean} - Additive blending */
3714
+ this.additive = additive;
3715
+ /** @property {number} - Seconds of each particle's path to draw as a ribbon behind it, 0 draws billboards */
3716
+ this.trailTime = 0;
3717
+ /** @property {number} - Radians per frame each particle turns in the camera plane, either way; 0 is no spin */
3718
+ this.angleSpeed = 0;
3719
+ /** @property {number} - Per frame multiplier on that spin, 1 keeps it */
3720
+ this.angleDamping = 1;
3721
+ /** @property {Array<Object>} - Live particles
3722
+ * @type {Array<Object>} */
3723
+ this.particles = [];
3724
+ this.emitTimeBuffer = 0;
3725
+ }
3726
+
3727
+ /** Spawn new particles, move the live ones, and go away when done */
3728
+ update()
3729
+ {
3730
+ // one transform for the frame: where the emitter is, and how big the effect it makes is
3731
+ const matrix = this.getMatrix();
3732
+ this.worldPos3D = matrix.getTranslation(); // remembered for when the parent is destroyed
3733
+ const scale = render3DMaxScale(matrix.m);
3734
+
3735
+ // emit until the emit time is up, then wait for the last particle and go away
3736
+ if (!this.emitTime || this.getAliveTime() <= this.emitTime)
3737
+ {
3738
+ // a rate of zero is an emitter fed by hand, and the global scale only quiets it,
3739
+ // neither is a reason to stop counting down the emit time
3740
+ if (this.emitRate && particleEmitRateScale)
3741
+ {
3742
+ this.emitTimeBuffer += this.emitRate * particleEmitRateScale * timeDelta;
3743
+ for (; this.emitTimeBuffer >= 1; --this.emitTimeBuffer)
3744
+ this.emitParticle();
3745
+ }
3746
+ }
3747
+ else if (!this.particles.length)
3748
+ this.destroy();
3749
+
3750
+ // move the particles and drop the dead ones
3751
+ const particles = this.particles;
3752
+ for (let i = particles.length; i--;)
3753
+ {
3754
+ // damped first and gravity added after, the order the 2D particle uses, so the same
3755
+ // damping and gravity give the same arc in both
3756
+ const p = particles[i], v = p.velocity;
3757
+ v.x *= this.damping, v.y *= this.damping, v.z *= this.damping; // in place, this runs per particle
3758
+ v.y += this.gravity * scale; // a bigger effect has to fall faster to keep the same arc
3759
+ p.pos = p.pos.add(v);
3760
+ p.angle += p.angleVelocity *= this.angleDamping;
3761
+ if (this.trailTime)
3762
+ {
3763
+ // remember where it has been, oldest first
3764
+ const trail = p.trail || (p.trail = []);
3765
+ trail.push(p.pos);
3766
+ const extra = trail.length - this.trailTime / timeDelta;
3767
+ extra > 0 && trail.splice(0, extra);
3768
+ }
3769
+ if ((p.age += timeDelta) >= p.life)
3770
+ particles[i] = particles[particles.length - 1], particles.pop(); // swap with the last, order does not matter
3771
+ }
3772
+ }
3773
+
3774
+ /** Stop emitting, and go away once the particles already out have finished like the 2D emitter's do
3775
+ * @param {boolean} [immediate] */
3776
+ destroy(immediate)
3777
+ {
3778
+ if (immediate || !this.particles.length || this.destroyed)
3779
+ return super.destroy(immediate);
3780
+ this.emitTime = -1; // stops emitting, and update destroys it once the particles are gone
3781
+ render3DDetach(this); // the particles are in world space, they no longer need the parent
3782
+ }
3783
+
3784
+ /** Spawn one particle now */
3785
+ emitParticle()
3786
+ {
3787
+ const random = ()=> rand(1 - this.randomness, 1 + this.randomness);
3788
+ const matrix = this.getMatrix();
3789
+ // the whole effect grows with the emitter, not just the area the particles start in
3790
+ const scale = render3DMaxScale(matrix.m);
3791
+
3792
+ // spawn offset: inside a box or a sphere
3793
+ const size = this.emitSize;
3794
+ const offset = isVector3(size) ? vec3(rand(-.5, .5) * size.x, rand(-.5, .5) * size.y, rand(-.5, .5) * size.z)
3795
+ : randInSphere(size / 2);
3796
+
3797
+ // direction inside the cone around local +Y
3798
+ const direction = matrix.transformDirection(randVector3(1, this.emitConeAngle)).normalize();
3799
+
3800
+ this.particles.push({
3801
+ pos: matrix.transformPoint(offset),
3802
+ velocity: direction.scale(this.speed * random() * scale),
3803
+ colorStart: randColor(this.colorStartA, this.colorStartB, true),
3804
+ colorEnd: randColor(this.colorEndA, this.colorEndB, true),
3805
+ sizeStart: this.sizeStart * random() * scale,
3806
+ sizeEnd: this.sizeEnd * random() * scale,
3807
+ life: this.particleTime * random(),
3808
+ // a spinning particle starts anywhere and turns either way, one that is not stays at zero
3809
+ angle: this.angleSpeed ? rand(2*PI) : 0,
3810
+ angleVelocity: this.angleSpeed ? this.angleSpeed * random() * randSign() : 0,
3811
+ age: 0 });
3812
+ }
3813
+
3814
+ /** Draw the particles, as flat squares or as streaks when trailTime is set
3815
+ * - The whole emitter sorts as one thing, its particles are not sorted against each other */
3816
+ render3D()
3817
+ {
3818
+ if (render3D.transparentQueue)
3819
+ return render3D.queueTransparent(this.getWorldPos3D(), ()=> this.render3D());
3820
+ const fade = this.fadeRate / 2, texture = this.tileInfo || render3DSoftDot(); // no dot headless
3821
+ for (const p of this.particles)
3822
+ {
3823
+ const t = p.age / p.life;
3824
+ const alpha = t < fade ? t / fade : t > 1 - fade ? (1 - t) / fade : 1;
3825
+ const color = p.colorStart.lerp(p.colorEnd, t), size = lerp(p.sizeStart, p.sizeEnd, t);
3826
+ color.a *= alpha;
3827
+ const trail = p.trail;
3828
+ if (trail && trail.length > 1)
3829
+ {
3830
+ // a ribbon from the tail to the head, the tail thins and fades out
3831
+ const widths = [], colors = [];
3832
+ for (let i = 0; i < trail.length; ++i)
3833
+ {
3834
+ const s = (i + 1) / trail.length;
3835
+ widths.push(size * s);
3836
+ colors.push(color.scale(1, s));
3837
+ }
3838
+ render3D.drawRibbon(trail, widths, this.tileInfo, colors);
3839
+ }
3840
+ else if (texture)
3841
+ render3D.drawBillboard(p.pos, vec2(size), texture, color, p.angle);
3842
+ else
3843
+ render3D.drawSoftDisc(p.pos, size, color, undefined, 8); // no canvas for the dot, headless
3844
+ }
3845
+ }
3846
+ }
3847
+
3848
+ ///////////////////////////////////////////////////////////////////////////////
3849
+ /**
3850
+ * Trail3D - A ribbon through where the object has been, thinning and fading with age
3851
+ * - Records its world position each frame it moves, so parent it to something that moves or set pos3D yourself
3852
+ * - The samples are world space, so width is a world width and scale3D does nothing to the ribbon
3853
+ * - Drawn unlit in the transparent stage, dies down on its own once the object stops
3854
+ * @extends EngineObject3D
3855
+ * @memberof Render3D
3856
+ * @example
3857
+ * const trail = new Trail3D(vec3(), 1, .3, undefined, hsl(.08, 1, .5), hsl(0, 1, .5, 0), true);
3858
+ * ball.addChild(trail); // follows the ball
3859
+ */
3860
+ class Trail3D extends EngineObject3D
3861
+ {
3862
+ /** Create a trail
3863
+ * @param {Vector3} [pos3D]
3864
+ * @param {number} [lifeTime] - Seconds the ribbon takes to thin and fade from head to tail,
3865
+ * Infinity keeps every sample at full width and never drops one, so it grows as long as the object moves
3866
+ * @param {number} [width] - Width at the head, it thins to nothing at the tail
3867
+ * @param {TileInfo|TextureInfo} [tileInfo] - Tile or whole texture stretched along the trail, undefined is untextured
3868
+ * @param {Color} [color] - Color at the head
3869
+ * @param {Color} [colorEnd] - Color at the tail
3870
+ * @param {boolean} [additive] - Additive blending */
3871
+ constructor(pos3D=vec3(), lifeTime=1, width=.2, tileInfo, color=WHITE, colorEnd=CLEAR_WHITE, additive=false)
3872
+ {
3873
+ super(pos3D, undefined, tileInfo, color);
3874
+ this.transparent = true;
3875
+ this.additive = additive;
3876
+ this.castShadow = false;
3877
+ this.size3D = vec3(); // not a solid thing to pick or collect
3878
+ this.finishing = false; // set by destroy, the ribbon fades out then goes away
3879
+
3880
+ /** @property {number} - Seconds the ribbon takes to thin and fade from head to tail, Infinity never drops a sample */
3881
+ this.lifeTime = lifeTime;
3882
+ /** @property {number} - Width at the head */
3883
+ this.width = width;
3884
+ /** @property {Color} - Color at the tail */
3885
+ this.colorEnd = colorEnd.copy();
3886
+ /** @property {Vector3|undefined} - Direction across the ribbon, recorded with each sample, undefined faces the camera
3887
+ * @type {Vector3|undefined} */
3888
+ this.side = undefined;
3889
+ /** @property {Array<Object>} - Recorded samples, oldest first
3890
+ * @type {Array<Object>} */
3891
+ this.samples = [];
3892
+ }
3893
+
3894
+ /** Forget the trail so far, for when the object teleports */
3895
+ clear() { this.samples.length = 0; }
3896
+
3897
+ /** Stop recording, and go away once the ribbon has faded
3898
+ * @param {boolean} [immediate] */
3899
+ destroy(immediate)
3900
+ {
3901
+ if (immediate || !this.samples.length || this.destroyed || this.lifeTime == Infinity)
3902
+ return super.destroy(immediate);
3903
+ this.finishing = true;
3904
+ render3DDetach(this); // the samples are in world space, they no longer need the parent
3905
+ }
3906
+
3907
+ /** Record the position when it moved and drop old samples, called automatically each frame */
3908
+ update()
3909
+ {
3910
+ const samples = this.samples;
3911
+ if (!this.finishing)
3912
+ {
3913
+ const pos = this.worldPos3D = this.getWorldPos3D(), last = samples[samples.length - 1];
3914
+ if (!last || pos.distanceSquared(last.pos) > 1e-8)
3915
+ samples.push({pos, side: this.side?.copy(), time});
3916
+ }
3917
+ while (samples.length && time - samples[0].time > this.lifeTime)
3918
+ samples.shift();
3919
+ this.finishing && !samples.length && this.destroy();
3920
+ }
3921
+
3922
+ /** Draw the ribbon */
3923
+ render3D()
3924
+ {
3925
+ const samples = this.samples;
3926
+ if (samples.length < 2) return;
3927
+ const points = [], widths = [], colors = [], sides = this.side ? [] : undefined;
3928
+ for (const s of samples)
3929
+ {
3930
+ const age = clamp((time - s.time) / this.lifeTime);
3931
+ points.push(s.pos);
3932
+ widths.push(this.width * (1 - age));
3933
+ colors.push(this.color.lerp(this.colorEnd, age));
3934
+ sides?.push(s.side);
3935
+ }
3936
+ render3D.drawRibbon(points, widths, this.tileInfo, colors, sides);
3937
+ }
3938
+ }
3939
+
3940
+ ///////////////////////////////////////////////////////////////////////////////
3941
+ // OBJ meshes
3942
+
3943
+ /**
3944
+ * Parse Wavefront OBJ text into a Mesh
3945
+ * - Reads v, vt, vn and f lines with convex polygons of any size, materials and groups are ignored
3946
+ * - Normals come from the file when every corner of a face has one, otherwise from the face
3947
+ * - Use mesh.center() and mesh.fit(size) to bring a model of unknown units to the origin
3948
+ * - Back faces are skipped like any mesh, set doubleSided for a model with open walls or single sided parts
3949
+ * @param {string} text
3950
+ * @param {boolean} [smooth] - Compute smooth normals when the file has none, defaults to render3D.smoothShading
3951
+ * @return {Mesh}
3952
+ * @memberof Render3D
3953
+ * @example
3954
+ * new EngineObject3D(vec3(), parseOBJ(objText).center().fit(4));
3955
+ */
3956
+ function parseOBJ(text, smooth=render3D?.smoothShading)
3957
+ {
3958
+ const positions = [], normals = [], uvs = [], mesh = new Mesh;
3959
+ let fileNormals = false;
3960
+
3961
+ // OBJ indices count from 1, and a negative one counts back from the end of the list so far
3962
+ const lookup = (s, list)=> { const i = parseInt(s); return list[i < 0 ? list.length + i : i - 1]; };
3963
+ for (const line of text.split('\n'))
3964
+ {
3965
+ const parts = line.trim().split(/\s+/);
3966
+ switch (parts[0])
3967
+ {
3968
+ case 'v': positions.push(vec3(+parts[1], +parts[2], +parts[3])); break;
3969
+ case 'vn': normals.push(vec3(+parts[1], +parts[2], +parts[3])); break;
3970
+ case 'vt': uvs.push(vec2(+parts[1], 1 - +parts[2])); break; // OBJ v runs up, tiles run down
3971
+ case 'f':
3972
+ {
3973
+ const corners = parts.slice(1).map(c=> c.split('/'));
3974
+ if (corners.length < 3) break;
3975
+ const points = corners.map(c=> lookup(c[0], positions));
3976
+ ASSERT(points.every(isVector3), 'OBJ face uses a vertex index the file does not have', line);
3977
+ const uv = corners.map(c=> c[1] ? lookup(c[1], uvs) : RENDER3D_DEFAULT_UV);
3978
+ const hasNormals = corners.every(c=> c[2]);
3979
+ fileNormals ||= hasNormals;
3980
+ const n = hasNormals ? render3DPolygonStrip(corners.map(c=> lookup(c[2], normals)))
3981
+ : render3DFaceNormal(points[0], points[1], points[2], points[3]);
3982
+ mesh.addStrip(render3DPolygonStrip(points), n, render3DPolygonStrip(uv));
3983
+ }
3984
+ }
3985
+ }
3986
+ if (!fileNormals && smooth)
3987
+ mesh.computeNormals(true);
3988
+ return mesh;
3989
+ }
3990
+
3991
+ /**
3992
+ * Fetch and parse an OBJ file
3993
+ * @param {string} url
3994
+ * @param {boolean} [smooth] - Compute smooth normals when the file has none, defaults to render3D.smoothShading
3995
+ * @return {Promise<Mesh>}
3996
+ * @memberof Render3D
3997
+ * @example
3998
+ * const mesh = await loadOBJ('ship.obj'); // in an async gameInit
3999
+ */
4000
+ async function loadOBJ(url, smooth=render3D?.smoothShading)
4001
+ {
4002
+ const response = await fetch(url);
4003
+ if (!response.ok)
4004
+ throw new Error('loadOBJ failed: ' + url);
4005
+ return parseOBJ(await response.text(), smooth);
4006
+ }