littlejsengine 1.19.3 → 1.20.0

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.
Files changed (50) hide show
  1. package/FAQ.md +841 -820
  2. package/README.md +29 -25
  3. package/dist/littlejs.d.ts +2455 -935
  4. package/dist/littlejs.esm.js +30344 -21110
  5. package/dist/littlejs.esm.min.js +4 -3
  6. package/dist/littlejs.js +30259 -21052
  7. package/dist/littlejs.min.js +2 -2
  8. package/dist/littlejs.release.js +26567 -20455
  9. package/package.json +10 -5
  10. package/plugins/audioEffects.js +28 -24
  11. package/plugins/box2d.js +2516 -2073
  12. package/plugins/drawUtilities.js +206 -31
  13. package/plugins/gltf.js +740 -0
  14. package/plugins/jsconfig.json +19 -0
  15. package/plugins/lightSystem.js +719 -332
  16. package/plugins/math3d.js +58 -17
  17. package/plugins/medalSystem.js +326 -280
  18. package/plugins/newgrounds.js +400 -199
  19. package/plugins/pathFinder.js +208 -175
  20. package/plugins/pluginExport.js +15 -0
  21. package/plugins/postProcess.js +56 -13
  22. package/plugins/render3d.js +1643 -1577
  23. package/plugins/render3dDebug.js +109 -0
  24. package/plugins/render3dExtras.js +1754 -0
  25. package/plugins/render3dVoxels.js +502 -0
  26. package/plugins/textureSheet.js +496 -458
  27. package/plugins/threejs.js +183 -163
  28. package/plugins/tweakables.js +411 -0
  29. package/plugins/tweenSystem.js +263 -145
  30. package/plugins/uiSystem.js +2024 -1687
  31. package/plugins/zzfxm.js +159 -159
  32. package/src/engine.js +191 -75
  33. package/src/engineAudio.js +229 -125
  34. package/src/engineBuild.mjs +289 -256
  35. package/src/engineDebug.js +912 -742
  36. package/src/engineDraw.js +295 -193
  37. package/src/engineEditor.js +2431 -0
  38. package/src/engineExport.js +12 -0
  39. package/src/engineInput.js +1462 -1293
  40. package/src/engineMath.js +1274 -1252
  41. package/src/engineObject.js +194 -80
  42. package/src/engineParticles.js +583 -532
  43. package/src/engineRelease.js +67 -45
  44. package/src/engineSettings.js +77 -29
  45. package/src/engineTileLayer.js +995 -741
  46. package/src/engineUtilities.js +19 -13
  47. package/src/engineWebGL.js +239 -82
  48. package/src/jsconfig.json +17 -13
  49. package/plugins/box2d.wasm.js +0 -630
  50. package/plugins/box2d.wasm.wasm +0 -0
@@ -6,7 +6,8 @@
6
6
  * - The 3D scene draws under the 2D sprites, so HUD and text land on top
7
7
  * - Lighting is the sun plus ambient, with optional extra lights, fog and shadows
8
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
9
+ * - Build shapes with buildBox, buildSphere, buildGrid and buildLathe; the other builders, terrain, particles,
10
+ * camera controls and the OBJ loader are in the Render3D Extras plugin, which goes after this one
10
11
  * - Requires the Math3D plugin
11
12
  * @namespace Render3D
12
13
  */
@@ -24,29 +25,26 @@ let render3D;
24
25
  const RENDER3D_VERTEX_FLOATS = 9;
25
26
  const RENDER3D_VERTEX_BYTES = RENDER3D_VERTEX_FLOATS * 4;
26
27
 
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;
28
+ // per draw values the shaders read as vertex attributes: the model matrix columns (4-7), the tint (11) and the
29
+ // uv rect (12); constants for one draw, one per instance for a batch; the shader derives the normal matrix
30
+ const RENDER3D_INSTANCE_FLOATS = 24;
30
31
  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_INSTANCE_ATTRIBS = [[4, 4, 0], [5, 4, 16], [6, 4, 32], [7, 4, 48], [11, 4, 64], [12, 4, 80]];
32
33
  const RENDER3D_VERTEX_INPUTS =
33
34
  '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
35
  '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
36
  'layout(location=11) in vec4 tint;layout(location=12) in vec4 uvRect;';
37
37
  const RENDER3D_MAX_STREAM_VERTS = 32768;
38
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
39
+ // strip order, frozen, and typed as the plain array the uv parameters take
40
+ const RENDER3D_QUAD_UVS = /** @type {Array<Vector2>} */
41
+ (Object.freeze([vec2(0, 0), vec2(0, 1), vec2(1, 0), vec2(1, 1)].map(uv=> Object.freeze(uv))));
40
42
  const RENDER3D_FULL_UV_RECT = Object.freeze({x:0, y:0, w:1, h:1});
41
43
  const RENDER3D_DEFAULT_NORMAL = Object.freeze(vec3(0, 1, 0));
42
44
  const RENDER3D_DEFAULT_UV = Object.freeze(vec2());
43
45
  const RENDER3D_SHADOW_COLOR = Object.freeze(hsl(0, 0, 0, .5));
44
46
  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
-
47
+ let render3DShadowCut; // the cut last sent to the shadow shader, see render3DSetDrawUniforms
50
48
  ///////////////////////////////////////////////////////////////////////////////
51
49
  // Private helpers
52
50
 
@@ -68,29 +66,56 @@ function render3DQuadValues(v) { return isArray(v) ? render3DQuadStrip(...v) : v
68
66
  function render3DCanDraw()
69
67
  {
70
68
  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');
69
+ ASSERT(render3D.isRendering,
70
+ '3D draws are only valid during the 3D pass, draw from an EngineObject3D or render3D.onRenderOpaque');
72
71
  return render3D.isRendering;
73
72
  }
74
73
 
75
74
  // 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'];
75
+ // the three functions below write them out by hand for speed, so a new field goes in all four
76
+ const RENDER3D_STATE_FIELDS = ['blend', 'additive', 'depthTest', 'depthWrite', 'cullBackFaces', 'mirrored', 'lighting',
77
+ 'emissive', 'receiveShadow', 'specular', 'pixelated', 'shader'];
77
78
 
78
- // a copy of the current draw state
79
+ // a copy of the draw state in one fixed shape, the fields of RENDER3D_STATE_FIELDS written out so the
80
+ // compare below stays a handful of direct reads, it runs for every instance drawn
79
81
  function render3DCaptureBatchState()
80
82
  {
81
- const state = {};
82
- for (const field of RENDER3D_STATE_FIELDS)
83
- state[field] = render3D[field];
84
- return state;
83
+ const r = render3D;
84
+ return {blend: r.blend, additive: r.additive, depthTest: r.depthTest, depthWrite: r.depthWrite,
85
+ cullBackFaces: r.cullBackFaces, mirrored: r.mirrored, lighting: r.lighting, emissive: r.emissive,
86
+ receiveShadow: r.receiveShadow, specular: r.specular, pixelated: r.pixelated, shader: r.shader};
87
+ }
88
+
89
+ // put a captured draw state back, written out the same way; the transparent stage does this for every queued draw
90
+ function render3DApplyBatchState(s)
91
+ {
92
+ const r = render3D;
93
+ r.blend = s.blend, r.additive = s.additive, r.depthTest = s.depthTest, r.depthWrite = s.depthWrite,
94
+ r.cullBackFaces = s.cullBackFaces, r.mirrored = s.mirrored, r.lighting = s.lighting, r.emissive = s.emissive,
95
+ r.receiveShadow = s.receiveShadow, r.specular = s.specular, r.pixelated = s.pixelated, r.shader = s.shader;
85
96
  }
86
97
 
87
98
  // true when the current draw state differs from a captured one, so a pending batch must flush first
88
- function render3DStateChanged(state)
99
+ function render3DStateChanged(s)
100
+ {
101
+ const r = render3D;
102
+ return r.blend !== s.blend || r.additive !== s.additive || r.depthTest !== s.depthTest
103
+ || r.depthWrite !== s.depthWrite || r.cullBackFaces !== s.cullBackFaces || r.mirrored !== s.mirrored
104
+ || r.lighting !== s.lighting || r.emissive !== s.emissive || r.receiveShadow !== s.receiveShadow
105
+ || r.specular !== s.specular || r.pixelated !== s.pixelated || r.shader !== s.shader;
106
+ }
107
+
108
+ // whether a sphere is inside the view, or the shadow map's box during the shadow pass, without a vector
109
+ function render3DSphereVisible(x, y, z, radius)
89
110
  {
90
- for (const field of RENDER3D_STATE_FIELDS)
91
- if (render3D[field] !== state[field])
92
- return true;
93
- return false;
111
+ const planes = render3D.shadowPass ? render3D.shadowPlanes : render3D.frustumPlanes;
112
+ for (let i = 0; i < planes.length; ++i)
113
+ {
114
+ const p = planes[i];
115
+ if (p[0]*x + p[1]*y + p[2]*z + p[3] < -radius)
116
+ return false;
117
+ }
118
+ return true;
94
119
  }
95
120
 
96
121
  // run a function with some draw state fields overridden, restored afterward even on a throw
@@ -107,9 +132,20 @@ function render3DWithState(fields, fn)
107
132
  function render3DIsAfter2D(o) { return !!(o.renderAfter2D ?? render3D.renderAfter2D); }
108
133
 
109
134
  // a size given as a number or a vec3
110
- function render3DSize3(size) { return isNumber(size) ? vec3(size) : size; }
135
+ /** @param {Vector3|number} size
136
+ * @return {Vector3} */
137
+ function render3DSize3(size)
138
+ { return isNumber(size) ? vec3(/** @type {number} */ (size)) : /** @type {Vector3} */ (size); }
139
+
140
+ // a size given as a number or a vec2
141
+ /** @param {Vector2|number} size
142
+ * @return {Vector2} */
143
+ function render3DSize2(size)
144
+ { return isNumber(size) ? vec2(/** @type {number} */ (size)) : /** @type {Vector2} */ (size); }
111
145
 
112
146
  // a transform given as a matrix, or as a vec3 for one that only moves there
147
+ /** @param {Matrix4|Vector3} matrix
148
+ * @return {Matrix4} */
113
149
  function render3DMatrix(matrix)
114
150
  {
115
151
  if (matrix instanceof Vector3)
@@ -121,16 +157,18 @@ function render3DMatrix(matrix)
121
157
  // the matrix that keeps normals pointing out when an object is scaled unevenly
122
158
  function render3DNormalMatrix(matrix) { return matrix.copy().invert().transpose(); }
123
159
 
160
+ // whether a matrix mirrors, its determinant negative, so what it moves reads the other way round
161
+ function render3DMirrors(matrix) { return matrix.determinant() < 0; }
162
+
163
+
124
164
  // a column of a matrix as a direction: 0 is the right axis, 4 up, 8 back
125
165
  function render3DAxis(m, i) { return vec3(m[i], m[i+1], m[i+2]); }
126
166
 
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)
167
+ // a matrix turning by angle around a unit axis through the origin, counter clockwise when the axis points at you
168
+ function render3DAxisRotation(axis, angle)
129
169
  {
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();
170
+ const x = vec3(1, 0, 0).rotate(axis, angle), y = vec3(0, 1, 0).rotate(axis, angle), z = vec3(0, 0, 1).rotate(axis, angle);
171
+ return new Matrix4([x.x, x.y, x.z, 0, y.x, y.y, y.z, 0, z.x, z.y, z.z, 0, 0, 0, 0, 1]);
134
172
  }
135
173
 
136
174
  // the largest axis scale of a matrix, how much it grows a bounding sphere
@@ -139,6 +177,20 @@ function render3DMaxScale(m)
139
177
  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
178
  }
141
179
 
180
+ // how far a matrix at offset k can move a point that is one unit from its origin, for a bounding sphere: the
181
+ // longest axis when the axes are square to each other, more when they are not, as a turned child under a parent
182
+ // scaled on one axis leaves them; the axes' dot products bound the largest stretch by their largest row sum
183
+ function render3DMaxStretch(m, k=0)
184
+ {
185
+ const xx = m[k]*m[k] + m[k+1]*m[k+1] + m[k+2]*m[k+2];
186
+ const yy = m[k+4]*m[k+4] + m[k+5]*m[k+5] + m[k+6]*m[k+6];
187
+ const zz = m[k+8]*m[k+8] + m[k+9]*m[k+9] + m[k+10]*m[k+10];
188
+ const xy = abs(m[k]*m[k+4] + m[k+1]*m[k+5] + m[k+2]*m[k+6]);
189
+ const xz = abs(m[k]*m[k+8] + m[k+1]*m[k+9] + m[k+2]*m[k+10]);
190
+ const yz = abs(m[k+4]*m[k+8] + m[k+5]*m[k+9] + m[k+6]*m[k+10]);
191
+ return max(xx + xy + xz, yy + xy + yz, zz + xz + yz) ** .5;
192
+ }
193
+
142
194
  // a quad as a strip from its center and half axes, the same corner order as render3DQuadStrip
143
195
  function render3DQuadAxes(center, right, up)
144
196
  {
@@ -162,6 +214,8 @@ function render3DSetObjectState(o)
162
214
  ASSERT(!o?.shader || o.shader instanceof Shader, 'shader must be a Shader, not the snippet itself');
163
215
  r.shader = o?.shader || undefined; // null is no shader too, so it batches with none
164
216
  r.depthTest = true;
217
+ if (r.shadowPass)
218
+ r.blend = !!o?.transparent; // the depth shader cuts a see through caster by the alpha it would blend with
165
219
  }
166
220
 
167
221
  // draw objects each with the draw state set from its own flags, then reset to the defaults
@@ -175,20 +229,23 @@ function render3DDrawObjects(objects)
175
229
  render3DSetObjectState();
176
230
  }
177
231
 
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)
232
+ // 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
233
+ function render3DInstance(mesh, matrix, tileInfo, color)
181
234
  {
182
- if (o.parent)
183
- o.pos3D = o.getWorldPos3D(), o.parent.removeChild(o);
184
- else if (o.worldPos3D)
185
- o.pos3D = o.worldPos3D;
235
+ const k = render3DInstanceSlot(mesh, render3DTextureOf(tileInfo)), data = mesh.instanceData;
236
+ data.set(matrix.m, k);
237
+ // the tint; this batch is never blended, so in the shadow pass the depth shader cuts it by its texture alpha
238
+ // alone, a see through caster draws blended instead and is cut by its tint alpha too
239
+ data[k+16] = color.r, data[k+17] = color.g, data[k+18] = color.b, data[k+19] = color.a;
240
+ const uv = render3DGetTileUVs(tileInfo);
241
+ data[k+20] = uv.x; data[k+21] = uv.y; data[k+22] = uv.w; data[k+23] = uv.h;
186
242
  }
187
243
 
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)
244
+ // make room for one more instance of a mesh under a texture and the current draw state, flushing a batch that
245
+ // differs first, and return where its 24 floats go in mesh.instanceData: the matrix, the tint and the uv rect
246
+ function render3DInstanceSlot(mesh, textureInfo)
190
247
  {
191
- const r = render3D, textureInfo = tileInfo instanceof TileInfo ? tileInfo.textureInfo : tileInfo;
248
+ const r = render3D;
192
249
  if (mesh.instanceCount && (mesh.instanceTextureInfo !== textureInfo || render3DStateChanged(mesh.instanceState)))
193
250
  render3DFlushInstances(mesh);
194
251
  if (!mesh.instanceCount)
@@ -199,19 +256,14 @@ function render3DInstance(mesh, matrix, tileInfo, color)
199
256
  }
200
257
 
201
258
  // room for one more, doubling as the batch grows
202
- let data = mesh.instanceData;
203
- const k = mesh.instanceCount++ * RENDER3D_INSTANCE_FLOATS;
259
+ const data = mesh.instanceData, k = mesh.instanceCount++ * RENDER3D_INSTANCE_FLOATS;
204
260
  if (!data || data.length < k + RENDER3D_INSTANCE_FLOATS)
205
261
  {
206
262
  const grown = new Float32Array(max(64 * RENDER3D_INSTANCE_FLOATS, data ? data.length * 2 : 0));
207
263
  data && grown.set(data);
208
- mesh.instanceData = data = grown;
264
+ mesh.instanceData = grown;
209
265
  }
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;
266
+ return k;
215
267
  }
216
268
 
217
269
  // draw the pending batches, or just one mesh's, each as a single instanced call
@@ -226,21 +278,10 @@ function render3DFlushInstances(only)
226
278
 
227
279
  // the per instance values on top of the constant attributes, then the mesh under them
228
280
  // 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]);
281
+ const buffers = r.instanceBuffers, buffer = buffers[r.instanceBufferIndex = (r.instanceBufferIndex + 1) % buffers.length];
282
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
231
283
  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;
284
+ render3DDrawInstanced(mesh, buffer, count, mesh.instanceTextureInfo, mesh.instanceState);
244
285
  }
245
286
  if (!only)
246
287
  r.instanceMeshes.length = 0;
@@ -251,6 +292,32 @@ function render3DFlushInstances(only)
251
292
  }
252
293
  }
253
294
 
295
+ // draw a mesh count times from a buffer that holds the per instance values, under a draw state
296
+ // the arrays are turned on with their instance divisor for this one call and both are turned off after: a single
297
+ // draw reads these slots as constant attributes, and in Firefox a draw that reads a constant through a slot whose
298
+ // divisor is set leaves the next batch on that slot reading the wrong values
299
+ function render3DDrawInstanced(mesh, buffer, count, textureInfo, state)
300
+ {
301
+ const gl = glContext;
302
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
303
+ for (const [location, size, offset] of RENDER3D_INSTANCE_ATTRIBS)
304
+ {
305
+ gl.vertexAttribPointer(location, size, gl.FLOAT, false, RENDER3D_INSTANCE_BYTES, offset);
306
+ gl.enableVertexAttribArray(location);
307
+ gl.vertexAttribDivisor(location, 1);
308
+ }
309
+ render3DSetDrawUniforms(RENDER3D_IDENTITY, textureInfo, WHITE, RENDER3D_FULL_UV_RECT, state);
310
+ render3DBindMesh(mesh);
311
+ gl.drawElementsInstanced(gl.TRIANGLES, mesh.bufferCount, mesh.indexType, 0, count);
312
+ for (const [location] of RENDER3D_INSTANCE_ATTRIBS)
313
+ {
314
+ gl.disableVertexAttribArray(location);
315
+ gl.vertexAttribDivisor(location, 0);
316
+ }
317
+ ++drawCount;
318
+ primitiveCount += mesh.bufferCount / 3 * count;
319
+ }
320
+
254
321
  // forget the pending batches, for a frame that threw or a lost context
255
322
  function render3DClearInstances()
