littlejsengine 1.21.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,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,36 @@ 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
+ // 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
+
377
448
  // unit circle directions for a number of sides, [cos, sin, cos, sin, ...] including the closing point, cached
378
449
  const render3DCircleCache = new Map;
379
450
  function render3DCircle(sides)
@@ -425,7 +496,15 @@ class Render3DPlugin
425
496
  {
426
497
  ASSERT(!render3D, 'Render3D plugin already initialized');
427
498
  render3D = this;
428
- 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(),
429
508
  'the batch state functions must list RENDER3D_STATE_FIELDS');
430
509
 
431
510
  /** @property {Camera3D} - The camera */
@@ -471,8 +550,16 @@ class Render3DPlugin
471
550
  // shadows
472
551
  /** @property {boolean} - Cast real shadows from the sun, off by default and free when off */
473
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;
474
556
  /** @property {number} - Size of the shadow map in pixels, bigger is sharper and slower */
475
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;
476
563
  /** @property {number} - World size the shadow map covers around shadowCenter, smaller is sharper; it is a
477
564
  * square facing the light, so it turns as the light does, and about 1.5 times an area's width covers it from
478
565
  * any angle */
@@ -485,50 +572,6 @@ class Render3DPlugin
485
572
  /** @property {number} - How much to blur the shadow edges */
486
573
  this.shadowSoftness = 1;
487
574
 
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
575
  // the pass
533
576
  /** @property {Function|undefined} - Draw solid world here, it runs again for shadows so only draw in it
534
577
  * @type {Function|undefined} */
@@ -551,9 +594,6 @@ class Render3DPlugin
551
594
  /** @property {boolean} - Sample textures through mipmaps so they do not shimmer in the distance, false uses
552
595
  * each texture's own filtering like 2D */
553
596
  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
597
  /** @property {number} - Anisotropic filtering for textures seen at an angle, 1 to 16, 1 is off; needs mipmaps */
558
598
  this.anisotropy = 4;
559
599
 
@@ -597,7 +637,6 @@ class Render3DPlugin
597
637
  this.cameraBack = vec3(0, 0, 1); // its opposite, the normal of camera facing draws
598
638
 
599
639
  // internal state
600
- this.blend = false; // blending on, set by the stages
601
640
  /** @type {Array<Array<number>>} */
602
641
  this.frustumPlanes = []; // the view as six inward planes [x, y, z, w]
603
642
  /** @type {Array<Array<number>>} */
@@ -637,6 +676,9 @@ class Render3DPlugin
637
676
  this.passIsDefault = true; // the running pass is the default layer, the only one shadowed
638
677
  this.lightPositions = new Float32Array(RENDER3D_MAX_LIGHTS * 4); // Light3D uniforms, filled each pass
639
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
640
682
 
641
683
  // the stream of immediate mode draws
642
684
  /** @type {WebGLBuffer|undefined} */
@@ -664,6 +706,116 @@ class Render3DPlugin
664
706
  engineAddPlugin(undefined, render3DRender, render3DContextLost, render3DContextRestored, render3DPreRender);
665
707
  }
666
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
+
667
819
  ///////////////////////////////////////////////////////////////////////////
668
820
  // Matrices and picking
669
821
 
@@ -773,7 +925,8 @@ class Render3DPlugin
773
925
  }
774
926
 
775
927
  /** 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
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
777
930
  * @param {Sound} sound
778
931
  * @param {Vector3} pos3D
779
932
  * @param {number} [volume]
@@ -787,7 +940,9 @@ class Render3DPlugin
787
940
  // the 3D range fade and pan, Sound.play with no position does the rest
788
941
  ASSERT(sound instanceof Sound, 'sound must be a Sound');
789
942
  ASSERT(isVector3(pos3D), 'pos3D must be a vec3');
790
- 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;
791
946
  if (range)
792
947
  {
793
948
  const distance = offset.length();
@@ -851,15 +1006,15 @@ class Render3DPlugin
851
1006
  if (this.frustumCulling && !render3DSphereVisible(m[12], m[13], m[14], mesh.radius * render3DMaxStretch(m)))
852
1007
  return;
853
1008
  // 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
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
859
1011
  if (!this.blend && this.depthTest && (mesh.instanced ?? this.instancing))
860
1012
  render3DInstance(mesh, matrix, tileInfo, color);
861
1013
  else
862
1014
  {
1015
+ const cullBackFaces = this.cullBackFaces, mirrored = this.mirrored;
1016
+ this.cullBackFaces = !mesh.doubleSided;
1017
+ this.mirrored = render3DMirrors(matrix);
863
1018
  this.flush();
864
1019
  render3DFlushBeforeOverlay();
865
1020
  render3DSetDrawUniforms(matrix, tileInfo, color);
@@ -867,8 +1022,8 @@ class Render3DPlugin
867
1022
  glContext.drawElements(glContext.TRIANGLES, mesh.bufferCount, mesh.indexType, 0);
868
1023
  ++drawCount;
869
1024
  primitiveCount += mesh.bufferCount / 3;
1025
+ this.cullBackFaces = cullBackFaces, this.mirrored = mirrored;
870
1026
  }
871
- this.cullBackFaces = cullBackFaces, this.mirrored = mirrored;
872
1027
  }
873
1028
 
874
1029
  /** Draw a triangle strip, batched into the stream with the current draw state
@@ -1064,6 +1219,23 @@ class Render3DPlugin
1064
1219
  /** Rebuild the light's view projection around the shadow center, called automatically each frame shadows are on */
