littlejsengine 1.20.0 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -35,7 +35,7 @@ const engineName = 'LittleJS';
35
35
  * @type {string}
36
36
  * @default
37
37
  * @memberof Engine */
38
- const engineVersion = '1.20.0';
38
+ const engineVersion = '1.21.0';
39
39
 
40
40
  /** Frames per second to update
41
41
  * @type {number}
@@ -43,11 +43,12 @@ const engineVersion = '1.20.0';
43
43
  * @memberof Engine */
44
44
  const frameRate = 60;
45
45
 
46
- /** How many seconds each frame lasts, engine uses a fixed time step
46
+ /** How many seconds the update covers: 1/60 with the fixed time step, or with engineVariableStep the time of the
47
+ * display frame it runs on, times timeScale; while paused it keeps the last update's
47
48
  * @type {number}
48
49
  * @default 1/60
49
50
  * @memberof Engine */
50
- const timeDelta = 1/frameRate;
51
+ let timeDelta = 1/frameRate;
51
52
 
52
53
  /** Array containing all engine objects
53
54
  * @type {Array<EngineObject>}
@@ -95,6 +96,75 @@ function setPaused(isPaused=true) { paused = isPaused; }
95
96
 
96
97
  // Engine internal variables
97
98
  let frameTimeLastMS = 0, frameTimeBufferMS = 0, averageFPS = 0;
99
+
100
+ // delta smoothing, after Time Delta Smoothing by Frank Force (2013): a frame is on screen for whole display frames,
101
+ // so each delta is rounded to them and the rest is carried to the next, keeping the total real time; the frame
102
+ // length is estimated from recent frames and kept internal, since the browser does not say what it is
103
+ const frameDeltaHistory = [];
104
+ let frameIntervalMS = 1e3 / 60, frameDeltaCarryMS = 0, frameDeltaCarryAverageMS = 0, frameDeltaSmoothing = true;
105
+ function engineSmoothDelta(deltaMS)
106
+ {
107
+ if (!(deltaMS > 0)) return 0;
108
+ if (deltaMS < 250) // a gap from a hidden tab is not a display frame
109
+ {
110
+ frameDeltaHistory.push(deltaMS);
111
+ frameDeltaHistory.length > 64 && frameDeltaHistory.shift();
112
+
113
+ // the frames each delta held are counted in the last estimate or in the lower quartile of the last 16,
114
+ // whichever the deltas fit better: the quartile is one frame even when a busy game misses most of them, and
115
+ // it takes over when the window moves to a display with another refresh rate, or the estimate went wrong
116
+ const recent = frameDeltaHistory.slice(-16).sort((a, b)=> a - b);
117
+ const [lastMS, lastOff] = engineFrameFit(frameIntervalMS);
118
+ const [quartileMS, quartileOff] = engineFrameFit(recent[recent.length >> 2]);
119
+ frameIntervalMS = lastOff <= quartileOff ? lastMS : quartileMS;
120
+
121
+ // deltas far from whole frames mean the display has no fixed refresh, with a margin so it does not flip
122
+ frameDeltaSmoothing = min(lastOff, quartileOff) < (frameDeltaSmoothing ? .2 : .12);
123
+ }
124
+
125
+ if (!frameDeltaSmoothing)
126
+ {
127
+ // deltas that are not whole frames of one length, like a variable refresh display, are the frame times
128
+ // as they are, with any carry paid, and smoothing starts over once they fit again
129
+ const rawMS = deltaMS + frameDeltaCarryMS;
130
+ frameDeltaCarryMS = min(rawMS, 0);
131
+ frameDeltaCarryAverageMS = 0;
132
+ return max(rawMS, 0);
133
+ }
134
+
135
+ // whole frames, 0 for one that came early or 2 after one was missed, the carry stays within half a frame; a
136
+ // little of its slow average is paid each frame, so jitter can not hold it at half a frame and flip every frame
137
+ const pullMS = frameDeltaCarryAverageMS / 32;
138
+ frameDeltaCarryMS += deltaMS;
139
+ const frames = round((frameDeltaCarryMS - pullMS) / frameIntervalMS);
140
+ if (frames < 1)
141
+ return 0; // the carry keeps it
142
+ const smoothMS = frames * frameIntervalMS + pullMS;
143
+ frameDeltaCarryMS -= smoothMS;
144
+ frameDeltaCarryAverageMS += (frameDeltaCarryMS - frameDeltaCarryAverageMS) / 32;
145
+ return smoothMS;
146
+ }
147
+
148
+ // fit the delta history as whole frames of about unitMS: the frame length is the least squares slope of time over
149
+ // frames held, so jitter and whole ms timestamps average out, with how far the deltas are from whole frames
150
+ function engineFrameFit(unitMS)
151
+ {
152
+ let timeMS = 0, frames = 0, offMS = 0, sumF = 0, sumT = 0, sumFF = 0, sumFT = 0;
153
+ const count = frameDeltaHistory.length;
154
+ for (const d of frameDeltaHistory)
155
+ {
156
+ const held = max(1, round(d / unitMS));
157
+ timeMS += d;
158
+ frames += held;
159
+ offMS += abs(d - held * unitMS);
160
+ sumF += frames, sumT += timeMS, sumFF += frames * frames, sumFT += frames * timeMS;
161
+ }
162
+ const spread = count * sumFF - sumF * sumF;
163
+ return [spread ? (count * sumFT - sumF * sumT) / spread : unitMS, offMS / count / unitMS];
164
+ }
165
+
166
+ // where the fixed step's time counts from, moved when the variable step hands back so time goes on from there
167
+ let timeFixedStart = 0, frameFixedStart = 0;
98
168
  let windowWidthLast = 0, windowHeightLast = 0, windowPixelRatioLast = 0;
99
169
  let engineUpdateInternal; // assigned by engineInit so engineStep can drive it
100
170
  let engineFrameScheduled = false; // a frame of the loop is asked for and has not run yet
@@ -112,6 +182,9 @@ function engineObjectsCollidePairAdd(asker, other, resolve=false)
112
182
  others.set(other, resolve);
113
183
  }
114
184
  let engineInitialized = false; // engineInit ran, with or without a canvas
185
+ // the loads startup waits for, each counted for the loading screen, and how many are done; undefined once the game
186
+ // loop starts
187
+ let engineLoads, engineLoadsDone = 0;
115
188
  let engineObjectsUpdateCount = 0; // passes of engineObjectsUpdate so far, how a child knows it moved this pass
116
189
  const engineChildStack = []; // the children being updated, taken off the live lists so one leaving does not skip the next
117
190
  let showEngineVersion = true;
@@ -161,11 +234,68 @@ function engineAddPlugin(update, render, glContextLost, glContextRestored, preRe
161
234
  ///////////////////////////////////////////////////////////////////////////////
162
235
  // Main Engine Functions
163
236
 
237
+ /** Add something the game loads to what startup waits for: while engineInit and gameInit run, the game loop starts
238
+ * once it is done, and the loading screen counts it; images from loadTexture and sounds from files are added on
239
+ * their own, and a load that fails counts as done; after startup it does nothing
240
+ * @param {Promise<any>} promise
241
+ * @return {Promise<any>} - The same promise
242
+ * @example
243
+ * async function gameInit() { level = await engineAddLoad(fetchJSON('level.json')); }
244
+ * @memberof Engine */
245
+ function engineAddLoad(promise)
246
+ {
247
+ if (engineLoads)
248
+ {
249
+ engineLoads.push(promise);
250
+ const done = ()=> { ++engineLoadsDone; };
251
+ promise.then(done, done);
252
+ }
253
+ return promise;
254
+ }
255
+
256
+ // wait for every load, the ones added while waiting too, drawing the loading screen each frame meanwhile
257
+ async function engineWaitForLoads()
258
+ {
259
+ let waiting = true;
260
+ const start = performance.now();
261
+ const drawFrame = ()=>
262
+ {
263
+ if (!waiting) return;
264
+ engineLoadingScreenDraw((performance.now() - start) / 1e3);
265
+ setTimeout(drawFrame, 16);
266
+ };
267
+ headlessMode || drawFrame();
268
+ try
269
+ {
270
+ for (let count; count !== engineLoads.length;)
271
+ {
272
+ count = engineLoads.length;
273
+ await Promise.allSettled(engineLoads);
274
+ }
275
+ }
276
+ finally { waiting = false; }
277
+ }
278
+
279
+ // one frame of the loading screen, once loading has taken half a second, so a fast load never shows it; input
280
+ // while it shows is dropped, as it is under the splash
281
+ function engineLoadingScreenDraw(elapsed)
282
+ {
283
+ if (headlessMode || !loadingScreen || elapsed < .5) return;
284
+ inputClear();
285
+ engineUpdateCanvas();
286
+ loadingScreen(engineLoadsDone / engineLoads.length);
287
+ }
288
+
164
289
  /**
165
290
  * @callback GameInitCallback - Called after the engine starts, can be async
166
291
  * @return {void|Promise<void>}
167
292
  * @memberof Engine
168
293
  */
294
+ /**
295
+ * @callback LoadingScreenCallback - Draws the loading screen on mainContext, each frame while the game loads
296
+ * @param {number} progress - The part of the loads done, 0 to 1
297
+ * @memberof Engine
298
+ */
169
299
  /**
170
300
  * @callback GameCallback - Update or render function for the game
171
301
  * @memberof Engine
@@ -200,6 +330,7 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
200
330
  // double-register listeners / double-add canvases on a second call
201
331
  if (engineInitialized) return;
202
332
  engineInitialized = true;
333
+ engineLoads = [], engineLoadsDone = 0;
203
334
  false&&ASSERT(isArray(imageSources), 'pass in images as array');
204
335
 
205
336
  // allow passing in empty functions
@@ -239,6 +370,9 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
239
370
  frameTimeLastMS = frameTimeMS;
240
371
  if (debug || debugWatermark)
241
372
  averageFPS = lerp(averageFPS, 1e3/(frameTimeDeltaMS||1), .05);
373
+ // the time the frame will be on screen, in whole display frames; engineStep's steps are exact already
374
+ if (!manualStepAtStart)
375
+ frameTimeDeltaMS = engineSmoothDelta(frameTimeDeltaMS);
242
376
  audioUpdateVolume();
243
377
  // the time keys work while the debug overlay is open, or always when debugKeysAlways is set
244
378
  const debugKeys = debug && (debugOverlay || debugKeysAlways);
@@ -251,32 +385,61 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
251
385
  timeReal += frameTimeDeltaMS * debugScale / 1e3;
252
386
  const combinedScale = timeScale * debugScale;
253
387
  frameTimeDeltaMS *= combinedScale;
254
- // paused or a time scale of 0 is frozen: it ticks on unscaled time, so the update rate stays fixed instead of
255
- // following however fast the display refreshes, and gameUpdatePost and input still run to leave it
256
- const frozen = paused || !combinedScale;
257
- frameTimeBufferMS += frozen ? frameTimeDeltaUnscaledMS : frameTimeDeltaMS;
258
- frameTimeBufferMS = min(frameTimeBufferMS, 50 * (frozen ? 1 : max(1, combinedScale))); // clamp min framerate
259
-
260
- // apply time delta smoothing, improves smoothness of framerate in some browsers
261
- let wasUpdated = false, deltaSmooth = 0;
262
- if (frameTimeBufferMS < 0 && frameTimeBufferMS > -9)
388
+ let wasUpdated = false;
389
+ if (engineVariableStep)
263
390
  {
264
- // force at least one update each frame since it is waiting for refresh
265
- deltaSmooth = frameTimeBufferMS;
266
- frameTimeBufferMS = 0;
391
+ // one update for the frame with timeDelta the time it covers, per-frame values are the game's to scale;
392
+ // engineStep's frames are exactly 1/60, its first too, which has no frame before it to take a delta from
393
+ if (frameTimeDeltaUnscaledMS > 0 || manualStepAtStart)
394
+ {
395
+ // frozen stands still as in the fixed step, and timeDelta keeps the last update's
396
+ const frozenTick = paused || !combinedScale;
397
+ if (!frozenTick)
398
+ {
399
+ timeDelta = manualStepAtStart ? combinedScale / frameRate :
400
+ min(frameTimeDeltaUnscaledMS, 50) * combinedScale / 1e3; // clamp min framerate
401
+ time += timeDelta;
402
+ ++frame;
403
+ }
404
+ engineTick(frozenTick);
405
+ }
267
406
  }
268
-
269
- // update multiple frames if necessary in case of slow framerate
270
- for (; frameTimeBufferMS >= 0; frameTimeBufferMS -= 1e3 / frameRate)
407
+ else
271
408
  {
272
- // read again each tick, so a pause set by the game stops the rest of this frame's catch-up ticks
273
- const frozenTick = paused || !(timeScale * debugScale);
409
+ // paused or a time scale of 0 is frozen: it ticks on unscaled time, so the update rate stays fixed instead
410
+ // of following however fast the display refreshes, and gameUpdatePost and input still run to leave it
411
+ const frozen = paused || !combinedScale;
412
+ frameTimeBufferMS += frozen ? frameTimeDeltaUnscaledMS : frameTimeDeltaMS;
413
+ frameTimeBufferMS = min(frameTimeBufferMS, 50 * (frozen ? 1 : max(1, combinedScale))); // clamp min framerate
274
414
 
275
- // increment frame and update time, frozen does not advance time
276
- if (!frozenTick)
277
- time = frame++ / frameRate;
415
+ // apply time delta smoothing, improves smoothness of framerate in some browsers
416
+ let deltaSmooth = 0;
417
+ if (frameTimeBufferMS < 0 && frameTimeBufferMS > -9)
418
+ {
419
+ // force at least one update each frame since it is waiting for refresh
420
+ deltaSmooth = frameTimeBufferMS;
421
+ frameTimeBufferMS = 0;
422
+ }
423
+
424
+ // update multiple frames if necessary in case of slow framerate
425
+ for (; frameTimeBufferMS >= 0; frameTimeBufferMS -= 1e3 / frameRate)
426
+ {
427
+ // read again each tick, so a pause set by the game stops the rest of this frame's catch-up ticks
428
+ const frozenTick = paused || !(timeScale * debugScale);
429
+
430
+ // increment frame and update time, frozen does not advance time
431
+ if (!frozenTick)
432
+ time = timeFixedStart + (frame++ - frameFixedStart) / frameRate;
433
+ engineTick(frozenTick);
434
+ }
435
+
436
+ // add the time smoothing back in
437
+ frameTimeBufferMS += deltaSmooth;
438
+ }
278
439
 
279
- // update game and objects, when frozen update everything except them
440
+ // one tick of the loop: update game and objects, when frozen update everything except them
441
+ function engineTick(frozenTick)
442
+ {
280
443
  wasUpdated = true;
281
444
  engineUpdateCanvas();
282
445
  inputUpdate();
@@ -303,9 +466,6 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
303
466
  renderFrame();
304
467
  }
305
468
 
306
- // add the time smoothing back in
307
- frameTimeBufferMS += deltaSmooth;
308
-
309
469
  // manual step turned on by this frame's updates, set the buffer the loop and smoothing just moved again
310
470
  if (engineManualStep && !manualStepAtStart)
311
471
  frameTimeBufferMS = -.5e3 / frameRate;
@@ -364,7 +524,7 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
364
524
  }
365
525
 
366
526
  // skip setup if headless
367
- if (headlessMode) return startEngine();
527
+ if (headlessMode) return startEngine([]);
368
528
 
369
529
  // ensure body exists for minimal HTML where the script runs before <body> is parsed
370
530
  if (!document.body)
@@ -442,14 +602,19 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
442
602
  promises.push(splash);
443
603
  }
444
604
 
445
- // wait for all the promises to finish
446
- await Promise.all(promises);
447
- return startEngine();
605
+ // the splash first, the images load under it, then the loading screen for the rest
606
+ showSplashScreen && await promises.at(-1);
607
+ return startEngine(promises);
448
608
 
449
- async function startEngine()
609
+ // gameInit runs once the images are in, and the game loop starts once it and everything loaded while it ran are
610
+ // done, the loading screen showing in the meantime; an error in gameInit reaches the caller
611
+ async function startEngine(images)
450
612
  {
451
- // wait for gameInit to load
452
- await gameInit();
613
+ const init = (async ()=> { await Promise.all(images); await gameInit(); })();
614
+ engineAddLoad(init);
615
+ await engineWaitForLoads();
616
+ engineLoads = undefined;
617
+ await init;
453
618
  engineUpdateInternal = engineUpdate; // engineStep only runs once the game is set up
454
619
  engineManualStep || engineUpdate();
455
620
  }
@@ -817,13 +982,19 @@ function tweak(){}
817
982
  function tweakButton(){}
818
983
  function tweakDivider(){}
819
984
  function tweakEngineDefaults(){}
820
- const levelEditor = {isOpen: false, open(){}, close(){}, onTile: undefined, onRestart: undefined, onPlayFrom: undefined};
985
+ const levelEditor = {isOpen: false, open(){}, close(){}, onTile: undefined, onRestart: undefined, onPlayFrom: undefined,
986
+ paletteTiles: undefined, use3D: undefined};
821
987
  function editorMapRestore(map){ return map; }
822
988
  function editorMapLoaded(){}
823
989
  function editorJSONFetched(){}
824
990
  function editorPreRender(){}
825
991
  function editorObjectMade(){}
826
992
 
993
+ // the input capture of the free camera and the 3D editor
994
+ function inputCaptureHides(){ return false; }
995
+ function inputCaptureMouse(){}
996
+ function inputLockExit(){}
997
+
827
998
  // the 3D debug draws are debug only too
828
999
  function debugBox3D(){}
829
1000
  function debugSphere3D(){}
@@ -831,6 +1002,12 @@ function debugLine3D(){}
831
1002
  function debugPoint3D(){}
832
1003
  function render3DRenderDebug(){}
833
1004
 
1005
+ // the free camera and the 3D level editor are debug only
1006
+ function editor3DCameraBegin(){}
1007
+ function editor3DCameraEnd(){}
1008
+ function editor3DLevelLoaded(){}
1009
+ function editor3DObjectMade(){}
1010
+
834
1011
  /**
835
1012
  * LittleJS Math Classes and Functions
836
1013
  * - Comprehensive math utilities for game development
@@ -2516,6 +2693,13 @@ let fontDefault = 'arial';
2516
2693
  * @memberof Settings */
2517
2694
  let showSplashScreen = false;
2518
2695
 
2696
+ /** The loading screen, drawn after the splash while the images, gameInit and what they load are loading, once that
2697
+ * takes more than half a second: a function given the part done, 0 to 1, or undefined for none, which leaves the
2698
+ * screen blank; the default says Loading over a bar
2699
+ * @type {LoadingScreenCallback|undefined}
2700
+ * @memberof Settings */
2701
+ let loadingScreen = drawLoadingScreen;
2702
+
2519
2703
  /** Disables all rendering, audio, and input for servers, must be set before engineInit
2520
2704
  * @type {boolean}
2521
2705
  * @default
@@ -2529,6 +2713,15 @@ let headlessMode = false;
2529
2713
  * @memberof Settings */
2530
2714
  let engineManualStep = false;
2531
2715
 
2716
+ /** Run one update per display frame with timeDelta the time it covers, in place of fixed updates at frameRate
2717
+ * - Per-frame values are not scaled: velocity, gravity, damping and particle speeds act once per update, so a
2718
+ * game that turns this on scales its own movement by timeDelta
2719
+ * - Values in seconds follow real time as they are: time, timers, particle emit rate and life, animation, Box2D
2720
+ * @type {boolean}
2721
+ * @default
2722
+ * @memberof Settings */
2723
+ let engineVariableStep = false;
2724
+
2532
2725
  ///////////////////////////////////////////////////////////////////////////////
2533
2726
  // WebGL settings
2534
2727
 
@@ -2924,6 +3117,11 @@ function setFontDefault(font) { fontDefault = font; }
2924
3117
  * @memberof Settings */
