littlejsengine 1.20.0 → 1.22.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.
@@ -29,6 +29,9 @@ const RENDER3D_VERTEX_BYTES = RENDER3D_VERTEX_FLOATS * 4;
29
29
  // uv rect (12); constants for one draw, one per instance for a batch; the shader derives the normal matrix
30
30
  const RENDER3D_INSTANCE_FLOATS = 24;
31
31
  const RENDER3D_INSTANCE_BYTES = RENDER3D_INSTANCE_FLOATS * 4;
32
+ // an InstancedMesh3D keeps them apart: 16 floats of matrix, and 8 of tint and uv rect uploaded only when they change
33
+ const RENDER3D_MATRIX_FLOATS = 16;
34
+ const RENDER3D_COLOR_FLOATS = 8;
32
35
  const RENDER3D_INSTANCE_ATTRIBS = [[4, 4, 0], [5, 4, 16], [6, 4, 32], [7, 4, 48], [11, 4, 64], [12, 4, 80]];
33
36
  const RENDER3D_VERTEX_INPUTS =
34
37
  'layout(location=0) in vec3 p;layout(location=1) in vec3 n;layout(location=2) in vec2 t;layout(location=3) in vec4 c;' +
@@ -72,18 +75,49 @@ function render3DCanDraw()
72
75
  }
73
76
 
74
77
  // the draw state fields a batch is drawn under; lights and fog are not captured, they are read live at flush
75
- // the three functions below write them out by hand for speed, so a new field goes in all four
78
+ // each is an accessor on the plugin over render3D.drawState, and a real change moves render3D.stateVersion, so a
79
+ // batch checks that one number at each draw while the state holds; the three functions below write the fields out
80
+ // by hand for speed, so a new field goes in all four, in drawState and gets an accessor; emissiveMapColor is
81
+ // compared by its rgb, so two objects with equal colors batch
82
+ /** The draw state's values, behind render3D's accessors, and the rgb emissiveMapColor had when it was set
83
+ * @typedef {Object} Render3DDrawState
84
+ * @property {boolean} blend
85
+ * @property {boolean} additive
86
+ * @property {boolean} depthTest
87
+ * @property {boolean} depthWrite
88
+ * @property {boolean} cullBackFaces
89
+ * @property {boolean} mirrored
90
+ * @property {boolean} lighting
91
+ * @property {number} emissive
92
+ * @property {boolean} receiveShadow
93
+ * @property {number} specular
94
+ * @property {boolean} pixelated
95
+ * @property {Shader|undefined} shader
96
+ * @property {TextureInfo|undefined} normalMap
97
+ * @property {number} normalScale
98
+ * @property {number} shininess
99
+ * @property {number} reflectivity
100
+ * @property {TextureInfo|undefined} emissiveMap
101
+ * @property {Color} emissiveMapColor
102
+ * @property {number} emissiveR
103
+ * @property {number} emissiveG
104
+ * @property {number} emissiveB
105
+ * @memberof Render3D */
76
106
  const RENDER3D_STATE_FIELDS = ['blend', 'additive', 'depthTest', 'depthWrite', 'cullBackFaces', 'mirrored', 'lighting',
77
- 'emissive', 'receiveShadow', 'specular', 'pixelated', 'shader'];
107
+ 'emissive', 'receiveShadow', 'specular', 'pixelated', 'shader', 'normalMap', 'normalScale', 'shininess',
108
+ 'reflectivity', 'emissiveMap', 'emissiveMapColor'];
78
109
 
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
110
+ // a copy of the draw state in one fixed shape, the fields of RENDER3D_STATE_FIELDS written out, and the version
111
+ // it was taken at
81
112
  function render3DCaptureBatchState()
82
113
  {
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};
114
+ const r = render3D, d = r.drawState;
115
+ return {blend: d.blend, additive: d.additive, depthTest: d.depthTest, depthWrite: d.depthWrite,
116
+ cullBackFaces: d.cullBackFaces, mirrored: d.mirrored, lighting: d.lighting, emissive: d.emissive,
117
+ receiveShadow: d.receiveShadow, specular: d.specular, pixelated: d.pixelated, shader: d.shader,
118
+ normalMap: d.normalMap, normalScale: d.normalScale, shininess: d.shininess, reflectivity: d.reflectivity,
119
+ emissiveMap: d.emissiveMap, emissiveMapColor: (d.emissiveMapColor || WHITE).copy(), // the caller may change it
120
+ version: r.stateVersion};
87
121
  }
88
122
 
89
123
  // put a captured draw state back, written out the same way; the transparent stage does this for every queued draw
@@ -92,23 +126,34 @@ function render3DApplyBatchState(s)
92
126
  const r = render3D;
93
127
  r.blend = s.blend, r.additive = s.additive, r.depthTest = s.depthTest, r.depthWrite = s.depthWrite,
94
128
  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;
129
+ r.receiveShadow = s.receiveShadow, r.specular = s.specular, r.pixelated = s.pixelated, r.shader = s.shader,
130
+ r.normalMap = s.normalMap, r.normalScale = s.normalScale, r.shininess = s.shininess,
131
+ r.reflectivity = s.reflectivity, r.emissiveMap = s.emissiveMap, r.emissiveMapColor = s.emissiveMapColor;
96
132
  }
97
133
 
98
- // true when the current draw state differs from a captured one, so a pending batch must flush first
134
+ // true when the current draw state differs from a captured one, so a pending batch must flush first; it runs for
135
+ // every instance drawn, so while nothing was set since the capture it checks the version alone
99
136
  function render3DStateChanged(s)
100
137
  {
101
138
  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;
139
+ if (s.version === r.stateVersion) return false;
140
+ const d = r.drawState, c = s.emissiveMapColor;
141
+ if (d.blend !== s.blend || d.additive !== s.additive || d.depthTest !== s.depthTest
142
+ || d.depthWrite !== s.depthWrite || d.cullBackFaces !== s.cullBackFaces || d.mirrored !== s.mirrored
143
+ || d.lighting !== s.lighting || d.emissive !== s.emissive || d.receiveShadow !== s.receiveShadow
144
+ || d.specular !== s.specular || d.pixelated !== s.pixelated || d.shader !== s.shader
145
+ || d.normalMap !== s.normalMap || d.normalScale !== s.normalScale || d.shininess !== s.shininess
146
+ || d.reflectivity !== s.reflectivity || d.emissiveMap !== s.emissiveMap
147
+ || d.emissiveR !== c.r || d.emissiveG !== c.g || d.emissiveB !== c.b)
148
+ return true;
149
+ s.version = r.stateVersion; // set and set back to what it was: the batch goes on, checking the version again
150
+ return false;
106
151
  }
107
152
 
108
153
  // whether a sphere is inside the view, or the shadow map's box during the shadow pass, without a vector
109
154
  function render3DSphereVisible(x, y, z, radius)