1065
1220
  updateShadowMatrix()
1066
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
1067
1239
  ASSERT(this.shadowRange > 0, 'shadowRange must be positive');
1068
1240
  const range = this.shadowRange > 0 ? this.shadowRange : 1, half = range / 2;
1069
1241
  const toSun = this.sunDirection.normalize();
@@ -1493,6 +1665,7 @@ function render3DFragmentSource(fragmentCode)
1493
1665
  'precision highp float;' +
1494
1666
  'uniform vec4 lightDir,lightColor,ambientFog,ambientGround,fogColor,shadowParams;' +
1495
1667
  'uniform vec4 extraLights[' + RENDER3D_MAX_LIGHTS + '],extraLightColors[' + RENDER3D_MAX_LIGHTS + '];' +
1668
+ 'uniform vec4 extraLightCones[' + RENDER3D_MAX_LIGHTS + '];' +
1496
1669
  'uniform int extraLightCount;' +
1497
1670
  'uniform vec3 cameraPos;' +
1498
1671
  'uniform vec4 materialParams,emissiveTint,skyTop,skyHorizon,skyBottom;' +
@@ -1501,12 +1674,14 @@ function render3DFragmentSource(fragmentCode)
1501
1674
  'uniform highp sampler2DShadow shadowMap;' +
1502
1675
  'in vec3 P,N;in vec2 T,L;in vec4 C,S;' +
1503
1676
  '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
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
1505
1680
  'float shadow(){' +
1506
- 'if(shadowParams.x<=0.)return 1.;' +
1681
+ 'if(shadowParams.x<=0.||S.w<=0.)return 1.;' +
1507
1682
  'vec3 q=S.xyz/S.w*.5+.5;' +
1508
1683
  'if(any(greaterThanEqual(abs(q-.5),vec3(.5))))return 1.;' +
1509
- 'q.z-=shadowParams.y;' +
1684
+ 'q.z-=shadowParams.y/(S.w*S.w);' +
1510
1685
  'float s=0.;' +
1511
1686
  'for(int x=-1;x<=1;++x)for(int y=-1;y<=1;++y)' +
1512
1687
  's+=texture(shadowMap,vec3(q.xy+vec2(x,y)*shadowParams.z,q.z));' +
@@ -1534,7 +1709,9 @@ function render3DFragmentSource(fragmentCode)
1534
1709
  'if(!gl_FrontFacing)n=-n;' + // only a double sided mesh shows a back face, light it on the side that is seen
1535
1710
  'if(materialParams.x!=0.)n=normalMapNormal(n);' +
1536
1711
  'float nl=dot(n,-lightDir.xyz);' +
1537
- '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;' +
1538
1715
  // the ambient: one color, or blended from the ground color below to the sky color above by the way the face points
1539
1716
  'vec3 l=(ambientGround.a>0.?mix(ambientGround.rgb,ambientFog.rgb,n.y*.5+.5):ambientFog.rgb)+lightColor.rgb*max(nl,0.)*s;' +
1540
1717
  // the Light3D objects: a point light falls off with distance, a directional one does not and carries the
@@ -1550,6 +1727,11 @@ function render3DFragmentSource(fragmentCode)
1550
1727
  'v/=max(d,1e-6);' +
1551
1728
  'float ln=dot(n,v);' +
1552
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;' +
1553
1735
  'l+=lc*max(0.,ln);' +
1554
1736
  'if(lightColor.a>0.)sp+=lc*pow(max(dot(reflect(-v,n),eye),0.),materialParams.y)*step(0.,ln);' +
1555
1737
  '}' +