2925
3118
  function setShowSplashScreen(show) { showSplashScreen = show; }
2926
3119
 
3120
+ /** Set the loading screen, drawn after the splash while the game loads, once that takes more than half a second
3121
+ * @param {LoadingScreenCallback} [callback] - Draws on mainContext given the part done, 0 to 1; undefined for none
3122
+ * @memberof Settings */
3123
+ function setLoadingScreen(callback) { loadingScreen = callback; }
3124
+
2927
3125
  /** Set to disable rendering, audio, and input for servers, must be set before engineInit
2928
3126
  * @param {boolean} headless
2929
3127
  * @memberof Settings */
@@ -2949,6 +3147,23 @@ function setEngineManualStep(enable=true)
2949
3147
  }
2950
3148
  }
2951
3149
 
3150
+ /** Set if the engine runs one update per display frame with timeDelta the time it covers
3151
+ * Can be set before engineInit or while running; turned off it goes back to fixed updates at frameRate
3152
+ * @param {boolean} [enable]
3153
+ * @memberof Settings */
3154
+ function setEngineVariableStep(enable=true)
3155
+ {
3156
+ if (engineVariableStep && !enable)
3157
+ {
3158
+ // fixed time goes on one fixed step after the last update, if one ran, and timeDelta is the fixed step again
3159
+ timeFixedStart = frame ? time + 1 / frameRate : time;
3160
+ frameFixedStart = frame;
3161
+ timeDelta = 1 / frameRate;
3162
+ frameTimeBufferMS = engineManualStep ? -.5e3 / frameRate : 0;
3163
+ }
3164
+ engineVariableStep = enable;
3165
+ }
3166
+
2952
3167
  /** Set whether the draw and debug functions draw in screen space when a call leaves screenSpace out
2953
3168
  * @param {boolean} screenSpace
2954
3169
  * @memberof Settings */
@@ -3388,9 +3603,10 @@ class EngineObject
3388
3603
  // bail if a collision callback destroyed us mid-frame
3389
3604
  if (this.destroyed) return;
3390
3605
 
3391
- if (this.clampSpeed)
3606
+ // limit max speed to prevent missing collisions, only for what collides: with solids, or with tiles while it
3607
+ // has a mass, which tile collision needs; anything else moves as fast as it is told
3608
+ if (this.clampSpeed && enablePhysicsSolver && (this.collideSolidObjects || this.collideLevel && this.mass))
3392
3609
  {
3393
- // limit max speed to prevent missing collisions
3394
3610
  this.velocity.x = clamp(this.velocity.x, -objectMaxSpeed, objectMaxSpeed);
3395
3611
  this.velocity.y = clamp(this.velocity.y, -objectMaxSpeed, objectMaxSpeed);
3396
3612
  }
@@ -4181,12 +4397,20 @@ class TextureInfo
4181
4397
  }
4182
4398
 
4183
4399
  ///////////////////////////////////////////////////////////////////////////////
4400
+ /**
4401
+ * @callback SpriteAnimationEndCallback - Called once when a SpriteAnimation's play ends
4402
+ * @return {void}
4403
+ * @memberof Draw
4404
+ */
4405
+
4184
4406
  /**
4185
4407
  * SpriteAnimation - Steps a tile through its frames over time: looping, once, or there and back
4186
4408
  * - Driven by the engine time like a Timer, so it pauses with the game and needs no update call
4187
4409
  * - Read tileInfo each frame for the frame to draw, from an object's update or before a drawTile
4188
4410
  * - loop, play and pingPong each start over from the first frame; stop holds the current one
4411
+ * - play(onEnd) calls onEnd once the play ends, on the first read after it, since there is no update to call it
4189
4412
  * - Frames follow each other along the row, as tileInfo.frame counts them
4413
+ * - SpriteAnimator switches between a character's animations by name
4190
4414
  * @example
4191
4415
  * const walk = new SpriteAnimation(tile(0, 16), 4, .1); // four frames, a tenth of a second each
4192
4416
  * const attack = new SpriteAnimation(tile(4, 16), 3, .05).play(); // once, then holds the last frame
@@ -4218,6 +4442,9 @@ class SpriteAnimation
4218
4442
  /** @property {number|undefined} - The frame held by stop, undefined while running
4219
4443
  * @type {number|undefined} */
4220
4444
  this.heldFrame = undefined;
4445
+ /** @property {SpriteAnimationEndCallback|undefined} - Called once when a play ends, on the first read after it
4446
+ * @type {SpriteAnimationEndCallback|undefined} */
4447
+ this.onEnd = undefined;
4221
4448
  }
4222
4449
 
4223
4450
  /** Start over from the first frame and repeat forever
@@ -4225,8 +4452,14 @@ class SpriteAnimation
4225
4452
  loop() { return this.restart('loop'); }
4226
4453
 
4227
4454
  /** Start over from the first frame, run through once and hold the last frame, last to first at a negative speed
4455
+ * @param {function():void} [onEnd] - Called once when it ends, on the first read of it after that
4228
4456
  * @return {SpriteAnimation} */
4229
- play() { return this.restart('once'); }
4457
+ play(onEnd)
4458
+ {
4459
+ this.restart('once');
4460
+ this.onEnd = onEnd;
4461
+ return this;
4462
+ }
4230
4463
 
4231
4464
  /** Start over from the first frame and run there and back forever
4232
4465
  * @return {SpriteAnimation} */
@@ -4236,7 +4469,7 @@ class SpriteAnimation
4236
4469
  * @return {SpriteAnimation} */
4237
4470
  stop() { this.heldFrame = this.frame; return this; }
4238
4471
 
4239
- /** Start over from the first frame in a mode
4472
+ /** Start over from the first frame in a mode, with no end callback
4240
4473
  * @param {string} [mode] - 'loop', 'once' or 'pingPong', the current mode when left out
4241
4474
  * @return {SpriteAnimation} */
4242
4475
  restart(mode=this.mode)
@@ -4244,6 +4477,7 @@ class SpriteAnimation
4244
4477
  this.mode = mode;
4245
4478
  this.startTime = time;
4246
4479
  this.heldFrame = undefined;
4480
+ this.onEnd = undefined;
4247
4481
  return this;
4248
4482
  }
4249
4483
 
@@ -4255,6 +4489,7 @@ class SpriteAnimation
4255
4489
  * @return {number} */
4256
4490
  get frame()
4257
4491
  {
4492
+ this.isDone; // a read after a play ends calls onEnd, which may start it over
4258
4493
  if (this.heldFrame !== undefined)
4259
4494
  return this.heldFrame;
4260
4495
  const n = this.frameCount, f = floor(this.elapsedFrames);
@@ -4270,9 +4505,81 @@ class SpriteAnimation
4270
4505
  * @return {TileInfo} */
4271
4506
  get tileInfo() { return this.firstTile.frame(this.frame); }
4272
4507
 
4273
- /** True once a play has shown its last frame for its time
4508
+ /** True once a play has shown its last frame for its time, the first read after that calls onEnd
4274
4509
  * @return {boolean} */
4275
- get isDone() { return this.mode == 'once' && this.heldFrame === undefined && abs(this.elapsedFrames) >= this.frameCount; }
4510
+ get isDone()
4511
+ {
4512
+ const done = this.mode == 'once' && this.heldFrame === undefined && abs(this.elapsedFrames) >= this.frameCount;
4513
+ if (done && this.onEnd)
4514
+ {
4515
+ const onEnd = this.onEnd;
4516
+ this.onEnd = undefined; // once, and it can start a new play with a callback of its own
4517
+ onEnd();
4518
+ }
4519
+ return done;
4520
+ }
4521
+ }
4522
+
4523
+ ///////////////////////////////////////////////////////////////////////////////
4524
+ /**
4525
+ * SpriteAnimator - A character's animations by name, like idle, walk and attack, and the one showing now
4526
+ * - Each clip is a SpriteAnimation and keeps its own mode, loop, play or pingPong
4527
+ * - set starts a clip over only when it changes or a play of it has ended, so it can be called every update
4528
+ * - Read tileInfo each frame for the frame to draw, as with a SpriteAnimation
4529
+ * @example
4530
+ * const hero = new SpriteAnimator({
4531
+ * idle: new SpriteAnimation(tile(0, 16), 2, .4),
4532
+ * walk: new SpriteAnimation(tile(2, 16), 4, .1),
4533
+ * attack: new SpriteAnimation(tile(6, 16), 3, .05).play(),
4534
+ * });
4535
+ * hero.set('attack', ()=> hero.set('idle')); // back to idle when the attack ends
4536
+ * // in update: this.tileInfo = hero.tileInfo;
4537
+ * @memberof Draw
4538
+ */
4539
+ class SpriteAnimator
4540
+ {
4541
+ /** Create an animator from its clips, showing the first one
4542
+ * @param {Object<string, SpriteAnimation>} clips - The clips by name */
4543
+ constructor(clips)
4544
+ {
4545
+ const names = Object.keys(clips);
4546
+ false&&ASSERT(names.length > 0, 'an animator needs at least one clip');
4547
+ /** @property {Object<string, SpriteAnimation>} - The clips by name */
4548
+ this.clips = clips;
4549
+ /** @property {string} - The name of the clip showing now */
4550
+ this.name = names[0];
4551
+ }
4552
+
4553
+ /** Show a clip, starting it over in its own mode unless it is showing and has not ended
4554
+ * @param {string} name - The clip's name
4555
+ * @param {function():void} [onEnd] - Called once when a play clip ends, as with SpriteAnimation.play
4556
+ * @return {SpriteAnimator} */
4557
+ set(name, onEnd)
4558
+ {
4559
+ false&&ASSERT(this.clips[name], 'no clip named ' + name);
4560
+ if (name == this.name && !this.clip.isDone)
4561
+ return this;
4562
+ this.name = name;
4563
+ this.clip.restart();
4564
+ this.clip.onEnd = onEnd;
4565
+ return this;
4566
+ }
4567
+
4568
+ /** The clip showing now
4569
+ * @return {SpriteAnimation} */
4570
+ get clip() { return this.clips[this.name]; }
4571
+
4572
+ /** The tile of the frame showing now
4573
+ * @return {TileInfo} */
4574
+ get tileInfo() { return this.clip.tileInfo; }
4575
+
4576
+ /** The frame of the clip showing now
4577
+ * @return {number} */
4578
+ get frame() { return this.clip.frame; }
4579
+
4580
+ /** True once the clip showing now is a play that has ended
4581
+ * @return {boolean} */
4582
+ get isDone() { return this.clip.isDone; }
4276
4583
  }
4277
4584
 
4278
4585
  ///////////////////////////////////////////////////////////////////////////////
@@ -5097,7 +5404,7 @@ async function loadTexture(textureIndex, src)
5097
5404
  const image = new Image;
5098
5405
  if (src)
5099
5406
  {
5100
- await new Promise(resolve =>
5407
+ await engineAddLoad(new Promise(resolve => // startup waits for it
5101
5408
  {
5102
5409
  image.onload = resolve;
5103
5410
  image.onerror = ()=>
@@ -5107,7 +5414,7 @@ async function loadTexture(textureIndex, src)
5107
5414
  };
5108
5415
  image.crossOrigin = 'anonymous';
5109
5416
  image.src = src;
5110
- });
5417
+ }));
5111
5418
  }
5112
5419
 
5113
5420
  return textureInfos[textureIndex] = new TextureInfo(image);
@@ -5838,7 +6145,7 @@ function keyIsDown(key, device=0)
5838
6145
  false&&ASSERT(isStringLike(key), 'key must be a number or string');
5839
6146
  false&&ASSERT(typeof key !== 'string' || key.length > 1, "keys are codes like 'KeyW' or 'Space', not characters");
5840
6147
  false&&ASSERT(device > 0 || typeof key !== 'number' || key < 5, 'use code string for keyboard');
5841
- return !!(inputData[device]?.[key] & 1);
6148
+ return !!(inputData[device]?.[key] & 1) && !inputCaptureHides(device);
5842
6149
  }
5843
6150
 
5844
6151
  /** Returns true if device key was pressed this frame
@@ -5851,7 +6158,7 @@ function keyWasPressed(key, device=0)
5851
6158
  false&&ASSERT(isStringLike(key), 'key must be a number or string');
5852
6159
  false&&ASSERT(typeof key !== 'string' || key.length > 1, "keys are codes like 'KeyW' or 'Space', not characters");
5853
6160
  false&&ASSERT(device > 0 || typeof key !== 'number' || key < 5, 'use code string for keyboard');
5854
- return !!(inputData[device]?.[key] & 2);
6161
+ return !!(inputData[device]?.[key] & 2) && !inputCaptureHides(device);
5855
6162
  }
5856
6163
 
5857
6164
  /** Returns true if device key was released this frame
@@ -5864,7 +6171,7 @@ function keyWasReleased(key, device=0)
5864
6171
  false&&ASSERT(isStringLike(key), 'key must be a number or string');
5865
6172
  false&&ASSERT(typeof key !== 'string' || key.length > 1, "keys are codes like 'KeyW' or 'Space', not characters");
5866
6173
  false&&ASSERT(device > 0 || typeof key !== 'number' || key < 5, 'use code string for keyboard');
5867
- return !!(inputData[device]?.[key] & 4);
6174
+ return !!(inputData[device]?.[key] & 4) && !inputCaptureHides(device);
5868
6175
  }
5869
6176
 
5870
6177
  /** Returns input vector from arrow keys or WASD if enabled
@@ -6067,7 +6374,10 @@ function pointerLockRequest()
6067
6374
  /** Request to unlock the pointer
6068
6375
  * @memberof Input */
6069
6376
  function pointerLockExit()
6070
- { document.exitPointerLock?.(); }
6377
+ {
6378
+ inputLockExit(); // debug builds: the free camera and the 3D editor tell this from Escape
6379
+ document.exitPointerLock?.();
6380
+ }
6071
6381
 
6072
6382
  /** Check if pointer is locked (true if locked)
6073
6383
  * @return {boolean}
@@ -6384,6 +6694,7 @@ function inputInit()
6384
6694
 
6385
6695
  function inputUpdate()
6386
6696
  {
6697
+ inputCaptureMouse(); // debug builds: the free camera or the 3D editor may have the mouse
6387
6698
  if (headlessMode) return;
6388
6699
 
6389
6700
  // clear input when lost focus (prevent stuck keys)
@@ -7388,8 +7699,8 @@ class Sound
7388
7699
  // load the audio file, a URL object as bundlers give works like its string;
7389
7700
  // report failures rather than leaving an unhandled rejection, the sound just stays unloaded and silent
7390
7701
  const filename = asset + '';
7391
- this.loadSound(filename).catch(e=>
7392
- false&&LOG('Sound load failed for', filename, '-', e.message));
7702
+ engineAddLoad(this.loadSound(filename).catch(e=>
7703
+ false&&LOG('Sound load failed for', filename, '-', e.message))); // startup waits for it
7393
7704
  }
7394
7705
  }
7395
7706
 
@@ -10872,6 +11183,25 @@ function glPolyStrip(points)
10872
11183
  */
10873
11184
 
10874
11185
  ///////////////////////////////////////////////////////////////////////////////
11186
+ // the default loading screen: Loading over a bar across the middle, filled by the part of the loads done
11187
+ function drawLoadingScreen(progress)
11188
+ {
11189
+ const x = mainContext, w = mainCanvasSize.x, h = mainCanvasSize.y;
11190
+ const barWidth = min(w * .6, 400), barHeight = 12, left = (w - barWidth) / 2, top = h / 2;
11191
+ x.save();
11192
+ x.fillStyle = '#000';
11193
+ x.fillRect(0, 0, w, h);
11194
+ x.fillStyle = x.strokeStyle = '#fff';
11195
+ x.font = '28px ' + fontDefault;
11196
+ x.textAlign = 'center';
11197
+ x.textBaseline = 'bottom';
11198
+ x.fillText('Loading', w / 2, top - 16);
11199
+ x.lineWidth = 2;
11200
+ x.strokeRect(left, top, barWidth, barHeight);
11201
+ x.fillRect(left, top, barWidth * clamp(progress), barHeight);
11202
+ x.restore();
11203
+ }
11204
+
10875
11205
  function drawEngineLogo(t)
10876
11206
  {
10877
11207
  const blackAndWhite = 0;
@@ -11828,18 +12158,7 @@ class PostProcessPlugin
11828
12158
  'gl_Position=vec4(p+p-1.,1,1);'+ // set position
11829
12159
  '}' // end of shader
11830
12160
  ,
11831
- '#version 300 es\n' + // specify GLSL ES version
11832
- 'precision highp float;'+ // use highp for accuracy
11833
- 'uniform sampler2D iChannel0;'+ // input texture
11834
- 'uniform sampler2D iChannel1;'+ // the previous frame's output, when feedbackTexture is set
11835
- 'uniform vec3 iResolution;'+ // size of output texture
11836
- 'uniform float iTime;'+ // time
11837
- 'out vec4 c;'+ // out color
11838
- '\n' + shaderCode + '\n'+ // insert custom shader code
11839
- 'void main(){'+ // shader entry point
11840
- 'mainImage(c,gl_FragCoord.xy);'+ // call post process function
11841
- 'c.a=1.;'+ // always use full alpha
11842
- '}' // end of shader
12161
+ postProcessFragmentSource(shaderCode)
11843
12162
  );
11844
12163
 
11845
12164
  // setup VAO for post processing
@@ -11927,10 +12246,20 @@ class PostProcessPlugin
11927
12246
  glContext.bindTexture(glContext.TEXTURE_2D, postProcess.feedbackTexture);
11928
12247
  }
11929
12248
 
12249
+ // the 3D depth, when render3D draws it, on the third texture unit, with what sceneDepth needs to read it
12250
+ const depth = typeof render3D != 'undefined' && render3D?.depthTexture ? render3D : undefined;
12251
+ glContext.activeTexture(glContext.TEXTURE2);
12252
+ glContext.bindTexture(glContext.TEXTURE_2D, depth?.cameraDepthTexture || null);
12253
+ glContext.activeTexture(glContext.TEXTURE0);
12254
+ const camera = depth?.camera;
12255
+ camera && glContext.uniform3f(glUniformLocation(postProcess.shader, 'iDepthRange'), camera.near,
12256
+ camera.far == Infinity ? 0 : camera.far, camera.orthographic ? 1 : 0);
12257
+
11930
12258
  // set uniforms and draw
11931
12259
  const uniformLocation = (name)=>glUniformLocation(postProcess.shader, name);
11932
12260
  glContext.uniform1i(uniformLocation('iChannel0'), 0);
11933
12261
  glContext.uniform1i(uniformLocation('iChannel1'), 1);
12262
+ glContext.uniform1i(uniformLocation('iChannel2'), 2);
11934
12263
  glContext.uniform1f(uniformLocation('iTime'), time);
11935
12264
  glContext.uniform3f(uniformLocation('iResolution'), mainCanvas.width, mainCanvas.height, 1);
11936
12265
  glContext.drawArrays(glContext.TRIANGLE_STRIP, 0, 4);
@@ -11942,6 +12271,11 @@ class PostProcessPlugin
11942
12271
  glContext.activeTexture(glContext.TEXTURE0);
11943
12272
  }
11944
12273
 
12274
+ // the depth leaves the third unit for whatever draws next
12275
+ glContext.activeTexture(glContext.TEXTURE2);
12276
+ glContext.bindTexture(glContext.TEXTURE_2D, null);
12277
+ glContext.activeTexture(glContext.TEXTURE0);
12278
+
11945
12279
  // restore defaults so subsequent dynamic texture uploads aren't flipped or premultiplied
11946
12280
  glContext.pixelStorei(glContext.UNPACK_FLIP_Y_WEBGL, false);
