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}
@@ -5236,8 +5236,9 @@ class TextureInfo
5236
5236
  * @param {boolean} [useWebGL] - Should use WebGL if available?
5237
5237
  * @param {boolean|Array<number>} [wrap] - Should the texture wrap (REPEAT) or clamp (CLAMP_TO_EDGE)? Or the WebGL
5238
5238
  * modes across and down, like [gl.CLAMP_TO_EDGE, gl.MIRRORED_REPEAT], as a glTF sampler gives them
5239
+ * @param {boolean} [pixelated] - Hard edged or smooth for this texture alone, undefined follows tilesPixelated
5239
5240
  */
5240
- constructor(image, useWebGL=true, wrap=false)
5241
+ constructor(image, useWebGL=true, wrap=false, pixelated)
5241
5242
  {
5242
5243
  /** @property {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap} - image source */
5243
5244
  this.image = image;
@@ -5252,6 +5253,10 @@ class TextureInfo
5252
5253
  * across and down
5253
5254
  * @type {boolean|Array<number>} */
5254
5255
  this.wrap = wrap;
5256
+ /** @property {boolean|undefined} - Hard edged or smooth for this texture alone, a soft glow in a pixel art
5257
+ * game or pixel art in a smooth one; undefined follows tilesPixelated
5258
+ * @type {boolean|undefined} */
5259
+ this.pixelated = pixelated;
5255
5260
  useWebGL && this.createWebGLTexture();
5256
5261
  }
5257
5262
 
@@ -5282,6 +5287,17 @@ class TextureInfo
5282
5287
  this.wrap = wrap;
5283
5288
  glSetTextureWrap(this.glTexture, wrap);
5284
5289
  }
5290
+
5291
+ /** Make this texture hard edged or smooth on its own, whatever tilesPixelated says for the rest; it is made again
5292
+ * with the new filtering
5293
+ * @param {boolean} [pixelated] - undefined follows tilesPixelated again */
5294
+ setPixelated(pixelated)
5295
+ {
5296
+ this.pixelated = pixelated;
5297
+ if (!this.glTexture) return;
5298
+ this.destroyWebGLTexture();
5299
+ this.createWebGLTexture();
5300
+ }
5285
5301
  }
5286
5302
 
5287
5303
  ///////////////////////////////////////////////////////////////////////////////
@@ -6825,6 +6841,33 @@ class ImageFont
6825
6841
  }
6826
6842
  }
6827
6843
 
6844
+ // 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
6845
+ // and nothing at the edge, fading faster the higher the falloff
6846
+ function engineGlowAlpha(r, falloff)
6847
+ {
6848
+ const k = 3.5 * falloff, edge = Math.exp(-k);
6849
+ return (Math.exp(-k * r * r) - edge) / (1 - edge);
6850
+ }
6851
+
6852
+ // the soft round glow of the 2D and 3D lights, one texture for each falloff, rounded to a tenth so a changing falloff
6853
+ // makes only a few, each made once from a canvas; undefined headless or without a canvas
6854
+ const engineGlowTextures = new Map;
6855
+ function engineGlowTexture(falloff=1)
6856
+ {
6857
+ ASSERT(isNumber(falloff) && falloff > 0, 'glowFalloff must be a number above 0');
6858
+ const key = max(round(falloff * 10), 1) / 10;
6859
+ let texture = engineGlowTextures.get(key);
6860
+ if (texture || !glContext || typeof OffscreenCanvas == 'undefined') return texture;
6861
+ const size = 64, context = createCanvasContext(size), steps = 16;
6862
+ const gradient = context.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
6863
+ for (let i = 0; i <= steps; ++i)
6864
+ gradient.addColorStop(i / steps, 'rgba(255,255,255,' + engineGlowAlpha(i / steps, key).toFixed(4) + ')');
6865
+ context.fillStyle = gradient;
6866
+ context.fillRect(0, 0, size, size);
6867
+ engineGlowTextures.set(key, texture = new TextureInfo(context.canvas, true, false, false));
6868
+ return texture;
6869
+ }
6870
+
6828
6871
  // load engine font, called automatically on startup
6829
6872
  async function imageFontInit()
