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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "littlejsengine",
3
- "version": "1.20.0",
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.map(p=> p.textureInfo)))
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
- return parseGLTF(await response.arrayBuffer(), url.slice(0, url.lastIndexOf('/') + 1));
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; only the base color
400
- // textures are drawn with, so the normal, roughness and other maps are not loaded at all
401
- const baseColorTextures = new Set((json.materials || []).map(m=> m.pbrMetallicRoughness?.baseColorTexture?.index));
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' || !baseColorTextures.has(index)) return;
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
- const bitmap = opaque ? await createImageBitmap(blob, {premultiplyAlpha: 'none'}).then(gltfOpaqueImage) : await createImageBitmap(blob);
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.map(p=> p.textureInfo));
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)
@@ -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
- const cx = cos(euler.x), sx = sin(euler.x);
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
- ASSERT(matrix instanceof Matrix4, 'the matrix to write into must be a Matrix4');
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
- const turned = rotation && (rotation.x || rotation.y || rotation.z), m = matrix.m;
605
- turned ? Matrix4.rotation(rotation, matrix) : m.set(matrix4Identity);
606
- if (scale)
618
+ if (rotation && (rotation.x || rotation.y || rotation.z))
607
619
  {
608
- m[0] *= scale.x; m[1] *= scale.x; m[2] *= scale.x;
609
- m[4] *= scale.y; m[5] *= scale.y; m[6] *= scale.y;
610
- m[8] *= scale.z; m[9] *= scale.z; m[10] *= scale.z;
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
- if (pos)
613
- m[12] = pos.x, m[13] = pos.y, m[14] = pos.z;
614
- return matrix;
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 an axis aligned box, boundary is inclusive
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 axis aligned boxes are overlapping, touching edges do not overlap
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 an axis aligned box, or undefined
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 an axis aligned box, or undefined
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);