110
155
  {
111
- const planes = render3D.shadowPass ? render3D.shadowPlanes : render3D.frustumPlanes;
156
+ const planes = render3D.shadowPass && !render3D.depthPass ? render3D.shadowPlanes : render3D.frustumPlanes;
112
157
  for (let i = 0; i < planes.length; ++i)
113
158
  {
114
159
  const p = planes[i];
@@ -180,7 +225,10 @@ function render3DMaxScale(m)
180
225
  // how far a matrix at offset k can move a point that is one unit from its origin, for a bounding sphere: the
181
226
  // longest axis when the axes are square to each other, more when they are not, as a turned child under a parent
182
227
  // 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)
228
+ function render3DMaxStretch(m, k=0) { return render3DMaxStretchSquared(m, k) ** .5; }
229
+
230
+ // the same, squared, for bounds that grow many times before they are read
231
+ function render3DMaxStretchSquared(m, k=0)
184
232
  {
185
233
  const xx = m[k]*m[k] + m[k+1]*m[k+1] + m[k+2]*m[k+2];
186
234
  const yy = m[k+4]*m[k+4] + m[k+5]*m[k+5] + m[k+6]*m[k+6];
@@ -188,7 +236,7 @@ function render3DMaxStretch(m, k=0)
188
236
  const xy = abs(m[k]*m[k+4] + m[k+1]*m[k+5] + m[k+2]*m[k+6]);
189
237
  const xz = abs(m[k]*m[k+8] + m[k+1]*m[k+9] + m[k+2]*m[k+10]);
190
238
  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;
239
+ return max(xx + xy + xz, yy + xy + yz, zz + xz + yz);
192
240
  }
193
241
 
194
242
  // a quad as a strip from its center and half axes, the same corner order as render3DQuadStrip
@@ -208,6 +256,15 @@ function render3DSetObjectState(o)
208
256
  r.emissive = emissive;
209
257
  r.additive = !!o?.additive;
210
258
  r.specular = o?.specular || 0;
259
+ const shininess = o?.shininess ?? 16, reflectivity = o?.reflectivity || 0;
260
+ ASSERT(isNumber(shininess) && shininess > 0, 'shininess must be a number above 0', shininess);
261
+ ASSERT(isNumber(reflectivity) && reflectivity >= 0 && reflectivity <= 1, 'reflectivity must be 0 to 1', reflectivity);
262
+ r.shininess = shininess;
263
+ r.reflectivity = reflectivity;
264
+ r.normalMap = o?.normalMap || undefined;
265
+ r.normalScale = o?.normalScale ?? 1;
266
+ r.emissiveMap = o?.emissiveMap || undefined;
267
+ r.emissiveMapColor = o?.emissiveMapColor || WHITE;
211
268
  r.receiveShadow = !o || o.receiveShadow;
212
269
  r.cullBackFaces = r.mirrored = false; // each mesh sets these as it draws
213
270
  r.pixelated = !!o?.pixelated;
@@ -229,10 +286,11 @@ function render3DDrawObjects(objects)
229
286
  render3DSetObjectState();
230
287
  }
231
288
 
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
289
+ // 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,
290
+ // and under one winding: a mirroring transform, one with a negative determinant, turns it around
233
291
  function render3DInstance(mesh, matrix, tileInfo, color)
234
292
  {
235
- const k = render3DInstanceSlot(mesh, render3DTextureOf(tileInfo)), data = mesh.instanceData;
293
+ const k = render3DInstanceSlot(mesh, render3DTextureOf(tileInfo), render3DMirrors(matrix)), data = mesh.instanceData;
236
294
  data.set(matrix.m, k);
237
295
  // the tint; this batch is never blended, so in the shadow pass the depth shader cuts it by its texture alpha
238
296
  // alone, a see through caster draws blended instead and is cut by its tint alpha too
@@ -243,14 +301,18 @@ function render3DInstance(mesh, matrix, tileInfo, color)
243
301
 
244
302
  // make room for one more instance of a mesh under a texture and the current draw state, flushing a batch that
245
303
  // 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)
304
+ // drawMesh passes whether its matrix mirrors, and its batch then culls by the mesh and winds by that at the flush,
305
+ // so it never sets the draw state for them, which would move the state's version at every draw
306
+ function render3DInstanceSlot(mesh, textureInfo, mirrored)
247
307
  {
248
308
  const r = render3D;
249
- if (mesh.instanceCount && (mesh.instanceTextureInfo !== textureInfo || render3DStateChanged(mesh.instanceState)))
309
+ if (mesh.instanceCount && (mesh.instanceTextureInfo !== textureInfo || mesh.instanceMirrored !== mirrored
310
+ || render3DStateChanged(mesh.instanceState)))
250
311
  render3DFlushInstances(mesh);
251
312
  if (!mesh.instanceCount)
252
313
  {
253
314
  mesh.instanceTextureInfo = textureInfo;
315
+ mesh.instanceMirrored = mirrored;
254
316
  mesh.instanceState = render3DCaptureBatchState();
255
317
  r.instanceMeshes.push(mesh);
256
318
  }
@@ -281,7 +343,10 @@ function render3DFlushInstances(only)
281
343
  const buffers = r.instanceBuffers, buffer = buffers[r.instanceBufferIndex = (r.instanceBufferIndex + 1) % buffers.length];
282
344
  gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
283
345
  gl.bufferData(gl.ARRAY_BUFFER, mesh.instanceData, gl.DYNAMIC_DRAW, 0, count * RENDER3D_INSTANCE_FLOATS);
284
- render3DDrawInstanced(mesh, buffer, count, mesh.instanceTextureInfo, mesh.instanceState);
346
+ const state = mesh.instanceState;
347
+ if (mesh.instanceMirrored !== undefined) // a drawMesh batch: culled by its mesh, wound by its matrices
348
+ state.cullBackFaces = !mesh.doubleSided, state.mirrored = mesh.instanceMirrored;
349
+ render3DDrawInstanced(mesh, buffer, count, mesh.instanceTextureInfo, state);
285
350
  }
286
351
  if (!only)
287
352
  r.instanceMeshes.length = 0;
@@ -296,13 +361,16 @@ function render3DFlushInstances(only)
296
361
  // the arrays are turned on with their instance divisor for this one call and both are turned off after: a single
297
362
  // draw reads these slots as constant attributes, and in Firefox a draw that reads a constant through a slot whose
298
363
  // divisor is set leaves the next batch on that slot reading the wrong values
299
- function render3DDrawInstanced(mesh, buffer, count, textureInfo, state)
364
+ // colorBuffer holds the tint and uv rect apart from the matrices, as an InstancedMesh3D keeps them
365
+ function render3DDrawInstanced(mesh, buffer, count, textureInfo, state, colorBuffer)
300
366
  {
301
- const gl = glContext;
302
- gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
367
+ const gl = glContext, matrixBytes = RENDER3D_MATRIX_FLOATS * 4;
303
368
  for (const [location, size, offset] of RENDER3D_INSTANCE_ATTRIBS)
304
369
  {
305
- gl.vertexAttribPointer(location, size, gl.FLOAT, false, RENDER3D_INSTANCE_BYTES, offset);
370
+ const inColors = colorBuffer && offset >= matrixBytes;
371
+ const stride = !colorBuffer ? RENDER3D_INSTANCE_BYTES : inColors ? RENDER3D_COLOR_FLOATS * 4 : matrixBytes;
372
+ gl.bindBuffer(gl.ARRAY_BUFFER, inColors ? colorBuffer : buffer);
373
+ gl.vertexAttribPointer(location, size, gl.FLOAT, false, stride, inColors ? offset - matrixBytes : offset);
306
374
  gl.enableVertexAttribArray(location);
307
375
  gl.vertexAttribDivisor(location, 1);
308
376
  }
@@ -347,11 +415,36 @@ function render3DCollectLights()
347
415
  for (const light of lights)
348
416
  distances.set(light, light.directional ? -1 : light.getWorldPos3D().distanceSquared(cameraPos));
349
417
  lights.sort((a, b)=> distances.get(a) - distances.get(b));
418
+ // the light that casts the shadows keeps a slot however far it is, in place of the farthest
419
+ const caster = render3DShadowCaster();
420
+ if (caster && lights.indexOf(caster) >= RENDER3D_MAX_LIGHTS)
421
+ lights[RENDER3D_MAX_LIGHTS - 1] = caster;
350
422
  lights.length = RENDER3D_MAX_LIGHTS;
351
423
  }
352
424
  return lights;
353
425
  }
354
426
 
427
+ // the spotlight that casts the shadows in place of the sun: render3D.shadowLight while it is a light that is on
428
+ // and has a cone, undefined for the sun
429
+ function render3DShadowCaster()
430
+ {
431
+ const light = render3D.shadowLight;
432
+ return light && !light.destroyed && !light.directional && light.coneAngle > 0 && light.radius > 0 &&
433
+ light.intensity > 0 && light.color.a > 0 ? light : undefined;
434
+ }
435
+
436
+ // a light's cone as the four numbers the shader gets: the way it shines, scaled so that its dot with the way to
437
+ // a point, less the fourth number, is 0 at the edge of the cone and 1 where its fade starts; a light with no cone
438
+ // gets numbers that make it 1 every way
439
+ function render3DLightCone(light)
440
+ {
441
+ const angle = light.directional ? 0 : min(light.coneAngle, PI);
442
+ if (!(angle > 0)) return [0, 0, 0, -1];
443
+ const outer = cos(angle), inner = cos(angle * (1 - clamp(light.coneSoftness)));
444
+ const k = 1 / max(inner - outer, 1e-4), forward = light.getForward3D();
445
+ return [forward.x * k, forward.y * k, forward.z * k, outer * k];
446
+ }
447
+
355
448
  // unit circle directions for a number of sides, [cos, sin, cos, sin, ...] including the closing point, cached
356
449
  const render3DCircleCache = new Map;
357
450
  function render3DCircle(sides)
@@ -403,7 +496,15 @@ class Render3DPlugin
403
496
  {
404
497
  ASSERT(!render3D, 'Render3D plugin already initialized');
405
498
  render3D = this;
406
- ASSERT(Object.keys(render3DCaptureBatchState()).join() === RENDER3D_STATE_FIELDS.join(),
499
+ // the draw state, read at each draw through the accessors below the constructor
500
+ /** @type {Render3DDrawState} */
501
+ this.drawState = {blend: false, additive: false, depthTest: true, depthWrite: true, cullBackFaces: false,
502
+ mirrored: false, lighting: true, emissive: 0, receiveShadow: true, specular: 0, pixelated: false,
503
+ shader: undefined, normalMap: undefined, normalScale: 1, shininess: 16, reflectivity: 0,
504
+ emissiveMap: undefined, emissiveMapColor: WHITE, emissiveR: 1, emissiveG: 1, emissiveB: 1};
505
+ /** @property {number} - Goes up when a draw state field changes, so a batch sees at a glance that none did */
506
+ this.stateVersion = 0;
507
+ ASSERT(Object.keys(render3DCaptureBatchState()).join() === [...RENDER3D_STATE_FIELDS, 'version'].join(),
407
508
  'the batch state functions must list RENDER3D_STATE_FIELDS');
408
509
 
409
510
  /** @property {Camera3D} - The camera */
@@ -441,11 +542,24 @@ class Render3DPlugin
441
542
  * flat faces */
442
543
  this.smoothShading = false;
443
544
 
545
+ /** @property {boolean} - Draw the camera's depth into a texture each frame for post processing:
546
+ * PostProcessPlugin hands it to its shader as iChannel2, read with sceneDepth(uv); off by default and free
547
+ * when off, on it draws the solid objects of the default layer once more, depth only */
548
+ this.depthTexture = false;
549
+
444
550
  // shadows
445
551
  /** @property {boolean} - Cast real shadows from the sun, off by default and free when off */
446
552
  this.shadows = false;
553
+ /** @property {number} - How far a sound played with playSound is heard when it was made with no range of
554
+ * its own, in world units; further than the 2D soundDefaultRange, a 3D world is bigger */
555
+ this.soundDefaultRange = 100;
447
556
  /** @property {number} - Size of the shadow map in pixels, bigger is sharper and slower */
448
557
  this.shadowMapSize = 1024;
558
+ /** @property {Light3D|undefined} - A spotlight, a Light3D with a coneAngle, to cast the shadows in place
559
+ * of the sun, a flashlight in the dark: the shadow map looks down its cone, as far as its radius, and the
560
+ * sun still lights the scene but casts none; undefined for the sun, as is a light that is off or has no cone
561
+ * @type {Light3D|undefined} */
562
+ this.shadowLight = undefined;
449
563
  /** @property {number} - World size the shadow map covers around shadowCenter, smaller is sharper; it is a
450
564
  * square facing the light, so it turns as the light does, and about 1.5 times an area's width covers it from
451
565
  * any angle */
@@ -458,35 +572,6 @@ class Render3DPlugin
458
572
  /** @property {number} - How much to blur the shadow edges */
459
573
  this.shadowSoftness = 1;
460
574
 
461
- // draw state, read at each draw
462
- /** @property {boolean} - Apply lighting, when false draws plain vertex color times texture and casts no shadow;
463
- * off for billboards, lines, ribbons and soft discs, an object sets emissive instead */
464
- this.lighting = true;
465
- /** @property {number} - How much a surface lights itself, set per object by its emissive */
466
- this.emissive = 0;
467
- /** @property {boolean} - Additive blending instead of alpha, in the transparent stage */
468
- this.additive = false;
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 */
472
- this.depthTest = true;
473
- /** @property {boolean} - Write to the depth buffer, owned by the stages: on for opaque, off for transparent */
474
- this.depthWrite = true;
475
- // batch state set by drawMesh from each mesh: whether its back faces are skipped, off for strips so they
476
- // show from both sides, and whether its transform mirrors it so the other winding is the front
477
- this.cullBackFaces = false;
478
- this.mirrored = false;
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 */
481
- this.specular = 0;
482
- /** @property {Shader|undefined} - Custom Shader for the next draws, set from each object's shader; undefined
483
- * draws with the plugin's own
484
- * @type {Shader|undefined} */
485
- this.shader = undefined;
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 */
488
- this.receiveShadow = true;
489
-
490
575
  // the pass
491
576
  /** @property {Function|undefined} - Draw solid world here, it runs again for shadows so only draw in it
492
577
  * @type {Function|undefined} */
@@ -509,9 +594,6 @@ class Render3DPlugin
509
594
  /** @property {boolean} - Sample textures through mipmaps so they do not shimmer in the distance, false uses
510
595
  * each texture's own filtering like 2D */
511
596
  this.mipmaps = true;
512
- /** @property {boolean} - Draw state: keep texture pixels hard edged, no mipmaps and no blending between them,
513
- * set per object by pixelated */
514
- this.pixelated = false;
515
597
  /** @property {number} - Anisotropic filtering for textures seen at an angle, 1 to 16, 1 is off; needs mipmaps */
516
598
  this.anisotropy = 4;
517
599
 
@@ -555,7 +637,6 @@ class Render3DPlugin
555
637
  this.cameraBack = vec3(0, 0, 1); // its opposite, the normal of camera facing draws
556
638
 
557
639
  // internal state
558
- this.blend = false; // blending on, set by the stages
559
640
  /** @type {Array<Array<number>>} */
560
641
  this.frustumPlanes = []; // the view as six inward planes [x, y, z, w]
561
642
  /** @type {Array<Array<number>>} */
@@ -580,6 +661,13 @@ class Render3DPlugin
580
661
  /** @type {WebGLFramebuffer|undefined} */
581
662
  this.shadowFramebuffer = undefined;
582
663
  this.shadowTextureSize = 0;
664
+ /** @type {WebGLTexture|undefined} */
665
+ this.cameraDepthTexture = undefined; // the camera's depth, drawn when depthTexture is on
666
+ /** @type {WebGLFramebuffer|undefined} */
667
+ this.cameraDepthFramebuffer = undefined;
668
+ this.cameraDepthWidth = 0;
669
+ this.cameraDepthHeight = 0;
670
+ this.depthPass = false; // drawing the camera's depth, a shadow pass seen from the camera
583
671
  this.contextGeneration = 0; // counts context losses, a mesh uploaded under an older one uploads again
584
672
  this.uniforms = new Map; // uniform locations by program
585
673
  /** @type {Object<string, Array<number>>} */
@@ -588,6 +676,9 @@ class Render3DPlugin
588
676
  this.passIsDefault = true; // the running pass is the default layer, the only one shadowed
589
677
  this.lightPositions = new Float32Array(RENDER3D_MAX_LIGHTS * 4); // Light3D uniforms, filled each pass
590
678
  this.lightColors = new Float32Array(RENDER3D_MAX_LIGHTS * 4);
679
+ this.lightCones = new Float32Array(RENDER3D_MAX_LIGHTS * 4); // each light's cone, see render3DLightCone
680
+ this.shadowLightIndex = -1; // which of the lights sent casts the shadows, -1 for the sun
681
+ this.shadowDepthBias = 0; // the bias as the shadow lookup takes it, set with the shadow matrix
591
682
 
592
683
  // the stream of immediate mode draws
593
684
  /** @type {WebGLBuffer|undefined} */
@@ -615,6 +706,116 @@ class Render3DPlugin
615
706
  engineAddPlugin(undefined, render3DRender, render3DContextLost, render3DContextRestored, render3DPreRender);
616
707
  }
617
708
 
709
+ ///////////////////////////////////////////////////////////////////////////
710
+ // Draw state: each field an accessor over drawState, whose setter moves stateVersion only on a real change
711
+
712
+ /** Apply lighting, when false draws plain vertex color times texture and casts no shadow;
713
+ * off for billboards, lines, ribbons and soft discs, an object sets emissive instead
714
+ * @return {boolean} */
715
+ get lighting() { return this.drawState.lighting; }
716
+ set lighting(v) { const d = this.drawState; d.lighting === v || (d.lighting = v, ++this.stateVersion); }
717
+
718
+ /** How much a surface lights itself, set per object by its emissive
719
+ * @return {number} */
720
+ get emissive() { return this.drawState.emissive; }
721
+ set emissive(v) { const d = this.drawState; d.emissive === v || (d.emissive = v, ++this.stateVersion); }
722
+
723
+ /** Additive blending instead of alpha, in the transparent stage
724
+ * @return {boolean} */
725
+ get additive() { return this.drawState.additive; }
726
+ set additive(v) { const d = this.drawState; d.additive === v || (d.additive = v, ++this.stateVersion); }
727
+
728
+ /** Test against the depth buffer, reset to true before each object and callback; a draw
729
+ * with it off goes over what was drawn before it and under what is drawn after, by render order, which
730
+ * ends the batch of meshes before it, so one per object costs a draw per object
731
+ * @return {boolean} */
732
+ get depthTest() { return this.drawState.depthTest; }
733
+ set depthTest(v) { const d = this.drawState; d.depthTest === v || (d.depthTest = v, ++this.stateVersion); }
734
+
735
+ /** Write to the depth buffer, owned by the stages: on for opaque, off for transparent
736
+ * @return {boolean} */
737
+ get depthWrite() { return this.drawState.depthWrite; }
738
+ set depthWrite(v) { const d = this.drawState; d.depthWrite === v || (d.depthWrite = v, ++this.stateVersion); }
739
+
740
+ /** Skip back faces, set by drawMesh from each mesh: off for strips so they show from
741
+ * both sides
742
+ * @return {boolean} */
743
+ get cullBackFaces() { return this.drawState.cullBackFaces; }
744
+ set cullBackFaces(v) { const d = this.drawState; d.cullBackFaces === v || (d.cullBackFaces = v, ++this.stateVersion); }
745
+
746
+ /** The transform mirrors the draw, so the other winding is the front, set by drawMesh
747
+ * from each transform
748
+ * @return {boolean} */
749
+ get mirrored() { return this.drawState.mirrored; }
750
+ set mirrored(v) { const d = this.drawState; d.mirrored === v || (d.mirrored = v, ++this.stateVersion); }
751
+
752
+ /** Strength of the highlight where the sun and the Light3D objects reflect, 0 is none and
753
+ * 1 adds a light's full color at its brightest; shininess sets its size
754
+ * @return {number} */
755
+ get specular() { return this.drawState.specular; }
756
+ set specular(v) { const d = this.drawState; d.specular === v || (d.specular = v, ++this.stateVersion); }
757
+
758
+ /** The highlight's exponent, how small and sharp it is: 4 is broad like rubber, 100 sharp
759
+ * like polished metal; set from each object's shininess
760
+ * @return {number} */
761
+ get shininess() { return this.drawState.shininess; }
762
+ set shininess(v) { const d = this.drawState; d.shininess === v || (d.shininess = v, ++this.stateVersion); }
763
+
764
+ /** Normal map for the next draws, set from each object's normalMap
765
+ * @return {TextureInfo|undefined} */
766
+ get normalMap() { return this.drawState.normalMap; }
767
+ set normalMap(v) { const d = this.drawState; d.normalMap === v || (d.normalMap = v, ++this.stateVersion); }
768
+
769
+ /** How strongly the normal map bends the surface, set from each object's normalScale
770
+ * @return {number} */
771
+ get normalScale() { return this.drawState.normalScale; }
772
+ set normalScale(v) { const d = this.drawState; d.normalScale === v || (d.normalScale = v, ++this.stateVersion); }
773
+
774
+ /** How much the surface reflects the sky, 0 to 1, set from each object's reflectivity
775
+ * @return {number} */
776
+ get reflectivity() { return this.drawState.reflectivity; }
777
+ set reflectivity(v) { const d = this.drawState; d.reflectivity === v || (d.reflectivity = v, ++this.stateVersion); }
778
+
779
+ /** Emissive map for the next draws, set from each object's emissiveMap
780
+ * @return {TextureInfo|undefined} */
781
+ get emissiveMap() { return this.drawState.emissiveMap; }
782
+ set emissiveMap(v) { const d = this.drawState; d.emissiveMap === v || (d.emissiveMap = v, ++this.stateVersion); }
783
+
784
+ /** Multiplies the emissive map, set from each object's emissiveMapColor; compared by its rgb, so a Color
785
+ * changed in place is seen when it is set again, and undefined is white
786
+ * @return {Color} */
787
+ get emissiveMapColor() { return this.drawState.emissiveMapColor; }
788
+ set emissiveMapColor(v)
789
+ {
790
+ const d = this.drawState, c = v || WHITE;
791
+ d.emissiveMapColor = v;
792
+ if (c.r !== d.emissiveR || c.g !== d.emissiveG || c.b !== d.emissiveB)
793
+ d.emissiveR = c.r, d.emissiveG = c.g, d.emissiveB = c.b, ++this.stateVersion;
794
+ }
795
+
796
+ /** Custom Shader for the next draws, set from each object's shader; undefined draws
797
+ * with the plugin's own
798
+ * @return {Shader|undefined} */
799
+ get shader() { return this.drawState.shader; }
800
+ set shader(v) { const d = this.drawState; d.shader === v || (d.shader = v, ++this.stateVersion); }
801
+
802
+ /** Darken by the shadow map when shadows are on, turn it off for things that should
803
+ * stay lit inside a shadow
804
+ * @return {boolean} */
805
+ get receiveShadow() { return this.drawState.receiveShadow; }
806
+ set receiveShadow(v) { const d = this.drawState; d.receiveShadow === v || (d.receiveShadow = v, ++this.stateVersion); }
807
+
808
+ /** Keep texture pixels hard edged, no mipmaps and no blending between them, set per
809
+ * object by pixelated
810
+ * @return {boolean} */
811
+ get pixelated() { return this.drawState.pixelated; }
812
+ set pixelated(v) { const d = this.drawState; d.pixelated === v || (d.pixelated = v, ++this.stateVersion); }
813
+
814
+ /** Blending on, set by the stages
815
+ * @return {boolean} */
816
+ get blend() { return this.drawState.blend; }
817
+ set blend(v) { const d = this.drawState; d.blend === v || (d.blend = v, ++this.stateVersion); }
818
+
618
819
  ///////////////////////////////////////////////////////////////////////////
619
820
  // Matrices and picking
620
821
 
@@ -724,7 +925,8 @@ class Render3DPlugin
724
925
  }
725
926
 
726
927
  /** 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
928
+ * with a 2D position; a sound made with no range of its own is heard to soundDefaultRange, 100, and one given
929
+ * a range keeps it
728
930
  * @param {Sound} sound
729
931
  * @param {Vector3} pos3D
730
932
  * @param {number} [volume]
@@ -738,7 +940,9 @@ class Render3DPlugin
738
940
  // the 3D range fade and pan, Sound.play with no position does the rest
739
941
  ASSERT(sound instanceof Sound, 'sound must be a Sound');
740
942
  ASSERT(isVector3(pos3D), 'pos3D must be a vec3');
741
- const offset = pos3D.subtract(this.camera.pos), range = sound.range;
943
+ // a sound with no range of its own is heard further in 3D than in 2D
944
+ const offset = pos3D.subtract(this.camera.pos);
945
+ const range = sound.rangeIsDefault ? this.soundDefaultRange : sound.range;
742
946
  if (range)
743
947
  {
744
948
  const distance = offset.length();
@@ -802,15 +1006,15 @@ class Render3DPlugin
802
1006
  if (this.frustumCulling && !render3DSphereVisible(m[12], m[13], m[14], mesh.radius * render3DMaxStretch(m)))
803
1007
  return;
804
1008
  // the mesh says whether its back faces can be skipped, and a mirroring transform, one with a negative
805
- // determinant, turns the winding around so the other one is its front
806
- const cullBackFaces = this.cullBackFaces, mirrored = this.mirrored;
807
- this.cullBackFaces = !mesh.doubleSided;
808
- this.mirrored = render3DMirrors(matrix);
809
- // the stage draws the batch at its end
1009
+ // determinant, turns the winding around so the other one is its front; the stage draws a batch at its
1010
+ // end, which keys on those itself
810
1011
  if (!this.blend && this.depthTest && (mesh.instanced ?? this.instancing))
811
1012
  render3DInstance(mesh, matrix, tileInfo, color);
812
1013
  else
813
1014
  {
1015
+ const cullBackFaces = this.cullBackFaces, mirrored = this.mirrored;
1016
+ this.cullBackFaces = !mesh.doubleSided;
1017
+ this.mirrored = render3DMirrors(matrix);
814
1018
  this.flush();
815
1019
  render3DFlushBeforeOverlay();
816
1020
  render3DSetDrawUniforms(matrix, tileInfo, color);
@@ -818,8 +1022,8 @@ class Render3DPlugin
818
1022
  glContext.drawElements(glContext.TRIANGLES, mesh.bufferCount, mesh.indexType, 0);
819
1023
  ++drawCount;
820
1024
  primitiveCount += mesh.bufferCount / 3;
1025
+ this.cullBackFaces = cullBackFaces, this.mirrored = mirrored;
821
1026
  }
822
- this.cullBackFaces = cullBackFaces, this.mirrored = mirrored;
823
1027
  }
824
1028
 
825
1029
  /** Draw a triangle strip, batched into the stream with the current draw state
@@ -1007,13 +1211,31 @@ class Render3DPlugin
1007
1211
  // a far plane at Infinity has no midpoint, so put it a long way out instead
1008
1212
  const {near, far} = this.camera;
1009
1213
  const radius = far == Infinity ? near * 1e4 : (near + far) / 2;
1010
- render3DWithState({lighting: false, blend: false, depthTest: false, depthWrite: false, fogEnd: 0, shader: undefined}, ()=>
1214
+ render3DWithState({lighting: false, blend: false, depthTest: false, depthWrite: false, fogEnd: 0, shader: undefined,
1215
+ emissiveMap: undefined}, ()=>
1011
1216
  this.drawMesh(this.sky, buildMatrix(this.camera.pos, undefined, vec3(radius))));
1012
1217
  }
1013
1218
 
1014
1219
  /** Rebuild the light's view projection around the shadow center, called automatically each frame shadows are on */
1015
1220
  updateShadowMatrix()
1016
1221
  {
1222
+ const caster = render3DShadowCaster();
1223
+ if (caster)
1224
+ {
1225
+ // a spotlight's map looks down its cone with perspective, from just in front of it out to its radius
1226
+ const pos = caster.getWorldPos3D(), forward = caster.getForward3D();
1227
+ const up = abs(forward.y) > .99 ? vec3(0, 0, 1) : vec3(0, 1, 0);
1228
+ const far = caster.radius, near = max(far / 500, .02);
1229
+ const fov = min(min(caster.coneAngle, PI) * 2 + .1, 2.8);
1230
+ this.shadowMatrix = Matrix4.perspective(fov, 1, near, far).multiply(
1231
+ Matrix4.lookAt(pos, pos.add(forward), up).invert());
1232
+ this.shadowPlanes = render3DFrustumPlanes(this.shadowMatrix);
1233
+ // depth is not even with perspective: the lookup divides this by the distance squared, which makes
1234
+ // the bias the same distance in the world near the light and far from it
1235
+ this.shadowDepthBias = this.shadowBias * far * far * near / (far - near);
1236
+ return;
1237
+ }
1238
+ this.shadowDepthBias = this.shadowBias; // the sun's map is flat, its depth even
1017
1239
  ASSERT(this.shadowRange > 0, 'shadowRange must be positive');
1018
1240
  const range = this.shadowRange > 0 ? this.shadowRange : 1, half = range / 2;
1019
1241
  const toSun = this.sunDirection.normalize();
@@ -1132,8 +1354,10 @@ class Render3DPlugin
1132
1354
  drawQuad(a, b, c, d, tileInfo, color=WHITE)
1133
1355
  {
1134
1356
  if (this.transparentQueue) // the queue replays later, so it keeps copies of what a caller may reuse
1357
+ {
1135
1358
  a = a.copy(), b = b.copy(), c = c.copy(), d = d.copy();
1136
1359
  color = isArray(color) ? color.map(k=> k.copy()) : color.copy();
1360
+ }
1137
1361
  this.drawStrip(render3DQuadStrip(a, b, c, d), render3DFaceNormal(a, b, c, d), RENDER3D_QUAD_UVS,
1138
1362
  render3DQuadValues(color), tileInfo);
1139
1363
  }
@@ -1432,30 +1656,47 @@ const RENDER3D_SNIPPET_NAMES =
1432
1656
  // uniforms: lightDir (xyz the way the sunlight travels, w = emissive, 1 or more skips the lighting),
1433
1657
  // lightColor (the sun's rgb, a = specular), ambientFog (rgb, a = fogEnd), fogColor (rgb, a = fogStart),
1434
1658
  // cameraPos, tex, shadowMap, shadowParams (x = shadows on, y = bias, z = blur step in texture space,
1435
- // w = how the draw finishes: 1 opaque and alpha tested, 0 blended, -1 additive)
1659
+ // w = how the draw finishes: 1 opaque and alpha tested, 0 blended, -1 additive),
1660
+ // materialParams (x = normal map scale, 0 skips it, y = shininess, z = reflectivity), emissiveTint (rgb, a = the
1661
+ // emissive map is on), skyTop, skyHorizon, skyBottom (what a reflection shows), normalTex, emissiveTex
1436
1662
  function render3DFragmentSource(fragmentCode)
1437
1663
  {
1438
1664
  return '#version 300 es\n' +
1439
1665
  'precision highp float;' +
1440
1666
  'uniform vec4 lightDir,lightColor,ambientFog,ambientGround,fogColor,shadowParams;' +
1441
1667
  'uniform vec4 extraLights[' + RENDER3D_MAX_LIGHTS + '],extraLightColors[' + RENDER3D_MAX_LIGHTS + '];' +
1668
+ 'uniform vec4 extraLightCones[' + RENDER3D_MAX_LIGHTS + '];' +
1442
1669
  'uniform int extraLightCount;' +
1443
1670
  'uniform vec3 cameraPos;' +
1444
- 'uniform sampler2D tex;' +
1671
+ 'uniform vec4 materialParams,emissiveTint,skyTop,skyHorizon,skyBottom;' +
1672
+ 'uniform sampler2D tex,normalTex,emissiveTex;' +
1445
1673
  'uniform bool premultipliedTexture;' + // is the texture a render target, which holds premultiplied color
1446
1674
  'uniform highp sampler2DShadow shadowMap;' +
1447
1675
  'in vec3 P,N;in vec2 T,L;in vec4 C,S;' +
1448
1676
  'out vec4 o;' +
1449
- // the sun shadow at this fragment, 0 to 1: the light's depth map with a 3x3 blur, outside the map is lit
1677
+ // the shadow at this fragment, 0 to 1: the light's depth map with a 3x3 blur, outside the map is lit; the
1678
+ // sun's map is flat and S.w is 1, a spotlight's has perspective and S.w is the distance from it, where the
1679
+ // bias shrinks by its square to stay the same distance in the world
1450
1680
  'float shadow(){' +
1451
- 'if(shadowParams.x<=0.)return 1.;' +
1681
+ 'if(shadowParams.x<=0.||S.w<=0.)return 1.;' +
1452
1682
  'vec3 q=S.xyz/S.w*.5+.5;' +
1453
1683
  'if(any(greaterThanEqual(abs(q-.5),vec3(.5))))return 1.;' +
1454
- 'q.z-=shadowParams.y;' +
1684
+ 'q.z-=shadowParams.y/(S.w*S.w);' +
1455
1685
  'float s=0.;' +
1456
1686
  'for(int x=-1;x<=1;++x)for(int y=-1;y<=1;++y)' +
1457
1687
  's+=texture(shadowMap,vec3(q.xy+vec2(x,y)*shadowParams.z,q.z));' +
1458
1688
  'return s/9.;}' +
1689
+ // the normal map's normal here, in the frame that the position and texture coordinate change along across the
1690
+ // screen, so a mesh needs no tangents; green points up the image and v runs down it, so up is -v;
1691
+ // a mesh with no texture coordinates has no frame and keeps its own normal
1692
+ 'vec3 normalMapNormal(vec3 n){' +
1693
+ 'vec3 p1=dFdx(P),p2=dFdy(P);vec2 t1=dFdx(T),t2=dFdy(T);' +
1694
+ 'vec3 a=cross(p2,n),b=cross(n,p1),u=a*t1.x+b*t2.x,v=a*t1.y+b*t2.y;' +
1695
+ 'float k=max(dot(u,u),dot(v,v));' +
1696
+ 'if(k<=0.)return n;' +
1697
+ 'vec3 m=texture(normalTex,T).xyz*2.-1.;' +
1698
+ 'm.xy*=materialParams.x;' +
1699
+ 'return normalize((u*m.x-v*m.y)*inversesqrt(k)+n*m.z);}' +
1459
1700
  (fragmentCode ? RENDER3D_SNIPPET_NAMES + fragmentCode + '\n' : '') +
1460
1701
  'void main(){' +
1461
1702
  (fragmentCode ? 'vec4 t;mainImage(t,T);' : 'vec4 t=texture(tex,T);') +
@@ -1466,8 +1707,11 @@ function render3DFragmentSource(fragmentCode)
1466
1707
  'if(e<1.){' +
1467
1708
  'vec3 n=dot(N,N)>0.?normalize(N):vec3(0,1,0);' +
1468
1709
  'if(!gl_FrontFacing)n=-n;' + // only a double sided mesh shows a back face, light it on the side that is seen
1710
+ 'if(materialParams.x!=0.)n=normalMapNormal(n);' +
1469
1711
  'float nl=dot(n,-lightDir.xyz);' +
1470
- 'float s=shadow();' +
1712
+ // the shadow is the sun's, or one spotlight's: shadowParams.x is 1 for the sun, 2 and up for that Light3D
1713
+ 'float sh=shadow(),s=shadowParams.x<1.5?sh:1.;' +
1714
+ 'int si=int(shadowParams.x+.5)-2;' +
1471
1715
  // the ambient: one color, or blended from the ground color below to the sky color above by the way the face points
1472
1716
  'vec3 l=(ambientGround.a>0.?mix(ambientGround.rgb,ambientFog.rgb,n.y*.5+.5):ambientFog.rgb)+lightColor.rgb*max(nl,0.)*s;' +
1473
1717
  // the Light3D objects: a point light falls off with distance, a directional one does not and carries the
@@ -1483,16 +1727,30 @@ function render3DFragmentSource(fragmentCode)
1483
1727
  'v/=max(d,1e-6);' +
1484
1728
  'float ln=dot(n,v);' +
1485
1729
  'vec3 lc=extraLightColors[i].rgb*extraLightColors[i].a*a*a;' +
1730
+ // a spotlight's cone: full where its fade starts, nothing at its edge, smooth between; 1 with no cone
1731
+ 'vec4 K=extraLightCones[i];' +
1732
+ 'float k=clamp(dot(K.xyz,-v)-K.w,0.,1.);' +
1733
+ 'lc*=k*k*(3.-2.*k);' +
1734
+ 'if(i==si)lc*=sh;' +
1486
1735
  'l+=lc*max(0.,ln);' +
1487
- 'if(lightColor.a>0.)sp+=lc*pow(max(dot(reflect(-v,n),eye),0.),16.)*step(0.,ln);' +
1736
+ 'if(lightColor.a>0.)sp+=lc*pow(max(dot(reflect(-v,n),eye),0.),materialParams.y)*step(0.,ln);' +
1488
1737
  '}' +
1489
1738
  'c.rgb*=l*(1.-e)+e;' + // lit, blended toward its own color by how emissive it is
1490
1739
  // specular: the sun's only where its light hits and out of shadow, then the Light3D highlights,
1491
1740
  // skipped entirely when the strength is zero
1492
1741
  'if(lightColor.a>0.){' +
1493
1742
  'vec3 r=reflect(lightDir.xyz,n);' +
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);' +
1743
+ 'c.rgb+=lightColor.rgb*pow(max(dot(r,eye),0.),materialParams.y)*lightColor.a*step(0.,nl)*s*(1.-e)+sp*lightColor.a*(1.-e);' +
1744
+ '}' +
1745
+ // the sky along the reflected view, more at a glancing angle (Schlick's Fresnel); sky light, so the sun's
1746
+ // shadow does not dim it
1747
+ 'if(materialParams.z>0.){' +
1748
+ 'vec3 w=normalize(P-cameraPos),q=reflect(w,n);' +
1749
+ 'float f=materialParams.z+(1.-materialParams.z)*pow(1.-max(dot(n,-w),0.),5.);' +
1750
+ 'c.rgb=mix(c.rgb,q.y>0.?mix(skyHorizon.rgb,skyTop.rgb,q.y):mix(skyHorizon.rgb,skyBottom.rgb,-q.y),f);' +
1495
1751
  '}}else c.rgb*=e;' + // fully emissive: its own color, or brighter, with no lighting to work out
1752
+ // the emissive map adds its light on top, lit or not
1753
+ 'if(emissiveTint.a>0.)c.rgb+=texture(emissiveTex,T).rgb*emissiveTint.rgb;' +
1496
1754
  'if(ambientFog.a>0.){' +
1497
1755
  'float z=distance(cameraPos,P);' +
1498
1756
  'c.rgb=mix(c.rgb,shadowParams.w<0.?vec3(0):fogColor.rgb,smoothstep(fogColor.a,ambientFog.a,z));' +
@@ -1519,6 +1777,8 @@ function render3DUseProgram(program)
1519
1777
  gl.uniformMatrix4fv(render3DUniform('lightViewProj'), false, r.shadowMatrix.m);
1520
1778
  gl.uniform1i(render3DUniform('tex'), 0);
1521
1779
  gl.uniform1i(render3DUniform('shadowMap'), 1);
1780
+ gl.uniform1i(render3DUniform('normalTex'), 2);
1781
+ gl.uniform1i(render3DUniform('emissiveTex'), 3);
1522
1782
  const c = r.camera.pos;
1523
1783
  gl.uniform3f(render3DUniform('cameraPos'), c.x, c.y, c.z);
1524
1784
  gl.uniform1i(render3DUniform('extraLightCount'), r.lightCount);
@@ -1526,6 +1786,7 @@ function render3DUseProgram(program)
1526
1786
  {
1527
1787
  gl.uniform4fv(render3DUniform('extraLights'), r.lightPositions, 0, r.lightCount * 4);
1528
1788
  gl.uniform4fv(render3DUniform('extraLightColors'), r.lightColors, 0, r.lightCount * 4);
1789
+ gl.uniform4fv(render3DUniform('extraLightCones'), r.lightCones, 0, r.lightCount * 4);
1529
1790
  }
1530
1791
  if (program !== r.program)
1531
1792
  {
@@ -1611,6 +1872,8 @@ function render3DContextLost()
1611
1872
  render3DClearInstances();
1612
1873
  r.shadowFramebuffer = r.shadowTexture = undefined;
1613
1874
  r.shadowTextureSize = 0;
1875
+ r.cameraDepthFramebuffer = r.cameraDepthTexture = undefined;
1876
+ r.cameraDepthWidth = r.cameraDepthHeight = 0;
1614
1877
  r.streamCount = 0;
1615
1878
  ++r.contextGeneration; // every uploaded mesh is stale now, the soft dot is a TextureInfo the engine restores
1616
1879
  }
@@ -1664,18 +1927,19 @@ function render3DUpdateSamplers()
1664
1927
  r.samplers.clear();
1665
1928
  }
1666
1929
 
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)
1930
+ // the sampler for a texture's wrap modes, smooth or hard edged, made the first time it is needed; pixelated is the
1931
+ // draw's, texturePixelated the texture's own, which a smooth texture in a pixel art game sets false
1932
+ function render3DSampler(wrap, pixelated, texturePixelated=tilesPixelated)
1669
1933
  {
1670
1934
  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
1935
+ const key = wrapS * 1e5 + wrapT * 4 + (pixelated ? 1 : 0) + (texturePixelated ? 2 : 0); // the modes are 5 digit numbers
1672
1936
  let sampler = r.samplers.get(key);
1673
1937
  if (sampler) return sampler;
1674
1938
  sampler = gl.createSampler();
1675
- const sharp = pixelated || tilesPixelated;
1939
+ const sharp = pixelated || texturePixelated;
1676
1940
  gl.samplerParameteri(sampler, gl.TEXTURE_MAG_FILTER, sharp ? gl.NEAREST : gl.LINEAR);
1677
1941
  gl.samplerParameteri(sampler, gl.TEXTURE_MIN_FILTER, pixelated ? gl.NEAREST
1678
- : tilesPixelated ? gl.NEAREST_MIPMAP_LINEAR : gl.LINEAR_MIPMAP_LINEAR);
1942
+ : texturePixelated ? gl.NEAREST_MIPMAP_LINEAR : gl.LINEAR_MIPMAP_LINEAR);
1679
1943
  gl.samplerParameteri(sampler, gl.TEXTURE_WRAP_S, wrapS);