6830
6873
  {
@@ -8526,9 +8569,12 @@ class Sound
8526
8569
  * @param {number} [taper=soundDefaultTaper] - At what percentage of range should it start tapering
8527
8570
  * @param {SoundLoadCallback} [onloadCallback] - callback function to call when sound is loaded
8528
8571
  */
8529
- constructor(asset, randomness, range=soundDefaultRange, taper=soundDefaultTaper, onloadCallback)
8572
+ constructor(asset, randomness, range, taper=soundDefaultTaper, onloadCallback)
8530
8573
  {
8531
8574
  if (!soundEnable || headlessMode) return;
8575
+ const rangeIsDefault = range === undefined;
8576
+ if (rangeIsDefault)
8577
+ range = soundDefaultRange;
8532
8578
 
8533
8579
  ASSERT(!asset || isArray(asset) || isStringLike(asset), 'asset must be a file name or zzfx array');
8534
8580
  ASSERT(randomness === undefined || isNumber(randomness), 'randomness must be a number');
@@ -8538,6 +8584,9 @@ class Sound
8538
8584
 
8539
8585
  /** @property {number} - World space max range of sound */
8540
8586
  this.range = range;
8587
+ /** @property {boolean} - No range was given, so it got soundDefaultRange; played in 3D such a sound is heard
8588
+ * to render3D.soundDefaultRange instead, which is further */
8589
+ this.rangeIsDefault = rangeIsDefault;
8541
8590
  /** @property {number} - At what percentage of range should it start tapering */
8542
8591
  this.taper = taper;
8543
8592
  /** @property {number} - How much to randomize frequency each time sound plays
@@ -9173,7 +9222,7 @@ function playAudioBuffer(buffer, volume=1, rate=1, pan=0, loop=false, gainNode,
9173
9222
  }
9174
9223
 
9175
9224
  ///////////////////////////////////////////////////////////////////////////////
9176
- // ZzFXMicro - Zuper Zmall Zound Zynth - v1.3.2 by Frank Force
9225
+ // ZzFXMicro - Zuper Zmall Zound Zynth - v1.4.0 by Frank Force
9177
9226
 
9178
9227
  /** Generate and play a ZzFX sound
9179
9228
  *
@@ -9205,7 +9254,7 @@ function zzfx(...zzfxSound) { return playSamples([zzfxG(...zzfxSound)]); }
9205
9254
  * @param {number} [decay] - Decay time, how long to reach sustain after attack (seconds)
9206
9255
  * @param {number} [tremolo] - Trembling effect, rate controlled by repeat time (percent)
9207
9256
  * @param {number} [filter] - Filter cutoff frequency, positive for HPF, negative for LPF (Hz)
9208
- * @return {Array} - Array of audio samples
9257
+ * @return {Float32Array} - The audio samples
9209
9258
  * @memberof Audio */
9210
9259
  function zzfxG
9211
9260
  (
@@ -9243,7 +9292,7 @@ function zzfxG
9243
9292
  crush = 0, // bit crush offset
9244
9293
  jump = 1, // pitch jump timer
9245
9294
  length, // sample length
9246
- b = [], // sample buffer
9295
+ b, // sample buffer
9247
9296
  t = 0, // sample time
9248
9297
  i = 0, // sample index
9249
9298
  s = 0, // sample value
@@ -9270,9 +9319,12 @@ function zzfxG
9270
9319
  pitchJumpTime *= sampleRate;
9271
9320
  repeatTime = repeatTime * sampleRate | 0;
9272
9321
 
9322
+ // allocate the full sample buffer up front, much faster than growing an array
9323
+ length = attack + decay + sustain + release + delay | 0;
9324
+ b = new Float32Array(length > 0 ? length : 0);
9325
+
9273
9326
  // generate waveform
9274
- for (length = attack + decay + sustain + release + delay | 0;
9275
- i < length; b[i++] = s * volume) // sample
9327
+ for (; i < length; b[i++] = s * volume) // sample
9276
9328
  {
9277
9329
  if (!(++crush%(bitCrush*100|0))) // bit crush
9278
9330
  {
@@ -9308,7 +9360,7 @@ function zzfxG
9308
9360
 
9309
9361
  f = (frequency += slide += deltaSlide) *// frequency
9310
9362
  cos(modulation*modOffset++); // modulation
9311
- t += f + f*noise*sin(i**5); // noise
9363
+ t += f + f*noise*(i*i*PI2%2-1); // noise
9312
9364
 
9313
9365
  if (jump && ++jump > pitchJumpTime) // pitch jump
9314
9366
  {
@@ -10486,6 +10538,13 @@ class ParticleEmitter extends EngineObject
10486
10538
  this.localSpace = localSpace;
10487
10539
  /** @property {number} - If non zero the particle is drawn as a trail, stretched in the direction of velocity */
10488
10540
  this.trailScale = 0;
10541
+ /** @property {number} - Grows the whole effect as each particle is born: the spawn area, the particles' sizes,
10542
+ * speed and fall, so it looks the same only bigger; 1 is as made, and a particle keeps the scale it was born
10543
+ * with, so a change never moves the ones already out */
10544
+ this.scale = 1;
10545
+ /** @property {number} - The effect's own fall, added to each particle's velocity y every frame, on top of the
10546
+ * world's gravity times gravityScale; an effect that sets its own looks the same in any game */
10547
+ this.gravity = 0;
10489
10548
  /** @property {ParticleCallback|undefined} - Callback when particle is created
10490
10549
  * @type {ParticleCallback|undefined} */
10491
10550
  this.particleCreateCallback = undefined;
@@ -10572,9 +10631,9 @@ class ParticleEmitter extends EngineObject
10572
10631
  {
10573
10632
  // show emitter bounds
10574
10633
  if (this.emitCircle)
10575
- debugCircle(this.pos, this.emitSize.x, '#0f0', 0, false, false);
10634
+ debugCircle(this.pos, this.emitSize.x * this.scale, '#0f0', 0, false, false);
10576
10635
  else
10577
- debugRect(this.pos, this.emitSize, '#0f0', 0, this.angle, false, false);
10636
+ debugRect(this.pos, this.emitSize.scale(this.scale), '#0f0', 0, this.angle, false, false);
10578
10637
  }
10579
10638
  }
10580
10639
 
@@ -10583,10 +10642,11 @@ class ParticleEmitter extends EngineObject
10583
10642
  emitParticle()
10584
10643
  {
10585
10644
  // spawn a particle
10645
+ const scale = this.scale; // grows the spawn area, the sizes and the speed of each particle born
10586
10646
  let pos = this.emitCircle ? // check if circle emitter
10587
- randInCircle(this.emitSize.x/2) // circle emitter
10588
- : vec2(rand(-.5,.5), rand(-.5,.5)) // box emitter
10589
- .multiply(this.emitSize);
10647
+ randInCircle(this.emitSize.x/2 * scale) // circle emitter
10648
+ : vec2(rand(-.5,.5) * this.emitSize.x * scale, // box emitter, one vector made
10649
+ rand(-.5,.5) * this.emitSize.y * scale);
10590
10650
  let angle = rand(this.particleConeAngle, -this.particleConeAngle);
10591
10651
  if (!this.localSpace)
10592
10652
  {
@@ -10603,9 +10663,9 @@ class ParticleEmitter extends EngineObject
10603
10663
 
10604
10664
  // randomize particle settings
10605
10665
  const particleTime = randomizeScale(this.particleTime);
10606
- const sizeStart = randomizeScale(this.sizeStart);
10607
- const sizeEnd = randomizeScale(this.sizeEnd);
10608
- const speed = randomizeScale(this.speed);
10666
+ const sizeStart = randomizeScale(this.sizeStart) * scale;
10667
+ const sizeEnd = randomizeScale(this.sizeEnd) * scale;
10668
+ const speed = randomizeScale(this.speed) * scale;
10609
10669
  const angleSpeed = randomizeScale(this.angleSpeed) * randSign();
10610
10670
  const coneAngle = rand(this.emitConeAngle, -this.emitConeAngle);
10611
10671
  const colorStart = randColor(this.colorStartA, this.colorStartB, this.randomColorLinear);
@@ -10639,9 +10699,13 @@ class ParticleEmitter extends EngineObject
10639
10699
  render()
10640
10700
  {
10641
10701
  // render all particles, the blend switched once for them all, not around each one
10702
+ // and the tile's draw worked out once for them all, fresh each frame so a tile changed in place draws changed
10642
10703
  this.additive && setAdditiveBlendMode();
10704
+ particleTile = null; // none yet, undefined is the untextured tile
10705
+ particleTileBatch = true;
10643
10706
  for (const particle of this.particles)
10644
10707
  particle.render();
10708
+ particleTileBatch = false;
10645
10709
  this.additive && setAdditiveBlendMode(false);
10646
10710
  }
10647
10711
 
@@ -10668,6 +10732,39 @@ class ParticleEmitter extends EngineObject
10668
10732
  // scratch vectors reused by Particle.render and the tile collision to avoid per-frame allocations
10669
10733
  const particleDrawPos = new Vector2, particleDrawSize = new Vector2, particleCollidePos = new Vector2;
10670
10734
 
10735
+ // the WebGL draw of the tile particles draw with, what drawTile works out for each sprite: kept while an emitter
10736
+ // renders its particles, so each one only writes its own values into the batch
10737
+ let particleTile, particleTileBatch = false, particleTileTextured = false, particleTileEmpty = false;
10738
+ let particleTileU0 = 0, particleTileV0 = 0, particleTileU1 = 0, particleTileV1 = 0;
10739
+ function particleTileSet(tileInfo)
10740
+ {
10741
+ particleTile = tileInfo;
10742
+ const textureInfo = tileInfo?.textureInfo;
10743
+ particleTileTextured = !!textureInfo;
10744
+ particleTileEmpty = !!textureInfo && !(tileInfo.size.x && tileInfo.size.y); // like a sprite still loading
10745
+ if (!textureInfo || particleTileEmpty) return;
10746
+
10747
+ ASSERT(!!textureInfo.glTexture, 'texture has no WebGL texture, draw it with useWebGL false');
10748
+ glSetTexture(textureInfo.glTexture); // bound, and its mipmaps brought up to date, once for the emitter
10749
+ const sizeInverse = textureInfo.sizeInverse, bleed = tileInfo.bleed;
10750
+ const x = tileInfo.pos.x * sizeInverse.x;
10751
+ const y = tileInfo.pos.y * sizeInverse.y;
10752
+ const w = tileInfo.size.x * sizeInverse.x;
10753
+ const h = tileInfo.size.y * sizeInverse.y;
10754
+ if (bleed)
10755
+ {
10756
+ const bleedX = sizeInverse.x*bleed;
10757
+ const bleedY = sizeInverse.y*bleed;
10758
+ particleTileU0 = x + bleedX; particleTileV0 = y + bleedY;
10759
+ particleTileU1 = x - bleedX + w; particleTileV1 = y - bleedY + h;
10760
+ }
10761
+ else
10762
+ {
10763
+ particleTileU0 = x; particleTileV0 = y;
10764
+ particleTileU1 = x + w; particleTileV1 = y + h;
10765
+ }
10766
+ }
10767
+
10671
10768
  // tests if a particle collides with tiles at x, y, through its emitter's collide callback if it has one
10672
10769
  function particleCollideTest(particle, collideCallback, x, y)
10673
10770
  {
@@ -10703,6 +10800,8 @@ class Particle
10703
10800
  {
10704
10801
  /** @property {ParticleEmitter} - The emitter this particle came from */
10705
10802
  this.emitter = emitter;
10803
+ /** @property {number} - The emitter's scale when it was made, which grows its fall */
10804
+ this.scale = emitter.scale ?? 1; // a hand made emitter-like object may have none
10706
10805
  /** @property {Vector2} - Position, world space or local to the emitter when localSpace is set */
10707
10806
  this.pos = pos;
10708
10807
  /** @property {number} - Angle in radians */
@@ -10765,7 +10864,10 @@ class Particle
10765
10864
  // apply physics; only the tile collision needs where the particle was
10766
10865
  const solve = enablePhysicsSolver && collideLevel;
10767
10866
  const oldX = this.pos.x, oldY = this.pos.y;
10768
- let gravityX = gravity.x * gravityScale, gravityY = gravity.y * gravityScale;
10867
+ // the world's gravity times gravityScale plus the emitter's own, grown by the particle's scale
10868
+ const fall = this.scale;
10869
+ let gravityX = gravity.x * gravityScale * fall;
10870
+ let gravityY = (gravity.y * gravityScale + (emitter.gravity || 0)) * fall;
10769
10871
  if (emitter.localSpace && emitter.angle)
10770
10872
  {
10771
10873
  // world gravity turned into the emitter's space, the render turns it back
@@ -10819,7 +10921,9 @@ class Particle
10819
10921
  }
10820
10922
  if (isBlockedY || !isBlockedX)
10821
10923
  {
10822
- const wasFalling = this.velocity.y < 0 && gravity.y < 0 || this.velocity.y > 0 && gravity.y > 0;
10924
+ // down is the world's gravity, or the emitter's own fall in a world with none
10925
+ const down = gravity.y || emitter.gravity || 0;
10926
+ const wasFalling = this.velocity.y < 0 && down < 0 || this.velocity.y > 0 && down > 0;
10823
10927
  if (wasFalling)
10824
10928
  this.groundObject = hitLayer;
10825
10929
 
@@ -10898,7 +11002,24 @@ class Particle
10898
11002
  angle = atan2(velocity.x, velocity.y);
10899
11003
  }
10900
11004
  }
10901
- drawTile(pos, size, this.tileInfo, this.color, angle, this.mirror, undefined, glEnable, false);
11005
+ if (glEnable && !headlessMode)
11006
+ {
11007
+ // straight into the batch, the tile's draw worked out once for the emitter's particles
11008
+ const tileInfo = this.tileInfo;
11009
+ if (!particleTileBatch || tileInfo !== particleTile)
11010
+ particleTileSet(tileInfo);
11011
+ if (!particleTileTextured)
11012
+ glDrawUntextured(pos.x, pos.y, size.x, size.y, angle, this.color.rgbaInt());
11013
+ else if (!particleTileEmpty)
11014
+ {
11015
+ const texture = tileInfo.textureInfo.glTexture;
11016
+ glActiveTexture === texture || glSetTexture(texture); // bound again only if a draw between changed it
11017
+ glDraw(pos.x, pos.y, this.mirror ? -size.x : size.x, size.y, angle,
11018
+ particleTileU0, particleTileV0, particleTileU1, particleTileV1, this.color.rgbaInt());
11019
+ }
11020
+ }
11021
+ else
11022
+ drawTile(pos, size, this.tileInfo, this.color, angle, this.mirror, undefined, false, false);
10902
11023
  additive && setAdditiveBlendMode(false);
10903
11024
  debugParticles && debugRect(pos, size, '#f005', 0, angle, false, false);
10904
11025
  }
@@ -10929,6 +11050,19 @@ let glCanvas;
10929
11050
  * @memberof WebGL */
10930
11051
  let glContext;
10931
11052
 
11053
+ /** The largest texture this device makes, in pixels across, 0 until WebGL starts; at least 2048 with WebGL2
11054
+ * @type {number}
11055
+ * @memberof WebGL */
11056
+ let glMaxTextureSize = 0;
11057
+
11058
+ /** A texture size this device can make: the size asked for, or the largest it supports when that is smaller
11059
+ * - The engine sizes the textures it makes itself through this, the shadow maps and the like, so a size a device
11060
+ * does not have falls back instead of failing
11061
+ * @param {number} size - Pixels across
11062
+ * @return {number}
11063
+ * @memberof WebGL */
11064
+ function glClampTextureSize(size) { return glMaxTextureSize ? min(size, glMaxTextureSize) : size; }
11065
+
10932
11066
  /** Should WebGL be setup with anti-aliasing? must be set before calling engineInit
10933
11067
  * @type {boolean}
10934
11068
  * @memberof WebGL */
@@ -11018,7 +11152,7 @@ function glInit(rootElement)
11018
11152
  // startup webgl, and make the textures of any texture infos made before it
11019
11153
  initWebGL();
11020
11154
  for (const info of glTextureInfos)
11021
- info.glTexture ||= glCreateTexture(info.image, info.wrap);
11155
+ info.glTexture ||= glCreateTexture(info.image, info.wrap, info.pixelated);
11022
11156
 
11023
11157
  // setup context lost and restore handlers
11024
11158
  glCanvas.addEventListener('webglcontextlost', (e)=>
@@ -11058,7 +11192,7 @@ function glInit(rootElement)
11058
11192
  glPremultipliedTextures = new WeakSet; // the tile layers draw into their new textures again below
11059
11193
  initWebGL();
11060
11194
  for (const info of glTextureInfos)
11061
- info.glTexture = glCreateTexture(info.image, info.wrap);
11195
+ info.glTexture = glCreateTexture(info.image, info.wrap, info.pixelated);
11062
11196
  pluginList.forEach(plugin=>plugin.glContextRestored?.());
11063
11197
 
11064
11198
  // a tile layer drawn on the GPU only had its tiles in the lost texture, it draws them again
@@ -11068,6 +11202,8 @@ function glInit(rootElement)
11068
11202
 
11069
11203
  function initWebGL()
11070
11204
  {
11205
+ glMaxTextureSize = glContext.getParameter(glContext.MAX_TEXTURE_SIZE) | 0;
11206
+
11071
11207
  // setup instanced rendering shader program
11072
11208
  glShader = glCreateProgram(gl_VERTEX_SOURCE,
11073
11209
  '#version 300 es\n' + // specify GLSL ES version
@@ -11391,9 +11527,10 @@ function glShaderProgram(shader)
11391
11527
  * Restores the active texture when done
11392
11528
  * @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap} [image]
11393
11529
  * @param {boolean|Array<number>} [wrap] - true for REPEAT, false for CLAMP_TO_EDGE, or the WebGL modes across and down
11530
+ * @param {boolean} [pixelated] - Hard edged or smooth, undefined follows tilesPixelated
11394
11531
  * @return {WebGLTexture}
11395
11532
  * @memberof WebGL */
11396
- function glCreateTexture(image, wrap=false)
11533
+ function glCreateTexture(image, wrap=false, pixelated=tilesPixelated)
11397
11534
  {
11398
11535
  if (!glContext) return;
11399
11536
 
@@ -11402,10 +11539,10 @@ function glCreateTexture(image, wrap=false)
11402
11539
  let mipMap = false;
11403
11540
  if (image?.width)
11404
11541
  {
11405
- glSetTextureData(texture, image);
11542
+ glSetTextureData(texture, image, pixelated);
11406
11543
  glContext.bindTexture(glContext.TEXTURE_2D, texture);
11407
11544
  // WebGL2 makes mipmaps at any size, a texture that becomes a render target keeps them only at powers of two
11408
- mipMap = !tilesPixelated;
11545
+ mipMap = !pixelated;
11409
11546
  if (mipMap && !(isPowerOfTwo(image.width) && isPowerOfTwo(image.height)))
11410
11547
  glMipmapsUntilTarget.add(texture);
11411
11548
  }
@@ -11418,7 +11555,7 @@ function glCreateTexture(image, wrap=false)
11418
11555
  }
11419
11556
 
11420
11557
  // set texture filtering
11421
- const magFilter = tilesPixelated ? glContext.NEAREST : glContext.LINEAR;
11558
+ const magFilter = pixelated ? glContext.NEAREST : glContext.LINEAR;
11422
11559
  const minFilter = mipMap ? glContext.LINEAR_MIPMAP_LINEAR : magFilter;
11423
11560
  glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MAG_FILTER, magFilter);
11424
11561
  glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MIN_FILTER, minFilter);
@@ -11449,8 +11586,9 @@ function glDeleteTexture(texture)
11449
11586
  /** Set WebGL texture data from an image, restores the active texture when done
11450
11587
  * @param {WebGLTexture} texture
11451
11588
  * @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap} image
11589
+ * @param {boolean} [pixelated] - Hard edged or smooth, undefined follows tilesPixelated
11452
11590
  * @memberof WebGL */
11453
- function glSetTextureData(texture, image)
11591
+ function glSetTextureData(texture, image, pixelated=tilesPixelated)
11454
11592
  {
11455
11593
  if (!glContext) return;
11456
11594
 
@@ -11468,7 +11606,7 @@ function glSetTextureData(texture, image)
11468
11606
  }
11469
11607
  // smooth filtering mixes a texel with its see through neighbors, right only for premultiplied color, or the
11470
11608
  // edges go dark; pixel art is sampled a texel at a time, and uploads straight color as it always has
11471
- const premultiply = !tilesPixelated;
11609
+ const premultiply = !pixelated;
11472
11610
  glContext.pixelStorei(glContext.UNPACK_PREMULTIPLY_ALPHA_WEBGL, premultiply);
11473
11611
  glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA, glContext.RGBA, glContext.UNSIGNED_BYTE, image);
11474
11612
  glContext.pixelStorei(glContext.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
@@ -11499,9 +11637,9 @@ function glRegisterTextureInfo(textureInfo)
11499
11637
 
11500
11638
  // create or set the texture data
11501
11639
  if (textureInfo.glTexture)
11502
- glSetTextureData(textureInfo.glTexture, textureInfo.image);
11640
+ glSetTextureData(textureInfo.glTexture, textureInfo.image, textureInfo.pixelated);
11503
11641
  else
11504
- textureInfo.glTexture = glCreateTexture(textureInfo.image, textureInfo.wrap);
11642
+ textureInfo.glTexture = glCreateTexture(textureInfo.image, textureInfo.wrap, textureInfo.pixelated);
11505
11643
  }
11506
11644
 
11507
11645
  /** Internal: tells WebGL to destroy the glTexture and stop tracking it, TextureInfo calls it
@@ -12999,8 +13137,8 @@ class PostProcessPlugin
12999
13137
  ASSERT(!(includeMainCanvas && feedbackTexture), 'Post process cannot both include main canvas and use feedback texture');
13000
13138
  postProcess = this;
13001
13139
 
13002
- if (!shaderCode) // default shader pass through
13003
- shaderCode = 'void mainImage(out vec4 c,vec2 p){c=texture(iChannel0,p/iResolution.xy);}';
13140
+ /** @property {string} - The shadertoy style mainImage code it shades with, see setShaderCode */
13141
+ this.shaderCode = shaderCode || postProcessEffects(); // no code passes the frame through
13004
13142
 
13005
13143
  /** @property {WebGLProgram|undefined} - Shader for post processing
13006
13144
  * @type {WebGLProgram|undefined} */
@@ -13046,7 +13184,7 @@ class PostProcessPlugin
13046
13184
  'gl_Position=vec4(p+p-1.,1,1);'+ // set position
13047
13185
  '}' // end of shader
13048
13186
  ,
13049
- postProcessFragmentSource(shaderCode)
13187
+ postProcessFragmentSource(postProcess.shaderCode)
13050
13188
  );
13051
13189
 
13052
13190
  // setup VAO for post processing
@@ -13059,6 +13197,10 @@ class PostProcessPlugin
13059
13197
  const pLocation = glContext.getAttribLocation(postProcess.shader, 'p');
13060
13198
  glContext.enableVertexAttribArray(pLocation);
13061
13199
  glContext.vertexAttribPointer(pLocation, 2, glContext.FLOAT, false, vertexByteStride, 0);
13200
+
13201
+ // the engine's own vertex array and buffer back, its next batch is written into the buffer bound
13202
+ glContext.bindVertexArray(glPolyMode ? glPolyVAO : glInstancedVAO);
13203
+ glContext.bindBuffer(glContext.ARRAY_BUFFER, glArrayBuffer);
13062
13204
  }
13063
13205
  function postProcessContextLost()
13064
13206
  {
@@ -13076,16 +13218,17 @@ class PostProcessPlugin
13076
13218
  {
13077
13219
  if (headlessMode || !glEnable) return;
13078
13220
 
13079
- // made now if WebGL was off when the plugin was made, or when a lost context came back
13221
+ // clear out the buffer, before anything here binds its own
13222
+ glFlush();
13223
+
13224
+ // made now if WebGL was off when the plugin was made, when a lost context came back, or when
13225
+ // setShaderCode gave it new code
13080
13226
  if (!postProcess.shader)
13081
13227
  {
13082
13228
  if (glContext.isContextLost()) return;
13083
13229
  initPostProcess();
13084
13230
  }
13085
13231
 
13086
- // clear out the buffer
13087
- glFlush();
13088
-
13089
13232
  // ensure we render to the default framebuffer (in case any earlier
13090
13233
  // caller this frame left a render target bound)
13091
13234
  glContext.bindFramebuffer(glContext.FRAMEBUFFER, null);
@@ -13154,7 +13297,9 @@ class PostProcessPlugin
13154
13297
 
13155
13298
  if (feedbackTexture)
13156
13299
  {
13157
- // keep this frame's output for the next one, then hand the first texture unit back to the engine
13300
+ // keep this frame's output for the next one, in the feedback texture still bound to the second
13301
+ // unit, then hand the first texture unit back to the engine
13302
+ glContext.activeTexture(glContext.TEXTURE1);
13158
13303
  glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA, glContext.RGBA, glContext.UNSIGNED_BYTE, glCanvas);
13159
13304
  glContext.activeTexture(glContext.TEXTURE0);
13160
13305
  }
@@ -13176,6 +13321,23 @@ class PostProcessPlugin
13176
13321
  glSetInstancedMode(true);
13177
13322
  }
13178
13323
  }
13324
+
13325
+ /** Shade with new code from the next frame on, to switch effects while the game runs; the shader is made again
13326
+ * @param {string} [shaderCode] - Shadertoy style mainImage code, postProcessEffects builds it; none passes the
13327
+ * frame through */
13328
+ setShaderCode(shaderCode)
13329
+ {
13330
+ ASSERT(!shaderCode || typeof shaderCode === 'string', 'shader code must be a string');
13331
+ this.shaderCode = shaderCode || postProcessEffects();
13332
+ if (!this.shader || headlessMode || !glContext) return;
13333
+
13334
+ // what the new shader replaces, made again by the next render
13335
+ glContext.deleteProgram(this.shader);
13336
+ glContext.deleteTexture(this.texture);
13337
+ this.feedbackTexture && glContext.deleteTexture(this.feedbackTexture);
13338
+ glContext.deleteVertexArray(this.vao);
13339
+ this.shader = this.texture = this.feedbackTexture = this.vao = undefined;
13340
+ }
13179
13341
  }
13180
13342
 
13181
13343
  ///////////////////////////////////////////////////////////////////////////////
@@ -13216,9 +13378,66 @@ function postProcessFragmentSource(shaderCode)
13216
13378
  */
13217
13379
  function postProcessBloomShader(threshold=.6, strength=1, size=6)
13218
13380
  {
13219
- ASSERT(isNumber(threshold) && isNumber(strength) && isNumber(size), 'bloom settings must be numbers');
13220
- ASSERT(size > 0, 'bloom size must be above zero');
13221
- ASSERT(size <= 32, 'a bloom this wide takes a sample every few pixels of every ring, which is hundreds of samples a pixel', size);
13381
+ return postProcessEffects(postProcessGlow(threshold, strength, size));
13382
+ }
13383
+
13384
+ /**
13385
+ * Set up post processing with a bloom effect, so bright colors and lights glow
13386
+ * @param {number} [threshold] - Brightness where the glow starts, 0 is everything and 1 is only pure white
13387
+ * @param {number} [strength] - How much glow to add
13388
+ * @param {number} [size] - How far the glow spreads in pixels
13389
+ * @param {boolean} [includeMainCanvas] - Glow the 2D canvas too, off by default so HUD text stays crisp
13390
+ * (a HUD drawn with WebGL in gameRenderPost glows either way, draw it with useWebGL=false)
13391
+ * @return {PostProcessPlugin}
13392
+ * @memberof PostProcess
13393
+ * @example
13394
+ * postProcessBloom(); // in gameInit, after any Render3DPlugin
13395
+ */
13396
+ function postProcessBloom(threshold=.6, strength=1, size=6, includeMainCanvas=false)
13397
+ { return new PostProcessPlugin(postProcessBloomShader(threshold, strength, size), includeMainCanvas); }
13398
+
13399
+ ///////////////////////////////////////////////////////////////////////////////
13400
+ // Effects: pieces of shader code, each with its settings written in as numbers, that postProcessEffects joins in
13401
+ // 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
13402
+ // its pixel; one that bends uv or samples the frame, the curve, chromatic and glow, goes before the ones that shade
13403
+
13404
+ // a number as GLSL writes it, a float with a point
13405
+ const postProcessNumber = (n)=> (ASSERT(isNumber(n), 'effect settings must be numbers', n), n.toFixed(4));
13406
+
13407
+ /**
13408
+ * Join effects into one post process shader, in the order given, for PostProcessPlugin or setShaderCode
13409
+ * - 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;
13410
+ * your own code is a piece too, a line of GLSL or many, dropped in where you put it
13411
+ * - Put the ones that bend the picture or sample it first: postProcessCurve, postProcessChromatic, postProcessGlow
13412
+ * @param {...string} effects - The pieces, from the effect functions or your own code
13413
+ * @return {string} - Shadertoy style mainImage code
13414
+ * @example
13415
+ * new PostProcessPlugin(postProcessEffects(
13416
+ * postProcessScanlines(.5), postProcessVignette(), 'c.rgb *= vec3(1, .9, .8);'));
13417
+ * @memberof PostProcess
13418
+ */
13419
+ function postProcessEffects(...effects)
13420
+ {
13421
+ ASSERT(effects.every((e)=> typeof e === 'string'), 'each effect is a piece of shader code, a string');
13422
+ return 'void mainImage(out vec4 c, vec2 p)\n{\n' +
13423
+ ' vec2 uv = p / iResolution.xy;\n' +
13424
+ ' c = texture(iChannel0, uv);\n' +
13425
+ effects.map((e)=> ' {\n' + e + '\n }\n').join('') + '}\n';
13426
+ }
13427
+
13428
+ /**
13429
+ * Bright parts glow, the bloom as an effect to join with others; postProcessBloom sets up bloom on its own
13430
+ * @param {number} [threshold] - Brightness where the glow starts, 0 is everything and 1 is only pure white
13431
+ * @param {number} [strength] - How much glow to add
13432
+ * @param {number} [size] - How far the glow spreads in pixels, which also sets how many samples it takes
13433
+ * @return {string}
13434
+ * @memberof PostProcess
13435
+ */
13436
+ function postProcessGlow(threshold=.6, strength=1, size=6)
13437
+ {
13438
+ ASSERT(isNumber(threshold) && isNumber(strength) && isNumber(size), 'glow settings must be numbers');
13439
+ ASSERT(size > 0, 'glow size must be above zero');
13440
+ ASSERT(size <= 32, 'a glow this wide takes a sample every few pixels of every ring, which is hundreds of samples a pixel', size);
13222
13441
 
13223
13442
  // Taps on three rings over a disc of the given size, one every three pixels or so of each ring
13224
13443
  // so there is no gap wide enough to show. The count follows the ring all the way out: hold it
@@ -13240,30 +13459,127 @@ function postProcessBloomShader(threshold=.6, strength=1, size=6)
13240
13459
  glow += max(vec3(0), texture(iChannel0, uv + vec2(cos(a), sin(a)) * ${radius.toFixed(4)} / iResolution.xy).rgb - ${threshold.toFixed(4)});
13241
13460
  }`;
13242
13461
  }
13243
- return `
13244
- void mainImage(out vec4 color, vec2 pixel)
13245
- {
13246
- vec2 uv = pixel / iResolution.xy;
13247
- color = texture(iChannel0, uv);
13462
+ return ` // glow
13248
13463
  vec3 glow = vec3(0);${code}
13249
- color.rgb += glow * ${(strength / taps).toFixed(6)};
13250
- }`;
13464
+ c.rgb += glow * ${(strength / taps).toFixed(6)};`;
13251
13465
  }
13252
13466
 
13253
13467
  /**
13254
- * Set up post processing with a bloom effect, so bright colors and lights glow
13255
- * @param {number} [threshold] - Brightness where the glow starts, 0 is everything and 1 is only pure white
13256
- * @param {number} [strength] - How much glow to add
13257
- * @param {number} [size] - How far the glow spreads in pixels
13258
- * @param {boolean} [includeMainCanvas] - Glow the 2D canvas too, off by default so HUD text stays crisp
13259
- * (a HUD drawn with WebGL in gameRenderPost glows either way, draw it with useWebGL=false)
13260
- * @return {PostProcessPlugin}
13468
+ * Scan lines across the screen, like an old TV
13469
+ * @param {number} [strength] - How dark the lines are, and how bright between them
13470
+ * @param {number} [spacing] - Pixels from one line to the next
13471
+ * @return {string}
13472
+ * @memberof PostProcess
13473
+ */
13474
+ function postProcessScanlines(strength=.5, spacing=6)
13475
+ {
13476
+ return ` // scanlines
13477
+ c.rgb *= 1. - ${postProcessNumber(strength)} * cos(p.y * 6.2832 / ${postProcessNumber(spacing)});`;
13478
+ }
13479
+
13480
+ /**
13481
+ * Static noise over the picture, changing every frame
13482
+ * @param {number} [strength] - How bright the static is
13483
+ * @param {number} [size] - Size of a speck in pixels
13484
+ * @return {string}
13485
+ * @memberof PostProcess
13486
+ */
13487
+ function postProcessNoise(strength=.1, size=2)
13488
+ {
13489
+ return ` // noise
13490
+ vec2 q = fract((floor(p / ${postProcessNumber(size)}) + mod(iTime * 500., 1e3)) * .3197);
13491
+ c.rgb += ${postProcessNumber(strength)} * fract(1. + sin(51. * q.x + 73. * q.y) * 13753.3);`;
13492
+ }
13493
+
13494
+ /**
13495
+ * Darken toward the edges and corners
13496
+ * @param {number} [strength] - How dark the corners get, 1 is black
13497
+ * @param {number} [falloff] - How far in it reaches, low darkens most of the screen, high only the corners
13498
+ * @return {string}
13499
+ * @memberof PostProcess
13500
+ */
13501
+ function postProcessVignette(strength=1, falloff=3)
13502
+ {
13503
+ return ` // vignette
13504
+ vec2 d = uv * 2. - 1.;
13505
+ c.rgb *= 1. - ${postProcessNumber(strength)} * min(1., pow(dot(d, d) / 2., ${postProcessNumber(falloff)}));`;
13506
+ }
13507
+
13508
+ /**
13509
+ * Bend the picture like the bulged glass of an old TV, black past the corners; put it first
13510
+ * @param {number} [strength] - How much it bends
13511
+ * @return {string}
13512
+ * @memberof PostProcess
13513
+ */
13514
+ function postProcessCurve(strength=.1)
13515
+ {
13516
+ return ` // curve
13517
+ vec2 d = uv * 2. - 1.;
13518
+ d *= 1. + ${postProcessNumber(strength)} * dot(d, d);
13519
+ uv = d * .5 + .5;
13520
+ c = all(lessThan(abs(d), vec2(1))) ? texture(iChannel0, uv) : vec4(0, 0, 0, 1);`;
13521
+ }
13522
+
13523
+ /**
13524
+ * Split red and blue apart toward the edges, like a cheap lens; put it before what shades the picture
13525
+ * @param {number} [strength] - How far apart at the edge, as a part of the screen
13526
+ * @return {string}
13527
+ * @memberof PostProcess
13528
+ */
13529
+ function postProcessChromatic(strength=.005)
13530
+ {
13531
+ return ` // chromatic
13532
+ vec2 d = (uv - .5) * ${postProcessNumber(strength)} * 2.;
13533
+ c.r = texture(iChannel0, uv + d).r;
13534
+ c.b = texture(iChannel0, uv - d).b;`;
13535
+ }
13536
+
13537
+ /**
13538
+ * Draw lines where the 3D depth jumps, around objects and along their creases; needs render3D.depthTexture on
13539
+ * @param {Color} [color] - The lines' color, its alpha how strong they are
13540
+ * @param {number} [thickness] - How wide the lines are in pixels
13541
+ * @param {number} [threshold] - How big a jump makes a line, as a part of the distance, lower draws more
13542
+ * @return {string}
13261
13543
  * @memberof PostProcess
13544
+ */
13545
+ function postProcessOutline(color=BLACK, thickness=1, threshold=.02)
13546
+ {
13547
+ ASSERT(isColor(color), 'outline color must be a Color');
13548
+ const n = postProcessNumber;
13549
+ return ` // outline
13550
+ vec2 o = ${n(thickness)} / iResolution.xy;
13551
+ float d = sceneDepth(uv);
13552
+ float dx = abs(sceneDepth(uv + vec2(o.x, 0)) + sceneDepth(uv - vec2(o.x, 0)) - 2. * d);
13553
+ float dy = abs(sceneDepth(uv + vec2(0, o.y)) + sceneDepth(uv - vec2(0, o.y)) - 2. * d);
13554
+ vec4 line = vec4(${n(color.r)}, ${n(color.g)}, ${n(color.b)}, ${n(color.a)});
13555
+ c.rgb = mix(c.rgb, line.rgb, line.a * step(${n(threshold)}, max(dx, dy) / d));`;
13556
+ }
13557
+
13558
+ /**
13559
+ * The look of an old TV, as one effect to use alone or join with others: static noise, scan lines, a soft glow and
13560
+ * a vignette, and a bulged screen when curve is set; any setting at 0 leaves that part out
13561
+ * @param {Object} [settings]
13562
+ * @param {number} [settings.noise] - Static noise strength
13563
+ * @param {number} [settings.scanlines] - Scan line strength
13564
+ * @param {number} [settings.scanlineSpacing] - Pixels from one scan line to the next
13565
+ * @param {number} [settings.glow] - Soft glow strength
13566
+ * @param {number} [settings.vignette] - Vignette strength
13567
+ * @param {number} [settings.curve] - How much the screen bulges, 0 by default for flat
13568
+ * @return {string}
13262
13569
  * @example
13263
- * postProcessBloom(); // in gameInit, after any Render3DPlugin
13570
+ * new PostProcessPlugin(postProcessEffects(postProcessTV({scanlines: .4, curve: .1})));
13571
+ * @memberof PostProcess
13264
13572
  */
13265
- function postProcessBloom(threshold=.6, strength=1, size=6, includeMainCanvas=false)
13266
- { return new PostProcessPlugin(postProcessBloomShader(threshold, strength, size), includeMainCanvas); }
13573
+ function postProcessTV({noise=.1, scanlines=.5, scanlineSpacing=6, glow=.4, vignette=1, curve=0}={})
13574
+ {
13575
+ const parts = [];
13576
+ curve && parts.push(postProcessCurve(curve));
13577
+ glow && parts.push(postProcessGlow(0, glow, 2));
13578
+ scanlines && parts.push(postProcessScanlines(scanlines, scanlineSpacing));
13579
+ noise && parts.push(postProcessNoise(noise, 1));
13580
+ vignette && parts.push(postProcessVignette(vignette, 6));
13581
+ return parts.map((part)=> ' {\n' + part + '\n }').join('\n');
13582
+ }
13267
13583
 
13268
13584
  /**
13269
13585
  * LittleJS Light System Plugin
@@ -13287,6 +13603,8 @@ function postProcessBloom(threshold=.6, strength=1, size=6, includeMainCanvas=fa
13287
13603
  * shadow map through renderShadow(), which calls render() by default; obj.castShadow = false keeps it
13288
13604
  * out (a floor TileLayer, a background), a draw's alpha sets how much light it blocks, and
13289
13605
  * setShadowTransparent lets its color tint the light
13606
+ * - 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
13607
+ * lightmap, so it shows in the dark and sits in front of everything there
13290
13608
  * - Must be constructed BEFORE PostProcessPlugin so post-process sees lit pixels
13291
13609
  * @namespace LightSystem
13292
13610
  */
@@ -13421,6 +13739,7 @@ class LightSystemPlugin
13421
13739
  // again on a context restore, the canvas may have changed since
13422
13740
  if (lightSystem.textureSizeAuto)
13423
13741
  lightSystem.textureSize = mainCanvasSize.copy();
13742
+ lightSystem.clampTextureSizes();
13424
13743
 
13425
13744
  // allocate the lightmap texture with null data at textureSize
13426
13745
  lightSystem.texture = glContext.createTexture();
@@ -13612,6 +13931,7 @@ class LightSystemPlugin
13612
13931
  const ls = lightSystem;
13613
13932
 
13614
13933
  // make the resources the first time, and again when a size changed
13934
+ ls.clampTextureSizes();
13615
13935
  if (!ls.shadowMap || ls.shadowMapSize !== ls.shadowMapSizeAllocated
13616
13936
  || ls.shadowTextureSize !== ls.shadowTextureSizeAllocated)
13617
13937
  {
@@ -13799,8 +14119,14 @@ class LightSystemPlugin
13799
14119
  // is, and any debug text / future draw could sample the lightmap)
13800
14120
  if (glActiveTexture)
13801
14121
  glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
13802
- setAdditiveBlendMode(prevAdditive);
13803
14122
  glSetInstancedMode(true);
14123
+
14124
+ // 7. the lights' glows, added over the lit scene so the darkness does not dim them
14125
+ setAdditiveBlendMode();
14126
+ for (const o of engineObjects)
14127
+ o instanceof Light && !o.destroyed && o.renderGlow();
14128
+ glFlush();
14129
+ setAdditiveBlendMode(prevAdditive);
13804
14130
  }
13805
14131
  function lightSystemContextLost()
13806
14132
  {
@@ -13819,6 +14145,17 @@ class LightSystemPlugin
13819
14145
  }
13820
14146
  }
13821
14147
 
14148
+ /** Bring the sizes of the textures it makes down to what the device can make: shadowMapSize, shadowTextureSize
14149
+ * and a textureSize given by hand; called before they are made, so a size too big falls back instead of failing */
14150
+ clampTextureSizes()
14151
+ {
14152
+ this.shadowMapSize = glClampTextureSize(this.shadowMapSize);
14153
+ this.shadowTextureSize = glClampTextureSize(this.shadowTextureSize);
14154
+ const size = this.textureSize;
14155
+ if (size)
14156
+ this.textureSize = vec2(glClampTextureSize(size.x), glClampTextureSize(size.y));
14157
+ }
14158
+
13822
14159
  /** Draw a single Light's falloff blob into the currently bound lightmap.
13823
14160
  * Called by Light.renderLight() during the plugin's render pass.
13824
14161
  * @param {Light} light */
@@ -13968,6 +14305,14 @@ class Light extends EngineObject
13968
14305
  * or torch that holds it, or the player carrying it, does not block it; it has to reach past that object's
13969
14306
  * corners, about half its diagonal and a little more, or dark rays run out from them */
13970
14307
  this.shadowCore = 0;
14308
+ /** @property {number} - Size across of a soft hazy glow drawn over the light, like a lamp at night, 0 for
14309
+ * none; it is added over the lit scene, in front of everything there */
14310
+ this.glow = 0;
14311
+ /** @property {number} - How fast the glow fades from its middle: 1 by default, .5 a wide haze, 2 a tight
14312
+ * bright core */
14313
+ this.glowFalloff = 1;
14314
+ /** @type {TileInfo|undefined} */
14315
+ this.glowTileInfo = undefined; // the whole glow texture, kept for the falloff it was made for
13971
14316
  }
13972
14317
 
13973
14318
  /** Lights are invisible in the main render pass — they only contribute
@@ -13981,6 +14326,20 @@ class Light extends EngineObject
13981
14326
  {
13982
14327
  lightSystem && lightSystem.drawLight(this);
13983
14328
  }
14329
+
14330
+ /** Draw this light's glow, soft and round, its glow size across and in its color, added over the lit scene;
14331
+ * called by LightSystemPlugin after the lightmap is applied */
14332
+ renderGlow()
14333
+ {
14334
+ if (!(this.glow > 0)) return;
14335
+ const size = vec2(this.glow);
14336
+ if (!isOnScreen(this.pos, size)) return;
14337
+ const texture = engineGlowTexture(this.glowFalloff);
14338
+ if (!texture) return;
14339
+ if (this.glowTileInfo?.textureInfo !== texture)
14340
+ this.glowTileInfo = new TileInfo(vec2(), texture.size, texture);
14341
+ drawTile(this.pos, size, this.glowTileInfo, this.color, 0, false, undefined, true, false);
14342
+ }
13984
14343
  }
13985
14344
 
13986
14345
  /**
@@ -20612,9 +20971,10 @@ function setScene(scene)
20612
20971
 
20613
20972
  if (sceneCurrent)
20614
20973
  {
20974
+ // a leave that throws still lets the next switch through
20615
20975
  sceneLeaving = true;
20616
- sceneCurrent.leave?.();
20617
- sceneLeaving = false;
20976
+ try { sceneCurrent.leave?.(); }
20977
+ finally { sceneLeaving = false; }
20618
20978
  }
20619
20979
  engineObjectsDestroy();
20620
20980
  sceneCurrent = scene;
@@ -21790,15 +22150,7 @@ class Matrix4
21790
22150
  static rotation(euler, matrix=new Matrix4)
21791
22151
  {
21792
22152
  ASSERT_VECTOR3_VALID(euler);
21793
- const cx = cos(euler.x), sx = sin(euler.x);
21794
- const cy = cos(euler.y), sy = sin(euler.y);
21795
- const cz = cos(euler.z), sz = sin(euler.z);
21796
- const m = matrix.m;
21797
- // R = Ry * Rx * Rz written out, column major, every element set so a reused matrix comes out clean
21798
- m[0] = cy*cz + sy*sx*sz; m[1] = cx*sz; m[2] = -sy*cz + cy*sx*sz; m[3] = 0;
21799
- m[4] = -cy*sz + sy*sx*cz; m[5] = cx*cz; m[6] = sy*sz + cy*sx*cz; m[7] = 0;
21800
- m[8] = sy*cx; m[9] = -sx; m[10] = cy*cx; m[11] = 0;
21801
- m[12] = m[13] = m[14] = 0; m[15] = 1;
22153
+ matrix4RotationAt(matrix.m, 0, euler);
21802
22154
  return matrix;
21803
22155
  }
21804
22156
 
@@ -22046,24 +22398,54 @@ class Matrix4
22046
22398
  * @memberof Math3D
22047
22399
  */
22048
22400
  function buildMatrix(pos, rotation, scale, matrix=new Matrix4)
22401
+ {
22402
+ ASSERT(matrix instanceof Matrix4, 'the matrix to write into must be a Matrix4');
22403
+ matrix4Compose(matrix.m, 0, pos, rotation, scale);
22404
+ return matrix;
22405
+ }
22406
+
22407
+ // the rotation matrix of euler angles written into 16 floats of an array at k
22408
+ function matrix4RotationAt(m, k, euler)
22409
+ {
22410
+ const cx = cos(euler.x), sx = sin(euler.x);
22411
+ const cy = cos(euler.y), sy = sin(euler.y);
22412
+ const cz = cos(euler.z), sz = sin(euler.z);
22413
+ // R = Ry * Rx * Rz written out, column major, every element set so a reused matrix comes out clean
22414
+ m[k] = cy*cz + sy*sx*sz; m[k+1] = cx*sz; m[k+2] = -sy*cz + cy*sx*sz; m[k+3] = 0;
22415
+ 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;
22416
+ m[k+8] = sy*cx; m[k+9] = -sx; m[k+10] = cy*cx; m[k+11] = 0;
22417
+ m[k+12] = m[k+13] = m[k+14] = 0; m[k+15] = 1;
22418
+ }
22419
+
22420
+ // the transform buildMatrix makes, written into 16 floats of an array at k, so an instance buffer takes it straight in
22421
+ // the rotation columns are scaled and the position dropped in, instead of multiplying three matrices, each value
22422
+ // worked out whole and stored once; InstancedMesh3D.setTransforms writes the same expressions for many at a time
22423
+ function matrix4Compose(m, k, pos, rotation, scale)
22049
22424
  {
22050
22425
  ASSERT(!pos || isVector3(pos), 'pos must be a Vector3', pos);
22051
22426
  ASSERT(!scale || isVector3(scale), 'scale must be a Vector3', scale);
22052
- ASSERT(matrix instanceof Matrix4, 'the matrix to write into must be a Matrix4');
22053
- // scale the rotation columns and drop the position in, instead of multiplying three matrices
22427
+ const scaleX = scale ? scale.x : 1, scaleY = scale ? scale.y : 1, scaleZ = scale ? scale.z : 1;
22054
22428
  // an object that is not turned at all is most of a big scene, and identity is what the six
22055
22429
  // trig calls would have worked out to anyway
22056
- const turned = rotation && (rotation.x || rotation.y || rotation.z), m = matrix.m;
22057
- turned ? Matrix4.rotation(rotation, matrix) : m.set(matrix4Identity);
22058
- if (scale)
22430
+ if (rotation && (rotation.x || rotation.y || rotation.z))
22059
22431
  {
22060
- m[0] *= scale.x; m[1] *= scale.x; m[2] *= scale.x;
22061
- m[4] *= scale.y; m[5] *= scale.y; m[6] *= scale.y;
22062
- m[8] *= scale.z; m[9] *= scale.z; m[10] *= scale.z;
22432
+ ASSERT_VECTOR3_VALID(rotation);
22433
+ const cx = cos(rotation.x), sx = sin(rotation.x);
22434
+ const cy = cos(rotation.y), sy = sin(rotation.y);
22435
+ const cz = cos(rotation.z), sz = sin(rotation.z);
22436
+ // R = Ry * Rx * Rz written out, column major, as matrix4RotationAt, each column times its scale
22437
+ m[k] = (cy*cz + sy*sx*sz) * scaleX; m[k+1] = cx*sz * scaleX; m[k+2] = (-sy*cz + cy*sx*sz) * scaleX;
22438
+ m[k+4] = (-cy*sz + sy*sx*cz) * scaleY; m[k+5] = cx*cz * scaleY; m[k+6] = (sy*sz + cy*sx*cz) * scaleY;
22439
+ m[k+8] = sy*cx * scaleZ; m[k+9] = -sx * scaleZ; m[k+10] = cy*cx * scaleZ;
22063
22440
  }
22064
- if (pos)
22065
- m[12] = pos.x, m[13] = pos.y, m[14] = pos.z;
22066
- return matrix;
22441
+ else
22442
+ {
22443
+ m[k] = scaleX; m[k+1] = 0; m[k+2] = 0;
22444
+ m[k+4] = 0; m[k+5] = scaleY; m[k+6] = 0;
22445
+ m[k+8] = 0; m[k+9] = 0; m[k+10] = scaleZ;
22446
+ }
22447
+ m[k+3] = m[k+7] = m[k+11] = 0; m[k+15] = 1;
22448
+ m[k+12] = pos ? pos.x : 0, m[k+13] = pos ? pos.y : 0, m[k+14] = pos ? pos.z : 0;
22067
22449
  }
22068
22450
 
22069
22451
  ///////////////////////////////////////////////////////////////////////////////
@@ -22471,6 +22853,9 @@ const RENDER3D_VERTEX_BYTES = RENDER3D_VERTEX_FLOATS * 4;
22471
22853
  // uv rect (12); constants for one draw, one per instance for a batch; the shader derives the normal matrix
22472
22854
  const RENDER3D_INSTANCE_FLOATS = 24;
22473
22855
  const RENDER3D_INSTANCE_BYTES = RENDER3D_INSTANCE_FLOATS * 4;
22856
+ // an InstancedMesh3D keeps them apart: 16 floats of matrix, and 8 of tint and uv rect uploaded only when they change
22857
+ const RENDER3D_MATRIX_FLOATS = 16;
22858
+ const RENDER3D_COLOR_FLOATS = 8;
22474
22859
  const RENDER3D_INSTANCE_ATTRIBS = [[4, 4, 0], [5, 4, 16], [6, 4, 32], [7, 4, 48], [11, 4, 64], [12, 4, 80]];
22475
22860
  const RENDER3D_VERTEX_INPUTS =
22476
22861
  'layout(location=0) in vec3 p;layout(location=1) in vec3 n;layout(location=2) in vec2 t;layout(location=3) in vec4 c;' +
@@ -22514,22 +22899,49 @@ function render3DCanDraw()
22514
22899
  }
22515
22900
 
22516
22901
  // the draw state fields a batch is drawn under; lights and fog are not captured, they are read live at flush
22517
- // the three functions below write them out by hand for speed, so a new field goes in all four; emissiveMapColor is
22518
- // compared by its values, so two objects with equal colors batch
22902
+ // each is an accessor on the plugin over render3D.drawState, and a real change moves render3D.stateVersion, so a
22903
+ // batch checks that one number at each draw while the state holds; the three functions below write the fields out
22904
+ // by hand for speed, so a new field goes in all four, in drawState and gets an accessor; emissiveMapColor is
22905
+ // compared by its rgb, so two objects with equal colors batch
22906
+ /** The draw state's values, behind render3D's accessors, and the rgb emissiveMapColor had when it was set
22907
+ * @typedef {Object} Render3DDrawState
22908
+ * @property {boolean} blend
22909
+ * @property {boolean} additive
22910
+ * @property {boolean} depthTest
22911
+ * @property {boolean} depthWrite
22912
+ * @property {boolean} cullBackFaces
22913
+ * @property {boolean} mirrored
22914
+ * @property {boolean} lighting
22915
+ * @property {number} emissive
22916
+ * @property {boolean} receiveShadow
22917
+ * @property {number} specular
22918
+ * @property {boolean} pixelated
22919
+ * @property {Shader|undefined} shader
22920
+ * @property {TextureInfo|undefined} normalMap
22921
+ * @property {number} normalScale
22922
+ * @property {number} shininess
22923
+ * @property {number} reflectivity
22924
+ * @property {TextureInfo|undefined} emissiveMap
22925
+ * @property {Color} emissiveMapColor
22926
+ * @property {number} emissiveR
22927
+ * @property {number} emissiveG
22928
+ * @property {number} emissiveB
22929
+ * @memberof Render3D */
22519
22930
  const RENDER3D_STATE_FIELDS = ['blend', 'additive', 'depthTest', 'depthWrite', 'cullBackFaces', 'mirrored', 'lighting',
22520
22931
  'emissive', 'receiveShadow', 'specular', 'pixelated', 'shader', 'normalMap', 'normalScale', 'shininess',
22521
22932
  'reflectivity', 'emissiveMap', 'emissiveMapColor'];
22522
22933
 
22523
- // a copy of the draw state in one fixed shape, the fields of RENDER3D_STATE_FIELDS written out so the
22524
- // compare below stays a handful of direct reads, it runs for every instance drawn
22934
+ // a copy of the draw state in one fixed shape, the fields of RENDER3D_STATE_FIELDS written out, and the version
22935
+ // it was taken at
22525
22936
  function render3DCaptureBatchState()
22526
22937
  {
22527
- const r = render3D;
22528
- return {blend: r.blend, additive: r.additive, depthTest: r.depthTest, depthWrite: r.depthWrite,
22529
- cullBackFaces: r.cullBackFaces, mirrored: r.mirrored, lighting: r.lighting, emissive: r.emissive,
22530
- receiveShadow: r.receiveShadow, specular: r.specular, pixelated: r.pixelated, shader: r.shader,
22531
- normalMap: r.normalMap, normalScale: r.normalScale, shininess: r.shininess, reflectivity: r.reflectivity,
22532
- emissiveMap: r.emissiveMap, emissiveMapColor: (r.emissiveMapColor || WHITE).copy()}; // the caller may change it
22938
+ const r = render3D, d = r.drawState;
22939
+ return {blend: d.blend, additive: d.additive, depthTest: d.depthTest, depthWrite: d.depthWrite,
22940
+ cullBackFaces: d.cullBackFaces, mirrored: d.mirrored, lighting: d.lighting, emissive: d.emissive,
22941
+ receiveShadow: d.receiveShadow, specular: d.specular, pixelated: d.pixelated, shader: d.shader,
22942
+ normalMap: d.normalMap, normalScale: d.normalScale, shininess: d.shininess, reflectivity: d.reflectivity,
22943
+ emissiveMap: d.emissiveMap, emissiveMapColor: (d.emissiveMapColor || WHITE).copy(), // the caller may change it
22944
+ version: r.stateVersion};
22533
22945
  }
22534
22946
 
22535
22947
  // put a captured draw state back, written out the same way; the transparent stage does this for every queued draw
@@ -22543,21 +22955,23 @@ function render3DApplyBatchState(s)
22543
22955
  r.reflectivity = s.reflectivity, r.emissiveMap = s.emissiveMap, r.emissiveMapColor = s.emissiveMapColor;
22544
22956
  }
22545
22957
 
22546
- // true when the current draw state differs from a captured one, so a pending batch must flush first
22958
+ // true when the current draw state differs from a captured one, so a pending batch must flush first; it runs for
22959
+ // every instance drawn, so while nothing was set since the capture it checks the version alone
22547
22960
  function render3DStateChanged(s)
22548
22961
  {
22549
22962
  const r = render3D;
22550
- return r.blend !== s.blend || r.additive !== s.additive || r.depthTest !== s.depthTest
22551
- || r.depthWrite !== s.depthWrite || r.cullBackFaces !== s.cullBackFaces || r.mirrored !== s.mirrored
22552
- || r.lighting !== s.lighting || r.emissive !== s.emissive || r.receiveShadow !== s.receiveShadow
22553
- || r.specular !== s.specular || r.pixelated !== s.pixelated || r.shader !== s.shader
22554
- || r.normalMap !== s.normalMap || r.normalScale !== s.normalScale || r.shininess !== s.shininess
22555
- || r.reflectivity !== s.reflectivity || r.emissiveMap !== s.emissiveMap
22556
- || render3DColorChanged(r.emissiveMapColor || WHITE, s.emissiveMapColor);
22557
- }
22558
-
22559
- // whether two colors differ in their rgb, for the batch key's emissiveMapColor
22560
- function render3DColorChanged(a, b) { return a.r !== b.r || a.g !== b.g || a.b !== b.b;
22963
+ if (s.version === r.stateVersion) return false;
22964
+ const d = r.drawState, c = s.emissiveMapColor;
22965
+ if (d.blend !== s.blend || d.additive !== s.additive || d.depthTest !== s.depthTest
22966
+ || d.depthWrite !== s.depthWrite || d.cullBackFaces !== s.cullBackFaces || d.mirrored !== s.mirrored
22967
+ || d.lighting !== s.lighting || d.emissive !== s.emissive || d.receiveShadow !== s.receiveShadow
22968
+ || d.specular !== s.specular || d.pixelated !== s.pixelated || d.shader !== s.shader
22969
+ || d.normalMap !== s.normalMap || d.normalScale !== s.normalScale || d.shininess !== s.shininess
22970
+ || d.reflectivity !== s.reflectivity || d.emissiveMap !== s.emissiveMap
22971
+ || d.emissiveR !== c.r || d.emissiveG !== c.g || d.emissiveB !== c.b)
22972
+ return true;
22973
+ s.version = r.stateVersion; // set and set back to what it was: the batch goes on, checking the version again
22974
+ return false;
22561
22975
  }
22562
22976
 
22563
22977
  // whether a sphere is inside the view, or the shadow map's box during the shadow pass, without a vector
@@ -22635,7 +23049,10 @@ function render3DMaxScale(m)
22635
23049
  // how far a matrix at offset k can move a point that is one unit from its origin, for a bounding sphere: the
22636
23050
  // longest axis when the axes are square to each other, more when they are not, as a turned child under a parent
22637
23051
  // scaled on one axis leaves them; the axes' dot products bound the largest stretch by their largest row sum
22638
- function render3DMaxStretch(m, k=0)
23052
+ function render3DMaxStretch(m, k=0) { return render3DMaxStretchSquared(m, k) ** .5; }
23053
+
23054
+ // the same, squared, for bounds that grow many times before they are read
23055
+ function render3DMaxStretchSquared(m, k=0)
22639
23056
  {
22640
23057
  const xx = m[k]*m[k] + m[k+1]*m[k+1] + m[k+2]*m[k+2];
22641
23058
  const yy = m[k+4]*m[k+4] + m[k+5]*m[k+5] + m[k+6]*m[k+6];
@@ -22643,7 +23060,7 @@ function render3DMaxStretch(m, k=0)
22643
23060
  const xy = abs(m[k]*m[k+4] + m[k+1]*m[k+5] + m[k+2]*m[k+6]);
22644
23061
  const xz = abs(m[k]*m[k+8] + m[k+1]*m[k+9] + m[k+2]*m[k+10]);
22645
23062
  const yz = abs(m[k+4]*m[k+8] + m[k+5]*m[k+9] + m[k+6]*m[k+10]);
22646
- return max(xx + xy + xz, yy + xy + yz, zz + xz + yz) ** .5;
23063
+ return max(xx + xy + xz, yy + xy + yz, zz + xz + yz);
22647
23064
  }
22648
23065
 
22649
23066
  // a quad as a strip from its center and half axes, the same corner order as render3DQuadStrip
@@ -22693,10 +23110,11 @@ function render3DDrawObjects(objects)
22693
23110
  render3DSetObjectState();
22694
23111
  }
22695
23112
 
22696
- // 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
23113
+ // 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,
23114
+ // and under one winding: a mirroring transform, one with a negative determinant, turns it around
22697
23115
  function render3DInstance(mesh, matrix, tileInfo, color)
22698
23116
  {
22699
- const k = render3DInstanceSlot(mesh, render3DTextureOf(tileInfo)), data = mesh.instanceData;
23117
+ const k = render3DInstanceSlot(mesh, render3DTextureOf(tileInfo), render3DMirrors(matrix)), data = mesh.instanceData;
22700
23118
  data.set(matrix.m, k);
22701
23119
  // the tint; this batch is never blended, so in the shadow pass the depth shader cuts it by its texture alpha
22702
23120
  // alone, a see through caster draws blended instead and is cut by its tint alpha too
@@ -22707,14 +23125,18 @@ function render3DInstance(mesh, matrix, tileInfo, color)
22707
23125
 
22708
23126
  // make room for one more instance of a mesh under a texture and the current draw state, flushing a batch that
22709
23127
  // differs first, and return where its 24 floats go in mesh.instanceData: the matrix, the tint and the uv rect
22710
- function render3DInstanceSlot(mesh, textureInfo)
23128
+ // drawMesh passes whether its matrix mirrors, and its batch then culls by the mesh and winds by that at the flush,
23129
+ // so it never sets the draw state for them, which would move the state's version at every draw
23130
+ function render3DInstanceSlot(mesh, textureInfo, mirrored)
22711
23131
  {
22712
23132
  const r = render3D;
22713
- if (mesh.instanceCount && (mesh.instanceTextureInfo !== textureInfo || render3DStateChanged(mesh.instanceState)))
23133
+ if (mesh.instanceCount && (mesh.instanceTextureInfo !== textureInfo || mesh.instanceMirrored !== mirrored
23134
+ || render3DStateChanged(mesh.instanceState)))
22714
23135
  render3DFlushInstances(mesh);
22715
23136
  if (!mesh.instanceCount)
22716
23137
  {
22717
23138
  mesh.instanceTextureInfo = textureInfo;
23139
+ mesh.instanceMirrored = mirrored;
22718
23140
  mesh.instanceState = render3DCaptureBatchState();
22719
23141
  r.instanceMeshes.push(mesh);
22720
23142
  }
@@ -22745,7 +23167,10 @@ function render3DFlushInstances(only)
22745
23167
  const buffers = r.instanceBuffers, buffer = buffers[r.instanceBufferIndex = (r.instanceBufferIndex + 1) % buffers.length];
22746
23168
  gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
22747
23169
  gl.bufferData(gl.ARRAY_BUFFER, mesh.instanceData, gl.DYNAMIC_DRAW, 0, count * RENDER3D_INSTANCE_FLOATS);
22748
- render3DDrawInstanced(mesh, buffer, count, mesh.instanceTextureInfo, mesh.instanceState);
23170
+ const state = mesh.instanceState;
23171
+ if (mesh.instanceMirrored !== undefined) // a drawMesh batch: culled by its mesh, wound by its matrices
23172
+ state.cullBackFaces = !mesh.doubleSided, state.mirrored = mesh.instanceMirrored;
23173
+ render3DDrawInstanced(mesh, buffer, count, mesh.instanceTextureInfo, state);
22749
23174
  }
22750
23175
  if (!only)
22751
23176
  r.instanceMeshes.length = 0;
@@ -22760,13 +23185,16 @@ function render3DFlushInstances(only)
22760
23185
  // the arrays are turned on with their instance divisor for this one call and both are turned off after: a single
22761
23186
  // draw reads these slots as constant attributes, and in Firefox a draw that reads a constant through a slot whose
22762
23187
  // divisor is set leaves the next batch on that slot reading the wrong values
22763
- function render3DDrawInstanced(mesh, buffer, count, textureInfo, state)
23188
+ // colorBuffer holds the tint and uv rect apart from the matrices, as an InstancedMesh3D keeps them
23189
+ function render3DDrawInstanced(mesh, buffer, count, textureInfo, state, colorBuffer)
22764
23190
  {
22765
- const gl = glContext;
22766
- gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
23191
+ const gl = glContext, matrixBytes = RENDER3D_MATRIX_FLOATS * 4;
22767
23192
  for (const [location, size, offset] of RENDER3D_INSTANCE_ATTRIBS)
22768
23193
  {
22769
- gl.vertexAttribPointer(location, size, gl.FLOAT, false, RENDER3D_INSTANCE_BYTES, offset);
23194
+ const inColors = colorBuffer && offset >= matrixBytes;
23195
+ const stride = !colorBuffer ? RENDER3D_INSTANCE_BYTES : inColors ? RENDER3D_COLOR_FLOATS * 4 : matrixBytes;
23196
+ gl.bindBuffer(gl.ARRAY_BUFFER, inColors ? colorBuffer : buffer);
23197
+ gl.vertexAttribPointer(location, size, gl.FLOAT, false, stride, inColors ? offset - matrixBytes : offset);
22770
23198
  gl.enableVertexAttribArray(location);
22771
23199
  gl.vertexAttribDivisor(location, 1);
22772
23200
  }
@@ -22811,11 +23239,36 @@ function render3DCollectLights()
22811
23239
  for (const light of lights)
22812
23240
  distances.set(light, light.directional ? -1 : light.getWorldPos3D().distanceSquared(cameraPos));
22813
23241
  lights.sort((a, b)=> distances.get(a) - distances.get(b));
23242
+ // the light that casts the shadows keeps a slot however far it is, in place of the farthest
23243
+ const caster = render3DShadowCaster();
23244
+ if (caster && lights.indexOf(caster) >= RENDER3D_MAX_LIGHTS)
23245
+ lights[RENDER3D_MAX_LIGHTS - 1] = caster;
22814
23246
  lights.length = RENDER3D_MAX_LIGHTS;
22815
23247
  }
22816
23248
  return lights;
22817
23249
  }
22818
23250
 
23251
+ // the spotlight that casts the shadows in place of the sun: render3D.shadowLight while it is a light that is on
23252
+ // and has a cone, undefined for the sun
23253
+ function render3DShadowCaster()
23254
+ {
23255
+ const light = render3D.shadowLight;
23256
+ return light && !light.destroyed && !light.directional && light.coneAngle > 0 && light.radius > 0 &&
23257
+ light.intensity > 0 && light.color.a > 0 ? light : undefined;
23258
+ }
23259
+
23260
+ // a light's cone as the four numbers the shader gets: the way it shines, scaled so that its dot with the way to
23261
+ // 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
23262
+ // gets numbers that make it 1 every way
23263
+ function render3DLightCone(light)
23264
+ {
23265
+ const angle = light.directional ? 0 : min(light.coneAngle, PI);
23266
+ if (!(angle > 0)) return [0, 0, 0, -1];
23267
+ const outer = cos(angle), inner = cos(angle * (1 - clamp(light.coneSoftness)));
23268
+ const k = 1 / max(inner - outer, 1e-4), forward = light.getForward3D();
23269
+ return [forward.x * k, forward.y * k, forward.z * k, outer * k];
23270
+ }
23271
+
22819
23272
  // unit circle directions for a number of sides, [cos, sin, cos, sin, ...] including the closing point, cached
22820
23273
  const render3DCircleCache = new Map;
22821
23274
  function render3DCircle(sides)
@@ -22867,7 +23320,15 @@ class Render3DPlugin
22867
23320
  {
22868
23321
  ASSERT(!render3D, 'Render3D plugin already initialized');
22869
23322
  render3D = this;
22870
- ASSERT(Object.keys(render3DCaptureBatchState()).join() === RENDER3D_STATE_FIELDS.join(),
23323
+ // the draw state, read at each draw through the accessors below the constructor
23324
+ /** @type {Render3DDrawState} */
23325
+ this.drawState = {blend: false, additive: false, depthTest: true, depthWrite: true, cullBackFaces: false,
23326
+ mirrored: false, lighting: true, emissive: 0, receiveShadow: true, specular: 0, pixelated: false,
23327
+ shader: undefined, normalMap: undefined, normalScale: 1, shininess: 16, reflectivity: 0,
23328
+ emissiveMap: undefined, emissiveMapColor: WHITE, emissiveR: 1, emissiveG: 1, emissiveB: 1};
23329
+ /** @property {number} - Goes up when a draw state field changes, so a batch sees at a glance that none did */
23330
+ this.stateVersion = 0;
23331
+ ASSERT(Object.keys(render3DCaptureBatchState()).join() === [...RENDER3D_STATE_FIELDS, 'version'].join(),
22871
23332
  'the batch state functions must list RENDER3D_STATE_FIELDS');
22872
23333
 
22873
23334
  /** @property {Camera3D} - The camera */
@@ -22913,8 +23374,16 @@ class Render3DPlugin
22913
23374
  // shadows
22914
23375
  /** @property {boolean} - Cast real shadows from the sun, off by default and free when off */
22915
23376
  this.shadows = false;
23377
+ /** @property {number} - How far a sound played with playSound is heard when it was made with no range of
23378
+ * its own, in world units; further than the 2D soundDefaultRange, a 3D world is bigger */
23379
+ this.soundDefaultRange = 100;
22916
23380
  /** @property {number} - Size of the shadow map in pixels, bigger is sharper and slower */
22917
23381
  this.shadowMapSize = 1024;
23382
+ /** @property {Light3D|undefined} - A spotlight, a Light3D with a coneAngle, to cast the shadows in place
23383
+ * of the sun, a flashlight in the dark: the shadow map looks down its cone, as far as its radius, and the
23384
+ * sun still lights the scene but casts none; undefined for the sun, as is a light that is off or has no cone
23385
+ * @type {Light3D|undefined} */
23386
+ this.shadowLight = undefined;
22918
23387
  /** @property {number} - World size the shadow map covers around shadowCenter, smaller is sharper; it is a
22919
23388
  * square facing the light, so it turns as the light does, and about 1.5 times an area's width covers it from
22920
23389
  * any angle */
@@ -22927,50 +23396,6 @@ class Render3DPlugin
22927
23396
  /** @property {number} - How much to blur the shadow edges */
22928
23397
  this.shadowSoftness = 1;
22929
23398
 
22930
- // draw state, read at each draw
22931
- /** @property {boolean} - Apply lighting, when false draws plain vertex color times texture and casts no shadow;
22932
- * off for billboards, lines, ribbons and soft discs, an object sets emissive instead */
22933
- this.lighting = true;
22934
- /** @property {number} - How much a surface lights itself, set per object by its emissive */
22935
- this.emissive = 0;
22936
- /** @property {boolean} - Additive blending instead of alpha, in the transparent stage */
22937
- this.additive = false;
22938
- /** @property {boolean} - Test against the depth buffer, reset to true before each object and callback; a draw
22939
- * with it off goes over what was drawn before it and under what is drawn after, by render order, which
22940
- * ends the batch of meshes before it, so one per object costs a draw per object */
22941
- this.depthTest = true;
22942
- /** @property {boolean} - Write to the depth buffer, owned by the stages: on for opaque, off for transparent */
22943
- this.depthWrite = true;
22944
- // batch state set by drawMesh from each mesh: whether its back faces are skipped, off for strips so they
22945
- // show from both sides, and whether its transform mirrors it so the other winding is the front
22946
- this.cullBackFaces = false;
22947
- this.mirrored = false;
22948
- /** @property {number} - Strength of the highlight where the sun and the Light3D objects reflect, 0 is none and
22949
- * 1 adds a light's full color at its brightest; shininess sets its size */
22950
- this.specular = 0;
22951
- /** @property {number} - The highlight's exponent, how small and sharp it is: 4 is broad like rubber, 100 sharp
22952
- * like polished metal; set from each object's shininess */
22953
- this.shininess = 16;
22954
- /** @property {TextureInfo|undefined} - Normal map for the next draws, set from each object's normalMap
22955
- * @type {TextureInfo|undefined} */
22956
- this.normalMap = undefined;
22957
- /** @property {number} - How strongly the normal map bends the surface, set from each object's normalScale */
22958
- this.normalScale = 1;
22959
- /** @property {number} - How much the surface reflects the sky, 0 to 1, set from each object's reflectivity */
22960
- this.reflectivity = 0;
22961
- /** @property {TextureInfo|undefined} - Emissive map for the next draws, set from each object's emissiveMap
22962
- * @type {TextureInfo|undefined} */
22963
- this.emissiveMap = undefined;
22964
- /** @property {Color} - Multiplies the emissive map, set from each object's emissiveMapColor */
22965
- this.emissiveMapColor = WHITE;
22966
- /** @property {Shader|undefined} - Custom Shader for the next draws, set from each object's shader; undefined
22967
- * draws with the plugin's own
22968
- * @type {Shader|undefined} */
22969
- this.shader = undefined;
22970
- /** @property {boolean} - Darken by the shadow map when shadows are on, turn it off for things that should stay
22971
- * lit inside a shadow */
22972
- this.receiveShadow = true;
22973
-
22974
23399
  // the pass
22975
23400
  /** @property {Function|undefined} - Draw solid world here, it runs again for shadows so only draw in it
22976
23401
  * @type {Function|undefined} */
@@ -22993,9 +23418,6 @@ class Render3DPlugin
22993
23418
  /** @property {boolean} - Sample textures through mipmaps so they do not shimmer in the distance, false uses
22994
23419
  * each texture's own filtering like 2D */
22995
23420
  this.mipmaps = true;
22996
- /** @property {boolean} - Draw state: keep texture pixels hard edged, no mipmaps and no blending between them,
22997
- * set per object by pixelated */
22998
- this.pixelated = false;
22999
23421
  /** @property {number} - Anisotropic filtering for textures seen at an angle, 1 to 16, 1 is off; needs mipmaps */
23000
23422
  this.anisotropy = 4;
23001
23423
 
@@ -23039,7 +23461,6 @@ class Render3DPlugin
23039
23461
  this.cameraBack = vec3(0, 0, 1); // its opposite, the normal of camera facing draws
23040
23462
 
23041
23463
  // internal state
23042
- this.blend = false; // blending on, set by the stages
23043
23464
  /** @type {Array<Array<number>>} */
23044
23465
  this.frustumPlanes = []; // the view as six inward planes [x, y, z, w]
23045
23466
  /** @type {Array<Array<number>>} */
@@ -23079,6 +23500,9 @@ class Render3DPlugin
23079
23500
  this.passIsDefault = true; // the running pass is the default layer, the only one shadowed
23080
23501
  this.lightPositions = new Float32Array(RENDER3D_MAX_LIGHTS * 4); // Light3D uniforms, filled each pass
23081
23502
  this.lightColors = new Float32Array(RENDER3D_MAX_LIGHTS * 4);
23503
+ this.lightCones = new Float32Array(RENDER3D_MAX_LIGHTS * 4); // each light's cone, see render3DLightCone
23504
+ this.shadowLightIndex = -1; // which of the lights sent casts the shadows, -1 for the sun
23505
+ this.shadowDepthBias = 0; // the bias as the shadow lookup takes it, set with the shadow matrix
23082
23506
 
23083
23507
  // the stream of immediate mode draws
23084
23508
  /** @type {WebGLBuffer|undefined} */
@@ -23106,6 +23530,116 @@ class Render3DPlugin
23106
23530
  engineAddPlugin(undefined, render3DRender, render3DContextLost, render3DContextRestored, render3DPreRender);
23107
23531
  }
23108
23532
 
23533
+ ///////////////////////////////////////////////////////////////////////////
23534
+ // Draw state: each field an accessor over drawState, whose setter moves stateVersion only on a real change
23535
+
23536
+ /** Apply lighting, when false draws plain vertex color times texture and casts no shadow;
23537
+ * off for billboards, lines, ribbons and soft discs, an object sets emissive instead
23538
+ * @return {boolean} */
23539
+ get lighting() { return this.drawState.lighting; }
23540
+ set lighting(v) { const d = this.drawState; d.lighting === v || (d.lighting = v, ++this.stateVersion); }
23541
+
23542
+ /** How much a surface lights itself, set per object by its emissive
23543
+ * @return {number} */
23544
+ get emissive() { return this.drawState.emissive; }
23545
+ set emissive(v) { const d = this.drawState; d.emissive === v || (d.emissive = v, ++this.stateVersion); }
23546
+
23547
+ /** Additive blending instead of alpha, in the transparent stage
23548
+ * @return {boolean} */
23549
+ get additive() { return this.drawState.additive; }
23550
+ set additive(v) { const d = this.drawState; d.additive === v || (d.additive = v, ++this.stateVersion); }
23551
+
23552
+ /** Test against the depth buffer, reset to true before each object and callback; a draw
23553
+ * with it off goes over what was drawn before it and under what is drawn after, by render order, which
23554
+ * ends the batch of meshes before it, so one per object costs a draw per object
23555
+ * @return {boolean} */
23556
+ get depthTest() { return this.drawState.depthTest; }
23557
+ set depthTest(v) { const d = this.drawState; d.depthTest === v || (d.depthTest = v, ++this.stateVersion); }
23558
+
23559
+ /** Write to the depth buffer, owned by the stages: on for opaque, off for transparent
23560
+ * @return {boolean} */
23561
+ get depthWrite() { return this.drawState.depthWrite; }
23562
+ set depthWrite(v) { const d = this.drawState; d.depthWrite === v || (d.depthWrite = v, ++this.stateVersion); }
23563
+
23564
+ /** Skip back faces, set by drawMesh from each mesh: off for strips so they show from
23565
+ * both sides
23566
+ * @return {boolean} */
23567
+ get cullBackFaces() { return this.drawState.cullBackFaces; }
23568
+ set cullBackFaces(v) { const d = this.drawState; d.cullBackFaces === v || (d.cullBackFaces = v, ++this.stateVersion); }
23569
+
23570
+ /** The transform mirrors the draw, so the other winding is the front, set by drawMesh
23571
+ * from each transform
23572
+ * @return {boolean} */
23573
+ get mirrored() { return this.drawState.mirrored; }
23574
+ set mirrored(v) { const d = this.drawState; d.mirrored === v || (d.mirrored = v, ++this.stateVersion); }
23575
+
23576
+ /** Strength of the highlight where the sun and the Light3D objects reflect, 0 is none and
23577
+ * 1 adds a light's full color at its brightest; shininess sets its size
23578
+ * @return {number} */
23579
+ get specular() { return this.drawState.specular; }
23580
+ set specular(v) { const d = this.drawState; d.specular === v || (d.specular = v, ++this.stateVersion); }
23581
+
23582
+ /** The highlight's exponent, how small and sharp it is: 4 is broad like rubber, 100 sharp
23583
+ * like polished metal; set from each object's shininess
23584
+ * @return {number} */
23585
+ get shininess() { return this.drawState.shininess; }
23586
+ set shininess(v) { const d = this.drawState; d.shininess === v || (d.shininess = v, ++this.stateVersion); }
23587
+
23588
+ /** Normal map for the next draws, set from each object's normalMap
23589
+ * @return {TextureInfo|undefined} */
23590
+ get normalMap() { return this.drawState.normalMap; }
23591
+ set normalMap(v) { const d = this.drawState; d.normalMap === v || (d.normalMap = v, ++this.stateVersion); }
23592
+
23593
+ /** How strongly the normal map bends the surface, set from each object's normalScale
23594
+ * @return {number} */
23595
+ get normalScale() { return this.drawState.normalScale; }
23596
+ set normalScale(v) { const d = this.drawState; d.normalScale === v || (d.normalScale = v, ++this.stateVersion); }
23597
+
23598
+ /** How much the surface reflects the sky, 0 to 1, set from each object's reflectivity
23599
+ * @return {number} */
23600
+ get reflectivity() { return this.drawState.reflectivity; }
23601
+ set reflectivity(v) { const d = this.drawState; d.reflectivity === v || (d.reflectivity = v, ++this.stateVersion); }
23602
+
23603
+ /** Emissive map for the next draws, set from each object's emissiveMap
23604
+ * @return {TextureInfo|undefined} */
23605
+ get emissiveMap() { return this.drawState.emissiveMap; }
23606
+ set emissiveMap(v) { const d = this.drawState; d.emissiveMap === v || (d.emissiveMap = v, ++this.stateVersion); }
23607
+
23608
+ /** Multiplies the emissive map, set from each object's emissiveMapColor; compared by its rgb, so a Color
23609
+ * changed in place is seen when it is set again, and undefined is white
23610
+ * @return {Color} */
23611
+ get emissiveMapColor() { return this.drawState.emissiveMapColor; }
23612
+ set emissiveMapColor(v)
23613
+ {
23614
+ const d = this.drawState, c = v || WHITE;
23615
+ d.emissiveMapColor = v;
23616
+ if (c.r !== d.emissiveR || c.g !== d.emissiveG || c.b !== d.emissiveB)
23617
+ d.emissiveR = c.r, d.emissiveG = c.g, d.emissiveB = c.b, ++this.stateVersion;
23618
+ }
23619
+
23620
+ /** Custom Shader for the next draws, set from each object's shader; undefined draws
23621
+ * with the plugin's own
23622
+ * @return {Shader|undefined} */
23623
+ get shader() { return this.drawState.shader; }
23624
+ set shader(v) { const d = this.drawState; d.shader === v || (d.shader = v, ++this.stateVersion); }
23625
+
23626
+ /** Darken by the shadow map when shadows are on, turn it off for things that should
23627
+ * stay lit inside a shadow
23628
+ * @return {boolean} */
23629
+ get receiveShadow() { return this.drawState.receiveShadow; }
23630
+ set receiveShadow(v) { const d = this.drawState; d.receiveShadow === v || (d.receiveShadow = v, ++this.stateVersion); }
23631
+
23632
+ /** Keep texture pixels hard edged, no mipmaps and no blending between them, set per
23633
+ * object by pixelated
23634
+ * @return {boolean} */
23635
+ get pixelated() { return this.drawState.pixelated; }
23636
+ set pixelated(v) { const d = this.drawState; d.pixelated === v || (d.pixelated = v, ++this.stateVersion); }
23637
+
23638
+ /** Blending on, set by the stages
23639
+ * @return {boolean} */
23640
+ get blend() { return this.drawState.blend; }
23641
+ set blend(v) { const d = this.drawState; d.blend === v || (d.blend = v, ++this.stateVersion); }
23642
+
23109
23643
  ///////////////////////////////////////////////////////////////////////////
23110
23644
  // Matrices and picking
23111
23645
 
@@ -23215,7 +23749,8 @@ class Render3DPlugin
23215
23749
  }
23216
23750
 
23217
23751
  /** Play a sound at a 3D position, quieter with distance from the camera and panned by its side, like Sound.play
23218
- * with a 2D position
23752
+ * with a 2D position; a sound made with no range of its own is heard to soundDefaultRange, 100, and one given
23753
+ * a range keeps it
23219
23754
  * @param {Sound} sound
23220
23755
  * @param {Vector3} pos3D
23221
23756
  * @param {number} [volume]
@@ -23229,7 +23764,9 @@ class Render3DPlugin
23229
23764
  // the 3D range fade and pan, Sound.play with no position does the rest
23230
23765
  ASSERT(sound instanceof Sound, 'sound must be a Sound');
23231
23766
  ASSERT(isVector3(pos3D), 'pos3D must be a vec3');
23232
- const offset = pos3D.subtract(this.camera.pos), range = sound.range;
23767
+ // a sound with no range of its own is heard further in 3D than in 2D
23768
+ const offset = pos3D.subtract(this.camera.pos);
23769
+ const range = sound.rangeIsDefault ? this.soundDefaultRange : sound.range;
23233
23770
  if (range)
23234
23771
  {
23235
23772
  const distance = offset.length();
@@ -23293,15 +23830,15 @@ class Render3DPlugin
23293
23830
  if (this.frustumCulling && !render3DSphereVisible(m[12], m[13], m[14], mesh.radius * render3DMaxStretch(m)))
23294
23831
  return;
23295
23832
  // the mesh says whether its back faces can be skipped, and a mirroring transform, one with a negative
23296
- // determinant, turns the winding around so the other one is its front
23297
- const cullBackFaces = this.cullBackFaces, mirrored = this.mirrored;
23298
- this.cullBackFaces = !mesh.doubleSided;
23299
- this.mirrored = render3DMirrors(matrix);
23300
- // the stage draws the batch at its end
23833
+ // determinant, turns the winding around so the other one is its front; the stage draws a batch at its
23834
+ // end, which keys on those itself
23301
23835
  if (!this.blend && this.depthTest && (mesh.instanced ?? this.instancing))
23302
23836
  render3DInstance(mesh, matrix, tileInfo, color);
23303
23837
  else
23304
23838
  {
23839
+ const cullBackFaces = this.cullBackFaces, mirrored = this.mirrored;
23840
+ this.cullBackFaces = !mesh.doubleSided;
23841
+ this.mirrored = render3DMirrors(matrix);
23305
23842
  this.flush();
23306
23843
  render3DFlushBeforeOverlay();
23307
23844
  render3DSetDrawUniforms(matrix, tileInfo, color);
@@ -23309,8 +23846,8 @@ class Render3DPlugin
23309
23846
  glContext.drawElements(glContext.TRIANGLES, mesh.bufferCount, mesh.indexType, 0);
23310
23847
  ++drawCount;
23311
23848
  primitiveCount += mesh.bufferCount / 3;
23849
+ this.cullBackFaces = cullBackFaces, this.mirrored = mirrored;
23312
23850
  }
23313
- this.cullBackFaces = cullBackFaces, this.mirrored = mirrored;
23314
23851
  }
23315
23852
 
23316
23853
  /** Draw a triangle strip, batched into the stream with the current draw state
@@ -23506,6 +24043,23 @@ class Render3DPlugin
23506
24043
  /** Rebuild the light's view projection around the shadow center, called automatically each frame shadows are on */
23507
24044
  updateShadowMatrix()
23508
24045
  {
24046
+ const caster = render3DShadowCaster();
24047
+ if (caster)
24048
+ {
24049
+ // a spotlight's map looks down its cone with perspective, from just in front of it out to its radius
24050
+ const pos = caster.getWorldPos3D(), forward = caster.getForward3D();
24051
+ const up = abs(forward.y) > .99 ? vec3(0, 0, 1) : vec3(0, 1, 0);
24052
+ const far = caster.radius, near = max(far / 500, .02);
24053
+ const fov = min(min(caster.coneAngle, PI) * 2 + .1, 2.8);
24054
+ this.shadowMatrix = Matrix4.perspective(fov, 1, near, far).multiply(
24055
+ Matrix4.lookAt(pos, pos.add(forward), up).invert());
24056
+ this.shadowPlanes = render3DFrustumPlanes(this.shadowMatrix);
24057
+ // depth is not even with perspective: the lookup divides this by the distance squared, which makes
24058
+ // the bias the same distance in the world near the light and far from it
24059
+ this.shadowDepthBias = this.shadowBias * far * far * near / (far - near);
24060
+ return;
24061
+ }
24062
+ this.shadowDepthBias = this.shadowBias; // the sun's map is flat, its depth even
23509
24063
  ASSERT(this.shadowRange > 0, 'shadowRange must be positive');
23510
24064
  const range = this.shadowRange > 0 ? this.shadowRange : 1, half = range / 2;
23511
24065
  const toSun = this.sunDirection.normalize();
@@ -23935,6 +24489,7 @@ function render3DFragmentSource(fragmentCode)
23935
24489
  'precision highp float;' +
23936
24490
  'uniform vec4 lightDir,lightColor,ambientFog,ambientGround,fogColor,shadowParams;' +
23937
24491
  'uniform vec4 extraLights[' + RENDER3D_MAX_LIGHTS + '],extraLightColors[' + RENDER3D_MAX_LIGHTS + '];' +
24492
+ 'uniform vec4 extraLightCones[' + RENDER3D_MAX_LIGHTS + '];' +
23938
24493
  'uniform int extraLightCount;' +
23939
24494
  'uniform vec3 cameraPos;' +
23940
24495
  'uniform vec4 materialParams,emissiveTint,skyTop,skyHorizon,skyBottom;' +
@@ -23943,12 +24498,14 @@ function render3DFragmentSource(fragmentCode)
23943
24498
  'uniform highp sampler2DShadow shadowMap;' +
23944
24499
  'in vec3 P,N;in vec2 T,L;in vec4 C,S;' +
23945
24500
  'out vec4 o;' +
23946
- // the sun shadow at this fragment, 0 to 1: the light's depth map with a 3x3 blur, outside the map is lit
24501
+ // the shadow at this fragment, 0 to 1: the light's depth map with a 3x3 blur, outside the map is lit; the
24502
+ // 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
24503
+ // bias shrinks by its square to stay the same distance in the world
23947
24504
  'float shadow(){' +
23948
- 'if(shadowParams.x<=0.)return 1.;' +
24505
+ 'if(shadowParams.x<=0.||S.w<=0.)return 1.;' +
23949
24506
  'vec3 q=S.xyz/S.w*.5+.5;' +
23950
24507
  'if(any(greaterThanEqual(abs(q-.5),vec3(.5))))return 1.;' +
23951
- 'q.z-=shadowParams.y;' +
24508
+ 'q.z-=shadowParams.y/(S.w*S.w);' +
23952
24509
  'float s=0.;' +
23953
24510
  'for(int x=-1;x<=1;++x)for(int y=-1;y<=1;++y)' +
23954
24511
  's+=texture(shadowMap,vec3(q.xy+vec2(x,y)*shadowParams.z,q.z));' +
@@ -23976,7 +24533,9 @@ function render3DFragmentSource(fragmentCode)
23976
24533
  'if(!gl_FrontFacing)n=-n;' + // only a double sided mesh shows a back face, light it on the side that is seen
23977
24534
  'if(materialParams.x!=0.)n=normalMapNormal(n);' +
23978
24535
  'float nl=dot(n,-lightDir.xyz);' +
23979
- 'float s=shadow();' +
24536
+ // the shadow is the sun's, or one spotlight's: shadowParams.x is 1 for the sun, 2 and up for that Light3D
24537
+ 'float sh=shadow(),s=shadowParams.x<1.5?sh:1.;' +
24538
+ 'int si=int(shadowParams.x+.5)-2;' +
23980
24539
  // the ambient: one color, or blended from the ground color below to the sky color above by the way the face points
23981
24540
  'vec3 l=(ambientGround.a>0.?mix(ambientGround.rgb,ambientFog.rgb,n.y*.5+.5):ambientFog.rgb)+lightColor.rgb*max(nl,0.)*s;' +
23982
24541
  // the Light3D objects: a point light falls off with distance, a directional one does not and carries the
@@ -23992,6 +24551,11 @@ function render3DFragmentSource(fragmentCode)
23992
24551
  'v/=max(d,1e-6);' +
23993
24552
  'float ln=dot(n,v);' +
23994
24553
  'vec3 lc=extraLightColors[i].rgb*extraLightColors[i].a*a*a;' +
24554
+ // a spotlight's cone: full where its fade starts, nothing at its edge, smooth between; 1 with no cone
24555
+ 'vec4 K=extraLightCones[i];' +
24556
+ 'float k=clamp(dot(K.xyz,-v)-K.w,0.,1.);' +
24557
+ 'lc*=k*k*(3.-2.*k);' +
24558
+ 'if(i==si)lc*=sh;' +
23995
24559
  'l+=lc*max(0.,ln);' +
23996
24560
  'if(lightColor.a>0.)sp+=lc*pow(max(dot(reflect(-v,n),eye),0.),materialParams.y)*step(0.,ln);' +
23997
24561
  '}' +
@@ -24046,6 +24610,7 @@ function render3DUseProgram(program)
24046
24610
  {
24047
24611
  gl.uniform4fv(render3DUniform('extraLights'), r.lightPositions, 0, r.lightCount * 4);
24048
24612
  gl.uniform4fv(render3DUniform('extraLightColors'), r.lightColors, 0, r.lightCount * 4);
24613
+ gl.uniform4fv(render3DUniform('extraLightCones'), r.lightCones, 0, r.lightCount * 4);
24049
24614
  }
24050
24615
  if (program !== r.program)
24051
24616
  {
@@ -24186,18 +24751,19 @@ function render3DUpdateSamplers()
24186
24751
  r.samplers.clear();
24187
24752
  }
24188
24753
 
24189
- // the sampler for a texture's wrap modes, smooth or hard edged, made the first time it is needed
24190
- function render3DSampler(wrap, pixelated)
24754
+ // the sampler for a texture's wrap modes, smooth or hard edged, made the first time it is needed; pixelated is the
24755
+ // draw's, texturePixelated the texture's own, which a smooth texture in a pixel art game sets false
24756
+ function render3DSampler(wrap, pixelated, texturePixelated=tilesPixelated)
24191
24757
  {
24192
24758
  const gl = glContext, r = render3D, [wrapS, wrapT] = glWrapModes(wrap);
24193
- const key = wrapS * 1e5 + wrapT * 2 + (pixelated ? 1 : 0); // the modes are 5 digit numbers
24759
+ const key = wrapS * 1e5 + wrapT * 4 + (pixelated ? 1 : 0) + (texturePixelated ? 2 : 0); // the modes are 5 digit numbers
24194
24760
  let sampler = r.samplers.get(key);
24195
24761
  if (sampler) return sampler;
24196
24762
  sampler = gl.createSampler();
24197
- const sharp = pixelated || tilesPixelated;
24763
+ const sharp = pixelated || texturePixelated;
24198
24764
  gl.samplerParameteri(sampler, gl.TEXTURE_MAG_FILTER, sharp ? gl.NEAREST : gl.LINEAR);
24199
24765
  gl.samplerParameteri(sampler, gl.TEXTURE_MIN_FILTER, pixelated ? gl.NEAREST
24200
- : tilesPixelated ? gl.NEAREST_MIPMAP_LINEAR : gl.LINEAR_MIPMAP_LINEAR);
24766
+ : texturePixelated ? gl.NEAREST_MIPMAP_LINEAR : gl.LINEAR_MIPMAP_LINEAR);
24201
24767
  gl.samplerParameteri(sampler, gl.TEXTURE_WRAP_S, wrapS);
24202
24768
  gl.samplerParameteri(sampler, gl.TEXTURE_WRAP_T, wrapT);
24203
24769
  const anisotropy = gl.getExtension('EXT_texture_filter_anisotropic');
@@ -24225,7 +24791,7 @@ function render3DBindTexture(tileInfo, state=render3D, unit=0)
24225
24791
  // or anisotropy, and filtering it that way costs every untextured fragment
24226
24792
  else
24227
24793
  {
24228
- gl.bindSampler(unit, render3DSampler(textureInfo?.wrap, state.pixelated));
24794
+ gl.bindSampler(unit, render3DSampler(textureInfo?.wrap, state.pixelated, textureInfo?.pixelated ?? tilesPixelated));
24229
24795
  glUpdateMipmaps(texture); // drawn into since its mipmaps were made
24230
24796
  if (!state.pixelated && !glMipmappedTextures.has(texture)) // a hard edged draw never reads them
24231
24797
  {
@@ -24405,7 +24971,9 @@ function render3DSetDrawUniforms(matrix, tileInfo, tint, uvRect, state=render3D)
24405
24971
  // 0 blends them away instead, and -1 is additive, which has to fade into fog differently
24406
24972
  const blendMode = state.blend ? (state.additive ? -1 : 0) : 1;
24407
24973
  const receives = r.shadows && r.passIsDefault && state.receiveShadow ? 1 : 0;
24408
- render3DUniform4f('shadowParams', receives, r.shadowBias, r.shadowSoftness / r.shadowTextureSize, blendMode);
24974
+ // 1 the sun's shadow, 2 and up the shadow of that Light3D, the spotlight that casts them
24975
+ const shadowOf = receives && r.shadowLightIndex >= 0 ? 2 + r.shadowLightIndex : receives;
24976
+ render3DUniform4f('shadowParams', shadowOf, r.shadowDepthBias, r.shadowSoftness / r.shadowTextureSize, blendMode);
24409
24977
 
24410
24978
  // a render target's texture holds premultiplied color, the blend writes it that way, so the shader undoes it
24411
24979
  const textureInfo = render3DTextureOf(tileInfo);
@@ -24471,6 +25039,8 @@ function render3DRenderPass(after2D)
24471
25039
  // gathered before the gl state is taken over, so an assert here leaves nothing to hand back
24472
25040
  const lights = render3DCollectLights();
24473
25041
  r.lightCount = lights.length;
25042
+ const caster = render3DShadowCaster();
25043
+ r.shadowLightIndex = caster ? lights.indexOf(caster) : -1;
24474
25044
  const positions = r.lightPositions, colors = r.lightColors;
24475
25045
  lights.forEach((light, i)=>
24476
25046
  {
@@ -24481,6 +25051,7 @@ function render3DRenderPass(after2D)
24481
25051
  positions[k] = p.x, positions[k+1] = p.y, positions[k+2] = p.z;
24482
25052
  positions[k+3] = light.directional ? -1 : max(0, light.radius); // a negative radius marks a direction
24483
25053
  colors[k] = c.r, colors[k+1] = c.g, colors[k+2] = c.b, colors[k+3] = c.a * light.intensity;
25054
+ r.lightCones.set(render3DLightCone(light), k);
24484
25055
  });
24485
25056
 
24486
25057
  // take over the gl state
@@ -24552,6 +25123,7 @@ function render3DUpdateShadowMap(size)
24552
25123
  {
24553
25124
  const gl = glContext, r = render3D;
24554
25125
  ASSERT(size > 0, 'shadowMapSize must be positive');
25126
+ size = glClampTextureSize(size); // a size the device does not have falls back to the largest it has
24555
25127
  if (r.shadowTexture && r.shadowTextureSize === size) return;
24556
25128
  r.shadowTexture && gl.deleteTexture(r.shadowTexture);
24557
25129
  r.shadowFramebuffer && gl.deleteFramebuffer(r.shadowFramebuffer);
@@ -26604,20 +27176,24 @@ function engineObjectsCallback3D(pos, size, callback, objects=engineObjects, tes
26604
27176
  * InstancedMesh3D - Many copies of one mesh drawn as one call, with their transforms kept on the GPU
26605
27177
  * - For big sets that mostly stay put: an instance costs nothing per frame until it changes, so a hundred thousand
26606
27178
  * trees cost what one tree does; objects and drawMesh batch by themselves too, but rebuild their batch every frame
26607
- * - setMatrixAt and setColorAt change one instance, and only the changed range uploads before the next draw
27179
+ * - setTransformAt, setMatrixAt and setColorAt change one instance, and only the changed range uploads before the
27180
+ * next draw; the matrices and the colors are kept apart, so moving instances uploads 64 bytes each
27181
+ * - For many instances moving every frame, setTransforms places a run of them from arrays of positions, rotations
27182
+ * and scales in one loop, the fastest way; or write matrixData directly and call markDirtyRange
26608
27183
  * - The instances are in world space; the object's own pos3D, rotation3D and scale3D do not move them
26609
- * - The whole set is culled by one bounding sphere around the origin, and casts and receives shadows like any object
27184
+ * - The whole set is culled by one bounding sphere around the origin, worked out when culling reads it, or set by hand
27185
+ * with radius; it casts and receives shadows like any object
26610
27186
  * - The object's flags cover the whole set, one emissive, one tileInfo, one shader; only the colors are per instance
26611
27187
  * - A mirrored instance, one with a negative scale, shows its inside unless the mesh is doubleSided
26612
27188
  * - A transparent set draws in one go in the transparent stage, its instances are not sorted against each other;
26613
27189
  * the set sorts against other transparent draws by the object's position, so put pos3D at its middle
26614
- * - pick, the raycast and the collect helpers do not see the instances, test them yourself from instanceData
27190
+ * - pick, the raycast and the collect helpers do not see the instances, test them yourself from matrixData
26615
27191
  * @extends EngineObject3D
26616
27192
  * @memberof Render3D
26617
27193
  * @example
26618
27194
  * const forest = new InstancedMesh3D(treeMesh, 1000);
26619
27195
  * for (let i = 0; i < 1000; ++i)
26620
- * forest.setMatrixAt(i, buildMatrix(randomGroundPos(), vec3(0, rand(2*PI), 0)));
27196
+ * forest.setTransformAt(i, randomGroundPos(), vec3(0, rand(2*PI), 0));
26621
27197
  */
26622
27198
  class InstancedMesh3D extends EngineObject3D
26623
27199
  {
@@ -26636,29 +27212,101 @@ class InstancedMesh3D extends EngineObject3D
26636
27212
  this.count = count;
26637
27213
  /** @property {number} - How many instances it was made with */
26638
27214
  this.maxCount = count;
26639
- /** @property {Float32Array} - The per instance values the shader reads, 24 floats each: the matrix, the color
26640
- * and the uv rect; edit it directly and call markDirty for the instances changed */
26641
- this.instanceData = new Float32Array(count * RENDER3D_INSTANCE_FLOATS);
26642
- /** @property {number} - Radius of the sphere around the origin that holds every instance set so far, for
26643
- * culling; from the farthest instance and the largest scale, and the mesh's size when it draws */
26644
- this.radius = 0;
26645
- this.reach = 0; // the farthest any instance's position has been from the origin
26646
- this.maxScale = 0; // and the largest scale any instance has had
26647
- /** @property {number} - First instance to upload before the next draw */
27215
+ /** @property {Float32Array} - Each instance's matrix, 16 floats, what moving them uploads; edit it directly and
27216
+ * call markDirty for the instances changed */
27217
+ this.matrixData = new Float32Array(count * RENDER3D_MATRIX_FLOATS);
27218
+ /** @property {Float32Array} - Each instance's color and uv rect, 8 floats, uploaded only when they change; edit
27219
+ * it directly and call markColorDirty for the instances changed */
27220
+ this.colorData = new Float32Array(count * RENDER3D_COLOR_FLOATS);
27221
+ /** @property {number} - First instance whose matrix uploads before the next draw */
26648
27222
  this.dirtyStart = 0;
26649
- /** @property {number} - One past the last instance to upload, so nothing uploads when it is not past dirtyStart */
27223
+ /** @property {number} - One past the last instance whose matrix uploads, nothing when it is not past dirtyStart */
26650
27224
  this.dirtyEnd = count;
26651
- this.buffer = undefined; // the GPU copy of instanceData
26652
- this.bufferGeneration = -1; // the context it was made under
26653
- this.uvTileInfo = tileInfo; // the tile the uv rects were written for
26654
- const uv = render3DGetTileUVs(tileInfo), data = this.instanceData;
27225
+ /** @property {number} - First instance whose color uploads before the next draw */
27226
+ this.colorDirtyStart = 0;
27227
+ /** @property {number} - One past the last instance whose color uploads */
27228
+ this.colorDirtyEnd = count;
27229
+ // the farthest any instance's position has been from the origin, and the largest scale any has had, both
27230
+ // squared, and the instances set since radius last took them in: setting one does no bounds work at all
27231
+ this.reachSquared = 0;
27232
+ this.scaleSquared = 0;
27233
+ this.boundsStart = 0;
27234
+ this.boundsEnd = count;
27235
+ /** @type {number|undefined} */
27236
+ this.fixedRadius = undefined; // a radius set by hand, see radius
27237
+ this.buffer = undefined; // the GPU copy of matrixData
27238
+ /** @type {WebGLBuffer|undefined} */
27239
+ this.colorBuffer = undefined; // and of colorData
27240
+ this.bufferGeneration = -1; // the context they were made under
27241
+ this.uvTileInfo = tileInfo; // the tile the uv rects were written for
27242
+ const uv = render3DGetTileUVs(tileInfo), matrices = this.matrixData, colors = this.colorData;
26655
27243
  for (let i = 0; i < count; ++i)
26656
27244
  {
26657
- this.setMatrixAt(i, RENDER3D_IDENTITY);
26658
- const k = i * RENDER3D_INSTANCE_FLOATS;
26659
- data[k+16] = color.r; data[k+17] = color.g; data[k+18] = color.b; data[k+19] = color.a;
26660
- data[k+20] = uv.x; data[k+21] = uv.y; data[k+22] = uv.w; data[k+23] = uv.h;
27245
+ matrices.set(RENDER3D_IDENTITY.m, i * RENDER3D_MATRIX_FLOATS);
27246
+ const k = i * RENDER3D_COLOR_FLOATS;
27247
+ colors[k] = color.r; colors[k+1] = color.g; colors[k+2] = color.b; colors[k+3] = color.a;
27248
+ colors[k+4] = uv.x; colors[k+5] = uv.y; colors[k+6] = uv.w; colors[k+7] = uv.h;
27249
+ }
27250
+ }
27251
+
27252
+ /** Place an instance by its position, rotation and scale, what buildMatrix takes, written straight in with no
27253
+ * matrix made; in world space
27254
+ * @param {number} i
27255
+ * @param {Vector3} [pos]
27256
+ * @param {Vector3} [rotation] - Euler angles in radians
27257
+ * @param {Vector3} [scale] */
27258
+ setTransformAt(i, pos, rotation, scale)
27259
+ {
27260
+ ASSERT(i >= 0 && i < this.maxCount, 'instance index out of range');
27261
+ matrix4Compose(this.matrixData, i * RENDER3D_MATRIX_FLOATS, pos, rotation, scale);
27262
+ this.markDirty(i);
27263
+ }
27264
+
27265
+ /** Place a run of instances from arrays indexed by instance, plain or typed, as a game keeps them: each is placed
27266
+ * as setTransformAt places it, all in one loop with nothing made, the fastest way to move many every frame;
27267
+ * in world space
27268
+ * @param {number} start - First instance
27269
+ * @param {number} count - How many
27270
+ * @param {ArrayLike<number>} x - Positions
27271
+ * @param {ArrayLike<number>} y
27272
+ * @param {ArrayLike<number>} z
27273
+ * @param {ArrayLike<number>} [rx] - Euler angles in radians, all three or none for upright
27274
+ * @param {ArrayLike<number>} [ry]
27275
+ * @param {ArrayLike<number>} [rz]
27276
+ * @param {ArrayLike<number>} [sx] - Scales, all three or none for 1
27277
+ * @param {ArrayLike<number>} [sy]
27278
+ * @param {ArrayLike<number>} [sz] */
27279
+ setTransforms(start, count, x, y, z, rx, ry, rz, sx, sy, sz)
27280
+ {
27281
+ ASSERT(start >= 0 && count >= 0 && start + count <= this.maxCount, 'instance range out of range');
27282
+ ASSERT(!rx === !ry && !rx === !rz && !sx === !sy && !sx === !sz, 'rotations and scales come in threes');
27283
+ const m = this.matrixData, end = start + count;
27284
+ for (let i = start; i < end; ++i)
27285
+ {
27286
+ // matrix4Compose's expressions, so a run placed here is the same as each placed by setTransformAt
27287
+ const k = i * RENDER3D_MATRIX_FLOATS;
27288
+ const scaleX = sx ? sx[i] : 1, scaleY = sy ? sy[i] : 1, scaleZ = sz ? sz[i] : 1;
27289
+ if (rx)
27290
+ {
27291
+ const cx = cos(rx[i]), sinX = sin(rx[i]);
27292
+ const cy = cos(ry[i]), sinY = sin(ry[i]);
27293
+ const cz = cos(rz[i]), sinZ = sin(rz[i]);
27294
+ m[k] = (cy*cz + sinY*sinX*sinZ) * scaleX; m[k+1] = cx*sinZ * scaleX;
27295
+ m[k+2] = (-sinY*cz + cy*sinX*sinZ) * scaleX;
27296
+ m[k+4] = (-cy*sinZ + sinY*sinX*cz) * scaleY; m[k+5] = cx*cz * scaleY;
27297
+ m[k+6] = (sinY*sinZ + cy*sinX*cz) * scaleY;
27298
+ m[k+8] = sinY*cx * scaleZ; m[k+9] = -sinX * scaleZ; m[k+10] = cy*cx * scaleZ;
27299
+ }
27300
+ else
27301
+ {
27302
+ m[k] = scaleX; m[k+1] = 0; m[k+2] = 0;
27303
+ m[k+4] = 0; m[k+5] = scaleY; m[k+6] = 0;
27304
+ m[k+8] = 0; m[k+9] = 0; m[k+10] = scaleZ;
27305
+ }
27306
+ m[k+3] = m[k+7] = m[k+11] = 0; m[k+15] = 1;
27307
+ m[k+12] = x[i], m[k+13] = y[i], m[k+14] = z[i];
26661
27308
  }
27309
+ count && this.markDirtyRange(start, end);
26662
27310
  }
26663
27311
 
26664
27312
  /** Place an instance, in world space
@@ -26667,8 +27315,9 @@ class InstancedMesh3D extends EngineObject3D
26667
27315
  setMatrixAt(i, matrix)
26668
27316
  {
26669
27317
  ASSERT(i >= 0 && i < this.maxCount, 'instance index out of range');
26670
- const data = this.instanceData, k = i * RENDER3D_INSTANCE_FLOATS, m = matrix.m;
26671
- data.set(m, k);
27318
+ const data = this.matrixData, k = i * RENDER3D_MATRIX_FLOATS, m = matrix.m;
27319
+ for (let j = 0; j < 16; ++j)
27320
+ data[k+j] = m[j]; // for 16 floats a loop beats set's call
26672
27321
  this.markDirty(i);
26673
27322
  }
26674
27323
 
@@ -26678,8 +27327,8 @@ class InstancedMesh3D extends EngineObject3D
26678
27327
  getMatrixAt(i)
26679
27328
  {
26680
27329
  ASSERT(i >= 0 && i < this.maxCount, 'instance index out of range');
26681
- const k = i * RENDER3D_INSTANCE_FLOATS, matrix = new Matrix4;
26682
- matrix.m.set(this.instanceData.subarray(k, k + 16));
27330
+ const k = i * RENDER3D_MATRIX_FLOATS, matrix = new Matrix4;
27331
+ matrix.m.set(this.matrixData.subarray(k, k + 16));
26683
27332
  return matrix;
26684
27333
  }
26685
27334
 
@@ -26690,26 +27339,71 @@ class InstancedMesh3D extends EngineObject3D
26690
27339
  {
26691
27340
  ASSERT(i >= 0 && i < this.maxCount, 'instance index out of range');
26692
27341
  ASSERT(isColor(color), 'color must be a Color');
26693
- const data = this.instanceData, k = i * RENDER3D_INSTANCE_FLOATS;
26694
- data[k+16] = color.r; data[k+17] = color.g; data[k+18] = color.b; data[k+19] = color.a;
26695
- this.markDirty(i);
27342
+ const data = this.colorData, k = i * RENDER3D_COLOR_FLOATS;
27343
+ data[k] = color.r; data[k+1] = color.g; data[k+2] = color.b; data[k+3] = color.a;
27344
+ this.markColorDirty(i);
26696
27345
  }
26697
27346
 
26698
- /** Note that an instance changed, so it uploads before the next draw and the bounds hold it; setMatrixAt and
26699
- * setColorAt call this, and so must an edit made straight to instanceData
27347
+ /** Note that an instance's matrix changed, so it uploads before the next draw and the bounds take it in when
27348
+ * they are read; setTransformAt and setMatrixAt call this, and so must an edit made straight to matrixData
26700
27349
  * @param {number} i */
26701
27350
  markDirty(i)
26702
27351
  {
26703
- this.dirtyStart = min(this.dirtyStart, i);
26704
- this.dirtyEnd = max(this.dirtyEnd, i + 1);
27352
+ if (i < this.dirtyStart) this.dirtyStart = i;
27353
+ if (i >= this.dirtyEnd) this.dirtyEnd = i + 1;
27354
+ if (i < this.boundsStart) this.boundsStart = i;
27355
+ if (i >= this.boundsEnd) this.boundsEnd = i + 1;
27356
+ }
27357
+
27358
+ /** Note that a run of instances' matrices changed, for code that writes matrixData directly, the lowest level
27359
+ * way to move many: they upload before the next draw and the bounds take them in when they are read
27360
+ * @param {number} start - First instance
27361
+ * @param {number} end - One past the last */
27362
+ markDirtyRange(start, end)
27363
+ {
27364
+ ASSERT(start >= 0 && end <= this.maxCount && start <= end, 'instance range out of range');
27365
+ if (start < this.dirtyStart) this.dirtyStart = start;
27366
+ if (end > this.dirtyEnd) this.dirtyEnd = end;
27367
+ if (start < this.boundsStart) this.boundsStart = start;
27368
+ if (end > this.boundsEnd) this.boundsEnd = end;
27369
+ }
26705
27370
 
26706
- // the bounds grow to hold where it is now and how big, read back from the matrix it has
26707
- const d = this.instanceData, k = i * RENDER3D_INSTANCE_FLOATS;
26708
- this.reach = max(this.reach, hypot(d[k+12], d[k+13], d[k+14]));
26709
- this.maxScale = max(this.maxScale, render3DMaxStretch(d, k));
26710
- const meshRadius = this.mesh.radius || this.mesh.computeRadius(); // measured once, the draw takes a new size up
26711
- this.radius = this.reach + meshRadius * this.maxScale;
27371
+ /** Note that an instance's color changed, so it uploads before the next draw; setColorAt calls this, and so must
27372
+ * an edit made straight to colorData
27373
+ * @param {number} i */
27374
+ markColorDirty(i)
27375
+ {
27376
+ if (i < this.colorDirtyStart) this.colorDirtyStart = i;
27377
+ if (i >= this.colorDirtyEnd) this.colorDirtyEnd = i + 1;
27378
+ }
27379
+
27380
+ /** Radius of the sphere around the origin that holds every instance set so far, for culling: the farthest
27381
+ * instance, and the mesh's size at the largest scale; it only grows, and takes in the instances set since it
27382
+ * was last read, so with frustum culling off it is never worked out. Set it to fix the sphere, which a big set
27383
+ * that is culled can use to skip that work, and set it to undefined to go back to the bounds
27384
+ * @return {number} */
27385
+ get radius()
27386
+ {
27387
+ if (this.fixedRadius !== undefined) return this.fixedRadius;
27388
+ if (this.boundsEnd > this.boundsStart)
27389
+ {
27390
+ // the bounds grow to hold where each is now and how big, read back from its matrix, squared
27391
+ const d = this.matrixData;
27392
+ let reachSquared = this.reachSquared, scaleSquared = this.scaleSquared;
27393
+ for (let i = this.boundsStart; i < this.boundsEnd; ++i)
27394
+ {
27395
+ const k = i * RENDER3D_MATRIX_FLOATS, x = d[k+12], y = d[k+13], z = d[k+14];
27396
+ const reach = x*x + y*y + z*z, scale = render3DMaxStretchSquared(d, k);
27397
+ if (reach > reachSquared) reachSquared = reach;
27398
+ if (scale > scaleSquared) scaleSquared = scale;
27399
+ }
27400
+ this.reachSquared = reachSquared, this.scaleSquared = scaleSquared;
27401
+ this.boundsStart = Infinity, this.boundsEnd = 0;
27402
+ }
27403
+ const mesh = this.mesh, meshRadius = mesh.radius || mesh.computeRadius(); // the mesh measured once
27404
+ return this.reachSquared ** .5 + meshRadius * this.scaleSquared ** .5;
26712
27405
  }
27406
+ set radius(radius) { this.fixedRadius = radius; }
26713
27407
 
26714
27408
  /** Draws every instance as one call, uploading the ones that changed first
26715
27409
  * @return {void} */
@@ -26723,41 +27417,46 @@ class InstancedMesh3D extends EngineObject3D
26723
27417
  if (r.shadowPass && !r.lighting) return; // unlit things cast no shadow
26724
27418
  render3DMeshUpload(mesh);
26725
27419
  if (!mesh.bufferCount) return;
26726
- this.radius = this.reach + mesh.radius * this.maxScale; // the mesh may have grown since
26727
27420
  if (r.frustumCulling && !render3DSphereVisible(0, 0, 0, this.radius)) return;
26728
27421
 
26729
27422
  // the uv rect is the object's tile for every instance, rewritten when the tile changes
26730
27423
  if (this.uvTileInfo !== this.tileInfo)
26731
27424
  {
26732
- const uv = render3DGetTileUVs(this.tileInfo), data = this.instanceData;
26733
- for (let k = 20; k < data.length; k += RENDER3D_INSTANCE_FLOATS)
27425
+ const uv = render3DGetTileUVs(this.tileInfo), data = this.colorData;
27426
+ for (let k = 4; k < data.length; k += RENDER3D_COLOR_FLOATS)
26734
27427
  data[k] = uv.x, data[k+1] = uv.y, data[k+2] = uv.w, data[k+3] = uv.h;
26735
27428
  this.uvTileInfo = this.tileInfo;
26736
- this.dirtyStart = 0, this.dirtyEnd = this.maxCount;
27429
+ this.colorDirtyStart = 0, this.colorDirtyEnd = this.maxCount;
26737
27430
  }
26738
27431
 
26739
- // the GPU copy: all of it under a fresh context, otherwise just the changed range
27432
+ // the GPU copies: all of each under a fresh context, otherwise just the changed ranges
26740
27433
  if (!this.buffer || this.bufferGeneration !== r.contextGeneration)
26741
27434
  {
26742
- this.buffer = gl.createBuffer();
26743
27435
  this.bufferGeneration = r.contextGeneration;
26744
- gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
26745
- gl.bufferData(gl.ARRAY_BUFFER, this.instanceData, gl.DYNAMIC_DRAW);
27436
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer = gl.createBuffer());
27437
+ gl.bufferData(gl.ARRAY_BUFFER, this.matrixData, gl.DYNAMIC_DRAW);
27438
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.colorBuffer = gl.createBuffer());
27439
+ gl.bufferData(gl.ARRAY_BUFFER, this.colorData, gl.DYNAMIC_DRAW);
26746
27440
  }
26747
- else if (this.dirtyEnd > this.dirtyStart)
27441
+ else
26748
27442
  {
26749
- const start = this.dirtyStart * RENDER3D_INSTANCE_FLOATS, end = this.dirtyEnd * RENDER3D_INSTANCE_FLOATS;
26750
- gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
26751
- gl.bufferSubData(gl.ARRAY_BUFFER, start * 4, this.instanceData, start, end - start);
27443
+ const upload = (buffer, data, floats, start, end)=>
27444
+ {
27445
+ if (end <= start) return;
27446
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
27447
+ gl.bufferSubData(gl.ARRAY_BUFFER, start * floats * 4, data, start * floats, (end - start) * floats);
27448
+ };
27449
+ upload(this.buffer, this.matrixData, RENDER3D_MATRIX_FLOATS, this.dirtyStart, this.dirtyEnd);
27450
+ upload(this.colorBuffer, this.colorData, RENDER3D_COLOR_FLOATS, this.colorDirtyStart, this.colorDirtyEnd);
26752
27451
  }
26753
- this.dirtyStart = Infinity, this.dirtyEnd = 0;
27452
+ this.dirtyStart = this.colorDirtyStart = Infinity, this.dirtyEnd = this.colorDirtyEnd = 0;
26754
27453
 
26755
27454
  // one draw under the object's state, the mesh setting the culling as drawMesh does
26756
27455
  r.flush();
26757
27456
  render3DFlushBeforeOverlay(); // as in drawMesh, what was drawn before goes under it
26758
27457
  const cullBackFaces = r.cullBackFaces, tileInfo = this.tileInfo;
26759
27458
  r.cullBackFaces = !mesh.doubleSided;
26760
- render3DDrawInstanced(mesh, this.buffer, this.count, render3DTextureOf(tileInfo), r);
27459
+ render3DDrawInstanced(mesh, this.buffer, this.count, render3DTextureOf(tileInfo), r, this.colorBuffer);
26761
27460
  r.cullBackFaces = cullBackFaces;
26762
27461
  }
26763
27462
 
@@ -26766,8 +27465,8 @@ class InstancedMesh3D extends EngineObject3D
26766
27465
  destroy(immediate)
26767
27466
  {
26768
27467
  if (this.buffer && this.bufferGeneration === render3D?.contextGeneration)
26769
- glContext?.deleteBuffer(this.buffer);
26770
- this.buffer = undefined;
27468
+ glContext?.deleteBuffer(this.buffer), glContext?.deleteBuffer(this.colorBuffer);
27469
+ this.buffer = this.colorBuffer = undefined;
26771
27470
  super.destroy(immediate);
26772
27471
  }
26773
27472
  }
@@ -26776,7 +27475,10 @@ class InstancedMesh3D extends EngineObject3D
26776
27475
  /**
26777
27476
  * Light3D - A light that is an EngineObject3D, so it can move, follow a parent or be destroyed like anything else
26778
27477
  * - A point light: it lights what is near it and fades out by its radius, DirectionalLight3D shines from far away
26779
- * - Only the sun, render3D.sunDirection, casts shadows; these light and make highlights without one
27478
+ * - A coneAngle makes it a spotlight: it shines along its own forward, turned by rotation3D or by what it is
27479
+ * attached to, inside the cone, fading over the part of it coneSoftness says
27480
+ * - The sun, render3D.sunDirection, casts the shadows; a spotlight can cast them in its place, see
27481
+ * render3D.shadowLight; the other lights light and make highlights without one
26780
27482
  * - Only the 8 lights nearest the camera are used each frame
26781
27483
  * - radius is where the light fades out, and it fades fast, so a small radius wants a higher intensity
26782
27484
  * - intensity multiplies the color, above 1 for a light brighter than white
@@ -26808,6 +27510,12 @@ class Light3D extends EngineObject3D
26808
27510
  /** @property {boolean} - Shine from far away, from its position toward the origin, instead of out from its
26809
27511
  * position with a falloff; DirectionalLight3D sets it */
26810
27512
  this.directional = false;
27513
+ /** @property {number} - Makes it a spotlight: the angle in radians from its forward out to the edge of its
27514
+ * cone, so the beam is twice this across; 0 for a light that shines every way */
27515
+ this.coneAngle = 0;
27516
+ /** @property {number} - How much of the cone is its fading edge: 0 a hard edge, .2 by default, the outer
27517
+ * fifth, 1 fading all the way from the middle of the beam */
27518
+ this.coneSoftness = .2;
26811
27519
  /** @property {number} - Size of a soft hazy glow drawn over the light, like a lamp at night, 0 for none; it
26812
27520
  * is added onto what is behind it, and what is in front of the light hides it */
26813
27521
  this.glow = 0;
@@ -26826,37 +27534,10 @@ class Light3D extends EngineObject3D
26826
27534
  const toCamera = r.camera.pos.subtract(pos), distance = toCamera.length();
26827
27535
  const at = distance ? pos.add(toCamera.scale(min(this.glow, distance) / 2 / distance)) : pos;
26828
27536
  const color = rgb(c.r, c.g, c.b, c.a * min(this.intensity, 1));
26829
- r.drawBillboard(at, vec2(this.glow), render3DGlow(this.glowFalloff), color);
27537
+ r.drawBillboard(at, vec2(this.glow), engineGlowTexture(this.glowFalloff), color);
26830
27538
  }
26831
27539
  }
26832
27540
 
26833
- // 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
26834
- // and nothing at the edge, fading faster the higher the falloff
26835
- function render3DGlowAlpha(r, falloff)
26836
- {
26837
- const k = 3.5 * falloff, edge = Math.exp(-k);
26838
- return (Math.exp(-k * r * r) - edge) / (1 - edge);
26839
- }
26840
-
26841
- // the soft round glow of the lights, one texture for each falloff, rounded to a tenth so a changing falloff makes
26842
- // only a few, each made once from a canvas; undefined headless or without a canvas
26843
- const render3DGlowTextures = new Map;
26844
- function render3DGlow(falloff=1)
26845
- {
26846
- ASSERT(isNumber(falloff) && falloff > 0, 'glowFalloff must be a number above 0');
26847
- const key = max(round(falloff * 10), 1) / 10;
26848
- let texture = render3DGlowTextures.get(key);
26849
- if (texture || !glContext || typeof OffscreenCanvas == 'undefined') return texture;
26850
- const size = 64, context = createCanvasContext(size), steps = 16;
26851
- const gradient = context.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
26852
- for (let i = 0; i <= steps; ++i)
26853
- gradient.addColorStop(i / steps, 'rgba(255,255,255,' + render3DGlowAlpha(i / steps, key).toFixed(4) + ')');
26854
- context.fillStyle = gradient;
26855
- context.fillRect(0, 0, size, size);
26856
- render3DGlowTextures.set(key, texture = new TextureInfo(context.canvas));
26857
- return texture;
26858
- }
26859
-
26860
27541
  ///////////////////////////////////////////////////////////////////////////////
26861
27542
  /**
26862
27543
  * DirectionalLight3D - A Light3D that shines from far away with no falloff, like sunlight
@@ -27790,7 +28471,7 @@ const render3DParticlesCalling = new Set;
27790
28471
  * @return {Particle3D} */
27791
28472
  function render3DParticleView(emitter)
27792
28473
  {
27793
- return {emitter, pos: vec3(), velocity: vec3(), age: 0, lifeTime: 0, destroyed: false,
28474
+ return {emitter, pos: vec3(), velocity: vec3(), age: 0, lifeTime: 0, scale: 1, destroyed: false,
27794
28475
  destroy() { this.destroyed = true; }};
27795
28476
  }
27796
28477
 
@@ -27803,6 +28484,8 @@ function render3DParticleView(emitter)
27803
28484
  * @property {Vector3} velocity - How far it moves each frame
27804
28485
  * @property {number} age - Seconds it has lived
27805
28486
  * @property {number} lifeTime - Seconds it lives
28487
+ * @property {number} scale - How much the emitter grows its effect, from its scale3D and its parents', as the 2D
28488
+ * particle's scale
27806
28489
  * @property {boolean} destroyed - Set by destroy
27807
28490
  * @property {function(): void} destroy - End it this update, the destroy callback gets it
27808
28491
  * @memberof Render3D
@@ -27832,8 +28515,9 @@ function render3DParticleView(emitter)
27832
28515
  * - emitConeAngle spreads them, PI sprays in every direction
27833
28516
  * - Speeds are per frame and sizes are world units, the same as the 2D emitter
27834
28517
  * - scale3D, its own or a parent's, grows the whole effect: the spawn area, the sizes, the speed and the fall
27835
- * - gravity here is its own number added to velocity y each frame: it is neither the engine's 2D
27836
- * gravity nor render3D.gravity, so an effect keeps its own fall wherever it is used
28518
+ * - gravity here is its own number added to velocity y each frame, so an effect keeps its own fall wherever it is
28519
+ * used, the same as the 2D emitter's gravity; gravityScale adds a share of render3D.gravity on top, as the 2D
28520
+ * emitter's gravityScale adds the engine's gravity
27837
28521
  * - An emitter with an emitTime destroys itself once its last particle is gone, like the 2D emitter
27838
28522
  * - Callbacks as the 2D emitter's: particleCreateCallback, particleUpdateCallback, particleCollideCallback and
27839
28523
  * particleDestroyCallback, each given a Particle3D, one object the emitter reuses for every particle and call
@@ -27863,8 +28547,8 @@ class ParticleEmitter3D extends EngineObject3D
27863
28547
  * @param {number} [sizeEnd] - Particle size at end of life
27864
28548
  * @param {number} [speed] - Spawn speed in world units per frame
27865
28549
  * @param {number} [damping] - Per frame velocity multiplier, 1 is none
27866
- * @param {number} [gravity] - Per frame change to velocity y, negative pulls down; its own number,
27867
- * not render3D.gravity, so the 2D emitter's gravityScale has no equivalent here
28550
+ * @param {number} [gravity] - Per frame change to velocity y, negative pulls down; its own number, and
28551
+ * gravityScale adds a share of render3D.gravity on top
27868
28552
  * @param {number} [fadeRate] - Fraction of life spent fading, half in and half out
27869
28553
  * @param {number} [randomness] - Extra randomness applied to speed, size and life
27870
28554
  * @param {boolean} [additive] - Additive blending */
@@ -27879,6 +28563,9 @@ class ParticleEmitter3D extends EngineObject3D
27879
28563
 
27880
28564
  /** @property {number|Vector3} - Spawn area, a number for a sphere diameter or a vec3 for a box */
27881
28565
  this.emitSize = emitSize;
28566
+ /** @property {boolean} - Flatten the spawn area across the way it emits, its own up: a sphere becomes a disc
28567
+ * and a box a flat rectangle, for rain from a sheet of sky or flames from a patch of ground */
28568
+ this.emitFlat = false;
27882
28569
  /** @property {number} - How long to keep emitting, 0 is forever */
27883
28570
  this.emitTime = emitTime;
27884
28571
  /** @property {number} - Particles per second, 0 does not emit */
@@ -27905,6 +28592,9 @@ class ParticleEmitter3D extends EngineObject3D
27905
28592
  this.damping = damping;
27906
28593
  /** @property {number} - Per frame change to velocity y, its own number and not render3D.gravity */
27907
28594
  this.gravity = gravity;
28595
+ /** @property {number} - Share of render3D.gravity added each frame on top of its own gravity, 0 by default so
28596
+ * an effect keeps its own fall wherever it is used; 1 falls with the world, like debris */
28597
+ this.gravityScale = 0;
27908
28598
  /** @property {number} - Fraction of life spent fading, half in and half out */
27909
28599
  this.fadeRate = fadeRate;
27910
28600
  /** @property {number} - Extra randomness applied to speed, size and life */
@@ -27960,6 +28650,7 @@ class ParticleEmitter3D extends EngineObject3D
27960
28650
  const matrix = render3DObjectMatrix(this); // the object's own, read only
27961
28651
  this.worldPos3D = matrix.getTranslation(); // remembered for when the parent is destroyed
27962
28652
  const scale = render3DMaxScale(matrix.m);
28653
+ this.particleView.scale = scale; // the callbacks read it from the view instead of working it out each time
27963
28654
 
27964
28655
  // emit until the emit time is up, then wait for the last particle and go away
27965
28656
  if (!this.emitTime || this.getAliveTime() <= this.emitTime)
@@ -27992,7 +28683,11 @@ class ParticleEmitter3D extends EngineObject3D
27992
28683
  const F = RENDER3D_PARTICLE_FLOATS;
27993
28684
  let data = this.particleData, trail = this.trailData;
27994
28685
  const reread = ()=> { data = this.particleData, trail = this.trailData; };
27995
- const damping = this.damping, gravity = this.gravity * scale, angleDamping = this.angleDamping; // a bigger effect has to fall faster to keep the same arc
28686
+ // its own fall and a share of the world's, grown by the scale: a bigger effect has to fall faster to keep the
28687
+ // same arc
28688
+ const damping = this.damping, angleDamping = this.angleDamping, g = render3D.gravity, share = this.gravityScale;
28689
+ const gravityX = g.x * share * scale, gravityY = (this.gravity + g.y * share) * scale;
28690
+ const gravityZ = g.z * share * scale;
27996
28691
  const collideLevel = this.collideLevel && render3DLevel.length;
27997
28692
  const updateCallback = this.particleUpdateCallback, destroyCallback = this.particleDestroyCallback;
27998
28693
  for (let i = this.particleCount; i--;)
@@ -28000,7 +28695,8 @@ class ParticleEmitter3D extends EngineObject3D
28000
28695
  // damped first and gravity added after, the order the 2D particle uses, so the same
28001
28696
  // damping and gravity give the same arc in both
28002
28697
  const k = i * F;
28003
- const vx = data[k+3] *= damping, vy = data[k+4] = data[k+4] * damping + gravity, vz = data[k+5] *= damping;
28698
+ const vx = data[k+3] = data[k+3] * damping + gravityX, vy = data[k+4] = data[k+4] * damping + gravityY;
28699
+ const vz = data[k+5] = data[k+5] * damping + gravityZ;
28004
28700
  data[k] += vx, data[k+1] += vy, data[k+2] += vz;
28005
28701
  data[k+18] += data[k+19] *= angleDamping;
28006
28702
  collideLevel && (this.particleCollide(k, data[k] - vx, data[k+1] - vy, data[k+2] - vz), reread());
@@ -28048,10 +28744,18 @@ class ParticleEmitter3D extends EngineObject3D
28048
28744
  // the whole effect grows with the emitter, not just the area the particles start in
28049
28745
  const scale = render3DMaxScale(matrix.m);
28050
28746
 
28051
- // spawn offset: inside a box or a sphere
28052
- const size = this.emitSize, box = /** @type {Vector3} */ (size);
28053
- const offset = isVector3(size) ? vec3(rand(-.5, .5) * box.x, rand(-.5, .5) * box.y, rand(-.5, .5) * box.z)
28054
- : randInSphere(/** @type {number} */ (size) / 2);
28747
+ // spawn offset: inside a box or a sphere, or flat across the emitter's up, a flat rectangle or a disc
28748
+ const size = this.emitSize, box = /** @type {Vector3} */ (size), flat = this.emitFlat;
28749
+ let offset;
28750
+ if (isVector3(size))
28751
+ offset = vec3(rand(-.5, .5) * box.x, flat ? 0 : rand(-.5, .5) * box.y, rand(-.5, .5) * box.z);
28752
+ else if (flat)
28753
+ {
28754
+ const disc = randInCircle(/** @type {number} */ (size) / 2);
28755
+ offset = vec3(disc.x, 0, disc.y);
28756
+ }
28757
+ else
28758
+ offset = randInSphere(/** @type {number} */ (size) / 2);
28055
28759
 
28056
28760
  // direction inside the cone around local +Y
28057
28761
  const direction = matrix.transformDirection(randVector3(1, this.emitConeAngle)).normalize();
@@ -28321,6 +29025,195 @@ class Trail3D extends EngineObject3D
28321
29025
  }
28322
29026
  }
28323
29027
 
29028
+ ///////////////////////////////////////////////////////////////////////////////
29029
+ // lens flare
29030
+
29031
+ // the shapes a flare is made of, white on clear on one smooth texture made the first time it is asked for: a soft
29032
+ // glow, a flat disc with a brighter rim, and a ring; undefined headless or without a canvas
29033
+ let render3DFlareTiles;
29034
+ function render3DFlareTile(shape)
29035
+ {
29036
+ if (!render3DFlareTiles)
29037
+ {
29038
+ if (headlessMode || !glContext || typeof OffscreenCanvas == 'undefined') return;
29039
+ const cell = 128, shapes = ['glow', 'disc', 'ring'], context = createCanvasContext(cell * shapes.length, cell);
29040
+ // each shape is how see-through it is from its middle out
29041
+ const alpha =
29042
+ {
29043
+ glow: (t)=> engineGlowAlpha(t, 1),
29044
+ disc: (t)=> t < .8 ? .4 + .1 * t : t < .92 ? .48 + (t - .8) * 3 : (1 - t) / .08 * .84,
29045
+ ring: (t)=> max(0, 1 - abs(t - .86) / .12),
29046
+ };
29047
+ shapes.forEach((shape, i)=>
29048
+ {
29049
+ const x = i * cell + cell / 2, y = cell / 2, r = cell / 2 - 2, steps = 32;
29050
+ const gradient = context.createRadialGradient(x, y, 0, x, y, r);
29051
+ for (let k = 0; k <= steps; ++k)
29052
+ gradient.addColorStop(k / steps, 'rgba(255,255,255,' + clamp(alpha[shape](k / steps)).toFixed(4) + ')');
29053
+ context.fillStyle = gradient;
29054
+ context.beginPath();
29055
+ context.arc(x, y, r, 0, 2 * PI);
29056
+ context.fill();
29057
+ });
29058
+ const texture = new TextureInfo(context.canvas, true, false, false); // smooth even in a pixel art game
29059
+ render3DFlareTiles = new Map(shapes.map((shape, i)=>
29060
+ [shape, new TileInfo(vec2(i * cell, 0), vec2(cell), texture)]));
29061
+ }
29062
+ return render3DFlareTiles.get(shape) || render3DFlareTiles.get('glow');
29063
+ }
29064
+
29065
+ /**
29066
+ * LensFlare3D - The sun's lens flare, the old kind: a glow at the sun and a row of discs and rings of different
29067
+ * sizes along the line from the sun through the middle of the screen
29068
+ * - Make one and it shows, over the 3D scene and under what the game draws after, a HUD; destroy it to take it away
29069
+ * - It follows render3D.sunDirection, and fades out as the sun leaves the screen or goes behind something
29070
+ * - flareSize, count, intensity and saturation set its look, seed picks another arrangement, and its color tints it,
29071
+ * with the sun's own color; or give it elements of your own
29072
+ * - visible is how much of the sun shows, 0 to 1, eased over fadeTime, there for a game to read
29073
+ * - What hides the sun is found with a ray from the camera, against the level and every object that is not see
29074
+ * through, each as the box around its mesh, see render3D.pick; turn it off with occlusion
29075
+ * - It needs WebGL, and it draws nothing in the shadow of renderAfter2D
29076
+ * @extends EngineObject3D
29077
+ * @memberof Render3D
29078
+ * @example
29079
+ * new LensFlare3D; // the sun flares
29080
+ * new LensFlare3D(1.5, 10, .7, 0); // bigger, 10 ghosts, dimmer, all one color
29081
+ */
29082
+ class LensFlare3D extends EngineObject3D
29083
+ {
29084
+ /** Create the sun's lens flare
29085
+ * @param {number} [size] - Scales every part of it, 1 by default
29086
+ * @param {number} [count] - How many ghosts there are along the line, besides the glow at the sun
29087
+ * @param {number} [intensity] - How bright it is
29088
+ * @param {number} [saturation] - How colorful the ghosts are, 0 for all the flare's own color, 1 a rainbow
29089
+ * @param {Color} [color] - Tints the whole flare, with the sun's color */
29090
+ constructor(size=1, count=7, intensity=1, saturation=1, color=WHITE)
29091
+ {
29092
+ super(vec3(), undefined, undefined, color);
29093
+ this.size3D = vec3(); // not a thing to pick or collect
29094
+ /** @property {number} - Scales every part of the flare */
29095
+ this.flareSize = size;
29096
+ /** @property {number} - How many ghosts there are along the line, besides the glow at the sun */
29097
+ this.count = count;
29098
+ /** @property {number} - How bright it is */
29099
+ this.intensity = intensity;
29100
+ /** @property {number} - How colorful the ghosts are, 0 for all the flare's own color, 1 a rainbow */
29101
+ this.saturation = saturation;
29102
+ /** @property {number} - Picks the arrangement of the ghosts, another seed is another flare */
29103
+ this.seed = 1;
29104
+ /** @property {Array<{at: number, size: number, color: Color, shape: string}>|undefined} - The parts of the
29105
+ * flare, to set your own in place of the ones made from count, seed and saturation: at is where along the
29106
+ * 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
29107
+ * height; shape is glow, disc or ring
29108
+ * @type {Array<{at: number, size: number, color: Color, shape: string}>|undefined} */
29109
+ this.elements = undefined;
29110
+ /** @property {boolean} - Fade out when something is between the camera and the sun */
29111
+ this.occlusion = true;
29112
+ /** @property {number} - Seconds the flare takes to fade out or in when the sun is hidden or shows again */
29113
+ this.fadeTime = .15;
29114
+ /** @property {number} - How much of the sun shows, 0 hidden or behind the camera to 1 in plain view, eased */
29115
+ this.visible = 1;
29116
+ this.renderOrder = 1e9; // over the game's sprites, it is light in the lens
29117
+ this.madeKey = '';
29118
+ /** @type {Array<{at: number, size: number, color: Color, shape: string}>} */
29119
+ this.made = [];
29120
+ }
29121
+
29122
+ /** The parts of the flare: the elements set by hand, or the ones made from count, seed and saturation, a glow
29123
+ * and a core at the sun and the ghosts, made again when one of those changes
29124
+ * @return {Array<{at: number, size: number, color: Color, shape: string}>} */
29125
+ getElements()
29126
+ {
29127
+ if (this.elements) return this.elements;
29128
+ const key = [this.count, this.seed, this.saturation].join();
29129
+ if (key !== this.madeKey)
29130
+ {
29131
+ const random = new RandomGenerator(this.seed * 7919 + 1), s = clamp(this.saturation);
29132
+ const made = this.made = [
29133
+ {at: 0, size: .7, color: hsl(0, 0, 1, .5), shape: 'glow'},
29134
+ {at: 0, size: .25, color: hsl(0, 0, 1, .9), shape: 'glow'}];
29135
+ for (let i = 0; i < this.count; ++i)
29136
+ {
29137
+ // spread along the line, each a place of its own, the far ones bigger
29138
+ const at = (i + random.float(.2, .8)) / max(this.count, 1) * 1.9 + .2;
29139
+ const pick = random.float(), shape = pick < .5 ? 'disc' : pick < .8 ? 'ring' : 'glow';
29140
+ made.push({at, size: random.float(.04, .1) * (1 + at), shape,
29141
+ color: hsl(random.float(), s * .9, .6, random.float(.15, .4))});
29142
+ }
29143
+ this.madeKey = key;
29144
+ }
29145
+ return this.made;
29146
+ }
29147
+
29148
+ /** Where the sun is on the screen, in pixels like mousePosScreen, undefined when it is behind the camera
29149
+ * @return {Vector2|undefined} */
29150
+ getSunScreenPos()
29151
+ {
29152
+ const direction = render3D.sunDirection;
29153
+ if (!direction.lengthSquared()) return;
29154
+ return render3D.worldToScreen(render3D.camera.pos.add(direction.normalize(100)));
29155
+ }
29156
+
29157
+ /** The parts of the flare as they are drawn now: each one's place on the screen, its size in pixels and its
29158
+ * color, dimmed by how much of the sun shows; empty when there is nothing to draw
29159
+ * @return {Array<{pos: Vector2, size: number, color: Color, shape: string}>} */
29160
+ getScreenElements()
29161
+ {
29162
+ const sun = this.getSunScreenPos(), center = mainCanvasSize.scale(.5);
29163
+ if (!sun || !center.x || !center.y) return [];
29164
+ // it fades as the sun leaves the screen, gone when it is a third of the screen past the edge
29165
+ const off = max(abs(sun.x - center.x) / center.x, abs(sun.y - center.y) / center.y);
29166
+ const strength = this.visible * clamp((1.3 - off) / .5) * this.intensity;
29167
+ if (!(strength > 0)) return [];
29168
+ const tint = this.color.multiply(render3D.sunColor), height = mainCanvasSize.y * this.flareSize;
29169
+ return this.getElements().map((e)=>
29170
+ {
29171
+ const c = e.color.multiply(tint);
29172
+ // along the line and past the middle, a lerp would stop there
29173
+ return {pos: sun.add(center.subtract(sun).scale(e.at)), size: e.size * height, shape: e.shape,
29174
+ color: rgb(c.r, c.g, c.b, c.a * strength)};
29175
+ });
29176
+ }
29177
+
29178
+ /** Is something between the camera and the sun: the level, or an object that is not see through
29179
+ * @return {boolean} */
29180
+ isSunHidden()
29181
+ {
29182
+ const direction = render3D.sunDirection;
29183
+ if (!direction.lengthSquared()) return true;
29184
+ const blockers = engineObjects.filter((o)=> o !== this && o instanceof EngineObject3D && !o.transparent &&
29185
+ !o.additive);
29186
+ return !!render3D.pick(new Ray3D(render3D.camera.pos, direction.normalize()), blockers);
29187
+ }
29188
+
29189
+ /** Ease visible toward whether the sun shows, called automatically each frame */
29190
+ update()
29191
+ {
29192
+ super.update();
29193
+ const shows = !!this.getSunScreenPos() && !(this.occlusion && this.isSunHidden());
29194
+ const step = this.fadeTime > 0 ? timeDelta / this.fadeTime : 1;
29195
+ this.visible = clamp(this.visible + (shows ? step : -step));
29196
+ }
29197
+
29198
+ /** Draw the flare over the 3D scene, added onto it, called automatically in the 2D pass */
29199
+ render()
29200
+ {
29201
+ if (headlessMode || !glEnable) return;
29202
+ const elements = this.getScreenElements();
29203
+ if (!elements.length) return;
29204
+ setAdditiveBlendMode(true);
29205
+ for (const e of elements)
29206
+ {
29207
+ const tileInfo = render3DFlareTile(e.shape);
29208
+ tileInfo && drawTile(e.pos, vec2(e.size), tileInfo, e.color, 0, false, undefined, true, true);
29209
+ }
29210
+ setAdditiveBlendMode(false);
29211
+ }
29212
+
29213
+ /** A flare has nothing to draw in the 3D pass */
29214
+ render3D() {}
29215
+ }
29216
+
28324
29217
  ///////////////////////////////////////////////////////////////////////////////
28325
29218
  // OBJ meshes
28326
29219
 
@@ -29415,6 +30308,16 @@ function level3DAddMesh(name, mesh, tileInfo, color=WHITE)
29415
30308
  * - What a file written by hand gets wrong uses the default: a value that is not of its default's type
29416
30309
  * - An object its type can not make is skipped with an error in debug builds, where asserts throw, and the rest
29417
30310
  * of the level is made
30311
+ * - A level may hold a map of blocks, in a voxels block: pos, its corner, size, its cells along x, y and z, and
30312
+ * blocks, runs of a count and a type along x, then y, then z; it is made a VoxelMap, the first of what is
30313
+ * returned, see level3DVoxelSetup for its sheet
30314
+ * - A level may hold a terrain, in a terrain block: pos, its center, size, its size in the world along x and z,
30315
+ * 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
30316
+ * is made a HeightMap, returned with what else was made
30317
+ * - A level may set the scene too, in a scene block beside its objects: sky, three colors for straight up, the
30318
+ * horizon and straight down, ambient, how much of them lights the scene, .5 when not given, sunDirection and
30319
+ * sunColor, fog, its start and end, fogColor, the horizon color when not given, and shadows; what the block
30320
+ * leaves out stays as the game set it, and a level with no block changes nothing
29418
30321
  * @param {Object} level - The level, the level editor edits this same object
29419
30322
  * @return {Array<any>} - What each object's type made, a function that made nothing is left out
29420
30323
  * @memberof Level3D */
@@ -29423,7 +30326,11 @@ function level3DLoad(level)
29423
30326
  ASSERT(!!level && typeof level === 'object', 'a level is an object, {} for a new one');
29424
30327
  ASSERT(!(level.littlejs3D > LEVEL3D_VERSION), 'the level was made by a newer LittleJS');
29425
30328
  editor3DLevelLoaded(level); // debug builds: the 3D editor takes the level, its autosaved edits go in first
29426
- const made = [];
30329
+ level3DSceneApply(level.scene);
30330
+ const made = [], map = level3DVoxelMap = level3DVoxelsMake(level.voxels);
30331
+ map && made.push(map);
30332
+ const terrain = level3DTerrainMap = level3DTerrainMake(level.terrain);
30333
+ terrain && made.push(terrain);
29427
30334
  for (const object of isArray(level.objects) ? level.objects : [])
29428
30335
  {
29429
30336
  if (!object || typeof object !== 'object') continue;
@@ -29434,6 +30341,147 @@ function level3DLoad(level)
29434
30341
  return made;
29435
30342
  }
29436
30343
 
30344
+ // the sheet a level's block map is made with, and what sets each one up, see level3DVoxelSetup; and the map the
30345
+ // level loaded last made, the one the 3D editor paints
30346
+ let level3DVoxelTiles, level3DVoxelSetupMap, level3DVoxelMap;
30347
+
30348
+ /** How a level's block map is made: the sheet its blocks show tiles of, and a function to set it up
30349
+ * - A level's voxels block makes a VoxelMap when the level loads, with texture 0 and the default tile size unless
30350
+ * a sheet is given here; a block's type shows that tile of the sheet on every face
30351
+ * - setup is called with each map a level makes, to give block types their own faces or make them see-through
30352
+ * - Call it before level3DLoad; with no arguments the defaults are back
30353
+ * @param {TileInfo} [tileInfo] - The sheet's first tile, as for a VoxelMap
30354
+ * @param {function(VoxelMap): void} [setup]
30355
+ * @example
30356
+ * level3DVoxelSetup(tile(0, 16, 1), (map)=> map.setBlockType(1, {top: 0, side: 1, bottom: 2})); // grass
30357
+ * @memberof Level3D */
30358
+ function level3DVoxelSetup(tileInfo, setup)
30359
+ {
30360
+ level3DVoxelTiles = tileInfo;
30361
+ level3DVoxelSetupMap = setup;
30362
+ }
30363
+
30364
+ // the most cells a level's map may have, 256 each way
30365
+ const LEVEL3D_VOXEL_CELLS = 2 ** 24;
30366
+
30367
+ // 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
30368
+ function level3DVoxelsEncode(data)
30369
+ {
30370
+ const blocks = [];
30371
+ for (let i = 0; i < data.length;)
30372
+ {
30373
+ let end = i + 1;
30374
+ while (end < data.length && data[end] === data[i]) ++end;
30375
+ blocks.push(end - i, data[i]);
30376
+ i = end;
30377
+ }
30378
+ return blocks;
30379
+ }
30380
+
30381
+ // 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
30382
+ // is not two numbers ends them
30383
+ function level3DVoxelsDecode(blocks, data)
30384
+ {
30385
+ data.fill(0);
30386
+ if (!isArray(blocks)) return;
30387
+ for (let i = 0, cell = 0; i + 1 < blocks.length && cell < data.length; i += 2)
30388
+ {
30389
+ const count = blocks[i], type = blocks[i + 1];
30390
+ if (!isNumber(count) || !isNumber(type) || count < 0) return;
30391
+ const end = min(cell + floor(count), data.length);
30392
+ data.fill(type >= 0 && type <= 255 && type % 1 === 0 ? type : 0, cell, end);
30393
+ cell = end;
30394
+ }
30395
+ }
30396
+
30397
+ // 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
30398
+ function level3DVoxelsShape(voxels)
30399
+ {
30400
+ if (!voxels || typeof voxels !== 'object') return;
30401
+ const size = level3DVector(voxels.size);
30402
+ if (!size || [size.x, size.y, size.z].some((n)=> n < 1 || n % 1) ||
30403
+ size.x * size.y * size.z > LEVEL3D_VOXEL_CELLS) return;
30404
+ return {pos: level3DVector(voxels.pos, vec3()), size};
30405
+ }
30406
+
30407
+ // the VoxelMap of a level's voxels block, undefined when it has none or it is wrong
30408
+ function level3DVoxelsMake(voxels)
30409
+ {
30410
+ const shape = level3DVoxelsShape(voxels);
30411
+ if (!shape || typeof VoxelMap == 'undefined') return;
30412
+ const map = new VoxelMap(shape.pos, shape.size, level3DVoxelTiles || tile());
30413
+ level3DVoxelsDecode(voxels.blocks, map.data);
30414
+ level3DVoxelSetupMap?.(map);
30415
+ map.rebuild();
30416
+ return map;
30417
+ }
30418
+
30419
+ // the terrain the level loaded last made, the one the 3D editor sculpts, and the most samples it may have a side
30420
+ let level3DTerrainMap;
30421
+ const LEVEL3D_TERRAIN_SAMPLES = 129;
30422
+
30423
+ // what a level's terrain block is made with: its center, its size in the world along x and z, how tall a full
30424
+ // 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
30425
+ // one a HeightMap can be made of
30426
+ function level3DTerrainShape(terrain)
30427
+ {
30428
+ if (!terrain || typeof terrain !== 'object') return;
30429
+ const size = terrain.size, rows = terrain.heights, n = LEVEL3D_TERRAIN_SAMPLES;
30430
+ if (!isArray(size) || size.length !== 2 || !size.every((v)=> isNumber(v) && v > 0)) return;
30431
+ if (!isArray(rows) || rows.length < 2 || rows.length > n || !isArray(rows[0])) return;
30432
+ const columns = rows[0].length;
30433
+ if (columns < 2 || columns > n || !rows.every((row)=> isArray(row) && row.length === columns)) return;
30434
+ const heights = rows.map((row)=> row.map((v)=> isNumber(v) ? clamp(v) : 0));
30435
+ return {pos: level3DVector(terrain.pos, vec3()), size: vec2(size[0], size[1]),
30436
+ height: isNumber(terrain.height) && terrain.height > 0 ? terrain.height : 1, heights};
30437
+ }
30438
+
30439
+ // the HeightMap of a level's terrain block, undefined when it has none or it is wrong
30440
+ function level3DTerrainMake(terrain)
30441
+ {
30442
+ const shape = level3DTerrainShape(terrain);
30443
+ if (!shape || typeof HeightMap == 'undefined') return;
30444
+ const map = new HeightMap(shape.heights, shape.size, shape.height, undefined, shape.pos);
30445
+ if (/^#([0-9a-f]{6}|[0-9a-f]{8})$/i.test(terrain.color))
30446
+ map.color = new Color().setHex(terrain.color);
30447
+ return map;
30448
+ }
30449
+
30450
+ // the sky domes levels made, a level's sky takes the place of the one before and disposes it; a dome the game made
30451
+ // itself is left whole, for the game to put back
30452
+ const level3DSkies = new WeakSet;
30453
+
30454
+ // set the scene a level's scene block gives: sky as its top, horizon and bottom colors, with ambient how much of
30455
+ // them lights the scene, sunDirection and sunColor, fog as its start and end with fogColor, the horizon without one,
30456
+ // and shadows; a setting the block does not have, or has wrong, stays as it is
30457
+ function level3DSceneApply(scene)
30458
+ {
30459
+ const r = render3D;
30460
+ if (!r || !scene || typeof scene !== 'object') return;
30461
+ const color = (value)=> /^#([0-9a-f]{6}|[0-9a-f]{8})$/i.test(value) ? new Color().setHex(value) : undefined;
30462
+ const sky = isArray(scene.sky) && scene.sky.length === 3 ? scene.sky.map(color) : [];
30463
+ if (sky.length && sky.every((c)=> c))
30464
+ {
30465
+ // as setSky does, without disposing a dome that is the game's own
30466
+ const [top, horizon, bottom] = sky, ambient = isNumber(scene.ambient) ? scene.ambient : .5;
30467
+ level3DSkies.has(r.sky) && r.sky.dispose();
30468
+ level3DSkies.add(r.sky = buildSky(top, horizon, bottom));
30469
+ r.fogColor = horizon.copy();
30470
+ r.ambientColor = top.scale(ambient, 1);
30471
+ r.ambientGroundColor = bottom.scale(ambient, 1);
30472
+ }
30473
+ const sun = level3DVector(scene.sunDirection);
30474
+ if (sun && sun.lengthSquared())
30475
+ r.sunDirection = sun;
30476
+ r.sunColor = color(scene.sunColor) || r.sunColor;
30477
+ const fog = scene.fog;
30478
+ if (isArray(fog) && fog.length === 2 && fog.every((v)=> isNumber(v)))
30479
+ r.fogStart = fog[0], r.fogEnd = fog[1];
30480
+ r.fogColor = color(scene.fogColor) || r.fogColor;
30481
+ if (typeof scene.shadows === 'boolean')
30482
+ r.shadows = scene.shadows;
30483
+ }
30484
+
29437
30485
  // a vec3 of an array of three numbers, as the file has them, or the fallback
29438
30486
  function level3DVector(value, fallback)
29439
30487
  {
@@ -29526,9 +30574,761 @@ level3DAddType('Sphere', function(pos, properties)
29526
30574
  level3DAddType('Cylinder', function(pos, properties)
29527
30575
  { return level3DMakeShape(pos, properties, level3DCylinderMesh ||= buildCylinder()); },
29528
30576
  {color: WHITE, tile: -1, solid: true});
30577
+ // a light, a spotlight with a cone: cone is the angle in degrees from its forward out to the edge of its beam, the
30578
+ // object's rotation aims it, softness is how much of the cone fades, and shadows makes it the one that casts them
29529
30579
  level3DAddType('Light', function(pos, properties)
29530
- { return new Light3D(pos, properties.radius, properties.color, properties.intensity); },
29531
- {color: WHITE, radius: 5, intensity: 1});
30580
+ {
30581
+ const light = new Light3D(pos, properties.radius, properties.color, properties.intensity);
30582
+ light.coneAngle = max(properties.cone, 0) * PI / 180;
30583
+ light.coneSoftness = properties.softness;
30584
+ if (properties.shadows && render3D)
30585
+ render3D.shadowLight = light;
30586
+ return light;
30587
+ }, {color: WHITE, radius: 5, intensity: 1, cone: 0, softness: .2, shadows: false});
30588
+
30589
+ /**
30590
+ * LittleJS Particle Effects Plugin
30591
+ * - Ready made particle effects in one line, particleEffect('fire', pos), and particleEffect3D for 3D
30592
+ * - Every built-in effect is tuned around a one unit emitter; options.scale grows it to fit, options.hue and
30593
+ * options.saturation recolor it
30594
+ * - Effects are plain data, {name, settings, behaviors}, the format the particle designer saves: load a library the
30595
+ * designer saved with particleEffectsLoad and play its effects by name
30596
+ * - The built-in effects draw with a sheet of shapes the plugin draws itself, so they need no image
30597
+ * @namespace ParticleEffects
30598
+ */
30599
+
30600
+ ///////////////////////////////////////////////////////////////////////////////
30601
+ // settings, what an effect sets on an emitter
30602
+
30603
+ /** The groups the settings are in, in order
30604
+ * @type {Array<string>}
30605
+ * @memberof ParticleEffects */
30606
+ const particleEffectGroups = ['Emitter', 'Particles', 'Color', 'Motion', 'Collision', 'Texture', 'Behaviors'];
30607
+
30608
+ /** Every setting an effect has: its name, kind (number, checkbox, color or shape), default, the range the designer's
30609
+ * slider covers (min, max, step), the hard limits a typed value is clamped to, and whether it is set after the
30610
+ * emitter is made rather than passed to its constructor (extra)
30611
+ * @type {Array<{group:string, name:string, kind:string, value:any, min:number, max:number, step:number,
30612
+ * description:string, hardMin:number, hardMax:number, extra:boolean}>}
30613
+ * @memberof ParticleEffects */
30614
+ const particleEffectSettings = [];
30615
+ let particleEffectSettingGroup; // the group settings are being added to
30616
+
30617
+ // the slider covers min to max, typed values may go out to hardMin and hardMax
30618
+ function particleEffectAddSetting(name, kind, value, min, max, step, description, hardMin=min, hardMax=max)
30619
+ {
30620
+ // extras are not constructor arguments, the code export assigns them after
30621
+ const extra = ['trailScale', 'velocityInheritance', 'localSpace', 'restitution', 'friction', 'gravity'].includes(name);
30622
+ particleEffectSettings.push({group:particleEffectSettingGroup, name, kind, value, min, max, step, description,
30623
+ hardMin, hardMax, extra});
30624
+ }
30625
+
30626
+ particleEffectSettingGroup = 'Emitter';
30627
+ particleEffectAddSetting('emitRate', 'number', 100, 0, 500, .1,
30628
+ 'Particles per second, 0 emits none', 0, 1e4);
30629
+ particleEffectAddSetting('emitTime', 'number', 0, 0, 5, .05,
30630
+ 'Seconds to emit for, 0 is forever', 0, 1e9);
30631
+ particleEffectAddSetting('emitSize', 'number', 0, 0, 10, .05,
30632
+ 'Diameter, or width when rectangular', 0, 1e9);
30633
+ particleEffectAddSetting('emitRect', 'checkbox', false, 0, 0, 0,
30634
+ 'Rectangle emitter, off is a circle');
30635
+ particleEffectAddSetting('emitHeight', 'number', 0, 0, 10, .05,
30636
+ 'Rectangle height', 0, 1e9);
30637
+ particleEffectAddSetting('emitConeAngle', 'number', PI, 0, PI, .01,
30638
+ 'Half angle particles move in, 3.14 is all ways');
30639
+ particleEffectAddSetting('angle', 'number', 0, -3.14, 3.14, .01,
30640
+ 'Emitter angle, 0 points up', -PI, PI); // the slider steps from its min
30641
+
30642
+ particleEffectSettingGroup = 'Particles';
30643
+ particleEffectAddSetting('particleTime', 'number', .5, 0, 5, .05,
30644
+ 'Seconds each particle lives', 0, 1e9);
30645
+ particleEffectAddSetting('sizeStart', 'number', .1, 0, 5, .01,
30646
+ 'Size at the start of life', 0, 1e9);
30647
+ particleEffectAddSetting('sizeEnd', 'number', 1, 0, 5, .01,
30648
+ 'Size at the end of life', 0, 1e9);
30649
+ particleEffectAddSetting('fadeRate', 'number', .1, 0, 1, .01,
30650
+ 'Share of life spent fading in and out');
30651
+ particleEffectAddSetting('randomness', 'number', .2, 0, 1, .01,
30652
+ 'How much each particle varies');
30653
+ particleEffectAddSetting('particleConeAngle', 'number', PI, 0, PI, .01,
30654
+ 'Half angle of start rotation, 3.14 is any');
30655
+ particleEffectAddSetting('trailScale', 'number', 0, 0, 20, .1,
30656
+ 'Stretch along the motion, 0 is off', 0, 1e9);
30657
+ particleEffectAddSetting('additive', 'checkbox', false, 0, 0, 0,
30658
+ 'Glow with additive blending');
30659
+
30660
+ particleEffectSettingGroup = 'Color';
30661
+ particleEffectAddSetting('colorStartA', 'color', [1,1,1,1], 0, 1, .01,
30662
+ 'Start color, each particle picks between A and B');
30663
+ particleEffectAddSetting('colorStartB', 'color', [1,1,1,1], 0, 1, .01,
30664
+ 'Second start color');
30665
+ particleEffectAddSetting('colorEndA', 'color', [1,1,1,0], 0, 1, .01,
30666
+ 'End color, each particle picks between A and B');
30667
+ particleEffectAddSetting('colorEndB', 'color', [1,1,1,0], 0, 1, .01,
30668
+ 'Second end color');
30669
+ particleEffectAddSetting('randomColorLinear', 'checkbox', true, 0, 0, 0,
30670
+ 'Pick along A to B, off picks each channel');
30671
+
30672
+ particleEffectSettingGroup = 'Motion';
30673
+ particleEffectAddSetting('speed', 'number', .1, 0, .5, .005,
30674
+ 'Start speed, world units per frame', 0, 1e9);
30675
+ particleEffectAddSetting('angleSpeed', 'number', .05, 0, .5, .005,
30676
+ 'Spin speed, radians per frame', 0, 1e9);
30677
+ particleEffectAddSetting('damping', 'number', 1, .8, 1, .001,
30678
+ 'Speed kept each frame, 1 keeps it all', 0, 1);
30679
+ particleEffectAddSetting('angleDamping', 'number', 1, .8, 1, .001,
30680
+ 'Spin kept each frame', 0, 1);
30681
+ particleEffectAddSetting('gravityScale', 'number', 0, -2, 2, .05,
30682
+ 'How much gravity pulls', -1e9, 1e9);
30683
+ particleEffectAddSetting('gravity', 'number', 0, -.02, .02, .0005,
30684
+ 'Its own fall each frame, the same in any game', -1, 1);
30685
+ particleEffectAddSetting('velocityInheritance', 'number', 0, 0, 1, .01,
30686
+ 'Share of emitter motion passed on');
30687
+ particleEffectAddSetting('localSpace', 'checkbox', false, 0, 0, 0,
30688
+ 'Particles move with the emitter');
30689
+
30690
+ particleEffectSettingGroup = 'Collision';
30691
+ particleEffectAddSetting('collideLevel', 'checkbox', false, 0, 0, 0,
30692
+ 'Hit tiles, the preview adds a floor');
30693
+ particleEffectAddSetting('restitution', 'number', 0, 0, 1, .01,
30694
+ 'Bounce when hitting tiles');
30695
+ particleEffectAddSetting('friction', 'number', .8, 0, 1, .01,
30696
+ 'Speed kept sliding along tiles');
30697
+
30698
+ particleEffectSettingGroup = 'Texture';
30699
+ particleEffectAddSetting('shape', 'shape', 'soft', 0, 0, 0,
30700
+ 'Built-in shape, none uses the tile');
30701
+ particleEffectAddSetting('tileIndex', 'number', -1, -1, 63, 1,
30702
+ 'Tile in texture 0 when no shape, -1 is untextured', -1, 1e4);
30703
+ particleEffectAddSetting('tileSize', 'number', 16, 1, 128, 1,
30704
+ 'Tile size in texture pixels', 1, 4096);
30705
+ particleEffectAddSetting('tilePadding', 'number', 1, 0, 8, 1,
30706
+ 'Pixels of padding around each tile', 0, 64);
30707
+
30708
+ /** The behaviors an effect can use, each a push applied to every particle every update, with a 2D and a 3D version
30709
+ * @type {Array<{name:string, update:(Function|undefined), update3D:(Function|undefined), min:number, max:number,
30710
+ * value:number, description:string}>}
30711
+ * @memberof ParticleEffects */
30712
+ const particleEffectBehaviors =
30713
+ [
30714
+ {name:'wobble', min:0, max:5, value:1, description:'Sway from side to side'},
30715
+ {name:'swirl', min:-2, max:2, value:1, description:'Turn the direction of travel'},
30716
+ {name:'turbulence', min:0, max:5, value:1, description:'Random pushes every frame'},
30717
+ {name:'attract', min:-2, max:2, value:1, description:'Pull toward the emitter, negative pushes away'},
30718
+ {name:'orbit', min:-2, max:2, value:1, description:'Push around the emitter, add attract to circle it'},
30719
+ {name:'wind', min:-2, max:2, value:1, description:'A sideways push that grows with age'},
30720
+ {name:'stick', min:0, max:1, value:1, description:'Grip on landing, pair with collideLevel'},
30721
+ ].map(b=> ({...b, update: undefined, update3D: undefined}));
30722
+ const particleEffectBehavior = (name)=> particleEffectBehaviors.find(b=> b.name === name);
30723
+
30724
+ /** Names of the shapes the plugin draws, for an effect's shape setting
30725
+ * @type {Array<string>}
30726
+ * @memberof ParticleEffects */
30727
+ const particleEffectShapes = ['dot', 'soft', 'glow', 'smoke', 'spark', 'square', 'triangle', 'ring', 'star', 'plus'];
30728
+
30729
+ ///////////////////////////////////////////////////////////////////////////////
30730
+ // effects
30731
+
30732
+ // a color from an [r,g,b,a] array or a Color, undefined if it is neither
30733
+ function particleEffectColor(value)
30734
+ {
30735
+ if (value instanceof Color)
30736
+ value = [value.r, value.g, value.b, value.a];
30737
+ if (isArray(value) && value.length === 4 && value.every(isNumber))
30738
+ return value.map(c=> clamp(c));
30739
+ }
30740
+
30741
+ /** Any input into a whole effect: missing fields take their defaults, bad ones are dropped or clamped
30742
+ * @param {Object} [raw] - {name, settings, behaviors}, from a library file, the designer or code
30743
+ * @return {{name:string, settings:Object, behaviors:Array<{name:string, strength:number}>}}
30744
+ * @memberof ParticleEffects */
30745
+ function particleEffectSanitize(raw)
30746
+ {
30747
+ // one line of text, line breaks and control characters made spaces
30748
+ const text = typeof raw?.name === 'string' ? raw.name.replace(/[\x00-\x1f\x7f]+/g, ' ').trim() : '';
30749
+ const name = text ? text.slice(0, 60) : 'Effect';
30750
+ const input = raw?.settings && typeof raw.settings === 'object' ? {...raw.settings} : {};
30751
+ input.collideLevel ??= input.collideTiles; // its name before 1.20
30752
+ // a library saved before shapes names its tile and no shape, it keeps its tile
30753
+ if (input.shape === undefined && isNumber(input.tileIndex))
30754
+ input.shape = '';
30755
+ const settings = {};
30756
+ for (const setting of particleEffectSettings)
30757
+ {
30758
+ const value = input[setting.name], fallback = setting.value;
30759
+ if (setting.kind === 'checkbox')
30760
+ settings[setting.name] = typeof value === 'boolean' ? value : value === 0 || value === 1 ? !!value : fallback;
30761
+ else if (setting.kind === 'color')
30762
+ settings[setting.name] = particleEffectColor(value) || fallback.slice();
30763
+ else if (setting.kind === 'shape')
30764
+ settings[setting.name] = value === '' || particleEffectShapes.includes(value) ? value : fallback;
30765
+ else if (isNumber(value))
30766
+ {
30767
+ const clamped = clamp(value, setting.hardMin, setting.hardMax);
30768
+ settings[setting.name] = setting.step >= 1 ? round(clamped) : clamped;
30769
+ }
30770
+ else
30771
+ settings[setting.name] = fallback;
30772
+ }
30773
+
30774
+ // local space particles are placed relative to the emitter, the tile collision is in the world
30775
+ if (settings.localSpace)
30776
+ settings.collideLevel = false;
30777
+
30778
+ // known behaviors once each, in table order
30779
+ const behaviors = [];
30780
+ const list = isArray(raw?.behaviors) ? raw.behaviors : [];
30781
+ for (const b of particleEffectBehaviors)
30782
+ {
30783
+ const found = list.find(x=> x?.name === b.name);
30784
+ if (!found)
30785
+ continue;
30786
+ const strength = isNumber(found.strength) ? clamp(found.strength, b.min, b.max) : b.value;
30787
+ behaviors.push({name:b.name, strength});
30788
+ }
30789
+
30790
+ // behaviors not known yet stay, a game may add them after the effect is loaded
30791
+ for (const x of list)
30792
+ if (typeof x?.name === 'string' && x.name && !particleEffectBehavior(x.name) &&
30793
+ !behaviors.some(b=> b.name === x.name))
30794
+ behaviors.push({name:x.name, strength: isNumber(x.strength) ? x.strength : 1});
30795
+ return {name, settings, behaviors};
30796
+ }
30797
+
30798
+ /** A copy of an effect with its four colors turned around the color wheel and their saturation scaled; lightness and
30799
+ * alpha stay, and grey and white have no hue to turn
30800
+ * @param {Object} effect
30801
+ * @param {number} [hue] - How far around the wheel, 1 is all the way
30802
+ * @param {number} [saturation] - Multiplies the saturation, 0 is grey, clamped to 1
30803
+ * @return {Object}
30804
+ * @memberof ParticleEffects */
30805
+ function particleEffectRecolor(effect, hue=0, saturation=1)
30806
+ {
30807
+ effect = particleEffectSanitize(effect); // colors as channels, whatever it was given
30808
+ const settings = {...effect.settings};
30809
+ for (const name of ['colorStartA', 'colorStartB', 'colorEndA', 'colorEndB'])
30810
+ {
30811
+ const [h, s, l, a] = new Color(...settings[name]).HSLA();
30812
+ const c = new Color().setHSLA(mod(h + hue), clamp(s * saturation), l, a);
30813
+ settings[name] = [c.r, c.g, c.b, c.a];
30814
+ }
30815
+ return {...effect, settings, behaviors: effect.behaviors.map(b=> ({...b}))};
30816
+ }
30817
+
30818
+ ///////////////////////////////////////////////////////////////////////////////
30819
+ // libraries
30820
+
30821
+ // the effects by lower case name, the built-ins and any a game adds or loads
30822
+ const particleEffectsByName = new Map;
30823
+
30824
+ /** Add effects to play by name, sanitized; one with the name of an effect already there replaces it
30825
+ * @param {Array<Object>|Object} effects
30826
+ * @memberof ParticleEffects */
30827
+ function particleEffectsAdd(effects)
30828
+ {
30829
+ for (const effect of isArray(effects) ? effects : [effects])
30830
+ {
30831
+ const clean = particleEffectSanitize(effect);
30832
+ particleEffectsByName.set(clean.name.toLowerCase(), clean);
30833
+ }
30834
+ }
30835
+
30836
+ /** The effect with a name, any case, undefined if there is none
30837
+ * @param {string} name
30838
+ * @return {Object|undefined}
30839
+ * @memberof ParticleEffects */
30840
+ function particleEffectsGet(name) { return particleEffectsByName.get(String(name).toLowerCase()); }
30841
+
30842
+ /** A library file, or one effect, into sanitized effects; throws on bad input
30843
+ * @param {string} text
30844
+ * @return {Array<Object>}
30845
+ * @memberof ParticleEffects */
30846
+ function particleEffectsParse(text)
30847
+ {
30848
+ const data = JSON.parse(text);
30849
+ const list = isArray(data?.effects) ? data.effects : data?.settings ? [data] : undefined;
30850
+ if (!list || !list.length)
30851
+ throw new Error('no effects in this file');
30852
+ return list.map(particleEffectSanitize);
30853
+ }
30854
+
30855
+ /** The text of a library file with these effects, what particleEffectsLoad and the designer read
30856
+ * @param {Array<Object>} effects
30857
+ * @return {string}
30858
+ * @memberof ParticleEffects */
30859
+ function particleEffectsText(effects) { return JSON.stringify({version:1, effects}, undefined, 1); }
30860
+
30861
+ /** Load a library file the particle designer saved and add its effects
30862
+ * @param {string} url
30863
+ * @return {Promise<Array<Object>>} - The effects it had
30864
+ * @memberof ParticleEffects */
30865
+ async function particleEffectsLoad(url)
30866
+ {
30867
+ const effects = particleEffectsParse(JSON.stringify(await fetchJSON(url)));
30868
+ particleEffectsAdd(effects);
30869
+ return effects;
30870
+ }
30871
+
30872
+ ///////////////////////////////////////////////////////////////////////////////
30873
+ // the shape sheet: every shape white on clear in a cell of one canvas, drawn the first time one is asked for
30874
+
30875
+ let particleEffectShapeTiles;
30876
+
30877
+ /** The tile of a built-in shape, on a sheet the plugin draws once; undefined headless, without a canvas or without
30878
+ * WebGL, where an effect draws untextured squares
30879
+ * @param {string} name - One of particleEffectShapes
30880
+ * @return {TileInfo|undefined}
30881
+ * @memberof ParticleEffects */
30882
+ function particleEffectShapeTile(name)
30883
+ {
30884
+ if (!particleEffectShapeTiles)
30885
+ {
30886
+ if (headlessMode || !glContext || typeof OffscreenCanvas == 'undefined') return;
30887
+ // drawn at 4 times 32 pixel cells, so a big soft shape stays smooth when textures are pixelated
30888
+ const cell = 32, r = 15, count = particleEffectShapes.length, res = 4;
30889
+ const context = createCanvasContext(cell * count * res, cell * res);
30890
+ context.scale(res, res);
30891
+ context.fillStyle = context.strokeStyle = '#fff';
30892
+ const soft = (x, y, radius, alpha=(t)=> 1 - t)=>
30893
+ {
30894
+ // a round falloff from the middle out
30895
+ const g = context.createRadialGradient(x, y, 0, x, y, radius);
30896
+ for (let i = 0; i <= 8; ++i)
30897
+ g.addColorStop(i/8, 'rgba(255,255,255,' + alpha(i/8).toFixed(3) + ')');
30898
+ context.fillStyle = g;
30899
+ context.beginPath(); context.arc(x, y, radius, 0, 2*PI); context.fill();
30900
+ context.fillStyle = '#fff';
30901
+ };
30902
+ const draw =
30903
+ {
30904
+ dot: (x, y)=> { context.beginPath(); context.arc(x, y, r, 0, 2*PI); context.fill(); },
30905
+ soft: (x, y)=> soft(x, y, r),
30906
+ glow: (x, y)=> soft(x, y, r, (t)=> engineGlowAlpha(t, 1)),
30907
+ smoke: (x, y)=>
30908
+ {
30909
+ // a lumpy puff of soft circles
30910
+ for (const [dx, dy, dr] of [[-4,-3,9], [4,-2,8], [0,4,9], [-5,5,6], [6,5,6]])
30911
+ soft(x + dx, y + dy, dr, (t)=> (1 - t) * .7);
30912
+ },
30913
+ spark: (x, y)=>
30914
+ {
30915
+ // a glow squeezed thin, a streak along the tile's up
30916
+ context.save(); context.translate(x, y); context.scale(.3, 1);
30917
+ soft(0, 0, r, (t)=> engineGlowAlpha(t, .6));
30918
+ context.restore();
30919
+ },
30920
+ square: (x, y)=> context.fillRect(x - r, y - r, 2*r, 2*r),
30921
+ triangle: (x, y)=>
30922
+ {
30923
+ context.beginPath(); context.moveTo(x, y - r); context.lineTo(x + r, y + r); context.lineTo(x - r, y + r);
30924
+ context.fill();
30925
+ },
30926
+ ring: (x, y)=>
30927
+ {
30928
+ context.lineWidth = 3;
30929
+ context.beginPath(); context.arc(x, y, r - 2, 0, 2*PI); context.stroke();
30930
+ },
30931
+ star: (x, y)=>
30932
+ {
30933
+ context.beginPath();
30934
+ for (let i = 0; i < 10; ++i)
30935
+ {
30936
+ const a = i * PI / 5 - PI/2, d = i % 2 ? r * .45 : r;
30937
+ context.lineTo(x + cos(a) * d, y + sin(a) * d);
30938
+ }
30939
+ context.fill();
30940
+ },
30941
+ plus: (x, y)=> { context.fillRect(x - r, y - 4, 2*r, 8); context.fillRect(x - 4, y - r, 8, 2*r); },
30942
+ };
30943
+ particleEffectShapes.forEach((name, i)=> draw[name](i * cell + cell/2, cell/2));
30944
+ const texture = new TextureInfo(context.canvas, true, false, false); // smooth even in a pixel art game
30945
+ particleEffectShapeTiles = new Map(particleEffectShapes.map((name, i)=>
30946
+ [name, new TileInfo(vec2((i * cell + 1) * res, res), vec2((cell - 2) * res), texture)]));
30947
+ }
30948
+ return particleEffectShapeTiles.get(name);
30949
+ }
30950
+
30951
+ ///////////////////////////////////////////////////////////////////////////////
30952
+ // behaviors, the 2D pushes: a distance moved grows with the particle's scale, so a bigger effect moves the same way
30953
+
30954
+ {
30955
+ const b = particleEffectBehavior;
30956
+ b('wobble').update = (p, s)=>
30957
+ {
30958
+ // each particle on its own beat, from its randomized life, so nothing is added to it
30959
+ p.velocity.x += s * .003 * p.scale * sin(time*6 + p.lifeTime * 97 % 9);
30960
+ };
30961
+ b('swirl').update = (p, s)=>
30962
+ {
30963
+ // turn the direction of travel a little each frame
30964
+ const a = s * .05, c = cos(a), n = sin(a), v = p.velocity;
30965
+ v.set(v.x*c - v.y*n, v.x*n + v.y*c);
30966
+ };
30967
+ b('turbulence').update = (p, s)=>
30968
+ {
30969
+ p.velocity.x += rand(-1, 1) * s * .005 * p.scale;
30970
+ p.velocity.y += rand(-1, 1) * s * .005 * p.scale;
30971
+ };
30972
+ b('attract').update = (p, s)=>
30973
+ {
30974
+ // pull toward the emitter by the distance, which grows with the scale already
30975
+ const e = p.emitter, x = e.localSpace ? 0 : e.pos.x, y = e.localSpace ? 0 : e.pos.y;
30976
+ p.velocity.x += (x - p.pos.x) * s * .002;
30977
+ p.velocity.y += (y - p.pos.y) * s * .002;
30978
+ };
30979
+ b('orbit').update = (p, s)=>
30980
+ {
30981
+ const e = p.emitter, x = e.localSpace ? 0 : e.pos.x, y = e.localSpace ? 0 : e.pos.y;
30982
+ p.velocity.x -= (p.pos.y - y) * s * .002;
30983
+ p.velocity.y += (p.pos.x - x) * s * .002;
30984
+ };
30985
+ b('wind').update = (p, s)=>
30986
+ p.velocity.x += s * .004 * p.scale * min((time - p.spawnTime) / p.lifeTime, 1);
30987
+ b('stick').update = (p, s)=>
30988
+ {
30989
+ // grip the ground on landing, pair with collideLevel
30990
+ if (!p.groundObject) return;
30991
+ p.velocity.x *= 1 - s;
30992
+ p.angleVelocity *= 1 - s;
30993
+ };
30994
+ }
30995
+
30996
+ /** Add a behavior effects can use by name, or replace one
30997
+ * @param {string} name
30998
+ * @param {function(Particle, number): void} update - Pushes a 2D particle, given the strength
30999
+ * @param {function(Particle3D, number): void} [update3D] - The same for a 3D particle, none leaves 3D alone
31000
+ * @param {number} [min] - Strength range the designer offers
31001
+ * @param {number} [max]
31002
+ * @param {number} [value] - Strength when first added
31003
+ * @param {string} [description]
31004
+ * @memberof ParticleEffects */
31005
+ function particleEffectsAddBehavior(name, update, update3D, min=-2, max=2, value=1, description='')
31006
+ {
31007
+ const old = particleEffectBehavior(name);
31008
+ old && particleEffectBehaviors.splice(particleEffectBehaviors.indexOf(old), 1);
31009
+ particleEffectBehaviors.push({name, update, update3D, min, max, value, description});
31010
+ }
31011
+
31012
+ // the update callback that runs an effect's behaviors, in 2D or 3D, undefined when it has none for that
31013
+ function particleEffectUpdateCallback(behaviors, is3D)
31014
+ {
31015
+ const calls = behaviors.map(b=> [particleEffectBehavior(b.name)?.[is3D ? 'update3D' : 'update'], b.strength])
31016
+ .filter(c=> c[0]);
31017
+ if (calls.length)
31018
+ return (p)=> { for (const [update, strength] of calls) update(p, strength); };
31019
+ }
31020
+
31021
+ ///////////////////////////////////////////////////////////////////////////////
31022
+ // 2D effects
31023
+
31024
+ // the settings the builders set themselves instead of copying across
31025
+ const particleEffectIndirect = ['emitSize', 'emitRect', 'emitHeight', 'tileIndex', 'tileSize', 'tilePadding', 'shape'];
31026
+
31027
+ // the tile an effect draws with: its shape, else its tile in texture 0 when that fits, else none
31028
+ function particleEffectTileInfo(s)
31029
+ {
31030
+ if (s.shape)
31031
+ return particleEffectShapeTile(s.shape);
31032
+ if (s.tileIndex < 0) return;
31033
+ const texture = textureInfos[0], cell = s.tileSize + s.tilePadding*2;
31034
+ if (!texture || !texture.size.x) return;
31035
+ if (s.tileIndex < floor(texture.size.x / cell) * floor(texture.size.y / cell))
31036
+ return tile(s.tileIndex, s.tileSize, 0, s.tilePadding);
31037
+ }
31038
+
31039
+ /** Set a 2D emitter to an effect, live, so a running one keeps its particles
31040
+ * @param {ParticleEmitter} emitter
31041
+ * @param {Object} effect
31042
+ * @memberof ParticleEffects */
31043
+ function particleEffectApply(emitter, effect)
31044
+ {
31045
+ effect = particleEffectSanitize(effect); // an effect written by hand or changed since it was added
31046
+ const s = effect.settings, ended = emitter.emitTime < 0; // destroyed, its last particles going
31047
+ for (const setting of particleEffectSettings)
31048
+ if (!particleEffectIndirect.includes(setting.name))
31049
+ emitter[setting.name] = setting.kind === 'color' ? new Color(...s[setting.name]) : s[setting.name];
31050
+ ended && (emitter.emitTime = -1);
31051
+ emitter.emitCircle = !s.emitRect;
31052
+ emitter.emitSize = vec2(s.emitSize, s.emitRect ? s.emitHeight : s.emitSize);
31053
+ emitter.tileInfo = particleEffectTileInfo(s);
31054
+ emitter.renderOrder = s.additive ? 1e9 : 0;
31055
+ emitter.particleUpdateCallback = particleEffectUpdateCallback(effect.behaviors);
31056
+ }
31057
+
31058
+ // an effect from a name or an effect, recolored by the options
31059
+ function particleEffectResolve(nameOrEffect, options)
31060
+ {
31061
+ const found = typeof nameOrEffect == 'string' ? particleEffectsGet(nameOrEffect) : nameOrEffect;
31062
+ ASSERT(!!found, 'no particle effect named ' + nameOrEffect);
31063
+ if (!found) return;
31064
+ // any setting in the options replaces the effect's own for this play, emitTime for a burst or a loop
31065
+ const settings = {...found.settings};
31066
+ for (const setting of particleEffectSettings)
31067
+ if (options[setting.name] !== undefined)
31068
+ settings[setting.name] = options[setting.name];
31069
+ // sanitized each time, an effect may be written by hand or changed after it was added
31070
+ const effect = particleEffectSanitize({...found, settings}), {hue=0, saturation=1} = options;
31071
+ return hue || saturation != 1 ? particleEffectRecolor(effect, hue, saturation) : effect;
31072
+ }
31073
+
31074
+ /** Play an effect: a 2D emitter set to it, placed, scaled and recolored
31075
+ * - A continuous effect (fire, a torch) goes until destroyed or given an emitTime, a one-shot ends itself
31076
+ * - Any setting in the options replaces the effect's own for this play: {emitTime: .5} for a burst of a continuous
31077
+ * effect, {emitTime: 0, emitRate: 30} to keep a one-shot going, or speed, particleTime and the rest
31078
+ * - Attach it to an object with addChild to follow it
31079
+ * @param {string|Object} nameOrEffect - A built-in or added effect's name, or an effect
31080
+ * @param {Vector2} [pos]
31081
+ * @param {Object} [options]
31082
+ * @param {number} [options.scale] - Grows the whole effect, the built-ins fit a one unit object at 1
31083
+ * @param {number} [options.hue] - Turns its colors around the color wheel, 1 is all the way
31084
+ * @param {number} [options.saturation] - Multiplies its saturation, 0 is grey
31085
+ * @param {number} [options.angle] - Direction, 0 is up; the effect's own angle when not given
31086
+ * @param {TileInfo|TextureInfo} [options.tileInfo] - The game's own art to draw with in place of the effect's shape,
31087
+ * tinted by its colors; a whole texture draws as one tile
31088
+ * @param {*} [options.settings] - Any effect setting by its name, emitTime, emitRate, speed and the rest, replacing
31089
+ * the effect's own for this play
31090
+ * @return {ParticleEmitter|undefined} - undefined when there is no such effect
31091
+ * @memberof ParticleEffects */
31092
+ function particleEffect(nameOrEffect, pos=vec2(), options={})
31093
+ {
31094
+ const effect = particleEffectResolve(nameOrEffect, options);
31095
+ if (!effect) return;
31096
+ const emitter = new ParticleEmitter(pos.copy());
31097
+ particleEffectApply(emitter, effect);
31098
+ emitter.scale = options.scale ?? 1;
31099
+ if (options.angle !== undefined)
31100
+ emitter.angle = options.angle;
31101
+ const tileInfo = options.tileInfo; // the game's own art in place of the shape, a whole texture as one tile
31102
+ if (tileInfo)
31103
+ emitter.tileInfo = tileInfo instanceof TextureInfo ? new TileInfo(vec2(), tileInfo.size, tileInfo) : tileInfo;
31104
+ return emitter;
31105
+ }
31106
+
31107
+ /** An effect with a 2D emitter's settings, to save, build again, or build in 3D with particleEffect3D; its tile is
31108
+ * left out, since a hand made emitter's tile is its own texture and not one an effect can name, and so is its
31109
+ * scale, which an effect does not keep: pass it again with options.scale
31110
+ * @param {ParticleEmitter} emitter
31111
+ * @param {string} [name]
31112
+ * @return {Object}
31113
+ * @memberof ParticleEffects */
31114
+ function particleEffectFromEmitter(emitter, name='Effect')
31115
+ {
31116
+ const settings = {};
31117
+ for (const setting of particleEffectSettings)
31118
+ if (!particleEffectIndirect.includes(setting.name) && emitter[setting.name] !== undefined)
31119
+ settings[setting.name] = emitter[setting.name]; // a Color is taken as its channels by the sanitizer
31120
+ settings.emitRect = !emitter.emitCircle;
31121
+ settings.emitSize = emitter.emitSize.x;
31122
+ settings.emitHeight = emitter.emitSize.y;
31123
+ settings.shape = '';
31124
+ settings.tileIndex = -1;
31125
+ return particleEffectSanitize({name, settings});
31126
+ }
31127
+
31128
+ ///////////////////////////////////////////////////////////////////////////////
31129
+ // 3D effects
31130
+
31131
+ // the 3D pushes: a 3D particle is one view object the emitter reuses, so what differs per particle comes from its
31132
+ // randomized lifetime, and distances grow with the emitter's scale
31133
+ {
31134
+ const b = particleEffectBehavior;
31135
+ const center = (p)=> p.emitter.worldPos3D || p.emitter.pos3D; // where it was this update
31136
+ b('wobble').update3D = (p, s)=> p.velocity.x += s * .003 * p.scale * sin(time*6 + p.lifeTime * 97 % 9);
31137
+ b('swirl').update3D = (p, s)=>
31138
+ {
31139
+ const a = s * .05, c = cos(a), n = sin(a), v = p.velocity;
31140
+ v.set(v.x*c - v.y*n, v.x*n + v.y*c, v.z);
31141
+ };
31142
+ b('turbulence').update3D = (p, s)=>
31143
+ {
31144
+ const k = s * .005 * p.scale;
31145
+ p.velocity.x += rand(-1, 1) * k, p.velocity.y += rand(-1, 1) * k, p.velocity.z += rand(-1, 1) * k;
31146
+ };
31147
+ b('attract').update3D = (p, s)=>
31148
+ {
31149
+ const c = center(p);
31150
+ p.velocity.x += (c.x - p.pos.x) * s * .002;
31151
+ p.velocity.y += (c.y - p.pos.y) * s * .002;
31152
+ p.velocity.z += (c.z - p.pos.z) * s * .002;
31153
+ };
31154
+ b('orbit').update3D = (p, s)=>
31155
+ {
31156
+ const c = center(p);
31157
+ p.velocity.x -= (p.pos.y - c.y) * s * .002;
31158
+ p.velocity.y += (p.pos.x - c.x) * s * .002;
31159
+ };
31160
+ b('wind').update3D = (p, s)=> p.velocity.x += s * .004 * p.scale * min(p.age / p.lifeTime, 1);
31161
+ // stick has no 3D push: particleEffect3D makes it the friction a particle lands with
31162
+ }
31163
+
31164
+ /** Set a 3D emitter to an effect, live, so a running one keeps its particles; its place, scale and flatten stay
31165
+ * @param {ParticleEmitter3D} emitter
31166
+ * @param {Object} effect
31167
+ * @memberof ParticleEffects */
31168
+ function particleEffectApply3D(emitter, effect)
31169
+ {
31170
+ effect = particleEffectSanitize(effect); // an effect written by hand or changed since it was added
31171
+ const s = effect.settings, e = emitter;
31172
+ // a circle is a sphere and a rectangle a box as deep as it is wide
31173
+ e.emitSize = s.emitRect ? vec3(s.emitSize, s.emitHeight, s.emitSize) : s.emitSize;
31174
+ e.emitTime = e.emitTime < 0 ? -1 : s.emitTime; // destroyed, its last particles going, stays so
31175
+ e.emitRate = s.emitRate, e.emitConeAngle = s.emitConeAngle;
31176
+ e.tileInfo = particleEffectTileInfo(s);
31177
+ for (const name of ['colorStartA', 'colorStartB', 'colorEndA', 'colorEndB'])
31178
+ e[name] = new Color(...s[name]);
31179
+ for (const name of ['particleTime', 'sizeStart', 'sizeEnd', 'speed', 'damping', 'gravity', 'fadeRate',
31180
+ 'randomness', 'additive', 'gravityScale', 'angleSpeed', 'angleDamping', 'collideLevel', 'restitution'])
31181
+ e[name] = s[name];
31182
+ e.trailTime = s.trailScale / 60; // a stretch of speed times trailScale is a streak of that many frames
31183
+ const stick = effect.behaviors.find(b=> b.name == 'stick'); // in 3D, a grip on landing is less sliding
31184
+ e.friction = stick ? min(s.friction, 1 - stick.strength) : s.friction;
31185
+ // 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
31186
+ e.rotation3D = vec3(0, 0, -s.angle);
31187
+ e.particleUpdateCallback = particleEffectUpdateCallback(effect.behaviors, true);
31188
+ }
31189
+
31190
+ /** Play an effect in 3D: a ParticleEmitter3D set to it, placed, scaled and recolored
31191
+ * - The same effect data as particleEffect, so the look carries across: a circle spawn area becomes a sphere and a
31192
+ * rectangle a box as deep as it is wide, both flat across the way it emits with options.flatten, a disc or a
31193
+ * sheet; a trail becomes a streak of the same length, and the settings the 3D emitter lacks (particleConeAngle,
31194
+ * randomColorLinear, velocityInheritance, localSpace) are left out; the stick behavior becomes the friction a
31195
+ * particle lands with
31196
+ * @param {string|Object} nameOrEffect - A built-in or added effect's name, or an effect
31197
+ * @param {Vector3} [pos3D]
31198
+ * @param {Object} [options] - scale, hue, saturation, angle, tileInfo and any setting as particleEffect; angle turns it
31199
+ * about z, so 0 is up; flatten makes the spawn area flat across the way it emits
31200
+ * @return {ParticleEmitter3D|undefined} - undefined when there is no such effect
31201
+ * @memberof ParticleEffects */
31202
+ function particleEffect3D(nameOrEffect, pos3D=vec3(), options={})
31203
+ {
31204
+ const effect = particleEffectResolve(nameOrEffect, options);
31205
+ if (!effect) return;
31206
+ const e = new ParticleEmitter3D(pos3D.copy());
31207
+ particleEffectApply3D(e, effect);
31208
+ if (options.angle !== undefined)
31209
+ e.rotation3D = vec3(0, 0, -options.angle);
31210
+ e.scale3D = vec3(options.scale ?? 1);
31211
+ e.emitFlat = !!options.flatten; // a sphere a disc, a box a sheet, across the way it emits
31212
+ if (options.tileInfo)
31213
+ e.tileInfo = options.tileInfo;
31214
+ return e;
31215
+ }
31216
+
31217
+ ///////////////////////////////////////////////////////////////////////////////
31218
+ // the built-in effects, each tuned around a one unit emitter; one-shots have an emitTime and end themselves
31219
+
31220
+ /** The names of the built-in effects, in the order a gallery shows them
31221
+ * @type {Array<string>}
31222
+ * @memberof ParticleEffects */
31223
+ const particleEffectsBuiltIn = [];
31224
+ {
31225
+ const add = (name, settings, behaviors=[])=>
31226
+ {
31227
+ particleEffectsBuiltIn.push(name);
31228
+ particleEffectsAdd({name, settings, behaviors});
31229
+ };
31230
+ const clear = (h, s, l)=> hsl(h, s, l, 0);
31231
+
31232
+ // fire
31233
+ add('fire', {emitRate:120, emitSize:.6, emitConeAngle:.3, shape:'glow', particleTime:.8, sizeStart:.7,
31234
+ sizeEnd:.15, speed:.02, angleSpeed:.02, damping:.95, gravity:.003, fadeRate:.3, randomness:.3, additive:true,
31235
+ colorStartA:hsl(.08,1,.6), colorStartB:hsl(.14,1,.65), colorEndA:clear(.02,1,.5), colorEndB:clear(0,1,.3)});
31236
+ add('torch', {emitRate:60, emitSize:.25, emitConeAngle:.2, shape:'glow', particleTime:.6, sizeStart:.45,
31237
+ sizeEnd:.1, speed:.015, angleSpeed:.02, damping:.95, gravity:.003, fadeRate:.3, randomness:.3, additive:true,
31238
+ colorStartA:hsl(.09,1,.6), colorStartB:hsl(.13,1,.7), colorEndA:clear(.03,1,.5), colorEndB:clear(0,1,.35)});
31239
+ add('smoke', {emitRate:25, emitSize:.5, emitConeAngle:.3, shape:'smoke', particleTime:3, sizeStart:.5,
31240
+ sizeEnd:2, speed:.01, angleSpeed:.01, damping:.99, gravity:.0004, fadeRate:.5, randomness:.3,
31241
+ colorStartA:hsl(0,0,.5,.5), colorStartB:hsl(0,0,.3,.5), colorEndA:clear(0,0,.2), colorEndB:clear(0,0,.4)},
31242
+ [{name:'wobble', strength:.5}]);
31243
+ add('steam', {emitRate:30, emitSize:.4, emitConeAngle:.2, shape:'smoke', particleTime:1.5, sizeStart:.3,
31244
+ sizeEnd:1.5, speed:.02, damping:.98, gravity:.0008, fadeRate:.6, randomness:.3,
31245
+ colorStartA:hsl(0,0,1,.4), colorStartB:hsl(.6,.2,.9,.3), colorEndA:clear(0,0,1), colorEndB:clear(.6,.2,.9)},
31246
+ [{name:'wobble', strength:.3}]);
31247
+
31248
+ // impacts
31249
+ add('explosion', {emitRate:600, emitTime:.1, emitSize:.5, shape:'glow', particleTime:.6, sizeStart:1,
31250
+ sizeEnd:.1, speed:.1, damping:.9, fadeRate:.2, randomness:.5, additive:true,
31251
+ colorStartA:hsl(.12,1,.65), colorStartB:hsl(.06,1,.55), colorEndA:clear(0,1,.5), colorEndB:clear(0,0,.3)});
31252
+ add('sparks', {emitRate:100, emitSize:.2, shape:'soft', particleTime:.6, sizeStart:.08, sizeEnd:.04,
31253
+ speed:.07, damping:.96, gravity:-.006, fadeRate:.1, randomness:.4, additive:true, trailScale:3,
31254
+ colorStartA:hsl(.17,1,.9), colorStartB:hsl(.12,1,.65), colorEndA:clear(.07,1,.5), colorEndB:clear(.03,1,.5)});
31255
+ add('hit', {emitRate:400, emitTime:.05, emitSize:.2, shape:'spark', particleTime:.35, sizeStart:.5,
31256
+ sizeEnd:.1, speed:.08, damping:.85, fadeRate:.1, randomness:.4, additive:true, trailScale:2,
31257
+ colorStartA:hsl(0,0,1), colorStartB:hsl(0,1,.7), colorEndA:clear(0,1,.5), colorEndB:clear(0,1,.4)});
31258
+ add('dust', {emitRate:300, emitTime:.05, emitSize:1, emitRect:true, emitHeight:.1, emitConeAngle:1.2,
31259
+ shape:'smoke', particleTime:.8, sizeStart:.3, sizeEnd:.8, speed:.04, damping:.9, gravity:.0005,
31260
+ fadeRate:.5, randomness:.4, colorStartA:hsl(.1,.3,.6,.5), colorStartB:hsl(.08,.2,.5,.5),
31261
+ colorEndA:clear(.1,.3,.6), colorEndB:clear(.08,.2,.5)});
31262
+ add('debris', {emitRate:200, emitTime:.05, emitSize:.5, emitConeAngle:1, shape:'square', particleTime:1.5,
31263
+ sizeStart:.15, sizeEnd:.1, speed:.07, angleSpeed:.2, damping:.99, gravity:-.008, fadeRate:.1,
31264
+ randomness:.5, colorStartA:hsl(.08,.4,.35), colorStartB:hsl(0,0,.4), colorEndA:hsl(.08,.4,.3),
31265
+ colorEndB:hsl(0,0,.35)});
31266
+
31267
+ // pickups and magic
31268
+ add('sparkle', {emitRate:30, emitSize:1, shape:'star', particleTime:.8, sizeStart:.3, sizeEnd:0,
31269
+ speed:.005, angleSpeed:.05, fadeRate:.5, randomness:.3, additive:true,
31270
+ colorStartA:hsl(.14,1,.7), colorStartB:hsl(0,0,1), colorEndA:clear(.14,1,.7), colorEndB:clear(0,0,1)});
31271
+ add('magic', {emitRate:60, emitSize:1, shape:'soft', particleTime:1.5, sizeStart:.25, sizeEnd:0,
31272
+ speed:.015, fadeRate:.3, randomness:.3, additive:true, colorStartA:hsl(.55,1,.65),
31273
+ colorStartB:hsl(.79,1,.65), colorEndA:clear(0,0,1), colorEndB:clear(0,0,1)}, [{name:'swirl', strength:.5}]);
31274
+ add('heal', {emitRate:30, emitSize:.8, emitConeAngle:.2, shape:'plus', particleTime:1.2, sizeStart:.25,
31275
+ sizeEnd:.1, speed:.015, angleSpeed:0, particleConeAngle:0, gravity:.0005, fadeRate:.4, randomness:.3,
31276
+ additive:true, colorStartA:hsl(.33,1,.6), colorStartB:hsl(.3,1,.75), colorEndA:clear(.33,1,.6),
31277
+ colorEndB:clear(.3,1,.75)});
31278
+ add('poison', {emitRate:20, emitSize:.8, shape:'smoke', particleTime:2, sizeStart:.3, sizeEnd:1,
31279
+ speed:.006, gravity:.0002, fadeRate:.6, randomness:.4, colorStartA:hsl(.3,.8,.45,.6),
31280
+ colorStartB:hsl(.8,.6,.45,.6), colorEndA:clear(.3,.8,.4), colorEndB:clear(.8,.6,.4)},
31281
+ [{name:'wobble', strength:.5}]);
31282
+ add('portal', {emitRate:80, emitSize:1, shape:'soft', particleTime:1, sizeStart:.15, sizeEnd:.05,
31283
+ speed:.02, fadeRate:.3, randomness:.3, additive:true, colorStartA:hsl(.75,1,.6),
31284
+ colorStartB:hsl(.6,1,.6), colorEndA:clear(.75,1,.6), colorEndB:clear(.6,1,.6)},
31285
+ [{name:'orbit', strength:1}, {name:'attract', strength:.5}]);
31286
+
31287
+ // weather and ambience, one unit across at scale 1, so scale them to the view
31288
+ add('rain', {emitRate:30, emitSize:1, emitRect:true, emitHeight:.6, angle:PI, emitConeAngle:0,
31289
+ shape:'soft', particleTime:.8, sizeStart:.02, sizeEnd:.02, speed:.03, gravity:-.001, fadeRate:.1,
31290
+ randomness:.2, trailScale:5, colorStartA:hsl(.63,1,.8,.6), colorStartB:hsl(.63,1,.8,.4),
31291
+ colorEndA:hsl(.63,1,.8,.6), colorEndB:hsl(.63,1,.8,.4)});
31292
+ add('snow', {emitRate:6, emitSize:1, emitRect:true, emitHeight:.6, angle:PI, emitConeAngle:.3,
31293
+ shape:'soft', particleTime:4, sizeStart:.05, sizeEnd:.05, speed:.004, damping:.98, gravity:-.0002,
31294
+ fadeRate:.2, randomness:.3, colorStartA:hsl(0,0,1), colorStartB:hsl(.58,1,.9), colorEndA:hsl(0,0,1),
31295
+ colorEndB:hsl(.58,1,.9)}, [{name:'wobble', strength:.15}]);
31296
+ add('leaves', {emitRate:3, emitSize:1, emitRect:true, emitHeight:.2, angle:PI, emitConeAngle:.5,
31297
+ shape:'triangle', particleTime:4, sizeStart:.12, sizeEnd:.12, speed:.003, angleSpeed:.05, damping:.98,
31298
+ gravity:-.0001, fadeRate:.2, randomness:.4, randomColorLinear:false, colorStartA:hsl(.25,.7,.4),
31299
+ colorStartB:hsl(.07,.8,.5), colorEndA:hsl(.08,.6,.35), colorEndB:hsl(.05,.7,.4)},
31300
+ [{name:'wobble', strength:.15}, {name:'wind', strength:.05}]);
31301
+ add('bubbles', {emitRate:8, emitSize:1, emitRect:true, emitHeight:.1, emitConeAngle:.2, shape:'ring',
31302
+ particleTime:2, sizeStart:.1, sizeEnd:.2, speed:.01, gravity:.0003, fadeRate:.2, randomness:.4,
31303
+ colorStartA:hsl(.55,.8,.8,.8), colorStartB:hsl(.5,.6,.9,.6), colorEndA:hsl(.55,.8,.8,.3),
31304
+ colorEndB:hsl(.5,.6,.9,.2)}, [{name:'wobble', strength:.3}]);
31305
+ add('fireflies', {emitRate:3, emitSize:1, shape:'glow', particleTime:3, sizeStart:.15, sizeEnd:.15,
31306
+ speed:.003, damping:.99, fadeRate:.8, randomness:.3, additive:true, colorStartA:hsl(.2,1,.6),
31307
+ colorStartB:hsl(.15,1,.7), colorEndA:hsl(.2,1,.6), colorEndB:hsl(.15,1,.7)},
31308
+ [{name:'turbulence', strength:.3}]);
31309
+
31310
+ // trails and shots
31311
+ add('trail', {emitRate:60, shape:'soft', particleTime:.5, sizeStart:.4, sizeEnd:0, speed:0, angleSpeed:0,
31312
+ fadeRate:.1, randomness:.2, additive:true, colorStartA:hsl(.08,1,.6), colorStartB:hsl(.12,1,.65),
31313
+ colorEndA:clear(.03,1,.5), colorEndB:clear(0,1,.4)});
31314
+ add('muzzle', {emitRate:300, emitTime:.05, emitConeAngle:.3, shape:'glow', particleTime:.2,
31315
+ sizeStart:.5, sizeEnd:.1, speed:.1, damping:.8, fadeRate:.1, randomness:.3, additive:true,
31316
+ colorStartA:hsl(.14,1,.8), colorStartB:hsl(.1,1,.6), colorEndA:clear(.08,1,.5), colorEndB:clear(.05,1,.4)});
31317
+
31318
+ // other
31319
+ add('blood', {emitRate:300, emitTime:.1, emitConeAngle:.8, shape:'dot', particleTime:3, sizeStart:.15,
31320
+ sizeEnd:.1, speed:.09, gravity:-.01, fadeRate:.1, randomness:.4, collideLevel:true, restitution:.1,
31321
+ colorStartA:hsl(0,1,.35), colorStartB:hsl(0,1,.2), colorEndA:hsl(0,1,.25), colorEndB:hsl(0,1,.15)},
31322
+ [{name:'stick', strength:1}]);
31323
+ add('confetti', {emitRate:200, emitTime:.1, emitSize:.2, emitConeAngle:.6, shape:'square', particleTime:3,
31324
+ sizeStart:.1, sizeEnd:.1, speed:.09, angleSpeed:.15, damping:.96, gravity:-.004, fadeRate:.1,
31325
+ randomness:.4, randomColorLinear:false, colorStartA:hsl(0,1,.5), colorStartB:hsl(.6,1,.6),
31326
+ colorEndA:hsl(0,1,.5), colorEndB:hsl(.6,1,.6)}, [{name:'wobble', strength:.3}]);
31327
+ add('splash', {emitRate:300, emitTime:.05, emitSize:.5, emitConeAngle:.6, shape:'soft', particleTime:.7,
31328
+ sizeStart:.12, sizeEnd:.06, speed:.08, gravity:-.008, fadeRate:.2, randomness:.4,
31329
+ colorStartA:hsl(.58,.8,.7,.8), colorStartB:hsl(.55,.6,.85,.8), colorEndA:hsl(.58,.8,.7,.2),
31330
+ colorEndB:hsl(.55,.6,.85,.2)});
31331
+ }
29532
31332
 
29533
31333
  /**
29534
31334
  * LittleJS glTF Plugin
@@ -29891,7 +31691,13 @@ async function loadGLTF(url)
29891
31691
  const response = await fetch(url);
29892
31692
  if (!response.ok)
29893
31693
  throw new Error('loadGLTF failed: ' + url);
29894
- return parseGLTF(await response.arrayBuffer(), url.slice(0, url.lastIndexOf('/') + 1));
31694
+ // the files beside it are beside where it came from, after any redirect; a blob or data url has nothing
31695
+ // beside it, and a model in one is whole, or parsed with parseGLTF and a base of its own
31696
+ const from = response.url || url;
31697
+ let base = '';
31698
+ if (!/^(blob|data):/i.test(from))
31699
+ base = response.url ? new URL('.', from).href : from.slice(0, from.lastIndexOf('/') + 1);
31700
+ return parseGLTF(await response.arrayBuffer(), base);
29895
31701
  }
29896
31702
 
29897
31703
  /** Parse a model from GLB bytes or glTF JSON, fetching the buffers and images it refers to
@@ -31448,12 +33254,21 @@ function editorSetBaseline(record, written)
31448
33254
  // save a map as Tiled JSON: where the browser lets a page write files, Chrome and Edge, to a file picked once and
31449
33255
  // written again on each Save after, even after a reload once the browser gives permission, or picked again with
31450
33256
  // Save As; elsewhere as a download under the name of the file it came from; resolves to how it saved, undefined
31451
- // when the picker was closed
33257
+ // when the picker was closed; saves of a map run one at a time in the order asked, so the file ends with the last
31452
33258
  async function editorSave(record, pickAgain=false)
31453
33259
  {
31454
33260
  if (!record) return;
31455
33261
  editorStrokeEnd();
31456
- const text = editorMapJSON(record), picker = /** @type {any} */ (globalThis).showSaveFilePicker;
33262
+ const text = editorMapJSON(record); // the map as it is now, later edits wait for a later save
33263
+ const saved = (async ()=> { await record.saving; return editorSaveText(record, text, pickAgain); })();
33264
+ record.saving = saved.catch(()=> {});
33265
+ return saved;
33266
+ }
33267
+
33268
+ // write one save of a map, the one before it done
33269
+ async function editorSaveText(record, text, pickAgain)
33270
+ {
33271
+ const picker = /** @type {any} */ (globalThis).showSaveFilePicker;
31457
33272
  if (picker)
31458
33273
  {
31459
33274
  try
@@ -33758,9 +35573,11 @@ let editor3DBrush;
33758
35573
  // the copied objects
33759
35574
  /** @type {Array<Object>|undefined} */
33760
35575
  let editor3DClipboard;
33761
- let editor3DUndoList = [], editor3DRedoList = []; // the level's, each entry its object list before and after an edit
33762
- // the edit being made, a drag is one
33763
- /** @type {{before: Array<Object>}|undefined} */
35576
+ // the level's, each entry its object list and its parts, the scene and the blocks, before and after an edit
35577
+ let editor3DUndoList = [], editor3DRedoList = [];
35578
+ // the edit being made, a drag is one: the objects and the parts as they were before it, and painted when blocks
35579
+ // were set in the game's map and are not in the level yet
35580
+ /** @type {{before: Array<Object>, parts: Object, painted?: boolean, sculpted?: boolean}|undefined} */
33764
35581
  let editor3DStroke;
33765
35582
 
33766
35583
  const editor3DCopy = (value)=> JSON.parse(JSON.stringify(value));
@@ -33772,6 +35589,103 @@ const editor3DObjects = ()=> isArray(editor3DLevel?.objects) ? editor3DLevel.obj
33772
35589
  const editor3DObject = (id)=> editor3DObjects().find((o)=> o.id === id);
33773
35590
  const editor3DSelected = ()=> editor3DObjects().filter((o)=> editor3DSelection.has(o.id));
33774
35591
 
35592
+ // the parts of a level beside its objects that the editor edits: its scene block and its map of blocks; each is
35593
+ // undone, autosaved, reset and saved as the objects are
35594
+ const editor3DLevelPartNames = ['scene', 'voxels', 'terrain'];
35595
+
35596
+ // the last copy made of each part, as its text and the copy: a part that has not changed since is not copied
35597
+ // again, so undo steps share one copy of a terrain until it is sculpted; a copy is never changed
35598
+ /** @type {Object<string, {text: string, part: Object}>} */
35599
+ const editor3DPartCopies = {};
35600
+
35601
+ // a part of a level, undefined when it has none
35602
+ function editor3DLevelPart(name, level=editor3DLevel)
35603
+ {
35604
+ const part = level?.[name];
35605
+ return part && typeof part === 'object' && !isArray(part) ? part : undefined;
35606
+ }
35607
+
35608
+ // a copy of every part a level has, {} for a level with none; the copies are shared, see editor3DPartCopies, so
35609
+ // what is given is copied again before it is changed or made a level's own
35610
+ function editor3DLevelParts(level=editor3DLevel)
35611
+ {
35612
+ const parts = {};
35613
+ for (const name of editor3DLevelPartNames)
35614
+ {
35615
+ const part = editor3DLevelPart(name, level);
35616
+ if (!part) continue;
35617
+ const text = JSON.stringify(part);
35618
+ if (editor3DPartCopies[name]?.text !== text)
35619
+ editor3DPartCopies[name] = {text, part: JSON.parse(text)};
35620
+ parts[name] = editor3DPartCopies[name].part;
35621
+ }
35622
+ return parts;
35623
+ }
35624
+ const editor3DScene = ()=> editor3DLevelPart('scene');
35625
+
35626
+ // what a level's hash covers: its objects, and the parts it has, so a level with none hashes as before
35627
+ const editor3DContentHash = (objects, parts)=> editor3DHash(JSON.stringify(objects) +
35628
+ editor3DLevelPartNames.map((name)=> parts[name] ? JSON.stringify(parts[name]) : '').join(''));
35629
+
35630
+ // the renderer's settings a scene block can set, as they are now, and put back as they were: what the game set
35631
+ // itself shows again when a level's block changes or goes
35632
+ function editor3DSceneState()
35633
+ {
35634
+ const r = render3D;
35635
+ return {sky: r.sky, ambientColor: r.ambientColor.copy(), ambientGroundColor: r.ambientGroundColor?.copy(),
35636
+ fogStart: r.fogStart, fogEnd: r.fogEnd, fogColor: r.fogColor?.copy(), sunDirection: r.sunDirection.copy(),
35637
+ sunColor: r.sunColor.copy(), shadows: r.shadows};
35638
+ }
35639
+ function editor3DSceneRestore(state)
35640
+ {
35641
+ const r = render3D;
35642
+ if (!r || !state) return;
35643
+ r.sky !== state.sky && level3DSkies.has(r.sky) && r.sky.dispose(); // a dome a level made, not the game's
35644
+ r.sky = state.sky;
35645
+ r.ambientColor = state.ambientColor.copy();
35646
+ r.ambientGroundColor = state.ambientGroundColor?.copy();
35647
+ r.fogStart = state.fogStart, r.fogEnd = state.fogEnd;
35648
+ r.fogColor = state.fogColor?.copy();
35649
+ r.sunDirection = state.sunDirection.copy();
35650
+ r.sunColor = state.sunColor.copy();
35651
+ r.shadows = state.shadows;
35652
+ }
35653
+
35654
+ // the scene on screen as a block, to start a level's scene from: a sky only when the dome's colors are known
35655
+ const editor3DHex = (color)=> new Color(clamp(color.r), clamp(color.g), clamp(color.b)).toString(false);
35656
+ function editor3DSceneFromView()
35657
+ {
35658
+ const r = render3D, scene = {}, colors = r.sky && render3DSkyColors.get(r.sky);
35659
+ if (colors)
35660
+ {
35661
+ // how much of the top color the ambient light is
35662
+ const top = max(colors[0].r, colors[0].g, colors[0].b);
35663
+ const lit = max(r.ambientColor.r, r.ambientColor.g, r.ambientColor.b);
35664
+ scene.sky = colors.map(editor3DHex);
35665
+ scene.ambient = top ? round(clamp(lit / top) * 100) / 100 : .5;
35666
+ }
35667
+ const sun = r.sunDirection.normalize();
35668
+ scene.sunDirection = [sun.x, sun.y, sun.z].map((v)=> round(v * 1e3) / 1e3);
35669
+ scene.sunColor = editor3DHex(r.sunColor);
35670
+ scene.fog = [editor3DRound(r.fogStart || 0), editor3DRound(r.fogEnd || 0)];
35671
+ scene.fogColor = editor3DHex(r.fogColor || canvasClearColor);
35672
+ scene.shadows = !!r.shadows;
35673
+ return scene;
35674
+ }
35675
+
35676
+ // the sun's direction as two angles in degrees, for two sliders: around, from +z toward +x, and its height over
35677
+ // the horizon; and the direction of two angles, as the file keeps it
35678
+ function editor3DSunAngles(direction)
35679
+ {
35680
+ const d = level3DVector(direction, vec3(0, 1, 0)).normalize();
35681
+ return [round((atan2(d.x, d.z) * 180 / PI + 360) % 360) % 360, round(Math.asin(clamp(d.y, -1, 1)) * 180 / PI)];
35682
+ }
35683
+ function editor3DSunDirection(around, height)
35684
+ {
35685
+ const a = around * PI / 180, h = height * PI / 180;
35686
+ return [sin(a) * cos(h), sin(h), cos(a) * cos(h)].map((v)=> round(v * 1e3) / 1e3 + 0);
35687
+ }
35688
+
33775
35689
  // an object's position, rotation in degrees and scale, the defaults where the level leaves them out
33776
35690
  const editor3DPos = (object)=> level3DVector(object.pos, vec3());
33777
35691
  const editor3DRotation = (object)=> level3DVector(object.rotation, vec3());
@@ -33831,6 +35745,14 @@ function editor3DLevelLoaded(level)
33831
35745
  level.objects[i] && typeof level.objects[i] === 'object' || level.objects.splice(i, 1);
33832
35746
  editor3DFixIds(level.objects);
33833
35747
  }
35748
+ // its blocks as the editor writes them, so the same map always reads the same, however the file wrote its runs
35749
+ const voxels = editor3DLevelPart('voxels', level), shape = level3DVoxelsShape(voxels);
35750
+ if (shape)
35751
+ {
35752
+ const data = new Uint8Array(shape.size.x * shape.size.y * shape.size.z);
35753
+ level3DVoxelsDecode(voxels.blocks, data);
35754
+ voxels.blocks = level3DVoxelsEncode(data);
35755
+ }
33834
35756
  const known = editor3DRecords.get(level);
33835
35757
  if (known)
33836
35758
  {
@@ -33843,18 +35765,28 @@ function editor3DLevelLoaded(level)
33843
35765
  // a level from a file goes by the file, one made in code by its objects as they were loaded, so each has an
33844
35766
  // autosave of its own
33845
35767
  const url = editorFetchedURLs.get(level)?.split(/[?#]/)[0];
33846
- const original = editor3DCopy(editor3DObjects()), hash = editor3DHash(JSON.stringify(original));
35768
+ const original = editor3DCopy(editor3DObjects()), originalParts = editor3DLevelParts();
35769
+ const hash = editor3DContentHash(original, originalParts);
35770
+ // sceneBase is the scene the game set, taken before the level's own block is applied
33847
35771
  const record = {fileName: url ? url.split('/').pop() : 'level3D.json', key: url ?? 'level #' + hash, original,
33848
- hash, pending: undefined, fileHandle: undefined, undo: [], redo: []};
35772
+ originalParts, sceneBase: render3D ? editor3DSceneState() : undefined, hash, pending: undefined,
35773
+ fileHandle: undefined, undo: [], redo: []};
33849
35774
  editor3DRecords.set(level, record);
33850
35775
  editor3DUndoList = record.undo;
33851
35776
  editor3DRedoList = record.redo;
33852
35777
  // an autosave the file already has, from a Save, goes; one of this file, or of the file a Save wrote, comes back
33853
35778
  const saved = editor3DSaves()[record.key];
33854
- if (saved && isArray(saved.objects) && editor3DSame(saved.objects, original))
35779
+ if (saved && isArray(saved.objects) && editor3DSame(saved.objects, original) &&
35780
+ editor3DSame(editor3DLevelParts(saved), originalParts))
33855
35781
  editor3DAutosave(level);
33856
35782
  else if ((saved?.hash === hash || saved?.savedHash === hash) && isArray(saved.objects))
35783
+ {
35784
+ // before level3DLoad makes anything, so the scene and the map are made with the edits in them
35785
+ const parts = editor3DLevelParts(saved);
33857
35786
  level.objects = editor3DCopy(saved.objects);
35787
+ for (const name of editor3DLevelPartNames)
35788
+ parts[name] ? level[name] = editor3DCopy(parts[name]) : delete level[name];
35789
+ }
33858
35790
  else if (saved)
33859
35791
  record.pending = saved;
33860
35792
  }
@@ -33918,17 +35850,320 @@ function editor3DChange(change)
33918
35850
  change(after);
33919
35851
  if (editor3DSame(before, after)) return false;
33920
35852
  editor3DSetObjects(after);
33921
- editor3DStroke ||= {before};
35853
+ editor3DStroke ||= {before, parts: editor3DLevelParts()};
35854
+ return true;
35855
+ }
35856
+
35857
+ // make a part the level's, or take it away, and show it: for the scene the game's own setup, then what the block
35858
+ // sets; for the blocks the game's map, brought in line
35859
+ function editor3DSetPart(name, part)
35860
+ {
35861
+ const level = editor3DLevel;
35862
+ part ? level[name] = editor3DCopy(part) : delete level[name];
35863
+ if (name === 'scene')
35864
+ {
35865
+ editor3DSceneRestore(editor3DRecords.get(level)?.sceneBase);
35866
+ level3DSceneApply(level.scene);
35867
+ }
35868
+ else if (name === 'voxels')
35869
+ editor3DVoxelShow();
35870
+ else
35871
+ editor3DTerrainShow();
35872
+ }
35873
+
35874
+ // make the level's parts these, the ones that differ
35875
+ function editor3DSetParts(parts)
35876
+ {
35877
+ for (const name of editor3DLevelPartNames)
35878
+ editor3DSame(parts[name], editor3DLevelPart(name)) || editor3DSetPart(name, parts[name]);
35879
+ }
35880
+
35881
+ // change a part of the level as part of the edit being made, as editor3DChange does its objects: change is given
35882
+ // a copy of the part, undefined when the level has none, and returns the new one, or undefined for none; false
35883
+ // when nothing changed
35884
+ function editor3DChangePart(name, change)
35885
+ {
35886
+ if (!editor3DLevel || editor3DRecords.get(editor3DLevel)?.pending) return false; // its autosave waits first
35887
+ const parts = editor3DLevelParts(), after = change(parts[name] && editor3DCopy(parts[name]));
35888
+ if (editor3DSame(parts[name], after)) return false;
35889
+ editor3DStroke ||= {before: editor3DCopy(editor3DObjects()), parts};
35890
+ editor3DSetPart(name, after);
33922
35891
  return true;
33923
35892
  }
35893
+ const editor3DChangeScene = (change)=> editor3DChangePart('scene', change);
35894
+
35895
+ ///////////////////////////////////////////////////////////////////////////////
35896
+ // the level's map of blocks: the level's voxels block is the source of truth, the game's VoxelMap follows it;
35897
+ // painting sets blocks in the game's map as it goes, and the stroke's end writes them into the level
35898
+
35899
+ // the game's map of the level's blocks, undefined when the level has none
35900
+ function editor3DVoxelMap()
35901
+ {
35902
+ const map = level3DVoxelMap;
35903
+ return map && !map.destroyed && editor3DLevelPart('voxels') ? map : undefined;
35904
+ }
35905
+
35906
+ // bring the game's map in line with the level's voxels block: made, made again when its place or size changed,
35907
+ // its cells set, or destroyed with the block gone
35908
+ function editor3DVoxelShow()
35909
+ {
35910
+ const voxels = editor3DLevelPart('voxels'), shape = level3DVoxelsShape(voxels);
35911
+ let map = level3DVoxelMap && !level3DVoxelMap.destroyed ? level3DVoxelMap : undefined;
35912
+ const sameVector = (a, b)=> a.x === b.x && a.y === b.y && a.z === b.z;
35913
+ if (map && !(shape && sameVector(map.pos3D, shape.pos) && sameVector(map.mapSize, shape.size)))
35914
+ {
35915
+ map.destroy();
35916
+ map = level3DVoxelMap = undefined;
35917
+ }
35918
+ if (!shape) return;
35919
+ if (!map)
35920
+ {
35921
+ level3DVoxelMap = level3DVoxelsMake(voxels);
35922
+ return;
35923
+ }
35924
+ level3DVoxelsDecode(voxels.blocks, map.data);
35925
+ map.rebuild();
35926
+ }
35927
+
35928
+ // write the game's map into the level, as a stroke of painting ends
35929
+ function editor3DVoxelStore()
35930
+ {
35931
+ const map = editor3DVoxelMap(), voxels = editor3DLevelPart('voxels');
35932
+ map && voxels && (voxels.blocks = level3DVoxelsEncode(map.data));
35933
+ }
35934
+
35935
+ // give the level an empty map of a size in cells, centered on the origin with its bottom on the ground, as one undo
35936
+ function editor3DVoxelAdd(size=vec3(32, 16, 32))
35937
+ {
35938
+ const changed = editor3DChangePart('voxels', ()=> ({pos: [-floor(size.x / 2), 0, -floor(size.z / 2)],
35939
+ size: [size.x, size.y, size.z], blocks: [size.x * size.y * size.z, 0]}));
35940
+ editor3DStrokeEnd();
35941
+ return changed;
35942
+ }
35943
+
35944
+ // take the level's map away, as one undo
35945
+ function editor3DVoxelRemove()
35946
+ {
35947
+ const changed = editor3DChangePart('voxels', ()=> undefined);
35948
+ editor3DStrokeEnd();
35949
+ return changed;
35950
+ }
35951
+
35952
+ // is a cell in a map
35953
+ function editor3DVoxelInside(map, cell)
35954
+ {
35955
+ const s = map.mapSize;
35956
+ return cell.x >= 0 && cell.y >= 0 && cell.z >= 0 && cell.x < s.x && cell.y < s.y && cell.z < s.z;
35957
+ }
35958
+
35959
+ // set a block as part of the edit being made, 0 for none: in the game's map now, in the level when the stroke
35960
+ // ends; false when it is that already, or there is no such cell
35961
+ function editor3DVoxelSet(cell, type)
35962
+ {
35963
+ const map = editor3DVoxelMap();
35964
+ if (!map || editor3DRecords.get(editor3DLevel)?.pending) return false; // its autosave waits first
35965
+ if (!editor3DVoxelInside(map, cell) || map.getVoxel(cell) === type) return false;
35966
+ editor3DStroke ||= {before: editor3DCopy(editor3DObjects()), parts: editor3DLevelParts()};
35967
+ editor3DStroke.painted = true;
35968
+ map.setVoxel(cell, type);
35969
+ return true;
35970
+ }
35971
+
35972
+ // the cell a ray picks on a layer of the map: drag has the axis the layer is across, the plane the ray is met
35973
+ // with, in cells from the map's corner, and the layer, the cell's place along the axis; undefined off the map
35974
+ function editor3DVoxelDragCell(drag, ray)
35975
+ {
35976
+ const map = editor3DVoxelMap(), axis = drag.axis, along = ray.direction[axis];
35977
+ if (!map || !along) return;
35978
+ const distance = (map.pos3D[axis] + drag.plane - ray.origin[axis]) / along;
35979
+ if (distance < 0) return;
35980
+ const p = ray.getPosition(distance).subtract(map.pos3D), cell = vec3(floor(p.x), floor(p.y), floor(p.z));
35981
+ cell[axis] = drag.layer;
35982
+ return editor3DVoxelInside(map, cell) ? cell : undefined;
35983
+ }
35984
+
35985
+ ///////////////////////////////////////////////////////////////////////////////
35986
+ // the level's terrain: the level's terrain block is the source of truth, the game's HeightMap follows it;
35987
+ // sculpting changes the game's map as it goes, and the stroke's end writes its heights into the level
35988
+
35989
+ // the brush: its size across in world units, and how strong it is, 0 to 1
35990
+ const editor3DTerrainBrush = {size: 8, strength: .5};
35991
+
35992
+ // the game's map of the level's terrain, undefined when the level has none
35993
+ function editor3DTerrainMap()
35994
+ {
35995
+ const map = level3DTerrainMap;
35996
+ return map && !map.destroyed && editor3DLevelPart('terrain') ? map : undefined;
35997
+ }
35998
+
35999
+ // bring the game's map in line with the level's terrain block: made, made again when its place or shape changed,
36000
+ // its heights and color set, or destroyed with the block gone
36001
+ function editor3DTerrainShow()
36002
+ {
36003
+ const terrain = editor3DLevelPart('terrain'), shape = level3DTerrainShape(terrain);
36004
+ let map = level3DTerrainMap && !level3DTerrainMap.destroyed ? level3DTerrainMap : undefined;
36005
+ const fits = map && shape && map.pos3D.distance(shape.pos) === 0 && map.mapSize.distance(shape.size) === 0 &&
36006
+ map.height === shape.height && map.rows === shape.heights.length && map.columns === shape.heights[0].length;
36007
+ if (map && !fits)
36008
+ {
36009
+ map.destroy();
36010
+ map = level3DTerrainMap = undefined;
36011
+ }
36012
+ if (!shape) return;
36013
+ if (!map)
36014
+ {
36015
+ level3DTerrainMap = level3DTerrainMake(terrain);
36016
+ return;
36017
+ }
36018
+ map.heights = shape.heights;
36019
+ map.color = /^#([0-9a-f]{6}|[0-9a-f]{8})$/i.test(terrain.color) ? new Color().setHex(terrain.color) : WHITE;
36020
+ map.rebuild();
36021
+ }
36022
+
36023
+ // write the game's map into the level, as a stroke of sculpting ends, its heights to a thousandth
36024
+ function editor3DTerrainStore()
36025
+ {
36026
+ const map = editor3DTerrainMap(), terrain = editor3DLevelPart('terrain');
36027
+ map && terrain && (terrain.heights = map.heights.map((row)=> row.map((v)=> round(v * 1e3) / 1e3)));
36028
+ }
36029
+
36030
+ // give the level a flat terrain, as one undo: its size in the world, how many cells a side, and how tall a full
36031
+ // height is; it starts a fifth of the way up with its surface on the ground, so it can be dug as well as raised
36032
+ function editor3DTerrainAdd(size=vec2(64), cells=64, height=16)
36033
+ {
36034
+ const samples = clamp(floor(cells), 1, LEVEL3D_TERRAIN_SAMPLES - 1) + 1;
36035
+ const changed = editor3DChangePart('terrain', ()=> ({pos: [0, editor3DRound(-height * .2), 0],
36036
+ size: [size.x, size.y], height, color: '#6a9955',
36037
+ heights: Array.from({length: samples}, ()=> Array(samples).fill(.2))}));
36038
+ editor3DStrokeEnd();
36039
+ return changed;
36040
+ }
36041
+
36042
+ // take the level's terrain away, as one undo
36043
+ function editor3DTerrainRemove()
36044
+ {
36045
+ const changed = editor3DChangePart('terrain', ()=> undefined);
36046
+ editor3DStrokeEnd();
36047
+ return changed;
36048
+ }
36049
+
36050
+ // sculpt the ground around a point for some seconds, as part of the edit being made: raise, lower or smooth it
36051
+ // under the brush, a soft circle, most in its middle and nothing at its edge; in the game's map now, in the level
36052
+ // when the stroke ends; false when nothing changed
36053
+ function editor3DTerrainSculpt(point, mode, seconds)
36054
+ {
36055
+ const map = editor3DTerrainMap();
36056
+ if (!map || editor3DRecords.get(editor3DLevel)?.pending) return false; // its autosave waits first
36057
+ const {size, strength} = editor3DTerrainBrush, radius = size / 2, heights = map.heights;
36058
+ const rows = map.rows, columns = map.columns;
36059
+ const stepX = map.mapSize.x / (columns - 1), stepZ = map.mapSize.y / (rows - 1);
36060
+ const x0 = map.pos3D.x - map.mapSize.x / 2, z0 = map.pos3D.z - map.mapSize.y / 2;
36061
+ const c0 = max(0, ceil((point.x - radius - x0) / stepX)), c1 = min(columns - 1, floor((point.x + radius - x0) / stepX));
36062
+ const r0 = max(0, ceil((point.z - radius - z0) / stepZ)), r1 = min(rows - 1, floor((point.z + radius - z0) / stepZ));
36063
+ // smoothing reads the ground as it was, so the order the samples are done in does not show
36064
+ const old = mode === 'smooth' ? heights.map((row)=> row.slice()) : heights;
36065
+ const rate = strength * 10 * seconds; // world units of height at full strength, 10 a second
36066
+ let changed = false;
36067
+ for (let r = r0; r <= r1; ++r)
36068
+ for (let c = c0; c <= c1; ++c)
36069
+ {
36070
+ const distance = hypot(x0 + c * stepX - point.x, z0 + r * stepZ - point.z);
36071
+ if (distance >= radius) continue;
36072
+ const falloff = .5 + .5 * cos(PI * distance / radius), h = heights[r][c];
36073
+ let v;
36074
+ if (mode === 'smooth')
36075
+ {
36076
+ // toward the average of the samples beside it
36077
+ let sum = 0, count = 0;
36078
+ for (const [dr, dc] of [[-1, 0], [1, 0], [0, -1], [0, 1]])
36079
+ old[r + dr]?.[c + dc] !== undefined && (sum += old[r + dr][c + dc], ++count);
36080
+ v = h + (sum / count - h) * min(1, rate * falloff);
36081
+ }
36082
+ else
36083
+ v = clamp(h + (mode === 'lower' ? -1 : 1) * rate * falloff / map.height);
36084
+ if (v === h) continue;
36085
+ heights[r][c] = v;
36086
+ changed = true;
36087
+ }
36088
+ if (!changed) return false;
36089
+ editor3DStroke ||= {before: editor3DCopy(editor3DObjects()), parts: editor3DLevelParts()};
36090
+ editor3DStroke.sculpted = true;
36091
+ map.rebuild();
36092
+ return true;
36093
+ }
36094
+
36095
+ // what the Blocks tool does to a cell: place puts the current type in an empty one, paint gives a block the
36096
+ // current type, remove empties it
36097
+ function editor3DVoxelApply(cell, mode)
36098
+ {
36099
+ const map = editor3DVoxelMap(), has = map ? map.getVoxel(cell) : 0;
36100
+ return mode === 'place' ? !has && editor3DVoxelSet(cell, editor3DBlockType) :
36101
+ mode === 'paint' ? !!has && editor3DVoxelSet(cell, editor3DBlockType) :
36102
+ editor3DVoxelSet(cell, 0);
36103
+ }
36104
+
36105
+ // a press of the Blocks tool: the cell under the ray is changed, and what a drag needs to go on from it on the
36106
+ // same layer; undefined with no cell there
36107
+ function editor3DVoxelPress(ray, mode)
36108
+ {
36109
+ const target = editor3DVoxelTarget(ray, mode);
36110
+ if (!target) return;
36111
+ const {cell, axis, plane} = target;
36112
+ editor3DVoxelApply(cell, mode);
36113
+ return {kind: 'blocks', mode, axis, plane, layer: cell[axis], last: cell};
36114
+ }
36115
+
36116
+ // a drag of the Blocks tool goes on: every cell of the layer from the last one to the one under the ray
36117
+ function editor3DVoxelDragTo(drag, ray)
36118
+ {
36119
+ const cell = editor3DVoxelDragCell(drag, ray), last = drag.last;
36120
+ if (!cell) return;
36121
+ const d = cell.subtract(last), steps = max(abs(d.x), abs(d.y), abs(d.z));
36122
+ for (let i = 1; i <= steps; ++i)
36123
+ editor3DVoxelApply(vec3(round(last.x + d.x * i / steps), round(last.y + d.y * i / steps),
36124
+ round(last.z + d.z * i / steps)), drag.mode);
36125
+ drag.last = cell;
36126
+ }
36127
+
36128
+ // make the type of the block under a ray the one to place; false with no block there
36129
+ function editor3DVoxelPick(ray)
36130
+ {
36131
+ const hit = editor3DVoxelMap()?.raycast(ray);
36132
+ if (!hit) return false;
36133
+ editor3DBlockType = hit.type;
36134
+ }
36135
+
36136
+ // the cell a click is for: to place, the empty cell against the face the ray hits, or the one standing on the
36137
+ // map's floor where it hits no block; otherwise the block it hits; with the axis the face is across and the plane
36138
+ // of the face, for a drag to stay on; undefined with no such cell
36139
+ function editor3DVoxelTarget(ray, mode)
36140
+ {
36141
+ const map = editor3DVoxelMap();
36142
+ if (!map) return;
36143
+ const place = mode === 'place', hit = map.raycast(ray);
36144
+ if (!hit)
36145
+ {
36146
+ const cell = place ? editor3DVoxelDragCell({axis: 'y', plane: 0, layer: 0}, ray) : undefined;
36147
+ return cell && {cell, axis: 'y', plane: 0};
36148
+ }
36149
+ const n = hit.normal, axis = n.x ? 'x' : n.y ? 'y' : 'z', cell = place ? hit.cell.add(n) : hit.cell;
36150
+ if (!editor3DVoxelInside(map, cell)) return;
36151
+ // the face between the block hit and the cell in front of it
36152
+ return {cell, axis, plane: cell[axis] + ((n[axis] > 0) === place ? 0 : 1)};
36153
+ }
33924
36154
 
33925
36155
  // end the edit being made: one undo, and the autosave
33926
36156
  function editor3DStrokeEnd()
33927
36157
  {
33928
36158
  const stroke = editor3DStroke;
33929
36159
  editor3DStroke = undefined;
33930
- if (!stroke || editor3DSame(stroke.before, editor3DObjects())) return;
33931
- editor3DUndoList.push({before: stroke.before, after: editor3DCopy(editor3DObjects())});
36160
+ if (!stroke) return;
36161
+ stroke.painted && editor3DVoxelStore(); // the blocks painted go into the level
36162
+ stroke.sculpted && editor3DTerrainStore(); // and the ground sculpted
36163
+ const parts = editor3DLevelParts();
36164
+ if (editor3DSame(stroke.before, editor3DObjects()) && editor3DSame(stroke.parts, parts)) return;
36165
+ editor3DUndoList.push({before: stroke.before, after: editor3DCopy(editor3DObjects()),
36166
+ partsBefore: stroke.parts, partsAfter: parts});
33932
36167
  editor3DUndoList.length > 100 && editor3DUndoList.shift();
33933
36168
  editor3DRedoList.length = 0;
33934
36169
  editor3DAutosave();
@@ -33939,7 +36174,11 @@ function editor3DStrokeCancel()
33939
36174
  {
33940
36175
  const stroke = editor3DStroke;
33941
36176
  editor3DStroke = undefined;
33942
- stroke && editor3DSetObjects(stroke.before);
36177
+ if (!stroke) return;
36178
+ editor3DSetObjects(stroke.before);
36179
+ stroke.painted && editor3DVoxelShow(); // the level never had them, the game's map goes back to it
36180
+ stroke.sculpted && editor3DTerrainShow();
36181
+ editor3DSetParts(stroke.parts);
33943
36182
  }
33944
36183
 
33945
36184
  // undo the last edit, or redo the last one undone; false with none
@@ -33950,6 +36189,7 @@ function editor3DUndo(redo=false)
33950
36189
  if (!entry) return false;
33951
36190
  (redo ? editor3DUndoList : editor3DRedoList).push(entry);
33952
36191
  editor3DSetObjects(redo ? entry.after : entry.before);
36192
+ editor3DSetParts(redo ? entry.partsAfter : entry.partsBefore);
33953
36193
  editor3DAutosave();
33954
36194
  return true;
33955
36195
  }
@@ -34172,18 +36412,20 @@ const editor3DSaves = ()=> readSaveData(editor3DSaveName(), {});
34172
36412
  // if the last autosave did not fit in storage, the panel says so
34173
36413
  let editor3DSaveFailed = false;
34174
36414
 
34175
- // remember the level's objects, or forget them when they are back to the file
36415
+ // remember the level's objects and parts, or forget them when they are back to the file
34176
36416
  function editor3DAutosave(level=editor3DLevel)
34177
36417
  {
34178
36418
  const record = editor3DRecords.get(level);
34179
36419
  if (!record || record.pending) return; // edits waiting to be applied keep their autosave
34180
36420
  const saves = editor3DSaves(), objects = isArray(level.objects) ? level.objects : [];
36421
+ const parts = editor3DLevelParts(level);
34181
36422
  // the level as it was loaded has nothing to keep; one a Save wrote is kept until a reload shows the file has it,
34182
36423
  // since the browser gives a picked file's name and not its folder, and a file of the same name may be a copy
34183
- if (editor3DSame(objects, record.original) && !record.savedHash)
36424
+ if (editor3DSame(objects, record.original) && editor3DSame(parts, record.originalParts) && !record.savedHash)
34184
36425
  delete saves[record.key];
34185
36426
  else
34186
- saves[record.key] = {hash: record.hash, savedHash: record.savedHash, objects: editor3DCopy(objects)};
36427
+ saves[record.key] = {hash: record.hash, savedHash: record.savedHash, objects: editor3DCopy(objects),
36428
+ ...parts};
34187
36429
  try
34188
36430
  {
34189
36431
  localStorage.setItem(editor3DSaveName(), JSON.stringify(saves));
@@ -34200,6 +36442,9 @@ function editor3DApplyPending()
34200
36442
  record.pending = undefined;
34201
36443
  editor3DFixIds(saved.objects);
34202
36444
  editor3DChange((list)=> { list.length = 0; list.push(...editor3DCopy(saved.objects)); });
36445
+ const parts = editor3DLevelParts(saved);
36446
+ for (const name of editor3DLevelPartNames)
36447
+ editor3DChangePart(name, ()=> parts[name]);
34203
36448
  editor3DStrokeEnd();
34204
36449
  editor3DAutosave();
34205
36450
  }
@@ -34220,16 +36465,18 @@ function editor3DRevert()
34220
36465
  const record = editor3DRecords.get(editor3DLevel);
34221
36466
  if (!record) return false;
34222
36467
  record.pending = undefined;
34223
- if (editor3DSame(editor3DObjects(), record.original))
36468
+ if (editor3DSame(editor3DObjects(), record.original) && editor3DSame(editor3DLevelParts(), record.originalParts))
34224
36469
  return editor3DAutosave(), false;
34225
36470
  editor3DChange((list)=> { list.length = 0; list.push(...editor3DCopy(record.original)); });
36471
+ for (const name of editor3DLevelPartNames)
36472
+ editor3DChangePart(name, ()=> record.originalParts[name] && editor3DCopy(record.originalParts[name]));
34226
36473
  editor3DStrokeEnd();
34227
36474
  }
34228
36475
 
34229
36476
  // save the level as JSON: where the browser lets a page write files, Chrome and Edge, to a file picked once and
34230
36477
  // written again on each Save after, or picked again with Save As; elsewhere as a download; a file of the level's
34231
36478
  // own name that was written is the file from then on, for the autosave; resolves to how it saved, undefined when
34232
- // the picker was closed
36479
+ // the picker was closed; saves of a level run one at a time in the order asked, so the file ends with the last
34233
36480
  async function editor3DSave(pickAgain=false)
34234
36481
  {
34235
36482
  const level = editor3DLevel, record = editor3DRecords.get(level);
@@ -34238,7 +36485,16 @@ async function editor3DSave(pickAgain=false)
34238
36485
  // what is written, kept as it is now: the level can change while the file is picked and written, and those
34239
36486
  // edits are not in the file, so they stay in the autosave; the level and record are this one's, whichever is
34240
36487
  // open by then
34241
- const text = editor3DLevelJSON(level), written = editor3DCopy(JSON.parse(text).objects);
36488
+ const text = editor3DLevelJSON(level);
36489
+ const saved = (async ()=> { await record.saving; return editor3DSaveText(level, record, text, pickAgain); })();
36490
+ record.saving = saved.catch(()=> {});
36491
+ return saved;
36492
+ }
36493
+
36494
+ // write one save of a level, the one before it done
36495
+ async function editor3DSaveText(level, record, text, pickAgain)
36496
+ {
36497
+ const file = JSON.parse(text), written = editor3DCopy(file.objects), writtenParts = editor3DLevelParts(file);
34242
36498
  const picker = /** @type {any} */ (globalThis).showSaveFilePicker;
34243
36499
  const fileKey = (globalThis.location?.pathname ?? '') + ' 3D ' + record.key;
34244
36500
  if (picker)
@@ -34264,7 +36520,8 @@ async function editor3DSave(pickAgain=false)
34264
36520
  if (record.fileHandle.name === record.fileName)
34265
36521
  {
34266
36522
  record.original = written;
34267
- record.savedHash = editor3DHash(JSON.stringify(written));
36523
+ record.originalParts = writtenParts;
36524
+ record.savedHash = editor3DContentHash(written, writtenParts);
34268
36525
  editor3DAutosave(level);
34269
36526
  }
34270
36527
  return 'written';
@@ -34287,7 +36544,15 @@ const EDITOR3D_HANDLE_SIZE = .15; // a handle's length as a part of the view's h
34287
36544
  const EDITOR3D_HANDLE_REACH = 10; // how many pixels from a handle still grab it
34288
36545
  const EDITOR3D_AXES = {x: vec3(1, 0, 0), y: vec3(0, 1, 0), z: vec3(0, 0, 1)};
34289
36546
 
34290
- let editor3DTool = 'move'; // 'select', 'move', 'rotate' or 'scale'
36547
+ let editor3DTool = 'move'; // 'select', 'move', 'rotate', 'scale', 'blocks' or 'terrain'
36548
+ let editor3DBlockType = 1; // the block type the Blocks tool places
36549
+ let editor3DSeconds = 0; // how long the last step was, for a brush held down
36550
+ // where the mouse is on the terrain, for the Terrain tool's brush
36551
+ /** @type {Vector3|undefined} */
36552
+ let editor3DTerrainHover;
36553
+ // the cell the Blocks tool would change, for its outline: {cell, mode}
36554
+ /** @type {{cell: Vector3, mode: string}|undefined} */
36555
+ let editor3DBlockHover;
34291
36556
  let editor3DGrid = true; // moves, turns and sizes go in steps, Ctrl flips it for a drag
34292
36557
  let editor3DGroundSnap = true; // a body drag slides along what is under the mouse
34293
36558
  let editor3DMoveStep = 1, editor3DRotateStep = 15, editor3DScaleStep = .25; // the rotate step in degrees
@@ -34317,7 +36582,7 @@ function editor3DSelectionCenter()
34317
36582
  function editor3DHandles()
34318
36583
  {
34319
36584
  const center = editor3DSelectionCenter(), tool = editor3DTool, handles = [];
34320
- if (!center || tool === 'select') return handles;
36585
+ if (!center || tool === 'select' || tool === 'blocks' || tool === 'terrain') return handles;
34321
36586
  const length = editor3DScreenScale(center) * EDITOR3D_HANDLE_SIZE;
34322
36587
  const rotation = editor3DRotation(editor3DSelected()[0]).scale(PI / 180), frame = buildMatrix(vec3(), rotation);
34323
36588
  for (const axis of 'xyz')
@@ -34493,6 +36758,10 @@ function editor3DPlaceAt(type, ray)
34493
36758
  // the mouse moved with a drag held: the selection goes from where the drag started to where the mouse says
34494
36759
  function editor3DDragTo(drag, mouse, ray, snap)
34495
36760
  {
36761
+ if (drag.kind === 'blocks')
36762
+ return editor3DVoxelDragTo(drag, ray);
36763
+ if (drag.kind === 'terrain')
36764
+ return editor3DTerrainHover && editor3DTerrainSculpt(editor3DTerrainHover, drag.mode, editor3DSeconds);
34496
36765
  if (drag.kind === 'box')
34497
36766
  {
34498
36767
  drag.to = mouse.copy();
@@ -34657,6 +36926,9 @@ const editor3DKeys =
34657
36926
  KeyW: ()=> { editor3DTool = 'move'; },
34658
36927
  KeyE: ()=> { editor3DTool = 'rotate'; },
34659
36928
  KeyR: ()=> { editor3DTool = 'scale'; },
36929
+ KeyB: ()=> { editor3DTool = 'blocks'; editor3DBrush = undefined; },
36930
+ KeyT: ()=> { editor3DTool = 'terrain'; editor3DBrush = undefined; },
36931
+ KeyP: ()=> editor3DTool === 'blocks' && editor3DVoxelPick(render3D.screenToRay(mousePosScreen)),
34660
36932
  KeyG: ()=> { editor3DGrid = !editor3DGrid; },
34661
36933
  KeyF: ()=> editor3DFrame(),
34662
36934
  End: ()=> editor3DDropSelection(),
@@ -34715,6 +36987,7 @@ function editor3DCameraUpdate(seconds, shift, alt)
34715
36987
  // the editor's update, called while it is open with the input readable and render3D looking through its camera
34716
36988
  function editor3DEditorUpdate(seconds)
34717
36989
  {
36990
+ editor3DSeconds = seconds;
34718
36991
  const held = (...codes)=> codes.some((code)=> keyIsDown(code));
34719
36992
  const shift = held('ShiftLeft', 'ShiftRight'), alt = held('AltLeft', 'AltRight');
34720
36993
  const ctrl = held('ControlLeft', 'ControlRight', 'MetaLeft', 'MetaRight');
@@ -34750,7 +37023,23 @@ function editor3DEditorUpdate(seconds)
34750
37023
 
34751
37024
  const ray = render3D.screenToRay(mouse), idle = !cameraDrag && !editor3DMouseOnPanel && !editor3DDrag;
34752
37025
  editor3DHover = idle ? editor3DHandleAt(mouse) : undefined;
34753
- if (idle && mouseWasPressed(0))
37026
+
37027
+ // the Blocks tool takes the mouse while the level has a map: Shift removes, Ctrl repaints
37028
+ const painting = editor3DTool === 'blocks' && !!editor3DVoxelMap();
37029
+ const mode = shift ? 'remove' : ctrl ? 'paint' : 'place';
37030
+ const target = painting && idle ? editor3DVoxelTarget(ray, mode) : undefined;
37031
+ editor3DBlockHover = target && {cell: target.cell, mode};
37032
+ // the Terrain tool takes it while the level has a terrain: held down it raises the ground, Shift lowers,
37033
+ // Ctrl smooths
37034
+ const terrain = editor3DTool === 'terrain' ? editor3DTerrainMap() : undefined;
37035
+ const reach = terrain && !cameraDrag && !editor3DMouseOnPanel ? terrain.raycast(ray) : undefined;
37036
+ editor3DTerrainHover = reach === undefined ? undefined : ray.getPosition(reach);
37037
+ if (painting)
37038
+ idle && mouseWasPressed(0) && (editor3DDrag = editor3DVoxelPress(ray, mode));
37039
+ else if (terrain)
37040
+ idle && mouseWasPressed(0) && editor3DTerrainHover &&
37041
+ (editor3DDrag = {kind: 'terrain', mode: shift ? 'lower' : ctrl ? 'smooth' : 'raise'});
37042
+ else if (idle && mouseWasPressed(0))
34754
37043
  editor3DPress(mouse, ray, shift);
34755
37044
 
34756
37045
  // a quick click, down and up before this step, is a press and its release in one
@@ -34779,6 +37068,8 @@ const editor3DHelpLines =
34779
37068
  'Right button: look, and WASD and QE fly while it is held, Shift faster',
34780
37069
  'Wheel: zoom · Middle drag or Space+drag: pan · Alt+drag: orbit · F: frame the selection',
34781
37070
  'Pick a type, then click to place it, Shift+click keeps placing',
37071
+ 'B blocks: click or drag places · Shift removes · Ctrl repaints · P picks the type under the mouse',
37072
+ 'T terrain: hold to raise the ground · Shift lowers · Ctrl smooths · the brush is in the panel',
34782
37073
  'Delete · Ctrl+C / X / V: copy, cut, paste · Ctrl+D: duplicate · Ctrl+Z / Y: undo, redo',
34783
37074
  'Reset to file: the level as its file has it, Restart keeps your edits, Undo brings them back',
34784
37075
  'Esc: play and edit · 0: exit the editor · ?: keys',
@@ -34796,6 +37087,12 @@ function editor3DHint()
34796
37087
  return 'WASD and QE fly · Shift faster · the wheel sets the speed';
34797
37088
  if (editor3DBrush)
34798
37089
  return `Click to place a ${editor3DBrush} · Shift keeps placing · Q puts it down`;
37090
+ if (editor3DTool === 'terrain')
37091
+ return editor3DTerrainMap() ? 'Hold to raise the ground · Shift lowers · Ctrl smooths' :
37092
+ 'Add a terrain in the panel to sculpt it';
37093
+ if (editor3DTool === 'blocks')
37094
+ return editor3DVoxelMap() ? 'Click or drag places · Shift removes · Ctrl repaints · P picks a type' :
37095
+ 'Add a block map in the panel to paint blocks';
34799
37096
  if (editor3DSelection.size)
34800
37097
  return 'Drag a handle or the object · W move · E rotate · R scale · End drops it';
34801
37098
  return 'Click to select · pick a type to place · the right button looks · ? keys';
@@ -34928,6 +37225,24 @@ function editor3DDraw()
34928
37225
  }
34929
37226
  }
34930
37227
 
37228
+ // the cell the Blocks tool would change: white to place, red to remove, yellow to repaint
37229
+ const map = editor3DTool === 'blocks' && !editor3DMouseOnPanel ? editor3DVoxelMap() : undefined;
37230
+ if (map && editor3DBlockHover && !editor3DDrag)
37231
+ {
37232
+ const {cell, mode} = editor3DBlockHover;
37233
+ editor3DDrawWire(buildMatrix(map.pos3D.add(cell).add(vec3(.5)), undefined, vec3(1.02)),
37234
+ mode === 'place' ? hsl(0, 0, 1, .9) : mode === 'remove' ? hsl(0, 1, .6) : hsl(.15, 1, .6), 2.5);
37235
+ }
37236
+ // the Terrain tool's brush, a ring on the ground
37237
+ if (editor3DTool === 'terrain' && editor3DTerrainHover && editor3DTerrainMap())
37238
+ editor3DDrawRing(editor3DTerrainHover.add(vec3(0, .05, 0)), EDITOR3D_AXES.x, EDITOR3D_AXES.z,
37239
+ editor3DTerrainBrush.size / 2, hsl(0, 0, 1, .9), 2.5);
37240
+
37241
+ // the map's edges, to see where it ends
37242
+ if (map)
37243
+ editor3DDrawWire(buildMatrix(map.pos3D.add(map.mapSize.scale(.5)), undefined, map.mapSize),
37244
+ hsl(.55, .6, .6, .5), 1);
37245
+
34931
37246
  // the brush, a box where a click would place it
34932
37247
  const point = editor3DBrush && !editor3DMouseOnPanel && !editor3DDrag && editor3DMousePoint();
34933
37248
  if (point)
@@ -35056,11 +37371,11 @@ function editor3DPanelInit()
35056
37371
  // the tools, and the snapping
35057
37372
  const tools = row(), toolButtons = {};
35058
37373
  for (const [tool, text, key] of [['select', 'Select', 'Q'], ['move', 'Move', 'W'], ['rotate', 'Rotate', 'E'],
35059
- ['scale', 'Scale', 'R']])
37374
+ ['scale', 'Scale', 'R'], ['blocks', 'Blocks', 'B'], ['terrain', 'Terrain', 'T']])
35060
37375
  toolButtons[tool] = button(tools, text, ()=>
35061
37376
  {
35062
37377
  editor3DTool = tool;
35063
- tool === 'select' && (editor3DBrush = undefined);
37378
+ (tool === 'select' || tool === 'blocks' || tool === 'terrain') && (editor3DBrush = undefined);
35064
37379
  }, key);
35065
37380
  const snap = editorElement('div', panel, box);
35066
37381
  const grid = check(snap, 'Grid snap', (on)=> editor3DGrid = on, 'G, and Ctrl flips it for a drag');
@@ -35076,6 +37391,21 @@ function editor3DPanelInit()
35076
37391
  const types = row();
35077
37392
  const properties = editorElement('div', panel, box);
35078
37393
 
37394
+ // the Blocks tool's box: a map to add, or the types to paint with
37395
+ const blocks = editorElement('div', panel, box);
37396
+
37397
+ // the Terrain tool's box: a terrain to add, or the brush to sculpt with
37398
+ const terrainBox = editorElement('div', panel, box);
37399
+
37400
+ // the scene the level sets: its sky, sun, fog and shadows, or none, the game's own
37401
+ const scene = editorElement('div', panel, box);
37402
+ const sceneOn = check(scene, 'Level sets the scene', (on)=>
37403
+ {
37404
+ editor3DChangeScene(()=> on ? editor3DSceneFromView() : undefined);
37405
+ editor3DStrokeEnd();
37406
+ }, 'The sky, sun, fog and shadows are saved in the level; off leaves them to the game');
37407
+ const sceneRows = editorElement('div', scene);
37408
+
35079
37409
  const file = row();
35080
37410
  // Save says so for a moment when it saved, Save As shows where a page can write files
35081
37411
  const saved = (b, label)=> (result)=> result && (b.textContent = 'Saved', setTimeout(()=> b.textContent = label, 1e3));
@@ -35098,7 +37428,7 @@ function editor3DPanelInit()
35098
37428
  button(help, 'Close', ()=> editor3DHelp = false, '?');
35099
37429
 
35100
37430
  editor3DPanelParts = {restart, pending, toolButtons, grid, moveStep, rotateStep, scaleStep, ground, types,
35101
- properties, playFrom, storage, hint, help, typeNames: ''};
37431
+ properties, blocks, terrainBox, sceneOn, sceneRows, playFrom, storage, hint, help, typeNames: ''};
35102
37432
  }
35103
37433
 
35104
37434
  // show the panel as the editor is now
@@ -35141,6 +37471,255 @@ function editor3DPanelUpdate()
35141
37471
  }
35142
37472
  names.forEach((name, i)=> p.types.children[i].style.outline = name === editor3DBrush ? lit : '');
35143
37473
  editor3DPropertiesUpdate(p.properties);
37474
+ editor3DBlocksUpdate(p.blocks);
37475
+ editor3DTerrainBoxUpdate(p.terrainBox);
37476
+ p.sceneOn.checked = !!editor3DScene();
37477
+ editor3DSceneUpdate(p.sceneRows);
37478
+ }
37479
+
37480
+ // the terrain box, shown with the Terrain tool: without a terrain, its size, cells and height and a button to add
37481
+ // it; with one, the brush's size and strength, the terrain's color, and a button to take it away
37482
+ function editor3DTerrainBoxUpdate(box)
37483
+ {
37484
+ const map = editor3DTerrainMap(), shown = editor3DTool === 'terrain';
37485
+ box.style.display = shown ? '' : 'none';
37486
+ if (!shown) return;
37487
+ const terrain = editor3DLevelPart('terrain'), key = map ? 'terrain ' + terrain.color : 'none';
37488
+ if (box.dataset.key === key || box.contains(document.activeElement)) return;
37489
+ box.dataset.key = key;
37490
+ box.replaceChildren();
37491
+ const field = 'background:#222;color:#eee';
37492
+ const line = (name, title='')=>
37493
+ {
37494
+ const row = editorElement('label', box, 'display:flex;gap:4px;align-items:center;margin:2px 0');
37495
+ editorElement('span', row, 'flex:1', name);
37496
+ row.title = title;
37497
+ return row;
37498
+ };
37499
+ const number = (name, title, value, most)=>
37500
+ {
37501
+ const input = editorElement('input', line(name, title), field + ';width:60px');
37502
+ input.type = 'number';
37503
+ input.min = '1', input.max = most + '', input.step = '1';
37504
+ input.value = value + '';
37505
+ return input;
37506
+ };
37507
+ if (!map)
37508
+ {
37509
+ const size = number('Size', 'How far across it is in the world, each way', 64, 1024);
37510
+ const cellsRow = line('Cells', 'How many cells a side, more is finer and slower to sculpt');
37511
+ const cells = editorElement('select', cellsRow, field + ';width:68px');
37512
+ for (const n of [32, 64, 128])
37513
+ editorElement('option', cells, '', n + '').value = n + '';
37514
+ cells.value = '64';
37515
+ const height = number('Height', 'How tall its highest ground can be', 16, 1024);
37516
+ const add = editorElement('button', box, 'width:100%;padding:3px;cursor:pointer', 'Add terrain');
37517
+ add.title = 'A flat terrain, centered, its surface on the ground, to raise and dig';
37518
+ add.onclick = ()=>
37519
+ {
37520
+ const read = (input, fallback)=> clamp(parseFloat(input.value) || fallback, 1, 1024);
37521
+ editor3DTerrainAdd(vec2(read(size, 64)), parseInt(cells.value), read(height, 16));
37522
+ add.blur();
37523
+ };
37524
+ return;
37525
+ }
37526
+ const slider = (name, title, min, max, step, value, set)=>
37527
+ {
37528
+ const input = editorElement('input', line(name, title), 'width:120px');
37529
+ input.type = 'range';
37530
+ input.min = min, input.max = max, input.step = step;
37531
+ input.value = value + '';
37532
+ input.oninput = ()=> set(parseFloat(input.value));
37533
+ input.onchange = ()=> input.blur();
37534
+ };
37535
+ slider('Brush size', 'How far across the brush is', 1, 32, .5, editor3DTerrainBrush.size,
37536
+ (v)=> editor3DTerrainBrush.size = v);
37537
+ slider('Strength', 'How fast the brush works', .05, 1, .05, editor3DTerrainBrush.strength,
37538
+ (v)=> editor3DTerrainBrush.strength = v);
37539
+ const color = editorElement('input', line('Color'), field + ';width:60px');
37540
+ color.type = 'color';
37541
+ color.value = /^#[0-9a-f]{6}/i.test(terrain.color) ? terrain.color.slice(0, 7) : '#ffffff';
37542
+ color.oninput = ()=> editor3DChangePart('terrain', (t)=> ({...t, color: color.value}));
37543
+ color.onchange = ()=> { color.oninput(); editor3DStrokeEnd(); color.blur(); };
37544
+ const remove = editorElement('button', box, 'margin-top:4px;padding:2px 6px;cursor:pointer', 'Remove terrain');
37545
+ remove.title = 'Take the terrain out of the level, Undo brings it back';
37546
+ remove.onclick = ()=> { editor3DTerrainRemove(); remove.blur(); };
37547
+ }
37548
+
37549
+ // the blocks box, shown with the Blocks tool: without a map, its size and a button to add it; with one, a tile
37550
+ // for each block type to paint with, and a button to take the map away; made again when what it shows changes
37551
+ function editor3DBlocksUpdate(box)
37552
+ {
37553
+ const map = editor3DVoxelMap(), shown = editor3DTool === 'blocks';
37554
+ box.style.display = shown ? '' : 'none';
37555
+ if (!shown) return;
37556
+ const first = map?.tileInfo, texture = first?.textureInfo;
37557
+ const key = map ? ['map', editor3DBlockType, texture?.size.x, map.blockTypes.length].join() : 'none';
37558
+ if (box.dataset.key === key || box.contains(document.activeElement)) return;
37559
+ box.dataset.key = key;
37560
+ box.replaceChildren();
37561
+ const field = 'background:#222;color:#eee;width:46px';
37562
+ if (!map)
37563
+ {
37564
+ const row = editorElement('label', box, 'display:flex;gap:4px;align-items:center;margin:2px 0');
37565
+ editorElement('span', row, 'flex:1', 'Map size');
37566
+ const inputs = [32, 16, 32].map((n)=>
37567
+ {
37568
+ const input = editorElement('input', row, field);
37569
+ input.type = 'number';
37570
+ input.min = '1', input.max = '256', input.step = '1';
37571
+ input.value = n + '';
37572
+ return input;
37573
+ });
37574
+ const add = editorElement('button', box, 'width:100%;padding:3px;cursor:pointer', 'Add block map');
37575
+ add.title = 'An empty map of blocks, centered, its bottom on the ground';
37576
+ add.onclick = ()=>
37577
+ {
37578
+ const [x, y, z] = inputs.map((i)=> clamp(floor(parseFloat(i.value)) || 1, 1, 256));
37579
+ editor3DVoxelAdd(vec3(x, y, z));
37580
+ add.blur();
37581
+ };
37582
+ return;
37583
+ }
37584
+
37585
+ // the tiles of the sheet, a type shows the tile of its side; a click picks the type
37586
+ const image = texture?.image, padding = first.padding || 0;
37587
+ const cell = first.size.add(vec2(padding * 2));
37588
+ const count = texture && cell.x && cell.y ? floor(texture.size.x / cell.x) * floor(texture.size.y / cell.y) : 0;
37589
+ const types = [];
37590
+ for (let type = 1; type < 256 && types.length < 96; ++type)
37591
+ (type < count || map.blockTypes[type]) && types.push(type);
37592
+ types.includes(editor3DBlockType) || types.push(editor3DBlockType);
37593
+ const grid = editorElement('div', box, 'display:flex;flex-wrap:wrap;gap:2px');
37594
+ for (const type of types)
37595
+ {
37596
+ const canvas = editorElement('canvas', grid, 'width:26px;height:26px;cursor:pointer;background:#444;' +
37597
+ 'image-rendering:pixelated;outline:' + (type === editor3DBlockType ? '2px solid #4af' : 'none'));
37598
+ canvas.width = canvas.height = 26;
37599
+ canvas.title = 'Block ' + type;
37600
+ canvas.onclick = ()=> editor3DBlockType = type;
37601
+ // the tile of its side; read without map.blockType, which would make every type one the map has
37602
+ const index = map.blockTypes[type]?.faces[0] ?? type;
37603
+ const tile = first.columns ? first.frame(index) : first.index(index);
37604
+ const context = canvas.getContext('2d');
37605
+ context.imageSmoothingEnabled = false;
37606
+ try
37607
+ {
37608
+ image && context.drawImage(image, tile.pos.x, tile.pos.y, tile.size.x, tile.size.y, 0, 0, 26, 26);
37609
+ // a tile with nothing on it is left out, unless it is the type picked, one in use or one the game set up
37610
+ const pixels = context.getImageData(0, 0, 26, 26).data;
37611
+ let blank = !!image;
37612
+ for (let i = 3; blank && i < pixels.length; i += 4)
37613
+ blank = !pixels[i];
37614
+ blank && type !== editor3DBlockType && !map.blockTypes[type] && canvas.remove();
37615
+ }
37616
+ catch {} // an image that can not be drawn or read, the title still says the type
37617
+ }
37618
+ const remove = editorElement('button', box, 'margin-top:4px;padding:2px 6px;cursor:pointer', 'Remove map');
37619
+ remove.title = 'Take the block map out of the level, Undo brings it back';
37620
+ remove.onclick = ()=> { editor3DVoxelRemove(); remove.blur(); };
37621
+ }
37622
+
37623
+ // the scene box: an input for each setting of the level's scene block, made again when the block changes, but not
37624
+ // while one of its inputs is being used; a slider or a color changes the scene as it moves, and is one undo
37625
+ function editor3DSceneUpdate(box)
37626
+ {
37627
+ const scene = editor3DScene(), key = scene ? JSON.stringify(scene) : '';
37628
+ box.style.display = scene ? '' : 'none';
37629
+ if (box.dataset.key === key || box.contains(document.activeElement)) return;
37630
+ box.dataset.key = key;
37631
+ box.replaceChildren();
37632
+ if (!scene) return;
37633
+
37634
+ // a setting the block leaves out shows as it is on screen
37635
+ const view = editor3DSceneFromView(), value = (name)=> scene[name] ?? view[name];
37636
+ const change = (name, v)=> editor3DChangeScene((s={})=> ({...s, [name]: v}));
37637
+ const field = 'background:#222;color:#eee';
37638
+ const line = (name, title='')=>
37639
+ {
37640
+ const row = editorElement('label', box, 'display:flex;gap:4px;align-items:center;margin:2px 0');
37641
+ editorElement('span', row, 'flex:1', name);
37642
+ row.title = title;
37643
+ return row;
37644
+ };
37645
+ // an input that changes the scene as it moves and ends the edit when let go
37646
+ const live = (input, read)=>
37647
+ {
37648
+ input.oninput = ()=> read();
37649
+ input.onchange = ()=> { read(); editor3DStrokeEnd(); input.blur(); };
37650
+ return input;
37651
+ };
37652
+ const slider = (name, title, min, max, step, v, set)=>
37653
+ {
37654
+ const input = editorElement('input', line(name, title), 'width:120px');
37655
+ input.type = 'range';
37656
+ input.min = min, input.max = max, input.step = step;
37657
+ input.value = v + '';
37658
+ return live(input, ()=> set(parseFloat(input.value)));
37659
+ };
37660
+ const color = (name, title, hex, set)=>
37661
+ {
37662
+ const input = editorElement('input', line(name, title), field + ';width:60px');
37663
+ input.type = 'color';
37664
+ input.value = /^#[0-9a-f]{6}/i.test(hex) ? hex.slice(0, 7) : '#ffffff';
37665
+ return live(input, ()=> set(input.value));
37666
+ };
37667
+ const number = (row, v, set)=>
37668
+ {
37669
+ const input = editorElement('input', row, field + ';width:56px');
37670
+ input.type = 'number';
37671
+ input.step = 'any';
37672
+ input.min = '0';
37673
+ input.value = v + '';
37674
+ input.onchange = ()=>
37675
+ {
37676
+ const n = parseFloat(input.value);
37677
+ isNumber(n) && n >= 0 && set(n);
37678
+ editor3DStrokeEnd();
37679
+ input.blur();
37680
+ };
37681
+ return input;
37682
+ };
37683
+ const toggle = (name, title, on, set)=>
37684
+ {
37685
+ const input = editorElement('input', line(name, title));
37686
+ input.type = 'checkbox';
37687
+ input.checked = on;
37688
+ input.onchange = ()=> { set(input.checked); editor3DStrokeEnd(); input.blur(); };
37689
+ };
37690
+
37691
+ // the sky: three colors and how much of them lights the scene, or no sky
37692
+ const sky = isArray(scene.sky) && scene.sky.length === 3 ? scene.sky : undefined;
37693
+ toggle('Sky', 'A sky dome, and light from it; off leaves the sky to the game', !!sky, (on)=>
37694
+ editor3DChangeScene((s={})=>
37695
+ {
37696
+ const {sky, ambient, ...rest} = s;
37697
+ return on ? {sky: ['#3d9be9', '#ccebff', '#ccebff'], ambient: .5, ...rest} : rest;
37698
+ }));
37699
+ if (sky)
37700
+ {
37701
+ ['Sky top', 'Sky horizon', 'Sky bottom'].forEach((name, i)=> color(name, '', sky[i], (hex)=>
37702
+ change('sky', sky.map((c, k)=> k === i ? hex : c))));
37703
+ slider('Ambient', 'How much of the sky colors lights the scene', 0, 1, .05,
37704
+ isNumber(scene.ambient) ? scene.ambient : .5, (v)=> change('ambient', v));
37705
+ }
37706
+
37707
+ // the sun, as two angles
37708
+ const [around, height] = editor3DSunAngles(value('sunDirection'));
37709
+ slider('Sun around', 'Where the sun is around the scene, in degrees', 0, 359, 1, around, (v)=>
37710
+ change('sunDirection', editor3DSunDirection(v, editor3DSunAngles(value('sunDirection'))[1])));
37711
+ slider('Sun height', 'How high the sun is over the horizon, in degrees', 5, 90, 1, height, (v)=>
37712
+ change('sunDirection', editor3DSunDirection(editor3DSunAngles(value('sunDirection'))[0], v)));
37713
+ color('Sun color', '', value('sunColor'), (hex)=> change('sunColor', hex));
37714
+
37715
+ // the fog, by its distances, an end of 0 for none
37716
+ const fog = isArray(value('fog')) ? value('fog') : [0, 0], fogRow = line('Fog start, end',
37717
+ 'Where the fog starts and where it is total, an end of 0 for no fog');
37718
+ number(fogRow, fog[0], (v)=> change('fog', [v, fog[1]]));
37719
+ number(fogRow, fog[1], (v)=> change('fog', [fog[0], v]));
37720
+ color('Fog color', 'The sky\'s horizon color when the level has a sky and no fog color',
37721
+ scene.fogColor ?? (sky ? sky[1] : view.fogColor), (hex)=> change('fogColor', hex));
37722
+ toggle('Shadows', 'The sun casts shadows', !!value('shadows'), (on)=> change('shadows', on));
35144
37723
  }
35145
37724
 
35146
37725
  // the properties box: the position, rotation and scale of the one selected object and an input for each default
@@ -35616,6 +38195,8 @@ export
35616
38195
  glFlush,
35617
38196
  glCopyToContext,
35618
38197
  glSetAntialias,
38198
+ glMaxTextureSize,
38199
+ glClampTextureSize,
35619
38200
  glDraw,
35620
38201
  glDrawUntextured,
35621
38202
  glDrawPointsTransform,
@@ -35736,6 +38317,15 @@ export
35736
38317
  PostProcessPlugin,
35737
38318
  postProcessBloom,
35738
38319
  postProcessBloomShader,
38320
+ postProcessEffects,
38321
+ postProcessGlow,
38322
+ postProcessScanlines,
38323
+ postProcessNoise,
38324
+ postProcessVignette,
38325
+ postProcessCurve,
38326
+ postProcessChromatic,
38327
+ postProcessOutline,
38328
+ postProcessTV,
35739
38329
 
35740
38330
  // Light System
35741
38331
  lightSystem,
@@ -35873,6 +38463,7 @@ export
35873
38463
  FirstPersonCamera3D,
35874
38464
  ParticleEmitter3D,
35875
38465
  Trail3D,
38466
+ LensFlare3D,
35876
38467
  engineObjectsCollect3D,
35877
38468
  engineObjectsCallback3D,
35878
38469
  engineObjectsRaycast3D,
@@ -35893,6 +38484,7 @@ export
35893
38484
  level3DAddType,
35894
38485
  level3DAddMesh,
35895
38486
  level3DLoad,
38487
+ level3DVoxelSetup,
35896
38488
 
35897
38489
  // Three.js
35898
38490
  threeJS,
@@ -35910,5 +38502,26 @@ export
35910
38502
  loadAtlas,
35911
38503
  parseAtlas,
35912
38504
  spritesReady,
38505
+
38506
+ // Particle Effects
38507
+ particleEffectGroups,
38508
+ particleEffectSettings,
38509
+ particleEffectBehaviors,
38510
+ particleEffectShapes,
38511
+ particleEffectSanitize,
38512
+ particleEffectRecolor,
38513
+ particleEffectsAdd,
38514
+ particleEffectsGet,
38515
+ particleEffectsParse,
38516
+ particleEffectsText,
38517
+ particleEffectsLoad,
38518
+ particleEffectShapeTile,
38519
+ particleEffect,
38520
+ particleEffect3D,
38521
+ particleEffectsBuiltIn,
38522
+ particleEffectApply,
38523
+ particleEffectApply3D,
38524
+ particleEffectFromEmitter,
38525
+ particleEffectsAddBehavior,
35913
38526
  }
35914
38527