littlejsengine 1.21.0 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.23.0';
39
39
 
40
40
  /** Frames per second to update
41
41
  * @type {number}
@@ -982,8 +982,19 @@ function tweak(){}
982
982
  function tweakButton(){}
983
983
  function tweakDivider(){}
984
984
  function tweakEngineDefaults(){}
985
- const levelEditor = {isOpen: false, open(){}, close(){}, onTile: undefined, onRestart: undefined, onPlayFrom: undefined,
986
- paletteTiles: undefined, use3D: undefined};
985
+ class LevelEditor
986
+ {
987
+ constructor() { this.paletteTiles = this.use3D = this.tool = undefined; }
988
+ get isOpen() { return false; }
989
+ get is3D() { return false; }
990
+ get edit2D() { return undefined; }
991
+ get edit3D() { return undefined; }
992
+ open(){} close(){} addKey(){} addButton(){} addTool(){}
993
+ onTile(){} onRestart(){} onPlayFrom(){} onOpen(){} onClose(){} onUpdate(){} onDraw(){} onPanel(){}
994
+ onSave(){ return false; }
995
+ }
996
+ let levelEditor = new LevelEditor;
997
+ function setLevelEditor(editor){ levelEditor = editor; }
987
998
  function editorMapRestore(map){ return map; }
988
999
  function editorMapLoaded(){}
989
1000
  function editorJSONFetched(){}
@@ -2884,6 +2895,15 @@ let touchGamepadEnable = false;
2884
2895
  * @memberof Settings */
2885
2896
  let touchGamepadPassthrough = false;
2886
2897
 
2898
+ /** True if a pinch of two fingers turns the mouse wheel: touchPinch is added to mouseWheel, so what zooms with
2899
+ * the wheel zooms with a pinch, the mouse is between the two fingers, and a second finger lets go of the button
2900
+ * the first pressed, since a pinch is not a drag; turn it off for a game with its own use for two fingers, the
2901
+ * first finger stays the mouse and touchPinch can still be read
2902
+ * @type {boolean}
2903
+ * @default
2904
+ * @memberof Settings */
2905
+ let touchPinchWheel = true;
2906
+
2887
2907
  /** Size of center button if touch gamepad should have start button in the center
2888
2908
  * - Prevents activating when pressed near virtual stick or face buttons
2889
2909
  * - When the game is paused, any touch will press the button
@@ -3279,6 +3299,11 @@ function setTouchInputEnable(enable) { touchInputEnable = enable; }
3279
3299
  * @memberof Settings */
3280
3300
  function setTouchGamepadEnable(enable) { touchGamepadEnable = enable; }
3281
3301
 
3302
+ /** Set if a pinch of two fingers turns the mouse wheel
3303
+ * @param {boolean} enable
3304
+ * @memberof Settings */
3305
+ function setTouchPinchWheel(enable) { touchPinchWheel = enable; }
3306
+
3282
3307
  /** Set if touches outside the gamepad controls should still drive mouse/touch input
3283
3308
  * @param {boolean} passthrough
3284
3309
  * @memberof Settings */
@@ -4348,8 +4373,9 @@ class TextureInfo
4348
4373
  * @param {boolean} [useWebGL] - Should use WebGL if available?
4349
4374
  * @param {boolean|Array<number>} [wrap] - Should the texture wrap (REPEAT) or clamp (CLAMP_TO_EDGE)? Or the WebGL
4350
4375
  * modes across and down, like [gl.CLAMP_TO_EDGE, gl.MIRRORED_REPEAT], as a glTF sampler gives them
4376
+ * @param {boolean} [pixelated] - Hard edged or smooth for this texture alone, undefined follows tilesPixelated
4351
4377
  */
4352
- constructor(image, useWebGL=true, wrap=false)
4378
+ constructor(image, useWebGL=true, wrap=false, pixelated)
4353
4379
  {
4354
4380
  /** @property {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap} - image source */
4355
4381
  this.image = image;
@@ -4364,6 +4390,10 @@ class TextureInfo
4364
4390
  * across and down
4365
4391
  * @type {boolean|Array<number>} */
4366
4392
  this.wrap = wrap;
4393
+ /** @property {boolean|undefined} - Hard edged or smooth for this texture alone, a soft glow in a pixel art
4394
+ * game or pixel art in a smooth one; undefined follows tilesPixelated
4395
+ * @type {boolean|undefined} */
4396
+ this.pixelated = pixelated;
4367
4397
  useWebGL && this.createWebGLTexture();
4368
4398
  }
4369
4399
 
@@ -4394,6 +4424,17 @@ class TextureInfo
4394
4424
  this.wrap = wrap;
4395
4425
  glSetTextureWrap(this.glTexture, wrap);
4396
4426
  }
4427
+
4428
+ /** Make this texture hard edged or smooth on its own, whatever tilesPixelated says for the rest; it is made again
4429
+ * with the new filtering
4430
+ * @param {boolean} [pixelated] - undefined follows tilesPixelated again */
4431
+ setPixelated(pixelated)
4432
+ {
4433
+ this.pixelated = pixelated;
4434
+ if (!this.glTexture) return;
4435
+ this.destroyWebGLTexture();
4436
+ this.createWebGLTexture();
4437
+ }
4397
4438
  }
4398
4439
 
4399
4440
  ///////////////////////////////////////////////////////////////////////////////
@@ -4569,17 +4610,29 @@ class SpriteAnimator
4569
4610
  * @return {SpriteAnimation} */
4570
4611
  get clip() { return this.clips[this.name]; }
4571
4612
 
4613
+ /** The clip showing now, once a clip that has ended has called its onEnd: the callback may switch clips, and a
4614
+ * read gives the clip it switched to, not the one that ended
4615
+ * @return {SpriteAnimation}
4616
+ * @ignore */
4617
+ get settledClip()
4618
+ {
4619
+ // each clip's own isDone calls its onEnd once; a few switches in a row at most
4620
+ for (let i = 4, clip; i-- && clip !== this.clip;)
4621
+ (clip = this.clip).isDone;
4622
+ return this.clip;
4623
+ }
4624
+
4572
4625
  /** The tile of the frame showing now
4573
4626
  * @return {TileInfo} */
4574
- get tileInfo() { return this.clip.tileInfo; }
4627
+ get tileInfo() { return this.settledClip.tileInfo; }
4575
4628
 
4576
4629
  /** The frame of the clip showing now
4577
4630
  * @return {number} */
4578
- get frame() { return this.clip.frame; }
4631
+ get frame() { return this.settledClip.frame; }
4579
4632
 
4580
4633
  /** True once the clip showing now is a play that has ended
4581
4634
  * @return {boolean} */
4582
- get isDone() { return this.clip.isDone; }
4635
+ get isDone() { return this.settledClip.isDone; }
4583
4636
  }
4584
4637
 
4585
4638
  ///////////////////////////////////////////////////////////////////////////////
@@ -5937,6 +5990,33 @@ class ImageFont
5937
5990
  }
5938
5991
  }
5939
5992
 
5993
+ // 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
5994
+ // and nothing at the edge, fading faster the higher the falloff
5995
+ function engineGlowAlpha(r, falloff)
5996
+ {
5997
+ const k = 3.5 * falloff, edge = Math.exp(-k);
5998
+ return (Math.exp(-k * r * r) - edge) / (1 - edge);
5999
+ }
6000
+
6001
+ // the soft round glow of the 2D and 3D lights, one texture for each falloff, rounded to a tenth so a changing falloff
6002
+ // makes only a few, each made once from a canvas; undefined headless or without a canvas
6003
+ const engineGlowTextures = new Map;
6004
+ function engineGlowTexture(falloff=1)
6005
+ {
6006
+ false&&ASSERT(isNumber(falloff) && falloff > 0, 'glowFalloff must be a number above 0');
6007
+ const key = max(round(falloff * 10), 1) / 10;
6008
+ let texture = engineGlowTextures.get(key);
6009
+ if (texture || !glContext || typeof OffscreenCanvas == 'undefined') return texture;
6010
+ const size = 64, context = createCanvasContext(size), steps = 16;
6011
+ const gradient = context.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
6012
+ for (let i = 0; i <= steps; ++i)
6013
+ gradient.addColorStop(i / steps, 'rgba(255,255,255,' + engineGlowAlpha(i / steps, key).toFixed(4) + ')');
6014
+ context.fillStyle = gradient;
6015
+ context.fillRect(0, 0, size, size);
6016
+ engineGlowTextures.set(key, texture = new TextureInfo(context.canvas, true, false, false));
6017
+ return texture;
6018
+ }
6019
+
5940
6020
  // load engine font, called automatically on startup
5941
6021
  async function imageFontInit()
5942
6022
  {
@@ -5991,6 +6071,13 @@ let mouseDeltaScreen = vec2();
5991
6071
  * @memberof Input */
5992
6072
  let mouseWheel = 0;
5993
6073
 
6074
+ /** Touch pinch delta this frame, in the mouse wheel's units: two fingers moving apart are negative, as the wheel
6075
+ * turned up is, and moving together positive, 1 for every 50 pixels; added to mouseWheel while touchPinchWheel
6076
+ * is on, so a game that zooms with the wheel zooms with a pinch
6077
+ * @type {number}
6078
+ * @memberof Input */
6079
+ let touchPinch = 0;
6080
+
5994
6081
  /** True if mouse was inside the document window, set to false when mouse leaves
5995
6082
  * @type {boolean}
5996
6083
  * @memberof Input */
@@ -6079,6 +6166,7 @@ function inputClearKey(key, device=0, clearDown=true, clearPressed=true, clearRe
6079
6166
  const inputWASDToArrow = {KeyW:'ArrowUp', KeyS:'ArrowDown', KeyA:'ArrowLeft', KeyD:'ArrowRight'};
6080
6167
  const inputArrowToWASD = {ArrowUp:'KeyW', ArrowDown:'KeyS', ArrowLeft:'KeyA', ArrowRight:'KeyD'};
6081
6168
  const inputKeysHeld = new Set; // the keys physically down, since an arrow's slot is shared with its alias
6169
+ let inputPinch; // the two fingers pinching and how far apart they were, undefined with no pinch
6082
6170
  let inputWasTouching = 0, inputTouchIdentifier; // the touch driving the mouse, cleared with the input so only a new touch presses
6083
6171
  let inputLastTouchTime = -1e9; // when a touch event last came, the mouse events a browser makes from a tap follow it
6084
6172
 
@@ -6121,6 +6209,7 @@ function inputClear()
6121
6209
  if (!i || inputData[i]) inputData[i] = [];
6122
6210
  inputKeysHeld.clear();
6123
6211
  inputWasTouching = 0;
6212
+ inputPinch = undefined;
6124
6213
  // release the touch gamepad, a finger still on it has to lift before it can take a control again
6125
6214
  touchGamepadPointerRole.clear();
6126
6215
  touchGamepadButtons.length = 0;
@@ -6648,19 +6737,52 @@ function inputInit()
6648
6737
  const pressTouch = !inputWasTouching && e.type == 'touchstart' &&
6649
6738
  [...e.changedTouches].find(t=> !isGamepadTouch(t));
6650
6739
  const touch = pressTouch || gameTouches[0];
6740
+ let pos = vec2(touch.clientX, touch.clientY);
6741
+
6742
+ // two fingers pinch: how far they moved together or apart is the touch screen's wheel, a
6743
+ // click for every 50 pixels, and the mouse is between them, the place to zoom about; with
6744
+ // touchPinchWheel off the pinch is only measured, and the first finger is the mouse as ever
6745
+ const pinching = touching > 1 && touchPinchWheel;
6746
+ if (touching > 1)
6747
+ {
6748
+ const [a, b] = gameTouches, key = a.identifier + ' ' + b.identifier;
6749
+ const distance = hypot(a.clientX - b.clientX, a.clientY - b.clientY);
6750
+ if (inputPinch && inputPinch.key === key)
6751
+ {
6752
+ const delta = (inputPinch.distance - distance) / 50;
6753
+ touchPinch += delta;
6754
+ if (touchPinchWheel)
6755
+ mouseWheel += delta;
6756
+ }
6757
+ inputPinch = {key, distance}; // other fingers start it over, with no jump
6758
+ if (pinching)
6759
+ pos = vec2((a.clientX + b.clientX) / 2, (a.clientY + b.clientY) / 2);
6760
+ }
6761
+ else
6762
+ inputPinch = undefined;
6651
6763
 
6652
6764
  // set event pos and pass it along
6653
- const pos = vec2(touch.clientX, touch.clientY);
6654
6765
  const mousePosScreenLast = mousePosScreen;
6655
6766
  mousePosScreen = mouseEventToScreen(pos);
6656
- if (inputWasTouching && touch.identifier === inputTouchIdentifier)
6767
+ if (pinching)
6768
+ {
6769
+ // a pinch is not a press or a drag, the finger that pressed lets go
6770
+ if (inputData[0][button] & 1)
6771
+ inputData[0][button] = inputData[0][button] & 2 | 4;
6772
+ }
6773
+ else if (inputWasTouching && touch.identifier === inputTouchIdentifier)
6657
6774
  mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
6658
6775
  else if (pressTouch)
6659
6776
  inputData[0][button] = 3;
6660
- inputTouchIdentifier = touch.identifier;
6777
+ // the finger left after a pinch moves the mouse from where it is, with no jump and no press
6778
+ inputTouchIdentifier = pinching ? undefined : touch.identifier;
6779
+ }
6780
+ else
6781
+ {
6782
+ inputPinch = undefined;
6783
+ if (inputWasTouching && (inputData[0][button] & 1))
6784
+ inputData[0][button] = inputData[0][button] & 2 | 4; // released only if it was pressed
6661
6785
  }
6662
- else if (inputWasTouching && (inputData[0][button] & 1))
6663
- inputData[0][button] = inputData[0][button] & 2 | 4; // released only if it was pressed
6664
6786
 
6665
6787
  // set was touching
6666
6788
  inputWasTouching = touching;
@@ -6919,7 +7041,7 @@ function inputUpdatePost()
6919
7041
  for (const deviceInputData of inputData)
6920
7042
  for (const i in deviceInputData)
6921
7043
  deviceInputData[i] &= 1;
6922
- mouseWheel = 0;
7044
+ mouseWheel = touchPinch = 0;
6923
7045
  mouseDelta = vec2();
6924
7046
  mouseDeltaScreen = vec2();
6925
7047
  }
@@ -7638,9 +7760,12 @@ class Sound
7638
7760
  * @param {number} [taper=soundDefaultTaper] - At what percentage of range should it start tapering
7639
7761
  * @param {SoundLoadCallback} [onloadCallback] - callback function to call when sound is loaded
7640
7762
  */