256
323
  {
@@ -261,15 +328,18 @@ function render3DClearInstances()
261
328
 
262
329
  // the live objects drawn on one side of the 2D scene
263
330
  function render3DLayerObjects(after2D)
264
- { return engineObjects.filter(o=> !o.destroyed && o instanceof EngineObject3D && render3DIsAfter2D(o) === after2D); }
331
+ {
332
+ return /** @type {Array<EngineObject3D>} */ (engineObjects.filter(o=>
333
+ !o.destroyed && o instanceof EngineObject3D && render3DIsAfter2D(o) === after2D));
334
+ }
265
335
 
266
336
  // the Light3D objects the shader gets this frame: directional lights light the whole scene so they come first,
267
337
  // then the point lights nearest the camera
268
338
  function render3DCollectLights()
269
339
  {
270
340
  // 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));
341
+ const lights = /** @type {Array<Light3D>} */ (engineObjects.filter(o=> !o.destroyed && o instanceof Light3D &&
342
+ o.color.a > 0 && o.intensity > 0 && (o.directional || o.radius > 0)));
273
343
  if (lights.length > RENDER3D_MAX_LIGHTS)
274
344
  {
275
345
  // distances cached once, getWorldPos3D walks the parent chain and the sort asks many times
@@ -301,20 +371,6 @@ function render3DCircle(sides)
301
371
  return circle;
302
372
  }
303
373
 
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
374
  // the rotation that points -Z along a direction, as vec3(pitch, yaw, 0); a zero direction keeps the current one
319
375
  function render3DLookRotation(direction, current)
320
376
  {
@@ -347,6 +403,8 @@ class Render3DPlugin
347
403
  {
348
404
  ASSERT(!render3D, 'Render3D plugin already initialized');
349
405
  render3D = this;
406
+ ASSERT(Object.keys(render3DCaptureBatchState()).join() === RENDER3D_STATE_FIELDS.join(),
407
+ 'the batch state functions must list RENDER3D_STATE_FIELDS');
350
408
 
351
409
  /** @property {Camera3D} - The camera */
352
410
  this.camera = new Camera3D;
@@ -354,12 +412,17 @@ class Render3DPlugin
354
412
  // lights and fog
355
413
  /** @property {Vector3} - Direction toward the sun, where its light comes from, like a directional Light3D;
356
414
  * 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 */
415
+ * the sun is the one light that casts shadows */
358
416
  this.sunDirection = vec3(-.3, 1, .5);
359
417
  /** @property {Color} - Sunlight color */
360
418
  this.sunColor = WHITE.copy();
361
- /** @property {Color} - Ambient light color */
419
+ /** @property {Color} - Ambient light color, from above when ambientGroundColor is set */
362
420
  this.ambientColor = hsl(0, 0, .3);
421
+ /** @property {Color|undefined} - Ambient light from below: set, the ambient blends from this on faces pointing
422
+ * down to ambientColor on faces pointing up, the way a sky and a ground light a scene; setSky sets both from
423
+ * its colors
424
+ * @type {Color|undefined} */
425
+ this.ambientGroundColor = undefined;
363
426
  /** @property {Color|undefined} - Fog color, uses canvasClearColor when undefined
364
427
  * @type {Color|undefined} */
365
428
  this.fogColor = undefined;
@@ -367,12 +430,15 @@ class Render3DPlugin
367
430
  this.fogStart = 0;
368
431
  /** @property {number} - Distance from the camera where fog is total, 0 disables fog */
369
432
  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 */
433
+ /** @property {Vector3} - Added to the velocity3D of every object with a mass each frame, scaled by its
434
+ * gravityScale; sync2D objects use the 2D gravity */
371
435
  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} */
436
+ /** @property {number|HeightMap|function(number, number): number} - Floor for objects with a softShadow: a
437
+ * height, a HeightMap, or (x, z) => y
438
+ * @type {number|HeightMap|function(number, number): number} */
374
439
  this.softShadowHeight = 0;
375
- /** @property {boolean} - Default for every builder's smooth argument: true for smooth vertex normals, false for flat faces */
440
+ /** @property {boolean} - Default for every builder's smooth argument: true for smooth vertex normals, false for
441
+ * flat faces */
376
442
  this.smoothShading = false;
377
443
 
378
444
  // shadows
@@ -380,8 +446,9 @@ class Render3DPlugin
380
446
  this.shadows = false;
381
447
  /** @property {number} - Size of the shadow map in pixels, bigger is sharper and slower */
382
448
  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 */
449
+ /** @property {number} - World size the shadow map covers around shadowCenter, smaller is sharper; it is a
450
+ * square facing the light, so it turns as the light does, and about 1.5 times an area's width covers it from
451
+ * any angle */
385
452
  this.shadowRange = 40;
386
453
  /** @property {Vector3|undefined} - Center of the shadowed area, read each frame, undefined follows the camera
387
454
  * @type {Vector3|undefined} */
@@ -399,7 +466,9 @@ class Render3DPlugin
399
466
  this.emissive = 0;
400
467
  /** @property {boolean} - Additive blending instead of alpha, in the transparent stage */
401
468
  this.additive = false;
402
- /** @property {boolean} - Test against the depth buffer, reset to true before each object and callback */
469
+ /** @property {boolean} - Test against the depth buffer, reset to true before each object and callback; a draw
470
+ * with it off goes over what was drawn before it and under what is drawn after, by render order, which
471
+ * ends the batch of meshes before it, so one per object costs a draw per object */
403
472
  this.depthTest = true;
404
473
  /** @property {boolean} - Write to the depth buffer, owned by the stages: on for opaque, off for transparent */
405
474
  this.depthWrite = true;
@@ -407,12 +476,15 @@ class Render3DPlugin
407
476
  // show from both sides, and whether its transform mirrors it so the other winding is the front
408
477
  this.cullBackFaces = false;
409
478
  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 */
479
+ /** @property {number} - Strength of the highlight where the sun and the Light3D objects reflect, 0 is none and
480
+ * 1 adds a light's full color at its brightest; its size is fixed */
411
481
  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
482
+ /** @property {Shader|undefined} - Custom Shader for the next draws, set from each object's shader; undefined
483
+ * draws with the plugin's own
413
484
  * @type {Shader|undefined} */
414
485
  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 */
486
+ /** @property {boolean} - Darken by the shadow map when shadows are on, turn it off for things that should stay
487
+ * lit inside a shadow */
416
488
  this.receiveShadow = true;
417
489
 
418
490
  // the pass
@@ -431,11 +503,14 @@ class Render3DPlugin
431
503
  this.sortTransparent = true;
432
504
  /** @property {boolean} - Skip meshes whose bounding sphere is outside the view */
433
505
  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 */
506
+ /** @property {boolean} - Draw every use of a mesh in the opaque stage as one instanced call, mesh.instanced
507
+ * overrides it per mesh */
435
508
  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 */
509
+ /** @property {boolean} - Sample textures through mipmaps so they do not shimmer in the distance, false uses
510
+ * each texture's own filtering like 2D */
437
511
  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 */
512
+ /** @property {boolean} - Draw state: keep texture pixels hard edged, no mipmaps and no blending between them,
513
+ * set per object by pixelated */
439
514
  this.pixelated = false;
440
515
  /** @property {number} - Anisotropic filtering for textures seen at an angle, 1 to 16, 1 is off; needs mipmaps */
441
516
  this.anisotropy = 4;
@@ -452,6 +527,11 @@ class Render3DPlugin
452
527
  this.planeMesh.doubleSided = false;
453
528
  /** @property {Mesh} - The same square seen and lit from both sides, for signs, cards and leaves */
454
529
  this.planeMeshDoubleSided = buildGrid();
530
+ /** @property {Mesh} - A square of size 1 facing +Z with the tile across it, the corners in the order
531
+ * drawBillboard writes them; a ParticleEmitter3D draws its particles as instances of it, each with its own matrix */
532
+ this.billboardMesh = new Mesh().addStrip([vec3(-.5, .5, 0), vec3(-.5, -.5, 0), vec3(.5, .5, 0), vec3(.5, -.5, 0)],
533
+ vec3(0, 0, 1), RENDER3D_QUAD_UVS);
534
+ this.billboardMesh.doubleSided = true;
455
535
 
456
536
  // read only
457
537
  /** @property {boolean} - True while the 3D pass is running, 3D draws are only valid then */
@@ -476,22 +556,33 @@ class Render3DPlugin
476
556
 
477
557
  // internal state
478
558
  this.blend = false; // blending on, set by the stages
559
+ /** @type {Array<Array<number>>} */
479
560
  this.frustumPlanes = []; // the view as six inward planes [x, y, z, w]
561
+ /** @type {Array<Array<number>>} */
480
562
  this.shadowPlanes = []; // the shadow map's box as six planes
563
+ /** @type {WebGLProgram|undefined} */
481
564
  this.program = undefined; // the main program, undefined when not available
565
+ /** @type {WebGLProgram|undefined} */
482
566
  this.currentProgram = undefined; // the program in use during a pass, a Shader's or the main one
483
567
  this.lightCount = 0; // Light3D objects sent this pass
568
+ /** @type {WebGLProgram|undefined} */
484
569
  this.shadowShader = undefined;
570
+ /** @type {WebGLVertexArrayObject|undefined} */
485
571
  this.vao = undefined;
572
+ /** @type {WebGLTexture|undefined} */
486
573
  this.whiteTexture = undefined; // 1x1 white for untextured draws
487
- this.samplers = []; // how textures are filtered in 3D, clamped and wrapping, see render3DInitGL
574
+ /** @type {Map<number, WebGLSampler>} */
575
+ this.samplers = new Map; // how textures are filtered in 3D, by wrap and hard edge, see render3DSampler
576
+ /** @type {string|undefined} */
488
577
  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
578
+ /** @type {WebGLTexture|undefined} */
490
579
  this.shadowTexture = undefined;
580
+ /** @type {WebGLFramebuffer|undefined} */
491
581
  this.shadowFramebuffer = undefined;
492
582
  this.shadowTextureSize = 0;
493
583
  this.contextGeneration = 0; // counts context losses, a mesh uploaded under an older one uploads again
494
584
  this.uniforms = new Map; // uniform locations by program
585
+ /** @type {Object<string, Array<number>>} */
495
586
  this.uniformValues = {}; // last values sent for the cached vec4 uniforms
496
587
  this.shadowMapDrawn = false; // the shadow map is drawn by the first pass of the frame
497
588
  this.passIsDefault = true; // the running pass is the default layer, the only one shadowed
@@ -499,18 +590,25 @@ class Render3DPlugin
499
590
  this.lightColors = new Float32Array(RENDER3D_MAX_LIGHTS * 4);
500
591
 
501
592
  // the stream of immediate mode draws
593
+ /** @type {WebGLBuffer|undefined} */
502
594
  this.streamBuffer = undefined;
595
+ /** @type {Array<WebGLBuffer>} */
503
596
  this.instanceBuffers = []; // the per instance values of the batches being drawn, used in turn
504
597
  this.instanceBufferIndex = 0;
598
+ /** @type {Array<Mesh>} */
505
599
  this.instanceMeshes = []; // meshes with a batch pending this stage
600
+ /** @type {Array<Array<number>>} */
506
601
  this.attribValues = []; // last values sent for the cached constant attributes
507
602
  this.streamData = new ArrayBuffer(RENDER3D_MAX_STREAM_VERTS * RENDER3D_VERTEX_BYTES);
508
603
  this.streamFloats = new Float32Array(this.streamData);
509
604
  this.streamInts = new Uint32Array(this.streamData);
510
605
  this.streamCount = 0;
606
+ /** @type {TextureInfo|undefined} */
511
607
  this.streamTileInfo = undefined;
512
608
  this.streamState = undefined; // captured state the pending batch was drawn under
609
+ /** @type {Mesh|undefined} */
513
610
  this.capture = undefined; // the mesh a bake is filling
611
+ /** @type {Array<{distance: number, state: Object, draw: function(): void}>|undefined} */
514
612
  this.transparentQueue = undefined; // draws queued during the transparent stage, replayed far to near
515
613
 
516
614
  render3DInitGL();
@@ -574,10 +672,11 @@ class Render3DPlugin
574
672
  * - It brings the view matrices up to date for that canvas, so worldToScreen stays its exact opposite
575
673
  * @param {Vector2} screenPos - Same space as mousePosScreen
576
674
  * @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 */
675
+ * @return {Ray3D} - Starts at the camera with a unit direction, or on the near plane when orthographic */
578
676
  screenToRay(screenPos, canvasSize=mainCanvasSize)
579
677
  {
580
- const width = canvasSize.x || 1, height = canvasSize.y || 1; // a canvas with no size stands in as 1x1, rather than dividing by zero
678
+ // a canvas with no size stands in as 1x1, rather than dividing by zero
679
+ const width = canvasSize.x || 1, height = canvasSize.y || 1;
581
680
  const aspect = width / height, camera = this.camera;
582
681
  // bring the matrices up to date for this canvas, so worldToScreen and this agree on where things are
583
682
  this.updateMatrices(aspect);
@@ -586,8 +685,9 @@ class Render3DPlugin
586
685
  // the screen offset moves a parallel ray's origin, or bends a perspective ray's direction
587
686
  const h = camera.orthographic ? camera.orthographic / 2 : tan(camera.fov / 2);
588
687
  const offset = this.cameraRight.scale(clipX * h * aspect).add(this.cameraUp.scale(clipY * h));
688
+ // a parallel ray starts on the near plane, which an orthographic camera may put behind it, like three.js
589
689
  return camera.orthographic
590
- ? new Ray3D(camera.pos.add(offset), this.cameraForward.copy())
690
+ ? new Ray3D(camera.pos.add(offset).add(this.cameraForward.scale(camera.near)), this.cameraForward.copy())
591
691
  : new Ray3D(camera.pos.copy(), this.cameraForward.add(offset).normalize());
592
692
  }
593
693
 
@@ -604,7 +704,8 @@ class Render3DPlugin
604
704
  }
605
705
 
606
706
  /** 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
707
+ * - Each object is tested as the box around its mesh in its own space, or a sprite as the quad it draws,
708
+ * not triangle by triangle
608
709
  * - engineObjectsRaycast3D is the other half of this, every object along a ray instead of the nearest
609
710
  * @param {Vector2|Ray3D} from - A screen position like mousePosScreen, or a ray to look along
610
711
  * @param {Array<EngineObject>} [objects] - Defaults to every object; only those with a mesh or a sprite count
@@ -617,37 +718,39 @@ class Render3DPlugin
617
718
  {
618
719
  const distance = render3DRaycastObject(ray, o);
619
720
  if (distance !== undefined && (!nearest || distance < nearest.distance))
620
- nearest = {object: o, distance};
721
+ nearest = {object: /** @type {EngineObject3D} */ (o), distance}; // only a 3D object has a distance
621
722
  }
622
723
  return nearest;
623
724
  }
624
725
 
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
726
+ /** Play a sound at a 3D position, quieter with distance from the camera and panned by its side, like Sound.play
727
+ * with a 2D position
626
728
  * @param {Sound} sound
627
729
  * @param {Vector3} pos3D
628
730
  * @param {number} [volume]
629
731
  * @param {number} [pitch]
630
732
  * @param {number} [randomnessScale] - How much to scale pitch randomness
631
733
  * @param {boolean} [loop]
734
+ * @param {boolean} [paused] - Start it paused
632
735
  * @return {SoundInstance|undefined} - undefined when out of range or sound is off */
633
- playSound(sound, pos3D, volume=1, pitch=1, randomnessScale=1, loop=false)
736
+ playSound(sound, pos3D, volume=1, pitch=1, randomnessScale=1, loop=false, paused=false)
634
737
  {
635
- // keep in step with Sound.play, only the pan differs
738
+ // the 3D range fade and pan, Sound.play with no position does the rest
636
739
  ASSERT(sound instanceof Sound, 'sound must be a Sound');
637
740
  ASSERT(isVector3(pos3D), 'pos3D must be a vec3');
638
- if (!soundEnable || headlessMode) return;
639
- if (!sound.sampleBuffer && !sound._sampleChannels) return; // still loading
640
741
  const offset = pos3D.subtract(this.camera.pos), range = sound.range;
641
742
  if (range)
642
743
  {
643
744
  const distance = offset.length();
644
745
  if (distance > range)
645
746
  return; // out of range
646
- volume *= percent(distance, range, range * sound.taper);
747
+ const taperRange = range * sound.taper;
748
+ if (distance > taperRange)
749
+ volume *= percent(distance, range, taperRange);
647
750
  }
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);
751
+ const instance = sound.play(undefined, volume, pitch, randomnessScale, loop, paused);
752
+ instance?.setPan(offset.normalize().dot(this.cameraRight));
753
+ return instance;
651
754
  }
652
755
 
653
756
  /** Play a sound on a loop at a 3D position, the same as playSound with loop on
@@ -666,13 +769,7 @@ class Render3DPlugin
666
769
  * @param {Vector3} center
667
770
  * @param {number} radius
668
771
  * @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
- }
772
+ isSphereVisible(center, radius) { return render3DSphereVisible(center.x, center.y, center.z, radius); }
676
773
 
677
774
  ///////////////////////////////////////////////////////////////////////////
678
775
  // Meshes and the stream
@@ -681,38 +778,46 @@ class Render3DPlugin
681
778
  * @param {Mesh} mesh
682
779
  * @param {Matrix4|Vector3} [matrix] - Object transform, or just a position to draw it at
683
780
  * @param {TileInfo|TextureInfo} [tileInfo] - Texture, mesh uvs map across the tile or the whole texture
684
- * @param {Color} [color] - Tint */
781
+ * @param {Color} [color] - Tint
782
+ * @return {void} */
685
783
  drawMesh(mesh, matrix=RENDER3D_IDENTITY, tileInfo, color=WHITE)
686
784
  {
687
785
  matrix = render3DMatrix(matrix);
688
- ASSERT(!tileInfo || tileInfo instanceof TileInfo || tileInfo instanceof TextureInfo, 'tileInfo must be a TileInfo or TextureInfo, it comes before color');
786
+ ASSERT(!tileInfo || tileInfo instanceof TileInfo || tileInfo instanceof TextureInfo,
787
+ 'tileInfo must be a TileInfo or TextureInfo, it comes before color');
689
788
  ASSERT(isColor(color), 'color must be a Color');
690
789
  if (this.capture)
691
790
  return void this.capture.combine(mesh, matrix, color);
692
791
  if (this.transparentQueue)
693
- return this.queueTransparent(matrix.getTranslation(), ()=> this.drawMesh(mesh, matrix, tileInfo, color));
792
+ {
793
+ // the queue replays later, so it keeps copies of what a caller may reuse, like a scratch matrix
794
+ const m = matrix.copy(), c = color.copy();
795
+ return this.queueTransparent(m.getTranslation(), ()=> this.drawMesh(mesh, m, tileInfo, c));
796
+ }
694
797
  if (!render3DCanDraw()) return;
695
798
  if (this.shadowPass && !this.lighting) return; // unlit things cast no shadow
696
- if (!mesh.buffer || mesh.dirty || mesh.contextGeneration !== this.contextGeneration)
697
- mesh.upload();
799
+ render3DMeshUpload(mesh);
698
800
  if (!mesh.bufferCount) return;
699
- if (this.frustumCulling && !this.isSphereVisible(matrix.getTranslation(), mesh.radius * render3DMaxScale(matrix.m)))
801
+ const m = matrix.m;
802
+ if (this.frustumCulling && !render3DSphereVisible(m[12], m[13], m[14], mesh.radius * render3DMaxStretch(m)))
700
803
  return;
701
804
  // the mesh says whether its back faces can be skipped, and a mirroring transform, one with a negative
702
805
  // determinant, turns the winding around so the other one is its front
703
- const m = matrix.m, cullBackFaces = this.cullBackFaces, mirrored = this.mirrored;
806
+ const cullBackFaces = this.cullBackFaces, mirrored = this.mirrored;
704
807
  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
808
+ this.mirrored = render3DMirrors(matrix);
809
+ // the stage draws the batch at its end
810
+ if (!this.blend && this.depthTest && (mesh.instanced ?? this.instancing))
707
811
  render3DInstance(mesh, matrix, tileInfo, color);
708
812
  else
709
813
  {
710
814
  this.flush();
815
+ render3DFlushBeforeOverlay();
711
816
  render3DSetDrawUniforms(matrix, tileInfo, color);
712
- render3DBindVertexBuffer(mesh.buffer);
713
- glContext.drawArrays(glContext.TRIANGLE_STRIP, 0, mesh.bufferCount);
817
+ render3DBindMesh(mesh);
818
+ glContext.drawElements(glContext.TRIANGLES, mesh.bufferCount, mesh.indexType, 0);
714
819
  ++drawCount;
715
- primitiveCount += mesh.bufferCount;
820
+ primitiveCount += mesh.bufferCount / 3;
716
821
  }
717
822
  this.cullBackFaces = cullBackFaces, this.mirrored = mirrored;
718
823
  }
@@ -722,11 +827,13 @@ class Render3DPlugin
722
827
  * - List the first three points counter clockwise as seen from the front, or the face points away
723
828
  * and may vanish when back faces are culled
724
829
  * - inside a bake the strip goes into the mesh instead, in the transparent stage it is queued for sorting
830
+ * and the arrays are read when the queue replays, so leave them unchanged until the stage ends
725
831
  * @param {Array<Vector3>} points - In strip order
726
832
  * @param {Vector3|Array<Vector3>} [normals] - One for all or one per point, default up
727
833
  * @param {Vector2|Array<Vector2>} [uvs] - One for all or one per point, 0-1 across the tile
728
834
  * @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 */
835
+ * @param {TileInfo|TextureInfo} [tileInfo] - Texture for this strip
836
+ * @return {void} */
730
837
  drawStrip(points, normals, uvs, colors, tileInfo)
731
838
  {
732
839
  if (this.capture)
@@ -740,7 +847,8 @@ class Render3DPlugin
740
847
  let x = 0, y = 0, z = 0;
741
848
  for (const p of points)
742
849
  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));
850
+ return this.queueTransparent(vec3(x, y, z).scale(1 / points.length),
851
+ ()=> this.drawStrip(points, normals, uvs, colors, tileInfo));
744
852
  }
745
853
  ASSERT(isArray(points) && points.length > 2, 'strip needs at least 3 points');
746
854
  const n = points.length, count = render3DStripCount(n);
@@ -753,9 +861,9 @@ class Render3DPlugin
753
861
  const rgba = colorArray ? 0 : (colors || WHITE).rgbaInt();
754
862
  for (let k = 0; k < count; ++k)
755
863
  {
756
- const i = render3DStripIndex(k, n);
864
+ const i = render3DStripIndex(k, n), p = points[i];
757
865
  const uv = uvArray ? uvs[i] : uvs || RENDER3D_DEFAULT_UV;
758
- render3DWriteVertex(floats, ints, this.streamCount++ * RENDER3D_VERTEX_FLOATS, points[i],
866
+ render3DWriteVertex(floats, ints, this.streamCount++ * RENDER3D_VERTEX_FLOATS, p.x, p.y, p.z,
759
867
  normalArray ? normals[i] : normals || RENDER3D_DEFAULT_NORMAL,
760
868
  uvRect.x + uv.x * uvRect.w, uvRect.y + uv.y * uvRect.h, colorArray ? colors[i].rgbaInt() : rgba);
761
869
  }
@@ -839,7 +947,7 @@ class Render3DPlugin
839
947
  {
840
948
  // the shadow grows with the object, by the same scale picking and culling
841
949
  // measure it at, so one size set once holds however the object is scaled
842
- const m = o.getMatrix();
950
+ const m = render3DObjectMatrix(o);
843
951
  this.drawSoftShadow(m.getTranslation(), o.softShadow * render3DMaxScale(m.m), this.softShadowHeight);
844
952
  }
845
953
  isDefault && this.onRenderTransparent?.();
@@ -854,25 +962,41 @@ class Render3DPlugin
854
962
  this.depthWrite = true;
855
963
  }
856
964
 
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
965
+ /** Queue a draw for the transparent stage, replayed far to near with the current draw state, or draw it now when
966
+ * sorting is off
967
+ * - The draw runs later, so it should hold copies of any values the caller may change before then
858
968
  * @param {Vector3} pos - Where the draw is, for sorting
859
- * @param {Function} draw */
969
+ * @param {function(): void} draw
970
+ * @return {void} */
860
971
  queueTransparent(pos, draw)
861
972
  {
862
973
  if (!this.transparentQueue)
863
- return draw();
864
- this.transparentQueue.push({distance: pos.distanceSquared(this.camera.pos), state: render3DCaptureBatchState(), draw});
974
+ {
975
+ draw();
976
+ return;
977
+ }
978
+ // sort by depth along the view, not distance, so an orthographic camera orders them right too
979
+ const f = this.cameraForward, c = this.camera.pos;
980
+ const distance = (pos.x - c.x)*f.x + (pos.y - c.y)*f.y + (pos.z - c.z)*f.z;
981
+ this.transparentQueue.push({distance, state: render3DCaptureBatchState(), draw});
865
982
  }
866
983
 
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 */
984
+ /** Draw the queued transparent draws far to near with the state each was drawn under, called automatically at the
985
+ * end of the transparent stage */
868
986
  flushTransparentQueue()
869
987
  {
870
988
  const queue = this.transparentQueue;
871
989
  if (!queue) return;
872
990
  this.transparentQueue = undefined;
873
991
  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
992
+ // each draw under the state it was queued with, and the state left as it was found
993
+ const state = render3DCaptureBatchState();
994
+ try
995
+ {
996
+ for (const item of queue)
997
+ render3DApplyBatchState(item.state), item.draw();
998
+ }
999
+ finally { render3DApplyBatchState(state); }
876
1000
  }
877
1001
 
878
1002
  /** Draw render3D.sky around the camera, unlit, unfogged and behind everything, called automatically by the pass */
@@ -903,16 +1027,20 @@ class Render3DPlugin
903
1027
  this.shadowPlanes = render3DFrustumPlanes(this.shadowMatrix);
904
1028
  }
905
1029
 
906
- /** Build a sky dome, set it as the sky and set the fog color to the horizon color
1030
+ /** Build a sky dome, set it as the sky, and light the scene by it: the fog takes the horizon color, and the
1031
+ * ambient light comes from the top color above and the bottom color below, both at the ambient strength
907
1032
  * @param {Color} [topColor] - Straight up
908
1033
  * @param {Color} [horizonColor] - Level with the camera
909
1034
  * @param {Color} [bottomColor] - Straight down, defaults to the horizon color
1035
+ * @param {number} [ambient] - How much of the sky colors lights the scene as ambient, 0 for none
910
1036
  * @return {Mesh} - The dome, also in render3D.sky */
911
- setSky(topColor, horizonColor=hsl(.6, 1, .9), bottomColor)
1037
+ setSky(topColor=hsl(.6, .8, .55), horizonColor=hsl(.6, 1, .9), bottomColor=horizonColor, ambient=.5)
912
1038
  {
913
1039
  this.sky?.dispose();
914
1040
  this.sky = buildSky(topColor, horizonColor, bottomColor);
915
1041
  this.fogColor = horizonColor.copy();
1042
+ this.ambientColor = topColor.scale(ambient, 1);
1043
+ this.ambientGroundColor = bottomColor.scale(ambient, 1);
916
1044
  return this.sky;
917
1045
  }
918
1046
 
@@ -957,27 +1085,41 @@ class Render3DPlugin
957
1085
  * @param {TileInfo|TextureInfo} [tileInfo]
958
1086
  * @param {Color} [color]
959
1087
  * @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 */
1088
+ * @param {boolean} [upright] - Stand on world up and only turn to face the camera, for sprites on the ground
1089
+ * @return {void} */
961
1090
  drawBillboard(pos, size=vec2(1), tileInfo, color=WHITE, angle=0, upright=false)
962
1091
  {
963
- if (this.capture)
964
- return this.drawStripUnlit(render3DBillboardCorners(pos, size, angle, upright), this.cameraBack, RENDER3D_QUAD_UVS, color, tileInfo);
1092
+ if (this.capture) // the mesh keeps the color, so it gets its own, the particles reuse theirs
1093
+ return this.drawStripUnlit(render3DBillboardCorners(pos, size, angle, upright), this.cameraBack,
1094
+ RENDER3D_QUAD_UVS, color.copy(), tileInfo);
965
1095
  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
1096
  {
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);
1097
+ const p = pos.copy(), s = size.copy(), c = color.copy(); // copies, the queue replays later
1098
+ return this.queueTransparent(p, ()=> this.drawBillboard(p, s, tileInfo, c, angle, upright));
980
1099
  }
1100
+
1101
+ // the quad's six stream vertices written straight in with no vectors made, unlit, for sprites, and for
1102
+ // particles when instancing is off
1103
+ const lit = this.lighting;
1104
+ this.lighting = this.shadowPass && lit; // unlit on screen, in the shadow map the object's flag decides
1105
+ let uvRect;
1106
+ try { uvRect = render3DBeginStrip(6, tileInfo); }
1107
+ finally { this.lighting = lit; }
1108
+ if (!uvRect) return;
1109
+ const a = render3DBillboardAxes(size, angle, upright);
1110
+ const rx = a[0], ry = a[1], rz = a[2], ux = a[3], uy = a[4], uz = a[5];
1111
+ const x = pos.x, y = pos.y, z = pos.z, n = this.cameraBack, rgba = color.rgbaInt();
1112
+ const u0 = uvRect.x, v0 = uvRect.y, u1 = u0 + uvRect.w, v1 = v0 + uvRect.h;
1113
+ const floats = this.streamFloats, ints = this.streamInts, stride = RENDER3D_VERTEX_FLOATS;
1114
+ let j = this.streamCount * stride;
1115
+ this.streamCount += 6;
1116
+ // the corners in strip order with the repeats: top left twice, bottom left, top right, bottom right twice
1117
+ render3DWriteVertex(floats, ints, j, x - rx + ux, y - ry + uy, z - rz + uz, n, u0, v0, rgba);
1118
+ render3DWriteVertex(floats, ints, j += stride, x - rx + ux, y - ry + uy, z - rz + uz, n, u0, v0, rgba);
1119
+ render3DWriteVertex(floats, ints, j += stride, x - rx - ux, y - ry - uy, z - rz - uz, n, u0, v1, rgba);
1120
+ render3DWriteVertex(floats, ints, j += stride, x + rx + ux, y + ry + uy, z + rz + uz, n, u1, v0, rgba);
1121
+ render3DWriteVertex(floats, ints, j += stride, x + rx - ux, y + ry - uy, z + rz - uz, n, u1, v1, rgba);
1122
+ render3DWriteVertex(floats, ints, j += stride, x + rx - ux, y + ry - uy, z + rz - uz, n, u1, v1, rgba);
981
1123
  }