1680
1944
  gl.samplerParameteri(sampler, gl.TEXTURE_WRAP_T, wrapT);
1681
1945
  const anisotropy = gl.getExtension('EXT_texture_filter_anisotropic');
@@ -1689,25 +1953,68 @@ function render3DSampler(wrap, pixelated)
1689
1953
  }
1690
1954
 
1691
1955
  // bind the texture of a tile or texture, white when there is none or it is not loaded, with the 3D sampler that
1692
- // matches its wrap mode; the first time a texture is used in 3D it gets its mipmaps
1693
- function render3DBindTexture(tileInfo, state=render3D)
1956
+ // matches its wrap mode; the first time a texture is used in 3D it gets its mipmaps; on unit 0, the color texture,
1957
+ // or another unit, the material maps, after which unit 0 is active again
1958
+ function render3DBindTexture(tileInfo, state=render3D, unit=0)
1694
1959
  {
1695
1960
  const gl = glContext, r = render3D;
1696
1961
  const textureInfo = render3DTextureOf(tileInfo);
1697
1962
  const texture = textureInfo?.glTexture || r.whiteTexture;
1963
+ unit && gl.activeTexture(gl.TEXTURE0 + unit);
1698
1964
  gl.bindTexture(gl.TEXTURE_2D, texture);
1699
1965
  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
1966
+ gl.bindSampler(unit, null); // the texture's own filtering, as in 2D; the white texel needs no mipmaps
1967
+ // or anisotropy, and filtering it that way costs every untextured fragment
1968
+ else
1705
1969
  {
1706
- glMipmappedTextures.add(texture); // the core makes them again when the texture changes
1707
- gl.generateMipmap(gl.TEXTURE_2D);
1970
+ gl.bindSampler(unit, render3DSampler(textureInfo?.wrap, state.pixelated, textureInfo?.pixelated ?? tilesPixelated));
1971
+ glUpdateMipmaps(texture); // drawn into since its mipmaps were made
1972
+ if (!state.pixelated && !glMipmappedTextures.has(texture)) // a hard edged draw never reads them
1973
+ {
1974
+ glMipmappedTextures.add(texture); // the core makes them again when the texture changes
1975
+ gl.generateMipmap(gl.TEXTURE_2D);
1976
+ }
1977
+ }
1978
+ unit && gl.activeTexture(gl.TEXTURE0);
1979
+ }
1980
+
1981
+ // the material's uniforms and maps for a draw: the normal map on unit 2 and the emissive map on unit 3, white when
1982
+ // unused and bound only when they change, and the sky a reflection shows, the colors setSky was given or the
1983
+ // ambient ones; with nothing set every part of it is skipped in the shader, and a map with no GL texture yet, or
1984
+ // one freed, counts as none, since the white texture in its place would bend every normal or light the surface
1985
+ function render3DSetMaterialUniforms(state)
1986
+ {
1987
+ const r = render3D, loaded = (map)=> render3DTextureOf(map)?.glTexture ? map : undefined;
1988
+ const normalMap = state.normalScale ? loaded(state.normalMap) : undefined, emissiveMap = loaded(state.emissiveMap);
1989
+ render3DUniform4f('materialParams', normalMap ? state.normalScale : 0, state.shininess, state.reflectivity, 0);
1990
+ const ec = state.emissiveMapColor || WHITE;
1991
+ emissiveMap ? render3DUniform4f('emissiveTint', ec.r, ec.g, ec.b, 1) : render3DUniform4f('emissiveTint', 0, 0, 0, 0);
1992
+ render3DBindMap(2, normalMap, state);
1993
+ render3DBindMap(3, emissiveMap, state);
1994
+ if (state.reflectivity > 0)
1995
+ {
1996
+ const sky = r.sky && render3DSkyColors.get(r.sky), a = r.ambientColor, g = r.ambientGroundColor || a;
1997
+ const top = sky ? sky[0] : a, bottom = sky ? sky[2] : g;
1998
+ render3DUniform4f('skyTop', top.r, top.g, top.b, 1);
1999
+ render3DUniform4f('skyBottom', bottom.r, bottom.g, bottom.b, 1);
2000
+ sky ? render3DUniform4f('skyHorizon', sky[1].r, sky[1].g, sky[1].b, 1) :
2001
+ render3DUniform4f('skyHorizon', (a.r + g.r) / 2, (a.g + g.g) / 2, (a.b + g.b) / 2, 1);
1708
2002
  }
1709
2003
  }
