littlejsengine 1.19.3 → 1.21.0

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