11947
12281
  glContext.pixelStorei(glContext.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
@@ -11958,6 +12292,31 @@ class PostProcessPlugin
11958
12292
 
11959
12293
  ///////////////////////////////////////////////////////////////////////////////
11960
12294
 
12295
+ // the fragment shader of the post process pass around its mainImage snippet: the frame on iChannel0, the last output
12296
+ // on iChannel1 with a feedback texture, and the 3D depth on iChannel2 when render3D.depthTexture is on, read with
12297
+ // sceneDepth(uv), the distance from the camera along its view in world units, uv 0 to 1 across the screen
12298
+ function postProcessFragmentSource(shaderCode)
12299
+ {
12300
+ return '#version 300 es\n' + // specify GLSL ES version
12301
+ 'precision highp float;'+ // use highp for accuracy
12302
+ 'uniform sampler2D iChannel0;'+ // input texture
12303
+ 'uniform sampler2D iChannel1;'+ // the previous frame's output, when feedbackTexture is set
12304
+ 'uniform sampler2D iChannel2;'+ // the 3D depth, when render3D.depthTexture is on
12305
+ 'uniform vec3 iResolution;'+ // size of output texture
12306
+ 'uniform float iTime;'+ // time
12307
+ 'uniform vec3 iDepthRange;'+ // the camera's near, its far or 0 for none, and 1 when orthographic
12308
+ 'out vec4 c;'+ // out color
12309
+ // the depth texture's value back to a distance, as the camera's projection put it there
12310
+ 'float sceneDepth(vec2 uv){'+
12311
+ 'float d=texture(iChannel2,uv).r*2.-1.,n=iDepthRange.x,f=iDepthRange.y;'+
12312
+ 'return iDepthRange.z>0.?(d*(f-n)+f+n)/2.:f>0.?2.*n*f/(f+n-d*(f-n)):2.*n/(1.-d);}'+
12313
+ '\n' + shaderCode + '\n'+ // insert custom shader code
12314
+ 'void main(){'+ // shader entry point
12315
+ 'mainImage(c,gl_FragCoord.xy);'+ // call post process function
12316
+ 'c.a=1.;'+ // always use full alpha
12317
+ '}'; // end of shader
12318
+ }
12319
+
11961
12320
  /**
11962
12321
  * Shader code for a bloom effect, the bright parts of the image blurred back over it
11963
12322
  * - Pass it to PostProcessPlugin, or edit the string to build an effect on top of it
@@ -19276,7 +19635,9 @@ function tweenUpdate(gameDelta, realDelta)
19276
19635
  for (let i = list.length; i--;)
19277
19636
  {
19278
19637
  const t = list[i];
19279
- if (!t.active || t.activePass === pass) continue; // stopped, or started again by a callback this update
19638
+ // stopped, or started again by a callback this update, or during an update a callback ran inside it, which
19639
+ // counts on from this one
19640
+ if (!t.active || t.activePass >= pass) continue;
19280
19641
  let dt;
19281
19642
  if (enginePath)
19282
19643
  {
@@ -19300,7 +19661,7 @@ function tweenUpdate(gameDelta, realDelta)
19300
19661
  // Completion: fire end value, remove from active, start the next iteration
19301
19662
  // of a loop or pingPong, or when there is none it has completed, fire onComplete
19302
19663
  t.callback(t.interp(0));
19303
- if (!t.active || t.activePass === pass)
19664
+ if (!t.active || t.activePass >= pass)
19304
19665
  continue; // stopped or restarted by its own callback, the run it was on ends without completing
19305
19666
  tweenDeactivate(t);
19306
19667
  const next = t.thenCallback;
@@ -19325,6 +19686,63 @@ function tweenStopAll()
19325
19686
  // Register with the engine so tweens auto-advance.
19326
19687
  engineAddPlugin(tweenUpdate);
19327
19688
 
19689
+ /**
19690
+ * LittleJS Scene System Plugin
19691
+ * - A game's states, like a title, the game and game over, as scene objects
19692
+ * - setScene leaves the current scene, destroys the objects and enters the next
19693
+ * - Objects with the persistent flag set outlive every switch
19694
+ * - Pause is the engine's paused flag, a scene's update stops with gameUpdate
19695
+ * @namespace SceneSystem
19696
+ */
19697
+
19698
+ ///////////////////////////////////////////////////////////////////////////////
19699
+
19700
+ /** A scene, any object with the hooks it needs, each optional and called as a method so this is the scene
19701
+ * @typedef {Object} Scene
19702
+ * @property {function():void} [enter] - Called when the scene starts, after the old one left and the objects were destroyed
19703
+ * @property {function():void} [leave] - Called before the next scene starts, while the scene is current and its objects are still there
19704
+ * @property {function():void} [update] - Called each update after gameUpdate, not while paused or at time scale 0
19705
+ * @property {function():void} [render] - Called before gameRender, to draw under the objects
19706
+ * @property {function():void} [renderPost] - Called after gameRenderPost, to draw over the game, while paused too; plugins made after the first setScene draw over it
19707
+ * @memberof SceneSystem */
19708
+
19709
+ let sceneCurrent, sceneLeaving = false, scenePluginAdded = false;
19710
+
19711
+ /** Leave the current scene, destroy every object that is not persistent, and enter the next scene
19712
+ * - Setting the current scene again restarts it, and no scene leaves the game with none
19713
+ * - The switch happens at once, it can be called from anywhere but a scene's leave
19714
+ * @param {Scene} [scene] - The scene to enter
19715
+ * @memberof SceneSystem */
19716
+ function setScene(scene)
19717
+ {
19718
+ false&&ASSERT(!sceneLeaving, 'setScene can not be called from a scene leave');
19719
+ if (!scenePluginAdded)
19720
+ {
19721
+ scenePluginAdded = true;
19722
+ engineAddPlugin(sceneUpdate, sceneRenderPost, undefined, undefined, sceneRender);
19723
+ }
19724
+
19725
+ if (sceneCurrent)
19726
+ {
19727
+ sceneLeaving = true;
19728
+ sceneCurrent.leave?.();
19729
+ sceneLeaving = false;
19730
+ }
19731
+ engineObjectsDestroy();
19732
+ sceneCurrent = scene;
19733
+ scene?.enter?.();
19734
+ }
19735
+
19736
+ /** Get the current scene
19737
+ * @return {Scene|undefined}
19738
+ * @memberof SceneSystem */
19739
+ function getScene() { return sceneCurrent; }
19740
+
19741
+ // the plugin hooks, update skips the frozen ticks that skip gameUpdate
19742
+ function sceneUpdate() { paused || !timeScale || sceneCurrent?.update?.(); }
19743
+ function sceneRender() { sceneCurrent?.render?.(); }
19744
+ function sceneRenderPost() { sceneCurrent?.renderPost?.(); }
19745
+
19328
19746
  /**
19329
19747
  * LittleJS PathFinder Plugin
19330
19748
  * - Grid-based A* pathfinder with two-pass smoothing for natural-looking paths
@@ -20798,36 +21216,62 @@ class Ray3D
20798
21216
 
20799
21217
  ///////////////////////////////////////////////////////////////////////////////
20800
21218
  // 3D collision helpers, none of them change anything that is passed in
20801
- // Boxes sit centered on pos and take a full size, like drawRect
21219
+ // Boxes sit centered on pos and take a full size, like drawRect, upright unless given a rotation, an Euler vec3 like
21220
+ // rotation3D, which turns them about their center
20802
21221
  // Cylinders stand up the Y axis, centered on pos, with a full height
20803
21222
  // Names that could be mistaken for 2D functions get a 3D suffix
20804
21223
 
21224
+ // whether a rotation turns anything, a missing one does not
21225
+ const isTurned3D = (rotation)=> !!rotation && !!(rotation.x || rotation.y || rotation.z);
21226
+
21227
+ // a turned box's three unit axes, the columns of its rotation
21228
+ function boxAxes3D(rotation)
21229
+ {
21230
+ const m = buildMatrix(undefined, rotation).m;
21231
+ return [vec3(m[0], m[1], m[2]), vec3(m[4], m[5], m[6]), vec3(m[8], m[9], m[10])];
21232
+ }
21233
+
21234
+ // a point in a turned box's own space, from its center, and a vector from that space back to the world's
21235
+ function boxLocal3D(point, pos, axes)
21236
+ {
21237
+ const d = point.subtract(pos);
21238
+ return vec3(d.dot(axes[0]), d.dot(axes[1]), d.dot(axes[2]));
21239
+ }
21240
+ const boxWorld3D = (v, axes)=> axes[0].scale(v.x).add(axes[1].scale(v.y)).add(axes[2].scale(v.z));
21241
+
20805
21242
  /**
20806
- * Check if a point is inside an axis aligned box, boundary is inclusive
21243
+ * Check if a point is inside a box, boundary is inclusive
20807
21244
  * @param {Vector3} point
20808
21245
  * @param {Vector3} pos - Center of the box
20809
21246
  * @param {Vector3} size - Full size of the box
21247
+ * @param {Vector3} [rotation] - How the box is turned, upright when left out
20810
21248
  * @return {boolean}
20811
21249
  * @memberof Math3D
20812
21250
  */
20813
- function isPointInBox3D(point, pos, size)
21251
+ function isPointInBox3D(point, pos, size, rotation)
20814
21252
  {
21253
+ if (isTurned3D(rotation))
21254
+ return isPointInBox3D(boxLocal3D(point, pos, boxAxes3D(rotation)), vec3(), size);
20815
21255
  return abs(point.x - pos.x) <= size.x/2 &&
20816
21256
  abs(point.y - pos.y) <= size.y/2 &&
20817
21257
  abs(point.z - pos.z) <= size.z/2;
20818
21258
  }
20819
21259
 
20820
21260
  /**
20821
- * Check if two axis aligned boxes are overlapping, touching edges do not overlap
21261
+ * Check if two boxes are overlapping, touching edges do not overlap
20822
21262
  * @param {Vector3} posA
20823
21263
  * @param {Vector3} sizeA - Full size of box A
20824
21264
  * @param {Vector3} posB
20825
21265
  * @param {Vector3} [sizeB] - Full size of box B, zero for a point
21266
+ * @param {Vector3} [rotationA] - How box A is turned, upright when left out
21267
+ * @param {Vector3} [rotationB] - How box B is turned
20826
21268
  * @return {boolean}
20827
21269
  * @memberof Math3D
20828
21270
  */
20829
- function isOverlapping3D(posA, sizeA, posB, sizeB=vec3())
21271
+ function isOverlapping3D(posA, sizeA, posB, sizeB=vec3(), rotationA, rotationB)
20830
21272
  {
21273
+ if (isTurned3D(rotationA) || isTurned3D(rotationB))
21274
+ return !!collideBoxBox3D(posA, sizeA, posB, sizeB, rotationA, rotationB);
20831
21275
  const d = posA.subtract(posB);
20832
21276
  return abs(d.x) < (sizeA.x + sizeB.x)/2 &&
20833
21277
  abs(d.y) < (sizeA.y + sizeB.y)/2 &&
@@ -20856,16 +21300,19 @@ function collideSphereSphere(posA, radiusA, posB, radiusB)
20856
21300
  }
20857
21301
 
20858
21302
  /**
20859
- * Returns the vector to move a sphere out of an axis aligned box, or undefined
21303
+ * Returns the vector to move a sphere out of a box, or undefined
20860
21304
  * @param {Vector3} pos - Sphere center
20861
21305
  * @param {number} radius
20862
21306
  * @param {Vector3} boxPos
20863
21307
  * @param {Vector3} boxSize - Full size of the box
21308
+ * @param {Vector3} [boxRotation] - How the box is turned, upright when left out
20864
21309
  * @return {Vector3|undefined}
20865
21310
  * @memberof Math3D
20866
21311
  */
20867
- function collideSphereBox(pos, radius, boxPos, boxSize)
21312
+ function collideSphereBox(pos, radius, boxPos, boxSize, boxRotation)
20868
21313
  {
21314
+ if (isTurned3D(boxRotation))
21315
+ return collideSphereOrientedBox3D(pos, radius, boxPos, boxSize, boxAxes3D(boxRotation));
20869
21316
  const h = boxSize.scale(.5);
20870
21317
  const closest = vec3(
20871
21318
  clamp(pos.x, boxPos.x - h.x, boxPos.x + h.x),
@@ -20953,15 +21400,20 @@ function collideSphereCylinder(pos, radius, cylinderPos, cylinderRadius, cylinde
20953
21400
  /**
20954
21401
  * Returns the vector to move box A by so it no longer overlaps box B, the shortest way out, or undefined
20955
21402
  * - The 3D twin of collideBoxBox
21403
+ * - Turned boxes are tested by the separating axis test, the push is along the direction they overlap least on
20956
21404
  * @param {Vector3} posA
20957
21405
  * @param {Vector3} sizeA - Full size of box A
20958
21406
  * @param {Vector3} posB
20959
21407
  * @param {Vector3} sizeB - Full size of box B
21408
+ * @param {Vector3} [rotationA] - How box A is turned, upright when left out
21409
+ * @param {Vector3} [rotationB] - How box B is turned
20960
21410
  * @return {Vector3|undefined}
20961
21411
  * @memberof Math3D
20962
21412
  */
20963
- function collideBoxBox3D(posA, sizeA, posB, sizeB)
21413
+ function collideBoxBox3D(posA, sizeA, posB, sizeB, rotationA, rotationB)
20964
21414
  {
21415
+ if (isTurned3D(rotationA) || isTurned3D(rotationB))
21416
+ return collideOrientedBoxes3D(posA, sizeA, boxAxes3D(rotationA), posB, sizeB, boxAxes3D(rotationB));
20965
21417
  const d = posA.subtract(posB);
20966
21418
  const overlapX = (sizeA.x + sizeB.x)/2 - abs(d.x);
20967
21419
  const overlapY = (sizeA.y + sizeB.y)/2 - abs(d.y);
@@ -20971,6 +21423,44 @@ function collideBoxBox3D(posA, sizeA, posB, sizeB)
20971
21423
  return pushOutAxis3D(d, overlapX, overlapY, overlapZ);
20972
21424
  }
20973
21425
 
21426
+ // the push to move a sphere out of a box turned to these axes: in the box's own space it is upright, and the push
21427
+ // goes back out turned
21428
+ function collideSphereOrientedBox3D(pos, radius, boxPos, boxSize, axes)
21429
+ {
21430
+ const push = collideSphereBox(boxLocal3D(pos, boxPos, axes), radius, vec3(), boxSize);
21431
+ return push && boxWorld3D(push, axes);
21432
+ }
21433
+
21434
+ // the world's axes, for an upright box among turned ones
21435
+ const BOX_WORLD_AXES = Object.freeze([vec3(1, 0, 0), vec3(0, 1, 0), vec3(0, 0, 1)]);
21436
+
21437
+ // the shortest push to move box A out of box B by the separating axis test: each box's three face directions and
21438
+ // the nine across an edge of each; a gap on any of them is no touch, and the push is along the least overlap, from
21439
+ // B to A; an edge direction has to overlap clearly less to win, so a box lying on another leaves by the face
21440
+ function collideOrientedBoxes3D(posA, sizeA, axesA, posB, sizeB, axesB)
21441
+ {
21442
+ const d = posA.subtract(posB), ha = sizeA.scale(.5), hb = sizeB.scale(.5);
21443
+ const reach = (h, axes, n)=> h.x * abs(axes[0].dot(n)) + h.y * abs(axes[1].dot(n)) + h.z * abs(axes[2].dot(n));
21444
+ let push, least = Infinity;
21445
+ const separated = (axis, weight)=>
21446
+ {
21447
+ const lengthSquared = axis.lengthSquared();
21448
+ if (lengthSquared < 1e-12) return false; // parallel edges give no direction to test
21449
+ const n = axis.scale(lengthSquared ** -.5), along = d.dot(n);
21450
+ const overlap = reach(ha, axesA, n) + reach(hb, axesB, n) - abs(along);
21451
+ if (overlap <= 0) return true;
21452
+ if (overlap * weight < least)
21453
+ least = overlap * weight, push = n.scale(along < 0 ? -overlap : overlap);
21454
+ return false;
21455
+ };
21456
+ for (const axis of [...axesA, ...axesB])
21457
+ if (separated(axis, 1)) return;
21458
+ for (const a of axesA)
21459
+ for (const b of axesB)
21460
+ if (separated(a.cross(b), 1.05)) return;
21461
+ return push;
21462
+ }
21463
+
20974
21464
  /**
20975
21465
  * Returns the distance along the ray to the first intersection with a sphere, or undefined
20976
21466
  * - The hit is ray.getPosition(distance), a direction that is not unit length scales the distance
@@ -21019,17 +21509,25 @@ function raycastPlane(ray, planePos, planeNormal)
21019
21509
  }
21020
21510
 
21021
21511
  /**
21022
- * Returns the distance along the ray to the first intersection with an axis aligned box, or undefined
21512
+ * Returns the distance along the ray to the first intersection with a box, or undefined
21023
21513
  * - The hit is ray.getPosition(distance), a direction that is not unit length scales the distance
21024
21514
  * - A ray starting inside the box is already there, so it gets back 0
21025
21515
  * @param {Ray3D} ray
21026
21516
  * @param {Vector3} pos - Center of the box
21027
21517
  * @param {Vector3} size - Full size of the box
21518
+ * @param {Vector3} [rotation] - How the box is turned, upright when left out
21028
21519
  * @return {number|undefined}
21029
21520
  * @memberof Math3D
21030
21521
  */
21031
- function raycastBox(ray, pos, size)
21522
+ function raycastBox(ray, pos, size, rotation)
21032
21523
  {
21524
+ if (isTurned3D(rotation))
21525
+ {
21526
+ // the ray in the box's own space, where the box is upright; turning keeps the distances
21527
+ const axes = boxAxes3D(rotation), d = ray.direction;
21528
+ const direction = vec3(d.dot(axes[0]), d.dot(axes[1]), d.dot(axes[2]));
21529
+ return raycastBox(new Ray3D(boxLocal3D(ray.origin, pos, axes), direction), vec3(), size);
21530
+ }
21033
21531
  const {origin, direction} = ray;
21034
21532
  const h = size.scale(.5);
21035
21533
  const boxMin = pos.subtract(h), boxMax = pos.add(h);
@@ -21128,9 +21626,11 @@ function render3DCanDraw()
21128
21626
  }
21129
21627
 
21130
21628
  // the draw state fields a batch is drawn under; lights and fog are not captured, they are read live at flush
21131
- // the three functions below write them out by hand for speed, so a new field goes in all four
21629
+ // the three functions below write them out by hand for speed, so a new field goes in all four; emissiveMapColor is
21630
+ // compared by its values, so two objects with equal colors batch
21132
21631
  const RENDER3D_STATE_FIELDS = ['blend', 'additive', 'depthTest', 'depthWrite', 'cullBackFaces', 'mirrored', 'lighting',
21133
- 'emissive', 'receiveShadow', 'specular', 'pixelated', 'shader'];
21632
+ 'emissive', 'receiveShadow', 'specular', 'pixelated', 'shader', 'normalMap', 'normalScale', 'shininess',
21633
+ 'reflectivity', 'emissiveMap', 'emissiveMapColor'];
21134
21634
 
21135
21635
  // a copy of the draw state in one fixed shape, the fields of RENDER3D_STATE_FIELDS written out so the
21136
21636
  // compare below stays a handful of direct reads, it runs for every instance drawn
@@ -21139,7 +21639,9 @@ function render3DCaptureBatchState()
21139
21639
  const r = render3D;
21140
21640
  return {blend: r.blend, additive: r.additive, depthTest: r.depthTest, depthWrite: r.depthWrite,
21141
21641
  cullBackFaces: r.cullBackFaces, mirrored: r.mirrored, lighting: r.lighting, emissive: r.emissive,
21142
- receiveShadow: r.receiveShadow, specular: r.specular, pixelated: r.pixelated, shader: r.shader};
21642
+ receiveShadow: r.receiveShadow, specular: r.specular, pixelated: r.pixelated, shader: r.shader,
21643
+ normalMap: r.normalMap, normalScale: r.normalScale, shininess: r.shininess, reflectivity: r.reflectivity,
21644
+ emissiveMap: r.emissiveMap, emissiveMapColor: (r.emissiveMapColor || WHITE).copy()}; // the caller may change it
21143
21645
  }
21144
21646
 
21145
21647
  // put a captured draw state back, written out the same way; the transparent stage does this for every queued draw
@@ -21148,7 +21650,9 @@ function render3DApplyBatchState(s)
21148
21650
  const r = render3D;
21149
21651
  r.blend = s.blend, r.additive = s.additive, r.depthTest = s.depthTest, r.depthWrite = s.depthWrite,
21150
21652
  r.cullBackFaces = s.cullBackFaces, r.mirrored = s.mirrored, r.lighting = s.lighting, r.emissive = s.emissive,
21151
- r.receiveShadow = s.receiveShadow, r.specular = s.specular, r.pixelated = s.pixelated, r.shader = s.shader;
21653
+ r.receiveShadow = s.receiveShadow, r.specular = s.specular, r.pixelated = s.pixelated, r.shader = s.shader,
21654
+ r.normalMap = s.normalMap, r.normalScale = s.normalScale, r.shininess = s.shininess,
21655
+ r.reflectivity = s.reflectivity, r.emissiveMap = s.emissiveMap, r.emissiveMapColor = s.emissiveMapColor;
21152
21656
  }