982
1124
 
983
1125
  /** Draw a quad from four corners in loop order, counter clockwise seen from the front, a is the top left of the texture
@@ -989,7 +1131,11 @@ class Render3DPlugin
989
1131
  * @param {Color|Array<Color>} [color] - One for all or one per corner */
990
1132
  drawQuad(a, b, c, d, tileInfo, color=WHITE)
991
1133
  {
992
- this.drawStrip(render3DQuadStrip(a, b, c, d), render3DFaceNormal(a, b, c, d), RENDER3D_QUAD_UVS, render3DQuadValues(color), tileInfo);
1134
+ if (this.transparentQueue) // the queue replays later, so it keeps copies of what a caller may reuse
1135
+ a = a.copy(), b = b.copy(), c = c.copy(), d = d.copy();
1136
+ color = isArray(color) ? color.map(k=> k.copy()) : color.copy();
1137
+ this.drawStrip(render3DQuadStrip(a, b, c, d), render3DFaceNormal(a, b, c, d), RENDER3D_QUAD_UVS,
1138
+ render3DQuadValues(color), tileInfo);
993
1139
  }
994
1140
 
995
1141
  /** Draw a triangle, counter clockwise from outside is the front
@@ -999,6 +1145,8 @@ class Render3DPlugin
999
1145
  * @param {Color} [color] */
1000
1146
  drawTriangle(a, b, c, color=WHITE)
1001
1147
  {
1148
+ if (this.transparentQueue) // the queue replays later, so it keeps copies of what a caller may reuse
1149
+ a = a.copy(), b = b.copy(), c = c.copy(), color = color.copy();
1002
1150
  this.drawStrip([a, b, c], render3DFaceNormal(a, b, c), undefined, color);
1003
1151
  }
1004
1152
 
@@ -1019,12 +1167,14 @@ class Render3DPlugin
1019
1167
  * @param {number|Array<number>} [width] - Full width, one for all or one per point
1020
1168
  * @param {TileInfo|TextureInfo} [tileInfo]
1021
1169
  * @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 */
1170
+ * @param {Vector3|Array<Vector3>} [side] - Direction across the ribbon, one for all or one per point, default
1171
+ * faces the camera */
1023
1172
  drawRibbon(points, width=.1, tileInfo, color=WHITE, side)
1024
1173
  {
1025
1174
  const count = points.length;
1026
1175
  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');
1176
+ ASSERT(!tileInfo || tileInfo instanceof TileInfo || tileInfo instanceof TextureInfo,
1177
+ 'tileInfo must be a TileInfo or TextureInfo, it comes before color');
1028
1178
  const strip = [], uvs = tileInfo ? [] : undefined, colors = [], forward = this.cameraForward;
1029
1179
  let across = vec3(1, 0, 0); // kept from the last point where the direction vanishes
1030
1180
  // a loop's two ends take their direction across the join, so they meet edge to edge
@@ -1033,7 +1183,9 @@ class Render3DPlugin
1033
1183
  {
1034
1184
  const p = points[i];
1035
1185
  const w = isArray(width) ? width[i] : width;
1036
- const c = isArray(color) ? color[i] : color;
1186
+ let c = isArray(color) ? color[i] : color;
1187
+ if (this.transparentQueue) // the queue replays later, so it keeps a copy of a color a caller may reuse
1188
+ c = c.copy();
1037
1189
  const s = side && (isArray(side) ? side[i] : side);
1038
1190
  // across the path in the camera plane unless a side is given
1039
1191
  const next = points[i < count - 1 ? i + 1 : loop ? 1 : i];
@@ -1046,7 +1198,8 @@ class Render3DPlugin
1046
1198
  uvs?.push(vec2(i / (count - 1), 0), vec2(i / (count - 1), 1));
1047
1199
  colors.push(c, c);
1048
1200
  }
1049
- render3DWithState({lighting: false, cullBackFaces: false}, ()=> this.drawStrip(strip, forward.scale(-1), uvs, colors, tileInfo));
1201
+ render3DWithState({lighting: false, cullBackFaces: false},
1202
+ ()=> this.drawStrip(strip, forward.scale(-1), uvs, colors, tileInfo));
1050
1203
  }
1051
1204
 
1052
1205
  /** Draw a disc that fades to transparent at the rim, unlit, for glows, puffs and sky dots
@@ -1054,12 +1207,16 @@ class Render3DPlugin
1054
1207
  * @param {number} [size] - Diameter
1055
1208
  * @param {Color} [color]
1056
1209
  * @param {Vector3} [normal] - Facing direction, faces the camera by default
1057
- * @param {number} [sides] */
1210
+ * @param {number} [sides]
1211
+ * @return {void} */
1058
1212
  drawSoftDisc(pos, size=1, color=WHITE, normal=this.cameraBack, sides=16)
1059
1213
  {
1060
1214
  render3DAssertBlending();
1061
1215
  if (this.transparentQueue && !this.capture)
1062
- return this.queueTransparent(pos, ()=> this.drawSoftDisc(pos, size, color, normal, sides));
1216
+ {
1217
+ const p = pos.copy(), c = color.copy(), n = normal.copy(); // copies, the queue replays later
1218
+ return this.queueTransparent(p, ()=> this.drawSoftDisc(p, size, c, n, sides));
1219
+ }
1063
1220
  // basis in the disc's plane
1064
1221
  const n = normal.normalize();
1065
1222
  const helper = abs(n.y) < .9 ? vec3(0, 1, 0) : vec3(1, 0, 0);
@@ -1072,16 +1229,24 @@ class Render3DPlugin
1072
1229
  * - Draw it from onRenderTransparent or from a transparent object
1073
1230
  * @param {Vector3} pos - Position of the thing casting the shadow
1074
1231
  * @param {number} [size] - Diameter
1075
- * @param {number|HeightMap|Function} [floorHeight] - Height of the ground, a HeightMap, or (x, z) => y to follow terrain
1232
+ * @param {number|HeightMap|function(number, number): number} [floorHeight] - Height of the ground, a HeightMap, or
1233
+ * (x, z) => y to follow terrain
1076
1234
  * @param {Color} [color]
1077
- * @param {number} [lift] - How far above the ground to draw, raise it if the shadow cuts into rough ground */
1235
+ * @param {number} [lift] - How far above the ground to draw, raise it if the shadow cuts into rough ground
1236
+ * @return {void} */
1078
1237
  drawSoftShadow(pos, size=1, floorHeight=0, color=RENDER3D_SHADOW_COLOR, lift=.02)
1079
1238
  {
1080
1239
  render3DAssertBlending();
1081
- const height = isNumber(floorHeight) ? ()=> floorHeight
1082
- : floorHeight instanceof HeightMap ? (x, z)=> floorHeight.getHeight(x, z) : floorHeight;
1240
+ // a HeightMap is in the extras plugin, so it is known by its getHeight rather than its class
1241
+ const heightMap = /** @type {HeightMap} */ (floorHeight);
1242
+ const height = /** @type {function(number, number): number} */ (isNumber(floorHeight) ? ()=> floorHeight
1243
+ : heightMap.getHeight ? (x, z)=> heightMap.getHeight(x, z) : floorHeight);
1083
1244
  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));
1245
+ {
1246
+ const p = pos.copy(), c = color.copy(); // copies, the queue replays later
1247
+ return this.queueTransparent(vec3(p.x, height(p.x, p.z) + lift, p.z),
1248
+ ()=> this.drawSoftShadow(p, size, floorHeight, c, lift));
1249
+ }
1085
1250
  render3DDrawSoftDisc(size / 2, color, 16, RENDER3D_DEFAULT_NORMAL, (c, s, r)=>
1086
1251
  {
1087
1252
  const x = pos.x + c * r, z = pos.z + s * r;
@@ -1093,7 +1258,8 @@ class Render3DPlugin
1093
1258
  function render3DAssertBlending()
1094
1259
  {
1095
1260
  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');
1261
+ ASSERT(r.blend || r.capture || r.shadowPass || !r.isRendering,
1262
+ 'soft discs and shadows need blending: set the object transparent or draw from onRenderTransparent');
1097
1263
  }
1098
1264
 
1099
1265
  // draw the three rings of a soft disc as unlit strips, pointAt(cos, sin, radius) gives the world point
@@ -1115,98 +1281,6 @@ function render3DDrawSoftDisc(radius, color, sides, normal, pointAt)
1115
1281
  }
1116
1282
  }
1117
1283
 
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
1284
  ///////////////////////////////////////////////////////////////////////////////
1211
1285
  /**
1212
1286
  * Camera3D - Position, rotation and lens for the 3D view
@@ -1248,7 +1322,8 @@ class Camera3D
1248
1322
  getProjectionMatrix(aspect)
1249
1323
  {
1250
1324
  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);
1325
+ return h ? Matrix4.orthographic(-w, w, -h, h, this.near, this.far) :
1326
+ Matrix4.perspective(this.fov, aspect, this.near, this.far);
1252
1327
  }
1253
1328
 
1254
1329
  /** Returns the direction the camera looks
@@ -1276,7 +1351,8 @@ class Camera3D
1276
1351
  {
1277
1352
  const r = cos(pitch) * distance;
1278
1353
  this.pos = target.add(vec3(sin(yaw) * r, sin(pitch) * distance, cos(yaw) * r));
1279
- this.lookAt(target);
1354
+ // straight down has no yaw of its own to look along, so the orbit's yaw is used, and a top down view turns
1355
+ this.rotation = render3DLookRotation(target.subtract(this.pos), vec3(0, yaw, 0));
1280
1356
  }
1281
1357
 
1282
1358
  /** Chase a target from an offset, easing toward it, and look at it
@@ -1309,12 +1385,16 @@ class Camera3D
1309
1385
  // GL setup, shaders and the frame hooks
1310
1386
 
1311
1387
  // the four attributes of a 36 byte vertex at the locations the shaders declare: location, size, type, normalize, byte offset
1388
+ /** @type {Array<[number, number, number, boolean, number]>} */
1312
1389
  const RENDER3D_ATTRIBS = [[0, 3, 5126, false, 0], [1, 3, 5126, false, 12], [2, 2, 5126, false, 24], [3, 4, 5121, true, 32]];
1313
1390
 
1314
1391
  // the vertex shader, shared by the plugin's program and every Shader's
1315
1392
  // 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
1393
+ // uniforms: viewProj, lightViewProj; the model matrix, the tint and the uv rect are vertex attributes, see
1394
+ // RENDER3D_VERTEX_INPUTS; L is the mesh's own uv for a Shader's localUV
1395
+ // the normal matrix comes from the model matrix here: each column over its squared length, which is the inverse
1396
+ // transpose of any rotation and scale, mirrored or not, and skips a 3x3 inverse per draw on the CPU; a sheared
1397
+ // matrix, one built by multiplying rotations with scales between them, gets normals that are only close
1318
1398
  const RENDER3D_VERTEX_SOURCE =
1319
1399
  '#version 300 es\n' +
1320
1400
  'precision highp float;' +
@@ -1325,7 +1405,8 @@ const RENDER3D_VERTEX_SOURCE =
1325
1405
  'vec4 w=mat4(m0,m1,m2,m3)*vec4(p,1.);' +
1326
1406
  'gl_Position=viewProj*w;' +
1327
1407
  'P=w.xyz;' +
1328
- 'N=mat3(n0,n1,n2)*n;' +
1408
+ 'vec3 c0=m0.xyz,c1=m1.xyz,c2=m2.xyz;' +
1409
+ 'N=mat3(c0/dot(c0,c0),c1/dot(c1,c1),c2/dot(c2,c2))*n;' +
1329
1410
  'T=uvRect.xy+t*uvRect.zw;' +
1330
1411
  'L=t;' +
1331
1412
  'C=c*tint;' +
@@ -1342,6 +1423,7 @@ const RENDER3D_SNIPPET_NAMES =
1342
1423
  '#define sunDirection (-lightDir.xyz)\n' +
1343
1424
  '#define sunColor lightColor.rgb\n' +
1344
1425
  '#define ambientColor ambientFog.rgb\n' +
1426
+ '#define ambientGroundColor ambientGround.rgb\n' +
1345
1427
  '#define lightCount extraLightCount\n' +
1346
1428
  '#define lights extraLights\n' +
1347
1429
  '#define lightColors extraLightColors\n';
@@ -1355,11 +1437,12 @@ function render3DFragmentSource(fragmentCode)
1355
1437
  {
1356
1438
  return '#version 300 es\n' +
1357
1439
  'precision highp float;' +
1358
- 'uniform vec4 lightDir,lightColor,ambientFog,fogColor,shadowParams;' +
1440
+ 'uniform vec4 lightDir,lightColor,ambientFog,ambientGround,fogColor,shadowParams;' +
1359
1441
  'uniform vec4 extraLights[' + RENDER3D_MAX_LIGHTS + '],extraLightColors[' + RENDER3D_MAX_LIGHTS + '];' +
1360
1442
  'uniform int extraLightCount;' +
1361
1443
  'uniform vec3 cameraPos;' +
1362
1444
  'uniform sampler2D tex;' +
1445
+ 'uniform bool premultipliedTexture;' + // is the texture a render target, which holds premultiplied color
1363
1446
  'uniform highp sampler2DShadow shadowMap;' +
1364
1447
  'in vec3 P,N;in vec2 T,L;in vec4 C,S;' +
1365
1448
  'out vec4 o;' +
@@ -1376,6 +1459,7 @@ function render3DFragmentSource(fragmentCode)
1376
1459
  (fragmentCode ? RENDER3D_SNIPPET_NAMES + fragmentCode + '\n' : '') +
1377
1460
  'void main(){' +
1378
1461
  (fragmentCode ? 'vec4 t;mainImage(t,T);' : 'vec4 t=texture(tex,T);') +
1462
+ 'if(premultipliedTexture&&t.a>0.)t.rgb/=t.a;' + // back to straight color, what the lighting and blend expect
1379
1463
  'if(shadowParams.w>0.&&t.a<.5)discard;' + // an opaque draw drops see through texels, as the shadow map does
1380
1464
  'vec4 c=C*t;' +
1381
1465
  'float e=lightDir.w;' +
@@ -1384,9 +1468,11 @@ function render3DFragmentSource(fragmentCode)
1384
1468
  'if(!gl_FrontFacing)n=-n;' + // only a double sided mesh shows a back face, light it on the side that is seen
1385
1469
  'float nl=dot(n,-lightDir.xyz);' +
1386
1470
  '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
1471
+ // the ambient: one color, or blended from the ground color below to the sky color above by the way the face points
1472
+ 'vec3 l=(ambientGround.a>0.?mix(ambientGround.rgb,ambientFog.rgb,n.y*.5+.5):ambientFog.rgb)+lightColor.rgb*max(nl,0.)*s;' +
1473
+ // the Light3D objects: a point light falls off with distance, a directional one does not and carries the
1474
+ // direction toward it in xyz, marked by a negative radius; each adds its own highlight when there is a strength
1475
+ 'vec3 eye=lightColor.a>0.?normalize(cameraPos-P):vec3(0),sp=vec3(0);' +
1390
1476
  'for(int i=0;i<' + RENDER3D_MAX_LIGHTS + ';++i){' +
1391
1477
  'if(i>=extraLightCount)break;' +
1392
1478
  'vec4 L=extraLights[i];' +
@@ -1394,14 +1480,18 @@ function render3DFragmentSource(fragmentCode)
1394
1480
  'vec3 v=directional?L.xyz:L.xyz-P;' +
1395
1481
  'float d=length(v);' +
1396
1482
  '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)));' +
1483
+ 'v/=max(d,1e-6);' +
1484
+ 'float ln=dot(n,v);' +
1485
+ 'vec3 lc=extraLightColors[i].rgb*extraLightColors[i].a*a*a;' +
1486
+ 'l+=lc*max(0.,ln);' +
1487
+ 'if(lightColor.a>0.)sp+=lc*pow(max(dot(reflect(-v,n),eye),0.),16.)*step(0.,ln);' +
1398
1488
  '}' +
1399
1489
  '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
1490
+ // specular: the sun's only where its light hits and out of shadow, then the Light3D highlights,
1491
+ // skipped entirely when the strength is zero
1401
1492
  'if(lightColor.a>0.){' +
1402
- 'vec3 v=normalize(cameraPos-P);' +
1403
1493
  '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);' +
1494
+ 'c.rgb+=lightColor.rgb*pow(max(dot(r,eye),0.),16.)*lightColor.a*step(0.,nl)*s*(1.-e)+sp*lightColor.a*(1.-e);' +
1405
1495
  '}}else c.rgb*=e;' + // fully emissive: its own color, or brighter, with no lighting to work out
1406
1496
  'if(ambientFog.a>0.){' +
1407
1497
  'float z=distance(cameraPos,P);' +
@@ -1453,37 +1543,40 @@ function render3DInitGL()
1453
1543
  return;
1454
1544
  }
1455
1545
  const gl = glContext, r = render3D;
1546
+ glFlush(); // a pending 2D batch draws now, while the engine's own buffer, vertex array and program are bound
1456
1547
  r.uniforms = new Map;
1457
1548
  r.uniformValues = {};
1549
+ render3DShadowCut = undefined; // the shadow shader is new too
1458
1550
  r.attribValues = []; // a fresh context has its own attribute defaults, so nothing sent before it counts
1459
1551
 
1460
1552
  // the shader, see RENDER3D_VERTEX_SOURCE and render3DFragmentSource
1461
1553
  r.program = glCreateProgram(RENDER3D_VERTEX_SOURCE, render3DFragmentSource());
1462
1554
 
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
1555
+ // the depth only shader for the shadow map, same vertex layout; see through pixels cast nothing, so sprites and
1556
+ // cut out textures cast their outline, and a blended object faded below half its alpha casts nothing, as it
1557
+ // draws; an opaque one draws solid whatever its tint alpha, and casts so
1465
1558
  r.shadowShader = glCreateProgram(
1466
1559
  '#version 300 es\n' +
1467
1560
  'precision highp float;' +
1468
1561
  'uniform mat4 viewProj;' +
1469
1562
  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.);}'
1563
+ 'out vec2 T;out float A;' +
1564
+ 'void main(){T=uvRect.xy+t*uvRect.zw;A=c.a*tint.a;gl_Position=viewProj*mat4(m0,m1,m2,m3)*vec4(p,1.);}'
1472
1565
  ,
1473
1566
  '#version 300 es\n' +
1474
1567
  'precision highp float;' +
1475
1568
  'uniform sampler2D tex;' +
1476
- 'in vec2 T;' +
1477
- 'void main(){if(texture(tex,T).a<.5)discard;}'
1569
+ 'uniform float cut;' + // 1 for a blended batch, cut by its tint and vertex alpha too, 0 by the texture only
1570
+ 'in vec2 T;in float A;' +
1571
+ 'void main(){if(texture(tex,T).a*mix(1.,A,cut)<.5)discard;}'
1478
1572
  );
1479
1573
 
1480
1574
  // the vertex array object with the attributes enabled once, pointers are set per buffer by render3DBindVertexBuffer
1575
+ // the per instance attributes get their divisor only while a batch has them on, see render3DDrawInstanced
1481
1576
  r.vao = gl.createVertexArray();
1482
1577
  gl.bindVertexArray(r.vao);
1483
1578
  for (const [location] of RENDER3D_ATTRIBS)
1484
1579
  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
1580
 
1488
1581
  // the stream buffer
1489
1582
  r.streamBuffer = gl.createBuffer();
@@ -1492,15 +1585,15 @@ function render3DInitGL()
1492
1585
  r.streamCount = 0;
1493
1586
  r.instanceBuffers = [gl.createBuffer(), gl.createBuffer(), gl.createBuffer()];
1494
1587
 
1495
- // white texture for untextured draws, and a one texel shadow map that keeps the shadow sampler valid until shadows are on
1588
+ // white texture for untextured draws, and a one texel shadow map that keeps the shadow sampler valid until shadows
1589
+ // are on
1496
1590
  r.whiteTexture = glCreateTexture();
1497
- r.mipmapped = new WeakSet;
1498
1591
 
1499
- r.samplers = [];
1592
+ r.samplers = new Map;
1500
1593
  r.samplerKey = undefined;
1501
1594
  render3DUpdateShadowMap(1);
1502
1595
 
1503
- // hand the engine back its own buffer and vertex array, in that order so a pending 2D batch flushes right
1596
+ // hand the engine back its own buffer, vertex array and program, the 2D batch was flushed before they changed
1504
1597
  gl.bindBuffer(gl.ARRAY_BUFFER, glArrayBuffer);
1505
1598
  glSetInstancedMode(true);
1506
1599
  }
@@ -1512,7 +1605,8 @@ function render3DContextLost()
1512
1605
  for (const shader of glShaderObjects)
1513
1606
  shader.program3D = undefined; // compiled again by the next draw
1514
1607
  r.lightCount = 0;
1515
- r.instanceBuffers = r.samplers = [];
1608
+ r.instanceBuffers = [];
1609
+ r.samplers = new Map;
1516
1610
  r.samplerKey = undefined;
1517
1611
  render3DClearInstances();
1518
1612
  r.shadowFramebuffer = r.shadowTexture = undefined;
@@ -1535,8 +1629,7 @@ function render3DUniform(name, program=render3D.currentProgram)
1535
1629
  return cache[name] ??= glContext.getUniformLocation(program, name);
1536
1630
  }
1537
1631
 
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);
1632
+ // the model matrix, the tint and the uv rect as constant attributes for one draw
1540
1633
  function render3DDrawAttribs(m, tint, uvRect)
1541
1634
  {
1542
1635
  const gl = glContext;
@@ -1544,13 +1637,6 @@ function render3DDrawAttribs(m, tint, uvRect)
1544
1637
  gl.vertexAttrib4f(5, m[4], m[5], m[6], m[7]);
1545
1638
  gl.vertexAttrib4f(6, m[8], m[9], m[10], m[11]);
1546
1639
  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
1640
  render3DAttrib4f(11, tint.r, tint.g, tint.b, tint.a);
1555
1641
  render3DAttrib4f(12, uvRect.x, uvRect.y, uvRect.w, uvRect.h);
1556
1642
  }
@@ -1558,62 +1644,48 @@ function render3DDrawAttribs(m, tint, uvRect)
1558
1644
  // set a constant vec4 attribute only when its value changed since the last time
1559
1645
  function render3DAttrib4f(location, x, y, z, w)
1560
1646
  {
1561
- const values = render3D.attribValues, last = values[location];
1562
- if (last && last[0] === x && last[1] === y && last[2] === z && last[3] === w)
1647
+ const values = render3D.attribValues, last = values[location] ||= [NaN, NaN, NaN, NaN]; // kept, not made per draw
1648
+ if (last[0] === x && last[1] === y && last[2] === z && last[3] === w)
1563
1649
  return;
1564
- values[location] = [x, y, z, w];
1650
+ last[0] = x, last[1] = y, last[2] = z, last[3] = w;
1565
1651
  glContext.vertexAttrib4f(location, x, y, z, w);
1566
1652
  }
1567
1653
 
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
1654
  // 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
1655
+ // a sampler sets the filtering for the 3D pass only and leaves the engine's textures as they are for 2D; they are
1656
+ // made as textures need them, and all dropped when the settings change
1592
1657
  function render3DUpdateSamplers()
