littlejsengine 1.21.0 → 1.23.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,22 +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; emissiveMapColor is
76
- // compared by its values, so two objects with equal colors batch
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 */
77
106
  const RENDER3D_STATE_FIELDS = ['blend', 'additive', 'depthTest', 'depthWrite', 'cullBackFaces', 'mirrored', 'lighting',
78
107
  'emissive', 'receiveShadow', 'specular', 'pixelated', 'shader', 'normalMap', 'normalScale', 'shininess',
79
108
  'reflectivity', 'emissiveMap', 'emissiveMapColor'];
80
109
 
81
- // a copy of the draw state in one fixed shape, the fields of RENDER3D_STATE_FIELDS written out so the
82
- // 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
83
112
  function render3DCaptureBatchState()
84
113
  {
85
- const r = render3D;
86
- return {blend: r.blend, additive: r.additive, depthTest: r.depthTest, depthWrite: r.depthWrite,
87
- cullBackFaces: r.cullBackFaces, mirrored: r.mirrored, lighting: r.lighting, emissive: r.emissive,
88
- receiveShadow: r.receiveShadow, specular: r.specular, pixelated: r.pixelated, shader: r.shader,
89
- normalMap: r.normalMap, normalScale: r.normalScale, shininess: r.shininess, reflectivity: r.reflectivity,
90
- emissiveMap: r.emissiveMap, emissiveMapColor: (r.emissiveMapColor || WHITE).copy()}; // the caller may change it
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};
91
121
  }
92
122
 
93
123
  // put a captured draw state back, written out the same way; the transparent stage does this for every queued draw
@@ -101,21 +131,23 @@ function render3DApplyBatchState(s)
101
131
  r.reflectivity = s.reflectivity, r.emissiveMap = s.emissiveMap, r.emissiveMapColor = s.emissiveMapColor;
102
132
  }
103
133
 
104
- // 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
105
136
  function render3DStateChanged(s)
106
137
  {
107
138
  const r = render3D;
108
- return r.blend !== s.blend || r.additive !== s.additive || r.depthTest !== s.depthTest
109
- || r.depthWrite !== s.depthWrite || r.cullBackFaces !== s.cullBackFaces || r.mirrored !== s.mirrored
110
- || r.lighting !== s.lighting || r.emissive !== s.emissive || r.receiveShadow !== s.receiveShadow
111
- || r.specular !== s.specular || r.pixelated !== s.pixelated || r.shader !== s.shader
112
- || r.normalMap !== s.normalMap || r.normalScale !== s.normalScale || r.shininess !== s.shininess
113
- || r.reflectivity !== s.reflectivity || r.emissiveMap !== s.emissiveMap
114
- || render3DColorChanged(r.emissiveMapColor || WHITE, s.emissiveMapColor);
115
- }
116
-
117
- // whether two colors differ in their rgb, for the batch key's emissiveMapColor
118
- function render3DColorChanged(a, b) { return a.r !== b.r || a.g !== b.g || a.b !== b.b;
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;
119
151
  }
120
152
 
121
153
  // whether a sphere is inside the view, or the shadow map's box during the shadow pass, without a vector
@@ -193,7 +225,10 @@ function render3DMaxScale(m)
193
225
  // how far a matrix at offset k can move a point that is one unit from its origin, for a bounding sphere: the
194
226
  // longest axis when the axes are square to each other, more when they are not, as a turned child under a parent
195
227
  // scaled on one axis leaves them; the axes' dot products bound the largest stretch by their largest row sum
196
- 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)
197
232
  {
198
233
  const xx = m[k]*m[k] + m[k+1]*m[k+1] + m[k+2]*m[k+2];
199
234
  const yy = m[k+4]*m[k+4] + m[k+5]*m[k+5] + m[k+6]*m[k+6];
@@ -201,7 +236,7 @@ function render3DMaxStretch(m, k=0)
201
236
  const xy = abs(m[k]*m[k+4] + m[k+1]*m[k+5] + m[k+2]*m[k+6]);
202
237
  const xz = abs(m[k]*m[k+8] + m[k+1]*m[k+9] + m[k+2]*m[k+10]);
203
238
  const yz = abs(m[k+4]*m[k+8] + m[k+5]*m[k+9] + m[k+6]*m[k+10]);
204
- return max(xx + xy + xz, yy + xy + yz, zz + xz + yz) ** .5;
239
+ return max(xx + xy + xz, yy + xy + yz, zz + xz + yz);
205
240
  }
206
241
 
207
242
  // a quad as a strip from its center and half axes, the same corner order as render3DQuadStrip
@@ -251,10 +286,11 @@ function render3DDrawObjects(objects)
251
286
  render3DSetObjectState();
252
287
  }
253
288
 
254
- // 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
255
291
  function render3DInstance(mesh, matrix, tileInfo, color)
256
292
  {
257
- const k = render3DInstanceSlot(mesh, render3DTextureOf(tileInfo)), data = mesh.instanceData;
293
+ const k = render3DInstanceSlot(mesh, render3DTextureOf(tileInfo), render3DMirrors(matrix)), data = mesh.instanceData;
258
294
  data.set(matrix.m, k);
259
295
  // the tint; this batch is never blended, so in the shadow pass the depth shader cuts it by its texture alpha
260
296
  // alone, a see through caster draws blended instead and is cut by its tint alpha too
@@ -265,14 +301,18 @@ function render3DInstance(mesh, matrix, tileInfo, color)
265
301
 
266
302
  // make room for one more instance of a mesh under a texture and the current draw state, flushing a batch that
267
303
  // differs first, and return where its 24 floats go in mesh.instanceData: the matrix, the tint and the uv rect
268
- 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)
269
307
  {
270
308
  const r = render3D;
271
- if (mesh.instanceCount && (mesh.instanceTextureInfo !== textureInfo || render3DStateChanged(mesh.instanceState)))
309
+ if (mesh.instanceCount && (mesh.instanceTextureInfo !== textureInfo || mesh.instanceMirrored !== mirrored
310
+ || render3DStateChanged(mesh.instanceState)))
272
311
  render3DFlushInstances(mesh);
273
312
  if (!mesh.instanceCount)
274
313
  {
275
314
  mesh.instanceTextureInfo = textureInfo;
315
+ mesh.instanceMirrored = mirrored;
276
316
  mesh.instanceState = render3DCaptureBatchState();
277
317
  r.instanceMeshes.push(mesh);
278
318
  }
@@ -303,7 +343,10 @@ function render3DFlushInstances(only)
303
343
  const buffers = r.instanceBuffers, buffer = buffers[r.instanceBufferIndex = (r.instanceBufferIndex + 1) % buffers.length];
304
344
  gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
305
345
  gl.bufferData(gl.ARRAY_BUFFER, mesh.instanceData, gl.DYNAMIC_DRAW, 0, count * RENDER3D_INSTANCE_FLOATS);
306
- 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);
307
350
  }
308
351
  if (!only)
309
352
  r.instanceMeshes.length = 0;
@@ -318,13 +361,16 @@ function render3DFlushInstances(only)
318
361
  // the arrays are turned on with their instance divisor for this one call and both are turned off after: a single
319
362
  // draw reads these slots as constant attributes, and in Firefox a draw that reads a constant through a slot whose
320
363
  // divisor is set leaves the next batch on that slot reading the wrong values
321
- 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)
322
366
  {
323
- const gl = glContext;
324
- gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
367
+ const gl = glContext, matrixBytes = RENDER3D_MATRIX_FLOATS * 4;
325
368
  for (const [location, size, offset] of RENDER3D_INSTANCE_ATTRIBS)
326
369
  {
327
- 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);
328
374
  gl.enableVertexAttribArray(location);
329
375
  gl.vertexAttribDivisor(location, 1);
330
376
  }
@@ -369,11 +415,41 @@ function render3DCollectLights()
369
415
  for (const light of lights)
370
416
  distances.set(light, light.directional ? -1 : light.getWorldPos3D().distanceSquared(cameraPos));
371
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;
372
422
  lights.length = RENDER3D_MAX_LIGHTS;
373
423
  }
374
424
  return lights;
375
425
  }