21153
21657
 
21154
21658
  // true when the current draw state differs from a captured one, so a pending batch must flush first
@@ -21158,13 +21662,20 @@ function render3DStateChanged(s)
21158
21662
  return r.blend !== s.blend || r.additive !== s.additive || r.depthTest !== s.depthTest
21159
21663
  || r.depthWrite !== s.depthWrite || r.cullBackFaces !== s.cullBackFaces || r.mirrored !== s.mirrored
21160
21664
  || r.lighting !== s.lighting || r.emissive !== s.emissive || r.receiveShadow !== s.receiveShadow
21161
- || r.specular !== s.specular || r.pixelated !== s.pixelated || r.shader !== s.shader;
21665
+ || r.specular !== s.specular || r.pixelated !== s.pixelated || r.shader !== s.shader
21666
+ || r.normalMap !== s.normalMap || r.normalScale !== s.normalScale || r.shininess !== s.shininess
21667
+ || r.reflectivity !== s.reflectivity || r.emissiveMap !== s.emissiveMap
21668
+ || render3DColorChanged(r.emissiveMapColor || WHITE, s.emissiveMapColor);
21669
+ }
21670
+
21671
+ // whether two colors differ in their rgb, for the batch key's emissiveMapColor
21672
+ function render3DColorChanged(a, b) { return a.r !== b.r || a.g !== b.g || a.b !== b.b;
21162
21673
  }
21163
21674
 
21164
21675
  // whether a sphere is inside the view, or the shadow map's box during the shadow pass, without a vector
21165
21676
  function render3DSphereVisible(x, y, z, radius)
21166
21677
  {
21167
- const planes = render3D.shadowPass ? render3D.shadowPlanes : render3D.frustumPlanes;
21678
+ const planes = render3D.shadowPass && !render3D.depthPass ? render3D.shadowPlanes : render3D.frustumPlanes;
21168
21679
  for (let i = 0; i < planes.length; ++i)
21169
21680
  {
21170
21681
  const p = planes[i];
@@ -21264,6 +21775,15 @@ function render3DSetObjectState(o)
21264
21775
  r.emissive = emissive;
21265
21776
  r.additive = !!o?.additive;
21266
21777
  r.specular = o?.specular || 0;
21778
+ const shininess = o?.shininess ?? 16, reflectivity = o?.reflectivity || 0;
21779
+ false&&ASSERT(isNumber(shininess) && shininess > 0, 'shininess must be a number above 0', shininess);
21780
+ false&&ASSERT(isNumber(reflectivity) && reflectivity >= 0 && reflectivity <= 1, 'reflectivity must be 0 to 1', reflectivity);
21781
+ r.shininess = shininess;
21782
+ r.reflectivity = reflectivity;
21783
+ r.normalMap = o?.normalMap || undefined;
21784
+ r.normalScale = o?.normalScale ?? 1;
21785
+ r.emissiveMap = o?.emissiveMap || undefined;
21786
+ r.emissiveMapColor = o?.emissiveMapColor || WHITE;
21267
21787
  r.receiveShadow = !o || o.receiveShadow;
21268
21788
  r.cullBackFaces = r.mirrored = false; // each mesh sets these as it draws
21269
21789
  r.pixelated = !!o?.pixelated;
@@ -21497,6 +22017,11 @@ class Render3DPlugin
21497
22017
  * flat faces */
21498
22018
  this.smoothShading = false;
21499
22019
 
22020
+ /** @property {boolean} - Draw the camera's depth into a texture each frame for post processing:
22021
+ * PostProcessPlugin hands it to its shader as iChannel2, read with sceneDepth(uv); off by default and free
22022
+ * when off, on it draws the solid objects of the default layer once more, depth only */
22023
+ this.depthTexture = false;
22024
+
21500
22025
  // shadows
21501
22026
  /** @property {boolean} - Cast real shadows from the sun, off by default and free when off */
21502
22027
  this.shadows = false;
@@ -21533,8 +22058,23 @@ class Render3DPlugin
21533
22058
  this.cullBackFaces = false;
21534
22059
  this.mirrored = false;
21535
22060
  /** @property {number} - Strength of the highlight where the sun and the Light3D objects reflect, 0 is none and
21536
- * 1 adds a light's full color at its brightest; its size is fixed */
22061
+ * 1 adds a light's full color at its brightest; shininess sets its size */
21537
22062
  this.specular = 0;
22063
+ /** @property {number} - The highlight's exponent, how small and sharp it is: 4 is broad like rubber, 100 sharp
22064
+ * like polished metal; set from each object's shininess */
22065
+ this.shininess = 16;
22066
+ /** @property {TextureInfo|undefined} - Normal map for the next draws, set from each object's normalMap
22067
+ * @type {TextureInfo|undefined} */
22068
+ this.normalMap = undefined;
22069
+ /** @property {number} - How strongly the normal map bends the surface, set from each object's normalScale */
22070
+ this.normalScale = 1;
22071
+ /** @property {number} - How much the surface reflects the sky, 0 to 1, set from each object's reflectivity */
22072
+ this.reflectivity = 0;
22073
+ /** @property {TextureInfo|undefined} - Emissive map for the next draws, set from each object's emissiveMap
22074
+ * @type {TextureInfo|undefined} */
22075
+ this.emissiveMap = undefined;
22076
+ /** @property {Color} - Multiplies the emissive map, set from each object's emissiveMapColor */
22077
+ this.emissiveMapColor = WHITE;
21538
22078
  /** @property {Shader|undefined} - Custom Shader for the next draws, set from each object's shader; undefined
21539
22079
  * draws with the plugin's own
21540
22080
  * @type {Shader|undefined} */
@@ -21636,6 +22176,13 @@ class Render3DPlugin
21636
22176
  /** @type {WebGLFramebuffer|undefined} */
21637
22177
  this.shadowFramebuffer = undefined;
21638
22178
  this.shadowTextureSize = 0;
22179
+ /** @type {WebGLTexture|undefined} */
22180
+ this.cameraDepthTexture = undefined; // the camera's depth, drawn when depthTexture is on
22181
+ /** @type {WebGLFramebuffer|undefined} */
22182
+ this.cameraDepthFramebuffer = undefined;
22183
+ this.cameraDepthWidth = 0;
22184
+ this.cameraDepthHeight = 0;
22185
+ this.depthPass = false; // drawing the camera's depth, a shadow pass seen from the camera
21639
22186
  this.contextGeneration = 0; // counts context losses, a mesh uploaded under an older one uploads again
21640
22187
  this.uniforms = new Map; // uniform locations by program
21641
22188
  /** @type {Object<string, Array<number>>} */
@@ -22063,7 +22610,8 @@ class Render3DPlugin
22063
22610
  // a far plane at Infinity has no midpoint, so put it a long way out instead
22064
22611
  const {near, far} = this.camera;
22065
22612
  const radius = far == Infinity ? near * 1e4 : (near + far) / 2;
22066
- render3DWithState({lighting: false, blend: false, depthTest: false, depthWrite: false, fogEnd: 0, shader: undefined}, ()=>
22613
+ render3DWithState({lighting: false, blend: false, depthTest: false, depthWrite: false, fogEnd: 0, shader: undefined,
22614
+ emissiveMap: undefined}, ()=>
22067
22615
  this.drawMesh(this.sky, buildMatrix(this.camera.pos, undefined, vec3(radius))));
22068
22616
  }
22069
22617
 
@@ -22188,8 +22736,10 @@ class Render3DPlugin
22188
22736
  drawQuad(a, b, c, d, tileInfo, color=WHITE)
22189
22737
  {
22190
22738
  if (this.transparentQueue) // the queue replays later, so it keeps copies of what a caller may reuse
22739
+ {
22191
22740
  a = a.copy(), b = b.copy(), c = c.copy(), d = d.copy();
22192
22741
  color = isArray(color) ? color.map(k=> k.copy()) : color.copy();
22742
+ }
22193
22743
  this.drawStrip(render3DQuadStrip(a, b, c, d), render3DFaceNormal(a, b, c, d), RENDER3D_QUAD_UVS,
22194
22744
  render3DQuadValues(color), tileInfo);
22195
22745
  }
@@ -22488,7 +23038,9 @@ const RENDER3D_SNIPPET_NAMES =
22488
23038
  // uniforms: lightDir (xyz the way the sunlight travels, w = emissive, 1 or more skips the lighting),
22489
23039
  // lightColor (the sun's rgb, a = specular), ambientFog (rgb, a = fogEnd), fogColor (rgb, a = fogStart),
22490
23040
  // cameraPos, tex, shadowMap, shadowParams (x = shadows on, y = bias, z = blur step in texture space,
22491
- // w = how the draw finishes: 1 opaque and alpha tested, 0 blended, -1 additive)
23041
+ // w = how the draw finishes: 1 opaque and alpha tested, 0 blended, -1 additive),
23042
+ // materialParams (x = normal map scale, 0 skips it, y = shininess, z = reflectivity), emissiveTint (rgb, a = the
23043
+ // emissive map is on), skyTop, skyHorizon, skyBottom (what a reflection shows), normalTex, emissiveTex
22492
23044
  function render3DFragmentSource(fragmentCode)
22493
23045
  {
22494
23046
  return '#version 300 es\n' +
@@ -22497,7 +23049,8 @@ function render3DFragmentSource(fragmentCode)
22497
23049
  'uniform vec4 extraLights[' + RENDER3D_MAX_LIGHTS + '],extraLightColors[' + RENDER3D_MAX_LIGHTS + '];' +
22498
23050
  'uniform int extraLightCount;' +
22499
23051
  'uniform vec3 cameraPos;' +
22500
- 'uniform sampler2D tex;' +
23052
+ 'uniform vec4 materialParams,emissiveTint,skyTop,skyHorizon,skyBottom;' +
23053
+ 'uniform sampler2D tex,normalTex,emissiveTex;' +
22501
23054
  'uniform bool premultipliedTexture;' + // is the texture a render target, which holds premultiplied color
22502
23055
  'uniform highp sampler2DShadow shadowMap;' +
22503
23056
  'in vec3 P,N;in vec2 T,L;in vec4 C,S;' +
@@ -22512,6 +23065,17 @@ function render3DFragmentSource(fragmentCode)
22512
23065
  'for(int x=-1;x<=1;++x)for(int y=-1;y<=1;++y)' +
22513
23066
  's+=texture(shadowMap,vec3(q.xy+vec2(x,y)*shadowParams.z,q.z));' +
22514
23067
  'return s/9.;}' +
23068
+ // the normal map's normal here, in the frame that the position and texture coordinate change along across the
23069
+ // screen, so a mesh needs no tangents; green points up the image and v runs down it, so up is -v;
23070
+ // a mesh with no texture coordinates has no frame and keeps its own normal
23071
+ 'vec3 normalMapNormal(vec3 n){' +
23072
+ 'vec3 p1=dFdx(P),p2=dFdy(P);vec2 t1=dFdx(T),t2=dFdy(T);' +
23073
+ 'vec3 a=cross(p2,n),b=cross(n,p1),u=a*t1.x+b*t2.x,v=a*t1.y+b*t2.y;' +
23074
+ 'float k=max(dot(u,u),dot(v,v));' +
23075
+ 'if(k<=0.)return n;' +
23076
+ 'vec3 m=texture(normalTex,T).xyz*2.-1.;' +
23077
+ 'm.xy*=materialParams.x;' +
23078
+ 'return normalize((u*m.x-v*m.y)*inversesqrt(k)+n*m.z);}' +
22515
23079
  (fragmentCode ? RENDER3D_SNIPPET_NAMES + fragmentCode + '\n' : '') +
22516
23080
  'void main(){' +
22517
23081
  (fragmentCode ? 'vec4 t;mainImage(t,T);' : 'vec4 t=texture(tex,T);') +
@@ -22522,6 +23086,7 @@ function render3DFragmentSource(fragmentCode)
22522
23086
  'if(e<1.){' +
22523
23087
  'vec3 n=dot(N,N)>0.?normalize(N):vec3(0,1,0);' +
22524
23088
  'if(!gl_FrontFacing)n=-n;' + // only a double sided mesh shows a back face, light it on the side that is seen
23089
+ 'if(materialParams.x!=0.)n=normalMapNormal(n);' +
22525
23090
  'float nl=dot(n,-lightDir.xyz);' +
22526
23091
  'float s=shadow();' +
22527
23092
  // the ambient: one color, or blended from the ground color below to the sky color above by the way the face points
@@ -22540,15 +23105,24 @@ function render3DFragmentSource(fragmentCode)
22540
23105
  'float ln=dot(n,v);' +
22541
23106
  'vec3 lc=extraLightColors[i].rgb*extraLightColors[i].a*a*a;' +
22542
23107
  'l+=lc*max(0.,ln);' +
22543
- 'if(lightColor.a>0.)sp+=lc*pow(max(dot(reflect(-v,n),eye),0.),16.)*step(0.,ln);' +
23108
+ 'if(lightColor.a>0.)sp+=lc*pow(max(dot(reflect(-v,n),eye),0.),materialParams.y)*step(0.,ln);' +
22544
23109
  '}' +
22545
23110
  'c.rgb*=l*(1.-e)+e;' + // lit, blended toward its own color by how emissive it is
22546
23111
  // specular: the sun's only where its light hits and out of shadow, then the Light3D highlights,
22547
23112
  // skipped entirely when the strength is zero
22548
23113
  'if(lightColor.a>0.){' +
22549
23114
  'vec3 r=reflect(lightDir.xyz,n);' +
22550
- 'c.rgb+=lightColor.rgb*pow(max(dot(r,eye),0.),16.)*lightColor.a*step(0.,nl)*s*(1.-e)+sp*lightColor.a*(1.-e);' +
23115
+ 'c.rgb+=lightColor.rgb*pow(max(dot(r,eye),0.),materialParams.y)*lightColor.a*step(0.,nl)*s*(1.-e)+sp*lightColor.a*(1.-e);' +
23116
+ '}' +
23117
+ // the sky along the reflected view, more at a glancing angle (Schlick's Fresnel); sky light, so the sun's
23118
+ // shadow does not dim it
23119
+ 'if(materialParams.z>0.){' +
23120
+ 'vec3 w=normalize(P-cameraPos),q=reflect(w,n);' +
23121
+ 'float f=materialParams.z+(1.-materialParams.z)*pow(1.-max(dot(n,-w),0.),5.);' +
23122
+ 'c.rgb=mix(c.rgb,q.y>0.?mix(skyHorizon.rgb,skyTop.rgb,q.y):mix(skyHorizon.rgb,skyBottom.rgb,-q.y),f);' +
22551
23123
  '}}else c.rgb*=e;' + // fully emissive: its own color, or brighter, with no lighting to work out
23124
+ // the emissive map adds its light on top, lit or not
23125
+ 'if(emissiveTint.a>0.)c.rgb+=texture(emissiveTex,T).rgb*emissiveTint.rgb;' +
22552
23126
  'if(ambientFog.a>0.){' +
22553
23127
  'float z=distance(cameraPos,P);' +
22554
23128
  'c.rgb=mix(c.rgb,shadowParams.w<0.?vec3(0):fogColor.rgb,smoothstep(fogColor.a,ambientFog.a,z));' +
@@ -22575,6 +23149,8 @@ function render3DUseProgram(program)
22575
23149
  gl.uniformMatrix4fv(render3DUniform('lightViewProj'), false, r.shadowMatrix.m);
22576
23150
  gl.uniform1i(render3DUniform('tex'), 0);
22577
23151
  gl.uniform1i(render3DUniform('shadowMap'), 1);
23152
+ gl.uniform1i(render3DUniform('normalTex'), 2);
23153
+ gl.uniform1i(render3DUniform('emissiveTex'), 3);
22578
23154
  const c = r.camera.pos;
22579
23155
  gl.uniform3f(render3DUniform('cameraPos'), c.x, c.y, c.z);
22580
23156
  gl.uniform1i(render3DUniform('extraLightCount'), r.lightCount);
@@ -22667,6 +23243,8 @@ function render3DContextLost()
22667
23243
  render3DClearInstances();
22668
23244
  r.shadowFramebuffer = r.shadowTexture = undefined;
22669
23245
  r.shadowTextureSize = 0;
23246
+ r.cameraDepthFramebuffer = r.cameraDepthTexture = undefined;
23247
+ r.cameraDepthWidth = r.cameraDepthHeight = 0;
22670
23248
  r.streamCount = 0;
22671
23249
  ++r.contextGeneration; // every uploaded mesh is stale now, the soft dot is a TextureInfo the engine restores
22672
23250
  }
@@ -22745,23 +23323,66 @@ function render3DSampler(wrap, pixelated)
22745
23323
  }
22746
23324
 
22747
23325
  // bind the texture of a tile or texture, white when there is none or it is not loaded, with the 3D sampler that
22748
- // matches its wrap mode; the first time a texture is used in 3D it gets its mipmaps
22749
- function render3DBindTexture(tileInfo, state=render3D)
23326
+ // matches its wrap mode; the first time a texture is used in 3D it gets its mipmaps; on unit 0, the color texture,
23327
+ // or another unit, the material maps, after which unit 0 is active again
23328
+ function render3DBindTexture(tileInfo, state=render3D, unit=0)
22750
23329
  {
22751
23330
  const gl = glContext, r = render3D;
22752
23331
  const textureInfo = render3DTextureOf(tileInfo);
22753
23332
  const texture = textureInfo?.glTexture || r.whiteTexture;
23333
+ unit && gl.activeTexture(gl.TEXTURE0 + unit);
22754
23334
  gl.bindTexture(gl.TEXTURE_2D, texture);
22755
23335
  if (texture === r.whiteTexture || !r.mipmaps && !state.pixelated)
22756
- return gl.bindSampler(0, null); // the texture's own filtering, as in 2D; the white texel needs no mipmaps
22757
- // or anisotropy, and filtering it that way costs every untextured fragment
22758
- gl.bindSampler(0, render3DSampler(textureInfo?.wrap, state.pixelated));
22759
- glUpdateMipmaps(texture); // drawn into since its mipmaps were made
22760
- if (!state.pixelated && !glMipmappedTextures.has(texture)) // a hard edged draw never reads them
23336
+ gl.bindSampler(unit, null); // the texture's own filtering, as in 2D; the white texel needs no mipmaps
23337
+ // or anisotropy, and filtering it that way costs every untextured fragment
23338
+ else
22761
23339
  {
22762
- glMipmappedTextures.add(texture); // the core makes them again when the texture changes
22763
- gl.generateMipmap(gl.TEXTURE_2D);
23340
+ gl.bindSampler(unit, render3DSampler(textureInfo?.wrap, state.pixelated));
23341
+ glUpdateMipmaps(texture); // drawn into since its mipmaps were made
23342
+ if (!state.pixelated && !glMipmappedTextures.has(texture)) // a hard edged draw never reads them
23343
+ {
23344
+ glMipmappedTextures.add(texture); // the core makes them again when the texture changes
23345
+ gl.generateMipmap(gl.TEXTURE_2D);
23346
+ }
22764
23347
  }
23348
+ unit && gl.activeTexture(gl.TEXTURE0);
23349
+ }
23350
+
23351
+ // the material's uniforms and maps for a draw: the normal map on unit 2 and the emissive map on unit 3, white when
23352
+ // unused and bound only when they change, and the sky a reflection shows, the colors setSky was given or the
23353
+ // ambient ones; with nothing set every part of it is skipped in the shader, and a map with no GL texture yet, or
23354
+ // one freed, counts as none, since the white texture in its place would bend every normal or light the surface
23355
+ function render3DSetMaterialUniforms(state)
23356
+ {
23357
+ const r = render3D, loaded = (map)=> render3DTextureOf(map)?.glTexture ? map : undefined;
23358
+ const normalMap = state.normalScale ? loaded(state.normalMap) : undefined, emissiveMap = loaded(state.emissiveMap);
23359
+ render3DUniform4f('materialParams', normalMap ? state.normalScale : 0, state.shininess, state.reflectivity, 0);
23360
+ const ec = state.emissiveMapColor || WHITE;
23361
+ emissiveMap ? render3DUniform4f('emissiveTint', ec.r, ec.g, ec.b, 1) : render3DUniform4f('emissiveTint', 0, 0, 0, 0);
23362
+ render3DBindMap(2, normalMap, state);
23363
+ render3DBindMap(3, emissiveMap, state);
23364
+ if (state.reflectivity > 0)
23365
+ {
23366
+ const sky = r.sky && render3DSkyColors.get(r.sky), a = r.ambientColor, g = r.ambientGroundColor || a;
23367
+ const top = sky ? sky[0] : a, bottom = sky ? sky[2] : g;
23368
+ render3DUniform4f('skyTop', top.r, top.g, top.b, 1);
23369
+ render3DUniform4f('skyBottom', bottom.r, bottom.g, bottom.b, 1);
23370
+ sky ? render3DUniform4f('skyHorizon', sky[1].r, sky[1].g, sky[1].b, 1) :
23371
+ render3DUniform4f('skyHorizon', (a.r + g.r) / 2, (a.g + g.g) / 2, (a.b + g.b) / 2, 1);
23372
+ }
23373
+ }
23374
+
23375
+ // what each material map unit has bound, index 2 and 3 the maps and 4 and 5 whether each is pixelated, forgotten at
23376
+ // the start and end of each pass
23377
+ let render3DBoundMaps = [];
23378
+
23379
+ // bind a material map to its unit, or white for none, only when it or its filtering changed since the last draw
23380
+ function render3DBindMap(unit, map, state)
23381
+ {
23382
+ const bound = render3DBoundMaps, pixelated = !!map && state.pixelated;
23383
+ if (bound[unit] === map && bound[unit + 2] === pixelated) return;
23384
+ bound[unit] = map, bound[unit + 2] = pixelated;
23385
+ render3DBindTexture(map, state, unit);
22765
23386
  }
22766
23387
 
22767
23388
  // send a vec4 uniform of the main shader only when its value changed since the last send
@@ -22901,6 +23522,7 @@ function render3DSetDrawUniforms(matrix, tileInfo, tint, uvRect, state=render3D)
22901
23522
  // a render target's texture holds premultiplied color, the blend writes it that way, so the shader undoes it
22902
23523
  const textureInfo = render3DTextureOf(tileInfo);
22903
23524
  render3DUniform1i('premultipliedTexture', +!!(textureInfo?.glTexture && glPremultipliedTextures.has(textureInfo.glTexture)));
23525
+ render3DSetMaterialUniforms(state);
22904
23526
  }