1710
2004
 
2005
+ // what each material map unit has bound, index 2 and 3 the maps and 4 and 5 whether each is pixelated, forgotten at
2006
+ // the start and end of each pass
2007
+ let render3DBoundMaps = [];
2008
+
2009
+ // bind a material map to its unit, or white for none, only when it or its filtering changed since the last draw
2010
+ function render3DBindMap(unit, map, state)
2011
+ {
2012
+ const bound = render3DBoundMaps, pixelated = !!map && state.pixelated;
2013
+ if (bound[unit] === map && bound[unit + 2] === pixelated) return;
2014
+ bound[unit] = map, bound[unit + 2] = pixelated;
2015
+ render3DBindTexture(map, state, unit);
2016
+ }
2017
+
1711
2018
  // send a vec4 uniform of the main shader only when its value changed since the last send
1712
2019
  function render3DUniform4f(name, x, y, z, w)
1713
2020
  {
@@ -1840,11 +2147,14 @@ function render3DSetDrawUniforms(matrix, tileInfo, tint, uvRect, state=render3D)
1840
2147
  // 0 blends them away instead, and -1 is additive, which has to fade into fog differently
1841
2148
  const blendMode = state.blend ? (state.additive ? -1 : 0) : 1;
1842
2149
  const receives = r.shadows && r.passIsDefault && state.receiveShadow ? 1 : 0;
1843
- render3DUniform4f('shadowParams', receives, r.shadowBias, r.shadowSoftness / r.shadowTextureSize, blendMode);
2150
+ // 1 the sun's shadow, 2 and up the shadow of that Light3D, the spotlight that casts them
2151
+ const shadowOf = receives && r.shadowLightIndex >= 0 ? 2 + r.shadowLightIndex : receives;
2152
+ render3DUniform4f('shadowParams', shadowOf, r.shadowDepthBias, r.shadowSoftness / r.shadowTextureSize, blendMode);
1844
2153
 
1845
2154
  // a render target's texture holds premultiplied color, the blend writes it that way, so the shader undoes it
1846
2155
  const textureInfo = render3DTextureOf(tileInfo);
1847
2156
  render3DUniform1i('premultipliedTexture', +!!(textureInfo?.glTexture && glPremultipliedTextures.has(textureInfo.glTexture)));
2157
+ render3DSetMaterialUniforms(state);
1848
2158
  }
