littlejsengine 1.20.0 → 1.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/littlejs.d.ts +1041 -81
- package/dist/littlejs.esm.js +6191 -379
- package/dist/littlejs.esm.min.js +22 -9
- package/dist/littlejs.js +6238 -476
- package/dist/littlejs.min.js +21 -8
- package/dist/littlejs.release.js +3500 -387
- package/package.json +3 -2
- package/plugins/gltf.js +61 -9
- package/plugins/lightSystem.js +44 -1
- package/plugins/math3d.js +132 -30
- package/plugins/particleEffects.js +745 -0
- package/plugins/pluginExport.js +42 -0
- package/plugins/postProcess.js +246 -39
- package/plugins/render3d.js +850 -182
- package/plugins/render3dDebug.js +2 -1
- package/plugins/render3dEditor.js +2576 -0
- package/plugins/render3dExtras.js +421 -15
- package/plugins/render3dLevel.js +352 -0
- package/plugins/render3dVoxels.js +12 -0
- package/plugins/sceneSystem.js +59 -0
- package/plugins/tweenSystem.js +4 -2
- package/src/engine.js +199 -34
- package/src/engineAudio.js +18 -9
- package/src/engineBuild.mjs +4 -0
- package/src/engineDebug.js +55 -1
- package/src/engineDraw.js +141 -7
- package/src/engineEditor.js +52 -22
- package/src/engineExport.js +8 -0
- package/src/engineInput.js +8 -4
- package/src/engineLogo.js +19 -0
- package/src/engineObject.js +3 -2
- package/src/engineParticles.js +80 -11
- package/src/engineRelease.js +13 -1
- package/src/engineSettings.js +38 -0
- package/src/engineWebGL.js +27 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "littlejsengine",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.22.0",
|
|
4
4
|
"description": "LittleJS - Tiny and Fast HTML5 Game Engine",
|
|
5
5
|
"main": "dist/littlejs.esm.js",
|
|
6
6
|
"types": "dist/littlejs.d.ts",
|
|
@@ -45,7 +45,8 @@
|
|
|
45
45
|
"build": "node src/engineBuild.mjs",
|
|
46
46
|
"build-docs": "node tools/buildDocs.mjs",
|
|
47
47
|
"build-release": "node tools/buildRelease.mjs",
|
|
48
|
-
"test": "node --test --import ./test/setup.mjs \"test/**/*.test.mjs\""
|
|
48
|
+
"test": "node --test --import ./test/setup.mjs \"test/**/*.test.mjs\"",
|
|
49
|
+
"test:browser": "node test/browser/run.mjs"
|
|
49
50
|
},
|
|
50
51
|
"engines": {
|
|
51
52
|
"node": "^20.19.0 || >=22.12.0"
|
package/plugins/gltf.js
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* GLTFObject that createObject makes; a skinned character's walk is not read
|
|
9
9
|
* - Materials give a base color and texture and whether they blend; glass made with KHR_materials_transmission blends too,
|
|
10
10
|
* and a KHR_materials_unlit material comes in emissive, its own color with no shading
|
|
11
|
+
* - A material's normal map and emissive map load too, with its normal scale and emissive factor, read at the base
|
|
12
|
+
* color texture's uvs; roughness, metalness and occlusion maps are not loaded
|
|
11
13
|
* - The base color texture reads the uv set its texCoord names, moved by KHR_texture_transform as gltfpack and
|
|
12
14
|
* Blender write it
|
|
13
15
|
* - An OPAQUE material, the default, ignores its texture's alpha as the format says: a texture only such materials
|
|
@@ -56,6 +58,17 @@ class GLTFPart
|
|
|
56
58
|
/** @property {boolean} - The material is unlit (KHR_materials_unlit), its own color with no shading; the object
|
|
57
59
|
* createObject makes draws it with emissive 1 */
|
|
58
60
|
this.unlit = false;
|
|
61
|
+
/** @property {TextureInfo|undefined} - The material's normal map, drawn with the base color texture's uvs
|
|
62
|
+
* @type {TextureInfo|undefined} */
|
|
63
|
+
this.normalMap = undefined;
|
|
64
|
+
/** @property {number} - The normal map's strength, its scale in the file */
|
|
65
|
+
this.normalScale = 1;
|
|
66
|
+
/** @property {TextureInfo|undefined} - The material's emissive map, or a white texture when it has an
|
|
67
|
+
* emissiveFactor and no texture, so it glows all over
|
|
68
|
+
* @type {TextureInfo|undefined} */
|
|
69
|
+
this.emissiveMap = undefined;
|
|
70
|
+
/** @property {Color} - The emissiveFactor, which multiplies the emissive map */
|
|
71
|
+
this.emissiveMapColor = WHITE;
|
|
59
72
|
}
|
|
60
73
|
}
|
|
61
74
|
|
|
@@ -201,8 +214,8 @@ class GLTFModel
|
|
|
201
214
|
for (const part of this.parts)
|
|
202
215
|
part.mesh.dispose();
|
|
203
216
|
this.mesh.dispose();
|
|
204
|
-
for (const textureInfo of new Set(this.parts.
|
|
205
|
-
textureInfo?.destroyWebGLTexture();
|
|
217
|
+
for (const textureInfo of new Set(this.parts.flatMap(p=> [p.textureInfo, p.normalMap, p.emissiveMap])))
|
|
218
|
+
textureInfo !== gltfWhiteTextureInfo && textureInfo?.destroyWebGLTexture(); // the white one is shared
|
|
206
219
|
}
|
|
207
220
|
|
|
208
221
|
/** Make an object at a position with a child per part, so each keeps its own texture, color and blending, and
|
|
@@ -260,6 +273,8 @@ class GLTFObject extends EngineObject3D
|
|
|
260
273
|
o.transparent = part.transparent;
|
|
261
274
|
o.pixelated = part.pixelated;
|
|
262
275
|
o.emissive = part.unlit ? 1 : 0;
|
|
276
|
+
o.normalMap = part.normalMap, o.normalScale = part.normalScale;
|
|
277
|
+
o.emissiveMap = part.emissiveMap, o.emissiveMapColor = part.emissiveMapColor.copy(); // its own, as color is
|
|
263
278
|
const rest = model.nodeTree?.restPose?.[part.node];
|
|
264
279
|
if (rest)
|
|
265
280
|
{
|
|
@@ -346,7 +361,13 @@ async function loadGLTF(url)
|
|
|
346
361
|
const response = await fetch(url);
|
|
347
362
|
if (!response.ok)
|
|
348
363
|
throw new Error('loadGLTF failed: ' + url);
|
|
349
|
-
|
|
364
|
+
// the files beside it are beside where it came from, after any redirect; a blob or data url has nothing
|
|
365
|
+
// beside it, and a model in one is whole, or parsed with parseGLTF and a base of its own
|
|
366
|
+
const from = response.url || url;
|
|
367
|
+
let base = '';
|
|
368
|
+
if (!/^(blob|data):/i.test(from))
|
|
369
|
+
base = response.url ? new URL('.', from).href : from.slice(0, from.lastIndexOf('/') + 1);
|
|
370
|
+
return parseGLTF(await response.arrayBuffer(), base);
|
|
350
371
|
}
|
|
351
372
|
|
|
352
373
|
/** Parse a model from GLB bytes or glTF JSON, fetching the buffers and images it refers to
|
|
@@ -396,13 +417,15 @@ async function parseGLTF(data, baseUrl='')
|
|
|
396
417
|
return gltfFetch(buffer.uri, baseUrl).then(r=> r.arrayBuffer());
|
|
397
418
|
}));
|
|
398
419
|
|
|
399
|
-
// the textures, decoded together first; none without WebGL, and a failed image only logs;
|
|
400
|
-
// textures are drawn with,
|
|
401
|
-
const
|
|
420
|
+
// the textures, decoded together first; none without WebGL, and a failed image only logs; the base color, normal
|
|
421
|
+
// and emissive textures are drawn with, the roughness and occlusion maps are not loaded at all
|
|
422
|
+
const usedTextures = new Set((json.materials || []).flatMap(m=> [m.pbrMetallicRoughness?.baseColorTexture?.index,
|
|
423
|
+
m.normalTexture?.index, m.emissiveTexture?.index]));
|
|
424
|
+
const normalTextures = new Set((json.materials || []).map(m=> m.normalTexture?.index));
|
|
402
425
|
const opaqueTextures = gltfOpaqueTextures(json);
|
|
403
426
|
const textures = await Promise.all((json.textures || []).map(async (texture, index)=>
|
|
404
427
|
{
|
|
405
|
-
if (!glContext || typeof createImageBitmap == 'undefined' || !
|
|
428
|
+
if (!glContext || typeof createImageBitmap == 'undefined' || !usedTextures.has(index)) return;
|
|
406
429
|
try
|
|
407
430
|
{
|
|
408
431
|
// a WebP or AVIF image is named by its extension, the browser decodes those like any other
|
|
@@ -419,7 +442,11 @@ async function parseGLTF(data, baseUrl='')
|
|
|
419
442
|
// an OPAQUE material ignores its texture's alpha, which the 3D pass would cut holes by, so a texture only
|
|
420
443
|
// those use has it set to 1; decoded as stored for that, and a jpeg has no alpha to set
|
|
421
444
|
const opaque = opaqueTextures.has(index) && blob.type !== 'image/jpeg';
|
|
422
|
-
|
|
445
|
+
// a normal map is directions, not colors, so it is decoded as stored; one a material also uses as a
|
|
446
|
+
// color, an odd file, is decoded that way too
|
|
447
|
+
const bitmap = normalTextures.has(index) ?
|
|
448
|
+
await createImageBitmap(blob, {colorSpaceConversion: 'none', premultiplyAlpha: 'none'}) :
|
|
449
|
+
opaque ? await createImageBitmap(blob, {premultiplyAlpha: 'none'}).then(gltfOpaqueImage) : await createImageBitmap(blob);
|
|
423
450
|
return new TextureInfo(bitmap, true, [sampler.wrapS ?? 10497, sampler.wrapT ?? 10497]); // REPEAT by default
|
|
424
451
|
}
|
|
425
452
|
catch (e) { LOG('glTF image not loaded', e); }
|
|
@@ -495,7 +522,7 @@ async function parseGLTF(data, baseUrl='')
|
|
|
495
522
|
}
|
|
496
523
|
|
|
497
524
|
// a texture none of the parts draws with, another scene's, goes now too, since dispose finds them by the parts
|
|
498
|
-
const used = new Set(parts.
|
|
525
|
+
const used = new Set(parts.flatMap(p=> [p.textureInfo, p.normalMap, p.emissiveMap]));
|
|
499
526
|
for (const texture of textures)
|
|
500
527
|
texture && !used.has(texture) && texture.destroyWebGLTexture();
|
|
501
528
|
return new GLTFModel(parts, animations, {nodes: json.nodes, parents, restInverse, restPose});
|
|
@@ -731,9 +758,34 @@ function gltfPart(json, buffers, textures, primitive, matrix, name)
|
|
|
731
758
|
textureRef ? textures[textureRef.index] : undefined, blend || transmission > 0);
|
|
732
759
|
part.pixelated = json.samplers?.[texture?.sampler]?.magFilter === 9728; // NEAREST
|
|
733
760
|
part.unlit = !!material.extensions?.KHR_materials_unlit;
|
|
761
|
+
|
|
762
|
+
// the normal and emissive maps, read at the base color texture's uvs; the emissive texture is multiplied by the
|
|
763
|
+
// factor, which is black by default as the format says, so a texture alone does not glow; a factor with no
|
|
764
|
+
// texture glows all over
|
|
765
|
+
const normalRef = material.normalTexture, emissiveRef = material.emissiveTexture;
|
|
766
|
+
part.normalMap = normalRef && textures[normalRef.index];
|
|
767
|
+
part.normalScale = normalRef?.scale ?? 1;
|
|
768
|
+
const [er, eg, eb] = material.emissiveFactor || [0, 0, 0];
|
|
769
|
+
if (er || eg || eb)
|
|
770
|
+
{
|
|
771
|
+
part.emissiveMap = emissiveRef ? textures[emissiveRef.index] : gltfWhiteTexture();
|
|
772
|
+
part.emissiveMapColor = rgb(gltfSRGB(er), gltfSRGB(eg), gltfSRGB(eb));
|
|
773
|
+
}
|
|
734
774
|
return part;
|
|
735
775
|
}
|
|
736
776
|
|
|
777
|
+
// a 1 by 1 white texture, the emissive map of a material that glows all over with no texture; made once and never
|
|
778
|
+
// freed, like the engine's own white texture, undefined without WebGL
|
|
779
|
+
let gltfWhiteTextureInfo;
|
|
780
|
+
function gltfWhiteTexture()
|
|
781
|
+
{
|
|
782
|
+
if (gltfWhiteTextureInfo || !glContext || typeof OffscreenCanvas == 'undefined') return gltfWhiteTextureInfo;
|
|
783
|
+
const context = createCanvasContext(1);
|
|
784
|
+
context.fillStyle = '#fff';
|
|
785
|
+
context.fillRect(0, 0, 1, 1);
|
|
786
|
+
return gltfWhiteTextureInfo = new TextureInfo(context.canvas);
|
|
787
|
+
}
|
|
788
|
+
|
|
737
789
|
// a glTF color factor or vertex color is linear, where the renderer works in sRGB like the textures, so it is
|
|
738
790
|
// brought across at load or a mid gray material would come out nearly black; 0 and 1 stay exact
|
|
739
791
|
function gltfSRGB(c)
|
package/plugins/lightSystem.js
CHANGED
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
* shadow map through renderShadow(), which calls render() by default; obj.castShadow = false keeps it
|
|
21
21
|
* out (a floor TileLayer, a background), a draw's alpha sets how much light it blocks, and
|
|
22
22
|
* setShadowTransparent lets its color tint the light
|
|
23
|
+
* - Set light.glow for a soft hazy glow over a light, like a lamp at night; it is added over the lit scene after the
|
|
24
|
+
* lightmap, so it shows in the dark and sits in front of everything there
|
|
23
25
|
* - Must be constructed BEFORE PostProcessPlugin so post-process sees lit pixels
|
|
24
26
|
* @namespace LightSystem
|
|
25
27
|
*/
|
|
@@ -156,6 +158,7 @@ class LightSystemPlugin
|
|
|
156
158
|
// again on a context restore, the canvas may have changed since
|
|
157
159
|
if (lightSystem.textureSizeAuto)
|
|
158
160
|
lightSystem.textureSize = mainCanvasSize.copy();
|
|
161
|
+
lightSystem.clampTextureSizes();
|
|
159
162
|
|
|
160
163
|
// allocate the lightmap texture with null data at textureSize
|
|
161
164
|
lightSystem.texture = glContext.createTexture();
|
|
@@ -347,6 +350,7 @@ class LightSystemPlugin
|
|
|
347
350
|
const ls = lightSystem;
|
|
348
351
|
|
|
349
352
|
// make the resources the first time, and again when a size changed
|
|
353
|
+
ls.clampTextureSizes();
|
|
350
354
|
if (!ls.shadowMap || ls.shadowMapSize !== ls.shadowMapSizeAllocated
|
|
351
355
|
|| ls.shadowTextureSize !== ls.shadowTextureSizeAllocated)
|
|
352
356
|
{
|
|
@@ -534,8 +538,14 @@ class LightSystemPlugin
|
|
|
534
538
|
// is, and any debug text / future draw could sample the lightmap)
|
|
535
539
|
if (glActiveTexture)
|
|
536
540
|
glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
|
|
537
|
-
setAdditiveBlendMode(prevAdditive);
|
|
538
541
|
glSetInstancedMode(true);
|
|
542
|
+
|
|
543
|
+
// 7. the lights' glows, added over the lit scene so the darkness does not dim them
|
|
544
|
+
setAdditiveBlendMode();
|
|
545
|
+
for (const o of engineObjects)
|
|
546
|
+
o instanceof Light && !o.destroyed && o.renderGlow();
|
|
547
|
+
glFlush();
|
|
548
|
+
setAdditiveBlendMode(prevAdditive);
|
|
539
549
|
}
|
|
540
550
|
function lightSystemContextLost()
|
|
541
551
|
{
|
|
@@ -554,6 +564,17 @@ class LightSystemPlugin
|
|
|
554
564
|
}
|
|
555
565
|
}
|
|
556
566
|
|
|
567
|
+
/** Bring the sizes of the textures it makes down to what the device can make: shadowMapSize, shadowTextureSize
|
|
568
|
+
* and a textureSize given by hand; called before they are made, so a size too big falls back instead of failing */
|
|
569
|
+
clampTextureSizes()
|
|
570
|
+
{
|
|
571
|
+
this.shadowMapSize = glClampTextureSize(this.shadowMapSize);
|
|
572
|
+
this.shadowTextureSize = glClampTextureSize(this.shadowTextureSize);
|
|
573
|
+
const size = this.textureSize;
|
|
574
|
+
if (size)
|
|
575
|
+
this.textureSize = vec2(glClampTextureSize(size.x), glClampTextureSize(size.y));
|
|
576
|
+
}
|
|
577
|
+
|
|
557
578
|
/** Draw a single Light's falloff blob into the currently bound lightmap.
|
|
558
579
|
* Called by Light.renderLight() during the plugin's render pass.
|
|
559
580
|
* @param {Light} light */
|
|
@@ -703,6 +724,14 @@ class Light extends EngineObject
|
|
|
703
724
|
* or torch that holds it, or the player carrying it, does not block it; it has to reach past that object's
|
|
704
725
|
* corners, about half its diagonal and a little more, or dark rays run out from them */
|
|
705
726
|
this.shadowCore = 0;
|
|
727
|
+
/** @property {number} - Size across of a soft hazy glow drawn over the light, like a lamp at night, 0 for
|
|
728
|
+
* none; it is added over the lit scene, in front of everything there */
|
|
729
|
+
this.glow = 0;
|
|
730
|
+
/** @property {number} - How fast the glow fades from its middle: 1 by default, .5 a wide haze, 2 a tight
|
|
731
|
+
* bright core */
|
|
732
|
+
this.glowFalloff = 1;
|
|
733
|
+
/** @type {TileInfo|undefined} */
|
|
734
|
+
this.glowTileInfo = undefined; // the whole glow texture, kept for the falloff it was made for
|
|
706
735
|
}
|
|
707
736
|
|
|
708
737
|
/** Lights are invisible in the main render pass — they only contribute
|
|
@@ -716,4 +745,18 @@ class Light extends EngineObject
|
|
|
716
745
|
{
|
|
717
746
|
lightSystem && lightSystem.drawLight(this);
|
|
718
747
|
}
|
|
748
|
+
|
|
749
|
+
/** Draw this light's glow, soft and round, its glow size across and in its color, added over the lit scene;
|
|
750
|
+
* called by LightSystemPlugin after the lightmap is applied */
|
|
751
|
+
renderGlow()
|
|
752
|
+
{
|
|
753
|
+
if (!(this.glow > 0)) return;
|
|
754
|
+
const size = vec2(this.glow);
|
|
755
|
+
if (!isOnScreen(this.pos, size)) return;
|
|
756
|
+
const texture = engineGlowTexture(this.glowFalloff);
|
|
757
|
+
if (!texture) return;
|
|
758
|
+
if (this.glowTileInfo?.textureInfo !== texture)
|
|
759
|
+
this.glowTileInfo = new TileInfo(vec2(), texture.size, texture);
|
|
760
|
+
drawTile(this.pos, size, this.glowTileInfo, this.color, 0, false, undefined, true, false);
|
|
761
|
+
}
|
|
719
762
|
}
|
package/plugins/math3d.js
CHANGED
|
@@ -338,15 +338,7 @@ class Matrix4
|
|
|
338
338
|
static rotation(euler, matrix=new Matrix4)
|
|
339
339
|
{
|
|
340
340
|
ASSERT_VECTOR3_VALID(euler);
|
|
341
|
-
|
|
342
|
-
const cy = cos(euler.y), sy = sin(euler.y);
|
|
343
|
-
const cz = cos(euler.z), sz = sin(euler.z);
|
|
344
|
-
const m = matrix.m;
|
|
345
|
-
// R = Ry * Rx * Rz written out, column major, every element set so a reused matrix comes out clean
|
|
346
|
-
m[0] = cy*cz + sy*sx*sz; m[1] = cx*sz; m[2] = -sy*cz + cy*sx*sz; m[3] = 0;
|
|
347
|
-
m[4] = -cy*sz + sy*sx*cz; m[5] = cx*cz; m[6] = sy*sz + cy*sx*cz; m[7] = 0;
|
|
348
|
-
m[8] = sy*cx; m[9] = -sx; m[10] = cy*cx; m[11] = 0;
|
|
349
|
-
m[12] = m[13] = m[14] = 0; m[15] = 1;
|
|
341
|
+
matrix4RotationAt(matrix.m, 0, euler);
|
|
350
342
|
return matrix;
|
|
351
343
|
}
|
|
352
344
|
|
|
@@ -594,24 +586,54 @@ class Matrix4
|
|
|
594
586
|
* @memberof Math3D
|
|
595
587
|
*/
|
|
596
588
|
function buildMatrix(pos, rotation, scale, matrix=new Matrix4)
|
|
589
|
+
{
|
|
590
|
+
ASSERT(matrix instanceof Matrix4, 'the matrix to write into must be a Matrix4');
|
|
591
|
+
matrix4Compose(matrix.m, 0, pos, rotation, scale);
|
|
592
|
+
return matrix;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// the rotation matrix of euler angles written into 16 floats of an array at k
|
|
596
|
+
function matrix4RotationAt(m, k, euler)
|
|
597
|
+
{
|
|
598
|
+
const cx = cos(euler.x), sx = sin(euler.x);
|
|
599
|
+
const cy = cos(euler.y), sy = sin(euler.y);
|
|
600
|
+
const cz = cos(euler.z), sz = sin(euler.z);
|
|
601
|
+
// R = Ry * Rx * Rz written out, column major, every element set so a reused matrix comes out clean
|
|
602
|
+
m[k] = cy*cz + sy*sx*sz; m[k+1] = cx*sz; m[k+2] = -sy*cz + cy*sx*sz; m[k+3] = 0;
|
|
603
|
+
m[k+4] = -cy*sz + sy*sx*cz; m[k+5] = cx*cz; m[k+6] = sy*sz + cy*sx*cz; m[k+7] = 0;
|
|
604
|
+
m[k+8] = sy*cx; m[k+9] = -sx; m[k+10] = cy*cx; m[k+11] = 0;
|
|
605
|
+
m[k+12] = m[k+13] = m[k+14] = 0; m[k+15] = 1;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// the transform buildMatrix makes, written into 16 floats of an array at k, so an instance buffer takes it straight in
|
|
609
|
+
// the rotation columns are scaled and the position dropped in, instead of multiplying three matrices, each value
|
|
610
|
+
// worked out whole and stored once; InstancedMesh3D.setTransforms writes the same expressions for many at a time
|
|
611
|
+
function matrix4Compose(m, k, pos, rotation, scale)
|
|
597
612
|
{
|
|
598
613
|
ASSERT(!pos || isVector3(pos), 'pos must be a Vector3', pos);
|
|
599
614
|
ASSERT(!scale || isVector3(scale), 'scale must be a Vector3', scale);
|
|
600
|
-
|
|
601
|
-
// scale the rotation columns and drop the position in, instead of multiplying three matrices
|
|
615
|
+
const scaleX = scale ? scale.x : 1, scaleY = scale ? scale.y : 1, scaleZ = scale ? scale.z : 1;
|
|
602
616
|
// an object that is not turned at all is most of a big scene, and identity is what the six
|
|
603
617
|
// trig calls would have worked out to anyway
|
|
604
|
-
|
|
605
|
-
turned ? Matrix4.rotation(rotation, matrix) : m.set(matrix4Identity);
|
|
606
|
-
if (scale)
|
|
618
|
+
if (rotation && (rotation.x || rotation.y || rotation.z))
|
|
607
619
|
{
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
620
|
+
ASSERT_VECTOR3_VALID(rotation);
|
|
621
|
+
const cx = cos(rotation.x), sx = sin(rotation.x);
|
|
622
|
+
const cy = cos(rotation.y), sy = sin(rotation.y);
|
|
623
|
+
const cz = cos(rotation.z), sz = sin(rotation.z);
|
|
624
|
+
// R = Ry * Rx * Rz written out, column major, as matrix4RotationAt, each column times its scale
|
|
625
|
+
m[k] = (cy*cz + sy*sx*sz) * scaleX; m[k+1] = cx*sz * scaleX; m[k+2] = (-sy*cz + cy*sx*sz) * scaleX;
|
|
626
|
+
m[k+4] = (-cy*sz + sy*sx*cz) * scaleY; m[k+5] = cx*cz * scaleY; m[k+6] = (sy*sz + cy*sx*cz) * scaleY;
|
|
627
|
+
m[k+8] = sy*cx * scaleZ; m[k+9] = -sx * scaleZ; m[k+10] = cy*cx * scaleZ;
|
|
611
628
|
}
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
629
|
+
else
|
|
630
|
+
{
|
|
631
|
+
m[k] = scaleX; m[k+1] = 0; m[k+2] = 0;
|
|
632
|
+
m[k+4] = 0; m[k+5] = scaleY; m[k+6] = 0;
|
|
633
|
+
m[k+8] = 0; m[k+9] = 0; m[k+10] = scaleZ;
|
|
634
|
+
}
|
|
635
|
+
m[k+3] = m[k+7] = m[k+11] = 0; m[k+15] = 1;
|
|
636
|
+
m[k+12] = pos ? pos.x : 0, m[k+13] = pos ? pos.y : 0, m[k+14] = pos ? pos.z : 0;
|
|
615
637
|
}
|
|
616
638
|
|
|
617
639
|
///////////////////////////////////////////////////////////////////////////////
|
|
@@ -652,36 +674,62 @@ class Ray3D
|
|
|
652
674
|
|
|
653
675
|
///////////////////////////////////////////////////////////////////////////////
|
|
654
676
|
// 3D collision helpers, none of them change anything that is passed in
|
|
655
|
-
// Boxes sit centered on pos and take a full size, like drawRect
|
|
677
|
+
// Boxes sit centered on pos and take a full size, like drawRect, upright unless given a rotation, an Euler vec3 like
|
|
678
|
+
// rotation3D, which turns them about their center
|
|
656
679
|
// Cylinders stand up the Y axis, centered on pos, with a full height
|
|
657
680
|
// Names that could be mistaken for 2D functions get a 3D suffix
|
|
658
681
|
|
|
682
|
+
// whether a rotation turns anything, a missing one does not
|
|
683
|
+
const isTurned3D = (rotation)=> !!rotation && !!(rotation.x || rotation.y || rotation.z);
|
|
684
|
+
|
|
685
|
+
// a turned box's three unit axes, the columns of its rotation
|
|
686
|
+
function boxAxes3D(rotation)
|
|
687
|
+
{
|
|
688
|
+
const m = buildMatrix(undefined, rotation).m;
|
|
689
|
+
return [vec3(m[0], m[1], m[2]), vec3(m[4], m[5], m[6]), vec3(m[8], m[9], m[10])];
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// a point in a turned box's own space, from its center, and a vector from that space back to the world's
|
|
693
|
+
function boxLocal3D(point, pos, axes)
|
|
694
|
+
{
|
|
695
|
+
const d = point.subtract(pos);
|
|
696
|
+
return vec3(d.dot(axes[0]), d.dot(axes[1]), d.dot(axes[2]));
|
|
697
|
+
}
|
|
698
|
+
const boxWorld3D = (v, axes)=> axes[0].scale(v.x).add(axes[1].scale(v.y)).add(axes[2].scale(v.z));
|
|
699
|
+
|
|
659
700
|
/**
|
|
660
|
-
* Check if a point is inside
|
|
701
|
+
* Check if a point is inside a box, boundary is inclusive
|
|
661
702
|
* @param {Vector3} point
|
|
662
703
|
* @param {Vector3} pos - Center of the box
|
|
663
704
|
* @param {Vector3} size - Full size of the box
|
|
705
|
+
* @param {Vector3} [rotation] - How the box is turned, upright when left out
|
|
664
706
|
* @return {boolean}
|
|
665
707
|
* @memberof Math3D
|
|
666
708
|
*/
|
|
667
|
-
function isPointInBox3D(point, pos, size)
|
|
709
|
+
function isPointInBox3D(point, pos, size, rotation)
|
|
668
710
|
{
|
|
711
|
+
if (isTurned3D(rotation))
|
|
712
|
+
return isPointInBox3D(boxLocal3D(point, pos, boxAxes3D(rotation)), vec3(), size);
|
|
669
713
|
return abs(point.x - pos.x) <= size.x/2 &&
|
|
670
714
|
abs(point.y - pos.y) <= size.y/2 &&
|
|
671
715
|
abs(point.z - pos.z) <= size.z/2;
|
|
672
716
|
}
|
|
673
717
|
|
|
674
718
|
/**
|
|
675
|
-
* Check if two
|
|
719
|
+
* Check if two boxes are overlapping, touching edges do not overlap
|
|
676
720
|
* @param {Vector3} posA
|
|
677
721
|
* @param {Vector3} sizeA - Full size of box A
|
|
678
722
|
* @param {Vector3} posB
|
|
679
723
|
* @param {Vector3} [sizeB] - Full size of box B, zero for a point
|
|
724
|
+
* @param {Vector3} [rotationA] - How box A is turned, upright when left out
|
|
725
|
+
* @param {Vector3} [rotationB] - How box B is turned
|
|
680
726
|
* @return {boolean}
|
|
681
727
|
* @memberof Math3D
|
|
682
728
|
*/
|
|
683
|
-
function isOverlapping3D(posA, sizeA, posB, sizeB=vec3())
|
|
729
|
+
function isOverlapping3D(posA, sizeA, posB, sizeB=vec3(), rotationA, rotationB)
|
|
684
730
|
{
|
|
731
|
+
if (isTurned3D(rotationA) || isTurned3D(rotationB))
|
|
732
|
+
return !!collideBoxBox3D(posA, sizeA, posB, sizeB, rotationA, rotationB);
|
|
685
733
|
const d = posA.subtract(posB);
|
|
686
734
|
return abs(d.x) < (sizeA.x + sizeB.x)/2 &&
|
|
687
735
|
abs(d.y) < (sizeA.y + sizeB.y)/2 &&
|
|
@@ -710,16 +758,19 @@ function collideSphereSphere(posA, radiusA, posB, radiusB)
|
|
|
710
758
|
}
|
|
711
759
|
|
|
712
760
|
/**
|
|
713
|
-
* Returns the vector to move a sphere out of
|
|
761
|
+
* Returns the vector to move a sphere out of a box, or undefined
|
|
714
762
|
* @param {Vector3} pos - Sphere center
|
|
715
763
|
* @param {number} radius
|
|
716
764
|
* @param {Vector3} boxPos
|
|
717
765
|
* @param {Vector3} boxSize - Full size of the box
|
|
766
|
+
* @param {Vector3} [boxRotation] - How the box is turned, upright when left out
|
|
718
767
|
* @return {Vector3|undefined}
|
|
719
768
|
* @memberof Math3D
|
|
720
769
|
*/
|
|
721
|
-
function collideSphereBox(pos, radius, boxPos, boxSize)
|
|
770
|
+
function collideSphereBox(pos, radius, boxPos, boxSize, boxRotation)
|
|
722
771
|
{
|
|
772
|
+
if (isTurned3D(boxRotation))
|
|
773
|
+
return collideSphereOrientedBox3D(pos, radius, boxPos, boxSize, boxAxes3D(boxRotation));
|
|
723
774
|
const h = boxSize.scale(.5);
|
|
724
775
|
const closest = vec3(
|
|
725
776
|
clamp(pos.x, boxPos.x - h.x, boxPos.x + h.x),
|
|
@@ -807,15 +858,20 @@ function collideSphereCylinder(pos, radius, cylinderPos, cylinderRadius, cylinde
|
|
|
807
858
|
/**
|
|
808
859
|
* Returns the vector to move box A by so it no longer overlaps box B, the shortest way out, or undefined
|
|
809
860
|
* - The 3D twin of collideBoxBox
|
|
861
|
+
* - Turned boxes are tested by the separating axis test, the push is along the direction they overlap least on
|
|
810
862
|
* @param {Vector3} posA
|
|
811
863
|
* @param {Vector3} sizeA - Full size of box A
|
|
812
864
|
* @param {Vector3} posB
|
|
813
865
|
* @param {Vector3} sizeB - Full size of box B
|
|
866
|
+
* @param {Vector3} [rotationA] - How box A is turned, upright when left out
|
|
867
|
+
* @param {Vector3} [rotationB] - How box B is turned
|
|
814
868
|
* @return {Vector3|undefined}
|
|
815
869
|
* @memberof Math3D
|
|
816
870
|
*/
|
|
817
|
-
function collideBoxBox3D(posA, sizeA, posB, sizeB)
|
|
871
|
+
function collideBoxBox3D(posA, sizeA, posB, sizeB, rotationA, rotationB)
|
|
818
872
|
{
|
|
873
|
+
if (isTurned3D(rotationA) || isTurned3D(rotationB))
|
|
874
|
+
return collideOrientedBoxes3D(posA, sizeA, boxAxes3D(rotationA), posB, sizeB, boxAxes3D(rotationB));
|
|
819
875
|
const d = posA.subtract(posB);
|
|
820
876
|
const overlapX = (sizeA.x + sizeB.x)/2 - abs(d.x);
|
|
821
877
|
const overlapY = (sizeA.y + sizeB.y)/2 - abs(d.y);
|
|
@@ -825,6 +881,44 @@ function collideBoxBox3D(posA, sizeA, posB, sizeB)
|
|
|
825
881
|
return pushOutAxis3D(d, overlapX, overlapY, overlapZ);
|
|
826
882
|
}
|
|
827
883
|
|
|
884
|
+
// 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
|
|
885
|
+
// goes back out turned
|
|
886
|
+
function collideSphereOrientedBox3D(pos, radius, boxPos, boxSize, axes)
|
|
887
|
+
{
|
|
888
|
+
const push = collideSphereBox(boxLocal3D(pos, boxPos, axes), radius, vec3(), boxSize);
|
|
889
|
+
return push && boxWorld3D(push, axes);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// the world's axes, for an upright box among turned ones
|
|
893
|
+
const BOX_WORLD_AXES = Object.freeze([vec3(1, 0, 0), vec3(0, 1, 0), vec3(0, 0, 1)]);
|
|
894
|
+
|
|
895
|
+
// the shortest push to move box A out of box B by the separating axis test: each box's three face directions and
|
|
896
|
+
// 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
|
|
897
|
+
// B to A; an edge direction has to overlap clearly less to win, so a box lying on another leaves by the face
|
|
898
|
+
function collideOrientedBoxes3D(posA, sizeA, axesA, posB, sizeB, axesB)
|
|
899
|
+
{
|
|
900
|
+
const d = posA.subtract(posB), ha = sizeA.scale(.5), hb = sizeB.scale(.5);
|
|
901
|
+
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));
|
|
902
|
+
let push, least = Infinity;
|
|
903
|
+
const separated = (axis, weight)=>
|
|
904
|
+
{
|
|
905
|
+
const lengthSquared = axis.lengthSquared();
|
|
906
|
+
if (lengthSquared < 1e-12) return false; // parallel edges give no direction to test
|
|
907
|
+
const n = axis.scale(lengthSquared ** -.5), along = d.dot(n);
|
|
908
|
+
const overlap = reach(ha, axesA, n) + reach(hb, axesB, n) - abs(along);
|
|
909
|
+
if (overlap <= 0) return true;
|
|
910
|
+
if (overlap * weight < least)
|
|
911
|
+
least = overlap * weight, push = n.scale(along < 0 ? -overlap : overlap);
|
|
912
|
+
return false;
|
|
913
|
+
};
|
|
914
|
+
for (const axis of [...axesA, ...axesB])
|
|
915
|
+
if (separated(axis, 1)) return;
|
|
916
|
+
for (const a of axesA)
|
|
917
|
+
for (const b of axesB)
|
|
918
|
+
if (separated(a.cross(b), 1.05)) return;
|
|
919
|
+
return push;
|
|
920
|
+
}
|
|
921
|
+
|
|
828
922
|
/**
|
|
829
923
|
* Returns the distance along the ray to the first intersection with a sphere, or undefined
|
|
830
924
|
* - The hit is ray.getPosition(distance), a direction that is not unit length scales the distance
|
|
@@ -873,17 +967,25 @@ function raycastPlane(ray, planePos, planeNormal)
|
|
|
873
967
|
}
|
|
874
968
|
|
|
875
969
|
/**
|
|
876
|
-
* Returns the distance along the ray to the first intersection with
|
|
970
|
+
* Returns the distance along the ray to the first intersection with a box, or undefined
|
|
877
971
|
* - The hit is ray.getPosition(distance), a direction that is not unit length scales the distance
|
|
878
972
|
* - A ray starting inside the box is already there, so it gets back 0
|
|
879
973
|
* @param {Ray3D} ray
|
|
880
974
|
* @param {Vector3} pos - Center of the box
|
|
881
975
|
* @param {Vector3} size - Full size of the box
|
|
976
|
+
* @param {Vector3} [rotation] - How the box is turned, upright when left out
|
|
882
977
|
* @return {number|undefined}
|
|
883
978
|
* @memberof Math3D
|
|
884
979
|
*/
|
|
885
|
-
function raycastBox(ray, pos, size)
|
|
980
|
+
function raycastBox(ray, pos, size, rotation)
|
|
886
981
|
{
|
|
982
|
+
if (isTurned3D(rotation))
|
|
983
|
+
{
|
|
984
|
+
// the ray in the box's own space, where the box is upright; turning keeps the distances
|
|
985
|
+
const axes = boxAxes3D(rotation), d = ray.direction;
|
|
986
|
+
const direction = vec3(d.dot(axes[0]), d.dot(axes[1]), d.dot(axes[2]));
|
|
987
|
+
return raycastBox(new Ray3D(boxLocal3D(ray.origin, pos, axes), direction), vec3(), size);
|
|
988
|
+
}
|
|
887
989
|
const {origin, direction} = ray;
|
|
888
990
|
const h = size.scale(.5);
|
|
889
991
|
const boxMin = pos.subtract(h), boxMax = pos.add(h);
|