littlejsengine 1.19.3 → 1.20.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.
Files changed (50) hide show
  1. package/FAQ.md +841 -820
  2. package/README.md +29 -25
  3. package/dist/littlejs.d.ts +2455 -935
  4. package/dist/littlejs.esm.js +30344 -21110
  5. package/dist/littlejs.esm.min.js +4 -3
  6. package/dist/littlejs.js +30259 -21052
  7. package/dist/littlejs.min.js +2 -2
  8. package/dist/littlejs.release.js +26567 -20455
  9. package/package.json +10 -5
  10. package/plugins/audioEffects.js +28 -24
  11. package/plugins/box2d.js +2516 -2073
  12. package/plugins/drawUtilities.js +206 -31
  13. package/plugins/gltf.js +740 -0
  14. package/plugins/jsconfig.json +19 -0
  15. package/plugins/lightSystem.js +719 -332
  16. package/plugins/math3d.js +58 -17
  17. package/plugins/medalSystem.js +326 -280
  18. package/plugins/newgrounds.js +400 -199
  19. package/plugins/pathFinder.js +208 -175
  20. package/plugins/pluginExport.js +15 -0
  21. package/plugins/postProcess.js +56 -13
  22. package/plugins/render3d.js +1643 -1577
  23. package/plugins/render3dDebug.js +109 -0
  24. package/plugins/render3dExtras.js +1754 -0
  25. package/plugins/render3dVoxels.js +502 -0
  26. package/plugins/textureSheet.js +496 -458
  27. package/plugins/threejs.js +183 -163
  28. package/plugins/tweakables.js +411 -0
  29. package/plugins/tweenSystem.js +263 -145
  30. package/plugins/uiSystem.js +2024 -1687
  31. package/plugins/zzfxm.js +159 -159
  32. package/src/engine.js +191 -75
  33. package/src/engineAudio.js +229 -125
  34. package/src/engineBuild.mjs +289 -256
  35. package/src/engineDebug.js +912 -742
  36. package/src/engineDraw.js +295 -193
  37. package/src/engineEditor.js +2431 -0
  38. package/src/engineExport.js +12 -0
  39. package/src/engineInput.js +1462 -1293
  40. package/src/engineMath.js +1274 -1252
  41. package/src/engineObject.js +194 -80
  42. package/src/engineParticles.js +583 -532
  43. package/src/engineRelease.js +67 -45
  44. package/src/engineSettings.js +77 -29
  45. package/src/engineTileLayer.js +995 -741
  46. package/src/engineUtilities.js +19 -13
  47. package/src/engineWebGL.js +239 -82
  48. package/src/jsconfig.json +17 -13
  49. package/plugins/box2d.wasm.js +0 -630
  50. package/plugins/box2d.wasm.wasm +0 -0