1849
2159
 
1850
2160
  // the six flat sides of the camera's visible box, each as [x, y, z, w] facing inward
@@ -1866,6 +2176,7 @@ function render3DFrustumPlanes(matrix)
1866
2176
  function render3DPreRender()
1867
2177
  {
1868
2178
  const r = render3D;
2179
+ editor3DCameraBegin(); // debug builds draw with the free camera or the 3D editor's, the game's is back after
1869
2180
  r.updateMatrices();
1870
2181
  r.shadowMapDrawn = false;
1871
2182
  render3DRenderPass(false);
@@ -1875,6 +2186,7 @@ function render3DPreRender()
1875
2186
  function render3DRender()
1876
2187
  {
1877
2188
  render3DRenderPass(true);
2189
+ editor3DCameraEnd();
1878
2190
  }
1879
2191
 
1880
2192
  // 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
@@ -1903,6 +2215,8 @@ function render3DRenderPass(after2D)
1903
2215
  // gathered before the gl state is taken over, so an assert here leaves nothing to hand back
1904
2216
  const lights = render3DCollectLights();
1905
2217
  r.lightCount = lights.length;
2218
+ const caster = render3DShadowCaster();
2219
+ r.shadowLightIndex = caster ? lights.indexOf(caster) : -1;
1906
2220
  const positions = r.lightPositions, colors = r.lightColors;
1907
2221
  lights.forEach((light, i)=>
1908
2222
  {
@@ -1913,6 +2227,7 @@ function render3DRenderPass(after2D)
1913
2227
  positions[k] = p.x, positions[k+1] = p.y, positions[k+2] = p.z;
1914
2228
  positions[k+3] = light.directional ? -1 : max(0, light.radius); // a negative radius marks a direction
1915
2229
  colors[k] = c.r, colors[k+1] = c.g, colors[k+2] = c.b, colors[k+3] = c.a * light.intensity;
2230
+ r.lightCones.set(render3DLightCone(light), k);
1916
2231
  });
1917
2232
 
1918
2233
  // take over the gl state
@@ -1924,7 +2239,8 @@ function render3DRenderPass(after2D)
1924
2239
  // may have left its own texture, which fails every draw; the shadow map goes back there even with shadows off
1925
2240
  gl.activeTexture(gl.TEXTURE1);
1926
2241
  gl.bindTexture(gl.TEXTURE_2D, r.shadowTexture || null);
1927
- gl.activeTexture(gl.TEXTURE0);
2242
+ // the material maps' units start white, a 2D plugin may have left its own textures there
2243
+ render3DSetMapUnits(r.whiteTexture);
1928
2244
  gl.depthMask(true);
1929
2245
  gl.clear(gl.DEPTH_BUFFER_BIT);
1930
2246
 
@@ -1937,6 +2253,8 @@ function render3DRenderPass(after2D)
1937
2253
  render3DRenderShadowMap();
1938
2254
  r.shadowMapDrawn = true;
1939
2255
  }
2256
+ // the camera's depth for post processing, from the default layer
2257
+ r.depthTexture && isDefault && render3DRenderDepth();
1940
2258
  render3DUseProgram(r.program); // after the shadow map, so the light matrix it sends is this frame's
1941
2259
  r.renderStages(objects, isDefault);
1942
2260
  }
@@ -1952,6 +2270,7 @@ function render3DRenderPass(after2D)
1952
2270
  gl.depthMask(true);
1953
2271
  gl.frontFace(gl.CCW);
1954
2272
  gl.bindSampler(0, null); // back to the textures' own filtering for 2D
2273
+ render3DSetMapUnits(null); // the material maps leave no texture or sampler behind for 2D
1955
2274
  if (glActiveTexture)
1956
2275
  gl.bindTexture(gl.TEXTURE_2D, glActiveTexture);
1957
2276
  // ARRAY_BUFFER is not part of VAO state in WebGL2, so bindVertexArray alone would not restore it
@@ -1960,11 +2279,27 @@ function render3DRenderPass(after2D)
1960
2279
  }
1961
2280
  }
1962
2281
 
2282
+ // put a texture on both material map units with no sampler, white at the start of the pass and none at its end, and
2283
+ // forget what the map cache thought was bound; unit 0 is active after
2284
+ function render3DSetMapUnits(texture)
2285
+ {
2286
+ const gl = glContext;
2287
+ for (const unit of [2, 3])
2288
+ {
2289
+ gl.activeTexture(gl.TEXTURE0 + unit);
2290
+ gl.bindTexture(gl.TEXTURE_2D, texture);
2291
+ gl.bindSampler(unit, null);
2292
+ }
2293
+ gl.activeTexture(gl.TEXTURE0);
2294
+ render3DBoundMaps = [];
2295
+ }
2296
+
1963
2297
  // create the shadow map depth texture and framebuffer at a size, or keep them when the size matches
1964
2298
  function render3DUpdateShadowMap(size)
1965
2299
  {
1966
2300
  const gl = glContext, r = render3D;
1967
2301
  ASSERT(size > 0, 'shadowMapSize must be positive');
2302
+ size = glClampTextureSize(size); // a size the device does not have falls back to the largest it has
1968
2303
  if (r.shadowTexture && r.shadowTextureSize === size) return;
1969
2304
  r.shadowTexture && gl.deleteTexture(r.shadowTexture);
1970
2305
  r.shadowFramebuffer && gl.deleteFramebuffer(r.shadowFramebuffer);
@@ -2036,6 +2371,65 @@ function render3DRenderShadowMap()
2036
2371
  }
2037
2372
  }
2038
2373
 
2374
+ // the camera's depth texture and its framebuffer at a size, made again when the canvas changes size; depth values
2375
+ // are not filtered, so it is read texel by texel
2376
+ function render3DUpdateCameraDepth(width, height)
2377
+ {
2378
+ const gl = glContext, r = render3D;
2379
+ if (r.cameraDepthTexture && r.cameraDepthWidth === width && r.cameraDepthHeight === height) return;
2380
+ r.cameraDepthTexture && gl.deleteTexture(r.cameraDepthTexture);
2381
+ r.cameraDepthFramebuffer && gl.deleteFramebuffer(r.cameraDepthFramebuffer);
2382
+ const texture = r.cameraDepthTexture = gl.createTexture();
2383
+ gl.bindTexture(gl.TEXTURE_2D, texture);
2384
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.DEPTH_COMPONENT24, width, height, 0, gl.DEPTH_COMPONENT, gl.UNSIGNED_INT, null);
2385
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
2386
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
2387
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
2388
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
2389
+ gl.bindTexture(gl.TEXTURE_2D, null);
2390
+ const framebuffer = r.cameraDepthFramebuffer = gl.createFramebuffer();
2391
+ gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
2392
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, texture, 0);
2393
+ gl.drawBuffers([gl.NONE]); // depth only
2394
+ gl.readBuffer(gl.NONE);
2395
+ ASSERT(gl.checkFramebufferStatus(gl.FRAMEBUFFER) == gl.FRAMEBUFFER_COMPLETE, 'camera depth framebuffer is incomplete');
2396
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
2397
+ r.cameraDepthWidth = width, r.cameraDepthHeight = height;
2398
+ }
2399
+
2400
+ // draw the camera's depth for post processing: the solid objects from the camera with the shadow map's depth only
2401
+ // shader, into a texture the size of the canvas; what is see through is cut by its alpha as it is for shadows
2402
+ function render3DRenderDepth()
2403
+ {
2404
+ const gl = glContext, r = render3D, width = glCanvas.width, height = glCanvas.height;
2405
+ render3DUpdateCameraDepth(width, height);
2406
+ gl.bindFramebuffer(gl.FRAMEBUFFER, r.cameraDepthFramebuffer);
2407
+ gl.viewport(0, 0, width, height);
2408
+ gl.clear(gl.DEPTH_BUFFER_BIT);
2409
+ gl.useProgram(r.shadowShader);
2410
+ gl.uniformMatrix4fv(render3DUniform('viewProj', r.shadowShader), false, r.viewProjection.m);
2411
+ gl.enable(gl.DEPTH_TEST);
2412
+ gl.depthMask(true);
2413
+ gl.disable(gl.BLEND);
2414
+ gl.disable(gl.CULL_FACE);
2415
+
2416
+ r.shadowPass = r.depthPass = true;
2417
+ try
2418
+ {
2419
+ const solids = render3DLayerObjects(!!r.renderAfter2D).filter(o=> !o.additive && (!o.transparent || o.tileInfo));
2420
+ render3DDrawObjects(solids);
2421
+ r.onRenderOpaque?.();
2422
+ r.flush();
2423
+ render3DFlushInstances();
2424
+ }
2425
+ finally
2426
+ {
2427
+ r.shadowPass = r.depthPass = false;
2428
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
2429
+ gl.viewport(0, 0, width, height);
2430
+ }
2431
+ }
2432
+
2039
2433
  ///////////////////////////////////////////////////////////////////////////////
2040
2434
  // Strips: every strip repeats its first point once at the start and its last
2041
2435
  // point once at the end. Those repeats make flat triangles with no area, which
@@ -2189,18 +2583,26 @@ function render3DStripTriangles(count, remap, place)
2189
2583
  }
2190
2584
 
2191
2585
  // 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
2586
+ // written into data when given, the buffer an earlier call returned for the same layout; it measures the mesh's
2587
+ // radius and box as it goes, as computeRadius does, over the vertices once instead of every strip entry
2193
2588
  function render3DMeshVertexData(mesh, vertices, data=new ArrayBuffer(vertices.length * RENDER3D_VERTEX_BYTES))
2194
2589
  {
2195
2590
  const count = vertices.length;
2196
2591
  const floats = new Float32Array(data), ints = new Uint32Array(data);
2592
+ let r = 0, x0 = Infinity, y0 = Infinity, z0 = Infinity, x1 = -Infinity, y1 = -Infinity, z1 = -Infinity;
2197
2593
  for (let j = 0; j < count; ++j)
2198
2594
  {
2199
2595
  // a hand built mesh may leave normals, uvs and colors empty
2200
2596
  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,
2597
+ const x = p.x, y = p.y, z = p.z;
2598
+ render3DWriteVertex(floats, ints, j * RENDER3D_VERTEX_FLOATS, x, y, z,
2202
2599
  mesh.normals[i] || RENDER3D_DEFAULT_NORMAL, uv.x, uv.y, (mesh.colors[i] || WHITE).rgbaInt());
2600
+ r = max(r, x*x + y*y + z*z);
2601
+ x0 = min(x0, x), y0 = min(y0, y), z0 = min(z0, z);
2602
+ x1 = max(x1, x), y1 = max(y1, y), z1 = max(z1, z);
2203
2603
  }
2604
+ mesh.radius = r ** .5;
2605
+ mesh.bounds = count ? {min: vec3(x0, y0, z0), max: vec3(x1, y1, z1)} : {min: vec3(), max: vec3()};
2204
2606
  return data;
2205
2607
  }
2206
2608
 
@@ -2712,8 +3114,8 @@ class Mesh
2712
3114
  * @return {Mesh} */
2713
3115
  upload()
