littlejsengine 1.18.29 → 1.19.3

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.
@@ -0,0 +1,870 @@
1
+ /**
2
+ * LittleJS 3D Math Plugin
3
+ * - Vector3 and Matrix4 for 3D games and plugins
4
+ * - Right handed, Y up, angles in radians
5
+ * - Used by the Render3D plugin, but has no rendering dependencies
6
+ * @namespace Math3D
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ ///////////////////////////////////////////////////////////////////////////////
12
+
13
+ /**
14
+ * Create a 3D vector, can take 0, 1, 2 or 3 numbers
15
+ * - vec3() is zero, vec3(s) fills all three, vec3(x, y) sets z to 0
16
+ * @param {number} [x]
17
+ * @param {number} [y]
18
+ * @param {number} [z]
19
+ * @return {Vector3}
20
+ * @memberof Math3D
21
+ */
22
+ function vec3(x=0, y, z)
23
+ {
24
+ return y === undefined ? new Vector3(x, x, x) : new Vector3(x, y, z === undefined ? 0 : z);
25
+ }
26
+
27
+ /**
28
+ * Check if the object is a valid Vector3
29
+ * @param {any} v
30
+ * @return {boolean}
31
+ * @memberof Math3D
32
+ */
33
+ function isVector3(v) { return v instanceof Vector3 && v.isValid(); }
34
+
35
+ // debug check that a value is a usable Vector3, stripped in release like the 2D one
36
+ function ASSERT_VECTOR3_VALID(v) { ASSERT(isVector3(v), 'Vector3 is invalid.', v); }
37
+
38
+ /**
39
+ * Returns a random Vector3 of a given length, pointing any direction evenly, or within a cone around +Y
40
+ * @param {number} [length]
41
+ * @param {number} [coneAngle] - Half angle of the cone around +Y in radians, PI is every direction
42
+ * @return {Vector3}
43
+ * @memberof Math3D
44
+ */
45
+ function randVector3(length=1, coneAngle=PI)
46
+ {
47
+ // a random height on the sphere is uniform over its surface, then a random turn around Y
48
+ const y = rand(cos(coneAngle), 1), s = (1 - y * y) ** .5, a = rand(2 * PI);
49
+ return new Vector3(s * cos(a) * length, y * length, s * sin(a) * length);
50
+ }
51
+
52
+ /**
53
+ * Returns a random Vector3 inside a sphere, spread evenly through its volume, the 3D twin of randInCircle
54
+ * @param {number} [radius]
55
+ * @param {number} [minRadius] - Leave a hollow middle this big
56
+ * @return {Vector3}
57
+ * @memberof Math3D
58
+ */
59
+ function randInSphere(radius=1, minRadius=0)
60
+ {
61
+ // the volume inside a radius grows with its cube, so that is what has to come out even
62
+ if (radius <= 0) return new Vector3;
63
+ const ratio = clamp(minRadius / radius);
64
+ return randVector3(radius * rand(ratio**3, 1) ** (1/3));
65
+ }
66
+
67
+ /**
68
+ * 3D Vector object, right handed with Y up
69
+ * - Methods return new vectors except set and setFrom
70
+ * @memberof Math3D
71
+ * @example
72
+ * const a = vec3(1, 2, 3);
73
+ * const b = a.add(vec3(0, 1, 0)).normalize();
74
+ */
75
+ class Vector3
76
+ {
77
+ /** Create a 3D vector
78
+ * @param {number} [x]
79
+ * @param {number} [y]
80
+ * @param {number} [z] */
81
+ constructor(x=0, y=0, z=0)
82
+ {
83
+ ASSERT(isNumber(x) && isNumber(y) && isNumber(z), 'Vector3 components must be numbers');
84
+ /** @property {number} - X axis location */
85
+ this.x = x;
86
+ /** @property {number} - Y axis location */
87
+ this.y = y;
88
+ /** @property {number} - Z axis location */
89
+ this.z = z;
90
+ }
91
+
92
+ /** Sets values of this vector and returns self
93
+ * @param {number} [x]
94
+ * @param {number} [y]
95
+ * @param {number} [z]
96
+ * @return {Vector3} */
97
+ set(x=0, y=0, z=0) { this.x = x; this.y = y; this.z = z; ASSERT_VECTOR3_VALID(this); return this; }
98
+
99
+ /** Copies the values of another vector into this one and returns self
100
+ * @param {Vector3} v
101
+ * @return {Vector3} */
102
+ setFrom(v) { return this.set(v.x, v.y, v.z); }
103
+
104
+ /** Returns a new vector that is a copy of this
105
+ * @return {Vector3} */
106
+ copy() { return new Vector3(this.x, this.y, this.z); }
107
+
108
+ /** Returns a copy of this vector plus the vector passed in
109
+ * @param {Vector3} v
110
+ * @return {Vector3} */
111
+ add(v) { return new Vector3(this.x + v.x, this.y + v.y, this.z + v.z); }
112
+
113
+ /** Returns a copy of this vector minus the vector passed in
114
+ * @param {Vector3} v
115
+ * @return {Vector3} */
116
+ subtract(v) { return new Vector3(this.x - v.x, this.y - v.y, this.z - v.z); }
117
+
118
+ /** Returns a copy of this vector times the vector passed in
119
+ * @param {Vector3} v
120
+ * @return {Vector3} */
121
+ multiply(v) { return new Vector3(this.x * v.x, this.y * v.y, this.z * v.z); }
122
+
123
+ /** Returns a copy of this vector divided by the vector passed in
124
+ * @param {Vector3} v
125
+ * @return {Vector3} */
126
+ divide(v) { return new Vector3(this.x / v.x, this.y / v.y, this.z / v.z); }
127
+
128
+ /** Returns a copy of this vector scaled by the number passed in
129
+ * @param {number} s
130
+ * @return {Vector3} */
131
+ scale(s) { return new Vector3(this.x * s, this.y * s, this.z * s); }
132
+
133
+ /** Returns the length of this vector
134
+ * @return {number} */
135
+ length() { return this.lengthSquared()**.5; }
136
+
137
+ /** Returns the length of this vector squared
138
+ * @return {number} */
139
+ lengthSquared() { return this.x**2 + this.y**2 + this.z**2; }
140
+
141
+ /** Returns a copy of this vector reflected by a surface normal
142
+ * @param {Vector3} normal - Surface normal, should be normalized
143
+ * @param {number} [restitution] - How much to bounce, 1 is a perfect bounce, 0 slides along the surface
144
+ * @return {Vector3} */
145
+ reflect(normal, restitution=1) { return this.subtract(normal.scale((1 + restitution) * this.dot(normal))); }
146
+
147
+ /** Returns the distance from this vector to the vector passed in
148
+ * @param {Vector3} v
149
+ * @return {number} */
150
+ distance(v) { return this.distanceSquared(v)**.5; }
151
+
152
+ /** Returns the distance squared from this vector to the vector passed in
153
+ * @param {Vector3} v
154
+ * @return {number} */
155
+ distanceSquared(v) { return (this.x - v.x)**2 + (this.y - v.y)**2 + (this.z - v.z)**2; }
156
+
157
+ /** Returns a new vector in the same direction with the length passed in, zero stays zero
158
+ * @param {number} [length]
159
+ * @return {Vector3} */
160
+ normalize(length=1)
161
+ {
162
+ const l = this.length();
163
+ return l ? this.scale(length/l) : new Vector3;
164
+ }
165
+
166
+ /** Returns a new vector clamped to the length passed in
167
+ * @param {number} [length]
168
+ * @return {Vector3} */
169
+ clampLength(length=1)
170
+ {
171
+ const l = this.length();
172
+ return l > length ? this.scale(length/l) : this.copy();
173
+ }
174
+
175
+ /** Returns the dot product of this vector and the vector passed in
176
+ * @param {Vector3} v
177
+ * @return {number} */
178
+ dot(v) { return this.x*v.x + this.y*v.y + this.z*v.z; }
179
+
180
+ /** Returns a vector at right angles to both this and the one passed in
181
+ * @param {Vector3} v
182
+ * @return {Vector3} */
183
+ cross(v)
184
+ {
185
+ return new Vector3(
186
+ this.y*v.z - this.z*v.y,
187
+ this.z*v.x - this.x*v.z,
188
+ this.x*v.y - this.y*v.x);
189
+ }
190
+
191
+ /** Returns a new vector interpolated between this and the vector passed in, percent is clamped to 0-1
192
+ * @param {Vector3} v
193
+ * @param {number} percent
194
+ * @return {Vector3} */
195
+ lerp(v, percent)
196
+ {
197
+ ASSERT_VECTOR3_VALID(v);
198
+ return this.add(v.subtract(this).scale(clamp(percent)));
199
+ }
200
+
201
+ /** Returns a new vector turned around an axis, counter clockwise when the axis points at you
202
+ * @param {Vector3} axis - Unit length
203
+ * @param {number} angle - Radians
204
+ * @return {Vector3} */
205
+ rotate(axis, angle)
206
+ {
207
+ ASSERT_VECTOR3_VALID(axis); // unlike Vector2.rotate this takes an axis first
208
+ // Rodrigues' formula: the part along the axis stays, the rest turns
209
+ const c = cos(angle), s = sin(angle), d = axis.dot(this) * (1 - c);
210
+ return this.scale(c).add(axis.cross(this).scale(s)).add(axis.scale(d));
211
+ }
212
+
213
+ /** Returns a new vector turned around the X axis, the way a positive pitch in rotation3D turns things
214
+ * @param {number} angle - Radians
215
+ * @return {Vector3} */
216
+ rotateX(angle)
217
+ {
218
+ const c = cos(angle), s = sin(angle);
219
+ return new Vector3(this.x, this.y*c - this.z*s, this.y*s + this.z*c);
220
+ }
221
+
222
+ /** Returns a new vector turned around the Y axis, the way a positive yaw in rotation3D turns things
223
+ * @param {number} angle - Radians
224
+ * @return {Vector3} */
225
+ rotateY(angle)
226
+ {
227
+ const c = cos(angle), s = sin(angle);
228
+ return new Vector3(this.x*c + this.z*s, this.y, this.z*c - this.x*s);
229
+ }
230
+
231
+ /** Returns a new vector turned around the Z axis, the way a positive roll in rotation3D turns things
232
+ * @param {number} angle - Radians
233
+ * @return {Vector3} */
234
+ rotateZ(angle)
235
+ {
236
+ const c = cos(angle), s = sin(angle);
237
+ return new Vector3(this.x*c - this.y*s, this.x*s + this.y*c, this.z);
238
+ }
239
+
240
+ /** Returns a new vector with the absolute value of each component
241
+ * @return {Vector3} */
242
+ abs() { return new Vector3(abs(this.x), abs(this.y), abs(this.z)); }
243
+
244
+ /** Returns a new vector with each component floored
245
+ * @return {Vector3} */
246
+ floor() { return new Vector3(floor(this.x), floor(this.y), floor(this.z)); }
247
+
248
+ /** Returns a new vector with each component rounded
249
+ * @return {Vector3} */
250
+ round() { return new Vector3(round(this.x), round(this.y), round(this.z)); }
251
+
252
+ /** Returns a new vector snapped down to a grid, grid is the number of steps per unit like Vector2.snap
253
+ * @param {number} grid - Snap steps per unit, 2 snaps to halves
254
+ * @return {Vector3} */
255
+ snap(grid)
256
+ {
257
+ ASSERT_NUMBER_VALID(grid);
258
+ return new Vector3(floor(this.x*grid)/grid, floor(this.y*grid)/grid, floor(this.z*grid)/grid);
259
+ }
260
+
261
+ /** Returns this point transformed by a matrix, translation included
262
+ * @param {Matrix4} matrix
263
+ * @return {Vector3} */
264
+ transform(matrix) { return matrix.transformPoint(this); }
265
+
266
+ /** Returns this direction transformed by a matrix, rotation and scale only
267
+ * @param {Matrix4} matrix
268
+ * @return {Vector3} */
269
+ transformDirection(matrix) { return matrix.transformDirection(this); }
270
+
271
+ /** Checks if this is a valid vector
272
+ * @return {boolean} */
273
+ isValid() { return isNumber(this.x) && isNumber(this.y) && isNumber(this.z); }
274
+
275
+ /** Returns a string representation of this vector for debugging
276
+ * @param {number} [digits] - Number of digits to display
277
+ * @return {string} */
278
+ toString(digits=3)
279
+ {
280
+ if (!this.isValid())
281
+ return `(${this.x},${this.y},${this.z})`; // show the bad values instead of throwing
282
+ const f = (v)=> (v < 0 ? '' : ' ') + v.toFixed(digits);
283
+ return `(${f(this.x)},${f(this.y)},${f(this.z)} )`;
284
+ }
285
+ }
286
+
287
+ ///////////////////////////////////////////////////////////////////////////////
288
+
289
+ // scratch for multiply, nothing keeps a reference to it
290
+ const matrix4Scratch = new Float32Array(16);
291
+
292
+ /**
293
+ * 4x4 transform matrix for moving, rotating and scaling points in 3D
294
+ * - Static builders like Matrix4.translation return a new matrix
295
+ * - Methods on a matrix change it in place and return it, so calls can chain
296
+ * - a.multiply(b) means b happens first, then a
297
+ * - Stored the way WebGL wants it, so it can be sent to a shader as is
298
+ * @memberof Math3D
299
+ * @example
300
+ * const m = buildMatrix(vec3(0, 1, 0), vec3(0, PI/2, 0)); // rotate then move up
301
+ * const p = m.transformPoint(vec3(1, 0, 0));
302
+ */
303
+ class Matrix4
304
+ {
305
+ /** Create a matrix, identity by default
306
+ * @param {Float32Array|Array<number>} [m] - 16 column major values */
307
+ constructor(m)
308
+ {
309
+ /** @property {Float32Array} - The 16 column major values */
310
+ this.m = new Float32Array(16);
311
+ ASSERT(!m || m.length == 16, 'Matrix4 takes 16 values, use copy() to duplicate a matrix');
312
+ if (m)
313
+ this.m.set(m);
314
+ else
315
+ this.m[0] = this.m[5] = this.m[10] = this.m[15] = 1;
316
+ }
317
+
318
+ /** Returns a new identity matrix
319
+ * @return {Matrix4} */
320
+ static identity() { return new Matrix4; }
321
+
322
+ /** Returns a new translation matrix
323
+ * @param {Vector3} v
324
+ * @return {Matrix4} */
325
+ static translation(v)
326
+ {
327
+ ASSERT_VECTOR3_VALID(v);
328
+ const r = new Matrix4;
329
+ r.m[12] = v.x; r.m[13] = v.y; r.m[14] = v.z;
330
+ return r;
331
+ }
332
+
333
+ /** Returns a new rotation matrix, rolled first, then pitched, then yawed
334
+ * @param {Vector3} euler - vec3(pitch, yaw, roll) in radians
335
+ * @return {Matrix4} */
336
+ static rotation(euler)
337
+ {
338
+ ASSERT_VECTOR3_VALID(euler);
339
+ const cx = cos(euler.x), sx = sin(euler.x);
340
+ const cy = cos(euler.y), sy = sin(euler.y);
341
+ const cz = cos(euler.z), sz = sin(euler.z);
342
+ const r = new Matrix4;
343
+ const m = r.m;
344
+ // R = Ry * Rx * Rz written out, column major
345
+ m[0] = cy*cz + sy*sx*sz; m[1] = cx*sz; m[2] = -sy*cz + cy*sx*sz;
346
+ m[4] = -cy*sz + sy*sx*cz; m[5] = cx*cz; m[6] = sy*sz + cy*sx*cz;
347
+ m[8] = sy*cx; m[9] = -sx; m[10] = cy*cx;
348
+ return r;
349
+ }
350
+
351
+ /** Returns a new scale matrix
352
+ * @param {Vector3} v
353
+ * @return {Matrix4} */
354
+ static scaling(v)
355
+ {
356
+ ASSERT_VECTOR3_VALID(v);
357
+ const r = new Matrix4;
358
+ r.m[0] = v.x; r.m[5] = v.y; r.m[10] = v.z;
359
+ return r;
360
+ }
361
+
362
+ /** Returns a new perspective projection, camera looks down -Z
363
+ * @param {number} fov - Vertical field of view in radians
364
+ * @param {number} aspect - Width divided by height
365
+ * @param {number} near - Closest visible distance
366
+ * @param {number} far - Furthest visible distance, Infinity is allowed
367
+ * @return {Matrix4} */
368
+ static perspective(fov, aspect, near, far)
369
+ {
370
+ ASSERT(near > 0 && far > near, 'a perspective projection needs 0 < near < far, or nothing is visible', near, far);
371
+ const f = 1 / tan(fov/2);
372
+ const r = new Matrix4;
373
+ const m = r.m;
374
+ m[0] = f / aspect;
375
+ m[5] = f;
376
+ m[10] = far == Infinity ? -1 : (far + near) / (near - far); // the infinite case is the limit of the formula
377
+ m[11] = -1;
378
+ m[14] = far == Infinity ? -2 * near : 2 * far * near / (near - far);
379
+ m[15] = 0;
380
+ return r;
381
+ }
382
+
383
+ /** Returns a new orthographic projection, camera looks down -Z
384
+ * @param {number} left - Edge of the visible box
385
+ * @param {number} right - Edge of the visible box
386
+ * @param {number} bottom - Edge of the visible box
387
+ * @param {number} top - Edge of the visible box
388
+ * @param {number} near - Closest visible distance
389
+ * @param {number} far - Furthest visible distance, Infinity is not allowed here
390
+ * @return {Matrix4} */
391
+ static orthographic(left, right, bottom, top, near, far)
392
+ {
393
+ // an infinite far plane has no orthographic form: every depth would land on the near plane,
394
+ // and the formula below works out to NaN, which quietly clips the whole scene away
395
+ ASSERT(far > near && far != Infinity, 'an orthographic projection needs a real far plane past near, Infinity is perspective only', near, far);
396
+ const r = new Matrix4;
397
+ const m = r.m;
398
+ m[0] = 2 / (right - left);
399
+ m[5] = 2 / (top - bottom);
400
+ m[10] = -2 / (far - near);
401
+ m[12] = -(right + left) / (right - left);
402
+ m[13] = -(top + bottom) / (top - bottom);
403
+ m[14] = -(far + near) / (far - near);
404
+ return r;
405
+ }
406
+
407
+ /** Returns the transform of something at eye turned to face target
408
+ * - Invert it to get a view matrix for a camera there
409
+ * @param {Vector3} eye
410
+ * @param {Vector3} target
411
+ * @param {Vector3} [up]
412
+ * @return {Matrix4} */
413
+ static lookAt(eye, target, up=vec3(0, 1, 0))
414
+ {
415
+ let z = eye.subtract(target).normalize();
416
+ if (!z.lengthSquared())
417
+ z = vec3(0, 0, 1); // eye is on the target, face -Z
418
+ let x = up.cross(z).normalize();
419
+ if (!x.lengthSquared()) // up is along the view direction, pick another
420
+ x = (abs(z.y) > .99 ? vec3(0, 0, 1) : vec3(0, 1, 0)).cross(z).normalize();
421
+ const y = z.cross(x);
422
+ return new Matrix4([x.x, x.y, x.z, 0, y.x, y.y, y.z, 0, z.x, z.y, z.z, 0, eye.x, eye.y, eye.z, 1]);
423
+ }
424
+
425
+ /** Returns a new matrix that is a copy of this
426
+ * @return {Matrix4} */
427
+ copy() { return new Matrix4(this.m); }
428
+
429
+ /** Multiply this matrix by another and return this, the other happens first
430
+ * @param {Matrix4} matrix
431
+ * @return {Matrix4} */
432
+ multiply(matrix)
433
+ {
434
+ const a = this.m, b = matrix.m, r = matrix4Scratch;
435
+ for (let j = 0; j < 4; ++j)
436
+ for (let i = 0; i < 4; ++i)
437
+ r[j*4 + i] = a[i]*b[j*4] + a[4 + i]*b[j*4 + 1] + a[8 + i]*b[j*4 + 2] + a[12 + i]*b[j*4 + 3];
438
+ this.m.set(r);
439
+ return this;
440
+ }
441
+
442
+ /** Append a translation, returns self
443
+ * @param {Vector3} v
444
+ * @return {Matrix4} */
445
+ translate(v) { return this.multiply(Matrix4.translation(v)); }
446
+
447
+ /** Append a rotation, returns self
448
+ * @param {Vector3} euler - vec3(pitch, yaw, roll) in radians
449
+ * @return {Matrix4} */
450
+ rotate(euler) { return this.multiply(Matrix4.rotation(euler)); }
451
+
452
+ /** Append a scale, returns self
453
+ * @param {Vector3} v
454
+ * @return {Matrix4} */
455
+ scale(v) { return this.multiply(Matrix4.scaling(v)); }
456
+
457
+ /** Transpose this matrix in place, returns self
458
+ * @return {Matrix4} */
459
+ transpose()
460
+ {
461
+ const m = this.m;
462
+ for (let i = 0; i < 4; ++i)
463
+ for (let j = i + 1; j < 4; ++j)
464
+ {
465
+ const t = m[i*4 + j];
466
+ m[i*4 + j] = m[j*4 + i];
467
+ m[j*4 + i] = t;
468
+ }
469
+ return this;
470
+ }
471
+
472
+ /** Flip this matrix so it undoes itself, returns this and does nothing if it cannot be inverted
473
+ * @return {Matrix4} */
474
+ invert()
475
+ {
476
+ const m = this.m;
477
+ const [a00, a01, a02, a03, a10, a11, a12, a13, a20, a21, a22, a23, a30, a31, a32, a33] = m;
478
+ const b00 = a00*a11 - a01*a10, b01 = a00*a12 - a02*a10, b02 = a00*a13 - a03*a10;
479
+ const b03 = a01*a12 - a02*a11, b04 = a01*a13 - a03*a11, b05 = a02*a13 - a03*a12;
480
+ const b06 = a20*a31 - a21*a30, b07 = a20*a32 - a22*a30, b08 = a20*a33 - a23*a30;
481
+ const b09 = a21*a32 - a22*a31, b10 = a21*a33 - a23*a31, b11 = a22*a33 - a23*a32;
482
+ let det = b00*b11 - b01*b10 + b02*b09 + b03*b08 - b04*b07 + b05*b06;
483
+ if (!det)
484
+ return this;
485
+ det = 1 / det;
486
+ m[0] = (a11*b11 - a12*b10 + a13*b09) * det;
487
+ m[1] = (a02*b10 - a01*b11 - a03*b09) * det;
488
+ m[2] = (a31*b05 - a32*b04 + a33*b03) * det;
489
+ m[3] = (a22*b04 - a21*b05 - a23*b03) * det;
490
+ m[4] = (a12*b08 - a10*b11 - a13*b07) * det;
491
+ m[5] = (a00*b11 - a02*b08 + a03*b07) * det;
492
+ m[6] = (a32*b02 - a30*b05 - a33*b01) * det;
493
+ m[7] = (a20*b05 - a22*b02 + a23*b01) * det;
494
+ m[8] = (a10*b10 - a11*b08 + a13*b06) * det;
495
+ m[9] = (a01*b08 - a00*b10 - a03*b06) * det;
496
+ m[10] = (a30*b04 - a31*b02 + a33*b00) * det;
497
+ m[11] = (a21*b02 - a20*b04 - a23*b00) * det;
498
+ m[12] = (a11*b07 - a10*b09 - a12*b06) * det;
499
+ m[13] = (a00*b09 - a01*b07 + a02*b06) * det;
500
+ m[14] = (a31*b01 - a30*b03 - a32*b00) * det;
501
+ m[15] = (a20*b03 - a21*b01 + a22*b00) * det;
502
+ return this;
503
+ }
504
+
505
+ /** Transform a point, translation included
506
+ * @param {Vector3} v
507
+ * @return {Vector3} */
508
+ transformPoint(v)
509
+ {
510
+ const m = this.m;
511
+ return new Vector3(
512
+ m[0]*v.x + m[4]*v.y + m[8]*v.z + m[12],
513
+ m[1]*v.x + m[5]*v.y + m[9]*v.z + m[13],
514
+ m[2]*v.x + m[6]*v.y + m[10]*v.z + m[14]);
515
+ }
516
+
517
+ /** Transform a direction, rotation and scale only
518
+ * @param {Vector3} v
519
+ * @return {Vector3} */
520
+ transformDirection(v)
521
+ {
522
+ const m = this.m;
523
+ return new Vector3(
524
+ m[0]*v.x + m[4]*v.y + m[8]*v.z,
525
+ m[1]*v.x + m[5]*v.y + m[9]*v.z,
526
+ m[2]*v.x + m[6]*v.y + m[10]*v.z);
527
+ }
528
+
529
+ /** Returns the translation part of this matrix
530
+ * @return {Vector3} */
531
+ getTranslation() { return new Vector3(this.m[12], this.m[13], this.m[14]); }
532
+
533
+ /** Returns a string representation of this matrix for debugging
534
+ * @return {string} */
535
+ toString()
536
+ {
537
+ const m = this.m, f = (i)=> m[i].toFixed(2).padStart(7);
538
+ let s = '';
539
+ for (let row = 0; row < 4; ++row)
540
+ s += `[${f(row)} ${f(4 + row)} ${f(8 + row)} ${f(12 + row)} ]\n`;
541
+ return s;
542
+ }
543
+ }
544
+
545
+ ///////////////////////////////////////////////////////////////////////////////
546
+
547
+ /**
548
+ * Build a transform for an object from its position, rotation and scale
549
+ * - A point is scaled first, then rotated, then moved, which is what you want for a game object
550
+ * @param {Vector3} [pos]
551
+ * @param {Vector3} [rotation] - vec3(pitch, yaw, roll) in radians
552
+ * @param {Vector3} [scale]
553
+ * @return {Matrix4}
554
+ * @memberof Math3D
555
+ */
556
+ function buildMatrix(pos, rotation, scale)
557
+ {
558
+ ASSERT(!pos || isVector3(pos), 'pos must be a Vector3', pos);
559
+ ASSERT(!scale || isVector3(scale), 'scale must be a Vector3', scale);
560
+ // scale the rotation columns and drop the position in, instead of multiplying three matrices
561
+ // an object that is not turned at all is most of a big scene, and identity is what the six
562
+ // trig calls would have worked out to anyway
563
+ const turned = rotation && (rotation.x || rotation.y || rotation.z);
564
+ const matrix = turned ? Matrix4.rotation(rotation) : new Matrix4, m = matrix.m;
565
+ if (scale)
566
+ {
567
+ m[0] *= scale.x; m[1] *= scale.x; m[2] *= scale.x;
568
+ m[4] *= scale.y; m[5] *= scale.y; m[6] *= scale.y;
569
+ m[8] *= scale.z; m[9] *= scale.z; m[10] *= scale.z;
570
+ }
571
+ if (pos)
572
+ m[12] = pos.x, m[13] = pos.y, m[14] = pos.z;
573
+ return matrix;
574
+ }
575
+
576
+ ///////////////////////////////////////////////////////////////////////////////
577
+ /**
578
+ * Ray3D - A start point and a direction, what screenToRay returns and the raycast helpers take
579
+ * - The direction need not be unit length, the distances that come back are in units of it
580
+ * @memberof Math3D
581
+ * @example
582
+ * const ray = render3D.screenToRay(mousePosScreen);
583
+ * const distance = raycastPlane(ray, vec3(), vec3(0, 1, 0));
584
+ * if (distance !== undefined)
585
+ * ball.pos3D = ray.getPosition(distance);
586
+ */
587
+ class Ray3D
588
+ {
589
+ /** Create a ray
590
+ * @param {Vector3} [origin]
591
+ * @param {Vector3} [direction] - Defaults to -Z, forward */
592
+ constructor(origin=vec3(), direction=vec3(0, 0, -1))
593
+ {
594
+ ASSERT_VECTOR3_VALID(origin);
595
+ ASSERT_VECTOR3_VALID(direction);
596
+ /** @property {Vector3} - Where the ray starts */
597
+ this.origin = origin;
598
+ /** @property {Vector3} - Which way it goes */
599
+ this.direction = direction;
600
+ }
601
+
602
+ /** Returns the point a distance along the ray
603
+ * @param {number} distance - What the raycast helpers return
604
+ * @return {Vector3} */
605
+ getPosition(distance) { return this.origin.add(this.direction.scale(distance)); }
606
+
607
+ /** Returns a new ray that is a copy of this
608
+ * @return {Ray3D} */
609
+ copy() { return new Ray3D(this.origin.copy(), this.direction.copy()); }
610
+ }
611
+
612
+ ///////////////////////////////////////////////////////////////////////////////
613
+ // 3D collision helpers, none of them change anything that is passed in
614
+ // Boxes sit centered on pos and take a full size, like drawRect
615
+ // Cylinders stand up the Y axis, centered on pos, with a full height
616
+ // Names that could be mistaken for 2D functions get a 3D suffix
617
+
618
+ /**
619
+ * Check if a point is inside an axis aligned box, boundary is inclusive
620
+ * @param {Vector3} point
621
+ * @param {Vector3} pos - Center of the box
622
+ * @param {Vector3} size - Full size of the box
623
+ * @return {boolean}
624
+ * @memberof Math3D
625
+ */
626
+ function isPointInBox3D(point, pos, size)
627
+ {
628
+ return abs(point.x - pos.x) <= size.x/2 &&
629
+ abs(point.y - pos.y) <= size.y/2 &&
630
+ abs(point.z - pos.z) <= size.z/2;
631
+ }
632
+
633
+ /**
634
+ * Check if two axis aligned boxes are overlapping, touching edges do not overlap
635
+ * @param {Vector3} posA
636
+ * @param {Vector3} sizeA - Full size of box A
637
+ * @param {Vector3} posB
638
+ * @param {Vector3} [sizeB] - Full size of box B, zero for a point
639
+ * @return {boolean}
640
+ * @memberof Math3D
641
+ */
642
+ function isOverlapping3D(posA, sizeA, posB, sizeB=vec3())
643
+ {
644
+ const d = posA.subtract(posB);
645
+ return abs(d.x) < (sizeA.x + sizeB.x)/2 &&
646
+ abs(d.y) < (sizeA.y + sizeB.y)/2 &&
647
+ abs(d.z) < (sizeA.z + sizeB.z)/2;
648
+ }
649
+
650
+ /**
651
+ * Returns the vector to move sphere A by so it no longer overlaps sphere B, or undefined
652
+ * @param {Vector3} posA
653
+ * @param {number} radiusA
654
+ * @param {Vector3} posB
655
+ * @param {number} radiusB
656
+ * @return {Vector3|undefined}
657
+ * @memberof Math3D
658
+ */
659
+ function collideSphereSphere(posA, radiusA, posB, radiusB)
660
+ {
661
+ const d = posA.subtract(posB);
662
+ const r = radiusA + radiusB;
663
+ const dist = d.length();
664
+ if (dist >= r)
665
+ return undefined;
666
+ if (!dist)
667
+ return vec3(0, r, 0); // coincident centers, push straight up
668
+ return d.normalize(r - dist);
669
+ }
670
+
671
+ /**
672
+ * Returns the vector to move a sphere out of an axis aligned box, or undefined
673
+ * @param {Vector3} pos - Sphere center
674
+ * @param {number} radius
675
+ * @param {Vector3} boxPos
676
+ * @param {Vector3} boxSize - Full size of the box
677
+ * @return {Vector3|undefined}
678
+ * @memberof Math3D
679
+ */
680
+ function collideSphereBox(pos, radius, boxPos, boxSize)
681
+ {
682
+ const h = boxSize.scale(.5);
683
+ const closest = vec3(
684
+ clamp(pos.x, boxPos.x - h.x, boxPos.x + h.x),
685
+ clamp(pos.y, boxPos.y - h.y, boxPos.y + h.y),
686
+ clamp(pos.z, boxPos.z - h.z, boxPos.z + h.z));
687
+ const d = pos.subtract(closest), distSq = d.lengthSquared();
688
+ if (distSq)
689
+ return distSq >= radius*radius ? undefined : d.normalize(radius - distSq**.5);
690
+
691
+ // center is inside the box, push out along the axis of least penetration
692
+ const offset = pos.subtract(boxPos);
693
+ return pushOutAxis3D(offset, h.x - abs(offset.x), h.y - abs(offset.y), h.z - abs(offset.z), radius);
694
+ }
695
+
696
+ /**
697
+ * Returns the vector to move a sphere back inside an axis aligned box, or undefined when it is all inside
698
+ * - The inside out twin of collideSphereBox, for keeping things in a room or an arena
699
+ * - A sphere too big for the box on some axis is held at the middle of it on that axis
700
+ * @param {Vector3} pos - Sphere center
701
+ * @param {number} radius
702
+ * @param {Vector3} boxPos
703
+ * @param {Vector3} boxSize - Full size of the box
704
+ * @return {Vector3|undefined}
705
+ * @memberof Math3D
706
+ */
707
+ function collideSphereInBox(pos, radius, boxPos, boxSize)
708
+ {
709
+ // the box shrunk by the radius is everywhere the center can be, so clamp the center into it
710
+ const x = max(0, boxSize.x/2 - radius), y = max(0, boxSize.y/2 - radius), z = max(0, boxSize.z/2 - radius);
711
+ const push = vec3(
712
+ clamp(pos.x, boxPos.x - x, boxPos.x + x) - pos.x,
713
+ clamp(pos.y, boxPos.y - y, boxPos.y + y) - pos.y,
714
+ clamp(pos.z, boxPos.z - z, boxPos.z + z) - pos.z);
715
+ return push.lengthSquared() ? push : undefined;
716
+ }
717
+
718
+ // the axis with the smallest penetration, pointing the way d does, with extra distance added
719
+ function pushOutAxis3D(d, penX, penY, penZ, extra=0)
720
+ {
721
+ const s = (v)=> v >= 0 ? 1 : -1; // sign() gives 0 on a tie, which would be no push
722
+ if (penX <= penY && penX <= penZ)
723
+ return vec3(s(d.x)*(penX + extra), 0, 0);
724
+ if (penY <= penZ)
725
+ return vec3(0, s(d.y)*(penY + extra), 0);
726
+ return vec3(0, 0, s(d.z)*(penZ + extra));
727
+ }
728
+
729
+ /**
730
+ * Returns the vector to move a sphere out of a vertical cylinder, or undefined
731
+ * @param {Vector3} pos - Sphere center
732
+ * @param {number} radius
733
+ * @param {Vector3} cylinderPos
734
+ * @param {number} cylinderRadius
735
+ * @param {number} cylinderHeight - Full height along Y
736
+ * @return {Vector3|undefined}
737
+ * @memberof Math3D
738
+ */
739
+ function collideSphereCylinder(pos, radius, cylinderPos, cylinderRadius, cylinderHeight)
740
+ {
741
+ const halfHeight = cylinderHeight/2;
742
+ const offsetX = pos.x - cylinderPos.x;
743
+ const offsetZ = pos.z - cylinderPos.z;
744
+ const offsetY = pos.y - cylinderPos.y;
745
+ const radialDist = (offsetX**2 + offsetZ**2)**.5;
746
+ const radialScale = radialDist ? min(radialDist, cylinderRadius)/radialDist : 0; // pull the point onto the wall, or the axis
747
+ const closest = vec3(
748
+ cylinderPos.x + offsetX*radialScale,
749
+ clamp(pos.y, cylinderPos.y - halfHeight, cylinderPos.y + halfHeight),
750
+ cylinderPos.z + offsetZ*radialScale);
751
+ const d = pos.subtract(closest), distSq = d.lengthSquared();
752
+ if (distSq)
753
+ return distSq >= radius*radius ? undefined : d.normalize(radius - distSq**.5);
754
+
755
+ // center is inside the cylinder, push out through the nearer surface
756
+ const sidePen = cylinderRadius - radialDist;
757
+ const capPen = halfHeight - abs(offsetY);
758
+ if (sidePen <= capPen)
759
+ {
760
+ const dir = radialDist ? vec3(offsetX/radialDist, 0, offsetZ/radialDist) : vec3(1, 0, 0);
761
+ return dir.scale(sidePen + radius);
762
+ }
763
+ return vec3(0, (offsetY >= 0 ? 1 : -1)*(capPen + radius), 0);
764
+ }
765
+
766
+ /**
767
+ * Returns the vector to move box A by so it no longer overlaps box B, the shortest way out, or undefined
768
+ * - The 3D twin of collideBoxBox
769
+ * @param {Vector3} posA
770
+ * @param {Vector3} sizeA - Full size of box A
771
+ * @param {Vector3} posB
772
+ * @param {Vector3} sizeB - Full size of box B
773
+ * @return {Vector3|undefined}
774
+ * @memberof Math3D
775
+ */
776
+ function collideBoxBox3D(posA, sizeA, posB, sizeB)
777
+ {
778
+ const d = posA.subtract(posB);
779
+ const overlapX = (sizeA.x + sizeB.x)/2 - abs(d.x);
780
+ const overlapY = (sizeA.y + sizeB.y)/2 - abs(d.y);
781
+ const overlapZ = (sizeA.z + sizeB.z)/2 - abs(d.z);
782
+ if (overlapX <= 0 || overlapY <= 0 || overlapZ <= 0)
783
+ return undefined;
784
+ return pushOutAxis3D(d, overlapX, overlapY, overlapZ);
785
+ }
786
+
787
+ /**
788
+ * Returns the distance along the ray to the first intersection with a sphere, or undefined
789
+ * - The hit is ray.getPosition(distance), a direction that is not unit length scales the distance
790
+ * - A ray starting inside the sphere is already there, so it gets back 0
791
+ * @param {Ray3D} ray
792
+ * @param {Vector3} pos - Sphere center
793
+ * @param {number} radius
794
+ * @return {number|undefined}
795
+ * @memberof Math3D
796
+ */
797
+ function raycastSphere(ray, pos, radius)
798
+ {
799
+ const {origin, direction} = ray;
800
+ const oc = origin.subtract(pos);
801
+ const a = direction.dot(direction);
802
+ if (!a)
803
+ return undefined;
804
+ const c = oc.dot(oc) - radius*radius;
805
+ if (c < 0)
806
+ return 0; // origin is inside the sphere
807
+ const b = 2*oc.dot(direction);
808
+ const discriminant = b*b - 4*a*c;
809
+ if (discriminant < 0)
810
+ return undefined;
811
+ const t = (-b - discriminant**.5)/(2*a);
812
+ return t >= 0 ? t : undefined;
813
+ }
814
+
815
+ /**
816
+ * Returns the distance along the ray to a plane, or undefined if parallel or behind
817
+ * - The hit is ray.getPosition(distance), a direction that is not unit length scales the distance
818
+ * @param {Ray3D} ray
819
+ * @param {Vector3} planePos
820
+ * @param {Vector3} planeNormal
821
+ * @return {number|undefined}
822
+ * @memberof Math3D
823
+ */
824
+ function raycastPlane(ray, planePos, planeNormal)
825
+ {
826
+ const {origin, direction} = ray;
827
+ const denominator = direction.dot(planeNormal);
828
+ if (abs(denominator) < 1e-9)
829
+ return undefined;
830
+ const t = planePos.subtract(origin).dot(planeNormal)/denominator;
831
+ return t < 0 ? undefined : t;
832
+ }
833
+
834
+ /**
835
+ * Returns the distance along the ray to the first intersection with an axis aligned box, or undefined
836
+ * - The hit is ray.getPosition(distance), a direction that is not unit length scales the distance
837
+ * - A ray starting inside the box is already there, so it gets back 0
838
+ * @param {Ray3D} ray
839
+ * @param {Vector3} pos - Center of the box
840
+ * @param {Vector3} size - Full size of the box
841
+ * @return {number|undefined}
842
+ * @memberof Math3D
843
+ */
844
+ function raycastBox(ray, pos, size)
845
+ {
846
+ const {origin, direction} = ray;
847
+ const h = size.scale(.5);
848
+ const boxMin = pos.subtract(h), boxMax = pos.add(h);
849
+ let tMin = 0, tMax = Infinity;
850
+ for (const axis of 'xyz')
851
+ {
852
+ const o = origin[axis], d = direction[axis];
853
+ const mn = boxMin[axis], mx = boxMax[axis];
854
+ if (!d)
855
+ {
856
+ if (o < mn || o > mx)
857
+ return undefined; // ray is parallel to this slab and outside it
858
+ continue;
859
+ }
860
+ let t0 = (mn - o)/d;
861
+ let t1 = (mx - o)/d;
862
+ if (t0 > t1)
863
+ [t0, t1] = [t1, t0];
864
+ tMin = max(tMin, t0);
865
+ tMax = min(tMax, t1);
866
+ if (tMin > tMax)
867
+ return undefined;
868
+ }
869
+ return tMin;
870
+ }