7641
- constructor(asset, randomness, range=soundDefaultRange, taper=soundDefaultTaper, onloadCallback)
7763
+ constructor(asset, randomness, range, taper=soundDefaultTaper, onloadCallback)
7642
7764
  {
7643
7765
  if (!soundEnable || headlessMode) return;
7766
+ const rangeIsDefault = range === undefined;
7767
+ if (rangeIsDefault)
7768
+ range = soundDefaultRange;
7644
7769
 
7645
7770
  false&&ASSERT(!asset || isArray(asset) || isStringLike(asset), 'asset must be a file name or zzfx array');
7646
7771
  false&&ASSERT(randomness === undefined || isNumber(randomness), 'randomness must be a number');
@@ -7648,8 +7773,10 @@ class Sound
7648
7773
  false&&ASSERT(isNumber(range), 'range must be a number');
7649
7774
  false&&ASSERT(isNumber(taper), 'taper must be a number');
7650
7775
 
7651
- /** @property {number} - World space max range of sound */
7652
7776
  this.range = range;
7777
+ /** @property {boolean} - No range was given, so it got soundDefaultRange; played in 3D such a sound is heard
7778
+ * to render3D.soundDefaultRange instead, which is further; setting range makes it false */
7779
+ this.rangeIsDefault = rangeIsDefault;
7653
7780
  /** @property {number} - At what percentage of range should it start tapering */
7654
7781
  this.taper = taper;
7655
7782
  /** @property {number} - How much to randomize frequency each time sound plays
@@ -7704,6 +7831,15 @@ class Sound
7704
7831
  }
7705
7832
  }
7706
7833
 
7834
+ /** World space max range of sound, 0 for no limit; a range that is set is the sound's own, in 3D too
7835
+ * @type {number} */
7836
+ get range() { return this.rangeValue; }
7837
+ set range(range)
7838
+ {
7839
+ this.rangeValue = range;
7840
+ this.rangeIsDefault = false;
7841
+ }
7842
+
7707
7843
  /** Sample data for each channel
7708
7844
  * Sounds keep their samples in an audio buffer, so reading this rebuilds
7709
7845
  * the arrays from it and caches them. The copies are safe to hold onto,
@@ -8285,7 +8421,7 @@ function playAudioBuffer(buffer, volume=1, rate=1, pan=0, loop=false, gainNode,
8285
8421
  }
8286
8422
 
8287
8423
  ///////////////////////////////////////////////////////////////////////////////
8288
- // ZzFXMicro - Zuper Zmall Zound Zynth - v1.3.2 by Frank Force
8424
+ // ZzFXMicro - Zuper Zmall Zound Zynth - v1.4.0 by Frank Force
8289
8425
 
8290
8426
  /** Generate and play a ZzFX sound
8291
8427
  *
@@ -8317,7 +8453,7 @@ function zzfx(...zzfxSound) { return playSamples([zzfxG(...zzfxSound)]); }
8317
8453
  * @param {number} [decay] - Decay time, how long to reach sustain after attack (seconds)
8318
8454
  * @param {number} [tremolo] - Trembling effect, rate controlled by repeat time (percent)
8319
8455
  * @param {number} [filter] - Filter cutoff frequency, positive for HPF, negative for LPF (Hz)
8320
- * @return {Array} - Array of audio samples
8456
+ * @return {Float32Array} - The audio samples
8321
8457
  * @memberof Audio */
8322
8458
  function zzfxG
8323
8459
  (
@@ -8355,7 +8491,7 @@ function zzfxG
8355
8491
  crush = 0, // bit crush offset
8356
8492
  jump = 1, // pitch jump timer
8357
8493
  length, // sample length
8358
- b = [], // sample buffer
8494
+ b, // sample buffer
8359
8495
  t = 0, // sample time
8360
8496
  i = 0, // sample index
8361
8497
  s = 0, // sample value
@@ -8382,9 +8518,12 @@ function zzfxG
8382
8518
  pitchJumpTime *= sampleRate;
8383
8519
  repeatTime = repeatTime * sampleRate | 0;
8384
8520
 
8521
+ // allocate the full sample buffer up front, much faster than growing an array
8522
+ length = attack + decay + sustain + release + delay | 0;
8523
+ b = new Float32Array(length > 0 ? length : 0);
8524
+
8385
8525
  // generate waveform
8386
- for (length = attack + decay + sustain + release + delay | 0;
8387
- i < length; b[i++] = s * volume) // sample
8526
+ for (; i < length; b[i++] = s * volume) // sample
8388
8527
  {
8389
8528
  if (!(++crush%(bitCrush*100|0))) // bit crush
8390
8529
  {
@@ -8420,7 +8559,7 @@ function zzfxG
8420
8559
 
8421
8560
  f = (frequency += slide += deltaSlide) *// frequency
8422
8561
  cos(modulation*modOffset++); // modulation
8423
- t += f + f*noise*sin(i**5); // noise
8562
+ t += f + f*noise*(i*i*PI2%2-1); // noise
8424
8563
 
8425
8564
  if (jump && ++jump > pitchJumpTime) // pitch jump
8426
8565
  {
@@ -9598,6 +9737,13 @@ class ParticleEmitter extends EngineObject
9598
9737
  this.localSpace = localSpace;
9599
9738
  /** @property {number} - If non zero the particle is drawn as a trail, stretched in the direction of velocity */
9600
9739
  this.trailScale = 0;
9740
+ /** @property {number} - Grows the whole effect as each particle is born: the spawn area, the particles' sizes,
9741
+ * speed and fall, so it looks the same only bigger; 1 is as made, and a particle keeps the scale it was born
9742
+ * with, so a change never moves the ones already out */
9743
+ this.scale = 1;
9744
+ /** @property {number} - The effect's own fall, added to each particle's velocity y every frame, on top of the
9745
+ * world's gravity times gravityScale; an effect that sets its own looks the same in any game */
9746
+ this.gravity = 0;
9601
9747
  /** @property {ParticleCallback|undefined} - Callback when particle is created
9602
9748
  * @type {ParticleCallback|undefined} */
9603
9749
  this.particleCreateCallback = undefined;
@@ -9684,9 +9830,9 @@ class ParticleEmitter extends EngineObject
9684
9830
  {
9685
9831
  // show emitter bounds
9686
9832
  if (this.emitCircle)
9687
- debugCircle(this.pos, this.emitSize.x, '#0f0', 0, false, false);
9833
+ debugCircle(this.pos, this.emitSize.x * this.scale, '#0f0', 0, false, false);
9688
9834
  else
9689
- debugRect(this.pos, this.emitSize, '#0f0', 0, this.angle, false, false);
9835
+ debugRect(this.pos, this.emitSize.scale(this.scale), '#0f0', 0, this.angle, false, false);
9690
9836
  }
9691
9837
  }
9692
9838
 
@@ -9695,10 +9841,11 @@ class ParticleEmitter extends EngineObject
9695
9841
  emitParticle()
9696
9842
  {
9697
9843
  // spawn a particle
9844
+ const scale = this.scale; // grows the spawn area, the sizes and the speed of each particle born
9698
9845
  let pos = this.emitCircle ? // check if circle emitter
9699
- randInCircle(this.emitSize.x/2) // circle emitter
9700
- : vec2(rand(-.5,.5), rand(-.5,.5)) // box emitter
9701
- .multiply(this.emitSize);
9846
+ randInCircle(this.emitSize.x/2 * scale) // circle emitter
9847
+ : vec2(rand(-.5,.5) * this.emitSize.x * scale, // box emitter, one vector made
9848
+ rand(-.5,.5) * this.emitSize.y * scale);
9702
9849
  let angle = rand(this.particleConeAngle, -this.particleConeAngle);
9703
9850
  if (!this.localSpace)
9704
9851
  {
@@ -9715,9 +9862,9 @@ class ParticleEmitter extends EngineObject
9715
9862
 
9716
9863
  // randomize particle settings
9717
9864
  const particleTime = randomizeScale(this.particleTime);
9718
- const sizeStart = randomizeScale(this.sizeStart);
9719
- const sizeEnd = randomizeScale(this.sizeEnd);
9720
- const speed = randomizeScale(this.speed);
9865
+ const sizeStart = randomizeScale(this.sizeStart) * scale;
9866
+ const sizeEnd = randomizeScale(this.sizeEnd) * scale;
9867
+ const speed = randomizeScale(this.speed) * scale;
9721
9868
  const angleSpeed = randomizeScale(this.angleSpeed) * randSign();
9722
9869
  const coneAngle = rand(this.emitConeAngle, -this.emitConeAngle);
9723
9870
  const colorStart = randColor(this.colorStartA, this.colorStartB, this.randomColorLinear);
@@ -9751,9 +9898,13 @@ class ParticleEmitter extends EngineObject
9751
9898
  render()
9752
9899
  {
9753
9900
  // render all particles, the blend switched once for them all, not around each one
9901
+ // and the tile's draw worked out once for them all, fresh each frame so a tile changed in place draws changed
9754
9902
  this.additive && setAdditiveBlendMode();
9903
+ particleTile = null; // none yet, undefined is the untextured tile
9904
+ particleTileBatch = true;
9755
9905
  for (const particle of this.particles)
9756
9906
  particle.render();
9907
+ particleTileBatch = false;
9757
9908
  this.additive && setAdditiveBlendMode(false);
9758
9909
  }
9759
9910
 
@@ -9780,6 +9931,39 @@ class ParticleEmitter extends EngineObject
9780
9931
  // scratch vectors reused by Particle.render and the tile collision to avoid per-frame allocations
9781
9932
  const particleDrawPos = new Vector2, particleDrawSize = new Vector2, particleCollidePos = new Vector2;
9782
9933
 
9934
+ // the WebGL draw of the tile particles draw with, what drawTile works out for each sprite: kept while an emitter
9935
+ // renders its particles, so each one only writes its own values into the batch
9936
+ let particleTile, particleTileBatch = false, particleTileTextured = false, particleTileEmpty = false;
9937
+ let particleTileU0 = 0, particleTileV0 = 0, particleTileU1 = 0, particleTileV1 = 0;
9938
+ function particleTileSet(tileInfo)
9939
+ {
9940
+ particleTile = tileInfo;
9941
+ const textureInfo = tileInfo?.textureInfo;
9942
+ particleTileTextured = !!textureInfo;
9943
+ particleTileEmpty = !!textureInfo && !(tileInfo.size.x && tileInfo.size.y); // like a sprite still loading
9944
+ if (!textureInfo || particleTileEmpty) return;
9945
+
9946
+ false&&ASSERT(!!textureInfo.glTexture, 'texture has no WebGL texture, draw it with useWebGL false');
9947
+ glSetTexture(textureInfo.glTexture); // bound, and its mipmaps brought up to date, once for the emitter
9948
+ const sizeInverse = textureInfo.sizeInverse, bleed = tileInfo.bleed;
9949
+ const x = tileInfo.pos.x * sizeInverse.x;
9950
+ const y = tileInfo.pos.y * sizeInverse.y;
9951
+ const w = tileInfo.size.x * sizeInverse.x;
9952
+ const h = tileInfo.size.y * sizeInverse.y;
9953
+ if (bleed)
9954
+ {
9955
+ const bleedX = sizeInverse.x*bleed;
9956
+ const bleedY = sizeInverse.y*bleed;
9957
+ particleTileU0 = x + bleedX; particleTileV0 = y + bleedY;
9958
+ particleTileU1 = x - bleedX + w; particleTileV1 = y - bleedY + h;
9959
+ }
9960
+ else
9961
+ {
9962
+ particleTileU0 = x; particleTileV0 = y;
9963
+ particleTileU1 = x + w; particleTileV1 = y + h;
9964
+ }
9965
+ }
9966
+
9783
9967
  // tests if a particle collides with tiles at x, y, through its emitter's collide callback if it has one
9784
9968
  function particleCollideTest(particle, collideCallback, x, y)
9785
9969
  {
@@ -9815,6 +9999,8 @@ class Particle
9815
9999
  {
9816
10000
  /** @property {ParticleEmitter} - The emitter this particle came from */
9817
10001
  this.emitter = emitter;
10002
+ /** @property {number} - The emitter's scale when it was made, which grows its fall */
10003
+ this.scale = emitter.scale ?? 1; // a hand made emitter-like object may have none
9818
10004
  /** @property {Vector2} - Position, world space or local to the emitter when localSpace is set */
9819
10005
  this.pos = pos;
9820
10006
  /** @property {number} - Angle in radians */
@@ -9877,7 +10063,10 @@ class Particle
9877
10063
  // apply physics; only the tile collision needs where the particle was
9878
10064
  const solve = enablePhysicsSolver && collideLevel;
9879
10065
  const oldX = this.pos.x, oldY = this.pos.y;
9880
- let gravityX = gravity.x * gravityScale, gravityY = gravity.y * gravityScale;
10066
+ // the world's gravity times gravityScale plus the emitter's own, grown by the particle's scale
10067
+ const fall = this.scale;
10068
+ let gravityX = gravity.x * gravityScale * fall;
10069
+ let gravityY = (gravity.y * gravityScale + (emitter.gravity || 0)) * fall;
9881
10070
  if (emitter.localSpace && emitter.angle)
9882
10071
  {
9883
10072
  // world gravity turned into the emitter's space, the render turns it back
@@ -9931,7 +10120,9 @@ class Particle
9931
10120
  }
9932
10121
  if (isBlockedY || !isBlockedX)
9933
10122
  {
9934
- const wasFalling = this.velocity.y < 0 && gravity.y < 0 || this.velocity.y > 0 && gravity.y > 0;
10123
+ // down is the world's gravity, or the emitter's own fall in a world with none
10124
+ const down = gravity.y || emitter.gravity || 0;
10125
+ const wasFalling = this.velocity.y < 0 && down < 0 || this.velocity.y > 0 && down > 0;
9935
10126
  if (wasFalling)
9936
10127
  this.groundObject = hitLayer;
9937
10128
 
@@ -10010,7 +10201,24 @@ class Particle
10010
10201
  angle = atan2(velocity.x, velocity.y);
10011
10202
  }
10012
10203
  }
10013
- drawTile(pos, size, this.tileInfo, this.color, angle, this.mirror, undefined, glEnable, false);
10204
+ if (glEnable && !headlessMode)
10205
+ {
10206
+ // straight into the batch, the tile's draw worked out once for the emitter's particles
10207
+ const tileInfo = this.tileInfo;
10208
+ if (!particleTileBatch || tileInfo !== particleTile)
10209
+ particleTileSet(tileInfo);
10210
+ if (!particleTileTextured)
10211
+ glDrawUntextured(pos.x, pos.y, size.x, size.y, angle, this.color.rgbaInt());
10212
+ else if (!particleTileEmpty)
10213
+ {
10214
+ const texture = tileInfo.textureInfo.glTexture;
10215
+ glActiveTexture === texture || glSetTexture(texture); // bound again only if a draw between changed it
10216
+ glDraw(pos.x, pos.y, this.mirror ? -size.x : size.x, size.y, angle,
10217
+ particleTileU0, particleTileV0, particleTileU1, particleTileV1, this.color.rgbaInt());
10218
+ }
10219
+ }
10220
+ else
10221
+ drawTile(pos, size, this.tileInfo, this.color, angle, this.mirror, undefined, false, false);
10014
10222
  additive && setAdditiveBlendMode(false);
10015
10223
  debugParticles && debugRect(pos, size, '#f005', 0, angle, false, false);
10016
10224
  }
@@ -10041,6 +10249,19 @@ let glCanvas;
10041
10249
  * @memberof WebGL */
10042
10250
  let glContext;
10043
10251
 
10252
+ /** The largest texture this device makes, in pixels across, 0 until WebGL starts; at least 2048 with WebGL2
10253
+ * @type {number}
10254
+ * @memberof WebGL */
10255
+ let glMaxTextureSize = 0;
10256
+
10257
+ /** A texture size this device can make: the size asked for, or the largest it supports when that is smaller
10258
+ * - The engine sizes the textures it makes itself through this, the shadow maps and the like, so a size a device
10259
+ * does not have falls back instead of failing
10260
+ * @param {number} size - Pixels across
10261
+ * @return {number}
10262
+ * @memberof WebGL */
10263
+ function glClampTextureSize(size) { return glMaxTextureSize ? min(size, glMaxTextureSize) : size; }
10264
+
10044
10265
  /** Should WebGL be setup with anti-aliasing? must be set before calling engineInit
10045
10266
  * @type {boolean}
10046
10267
  * @memberof WebGL */
@@ -10130,7 +10351,7 @@ function glInit(rootElement)
10130
10351
  // startup webgl, and make the textures of any texture infos made before it
10131
10352
  initWebGL();
10132
10353
  for (const info of glTextureInfos)
10133
- info.glTexture ||= glCreateTexture(info.image, info.wrap);
10354
+ info.glTexture ||= glCreateTexture(info.image, info.wrap, info.pixelated);
10134
10355
 
10135
10356
  // setup context lost and restore handlers
10136
10357
  glCanvas.addEventListener('webglcontextlost', (e)=>
@@ -10170,7 +10391,7 @@ function glInit(rootElement)
10170
10391
  glPremultipliedTextures = new WeakSet; // the tile layers draw into their new textures again below
10171
10392
  initWebGL();
10172
10393
  for (const info of glTextureInfos)
10173
- info.glTexture = glCreateTexture(info.image, info.wrap);
10394
+ info.glTexture = glCreateTexture(info.image, info.wrap, info.pixelated);
10174
10395
  pluginList.forEach(plugin=>plugin.glContextRestored?.());
10175
10396
 
10176
10397
  // a tile layer drawn on the GPU only had its tiles in the lost texture, it draws them again
@@ -10180,6 +10401,8 @@ function glInit(rootElement)
10180
10401
 
10181
10402
  function initWebGL()
10182
10403
  {
10404
+ glMaxTextureSize = glContext.getParameter(glContext.MAX_TEXTURE_SIZE) | 0;
10405
+
10183
10406
  // setup instanced rendering shader program
10184
10407
  glShader = glCreateProgram(gl_VERTEX_SOURCE,
10185
10408
  '#version 300 es\n' + // specify GLSL ES version
@@ -10503,9 +10726,10 @@ function glShaderProgram(shader)
10503
10726
  * Restores the active texture when done
10504
10727
  * @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap} [image]
10505
10728
  * @param {boolean|Array<number>} [wrap] - true for REPEAT, false for CLAMP_TO_EDGE, or the WebGL modes across and down
10729
+ * @param {boolean} [pixelated] - Hard edged or smooth, undefined follows tilesPixelated
10506
10730
  * @return {WebGLTexture}
10507
10731
  * @memberof WebGL */
10508
- function glCreateTexture(image, wrap=false)
10732
+ function glCreateTexture(image, wrap=false, pixelated=tilesPixelated)
10509
10733
  {
10510
10734
  if (!glContext) return;
10511
10735
 
@@ -10514,10 +10738,10 @@ function glCreateTexture(image, wrap=false)
10514
10738
  let mipMap = false;
10515
10739
  if (image?.width)
10516
10740
  {
10517
- glSetTextureData(texture, image);
10741
+ glSetTextureData(texture, image, pixelated);
10518
10742
  glContext.bindTexture(glContext.TEXTURE_2D, texture);
10519
10743
  // WebGL2 makes mipmaps at any size, a texture that becomes a render target keeps them only at powers of two
10520
- mipMap = !tilesPixelated;
10744
+ mipMap = !pixelated;
10521
10745
  if (mipMap && !(isPowerOfTwo(image.width) && isPowerOfTwo(image.height)))
10522
10746
  glMipmapsUntilTarget.add(texture);
10523
10747
  }
@@ -10530,7 +10754,7 @@ function glCreateTexture(image, wrap=false)
10530
10754
  }
10531
10755
 
10532
10756
  // set texture filtering
10533
- const magFilter = tilesPixelated ? glContext.NEAREST : glContext.LINEAR;
10757
+ const magFilter = pixelated ? glContext.NEAREST : glContext.LINEAR;
10534
10758
  const minFilter = mipMap ? glContext.LINEAR_MIPMAP_LINEAR : magFilter;
10535
10759
  glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MAG_FILTER, magFilter);
10536
10760
  glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MIN_FILTER, minFilter);
@@ -10561,8 +10785,9 @@ function glDeleteTexture(texture)
10561
10785
  /** Set WebGL texture data from an image, restores the active texture when done
10562
10786
  * @param {WebGLTexture} texture
10563
10787
  * @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap} image
10788
+ * @param {boolean} [pixelated] - Hard edged or smooth, undefined follows tilesPixelated
10564
10789
  * @memberof WebGL */
10565
- function glSetTextureData(texture, image)
10790
+ function glSetTextureData(texture, image, pixelated=tilesPixelated)
10566
10791
  {
10567
10792
  if (!glContext) return;
10568
10793
 
@@ -10580,7 +10805,7 @@ function glSetTextureData(texture, image)
10580
10805
  }
10581
10806
  // smooth filtering mixes a texel with its see through neighbors, right only for premultiplied color, or the
10582
10807
  // edges go dark; pixel art is sampled a texel at a time, and uploads straight color as it always has
10583
- const premultiply = !tilesPixelated;
10808
+ const premultiply = !pixelated;
10584
10809
  glContext.pixelStorei(glContext.UNPACK_PREMULTIPLY_ALPHA_WEBGL, premultiply);
10585
10810
  glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA, glContext.RGBA, glContext.UNSIGNED_BYTE, image);
10586
10811
  glContext.pixelStorei(glContext.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
@@ -10611,9 +10836,9 @@ function glRegisterTextureInfo(textureInfo)
10611
10836
 
10612
10837
  // create or set the texture data
10613
10838
  if (textureInfo.glTexture)
10614
- glSetTextureData(textureInfo.glTexture, textureInfo.image);
10839
+ glSetTextureData(textureInfo.glTexture, textureInfo.image, textureInfo.pixelated);
10615
10840
  else
10616
- textureInfo.glTexture = glCreateTexture(textureInfo.image, textureInfo.wrap);
10841
+ textureInfo.glTexture = glCreateTexture(textureInfo.image, textureInfo.wrap, textureInfo.pixelated);
10617
10842
  }
10618
10843
 
10619
10844
  /** Internal: tells WebGL to destroy the glTexture and stop tracking it, TextureInfo calls it
@@ -12111,8 +12336,8 @@ class PostProcessPlugin
12111
12336
  false&&ASSERT(!(includeMainCanvas && feedbackTexture), 'Post process cannot both include main canvas and use feedback texture');
12112
12337
  postProcess = this;
12113
12338
 
12114
- if (!shaderCode) // default shader pass through
12115
- shaderCode = 'void mainImage(out vec4 c,vec2 p){c=texture(iChannel0,p/iResolution.xy);}';
12339
+ /** @property {string} - The shadertoy style mainImage code it shades with, see setShaderCode */
12340
+ this.shaderCode = shaderCode || postProcessEffects(); // no code passes the frame through
12116
12341
 
12117
12342
  /** @property {WebGLProgram|undefined} - Shader for post processing
12118
12343
  * @type {WebGLProgram|undefined} */
@@ -12158,7 +12383,7 @@ class PostProcessPlugin
12158
12383
  'gl_Position=vec4(p+p-1.,1,1);'+ // set position
12159
12384
  '}' // end of shader
12160
12385
  ,
12161
- postProcessFragmentSource(shaderCode)
12386
+ postProcessFragmentSource(postProcess.shaderCode)
12162
12387
  );
12163
12388
 
12164
12389
  // setup VAO for post processing
@@ -12171,6 +12396,10 @@ class PostProcessPlugin
12171
12396
  const pLocation = glContext.getAttribLocation(postProcess.shader, 'p');
12172
12397
  glContext.enableVertexAttribArray(pLocation);
12173
12398
  glContext.vertexAttribPointer(pLocation, 2, glContext.FLOAT, false, vertexByteStride, 0);
12399
+
12400
+ // the engine's own vertex array and buffer back, its next batch is written into the buffer bound
12401
+ glContext.bindVertexArray(glPolyMode ? glPolyVAO : glInstancedVAO);
12402
+ glContext.bindBuffer(glContext.ARRAY_BUFFER, glArrayBuffer);
12174
12403
  }
12175
12404
  function postProcessContextLost()
12176
12405
  {
@@ -12188,16 +12417,17 @@ class PostProcessPlugin
12188
12417
  {
12189
12418
  if (headlessMode || !glEnable) return;
12190
12419
 
12191
- // made now if WebGL was off when the plugin was made, or when a lost context came back
12420
+ // clear out the buffer, before anything here binds its own
12421
+ glFlush();
12422
+
12423
+ // made now if WebGL was off when the plugin was made, when a lost context came back, or when
12424
+ // setShaderCode gave it new code
12192
12425
  if (!postProcess.shader)
12193
12426
  {
12194
12427
  if (glContext.isContextLost()) return;
12195
12428
  initPostProcess();
12196
12429
  }
12197
12430
 
12198
- // clear out the buffer
12199
- glFlush();
12200
-
12201
12431
  // ensure we render to the default framebuffer (in case any earlier
12202
12432
  // caller this frame left a render target bound)
12203
12433
  glContext.bindFramebuffer(glContext.FRAMEBUFFER, null);
@@ -12266,7 +12496,9 @@ class PostProcessPlugin
12266
12496
 
12267
12497
  if (feedbackTexture)
12268
12498
  {
12269
- // keep this frame's output for the next one, then hand the first texture unit back to the engine
12499
+ // keep this frame's output for the next one, in the feedback texture still bound to the second
12500
+ // unit, then hand the first texture unit back to the engine
12501
+ glContext.activeTexture(glContext.TEXTURE1);
12270
12502
  glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA, glContext.RGBA, glContext.UNSIGNED_BYTE, glCanvas);
12271
12503
  glContext.activeTexture(glContext.TEXTURE0);
12272
12504
  }
@@ -12288,6 +12520,23 @@ class PostProcessPlugin
12288
12520
  glSetInstancedMode(true);
12289
12521
  }
12290
12522
  }
12523
+
12524
+ /** Shade with new code from the next frame on, to switch effects while the game runs; the shader is made again
12525
+ * @param {string} [shaderCode] - Shadertoy style mainImage code, postProcessEffects builds it; none passes the
12526
+ * frame through */
12527
+ setShaderCode(shaderCode)
12528
+ {
12529
+ false&&ASSERT(!shaderCode || typeof shaderCode === 'string', 'shader code must be a string');
12530
+ this.shaderCode = shaderCode || postProcessEffects();
12531
+ if (!this.shader || headlessMode || !glContext) return;
12532
+
12533
+ // what the new shader replaces, made again by the next render
12534
+ glContext.deleteProgram(this.shader);
12535
+ glContext.deleteTexture(this.texture);
12536
+ this.feedbackTexture && glContext.deleteTexture(this.feedbackTexture);
12537
+ glContext.deleteVertexArray(this.vao);
12538
+ this.shader = this.texture = this.feedbackTexture = this.vao = undefined;
12539
+ }
12291
12540
  }
12292
12541
 
12293
12542
  ///////////////////////////////////////////////////////////////////////////////
@@ -12328,9 +12577,66 @@ function postProcessFragmentSource(shaderCode)
12328
12577
  */
12329
12578
  function postProcessBloomShader(threshold=.6, strength=1, size=6)
12330
12579
  {
12331
- false&&ASSERT(isNumber(threshold) && isNumber(strength) && isNumber(size), 'bloom settings must be numbers');
12332
- false&&ASSERT(size > 0, 'bloom size must be above zero');
12333
- false&&ASSERT(size <= 32, 'a bloom this wide takes a sample every few pixels of every ring, which is hundreds of samples a pixel', size);
12580
+ return postProcessEffects(postProcessGlow(threshold, strength, size));
12581
+ }
12582
+
12583
+ /**
12584
+ * Set up post processing with a bloom effect, so bright colors and lights glow
12585
+ * @param {number} [threshold] - Brightness where the glow starts, 0 is everything and 1 is only pure white
12586
+ * @param {number} [strength] - How much glow to add
12587
+ * @param {number} [size] - How far the glow spreads in pixels
12588
+ * @param {boolean} [includeMainCanvas] - Glow the 2D canvas too, off by default so HUD text stays crisp
12589
+ * (a HUD drawn with WebGL in gameRenderPost glows either way, draw it with useWebGL=false)
12590
+ * @return {PostProcessPlugin}
12591
+ * @memberof PostProcess
12592
+ * @example
12593
+ * postProcessBloom(); // in gameInit, after any Render3DPlugin
12594
+ */
12595
+ function postProcessBloom(threshold=.6, strength=1, size=6, includeMainCanvas=false)
12596
+ { return new PostProcessPlugin(postProcessBloomShader(threshold, strength, size), includeMainCanvas); }
12597
+
12598
+ ///////////////////////////////////////////////////////////////////////////////
12599
+ // Effects: pieces of shader code, each with its settings written in as numbers, that postProcessEffects joins in
12600
+ // 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
12601
+ // its pixel; one that bends uv or samples the frame, the curve, chromatic and glow, goes before the ones that shade
12602
+
12603
+ // a number as GLSL writes it, a float with a point
12604
+ const postProcessNumber = (n)=> (false&&ASSERT(isNumber(n), 'effect settings must be numbers', n), n.toFixed(4));
12605
+
12606
+ /**
12607
+ * Join effects into one post process shader, in the order given, for PostProcessPlugin or setShaderCode
12608
+ * - 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;
12609
+ * your own code is a piece too, a line of GLSL or many, dropped in where you put it
12610
+ * - Put the ones that bend the picture or sample it first: postProcessCurve, postProcessChromatic, postProcessGlow
12611
+ * @param {...string} effects - The pieces, from the effect functions or your own code
12612
+ * @return {string} - Shadertoy style mainImage code
12613
+ * @example
12614
+ * new PostProcessPlugin(postProcessEffects(
12615
+ * postProcessScanlines(.5), postProcessVignette(), 'c.rgb *= vec3(1, .9, .8);'));
12616
+ * @memberof PostProcess
12617
+ */
12618
+ function postProcessEffects(...effects)
12619
+ {
12620
+ false&&ASSERT(effects.every((e)=> typeof e === 'string'), 'each effect is a piece of shader code, a string');
12621
+ return 'void mainImage(out vec4 c, vec2 p)\n{\n' +
12622
+ ' vec2 uv = p / iResolution.xy;\n' +
12623
+ ' c = texture(iChannel0, uv);\n' +
12624
+ effects.map((e)=> ' {\n' + e + '\n }\n').join('') + '}\n';
12625
+ }
12626
+
12627
+ /**
12628
+ * Bright parts glow, the bloom as an effect to join with others; postProcessBloom sets up bloom on its own
12629
+ * @param {number} [threshold] - Brightness where the glow starts, 0 is everything and 1 is only pure white
12630
+ * @param {number} [strength] - How much glow to add
12631
+ * @param {number} [size] - How far the glow spreads in pixels, which also sets how many samples it takes
12632
+ * @return {string}
12633
+ * @memberof PostProcess
12634
+ */
12635
+ function postProcessGlow(threshold=.6, strength=1, size=6)
12636
+ {
12637
+ false&&ASSERT(isNumber(threshold) && isNumber(strength) && isNumber(size), 'glow settings must be numbers');
12638
+ false&&ASSERT(size > 0, 'glow size must be above zero');
12639
+ false&&ASSERT(size <= 32, 'a glow this wide takes a sample every few pixels of every ring, which is hundreds of samples a pixel', size);
12334
12640
 
12335
12641
  // Taps on three rings over a disc of the given size, one every three pixels or so of each ring
12336
12642
  // so there is no gap wide enough to show. The count follows the ring all the way out: hold it
@@ -12352,30 +12658,127 @@ function postProcessBloomShader(threshold=.6, strength=1, size=6)
12352
12658
  glow += max(vec3(0), texture(iChannel0, uv + vec2(cos(a), sin(a)) * ${radius.toFixed(4)} / iResolution.xy).rgb - ${threshold.toFixed(4)});
12353
12659
  }`;
12354
12660
  }
12355
- return `
12356
- void mainImage(out vec4 color, vec2 pixel)
12357
- {
12358
- vec2 uv = pixel / iResolution.xy;
12359
- color = texture(iChannel0, uv);
12661
+ return ` // glow
12360
12662
  vec3 glow = vec3(0);${code}
12361
- color.rgb += glow * ${(strength / taps).toFixed(6)};
12362
- }`;
12663
+ c.rgb += glow * ${(strength / taps).toFixed(6)};`;
12363
12664
  }
12364
12665
 
12365
12666
  /**
12366
- * Set up post processing with a bloom effect, so bright colors and lights glow
12367
- * @param {number} [threshold] - Brightness where the glow starts, 0 is everything and 1 is only pure white
12368
- * @param {number} [strength] - How much glow to add
12369
- * @param {number} [size] - How far the glow spreads in pixels
12370
- * @param {boolean} [includeMainCanvas] - Glow the 2D canvas too, off by default so HUD text stays crisp
12371
- * (a HUD drawn with WebGL in gameRenderPost glows either way, draw it with useWebGL=false)
12372
- * @return {PostProcessPlugin}
12667
+ * Scan lines across the screen, like an old TV
12668
+ * @param {number} [strength] - How dark the lines are, and how bright between them
12669
+ * @param {number} [spacing] - Pixels from one line to the next
12670
+ * @return {string}
12671
+ * @memberof PostProcess
12672
+ */
12673
+ function postProcessScanlines(strength=.5, spacing=6)
12674
+ {
12675
+ return ` // scanlines
12676
+ c.rgb *= 1. - ${postProcessNumber(strength)} * cos(p.y * 6.2832 / ${postProcessNumber(spacing)});`;
12677
+ }
12678
+
12679
+ /**
12680
+ * Static noise over the picture, changing every frame
12681
+ * @param {number} [strength] - How bright the static is
12682
+ * @param {number} [size] - Size of a speck in pixels
12683
+ * @return {string}
12684
+ * @memberof PostProcess
12685
+ */
12686
+ function postProcessNoise(strength=.1, size=2)
12687
+ {
12688
+ return ` // noise
12689
+ vec2 q = fract((floor(p / ${postProcessNumber(size)}) + mod(iTime * 500., 1e3)) * .3197);
12690
+ c.rgb += ${postProcessNumber(strength)} * fract(1. + sin(51. * q.x + 73. * q.y) * 13753.3);`;
12691
+ }
12692
+
12693
+ /**
12694
+ * Darken toward the edges and corners
12695
+ * @param {number} [strength] - How dark the corners get, 1 is black
12696
+ * @param {number} [falloff] - How far in it reaches, low darkens most of the screen, high only the corners
12697
+ * @return {string}
12698
+ * @memberof PostProcess
12699
+ */
12700
+ function postProcessVignette(strength=1, falloff=3)
12701
+ {
12702
+ return ` // vignette
12703
+ vec2 d = uv * 2. - 1.;
12704
+ c.rgb *= 1. - ${postProcessNumber(strength)} * min(1., pow(dot(d, d) / 2., ${postProcessNumber(falloff)}));`;
12705
+ }
12706
+
12707
+ /**
12708
+ * Bend the picture like the bulged glass of an old TV, black past the corners; put it first
12709
+ * @param {number} [strength] - How much it bends
12710
+ * @return {string}
12711
+ * @memberof PostProcess
12712
+ */
12713
+ function postProcessCurve(strength=.1)
12714
+ {
12715
+ return ` // curve
12716
+ vec2 d = uv * 2. - 1.;
12717
+ d *= 1. + ${postProcessNumber(strength)} * dot(d, d);
12718
+ uv = d * .5 + .5;
12719
+ c = all(lessThan(abs(d), vec2(1))) ? texture(iChannel0, uv) : vec4(0, 0, 0, 1);`;
12720
+ }
12721
+
12722
+ /**
12723
+ * Split red and blue apart toward the edges, like a cheap lens; put it before what shades the picture
12724
+ * @param {number} [strength] - How far apart at the edge, as a part of the screen
12725
+ * @return {string}
12726
+ * @memberof PostProcess
12727
+ */
12728
+ function postProcessChromatic(strength=.005)
12729
+ {
12730
+ return ` // chromatic
12731
+ vec2 d = (uv - .5) * ${postProcessNumber(strength)} * 2.;
12732
+ c.r = texture(iChannel0, uv + d).r;
12733
+ c.b = texture(iChannel0, uv - d).b;`;
12734
+ }
12735
+
12736
+ /**
12737
+ * Draw lines where the 3D depth jumps, around objects and along their creases; needs render3D.depthTexture on
12738
+ * @param {Color} [color] - The lines' color, its alpha how strong they are
12739
+ * @param {number} [thickness] - How wide the lines are in pixels
12740
+ * @param {number} [threshold] - How big a jump makes a line, as a part of the distance, lower draws more
12741
+ * @return {string}
12373
12742
  * @memberof PostProcess
12743
+ */
12744
+ function postProcessOutline(color=BLACK, thickness=1, threshold=.02)
12745
+ {
12746
+ false&&ASSERT(isColor(color), 'outline color must be a Color');
12747
+ const n = postProcessNumber;
12748
+ return ` // outline
12749
+ vec2 o = ${n(thickness)} / iResolution.xy;
12750
+ float d = sceneDepth(uv);
12751
+ float dx = abs(sceneDepth(uv + vec2(o.x, 0)) + sceneDepth(uv - vec2(o.x, 0)) - 2. * d);
12752
+ float dy = abs(sceneDepth(uv + vec2(0, o.y)) + sceneDepth(uv - vec2(0, o.y)) - 2. * d);
12753
+ vec4 line = vec4(${n(color.r)}, ${n(color.g)}, ${n(color.b)}, ${n(color.a)});
12754
+ c.rgb = mix(c.rgb, line.rgb, line.a * step(${n(threshold)}, max(dx, dy) / d));`;
12755
+ }
12756
+
12757
+ /**
12758
+ * The look of an old TV, as one effect to use alone or join with others: static noise, scan lines, a soft glow and
12759
+ * a vignette, and a bulged screen when curve is set; any setting at 0 leaves that part out
12760
+ * @param {Object} [settings]
12761
+ * @param {number} [settings.noise] - Static noise strength
12762
+ * @param {number} [settings.scanlines] - Scan line strength
12763
+ * @param {number} [settings.scanlineSpacing] - Pixels from one scan line to the next
12764
+ * @param {number} [settings.glow] - Soft glow strength
12765
+ * @param {number} [settings.vignette] - Vignette strength
12766
+ * @param {number} [settings.curve] - How much the screen bulges, 0 by default for flat
12767
+ * @return {string}
12374
12768
  * @example
12375
- * postProcessBloom(); // in gameInit, after any Render3DPlugin
12769
+ * new PostProcessPlugin(postProcessEffects(postProcessTV({scanlines: .4, curve: .1})));
12770
+ * @memberof PostProcess
12376
12771
  */
12377
- function postProcessBloom(threshold=.6, strength=1, size=6, includeMainCanvas=false)
12378
- { return new PostProcessPlugin(postProcessBloomShader(threshold, strength, size), includeMainCanvas); }
12772
+ function postProcessTV({noise=.1, scanlines=.5, scanlineSpacing=6, glow=.4, vignette=1, curve=0}={})
12773
+ {
12774
+ const parts = [];
12775
+ curve && parts.push(postProcessCurve(curve));
12776
+ glow && parts.push(postProcessGlow(0, glow, 2));
12777
+ scanlines && parts.push(postProcessScanlines(scanlines, scanlineSpacing));
12778
+ noise && parts.push(postProcessNoise(noise, 1));
12779
+ vignette && parts.push(postProcessVignette(vignette, 6));
12780
+ return parts.map((part)=> ' {\n' + part + '\n }').join('\n');
12781
+ }
12379
12782
 
12380
12783
  /**
12381
12784
  * LittleJS Light System Plugin
@@ -12399,6 +12802,8 @@ function postProcessBloom(threshold=.6, strength=1, size=6, includeMainCanvas=fa
12399
12802
  * shadow map through renderShadow(), which calls render() by default; obj.castShadow = false keeps it
12400
12803
  * out (a floor TileLayer, a background), a draw's alpha sets how much light it blocks, and
12401
12804
  * setShadowTransparent lets its color tint the light
12805
+ * - 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
12806
+ * lightmap, so it shows in the dark and sits in front of everything there
12402
12807
  * - Must be constructed BEFORE PostProcessPlugin so post-process sees lit pixels
12403
12808
  * @namespace LightSystem
12404
12809
  */
@@ -12533,6 +12938,7 @@ class LightSystemPlugin
12533
12938
  // again on a context restore, the canvas may have changed since
12534
12939
  if (lightSystem.textureSizeAuto)
12535
12940
  lightSystem.textureSize = mainCanvasSize.copy();
12941
+ lightSystem.clampTextureSizes();
12536
12942
 
12537
12943
  // allocate the lightmap texture with null data at textureSize
12538
12944
  lightSystem.texture = glContext.createTexture();
@@ -12724,6 +13130,7 @@ class LightSystemPlugin
12724
13130
  const ls = lightSystem;
12725
13131
 
12726
13132
  // make the resources the first time, and again when a size changed
13133
+ ls.clampTextureSizes();
12727
13134
  if (!ls.shadowMap || ls.shadowMapSize !== ls.shadowMapSizeAllocated
12728
13135
  || ls.shadowTextureSize !== ls.shadowTextureSizeAllocated)
12729
13136
  {
@@ -12798,13 +13205,13 @@ class LightSystemPlugin
12798
13205
  // an automatic size follows the canvas, so reallocate the lightmap when
12799
13206
  // the canvas changed size, after the flush so the batch keeps its texture
12800
13207
  const size = lightSystem.textureSize;
12801
- if (lightSystem.textureSizeAuto &&
12802
- (size.x !== mainCanvasSize.x || size.y !== mainCanvasSize.y))
13208
+ const wantX = glClampTextureSize(mainCanvasSize.x), wantY = glClampTextureSize(mainCanvasSize.y);
13209
+ if (lightSystem.textureSizeAuto && (size.x !== wantX || size.y !== wantY))
12803
13210
  {
12804
- lightSystem.textureSize = mainCanvasSize.copy();
13211
+ lightSystem.textureSize = vec2(wantX, wantY);
12805
13212
  glContext.bindTexture(glContext.TEXTURE_2D, lightSystem.texture);
12806
13213
  glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA,
12807
- mainCanvasSize.x, mainCanvasSize.y, 0,
13214
+ wantX, wantY, 0,
12808
13215
  glContext.RGBA, glContext.UNSIGNED_BYTE, null);
12809
13216
  // put back the texture the engine tracks, a draw in renderLight must not sample the lightmap
12810
13217
  if (glActiveTexture)
@@ -12911,8 +13318,14 @@ class LightSystemPlugin
12911
13318
  // is, and any debug text / future draw could sample the lightmap)
12912
13319
  if (glActiveTexture)
12913
13320
  glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
12914
- setAdditiveBlendMode(prevAdditive);
12915
13321
  glSetInstancedMode(true);
13322
+
13323
+ // 7. the lights' glows, added over the lit scene so the darkness does not dim them
13324
+ setAdditiveBlendMode();
13325
+ for (const o of engineObjects)
13326
+ o instanceof Light && !o.destroyed && o.renderGlow();
13327
+ glFlush();
13328
+ setAdditiveBlendMode(prevAdditive);
12916
13329
  }
12917
13330
  function lightSystemContextLost()
12918
13331
  {
@@ -12931,6 +13344,19 @@ class LightSystemPlugin
12931
13344
  }
12932
13345
  }
12933
13346
 
13347
+ /** Bring the sizes of the textures it makes down to what the device can make: shadowMapSize, shadowTextureSize
13348
+ * and a textureSize given by hand; called before they are made, so a size too big falls back instead of failing */
13349
+ clampTextureSizes()
13350
+ {
13351
+ this.shadowMapSize = glClampTextureSize(this.shadowMapSize);
13352
+ this.shadowTextureSize = glClampTextureSize(this.shadowTextureSize);
13353
+ const size = this.textureSize;
13354
+ if (!size) return;
13355
+ const x = glClampTextureSize(size.x), y = glClampTextureSize(size.y);
13356
+ if (x !== size.x || y !== size.y)
13357
+ this.textureSize = vec2(x, y); // only when it is too large, this runs every frame
13358
+ }
13359
+
12934
13360
  /** Draw a single Light's falloff blob into the currently bound lightmap.
12935
13361
  * Called by Light.renderLight() during the plugin's render pass.
12936
13362
  * @param {Light} light */
@@ -13080,6 +13506,14 @@ class Light extends EngineObject
13080
13506
  * or torch that holds it, or the player carrying it, does not block it; it has to reach past that object's
13081
13507
  * corners, about half its diagonal and a little more, or dark rays run out from them */
13082
13508
  this.shadowCore = 0;
13509
+ /** @property {number} - Size across of a soft hazy glow drawn over the light, like a lamp at night, 0 for
13510
+ * none; it is added over the lit scene, in front of everything there */
13511
+ this.glow = 0;
13512
+ /** @property {number} - How fast the glow fades from its middle: 1 by default, .5 a wide haze, 2 a tight
13513
+ * bright core */
13514
+ this.glowFalloff = 1;
13515
+ /** @type {TileInfo|undefined} */
13516
+ this.glowTileInfo = undefined; // the whole glow texture, kept for the falloff it was made for
13083
13517
  }
13084
13518
 
13085
13519
  /** Lights are invisible in the main render pass — they only contribute
@@ -13093,6 +13527,20 @@ class Light extends EngineObject
13093
13527
  {
13094
13528
  lightSystem && lightSystem.drawLight(this);
13095
13529
  }
13530
+
13531
+ /** Draw this light's glow, soft and round, its glow size across and in its color, added over the lit scene;
13532
+ * called by LightSystemPlugin after the lightmap is applied */
13533
+ renderGlow()
13534
+ {
13535
+ if (!(this.glow > 0)) return;
13536
+ const size = vec2(this.glow);
13537
+ if (!isOnScreen(this.pos, size)) return;
13538
+ const texture = engineGlowTexture(this.glowFalloff);
13539
+ if (!texture) return;
13540
+ if (this.glowTileInfo?.textureInfo !== texture)
13541
+ this.glowTileInfo = new TileInfo(vec2(), texture.size, texture);
13542
+ drawTile(this.pos, size, this.glowTileInfo, this.color, 0, false, undefined, true, false);
13543
+ }
13096
13544
  }
13097
13545
 
13098
13546
  /**
@@ -19724,9 +20172,10 @@ function setScene(scene)
19724
20172
 
19725
20173
  if (sceneCurrent)
19726
20174
  {
20175
+ // a leave that throws still lets the next switch through
19727
20176
  sceneLeaving = true;
19728
- sceneCurrent.leave?.();
19729
- sceneLeaving = false;
20177
+ try { sceneCurrent.leave?.(); }
20178
+ finally { sceneLeaving = false; }
19730
20179
  }
19731
20180
  engineObjectsDestroy();
19732
20181
  sceneCurrent = scene;
@@ -19743,6 +20192,179 @@ function sceneUpdate() { paused || !timeScale || sceneCurrent?.update?.(); }
19743
20192
  function sceneRender() { sceneCurrent?.render?.(); }
19744
20193
  function sceneRenderPost() { sceneCurrent?.renderPost?.(); }
19745
20194
 
20195
+ /**
20196
+ * LittleJS Parallax Plugin
20197
+ * - A background layer that follows the camera by a part of its movement, so it looks far away
20198
+ * - Repeats across the view, so a level of any width has a background
20199
+ * - Draws mountains by default, or anything a game draws into it
20200
+ * @namespace Parallax
20201
+ */
20202
+
20203
+ ///////////////////////////////////////////////////////////////////////////////
20204
+
20205
+ /**
20206
+ * A background layer with parallax: an image that follows the camera by a part of its movement and repeats across
20207
+ * the view, drawn once into a canvas by a function, mountains when none is given
20208
+ * - A far layer has a parallax near 1 and a low renderOrder, a near one a smaller parallax and a higher renderOrder
20209
+ * - The image should meet itself at its left and right edges to repeat without a seam, as the mountains do
20210
+ * @memberof Parallax
20211
+ * @extends CanvasLayer
20212
+ * @example
20213
+ * // three ranges of mountains, the far ones lighter and slower
20214
+ * for (let i = 3; i--;)
20215
+ * new ParallaxLayer(vec2(0, i*2), vec2(40, 20), .9 - i*.2, -1e3 + i,
20216
+ * parallaxMountains(hsl(.6, .3, .7 - i*.2), hsl(.6, .5, .2), i));
20217
+ *
20218
+ * // or draw your own image into the layer
20219
+ * new ParallaxLayer(vec2(), vec2(40, 20), .5, -1e3, (context, size)=>
20220
+ * {
20221
+ * context.fillStyle = '#fff';
20222
+ * context.fillRect(size.x/2 - 20, size.y/2 - 20, 40, 40);
20223
+ * });
20224
+ */
20225
+ class ParallaxLayer extends CanvasLayer
20226
+ {
20227
+ /** Create a parallax layer and draw its image
20228
+ * @param {Vector2} [pos] - Where the middle of the image is in the world when the camera is there too
20229
+ * @param {Vector2} [size] - World size of one copy of the image
20230
+ * @param {number|Vector2} [parallax] - How much of the camera's movement it follows, 0 stays with the world
20231
+ * and 1 with the screen, a Vector2 to follow x and y by different amounts
20232
+ * @param {number} [renderOrder] - Low to draw behind the game, far layers lowest
20233
+ * @param {function(OffscreenCanvasRenderingContext2D, Vector2, ParallaxLayer): void} [drawFunction] - Draws
20234
+ * the image, given the canvas context, its size in pixels and the layer; mountains when not given
20235
+ * @param {Vector2} [canvasSize] - Size of the image in pixels */
20236
+ constructor(pos=vec2(), size=vec2(32, 16), parallax=.5, renderOrder=-1e3, drawFunction=parallaxMountains(),
20237
+ canvasSize=vec2(512, 256))
20238
+ {
20239
+ false&&ASSERT(isNumber(parallax) || isVector2(parallax), 'parallax must be a number or a Vector2');
20240
+ false&&ASSERT(typeof drawFunction == 'function', 'drawFunction must be a function');
20241
+ super(pos, size, 0, renderOrder, canvasSize);
20242
+
20243
+ /** @property {Vector2} - How much of the camera's movement it follows on each axis, 0 stays with the world
20244
+ * and 1 with the screen */
20245
+ this.parallax = isVector2(parallax) ? /** @type {Vector2} */ (parallax).copy() :
20246
+ vec2(/** @type {number} */ (parallax));
20247
+ /** @property {boolean} - Repeat the image across the view, left and right */
20248
+ this.wrapX = true;
20249
+ /** @property {boolean} - Repeat the image up and down the view */
20250
+ this.wrapY = false;
20251
+ /** @property {number} - How much the camera's zoom changes its size on the screen: 1 like the world, 0 not
20252
+ * at all, the same on the screen at any zoom as it is at zoomScale */
20253
+ this.zoomFollow = 1;
20254
+ /** @property {number} - The camera scale its size is given for, used when zoomFollow is under 1 */
20255
+ this.zoomScale = cameraScale;
20256
+ /** @property {function(OffscreenCanvasRenderingContext2D, Vector2, ParallaxLayer): void} - Draws the
20257
+ * image, redraw() after changing it */
20258
+ this.drawFunction = drawFunction;
20259
+ this.redraw();
20260
+ }
20261
+
20262
+ /** Draw the image again with drawFunction, after changing it or what it draws from */
20263
+ redraw()
20264
+ {
20265
+ const context = this.context;
20266
+ if (!context) return; // headless
20267
+ const size = vec2(this.canvas.width, this.canvas.height);
20268
+ context.clearRect(0, 0, size.x, size.y);
20269
+ this.drawFunction(context, size, this);
20270
+ this.updateWebGL();
20271
+ }
20272
+
20273
+ // how much smaller it is drawn than its size, from the camera's zoom and how much of it the layer follows
20274
+ zoomFactor()
20275
+ { return this.zoomFollow < 1 && cameraScale > 0 ? (this.zoomScale / cameraScale) ** (1 - this.zoomFollow) : 1; }
20276
+
20277
+ /** World size one copy of the image is drawn at now, its size unless zoomFollow is under 1
20278
+ * @return {Vector2} */
20279
+ getDrawSize() { return this.size.scale(this.zoomFactor()); }
20280
+
20281
+ /** Where each copy of the image is drawn now, in the world: one place, or with wrapping every place that
20282
+ * shows in the view
20283
+ * @return {Array<Vector2>} */
20284
+ getDrawPositions()
20285
+ {
20286
+ // its place seen from the camera is cut down by the parallax, and by the zoom it does not follow
20287
+ const zoom = this.zoomFactor(), size = this.size.scale(zoom), p = this.parallax;
20288
+ const offset = this.pos.subtract(cameraPos);
20289
+ const center = cameraPos.add(vec2(offset.x * (1 - p.x) * zoom, offset.y * (1 - p.y) * zoom));
20290
+
20291
+ // the copies that reach into the view; a turned camera sees as far as its corner each way
20292
+ const view = mainCanvasSize.scale(.5 / (cameraScale || 1));
20293
+ const reach = cameraAngle ? vec2(view.length()) : view;
20294
+ const range = (wrap, axis)=>
20295
+ {
20296
+ if (!wrap || !(size[axis] > 0)) return [0, 0];
20297
+ const from = cameraPos[axis] - reach[axis] - size[axis] / 2 - center[axis];
20298
+ const to = cameraPos[axis] + reach[axis] + size[axis] / 2 - center[axis];
20299
+ const first = ceil(from / size[axis]);
20300
+ return [first, min(floor(to / size[axis]), first + 99)]; // a tiny image does not draw without end
20301
+ };
20302
+ const [x0, x1] = range(this.wrapX, 'x'), [y0, y1] = range(this.wrapY, 'y'), positions = [];
20303
+ for (let y = y0; y <= y1; ++y)
20304
+ for (let x = x0; x <= x1; ++x)
20305
+ positions.push(vec2(center.x + x * size.x, center.y + y * size.y));
20306
+ return positions;
20307
+ }
20308
+
20309
+ // Render the layer, called automatically by the engine
20310
+ render()
20311
+ {
20312
+ const size = this.getDrawSize();
20313
+ for (const pos of this.getDrawPositions())
20314
+ this.draw(pos, size, this.color, 0, this.mirror, this.additiveColor, false);
20315
+ }
20316
+ }
20317
+
20318
+ ///////////////////////////////////////////////////////////////////////////////
20319
+
20320
+ // the ridge of a range of mountains: a height for each column of an image and one more, the last the same as the
20321
+ // first so the image repeats with no step; it wanders about the middle, turning now and then
20322
+ function parallaxRidge(width, height, random)
20323
+ {
20324
+ const pointiness = .2, levelness = .005, slopeRange = height / 256; // how often it turns, how hard it is
20325
+ const ridge = new Float32Array(width + 1), middle = height / 2; // pulled level, how steep it gets
20326
+ let y = middle, slope = random.float(-slopeRange, slopeRange);
20327
+ for (let x = 0; x <= width; ++x)
20328
+ {
20329
+ ridge[x] = y;
20330
+ y += slope -= (y - middle) * levelness;
20331
+ if (random.float() < pointiness)
20332
+ slope = random.float(-slopeRange, slopeRange);
20333
+ }
20334
+ // take out the difference between its ends, a little from each column, and keep it inside the image
20335
+ const drift = ridge[width] - ridge[0];
20336
+ for (let x = 0; x <= width; ++x)
20337
+ ridge[x] = clamp(ridge[x] - drift * x / width, 0, height);
20338
+ return ridge;
20339
+ }
20340
+
20341
+ /** A draw function for a ParallaxLayer: a range of mountains across the image, shaded from the color of their
20342
+ * peaks down to the color of their feet, that repeats with no seam
20343
+ * @param {Color} [topColor] - Color at the peaks
20344
+ * @param {Color} [bottomColor] - Color at the bottom of the image
20345
+ * @param {number} [seed] - The same seed gives the same mountains, random when not given
20346
+ * @return {function(OffscreenCanvasRenderingContext2D, Vector2): void}
20347
+ * @memberof Parallax */
20348
+ function parallaxMountains(topColor=hsl(.6, .2, .6), bottomColor=hsl(.6, .3, .25), seed)
20349
+ {
20350
+ return (context, size)=>
20351
+ {
20352
+ const w = size.x, h = size.y;
20353
+ const random = new RandomGenerator(seed === undefined ? randInt(1e9) + 1 : seed * 7919 + 1);
20354
+ const ridge = parallaxRidge(w, h, random);
20355
+
20356
+ // a strip one pixel wide with the shading, stretched down each column from its peak
20357
+ const strip = createCanvasContext(1, h);
20358
+ for (let i = h; i--;)
20359
+ {
20360
+ strip.fillStyle = topColor.lerp(bottomColor, i / h).toString();
20361
+ strip.fillRect(0, i, 1, 1);
20362
+ }
20363
+ for (let x = w; x--;)
20364
+ ridge[x] < h && context.drawImage(strip.canvas, 0, 0, 1, h, x, ridge[x], 1, h - ridge[x]);
20365
+ };
20366
+ }
20367
+
19746
20368
  /**
19747
20369
  * LittleJS PathFinder Plugin
19748
20370
  * - Grid-based A* pathfinder with two-pass smoothing for natural-looking paths
@@ -20902,15 +21524,7 @@ class Matrix4
20902
21524
  static rotation(euler, matrix=new Matrix4)
20903
21525
  {
20904
21526
  false&&ASSERT_VECTOR3_VALID(euler);
20905
- const cx = cos(euler.x), sx = sin(euler.x);
20906
- const cy = cos(euler.y), sy = sin(euler.y);
20907
- const cz = cos(euler.z), sz = sin(euler.z);
20908
- const m = matrix.m;
20909
- // R = Ry * Rx * Rz written out, column major, every element set so a reused matrix comes out clean
20910
- m[0] = cy*cz + sy*sx*sz; m[1] = cx*sz; m[2] = -sy*cz + cy*sx*sz; m[3] = 0;
20911
- m[4] = -cy*sz + sy*sx*cz; m[5] = cx*cz; m[6] = sy*sz + cy*sx*cz; m[7] = 0;
20912
- m[8] = sy*cx; m[9] = -sx; m[10] = cy*cx; m[11] = 0;
20913
- m[12] = m[13] = m[14] = 0; m[15] = 1;
21527
+ matrix4RotationAt(matrix.m, 0, euler);
20914
21528
  return matrix;
20915
21529
  }
20916
21530
 
@@ -21158,24 +21772,54 @@ class Matrix4
21158
21772
  * @memberof Math3D
21159
21773
  */
21160
21774
  function buildMatrix(pos, rotation, scale, matrix=new Matrix4)
21775
+ {
21776
+ false&&ASSERT(matrix instanceof Matrix4, 'the matrix to write into must be a Matrix4');
21777
+ matrix4Compose(matrix.m, 0, pos, rotation, scale);
21778
+ return matrix;
21779
+ }
21780
+
21781
+ // the rotation matrix of euler angles written into 16 floats of an array at k
21782
+ function matrix4RotationAt(m, k, euler)
21783
+ {
21784
+ const cx = cos(euler.x), sx = sin(euler.x);
21785
+ const cy = cos(euler.y), sy = sin(euler.y);
21786
+ const cz = cos(euler.z), sz = sin(euler.z);
21787
+ // R = Ry * Rx * Rz written out, column major, every element set so a reused matrix comes out clean
21788
+ m[k] = cy*cz + sy*sx*sz; m[k+1] = cx*sz; m[k+2] = -sy*cz + cy*sx*sz; m[k+3] = 0;
21789
+ 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;
21790
+ m[k+8] = sy*cx; m[k+9] = -sx; m[k+10] = cy*cx; m[k+11] = 0;
21791
+ m[k+12] = m[k+13] = m[k+14] = 0; m[k+15] = 1;
21792
+ }
21793
+
21794
+ // the transform buildMatrix makes, written into 16 floats of an array at k, so an instance buffer takes it straight in
21795
+ // the rotation columns are scaled and the position dropped in, instead of multiplying three matrices, each value
21796
+ // worked out whole and stored once; InstancedMesh3D.setTransforms writes the same expressions for many at a time
21797
+ function matrix4Compose(m, k, pos, rotation, scale)
21161
21798
  {
21162
21799
  false&&ASSERT(!pos || isVector3(pos), 'pos must be a Vector3', pos);
21163
21800
  false&&ASSERT(!scale || isVector3(scale), 'scale must be a Vector3', scale);
21164
- false&&ASSERT(matrix instanceof Matrix4, 'the matrix to write into must be a Matrix4');
21165
- // scale the rotation columns and drop the position in, instead of multiplying three matrices
21801
+ const scaleX = scale ? scale.x : 1, scaleY = scale ? scale.y : 1, scaleZ = scale ? scale.z : 1;
21166
21802
  // an object that is not turned at all is most of a big scene, and identity is what the six
21167
21803
  // trig calls would have worked out to anyway
21168
- const turned = rotation && (rotation.x || rotation.y || rotation.z), m = matrix.m;
21169
- turned ? Matrix4.rotation(rotation, matrix) : m.set(matrix4Identity);
21170
- if (scale)
21804
+ if (rotation && (rotation.x || rotation.y || rotation.z))
21171
21805
  {
21172
- m[0] *= scale.x; m[1] *= scale.x; m[2] *= scale.x;
21173
- m[4] *= scale.y; m[5] *= scale.y; m[6] *= scale.y;
21174
- m[8] *= scale.z; m[9] *= scale.z; m[10] *= scale.z;
21806
+ false&&ASSERT_VECTOR3_VALID(rotation);
21807
+ const cx = cos(rotation.x), sx = sin(rotation.x);
21808
+ const cy = cos(rotation.y), sy = sin(rotation.y);
21809
+ const cz = cos(rotation.z), sz = sin(rotation.z);
21810
+ // R = Ry * Rx * Rz written out, column major, as matrix4RotationAt, each column times its scale
21811
+ m[k] = (cy*cz + sy*sx*sz) * scaleX; m[k+1] = cx*sz * scaleX; m[k+2] = (-sy*cz + cy*sx*sz) * scaleX;
21812
+ m[k+4] = (-cy*sz + sy*sx*cz) * scaleY; m[k+5] = cx*cz * scaleY; m[k+6] = (sy*sz + cy*sx*cz) * scaleY;
21813
+ m[k+8] = sy*cx * scaleZ; m[k+9] = -sx * scaleZ; m[k+10] = cy*cx * scaleZ;
21175
21814
  }
21176
- if (pos)
21177
- m[12] = pos.x, m[13] = pos.y, m[14] = pos.z;
21178
- return matrix;
21815
+ else
21816
+ {
21817
+ m[k] = scaleX; m[k+1] = 0; m[k+2] = 0;
21818
+ m[k+4] = 0; m[k+5] = scaleY; m[k+6] = 0;
21819
+ m[k+8] = 0; m[k+9] = 0; m[k+10] = scaleZ;
21820
+ }
21821
+ m[k+3] = m[k+7] = m[k+11] = 0; m[k+15] = 1;
21822
+ m[k+12] = pos ? pos.x : 0, m[k+13] = pos ? pos.y : 0, m[k+14] = pos ? pos.z : 0;
21179
21823
  }
21180
21824
 
21181
21825
  ///////////////////////////////////////////////////////////////////////////////
@@ -21583,6 +22227,9 @@ const RENDER3D_VERTEX_BYTES = RENDER3D_VERTEX_FLOATS * 4;
21583
22227
  // uv rect (12); constants for one draw, one per instance for a batch; the shader derives the normal matrix
21584
22228
  const RENDER3D_INSTANCE_FLOATS = 24;
21585
22229
  const RENDER3D_INSTANCE_BYTES = RENDER3D_INSTANCE_FLOATS * 4;
22230
+ // an InstancedMesh3D keeps them apart: 16 floats of matrix, and 8 of tint and uv rect uploaded only when they change
22231
+ const RENDER3D_MATRIX_FLOATS = 16;
22232
+ const RENDER3D_COLOR_FLOATS = 8;
21586
22233
  const RENDER3D_INSTANCE_ATTRIBS = [[4, 4, 0], [5, 4, 16], [6, 4, 32], [7, 4, 48], [11, 4, 64], [12, 4, 80]];
21587
22234
  const RENDER3D_VERTEX_INPUTS =
21588
22235
  'layout(location=0) in vec3 p;layout(location=1) in vec3 n;layout(location=2) in vec2 t;layout(location=3) in vec4 c;' +
@@ -21626,22 +22273,49 @@ function render3DCanDraw()
21626
22273
  }
21627
22274
 
21628
22275
  // the draw state fields a batch is drawn under; lights and fog are not captured, they are read live at flush
21629
- // the three functions below write them out by hand for speed, so a new field goes in all four; emissiveMapColor is
21630
- // compared by its values, so two objects with equal colors batch
22276
+ // each is an accessor on the plugin over render3D.drawState, and a real change moves render3D.stateVersion, so a
22277
+ // batch checks that one number at each draw while the state holds; the three functions below write the fields out
22278
+ // by hand for speed, so a new field goes in all four, in drawState and gets an accessor; emissiveMapColor is
22279
+ // compared by its rgb, so two objects with equal colors batch
22280
+ /** The draw state's values, behind render3D's accessors, and the rgb emissiveMapColor had when it was set
22281
+ * @typedef {Object} Render3DDrawState
22282
+ * @property {boolean} blend
22283
+ * @property {boolean} additive
22284
+ * @property {boolean} depthTest
22285
+ * @property {boolean} depthWrite
22286
+ * @property {boolean} cullBackFaces
22287
+ * @property {boolean} mirrored
22288
+ * @property {boolean} lighting
22289
+ * @property {number} emissive
22290
+ * @property {boolean} receiveShadow
22291
+ * @property {number} specular
22292
+ * @property {boolean} pixelated
22293
+ * @property {Shader|undefined} shader
22294
+ * @property {TextureInfo|undefined} normalMap
22295
+ * @property {number} normalScale
22296
+ * @property {number} shininess
22297
+ * @property {number} reflectivity
22298
+ * @property {TextureInfo|undefined} emissiveMap
22299
+ * @property {Color} emissiveMapColor
22300
+ * @property {number} emissiveR
22301
+ * @property {number} emissiveG
22302
+ * @property {number} emissiveB
22303
+ * @memberof Render3D */
21631
22304
  const RENDER3D_STATE_FIELDS = ['blend', 'additive', 'depthTest', 'depthWrite', 'cullBackFaces', 'mirrored', 'lighting',
21632
22305
  'emissive', 'receiveShadow', 'specular', 'pixelated', 'shader', 'normalMap', 'normalScale', 'shininess',
21633
22306
  'reflectivity', 'emissiveMap', 'emissiveMapColor'];
21634
22307
 
21635
- // a copy of the draw state in one fixed shape, the fields of RENDER3D_STATE_FIELDS written out so the
21636
- // compare below stays a handful of direct reads, it runs for every instance drawn
22308
+ // a copy of the draw state in one fixed shape, the fields of RENDER3D_STATE_FIELDS written out, and the version
22309
+ // it was taken at
21637
22310
  function render3DCaptureBatchState()
21638
22311
  {
21639
- const r = render3D;
21640
- return {blend: r.blend, additive: r.additive, depthTest: r.depthTest, depthWrite: r.depthWrite,
21641
- cullBackFaces: r.cullBackFaces, mirrored: r.mirrored, lighting: r.lighting, emissive: r.emissive,
21642
- receiveShadow: r.receiveShadow, specular: r.specular, pixelated: r.pixelated, shader: r.shader,
21643
- normalMap: r.normalMap, normalScale: r.normalScale, shininess: r.shininess, reflectivity: r.reflectivity,
21644
- emissiveMap: r.emissiveMap, emissiveMapColor: (r.emissiveMapColor || WHITE).copy()}; // the caller may change it
22312
+ const r = render3D, d = r.drawState;
22313
+ return {blend: d.blend, additive: d.additive, depthTest: d.depthTest, depthWrite: d.depthWrite,
22314
+ cullBackFaces: d.cullBackFaces, mirrored: d.mirrored, lighting: d.lighting, emissive: d.emissive,
22315
+ receiveShadow: d.receiveShadow, specular: d.specular, pixelated: d.pixelated, shader: d.shader,
22316
+ normalMap: d.normalMap, normalScale: d.normalScale, shininess: d.shininess, reflectivity: d.reflectivity,
22317
+ emissiveMap: d.emissiveMap, emissiveMapColor: (d.emissiveMapColor || WHITE).copy(), // the caller may change it
22318
+ version: r.stateVersion};
21645
22319
  }
21646
22320
 
21647
22321
  // put a captured draw state back, written out the same way; the transparent stage does this for every queued draw
@@ -21655,21 +22329,23 @@ function render3DApplyBatchState(s)
21655
22329
  r.reflectivity = s.reflectivity, r.emissiveMap = s.emissiveMap, r.emissiveMapColor = s.emissiveMapColor;
21656
22330
  }
21657
22331
 
21658
- // true when the current draw state differs from a captured one, so a pending batch must flush first
22332
+ // true when the current draw state differs from a captured one, so a pending batch must flush first; it runs for
22333
+ // every instance drawn, so while nothing was set since the capture it checks the version alone
21659
22334
  function render3DStateChanged(s)
21660
22335
  {
21661
22336
  const r = render3D;
21662
- return r.blend !== s.blend || r.additive !== s.additive || r.depthTest !== s.depthTest
21663
- || r.depthWrite !== s.depthWrite || r.cullBackFaces !== s.cullBackFaces || r.mirrored !== s.mirrored
21664
- || r.lighting !== s.lighting || r.emissive !== s.emissive || r.receiveShadow !== s.receiveShadow
21665
- || r.specular !== s.specular || r.pixelated !== s.pixelated || r.shader !== s.shader
21666
- || r.normalMap !== s.normalMap || r.normalScale !== s.normalScale || r.shininess !== s.shininess
21667
- || r.reflectivity !== s.reflectivity || r.emissiveMap !== s.emissiveMap
21668
- || render3DColorChanged(r.emissiveMapColor || WHITE, s.emissiveMapColor);
21669
- }
21670
-
21671
- // whether two colors differ in their rgb, for the batch key's emissiveMapColor
21672
- function render3DColorChanged(a, b) { return a.r !== b.r || a.g !== b.g || a.b !== b.b;
22337
+ if (s.version === r.stateVersion) return false;
22338
+ const d = r.drawState, c = s.emissiveMapColor;
22339
+ if (d.blend !== s.blend || d.additive !== s.additive || d.depthTest !== s.depthTest
22340
+ || d.depthWrite !== s.depthWrite || d.cullBackFaces !== s.cullBackFaces || d.mirrored !== s.mirrored
22341
+ || d.lighting !== s.lighting || d.emissive !== s.emissive || d.receiveShadow !== s.receiveShadow
22342
+ || d.specular !== s.specular || d.pixelated !== s.pixelated || d.shader !== s.shader
22343
+ || d.normalMap !== s.normalMap || d.normalScale !== s.normalScale || d.shininess !== s.shininess
22344
+ || d.reflectivity !== s.reflectivity || d.emissiveMap !== s.emissiveMap
22345
+ || d.emissiveR !== c.r || d.emissiveG !== c.g || d.emissiveB !== c.b)
22346
+ return true;
22347
+ s.version = r.stateVersion; // set and set back to what it was: the batch goes on, checking the version again
22348
+ return false;
21673
22349
  }
21674
22350
 
21675
22351
  // whether a sphere is inside the view, or the shadow map's box during the shadow pass, without a vector
@@ -21747,7 +22423,10 @@ function render3DMaxScale(m)
21747
22423
  // how far a matrix at offset k can move a point that is one unit from its origin, for a bounding sphere: the
21748
22424
  // longest axis when the axes are square to each other, more when they are not, as a turned child under a parent
21749
22425
  // scaled on one axis leaves them; the axes' dot products bound the largest stretch by their largest row sum
21750
- function render3DMaxStretch(m, k=0)
22426
+ function render3DMaxStretch(m, k=0) { return render3DMaxStretchSquared(m, k) ** .5; }
22427
+
22428
+ // the same, squared, for bounds that grow many times before they are read
22429
+ function render3DMaxStretchSquared(m, k=0)
21751
22430
  {
21752
22431
  const xx = m[k]*m[k] + m[k+1]*m[k+1] + m[k+2]*m[k+2];
21753
22432
  const yy = m[k+4]*m[k+4] + m[k+5]*m[k+5] + m[k+6]*m[k+6];
@@ -21755,7 +22434,7 @@ function render3DMaxStretch(m, k=0)
21755
22434
  const xy = abs(m[k]*m[k+4] + m[k+1]*m[k+5] + m[k+2]*m[k+6]);
21756
22435
  const xz = abs(m[k]*m[k+8] + m[k+1]*m[k+9] + m[k+2]*m[k+10]);
21757
22436
  const yz = abs(m[k+4]*m[k+8] + m[k+5]*m[k+9] + m[k+6]*m[k+10]);
21758
- return max(xx + xy + xz, yy + xy + yz, zz + xz + yz) ** .5;
22437
+ return max(xx + xy + xz, yy + xy + yz, zz + xz + yz);
21759
22438
  }
21760
22439
 
21761
22440
  // a quad as a strip from its center and half axes, the same corner order as render3DQuadStrip
@@ -21805,10 +22484,11 @@ function render3DDrawObjects(objects)
21805
22484
  render3DSetObjectState();
21806
22485
  }
21807
22486
 
21808
- // add a draw of a mesh to its batch; a batch is one mesh under one texture and draw state, so a change flushes it
22487
+ // 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,
22488
+ // and under one winding: a mirroring transform, one with a negative determinant, turns it around
21809
22489
  function render3DInstance(mesh, matrix, tileInfo, color)
21810
22490
  {
21811
- const k = render3DInstanceSlot(mesh, render3DTextureOf(tileInfo)), data = mesh.instanceData;
22491
+ const k = render3DInstanceSlot(mesh, render3DTextureOf(tileInfo), render3DMirrors(matrix)), data = mesh.instanceData;
21812
22492
  data.set(matrix.m, k);
21813
22493
  // the tint; this batch is never blended, so in the shadow pass the depth shader cuts it by its texture alpha
21814
22494
  // alone, a see through caster draws blended instead and is cut by its tint alpha too
@@ -21819,14 +22499,18 @@ function render3DInstance(mesh, matrix, tileInfo, color)
21819
22499
 
21820
22500
  // make room for one more instance of a mesh under a texture and the current draw state, flushing a batch that
21821
22501
  // differs first, and return where its 24 floats go in mesh.instanceData: the matrix, the tint and the uv rect
21822
- function render3DInstanceSlot(mesh, textureInfo)
22502
+ // drawMesh passes whether its matrix mirrors, and its batch then culls by the mesh and winds by that at the flush,
22503
+ // so it never sets the draw state for them, which would move the state's version at every draw
22504
+ function render3DInstanceSlot(mesh, textureInfo, mirrored)
21823
22505
  {
21824
22506
  const r = render3D;
21825
- if (mesh.instanceCount && (mesh.instanceTextureInfo !== textureInfo || render3DStateChanged(mesh.instanceState)))
22507
+ if (mesh.instanceCount && (mesh.instanceTextureInfo !== textureInfo || mesh.instanceMirrored !== mirrored
22508
+ || render3DStateChanged(mesh.instanceState)))
21826
22509
  render3DFlushInstances(mesh);
21827
22510
  if (!mesh.instanceCount)
21828
22511
  {
21829
22512
  mesh.instanceTextureInfo = textureInfo;
22513
+ mesh.instanceMirrored = mirrored;
21830
22514
  mesh.instanceState = render3DCaptureBatchState();
21831
22515
  r.instanceMeshes.push(mesh);
21832
22516
  }
@@ -21857,7 +22541,10 @@ function render3DFlushInstances(only)
21857
22541
  const buffers = r.instanceBuffers, buffer = buffers[r.instanceBufferIndex = (r.instanceBufferIndex + 1) % buffers.length];
21858
22542
  gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
21859
22543
  gl.bufferData(gl.ARRAY_BUFFER, mesh.instanceData, gl.DYNAMIC_DRAW, 0, count * RENDER3D_INSTANCE_FLOATS);
21860
- render3DDrawInstanced(mesh, buffer, count, mesh.instanceTextureInfo, mesh.instanceState);
22544
+ const state = mesh.instanceState;
22545
+ if (mesh.instanceMirrored !== undefined) // a drawMesh batch: culled by its mesh, wound by its matrices
22546
+ state.cullBackFaces = !mesh.doubleSided, state.mirrored = mesh.instanceMirrored;
22547
+ render3DDrawInstanced(mesh, buffer, count, mesh.instanceTextureInfo, state);
21861
22548
  }
21862
22549
  if (!only)
21863
22550
  r.instanceMeshes.length = 0;
@@ -21872,13 +22559,16 @@ function render3DFlushInstances(only)
21872
22559
  // the arrays are turned on with their instance divisor for this one call and both are turned off after: a single
21873
22560
  // draw reads these slots as constant attributes, and in Firefox a draw that reads a constant through a slot whose
21874
22561
  // divisor is set leaves the next batch on that slot reading the wrong values
21875
- function render3DDrawInstanced(mesh, buffer, count, textureInfo, state)
22562
+ // colorBuffer holds the tint and uv rect apart from the matrices, as an InstancedMesh3D keeps them
22563
+ function render3DDrawInstanced(mesh, buffer, count, textureInfo, state, colorBuffer)
21876
22564
  {
21877
- const gl = glContext;
21878
- gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
22565
+ const gl = glContext, matrixBytes = RENDER3D_MATRIX_FLOATS * 4;
21879
22566
  for (const [location, size, offset] of RENDER3D_INSTANCE_ATTRIBS)
21880
22567
  {
21881
- gl.vertexAttribPointer(location, size, gl.FLOAT, false, RENDER3D_INSTANCE_BYTES, offset);
22568
+ const inColors = colorBuffer && offset >= matrixBytes;
22569
+ const stride = !colorBuffer ? RENDER3D_INSTANCE_BYTES : inColors ? RENDER3D_COLOR_FLOATS * 4 : matrixBytes;
22570
+ gl.bindBuffer(gl.ARRAY_BUFFER, inColors ? colorBuffer : buffer);
22571
+ gl.vertexAttribPointer(location, size, gl.FLOAT, false, stride, inColors ? offset - matrixBytes : offset);
21882
22572
  gl.enableVertexAttribArray(location);
21883
22573
  gl.vertexAttribDivisor(location, 1);
21884
22574
  }
@@ -21923,11 +22613,41 @@ function render3DCollectLights()
21923
22613
  for (const light of lights)
21924
22614
  distances.set(light, light.directional ? -1 : light.getWorldPos3D().distanceSquared(cameraPos));
21925
22615
  lights.sort((a, b)=> distances.get(a) - distances.get(b));
22616
+ // the light that casts the shadows keeps a slot however far it is, in place of the farthest
22617
+ const caster = render3DShadowCaster();
22618
+ if (caster && lights.indexOf(caster) >= RENDER3D_MAX_LIGHTS)
22619
+ lights[RENDER3D_MAX_LIGHTS - 1] = caster;
21926
22620
  lights.length = RENDER3D_MAX_LIGHTS;
21927
22621
  }
21928
22622
  return lights;
21929
22623
  }
21930
22624
 
22625
+ // the spotlight that casts the shadows in place of the sun: render3D.shadowLight while it is a light that is on
22626
+ // and has a cone, undefined for the sun
22627
+ function render3DShadowCaster()
22628
+ {
22629
+ const light = render3D.shadowLight;
22630
+ return light && !light.destroyed && !light.directional && light.coneAngle > 0 && light.radius > 0 &&
22631
+ light.intensity > 0 && light.color.a > 0 ? light : undefined;
22632
+ }
22633
+
22634
+ // the widest cone a shadow map can look down, to each side; the light that casts the shadows lights no wider, or
22635
+ // what is past the map's edge would be lit with nothing to shade it
22636
+ const RENDER3D_SHADOW_CONE_MAX = 1.35;
22637
+
22638
+ // a light's cone as the four numbers the shader gets: the way it shines, scaled so that its dot with the way to
22639
+ // 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
22640
+ // gets numbers that make it 1 every way
22641
+ function render3DLightCone(light)
22642
+ {
22643
+ const widest = light === render3DShadowCaster() ? RENDER3D_SHADOW_CONE_MAX : PI;
22644
+ const angle = light.directional ? 0 : min(light.coneAngle, widest);
22645
+ if (!(angle > 0)) return [0, 0, 0, -1];
22646
+ const outer = cos(angle), inner = cos(angle * (1 - clamp(light.coneSoftness)));
22647
+ const k = 1 / max(inner - outer, 1e-4), forward = light.getForward3D();
22648
+ return [forward.x * k, forward.y * k, forward.z * k, outer * k];
22649
+ }
22650
+
21931
22651
  // unit circle directions for a number of sides, [cos, sin, cos, sin, ...] including the closing point, cached
21932
22652
  const render3DCircleCache = new Map;
21933
22653
  function render3DCircle(sides)
@@ -21979,7 +22699,15 @@ class Render3DPlugin
21979
22699
  {
21980
22700
  false&&ASSERT(!render3D, 'Render3D plugin already initialized');
21981
22701
  render3D = this;
21982
- false&&ASSERT(Object.keys(render3DCaptureBatchState()).join() === RENDER3D_STATE_FIELDS.join(),
22702
+ // the draw state, read at each draw through the accessors below the constructor
22703
+ /** @type {Render3DDrawState} */
22704
+ this.drawState = {blend: false, additive: false, depthTest: true, depthWrite: true, cullBackFaces: false,
22705
+ mirrored: false, lighting: true, emissive: 0, receiveShadow: true, specular: 0, pixelated: false,
22706
+ shader: undefined, normalMap: undefined, normalScale: 1, shininess: 16, reflectivity: 0,
22707
+ emissiveMap: undefined, emissiveMapColor: WHITE, emissiveR: 1, emissiveG: 1, emissiveB: 1};
22708
+ /** @property {number} - Goes up when a draw state field changes, so a batch sees at a glance that none did */
22709
+ this.stateVersion = 0;
22710
+ false&&ASSERT(Object.keys(render3DCaptureBatchState()).join() === [...RENDER3D_STATE_FIELDS, 'version'].join(),
21983
22711
  'the batch state functions must list RENDER3D_STATE_FIELDS');
21984
22712
 
21985
22713
  /** @property {Camera3D} - The camera */
@@ -22025,8 +22753,16 @@ class Render3DPlugin
22025
22753
  // shadows
22026
22754
  /** @property {boolean} - Cast real shadows from the sun, off by default and free when off */
22027
22755
  this.shadows = false;
22756
+ /** @property {number} - How far a sound played with playSound is heard when it was made with no range of
22757
+ * its own, in world units; further than the 2D soundDefaultRange, a 3D world is bigger */
22758
+ this.soundDefaultRange = 100;
22028
22759
  /** @property {number} - Size of the shadow map in pixels, bigger is sharper and slower */
22029
22760
  this.shadowMapSize = 1024;
22761
+ /** @property {Light3D|undefined} - A spotlight, a Light3D with a coneAngle, to cast the shadows in place
22762
+ * of the sun, a flashlight in the dark: the shadow map looks down its cone, as far as its radius, and the
22763
+ * sun still lights the scene but casts none; undefined for the sun, as is a light that is off or has no cone
22764
+ * @type {Light3D|undefined} */
22765
+ this.shadowLight = undefined;
22030
22766
  /** @property {number} - World size the shadow map covers around shadowCenter, smaller is sharper; it is a
22031
22767
  * square facing the light, so it turns as the light does, and about 1.5 times an area's width covers it from
22032
22768
  * any angle */
@@ -22039,50 +22775,6 @@ class Render3DPlugin
22039
22775
  /** @property {number} - How much to blur the shadow edges */
22040
22776
  this.shadowSoftness = 1;
22041
22777
 
22042
- // draw state, read at each draw
22043
- /** @property {boolean} - Apply lighting, when false draws plain vertex color times texture and casts no shadow;
22044
- * off for billboards, lines, ribbons and soft discs, an object sets emissive instead */
22045
- this.lighting = true;
22046
- /** @property {number} - How much a surface lights itself, set per object by its emissive */
22047
- this.emissive = 0;
22048
- /** @property {boolean} - Additive blending instead of alpha, in the transparent stage */
22049
- this.additive = false;
22050
- /** @property {boolean} - Test against the depth buffer, reset to true before each object and callback; a draw
22051
- * with it off goes over what was drawn before it and under what is drawn after, by render order, which
22052
- * ends the batch of meshes before it, so one per object costs a draw per object */
22053
- this.depthTest = true;
22054
- /** @property {boolean} - Write to the depth buffer, owned by the stages: on for opaque, off for transparent */
22055
- this.depthWrite = true;
22056
- // batch state set by drawMesh from each mesh: whether its back faces are skipped, off for strips so they
22057
- // show from both sides, and whether its transform mirrors it so the other winding is the front
22058
- this.cullBackFaces = false;
22059
- this.mirrored = false;
22060
- /** @property {number} - Strength of the highlight where the sun and the Light3D objects reflect, 0 is none and
22061
- * 1 adds a light's full color at its brightest; shininess sets its size */
22062
- this.specular = 0;
22063
- /** @property {number} - The highlight's exponent, how small and sharp it is: 4 is broad like rubber, 100 sharp
22064
- * like polished metal; set from each object's shininess */
22065
- this.shininess = 16;
22066
- /** @property {TextureInfo|undefined} - Normal map for the next draws, set from each object's normalMap
22067
- * @type {TextureInfo|undefined} */
22068
- this.normalMap = undefined;
22069
- /** @property {number} - How strongly the normal map bends the surface, set from each object's normalScale */
22070
- this.normalScale = 1;
22071
- /** @property {number} - How much the surface reflects the sky, 0 to 1, set from each object's reflectivity */
22072
- this.reflectivity = 0;
22073
- /** @property {TextureInfo|undefined} - Emissive map for the next draws, set from each object's emissiveMap
22074
- * @type {TextureInfo|undefined} */
22075
- this.emissiveMap = undefined;
22076
- /** @property {Color} - Multiplies the emissive map, set from each object's emissiveMapColor */
22077
- this.emissiveMapColor = WHITE;
22078
- /** @property {Shader|undefined} - Custom Shader for the next draws, set from each object's shader; undefined
22079
- * draws with the plugin's own
22080
- * @type {Shader|undefined} */
22081
- this.shader = undefined;
22082
- /** @property {boolean} - Darken by the shadow map when shadows are on, turn it off for things that should stay
22083
- * lit inside a shadow */
22084
- this.receiveShadow = true;
22085
-
22086
22778
  // the pass
22087
22779
  /** @property {Function|undefined} - Draw solid world here, it runs again for shadows so only draw in it
22088
22780
  * @type {Function|undefined} */
@@ -22105,9 +22797,6 @@ class Render3DPlugin
22105
22797
  /** @property {boolean} - Sample textures through mipmaps so they do not shimmer in the distance, false uses
22106
22798
  * each texture's own filtering like 2D */
22107
22799
  this.mipmaps = true;
22108
- /** @property {boolean} - Draw state: keep texture pixels hard edged, no mipmaps and no blending between them,
22109
- * set per object by pixelated */
22110
- this.pixelated = false;
22111
22800
  /** @property {number} - Anisotropic filtering for textures seen at an angle, 1 to 16, 1 is off; needs mipmaps */
22112
22801
  this.anisotropy = 4;
22113
22802
 
@@ -22151,7 +22840,6 @@ class Render3DPlugin
22151
22840
  this.cameraBack = vec3(0, 0, 1); // its opposite, the normal of camera facing draws
22152
22841
 
22153
22842
  // internal state
22154
- this.blend = false; // blending on, set by the stages
22155
22843
  /** @type {Array<Array<number>>} */
22156
22844
  this.frustumPlanes = []; // the view as six inward planes [x, y, z, w]
22157
22845
  /** @type {Array<Array<number>>} */
@@ -22191,6 +22879,9 @@ class Render3DPlugin
22191
22879
  this.passIsDefault = true; // the running pass is the default layer, the only one shadowed
22192
22880
  this.lightPositions = new Float32Array(RENDER3D_MAX_LIGHTS * 4); // Light3D uniforms, filled each pass
22193
22881
  this.lightColors = new Float32Array(RENDER3D_MAX_LIGHTS * 4);
22882
+ this.lightCones = new Float32Array(RENDER3D_MAX_LIGHTS * 4); // each light's cone, see render3DLightCone
22883
+ this.shadowLightIndex = -1; // which of the lights sent casts the shadows, -1 for the sun
22884
+ this.shadowDepthBias = 0; // the bias as the shadow lookup takes it, set with the shadow matrix
22194
22885
 
22195
22886
  // the stream of immediate mode draws
22196
22887
  /** @type {WebGLBuffer|undefined} */
@@ -22218,6 +22909,116 @@ class Render3DPlugin
22218
22909
  engineAddPlugin(undefined, render3DRender, render3DContextLost, render3DContextRestored, render3DPreRender);
22219
22910
  }
22220
22911
 
22912
+ ///////////////////////////////////////////////////////////////////////////
22913
+ // Draw state: each field an accessor over drawState, whose setter moves stateVersion only on a real change
22914
+
22915
+ /** Apply lighting, when false draws plain vertex color times texture and casts no shadow;
22916
+ * off for billboards, lines, ribbons and soft discs, an object sets emissive instead
22917
+ * @return {boolean} */
22918
+ get lighting() { return this.drawState.lighting; }
22919
+ set lighting(v) { const d = this.drawState; d.lighting === v || (d.lighting = v, ++this.stateVersion); }
22920
+
22921
+ /** How much a surface lights itself, set per object by its emissive
22922
+ * @return {number} */
22923
+ get emissive() { return this.drawState.emissive; }
22924
+ set emissive(v) { const d = this.drawState; d.emissive === v || (d.emissive = v, ++this.stateVersion); }
22925
+
22926
+ /** Additive blending instead of alpha, in the transparent stage
22927
+ * @return {boolean} */
22928
+ get additive() { return this.drawState.additive; }
22929
+ set additive(v) { const d = this.drawState; d.additive === v || (d.additive = v, ++this.stateVersion); }
22930
+
22931
+ /** Test against the depth buffer, reset to true before each object and callback; a draw
22932
+ * with it off goes over what was drawn before it and under what is drawn after, by render order, which
22933
+ * ends the batch of meshes before it, so one per object costs a draw per object
22934
+ * @return {boolean} */
22935
+ get depthTest() { return this.drawState.depthTest; }
22936
+ set depthTest(v) { const d = this.drawState; d.depthTest === v || (d.depthTest = v, ++this.stateVersion); }
22937
+
22938
+ /** Write to the depth buffer, owned by the stages: on for opaque, off for transparent
22939
+ * @return {boolean} */
22940
+ get depthWrite() { return this.drawState.depthWrite; }
22941
+ set depthWrite(v) { const d = this.drawState; d.depthWrite === v || (d.depthWrite = v, ++this.stateVersion); }
22942
+
22943
+ /** Skip back faces, set by drawMesh from each mesh: off for strips so they show from
22944
+ * both sides
22945
+ * @return {boolean} */
22946
+ get cullBackFaces() { return this.drawState.cullBackFaces; }
22947
+ set cullBackFaces(v) { const d = this.drawState; d.cullBackFaces === v || (d.cullBackFaces = v, ++this.stateVersion); }
22948
+
22949
+ /** The transform mirrors the draw, so the other winding is the front, set by drawMesh
22950
+ * from each transform
22951
+ * @return {boolean} */
22952
+ get mirrored() { return this.drawState.mirrored; }
22953
+ set mirrored(v) { const d = this.drawState; d.mirrored === v || (d.mirrored = v, ++this.stateVersion); }
22954
+
22955
+ /** Strength of the highlight where the sun and the Light3D objects reflect, 0 is none and
22956
+ * 1 adds a light's full color at its brightest; shininess sets its size
22957
+ * @return {number} */
22958
+ get specular() { return this.drawState.specular; }
22959
+ set specular(v) { const d = this.drawState; d.specular === v || (d.specular = v, ++this.stateVersion); }
22960
+
22961
+ /** The highlight's exponent, how small and sharp it is: 4 is broad like rubber, 100 sharp
22962
+ * like polished metal; set from each object's shininess
22963
+ * @return {number} */
22964
+ get shininess() { return this.drawState.shininess; }
22965
+ set shininess(v) { const d = this.drawState; d.shininess === v || (d.shininess = v, ++this.stateVersion); }
22966
+
22967
+ /** Normal map for the next draws, set from each object's normalMap
22968
+ * @return {TextureInfo|undefined} */
22969
+ get normalMap() { return this.drawState.normalMap; }
22970
+ set normalMap(v) { const d = this.drawState; d.normalMap === v || (d.normalMap = v, ++this.stateVersion); }
22971
+
22972
+ /** How strongly the normal map bends the surface, set from each object's normalScale
22973
+ * @return {number} */
22974
+ get normalScale() { return this.drawState.normalScale; }
22975
+ set normalScale(v) { const d = this.drawState; d.normalScale === v || (d.normalScale = v, ++this.stateVersion); }
22976
+
22977
+ /** How much the surface reflects the sky, 0 to 1, set from each object's reflectivity
22978
+ * @return {number} */
22979
+ get reflectivity() { return this.drawState.reflectivity; }
22980
+ set reflectivity(v) { const d = this.drawState; d.reflectivity === v || (d.reflectivity = v, ++this.stateVersion); }
22981
+
22982
+ /** Emissive map for the next draws, set from each object's emissiveMap
22983
+ * @return {TextureInfo|undefined} */
22984
+ get emissiveMap() { return this.drawState.emissiveMap; }
22985
+ set emissiveMap(v) { const d = this.drawState; d.emissiveMap === v || (d.emissiveMap = v, ++this.stateVersion); }
22986
+
22987
+ /** Multiplies the emissive map, set from each object's emissiveMapColor; compared by its rgb, so a Color
22988
+ * changed in place is seen when it is set again, and undefined is white
22989
+ * @return {Color} */
22990
+ get emissiveMapColor() { return this.drawState.emissiveMapColor; }
22991
+ set emissiveMapColor(v)
22992
+ {
22993
+ const d = this.drawState, c = v || WHITE;
22994
+ d.emissiveMapColor = v;
22995
+ if (c.r !== d.emissiveR || c.g !== d.emissiveG || c.b !== d.emissiveB)
22996
+ d.emissiveR = c.r, d.emissiveG = c.g, d.emissiveB = c.b, ++this.stateVersion;
22997
+ }
22998
+
22999
+ /** Custom Shader for the next draws, set from each object's shader; undefined draws
23000
+ * with the plugin's own
23001
+ * @return {Shader|undefined} */
23002
+ get shader() { return this.drawState.shader; }
23003
+ set shader(v) { const d = this.drawState; d.shader === v || (d.shader = v, ++this.stateVersion); }
23004
+
23005
+ /** Darken by the shadow map when shadows are on, turn it off for things that should
23006
+ * stay lit inside a shadow
23007
+ * @return {boolean} */
23008
+ get receiveShadow() { return this.drawState.receiveShadow; }
23009
+ set receiveShadow(v) { const d = this.drawState; d.receiveShadow === v || (d.receiveShadow = v, ++this.stateVersion); }
23010
+
23011
+ /** Keep texture pixels hard edged, no mipmaps and no blending between them, set per
23012
+ * object by pixelated
23013
+ * @return {boolean} */
23014
+ get pixelated() { return this.drawState.pixelated; }
23015
+ set pixelated(v) { const d = this.drawState; d.pixelated === v || (d.pixelated = v, ++this.stateVersion); }
23016
+
23017
+ /** Blending on, set by the stages
23018
+ * @return {boolean} */
23019
+ get blend() { return this.drawState.blend; }
23020
+ set blend(v) { const d = this.drawState; d.blend === v || (d.blend = v, ++this.stateVersion); }
23021
+
22221
23022
  ///////////////////////////////////////////////////////////////////////////
22222
23023
  // Matrices and picking
22223
23024
 
@@ -22327,7 +23128,8 @@ class Render3DPlugin
22327
23128
  }
22328
23129
 
22329
23130
  /** Play a sound at a 3D position, quieter with distance from the camera and panned by its side, like Sound.play
22330
- * with a 2D position
23131
+ * with a 2D position; a sound made with no range of its own is heard to soundDefaultRange, 100, and one given
23132
+ * a range keeps it
22331
23133
  * @param {Sound} sound
22332
23134
  * @param {Vector3} pos3D
22333
23135
  * @param {number} [volume]
@@ -22341,7 +23143,9 @@ class Render3DPlugin
22341
23143
  // the 3D range fade and pan, Sound.play with no position does the rest
22342
23144
  false&&ASSERT(sound instanceof Sound, 'sound must be a Sound');
22343
23145
  false&&ASSERT(isVector3(pos3D), 'pos3D must be a vec3');
22344
- const offset = pos3D.subtract(this.camera.pos), range = sound.range;
23146
+ // a sound with no range of its own is heard further in 3D than in 2D
23147
+ const offset = pos3D.subtract(this.camera.pos);
23148
+ const range = sound.rangeIsDefault ? this.soundDefaultRange : sound.range;
22345
23149
  if (range)
22346
23150
  {
22347
23151
  const distance = offset.length();
@@ -22405,15 +23209,15 @@ class Render3DPlugin
22405
23209
  if (this.frustumCulling && !render3DSphereVisible(m[12], m[13], m[14], mesh.radius * render3DMaxStretch(m)))
22406
23210
  return;
22407
23211
  // the mesh says whether its back faces can be skipped, and a mirroring transform, one with a negative
22408
- // determinant, turns the winding around so the other one is its front
22409
- const cullBackFaces = this.cullBackFaces, mirrored = this.mirrored;
22410
- this.cullBackFaces = !mesh.doubleSided;
22411
- this.mirrored = render3DMirrors(matrix);
22412
- // the stage draws the batch at its end
23212
+ // determinant, turns the winding around so the other one is its front; the stage draws a batch at its
23213
+ // end, which keys on those itself
22413
23214
  if (!this.blend && this.depthTest && (mesh.instanced ?? this.instancing))
22414
23215
  render3DInstance(mesh, matrix, tileInfo, color);
22415
23216
  else
22416
23217
  {
23218
+ const cullBackFaces = this.cullBackFaces, mirrored = this.mirrored;
23219
+ this.cullBackFaces = !mesh.doubleSided;
23220
+ this.mirrored = render3DMirrors(matrix);
22417
23221
  this.flush();
22418
23222
  render3DFlushBeforeOverlay();
22419
23223
  render3DSetDrawUniforms(matrix, tileInfo, color);
@@ -22421,8 +23225,8 @@ class Render3DPlugin
22421
23225
  glContext.drawElements(glContext.TRIANGLES, mesh.bufferCount, mesh.indexType, 0);
22422
23226
  ++drawCount;
22423
23227
  primitiveCount += mesh.bufferCount / 3;
23228
+ this.cullBackFaces = cullBackFaces, this.mirrored = mirrored;
22424
23229
  }
22425
- this.cullBackFaces = cullBackFaces, this.mirrored = mirrored;
22426
23230
  }
22427
23231
 
22428
23232
  /** Draw a triangle strip, batched into the stream with the current draw state
@@ -22618,6 +23422,24 @@ class Render3DPlugin
22618
23422
  /** Rebuild the light's view projection around the shadow center, called automatically each frame shadows are on */
22619
23423
  updateShadowMatrix()
22620
23424
  {
23425
+ const caster = render3DShadowCaster();
23426
+ if (caster)
23427
+ {
23428
+ // a spotlight's map looks down its cone with perspective, from just in front of it out to its radius
23429
+ const pos = caster.getWorldPos3D(), forward = caster.getForward3D();
23430
+ const up = abs(forward.y) > .99 ? vec3(0, 0, 1) : vec3(0, 1, 0);
23431
+ // the near plane stays in front of the far one however small the light
23432
+ const far = caster.radius, near = min(max(far / 500, .02), far / 2);
23433
+ const fov = min(caster.coneAngle, RENDER3D_SHADOW_CONE_MAX) * 2 + .1;
23434
+ this.shadowMatrix = Matrix4.perspective(fov, 1, near, far).multiply(
23435
+ Matrix4.lookAt(pos, pos.add(forward), up).invert());
23436
+ this.shadowPlanes = render3DFrustumPlanes(this.shadowMatrix);
23437
+ // depth is not even with perspective: the lookup divides this by the distance squared, which makes
23438
+ // the bias the same distance in the world near the light and far from it
23439
+ this.shadowDepthBias = this.shadowBias * far * far * near / (far - near);
23440
+ return;
23441
+ }
23442
+ this.shadowDepthBias = this.shadowBias; // the sun's map is flat, its depth even
22621
23443
  false&&ASSERT(this.shadowRange > 0, 'shadowRange must be positive');
22622
23444
  const range = this.shadowRange > 0 ? this.shadowRange : 1, half = range / 2;
22623
23445
  const toSun = this.sunDirection.normalize();
@@ -23047,6 +23869,7 @@ function render3DFragmentSource(fragmentCode)
23047
23869
  'precision highp float;' +
23048
23870
  'uniform vec4 lightDir,lightColor,ambientFog,ambientGround,fogColor,shadowParams;' +
23049
23871
  'uniform vec4 extraLights[' + RENDER3D_MAX_LIGHTS + '],extraLightColors[' + RENDER3D_MAX_LIGHTS + '];' +
23872
+ 'uniform vec4 extraLightCones[' + RENDER3D_MAX_LIGHTS + '];' +
23050
23873
  'uniform int extraLightCount;' +
23051
23874
  'uniform vec3 cameraPos;' +
23052
23875
  'uniform vec4 materialParams,emissiveTint,skyTop,skyHorizon,skyBottom;' +
@@ -23055,12 +23878,14 @@ function render3DFragmentSource(fragmentCode)
23055
23878
  'uniform highp sampler2DShadow shadowMap;' +
23056
23879
  'in vec3 P,N;in vec2 T,L;in vec4 C,S;' +
23057
23880
  'out vec4 o;' +
23058
- // the sun shadow at this fragment, 0 to 1: the light's depth map with a 3x3 blur, outside the map is lit
23881
+ // the shadow at this fragment, 0 to 1: the light's depth map with a 3x3 blur, outside the map is lit; the
23882
+ // 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
23883
+ // bias shrinks by its square to stay the same distance in the world
23059
23884
  'float shadow(){' +
23060
- 'if(shadowParams.x<=0.)return 1.;' +
23885
+ 'if(shadowParams.x<=0.||S.w<=0.)return 1.;' +
23061
23886
  'vec3 q=S.xyz/S.w*.5+.5;' +
23062
23887
  'if(any(greaterThanEqual(abs(q-.5),vec3(.5))))return 1.;' +
23063
- 'q.z-=shadowParams.y;' +
23888
+ 'q.z-=shadowParams.y/(S.w*S.w);' +
23064
23889
  'float s=0.;' +
23065
23890
  'for(int x=-1;x<=1;++x)for(int y=-1;y<=1;++y)' +
23066
23891
  's+=texture(shadowMap,vec3(q.xy+vec2(x,y)*shadowParams.z,q.z));' +
@@ -23088,7 +23913,9 @@ function render3DFragmentSource(fragmentCode)
23088
23913
  'if(!gl_FrontFacing)n=-n;' + // only a double sided mesh shows a back face, light it on the side that is seen
23089
23914
  'if(materialParams.x!=0.)n=normalMapNormal(n);' +
23090
23915
  'float nl=dot(n,-lightDir.xyz);' +
23091
- 'float s=shadow();' +
23916
+ // the shadow is the sun's, or one spotlight's: shadowParams.x is 1 for the sun, 2 and up for that Light3D
23917
+ 'float sh=shadow(),s=shadowParams.x<1.5?sh:1.;' +
23918
+ 'int si=int(shadowParams.x+.5)-2;' +
23092
23919
  // the ambient: one color, or blended from the ground color below to the sky color above by the way the face points
23093
23920
  'vec3 l=(ambientGround.a>0.?mix(ambientGround.rgb,ambientFog.rgb,n.y*.5+.5):ambientFog.rgb)+lightColor.rgb*max(nl,0.)*s;' +
23094
23921
  // the Light3D objects: a point light falls off with distance, a directional one does not and carries the
@@ -23104,6 +23931,11 @@ function render3DFragmentSource(fragmentCode)
23104
23931
  'v/=max(d,1e-6);' +
23105
23932
  'float ln=dot(n,v);' +
23106
23933
  'vec3 lc=extraLightColors[i].rgb*extraLightColors[i].a*a*a;' +
23934
+ // a spotlight's cone: full where its fade starts, nothing at its edge, smooth between; 1 with no cone
23935
+ 'vec4 K=extraLightCones[i];' +
23936
+ 'float k=clamp(dot(K.xyz,-v)-K.w,0.,1.);' +
23937
+ 'lc*=k*k*(3.-2.*k);' +
23938
+ 'if(i==si)lc*=sh;' +
23107
23939
  'l+=lc*max(0.,ln);' +
23108
23940
  'if(lightColor.a>0.)sp+=lc*pow(max(dot(reflect(-v,n),eye),0.),materialParams.y)*step(0.,ln);' +
23109
23941
  '}' +
@@ -23158,6 +23990,7 @@ function render3DUseProgram(program)
23158
23990
  {
23159
23991
  gl.uniform4fv(render3DUniform('extraLights'), r.lightPositions, 0, r.lightCount * 4);
23160
23992
  gl.uniform4fv(render3DUniform('extraLightColors'), r.lightColors, 0, r.lightCount * 4);
23993
+ gl.uniform4fv(render3DUniform('extraLightCones'), r.lightCones, 0, r.lightCount * 4);
23161
23994
  }
23162
23995
  if (program !== r.program)
23163
23996
  {
@@ -23298,18 +24131,19 @@ function render3DUpdateSamplers()
23298
24131
  r.samplers.clear();
23299
24132
  }
23300
24133
 
23301
- // the sampler for a texture's wrap modes, smooth or hard edged, made the first time it is needed
23302
- function render3DSampler(wrap, pixelated)
24134
+ // the sampler for a texture's wrap modes, smooth or hard edged, made the first time it is needed; pixelated is the
24135
+ // draw's, texturePixelated the texture's own, which a smooth texture in a pixel art game sets false
24136
+ function render3DSampler(wrap, pixelated, texturePixelated=tilesPixelated)
23303
24137
  {
23304
24138
  const gl = glContext, r = render3D, [wrapS, wrapT] = glWrapModes(wrap);
23305
- const key = wrapS * 1e5 + wrapT * 2 + (pixelated ? 1 : 0); // the modes are 5 digit numbers
24139
+ const key = wrapS * 1e5 + wrapT * 4 + (pixelated ? 1 : 0) + (texturePixelated ? 2 : 0); // the modes are 5 digit numbers
23306
24140
  let sampler = r.samplers.get(key);
23307
24141
  if (sampler) return sampler;
23308
24142
  sampler = gl.createSampler();
23309
- const sharp = pixelated || tilesPixelated;
24143
+ const sharp = pixelated || texturePixelated;
23310
24144
  gl.samplerParameteri(sampler, gl.TEXTURE_MAG_FILTER, sharp ? gl.NEAREST : gl.LINEAR);
23311
24145
  gl.samplerParameteri(sampler, gl.TEXTURE_MIN_FILTER, pixelated ? gl.NEAREST
23312
- : tilesPixelated ? gl.NEAREST_MIPMAP_LINEAR : gl.LINEAR_MIPMAP_LINEAR);
24146
+ : texturePixelated ? gl.NEAREST_MIPMAP_LINEAR : gl.LINEAR_MIPMAP_LINEAR);
23313
24147
  gl.samplerParameteri(sampler, gl.TEXTURE_WRAP_S, wrapS);
23314
24148
  gl.samplerParameteri(sampler, gl.TEXTURE_WRAP_T, wrapT);
23315
24149
  const anisotropy = gl.getExtension('EXT_texture_filter_anisotropic');
@@ -23337,7 +24171,7 @@ function render3DBindTexture(tileInfo, state=render3D, unit=0)
23337
24171
  // or anisotropy, and filtering it that way costs every untextured fragment
23338
24172
  else
23339
24173
  {
23340
- gl.bindSampler(unit, render3DSampler(textureInfo?.wrap, state.pixelated));
24174
+ gl.bindSampler(unit, render3DSampler(textureInfo?.wrap, state.pixelated, textureInfo?.pixelated ?? tilesPixelated));
23341
24175
  glUpdateMipmaps(texture); // drawn into since its mipmaps were made
23342
24176
  if (!state.pixelated && !glMipmappedTextures.has(texture)) // a hard edged draw never reads them
23343
24177
  {
@@ -23517,7 +24351,9 @@ function render3DSetDrawUniforms(matrix, tileInfo, tint, uvRect, state=render3D)
23517
24351
  // 0 blends them away instead, and -1 is additive, which has to fade into fog differently
23518
24352
  const blendMode = state.blend ? (state.additive ? -1 : 0) : 1;
23519
24353
  const receives = r.shadows && r.passIsDefault && state.receiveShadow ? 1 : 0;
23520
- render3DUniform4f('shadowParams', receives, r.shadowBias, r.shadowSoftness / r.shadowTextureSize, blendMode);
24354
+ // 1 the sun's shadow, 2 and up the shadow of that Light3D, the spotlight that casts them
24355
+ const shadowOf = receives && r.shadowLightIndex >= 0 ? 2 + r.shadowLightIndex : receives;
24356
+ render3DUniform4f('shadowParams', shadowOf, r.shadowDepthBias, r.shadowSoftness / r.shadowTextureSize, blendMode);
23521
24357
 
23522
24358
  // a render target's texture holds premultiplied color, the blend writes it that way, so the shader undoes it
23523
24359
  const textureInfo = render3DTextureOf(tileInfo);
@@ -23583,6 +24419,8 @@ function render3DRenderPass(after2D)
23583
24419
  // gathered before the gl state is taken over, so an assert here leaves nothing to hand back
23584
24420
  const lights = render3DCollectLights();
23585
24421
  r.lightCount = lights.length;
24422
+ const caster = render3DShadowCaster();
24423
+ r.shadowLightIndex = caster ? lights.indexOf(caster) : -1;
23586
24424
  const positions = r.lightPositions, colors = r.lightColors;
23587
24425
  lights.forEach((light, i)=>
23588
24426
  {
@@ -23593,6 +24431,7 @@ function render3DRenderPass(after2D)
23593
24431
  positions[k] = p.x, positions[k+1] = p.y, positions[k+2] = p.z;
23594
24432
  positions[k+3] = light.directional ? -1 : max(0, light.radius); // a negative radius marks a direction
23595
24433
  colors[k] = c.r, colors[k+1] = c.g, colors[k+2] = c.b, colors[k+3] = c.a * light.intensity;
24434
+ r.lightCones.set(render3DLightCone(light), k);
23596
24435
  });
23597
24436
 
23598
24437
  // take over the gl state
@@ -23664,6 +24503,7 @@ function render3DUpdateShadowMap(size)
23664
24503
  {
23665
24504
  const gl = glContext, r = render3D;
23666
24505
  false&&ASSERT(size > 0, 'shadowMapSize must be positive');
24506
+ size = glClampTextureSize(size); // a size the device does not have falls back to the largest it has
23667
24507
  if (r.shadowTexture && r.shadowTextureSize === size) return;
23668
24508
  r.shadowTexture && gl.deleteTexture(r.shadowTexture);
23669
24509
  r.shadowFramebuffer && gl.deleteFramebuffer(r.shadowFramebuffer);
@@ -25716,20 +26556,24 @@ function engineObjectsCallback3D(pos, size, callback, objects=engineObjects, tes
25716
26556
  * InstancedMesh3D - Many copies of one mesh drawn as one call, with their transforms kept on the GPU
25717
26557
  * - For big sets that mostly stay put: an instance costs nothing per frame until it changes, so a hundred thousand
25718
26558
  * trees cost what one tree does; objects and drawMesh batch by themselves too, but rebuild their batch every frame
25719
- * - setMatrixAt and setColorAt change one instance, and only the changed range uploads before the next draw
26559
+ * - setTransformAt, setMatrixAt and setColorAt change one instance, and only the changed range uploads before the
26560
+ * next draw; the matrices and the colors are kept apart, so moving instances uploads 64 bytes each
26561
+ * - For many instances moving every frame, setTransforms places a run of them from arrays of positions, rotations
26562
+ * and scales in one loop, the fastest way; or write matrixData directly and call markDirtyRange
25720
26563
  * - The instances are in world space; the object's own pos3D, rotation3D and scale3D do not move them
25721
- * - The whole set is culled by one bounding sphere around the origin, and casts and receives shadows like any object
26564
+ * - The whole set is culled by one bounding sphere around the origin, worked out when culling reads it, or set by hand
26565
+ * with radius; it casts and receives shadows like any object
25722
26566
  * - The object's flags cover the whole set, one emissive, one tileInfo, one shader; only the colors are per instance
25723
26567
  * - A mirrored instance, one with a negative scale, shows its inside unless the mesh is doubleSided
25724
26568
  * - A transparent set draws in one go in the transparent stage, its instances are not sorted against each other;
25725
26569
  * the set sorts against other transparent draws by the object's position, so put pos3D at its middle
25726
- * - pick, the raycast and the collect helpers do not see the instances, test them yourself from instanceData
26570
+ * - pick, the raycast and the collect helpers do not see the instances, test them yourself from matrixData
25727
26571
  * @extends EngineObject3D
25728
26572
  * @memberof Render3D
25729
26573
  * @example
25730
26574
  * const forest = new InstancedMesh3D(treeMesh, 1000);
25731
26575
  * for (let i = 0; i < 1000; ++i)
25732
- * forest.setMatrixAt(i, buildMatrix(randomGroundPos(), vec3(0, rand(2*PI), 0)));
26576
+ * forest.setTransformAt(i, randomGroundPos(), vec3(0, rand(2*PI), 0));
25733
26577
  */
25734
26578
  class InstancedMesh3D extends EngineObject3D
25735
26579
  {
@@ -25748,29 +26592,101 @@ class InstancedMesh3D extends EngineObject3D
25748
26592
  this.count = count;
25749
26593
  /** @property {number} - How many instances it was made with */
25750
26594
  this.maxCount = count;
25751
- /** @property {Float32Array} - The per instance values the shader reads, 24 floats each: the matrix, the color
25752
- * and the uv rect; edit it directly and call markDirty for the instances changed */
25753
- this.instanceData = new Float32Array(count * RENDER3D_INSTANCE_FLOATS);
25754
- /** @property {number} - Radius of the sphere around the origin that holds every instance set so far, for
25755
- * culling; from the farthest instance and the largest scale, and the mesh's size when it draws */
25756
- this.radius = 0;
25757
- this.reach = 0; // the farthest any instance's position has been from the origin
25758
- this.maxScale = 0; // and the largest scale any instance has had
25759
- /** @property {number} - First instance to upload before the next draw */
26595
+ /** @property {Float32Array} - Each instance's matrix, 16 floats, what moving them uploads; edit it directly and
26596
+ * call markDirty for the instances changed */
26597
+ this.matrixData = new Float32Array(count * RENDER3D_MATRIX_FLOATS);
26598
+ /** @property {Float32Array} - Each instance's color and uv rect, 8 floats, uploaded only when they change; edit
26599
+ * it directly and call markColorDirty for the instances changed */
26600
+ this.colorData = new Float32Array(count * RENDER3D_COLOR_FLOATS);
26601
+ /** @property {number} - First instance whose matrix uploads before the next draw */
25760
26602
  this.dirtyStart = 0;
25761
- /** @property {number} - One past the last instance to upload, so nothing uploads when it is not past dirtyStart */
26603
+ /** @property {number} - One past the last instance whose matrix uploads, nothing when it is not past dirtyStart */
25762
26604
  this.dirtyEnd = count;
25763
- this.buffer = undefined; // the GPU copy of instanceData
25764
- this.bufferGeneration = -1; // the context it was made under
25765
- this.uvTileInfo = tileInfo; // the tile the uv rects were written for
25766
- const uv = render3DGetTileUVs(tileInfo), data = this.instanceData;
26605
+ /** @property {number} - First instance whose color uploads before the next draw */
26606
+ this.colorDirtyStart = 0;
26607
+ /** @property {number} - One past the last instance whose color uploads */
26608
+ this.colorDirtyEnd = count;
26609
+ // the farthest any instance's position has been from the origin, and the largest scale any has had, both
26610
+ // squared, and the instances set since radius last took them in: setting one does no bounds work at all
26611
+ this.reachSquared = 0;
26612
+ this.scaleSquared = 0;
26613
+ this.boundsStart = 0;
26614
+ this.boundsEnd = count;
26615
+ /** @type {number|undefined} */
26616
+ this.fixedRadius = undefined; // a radius set by hand, see radius
26617
+ this.buffer = undefined; // the GPU copy of matrixData
26618
+ /** @type {WebGLBuffer|undefined} */
26619
+ this.colorBuffer = undefined; // and of colorData
26620
+ this.bufferGeneration = -1; // the context they were made under
26621
+ this.uvTileInfo = tileInfo; // the tile the uv rects were written for
26622
+ const uv = render3DGetTileUVs(tileInfo), matrices = this.matrixData, colors = this.colorData;
25767
26623
  for (let i = 0; i < count; ++i)
25768
26624
  {
25769
- this.setMatrixAt(i, RENDER3D_IDENTITY);
25770
- const k = i * RENDER3D_INSTANCE_FLOATS;
25771
- data[k+16] = color.r; data[k+17] = color.g; data[k+18] = color.b; data[k+19] = color.a;
25772
- data[k+20] = uv.x; data[k+21] = uv.y; data[k+22] = uv.w; data[k+23] = uv.h;
26625
+ matrices.set(RENDER3D_IDENTITY.m, i * RENDER3D_MATRIX_FLOATS);
26626
+ const k = i * RENDER3D_COLOR_FLOATS;
26627
+ colors[k] = color.r; colors[k+1] = color.g; colors[k+2] = color.b; colors[k+3] = color.a;
26628
+ colors[k+4] = uv.x; colors[k+5] = uv.y; colors[k+6] = uv.w; colors[k+7] = uv.h;
26629
+ }
26630
+ }
26631
+
26632
+ /** Place an instance by its position, rotation and scale, what buildMatrix takes, written straight in with no
26633
+ * matrix made; in world space
26634
+ * @param {number} i
26635
+ * @param {Vector3} [pos]
26636
+ * @param {Vector3} [rotation] - Euler angles in radians
26637
+ * @param {Vector3} [scale] */
26638
+ setTransformAt(i, pos, rotation, scale)
26639
+ {
26640
+ false&&ASSERT(i >= 0 && i < this.maxCount, 'instance index out of range');
26641
+ matrix4Compose(this.matrixData, i * RENDER3D_MATRIX_FLOATS, pos, rotation, scale);
26642
+ this.markDirty(i);
26643
+ }
26644
+
26645
+ /** Place a run of instances from arrays indexed by instance, plain or typed, as a game keeps them: each is placed
26646
+ * as setTransformAt places it, all in one loop with nothing made, the fastest way to move many every frame;
26647
+ * in world space
26648
+ * @param {number} start - First instance
26649
+ * @param {number} count - How many
26650
+ * @param {ArrayLike<number>} x - Positions
26651
+ * @param {ArrayLike<number>} y
26652
+ * @param {ArrayLike<number>} z
26653
+ * @param {ArrayLike<number>} [rx] - Euler angles in radians, all three or none for upright
26654
+ * @param {ArrayLike<number>} [ry]
26655
+ * @param {ArrayLike<number>} [rz]
26656
+ * @param {ArrayLike<number>} [sx] - Scales, all three or none for 1
26657
+ * @param {ArrayLike<number>} [sy]
26658
+ * @param {ArrayLike<number>} [sz] */
26659
+ setTransforms(start, count, x, y, z, rx, ry, rz, sx, sy, sz)
26660
+ {
26661
+ false&&ASSERT(start >= 0 && count >= 0 && start + count <= this.maxCount, 'instance range out of range');
26662
+ false&&ASSERT(!rx === !ry && !rx === !rz && !sx === !sy && !sx === !sz, 'rotations and scales come in threes');
26663
+ const m = this.matrixData, end = start + count;
26664
+ for (let i = start; i < end; ++i)
26665
+ {
26666
+ // matrix4Compose's expressions, so a run placed here is the same as each placed by setTransformAt
26667
+ const k = i * RENDER3D_MATRIX_FLOATS;
26668
+ const scaleX = sx ? sx[i] : 1, scaleY = sy ? sy[i] : 1, scaleZ = sz ? sz[i] : 1;
26669
+ if (rx)
26670
+ {
26671
+ const cx = cos(rx[i]), sinX = sin(rx[i]);
26672
+ const cy = cos(ry[i]), sinY = sin(ry[i]);
26673
+ const cz = cos(rz[i]), sinZ = sin(rz[i]);
26674
+ m[k] = (cy*cz + sinY*sinX*sinZ) * scaleX; m[k+1] = cx*sinZ * scaleX;
26675
+ m[k+2] = (-sinY*cz + cy*sinX*sinZ) * scaleX;
26676
+ m[k+4] = (-cy*sinZ + sinY*sinX*cz) * scaleY; m[k+5] = cx*cz * scaleY;
26677
+ m[k+6] = (sinY*sinZ + cy*sinX*cz) * scaleY;
26678
+ m[k+8] = sinY*cx * scaleZ; m[k+9] = -sinX * scaleZ; m[k+10] = cy*cx * scaleZ;
26679
+ }
26680
+ else
26681
+ {
26682
+ m[k] = scaleX; m[k+1] = 0; m[k+2] = 0;
26683
+ m[k+4] = 0; m[k+5] = scaleY; m[k+6] = 0;
26684
+ m[k+8] = 0; m[k+9] = 0; m[k+10] = scaleZ;
26685
+ }
26686
+ m[k+3] = m[k+7] = m[k+11] = 0; m[k+15] = 1;
26687
+ m[k+12] = x[i], m[k+13] = y[i], m[k+14] = z[i];
25773
26688
  }
26689
+ count && this.markDirtyRange(start, end);
25774
26690
  }
25775
26691
 
25776
26692
  /** Place an instance, in world space
@@ -25779,8 +26695,9 @@ class InstancedMesh3D extends EngineObject3D
25779
26695
  setMatrixAt(i, matrix)
25780
26696
  {
25781
26697
  false&&ASSERT(i >= 0 && i < this.maxCount, 'instance index out of range');
25782
- const data = this.instanceData, k = i * RENDER3D_INSTANCE_FLOATS, m = matrix.m;
25783
- data.set(m, k);
26698
+ const data = this.matrixData, k = i * RENDER3D_MATRIX_FLOATS, m = matrix.m;
26699
+ for (let j = 0; j < 16; ++j)
26700
+ data[k+j] = m[j]; // for 16 floats a loop beats set's call
25784
26701
  this.markDirty(i);
25785
26702
  }
25786
26703
 
@@ -25790,8 +26707,8 @@ class InstancedMesh3D extends EngineObject3D
25790
26707
  getMatrixAt(i)
25791
26708
  {
25792
26709
  false&&ASSERT(i >= 0 && i < this.maxCount, 'instance index out of range');
25793
- const k = i * RENDER3D_INSTANCE_FLOATS, matrix = new Matrix4;
25794
- matrix.m.set(this.instanceData.subarray(k, k + 16));
26710
+ const k = i * RENDER3D_MATRIX_FLOATS, matrix = new Matrix4;
26711
+ matrix.m.set(this.matrixData.subarray(k, k + 16));
25795
26712
  return matrix;
25796
26713
  }
25797
26714
 
@@ -25802,26 +26719,71 @@ class InstancedMesh3D extends EngineObject3D
25802
26719
  {
25803
26720
  false&&ASSERT(i >= 0 && i < this.maxCount, 'instance index out of range');
25804
26721
  false&&ASSERT(isColor(color), 'color must be a Color');
25805
- const data = this.instanceData, k = i * RENDER3D_INSTANCE_FLOATS;
25806
- data[k+16] = color.r; data[k+17] = color.g; data[k+18] = color.b; data[k+19] = color.a;
25807
- this.markDirty(i);
26722
+ const data = this.colorData, k = i * RENDER3D_COLOR_FLOATS;
26723
+ data[k] = color.r; data[k+1] = color.g; data[k+2] = color.b; data[k+3] = color.a;
26724
+ this.markColorDirty(i);
25808
26725
  }
25809
26726
 
25810
- /** Note that an instance changed, so it uploads before the next draw and the bounds hold it; setMatrixAt and
25811
- * setColorAt call this, and so must an edit made straight to instanceData
26727
+ /** Note that an instance's matrix changed, so it uploads before the next draw and the bounds take it in when
26728
+ * they are read; setTransformAt and setMatrixAt call this, and so must an edit made straight to matrixData
25812
26729
  * @param {number} i */
25813
26730
  markDirty(i)
25814
26731
  {
25815
- this.dirtyStart = min(this.dirtyStart, i);
25816
- this.dirtyEnd = max(this.dirtyEnd, i + 1);
26732
+ if (i < this.dirtyStart) this.dirtyStart = i;
26733
+ if (i >= this.dirtyEnd) this.dirtyEnd = i + 1;
26734
+ if (i < this.boundsStart) this.boundsStart = i;
26735
+ if (i >= this.boundsEnd) this.boundsEnd = i + 1;
26736
+ }
26737
+
26738
+ /** Note that a run of instances' matrices changed, for code that writes matrixData directly, the lowest level
26739
+ * way to move many: they upload before the next draw and the bounds take them in when they are read
26740
+ * @param {number} start - First instance
26741
+ * @param {number} end - One past the last */
26742
+ markDirtyRange(start, end)
26743
+ {
26744
+ false&&ASSERT(start >= 0 && end <= this.maxCount && start <= end, 'instance range out of range');
26745
+ if (start < this.dirtyStart) this.dirtyStart = start;
26746
+ if (end > this.dirtyEnd) this.dirtyEnd = end;
26747
+ if (start < this.boundsStart) this.boundsStart = start;
26748
+ if (end > this.boundsEnd) this.boundsEnd = end;
26749
+ }
26750
+
26751
+ /** Note that an instance's color changed, so it uploads before the next draw; setColorAt calls this, and so must
26752
+ * an edit made straight to colorData
26753
+ * @param {number} i */
26754
+ markColorDirty(i)
26755
+ {
26756
+ if (i < this.colorDirtyStart) this.colorDirtyStart = i;
26757
+ if (i >= this.colorDirtyEnd) this.colorDirtyEnd = i + 1;
26758
+ }
25817
26759
 
25818
- // the bounds grow to hold where it is now and how big, read back from the matrix it has
25819
- const d = this.instanceData, k = i * RENDER3D_INSTANCE_FLOATS;
25820
- this.reach = max(this.reach, hypot(d[k+12], d[k+13], d[k+14]));
25821
- this.maxScale = max(this.maxScale, render3DMaxStretch(d, k));
25822
- const meshRadius = this.mesh.radius || this.mesh.computeRadius(); // measured once, the draw takes a new size up
25823
- this.radius = this.reach + meshRadius * this.maxScale;
26760
+ /** Radius of the sphere around the origin that holds every instance set so far, for culling: the farthest
26761
+ * instance, and the mesh's size at the largest scale; it only grows, and takes in the instances set since it
26762
+ * was last read, so with frustum culling off it is never worked out. Set it to fix the sphere, which a big set
26763
+ * that is culled can use to skip that work, and set it to undefined to go back to the bounds
26764
+ * @return {number} */
26765
+ get radius()
26766
+ {
26767
+ if (this.fixedRadius !== undefined) return this.fixedRadius;
26768
+ if (this.boundsEnd > this.boundsStart)
26769
+ {
26770
+ // the bounds grow to hold where each is now and how big, read back from its matrix, squared
26771
+ const d = this.matrixData;
26772
+ let reachSquared = this.reachSquared, scaleSquared = this.scaleSquared;
26773
+ for (let i = this.boundsStart; i < this.boundsEnd; ++i)
26774
+ {
26775
+ const k = i * RENDER3D_MATRIX_FLOATS, x = d[k+12], y = d[k+13], z = d[k+14];
26776
+ const reach = x*x + y*y + z*z, scale = render3DMaxStretchSquared(d, k);
26777
+ if (reach > reachSquared) reachSquared = reach;
26778
+ if (scale > scaleSquared) scaleSquared = scale;
26779
+ }
26780
+ this.reachSquared = reachSquared, this.scaleSquared = scaleSquared;
26781
+ this.boundsStart = Infinity, this.boundsEnd = 0;
26782
+ }
26783
+ const mesh = this.mesh, meshRadius = mesh.radius || mesh.computeRadius(); // the mesh measured once
26784
+ return this.reachSquared ** .5 + meshRadius * this.scaleSquared ** .5;
25824
26785
  }
26786
+ set radius(radius) { this.fixedRadius = radius; }
25825
26787
 
25826
26788
  /** Draws every instance as one call, uploading the ones that changed first
25827
26789
  * @return {void} */
@@ -25835,41 +26797,46 @@ class InstancedMesh3D extends EngineObject3D
25835
26797
  if (r.shadowPass && !r.lighting) return; // unlit things cast no shadow
25836
26798
  render3DMeshUpload(mesh);
25837
26799
  if (!mesh.bufferCount) return;
25838
- this.radius = this.reach + mesh.radius * this.maxScale; // the mesh may have grown since
25839
26800
  if (r.frustumCulling && !render3DSphereVisible(0, 0, 0, this.radius)) return;
25840
26801
 
25841
26802
  // the uv rect is the object's tile for every instance, rewritten when the tile changes
25842
26803
  if (this.uvTileInfo !== this.tileInfo)
25843
26804
  {
25844
- const uv = render3DGetTileUVs(this.tileInfo), data = this.instanceData;
25845
- for (let k = 20; k < data.length; k += RENDER3D_INSTANCE_FLOATS)
26805
+ const uv = render3DGetTileUVs(this.tileInfo), data = this.colorData;
26806
+ for (let k = 4; k < data.length; k += RENDER3D_COLOR_FLOATS)
25846
26807
  data[k] = uv.x, data[k+1] = uv.y, data[k+2] = uv.w, data[k+3] = uv.h;
25847
26808
  this.uvTileInfo = this.tileInfo;
25848
- this.dirtyStart = 0, this.dirtyEnd = this.maxCount;
26809
+ this.colorDirtyStart = 0, this.colorDirtyEnd = this.maxCount;
25849
26810
  }
25850
26811
 
25851
- // the GPU copy: all of it under a fresh context, otherwise just the changed range
26812
+ // the GPU copies: all of each under a fresh context, otherwise just the changed ranges
25852
26813
  if (!this.buffer || this.bufferGeneration !== r.contextGeneration)
25853
26814
  {
25854
- this.buffer = gl.createBuffer();
25855
26815
  this.bufferGeneration = r.contextGeneration;
25856
- gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
25857
- gl.bufferData(gl.ARRAY_BUFFER, this.instanceData, gl.DYNAMIC_DRAW);
26816
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer = gl.createBuffer());
26817
+ gl.bufferData(gl.ARRAY_BUFFER, this.matrixData, gl.DYNAMIC_DRAW);
26818
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.colorBuffer = gl.createBuffer());
26819
+ gl.bufferData(gl.ARRAY_BUFFER, this.colorData, gl.DYNAMIC_DRAW);
25858
26820
  }
25859
- else if (this.dirtyEnd > this.dirtyStart)
26821
+ else
25860
26822
  {
25861
- const start = this.dirtyStart * RENDER3D_INSTANCE_FLOATS, end = this.dirtyEnd * RENDER3D_INSTANCE_FLOATS;
25862
- gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
25863
- gl.bufferSubData(gl.ARRAY_BUFFER, start * 4, this.instanceData, start, end - start);
26823
+ const upload = (buffer, data, floats, start, end)=>
26824
+ {
26825
+ if (end <= start) return;
26826
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
26827
+ gl.bufferSubData(gl.ARRAY_BUFFER, start * floats * 4, data, start * floats, (end - start) * floats);
26828
+ };
26829
+ upload(this.buffer, this.matrixData, RENDER3D_MATRIX_FLOATS, this.dirtyStart, this.dirtyEnd);
26830
+ upload(this.colorBuffer, this.colorData, RENDER3D_COLOR_FLOATS, this.colorDirtyStart, this.colorDirtyEnd);
25864
26831
  }
25865
- this.dirtyStart = Infinity, this.dirtyEnd = 0;
26832
+ this.dirtyStart = this.colorDirtyStart = Infinity, this.dirtyEnd = this.colorDirtyEnd = 0;
25866
26833
 
25867
26834
  // one draw under the object's state, the mesh setting the culling as drawMesh does
25868
26835
  r.flush();
25869
26836
  render3DFlushBeforeOverlay(); // as in drawMesh, what was drawn before goes under it
25870
26837
  const cullBackFaces = r.cullBackFaces, tileInfo = this.tileInfo;
25871
26838
  r.cullBackFaces = !mesh.doubleSided;
25872
- render3DDrawInstanced(mesh, this.buffer, this.count, render3DTextureOf(tileInfo), r);
26839
+ render3DDrawInstanced(mesh, this.buffer, this.count, render3DTextureOf(tileInfo), r, this.colorBuffer);
25873
26840
  r.cullBackFaces = cullBackFaces;
25874
26841
  }
25875
26842
 
@@ -25878,8 +26845,8 @@ class InstancedMesh3D extends EngineObject3D
25878
26845
  destroy(immediate)
25879
26846
  {
25880
26847
  if (this.buffer && this.bufferGeneration === render3D?.contextGeneration)
25881
- glContext?.deleteBuffer(this.buffer);
25882
- this.buffer = undefined;
26848
+ glContext?.deleteBuffer(this.buffer), glContext?.deleteBuffer(this.colorBuffer);
26849
+ this.buffer = this.colorBuffer = undefined;
25883
26850
  super.destroy(immediate);
25884
26851
  }
25885
26852
  }
@@ -25888,13 +26855,16 @@ class InstancedMesh3D extends EngineObject3D
25888
26855
  /**
25889
26856
  * Light3D - A light that is an EngineObject3D, so it can move, follow a parent or be destroyed like anything else
25890
26857
  * - A point light: it lights what is near it and fades out by its radius, DirectionalLight3D shines from far away
25891
- * - Only the sun, render3D.sunDirection, casts shadows; these light and make highlights without one
26858
+ * - A coneAngle makes it a spotlight: it shines along its own forward, turned by rotation3D or by what it is
26859
+ * attached to, inside the cone, fading over the part of it coneSoftness says
26860
+ * - The sun, render3D.sunDirection, casts the shadows; a spotlight can cast them in its place, see
26861
+ * render3D.shadowLight; the other lights light and make highlights without one
25892
26862
  * - Only the 8 lights nearest the camera are used each frame
25893
26863
  * - radius is where the light fades out, and it fades fast, so a small radius wants a higher intensity
25894
26864
  * - intensity multiplies the color, above 1 for a light brighter than white
25895
26865
  * - radius is a world distance, so scale3D does not change it
25896
26866
  * - An alpha, an intensity or a radius of 0 switches it off, and a light that is off takes none of those slots
25897
- * - Draws nothing itself, add a glow with drawSoftDisc or a small emissive mesh if it should be seen
26867
+ * - Draws nothing but its glow, when it has one; add a small emissive mesh if the lamp itself should be seen
25898
26868
  * @extends EngineObject3D
25899
26869
  * @memberof Render3D
25900
26870
  * @example
@@ -25920,6 +26890,12 @@ class Light3D extends EngineObject3D
25920
26890
  /** @property {boolean} - Shine from far away, from its position toward the origin, instead of out from its
25921
26891
  * position with a falloff; DirectionalLight3D sets it */
25922
26892
  this.directional = false;
26893
+ /** @property {number} - Makes it a spotlight: the angle in radians from its forward out to the edge of its
26894
+ * cone, so the beam is twice this across; 0 for a light that shines every way */
26895
+ this.coneAngle = 0;
26896
+ /** @property {number} - How much of the cone is its fading edge: 0 a hard edge, .2 by default, the outer
26897
+ * fifth, 1 fading all the way from the middle of the beam */
26898
+ this.coneSoftness = .2;
25923
26899
  /** @property {number} - Size of a soft hazy glow drawn over the light, like a lamp at night, 0 for none; it
25924
26900
  * is added onto what is behind it, and what is in front of the light hides it */
25925
26901
  this.glow = 0;
@@ -25937,45 +26913,20 @@ class Light3D extends EngineObject3D
25937
26913
  const r = render3D, c = this.color, pos = render3DObjectMatrix(this).getTranslation();
25938
26914
  const toCamera = r.camera.pos.subtract(pos), distance = toCamera.length();
25939
26915
  const at = distance ? pos.add(toCamera.scale(min(this.glow, distance) / 2 / distance)) : pos;
25940
- const color = rgb(c.r, c.g, c.b, c.a * min(this.intensity, 1));
25941
- r.drawBillboard(at, vec2(this.glow), render3DGlow(this.glowFalloff), color);
26916
+ const color = rgb(c.r, c.g, c.b, c.a * min(this.intensity, 1)), texture = engineGlowTexture(this.glowFalloff);
26917
+ // a browser that can not make the glow's texture draws no glow, not a plain square
26918
+ if (texture || !glContext)
26919
+ r.drawBillboard(at, vec2(this.glow), texture, color);
25942
26920
  }
25943
26921
  }
25944
26922
 
25945
- // how strong a light's glow is at a distance from its middle, 0 there to 1 at the edge: a bell, full in the middle
25946
- // and nothing at the edge, fading faster the higher the falloff
25947
- function render3DGlowAlpha(r, falloff)
25948
- {
25949
- const k = 3.5 * falloff, edge = Math.exp(-k);
25950
- return (Math.exp(-k * r * r) - edge) / (1 - edge);
25951
- }
25952
-
25953
- // the soft round glow of the lights, one texture for each falloff, rounded to a tenth so a changing falloff makes
25954
- // only a few, each made once from a canvas; undefined headless or without a canvas
25955
- const render3DGlowTextures = new Map;
25956
- function render3DGlow(falloff=1)
25957
- {
25958
- false&&ASSERT(isNumber(falloff) && falloff > 0, 'glowFalloff must be a number above 0');
25959
- const key = max(round(falloff * 10), 1) / 10;
25960
- let texture = render3DGlowTextures.get(key);
25961
- if (texture || !glContext || typeof OffscreenCanvas == 'undefined') return texture;
25962
- const size = 64, context = createCanvasContext(size), steps = 16;
25963
- const gradient = context.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
25964
- for (let i = 0; i <= steps; ++i)
25965
- gradient.addColorStop(i / steps, 'rgba(255,255,255,' + render3DGlowAlpha(i / steps, key).toFixed(4) + ')');
25966
- context.fillStyle = gradient;
25967
- context.fillRect(0, 0, size, size);
25968
- render3DGlowTextures.set(key, texture = new TextureInfo(context.canvas));
25969
- return texture;
25970
- }
25971
-
25972
26923
  ///////////////////////////////////////////////////////////////////////////////
25973
26924
  /**
25974
26925
  * DirectionalLight3D - A Light3D that shines from far away with no falloff, like sunlight
25975
26926
  * - It shines from its position toward the origin, like a three.js DirectionalLight: only the direction to it
25976
26927
  * counts, so moving it or its parent swings the light around; parent it to a sun in the sky and it follows
25977
26928
  * - It cannot sit on the origin, since that leaves no direction
25978
- * - Like every Light3D it casts no shadow, only the sun, render3D.sunDirection, does
26929
+ * - It casts no shadow: the sun, render3D.sunDirection, does, or a spotlight set as render3D.shadowLight
25979
26930
  * @extends Light3D
25980
26931
  * @memberof Render3D
25981
26932
  * @example
@@ -26902,7 +27853,7 @@ const render3DParticlesCalling = new Set;
26902
27853
  * @return {Particle3D} */
26903
27854
  function render3DParticleView(emitter)
26904
27855
  {
26905
- return {emitter, pos: vec3(), velocity: vec3(), age: 0, lifeTime: 0, destroyed: false,
27856
+ return {emitter, pos: vec3(), velocity: vec3(), age: 0, lifeTime: 0, scale: 1, destroyed: false,
26906
27857
  destroy() { this.destroyed = true; }};
26907
27858
  }
26908
27859
 
@@ -26915,6 +27866,8 @@ function render3DParticleView(emitter)
26915
27866
  * @property {Vector3} velocity - How far it moves each frame
26916
27867
  * @property {number} age - Seconds it has lived
26917
27868
  * @property {number} lifeTime - Seconds it lives
27869
+ * @property {number} scale - How much the emitter grows its effect, from its scale3D and its parents', as the 2D
27870
+ * particle's scale
26918
27871
  * @property {boolean} destroyed - Set by destroy
26919
27872
  * @property {function(): void} destroy - End it this update, the destroy callback gets it
26920
27873
  * @memberof Render3D
@@ -26944,8 +27897,9 @@ function render3DParticleView(emitter)
26944
27897
  * - emitConeAngle spreads them, PI sprays in every direction
26945
27898
  * - Speeds are per frame and sizes are world units, the same as the 2D emitter
26946
27899
  * - scale3D, its own or a parent's, grows the whole effect: the spawn area, the sizes, the speed and the fall
26947
- * - gravity here is its own number added to velocity y each frame: it is neither the engine's 2D
26948
- * gravity nor render3D.gravity, so an effect keeps its own fall wherever it is used
27900
+ * - gravity here is its own number added to velocity y each frame, so an effect keeps its own fall wherever it is
27901
+ * used, the same as the 2D emitter's gravity; gravityScale adds a share of render3D.gravity on top, as the 2D
27902
+ * emitter's gravityScale adds the engine's gravity
26949
27903
  * - An emitter with an emitTime destroys itself once its last particle is gone, like the 2D emitter
26950
27904
  * - Callbacks as the 2D emitter's: particleCreateCallback, particleUpdateCallback, particleCollideCallback and
26951
27905
  * particleDestroyCallback, each given a Particle3D, one object the emitter reuses for every particle and call
@@ -26975,8 +27929,8 @@ class ParticleEmitter3D extends EngineObject3D
26975
27929
  * @param {number} [sizeEnd] - Particle size at end of life
26976
27930
  * @param {number} [speed] - Spawn speed in world units per frame
26977
27931
  * @param {number} [damping] - Per frame velocity multiplier, 1 is none
26978
- * @param {number} [gravity] - Per frame change to velocity y, negative pulls down; its own number,
26979
- * not render3D.gravity, so the 2D emitter's gravityScale has no equivalent here
27932
+ * @param {number} [gravity] - Per frame change to velocity y, negative pulls down; its own number, and
27933
+ * gravityScale adds a share of render3D.gravity on top
26980
27934
  * @param {number} [fadeRate] - Fraction of life spent fading, half in and half out
26981
27935
  * @param {number} [randomness] - Extra randomness applied to speed, size and life
26982
27936
  * @param {boolean} [additive] - Additive blending */
@@ -26991,6 +27945,9 @@ class ParticleEmitter3D extends EngineObject3D
26991
27945
 
26992
27946
  /** @property {number|Vector3} - Spawn area, a number for a sphere diameter or a vec3 for a box */
26993
27947
  this.emitSize = emitSize;
27948
+ /** @property {boolean} - Flatten the spawn area across the way it emits, its own up: a sphere becomes a disc
27949
+ * and a box a flat rectangle, for rain from a sheet of sky or flames from a patch of ground */
27950
+ this.emitFlat = false;
26994
27951
  /** @property {number} - How long to keep emitting, 0 is forever */
26995
27952
  this.emitTime = emitTime;
26996
27953
  /** @property {number} - Particles per second, 0 does not emit */
@@ -27017,6 +27974,9 @@ class ParticleEmitter3D extends EngineObject3D
27017
27974
  this.damping = damping;
27018
27975
  /** @property {number} - Per frame change to velocity y, its own number and not render3D.gravity */
27019
27976
  this.gravity = gravity;
27977
+ /** @property {number} - Share of render3D.gravity added each frame on top of its own gravity, 0 by default so
27978
+ * an effect keeps its own fall wherever it is used; 1 falls with the world, like debris */
27979
+ this.gravityScale = 0;
27020
27980
  /** @property {number} - Fraction of life spent fading, half in and half out */
27021
27981
  this.fadeRate = fadeRate;
27022
27982
  /** @property {number} - Extra randomness applied to speed, size and life */
@@ -27043,6 +28003,9 @@ class ParticleEmitter3D extends EngineObject3D
27043
28003
  /** @property {boolean} - Particles hit the level, the height maps and voxel maps, bouncing by restitution and
27044
28004
  * sliding along by friction; off by default, it tests each particle's move against the level every frame */
27045
28005
  this.collideLevel = false;
28006
+ /** @property {number} - How much a particle grips where it lands, 0 to 1: its speed along the surface is
28007
+ * cut by this much on each hit, on top of the friction, 1 stops it there */
28008
+ this.stick = 0;
27046
28009
  /** @property {Particle3DCallback|undefined} - Called with each particle as it is made
27047
28010
  * @type {Particle3DCallback|undefined} */
27048
28011
  this.particleCreateCallback = undefined;
@@ -27062,7 +28025,7 @@ class ParticleEmitter3D extends EngineObject3D
27062
28025
  /** @property {Vector3|undefined} - Where the emitter was at its last update, for when its parent is destroyed
27063
28026
  * @type {Vector3|undefined} */
27064
28027
  this.worldPos3D = undefined;
27065
- this.emitTimeBuffer = 0;
28028
+ this.emitTimeBuffer = 1; // the first particle comes at once, as the 2D emitter's does
27066
28029
  }
27067
28030
 
27068
28031
  /** Spawn new particles, move the live ones, and go away when done */
@@ -27072,15 +28035,17 @@ class ParticleEmitter3D extends EngineObject3D
27072
28035
  const matrix = render3DObjectMatrix(this); // the object's own, read only
27073
28036
  this.worldPos3D = matrix.getTranslation(); // remembered for when the parent is destroyed
27074
28037
  const scale = render3DMaxScale(matrix.m);
28038
+ this.particleView.scale = scale; // the callbacks read it from the view instead of working it out each time
27075
28039
 
27076
28040
  // emit until the emit time is up, then wait for the last particle and go away
27077
28041
  if (!this.emitTime || this.getAliveTime() <= this.emitTime)
27078
28042
  {
27079
28043
  // a rate of zero is an emitter fed by hand, and the global scale only quiets it,
27080
28044
  // neither is a reason to stop counting down the emit time
27081
- if (this.emitRate && particleEmitRateScale)
28045
+ const rate = this.emitRate * particleEmitRateScale;
28046
+ if (rate > 0 && rate < Infinity)
27082
28047
  {
27083
- this.emitTimeBuffer += this.emitRate * particleEmitRateScale * timeDelta;
28048
+ this.emitTimeBuffer += rate * timeDelta;
27084
28049
  for (; this.emitTimeBuffer >= 1; --this.emitTimeBuffer)
27085
28050
  this.emitParticle();
27086
28051
  }
@@ -27104,7 +28069,11 @@ class ParticleEmitter3D extends EngineObject3D
27104
28069
  const F = RENDER3D_PARTICLE_FLOATS;
27105
28070
  let data = this.particleData, trail = this.trailData;
27106
28071
  const reread = ()=> { data = this.particleData, trail = this.trailData; };
27107
- const damping = this.damping, gravity = this.gravity * scale, angleDamping = this.angleDamping; // a bigger effect has to fall faster to keep the same arc
28072
+ // its own fall and a share of the world's, grown by the scale: a bigger effect has to fall faster to keep the
28073
+ // same arc
28074
+ const damping = this.damping, angleDamping = this.angleDamping, g = render3D.gravity, share = this.gravityScale;
28075
+ const gravityX = g.x * share * scale, gravityY = (this.gravity + g.y * share) * scale;
28076
+ const gravityZ = g.z * share * scale;
27108
28077
  const collideLevel = this.collideLevel && render3DLevel.length;
27109
28078
  const updateCallback = this.particleUpdateCallback, destroyCallback = this.particleDestroyCallback;
27110
28079
  for (let i = this.particleCount; i--;)
@@ -27112,10 +28081,13 @@ class ParticleEmitter3D extends EngineObject3D
27112
28081
  // damped first and gravity added after, the order the 2D particle uses, so the same
27113
28082
  // damping and gravity give the same arc in both
27114
28083
  const k = i * F;
27115
- const vx = data[k+3] *= damping, vy = data[k+4] = data[k+4] * damping + gravity, vz = data[k+5] *= damping;
28084
+ const vx = data[k+3] = data[k+3] * damping + gravityX, vy = data[k+4] = data[k+4] * damping + gravityY;
28085
+ const vz = data[k+5] = data[k+5] * damping + gravityZ;
27116
28086
  data[k] += vx, data[k+1] += vy, data[k+2] += vz;
27117
28087
  data[k+18] += data[k+19] *= angleDamping;
27118
- collideLevel && (this.particleCollide(k, data[k] - vx, data[k+1] - vy, data[k+2] - vz), reread());
28088
+ // true when its collide callback destroyed it, it has had its last callback but the destroy one
28089
+ const gone = collideLevel && this.particleCollide(k, data[k] - vx, data[k+1] - vy, data[k+2] - vz);
28090
+ collideLevel && reread();
27119
28091
  const t = i * trailMax * 3;
27120
28092
  if (trailMax)
27121
28093
  {
@@ -27127,7 +28099,7 @@ class ParticleEmitter3D extends EngineObject3D
27127
28099
  trail[j] = data[k], trail[j+1] = data[k+1], trail[j+2] = data[k+2];
27128
28100
  data[k+20] = n + 1;
27129
28101
  }
27130
- updateCallback && (this.particleCall(updateCallback, k), reread());
28102
+ updateCallback && !gone && (this.particleCall(updateCallback, k), reread());
27131
28103
  if ((data[k+17] += timeDelta) >= data[k+16])
27132
28104
  {
27133
28105
  // dead: the last particle takes its slot, trail and all, order does not matter
@@ -27160,10 +28132,18 @@ class ParticleEmitter3D extends EngineObject3D
27160
28132
  // the whole effect grows with the emitter, not just the area the particles start in
27161
28133
  const scale = render3DMaxScale(matrix.m);
27162
28134
 
27163
- // spawn offset: inside a box or a sphere
27164
- const size = this.emitSize, box = /** @type {Vector3} */ (size);
27165
- const offset = isVector3(size) ? vec3(rand(-.5, .5) * box.x, rand(-.5, .5) * box.y, rand(-.5, .5) * box.z)
27166
- : randInSphere(/** @type {number} */ (size) / 2);
28135
+ // spawn offset: inside a box or a sphere, or flat across the emitter's up, a flat rectangle or a disc
28136
+ const size = this.emitSize, box = /** @type {Vector3} */ (size), flat = this.emitFlat;
28137
+ let offset;
28138
+ if (isVector3(size))
28139
+ offset = vec3(rand(-.5, .5) * box.x, flat ? 0 : rand(-.5, .5) * box.y, rand(-.5, .5) * box.z);
28140
+ else if (flat)
28141
+ {
28142
+ const disc = randInCircle(/** @type {number} */ (size) / 2);
28143
+ offset = vec3(disc.x, 0, disc.y);
28144
+ }
28145
+ else
28146
+ offset = randInSphere(/** @type {number} */ (size) / 2);
27167
28147
 
27168
28148
  // direction inside the cone around local +Y
27169
28149
  const direction = matrix.transformDirection(randVector3(1, this.emitConeAngle)).normalize();
@@ -27221,13 +28201,14 @@ class ParticleEmitter3D extends EngineObject3D
27221
28201
  out[k] = view.pos.x, out[k+1] = view.pos.y, out[k+2] = view.pos.z;
27222
28202
  out[k+3] = view.velocity.x, out[k+4] = view.velocity.y, out[k+5] = view.velocity.z;
27223
28203
  if (view.destroyed)
27224
- out[k+17] = max(out[k+17], out[k+16] - timeDelta); // aged past its life this update
28204
+ out[k+17] = max(out[k+17], out[k+16]); // its life lived: a step short of it can round to just under
27225
28205
  return result;
27226
28206
  }
27227
28207
 
27228
28208
  // stop the particle at k where its last move went into the level, from where it was: back at the surface, a hair
27229
28209
  // off it, its speed into it turned around by restitution and its speed along it kept by friction, unless the
27230
- // collide callback lets it through; one that starts inside is let go, as a 2D one is
28210
+ // collide callback lets it through; one that starts inside is let go, as a 2D one is; true when the callback
28211
+ // destroyed it
27231
28212
  particleCollide(k, x, y, z)
27232
28213
  {
27233
28214
  let data = this.particleData;
@@ -27240,18 +28221,21 @@ class ParticleEmitter3D extends EngineObject3D
27240
28221
  if (h && (!hit || h.distance < hit.distance))
27241
28222
  hit = h, level = l;
27242
28223
  }
27243
- if (!hit) return;
28224
+ if (!hit) return false;
27244
28225
  const point = from.lerp(to, hit.distance);
27245
28226
  const callback = this.particleCollideCallback;
27246
28227
  if (callback && (!this.particleCall(callback, k, level, point.copy()) || this.particleView.destroyed))
27247
- return; // let through, or destroyed by the callback
28228
+ return this.particleView.destroyed; // let through, or destroyed by the callback
27248
28229
  data = this.particleData; // as the callback left it, an emit may have grown it
27249
28230
 
27250
28231
  const n = hit.normal, v = vec3(data[k+3], data[k+4], data[k+5]), into = n.scale(v.dot(n));
27251
- const restitution = max(this.restitution, level.restitution), friction = max(this.friction, level.friction);
28232
+ // the larger friction of the two, as objects take it, then the emitter's own grip, which a level can not undo
28233
+ const restitution = max(this.restitution, level.restitution);
28234
+ const friction = max(this.friction, level.friction) * (1 - clamp(this.stick));
27252
28235
  const out = v.subtract(into).scale(friction).subtract(into.scale(restitution)), p = point.add(n.scale(1e-3));
27253
28236
  data[k] = p.x, data[k+1] = p.y, data[k+2] = p.z;
27254
28237
  data[k+3] = out.x, data[k+4] = out.y, data[k+5] = out.z;
28238
+ return false;
27255
28239
  }
27256
28240
 
27257
28241
  /** Draw the particles, as flat squares or as streaks when trailTime is set
@@ -27433,6 +28417,246 @@ class Trail3D extends EngineObject3D
27433
28417
  }
27434
28418
  }
27435
28419
 
28420
+ ///////////////////////////////////////////////////////////////////////////////
28421
+ // lens flare
28422
+
28423
+ // the shapes a flare is made of, white on clear on one smooth texture made the first time it is asked for: a soft
28424
+ // glow, a flat disc with a brighter rim, and a ring; undefined headless or without a canvas
28425
+ let render3DFlareTiles;
28426
+ function render3DFlareTile(shape)
28427
+ {
28428
+ if (!render3DFlareTiles)
28429
+ {
28430
+ if (headlessMode || !glContext || typeof OffscreenCanvas == 'undefined') return;
28431
+ const cell = 128, shapes = ['glow', 'disc', 'ring'], context = createCanvasContext(cell * shapes.length, cell);
28432
+ // each shape is how see-through it is from its middle out
28433
+ const alpha =
28434
+ {
28435
+ glow: (t)=> engineGlowAlpha(t, 1),
28436
+ disc: (t)=> t < .8 ? .4 + .1 * t : t < .92 ? .48 + (t - .8) * 3 : (1 - t) / .08 * .84,
28437
+ ring: (t)=> max(0, 1 - abs(t - .86) / .12),
28438
+ };
28439
+ shapes.forEach((shape, i)=>
28440
+ {
28441
+ const x = i * cell + cell / 2, y = cell / 2, r = cell / 2 - 2, steps = 32;
28442
+ const gradient = context.createRadialGradient(x, y, 0, x, y, r);
28443
+ for (let k = 0; k <= steps; ++k)
28444
+ gradient.addColorStop(k / steps, 'rgba(255,255,255,' + clamp(alpha[shape](k / steps)).toFixed(4) + ')');
28445
+ context.fillStyle = gradient;
28446
+ context.beginPath();
28447
+ context.arc(x, y, r, 0, 2 * PI);
28448
+ context.fill();
28449
+ });
28450
+ const texture = new TextureInfo(context.canvas, true, false, false); // smooth even in a pixel art game
28451
+ render3DFlareTiles = new Map(shapes.map((shape, i)=>
28452
+ [shape, new TileInfo(vec2(i * cell, 0), vec2(cell), texture)]));
28453
+ }
28454
+ return render3DFlareTiles.get(shape) || render3DFlareTiles.get('glow');
28455
+ }
28456
+
28457
+ /**
28458
+ * LensFlare3D - The sun's lens flare, the old kind: a glow at the sun and a row of discs and rings of different
28459
+ * sizes along the line from the sun through the middle of the screen
28460
+ * - Make one and it shows, over the 3D scene and under what the game draws after, a HUD; destroy it to take it away
28461
+ * - It follows render3D.sunDirection, and fades out as the sun leaves the screen or goes behind something
28462
+ * - flareSize, count, intensity and saturation set its look, seed picks another arrangement, and its color tints it,
28463
+ * with the sun's own color; or give it elements of your own
28464
+ * - visible is how much of the sun shows, 0 to 1, eased over fadeTime, there for a game to read
28465
+ * - What hides the sun is found with a ray from the camera, against the level and every object that is not see
28466
+ * through, each as the box around its mesh, see render3D.pick; turn it off with occlusion
28467
+ * - It needs WebGL, and it draws nothing in the shadow of renderAfter2D
28468
+ * @extends EngineObject3D
28469
+ * @memberof Render3D
28470
+ * @example
28471
+ * new LensFlare3D; // the sun flares
28472
+ * new LensFlare3D(1.5, 10, .7, 0); // bigger, 10 ghosts, dimmer, all one color
28473
+ */
28474
+ class LensFlare3D extends EngineObject3D
28475
+ {
28476
+ /** Create the sun's lens flare
28477
+ * @param {number} [size] - Scales every part of it, 1 by default
28478
+ * @param {number} [count] - How many ghosts there are along the line, besides the glow at the sun
28479
+ * @param {number} [intensity] - How bright it is
28480
+ * @param {number} [saturation] - How colorful the ghosts are, 0 for all the flare's own color, 1 a rainbow
28481
+ * @param {Color} [color] - Tints the whole flare, with the sun's color */
28482
+ constructor(size=1, count=7, intensity=1, saturation=1, color=WHITE)
28483
+ {
28484
+ super(vec3(), undefined, undefined, color);
28485
+ this.size3D = vec3(); // not a thing to pick or collect
28486
+ /** @property {number} - Scales every part of the flare */
28487
+ this.flareSize = size;
28488
+ /** @property {number} - How many ghosts there are along the line, besides the glow at the sun */
28489
+ this.count = count;
28490
+ /** @property {number} - How bright it is */
28491
+ this.intensity = intensity;
28492
+ /** @property {number} - How colorful the ghosts are, 0 for all the flare's own color, 1 a rainbow */
28493
+ this.saturation = saturation;
28494
+ /** @property {number} - Picks the arrangement of the ghosts, another seed is another flare */
28495
+ this.seed = 1;
28496
+ /** @property {Array<{at: number, size: number, color: Color, shape: string}>|undefined} - The parts of the
28497
+ * flare, to set your own in place of the ones made from count, seed and saturation: at is where along the
28498
+ * 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
28499
+ * height; shape is glow, disc or ring
28500
+ * @type {Array<{at: number, size: number, color: Color, shape: string}>|undefined} */
28501
+ this.elements = undefined;
28502
+ /** @property {Light3D|undefined} - A light the flare is of in place of the sun, a lamp or a spotlight: the
28503
+ * flare is at the light and in its color, smaller from farther than the light reaches, hidden by what is
28504
+ * in front of the light, and a spotlight's shows from inside its beam only
28505
+ * @type {Light3D|undefined} */
28506
+ this.light = undefined;
28507
+ /** @property {boolean} - Fade out when something is between the camera and the sun */
28508
+ this.occlusion = true;
28509
+ /** @property {number} - Seconds the flare takes to fade out or in when the sun is hidden or shows again */
28510
+ this.fadeTime = .15;
28511
+ /** @property {number} - How much of the sun shows, 0 hidden or behind the camera to 1 in plain view, eased */
28512
+ this.visible = 1;
28513
+ this.renderOrder = 1e9; // over the game's sprites, it is light in the lens
28514
+ this.madeKey = '';
28515
+ /** @type {Array<{at: number, size: number, color: Color, shape: string}>} */
28516
+ this.made = [];
28517
+ }
28518
+
28519
+ /** The parts of the flare: the elements set by hand, or the ones made from count, seed and saturation, a glow
28520
+ * and a core at the sun and the ghosts, made again when one of those changes
28521
+ * @return {Array<{at: number, size: number, color: Color, shape: string}>} */
28522
+ getElements()
28523
+ {
28524
+ if (this.elements) return this.elements;
28525
+ const key = [this.count, this.seed, this.saturation].join();
28526
+ if (key !== this.madeKey)
28527
+ {
28528
+ const random = new RandomGenerator(this.seed * 7919 + 1), s = clamp(this.saturation);
28529
+ const made = this.made = [
28530
+ {at: 0, size: .7, color: hsl(0, 0, 1, .5), shape: 'glow'},
28531
+ {at: 0, size: .25, color: hsl(0, 0, 1, .9), shape: 'glow'}];
28532
+ for (let i = 0; i < this.count; ++i)
28533
+ {
28534
+ // spread along the line, each a place of its own, the far ones bigger
28535
+ const at = (i + random.float(.2, .8)) / max(this.count, 1) * 1.9 + .2;
28536
+ const pick = random.float(), shape = pick < .5 ? 'disc' : pick < .8 ? 'ring' : 'glow';
28537
+ made.push({at, size: random.float(.04, .1) * (1 + at), shape,
28538
+ color: hsl(random.float(), s * .9, .6, random.float(.15, .4))});
28539
+ }
28540
+ this.madeKey = key;
28541
+ }
28542
+ return this.made;
28543
+ }
28544
+
28545
+ // what the flare is of, seen from the camera: the way to it, how far it is, Infinity for the sun, and a point
28546
+ // to find it on the screen by; undefined with no sun direction, or a light that is gone or at the camera
28547
+ flareSource()
28548
+ {
28549
+ const camera = render3D.camera.pos, light = this.light;
28550
+ if (!light)
28551
+ {
28552
+ const sun = render3D.sunDirection;
28553
+ if (!sun.lengthSquared()) return;
28554
+ const direction = sun.normalize();
28555
+ return {direction, distance: Infinity, pos: camera.add(direction.scale(100))};
28556
+ }
28557
+ if (light.destroyed) return;
28558
+ const pos = light.getWorldPos3D(), offset = pos.subtract(camera), distance = offset.length();
28559
+ return distance ? {direction: offset.scale(1 / distance), distance, pos} : undefined;
28560
+ }
28561
+
28562
+ /** Where the sun, or the flare's light, is on the screen, in pixels like mousePosScreen, undefined when it is
28563
+ * behind the camera
28564
+ * @return {Vector2|undefined} */
28565
+ getSunScreenPos()
28566
+ {
28567
+ const source = this.flareSource();
28568
+ return source && render3D.worldToScreen(source.pos);
28569
+ }
28570
+
28571
+ /** The parts of the flare as they are drawn now: each one's place on the screen, its size in pixels and its
28572
+ * color, dimmed by how much of the sun shows; empty when there is nothing to draw
28573
+ * @return {Array<{pos: Vector2, size: number, color: Color, shape: string}>} */
28574
+ getScreenElements()
28575
+ {
28576
+ const sun = this.getSunScreenPos(), center = mainCanvasSize.scale(.5);
28577
+ if (!sun || !center.x || !center.y) return [];
28578
+ // it fades as the sun leaves the screen, gone when it is a third of the screen past the edge
28579
+ const off = max(abs(sun.x - center.x) / center.x, abs(sun.y - center.y) / center.y);
28580
+ const strength = this.visible * clamp((1.3 - off) / .5) * this.intensity;
28581
+ if (!(strength > 0)) return [];
28582
+ let tint = this.color.multiply(render3D.sunColor), height = mainCanvasSize.y * this.flareSize;
28583
+ const light = this.light, source = light && this.flareSource();
28584
+ if (light)
28585
+ {
28586
+ if (!source) return [];
28587
+ // a light's flare is the light's color, as bright as the light up to 1, and only from inside its cone
28588
+ const cone = render3DLightCone(light), d = source.direction;
28589
+ const inCone = clamp(-(cone[0] * d.x + cone[1] * d.y + cone[2] * d.z) - cone[3]);
28590
+ const c = light.color, bright = c.a * clamp(light.intensity) * inCone;
28591
+ if (!(bright > 0)) return [];
28592
+ tint = this.color.multiply(rgb(c.r, c.g, c.b, bright));
28593
+ height *= min(1, light.radius / source.distance); // smaller from farther than it reaches
28594
+ }
28595
+ return this.getElements().map((e)=>
28596
+ {
28597
+ const c = e.color.multiply(tint);
28598
+ // along the line and past the middle, a lerp would stop there
28599
+ return {pos: sun.add(center.subtract(sun).scale(e.at)), size: e.size * height, shape: e.shape,
28600
+ color: rgb(c.r, c.g, c.b, c.a * strength)};
28601
+ });
28602
+ }
28603
+
28604
+ /** Is something between the camera and the sun, or the flare's light: the level, or an object that is not see
28605
+ * through
28606
+ * @return {boolean} */
28607
+ isSunHidden()
28608
+ {
28609
+ const source = this.flareSource();
28610
+ if (!source) return true;
28611
+ const ray = new Ray3D(render3D.camera.pos, source.direction), reach = source.distance;
28612
+ const blockers = engineObjects.filter((o)=> o !== this && o instanceof EngineObject3D && !o.destroyed &&
28613
+ !o.transparent && !o.additive);
28614
+ // a voxel map says block by block what is see through: glass, water and leaves let the sun by, and the
28615
+ // ray goes on to the blocks behind them
28616
+ const maps = /** @type {Array<VoxelMap>} */ (typeof VoxelMap == 'undefined' ? [] :
28617
+ blockers.filter((o)=> o instanceof VoxelMap));
28618
+ for (const map of maps)
28619
+ if (map.raycast(ray, reach, (type)=> !map.blockType(type).seeThrough))
28620
+ return true;
28621
+ // an object is hit by its box, and a box the camera is inside, a room, a wide floor or the player's own
28622
+ // body, says nothing of what is in the way: the ray the other way hits it too, and it is left out; so is
28623
+ // one the light is inside, its lamp
28624
+ const backward = ray.direction.scale(-1), back = new Ray3D(ray.origin, backward);
28625
+ const lamp = this.light && [new Ray3D(source.pos, ray.direction), new Ray3D(source.pos, backward)];
28626
+ const hits = (r, o)=> render3DRaycastObject(r, o) !== undefined;
28627
+ const around = (o)=> !(o instanceof HeightMap) && (hits(back, o) || lamp && hits(lamp[0], o) && hits(lamp[1], o));
28628
+ const hit = render3D.pick(ray, blockers.filter((o)=> !maps.some((map)=> map === o) && !around(o)));
28629
+ return !!hit && hit.distance < reach;
28630
+ }
28631
+
28632
+ /** Ease visible toward whether the sun shows, called automatically each frame */
28633
+ update()
28634
+ {
28635
+ super.update();
28636
+ const shows = !!this.getSunScreenPos() && !(this.occlusion && this.isSunHidden());
28637
+ const step = this.fadeTime > 0 ? timeDelta / this.fadeTime : 1;
28638
+ this.visible = clamp(this.visible + (shows ? step : -step));
28639
+ }
28640
+
28641
+ /** Draw the flare over the 3D scene, added onto it, called automatically in the 2D pass */
28642
+ render()
28643
+ {
28644
+ if (headlessMode || !glEnable) return;
28645
+ const elements = this.getScreenElements();
28646
+ if (!elements.length) return;
28647
+ setAdditiveBlendMode(true);
28648
+ for (const e of elements)
28649
+ {
28650
+ const tileInfo = render3DFlareTile(e.shape);
28651
+ tileInfo && drawTile(e.pos, vec2(e.size), tileInfo, e.color, 0, false, undefined, true, true);
28652
+ }
28653
+ setAdditiveBlendMode(false);
28654
+ }
28655
+
28656
+ /** A flare has nothing to draw in the 3D pass */
28657
+ render3D() {}
28658
+ }
28659
+
27436
28660
  ///////////////////////////////////////////////////////////////////////////////
27437
28661
  // OBJ meshes
27438
28662
 
@@ -28527,6 +29751,19 @@ function level3DAddMesh(name, mesh, tileInfo, color=WHITE)
28527
29751
  * - What a file written by hand gets wrong uses the default: a value that is not of its default's type
28528
29752
  * - An object its type can not make is skipped with an error in debug builds, where asserts throw, and the rest
28529
29753
  * of the level is made
29754
+ * - A level may hold a map of blocks, in a voxels block: pos, its corner, size, its cells along x, y and z, and
29755
+ * blocks, runs of a count and a type along x, then y, then z; it is made a VoxelMap, the first of what is
29756
+ * returned, see level3DVoxelSetup for its sheet
29757
+ * - A level may hold a terrain, in a terrain block: pos, its center, size, its size in the world along x and z,
29758
+ * 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
29759
+ * is made a HeightMap, returned with what else was made; paint, when it has it, colors its samples: colors, a
29760
+ * list, and cells, runs of a count and a color along the rows, 0 for the terrain's own and 1 the list's first
29761
+ * - A level may hold prefabs of its own, in a prefabs block, each by its name as level3DAddPrefab takes it; they
29762
+ * are added before its objects are made, one the game added itself keeps its place
29763
+ * - A level may set the scene too, in a scene block beside its objects: sky, three colors for straight up, the
29764
+ * horizon and straight down, ambient, how much of them lights the scene, .5 when not given, sunDirection and
29765
+ * sunColor, fog, its start and end, fogColor, the horizon color when not given, and shadows; what the block
29766
+ * leaves out stays as the game set it, and a level with no block changes nothing
28530
29767
  * @param {Object} level - The level, the level editor edits this same object
28531
29768
  * @return {Array<any>} - What each object's type made, a function that made nothing is left out
28532
29769
  * @memberof Level3D */
@@ -28535,7 +29772,12 @@ function level3DLoad(level)
28535
29772
  false&&ASSERT(!!level && typeof level === 'object', 'a level is an object, {} for a new one');
28536
29773
  false&&ASSERT(!(level.littlejs3D > LEVEL3D_VERSION), 'the level was made by a newer LittleJS');
28537
29774
  editor3DLevelLoaded(level); // debug builds: the 3D editor takes the level, its autosaved edits go in first
28538
- const made = [];
29775
+ level3DPrefabsAdd(level.prefabs);
29776
+ level3DSceneApply(level.scene);
29777
+ const made = [], map = level3DVoxelMap = level3DVoxelsMake(level.voxels);
29778
+ map && made.push(map);
29779
+ const terrain = level3DTerrainMap = level3DTerrainMake(level.terrain);
29780
+ terrain && made.push(terrain);
28539
29781
  for (const object of isArray(level.objects) ? level.objects : [])
28540
29782
  {
28541
29783
  if (!object || typeof object !== 'object') continue;
@@ -28546,6 +29788,182 @@ function level3DLoad(level)
28546
29788
  return made;
28547
29789
  }
28548
29790
 
29791
+ // the sheet a level's block map is made with, and what sets each one up, see level3DVoxelSetup; and the map the
29792
+ // level loaded last made, the one the 3D editor paints
29793
+ let level3DVoxelTiles, level3DVoxelSetupMap, level3DVoxelMap;
29794
+
29795
+ /** How a level's block map is made: the sheet its blocks show tiles of, and a function to set it up
29796
+ * - A level's voxels block makes a VoxelMap when the level loads, with texture 0 and the default tile size unless
29797
+ * a sheet is given here; a block's type shows that tile of the sheet on every face
29798
+ * - setup is called with each map a level makes, to give block types their own faces or make them see-through
29799
+ * - Call it before level3DLoad; with no arguments the defaults are back
29800
+ * @param {TileInfo} [tileInfo] - The sheet's first tile, as for a VoxelMap
29801
+ * @param {function(VoxelMap): void} [setup]
29802
+ * @example
29803
+ * level3DVoxelSetup(tile(0, 16, 1), (map)=> map.setBlockType(1, {top: 0, side: 1, bottom: 2})); // grass
29804
+ * @memberof Level3D */
29805
+ function level3DVoxelSetup(tileInfo, setup)
29806
+ {
29807
+ level3DVoxelTiles = tileInfo;
29808
+ level3DVoxelSetupMap = setup;
29809
+ }
29810
+
29811
+ // the most cells a level's map may have, 256 each way
29812
+ const LEVEL3D_VOXEL_CELLS = 2 ** 24;
29813
+
29814
+ // 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
29815
+ function level3DVoxelsEncode(data)
29816
+ {
29817
+ const blocks = [];
29818
+ for (let i = 0; i < data.length;)
29819
+ {
29820
+ let end = i + 1;
29821
+ while (end < data.length && data[end] === data[i]) ++end;
29822
+ blocks.push(end - i, data[i]);
29823
+ i = end;
29824
+ }
29825
+ return blocks;
29826
+ }
29827
+
29828
+ // 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
29829
+ // is not two numbers ends them
29830
+ function level3DVoxelsDecode(blocks, data)
29831
+ {
29832
+ data.fill(0);
29833
+ if (!isArray(blocks)) return;
29834
+ for (let i = 0, cell = 0; i + 1 < blocks.length && cell < data.length; i += 2)
29835
+ {
29836
+ const count = blocks[i], type = blocks[i + 1];
29837
+ if (!isNumber(count) || !isNumber(type) || count < 0) return;
29838
+ const end = min(cell + floor(count), data.length);
29839
+ data.fill(type >= 0 && type <= 255 && type % 1 === 0 ? type : 0, cell, end);
29840
+ cell = end;
29841
+ }
29842
+ }
29843
+
29844
+ // 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
29845
+ function level3DVoxelsShape(voxels)
29846
+ {
29847
+ if (!voxels || typeof voxels !== 'object') return;
29848
+ const size = level3DVector(voxels.size);
29849
+ if (!size || [size.x, size.y, size.z].some((n)=> n < 1 || n % 1) ||
29850
+ size.x * size.y * size.z > LEVEL3D_VOXEL_CELLS) return;
29851
+ return {pos: level3DVector(voxels.pos, vec3()), size};
29852
+ }
29853
+
29854
+ // the VoxelMap of a level's voxels block, undefined when it has none or it is wrong
29855
+ function level3DVoxelsMake(voxels)
29856
+ {
29857
+ const shape = level3DVoxelsShape(voxels);
29858
+ if (!shape || typeof VoxelMap == 'undefined') return;
29859
+ const map = new VoxelMap(shape.pos, shape.size, level3DVoxelTiles || tile());
29860
+ level3DVoxelsDecode(voxels.blocks, map.data);
29861
+ level3DVoxelSetupMap?.(map);
29862
+ map.rebuild();
29863
+ return map;
29864
+ }
29865
+
29866
+ // the terrain the level loaded last made, the one the 3D editor sculpts, and the most samples it may have a side
29867
+ let level3DTerrainMap;
29868
+ const LEVEL3D_TERRAIN_SAMPLES = 129;
29869
+
29870
+ // what a level's terrain block is made with: its center, its size in the world along x and z, how tall a full
29871
+ // 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
29872
+ // one a HeightMap can be made of
29873
+ function level3DTerrainShape(terrain)
29874
+ {
29875
+ if (!terrain || typeof terrain !== 'object') return;
29876
+ const size = terrain.size, rows = terrain.heights, n = LEVEL3D_TERRAIN_SAMPLES;
29877
+ if (!isArray(size) || size.length !== 2 || !size.every((v)=> isNumber(v) && v > 0)) return;
29878
+ if (!isArray(rows) || rows.length < 2 || rows.length > n || !isArray(rows[0])) return;
29879
+ const columns = rows[0].length;
29880
+ if (columns < 2 || columns > n || !rows.every((row)=> isArray(row) && row.length === columns)) return;
29881
+ const heights = rows.map((row)=> row.map((v)=> isNumber(v) ? clamp(v) : 0));
29882
+ return {pos: level3DVector(terrain.pos, vec3()), size: vec2(size[0], size[1]),
29883
+ height: isNumber(terrain.height) && terrain.height > 0 ? terrain.height : 1, heights};
29884
+ }
29885
+
29886
+ // a hex color of a level as a Color, undefined when it is not one
29887
+ function level3DHexColor(hex)
29888
+ { return /^#([0-9a-f]{6}|[0-9a-f]{8})$/i.test(hex) ? new Color().setHex(hex) : undefined; }
29889
+
29890
+ // the paint of a level's terrain block, for a terrain of so many samples: its colors as they are written and a
29891
+ // color for each sample, row after row, 0 for none and 1 the first of the colors; undefined when it has none or
29892
+ // it does not fit the terrain
29893
+ function level3DTerrainPaint(terrain, samples)
29894
+ {
29895
+ const paint = terrain?.paint, colors = paint?.colors, runs = paint?.cells;
29896
+ if (!isArray(colors) || !isArray(runs) || colors.length > 255 || !colors.every(level3DHexColor)) return;
29897
+ const cells = new Uint8Array(samples);
29898
+ let at = 0;
29899
+ for (let i = 0; i < runs.length; i += 2)
29900
+ {
29901
+ const count = runs[i], color = runs[i + 1];
29902
+ if (!(count > 0 && count % 1 === 0 && color >= 0 && color % 1 === 0) || color > colors.length ||
29903
+ at + count > samples) return;
29904
+ cells.fill(color, at, at += count);
29905
+ }
29906
+ return at === samples ? {colors: colors.slice(), cells} : undefined;
29907
+ }
29908
+
29909
+ // give a terrain's map the colors of its block: its own color over the whole of it, or with paint a color for
29910
+ // each sample, the terrain's own where it is not painted; the caller builds the map again
29911
+ function level3DTerrainSetColors(map, terrain, paint=level3DTerrainPaint(terrain, map.rows * map.columns))
29912
+ {
29913
+ const base = level3DHexColor(terrain.color) || WHITE;
29914
+ map.color = base, map.colors = undefined;
29915
+ if (!paint || !paint.cells.some((v)=> v)) return;
29916
+ const palette = [base, ...paint.colors.map(level3DHexColor)];
29917
+ map.color = WHITE;
29918
+ map.colors = map.heights.map((row, r)=> row.map((v, c)=> palette[paint.cells[r * map.columns + c]] || base));
29919
+ }
29920
+
29921
+ // the HeightMap of a level's terrain block, undefined when it has none or it is wrong
29922
+ function level3DTerrainMake(terrain)
29923
+ {
29924
+ const shape = level3DTerrainShape(terrain);
29925
+ if (!shape || typeof HeightMap == 'undefined') return;
29926
+ const map = new HeightMap(shape.heights, shape.size, shape.height, undefined, shape.pos);
29927
+ level3DTerrainSetColors(map, terrain);
29928
+ map.colors && map.rebuild();
29929
+ return map;
29930
+ }
29931
+
29932
+ // the sky domes levels made, a level's sky takes the place of the one before and disposes it; a dome the game made
29933
+ // itself is left whole, for the game to put back
29934
+ const level3DSkies = new WeakSet;
29935
+
29936
+ // set the scene a level's scene block gives: sky as its top, horizon and bottom colors, with ambient how much of
29937
+ // them lights the scene, sunDirection and sunColor, fog as its start and end with fogColor, the horizon without one,
29938
+ // and shadows; a setting the block does not have, or has wrong, stays as it is
29939
+ function level3DSceneApply(scene)
29940
+ {
29941
+ const r = render3D;
29942
+ if (!r || !scene || typeof scene !== 'object') return;
29943
+ const color = (value)=> /^#([0-9a-f]{6}|[0-9a-f]{8})$/i.test(value) ? new Color().setHex(value) : undefined;
29944
+ const sky = isArray(scene.sky) && scene.sky.length === 3 ? scene.sky.map(color) : [];
29945
+ if (sky.length && sky.every((c)=> c))
29946
+ {
29947
+ // as setSky does, without disposing a dome that is the game's own
29948
+ const [top, horizon, bottom] = sky, ambient = isNumber(scene.ambient) ? scene.ambient : .5;
29949
+ level3DSkies.has(r.sky) && r.sky.dispose();
29950
+ level3DSkies.add(r.sky = buildSky(top, horizon, bottom));
29951
+ r.fogColor = horizon.copy();
29952
+ r.ambientColor = top.scale(ambient, 1);
29953
+ r.ambientGroundColor = bottom.scale(ambient, 1);
29954
+ }
29955
+ const sun = level3DVector(scene.sunDirection);
29956
+ if (sun && sun.lengthSquared())
29957
+ r.sunDirection = sun;
29958
+ r.sunColor = color(scene.sunColor) || r.sunColor;
29959
+ const fog = scene.fog;
29960
+ if (isArray(fog) && fog.length === 2 && fog.every((v)=> isNumber(v)))
29961
+ r.fogStart = fog[0], r.fogEnd = fog[1];
29962
+ r.fogColor = color(scene.fogColor) || r.fogColor;
29963
+ if (typeof scene.shadows === 'boolean')
29964
+ r.shadows = scene.shadows;
29965
+ }
29966
+
28549
29967
  // a vec3 of an array of three numbers, as the file has them, or the fallback
28550
29968
  function level3DVector(value, fallback)
28551
29969
  {
@@ -28580,6 +29998,14 @@ function level3DProperties(type, object)
28580
29998
  // make one object of a level from the type added for its name, undefined when there is no such type or it made
28581
29999
  // nothing
28582
30000
  function level3DMake(object)
30001
+ {
30002
+ return level3DMakeAt(object, level3DVector(object.pos, vec3()),
30003
+ level3DVector(object.rotation, vec3()).scale(PI / 180), level3DVector(object.scale, vec3(1)));
30004
+ }
30005
+
30006
+ // make a level object's game object at a place, a rotation in radians and a scale, its own as level3DMake gives
30007
+ // them or the ones a prefab's instance puts it at
30008
+ function level3DMakeAt(object, pos, rotation, scale)
28583
30009
  {
28584
30010
  const type = level3DTypes.get(object.type);
28585
30011
  if (!type)
@@ -28587,7 +30013,7 @@ function level3DMake(object)
28587
30013
  debug && console.warn(`level3DLoad: no type added for ${object.type}, skipped`);
28588
30014
  return;
28589
30015
  }
28590
- const pos = level3DVector(object.pos, vec3()), properties = level3DProperties(type, object);
30016
+ const properties = level3DProperties(type, object);
28591
30017
  const {make} = type, count = engineObjects.length;
28592
30018
  let result;
28593
30019
  try { result = make.prototype ? new make(pos, properties) : make(pos, properties); }
@@ -28605,16 +30031,263 @@ function level3DMake(object)
28605
30031
  if (result instanceof EngineObject3D)
28606
30032
  {
28607
30033
  level3DBaseScale.set(result, result.scale3D.copy());
28608
- result.rotation3D = level3DVector(object.rotation, vec3()).scale(PI / 180);
28609
- result.scale3D = result.scale3D.multiply(level3DVector(object.scale, vec3(1)));
30034
+ result.rotation3D = rotation.copy();
30035
+ result.scale3D = result.scale3D.multiply(scale);
28610
30036
  }
28611
30037
 
28612
30038
  // the type's own properties are set on it, one the type has no default for could be a field of the engine's
28613
30039
  for (const key in type.defaults)
28614
30040
  result[key] = properties[key];
30041
+ // a prefab's instance makes its parts now that it is where it goes
30042
+ result instanceof Prefab3D && result.placeAt(pos);
28615
30043
  return result;
28616
30044
  }
28617
30045
 
30046
+ /** Make one object of a type added with level3DAddType, level3DAddMesh or level3DAddPrefab, from code, with no
30047
+ * level: a prefab's instance, or a plain type
30048
+ * @param {string} type - The type's name
30049
+ * @param {Vector3} [pos3D]
30050
+ * @param {Vector3} [rotation3D] - In radians, as an object has it
30051
+ * @param {Vector3} [scale3D] - Times the scale the type makes it with
30052
+ * @param {Object} [properties] - Over the type's defaults
30053
+ * @return {any} - What the type made, a Prefab3D for a prefab, undefined when there is no such type
30054
+ * @memberof Level3D
30055
+ * @example
30056
+ * level3DAddPrefab('House', await fetchJSON('house.json'));
30057
+ * level3DSpawn('House', vec3(10, 0, 0), vec3(0, PI/2, 0)); */
30058
+ function level3DSpawn(type, pos3D=vec3(), rotation3D=vec3(), scale3D=vec3(1), properties)
30059
+ {
30060
+ false&&ASSERT(isVector3(pos3D) && isVector3(rotation3D) && isVector3(scale3D), 'pos3D, rotation3D and scale3D are vec3');
30061
+ return level3DMakeAt(properties ? {type, properties} : {type}, pos3D.copy(), rotation3D, scale3D);
30062
+ }
30063
+
30064
+ ///////////////////////////////////////////////////////////////////////////////
30065
+ // prefabs: a prefab is a small level, a list of objects about its own origin, placed many times
30066
+
30067
+ // the prefabs by name: how an instance is built, its objects, fromLevel, true for one a level's own prefabs
30068
+ // block added, which one the game adds takes the place of, and source, the object it was added from, which the
30069
+ // level editor knows a level that is a prefab by
30070
+ /** @type {Map<string, {attached: boolean, objects: Array<Object>, fromLevel: boolean, source: Object}>} */
30071
+ const level3DPrefabs = new Map;
30072
+
30073
+ // how many prefabs deep an instance being made is, a prefab that holds itself is stopped; and how many of those
30074
+ // are attached: a prefab inside an attached one is attached too, or its parts would be left behind in the world
30075
+ let level3DPrefabDepth = 0, level3DPrefabAttached = 0;
30076
+
30077
+ /** Add a prefab: a small level, its objects placed about its own origin, to place many times under one name
30078
+ * - It is a type from then on: a level's object of that type, the level editor's Place list and level3DSpawn make
30079
+ * an instance, a Prefab3D; an instance is made of the prefab as it is then, so the level editor, where a prefab
30080
+ * changes, makes its instances again
30081
+ * - The prefab is a level as the level editor saves it, {objects: [...]}, so the editor is the prefab editor too;
30082
+ * only its objects are used, and they may be of other prefabs
30083
+ * - With attached true in it the parts are children of the instance and move with it as one body, without
30084
+ * collision of their own, a prefab inside it too; its handle is at the middle of the box around them and its
30085
+ * size3D is that box, so setCollision makes the body solid where it is seen; otherwise each part is an object of its own in the world and collides as one placed
30086
+ * by hand does
30087
+ * - Adding a name again replaces it
30088
+ * @param {string} name - The type its instances have in a level
30089
+ * @param {Object} prefab - {objects, attached}
30090
+ * @memberof Level3D
30091
+ * @example
30092
+ * level3DAddPrefab('Tower', {objects: [{type: 'Box', pos: [0, 1, 0], scale: [2, 2, 2]},
30093
+ * {type: 'Cylinder', pos: [0, 3, 0]}]});
30094
+ * level3DLoad({objects: [{type: 'Tower', pos: [5, 0, 5]}, {type: 'Tower', pos: [-5, 0, 5], rotation: [0, 45, 0]}]}); */
30095
+ function level3DAddPrefab(name, prefab) { level3DPrefabSet(name, prefab, false); }
30096
+
30097
+ // add a prefab, the game's or a level's own
30098
+ function level3DPrefabSet(name, prefab, fromLevel)
30099
+ {
30100
+ false&&ASSERT(!!prefab && typeof prefab === 'object', 'a prefab is an object, {objects: [...]}');
30101
+ name = String(name);
30102
+ const objects = isArray(prefab?.objects) ? prefab.objects.filter((o)=> o && typeof o === 'object') : [];
30103
+ // a copy, what the game or the editor does to its own afterwards does not change the instances made later
30104
+ level3DPrefabs.set(name, {attached: !!prefab?.attached, objects: JSON.parse(JSON.stringify(objects)), fromLevel,
30105
+ source: prefab});
30106
+ // a class of its own, so the level editor makes its instances again as it does a class's
30107
+ level3DTypes.set(name, {make: class extends Prefab3D { constructor(pos) { super(pos, name); } }, defaults: {},
30108
+ tileInfo: undefined});
30109
+ }
30110
+
30111
+ // add a level's own prefabs, each unless the game added one of that name or it is a plain type's name
30112
+ function level3DPrefabsAdd(prefabs)
30113
+ {
30114
+ if (!prefabs || typeof prefabs !== 'object' || isArray(prefabs)) return;
30115
+ for (const name in prefabs)
30116
+ {
30117
+ const known = level3DPrefabs.get(name);
30118
+ if (known ? !known.fromLevel : level3DTypes.has(name)) continue;
30119
+ prefabs[name] && typeof prefabs[name] === 'object' && level3DPrefabSet(name, prefabs[name], true);
30120
+ }
30121
+ }
30122
+
30123
+ /** Load a prefab from a file the level editor saved and add it
30124
+ * @param {string} name - The type its instances have in a level
30125
+ * @param {string} url
30126
+ * @return {Promise<void>}
30127
+ * @memberof Level3D */
30128
+ async function level3DLoadPrefab(name, url) { level3DAddPrefab(name, await fetchJSON(url)); }
30129
+
30130
+ // where an instance puts one of its prefab's objects: the object's own place, turn and size inside the instance's
30131
+ function level3DPrefabPartTransform(instance, object)
30132
+ {
30133
+ const pos = level3DVector(object.pos, vec3()), scale = level3DVector(object.scale, vec3(1));
30134
+ const rotation = level3DVector(object.rotation, vec3()).scale(PI / 180);
30135
+ // the two turns one after the other; the sizes multiply along each axis, which is exact for an instance sized
30136
+ // evenly or a part that is not turned, and as near as a box can be otherwise
30137
+ const turn = buildMatrix(vec3(), instance.rotation3D).multiply(buildMatrix(vec3(), rotation));
30138
+ return {pos: buildMatrix(instance.pos3D, instance.rotation3D, instance.scale3D).transformPoint(pos),
30139
+ rotation: turn.getRotation(), scale: scale.multiply(instance.scale3D)};
30140
+ }
30141
+
30142
+ /**
30143
+ * An instance of a prefab, what a prefab's type makes: a handle with no shape of its own, and its parts, what the
30144
+ * prefab's objects made
30145
+ * - Parts of a prefab that is not attached are objects of their own in the world: moving, turning or sizing the
30146
+ * handle puts them where it now says with its next update, or call placeParts; destroying it destroys them
30147
+ * - Parts of an attached prefab are its children and move with it as one body
30148
+ * @extends EngineObject3D
30149
+ * @memberof Level3D
30150
+ */
30151
+ class Prefab3D extends EngineObject3D
30152
+ {
30153
+ /** Create an instance of a prefab, made by its type: place one with a level or level3DSpawn
30154
+ * @param {Vector3} [pos3D]
30155
+ * @param {string} [prefabName] - A prefab added with level3DAddPrefab */
30156
+ constructor(pos3D, prefabName='')
30157
+ {
30158
+ super(pos3D);
30159
+ /** @property {string} - The prefab it is an instance of */
30160
+ this.prefabName = prefabName;
30161
+ /** @property {boolean} - Are its parts its children, moving with it as one body */
30162
+ this.attached = !!level3DPrefabs.get(prefabName)?.attached || level3DPrefabAttached > 0;
30163
+ /** @property {Array<any>} - What the prefab's objects made, in the prefab's order */
30164
+ this.parts = [];
30165
+ /** @property {Vector3} - From the prefab's origin to the handle, in the prefab's own space: nothing for
30166
+ * separate parts, and for an attached prefab the middle of the box around its parts, where its handle
30167
+ * is, as an object's place is the middle of its body */
30168
+ this.originOffset = vec3();
30169
+ this.partObjects = []; // the prefab's object of each part
30170
+ this.partsPlaced = ''; // where the handle was when its parts were last placed, undefined parts not made
30171
+ this.partsMade = false;
30172
+ }
30173
+
30174
+ /** Put the instance with its prefab's origin at a place, turned and sized as the handle is: where a level's
30175
+ * object or level3DSpawn says it goes; the handle of an attached prefab is then at the middle of its body
30176
+ * @param {Vector3} pos3D */
30177
+ placeAt(pos3D)
30178
+ {
30179
+ this.pos3D = pos3D.copy();
30180
+ this.placeParts();
30181
+ const o = this.originOffset;
30182
+ if (o.x || o.y || o.z)
30183
+ this.pos3D = buildMatrix(pos3D, this.rotation3D, this.scale3D).transformPoint(o);
30184
+ }
30185
+
30186
+ /** Put the parts where the handle is now, making them the first time; called by the handle's update when it
30187
+ * has moved, turned or changed size */
30188
+ placeParts()
30189
+ {
30190
+ if (this.destroyed) return;
30191
+ this.partsMade ||= (this.makeParts(), true);
30192
+ this.partsPlaced = this.placeKey();
30193
+ if (this.attached) return; // children follow by themselves
30194
+ this.parts.forEach((part, i)=>
30195
+ {
30196
+ if (!(part instanceof EngineObject3D)) return; // what a function made is where it was made
30197
+ const to = level3DPrefabPartTransform(this, this.partObjects[i]);
30198
+ part.rotation3D = to.rotation;
30199
+ part.scale3D = (level3DBaseScale.get(part) ?? vec3(1)).multiply(to.scale);
30200
+ // a prefab inside this one goes by its origin, which is not where an attached one's handle is
30201
+ part instanceof Prefab3D ? part.placeAt(to.pos) : part.pos3D = to.pos;
30202
+ });
30203
+ }
30204
+
30205
+ // the handle's place, turn and size as one text, to tell when it has changed
30206
+ placeKey()
30207
+ {
30208
+ const p = this.pos3D, r = this.rotation3D, s = this.scale3D;
30209
+ return [p.x, p.y, p.z, r.x, r.y, r.z, s.x, s.y, s.z].join();
30210
+ }
30211
+
30212
+ // make the parts, each where the instance puts it, or attached at its own place inside the instance
30213
+ makeParts()
30214
+ {
30215
+ const prefab = level3DPrefabs.get(this.prefabName);
30216
+ if (!prefab) return;
30217
+ if (level3DPrefabDepth >= 8)
30218
+ {
30219
+ debug && console.error(`level3DLoad: the prefab ${this.prefabName} holds itself, left out there`);
30220
+ return;
30221
+ }
30222
+ ++level3DPrefabDepth;
30223
+ this.attached && ++level3DPrefabAttached;
30224
+ try
30225
+ {
30226
+ const low = vec3(Infinity), high = vec3(-Infinity), children = [];
30227
+ for (const object of prefab.objects)
30228
+ {
30229
+ const local = {pos: level3DVector(object.pos, vec3()), scale: level3DVector(object.scale, vec3(1)),
30230
+ rotation: level3DVector(object.rotation, vec3()).scale(PI / 180)};
30231
+ const at = this.attached ? local : level3DPrefabPartTransform(this, object);
30232
+ const part = level3DMakeAt(object, at.pos, at.rotation, at.scale);
30233
+ if (!part) continue; // level3DMakeAt gives an object or nothing
30234
+ this.parts.push(part);
30235
+ this.partObjects.push(object);
30236
+ if (!this.attached || !(part instanceof EngineObject3D)) continue;
30237
+ // a child rides with its parent and has no collision of its own
30238
+ part.setCollision(false, false, false);
30239
+ children.push(part);
30240
+ // the corners of its box as it is turned; its place is its middle, a prefab inside this one too
30241
+ const box = buildMatrix(part.pos3D, part.rotation3D, part.size3D.multiply(part.scale3D));
30242
+ for (let i = 8; i--;)
30243
+ {
30244
+ const p = box.transformPoint(vec3(i & 1 ? .5 : -.5, i & 2 ? .5 : -.5, i & 4 ? .5 : -.5));
30245
+ for (const k of ['x', 'y', 'z'])
30246
+ low[k] = min(low[k], p[k]), high[k] = max(high[k], p[k]);
30247
+ }
30248
+ }
30249
+ // an attached instance is the box around its parts, its handle at the middle of it as an object's
30250
+ // place is the middle of its body, so a game that makes it solid has it solid where it is seen
30251
+ if (children.length)
30252
+ {
30253
+ this.size3D = high.subtract(low);
30254
+ this.originOffset = low.add(high).scale(.5);
30255
+ }
30256
+ for (const part of children)
30257
+ {
30258
+ part.pos3D = part.pos3D.subtract(this.originOffset);
30259
+ this.addChild(part);
30260
+ }
30261
+ }
30262
+ finally
30263
+ {
30264
+ --level3DPrefabDepth;
30265
+ this.attached && --level3DPrefabAttached;
30266
+ }
30267
+ }
30268
+
30269
+ /** Keep the parts with the handle, called automatically each frame */
30270
+ update()
30271
+ {
30272
+ super.update();
30273
+ if (!this.attached && this.partsPlaced !== this.placeKey())
30274
+ this.placeParts();
30275
+ }
30276
+
30277
+ /** Destroy the instance and its parts
30278
+ * @param {boolean} [immediate] */
30279
+ destroy(immediate)
30280
+ {
30281
+ if (this.destroyed) return;
30282
+ for (const part of this.parts)
30283
+ part.parent === this || part.destroy?.(immediate); // children go with their parent
30284
+ super.destroy(immediate);
30285
+ }
30286
+
30287
+ /** A prefab's instance has nothing of its own to draw */
30288
+ render3D() {}
30289
+ }
30290
+
28618
30291
  ///////////////////////////////////////////////////////////////////////////////
28619
30292
  // the built-in types
28620
30293
 
@@ -28638,9 +30311,776 @@ level3DAddType('Sphere', function(pos, properties)
28638
30311
  level3DAddType('Cylinder', function(pos, properties)
28639
30312
  { return level3DMakeShape(pos, properties, level3DCylinderMesh ||= buildCylinder()); },
28640
30313
  {color: WHITE, tile: -1, solid: true});
30314
+ // a light, a spotlight with a cone: cone is the angle in degrees from its forward out to the edge of its beam, the
30315
+ // object's rotation aims it, softness is how much of the cone fades, and shadows makes it the one that casts them
28641
30316
  level3DAddType('Light', function(pos, properties)
28642
- { return new Light3D(pos, properties.radius, properties.color, properties.intensity); },
28643
- {color: WHITE, radius: 5, intensity: 1});
30317
+ {
30318
+ const light = new Light3D(pos, properties.radius, properties.color, properties.intensity);
30319
+ light.coneAngle = max(properties.cone, 0) * PI / 180;
30320
+ light.coneSoftness = properties.softness;
30321
+ if (properties.shadows && render3D)
30322
+ render3D.shadowLight = light;
30323
+ return light;
30324
+ }, {color: WHITE, radius: 5, intensity: 1, cone: 0, softness: .2, shadows: false});
30325
+
30326
+ /**
30327
+ * LittleJS Particle Effects Plugin
30328
+ * - Ready made particle effects in one line, particleEffect('fire', pos), and particleEffect3D for 3D
30329
+ * - Every built-in effect is tuned around a one unit emitter; options.scale grows it to fit, options.hue and
30330
+ * options.saturation recolor it
30331
+ * - Effects are plain data, {name, settings, behaviors}, the format the particle designer saves: load a library the
30332
+ * designer saved with particleEffectsLoad and play its effects by name
30333
+ * - The built-in effects draw with a sheet of shapes the plugin draws itself, so they need no image
30334
+ * @namespace ParticleEffects
30335
+ */
30336
+
30337
+ ///////////////////////////////////////////////////////////////////////////////
30338
+ // settings, what an effect sets on an emitter
30339
+
30340
+ /** The groups the settings are in, in order
30341
+ * @type {Array<string>}
30342
+ * @memberof ParticleEffects */
30343
+ const particleEffectGroups = ['Emitter', 'Particles', 'Color', 'Motion', 'Collision', 'Texture', 'Behaviors'];
30344
+
30345
+ /** Every setting an effect has: its name, kind (number, checkbox, color or shape), default, the range the designer's
30346
+ * slider covers (min, max, step), the hard limits a typed value is clamped to, and whether it is set after the
30347
+ * emitter is made rather than passed to its constructor (extra)
30348
+ * @type {Array<{group:string, name:string, kind:string, value:any, min:number, max:number, step:number,
30349
+ * description:string, hardMin:number, hardMax:number, extra:boolean}>}
30350
+ * @memberof ParticleEffects */
30351
+ const particleEffectSettings = [];
30352
+ let particleEffectSettingGroup; // the group settings are being added to
30353
+
30354
+ // the slider covers min to max, typed values may go out to hardMin and hardMax
30355
+ function particleEffectAddSetting(name, kind, value, min, max, step, description, hardMin=min, hardMax=max)
30356
+ {
30357
+ // extras are not constructor arguments, the code export assigns them after
30358
+ const extra = ['trailScale', 'velocityInheritance', 'localSpace', 'restitution', 'friction', 'gravity'].includes(name);
30359
+ particleEffectSettings.push({group:particleEffectSettingGroup, name, kind, value, min, max, step, description,
30360
+ hardMin, hardMax, extra});
30361
+ }
30362
+
30363
+ particleEffectSettingGroup = 'Emitter';
30364
+ particleEffectAddSetting('emitRate', 'number', 100, 0, 500, .1,
30365
+ 'Particles per second, 0 emits none', 0, 1e4);
30366
+ particleEffectAddSetting('emitTime', 'number', 0, 0, 5, .05,
30367
+ 'Seconds to emit for, 0 is forever', 0, 1e9);
30368
+ particleEffectAddSetting('emitSize', 'number', 0, 0, 10, .05,
30369
+ 'Diameter, or width when rectangular', 0, 1e9);
30370
+ particleEffectAddSetting('emitRect', 'checkbox', false, 0, 0, 0,
30371
+ 'Rectangle emitter, off is a circle');
30372
+ particleEffectAddSetting('emitHeight', 'number', 0, 0, 10, .05,
30373
+ 'Rectangle height', 0, 1e9);
30374
+ particleEffectAddSetting('emitConeAngle', 'number', PI, 0, PI, .01,
30375
+ 'Half angle particles move in, 3.14 is all ways');
30376
+ particleEffectAddSetting('angle', 'number', 0, -3.14, 3.14, .01,
30377
+ 'Emitter angle, 0 points up', -PI, PI); // the slider steps from its min
30378
+
30379
+ particleEffectSettingGroup = 'Particles';
30380
+ particleEffectAddSetting('particleTime', 'number', .5, 0, 5, .05,
30381
+ 'Seconds each particle lives', 0, 1e9);
30382
+ particleEffectAddSetting('sizeStart', 'number', .1, 0, 5, .01,
30383
+ 'Size at the start of life', 0, 1e9);
30384
+ particleEffectAddSetting('sizeEnd', 'number', 1, 0, 5, .01,
30385
+ 'Size at the end of life', 0, 1e9);
30386
+ particleEffectAddSetting('fadeRate', 'number', .1, 0, 1, .01,
30387
+ 'Share of life spent fading in and out');
30388
+ particleEffectAddSetting('randomness', 'number', .2, 0, 1, .01,
30389
+ 'How much each particle varies');
30390
+ particleEffectAddSetting('particleConeAngle', 'number', PI, 0, PI, .01,
30391
+ 'Half angle of start rotation, 3.14 is any');
30392
+ particleEffectAddSetting('trailScale', 'number', 0, 0, 20, .1,
30393
+ 'Stretch along the motion, 0 is off', 0, 1e9);
30394
+ particleEffectAddSetting('additive', 'checkbox', false, 0, 0, 0,
30395
+ 'Glow with additive blending');
30396
+
30397
+ particleEffectSettingGroup = 'Color';
30398
+ particleEffectAddSetting('colorStartA', 'color', [1,1,1,1], 0, 1, .01,
30399
+ 'Start color, each particle picks between A and B');
30400
+ particleEffectAddSetting('colorStartB', 'color', [1,1,1,1], 0, 1, .01,
30401
+ 'Second start color');
30402
+ particleEffectAddSetting('colorEndA', 'color', [1,1,1,0], 0, 1, .01,
30403
+ 'End color, each particle picks between A and B');
30404
+ particleEffectAddSetting('colorEndB', 'color', [1,1,1,0], 0, 1, .01,
30405
+ 'Second end color');
30406
+ particleEffectAddSetting('randomColorLinear', 'checkbox', true, 0, 0, 0,
30407
+ 'Pick along A to B, off picks each channel');
30408
+
30409
+ particleEffectSettingGroup = 'Motion';
30410
+ particleEffectAddSetting('speed', 'number', .1, 0, .5, .005,
30411
+ 'Start speed, world units per frame', 0, 1e9);
30412
+ particleEffectAddSetting('angleSpeed', 'number', .05, 0, .5, .005,
30413
+ 'Spin speed, radians per frame', 0, 1e9);
30414
+ particleEffectAddSetting('damping', 'number', 1, .8, 1, .001,
30415
+ 'Speed kept each frame, 1 keeps it all', 0, 1);
30416
+ particleEffectAddSetting('angleDamping', 'number', 1, .8, 1, .001,
30417
+ 'Spin kept each frame', 0, 1);
30418
+ particleEffectAddSetting('gravityScale', 'number', 0, -2, 2, .05,
30419
+ 'How much gravity pulls', -1e9, 1e9);
30420
+ particleEffectAddSetting('gravity', 'number', 0, -.02, .02, .0005,
30421
+ 'Its own fall each frame, the same in any game', -1, 1);
30422
+ particleEffectAddSetting('velocityInheritance', 'number', 0, 0, 1, .01,
30423
+ 'Share of emitter motion passed on');
30424
+ particleEffectAddSetting('localSpace', 'checkbox', false, 0, 0, 0,
30425
+ 'Particles move with the emitter');
30426
+
30427
+ particleEffectSettingGroup = 'Collision';
30428
+ particleEffectAddSetting('collideLevel', 'checkbox', false, 0, 0, 0,
30429
+ 'Hit tiles, the preview adds a floor');
30430
+ particleEffectAddSetting('restitution', 'number', 0, 0, 1, .01,
30431
+ 'Bounce when hitting tiles');
30432
+ particleEffectAddSetting('friction', 'number', .8, 0, 1, .01,
30433
+ 'Speed kept sliding along tiles');
30434
+
30435
+ particleEffectSettingGroup = 'Texture';
30436
+ particleEffectAddSetting('shape', 'shape', 'soft', 0, 0, 0,
30437
+ 'Built-in shape, none uses the tile');
30438
+ particleEffectAddSetting('tileIndex', 'number', -1, -1, 63, 1,
30439
+ 'Tile in texture 0 when no shape, -1 is untextured', -1, 1e4);
30440
+ particleEffectAddSetting('tileSize', 'number', 16, 1, 128, 1,
30441
+ 'Tile size in texture pixels', 1, 4096);
30442
+ particleEffectAddSetting('tilePadding', 'number', 1, 0, 8, 1,
30443
+ 'Pixels of padding around each tile', 0, 64);
30444
+
30445
+ /** The behaviors an effect can use, each a push applied to every particle every update, with a 2D and a 3D version
30446
+ * @type {Array<{name:string, update:(Function|undefined), update3D:(Function|undefined), min:number, max:number,
30447
+ * value:number, description:string}>}
30448
+ * @memberof ParticleEffects */
30449
+ const particleEffectBehaviors =
30450
+ [
30451
+ {name:'wobble', min:0, max:5, value:1, description:'Sway from side to side'},
30452
+ {name:'swirl', min:-2, max:2, value:1, description:'Turn the direction of travel'},
30453
+ {name:'turbulence', min:0, max:5, value:1, description:'Random pushes every frame'},
30454
+ {name:'attract', min:-2, max:2, value:1, description:'Pull toward the emitter, negative pushes away'},
30455
+ {name:'orbit', min:-2, max:2, value:1, description:'Push around the emitter, add attract to circle it'},
30456
+ {name:'wind', min:-2, max:2, value:1, description:'A sideways push that grows with age'},
30457
+ {name:'stick', min:0, max:1, value:1, description:'Grip on landing, pair with collideLevel'},
30458
+ ].map(b=> ({...b, update: undefined, update3D: undefined}));
30459
+ const particleEffectBehavior = (name)=> particleEffectBehaviors.find(b=> b.name === name);
30460
+
30461
+ /** Names of the shapes the plugin draws, for an effect's shape setting
30462
+ * @type {Array<string>}
30463
+ * @memberof ParticleEffects */
30464
+ const particleEffectShapes = ['dot', 'soft', 'glow', 'smoke', 'spark', 'square', 'triangle', 'ring', 'star', 'plus'];
30465
+
30466
+ ///////////////////////////////////////////////////////////////////////////////
30467
+ // effects
30468
+
30469
+ // a color from an [r,g,b,a] array or a Color, undefined if it is neither
30470
+ function particleEffectColor(value)
30471
+ {
30472
+ if (value instanceof Color)
30473
+ value = [value.r, value.g, value.b, value.a];
30474
+ if (isArray(value) && value.length === 4 && value.every(isNumber))
30475
+ return value.map(c=> clamp(c));
30476
+ }
30477
+
30478
+ /** Any input into a whole effect: missing fields take their defaults, bad ones are dropped or clamped
30479
+ * @param {Object} [raw] - {name, settings, behaviors}, from a library file, the designer or code
30480
+ * @return {{name:string, settings:Object, behaviors:Array<{name:string, strength:number}>}}
30481
+ * @memberof ParticleEffects */
30482
+ function particleEffectSanitize(raw)
30483
+ {
30484
+ // one line of text, line breaks and control characters made spaces
30485
+ const text = typeof raw?.name === 'string' ? raw.name.replace(/[\x00-\x1f\x7f]+/g, ' ').trim() : '';
30486
+ const name = text ? text.slice(0, 60) : 'Effect';
30487
+ const input = raw?.settings && typeof raw.settings === 'object' ? {...raw.settings} : {};
30488
+ input.collideLevel ??= input.collideTiles; // its name before 1.20
30489
+ // a library saved before shapes names its tile and no shape, it keeps its tile
30490
+ if (input.shape === undefined && isNumber(input.tileIndex))
30491
+ input.shape = '';
30492
+ const settings = {};
30493
+ for (const setting of particleEffectSettings)
30494
+ {
30495
+ const value = input[setting.name], fallback = setting.value;
30496
+ if (setting.kind === 'checkbox')
30497
+ settings[setting.name] = typeof value === 'boolean' ? value : value === 0 || value === 1 ? !!value : fallback;
30498
+ else if (setting.kind === 'color')
30499
+ settings[setting.name] = particleEffectColor(value) || fallback.slice();
30500
+ else if (setting.kind === 'shape')
30501
+ settings[setting.name] = value === '' || particleEffectShapes.includes(value) ? value : fallback;
30502
+ else if (isNumber(value))
30503
+ {
30504
+ // an angle past half a turn comes around, a clamp would point it the other way
30505
+ const wraps = setting.name === 'angle' && isFinite(value) && abs(value) > PI;
30506
+ const clamped = clamp(wraps ? mod(value + PI, 2*PI) - PI : value, setting.hardMin, setting.hardMax);
30507
+ settings[setting.name] = setting.step >= 1 ? round(clamped) : clamped;
30508
+ }
30509
+ else
30510
+ settings[setting.name] = fallback;
30511
+ }
30512
+
30513
+ // local space particles are placed relative to the emitter, the tile collision is in the world
30514
+ if (settings.localSpace)
30515
+ settings.collideLevel = false;
30516
+
30517
+ // known behaviors once each, in table order
30518
+ const behaviors = [];
30519
+ const list = isArray(raw?.behaviors) ? raw.behaviors : [];
30520
+ for (const b of particleEffectBehaviors)
30521
+ {
30522
+ const found = list.find(x=> x?.name === b.name);
30523
+ if (!found)
30524
+ continue;
30525
+ const strength = isNumber(found.strength) ? clamp(found.strength, b.min, b.max) : b.value;
30526
+ behaviors.push({name:b.name, strength});
30527
+ }
30528
+
30529
+ // behaviors not known yet stay, a game may add them after the effect is loaded
30530
+ for (const x of list)
30531
+ if (typeof x?.name === 'string' && x.name && !particleEffectBehavior(x.name) &&
30532
+ !behaviors.some(b=> b.name === x.name))
30533
+ behaviors.push({name:x.name, strength: isNumber(x.strength) ? x.strength : 1});
30534
+ return {name, settings, behaviors};
30535
+ }
30536
+
30537
+ /** A copy of an effect with its four colors turned around the color wheel and their saturation scaled; lightness and
30538
+ * alpha stay, and grey and white have no hue to turn
30539
+ * @param {Object} effect
30540
+ * @param {number} [hue] - How far around the wheel, 1 is all the way
30541
+ * @param {number} [saturation] - Multiplies the saturation, 0 is grey, clamped to 1
30542
+ * @return {Object}
30543
+ * @memberof ParticleEffects */
30544
+ function particleEffectRecolor(effect, hue=0, saturation=1)
30545
+ {
30546
+ effect = particleEffectSanitize(effect); // colors as channels, whatever it was given
30547
+ const settings = {...effect.settings};
30548
+ for (const name of ['colorStartA', 'colorStartB', 'colorEndA', 'colorEndB'])
30549
+ {
30550
+ const [h, s, l, a] = new Color(...settings[name]).HSLA();
30551
+ const c = new Color().setHSLA(mod(h + hue), clamp(s * saturation), l, a);
30552
+ settings[name] = [c.r, c.g, c.b, c.a];
30553
+ }
30554
+ return {...effect, settings, behaviors: effect.behaviors.map(b=> ({...b}))};
30555
+ }
30556
+
30557
+ ///////////////////////////////////////////////////////////////////////////////
30558
+ // libraries
30559
+
30560
+ // the effects by lower case name, the built-ins and any a game adds or loads
30561
+ const particleEffectsByName = new Map;
30562
+
30563
+ /** Add effects to play by name, sanitized; one with the name of an effect already there replaces it
30564
+ * @param {Array<Object>|Object} effects
30565
+ * @memberof ParticleEffects */
30566
+ function particleEffectsAdd(effects)
30567
+ {
30568
+ for (const effect of isArray(effects) ? effects : [effects])
30569
+ {
30570
+ const clean = particleEffectSanitize(effect);
30571
+ particleEffectsByName.set(clean.name.toLowerCase(), clean);
30572
+ }
30573
+ }
30574
+
30575
+ /** The effect with a name, any case, undefined if there is none
30576
+ * @param {string} name
30577
+ * @return {Object|undefined}
30578
+ * @memberof ParticleEffects */
30579
+ function particleEffectsGet(name) { return particleEffectsByName.get(String(name).toLowerCase()); }
30580
+
30581
+ /** A library file, or one effect, into sanitized effects; throws on bad input
30582
+ * @param {string} text
30583
+ * @return {Array<Object>}
30584
+ * @memberof ParticleEffects */
30585
+ function particleEffectsParse(text) { return particleEffectsFromData(JSON.parse(text)); }
30586
+
30587
+ // a library file's data, or one effect's, into sanitized effects
30588
+ function particleEffectsFromData(data)
30589
+ {
30590
+ const list = isArray(data?.effects) ? data.effects : data?.settings ? [data] : undefined;
30591
+ if (!list || !list.length)
30592
+ throw new Error('no effects in this file');
30593
+ return list.map(particleEffectSanitize);
30594
+ }
30595
+
30596
+ /** The text of a library file with these effects, what particleEffectsLoad and the designer read
30597
+ * @param {Array<Object>} effects
30598
+ * @return {string}
30599
+ * @memberof ParticleEffects */
30600
+ function particleEffectsText(effects) { return JSON.stringify({version:1, effects}, undefined, 1); }
30601
+
30602
+ /** Load a library file the particle designer saved and add its effects
30603
+ * @param {string} url
30604
+ * @return {Promise<Array<Object>>} - The effects it had
30605
+ * @memberof ParticleEffects */
30606
+ async function particleEffectsLoad(url)
30607
+ {
30608
+ const effects = particleEffectsFromData(await fetchJSON(url));
30609
+ particleEffectsAdd(effects);
30610
+ return effects;
30611
+ }
30612
+
30613
+ ///////////////////////////////////////////////////////////////////////////////
30614
+ // the shape sheet: every shape white on clear in a cell of one canvas, drawn the first time one is asked for
30615
+
30616
+ let particleEffectShapeTiles;
30617
+
30618
+ /** The tile of a built-in shape, on a sheet the plugin draws once; undefined headless, without a canvas or without
30619
+ * WebGL, where an effect draws untextured squares
30620
+ * @param {string} name - One of particleEffectShapes
30621
+ * @return {TileInfo|undefined}
30622
+ * @memberof ParticleEffects */
30623
+ function particleEffectShapeTile(name)
30624
+ {
30625
+ if (!particleEffectShapeTiles)
30626
+ {
30627
+ if (headlessMode || !glContext || typeof OffscreenCanvas == 'undefined') return;
30628
+ // drawn at 4 times 32 pixel cells, so a big soft shape stays smooth when textures are pixelated
30629
+ const cell = 32, r = 15, count = particleEffectShapes.length, res = 4;
30630
+ const context = createCanvasContext(cell * count * res, cell * res);
30631
+ context.scale(res, res);
30632
+ context.fillStyle = context.strokeStyle = '#fff';
30633
+ const soft = (x, y, radius, alpha=(t)=> 1 - t)=>
30634
+ {
30635
+ // a round falloff from the middle out
30636
+ const g = context.createRadialGradient(x, y, 0, x, y, radius);
30637
+ for (let i = 0; i <= 8; ++i)
30638
+ g.addColorStop(i/8, 'rgba(255,255,255,' + alpha(i/8).toFixed(3) + ')');
30639
+ context.fillStyle = g;
30640
+ context.beginPath(); context.arc(x, y, radius, 0, 2*PI); context.fill();
30641
+ context.fillStyle = '#fff';
30642
+ };
30643
+ const draw =
30644
+ {
30645
+ dot: (x, y)=> { context.beginPath(); context.arc(x, y, r, 0, 2*PI); context.fill(); },
30646
+ soft: (x, y)=> soft(x, y, r),
30647
+ glow: (x, y)=> soft(x, y, r, (t)=> engineGlowAlpha(t, 1)),
30648
+ smoke: (x, y)=>
30649
+ {
30650
+ // a lumpy puff of soft circles
30651
+ for (const [dx, dy, dr] of [[-4,-3,9], [4,-2,8], [0,4,9], [-5,5,6], [6,5,6]])
30652
+ soft(x + dx, y + dy, dr, (t)=> (1 - t) * .7);
30653
+ },
30654
+ spark: (x, y)=>
30655
+ {
30656
+ // a glow squeezed thin, a streak along the tile's up
30657
+ context.save(); context.translate(x, y); context.scale(.3, 1);
30658
+ soft(0, 0, r, (t)=> engineGlowAlpha(t, .6));
30659
+ context.restore();
30660
+ },
30661
+ square: (x, y)=> context.fillRect(x - r, y - r, 2*r, 2*r),
30662
+ triangle: (x, y)=>
30663
+ {
30664
+ context.beginPath(); context.moveTo(x, y - r); context.lineTo(x + r, y + r); context.lineTo(x - r, y + r);
30665
+ context.fill();
30666
+ },
30667
+ ring: (x, y)=>
30668
+ {
30669
+ context.lineWidth = 3;
30670
+ context.beginPath(); context.arc(x, y, r - 2, 0, 2*PI); context.stroke();
30671
+ },
30672
+ star: (x, y)=>
30673
+ {
30674
+ context.beginPath();
30675
+ for (let i = 0; i < 10; ++i)
30676
+ {
30677
+ const a = i * PI / 5 - PI/2, d = i % 2 ? r * .45 : r;
30678
+ context.lineTo(x + cos(a) * d, y + sin(a) * d);
30679
+ }
30680
+ context.fill();
30681
+ },
30682
+ plus: (x, y)=> { context.fillRect(x - r, y - 4, 2*r, 8); context.fillRect(x - 4, y - r, 8, 2*r); },
30683
+ };
30684
+ particleEffectShapes.forEach((name, i)=> draw[name](i * cell + cell/2, cell/2));
30685
+ const texture = new TextureInfo(context.canvas, true, false, false); // smooth even in a pixel art game
30686
+ particleEffectShapeTiles = new Map(particleEffectShapes.map((name, i)=>
30687
+ [name, new TileInfo(vec2((i * cell + 1) * res, res), vec2((cell - 2) * res), texture)]));
30688
+ }
30689
+ return particleEffectShapeTiles.get(name);
30690
+ }
30691
+
30692
+ ///////////////////////////////////////////////////////////////////////////////
30693
+ // behaviors, the 2D pushes: a distance moved grows with the particle's scale, so a bigger effect moves the same way
30694
+
30695
+ {
30696
+ const b = particleEffectBehavior;
30697
+ b('wobble').update = (p, s)=>
30698
+ {
30699
+ // each particle on its own beat, from its randomized life, so nothing is added to it
30700
+ p.velocity.x += s * .003 * p.scale * sin(time*6 + p.lifeTime * 97 % 9);
30701
+ };
30702
+ b('swirl').update = (p, s)=>
30703
+ {
30704
+ // turn the direction of travel a little each frame
30705
+ const a = s * .05, c = cos(a), n = sin(a), v = p.velocity;
30706
+ v.set(v.x*c - v.y*n, v.x*n + v.y*c);
30707
+ };
30708
+ b('turbulence').update = (p, s)=>
30709
+ {
30710
+ p.velocity.x += rand(-1, 1) * s * .005 * p.scale;
30711
+ p.velocity.y += rand(-1, 1) * s * .005 * p.scale;
30712
+ };
30713
+ b('attract').update = (p, s)=>
30714
+ {
30715
+ // pull toward the emitter by the distance, which grows with the scale already
30716
+ const e = p.emitter, x = e.localSpace ? 0 : e.pos.x, y = e.localSpace ? 0 : e.pos.y;
30717
+ p.velocity.x += (x - p.pos.x) * s * .002;
30718
+ p.velocity.y += (y - p.pos.y) * s * .002;
30719
+ };
30720
+ b('orbit').update = (p, s)=>
30721
+ {
30722
+ const e = p.emitter, x = e.localSpace ? 0 : e.pos.x, y = e.localSpace ? 0 : e.pos.y;
30723
+ p.velocity.x -= (p.pos.y - y) * s * .002;
30724
+ p.velocity.y += (p.pos.x - x) * s * .002;
30725
+ };
30726
+ b('wind').update = (p, s)=>
30727
+ p.velocity.x += s * .004 * p.scale * min((time - p.spawnTime) / p.lifeTime, 1);
30728
+ b('stick').update = (p, s)=>
30729
+ {
30730
+ // grip the ground on landing, pair with collideLevel
30731
+ if (!p.groundObject) return;
30732
+ p.velocity.x *= 1 - s;
30733
+ p.angleVelocity *= 1 - s;
30734
+ };
30735
+ }
30736
+
30737
+ /** Add a behavior effects can use by name, or replace one
30738
+ * @param {string} name
30739
+ * @param {function(Particle, number): void} update - Pushes a 2D particle, given the strength
30740
+ * @param {function(Particle3D, number): void} [update3D] - The same for a 3D particle, none leaves 3D alone
30741
+ * @param {number} [min] - Strength range the designer offers
30742
+ * @param {number} [max]
30743
+ * @param {number} [value] - Strength when first added
30744
+ * @param {string} [description]
30745
+ * @memberof ParticleEffects */
30746
+ function particleEffectsAddBehavior(name, update, update3D, min=-2, max=2, value=1, description='')
30747
+ {
30748
+ const old = particleEffectBehavior(name);
30749
+ old && particleEffectBehaviors.splice(particleEffectBehaviors.indexOf(old), 1);
30750
+ particleEffectBehaviors.push({name, update, update3D, min, max, value, description});
30751
+ }
30752
+
30753
+ // the update callback that runs an effect's behaviors, in 2D or 3D, undefined when it has none for that
30754
+ function particleEffectUpdateCallback(behaviors, is3D)
30755
+ {
30756
+ const calls = behaviors.map(b=> [particleEffectBehavior(b.name)?.[is3D ? 'update3D' : 'update'], b.strength])
30757
+ .filter(c=> c[0]);
30758
+ if (calls.length)
30759
+ return (p)=> { for (const [update, strength] of calls) update(p, strength); };
30760
+ }
30761
+
30762
+ ///////////////////////////////////////////////////////////////////////////////
30763
+ // 2D effects
30764
+
30765
+ // the settings the builders set themselves instead of copying across
30766
+ const particleEffectIndirect = ['emitSize', 'emitRect', 'emitHeight', 'tileIndex', 'tileSize', 'tilePadding', 'shape'];
30767
+
30768
+ // the tile an effect draws with: its shape, else its tile in texture 0 when that fits, else none
30769
+ function particleEffectTileInfo(s)
30770
+ {
30771
+ if (s.shape)
30772
+ return particleEffectShapeTile(s.shape);
30773
+ if (s.tileIndex < 0) return;
30774
+ const texture = textureInfos[0], cell = s.tileSize + s.tilePadding*2;
30775
+ if (!texture || !texture.size.x) return;
30776
+ if (s.tileIndex < floor(texture.size.x / cell) * floor(texture.size.y / cell))
30777
+ return tile(s.tileIndex, s.tileSize, 0, s.tilePadding);
30778
+ }
30779
+
30780
+ /** Set a 2D emitter to an effect, live, so a running one keeps its particles
30781
+ * @param {ParticleEmitter} emitter
30782
+ * @param {Object} effect
30783
+ * @memberof ParticleEffects */
30784
+ function particleEffectApply(emitter, effect)
30785
+ {
30786
+ // an effect written by hand or changed since it was added
30787
+ particleEffectSet(emitter, particleEffectSanitize(effect));
30788
+ }
30789
+
30790
+ // set a 2D emitter to an effect that is sanitized already, as a play's is
30791
+ function particleEffectSet(emitter, effect)
30792
+ {
30793
+ const s = effect.settings, ended = emitter.emitTime < 0; // destroyed, its last particles going
30794
+ for (const setting of particleEffectSettings)
30795
+ if (!particleEffectIndirect.includes(setting.name))
30796
+ emitter[setting.name] = setting.kind === 'color' ? new Color(...s[setting.name]) : s[setting.name];
30797
+ ended && (emitter.emitTime = -1);
30798
+ emitter.emitCircle = !s.emitRect;
30799
+ emitter.emitSize = vec2(s.emitSize, s.emitRect ? s.emitHeight : s.emitSize);
30800
+ emitter.tileInfo = particleEffectTileInfo(s);
30801
+ emitter.renderOrder = s.additive ? 1e9 : 0;
30802
+ emitter.particleUpdateCallback = particleEffectUpdateCallback(effect.behaviors);
30803
+ }
30804
+
30805
+ // an effect from a name or an effect, recolored by the options
30806
+ function particleEffectResolve(nameOrEffect, options)
30807
+ {
30808
+ const found = typeof nameOrEffect == 'string' ? particleEffectsGet(nameOrEffect) : nameOrEffect;
30809
+ false&&ASSERT(!!found, 'no particle effect named ' + nameOrEffect);
30810
+ if (!found) return;
30811
+ // any setting in the options replaces the effect's own for this play, emitTime for a burst or a loop
30812
+ const settings = {...found.settings};
30813
+ for (const setting of particleEffectSettings)
30814
+ if (options[setting.name] !== undefined)
30815
+ settings[setting.name] = options[setting.name];
30816
+ // sanitized each time, an effect may be written by hand or changed after it was added
30817
+ const effect = particleEffectSanitize({...found, settings}), {hue=0, saturation=1} = options;
30818
+ return hue || saturation != 1 ? particleEffectRecolor(effect, hue, saturation) : effect;
30819
+ }
30820
+
30821
+ /** Play an effect: a 2D emitter set to it, placed, scaled and recolored
30822
+ * - A continuous effect (fire, a torch) goes until destroyed or given an emitTime, a one-shot ends itself
30823
+ * - Any setting in the options replaces the effect's own for this play: {emitTime: .5} for a burst of a continuous
30824
+ * effect, {emitTime: 0, emitRate: 30} to keep a one-shot going, or speed, particleTime and the rest
30825
+ * - Attach it to an object with addChild to follow it
30826
+ * @param {string|Object} nameOrEffect - A built-in or added effect's name, or an effect
30827
+ * @param {Vector2} [pos]
30828
+ * @param {Object} [options] - What to change for this play, each left out when not wanted:
30829
+ * scale grows the whole effect, the built-ins fit a one unit object at 1;
30830
+ * hue turns its colors around the color wheel, 1 is all the way;
30831
+ * saturation multiplies its saturation, 0 is grey;
30832
+ * angle is its direction, 0 is up, the effect's own angle when not given;
30833
+ * tileInfo, a TileInfo or a TextureInfo, is the game's own art to draw with in place of the effect's shape,
30834
+ * tinted by its colors, a whole texture drawn as one tile;
30835
+ * and any effect setting by its name, emitTime, emitRate, speed and the rest, replaces the effect's own
30836
+ * @return {ParticleEmitter|undefined} - undefined when there is no such effect
30837
+ * @memberof ParticleEffects */
30838
+ function particleEffect(nameOrEffect, pos=vec2(), options={})
30839
+ {
30840
+ const effect = particleEffectResolve(nameOrEffect, options);
30841
+ if (!effect) return;
30842
+ const emitter = new ParticleEmitter(pos.copy());
30843
+ particleEffectSet(emitter, effect);
30844
+ emitter.scale = options.scale ?? 1;
30845
+ if (options.angle !== undefined)
30846
+ emitter.angle = options.angle;
30847
+ const tileInfo = options.tileInfo; // the game's own art in place of the shape, a whole texture as one tile
30848
+ if (tileInfo)
30849
+ emitter.tileInfo = tileInfo instanceof TextureInfo ? new TileInfo(vec2(), tileInfo.size, tileInfo) : tileInfo;
30850
+ return emitter;
30851
+ }
30852
+
30853
+ /** An effect with a 2D emitter's settings, to save, build again, or build in 3D with particleEffect3D; its tile is
30854
+ * left out, since a hand made emitter's tile is its own texture and not one an effect can name, and so is its
30855
+ * scale, which an effect does not keep: pass it again with options.scale
30856
+ * @param {ParticleEmitter} emitter
30857
+ * @param {string} [name]
30858
+ * @return {Object}
30859
+ * @memberof ParticleEffects */
30860
+ function particleEffectFromEmitter(emitter, name='Effect')
30861
+ {
30862
+ const settings = {};
30863
+ for (const setting of particleEffectSettings)
30864
+ if (!particleEffectIndirect.includes(setting.name) && emitter[setting.name] !== undefined)
30865
+ settings[setting.name] = emitter[setting.name]; // a Color is taken as its channels by the sanitizer
30866
+ settings.emitRect = !emitter.emitCircle;
30867
+ settings.emitSize = emitter.emitSize.x;
30868
+ settings.emitHeight = emitter.emitSize.y;
30869
+ settings.shape = '';
30870
+ settings.tileIndex = -1;
30871
+ return particleEffectSanitize({name, settings});
30872
+ }
30873
+
30874
+ ///////////////////////////////////////////////////////////////////////////////
30875
+ // 3D effects
30876
+
30877
+ // the 3D pushes: a 3D particle is one view object the emitter reuses, so what differs per particle comes from its
30878
+ // randomized lifetime, and distances grow with the emitter's scale
30879
+ {
30880
+ const b = particleEffectBehavior;
30881
+ const center = (p)=> p.emitter.worldPos3D || p.emitter.pos3D; // where it was this update
30882
+ b('wobble').update3D = (p, s)=> p.velocity.x += s * .003 * p.scale * sin(time*6 + p.lifeTime * 97 % 9);
30883
+ b('swirl').update3D = (p, s)=>
30884
+ {
30885
+ const a = s * .05, c = cos(a), n = sin(a), v = p.velocity;
30886
+ v.set(v.x*c - v.y*n, v.x*n + v.y*c, v.z);
30887
+ };
30888
+ b('turbulence').update3D = (p, s)=>
30889
+ {
30890
+ const k = s * .005 * p.scale;
30891
+ p.velocity.x += rand(-1, 1) * k, p.velocity.y += rand(-1, 1) * k, p.velocity.z += rand(-1, 1) * k;
30892
+ };
30893
+ b('attract').update3D = (p, s)=>
30894
+ {
30895
+ const c = center(p);
30896
+ p.velocity.x += (c.x - p.pos.x) * s * .002;
30897
+ p.velocity.y += (c.y - p.pos.y) * s * .002;
30898
+ p.velocity.z += (c.z - p.pos.z) * s * .002;
30899
+ };
30900
+ b('orbit').update3D = (p, s)=>
30901
+ {
30902
+ const c = center(p);
30903
+ p.velocity.x -= (p.pos.y - c.y) * s * .002;
30904
+ p.velocity.y += (p.pos.x - c.x) * s * .002;
30905
+ };
30906
+ b('wind').update3D = (p, s)=> p.velocity.x += s * .004 * p.scale * min(p.age / p.lifeTime, 1);
30907
+ // stick has no 3D push: particleEffectApply3D makes it the emitter's stick, its grip on landing
30908
+ }
30909
+
30910
+ /** Set a 3D emitter to an effect, live, so a running one keeps its particles; its place, scale and flatten stay
30911
+ * @param {ParticleEmitter3D} emitter
30912
+ * @param {Object} effect
30913
+ * @memberof ParticleEffects */
30914
+ function particleEffectApply3D(emitter, effect)
30915
+ {
30916
+ // an effect written by hand or changed since it was added
30917
+ particleEffectSet3D(emitter, particleEffectSanitize(effect));
30918
+ }
30919
+
30920
+ // set a 3D emitter to an effect that is sanitized already, as a play's is
30921
+ function particleEffectSet3D(emitter, effect)
30922
+ {
30923
+ const s = effect.settings, e = emitter;
30924
+ // a circle is a sphere and a rectangle a box as deep as it is wide
30925
+ e.emitSize = s.emitRect ? vec3(s.emitSize, s.emitHeight, s.emitSize) : s.emitSize;
30926
+ e.emitTime = e.emitTime < 0 ? -1 : s.emitTime; // destroyed, its last particles going, stays so
30927
+ e.emitRate = s.emitRate, e.emitConeAngle = s.emitConeAngle;
30928
+ e.tileInfo = particleEffectTileInfo(s);
30929
+ for (const name of ['colorStartA', 'colorStartB', 'colorEndA', 'colorEndB'])
30930
+ e[name] = new Color(...s[name]);
30931
+ for (const name of ['particleTime', 'sizeStart', 'sizeEnd', 'speed', 'damping', 'gravity', 'fadeRate',
30932
+ 'randomness', 'additive', 'gravityScale', 'angleSpeed', 'angleDamping', 'collideLevel', 'restitution'])
30933
+ e[name] = s[name];
30934
+ e.trailTime = s.trailScale / 60; // a stretch of speed times trailScale is a streak of that many frames
30935
+ e.friction = s.friction;
30936
+ e.stick = effect.behaviors.find(b=> b.name == 'stick')?.strength ?? 0; // in 3D the emitter grips on landing
30937
+ // 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
30938
+ e.rotation3D = vec3(0, 0, -s.angle);
30939
+ e.particleUpdateCallback = particleEffectUpdateCallback(effect.behaviors, true);
30940
+ }
30941
+
30942
+ /** Play an effect in 3D: a ParticleEmitter3D set to it, placed, scaled and recolored
30943
+ * - The same effect data as particleEffect, so the look carries across: a circle spawn area becomes a sphere and a
30944
+ * rectangle a box as deep as it is wide, both flat across the way it emits with options.flatten, a disc or a
30945
+ * sheet; a trail becomes a streak of the same length, and the settings the 3D emitter lacks (particleConeAngle,
30946
+ * randomColorLinear, velocityInheritance, localSpace) are left out; the stick behavior becomes the emitter's
30947
+ * stick, its grip where a particle lands
30948
+ * @param {string|Object} nameOrEffect - A built-in or added effect's name, or an effect
30949
+ * @param {Vector3} [pos3D]
30950
+ * @param {Object} [options] - scale, hue, saturation, angle, tileInfo and any setting as particleEffect; angle turns it
30951
+ * about z, so 0 is up; flatten makes the spawn area flat across the way it emits
30952
+ * @return {ParticleEmitter3D|undefined} - undefined when there is no such effect
30953
+ * @memberof ParticleEffects */
30954
+ function particleEffect3D(nameOrEffect, pos3D=vec3(), options={})
30955
+ {
30956
+ const effect = particleEffectResolve(nameOrEffect, options);
30957
+ if (!effect) return;
30958
+ const e = new ParticleEmitter3D(pos3D.copy());
30959
+ particleEffectSet3D(e, effect);
30960
+ if (options.angle !== undefined)
30961
+ e.rotation3D = vec3(0, 0, -options.angle);
30962
+ e.scale3D = vec3(options.scale ?? 1);
30963
+ e.emitFlat = !!options.flatten; // a sphere a disc, a box a sheet, across the way it emits
30964
+ if (options.tileInfo)
30965
+ e.tileInfo = options.tileInfo;
30966
+ return e;
30967
+ }
30968
+
30969
+ ///////////////////////////////////////////////////////////////////////////////
30970
+ // the built-in effects, each tuned around a one unit emitter; one-shots have an emitTime and end themselves
30971
+
30972
+ /** The names of the built-in effects, in the order a gallery shows them
30973
+ * @type {Array<string>}
30974
+ * @memberof ParticleEffects */
30975
+ const particleEffectsBuiltIn = [];
30976
+ {
30977
+ const add = (name, settings, behaviors=[])=>
30978
+ {
30979
+ particleEffectsBuiltIn.push(name);
30980
+ particleEffectsAdd({name, settings, behaviors});
30981
+ };
30982
+ const clear = (h, s, l)=> hsl(h, s, l, 0);
30983
+
30984
+ // fire
30985
+ add('fire', {emitRate:120, emitSize:.6, emitConeAngle:.3, shape:'glow', particleTime:.8, sizeStart:.7,
30986
+ sizeEnd:.15, speed:.02, angleSpeed:.02, damping:.95, gravity:.003, fadeRate:.3, randomness:.3, additive:true,
30987
+ colorStartA:hsl(.08,1,.6), colorStartB:hsl(.14,1,.65), colorEndA:clear(.02,1,.5), colorEndB:clear(0,1,.3)});
30988
+ add('torch', {emitRate:60, emitSize:.25, emitConeAngle:.2, shape:'glow', particleTime:.6, sizeStart:.45,
30989
+ sizeEnd:.1, speed:.015, angleSpeed:.02, damping:.95, gravity:.003, fadeRate:.3, randomness:.3, additive:true,
30990
+ colorStartA:hsl(.09,1,.6), colorStartB:hsl(.13,1,.7), colorEndA:clear(.03,1,.5), colorEndB:clear(0,1,.35)});
30991
+ add('smoke', {emitRate:25, emitSize:.5, emitConeAngle:.3, shape:'smoke', particleTime:3, sizeStart:.5,
30992
+ sizeEnd:2, speed:.01, angleSpeed:.01, damping:.99, gravity:.0004, fadeRate:.5, randomness:.3,
30993
+ colorStartA:hsl(0,0,.5,.5), colorStartB:hsl(0,0,.3,.5), colorEndA:clear(0,0,.2), colorEndB:clear(0,0,.4)},
30994
+ [{name:'wobble', strength:.5}]);
30995
+ add('steam', {emitRate:30, emitSize:.4, emitConeAngle:.2, shape:'smoke', particleTime:1.5, sizeStart:.3,
30996
+ sizeEnd:1.5, speed:.02, damping:.98, gravity:.0008, fadeRate:.6, randomness:.3,
30997
+ colorStartA:hsl(0,0,1,.4), colorStartB:hsl(.6,.2,.9,.3), colorEndA:clear(0,0,1), colorEndB:clear(.6,.2,.9)},
30998
+ [{name:'wobble', strength:.3}]);
30999
+
31000
+ // impacts
31001
+ add('explosion', {emitRate:600, emitTime:.1, emitSize:.5, shape:'glow', particleTime:.6, sizeStart:1,
31002
+ sizeEnd:.1, speed:.1, damping:.9, fadeRate:.2, randomness:.5, additive:true,
31003
+ colorStartA:hsl(.12,1,.65), colorStartB:hsl(.06,1,.55), colorEndA:clear(0,1,.5), colorEndB:clear(0,0,.3)});
31004
+ add('sparks', {emitRate:100, emitSize:.2, shape:'soft', particleTime:.6, sizeStart:.08, sizeEnd:.04,
31005
+ speed:.07, damping:.96, gravity:-.006, fadeRate:.1, randomness:.4, additive:true, trailScale:3,
31006
+ colorStartA:hsl(.17,1,.9), colorStartB:hsl(.12,1,.65), colorEndA:clear(.07,1,.5), colorEndB:clear(.03,1,.5)});
31007
+ add('hit', {emitRate:400, emitTime:.05, emitSize:.2, shape:'spark', particleTime:.35, sizeStart:.5,
31008
+ sizeEnd:.1, speed:.08, damping:.85, fadeRate:.1, randomness:.4, additive:true, trailScale:2,
31009
+ colorStartA:hsl(0,0,1), colorStartB:hsl(0,1,.7), colorEndA:clear(0,1,.5), colorEndB:clear(0,1,.4)});
31010
+ add('dust', {emitRate:300, emitTime:.05, emitSize:1, emitRect:true, emitHeight:.1, emitConeAngle:1.2,
31011
+ shape:'smoke', particleTime:.8, sizeStart:.3, sizeEnd:.8, speed:.04, damping:.9, gravity:.0005,
31012
+ fadeRate:.5, randomness:.4, colorStartA:hsl(.1,.3,.6,.5), colorStartB:hsl(.08,.2,.5,.5),
31013
+ colorEndA:clear(.1,.3,.6), colorEndB:clear(.08,.2,.5)});
31014
+ add('debris', {emitRate:200, emitTime:.05, emitSize:.5, emitConeAngle:1, shape:'square', particleTime:1.5,
31015
+ sizeStart:.15, sizeEnd:.1, speed:.07, angleSpeed:.2, damping:.99, gravity:-.008, fadeRate:.1,
31016
+ randomness:.5, colorStartA:hsl(.08,.4,.35), colorStartB:hsl(0,0,.4), colorEndA:hsl(.08,.4,.3),
31017
+ colorEndB:hsl(0,0,.35)});
31018
+
31019
+ // pickups and magic
31020
+ add('sparkle', {emitRate:30, emitSize:1, shape:'star', particleTime:.8, sizeStart:.3, sizeEnd:0,
31021
+ speed:.005, angleSpeed:.05, fadeRate:.5, randomness:.3, additive:true,
31022
+ colorStartA:hsl(.14,1,.7), colorStartB:hsl(0,0,1), colorEndA:clear(.14,1,.7), colorEndB:clear(0,0,1)});
31023
+ add('magic', {emitRate:60, emitSize:1, shape:'soft', particleTime:1.5, sizeStart:.25, sizeEnd:0,
31024
+ speed:.015, fadeRate:.3, randomness:.3, additive:true, colorStartA:hsl(.55,1,.65),
31025
+ colorStartB:hsl(.79,1,.65), colorEndA:clear(0,0,1), colorEndB:clear(0,0,1)}, [{name:'swirl', strength:.5}]);
31026
+ add('heal', {emitRate:30, emitSize:.8, emitConeAngle:.2, shape:'plus', particleTime:1.2, sizeStart:.25,
31027
+ sizeEnd:.1, speed:.015, angleSpeed:0, particleConeAngle:0, gravity:.0005, fadeRate:.4, randomness:.3,
31028
+ additive:true, colorStartA:hsl(.33,1,.6), colorStartB:hsl(.3,1,.75), colorEndA:clear(.33,1,.6),
31029
+ colorEndB:clear(.3,1,.75)});
31030
+ add('poison', {emitRate:20, emitSize:.8, shape:'smoke', particleTime:2, sizeStart:.3, sizeEnd:1,
31031
+ speed:.006, gravity:.0002, fadeRate:.6, randomness:.4, colorStartA:hsl(.3,.8,.45,.6),
31032
+ colorStartB:hsl(.8,.6,.45,.6), colorEndA:clear(.3,.8,.4), colorEndB:clear(.8,.6,.4)},
31033
+ [{name:'wobble', strength:.5}]);
31034
+ add('portal', {emitRate:80, emitSize:1, shape:'soft', particleTime:1, sizeStart:.15, sizeEnd:.05,
31035
+ speed:.02, fadeRate:.3, randomness:.3, additive:true, colorStartA:hsl(.75,1,.6),
31036
+ colorStartB:hsl(.6,1,.6), colorEndA:clear(.75,1,.6), colorEndB:clear(.6,1,.6)},
31037
+ [{name:'orbit', strength:1}, {name:'attract', strength:.5}]);
31038
+
31039
+ // weather and ambience, one unit across at scale 1, so scale them to the view
31040
+ add('rain', {emitRate:30, emitSize:1, emitRect:true, emitHeight:.6, angle:PI, emitConeAngle:0,
31041
+ shape:'soft', particleTime:.8, sizeStart:.02, sizeEnd:.02, speed:.03, gravity:-.001, fadeRate:.1,
31042
+ randomness:.2, trailScale:5, colorStartA:hsl(.63,1,.8,.6), colorStartB:hsl(.63,1,.8,.4),
31043
+ colorEndA:hsl(.63,1,.8,.6), colorEndB:hsl(.63,1,.8,.4)});
31044
+ add('snow', {emitRate:6, emitSize:1, emitRect:true, emitHeight:.6, angle:PI, emitConeAngle:.3,
31045
+ shape:'soft', particleTime:4, sizeStart:.05, sizeEnd:.05, speed:.004, damping:.98, gravity:-.0002,
31046
+ fadeRate:.2, randomness:.3, colorStartA:hsl(0,0,1), colorStartB:hsl(.58,1,.9), colorEndA:hsl(0,0,1),
31047
+ colorEndB:hsl(.58,1,.9)}, [{name:'wobble', strength:.15}]);
31048
+ add('leaves', {emitRate:3, emitSize:1, emitRect:true, emitHeight:.2, angle:PI, emitConeAngle:.5,
31049
+ shape:'triangle', particleTime:4, sizeStart:.12, sizeEnd:.12, speed:.003, angleSpeed:.05, damping:.98,
31050
+ gravity:-.0001, fadeRate:.2, randomness:.4, randomColorLinear:false, colorStartA:hsl(.25,.7,.4),
31051
+ colorStartB:hsl(.07,.8,.5), colorEndA:hsl(.08,.6,.35), colorEndB:hsl(.05,.7,.4)},
31052
+ [{name:'wobble', strength:.15}, {name:'wind', strength:.05}]);
31053
+ add('bubbles', {emitRate:8, emitSize:1, emitRect:true, emitHeight:.1, emitConeAngle:.2, shape:'ring',
31054
+ particleTime:2, sizeStart:.1, sizeEnd:.2, speed:.01, gravity:.0003, fadeRate:.2, randomness:.4,
31055
+ colorStartA:hsl(.55,.8,.8,.8), colorStartB:hsl(.5,.6,.9,.6), colorEndA:hsl(.55,.8,.8,.3),
31056
+ colorEndB:hsl(.5,.6,.9,.2)}, [{name:'wobble', strength:.3}]);
31057
+ add('fireflies', {emitRate:3, emitSize:1, shape:'glow', particleTime:3, sizeStart:.15, sizeEnd:.15,
31058
+ speed:.003, damping:.99, fadeRate:.8, randomness:.3, additive:true, colorStartA:hsl(.2,1,.6),
31059
+ colorStartB:hsl(.15,1,.7), colorEndA:hsl(.2,1,.6), colorEndB:hsl(.15,1,.7)},
31060
+ [{name:'turbulence', strength:.3}]);
31061
+
31062
+ // trails and shots
31063
+ add('trail', {emitRate:60, shape:'soft', particleTime:.5, sizeStart:.4, sizeEnd:0, speed:0, angleSpeed:0,
31064
+ fadeRate:.1, randomness:.2, additive:true, colorStartA:hsl(.08,1,.6), colorStartB:hsl(.12,1,.65),
31065
+ colorEndA:clear(.03,1,.5), colorEndB:clear(0,1,.4)});
31066
+ add('muzzle', {emitRate:300, emitTime:.05, emitConeAngle:.3, shape:'glow', particleTime:.2,
31067
+ sizeStart:.5, sizeEnd:.1, speed:.1, damping:.8, fadeRate:.1, randomness:.3, additive:true,
31068
+ colorStartA:hsl(.14,1,.8), colorStartB:hsl(.1,1,.6), colorEndA:clear(.08,1,.5), colorEndB:clear(.05,1,.4)});
31069
+
31070
+ // other
31071
+ add('blood', {emitRate:300, emitTime:.1, emitConeAngle:.8, shape:'dot', particleTime:3, sizeStart:.15,
31072
+ sizeEnd:.1, speed:.09, gravity:-.01, fadeRate:.1, randomness:.4, collideLevel:true, restitution:.1,
31073
+ colorStartA:hsl(0,1,.35), colorStartB:hsl(0,1,.2), colorEndA:hsl(0,1,.25), colorEndB:hsl(0,1,.15)},
31074
+ [{name:'stick', strength:1}]);
31075
+ add('confetti', {emitRate:200, emitTime:.1, emitSize:.2, emitConeAngle:.6, shape:'square', particleTime:3,
31076
+ sizeStart:.1, sizeEnd:.1, speed:.09, angleSpeed:.15, damping:.96, gravity:-.004, fadeRate:.1,
31077
+ randomness:.4, randomColorLinear:false, colorStartA:hsl(0,1,.5), colorStartB:hsl(.6,1,.6),
31078
+ colorEndA:hsl(0,1,.5), colorEndB:hsl(.6,1,.6)}, [{name:'wobble', strength:.3}]);
31079
+ add('splash', {emitRate:300, emitTime:.05, emitSize:.5, emitConeAngle:.6, shape:'soft', particleTime:.7,
31080
+ sizeStart:.12, sizeEnd:.06, speed:.08, gravity:-.008, fadeRate:.2, randomness:.4,
31081
+ colorStartA:hsl(.58,.8,.7,.8), colorStartB:hsl(.55,.6,.85,.8), colorEndA:hsl(.58,.8,.7,.2),
31082
+ colorEndB:hsl(.55,.6,.85,.2)});
31083
+ }
28644
31084
 
28645
31085
  /**
28646
31086
  * LittleJS glTF Plugin
@@ -29003,7 +31443,13 @@ async function loadGLTF(url)
29003
31443
  const response = await fetch(url);
29004
31444
  if (!response.ok)
29005
31445
  throw new Error('loadGLTF failed: ' + url);
29006
- return parseGLTF(await response.arrayBuffer(), url.slice(0, url.lastIndexOf('/') + 1));
31446
+ // the files beside it are beside where it came from, after any redirect; a blob or data url has nothing
31447
+ // beside it, and a model in one is whole, or parsed with parseGLTF and a base of its own
31448
+ const from = response.url || url;
31449
+ let base = '';
31450
+ if (!/^(blob|data):/i.test(from))
31451
+ base = response.url ? new URL('.', from).href : from.slice(0, from.lastIndexOf('/') + 1);
31452
+ return parseGLTF(await response.arrayBuffer(), base);
29007
31453
  }
29008
31454
 
29009
31455
  /** Parse a model from GLB bytes or glTF JSON, fetching the buffers and images it refers to