@@ -1604,6 +1786,7 @@ function render3DUseProgram(program)
1604
1786
  {
1605
1787
  gl.uniform4fv(render3DUniform('extraLights'), r.lightPositions, 0, r.lightCount * 4);
1606
1788
  gl.uniform4fv(render3DUniform('extraLightColors'), r.lightColors, 0, r.lightCount * 4);
1789
+ gl.uniform4fv(render3DUniform('extraLightCones'), r.lightCones, 0, r.lightCount * 4);
1607
1790
  }
1608
1791
  if (program !== r.program)
1609
1792
  {
@@ -1744,18 +1927,19 @@ function render3DUpdateSamplers()
1744
1927
  r.samplers.clear();
1745
1928
  }
1746
1929
 
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)
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)
1749
1933
  {
1750
1934
  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
1935
+ const key = wrapS * 1e5 + wrapT * 4 + (pixelated ? 1 : 0) + (texturePixelated ? 2 : 0); // the modes are 5 digit numbers
1752
1936
  let sampler = r.samplers.get(key);
1753
1937
  if (sampler) return sampler;
1754
1938
  sampler = gl.createSampler();
1755
- const sharp = pixelated || tilesPixelated;
1939
+ const sharp = pixelated || texturePixelated;
1756
1940
  gl.samplerParameteri(sampler, gl.TEXTURE_MAG_FILTER, sharp ? gl.NEAREST : gl.LINEAR);
1757
1941
  gl.samplerParameteri(sampler, gl.TEXTURE_MIN_FILTER, pixelated ? gl.NEAREST
1758
- : tilesPixelated ? gl.NEAREST_MIPMAP_LINEAR : gl.LINEAR_MIPMAP_LINEAR);
1942
+ : texturePixelated ? gl.NEAREST_MIPMAP_LINEAR : gl.LINEAR_MIPMAP_LINEAR);
1759
1943
  gl.samplerParameteri(sampler, gl.TEXTURE_WRAP_S, wrapS);
1760
1944
  gl.samplerParameteri(sampler, gl.TEXTURE_WRAP_T, wrapT);
1761
1945
  const anisotropy = gl.getExtension('EXT_texture_filter_anisotropic');
@@ -1783,7 +1967,7 @@ function render3DBindTexture(tileInfo, state=render3D, unit=0)
1783
1967
  // or anisotropy, and filtering it that way costs every untextured fragment
1784
1968
  else
1785
1969
  {
1786
- gl.bindSampler(unit, render3DSampler(textureInfo?.wrap, state.pixelated));
1970
+ gl.bindSampler(unit, render3DSampler(textureInfo?.wrap, state.pixelated, textureInfo?.pixelated ?? tilesPixelated));
1787
1971
  glUpdateMipmaps(texture); // drawn into since its mipmaps were made
1788
1972
  if (!state.pixelated && !glMipmappedTextures.has(texture)) // a hard edged draw never reads them
1789
1973
  {
@@ -1963,7 +2147,9 @@ function render3DSetDrawUniforms(matrix, tileInfo, tint, uvRect, state=render3D)
1963
2147
  // 0 blends them away instead, and -1 is additive, which has to fade into fog differently
1964
2148
  const blendMode = state.blend ? (state.additive ? -1 : 0) : 1;
1965
2149
  const receives = r.shadows && r.passIsDefault && state.receiveShadow ? 1 : 0;
1966
- 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);
1967
2153
 
1968
2154
  // a render target's texture holds premultiplied color, the blend writes it that way, so the shader undoes it
1969
2155
  const textureInfo = render3DTextureOf(tileInfo);
@@ -2029,6 +2215,8 @@ function render3DRenderPass(after2D)
2029
2215
  // gathered before the gl state is taken over, so an assert here leaves nothing to hand back
2030
2216
  const lights = render3DCollectLights();
2031
2217
  r.lightCount = lights.length;
2218
+ const caster = render3DShadowCaster();
2219
+ r.shadowLightIndex = caster ? lights.indexOf(caster) : -1;
2032
2220
  const positions = r.lightPositions, colors = r.lightColors;
2033
2221
  lights.forEach((light, i)=>
2034
2222
  {
@@ -2039,6 +2227,7 @@ function render3DRenderPass(after2D)
2039
2227
  positions[k] = p.x, positions[k+1] = p.y, positions[k+2] = p.z;
2040
2228
  positions[k+3] = light.directional ? -1 : max(0, light.radius); // a negative radius marks a direction
2041
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);
2042
2231
  });
2043
2232
 
2044
2233
  // take over the gl state
