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.
@@ -35,7 +35,7 @@ const engineName = 'LittleJS';
35
35
  * @type {string}
36
36
  * @default
37
37
  * @memberof Engine */
38
- const engineVersion = '1.21.0';
38
+ const engineVersion = '1.22.0';
39
39
 
40
40
  /** Frames per second to update
41
41
  * @type {number}
@@ -4348,8 +4348,9 @@ class TextureInfo
4348
4348
  * @param {boolean} [useWebGL] - Should use WebGL if available?
4349
4349
  * @param {boolean|Array<number>} [wrap] - Should the texture wrap (REPEAT) or clamp (CLAMP_TO_EDGE)? Or the WebGL
4350
4350
  * modes across and down, like [gl.CLAMP_TO_EDGE, gl.MIRRORED_REPEAT], as a glTF sampler gives them
4351
+ * @param {boolean} [pixelated] - Hard edged or smooth for this texture alone, undefined follows tilesPixelated
4351
4352
  */
4352
- constructor(image, useWebGL=true, wrap=false)
4353
+ constructor(image, useWebGL=true, wrap=false, pixelated)
4353
4354
  {
4354
4355
  /** @property {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap} - image source */
4355
4356
  this.image = image;
@@ -4364,6 +4365,10 @@ class TextureInfo
4364
4365
  * across and down
4365
4366
  * @type {boolean|Array<number>} */
4366
4367
  this.wrap = wrap;
4368
+ /** @property {boolean|undefined} - Hard edged or smooth for this texture alone, a soft glow in a pixel art
4369
+ * game or pixel art in a smooth one; undefined follows tilesPixelated
4370
+ * @type {boolean|undefined} */
4371
+ this.pixelated = pixelated;
4367
4372
  useWebGL && this.createWebGLTexture();
4368
4373
  }
4369
4374
 
@@ -4394,6 +4399,17 @@ class TextureInfo
4394
4399
  this.wrap = wrap;
4395
4400
  glSetTextureWrap(this.glTexture, wrap);
4396
4401
  }
4402
+
4403
+ /** Make this texture hard edged or smooth on its own, whatever tilesPixelated says for the rest; it is made again
4404
+ * with the new filtering
4405
+ * @param {boolean} [pixelated] - undefined follows tilesPixelated again */
4406
+ setPixelated(pixelated)
4407
+ {
4408
+ this.pixelated = pixelated;
4409
+ if (!this.glTexture) return;
4410
+ this.destroyWebGLTexture();
4411
+ this.createWebGLTexture();
4412
+ }
4397
4413
  }
4398
4414
 
4399
4415
  ///////////////////////////////////////////////////////////////////////////////
@@ -5937,6 +5953,33 @@ class ImageFont
5937
5953
  }
5938
5954
  }
5939
5955
 
5956
+ // 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
5957
+ // and nothing at the edge, fading faster the higher the falloff
5958
+ function engineGlowAlpha(r, falloff)
5959
+ {
5960
+ const k = 3.5 * falloff, edge = Math.exp(-k);
5961
+ return (Math.exp(-k * r * r) - edge) / (1 - edge);
5962
+ }
5963
+
5964
+ // the soft round glow of the 2D and 3D lights, one texture for each falloff, rounded to a tenth so a changing falloff
5965
+ // makes only a few, each made once from a canvas; undefined headless or without a canvas
5966
+ const engineGlowTextures = new Map;
5967
+ function engineGlowTexture(falloff=1)
5968
+ {
5969
+ false&&ASSERT(isNumber(falloff) && falloff > 0, 'glowFalloff must be a number above 0');
5970
+ const key = max(round(falloff * 10), 1) / 10;
5971
+ let texture = engineGlowTextures.get(key);
5972
+ if (texture || !glContext || typeof OffscreenCanvas == 'undefined') return texture;
5973
+ const size = 64, context = createCanvasContext(size), steps = 16;
5974
+ const gradient = context.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
5975
+ for (let i = 0; i <= steps; ++i)
5976
+ gradient.addColorStop(i / steps, 'rgba(255,255,255,' + engineGlowAlpha(i / steps, key).toFixed(4) + ')');
5977
+ context.fillStyle = gradient;
5978
+ context.fillRect(0, 0, size, size);
5979
+ engineGlowTextures.set(key, texture = new TextureInfo(context.canvas, true, false, false));
5980
+ return texture;
5981
+ }
5982
+
5940
5983
  // load engine font, called automatically on startup
5941
5984
  async function imageFontInit()
