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