22905
23527
 
22906
23528
  // the six flat sides of the camera's visible box, each as [x, y, z, w] facing inward
@@ -22922,6 +23544,7 @@ function render3DFrustumPlanes(matrix)
22922
23544
  function render3DPreRender()
22923
23545
  {
22924
23546
  const r = render3D;
23547
+ editor3DCameraBegin(); // debug builds draw with the free camera or the 3D editor's, the game's is back after
22925
23548
  r.updateMatrices();
22926
23549
  r.shadowMapDrawn = false;
22927
23550
  render3DRenderPass(false);
@@ -22931,6 +23554,7 @@ function render3DPreRender()
22931
23554
  function render3DRender()
22932
23555
  {
22933
23556
  render3DRenderPass(true);
23557
+ editor3DCameraEnd();
22934
23558
  }
22935
23559
 
22936
23560
  // one 3D pass for the objects of a layer: take over the gl state, draw the shadow map once a frame and the stages, hand
@@ -22980,7 +23604,8 @@ function render3DRenderPass(after2D)
22980
23604
  // may have left its own texture, which fails every draw; the shadow map goes back there even with shadows off
22981
23605
  gl.activeTexture(gl.TEXTURE1);
22982
23606
  gl.bindTexture(gl.TEXTURE_2D, r.shadowTexture || null);
22983
- gl.activeTexture(gl.TEXTURE0);
23607
+ // the material maps' units start white, a 2D plugin may have left its own textures there
23608
+ render3DSetMapUnits(r.whiteTexture);
22984
23609
  gl.depthMask(true);
22985
23610
  gl.clear(gl.DEPTH_BUFFER_BIT);
22986
23611
 
@@ -22993,6 +23618,8 @@ function render3DRenderPass(after2D)
22993
23618
  render3DRenderShadowMap();
22994
23619
  r.shadowMapDrawn = true;
22995
23620
  }
23621
+ // the camera's depth for post processing, from the default layer
23622
+ r.depthTexture && isDefault && render3DRenderDepth();
22996
23623
  render3DUseProgram(r.program); // after the shadow map, so the light matrix it sends is this frame's
22997
23624
  r.renderStages(objects, isDefault);
22998
23625
  }
@@ -23008,6 +23635,7 @@ function render3DRenderPass(after2D)
23008
23635
  gl.depthMask(true);
23009
23636
  gl.frontFace(gl.CCW);
23010
23637
  gl.bindSampler(0, null); // back to the textures' own filtering for 2D
23638
+ render3DSetMapUnits(null); // the material maps leave no texture or sampler behind for 2D
23011
23639
  if (glActiveTexture)
23012
23640
  gl.bindTexture(gl.TEXTURE_2D, glActiveTexture);
23013
23641
  // ARRAY_BUFFER is not part of VAO state in WebGL2, so bindVertexArray alone would not restore it
@@ -23016,6 +23644,21 @@ function render3DRenderPass(after2D)
23016
23644
  }
23017
23645
  }
23018
23646
 
23647
+ // put a texture on both material map units with no sampler, white at the start of the pass and none at its end, and
23648
+ // forget what the map cache thought was bound; unit 0 is active after
23649
+ function render3DSetMapUnits(texture)
23650
+ {
23651
+ const gl = glContext;
23652
+ for (const unit of [2, 3])
23653
+ {
23654
+ gl.activeTexture(gl.TEXTURE0 + unit);
23655
+ gl.bindTexture(gl.TEXTURE_2D, texture);
23656
+ gl.bindSampler(unit, null);
23657
+ }
23658
+ gl.activeTexture(gl.TEXTURE0);
23659
+ render3DBoundMaps = [];
23660
+ }
23661
+
23019
23662
  // create the shadow map depth texture and framebuffer at a size, or keep them when the size matches
23020
23663
  function render3DUpdateShadowMap(size)
23021
23664
  {
@@ -23092,6 +23735,65 @@ function render3DRenderShadowMap()
23092
23735
  }
23093
23736
  }
23094
23737
 
23738
+ // the camera's depth texture and its framebuffer at a size, made again when the canvas changes size; depth values
23739
+ // are not filtered, so it is read texel by texel
23740
+ function render3DUpdateCameraDepth(width, height)
23741
+ {
23742
+ const gl = glContext, r = render3D;
23743
+ if (r.cameraDepthTexture && r.cameraDepthWidth === width && r.cameraDepthHeight === height) return;
23744
+ r.cameraDepthTexture && gl.deleteTexture(r.cameraDepthTexture);
23745
+ r.cameraDepthFramebuffer && gl.deleteFramebuffer(r.cameraDepthFramebuffer);
23746
+ const texture = r.cameraDepthTexture = gl.createTexture();
23747
+ gl.bindTexture(gl.TEXTURE_2D, texture);
23748
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.DEPTH_COMPONENT24, width, height, 0, gl.DEPTH_COMPONENT, gl.UNSIGNED_INT, null);
23749
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
23750
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
23751
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
23752
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
23753
+ gl.bindTexture(gl.TEXTURE_2D, null);
23754
+ const framebuffer = r.cameraDepthFramebuffer = gl.createFramebuffer();
23755
+ gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
23756
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, texture, 0);
23757
+ gl.drawBuffers([gl.NONE]); // depth only
23758
+ gl.readBuffer(gl.NONE);
23759
+ false&&ASSERT(gl.checkFramebufferStatus(gl.FRAMEBUFFER) == gl.FRAMEBUFFER_COMPLETE, 'camera depth framebuffer is incomplete');
23760
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
23761
+ r.cameraDepthWidth = width, r.cameraDepthHeight = height;
23762
+ }
23763
+
23764
+ // draw the camera's depth for post processing: the solid objects from the camera with the shadow map's depth only
23765
+ // shader, into a texture the size of the canvas; what is see through is cut by its alpha as it is for shadows
23766
+ function render3DRenderDepth()
23767
+ {
23768
+ const gl = glContext, r = render3D, width = glCanvas.width, height = glCanvas.height;
23769
+ render3DUpdateCameraDepth(width, height);
23770
+ gl.bindFramebuffer(gl.FRAMEBUFFER, r.cameraDepthFramebuffer);
23771
+ gl.viewport(0, 0, width, height);
23772
+ gl.clear(gl.DEPTH_BUFFER_BIT);
23773
+ gl.useProgram(r.shadowShader);
23774
+ gl.uniformMatrix4fv(render3DUniform('viewProj', r.shadowShader), false, r.viewProjection.m);
23775
+ gl.enable(gl.DEPTH_TEST);
23776
+ gl.depthMask(true);
23777
+ gl.disable(gl.BLEND);
23778
+ gl.disable(gl.CULL_FACE);
23779
+
23780
+ r.shadowPass = r.depthPass = true;
23781
+ try
23782
+ {
23783
+ const solids = render3DLayerObjects(!!r.renderAfter2D).filter(o=> !o.additive && (!o.transparent || o.tileInfo));
23784
+ render3DDrawObjects(solids);
23785
+ r.onRenderOpaque?.();
23786
+ r.flush();
23787
+ render3DFlushInstances();
23788
+ }
23789
+ finally
23790
+ {
23791
+ r.shadowPass = r.depthPass = false;
23792
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
23793
+ gl.viewport(0, 0, width, height);
23794
+ }
23795
+ }
23796
+
23095
23797
  ///////////////////////////////////////////////////////////////////////////////
23096
23798
  // Strips: every strip repeats its first point once at the start and its last
23097
23799
  // point once at the end. Those repeats make flat triangles with no area, which
@@ -23245,18 +23947,26 @@ function render3DStripTriangles(count, remap, place)
23245
23947
  }
23246
23948
 
23247
23949
  // a mesh's packed vertex data for the GPU, one vertex per entry of a layout, which is the strip index of each,
23248
- // written into data when given, the buffer an earlier call returned for the same layout
23950
+ // written into data when given, the buffer an earlier call returned for the same layout; it measures the mesh's
23951
+ // radius and box as it goes, as computeRadius does, over the vertices once instead of every strip entry
23249
23952
  function render3DMeshVertexData(mesh, vertices, data=new ArrayBuffer(vertices.length * RENDER3D_VERTEX_BYTES))