376
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
+ // the widest cone a shadow map can look down, to each side; the light that casts the shadows lights no wider, or
437
+ // what is past the map's edge would be lit with nothing to shade it
438
+ const RENDER3D_SHADOW_CONE_MAX = 1.35;
439
+
440
+ // a light's cone as the four numbers the shader gets: the way it shines, scaled so that its dot with the way to
441
+ // 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
442
+ // gets numbers that make it 1 every way
443
+ function render3DLightCone(light)
444
+ {
445
+ const widest = light === render3DShadowCaster() ? RENDER3D_SHADOW_CONE_MAX : PI;
446
+ const angle = light.directional ? 0 : min(light.coneAngle, widest);
447
+ if (!(angle > 0)) return [0, 0, 0, -1];
448
+ const outer = cos(angle), inner = cos(angle * (1 - clamp(light.coneSoftness)));
449
+ const k = 1 / max(inner - outer, 1e-4), forward = light.getForward3D();
450
+ return [forward.x * k, forward.y * k, forward.z * k, outer * k];
451
+ }
452
+
377
453
  // unit circle directions for a number of sides, [cos, sin, cos, sin, ...] including the closing point, cached
378
454
  const render3DCircleCache = new Map;
379
455
  function render3DCircle(sides)
@@ -425,7 +501,15 @@ class Render3DPlugin
425
501
  {
426
502
  ASSERT(!render3D, 'Render3D plugin already initialized');
427
503
  render3D = this;
428
- ASSERT(Object.keys(render3DCaptureBatchState()).join() === RENDER3D_STATE_FIELDS.join(),
504
+ // the draw state, read at each draw through the accessors below the constructor
505
+ /** @type {Render3DDrawState} */
506
+ this.drawState = {blend: false, additive: false, depthTest: true, depthWrite: true, cullBackFaces: false,
507
+ mirrored: false, lighting: true, emissive: 0, receiveShadow: true, specular: 0, pixelated: false,
508
+ shader: undefined, normalMap: undefined, normalScale: 1, shininess: 16, reflectivity: 0,
509
+ emissiveMap: undefined, emissiveMapColor: WHITE, emissiveR: 1, emissiveG: 1, emissiveB: 1};
510
+ /** @property {number} - Goes up when a draw state field changes, so a batch sees at a glance that none did */
511
+ this.stateVersion = 0;
512
+ ASSERT(Object.keys(render3DCaptureBatchState()).join() === [...RENDER3D_STATE_FIELDS, 'version'].join(),
429
513
  'the batch state functions must list RENDER3D_STATE_FIELDS');
430
514
 
431
515
  /** @property {Camera3D} - The camera */
@@ -471,8 +555,16 @@ class Render3DPlugin
471
555
  // shadows
472
556
  /** @property {boolean} - Cast real shadows from the sun, off by default and free when off */
473
557
  this.shadows = false;
558
+ /** @property {number} - How far a sound played with playSound is heard when it was made with no range of
559
+ * its own, in world units; further than the 2D soundDefaultRange, a 3D world is bigger */
560
+ this.soundDefaultRange = 100;
474
561
  /** @property {number} - Size of the shadow map in pixels, bigger is sharper and slower */
475
562
  this.shadowMapSize = 1024;
563
+ /** @property {Light3D|undefined} - A spotlight, a Light3D with a coneAngle, to cast the shadows in place
564
+ * of the sun, a flashlight in the dark: the shadow map looks down its cone, as far as its radius, and the
565
+ * sun still lights the scene but casts none; undefined for the sun, as is a light that is off or has no cone
566
+ * @type {Light3D|undefined} */
567
+ this.shadowLight = undefined;
476
568
  /** @property {number} - World size the shadow map covers around shadowCenter, smaller is sharper; it is a
477
569
  * square facing the light, so it turns as the light does, and about 1.5 times an area's width covers it from
478
570
  * any angle */
@@ -485,50 +577,6 @@ class Render3DPlugin
485
577
  /** @property {number} - How much to blur the shadow edges */
486
578
  this.shadowSoftness = 1;
487
579
 
488
- // draw state, read at each draw
489
- /** @property {boolean} - Apply lighting, when false draws plain vertex color times texture and casts no shadow;
490
- * off for billboards, lines, ribbons and soft discs, an object sets emissive instead */
491
- this.lighting = true;
492
- /** @property {number} - How much a surface lights itself, set per object by its emissive */
493
- this.emissive = 0;
494
- /** @property {boolean} - Additive blending instead of alpha, in the transparent stage */
495
- this.additive = false;
496
- /** @property {boolean} - Test against the depth buffer, reset to true before each object and callback; a draw
497
- * with it off goes over what was drawn before it and under what is drawn after, by render order, which
498
- * ends the batch of meshes before it, so one per object costs a draw per object */
499
- this.depthTest = true;
500
- /** @property {boolean} - Write to the depth buffer, owned by the stages: on for opaque, off for transparent */
501
- this.depthWrite = true;
502
- // batch state set by drawMesh from each mesh: whether its back faces are skipped, off for strips so they
503
- // show from both sides, and whether its transform mirrors it so the other winding is the front
504
- this.cullBackFaces = false;
505
- this.mirrored = false;
506
- /** @property {number} - Strength of the highlight where the sun and the Light3D objects reflect, 0 is none and
507
- * 1 adds a light's full color at its brightest; shininess sets its size */
508
- this.specular = 0;
509
- /** @property {number} - The highlight's exponent, how small and sharp it is: 4 is broad like rubber, 100 sharp
510
- * like polished metal; set from each object's shininess */
511
- this.shininess = 16;
512
- /** @property {TextureInfo|undefined} - Normal map for the next draws, set from each object's normalMap
513
- * @type {TextureInfo|undefined} */
514
- this.normalMap = undefined;
515
- /** @property {number} - How strongly the normal map bends the surface, set from each object's normalScale */
516
- this.normalScale = 1;
517
- /** @property {number} - How much the surface reflects the sky, 0 to 1, set from each object's reflectivity */
518
- this.reflectivity = 0;
519
- /** @property {TextureInfo|undefined} - Emissive map for the next draws, set from each object's emissiveMap
520
- * @type {TextureInfo|undefined} */
521
- this.emissiveMap = undefined;
522
- /** @property {Color} - Multiplies the emissive map, set from each object's emissiveMapColor */
523
- this.emissiveMapColor = WHITE;
524
- /** @property {Shader|undefined} - Custom Shader for the next draws, set from each object's shader; undefined
525
- * draws with the plugin's own
526
- * @type {Shader|undefined} */
527
- this.shader = undefined;
528
- /** @property {boolean} - Darken by the shadow map when shadows are on, turn it off for things that should stay
529
- * lit inside a shadow */
530
- this.receiveShadow = true;
531
-
532
580
  // the pass
533
581
  /** @property {Function|undefined} - Draw solid world here, it runs again for shadows so only draw in it
534
582
  * @type {Function|undefined} */
@@ -551,9 +599,6 @@ class Render3DPlugin
551
599
  /** @property {boolean} - Sample textures through mipmaps so they do not shimmer in the distance, false uses
552
600
  * each texture's own filtering like 2D */
553
601
  this.mipmaps = true;
554
- /** @property {boolean} - Draw state: keep texture pixels hard edged, no mipmaps and no blending between them,
555
- * set per object by pixelated */
556
- this.pixelated = false;
557
602
  /** @property {number} - Anisotropic filtering for textures seen at an angle, 1 to 16, 1 is off; needs mipmaps */
558
603
  this.anisotropy = 4;
559
604
 
@@ -597,7 +642,6 @@ class Render3DPlugin
597
642
  this.cameraBack = vec3(0, 0, 1); // its opposite, the normal of camera facing draws
598
643
 
599
644
  // internal state
600
- this.blend = false; // blending on, set by the stages
601
645
  /** @type {Array<Array<number>>} */
602
646
  this.frustumPlanes = []; // the view as six inward planes [x, y, z, w]
603
647
  /** @type {Array<Array<number>>} */
@@ -637,6 +681,9 @@ class Render3DPlugin
637
681
  this.passIsDefault = true; // the running pass is the default layer, the only one shadowed
638
682
  this.lightPositions = new Float32Array(RENDER3D_MAX_LIGHTS * 4); // Light3D uniforms, filled each pass
639
683
  this.lightColors = new Float32Array(RENDER3D_MAX_LIGHTS * 4);
684
+ this.lightCones = new Float32Array(RENDER3D_MAX_LIGHTS * 4); // each light's cone, see render3DLightCone
685
+ this.shadowLightIndex = -1; // which of the lights sent casts the shadows, -1 for the sun
686
+ this.shadowDepthBias = 0; // the bias as the shadow lookup takes it, set with the shadow matrix
640
687
 
641
688
  // the stream of immediate mode draws
642
689
  /** @type {WebGLBuffer|undefined} */
@@ -664,6 +711,116 @@ class Render3DPlugin
664
711
  engineAddPlugin(undefined, render3DRender, render3DContextLost, render3DContextRestored, render3DPreRender);
665
712
  }
666
713
 
714
+ ///////////////////////////////////////////////////////////////////////////
715
+ // Draw state: each field an accessor over drawState, whose setter moves stateVersion only on a real change
716
+
717
+ /** Apply lighting, when false draws plain vertex color times texture and casts no shadow;
718
+ * off for billboards, lines, ribbons and soft discs, an object sets emissive instead
719
+ * @return {boolean} */
720
+ get lighting() { return this.drawState.lighting; }
721
+ set lighting(v) { const d = this.drawState; d.lighting === v || (d.lighting = v, ++this.stateVersion); }
722
+
723
+ /** How much a surface lights itself, set per object by its emissive
724
+ * @return {number} */
725
+ get emissive() { return this.drawState.emissive; }
726
+ set emissive(v) { const d = this.drawState; d.emissive === v || (d.emissive = v, ++this.stateVersion); }
727
+
728
+ /** Additive blending instead of alpha, in the transparent stage
729
+ * @return {boolean} */
730
+ get additive() { return this.drawState.additive; }
731
+ set additive(v) { const d = this.drawState; d.additive === v || (d.additive = v, ++this.stateVersion); }
732
+
733
+ /** Test against the depth buffer, reset to true before each object and callback; a draw
734
+ * with it off goes over what was drawn before it and under what is drawn after, by render order, which
735
+ * ends the batch of meshes before it, so one per object costs a draw per object
736
+ * @return {boolean} */
737
+ get depthTest() { return this.drawState.depthTest; }
738
+ set depthTest(v) { const d = this.drawState; d.depthTest === v || (d.depthTest = v, ++this.stateVersion); }
739
+
740
+ /** Write to the depth buffer, owned by the stages: on for opaque, off for transparent
741
+ * @return {boolean} */
742
+ get depthWrite() { return this.drawState.depthWrite; }
743
+ set depthWrite(v) { const d = this.drawState; d.depthWrite === v || (d.depthWrite = v, ++this.stateVersion); }
744
+
745
+ /** Skip back faces, set by drawMesh from each mesh: off for strips so they show from
746
+ * both sides
747
+ * @return {boolean} */
748
+ get cullBackFaces() { return this.drawState.cullBackFaces; }
749
+ set cullBackFaces(v) { const d = this.drawState; d.cullBackFaces === v || (d.cullBackFaces = v, ++this.stateVersion); }
750
+
751
+ /** The transform mirrors the draw, so the other winding is the front, set by drawMesh
752
+ * from each transform
753
+ * @return {boolean} */
754
+ get mirrored() { return this.drawState.mirrored; }
755
+ set mirrored(v) { const d = this.drawState; d.mirrored === v || (d.mirrored = v, ++this.stateVersion); }
756
+
757
+ /** Strength of the highlight where the sun and the Light3D objects reflect, 0 is none and
758
+ * 1 adds a light's full color at its brightest; shininess sets its size
759
+ * @return {number} */
760
+ get specular() { return this.drawState.specular; }
761
+ set specular(v) { const d = this.drawState; d.specular === v || (d.specular = v, ++this.stateVersion); }
762
+
763
+ /** The highlight's exponent, how small and sharp it is: 4 is broad like rubber, 100 sharp
764
+ * like polished metal; set from each object's shininess
765
+ * @return {number} */
766
+ get shininess() { return this.drawState.shininess; }
767
+ set shininess(v) { const d = this.drawState; d.shininess === v || (d.shininess = v, ++this.stateVersion); }
768
+
769
+ /** Normal map for the next draws, set from each object's normalMap
770
+ * @return {TextureInfo|undefined} */
771
+ get normalMap() { return this.drawState.normalMap; }
772
+ set normalMap(v) { const d = this.drawState; d.normalMap === v || (d.normalMap = v, ++this.stateVersion); }
773
+
774
+ /** How strongly the normal map bends the surface, set from each object's normalScale
775
+ * @return {number} */
776
+ get normalScale() { return this.drawState.normalScale; }
777
+ set normalScale(v) { const d = this.drawState; d.normalScale === v || (d.normalScale = v, ++this.stateVersion); }
778
+
779
+ /** How much the surface reflects the sky, 0 to 1, set from each object's reflectivity
780
+ * @return {number} */
781
+ get reflectivity() { return this.drawState.reflectivity; }
782
+ set reflectivity(v) { const d = this.drawState; d.reflectivity === v || (d.reflectivity = v, ++this.stateVersion); }
783
+
784
+ /** Emissive map for the next draws, set from each object's emissiveMap
785
+ * @return {TextureInfo|undefined} */
786
+ get emissiveMap() { return this.drawState.emissiveMap; }
787
+ set emissiveMap(v) { const d = this.drawState; d.emissiveMap === v || (d.emissiveMap = v, ++this.stateVersion); }
788
+
789
+ /** Multiplies the emissive map, set from each object's emissiveMapColor; compared by its rgb, so a Color
790
+ * changed in place is seen when it is set again, and undefined is white
791
+ * @return {Color} */
792
+ get emissiveMapColor() { return this.drawState.emissiveMapColor; }
793
+ set emissiveMapColor(v)
794
+ {
795
+ const d = this.drawState, c = v || WHITE;
796
+ d.emissiveMapColor = v;
797
+ if (c.r !== d.emissiveR || c.g !== d.emissiveG || c.b !== d.emissiveB)
798
+ d.emissiveR = c.r, d.emissiveG = c.g, d.emissiveB = c.b, ++this.stateVersion;
799
+ }
800
+
801
+ /** Custom Shader for the next draws, set from each object's shader; undefined draws
802
+ * with the plugin's own
803
+ * @return {Shader|undefined} */
804
+ get shader() { return this.drawState.shader; }
805
+ set shader(v) { const d = this.drawState; d.shader === v || (d.shader = v, ++this.stateVersion); }
806
+
807
+ /** Darken by the shadow map when shadows are on, turn it off for things that should
808
+ * stay lit inside a shadow
809
+ * @return {boolean} */
810
+ get receiveShadow() { return this.drawState.receiveShadow; }
811
+ set receiveShadow(v) { const d = this.drawState; d.receiveShadow === v || (d.receiveShadow = v, ++this.stateVersion); }
812
+
813
+ /** Keep texture pixels hard edged, no mipmaps and no blending between them, set per
814
+ * object by pixelated
815
+ * @return {boolean} */
816
+ get pixelated() { return this.drawState.pixelated; }
817
+ set pixelated(v) { const d = this.drawState; d.pixelated === v || (d.pixelated = v, ++this.stateVersion); }
818
+
819
+ /** Blending on, set by the stages
820
+ * @return {boolean} */
821
+ get blend() { return this.drawState.blend; }
822
+ set blend(v) { const d = this.drawState; d.blend === v || (d.blend = v, ++this.stateVersion); }
823
+
667
824
  ///////////////////////////////////////////////////////////////////////////
668
825
  // Matrices and picking
669
826
 
@@ -773,7 +930,8 @@ class Render3DPlugin
773
930
  }
