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.
- package/FAQ.md +841 -820
- package/README.md +29 -25
- package/dist/littlejs.d.ts +2857 -955
- package/dist/littlejs.esm.js +33428 -20995
- package/dist/littlejs.esm.min.js +4 -3
- package/dist/littlejs.js +33386 -20996
- package/dist/littlejs.min.js +2 -2
- package/dist/littlejs.release.js +27841 -20422
- package/package.json +10 -5
- package/plugins/audioEffects.js +28 -24
- package/plugins/box2d.js +2516 -2073
- package/plugins/drawUtilities.js +206 -31
- package/plugins/gltf.js +786 -0
- package/plugins/jsconfig.json +19 -0
- package/plugins/lightSystem.js +719 -332
- package/plugins/math3d.js +148 -27
- package/plugins/medalSystem.js +326 -280
- package/plugins/newgrounds.js +400 -199
- package/plugins/pathFinder.js +208 -175
- package/plugins/pluginExport.js +25 -0
- package/plugins/postProcess.js +96 -24
- package/plugins/render3d.js +2067 -1632
- package/plugins/render3dDebug.js +110 -0
- package/plugins/render3dEditor.js +1812 -0
- package/plugins/render3dExtras.js +1948 -0
- package/plugins/render3dLevel.js +189 -0
- package/plugins/render3dVoxels.js +514 -0
- package/plugins/sceneSystem.js +58 -0
- package/plugins/textureSheet.js +496 -458
- package/plugins/threejs.js +183 -163
- package/plugins/tweakables.js +411 -0
- package/plugins/tweenSystem.js +265 -145
- package/plugins/uiSystem.js +2024 -1687
- package/plugins/zzfxm.js +159 -159
- package/src/engine.js +378 -97
- package/src/engineAudio.js +231 -127
- package/src/engineBuild.mjs +292 -256
- package/src/engineDebug.js +967 -743
- package/src/engineDraw.js +391 -198
- package/src/engineEditor.js +2452 -0
- package/src/engineExport.js +18 -0
- package/src/engineInput.js +1466 -1293
- package/src/engineLogo.js +19 -0
- package/src/engineMath.js +1274 -1252
- package/src/engineObject.js +197 -82
- package/src/engineParticles.js +583 -532
- package/src/engineRelease.js +79 -45
- package/src/engineSettings.js +115 -29
- package/src/engineTileLayer.js +995 -741
- package/src/engineUtilities.js +19 -13
- package/src/engineWebGL.js +239 -82
- package/src/jsconfig.json +17 -13
- package/plugins/box2d.wasm.js +0 -630
- 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
|
+
}
|