2714
3116
  {
2715
- this.computeRadius();
2716
- if (!render3D?.program || !glContext) return this;
3117
+ // the packing below measures the radius and box, with nothing to pack to they are measured here
3118
+ if (!render3D?.program || !glContext) return this.computeRadius(), this;
2717
3119
  const gl = glContext, layout = this.vertexLayout;
2718
3120
  // no layout when dynamicDraw was turned on after an upload, the next upload makes one
2719
3121
  if (this.dynamicDraw && layout && this.buffer && this.contextGeneration === render3D.contextGeneration)
@@ -3215,25 +3617,30 @@ function buildSky(topColor=hsl(.6, .8, .55), horizonColor=hsl(.6, 1, .9), bottom
3215
3617
  // a sphere turned inside out so it is seen from within, each point colored by how high it is
3216
3618
  const mesh = buildSphere(2, sides, rings, true).flipNormals();
3217
3619
  mesh.colors = mesh.points.map(p=> p.y < 0 ? horizonColor.lerp(bottomColor, -p.y) : horizonColor.lerp(topColor, p.y));
3620
+ render3DSkyColors.set(mesh, [topColor.copy(), horizonColor.copy(), bottomColor.copy()]);
3218
3621
  return mesh;
3219
3622
  }
3220
3623
 
3624
+ // the top, horizon and bottom colors of each sky dome buildSky made, which a reflection shows while that dome is
3625
+ // render3D.sky, whether setSky set it or the game did
3626
+ const render3DSkyColors = new WeakMap;
3627
+
3221
3628
  ///////////////////////////////////////////////////////////////////////////////
3222
3629
  /**
3223
3630
  * EngineObject3D - An EngineObject with a 3D transform and a mesh
3224
3631
  * - Set pos3D, rotation3D and scale3D instead of the 2D pos, size and angle
3225
3632
  * - Gets update, children, timers, destroy and renderOrder from EngineObject
3226
- * - velocity3D is added to pos3D each frame, along with render3D.gravity and damping once it has a mass
3633
+ * - velocity3D is added to pos3D each frame, slowed by damping, and render3D.gravity pulls it once it has a mass
3227
3634
  * - Objects face -Z, the same way the camera does, so lookAt turns them to face a point
3228
3635
  * - The 2D pos and velocity are still there but nothing draws them
3229
- * - These inherited fields are 2D only and do nothing here: angle, angleVelocity, angleDamping,
3230
- * additiveColor, drawSize, mirror, clampSpeed, friction and groundObject
3636
+ * - These inherited fields are 2D only and do nothing here: angle, angleVelocity, additiveColor, drawSize and mirror;
3637
+ * damping, angleDamping, clampSpeed, friction and groundObject work as in 2D, on velocity3D and angleVelocity3D
3231
3638
  * - The inherited shader works here as in 2D, and with emissive at 1 its snippet does its own lighting
3232
3639
  * - Set sync2D for a 2D game with 3D looks, pos and angle then drive pos3D and rotation3D,
3233
3640
  * which is the one way those 2D fields reach a 3D object
3234
3641
  * - 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
3642
+ * - The solid box turns with rotation3D, so a turned wall or a ramp collides as it looks; resting on one no steeper
3643
+ * than groundAngle stands there
3237
3644
  * - Its tile and raycast halves are 2D only so they default off here, and a child sits solid collision out
3238
3645
  * - A sync2D object collides in 2D instead, which needs the 2D size set as well as size3D
3239
3646
  * - setMesh swaps the mesh and frees the old one, for text and terrain that get built again
@@ -3281,7 +3688,7 @@ class EngineObject3D extends EngineObject
3281
3688
  /** @property {Vector3} - Added to pos3D each frame by the engine before update, like the 2D velocity, no super
3282
3689
  * call needed; damping and render3D.gravity act on it once the object has a mass */
3283
3690
  this.velocity3D = vec3();
3284
- /** @property {Vector3} - Added to rotation3D each frame by the engine before update, angleDamping is 2D only */
3691
+ /** @property {Vector3} - Added to rotation3D each frame by the engine before update, slowed by angleDamping */
3285
3692
  this.angleVelocity3D = vec3();
3286
3693
  /** @property {Mesh|undefined} - Mesh to draw
3287
3694
  * @type {Mesh|undefined} */
@@ -3312,8 +3719,27 @@ class EngineObject3D extends EngineObject
3312
3719
  * lamps and glowing things, between is partly self lit, and above 1 is brighter than its color, for bloom */
3313
3720
  this.emissive = 0;
3314
3721
  /** @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 */
3722
+ * 1 adds a light's full color at its brightest; shininess sets its size */
3316
3723
  this.specular = 0;
3724
+ /** @property {number} - The highlight's exponent, how small and sharp it is: 4 is broad like rubber, 16 the
3725
+ * default, 100 sharp like polished metal; shows only with specular above 0 */
3726
+ this.shininess = 16;
3727
+ /** @property {TextureInfo|undefined} - A normal map that bends the surface at each texel so it catches the
3728
+ * light like bumps and grooves, green pointing up the image as OpenGL and glTF have it; read at the color
3729
+ * texture's coordinates, see normalMapFromHeight to make one in code
3730
+ * @type {TextureInfo|undefined} */
3731
+ this.normalMap = undefined;
3732
+ /** @property {number} - How strongly the normal map bends the surface, 0 turns it off, as glTF's scale */
3733
+ this.normalScale = 1;
3734
+ /** @property {number} - How much it reflects the sky, 0 none and 1 a mirror of it; the edges seen at a
3735
+ * glancing angle reflect more either way, as water and glass do */
3736
+ this.reflectivity = 0;
3737
+ /** @property {TextureInfo|undefined} - A texture of where it glows, added on top of the lit surface so it
3738
+ * shows in the dark, like lit windows; read at the color texture's coordinates
3739
+ * @type {TextureInfo|undefined} */
3740
+ this.emissiveMap = undefined;
3741
+ /** @property {Color} - Multiplies the emissive map, as glTF's emissiveFactor */
3742
+ this.emissiveMapColor = WHITE;
3317
3743
  /** @property {boolean} - Draw into the shadow map when render3D.shadows is on; sprites and cut out textures
3318
3744
  * cast their outline, an object faded below half its alpha casts nothing, a see through one casts only when
3319
3745
  * textured, and additive objects never cast */
@@ -3321,6 +3747,9 @@ class EngineObject3D extends EngineObject
3321
3747
  /** @property {boolean} - Collide as the sphere that fits size3D instead of as the size3D box, so it rolls
3322
3748
  * around corners */
3323
3749
  this.collideAsSphere3D = false;
3750
+ /** @property {number} - The steepest slope it stands on, in radians from level, PI/4 by default: resting on
3751
+ * a solid within this of flat sets groundObject and holds it still, steeper it slides down */
3752
+ this.groundAngle = PI / 4;
3324
3753
  /** @property {boolean} - Darkened by the shadow map when render3D.shadows is on */
3325
3754
  this.receiveShadow = true;
3326
3755
  /** @property {boolean|undefined} - Draw this object over the 2D scene, undefined uses render3D.renderAfter2D
@@ -3351,10 +3780,32 @@ class EngineObject3D extends EngineObject
3351
3780
  'a sync2D object collides in 2D, so give it a 2D size as well as a size3D', this.size);
3352
3781
  if (this.sync2D)
3353
3782
  super.updatePhysics();
3783
+ ASSERT(isNumber(this.groundAngle) && this.groundAngle >= 0 && this.groundAngle < PI / 2,
3784
+ 'groundAngle must be 0 to less than PI/2, a slope from level', this.groundAngle);
3785
+ // what it stands on is found again each frame, by the level and by the solids it rests on; a sync2D
3786
+ // object's is the 2D physics'
3787
+ const ground = this.groundObject;
3788
+ this.sync2D || (this.groundObject = undefined);
3789
+ if (this.clampSpeed && !this.sync2D && (this.collideSolidObjects || this.collideLevel && this.mass))
3790
+ {
3791
+ // each axis within objectMaxSpeed, as in 2D, so a fast object does not pass through a thin wall; only
3792
+ // for what collides, anything else moves as fast as it is told
3793
+ const v = this.velocity3D, s = objectMaxSpeed;
3794
+ v.x = clamp(v.x, -s, s), v.y = clamp(v.y, -s, s), v.z = clamp(v.z, -s, s);
3795
+ }
3354
3796
  // a moving object keeps out of the level, the height maps and voxel maps, from where it was before it moved
3355
3797
  const oldPos = this.collideLevel && this.mass && !this.sync2D ? this.pos3D.copy() : undefined;
3356
3798
  render3DMove(this);
3357
- oldPos && render3DCollideLevel(this, oldPos);
3799
+ if (ground && this.mass && !this.sync2D)
3800
+ {
3801
+ // sliding on what it stood on slows by friction, the less grippy of the two, relative to that one's own
3802
+ // speed so a moving platform carries it, as in 2D
3803
+ const friction = max(this.friction, ground.friction), v = this.velocity3D;
3804
+ const moving = ground instanceof EngineObject3D ? ground.velocity3D : undefined;
3805
+ const gx = moving?.x ?? 0, gz = moving?.z ?? 0;
3806
+ v.x = gx + (v.x - gx) * friction, v.z = gz + (v.z - gz) * friction;
3807
+ }
3808
+ oldPos && render3DCollideLevel(this, oldPos, ground);
3358
3809
  // the engine only runs this for objects that own where they are, a child rides along with its parent
3359
3810
  if (this.collideSolidObjects && !this.sync2D)
3360
3811
  render3DCollideSolid(this);
@@ -3387,7 +3838,7 @@ class EngineObject3D extends EngineObject
3387
3838
 
3388
3839
  /** Set how this object collides, the same flags as in 2D
3389
3840
  * - 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
3841
+ * - The boxes turn with rotation3D, against height maps and voxel maps an object is still its upright box
3391
3842
  * - A sync2D object collides in 2D instead, against the 2D size, so set that as well as size3D
3392
3843
  * @param {boolean} [collideSolidObjects] - Take part in solid collision
3393
3844
  * @param {boolean} [isSolid] - Block other objects, a pair where neither one blocks passes through;
@@ -3575,27 +4026,29 @@ function render3DObjectMatrix(o)
3575
4026
  return o.worldMatrix;
3576
4027
  }
3577
4028
 
3578
- // move an object by its 3D velocities, an object with mass falling with render3D.gravity and slowing by its damping
4029
+ // move an object by its 3D velocities, each slowed by its damping, an object with mass falling with render3D.gravity
3579
4030
  function render3DMove(o)
3580
4031
  {
3581
4032
  // the vectors change in place, as the 2D object's do: this runs for every object every frame
4033
+ // a sync2D object's damping is the 2D physics', its 3D velocities are its own to set
3582
4034
  const p = o.pos3D, v = o.velocity3D, r = o.rotation3D, a = o.angleVelocity3D;
4035
+ const d = o.sync2D ? 1 : o.damping, e = o.sync2D ? 1 : o.angleDamping;
4036
+ // damped first and gravity added after, the order EngineObject.updatePhysics uses,
4037
+ // so the same mass, damping and gravity fall the same way in both
4038
+ v.x *= d, v.y *= d, v.z *= d;
3583
4039
  if (o.mass && !o.sync2D) // a 2D driven object gets the 2D gravity instead
3584
4040
  {
3585
- // damped first and gravity added after, the order EngineObject.updatePhysics uses,
3586
- // so the same mass, damping and gravity fall the same way in both
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;
4041
+ const g = render3D.gravity, s = o.gravityScale;
4042
+ v.x += g.x * s, v.y += g.y * s, v.z += g.z * s;
3589
4043
  }
3590
4044
  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;
4045
+ r.x += a.x *= e, r.y += a.y *= e, r.z += a.z *= e;
3592
4046
  }
3593
4047
 
3594
4048
  // 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)
4049
+ // ground is what it stood on last frame, which a height map keeps it on going down a slope
4050
+ function render3DCollideLevel(o, oldPos, ground)
3596
4051
  {
3597
- const ground = o.groundObject;
3598
- o.groundObject = undefined;
3599
4052
  for (const level of render3DLevel)
3600
4053
  level.destroyed || level.levelCollide3D(o, oldPos, level === ground);
3601
4054
  }
@@ -3612,7 +4065,23 @@ function render3DSolidShape(o)
3612
4065
  const kx = abs(k.x), ky = abs(k.y), kz = abs(k.z);
3613
4066
  if (o.collideAsSphere3D)
3614
4067
  return {pos: o.pos3D.copy(), radius: max(s.x, s.y, s.z) / 2 * max(kx, ky, kz)};
3615
- return {pos: o.pos3D.copy(), size: vec3(s.x * kx, s.y * ky, s.z * kz)};
4068
+ // a turned box has its axes, a solid is never a child so its rotation is the world's; an upright one has none, and
4069
+ // neither does a sprite, whose rotation turns how it faces the camera, not its box
4070
+ const axes = !render3DIsSprite(o) && isTurned3D(o.rotation3D) ? render3DSolidAxes(o) : undefined;
4071
+ return {pos: o.pos3D.copy(), size: vec3(s.x * kx, s.y * ky, s.z * kz), axes};
4072
+ }
4073
+
4074
+ // a turned solid's three axes, worked out when its rotation changes and kept until then, so the pairs it is tested
4075
+ // against each frame do not each work them out again
4076
+ const render3DSolidAxesCache = new WeakMap;
4077
+ function render3DSolidAxes(o)
4078
+ {
4079
+ const r = o.rotation3D, kept = render3DSolidAxesCache.get(o);
4080
+ if (kept && kept.x === r.x && kept.y === r.y && kept.z === r.z)
4081
+ return kept.axes;
4082
+ const axes = boxAxes3D(r);
4083
+ render3DSolidAxesCache.set(o, {x: r.x, y: r.y, z: r.z, axes});
4084
+ return axes;
3616
4085
  }
3617
4086
 
3618
4087
  // how far a solid shape can reach from its own center, for a quick reject before the exact test
@@ -3630,15 +4099,30 @@ function render3DSolidReach(o)
3630
4099
  // what it takes to move shape a clear of shape b, whichever pair of shapes they are, or undefined for no touch
3631
4100
  function render3DSolidPush(a, b)
3632
4101
  {
4102
+ // a turned box is tested by its kept axes, the upright pairs as they always were
4103
+ const sphereBox = (sphere, box)=> box.axes ? collideSphereOrientedBox3D(sphere.pos, sphere.radius, box.pos,
4104
+ box.size, box.axes) : collideSphereBox(sphere.pos, sphere.radius, box.pos, box.size);
3633
4105
  if (!a.size) // a is a sphere
3634
- return b.size ? collideSphereBox(a.pos, a.radius, b.pos, b.size)
3635
- : collideSphereSphere(a.pos, a.radius, b.pos, b.radius);
4106
+ return b.size ? sphereBox(a, b) : collideSphereSphere(a.pos, a.radius, b.pos, b.radius);
3636
4107
  if (!b.size) // only b is, so push b out of a and turn it around
3637
4108
  {
3638
- const push = collideSphereBox(b.pos, b.radius, a.pos, a.size);
4109
+ const push = sphereBox(b, a);
3639
4110
  return push && push.scale(-1);
3640
4111
  }
3641
- return collideBoxBox3D(a.pos, a.size, b.pos, b.size);
4112
+ return a.axes || b.axes ? collideOrientedBoxes3D(a.pos, a.size, a.axes ?? BOX_WORLD_AXES, b.pos, b.size,
4113
+ b.axes ?? BOX_WORLD_AXES) : collideBoxBox3D(a.pos, a.size, b.pos, b.size);
4114
+ }
4115
+
4116
+ // a sprite, a tile with no mesh, which faces the camera however it is turned
4117
+ const render3DIsSprite = (o)=> !o.mesh && !!o.tileInfo;
4118
+
4119
+ // whether a push leaves a box by one of its faces, along one of its axes, not by an edge or a corner; a sphere has
4120
+ // no face to stand on
4121
+ function render3DOnFace(push, shape)
4122
+ {
4123
+ if (!shape.size) return false;
4124
+ const n = push.normalize(), axes = shape.axes ?? BOX_WORLD_AXES;
4125
+ return max(abs(n.dot(axes[0])), abs(n.dot(axes[1])), abs(n.dot(axes[2]))) > 1 - 1e-6;
3642
4126
  }
3643
4127
 
3644
4128
  // push a solid object out of the solids before it in the engine's list of them, so each pair is resolved once:
@@ -3664,7 +4148,8 @@ function render3DCollideSolid(a)
3664
4148
  if (dx*dx + dy*dy + dz*dz > reach*reach)
3665
4149
  continue;
3666
4150
 
3667
- const push = render3DSolidPush(shapeA, render3DSolidShape(b));
4151
+ const shapeB = render3DSolidShape(b);
4152
+ let push = render3DSolidPush(shapeA, shapeB);
3668
4153
  if (!push) continue;
3669
4154
 
3670
4155
  // both objects hear about it, and either one can take the touch over
@@ -3672,6 +4157,19 @@ function render3DCollideSolid(a)
3672
4157
  const resolveB = b.collideWithObject(a, push.scale(-1));
3673
4158
  if (!resolveA || !resolveB) continue;
3674
4159
 
4160
+ // standing: resting on a box's face within the upper one's groundAngle of level holds it there, the push
4161
+ // turned straight up, as far as it takes to leave the surface, so it does not creep down a ramp; only what
4162
+ // moves stands, and a sphere, an edge or a corner is nothing to stand on, what rests there rolls off
4163
+ const lengthSquared = push.lengthSquared(), up = push.y / lengthSquared ** .5;
4164
+ const aStands = up > 0 && a.mass && up >= cos(a.groundAngle) && render3DOnFace(push, shapeB);
4165
+ const bStands = up < 0 && b.mass && -up >= cos(b.groundAngle) && render3DOnFace(push, shapeA);
4166
+ if (aStands || bStands)
4167
+ {
4168
+ aStands ? a.groundObject = b : b.groundObject = a;
4169
+ if (push.x || push.z) // one straight up already is used as it is
4170
+ push = vec3(0, lengthSquared / push.y, 0);
4171
+ }
4172
+
3675
4173
  // heavier objects move less, mass 0 stays put; then bounce apart when moving toward each other
3676
4174
  const total = a.mass + b.mass;
3677
4175
  const weightA = !a.mass ? 0 : !b.mass ? 1 : b.mass / total;
@@ -3692,8 +4190,8 @@ function render3DCollideSolid(a)
3692
4190
 
3693
4191
  /**
3694
4192
  * Collect the EngineObject3D objects whose boxes overlap a sphere or a box, the 3D twin of engineObjectsCollect
3695
- * - Boxes are axis aligned around the world position, rotation3D is ignored; lights, emitters and trails have no size
3696
- * and are never collected
4193
+ * - Each object is its size3D box at its world position, turned as it is turned; lights, emitters and trails have no
4194
+ * size and are never collected
3697
4195
  * @param {Vector3} pos - Center of the area
3698
4196
  * @param {Vector3|number} size - Diameter of a sphere if a number, 0 for a point, full size of a box if a Vector3
3699
4197
  * @param {Array<EngineObject>} [objects] - Defaults to every object
@@ -3715,9 +4213,27 @@ function engineObjectsCollect3D(pos, size, objects=engineObjects, testCenters=fa
3715
4213
  if (!(s.x || s.y || s.z)) continue;
3716
4214
  const center = vec3(m[12], m[13], m[14]);
3717
4215
  const worldSize = testCenters ? vec3() : render3DWorldSize(o, m);
4216
+ // a turned box is tested as turned, its axes read off the matrix, its columns with the scale taken out
4217
+ const turned = !testCenters && !render3DIsSprite(o) && (m[1] || m[2] || m[4] || m[6] || m[8] || m[9]);
4218
+ const axis = (i)=> vec3(m[i], m[i+1], m[i+2]).normalize();
4219
+ let axes = turned ? [axis(0), axis(4), axis(8)] : undefined;
4220
+ if (axes && (abs(axes[0].dot(axes[1])) > 1e-6 || abs(axes[0].dot(axes[2])) > 1e-6 ||
4221
+ abs(axes[1].dot(axes[2])) > 1e-6))
4222
+ {
4223
+ // a turned child of an unevenly scaled parent is sheared, its edges no longer square, which the box
4224
+ // tests do not take; the upright box around it stands in, which may take a little more but never misses
4225
+ const e = axes.map((a, i)=> a.scale(i ? i > 1 ? worldSize.z / 2 : worldSize.y / 2 : worldSize.x / 2));
4226
+ const reach = (c)=> 2 * (abs(e[0][c]) + abs(e[1][c]) + abs(e[2][c]));
4227
+ worldSize.set(reach('x'), reach('y'), reach('z'));
4228
+ axes = undefined;
4229
+ }
3718
4230
  let hit;
3719
- if (box)
4231
+ if (box && axes)
4232
+ hit = !!collideOrientedBoxes3D(pos, box, BOX_WORLD_AXES, center, worldSize, axes);
4233
+ else if (box)
3720
4234
  hit = isOverlapping3D(pos, box, center, worldSize);
4235
+ else if (axes)
4236
+ hit = !!collideSphereOrientedBox3D(pos, radiusSquared ** .5, center, worldSize, axes);
3721
4237
  else
3722
4238
  {
3723
4239
  // a sphere against the nearest point of the box
@@ -3836,20 +4352,24 @@ function engineObjectsCallback3D(pos, size, callback, objects=engineObjects, tes
3836
4352
  * InstancedMesh3D - Many copies of one mesh drawn as one call, with their transforms kept on the GPU
3837
4353
  * - For big sets that mostly stay put: an instance costs nothing per frame until it changes, so a hundred thousand
3838
4354
  * 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
4355
+ * - setTransformAt, setMatrixAt and setColorAt change one instance, and only the changed range uploads before the
4356
+ * next draw; the matrices and the colors are kept apart, so moving instances uploads 64 bytes each
4357
+ * - For many instances moving every frame, setTransforms places a run of them from arrays of positions, rotations
4358
+ * and scales in one loop, the fastest way; or write matrixData directly and call markDirtyRange
3840
4359
  * - 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
4360
+ * - The whole set is culled by one bounding sphere around the origin, worked out when culling reads it, or set by hand
4361
+ * with radius; it casts and receives shadows like any object
3842
4362
  * - The object's flags cover the whole set, one emissive, one tileInfo, one shader; only the colors are per instance
3843
4363
  * - A mirrored instance, one with a negative scale, shows its inside unless the mesh is doubleSided
3844
4364
  * - A transparent set draws in one go in the transparent stage, its instances are not sorted against each other;
3845
4365
  * 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
4366
+ * - pick, the raycast and the collect helpers do not see the instances, test them yourself from matrixData
3847
4367
  * @extends EngineObject3D
3848
4368
  * @memberof Render3D
3849
4369
  * @example
3850
4370
  * const forest = new InstancedMesh3D(treeMesh, 1000);
3851
4371
  * for (let i = 0; i < 1000; ++i)
3852
- * forest.setMatrixAt(i, buildMatrix(randomGroundPos(), vec3(0, rand(2*PI), 0)));
4372
+ * forest.setTransformAt(i, randomGroundPos(), vec3(0, rand(2*PI), 0));
3853
4373
  */
3854
4374
  class InstancedMesh3D extends EngineObject3D
3855
4375
  {
@@ -3868,29 +4388,101 @@ class InstancedMesh3D extends EngineObject3D
3868
4388
  this.count = count;
3869
4389
  /** @property {number} - How many instances it was made with */
3870
4390
  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 */
4391
+ /** @property {Float32Array} - Each instance's matrix, 16 floats, what moving them uploads; edit it directly and
4392
+ * call markDirty for the instances changed */
4393
+ this.matrixData = new Float32Array(count * RENDER3D_MATRIX_FLOATS);
4394
+ /** @property {Float32Array} - Each instance's color and uv rect, 8 floats, uploaded only when they change; edit
4395
+ * it directly and call markColorDirty for the instances changed */
4396
+ this.colorData = new Float32Array(count * RENDER3D_COLOR_FLOATS);
4397
+ /** @property {number} - First instance whose matrix uploads before the next draw */
3880
4398
  this.dirtyStart = 0;
3881
- /** @property {number} - One past the last instance to upload, so nothing uploads when it is not past dirtyStart */
4399
+ /** @property {number} - One past the last instance whose matrix uploads, nothing when it is not past dirtyStart */
3882
4400
  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;
4401
+ /** @property {number} - First instance whose color uploads before the next draw */
4402
+ this.colorDirtyStart = 0;
4403
+ /** @property {number} - One past the last instance whose color uploads */
4404
+ this.colorDirtyEnd = count;
4405
+ // the farthest any instance's position has been from the origin, and the largest scale any has had, both
4406
+ // squared, and the instances set since radius last took them in: setting one does no bounds work at all
4407
+ this.reachSquared = 0;
4408
+ this.scaleSquared = 0;
4409
+ this.boundsStart = 0;
4410
+ this.boundsEnd = count;
4411
+ /** @type {number|undefined} */
4412
+ this.fixedRadius = undefined; // a radius set by hand, see radius
4413
+ this.buffer = undefined; // the GPU copy of matrixData
4414
+ /** @type {WebGLBuffer|undefined} */
4415
+ this.colorBuffer = undefined; // and of colorData
4416
+ this.bufferGeneration = -1; // the context they were made under
4417
+ this.uvTileInfo = tileInfo; // the tile the uv rects were written for
4418
+ const uv = render3DGetTileUVs(tileInfo), matrices = this.matrixData, colors = this.colorData;
3887
4419
  for (let i = 0; i < count; ++i)
3888
4420
  {
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;
4421
+ matrices.set(RENDER3D_IDENTITY.m, i * RENDER3D_MATRIX_FLOATS);
4422
+ const k = i * RENDER3D_COLOR_FLOATS;
4423
+ colors[k] = color.r; colors[k+1] = color.g; colors[k+2] = color.b; colors[k+3] = color.a;
4424
+ colors[k+4] = uv.x; colors[k+5] = uv.y; colors[k+6] = uv.w; colors[k+7] = uv.h;
4425
+ }
4426
+ }
4427
+
4428
+ /** Place an instance by its position, rotation and scale, what buildMatrix takes, written straight in with no
4429
+ * matrix made; in world space
4430
+ * @param {number} i
4431
+ * @param {Vector3} [pos]
4432
+ * @param {Vector3} [rotation] - Euler angles in radians
4433
+ * @param {Vector3} [scale] */
4434
+ setTransformAt(i, pos, rotation, scale)
4435
+ {
4436
+ ASSERT(i >= 0 && i < this.maxCount, 'instance index out of range');
4437
+ matrix4Compose(this.matrixData, i * RENDER3D_MATRIX_FLOATS, pos, rotation, scale);
4438
+ this.markDirty(i);
4439
+ }
4440
+
4441
+ /** Place a run of instances from arrays indexed by instance, plain or typed, as a game keeps them: each is placed
4442
+ * as setTransformAt places it, all in one loop with nothing made, the fastest way to move many every frame;
4443
+ * in world space
4444
+ * @param {number} start - First instance
4445
+ * @param {number} count - How many
4446
+ * @param {ArrayLike<number>} x - Positions
4447
+ * @param {ArrayLike<number>} y
4448
+ * @param {ArrayLike<number>} z
4449
+ * @param {ArrayLike<number>} [rx] - Euler angles in radians, all three or none for upright
4450
+ * @param {ArrayLike<number>} [ry]
4451
+ * @param {ArrayLike<number>} [rz]
4452
+ * @param {ArrayLike<number>} [sx] - Scales, all three or none for 1
4453
+ * @param {ArrayLike<number>} [sy]
4454
+ * @param {ArrayLike<number>} [sz] */
4455
+ setTransforms(start, count, x, y, z, rx, ry, rz, sx, sy, sz)
4456
+ {
4457
+ ASSERT(start >= 0 && count >= 0 && start + count <= this.maxCount, 'instance range out of range');
4458
+ ASSERT(!rx === !ry && !rx === !rz && !sx === !sy && !sx === !sz, 'rotations and scales come in threes');
4459
+ const m = this.matrixData, end = start + count;
4460
+ for (let i = start; i < end; ++i)
4461
+ {
4462
+ // matrix4Compose's expressions, so a run placed here is the same as each placed by setTransformAt
4463
+ const k = i * RENDER3D_MATRIX_FLOATS;
4464
+ const scaleX = sx ? sx[i] : 1, scaleY = sy ? sy[i] : 1, scaleZ = sz ? sz[i] : 1;
4465
+ if (rx)
4466
+ {
4467
+ const cx = cos(rx[i]), sinX = sin(rx[i]);
4468
+ const cy = cos(ry[i]), sinY = sin(ry[i]);
4469
+ const cz = cos(rz[i]), sinZ = sin(rz[i]);
4470
+ m[k] = (cy*cz + sinY*sinX*sinZ) * scaleX; m[k+1] = cx*sinZ * scaleX;
4471
+ m[k+2] = (-sinY*cz + cy*sinX*sinZ) * scaleX;
4472
+ m[k+4] = (-cy*sinZ + sinY*sinX*cz) * scaleY; m[k+5] = cx*cz * scaleY;
4473
+ m[k+6] = (sinY*sinZ + cy*sinX*cz) * scaleY;
4474
+ m[k+8] = sinY*cx * scaleZ; m[k+9] = -sinX * scaleZ; m[k+10] = cy*cx * scaleZ;
4475
+ }
4476
+ else
4477
+ {
4478
+ m[k] = scaleX; m[k+1] = 0; m[k+2] = 0;
4479
+ m[k+4] = 0; m[k+5] = scaleY; m[k+6] = 0;
4480
+ m[k+8] = 0; m[k+9] = 0; m[k+10] = scaleZ;
4481
+ }
4482
+ m[k+3] = m[k+7] = m[k+11] = 0; m[k+15] = 1;
4483
+ m[k+12] = x[i], m[k+13] = y[i], m[k+14] = z[i];
3893
4484
  }
4485
+ count && this.markDirtyRange(start, end);
3894
4486
  }
3895
4487
 
3896
4488
  /** Place an instance, in world space
@@ -3899,8 +4491,9 @@ class InstancedMesh3D extends EngineObject3D
3899
4491
  setMatrixAt(i, matrix)
3900
4492
  {
3901
4493
  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);
4494
+ const data = this.matrixData, k = i * RENDER3D_MATRIX_FLOATS, m = matrix.m;
4495
+ for (let j = 0; j < 16; ++j)
4496
+ data[k+j] = m[j]; // for 16 floats a loop beats set's call
3904
4497
  this.markDirty(i);
3905
4498
  }
3906
4499
 
@@ -3910,8 +4503,8 @@ class InstancedMesh3D extends EngineObject3D
3910
4503
  getMatrixAt(i)
3911
4504
  {
3912
4505
  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));
4506
+ const k = i * RENDER3D_MATRIX_FLOATS, matrix = new Matrix4;
4507
+ matrix.m.set(this.matrixData.subarray(k, k + 16));
3915
4508
  return matrix;
3916
4509
  }
3917
4510
 
@@ -3922,27 +4515,72 @@ class InstancedMesh3D extends EngineObject3D
3922
4515
  {
3923
4516
  ASSERT(i >= 0 && i < this.maxCount, 'instance index out of range');
3924
4517
  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);
4518
+ const data = this.colorData, k = i * RENDER3D_COLOR_FLOATS;
4519
+ data[k] = color.r; data[k+1] = color.g; data[k+2] = color.b; data[k+3] = color.a;
4520
+ this.markColorDirty(i);
3928
4521
  }
3929
4522
 
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
4523
+ /** Note that an instance's matrix changed, so it uploads before the next draw and the bounds take it in when
4524
+ * they are read; setTransformAt and setMatrixAt call this, and so must an edit made straight to matrixData
3932
4525
  * @param {number} i */
3933
4526
  markDirty(i)
3934
4527
  {
3935
- this.dirtyStart = min(this.dirtyStart, i);
3936
- this.dirtyEnd = max(this.dirtyEnd, i + 1);
4528
+ if (i < this.dirtyStart) this.dirtyStart = i;
4529
+ if (i >= this.dirtyEnd) this.dirtyEnd = i + 1;
4530
+ if (i < this.boundsStart) this.boundsStart = i;
4531
+ if (i >= this.boundsEnd) this.boundsEnd = i + 1;
4532
+ }
3937
4533
 
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;
4534
+ /** Note that a run of instances' matrices changed, for code that writes matrixData directly, the lowest level
4535
+ * way to move many: they upload before the next draw and the bounds take them in when they are read
4536
+ * @param {number} start - First instance
4537
+ * @param {number} end - One past the last */
4538
+ markDirtyRange(start, end)
4539
+ {
4540
+ ASSERT(start >= 0 && end <= this.maxCount && start <= end, 'instance range out of range');
4541
+ if (start < this.dirtyStart) this.dirtyStart = start;
4542
+ if (end > this.dirtyEnd) this.dirtyEnd = end;
4543
+ if (start < this.boundsStart) this.boundsStart = start;
4544
+ if (end > this.boundsEnd) this.boundsEnd = end;
3944
4545
  }
3945
4546
 
4547
+ /** Note that an instance's color changed, so it uploads before the next draw; setColorAt calls this, and so must
4548
+ * an edit made straight to colorData
4549
+ * @param {number} i */
4550
+ markColorDirty(i)
4551
+ {
4552
+ if (i < this.colorDirtyStart) this.colorDirtyStart = i;
4553
+ if (i >= this.colorDirtyEnd) this.colorDirtyEnd = i + 1;
4554
+ }
4555
+
4556
+ /** Radius of the sphere around the origin that holds every instance set so far, for culling: the farthest
4557
+ * instance, and the mesh's size at the largest scale; it only grows, and takes in the instances set since it
4558
+ * was last read, so with frustum culling off it is never worked out. Set it to fix the sphere, which a big set
4559
+ * that is culled can use to skip that work, and set it to undefined to go back to the bounds
4560
+ * @return {number} */
4561
+ get radius()
4562
+ {
4563
+ if (this.fixedRadius !== undefined) return this.fixedRadius;
4564
+ if (this.boundsEnd > this.boundsStart)
4565
+ {
4566
+ // the bounds grow to hold where each is now and how big, read back from its matrix, squared
4567
+ const d = this.matrixData;
4568
+ let reachSquared = this.reachSquared, scaleSquared = this.scaleSquared;
4569
+ for (let i = this.boundsStart; i < this.boundsEnd; ++i)
4570
+ {
4571
+ const k = i * RENDER3D_MATRIX_FLOATS, x = d[k+12], y = d[k+13], z = d[k+14];
4572
+ const reach = x*x + y*y + z*z, scale = render3DMaxStretchSquared(d, k);
4573
+ if (reach > reachSquared) reachSquared = reach;
4574
+ if (scale > scaleSquared) scaleSquared = scale;
4575
+ }
4576
+ this.reachSquared = reachSquared, this.scaleSquared = scaleSquared;
4577
+ this.boundsStart = Infinity, this.boundsEnd = 0;
4578
+ }
4579
+ const mesh = this.mesh, meshRadius = mesh.radius || mesh.computeRadius(); // the mesh measured once
4580
+ return this.reachSquared ** .5 + meshRadius * this.scaleSquared ** .5;
4581
+ }
4582
+ set radius(radius) { this.fixedRadius = radius; }
4583
+
3946
4584
  /** Draws every instance as one call, uploading the ones that changed first
3947
4585
  * @return {void} */
3948
4586
  render3D()
@@ -3955,41 +4593,46 @@ class InstancedMesh3D extends EngineObject3D
3955
4593
  if (r.shadowPass && !r.lighting) return; // unlit things cast no shadow
3956
4594
  render3DMeshUpload(mesh);
3957
4595
  if (!mesh.bufferCount) return;
3958
- this.radius = this.reach + mesh.radius * this.maxScale; // the mesh may have grown since
3959
4596
  if (r.frustumCulling && !render3DSphereVisible(0, 0, 0, this.radius)) return;
3960
4597
 
3961
4598
  // the uv rect is the object's tile for every instance, rewritten when the tile changes
3962
4599
  if (this.uvTileInfo !== this.tileInfo)
3963
4600
  {
3964
- const uv = render3DGetTileUVs(this.tileInfo), data = this.instanceData;
3965
- for (let k = 20; k < data.length; k += RENDER3D_INSTANCE_FLOATS)
4601
+ const uv = render3DGetTileUVs(this.tileInfo), data = this.colorData;
4602
+ for (let k = 4; k < data.length; k += RENDER3D_COLOR_FLOATS)
3966
4603
  data[k] = uv.x, data[k+1] = uv.y, data[k+2] = uv.w, data[k+3] = uv.h;
3967
4604
  this.uvTileInfo = this.tileInfo;
3968
- this.dirtyStart = 0, this.dirtyEnd = this.maxCount;
4605
+ this.colorDirtyStart = 0, this.colorDirtyEnd = this.maxCount;
3969
4606
  }
3970
4607
 
3971
- // the GPU copy: all of it under a fresh context, otherwise just the changed range
4608
+ // the GPU copies: all of each under a fresh context, otherwise just the changed ranges
3972
4609
  if (!this.buffer || this.bufferGeneration !== r.contextGeneration)
3973
4610
  {
3974
- this.buffer = gl.createBuffer();
3975
4611
  this.bufferGeneration = r.contextGeneration;
3976
- gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
3977
- gl.bufferData(gl.ARRAY_BUFFER, this.instanceData, gl.DYNAMIC_DRAW);
4612
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer = gl.createBuffer());
4613
+ gl.bufferData(gl.ARRAY_BUFFER, this.matrixData, gl.DYNAMIC_DRAW);
4614
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.colorBuffer = gl.createBuffer());
4615
+ gl.bufferData(gl.ARRAY_BUFFER, this.colorData, gl.DYNAMIC_DRAW);
3978
4616
  }