774
931
 
775
932
  /** Play a sound at a 3D position, quieter with distance from the camera and panned by its side, like Sound.play
776
- * with a 2D position
933
+ * with a 2D position; a sound made with no range of its own is heard to soundDefaultRange, 100, and one given
934
+ * a range keeps it
777
935
  * @param {Sound} sound
778
936
  * @param {Vector3} pos3D
779
937
  * @param {number} [volume]
@@ -787,7 +945,9 @@ class Render3DPlugin
787
945
  // the 3D range fade and pan, Sound.play with no position does the rest
788
946
  ASSERT(sound instanceof Sound, 'sound must be a Sound');
789
947
  ASSERT(isVector3(pos3D), 'pos3D must be a vec3');
790
- const offset = pos3D.subtract(this.camera.pos), range = sound.range;
948
+ // a sound with no range of its own is heard further in 3D than in 2D
949
+ const offset = pos3D.subtract(this.camera.pos);
950
+ const range = sound.rangeIsDefault ? this.soundDefaultRange : sound.range;
791
951
  if (range)
792
952
  {
793
953
  const distance = offset.length();
@@ -851,15 +1011,15 @@ class Render3DPlugin
851
1011
  if (this.frustumCulling && !render3DSphereVisible(m[12], m[13], m[14], mesh.radius * render3DMaxStretch(m)))
852
1012
  return;
853
1013
  // the mesh says whether its back faces can be skipped, and a mirroring transform, one with a negative
854
- // determinant, turns the winding around so the other one is its front
855
- const cullBackFaces = this.cullBackFaces, mirrored = this.mirrored;
856
- this.cullBackFaces = !mesh.doubleSided;
857
- this.mirrored = render3DMirrors(matrix);
858
- // the stage draws the batch at its end
1014
+ // determinant, turns the winding around so the other one is its front; the stage draws a batch at its
1015
+ // end, which keys on those itself
859
1016
  if (!this.blend && this.depthTest && (mesh.instanced ?? this.instancing))
860
1017
  render3DInstance(mesh, matrix, tileInfo, color);
861
1018
  else
862
1019
  {
1020
+ const cullBackFaces = this.cullBackFaces, mirrored = this.mirrored;
1021
+ this.cullBackFaces = !mesh.doubleSided;
1022
+ this.mirrored = render3DMirrors(matrix);
863
1023
  this.flush();
864
1024
  render3DFlushBeforeOverlay();
865
1025
  render3DSetDrawUniforms(matrix, tileInfo, color);
@@ -867,8 +1027,8 @@ class Render3DPlugin
867
1027
  glContext.drawElements(glContext.TRIANGLES, mesh.bufferCount, mesh.indexType, 0);
868
1028
  ++drawCount;
869
1029
  primitiveCount += mesh.bufferCount / 3;
1030
+ this.cullBackFaces = cullBackFaces, this.mirrored = mirrored;
870
1031
  }
871
- this.cullBackFaces = cullBackFaces, this.mirrored = mirrored;
872
1032
  }
873
1033
 
874
1034
  /** Draw a triangle strip, batched into the stream with the current draw state
@@ -1064,6 +1224,24 @@ class Render3DPlugin
1064
1224
  /** Rebuild the light's view projection around the shadow center, called automatically each frame shadows are on */