23250
23953
  {
23251
23954
  const count = vertices.length;
23252
23955
  const floats = new Float32Array(data), ints = new Uint32Array(data);
23956
+ let r = 0, x0 = Infinity, y0 = Infinity, z0 = Infinity, x1 = -Infinity, y1 = -Infinity, z1 = -Infinity;
23253
23957
  for (let j = 0; j < count; ++j)
23254
23958
  {
23255
23959
  // a hand built mesh may leave normals, uvs and colors empty
23256
23960
  const i = vertices[j], p = mesh.points[i], uv = mesh.uvs[i] || RENDER3D_DEFAULT_UV;
23257
- render3DWriteVertex(floats, ints, j * RENDER3D_VERTEX_FLOATS, p.x, p.y, p.z,
23961
+ const x = p.x, y = p.y, z = p.z;
23962
+ render3DWriteVertex(floats, ints, j * RENDER3D_VERTEX_FLOATS, x, y, z,
23258
23963
  mesh.normals[i] || RENDER3D_DEFAULT_NORMAL, uv.x, uv.y, (mesh.colors[i] || WHITE).rgbaInt());
23964
+ r = max(r, x*x + y*y + z*z);
23965
+ x0 = min(x0, x), y0 = min(y0, y), z0 = min(z0, z);
23966
+ x1 = max(x1, x), y1 = max(y1, y), z1 = max(z1, z);
23259
23967
  }
23968
+ mesh.radius = r ** .5;
23969
+ mesh.bounds = count ? {min: vec3(x0, y0, z0), max: vec3(x1, y1, z1)} : {min: vec3(), max: vec3()};
23260
23970
  return data;
23261
23971
  }
23262
23972
 
@@ -23768,8 +24478,8 @@ class Mesh
23768
24478
  * @return {Mesh} */
23769
24479
  upload()
23770
24480
  {
23771
- this.computeRadius();
23772
- if (!render3D?.program || !glContext) return this;
24481
+ // the packing below measures the radius and box, with nothing to pack to they are measured here
24482
+ if (!render3D?.program || !glContext) return this.computeRadius(), this;
23773
24483
  const gl = glContext, layout = this.vertexLayout;
23774
24484
  // no layout when dynamicDraw was turned on after an upload, the next upload makes one
23775
24485
  if (this.dynamicDraw && layout && this.buffer && this.contextGeneration === render3D.contextGeneration)
@@ -24271,25 +24981,30 @@ function buildSky(topColor=hsl(.6, .8, .55), horizonColor=hsl(.6, 1, .9), bottom
24271
24981
  // a sphere turned inside out so it is seen from within, each point colored by how high it is
24272
24982
  const mesh = buildSphere(2, sides, rings, true).flipNormals();
24273
24983
  mesh.colors = mesh.points.map(p=> p.y < 0 ? horizonColor.lerp(bottomColor, -p.y) : horizonColor.lerp(topColor, p.y));
24984
+ render3DSkyColors.set(mesh, [topColor.copy(), horizonColor.copy(), bottomColor.copy()]);
24274
24985
  return mesh;
24275
24986
  }
24276
24987
 
24988
+ // the top, horizon and bottom colors of each sky dome buildSky made, which a reflection shows while that dome is
24989
+ // render3D.sky, whether setSky set it or the game did
24990
+ const render3DSkyColors = new WeakMap;
24991
+
24277
24992
  ///////////////////////////////////////////////////////////////////////////////
24278
24993
  /**
24279
24994
  * EngineObject3D - An EngineObject with a 3D transform and a mesh
24280
24995
  * - Set pos3D, rotation3D and scale3D instead of the 2D pos, size and angle
24281
24996
  * - Gets update, children, timers, destroy and renderOrder from EngineObject
24282
- * - velocity3D is added to pos3D each frame, along with render3D.gravity and damping once it has a mass
24997
+ * - velocity3D is added to pos3D each frame, slowed by damping, and render3D.gravity pulls it once it has a mass
24283
24998
  * - Objects face -Z, the same way the camera does, so lookAt turns them to face a point
24284
24999
  * - The 2D pos and velocity are still there but nothing draws them
24285
- * - These inherited fields are 2D only and do nothing here: angle, angleVelocity, angleDamping,
24286
- * additiveColor, drawSize, mirror, clampSpeed, friction and groundObject
25000
+ * - These inherited fields are 2D only and do nothing here: angle, angleVelocity, additiveColor, drawSize and mirror;
25001
+ * damping, angleDamping, clampSpeed, friction and groundObject work as in 2D, on velocity3D and angleVelocity3D
24287
25002
  * - The inherited shader works here as in 2D, and with emissive at 1 its snippet does its own lighting
24288
25003
  * - Set sync2D for a 2D game with 3D looks, pos and angle then drive pos3D and rotation3D,
24289
25004
  * which is the one way those 2D fields reach a 3D object
24290
25005
  * - setCollision takes the same flags as in 2D, but the solid collision happens in 3D against size3D
24291
- * - The solid box is axis aligned in the world, rotation3D is ignored like angle is in 2D, so give a turned wall a
24292
- * size3D along the world axes
25006
+ * - The solid box turns with rotation3D, so a turned wall or a ramp collides as it looks; resting on one no steeper
25007
+ * than groundAngle stands there
24293
25008
  * - Its tile and raycast halves are 2D only so they default off here, and a child sits solid collision out
24294
25009
  * - A sync2D object collides in 2D instead, which needs the 2D size set as well as size3D
24295
25010
  * - setMesh swaps the mesh and frees the old one, for text and terrain that get built again
@@ -24337,7 +25052,7 @@ class EngineObject3D extends EngineObject
24337
25052
  /** @property {Vector3} - Added to pos3D each frame by the engine before update, like the 2D velocity, no super
24338
25053
  * call needed; damping and render3D.gravity act on it once the object has a mass */
24339
25054
  this.velocity3D = vec3();
24340
- /** @property {Vector3} - Added to rotation3D each frame by the engine before update, angleDamping is 2D only */
25055
+ /** @property {Vector3} - Added to rotation3D each frame by the engine before update, slowed by angleDamping */
24341
25056
  this.angleVelocity3D = vec3();
24342
25057
  /** @property {Mesh|undefined} - Mesh to draw
24343
25058
  * @type {Mesh|undefined} */
@@ -24368,8 +25083,27 @@ class EngineObject3D extends EngineObject
24368
25083
  * lamps and glowing things, between is partly self lit, and above 1 is brighter than its color, for bloom */
24369
25084
  this.emissive = 0;
24370
25085
  /** @property {number} - Strength of the highlight where the sun and the Light3D objects reflect, 0 is none and
24371
- * 1 adds a light's full color at its brightest; its size is fixed */
25086
+ * 1 adds a light's full color at its brightest; shininess sets its size */
24372
25087
  this.specular = 0;
25088
+ /** @property {number} - The highlight's exponent, how small and sharp it is: 4 is broad like rubber, 16 the
25089
+ * default, 100 sharp like polished metal; shows only with specular above 0 */
25090
+ this.shininess = 16;
25091
+ /** @property {TextureInfo|undefined} - A normal map that bends the surface at each texel so it catches the
25092
+ * light like bumps and grooves, green pointing up the image as OpenGL and glTF have it; read at the color
25093
+ * texture's coordinates, see normalMapFromHeight to make one in code
25094
+ * @type {TextureInfo|undefined} */
25095
+ this.normalMap = undefined;
25096
+ /** @property {number} - How strongly the normal map bends the surface, 0 turns it off, as glTF's scale */
25097
+ this.normalScale = 1;
25098
+ /** @property {number} - How much it reflects the sky, 0 none and 1 a mirror of it; the edges seen at a
25099
+ * glancing angle reflect more either way, as water and glass do */
25100
+ this.reflectivity = 0;
25101
+ /** @property {TextureInfo|undefined} - A texture of where it glows, added on top of the lit surface so it
25102
+ * shows in the dark, like lit windows; read at the color texture's coordinates
25103
+ * @type {TextureInfo|undefined} */
25104
+ this.emissiveMap = undefined;
25105
+ /** @property {Color} - Multiplies the emissive map, as glTF's emissiveFactor */
25106
+ this.emissiveMapColor = WHITE;
24373
25107
  /** @property {boolean} - Draw into the shadow map when render3D.shadows is on; sprites and cut out textures
24374
25108
  * cast their outline, an object faded below half its alpha casts nothing, a see through one casts only when
24375
25109
  * textured, and additive objects never cast */
@@ -24377,6 +25111,9 @@ class EngineObject3D extends EngineObject
24377
25111
  /** @property {boolean} - Collide as the sphere that fits size3D instead of as the size3D box, so it rolls
24378
25112
  * around corners */
24379
25113
  this.collideAsSphere3D = false;
25114
+ /** @property {number} - The steepest slope it stands on, in radians from level, PI/4 by default: resting on
25115
+ * a solid within this of flat sets groundObject and holds it still, steeper it slides down */
25116
+ this.groundAngle = PI / 4;
24380
25117
  /** @property {boolean} - Darkened by the shadow map when render3D.shadows is on */
24381
25118
  this.receiveShadow = true;
24382
25119
  /** @property {boolean|undefined} - Draw this object over the 2D scene, undefined uses render3D.renderAfter2D
@@ -24407,10 +25144,32 @@ class EngineObject3D extends EngineObject
24407
25144
  'a sync2D object collides in 2D, so give it a 2D size as well as a size3D', this.size);
24408
25145
  if (this.sync2D)
24409
25146
  super.updatePhysics();
25147
+ false&&ASSERT(isNumber(this.groundAngle) && this.groundAngle >= 0 && this.groundAngle < PI / 2,
25148
+ 'groundAngle must be 0 to less than PI/2, a slope from level', this.groundAngle);
25149
+ // what it stands on is found again each frame, by the level and by the solids it rests on; a sync2D
25150
+ // object's is the 2D physics'
25151
+ const ground = this.groundObject;
25152
+ this.sync2D || (this.groundObject = undefined);
25153
+ if (this.clampSpeed && !this.sync2D && (this.collideSolidObjects || this.collideLevel && this.mass))
25154
+ {
25155
+ // each axis within objectMaxSpeed, as in 2D, so a fast object does not pass through a thin wall; only
25156
+ // for what collides, anything else moves as fast as it is told
25157
+ const v = this.velocity3D, s = objectMaxSpeed;
25158
+ v.x = clamp(v.x, -s, s), v.y = clamp(v.y, -s, s), v.z = clamp(v.z, -s, s);
25159
+ }
24410
25160
  // a moving object keeps out of the level, the height maps and voxel maps, from where it was before it moved
24411
25161
  const oldPos = this.collideLevel && this.mass && !this.sync2D ? this.pos3D.copy() : undefined;
24412
25162
  render3DMove(this);
24413
- oldPos && render3DCollideLevel(this, oldPos);
25163
+ if (ground && this.mass && !this.sync2D)
25164
+ {
25165
+ // sliding on what it stood on slows by friction, the less grippy of the two, relative to that one's own
25166
+ // speed so a moving platform carries it, as in 2D
25167
+ const friction = max(this.friction, ground.friction), v = this.velocity3D;
25168
+ const moving = ground instanceof EngineObject3D ? ground.velocity3D : undefined;
25169
+ const gx = moving?.x ?? 0, gz = moving?.z ?? 0;
25170
+ v.x = gx + (v.x - gx) * friction, v.z = gz + (v.z - gz) * friction;
25171
+ }
25172
+ oldPos && render3DCollideLevel(this, oldPos, ground);
24414
25173
  // the engine only runs this for objects that own where they are, a child rides along with its parent
24415
25174
  if (this.collideSolidObjects && !this.sync2D)
24416
25175
  render3DCollideSolid(this);
@@ -24443,7 +25202,7 @@ class EngineObject3D extends EngineObject
24443
25202
 
24444
25203
  /** Set how this object collides, the same flags as in 2D
24445
25204
  * - Solid collision happens in 3D here, against size3D boxes or spheres; a child sits it out
24446
- * - The boxes are axis aligned in the world, rotation3D is ignored
25205
+ * - The boxes turn with rotation3D, against height maps and voxel maps an object is still its upright box
24447
25206
  * - A sync2D object collides in 2D instead, against the 2D size, so set that as well as size3D
24448
25207
  * @param {boolean} [collideSolidObjects] - Take part in solid collision
24449
25208
  * @param {boolean} [isSolid] - Block other objects, a pair where neither one blocks passes through;
@@ -24631,27 +25390,29 @@ function render3DObjectMatrix(o)
24631
25390
  return o.worldMatrix;
24632
25391
  }
24633
25392
 
24634
- // move an object by its 3D velocities, an object with mass falling with render3D.gravity and slowing by its damping
25393
+ // move an object by its 3D velocities, each slowed by its damping, an object with mass falling with render3D.gravity
24635
25394
  function render3DMove(o)
24636
25395
  {
24637
25396
  // the vectors change in place, as the 2D object's do: this runs for every object every frame
25397
+ // a sync2D object's damping is the 2D physics', its 3D velocities are its own to set
24638
25398
  const p = o.pos3D, v = o.velocity3D, r = o.rotation3D, a = o.angleVelocity3D;
25399
+ const d = o.sync2D ? 1 : o.damping, e = o.sync2D ? 1 : o.angleDamping;
25400
+ // damped first and gravity added after, the order EngineObject.updatePhysics uses,
25401
+ // so the same mass, damping and gravity fall the same way in both
25402
+ v.x *= d, v.y *= d, v.z *= d;
24639
25403
  if (o.mass && !o.sync2D) // a 2D driven object gets the 2D gravity instead
24640
25404
  {
24641
- // damped first and gravity added after, the order EngineObject.updatePhysics uses,
24642
- // so the same mass, damping and gravity fall the same way in both
24643
- const g = render3D.gravity, s = o.gravityScale, d = o.damping;
24644
- v.x = v.x * d + g.x * s, v.y = v.y * d + g.y * s, v.z = v.z * d + g.z * s;
25405
+ const g = render3D.gravity, s = o.gravityScale;
25406
+ v.x += g.x * s, v.y += g.y * s, v.z += g.z * s;
24645
25407
  }
24646
25408
  p.x += v.x, p.y += v.y, p.z += v.z;
24647
- r.x += a.x, r.y += a.y, r.z += a.z;
25409
+ r.x += a.x *= e, r.y += a.y *= e, r.z += a.z *= e;
24648
25410
  }
24649
25411
 
24650
25412
  // keep an object that moved out of the level's solid geometry, clearing what it stood on for the level to set again
24651
- function render3DCollideLevel(o, oldPos)
25413
+ // ground is what it stood on last frame, which a height map keeps it on going down a slope
25414
+ function render3DCollideLevel(o, oldPos, ground)
24652
25415
  {
24653
- const ground = o.groundObject;
24654
- o.groundObject = undefined;
24655
25416
  for (const level of render3DLevel)
24656
25417
  level.destroyed || level.levelCollide3D(o, oldPos, level === ground);
24657
25418
  }
@@ -24668,7 +25429,23 @@ function render3DSolidShape(o)
24668
25429
  const kx = abs(k.x), ky = abs(k.y), kz = abs(k.z);
24669
25430
  if (o.collideAsSphere3D)
24670
25431
  return {pos: o.pos3D.copy(), radius: max(s.x, s.y, s.z) / 2 * max(kx, ky, kz)};
24671
- return {pos: o.pos3D.copy(), size: vec3(s.x * kx, s.y * ky, s.z * kz)};
25432
+ // a turned box has its axes, a solid is never a child so its rotation is the world's; an upright one has none, and
25433
+ // neither does a sprite, whose rotation turns how it faces the camera, not its box
25434
+ const axes = !render3DIsSprite(o) && isTurned3D(o.rotation3D) ? render3DSolidAxes(o) : undefined;
25435
+ return {pos: o.pos3D.copy(), size: vec3(s.x * kx, s.y * ky, s.z * kz), axes};
25436
+ }
25437
+
25438
+ // a turned solid's three axes, worked out when its rotation changes and kept until then, so the pairs it is tested
25439
+ // against each frame do not each work them out again
25440
+ const render3DSolidAxesCache = new WeakMap;
25441
+ function render3DSolidAxes(o)
25442
+ {
25443
+ const r = o.rotation3D, kept = render3DSolidAxesCache.get(o);
25444
+ if (kept && kept.x === r.x && kept.y === r.y && kept.z === r.z)
25445
+ return kept.axes;
25446
+ const axes = boxAxes3D(r);
25447
+ render3DSolidAxesCache.set(o, {x: r.x, y: r.y, z: r.z, axes});
25448
+ return axes;
24672
25449
  }
24673
25450
 
24674
25451
  // how far a solid shape can reach from its own center, for a quick reject before the exact test
@@ -24686,15 +25463,30 @@ function render3DSolidReach(o)
24686
25463
  // what it takes to move shape a clear of shape b, whichever pair of shapes they are, or undefined for no touch
24687
25464
  function render3DSolidPush(a, b)
24688
25465
  {
25466
+ // a turned box is tested by its kept axes, the upright pairs as they always were
25467
+ const sphereBox = (sphere, box)=> box.axes ? collideSphereOrientedBox3D(sphere.pos, sphere.radius, box.pos,
25468
+ box.size, box.axes) : collideSphereBox(sphere.pos, sphere.radius, box.pos, box.size);
24689
25469
  if (!a.size) // a is a sphere
24690
- return b.size ? collideSphereBox(a.pos, a.radius, b.pos, b.size)
24691
- : collideSphereSphere(a.pos, a.radius, b.pos, b.radius);
25470
+ return b.size ? sphereBox(a, b) : collideSphereSphere(a.pos, a.radius, b.pos, b.radius);
24692
25471
  if (!b.size) // only b is, so push b out of a and turn it around
24693
25472
  {
24694
- const push = collideSphereBox(b.pos, b.radius, a.pos, a.size);
25473
+ const push = sphereBox(b, a);
24695
25474
  return push && push.scale(-1);
24696
25475
  }
24697
- return collideBoxBox3D(a.pos, a.size, b.pos, b.size);
25476
+ return a.axes || b.axes ? collideOrientedBoxes3D(a.pos, a.size, a.axes ?? BOX_WORLD_AXES, b.pos, b.size,
25477
+ b.axes ?? BOX_WORLD_AXES) : collideBoxBox3D(a.pos, a.size, b.pos, b.size);
25478
+ }
25479
+
25480
+ // a sprite, a tile with no mesh, which faces the camera however it is turned
25481
+ const render3DIsSprite = (o)=> !o.mesh && !!o.tileInfo;
25482
+
25483
+ // whether a push leaves a box by one of its faces, along one of its axes, not by an edge or a corner; a sphere has
25484
+ // no face to stand on
25485
+ function render3DOnFace(push, shape)
25486
+ {
25487
+ if (!shape.size) return false;
25488
+ const n = push.normalize(), axes = shape.axes ?? BOX_WORLD_AXES;
25489
+ return max(abs(n.dot(axes[0])), abs(n.dot(axes[1])), abs(n.dot(axes[2]))) > 1 - 1e-6;
24698
25490
  }
24699
25491
 
24700
25492
  // push a solid object out of the solids before it in the engine's list of them, so each pair is resolved once:
@@ -24720,7 +25512,8 @@ function render3DCollideSolid(a)
24720
25512
  if (dx*dx + dy*dy + dz*dz > reach*reach)
24721
25513
  continue;
24722
25514
 
24723
- const push = render3DSolidPush(shapeA, render3DSolidShape(b));
25515
+ const shapeB = render3DSolidShape(b);
25516
+ let push = render3DSolidPush(shapeA, shapeB);
24724
25517
  if (!push) continue;
24725
25518
 
24726
25519
  // both objects hear about it, and either one can take the touch over
@@ -24728,6 +25521,19 @@ function render3DCollideSolid(a)
24728
25521
  const resolveB = b.collideWithObject(a, push.scale(-1));
24729
25522
  if (!resolveA || !resolveB) continue;
24730
25523
 
25524
+ // standing: resting on a box's face within the upper one's groundAngle of level holds it there, the push
25525
+ // turned straight up, as far as it takes to leave the surface, so it does not creep down a ramp; only what
25526
+ // moves stands, and a sphere, an edge or a corner is nothing to stand on, what rests there rolls off
25527
+ const lengthSquared = push.lengthSquared(), up = push.y / lengthSquared ** .5;
25528
+ const aStands = up > 0 && a.mass && up >= cos(a.groundAngle) && render3DOnFace(push, shapeB);
25529
+ const bStands = up < 0 && b.mass && -up >= cos(b.groundAngle) && render3DOnFace(push, shapeA);
25530
+ if (aStands || bStands)
25531
+ {
25532
+ aStands ? a.groundObject = b : b.groundObject = a;
25533
+ if (push.x || push.z) // one straight up already is used as it is
25534
+ push = vec3(0, lengthSquared / push.y, 0);
25535
+ }
25536
+
24731
25537
  // heavier objects move less, mass 0 stays put; then bounce apart when moving toward each other
24732
25538
  const total = a.mass + b.mass;
24733
25539
  const weightA = !a.mass ? 0 : !b.mass ? 1 : b.mass / total;
@@ -24748,8 +25554,8 @@ function render3DCollideSolid(a)
24748
25554
 
24749
25555
  /**
24750
25556
  * Collect the EngineObject3D objects whose boxes overlap a sphere or a box, the 3D twin of engineObjectsCollect
24751
- * - Boxes are axis aligned around the world position, rotation3D is ignored; lights, emitters and trails have no size
24752
- * and are never collected
25557
+ * - Each object is its size3D box at its world position, turned as it is turned; lights, emitters and trails have no
25558
+ * size and are never collected
24753
25559
  * @param {Vector3} pos - Center of the area
24754
25560
  * @param {Vector3|number} size - Diameter of a sphere if a number, 0 for a point, full size of a box if a Vector3
24755
25561
  * @param {Array<EngineObject>} [objects] - Defaults to every object
@@ -24771,9 +25577,27 @@ function engineObjectsCollect3D(pos, size, objects=engineObjects, testCenters=fa
24771
25577
  if (!(s.x || s.y || s.z)) continue;
24772
25578
  const center = vec3(m[12], m[13], m[14]);
24773
25579
  const worldSize = testCenters ? vec3() : render3DWorldSize(o, m);
25580
+ // a turned box is tested as turned, its axes read off the matrix, its columns with the scale taken out
25581
+ const turned = !testCenters && !render3DIsSprite(o) && (m[1] || m[2] || m[4] || m[6] || m[8] || m[9]);
25582
+ const axis = (i)=> vec3(m[i], m[i+1], m[i+2]).normalize();
25583
+ let axes = turned ? [axis(0), axis(4), axis(8)] : undefined;
25584
+ if (axes && (abs(axes[0].dot(axes[1])) > 1e-6 || abs(axes[0].dot(axes[2])) > 1e-6 ||
25585
+ abs(axes[1].dot(axes[2])) > 1e-6))
25586
+ {
25587
+ // a turned child of an unevenly scaled parent is sheared, its edges no longer square, which the box
25588
+ // tests do not take; the upright box around it stands in, which may take a little more but never misses
25589
+ const e = axes.map((a, i)=> a.scale(i ? i > 1 ? worldSize.z / 2 : worldSize.y / 2 : worldSize.x / 2));
25590
+ const reach = (c)=> 2 * (abs(e[0][c]) + abs(e[1][c]) + abs(e[2][c]));
25591
+ worldSize.set(reach('x'), reach('y'), reach('z'));
25592
+ axes = undefined;
25593
+ }
24774
25594
  let hit;
24775
- if (box)
25595
+ if (box && axes)
25596
+ hit = !!collideOrientedBoxes3D(pos, box, BOX_WORLD_AXES, center, worldSize, axes);
25597
+ else if (box)
24776
25598
  hit = isOverlapping3D(pos, box, center, worldSize);
25599
+ else if (axes)
25600
+ hit = !!collideSphereOrientedBox3D(pos, radiusSquared ** .5, center, worldSize, axes);
24777
25601
  else
24778
25602
  {
24779
25603
  // a sphere against the nearest point of the box
@@ -25096,10 +25920,53 @@ class Light3D extends EngineObject3D
25096
25920
  /** @property {boolean} - Shine from far away, from its position toward the origin, instead of out from its
25097
25921
  * position with a falloff; DirectionalLight3D sets it */
25098
25922
  this.directional = false;
25923
+ /** @property {number} - Size of a soft hazy glow drawn over the light, like a lamp at night, 0 for none; it
25924
+ * is added onto what is behind it, and what is in front of the light hides it */
25925
+ this.glow = 0;
25926
+ /** @property {number} - How fast the glow fades from its middle: 1 by default, .5 a wide haze, 2 a tight
25927
+ * bright core */
25928
+ this.glowFalloff = 1;
25929
+ this.additive = true; // the glow is added on, in the transparent stage; a light with none draws nothing
25099
25930
  }
25100
25931
 
25101
- /** Lights draw nothing */
25102
- render3D() {}
25932
+ /** Draw the glow, a quad facing the camera with a soft round glow on it, pulled toward the camera by half its
25933
+ * size so a lamp at the light does not cut into it, but never past the camera */
25934
+ render3D()
25935
+ {
25936
+ if (!(this.glow > 0) || this.directional) return;
25937
+ const r = render3D, c = this.color, pos = render3DObjectMatrix(this).getTranslation();
25938
+ const toCamera = r.camera.pos.subtract(pos), distance = toCamera.length();
25939
+ const at = distance ? pos.add(toCamera.scale(min(this.glow, distance) / 2 / distance)) : pos;
25940
+ const color = rgb(c.r, c.g, c.b, c.a * min(this.intensity, 1));
25941
+ r.drawBillboard(at, vec2(this.glow), render3DGlow(this.glowFalloff), color);
25942
+ }
25943
+ }
25944
+
25945
+ // how strong a light's glow is at a distance from its middle, 0 there to 1 at the edge: a bell, full in the middle
25946
+ // and nothing at the edge, fading faster the higher the falloff
25947
+ function render3DGlowAlpha(r, falloff)
25948
+ {
25949
+ const k = 3.5 * falloff, edge = Math.exp(-k);
25950
+ return (Math.exp(-k * r * r) - edge) / (1 - edge);
25951
+ }
25952
+
25953
+ // the soft round glow of the lights, one texture for each falloff, rounded to a tenth so a changing falloff makes
25954
+ // only a few, each made once from a canvas; undefined headless or without a canvas
25955
+ const render3DGlowTextures = new Map;
25956
+ function render3DGlow(falloff=1)
25957
+ {
25958
+ false&&ASSERT(isNumber(falloff) && falloff > 0, 'glowFalloff must be a number above 0');
25959
+ const key = max(round(falloff * 10), 1) / 10;
25960
+ let texture = render3DGlowTextures.get(key);
25961
+ if (texture || !glContext || typeof OffscreenCanvas == 'undefined') return texture;
25962
+ const size = 64, context = createCanvasContext(size), steps = 16;
25963
+ const gradient = context.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
25964
+ for (let i = 0; i <= steps; ++i)
25965
+ gradient.addColorStop(i / steps, 'rgba(255,255,255,' + render3DGlowAlpha(i / steps, key).toFixed(4) + ')');
25966
+ context.fillStyle = gradient;
25967
+ context.fillRect(0, 0, size, size);
25968
+ render3DGlowTextures.set(key, texture = new TextureInfo(context.canvas));
25969
+ return texture;
25103
25970
  }
25104
25971
 
25105
25972
  ///////////////////////////////////////////////////////////////////////////////
@@ -25165,6 +26032,54 @@ function render3DSoftDot()
25165
26032
  return render3DSoftDotTexture = new TextureInfo(context.canvas);
25166
26033
  }
25167
26034
 