@@ -0,0 +1,1754 @@
1
+ /*
2
+ * LittleJS 3D Extras Plugin
3
+ * - Things built on the 3D renderer that it does not need in order to draw: the rest of the shape builders,
4
+ * HeightMap terrain, the camera controls, ParticleEmitter3D and Trail3D, and the OBJ loader
5
+ * - Requires the Render3D plugin and goes after it, everything here is part of its Render3D namespace
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ ///////////////////////////////////////////////////////////////////////////////
11
+ // Helpers used only here
12
+
13
+ // gap between lines of 3D text, as a share of the character height; flat text can let lines touch
14
+ // the way the 2D font does, but extruded glyphs seen from an angle then overlap the line below
15
+ const RENDER3D_TEXT_LEADING = 1.3;
16
+
17
+ // let go of the parent but stay where the object was in the world, which removeChild keeps by itself; a destroyed
18
+ // parent has already let go, so the position remembered by the last update stands in
19
+ function render3DDetach(o)
20
+ {
21
+ if (o.parent)
22
+ o.parent.removeChild(o);
23
+ else if (o.worldPos3D)
24
+ o.pos3D = o.worldPos3D;
25
+ }
26
+
27
+ // a soft white dot for untextured particles, made once from a canvas, undefined headless or without a canvas
28
+ let render3DSoftDotTexture;
29
+ function render3DSoftDot()
30
+ {
31
+ if (render3DSoftDotTexture || !glContext || typeof OffscreenCanvas == 'undefined') return render3DSoftDotTexture;
32
+ const size = 32, context = createCanvasContext(size);
33
+ const gradient = context.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
34
+ for (const [stop, alpha] of [[0, 1], [.33, .9], [.67, .7], [1, 0]]) // the same falloff as a soft disc
35
+ gradient.addColorStop(stop, 'rgba(255,255,255,' + alpha + ')');
36
+ context.fillStyle = gradient;
37
+ context.fillRect(0, 0, size, size);
38
+ return render3DSoftDotTexture = new TextureInfo(context.canvas);
39
+ }
40
+
41
+ ///////////////////////////////////////////////////////////////////////////////
42
+ // The rest of the shape builders: buildLathe, buildSphere, buildBox, buildGrid and buildSky live with the
43
+ // renderer, since it hands those out itself
44
+
45
+ // lathe profile points rounding the corner at [r, y] where two sides leave it along the unit directions d1 and d2,
46
+ // from t along d1 to t along d2; one segment is a flat chamfer, its points doubled so the lathe shades both of its
47
+ // edges hard, more follow the circle that touches both sides
48
+ function render3DBevelProfile(corner, d1, d2, t, segments)
49
+ {
50
+ const p1 = [corner[0] + d1[0] * t, corner[1] + d1[1] * t];
51
+ const p2 = [corner[0] + d2[0] * t, corner[1] + d2[1] * t];
52
+ segments = max(1, segments | 0);
53
+ if (segments === 1)
54
+ return [p1, p1, p2, p2];
55
+
56
+ // the circle's center is on the line halfway between the sides, as far from each as its radius
57
+ const half = Math.acos(clamp(d1[0] * d2[0] + d1[1] * d2[1], -1, 1)) / 2;
58
+ const bx = d1[0] + d2[0], by = d1[1] + d2[1], reach = t / cos(half) / hypot(bx, by);
59
+ const cx = corner[0] + bx * reach, cy = corner[1] + by * reach, radius = t * tan(half);
60
+ const start = atan2(p1[1] - cy, p1[0] - cx);
61
+ const turn = mod(atan2(p2[1] - cy, p2[0] - cx) - start + PI, 2 * PI) - PI; // the short way round
62
+ const points = [p1];
63
+ for (let i = 1; i < segments; ++i)
64
+ {
65
+ const a = start + turn * i / segments;
66
+ points.push([cx + cos(a) * radius, cy + sin(a) * radius]);
67
+ }
68
+ points.push(p2);
69
+ return points;
70
+ }
71
+
72
+ /**
73
+ * Build a cylinder standing on the Y axis, centered on the origin
74
+ * @param {number} [size] - Diameter
75
+ * @param {number} [height]
76
+ * @param {number} [sides] - Around
77
+ * @param {boolean} [smooth] - Defaults to render3D.smoothShading
78
+ * @param {boolean} [capped] - Close the ends
79
+ * @param {number} [bevel] - Size of the cut on the top and bottom rims, clamped to the radius and half the height
80
+ * @param {number} [bevelSegments] - Steps around each rim, 1 for a flat chamfer
81
+ * @return {Mesh}
82
+ * @memberof Render3D
83
+ */
84
+ function buildCylinder(size=1, height=1, sides=16, smooth=render3D?.smoothShading, capped=true, bevel=0, bevelSegments=1)
85
+ {
86
+ ASSERT(isNumber(bevel) && bevel >= 0, 'bevel must be a number, 0 or more');
87
+ const r = size / 2, h = height / 2, t = min(bevel, r, h);
88
+ if (!(t > 0))
89
+ return buildLathe([[r, -h], [r, h]], sides, smooth, capped);
90
+ return buildLathe([...render3DBevelProfile([r, -h], [-1, 0], [0, 1], t, bevelSegments),
91
+ ...render3DBevelProfile([r, h], [0, -1], [-1, 0], t, bevelSegments)], sides, smooth, capped);
92
+ }
93
+
94
+ /**
95
+ * Build a cone standing on the Y axis, centered on the origin, the point up
96
+ * @param {number} [size] - Diameter of the base
97
+ * @param {number} [height]
98
+ * @param {number} [sides] - Around
99
+ * @param {boolean} [smooth] - Defaults to render3D.smoothShading
100
+ * @param {boolean} [capped] - Close the base
101
+ * @param {number} [bevel] - Size of the cut on the base rim, clamped to the radius and half the slanted side
102
+ * @param {number} [bevelSegments] - Steps around the rim, 1 for a flat chamfer
103
+ * @return {Mesh}
104
+ * @memberof Render3D
105
+ */
106
+ function buildCone(size=1, height=1, sides=16, smooth=render3D?.smoothShading, capped=true, bevel=0, bevelSegments=1)
107
+ {
108
+ ASSERT(isNumber(bevel) && bevel >= 0, 'bevel must be a number, 0 or more');
109
+ const r = size / 2, h = height / 2, slant = hypot(r, height), t = min(bevel, r, slant / 2);
110
+ if (!(t > 0))
111
+ return buildLathe([[r, -h], [0, h]], sides, smooth, capped);
112
+ const up = [-r / slant, height / slant]; // along the slanted side from the rim toward the point
113
+ return buildLathe([...render3DBevelProfile([r, -h], [-1, 0], up, t, bevelSegments), [0, h]],
114
+ sides, smooth, capped);
115
+ }
116
+
117
+ /**
118
+ * Build a capsule standing on the Y axis, centered on the origin: a cylinder with a half sphere on each end
119
+ * @param {number} [size] - Diameter
120
+ * @param {number} [height] - Total height including the rounded ends, at least the size
121
+ * @param {number} [sides] - Around
122
+ * @param {number} [rings] - On each end
123
+ * @param {boolean} [smooth] - Defaults to render3D.smoothShading
124
+ * @return {Mesh}
125
+ * @memberof Render3D
126
+ */
127
+ function buildCapsule(size=1, height=1, sides=16, rings=4, smooth=render3D?.smoothShading)
128
+ {
129
+ // the rounded ends alone are already the size tall, so a shorter capsule is only a sphere
130
+ ASSERT(height >= size, 'a capsule is at least as tall as it is wide, the ends take up the size', size, height);
131
+ const profile = [], r = size / 2, straight = max(0, height - size) / 2;
132
+ for (let i = 0; i <= rings; ++i)
133
+ {
134
+ const a = i / rings * PI / 2;
135
+ profile.push([r * sin(a), -straight - r * cos(a)]);
136
+ }
137
+ for (let i = 0; i <= rings; ++i)
138
+ {
139
+ const a = i / rings * PI / 2;
140
+ profile.push([r * cos(a), straight + r * sin(a)]);
141
+ }
142
+ return buildLathe(profile, sides, smooth);
143
+ }
144
+
145
+ /**
146
+ * Build a donut lying flat around the Y axis
147
+ * @param {number} [size] - Diameter of the whole donut, outside edge to outside edge
148
+ * @param {number} [tubeSize] - Diameter of the tube
149
+ * @param {number} [sides] - Around the ring
150
+ * @param {number} [tubeSides] - Around the tube
151
+ * @param {boolean} [smooth] - Defaults to render3D.smoothShading
152
+ * @return {Mesh}
153
+ * @memberof Render3D
154
+ */
155
+ function buildTorus(size=1, tubeSize=.3, sides=16, tubeSides=8, smooth=render3D?.smoothShading)
156
+ {
157
+ ASSERT(tubeSize <= size, 'the tube must fit inside the torus');
158
+ const profile = [], radius = (size - tubeSize) / 2, tubeRadius = tubeSize / 2;
159
+ for (let i = 0; i <= tubeSides; ++i)
160
+ {
161
+ const a = i / tubeSides * 2 * PI;
162
+ profile.push([radius + tubeRadius * cos(a), tubeRadius * sin(a)]);
163
+ }
164
+ return buildLathe(profile, sides, smooth);
165
+ }
166
+
167
+ /**
168
+ * Build a lit ribbon along a path, for roads, tracks and walls
169
+ * - Each segment is a flat quad, the sides are across the path in the plane of the up vector
170
+ * - doubleSided, so it is seen and lit from below as well
171
+ * @param {Array<Vector3>} points - Center line in order
172
+ * @param {number|Array<number>} [width] - Full width, one for all or one per point
173
+ * @param {Color|Array<Color>} [color] - One for all or one per point
174
+ * @param {boolean} [closed] - Join the last point back to the first
175
+ * @param {Vector3} [up] - Which way the ribbon faces
176
+ * @return {Mesh}
177
+ * @memberof Render3D
178
+ * @example
179
+ * const road = buildRibbon(trackPoints, 8, GRAY, true); // a loop of road
180
+ */
181
+ function buildRibbon(points, width=1, color=WHITE, closed=false, up=vec3(0, 1, 0))
182
+ {
183
+ ASSERT(isArray(points) && points.length > 1, 'ribbon needs at least 2 points');
184
+ const mesh = new Mesh, count = points.length, edges = [];
185
+ let across = (abs(up.y) < .9 ? vec3(0, 1, 0) : vec3(1, 0, 0)).cross(up).normalize(); // anything across up
186
+ for (let i = 0; i < count; ++i)
187
+ {
188
+ // across the path, from the tangent through this point; a step along up keeps the last across
189
+ const next = points[closed ? (i + 1) % count : min(i + 1, count - 1)];
190
+ const last = points[closed ? (i + count - 1) % count : max(i - 1, 0)];
191
+ const dir = next.subtract(last).cross(up);
192
+ if (dir.lengthSquared() > 1e-12)
193
+ across = dir.normalize();
194
+ const half = across.scale((isArray(width) ? width[i] : width) / 2);
195
+ edges.push([points[i].subtract(half), points[i].add(half)]);
196
+ }
197
+ for (let i = 0; i + 1 < count + (closed ? 1 : 0); ++i)
198
+ {
199
+ const j = (i + 1) % count, a = edges[i], b = edges[j];
200
+ const c = isArray(color) ? [color[i], color[i], color[j], color[j]] : color;
201
+ mesh.addQuad(a[0], a[1], b[1], b[0], c); // counter clockwise seen from above
202
+ }
203
+ mesh.doubleSided = true; // a flat strip, seen from both sides
204
+ return mesh;
205
+ }
206
+
207
+ /**
208
+ * Build a hull from a row of diamond shaped slices along Z, for ships, planes and cars
209
+ * - Each slice is [z, width, top, bottom, sideHeight]
210
+ * - sideHeight is 0 to 1 and puts the side corners between the bottom and the top
211
+ * - List the slices nose first, with the nose at the largest z
212
+ * @param {Array<Array<number>>} stations
213
+ * @return {Mesh}
214
+ * @memberof Render3D
215
+ * @example
216
+ * const hull = buildLoft([[1.2, .4, .2, -.1], [0, 1.4, .5, -.4], [-1, 1, .3, -.3]]);
217
+ */
218
+ function buildLoft(stations)
219
+ {
220
+ ASSERT(isArray(stations) && stations.length > 1, 'loft needs at least 2 stations');
221
+ // the caps and the winding both assume the nose leads, so the other order turns the hull inside out
222
+ ASSERT(stations[0][0] > stations[stations.length-1][0], 'loft stations go nose first, from the largest z to the smallest');
223
+ const mesh = new Mesh;
224
+ // section points: left, top, right, bottom, wound clockwise seen from +z
225
+ /** @type {function(Array<number>): Array<Vector3>} */
226
+ const section = ([z, w, t, b, m=.5])=>
227
+ [vec3(-w / 2, lerp(b, t, m), z), vec3(0, t, z), vec3(w / 2, lerp(b, t, m), z), vec3(0, b, z)];
228
+ for (let i = 0; i + 1 < stations.length; ++i)
229
+ {
230
+ const s1 = section(stations[i]), s2 = section(stations[i + 1]);
231
+ for (let k = 0; k < 4; ++k)
232
+ mesh.addQuad(s1[k], s1[(k + 1) % 4], s2[(k + 1) % 4], s2[k]);
233
+ }
234
+ const tail = section(stations[stations.length - 1]), nose = section(stations[0]);
235
+ mesh.addQuad(tail[0], tail[1], tail[2], tail[3]);
236
+ mesh.addQuad(nose[3], nose[2], nose[1], nose[0]);
237
+ return mesh;
238
+ }
239
+
240
+ /**
241
+ * Turn a sprite into a 3D block model by giving its pixels thickness
242
+ * - A pixel counts as solid when it is more than half opaque
243
+ * - Each pixel keeps its own color, so white art takes the object's tint
244
+ * - Runs of matching pixels merge into one face, and side walls appear only at the sprite's edges
245
+ * - A texture's pixels are read once and kept, so redrawing a canvas texture will not change what this builds
246
+ * - Pixels can also be an array of rows, each a Color, a truthy value for white, or a falsy value for empty
247
+ * @param {TileInfo|Array<Array<Color|number|boolean>>} pixels - A tile from a loaded texture, or rows of pixels,
248
+ * each a Color (empty when see through), a truthy value for white or a falsy value for empty
249
+ * @param {Vector2} [size] - World width and height of the whole tile, centered like buildBox
250
+ * @param {number} [depth] - Thickness along Z
251
+ * @return {Mesh}
252
+ * @memberof Render3D
253
+ * @example
254
+ * new EngineObject3D(vec3(), buildExtrude(tile(3, 16), vec2(2), .5)); // a chunky version of tile 3
255
+ */
256
+ function buildExtrude(pixels, size=vec2(1), depth=1)
257
+ {
258
+ let rows = /** @type {Array<Array<Color|number|boolean>>} */ (pixels), width, height;
259
+ if (pixels instanceof TileInfo)
260
+ {
261
+ // colors for the tile's pixels only, undefined where alpha is half or less
262
+ const image = render3DReadPixels(pixels.textureInfo), data = image.data;
263
+ const x0 = pixels.pos.x | 0, y0 = pixels.pos.y | 0;
264
+ width = pixels.size.x | 0, height = pixels.size.y | 0;
265
+ rows = [];
266
+ for (let y = 0; y < height; ++y)
267
+ {
268
+ const row = rows[y] = [];
269
+ for (let x = 0; x < width; ++x)
270
+ {
271
+ const k = ((y0 + y) * image.width + x0 + x) * 4;
272
+ row.push(data[k + 3] > 127 ? rgb(data[k] / 255, data[k + 1] / 255, data[k + 2] / 255) : undefined);
273
+ }
274
+ }
275
+ }
276
+ else
277
+ {
278
+ ASSERT(isArray(pixels) && pixels.length, 'pixels must be a TileInfo or rows of pixels');
279
+ height = rows.length, width = rows[0].length;
280
+ }
281
+
282
+ // the color of a solid pixel, undefined outside or where it is empty
283
+ const solid = (x, y)=>
284
+ {
285
+ if (x < 0 || y < 0 || x >= width || y >= height) return;
286
+ const c = /** @type {Color} */ (rows[y] && rows[y][x]); // or a truthy value for white
287
+ if (!c) return;
288
+ return isColor(c) ? (c.a > .5 ? c : undefined) : WHITE; // a see through Color is empty too
289
+ };
290
+ const same = (a, b)=> a === b || !!a && !!b && a.rgbaInt() === b.rgbaInt();
291
+
292
+ // call emit(start, end, color) for each run of same colored pixels, colorAt(i) undefined breaks the run
293
+ const runs = (count, colorAt, emit)=>
294
+ {
295
+ let start = 0, color;
296
+ for (let i = 0; i <= count; ++i)
297
+ {
298
+ const c = i < count ? colorAt(i) : undefined;
299
+ if (same(c, color)) continue;
300
+ if (color) emit(start, i, color);
301
+ start = i, color = c;
302
+ }
303
+ };
304
+
305
+ // pixel edges in world space, y runs down the image
306
+ const mesh = new Mesh, sx = size.x / width, sy = size.y / height, hz = depth / 2;
307
+ const px = x=> x * sx - size.x / 2, py = y=> size.y / 2 - y * sy;
308
+ const quad = (origin, right, up, normal, color)=>
309
+ mesh.addStrip(render3DQuadAxes(origin.add(right.scale(.5)).add(up.scale(.5)), right.scale(.5), up.scale(.5)), normal, RENDER3D_QUAD_UVS, color);
310
+ const X = vec3(1, 0, 0), Y = vec3(0, 1, 0), Z = vec3(0, 0, 1);
311
+ for (let y = 0; y < height; ++y)
312
+ {
313
+ // front and back faces along each row
314
+ runs(width, x=> solid(x, y), (a, b, c)=>
315
+ {
316
+ const w = X.scale((b - a) * sx), h = Y.scale(sy);
317
+ quad(vec3(px(a), py(y + 1), hz), w, h, Z, c);
318
+ quad(vec3(px(b), py(y + 1), -hz), w.scale(-1), h, Z.scale(-1), c);
319
+ });
320
+ // walls facing up and down where the pixel above or below is empty
321
+ runs(width, x=> solid(x, y - 1) ? undefined : solid(x, y), (a, b, c)=>
322
+ quad(vec3(px(a), py(y), hz), X.scale((b - a) * sx), Z.scale(-depth), Y, c));
323
+ runs(width, x=> solid(x, y + 1) ? undefined : solid(x, y), (a, b, c)=>
324
+ quad(vec3(px(a), py(y + 1), -hz), X.scale((b - a) * sx), Z.scale(depth), Y.scale(-1), c));
325
+ }
326
+ for (let x = 0; x < width; ++x)
327
+ {
328
+ // walls facing left and right where the pixel beside is empty
329
+ runs(height, y=> solid(x - 1, y) ? undefined : solid(x, y), (a, b, c)=>
330
+ quad(vec3(px(x), py(b), -hz), Z.scale(depth), Y.scale((b - a) * sy), X.scale(-1), c));
331
+ runs(height, y=> solid(x + 1, y) ? undefined : solid(x, y), (a, b, c)=>
332
+ quad(vec3(px(x + 1), py(b), hz), Z.scale(-depth), Y.scale((b - a) * sy), X, c));
333
+ }
334
+ return mesh;
335
+ }
336
+
337
+ /**
338
+ * Build a mesh of extruded text from an image font, the engine font by default so it needs no assets
339
+ * - Each glyph is extruded once per font and reused, the block is centered and faces +Z
340
+ * - Newlines stack downward, spaced a little wider than the character height so the sides do not collide
341
+ * - Every call builds a new mesh, dispose the old one when text changes often
342
+ * - Glyphs are white in the engine font, so the object's color tints the text
343
+ * @param {string|number} text
344
+ * @param {number} [size] - Character height in world units
345
+ * @param {number} [depth] - Thickness along Z
346
+ * @param {ImageFont} [font] - Defaults to engineImageFont
347
+ * @return {Mesh}
348
+ * @memberof Render3D
349
+ * @example
350
+ * new EngineObject3D(vec3(0, 2, 0), buildText3D('HELLO'), undefined, YELLOW);
351
+ */
352
+ function buildText3D(text, size=1, depth=.2, font=engineImageFont)
353
+ {
354
+ ASSERT(font instanceof ImageFont, 'font must be an ImageFont, the engine font loads before gameInit');
355
+ const tileInfo = font.tileInfo;
356
+ let glyphs = render3DGlyphCache.get(font); // unit sized, scaled when combined
357
+ glyphs || render3DGlyphCache.set(font, glyphs = new Map);
358
+ const charSize = vec2(size * tileInfo.size.x / tileInfo.size.y, size);
359
+ const mesh = new Mesh, lines = (text + '').split('\n');
360
+ lines.forEach((line, j)=>
361
+ {
362
+ const y = ((lines.length - 1) / 2 - j) * charSize.y * RENDER3D_TEXT_LEADING;
363
+ for (let i = 0; i < line.length; ++i)
364
+ {
365
+ const charCode = line.charCodeAt(i);
366
+ const index = charCode < 32 || charCode > 127 ? 95 : charCode - 32; // like ImageFont
367
+ if (!index) continue; // space
368
+ let glyph = glyphs.get(index);
369
+ if (!glyph)
370
+ {
371
+ const pos = font.getGlyphPos(index); // where ImageFont finds it
372
+ glyphs.set(index, glyph = buildExtrude(new TileInfo(pos, tileInfo.size, tileInfo.textureInfo)));
373
+ }
374
+ const x = (i - (line.length - 1) / 2) * charSize.x;
375
+ mesh.combine(glyph, buildMatrix(vec3(x, y, 0), undefined, vec3(charSize.x, charSize.y, depth)));
376
+ }
377
+ });
378
+ return mesh;
379
+ }
380
+
381
+ ///////////////////////////////////////////////////////////////////////////////
382
+ /**
383
+ * HeightMap - Terrain from a grid of heights: an object that draws itself, and that objects with collideLevel stand on
384
+ * - heights is a 2D array [row][column] of 0 to 1 values
385
+ * - Row 0 is the far edge at -Z and column 0 is the left edge at -X
386
+ * - It can be an image instead, where the red channel is the height
387
+ * - colors is an optional 2D array of Colors or an image, sampled per vertex
388
+ * - images are read through a canvas, so they must be same origin or loaded with crossOrigin set
389
+ * - pos3D is the center of the map, its grid spans mapSize on X and Z around it, and a full value is height above it
390
+ * - getHeight, getNormal, getColor and raycast are in world space, with the map's position taken off
391
+ * - It stays upright and unscaled, its lookups do not turn with it
392
+ * @extends EngineObject3D
393
+ * @memberof Render3D
394
+ * @example
395
+ * const terrain = new HeightMap(heightImage, vec2(100, 100), 10, colorImage);
396
+ * const y = terrain.getHeight(x, z); // stand things on it, or give them collideLevel
397
+ */
398
+ class HeightMap extends EngineObject3D
399
+ {
400
+ /** Create a height map from an array or an image, it draws itself and joins the level's collision
401
+ * @param {Array<Array<number>>|HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|TextureInfo} heights
402
+ * @param {Vector2} [mapSize] - World size along X and Z
403
+ * @param {number} [height] - World height of a full value
404
+ * @param {Array<Array<Color>>|HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|TextureInfo} [colors]
405
+ * @param {Vector3} [pos3D] - Center of the map
406
+ * @param {boolean} [smooth] - Defaults to render3D.smoothShading */
407
+ constructor(heights, mapSize=vec2(1), height=1, colors, pos3D=vec3(), smooth=render3D?.smoothShading)
408
+ {
409
+ super(pos3D);
410
+ if (!isArray(heights))
411
+ heights = render3DImageToArray(heights, (r)=> r / 255);
412
+ if (colors && !isArray(colors))
413
+ colors = render3DImageToArray(colors, (r, g, b, a)=> rgb(r / 255, g / 255, b / 255, a / 255));
414
+ ASSERT(isArray(heights) && heights.length > 1 && isArray(heights[0]) && heights[0].length > 1, 'height map needs at least 2 rows and 2 columns');
415
+ ASSERT(mapSize.x > 0 && mapSize.y > 0, 'height map size must be positive, a zero size has nowhere to look things up');
416
+
417
+ /** @property {Array<Array<number>>} - Heights 0-1 as [row][column], rows along Z, rebuild() after changing them */
418
+ this.heights = heights;
419
+ /** @property {Array<Array<Color>>|undefined} - Vertex colors as [row][column], undefined for white
420
+ * @type {Array<Array<Color>>|undefined} */
421
+ this.colors = /** @type {Array<Array<Color>>|undefined} */ (colors);
422
+ /** @property {Vector2} - World size along X and Z */
423
+ this.mapSize = mapSize.copy();
424
+ /** @property {number} - World height of a full value */
425
+ this.height = height;
426
+ /** @property {boolean} - Smooth shading, rebuild() after changing it */
427
+ this.smooth = !!smooth;
428
+ this.size3D = vec3(mapSize.x, height, mapSize.y);
429
+ this.rebuild();
430
+ render3DLevel.push(this);
431
+ }
432
+
433
+ /** Number of rows, along Z
434
+ * @return {number} */
435
+ get rows() { return this.heights.length; }
436
+
437
+ /** Number of columns, along X
438
+ * @return {number} */
439
+ get columns() { return this.heights[0].length; }
440
+
441
+ /** World height at a position, exactly the height of the mesh buildMesh draws there, clamped at the edges
442
+ * @param {number|Vector3} x - X, or a position to take X and Z from
443
+ * @param {number} [z]
444
+ * @return {number} */
445
+ getHeight(x, z)
446
+ {
447
+ if (x instanceof Vector3)
448
+ z = x.z, x = x.x; // a position works as well as its two numbers, its own y is ignored
449
+ const p = this.pos3D;
450
+ x -= p.x, z -= p.z;
451
+ const columns = this.columns, rows = this.rows, h = this.heights;
452
+ const u = clamp((x / this.mapSize.x + .5) * (columns - 1), 0, columns - 1);
453
+ const v = clamp((z / this.mapSize.y + .5) * (rows - 1), 0, rows - 1);
454
+ const i = min(floor(u), columns - 2), j = min(floor(v), rows - 2);
455
+ const fu = u - i, fv = v - j;
456
+ // each cell is two triangles split from (i, j+1) to (i+1, j), the same split buildGrid's quads use
457
+ const a = h[j][i], b = h[j+1][i], c = h[j+1][i+1], d = h[j][i+1];
458
+ const height = fu + fv <= 1 ? a + fu * (d - a) + fv * (b - a) : c + (1 - fu) * (b - c) + (1 - fv) * (d - c);
459
+ return height * this.height + p.y;
460
+ }
461
+
462
+ /** Surface normal at a position, from the slope across a sample
463
+ * @param {number|Vector3} x - X, or a position to take X and Z from
464
+ * @param {number} [z]
465
+ * @return {Vector3} */
466
+ getNormal(x, z)
467
+ {
468
+ if (x instanceof Vector3)
469
+ z = x.z, x = x.x;
470
+ const p = this.pos3D, size = this.mapSize;
471
+ const ex = size.x / (this.columns - 1) / 2, ez = size.y / (this.rows - 1) / 2;
472
+ const local = (lx, lz)=> this.getHeight(lx + p.x, lz + p.z);
473
+ return render3DSlopeNormal(local, x - p.x, z - p.z, ex, ez, size.x / 2, size.y / 2);
474
+ }
475
+
476
+ /** Color of the nearest sample to a position, white when there are no colors
477
+ * @param {number|Vector3} x - X, or a position to take X and Z from
478
+ * @param {number} [z]
479
+ * @return {Color} */
480
+ getColor(x, z)
481
+ {
482
+ if (x instanceof Vector3)
483
+ z = x.z, x = x.x;
484
+ const c = this.colors;
485
+ if (!c) return WHITE;
486
+ x -= this.pos3D.x, z -= this.pos3D.z;
487
+ const columns = c[0].length, rows = c.length;
488
+ const i = clamp(round((x / this.mapSize.x + .5) * (columns - 1)), 0, columns - 1);
489
+ const j = clamp(round((z / this.mapSize.y + .5) * (rows - 1)), 0, rows - 1);
490
+ return c[j][i];
491
+ }
492
+
493
+ /** Distance along a ray to where it crosses the terrain surface, or undefined for a miss
494
+ * - Exact: the ground is flat inside each triangle, so the ray is checked between each grid line and
495
+ * cell diagonal it crosses, and a hill it only grazes is still hit
496
+ * - A ray that starts under the ground crosses on its way out, so the hit is still on the surface
497
+ * @param {Ray3D} ray - From screenToRay, or any ray
498
+ * @return {number|undefined} */
499
+ raycast(ray)
500
+ {
501
+ // in the map's own space, its position taken off the ray
502
+ const p = this.pos3D, origin = ray.origin.subtract(p), direction = ray.direction;
503
+ const size = this.mapSize, height = this.height, length = direction.length();
504
+ if (!length) return;
505
+
506
+ // clip to the box around the terrain, from where the ray enters it to where it leaves the map's footprint
507
+ const start = raycastBox(new Ray3D(origin, direction), vec3(0, height / 2, 0), vec3(size.x, abs(height) + 1e-3, size.y));
508
+ if (start === undefined || !(size.x > 0 && size.y > 0)) return;
509
+ let end = start + hypot(size.x, size.y, height) / length;
510
+ if (direction.x)
511
+ end = min(end, (sign(direction.x) * size.x / 2 - origin.x) / direction.x);
512
+ if (direction.z)
513
+ end = min(end, (sign(direction.z) * size.y / 2 - origin.z) / direction.z);
514
+
515
+ // the grid lines and cell diagonals it crosses, in grid units u across the columns and v across the rows,
516
+ // each linear along the ray; between two of them the ground under it is one flat triangle
517
+ const scaleU = (this.columns - 1) / size.x, scaleV = (this.rows - 1) / size.y;
518
+ const u0 = (origin.x / size.x + .5) * (this.columns - 1), u1 = direction.x * scaleU;
519
+ const v0 = (origin.z / size.y + .5) * (this.rows - 1), v1 = direction.z * scaleV;
520
+ const breaks = [start, end];
521
+ for (const [f0, f1] of [[u0, u1], [v0, v1], [u0 + v0, u1 + v1]])
522
+ {
523
+ if (!f1) continue;
524
+ const a = f0 + f1 * start, b = f0 + f1 * end;
525
+ for (let k = ceil(min(a, b)); k <= max(a, b); ++k)
526
+ breaks.push((k - f0) / f1);
527
+ }
528
+ breaks.sort((a, b)=> a - b);
529
+
530
+ // how far above the ground the ray is, straight along each piece, so a crossing is solved exactly;
531
+ // one that starts under the ground finds where it comes out, the surface it breaks through
532
+ const above = (at)=>
533
+ {
534
+ const q = origin.add(direction.scale(at));
535
+ return q.y + p.y - this.getHeight(q.x + p.x, q.z + p.z);
536
+ };
537
+ // touching the surface counts as a hit too, where it starts, at the map's edge, or grazing it from below
538
+ const touching = (d)=> abs(d) <= 1e-9;
539
+ let a = start, da = above(a);
540
+ if (touching(da)) return start;
541
+ const startUnder = da <= 0;
542
+ for (const b of breaks)
543
+ {
544
+ if (b <= a || b > end) continue;
545
+ const db = above(b);
546
+ if (touching(db)) return b;
547
+ if (db <= 0 !== startUnder)
548
+ return da === db ? b : a + (b - a) * da / (da - db);
549
+ a = b, da = db;
550
+ }
551
+ }
552
+
553
+ /** Build the terrain mesh, one vertex per sample, centered on the map's own origin
554
+ * @param {boolean} [smooth] - Defaults to render3D.smoothShading
555
+ * @return {Mesh} */
556
+ buildMesh(smooth=render3D?.smoothShading)
557
+ {
558
+ // flat shading colors each cell from its center, halfway between two samples where rounding could pick
559
+ // either, so it is nudged a thousandth of a cell back to make it the cell's first corner every time
560
+ const p = this.pos3D, size = this.mapSize;
561
+ const nudgeX = smooth ? 0 : size.x / (this.columns - 1) / 1e3;
562
+ const nudgeZ = smooth ? 0 : size.y / (this.rows - 1) / 1e3;
563
+ return buildGrid(size, vec2(this.columns - 1, this.rows - 1),
564
+ this.colors && ((x, z)=> this.getColor(x - nudgeX + p.x, z - nudgeZ + p.z)),
565
+ (x, z)=> this.getHeight(x + p.x, z + p.z) - p.y, smooth);
566
+ }
567
+
568
+ /** Make the mesh again from the heights and colors, after changing them or smooth */
569
+ rebuild() { this.setMesh(this.buildMesh(this.smooth)); }
570
+
571
+ /** How far along a ray the surface is, for picking, see raycast
572
+ * @param {Ray3D} ray
573
+ * @return {number|undefined}
574
+ * @ignore */
575
+ levelRaycast3D(ray) { return this.raycast(ray); }
576
+
577
+ /** Keep an object above the ground, called by the engine for each object with collideLevel
578
+ * @param {EngineObject3D} o
579
+ * @param {Vector3} oldPos - Where it was before it moved
580
+ * @param {boolean} [wasOn] - It stood on this map last frame
581
+ * @ignore */
582
+ levelCollide3D(o, oldPos, wasOn)
583
+ {
584
+ const p = o.pos3D, m = this.pos3D, size = this.mapSize;
585
+ if (abs(p.x - m.x) > size.x / 2 || abs(p.z - m.z) > size.y / 2) return; // off the map
586
+ const half = o.size3D.y * abs(o.scale3D.y) / 2, ground = this.getHeight(p.x, p.z);
587
+ // one that stood on it and is not rising keeps to it going downhill, as far down as it moved across, so it
588
+ // stays grounded down a slope as steep as 45 degrees instead of falling in small hops
589
+ const follow = wasOn && o.velocity3D.y <= 0 ? hypot(p.x - oldPos.x, p.z - oldPos.z) : 0;
590
+ if (p.y - half > ground + follow) return;
591
+ p.y = ground + half;
592
+ const v = o.velocity3D;
593
+ if (v.y < 0)
594
+ v.y *= -max(o.restitution, this.restitution);
595
+ o.groundObject = this;
596
+ }
597
+
598
+ /** Keeps an eye on its placement, called automatically each frame */
599
+ update()
600
+ {
601
+ super.update();
602
+ render3DLevelAssertPlaced(this);
603
+ }
604
+
605
+ /** Destroy the map, it leaves the level's collision
606
+ * @param {boolean} [immediate] */
607
+ destroy(immediate)
608
+ {
609
+ render3DLevelLeave(this);
610
+ super.destroy(immediate);
611
+ }
612
+ }
613
+
614
+ // read an image's pixel bytes through the engine's work canvas, as {data, width, height}
615
+ function render3DImageData(image)
616
+ {
617
+ if (image instanceof TextureInfo)
618
+ image = image.image;
619
+ ASSERT(image && image.width && image.height, 'image is not loaded');
620
+ ASSERT(workReadCanvas, 'reading an image needs a canvas, pass arrays in headless mode');
621
+ const width = image.width, height = image.height;
622
+ workReadCanvas.width = width;
623
+ workReadCanvas.height = height;
624
+ workReadContext.drawImage(image, 0, 0);
625
+ return workReadContext.getImageData(0, 0, width, height);
626
+ }
627
+
628
+ // read an image into a 2D array [row][column], sample is called with (r, g, b, a) bytes for each pixel
629
+ function render3DImageToArray(image, sample)
630
+ {
631
+ const {data, width, height} = render3DImageData(image);
632
+ const rows = [];
633
+ for (let y = 0; y < height; ++y)
634
+ {
635
+ const row = rows[y] = [];
636
+ for (let x = 0; x < width; ++x)
637
+ {
638
+ const k = (y * width + x) * 4;
639
+ row.push(sample(data[k], data[k+1], data[k+2], data[k+3]));
640
+ }
641
+ }
642
+ return rows;
643
+ }
644
+
645
+ // extruded glyph meshes by font, and the pixel bytes of a texture, read once per image
646
+ const render3DGlyphCache = new WeakMap, render3DPixelCache = new WeakMap;
647
+ function render3DReadPixels(textureInfo)
648
+ {
649
+ const image = textureInfo.image;
650
+ let pixels = render3DPixelCache.get(image);
651
+ if (!pixels)
652
+ render3DPixelCache.set(image, pixels = render3DImageData(image));
653
+ return pixels;
654
+ }
655
+
656
+ ///////////////////////////////////////////////////////////////////////////////
657
+ /**
658
+ * CameraControl3D - Drag to turn the camera around a point, roll the wheel to zoom
659
+ * - An EngineObject3D, so move its pos3D to follow something, or parent it to an object
660
+ * - Destroy it to hand the camera back, and it stops driving the camera
661
+ * - Set persistent to keep it when engineObjectsDestroy clears out a level
662
+ * - Every part of it is a field, so a game can change the buttons, speeds and limits
663
+ * @extends EngineObject3D
664
+ * @memberof Render3D
665
+ * @example
666
+ * new CameraControl3D(vec3(0, 1, 0), 15); // look at a point from 15 units away
667
+ */
668
+ class CameraControl3D extends EngineObject3D
669
+ {
670
+ /** Create a camera control, it drives render3D.camera every frame
671
+ * @param {Vector3} [target] - The point to look at, its pos3D
672
+ * @param {number} [distance] - How far the camera sits from the target
673
+ * @param {number} [pitch] - Angle above the horizon, PI/2 looks straight down, clamped to pitchRange
674
+ * @param {number} [idleSpin] - Turned each frame while not dragging, 0 holds still */
675
+ constructor(target=vec3(), distance=10, pitch=.4, idleSpin=0)
676
+ {
677
+ super(target);
678
+ this.size3D = vec3(); // not a solid thing to pick or collect
679
+ /** @property {number} - How far the camera sits from the target */
680
+ this.distance = distance;
681
+ /** @property {number} - Angle above the horizon */
682
+ this.pitch = pitch;
683
+ /** @property {number} - Turned each frame while not dragging */
684
+ this.idleSpin = idleSpin;
685
+ /** @property {number} - Angle around the target, dragging changes it */
686
+ this.yaw = 0;
687
+ /** @property {number} - Mouse button that turns the camera, 0 is left and 2 is right */
688
+ this.dragButton = 0;
689
+ /** @property {number} - How far dragging a pixel turns the camera */
690
+ this.dragSpeed = .01;
691
+ /** @property {number} - How much one wheel notch zooms, 0 turns zooming off */
692
+ this.zoomSpeed = .1;
693
+ /** @property {Vector2} - Closest and furthest the wheel can zoom to */
694
+ this.zoomRange = vec2(distance/4, distance*3);
695
+ /** @property {Vector2} - Lowest and highest pitch, so it cannot tip over the top, widened to hold the pitch given */
696
+ this.pitchRange = vec2(min(-.2, pitch), max(1.4, pitch));
697
+ }
698
+
699
+ /** Read the mouse and put the camera on its orbit, called automatically each frame */
700
+ update()
701
+ {
702
+ if (mouseIsDown(this.dragButton))
703
+ {
704
+ // the scene follows the drag
705
+ this.yaw -= mouseDeltaScreen.x * this.dragSpeed;
706
+ this.pitch += mouseDeltaScreen.y * this.dragSpeed;
707
+ }
708
+ else
709
+ this.yaw += this.idleSpin;
710
+ this.pitch = clamp(this.pitch, this.pitchRange.x, this.pitchRange.y);
711
+ if (this.zoomSpeed && mouseWheel)
712
+ this.distance = clamp(this.distance * (1 + sign(mouseWheel) * this.zoomSpeed), this.zoomRange.x, this.zoomRange.y);
713
+ render3D.camera.orbit(this.getWorldPos3D(), this.distance, this.yaw, this.pitch);
714
+ }
715
+
716
+ /** Camera controls draw nothing */
717
+ render3D() {}
718
+ }
719
+
720
+ ///////////////////////////////////////////////////////////////////////////////
721
+ /**
722
+ * FirstPersonCamera3D - Look around with the mouse and move with the keys, with the camera at its position
723
+ * - Click to capture the mouse so looking needs no button held, Esc lets it go; holding the button looks too, for touch
724
+ * - WASD or the arrow keys walk level, or move the way it looks when fly is set
725
+ * - An EngineObject3D that moves by velocity3D, so give it a size3D and call setCollision to walk into solid
726
+ * objects instead of through them; walking keeps velocity3D.y, so render3D.gravity can pull it down
727
+ * - Starts from wherever render3D.camera is, so it can take over from another camera without a jump
728
+ * - As the child of an EngineObject3D, like a player on a ship, its yaw, pitch and walking are relative to the parent,
729
+ * so it turns and moves with it
730
+ * - Destroy it to hand the camera back
731
+ * @extends EngineObject3D
732
+ * @memberof Render3D
733
+ * @example
734
+ * const player = new FirstPersonCamera3D(vec3(0, 1.5, 5));
735
+ * player.size3D = vec3(1); // bump into solid objects
736
+ * player.collideAsSphere3D = true;
737
+ * player.setCollision();
738
+ */
739
+ class FirstPersonCamera3D extends EngineObject3D
740
+ {
741
+ /** Create a first person camera, it drives render3D.camera every frame
742
+ * @param {Vector3} [pos3D] - Where the eye is, defaults to where the camera is now
743
+ * @param {number} [yaw] - Radians around Y, defaults to the camera's
744
+ * @param {number} [pitch] - Radians up from level, defaults to the camera's */
745
+ constructor(pos3D=render3D.camera.pos, yaw=render3D.camera.rotation.y, pitch=render3D.camera.rotation.x)
746
+ {
747
+ super(pos3D);
748
+ this.size3D = vec3(); // not a solid thing to pick or collect until it is given a size
749
+ this.mass = 1; // so solids push it out, and render3D.gravity pulls on it
750
+ /** @property {number} - Angle around Y, the mouse turns it */
751
+ this.yaw = yaw;
752
+ /** @property {number} - Angle up from level, the mouse tilts it */
753
+ this.pitch = pitch;
754
+ /** @property {number} - World units per frame at full speed */
755
+ this.moveSpeed = .1;
756
+ /** @property {number} - How far a pixel of mouse movement turns the view */
757
+ this.lookSpeed = .003;
758
+ /** @property {Vector2} - Lowest and highest pitch */
759
+ this.pitchRange = vec2(-1.5, 1.5);
760
+ /** @property {boolean} - Move the way it looks, up and down included, instead of walking level */
761
+ this.fly = false;
762
+ /** @property {boolean} - Capture the mouse on a click, so looking needs no button held */
763
+ this.lockPointer = true;
764
+ /** @property {number} - Speed of a jump in world units a frame, 0 for none; Space or gamepad button 0 jumps
765
+ * while it stands on something, a height map or voxel map it collides with, see collideLevel */
766
+ this.jumpSpeed = 0;
767
+ /** @property {number} - How far above its position the eye is, in its own space, so with a size3D the eye can
768
+ * sit toward the top of the body instead of its middle; keep it under half the body's height, or the eye is
769
+ * outside the body and sees through a ceiling it stands under */
770
+ this.eyeHeight = 0;
771
+ }
772
+
773
+ /** Move by velocity3D, and fall by render3D.gravity unless flying, called automatically each frame */
774
+ updatePhysics()
775
+ {
776
+ // flying moves the way it looks and nothing else, so gravity does not pull while fly is on; the scale set
777
+ // on it is put back, for walking
778
+ const gravityScale = this.gravityScale;
779
+ if (this.fly)
780
+ this.gravityScale = 0;
781
+ super.updatePhysics();
782
+ this.gravityScale = gravityScale;
783
+ }
784
+
785
+ /** Read the mouse and keys and put the camera at the eye, called automatically each frame */
786
+ update()
787
+ {
788
+ // only a root moves by its own physics, a child follows its parent, so gravity would still pull one that flies
789
+ ASSERT(!this.fly || !this.parent, 'a flying FirstPersonCamera3D moves on its own, it cannot be a child');
790
+
791
+ // a click captures the mouse, then it looks around while captured or while a button is held
792
+ if (this.lockPointer && mouseWasPressed(0))
793
+ pointerLockRequest();
794
+ if (pointerLockIsActive() || mouseIsDown(0))
795
+ {
796
+ this.yaw -= mouseDeltaScreen.x * this.lookSpeed;
797
+ this.pitch -= mouseDeltaScreen.y * this.lookSpeed;
798
+ }
799
+ this.pitch = clamp(this.pitch, this.pitchRange.x, this.pitchRange.y);
800
+
801
+ // the keys move it level, or the way it looks when flying, and walking keeps its fall
802
+ const input = keyDirection();
803
+ const move = vec3(input.x, 0, -input.y).clampLength(1).scale(this.moveSpeed)
804
+ .rotateX(this.fly ? this.pitch : 0).rotateY(this.yaw);
805
+ this.velocity3D = this.fly ? move : vec3(move.x, this.velocity3D.y, move.z);
806
+ // a jump from the ground, level collision sets what it stands on
807
+ if (this.jumpSpeed && !this.fly && this.groundObject && (keyWasPressed('Space') || gamepadWasPressed(0)))
808
+ this.velocity3D.y = this.jumpSpeed;
809
+
810
+ // the camera sits at the eye, eyeHeight above where this frame's physics left it, looking the way it does in
811
+ // its parent's space, since a child's velocity3D moves it in that space too
812
+ const rotation = vec3(this.pitch, this.yaw, 0);
813
+ render3D.camera.pos = render3DObjectMatrix(this).transformPoint(vec3(0, this.eyeHeight, 0));
814
+ render3D.camera.rotation = this.parent instanceof EngineObject3D ?
815
+ this.parent.getMatrix().multiply(Matrix4.rotation(rotation)).getRotation() : rotation;
816
+ }
817
+
818
+ /** Let go of the mouse and stop driving the camera
819
+ * @param {boolean} [immediate] */
820
+ destroy(immediate)
821
+ {
822
+ this.lockPointer && pointerLockIsActive() && pointerLockExit();
823
+ super.destroy(immediate);
824
+ }
825
+
826
+ /** Camera controls draw nothing */
827
+ render3D() {}
828
+ }
829
+
830
+ ///////////////////////////////////////////////////////////////////////////////
831
+ // a particle is this many floats of its emitter's particleData: position, velocity, start color, end color, start
832
+ // and end size, life, age, angle, spin, and how many trail points it has in trailData
833
+ const RENDER3D_PARTICLE_FLOATS = 21;
834
+ // the position, color and size of the particle being drawn, shared by every emitter so the draw loop makes no objects
835
+ const render3DParticlePos = vec3(), render3DParticleColor = new Color, render3DParticleSize = vec2();
836
+
837
+ /**
838
+ * ParticleEmitter3D - Spawns camera facing particles, the 3D twin of ParticleEmitter
839
+ * - Each particle is a flat square facing the camera, with a soft round dot when no tile is given
840
+ * - Set trailTime to draw each particle as a streak along where it has been, for sparks
841
+ * - Set angleSpeed to tumble them in the camera plane, which the 2D emitter takes as an argument
842
+ * - Particles shoot out along the emitter's own up axis, turned by rotation3D
843
+ * - emitConeAngle spreads them, PI sprays in every direction
844
+ * - Speeds are per frame and sizes are world units, the same as the 2D emitter
845
+ * - scale3D, its own or a parent's, grows the whole effect: the spawn area, the sizes, the speed and the fall
846
+ * - gravity here is its own number added to velocity y each frame: it is neither the engine's 2D
847
+ * gravity nor render3D.gravity, so an effect keeps its own fall wherever it is used
848
+ * - An emitter with an emitTime destroys itself once its last particle is gone, like the 2D emitter
849
+ * @extends EngineObject3D
850
+ * @memberof Render3D
851
+ * @example
852
+ * // fire: a stream upward, yellow fading to transparent red, additive
853
+ * new ParticleEmitter3D(vec3(), .5, 0, 100, .3, undefined, hsl(.12, 1, .6), hsl(.08, 1, .5), hsl(0, 1, .5, 0), hsl(0, 1, .25, 0), 1, .5, 1.5, .05, .95, 0, .3, .2, true);
854
+ */
855
+ class ParticleEmitter3D extends EngineObject3D
856
+ {
857
+ /** Create a particle emitter
858
+ * @param {Vector3} [pos3D] - World space position of the emitter
859
+ * @param {number|Vector3} [emitSize] - Spawn area, a number for a sphere diameter or a vec3 for a box
860
+ * @param {number} [emitTime] - How long to keep emitting, 0 is forever
861
+ * @param {number} [emitRate] - Particles per second, 0 does not emit
862
+ * @param {number} [emitConeAngle] - Half angle around the emit direction, PI is every direction
863
+ * @param {TileInfo|TextureInfo} [tileInfo] - Tile to render particles with, or a whole texture, undefined is untextured
864
+ * @param {Color} [colorStartA] - Color at start of life, randomized between the start colors
865
+ * @param {Color} [colorStartB]
866
+ * @param {Color} [colorEndA] - Color at end of life, randomized between the end colors
867
+ * @param {Color} [colorEndB]
868
+ * @param {number} [particleTime] - How long particles live in seconds
869
+ * @param {number} [sizeStart] - Particle size at start of life
870
+ * @param {number} [sizeEnd] - Particle size at end of life
871
+ * @param {number} [speed] - Spawn speed in world units per frame
872
+ * @param {number} [damping] - Per frame velocity multiplier, 1 is none
873
+ * @param {number} [gravity] - Per frame change to velocity y, negative pulls down; its own number,
874
+ * not render3D.gravity, so the 2D emitter's gravityScale has no equivalent here
875
+ * @param {number} [fadeRate] - Fraction of life spent fading, half in and half out
876
+ * @param {number} [randomness] - Extra randomness applied to speed, size and life
877
+ * @param {boolean} [additive] - Additive blending */
878
+ constructor(pos3D=vec3(), emitSize=0, emitTime=0, emitRate=100, emitConeAngle=PI, tileInfo,
879
+ colorStartA=WHITE, colorStartB=WHITE, colorEndA=CLEAR_WHITE, colorEndB=CLEAR_WHITE,
880
+ particleTime=.5, sizeStart=.1, sizeEnd=1, speed=.1, damping=1, gravity=0, fadeRate=.1, randomness=.2, additive=false)
881
+ {
882
+ super(pos3D, undefined, tileInfo);
883
+ this.transparent = true;
884
+ this.castShadow = false;
885
+ this.size3D = vec3(); // not a solid thing to pick or collect
886
+
887
+ /** @property {number|Vector3} - Spawn area, a number for a sphere diameter or a vec3 for a box */
888
+ this.emitSize = emitSize;
889
+ /** @property {number} - How long to keep emitting, 0 is forever */
890
+ this.emitTime = emitTime;
891
+ /** @property {number} - Particles per second, 0 does not emit */
892
+ this.emitRate = emitRate;
893
+ /** @property {number} - Half angle around the emit direction, PI is every direction */
894
+ this.emitConeAngle = emitConeAngle;
895
+ /** @property {Color} - Color at start of life, randomized between the start colors */
896
+ this.colorStartA = colorStartA.copy();
897
+ /** @property {Color} - Color at start of life, randomized between the start colors */
898
+ this.colorStartB = colorStartB.copy();
899
+ /** @property {Color} - Color at end of life, randomized between the end colors */
900
+ this.colorEndA = colorEndA.copy();
901
+ /** @property {Color} - Color at end of life, randomized between the end colors */
902
+ this.colorEndB = colorEndB.copy();
903
+ /** @property {number} - How long particles live in seconds */
904
+ this.particleTime = particleTime;
905
+ /** @property {number} - Particle size at start of life */
906
+ this.sizeStart = sizeStart;
907
+ /** @property {number} - Particle size at end of life */
908
+ this.sizeEnd = sizeEnd;
909
+ /** @property {number} - Spawn speed in world units per frame */
910
+ this.speed = speed;
911
+ /** @property {number} - Per frame velocity multiplier */
912
+ this.damping = damping;
913
+ /** @property {number} - Per frame change to velocity y, its own number and not render3D.gravity */
914
+ this.gravity = gravity;
915
+ /** @property {number} - Fraction of life spent fading, half in and half out */
916
+ this.fadeRate = fadeRate;
917
+ /** @property {number} - Extra randomness applied to speed, size and life */
918
+ this.randomness = randomness;
919
+ /** @property {boolean} - Additive blending */
920
+ this.additive = additive;
921
+ /** @property {number} - Seconds of each particle's path to draw as a ribbon behind it, 0 draws billboards */
922
+ this.trailTime = 0;
923
+ /** @property {number} - Radians per frame each particle turns in the camera plane, either way; 0 is no spin */
924
+ this.angleSpeed = 0;
925
+ /** @property {number} - Per frame multiplier on that spin, 1 keeps it */
926
+ this.angleDamping = 1;
927
+ /** @property {Float32Array} - The live particles, 21 floats each: position, velocity, start and end color, start
928
+ * and end size, life, age, angle, spin, and trail point count; the emitter owns them, nothing else needs to */
929
+ this.particleData = new Float32Array(64 * RENDER3D_PARTICLE_FLOATS);
930
+ /** @property {number} - How many particles are alive, the first that many of particleData */
931
+ this.particleCount = 0;
932
+ /** @property {Float32Array|undefined} - The trail points of every particle, trailMax per particle oldest first, when trailTime is set
933
+ * @type {Float32Array|undefined} */
934
+ this.trailData = undefined;
935
+ /** @property {number} - Trail points kept per particle, from trailTime */
936
+ this.trailMax = 0;
937
+ /** @property {Vector3|undefined} - Where the emitter was at its last update, for when its parent is destroyed
938
+ * @type {Vector3|undefined} */
939
+ this.worldPos3D = undefined;
940
+ this.emitTimeBuffer = 0;
941
+ }
942
+
943
+ /** Spawn new particles, move the live ones, and go away when done */
944
+ update()
945
+ {
946
+ // one transform for the frame: where the emitter is, and how big the effect it makes is
947
+ const matrix = render3DObjectMatrix(this); // the object's own, read only
948
+ this.worldPos3D = matrix.getTranslation(); // remembered for when the parent is destroyed
949
+ const scale = render3DMaxScale(matrix.m);
950
+
951
+ // emit until the emit time is up, then wait for the last particle and go away
952
+ if (!this.emitTime || this.getAliveTime() <= this.emitTime)
953
+ {
954
+ // a rate of zero is an emitter fed by hand, and the global scale only quiets it,
955
+ // neither is a reason to stop counting down the emit time
956
+ if (this.emitRate && particleEmitRateScale)
957
+ {
958
+ this.emitTimeBuffer += this.emitRate * particleEmitRateScale * timeDelta;
959
+ for (; this.emitTimeBuffer >= 1; --this.emitTimeBuffer)
960
+ this.emitParticle();
961
+ }
962
+ }
963
+ else if (!this.particleCount)
964
+ this.destroy();
965
+
966
+ // the trail storage follows trailTime, a change starts every trail over
967
+ const trailMax = this.trailTime ? max(1, round(this.trailTime / timeDelta)) : 0;
968
+ if (trailMax !== this.trailMax)
969
+ {
970
+ this.trailMax = trailMax;
971
+ this.trailData = trailMax ? new Float32Array(this.particleData.length / RENDER3D_PARTICLE_FLOATS * trailMax * 3) : undefined;
972
+ for (let k = 20; k < this.particleData.length; k += RENDER3D_PARTICLE_FLOATS)
973
+ this.particleData[k] = 0;
974
+ }
975
+
976
+ // move the particles and drop the dead ones, all in the typed array: this runs for every particle every frame
977
+ const F = RENDER3D_PARTICLE_FLOATS, data = this.particleData, trail = this.trailData;
978
+ const damping = this.damping, gravity = this.gravity * scale, angleDamping = this.angleDamping; // a bigger effect has to fall faster to keep the same arc
979
+ for (let i = this.particleCount; i--;)
980
+ {
981
+ // damped first and gravity added after, the order the 2D particle uses, so the same
982
+ // damping and gravity give the same arc in both
983
+ const k = i * F;
984
+ const vx = data[k+3] *= damping, vy = data[k+4] = data[k+4] * damping + gravity, vz = data[k+5] *= damping;
985
+ data[k] += vx, data[k+1] += vy, data[k+2] += vz;
986
+ data[k+18] += data[k+19] *= angleDamping;
987
+ const t = i * trailMax * 3;
988
+ if (trailMax)
989
+ {
990
+ // remember where it has been, oldest first; a full trail drops its oldest point
991
+ let n = data[k+20];
992
+ if (n === trailMax)
993
+ trail.copyWithin(t, t + 3, t + n * 3), --n;
994
+ const j = t + n * 3;
995
+ trail[j] = data[k], trail[j+1] = data[k+1], trail[j+2] = data[k+2];
996
+ data[k+20] = n + 1;
997
+ }
998
+ if ((data[k+17] += timeDelta) >= data[k+16])
999
+ {
1000
+ // dead: the last particle takes its slot, trail and all, order does not matter
1001
+ const last = --this.particleCount, kl = last * F;
1002
+ if (i !== last)
1003
+ {
1004
+ data.copyWithin(k, kl, kl + F);
1005
+ trailMax && trail.copyWithin(t, last * trailMax * 3, (last + 1) * trailMax * 3);
1006
+ }
1007
+ }
1008
+ }
1009
+ }
1010
+
1011
+ /** Stop emitting, and go away once the particles already out have finished like the 2D emitter's do
1012
+ * @param {boolean} [immediate] */
1013
+ destroy(immediate)
1014
+ {
1015
+ if (immediate || !this.particleCount || this.destroyed)
1016
+ return super.destroy(immediate);
1017
+ this.emitTime = -1; // stops emitting, and update destroys it once the particles are gone
1018
+ render3DDetach(this); // the particles are in world space, they no longer need the parent
1019
+ }
1020
+
1021
+ /** Spawn one particle now */
1022
+ emitParticle()
1023
+ {
1024
+ const random = ()=> rand(1 - this.randomness, 1 + this.randomness);
1025
+ const matrix = render3DObjectMatrix(this); // the object's own, read only
1026
+ // the whole effect grows with the emitter, not just the area the particles start in
1027
+ const scale = render3DMaxScale(matrix.m);
1028
+
1029
+ // spawn offset: inside a box or a sphere
1030
+ const size = this.emitSize, box = /** @type {Vector3} */ (size);
1031
+ const offset = isVector3(size) ? vec3(rand(-.5, .5) * box.x, rand(-.5, .5) * box.y, rand(-.5, .5) * box.z)
1032
+ : randInSphere(/** @type {number} */ (size) / 2);
1033
+
1034
+ // direction inside the cone around local +Y
1035
+ const direction = matrix.transformDirection(randVector3(1, this.emitConeAngle)).normalize();
1036
+
1037
+ const pos = matrix.transformPoint(offset), speed = this.speed * random() * scale;
1038
+ const colorStart = randColor(this.colorStartA, this.colorStartB, true), colorEnd = randColor(this.colorEndA, this.colorEndB, true);
1039
+
1040
+ // room for one more, doubling as the set grows, the trails along with it
1041
+ const F = RENDER3D_PARTICLE_FLOATS, k = this.particleCount++ * F;
1042
+ if (k + F > this.particleData.length)
1043
+ {
1044
+ const grown = new Float32Array(this.particleData.length * 2);
1045
+ grown.set(this.particleData);
1046
+ this.particleData = grown;
1047
+ if (this.trailData)
1048
+ {
1049
+ const trails = new Float32Array(this.trailData.length * 2);
1050
+ trails.set(this.trailData);
1051
+ this.trailData = trails;
1052
+ }
1053
+ }
1054
+ const data = this.particleData;
1055
+ data[k] = pos.x, data[k+1] = pos.y, data[k+2] = pos.z;
1056
+ data[k+3] = direction.x * speed, data[k+4] = direction.y * speed, data[k+5] = direction.z * speed;
1057
+ data[k+6] = colorStart.r, data[k+7] = colorStart.g, data[k+8] = colorStart.b, data[k+9] = colorStart.a;
1058
+ data[k+10] = colorEnd.r, data[k+11] = colorEnd.g, data[k+12] = colorEnd.b, data[k+13] = colorEnd.a;
1059
+ data[k+14] = this.sizeStart * random() * scale;
1060
+ data[k+15] = this.sizeEnd * random() * scale;
1061
+ data[k+16] = this.particleTime * random(); // life
1062
+ data[k+17] = 0; // age
1063
+ // a spinning particle starts anywhere and turns either way, one that is not stays at zero
1064
+ data[k+18] = this.angleSpeed ? rand(2*PI) : 0;
1065
+ data[k+19] = this.angleSpeed ? this.angleSpeed * random() * randSign() : 0;
1066
+ data[k+20] = 0; // trail points
1067
+ }
1068
+
1069
+ /** Draw the particles, as flat squares or as streaks when trailTime is set
1070
+ * - The whole emitter sorts as one thing, its particles are not sorted against each other
1071
+ * - With render3D.instancing on the squares go out as one instanced draw of render3D.billboardMesh
1072
+ * @return {void} */
1073
+ render3D()
1074
+ {
1075
+ const r = render3D;
1076
+ if (r.transparentQueue)
1077
+ return r.queueTransparent(this.getWorldPos3D(), ()=> this.render3D());
1078
+ const fade = this.fadeRate / 2, texture = this.tileInfo || render3DSoftDot(); // no dot headless
1079
+ const color = render3DParticleColor, size = render3DParticleSize; // shared, so a particle makes no objects
1080
+
1081
+ // the instanced path: one matrix per particle, its right and up axes the quad's size long, facing the camera,
1082
+ // written straight into the batch; the batch draws at the end so the order of the transparent stage holds
1083
+ const quad = r.billboardMesh, instanced = texture && r.instancing && !r.capture && render3DCanDraw();
1084
+ let data, textureInfo, uv, lit;
1085
+ if (instanced)
1086
+ {
1087
+ render3DMeshUpload(quad);
1088
+ textureInfo = render3DTextureOf(texture);
1089
+ uv = render3DGetTileUVs(texture);
1090
+ lit = r.lighting;
1091
+ r.lighting = r.shadowPass && lit; // unlit on screen, in the shadow map the object's flag decides
1092
+ r.cullBackFaces = r.mirrored = false;
1093
+ }
1094
+ const cb = r.cameraBack;
1095
+ const F = RENDER3D_PARTICLE_FLOATS, particles = this.particleData, trailMax = this.trailMax, pos = render3DParticlePos;
1096
+ try
1097
+ {
1098
+ for (let i = 0, count = this.particleCount; i < count; ++i)
1099
+ {
1100
+ const p = i * F, t = particles[p+17] / particles[p+16];
1101
+ const alpha = t < fade ? t / fade : t > 1 - fade ? (1 - t) / fade : 1;
1102
+ color.r = particles[p+6] + (particles[p+10] - particles[p+6]) * t;
1103
+ color.g = particles[p+7] + (particles[p+11] - particles[p+7]) * t;
1104
+ color.b = particles[p+8] + (particles[p+12] - particles[p+8]) * t;
1105
+ color.a = (particles[p+9] + (particles[p+13] - particles[p+9]) * t) * alpha;
1106
+ const s = size.x = size.y = lerp(particles[p+14], particles[p+15], t), angle = particles[p+18];
1107
+ const trailCount = particles[p+20];
1108
+ if (trailCount > 1)
1109
+ {
1110
+ // a ribbon from the tail to the head, the tail thins and fades out
1111
+ const trail = this.trailData, points = [], widths = [], colors = [];
1112
+ for (let j = 0; j < trailCount; ++j)
1113
+ {
1114
+ const f = (j + 1) / trailCount, q = (i * trailMax + j) * 3;
1115
+ points.push(vec3(trail[q], trail[q+1], trail[q+2]));
1116
+ widths.push(s * f);
1117
+ colors.push(color.scale(1, f));
1118
+ }
1119
+ r.drawRibbon(points, widths, this.tileInfo, colors);
1120
+ }
1121
+ else if (instanced)
1122
+ {
1123
+ // the quad's half axes, turned as drawBillboard turns them, doubled for billboardMesh's unit square
1124
+ const a = render3DBillboardAxes(size, angle, false), k = render3DInstanceSlot(quad, textureInfo);
1125
+ data = quad.instanceData;
1126
+ data[k] = a[0] * 2; data[k+1] = a[1] * 2; data[k+2] = a[2] * 2; data[k+3] = 0;
1127
+ data[k+4] = a[3] * 2; data[k+5] = a[4] * 2; data[k+6] = a[5] * 2; data[k+7] = 0;
1128
+ data[k+8] = cb.x; data[k+9] = cb.y; data[k+10] = cb.z; data[k+11] = 0;
1129
+ data[k+12] = particles[p]; data[k+13] = particles[p+1]; data[k+14] = particles[p+2]; data[k+15] = 1;
1130
+ data[k+16] = color.r, data[k+17] = color.g, data[k+18] = color.b, data[k+19] = color.a;
1131
+ data[k+20] = uv.x; data[k+21] = uv.y; data[k+22] = uv.w; data[k+23] = uv.h;
1132
+ }
1133
+ else
1134
+ {
1135
+ pos.x = particles[p], pos.y = particles[p+1], pos.z = particles[p+2];
1136
+ if (texture)
1137
+ r.drawBillboard(pos, size, texture, color, angle);
1138
+ else
1139
+ r.drawSoftDisc(pos, s, color, undefined, 8); // no canvas for the dot, headless
1140
+ }
1141
+ }
1142
+ }
1143
+ finally
1144
+ {
1145
+ if (instanced)
1146
+ {
1147
+ r.lighting = lit;
1148
+ r.flush(); // whatever the stream holds from before this emitter draws first
1149
+ render3DFlushInstances(quad);
1150
+ }
1151
+ }
1152
+ }
1153
+ }
1154
+
1155
+ ///////////////////////////////////////////////////////////////////////////////
1156
+ /**
1157
+ * Trail3D - A ribbon through where the object has been, thinning and fading with age
1158
+ * - Records its world position each frame it moves, so parent it to something that moves or set pos3D yourself
1159
+ * - The samples are world space, so width is a world width and scale3D does nothing to the ribbon
1160
+ * - Drawn unlit in the transparent stage, dies down on its own once the object stops
1161
+ * @extends EngineObject3D
1162
+ * @memberof Render3D
1163
+ * @example
1164
+ * const trail = new Trail3D(vec3(), 1, .3, undefined, hsl(.08, 1, .5), hsl(0, 1, .5, 0), true);
1165
+ * ball.addChild(trail); // follows the ball
1166
+ */
1167
+ class Trail3D extends EngineObject3D
1168
+ {
1169
+ /** Create a trail
1170
+ * @param {Vector3} [pos3D]
1171
+ * @param {number} [lifeTime] - Seconds the ribbon takes to thin and fade from head to tail,
1172
+ * Infinity keeps every sample at full width and never drops one, so it grows as long as the object moves
1173
+ * @param {number} [width] - Width at the head, it thins to nothing at the tail
1174
+ * @param {TileInfo|TextureInfo} [tileInfo] - Tile or whole texture stretched along the trail, undefined is untextured
1175
+ * @param {Color} [color] - Color at the head
1176
+ * @param {Color} [colorEnd] - Color at the tail
1177
+ * @param {boolean} [additive] - Additive blending */
1178
+ constructor(pos3D=vec3(), lifeTime=1, width=.2, tileInfo, color=WHITE, colorEnd=CLEAR_WHITE, additive=false)
1179
+ {
1180
+ super(pos3D, undefined, tileInfo, color);
1181
+ this.transparent = true;
1182
+ this.additive = additive;
1183
+ this.castShadow = false;
1184
+ this.size3D = vec3(); // not a solid thing to pick or collect
1185
+ this.finishing = false; // set by destroy, the ribbon fades out then goes away
1186
+
1187
+ /** @property {number} - Seconds the ribbon takes to thin and fade from head to tail, Infinity never drops a sample */
1188
+ this.lifeTime = lifeTime;
1189
+ /** @property {number} - Width at the head */
1190
+ this.width = width;
1191
+ /** @property {Color} - Color at the tail */
1192
+ this.colorEnd = colorEnd.copy();
1193
+ /** @property {Vector3|undefined} - Direction across the ribbon, recorded with each sample, undefined faces the camera
1194
+ * @type {Vector3|undefined} */
1195
+ this.side = undefined;
1196
+ /** @property {Array<{pos: Vector3, side: Vector3|undefined, time: number}>} - Recorded samples, oldest first
1197
+ * @type {Array<{pos: Vector3, side: Vector3|undefined, time: number}>} */
1198
+ this.samples = [];
1199
+ }
1200
+
1201
+ /** Forget the trail so far, for when the object teleports */
1202
+ clear() { this.samples.length = 0; }
1203
+
1204
+ /** Stop recording, and go away once the ribbon has faded
1205
+ * @param {boolean} [immediate] */
1206
+ destroy(immediate)
1207
+ {
1208
+ if (immediate || !this.samples.length || this.destroyed || this.lifeTime == Infinity)
1209
+ return super.destroy(immediate);
1210
+ this.finishing = true;
1211
+ render3DDetach(this); // the samples are in world space, they no longer need the parent
1212
+ }
1213
+
1214
+ /** Record the position when it moved and drop old samples, called automatically each frame */
1215
+ update()
1216
+ {
1217
+ const samples = this.samples;
1218
+ if (!this.finishing)
1219
+ {
1220
+ const pos = this.worldPos3D = this.getWorldPos3D(), last = samples[samples.length - 1];
1221
+ if (!last || pos.distanceSquared(last.pos) > 1e-8)
1222
+ samples.push({pos, side: this.side?.copy(), time});
1223
+ }
1224
+ while (samples.length && time - samples[0].time > this.lifeTime)
1225
+ samples.shift();
1226
+ this.finishing && !samples.length && this.destroy();
1227
+ }
1228
+
1229
+ /** Draw the ribbon
1230
+ * @return {void} */
1231
+ render3D()
1232
+ {
1233
+ const samples = this.samples;
1234
+ if (samples.length < 2) return;
1235
+ const points = [], widths = [], colors = [], sides = this.side ? [] : undefined;
1236
+ for (const s of samples)
1237
+ {
1238
+ const age = clamp((time - s.time) / this.lifeTime);
1239
+ points.push(s.pos);
1240
+ widths.push(this.width * (1 - age));
1241
+ colors.push(this.color.lerp(this.colorEnd, age));
1242
+ sides?.push(s.side);
1243
+ }
1244
+ render3D.drawRibbon(points, widths, this.tileInfo, colors, sides);
1245
+ }
1246
+ }
1247
+
1248
+ ///////////////////////////////////////////////////////////////////////////////
1249
+ // OBJ meshes
1250
+
1251
+ /**
1252
+ * Parse Wavefront OBJ text into a Mesh
1253
+ * - Reads v, vt, vn and f lines with convex polygons of any size, materials and groups are ignored
1254
+ * - Normals come from the file when every corner of a face has one, otherwise from the face
1255
+ * - Use mesh.center() and mesh.fit(size) to bring a model of unknown units to the origin
1256
+ * - Back faces are skipped like any mesh, set doubleSided for a model with open walls or single sided parts
1257
+ * @param {string} text
1258
+ * @param {boolean} [smooth] - Compute smooth normals for the faces the file gives none, defaults to render3D.smoothShading
1259
+ * @return {Mesh}
1260
+ * @memberof Render3D
1261
+ * @example
1262
+ * new EngineObject3D(vec3(), parseOBJ(objText).center().fit(4));
1263
+ */
1264
+ function parseOBJ(text, smooth=render3D?.smoothShading)
1265
+ {
1266
+ const positions = [], normals = [], uvs = [];
1267
+ const points = [], vertexNormals = [], vertexUVs = [], indices = [], seen = new Map;
1268
+ const fromFile = []; // which vertices have a normal from the file, the rest are smoothed when smooth is on
1269
+ let missingNormals = false, face = 0;
1270
+
1271
+ // OBJ indices count from 1, and a negative one counts back from the end of the list so far
1272
+ const index = (s, list)=> { const i = parseInt(s); return i < 0 ? list.length + i : i - 1; };
1273
+ const lookup = (s, list)=> list[index(s, list)];
1274
+ for (const line of text.split('\n'))
1275
+ {
1276
+ const parts = line.trim().split(/\s+/);
1277
+ switch (parts[0])
1278
+ {
1279
+ case 'v': positions.push(vec3(+parts[1], +parts[2], +parts[3])); break;
1280
+ case 'vn': normals.push(vec3(+parts[1], +parts[2], +parts[3])); break;
1281
+ case 'vt': uvs.push(vec2(+parts[1], 1 - (+parts[2] || 0))); break; // v runs up and is 0 left out
1282
+ case 'f':
1283
+ {
1284
+ const corners = parts.slice(1).map(c=> c.split('/'));
1285
+ if (corners.length < 3) break;
1286
+ const facePoints = corners.map(c=> lookup(c[0], positions));
1287
+ ASSERT(facePoints.every(isVector3), 'OBJ face uses a vertex index the file does not have', line);
1288
+ const hasNormals = corners.every(c=> c[2]);
1289
+ missingNormals ||= !hasNormals;
1290
+ const faceNormal = hasNormals ? undefined : render3DFaceNormal(facePoints[0], facePoints[1], facePoints[2], facePoints[3]);
1291
+ // a corner is one vertex with the same position, uv and normal; without file normals the face's own
1292
+ // normal keeps its corners apart, unless they will be smoothed, when the position and uv are enough
1293
+ const ids = corners.map((c, i)=>
1294
+ {
1295
+ // by the vertices they are, a negative index means another vertex as the lists grow
1296
+ const key = index(c[0], positions) + '/' + (c[1] ? index(c[1], uvs) : '') + '/' +
1297
+ (hasNormals ? index(c[2], normals) : smooth ? '' : 'f' + face);
1298
+ let id = seen.get(key);
1299
+ if (id === undefined)
1300
+ {
1301
+ seen.set(key, id = points.length);
1302
+ points.push(facePoints[i]);
1303
+ vertexUVs.push(c[1] ? lookup(c[1], uvs) : RENDER3D_DEFAULT_UV);
1304
+ vertexNormals.push(hasNormals ? lookup(c[2], normals) : faceNormal);
1305
+ fromFile.push(hasNormals);
1306
+ }
1307
+ return id;
1308
+ });
1309
+ // a fan around the second corner, counter clockwise as the file lists them; that cuts a quad along
1310
+ // the same diagonal the strip form did, so a smoothed model shades the same as before
1311
+ for (let k = 2; k < ids.length; ++k)
1312
+ indices.push(ids[1], ids[k], ids[(k + 1) % ids.length]);
1313
+ ++face;
1314
+ }
1315
+ }
1316
+ }
1317
+ const mesh = new Mesh().addTriangles(points, indices, vertexNormals, vertexUVs);
1318
+ if (missingNormals && smooth)
1319
+ {
1320
+ // smooth the faces the file gave no normals, and keep the normals it did give
1321
+ const given = mesh.normals;
1322
+ mesh.computeNormals(true);
1323
+ fromFile.forEach((f, i)=> f && (mesh.normals[i] = given[i]));
1324
+ }
1325
+ return mesh;
1326
+ }
1327
+
1328
+ /**
1329
+ * Fetch and parse an OBJ file
1330
+ * @param {string} url
1331
+ * @param {boolean} [smooth] - Compute smooth normals when the file has none, defaults to render3D.smoothShading
1332
+ * @return {Promise<Mesh>}
1333
+ * @memberof Render3D
1334
+ * @example
1335
+ * const mesh = await loadOBJ('ship.obj'); // in an async gameInit
1336
+ */
1337
+ async function loadOBJ(url, smooth=render3D?.smoothShading)
1338
+ {
1339
+ const response = await fetch(url);
1340
+ if (!response.ok)
1341
+ throw new Error('loadOBJ failed: ' + url);
1342
+ return parseOBJ(await response.text(), smooth);
1343
+ }
1344
+
1345
+ ///////////////////////////////////////////////////////////////////////////////
1346
+ // CSG: union, subtract and intersect of closed meshes with BSP trees, after Evan Wallace's csg.js (MIT license);
1347
+ // the trees are walked with stacks, so a deep one can not overflow the call stack
1348
+
1349
+ const RENDER3D_CSG_EPSILON = 1e-5; // closer than this to a plane counts as on it
1350
+
1351
+ // a vertex of a CSG polygon, its values lerped where a cut splits an edge
1352
+ class Render3DCSGVertex
1353
+ {
1354
+ constructor(pos, normal, uv, color) { this.pos = pos; this.normal = normal; this.uv = uv; this.color = color; }
1355
+ lerp(v, t)
1356
+ { return new Render3DCSGVertex(this.pos.lerp(v.pos, t), this.normal.lerp(v.normal, t), this.uv.lerp(v.uv, t), this.color.lerp(v.color, t)); }
1357
+ flipped() { return new Render3DCSGVertex(this.pos, this.normal.scale(-1), this.uv, this.color); }
1358
+ }
1359
+
1360
+ // a convex polygon, its vertices counter clockwise from the front, and the plane it lies on
1361
+ class Render3DCSGPolygon
1362
+ {
1363
+ constructor(vertices, plane) { this.vertices = vertices; this.plane = plane; }
1364
+ flipped()
1365
+ {
1366
+ const plane = {normal: this.plane.normal.scale(-1), w: -this.plane.w};
1367
+ return new Render3DCSGPolygon(this.vertices.map(v=> v.flipped()).reverse(), plane);
1368
+ }
1369
+ }
1370
+
1371
+ // a node of a BSP tree: a plane, the polygons that lie on it, and the trees in front of and behind it
1372
+ class Render3DCSGNode
1373
+ {
1374
+ constructor()
1375
+ {
1376
+ this.plane = undefined;
1377
+ this.front = undefined;
1378
+ this.back = undefined;
1379
+ this.polygons = [];
1380
+ }
1381
+ }
1382
+
1383
+ // the plane through three points, facing the side they run counter clockwise from, undefined when they are in a
1384
+ // line, or so nearly that the plane would lean any way
1385
+ function render3DCSGPlane(a, b, c)
1386
+ {
1387
+ const n = b.subtract(a).cross(c.subtract(a));
1388
+ if (n.lengthSquared() < 1e-18) return;
1389
+ const normal = n.normalize();
1390
+ return {normal, w: normal.dot(a)};
1391
+ }
1392
+
1393
+ // sort a polygon against a plane into the lists, cutting it in two when it spans the plane
1394
+ function render3DCSGSplit(plane, polygon, coplanarFront, coplanarBack, front, back)
1395
+ {
1396
+ const COPLANAR = 0, FRONT = 1, BACK = 2, SPANNING = 3;
1397
+ const {normal, w} = plane, vertices = polygon.vertices, types = [];
1398
+ let polygonType = COPLANAR;
1399
+ for (const v of vertices)
1400
+ {
1401
+ const t = normal.dot(v.pos) - w;
1402
+ const type = t < -RENDER3D_CSG_EPSILON ? BACK : t > RENDER3D_CSG_EPSILON ? FRONT : COPLANAR;
1403
+ polygonType |= type;
1404
+ types.push(type);
1405
+ }
1406
+ if (polygonType === COPLANAR)
1407
+ (normal.dot(polygon.plane.normal) > 0 ? coplanarFront : coplanarBack).push(polygon);
1408
+ else if (polygonType === FRONT)
1409
+ front.push(polygon);
1410
+ else if (polygonType === BACK)
1411
+ back.push(polygon);
1412
+ else
1413
+ {
1414
+ const f = [], b = [];
1415
+ for (let i = 0; i < vertices.length; ++i)
1416
+ {
1417
+ const j = (i + 1) % vertices.length, ti = types[i], tj = types[j], vi = vertices[i], vj = vertices[j];
1418
+ if (ti !== BACK) f.push(vi);
1419
+ if (ti !== FRONT) b.push(vi);
1420
+ if ((ti | tj) === SPANNING)
1421
+ {
1422
+ const v = vi.lerp(vj, (w - normal.dot(vi.pos)) / normal.dot(vj.pos.subtract(vi.pos)));
1423
+ f.push(v);
1424
+ b.push(v);
1425
+ }
1426
+ }
1427
+ f.length >= 3 && front.push(new Render3DCSGPolygon(f, polygon.plane));
1428
+ b.length >= 3 && back.push(new Render3DCSGPolygon(b, polygon.plane));
1429
+ }
1430
+ }
1431
+
1432
+ // every node of a tree
1433
+ function render3DCSGNodes(root)
1434
+ {
1435
+ const nodes = [], stack = [root];
1436
+ while (stack.length)
1437
+ {
1438
+ const node = stack.pop();
1439
+ nodes.push(node);
1440
+ node.front && stack.push(node.front);
1441
+ node.back && stack.push(node.back);
1442
+ }
1443
+ return nodes;
1444
+ }
1445
+
1446
+ // add polygons to a tree, each node splitting what reaches it by its plane
1447
+ function render3DCSGBuild(root, polygons)
1448
+ {
1449
+ const stack = [[root, polygons]];
1450
+ while (stack.length)
1451
+ {
1452
+ const [node, list] = stack.pop();
1453
+ if (!list.length) continue;
1454
+ node.plane ||= list[0].plane;
1455
+ const front = [], back = [];
1456
+ for (const polygon of list)
1457
+ render3DCSGSplit(node.plane, polygon, node.polygons, node.polygons, front, back);
1458
+ front.length && stack.push([node.front ||= new Render3DCSGNode, front]);
1459
+ back.length && stack.push([node.back ||= new Render3DCSGNode, back]);
1460
+ }
1461
+ }
1462
+
1463
+ // turn the solid a tree bounds inside out, its planes facing the other way
1464
+ function render3DCSGInvert(root)
1465
+ {
1466
+ for (const node of render3DCSGNodes(root))
1467
+ {
1468
+ node.plane &&= {normal: node.plane.normal.scale(-1), w: -node.plane.w};
1469
+ [node.front, node.back] = [node.back, node.front];
1470
+ }
1471
+ }
1472
+
1473
+ // the parts of the polygons outside the solid a tree bounds
1474
+ function render3DCSGClipPolygons(root, polygons)
1475
+ {
1476
+ const result = [], stack = [[root, polygons]];
1477
+ while (stack.length)
1478
+ {
1479
+ const [node, list] = stack.pop();
1480
+ if (!node.plane)
1481
+ {
1482
+ for (const polygon of list) result.push(polygon);
1483
+ continue;
1484
+ }
1485
+ const front = [], back = [];
1486
+ for (const polygon of list)
1487
+ render3DCSGSplit(node.plane, polygon, front, back, front, back);
1488
+ if (node.front)
1489
+ stack.push([node.front, front]);
1490
+ else
1491
+ for (const polygon of front) result.push(polygon);
1492
+ node.back && stack.push([node.back, back]); // behind a leaf is inside the solid, and goes
1493
+ }
1494
+ return result;
1495
+ }
1496
+
1497
+ // the polygons split into the parts inside the box around the other polygons and the parts beyond it, a polygon
1498
+ // that reaches into the box cut at its faces, so the cuts made inside it stay inside it; a part on a face, within
1499
+ // the epsilon, counts as inside
1500
+ function render3DCSGNear(polygons, polygonsOther)
1501
+ {
1502
+ const lo = vec3(Infinity), hi = vec3(-Infinity);
1503
+ for (const polygon of polygonsOther)
1504
+ for (const {pos} of polygon.vertices)
1505
+ {
1506
+ lo.x = min(lo.x, pos.x); lo.y = min(lo.y, pos.y); lo.z = min(lo.z, pos.z);
1507
+ hi.x = max(hi.x, pos.x); hi.y = max(hi.y, pos.y); hi.z = max(hi.z, pos.z);
1508
+ }
1509
+ // the box's faces as planes facing out, a part in front of any of them is beyond it
1510
+ const near = [], far = [];
1511
+ const faces = [[vec3(1, 0, 0), hi.x], [vec3(-1, 0, 0), -lo.x], [vec3(0, 1, 0), hi.y],
1512
+ [vec3(0, -1, 0), -lo.y], [vec3(0, 0, 1), hi.z], [vec3(0, 0, -1), -lo.z]];
1513
+ for (const polygon of polygons)
1514
+ {
1515
+ let inside = [polygon];
1516
+ for (const [normal, w] of faces)
1517
+ {
1518
+ const next = [];
1519
+ for (const part of inside)
1520
+ render3DCSGSplit({normal, w}, part, next, next, far, next);
1521
+ inside = next;
1522
+ }
1523
+ for (const part of inside) near.push(part);
1524
+ }
1525
+ return [near, far];
1526
+ }
1527
+
1528
+ // a function giving a point the first point it was given within distance of it, or the point itself when there is
1529
+ // none, through a grid of cells that size, so points closer than distance come out as one
1530
+ function render3DCSGPlacer(distance)
1531
+ {
1532
+ const cells = new Map, cellKey = (x, y, z)=> x + ',' + y + ',' + z;
1533
+ return (p)=>
1534
+ {
1535
+ const cx = floor(p.x / distance), cy = floor(p.y / distance), cz = floor(p.z / distance);
1536
+ for (let x = cx - 1; x <= cx + 1; ++x)
1537
+ for (let y = cy - 1; y <= cy + 1; ++y)
1538
+ for (let z = cz - 1; z <= cz + 1; ++z)
1539
+ for (const q of cells.get(cellKey(x, y, z)) || [])
1540
+ if (q.distanceSquared(p) < distance ** 2)
1541
+ return q;
1542
+ const k = cellKey(cx, cy, cz);
1543
+ cells.has(k) || cells.set(k, []);
1544
+ cells.get(k).push(p);
1545
+ return p;
1546
+ };
1547
+ }
1548
+
1549
+ // whether an indexed mesh is closed around an inside: the areas of a closed surface, each facing out, add up to
1550
+ // nothing, and an opening or a face turned the wrong way leaves an area over; CSG run on its own results can leave
1551
+ // gaps a few epsilons wide, which come to next to nothing, so a sliver of the whole is allowed
1552
+ function render3DMeshIsClosed(mesh)
1553
+ {
1554
+ const points = mesh.points, indices = mesh.indices;
1555
+ let x = 0, y = 0, z = 0, total = 0;
1556
+ for (let t = 0; t < indices.length; t += 3)
1557
+ {
1558
+ const a = points[indices[t]], n = points[indices[t + 1]].subtract(a).cross(points[indices[t + 2]].subtract(a));
1559
+ x += n.x, y += n.y, z += n.z;
1560
+ total += n.length();
1561
+ }
1562
+ return total > 0 && hypot(x, y, z) <= total * 1e-4;
1563
+ }
1564
+
1565
+ // the triangles of a closed mesh placed by a matrix, as CSG polygons
1566
+ function render3DCSGPolygons(mesh, matrix)
1567
+ {
1568
+ ASSERT(mesh instanceof Mesh, 'CSG takes a Mesh');
1569
+ ASSERT(!mesh.doubleSided, 'CSG needs closed meshes, a doubleSided one like buildGrid has no inside');
1570
+ const list = new Mesh().combine(mesh, matrix).toIndexed();
1571
+ ASSERT(!list.points.length || render3DMeshIsClosed(list), 'CSG needs closed meshes, every edge shared by two triangles');
1572
+ const vertex = (i)=> new Render3DCSGVertex(list.points[i], list.normals[i], list.uvs[i], list.colors[i]);
1573
+ const polygons = [];
1574
+ for (let t = 0; t < list.indices.length; t += 3)
1575
+ {
1576
+ const i = list.indices[t], j = list.indices[t + 1], k = list.indices[t + 2];
1577
+ const plane = render3DCSGPlane(list.points[i], list.points[j], list.points[k]);
1578
+ plane && polygons.push(new Render3DCSGPolygon([vertex(i), vertex(j), vertex(k)], plane)); // no area, no face
1579
+ }
1580
+ return polygons;
1581
+ }
1582
+
1583
+ // a mesh from CSG polygons: points close together made one, and each edge given the points other polygons have
1584
+ // along it, so neighbors meet at every point with no crack between them; each polygon then as a fan of triangles
1585
+ function render3DCSGMesh(polygons)
1586
+ {
1587
+ // points within the epsilon made one, the first found standing for the rest; moving one any farther would take
1588
+ // it off its plane by more than the epsilon, and the next cut through it would split it again; a point on an
1589
+ // edge, whose ends may each have moved that far, is looked for a few times that far from its line
1590
+ const reach = RENDER3D_CSG_EPSILON * 4, placer = render3DCSGPlacer(RENDER3D_CSG_EPSILON), placed = new Set;
1591
+ const cellKey = (x, y, z)=> x + ',' + y + ',' + z;
1592
+ const place = (p)=>
1593
+ {
1594
+ const q = placer(p);
1595
+ placed.add(q); // polygons share points, so one can be placed more than once
1596
+ return q;
1597
+ };
1598
+ const loops = polygons.map(polygon=>
1599
+ {
1600
+ const loop = [];
1601
+ for (const v of polygon.vertices)
1602
+ {
1603
+ const pos = place(v.pos);
1604
+ if (loop.length && loop[loop.length - 1].pos === pos) continue; // a sliver edge closed up
1605
+ loop.push(new Render3DCSGVertex(pos, v.normal, v.uv, v.color));
1606
+ }
1607
+ loop.length > 1 && loop[0].pos === loop[loop.length - 1].pos && loop.pop();
1608
+ return loop;
1609
+ });
1610
+
1611
+ // the places in a coarser grid, sized so a cell holds a few, to find the ones lying along each edge
1612
+ const places = [...placed];
1613
+ let lo = vec3(Infinity), hi = vec3(-Infinity);
1614
+ for (const p of places)
1615
+ {
1616
+ lo = vec3(min(lo.x, p.x), min(lo.y, p.y), min(lo.z, p.z));
1617
+ hi = vec3(max(hi.x, p.x), max(hi.y, p.y), max(hi.z, p.z));
1618
+ }
1619
+ const span = places.length ? max(hi.x - lo.x, hi.y - lo.y, hi.z - lo.z) : 0;
1620
+ const size = max(span / Math.cbrt(places.length || 1), reach), grid = new Map;
1621
+ const gridKey = (p)=> cellKey(floor((p.x - lo.x) / size), floor((p.y - lo.y) / size), floor((p.z - lo.z) / size));
1622
+ for (const p of places)
1623
+ {
1624
+ const k = gridKey(p);
1625
+ grid.has(k) || grid.set(k, []);
1626
+ grid.get(k).push(p);
1627
+ }
1628
+ const onEdge = (a, b)=>
1629
+ {
1630
+ // the places strictly between a and b, within reach of the line, in order from a
1631
+ const found = [], ab = b.subtract(a), length2 = ab.lengthSquared();
1632
+ const x0 = floor((min(a.x, b.x) - lo.x - reach) / size), x1 = floor((max(a.x, b.x) - lo.x + reach) / size);
1633
+ const y0 = floor((min(a.y, b.y) - lo.y - reach) / size), y1 = floor((max(a.y, b.y) - lo.y + reach) / size);
1634
+ const z0 = floor((min(a.z, b.z) - lo.z - reach) / size), z1 = floor((max(a.z, b.z) - lo.z + reach) / size);
1635
+ for (let x = x0; x <= x1; ++x)
1636
+ for (let y = y0; y <= y1; ++y)
1637
+ for (let z = z0; z <= z1; ++z)
1638
+ for (const p of grid.get(cellKey(x, y, z)) || [])
1639
+ {
1640
+ if (p === a || p === b) continue;
1641
+ const t = p.subtract(a).dot(ab) / length2;
1642
+ if (t <= 0 || t >= 1) continue;
1643
+ if (a.add(ab.scale(t)).distanceSquared(p) < reach ** 2)
1644
+ found.push([t, p]);
1645
+ }
1646
+ return found.sort((f, g)=> f[0] - g[0]);
1647
+ };
1648
+
1649
+ // the mesh, each vertex once: vertices at one place that are the same in every value are shared
1650
+ const mesh = new Mesh, shared = new Map;
1651
+ mesh.indices = [];
1652
+ const vertexIndex = (v)=>
1653
+ {
1654
+ const n = v.normal.lengthSquared() ? v.normal.normalize() : RENDER3D_DEFAULT_NORMAL, c = v.color;
1655
+ const key = [n.x, n.y, n.z, v.uv.x, v.uv.y, c.r, c.g, c.b, c.a].map(x=> round(x * 1e6)).join();
1656
+ let atPlace = shared.get(v.pos), i;
1657
+ atPlace || shared.set(v.pos, atPlace = new Map);
1658
+ if ((i = atPlace.get(key)) === undefined)
1659
+ {
1660
+ atPlace.set(key, i = mesh.points.length);
1661
+ mesh.points.push(v.pos.copy());
1662
+ mesh.normals.push(n);
1663
+ mesh.uvs.push(v.uv.copy());
1664
+ mesh.colors.push(c.copy());
1665
+ }
1666
+ return i;
1667
+ };
1668
+
1669
+ // each loop with the points along its edges put in, as a fan
1670
+ const flat = (a, b, c)=> b.subtract(a).cross(c.subtract(a)).lengthSquared() < 1e-18;
1671
+ for (const loop of loops)
1672
+ {
1673
+ if (loop.length < 3) continue;
1674
+ const full = [];
1675
+ for (let i = 0; i < loop.length; ++i)
1676
+ {
1677
+ const a = loop[i], b = loop[(i + 1) % loop.length];
1678
+ full.push(a);
1679
+ for (const [t, p] of onEdge(a.pos, b.pos))
1680
+ {
1681
+ const v = a.lerp(b, t);
1682
+ full.push(new Render3DCSGVertex(p, v.normal, v.uv, v.color));
1683
+ }
1684
+ }
1685
+ // a polygon with no area, its points in a line, is no surface, and its neighbors have its points along
1686
+ // their edges, so it goes
1687
+ const n = full.length, pos = (i)=> full[i % n].pos;
1688
+ let area = vec3();
1689
+ for (let j = 1; j < n - 1; ++j)
1690
+ area = area.add(pos(j).subtract(pos(0)).cross(pos(j + 1).subtract(pos(0))));
1691
+ if (area.lengthSquared() < 1e-18) continue;
1692
+
1693
+ // cut off one corner at a time, one that makes a triangle with some area with its neighbors and leaves the
1694
+ // rest some area too, so the points put in along the sides make no flat triangles; areas add, so what is
1695
+ // left is the whole less the corner
1696
+ const ring = full.map(v=> ({pos: v.pos, id: vertexIndex(v)}));
1697
+ const earArea = (i)=>
1698
+ {
1699
+ const a = ring[(i + ring.length - 1) % ring.length].pos, b = ring[i].pos, c = ring[(i + 1) % ring.length].pos;
1700
+ return b.subtract(a).cross(c.subtract(a));
1701
+ };
1702
+ while (ring.length > 3)
1703
+ {
1704
+ let i = ring.findIndex((_, i)=>
1705
+ {
1706
+ const ear = earArea(i);
1707
+ return ear.lengthSquared() >= 1e-18 && area.subtract(ear).lengthSquared() >= 1e-18;
1708
+ });
1709
+ i < 0 && (i = 0); // no corner leaves area, a sliver, cut off the first
1710
+ area = area.subtract(earArea(i));
1711
+ mesh.indices.push(ring[(i + ring.length - 1) % ring.length].id, ring[i].id, ring[(i + 1) % ring.length].id);
1712
+ ring.splice(i, 1);
1713
+ }
1714
+ mesh.indices.push(ring[0].id, ring[1].id, ring[2].id);
1715
+ }
1716
+ mesh.dirty = true;
1717
+ return mesh;
1718
+ }
1719
+
1720
+ // a new mesh of a and b placed by matrix: 0 their union, 1 a with b cut out, 2 what is in both
1721
+ // - csg.js's steps with the polygons kept in lists: the trees only tell inside from outside, and only the polygons
1722
+ // that reach into the other mesh's box are cut, one beyond it is all outside and is kept or dropped whole, so a
1723
+ // shape cut again and again only splits where each cut is
1724
+ // - the clip twice through the same tree, with a flip between, drops the one of two coplanar faces that would
1725
+ // double up
1726
+ function render3DMeshCSG(a, b, matrix, operation)
1727
+ {
1728
+ const polygonsA = render3DCSGPolygons(a), polygonsB = render3DCSGPolygons(b, matrix);
1729
+ const [nearA, farA] = render3DCSGNear(polygonsA, polygonsB), [nearB, farB] = render3DCSGNear(polygonsB, polygonsA);
1730
+ const treeA = new Render3DCSGNode, treeB = new Render3DCSGNode;
1731
+ render3DCSGBuild(treeA, polygonsA);
1732
+ render3DCSGBuild(treeB, polygonsB);
1733
+ const clip = render3DCSGClipPolygons, flip = (list)=> list.map(polygon=> polygon.flipped());
1734
+ let polygons;
1735
+ if (operation === 0)
1736
+ {
1737
+ // each outside the other
1738
+ polygons = [...farA, ...clip(treeB, nearA), ...farB, ...flip(clip(treeA, flip(clip(treeA, nearB))))];
1739
+ }
1740
+ else if (operation === 1)
1741
+ {
1742
+ // a outside b, and b inside a turned to face out
1743
+ render3DCSGInvert(treeA);
1744
+ polygons = [...farA, ...flip(clip(treeB, flip(nearA))), ...clip(treeA, flip(clip(treeA, nearB)))];
1745
+ }
1746
+ else
1747
+ {
1748
+ // each inside the other
1749
+ render3DCSGInvert(treeA);
1750
+ render3DCSGInvert(treeB);
1751
+ polygons = [...flip(clip(treeB, flip(nearA))), ...flip(clip(treeA, flip(clip(treeA, nearB))))];
1752
+ }
1753
+ return render3DCSGMesh(polygons);
1754
+ }