1593
1658
  {
1594
- const gl = glContext, r = render3D, key = tilesPixelated + ' ' + r.anisotropy;
1659
+ const r = render3D, key = tilesPixelated + ' ' + r.anisotropy;
1595
1660
  if (r.samplerKey === key) return;
1596
1661
  r.samplerKey = key;
1597
- for (const sampler of r.samplers)
1598
- gl.deleteSampler(sampler); // the set being replaced, a lost context empties this first
1662
+ for (const sampler of r.samplers.values())
1663
+ glContext.deleteSampler(sampler); // the set being replaced, a lost context empties this first
1664
+ r.samplers.clear();
1665
+ }
1666
+
1667
+ // the sampler for a texture's wrap modes, smooth or hard edged, made the first time it is needed
1668
+ function render3DSampler(wrap, pixelated)
1669
+ {
1670
+ const gl = glContext, r = render3D, [wrapS, wrapT] = glWrapModes(wrap);
1671
+ const key = wrapS * 1e5 + wrapT * 2 + (pixelated ? 1 : 0); // the modes are 5 digit numbers
1672
+ let sampler = r.samplers.get(key);
1673
+ if (sampler) return sampler;
1674
+ sampler = gl.createSampler();
1675
+ const sharp = pixelated || tilesPixelated;
1676
+ gl.samplerParameteri(sampler, gl.TEXTURE_MAG_FILTER, sharp ? gl.NEAREST : gl.LINEAR);
1677
+ gl.samplerParameteri(sampler, gl.TEXTURE_MIN_FILTER, pixelated ? gl.NEAREST
1678
+ : tilesPixelated ? gl.NEAREST_MIPMAP_LINEAR : gl.LINEAR_MIPMAP_LINEAR);
1679
+ gl.samplerParameteri(sampler, gl.TEXTURE_WRAP_S, wrapS);
1680
+ gl.samplerParameteri(sampler, gl.TEXTURE_WRAP_T, wrapT);
1599
1681
  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
- }));
1682
+ if (anisotropy && !pixelated)
1683
+ {
1684
+ const most = gl.getParameter(anisotropy.MAX_TEXTURE_MAX_ANISOTROPY_EXT);
1685
+ gl.samplerParameterf(sampler, anisotropy.TEXTURE_MAX_ANISOTROPY_EXT, clamp(r.anisotropy, 1, most));
1686
+ }
1687
+ r.samplers.set(key, sampler);
1688
+ return sampler;
1617
1689
  }
1618
1690
 
1619
1691
  // bind the texture of a tile or texture, white when there is none or it is not loaded, with the 3D sampler that
@@ -1621,15 +1693,17 @@ function render3DUpdateSamplers()
1621
1693
  function render3DBindTexture(tileInfo, state=render3D)
1622
1694
  {
1623
1695
  const gl = glContext, r = render3D;
1624
- const textureInfo = tileInfo instanceof TileInfo ? tileInfo.textureInfo : tileInfo;
1696
+ const textureInfo = render3DTextureOf(tileInfo);
1625
1697
  const texture = textureInfo?.glTexture || r.whiteTexture;
1626
1698
  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);
1699
+ if (texture === r.whiteTexture || !r.mipmaps && !state.pixelated)
1700
+ return gl.bindSampler(0, null); // the texture's own filtering, as in 2D; the white texel needs no mipmaps
1701
+ // or anisotropy, and filtering it that way costs every untextured fragment
1702
+ gl.bindSampler(0, render3DSampler(textureInfo?.wrap, state.pixelated));
1703
+ glUpdateMipmaps(texture); // drawn into since its mipmaps were made
1704
+ if (!state.pixelated && !glMipmappedTextures.has(texture)) // a hard edged draw never reads them
1705
+ {
1706
+ glMipmappedTextures.add(texture); // the core makes them again when the texture changes
1633
1707
  gl.generateMipmap(gl.TEXTURE_2D);
1634
1708
  }
1635
1709
  }
@@ -1637,13 +1711,23 @@ function render3DBindTexture(tileInfo, state=render3D)
1637
1711
  // send a vec4 uniform of the main shader only when its value changed since the last send
1638
1712
  function render3DUniform4f(name, x, y, z, w)
1639
1713
  {
1640
- const values = render3D.uniformValues, last = values[name];
1641
- if (last && last[0] === x && last[1] === y && last[2] === z && last[3] === w)
1714
+ const values = render3D.uniformValues, last = values[name] ||= [NaN, NaN, NaN, NaN]; // kept, not made per draw
1715
+ if (last[0] === x && last[1] === y && last[2] === z && last[3] === w)
1642
1716
  return;
1643
- values[name] = [x, y, z, w];
1717
+ last[0] = x, last[1] = y, last[2] = z, last[3] = w;
1644
1718
  glContext.uniform4f(render3DUniform(name), x, y, z, w);
1645
1719
  }
1646
1720
 
1721
+ // send an int uniform of the main shader only when its value changed since the last send
1722
+ function render3DUniform1i(name, x)
1723
+ {
1724
+ const values = render3D.uniformValues;
1725
+ if (values[name] === x)
1726
+ return;
1727
+ values[name] = x;
1728
+ glContext.uniform1i(render3DUniform(name), x);
1729
+ }
1730
+
1647
1731
  // bind a vertex buffer and point the attributes at it
1648
1732
  function render3DBindVertexBuffer(buffer)
1649
1733
  {
@@ -1653,12 +1737,43 @@ function render3DBindVertexBuffer(buffer)
1653
1737
  gl.vertexAttribPointer(a[0], a[1], a[2], a[3], RENDER3D_VERTEX_BYTES, a[4]);
1654
1738
  }
1655
1739
 
1740
+ // a mesh's vertices and its triangle indices, ready for drawElements
1741
+ function render3DBindMesh(mesh)
1742
+ {
1743
+ render3DBindVertexBuffer(mesh.buffer);
1744
+ glContext.bindBuffer(glContext.ELEMENT_ARRAY_BUFFER, mesh.indexBuffer);
1745
+ }
1746
+
1747
+ // the texture a draw samples: a tile's texture, a TextureInfo as it is, or undefined for white
1748
+ function render3DTextureOf(tileInfo) { return tileInfo instanceof TileInfo ? tileInfo.textureInfo : tileInfo; }
1749
+
1656
1750
  // where a tile sits in its texture, pulled in slightly at the edges so neighbors do not bleed in
1751
+ // the level's solid geometry in 3D, the height maps and voxel maps that objects with collideLevel collide with, as
1752
+ // tileCollisionLayers is in 2D; each joins when made and leaves when destroyed
1753
+ const render3DLevel = [];
1754
+
1755
+ // take one of the level's parts out of it, when it is destroyed
1756
+ function render3DLevelLeave(o)
1757
+ {
1758
+ const i = render3DLevel.indexOf(o);
1759
+ i >= 0 && render3DLevel.splice(i, 1);
1760
+ }
1761
+
1762
+ // the level's parts are looked up in world space, so they stay upright and unscaled at the root
1763
+ function render3DLevelAssertPlaced(o)
1764
+ {
1765
+ ASSERT(!o.parent && !o.rotation3D.lengthSquared() && o.scale3D.x === 1 && o.scale3D.y === 1 && o.scale3D.z === 1,
1766
+ 'a height map or voxel map stays upright and unscaled at the root, its lookups are in world space');
1767
+ }
1768
+
1657
1769
  // this returns one shared object, so read it before calling again
1658
1770
  const render3DTileUVRect = {x:0, y:0, w:1, h:1};
1771
+ // the tiles of objects made from a whole TextureInfo, they cover all of it even after the texture is resized
1772
+ const render3DWholeTiles = new WeakSet;
1659
1773
  function render3DGetTileUVs(tileInfo)
1660
1774
  {
1661
- if (!(tileInfo instanceof TileInfo))
1775
+ // a headless tile has no texture, and a whole texture's tile covers it at any size
1776
+ if (!(tileInfo instanceof TileInfo) || !tileInfo.textureInfo || render3DWholeTiles.has(tileInfo))
1662
1777
  return RENDER3D_FULL_UV_RECT;
1663
1778
  const inv = tileInfo.textureInfo.sizeInverse, rect = render3DTileUVRect;
1664
1779
  const bleedX = inv.x * tileInfo.bleed, bleedY = inv.y * tileInfo.bleed;
@@ -1669,7 +1784,8 @@ function render3DGetTileUVs(tileInfo)
1669
1784
  return rect;
1670
1785
  }
1671
1786
 
1672
- // set the per draw uniforms and gl state for a draw, in the shadow pass only the model matrix of the depth shader
1787
+ // set the per draw uniforms and gl state for a draw, in the shadow pass only the per draw attributes, the texture
1788
+ // and the depth shader's cut
1673
1789
  // tileInfo may be a TileInfo, a TextureInfo, or undefined for the white texture
1674
1790
  // state is the plugin's current fields, or the captured state of a stream batch
1675
1791
  function render3DSetDrawUniforms(matrix, tileInfo, tint, uvRect, state=render3D)
@@ -1680,7 +1796,14 @@ function render3DSetDrawUniforms(matrix, tileInfo, tint, uvRect, state=render3D)
1680
1796
  uvRect ||= render3DGetTileUVs(tileInfo);
1681
1797
  render3DDrawAttribs(matrix.m, tint, uvRect);
1682
1798
  render3DBindTexture(tileInfo, state);
1683
- if (r.shadowPass) return; // the shadow map needs nothing else
1799
+ if (r.shadowPass)
1800
+ {
1801
+ // the shadow map needs only how to cut: by the alpha it blends with, or the texture's as an opaque draw
1802
+ const cut = state.blend ? 1 : 0;
1803
+ if (render3DShadowCut !== cut)
1804
+ glContext.uniform1f(render3DUniform('cut', r.shadowShader), render3DShadowCut = cut);
1805
+ return;
1806
+ }
1684
1807
 
1685
1808
  // the program: a Shader's own, compiled by its first draw, or the plugin's; switching sends the pass uniforms
1686
1809
  const program = state.shader ? render3DShaderProgram(state.shader) : r.program;
@@ -1708,11 +1831,20 @@ function render3DSetDrawUniforms(matrix, tileInfo, tint, uvRect, state=render3D)
1708
1831
  render3DUniform4f('lightDir', s.x / sl, s.y / sl, s.z / sl, state.lighting ? state.emissive : 1);
1709
1832
  render3DUniform4f('lightColor', lc.r, lc.g, lc.b, state.specular);
1710
1833
  render3DUniform4f('ambientFog', ac.r, ac.g, ac.b, r.fogEnd);
1834
+ const gc = r.ambientGroundColor;
1835
+ // its alpha says whether the ground color is on
1836
+ gc ? render3DUniform4f('ambientGround', gc.r, gc.g, gc.b, 1) : render3DUniform4f('ambientGround', 0, 0, 0, 0);
1837
+
1711
1838
  render3DUniform4f('fogColor', fc.r, fc.g, fc.b, r.fogStart);
1712
1839
  // how the fragment shader finishes: 1 drops see through texels and keeps the draw opaque,
1713
1840
  // 0 blends them away instead, and -1 is additive, which has to fade into fog differently
1714
1841
  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);
1842
+ const receives = r.shadows && r.passIsDefault && state.receiveShadow ? 1 : 0;
1843
+ render3DUniform4f('shadowParams', receives, r.shadowBias, r.shadowSoftness / r.shadowTextureSize, blendMode);
1844
+
1845
+ // a render target's texture holds premultiplied color, the blend writes it that way, so the shader undoes it
1846
+ const textureInfo = render3DTextureOf(tileInfo);
1847
+ render3DUniform1i('premultipliedTexture', +!!(textureInfo?.glTexture && glPremultipliedTextures.has(textureInfo.glTexture)));
1716
1848
  }
1717
1849
 
1718
1850
  // the six flat sides of the camera's visible box, each as [x, y, z, w] facing inward
@@ -1745,48 +1877,57 @@ function render3DRender()
1745
1877
  render3DRenderPass(true);
1746
1878
  }
1747
1879
 
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
1880
+ // 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
1881
+ // the state back
1749
1882
  // the layer matching render3D.renderAfter2D is the default and always runs, the other only when an object asks for it
1750
1883
  function render3DRenderPass(after2D)
1751
1884
  {
1752
1885
  const gl = glContext, r = render3D;
1753
- if (!r.program) return; // headless, gl disabled, or context lost
1886
+ if (!r.program || !glEnable) return; // headless, gl disabled, or context lost
1754
1887
  render3DUpdateSamplers();
1755
1888
  ASSERT(!r.fogEnd || r.fogStart < r.fogEnd, 'fogStart must be less than fogEnd');
1756
1889
  ASSERT(!glRenderTarget, 'the 3D pass needs the canvas depth buffer, it can not draw into a render target');
1757
1890
  const isDefault = after2D === !!r.renderAfter2D, objects = render3DLayerObjects(after2D);
1758
1891
  if (!isDefault && !objects.length) return;
1759
1892
  r.passIsDefault = isDefault;
1760
- after2D && glFlush(); // the 2D sprites drawn so far go under this layer
1893
+ // the 2D sprites drawn so far go under this layer, and a batch a plugin left pending before the layer under
1894
+ // the 2D scene draws now, with the engine's own gl state, not after the pass with its own
1895
+ glFlush();
1761
1896
 
1762
1897
  // a previous frame that threw must not leave anything pending
1763
1898
  r.streamCount = 0;
1764
1899
  r.capture = r.transparentQueue = undefined;
1765
1900
  render3DClearInstances();
1766
1901
 
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
1902
  // the Light3D objects, a directional one sends the direction toward it, from the origin, and a negative radius
1903
+ // gathered before the gl state is taken over, so an assert here leaves nothing to hand back
1777
1904
  const lights = render3DCollectLights();
1778
1905
  r.lightCount = lights.length;
1779
1906
  const positions = r.lightPositions, colors = r.lightColors;
1780
1907
  lights.forEach((light, i)=>
1781
1908
  {
1782
1909
  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');
1910
+ ASSERT(!light.directional || p.lengthSquared(),
1911
+ 'a directional light shines from its position toward the origin, so it cannot sit on the origin');
1784
1912
  const c = light.color, k = i * 4;
1785
1913
  positions[k] = p.x, positions[k+1] = p.y, positions[k+2] = p.z;
1786
1914
  positions[k+3] = light.directional ? -1 : max(0, light.radius); // a negative radius marks a direction
1787
1915
  colors[k] = c.r, colors[k+1] = c.g, colors[k+2] = c.b, colors[k+3] = c.a * light.intensity;
1788
1916
  });
1789
1917
 
1918
+ // take over the gl state
1919
+ gl.bindVertexArray(r.vao);
1920
+ // the leading repeat on every strip shifts the triangles by one, which flips
1921
+ // which way they read, so tell WebGL that clockwise is the front here
1922
+ gl.frontFace(gl.CW);
1923
+ // every program's shadow sampler is on unit 1, where a 2D plugin like PostProcessPlugin or LightSystemPlugin
1924
+ // may have left its own texture, which fails every draw; the shadow map goes back there even with shadows off
1925
+ gl.activeTexture(gl.TEXTURE1);
1926
+ gl.bindTexture(gl.TEXTURE_2D, r.shadowTexture || null);
1927
+ gl.activeTexture(gl.TEXTURE0);
1928
+ gl.depthMask(true);
1929
+ gl.clear(gl.DEPTH_BUFFER_BIT);
1930
+
1790
1931
  r.isRendering = true;
1791
1932
  try
1792
1933
  {
@@ -1843,7 +1984,8 @@ function render3DUpdateShadowMap(size)
1843
1984
  gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, texture, 0);
1844
1985
  gl.drawBuffers([gl.NONE]); // depth only
1845
1986
  gl.readBuffer(gl.NONE);
1846
- ASSERT(gl.checkFramebufferStatus(gl.FRAMEBUFFER) == gl.FRAMEBUFFER_COMPLETE, 'shadow map framebuffer is incomplete, try a smaller shadowMapSize');
1987
+ ASSERT(gl.checkFramebufferStatus(gl.FRAMEBUFFER) == gl.FRAMEBUFFER_COMPLETE,
1988
+ 'shadow map framebuffer is incomplete, try a smaller shadowMapSize');
1847
1989
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
1848
1990
  r.shadowTextureSize = size;
1849
1991
  }
@@ -1873,7 +2015,8 @@ function render3DRenderShadowMap()
1873
2015
  try
1874
2016
  {
1875
2017
  // 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));
2018
+ const casters = render3DLayerObjects(!!r.renderAfter2D).filter(o=>
2019
+ o.castShadow && !o.additive && (!o.transparent || o.tileInfo));
1877
2020
  render3DDrawObjects(casters);
1878
2021
  r.onRenderOpaque?.();
1879
2022
  r.flush();
@@ -1909,30 +2052,80 @@ function render3DBeginStrip(count, tileInfo)
1909
2052
  if (r.shadowPass && !r.lighting) return; // unlit things cast no shadow
1910
2053
  ASSERT(count <= RENDER3D_MAX_STREAM_VERTS, 'strip is too large for the stream, bake it into a mesh');
1911
2054
  if (count > RENDER3D_MAX_STREAM_VERTS) return;
1912
- const textureInfo = tileInfo instanceof TileInfo ? tileInfo.textureInfo : tileInfo;
2055
+ const textureInfo = render3DTextureOf(tileInfo);
1913
2056
  if (r.streamCount && (textureInfo !== r.streamTileInfo || render3DStateChanged(r.streamState)
1914
2057
  || r.streamCount + count > RENDER3D_MAX_STREAM_VERTS))
1915
2058
  r.flush();
2059
+ render3DFlushBeforeOverlay();
1916
2060
  if (!r.streamCount)
1917
2061
  r.streamState = render3DCaptureBatchState();
1918
2062
  r.streamTileInfo = textureInfo;
1919
2063
  return render3DGetTileUVs(tileInfo);
1920
2064
  }
1921
2065
 
1922
- // the four corners of a camera facing quad in strip order
1923
- function render3DBillboardCorners(pos, size, angle, upright)
2066
+ // a draw that is not depth tested goes over what is already drawn, so the batches drawn before it go first, or they
2067
+ // would draw at the end of the stage and cover it whatever its render order; a stream open with the same state is
2068
+ // drawn too, so each such draw splits the batches: one draw per object for meshes each with an overlay
2069
+ function render3DFlushBeforeOverlay()
2070
+ {
2071
+ const r = render3D;
2072
+ if (r.depthTest || r.shadowPass || !r.instanceMeshes.length) return;
2073
+ r.flush();
2074
+ render3DFlushInstances();
2075
+ }
2076
+
2077
+ // upload a mesh that is new, changed, or from before the context was lost
2078
+ function render3DMeshUpload(mesh)
2079
+ {
2080
+ if (!mesh.buffer || mesh.dirty || mesh.contextGeneration !== render3D.contextGeneration)
2081
+ mesh.upload();
2082
+ }
2083
+
2084
+ // an object's size3D in the world, grown by its scale and its parents', the size its sprite is drawn at and that
2085
+ // collecting, picking and collision measure it at
2086
+ function render3DWorldSize(o, m)
2087
+ {
2088
+ const s = o.size3D;
2089
+ return vec3(s.x * hypot(m[0], m[1], m[2]), s.y * hypot(m[4], m[5], m[6]), s.z * hypot(m[8], m[9], m[10]));
2090
+ }
2091
+
2092
+ // the surface normal of a height function at x, z, from the slope across a cell each way, clamped to the edges so a
2093
+ // border sample leans the same as its neighbor; buildGrid and HeightMap both light their slopes by it, and it makes
2094
+ // one vector, a big terrain has millions of vertices
2095
+ function render3DSlopeNormal(heightFunction, x, z, ex, ez, halfX, halfZ)
2096
+ {
2097
+ const x0 = max(x - ex, -halfX), x1 = min(x + ex, halfX), z0 = max(z - ez, -halfZ), z1 = min(z + ez, halfZ);
2098
+ const dx = (heightFunction(x1, z) - heightFunction(x0, z)) / (x1 - x0 || 1);
2099
+ const dz = (heightFunction(x, z1) - heightFunction(x, z0)) / (z1 - z0 || 1);
2100
+ const s = 1 / hypot(dx, 1, dz);
2101
+ return vec3(-dx * s, s, -dz * s);
2102
+ }
2103
+
2104
+ // the half axes of a camera facing quad of a size turned by an angle, right then up, in one shared array
2105
+ // so a particle costs no vectors; read it before calling again
2106
+ const render3DBillboardAxesScratch = new Float64Array(6);
2107
+ function render3DBillboardAxes(size, angle, upright)
1924
2108
  {
1925
- // an upright quad stands on world up and only turns to face the camera
1926
- let r = render3D.cameraRight, u = render3D.cameraUp;
2109
+ const r = render3D.cameraRight, u = render3D.cameraUp;
2110
+ let rx = r.x, ry = r.y, rz = r.z, ux = u.x, uy = u.y, uz = u.z;
1927
2111
  if (upright)
1928
2112
  {
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;
2113
+ // an upright quad stands on world up and only turns to face the camera
2114
+ const l = hypot(rx, rz); // a rolled camera has no flat right
2115
+ rx = l ? rx / l : 1, ry = 0, rz = l ? rz / l : 0;
2116
+ ux = 0, uy = 1, uz = 0;
1932
2117
  }
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;
2118
+ const c = cos(angle), s = sin(angle), w = size.x / 2, h = size.y / 2, a = render3DBillboardAxesScratch;
2119
+ a[0] = (rx * c + ux * s) * w, a[1] = (ry * c + uy * s) * w, a[2] = (rz * c + uz * s) * w;
2120
+ a[3] = (ux * c - rx * s) * h, a[4] = (uy * c - ry * s) * h, a[5] = (uz * c - rz * s) * h;
2121
+ return a;
2122
+ }
2123
+
2124
+ // the four corners of a camera facing quad in strip order
2125
+ function render3DBillboardCorners(pos, size, angle, upright)
2126
+ {
2127
+ const a = render3DBillboardAxes(size, angle, upright);
2128
+ const rx = a[0], ry = a[1], rz = a[2], ux = a[3], uy = a[4], uz = a[5];
1936
2129
  return [
1937
2130
  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
2131
  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)];
@@ -1960,14 +2153,57 @@ function render3DForEachStripVertex(points, normals, uvs, colors, callback)
1960
2153
  }
1961
2154
 
1962
2155
  // write one vertex into a packed buffer at float index j
1963
- function render3DWriteVertex(floats, ints, j, p, n, u, v, rgba)
2156
+ function render3DWriteVertex(floats, ints, j, x, y, z, n, u, v, rgba)
1964
2157
  {
1965
- floats[j] = p.x; floats[j+1] = p.y; floats[j+2] = p.z;
2158
+ floats[j] = x; floats[j+1] = y; floats[j+2] = z;
1966
2159
  floats[j+3] = n.x; floats[j+4] = n.y; floats[j+5] = n.z;
1967
2160
  floats[j+6] = u; floats[j+7] = v;
1968
2161
  ints[j+8] = rgba;
1969
2162
  }
1970
2163
 
2164
+ // turn every triangle of an index list the other way round, in place: the authoring form reads counter clockwise
2165
+ // from the front and the pass draws clockwise, as a strip's triangles come out after its leading repeat
2166
+ function render3DFlipTriangles(indices)
2167
+ {
2168
+ for (let t = 0; t < indices.length; t += 3)
2169
+ {
2170
+ const b = indices[t+1];
2171
+ indices[t+1] = indices[t+2], indices[t+2] = b;
2172
+ }
2173
+ return indices;
2174
+ }
2175
+
2176
+ // the triangles of a strip of count entries as an index list, given which vertex each entry maps to and which place
2177
+ // it is at: the strip's triangle i is (i-2, i-1, i), its odd ones read the other way as the GPU reads a strip, and a
2178
+ // triangle with two corners in one place has no area, it is a join between pieces or a sliver at a pole, and is left out
2179
+ function render3DStripTriangles(count, remap, place)
2180
+ {
2181
+ const indices = [];
2182
+ for (let i = 2; i < count; ++i)
2183
+ {
2184
+ const a = i & 1 ? i - 1 : i - 2, b = i & 1 ? i - 2 : i - 1;
2185
+ if (place[a] != place[b] && place[b] != place[i] && place[a] != place[i])
2186
+ indices.push(remap[a], remap[b], remap[i]);
2187
+ }
2188
+ return indices;
2189
+ }
2190
+
2191
+ // a mesh's packed vertex data for the GPU, one vertex per entry of a layout, which is the strip index of each,
2192
+ // written into data when given, the buffer an earlier call returned for the same layout
2193
+ function render3DMeshVertexData(mesh, vertices, data=new ArrayBuffer(vertices.length * RENDER3D_VERTEX_BYTES))
2194
+ {
2195
+ const count = vertices.length;
2196
+ const floats = new Float32Array(data), ints = new Uint32Array(data);
2197
+ for (let j = 0; j < count; ++j)
2198
+ {
2199
+ // a hand built mesh may leave normals, uvs and colors empty
2200
+ const i = vertices[j], p = mesh.points[i], uv = mesh.uvs[i] || RENDER3D_DEFAULT_UV;
2201
+ render3DWriteVertex(floats, ints, j * RENDER3D_VERTEX_FLOATS, p.x, p.y, p.z,
2202
+ mesh.normals[i] || RENDER3D_DEFAULT_NORMAL, uv.x, uv.y, (mesh.colors[i] || WHITE).rgbaInt());
2203
+ }
2204
+ return data;
2205
+ }
2206
+
1971
2207
  // reorder a convex polygon's points, counter clockwise from outside, into one triangle strip
1972
2208
  function render3DPolygonStrip(points)
1973
2209
  {
@@ -1981,18 +2217,53 @@ function render3DPolygonStrip(points)
1981
2217
  return strip;
1982
2218
  }