1065
1225
  updateShadowMatrix()
1066
1226
  {
1227
+ const caster = render3DShadowCaster();
1228
+ if (caster)
1229
+ {
1230
+ // a spotlight's map looks down its cone with perspective, from just in front of it out to its radius
1231
+ const pos = caster.getWorldPos3D(), forward = caster.getForward3D();
1232
+ const up = abs(forward.y) > .99 ? vec3(0, 0, 1) : vec3(0, 1, 0);
1233
+ // the near plane stays in front of the far one however small the light
1234
+ const far = caster.radius, near = min(max(far / 500, .02), far / 2);
1235
+ const fov = min(caster.coneAngle, RENDER3D_SHADOW_CONE_MAX) * 2 + .1;
1236
+ this.shadowMatrix = Matrix4.perspective(fov, 1, near, far).multiply(
1237
+ Matrix4.lookAt(pos, pos.add(forward), up).invert());
1238
+ this.shadowPlanes = render3DFrustumPlanes(this.shadowMatrix);
1239
+ // depth is not even with perspective: the lookup divides this by the distance squared, which makes
1240
+ // the bias the same distance in the world near the light and far from it
1241
+ this.shadowDepthBias = this.shadowBias * far * far * near / (far - near);
1242
+ return;
1243
+ }
1244
+ this.shadowDepthBias = this.shadowBias; // the sun's map is flat, its depth even
1067
1245
  ASSERT(this.shadowRange > 0, 'shadowRange must be positive');
1068
1246
  const range = this.shadowRange > 0 ? this.shadowRange : 1, half = range / 2;
1069
1247
  const toSun = this.sunDirection.normalize();
@@ -1493,6 +1671,7 @@ function render3DFragmentSource(fragmentCode)
1493
1671
  'precision highp float;' +
1494
1672
  'uniform vec4 lightDir,lightColor,ambientFog,ambientGround,fogColor,shadowParams;' +
1495
1673
  'uniform vec4 extraLights[' + RENDER3D_MAX_LIGHTS + '],extraLightColors[' + RENDER3D_MAX_LIGHTS + '];' +
1674
+ 'uniform vec4 extraLightCones[' + RENDER3D_MAX_LIGHTS + '];' +
1496
1675
  'uniform int extraLightCount;' +
1497
1676
  'uniform vec3 cameraPos;' +
1498
1677
  'uniform vec4 materialParams,emissiveTint,skyTop,skyHorizon,skyBottom;' +
@@ -1501,12 +1680,14 @@ function render3DFragmentSource(fragmentCode)
1501
1680
  'uniform highp sampler2DShadow shadowMap;' +
1502
1681
  'in vec3 P,N;in vec2 T,L;in vec4 C,S;' +
1503
1682
  'out vec4 o;' +
1504
- // the sun shadow at this fragment, 0 to 1: the light's depth map with a 3x3 blur, outside the map is lit
1683
+ // the shadow at this fragment, 0 to 1: the light's depth map with a 3x3 blur, outside the map is lit; the
1684
+ // 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
1685
+ // bias shrinks by its square to stay the same distance in the world
1505
1686
  'float shadow(){' +
1506
- 'if(shadowParams.x<=0.)return 1.;' +
1687
+ 'if(shadowParams.x<=0.||S.w<=0.)return 1.;' +
1507
1688
  'vec3 q=S.xyz/S.w*.5+.5;' +
1508
1689
  'if(any(greaterThanEqual(abs(q-.5),vec3(.5))))return 1.;' +
1509
- 'q.z-=shadowParams.y;' +
1690
+ 'q.z-=shadowParams.y/(S.w*S.w);' +
1510
1691
  'float s=0.;' +
1511
1692
  'for(int x=-1;x<=1;++x)for(int y=-1;y<=1;++y)' +
1512
1693
  's+=texture(shadowMap,vec3(q.xy+vec2(x,y)*shadowParams.z,q.z));' +
@@ -1534,7 +1715,9 @@ function render3DFragmentSource(fragmentCode)
1534
1715
  'if(!gl_FrontFacing)n=-n;' + // only a double sided mesh shows a back face, light it on the side that is seen
1535
1716
  'if(materialParams.x!=0.)n=normalMapNormal(n);' +
1536
1717
  'float nl=dot(n,-lightDir.xyz);' +
1537
- 'float s=shadow();' +
1718
+ // the shadow is the sun's, or one spotlight's: shadowParams.x is 1 for the sun, 2 and up for that Light3D
1719
+ 'float sh=shadow(),s=shadowParams.x<1.5?sh:1.;' +
1720
+ 'int si=int(shadowParams.x+.5)-2;' +
1538
1721
  // the ambient: one color, or blended from the ground color below to the sky color above by the way the face points
1539
1722
  'vec3 l=(ambientGround.a>0.?mix(ambientGround.rgb,ambientFog.rgb,n.y*.5+.5):ambientFog.rgb)+lightColor.rgb*max(nl,0.)*s;' +
1540
1723
  // the Light3D objects: a point light falls off with distance, a directional one does not and carries the
@@ -1550,6 +1733,11 @@ function render3DFragmentSource(fragmentCode)
1550
1733
  'v/=max(d,1e-6);' +
1551
1734
  'float ln=dot(n,v);' +
1552
1735
  'vec3 lc=extraLightColors[i].rgb*extraLightColors[i].a*a*a;' +
1736
+ // a spotlight's cone: full where its fade starts, nothing at its edge, smooth between; 1 with no cone
1737
+ 'vec4 K=extraLightCones[i];' +
1738
+ 'float k=clamp(dot(K.xyz,-v)-K.w,0.,1.);' +
1739
+ 'lc*=k*k*(3.-2.*k);' +
1740
+ 'if(i==si)lc*=sh;' +
1553
1741
  'l+=lc*max(0.,ln);' +
1554
1742
  'if(lightColor.a>0.)sp+=lc*pow(max(dot(reflect(-v,n),eye),0.),materialParams.y)*step(0.,ln);' +
1555
1743
  '}' +
@@ -1604,6 +1792,7 @@ function render3DUseProgram(program)
1604
1792
  {
1605
1793
  gl.uniform4fv(render3DUniform('extraLights'), r.lightPositions, 0, r.lightCount * 4);
1606
1794
  gl.uniform4fv(render3DUniform('extraLightColors'), r.lightColors, 0, r.lightCount * 4);
1795
+ gl.uniform4fv(render3DUniform('extraLightCones'), r.lightCones, 0, r.lightCount * 4);
1607
1796
  }
1608
1797
  if (program !== r.program)
1609
1798
  {
@@ -1744,18 +1933,19 @@ function render3DUpdateSamplers()
1744
1933
  r.samplers.clear();
1745
1934
  }
1746
1935
 
1747
- // the sampler for a texture's wrap modes, smooth or hard edged, made the first time it is needed
1748
- function render3DSampler(wrap, pixelated)
1936
+ // the sampler for a texture's wrap modes, smooth or hard edged, made the first time it is needed; pixelated is the
1937
+ // draw's, texturePixelated the texture's own, which a smooth texture in a pixel art game sets false
1938
+ function render3DSampler(wrap, pixelated, texturePixelated=tilesPixelated)
1749
1939
  {
1750
1940
  const gl = glContext, r = render3D, [wrapS, wrapT] = glWrapModes(wrap);
1751
- const key = wrapS * 1e5 + wrapT * 2 + (pixelated ? 1 : 0); // the modes are 5 digit numbers
1941
+ const key = wrapS * 1e5 + wrapT * 4 + (pixelated ? 1 : 0) + (texturePixelated ? 2 : 0); // the modes are 5 digit numbers
1752
1942
  let sampler = r.samplers.get(key);
1753
1943
  if (sampler) return sampler;
1754
1944
  sampler = gl.createSampler();
1755
- const sharp = pixelated || tilesPixelated;
1945
+ const sharp = pixelated || texturePixelated;
1756
1946
  gl.samplerParameteri(sampler, gl.TEXTURE_MAG_FILTER, sharp ? gl.NEAREST : gl.LINEAR);
1757
1947
  gl.samplerParameteri(sampler, gl.TEXTURE_MIN_FILTER, pixelated ? gl.NEAREST
1758
- : tilesPixelated ? gl.NEAREST_MIPMAP_LINEAR : gl.LINEAR_MIPMAP_LINEAR);
1948
+ : texturePixelated ? gl.NEAREST_MIPMAP_LINEAR : gl.LINEAR_MIPMAP_LINEAR);
1759
1949
  gl.samplerParameteri(sampler, gl.TEXTURE_WRAP_S, wrapS);
1760
1950
  gl.samplerParameteri(sampler, gl.TEXTURE_WRAP_T, wrapT);
1761
1951
  const anisotropy = gl.getExtension('EXT_texture_filter_anisotropic');
@@ -1783,7 +1973,7 @@ function render3DBindTexture(tileInfo, state=render3D, unit=0)
1783
1973
  // or anisotropy, and filtering it that way costs every untextured fragment
1784
1974
  else
1785
1975
  {
1786
- gl.bindSampler(unit, render3DSampler(textureInfo?.wrap, state.pixelated));
1976
+ gl.bindSampler(unit, render3DSampler(textureInfo?.wrap, state.pixelated, textureInfo?.pixelated ?? tilesPixelated));
1787
1977
  glUpdateMipmaps(texture); // drawn into since its mipmaps were made
1788
1978
  if (!state.pixelated && !glMipmappedTextures.has(texture)) // a hard edged draw never reads them
1789
1979
  {
@@ -1963,7 +2153,9 @@ function render3DSetDrawUniforms(matrix, tileInfo, tint, uvRect, state=render3D)
1963
2153
  // 0 blends them away instead, and -1 is additive, which has to fade into fog differently
1964
2154
  const blendMode = state.blend ? (state.additive ? -1 : 0) : 1;
1965
2155
  const receives = r.shadows && r.passIsDefault && state.receiveShadow ? 1 : 0;
1966
- render3DUniform4f('shadowParams', receives, r.shadowBias, r.shadowSoftness / r.shadowTextureSize, blendMode);
2156
+ // 1 the sun's shadow, 2 and up the shadow of that Light3D, the spotlight that casts them
2157
+ const shadowOf = receives && r.shadowLightIndex >= 0 ? 2 + r.shadowLightIndex : receives;
2158
+ render3DUniform4f('shadowParams', shadowOf, r.shadowDepthBias, r.shadowSoftness / r.shadowTextureSize, blendMode);
1967
2159
 
1968
2160
  // a render target's texture holds premultiplied color, the blend writes it that way, so the shader undoes it
1969
2161
  const textureInfo = render3DTextureOf(tileInfo);
@@ -2029,6 +2221,8 @@ function render3DRenderPass(after2D)
2029
2221
  // gathered before the gl state is taken over, so an assert here leaves nothing to hand back
2030
2222
  const lights = render3DCollectLights();
2031
2223
  r.lightCount = lights.length;
2224
+ const caster = render3DShadowCaster();
2225
+ r.shadowLightIndex = caster ? lights.indexOf(caster) : -1;
2032
2226
  const positions = r.lightPositions, colors = r.lightColors;
2033
2227
  lights.forEach((light, i)=>
2034
2228
  {
@@ -2039,6 +2233,7 @@ function render3DRenderPass(after2D)
2039
2233
  positions[k] = p.x, positions[k+1] = p.y, positions[k+2] = p.z;
2040
2234
  positions[k+3] = light.directional ? -1 : max(0, light.radius); // a negative radius marks a direction
2041
2235
  colors[k] = c.r, colors[k+1] = c.g, colors[k+2] = c.b, colors[k+3] = c.a * light.intensity;
2236
+ r.lightCones.set(render3DLightCone(light), k);
2042
2237
  });
2043
2238
 
2044
2239
  // take over the gl state
@@ -2110,6 +2305,7 @@ function render3DUpdateShadowMap(size)
2110
2305
  {
2111
2306
  const gl = glContext, r = render3D;
2112
2307
  ASSERT(size > 0, 'shadowMapSize must be positive');
2308
+ size = glClampTextureSize(size); // a size the device does not have falls back to the largest it has
2113
2309
  if (r.shadowTexture && r.shadowTextureSize === size) return;
2114
2310
  r.shadowTexture && gl.deleteTexture(r.shadowTexture);
2115
2311
  r.shadowFramebuffer && gl.deleteFramebuffer(r.shadowFramebuffer);
@@ -4162,20 +4358,24 @@ function engineObjectsCallback3D(pos, size, callback, objects=engineObjects, tes
4162
4358
  * InstancedMesh3D - Many copies of one mesh drawn as one call, with their transforms kept on the GPU
4163
4359
  * - For big sets that mostly stay put: an instance costs nothing per frame until it changes, so a hundred thousand
4164
4360
  * trees cost what one tree does; objects and drawMesh batch by themselves too, but rebuild their batch every frame
4165
- * - setMatrixAt and setColorAt change one instance, and only the changed range uploads before the next draw
4361
+ * - setTransformAt, setMatrixAt and setColorAt change one instance, and only the changed range uploads before the
4362
+ * next draw; the matrices and the colors are kept apart, so moving instances uploads 64 bytes each
4363
+ * - For many instances moving every frame, setTransforms places a run of them from arrays of positions, rotations
4364
+ * and scales in one loop, the fastest way; or write matrixData directly and call markDirtyRange
4166
4365
  * - The instances are in world space; the object's own pos3D, rotation3D and scale3D do not move them
4167
- * - The whole set is culled by one bounding sphere around the origin, and casts and receives shadows like any object
4366
+ * - The whole set is culled by one bounding sphere around the origin, worked out when culling reads it, or set by hand
4367
+ * with radius; it casts and receives shadows like any object
4168
4368
  * - The object's flags cover the whole set, one emissive, one tileInfo, one shader; only the colors are per instance
4169
4369
  * - A mirrored instance, one with a negative scale, shows its inside unless the mesh is doubleSided
4170
4370
  * - A transparent set draws in one go in the transparent stage, its instances are not sorted against each other;
4171
4371
  * the set sorts against other transparent draws by the object's position, so put pos3D at its middle
4172
- * - pick, the raycast and the collect helpers do not see the instances, test them yourself from instanceData
4372
+ * - pick, the raycast and the collect helpers do not see the instances, test them yourself from matrixData
4173
4373
  * @extends EngineObject3D
4174
4374
  * @memberof Render3D
4175
4375
  * @example
4176
4376
  * const forest = new InstancedMesh3D(treeMesh, 1000);
4177
4377
  * for (let i = 0; i < 1000; ++i)
4178
- * forest.setMatrixAt(i, buildMatrix(randomGroundPos(), vec3(0, rand(2*PI), 0)));
4378
+ * forest.setTransformAt(i, randomGroundPos(), vec3(0, rand(2*PI), 0));
4179
4379
  */
4180
4380
  class InstancedMesh3D extends EngineObject3D
4181
4381
  {
@@ -4194,39 +4394,112 @@ class InstancedMesh3D extends EngineObject3D
4194
4394
  this.count = count;
4195
4395
  /** @property {number} - How many instances it was made with */
4196
4396
  this.maxCount = count;
4197
- /** @property {Float32Array} - The per instance values the shader reads, 24 floats each: the matrix, the color
4198
- * and the uv rect; edit it directly and call markDirty for the instances changed */
4199
- this.instanceData = new Float32Array(count * RENDER3D_INSTANCE_FLOATS);
4200
- /** @property {number} - Radius of the sphere around the origin that holds every instance set so far, for
4201
- * culling; from the farthest instance and the largest scale, and the mesh's size when it draws */
4202
- this.radius = 0;
4203
- this.reach = 0; // the farthest any instance's position has been from the origin
4204
- this.maxScale = 0; // and the largest scale any instance has had
4205
- /** @property {number} - First instance to upload before the next draw */
4397
+ /** @property {Float32Array} - Each instance's matrix, 16 floats, what moving them uploads; edit it directly and
4398
+ * call markDirty for the instances changed */
4399
+ this.matrixData = new Float32Array(count * RENDER3D_MATRIX_FLOATS);
4400
+ /** @property {Float32Array} - Each instance's color and uv rect, 8 floats, uploaded only when they change; edit
4401
+ * it directly and call markColorDirty for the instances changed */
4402
+ this.colorData = new Float32Array(count * RENDER3D_COLOR_FLOATS);
4403
+ /** @property {number} - First instance whose matrix uploads before the next draw */
4206
4404
  this.dirtyStart = 0;
4207
- /** @property {number} - One past the last instance to upload, so nothing uploads when it is not past dirtyStart */
4405
+ /** @property {number} - One past the last instance whose matrix uploads, nothing when it is not past dirtyStart */
4208
4406
  this.dirtyEnd = count;
4209
- this.buffer = undefined; // the GPU copy of instanceData
4210
- this.bufferGeneration = -1; // the context it was made under
4211
- this.uvTileInfo = tileInfo; // the tile the uv rects were written for
4212
- const uv = render3DGetTileUVs(tileInfo), data = this.instanceData;
4407
+ /** @property {number} - First instance whose color uploads before the next draw */
4408
+ this.colorDirtyStart = 0;
4409
+ /** @property {number} - One past the last instance whose color uploads */
4410
+ this.colorDirtyEnd = count;
4411
+ // the farthest any instance's position has been from the origin, and the largest scale any has had, both
4412
+ // squared, and the instances set since radius last took them in: setting one does no bounds work at all
4413
+ this.reachSquared = 0;
4414
+ this.scaleSquared = 0;
4415
+ this.boundsStart = 0;
4416
+ this.boundsEnd = count;
4417
+ /** @type {number|undefined} */
4418
+ this.fixedRadius = undefined; // a radius set by hand, see radius
4419
+ this.buffer = undefined; // the GPU copy of matrixData
4420
+ /** @type {WebGLBuffer|undefined} */
4421
+ this.colorBuffer = undefined; // and of colorData
4422
+ this.bufferGeneration = -1; // the context they were made under
4423
+ this.uvTileInfo = tileInfo; // the tile the uv rects were written for
4424
+ const uv = render3DGetTileUVs(tileInfo), matrices = this.matrixData, colors = this.colorData;
4213
4425
  for (let i = 0; i < count; ++i)
4214
4426
  {
4215
- this.setMatrixAt(i, RENDER3D_IDENTITY);
4216
- const k = i * RENDER3D_INSTANCE_FLOATS;
4217
- data[k+16] = color.r; data[k+17] = color.g; data[k+18] = color.b; data[k+19] = color.a;
4218
- data[k+20] = uv.x; data[k+21] = uv.y; data[k+22] = uv.w; data[k+23] = uv.h;
4427
+ matrices.set(RENDER3D_IDENTITY.m, i * RENDER3D_MATRIX_FLOATS);
4428
+ const k = i * RENDER3D_COLOR_FLOATS;
4429
+ colors[k] = color.r; colors[k+1] = color.g; colors[k+2] = color.b; colors[k+3] = color.a;
4430
+ colors[k+4] = uv.x; colors[k+5] = uv.y; colors[k+6] = uv.w; colors[k+7] = uv.h;
4219
4431
  }
4220
4432
  }
4221
4433
 
4434
+ /** Place an instance by its position, rotation and scale, what buildMatrix takes, written straight in with no
4435
+ * matrix made; in world space
4436
+ * @param {number} i
4437
+ * @param {Vector3} [pos]
4438
+ * @param {Vector3} [rotation] - Euler angles in radians
4439
+ * @param {Vector3} [scale] */
4440
+ setTransformAt(i, pos, rotation, scale)
4441
+ {
4442
+ ASSERT(i >= 0 && i < this.maxCount, 'instance index out of range');
4443
+ matrix4Compose(this.matrixData, i * RENDER3D_MATRIX_FLOATS, pos, rotation, scale);
4444
+ this.markDirty(i);
4445
+ }
4446
+
4447
+ /** Place a run of instances from arrays indexed by instance, plain or typed, as a game keeps them: each is placed
4448
+ * as setTransformAt places it, all in one loop with nothing made, the fastest way to move many every frame;
4449
+ * in world space
4450
+ * @param {number} start - First instance
4451
+ * @param {number} count - How many
4452
+ * @param {ArrayLike<number>} x - Positions
4453
+ * @param {ArrayLike<number>} y
4454
+ * @param {ArrayLike<number>} z
4455
+ * @param {ArrayLike<number>} [rx] - Euler angles in radians, all three or none for upright
4456
+ * @param {ArrayLike<number>} [ry]
4457
+ * @param {ArrayLike<number>} [rz]
4458
+ * @param {ArrayLike<number>} [sx] - Scales, all three or none for 1
4459
+ * @param {ArrayLike<number>} [sy]
4460
+ * @param {ArrayLike<number>} [sz] */
4461
+ setTransforms(start, count, x, y, z, rx, ry, rz, sx, sy, sz)
4462
+ {
4463
+ ASSERT(start >= 0 && count >= 0 && start + count <= this.maxCount, 'instance range out of range');
4464
+ ASSERT(!rx === !ry && !rx === !rz && !sx === !sy && !sx === !sz, 'rotations and scales come in threes');
4465
+ const m = this.matrixData, end = start + count;
4466
+ for (let i = start; i < end; ++i)
4467
+ {
4468
+ // matrix4Compose's expressions, so a run placed here is the same as each placed by setTransformAt
4469
+ const k = i * RENDER3D_MATRIX_FLOATS;
4470
+ const scaleX = sx ? sx[i] : 1, scaleY = sy ? sy[i] : 1, scaleZ = sz ? sz[i] : 1;
4471
+ if (rx)
4472
+ {
4473
+ const cx = cos(rx[i]), sinX = sin(rx[i]);
4474
+ const cy = cos(ry[i]), sinY = sin(ry[i]);
4475
+ const cz = cos(rz[i]), sinZ = sin(rz[i]);
4476
+ m[k] = (cy*cz + sinY*sinX*sinZ) * scaleX; m[k+1] = cx*sinZ * scaleX;
4477
+ m[k+2] = (-sinY*cz + cy*sinX*sinZ) * scaleX;
4478
+ m[k+4] = (-cy*sinZ + sinY*sinX*cz) * scaleY; m[k+5] = cx*cz * scaleY;
4479
+ m[k+6] = (sinY*sinZ + cy*sinX*cz) * scaleY;
4480
+ m[k+8] = sinY*cx * scaleZ; m[k+9] = -sinX * scaleZ; m[k+10] = cy*cx * scaleZ;
4481
+ }
4482
+ else
4483
+ {
4484
+ m[k] = scaleX; m[k+1] = 0; m[k+2] = 0;
4485
+ m[k+4] = 0; m[k+5] = scaleY; m[k+6] = 0;
4486
+ m[k+8] = 0; m[k+9] = 0; m[k+10] = scaleZ;
4487
+ }
4488
+ m[k+3] = m[k+7] = m[k+11] = 0; m[k+15] = 1;
4489
+ m[k+12] = x[i], m[k+13] = y[i], m[k+14] = z[i];
4490
+ }
4491
+ count && this.markDirtyRange(start, end);
4492
+ }
4493
+
4222
4494
  /** Place an instance, in world space
4223
4495
  * @param {number} i
4224
4496
  * @param {Matrix4} matrix */
4225
4497
  setMatrixAt(i, matrix)
4226
4498
  {
4227
4499
  ASSERT(i >= 0 && i < this.maxCount, 'instance index out of range');
4228
- const data = this.instanceData, k = i * RENDER3D_INSTANCE_FLOATS, m = matrix.m;
4229
- data.set(m, k);
4500
+ const data = this.matrixData, k = i * RENDER3D_MATRIX_FLOATS, m = matrix.m;
4501
+ for (let j = 0; j < 16; ++j)
4502
+ data[k+j] = m[j]; // for 16 floats a loop beats set's call
4230
4503
  this.markDirty(i);
4231
4504
  }
4232
4505
 
@@ -4236,8 +4509,8 @@ class InstancedMesh3D extends EngineObject3D
4236
4509
  getMatrixAt(i)
4237
4510
  {
4238
4511
  ASSERT(i >= 0 && i < this.maxCount, 'instance index out of range');
4239
- const k = i * RENDER3D_INSTANCE_FLOATS, matrix = new Matrix4;
4240
- matrix.m.set(this.instanceData.subarray(k, k + 16));
4512
+ const k = i * RENDER3D_MATRIX_FLOATS, matrix = new Matrix4;
4513
+ matrix.m.set(this.matrixData.subarray(k, k + 16));
4241
4514
  return matrix;
4242
4515
  }
4243
4516
 
@@ -4248,26 +4521,71 @@ class InstancedMesh3D extends EngineObject3D
4248
4521
  {
4249
4522
  ASSERT(i >= 0 && i < this.maxCount, 'instance index out of range');
4250
4523
  ASSERT(isColor(color), 'color must be a Color');
4251
- const data = this.instanceData, k = i * RENDER3D_INSTANCE_FLOATS;
4252
- data[k+16] = color.r; data[k+17] = color.g; data[k+18] = color.b; data[k+19] = color.a;
4253
- this.markDirty(i);
4524
+ const data = this.colorData, k = i * RENDER3D_COLOR_FLOATS;
4525
+ data[k] = color.r; data[k+1] = color.g; data[k+2] = color.b; data[k+3] = color.a;
4526
+ this.markColorDirty(i);
4254
4527
  }
4255
4528
 
4256
- /** Note that an instance changed, so it uploads before the next draw and the bounds hold it; setMatrixAt and
4257
- * setColorAt call this, and so must an edit made straight to instanceData
4529
+ /** Note that an instance's matrix changed, so it uploads before the next draw and the bounds take it in when
4530
+ * they are read; setTransformAt and setMatrixAt call this, and so must an edit made straight to matrixData
4258
4531
  * @param {number} i */
4259
4532
  markDirty(i)
4260
4533
  {
4261
- this.dirtyStart = min(this.dirtyStart, i);
4262
- this.dirtyEnd = max(this.dirtyEnd, i + 1);
4534
+ if (i < this.dirtyStart) this.dirtyStart = i;
4535
+ if (i >= this.dirtyEnd) this.dirtyEnd = i + 1;
4536
+ if (i < this.boundsStart) this.boundsStart = i;
4537
+ if (i >= this.boundsEnd) this.boundsEnd = i + 1;
4538
+ }
4539
+
4540
+ /** Note that a run of instances' matrices changed, for code that writes matrixData directly, the lowest level
4541
+ * way to move many: they upload before the next draw and the bounds take them in when they are read
4542
+ * @param {number} start - First instance
4543
+ * @param {number} end - One past the last */
4544
+ markDirtyRange(start, end)
4545
+ {
4546
+ ASSERT(start >= 0 && end <= this.maxCount && start <= end, 'instance range out of range');
4547
+ if (start < this.dirtyStart) this.dirtyStart = start;
4548
+ if (end > this.dirtyEnd) this.dirtyEnd = end;
4549
+ if (start < this.boundsStart) this.boundsStart = start;
4550
+ if (end > this.boundsEnd) this.boundsEnd = end;
4551
+ }
4263
4552
 
4264
- // the bounds grow to hold where it is now and how big, read back from the matrix it has
4265
- const d = this.instanceData, k = i * RENDER3D_INSTANCE_FLOATS;
4266
- this.reach = max(this.reach, hypot(d[k+12], d[k+13], d[k+14]));
4267
- this.maxScale = max(this.maxScale, render3DMaxStretch(d, k));
4268
- const meshRadius = this.mesh.radius || this.mesh.computeRadius(); // measured once, the draw takes a new size up
4269
- this.radius = this.reach + meshRadius * this.maxScale;
4553
+ /** Note that an instance's color changed, so it uploads before the next draw; setColorAt calls this, and so must
4554
+ * an edit made straight to colorData
4555
+ * @param {number} i */
4556
+ markColorDirty(i)
4557
+ {
4558
+ if (i < this.colorDirtyStart) this.colorDirtyStart = i;
4559
+ if (i >= this.colorDirtyEnd) this.colorDirtyEnd = i + 1;
4560
+ }
4561
+
4562
+ /** Radius of the sphere around the origin that holds every instance set so far, for culling: the farthest
4563
+ * instance, and the mesh's size at the largest scale; it only grows, and takes in the instances set since it
4564
+ * was last read, so with frustum culling off it is never worked out. Set it to fix the sphere, which a big set
4565
+ * that is culled can use to skip that work, and set it to undefined to go back to the bounds
4566
+ * @return {number} */
4567
+ get radius()
4568
+ {
4569
+ if (this.fixedRadius !== undefined) return this.fixedRadius;
4570
+ if (this.boundsEnd > this.boundsStart)
4571
+ {
4572
+ // the bounds grow to hold where each is now and how big, read back from its matrix, squared
4573
+ const d = this.matrixData;
4574
+ let reachSquared = this.reachSquared, scaleSquared = this.scaleSquared;
4575
+ for (let i = this.boundsStart; i < this.boundsEnd; ++i)
4576
+ {
4577
+ const k = i * RENDER3D_MATRIX_FLOATS, x = d[k+12], y = d[k+13], z = d[k+14];
4578
+ const reach = x*x + y*y + z*z, scale = render3DMaxStretchSquared(d, k);
4579
+ if (reach > reachSquared) reachSquared = reach;
4580
+ if (scale > scaleSquared) scaleSquared = scale;
4581
+ }
4582
+ this.reachSquared = reachSquared, this.scaleSquared = scaleSquared;
4583
+ this.boundsStart = Infinity, this.boundsEnd = 0;
4584
+ }
4585
+ const mesh = this.mesh, meshRadius = mesh.radius || mesh.computeRadius(); // the mesh measured once
4586
+ return this.reachSquared ** .5 + meshRadius * this.scaleSquared ** .5;
4270
4587
  }
4588
+ set radius(radius) { this.fixedRadius = radius; }
4271
4589
 
4272
4590
  /** Draws every instance as one call, uploading the ones that changed first
4273
4591
  * @return {void} */
@@ -4281,41 +4599,46 @@ class InstancedMesh3D extends EngineObject3D
4281
4599
  if (r.shadowPass && !r.lighting) return; // unlit things cast no shadow
4282
4600
  render3DMeshUpload(mesh);
4283
4601
  if (!mesh.bufferCount) return;
4284
- this.radius = this.reach + mesh.radius * this.maxScale; // the mesh may have grown since
4285
4602
  if (r.frustumCulling && !render3DSphereVisible(0, 0, 0, this.radius)) return;
4286
4603
 
4287
4604
  // the uv rect is the object's tile for every instance, rewritten when the tile changes
4288
4605
  if (this.uvTileInfo !== this.tileInfo)
4289
4606
  {
4290
- const uv = render3DGetTileUVs(this.tileInfo), data = this.instanceData;
4291
- for (let k = 20; k < data.length; k += RENDER3D_INSTANCE_FLOATS)
4607
+ const uv = render3DGetTileUVs(this.tileInfo), data = this.colorData;
4608
+ for (let k = 4; k < data.length; k += RENDER3D_COLOR_FLOATS)
4292
4609
  data[k] = uv.x, data[k+1] = uv.y, data[k+2] = uv.w, data[k+3] = uv.h;
4293
4610
  this.uvTileInfo = this.tileInfo;
4294
- this.dirtyStart = 0, this.dirtyEnd = this.maxCount;
4611
+ this.colorDirtyStart = 0, this.colorDirtyEnd = this.maxCount;
4295
4612
  }
4296
4613
 
4297
- // the GPU copy: all of it under a fresh context, otherwise just the changed range
4614
+ // the GPU copies: all of each under a fresh context, otherwise just the changed ranges
4298
4615
  if (!this.buffer || this.bufferGeneration !== r.contextGeneration)
4299
4616
  {
4300
- this.buffer = gl.createBuffer();
4301
4617
  this.bufferGeneration = r.contextGeneration;
4302
- gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
4303
- gl.bufferData(gl.ARRAY_BUFFER, this.instanceData, gl.DYNAMIC_DRAW);
4618
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer = gl.createBuffer());
4619
+ gl.bufferData(gl.ARRAY_BUFFER, this.matrixData, gl.DYNAMIC_DRAW);
4620
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.colorBuffer = gl.createBuffer());
4621
+ gl.bufferData(gl.ARRAY_BUFFER, this.colorData, gl.DYNAMIC_DRAW);
4304
4622
  }
4305
- else if (this.dirtyEnd > this.dirtyStart)
4623
+ else
4306
4624
  {
4307
- const start = this.dirtyStart * RENDER3D_INSTANCE_FLOATS, end = this.dirtyEnd * RENDER3D_INSTANCE_FLOATS;
4308
- gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
4309
- gl.bufferSubData(gl.ARRAY_BUFFER, start * 4, this.instanceData, start, end - start);
4625
+ const upload = (buffer, data, floats, start, end)=>
4626
+ {
4627
+ if (end <= start) return;
4628
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
4629
+ gl.bufferSubData(gl.ARRAY_BUFFER, start * floats * 4, data, start * floats, (end - start) * floats);
4630
+ };
4631
+ upload(this.buffer, this.matrixData, RENDER3D_MATRIX_FLOATS, this.dirtyStart, this.dirtyEnd);
4632
+ upload(this.colorBuffer, this.colorData, RENDER3D_COLOR_FLOATS, this.colorDirtyStart, this.colorDirtyEnd);
4310
4633
  }
4311
- this.dirtyStart = Infinity, this.dirtyEnd = 0;
4634
+ this.dirtyStart = this.colorDirtyStart = Infinity, this.dirtyEnd = this.colorDirtyEnd = 0;
4312
4635
 
4313
4636
  // one draw under the object's state, the mesh setting the culling as drawMesh does
4314
4637
  r.flush();
4315
4638
  render3DFlushBeforeOverlay(); // as in drawMesh, what was drawn before goes under it
4316
4639
  const cullBackFaces = r.cullBackFaces, tileInfo = this.tileInfo;
4317
4640
  r.cullBackFaces = !mesh.doubleSided;
4318
- render3DDrawInstanced(mesh, this.buffer, this.count, render3DTextureOf(tileInfo), r);
4641
+ render3DDrawInstanced(mesh, this.buffer, this.count, render3DTextureOf(tileInfo), r, this.colorBuffer);
4319
4642
  r.cullBackFaces = cullBackFaces;
4320
4643
  }
4321
4644
 
@@ -4324,8 +4647,8 @@ class InstancedMesh3D extends EngineObject3D
4324
4647
  destroy(immediate)
4325
4648
  {
4326
4649
  if (this.buffer && this.bufferGeneration === render3D?.contextGeneration)
4327
- glContext?.deleteBuffer(this.buffer);
4328
- this.buffer = undefined;
4650
+ glContext?.deleteBuffer(this.buffer), glContext?.deleteBuffer(this.colorBuffer);
4651
+ this.buffer = this.colorBuffer = undefined;
4329
4652
  super.destroy(immediate);
4330
4653
  }
4331
4654
  }
@@ -4334,13 +4657,16 @@ class InstancedMesh3D extends EngineObject3D
4334
4657
  /**
4335
4658
  * Light3D - A light that is an EngineObject3D, so it can move, follow a parent or be destroyed like anything else
4336
4659
  * - A point light: it lights what is near it and fades out by its radius, DirectionalLight3D shines from far away
4337
- * - Only the sun, render3D.sunDirection, casts shadows; these light and make highlights without one
4660
+ * - A coneAngle makes it a spotlight: it shines along its own forward, turned by rotation3D or by what it is
4661
+ * attached to, inside the cone, fading over the part of it coneSoftness says
4662
+ * - The sun, render3D.sunDirection, casts the shadows; a spotlight can cast them in its place, see
4663
+ * render3D.shadowLight; the other lights light and make highlights without one
4338
4664
  * - Only the 8 lights nearest the camera are used each frame
4339
4665
  * - radius is where the light fades out, and it fades fast, so a small radius wants a higher intensity
4340
4666
  * - intensity multiplies the color, above 1 for a light brighter than white
4341
4667
  * - radius is a world distance, so scale3D does not change it
4342
4668
  * - An alpha, an intensity or a radius of 0 switches it off, and a light that is off takes none of those slots
4343
- * - Draws nothing itself, add a glow with drawSoftDisc or a small emissive mesh if it should be seen
4669
+ * - Draws nothing but its glow, when it has one; add a small emissive mesh if the lamp itself should be seen
4344
4670
  * @extends EngineObject3D
4345
4671
  * @memberof Render3D
4346
4672
  * @example
@@ -4366,6 +4692,12 @@ class Light3D extends EngineObject3D
4366
4692
  /** @property {boolean} - Shine from far away, from its position toward the origin, instead of out from its
4367
4693
  * position with a falloff; DirectionalLight3D sets it */
4368
4694
  this.directional = false;
4695
+ /** @property {number} - Makes it a spotlight: the angle in radians from its forward out to the edge of its
4696
+ * cone, so the beam is twice this across; 0 for a light that shines every way */
4697
+ this.coneAngle = 0;
4698
+ /** @property {number} - How much of the cone is its fading edge: 0 a hard edge, .2 by default, the outer
4699
+ * fifth, 1 fading all the way from the middle of the beam */
4700
+ this.coneSoftness = .2;
4369
4701
  /** @property {number} - Size of a soft hazy glow drawn over the light, like a lamp at night, 0 for none; it
4370
4702
  * is added onto what is behind it, and what is in front of the light hides it */
4371
4703
  this.glow = 0;
@@ -4383,45 +4715,20 @@ class Light3D extends EngineObject3D
4383
4715
  const r = render3D, c = this.color, pos = render3DObjectMatrix(this).getTranslation();
4384
4716
  const toCamera = r.camera.pos.subtract(pos), distance = toCamera.length();
4385
4717
  const at = distance ? pos.add(toCamera.scale(min(this.glow, distance) / 2 / distance)) : pos;
4386
- const color = rgb(c.r, c.g, c.b, c.a * min(this.intensity, 1));
4387
- r.drawBillboard(at, vec2(this.glow), render3DGlow(this.glowFalloff), color);
4718
+ const color = rgb(c.r, c.g, c.b, c.a * min(this.intensity, 1)), texture = engineGlowTexture(this.glowFalloff);
4719
+ // a browser that can not make the glow's texture draws no glow, not a plain square
4720
+ if (texture || !glContext)
4721
+ r.drawBillboard(at, vec2(this.glow), texture, color);
4388
4722
  }