3979
- else if (this.dirtyEnd > this.dirtyStart)
4617
+ else
3980
4618
  {
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);
4619
+ const upload = (buffer, data, floats, start, end)=>
4620
+ {
4621
+ if (end <= start) return;
4622
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
4623
+ gl.bufferSubData(gl.ARRAY_BUFFER, start * floats * 4, data, start * floats, (end - start) * floats);
4624
+ };
4625
+ upload(this.buffer, this.matrixData, RENDER3D_MATRIX_FLOATS, this.dirtyStart, this.dirtyEnd);
4626
+ upload(this.colorBuffer, this.colorData, RENDER3D_COLOR_FLOATS, this.colorDirtyStart, this.colorDirtyEnd);
3984
4627
  }
3985
- this.dirtyStart = Infinity, this.dirtyEnd = 0;
4628
+ this.dirtyStart = this.colorDirtyStart = Infinity, this.dirtyEnd = this.colorDirtyEnd = 0;
3986
4629
 
3987
4630
  // one draw under the object's state, the mesh setting the culling as drawMesh does
3988
4631
  r.flush();
3989
4632
  render3DFlushBeforeOverlay(); // as in drawMesh, what was drawn before goes under it
3990
4633
  const cullBackFaces = r.cullBackFaces, tileInfo = this.tileInfo;