5942
5985
  {
@@ -7638,9 +7681,12 @@ class Sound
7638
7681
  * @param {number} [taper=soundDefaultTaper] - At what percentage of range should it start tapering
7639
7682
  * @param {SoundLoadCallback} [onloadCallback] - callback function to call when sound is loaded
7640
7683
  */
7641
- constructor(asset, randomness, range=soundDefaultRange, taper=soundDefaultTaper, onloadCallback)
7684
+ constructor(asset, randomness, range, taper=soundDefaultTaper, onloadCallback)
7642
7685
  {
7643
7686
  if (!soundEnable || headlessMode) return;
7687
+ const rangeIsDefault = range === undefined;
7688
+ if (rangeIsDefault)
7689
+ range = soundDefaultRange;
7644
7690
 
7645
7691
  false&&ASSERT(!asset || isArray(asset) || isStringLike(asset), 'asset must be a file name or zzfx array');
7646
7692
  false&&ASSERT(randomness === undefined || isNumber(randomness), 'randomness must be a number');
@@ -7650,6 +7696,9 @@ class Sound
7650
7696
 
7651
7697
  /** @property {number} - World space max range of sound */
7652
7698
  this.range = range;
7699
+ /** @property {boolean} - No range was given, so it got soundDefaultRange; played in 3D such a sound is heard
7700
+ * to render3D.soundDefaultRange instead, which is further */
7701
+ this.rangeIsDefault = rangeIsDefault;
7653
7702
  /** @property {number} - At what percentage of range should it start tapering */
7654
7703
  this.taper = taper;
7655
7704
  /** @property {number} - How much to randomize frequency each time sound plays
@@ -8285,7 +8334,7 @@ function playAudioBuffer(buffer, volume=1, rate=1, pan=0, loop=false, gainNode,
8285
8334
  }
8286
8335
 
8287
8336
  ///////////////////////////////////////////////////////////////////////////////
8288
- // ZzFXMicro - Zuper Zmall Zound Zynth - v1.3.2 by Frank Force
8337
+ // ZzFXMicro - Zuper Zmall Zound Zynth - v1.4.0 by Frank Force
8289
8338
 
8290
8339
  /** Generate and play a ZzFX sound
8291
8340
  *
@@ -8317,7 +8366,7 @@ function zzfx(...zzfxSound) { return playSamples([zzfxG(...zzfxSound)]); }
8317
8366
  * @param {number} [decay] - Decay time, how long to reach sustain after attack (seconds)
8318
8367
  * @param {number} [tremolo] - Trembling effect, rate controlled by repeat time (percent)
8319
8368
  * @param {number} [filter] - Filter cutoff frequency, positive for HPF, negative for LPF (Hz)
8320
- * @return {Array} - Array of audio samples
8369
+ * @return {Float32Array} - The audio samples
8321
8370
  * @memberof Audio */
8322
8371
  function zzfxG
8323
8372
  (
@@ -8355,7 +8404,7 @@ function zzfxG
8355
8404
  crush = 0, // bit crush offset
8356
8405
  jump = 1, // pitch jump timer
8357
8406
  length, // sample length
8358
- b = [], // sample buffer
8407
+ b, // sample buffer
8359
8408
  t = 0, // sample time
8360
8409
  i = 0, // sample index
8361
8410
  s = 0, // sample value
@@ -8382,9 +8431,12 @@ function zzfxG
8382
8431
  pitchJumpTime *= sampleRate;
8383
8432
  repeatTime = repeatTime * sampleRate | 0;
8384
8433
 
8434
+ // allocate the full sample buffer up front, much faster than growing an array
8435
+ length = attack + decay + sustain + release + delay | 0;
8436
+ b = new Float32Array(length > 0 ? length : 0);
8437
+
8385
8438
  // generate waveform
8386
- for (length = attack + decay + sustain + release + delay | 0;
8387
- i < length; b[i++] = s * volume) // sample
8439
+ for (; i < length; b[i++] = s * volume) // sample
8388
8440
  {
8389
8441
  if (!(++crush%(bitCrush*100|0))) // bit crush
8390
8442
  {
@@ -8420,7 +8472,7 @@ function zzfxG
8420
8472
 
8421
8473
  f = (frequency += slide += deltaSlide) *// frequency
8422
8474
  cos(modulation*modOffset++); // modulation
8423
- t += f + f*noise*sin(i**5); // noise
8475
+ t += f + f*noise*(i*i*PI2%2-1); // noise
8424
8476
 
8425
8477
  if (jump && ++jump > pitchJumpTime) // pitch jump
8426
8478
  {
@@ -9598,6 +9650,13 @@ class ParticleEmitter extends EngineObject
9598
9650
  this.localSpace = localSpace;
9599
9651
  /** @property {number} - If non zero the particle is drawn as a trail, stretched in the direction of velocity */
9600
9652
  this.trailScale = 0;
9653
+ /** @property {number} - Grows the whole effect as each particle is born: the spawn area, the particles' sizes,
9654
+ * speed and fall, so it looks the same only bigger; 1 is as made, and a particle keeps the scale it was born
9655
+ * with, so a change never moves the ones already out */
9656
+ this.scale = 1;
9657
+ /** @property {number} - The effect's own fall, added to each particle's velocity y every frame, on top of the
9658
+ * world's gravity times gravityScale; an effect that sets its own looks the same in any game */
9659
+ this.gravity = 0;
9601
9660
  /** @property {ParticleCallback|undefined} - Callback when particle is created
9602
9661
  * @type {ParticleCallback|undefined} */
9603
9662
  this.particleCreateCallback = undefined;
@@ -9684,9 +9743,9 @@ class ParticleEmitter extends EngineObject
9684
9743
  {
9685
9744
  // show emitter bounds
9686
9745
  if (this.emitCircle)
9687
- debugCircle(this.pos, this.emitSize.x, '#0f0', 0, false, false);
9746
+ debugCircle(this.pos, this.emitSize.x * this.scale, '#0f0', 0, false, false);
9688
9747
  else
9689
- debugRect(this.pos, this.emitSize, '#0f0', 0, this.angle, false, false);
9748
+ debugRect(this.pos, this.emitSize.scale(this.scale), '#0f0', 0, this.angle, false, false);
9690
9749
  }
9691
9750
  }
9692
9751
 
@@ -9695,10 +9754,11 @@ class ParticleEmitter extends EngineObject
9695
9754
  emitParticle()
9696
9755
  {
9697
9756
  // spawn a particle
9757
+ const scale = this.scale; // grows the spawn area, the sizes and the speed of each particle born
9698
9758
  let pos = this.emitCircle ? // check if circle emitter
9699
- randInCircle(this.emitSize.x/2) // circle emitter
9700
- : vec2(rand(-.5,.5), rand(-.5,.5)) // box emitter
9701
- .multiply(this.emitSize);
9759
+ randInCircle(this.emitSize.x/2 * scale) // circle emitter
9760
+ : vec2(rand(-.5,.5) * this.emitSize.x * scale, // box emitter, one vector made
9761
+ rand(-.5,.5) * this.emitSize.y * scale);
9702
9762
  let angle = rand(this.particleConeAngle, -this.particleConeAngle);
9703
9763
  if (!this.localSpace)
9704
9764
  {
@@ -9715,9 +9775,9 @@ class ParticleEmitter extends EngineObject
9715
9775
 
9716
9776
  // randomize particle settings
9717
9777
  const particleTime = randomizeScale(this.particleTime);
9718
- const sizeStart = randomizeScale(this.sizeStart);
9719
- const sizeEnd = randomizeScale(this.sizeEnd);
9720
- const speed = randomizeScale(this.speed);
9778
+ const sizeStart = randomizeScale(this.sizeStart) * scale;
9779
+ const sizeEnd = randomizeScale(this.sizeEnd) * scale;
9780
+ const speed = randomizeScale(this.speed) * scale;
9721
9781
  const angleSpeed = randomizeScale(this.angleSpeed) * randSign();
9722
9782
  const coneAngle = rand(this.emitConeAngle, -this.emitConeAngle);
9723
9783
  const colorStart = randColor(this.colorStartA, this.colorStartB, this.randomColorLinear);
@@ -9751,9 +9811,13 @@ class ParticleEmitter extends EngineObject
9751
9811
  render()
9752
9812
  {
9753
9813
  // render all particles, the blend switched once for them all, not around each one
9814
+ // and the tile's draw worked out once for them all, fresh each frame so a tile changed in place draws changed
9754
9815
  this.additive && setAdditiveBlendMode();
9816
+ particleTile = null; // none yet, undefined is the untextured tile
9817
+ particleTileBatch = true;
9755
9818
  for (const particle of this.particles)
9756
9819
  particle.render();
9820
+ particleTileBatch = false;
9757
9821
  this.additive && setAdditiveBlendMode(false);
9758
9822
  }
9759
9823
 
@@ -9780,6 +9844,39 @@ class ParticleEmitter extends EngineObject
9780
9844
  // scratch vectors reused by Particle.render and the tile collision to avoid per-frame allocations
9781
9845
  const particleDrawPos = new Vector2, particleDrawSize = new Vector2, particleCollidePos = new Vector2;
9782
9846
 
9847
+ // the WebGL draw of the tile particles draw with, what drawTile works out for each sprite: kept while an emitter
9848
+ // renders its particles, so each one only writes its own values into the batch
9849
+ let particleTile, particleTileBatch = false, particleTileTextured = false, particleTileEmpty = false;
9850
+ let particleTileU0 = 0, particleTileV0 = 0, particleTileU1 = 0, particleTileV1 = 0;
9851
+ function particleTileSet(tileInfo)
9852
+ {
9853
+ particleTile = tileInfo;
9854
+ const textureInfo = tileInfo?.textureInfo;
9855
+ particleTileTextured = !!textureInfo;
9856
+ particleTileEmpty = !!textureInfo && !(tileInfo.size.x && tileInfo.size.y); // like a sprite still loading
9857
+ if (!textureInfo || particleTileEmpty) return;
9858
+
9859
+ false&&ASSERT(!!textureInfo.glTexture, 'texture has no WebGL texture, draw it with useWebGL false');
9860
+ glSetTexture(textureInfo.glTexture); // bound, and its mipmaps brought up to date, once for the emitter
9861
+ const sizeInverse = textureInfo.sizeInverse, bleed = tileInfo.bleed;
9862
+ const x = tileInfo.pos.x * sizeInverse.x;
9863
+ const y = tileInfo.pos.y * sizeInverse.y;
9864
+ const w = tileInfo.size.x * sizeInverse.x;
9865
+ const h = tileInfo.size.y * sizeInverse.y;
9866
+ if (bleed)
9867
+ {
9868
+ const bleedX = sizeInverse.x*bleed;
9869
+ const bleedY = sizeInverse.y*bleed;
9870
+ particleTileU0 = x + bleedX; particleTileV0 = y + bleedY;
9871
+ particleTileU1 = x - bleedX + w; particleTileV1 = y - bleedY + h;
9872
+ }
9873
+ else
9874
+ {
9875
+ particleTileU0 = x; particleTileV0 = y;
9876
+ particleTileU1 = x + w; particleTileV1 = y + h;
9877
+ }
9878
+ }
9879
+
9783
9880
  // tests if a particle collides with tiles at x, y, through its emitter's collide callback if it has one
9784
9881
  function particleCollideTest(particle, collideCallback, x, y)
9785
9882
  {
@@ -9815,6 +9912,8 @@ class Particle
9815
9912
  {
9816
9913
  /** @property {ParticleEmitter} - The emitter this particle came from */
9817
9914
  this.emitter = emitter;
9915
+ /** @property {number} - The emitter's scale when it was made, which grows its fall */
9916
+ this.scale = emitter.scale ?? 1; // a hand made emitter-like object may have none
9818
9917
  /** @property {Vector2} - Position, world space or local to the emitter when localSpace is set */
9819
9918
  this.pos = pos;
9820
9919
  /** @property {number} - Angle in radians */
@@ -9877,7 +9976,10 @@ class Particle
9877
9976
  // apply physics; only the tile collision needs where the particle was
9878
9977
  const solve = enablePhysicsSolver && collideLevel;
9879
9978
  const oldX = this.pos.x, oldY = this.pos.y;
9880
- let gravityX = gravity.x * gravityScale, gravityY = gravity.y * gravityScale;
9979
+ // the world's gravity times gravityScale plus the emitter's own, grown by the particle's scale
9980
+ const fall = this.scale;
9981
+ let gravityX = gravity.x * gravityScale * fall;
9982
+ let gravityY = (gravity.y * gravityScale + (emitter.gravity || 0)) * fall;
9881
9983
  if (emitter.localSpace && emitter.angle)
9882
9984
  {
9883
9985
  // world gravity turned into the emitter's space, the render turns it back
@@ -9931,7 +10033,9 @@ class Particle
9931
10033
  }
9932
10034
  if (isBlockedY || !isBlockedX)
9933
10035
  {
9934
- const wasFalling = this.velocity.y < 0 && gravity.y < 0 || this.velocity.y > 0 && gravity.y > 0;
10036
+ // down is the world's gravity, or the emitter's own fall in a world with none
10037
+ const down = gravity.y || emitter.gravity || 0;
10038
+ const wasFalling = this.velocity.y < 0 && down < 0 || this.velocity.y > 0 && down > 0;
9935
10039
  if (wasFalling)
9936
10040
  this.groundObject = hitLayer;
9937
10041
 
@@ -10010,7 +10114,24 @@ class Particle
10010
10114
  angle = atan2(velocity.x, velocity.y);
10011
10115
  }
10012
10116
  }
10013
- drawTile(pos, size, this.tileInfo, this.color, angle, this.mirror, undefined, glEnable, false);
10117
+ if (glEnable && !headlessMode)
10118
+ {
10119
+ // straight into the batch, the tile's draw worked out once for the emitter's particles
10120
+ const tileInfo = this.tileInfo;
10121
+ if (!particleTileBatch || tileInfo !== particleTile)
10122
+ particleTileSet(tileInfo);
10123
+ if (!particleTileTextured)
10124
+ glDrawUntextured(pos.x, pos.y, size.x, size.y, angle, this.color.rgbaInt());
10125
+ else if (!particleTileEmpty)
10126
+ {
10127
+ const texture = tileInfo.textureInfo.glTexture;
10128
+ glActiveTexture === texture || glSetTexture(texture); // bound again only if a draw between changed it
10129
+ glDraw(pos.x, pos.y, this.mirror ? -size.x : size.x, size.y, angle,
10130
+ particleTileU0, particleTileV0, particleTileU1, particleTileV1, this.color.rgbaInt());
10131
+ }
10132
+ }
10133
+ else
10134
+ drawTile(pos, size, this.tileInfo, this.color, angle, this.mirror, undefined, false, false);
10014
10135
  additive && setAdditiveBlendMode(false);
10015
10136
  debugParticles && debugRect(pos, size, '#f005', 0, angle, false, false);
10016
10137
  }
@@ -10041,6 +10162,19 @@ let glCanvas;
10041
10162
  * @memberof WebGL */
10042
10163
  let glContext;
10043
10164
 
10165
+ /** The largest texture this device makes, in pixels across, 0 until WebGL starts; at least 2048 with WebGL2
10166
+ * @type {number}
10167
+ * @memberof WebGL */
10168
+ let glMaxTextureSize = 0;
10169
+
10170
+ /** A texture size this device can make: the size asked for, or the largest it supports when that is smaller
10171
+ * - The engine sizes the textures it makes itself through this, the shadow maps and the like, so a size a device
10172
+ * does not have falls back instead of failing
10173
+ * @param {number} size - Pixels across
10174
+ * @return {number}
10175
+ * @memberof WebGL */
10176
+ function glClampTextureSize(size) { return glMaxTextureSize ? min(size, glMaxTextureSize) : size; }
10177
+
10044
10178
  /** Should WebGL be setup with anti-aliasing? must be set before calling engineInit
10045
10179
  * @type {boolean}
10046
10180
  * @memberof WebGL */
@@ -10130,7 +10264,7 @@ function glInit(rootElement)
10130
10264
  // startup webgl, and make the textures of any texture infos made before it
10131
10265
  initWebGL();
10132
10266
  for (const info of glTextureInfos)
10133
- info.glTexture ||= glCreateTexture(info.image, info.wrap);
10267
+ info.glTexture ||= glCreateTexture(info.image, info.wrap, info.pixelated);
10134
10268
 
10135
10269
  // setup context lost and restore handlers
10136
10270
  glCanvas.addEventListener('webglcontextlost', (e)=>
@@ -10170,7 +10304,7 @@ function glInit(rootElement)
10170
10304
  glPremultipliedTextures = new WeakSet; // the tile layers draw into their new textures again below
10171
10305
  initWebGL();
10172
10306
  for (const info of glTextureInfos)
10173
- info.glTexture = glCreateTexture(info.image, info.wrap);
10307
+ info.glTexture = glCreateTexture(info.image, info.wrap, info.pixelated);
10174
10308
  pluginList.forEach(plugin=>plugin.glContextRestored?.());
10175
10309
 
10176
10310
  // a tile layer drawn on the GPU only had its tiles in the lost texture, it draws them again
@@ -10180,6 +10314,8 @@ function glInit(rootElement)
10180
10314
 
10181
10315
  function initWebGL()
10182
10316
  {
10317
+ glMaxTextureSize = glContext.getParameter(glContext.MAX_TEXTURE_SIZE) | 0;
10318
+
10183
10319
  // setup instanced rendering shader program
10184
10320
  glShader = glCreateProgram(gl_VERTEX_SOURCE,
10185
10321
  '#version 300 es\n' + // specify GLSL ES version
@@ -10503,9 +10639,10 @@ function glShaderProgram(shader)
10503
10639
  * Restores the active texture when done
10504
10640
  * @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap} [image]
10505
10641
  * @param {boolean|Array<number>} [wrap] - true for REPEAT, false for CLAMP_TO_EDGE, or the WebGL modes across and down
10642
+ * @param {boolean} [pixelated] - Hard edged or smooth, undefined follows tilesPixelated
10506
10643
  * @return {WebGLTexture}
10507
10644
  * @memberof WebGL */
10508
- function glCreateTexture(image, wrap=false)
10645
+ function glCreateTexture(image, wrap=false, pixelated=tilesPixelated)
10509
10646
  {
10510
10647
  if (!glContext) return;
10511
10648
 
@@ -10514,10 +10651,10 @@ function glCreateTexture(image, wrap=false)
10514
10651
  let mipMap = false;
10515
10652
  if (image?.width)
10516
10653
  {
10517
- glSetTextureData(texture, image);
10654
+ glSetTextureData(texture, image, pixelated);
10518
10655
  glContext.bindTexture(glContext.TEXTURE_2D, texture);
10519
10656
  // WebGL2 makes mipmaps at any size, a texture that becomes a render target keeps them only at powers of two
10520
- mipMap = !tilesPixelated;
10657
+ mipMap = !pixelated;
10521
10658
  if (mipMap && !(isPowerOfTwo(image.width) && isPowerOfTwo(image.height)))
10522
10659
  glMipmapsUntilTarget.add(texture);
10523
10660
  }
@@ -10530,7 +10667,7 @@ function glCreateTexture(image, wrap=false)
10530
10667
  }
10531
10668
 
10532
10669
  // set texture filtering
10533
- const magFilter = tilesPixelated ? glContext.NEAREST : glContext.LINEAR;
10670
+ const magFilter = pixelated ? glContext.NEAREST : glContext.LINEAR;
10534
10671
  const minFilter = mipMap ? glContext.LINEAR_MIPMAP_LINEAR : magFilter;
10535
10672
  glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MAG_FILTER, magFilter);
10536
10673
  glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MIN_FILTER, minFilter);
@@ -10561,8 +10698,9 @@ function glDeleteTexture(texture)
10561
10698
  /** Set WebGL texture data from an image, restores the active texture when done
10562
10699
  * @param {WebGLTexture} texture
10563
10700
  * @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap} image
10701
+ * @param {boolean} [pixelated] - Hard edged or smooth, undefined follows tilesPixelated
10564
10702
  * @memberof WebGL */
10565
- function glSetTextureData(texture, image)
10703
+ function glSetTextureData(texture, image, pixelated=tilesPixelated)
10566
10704
  {
10567
10705
  if (!glContext) return;
10568
10706
 
@@ -10580,7 +10718,7 @@ function glSetTextureData(texture, image)
10580
10718
  }
10581
10719
  // smooth filtering mixes a texel with its see through neighbors, right only for premultiplied color, or the
10582
10720
  // edges go dark; pixel art is sampled a texel at a time, and uploads straight color as it always has
10583
- const premultiply = !tilesPixelated;
10721
+ const premultiply = !pixelated;
10584
10722
  glContext.pixelStorei(glContext.UNPACK_PREMULTIPLY_ALPHA_WEBGL, premultiply);
10585
10723
  glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA, glContext.RGBA, glContext.UNSIGNED_BYTE, image);
10586
10724
  glContext.pixelStorei(glContext.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
@@ -10611,9 +10749,9 @@ function glRegisterTextureInfo(textureInfo)
10611
10749
 
10612
10750
  // create or set the texture data
10613
10751
  if (textureInfo.glTexture)
10614
- glSetTextureData(textureInfo.glTexture, textureInfo.image);
10752
+ glSetTextureData(textureInfo.glTexture, textureInfo.image, textureInfo.pixelated);
10615
10753
  else
10616
- textureInfo.glTexture = glCreateTexture(textureInfo.image, textureInfo.wrap);
10754
+ textureInfo.glTexture = glCreateTexture(textureInfo.image, textureInfo.wrap, textureInfo.pixelated);
10617
10755
  }
10618
10756
 
10619
10757
  /** Internal: tells WebGL to destroy the glTexture and stop tracking it, TextureInfo calls it
@@ -12111,8 +12249,8 @@ class PostProcessPlugin
12111
12249
  false&&ASSERT(!(includeMainCanvas && feedbackTexture), 'Post process cannot both include main canvas and use feedback texture');
12112
12250
  postProcess = this;
12113
12251
 
12114
- if (!shaderCode) // default shader pass through
12115
- shaderCode = 'void mainImage(out vec4 c,vec2 p){c=texture(iChannel0,p/iResolution.xy);}';
12252
+ /** @property {string} - The shadertoy style mainImage code it shades with, see setShaderCode */
12253
+ this.shaderCode = shaderCode || postProcessEffects(); // no code passes the frame through
12116
12254
 
12117
12255
  /** @property {WebGLProgram|undefined} - Shader for post processing
12118
12256
  * @type {WebGLProgram|undefined} */
@@ -12158,7 +12296,7 @@ class PostProcessPlugin
12158
12296
  'gl_Position=vec4(p+p-1.,1,1);'+ // set position
12159
12297
  '}' // end of shader
12160
12298
  ,
12161
- postProcessFragmentSource(shaderCode)
12299
+ postProcessFragmentSource(postProcess.shaderCode)
12162
12300
  );
12163
12301
 
12164
12302
  // setup VAO for post processing
@@ -12171,6 +12309,10 @@ class PostProcessPlugin
12171
12309
  const pLocation = glContext.getAttribLocation(postProcess.shader, 'p');
12172
12310
  glContext.enableVertexAttribArray(pLocation);
12173
12311
  glContext.vertexAttribPointer(pLocation, 2, glContext.FLOAT, false, vertexByteStride, 0);
12312
+
12313
+ // the engine's own vertex array and buffer back, its next batch is written into the buffer bound
12314
+ glContext.bindVertexArray(glPolyMode ? glPolyVAO : glInstancedVAO);
12315
+ glContext.bindBuffer(glContext.ARRAY_BUFFER, glArrayBuffer);
12174
12316
  }
12175
12317
  function postProcessContextLost()
12176
12318
  {
@@ -12188,16 +12330,17 @@ class PostProcessPlugin
12188
12330
  {
12189
12331
  if (headlessMode || !glEnable) return;
12190
12332
 
12191
- // made now if WebGL was off when the plugin was made, or when a lost context came back
12333
+ // clear out the buffer, before anything here binds its own
12334
+ glFlush();
12335
+
12336
+ // made now if WebGL was off when the plugin was made, when a lost context came back, or when
12337
+ // setShaderCode gave it new code
12192
12338
  if (!postProcess.shader)
12193
12339
  {
12194
12340
  if (glContext.isContextLost()) return;
12195
12341
  initPostProcess();
12196
12342
  }
12197
12343
 
12198
- // clear out the buffer
12199
- glFlush();
12200
-
12201
12344
  // ensure we render to the default framebuffer (in case any earlier
12202
12345
  // caller this frame left a render target bound)
12203
12346
  glContext.bindFramebuffer(glContext.FRAMEBUFFER, null);
@@ -12266,7 +12409,9 @@ class PostProcessPlugin
12266
12409
 
12267
12410
  if (feedbackTexture)
12268
12411
  {
12269
- // keep this frame's output for the next one, then hand the first texture unit back to the engine
12412
+ // keep this frame's output for the next one, in the feedback texture still bound to the second
12413
+ // unit, then hand the first texture unit back to the engine
12414
+ glContext.activeTexture(glContext.TEXTURE1);
12270
12415
  glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA, glContext.RGBA, glContext.UNSIGNED_BYTE, glCanvas);
12271
12416
  glContext.activeTexture(glContext.TEXTURE0);
12272
12417
  }
@@ -12288,6 +12433,23 @@ class PostProcessPlugin
12288
12433
  glSetInstancedMode(true);
12289
12434
  }
12290
12435
  }
12436
+
12437
+ /** Shade with new code from the next frame on, to switch effects while the game runs; the shader is made again
12438
+ * @param {string} [shaderCode] - Shadertoy style mainImage code, postProcessEffects builds it; none passes the
12439
+ * frame through */
12440
+ setShaderCode(shaderCode)
12441
+ {
12442
+ false&&ASSERT(!shaderCode || typeof shaderCode === 'string', 'shader code must be a string');
12443
+ this.shaderCode = shaderCode || postProcessEffects();
12444
+ if (!this.shader || headlessMode || !glContext) return;
12445
+
12446
+ // what the new shader replaces, made again by the next render
12447
+ glContext.deleteProgram(this.shader);
12448
+ glContext.deleteTexture(this.texture);
12449
+ this.feedbackTexture && glContext.deleteTexture(this.feedbackTexture);
12450
+ glContext.deleteVertexArray(this.vao);
12451
+ this.shader = this.texture = this.feedbackTexture = this.vao = undefined;
12452
+ }
12291
12453
  }
12292
12454
 
12293
12455
  ///////////////////////////////////////////////////////////////////////////////
@@ -12328,9 +12490,66 @@ function postProcessFragmentSource(shaderCode)
12328
12490
  */
12329
12491
  function postProcessBloomShader(threshold=.6, strength=1, size=6)
12330
12492
  {
12331
- false&&ASSERT(isNumber(threshold) && isNumber(strength) && isNumber(size), 'bloom settings must be numbers');
12332
- false&&ASSERT(size > 0, 'bloom size must be above zero');
12333
- false&&ASSERT(size <= 32, 'a bloom this wide takes a sample every few pixels of every ring, which is hundreds of samples a pixel', size);
12493
+ return postProcessEffects(postProcessGlow(threshold, strength, size));
12494
+ }
12495
+
12496
+ /**
12497
+ * Set up post processing with a bloom effect, so bright colors and lights glow
12498
+ * @param {number} [threshold] - Brightness where the glow starts, 0 is everything and 1 is only pure white
12499
+ * @param {number} [strength] - How much glow to add
12500
+ * @param {number} [size] - How far the glow spreads in pixels
12501
+ * @param {boolean} [includeMainCanvas] - Glow the 2D canvas too, off by default so HUD text stays crisp
12502
+ * (a HUD drawn with WebGL in gameRenderPost glows either way, draw it with useWebGL=false)
12503
+ * @return {PostProcessPlugin}
12504
+ * @memberof PostProcess
12505
+ * @example
12506
+ * postProcessBloom(); // in gameInit, after any Render3DPlugin
12507
+ */
12508
+ function postProcessBloom(threshold=.6, strength=1, size=6, includeMainCanvas=false)
12509
+ { return new PostProcessPlugin(postProcessBloomShader(threshold, strength, size), includeMainCanvas); }
12510
+
12511
+ ///////////////////////////////////////////////////////////////////////////////
12512
+ // Effects: pieces of shader code, each with its settings written in as numbers, that postProcessEffects joins in
12513
+ // order into one shader; a piece works on c, the pixel's color, and uv, where it is on the screen from 0 to 1, with p
12514
+ // its pixel; one that bends uv or samples the frame, the curve, chromatic and glow, goes before the ones that shade
12515
+
12516
+ // a number as GLSL writes it, a float with a point
12517
+ const postProcessNumber = (n)=> (false&&ASSERT(isNumber(n), 'effect settings must be numbers', n), n.toFixed(4));
12518
+
12519
+ /**
12520
+ * Join effects into one post process shader, in the order given, for PostProcessPlugin or setShaderCode
12521
+ * - Each effect is a piece of shader code on c, the pixel's color, and uv, where it is on the screen from 0 to 1;
12522
+ * your own code is a piece too, a line of GLSL or many, dropped in where you put it
12523
+ * - Put the ones that bend the picture or sample it first: postProcessCurve, postProcessChromatic, postProcessGlow
12524
+ * @param {...string} effects - The pieces, from the effect functions or your own code
12525
+ * @return {string} - Shadertoy style mainImage code
12526
+ * @example
12527
+ * new PostProcessPlugin(postProcessEffects(
12528
+ * postProcessScanlines(.5), postProcessVignette(), 'c.rgb *= vec3(1, .9, .8);'));
12529
+ * @memberof PostProcess
12530
+ */
12531
+ function postProcessEffects(...effects)
12532
+ {
12533
+ false&&ASSERT(effects.every((e)=> typeof e === 'string'), 'each effect is a piece of shader code, a string');
12534
+ return 'void mainImage(out vec4 c, vec2 p)\n{\n' +
12535
+ ' vec2 uv = p / iResolution.xy;\n' +
12536
+ ' c = texture(iChannel0, uv);\n' +
12537
+ effects.map((e)=> ' {\n' + e + '\n }\n').join('') + '}\n';
12538
+ }
12539
+
12540
+ /**
12541
+ * Bright parts glow, the bloom as an effect to join with others; postProcessBloom sets up bloom on its own
12542
+ * @param {number} [threshold] - Brightness where the glow starts, 0 is everything and 1 is only pure white
12543
+ * @param {number} [strength] - How much glow to add
12544
+ * @param {number} [size] - How far the glow spreads in pixels, which also sets how many samples it takes
12545
+ * @return {string}
12546
+ * @memberof PostProcess
12547
+ */
12548
+ function postProcessGlow(threshold=.6, strength=1, size=6)
12549
+ {
12550
+ false&&ASSERT(isNumber(threshold) && isNumber(strength) && isNumber(size), 'glow settings must be numbers');
12551
+ false&&ASSERT(size > 0, 'glow size must be above zero');
12552
+ false&&ASSERT(size <= 32, 'a glow this wide takes a sample every few pixels of every ring, which is hundreds of samples a pixel', size);
12334
12553
 
12335
12554
  // Taps on three rings over a disc of the given size, one every three pixels or so of each ring
12336
12555
  // so there is no gap wide enough to show. The count follows the ring all the way out: hold it
@@ -12352,30 +12571,127 @@ function postProcessBloomShader(threshold=.6, strength=1, size=6)
12352
12571
  glow += max(vec3(0), texture(iChannel0, uv + vec2(cos(a), sin(a)) * ${radius.toFixed(4)} / iResolution.xy).rgb - ${threshold.toFixed(4)});
12353
12572
  }`;
12354
12573
  }
12355
- return `
12356
- void mainImage(out vec4 color, vec2 pixel)
12357
- {
12358
- vec2 uv = pixel / iResolution.xy;
12359
- color = texture(iChannel0, uv);
12574
+ return ` // glow
12360
12575
  vec3 glow = vec3(0);${code}
12361
- color.rgb += glow * ${(strength / taps).toFixed(6)};
12362
- }`;
12576
+ c.rgb += glow * ${(strength / taps).toFixed(6)};`;
12363
12577
  }
12364
12578
 
12365
12579
  /**
12366
- * Set up post processing with a bloom effect, so bright colors and lights glow
12367
- * @param {number} [threshold] - Brightness where the glow starts, 0 is everything and 1 is only pure white
12368
- * @param {number} [strength] - How much glow to add
12369
- * @param {number} [size] - How far the glow spreads in pixels
12370
- * @param {boolean} [includeMainCanvas] - Glow the 2D canvas too, off by default so HUD text stays crisp
12371
- * (a HUD drawn with WebGL in gameRenderPost glows either way, draw it with useWebGL=false)
12372
- * @return {PostProcessPlugin}
12580
+ * Scan lines across the screen, like an old TV
12581
+ * @param {number} [strength] - How dark the lines are, and how bright between them
12582
+ * @param {number} [spacing] - Pixels from one line to the next
12583
+ * @return {string}
12584
+ * @memberof PostProcess
12585
+ */
12586
+ function postProcessScanlines(strength=.5, spacing=6)
12587
+ {
12588
+ return ` // scanlines
12589
+ c.rgb *= 1. - ${postProcessNumber(strength)} * cos(p.y * 6.2832 / ${postProcessNumber(spacing)});`;
12590
+ }
12591
+
12592
+ /**
12593
+ * Static noise over the picture, changing every frame
12594
+ * @param {number} [strength] - How bright the static is
12595
+ * @param {number} [size] - Size of a speck in pixels
12596
+ * @return {string}
12373
12597
  * @memberof PostProcess
12598
+ */
12599
+ function postProcessNoise(strength=.1, size=2)
12600
+ {
12601
+ return ` // noise
12602
+ vec2 q = fract((floor(p / ${postProcessNumber(size)}) + mod(iTime * 500., 1e3)) * .3197);
12603
+ c.rgb += ${postProcessNumber(strength)} * fract(1. + sin(51. * q.x + 73. * q.y) * 13753.3);`;
12604
+ }
12605
+
12606
+ /**
12607
+ * Darken toward the edges and corners
12608
+ * @param {number} [strength] - How dark the corners get, 1 is black
12609
+ * @param {number} [falloff] - How far in it reaches, low darkens most of the screen, high only the corners
12610
+ * @return {string}
12611
+ * @memberof PostProcess
12612
+ */
12613
+ function postProcessVignette(strength=1, falloff=3)
12614
+ {
12615
+ return ` // vignette
12616
+ vec2 d = uv * 2. - 1.;
12617
+ c.rgb *= 1. - ${postProcessNumber(strength)} * min(1., pow(dot(d, d) / 2., ${postProcessNumber(falloff)}));`;
12618
+ }
12619
+
12620
+ /**
12621
+ * Bend the picture like the bulged glass of an old TV, black past the corners; put it first
12622
+ * @param {number} [strength] - How much it bends
12623
+ * @return {string}
12624
+ * @memberof PostProcess
12625
+ */
12626
+ function postProcessCurve(strength=.1)
12627
+ {
12628
+ return ` // curve
12629
+ vec2 d = uv * 2. - 1.;
12630
+ d *= 1. + ${postProcessNumber(strength)} * dot(d, d);
12631
+ uv = d * .5 + .5;
12632
+ c = all(lessThan(abs(d), vec2(1))) ? texture(iChannel0, uv) : vec4(0, 0, 0, 1);`;
12633
+ }
12634
+
12635
+ /**
12636
+ * Split red and blue apart toward the edges, like a cheap lens; put it before what shades the picture
12637
+ * @param {number} [strength] - How far apart at the edge, as a part of the screen
12638
+ * @return {string}
12639
+ * @memberof PostProcess
12640
+ */
12641
+ function postProcessChromatic(strength=.005)
12642
+ {
12643
+ return ` // chromatic
12644
+ vec2 d = (uv - .5) * ${postProcessNumber(strength)} * 2.;
12645
+ c.r = texture(iChannel0, uv + d).r;
12646
+ c.b = texture(iChannel0, uv - d).b;`;
12647
+ }
12648
+
12649
+ /**
12650
+ * Draw lines where the 3D depth jumps, around objects and along their creases; needs render3D.depthTexture on
12651
+ * @param {Color} [color] - The lines' color, its alpha how strong they are
12652
+ * @param {number} [thickness] - How wide the lines are in pixels
12653
+ * @param {number} [threshold] - How big a jump makes a line, as a part of the distance, lower draws more
12654
+ * @return {string}
12655
+ * @memberof PostProcess
12656
+ */
12657
+ function postProcessOutline(color=BLACK, thickness=1, threshold=.02)
12658
+ {
12659
+ false&&ASSERT(isColor(color), 'outline color must be a Color');
12660
+ const n = postProcessNumber;
12661
+ return ` // outline
12662
+ vec2 o = ${n(thickness)} / iResolution.xy;
12663
+ float d = sceneDepth(uv);
12664
+ float dx = abs(sceneDepth(uv + vec2(o.x, 0)) + sceneDepth(uv - vec2(o.x, 0)) - 2. * d);
12665
+ float dy = abs(sceneDepth(uv + vec2(0, o.y)) + sceneDepth(uv - vec2(0, o.y)) - 2. * d);
12666
+ vec4 line = vec4(${n(color.r)}, ${n(color.g)}, ${n(color.b)}, ${n(color.a)});
12667
+ c.rgb = mix(c.rgb, line.rgb, line.a * step(${n(threshold)}, max(dx, dy) / d));`;
12668
+ }
12669
+
12670
+ /**
12671
+ * The look of an old TV, as one effect to use alone or join with others: static noise, scan lines, a soft glow and
12672
+ * a vignette, and a bulged screen when curve is set; any setting at 0 leaves that part out
12673
+ * @param {Object} [settings]
12674
+ * @param {number} [settings.noise] - Static noise strength
12675
+ * @param {number} [settings.scanlines] - Scan line strength
12676
+ * @param {number} [settings.scanlineSpacing] - Pixels from one scan line to the next
12677
+ * @param {number} [settings.glow] - Soft glow strength
12678
+ * @param {number} [settings.vignette] - Vignette strength
12679
+ * @param {number} [settings.curve] - How much the screen bulges, 0 by default for flat
12680
+ * @return {string}
12374
12681
  * @example
12375
- * postProcessBloom(); // in gameInit, after any Render3DPlugin
12682
+ * new PostProcessPlugin(postProcessEffects(postProcessTV({scanlines: .4, curve: .1})));
12683
+ * @memberof PostProcess
12376
12684
  */
12377
- function postProcessBloom(threshold=.6, strength=1, size=6, includeMainCanvas=false)
12378
- { return new PostProcessPlugin(postProcessBloomShader(threshold, strength, size), includeMainCanvas); }
12685
+ function postProcessTV({noise=.1, scanlines=.5, scanlineSpacing=6, glow=.4, vignette=1, curve=0}={})
12686
+ {
12687
+ const parts = [];
12688
+ curve && parts.push(postProcessCurve(curve));
12689
+ glow && parts.push(postProcessGlow(0, glow, 2));
12690
+ scanlines && parts.push(postProcessScanlines(scanlines, scanlineSpacing));
12691
+ noise && parts.push(postProcessNoise(noise, 1));
12692
+ vignette && parts.push(postProcessVignette(vignette, 6));
12693
+ return parts.map((part)=> ' {\n' + part + '\n }').join('\n');
12694
+ }
12379
12695
 
12380
12696
  /**
12381
12697
  * LittleJS Light System Plugin
@@ -12399,6 +12715,8 @@ function postProcessBloom(threshold=.6, strength=1, size=6, includeMainCanvas=fa
12399
12715
  * shadow map through renderShadow(), which calls render() by default; obj.castShadow = false keeps it
12400
12716
  * out (a floor TileLayer, a background), a draw's alpha sets how much light it blocks, and
12401
12717
  * setShadowTransparent lets its color tint the light
12718
+ * - Set light.glow for a soft hazy glow over a light, like a lamp at night; it is added over the lit scene after the
12719
+ * lightmap, so it shows in the dark and sits in front of everything there
12402
12720
  * - Must be constructed BEFORE PostProcessPlugin so post-process sees lit pixels
12403
12721
  * @namespace LightSystem
12404
12722
  */
@@ -12533,6 +12851,7 @@ class LightSystemPlugin
12533
12851
  // again on a context restore, the canvas may have changed since
12534
12852
  if (lightSystem.textureSizeAuto)
12535
12853
  lightSystem.textureSize = mainCanvasSize.copy();
12854
+ lightSystem.clampTextureSizes();
12536
12855
 
12537
12856
  // allocate the lightmap texture with null data at textureSize
12538
12857
  lightSystem.texture = glContext.createTexture();
@@ -12724,6 +13043,7 @@ class LightSystemPlugin
12724
13043
  const ls = lightSystem;
12725
13044
 
12726
13045
  // make the resources the first time, and again when a size changed
13046
+ ls.clampTextureSizes();
12727
13047
  if (!ls.shadowMap || ls.shadowMapSize !== ls.shadowMapSizeAllocated
12728
13048
  || ls.shadowTextureSize !== ls.shadowTextureSizeAllocated)
12729
13049
  {
@@ -12911,8 +13231,14 @@ class LightSystemPlugin
12911
13231
  // is, and any debug text / future draw could sample the lightmap)
12912
13232
  if (glActiveTexture)
12913
13233
  glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
12914
- setAdditiveBlendMode(prevAdditive);
12915
13234
  glSetInstancedMode(true);
13235
+
13236
+ // 7. the lights' glows, added over the lit scene so the darkness does not dim them
13237
+ setAdditiveBlendMode();
13238
+ for (const o of engineObjects)
13239
+ o instanceof Light && !o.destroyed && o.renderGlow();
13240
+ glFlush();
13241
+ setAdditiveBlendMode(prevAdditive);
12916
13242
  }
12917
13243
  function lightSystemContextLost()
12918
13244
  {
@@ -12931,6 +13257,17 @@ class LightSystemPlugin
12931
13257
  }
12932
13258
  }
12933
13259
 
13260
+ /** Bring the sizes of the textures it makes down to what the device can make: shadowMapSize, shadowTextureSize
13261
+ * and a textureSize given by hand; called before they are made, so a size too big falls back instead of failing */
13262
+ clampTextureSizes()
13263
+ {
13264
+ this.shadowMapSize = glClampTextureSize(this.shadowMapSize);
13265
+ this.shadowTextureSize = glClampTextureSize(this.shadowTextureSize);
13266
+ const size = this.textureSize;
13267
+ if (size)
13268
+ this.textureSize = vec2(glClampTextureSize(size.x), glClampTextureSize(size.y));
13269
+ }
13270
+
12934
13271
  /** Draw a single Light's falloff blob into the currently bound lightmap.
12935
13272
  * Called by Light.renderLight() during the plugin's render pass.
12936
13273
  * @param {Light} light */
@@ -13080,6 +13417,14 @@ class Light extends EngineObject
13080
13417
  * or torch that holds it, or the player carrying it, does not block it; it has to reach past that object's
13081
13418
  * corners, about half its diagonal and a little more, or dark rays run out from them */
13082
13419
  this.shadowCore = 0;
13420
+ /** @property {number} - Size across of a soft hazy glow drawn over the light, like a lamp at night, 0 for
13421
+ * none; it is added over the lit scene, in front of everything there */
13422
+ this.glow = 0;
13423
+ /** @property {number} - How fast the glow fades from its middle: 1 by default, .5 a wide haze, 2 a tight
13424
+ * bright core */
13425
+ this.glowFalloff = 1;
13426
+ /** @type {TileInfo|undefined} */
13427
+ this.glowTileInfo = undefined; // the whole glow texture, kept for the falloff it was made for
13083
13428
  }
13084
13429
 
13085
13430
  /** Lights are invisible in the main render pass — they only contribute
@@ -13093,6 +13438,20 @@ class Light extends EngineObject
13093
13438
  {
13094
13439
  lightSystem && lightSystem.drawLight(this);
13095
13440
  }
13441
+
13442
+ /** Draw this light's glow, soft and round, its glow size across and in its color, added over the lit scene;
13443
+ * called by LightSystemPlugin after the lightmap is applied */
13444
+ renderGlow()
13445
+ {
13446
+ if (!(this.glow > 0)) return;
13447
+ const size = vec2(this.glow);
13448
+ if (!isOnScreen(this.pos, size)) return;
13449
+ const texture = engineGlowTexture(this.glowFalloff);
13450
+ if (!texture) return;
13451
+ if (this.glowTileInfo?.textureInfo !== texture)
13452
+ this.glowTileInfo = new TileInfo(vec2(), texture.size, texture);
13453
+ drawTile(this.pos, size, this.glowTileInfo, this.color, 0, false, undefined, true, false);
13454
+ }
13096
13455
  }
13097
13456
 
13098
13457
  /**
@@ -19724,9 +20083,10 @@ function setScene(scene)
19724
20083
 
19725
20084
  if (sceneCurrent)
19726
20085
  {
20086
+ // a leave that throws still lets the next switch through
19727
20087
  sceneLeaving = true;
19728
- sceneCurrent.leave?.();
19729
- sceneLeaving = false;
20088
+ try { sceneCurrent.leave?.(); }
20089
+ finally { sceneLeaving = false; }
19730
20090
  }
19731
20091
  engineObjectsDestroy();
19732
20092
  sceneCurrent = scene;
@@ -20902,15 +21262,7 @@ class Matrix4
20902
21262
  static rotation(euler, matrix=new Matrix4)
20903
21263
  {
20904
21264
  false&&ASSERT_VECTOR3_VALID(euler);
20905
- const cx = cos(euler.x), sx = sin(euler.x);
20906
- const cy = cos(euler.y), sy = sin(euler.y);
20907
- const cz = cos(euler.z), sz = sin(euler.z);
20908
- const m = matrix.m;
20909
- // R = Ry * Rx * Rz written out, column major, every element set so a reused matrix comes out clean
20910
- m[0] = cy*cz + sy*sx*sz; m[1] = cx*sz; m[2] = -sy*cz + cy*sx*sz; m[3] = 0;
20911
- m[4] = -cy*sz + sy*sx*cz; m[5] = cx*cz; m[6] = sy*sz + cy*sx*cz; m[7] = 0;
20912
- m[8] = sy*cx; m[9] = -sx; m[10] = cy*cx; m[11] = 0;
20913
- m[12] = m[13] = m[14] = 0; m[15] = 1;
21265
+ matrix4RotationAt(matrix.m, 0, euler);
20914
21266
  return matrix;
20915
21267
  }
20916
21268
 
@@ -21158,24 +21510,54 @@ class Matrix4
21158
21510
  * @memberof Math3D
21159
21511
  */
21160
21512
  function buildMatrix(pos, rotation, scale, matrix=new Matrix4)
21513
+ {
21514
+ false&&ASSERT(matrix instanceof Matrix4, 'the matrix to write into must be a Matrix4');
21515
+ matrix4Compose(matrix.m, 0, pos, rotation, scale);
21516
+ return matrix;
21517
+ }
21518
+
21519
+ // the rotation matrix of euler angles written into 16 floats of an array at k
21520
+ function matrix4RotationAt(m, k, euler)
21521
+ {
21522
+ const cx = cos(euler.x), sx = sin(euler.x);
21523
+ const cy = cos(euler.y), sy = sin(euler.y);
21524
+ const cz = cos(euler.z), sz = sin(euler.z);
21525
+ // R = Ry * Rx * Rz written out, column major, every element set so a reused matrix comes out clean
21526
+ m[k] = cy*cz + sy*sx*sz; m[k+1] = cx*sz; m[k+2] = -sy*cz + cy*sx*sz; m[k+3] = 0;
21527
+ m[k+4] = -cy*sz + sy*sx*cz; m[k+5] = cx*cz; m[k+6] = sy*sz + cy*sx*cz; m[k+7] = 0;
21528
+ m[k+8] = sy*cx; m[k+9] = -sx; m[k+10] = cy*cx; m[k+11] = 0;
21529
+ m[k+12] = m[k+13] = m[k+14] = 0; m[k+15] = 1;
21530
+ }
21531
+
21532
+ // the transform buildMatrix makes, written into 16 floats of an array at k, so an instance buffer takes it straight in
21533
+ // the rotation columns are scaled and the position dropped in, instead of multiplying three matrices, each value
21534
+ // worked out whole and stored once; InstancedMesh3D.setTransforms writes the same expressions for many at a time
21535
+ function matrix4Compose(m, k, pos, rotation, scale)
21161
21536
  {
21162
21537
  false&&ASSERT(!pos || isVector3(pos), 'pos must be a Vector3', pos);
21163
21538
  false&&ASSERT(!scale || isVector3(scale), 'scale must be a Vector3', scale);
21164
- false&&ASSERT(matrix instanceof Matrix4, 'the matrix to write into must be a Matrix4');
21165
- // scale the rotation columns and drop the position in, instead of multiplying three matrices
21539
+ const scaleX = scale ? scale.x : 1, scaleY = scale ? scale.y : 1, scaleZ = scale ? scale.z : 1;
21166
21540
  // an object that is not turned at all is most of a big scene, and identity is what the six
21167
21541
  // trig calls would have worked out to anyway
21168
- const turned = rotation && (rotation.x || rotation.y || rotation.z), m = matrix.m;
21169
- turned ? Matrix4.rotation(rotation, matrix) : m.set(matrix4Identity);
21170
- if (scale)
21542
+ if (rotation && (rotation.x || rotation.y || rotation.z))
21171
21543
  {
21172
- m[0] *= scale.x; m[1] *= scale.x; m[2] *= scale.x;
21173
- m[4] *= scale.y; m[5] *= scale.y; m[6] *= scale.y;
21174
- m[8] *= scale.z; m[9] *= scale.z; m[10] *= scale.z;
21544
+ false&&ASSERT_VECTOR3_VALID(rotation);
21545
+ const cx = cos(rotation.x), sx = sin(rotation.x);
21546
+ const cy = cos(rotation.y), sy = sin(rotation.y);
21547
+ const cz = cos(rotation.z), sz = sin(rotation.z);
21548
+ // R = Ry * Rx * Rz written out, column major, as matrix4RotationAt, each column times its scale
21549
+ m[k] = (cy*cz + sy*sx*sz) * scaleX; m[k+1] = cx*sz * scaleX; m[k+2] = (-sy*cz + cy*sx*sz) * scaleX;
21550
+ m[k+4] = (-cy*sz + sy*sx*cz) * scaleY; m[k+5] = cx*cz * scaleY; m[k+6] = (sy*sz + cy*sx*cz) * scaleY;
21551
+ m[k+8] = sy*cx * scaleZ; m[k+9] = -sx * scaleZ; m[k+10] = cy*cx * scaleZ;
21175
21552
  }
21176
- if (pos)
21177
- m[12] = pos.x, m[13] = pos.y, m[14] = pos.z;
21178
- return matrix;
21553
+ else
21554
+ {
21555
+ m[k] = scaleX; m[k+1] = 0; m[k+2] = 0;
21556
+ m[k+4] = 0; m[k+5] = scaleY; m[k+6] = 0;
21557
+ m[k+8] = 0; m[k+9] = 0; m[k+10] = scaleZ;
21558
+ }
21559
+ m[k+3] = m[k+7] = m[k+11] = 0; m[k+15] = 1;
21560
+ m[k+12] = pos ? pos.x : 0, m[k+13] = pos ? pos.y : 0, m[k+14] = pos ? pos.z : 0;
21179
21561
  }
21180
21562
 
21181
21563
  ///////////////////////////////////////////////////////////////////////////////
@@ -21583,6 +21965,9 @@ const RENDER3D_VERTEX_BYTES = RENDER3D_VERTEX_FLOATS * 4;
21583
21965
  // uv rect (12); constants for one draw, one per instance for a batch; the shader derives the normal matrix
21584
21966
  const RENDER3D_INSTANCE_FLOATS = 24;
21585
21967
  const RENDER3D_INSTANCE_BYTES = RENDER3D_INSTANCE_FLOATS * 4;
21968
+ // an InstancedMesh3D keeps them apart: 16 floats of matrix, and 8 of tint and uv rect uploaded only when they change
21969
+ const RENDER3D_MATRIX_FLOATS = 16;
21970
+ const RENDER3D_COLOR_FLOATS = 8;
21586
21971
  const RENDER3D_INSTANCE_ATTRIBS = [[4, 4, 0], [5, 4, 16], [6, 4, 32], [7, 4, 48], [11, 4, 64], [12, 4, 80]];
21587
21972
  const RENDER3D_VERTEX_INPUTS =
21588
21973
  'layout(location=0) in vec3 p;layout(location=1) in vec3 n;layout(location=2) in vec2 t;layout(location=3) in vec4 c;' +
@@ -21626,22 +22011,49 @@ function render3DCanDraw()
21626
22011
  }
21627
22012
 
21628
22013
  // the draw state fields a batch is drawn under; lights and fog are not captured, they are read live at flush
21629
- // the three functions below write them out by hand for speed, so a new field goes in all four; emissiveMapColor is
21630
- // compared by its values, so two objects with equal colors batch
22014
+ // each is an accessor on the plugin over render3D.drawState, and a real change moves render3D.stateVersion, so a
22015
+ // batch checks that one number at each draw while the state holds; the three functions below write the fields out
22016
+ // by hand for speed, so a new field goes in all four, in drawState and gets an accessor; emissiveMapColor is
22017
+ // compared by its rgb, so two objects with equal colors batch
22018
+ /** The draw state's values, behind render3D's accessors, and the rgb emissiveMapColor had when it was set
22019
+ * @typedef {Object} Render3DDrawState
22020
+ * @property {boolean} blend
22021
+ * @property {boolean} additive
22022
+ * @property {boolean} depthTest
22023
+ * @property {boolean} depthWrite
22024
+ * @property {boolean} cullBackFaces
22025
+ * @property {boolean} mirrored
22026
+ * @property {boolean} lighting
22027
+ * @property {number} emissive
22028
+ * @property {boolean} receiveShadow
22029
+ * @property {number} specular
22030
+ * @property {boolean} pixelated
22031
+ * @property {Shader|undefined} shader
22032
+ * @property {TextureInfo|undefined} normalMap
22033
+ * @property {number} normalScale
22034
+ * @property {number} shininess
22035
+ * @property {number} reflectivity
22036
+ * @property {TextureInfo|undefined} emissiveMap
22037
+ * @property {Color} emissiveMapColor
22038
+ * @property {number} emissiveR
22039
+ * @property {number} emissiveG
22040
+ * @property {number} emissiveB
22041
+ * @memberof Render3D */
21631
22042
  const RENDER3D_STATE_FIELDS = ['blend', 'additive', 'depthTest', 'depthWrite', 'cullBackFaces', 'mirrored', 'lighting',
21632
22043
  'emissive', 'receiveShadow', 'specular', 'pixelated', 'shader', 'normalMap', 'normalScale', 'shininess',
21633
22044
  'reflectivity', 'emissiveMap', 'emissiveMapColor'];
21634
22045
 
21635
- // a copy of the draw state in one fixed shape, the fields of RENDER3D_STATE_FIELDS written out so the
21636
- // compare below stays a handful of direct reads, it runs for every instance drawn
22046
+ // a copy of the draw state in one fixed shape, the fields of RENDER3D_STATE_FIELDS written out, and the version
22047
+ // it was taken at
21637
22048
  function render3DCaptureBatchState()
21638
22049
  {
21639
- const r = render3D;
21640
- return {blend: r.blend, additive: r.additive, depthTest: r.depthTest, depthWrite: r.depthWrite,
21641
- cullBackFaces: r.cullBackFaces, mirrored: r.mirrored, lighting: r.lighting, emissive: r.emissive,
21642
- receiveShadow: r.receiveShadow, specular: r.specular, pixelated: r.pixelated, shader: r.shader,
21643
- normalMap: r.normalMap, normalScale: r.normalScale, shininess: r.shininess, reflectivity: r.reflectivity,
21644
- emissiveMap: r.emissiveMap, emissiveMapColor: (r.emissiveMapColor || WHITE).copy()}; // the caller may change it
22050
+ const r = render3D, d = r.drawState;
22051
+ return {blend: d.blend, additive: d.additive, depthTest: d.depthTest, depthWrite: d.depthWrite,
22052
+ cullBackFaces: d.cullBackFaces, mirrored: d.mirrored, lighting: d.lighting, emissive: d.emissive,
22053
+ receiveShadow: d.receiveShadow, specular: d.specular, pixelated: d.pixelated, shader: d.shader,
22054
+ normalMap: d.normalMap, normalScale: d.normalScale, shininess: d.shininess, reflectivity: d.reflectivity,
22055
+ emissiveMap: d.emissiveMap, emissiveMapColor: (d.emissiveMapColor || WHITE).copy(), // the caller may change it
22056
+ version: r.stateVersion};
21645
22057
  }
21646
22058
 
21647
22059
  // put a captured draw state back, written out the same way; the transparent stage does this for every queued draw
@@ -21655,21 +22067,23 @@ function render3DApplyBatchState(s)
21655
22067
  r.reflectivity = s.reflectivity, r.emissiveMap = s.emissiveMap, r.emissiveMapColor = s.emissiveMapColor;
21656
22068
  }
21657
22069
 
21658
- // true when the current draw state differs from a captured one, so a pending batch must flush first
22070
+ // true when the current draw state differs from a captured one, so a pending batch must flush first; it runs for
22071
+ // every instance drawn, so while nothing was set since the capture it checks the version alone
21659
22072
  function render3DStateChanged(s)
21660
22073
  {
21661
22074
  const r = render3D;
21662
- return r.blend !== s.blend || r.additive !== s.additive || r.depthTest !== s.depthTest
21663
- || r.depthWrite !== s.depthWrite || r.cullBackFaces !== s.cullBackFaces || r.mirrored !== s.mirrored
21664
- || r.lighting !== s.lighting || r.emissive !== s.emissive || r.receiveShadow !== s.receiveShadow
21665
- || r.specular !== s.specular || r.pixelated !== s.pixelated || r.shader !== s.shader
21666
- || r.normalMap !== s.normalMap || r.normalScale !== s.normalScale || r.shininess !== s.shininess
21667
- || r.reflectivity !== s.reflectivity || r.emissiveMap !== s.emissiveMap
21668
- || render3DColorChanged(r.emissiveMapColor || WHITE, s.emissiveMapColor);
21669
- }
21670
-
21671
- // whether two colors differ in their rgb, for the batch key's emissiveMapColor
21672
- function render3DColorChanged(a, b) { return a.r !== b.r || a.g !== b.g || a.b !== b.b;
22075
+ if (s.version === r.stateVersion) return false;
22076
+ const d = r.drawState, c = s.emissiveMapColor;
22077
+ if (d.blend !== s.blend || d.additive !== s.additive || d.depthTest !== s.depthTest
22078
+ || d.depthWrite !== s.depthWrite || d.cullBackFaces !== s.cullBackFaces || d.mirrored !== s.mirrored
22079
+ || d.lighting !== s.lighting || d.emissive !== s.emissive || d.receiveShadow !== s.receiveShadow
22080
+ || d.specular !== s.specular || d.pixelated !== s.pixelated || d.shader !== s.shader
22081
+ || d.normalMap !== s.normalMap || d.normalScale !== s.normalScale || d.shininess !== s.shininess
22082
+ || d.reflectivity !== s.reflectivity || d.emissiveMap !== s.emissiveMap
22083
+ || d.emissiveR !== c.r || d.emissiveG !== c.g || d.emissiveB !== c.b)
22084
+ return true;
22085
+ s.version = r.stateVersion; // set and set back to what it was: the batch goes on, checking the version again
22086
+ return false;
21673
22087
  }
21674
22088
 
21675
22089
  // whether a sphere is inside the view, or the shadow map's box during the shadow pass, without a vector
@@ -21747,7 +22161,10 @@ function render3DMaxScale(m)
21747
22161
  // how far a matrix at offset k can move a point that is one unit from its origin, for a bounding sphere: the
21748
22162
  // longest axis when the axes are square to each other, more when they are not, as a turned child under a parent
21749
22163
  // scaled on one axis leaves them; the axes' dot products bound the largest stretch by their largest row sum
21750
- function render3DMaxStretch(m, k=0)
22164
+ function render3DMaxStretch(m, k=0) { return render3DMaxStretchSquared(m, k) ** .5; }
22165
+
22166
+ // the same, squared, for bounds that grow many times before they are read
22167
+ function render3DMaxStretchSquared(m, k=0)
21751
22168
  {
21752
22169
  const xx = m[k]*m[k] + m[k+1]*m[k+1] + m[k+2]*m[k+2];
21753
22170
  const yy = m[k+4]*m[k+4] + m[k+5]*m[k+5] + m[k+6]*m[k+6];
@@ -21755,7 +22172,7 @@ function render3DMaxStretch(m, k=0)
21755
22172
  const xy = abs(m[k]*m[k+4] + m[k+1]*m[k+5] + m[k+2]*m[k+6]);
21756
22173
  const xz = abs(m[k]*m[k+8] + m[k+1]*m[k+9] + m[k+2]*m[k+10]);
21757
22174
  const yz = abs(m[k+4]*m[k+8] + m[k+5]*m[k+9] + m[k+6]*m[k+10]);
21758
- return max(xx + xy + xz, yy + xy + yz, zz + xz + yz) ** .5;
22175
+ return max(xx + xy + xz, yy + xy + yz, zz + xz + yz);
21759
22176
  }
21760
22177
 
21761
22178
  // a quad as a strip from its center and half axes, the same corner order as render3DQuadStrip
@@ -21805,10 +22222,11 @@ function render3DDrawObjects(objects)
21805
22222
  render3DSetObjectState();
21806
22223
  }
21807
22224
 
21808
- // 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
22225
+ // 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,
22226
+ // and under one winding: a mirroring transform, one with a negative determinant, turns it around
21809
22227
  function render3DInstance(mesh, matrix, tileInfo, color)
21810
22228
  {
21811
- const k = render3DInstanceSlot(mesh, render3DTextureOf(tileInfo)), data = mesh.instanceData;
22229
+ const k = render3DInstanceSlot(mesh, render3DTextureOf(tileInfo), render3DMirrors(matrix)), data = mesh.instanceData;
21812
22230
  data.set(matrix.m, k);
21813
22231
  // the tint; this batch is never blended, so in the shadow pass the depth shader cuts it by its texture alpha
21814
22232
  // alone, a see through caster draws blended instead and is cut by its tint alpha too
@@ -21819,14 +22237,18 @@ function render3DInstance(mesh, matrix, tileInfo, color)
21819
22237
 
21820
22238
  // make room for one more instance of a mesh under a texture and the current draw state, flushing a batch that
21821
22239
  // differs first, and return where its 24 floats go in mesh.instanceData: the matrix, the tint and the uv rect
21822
- function render3DInstanceSlot(mesh, textureInfo)
22240
+ // drawMesh passes whether its matrix mirrors, and its batch then culls by the mesh and winds by that at the flush,
22241
+ // so it never sets the draw state for them, which would move the state's version at every draw
22242
+ function render3DInstanceSlot(mesh, textureInfo, mirrored)
21823
22243
  {
21824
22244
  const r = render3D;
21825
- if (mesh.instanceCount && (mesh.instanceTextureInfo !== textureInfo || render3DStateChanged(mesh.instanceState)))
22245
+ if (mesh.instanceCount && (mesh.instanceTextureInfo !== textureInfo || mesh.instanceMirrored !== mirrored
22246
+ || render3DStateChanged(mesh.instanceState)))
21826
22247
  render3DFlushInstances(mesh);
21827
22248
  if (!mesh.instanceCount)
21828
22249
  {
21829
22250
  mesh.instanceTextureInfo = textureInfo;
22251
+ mesh.instanceMirrored = mirrored;
21830
22252
  mesh.instanceState = render3DCaptureBatchState();
21831
22253
  r.instanceMeshes.push(mesh);
21832
22254
  }
@@ -21857,7 +22279,10 @@ function render3DFlushInstances(only)
21857
22279
  const buffers = r.instanceBuffers, buffer = buffers[r.instanceBufferIndex = (r.instanceBufferIndex + 1) % buffers.length];
21858
22280
  gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
21859
22281
  gl.bufferData(gl.ARRAY_BUFFER, mesh.instanceData, gl.DYNAMIC_DRAW, 0, count * RENDER3D_INSTANCE_FLOATS);
21860
- render3DDrawInstanced(mesh, buffer, count, mesh.instanceTextureInfo, mesh.instanceState);
22282
+ const state = mesh.instanceState;
22283
+ if (mesh.instanceMirrored !== undefined) // a drawMesh batch: culled by its mesh, wound by its matrices
22284
+ state.cullBackFaces = !mesh.doubleSided, state.mirrored = mesh.instanceMirrored;
22285
+ render3DDrawInstanced(mesh, buffer, count, mesh.instanceTextureInfo, state);
21861
22286
  }
21862
22287
  if (!only)
21863
22288
  r.instanceMeshes.length = 0;
@@ -21872,13 +22297,16 @@ function render3DFlushInstances(only)
21872
22297
  // the arrays are turned on with their instance divisor for this one call and both are turned off after: a single
21873
22298
  // draw reads these slots as constant attributes, and in Firefox a draw that reads a constant through a slot whose
21874
22299
  // divisor is set leaves the next batch on that slot reading the wrong values
21875
- function render3DDrawInstanced(mesh, buffer, count, textureInfo, state)
22300
+ // colorBuffer holds the tint and uv rect apart from the matrices, as an InstancedMesh3D keeps them
22301
+ function render3DDrawInstanced(mesh, buffer, count, textureInfo, state, colorBuffer)
21876
22302
  {
21877
- const gl = glContext;
21878
- gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
22303
+ const gl = glContext, matrixBytes = RENDER3D_MATRIX_FLOATS * 4;
21879
22304
  for (const [location, size, offset] of RENDER3D_INSTANCE_ATTRIBS)
21880
22305
  {
21881
- gl.vertexAttribPointer(location, size, gl.FLOAT, false, RENDER3D_INSTANCE_BYTES, offset);
22306
+ const inColors = colorBuffer && offset >= matrixBytes;
22307
+ const stride = !colorBuffer ? RENDER3D_INSTANCE_BYTES : inColors ? RENDER3D_COLOR_FLOATS * 4 : matrixBytes;
22308
+ gl.bindBuffer(gl.ARRAY_BUFFER, inColors ? colorBuffer : buffer);
22309
+ gl.vertexAttribPointer(location, size, gl.FLOAT, false, stride, inColors ? offset - matrixBytes : offset);
21882
22310
  gl.enableVertexAttribArray(location);
21883
22311
  gl.vertexAttribDivisor(location, 1);
21884
22312
  }
@@ -21923,11 +22351,36 @@ function render3DCollectLights()
21923
22351
  for (const light of lights)
21924
22352
  distances.set(light, light.directional ? -1 : light.getWorldPos3D().distanceSquared(cameraPos));
21925
22353
  lights.sort((a, b)=> distances.get(a) - distances.get(b));
22354
+ // the light that casts the shadows keeps a slot however far it is, in place of the farthest
22355
+ const caster = render3DShadowCaster();
22356
+ if (caster && lights.indexOf(caster) >= RENDER3D_MAX_LIGHTS)
22357
+ lights[RENDER3D_MAX_LIGHTS - 1] = caster;
21926
22358
  lights.length = RENDER3D_MAX_LIGHTS;
21927
22359
  }
21928
22360
  return lights;
21929
22361
  }
21930
22362
 
22363
+ // the spotlight that casts the shadows in place of the sun: render3D.shadowLight while it is a light that is on
22364
+ // and has a cone, undefined for the sun
22365
+ function render3DShadowCaster()
22366
+ {
22367
+ const light = render3D.shadowLight;
22368
+ return light && !light.destroyed && !light.directional && light.coneAngle > 0 && light.radius > 0 &&
22369
+ light.intensity > 0 && light.color.a > 0 ? light : undefined;
22370
+ }
22371
+
22372
+ // a light's cone as the four numbers the shader gets: the way it shines, scaled so that its dot with the way to
22373
+ // 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
22374
+ // gets numbers that make it 1 every way
22375
+ function render3DLightCone(light)
22376
+ {
22377
+ const angle = light.directional ? 0 : min(light.coneAngle, PI);
22378
+ if (!(angle > 0)) return [0, 0, 0, -1];
22379
+ const outer = cos(angle), inner = cos(angle * (1 - clamp(light.coneSoftness)));
22380
+ const k = 1 / max(inner - outer, 1e-4), forward = light.getForward3D();
22381
+ return [forward.x * k, forward.y * k, forward.z * k, outer * k];
22382
+ }
22383
+
21931
22384
  // unit circle directions for a number of sides, [cos, sin, cos, sin, ...] including the closing point, cached
21932
22385
  const render3DCircleCache = new Map;
21933
22386
  function render3DCircle(sides)
@@ -21979,7 +22432,15 @@ class Render3DPlugin
21979
22432
  {
21980
22433
  false&&ASSERT(!render3D, 'Render3D plugin already initialized');
21981
22434
  render3D = this;
21982
- false&&ASSERT(Object.keys(render3DCaptureBatchState()).join() === RENDER3D_STATE_FIELDS.join(),
22435
+ // the draw state, read at each draw through the accessors below the constructor
22436
+ /** @type {Render3DDrawState} */
22437
+ this.drawState = {blend: false, additive: false, depthTest: true, depthWrite: true, cullBackFaces: false,
22438
+ mirrored: false, lighting: true, emissive: 0, receiveShadow: true, specular: 0, pixelated: false,
22439
+ shader: undefined, normalMap: undefined, normalScale: 1, shininess: 16, reflectivity: 0,
22440
+ emissiveMap: undefined, emissiveMapColor: WHITE, emissiveR: 1, emissiveG: 1, emissiveB: 1};
22441
+ /** @property {number} - Goes up when a draw state field changes, so a batch sees at a glance that none did */
22442
+ this.stateVersion = 0;
22443
+ false&&ASSERT(Object.keys(render3DCaptureBatchState()).join() === [...RENDER3D_STATE_FIELDS, 'version'].join(),
21983
22444
  'the batch state functions must list RENDER3D_STATE_FIELDS');
21984
22445
 
21985
22446
  /** @property {Camera3D} - The camera */
@@ -22025,8 +22486,16 @@ class Render3DPlugin
22025
22486
  // shadows
22026
22487
  /** @property {boolean} - Cast real shadows from the sun, off by default and free when off */
22027
22488
  this.shadows = false;
22489
+ /** @property {number} - How far a sound played with playSound is heard when it was made with no range of
22490
+ * its own, in world units; further than the 2D soundDefaultRange, a 3D world is bigger */
22491
+ this.soundDefaultRange = 100;
22028
22492
  /** @property {number} - Size of the shadow map in pixels, bigger is sharper and slower */
22029
22493
  this.shadowMapSize = 1024;
22494
+ /** @property {Light3D|undefined} - A spotlight, a Light3D with a coneAngle, to cast the shadows in place
22495
+ * of the sun, a flashlight in the dark: the shadow map looks down its cone, as far as its radius, and the
22496
+ * sun still lights the scene but casts none; undefined for the sun, as is a light that is off or has no cone
22497
+ * @type {Light3D|undefined} */
22498
+ this.shadowLight = undefined;
22030
22499
  /** @property {number} - World size the shadow map covers around shadowCenter, smaller is sharper; it is a
22031
22500
  * square facing the light, so it turns as the light does, and about 1.5 times an area's width covers it from
22032
22501
  * any angle */
@@ -22039,50 +22508,6 @@ class Render3DPlugin
22039
22508
  /** @property {number} - How much to blur the shadow edges */
22040
22509
  this.shadowSoftness = 1;
22041
22510
 
22042
- // draw state, read at each draw
22043
- /** @property {boolean} - Apply lighting, when false draws plain vertex color times texture and casts no shadow;
22044
- * off for billboards, lines, ribbons and soft discs, an object sets emissive instead */
22045
- this.lighting = true;
22046
- /** @property {number} - How much a surface lights itself, set per object by its emissive */
22047
- this.emissive = 0;
22048
- /** @property {boolean} - Additive blending instead of alpha, in the transparent stage */
22049
- this.additive = false;
22050
- /** @property {boolean} - Test against the depth buffer, reset to true before each object and callback; a draw
22051
- * with it off goes over what was drawn before it and under what is drawn after, by render order, which
22052
- * ends the batch of meshes before it, so one per object costs a draw per object */
22053
- this.depthTest = true;
22054
- /** @property {boolean} - Write to the depth buffer, owned by the stages: on for opaque, off for transparent */
22055
- this.depthWrite = true;
22056
- // batch state set by drawMesh from each mesh: whether its back faces are skipped, off for strips so they
22057
- // show from both sides, and whether its transform mirrors it so the other winding is the front
22058
- this.cullBackFaces = false;
22059
- this.mirrored = false;
22060
- /** @property {number} - Strength of the highlight where the sun and the Light3D objects reflect, 0 is none and
22061
- * 1 adds a light's full color at its brightest; shininess sets its size */
22062
- this.specular = 0;
22063
- /** @property {number} - The highlight's exponent, how small and sharp it is: 4 is broad like rubber, 100 sharp
22064
- * like polished metal; set from each object's shininess */
22065
- this.shininess = 16;
22066
- /** @property {TextureInfo|undefined} - Normal map for the next draws, set from each object's normalMap
22067
- * @type {TextureInfo|undefined} */
22068
- this.normalMap = undefined;
22069
- /** @property {number} - How strongly the normal map bends the surface, set from each object's normalScale */
22070
- this.normalScale = 1;
22071
- /** @property {number} - How much the surface reflects the sky, 0 to 1, set from each object's reflectivity */
22072
- this.reflectivity = 0;
22073
- /** @property {TextureInfo|undefined} - Emissive map for the next draws, set from each object's emissiveMap
22074
- * @type {TextureInfo|undefined} */
22075
- this.emissiveMap = undefined;
22076
- /** @property {Color} - Multiplies the emissive map, set from each object's emissiveMapColor */
22077
- this.emissiveMapColor = WHITE;
22078
- /** @property {Shader|undefined} - Custom Shader for the next draws, set from each object's shader; undefined
22079
- * draws with the plugin's own
22080
- * @type {Shader|undefined} */
22081
- this.shader = undefined;
22082
- /** @property {boolean} - Darken by the shadow map when shadows are on, turn it off for things that should stay
22083
- * lit inside a shadow */
22084
- this.receiveShadow = true;
22085
-
22086
22511
  // the pass
22087
22512
  /** @property {Function|undefined} - Draw solid world here, it runs again for shadows so only draw in it
22088
22513
  * @type {Function|undefined} */
@@ -22105,9 +22530,6 @@ class Render3DPlugin
22105
22530
  /** @property {boolean} - Sample textures through mipmaps so they do not shimmer in the distance, false uses
22106
22531
  * each texture's own filtering like 2D */
22107
22532
  this.mipmaps = true;
22108
- /** @property {boolean} - Draw state: keep texture pixels hard edged, no mipmaps and no blending between them,
22109
- * set per object by pixelated */
22110
- this.pixelated = false;
22111
22533
  /** @property {number} - Anisotropic filtering for textures seen at an angle, 1 to 16, 1 is off; needs mipmaps */
22112
22534
  this.anisotropy = 4;
22113
22535
 
@@ -22151,7 +22573,6 @@ class Render3DPlugin
22151
22573
  this.cameraBack = vec3(0, 0, 1); // its opposite, the normal of camera facing draws
22152
22574
 
22153
22575
  // internal state
22154
- this.blend = false; // blending on, set by the stages
22155
22576
  /** @type {Array<Array<number>>} */
22156
22577
  this.frustumPlanes = []; // the view as six inward planes [x, y, z, w]
22157
22578
  /** @type {Array<Array<number>>} */
@@ -22191,6 +22612,9 @@ class Render3DPlugin
22191
22612
  this.passIsDefault = true; // the running pass is the default layer, the only one shadowed
22192
22613
  this.lightPositions = new Float32Array(RENDER3D_MAX_LIGHTS * 4); // Light3D uniforms, filled each pass
22193
22614
  this.lightColors = new Float32Array(RENDER3D_MAX_LIGHTS * 4);
22615
+ this.lightCones = new Float32Array(RENDER3D_MAX_LIGHTS * 4); // each light's cone, see render3DLightCone
22616
+ this.shadowLightIndex = -1; // which of the lights sent casts the shadows, -1 for the sun
22617
+ this.shadowDepthBias = 0; // the bias as the shadow lookup takes it, set with the shadow matrix
22194
22618
 
22195
22619
  // the stream of immediate mode draws
22196
22620
  /** @type {WebGLBuffer|undefined} */
@@ -22218,6 +22642,116 @@ class Render3DPlugin
22218
22642
  engineAddPlugin(undefined, render3DRender, render3DContextLost, render3DContextRestored, render3DPreRender);
22219
22643
  }
22220
22644
 
22645
+ ///////////////////////////////////////////////////////////////////////////
22646
+ // Draw state: each field an accessor over drawState, whose setter moves stateVersion only on a real change
22647
+
22648
+ /** Apply lighting, when false draws plain vertex color times texture and casts no shadow;
22649
+ * off for billboards, lines, ribbons and soft discs, an object sets emissive instead
22650
+ * @return {boolean} */
22651
+ get lighting() { return this.drawState.lighting; }
22652
+ set lighting(v) { const d = this.drawState; d.lighting === v || (d.lighting = v, ++this.stateVersion); }
22653
+
22654
+ /** How much a surface lights itself, set per object by its emissive
22655
+ * @return {number} */
22656
+ get emissive() { return this.drawState.emissive; }
22657
+ set emissive(v) { const d = this.drawState; d.emissive === v || (d.emissive = v, ++this.stateVersion); }
22658
+
22659
+ /** Additive blending instead of alpha, in the transparent stage
22660
+ * @return {boolean} */
22661
+ get additive() { return this.drawState.additive; }
22662
+ set additive(v) { const d = this.drawState; d.additive === v || (d.additive = v, ++this.stateVersion); }
22663
+
22664
+ /** Test against the depth buffer, reset to true before each object and callback; a draw
22665
+ * with it off goes over what was drawn before it and under what is drawn after, by render order, which
22666
+ * ends the batch of meshes before it, so one per object costs a draw per object
22667
+ * @return {boolean} */
22668
+ get depthTest() { return this.drawState.depthTest; }
22669
+ set depthTest(v) { const d = this.drawState; d.depthTest === v || (d.depthTest = v, ++this.stateVersion); }
22670
+
22671
+ /** Write to the depth buffer, owned by the stages: on for opaque, off for transparent
22672
+ * @return {boolean} */
22673
+ get depthWrite() { return this.drawState.depthWrite; }
22674
+ set depthWrite(v) { const d = this.drawState; d.depthWrite === v || (d.depthWrite = v, ++this.stateVersion); }
22675
+
22676
+ /** Skip back faces, set by drawMesh from each mesh: off for strips so they show from
22677
+ * both sides
22678
+ * @return {boolean} */
22679
+ get cullBackFaces() { return this.drawState.cullBackFaces; }
22680
+ set cullBackFaces(v) { const d = this.drawState; d.cullBackFaces === v || (d.cullBackFaces = v, ++this.stateVersion); }
22681
+
22682
+ /** The transform mirrors the draw, so the other winding is the front, set by drawMesh
22683
+ * from each transform
22684
+ * @return {boolean} */
22685
+ get mirrored() { return this.drawState.mirrored; }
22686
+ set mirrored(v) { const d = this.drawState; d.mirrored === v || (d.mirrored = v, ++this.stateVersion); }
22687
+
22688
+ /** Strength of the highlight where the sun and the Light3D objects reflect, 0 is none and
22689
+ * 1 adds a light's full color at its brightest; shininess sets its size
22690
+ * @return {number} */
22691
+ get specular() { return this.drawState.specular; }
22692
+ set specular(v) { const d = this.drawState; d.specular === v || (d.specular = v, ++this.stateVersion); }
22693
+
22694
+ /** The highlight's exponent, how small and sharp it is: 4 is broad like rubber, 100 sharp
22695
+ * like polished metal; set from each object's shininess
22696
+ * @return {number} */
22697
+ get shininess() { return this.drawState.shininess; }
22698
+ set shininess(v) { const d = this.drawState; d.shininess === v || (d.shininess = v, ++this.stateVersion); }
22699
+
22700
+ /** Normal map for the next draws, set from each object's normalMap
22701
+ * @return {TextureInfo|undefined} */
22702
+ get normalMap() { return this.drawState.normalMap; }
22703
+ set normalMap(v) { const d = this.drawState; d.normalMap === v || (d.normalMap = v, ++this.stateVersion); }
22704
+
22705
+ /** How strongly the normal map bends the surface, set from each object's normalScale
22706
+ * @return {number} */
22707
+ get normalScale() { return this.drawState.normalScale; }
22708
+ set normalScale(v) { const d = this.drawState; d.normalScale === v || (d.normalScale = v, ++this.stateVersion); }
22709
+
22710
+ /** How much the surface reflects the sky, 0 to 1, set from each object's reflectivity
22711
+ * @return {number} */
22712
+ get reflectivity() { return this.drawState.reflectivity; }
22713
+ set reflectivity(v) { const d = this.drawState; d.reflectivity === v || (d.reflectivity = v, ++this.stateVersion); }
22714
+
22715
+ /** Emissive map for the next draws, set from each object's emissiveMap
22716
+ * @return {TextureInfo|undefined} */
22717
+ get emissiveMap() { return this.drawState.emissiveMap; }
22718
+ set emissiveMap(v) { const d = this.drawState; d.emissiveMap === v || (d.emissiveMap = v, ++this.stateVersion); }
22719
+
22720
+ /** Multiplies the emissive map, set from each object's emissiveMapColor; compared by its rgb, so a Color
22721
+ * changed in place is seen when it is set again, and undefined is white
22722
+ * @return {Color} */
22723
+ get emissiveMapColor() { return this.drawState.emissiveMapColor; }
22724
+ set emissiveMapColor(v)
22725
+ {
22726
+ const d = this.drawState, c = v || WHITE;
22727
+ d.emissiveMapColor = v;
22728
+ if (c.r !== d.emissiveR || c.g !== d.emissiveG || c.b !== d.emissiveB)
22729
+ d.emissiveR = c.r, d.emissiveG = c.g, d.emissiveB = c.b, ++this.stateVersion;
22730
+ }
22731
+
22732
+ /** Custom Shader for the next draws, set from each object's shader; undefined draws
22733
+ * with the plugin's own
22734
+ * @return {Shader|undefined} */
22735
+ get shader() { return this.drawState.shader; }
22736
+ set shader(v) { const d = this.drawState; d.shader === v || (d.shader = v, ++this.stateVersion); }
22737
+
22738
+ /** Darken by the shadow map when shadows are on, turn it off for things that should
22739
+ * stay lit inside a shadow
22740
+ * @return {boolean} */
22741
+ get receiveShadow() { return this.drawState.receiveShadow; }
22742
+ set receiveShadow(v) { const d = this.drawState; d.receiveShadow === v || (d.receiveShadow = v, ++this.stateVersion); }
22743
+
22744
+ /** Keep texture pixels hard edged, no mipmaps and no blending between them, set per
22745
+ * object by pixelated
22746
+ * @return {boolean} */
22747
+ get pixelated() { return this.drawState.pixelated; }
22748
+ set pixelated(v) { const d = this.drawState; d.pixelated === v || (d.pixelated = v, ++this.stateVersion); }
22749
+
22750
+ /** Blending on, set by the stages
22751
+ * @return {boolean} */
22752
+ get blend() { return this.drawState.blend; }
22753
+ set blend(v) { const d = this.drawState; d.blend === v || (d.blend = v, ++this.stateVersion); }
22754
+
22221
22755
  ///////////////////////////////////////////////////////////////////////////
22222
22756
  // Matrices and picking
22223
22757
 
@@ -22327,7 +22861,8 @@ class Render3DPlugin
22327
22861
  }
22328
22862
 
22329
22863
  /** Play a sound at a 3D position, quieter with distance from the camera and panned by its side, like Sound.play
22330
- * with a 2D position
22864
+ * with a 2D position; a sound made with no range of its own is heard to soundDefaultRange, 100, and one given
22865
+ * a range keeps it
22331
22866
  * @param {Sound} sound
22332
22867
  * @param {Vector3} pos3D
22333
22868
  * @param {number} [volume]
@@ -22341,7 +22876,9 @@ class Render3DPlugin
22341
22876
  // the 3D range fade and pan, Sound.play with no position does the rest
22342
22877
  false&&ASSERT(sound instanceof Sound, 'sound must be a Sound');
22343
22878
  false&&ASSERT(isVector3(pos3D), 'pos3D must be a vec3');
22344
- const offset = pos3D.subtract(this.camera.pos), range = sound.range;
22879
+ // a sound with no range of its own is heard further in 3D than in 2D
22880
+ const offset = pos3D.subtract(this.camera.pos);
22881
+ const range = sound.rangeIsDefault ? this.soundDefaultRange : sound.range;
22345
22882
  if (range)
22346
22883
  {
22347
22884
  const distance = offset.length();
@@ -22405,15 +22942,15 @@ class Render3DPlugin
22405
22942
  if (this.frustumCulling && !render3DSphereVisible(m[12], m[13], m[14], mesh.radius * render3DMaxStretch(m)))
22406
22943
  return;
22407
22944
  // the mesh says whether its back faces can be skipped, and a mirroring transform, one with a negative
22408
- // determinant, turns the winding around so the other one is its front
22409
- const cullBackFaces = this.cullBackFaces, mirrored = this.mirrored;
22410
- this.cullBackFaces = !mesh.doubleSided;
22411
- this.mirrored = render3DMirrors(matrix);
22412
- // the stage draws the batch at its end
22945
+ // determinant, turns the winding around so the other one is its front; the stage draws a batch at its
22946
+ // end, which keys on those itself
22413
22947
  if (!this.blend && this.depthTest && (mesh.instanced ?? this.instancing))
22414
22948
  render3DInstance(mesh, matrix, tileInfo, color);
22415
22949
  else
22416
22950
  {
22951
+ const cullBackFaces = this.cullBackFaces, mirrored = this.mirrored;
22952
+ this.cullBackFaces = !mesh.doubleSided;
22953
+ this.mirrored = render3DMirrors(matrix);
22417
22954
  this.flush();
22418
22955
  render3DFlushBeforeOverlay();
22419
22956
  render3DSetDrawUniforms(matrix, tileInfo, color);
@@ -22421,8 +22958,8 @@ class Render3DPlugin
22421
22958
  glContext.drawElements(glContext.TRIANGLES, mesh.bufferCount, mesh.indexType, 0);
22422
22959
  ++drawCount;
22423
22960
  primitiveCount += mesh.bufferCount / 3;
22961
+ this.cullBackFaces = cullBackFaces, this.mirrored = mirrored;
22424
22962
  }
22425
- this.cullBackFaces = cullBackFaces, this.mirrored = mirrored;
22426
22963
  }
22427
22964
 
22428
22965
  /** Draw a triangle strip, batched into the stream with the current draw state
@@ -22618,6 +23155,23 @@ class Render3DPlugin
22618
23155
  /** Rebuild the light's view projection around the shadow center, called automatically each frame shadows are on */
22619
23156
  updateShadowMatrix()
22620
23157
  {
23158
+ const caster = render3DShadowCaster();
23159
+ if (caster)
23160
+ {
23161
+ // a spotlight's map looks down its cone with perspective, from just in front of it out to its radius
23162
+ const pos = caster.getWorldPos3D(), forward = caster.getForward3D();
23163
+ const up = abs(forward.y) > .99 ? vec3(0, 0, 1) : vec3(0, 1, 0);
23164
+ const far = caster.radius, near = max(far / 500, .02);
23165
+ const fov = min(min(caster.coneAngle, PI) * 2 + .1, 2.8);
23166
+ this.shadowMatrix = Matrix4.perspective(fov, 1, near, far).multiply(
23167
+ Matrix4.lookAt(pos, pos.add(forward), up).invert());
23168
+ this.shadowPlanes = render3DFrustumPlanes(this.shadowMatrix);
23169
+ // depth is not even with perspective: the lookup divides this by the distance squared, which makes
23170
+ // the bias the same distance in the world near the light and far from it
23171
+ this.shadowDepthBias = this.shadowBias * far * far * near / (far - near);
23172
+ return;
23173
+ }
23174
+ this.shadowDepthBias = this.shadowBias; // the sun's map is flat, its depth even
22621
23175
  false&&ASSERT(this.shadowRange > 0, 'shadowRange must be positive');
22622
23176
  const range = this.shadowRange > 0 ? this.shadowRange : 1, half = range / 2;
22623
23177
  const toSun = this.sunDirection.normalize();
@@ -23047,6 +23601,7 @@ function render3DFragmentSource(fragmentCode)
23047
23601
  'precision highp float;' +
23048
23602
  'uniform vec4 lightDir,lightColor,ambientFog,ambientGround,fogColor,shadowParams;' +
23049
23603
  'uniform vec4 extraLights[' + RENDER3D_MAX_LIGHTS + '],extraLightColors[' + RENDER3D_MAX_LIGHTS + '];' +
23604
+ 'uniform vec4 extraLightCones[' + RENDER3D_MAX_LIGHTS + '];' +
23050
23605
  'uniform int extraLightCount;' +
23051
23606
  'uniform vec3 cameraPos;' +
23052
23607
  'uniform vec4 materialParams,emissiveTint,skyTop,skyHorizon,skyBottom;' +
@@ -23055,12 +23610,14 @@ function render3DFragmentSource(fragmentCode)
23055
23610
  'uniform highp sampler2DShadow shadowMap;' +
23056
23611
  'in vec3 P,N;in vec2 T,L;in vec4 C,S;' +
23057
23612
  'out vec4 o;' +
23058
- // the sun shadow at this fragment, 0 to 1: the light's depth map with a 3x3 blur, outside the map is lit
23613
+ // the shadow at this fragment, 0 to 1: the light's depth map with a 3x3 blur, outside the map is lit; the
23614
+ // 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
23615
+ // bias shrinks by its square to stay the same distance in the world
23059
23616
  'float shadow(){' +
23060
- 'if(shadowParams.x<=0.)return 1.;' +
23617
+ 'if(shadowParams.x<=0.||S.w<=0.)return 1.;' +
23061
23618
  'vec3 q=S.xyz/S.w*.5+.5;' +
23062
23619
  'if(any(greaterThanEqual(abs(q-.5),vec3(.5))))return 1.;' +
23063
- 'q.z-=shadowParams.y;' +
23620
+ 'q.z-=shadowParams.y/(S.w*S.w);' +
23064
23621
  'float s=0.;' +
23065
23622
  'for(int x=-1;x<=1;++x)for(int y=-1;y<=1;++y)' +
23066
23623
  's+=texture(shadowMap,vec3(q.xy+vec2(x,y)*shadowParams.z,q.z));' +
@@ -23088,7 +23645,9 @@ function render3DFragmentSource(fragmentCode)
23088
23645
  'if(!gl_FrontFacing)n=-n;' + // only a double sided mesh shows a back face, light it on the side that is seen
23089
23646
  'if(materialParams.x!=0.)n=normalMapNormal(n);' +
23090
23647
  'float nl=dot(n,-lightDir.xyz);' +
23091
- 'float s=shadow();' +
23648
+ // the shadow is the sun's, or one spotlight's: shadowParams.x is 1 for the sun, 2 and up for that Light3D
23649
+ 'float sh=shadow(),s=shadowParams.x<1.5?sh:1.;' +
23650
+ 'int si=int(shadowParams.x+.5)-2;' +
23092
23651
  // the ambient: one color, or blended from the ground color below to the sky color above by the way the face points
23093
23652
  'vec3 l=(ambientGround.a>0.?mix(ambientGround.rgb,ambientFog.rgb,n.y*.5+.5):ambientFog.rgb)+lightColor.rgb*max(nl,0.)*s;' +
23094
23653
  // the Light3D objects: a point light falls off with distance, a directional one does not and carries the
@@ -23104,6 +23663,11 @@ function render3DFragmentSource(fragmentCode)
23104
23663
  'v/=max(d,1e-6);' +
23105
23664
  'float ln=dot(n,v);' +
23106
23665
  'vec3 lc=extraLightColors[i].rgb*extraLightColors[i].a*a*a;' +
23666
+ // a spotlight's cone: full where its fade starts, nothing at its edge, smooth between; 1 with no cone
23667
+ 'vec4 K=extraLightCones[i];' +
23668
+ 'float k=clamp(dot(K.xyz,-v)-K.w,0.,1.);' +
23669
+ 'lc*=k*k*(3.-2.*k);' +
23670
+ 'if(i==si)lc*=sh;' +
23107
23671
  'l+=lc*max(0.,ln);' +
23108
23672
  'if(lightColor.a>0.)sp+=lc*pow(max(dot(reflect(-v,n),eye),0.),materialParams.y)*step(0.,ln);' +
23109
23673
  '}' +
@@ -23158,6 +23722,7 @@ function render3DUseProgram(program)
23158
23722
  {
23159
23723
  gl.uniform4fv(render3DUniform('extraLights'), r.lightPositions, 0, r.lightCount * 4);
23160
23724
  gl.uniform4fv(render3DUniform('extraLightColors'), r.lightColors, 0, r.lightCount * 4);
23725
+ gl.uniform4fv(render3DUniform('extraLightCones'), r.lightCones, 0, r.lightCount * 4);
23161
23726
  }
23162
23727
  if (program !== r.program)
23163
23728
  {
@@ -23298,18 +23863,19 @@ function render3DUpdateSamplers()
23298
23863
  r.samplers.clear();
23299
23864
  }
23300
23865
 
23301
- // the sampler for a texture's wrap modes, smooth or hard edged, made the first time it is needed
23302
- function render3DSampler(wrap, pixelated)
23866
+ // the sampler for a texture's wrap modes, smooth or hard edged, made the first time it is needed; pixelated is the
23867
+ // draw's, texturePixelated the texture's own, which a smooth texture in a pixel art game sets false
23868
+ function render3DSampler(wrap, pixelated, texturePixelated=tilesPixelated)
23303
23869
  {
23304
23870
  const gl = glContext, r = render3D, [wrapS, wrapT] = glWrapModes(wrap);
23305
- const key = wrapS * 1e5 + wrapT * 2 + (pixelated ? 1 : 0); // the modes are 5 digit numbers
23871
+ const key = wrapS * 1e5 + wrapT * 4 + (pixelated ? 1 : 0) + (texturePixelated ? 2 : 0); // the modes are 5 digit numbers
23306
23872
  let sampler = r.samplers.get(key);
23307
23873
  if (sampler) return sampler;
23308
23874
  sampler = gl.createSampler();
23309
- const sharp = pixelated || tilesPixelated;
23875
+ const sharp = pixelated || texturePixelated;
23310
23876
  gl.samplerParameteri(sampler, gl.TEXTURE_MAG_FILTER, sharp ? gl.NEAREST : gl.LINEAR);
23311
23877
  gl.samplerParameteri(sampler, gl.TEXTURE_MIN_FILTER, pixelated ? gl.NEAREST
23312
- : tilesPixelated ? gl.NEAREST_MIPMAP_LINEAR : gl.LINEAR_MIPMAP_LINEAR);
23878
+ : texturePixelated ? gl.NEAREST_MIPMAP_LINEAR : gl.LINEAR_MIPMAP_LINEAR);
23313
23879
  gl.samplerParameteri(sampler, gl.TEXTURE_WRAP_S, wrapS);
23314
23880
  gl.samplerParameteri(sampler, gl.TEXTURE_WRAP_T, wrapT);
23315
23881
  const anisotropy = gl.getExtension('EXT_texture_filter_anisotropic');
@@ -23337,7 +23903,7 @@ function render3DBindTexture(tileInfo, state=render3D, unit=0)
23337
23903
  // or anisotropy, and filtering it that way costs every untextured fragment
23338
23904
  else
23339
23905
  {
23340
- gl.bindSampler(unit, render3DSampler(textureInfo?.wrap, state.pixelated));
23906
+ gl.bindSampler(unit, render3DSampler(textureInfo?.wrap, state.pixelated, textureInfo?.pixelated ?? tilesPixelated));
23341
23907
  glUpdateMipmaps(texture); // drawn into since its mipmaps were made
23342
23908
  if (!state.pixelated && !glMipmappedTextures.has(texture)) // a hard edged draw never reads them
23343
23909
  {
@@ -23517,7 +24083,9 @@ function render3DSetDrawUniforms(matrix, tileInfo, tint, uvRect, state=render3D)
23517
24083
  // 0 blends them away instead, and -1 is additive, which has to fade into fog differently
23518
24084
  const blendMode = state.blend ? (state.additive ? -1 : 0) : 1;
23519
24085
  const receives = r.shadows && r.passIsDefault && state.receiveShadow ? 1 : 0;
23520
- render3DUniform4f('shadowParams', receives, r.shadowBias, r.shadowSoftness / r.shadowTextureSize, blendMode);
24086
+ // 1 the sun's shadow, 2 and up the shadow of that Light3D, the spotlight that casts them
24087
+ const shadowOf = receives && r.shadowLightIndex >= 0 ? 2 + r.shadowLightIndex : receives;
24088
+ render3DUniform4f('shadowParams', shadowOf, r.shadowDepthBias, r.shadowSoftness / r.shadowTextureSize, blendMode);
23521
24089
 
23522
24090
  // a render target's texture holds premultiplied color, the blend writes it that way, so the shader undoes it
23523
24091
  const textureInfo = render3DTextureOf(tileInfo);
@@ -23583,6 +24151,8 @@ function render3DRenderPass(after2D)
23583
24151
  // gathered before the gl state is taken over, so an assert here leaves nothing to hand back
23584
24152
  const lights = render3DCollectLights();
23585
24153
  r.lightCount = lights.length;
24154
+ const caster = render3DShadowCaster();
24155
+ r.shadowLightIndex = caster ? lights.indexOf(caster) : -1;
23586
24156
  const positions = r.lightPositions, colors = r.lightColors;
23587
24157
  lights.forEach((light, i)=>
23588
24158
  {
@@ -23593,6 +24163,7 @@ function render3DRenderPass(after2D)
23593
24163
  positions[k] = p.x, positions[k+1] = p.y, positions[k+2] = p.z;
23594
24164
  positions[k+3] = light.directional ? -1 : max(0, light.radius); // a negative radius marks a direction
23595
24165
  colors[k] = c.r, colors[k+1] = c.g, colors[k+2] = c.b, colors[k+3] = c.a * light.intensity;
24166
+ r.lightCones.set(render3DLightCone(light), k);
23596
24167
  });
23597
24168
 
23598
24169
  // take over the gl state
@@ -23664,6 +24235,7 @@ function render3DUpdateShadowMap(size)
23664
24235
  {
23665
24236
  const gl = glContext, r = render3D;
23666
24237
  false&&ASSERT(size > 0, 'shadowMapSize must be positive');
24238
+ size = glClampTextureSize(size); // a size the device does not have falls back to the largest it has
23667
24239
  if (r.shadowTexture && r.shadowTextureSize === size) return;
23668
24240
  r.shadowTexture && gl.deleteTexture(r.shadowTexture);
23669
24241
  r.shadowFramebuffer && gl.deleteFramebuffer(r.shadowFramebuffer);
@@ -25716,20 +26288,24 @@ function engineObjectsCallback3D(pos, size, callback, objects=engineObjects, tes
25716
26288
  * InstancedMesh3D - Many copies of one mesh drawn as one call, with their transforms kept on the GPU
25717
26289
  * - For big sets that mostly stay put: an instance costs nothing per frame until it changes, so a hundred thousand
25718
26290
  * trees cost what one tree does; objects and drawMesh batch by themselves too, but rebuild their batch every frame
25719
- * - setMatrixAt and setColorAt change one instance, and only the changed range uploads before the next draw
26291
+ * - setTransformAt, setMatrixAt and setColorAt change one instance, and only the changed range uploads before the
26292
+ * next draw; the matrices and the colors are kept apart, so moving instances uploads 64 bytes each
26293
+ * - For many instances moving every frame, setTransforms places a run of them from arrays of positions, rotations
26294
+ * and scales in one loop, the fastest way; or write matrixData directly and call markDirtyRange
25720
26295
  * - The instances are in world space; the object's own pos3D, rotation3D and scale3D do not move them
25721
- * - The whole set is culled by one bounding sphere around the origin, and casts and receives shadows like any object
26296
+ * - The whole set is culled by one bounding sphere around the origin, worked out when culling reads it, or set by hand
26297
+ * with radius; it casts and receives shadows like any object
25722
26298
  * - The object's flags cover the whole set, one emissive, one tileInfo, one shader; only the colors are per instance
25723
26299
  * - A mirrored instance, one with a negative scale, shows its inside unless the mesh is doubleSided
25724
26300
  * - A transparent set draws in one go in the transparent stage, its instances are not sorted against each other;
25725
26301
  * the set sorts against other transparent draws by the object's position, so put pos3D at its middle
25726
- * - pick, the raycast and the collect helpers do not see the instances, test them yourself from instanceData
26302
+ * - pick, the raycast and the collect helpers do not see the instances, test them yourself from matrixData
25727
26303
  * @extends EngineObject3D
25728
26304
  * @memberof Render3D
25729
26305
  * @example
25730
26306
  * const forest = new InstancedMesh3D(treeMesh, 1000);
25731
26307
  * for (let i = 0; i < 1000; ++i)
25732
- * forest.setMatrixAt(i, buildMatrix(randomGroundPos(), vec3(0, rand(2*PI), 0)));
26308
+ * forest.setTransformAt(i, randomGroundPos(), vec3(0, rand(2*PI), 0));
25733
26309
  */
25734
26310
  class InstancedMesh3D extends EngineObject3D
25735
26311
  {
@@ -25748,39 +26324,112 @@ class InstancedMesh3D extends EngineObject3D
25748
26324
  this.count = count;
25749
26325
  /** @property {number} - How many instances it was made with */
25750
26326
  this.maxCount = count;
25751
- /** @property {Float32Array} - The per instance values the shader reads, 24 floats each: the matrix, the color
25752
- * and the uv rect; edit it directly and call markDirty for the instances changed */
25753
- this.instanceData = new Float32Array(count * RENDER3D_INSTANCE_FLOATS);
25754
- /** @property {number} - Radius of the sphere around the origin that holds every instance set so far, for
25755
- * culling; from the farthest instance and the largest scale, and the mesh's size when it draws */
25756
- this.radius = 0;
25757
- this.reach = 0; // the farthest any instance's position has been from the origin
25758
- this.maxScale = 0; // and the largest scale any instance has had
25759
- /** @property {number} - First instance to upload before the next draw */
26327
+ /** @property {Float32Array} - Each instance's matrix, 16 floats, what moving them uploads; edit it directly and
26328
+ * call markDirty for the instances changed */
26329
+ this.matrixData = new Float32Array(count * RENDER3D_MATRIX_FLOATS);
26330
+ /** @property {Float32Array} - Each instance's color and uv rect, 8 floats, uploaded only when they change; edit
26331
+ * it directly and call markColorDirty for the instances changed */
26332
+ this.colorData = new Float32Array(count * RENDER3D_COLOR_FLOATS);
26333
+ /** @property {number} - First instance whose matrix uploads before the next draw */
25760
26334
  this.dirtyStart = 0;
25761
- /** @property {number} - One past the last instance to upload, so nothing uploads when it is not past dirtyStart */
26335
+ /** @property {number} - One past the last instance whose matrix uploads, nothing when it is not past dirtyStart */
25762
26336
  this.dirtyEnd = count;
25763
- this.buffer = undefined; // the GPU copy of instanceData
25764
- this.bufferGeneration = -1; // the context it was made under
25765
- this.uvTileInfo = tileInfo; // the tile the uv rects were written for
25766
- const uv = render3DGetTileUVs(tileInfo), data = this.instanceData;
26337
+ /** @property {number} - First instance whose color uploads before the next draw */
26338
+ this.colorDirtyStart = 0;
26339
+ /** @property {number} - One past the last instance whose color uploads */
26340
+ this.colorDirtyEnd = count;
26341
+ // the farthest any instance's position has been from the origin, and the largest scale any has had, both
26342
+ // squared, and the instances set since radius last took them in: setting one does no bounds work at all
26343
+ this.reachSquared = 0;
26344
+ this.scaleSquared = 0;
26345
+ this.boundsStart = 0;
26346
+ this.boundsEnd = count;
26347
+ /** @type {number|undefined} */
26348
+ this.fixedRadius = undefined; // a radius set by hand, see radius
26349
+ this.buffer = undefined; // the GPU copy of matrixData
26350
+ /** @type {WebGLBuffer|undefined} */
26351
+ this.colorBuffer = undefined; // and of colorData
26352
+ this.bufferGeneration = -1; // the context they were made under
26353
+ this.uvTileInfo = tileInfo; // the tile the uv rects were written for
26354
+ const uv = render3DGetTileUVs(tileInfo), matrices = this.matrixData, colors = this.colorData;
25767
26355
  for (let i = 0; i < count; ++i)
25768
26356
  {
25769
- this.setMatrixAt(i, RENDER3D_IDENTITY);
25770
- const k = i * RENDER3D_INSTANCE_FLOATS;
25771
- data[k+16] = color.r; data[k+17] = color.g; data[k+18] = color.b; data[k+19] = color.a;
25772
- data[k+20] = uv.x; data[k+21] = uv.y; data[k+22] = uv.w; data[k+23] = uv.h;
26357
+ matrices.set(RENDER3D_IDENTITY.m, i * RENDER3D_MATRIX_FLOATS);
26358
+ const k = i * RENDER3D_COLOR_FLOATS;
26359
+ colors[k] = color.r; colors[k+1] = color.g; colors[k+2] = color.b; colors[k+3] = color.a;
26360
+ colors[k+4] = uv.x; colors[k+5] = uv.y; colors[k+6] = uv.w; colors[k+7] = uv.h;
25773
26361
  }
25774
26362
  }
25775
26363
 
26364
+ /** Place an instance by its position, rotation and scale, what buildMatrix takes, written straight in with no
26365
+ * matrix made; in world space
26366
+ * @param {number} i
26367
+ * @param {Vector3} [pos]
26368
+ * @param {Vector3} [rotation] - Euler angles in radians
26369
+ * @param {Vector3} [scale] */
26370
+ setTransformAt(i, pos, rotation, scale)
26371
+ {
26372
+ false&&ASSERT(i >= 0 && i < this.maxCount, 'instance index out of range');
26373
+ matrix4Compose(this.matrixData, i * RENDER3D_MATRIX_FLOATS, pos, rotation, scale);
26374
+ this.markDirty(i);
26375
+ }
26376
+
26377
+ /** Place a run of instances from arrays indexed by instance, plain or typed, as a game keeps them: each is placed
26378
+ * as setTransformAt places it, all in one loop with nothing made, the fastest way to move many every frame;
26379
+ * in world space
26380
+ * @param {number} start - First instance
26381
+ * @param {number} count - How many
26382
+ * @param {ArrayLike<number>} x - Positions
26383
+ * @param {ArrayLike<number>} y
26384
+ * @param {ArrayLike<number>} z
26385
+ * @param {ArrayLike<number>} [rx] - Euler angles in radians, all three or none for upright
26386
+ * @param {ArrayLike<number>} [ry]
26387
+ * @param {ArrayLike<number>} [rz]
26388
+ * @param {ArrayLike<number>} [sx] - Scales, all three or none for 1
26389
+ * @param {ArrayLike<number>} [sy]
26390
+ * @param {ArrayLike<number>} [sz] */
26391
+ setTransforms(start, count, x, y, z, rx, ry, rz, sx, sy, sz)
26392
+ {
26393
+ false&&ASSERT(start >= 0 && count >= 0 && start + count <= this.maxCount, 'instance range out of range');
26394
+ false&&ASSERT(!rx === !ry && !rx === !rz && !sx === !sy && !sx === !sz, 'rotations and scales come in threes');
26395
+ const m = this.matrixData, end = start + count;
26396
+ for (let i = start; i < end; ++i)
26397
+ {
26398
+ // matrix4Compose's expressions, so a run placed here is the same as each placed by setTransformAt
26399
+ const k = i * RENDER3D_MATRIX_FLOATS;
26400
+ const scaleX = sx ? sx[i] : 1, scaleY = sy ? sy[i] : 1, scaleZ = sz ? sz[i] : 1;
26401
+ if (rx)
26402
+ {
26403
+ const cx = cos(rx[i]), sinX = sin(rx[i]);
26404
+ const cy = cos(ry[i]), sinY = sin(ry[i]);
26405
+ const cz = cos(rz[i]), sinZ = sin(rz[i]);
26406
+ m[k] = (cy*cz + sinY*sinX*sinZ) * scaleX; m[k+1] = cx*sinZ * scaleX;
26407
+ m[k+2] = (-sinY*cz + cy*sinX*sinZ) * scaleX;
26408
+ m[k+4] = (-cy*sinZ + sinY*sinX*cz) * scaleY; m[k+5] = cx*cz * scaleY;
26409
+ m[k+6] = (sinY*sinZ + cy*sinX*cz) * scaleY;
26410
+ m[k+8] = sinY*cx * scaleZ; m[k+9] = -sinX * scaleZ; m[k+10] = cy*cx * scaleZ;
26411
+ }
26412
+ else
26413
+ {
26414
+ m[k] = scaleX; m[k+1] = 0; m[k+2] = 0;
26415
+ m[k+4] = 0; m[k+5] = scaleY; m[k+6] = 0;
26416
+ m[k+8] = 0; m[k+9] = 0; m[k+10] = scaleZ;
26417
+ }
26418
+ m[k+3] = m[k+7] = m[k+11] = 0; m[k+15] = 1;
26419
+ m[k+12] = x[i], m[k+13] = y[i], m[k+14] = z[i];
26420
+ }
26421
+ count && this.markDirtyRange(start, end);
26422
+ }
26423
+
25776
26424
  /** Place an instance, in world space
25777
26425
  * @param {number} i
25778
26426
  * @param {Matrix4} matrix */
25779
26427
  setMatrixAt(i, matrix)
25780
26428
  {
25781
26429
  false&&ASSERT(i >= 0 && i < this.maxCount, 'instance index out of range');
25782
- const data = this.instanceData, k = i * RENDER3D_INSTANCE_FLOATS, m = matrix.m;
25783
- data.set(m, k);
26430
+ const data = this.matrixData, k = i * RENDER3D_MATRIX_FLOATS, m = matrix.m;
26431
+ for (let j = 0; j < 16; ++j)
26432
+ data[k+j] = m[j]; // for 16 floats a loop beats set's call
25784
26433
  this.markDirty(i);
25785
26434
  }
25786
26435
 
@@ -25790,8 +26439,8 @@ class InstancedMesh3D extends EngineObject3D
25790
26439
  getMatrixAt(i)
25791
26440
  {
25792
26441
  false&&ASSERT(i >= 0 && i < this.maxCount, 'instance index out of range');
25793
- const k = i * RENDER3D_INSTANCE_FLOATS, matrix = new Matrix4;
25794
- matrix.m.set(this.instanceData.subarray(k, k + 16));
26442
+ const k = i * RENDER3D_MATRIX_FLOATS, matrix = new Matrix4;
26443
+ matrix.m.set(this.matrixData.subarray(k, k + 16));
25795
26444
  return matrix;
25796
26445
  }
25797
26446
 
@@ -25802,26 +26451,71 @@ class InstancedMesh3D extends EngineObject3D
25802
26451
  {
25803
26452
  false&&ASSERT(i >= 0 && i < this.maxCount, 'instance index out of range');
25804
26453
  false&&ASSERT(isColor(color), 'color must be a Color');
25805
- const data = this.instanceData, k = i * RENDER3D_INSTANCE_FLOATS;
25806
- data[k+16] = color.r; data[k+17] = color.g; data[k+18] = color.b; data[k+19] = color.a;
25807
- this.markDirty(i);
26454
+ const data = this.colorData, k = i * RENDER3D_COLOR_FLOATS;
26455
+ data[k] = color.r; data[k+1] = color.g; data[k+2] = color.b; data[k+3] = color.a;
26456
+ this.markColorDirty(i);
25808
26457
  }
25809
26458
 
25810
- /** Note that an instance changed, so it uploads before the next draw and the bounds hold it; setMatrixAt and
25811
- * setColorAt call this, and so must an edit made straight to instanceData
26459
+ /** Note that an instance's matrix changed, so it uploads before the next draw and the bounds take it in when
26460
+ * they are read; setTransformAt and setMatrixAt call this, and so must an edit made straight to matrixData
25812
26461
  * @param {number} i */
25813
26462
  markDirty(i)
25814
26463
  {
25815
- this.dirtyStart = min(this.dirtyStart, i);
25816
- this.dirtyEnd = max(this.dirtyEnd, i + 1);
26464
+ if (i < this.dirtyStart) this.dirtyStart = i;
26465
+ if (i >= this.dirtyEnd) this.dirtyEnd = i + 1;
26466
+ if (i < this.boundsStart) this.boundsStart = i;
26467
+ if (i >= this.boundsEnd) this.boundsEnd = i + 1;
26468
+ }
26469
+
26470
+ /** Note that a run of instances' matrices changed, for code that writes matrixData directly, the lowest level
26471
+ * way to move many: they upload before the next draw and the bounds take them in when they are read
26472
+ * @param {number} start - First instance
26473
+ * @param {number} end - One past the last */
26474
+ markDirtyRange(start, end)
26475
+ {
26476
+ false&&ASSERT(start >= 0 && end <= this.maxCount && start <= end, 'instance range out of range');
26477
+ if (start < this.dirtyStart) this.dirtyStart = start;
26478
+ if (end > this.dirtyEnd) this.dirtyEnd = end;
26479
+ if (start < this.boundsStart) this.boundsStart = start;
26480
+ if (end > this.boundsEnd) this.boundsEnd = end;
26481
+ }
25817
26482
 
25818
- // the bounds grow to hold where it is now and how big, read back from the matrix it has
25819
- const d = this.instanceData, k = i * RENDER3D_INSTANCE_FLOATS;
25820
- this.reach = max(this.reach, hypot(d[k+12], d[k+13], d[k+14]));
25821
- this.maxScale = max(this.maxScale, render3DMaxStretch(d, k));
25822
- const meshRadius = this.mesh.radius || this.mesh.computeRadius(); // measured once, the draw takes a new size up
25823
- this.radius = this.reach + meshRadius * this.maxScale;
26483
+ /** Note that an instance's color changed, so it uploads before the next draw; setColorAt calls this, and so must
26484
+ * an edit made straight to colorData
26485
+ * @param {number} i */
26486
+ markColorDirty(i)
26487
+ {
26488
+ if (i < this.colorDirtyStart) this.colorDirtyStart = i;
26489
+ if (i >= this.colorDirtyEnd) this.colorDirtyEnd = i + 1;
26490
+ }
26491
+
26492
+ /** Radius of the sphere around the origin that holds every instance set so far, for culling: the farthest
26493
+ * instance, and the mesh's size at the largest scale; it only grows, and takes in the instances set since it
26494
+ * was last read, so with frustum culling off it is never worked out. Set it to fix the sphere, which a big set
26495
+ * that is culled can use to skip that work, and set it to undefined to go back to the bounds
26496
+ * @return {number} */
26497
+ get radius()
26498
+ {
26499
+ if (this.fixedRadius !== undefined) return this.fixedRadius;
26500
+ if (this.boundsEnd > this.boundsStart)
26501
+ {
26502
+ // the bounds grow to hold where each is now and how big, read back from its matrix, squared
26503
+ const d = this.matrixData;
26504
+ let reachSquared = this.reachSquared, scaleSquared = this.scaleSquared;
26505
+ for (let i = this.boundsStart; i < this.boundsEnd; ++i)
26506
+ {
26507
+ const k = i * RENDER3D_MATRIX_FLOATS, x = d[k+12], y = d[k+13], z = d[k+14];
26508
+ const reach = x*x + y*y + z*z, scale = render3DMaxStretchSquared(d, k);
26509
+ if (reach > reachSquared) reachSquared = reach;
26510
+ if (scale > scaleSquared) scaleSquared = scale;
26511
+ }
26512
+ this.reachSquared = reachSquared, this.scaleSquared = scaleSquared;
26513
+ this.boundsStart = Infinity, this.boundsEnd = 0;
26514
+ }
26515
+ const mesh = this.mesh, meshRadius = mesh.radius || mesh.computeRadius(); // the mesh measured once
26516
+ return this.reachSquared ** .5 + meshRadius * this.scaleSquared ** .5;
25824
26517
  }
26518
+ set radius(radius) { this.fixedRadius = radius; }
25825
26519
 
25826
26520
  /** Draws every instance as one call, uploading the ones that changed first
25827
26521
  * @return {void} */
@@ -25835,41 +26529,46 @@ class InstancedMesh3D extends EngineObject3D
25835
26529
  if (r.shadowPass && !r.lighting) return; // unlit things cast no shadow
25836
26530
  render3DMeshUpload(mesh);
25837
26531
  if (!mesh.bufferCount) return;
25838
- this.radius = this.reach + mesh.radius * this.maxScale; // the mesh may have grown since
25839
26532
  if (r.frustumCulling && !render3DSphereVisible(0, 0, 0, this.radius)) return;
25840
26533
 
25841
26534
  // the uv rect is the object's tile for every instance, rewritten when the tile changes
25842
26535
  if (this.uvTileInfo !== this.tileInfo)
25843
26536
  {
25844
- const uv = render3DGetTileUVs(this.tileInfo), data = this.instanceData;
25845
- for (let k = 20; k < data.length; k += RENDER3D_INSTANCE_FLOATS)
26537
+ const uv = render3DGetTileUVs(this.tileInfo), data = this.colorData;
26538
+ for (let k = 4; k < data.length; k += RENDER3D_COLOR_FLOATS)
25846
26539
  data[k] = uv.x, data[k+1] = uv.y, data[k+2] = uv.w, data[k+3] = uv.h;
25847
26540
  this.uvTileInfo = this.tileInfo;
25848
- this.dirtyStart = 0, this.dirtyEnd = this.maxCount;
26541
+ this.colorDirtyStart = 0, this.colorDirtyEnd = this.maxCount;
25849
26542
  }
25850
26543
 
25851
- // the GPU copy: all of it under a fresh context, otherwise just the changed range
26544
+ // the GPU copies: all of each under a fresh context, otherwise just the changed ranges
25852
26545
  if (!this.buffer || this.bufferGeneration !== r.contextGeneration)
25853
26546
  {
25854
- this.buffer = gl.createBuffer();
25855
26547
  this.bufferGeneration = r.contextGeneration;
25856
- gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
25857
- gl.bufferData(gl.ARRAY_BUFFER, this.instanceData, gl.DYNAMIC_DRAW);
26548
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer = gl.createBuffer());
26549
+ gl.bufferData(gl.ARRAY_BUFFER, this.matrixData, gl.DYNAMIC_DRAW);
26550
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.colorBuffer = gl.createBuffer());
26551
+ gl.bufferData(gl.ARRAY_BUFFER, this.colorData, gl.DYNAMIC_DRAW);
25858
26552
  }
25859
- else if (this.dirtyEnd > this.dirtyStart)
26553
+ else
25860
26554
  {
25861
- const start = this.dirtyStart * RENDER3D_INSTANCE_FLOATS, end = this.dirtyEnd * RENDER3D_INSTANCE_FLOATS;
25862
- gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
25863
- gl.bufferSubData(gl.ARRAY_BUFFER, start * 4, this.instanceData, start, end - start);
26555
+ const upload = (buffer, data, floats, start, end)=>
26556
+ {
26557
+ if (end <= start) return;
26558
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
26559
+ gl.bufferSubData(gl.ARRAY_BUFFER, start * floats * 4, data, start * floats, (end - start) * floats);
26560
+ };
26561
+ upload(this.buffer, this.matrixData, RENDER3D_MATRIX_FLOATS, this.dirtyStart, this.dirtyEnd);
26562
+ upload(this.colorBuffer, this.colorData, RENDER3D_COLOR_FLOATS, this.colorDirtyStart, this.colorDirtyEnd);
25864
26563
  }
25865
- this.dirtyStart = Infinity, this.dirtyEnd = 0;
26564
+ this.dirtyStart = this.colorDirtyStart = Infinity, this.dirtyEnd = this.colorDirtyEnd = 0;
25866
26565
 
25867
26566
  // one draw under the object's state, the mesh setting the culling as drawMesh does
25868
26567
  r.flush();
25869
26568
  render3DFlushBeforeOverlay(); // as in drawMesh, what was drawn before goes under it
25870
26569
  const cullBackFaces = r.cullBackFaces, tileInfo = this.tileInfo;
25871
26570
  r.cullBackFaces = !mesh.doubleSided;
25872
- render3DDrawInstanced(mesh, this.buffer, this.count, render3DTextureOf(tileInfo), r);
26571
+ render3DDrawInstanced(mesh, this.buffer, this.count, render3DTextureOf(tileInfo), r, this.colorBuffer);
25873
26572
  r.cullBackFaces = cullBackFaces;
25874
26573
  }
25875
26574
 
@@ -25878,8 +26577,8 @@ class InstancedMesh3D extends EngineObject3D
25878
26577
  destroy(immediate)
25879
26578
  {
25880
26579
  if (this.buffer && this.bufferGeneration === render3D?.contextGeneration)
25881
- glContext?.deleteBuffer(this.buffer);
25882
- this.buffer = undefined;
26580
+ glContext?.deleteBuffer(this.buffer), glContext?.deleteBuffer(this.colorBuffer);
26581
+ this.buffer = this.colorBuffer = undefined;
25883
26582
  super.destroy(immediate);
25884
26583
  }
25885
26584
  }
@@ -25888,7 +26587,10 @@ class InstancedMesh3D extends EngineObject3D
25888
26587
  /**
25889
26588
  * Light3D - A light that is an EngineObject3D, so it can move, follow a parent or be destroyed like anything else
25890
26589
  * - A point light: it lights what is near it and fades out by its radius, DirectionalLight3D shines from far away
25891
- * - Only the sun, render3D.sunDirection, casts shadows; these light and make highlights without one
26590
+ * - A coneAngle makes it a spotlight: it shines along its own forward, turned by rotation3D or by what it is
26591
+ * attached to, inside the cone, fading over the part of it coneSoftness says
26592
+ * - The sun, render3D.sunDirection, casts the shadows; a spotlight can cast them in its place, see
26593
+ * render3D.shadowLight; the other lights light and make highlights without one
25892
26594
  * - Only the 8 lights nearest the camera are used each frame
25893
26595
  * - radius is where the light fades out, and it fades fast, so a small radius wants a higher intensity
25894
26596
  * - intensity multiplies the color, above 1 for a light brighter than white
@@ -25920,6 +26622,12 @@ class Light3D extends EngineObject3D
25920
26622
  /** @property {boolean} - Shine from far away, from its position toward the origin, instead of out from its
25921
26623
  * position with a falloff; DirectionalLight3D sets it */
25922
26624
  this.directional = false;
26625
+ /** @property {number} - Makes it a spotlight: the angle in radians from its forward out to the edge of its
26626
+ * cone, so the beam is twice this across; 0 for a light that shines every way */
26627
+ this.coneAngle = 0;
26628
+ /** @property {number} - How much of the cone is its fading edge: 0 a hard edge, .2 by default, the outer
26629
+ * fifth, 1 fading all the way from the middle of the beam */
26630
+ this.coneSoftness = .2;
25923
26631
  /** @property {number} - Size of a soft hazy glow drawn over the light, like a lamp at night, 0 for none; it
25924
26632
  * is added onto what is behind it, and what is in front of the light hides it */
25925
26633
  this.glow = 0;
@@ -25938,37 +26646,10 @@ class Light3D extends EngineObject3D
25938
26646
  const toCamera = r.camera.pos.subtract(pos), distance = toCamera.length();
25939
26647
  const at = distance ? pos.add(toCamera.scale(min(this.glow, distance) / 2 / distance)) : pos;
25940
26648
  const color = rgb(c.r, c.g, c.b, c.a * min(this.intensity, 1));
25941
- r.drawBillboard(at, vec2(this.glow), render3DGlow(this.glowFalloff), color);
26649
+ r.drawBillboard(at, vec2(this.glow), engineGlowTexture(this.glowFalloff), color);
25942
26650
  }
25943
26651
  }
25944
26652
 
25945
- // 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
25946
- // and nothing at the edge, fading faster the higher the falloff
25947
- function render3DGlowAlpha(r, falloff)
25948
- {
25949
- const k = 3.5 * falloff, edge = Math.exp(-k);
25950
- return (Math.exp(-k * r * r) - edge) / (1 - edge);
25951
- }
25952
-
25953
- // the soft round glow of the lights, one texture for each falloff, rounded to a tenth so a changing falloff makes
25954
- // only a few, each made once from a canvas; undefined headless or without a canvas
25955
- const render3DGlowTextures = new Map;
25956
- function render3DGlow(falloff=1)
25957
- {
25958
- false&&ASSERT(isNumber(falloff) && falloff > 0, 'glowFalloff must be a number above 0');
25959
- const key = max(round(falloff * 10), 1) / 10;
25960
- let texture = render3DGlowTextures.get(key);
25961
- if (texture || !glContext || typeof OffscreenCanvas == 'undefined') return texture;
25962
- const size = 64, context = createCanvasContext(size), steps = 16;
25963
- const gradient = context.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
25964
- for (let i = 0; i <= steps; ++i)
25965
- gradient.addColorStop(i / steps, 'rgba(255,255,255,' + render3DGlowAlpha(i / steps, key).toFixed(4) + ')');
25966
- context.fillStyle = gradient;
25967
- context.fillRect(0, 0, size, size);
25968
- render3DGlowTextures.set(key, texture = new TextureInfo(context.canvas));
25969
- return texture;
25970
- }
25971
-
25972
26653
  ///////////////////////////////////////////////////////////////////////////////
25973
26654
  /**
25974
26655
  * DirectionalLight3D - A Light3D that shines from far away with no falloff, like sunlight
@@ -26902,7 +27583,7 @@ const render3DParticlesCalling = new Set;
26902
27583
  * @return {Particle3D} */
26903
27584
  function render3DParticleView(emitter)
26904
27585
  {
26905
- return {emitter, pos: vec3(), velocity: vec3(), age: 0, lifeTime: 0, destroyed: false,
27586
+ return {emitter, pos: vec3(), velocity: vec3(), age: 0, lifeTime: 0, scale: 1, destroyed: false,
26906
27587
  destroy() { this.destroyed = true; }};
26907
27588
  }
26908
27589
 
@@ -26915,6 +27596,8 @@ function render3DParticleView(emitter)
26915
27596
  * @property {Vector3} velocity - How far it moves each frame
26916
27597
  * @property {number} age - Seconds it has lived
26917
27598
  * @property {number} lifeTime - Seconds it lives
27599
+ * @property {number} scale - How much the emitter grows its effect, from its scale3D and its parents', as the 2D
27600
+ * particle's scale
26918
27601
  * @property {boolean} destroyed - Set by destroy
26919
27602
  * @property {function(): void} destroy - End it this update, the destroy callback gets it
26920
27603
  * @memberof Render3D
@@ -26944,8 +27627,9 @@ function render3DParticleView(emitter)
26944
27627
  * - emitConeAngle spreads them, PI sprays in every direction
26945
27628
  * - Speeds are per frame and sizes are world units, the same as the 2D emitter
26946
27629
  * - scale3D, its own or a parent's, grows the whole effect: the spawn area, the sizes, the speed and the fall
26947
- * - gravity here is its own number added to velocity y each frame: it is neither the engine's 2D
26948
- * gravity nor render3D.gravity, so an effect keeps its own fall wherever it is used
27630
+ * - gravity here is its own number added to velocity y each frame, so an effect keeps its own fall wherever it is
27631
+ * used, the same as the 2D emitter's gravity; gravityScale adds a share of render3D.gravity on top, as the 2D
27632
+ * emitter's gravityScale adds the engine's gravity
26949
27633
  * - An emitter with an emitTime destroys itself once its last particle is gone, like the 2D emitter
26950
27634
  * - Callbacks as the 2D emitter's: particleCreateCallback, particleUpdateCallback, particleCollideCallback and
26951
27635
  * particleDestroyCallback, each given a Particle3D, one object the emitter reuses for every particle and call
@@ -26975,8 +27659,8 @@ class ParticleEmitter3D extends EngineObject3D
26975
27659
  * @param {number} [sizeEnd] - Particle size at end of life
26976
27660
  * @param {number} [speed] - Spawn speed in world units per frame
26977
27661
  * @param {number} [damping] - Per frame velocity multiplier, 1 is none
26978
- * @param {number} [gravity] - Per frame change to velocity y, negative pulls down; its own number,
26979
- * not render3D.gravity, so the 2D emitter's gravityScale has no equivalent here
27662
+ * @param {number} [gravity] - Per frame change to velocity y, negative pulls down; its own number, and
27663
+ * gravityScale adds a share of render3D.gravity on top
26980
27664
  * @param {number} [fadeRate] - Fraction of life spent fading, half in and half out
26981
27665
  * @param {number} [randomness] - Extra randomness applied to speed, size and life
26982
27666
  * @param {boolean} [additive] - Additive blending */
@@ -26991,6 +27675,9 @@ class ParticleEmitter3D extends EngineObject3D
26991
27675
 
26992
27676
  /** @property {number|Vector3} - Spawn area, a number for a sphere diameter or a vec3 for a box */
26993
27677
  this.emitSize = emitSize;
27678
+ /** @property {boolean} - Flatten the spawn area across the way it emits, its own up: a sphere becomes a disc
27679
+ * and a box a flat rectangle, for rain from a sheet of sky or flames from a patch of ground */
27680
+ this.emitFlat = false;
26994
27681
  /** @property {number} - How long to keep emitting, 0 is forever */
26995
27682
  this.emitTime = emitTime;
26996
27683
  /** @property {number} - Particles per second, 0 does not emit */
@@ -27017,6 +27704,9 @@ class ParticleEmitter3D extends EngineObject3D
27017
27704
  this.damping = damping;
27018
27705
  /** @property {number} - Per frame change to velocity y, its own number and not render3D.gravity */
27019
27706
  this.gravity = gravity;
27707
+ /** @property {number} - Share of render3D.gravity added each frame on top of its own gravity, 0 by default so
27708
+ * an effect keeps its own fall wherever it is used; 1 falls with the world, like debris */
27709
+ this.gravityScale = 0;
27020
27710
  /** @property {number} - Fraction of life spent fading, half in and half out */
27021
27711
  this.fadeRate = fadeRate;
27022
27712
  /** @property {number} - Extra randomness applied to speed, size and life */
@@ -27072,6 +27762,7 @@ class ParticleEmitter3D extends EngineObject3D
27072
27762
  const matrix = render3DObjectMatrix(this); // the object's own, read only
27073
27763
  this.worldPos3D = matrix.getTranslation(); // remembered for when the parent is destroyed
27074
27764
  const scale = render3DMaxScale(matrix.m);
27765
+ this.particleView.scale = scale; // the callbacks read it from the view instead of working it out each time
27075
27766
 
27076
27767
  // emit until the emit time is up, then wait for the last particle and go away
27077
27768
  if (!this.emitTime || this.getAliveTime() <= this.emitTime)
@@ -27104,7 +27795,11 @@ class ParticleEmitter3D extends EngineObject3D
27104
27795
  const F = RENDER3D_PARTICLE_FLOATS;
27105
27796
  let data = this.particleData, trail = this.trailData;
27106
27797
  const reread = ()=> { data = this.particleData, trail = this.trailData; };
27107
- const damping = this.damping, gravity = this.gravity * scale, angleDamping = this.angleDamping; // a bigger effect has to fall faster to keep the same arc
27798
+ // its own fall and a share of the world's, grown by the scale: a bigger effect has to fall faster to keep the
27799
+ // same arc
27800
+ const damping = this.damping, angleDamping = this.angleDamping, g = render3D.gravity, share = this.gravityScale;
27801
+ const gravityX = g.x * share * scale, gravityY = (this.gravity + g.y * share) * scale;
27802
+ const gravityZ = g.z * share * scale;
27108
27803
  const collideLevel = this.collideLevel && render3DLevel.length;
27109
27804
  const updateCallback = this.particleUpdateCallback, destroyCallback = this.particleDestroyCallback;
27110
27805
  for (let i = this.particleCount; i--;)
@@ -27112,7 +27807,8 @@ class ParticleEmitter3D extends EngineObject3D
27112
27807
  // damped first and gravity added after, the order the 2D particle uses, so the same
27113
27808
  // damping and gravity give the same arc in both
27114
27809
  const k = i * F;
27115
- const vx = data[k+3] *= damping, vy = data[k+4] = data[k+4] * damping + gravity, vz = data[k+5] *= damping;
27810
+ const vx = data[k+3] = data[k+3] * damping + gravityX, vy = data[k+4] = data[k+4] * damping + gravityY;
27811
+ const vz = data[k+5] = data[k+5] * damping + gravityZ;
27116
27812
  data[k] += vx, data[k+1] += vy, data[k+2] += vz;
27117
27813
  data[k+18] += data[k+19] *= angleDamping;
27118
27814
  collideLevel && (this.particleCollide(k, data[k] - vx, data[k+1] - vy, data[k+2] - vz), reread());
@@ -27160,10 +27856,18 @@ class ParticleEmitter3D extends EngineObject3D
27160
27856
  // the whole effect grows with the emitter, not just the area the particles start in
27161
27857
  const scale = render3DMaxScale(matrix.m);
27162
27858
 
27163
- // spawn offset: inside a box or a sphere
27164
- const size = this.emitSize, box = /** @type {Vector3} */ (size);
27165
- const offset = isVector3(size) ? vec3(rand(-.5, .5) * box.x, rand(-.5, .5) * box.y, rand(-.5, .5) * box.z)
27166
- : randInSphere(/** @type {number} */ (size) / 2);
27859
+ // spawn offset: inside a box or a sphere, or flat across the emitter's up, a flat rectangle or a disc
27860
+ const size = this.emitSize, box = /** @type {Vector3} */ (size), flat = this.emitFlat;
27861
+ let offset;
27862
+ if (isVector3(size))
27863
+ offset = vec3(rand(-.5, .5) * box.x, flat ? 0 : rand(-.5, .5) * box.y, rand(-.5, .5) * box.z);
27864
+ else if (flat)
27865
+ {
27866
+ const disc = randInCircle(/** @type {number} */ (size) / 2);
27867
+ offset = vec3(disc.x, 0, disc.y);
27868
+ }
27869
+ else
27870
+ offset = randInSphere(/** @type {number} */ (size) / 2);
27167
27871
 
27168
27872
  // direction inside the cone around local +Y
27169
27873
  const direction = matrix.transformDirection(randVector3(1, this.emitConeAngle)).normalize();
@@ -27433,6 +28137,195 @@ class Trail3D extends EngineObject3D
27433
28137
  }
27434
28138
  }
27435
28139
 
28140
+ ///////////////////////////////////////////////////////////////////////////////
28141
+ // lens flare
28142
+
28143
+ // the shapes a flare is made of, white on clear on one smooth texture made the first time it is asked for: a soft
28144
+ // glow, a flat disc with a brighter rim, and a ring; undefined headless or without a canvas
28145
+ let render3DFlareTiles;
28146
+ function render3DFlareTile(shape)
28147
+ {
28148
+ if (!render3DFlareTiles)
28149
+ {
28150
+ if (headlessMode || !glContext || typeof OffscreenCanvas == 'undefined') return;
28151
+ const cell = 128, shapes = ['glow', 'disc', 'ring'], context = createCanvasContext(cell * shapes.length, cell);
28152
+ // each shape is how see-through it is from its middle out
28153
+ const alpha =
28154
+ {
28155
+ glow: (t)=> engineGlowAlpha(t, 1),
28156
+ disc: (t)=> t < .8 ? .4 + .1 * t : t < .92 ? .48 + (t - .8) * 3 : (1 - t) / .08 * .84,
28157
+ ring: (t)=> max(0, 1 - abs(t - .86) / .12),
28158
+ };
28159
+ shapes.forEach((shape, i)=>
28160
+ {
28161
+ const x = i * cell + cell / 2, y = cell / 2, r = cell / 2 - 2, steps = 32;
28162
+ const gradient = context.createRadialGradient(x, y, 0, x, y, r);
28163
+ for (let k = 0; k <= steps; ++k)
28164
+ gradient.addColorStop(k / steps, 'rgba(255,255,255,' + clamp(alpha[shape](k / steps)).toFixed(4) + ')');
28165
+ context.fillStyle = gradient;
28166
+ context.beginPath();
28167
+ context.arc(x, y, r, 0, 2 * PI);
28168
+ context.fill();
28169
+ });
28170
+ const texture = new TextureInfo(context.canvas, true, false, false); // smooth even in a pixel art game
28171
+ render3DFlareTiles = new Map(shapes.map((shape, i)=>
28172
+ [shape, new TileInfo(vec2(i * cell, 0), vec2(cell), texture)]));
28173
+ }
28174
+ return render3DFlareTiles.get(shape) || render3DFlareTiles.get('glow');
28175
+ }
28176
+
28177
+ /**
28178
+ * LensFlare3D - The sun's lens flare, the old kind: a glow at the sun and a row of discs and rings of different
28179
+ * sizes along the line from the sun through the middle of the screen
28180
+ * - Make one and it shows, over the 3D scene and under what the game draws after, a HUD; destroy it to take it away
28181
+ * - It follows render3D.sunDirection, and fades out as the sun leaves the screen or goes behind something
28182
+ * - flareSize, count, intensity and saturation set its look, seed picks another arrangement, and its color tints it,
28183
+ * with the sun's own color; or give it elements of your own
28184
+ * - visible is how much of the sun shows, 0 to 1, eased over fadeTime, there for a game to read
28185
+ * - What hides the sun is found with a ray from the camera, against the level and every object that is not see
28186
+ * through, each as the box around its mesh, see render3D.pick; turn it off with occlusion
28187
+ * - It needs WebGL, and it draws nothing in the shadow of renderAfter2D
28188
+ * @extends EngineObject3D
28189
+ * @memberof Render3D
28190
+ * @example
28191
+ * new LensFlare3D; // the sun flares
28192
+ * new LensFlare3D(1.5, 10, .7, 0); // bigger, 10 ghosts, dimmer, all one color
28193
+ */
28194
+ class LensFlare3D extends EngineObject3D
28195
+ {
28196
+ /** Create the sun's lens flare
28197
+ * @param {number} [size] - Scales every part of it, 1 by default
28198
+ * @param {number} [count] - How many ghosts there are along the line, besides the glow at the sun
28199
+ * @param {number} [intensity] - How bright it is
28200
+ * @param {number} [saturation] - How colorful the ghosts are, 0 for all the flare's own color, 1 a rainbow
28201
+ * @param {Color} [color] - Tints the whole flare, with the sun's color */
28202
+ constructor(size=1, count=7, intensity=1, saturation=1, color=WHITE)
28203
+ {
28204
+ super(vec3(), undefined, undefined, color);
28205
+ this.size3D = vec3(); // not a thing to pick or collect
28206
+ /** @property {number} - Scales every part of the flare */
28207
+ this.flareSize = size;
28208
+ /** @property {number} - How many ghosts there are along the line, besides the glow at the sun */
28209
+ this.count = count;
28210
+ /** @property {number} - How bright it is */
28211
+ this.intensity = intensity;
28212
+ /** @property {number} - How colorful the ghosts are, 0 for all the flare's own color, 1 a rainbow */
28213
+ this.saturation = saturation;
28214
+ /** @property {number} - Picks the arrangement of the ghosts, another seed is another flare */
28215
+ this.seed = 1;
28216
+ /** @property {Array<{at: number, size: number, color: Color, shape: string}>|undefined} - The parts of the
28217
+ * flare, to set your own in place of the ones made from count, seed and saturation: at is where along the
28218
+ * line, 0 the sun, 1 the middle of the screen, 2 as far past it; size is across, as a part of the screen's
28219
+ * height; shape is glow, disc or ring
28220
+ * @type {Array<{at: number, size: number, color: Color, shape: string}>|undefined} */
28221
+ this.elements = undefined;
28222
+ /** @property {boolean} - Fade out when something is between the camera and the sun */
28223
+ this.occlusion = true;
28224
+ /** @property {number} - Seconds the flare takes to fade out or in when the sun is hidden or shows again */
28225
+ this.fadeTime = .15;
28226
+ /** @property {number} - How much of the sun shows, 0 hidden or behind the camera to 1 in plain view, eased */
28227
+ this.visible = 1;
28228
+ this.renderOrder = 1e9; // over the game's sprites, it is light in the lens
28229
+ this.madeKey = '';
28230
+ /** @type {Array<{at: number, size: number, color: Color, shape: string}>} */
28231
+ this.made = [];
28232
+ }
28233
+
28234
+ /** The parts of the flare: the elements set by hand, or the ones made from count, seed and saturation, a glow
28235
+ * and a core at the sun and the ghosts, made again when one of those changes
28236
+ * @return {Array<{at: number, size: number, color: Color, shape: string}>} */
28237
+ getElements()
28238
+ {
28239
+ if (this.elements) return this.elements;
28240
+ const key = [this.count, this.seed, this.saturation].join();
28241
+ if (key !== this.madeKey)
28242
+ {
28243
+ const random = new RandomGenerator(this.seed * 7919 + 1), s = clamp(this.saturation);
28244
+ const made = this.made = [
28245
+ {at: 0, size: .7, color: hsl(0, 0, 1, .5), shape: 'glow'},
28246
+ {at: 0, size: .25, color: hsl(0, 0, 1, .9), shape: 'glow'}];
28247
+ for (let i = 0; i < this.count; ++i)
28248
+ {
28249
+ // spread along the line, each a place of its own, the far ones bigger
28250
+ const at = (i + random.float(.2, .8)) / max(this.count, 1) * 1.9 + .2;
28251
+ const pick = random.float(), shape = pick < .5 ? 'disc' : pick < .8 ? 'ring' : 'glow';
28252
+ made.push({at, size: random.float(.04, .1) * (1 + at), shape,
28253
+ color: hsl(random.float(), s * .9, .6, random.float(.15, .4))});
28254
+ }
28255
+ this.madeKey = key;
28256
+ }
28257
+ return this.made;
28258
+ }
28259
+
28260
+ /** Where the sun is on the screen, in pixels like mousePosScreen, undefined when it is behind the camera
28261
+ * @return {Vector2|undefined} */
28262
+ getSunScreenPos()
28263
+ {
28264
+ const direction = render3D.sunDirection;
28265
+ if (!direction.lengthSquared()) return;
28266
+ return render3D.worldToScreen(render3D.camera.pos.add(direction.normalize(100)));
28267
+ }
28268
+
28269
+ /** The parts of the flare as they are drawn now: each one's place on the screen, its size in pixels and its
28270
+ * color, dimmed by how much of the sun shows; empty when there is nothing to draw
28271
+ * @return {Array<{pos: Vector2, size: number, color: Color, shape: string}>} */
28272
+ getScreenElements()
28273
+ {
28274
+ const sun = this.getSunScreenPos(), center = mainCanvasSize.scale(.5);
28275
+ if (!sun || !center.x || !center.y) return [];
28276
+ // it fades as the sun leaves the screen, gone when it is a third of the screen past the edge
28277
+ const off = max(abs(sun.x - center.x) / center.x, abs(sun.y - center.y) / center.y);
28278
+ const strength = this.visible * clamp((1.3 - off) / .5) * this.intensity;
28279
+ if (!(strength > 0)) return [];
28280
+ const tint = this.color.multiply(render3D.sunColor), height = mainCanvasSize.y * this.flareSize;
28281
+ return this.getElements().map((e)=>
28282
+ {
28283
+ const c = e.color.multiply(tint);
28284
+ // along the line and past the middle, a lerp would stop there
28285
+ return {pos: sun.add(center.subtract(sun).scale(e.at)), size: e.size * height, shape: e.shape,
28286
+ color: rgb(c.r, c.g, c.b, c.a * strength)};
28287
+ });
28288
+ }
28289
+
28290
+ /** Is something between the camera and the sun: the level, or an object that is not see through
28291
+ * @return {boolean} */
28292
+ isSunHidden()
28293
+ {
28294
+ const direction = render3D.sunDirection;
28295
+ if (!direction.lengthSquared()) return true;
28296
+ const blockers = engineObjects.filter((o)=> o !== this && o instanceof EngineObject3D && !o.transparent &&
28297
+ !o.additive);
28298
+ return !!render3D.pick(new Ray3D(render3D.camera.pos, direction.normalize()), blockers);
28299
+ }
28300
+
28301
+ /** Ease visible toward whether the sun shows, called automatically each frame */
28302
+ update()
28303
+ {
28304
+ super.update();
28305
+ const shows = !!this.getSunScreenPos() && !(this.occlusion && this.isSunHidden());
28306
+ const step = this.fadeTime > 0 ? timeDelta / this.fadeTime : 1;
28307
+ this.visible = clamp(this.visible + (shows ? step : -step));
28308
+ }
28309
+
28310
+ /** Draw the flare over the 3D scene, added onto it, called automatically in the 2D pass */
28311
+ render()
28312
+ {
28313
+ if (headlessMode || !glEnable) return;
28314
+ const elements = this.getScreenElements();
28315
+ if (!elements.length) return;
28316
+ setAdditiveBlendMode(true);
28317
+ for (const e of elements)
28318
+ {
28319
+ const tileInfo = render3DFlareTile(e.shape);
28320
+ tileInfo && drawTile(e.pos, vec2(e.size), tileInfo, e.color, 0, false, undefined, true, true);
28321
+ }
28322
+ setAdditiveBlendMode(false);
28323
+ }
28324
+
28325
+ /** A flare has nothing to draw in the 3D pass */
28326
+ render3D() {}
28327
+ }
28328
+
27436
28329
  ///////////////////////////////////////////////////////////////////////////////
27437
28330
  // OBJ meshes
27438
28331
 
@@ -28527,6 +29420,16 @@ function level3DAddMesh(name, mesh, tileInfo, color=WHITE)
28527
29420
  * - What a file written by hand gets wrong uses the default: a value that is not of its default's type
28528
29421
  * - An object its type can not make is skipped with an error in debug builds, where asserts throw, and the rest
28529
29422
  * of the level is made
29423
+ * - A level may hold a map of blocks, in a voxels block: pos, its corner, size, its cells along x, y and z, and
29424
+ * blocks, runs of a count and a type along x, then y, then z; it is made a VoxelMap, the first of what is
29425
+ * returned, see level3DVoxelSetup for its sheet
29426
+ * - A level may hold a terrain, in a terrain block: pos, its center, size, its size in the world along x and z,
29427
+ * height, how tall a full height is, color, and heights, rows of 0 to 1 from -z to +z, each from -x to +x; it
29428
+ * is made a HeightMap, returned with what else was made
29429
+ * - A level may set the scene too, in a scene block beside its objects: sky, three colors for straight up, the
29430
+ * horizon and straight down, ambient, how much of them lights the scene, .5 when not given, sunDirection and
29431
+ * sunColor, fog, its start and end, fogColor, the horizon color when not given, and shadows; what the block
29432
+ * leaves out stays as the game set it, and a level with no block changes nothing
28530
29433
  * @param {Object} level - The level, the level editor edits this same object
28531
29434
  * @return {Array<any>} - What each object's type made, a function that made nothing is left out
28532
29435
  * @memberof Level3D */
@@ -28535,7 +29438,11 @@ function level3DLoad(level)
28535
29438
  false&&ASSERT(!!level && typeof level === 'object', 'a level is an object, {} for a new one');
28536
29439
  false&&ASSERT(!(level.littlejs3D > LEVEL3D_VERSION), 'the level was made by a newer LittleJS');
28537
29440
  editor3DLevelLoaded(level); // debug builds: the 3D editor takes the level, its autosaved edits go in first
28538
- const made = [];
29441
+ level3DSceneApply(level.scene);
29442
+ const made = [], map = level3DVoxelMap = level3DVoxelsMake(level.voxels);
29443
+ map && made.push(map);
29444
+ const terrain = level3DTerrainMap = level3DTerrainMake(level.terrain);
29445
+ terrain && made.push(terrain);
28539
29446
  for (const object of isArray(level.objects) ? level.objects : [])
28540
29447
  {
28541
29448
  if (!object || typeof object !== 'object') continue;
@@ -28546,6 +29453,147 @@ function level3DLoad(level)
28546
29453
  return made;
28547
29454
  }
28548
29455
 
29456
+ // the sheet a level's block map is made with, and what sets each one up, see level3DVoxelSetup; and the map the
29457
+ // level loaded last made, the one the 3D editor paints
29458
+ let level3DVoxelTiles, level3DVoxelSetupMap, level3DVoxelMap;
29459
+
29460
+ /** How a level's block map is made: the sheet its blocks show tiles of, and a function to set it up
29461
+ * - A level's voxels block makes a VoxelMap when the level loads, with texture 0 and the default tile size unless
29462
+ * a sheet is given here; a block's type shows that tile of the sheet on every face
29463
+ * - setup is called with each map a level makes, to give block types their own faces or make them see-through
29464
+ * - Call it before level3DLoad; with no arguments the defaults are back
29465
+ * @param {TileInfo} [tileInfo] - The sheet's first tile, as for a VoxelMap
29466
+ * @param {function(VoxelMap): void} [setup]
29467
+ * @example
29468
+ * level3DVoxelSetup(tile(0, 16, 1), (map)=> map.setBlockType(1, {top: 0, side: 1, bottom: 2})); // grass
29469
+ * @memberof Level3D */
29470
+ function level3DVoxelSetup(tileInfo, setup)
29471
+ {
29472
+ level3DVoxelTiles = tileInfo;
29473
+ level3DVoxelSetupMap = setup;
29474
+ }
29475
+
29476
+ // the most cells a level's map may have, 256 each way
29477
+ const LEVEL3D_VOXEL_CELLS = 2 ** 24;
29478
+
29479
+ // a map's blocks as the file keeps them, runs of a count and a type, cell by cell in the map's own order
29480
+ function level3DVoxelsEncode(data)
29481
+ {
29482
+ const blocks = [];
29483
+ for (let i = 0; i < data.length;)
29484
+ {
29485
+ let end = i + 1;
29486
+ while (end < data.length && data[end] === data[i]) ++end;
29487
+ blocks.push(end - i, data[i]);
29488
+ i = end;
29489
+ }
29490
+ return blocks;
29491
+ }
29492
+
29493
+ // runs into a map's cells: what they do not reach is empty, a type that is not 0 to 255 is empty, and a run that
29494
+ // is not two numbers ends them
29495
+ function level3DVoxelsDecode(blocks, data)
29496
+ {
29497
+ data.fill(0);
29498
+ if (!isArray(blocks)) return;
29499
+ for (let i = 0, cell = 0; i + 1 < blocks.length && cell < data.length; i += 2)
29500
+ {
29501
+ const count = blocks[i], type = blocks[i + 1];
29502
+ if (!isNumber(count) || !isNumber(type) || count < 0) return;
29503
+ const end = min(cell + floor(count), data.length);
29504
+ data.fill(type >= 0 && type <= 255 && type % 1 === 0 ? type : 0, cell, end);
29505
+ cell = end;
29506
+ }
29507
+ }
29508
+
29509
+ // the corner and the size in cells of a level's voxels block, undefined when it is not one a map can be made of
29510
+ function level3DVoxelsShape(voxels)
29511
+ {
29512
+ if (!voxels || typeof voxels !== 'object') return;
29513
+ const size = level3DVector(voxels.size);
29514
+ if (!size || [size.x, size.y, size.z].some((n)=> n < 1 || n % 1) ||
29515
+ size.x * size.y * size.z > LEVEL3D_VOXEL_CELLS) return;
29516
+ return {pos: level3DVector(voxels.pos, vec3()), size};
29517
+ }
29518
+
29519
+ // the VoxelMap of a level's voxels block, undefined when it has none or it is wrong
29520
+ function level3DVoxelsMake(voxels)
29521
+ {
29522
+ const shape = level3DVoxelsShape(voxels);
29523
+ if (!shape || typeof VoxelMap == 'undefined') return;
29524
+ const map = new VoxelMap(shape.pos, shape.size, level3DVoxelTiles || tile());
29525
+ level3DVoxelsDecode(voxels.blocks, map.data);
29526
+ level3DVoxelSetupMap?.(map);
29527
+ map.rebuild();
29528
+ return map;
29529
+ }
29530
+
29531
+ // the terrain the level loaded last made, the one the 3D editor sculpts, and the most samples it may have a side
29532
+ let level3DTerrainMap;
29533
+ const LEVEL3D_TERRAIN_SAMPLES = 129;
29534
+
29535
+ // what a level's terrain block is made with: its center, its size in the world along x and z, how tall a full
29536
+ // height is, and its heights as rows of 0 to 1, a copy, each row as long as the first; undefined when it is not
29537
+ // one a HeightMap can be made of
29538
+ function level3DTerrainShape(terrain)
29539
+ {
29540
+ if (!terrain || typeof terrain !== 'object') return;
29541
+ const size = terrain.size, rows = terrain.heights, n = LEVEL3D_TERRAIN_SAMPLES;
29542
+ if (!isArray(size) || size.length !== 2 || !size.every((v)=> isNumber(v) && v > 0)) return;
29543
+ if (!isArray(rows) || rows.length < 2 || rows.length > n || !isArray(rows[0])) return;
29544
+ const columns = rows[0].length;
29545
+ if (columns < 2 || columns > n || !rows.every((row)=> isArray(row) && row.length === columns)) return;
29546
+ const heights = rows.map((row)=> row.map((v)=> isNumber(v) ? clamp(v) : 0));
29547
+ return {pos: level3DVector(terrain.pos, vec3()), size: vec2(size[0], size[1]),
29548
+ height: isNumber(terrain.height) && terrain.height > 0 ? terrain.height : 1, heights};
29549
+ }
29550
+
29551
+ // the HeightMap of a level's terrain block, undefined when it has none or it is wrong
29552
+ function level3DTerrainMake(terrain)
29553
+ {
29554
+ const shape = level3DTerrainShape(terrain);
29555
+ if (!shape || typeof HeightMap == 'undefined') return;
29556
+ const map = new HeightMap(shape.heights, shape.size, shape.height, undefined, shape.pos);
29557
+ if (/^#([0-9a-f]{6}|[0-9a-f]{8})$/i.test(terrain.color))
29558
+ map.color = new Color().setHex(terrain.color);
29559
+ return map;
29560
+ }
29561
+
29562
+ // the sky domes levels made, a level's sky takes the place of the one before and disposes it; a dome the game made
29563
+ // itself is left whole, for the game to put back
29564
+ const level3DSkies = new WeakSet;
29565
+
29566
+ // set the scene a level's scene block gives: sky as its top, horizon and bottom colors, with ambient how much of
29567
+ // them lights the scene, sunDirection and sunColor, fog as its start and end with fogColor, the horizon without one,
29568
+ // and shadows; a setting the block does not have, or has wrong, stays as it is
29569
+ function level3DSceneApply(scene)
29570
+ {
29571
+ const r = render3D;
29572
+ if (!r || !scene || typeof scene !== 'object') return;
29573
+ const color = (value)=> /^#([0-9a-f]{6}|[0-9a-f]{8})$/i.test(value) ? new Color().setHex(value) : undefined;
29574
+ const sky = isArray(scene.sky) && scene.sky.length === 3 ? scene.sky.map(color) : [];
29575
+ if (sky.length && sky.every((c)=> c))
29576
+ {
29577
+ // as setSky does, without disposing a dome that is the game's own
29578
+ const [top, horizon, bottom] = sky, ambient = isNumber(scene.ambient) ? scene.ambient : .5;
29579
+ level3DSkies.has(r.sky) && r.sky.dispose();
29580
+ level3DSkies.add(r.sky = buildSky(top, horizon, bottom));
29581
+ r.fogColor = horizon.copy();
29582
+ r.ambientColor = top.scale(ambient, 1);
29583
+ r.ambientGroundColor = bottom.scale(ambient, 1);
29584
+ }
29585
+ const sun = level3DVector(scene.sunDirection);
29586
+ if (sun && sun.lengthSquared())
29587
+ r.sunDirection = sun;
29588
+ r.sunColor = color(scene.sunColor) || r.sunColor;
29589
+ const fog = scene.fog;
29590
+ if (isArray(fog) && fog.length === 2 && fog.every((v)=> isNumber(v)))
29591
+ r.fogStart = fog[0], r.fogEnd = fog[1];
29592
+ r.fogColor = color(scene.fogColor) || r.fogColor;
29593
+ if (typeof scene.shadows === 'boolean')
29594
+ r.shadows = scene.shadows;
29595
+ }
29596
+
28549
29597
  // a vec3 of an array of three numbers, as the file has them, or the fallback
28550
29598
  function level3DVector(value, fallback)
28551
29599
  {
@@ -28638,9 +29686,761 @@ level3DAddType('Sphere', function(pos, properties)
28638
29686
  level3DAddType('Cylinder', function(pos, properties)
28639
29687
  { return level3DMakeShape(pos, properties, level3DCylinderMesh ||= buildCylinder()); },
28640
29688
  {color: WHITE, tile: -1, solid: true});
29689
+ // a light, a spotlight with a cone: cone is the angle in degrees from its forward out to the edge of its beam, the
29690
+ // object's rotation aims it, softness is how much of the cone fades, and shadows makes it the one that casts them
28641
29691
  level3DAddType('Light', function(pos, properties)
28642
- { return new Light3D(pos, properties.radius, properties.color, properties.intensity); },
28643
- {color: WHITE, radius: 5, intensity: 1});
29692
+ {
29693
+ const light = new Light3D(pos, properties.radius, properties.color, properties.intensity);
29694
+ light.coneAngle = max(properties.cone, 0) * PI / 180;
29695
+ light.coneSoftness = properties.softness;
29696
+ if (properties.shadows && render3D)
29697
+ render3D.shadowLight = light;
29698
+ return light;
29699
+ }, {color: WHITE, radius: 5, intensity: 1, cone: 0, softness: .2, shadows: false});
29700
+
29701
+ /**
29702
+ * LittleJS Particle Effects Plugin
29703
+ * - Ready made particle effects in one line, particleEffect('fire', pos), and particleEffect3D for 3D
29704
+ * - Every built-in effect is tuned around a one unit emitter; options.scale grows it to fit, options.hue and
29705
+ * options.saturation recolor it
29706
+ * - Effects are plain data, {name, settings, behaviors}, the format the particle designer saves: load a library the
29707
+ * designer saved with particleEffectsLoad and play its effects by name
29708
+ * - The built-in effects draw with a sheet of shapes the plugin draws itself, so they need no image
29709
+ * @namespace ParticleEffects
29710
+ */
29711
+
29712
+ ///////////////////////////////////////////////////////////////////////////////
29713
+ // settings, what an effect sets on an emitter
29714
+
29715
+ /** The groups the settings are in, in order
29716
+ * @type {Array<string>}
29717
+ * @memberof ParticleEffects */
29718
+ const particleEffectGroups = ['Emitter', 'Particles', 'Color', 'Motion', 'Collision', 'Texture', 'Behaviors'];
29719
+
29720
+ /** Every setting an effect has: its name, kind (number, checkbox, color or shape), default, the range the designer's
29721
+ * slider covers (min, max, step), the hard limits a typed value is clamped to, and whether it is set after the
29722
+ * emitter is made rather than passed to its constructor (extra)
29723
+ * @type {Array<{group:string, name:string, kind:string, value:any, min:number, max:number, step:number,
29724
+ * description:string, hardMin:number, hardMax:number, extra:boolean}>}
29725
+ * @memberof ParticleEffects */
29726
+ const particleEffectSettings = [];
29727
+ let particleEffectSettingGroup; // the group settings are being added to
29728
+
29729
+ // the slider covers min to max, typed values may go out to hardMin and hardMax
29730
+ function particleEffectAddSetting(name, kind, value, min, max, step, description, hardMin=min, hardMax=max)
29731
+ {
29732
+ // extras are not constructor arguments, the code export assigns them after
29733
+ const extra = ['trailScale', 'velocityInheritance', 'localSpace', 'restitution', 'friction', 'gravity'].includes(name);
29734
+ particleEffectSettings.push({group:particleEffectSettingGroup, name, kind, value, min, max, step, description,
29735
+ hardMin, hardMax, extra});
29736
+ }
29737
+
29738
+ particleEffectSettingGroup = 'Emitter';
29739
+ particleEffectAddSetting('emitRate', 'number', 100, 0, 500, .1,
29740
+ 'Particles per second, 0 emits none', 0, 1e4);
29741
+ particleEffectAddSetting('emitTime', 'number', 0, 0, 5, .05,
29742
+ 'Seconds to emit for, 0 is forever', 0, 1e9);
29743
+ particleEffectAddSetting('emitSize', 'number', 0, 0, 10, .05,
29744
+ 'Diameter, or width when rectangular', 0, 1e9);
29745
+ particleEffectAddSetting('emitRect', 'checkbox', false, 0, 0, 0,
29746
+ 'Rectangle emitter, off is a circle');
29747
+ particleEffectAddSetting('emitHeight', 'number', 0, 0, 10, .05,
29748
+ 'Rectangle height', 0, 1e9);
29749
+ particleEffectAddSetting('emitConeAngle', 'number', PI, 0, PI, .01,
29750
+ 'Half angle particles move in, 3.14 is all ways');
29751
+ particleEffectAddSetting('angle', 'number', 0, -3.14, 3.14, .01,
29752
+ 'Emitter angle, 0 points up', -PI, PI); // the slider steps from its min
29753
+
29754
+ particleEffectSettingGroup = 'Particles';
29755
+ particleEffectAddSetting('particleTime', 'number', .5, 0, 5, .05,
29756
+ 'Seconds each particle lives', 0, 1e9);
29757
+ particleEffectAddSetting('sizeStart', 'number', .1, 0, 5, .01,
29758
+ 'Size at the start of life', 0, 1e9);
29759
+ particleEffectAddSetting('sizeEnd', 'number', 1, 0, 5, .01,
29760
+ 'Size at the end of life', 0, 1e9);
29761
+ particleEffectAddSetting('fadeRate', 'number', .1, 0, 1, .01,
29762
+ 'Share of life spent fading in and out');
29763
+ particleEffectAddSetting('randomness', 'number', .2, 0, 1, .01,
29764
+ 'How much each particle varies');
29765
+ particleEffectAddSetting('particleConeAngle', 'number', PI, 0, PI, .01,
29766
+ 'Half angle of start rotation, 3.14 is any');
29767
+ particleEffectAddSetting('trailScale', 'number', 0, 0, 20, .1,
29768
+ 'Stretch along the motion, 0 is off', 0, 1e9);
29769
+ particleEffectAddSetting('additive', 'checkbox', false, 0, 0, 0,
29770
+ 'Glow with additive blending');
29771
+
29772
+ particleEffectSettingGroup = 'Color';
29773
+ particleEffectAddSetting('colorStartA', 'color', [1,1,1,1], 0, 1, .01,
29774
+ 'Start color, each particle picks between A and B');
29775
+ particleEffectAddSetting('colorStartB', 'color', [1,1,1,1], 0, 1, .01,
29776
+ 'Second start color');
29777
+ particleEffectAddSetting('colorEndA', 'color', [1,1,1,0], 0, 1, .01,
29778
+ 'End color, each particle picks between A and B');
29779
+ particleEffectAddSetting('colorEndB', 'color', [1,1,1,0], 0, 1, .01,
29780
+ 'Second end color');
29781
+ particleEffectAddSetting('randomColorLinear', 'checkbox', true, 0, 0, 0,
29782
+ 'Pick along A to B, off picks each channel');
29783
+
29784
+ particleEffectSettingGroup = 'Motion';
29785
+ particleEffectAddSetting('speed', 'number', .1, 0, .5, .005,
29786
+ 'Start speed, world units per frame', 0, 1e9);
29787
+ particleEffectAddSetting('angleSpeed', 'number', .05, 0, .5, .005,
29788
+ 'Spin speed, radians per frame', 0, 1e9);
29789
+ particleEffectAddSetting('damping', 'number', 1, .8, 1, .001,
29790
+ 'Speed kept each frame, 1 keeps it all', 0, 1);
29791
+ particleEffectAddSetting('angleDamping', 'number', 1, .8, 1, .001,
29792
+ 'Spin kept each frame', 0, 1);
29793
+ particleEffectAddSetting('gravityScale', 'number', 0, -2, 2, .05,
29794
+ 'How much gravity pulls', -1e9, 1e9);
29795
+ particleEffectAddSetting('gravity', 'number', 0, -.02, .02, .0005,
29796
+ 'Its own fall each frame, the same in any game', -1, 1);
29797
+ particleEffectAddSetting('velocityInheritance', 'number', 0, 0, 1, .01,
29798
+ 'Share of emitter motion passed on');
29799
+ particleEffectAddSetting('localSpace', 'checkbox', false, 0, 0, 0,
29800
+ 'Particles move with the emitter');
29801
+
29802
+ particleEffectSettingGroup = 'Collision';
29803
+ particleEffectAddSetting('collideLevel', 'checkbox', false, 0, 0, 0,
29804
+ 'Hit tiles, the preview adds a floor');
29805
+ particleEffectAddSetting('restitution', 'number', 0, 0, 1, .01,
29806
+ 'Bounce when hitting tiles');
29807
+ particleEffectAddSetting('friction', 'number', .8, 0, 1, .01,
29808
+ 'Speed kept sliding along tiles');
29809
+
29810
+ particleEffectSettingGroup = 'Texture';
29811
+ particleEffectAddSetting('shape', 'shape', 'soft', 0, 0, 0,
29812
+ 'Built-in shape, none uses the tile');
29813
+ particleEffectAddSetting('tileIndex', 'number', -1, -1, 63, 1,
29814
+ 'Tile in texture 0 when no shape, -1 is untextured', -1, 1e4);
29815
+ particleEffectAddSetting('tileSize', 'number', 16, 1, 128, 1,
29816
+ 'Tile size in texture pixels', 1, 4096);
29817
+ particleEffectAddSetting('tilePadding', 'number', 1, 0, 8, 1,
29818
+ 'Pixels of padding around each tile', 0, 64);
29819
+
29820
+ /** The behaviors an effect can use, each a push applied to every particle every update, with a 2D and a 3D version
29821
+ * @type {Array<{name:string, update:(Function|undefined), update3D:(Function|undefined), min:number, max:number,
29822
+ * value:number, description:string}>}
29823
+ * @memberof ParticleEffects */
29824
+ const particleEffectBehaviors =
29825
+ [
29826
+ {name:'wobble', min:0, max:5, value:1, description:'Sway from side to side'},
29827
+ {name:'swirl', min:-2, max:2, value:1, description:'Turn the direction of travel'},
29828
+ {name:'turbulence', min:0, max:5, value:1, description:'Random pushes every frame'},
29829
+ {name:'attract', min:-2, max:2, value:1, description:'Pull toward the emitter, negative pushes away'},
29830
+ {name:'orbit', min:-2, max:2, value:1, description:'Push around the emitter, add attract to circle it'},
29831
+ {name:'wind', min:-2, max:2, value:1, description:'A sideways push that grows with age'},
29832
+ {name:'stick', min:0, max:1, value:1, description:'Grip on landing, pair with collideLevel'},
29833
+ ].map(b=> ({...b, update: undefined, update3D: undefined}));
29834
+ const particleEffectBehavior = (name)=> particleEffectBehaviors.find(b=> b.name === name);
29835
+
29836
+ /** Names of the shapes the plugin draws, for an effect's shape setting
29837
+ * @type {Array<string>}
29838
+ * @memberof ParticleEffects */
29839
+ const particleEffectShapes = ['dot', 'soft', 'glow', 'smoke', 'spark', 'square', 'triangle', 'ring', 'star', 'plus'];
29840
+
29841
+ ///////////////////////////////////////////////////////////////////////////////
29842
+ // effects
29843
+
29844
+ // a color from an [r,g,b,a] array or a Color, undefined if it is neither
29845
+ function particleEffectColor(value)
29846
+ {
29847
+ if (value instanceof Color)
29848
+ value = [value.r, value.g, value.b, value.a];
29849
+ if (isArray(value) && value.length === 4 && value.every(isNumber))
29850
+ return value.map(c=> clamp(c));
29851
+ }
29852
+
29853
+ /** Any input into a whole effect: missing fields take their defaults, bad ones are dropped or clamped
29854
+ * @param {Object} [raw] - {name, settings, behaviors}, from a library file, the designer or code
29855
+ * @return {{name:string, settings:Object, behaviors:Array<{name:string, strength:number}>}}
29856
+ * @memberof ParticleEffects */
29857
+ function particleEffectSanitize(raw)
29858
+ {
29859
+ // one line of text, line breaks and control characters made spaces
29860
+ const text = typeof raw?.name === 'string' ? raw.name.replace(/[\x00-\x1f\x7f]+/g, ' ').trim() : '';
29861
+ const name = text ? text.slice(0, 60) : 'Effect';
29862
+ const input = raw?.settings && typeof raw.settings === 'object' ? {...raw.settings} : {};
29863
+ input.collideLevel ??= input.collideTiles; // its name before 1.20
29864
+ // a library saved before shapes names its tile and no shape, it keeps its tile
29865
+ if (input.shape === undefined && isNumber(input.tileIndex))
29866
+ input.shape = '';
29867
+ const settings = {};
29868
+ for (const setting of particleEffectSettings)
29869
+ {
29870
+ const value = input[setting.name], fallback = setting.value;
29871
+ if (setting.kind === 'checkbox')
29872
+ settings[setting.name] = typeof value === 'boolean' ? value : value === 0 || value === 1 ? !!value : fallback;
29873
+ else if (setting.kind === 'color')
29874
+ settings[setting.name] = particleEffectColor(value) || fallback.slice();
29875
+ else if (setting.kind === 'shape')
29876
+ settings[setting.name] = value === '' || particleEffectShapes.includes(value) ? value : fallback;
29877
+ else if (isNumber(value))
29878
+ {
29879
+ const clamped = clamp(value, setting.hardMin, setting.hardMax);
29880
+ settings[setting.name] = setting.step >= 1 ? round(clamped) : clamped;
29881
+ }
29882
+ else
29883
+ settings[setting.name] = fallback;
29884
+ }
29885
+
29886
+ // local space particles are placed relative to the emitter, the tile collision is in the world
29887
+ if (settings.localSpace)
29888
+ settings.collideLevel = false;
29889
+
29890
+ // known behaviors once each, in table order
29891
+ const behaviors = [];
29892
+ const list = isArray(raw?.behaviors) ? raw.behaviors : [];
29893
+ for (const b of particleEffectBehaviors)
29894
+ {
29895
+ const found = list.find(x=> x?.name === b.name);
29896
+ if (!found)
29897
+ continue;
29898
+ const strength = isNumber(found.strength) ? clamp(found.strength, b.min, b.max) : b.value;
29899
+ behaviors.push({name:b.name, strength});
29900
+ }
29901
+
29902
+ // behaviors not known yet stay, a game may add them after the effect is loaded
29903
+ for (const x of list)
29904
+ if (typeof x?.name === 'string' && x.name && !particleEffectBehavior(x.name) &&
29905
+ !behaviors.some(b=> b.name === x.name))
29906
+ behaviors.push({name:x.name, strength: isNumber(x.strength) ? x.strength : 1});
29907
+ return {name, settings, behaviors};
29908
+ }
29909
+
29910
+ /** A copy of an effect with its four colors turned around the color wheel and their saturation scaled; lightness and
29911
+ * alpha stay, and grey and white have no hue to turn
29912
+ * @param {Object} effect
29913
+ * @param {number} [hue] - How far around the wheel, 1 is all the way
29914
+ * @param {number} [saturation] - Multiplies the saturation, 0 is grey, clamped to 1
29915
+ * @return {Object}
29916
+ * @memberof ParticleEffects */
29917
+ function particleEffectRecolor(effect, hue=0, saturation=1)
29918
+ {
29919
+ effect = particleEffectSanitize(effect); // colors as channels, whatever it was given
29920
+ const settings = {...effect.settings};
29921
+ for (const name of ['colorStartA', 'colorStartB', 'colorEndA', 'colorEndB'])
29922
+ {
29923
+ const [h, s, l, a] = new Color(...settings[name]).HSLA();
29924
+ const c = new Color().setHSLA(mod(h + hue), clamp(s * saturation), l, a);
29925
+ settings[name] = [c.r, c.g, c.b, c.a];
29926
+ }
29927
+ return {...effect, settings, behaviors: effect.behaviors.map(b=> ({...b}))};
29928
+ }
29929
+
29930
+ ///////////////////////////////////////////////////////////////////////////////
29931
+ // libraries
29932
+
29933
+ // the effects by lower case name, the built-ins and any a game adds or loads
29934
+ const particleEffectsByName = new Map;
29935
+
29936
+ /** Add effects to play by name, sanitized; one with the name of an effect already there replaces it
29937
+ * @param {Array<Object>|Object} effects
29938
+ * @memberof ParticleEffects */
29939
+ function particleEffectsAdd(effects)
29940
+ {
29941
+ for (const effect of isArray(effects) ? effects : [effects])
29942
+ {
29943
+ const clean = particleEffectSanitize(effect);
29944
+ particleEffectsByName.set(clean.name.toLowerCase(), clean);
29945
+ }
29946
+ }
29947
+
29948
+ /** The effect with a name, any case, undefined if there is none
29949
+ * @param {string} name
29950
+ * @return {Object|undefined}
29951
+ * @memberof ParticleEffects */
29952
+ function particleEffectsGet(name) { return particleEffectsByName.get(String(name).toLowerCase()); }
29953
+
29954
+ /** A library file, or one effect, into sanitized effects; throws on bad input
29955
+ * @param {string} text
29956
+ * @return {Array<Object>}
29957
+ * @memberof ParticleEffects */
29958
+ function particleEffectsParse(text)
29959
+ {
29960
+ const data = JSON.parse(text);
29961
+ const list = isArray(data?.effects) ? data.effects : data?.settings ? [data] : undefined;
29962
+ if (!list || !list.length)
29963
+ throw new Error('no effects in this file');
29964
+ return list.map(particleEffectSanitize);
29965
+ }
29966
+
29967
+ /** The text of a library file with these effects, what particleEffectsLoad and the designer read
29968
+ * @param {Array<Object>} effects
29969
+ * @return {string}
29970
+ * @memberof ParticleEffects */
29971
+ function particleEffectsText(effects) { return JSON.stringify({version:1, effects}, undefined, 1); }
29972
+
29973
+ /** Load a library file the particle designer saved and add its effects
29974
+ * @param {string} url
29975
+ * @return {Promise<Array<Object>>} - The effects it had
29976
+ * @memberof ParticleEffects */
29977
+ async function particleEffectsLoad(url)
29978
+ {
29979
+ const effects = particleEffectsParse(JSON.stringify(await fetchJSON(url)));
29980
+ particleEffectsAdd(effects);
29981
+ return effects;
29982
+ }
29983
+
29984
+ ///////////////////////////////////////////////////////////////////////////////
29985
+ // the shape sheet: every shape white on clear in a cell of one canvas, drawn the first time one is asked for
29986
+
29987
+ let particleEffectShapeTiles;
29988
+
29989
+ /** The tile of a built-in shape, on a sheet the plugin draws once; undefined headless, without a canvas or without
29990
+ * WebGL, where an effect draws untextured squares
29991
+ * @param {string} name - One of particleEffectShapes
29992
+ * @return {TileInfo|undefined}
29993
+ * @memberof ParticleEffects */
29994
+ function particleEffectShapeTile(name)
29995
+ {
29996
+ if (!particleEffectShapeTiles)
29997
+ {
29998
+ if (headlessMode || !glContext || typeof OffscreenCanvas == 'undefined') return;
29999
+ // drawn at 4 times 32 pixel cells, so a big soft shape stays smooth when textures are pixelated
30000
+ const cell = 32, r = 15, count = particleEffectShapes.length, res = 4;
30001
+ const context = createCanvasContext(cell * count * res, cell * res);
30002
+ context.scale(res, res);
30003
+ context.fillStyle = context.strokeStyle = '#fff';
30004
+ const soft = (x, y, radius, alpha=(t)=> 1 - t)=>
30005
+ {
30006
+ // a round falloff from the middle out
30007
+ const g = context.createRadialGradient(x, y, 0, x, y, radius);
30008
+ for (let i = 0; i <= 8; ++i)
30009
+ g.addColorStop(i/8, 'rgba(255,255,255,' + alpha(i/8).toFixed(3) + ')');
30010
+ context.fillStyle = g;
30011
+ context.beginPath(); context.arc(x, y, radius, 0, 2*PI); context.fill();
30012
+ context.fillStyle = '#fff';
30013
+ };
30014
+ const draw =
30015
+ {
30016
+ dot: (x, y)=> { context.beginPath(); context.arc(x, y, r, 0, 2*PI); context.fill(); },
30017
+ soft: (x, y)=> soft(x, y, r),
30018
+ glow: (x, y)=> soft(x, y, r, (t)=> engineGlowAlpha(t, 1)),
30019
+ smoke: (x, y)=>
30020
+ {
30021
+ // a lumpy puff of soft circles
30022
+ for (const [dx, dy, dr] of [[-4,-3,9], [4,-2,8], [0,4,9], [-5,5,6], [6,5,6]])
30023
+ soft(x + dx, y + dy, dr, (t)=> (1 - t) * .7);
30024
+ },
30025
+ spark: (x, y)=>
30026
+ {
30027
+ // a glow squeezed thin, a streak along the tile's up
30028
+ context.save(); context.translate(x, y); context.scale(.3, 1);
30029
+ soft(0, 0, r, (t)=> engineGlowAlpha(t, .6));
30030
+ context.restore();
30031
+ },
30032
+ square: (x, y)=> context.fillRect(x - r, y - r, 2*r, 2*r),
30033
+ triangle: (x, y)=>
30034
+ {
30035
+ context.beginPath(); context.moveTo(x, y - r); context.lineTo(x + r, y + r); context.lineTo(x - r, y + r);
30036
+ context.fill();
30037
+ },
30038
+ ring: (x, y)=>
30039
+ {
30040
+ context.lineWidth = 3;
30041
+ context.beginPath(); context.arc(x, y, r - 2, 0, 2*PI); context.stroke();
30042
+ },
30043
+ star: (x, y)=>
30044
+ {
30045
+ context.beginPath();
30046
+ for (let i = 0; i < 10; ++i)
30047
+ {
30048
+ const a = i * PI / 5 - PI/2, d = i % 2 ? r * .45 : r;
30049
+ context.lineTo(x + cos(a) * d, y + sin(a) * d);
30050
+ }
30051
+ context.fill();
30052
+ },
30053
+ plus: (x, y)=> { context.fillRect(x - r, y - 4, 2*r, 8); context.fillRect(x - 4, y - r, 8, 2*r); },
30054
+ };
30055
+ particleEffectShapes.forEach((name, i)=> draw[name](i * cell + cell/2, cell/2));
30056
+ const texture = new TextureInfo(context.canvas, true, false, false); // smooth even in a pixel art game
30057
+ particleEffectShapeTiles = new Map(particleEffectShapes.map((name, i)=>
30058
+ [name, new TileInfo(vec2((i * cell + 1) * res, res), vec2((cell - 2) * res), texture)]));
30059
+ }
30060
+ return particleEffectShapeTiles.get(name);
30061
+ }
30062
+
30063
+ ///////////////////////////////////////////////////////////////////////////////
30064
+ // behaviors, the 2D pushes: a distance moved grows with the particle's scale, so a bigger effect moves the same way
30065
+
30066
+ {
30067
+ const b = particleEffectBehavior;
30068
+ b('wobble').update = (p, s)=>
30069
+ {
30070
+ // each particle on its own beat, from its randomized life, so nothing is added to it
30071
+ p.velocity.x += s * .003 * p.scale * sin(time*6 + p.lifeTime * 97 % 9);
30072
+ };
30073
+ b('swirl').update = (p, s)=>
30074
+ {
30075
+ // turn the direction of travel a little each frame
30076
+ const a = s * .05, c = cos(a), n = sin(a), v = p.velocity;
30077
+ v.set(v.x*c - v.y*n, v.x*n + v.y*c);
30078
+ };
30079
+ b('turbulence').update = (p, s)=>
30080
+ {
30081
+ p.velocity.x += rand(-1, 1) * s * .005 * p.scale;
30082
+ p.velocity.y += rand(-1, 1) * s * .005 * p.scale;
30083
+ };
30084
+ b('attract').update = (p, s)=>
30085
+ {
30086
+ // pull toward the emitter by the distance, which grows with the scale already
30087
+ const e = p.emitter, x = e.localSpace ? 0 : e.pos.x, y = e.localSpace ? 0 : e.pos.y;
30088
+ p.velocity.x += (x - p.pos.x) * s * .002;
30089
+ p.velocity.y += (y - p.pos.y) * s * .002;
30090
+ };
30091
+ b('orbit').update = (p, s)=>
30092
+ {
30093
+ const e = p.emitter, x = e.localSpace ? 0 : e.pos.x, y = e.localSpace ? 0 : e.pos.y;
30094
+ p.velocity.x -= (p.pos.y - y) * s * .002;
30095
+ p.velocity.y += (p.pos.x - x) * s * .002;
30096
+ };
30097
+ b('wind').update = (p, s)=>
30098
+ p.velocity.x += s * .004 * p.scale * min((time - p.spawnTime) / p.lifeTime, 1);
30099
+ b('stick').update = (p, s)=>
30100
+ {
30101
+ // grip the ground on landing, pair with collideLevel
30102
+ if (!p.groundObject) return;
30103
+ p.velocity.x *= 1 - s;
30104
+ p.angleVelocity *= 1 - s;
30105
+ };
30106
+ }
30107
+
30108
+ /** Add a behavior effects can use by name, or replace one
30109
+ * @param {string} name
30110
+ * @param {function(Particle, number): void} update - Pushes a 2D particle, given the strength
30111
+ * @param {function(Particle3D, number): void} [update3D] - The same for a 3D particle, none leaves 3D alone
30112
+ * @param {number} [min] - Strength range the designer offers
30113
+ * @param {number} [max]
30114
+ * @param {number} [value] - Strength when first added
30115
+ * @param {string} [description]
30116
+ * @memberof ParticleEffects */
30117
+ function particleEffectsAddBehavior(name, update, update3D, min=-2, max=2, value=1, description='')
30118
+ {
30119
+ const old = particleEffectBehavior(name);
30120
+ old && particleEffectBehaviors.splice(particleEffectBehaviors.indexOf(old), 1);
30121
+ particleEffectBehaviors.push({name, update, update3D, min, max, value, description});
30122
+ }
30123
+
30124
+ // the update callback that runs an effect's behaviors, in 2D or 3D, undefined when it has none for that
30125
+ function particleEffectUpdateCallback(behaviors, is3D)
30126
+ {
30127
+ const calls = behaviors.map(b=> [particleEffectBehavior(b.name)?.[is3D ? 'update3D' : 'update'], b.strength])
30128
+ .filter(c=> c[0]);
30129
+ if (calls.length)
30130
+ return (p)=> { for (const [update, strength] of calls) update(p, strength); };
30131
+ }
30132
+
30133
+ ///////////////////////////////////////////////////////////////////////////////
30134
+ // 2D effects
30135
+
30136
+ // the settings the builders set themselves instead of copying across
30137
+ const particleEffectIndirect = ['emitSize', 'emitRect', 'emitHeight', 'tileIndex', 'tileSize', 'tilePadding', 'shape'];
30138
+
30139
+ // the tile an effect draws with: its shape, else its tile in texture 0 when that fits, else none
30140
+ function particleEffectTileInfo(s)
30141
+ {
30142
+ if (s.shape)
30143
+ return particleEffectShapeTile(s.shape);
30144
+ if (s.tileIndex < 0) return;
30145
+ const texture = textureInfos[0], cell = s.tileSize + s.tilePadding*2;
30146
+ if (!texture || !texture.size.x) return;
30147
+ if (s.tileIndex < floor(texture.size.x / cell) * floor(texture.size.y / cell))
30148
+ return tile(s.tileIndex, s.tileSize, 0, s.tilePadding);
30149
+ }
30150
+
30151
+ /** Set a 2D emitter to an effect, live, so a running one keeps its particles
30152
+ * @param {ParticleEmitter} emitter
30153
+ * @param {Object} effect
30154
+ * @memberof ParticleEffects */
30155
+ function particleEffectApply(emitter, effect)
30156
+ {
30157
+ effect = particleEffectSanitize(effect); // an effect written by hand or changed since it was added
30158
+ const s = effect.settings, ended = emitter.emitTime < 0; // destroyed, its last particles going
30159
+ for (const setting of particleEffectSettings)
30160
+ if (!particleEffectIndirect.includes(setting.name))
30161
+ emitter[setting.name] = setting.kind === 'color' ? new Color(...s[setting.name]) : s[setting.name];
30162
+ ended && (emitter.emitTime = -1);
30163
+ emitter.emitCircle = !s.emitRect;
30164
+ emitter.emitSize = vec2(s.emitSize, s.emitRect ? s.emitHeight : s.emitSize);
30165
+ emitter.tileInfo = particleEffectTileInfo(s);
30166
+ emitter.renderOrder = s.additive ? 1e9 : 0;
30167
+ emitter.particleUpdateCallback = particleEffectUpdateCallback(effect.behaviors);
30168
+ }
30169
+
30170
+ // an effect from a name or an effect, recolored by the options
30171
+ function particleEffectResolve(nameOrEffect, options)
30172
+ {
30173
+ const found = typeof nameOrEffect == 'string' ? particleEffectsGet(nameOrEffect) : nameOrEffect;
30174
+ false&&ASSERT(!!found, 'no particle effect named ' + nameOrEffect);
30175
+ if (!found) return;
30176
+ // any setting in the options replaces the effect's own for this play, emitTime for a burst or a loop
30177
+ const settings = {...found.settings};
30178
+ for (const setting of particleEffectSettings)
30179
+ if (options[setting.name] !== undefined)
30180
+ settings[setting.name] = options[setting.name];
30181
+ // sanitized each time, an effect may be written by hand or changed after it was added
30182
+ const effect = particleEffectSanitize({...found, settings}), {hue=0, saturation=1} = options;
30183
+ return hue || saturation != 1 ? particleEffectRecolor(effect, hue, saturation) : effect;
30184
+ }
30185
+
30186
+ /** Play an effect: a 2D emitter set to it, placed, scaled and recolored
30187
+ * - A continuous effect (fire, a torch) goes until destroyed or given an emitTime, a one-shot ends itself
30188
+ * - Any setting in the options replaces the effect's own for this play: {emitTime: .5} for a burst of a continuous
30189
+ * effect, {emitTime: 0, emitRate: 30} to keep a one-shot going, or speed, particleTime and the rest
30190
+ * - Attach it to an object with addChild to follow it
30191
+ * @param {string|Object} nameOrEffect - A built-in or added effect's name, or an effect
30192
+ * @param {Vector2} [pos]
30193
+ * @param {Object} [options]
30194
+ * @param {number} [options.scale] - Grows the whole effect, the built-ins fit a one unit object at 1
30195
+ * @param {number} [options.hue] - Turns its colors around the color wheel, 1 is all the way
30196
+ * @param {number} [options.saturation] - Multiplies its saturation, 0 is grey
30197
+ * @param {number} [options.angle] - Direction, 0 is up; the effect's own angle when not given
30198
+ * @param {TileInfo|TextureInfo} [options.tileInfo] - The game's own art to draw with in place of the effect's shape,
30199
+ * tinted by its colors; a whole texture draws as one tile
30200
+ * @param {*} [options.settings] - Any effect setting by its name, emitTime, emitRate, speed and the rest, replacing
30201
+ * the effect's own for this play
30202
+ * @return {ParticleEmitter|undefined} - undefined when there is no such effect
30203
+ * @memberof ParticleEffects */
30204
+ function particleEffect(nameOrEffect, pos=vec2(), options={})
30205
+ {
30206
+ const effect = particleEffectResolve(nameOrEffect, options);
30207
+ if (!effect) return;
30208
+ const emitter = new ParticleEmitter(pos.copy());
30209
+ particleEffectApply(emitter, effect);
30210
+ emitter.scale = options.scale ?? 1;
30211
+ if (options.angle !== undefined)
30212
+ emitter.angle = options.angle;
30213
+ const tileInfo = options.tileInfo; // the game's own art in place of the shape, a whole texture as one tile
30214
+ if (tileInfo)
30215
+ emitter.tileInfo = tileInfo instanceof TextureInfo ? new TileInfo(vec2(), tileInfo.size, tileInfo) : tileInfo;
30216
+ return emitter;
30217
+ }
30218
+
30219
+ /** An effect with a 2D emitter's settings, to save, build again, or build in 3D with particleEffect3D; its tile is
30220
+ * left out, since a hand made emitter's tile is its own texture and not one an effect can name, and so is its
30221
+ * scale, which an effect does not keep: pass it again with options.scale
30222
+ * @param {ParticleEmitter} emitter
30223
+ * @param {string} [name]
30224
+ * @return {Object}
30225
+ * @memberof ParticleEffects */
30226
+ function particleEffectFromEmitter(emitter, name='Effect')
30227
+ {
30228
+ const settings = {};
30229
+ for (const setting of particleEffectSettings)
30230
+ if (!particleEffectIndirect.includes(setting.name) && emitter[setting.name] !== undefined)
30231
+ settings[setting.name] = emitter[setting.name]; // a Color is taken as its channels by the sanitizer
30232
+ settings.emitRect = !emitter.emitCircle;
30233
+ settings.emitSize = emitter.emitSize.x;
30234
+ settings.emitHeight = emitter.emitSize.y;
30235
+ settings.shape = '';
30236
+ settings.tileIndex = -1;
30237
+ return particleEffectSanitize({name, settings});
30238
+ }
30239
+
30240
+ ///////////////////////////////////////////////////////////////////////////////
30241
+ // 3D effects
30242
+
30243
+ // the 3D pushes: a 3D particle is one view object the emitter reuses, so what differs per particle comes from its
30244
+ // randomized lifetime, and distances grow with the emitter's scale
30245
+ {
30246
+ const b = particleEffectBehavior;
30247
+ const center = (p)=> p.emitter.worldPos3D || p.emitter.pos3D; // where it was this update
30248
+ b('wobble').update3D = (p, s)=> p.velocity.x += s * .003 * p.scale * sin(time*6 + p.lifeTime * 97 % 9);
30249
+ b('swirl').update3D = (p, s)=>
30250
+ {
30251
+ const a = s * .05, c = cos(a), n = sin(a), v = p.velocity;
30252
+ v.set(v.x*c - v.y*n, v.x*n + v.y*c, v.z);
30253
+ };
30254
+ b('turbulence').update3D = (p, s)=>
30255
+ {
30256
+ const k = s * .005 * p.scale;
30257
+ p.velocity.x += rand(-1, 1) * k, p.velocity.y += rand(-1, 1) * k, p.velocity.z += rand(-1, 1) * k;
30258
+ };
30259
+ b('attract').update3D = (p, s)=>
30260
+ {
30261
+ const c = center(p);
30262
+ p.velocity.x += (c.x - p.pos.x) * s * .002;
30263
+ p.velocity.y += (c.y - p.pos.y) * s * .002;
30264
+ p.velocity.z += (c.z - p.pos.z) * s * .002;
30265
+ };
30266
+ b('orbit').update3D = (p, s)=>
30267
+ {
30268
+ const c = center(p);
30269
+ p.velocity.x -= (p.pos.y - c.y) * s * .002;
30270
+ p.velocity.y += (p.pos.x - c.x) * s * .002;
30271
+ };
30272
+ b('wind').update3D = (p, s)=> p.velocity.x += s * .004 * p.scale * min(p.age / p.lifeTime, 1);
30273
+ // stick has no 3D push: particleEffect3D makes it the friction a particle lands with
30274
+ }
30275
+
30276
+ /** Set a 3D emitter to an effect, live, so a running one keeps its particles; its place, scale and flatten stay
30277
+ * @param {ParticleEmitter3D} emitter
30278
+ * @param {Object} effect
30279
+ * @memberof ParticleEffects */
30280
+ function particleEffectApply3D(emitter, effect)
30281
+ {
30282
+ effect = particleEffectSanitize(effect); // an effect written by hand or changed since it was added
30283
+ const s = effect.settings, e = emitter;
30284
+ // a circle is a sphere and a rectangle a box as deep as it is wide
30285
+ e.emitSize = s.emitRect ? vec3(s.emitSize, s.emitHeight, s.emitSize) : s.emitSize;
30286
+ e.emitTime = e.emitTime < 0 ? -1 : s.emitTime; // destroyed, its last particles going, stays so
30287
+ e.emitRate = s.emitRate, e.emitConeAngle = s.emitConeAngle;
30288
+ e.tileInfo = particleEffectTileInfo(s);
30289
+ for (const name of ['colorStartA', 'colorStartB', 'colorEndA', 'colorEndB'])
30290
+ e[name] = new Color(...s[name]);
30291
+ for (const name of ['particleTime', 'sizeStart', 'sizeEnd', 'speed', 'damping', 'gravity', 'fadeRate',
30292
+ 'randomness', 'additive', 'gravityScale', 'angleSpeed', 'angleDamping', 'collideLevel', 'restitution'])
30293
+ e[name] = s[name];
30294
+ e.trailTime = s.trailScale / 60; // a stretch of speed times trailScale is a streak of that many frames
30295
+ const stick = effect.behaviors.find(b=> b.name == 'stick'); // in 3D, a grip on landing is less sliding
30296
+ e.friction = stick ? min(s.friction, 1 - stick.strength) : s.friction;
30297
+ // a 2D emitter at angle a shoots along (sin a, cos a), a z turn r takes up to (-sin r, cos r), so r is -a
30298
+ e.rotation3D = vec3(0, 0, -s.angle);
30299
+ e.particleUpdateCallback = particleEffectUpdateCallback(effect.behaviors, true);
30300
+ }
30301
+
30302
+ /** Play an effect in 3D: a ParticleEmitter3D set to it, placed, scaled and recolored
30303
+ * - The same effect data as particleEffect, so the look carries across: a circle spawn area becomes a sphere and a
30304
+ * rectangle a box as deep as it is wide, both flat across the way it emits with options.flatten, a disc or a
30305
+ * sheet; a trail becomes a streak of the same length, and the settings the 3D emitter lacks (particleConeAngle,
30306
+ * randomColorLinear, velocityInheritance, localSpace) are left out; the stick behavior becomes the friction a
30307
+ * particle lands with
30308
+ * @param {string|Object} nameOrEffect - A built-in or added effect's name, or an effect
30309
+ * @param {Vector3} [pos3D]
30310
+ * @param {Object} [options] - scale, hue, saturation, angle, tileInfo and any setting as particleEffect; angle turns it
30311
+ * about z, so 0 is up; flatten makes the spawn area flat across the way it emits
30312
+ * @return {ParticleEmitter3D|undefined} - undefined when there is no such effect
30313
+ * @memberof ParticleEffects */
30314
+ function particleEffect3D(nameOrEffect, pos3D=vec3(), options={})
30315
+ {
30316
+ const effect = particleEffectResolve(nameOrEffect, options);
30317
+ if (!effect) return;
30318
+ const e = new ParticleEmitter3D(pos3D.copy());
30319
+ particleEffectApply3D(e, effect);
30320
+ if (options.angle !== undefined)
30321
+ e.rotation3D = vec3(0, 0, -options.angle);
30322
+ e.scale3D = vec3(options.scale ?? 1);
30323
+ e.emitFlat = !!options.flatten; // a sphere a disc, a box a sheet, across the way it emits
30324
+ if (options.tileInfo)
30325
+ e.tileInfo = options.tileInfo;
30326
+ return e;
30327
+ }
30328
+
30329
+ ///////////////////////////////////////////////////////////////////////////////
30330
+ // the built-in effects, each tuned around a one unit emitter; one-shots have an emitTime and end themselves
30331
+
30332
+ /** The names of the built-in effects, in the order a gallery shows them
30333
+ * @type {Array<string>}
30334
+ * @memberof ParticleEffects */
30335
+ const particleEffectsBuiltIn = [];
30336
+ {
30337
+ const add = (name, settings, behaviors=[])=>
30338
+ {
30339
+ particleEffectsBuiltIn.push(name);
30340
+ particleEffectsAdd({name, settings, behaviors});
30341
+ };
30342
+ const clear = (h, s, l)=> hsl(h, s, l, 0);
30343
+
30344
+ // fire
30345
+ add('fire', {emitRate:120, emitSize:.6, emitConeAngle:.3, shape:'glow', particleTime:.8, sizeStart:.7,
30346
+ sizeEnd:.15, speed:.02, angleSpeed:.02, damping:.95, gravity:.003, fadeRate:.3, randomness:.3, additive:true,
30347
+ colorStartA:hsl(.08,1,.6), colorStartB:hsl(.14,1,.65), colorEndA:clear(.02,1,.5), colorEndB:clear(0,1,.3)});
30348
+ add('torch', {emitRate:60, emitSize:.25, emitConeAngle:.2, shape:'glow', particleTime:.6, sizeStart:.45,
30349
+ sizeEnd:.1, speed:.015, angleSpeed:.02, damping:.95, gravity:.003, fadeRate:.3, randomness:.3, additive:true,
30350
+ colorStartA:hsl(.09,1,.6), colorStartB:hsl(.13,1,.7), colorEndA:clear(.03,1,.5), colorEndB:clear(0,1,.35)});
30351
+ add('smoke', {emitRate:25, emitSize:.5, emitConeAngle:.3, shape:'smoke', particleTime:3, sizeStart:.5,
30352
+ sizeEnd:2, speed:.01, angleSpeed:.01, damping:.99, gravity:.0004, fadeRate:.5, randomness:.3,
30353
+ colorStartA:hsl(0,0,.5,.5), colorStartB:hsl(0,0,.3,.5), colorEndA:clear(0,0,.2), colorEndB:clear(0,0,.4)},
30354
+ [{name:'wobble', strength:.5}]);
30355
+ add('steam', {emitRate:30, emitSize:.4, emitConeAngle:.2, shape:'smoke', particleTime:1.5, sizeStart:.3,
30356
+ sizeEnd:1.5, speed:.02, damping:.98, gravity:.0008, fadeRate:.6, randomness:.3,
30357
+ colorStartA:hsl(0,0,1,.4), colorStartB:hsl(.6,.2,.9,.3), colorEndA:clear(0,0,1), colorEndB:clear(.6,.2,.9)},
30358
+ [{name:'wobble', strength:.3}]);
30359
+
30360
+ // impacts
30361
+ add('explosion', {emitRate:600, emitTime:.1, emitSize:.5, shape:'glow', particleTime:.6, sizeStart:1,
30362
+ sizeEnd:.1, speed:.1, damping:.9, fadeRate:.2, randomness:.5, additive:true,
30363
+ colorStartA:hsl(.12,1,.65), colorStartB:hsl(.06,1,.55), colorEndA:clear(0,1,.5), colorEndB:clear(0,0,.3)});
30364
+ add('sparks', {emitRate:100, emitSize:.2, shape:'soft', particleTime:.6, sizeStart:.08, sizeEnd:.04,
30365
+ speed:.07, damping:.96, gravity:-.006, fadeRate:.1, randomness:.4, additive:true, trailScale:3,
30366
+ colorStartA:hsl(.17,1,.9), colorStartB:hsl(.12,1,.65), colorEndA:clear(.07,1,.5), colorEndB:clear(.03,1,.5)});
30367
+ add('hit', {emitRate:400, emitTime:.05, emitSize:.2, shape:'spark', particleTime:.35, sizeStart:.5,
30368
+ sizeEnd:.1, speed:.08, damping:.85, fadeRate:.1, randomness:.4, additive:true, trailScale:2,
30369
+ colorStartA:hsl(0,0,1), colorStartB:hsl(0,1,.7), colorEndA:clear(0,1,.5), colorEndB:clear(0,1,.4)});
30370
+ add('dust', {emitRate:300, emitTime:.05, emitSize:1, emitRect:true, emitHeight:.1, emitConeAngle:1.2,
30371
+ shape:'smoke', particleTime:.8, sizeStart:.3, sizeEnd:.8, speed:.04, damping:.9, gravity:.0005,
30372
+ fadeRate:.5, randomness:.4, colorStartA:hsl(.1,.3,.6,.5), colorStartB:hsl(.08,.2,.5,.5),
30373
+ colorEndA:clear(.1,.3,.6), colorEndB:clear(.08,.2,.5)});
30374
+ add('debris', {emitRate:200, emitTime:.05, emitSize:.5, emitConeAngle:1, shape:'square', particleTime:1.5,
30375
+ sizeStart:.15, sizeEnd:.1, speed:.07, angleSpeed:.2, damping:.99, gravity:-.008, fadeRate:.1,
30376
+ randomness:.5, colorStartA:hsl(.08,.4,.35), colorStartB:hsl(0,0,.4), colorEndA:hsl(.08,.4,.3),
30377
+ colorEndB:hsl(0,0,.35)});
30378
+
30379
+ // pickups and magic
30380
+ add('sparkle', {emitRate:30, emitSize:1, shape:'star', particleTime:.8, sizeStart:.3, sizeEnd:0,
30381
+ speed:.005, angleSpeed:.05, fadeRate:.5, randomness:.3, additive:true,
30382
+ colorStartA:hsl(.14,1,.7), colorStartB:hsl(0,0,1), colorEndA:clear(.14,1,.7), colorEndB:clear(0,0,1)});
30383
+ add('magic', {emitRate:60, emitSize:1, shape:'soft', particleTime:1.5, sizeStart:.25, sizeEnd:0,
30384
+ speed:.015, fadeRate:.3, randomness:.3, additive:true, colorStartA:hsl(.55,1,.65),
30385
+ colorStartB:hsl(.79,1,.65), colorEndA:clear(0,0,1), colorEndB:clear(0,0,1)}, [{name:'swirl', strength:.5}]);
30386
+ add('heal', {emitRate:30, emitSize:.8, emitConeAngle:.2, shape:'plus', particleTime:1.2, sizeStart:.25,
30387
+ sizeEnd:.1, speed:.015, angleSpeed:0, particleConeAngle:0, gravity:.0005, fadeRate:.4, randomness:.3,
30388
+ additive:true, colorStartA:hsl(.33,1,.6), colorStartB:hsl(.3,1,.75), colorEndA:clear(.33,1,.6),
30389
+ colorEndB:clear(.3,1,.75)});
30390
+ add('poison', {emitRate:20, emitSize:.8, shape:'smoke', particleTime:2, sizeStart:.3, sizeEnd:1,
30391
+ speed:.006, gravity:.0002, fadeRate:.6, randomness:.4, colorStartA:hsl(.3,.8,.45,.6),
30392
+ colorStartB:hsl(.8,.6,.45,.6), colorEndA:clear(.3,.8,.4), colorEndB:clear(.8,.6,.4)},
30393
+ [{name:'wobble', strength:.5}]);
30394
+ add('portal', {emitRate:80, emitSize:1, shape:'soft', particleTime:1, sizeStart:.15, sizeEnd:.05,
30395
+ speed:.02, fadeRate:.3, randomness:.3, additive:true, colorStartA:hsl(.75,1,.6),
30396
+ colorStartB:hsl(.6,1,.6), colorEndA:clear(.75,1,.6), colorEndB:clear(.6,1,.6)},
30397
+ [{name:'orbit', strength:1}, {name:'attract', strength:.5}]);
30398
+
30399
+ // weather and ambience, one unit across at scale 1, so scale them to the view
30400
+ add('rain', {emitRate:30, emitSize:1, emitRect:true, emitHeight:.6, angle:PI, emitConeAngle:0,
30401
+ shape:'soft', particleTime:.8, sizeStart:.02, sizeEnd:.02, speed:.03, gravity:-.001, fadeRate:.1,
30402
+ randomness:.2, trailScale:5, colorStartA:hsl(.63,1,.8,.6), colorStartB:hsl(.63,1,.8,.4),
30403
+ colorEndA:hsl(.63,1,.8,.6), colorEndB:hsl(.63,1,.8,.4)});
30404
+ add('snow', {emitRate:6, emitSize:1, emitRect:true, emitHeight:.6, angle:PI, emitConeAngle:.3,
30405
+ shape:'soft', particleTime:4, sizeStart:.05, sizeEnd:.05, speed:.004, damping:.98, gravity:-.0002,
30406
+ fadeRate:.2, randomness:.3, colorStartA:hsl(0,0,1), colorStartB:hsl(.58,1,.9), colorEndA:hsl(0,0,1),
30407
+ colorEndB:hsl(.58,1,.9)}, [{name:'wobble', strength:.15}]);
30408
+ add('leaves', {emitRate:3, emitSize:1, emitRect:true, emitHeight:.2, angle:PI, emitConeAngle:.5,
30409
+ shape:'triangle', particleTime:4, sizeStart:.12, sizeEnd:.12, speed:.003, angleSpeed:.05, damping:.98,
30410
+ gravity:-.0001, fadeRate:.2, randomness:.4, randomColorLinear:false, colorStartA:hsl(.25,.7,.4),
30411
+ colorStartB:hsl(.07,.8,.5), colorEndA:hsl(.08,.6,.35), colorEndB:hsl(.05,.7,.4)},
30412
+ [{name:'wobble', strength:.15}, {name:'wind', strength:.05}]);
30413
+ add('bubbles', {emitRate:8, emitSize:1, emitRect:true, emitHeight:.1, emitConeAngle:.2, shape:'ring',
30414
+ particleTime:2, sizeStart:.1, sizeEnd:.2, speed:.01, gravity:.0003, fadeRate:.2, randomness:.4,
30415
+ colorStartA:hsl(.55,.8,.8,.8), colorStartB:hsl(.5,.6,.9,.6), colorEndA:hsl(.55,.8,.8,.3),
30416
+ colorEndB:hsl(.5,.6,.9,.2)}, [{name:'wobble', strength:.3}]);
30417
+ add('fireflies', {emitRate:3, emitSize:1, shape:'glow', particleTime:3, sizeStart:.15, sizeEnd:.15,
30418
+ speed:.003, damping:.99, fadeRate:.8, randomness:.3, additive:true, colorStartA:hsl(.2,1,.6),
30419
+ colorStartB:hsl(.15,1,.7), colorEndA:hsl(.2,1,.6), colorEndB:hsl(.15,1,.7)},
30420
+ [{name:'turbulence', strength:.3}]);
30421
+
30422
+ // trails and shots
30423
+ add('trail', {emitRate:60, shape:'soft', particleTime:.5, sizeStart:.4, sizeEnd:0, speed:0, angleSpeed:0,
30424
+ fadeRate:.1, randomness:.2, additive:true, colorStartA:hsl(.08,1,.6), colorStartB:hsl(.12,1,.65),
30425
+ colorEndA:clear(.03,1,.5), colorEndB:clear(0,1,.4)});
30426
+ add('muzzle', {emitRate:300, emitTime:.05, emitConeAngle:.3, shape:'glow', particleTime:.2,
30427
+ sizeStart:.5, sizeEnd:.1, speed:.1, damping:.8, fadeRate:.1, randomness:.3, additive:true,
30428
+ colorStartA:hsl(.14,1,.8), colorStartB:hsl(.1,1,.6), colorEndA:clear(.08,1,.5), colorEndB:clear(.05,1,.4)});
30429
+
30430
+ // other
30431
+ add('blood', {emitRate:300, emitTime:.1, emitConeAngle:.8, shape:'dot', particleTime:3, sizeStart:.15,
30432
+ sizeEnd:.1, speed:.09, gravity:-.01, fadeRate:.1, randomness:.4, collideLevel:true, restitution:.1,
30433
+ colorStartA:hsl(0,1,.35), colorStartB:hsl(0,1,.2), colorEndA:hsl(0,1,.25), colorEndB:hsl(0,1,.15)},
30434
+ [{name:'stick', strength:1}]);
30435
+ add('confetti', {emitRate:200, emitTime:.1, emitSize:.2, emitConeAngle:.6, shape:'square', particleTime:3,
30436
+ sizeStart:.1, sizeEnd:.1, speed:.09, angleSpeed:.15, damping:.96, gravity:-.004, fadeRate:.1,
30437
+ randomness:.4, randomColorLinear:false, colorStartA:hsl(0,1,.5), colorStartB:hsl(.6,1,.6),
30438
+ colorEndA:hsl(0,1,.5), colorEndB:hsl(.6,1,.6)}, [{name:'wobble', strength:.3}]);
30439
+ add('splash', {emitRate:300, emitTime:.05, emitSize:.5, emitConeAngle:.6, shape:'soft', particleTime:.7,
30440
+ sizeStart:.12, sizeEnd:.06, speed:.08, gravity:-.008, fadeRate:.2, randomness:.4,
30441
+ colorStartA:hsl(.58,.8,.7,.8), colorStartB:hsl(.55,.6,.85,.8), colorEndA:hsl(.58,.8,.7,.2),
30442
+ colorEndB:hsl(.55,.6,.85,.2)});
30443
+ }
28644
30444
 
28645
30445
  /**
28646
30446
  * LittleJS glTF Plugin
@@ -29003,7 +30803,13 @@ async function loadGLTF(url)
29003
30803
  const response = await fetch(url);
29004
30804
  if (!response.ok)
29005
30805
  throw new Error('loadGLTF failed: ' + url);
29006
- return parseGLTF(await response.arrayBuffer(), url.slice(0, url.lastIndexOf('/') + 1));
30806
+ // the files beside it are beside where it came from, after any redirect; a blob or data url has nothing
30807
+ // beside it, and a model in one is whole, or parsed with parseGLTF and a base of its own
30808
+ const from = response.url || url;
30809
+ let base = '';
30810
+ if (!/^(blob|data):/i.test(from))
30811
+ base = response.url ? new URL('.', from).href : from.slice(0, from.lastIndexOf('/') + 1);
30812
+ return parseGLTF(await response.arrayBuffer(), base);
29007
30813
  }
29008
30814
 
29009
30815
  /** Parse a model from GLB bytes or glTF JSON, fetching the buffers and images it refers to