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.
- package/dist/littlejs.d.ts +408 -26
- package/dist/littlejs.esm.js +3360 -161
- package/dist/littlejs.esm.min.js +3 -3
- package/dist/littlejs.js +3344 -161
- package/dist/littlejs.min.js +2 -2
- package/dist/littlejs.release.js +1448 -141
- package/package.json +1 -1
- package/plugins/gltf.js +54 -8
- package/plugins/math3d.js +90 -10
- package/plugins/pluginExport.js +10 -0
- package/plugins/postProcess.js +41 -12
- package/plugins/render3d.js +424 -55
- package/plugins/render3dDebug.js +2 -1
- package/plugins/render3dEditor.js +1812 -0
- package/plugins/render3dExtras.js +199 -5
- package/plugins/render3dLevel.js +189 -0
- package/plugins/render3dVoxels.js +12 -0
- package/plugins/sceneSystem.js +58 -0
- package/plugins/tweenSystem.js +4 -2
- package/src/engine.js +199 -34
- package/src/engineAudio.js +2 -2
- package/src/engineBuild.mjs +3 -0
- package/src/engineDebug.js +55 -1
- package/src/engineDraw.js +97 -6
- package/src/engineEditor.js +41 -20
- package/src/engineExport.js +6 -0
- package/src/engineInput.js +8 -4
- package/src/engineLogo.js +19 -0
- package/src/engineObject.js +3 -2
- package/src/engineRelease.js +13 -1
- package/src/engineSettings.js +38 -0
package/dist/littlejs.release.js
CHANGED
|
@@ -35,7 +35,7 @@ const engineName = 'LittleJS';
|
|
|
35
35
|
* @type {string}
|
|
36
36
|
* @default
|
|
37
37
|
* @memberof Engine */
|
|
38
|
-
const engineVersion = '1.
|
|
38
|
+
const engineVersion = '1.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
|
|
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
|
-
|
|
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
|
-
|
|
255
|
-
|
|
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
|
-
//
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
//
|
|
273
|
-
|
|
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
|
-
//
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
446
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
452
|
-
|
|
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
|
-
|
|
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()
|
|
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()
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.),
|
|
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.),
|
|
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
|
-
|
|
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
|
-
|
|
22757
|
-
|
|
22758
|
-
|
|
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
|
-
|
|
22763
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
24286
|
-
*
|
|
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
|
|
24292
|
-
*
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
24642
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ?
|
|
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 =
|
|
25473
|
+
const push = sphereBox(b, a);
|
|
24695
25474
|
return push && push.scale(-1);
|
|
24696
25475
|
}
|
|
24697
|
-
return
|
|
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
|
|
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
|
-
* -
|
|
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
|
-
/**
|
|
25102
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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;
|
|
27781
|
-
// textures are drawn with,
|
|
27782
|
-
const
|
|
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' || !
|
|
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
|
-
|
|
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.
|
|
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)
|