1983
2219
 
2220
+ // turn every triangle of a mesh the other way round, the normals left alone: an index list reads each one in the
2221
+ // other order, and a strip gets one extra point at each end, which flips every triangle and keeps the count even
2222
+ function render3DFlipWinding(mesh)
2223
+ {
2224
+ if (mesh.indices)
2225
+ return void render3DFlipTriangles(mesh.indices);
2226
+ for (const key of ['points', 'normals', 'uvs', 'colors'])
2227
+ {
2228
+ const a = mesh[key];
2229
+ if (a.length)
2230
+ a.unshift(a[0]), a.push(a[a.length - 1]);
2231
+ }
2232
+ mesh.vertexKeys = undefined;
2233
+ }
2234
+
1984
2235
  ///////////////////////////////////////////////////////////////////////////////
1985
2236
 
1986
2237
  // frees the GPU buffer of a mesh that is garbage collected without dispose, some time after it goes; it holds the
1987
2238
  // buffer and its context, never the mesh, or the mesh could not be collected, and dispose unregisters the mesh
1988
2239
  const render3DMeshBuffers = typeof FinalizationRegistry == 'undefined' ? undefined :
1989
- new FinalizationRegistry(({buffer, generation})=>
1990
- generation === render3D?.contextGeneration && glContext?.deleteBuffer(buffer));
2240
+ new FinalizationRegistry(({buffer, indexBuffer, generation})=>
2241
+ {
2242
+ if (generation !== render3D?.contextGeneration || !glContext) return;
2243
+ glContext.deleteBuffer(buffer);
2244
+ glContext.deleteBuffer(indexBuffer);
2245
+ });
2246
+
2247
+ // the place a point is at, to a hundred thousandth, which smooth normals are summed by
2248
+ function render3DPlaceKey(p) { return `${round(p.x * 1e5)},${round(p.y * 1e5)},${round(p.z * 1e5)}`; }
2249
+
2250
+ // add a face normal to the sum at a triangle's corner a, weighted by the angle there, so a cube corner averages its
2251
+ // three faces evenly however it is cut into triangles
2252
+ function render3DAddCornerNormal(sums, a, b, c, normal)
2253
+ {
2254
+ const u = b.subtract(a), v = c.subtract(a);
2255
+ const angle = Math.acos(clamp(u.dot(v) / (u.length() * v.length() || 1), -1, 1));
2256
+ const k = render3DPlaceKey(a);
2257
+ sums.set(k, (sums.get(k) || vec3()).add(normal.scale(angle)));
2258
+ }
1991
2259
 
1992
2260
  /**
1993
- * Mesh - A triangle strip with positions, normals, uvs and colors, uploaded once and drawn by matrix
2261
+ * Mesh - Triangles with positions, normals, uvs and colors, uploaded once and drawn by matrix
1994
2262
  * - Build with addStrip, addQuad, combine or the shape builders, then render each frame
1995
2263
  * - Its back faces are skipped unless doubleSided is set, which the open builders like buildGrid do for you
2264
+ * - Two forms: a triangle strip, what the builders make, or an indexed list of triangles over their own vertices,
2265
+ * what addTriangles and the model loaders make; upload sends the GPU an indexed list either way, see getTriangles,
2266
+ * so a strip's joins between its pieces cost nothing to draw, and toIndexed turns a strip mesh into the list form
1996
2267
  * - The GPU buffer is created lazily on first render and dropped by dispose, or freed once the mesh is garbage
1997
2268
  * collected, so dispose is only needed to free it right away, like for a mesh rebuilt often
1998
2269
  * @memberof Render3D
@@ -2005,7 +2276,7 @@ class Mesh
2005
2276
  /** Create an empty mesh */
2006
2277
  constructor()
2007
2278
  {
2008
- /** @property {Array<Vector3>} - Vertex positions in strip order
2279
+ /** @property {Array<Vector3>} - Vertex positions, in strip order or one per vertex of an indexed mesh
2009
2280
  * @type {Array<Vector3>} */
2010
2281
  this.points = [];
2011
2282
  /** @property {Array<Vector3>} - Vertex normals
@@ -2017,23 +2288,53 @@ class Mesh
2017
2288
  /** @property {Array<Color>} - Vertex colors
2018
2289
  * @type {Array<Color>} */
2019
2290
  this.colors = [];
2020
- /** @property {WebGLBuffer|undefined} - GPU buffer, created by upload
2291
+ /** @property {WebGLBuffer|undefined} - GPU vertex buffer, created by upload
2021
2292
  * @type {WebGLBuffer|undefined} */
2022
2293
  this.buffer = undefined;
2023
- /** @property {number} - Vertices in the GPU buffer */
2294
+ /** @property {WebGLBuffer|undefined} - GPU index buffer, the triangles, created by upload
2295
+ * @type {WebGLBuffer|undefined} */
2296
+ this.indexBuffer = undefined;
2297
+ /** @property {number} - Indices in the GPU index buffer, three per triangle */
2024
2298
  this.bufferCount = 0;
2299
+ this.indexType = 0; // gl.UNSIGNED_SHORT, or UNSIGNED_INT past 65535 vertices
2025
2300
  /** @property {boolean} - The mesh changed and needs uploading again, set it yourself if you edit the arrays */
2026
2301
  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
2302
+ /** @property {boolean|undefined} - Draw every use of this mesh in the opaque stage as one instanced call,
2303
+ * undefined follows render3D.instancing
2028
2304
  * @type {boolean|undefined} */
2029
2305
  this.instanced = undefined;
2030
2306
  /** @property {boolean} - Draw both sides, each lit as the side that is seen; off skips the faces pointing away,
2031
2307
  * which is faster and right for closed shapes, the open builders like buildGrid and buildRibbon turn it on */
2032
2308
  this.doubleSided = false;
2309
+ /** @property {boolean} - The values change often but the shape never does, for a water surface or a cloth: set
2310
+ * once, the mesh keeps its GPU layout and a dirty upload only rewrites the vertices into the buffer it has;
2311
+ * the strip must keep the same points in the same order, a new point count asserts; the layout is decided by
2312
+ * the first upload, so strip entries equal then stay one vertex and triangles with no area then stay dropped,
2313
+ * set vertexKeys or give it distinct values at the start, not a flat grid of one color or points all in one place */
2314
+ this.dynamicDraw = false;
2315
+ /** @property {Array<number>|undefined} - The mesh as an indexed triangle list instead of a strip: the arrays
2316
+ * hold each vertex once and this says how they join, three vertex numbers per triangle, counter clockwise seen
2317
+ * from the front like a strip's first triangle; addTriangles and the loaders fill it, toIndexed turns a strip
2318
+ * mesh into this form
2319
+ * @type {Array<number>|undefined} */
2320
+ this.indices = undefined;
2321
+ /** @property {Int32Array|undefined} - Which strip entries are one vertex, set by a builder that knows, one
2322
+ * whole number per entry with equal numbers meaning the same vertex; upload skips its search for them, then
2323
+ * drops the keys, since an edit after that may tell the entries apart; adding geometry or recomputing normals
2324
+ * drops them too
2325
+ * @type {Int32Array|undefined} */
2326
+ this.vertexKeys = undefined;
2327
+ /** @type {{vertices: Array<number>, pointCount: number, data: ArrayBuffer}|undefined} */
2328
+ // the strip index of each GPU vertex, the point count and packed data of the last upload, for a dynamicDraw mesh
2329
+ this.vertexLayout = undefined;
2033
2330
  this.instanceCount = 0; // draws waiting in this mesh's batch, with their values, texture and draw state
2331
+ /** @type {Float32Array|undefined} */
2034
2332
  this.instanceData = undefined;
2035
2333
  /** @property {number} - Bounding sphere radius around the origin, for culling and picking, computed by upload */
2036
2334
  this.radius = 0;
2335
+ /** @property {{min: Vector3, max: Vector3}|undefined} - Bounding box, for picking, measured with the radius
2336
+ * @type {{min: Vector3, max: Vector3}|undefined} */
2337
+ this.bounds = undefined;
2037
2338
  this.contextGeneration = 0; // the context the buffer belongs to, see render3D.contextGeneration
2038
2339
  }
2039
2340
 
@@ -2052,6 +2353,12 @@ class Mesh
2052
2353
  * @return {Mesh} */
2053
2354
  addStrip(points, normals, uvs, colors)
2054
2355
  {
2356
+ if (this.indices)
2357
+ {
2358
+ // an indexed mesh takes the strip as the triangles it makes
2359
+ const part = new Mesh().addStrip(points, normals, uvs, colors).toIndexed();
2360
+ return this.addTriangles(part.points, part.indices, part.normals, part.uvs, part.colors);
2361
+ }
2055
2362
  render3DForEachStripVertex(points, normals, uvs, colors, (p, n, uv, c)=>
2056
2363
  {
2057
2364
  this.points.push(p);
@@ -2059,6 +2366,54 @@ class Mesh
2059
2366
  this.uvs.push(uv);
2060
2367
  this.colors.push(c);
2061
2368
  });
2369
+ this.vertexKeys = undefined; // the new entries have no keys
2370
+ this.dirty = true;
2371
+ return this;
2372
+ }
2373
+
2374
+ /** Add triangles over their own vertices, the indexed form a model file comes in
2375
+ * - The mesh becomes indexed: a strip mesh is turned into triangles first, and strips added later join as triangles
2376
+ * - List each triangle counter clockwise as seen from the front, like a strip's first triangle
2377
+ * @param {Array<Vector3>} points - Each vertex once
2378
+ * @param {Array<number>} indices - Three vertex numbers per triangle, into points
2379
+ * @param {Vector3|Array<Vector3>} [normals] - One for all or one per point, default up
2380
+ * @param {Vector2|Array<Vector2>} [uvs] - One for all or one per point, default zero
2381
+ * @param {Color|Array<Color>} [colors] - One for all or one per point, default white
2382
+ * @return {Mesh} */
2383
+ addTriangles(points, indices, normals, uvs, colors)
2384
+ {
2385
+ ASSERT(isArray(points) && isArray(indices) && indices.length % 3 === 0,
2386
+ 'addTriangles takes points and three indices per triangle');
2387
+ ASSERT(indices.every(i=> i >= 0 && i < points.length && i % 1 === 0), 'an index points past the vertices given');
2388
+ this.toIndexed();
2389
+ const offset = this.points.length, normalArray = isArray(normals), uvArray = isArray(uvs), colorArray = isArray(colors);
2390
+ for (let i = 0; i < points.length; ++i)
2391
+ {
2392
+ this.points.push(points[i]);
2393
+ this.normals.push(normalArray ? normals[i] : normals || RENDER3D_DEFAULT_NORMAL);
2394
+ this.uvs.push(uvArray ? uvs[i] : uvs || RENDER3D_DEFAULT_UV);
2395
+ this.colors.push(colorArray ? colors[i] : colors || WHITE);
2396
+ }
2397
+ for (const i of indices)
2398
+ this.indices.push(i + offset);
2399
+ this.dirty = true;
2400
+ return this;
2401
+ }
2402
+
2403
+ /** Turn a strip mesh into the indexed form, each distinct vertex once and the real triangles over them, in place
2404
+ * - An indexed mesh is left as it is; the builders make strips and a loader makes this, and either draws the same
2405
+ * @return {Mesh} */
2406
+ toIndexed()
2407
+ {
2408
+ if (this.indices) return this;
2409
+ const {vertices, indices} = this.getTriangles();
2410
+ this.points = vertices.map(i=> this.points[i]);
2411
+ this.normals = vertices.map(i=> this.normals[i] || RENDER3D_DEFAULT_NORMAL);
2412
+ this.uvs = vertices.map(i=> this.uvs[i] || RENDER3D_DEFAULT_UV);
2413
+ this.colors = vertices.map(i=> this.colors[i] || WHITE);
2414
+ // the list upload sends reads clockwise, the pass draws it that way; the authoring form is counter clockwise
2415
+ this.indices = render3DFlipTriangles(indices);
2416
+ this.vertexKeys = undefined;
2062
2417
  this.dirty = true;
2063
2418
  return this;
2064
2419
  }
@@ -2085,26 +2440,105 @@ class Mesh
2085
2440
  combine(mesh, matrix=RENDER3D_IDENTITY, color=WHITE)
2086
2441
  {
2087
2442
  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)
2443
+ const normalMatrix = render3DNormalMatrix(matrix), mirrors = render3DMirrors(matrix);
2444
+ let part = mesh, order;
2445
+ if (this.indices || mesh.indices)
2446
+ {
2447
+ // one of them is indexed, so both are: the part as a copy if it is a strip
2448
+ this.toIndexed();
2449
+ part = mesh.indices ? mesh : new Mesh().combine(mesh).toIndexed();
2450
+ const offset = this.points.length, indices = part.indices;
2451
+ // the count read once, a mesh combined with itself grows as it is read; a mirror turns every triangle the
2452
+ // other way round
2453
+ for (let t = 0, n = indices.length; t < n; t += 3)
2454
+ this.indices.push(indices[t] + offset, indices[t + (mirrors ? 2 : 1)] + offset,
2455
+ indices[t + (mirrors ? 1 : 2)] + offset);
2456
+ }
2457
+ else if (mirrors && part.points.length)
2090
2458
  {
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));
2459
+ // a strip reads the other way round with one more point at each end, as render3DFlipWinding does
2460
+ const last = part.points.length - 1;
2461
+ order = [0, ...part.points.keys(), last];
2462
+ }
2463
+ for (let j = 0, count = order ? order.length : part.points.length; j < count; ++j)
2464
+ {
2465
+ const i = order ? order[j] : j;
2466
+ this.points.push(matrix.transformPoint(part.points[i]));
2467
+ this.normals.push(normalMatrix.transformDirection(part.normals[i] || RENDER3D_DEFAULT_NORMAL).normalize());
2468
+ this.uvs.push((part.uvs[i] || RENDER3D_DEFAULT_UV).copy());
2469
+ this.colors.push((part.colors[i] || WHITE).multiply(color));
2095
2470
  }
2096
2471
  this.doubleSided ||= mesh.doubleSided; // an open part leaves the whole mesh open
2472
+ this.vertexKeys = undefined; // the new entries have no keys
2097
2473
  this.dirty = true;
2098
2474
  return this;
2099
2475
  }
2100
2476
 
2477
+ /** Returns a new mesh: this one and its mirror image across the plane through the origin facing axis
2478
+ * - For modeling half a shape against that plane, a part that crosses it overlaps its image
2479
+ * @param {Vector3} [axis] - Faces the mirror plane, vec3(1,0,0) mirrors across x
2480
+ * @return {Mesh} */
2481
+ mirror(axis=vec3(1, 0, 0))
2482
+ {
2483
+ ASSERT(isVector3(axis) && axis.lengthSquared() > 0, 'mirror needs an axis');
2484
+ const n = axis.normalize(), a = -2 * n.x, b = -2 * n.y, c = -2 * n.z;
2485
+ const reflect = new Matrix4([1 + a * n.x, b * n.x, c * n.x, 0, a * n.y, 1 + b * n.y, c * n.y, 0,
2486
+ a * n.z, b * n.z, 1 + c * n.z, 0, 0, 0, 0, 1]);
2487
+ return new Mesh().combine(this).combine(this, reflect); // combine turns the image's faces the right way out
2488
+ }
2489
+
2490
+ /** Returns a new mesh of count copies of this one, each turned further around an axis through the origin
2491
+ * @param {number} count - Copies, spaced evenly around the whole turn
2492
+ * @param {Vector3} [axis] - Up by default
2493
+ * @return {Mesh} */
2494
+ spin(count, axis=vec3(0, 1, 0))
2495
+ {
2496
+ ASSERT(count >= 1 && count % 1 === 0, 'spin count must be a whole number, 1 or more');
2497
+ ASSERT(isVector3(axis) && axis.lengthSquared() > 0, 'spin needs an axis');
2498
+ const mesh = new Mesh, unit = axis.normalize();
2499
+ for (let i = 0; i < count; ++i)
2500
+ mesh.combine(this, render3DAxisRotation(unit, i / count * 2 * PI));
2501
+ return mesh;
2502
+ }
2503
+
2504
+ /** Returns a new mesh of everything in this mesh or the other, see subtract
2505
+ * @param {Mesh} mesh - Closed, as the builders make them apart from the open ones like buildGrid
2506
+ * @param {Matrix4|Vector3} [matrix] - Places the other mesh, or just a position to move it to
2507
+ * @return {Mesh} */
2508
+ union(mesh, matrix) { return render3DMeshCSG(this, mesh, matrix, 0); }
2509
+
2510
+ /** Returns a new mesh of this one with the other cut out of it, CSG with BSP trees
2511
+ * - Both must be closed, every edge shared by two triangles, as the builders make them apart from the open
2512
+ * ones like buildGrid and buildRibbon; the result is closed and indexed, and neither mesh changes
2513
+ * - The faces a cut makes come from the other mesh's surface, turned to face out, with its normals, uvs and
2514
+ * colors, so a smooth cylinder drills a round hole
2515
+ * - Parts that overlap must be joined with union to be one solid, not with combine, mirror or spin, which
2516
+ * leave them overlapping, and CSG then gives a wrong shape
2517
+ * - Cuts split the triangles near them, so the result has more: a few thousand triangles take tens to a few
2518
+ * hundred milliseconds, so build shapes this way at load time, not every frame; joining several cutters
2519
+ * with union and cutting once is quicker than cutting with each in turn
2520
+ * - Details closer than about 1e-4 are made one, so build a very small part larger and scale it after
2521
+ * @param {Mesh} mesh - Closed, as the builders make them apart from the open ones like buildGrid
2522
+ * @param {Matrix4|Vector3} [matrix] - Places the other mesh, or just a position to move it to
2523
+ * @return {Mesh}
2524
+ * @example
2525
+ * const wall = buildBox(vec3(4, 3, .5)).subtract(buildBox(vec3(1, 2, 1)), vec3(0, -.5, 0)); // a doorway */
2526
+ subtract(mesh, matrix) { return render3DMeshCSG(this, mesh, matrix, 1); }
2527
+
2528
+ /** Returns a new mesh of only what is in both this mesh and the other, see subtract
2529
+ * @param {Mesh} mesh - Closed, as the builders make them apart from the open ones like buildGrid
2530
+ * @param {Matrix4|Vector3} [matrix] - Places the other mesh, or just a position to move it to
2531
+ * @return {Mesh} */
2532
+ intersect(mesh, matrix) { return render3DMeshCSG(this, mesh, matrix, 2); }
2533
+
2101
2534
  /** Scale every uv, so a whole texture repeats across the mesh when its TextureInfo wraps
2102
2535
  * @param {Vector2|number} scale - Repeats across and up, a number for both
2103
2536
  * @return {Mesh} */
2104
2537
  scaleUVs(scale)
2105
2538
  {
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
2539
+ const s = render3DSize2(scale);
2540
+ // new vectors, builders share uv objects between faces
2541
+ this.uvs = this.uvs.map(uv=> vec2(uv.x * s.x, uv.y * s.y));
2108
2542
  this.dirty = true;
2109
2543
  return this;
2110
2544
  }
@@ -2122,6 +2556,7 @@ class Mesh
2122
2556
  // a mesh built by hand may have no normals yet, and then there is nothing to turn
2123
2557
  this.normals[i] &&= normalMatrix.transformDirection(this.normals[i]).normalize();
2124
2558
  }
2559
+ render3DMirrors(matrix) && render3DFlipWinding(this); // a mirror would leave the faces pointing in
2125
2560
  this.dirty = true;
2126
2561
  return this;
2127
2562
  }
@@ -2130,13 +2565,7 @@ class Mesh
2130
2565
  * @return {Mesh} */
2131
2566
  flipNormals()
2132
2567
  {
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
- }
2568
+ render3DFlipWinding(this);
2140
2569
  this.normals = this.normals.map(n=> n.scale(-1));
2141
2570
  this.dirty = true;
2142
2571
  return this;
@@ -2195,22 +2624,63 @@ class Mesh
2195
2624
  let r = 0;
2196
2625
  for (const p of this.points)
2197
2626
  r = max(r, p.lengthSquared());
2627
+ this.bounds = this.getBounds(); // measured with it, a pick tests a mesh's box after its sphere
2198
2628
  return this.radius = r ** .5;
2199
2629
  }
2200
2630
 
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
2631
+ /** Derive normals from the triangles, of the strip or of the index list
2632
+ * @param {boolean} [smooth] - Round the lighting across faces instead of giving each face a hard edge; flat
2633
+ * normals on an indexed mesh give every corner its own vertex
2203
2634
  * @return {Mesh} */
2204
2635
  computeNormals(smooth=false)
2205
2636
  {
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)
2637
+ if (this.indices)
2210
2638
  {
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
2639
+ // the triangles are listed: smooth normals add up around each position, weighted by the corner angle
2640
+ // like the strip's, so vertices split apart at one place smooth back together and a mesh can go flat and
2641
+ // smooth again; flat ones need a vertex per corner, so the vertices are split up first
2642
+ if (!smooth)
2643
+ {
2644
+ const split = (a)=> this.indices.map(i=> a[i]);
2645
+ this.points = split(this.points), this.normals = split(this.normals);
2646
+ this.uvs = split(this.uvs), this.colors = split(this.colors);
2647
+ this.indices = this.indices.map((_, i)=> i);
2648
+ }
2649
+ const points = this.points, indices = this.indices, sums = new Map;
2650
+ const normals = points.map(()=> RENDER3D_DEFAULT_NORMAL);
2651
+ for (let t = 0; t < indices.length; t += 3)
2652
+ {
2653
+ const a = points[indices[t]], b = points[indices[t+1]], c = points[indices[t+2]];
2654
+ const cross = b.subtract(a).cross(c.subtract(a));
2655
+ if (!cross.lengthSquared()) continue;
2656
+ const normal = cross.normalize();
2657
+ if (!smooth)
2658
+ {
2659
+ // its own three corners
2660
+ normals[indices[t]] = normals[indices[t+1]] = normals[indices[t+2]] = normal;
2661
+ continue;
2662
+ }
2663
+ for (let j = 0; j < 3; ++j)
2664
+ render3DAddCornerNormal(sums, points[indices[t+j]], points[indices[t+(j+1)%3]],
2665
+ points[indices[t+(j+2)%3]], normal);
2666
+ }
2667
+ this.normals = !smooth ? normals : points.map(p=>
2668
+ {
2669
+ const s = sums.get(render3DPlaceKey(p));
2670
+ return s && s.lengthSquared() ? s.normalize() : RENDER3D_DEFAULT_NORMAL;
2671
+ });
2672
+ this.dirty = true;
2673
+ return this;
2674
+ }
2675
+
2676
+ // the outward normal of each triangle in the strip
2677
+ const points = this.points, n = points.length;
2678
+ const faceNormals = [];
2679
+ for (let i = 0; i + 2 < n; ++i)
2680
+ {
2681
+ const a = points[i], b = points[i+1], c = points[i+2];
2682
+ const normal = b.subtract(a).cross(c.subtract(a));
2683
+ // triangles in a strip alternate which way they wind, so every other one is flipped back
2214
2684
  // a zero normal means a flat triangle joining two strips, so skip it
2215
2685
  faceNormals.push(normal.lengthSquared() ? normal.normalize(i & 1 ? 1 : -1) : undefined);
2216
2686
  }
@@ -2222,16 +2692,10 @@ class Mesh
2222
2692
  // add up the face normals meeting at each position, each weighted by its corner angle so a cube
2223
2693
  // corner averages its three faces evenly however the strips cut them, then normalize
2224
2694
  const sums = new Map;
2225
- const key = (p)=> `${round(p.x * 1e5)},${round(p.y * 1e5)},${round(p.z * 1e5)}`;
2226
2695
  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
- }));
2696
+ render3DAddCornerNormal(sums, points[i+j], points[i+(j+1)%3], points[i+(j+2)%3], f)));
2233
2697
  for (let i = 0; i < n; ++i)
2234
- normals[i] = (sums.get(key(points[i])) || RENDER3D_DEFAULT_NORMAL).normalize();
2698
+ normals[i] = (sums.get(render3DPlaceKey(points[i])) || RENDER3D_DEFAULT_NORMAL).normalize();
2235
2699
  }
2236
2700
  else
2237
2701
  // every triangle writes its own three corners, so the only vertices left with the default
@@ -2239,6 +2703,7 @@ class Mesh
2239
2703
  faceNormals.forEach((f, i)=> f && (normals[i] = normals[i+1] = normals[i+2] = f));
2240
2704
 
2241
2705
  this.normals = normals;