3991
4634
  r.cullBackFaces = !mesh.doubleSided;
3992
- render3DDrawInstanced(mesh, this.buffer, this.count, render3DTextureOf(tileInfo), r);
4635
+ render3DDrawInstanced(mesh, this.buffer, this.count, render3DTextureOf(tileInfo), r, this.colorBuffer);
3993
4636
  r.cullBackFaces = cullBackFaces;
3994
4637
  }
3995
4638
 
@@ -3998,8 +4641,8 @@ class InstancedMesh3D extends EngineObject3D
3998
4641
  destroy(immediate)
3999
4642
  {
4000
4643
  if (this.buffer && this.bufferGeneration === render3D?.contextGeneration)
4001
- glContext?.deleteBuffer(this.buffer);
4002
- this.buffer = undefined;
4644
+ glContext?.deleteBuffer(this.buffer), glContext?.deleteBuffer(this.colorBuffer);
4645
+ this.buffer = this.colorBuffer = undefined;
4003
4646
  super.destroy(immediate);
4004
4647
  }
4005
4648
  }
@@ -4008,7 +4651,10 @@ class InstancedMesh3D extends EngineObject3D
4008
4651
  /**
4009
4652
  * Light3D - A light that is an EngineObject3D, so it can move, follow a parent or be destroyed like anything else
4010
4653
  * - A point light: it lights what is near it and fades out by its radius, DirectionalLight3D shines from far away
4011
- * - Only the sun, render3D.sunDirection, casts shadows; these light and make highlights without one
4654
+ * - A coneAngle makes it a spotlight: it shines along its own forward, turned by rotation3D or by what it is
4655
+ * attached to, inside the cone, fading over the part of it coneSoftness says
4656
+ * - The sun, render3D.sunDirection, casts the shadows; a spotlight can cast them in its place, see
4657
+ * render3D.shadowLight; the other lights light and make highlights without one
4012
4658
  * - Only the 8 lights nearest the camera are used each frame
4013
4659
  * - radius is where the light fades out, and it fades fast, so a small radius wants a higher intensity
4014
4660
  * - intensity multiplies the color, above 1 for a light brighter than white
@@ -4040,10 +4686,32 @@ class Light3D extends EngineObject3D
4040
4686
  /** @property {boolean} - Shine from far away, from its position toward the origin, instead of out from its
4041
4687
  * position with a falloff; DirectionalLight3D sets it */
4042
4688
  this.directional = false;
4689
+ /** @property {number} - Makes it a spotlight: the angle in radians from its forward out to the edge of its
4690
+ * cone, so the beam is twice this across; 0 for a light that shines every way */
4691
+ this.coneAngle = 0;
4692
+ /** @property {number} - How much of the cone is its fading edge: 0 a hard edge, .2 by default, the outer
4693
+ * fifth, 1 fading all the way from the middle of the beam */
4694
+ this.coneSoftness = .2;
4695
+ /** @property {number} - Size of a soft hazy glow drawn over the light, like a lamp at night, 0 for none; it
4696
+ * is added onto what is behind it, and what is in front of the light hides it */
4697
+ this.glow = 0;
4698
+ /** @property {number} - How fast the glow fades from its middle: 1 by default, .5 a wide haze, 2 a tight
4699
+ * bright core */
4700
+ this.glowFalloff = 1;
4701
+ this.additive = true; // the glow is added on, in the transparent stage; a light with none draws nothing
4043
4702
  }
4044
4703
 
4045
- /** Lights draw nothing */
4046
- render3D() {}
4704
+ /** Draw the glow, a quad facing the camera with a soft round glow on it, pulled toward the camera by half its
4705
+ * size so a lamp at the light does not cut into it, but never past the camera */
4706
+ render3D()
4707
+ {
4708
+ if (!(this.glow > 0) || this.directional) return;
4709
+ const r = render3D, c = this.color, pos = render3DObjectMatrix(this).getTranslation();
4710
+ const toCamera = r.camera.pos.subtract(pos), distance = toCamera.length();
4711
+ const at = distance ? pos.add(toCamera.scale(min(this.glow, distance) / 2 / distance)) : pos;
4712
+ const color = rgb(c.r, c.g, c.b, c.a * min(this.intensity, 1));
4713
+ r.drawBillboard(at, vec2(this.glow), engineGlowTexture(this.glowFalloff), color);
4714
+ }
4047
4715
  }
4048
4716
 
4049
4717
  ///////////////////////////////////////////////////////////////////////////////