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.
- package/README.md +1 -1
- package/dist/littlejs.d.ts +1258 -141
- package/dist/littlejs.esm.js +5023 -429
- package/dist/littlejs.esm.min.js +22 -9
- package/dist/littlejs.js +4977 -430
- package/dist/littlejs.min.js +21 -8
- package/dist/littlejs.release.js +2759 -313
- package/package.json +3 -2
- package/plugins/gltf.js +7 -1
- package/plugins/lightSystem.js +50 -5
- package/plugins/math3d.js +42 -20
- package/plugins/parallax.js +174 -0
- package/plugins/particleEffects.js +760 -0
- package/plugins/pluginExport.js +40 -0
- package/plugins/postProcess.js +206 -28
- package/plugins/render3d.js +495 -188
- package/plugins/render3dEditor.js +1754 -80
- package/plugins/render3dExtras.js +294 -21
- package/plugins/render3dLevel.js +463 -6
- package/plugins/sceneSystem.js +3 -2
- package/src/engine.js +1 -1
- package/src/engineAudio.js +25 -8
- package/src/engineBuild.mjs +2 -0
- package/src/engineDraw.js +59 -4
- package/src/engineEditor.js +475 -37
- package/src/engineExport.js +7 -0
- package/src/engineInput.js +48 -6
- package/src/engineParticles.js +80 -11
- package/src/engineRelease.js +13 -2
- package/src/engineSettings.js +14 -0
- package/src/engineWebGL.js +27 -10
package/dist/littlejs.release.js
CHANGED
|
@@ -35,7 +35,7 @@ const engineName = 'LittleJS';
|
|
|
35
35
|
* @type {string}
|
|
36
36
|
* @default
|
|
37
37
|
* @memberof Engine */
|
|
38
|
-
const engineVersion = '1.
|
|
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
|
-
|
|
986
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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.
|
|
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 {
|
|
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
|
|
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 (
|
|
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*
|
|
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)
|
|
9700
|
-
: vec2(rand(-.5,.5),
|
|
9701
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = !
|
|
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 =
|
|
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 = !
|
|
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
|
-
|
|
12115
|
-
|
|
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
|
-
//
|
|
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,
|
|
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
|
-
|
|
12332
|
-
|
|
12333
|
-
|
|
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
|
-
|
|
12362
|
-
}`;
|
|
12663
|
+
c.rgb += glow * ${(strength / taps).toFixed(6)};`;
|
|
12363
12664
|
}
|
|
12364
12665
|
|
|
12365
12666
|
/**
|
|
12366
|
-
*
|
|
12367
|
-
* @param {number} [
|
|
12368
|
-
* @param {number} [
|
|
12369
|
-
* @
|
|
12370
|
-
* @
|
|
12371
|
-
|
|
12372
|
-
|
|
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
|
-
*
|
|
12769
|
+
* new PostProcessPlugin(postProcessEffects(postProcessTV({scanlines: .4, curve: .1})));
|
|
12770
|
+
* @memberof PostProcess
|
|
12376
12771
|
*/
|
|
12377
|
-
function
|
|
12378
|
-
{
|
|
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
|
-
|
|
12802
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
21169
|
-
turned ? Matrix4.rotation(rotation, matrix) : m.set(matrix4Identity);
|
|
21170
|
-
if (scale)
|
|
21804
|
+
if (rotation && (rotation.x || rotation.y || rotation.z))
|
|
21171
21805
|
{
|
|
21172
|
-
|
|
21173
|
-
|
|
21174
|
-
|
|
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
|
-
|
|
21177
|
-
|
|
21178
|
-
|
|
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
|
-
//
|
|
21630
|
-
//
|
|
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
|
|
21636
|
-
//
|
|
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:
|
|
21641
|
-
cullBackFaces:
|
|
21642
|
-
receiveShadow:
|
|
21643
|
-
normalMap:
|
|
21644
|
-
emissiveMap:
|
|
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
|
-
|
|
21663
|
-
|
|
21664
|
-
|
|
21665
|
-
||
|
|
21666
|
-
||
|
|
21667
|
-
||
|
|
21668
|
-
||
|
|
21669
|
-
|
|
21670
|
-
|
|
21671
|
-
|
|
21672
|
-
|
|
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)
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 *
|
|
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 ||
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
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.
|
|
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} -
|
|
25752
|
-
*
|
|
25753
|
-
this.
|
|
25754
|
-
/** @property {
|
|
25755
|
-
*
|
|
25756
|
-
this.
|
|
25757
|
-
|
|
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
|
|
26603
|
+
/** @property {number} - One past the last instance whose matrix uploads, nothing when it is not past dirtyStart */
|
|
25762
26604
|
this.dirtyEnd = count;
|
|
25763
|
-
|
|
25764
|
-
this.
|
|
25765
|
-
|
|
25766
|
-
|
|
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
|
-
|
|
25770
|
-
const k = i *
|
|
25771
|
-
|
|
25772
|
-
|
|
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.
|
|
25783
|
-
|
|
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 *
|
|
25794
|
-
matrix.m.set(this.
|
|
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.
|
|
25806
|
-
data[k
|
|
25807
|
-
this.
|
|
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
|
|
25811
|
-
*
|
|
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
|
|
25816
|
-
this.dirtyEnd
|
|
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
|
-
|
|
25819
|
-
|
|
25820
|
-
|
|
25821
|
-
|
|
25822
|
-
|
|
25823
|
-
|
|
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.
|
|
25845
|
-
for (let k =
|
|
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.
|
|
26809
|
+
this.colorDirtyStart = 0, this.colorDirtyEnd = this.maxCount;
|
|
25849
26810
|
}
|
|
25850
26811
|
|
|
25851
|
-
// the GPU
|
|
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.
|
|
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
|
|
26821
|
+
else
|
|
25860
26822
|
{
|
|
25861
|
-
const
|
|
25862
|
-
|
|
25863
|
-
|
|
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
|
-
* -
|
|
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
|
|
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
|
-
|
|
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
|
-
* -
|
|
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
|
|
26948
|
-
*
|
|
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
|
-
*
|
|
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 =
|
|
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
|
-
|
|
28045
|
+
const rate = this.emitRate * particleEmitRateScale;
|
|
28046
|
+
if (rate > 0 && rate < Infinity)
|
|
27082
28047
|
{
|
|
27083
|
-
this.emitTimeBuffer +=
|
|
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
|
-
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
27166
|
-
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
28609
|
-
result.scale3D = result.scale3D.multiply(
|
|
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
|
-
|
|
28643
|
-
|
|
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
|
-
|
|
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
|