2706
+ this.vertexKeys = undefined; // flat normals tell entries at one place apart
2242
2707
  this.dirty = true;
2243
2708
  return this;
2244
2709
  }
@@ -2248,29 +2713,123 @@ class Mesh
2248
2713
  upload()
2249
2714
  {
2250
2715
  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)
2716
+ if (!render3D?.program || !glContext) return this;
2717
+ const gl = glContext, layout = this.vertexLayout;
2718
+ // no layout when dynamicDraw was turned on after an upload, the next upload makes one
2719
+ if (this.dynamicDraw && layout && this.buffer && this.contextGeneration === render3D.contextGeneration)
2257
2720
  {
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());
2721
+ // the layout of the last upload stands, only the values are written again into the buffer it has,
2722
+ // packed into the same memory each time so a mesh uploaded every frame makes no garbage
2723
+ ASSERT(layout.pointCount === this.points.length,
2724
+ 'a dynamicDraw mesh keeps its shape, the same points in the same order; ' +
2725
+ 'for a new shape make a new mesh or turn dynamicDraw off', this.points.length);
2726
+ if (layout.pointCount === this.points.length)
2727
+ {
2728
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
2729
+ gl.bufferSubData(gl.ARRAY_BUFFER, 0, render3DMeshVertexData(this, layout.vertices, layout.data));
2730
+ gl.bindBuffer(gl.ARRAY_BUFFER, glArrayBuffer);
2731
+ this.dirty = false;
2732
+ return this;
2733
+ }
2261
2734
  }
2262
- const gl = glContext;
2735
+ this.dispose();
2736
+ const {vertices, indices} = this.getTriangles(), count = vertices.length, wide = count > 65535;
2737
+ const data = render3DMeshVertexData(this, vertices);
2738
+ this.vertexLayout = this.dynamicDraw ? {vertices, pointCount: this.points.length, data} : undefined;
2739
+ this.vertexKeys = undefined; // used once: an edit after this may tell entries apart
2263
2740
  this.buffer = gl.createBuffer();
2264
- this.bufferCount = count;
2741
+ this.indexBuffer = gl.createBuffer();
2742
+ this.bufferCount = indices.length;
2743
+ this.indexType = wide ? gl.UNSIGNED_INT : gl.UNSIGNED_SHORT;
2265
2744
  this.dirty = false;
2266
2745
  gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
2267
- gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
2746
+ gl.bufferData(gl.ARRAY_BUFFER, data, this.dynamicDraw ? gl.DYNAMIC_DRAW : gl.STATIC_DRAW);
2747
+ gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer);
2748
+ gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, wide ? new Uint32Array(indices) : new Uint16Array(indices), gl.STATIC_DRAW);
2268
2749
  this.contextGeneration = render3D.contextGeneration;
2269
- render3DMeshBuffers?.register(this, {buffer: this.buffer, generation: this.contextGeneration}, this);
2750
+ const buffers = {buffer: this.buffer, indexBuffer: this.indexBuffer, generation: this.contextGeneration};
2751
+ render3DMeshBuffers?.register(this, buffers, this);
2270
2752
  gl.bindBuffer(gl.ARRAY_BUFFER, glArrayBuffer); // the engine's 2D batch writes through this binding
2271
2753
  return this;
2272
2754
  }
2273
2755
 
2756
+ /** The mesh as an indexed triangle list, what upload sends to the GPU: the strip's real triangles over its
2757
+ * distinct vertices, the joins between its pieces dropped and every triangle facing the way it did in the strip
2758
+ * - Vertices are compared to a millionth, so two at one place with the same normal, uv and color are one
2759
+ * @return {{vertices: Array<number>, indices: Array<number>}} - vertices are strip indices, one per distinct
2760
+ * vertex; indices are the triangles, three per triangle, into vertices */
2761
+ getTriangles()
2762
+ {
2763
+ const count = this.points.length, vertices = [];
2764
+ if (this.indices)
2765
+ {
2766
+ // already a list: every vertex as it is, and the triangles read the way the pass draws, clockwise
2767
+ for (let i = 0; i < count; ++i)
2768
+ vertices.push(i);
2769
+ return {vertices, indices: render3DFlipTriangles(this.indices.slice())};
2770
+ }
2771
+ const remap = new Int32Array(count), place = new Int32Array(count), keys = this.vertexKeys;
2772
+ if (keys && keys.length === count)
2773
+ {
2774
+ // a builder said which entries are one vertex: the first entry with a key stands for every entry with it,
2775
+ // and is their place, so nothing is searched
2776
+ const first = new Int32Array(count).fill(-1);
2777
+ for (let i = 0; i < count; ++i)
2778
+ {
2779
+ const key = keys[i];
2780
+ ASSERT(key >= 0 && key < count, 'vertexKeys must be whole numbers below the entry count', key);
2781
+ const j = first[key];
2782
+ if (j < 0)
2783
+ first[key] = i, remap[i] = vertices.length, vertices.push(i);
2784
+ else
2785
+ remap[i] = remap[j];
2786
+ place[i] = first[key];
2787
+ }
2788
+ return {vertices, indices: render3DStripTriangles(count, remap, place)};
2789
+ }
2790
+
2791
+ // each vertex as nine whole numbers, its values in millionths and its color, so vertices hash and compare as
2792
+ // numbers; a hash table over all nine finds the distinct vertices and one over the first three the places,
2793
+ // each slot holding a vertex index plus one and a taken slot moving on to the next
2794
+ const values = new Float64Array(count * 9);
2795
+ let size = 1;
2796
+ while (size < count * 2) size *= 2;
2797
+ const mask = size - 1, seen = new Int32Array(size), places = new Int32Array(size);
2798
+ const mix = (h, v)=> Math.imul(h ^ v, 0x9e3779b1) >>> 0;
2799
+ for (let i = 0; i < count; ++i)
2800
+ {
2801
+ const p = this.points[i], n = this.normals[i] || RENDER3D_DEFAULT_NORMAL;
2802
+ const uv = this.uvs[i] || RENDER3D_DEFAULT_UV, k = i * 9;
2803
+ const x = values[k] = round(p.x * 1e6), y = values[k+1] = round(p.y * 1e6), z = values[k+2] = round(p.z * 1e6);
2804
+ values[k+3] = round(n.x * 1e6), values[k+4] = round(n.y * 1e6), values[k+5] = round(n.z * 1e6);
2805
+ values[k+6] = round(uv.x * 1e6), values[k+7] = round(uv.y * 1e6);
2806
+ values[k+8] = (this.colors[i] || WHITE).rgbaInt();
2807
+ let h = mix(mix(mix(0x811c9dc5, x), y), z);
2808
+
2809
+ // the place, shared with a vertex there that has another normal or uv, as at a lathe's poles
2810
+ for (let slot = h & mask;; slot = (slot + 1) & mask)
2811
+ {
2812
+ const o = places[slot] - 1;
2813
+ if (o < 0) { places[slot] = i + 1, place[i] = i; break; }
2814
+ if (values[o*9] === x && values[o*9+1] === y && values[o*9+2] === z) { place[i] = o; break; }
2815
+ }
2816
+
2817
+ // the whole vertex
2818
+ for (let m = 3; m < 9; ++m)
2819
+ h = mix(h, values[k+m]);
2820
+ for (let slot = h & mask;; slot = (slot + 1) & mask)
2821
+ {
2822
+ const o = seen[slot] - 1;
2823
+ if (o < 0) { seen[slot] = i + 1, remap[i] = vertices.length, vertices.push(i); break; }
2824
+ let same = true;
2825
+ for (let m = 0; same && m < 9; ++m)
2826
+ same = values[o*9+m] === values[k+m];
2827
+ if (same) { remap[i] = remap[o]; break; }
2828
+ }
2829
+ }
2830
+ return {vertices, indices: render3DStripTriangles(count, remap, place)};
2831
+ }
2832
+
2274
2833
  /** Draw the mesh with the current draw state, batched with its other uses in the opaque stage
2275
2834
  * @param {Matrix4|Vector3} [matrix] - Object transform, or just a position to draw it at
2276
2835
  * @param {TileInfo|TextureInfo} [tileInfo] - Texture, mesh uvs map across the tile or the whole texture
@@ -2285,8 +2844,8 @@ class Mesh
2285
2844
  render3DMeshBuffers?.unregister(this); // freed here, so not again when the mesh is collected
2286
2845
  // a buffer from a context that was lost is gone with it, and the new context refuses to delete it
2287
2846
  if (this.contextGeneration === render3D?.contextGeneration)
2288
- glContext?.deleteBuffer(this.buffer);
2289
- this.buffer = undefined;
2847
+ glContext?.deleteBuffer(this.buffer), glContext?.deleteBuffer(this.indexBuffer);
2848
+ this.buffer = this.indexBuffer = undefined;
2290
2849
  this.bufferCount = 0;
2291
2850
  }
2292
2851
  }
@@ -2300,6 +2859,8 @@ class Mesh
2300
2859
  * - profile is [[radius, y], ...] from bottom to top
2301
2860
  * - A profile that ends where it starts makes a closed ring like a donut
2302
2861
  * - An end left open, with a radius and no cap, makes the mesh doubleSided so its inside shows
2862
+ * - An end on the axis smooth shades as a round pole like a sphere's when its segment is within 45 degrees of
2863
+ * level, and as a point like a cone's tip when it is steeper
2303
2864
  * @param {Array<Array<number>>} profile
2304
2865
  * @param {number} [sides] - Around the axis
2305
2866
  * @param {boolean} [smooth] - Defaults to render3D.smoothShading
@@ -2327,13 +2888,19 @@ function buildLathe(profile, sides=16, smooth=render3D?.smoothShading, capped=tr
2327
2888
  };
2328
2889
  // vertex normal: average of the adjacent segment normals, across the seam when the profile is closed,
2329
2890
  // 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;
2891
+ const closed = rings > 2 && abs(profile[0][0] - profile[rings-1][0]) < 1e-9 &&
2892
+ abs(profile[0][1] - profile[rings-1][1]) < 1e-9;
2331
2893
  const segmentLength = (i)=> hypot(profile[i+1][0] - profile[i][0], profile[i+1][1] - profile[i][1]);
2332
2894
  const vertexNormal = (i)=>
2333
2895
  {
2334
- // an open end on the axis is a pole and points along it
2896
+ // an open end on the axis is a pole and points along it, like a sphere's, when the surface there is
2897
+ // within 45 degrees of level; a steeper one is a point like a cone's tip and takes its side's normal
2335
2898
  if (!closed && (!i || i == rings - 1) && abs(profile[i][0]) < 1e-9)
2336
- return vec2(0, i ? 1 : -1);
2899
+ {
2900
+ const up = i ? 1 : -1;
2901
+ if (segmentNormal(i ? i - 1 : 0).y * up > Math.SQRT1_2 - 1e-9)
2902
+ return vec2(0, up);
2903
+ }
2337
2904
  // otherwise the neighbors weighted by their length, so a short band does not tilt a long wall
2338
2905
  let n = vec2();
2339
2906
  const add = (s)=> n = n.add(segmentNormal(s).scale(segmentLength(s)));
@@ -2359,11 +2926,15 @@ function buildLathe(profile, sides=16, smooth=render3D?.smoothShading, capped=tr
2359
2926
  // one ribbon around the ring pair, top point then bottom point per column
2360
2927
  const points = [], normals = [], uvs = [];
2361
2928
  const n0 = vertexNormal(i), n1 = vertexNormal(i + 1);
2929
+ // a point on the axis is one per column, each drawn by the face beside it, so its normal turns half
2930
+ // a side toward the middle of that face; a pole's points along the axis and does not turn
2931
+ const half = PI / sides;
2932
+ const turn1 = abs(profile[i + 1][0]) < 1e-9 ? -half : 0, turn0 = abs(profile[i][0]) < 1e-9 ? half : 0;
2362
2933
  for (let j = 0; j <= sides; ++j)
2363
2934
  {
2364
2935
  const a = j / sides * 2 * PI, u = j / sides;
2365
2936
  points.push(point(i + 1, a), point(i, a));
2366
- normals.push(normal3D(n1, a), normal3D(n0, a));
2937
+ normals.push(normal3D(n1, a + turn1), normal3D(n0, a + turn0));
2367
2938
  uvs.push(vec2(u, v(i + 1)), vec2(u, v(i)));
2368
2939
  }
2369
2940
  mesh.addStrip(points, normals, uvs);
@@ -2386,7 +2957,7 @@ function buildLathe(profile, sides=16, smooth=render3D?.smoothShading, capped=tr
2386
2957
 
2387
2958
  // flat discs close the ends that have a radius, a hard edge even when the sides are smooth
2388
2959
  if (capped && !closed)
2389
- for (const [i, up] of [[0, false], [rings - 1, true]])
2960
+ for (const [i, up] of /** @type {Array<[number, boolean]>} */ ([[0, false], [rings - 1, true]]))
2390
2961
  {
2391
2962
  if (abs(profile[i][0]) < 1e-9) continue; // a pole has no cap
2392
2963
  const points = [], uvs = [];
@@ -2404,36 +2975,6 @@ function buildLathe(profile, sides=16, smooth=render3D?.smoothShading, capped=tr
2404
2975
  return mesh;
2405
2976
  }
2406
2977
 
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
2978
  /**
2438
2979
  * Build a sphere centered on the origin
2439
2980
  * @param {number} [size] - Diameter
@@ -2455,65 +2996,94 @@ function buildSphere(size=1, sides=16, rings=8, smooth=render3D?.smoothShading)
2455
2996
  return buildLathe(profile, sides, smooth);
2456
2997
  }
2457
2998
 
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)
2999
+ // a box with its edges cut by a bevel of size t: each face shrunk by t, a strip along each edge around a quarter
3000
+ // circle, and an eighth of a sphere at each corner; one segment makes them flat, a chamfer
3001
+ function render3DBevelBox(half, t, segments, faces)
3002
+ {
3003
+ const mesh = new Mesh, inner = half.subtract(vec3(t)), n = max(1, segments | 0);
3004
+
3005
+ // a triangle facing out, since the box is convex around the origin, flat shaded or with the normals of the
3006
+ // round, and uvs from the face it faces most, as a plain box maps that face
3007
+ const triangle = (a, b, c, na, nb, nc)=>
3008
+ {
3009
+ let normal = b.subtract(a).cross(c.subtract(a));
3010
+ if (normal.lengthSquared() < 1e-20) return; // an edge or face with no size
3011
+ if (normal.dot(a.add(b).add(c)) < 0)
3012
+ [b, c, nb, nc, normal] = [c, b, nc, nb, normal.scale(-1)];
3013
+ normal = normal.normalize();
3014
+ let face = faces[0];
3015
+ for (const f of faces)
3016
+ if (f[0].dot(normal) > face[0].dot(normal))
3017
+ face = f;
3018
+ const [, r, u] = face, hr = abs(half.dot(r)), hu = abs(half.dot(u));
3019
+ const uv = (p)=> vec2(.5 + p.dot(r) / (2 * hr), .5 - p.dot(u) / (2 * hu));
3020
+ mesh.addStrip([a, b, c], n > 1 && na ? [na, nb, nc] : normal, [uv(a), uv(b), uv(c)]);
3021
+ };
3022
+
3023
+ // the faces, shrunk by the bevel
3024
+ for (const [normal, r, u] of faces)
2474
3025
  {
2475
- const a = i / rings * PI / 2;
2476
- profile.push([r * sin(a), -straight - r * cos(a)]);
3026
+ const [a, b, c, d] = render3DQuadAxes(normal.multiply(half), r.multiply(inner), u.multiply(inner));
3027
+ triangle(a, b, c);
3028
+ triangle(c, b, d);
2477
3029
  }
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)]);
3030
+
3031
+ // the edges, each a strip around a quarter circle from one face to the next
3032
+ const axes = [vec3(1, 0, 0), vec3(0, 1, 0), vec3(0, 0, 1)];
3033
+ for (let k = 0; k < 3; ++k)
3034
+ for (const si of [-1, 1])
3035
+ for (const sj of [-1, 1])
3036
+ {
3037
+ const n1 = axes[(k + 1) % 3].scale(si), n2 = axes[(k + 2) % 3].scale(sj);
3038
+ const middle = n1.multiply(inner).add(n2.multiply(inner)), along = axes[k].multiply(inner);
3039
+ const start = middle.subtract(along), end = middle.add(along);
3040
+ const dir = (m)=> n1.scale(cos(m / n * PI / 2)).add(n2.scale(sin(m / n * PI / 2)));
3041
+ for (let m = 0; m < n; ++m)
3042
+ {
3043
+ const d0 = dir(m), d1 = dir(m + 1);
3044
+ const a = start.add(d0.scale(t)), b = end.add(d0.scale(t));
3045
+ const c = start.add(d1.scale(t)), d = end.add(d1.scale(t));
3046
+ triangle(a, b, c, d0, d0, d1);
3047
+ triangle(c, b, d, d1, d0, d1);
3048
+ }
2482
3049
  }
2483
- return buildLathe(profile, sides, smooth);
2484
- }
2485
3050
 
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)
3051
+ // the corners, an eighth of a sphere in rows from one face's axis down to the edge between the other two
3052
+ for (const sx of [-1, 1])
3053
+ for (const sy of [-1, 1])
3054
+ for (const sz of [-1, 1])
2501
3055
  {
2502
- const a = i / tubeSides * 2 * PI;
2503
- profile.push([radius + tubeRadius * cos(a), tubeRadius * sin(a)]);
3056
+ const nx = vec3(sx, 0, 0), ny = vec3(0, sy, 0), nz = vec3(0, 0, sz), corner = inner.multiply(vec3(sx, sy, sz));
3057
+ const dir = (i, j)=>
3058
+ {
3059
+ const polar = i / n * PI / 2, around = i ? j / i * PI / 2 : 0;
3060
+ return nz.scale(cos(polar)).add(nx.scale(cos(around) * sin(polar))).add(ny.scale(sin(around) * sin(polar)));
3061
+ };
3062
+ const point = (i, j)=> corner.add(dir(i, j).scale(t));
3063
+ for (let i = 0; i < n; ++i)
3064
+ {
3065
+ for (let j = 0; j <= i; ++j)
3066
+ triangle(point(i, j), point(i + 1, j), point(i + 1, j + 1), dir(i, j), dir(i + 1, j), dir(i + 1, j + 1));
3067
+ for (let j = 0; j < i; ++j)
3068
+ triangle(point(i, j), point(i + 1, j + 1), point(i, j + 1), dir(i, j), dir(i + 1, j + 1), dir(i, j + 1));
3069
+ }
2504
3070
  }
2505
- return buildLathe(profile, sides, smooth);
3071
+ return mesh;
2506
3072
  }
2507
3073
 
2508
3074
  /**
2509
3075
  * Build a box centered on the origin, six flat faces with uvs covering each face
3076
+ * - bevel cuts its edges and corners: 1 segment is a flat chamfer, more round them, and the biggest bevel, half
3077
+ * the smallest side, rounds a cube into a ball
2510
3078
  * @param {Vector3|number} [size] - Full size, a number for a cube
3079
+ * @param {number} [bevel] - Size of the cut on each edge, clamped to half the smallest side
3080
+ * @param {number} [bevelSegments] - Steps around each edge, 1 for a flat chamfer
2511
3081
  * @return {Mesh}
2512
3082
  * @memberof Render3D
2513
3083
  */
2514
- function buildBox(size=1)
3084
+ function buildBox(size=1, bevel=0, bevelSegments=1)
2515
3085
  {
2516
- const mesh = new Mesh;
3086
+ ASSERT(isNumber(bevel) && bevel >= 0, 'bevel must be a number, 0 or more');
2517
3087
  const half = render3DSize3(size).scale(.5);
2518
3088
  // each face: normal, right axis, up axis (right cross up = normal)
2519
3089
  const faces = [
@@ -2524,6 +3094,10 @@ function buildBox(size=1)
2524
3094
  [vec3(0, 1, 0), vec3(1, 0, 0), vec3(0, 0, -1)],
2525
3095
  [vec3(0, -1, 0), vec3(1, 0, 0), vec3(0, 0, 1)],
2526
3096
  ];
3097
+ const t = min(bevel, half.x, half.y, half.z);
3098
+ if (t > 0)
3099
+ return render3DBevelBox(half, t, bevelSegments, faces);
3100
+ const mesh = new Mesh;
2527
3101
  for (const [n, r, u] of faces)
2528
3102
  {
2529
3103
  const center = n.multiply(half);
@@ -2533,87 +3107,66 @@ function buildBox(size=1)
2533
3107
  return mesh;
2534
3108
  }
2535
3109
 
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
3110
  /**
2577
3111
  * Build a heightfield grid in the XZ plane centered on the origin
2578
3112
  * - smooth rounds the lighting across cells and colors each corner
2579
3113
  * - flat lights and colors each cell on its own, so a checkerboard stays crisp
2580
3114
  * - doubleSided, a sheet seen from both sides; turn it off for ground only ever seen from above
2581
3115
  * - One cell is a plain square, render3D.planeMesh and planeMeshDoubleSided are shared ones
2582
- * @param {Vector2} [size] - World size along X and Z
3116
+ * @param {Vector2|number} [size] - World size along X and Z, a number for a square
2583
3117
  * @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
3118
+ * @param {Color|function(number, number): Color} [color] - One Color for the whole grid, or (x, z) => Color
3119
+ * @param {function(number, number): number} [heightFunction] - (x, z) => y, default flat
3120
+ * @param {boolean} [smooth] - Defaults to render3D.smoothShading with a heightFunction; a flat grid is flat shaded,
3121
+ * since its light is even anyway and smoothing would only blend its cell colors; pass smooth to blend a gradient
2587
3122
  * @return {Mesh}
2588
3123
  * @memberof Render3D
2589
3124
  * @example
2590
3125
  * const ground = buildGrid(vec2(20), 10, (x, z)=> (floor(x / 2) + floor(z / 2)) & 1 ? GRAY : WHITE); // 2 unit checks
2591
3126
  */
2592
- function buildGrid(size=vec2(1), segments=1, color, heightFunction=()=>0, smooth=render3D?.smoothShading)
3127
+ function buildGrid(size=vec2(1), segments=1, color, heightFunction, smooth=heightFunction && render3D?.smoothShading)
2593
3128
  {
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');
3129
+ heightFunction ||= ()=> 0;
3130
+ size = render3DSize2(size);
3131
+ segments = render3DSize2(segments);
3132
+ ASSERT(segments.x > 0 && segments.y > 0 && segments.x % 1 === 0 && segments.y % 1 === 0,
3133
+ 'grid segments must be whole numbers above zero');
2597
3134
  const mesh = new Mesh;
2598
3135
  const segmentsX = segments.x, segmentsZ = segments.y;
2599
3136
  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));
3137
+ const halfX = size.x / 2, halfZ = size.y / 2, ex = cellX / 2, ez = cellZ / 2;
3138
+ const px = (i)=> i * cellX - halfX, pz = (j)=> j * cellZ - halfZ;
3139
+ const colorAt = /** @type {function(number, number): Color} */ (color);
3140
+ const cellColor = (i, j)=> !color ? WHITE : isColor(color) ? /** @type {Color} */ (color) : colorAt(px(i), pz(j));
3141
+ // a big terrain has millions of vertices, so each one is made once, shared by the rows above and below it
3142
+ const row = (j)=>
3143
+ {
3144
+ const points = [], normals = [], uvs = [], colors = [], z = pz(j);
3145
+ for (let i = 0; i <= segmentsX; ++i)
3146
+ {
3147
+ const x = px(i);
3148
+ points.push(vec3(x, heightFunction(x, z), z));
3149
+ uvs.push(vec2(i / segmentsX, j / segmentsZ));
3150
+ if (!smooth) continue;
3151
+ normals.push(render3DSlopeNormal(heightFunction, x, z, ex, ez, halfX, halfZ));
3152
+ colors.push(cellColor(i, j));
3153
+ }
3154
+ return {points, normals, uvs, colors};
3155
+ };
3156
+ let above = row(0);
2605
3157
  for (let j = 0; j < segmentsZ; ++j)
2606
3158
  {
3159
+ const below = row(j + 1);
2607
3160
  if (smooth)
2608
3161
  {
2609
3162
  // one ribbon per row with vertex normals from the slope
2610
3163
  const points = [], normals = [], uvs = [], colors = [];
2611
3164
  for (let i = 0; i <= segmentsX; ++i)
2612
3165
  {
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));
3166
+ points.push(above.points[i], below.points[i]);
3167
+ normals.push(above.normals[i], below.normals[i]);
3168
+ uvs.push(above.uvs[i], below.uvs[i]);
3169
+ colors.push(above.colors[i], below.colors[i]);
2617
3170
  }
2618
3171
  mesh.addStrip(points, normals, uvs, colors);
2619
3172
  }