@@ -2110,6 +2299,7 @@ function render3DUpdateShadowMap(size)
2110
2299
  {
2111
2300
  const gl = glContext, r = render3D;
2112
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
2113
2303
  if (r.shadowTexture && r.shadowTextureSize === size) return;
2114
2304
  r.shadowTexture && gl.deleteTexture(r.shadowTexture);
2115
2305
  r.shadowFramebuffer && gl.deleteFramebuffer(r.shadowFramebuffer);
@@ -4162,20 +4352,24 @@ function engineObjectsCallback3D(pos, size, callback, objects=engineObjects, tes
4162
4352
  * InstancedMesh3D - Many copies of one mesh drawn as one call, with their transforms kept on the GPU
4163
4353
  * - For big sets that mostly stay put: an instance costs nothing per frame until it changes, so a hundred thousand
4164
4354
  * 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
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
4166
4359
  * - 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
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
4168
4362
  * - The object's flags cover the whole set, one emissive, one tileInfo, one shader; only the colors are per instance
4169
4363
  * - A mirrored instance, one with a negative scale, shows its inside unless the mesh is doubleSided
4170
4364
  * - A transparent set draws in one go in the transparent stage, its instances are not sorted against each other;
4171
4365
  * 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
4366
+ * - pick, the raycast and the collect helpers do not see the instances, test them yourself from matrixData
4173
4367
  * @extends EngineObject3D
4174
4368
  * @memberof Render3D
4175
4369
  * @example
4176
4370
  * const forest = new InstancedMesh3D(treeMesh, 1000);
4177
4371
  * for (let i = 0; i < 1000; ++i)
4178
- * forest.setMatrixAt(i, buildMatrix(randomGroundPos(), vec3(0, rand(2*PI), 0)));
4372
+ * forest.setTransformAt(i, randomGroundPos(), vec3(0, rand(2*PI), 0));
4179
4373
  */
4180
4374
  class InstancedMesh3D extends EngineObject3D
4181
4375
  {
@@ -4194,29 +4388,101 @@ class InstancedMesh3D extends EngineObject3D
4194
4388
  this.count = count;
4195
4389
  /** @property {number} - How many instances it was made with */
4196
4390
  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 */
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 */
4206
4398
  this.dirtyStart = 0;
4207
- /** @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 */
4208
4400
  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;
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;
4213
4419
  for (let i = 0; i < count; ++i)
4214
4420
  {
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;
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];
4219
4484
  }
4485
+ count && this.markDirtyRange(start, end);
4220
4486
  }
4221
4487
 
4222
4488
  /** Place an instance, in world space
@@ -4225,8 +4491,9 @@ class InstancedMesh3D extends EngineObject3D
4225
4491
  setMatrixAt(i, matrix)
4226
4492
  {
4227
4493
  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);
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
4230
4497
  this.markDirty(i);
4231
4498
  }
4232
4499
 
@@ -4236,8 +4503,8 @@ class InstancedMesh3D extends EngineObject3D
4236
4503
  getMatrixAt(i)
4237
4504
  {
4238
4505
  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));
4506
+ const k = i * RENDER3D_MATRIX_FLOATS, matrix = new Matrix4;
4507
+ matrix.m.set(this.matrixData.subarray(k, k + 16));
4241
4508
  return matrix;
4242
4509
  }
4243
4510
 
@@ -4248,26 +4515,71 @@ class InstancedMesh3D extends EngineObject3D
4248
4515
  {
4249
4516
  ASSERT(i >= 0 && i < this.maxCount, 'instance index out of range');
4250
4517
  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);
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);
4254
4521
  }
4255
4522
 
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
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
4258
4525
  * @param {number} i */
4259
4526
  markDirty(i)
4260
4527
  {
4261
- this.dirtyStart = min(this.dirtyStart, i);
4262
- 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
+ }
4533
+
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;
4545
+ }
4263
4546
 
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;
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;
4270
4581
  }
4582
+ set radius(radius) { this.fixedRadius = radius; }
4271
4583
 
4272
4584
  /** Draws every instance as one call, uploading the ones that changed first
4273
4585
  * @return {void} */
@@ -4281,41 +4593,46 @@ class InstancedMesh3D extends EngineObject3D
4281
4593
  if (r.shadowPass && !r.lighting) return; // unlit things cast no shadow
4282
4594
  render3DMeshUpload(mesh);
4283
4595
  if (!mesh.bufferCount) return;
4284
- this.radius = this.reach + mesh.radius * this.maxScale; // the mesh may have grown since
4285
4596
  if (r.frustumCulling && !render3DSphereVisible(0, 0, 0, this.radius)) return;
4286
4597
 