4389
4723
  }
4390
4724
 
4391
- // how strong a light's glow is at a distance from its middle, 0 there to 1 at the edge: a bell, full in the middle
4392
- // and nothing at the edge, fading faster the higher the falloff
4393
- function render3DGlowAlpha(r, falloff)
4394
- {
4395
- const k = 3.5 * falloff, edge = Math.exp(-k);
4396
- return (Math.exp(-k * r * r) - edge) / (1 - edge);
4397
- }
4398
-
4399
- // the soft round glow of the lights, one texture for each falloff, rounded to a tenth so a changing falloff makes
4400
- // only a few, each made once from a canvas; undefined headless or without a canvas
4401
- const render3DGlowTextures = new Map;
4402
- function render3DGlow(falloff=1)
4403
- {
4404
- ASSERT(isNumber(falloff) && falloff > 0, 'glowFalloff must be a number above 0');
4405
- const key = max(round(falloff * 10), 1) / 10;
4406
- let texture = render3DGlowTextures.get(key);
4407
- if (texture || !glContext || typeof OffscreenCanvas == 'undefined') return texture;
4408
- const size = 64, context = createCanvasContext(size), steps = 16;
4409
- const gradient = context.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
4410
- for (let i = 0; i <= steps; ++i)
4411
- gradient.addColorStop(i / steps, 'rgba(255,255,255,' + render3DGlowAlpha(i / steps, key).toFixed(4) + ')');
4412
- context.fillStyle = gradient;
4413
- context.fillRect(0, 0, size, size);
4414
- render3DGlowTextures.set(key, texture = new TextureInfo(context.canvas));
4415
- return texture;
4416
- }
4417
-
4418
4725
  ///////////////////////////////////////////////////////////////////////////////
4419
4726
  /**
4420
4727
  * DirectionalLight3D - A Light3D that shines from far away with no falloff, like sunlight
4421
4728
  * - It shines from its position toward the origin, like a three.js DirectionalLight: only the direction to it
4422
4729
  * counts, so moving it or its parent swings the light around; parent it to a sun in the sky and it follows
4423
4730
  * - It cannot sit on the origin, since that leaves no direction
4424
- * - Like every Light3D it casts no shadow, only the sun, render3D.sunDirection, does
4731
+ * - It casts no shadow: the sun, render3D.sunDirection, does, or a spotlight set as render3D.shadowLight
4425
4732
  * @extends Light3D
4426
4733
  * @memberof Render3D
4427
4734
  * @example