@@ -2621,52 +3174,37 @@ function buildGrid(size=vec2(1), segments=1, color, heightFunction=()=>0, smooth
2621
3174
  {
2622
3175
  // one quad per cell with its face normal and one color sampled at its center
2623
3176
  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)]);
3177
+ mesh.addQuad(above.points[i], below.points[i], below.points[i+1], above.points[i+1], cellColor(i + .5, j + .5),
3178
+ [above.uvs[i], below.uvs[i], below.uvs[i+1], above.uvs[i+1]]);
3179
+ }
3180
+ above = below;
3181
+ }
3182
+ if (smooth)
3183
+ {
3184
+ // the grid knows which strip entries are one vertex, each shared by the rows above and below it and by the
3185
+ // repeats at the ends of its ribbons, so the upload of a big terrain skips searching millions of entries
3186
+ const n = 2 * (segmentsX + 1) + 2, keys = mesh.vertexKeys = new Int32Array(mesh.points.length);
3187
+ const id = (i, j)=> j * (segmentsX + 1) + i;
3188
+ for (let j = 0; j < segmentsZ; ++j)
3189
+ {
3190
+ const start = j * n;
3191
+ keys[start] = id(0, j); // the leading repeat
3192
+ for (let i = 0; i <= segmentsX; ++i)
3193
+ keys[start + 1 + 2*i] = id(i, j), keys[start + 2 + 2*i] = id(i, j + 1);
3194
+ keys[start + n - 1] = id(segmentsX, j + 1); // the trailing repeat
2626
3195
  }
2627
3196
  }
2628
3197
  mesh.doubleSided = true; // a sheet, seen from both sides; terrain seen only from above can turn it off
2629
3198
  return mesh;
2630
3199
  }
2631
3200
 
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
3201
  /**
2665
3202
  * Build a sky dome: a sphere colored by direction, wound to be seen from inside
2666
3203
  * - set it as render3D.sky and the pass draws it around the camera behind everything
2667
3204
  * @param {Color} [topColor] - Straight up
2668
3205
  * @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
3206
+ * @param {Color} [bottomColor] - Straight down, what a camera looking at the ground sees past its edge; defaults to the
3207
+ * horizon color
2670
3208
  * @param {number} [sides] - Around
2671
3209
  * @param {number} [rings] - Top to bottom
2672
3210
  * @return {Mesh}
@@ -2674,370 +3212,12 @@ function buildLoft(stations)
2674
3212
  */
2675
3213
  function buildSky(topColor=hsl(.6, .8, .55), horizonColor=hsl(.6, 1, .9), bottomColor=horizonColor, sides=16, rings=8)
2676
3214
  {
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
- }
3215
+ // a sphere turned inside out so it is seen from within, each point colored by how high it is
3216
+ const mesh = buildSphere(2, sides, rings, true).flipNormals();
3217
+ mesh.colors = mesh.points.map(p=> p.y < 0 ? horizonColor.lerp(bottomColor, -p.y) : horizonColor.lerp(topColor, p.y));
2700
3218
  return mesh;
2701
3219
  }
2702
3220
 
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
3221
  ///////////////////////////////////////////////////////////////////////////////
3042
3222
  /**
3043
3223
  * EngineObject3D - An EngineObject with a 3D transform and a mesh
@@ -3052,10 +3232,13 @@ function render3DReadPixels(textureInfo)
3052
3232
  * - Set sync2D for a 2D game with 3D looks, pos and angle then drive pos3D and rotation3D,
3053
3233
  * which is the one way those 2D fields reach a 3D object
3054
3234
  * - setCollision takes the same flags as in 2D, but the solid collision happens in 3D against size3D
3235
+ * - The solid box is axis aligned in the world, rotation3D is ignored like angle is in 2D, so give a turned wall a
3236
+ * size3D along the world axes
3055
3237
  * - Its tile and raycast halves are 2D only so they default off here, and a child sits solid collision out
3056
3238
  * - A sync2D object collides in 2D instead, which needs the 2D size set as well as size3D
3057
3239
  * - 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
3240
+ * - addChild attaches the 3D transform, and pos3D becomes an offset from the parent; attach keeps the child where
3241
+ * it is and works the offset out, and removeChild leaves it where it was in the world
3059
3242
  * - The 2D offset arguments of addChild do nothing here, set the child's pos3D
3060
3243
  * @extends EngineObject
3061
3244
  * @memberof Render3D
@@ -3071,65 +3254,90 @@ class EngineObject3D extends EngineObject
3071
3254
  /** Create a 3D object and add it to the object list
3072
3255
  * @param {Vector3} [pos3D] - World space position
3073
3256
  * @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
3257
+ * @param {TileInfo|TextureInfo} [tileInfo] - Texture, mesh uvs map across the tile; a whole TextureInfo becomes
3258
+ * the tile that covers it
3075
3259
  * @param {Color} [color] - Tint */
3076
3260
  constructor(pos3D=vec3(), mesh, tileInfo, color=WHITE)
3077
3261
  {
3078
- ASSERT(!tileInfo || tileInfo instanceof TileInfo || tileInfo instanceof TextureInfo, 'tileInfo must be a TileInfo or TextureInfo, it comes before color');
3262
+ ASSERT(!tileInfo || tileInfo instanceof TileInfo || tileInfo instanceof TextureInfo,
3263
+ 'tileInfo must be a TileInfo or TextureInfo, it comes before color');
3079
3264
  // a whole texture is stored as the tile that covers it, with no padding or bleed to trim
3080
3265
  // the edges, so this is always a TileInfo like the 2D one and the object stays an EngineObject
3081
3266
  if (tileInfo instanceof TextureInfo)
3082
- tileInfo = new TileInfo(vec2(), tileInfo.size, tileInfo, 0, 0);
3083
- super(vec2(), vec2(), tileInfo, 0, color);
3267
+ render3DWholeTiles.add(tileInfo = new TileInfo(vec2(), tileInfo.size, tileInfo, 0, 0));
3268
+ // the 2D pos starts where the object is, so turning on sync2D keeps it there
3269
+ super(vec2(pos3D.x, pos3D.y), vec2(), tileInfo, 0, color);
3084
3270
  ASSERT(isVector3(pos3D), 'pos3D must be a vec3');
3085
3271
  ASSERT(!mesh || mesh instanceof Mesh, 'mesh must be a Mesh or undefined');
3086
3272
  this.mass = 0; // static: no 2D physics, and no 3D gravity until a mass is set
3087
3273
 
3088
3274
  /** @property {Vector3} - World space position, local to the parent when attached to an EngineObject3D */
3089
3275
  this.pos3D = pos3D.copy();
3090
- /** @property {Vector3} - Rotation vec3(pitch, yaw, roll) in radians, local to the parent when attached to an EngineObject3D */
3276
+ /** @property {Vector3} - Rotation vec3(pitch, yaw, roll) in radians, local to the parent when attached to an
3277
+ * EngineObject3D */
3091
3278
  this.rotation3D = vec3();
3092
3279
  /** @property {Vector3} - Scale, local to the parent when attached to an EngineObject3D */
3093
3280
  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 */
3281
+ /** @property {Vector3} - Added to pos3D each frame by the engine before update, like the 2D velocity, no super
3282
+ * call needed; damping and render3D.gravity act on it once the object has a mass */
3096
3283
  this.velocity3D = vec3();
3097
3284
  /** @property {Vector3} - Added to rotation3D each frame by the engine before update, angleDamping is 2D only */
3098
3285
  this.angleVelocity3D = vec3();
3099
3286
  /** @property {Mesh|undefined} - Mesh to draw
3100
3287
  * @type {Mesh|undefined} */
3101
3288
  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 */
3289
+ /** @property {Vector3} - Size for solid collision and the collect and callback helpers, and of the sprite when
3290
+ * there is a tileInfo and no mesh; starts at the size of the mesh's box, or 1 with no mesh, and setMesh leaves
3291
+ * it as it is; the box is centered on pos3D, so center() a mesh whose origin is not its middle, like a model
3292
+ * standing on its feet, or set size3D; scale3D and any parent's scale grow it, so drawing and picking agree */
3293
+ // a shared mesh measured since it last changed is not walked again for each object made from it
3294
+ const bounds = mesh && mesh.points.length ? !mesh.dirty && mesh.bounds || mesh.getBounds() : undefined;
3295
+ this.size3D = bounds ? bounds.max.subtract(bounds.min) : vec3(1);
3296
+ /** @property {number} - Diameter of a soft shadow drawn under the object on render3D.softShadowHeight, 0 for
3297
+ * none; scale3D and a parent's scale grow it, so set it once for the unscaled object */
3107
3298
  this.softShadow = 0;
3108
3299
  /** @property {boolean} - A sprite stands on world up instead of tilting toward the camera */
3109
3300
  this.upright = false;
3110
3301
  /** @property {boolean} - Keep this object's texture pixels hard edged, for pixel art that should not blur or bleed */
3111
3302
  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 */
3303
+ /** @property {boolean} - Copy the 2D pos and angle into pos3D and rotation3D each frame, for 2D games with 3D
3304
+ * looks; set mass to use 2D physics, and pos3D.z stays yours to set or move with velocity3D.z */
3114
3305
  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 */
3306
+ /** @property {boolean} - Draw in the transparent stage, blended and sorted far to near with depth writes off;
3307
+ * on for a sprite */
3116
3308
  this.transparent = !mesh && !!tileInfo;
3117
3309
  /** @property {boolean} - Additive blending, in the transparent stage */
3118
3310
  this.additive = false;
3119
3311
  /** @property {number} - How much it lights itself: 0 is lit as normal, 1 is its own color with no shading, for
3120
3312
  * lamps and glowing things, between is partly self lit, and above 1 is brighter than its color, for bloom */
3121
3313
  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 */
3314
+ /** @property {number} - Strength of the highlight where the sun and the Light3D objects reflect, 0 is none and
3315
+ * 1 adds a light's full color at its brightest; its size is fixed */
3123
3316
  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 */
3317
+ /** @property {boolean} - Draw into the shadow map when render3D.shadows is on; sprites and cut out textures
3318
+ * cast their outline, an object faded below half its alpha casts nothing, a see through one casts only when
3319
+ * textured, and additive objects never cast */
3125
3320
  this.castShadow = true;
3126
- /** @property {boolean} - Collide as the sphere that fits size3D instead of as the size3D box, so it rolls around corners */
3321
+ /** @property {boolean} - Collide as the sphere that fits size3D instead of as the size3D box, so it rolls
3322
+ * around corners */
3127
3323
  this.collideAsSphere3D = false;
3128
3324
  /** @property {boolean} - Darkened by the shadow map when render3D.shadows is on */
3129
3325
  this.receiveShadow = true;
3130
3326
  /** @property {boolean|undefined} - Draw this object over the 2D scene, undefined uses render3D.renderAfter2D
3131
3327
  * @type {boolean|undefined} */
3132
3328
  this.renderAfter2D = undefined;
3329
+ /** @property {Matrix4|undefined} - The transform from its parent, used in place of pos3D, rotation3D and
3330
+ * scale3D when set, for one they cannot hold like a glTF pose with shear; read every frame it is set
3331
+ * @type {Matrix4|undefined} */
3332
+ this.localMatrix = undefined;
3333
+ // the world transform, kept up to date by render3DObjectMatrix; getMatrix returns a copy
3334
+ this.worldMatrix = new Matrix4;
3335
+ this.matrixBuilt = new Float64Array(9).fill(NaN); // the position, rotation and scale it was built from
3336
+ this.matrixVersion = 0; // counts the rebuilds, so a child knows when its parent's changed
3337
+ /** @type {EngineObject3D|undefined} */
3338
+ this.matrixParent = undefined; // the parent it was built under, and that parent's version then
3339
+ this.matrixParentVersion = 0;
3340
+ this.movePass = engineObjectsUpdateCount; // the engine pass a child last moved in, so a refresh is not a step
3133
3341
  }
3134
3342
 
3135
3343
  /** Move by the 3D velocities and push out of solids, called automatically each frame before update, like the 2D physics
@@ -3143,60 +3351,80 @@ class EngineObject3D extends EngineObject
3143
3351
  'a sync2D object collides in 2D, so give it a 2D size as well as a size3D', this.size);
3144
3352
  if (this.sync2D)
3145
3353
  super.updatePhysics();
3354
+ // a moving object keeps out of the level, the height maps and voxel maps, from where it was before it moved
3355
+ const oldPos = this.collideLevel && this.mass && !this.sync2D ? this.pos3D.copy() : undefined;
3146
3356
  render3DMove(this);
3357
+ oldPos && render3DCollideLevel(this, oldPos);
3147
3358
  // the engine only runs this for objects that own where they are, a child rides along with its parent
3148
3359
  if (this.collideSolidObjects && !this.sync2D)
3149
3360
  render3DCollideSolid(this);
3150
3361
  }
3151
3362
 
3152
3363
  /** 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()
3364
+ * called automatically each frame
3365
+ * @param {boolean} [updateChildren] - Also update the children's transforms */
3366
+ updateTransforms(updateChildren=true)
3155
3367
  {
3156
3368
  if (!paused)
3157
3369
  {
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;
3370
+ // a child is never given updatePhysics, so it moves here, as an offset from its parent: once per
3371
+ // engine pass, since addChild, attach and a game bring the transforms up to date too
3372
+ if (this.parent && this.movePass !== engineObjectsUpdateCount)
3373
+ {
3374
+ this.movePass = engineObjectsUpdateCount;
3375
+ render3DMove(this);
3376
+ }
3162
3377
  }
3163
- super.updateTransforms();
3378
+
3379
+ // placed from its parent first, so a sync2D object copies where it is now, then its children from it
3380
+ super.updateTransforms(false);
3381
+ if (!paused && this.sync2D)
3382
+ this.pos3D.x = this.pos.x, this.pos3D.y = this.pos.y, this.rotation3D.z = -this.angle;
3383
+ if (updateChildren)
3384
+ for (const child of this.children)
3385
+ child.updateTransforms();
3164
3386
  }
3165
3387
 
3166
3388
  /** Set how this object collides, the same flags as in 2D
3167
3389
  * - Solid collision happens in 3D here, against size3D boxes or spheres; a child sits it out
3390
+ * - The boxes are axis aligned in the world, rotation3D is ignored
3168
3391
  * - A sync2D object collides in 2D instead, against the 2D size, so set that as well as size3D
3169
3392
  * @param {boolean} [collideSolidObjects] - Take part in solid collision
3170
3393
  * @param {boolean} [isSolid] - Block other objects, a pair where neither one blocks passes through;
3171
3394
  * blocking needs collideSolidObjects, so isSolid on its own is not allowed
3172
- * @param {boolean} [collideTiles] - Tile collision, 2D only so it needs sync2D
3395
+ * @param {boolean} [collideLevel] - Collide with the level, the height maps and voxel maps, or the 2D tile layers
3396
+ * for a sync2D object
3173
3397
  * @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); }
3398
+ setCollision(collideSolidObjects=true, isSolid=true, collideLevel=true, collideRaycast=false)
3399
+ { super.setCollision(collideSolidObjects, isSolid, collideLevel, collideRaycast); }
3400
+
3401
+ /** Called by a VoxelMap to ask whether a block stops this object, a hook to let one through or react to it
3402
+ * @param {number} type - The block's type, 1 to 255
3403
+ * @param {Vector3} cell - The block's cell in the map
3404
+ * @return {boolean} - true to be stopped by it, every block stops it by default */
3405
+ collideWithVoxel(type, cell) { return true; }
3176
3406
 
3177
3407
  /** Returns the world position
3178
3408
  * @return {Vector3} */
3179
- getWorldPos3D() { return this.getMatrix().getTranslation(); }
3409
+ getWorldPos3D() { return render3DObjectMatrix(this).getTranslation(); }
3180
3410
 
3181
3411
  /** Returns the direction the object faces, its -Z axis in the world
3182
3412
  * @return {Vector3} */
3183
- getForward3D() { return render3DAxis(this.getMatrix().m, 8).normalize(-1); }
3413
+ getForward3D() { return render3DAxis(render3DObjectMatrix(this).m, 8).normalize(-1); }
3184
3414
 
3185
3415
  /** Returns the object's right axis in the world
3186
3416
  * @return {Vector3} */
3187
- getRight3D() { return render3DAxis(this.getMatrix().m, 0).normalize(); }
3417
+ getRight3D() { return render3DAxis(render3DObjectMatrix(this).m, 0).normalize(); }
3188
3418
 
3189
3419
  /** Returns the object's up axis in the world
3190
3420
  * @return {Vector3} */
3191
- getUp3D() { return render3DAxis(this.getMatrix().m, 4).normalize(); }
3421
+ getUp3D() { return render3DAxis(render3DObjectMatrix(this).m, 4).normalize(); }
3192
3422
 
3193
- /** Returns the object's world transform, relative to the parent's when attached to an EngineObject3D
3423
+ /** Returns a copy of the object's world transform, the parent's included when attached to an EngineObject3D
3424
+ * - The object keeps its matrix and rebuilds it only when its position, rotation or scale changed, so this is
3425
+ * cheap to call
3194
3426
  * @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
- }
3427
+ getMatrix() { return render3DObjectMatrix(this).copy(); }
3200
3428
 
3201
3429
  /** Turn the object so its -Z axis points at a world space target, sets pitch and yaw and clears roll
3202
3430
  * @param {Vector3} target */
@@ -3208,6 +3436,59 @@ class EngineObject3D extends EngineObject
3208
3436
  this.rotation3D = render3DLookRotation(local.subtract(this.pos3D), this.rotation3D);
3209
3437
  }
3210
3438
 
3439
+ /** Attaches a child, its pos3D, rotation3D and scale3D taken as the offset from this one; returns child for chaining
3440
+ * - The 2D offset arguments do nothing for an EngineObject3D child, set its pos3D
3441
+ * @param {EngineObject} child
3442
+ * @param {Vector2} [localPos]
3443
+ * @param {number} [localAngle]
3444
+ * @return {EngineObject} The child object attached */
3445
+ addChild(child, localPos, localAngle)
3446
+ {
3447
+ // addChild brings the child's transform up to date, which is not a step: it already moved this pass as
3448
+ // a root, or moves in the next one, so it must not move by its velocity again here
3449
+ if (child instanceof EngineObject3D)
3450
+ child.movePass = engineObjectsUpdateCount;
3451
+ return super.addChild(child, localPos, localAngle);
3452
+ }
3453
+
3454
+ /** Attaches a child without moving it: its pos3D, rotation3D and scale3D become what they have to be under this
3455
+ * parent to keep its world transform, where addChild takes them as the offset; returns child for chaining
3456
+ * - A parent scaled unevenly and a child turned under it make a shear, which those three values cannot hold,
3457
+ * so the child comes out as close as they can get; a uniform scale is exact
3458
+ * @param {EngineObject} child
3459
+ * @return {EngineObject} The child object attached */
3460
+ attach(child)
3461
+ {
3462
+ if (!(child instanceof EngineObject3D))
3463
+ return super.attach(child); // a 2D child only has the 2D transform to keep
3464
+ child.parent?.removeChild(child); // keeps its world values, so its own matrix is its world matrix
3465
+ const local = render3DObjectMatrix(this).copy().invert().multiply(render3DObjectMatrix(child));
3466
+ super.attach(child);
3467
+ child.pos3D = local.getTranslation();
3468
+ child.rotation3D = local.getRotation();
3469
+ child.scale3D = local.getScale();
3470
+ if (child.localMatrix)
3471
+ child.localMatrix = local; // a matrix given whole stays whole, shear and all
3472
+ return child;
3473
+ }
3474
+
3475
+ /** Removes a child from this one, it stays where it is in the world: its pos3D, rotation3D and scale3D become
3476
+ * its world values, with the same shear caveat as attach; a child being destroyed is let go as it is
3477
+ * @param {EngineObject} child */
3478
+ removeChild(child)
3479
+ {
3480
+ if (child instanceof EngineObject3D && !child.destroyed)
3481
+ {
3482
+ const world = render3DObjectMatrix(child);
3483
+ child.pos3D = world.getTranslation();
3484
+ child.rotation3D = world.getRotation();
3485
+ child.scale3D = world.getScale();
3486
+ if (child.localMatrix)
3487
+ child.localMatrix = world.copy(); // a matrix given whole stays whole, shear and all
3488
+ }
3489
+ super.removeChild(child);
3490
+ }
3491
+
3211
3492
  /** Draw a different mesh and free the GPU buffer of the one it replaces
3212
3493
  * - For a mesh built again when something changes, like a score, a rebuilt terrain or a loaded model
3213
3494
  * - A mesh another object is still drawing is left alone, since builders are often shared
@@ -3220,7 +3501,7 @@ class EngineObject3D extends EngineObject
3220
3501
  const old = this.mesh;
3221
3502
  this.mesh = mesh;
3222
3503
  // 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))
3504
+ if (old && old !== mesh && old.buffer && !engineObjects.some(o=> /** @type {EngineObject3D} */ (o).mesh === old))
3224
3505
  old.dispose();
3225
3506
  return mesh;
3226
3507
  }
@@ -3228,37 +3509,95 @@ class EngineObject3D extends EngineObject
3228
3509
  /** 2D rendering is skipped, the mesh is drawn by render3D during the 3D pass */
3229
3510
  render() {}
3230
3511
 
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 */
3512
+ /** 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
3513
+ * @return {void} */
3232
3514
  render3D()
3233
3515
  {
3234
3516
  // 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);
3517
+ ASSERT(this.transparent || this.additive || this.color.a >= 1,
3518
+ 'an object that fades needs its transparent flag, an opaque draw ignores the color alpha', this.color);
3519
+ // the matrix the object keeps, rebuilt only when it moved, the same one for the shadow pass and the main pass
3520
+ const matrix = render3DObjectMatrix(this);
3236
3521
  if (this.mesh)
3237
- render3D.drawMesh(this.mesh, this.getMatrix(), this.tileInfo, this.color);
3522
+ render3D.drawMesh(this.mesh, matrix, this.tileInfo, this.color);
3238
3523
  else if (this.tileInfo)
3239
3524
  {
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);
3525
+ // a sprite, at the world size the collect, pick and solid collision helpers measure it at
3526
+ const m = matrix.m, size = render3DWorldSize(this, m);
3527
+ render3D.drawBillboard(vec3(m[12], m[13], m[14]), vec2(size.x, size.y), this.tileInfo, this.color,
3528
+ this.rotation3D.z, this.upright);
3246
3529
  }
3247
3530
  }
3248
3531
  }
3249
3532
 
3533
+ // an object's world matrix, the one it keeps: rebuilt only when its position, rotation or scale changed since the
3534
+ // last build, or its parent's matrix did, so an object that stands still costs nine compares a frame instead of
3535
+ // the trig and a new matrix; the matrix returned is the object's own, read it and never change it
3536
+ const render3DLocalMatrix = new Matrix4;
3537
+ function render3DObjectMatrix(o)
3538
+ {
3539
+ const parent = o.parent instanceof EngineObject3D ? o.parent : undefined;
3540
+ const parentMatrix = parent && render3DObjectMatrix(parent); // the parent first, so its version is current
3541
+ const p = o.pos3D, r = o.rotation3D, s = o.scale3D, k = o.matrixBuilt, local = o.localMatrix;
3542
+ if (local)
3543
+ {
3544
+ // a matrix given whole is taken as it is each time, it can change in place
3545
+ if (parent)
3546
+ {
3547
+ o.worldMatrix.m.set(parentMatrix.m);
3548
+ o.worldMatrix.multiply(local);
3549
+ o.matrixParentVersion = parent.matrixVersion;
3550
+ }
3551
+ else
3552
+ o.worldMatrix.m.set(local.m);
3553
+ k[0] = NaN; // built from the matrix, so going back to pos3D builds again
3554
+ o.matrixParent = parent;
3555
+ ++o.matrixVersion;
3556
+ }
3557
+ else if (k[0] !== p.x || k[1] !== p.y || k[2] !== p.z || k[3] !== r.x || k[4] !== r.y || k[5] !== r.z
3558
+ || k[6] !== s.x || k[7] !== s.y || k[8] !== s.z || o.matrixParent !== parent
3559
+ || parent && o.matrixParentVersion !== parent.matrixVersion)
3560
+ {
3561
+ k[0] = p.x, k[1] = p.y, k[2] = p.z, k[3] = r.x, k[4] = r.y, k[5] = r.z, k[6] = s.x, k[7] = s.y, k[8] = s.z;
3562
+ if (parent)
3563
+ {
3564
+ // the parent's world matrix times the local one
3565
+ buildMatrix(p, r, s, render3DLocalMatrix);
3566
+ o.worldMatrix.m.set(parentMatrix.m);
3567
+ o.worldMatrix.multiply(render3DLocalMatrix);
3568
+ o.matrixParentVersion = parent.matrixVersion;
3569
+ }
3570
+ else
3571
+ buildMatrix(p, r, s, o.worldMatrix);
3572
+ o.matrixParent = parent;
3573
+ ++o.matrixVersion;
3574
+ }
3575
+ return o.worldMatrix;
3576
+ }
3577
+
3250
3578
  // move an object by its 3D velocities, an object with mass falling with render3D.gravity and slowing by its damping