26035
+ /** Make a normal map from a height at each pixel, for bumps and grooves with no image file: the slope at each pixel
26036
+ * from its neighbors' heights, taken around the edges so the map tiles; set it as an object's normalMap
26037
+ * @param {Vector2} size - In pixels
26038
+ * @param {function(number, number): number} heightFunction - The height 0 to 1 at a pixel, x across and y down
26039
+ * @param {number} [strength] - How steep the slopes are: a height change of 1 over one pixel leans the normal
26040
+ * by strength
26041
+ * @return {TextureInfo} - Wraps; headless it has no image
26042
+ * @memberof Render3D */
26043
+ function normalMapFromHeight(size, heightFunction, strength=1)
26044
+ {
26045
+ false&&ASSERT(isVector2(size) && size.x >= 1 && size.y >= 1, 'normalMapFromHeight size must be a Vector2 of pixels');
26046
+ const width = size.x | 0, height = size.y | 0;
26047
+ if (typeof OffscreenCanvas == 'undefined')
26048
+ {
26049
+ // headless, nothing to draw into; the right size so what reads it still works
26050
+ const textureInfo = new TextureInfo(undefined, false, true);
26051
+ textureInfo.size = vec2(width, height), textureInfo.sizeInverse = vec2(1 / width, 1 / height);
26052
+ return textureInfo;
26053
+ }
26054
+ const context = createCanvasContext(width, height);
26055
+ const pixels = render3DNormalMapPixels(width, height, heightFunction, strength);
26056
+ context.putImageData(new ImageData(pixels, width, height), 0, 0);
26057
+ return new TextureInfo(context.canvas, true, true);
26058
+ }
26059
+
26060
+ // the pixels of a normal map made from heights, rgba with the top row first: each normal leans away from the uphill
26061
+ // side, green up the image as OpenGL has it, and a flat height is (128, 128, 255); rounded before they are stored,
26062
+ // since the clamped array would round a flat 127.5 to even
26063
+ function render3DNormalMapPixels(width, height, heightFunction, strength)
26064
+ {
26065
+ const heights = new Float32Array(width * height), pixels = new Uint8ClampedArray(width * height * 4);
26066
+ for (let y = 0; y < height; ++y)
26067
+ for (let x = 0; x < width; ++x)
26068
+ heights[y * width + x] = heightFunction(x, y);
26069
+ const h = (x, y)=> heights[mod(y, height) * width + mod(x, width)];
26070
+ for (let y = 0; y < height; ++y)
26071
+ for (let x = 0; x < width; ++x)
26072
+ {
26073
+ const dx = (h(x+1, y) - h(x-1, y)) / 2 * strength, dy = (h(x, y+1) - h(x, y-1)) / 2 * strength;
26074
+ const k = 1 / hypot(dx, dy, 1), i = (y * width + x) * 4;
26075
+ pixels[i] = round((-dx * k * .5 + .5) * 255);
26076
+ pixels[i+1] = round((dy * k * .5 + .5) * 255);
26077
+ pixels[i+2] = round((k * .5 + .5) * 255);
26078
+ pixels[i+3] = 255;
26079
+ }
26080
+ return pixels;
26081
+ }
26082
+
25168
26083
  ///////////////////////////////////////////////////////////////////////////////
25169
26084
  // The rest of the shape builders: buildLathe, buildSphere, buildBox, buildGrid and buildSky live with the
25170
26085
  // renderer, since it hands those out itself
@@ -25701,6 +26616,22 @@ class HeightMap extends EngineObject3D
25701
26616
  * @ignore */
25702
26617
  levelRaycast3D(ray) { return this.raycast(ray); }
25703
26618
 
26619
+ /** Where a short move goes under the surface, for a particle's move in one frame: the part of the move made
26620
+ * before it, 0 to 1, and the surface normal there; undefined when it stays above, starts under, or is off the map
26621
+ * @param {Vector3} from
26622
+ * @param {Vector3} to
26623
+ * @return {{distance: number, normal: Vector3}|undefined}
26624
+ * @ignore */
26625
+ levelSegment3D(from, to)
26626
+ {
26627
+ const m = this.pos3D, size = this.mapSize;
26628
+ if (abs(to.x - m.x) > size.x / 2 || abs(to.z - m.z) > size.y / 2) return; // off the map
26629
+ const a = from.y - this.getHeight(from.x, from.z), b = to.y - this.getHeight(to.x, to.z);
26630
+ if (a < 0 || b >= 0) return; // above all the way, or under from the start
26631
+ const distance = a / (a - b);
26632
+ return {distance, normal: this.getNormal(from.x + (to.x - from.x) * distance, from.z + (to.z - from.z) * distance)};
26633
+ }
26634
+
25704
26635
  /** Keep an object above the ground, called by the engine for each object with collideLevel
25705
26636
  * @param {EngineObject3D} o
25706
26637
  * @param {Vector3} oldPos - Where it was before it moved
@@ -25889,7 +26820,7 @@ class FirstPersonCamera3D extends EngineObject3D
25889
26820
  /** @property {boolean} - Capture the mouse on a click, so looking needs no button held */
25890
26821
  this.lockPointer = true;
25891
26822
  /** @property {number} - Speed of a jump in world units a frame, 0 for none; Space or gamepad button 0 jumps
25892
- * while it stands on something, a height map or voxel map it collides with, see collideLevel */
26823
+ * while it stands on something, a height map, a voxel map or a solid, see groundObject */
25893
26824
  this.jumpSpeed = 0;
25894
26825
  /** @property {number} - How far above its position the eye is, in its own space, so with a size3D the eye can
25895
26826
  * sit toward the top of the body instead of its middle; keep it under half the body's height, or the eye is
@@ -25960,6 +26891,49 @@ class FirstPersonCamera3D extends EngineObject3D
25960
26891
  const RENDER3D_PARTICLE_FLOATS = 21;
25961
26892
  // the position, color and size of the particle being drawn, shared by every emitter so the draw loop makes no objects
25962
26893
  const render3DParticlePos = vec3(), render3DParticleColor = new Color, render3DParticleSize = vec2();
26894
+ // where a particle was and is, for the level collision
26895
+ const render3DParticleFrom = vec3(), render3DParticleTo = vec3();
26896
+
26897
+ // the emitters inside a particle callback now, whose nested calls each get a view of their own
26898
+ const render3DParticlesCalling = new Set;
26899
+
26900
+ // a particle as a ParticleEmitter3D's callbacks see it, set from the particle for each call
26901
+ /** @param {ParticleEmitter3D} emitter
26902
+ * @return {Particle3D} */
26903
+ function render3DParticleView(emitter)
26904
+ {
26905
+ return {emitter, pos: vec3(), velocity: vec3(), age: 0, lifeTime: 0, destroyed: false,
26906
+ destroy() { this.destroyed = true; }};
26907
+ }
26908
+
26909
+ /**
26910
+ * A particle as a ParticleEmitter3D's callbacks see it: one object the emitter reuses, set from the particle for each
26911
+ * call and written back after it, so copy what you keep
26912
+ * @typedef {Object} Particle3D
26913
+ * @property {ParticleEmitter3D} emitter - The emitter it is in
26914
+ * @property {Vector3} pos - Where it is, change it to move it
26915
+ * @property {Vector3} velocity - How far it moves each frame
26916
+ * @property {number} age - Seconds it has lived
26917
+ * @property {number} lifeTime - Seconds it lives
26918
+ * @property {boolean} destroyed - Set by destroy
26919
+ * @property {function(): void} destroy - End it this update, the destroy callback gets it
26920
+ * @memberof Render3D
26921
+ */
26922
+
26923
+ /**
26924
+ * @callback Particle3DCallback - A function a ParticleEmitter3D calls with one of its particles
26925
+ * @param {Particle3D} particle
26926
+ * @memberof Render3D
26927
+ */
26928
+
26929
+ /**
26930
+ * @callback Particle3DCollideCallback - Decides whether a particle stops where it hits the level, a filter as in 2D
26931
+ * @param {Particle3D} particle
26932
+ * @param {EngineObject3D} level - The HeightMap or VoxelMap it hit
26933
+ * @param {Vector3} pos - Where it hit
26934
+ * @return {boolean|void} - true to stop it there; a callback that returns nothing lets it pass through
26935
+ * @memberof Render3D
26936
+ */
25963
26937
 
25964
26938
  /**
25965
26939
  * ParticleEmitter3D - Spawns camera facing particles, the 3D twin of ParticleEmitter
@@ -25973,6 +26947,10 @@ const render3DParticlePos = vec3(), render3DParticleColor = new Color, render3DP
25973
26947
  * - gravity here is its own number added to velocity y each frame: it is neither the engine's 2D
25974
26948
  * gravity nor render3D.gravity, so an effect keeps its own fall wherever it is used
25975
26949
  * - An emitter with an emitTime destroys itself once its last particle is gone, like the 2D emitter
26950
+ * - Callbacks as the 2D emitter's: particleCreateCallback, particleUpdateCallback, particleCollideCallback and
26951
+ * particleDestroyCallback, each given a Particle3D, one object the emitter reuses for every particle and call
26952
+ * - collideLevel, off by default, has particles hit the height maps and voxel maps, bouncing by restitution and
26953
+ * sliding by friction
25976
26954
  * @extends EngineObject3D
25977
26955
  * @memberof Render3D
25978
26956
  * @example
@@ -26045,7 +27023,8 @@ class ParticleEmitter3D extends EngineObject3D
26045
27023
  this.randomness = randomness;
26046
27024
  /** @property {boolean} - Additive blending */
26047
27025
  this.additive = additive;
26048
- /** @property {number} - Seconds of each particle's path to draw as a ribbon behind it, 0 draws billboards */
27026
+ /** @property {number} - Seconds of each particle's path to draw as a ribbon behind it, 0 draws billboards;
27027
+ * with engineVariableStep it keeps trailTime * frameRate updates of path */
26049
27028
  this.trailTime = 0;
26050
27029
  /** @property {number} - Radians per frame each particle turns in the camera plane, either way; 0 is no spin */
26051
27030
  this.angleSpeed = 0;
@@ -26061,6 +27040,25 @@ class ParticleEmitter3D extends EngineObject3D
26061
27040
  this.trailData = undefined;
26062
27041
  /** @property {number} - Trail points kept per particle, from trailTime */
26063
27042
  this.trailMax = 0;
27043
+ /** @property {boolean} - Particles hit the level, the height maps and voxel maps, bouncing by restitution and
27044
+ * sliding along by friction; off by default, it tests each particle's move against the level every frame */
27045
+ this.collideLevel = false;
27046
+ /** @property {Particle3DCallback|undefined} - Called with each particle as it is made
27047
+ * @type {Particle3DCallback|undefined} */
27048
+ this.particleCreateCallback = undefined;
27049
+ /** @property {Particle3DCallback|undefined} - Called with each particle each update, after it moves
27050
+ * @type {Particle3DCallback|undefined} */
27051
+ this.particleUpdateCallback = undefined;
27052
+ /** @property {Particle3DCollideCallback|undefined} - Decides if a particle stops where it hits the level,
27053
+ * with collideLevel on; a callback that returns nothing lets it through
27054
+ * @type {Particle3DCollideCallback|undefined} */
27055
+ this.particleCollideCallback = undefined;
27056
+ /** @property {Particle3DCallback|undefined} - Called with each particle as it goes, its life over or destroyed
27057
+ * @type {Particle3DCallback|undefined} */
27058
+ this.particleDestroyCallback = undefined;
27059
+ /** @property {Particle3D} - The particle the callbacks get, one object for every particle and call
27060
+ * @type {Particle3D} */
27061
+ this.particleView = render3DParticleView(this);
26064
27062
  /** @property {Vector3|undefined} - Where the emitter was at its last update, for when its parent is destroyed
26065
27063
  * @type {Vector3|undefined} */
26066
27064
  this.worldPos3D = undefined;
@@ -26090,8 +27088,9 @@ class ParticleEmitter3D extends EngineObject3D
26090
27088
  else if (!this.particleCount)
26091
27089
  this.destroy();
26092
27090
 
26093
- // the trail storage follows trailTime, a change starts every trail over
26094
- const trailMax = this.trailTime ? max(1, round(this.trailTime / timeDelta)) : 0;
27091
+ // the trail storage follows trailTime at the fixed rate, a change starts every trail over, so a varying
27092
+ // timeDelta can not; with engineVariableStep a trail keeps that many updates
27093
+ const trailMax = this.trailTime ? max(1, round(this.trailTime * frameRate)) : 0;
26095
27094
  if (trailMax !== this.trailMax)
26096
27095
  {
26097
27096
  this.trailMax = trailMax;
@@ -26101,8 +27100,13 @@ class ParticleEmitter3D extends EngineObject3D
26101
27100
  }
26102
27101
 
26103
27102
  // move the particles and drop the dead ones, all in the typed array: this runs for every particle every frame
26104
- const F = RENDER3D_PARTICLE_FLOATS, data = this.particleData, trail = this.trailData;
27103
+ // a callback can emit, which grows the arrays when they are full, so they are read again after each one
27104
+ const F = RENDER3D_PARTICLE_FLOATS;
27105
+ let data = this.particleData, trail = this.trailData;
27106
+ const reread = ()=> { data = this.particleData, trail = this.trailData; };
26105
27107
  const damping = this.damping, gravity = this.gravity * scale, angleDamping = this.angleDamping; // a bigger effect has to fall faster to keep the same arc
27108
+ const collideLevel = this.collideLevel && render3DLevel.length;
27109
+ const updateCallback = this.particleUpdateCallback, destroyCallback = this.particleDestroyCallback;
26106
27110
  for (let i = this.particleCount; i--;)