4287
4598
  // the uv rect is the object's tile for every instance, rewritten when the tile changes
4288
4599
  if (this.uvTileInfo !== this.tileInfo)
4289
4600
  {
4290
- const uv = render3DGetTileUVs(this.tileInfo), data = this.instanceData;
4291
- 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)
4292
4603
  data[k] = uv.x, data[k+1] = uv.y, data[k+2] = uv.w, data[k+3] = uv.h;
4293
4604
  this.uvTileInfo = this.tileInfo;
4294
- this.dirtyStart = 0, this.dirtyEnd = this.maxCount;
4605
+ this.colorDirtyStart = 0, this.colorDirtyEnd = this.maxCount;
4295
4606
  }
4296
4607
 
4297
- // 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
4298
4609
  if (!this.buffer || this.bufferGeneration !== r.contextGeneration)
4299
4610
  {
4300
- this.buffer = gl.createBuffer();
4301
4611
  this.bufferGeneration = r.contextGeneration;
4302
- gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
4303
- 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);
4304
4616
  }
4305
- else if (this.dirtyEnd > this.dirtyStart)
4617
+ else
4306
4618
  {
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);
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);
4310
4627
  }
4311
- this.dirtyStart = Infinity, this.dirtyEnd = 0;
4628
+ this.dirtyStart = this.colorDirtyStart = Infinity, this.dirtyEnd = this.colorDirtyEnd = 0;
4312
4629
 
4313
4630
  // one draw under the object's state, the mesh setting the culling as drawMesh does
4314
4631
  r.flush();
4315
4632
  render3DFlushBeforeOverlay(); // as in drawMesh, what was drawn before goes under it
4316
4633
  const cullBackFaces = r.cullBackFaces, tileInfo = this.tileInfo;
4317
4634
  r.cullBackFaces = !mesh.doubleSided;
4318
- render3DDrawInstanced(mesh, this.buffer, this.count, render3DTextureOf(tileInfo), r);
4635
+ render3DDrawInstanced(mesh, this.buffer, this.count, render3DTextureOf(tileInfo), r, this.colorBuffer);
4319
4636
  r.cullBackFaces = cullBackFaces;
4320
4637
  }
4321
4638
 
@@ -4324,8 +4641,8 @@ class InstancedMesh3D extends EngineObject3D
4324
4641
  destroy(immediate)
4325
4642
  {
4326
4643
  if (this.buffer && this.bufferGeneration === render3D?.contextGeneration)
4327
- glContext?.deleteBuffer(this.buffer);
4328
- this.buffer = undefined;
4644
+ glContext?.deleteBuffer(this.buffer), glContext?.deleteBuffer(this.colorBuffer);
4645
+ this.buffer = this.colorBuffer = undefined;
4329
4646
  super.destroy(immediate);
4330
4647
  }
4331
4648
  }
@@ -4334,7 +4651,10 @@ class InstancedMesh3D extends EngineObject3D
4334
4651
  /**
4335
4652
  * Light3D - A light that is an EngineObject3D, so it can move, follow a parent or be destroyed like anything else
4336
4653
  * - 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
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
4338
4658
  * - Only the 8 lights nearest the camera are used each frame
4339
4659
  * - radius is where the light fades out, and it fades fast, so a small radius wants a higher intensity
4340
4660
  * - intensity multiplies the color, above 1 for a light brighter than white
@@ -4366,6 +4686,12 @@ class Light3D extends EngineObject3D
4366
4686
  /** @property {boolean} - Shine from far away, from its position toward the origin, instead of out from its
4367
4687
  * position with a falloff; DirectionalLight3D sets it */
4368
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;
4369
4695
  /** @property {number} - Size of a soft hazy glow drawn over the light, like a lamp at night, 0 for none; it
4370
4696
  * is added onto what is behind it, and what is in front of the light hides it */
4371
4697
  this.glow = 0;
@@ -4384,37 +4710,10 @@ class Light3D extends EngineObject3D
4384
4710
  const toCamera = r.camera.pos.subtract(pos), distance = toCamera.length();
4385
4711
  const at = distance ? pos.add(toCamera.scale(min(this.glow, distance) / 2 / distance)) : pos;
4386
4712
  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);
4713
+ r.drawBillboard(at, vec2(this.glow), engineGlowTexture(this.glowFalloff), color);
4388
4714
  }
4389
4715
  }
4390
4716
 
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
4717
  ///////////////////////////////////////////////////////////////////////////////
4419
4718
  /**
4420
4719
  * DirectionalLight3D - A Light3D that shines from far away with no falloff, like sunlight