3251
3579
  function render3DMove(o)
3252
3580
  {
3581
+ // the vectors change in place, as the 2D object's do: this runs for every object every frame
3582
+ const p = o.pos3D, v = o.velocity3D, r = o.rotation3D, a = o.angleVelocity3D;
3253
3583
  if (o.mass && !o.sync2D) // a 2D driven object gets the 2D gravity instead
3254
3584
  {
3255
3585
  // damped first and gravity added after, the order EngineObject.updatePhysics uses,
3256
3586
  // 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);
3587
+ const g = render3D.gravity, s = o.gravityScale, d = o.damping;
3588
+ v.x = v.x * d + g.x * s, v.y = v.y * d + g.y * s, v.z = v.z * d + g.z * s;
3259
3589
  }
3260
- o.pos3D = o.pos3D.add(o.velocity3D);
3261
- o.rotation3D = o.rotation3D.add(o.angleVelocity3D);
3590
+ p.x += v.x, p.y += v.y, p.z += v.z;
3591
+ r.x += a.x, r.y += a.y, r.z += a.z;
3592
+ }
3593
+
3594
+ // keep an object that moved out of the level's solid geometry, clearing what it stood on for the level to set again
3595
+ function render3DCollideLevel(o, oldPos)
3596
+ {
3597
+ const ground = o.groundObject;
3598
+ o.groundObject = undefined;
3599
+ for (const level of render3DLevel)
3600
+ level.destroyed || level.levelCollide3D(o, oldPos, level === ground);
3262
3601
  }
3263
3602
 
3264
3603
  // where a solid object is in the world and what it collides as: the sphere that fits size3D, or the size3D box,
@@ -3314,7 +3653,8 @@ function render3DCollideSolid(a)
3314
3653
  for (const b of engineObjectsCollide)
3315
3654
  {
3316
3655
  if (b === a) break;
3317
- if (b.destroyed || b.parent || b.sync2D || !(b instanceof EngineObject3D)) continue; // a child is part of its parent
3656
+ // a child is part of its parent
3657
+ if (b.destroyed || !(b instanceof EngineObject3D) || b.parent || b.sync2D) continue;
3318
3658
  if (!a.isSolid && !b.isSolid) continue; // neither one blocks, so they pass through each other
3319
3659
 
3320
3660
  // the pairs nowhere near each other are almost all of them in a scene of any size, so
@@ -3340,48 +3680,118 @@ function render3DCollideSolid(a)
3340
3680
  b.pos3D = b.pos3D.subtract(push.scale(weightB));
3341
3681
  if (weightA)
3342
3682
  shapeA = render3DSolidShape(a); // it moved, so the next solid must be tested against where it is now
3683
+ // mass 0 keeps its velocity too, so a moving platform keeps moving, and what hits it bounces by its own
3684
+ // restitution as it would off a static wall
3343
3685
  const normal = push.normalize();
3344
- if (a.velocity3D.dot(normal) < 0)
3686
+ if (weightA && a.velocity3D.dot(normal) < 0)
3345
3687
  a.velocity3D = a.velocity3D.reflect(normal, a.restitution);
3346
- if (b.velocity3D.dot(normal) > 0)
3688
+ if (weightB && b.velocity3D.dot(normal) > 0)
3347
3689
  b.velocity3D = b.velocity3D.reflect(normal, b.restitution);
3348
3690
  }
3349
3691
  }
3350
3692
 
3351
3693
  /**
3352
- * Collect the EngineObject3D objects whose boxes overlap a box, sizes are full sizes
3694
+ * Collect the EngineObject3D objects whose boxes overlap a sphere or a box, the 3D twin of engineObjectsCollect
3353
3695
  * - 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
3696
+ * and are never collected
3697
+ * @param {Vector3} pos - Center of the area
3698
+ * @param {Vector3|number} size - Diameter of a sphere if a number, 0 for a point, full size of a box if a Vector3
3356
3699
  * @param {Array<EngineObject>} [objects] - Defaults to every object
3700
+ * @param {boolean} [testCenters] - Test only each object's center, a little faster, and ignores object sizes
3357
3701
  * @return {Array<EngineObject3D>}
3358
3702
  * @memberof Render3D
3359
3703
  */
3360
- function engineObjectsCollect3D(pos, size, objects=engineObjects)
3704
+ function engineObjectsCollect3D(pos, size, objects=engineObjects, testCenters=false)
3361
3705
  {
3362
- size = render3DSize3(size);
3706
+ // a size of 0 is a point, tested against each box, since a sphere of no size could never hit
3707
+ const radiusSquared = typeof size === 'number' && size > 0 ? (size/2)**2 : undefined;
3708
+ const box = radiusSquared ? undefined : typeof size === 'number' ? vec3() : render3DSize3(size);
3363
3709
  const collected = [];
3364
3710
  for (const o of objects)
3365
3711
  {
3366
3712
  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
3713
+ // the box in world space, scaled by the object and its parents
3714
+ const m = render3DObjectMatrix(o).m, s = o.size3D;
3368
3715
  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);
3716
+ const center = vec3(m[12], m[13], m[14]);
3717
+ const worldSize = testCenters ? vec3() : render3DWorldSize(o, m);
3718
+ let hit;
3719
+ if (box)
3720
+ hit = isOverlapping3D(pos, box, center, worldSize);
3721
+ else
3722
+ {
3723
+ // a sphere against the nearest point of the box
3724
+ const dx = max(abs(pos.x - center.x) - worldSize.x/2, 0);
3725
+ const dy = max(abs(pos.y - center.y) - worldSize.y/2, 0);
3726
+ const dz = max(abs(pos.z - center.z) - worldSize.z/2, 0);
3727
+ hit = dx*dx + dy*dy + dz*dz < radiusSquared;
3728
+ }
3729
+ hit && collected.push(o);
3372
3730
  }
3373
3731
  return collected;
3374
3732
  }
3375
3733
 
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
3734
+ // how far along a ray an object is hit, or undefined for a miss; each one is tested as the box around its mesh in
3735
+ // its own space, or a sprite as the quad it draws, not triangle by triangle
3378
3736
  function render3DRaycastObject(ray, o)
3379
3737
  {
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);
3738
+ if (o.destroyed || !(o instanceof EngineObject3D)) return;
3739
+ // a height map or voxel map is hit on its surface, not its box
3740
+ if (o instanceof HeightMap || o instanceof VoxelMap) return o.levelRaycast3D(ray);
3741
+ if (!(o.mesh || o.tileInfo)) return;
3742
+ if (o instanceof InstancedMesh3D) return; // its instances are not objects, and its one sphere is not a thing to hit
3743
+ const matrix = render3DObjectMatrix(o), mesh = o.mesh;
3744
+ if (!mesh) return render3DRaycastSprite(ray, o, matrix);
3745
+ // a mesh that changed since it was measured is measured again, an upload may not have come yet
3746
+ const radius = (mesh.dirty || !mesh.radius ? mesh.computeRadius() : mesh.radius) * render3DMaxStretch(matrix.m);
3383
3747
  if (!(radius > 0)) return; // nothing to hit
3384
- return raycastSphere(ray, matrix.getTranslation(), radius);
3748
+ const center = matrix.getTranslation();
3749
+ const distance = raycastSphere(ray, center, radius);
3750
+ if (distance === undefined) return;
3751
+
3752
+ // the sphere is a quick reject, a mesh is hit where the ray meets its box in its own space, since a wide floor's
3753
+ // sphere reaches far above it; the direction is not made unit length, so the distance holds in the world;
3754
+ // a mesh flattened to nothing on an axis has no inverse, it is hit as a disc like a sprite
3755
+ if (!matrix.determinant()) return render3DRaycastDisc(ray, center, radius);
3756
+ const inverse = matrix.copy().invert(), bounds = mesh.bounds || mesh.getBounds();
3757
+ const local = new Ray3D(inverse.transformPoint(ray.origin), inverse.transformDirection(ray.direction));
3758
+ const hit = raycastBox(local, bounds.min.add(bounds.max).scale(.5), bounds.max.subtract(bounds.min));
3759
+ if (hit !== 0) return hit;
3760
+
3761
+ // it starts inside the box, like a camera on terrain or in a room, so it is hit where it leaves, and what stands
3762
+ // inside comes first
3763
+ let exit = Infinity;
3764
+ for (const k of ['x', 'y', 'z'])
3765
+ {
3766
+ const d = local.direction[k];
3767
+ if (d)
3768
+ exit = min(exit, ((d > 0 ? bounds.max[k] : bounds.min[k]) - local.origin[k]) / d);
3769
+ }
3770
+ return exit === Infinity ? 0 : exit; // a ray of no length is where it starts
3771
+ }
3772
+
3773
+ // a sprite is the quad drawBillboard draws, its size3D grown by the x and y scale as the draw does, facing the camera
3774
+ // as it was last drawn; where the ray meets its plane, inside its half axes
3775
+ function render3DRaycastSprite(ray, o, matrix)
3776
+ {
3777
+ const center = matrix.getTranslation(), worldSize = render3DWorldSize(o, matrix.m), size = vec2(worldSize.x, worldSize.y);
3778
+ if (!(size.x > 0 && size.y > 0)) return; // nothing to hit
3779
+ if (!ray.direction.lengthSquared()) // a ray of no length is where it starts
3780
+ return render3DRaycastDisc(ray, center, hypot(size.x, size.y) / 2);
3781
+ const a = render3DBillboardAxes(size, o.rotation3D.z, o.upright);
3782
+ const right = vec3(a[0], a[1], a[2]), up = vec3(a[3], a[4], a[5]);
3783
+ const t = raycastPlane(ray, center, right.cross(up));
3784
+ if (t === undefined) return; // behind, or seen edge on
3785
+ const p = ray.getPosition(t).subtract(center);
3786
+ return abs(p.dot(right)) <= right.lengthSquared() && abs(p.dot(up)) <= up.lengthSquared() ? t : undefined;
3787
+ }
3788
+
3789
+ // a mesh flattened to nothing, or a sprite for a ray of no length: a disc facing the ray at its center's depth
3790
+ function render3DRaycastDisc(ray, center, radius)
3791
+ {
3792
+ const d = ray.direction, oc = center.subtract(ray.origin), dd = d.dot(d);
3793
+ const t = dd ? oc.dot(d) / dd : 0; // its depth along the ray, in the ray's own units
3794
+ return t >= 0 && oc.subtract(d.scale(t)).lengthSquared() <= radius*radius ? t : undefined;
3385
3795
  }
3386
3796
 
3387
3797
  /**
@@ -3399,28 +3809,206 @@ function engineObjectsRaycast3D(ray, objects=engineObjects)
3399
3809
  for (const o of objects)
3400
3810
  {
3401
3811
  const distance = render3DRaycastObject(ray, o);
3402
- if (distance !== undefined)
3403
- hits.push({o, distance});
3812
+ if (distance !== undefined) // only a 3D object has a distance
3813
+ hits.push({o: /** @type {EngineObject3D} */ (o), distance});
3404
3814
  }
3405
3815
  return hits.sort((a, b)=> a.distance - b.distance).map(hit=> hit.o);
3406
3816
  }
3407
3817
 
3408
3818
  /**
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
3819
+ * Call a function for each EngineObject3D whose box overlaps a sphere or a box
3820
+ * - An object destroyed by an earlier callback is skipped
3821
+ * @param {Vector3} pos - Center of the area
3822
+ * @param {Vector3|number} size - Diameter of a sphere if a number, 0 for a point, full size of a box if a Vector3
3823
+ * @param {function(EngineObject3D): void} callback
3413
3824
  * @param {Array<EngineObject>} [objects] - Defaults to every object
3825
+ * @param {boolean} [testCenters] - Test only each object's center, see engineObjectsCollect3D
3414
3826
  * @memberof Render3D
3415
3827
  */
3416
- function engineObjectsCallback3D(pos, size, callback, objects=engineObjects)
3417
- { engineObjectsCollect3D(pos, size, objects).forEach(callback); }
3828
+ function engineObjectsCallback3D(pos, size, callback, objects=engineObjects, testCenters=false)
3829
+ {
3830
+ for (const o of engineObjectsCollect3D(pos, size, objects, testCenters))
3831
+ o.destroyed || callback(o);
3832
+ }
3833
+
3834
+ ///////////////////////////////////////////////////////////////////////////////
3835
+ /**
3836
+ * InstancedMesh3D - Many copies of one mesh drawn as one call, with their transforms kept on the GPU
3837
+ * - For big sets that mostly stay put: an instance costs nothing per frame until it changes, so a hundred thousand
3838
+ * trees cost what one tree does; objects and drawMesh batch by themselves too, but rebuild their batch every frame
3839
+ * - setMatrixAt and setColorAt change one instance, and only the changed range uploads before the next draw
3840
+ * - The instances are in world space; the object's own pos3D, rotation3D and scale3D do not move them
3841
+ * - The whole set is culled by one bounding sphere around the origin, and casts and receives shadows like any object
3842
+ * - The object's flags cover the whole set, one emissive, one tileInfo, one shader; only the colors are per instance
3843
+ * - A mirrored instance, one with a negative scale, shows its inside unless the mesh is doubleSided
3844
+ * - A transparent set draws in one go in the transparent stage, its instances are not sorted against each other;
3845
+ * the set sorts against other transparent draws by the object's position, so put pos3D at its middle
3846
+ * - pick, the raycast and the collect helpers do not see the instances, test them yourself from instanceData
3847
+ * @extends EngineObject3D
3848
+ * @memberof Render3D
3849
+ * @example
3850
+ * const forest = new InstancedMesh3D(treeMesh, 1000);
3851
+ * for (let i = 0; i < 1000; ++i)
3852
+ * forest.setMatrixAt(i, buildMatrix(randomGroundPos(), vec3(0, rand(2*PI), 0)));
3853
+ */
3854
+ class InstancedMesh3D extends EngineObject3D
3855
+ {
3856
+ /** Create a set of instances of a mesh, each at the origin in the object's color until it is set
3857
+ * @param {Mesh} mesh
3858
+ * @param {number} count - How many instances there is room for, all of them draw until count is lowered
3859
+ * @param {TileInfo|TextureInfo} [tileInfo] - Texture for all of them
3860
+ * @param {Color} [color] - The color they start with */
3861
+ constructor(mesh, count, tileInfo, color=WHITE)
3862
+ {
3863
+ super(vec3(), mesh, tileInfo, color);
3864
+ ASSERT(mesh instanceof Mesh, 'an InstancedMesh3D needs a Mesh');
3865
+ ASSERT(count >= 1, 'an InstancedMesh3D needs room for at least one instance');
3866
+ this.size3D = vec3(); // not a solid thing to pick or collect
3867
+ /** @property {number} - How many instances draw, the first ones, up to the count it was made with */
3868
+ this.count = count;
3869
+ /** @property {number} - How many instances it was made with */
3870
+ this.maxCount = count;
3871
+ /** @property {Float32Array} - The per instance values the shader reads, 24 floats each: the matrix, the color
3872
+ * and the uv rect; edit it directly and call markDirty for the instances changed */
3873
+ this.instanceData = new Float32Array(count * RENDER3D_INSTANCE_FLOATS);
3874
+ /** @property {number} - Radius of the sphere around the origin that holds every instance set so far, for
3875
+ * culling; from the farthest instance and the largest scale, and the mesh's size when it draws */
3876
+ this.radius = 0;
3877
+ this.reach = 0; // the farthest any instance's position has been from the origin
3878
+ this.maxScale = 0; // and the largest scale any instance has had
3879
+ /** @property {number} - First instance to upload before the next draw */
3880
+ this.dirtyStart = 0;
3881
+ /** @property {number} - One past the last instance to upload, so nothing uploads when it is not past dirtyStart */
3882
+ this.dirtyEnd = count;
3883
+ this.buffer = undefined; // the GPU copy of instanceData
3884
+ this.bufferGeneration = -1; // the context it was made under
3885
+ this.uvTileInfo = tileInfo; // the tile the uv rects were written for
3886
+ const uv = render3DGetTileUVs(tileInfo), data = this.instanceData;
3887
+ for (let i = 0; i < count; ++i)
3888
+ {
3889
+ this.setMatrixAt(i, RENDER3D_IDENTITY);
3890
+ const k = i * RENDER3D_INSTANCE_FLOATS;
3891
+ data[k+16] = color.r; data[k+17] = color.g; data[k+18] = color.b; data[k+19] = color.a;
3892
+ data[k+20] = uv.x; data[k+21] = uv.y; data[k+22] = uv.w; data[k+23] = uv.h;
3893
+ }
3894
+ }
3895
+
3896
+ /** Place an instance, in world space
3897
+ * @param {number} i
3898
+ * @param {Matrix4} matrix */
3899
+ setMatrixAt(i, matrix)
3900
+ {
3901
+ ASSERT(i >= 0 && i < this.maxCount, 'instance index out of range');
3902
+ const data = this.instanceData, k = i * RENDER3D_INSTANCE_FLOATS, m = matrix.m;
3903
+ data.set(m, k);
3904
+ this.markDirty(i);
3905
+ }
3906
+
3907
+ /** The matrix of an instance
3908
+ * @param {number} i
3909
+ * @return {Matrix4} */
3910
+ getMatrixAt(i)
3911
+ {
3912
+ ASSERT(i >= 0 && i < this.maxCount, 'instance index out of range');
3913
+ const k = i * RENDER3D_INSTANCE_FLOATS, matrix = new Matrix4;
3914
+ matrix.m.set(this.instanceData.subarray(k, k + 16));
3915
+ return matrix;
3916
+ }
3917
+
3918
+ /** Color an instance
3919
+ * @param {number} i
3920
+ * @param {Color} color */
3921
+ setColorAt(i, color)
3922
+ {
3923
+ ASSERT(i >= 0 && i < this.maxCount, 'instance index out of range');
3924
+ ASSERT(isColor(color), 'color must be a Color');
3925
+ const data = this.instanceData, k = i * RENDER3D_INSTANCE_FLOATS;
3926
+ data[k+16] = color.r; data[k+17] = color.g; data[k+18] = color.b; data[k+19] = color.a;
3927
+ this.markDirty(i);
3928
+ }
3929
+
3930
+ /** Note that an instance changed, so it uploads before the next draw and the bounds hold it; setMatrixAt and
3931
+ * setColorAt call this, and so must an edit made straight to instanceData
3932
+ * @param {number} i */
3933
+ markDirty(i)
3934
+ {
3935
+ this.dirtyStart = min(this.dirtyStart, i);
3936
+ this.dirtyEnd = max(this.dirtyEnd, i + 1);
3937
+
3938
+ // the bounds grow to hold where it is now and how big, read back from the matrix it has
3939
+ const d = this.instanceData, k = i * RENDER3D_INSTANCE_FLOATS;
3940
+ this.reach = max(this.reach, hypot(d[k+12], d[k+13], d[k+14]));
3941
+ this.maxScale = max(this.maxScale, render3DMaxStretch(d, k));
3942
+ const meshRadius = this.mesh.radius || this.mesh.computeRadius(); // measured once, the draw takes a new size up
3943
+ this.radius = this.reach + meshRadius * this.maxScale;
3944
+ }
3945
+
3946
+ /** Draws every instance as one call, uploading the ones that changed first
3947
+ * @return {void} */
3948
+ render3D()
3949
+ {
3950
+ const r = render3D, gl = glContext, mesh = this.mesh;
3951
+ ASSERT(this.count >= 0 && this.count <= this.maxCount, 'count must be within the count it was made with');
3952
+ if (r.transparentQueue) // sorted as one thing with the other transparent draws
3953
+ return r.queueTransparent(this.getWorldPos3D(), ()=> this.render3D());
3954
+ if (!mesh || !this.count || !render3DCanDraw()) return;
3955
+ if (r.shadowPass && !r.lighting) return; // unlit things cast no shadow
3956
+ render3DMeshUpload(mesh);
3957
+ if (!mesh.bufferCount) return;
3958
+ this.radius = this.reach + mesh.radius * this.maxScale; // the mesh may have grown since
3959
+ if (r.frustumCulling && !render3DSphereVisible(0, 0, 0, this.radius)) return;
3960
+
3961
+ // the uv rect is the object's tile for every instance, rewritten when the tile changes
3962
+ if (this.uvTileInfo !== this.tileInfo)
3963
+ {
3964
+ const uv = render3DGetTileUVs(this.tileInfo), data = this.instanceData;
3965
+ for (let k = 20; k < data.length; k += RENDER3D_INSTANCE_FLOATS)
3966
+ data[k] = uv.x, data[k+1] = uv.y, data[k+2] = uv.w, data[k+3] = uv.h;
3967
+ this.uvTileInfo = this.tileInfo;
3968
+ this.dirtyStart = 0, this.dirtyEnd = this.maxCount;
3969
+ }
3970
+
3971
+ // the GPU copy: all of it under a fresh context, otherwise just the changed range
3972
+ if (!this.buffer || this.bufferGeneration !== r.contextGeneration)
3973
+ {
3974
+ this.buffer = gl.createBuffer();
3975
+ this.bufferGeneration = r.contextGeneration;
3976
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
3977
+ gl.bufferData(gl.ARRAY_BUFFER, this.instanceData, gl.DYNAMIC_DRAW);
3978
+ }
3979
+ else if (this.dirtyEnd > this.dirtyStart)
3980
+ {
3981
+ const start = this.dirtyStart * RENDER3D_INSTANCE_FLOATS, end = this.dirtyEnd * RENDER3D_INSTANCE_FLOATS;
3982
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
3983
+ gl.bufferSubData(gl.ARRAY_BUFFER, start * 4, this.instanceData, start, end - start);
3984
+ }
3985
+ this.dirtyStart = Infinity, this.dirtyEnd = 0;
3986
+
3987
+ // one draw under the object's state, the mesh setting the culling as drawMesh does
3988
+ r.flush();
3989
+ render3DFlushBeforeOverlay(); // as in drawMesh, what was drawn before goes under it
3990
+ const cullBackFaces = r.cullBackFaces, tileInfo = this.tileInfo;
3991
+ r.cullBackFaces = !mesh.doubleSided;
3992
+ render3DDrawInstanced(mesh, this.buffer, this.count, render3DTextureOf(tileInfo), r);
3993
+ r.cullBackFaces = cullBackFaces;
3994
+ }
3995
+
3996
+ /** Destroy the set and free its GPU buffer
3997
+ * @param {boolean} [immediate] */
3998
+ destroy(immediate)
3999
+ {
4000
+ if (this.buffer && this.bufferGeneration === render3D?.contextGeneration)
4001
+ glContext?.deleteBuffer(this.buffer);
4002
+ this.buffer = undefined;
4003
+ super.destroy(immediate);
4004
+ }
4005
+ }
3418
4006
 
3419
4007
  ///////////////////////////////////////////////////////////////////////////////
3420
4008
  /**
3421
4009
  * Light3D - A light that is an EngineObject3D, so it can move, follow a parent or be destroyed like anything else
3422
4010
  * - 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
4011
+ * - Only the sun, render3D.sunDirection, casts shadows; these light and make highlights without one
3424
4012
  * - Only the 8 lights nearest the camera are used each frame
3425
4013
  * - radius is where the light fades out, and it fades fast, so a small radius wants a higher intensity
3426
4014
  * - intensity multiplies the color, above 1 for a light brighter than white
@@ -3464,7 +4052,7 @@ class Light3D extends EngineObject3D
3464
4052
  * - It shines from its position toward the origin, like a three.js DirectionalLight: only the direction to it
3465
4053
  * counts, so moving it or its parent swings the light around; parent it to a sun in the sky and it follows
3466
4054
  * - 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
4055
+ * - Like every Light3D it casts no shadow, only the sun, render3D.sunDirection, does
3468
4056
  * @extends Light3D
3469
4057
  * @memberof Render3D
3470
4058
  * @example
@@ -3482,525 +4070,3 @@ class DirectionalLight3D extends Light3D
3482
4070
  this.directional = true;
3483
4071
  }
3484
4072
  }
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
- }