26107
27111
  {
26108
27112
  // damped first and gravity added after, the order the 2D particle uses, so the same
@@ -26111,6 +27115,7 @@ class ParticleEmitter3D extends EngineObject3D
26111
27115
  const vx = data[k+3] *= damping, vy = data[k+4] = data[k+4] * damping + gravity, vz = data[k+5] *= damping;
26112
27116
  data[k] += vx, data[k+1] += vy, data[k+2] += vz;
26113
27117
  data[k+18] += data[k+19] *= angleDamping;
27118
+ collideLevel && (this.particleCollide(k, data[k] - vx, data[k+1] - vy, data[k+2] - vz), reread());
26114
27119
  const t = i * trailMax * 3;
26115
27120
  if (trailMax)
26116
27121
  {
@@ -26122,9 +27127,11 @@ class ParticleEmitter3D extends EngineObject3D
26122
27127
  trail[j] = data[k], trail[j+1] = data[k+1], trail[j+2] = data[k+2];
26123
27128
  data[k+20] = n + 1;
26124
27129
  }
27130
+ updateCallback && (this.particleCall(updateCallback, k), reread());
26125
27131
  if ((data[k+17] += timeDelta) >= data[k+16])
26126
27132
  {
26127
27133
  // dead: the last particle takes its slot, trail and all, order does not matter
27134
+ destroyCallback && (this.particleCall(destroyCallback, k), reread());
26128
27135
  const last = --this.particleCount, kl = last * F;
26129
27136
  if (i !== last)
26130
27137
  {
@@ -26191,6 +27198,60 @@ class ParticleEmitter3D extends EngineObject3D
26191
27198
  data[k+18] = this.angleSpeed ? rand(2*PI) : 0;
26192
27199
  data[k+19] = this.angleSpeed ? this.angleSpeed * random() * randSign() : 0;
26193
27200
  data[k+20] = 0; // trail points
27201
+ this.particleCreateCallback && this.particleCall(this.particleCreateCallback, k);
27202
+ }
27203
+
27204
+ // call a particle callback with the particle at k: the emitter's one Particle3D is set from its numbers, and
27205
+ // what the callback changes is written back; a particle it destroys has lived its life, the update removes it
27206
+ particleCall(callback, k, level, pos)
27207
+ {
27208
+ // a callback that emits runs the create callback inside itself, which gets a view of its own so this one
27209
+ // keeps its particle; what the callback changes is written to the arrays as they are after it, since an
27210
+ // emit into a full emitter grows them
27211
+ const nested = render3DParticlesCalling.has(this);
27212
+ const view = nested ? render3DParticleView(this) : this.particleView, data = this.particleData;
27213
+ view.pos.set(data[k], data[k+1], data[k+2]);
27214
+ view.velocity.set(data[k+3], data[k+4], data[k+5]);
27215
+ view.age = data[k+17], view.lifeTime = data[k+16], view.destroyed = false;
27216
+ render3DParticlesCalling.add(this);
27217
+ let result;
27218
+ try { result = callback(view, level, pos); }
27219
+ finally { nested || render3DParticlesCalling.delete(this); }
27220
+ const out = this.particleData;
27221
+ out[k] = view.pos.x, out[k+1] = view.pos.y, out[k+2] = view.pos.z;
27222
+ out[k+3] = view.velocity.x, out[k+4] = view.velocity.y, out[k+5] = view.velocity.z;
27223
+ if (view.destroyed)
27224
+ out[k+17] = max(out[k+17], out[k+16] - timeDelta); // aged past its life this update
27225
+ return result;
27226
+ }
27227
+
27228
+ // stop the particle at k where its last move went into the level, from where it was: back at the surface, a hair
27229
+ // off it, its speed into it turned around by restitution and its speed along it kept by friction, unless the
27230
+ // collide callback lets it through; one that starts inside is let go, as a 2D one is
27231
+ particleCollide(k, x, y, z)
27232
+ {
27233
+ let data = this.particleData;
27234
+ const from = render3DParticleFrom.set(x, y, z);
27235
+ const to = render3DParticleTo.set(data[k], data[k+1], data[k+2]);
27236
+ let hit, level;
27237
+ for (const l of render3DLevel)
27238
+ {
27239
+ const h = l.destroyed ? undefined : l.levelSegment3D(from, to);
27240
+ if (h && (!hit || h.distance < hit.distance))
27241
+ hit = h, level = l;
27242
+ }
27243
+ if (!hit) return;
27244
+ const point = from.lerp(to, hit.distance);
27245
+ const callback = this.particleCollideCallback;
27246
+ if (callback && (!this.particleCall(callback, k, level, point.copy()) || this.particleView.destroyed))
27247
+ return; // let through, or destroyed by the callback
27248
+ data = this.particleData; // as the callback left it, an emit may have grown it
27249
+
27250
+ const n = hit.normal, v = vec3(data[k+3], data[k+4], data[k+5]), into = n.scale(v.dot(n));
27251
+ const restitution = max(this.restitution, level.restitution), friction = max(this.friction, level.friction);
27252
+ const out = v.subtract(into).scale(friction).subtract(into.scale(restitution)), p = point.add(n.scale(1e-3));
27253
+ data[k] = p.x, data[k+1] = p.y, data[k+2] = p.z;
27254
+ data[k+3] = out.x, data[k+4] = out.y, data[k+5] = out.z;
26194
27255
  }
26195
27256
 
26196
27257
  /** Draw the particles, as flat squares or as streaks when trailTime is set
@@ -27345,6 +28406,18 @@ class VoxelMap extends EngineObject3D
27345
28406
  * @ignore */
27346
28407
  levelRaycast3D(ray) { return this.raycast(ray)?.distance; }
27347
28408
 
28409
+ /** Where a short move goes into a block, for a particle's move in one frame: the part of the move made before it,
28410
+ * 0 to 1, and the normal of the face it comes in through; undefined when it hits none, or starts inside one
28411
+ * @param {Vector3} from
28412
+ * @param {Vector3} to
28413
+ * @return {{distance: number, normal: Vector3}|undefined}
28414
+ * @ignore */
28415
+ levelSegment3D(from, to)
28416
+ {
28417
+ const hit = this.raycast(new Ray3D(from, to.subtract(from)), 1);
28418
+ return hit && hit.distance > 0 ? {distance: hit.distance, normal: hit.normal} : undefined;
28419
+ }
28420
+
27348
28421
  /** Keeps an eye on its placement, called automatically each frame */
27349
28422
  update()
27350
28423
  {
@@ -27381,6 +28454,194 @@ class VoxelMap extends EngineObject3D
27381
28454
  }
27382
28455
  }
27383
28456
 
28457
+ /**
28458
+ * LittleJS 3D Level Plugin
28459
+ * - A 3D level is a list of objects, each a type name, a position, and a rotation, scale and properties where
28460
+ * they are not the default, kept as plain JSON that can be written by hand
28461
+ * - level3DAddType names the types a game makes them from, as objectLayersAddType does in 2D
28462
+ * - level3DLoad makes every object of a level
28463
+ * - Box, Sphere, Cylinder and Light are built in, to block out and light a level with no code
28464
+ * - The 3D level editor, in debug builds, edits the level a game loaded
28465
+ * @namespace Level3D
28466
+ */
28467
+
28468
+ ///////////////////////////////////////////////////////////////////////////////
28469
+
28470
+ const LEVEL3D_VERSION = 1; // the format a level's littlejs3D names
28471
+
28472
+ // the types a level's objects are made from, by name
28473
+ const level3DTypes = new Map;
28474
+
28475
+ // the scale each object had when it was made, the level's scale multiplies it
28476
+ const level3DBaseScale = new WeakMap;
28477
+
28478
+ /** Add a type of object, so level3DLoad makes one wherever a level has an object of that type
28479
+ * - The name is a string because minified builds rename classes
28480
+ * - A class, or any function with a prototype, is made with new make(pos3D, properties); an arrow function is
28481
+ * called as make(pos3D, properties), for what is not an object, like a player start
28482
+ * - properties is the defaults with the object's own values over them, and each of the type's own is also set on
28483
+ * what was made; a property the type has no default for is in properties and is not set
28484
+ * - Give a class a constructor of its own that takes the position: one that hands every argument on to
28485
+ * EngineObject3D would hand it the properties as its mesh
28486
+ * - An EngineObject3D then gets the object's rotation, and its scale times the scale it was made with
28487
+ * - Adding a name again replaces it, Box, Sphere, Cylinder and Light too
28488
+ * @param {string} name - The type the objects have in the level
28489
+ * @param {Function} make - A class made at each object's position, or a function called with it
28490
+ * @param {Object} [defaults] - Properties of each one made, the level editor shows inputs for them
28491
+ * @param {TileInfo} [tileInfo] - An icon for the level editor
28492
+ * @memberof Level3D
28493
+ * @example
28494
+ * level3DAddType('Crate', Crate, {health: 3});
28495
+ * level3DAddType('PlayerStart', (pos)=> playerStart = pos); */
28496
+ function level3DAddType(name, make, defaults={}, tileInfo)
28497
+ {
28498
+ false&&ASSERT(isStringLike(name), 'object type name must be a string');
28499
+ false&&ASSERT(typeof make === 'function', 'make must be a class or function');
28500
+ false&&ASSERT(!!defaults && typeof defaults === 'object', 'defaults must be an object');
28501
+ level3DTypes.set(String(name), {make, defaults, tileInfo});
28502
+ }
28503
+
28504
+ /** Add a type that is a mesh and nothing more, a static prop with no class to write, for a built mesh or a model
28505
+ * - Each object has a color and a solid property, solid collides as the box around the mesh
28506
+ * @param {string} name - The type the objects have in the level
28507
+ * @param {Mesh} mesh - Shared by every object of the type
28508
+ * @param {TileInfo} [tileInfo] - Its texture
28509
+ * @param {Color} [color] - Its color, an object's own color property goes over it
28510
+ * @memberof Level3D
28511
+ * @example
28512
+ * level3DAddMesh('Tree', treeMesh, tile(4)); */
28513
+ function level3DAddMesh(name, mesh, tileInfo, color=WHITE)
28514
+ {
28515
+ false&&ASSERT(mesh instanceof Mesh, 'mesh must be a Mesh');
28516
+ false&&ASSERT(isColor(color), 'color must be a color');
28517
+ level3DAddType(name, function(pos, properties) { return level3DMakeShape(pos, properties, mesh, tileInfo); },
28518
+ {color, solid: false}, tileInfo);
28519
+ }
28520
+
28521
+ /** Make the objects of a level, each from the type added for its name
28522
+ * - The level is an object: {littlejs3D: 1, objects: [{id, type, pos: [x, y, z]}, ...]}, and {} is a new one
28523
+ * - rotation is pitch, yaw and roll in degrees, scale is a number for each axis, both left out when they are
28524
+ * the default, and properties holds what differs from the type's defaults
28525
+ * - A Color property is a #rrggbb or #rrggbbaa string, a Vector2 or Vector3 an array, as the default says
28526
+ * - An object whose type was not added is skipped, with a warning in debug builds
28527
+ * - What a file written by hand gets wrong uses the default: a value that is not of its default's type
28528
+ * - An object its type can not make is skipped with an error in debug builds, where asserts throw, and the rest
28529
+ * of the level is made
28530
+ * @param {Object} level - The level, the level editor edits this same object
28531
+ * @return {Array<any>} - What each object's type made, a function that made nothing is left out
28532
+ * @memberof Level3D */
28533
+ function level3DLoad(level)
28534
+ {
28535
+ false&&ASSERT(!!level && typeof level === 'object', 'a level is an object, {} for a new one');
28536
+ false&&ASSERT(!(level.littlejs3D > LEVEL3D_VERSION), 'the level was made by a newer LittleJS');
28537
+ editor3DLevelLoaded(level); // debug builds: the 3D editor takes the level, its autosaved edits go in first
28538
+ const made = [];
28539
+ for (const object of isArray(level.objects) ? level.objects : [])
28540
+ {
28541
+ if (!object || typeof object !== 'object') continue;
28542
+ const result = level3DMake(object);
28543
+ editor3DObjectMade(object, result); // debug builds link it for the 3D editor
28544
+ result && made.push(result);
28545
+ }
28546
+ return made;
28547
+ }
28548
+
28549
+ // a vec3 of an array of three numbers, as the file has them, or the fallback
28550
+ function level3DVector(value, fallback)
28551
+ {
28552
+ return isArray(value) && value.length === 3 && value.every((v)=> isNumber(v)) ?
28553
+ vec3(value[0], value[1], value[2]) : fallback;
28554
+ }
28555
+
28556
+ // the defaults of an object's type, a Color or vector copied for each object, then its own properties over them,
28557
+ // each read as its default is; one the file got wrong, a value not of its default's type, keeps the default, and
28558
+ // one the type has no default for is kept as it is
28559
+ function level3DProperties(type, object)
28560
+ {
28561
+ const properties = {}, own = object.properties;
28562
+ for (const [key, value] of Object.entries(type.defaults))
28563
+ properties[key] = value?.copy ? value.copy() : value;
28564
+ for (const [key, value] of Object.entries(own && typeof own === 'object' ? own : {}))
28565
+ {
28566
+ const d = type.defaults[key];
28567
+ if (isColor(d))
28568
+ /^#([0-9a-f]{6}|[0-9a-f]{8})$/i.test(value) && (properties[key] = new Color().setHex(value));
28569
+ else if (isVector3(d))
28570
+ properties[key] = level3DVector(value, properties[key]);
28571
+ else if (isVector2(d))
28572
+ isArray(value) && value.length === 2 && value.every((v)=> isNumber(v)) &&
28573
+ (properties[key] = vec2(value[0], value[1]));
28574
+ else if (d === undefined || typeof value === typeof d)
28575
+ properties[key] = value;
28576
+ }
28577
+ return properties;
28578
+ }
28579
+
28580
+ // make one object of a level from the type added for its name, undefined when there is no such type or it made
28581
+ // nothing
28582
+ function level3DMake(object)
28583
+ {
28584
+ const type = level3DTypes.get(object.type);
28585
+ if (!type)
28586
+ {
28587
+ debug && console.warn(`level3DLoad: no type added for ${object.type}, skipped`);
28588
+ return;
28589
+ }
28590
+ const pos = level3DVector(object.pos, vec3()), properties = level3DProperties(type, object);
28591
+ const {make} = type, count = engineObjects.length;
28592
+ let result;
28593
+ try { result = make.prototype ? new make(pos, properties) : make(pos, properties); }
28594
+ catch (error)
28595
+ {
28596
+ // a value the type can not be made with: in debug builds, where asserts throw, the object is skipped with
28597
+ // what it made so far, so the rest of the level loads and the level editor can put the value right
28598
+ if (!debug) throw error;
28599
+ console.error(`level3DLoad: ${object.type} ${object.id} could not be made, skipped:`, error);
28600
+ for (const o of engineObjects.slice(count))
28601
+ o.destroy();
28602
+ return;
28603
+ }
28604
+ if (!result || typeof result !== 'object') return;
28605
+ if (result instanceof EngineObject3D)
28606
+ {
28607
+ level3DBaseScale.set(result, result.scale3D.copy());
28608
+ result.rotation3D = level3DVector(object.rotation, vec3()).scale(PI / 180);
28609
+ result.scale3D = result.scale3D.multiply(level3DVector(object.scale, vec3(1)));
28610
+ }
28611
+
28612
+ // the type's own properties are set on it, one the type has no default for could be a field of the engine's
28613
+ for (const key in type.defaults)
28614
+ result[key] = properties[key];
28615
+ return result;
28616
+ }
28617
+
28618
+ ///////////////////////////////////////////////////////////////////////////////
28619
+ // the built-in types
28620
+
28621
+ // a static object of a mesh, with the color, tile and solid properties the built-in types have
28622
+ function level3DMakeShape(pos, properties, mesh, tileInfo, asSphere=false)
28623
+ {
28624
+ const o = new EngineObject3D(pos, mesh, properties.tile >= 0 ? tile(properties.tile) : tileInfo, properties.color);
28625
+ o.collideAsSphere3D = asSphere;
28626
+ properties.solid && o.setCollision();
28627
+ return o;
28628
+ }
28629
+
28630
+ // the cylinder all Cylinder objects share, built the first time one is made
28631
+ let level3DCylinderMesh;
28632
+
28633
+ level3DAddType('Box', function(pos, properties) { return level3DMakeShape(pos, properties, render3D.boxMesh); },
28634
+ {color: WHITE, tile: -1, solid: true});
28635
+ level3DAddType('Sphere', function(pos, properties)
28636
+ { return level3DMakeShape(pos, properties, render3D.sphereMesh, undefined, true); },
28637
+ {color: WHITE, tile: -1, solid: true});
28638
+ level3DAddType('Cylinder', function(pos, properties)
28639
+ { return level3DMakeShape(pos, properties, level3DCylinderMesh ||= buildCylinder()); },
28640
+ {color: WHITE, tile: -1, solid: true});
28641
+ level3DAddType('Light', function(pos, properties)
28642
+ { return new Light3D(pos, properties.radius, properties.color, properties.intensity); },
28643
+ {color: WHITE, radius: 5, intensity: 1});
28644
+
27384
28645
  /**
27385
28646
  * LittleJS glTF Plugin
27386
28647
  * - Loads glTF 2.0 models: a .gltf with its .bin and images beside it, or a .glb with everything in one file
@@ -27391,6 +28652,8 @@ class VoxelMap extends EngineObject3D
27391
28652
  * GLTFObject that createObject makes; a skinned character's walk is not read
27392
28653
  * - Materials give a base color and texture and whether they blend; glass made with KHR_materials_transmission blends too,
27393
28654
  * and a KHR_materials_unlit material comes in emissive, its own color with no shading
28655
+ * - A material's normal map and emissive map load too, with its normal scale and emissive factor, read at the base
28656
+ * color texture's uvs; roughness, metalness and occlusion maps are not loaded
27394
28657
  * - The base color texture reads the uv set its texCoord names, moved by KHR_texture_transform as gltfpack and
27395
28658
  * Blender write it
27396
28659
  * - An OPAQUE material, the default, ignores its texture's alpha as the format says: a texture only such materials
@@ -27437,6 +28700,17 @@ class GLTFPart
27437
28700
  /** @property {boolean} - The material is unlit (KHR_materials_unlit), its own color with no shading; the object
27438
28701
  * createObject makes draws it with emissive 1 */
27439
28702
  this.unlit = false;
28703
+ /** @property {TextureInfo|undefined} - The material's normal map, drawn with the base color texture's uvs
28704
+ * @type {TextureInfo|undefined} */
28705
+ this.normalMap = undefined;
28706
+ /** @property {number} - The normal map's strength, its scale in the file */
28707
+ this.normalScale = 1;
28708
+ /** @property {TextureInfo|undefined} - The material's emissive map, or a white texture when it has an
28709
+ * emissiveFactor and no texture, so it glows all over
28710
+ * @type {TextureInfo|undefined} */
28711
+ this.emissiveMap = undefined;
28712
+ /** @property {Color} - The emissiveFactor, which multiplies the emissive map */
28713
+ this.emissiveMapColor = WHITE;
27440
28714
  }
27441
28715
  }
27442
28716
 
@@ -27582,8 +28856,8 @@ class GLTFModel
27582
28856
  for (const part of this.parts)
27583
28857
  part.mesh.dispose();
27584
28858
  this.mesh.dispose();
27585
- for (const textureInfo of new Set(this.parts.map(p=> p.textureInfo)))
27586
- textureInfo?.destroyWebGLTexture();
28859
+ for (const textureInfo of new Set(this.parts.flatMap(p=> [p.textureInfo, p.normalMap, p.emissiveMap])))
28860
+ textureInfo !== gltfWhiteTextureInfo && textureInfo?.destroyWebGLTexture(); // the white one is shared
27587
28861
  }
27588
28862
 
27589
28863
  /** Make an object at a position with a child per part, so each keeps its own texture, color and blending, and
@@ -27641,6 +28915,8 @@ class GLTFObject extends EngineObject3D
27641
28915
  o.transparent = part.transparent;
27642
28916
  o.pixelated = part.pixelated;
27643
28917
  o.emissive = part.unlit ? 1 : 0;
28918
+ o.normalMap = part.normalMap, o.normalScale = part.normalScale;
28919
+ o.emissiveMap = part.emissiveMap, o.emissiveMapColor = part.emissiveMapColor.copy(); // its own, as color is
27644
28920
  const rest = model.nodeTree?.restPose?.[part.node];
27645
28921
  if (rest)
27646
28922
  {
@@ -27777,13 +29053,15 @@ async function parseGLTF(data, baseUrl='')
27777
29053
  return gltfFetch(buffer.uri, baseUrl).then(r=> r.arrayBuffer());
27778
29054
  }));
27779
29055
 
27780
- // the textures, decoded together first; none without WebGL, and a failed image only logs; only the base color
27781
- // textures are drawn with, so the normal, roughness and other maps are not loaded at all
27782
- const baseColorTextures = new Set((json.materials || []).map(m=> m.pbrMetallicRoughness?.baseColorTexture?.index));
29056
+ // the textures, decoded together first; none without WebGL, and a failed image only logs; the base color, normal
29057
+ // and emissive textures are drawn with, the roughness and occlusion maps are not loaded at all
29058
+ const usedTextures = new Set((json.materials || []).flatMap(m=> [m.pbrMetallicRoughness?.baseColorTexture?.index,
29059
+ m.normalTexture?.index, m.emissiveTexture?.index]));
29060
+ const normalTextures = new Set((json.materials || []).map(m=> m.normalTexture?.index));
27783
29061
  const opaqueTextures = gltfOpaqueTextures(json);
27784
29062
  const textures = await Promise.all((json.textures || []).map(async (texture, index)=>
27785
29063
  {
27786
- if (!glContext || typeof createImageBitmap == 'undefined' || !baseColorTextures.has(index)) return;
29064
+ if (!glContext || typeof createImageBitmap == 'undefined' || !usedTextures.has(index)) return;
27787
29065
  try
27788
29066
  {
27789
29067
  // a WebP or AVIF image is named by its extension, the browser decodes those like any other
@@ -27800,7 +29078,11 @@ async function parseGLTF(data, baseUrl='')
27800
29078
  // an OPAQUE material ignores its texture's alpha, which the 3D pass would cut holes by, so a texture only
27801
29079
  // those use has it set to 1; decoded as stored for that, and a jpeg has no alpha to set
27802
29080
  const opaque = opaqueTextures.has(index) && blob.type !== 'image/jpeg';
27803
- const bitmap = opaque ? await createImageBitmap(blob, {premultiplyAlpha: 'none'}).then(gltfOpaqueImage) : await createImageBitmap(blob);
29081
+ // a normal map is directions, not colors, so it is decoded as stored; one a material also uses as a
29082
+ // color, an odd file, is decoded that way too
29083
+ const bitmap = normalTextures.has(index) ?
29084
+ await createImageBitmap(blob, {colorSpaceConversion: 'none', premultiplyAlpha: 'none'}) :
29085
+ opaque ? await createImageBitmap(blob, {premultiplyAlpha: 'none'}).then(gltfOpaqueImage) : await createImageBitmap(blob);
27804
29086
  return new TextureInfo(bitmap, true, [sampler.wrapS ?? 10497, sampler.wrapT ?? 10497]); // REPEAT by default
27805
29087
  }
27806
29088
  catch (e) { false&&LOG('glTF image not loaded', e); }
@@ -27876,7 +29158,7 @@ async function parseGLTF(data, baseUrl='')
27876
29158
  }
27877
29159
 
27878
29160
  // a texture none of the parts draws with, another scene's, goes now too, since dispose finds them by the parts
27879
- const used = new Set(parts.map(p=> p.textureInfo));
29161
+ const used = new Set(parts.flatMap(p=> [p.textureInfo, p.normalMap, p.emissiveMap]));
27880
29162
  for (const texture of textures)
27881
29163
  texture && !used.has(texture) && texture.destroyWebGLTexture();
27882
29164
  return new GLTFModel(parts, animations, {nodes: json.nodes, parents, restInverse, restPose});
@@ -28112,9 +29394,34 @@ function gltfPart(json, buffers, textures, primitive, matrix, name)
28112
29394
  textureRef ? textures[textureRef.index] : undefined, blend || transmission > 0);
28113
29395
  part.pixelated = json.samplers?.[texture?.sampler]?.magFilter === 9728; // NEAREST
28114
29396
  part.unlit = !!material.extensions?.KHR_materials_unlit;
29397
+
29398
+ // the normal and emissive maps, read at the base color texture's uvs; the emissive texture is multiplied by the
29399
+ // factor, which is black by default as the format says, so a texture alone does not glow; a factor with no
29400
+ // texture glows all over
29401
+ const normalRef = material.normalTexture, emissiveRef = material.emissiveTexture;
29402
+ part.normalMap = normalRef && textures[normalRef.index];
29403
+ part.normalScale = normalRef?.scale ?? 1;
29404
+ const [er, eg, eb] = material.emissiveFactor || [0, 0, 0];
29405
+ if (er || eg || eb)
29406
+ {
29407
+ part.emissiveMap = emissiveRef ? textures[emissiveRef.index] : gltfWhiteTexture();
29408
+ part.emissiveMapColor = rgb(gltfSRGB(er), gltfSRGB(eg), gltfSRGB(eb));
29409
+ }
28115
29410
  return part;
28116
29411
  }
28117
29412
 
29413
+ // a 1 by 1 white texture, the emissive map of a material that glows all over with no texture; made once and never
29414
+ // freed, like the engine's own white texture, undefined without WebGL
29415
+ let gltfWhiteTextureInfo;
29416
+ function gltfWhiteTexture()
29417
+ {
29418
+ if (gltfWhiteTextureInfo || !glContext || typeof OffscreenCanvas == 'undefined') return gltfWhiteTextureInfo;
29419
+ const context = createCanvasContext(1);
29420
+ context.fillStyle = '#fff';
29421
+ context.fillRect(0, 0, 1, 1);
29422
+ return gltfWhiteTextureInfo = new TextureInfo(context.canvas);
29423
+ }
29424
+
28118
29425
  // a glTF color factor or vertex color is linear, where the renderer works in sRGB like the textures, so it is
28119
29426
  // brought across at load or a mid gray material would come out nearly black; 0 and 1 stay exact
28120
29427
  function gltfSRGB(c)