littlejsengine 1.18.28 → 1.19.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/FAQ.md +5 -0
- package/README.md +207 -199
- package/dist/littlejs.d.ts +2099 -48
- package/dist/littlejs.esm.js +16251 -10315
- package/dist/littlejs.esm.min.js +13 -1
- package/dist/littlejs.js +15747 -9888
- package/dist/littlejs.min.js +13 -1
- package/dist/littlejs.release.js +15719 -9867
- package/package.json +1 -1
- package/plugins/audioEffects.js +371 -0
- package/plugins/lightSystem.js +5 -3
- package/plugins/math3d.js +870 -0
- package/plugins/medalSystem.js +280 -280
- package/plugins/pluginExport.js +181 -115
- package/plugins/postProcess.js +241 -166
- package/plugins/render3d.js +4006 -0
- package/plugins/textureSheet.js +458 -458
- package/plugins/threejs.js +6 -1
- package/plugins/tweenSystem.js +528 -526
- package/plugins/zzfxm.js +159 -166
- package/src/engine.js +172 -137
- package/src/engineAudio.js +918 -775
- package/src/engineBuild.mjs +3 -0
- package/src/engineDebug.js +15 -8
- package/src/engineDraw.js +1672 -1510
- package/src/engineExport.js +407 -396
- package/src/engineInput.js +1 -1
- package/src/engineLogo.js +4 -3
- package/src/engineMath.js +69 -0
- package/src/engineObject.js +560 -551
- package/src/engineSettings.js +759 -725
- package/src/engineTileLayer.js +3 -3
- package/src/engineUtilities.js +13 -0
- package/src/engineWebGL.js +1005 -941
|
@@ -0,0 +1,4006 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LittleJS 3D Rendering Plugin
|
|
3
|
+
* - Adds a 3D scene that draws into the same WebGL canvas as the 2D game
|
|
4
|
+
* - Call new Render3DPlugin() in gameInit, then move render3D.camera and make EngineObject3D objects
|
|
5
|
+
* - EngineObject3D is an EngineObject with a 3D position, rotation and mesh
|
|
6
|
+
* - The 3D scene draws under the 2D sprites, so HUD and text land on top
|
|
7
|
+
* - Lighting is the sun plus ambient, with optional extra lights, fog and shadows
|
|
8
|
+
* - Any object or draw can bring its own Shader, a mainImage snippet the lighting then applies to
|
|
9
|
+
* - Build shapes with buildBox, buildSphere and friends, or load a model with loadOBJ
|
|
10
|
+
* - Requires the Math3D plugin
|
|
11
|
+
* @namespace Render3D
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
17
|
+
|
|
18
|
+
/** Global Render3D plugin object
|
|
19
|
+
* @type {Render3DPlugin}
|
|
20
|
+
* @memberof Render3D */
|
|
21
|
+
let render3D;
|
|
22
|
+
|
|
23
|
+
// vertex format: position xyz, normal xyz, uv, rgba bytes
|
|
24
|
+
const RENDER3D_VERTEX_FLOATS = 9;
|
|
25
|
+
const RENDER3D_VERTEX_BYTES = RENDER3D_VERTEX_FLOATS * 4;
|
|
26
|
+
|
|
27
|
+
// per draw values the shaders read as vertex attributes: the model matrix columns (4-7), the normal matrix columns
|
|
28
|
+
// (8-10), the tint (11) and the uv rect (12); constants for one draw, one per instance for a batch
|
|
29
|
+
const RENDER3D_INSTANCE_FLOATS = 33;
|
|
30
|
+
const RENDER3D_INSTANCE_BYTES = RENDER3D_INSTANCE_FLOATS * 4;
|
|
31
|
+
const RENDER3D_INSTANCE_ATTRIBS = [[4, 4, 0], [5, 4, 16], [6, 4, 32], [7, 4, 48], [8, 3, 64], [9, 3, 76], [10, 3, 88], [11, 4, 100], [12, 4, 116]];
|
|
32
|
+
const RENDER3D_VERTEX_INPUTS =
|
|
33
|
+
'layout(location=0) in vec3 p;layout(location=1) in vec3 n;layout(location=2) in vec2 t;layout(location=3) in vec4 c;' +
|
|
34
|
+
'layout(location=4) in vec4 m0;layout(location=5) in vec4 m1;layout(location=6) in vec4 m2;layout(location=7) in vec4 m3;' +
|
|
35
|
+
'layout(location=8) in vec3 n0;layout(location=9) in vec3 n1;layout(location=10) in vec3 n2;' +
|
|
36
|
+
'layout(location=11) in vec4 tint;layout(location=12) in vec4 uvRect;';
|
|
37
|
+
const RENDER3D_MAX_STREAM_VERTS = 32768;
|
|
38
|
+
const RENDER3D_MAX_LIGHTS = 8; // Light3D objects per frame, the shader loops over this many
|
|
39
|
+
const RENDER3D_QUAD_UVS = Object.freeze([vec2(0, 0), vec2(0, 1), vec2(1, 0), vec2(1, 1)].map(uv=> Object.freeze(uv))); // strip order
|
|
40
|
+
const RENDER3D_FULL_UV_RECT = Object.freeze({x:0, y:0, w:1, h:1});
|
|
41
|
+
const RENDER3D_DEFAULT_NORMAL = Object.freeze(vec3(0, 1, 0));
|
|
42
|
+
const RENDER3D_DEFAULT_UV = Object.freeze(vec2());
|
|
43
|
+
const RENDER3D_SHADOW_COLOR = Object.freeze(hsl(0, 0, 0, .5));
|
|
44
|
+
const RENDER3D_IDENTITY = new Matrix4; // never modified
|
|
45
|
+
const RENDER3D_DEBUG_WIDTH = .05; // line width of the debug primitives
|
|
46
|
+
// gap between lines of 3D text, as a share of the character height; flat text can let lines touch
|
|
47
|
+
// the way the 2D font does, but extruded glyphs seen from an angle then overlap the line below
|
|
48
|
+
const RENDER3D_TEXT_LEADING = 1.3;
|
|
49
|
+
|
|
50
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
51
|
+
// Private helpers
|
|
52
|
+
|
|
53
|
+
// outward normal of a triangle or a quad given its corners in loop order,
|
|
54
|
+
// from the diagonals so a collapsed corner still works
|
|
55
|
+
function render3DFaceNormal(a, b, c, d=a)
|
|
56
|
+
{
|
|
57
|
+
const n = c.subtract(a).cross(d.subtract(b));
|
|
58
|
+
return n.lengthSquared() ? n.normalize() : RENDER3D_DEFAULT_NORMAL;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// a quad's corners in loop order as a strip, the one place that knows the order
|
|
62
|
+
function render3DQuadStrip(a, b, c, d) { return [a, b, d, c]; }
|
|
63
|
+
|
|
64
|
+
// per corner values (colors, uvs) into strip order, a single value passes through
|
|
65
|
+
function render3DQuadValues(v) { return isArray(v) ? render3DQuadStrip(...v) : v; }
|
|
66
|
+
|
|
67
|
+
// 3D draws are only valid during the pass with a live shader
|
|
68
|
+
function render3DCanDraw()
|
|
69
|
+
{
|
|
70
|
+
if (!render3D.program) return false;
|
|
71
|
+
ASSERT(render3D.isRendering, '3D draws are only valid during the 3D pass, draw from an EngineObject3D or render3D.onRenderOpaque');
|
|
72
|
+
return render3D.isRendering;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// the draw state fields a batch is drawn under; lights and fog are not captured, they are read live at flush
|
|
76
|
+
const RENDER3D_STATE_FIELDS = ['blend', 'additive', 'depthTest', 'depthWrite', 'cullBackFaces', 'mirrored', 'lighting', 'emissive', 'receiveShadow', 'specular', 'pixelated', 'shader'];
|
|
77
|
+
|
|
78
|
+
// a copy of the current draw state
|
|
79
|
+
function render3DCaptureBatchState()
|
|
80
|
+
{
|
|
81
|
+
const state = {};
|
|
82
|
+
for (const field of RENDER3D_STATE_FIELDS)
|
|
83
|
+
state[field] = render3D[field];
|
|
84
|
+
return state;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// true when the current draw state differs from a captured one, so a pending batch must flush first
|
|
88
|
+
function render3DStateChanged(state)
|
|
89
|
+
{
|
|
90
|
+
for (const field of RENDER3D_STATE_FIELDS)
|
|
91
|
+
if (render3D[field] !== state[field])
|
|
92
|
+
return true;
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// run a function with some draw state fields overridden, restored afterward even on a throw
|
|
97
|
+
function render3DWithState(fields, fn)
|
|
98
|
+
{
|
|
99
|
+
const r = render3D, saved = {};
|
|
100
|
+
for (const key in fields)
|
|
101
|
+
saved[key] = r[key], r[key] = fields[key];
|
|
102
|
+
try { return fn(); }
|
|
103
|
+
finally { Object.assign(r, saved); }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// which side of the 2D scene an object draws on, its own flag or the plugin default
|
|
107
|
+
function render3DIsAfter2D(o) { return !!(o.renderAfter2D ?? render3D.renderAfter2D); }
|
|
108
|
+
|
|
109
|
+
// a size given as a number or a vec3
|
|
110
|
+
function render3DSize3(size) { return isNumber(size) ? vec3(size) : size; }
|
|
111
|
+
|
|
112
|
+
// a transform given as a matrix, or as a vec3 for one that only moves there
|
|
113
|
+
function render3DMatrix(matrix)
|
|
114
|
+
{
|
|
115
|
+
if (matrix instanceof Vector3)
|
|
116
|
+
return buildMatrix(matrix);
|
|
117
|
+
ASSERT(matrix instanceof Matrix4, 'takes a Matrix4, or a Vector3 for a position');
|
|
118
|
+
return matrix;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// the matrix that keeps normals pointing out when an object is scaled unevenly
|
|
122
|
+
function render3DNormalMatrix(matrix) { return matrix.copy().invert().transpose(); }
|
|
123
|
+
|
|
124
|
+
// a column of a matrix as a direction: 0 is the right axis, 4 up, 8 back
|
|
125
|
+
function render3DAxis(m, i) { return vec3(m[i], m[i+1], m[i+2]); }
|
|
126
|
+
|
|
127
|
+
// surface normal from the slope of a height function, sampled half a cell each way but kept inside the half sizes
|
|
128
|
+
function render3DSlopeNormal(heightFunction, x, z, ex, ez, halfX, halfZ)
|
|
129
|
+
{
|
|
130
|
+
const x0 = max(x - ex, -halfX), x1 = min(x + ex, halfX), z0 = max(z - ez, -halfZ), z1 = min(z + ez, halfZ);
|
|
131
|
+
const dx = (heightFunction(x1, z) - heightFunction(x0, z)) / (x1 - x0 || 1);
|
|
132
|
+
const dz = (heightFunction(x, z1) - heightFunction(x, z0)) / (z1 - z0 || 1);
|
|
133
|
+
return vec3(-dx, 1, -dz).normalize();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// the largest axis scale of a matrix, how much it grows a bounding sphere
|
|
137
|
+
function render3DMaxScale(m)
|
|
138
|
+
{
|
|
139
|
+
return max(m[0]*m[0] + m[1]*m[1] + m[2]*m[2], m[4]*m[4] + m[5]*m[5] + m[6]*m[6], m[8]*m[8] + m[9]*m[9] + m[10]*m[10]) ** .5;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// a quad as a strip from its center and half axes, the same corner order as render3DQuadStrip
|
|
143
|
+
function render3DQuadAxes(center, right, up)
|
|
144
|
+
{
|
|
145
|
+
return [center.subtract(right).add(up), center.subtract(right).subtract(up),
|
|
146
|
+
center.add(right).add(up), center.add(right).subtract(up)];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// set the draw state for an object's render3D, or the defaults for the stage callbacks
|
|
150
|
+
function render3DSetObjectState(o)
|
|
151
|
+
{
|
|
152
|
+
const r = render3D;
|
|
153
|
+
const emissive = o?.emissive || 0;
|
|
154
|
+
ASSERT(isNumber(emissive) && emissive >= 0, 'emissive must be a number, 0 or more', emissive);
|
|
155
|
+
r.lighting = true;
|
|
156
|
+
r.emissive = emissive;
|
|
157
|
+
r.additive = !!o?.additive;
|
|
158
|
+
r.specular = o?.specular || 0;
|
|
159
|
+
r.receiveShadow = !o || o.receiveShadow;
|
|
160
|
+
r.cullBackFaces = r.mirrored = false; // each mesh sets these as it draws
|
|
161
|
+
r.pixelated = !!o?.pixelated;
|
|
162
|
+
ASSERT(!o?.shader || o.shader instanceof Shader, 'shader must be a Shader, not the snippet itself');
|
|
163
|
+
r.shader = o?.shader || undefined; // null is no shader too, so it batches with none
|
|
164
|
+
r.depthTest = true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// draw objects each with the draw state set from its own flags, then reset to the defaults
|
|
168
|
+
function render3DDrawObjects(objects)
|
|
169
|
+
{
|
|
170
|
+
for (const o of objects)
|
|
171
|
+
{
|
|
172
|
+
render3DSetObjectState(o);
|
|
173
|
+
o.render3D();
|
|
174
|
+
}
|
|
175
|
+
render3DSetObjectState();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// let go of the parent but stay where the object was in the world; a destroyed parent has already let go, so the
|
|
179
|
+
// position remembered by the last update stands in
|
|
180
|
+
function render3DDetach(o)
|
|
181
|
+
{
|
|
182
|
+
if (o.parent)
|
|
183
|
+
o.pos3D = o.getWorldPos3D(), o.parent.removeChild(o);
|
|
184
|
+
else if (o.worldPos3D)
|
|
185
|
+
o.pos3D = o.worldPos3D;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// add a draw of a mesh to its batch; a batch is one mesh under one texture and draw state, so a change flushes it
|
|
189
|
+
function render3DInstance(mesh, matrix, tileInfo, color)
|
|
190
|
+
{
|
|
191
|
+
const r = render3D, textureInfo = tileInfo instanceof TileInfo ? tileInfo.textureInfo : tileInfo;
|
|
192
|
+
if (mesh.instanceCount && (mesh.instanceTextureInfo !== textureInfo || render3DStateChanged(mesh.instanceState)))
|
|
193
|
+
render3DFlushInstances(mesh);
|
|
194
|
+
if (!mesh.instanceCount)
|
|
195
|
+
{
|
|
196
|
+
mesh.instanceTextureInfo = textureInfo;
|
|
197
|
+
mesh.instanceState = render3DCaptureBatchState();
|
|
198
|
+
r.instanceMeshes.push(mesh);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// room for one more, doubling as the batch grows
|
|
202
|
+
let data = mesh.instanceData;
|
|
203
|
+
const k = mesh.instanceCount++ * RENDER3D_INSTANCE_FLOATS;
|
|
204
|
+
if (!data || data.length < k + RENDER3D_INSTANCE_FLOATS)
|
|
205
|
+
{
|
|
206
|
+
const grown = new Float32Array(max(64 * RENDER3D_INSTANCE_FLOATS, data ? data.length * 2 : 0));
|
|
207
|
+
data && grown.set(data);
|
|
208
|
+
mesh.instanceData = data = grown;
|
|
209
|
+
}
|
|
210
|
+
data.set(matrix.m, k);
|
|
211
|
+
render3DNormalMatrix3(matrix.m, data, k + 16);
|
|
212
|
+
data[k+25] = color.r; data[k+26] = color.g; data[k+27] = color.b; data[k+28] = color.a;
|
|
213
|
+
const uv = render3DGetTileUVs(tileInfo);
|
|
214
|
+
data[k+29] = uv.x; data[k+30] = uv.y; data[k+31] = uv.w; data[k+32] = uv.h;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// draw the pending batches, or just one mesh's, each as a single instanced call
|
|
218
|
+
function render3DFlushInstances(only)
|
|
219
|
+
{
|
|
220
|
+
const r = render3D, gl = glContext;
|
|
221
|
+
for (const mesh of only ? [only] : r.instanceMeshes)
|
|
222
|
+
{
|
|
223
|
+
const count = mesh.instanceCount;
|
|
224
|
+
mesh.instanceCount = 0;
|
|
225
|
+
if (!count || !mesh.buffer) continue;
|
|
226
|
+
|
|
227
|
+
// the per instance values on top of the constant attributes, then the mesh under them
|
|
228
|
+
// a ring of buffers with fresh storage each time, so the driver never waits for a draw still reading one
|
|
229
|
+
const buffers = r.instanceBuffers;
|
|
230
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, buffers[r.instanceBufferIndex = (r.instanceBufferIndex + 1) % buffers.length]);
|
|
231
|
+
gl.bufferData(gl.ARRAY_BUFFER, mesh.instanceData, gl.DYNAMIC_DRAW, 0, count * RENDER3D_INSTANCE_FLOATS);
|
|
232
|
+
for (const [location, size, offset] of RENDER3D_INSTANCE_ATTRIBS)
|
|
233
|
+
{
|
|
234
|
+
gl.vertexAttribPointer(location, size, gl.FLOAT, false, RENDER3D_INSTANCE_BYTES, offset);
|
|
235
|
+
gl.enableVertexAttribArray(location);
|
|
236
|
+
}
|
|
237
|
+
render3DSetDrawUniforms(RENDER3D_IDENTITY, mesh.instanceTextureInfo, WHITE, RENDER3D_FULL_UV_RECT, mesh.instanceState);
|
|
238
|
+
render3DBindVertexBuffer(mesh.buffer);
|
|
239
|
+
gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, mesh.bufferCount, count);
|
|
240
|
+
for (const [location] of RENDER3D_INSTANCE_ATTRIBS)
|
|
241
|
+
gl.disableVertexAttribArray(location);
|
|
242
|
+
++drawCount;
|
|
243
|
+
primitiveCount += mesh.bufferCount * count;
|
|
244
|
+
}
|
|
245
|
+
if (!only)
|
|
246
|
+
r.instanceMeshes.length = 0;
|
|
247
|
+
else
|
|
248
|
+
{
|
|
249
|
+
const i = r.instanceMeshes.indexOf(only);
|
|
250
|
+
i < 0 || r.instanceMeshes.splice(i, 1);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// forget the pending batches, for a frame that threw or a lost context
|
|
255
|
+
function render3DClearInstances()
|
|
256
|
+
{
|
|
257
|
+
for (const mesh of render3D.instanceMeshes)
|
|
258
|
+
mesh.instanceCount = 0;
|
|
259
|
+
render3D.instanceMeshes.length = 0;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// the live objects drawn on one side of the 2D scene
|
|
263
|
+
function render3DLayerObjects(after2D)
|
|
264
|
+
{ return engineObjects.filter(o=> !o.destroyed && o instanceof EngineObject3D && render3DIsAfter2D(o) === after2D); }
|
|
265
|
+
|
|
266
|
+
// the Light3D objects the shader gets this frame: directional lights light the whole scene so they come first,
|
|
267
|
+
// then the point lights nearest the camera
|
|
268
|
+
function render3DCollectLights()
|
|
269
|
+
{
|
|
270
|
+
// a light switched off by its radius or its alpha is left out, so it cannot take one of the few slots
|
|
271
|
+
const lights = engineObjects.filter(o=> !o.destroyed && o instanceof Light3D &&
|
|
272
|
+
o.color.a > 0 && o.intensity > 0 && (o.directional || o.radius > 0));
|
|
273
|
+
if (lights.length > RENDER3D_MAX_LIGHTS)
|
|
274
|
+
{
|
|
275
|
+
// distances cached once, getWorldPos3D walks the parent chain and the sort asks many times
|
|
276
|
+
const cameraPos = render3D.camera.pos, distances = new Map;
|
|
277
|
+
for (const light of lights)
|
|
278
|
+
distances.set(light, light.directional ? -1 : light.getWorldPos3D().distanceSquared(cameraPos));
|
|
279
|
+
lights.sort((a, b)=> distances.get(a) - distances.get(b));
|
|
280
|
+
lights.length = RENDER3D_MAX_LIGHTS;
|
|
281
|
+
}
|
|
282
|
+
return lights;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// unit circle directions for a number of sides, [cos, sin, cos, sin, ...] including the closing point, cached
|
|
286
|
+
const render3DCircleCache = new Map;
|
|
287
|
+
function render3DCircle(sides)
|
|
288
|
+
{
|
|
289
|
+
sides |= 0;
|
|
290
|
+
let circle = render3DCircleCache.get(sides);
|
|
291
|
+
if (!circle)
|
|
292
|
+
{
|
|
293
|
+
circle = new Float32Array(sides * 2 + 2);
|
|
294
|
+
for (let i = 0; i <= sides; ++i)
|
|
295
|
+
{
|
|
296
|
+
const a = i / sides * 2 * PI;
|
|
297
|
+
circle[i*2] = cos(a), circle[i*2 + 1] = sin(a);
|
|
298
|
+
}
|
|
299
|
+
render3DCircleCache.set(sides, circle);
|
|
300
|
+
}
|
|
301
|
+
return circle;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// a soft white dot for untextured particles, made once from a canvas, undefined headless or without a canvas
|
|
305
|
+
let render3DSoftDotTexture;
|
|
306
|
+
function render3DSoftDot()
|
|
307
|
+
{
|
|
308
|
+
if (render3DSoftDotTexture || !glContext || typeof OffscreenCanvas == 'undefined') return render3DSoftDotTexture;
|
|
309
|
+
const size = 32, context = createCanvasContext(size);
|
|
310
|
+
const gradient = context.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
|
|
311
|
+
for (const [stop, alpha] of [[0, 1], [.33, .9], [.67, .7], [1, 0]]) // the same falloff as a soft disc
|
|
312
|
+
gradient.addColorStop(stop, 'rgba(255,255,255,' + alpha + ')');
|
|
313
|
+
context.fillStyle = gradient;
|
|
314
|
+
context.fillRect(0, 0, size, size);
|
|
315
|
+
return render3DSoftDotTexture = new TextureInfo(context.canvas);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// the rotation that points -Z along a direction, as vec3(pitch, yaw, 0); a zero direction keeps the current one
|
|
319
|
+
function render3DLookRotation(direction, current)
|
|
320
|
+
{
|
|
321
|
+
const d = direction.normalize();
|
|
322
|
+
if (!d.lengthSquared()) return current;
|
|
323
|
+
if (abs(d.x) + abs(d.z) < 1e-9) // straight up or down has no yaw of its own
|
|
324
|
+
return vec3(d.y > 0 ? PI / 2 : -PI / 2, current.y, 0);
|
|
325
|
+
return vec3(Math.asin(clamp(d.y, -1, 1)), atan2(-d.x, -d.z), 0);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
329
|
+
/**
|
|
330
|
+
* Render3D Plugin - The 3D renderer, camera, lights, shadows and fog
|
|
331
|
+
* - There is one of these, in the global render3D
|
|
332
|
+
* - It draws the 3D scene before gameRender, so 2D drawing lands on top
|
|
333
|
+
* - Set renderAfter2D to draw the 3D scene over the 2D scene instead
|
|
334
|
+
* - Settings like lighting and specular are read as each thing draws
|
|
335
|
+
* - Every object sets them from its own flags, so you rarely touch them
|
|
336
|
+
* @memberof Render3D
|
|
337
|
+
* @example
|
|
338
|
+
* new Render3DPlugin;
|
|
339
|
+
* render3D.camera.pos = vec3(0, 5, 10);
|
|
340
|
+
* render3D.camera.lookAt(vec3());
|
|
341
|
+
* new EngineObject3D(vec3(), buildBox());
|
|
342
|
+
*/
|
|
343
|
+
class Render3DPlugin
|
|
344
|
+
{
|
|
345
|
+
/** Create the global 3D renderer, call in gameInit */
|
|
346
|
+
constructor()
|
|
347
|
+
{
|
|
348
|
+
ASSERT(!render3D, 'Render3D plugin already initialized');
|
|
349
|
+
render3D = this;
|
|
350
|
+
|
|
351
|
+
/** @property {Camera3D} - The camera */
|
|
352
|
+
this.camera = new Camera3D;
|
|
353
|
+
|
|
354
|
+
// lights and fog
|
|
355
|
+
/** @property {Vector3} - Direction toward the sun, where its light comes from, like a directional Light3D;
|
|
356
|
+
* read at each draw, and any length will do, the shading and the shadows normalize it themselves;
|
|
357
|
+
* the sun is the one light that casts shadows and makes specular highlights */
|
|
358
|
+
this.sunDirection = vec3(-.3, 1, .5);
|
|
359
|
+
/** @property {Color} - Sunlight color */
|
|
360
|
+
this.sunColor = WHITE.copy();
|
|
361
|
+
/** @property {Color} - Ambient light color */
|
|
362
|
+
this.ambientColor = hsl(0, 0, .3);
|
|
363
|
+
/** @property {Color|undefined} - Fog color, uses canvasClearColor when undefined
|
|
364
|
+
* @type {Color|undefined} */
|
|
365
|
+
this.fogColor = undefined;
|
|
366
|
+
/** @property {number} - Distance from the camera where fog starts */
|
|
367
|
+
this.fogStart = 0;
|
|
368
|
+
/** @property {number} - Distance from the camera where fog is total, 0 disables fog */
|
|
369
|
+
this.fogEnd = 0;
|
|
370
|
+
/** @property {Vector3} - Added to the velocity3D of every object with a mass each frame, scaled by its gravityScale; sync2D objects use the 2D gravity */
|
|
371
|
+
this.gravity = vec3();
|
|
372
|
+
/** @property {number|HeightMap|Function} - Floor for objects with a softShadow: a height, a HeightMap, or (x, z) => y
|
|
373
|
+
* @type {number|HeightMap|Function} */
|
|
374
|
+
this.softShadowHeight = 0;
|
|
375
|
+
/** @property {boolean} - Default for every builder's smooth argument: true for smooth vertex normals, false for flat faces */
|
|
376
|
+
this.smoothShading = false;
|
|
377
|
+
|
|
378
|
+
// shadows
|
|
379
|
+
/** @property {boolean} - Cast real shadows from the sun, off by default and free when off */
|
|
380
|
+
this.shadows = false;
|
|
381
|
+
/** @property {number} - Size of the shadow map in pixels, bigger is sharper and slower */
|
|
382
|
+
this.shadowMapSize = 1024;
|
|
383
|
+
/** @property {number} - World size the shadow map covers around shadowCenter, smaller is sharper; it is a square
|
|
384
|
+
* facing the light, so it turns as the light does, and about 1.5 times an area's width covers it from any angle */
|
|
385
|
+
this.shadowRange = 40;
|
|
386
|
+
/** @property {Vector3|undefined} - Center of the shadowed area, read each frame, undefined follows the camera
|
|
387
|
+
* @type {Vector3|undefined} */
|
|
388
|
+
this.shadowCenter = undefined;
|
|
389
|
+
/** @property {number} - Stops surfaces shadowing themselves, raise for speckles, lower if shadows drift off */
|
|
390
|
+
this.shadowBias = .003;
|
|
391
|
+
/** @property {number} - How much to blur the shadow edges */
|
|
392
|
+
this.shadowSoftness = 1;
|
|
393
|
+
|
|
394
|
+
// draw state, read at each draw
|
|
395
|
+
/** @property {boolean} - Apply lighting, when false draws plain vertex color times texture and casts no shadow;
|
|
396
|
+
* off for billboards, lines, ribbons and soft discs, an object sets emissive instead */
|
|
397
|
+
this.lighting = true;
|
|
398
|
+
/** @property {number} - How much a surface lights itself, set per object by its emissive */
|
|
399
|
+
this.emissive = 0;
|
|
400
|
+
/** @property {boolean} - Additive blending instead of alpha, in the transparent stage */
|
|
401
|
+
this.additive = false;
|
|
402
|
+
/** @property {boolean} - Test against the depth buffer, reset to true before each object and callback */
|
|
403
|
+
this.depthTest = true;
|
|
404
|
+
/** @property {boolean} - Write to the depth buffer, owned by the stages: on for opaque, off for transparent */
|
|
405
|
+
this.depthWrite = true;
|
|
406
|
+
// batch state set by drawMesh from each mesh: whether its back faces are skipped, off for strips so they
|
|
407
|
+
// show from both sides, and whether its transform mirrors it so the other winding is the front
|
|
408
|
+
this.cullBackFaces = false;
|
|
409
|
+
this.mirrored = false;
|
|
410
|
+
/** @property {number} - Strength of the highlight where the sunlight reflects, 0 is none and 1 adds the sun's full color at its brightest; its size is fixed */
|
|
411
|
+
this.specular = 0;
|
|
412
|
+
/** @property {Shader|undefined} - Custom Shader for the next draws, set from each object's shader; undefined draws with the plugin's own
|
|
413
|
+
* @type {Shader|undefined} */
|
|
414
|
+
this.shader = undefined;
|
|
415
|
+
/** @property {boolean} - Darken by the shadow map when shadows are on, turn it off for things that should stay lit inside a shadow */
|
|
416
|
+
this.receiveShadow = true;
|
|
417
|
+
|
|
418
|
+
// the pass
|
|
419
|
+
/** @property {Function|undefined} - Draw solid world here, it runs again for shadows so only draw in it
|
|
420
|
+
* @type {Function|undefined} */
|
|
421
|
+
this.onRenderOpaque = undefined;
|
|
422
|
+
/** @property {Function|undefined} - Draw see through things here, like glows, billboards and soft shadows
|
|
423
|
+
* @type {Function|undefined} */
|
|
424
|
+
this.onRenderTransparent = undefined;
|
|
425
|
+
/** @property {Mesh|undefined} - Sky dome from buildSky or setSky, drawn around the camera behind everything
|
|
426
|
+
* @type {Mesh|undefined} */
|
|
427
|
+
this.sky = undefined;
|
|
428
|
+
/** @property {boolean} - Draw the 3D scene on top of the 2D scene instead of under it */
|
|
429
|
+
this.renderAfter2D = false;
|
|
430
|
+
/** @property {boolean} - Draw see through things far to near so they blend correctly */
|
|
431
|
+
this.sortTransparent = true;
|
|
432
|
+
/** @property {boolean} - Skip meshes whose bounding sphere is outside the view */
|
|
433
|
+
this.frustumCulling = true;
|
|
434
|
+
/** @property {boolean} - Draw every use of a mesh in the opaque stage as one instanced call, mesh.instanced overrides it per mesh */
|
|
435
|
+
this.instancing = true;
|
|
436
|
+
/** @property {boolean} - Sample textures through mipmaps so they do not shimmer in the distance, false uses each texture's own filtering like 2D */
|
|
437
|
+
this.mipmaps = true;
|
|
438
|
+
/** @property {boolean} - Draw state: keep texture pixels hard edged, no mipmaps and no blending between them, set per object by pixelated */
|
|
439
|
+
this.pixelated = false;
|
|
440
|
+
/** @property {number} - Anisotropic filtering for textures seen at an angle, 1 to 16, 1 is off; needs mipmaps */
|
|
441
|
+
this.anisotropy = 4;
|
|
442
|
+
|
|
443
|
+
// shared meshes, every object using one draws in the same batch
|
|
444
|
+
/** @property {Mesh} - A box of size 1 that drawBox uses, for any object that is a box; set the object's scale3D
|
|
445
|
+
* and color instead of editing the mesh, which would change every box that uses it */
|
|
446
|
+
this.boxMesh = buildBox();
|
|
447
|
+
/** @property {Mesh} - A smooth sphere of diameter 1 that drawSphere uses, shared the same way as boxMesh */
|
|
448
|
+
this.sphereMesh = buildSphere(1, 16, 8, true);
|
|
449
|
+
/** @property {Mesh} - A flat square of size 1 facing +Y, seen from above only, for floors, water and decals;
|
|
450
|
+
* stand it up with the object's rotation3D, and size it with scale3D */
|
|
451
|
+
this.planeMesh = buildGrid();
|
|
452
|
+
this.planeMesh.doubleSided = false;
|
|
453
|
+
/** @property {Mesh} - The same square seen and lit from both sides, for signs, cards and leaves */
|
|
454
|
+
this.planeMeshDoubleSided = buildGrid();
|
|
455
|
+
|
|
456
|
+
// read only
|
|
457
|
+
/** @property {boolean} - True while the 3D pass is running, 3D draws are only valid then */
|
|
458
|
+
this.isRendering = false;
|
|
459
|
+
/** @property {boolean} - True while the shadow map is being drawn, draws go to the depth only shader */
|
|
460
|
+
this.shadowPass = false;
|
|
461
|
+
/** @property {Matrix4} - This frame's view matrix */
|
|
462
|
+
this.viewMatrix = new Matrix4;
|
|
463
|
+
/** @property {Matrix4} - This frame's projection matrix */
|
|
464
|
+
this.projectionMatrix = new Matrix4;
|
|
465
|
+
/** @property {Matrix4} - This frame's combined view projection */
|
|
466
|
+
this.viewProjection = new Matrix4;
|
|
467
|
+
/** @property {Matrix4} - This frame's light view projection for the shadow map */
|
|
468
|
+
this.shadowMatrix = new Matrix4;
|
|
469
|
+
/** @property {Vector3} - Camera right axis this frame */
|
|
470
|
+
this.cameraRight = vec3(1, 0, 0);
|
|
471
|
+
/** @property {Vector3} - Camera up axis this frame */
|
|
472
|
+
this.cameraUp = vec3(0, 1, 0);
|
|
473
|
+
/** @property {Vector3} - Camera forward axis this frame */
|
|
474
|
+
this.cameraForward = vec3(0, 0, -1);
|
|
475
|
+
this.cameraBack = vec3(0, 0, 1); // its opposite, the normal of camera facing draws
|
|
476
|
+
|
|
477
|
+
// internal state
|
|
478
|
+
this.blend = false; // blending on, set by the stages
|
|
479
|
+
this.frustumPlanes = []; // the view as six inward planes [x, y, z, w]
|
|
480
|
+
this.shadowPlanes = []; // the shadow map's box as six planes
|
|
481
|
+
this.program = undefined; // the main program, undefined when not available
|
|
482
|
+
this.currentProgram = undefined; // the program in use during a pass, a Shader's or the main one
|
|
483
|
+
this.lightCount = 0; // Light3D objects sent this pass
|
|
484
|
+
this.shadowShader = undefined;
|
|
485
|
+
this.vao = undefined;
|
|
486
|
+
this.whiteTexture = undefined; // 1x1 white for untextured draws
|
|
487
|
+
this.samplers = []; // how textures are filtered in 3D, clamped and wrapping, see render3DInitGL
|
|
488
|
+
this.samplerKey = undefined; // the settings the samplers were made for, they are rebuilt when it changes
|
|
489
|
+
this.mipmapped = new WeakSet; // textures given mipmaps for the 3D pass
|
|
490
|
+
this.shadowTexture = undefined;
|
|
491
|
+
this.shadowFramebuffer = undefined;
|
|
492
|
+
this.shadowTextureSize = 0;
|
|
493
|
+
this.contextGeneration = 0; // counts context losses, a mesh uploaded under an older one uploads again
|
|
494
|
+
this.uniforms = new Map; // uniform locations by program
|
|
495
|
+
this.uniformValues = {}; // last values sent for the cached vec4 uniforms
|
|
496
|
+
this.shadowMapDrawn = false; // the shadow map is drawn by the first pass of the frame
|
|
497
|
+
this.passIsDefault = true; // the running pass is the default layer, the only one shadowed
|
|
498
|
+
this.lightPositions = new Float32Array(RENDER3D_MAX_LIGHTS * 4); // Light3D uniforms, filled each pass
|
|
499
|
+
this.lightColors = new Float32Array(RENDER3D_MAX_LIGHTS * 4);
|
|
500
|
+
|
|
501
|
+
// the stream of immediate mode draws
|
|
502
|
+
this.streamBuffer = undefined;
|
|
503
|
+
this.instanceBuffers = []; // the per instance values of the batches being drawn, used in turn
|
|
504
|
+
this.instanceBufferIndex = 0;
|
|
505
|
+
this.instanceMeshes = []; // meshes with a batch pending this stage
|
|
506
|
+
this.attribValues = []; // last values sent for the cached constant attributes
|
|
507
|
+
this.streamData = new ArrayBuffer(RENDER3D_MAX_STREAM_VERTS * RENDER3D_VERTEX_BYTES);
|
|
508
|
+
this.streamFloats = new Float32Array(this.streamData);
|
|
509
|
+
this.streamInts = new Uint32Array(this.streamData);
|
|
510
|
+
this.streamCount = 0;
|
|
511
|
+
this.streamTileInfo = undefined;
|
|
512
|
+
this.streamState = undefined; // captured state the pending batch was drawn under
|
|
513
|
+
this.capture = undefined; // the mesh a bake is filling
|
|
514
|
+
this.transparentQueue = undefined; // draws queued during the transparent stage, replayed far to near
|
|
515
|
+
|
|
516
|
+
render3DInitGL();
|
|
517
|
+
engineAddPlugin(undefined, render3DRender, render3DContextLost, render3DContextRestored, render3DPreRender);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
///////////////////////////////////////////////////////////////////////////
|
|
521
|
+
// Matrices and picking
|
|
522
|
+
|
|
523
|
+
/** Rebuild the view and projection matrices from the camera, called automatically each frame
|
|
524
|
+
* @param {number} [aspect] - Width over height, defaults to the main canvas */
|
|
525
|
+
updateMatrices(aspect=mainCanvasSize.y ? mainCanvasSize.x / mainCanvasSize.y : 1)
|
|
526
|
+
{
|
|
527
|
+
const camera = this.camera;
|
|
528
|
+
if (camera.align2D)
|
|
529
|
+
camera.update2D();
|
|
530
|
+
const cameraMatrix = camera.getMatrix();
|
|
531
|
+
this.viewMatrix = cameraMatrix.copy().invert();
|
|
532
|
+
this.projectionMatrix = camera.getProjectionMatrix(aspect);
|
|
533
|
+
this.viewProjection = this.projectionMatrix.copy().multiply(this.viewMatrix);
|
|
534
|
+
const m = cameraMatrix.m;
|
|
535
|
+
this.cameraRight = render3DAxis(m, 0);
|
|
536
|
+
this.cameraUp = render3DAxis(m, 4);
|
|
537
|
+
this.cameraBack = render3DAxis(m, 8); // the normal of anything facing the camera
|
|
538
|
+
this.cameraForward = this.cameraBack.scale(-1);
|
|
539
|
+
this.frustumPlanes = render3DFrustumPlanes(this.viewProjection);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Where a world point lands on screen as -1 to 1 across and up, with z as depth
|
|
543
|
+
* - Uses this frame's camera, call updateMatrices first if the camera just moved
|
|
544
|
+
* @param {Vector3} pos
|
|
545
|
+
* @return {Vector3|undefined} - undefined when behind the camera or closer than the near plane */
|
|
546
|
+
worldToClip(pos)
|
|
547
|
+
{
|
|
548
|
+
const m = this.viewProjection.m;
|
|
549
|
+
const w = m[3]*pos.x + m[7]*pos.y + m[11]*pos.z + m[15];
|
|
550
|
+
const z = (m[2]*pos.x + m[6]*pos.y + m[10]*pos.z + m[14]) / w;
|
|
551
|
+
if (w <= 0 || z < -1)
|
|
552
|
+
return; // behind the camera, or in front of the near plane
|
|
553
|
+
return vec3(
|
|
554
|
+
(m[0]*pos.x + m[4]*pos.y + m[8]*pos.z + m[12]) / w,
|
|
555
|
+
(m[1]*pos.x + m[5]*pos.y + m[9]*pos.z + m[13]) / w, z);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** Project a world point to screen space pixels, same space as mousePosScreen
|
|
559
|
+
* - The opposite of screenToRay, and it takes the same canvas so the pair agree
|
|
560
|
+
* @param {Vector3} pos
|
|
561
|
+
* @param {Vector2} [canvasSize] - Defaults to the main canvas size, as in screenToRay;
|
|
562
|
+
* the projection is whatever updateMatrices last built, which screenToRay does for its canvas
|
|
563
|
+
* @return {Vector2|undefined} - undefined when behind the camera or closer than the near plane */
|
|
564
|
+
worldToScreen(pos, canvasSize=mainCanvasSize)
|
|
565
|
+
{
|
|
566
|
+
const clip = this.worldToClip(pos);
|
|
567
|
+
if (!clip)
|
|
568
|
+
return;
|
|
569
|
+
return vec2((clip.x + 1) / 2 * canvasSize.x, (1 - clip.y) / 2 * canvasSize.y);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** Get the world ray under a screen position, for clicking on things in 3D
|
|
573
|
+
* - Uses the camera where it is right now, so it is fine to call from gameUpdate
|
|
574
|
+
* - It brings the view matrices up to date for that canvas, so worldToScreen stays its exact opposite
|
|
575
|
+
* @param {Vector2} screenPos - Same space as mousePosScreen
|
|
576
|
+
* @param {Vector2} [canvasSize] - Defaults to the main canvas size
|
|
577
|
+
* @return {Ray3D} - Starts at the camera with a unit direction, or on the camera plane when orthographic */
|
|
578
|
+
screenToRay(screenPos, canvasSize=mainCanvasSize)
|
|
579
|
+
{
|
|
580
|
+
const width = canvasSize.x || 1, height = canvasSize.y || 1; // a canvas with no size stands in as 1x1, rather than dividing by zero
|
|
581
|
+
const aspect = width / height, camera = this.camera;
|
|
582
|
+
// bring the matrices up to date for this canvas, so worldToScreen and this agree on where things are
|
|
583
|
+
this.updateMatrices(aspect);
|
|
584
|
+
const clipX = screenPos.x / width * 2 - 1;
|
|
585
|
+
const clipY = 1 - screenPos.y / height * 2;
|
|
586
|
+
// the screen offset moves a parallel ray's origin, or bends a perspective ray's direction
|
|
587
|
+
const h = camera.orthographic ? camera.orthographic / 2 : tan(camera.fov / 2);
|
|
588
|
+
const offset = this.cameraRight.scale(clipX * h * aspect).add(this.cameraUp.scale(clipY * h));
|
|
589
|
+
return camera.orthographic
|
|
590
|
+
? new Ray3D(camera.pos.add(offset), this.cameraForward.copy())
|
|
591
|
+
: new Ray3D(camera.pos.copy(), this.cameraForward.add(offset).normalize());
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** Where a screen position lands on a flat ground plane, for top down games; use HeightMap.raycast for terrain
|
|
595
|
+
* @param {Vector2} screenPos - Same space as mousePosScreen
|
|
596
|
+
* @param {number} [groundHeight] - World height of the ground plane
|
|
597
|
+
* @param {Vector2} [canvasSize] - Defaults to the main canvas size, as in screenToRay
|
|
598
|
+
* @return {Vector3|undefined} - undefined when the ray misses the plane */
|
|
599
|
+
screenToGround(screenPos, groundHeight=0, canvasSize=mainCanvasSize)
|
|
600
|
+
{
|
|
601
|
+
const ray = this.screenToRay(screenPos, canvasSize);
|
|
602
|
+
const t = raycastPlane(ray, vec3(0, groundHeight, 0), RENDER3D_DEFAULT_NORMAL);
|
|
603
|
+
return t === undefined ? undefined : ray.getPosition(t);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** Find the nearest object under a screen position or along a ray, for clicking on things
|
|
607
|
+
* - Each object is tested as a sphere around its mesh, or around a sprite's size3D, not triangle by triangle
|
|
608
|
+
* - engineObjectsRaycast3D is the other half of this, every object along a ray instead of the nearest
|
|
609
|
+
* @param {Vector2|Ray3D} from - A screen position like mousePosScreen, or a ray to look along
|
|
610
|
+
* @param {Array<EngineObject>} [objects] - Defaults to every object; only those with a mesh or a sprite count
|
|
611
|
+
* @return {{object: EngineObject3D, distance: number}|undefined} */
|
|
612
|
+
pick(from, objects=engineObjects)
|
|
613
|
+
{
|
|
614
|
+
const ray = from instanceof Ray3D ? from : this.screenToRay(from);
|
|
615
|
+
let nearest;
|
|
616
|
+
for (const o of objects)
|
|
617
|
+
{
|
|
618
|
+
const distance = render3DRaycastObject(ray, o);
|
|
619
|
+
if (distance !== undefined && (!nearest || distance < nearest.distance))
|
|
620
|
+
nearest = {object: o, distance};
|
|
621
|
+
}
|
|
622
|
+
return nearest;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Play a sound at a 3D position, quieter with distance from the camera and panned by its side, like Sound.play with a 2D position
|
|
626
|
+
* @param {Sound} sound
|
|
627
|
+
* @param {Vector3} pos3D
|
|
628
|
+
* @param {number} [volume]
|
|
629
|
+
* @param {number} [pitch]
|
|
630
|
+
* @param {number} [randomnessScale] - How much to scale pitch randomness
|
|
631
|
+
* @param {boolean} [loop]
|
|
632
|
+
* @return {SoundInstance|undefined} - undefined when out of range or sound is off */
|
|
633
|
+
playSound(sound, pos3D, volume=1, pitch=1, randomnessScale=1, loop=false)
|
|
634
|
+
{
|
|
635
|
+
// keep in step with Sound.play, only the pan differs
|
|
636
|
+
ASSERT(sound instanceof Sound, 'sound must be a Sound');
|
|
637
|
+
ASSERT(isVector3(pos3D), 'pos3D must be a vec3');
|
|
638
|
+
if (!soundEnable || headlessMode) return;
|
|
639
|
+
if (!sound.sampleBuffer && !sound._sampleChannels) return; // still loading
|
|
640
|
+
const offset = pos3D.subtract(this.camera.pos), range = sound.range;
|
|
641
|
+
if (range)
|
|
642
|
+
{
|
|
643
|
+
const distance = offset.length();
|
|
644
|
+
if (distance > range)
|
|
645
|
+
return; // out of range
|
|
646
|
+
volume *= percent(distance, range, range * sound.taper);
|
|
647
|
+
}
|
|
648
|
+
const pan = offset.normalize().dot(this.cameraRight);
|
|
649
|
+
const rate = pitch + pitch * sound.randomness * randomnessScale * rand(-1, 1);
|
|
650
|
+
return new SoundInstance(sound, volume, rate, pan, loop);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** Play a sound on a loop at a 3D position, the same as playSound with loop on
|
|
654
|
+
* - Its volume and pan are set when it starts, change or stop it through the SoundInstance returned
|
|
655
|
+
* @param {Sound} sound
|
|
656
|
+
* @param {Vector3} pos3D
|
|
657
|
+
* @param {number} [volume]
|
|
658
|
+
* @param {number} [pitch]
|
|
659
|
+
* @param {number} [randomnessScale] - How much to scale pitch randomness
|
|
660
|
+
* @return {SoundInstance|undefined} - undefined when out of range or sound is off */
|
|
661
|
+
playSoundLoop(sound, pos3D, volume=1, pitch=1, randomnessScale=1)
|
|
662
|
+
{ return this.playSound(sound, pos3D, volume, pitch, randomnessScale, true); }
|
|
663
|
+
|
|
664
|
+
/** Is any part of a sphere on screen this frame, the test that skips meshes the camera cannot see
|
|
665
|
+
* - While the shadow map is drawing it tests the shadow area instead
|
|
666
|
+
* @param {Vector3} center
|
|
667
|
+
* @param {number} radius
|
|
668
|
+
* @return {boolean} */
|
|
669
|
+
isSphereVisible(center, radius)
|
|
670
|
+
{
|
|
671
|
+
for (const p of this.shadowPass ? this.shadowPlanes : this.frustumPlanes)
|
|
672
|
+
if (p[0] * center.x + p[1] * center.y + p[2] * center.z + p[3] < -radius)
|
|
673
|
+
return false;
|
|
674
|
+
return true;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
///////////////////////////////////////////////////////////////////////////
|
|
678
|
+
// Meshes and the stream
|
|
679
|
+
|
|
680
|
+
/** Draw a mesh with the current draw state, batched with its other uses in the opaque stage when instancing is on
|
|
681
|
+
* @param {Mesh} mesh
|
|
682
|
+
* @param {Matrix4|Vector3} [matrix] - Object transform, or just a position to draw it at
|
|
683
|
+
* @param {TileInfo|TextureInfo} [tileInfo] - Texture, mesh uvs map across the tile or the whole texture
|
|
684
|
+
* @param {Color} [color] - Tint */
|
|
685
|
+
drawMesh(mesh, matrix=RENDER3D_IDENTITY, tileInfo, color=WHITE)
|
|
686
|
+
{
|
|
687
|
+
matrix = render3DMatrix(matrix);
|
|
688
|
+
ASSERT(!tileInfo || tileInfo instanceof TileInfo || tileInfo instanceof TextureInfo, 'tileInfo must be a TileInfo or TextureInfo, it comes before color');
|
|
689
|
+
ASSERT(isColor(color), 'color must be a Color');
|
|
690
|
+
if (this.capture)
|
|
691
|
+
return void this.capture.combine(mesh, matrix, color);
|
|
692
|
+
if (this.transparentQueue)
|
|
693
|
+
return this.queueTransparent(matrix.getTranslation(), ()=> this.drawMesh(mesh, matrix, tileInfo, color));
|
|
694
|
+
if (!render3DCanDraw()) return;
|
|
695
|
+
if (this.shadowPass && !this.lighting) return; // unlit things cast no shadow
|
|
696
|
+
if (!mesh.buffer || mesh.dirty || mesh.contextGeneration !== this.contextGeneration)
|
|
697
|
+
mesh.upload();
|
|
698
|
+
if (!mesh.bufferCount) return;
|
|
699
|
+
if (this.frustumCulling && !this.isSphereVisible(matrix.getTranslation(), mesh.radius * render3DMaxScale(matrix.m)))
|
|
700
|
+
return;
|
|
701
|
+
// the mesh says whether its back faces can be skipped, and a mirroring transform, one with a negative
|
|
702
|
+
// determinant, turns the winding around so the other one is its front
|
|
703
|
+
const m = matrix.m, cullBackFaces = this.cullBackFaces, mirrored = this.mirrored;
|
|
704
|
+
this.cullBackFaces = !mesh.doubleSided;
|
|
705
|
+
this.mirrored = m[0]*(m[5]*m[10] - m[6]*m[9]) - m[4]*(m[1]*m[10] - m[2]*m[9]) + m[8]*(m[1]*m[6] - m[2]*m[5]) < 0;
|
|
706
|
+
if (!this.blend && this.depthTest && (mesh.instanced ?? this.instancing)) // the stage draws the batch at its end
|
|
707
|
+
render3DInstance(mesh, matrix, tileInfo, color);
|
|
708
|
+
else
|
|
709
|
+
{
|
|
710
|
+
this.flush();
|
|
711
|
+
render3DSetDrawUniforms(matrix, tileInfo, color);
|
|
712
|
+
render3DBindVertexBuffer(mesh.buffer);
|
|
713
|
+
glContext.drawArrays(glContext.TRIANGLE_STRIP, 0, mesh.bufferCount);
|
|
714
|
+
++drawCount;
|
|
715
|
+
primitiveCount += mesh.bufferCount;
|
|
716
|
+
}
|
|
717
|
+
this.cullBackFaces = cullBackFaces, this.mirrored = mirrored;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** Draw a triangle strip, batched into the stream with the current draw state
|
|
721
|
+
* - Strip order: the first three points make a triangle, then each point makes another with the two before it
|
|
722
|
+
* - List the first three points counter clockwise as seen from the front, or the face points away
|
|
723
|
+
* and may vanish when back faces are culled
|
|
724
|
+
* - inside a bake the strip goes into the mesh instead, in the transparent stage it is queued for sorting
|
|
725
|
+
* @param {Array<Vector3>} points - In strip order
|
|
726
|
+
* @param {Vector3|Array<Vector3>} [normals] - One for all or one per point, default up
|
|
727
|
+
* @param {Vector2|Array<Vector2>} [uvs] - One for all or one per point, 0-1 across the tile
|
|
728
|
+
* @param {Color|Array<Color>} [colors] - One for all or one per point, vertex colors come before the texture
|
|
729
|
+
* @param {TileInfo|TextureInfo} [tileInfo] - Texture for this strip */
|
|
730
|
+
drawStrip(points, normals, uvs, colors, tileInfo)
|
|
731
|
+
{
|
|
732
|
+
if (this.capture)
|
|
733
|
+
{
|
|
734
|
+
this.capture.addStrip(points, normals, uvs, colors);
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
if (this.transparentQueue)
|
|
738
|
+
{
|
|
739
|
+
// sort by the center of the strip
|
|
740
|
+
let x = 0, y = 0, z = 0;
|
|
741
|
+
for (const p of points)
|
|
742
|
+
x += p.x, y += p.y, z += p.z;
|
|
743
|
+
return this.queueTransparent(vec3(x, y, z).scale(1 / points.length), ()=> this.drawStrip(points, normals, uvs, colors, tileInfo));
|
|
744
|
+
}
|
|
745
|
+
ASSERT(isArray(points) && points.length > 2, 'strip needs at least 3 points');
|
|
746
|
+
const n = points.length, count = render3DStripCount(n);
|
|
747
|
+
const uvRect = render3DBeginStrip(count, tileInfo);
|
|
748
|
+
if (!uvRect) return;
|
|
749
|
+
|
|
750
|
+
// the tile rect is applied to each uv now, so the whole texture maps at flush
|
|
751
|
+
const floats = this.streamFloats, ints = this.streamInts;
|
|
752
|
+
const normalArray = isArray(normals), uvArray = isArray(uvs), colorArray = isArray(colors);
|
|
753
|
+
const rgba = colorArray ? 0 : (colors || WHITE).rgbaInt();
|
|
754
|
+
for (let k = 0; k < count; ++k)
|
|
755
|
+
{
|
|
756
|
+
const i = render3DStripIndex(k, n);
|
|
757
|
+
const uv = uvArray ? uvs[i] : uvs || RENDER3D_DEFAULT_UV;
|
|
758
|
+
render3DWriteVertex(floats, ints, this.streamCount++ * RENDER3D_VERTEX_FLOATS, points[i],
|
|
759
|
+
normalArray ? normals[i] : normals || RENDER3D_DEFAULT_NORMAL,
|
|
760
|
+
uvRect.x + uv.x * uvRect.w, uvRect.y + uv.y * uvRect.h, colorArray ? colors[i].rgbaInt() : rgba);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/** Draw a strip with lighting off, for camera facing shapes where the light direction means nothing
|
|
765
|
+
* @param {Array<Vector3>} points - Strip order
|
|
766
|
+
* @param {Vector3|Array<Vector3>} [normals]
|
|
767
|
+
* @param {Vector2|Array<Vector2>} [uvs]
|
|
768
|
+
* @param {Color|Array<Color>} [colors]
|
|
769
|
+
* @param {TileInfo|TextureInfo} [tileInfo] */
|
|
770
|
+
drawStripUnlit(points, normals, uvs, colors, tileInfo)
|
|
771
|
+
{ render3DWithState({lighting: false}, ()=> this.drawStrip(points, normals, uvs, colors, tileInfo)); }
|
|
772
|
+
|
|
773
|
+
/** Draw the pending stream vertices as one strip with the state they were drawn under, called automatically when needed */
|
|
774
|
+
flush()
|
|
775
|
+
{
|
|
776
|
+
if (!this.streamCount || !render3DCanDraw()) return;
|
|
777
|
+
const gl = glContext;
|
|
778
|
+
render3DSetDrawUniforms(RENDER3D_IDENTITY, this.streamTileInfo, WHITE, RENDER3D_FULL_UV_RECT, this.streamState);
|
|
779
|
+
render3DBindVertexBuffer(this.streamBuffer);
|
|
780
|
+
gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.streamFloats, 0, this.streamCount * RENDER3D_VERTEX_FLOATS);
|
|
781
|
+
gl.drawArrays(gl.TRIANGLE_STRIP, 0, this.streamCount);
|
|
782
|
+
++drawCount;
|
|
783
|
+
primitiveCount += this.streamCount;
|
|
784
|
+
this.streamCount = 0;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/** Build a mesh once out of draw calls, instead of redrawing the shapes every frame
|
|
788
|
+
* - Call the same drawStrip, drawQuad and drawBox calls inside, and get a mesh back
|
|
789
|
+
* - Strips inside a bake ignore their tileInfo, the finished mesh picks the texture when it draws
|
|
790
|
+
* - drawMesh, drawBox and drawSphere copy their mesh in, moved and tinted, their tileInfo dropped too
|
|
791
|
+
* - The mesh skips its back faces like any, set doubleSided when what was drawn is open
|
|
792
|
+
* @param {Function} drawFunction
|
|
793
|
+
* @return {Mesh} */
|
|
794
|
+
bake(drawFunction)
|
|
795
|
+
{
|
|
796
|
+
this.flush();
|
|
797
|
+
ASSERT(!this.capture, 'bake cannot be nested');
|
|
798
|
+
const mesh = this.capture = new Mesh;
|
|
799
|
+
try { drawFunction(); }
|
|
800
|
+
finally { this.capture = undefined; }
|
|
801
|
+
return mesh;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
///////////////////////////////////////////////////////////////////////////
|
|
805
|
+
// The stages, run by the pass
|
|
806
|
+
|
|
807
|
+
/** Draw a layer's objects, solid ones first and see through ones after, called automatically
|
|
808
|
+
* - The main layer also draws the sky, the render callbacks and the debug shapes
|
|
809
|
+
* @param {Array<EngineObject3D>} objects
|
|
810
|
+
* @param {boolean} [isDefault] */
|
|
811
|
+
renderStages(objects, isDefault=true)
|
|
812
|
+
{
|
|
813
|
+
const opaque = [], transparent = [];
|
|
814
|
+
for (const o of objects)
|
|
815
|
+
(o.transparent || o.additive ? transparent : opaque).push(o);
|
|
816
|
+
|
|
817
|
+
isDefault && this.sky && this.drawSky();
|
|
818
|
+
|
|
819
|
+
// opaque: no blending, depth writes on, by render order
|
|
820
|
+
this.blend = false;
|
|
821
|
+
this.depthWrite = true;
|
|
822
|
+
const byOrder = (a, b)=> a.renderOrder - b.renderOrder;
|
|
823
|
+
opaque.sort(byOrder);
|
|
824
|
+
transparent.sort(byOrder);
|
|
825
|
+
render3DDrawObjects(opaque);
|
|
826
|
+
isDefault && this.onRenderOpaque?.();
|
|
827
|
+
this.flush();
|
|
828
|
+
render3DFlushInstances();
|
|
829
|
+
|
|
830
|
+
// transparent: blending on, depth writes off, every draw queued then replayed far to near
|
|
831
|
+
this.blend = true;
|
|
832
|
+
this.depthWrite = false;
|
|
833
|
+
this.transparentQueue = this.sortTransparent ? [] : undefined;
|
|
834
|
+
try
|
|
835
|
+
{
|
|
836
|
+
render3DDrawObjects(transparent);
|
|
837
|
+
for (const o of objects)
|
|
838
|
+
if (o.softShadow)
|
|
839
|
+
{
|
|
840
|
+
// the shadow grows with the object, by the same scale picking and culling
|
|
841
|
+
// measure it at, so one size set once holds however the object is scaled
|
|
842
|
+
const m = o.getMatrix();
|
|
843
|
+
this.drawSoftShadow(m.getTranslation(), o.softShadow * render3DMaxScale(m.m), this.softShadowHeight);
|
|
844
|
+
}
|
|
845
|
+
isDefault && this.onRenderTransparent?.();
|
|
846
|
+
}
|
|
847
|
+
finally { this.flushTransparentQueue(); }
|
|
848
|
+
isDefault && render3DRenderDebug();
|
|
849
|
+
this.flush();
|
|
850
|
+
|
|
851
|
+
// leave the fields at the opaque defaults for anything reading them outside the pass
|
|
852
|
+
render3DSetObjectState();
|
|
853
|
+
this.blend = false;
|
|
854
|
+
this.depthWrite = true;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/** Queue a draw for the transparent stage, replayed far to near with the current draw state, or draw it now when sorting is off
|
|
858
|
+
* @param {Vector3} pos - Where the draw is, for sorting
|
|
859
|
+
* @param {Function} draw */
|
|
860
|
+
queueTransparent(pos, draw)
|
|
861
|
+
{
|
|
862
|
+
if (!this.transparentQueue)
|
|
863
|
+
return draw();
|
|
864
|
+
this.transparentQueue.push({distance: pos.distanceSquared(this.camera.pos), state: render3DCaptureBatchState(), draw});
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/** Draw the queued transparent draws far to near with the state each was drawn under, called automatically at the end of the transparent stage */
|
|
868
|
+
flushTransparentQueue()
|
|
869
|
+
{
|
|
870
|
+
const queue = this.transparentQueue;
|
|
871
|
+
if (!queue) return;
|
|
872
|
+
this.transparentQueue = undefined;
|
|
873
|
+
queue.sort((a, b)=> b.distance - a.distance);
|
|
874
|
+
for (const item of queue)
|
|
875
|
+
render3DWithState(item.state, item.draw); // each under the state it was queued with
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/** Draw render3D.sky around the camera, unlit, unfogged and behind everything, called automatically by the pass */
|
|
879
|
+
drawSky()
|
|
880
|
+
{
|
|
881
|
+
this.flush();
|
|
882
|
+
// the dome only has to sit between the clip planes, the pass draws it first with no depth test;
|
|
883
|
+
// a far plane at Infinity has no midpoint, so put it a long way out instead
|
|
884
|
+
const {near, far} = this.camera;
|
|
885
|
+
const radius = far == Infinity ? near * 1e4 : (near + far) / 2;
|
|
886
|
+
render3DWithState({lighting: false, blend: false, depthTest: false, depthWrite: false, fogEnd: 0, shader: undefined}, ()=>
|
|
887
|
+
this.drawMesh(this.sky, buildMatrix(this.camera.pos, undefined, vec3(radius))));
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/** Rebuild the light's view projection around the shadow center, called automatically each frame shadows are on */
|
|
891
|
+
updateShadowMatrix()
|
|
892
|
+
{
|
|
893
|
+
ASSERT(this.shadowRange > 0, 'shadowRange must be positive');
|
|
894
|
+
const range = this.shadowRange > 0 ? this.shadowRange : 1, half = range / 2;
|
|
895
|
+
const toSun = this.sunDirection.normalize();
|
|
896
|
+
const center = this.shadowCenter || this.camera.pos.add(this.cameraForward.scale(half * .8));
|
|
897
|
+
const view = Matrix4.lookAt(center.add(toSun.scale(range)), center).invert();
|
|
898
|
+
// move the light's view in whole pixel steps so shadow edges do not crawl as the camera moves
|
|
899
|
+
const texel = range / (this.shadowTextureSize || this.shadowMapSize), m = view.m; // no texture in headless mode
|
|
900
|
+
m[12] = round(m[12] / texel) * texel;
|
|
901
|
+
m[13] = round(m[13] / texel) * texel;
|
|
902
|
+
this.shadowMatrix = Matrix4.orthographic(-half, half, -half, half, 0, range * 2).multiply(view);
|
|
903
|
+
this.shadowPlanes = render3DFrustumPlanes(this.shadowMatrix);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/** Build a sky dome, set it as the sky and set the fog color to the horizon color
|
|
907
|
+
* @param {Color} [topColor] - Straight up
|
|
908
|
+
* @param {Color} [horizonColor] - Level with the camera
|
|
909
|
+
* @param {Color} [bottomColor] - Straight down, defaults to the horizon color
|
|
910
|
+
* @return {Mesh} - The dome, also in render3D.sky */
|
|
911
|
+
setSky(topColor, horizonColor=hsl(.6, 1, .9), bottomColor)
|
|
912
|
+
{
|
|
913
|
+
this.sky?.dispose();
|
|
914
|
+
this.sky = buildSky(topColor, horizonColor, bottomColor);
|
|
915
|
+
this.fogColor = horizonColor.copy();
|
|
916
|
+
return this.sky;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/** Set where fog starts and ends, and its color
|
|
920
|
+
* @param {number} fogStart - Distance from the camera where fog starts
|
|
921
|
+
* @param {number} fogEnd - Distance where fog is total, 0 disables fog
|
|
922
|
+
* @param {Color} [fogColor] - Leaves the color alone when not passed, setSky sets it to the horizon */
|
|
923
|
+
setFog(fogStart, fogEnd, fogColor)
|
|
924
|
+
{
|
|
925
|
+
this.fogStart = fogStart;
|
|
926
|
+
this.fogEnd = fogEnd;
|
|
927
|
+
if (fogColor)
|
|
928
|
+
this.fogColor = fogColor.copy();
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
///////////////////////////////////////////////////////////////////////////
|
|
932
|
+
// Immediate mode shapes
|
|
933
|
+
|
|
934
|
+
/** Draw a box, untextured, for blocking out a scene without meshes or objects
|
|
935
|
+
* @param {Vector3} pos - Center
|
|
936
|
+
* @param {Vector3|number} [size] - Full size, a number for a cube
|
|
937
|
+
* @param {Color} [color]
|
|
938
|
+
* @param {Vector3} [rotation] - vec3(pitch, yaw, roll) */
|
|
939
|
+
drawBox(pos, size=1, color=WHITE, rotation)
|
|
940
|
+
{
|
|
941
|
+
this.drawMesh(this.boxMesh, buildMatrix(pos, rotation, render3DSize3(size)), undefined, color);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/** Draw a sphere, untextured and smooth shaded
|
|
945
|
+
* @param {Vector3} pos - Center
|
|
946
|
+
* @param {number} [size] - Diameter
|
|
947
|
+
* @param {Color} [color] */
|
|
948
|
+
drawSphere(pos, size=1, color=WHITE)
|
|
949
|
+
{
|
|
950
|
+
this.drawMesh(this.sphereMesh, buildMatrix(pos, undefined, vec3(size)), undefined, color);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/** Draw a flat square that always faces the camera, unlit so it keeps its own colors
|
|
954
|
+
* - Draw it from onRenderTransparent or a transparent object so it can fade
|
|
955
|
+
* @param {Vector3} pos - Center
|
|
956
|
+
* @param {Vector2} [size] - World units
|
|
957
|
+
* @param {TileInfo|TextureInfo} [tileInfo]
|
|
958
|
+
* @param {Color} [color]
|
|
959
|
+
* @param {number} [angle] - Rotation in the camera plane, counter clockwise
|
|
960
|
+
* @param {boolean} [upright] - Stand on world up and only turn to face the camera, for sprites on the ground */
|
|
961
|
+
drawBillboard(pos, size=vec2(1), tileInfo, color=WHITE, angle=0, upright=false)
|
|
962
|
+
{
|
|
963
|
+
if (this.capture)
|
|
964
|
+
return this.drawStripUnlit(render3DBillboardCorners(pos, size, angle, upright), this.cameraBack, RENDER3D_QUAD_UVS, color, tileInfo);
|
|
965
|
+
if (this.transparentQueue) // sort by the exact position, a shadow under it sorts by the floor
|
|
966
|
+
return this.queueTransparent(pos, ()=> this.drawBillboard(pos, size, tileInfo, color, angle, upright));
|
|
967
|
+
|
|
968
|
+
// the particle path: the quad's six stream vertices written straight in, unlit
|
|
969
|
+
const count = render3DStripCount(4);
|
|
970
|
+
const lighting = this.shadowPass && this.lighting; // unlit on screen, in the shadow map the object's flag decides
|
|
971
|
+
const uvRect = render3DWithState({lighting}, ()=> render3DBeginStrip(count, tileInfo));
|
|
972
|
+
if (!uvRect) return;
|
|
973
|
+
const corners = render3DBillboardCorners(pos, size, angle, upright), rgba = color.rgbaInt();
|
|
974
|
+
const floats = this.streamFloats, ints = this.streamInts;
|
|
975
|
+
for (let k = 0; k < count; ++k)
|
|
976
|
+
{
|
|
977
|
+
const i = render3DStripIndex(k, 4), uv = RENDER3D_QUAD_UVS[i];
|
|
978
|
+
render3DWriteVertex(floats, ints, this.streamCount++ * RENDER3D_VERTEX_FLOATS, corners[i], this.cameraBack,
|
|
979
|
+
uvRect.x + uv.x * uvRect.w, uvRect.y + uv.y * uvRect.h, rgba);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/** Draw a quad from four corners in loop order, counter clockwise seen from the front, a is the top left of the texture
|
|
984
|
+
* @param {Vector3} a
|
|
985
|
+
* @param {Vector3} b
|
|
986
|
+
* @param {Vector3} c
|
|
987
|
+
* @param {Vector3} d
|
|
988
|
+
* @param {TileInfo|TextureInfo} [tileInfo]
|
|
989
|
+
* @param {Color|Array<Color>} [color] - One for all or one per corner */
|
|
990
|
+
drawQuad(a, b, c, d, tileInfo, color=WHITE)
|
|
991
|
+
{
|
|
992
|
+
this.drawStrip(render3DQuadStrip(a, b, c, d), render3DFaceNormal(a, b, c, d), RENDER3D_QUAD_UVS, render3DQuadValues(color), tileInfo);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/** Draw a triangle, counter clockwise from outside is the front
|
|
996
|
+
* @param {Vector3} a
|
|
997
|
+
* @param {Vector3} b
|
|
998
|
+
* @param {Vector3} c
|
|
999
|
+
* @param {Color} [color] */
|
|
1000
|
+
drawTriangle(a, b, c, color=WHITE)
|
|
1001
|
+
{
|
|
1002
|
+
this.drawStrip([a, b, c], render3DFaceNormal(a, b, c), undefined, color);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/** Draw a line as a camera facing ribbon, unlit
|
|
1006
|
+
* @param {Vector3} posA
|
|
1007
|
+
* @param {Vector3} posB
|
|
1008
|
+
* @param {number} [width]
|
|
1009
|
+
* @param {Color} [color] */
|
|
1010
|
+
drawLine(posA, posB, width=.1, color=WHITE)
|
|
1011
|
+
{
|
|
1012
|
+
this.drawRibbon([posA, posB], width, undefined, color);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/** Draw a ribbon along a path, unlit and visible from both sides; width and color can change along it
|
|
1016
|
+
* - The texture runs along the length, u from the first point to the last
|
|
1017
|
+
* - A path that ends where it starts is a loop, and joins with no seam
|
|
1018
|
+
* @param {Array<Vector3>} points - Center line in order, at least two
|
|
1019
|
+
* @param {number|Array<number>} [width] - Full width, one for all or one per point
|
|
1020
|
+
* @param {TileInfo|TextureInfo} [tileInfo]
|
|
1021
|
+
* @param {Color|Array<Color>} [color] - One for all or one per point
|
|
1022
|
+
* @param {Vector3|Array<Vector3>} [side] - Direction across the ribbon, one for all or one per point, default faces the camera */
|
|
1023
|
+
drawRibbon(points, width=.1, tileInfo, color=WHITE, side)
|
|
1024
|
+
{
|
|
1025
|
+
const count = points.length;
|
|
1026
|
+
ASSERT(count > 1, 'a ribbon needs at least two points');
|
|
1027
|
+
ASSERT(!tileInfo || tileInfo instanceof TileInfo || tileInfo instanceof TextureInfo, 'tileInfo must be a TileInfo or TextureInfo, it comes before color');
|
|
1028
|
+
const strip = [], uvs = tileInfo ? [] : undefined, colors = [], forward = this.cameraForward;
|
|
1029
|
+
let across = vec3(1, 0, 0); // kept from the last point where the direction vanishes
|
|
1030
|
+
// a loop's two ends take their direction across the join, so they meet edge to edge
|
|
1031
|
+
const loop = count > 2 && points[0].distanceSquared(points[count - 1]) < 1e-12;
|
|
1032
|
+
for (let i = 0; i < count; ++i)
|
|
1033
|
+
{
|
|
1034
|
+
const p = points[i];
|
|
1035
|
+
const w = isArray(width) ? width[i] : width;
|
|
1036
|
+
const c = isArray(color) ? color[i] : color;
|
|
1037
|
+
const s = side && (isArray(side) ? side[i] : side);
|
|
1038
|
+
// across the path in the camera plane unless a side is given
|
|
1039
|
+
const next = points[i < count - 1 ? i + 1 : loop ? 1 : i];
|
|
1040
|
+
const last = points[i > 0 ? i - 1 : loop ? count - 2 : i];
|
|
1041
|
+
const dir = s || next.subtract(last).cross(forward);
|
|
1042
|
+
if (dir.lengthSquared() > 1e-12)
|
|
1043
|
+
across = dir.normalize();
|
|
1044
|
+
const half = across.scale(w / 2);
|
|
1045
|
+
strip.push(p.add(half), p.subtract(half));
|
|
1046
|
+
uvs?.push(vec2(i / (count - 1), 0), vec2(i / (count - 1), 1));
|
|
1047
|
+
colors.push(c, c);
|
|
1048
|
+
}
|
|
1049
|
+
render3DWithState({lighting: false, cullBackFaces: false}, ()=> this.drawStrip(strip, forward.scale(-1), uvs, colors, tileInfo));
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/** Draw a disc that fades to transparent at the rim, unlit, for glows, puffs and sky dots
|
|
1053
|
+
* @param {Vector3} pos - Center
|
|
1054
|
+
* @param {number} [size] - Diameter
|
|
1055
|
+
* @param {Color} [color]
|
|
1056
|
+
* @param {Vector3} [normal] - Facing direction, faces the camera by default
|
|
1057
|
+
* @param {number} [sides] */
|
|
1058
|
+
drawSoftDisc(pos, size=1, color=WHITE, normal=this.cameraBack, sides=16)
|
|
1059
|
+
{
|
|
1060
|
+
render3DAssertBlending();
|
|
1061
|
+
if (this.transparentQueue && !this.capture)
|
|
1062
|
+
return this.queueTransparent(pos, ()=> this.drawSoftDisc(pos, size, color, normal, sides));
|
|
1063
|
+
// basis in the disc's plane
|
|
1064
|
+
const n = normal.normalize();
|
|
1065
|
+
const helper = abs(n.y) < .9 ? vec3(0, 1, 0) : vec3(1, 0, 0);
|
|
1066
|
+
const u = helper.cross(n).normalize(), w = u.cross(n);
|
|
1067
|
+
render3DDrawSoftDisc(size / 2, color, sides, n, (c, s, r)=>
|
|
1068
|
+
vec3(pos.x + (u.x * c + w.x * s) * r, pos.y + (u.y * c + w.y * s) * r, pos.z + (u.z * c + w.z * s) * r));
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/** Draw a soft round shadow on the ground under something, much cheaper than a real shadow
|
|
1072
|
+
* - Draw it from onRenderTransparent or from a transparent object
|
|
1073
|
+
* @param {Vector3} pos - Position of the thing casting the shadow
|
|
1074
|
+
* @param {number} [size] - Diameter
|
|
1075
|
+
* @param {number|HeightMap|Function} [floorHeight] - Height of the ground, a HeightMap, or (x, z) => y to follow terrain
|
|
1076
|
+
* @param {Color} [color]
|
|
1077
|
+
* @param {number} [lift] - How far above the ground to draw, raise it if the shadow cuts into rough ground */
|
|
1078
|
+
drawSoftShadow(pos, size=1, floorHeight=0, color=RENDER3D_SHADOW_COLOR, lift=.02)
|
|
1079
|
+
{
|
|
1080
|
+
render3DAssertBlending();
|
|
1081
|
+
const height = isNumber(floorHeight) ? ()=> floorHeight
|
|
1082
|
+
: floorHeight instanceof HeightMap ? (x, z)=> floorHeight.getHeight(x, z) : floorHeight;
|
|
1083
|
+
if (this.transparentQueue && !this.capture) // sort from the floor, under whatever casts it
|
|
1084
|
+
return this.queueTransparent(vec3(pos.x, height(pos.x, pos.z) + lift, pos.z), ()=> this.drawSoftShadow(pos, size, floorHeight, color, lift));
|
|
1085
|
+
render3DDrawSoftDisc(size / 2, color, 16, RENDER3D_DEFAULT_NORMAL, (c, s, r)=>
|
|
1086
|
+
{
|
|
1087
|
+
const x = pos.x + c * r, z = pos.z + s * r;
|
|
1088
|
+
return vec3(x, height(x, z) + lift, z);
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
function render3DAssertBlending()
|
|
1094
|
+
{
|
|
1095
|
+
const r = render3D;
|
|
1096
|
+
ASSERT(r.blend || r.capture || r.shadowPass || !r.isRendering, 'soft discs and shadows need blending: set the object transparent or draw from onRenderTransparent');
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// draw the three rings of a soft disc as unlit strips, pointAt(cos, sin, radius) gives the world point
|
|
1100
|
+
function render3DDrawSoftDisc(radius, color, sides, normal, pointAt)
|
|
1101
|
+
{
|
|
1102
|
+
const alpha = [1, .9, .7, 0], circle = render3DCircle(sides); // alpha by ring, center to rim
|
|
1103
|
+
for (let k = 0; k < 3; ++k)
|
|
1104
|
+
{
|
|
1105
|
+
const points = [], colors = [];
|
|
1106
|
+
const c0 = color.withAlpha(color.a * alpha[k]), c1 = color.withAlpha(color.a * alpha[k+1]);
|
|
1107
|
+
const r0 = radius * k / 3, r1 = radius * (k + 1) / 3;
|
|
1108
|
+
for (let i = 0; i <= sides; ++i)
|
|
1109
|
+
{
|
|
1110
|
+
const c = circle[i*2], s = circle[i*2 + 1];
|
|
1111
|
+
points.push(pointAt(c, s, r1), pointAt(c, s, r0));
|
|
1112
|
+
colors.push(c1, c0);
|
|
1113
|
+
}
|
|
1114
|
+
render3D.drawStripUnlit(points, normal, undefined, colors);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
1119
|
+
// Debug primitives, drawn on top of the 3D scene like the 2D debug functions, only in debug builds
|
|
1120
|
+
|
|
1121
|
+
let render3DDebugPrimitives = [];
|
|
1122
|
+
|
|
1123
|
+
// draw the live debug primitives with depth test off so they show through walls, drop the expired ones
|
|
1124
|
+
function render3DRenderDebug()
|
|
1125
|
+
{
|
|
1126
|
+
if (!render3DDebugPrimitives.length) return;
|
|
1127
|
+
render3DWithState({lighting: false, depthTest: false, receiveShadow: false, additive: false, shader: undefined}, ()=>
|
|
1128
|
+
{
|
|
1129
|
+
for (const p of render3DDebugPrimitives)
|
|
1130
|
+
p.draw();
|
|
1131
|
+
});
|
|
1132
|
+
render3DDebugPrimitives = render3DDebugPrimitives.filter(p=> p.timer < 0); // a Timer compares as negative until it elapses
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// record a debug draw for a time
|
|
1136
|
+
function render3DDebugPush(duration, draw)
|
|
1137
|
+
{
|
|
1138
|
+
ASSERT(isNumber(duration), 'duration must be a number');
|
|
1139
|
+
debug && render3D?.program && render3DDebugPrimitives.push({timer: new Timer(duration), draw});
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/** Draw a debug wireframe box
|
|
1143
|
+
* @param {Vector3} pos - Center
|
|
1144
|
+
* @param {Vector3|number} [size] - Full size, a number for a cube
|
|
1145
|
+
* @param {Color} [color]
|
|
1146
|
+
* @param {number} [time] - How long to show it, 0 is one frame
|
|
1147
|
+
* @param {Vector3} [rotation] - vec3(pitch, yaw, roll)
|
|
1148
|
+
* @memberof Render3D */
|
|
1149
|
+
function debugBox3D(pos, size=1, color=WHITE, time=0, rotation)
|
|
1150
|
+
{
|
|
1151
|
+
const matrix = buildMatrix(pos, rotation, render3DSize3(size));
|
|
1152
|
+
const corner = (i)=> matrix.transformPoint(vec3(i & 1 ? .5 : -.5, i & 2 ? .5 : -.5, i & 4 ? .5 : -.5));
|
|
1153
|
+
render3DDebugPush(time, ()=>
|
|
1154
|
+
{
|
|
1155
|
+
for (let i = 0; i < 8; ++i)
|
|
1156
|
+
for (const bit of [1, 2, 4])
|
|
1157
|
+
if (!(i & bit))
|
|
1158
|
+
render3D.drawLine(corner(i), corner(i | bit), RENDER3D_DEBUG_WIDTH, color);
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/** Draw a debug wireframe sphere as three rings
|
|
1163
|
+
* @param {Vector3} pos - Center
|
|
1164
|
+
* @param {number} [size] - Diameter
|
|
1165
|
+
* @param {Color} [color]
|
|
1166
|
+
* @param {number} [time] - How long to show it, 0 is one frame
|
|
1167
|
+
* @memberof Render3D */
|
|
1168
|
+
function debugSphere3D(pos, size=1, color=WHITE, time=0)
|
|
1169
|
+
{
|
|
1170
|
+
const circle = render3DCircle(24), r = size / 2;
|
|
1171
|
+
render3DDebugPush(time, ()=>
|
|
1172
|
+
{
|
|
1173
|
+
for (const ring of [(c, s)=> vec3(c, s, 0), (c, s)=> vec3(c, 0, s), (c, s)=> vec3(0, c, s)])
|
|
1174
|
+
{
|
|
1175
|
+
const points = [];
|
|
1176
|
+
for (let i = 0; i <= 24; ++i)
|
|
1177
|
+
points.push(pos.add(ring(circle[i*2], circle[i*2 + 1]).scale(r)));
|
|
1178
|
+
render3D.drawRibbon(points, RENDER3D_DEBUG_WIDTH, undefined, color);
|
|
1179
|
+
}
|
|
1180
|
+
});
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/** Draw a debug line
|
|
1184
|
+
* @param {Vector3} posA
|
|
1185
|
+
* @param {Vector3} posB
|
|
1186
|
+
* @param {Color} [color]
|
|
1187
|
+
* @param {number} [width]
|
|
1188
|
+
* @param {number} [time] - How long to show it, 0 is one frame
|
|
1189
|
+
* @memberof Render3D */
|
|
1190
|
+
function debugLine3D(posA, posB, color=WHITE, width=RENDER3D_DEBUG_WIDTH, time=0)
|
|
1191
|
+
{
|
|
1192
|
+
render3DDebugPush(time, ()=> render3D.drawLine(posA, posB, width, color));
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/** Draw a debug point as a small cross of three lines
|
|
1196
|
+
* @param {Vector3} pos
|
|
1197
|
+
* @param {Color} [color]
|
|
1198
|
+
* @param {number} [time] - How long to show it, 0 is one frame
|
|
1199
|
+
* @param {number} [size] - Length of the cross
|
|
1200
|
+
* @memberof Render3D */
|
|
1201
|
+
function debugPoint3D(pos, color=WHITE, time=0, size=.2)
|
|
1202
|
+
{
|
|
1203
|
+
render3DDebugPush(time, ()=>
|
|
1204
|
+
{
|
|
1205
|
+
for (const axis of [vec3(size / 2, 0, 0), vec3(0, size / 2, 0), vec3(0, 0, size / 2)])
|
|
1206
|
+
render3D.drawLine(pos.subtract(axis), pos.add(axis), RENDER3D_DEBUG_WIDTH, color);
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
1211
|
+
/**
|
|
1212
|
+
* Camera3D - Position, rotation and lens for the 3D view
|
|
1213
|
+
* - Looks down its -Z axis, rotation is vec3(pitch, yaw, roll)
|
|
1214
|
+
* @memberof Render3D
|
|
1215
|
+
*/
|
|
1216
|
+
class Camera3D
|
|
1217
|
+
{
|
|
1218
|
+
/** Create a camera, looking down -Z from z=10 by default */
|
|
1219
|
+
constructor()
|
|
1220
|
+
{
|
|
1221
|
+
/** @property {Vector3} - World position */
|
|
1222
|
+
this.pos = vec3(0, 0, 10);
|
|
1223
|
+
/** @property {Vector3} - Euler rotation, vec3(pitch, yaw, roll) in radians */
|
|
1224
|
+
this.rotation = vec3();
|
|
1225
|
+
/** @property {number} - Vertical field of view in radians */
|
|
1226
|
+
this.fov = PI/3;
|
|
1227
|
+
/** @property {number} - Near clip distance */
|
|
1228
|
+
this.near = .1;
|
|
1229
|
+
/** @property {number} - Far clip distance, Infinity is allowed for a perspective view */
|
|
1230
|
+
this.far = 1e3;
|
|
1231
|
+
/** @property {number} - Visible height in world units for an orthographic view, 0 is perspective */
|
|
1232
|
+
this.orthographic = 0;
|
|
1233
|
+
/** @property {boolean} - Line the 3D camera up with the 2D camera, so 3D things at z=0 sit on the 2D sprites */
|
|
1234
|
+
this.align2D = false;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/** Returns the camera's world transform
|
|
1238
|
+
* @return {Matrix4} */
|
|
1239
|
+
getMatrix() { return buildMatrix(this.pos, this.rotation); }
|
|
1240
|
+
|
|
1241
|
+
/** Returns the view matrix, world to camera space
|
|
1242
|
+
* @return {Matrix4} */
|
|
1243
|
+
getViewMatrix() { return this.getMatrix().invert(); }
|
|
1244
|
+
|
|
1245
|
+
/** Returns the projection matrix
|
|
1246
|
+
* @param {number} aspect - Width over height
|
|
1247
|
+
* @return {Matrix4} */
|
|
1248
|
+
getProjectionMatrix(aspect)
|
|
1249
|
+
{
|
|
1250
|
+
const h = this.orthographic / 2, w = h * aspect;
|
|
1251
|
+
return h ? Matrix4.orthographic(-w, w, -h, h, this.near, this.far) : Matrix4.perspective(this.fov, aspect, this.near, this.far);
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/** Returns the direction the camera looks
|
|
1255
|
+
* @return {Vector3} */
|
|
1256
|
+
getForward() { return render3DAxis(this.getMatrix().m, 8).scale(-1); }
|
|
1257
|
+
|
|
1258
|
+
/** Returns the camera's right axis
|
|
1259
|
+
* @return {Vector3} */
|
|
1260
|
+
getRight() { return render3DAxis(this.getMatrix().m, 0); }
|
|
1261
|
+
|
|
1262
|
+
/** Returns the camera's up axis
|
|
1263
|
+
* @return {Vector3} */
|
|
1264
|
+
getUp() { return render3DAxis(this.getMatrix().m, 4); }
|
|
1265
|
+
|
|
1266
|
+
/** Point the camera at a target, sets pitch and yaw and clears roll
|
|
1267
|
+
* @param {Vector3} target */
|
|
1268
|
+
lookAt(target) { this.rotation = render3DLookRotation(target.subtract(this.pos), this.rotation); }
|
|
1269
|
+
|
|
1270
|
+
/** Put the camera on an orbit around a target, looking at it
|
|
1271
|
+
* @param {Vector3} target
|
|
1272
|
+
* @param {number} distance
|
|
1273
|
+
* @param {number} yaw - Radians around Y
|
|
1274
|
+
* @param {number} [pitch] - Radians above the horizon */
|
|
1275
|
+
orbit(target, distance, yaw, pitch=.5)
|
|
1276
|
+
{
|
|
1277
|
+
const r = cos(pitch) * distance;
|
|
1278
|
+
this.pos = target.add(vec3(sin(yaw) * r, sin(pitch) * distance, cos(yaw) * r));
|
|
1279
|
+
this.lookAt(target);
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/** Chase a target from an offset, easing toward it, and look at it
|
|
1283
|
+
* @param {Vector3} target
|
|
1284
|
+
* @param {Vector3} offset - Where to sit relative to the target
|
|
1285
|
+
* @param {number} [percent] - How far to move toward the spot each call, 1 snaps */
|
|
1286
|
+
follow(target, offset, percent=1)
|
|
1287
|
+
{
|
|
1288
|
+
this.pos = this.pos.lerp(target.add(offset), percent);
|
|
1289
|
+
this.lookAt(target);
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
/** Line the 3D camera up with the 2D camera, called automatically when align2D is set
|
|
1293
|
+
* @param {number} [canvasHeight] - Defaults to the main canvas height */
|
|
1294
|
+
update2D(canvasHeight=mainCanvasSize.y)
|
|
1295
|
+
{
|
|
1296
|
+
const halfHeight = canvasHeight / 2 / cameraScale; // half visible height in world units
|
|
1297
|
+
const distance = halfHeight / tan(this.fov/2);
|
|
1298
|
+
// a zoomed out 2D camera sits a long way back, far enough to fall past the far plane and
|
|
1299
|
+
// clip the whole scene away, which looks like nothing rendering at all
|
|
1300
|
+
ASSERT(!canvasHeight || distance < this.far,
|
|
1301
|
+
'align2D needs this camera distance to match the 2D view, raise camera.far past it', distance);
|
|
1302
|
+
this.orthographic &&= halfHeight * 2; // an orthographic camera stays orthographic and shows the same height
|
|
1303
|
+
this.pos = vec3(cameraPos.x, cameraPos.y, distance);
|
|
1304
|
+
this.rotation = vec3(0, 0, -cameraAngle); // 2D angles turn the other way
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
1309
|
+
// GL setup, shaders and the frame hooks
|
|
1310
|
+
|
|
1311
|
+
// the four attributes of a 36 byte vertex at the locations the shaders declare: location, size, type, normalize, byte offset
|
|
1312
|
+
const RENDER3D_ATTRIBS = [[0, 3, 5126, false, 0], [1, 3, 5126, false, 12], [2, 2, 5126, false, 24], [3, 4, 5121, true, 32]];
|
|
1313
|
+
|
|
1314
|
+
// the vertex shader, shared by the plugin's program and every Shader's
|
|
1315
|
+
// attributes: p position, n normal, t uv, c color, at fixed slots the depth shader also uses
|
|
1316
|
+
// uniforms: viewProj, lightViewProj; the model matrix, the normal matrix, the tint and the uv rect are vertex
|
|
1317
|
+
// attributes, see RENDER3D_VERTEX_INPUTS; L is the mesh's own uv for a Shader's localUV
|
|
1318
|
+
const RENDER3D_VERTEX_SOURCE =
|
|
1319
|
+
'#version 300 es\n' +
|
|
1320
|
+
'precision highp float;' +
|
|
1321
|
+
'uniform mat4 viewProj,lightViewProj;' +
|
|
1322
|
+
RENDER3D_VERTEX_INPUTS +
|
|
1323
|
+
'out vec3 P,N;out vec2 T,L;out vec4 C,S;' +
|
|
1324
|
+
'void main(){' +
|
|
1325
|
+
'vec4 w=mat4(m0,m1,m2,m3)*vec4(p,1.);' +
|
|
1326
|
+
'gl_Position=viewProj*w;' +
|
|
1327
|
+
'P=w.xyz;' +
|
|
1328
|
+
'N=mat3(n0,n1,n2)*n;' +
|
|
1329
|
+
'T=uvRect.xy+t*uvRect.zw;' +
|
|
1330
|
+
'L=t;' +
|
|
1331
|
+
'C=c*tint;' +
|
|
1332
|
+
'S=lightViewProj*w;' +
|
|
1333
|
+
'}';
|
|
1334
|
+
|
|
1335
|
+
// the names a Shader's snippet can use in 3D, over the plugin's own uniforms and varyings
|
|
1336
|
+
const RENDER3D_SNIPPET_NAMES =
|
|
1337
|
+
'uniform float iTime;uniform vec3 iResolution;\n' +
|
|
1338
|
+
'#define iChannel0 tex\n' +
|
|
1339
|
+
'#define localUV L\n' +
|
|
1340
|
+
'#define worldPos P\n' +
|
|
1341
|
+
'#define worldNormal N\n' +
|
|
1342
|
+
'#define sunDirection (-lightDir.xyz)\n' +
|
|
1343
|
+
'#define sunColor lightColor.rgb\n' +
|
|
1344
|
+
'#define ambientColor ambientFog.rgb\n' +
|
|
1345
|
+
'#define lightCount extraLightCount\n' +
|
|
1346
|
+
'#define lights extraLights\n' +
|
|
1347
|
+
'#define lightColors extraLightColors\n';
|
|
1348
|
+
|
|
1349
|
+
// the fragment shader; given a Shader's snippet, its mainImage replaces the texture sample and all else is the same
|
|
1350
|
+
// uniforms: lightDir (xyz the way the sunlight travels, w = emissive, 1 or more skips the lighting),
|
|
1351
|
+
// lightColor (the sun's rgb, a = specular), ambientFog (rgb, a = fogEnd), fogColor (rgb, a = fogStart),
|
|
1352
|
+
// cameraPos, tex, shadowMap, shadowParams (x = shadows on, y = bias, z = blur step in texture space,
|
|
1353
|
+
// w = how the draw finishes: 1 opaque and alpha tested, 0 blended, -1 additive)
|
|
1354
|
+
function render3DFragmentSource(fragmentCode)
|
|
1355
|
+
{
|
|
1356
|
+
return '#version 300 es\n' +
|
|
1357
|
+
'precision highp float;' +
|
|
1358
|
+
'uniform vec4 lightDir,lightColor,ambientFog,fogColor,shadowParams;' +
|
|
1359
|
+
'uniform vec4 extraLights[' + RENDER3D_MAX_LIGHTS + '],extraLightColors[' + RENDER3D_MAX_LIGHTS + '];' +
|
|
1360
|
+
'uniform int extraLightCount;' +
|
|
1361
|
+
'uniform vec3 cameraPos;' +
|
|
1362
|
+
'uniform sampler2D tex;' +
|
|
1363
|
+
'uniform highp sampler2DShadow shadowMap;' +
|
|
1364
|
+
'in vec3 P,N;in vec2 T,L;in vec4 C,S;' +
|
|
1365
|
+
'out vec4 o;' +
|
|
1366
|
+
// the sun shadow at this fragment, 0 to 1: the light's depth map with a 3x3 blur, outside the map is lit
|
|
1367
|
+
'float shadow(){' +
|
|
1368
|
+
'if(shadowParams.x<=0.)return 1.;' +
|
|
1369
|
+
'vec3 q=S.xyz/S.w*.5+.5;' +
|
|
1370
|
+
'if(any(greaterThanEqual(abs(q-.5),vec3(.5))))return 1.;' +
|
|
1371
|
+
'q.z-=shadowParams.y;' +
|
|
1372
|
+
'float s=0.;' +
|
|
1373
|
+
'for(int x=-1;x<=1;++x)for(int y=-1;y<=1;++y)' +
|
|
1374
|
+
's+=texture(shadowMap,vec3(q.xy+vec2(x,y)*shadowParams.z,q.z));' +
|
|
1375
|
+
'return s/9.;}' +
|
|
1376
|
+
(fragmentCode ? RENDER3D_SNIPPET_NAMES + fragmentCode + '\n' : '') +
|
|
1377
|
+
'void main(){' +
|
|
1378
|
+
(fragmentCode ? 'vec4 t;mainImage(t,T);' : 'vec4 t=texture(tex,T);') +
|
|
1379
|
+
'if(shadowParams.w>0.&&t.a<.5)discard;' + // an opaque draw drops see through texels, as the shadow map does
|
|
1380
|
+
'vec4 c=C*t;' +
|
|
1381
|
+
'float e=lightDir.w;' +
|
|
1382
|
+
'if(e<1.){' +
|
|
1383
|
+
'vec3 n=dot(N,N)>0.?normalize(N):vec3(0,1,0);' +
|
|
1384
|
+
'if(!gl_FrontFacing)n=-n;' + // only a double sided mesh shows a back face, light it on the side that is seen
|
|
1385
|
+
'float nl=dot(n,-lightDir.xyz);' +
|
|
1386
|
+
'float s=shadow();' +
|
|
1387
|
+
'vec3 l=ambientFog.rgb+lightColor.rgb*max(nl,0.)*s;' +
|
|
1388
|
+
// the Light3D objects, diffuse only: a point light falls off with distance, a directional one does not and
|
|
1389
|
+
// carries the direction toward it in xyz, marked by a negative radius
|
|
1390
|
+
'for(int i=0;i<' + RENDER3D_MAX_LIGHTS + ';++i){' +
|
|
1391
|
+
'if(i>=extraLightCount)break;' +
|
|
1392
|
+
'vec4 L=extraLights[i];' +
|
|
1393
|
+
'bool directional=L.w<0.;' +
|
|
1394
|
+
'vec3 v=directional?L.xyz:L.xyz-P;' +
|
|
1395
|
+
'float d=length(v);' +
|
|
1396
|
+
'float a=directional?1.:max(0.,1.-d/L.w);' +
|
|
1397
|
+
'l+=extraLightColors[i].rgb*extraLightColors[i].a*a*a*max(0.,dot(n,v/max(d,1e-6)));' +
|
|
1398
|
+
'}' +
|
|
1399
|
+
'c.rgb*=l*(1.-e)+e;' + // lit, blended toward its own color by how emissive it is
|
|
1400
|
+
// specular: only where the light hits, skipped entirely when the strength is zero
|
|
1401
|
+
'if(lightColor.a>0.){' +
|
|
1402
|
+
'vec3 v=normalize(cameraPos-P);' +
|
|
1403
|
+
'vec3 r=reflect(lightDir.xyz,n);' +
|
|
1404
|
+
'c.rgb+=lightColor.rgb*pow(max(dot(r,v),0.),16.)*lightColor.a*step(0.,nl)*s*(1.-e);' +
|
|
1405
|
+
'}}else c.rgb*=e;' + // fully emissive: its own color, or brighter, with no lighting to work out
|
|
1406
|
+
'if(ambientFog.a>0.){' +
|
|
1407
|
+
'float z=distance(cameraPos,P);' +
|
|
1408
|
+
'c.rgb=mix(c.rgb,shadowParams.w<0.?vec3(0):fogColor.rgb,smoothstep(fogColor.a,ambientFog.a,z));' +
|
|
1409
|
+
'}' +
|
|
1410
|
+
'o=vec4(c.rgb,shadowParams.w>0.?1.:c.a);' + // an opaque draw stays opaque whatever the tint alpha says
|
|
1411
|
+
'}';
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
// a Shader's 3D program, compiled the first time a draw needs it
|
|
1415
|
+
function render3DShaderProgram(shader)
|
|
1416
|
+
{
|
|
1417
|
+
ASSERT(shader instanceof Shader, 'render3D.shader must be a Shader, not the snippet itself');
|
|
1418
|
+
return shader.program3D ||= glCreateProgram(RENDER3D_VERTEX_SOURCE, render3DFragmentSource(shader.fragmentCode));
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
// make a program current for the pass and send it the pass uniforms: the matrices, the camera and the lights,
|
|
1422
|
+
// plus the time and canvas size for a Shader's program; the per draw uniform cache starts over
|
|
1423
|
+
function render3DUseProgram(program)
|
|
1424
|
+
{
|
|
1425
|
+
const gl = glContext, r = render3D;
|
|
1426
|
+
gl.useProgram(r.currentProgram = program);
|
|
1427
|
+
r.uniformValues = {};
|
|
1428
|
+
gl.uniformMatrix4fv(render3DUniform('viewProj'), false, r.viewProjection.m);
|
|
1429
|
+
gl.uniformMatrix4fv(render3DUniform('lightViewProj'), false, r.shadowMatrix.m);
|
|
1430
|
+
gl.uniform1i(render3DUniform('tex'), 0);
|
|
1431
|
+
gl.uniform1i(render3DUniform('shadowMap'), 1);
|
|
1432
|
+
const c = r.camera.pos;
|
|
1433
|
+
gl.uniform3f(render3DUniform('cameraPos'), c.x, c.y, c.z);
|
|
1434
|
+
gl.uniform1i(render3DUniform('extraLightCount'), r.lightCount);
|
|
1435
|
+
if (r.lightCount)
|
|
1436
|
+
{
|
|
1437
|
+
gl.uniform4fv(render3DUniform('extraLights'), r.lightPositions, 0, r.lightCount * 4);
|
|
1438
|
+
gl.uniform4fv(render3DUniform('extraLightColors'), r.lightColors, 0, r.lightCount * 4);
|
|
1439
|
+
}
|
|
1440
|
+
if (program !== r.program)
|
|
1441
|
+
{
|
|
1442
|
+
gl.uniform1f(render3DUniform('iTime'), time);
|
|
1443
|
+
gl.uniform3f(render3DUniform('iResolution'), glCanvas.width, glCanvas.height, 1);
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
function render3DInitGL()
|
|
1448
|
+
{
|
|
1449
|
+
if (headlessMode) return;
|
|
1450
|
+
if (!glEnable || !glContext)
|
|
1451
|
+
{
|
|
1452
|
+
console.warn('Render3DPlugin: WebGL not enabled, construct the plugin in gameInit with glEnable set');
|
|
1453
|
+
return;
|
|
1454
|
+
}
|
|
1455
|
+
const gl = glContext, r = render3D;
|
|
1456
|
+
r.uniforms = new Map;
|
|
1457
|
+
r.uniformValues = {};
|
|
1458
|
+
r.attribValues = []; // a fresh context has its own attribute defaults, so nothing sent before it counts
|
|
1459
|
+
|
|
1460
|
+
// the shader, see RENDER3D_VERTEX_SOURCE and render3DFragmentSource
|
|
1461
|
+
r.program = glCreateProgram(RENDER3D_VERTEX_SOURCE, render3DFragmentSource());
|
|
1462
|
+
|
|
1463
|
+
// the depth only shader for the shadow map, same vertex layout; see through pixels cast nothing,
|
|
1464
|
+
// so sprites and cut out textures cast their outline
|
|
1465
|
+
r.shadowShader = glCreateProgram(
|
|
1466
|
+
'#version 300 es\n' +
|
|
1467
|
+
'precision highp float;' +
|
|
1468
|
+
'uniform mat4 viewProj;' +
|
|
1469
|
+
RENDER3D_VERTEX_INPUTS +
|
|
1470
|
+
'out vec2 T;' +
|
|
1471
|
+
'void main(){T=uvRect.xy+t*uvRect.zw;gl_Position=viewProj*mat4(m0,m1,m2,m3)*vec4(p,1.);}'
|
|
1472
|
+
,
|
|
1473
|
+
'#version 300 es\n' +
|
|
1474
|
+
'precision highp float;' +
|
|
1475
|
+
'uniform sampler2D tex;' +
|
|
1476
|
+
'in vec2 T;' +
|
|
1477
|
+
'void main(){if(texture(tex,T).a<.5)discard;}'
|
|
1478
|
+
);
|
|
1479
|
+
|
|
1480
|
+
// the vertex array object with the attributes enabled once, pointers are set per buffer by render3DBindVertexBuffer
|
|
1481
|
+
r.vao = gl.createVertexArray();
|
|
1482
|
+
gl.bindVertexArray(r.vao);
|
|
1483
|
+
for (const [location] of RENDER3D_ATTRIBS)
|
|
1484
|
+
gl.enableVertexAttribArray(location);
|
|
1485
|
+
for (const [location] of RENDER3D_INSTANCE_ATTRIBS)
|
|
1486
|
+
gl.vertexAttribDivisor(location, 1); // one value per instance whenever a batch turns these arrays on
|
|
1487
|
+
|
|
1488
|
+
// the stream buffer
|
|
1489
|
+
r.streamBuffer = gl.createBuffer();
|
|
1490
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, r.streamBuffer);
|
|
1491
|
+
gl.bufferData(gl.ARRAY_BUFFER, r.streamData.byteLength, gl.DYNAMIC_DRAW);
|
|
1492
|
+
r.streamCount = 0;
|
|
1493
|
+
r.instanceBuffers = [gl.createBuffer(), gl.createBuffer(), gl.createBuffer()];
|
|
1494
|
+
|
|
1495
|
+
// white texture for untextured draws, and a one texel shadow map that keeps the shadow sampler valid until shadows are on
|
|
1496
|
+
r.whiteTexture = glCreateTexture();
|
|
1497
|
+
r.mipmapped = new WeakSet;
|
|
1498
|
+
|
|
1499
|
+
r.samplers = [];
|
|
1500
|
+
r.samplerKey = undefined;
|
|
1501
|
+
render3DUpdateShadowMap(1);
|
|
1502
|
+
|
|
1503
|
+
// hand the engine back its own buffer and vertex array, in that order so a pending 2D batch flushes right
|
|
1504
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, glArrayBuffer);
|
|
1505
|
+
glSetInstancedMode(true);
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
function render3DContextLost()
|
|
1509
|
+
{
|
|
1510
|
+
const r = render3D;
|
|
1511
|
+
r.program = r.currentProgram = r.shadowShader = r.vao = r.streamBuffer = r.whiteTexture = undefined;
|
|
1512
|
+
for (const shader of glShaderObjects)
|
|
1513
|
+
shader.program3D = undefined; // compiled again by the next draw
|
|
1514
|
+
r.lightCount = 0;
|
|
1515
|
+
r.instanceBuffers = r.samplers = [];
|
|
1516
|
+
r.samplerKey = undefined;
|
|
1517
|
+
render3DClearInstances();
|
|
1518
|
+
r.shadowFramebuffer = r.shadowTexture = undefined;
|
|
1519
|
+
r.shadowTextureSize = 0;
|
|
1520
|
+
r.streamCount = 0;
|
|
1521
|
+
++r.contextGeneration; // every uploaded mesh is stale now, the soft dot is a TextureInfo the engine restores
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
function render3DContextRestored()
|
|
1525
|
+
{
|
|
1526
|
+
render3DInitGL();
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
// a uniform location, looked up once per program
|
|
1530
|
+
function render3DUniform(name, program=render3D.currentProgram)
|
|
1531
|
+
{
|
|
1532
|
+
const u = render3D.uniforms;
|
|
1533
|
+
let cache = u.get(program);
|
|
1534
|
+
cache || u.set(program, cache = {});
|
|
1535
|
+
return cache[name] ??= glContext.getUniformLocation(program, name);
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
// the model matrix, its normal matrix, the tint and the uv rect as constant attributes for one draw
|
|
1539
|
+
const render3DNormalScratch = new Float32Array(9);
|
|
1540
|
+
function render3DDrawAttribs(m, tint, uvRect)
|
|
1541
|
+
{
|
|
1542
|
+
const gl = glContext;
|
|
1543
|
+
gl.vertexAttrib4f(4, m[0], m[1], m[2], m[3]);
|
|
1544
|
+
gl.vertexAttrib4f(5, m[4], m[5], m[6], m[7]);
|
|
1545
|
+
gl.vertexAttrib4f(6, m[8], m[9], m[10], m[11]);
|
|
1546
|
+
gl.vertexAttrib4f(7, m[12], m[13], m[14], m[15]);
|
|
1547
|
+
if (!render3D.shadowPass) // the shadow map has no lighting
|
|
1548
|
+
{
|
|
1549
|
+
const n = render3DNormalMatrix3(m, render3DNormalScratch, 0);
|
|
1550
|
+
gl.vertexAttrib3f(8, n[0], n[1], n[2]);
|
|
1551
|
+
gl.vertexAttrib3f(9, n[3], n[4], n[5]);
|
|
1552
|
+
gl.vertexAttrib3f(10, n[6], n[7], n[8]);
|
|
1553
|
+
}
|
|
1554
|
+
render3DAttrib4f(11, tint.r, tint.g, tint.b, tint.a);
|
|
1555
|
+
render3DAttrib4f(12, uvRect.x, uvRect.y, uvRect.w, uvRect.h);
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
// set a constant vec4 attribute only when its value changed since the last time
|
|
1559
|
+
function render3DAttrib4f(location, x, y, z, w)
|
|
1560
|
+
{
|
|
1561
|
+
const values = render3D.attribValues, last = values[location];
|
|
1562
|
+
if (last && last[0] === x && last[1] === y && last[2] === z && last[3] === w)
|
|
1563
|
+
return;
|
|
1564
|
+
values[location] = [x, y, z, w];
|
|
1565
|
+
glContext.vertexAttrib4f(location, x, y, z, w);
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
// write the 3x3 matrix that keeps normals pointing out when the model matrix scales unevenly, the inverse transpose
|
|
1569
|
+
// of its top left 3x3 by cofactors; a flat model with no inverse keeps its own axes
|
|
1570
|
+
function render3DNormalMatrix3(m, out, offset)
|
|
1571
|
+
{
|
|
1572
|
+
const a = m[0], b = m[1], c = m[2], d = m[4], e = m[5], f = m[6], g = m[8], h = m[9], i = m[10];
|
|
1573
|
+
const c00 = e*i - h*f, c01 = h*c - b*i, c02 = b*f - e*c;
|
|
1574
|
+
const det = a*c00 + d*c01 + g*c02;
|
|
1575
|
+
if (abs(det) < 1e-12)
|
|
1576
|
+
{
|
|
1577
|
+
out[offset] = a; out[offset+1] = b; out[offset+2] = c;
|
|
1578
|
+
out[offset+3] = d; out[offset+4] = e; out[offset+5] = f;
|
|
1579
|
+
out[offset+6] = g; out[offset+7] = h; out[offset+8] = i;
|
|
1580
|
+
return out;
|
|
1581
|
+
}
|
|
1582
|
+
const s = 1 / det;
|
|
1583
|
+
out[offset] = c00 * s; out[offset+1] = (g*f - d*i) * s; out[offset+2] = (d*h - g*e) * s;
|
|
1584
|
+
out[offset+3] = c01 * s; out[offset+4] = (a*i - g*c) * s; out[offset+5] = (g*b - a*h) * s;
|
|
1585
|
+
out[offset+6] = c02 * s; out[offset+7] = (d*c - a*f) * s; out[offset+8] = (a*e - d*b) * s;
|
|
1586
|
+
return out;
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
// textures in 3D shrink into the distance far more than sprites do, so the pass samples them through their mipmaps;
|
|
1590
|
+
// a sampler sets the filtering for the 3D pass only and leaves the engine's textures as they are for 2D, one for
|
|
1591
|
+
// clamped textures and one for wrapping ones, rebuilt when the settings change
|
|
1592
|
+
function render3DUpdateSamplers()
|
|
1593
|
+
{
|
|
1594
|
+
const gl = glContext, r = render3D, key = tilesPixelated + ' ' + r.anisotropy;
|
|
1595
|
+
if (r.samplerKey === key) return;
|
|
1596
|
+
r.samplerKey = key;
|
|
1597
|
+
for (const sampler of r.samplers)
|
|
1598
|
+
gl.deleteSampler(sampler); // the set being replaced, a lost context empties this first
|
|
1599
|
+
const anisotropy = gl.getExtension('EXT_texture_filter_anisotropic');
|
|
1600
|
+
// four samplers: clamped and wrapping, each smooth or hard edged
|
|
1601
|
+
r.samplers = [false, true].flatMap(pixelated=> [gl.CLAMP_TO_EDGE, gl.REPEAT].map(wrap=>
|
|
1602
|
+
{
|
|
1603
|
+
const sampler = gl.createSampler();
|
|
1604
|
+
const sharp = pixelated || tilesPixelated;
|
|
1605
|
+
gl.samplerParameteri(sampler, gl.TEXTURE_MAG_FILTER, sharp ? gl.NEAREST : gl.LINEAR);
|
|
1606
|
+
gl.samplerParameteri(sampler, gl.TEXTURE_MIN_FILTER, pixelated ? gl.NEAREST
|
|
1607
|
+
: tilesPixelated ? gl.NEAREST_MIPMAP_LINEAR : gl.LINEAR_MIPMAP_LINEAR);
|
|
1608
|
+
gl.samplerParameteri(sampler, gl.TEXTURE_WRAP_S, wrap);
|
|
1609
|
+
gl.samplerParameteri(sampler, gl.TEXTURE_WRAP_T, wrap);
|
|
1610
|
+
if (anisotropy && !pixelated)
|
|
1611
|
+
{
|
|
1612
|
+
const most = gl.getParameter(anisotropy.MAX_TEXTURE_MAX_ANISOTROPY_EXT);
|
|
1613
|
+
gl.samplerParameterf(sampler, anisotropy.TEXTURE_MAX_ANISOTROPY_EXT, clamp(r.anisotropy, 1, most));
|
|
1614
|
+
}
|
|
1615
|
+
return sampler;
|
|
1616
|
+
}));
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
// bind the texture of a tile or texture, white when there is none or it is not loaded, with the 3D sampler that
|
|
1620
|
+
// matches its wrap mode; the first time a texture is used in 3D it gets its mipmaps
|
|
1621
|
+
function render3DBindTexture(tileInfo, state=render3D)
|
|
1622
|
+
{
|
|
1623
|
+
const gl = glContext, r = render3D;
|
|
1624
|
+
const textureInfo = tileInfo instanceof TileInfo ? tileInfo.textureInfo : tileInfo;
|
|
1625
|
+
const texture = textureInfo?.glTexture || r.whiteTexture;
|
|
1626
|
+
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
1627
|
+
if (!r.mipmaps && !state.pixelated)
|
|
1628
|
+
return gl.bindSampler(0, null); // the texture's own filtering, as in 2D
|
|
1629
|
+
gl.bindSampler(0, r.samplers[(textureInfo?.wrap ? 1 : 0) + (state.pixelated ? 2 : 0)]);
|
|
1630
|
+
if (!state.pixelated && !r.mipmapped.has(texture)) // a hard edged draw never reads them
|
|
1631
|
+
{
|
|
1632
|
+
r.mipmapped.add(texture);
|
|
1633
|
+
gl.generateMipmap(gl.TEXTURE_2D);
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
// send a vec4 uniform of the main shader only when its value changed since the last send
|
|
1638
|
+
function render3DUniform4f(name, x, y, z, w)
|
|
1639
|
+
{
|
|
1640
|
+
const values = render3D.uniformValues, last = values[name];
|
|
1641
|
+
if (last && last[0] === x && last[1] === y && last[2] === z && last[3] === w)
|
|
1642
|
+
return;
|
|
1643
|
+
values[name] = [x, y, z, w];
|
|
1644
|
+
glContext.uniform4f(render3DUniform(name), x, y, z, w);
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
// bind a vertex buffer and point the attributes at it
|
|
1648
|
+
function render3DBindVertexBuffer(buffer)
|
|
1649
|
+
{
|
|
1650
|
+
const gl = glContext;
|
|
1651
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
|
1652
|
+
for (const a of RENDER3D_ATTRIBS)
|
|
1653
|
+
gl.vertexAttribPointer(a[0], a[1], a[2], a[3], RENDER3D_VERTEX_BYTES, a[4]);
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
// where a tile sits in its texture, pulled in slightly at the edges so neighbors do not bleed in
|
|
1657
|
+
// this returns one shared object, so read it before calling again
|
|
1658
|
+
const render3DTileUVRect = {x:0, y:0, w:1, h:1};
|
|
1659
|
+
function render3DGetTileUVs(tileInfo)
|
|
1660
|
+
{
|
|
1661
|
+
if (!(tileInfo instanceof TileInfo))
|
|
1662
|
+
return RENDER3D_FULL_UV_RECT;
|
|
1663
|
+
const inv = tileInfo.textureInfo.sizeInverse, rect = render3DTileUVRect;
|
|
1664
|
+
const bleedX = inv.x * tileInfo.bleed, bleedY = inv.y * tileInfo.bleed;
|
|
1665
|
+
rect.x = tileInfo.pos.x * inv.x + bleedX;
|
|
1666
|
+
rect.y = tileInfo.pos.y * inv.y + bleedY;
|
|
1667
|
+
rect.w = tileInfo.size.x * inv.x - 2*bleedX;
|
|
1668
|
+
rect.h = tileInfo.size.y * inv.y - 2*bleedY;
|
|
1669
|
+
return rect;
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
// set the per draw uniforms and gl state for a draw, in the shadow pass only the model matrix of the depth shader
|
|
1673
|
+
// tileInfo may be a TileInfo, a TextureInfo, or undefined for the white texture
|
|
1674
|
+
// state is the plugin's current fields, or the captured state of a stream batch
|
|
1675
|
+
function render3DSetDrawUniforms(matrix, tileInfo, tint, uvRect, state=render3D)
|
|
1676
|
+
{
|
|
1677
|
+
const gl = glContext, r = render3D;
|
|
1678
|
+
|
|
1679
|
+
// the per draw values are constant vertex attributes, a batch turns on a per instance array over them
|
|
1680
|
+
uvRect ||= render3DGetTileUVs(tileInfo);
|
|
1681
|
+
render3DDrawAttribs(matrix.m, tint, uvRect);
|
|
1682
|
+
render3DBindTexture(tileInfo, state);
|
|
1683
|
+
if (r.shadowPass) return; // the shadow map needs nothing else
|
|
1684
|
+
|
|
1685
|
+
// the program: a Shader's own, compiled by its first draw, or the plugin's; switching sends the pass uniforms
|
|
1686
|
+
const program = state.shader ? render3DShaderProgram(state.shader) : r.program;
|
|
1687
|
+
program === r.currentProgram || render3DUseProgram(program);
|
|
1688
|
+
|
|
1689
|
+
// blending, matches the engine's 2D blend functions
|
|
1690
|
+
if (state.blend)
|
|
1691
|
+
{
|
|
1692
|
+
gl.enable(gl.BLEND);
|
|
1693
|
+
const destBlend = state.additive ? gl.ONE : gl.ONE_MINUS_SRC_ALPHA;
|
|
1694
|
+
gl.blendFuncSeparate(gl.SRC_ALPHA, destBlend, gl.ONE, destBlend);
|
|
1695
|
+
}
|
|
1696
|
+
else
|
|
1697
|
+
gl.disable(gl.BLEND);
|
|
1698
|
+
|
|
1699
|
+
// depth and culling
|
|
1700
|
+
state.depthTest ? gl.enable(gl.DEPTH_TEST) : gl.disable(gl.DEPTH_TEST);
|
|
1701
|
+
gl.depthMask(state.depthWrite);
|
|
1702
|
+
state.cullBackFaces ? gl.enable(gl.CULL_FACE) : gl.disable(gl.CULL_FACE);
|
|
1703
|
+
gl.frontFace(state.mirrored ? gl.CCW : gl.CW); // the pass's strips read clockwise, a mirror turns that around
|
|
1704
|
+
|
|
1705
|
+
// lights, fog and shadows are scene state read at draw time, sent only when they change
|
|
1706
|
+
// the shader takes the way the sunlight travels, away from the sun
|
|
1707
|
+
const s = r.sunDirection, sl = -(s.length() || 1), lc = r.sunColor, ac = r.ambientColor, fc = r.fogColor || canvasClearColor;
|
|
1708
|
+
render3DUniform4f('lightDir', s.x / sl, s.y / sl, s.z / sl, state.lighting ? state.emissive : 1);
|
|
1709
|
+
render3DUniform4f('lightColor', lc.r, lc.g, lc.b, state.specular);
|
|
1710
|
+
render3DUniform4f('ambientFog', ac.r, ac.g, ac.b, r.fogEnd);
|
|
1711
|
+
render3DUniform4f('fogColor', fc.r, fc.g, fc.b, r.fogStart);
|
|
1712
|
+
// how the fragment shader finishes: 1 drops see through texels and keeps the draw opaque,
|
|
1713
|
+
// 0 blends them away instead, and -1 is additive, which has to fade into fog differently
|
|
1714
|
+
const blendMode = state.blend ? (state.additive ? -1 : 0) : 1;
|
|
1715
|
+
render3DUniform4f('shadowParams', r.shadows && r.passIsDefault && state.receiveShadow ? 1 : 0, r.shadowBias, r.shadowSoftness / r.shadowTextureSize, blendMode);
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
// the six flat sides of the camera's visible box, each as [x, y, z, w] facing inward
|
|
1719
|
+
// a point is inside when x*px + y*py + z*pz + w is zero or more
|
|
1720
|
+
function render3DFrustumPlanes(matrix)
|
|
1721
|
+
{
|
|
1722
|
+
const m = matrix.m, planes = [];
|
|
1723
|
+
for (let i = 0; i < 3; ++i)
|
|
1724
|
+
for (const sign of [1, -1])
|
|
1725
|
+
{
|
|
1726
|
+
const p = [m[3] + sign * m[i], m[7] + sign * m[4+i], m[11] + sign * m[8+i], m[15] + sign * m[12+i]];
|
|
1727
|
+
const l = hypot(p[0], p[1], p[2]) || 1;
|
|
1728
|
+
planes.push(p.map(v=> v / l));
|
|
1729
|
+
}
|
|
1730
|
+
return planes;
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
// the preRender hook, before gameRender: the layer under the 2D scene
|
|
1734
|
+
function render3DPreRender()
|
|
1735
|
+
{
|
|
1736
|
+
const r = render3D;
|
|
1737
|
+
r.updateMatrices();
|
|
1738
|
+
r.shadowMapDrawn = false;
|
|
1739
|
+
render3DRenderPass(false);
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
// the render hook, after gameRenderPost: the layer on top of the 2D scene
|
|
1743
|
+
function render3DRender()
|
|
1744
|
+
{
|
|
1745
|
+
render3DRenderPass(true);
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
// one 3D pass for the objects of a layer: take over the gl state, draw the shadow map once a frame and the stages, hand the state back
|
|
1749
|
+
// the layer matching render3D.renderAfter2D is the default and always runs, the other only when an object asks for it
|
|
1750
|
+
function render3DRenderPass(after2D)
|
|
1751
|
+
{
|
|
1752
|
+
const gl = glContext, r = render3D;
|
|
1753
|
+
if (!r.program) return; // headless, gl disabled, or context lost
|
|
1754
|
+
render3DUpdateSamplers();
|
|
1755
|
+
ASSERT(!r.fogEnd || r.fogStart < r.fogEnd, 'fogStart must be less than fogEnd');
|
|
1756
|
+
ASSERT(!glRenderTarget, 'the 3D pass needs the canvas depth buffer, it can not draw into a render target');
|
|
1757
|
+
const isDefault = after2D === !!r.renderAfter2D, objects = render3DLayerObjects(after2D);
|
|
1758
|
+
if (!isDefault && !objects.length) return;
|
|
1759
|
+
r.passIsDefault = isDefault;
|
|
1760
|
+
after2D && glFlush(); // the 2D sprites drawn so far go under this layer
|
|
1761
|
+
|
|
1762
|
+
// a previous frame that threw must not leave anything pending
|
|
1763
|
+
r.streamCount = 0;
|
|
1764
|
+
r.capture = r.transparentQueue = undefined;
|
|
1765
|
+
render3DClearInstances();
|
|
1766
|
+
|
|
1767
|
+
// take over the gl state
|
|
1768
|
+
gl.bindVertexArray(r.vao);
|
|
1769
|
+
// the leading repeat on every strip shifts the triangles by one, which flips
|
|
1770
|
+
// which way they read, so tell WebGL that clockwise is the front here
|
|
1771
|
+
gl.frontFace(gl.CW);
|
|
1772
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
1773
|
+
gl.depthMask(true);
|
|
1774
|
+
gl.clear(gl.DEPTH_BUFFER_BIT);
|
|
1775
|
+
|
|
1776
|
+
// the Light3D objects, a directional one sends the direction toward it, from the origin, and a negative radius
|
|
1777
|
+
const lights = render3DCollectLights();
|
|
1778
|
+
r.lightCount = lights.length;
|
|
1779
|
+
const positions = r.lightPositions, colors = r.lightColors;
|
|
1780
|
+
lights.forEach((light, i)=>
|
|
1781
|
+
{
|
|
1782
|
+
const p = light.directional ? light.getWorldPos3D().normalize() : light.getWorldPos3D();
|
|
1783
|
+
ASSERT(!light.directional || p.lengthSquared(), 'a directional light shines from its position toward the origin, so it cannot sit on the origin');
|
|
1784
|
+
const c = light.color, k = i * 4;
|
|
1785
|
+
positions[k] = p.x, positions[k+1] = p.y, positions[k+2] = p.z;
|
|
1786
|
+
positions[k+3] = light.directional ? -1 : max(0, light.radius); // a negative radius marks a direction
|
|
1787
|
+
colors[k] = c.r, colors[k+1] = c.g, colors[k+2] = c.b, colors[k+3] = c.a * light.intensity;
|
|
1788
|
+
});
|
|
1789
|
+
|
|
1790
|
+
r.isRendering = true;
|
|
1791
|
+
try
|
|
1792
|
+
{
|
|
1793
|
+
// the shadow map from the light once a frame, then the stages sample it
|
|
1794
|
+
if (r.shadows && !r.shadowMapDrawn)
|
|
1795
|
+
{
|
|
1796
|
+
render3DRenderShadowMap();
|
|
1797
|
+
r.shadowMapDrawn = true;
|
|
1798
|
+
}
|
|
1799
|
+
render3DUseProgram(r.program); // after the shadow map, so the light matrix it sends is this frame's
|
|
1800
|
+
r.renderStages(objects, isDefault);
|
|
1801
|
+
}
|
|
1802
|
+
finally
|
|
1803
|
+
{
|
|
1804
|
+
// hand the state back to the engine's 2D batching, even when a draw threw
|
|
1805
|
+
r.isRendering = false;
|
|
1806
|
+
r.currentProgram = undefined; // the engine's 2D program takes over below
|
|
1807
|
+
r.streamCount = 0;
|
|
1808
|
+
r.capture = r.transparentQueue = undefined;
|
|
1809
|
+
gl.disable(gl.DEPTH_TEST);
|
|
1810
|
+
gl.disable(gl.CULL_FACE);
|
|
1811
|
+
gl.depthMask(true);
|
|
1812
|
+
gl.frontFace(gl.CCW);
|
|
1813
|
+
gl.bindSampler(0, null); // back to the textures' own filtering for 2D
|
|
1814
|
+
if (glActiveTexture)
|
|
1815
|
+
gl.bindTexture(gl.TEXTURE_2D, glActiveTexture);
|
|
1816
|
+
// ARRAY_BUFFER is not part of VAO state in WebGL2, so bindVertexArray alone would not restore it
|
|
1817
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, glArrayBuffer);
|
|
1818
|
+
glSetInstancedMode(true);
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
// create the shadow map depth texture and framebuffer at a size, or keep them when the size matches
|
|
1823
|
+
function render3DUpdateShadowMap(size)
|
|
1824
|
+
{
|
|
1825
|
+
const gl = glContext, r = render3D;
|
|
1826
|
+
ASSERT(size > 0, 'shadowMapSize must be positive');
|
|
1827
|
+
if (r.shadowTexture && r.shadowTextureSize === size) return;
|
|
1828
|
+
r.shadowTexture && gl.deleteTexture(r.shadowTexture);
|
|
1829
|
+
r.shadowFramebuffer && gl.deleteFramebuffer(r.shadowFramebuffer);
|
|
1830
|
+
const texture = r.shadowTexture = gl.createTexture();
|
|
1831
|
+
gl.activeTexture(gl.TEXTURE1);
|
|
1832
|
+
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
1833
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.DEPTH_COMPONENT24, size, size, 0, gl.DEPTH_COMPONENT, gl.UNSIGNED_INT, null);
|
|
1834
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); // smooth filtering softens shadow edges for free
|
|
1835
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
1836
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
1837
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
1838
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_COMPARE_MODE, gl.COMPARE_REF_TO_TEXTURE);
|
|
1839
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_COMPARE_FUNC, gl.LEQUAL);
|
|
1840
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
1841
|
+
const framebuffer = r.shadowFramebuffer = gl.createFramebuffer();
|
|
1842
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
|
|
1843
|
+
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, texture, 0);
|
|
1844
|
+
gl.drawBuffers([gl.NONE]); // depth only
|
|
1845
|
+
gl.readBuffer(gl.NONE);
|
|
1846
|
+
ASSERT(gl.checkFramebufferStatus(gl.FRAMEBUFFER) == gl.FRAMEBUFFER_COMPLETE, 'shadow map framebuffer is incomplete, try a smaller shadowMapSize');
|
|
1847
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
1848
|
+
r.shadowTextureSize = size;
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
// draw the lit opaque casters from the light into the shadow map with the depth only shader
|
|
1852
|
+
function render3DRenderShadowMap()
|
|
1853
|
+
{
|
|
1854
|
+
const gl = glContext, r = render3D;
|
|
1855
|
+
render3DUpdateShadowMap(r.shadowMapSize | 0);
|
|
1856
|
+
r.updateShadowMatrix();
|
|
1857
|
+
|
|
1858
|
+
// the map can not be read while it is drawn
|
|
1859
|
+
gl.activeTexture(gl.TEXTURE1);
|
|
1860
|
+
gl.bindTexture(gl.TEXTURE_2D, null);
|
|
1861
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
1862
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, r.shadowFramebuffer);
|
|
1863
|
+
gl.viewport(0, 0, r.shadowTextureSize, r.shadowTextureSize);
|
|
1864
|
+
gl.clear(gl.DEPTH_BUFFER_BIT);
|
|
1865
|
+
gl.useProgram(r.shadowShader);
|
|
1866
|
+
gl.uniformMatrix4fv(render3DUniform('viewProj', r.shadowShader), false, r.shadowMatrix.m);
|
|
1867
|
+
gl.enable(gl.DEPTH_TEST);
|
|
1868
|
+
gl.depthMask(true);
|
|
1869
|
+
gl.disable(gl.BLEND);
|
|
1870
|
+
gl.disable(gl.CULL_FACE);
|
|
1871
|
+
|
|
1872
|
+
r.shadowPass = true;
|
|
1873
|
+
try
|
|
1874
|
+
{
|
|
1875
|
+
// see through objects cast only when textured, their alpha cuts the shadow out
|
|
1876
|
+
const casters = render3DLayerObjects(!!r.renderAfter2D).filter(o=> o.castShadow && !o.additive && (!o.transparent || o.tileInfo));
|
|
1877
|
+
render3DDrawObjects(casters);
|
|
1878
|
+
r.onRenderOpaque?.();
|
|
1879
|
+
r.flush();
|
|
1880
|
+
render3DFlushInstances();
|
|
1881
|
+
}
|
|
1882
|
+
finally
|
|
1883
|
+
{
|
|
1884
|
+
// back to the frame with the map ready to sample
|
|
1885
|
+
r.shadowPass = false;
|
|
1886
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
1887
|
+
|
|
1888
|
+
// backing store size, mainCanvasSize is css pixels
|
|
1889
|
+
gl.viewport(0, 0, glCanvas.width, glCanvas.height);
|
|
1890
|
+
gl.activeTexture(gl.TEXTURE1);
|
|
1891
|
+
gl.bindTexture(gl.TEXTURE_2D, r.shadowTexture);
|
|
1892
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
1897
|
+
// Strips: every strip repeats its first point once at the start and its last
|
|
1898
|
+
// point once at the end. Those repeats make flat triangles with no area, which
|
|
1899
|
+
// are invisible, and they let one strip run straight into the next.
|
|
1900
|
+
// An odd count gets one more repeat at the end. Triangles in a strip alternate
|
|
1901
|
+
// which way they face, so keeping the count even keeps every strip facing out.
|
|
1902
|
+
|
|
1903
|
+
// make room in the stream for a strip of count vertices under the current state and texture, flushing a batch that
|
|
1904
|
+
// differs first; returns the uv rect to map the vertices with, or undefined when nothing can be drawn
|
|
1905
|
+
function render3DBeginStrip(count, tileInfo)
|
|
1906
|
+
{
|
|
1907
|
+
const r = render3D;
|
|
1908
|
+
if (!render3DCanDraw()) return;
|
|
1909
|
+
if (r.shadowPass && !r.lighting) return; // unlit things cast no shadow
|
|
1910
|
+
ASSERT(count <= RENDER3D_MAX_STREAM_VERTS, 'strip is too large for the stream, bake it into a mesh');
|
|
1911
|
+
if (count > RENDER3D_MAX_STREAM_VERTS) return;
|
|
1912
|
+
const textureInfo = tileInfo instanceof TileInfo ? tileInfo.textureInfo : tileInfo;
|
|
1913
|
+
if (r.streamCount && (textureInfo !== r.streamTileInfo || render3DStateChanged(r.streamState)
|
|
1914
|
+
|| r.streamCount + count > RENDER3D_MAX_STREAM_VERTS))
|
|
1915
|
+
r.flush();
|
|
1916
|
+
if (!r.streamCount)
|
|
1917
|
+
r.streamState = render3DCaptureBatchState();
|
|
1918
|
+
r.streamTileInfo = textureInfo;
|
|
1919
|
+
return render3DGetTileUVs(tileInfo);
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
// the four corners of a camera facing quad in strip order
|
|
1923
|
+
function render3DBillboardCorners(pos, size, angle, upright)
|
|
1924
|
+
{
|
|
1925
|
+
// an upright quad stands on world up and only turns to face the camera
|
|
1926
|
+
let r = render3D.cameraRight, u = render3D.cameraUp;
|
|
1927
|
+
if (upright)
|
|
1928
|
+
{
|
|
1929
|
+
const flat = vec3(r.x, 0, r.z);
|
|
1930
|
+
r = flat.lengthSquared() ? flat.normalize() : vec3(1, 0, 0); // a rolled camera has no flat right
|
|
1931
|
+
u = RENDER3D_DEFAULT_NORMAL;
|
|
1932
|
+
}
|
|
1933
|
+
const c = cos(angle), s = sin(angle), w = size.x / 2, h = size.y / 2;
|
|
1934
|
+
const rx = (r.x * c + u.x * s) * w, ry = (r.y * c + u.y * s) * w, rz = (r.z * c + u.z * s) * w;
|
|
1935
|
+
const ux = (u.x * c - r.x * s) * h, uy = (u.y * c - r.y * s) * h, uz = (u.z * c - r.z * s) * h;
|
|
1936
|
+
return [
|
|
1937
|
+
vec3(pos.x - rx + ux, pos.y - ry + uy, pos.z - rz + uz), vec3(pos.x - rx - ux, pos.y - ry - uy, pos.z - rz - uz),
|
|
1938
|
+
vec3(pos.x + rx + ux, pos.y + ry + uy, pos.z + rz + uz), vec3(pos.x + rx - ux, pos.y + ry - uy, pos.z + rz - uz)];
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
// how many vertices a strip of n points takes with its repeats, and which point vertex k of it is
|
|
1942
|
+
function render3DStripCount(n) { return n + 2 + (n & 1); }
|
|
1943
|
+
function render3DStripIndex(k, n) { return k < 1 ? 0 : k <= n ? k - 1 : n - 1; }
|
|
1944
|
+
|
|
1945
|
+
// walk a strip's vertices with the repeats applied, calling back with (point, normal, uv, color)
|
|
1946
|
+
// normals, uvs and colors may be one value for all points, an array per point, or undefined
|
|
1947
|
+
function render3DForEachStripVertex(points, normals, uvs, colors, callback)
|
|
1948
|
+
{
|
|
1949
|
+
ASSERT(isArray(points) && points.length > 2, 'strip needs at least 3 points');
|
|
1950
|
+
const n = points.length, count = render3DStripCount(n);
|
|
1951
|
+
const normalArray = isArray(normals), uvArray = isArray(uvs), colorArray = isArray(colors);
|
|
1952
|
+
for (let k = 0; k < count; ++k)
|
|
1953
|
+
{
|
|
1954
|
+
const i = render3DStripIndex(k, n);
|
|
1955
|
+
callback(points[i],
|
|
1956
|
+
normalArray ? normals[i] : normals || RENDER3D_DEFAULT_NORMAL,
|
|
1957
|
+
uvArray ? uvs[i] : uvs || RENDER3D_DEFAULT_UV,
|
|
1958
|
+
colorArray ? colors[i] : colors || WHITE);
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
// write one vertex into a packed buffer at float index j
|
|
1963
|
+
function render3DWriteVertex(floats, ints, j, p, n, u, v, rgba)
|
|
1964
|
+
{
|
|
1965
|
+
floats[j] = p.x; floats[j+1] = p.y; floats[j+2] = p.z;
|
|
1966
|
+
floats[j+3] = n.x; floats[j+4] = n.y; floats[j+5] = n.z;
|
|
1967
|
+
floats[j+6] = u; floats[j+7] = v;
|
|
1968
|
+
ints[j+8] = rgba;
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
// reorder a convex polygon's points, counter clockwise from outside, into one triangle strip
|
|
1972
|
+
function render3DPolygonStrip(points)
|
|
1973
|
+
{
|
|
1974
|
+
const strip = [points[0]];
|
|
1975
|
+
for (let i = 1, j = points.length - 1; i <= j; ++i, --j)
|
|
1976
|
+
{
|
|
1977
|
+
strip.push(points[i]);
|
|
1978
|
+
if (i !== j)
|
|
1979
|
+
strip.push(points[j]);
|
|
1980
|
+
}
|
|
1981
|
+
return strip;
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
1985
|
+
|
|
1986
|
+
// frees the GPU buffer of a mesh that is garbage collected without dispose, some time after it goes; it holds the
|
|
1987
|
+
// buffer and its context, never the mesh, or the mesh could not be collected, and dispose unregisters the mesh
|
|
1988
|
+
const render3DMeshBuffers = typeof FinalizationRegistry == 'undefined' ? undefined :
|
|
1989
|
+
new FinalizationRegistry(({buffer, generation})=>
|
|
1990
|
+
generation === render3D?.contextGeneration && glContext?.deleteBuffer(buffer));
|
|
1991
|
+
|
|
1992
|
+
/**
|
|
1993
|
+
* Mesh - A triangle strip with positions, normals, uvs and colors, uploaded once and drawn by matrix
|
|
1994
|
+
* - Build with addStrip, addQuad, combine or the shape builders, then render each frame
|
|
1995
|
+
* - Its back faces are skipped unless doubleSided is set, which the open builders like buildGrid do for you
|
|
1996
|
+
* - The GPU buffer is created lazily on first render and dropped by dispose, or freed once the mesh is garbage
|
|
1997
|
+
* collected, so dispose is only needed to free it right away, like for a mesh rebuilt often
|
|
1998
|
+
* @memberof Render3D
|
|
1999
|
+
* @example
|
|
2000
|
+
* const mesh = buildLathe([[0, -1], [1, 0], [0, 1]], 4); // octahedron
|
|
2001
|
+
* mesh.render(buildMatrix(vec3(0, 1, 0)), undefined, RED);
|
|
2002
|
+
*/
|
|
2003
|
+
class Mesh
|
|
2004
|
+
{
|
|
2005
|
+
/** Create an empty mesh */
|
|
2006
|
+
constructor()
|
|
2007
|
+
{
|
|
2008
|
+
/** @property {Array<Vector3>} - Vertex positions in strip order
|
|
2009
|
+
* @type {Array<Vector3>} */
|
|
2010
|
+
this.points = [];
|
|
2011
|
+
/** @property {Array<Vector3>} - Vertex normals
|
|
2012
|
+
* @type {Array<Vector3>} */
|
|
2013
|
+
this.normals = [];
|
|
2014
|
+
/** @property {Array<Vector2>} - Vertex texture coords, 0-1 across the tile
|
|
2015
|
+
* @type {Array<Vector2>} */
|
|
2016
|
+
this.uvs = [];
|
|
2017
|
+
/** @property {Array<Color>} - Vertex colors
|
|
2018
|
+
* @type {Array<Color>} */
|
|
2019
|
+
this.colors = [];
|
|
2020
|
+
/** @property {WebGLBuffer|undefined} - GPU buffer, created by upload
|
|
2021
|
+
* @type {WebGLBuffer|undefined} */
|
|
2022
|
+
this.buffer = undefined;
|
|
2023
|
+
/** @property {number} - Vertices in the GPU buffer */
|
|
2024
|
+
this.bufferCount = 0;
|
|
2025
|
+
/** @property {boolean} - The mesh changed and needs uploading again, set it yourself if you edit the arrays */
|
|
2026
|
+
this.dirty = false;
|
|
2027
|
+
/** @property {boolean|undefined} - Draw every use of this mesh in the opaque stage as one instanced call, undefined follows render3D.instancing
|
|
2028
|
+
* @type {boolean|undefined} */
|
|
2029
|
+
this.instanced = undefined;
|
|
2030
|
+
/** @property {boolean} - Draw both sides, each lit as the side that is seen; off skips the faces pointing away,
|
|
2031
|
+
* which is faster and right for closed shapes, the open builders like buildGrid and buildRibbon turn it on */
|
|
2032
|
+
this.doubleSided = false;
|
|
2033
|
+
this.instanceCount = 0; // draws waiting in this mesh's batch, with their values, texture and draw state
|
|
2034
|
+
this.instanceData = undefined;
|
|
2035
|
+
/** @property {number} - Bounding sphere radius around the origin, for culling and picking, computed by upload */
|
|
2036
|
+
this.radius = 0;
|
|
2037
|
+
this.contextGeneration = 0; // the context the buffer belongs to, see render3D.contextGeneration
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
/** Number of vertices in the mesh
|
|
2041
|
+
* @return {number} */
|
|
2042
|
+
get vertexCount() { return this.points.length; }
|
|
2043
|
+
|
|
2044
|
+
/** Add a triangle strip, joined to the previous one by invisible flat triangles so one mesh holds many strips
|
|
2045
|
+
* - Strip order: the first three points make a triangle, then each point makes another with the two before it
|
|
2046
|
+
* - List the first three points counter clockwise as seen from the front, or the face points away
|
|
2047
|
+
* and may vanish when back faces are culled
|
|
2048
|
+
* @param {Array<Vector3>} points - Strip order
|
|
2049
|
+
* @param {Vector3|Array<Vector3>} [normals] - One for all or one per point, default up
|
|
2050
|
+
* @param {Vector2|Array<Vector2>} [uvs] - One for all or one per point, default zero
|
|
2051
|
+
* @param {Color|Array<Color>} [colors] - One for all or one per point, default white
|
|
2052
|
+
* @return {Mesh} */
|
|
2053
|
+
addStrip(points, normals, uvs, colors)
|
|
2054
|
+
{
|
|
2055
|
+
render3DForEachStripVertex(points, normals, uvs, colors, (p, n, uv, c)=>
|
|
2056
|
+
{
|
|
2057
|
+
this.points.push(p);
|
|
2058
|
+
this.normals.push(n);
|
|
2059
|
+
this.uvs.push(uv);
|
|
2060
|
+
this.colors.push(c);
|
|
2061
|
+
});
|
|
2062
|
+
this.dirty = true;
|
|
2063
|
+
return this;
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
/** Add a flat quad from four corners in loop order, counter clockwise seen from the front, a is the top left of the texture
|
|
2067
|
+
* @param {Vector3} a
|
|
2068
|
+
* @param {Vector3} b
|
|
2069
|
+
* @param {Vector3} c
|
|
2070
|
+
* @param {Vector3} d
|
|
2071
|
+
* @param {Color|Array<Color>} [color] - One for all or one per corner
|
|
2072
|
+
* @param {Array<Vector2>} [uvs] - One per corner, default across the tile
|
|
2073
|
+
* @return {Mesh} */
|
|
2074
|
+
addQuad(a, b, c, d, color, uvs)
|
|
2075
|
+
{
|
|
2076
|
+
return this.addStrip(render3DQuadStrip(a, b, c, d), render3DFaceNormal(a, b, c, d),
|
|
2077
|
+
uvs ? render3DQuadValues(uvs) : RENDER3D_QUAD_UVS, render3DQuadValues(color));
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
/** Append another mesh transformed by a matrix, for building one shape out of several
|
|
2081
|
+
* @param {Mesh} mesh
|
|
2082
|
+
* @param {Matrix4|Vector3} [matrix] - Transform, or just a position to move it to
|
|
2083
|
+
* @param {Color} [color] - Multiplies the appended vertex colors
|
|
2084
|
+
* @return {Mesh} */
|
|
2085
|
+
combine(mesh, matrix=RENDER3D_IDENTITY, color=WHITE)
|
|
2086
|
+
{
|
|
2087
|
+
matrix = render3DMatrix(matrix); // most parts only need moving into place
|
|
2088
|
+
const normalMatrix = render3DNormalMatrix(matrix);
|
|
2089
|
+
for (let i = 0; i < mesh.points.length; ++i)
|
|
2090
|
+
{
|
|
2091
|
+
this.points.push(matrix.transformPoint(mesh.points[i]));
|
|
2092
|
+
this.normals.push(normalMatrix.transformDirection(mesh.normals[i] || RENDER3D_DEFAULT_NORMAL).normalize());
|
|
2093
|
+
this.uvs.push((mesh.uvs[i] || RENDER3D_DEFAULT_UV).copy());
|
|
2094
|
+
this.colors.push((mesh.colors[i] || WHITE).multiply(color));
|
|
2095
|
+
}
|
|
2096
|
+
this.doubleSided ||= mesh.doubleSided; // an open part leaves the whole mesh open
|
|
2097
|
+
this.dirty = true;
|
|
2098
|
+
return this;
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
/** Scale every uv, so a whole texture repeats across the mesh when its TextureInfo wraps
|
|
2102
|
+
* @param {Vector2|number} scale - Repeats across and up, a number for both
|
|
2103
|
+
* @return {Mesh} */
|
|
2104
|
+
scaleUVs(scale)
|
|
2105
|
+
{
|
|
2106
|
+
const s = isNumber(scale) ? vec2(scale) : scale;
|
|
2107
|
+
this.uvs = this.uvs.map(uv=> vec2(uv.x * s.x, uv.y * s.y)); // new vectors, builders share uv objects between faces
|
|
2108
|
+
this.dirty = true;
|
|
2109
|
+
return this;
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
/** Move, turn or scale every vertex in place, normals follow along
|
|
2113
|
+
* @param {Matrix4|Vector3} matrix - Transform, or just an offset to move by
|
|
2114
|
+
* @return {Mesh} */
|
|
2115
|
+
transform(matrix)
|
|
2116
|
+
{
|
|
2117
|
+
matrix = render3DMatrix(matrix);
|
|
2118
|
+
const normalMatrix = render3DNormalMatrix(matrix);
|
|
2119
|
+
for (let i = 0; i < this.points.length; ++i)
|
|
2120
|
+
{
|
|
2121
|
+
this.points[i] = matrix.transformPoint(this.points[i]);
|
|
2122
|
+
// a mesh built by hand may have no normals yet, and then there is nothing to turn
|
|
2123
|
+
this.normals[i] &&= normalMatrix.transformDirection(this.normals[i]).normalize();
|
|
2124
|
+
}
|
|
2125
|
+
this.dirty = true;
|
|
2126
|
+
return this;
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
/** Turn the mesh inside out so it is lit and drawn from within, for rooms and domes
|
|
2130
|
+
* @return {Mesh} */
|
|
2131
|
+
flipNormals()
|
|
2132
|
+
{
|
|
2133
|
+
// one extra point at each end flips which way every triangle faces, and keeps the count even
|
|
2134
|
+
for (const key of ['points', 'normals', 'uvs', 'colors'])
|
|
2135
|
+
{
|
|
2136
|
+
const a = this[key];
|
|
2137
|
+
if (a.length)
|
|
2138
|
+
a.unshift(a[0]), a.push(a[a.length - 1]);
|
|
2139
|
+
}
|
|
2140
|
+
this.normals = this.normals.map(n=> n.scale(-1));
|
|
2141
|
+
this.dirty = true;
|
|
2142
|
+
return this;
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
/** Set every vertex color
|
|
2146
|
+
* @param {Color} color
|
|
2147
|
+
* @return {Mesh} */
|
|
2148
|
+
setColor(color)
|
|
2149
|
+
{
|
|
2150
|
+
// one per point, not one per color already there, so a mesh built by hand with no
|
|
2151
|
+
// colors gets them instead of quietly staying white
|
|
2152
|
+
this.colors = this.points.map(()=> color);
|
|
2153
|
+
this.dirty = true;
|
|
2154
|
+
return this;
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
/** Measure the axis aligned box around the vertices
|
|
2158
|
+
* @return {{min: Vector3, max: Vector3}} */
|
|
2159
|
+
getBounds()
|
|
2160
|
+
{
|
|
2161
|
+
if (!this.points.length)
|
|
2162
|
+
return {min: vec3(), max: vec3()};
|
|
2163
|
+
const lo = vec3(Infinity), hi = vec3(-Infinity);
|
|
2164
|
+
for (const p of this.points)
|
|
2165
|
+
{
|
|
2166
|
+
lo.x = min(lo.x, p.x); lo.y = min(lo.y, p.y); lo.z = min(lo.z, p.z);
|
|
2167
|
+
hi.x = max(hi.x, p.x); hi.y = max(hi.y, p.y); hi.z = max(hi.z, p.z);
|
|
2168
|
+
}
|
|
2169
|
+
return {min: lo, max: hi};
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
/** Move the mesh so the center of its bounds is on the origin
|
|
2173
|
+
* @return {Mesh} */
|
|
2174
|
+
center()
|
|
2175
|
+
{
|
|
2176
|
+
const bounds = this.getBounds();
|
|
2177
|
+
return this.transform(bounds.min.add(bounds.max).scale(-.5));
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
/** Scale the mesh evenly so its largest extent is a size, for loaded models of unknown units
|
|
2181
|
+
* @param {number} [size]
|
|
2182
|
+
* @return {Mesh} */
|
|
2183
|
+
fit(size=1)
|
|
2184
|
+
{
|
|
2185
|
+
const bounds = this.getBounds();
|
|
2186
|
+
const extent = bounds.max.subtract(bounds.min);
|
|
2187
|
+
const scale = size / (max(extent.x, extent.y, extent.z) || 1);
|
|
2188
|
+
return this.transform(Matrix4.scaling(vec3(scale)));
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
/** Measure the bounding sphere around the origin into radius, called by upload
|
|
2192
|
+
* @return {number} */
|
|
2193
|
+
computeRadius()
|
|
2194
|
+
{
|
|
2195
|
+
let r = 0;
|
|
2196
|
+
for (const p of this.points)
|
|
2197
|
+
r = max(r, p.lengthSquared());
|
|
2198
|
+
return this.radius = r ** .5;
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
/** Derive normals from the strip's triangles
|
|
2202
|
+
* @param {boolean} [smooth] - Round the lighting across faces instead of giving each face a hard edge
|
|
2203
|
+
* @return {Mesh} */
|
|
2204
|
+
computeNormals(smooth=false)
|
|
2205
|
+
{
|
|
2206
|
+
// the outward normal of each triangle in the strip
|
|
2207
|
+
const points = this.points, n = points.length;
|
|
2208
|
+
const faceNormals = [];
|
|
2209
|
+
for (let i = 0; i + 2 < n; ++i)
|
|
2210
|
+
{
|
|
2211
|
+
const a = points[i], b = points[i+1], c = points[i+2];
|
|
2212
|
+
const normal = b.subtract(a).cross(c.subtract(a));
|
|
2213
|
+
// triangles in a strip alternate which way they wind, so every other one is flipped back
|
|
2214
|
+
// a zero normal means a flat triangle joining two strips, so skip it
|
|
2215
|
+
faceNormals.push(normal.lengthSquared() ? normal.normalize(i & 1 ? 1 : -1) : undefined);
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
// then hand those to the vertices, shared around a position or kept per face
|
|
2219
|
+
const normals = points.map(()=> RENDER3D_DEFAULT_NORMAL);
|
|
2220
|
+
if (smooth)
|
|
2221
|
+
{
|
|
2222
|
+
// add up the face normals meeting at each position, each weighted by its corner angle so a cube
|
|
2223
|
+
// corner averages its three faces evenly however the strips cut them, then normalize
|
|
2224
|
+
const sums = new Map;
|
|
2225
|
+
const key = (p)=> `${round(p.x * 1e5)},${round(p.y * 1e5)},${round(p.z * 1e5)}`;
|
|
2226
|
+
faceNormals.forEach((f, i)=> f && [0, 1, 2].forEach(j=>
|
|
2227
|
+
{
|
|
2228
|
+
const a = points[i + j], u = points[i + (j + 1) % 3].subtract(a), v = points[i + (j + 2) % 3].subtract(a);
|
|
2229
|
+
const angle = Math.acos(clamp(u.dot(v) / (u.length() * v.length() || 1), -1, 1));
|
|
2230
|
+
const k = key(a);
|
|
2231
|
+
sums.set(k, (sums.get(k) || vec3()).add(f.scale(angle)));
|
|
2232
|
+
}));
|
|
2233
|
+
for (let i = 0; i < n; ++i)
|
|
2234
|
+
normals[i] = (sums.get(key(points[i])) || RENDER3D_DEFAULT_NORMAL).normalize();
|
|
2235
|
+
}
|
|
2236
|
+
else
|
|
2237
|
+
// every triangle writes its own three corners, so the only vertices left with the default
|
|
2238
|
+
// are the repeats at the ends of a strip, which no triangle with any area uses
|
|
2239
|
+
faceNormals.forEach((f, i)=> f && (normals[i] = normals[i+1] = normals[i+2] = f));
|
|
2240
|
+
|
|
2241
|
+
this.normals = normals;
|
|
2242
|
+
this.dirty = true;
|
|
2243
|
+
return this;
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
/** Pack the vertices and create the GPU buffer, called automatically by render
|
|
2247
|
+
* @return {Mesh} */
|
|
2248
|
+
upload()
|
|
2249
|
+
{
|
|
2250
|
+
this.computeRadius();
|
|
2251
|
+
if (!render3D?.program) return this;
|
|
2252
|
+
this.dispose();
|
|
2253
|
+
const count = this.points.length;
|
|
2254
|
+
const data = new ArrayBuffer(count * RENDER3D_VERTEX_BYTES);
|
|
2255
|
+
const floats = new Float32Array(data), ints = new Uint32Array(data);
|
|
2256
|
+
for (let i = 0; i < count; ++i)
|
|
2257
|
+
{
|
|
2258
|
+
const uv = this.uvs[i] || RENDER3D_DEFAULT_UV; // a hand built mesh may leave normals, uvs and colors empty
|
|
2259
|
+
render3DWriteVertex(floats, ints, i * RENDER3D_VERTEX_FLOATS, this.points[i],
|
|
2260
|
+
this.normals[i] || RENDER3D_DEFAULT_NORMAL, uv.x, uv.y, (this.colors[i] || WHITE).rgbaInt());
|
|
2261
|
+
}
|
|
2262
|
+
const gl = glContext;
|
|
2263
|
+
this.buffer = gl.createBuffer();
|
|
2264
|
+
this.bufferCount = count;
|
|
2265
|
+
this.dirty = false;
|
|
2266
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
|
|
2267
|
+
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
|
|
2268
|
+
this.contextGeneration = render3D.contextGeneration;
|
|
2269
|
+
render3DMeshBuffers?.register(this, {buffer: this.buffer, generation: this.contextGeneration}, this);
|
|
2270
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, glArrayBuffer); // the engine's 2D batch writes through this binding
|
|
2271
|
+
return this;
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
/** Draw the mesh with the current draw state, batched with its other uses in the opaque stage
|
|
2275
|
+
* @param {Matrix4|Vector3} [matrix] - Object transform, or just a position to draw it at
|
|
2276
|
+
* @param {TileInfo|TextureInfo} [tileInfo] - Texture, mesh uvs map across the tile or the whole texture
|
|
2277
|
+
* @param {Color} [color] - Tint */
|
|
2278
|
+
render(matrix, tileInfo, color) { render3D?.drawMesh(this, matrix, tileInfo, color); }
|
|
2279
|
+
|
|
2280
|
+
/** Delete the GPU buffer now, the CPU arrays stay so the mesh can be rendered again
|
|
2281
|
+
* - Optional, the buffer is freed anyway once the mesh is garbage collected, this frees it right away */
|
|
2282
|
+
dispose()
|
|
2283
|
+
{
|
|
2284
|
+
if (!this.buffer) return;
|
|
2285
|
+
render3DMeshBuffers?.unregister(this); // freed here, so not again when the mesh is collected
|
|
2286
|
+
// a buffer from a context that was lost is gone with it, and the new context refuses to delete it
|
|
2287
|
+
if (this.contextGeneration === render3D?.contextGeneration)
|
|
2288
|
+
glContext?.deleteBuffer(this.buffer);
|
|
2289
|
+
this.buffer = undefined;
|
|
2290
|
+
this.bufferCount = 0;
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2295
|
+
// Shape builders, all centered on the origin so buildMatrix does placement
|
|
2296
|
+
// Sizes are full sizes like buildBox and the 2D drawCircle, sides go around an axis and rings along it
|
|
2297
|
+
|
|
2298
|
+
/**
|
|
2299
|
+
* Spin a flat outline around the Y axis to make a round shape, like a vase or a wheel
|
|
2300
|
+
* - profile is [[radius, y], ...] from bottom to top
|
|
2301
|
+
* - A profile that ends where it starts makes a closed ring like a donut
|
|
2302
|
+
* - An end left open, with a radius and no cap, makes the mesh doubleSided so its inside shows
|
|
2303
|
+
* @param {Array<Array<number>>} profile
|
|
2304
|
+
* @param {number} [sides] - Around the axis
|
|
2305
|
+
* @param {boolean} [smooth] - Defaults to render3D.smoothShading
|
|
2306
|
+
* @param {boolean} [capped] - Close the ends that have a radius with flat discs
|
|
2307
|
+
* @return {Mesh}
|
|
2308
|
+
* @memberof Render3D
|
|
2309
|
+
* @example
|
|
2310
|
+
* const vase = buildLathe([[0, -1], [.8, -.3], [.9, .2], [.4, .6], [0, 1]], 12);
|
|
2311
|
+
*/
|
|
2312
|
+
function buildLathe(profile, sides=16, smooth=render3D?.smoothShading, capped=true)
|
|
2313
|
+
{
|
|
2314
|
+
ASSERT(isArray(profile) && profile.length > 1, 'lathe profile needs at least 2 points');
|
|
2315
|
+
sides |= 0;
|
|
2316
|
+
ASSERT(sides > 2, 'lathe needs at least 3 sides');
|
|
2317
|
+
const mesh = new Mesh;
|
|
2318
|
+
const rings = profile.length;
|
|
2319
|
+
const point = (i, a)=> vec3(sin(a) * profile[i][0], profile[i][1], cos(a) * profile[i][0]);
|
|
2320
|
+
|
|
2321
|
+
// 2D outward normal of each profile segment, in (radius, y) space
|
|
2322
|
+
const segmentNormal = (i)=>
|
|
2323
|
+
{
|
|
2324
|
+
const [r0, y0] = profile[i], [r1, y1] = profile[i+1];
|
|
2325
|
+
const n = vec2(y1 - y0, r0 - r1);
|
|
2326
|
+
return n.length() ? n.normalize() : vec2(1, 0);
|
|
2327
|
+
};
|
|
2328
|
+
// vertex normal: average of the adjacent segment normals, across the seam when the profile is closed,
|
|
2329
|
+
// and a closed profile needs no caps
|
|
2330
|
+
const closed = rings > 2 && abs(profile[0][0] - profile[rings-1][0]) < 1e-9 && abs(profile[0][1] - profile[rings-1][1]) < 1e-9;
|
|
2331
|
+
const segmentLength = (i)=> hypot(profile[i+1][0] - profile[i][0], profile[i+1][1] - profile[i][1]);
|
|
2332
|
+
const vertexNormal = (i)=>
|
|
2333
|
+
{
|
|
2334
|
+
// an open end on the axis is a pole and points along it
|
|
2335
|
+
if (!closed && (!i || i == rings - 1) && abs(profile[i][0]) < 1e-9)
|
|
2336
|
+
return vec2(0, i ? 1 : -1);
|
|
2337
|
+
// otherwise the neighbors weighted by their length, so a short band does not tilt a long wall
|
|
2338
|
+
let n = vec2();
|
|
2339
|
+
const add = (s)=> n = n.add(segmentNormal(s).scale(segmentLength(s)));
|
|
2340
|
+
if (i > 0) add(i - 1);
|
|
2341
|
+
else if (closed) add(rings - 2);
|
|
2342
|
+
if (i < rings - 1) add(i);
|
|
2343
|
+
else if (closed) add(0);
|
|
2344
|
+
return n.length() ? n.normalize() : vec2(1, 0);
|
|
2345
|
+
};
|
|
2346
|
+
const normal3D = (n, a)=> vec3(sin(a) * n.x, n.y, cos(a) * n.x);
|
|
2347
|
+
|
|
2348
|
+
// v runs along the profile by arc length
|
|
2349
|
+
const lengths = [0];
|
|
2350
|
+
for (let i = 1; i < rings; ++i)
|
|
2351
|
+
lengths[i] = lengths[i-1] + hypot(profile[i][0] - profile[i-1][0], profile[i][1] - profile[i-1][1]);
|
|
2352
|
+
const total = lengths[rings - 1] || 1;
|
|
2353
|
+
const v = (i)=> 1 - lengths[i] / total;
|
|
2354
|
+
|
|
2355
|
+
for (let i = 0; i + 1 < rings; ++i)
|
|
2356
|
+
{
|
|
2357
|
+
if (smooth)
|
|
2358
|
+
{
|
|
2359
|
+
// one ribbon around the ring pair, top point then bottom point per column
|
|
2360
|
+
const points = [], normals = [], uvs = [];
|
|
2361
|
+
const n0 = vertexNormal(i), n1 = vertexNormal(i + 1);
|
|
2362
|
+
for (let j = 0; j <= sides; ++j)
|
|
2363
|
+
{
|
|
2364
|
+
const a = j / sides * 2 * PI, u = j / sides;
|
|
2365
|
+
points.push(point(i + 1, a), point(i, a));
|
|
2366
|
+
normals.push(normal3D(n1, a), normal3D(n0, a));
|
|
2367
|
+
uvs.push(vec2(u, v(i + 1)), vec2(u, v(i)));
|
|
2368
|
+
}
|
|
2369
|
+
mesh.addStrip(points, normals, uvs);
|
|
2370
|
+
}
|
|
2371
|
+
else
|
|
2372
|
+
{
|
|
2373
|
+
// one quad per side with its face normal
|
|
2374
|
+
const n = segmentNormal(i);
|
|
2375
|
+
for (let j = 0; j < sides; ++j)
|
|
2376
|
+
{
|
|
2377
|
+
const a0 = j / sides * 2 * PI, a1 = (j + 1) / sides * 2 * PI;
|
|
2378
|
+
const u0 = j / sides, u1 = (j + 1) / sides;
|
|
2379
|
+
mesh.addStrip(
|
|
2380
|
+
[point(i + 1, a0), point(i, a0), point(i + 1, a1), point(i, a1)],
|
|
2381
|
+
normal3D(n, (a0 + a1) / 2),
|
|
2382
|
+
[vec2(u0, v(i + 1)), vec2(u0, v(i)), vec2(u1, v(i + 1)), vec2(u1, v(i))]);
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
// flat discs close the ends that have a radius, a hard edge even when the sides are smooth
|
|
2388
|
+
if (capped && !closed)
|
|
2389
|
+
for (const [i, up] of [[0, false], [rings - 1, true]])
|
|
2390
|
+
{
|
|
2391
|
+
if (abs(profile[i][0]) < 1e-9) continue; // a pole has no cap
|
|
2392
|
+
const points = [], uvs = [];
|
|
2393
|
+
for (let j = 0; j < sides; ++j)
|
|
2394
|
+
{
|
|
2395
|
+
const a = (up ? j : -j) / sides * 2 * PI; // counter clockwise seen from outside
|
|
2396
|
+
points.push(point(i, a));
|
|
2397
|
+
uvs.push(vec2(sin(a) * .5 + .5, cos(a) * .5 + .5));
|
|
2398
|
+
}
|
|
2399
|
+
mesh.addStrip(render3DPolygonStrip(points), vec3(0, up ? 1 : -1, 0), render3DPolygonStrip(uvs));
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
// an end left open shows the inside, so it is seen from both sides
|
|
2403
|
+
mesh.doubleSided = !closed && !capped && (abs(profile[0][0]) > 1e-9 || abs(profile[rings-1][0]) > 1e-9);
|
|
2404
|
+
return mesh;
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
/**
|
|
2408
|
+
* Build a cylinder standing on the Y axis, centered on the origin
|
|
2409
|
+
* @param {number} [size] - Diameter
|
|
2410
|
+
* @param {number} [height]
|
|
2411
|
+
* @param {number} [sides] - Around
|
|
2412
|
+
* @param {boolean} [smooth] - Defaults to render3D.smoothShading
|
|
2413
|
+
* @param {boolean} [capped] - Close the ends
|
|
2414
|
+
* @return {Mesh}
|
|
2415
|
+
* @memberof Render3D
|
|
2416
|
+
*/
|
|
2417
|
+
function buildCylinder(size=1, height=1, sides=16, smooth=render3D?.smoothShading, capped=true)
|
|
2418
|
+
{
|
|
2419
|
+
return buildLathe([[size / 2, -height / 2], [size / 2, height / 2]], sides, smooth, capped);
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
/**
|
|
2423
|
+
* Build a cone standing on the Y axis, centered on the origin, the point up
|
|
2424
|
+
* @param {number} [size] - Diameter of the base
|
|
2425
|
+
* @param {number} [height]
|
|
2426
|
+
* @param {number} [sides] - Around
|
|
2427
|
+
* @param {boolean} [smooth] - Defaults to render3D.smoothShading
|
|
2428
|
+
* @param {boolean} [capped] - Close the base
|
|
2429
|
+
* @return {Mesh}
|
|
2430
|
+
* @memberof Render3D
|
|
2431
|
+
*/
|
|
2432
|
+
function buildCone(size=1, height=1, sides=16, smooth=render3D?.smoothShading, capped=true)
|
|
2433
|
+
{
|
|
2434
|
+
return buildLathe([[size / 2, -height / 2], [0, height / 2]], sides, smooth, capped);
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
/**
|
|
2438
|
+
* Build a sphere centered on the origin
|
|
2439
|
+
* @param {number} [size] - Diameter
|
|
2440
|
+
* @param {number} [sides] - Around
|
|
2441
|
+
* @param {number} [rings] - Top to bottom
|
|
2442
|
+
* @param {boolean} [smooth] - Defaults to render3D.smoothShading
|
|
2443
|
+
* @return {Mesh}
|
|
2444
|
+
* @memberof Render3D
|
|
2445
|
+
*/
|
|
2446
|
+
function buildSphere(size=1, sides=16, rings=8, smooth=render3D?.smoothShading)
|
|
2447
|
+
{
|
|
2448
|
+
ASSERT(rings > 1, 'sphere needs at least 2 rings');
|
|
2449
|
+
const profile = [];
|
|
2450
|
+
for (let i = 0; i <= rings; ++i)
|
|
2451
|
+
{
|
|
2452
|
+
const a = i / rings * PI - PI/2;
|
|
2453
|
+
profile.push([cos(a) * size / 2, sin(a) * size / 2]);
|
|
2454
|
+
}
|
|
2455
|
+
return buildLathe(profile, sides, smooth);
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
/**
|
|
2459
|
+
* Build a capsule standing on the Y axis, centered on the origin: a cylinder with a half sphere on each end
|
|
2460
|
+
* @param {number} [size] - Diameter
|
|
2461
|
+
* @param {number} [height] - Total height including the rounded ends, at least the size
|
|
2462
|
+
* @param {number} [sides] - Around
|
|
2463
|
+
* @param {number} [rings] - On each end
|
|
2464
|
+
* @param {boolean} [smooth] - Defaults to render3D.smoothShading
|
|
2465
|
+
* @return {Mesh}
|
|
2466
|
+
* @memberof Render3D
|
|
2467
|
+
*/
|
|
2468
|
+
function buildCapsule(size=1, height=1, sides=16, rings=4, smooth=render3D?.smoothShading)
|
|
2469
|
+
{
|
|
2470
|
+
// the rounded ends alone are already the size tall, so a shorter capsule is only a sphere
|
|
2471
|
+
ASSERT(height >= size, 'a capsule is at least as tall as it is wide, the ends take up the size', size, height);
|
|
2472
|
+
const profile = [], r = size / 2, straight = max(0, height - size) / 2;
|
|
2473
|
+
for (let i = 0; i <= rings; ++i)
|
|
2474
|
+
{
|
|
2475
|
+
const a = i / rings * PI / 2;
|
|
2476
|
+
profile.push([r * sin(a), -straight - r * cos(a)]);
|
|
2477
|
+
}
|
|
2478
|
+
for (let i = 0; i <= rings; ++i)
|
|
2479
|
+
{
|
|
2480
|
+
const a = i / rings * PI / 2;
|
|
2481
|
+
profile.push([r * cos(a), straight + r * sin(a)]);
|
|
2482
|
+
}
|
|
2483
|
+
return buildLathe(profile, sides, smooth);
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
/**
|
|
2487
|
+
* Build a donut lying flat around the Y axis
|
|
2488
|
+
* @param {number} [size] - Diameter of the whole donut, outside edge to outside edge
|
|
2489
|
+
* @param {number} [tubeSize] - Diameter of the tube
|
|
2490
|
+
* @param {number} [sides] - Around the ring
|
|
2491
|
+
* @param {number} [tubeSides] - Around the tube
|
|
2492
|
+
* @param {boolean} [smooth] - Defaults to render3D.smoothShading
|
|
2493
|
+
* @return {Mesh}
|
|
2494
|
+
* @memberof Render3D
|
|
2495
|
+
*/
|
|
2496
|
+
function buildTorus(size=1, tubeSize=.3, sides=16, tubeSides=8, smooth=render3D?.smoothShading)
|
|
2497
|
+
{
|
|
2498
|
+
ASSERT(tubeSize <= size, 'the tube must fit inside the torus');
|
|
2499
|
+
const profile = [], radius = (size - tubeSize) / 2, tubeRadius = tubeSize / 2;
|
|
2500
|
+
for (let i = 0; i <= tubeSides; ++i)
|
|
2501
|
+
{
|
|
2502
|
+
const a = i / tubeSides * 2 * PI;
|
|
2503
|
+
profile.push([radius + tubeRadius * cos(a), tubeRadius * sin(a)]);
|
|
2504
|
+
}
|
|
2505
|
+
return buildLathe(profile, sides, smooth);
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
/**
|
|
2509
|
+
* Build a box centered on the origin, six flat faces with uvs covering each face
|
|
2510
|
+
* @param {Vector3|number} [size] - Full size, a number for a cube
|
|
2511
|
+
* @return {Mesh}
|
|
2512
|
+
* @memberof Render3D
|
|
2513
|
+
*/
|
|
2514
|
+
function buildBox(size=1)
|
|
2515
|
+
{
|
|
2516
|
+
const mesh = new Mesh;
|
|
2517
|
+
const half = render3DSize3(size).scale(.5);
|
|
2518
|
+
// each face: normal, right axis, up axis (right cross up = normal)
|
|
2519
|
+
const faces = [
|
|
2520
|
+
[vec3(0, 0, 1), vec3(1, 0, 0), vec3(0, 1, 0)],
|
|
2521
|
+
[vec3(0, 0, -1), vec3(-1, 0, 0), vec3(0, 1, 0)],
|
|
2522
|
+
[vec3(1, 0, 0), vec3(0, 0, -1), vec3(0, 1, 0)],
|
|
2523
|
+
[vec3(-1, 0, 0), vec3(0, 0, 1), vec3(0, 1, 0)],
|
|
2524
|
+
[vec3(0, 1, 0), vec3(1, 0, 0), vec3(0, 0, -1)],
|
|
2525
|
+
[vec3(0, -1, 0), vec3(1, 0, 0), vec3(0, 0, 1)],
|
|
2526
|
+
];
|
|
2527
|
+
for (const [n, r, u] of faces)
|
|
2528
|
+
{
|
|
2529
|
+
const center = n.multiply(half);
|
|
2530
|
+
const right = r.multiply(half), up = u.multiply(half);
|
|
2531
|
+
mesh.addStrip(render3DQuadAxes(center, right, up), n, RENDER3D_QUAD_UVS);
|
|
2532
|
+
}
|
|
2533
|
+
return mesh;
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
/**
|
|
2537
|
+
* Build a lit ribbon along a path, for roads, tracks and walls
|
|
2538
|
+
* - Each segment is a flat quad, the sides are across the path in the plane of the up vector
|
|
2539
|
+
* - doubleSided, so it is seen and lit from below as well
|
|
2540
|
+
* @param {Array<Vector3>} points - Center line in order
|
|
2541
|
+
* @param {number|Array<number>} [width] - Full width, one for all or one per point
|
|
2542
|
+
* @param {Color|Array<Color>} [color] - One for all or one per point
|
|
2543
|
+
* @param {boolean} [closed] - Join the last point back to the first
|
|
2544
|
+
* @param {Vector3} [up] - Which way the ribbon faces
|
|
2545
|
+
* @return {Mesh}
|
|
2546
|
+
* @memberof Render3D
|
|
2547
|
+
* @example
|
|
2548
|
+
* const road = buildRibbon(trackPoints, 8, GRAY, true); // a loop of road
|
|
2549
|
+
*/
|
|
2550
|
+
function buildRibbon(points, width=1, color=WHITE, closed=false, up=vec3(0, 1, 0))
|
|
2551
|
+
{
|
|
2552
|
+
ASSERT(isArray(points) && points.length > 1, 'ribbon needs at least 2 points');
|
|
2553
|
+
const mesh = new Mesh, count = points.length, edges = [];
|
|
2554
|
+
let across = (abs(up.y) < .9 ? vec3(0, 1, 0) : vec3(1, 0, 0)).cross(up).normalize(); // anything across up
|
|
2555
|
+
for (let i = 0; i < count; ++i)
|
|
2556
|
+
{
|
|
2557
|
+
// across the path, from the tangent through this point; a step along up keeps the last across
|
|
2558
|
+
const next = points[closed ? (i + 1) % count : min(i + 1, count - 1)];
|
|
2559
|
+
const last = points[closed ? (i + count - 1) % count : max(i - 1, 0)];
|
|
2560
|
+
const dir = next.subtract(last).cross(up);
|
|
2561
|
+
if (dir.lengthSquared() > 1e-12)
|
|
2562
|
+
across = dir.normalize();
|
|
2563
|
+
const half = across.scale((isArray(width) ? width[i] : width) / 2);
|
|
2564
|
+
edges.push([points[i].subtract(half), points[i].add(half)]);
|
|
2565
|
+
}
|
|
2566
|
+
for (let i = 0; i + 1 < count + (closed ? 1 : 0); ++i)
|
|
2567
|
+
{
|
|
2568
|
+
const j = (i + 1) % count, a = edges[i], b = edges[j];
|
|
2569
|
+
const c = isArray(color) ? [color[i], color[i], color[j], color[j]] : color;
|
|
2570
|
+
mesh.addQuad(a[0], a[1], b[1], b[0], c); // counter clockwise seen from above
|
|
2571
|
+
}
|
|
2572
|
+
mesh.doubleSided = true; // a flat strip, seen from both sides
|
|
2573
|
+
return mesh;
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2576
|
+
/**
|
|
2577
|
+
* Build a heightfield grid in the XZ plane centered on the origin
|
|
2578
|
+
* - smooth rounds the lighting across cells and colors each corner
|
|
2579
|
+
* - flat lights and colors each cell on its own, so a checkerboard stays crisp
|
|
2580
|
+
* - doubleSided, a sheet seen from both sides; turn it off for ground only ever seen from above
|
|
2581
|
+
* - One cell is a plain square, render3D.planeMesh and planeMeshDoubleSided are shared ones
|
|
2582
|
+
* @param {Vector2} [size] - World size along X and Z
|
|
2583
|
+
* @param {Vector2|number} [segments] - Cells along X and Z, a number for both
|
|
2584
|
+
* @param {Color|Function} [color] - One Color for the whole grid, or (x, z) => Color
|
|
2585
|
+
* @param {Function} [heightFunction] - (x, z) => y, default flat
|
|
2586
|
+
* @param {boolean} [smooth] - Defaults to render3D.smoothShading
|
|
2587
|
+
* @return {Mesh}
|
|
2588
|
+
* @memberof Render3D
|
|
2589
|
+
* @example
|
|
2590
|
+
* const ground = buildGrid(vec2(20), 10, (x, z)=> (floor(x / 2) + floor(z / 2)) & 1 ? GRAY : WHITE); // 2 unit checks
|
|
2591
|
+
*/
|
|
2592
|
+
function buildGrid(size=vec2(1), segments=1, color, heightFunction=()=>0, smooth=render3D?.smoothShading)
|
|
2593
|
+
{
|
|
2594
|
+
if (isNumber(segments))
|
|
2595
|
+
segments = vec2(segments);
|
|
2596
|
+
ASSERT(segments.x > 0 && segments.y > 0 && segments.x % 1 === 0 && segments.y % 1 === 0, 'grid segments must be whole numbers above zero');
|
|
2597
|
+
const mesh = new Mesh;
|
|
2598
|
+
const segmentsX = segments.x, segmentsZ = segments.y;
|
|
2599
|
+
const cellX = size.x / segmentsX, cellZ = size.y / segmentsZ;
|
|
2600
|
+
const px = (i)=> i * cellX - size.x / 2, pz = (j)=> j * cellZ - size.y / 2;
|
|
2601
|
+
const point = (i, j)=> { const x = px(i), z = pz(j); return vec3(x, heightFunction(x, z), z); };
|
|
2602
|
+
const normal = (i, j)=> render3DSlopeNormal(heightFunction, px(i), pz(j), cellX / 2, cellZ / 2, size.x / 2, size.y / 2);
|
|
2603
|
+
const uv = (i, j)=> vec2(i / segmentsX, j / segmentsZ);
|
|
2604
|
+
const cellColor = (i, j)=> !color ? WHITE : isColor(color) ? color : color(px(i), pz(j));
|
|
2605
|
+
for (let j = 0; j < segmentsZ; ++j)
|
|
2606
|
+
{
|
|
2607
|
+
if (smooth)
|
|
2608
|
+
{
|
|
2609
|
+
// one ribbon per row with vertex normals from the slope
|
|
2610
|
+
const points = [], normals = [], uvs = [], colors = [];
|
|
2611
|
+
for (let i = 0; i <= segmentsX; ++i)
|
|
2612
|
+
{
|
|
2613
|
+
points.push(point(i, j), point(i, j + 1));
|
|
2614
|
+
normals.push(normal(i, j), normal(i, j + 1));
|
|
2615
|
+
uvs.push(uv(i, j), uv(i, j + 1));
|
|
2616
|
+
colors.push(cellColor(i, j), cellColor(i, j + 1));
|
|
2617
|
+
}
|
|
2618
|
+
mesh.addStrip(points, normals, uvs, colors);
|
|
2619
|
+
}
|
|
2620
|
+
else
|
|
2621
|
+
{
|
|
2622
|
+
// one quad per cell with its face normal and one color sampled at its center
|
|
2623
|
+
for (let i = 0; i < segmentsX; ++i)
|
|
2624
|
+
mesh.addQuad(point(i, j), point(i, j + 1), point(i + 1, j + 1), point(i + 1, j), cellColor(i + .5, j + .5),
|
|
2625
|
+
[uv(i, j), uv(i, j + 1), uv(i + 1, j + 1), uv(i + 1, j)]);
|
|
2626
|
+
}
|
|
2627
|
+
}
|
|
2628
|
+
mesh.doubleSided = true; // a sheet, seen from both sides; terrain seen only from above can turn it off
|
|
2629
|
+
return mesh;
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
/**
|
|
2633
|
+
* Build a hull from a row of diamond shaped slices along Z, for ships, planes and cars
|
|
2634
|
+
* - Each slice is [z, width, top, bottom, sideHeight]
|
|
2635
|
+
* - sideHeight is 0 to 1 and puts the side corners between the bottom and the top
|
|
2636
|
+
* - List the slices nose first, with the nose at the largest z
|
|
2637
|
+
* @param {Array<Array<number>>} stations
|
|
2638
|
+
* @return {Mesh}
|
|
2639
|
+
* @memberof Render3D
|
|
2640
|
+
* @example
|
|
2641
|
+
* const hull = buildLoft([[1.2, .4, .2, -.1], [0, 1.4, .5, -.4], [-1, 1, .3, -.3]]);
|
|
2642
|
+
*/
|
|
2643
|
+
function buildLoft(stations)
|
|
2644
|
+
{
|
|
2645
|
+
ASSERT(isArray(stations) && stations.length > 1, 'loft needs at least 2 stations');
|
|
2646
|
+
// the caps and the winding both assume the nose leads, so the other order turns the hull inside out
|
|
2647
|
+
ASSERT(stations[0][0] > stations[stations.length-1][0], 'loft stations go nose first, from the largest z to the smallest');
|
|
2648
|
+
const mesh = new Mesh;
|
|
2649
|
+
// section points: left, top, right, bottom, wound clockwise seen from +z
|
|
2650
|
+
const section = ([z, w, t, b, m=.5])=>
|
|
2651
|
+
[vec3(-w / 2, lerp(b, t, m), z), vec3(0, t, z), vec3(w / 2, lerp(b, t, m), z), vec3(0, b, z)];
|
|
2652
|
+
for (let i = 0; i + 1 < stations.length; ++i)
|
|
2653
|
+
{
|
|
2654
|
+
const s1 = section(stations[i]), s2 = section(stations[i + 1]);
|
|
2655
|
+
for (let k = 0; k < 4; ++k)
|
|
2656
|
+
mesh.addQuad(s1[k], s1[(k + 1) % 4], s2[(k + 1) % 4], s2[k]);
|
|
2657
|
+
}
|
|
2658
|
+
const tail = section(stations[stations.length - 1]), nose = section(stations[0]);
|
|
2659
|
+
mesh.addQuad(tail[0], tail[1], tail[2], tail[3]);
|
|
2660
|
+
mesh.addQuad(nose[3], nose[2], nose[1], nose[0]);
|
|
2661
|
+
return mesh;
|
|
2662
|
+
}
|
|
2663
|
+
|
|
2664
|
+
/**
|
|
2665
|
+
* Build a sky dome: a sphere colored by direction, wound to be seen from inside
|
|
2666
|
+
* - set it as render3D.sky and the pass draws it around the camera behind everything
|
|
2667
|
+
* @param {Color} [topColor] - Straight up
|
|
2668
|
+
* @param {Color} [horizonColor] - Level with the camera
|
|
2669
|
+
* @param {Color} [bottomColor] - Straight down, what a camera looking at the ground sees past its edge; defaults to the horizon color
|
|
2670
|
+
* @param {number} [sides] - Around
|
|
2671
|
+
* @param {number} [rings] - Top to bottom
|
|
2672
|
+
* @return {Mesh}
|
|
2673
|
+
* @memberof Render3D
|
|
2674
|
+
*/
|
|
2675
|
+
function buildSky(topColor=hsl(.6, .8, .55), horizonColor=hsl(.6, 1, .9), bottomColor=horizonColor, sides=16, rings=8)
|
|
2676
|
+
{
|
|
2677
|
+
const mesh = new Mesh;
|
|
2678
|
+
const point = (i, a)=>
|
|
2679
|
+
{
|
|
2680
|
+
const e = i / rings * PI - PI/2;
|
|
2681
|
+
return vec3(sin(a) * cos(e), sin(e), cos(a) * cos(e));
|
|
2682
|
+
};
|
|
2683
|
+
const color = (i)=>
|
|
2684
|
+
{
|
|
2685
|
+
const y = point(i, 0).y;
|
|
2686
|
+
return y < 0 ? horizonColor.lerp(bottomColor, -y) : horizonColor.lerp(topColor, y);
|
|
2687
|
+
};
|
|
2688
|
+
for (let i = 0; i < rings; ++i)
|
|
2689
|
+
{
|
|
2690
|
+
// bottom point then top point per column, the reverse of the lathe, so the front faces inward
|
|
2691
|
+
const points = [], colors = [];
|
|
2692
|
+
for (let j = 0; j <= sides; ++j)
|
|
2693
|
+
{
|
|
2694
|
+
const a = j / sides * 2 * PI;
|
|
2695
|
+
points.push(point(i, a), point(i + 1, a));
|
|
2696
|
+
colors.push(color(i), color(i + 1));
|
|
2697
|
+
}
|
|
2698
|
+
mesh.addStrip(points, undefined, undefined, colors);
|
|
2699
|
+
}
|
|
2700
|
+
return mesh;
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2703
|
+
/**
|
|
2704
|
+
* Turn a sprite into a 3D block model by giving its pixels thickness
|
|
2705
|
+
* - A pixel counts as solid when it is more than half opaque
|
|
2706
|
+
* - Each pixel keeps its own color, so white art takes the object's tint
|
|
2707
|
+
* - Runs of matching pixels merge into one face, and side walls appear only at the sprite's edges
|
|
2708
|
+
* - A texture's pixels are read once and kept, so redrawing a canvas texture will not change what this builds
|
|
2709
|
+
* - Pixels can also be an array of rows, each a Color, a truthy value for white, or a falsy value for empty
|
|
2710
|
+
* @param {TileInfo|Array<Array<Color|number|boolean>>} pixels - A tile from a loaded texture, or rows of pixels,
|
|
2711
|
+
* each a Color (empty when see through), a truthy value for white or a falsy value for empty
|
|
2712
|
+
* @param {Vector2} [size] - World width and height of the whole tile, centered like buildBox
|
|
2713
|
+
* @param {number} [depth] - Thickness along Z
|
|
2714
|
+
* @return {Mesh}
|
|
2715
|
+
* @memberof Render3D
|
|
2716
|
+
* @example
|
|
2717
|
+
* new EngineObject3D(vec3(), buildExtrude(tile(3, 16), vec2(2), .5)); // a chunky version of tile 3
|
|
2718
|
+
*/
|
|
2719
|
+
function buildExtrude(pixels, size=vec2(1), depth=1)
|
|
2720
|
+
{
|
|
2721
|
+
let rows = pixels, width, height;
|
|
2722
|
+
if (pixels instanceof TileInfo)
|
|
2723
|
+
{
|
|
2724
|
+
// colors for the tile's pixels only, undefined where alpha is half or less
|
|
2725
|
+
const image = render3DReadPixels(pixels.textureInfo), data = image.data;
|
|
2726
|
+
const x0 = pixels.pos.x | 0, y0 = pixels.pos.y | 0;
|
|
2727
|
+
width = pixels.size.x | 0, height = pixels.size.y | 0;
|
|
2728
|
+
rows = [];
|
|
2729
|
+
for (let y = 0; y < height; ++y)
|
|
2730
|
+
{
|
|
2731
|
+
const row = rows[y] = [];
|
|
2732
|
+
for (let x = 0; x < width; ++x)
|
|
2733
|
+
{
|
|
2734
|
+
const k = ((y0 + y) * image.width + x0 + x) * 4;
|
|
2735
|
+
row.push(data[k + 3] > 127 ? rgb(data[k] / 255, data[k + 1] / 255, data[k + 2] / 255) : undefined);
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
}
|
|
2739
|
+
else
|
|
2740
|
+
{
|
|
2741
|
+
ASSERT(isArray(pixels) && pixels.length, 'pixels must be a TileInfo or rows of pixels');
|
|
2742
|
+
height = rows.length, width = rows[0].length;
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
// the color of a solid pixel, undefined outside or where it is empty
|
|
2746
|
+
const solid = (x, y)=>
|
|
2747
|
+
{
|
|
2748
|
+
if (x < 0 || y < 0 || x >= width || y >= height) return;
|
|
2749
|
+
const c = rows[y] && rows[y][x];
|
|
2750
|
+
if (!c) return;
|
|
2751
|
+
return isColor(c) ? (c.a > .5 ? c : undefined) : WHITE; // a see through Color is empty too
|
|
2752
|
+
};
|
|
2753
|
+
const same = (a, b)=> a === b || !!a && !!b && a.rgbaInt() === b.rgbaInt();
|
|
2754
|
+
|
|
2755
|
+
// call emit(start, end, color) for each run of same colored pixels, colorAt(i) undefined breaks the run
|
|
2756
|
+
const runs = (count, colorAt, emit)=>
|
|
2757
|
+
{
|
|
2758
|
+
let start = 0, color;
|
|
2759
|
+
for (let i = 0; i <= count; ++i)
|
|
2760
|
+
{
|
|
2761
|
+
const c = i < count ? colorAt(i) : undefined;
|
|
2762
|
+
if (same(c, color)) continue;
|
|
2763
|
+
if (color) emit(start, i, color);
|
|
2764
|
+
start = i, color = c;
|
|
2765
|
+
}
|
|
2766
|
+
};
|
|
2767
|
+
|
|
2768
|
+
// pixel edges in world space, y runs down the image
|
|
2769
|
+
const mesh = new Mesh, sx = size.x / width, sy = size.y / height, hz = depth / 2;
|
|
2770
|
+
const px = x=> x * sx - size.x / 2, py = y=> size.y / 2 - y * sy;
|
|
2771
|
+
const quad = (origin, right, up, normal, color)=>
|
|
2772
|
+
mesh.addStrip(render3DQuadAxes(origin.add(right.scale(.5)).add(up.scale(.5)), right.scale(.5), up.scale(.5)), normal, RENDER3D_QUAD_UVS, color);
|
|
2773
|
+
const X = vec3(1, 0, 0), Y = vec3(0, 1, 0), Z = vec3(0, 0, 1);
|
|
2774
|
+
for (let y = 0; y < height; ++y)
|
|
2775
|
+
{
|
|
2776
|
+
// front and back faces along each row
|
|
2777
|
+
runs(width, x=> solid(x, y), (a, b, c)=>
|
|
2778
|
+
{
|
|
2779
|
+
const w = X.scale((b - a) * sx), h = Y.scale(sy);
|
|
2780
|
+
quad(vec3(px(a), py(y + 1), hz), w, h, Z, c);
|
|
2781
|
+
quad(vec3(px(b), py(y + 1), -hz), w.scale(-1), h, Z.scale(-1), c);
|
|
2782
|
+
});
|
|
2783
|
+
// walls facing up and down where the pixel above or below is empty
|
|
2784
|
+
runs(width, x=> solid(x, y - 1) ? undefined : solid(x, y), (a, b, c)=>
|
|
2785
|
+
quad(vec3(px(a), py(y), hz), X.scale((b - a) * sx), Z.scale(-depth), Y, c));
|
|
2786
|
+
runs(width, x=> solid(x, y + 1) ? undefined : solid(x, y), (a, b, c)=>
|
|
2787
|
+
quad(vec3(px(a), py(y + 1), -hz), X.scale((b - a) * sx), Z.scale(depth), Y.scale(-1), c));
|
|
2788
|
+
}
|
|
2789
|
+
for (let x = 0; x < width; ++x)
|
|
2790
|
+
{
|
|
2791
|
+
// walls facing left and right where the pixel beside is empty
|
|
2792
|
+
runs(height, y=> solid(x - 1, y) ? undefined : solid(x, y), (a, b, c)=>
|
|
2793
|
+
quad(vec3(px(x), py(b), -hz), Z.scale(depth), Y.scale((b - a) * sy), X.scale(-1), c));
|
|
2794
|
+
runs(height, y=> solid(x + 1, y) ? undefined : solid(x, y), (a, b, c)=>
|
|
2795
|
+
quad(vec3(px(x + 1), py(b), hz), Z.scale(-depth), Y.scale((b - a) * sy), X, c));
|
|
2796
|
+
}
|
|
2797
|
+
return mesh;
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
/**
|
|
2801
|
+
* Build a mesh of extruded text from an image font, the engine font by default so it needs no assets
|
|
2802
|
+
* - Each glyph is extruded once per font and reused, the block is centered and faces +Z
|
|
2803
|
+
* - Newlines stack downward, spaced a little wider than the character height so the sides do not collide
|
|
2804
|
+
* - Every call builds a new mesh, dispose the old one when text changes often
|
|
2805
|
+
* - Glyphs are white in the engine font, so the object's color tints the text
|
|
2806
|
+
* @param {string|number} text
|
|
2807
|
+
* @param {number} [size] - Character height in world units
|
|
2808
|
+
* @param {number} [depth] - Thickness along Z
|
|
2809
|
+
* @param {ImageFont} [font] - Defaults to engineImageFont
|
|
2810
|
+
* @return {Mesh}
|
|
2811
|
+
* @memberof Render3D
|
|
2812
|
+
* @example
|
|
2813
|
+
* new EngineObject3D(vec3(0, 2, 0), buildText3D('HELLO'), undefined, YELLOW);
|
|
2814
|
+
*/
|
|
2815
|
+
function buildText3D(text, size=1, depth=.2, font=engineImageFont)
|
|
2816
|
+
{
|
|
2817
|
+
ASSERT(font instanceof ImageFont, 'font must be an ImageFont, the engine font loads before gameInit');
|
|
2818
|
+
const tileInfo = font.tileInfo, padding = tileInfo.padding;
|
|
2819
|
+
const paddedX = tileInfo.size.x + padding * 2, paddedY = tileInfo.size.y + padding * 2;
|
|
2820
|
+
const columns = tileInfo.textureInfo.size.x / paddedX | 0;
|
|
2821
|
+
let glyphs = render3DGlyphCache.get(font); // unit sized, scaled when combined
|
|
2822
|
+
glyphs || render3DGlyphCache.set(font, glyphs = new Map);
|
|
2823
|
+
const charSize = vec2(size * tileInfo.size.x / tileInfo.size.y, size);
|
|
2824
|
+
const mesh = new Mesh, lines = (text + '').split('\n');
|
|
2825
|
+
lines.forEach((line, j)=>
|
|
2826
|
+
{
|
|
2827
|
+
const y = ((lines.length - 1) / 2 - j) * charSize.y * RENDER3D_TEXT_LEADING;
|
|
2828
|
+
for (let i = 0; i < line.length; ++i)
|
|
2829
|
+
{
|
|
2830
|
+
const charCode = line.charCodeAt(i);
|
|
2831
|
+
const index = charCode < 32 || charCode > 127 ? 95 : charCode - 32; // like ImageFont
|
|
2832
|
+
if (!index) continue; // space
|
|
2833
|
+
let glyph = glyphs.get(index);
|
|
2834
|
+
if (!glyph)
|
|
2835
|
+
{
|
|
2836
|
+
const pos = vec2(index % columns * paddedX + padding, (index / columns | 0) * paddedY + padding);
|
|
2837
|
+
glyphs.set(index, glyph = buildExtrude(new TileInfo(pos, tileInfo.size, tileInfo.textureInfo)));
|
|
2838
|
+
}
|
|
2839
|
+
const x = (i - (line.length - 1) / 2) * charSize.x;
|
|
2840
|
+
mesh.combine(glyph, buildMatrix(vec3(x, y, 0), undefined, vec3(charSize.x, charSize.y, depth)));
|
|
2841
|
+
}
|
|
2842
|
+
});
|
|
2843
|
+
return mesh;
|
|
2844
|
+
}
|
|
2845
|
+
|
|
2846
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2847
|
+
/**
|
|
2848
|
+
* HeightMap - Terrain built from a grid of heights, with a mesh, a height lookup and a raycast
|
|
2849
|
+
* - heights is a 2D array [row][column] of 0 to 1 values
|
|
2850
|
+
* - Row 0 is the far edge at -Z and column 0 is the left edge at -X
|
|
2851
|
+
* - It can be an image instead, where the red channel is the height
|
|
2852
|
+
* - colors is an optional 2D array of Colors or an image, sampled per vertex
|
|
2853
|
+
* - images are read through a canvas, so they must be same origin or loaded with crossOrigin set
|
|
2854
|
+
* @memberof Render3D
|
|
2855
|
+
* @example
|
|
2856
|
+
* const terrain = new HeightMap(heightImage, vec2(100, 100), 10, colorImage);
|
|
2857
|
+
* new EngineObject3D(vec3(), terrain.buildMesh());
|
|
2858
|
+
* const y = terrain.getHeight(x, z); // stand things on it
|
|
2859
|
+
*/
|
|
2860
|
+
class HeightMap
|
|
2861
|
+
{
|
|
2862
|
+
/** Create a height map from an array or an image
|
|
2863
|
+
* @param {Array<Array<number>>|HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|TextureInfo} heights
|
|
2864
|
+
* @param {Vector2} [size] - World size along X and Z
|
|
2865
|
+
* @param {number} [height] - World height of a full value
|
|
2866
|
+
* @param {Array<Array<Color>>|HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|TextureInfo} [colors] */
|
|
2867
|
+
constructor(heights, size=vec2(1), height=1, colors)
|
|
2868
|
+
{
|
|
2869
|
+
if (!isArray(heights))
|
|
2870
|
+
heights = render3DImageToArray(heights, (r)=> r / 255);
|
|
2871
|
+
if (colors && !isArray(colors))
|
|
2872
|
+
colors = render3DImageToArray(colors, (r, g, b, a)=> rgb(r / 255, g / 255, b / 255, a / 255));
|
|
2873
|
+
ASSERT(isArray(heights) && heights.length > 1 && isArray(heights[0]) && heights[0].length > 1, 'height map needs at least 2 rows and 2 columns');
|
|
2874
|
+
ASSERT(size.x > 0 && size.y > 0, 'height map size must be positive, a zero size has nowhere to look things up');
|
|
2875
|
+
|
|
2876
|
+
/** @property {Array<Array<number>>} - Heights 0-1 as [row][column], rows along Z */
|
|
2877
|
+
this.heights = heights;
|
|
2878
|
+
/** @property {Array<Array<Color>>|undefined} - Vertex colors as [row][column], undefined for white
|
|
2879
|
+
* @type {Array<Array<Color>>|undefined} */
|
|
2880
|
+
this.colors = colors;
|
|
2881
|
+
/** @property {Vector2} - World size along X and Z */
|
|
2882
|
+
this.size = size.copy();
|
|
2883
|
+
/** @property {number} - World height of a full value */
|
|
2884
|
+
this.height = height;
|
|
2885
|
+
}
|
|
2886
|
+
|
|
2887
|
+
/** Number of rows, along Z
|
|
2888
|
+
* @return {number} */
|
|
2889
|
+
get rows() { return this.heights.length; }
|
|
2890
|
+
|
|
2891
|
+
/** Number of columns, along X
|
|
2892
|
+
* @return {number} */
|
|
2893
|
+
get columns() { return this.heights[0].length; }
|
|
2894
|
+
|
|
2895
|
+
/** World height at a position, exactly the height of the mesh buildMesh draws there, clamped at the edges
|
|
2896
|
+
* @param {number|Vector3} x - X, or a position to take X and Z from
|
|
2897
|
+
* @param {number} [z]
|
|
2898
|
+
* @return {number} */
|
|
2899
|
+
getHeight(x, z)
|
|
2900
|
+
{
|
|
2901
|
+
if (x instanceof Vector3)
|
|
2902
|
+
z = x.z, x = x.x; // a position works as well as its two numbers, its own y is ignored
|
|
2903
|
+
const columns = this.columns, rows = this.rows, h = this.heights;
|
|
2904
|
+
const u = clamp((x / this.size.x + .5) * (columns - 1), 0, columns - 1);
|
|
2905
|
+
const v = clamp((z / this.size.y + .5) * (rows - 1), 0, rows - 1);
|
|
2906
|
+
const i = min(floor(u), columns - 2), j = min(floor(v), rows - 2);
|
|
2907
|
+
const fu = u - i, fv = v - j;
|
|
2908
|
+
// each cell is two triangles split from (i, j+1) to (i+1, j), the same split buildGrid's quads use
|
|
2909
|
+
const a = h[j][i], b = h[j+1][i], c = h[j+1][i+1], d = h[j][i+1];
|
|
2910
|
+
const height = fu + fv <= 1 ? a + fu * (d - a) + fv * (b - a) : c + (1 - fu) * (b - c) + (1 - fv) * (d - c);
|
|
2911
|
+
return height * this.height;
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2914
|
+
/** Surface normal at a position, from the slope across a sample
|
|
2915
|
+
* @param {number|Vector3} x - X, or a position to take X and Z from
|
|
2916
|
+
* @param {number} [z]
|
|
2917
|
+
* @return {Vector3} */
|
|
2918
|
+
getNormal(x, z)
|
|
2919
|
+
{
|
|
2920
|
+
if (x instanceof Vector3)
|
|
2921
|
+
z = x.z, x = x.x;
|
|
2922
|
+
const ex = this.size.x / (this.columns - 1) / 2, ez = this.size.y / (this.rows - 1) / 2;
|
|
2923
|
+
return render3DSlopeNormal((x, z)=> this.getHeight(x, z), x, z, ex, ez, this.size.x / 2, this.size.y / 2);
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
/** Color of the nearest sample to a position, white when there are no colors
|
|
2927
|
+
* @param {number|Vector3} x - X, or a position to take X and Z from
|
|
2928
|
+
* @param {number} [z]
|
|
2929
|
+
* @return {Color} */
|
|
2930
|
+
getColor(x, z)
|
|
2931
|
+
{
|
|
2932
|
+
if (x instanceof Vector3)
|
|
2933
|
+
z = x.z, x = x.x;
|
|
2934
|
+
const c = this.colors;
|
|
2935
|
+
if (!c) return WHITE;
|
|
2936
|
+
const columns = c[0].length, rows = c.length;
|
|
2937
|
+
const i = clamp(round((x / this.size.x + .5) * (columns - 1)), 0, columns - 1);
|
|
2938
|
+
const j = clamp(round((z / this.size.y + .5) * (rows - 1)), 0, rows - 1);
|
|
2939
|
+
return c[j][i];
|
|
2940
|
+
}
|
|
2941
|
+
|
|
2942
|
+
/** Distance along a ray to where it crosses the terrain surface, or undefined for a miss
|
|
2943
|
+
* - Steps along the ray half a cell at a time, then narrows in on the exact spot
|
|
2944
|
+
* - A ray that starts under the ground crosses on its way out, so the hit is still on the surface
|
|
2945
|
+
* @param {Ray3D} ray - From screenToRay, or any ray
|
|
2946
|
+
* @return {number|undefined} */
|
|
2947
|
+
raycast(ray)
|
|
2948
|
+
{
|
|
2949
|
+
const {origin, direction} = ray;
|
|
2950
|
+
const size = this.size, height = this.height, length = direction.length();
|
|
2951
|
+
if (!length) return;
|
|
2952
|
+
|
|
2953
|
+
// clip to the box around the terrain, and walk it in half cell steps from there
|
|
2954
|
+
let t = raycastBox(ray, vec3(0, height / 2, 0), vec3(size.x, abs(height) + 1e-3, size.y));
|
|
2955
|
+
if (t === undefined) return;
|
|
2956
|
+
const cell = min(size.x / (this.columns - 1), size.y / (this.rows - 1));
|
|
2957
|
+
const step = cell / 2 / length, end = t + hypot(size.x, size.y, height) / length;
|
|
2958
|
+
if (!(step > 0)) return; // a zero size
|
|
2959
|
+
|
|
2960
|
+
// is the ray below the ground this far along, or undefined where it is off the map
|
|
2961
|
+
const under = (at)=>
|
|
2962
|
+
{
|
|
2963
|
+
const p = origin.add(direction.scale(at));
|
|
2964
|
+
if (abs(p.x) > size.x / 2 || abs(p.z) > size.y / 2) return;
|
|
2965
|
+
return p.y <= this.getHeight(p.x, p.z);
|
|
2966
|
+
};
|
|
2967
|
+
|
|
2968
|
+
// look for where the ray changes sides, so one coming up from under the ground
|
|
2969
|
+
// lands on the surface it breaks through instead of wherever it entered the box
|
|
2970
|
+
const startUnder = under(t);
|
|
2971
|
+
if (startUnder === undefined) return; // it meets the box outside the map itself
|
|
2972
|
+
for (; t < end; t += step)
|
|
2973
|
+
{
|
|
2974
|
+
const u = under(t + step);
|
|
2975
|
+
if (u === undefined) return; // it left the map before crossing
|
|
2976
|
+
if (u === startUnder) continue;
|
|
2977
|
+
|
|
2978
|
+
// it crossed between the last two samples, halve the gap until it is exact
|
|
2979
|
+
let a = t, b = t + step;
|
|
2980
|
+
for (let i = 0; i < 16; ++i)
|
|
2981
|
+
{
|
|
2982
|
+
const mid = (a + b) / 2;
|
|
2983
|
+
under(mid) === startUnder ? a = mid : b = mid;
|
|
2984
|
+
}
|
|
2985
|
+
return b;
|
|
2986
|
+
}
|
|
2987
|
+
}
|
|
2988
|
+
|
|
2989
|
+
/** Build the terrain mesh, one vertex per sample, centered on the origin
|
|
2990
|
+
* @param {boolean} [smooth] - Defaults to render3D.smoothShading
|
|
2991
|
+
* @return {Mesh} */
|
|
2992
|
+
buildMesh(smooth=render3D?.smoothShading)
|
|
2993
|
+
{
|
|
2994
|
+
return buildGrid(this.size, vec2(this.columns - 1, this.rows - 1),
|
|
2995
|
+
this.colors && ((x, z)=> this.getColor(x, z)), (x, z)=> this.getHeight(x, z), smooth);
|
|
2996
|
+
}
|
|
2997
|
+
}
|
|
2998
|
+
|
|
2999
|
+
// read an image's pixel bytes through the engine's work canvas, as {data, width, height}
|
|
3000
|
+
function render3DImageData(image)
|
|
3001
|
+
{
|
|
3002
|
+
if (image instanceof TextureInfo)
|
|
3003
|
+
image = image.image;
|
|
3004
|
+
ASSERT(image && image.width && image.height, 'image is not loaded');
|
|
3005
|
+
ASSERT(workReadCanvas, 'reading an image needs a canvas, pass arrays in headless mode');
|
|
3006
|
+
const width = image.width, height = image.height;
|
|
3007
|
+
workReadCanvas.width = width;
|
|
3008
|
+
workReadCanvas.height = height;
|
|
3009
|
+
workReadContext.drawImage(image, 0, 0);
|
|
3010
|
+
return workReadContext.getImageData(0, 0, width, height);
|
|
3011
|
+
}
|
|
3012
|
+
|
|
3013
|
+
// read an image into a 2D array [row][column], sample is called with (r, g, b, a) bytes for each pixel
|
|
3014
|
+
function render3DImageToArray(image, sample)
|
|
3015
|
+
{
|
|
3016
|
+
const {data, width, height} = render3DImageData(image);
|
|
3017
|
+
const rows = [];
|
|
3018
|
+
for (let y = 0; y < height; ++y)
|
|
3019
|
+
{
|
|
3020
|
+
const row = rows[y] = [];
|
|
3021
|
+
for (let x = 0; x < width; ++x)
|
|
3022
|
+
{
|
|
3023
|
+
const k = (y * width + x) * 4;
|
|
3024
|
+
row.push(sample(data[k], data[k+1], data[k+2], data[k+3]));
|
|
3025
|
+
}
|
|
3026
|
+
}
|
|
3027
|
+
return rows;
|
|
3028
|
+
}
|
|
3029
|
+
|
|
3030
|
+
// extruded glyph meshes by font, and the pixel bytes of a texture, read once per image
|
|
3031
|
+
const render3DGlyphCache = new WeakMap, render3DPixelCache = new WeakMap;
|
|
3032
|
+
function render3DReadPixels(textureInfo)
|
|
3033
|
+
{
|
|
3034
|
+
const image = textureInfo.image;
|
|
3035
|
+
let pixels = render3DPixelCache.get(image);
|
|
3036
|
+
if (!pixels)
|
|
3037
|
+
render3DPixelCache.set(image, pixels = render3DImageData(image));
|
|
3038
|
+
return pixels;
|
|
3039
|
+
}
|
|
3040
|
+
|
|
3041
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3042
|
+
/**
|
|
3043
|
+
* EngineObject3D - An EngineObject with a 3D transform and a mesh
|
|
3044
|
+
* - Set pos3D, rotation3D and scale3D instead of the 2D pos, size and angle
|
|
3045
|
+
* - Gets update, children, timers, destroy and renderOrder from EngineObject
|
|
3046
|
+
* - velocity3D is added to pos3D each frame, along with render3D.gravity and damping once it has a mass
|
|
3047
|
+
* - Objects face -Z, the same way the camera does, so lookAt turns them to face a point
|
|
3048
|
+
* - The 2D pos and velocity are still there but nothing draws them
|
|
3049
|
+
* - These inherited fields are 2D only and do nothing here: angle, angleVelocity, angleDamping,
|
|
3050
|
+
* additiveColor, drawSize, mirror, clampSpeed, friction and groundObject
|
|
3051
|
+
* - The inherited shader works here as in 2D, and with emissive at 1 its snippet does its own lighting
|
|
3052
|
+
* - Set sync2D for a 2D game with 3D looks, pos and angle then drive pos3D and rotation3D,
|
|
3053
|
+
* which is the one way those 2D fields reach a 3D object
|
|
3054
|
+
* - setCollision takes the same flags as in 2D, but the solid collision happens in 3D against size3D
|
|
3055
|
+
* - Its tile and raycast halves are 2D only so they default off here, and a child sits solid collision out
|
|
3056
|
+
* - A sync2D object collides in 2D instead, which needs the 2D size set as well as size3D
|
|
3057
|
+
* - setMesh swaps the mesh and frees the old one, for text and terrain that get built again
|
|
3058
|
+
* - addChild attaches the 3D transform, and pos3D becomes an offset from the parent
|
|
3059
|
+
* - The 2D offset arguments of addChild do nothing here, set the child's pos3D
|
|
3060
|
+
* @extends EngineObject
|
|
3061
|
+
* @memberof Render3D
|
|
3062
|
+
* @example
|
|
3063
|
+
* class Spinner extends EngineObject3D
|
|
3064
|
+
* {
|
|
3065
|
+
* constructor(pos) { super(pos, buildBox(), undefined, RED); }
|
|
3066
|
+
* update() { this.rotation3D.y += .02; }
|
|
3067
|
+
* }
|
|
3068
|
+
*/
|
|
3069
|
+
class EngineObject3D extends EngineObject
|
|
3070
|
+
{
|
|
3071
|
+
/** Create a 3D object and add it to the object list
|
|
3072
|
+
* @param {Vector3} [pos3D] - World space position
|
|
3073
|
+
* @param {Mesh} [mesh] - Mesh to draw, undefined draws nothing
|
|
3074
|
+
* @param {TileInfo|TextureInfo} [tileInfo] - Texture, mesh uvs map across the tile; a whole TextureInfo becomes the tile that covers it
|
|
3075
|
+
* @param {Color} [color] - Tint */
|
|
3076
|
+
constructor(pos3D=vec3(), mesh, tileInfo, color=WHITE)
|
|
3077
|
+
{
|
|
3078
|
+
ASSERT(!tileInfo || tileInfo instanceof TileInfo || tileInfo instanceof TextureInfo, 'tileInfo must be a TileInfo or TextureInfo, it comes before color');
|
|
3079
|
+
// a whole texture is stored as the tile that covers it, with no padding or bleed to trim
|
|
3080
|
+
// the edges, so this is always a TileInfo like the 2D one and the object stays an EngineObject
|
|
3081
|
+
if (tileInfo instanceof TextureInfo)
|
|
3082
|
+
tileInfo = new TileInfo(vec2(), tileInfo.size, tileInfo, 0, 0);
|
|
3083
|
+
super(vec2(), vec2(), tileInfo, 0, color);
|
|
3084
|
+
ASSERT(isVector3(pos3D), 'pos3D must be a vec3');
|
|
3085
|
+
ASSERT(!mesh || mesh instanceof Mesh, 'mesh must be a Mesh or undefined');
|
|
3086
|
+
this.mass = 0; // static: no 2D physics, and no 3D gravity until a mass is set
|
|
3087
|
+
|
|
3088
|
+
/** @property {Vector3} - World space position, local to the parent when attached to an EngineObject3D */
|
|
3089
|
+
this.pos3D = pos3D.copy();
|
|
3090
|
+
/** @property {Vector3} - Rotation vec3(pitch, yaw, roll) in radians, local to the parent when attached to an EngineObject3D */
|
|
3091
|
+
this.rotation3D = vec3();
|
|
3092
|
+
/** @property {Vector3} - Scale, local to the parent when attached to an EngineObject3D */
|
|
3093
|
+
this.scale3D = vec3(1);
|
|
3094
|
+
/** @property {Vector3} - Added to pos3D each frame by the engine before update, like the 2D velocity, no super call needed;
|
|
3095
|
+
* damping and render3D.gravity act on it once the object has a mass */
|
|
3096
|
+
this.velocity3D = vec3();
|
|
3097
|
+
/** @property {Vector3} - Added to rotation3D each frame by the engine before update, angleDamping is 2D only */
|
|
3098
|
+
this.angleVelocity3D = vec3();
|
|
3099
|
+
/** @property {Mesh|undefined} - Mesh to draw
|
|
3100
|
+
* @type {Mesh|undefined} */
|
|
3101
|
+
this.mesh = mesh;
|
|
3102
|
+
/** @property {Vector3} - Size for the collect and callback helpers, and of the sprite when there is a tileInfo
|
|
3103
|
+
* and no mesh; scale3D and any parent's scale grow it, so drawing and picking agree */
|
|
3104
|
+
this.size3D = vec3(1);
|
|
3105
|
+
/** @property {number} - Diameter of a soft shadow drawn under the object on render3D.softShadowHeight, 0 for none;
|
|
3106
|
+
* scale3D and a parent's scale grow it, so set it once for the unscaled object */
|
|
3107
|
+
this.softShadow = 0;
|
|
3108
|
+
/** @property {boolean} - A sprite stands on world up instead of tilting toward the camera */
|
|
3109
|
+
this.upright = false;
|
|
3110
|
+
/** @property {boolean} - Keep this object's texture pixels hard edged, for pixel art that should not blur or bleed */
|
|
3111
|
+
this.pixelated = false;
|
|
3112
|
+
/** @property {boolean} - Copy the 2D pos and angle into pos3D and rotation3D each frame, for 2D games with 3D looks;
|
|
3113
|
+
* set mass to use 2D physics, and pos3D.z stays yours to set or move with velocity3D.z */
|
|
3114
|
+
this.sync2D = false;
|
|
3115
|
+
/** @property {boolean} - Draw in the transparent stage, blended and sorted far to near with depth writes off; on for a sprite */
|
|
3116
|
+
this.transparent = !mesh && !!tileInfo;
|
|
3117
|
+
/** @property {boolean} - Additive blending, in the transparent stage */
|
|
3118
|
+
this.additive = false;
|
|
3119
|
+
/** @property {number} - How much it lights itself: 0 is lit as normal, 1 is its own color with no shading, for
|
|
3120
|
+
* lamps and glowing things, between is partly self lit, and above 1 is brighter than its color, for bloom */
|
|
3121
|
+
this.emissive = 0;
|
|
3122
|
+
/** @property {number} - Strength of the highlight where the sunlight reflects, 0 is none and 1 adds the sun's full color at its brightest; its size is fixed */
|
|
3123
|
+
this.specular = 0;
|
|
3124
|
+
/** @property {boolean} - Draw into the shadow map when render3D.shadows is on; sprites and cut out textures cast their outline, additive objects never cast */
|
|
3125
|
+
this.castShadow = true;
|
|
3126
|
+
/** @property {boolean} - Collide as the sphere that fits size3D instead of as the size3D box, so it rolls around corners */
|
|
3127
|
+
this.collideAsSphere3D = false;
|
|
3128
|
+
/** @property {boolean} - Darkened by the shadow map when render3D.shadows is on */
|
|
3129
|
+
this.receiveShadow = true;
|
|
3130
|
+
/** @property {boolean|undefined} - Draw this object over the 2D scene, undefined uses render3D.renderAfter2D
|
|
3131
|
+
* @type {boolean|undefined} */
|
|
3132
|
+
this.renderAfter2D = undefined;
|
|
3133
|
+
}
|
|
3134
|
+
|
|
3135
|
+
/** Move by the 3D velocities and push out of solids, called automatically each frame before update, like the 2D physics
|
|
3136
|
+
* - update runs once every object has moved and collided, so bounce off anything else there, it lands before the draw
|
|
3137
|
+
* - Override this and call super to change how the object moves itself
|
|
3138
|
+
* - A sync2D object runs the 2D physics as well, and collides there instead */
|
|
3139
|
+
updatePhysics()
|
|
3140
|
+
{
|
|
3141
|
+
// a sync2D object collides in 2D, which measures the 2D size, and that starts at zero on a 3D object
|
|
3142
|
+
ASSERT(!this.sync2D || !this.collideSolidObjects || (this.size.x && this.size.y),
|
|
3143
|
+
'a sync2D object collides in 2D, so give it a 2D size as well as a size3D', this.size);
|
|
3144
|
+
if (this.sync2D)
|
|
3145
|
+
super.updatePhysics();
|
|
3146
|
+
render3DMove(this);
|
|
3147
|
+
// the engine only runs this for objects that own where they are, a child rides along with its parent
|
|
3148
|
+
if (this.collideSolidObjects && !this.sync2D)
|
|
3149
|
+
render3DCollideSolid(this);
|
|
3150
|
+
}
|
|
3151
|
+
|
|
3152
|
+
/** Move a child by its own velocities, bring a sync2D object's pos3D up to its 2D pos, then update the children,
|
|
3153
|
+
* called automatically each frame */
|
|
3154
|
+
updateTransforms()
|
|
3155
|
+
{
|
|
3156
|
+
if (!paused)
|
|
3157
|
+
{
|
|
3158
|
+
// a child is never given updatePhysics, so it moves here, as an offset from its parent
|
|
3159
|
+
this.parent && render3DMove(this);
|
|
3160
|
+
if (this.sync2D)
|
|
3161
|
+
this.pos3D.x = this.pos.x, this.pos3D.y = this.pos.y, this.rotation3D.z = -this.angle;
|
|
3162
|
+
}
|
|
3163
|
+
super.updateTransforms();
|
|
3164
|
+
}
|
|
3165
|
+
|
|
3166
|
+
/** Set how this object collides, the same flags as in 2D
|
|
3167
|
+
* - Solid collision happens in 3D here, against size3D boxes or spheres; a child sits it out
|
|
3168
|
+
* - A sync2D object collides in 2D instead, against the 2D size, so set that as well as size3D
|
|
3169
|
+
* @param {boolean} [collideSolidObjects] - Take part in solid collision
|
|
3170
|
+
* @param {boolean} [isSolid] - Block other objects, a pair where neither one blocks passes through;
|
|
3171
|
+
* blocking needs collideSolidObjects, so isSolid on its own is not allowed
|
|
3172
|
+
* @param {boolean} [collideTiles] - Tile collision, 2D only so it needs sync2D
|
|
3173
|
+
* @param {boolean} [collideRaycast] - Raycasts, 2D only; 3D has render3D.pick and engineObjectsRaycast3D */
|
|
3174
|
+
setCollision(collideSolidObjects=true, isSolid=true, collideTiles=false, collideRaycast=false)
|
|
3175
|
+
{ super.setCollision(collideSolidObjects, isSolid, collideTiles, collideRaycast); }
|
|
3176
|
+
|
|
3177
|
+
/** Returns the world position
|
|
3178
|
+
* @return {Vector3} */
|
|
3179
|
+
getWorldPos3D() { return this.getMatrix().getTranslation(); }
|
|
3180
|
+
|
|
3181
|
+
/** Returns the direction the object faces, its -Z axis in the world
|
|
3182
|
+
* @return {Vector3} */
|
|
3183
|
+
getForward3D() { return render3DAxis(this.getMatrix().m, 8).normalize(-1); }
|
|
3184
|
+
|
|
3185
|
+
/** Returns the object's right axis in the world
|
|
3186
|
+
* @return {Vector3} */
|
|
3187
|
+
getRight3D() { return render3DAxis(this.getMatrix().m, 0).normalize(); }
|
|
3188
|
+
|
|
3189
|
+
/** Returns the object's up axis in the world
|
|
3190
|
+
* @return {Vector3} */
|
|
3191
|
+
getUp3D() { return render3DAxis(this.getMatrix().m, 4).normalize(); }
|
|
3192
|
+
|
|
3193
|
+
/** Returns the object's world transform, relative to the parent's when attached to an EngineObject3D
|
|
3194
|
+
* @return {Matrix4} */
|
|
3195
|
+
getMatrix()
|
|
3196
|
+
{
|
|
3197
|
+
const matrix = buildMatrix(this.pos3D, this.rotation3D, this.scale3D);
|
|
3198
|
+
return this.parent instanceof EngineObject3D ? this.parent.getMatrix().multiply(matrix) : matrix;
|
|
3199
|
+
}
|
|
3200
|
+
|
|
3201
|
+
/** Turn the object so its -Z axis points at a world space target, sets pitch and yaw and clears roll
|
|
3202
|
+
* @param {Vector3} target */
|
|
3203
|
+
lookAt(target)
|
|
3204
|
+
{
|
|
3205
|
+
// rotation3D is local to the parent, so a child has to aim at the target from the parent's point of view
|
|
3206
|
+
const parent = this.parent instanceof EngineObject3D ? this.parent : undefined;
|
|
3207
|
+
const local = parent ? parent.getMatrix().invert().transformPoint(target) : target;
|
|
3208
|
+
this.rotation3D = render3DLookRotation(local.subtract(this.pos3D), this.rotation3D);
|
|
3209
|
+
}
|
|
3210
|
+
|
|
3211
|
+
/** Draw a different mesh and free the GPU buffer of the one it replaces
|
|
3212
|
+
* - For a mesh built again when something changes, like a score, a rebuilt terrain or a loaded model
|
|
3213
|
+
* - A mesh another object is still drawing is left alone, since builders are often shared
|
|
3214
|
+
* - Freeing one held somewhere else only costs it an upload, the points it was built from stay
|
|
3215
|
+
* @param {Mesh} [mesh] - The mesh to draw from now on, undefined to draw nothing
|
|
3216
|
+
* @return {Mesh|undefined} - The mesh passed in */
|
|
3217
|
+
setMesh(mesh)
|
|
3218
|
+
{
|
|
3219
|
+
ASSERT(!mesh || mesh instanceof Mesh, 'mesh must be a Mesh or undefined');
|
|
3220
|
+
const old = this.mesh;
|
|
3221
|
+
this.mesh = mesh;
|
|
3222
|
+
// nothing to free and nothing to look for when it was never uploaded
|
|
3223
|
+
if (old && old !== mesh && old.buffer && !engineObjects.some(o=> o.mesh === old))
|
|
3224
|
+
old.dispose();
|
|
3225
|
+
return mesh;
|
|
3226
|
+
}
|
|
3227
|
+
|
|
3228
|
+
/** 2D rendering is skipped, the mesh is drawn by render3D during the 3D pass */
|
|
3229
|
+
render() {}
|
|
3230
|
+
|
|
3231
|
+
/** Draw the object in 3D, called by the 3D pass with the draw state set from this object's flags, draws the mesh by default */
|
|
3232
|
+
render3D()
|
|
3233
|
+
{
|
|
3234
|
+
// an opaque draw comes out solid however low its alpha is, so a fade with no flag looks like nothing happened
|
|
3235
|
+
ASSERT(this.transparent || this.additive || this.color.a >= 1, 'an object that fades needs its transparent flag, an opaque draw ignores the color alpha', this.color);
|
|
3236
|
+
if (this.mesh)
|
|
3237
|
+
render3D.drawMesh(this.mesh, this.getMatrix(), this.tileInfo, this.color);
|
|
3238
|
+
else if (this.tileInfo)
|
|
3239
|
+
{
|
|
3240
|
+
// a sprite: size3D grown by its own scale and its parents', the same world size the
|
|
3241
|
+
// collect, pick and solid collision helpers measure it at
|
|
3242
|
+
const m = this.getMatrix().m;
|
|
3243
|
+
render3D.drawBillboard(vec3(m[12], m[13], m[14]),
|
|
3244
|
+
vec2(this.size3D.x * hypot(m[0], m[1], m[2]), this.size3D.y * hypot(m[4], m[5], m[6])),
|
|
3245
|
+
this.tileInfo, this.color, this.rotation3D.z, this.upright);
|
|
3246
|
+
}
|
|
3247
|
+
}
|
|
3248
|
+
}
|
|
3249
|
+
|
|
3250
|
+
// move an object by its 3D velocities, an object with mass falling with render3D.gravity and slowing by its damping
|
|
3251
|
+
function render3DMove(o)
|
|
3252
|
+
{
|
|
3253
|
+
if (o.mass && !o.sync2D) // a 2D driven object gets the 2D gravity instead
|
|
3254
|
+
{
|
|
3255
|
+
// damped first and gravity added after, the order EngineObject.updatePhysics uses,
|
|
3256
|
+
// so the same mass, damping and gravity fall the same way in both
|
|
3257
|
+
const v = o.velocity3D, g = render3D.gravity, s = o.gravityScale, d = o.damping;
|
|
3258
|
+
o.velocity3D = vec3(v.x * d + g.x * s, v.y * d + g.y * s, v.z * d + g.z * s);
|
|
3259
|
+
}
|
|
3260
|
+
o.pos3D = o.pos3D.add(o.velocity3D);
|
|
3261
|
+
o.rotation3D = o.rotation3D.add(o.angleVelocity3D);
|
|
3262
|
+
}
|
|
3263
|
+
|
|
3264
|
+
// where a solid object is in the world and what it collides as: the sphere that fits size3D, or the size3D box,
|
|
3265
|
+
// each grown by the object's scale
|
|
3266
|
+
// only objects that own where they are take part, so pos3D is already world space, and however the object is
|
|
3267
|
+
// turned its axes come out as long as its scale makes them; building the transform to read that back off it
|
|
3268
|
+
// costs six trig calls and a matrix for every pair tested, which is the whole cost of a crowded scene
|
|
3269
|
+
function render3DSolidShape(o)
|
|
3270
|
+
{
|
|
3271
|
+
ASSERT(!o.parent, 'a child rides along with its parent, it has no world pos3D of its own to collide with');
|
|
3272
|
+
const s = o.size3D, k = o.scale3D;
|
|
3273
|
+
const kx = abs(k.x), ky = abs(k.y), kz = abs(k.z);
|
|
3274
|
+
if (o.collideAsSphere3D)
|
|
3275
|
+
return {pos: o.pos3D.copy(), radius: max(s.x, s.y, s.z) / 2 * max(kx, ky, kz)};
|
|
3276
|
+
return {pos: o.pos3D.copy(), size: vec3(s.x * kx, s.y * ky, s.z * kz)};
|
|
3277
|
+
}
|
|
3278
|
+
|
|
3279
|
+
// how far a solid shape can reach from its own center, for a quick reject before the exact test
|
|
3280
|
+
// it has to be the shape's own radius, or a wider one: a box reaches to its corner, and a sphere
|
|
3281
|
+
// takes the largest scale the same way render3DSolidShape does, or the reject would skip real touches
|
|
3282
|
+
function render3DSolidReach(o)
|
|
3283
|
+
{
|
|
3284
|
+
const s = o.size3D, k = o.scale3D;
|
|
3285
|
+
const kx = abs(k.x), ky = abs(k.y), kz = abs(k.z);
|
|
3286
|
+
if (o.collideAsSphere3D)
|
|
3287
|
+
return max(s.x, s.y, s.z) / 2 * max(kx, ky, kz);
|
|
3288
|
+
return hypot(s.x * kx, s.y * ky, s.z * kz) / 2;
|
|
3289
|
+
}
|
|
3290
|
+
|
|
3291
|
+
// what it takes to move shape a clear of shape b, whichever pair of shapes they are, or undefined for no touch
|
|
3292
|
+
function render3DSolidPush(a, b)
|
|
3293
|
+
{
|
|
3294
|
+
if (!a.size) // a is a sphere
|
|
3295
|
+
return b.size ? collideSphereBox(a.pos, a.radius, b.pos, b.size)
|
|
3296
|
+
: collideSphereSphere(a.pos, a.radius, b.pos, b.radius);
|
|
3297
|
+
if (!b.size) // only b is, so push b out of a and turn it around
|
|
3298
|
+
{
|
|
3299
|
+
const push = collideSphereBox(b.pos, b.radius, a.pos, a.size);
|
|
3300
|
+
return push && push.scale(-1);
|
|
3301
|
+
}
|
|
3302
|
+
return collideBoxBox3D(a.pos, a.size, b.pos, b.size);
|
|
3303
|
+
}
|
|
3304
|
+
|
|
3305
|
+
// push a solid object out of the solids before it in the engine's list of them, so each pair is resolved once:
|
|
3306
|
+
// the ones after it update later and test against it then, and an object that is not in the list yet, because it
|
|
3307
|
+
// turned collision on this frame, tests them all itself and is not tested back
|
|
3308
|
+
// one pair per test is half the work of the 2D solver, which tests both directions; the difference only shows
|
|
3309
|
+
// when a collideWithObject destroys some third object, whose own turn then finds the pair already gone
|
|
3310
|
+
function render3DCollideSolid(a)
|
|
3311
|
+
{
|
|
3312
|
+
let shapeA = render3DSolidShape(a);
|
|
3313
|
+
const reachA = render3DSolidReach(a);
|
|
3314
|
+
for (const b of engineObjectsCollide)
|
|
3315
|
+
{
|
|
3316
|
+
if (b === a) break;
|
|
3317
|
+
if (b.destroyed || b.parent || b.sync2D || !(b instanceof EngineObject3D)) continue; // a child is part of its parent
|
|
3318
|
+
if (!a.isSolid && !b.isSolid) continue; // neither one blocks, so they pass through each other
|
|
3319
|
+
|
|
3320
|
+
// the pairs nowhere near each other are almost all of them in a scene of any size, so
|
|
3321
|
+
// settle those with one distance check instead of building a shape for each
|
|
3322
|
+
const p = shapeA.pos, q = b.pos3D, reach = reachA + render3DSolidReach(b);
|
|
3323
|
+
const dx = p.x - q.x, dy = p.y - q.y, dz = p.z - q.z;
|
|
3324
|
+
if (dx*dx + dy*dy + dz*dz > reach*reach)
|
|
3325
|
+
continue;
|
|
3326
|
+
|
|
3327
|
+
const push = render3DSolidPush(shapeA, render3DSolidShape(b));
|
|
3328
|
+
if (!push) continue;
|
|
3329
|
+
|
|
3330
|
+
// both objects hear about it, and either one can take the touch over
|
|
3331
|
+
const resolveA = a.collideWithObject(b, push);
|
|
3332
|
+
const resolveB = b.collideWithObject(a, push.scale(-1));
|
|
3333
|
+
if (!resolveA || !resolveB) continue;
|
|
3334
|
+
|
|
3335
|
+
// heavier objects move less, mass 0 stays put; then bounce apart when moving toward each other
|
|
3336
|
+
const total = a.mass + b.mass;
|
|
3337
|
+
const weightA = !a.mass ? 0 : !b.mass ? 1 : b.mass / total;
|
|
3338
|
+
const weightB = !b.mass ? 0 : !a.mass ? 1 : a.mass / total;
|
|
3339
|
+
a.pos3D = a.pos3D.add(push.scale(weightA));
|
|
3340
|
+
b.pos3D = b.pos3D.subtract(push.scale(weightB));
|
|
3341
|
+
if (weightA)
|
|
3342
|
+
shapeA = render3DSolidShape(a); // it moved, so the next solid must be tested against where it is now
|
|
3343
|
+
const normal = push.normalize();
|
|
3344
|
+
if (a.velocity3D.dot(normal) < 0)
|
|
3345
|
+
a.velocity3D = a.velocity3D.reflect(normal, a.restitution);
|
|
3346
|
+
if (b.velocity3D.dot(normal) > 0)
|
|
3347
|
+
b.velocity3D = b.velocity3D.reflect(normal, b.restitution);
|
|
3348
|
+
}
|
|
3349
|
+
}
|
|
3350
|
+
|
|
3351
|
+
/**
|
|
3352
|
+
* Collect the EngineObject3D objects whose boxes overlap a box, sizes are full sizes
|
|
3353
|
+
* - Boxes are axis aligned around the world position, rotation3D is ignored; lights, emitters and trails have no size
|
|
3354
|
+
* @param {Vector3} pos - Center of the box
|
|
3355
|
+
* @param {Vector3|number} size - Full size of the box, a number for a cube
|
|
3356
|
+
* @param {Array<EngineObject>} [objects] - Defaults to every object
|
|
3357
|
+
* @return {Array<EngineObject3D>}
|
|
3358
|
+
* @memberof Render3D
|
|
3359
|
+
*/
|
|
3360
|
+
function engineObjectsCollect3D(pos, size, objects=engineObjects)
|
|
3361
|
+
{
|
|
3362
|
+
size = render3DSize3(size);
|
|
3363
|
+
const collected = [];
|
|
3364
|
+
for (const o of objects)
|
|
3365
|
+
{
|
|
3366
|
+
if (!(o instanceof EngineObject3D) || o.destroyed) continue;
|
|
3367
|
+
const m = o.getMatrix().m, s = o.size3D; // the box in world space, scaled by the object and its parents
|
|
3368
|
+
if (!(s.x || s.y || s.z)) continue;
|
|
3369
|
+
const worldSize = vec3(s.x * hypot(m[0], m[1], m[2]), s.y * hypot(m[4], m[5], m[6]), s.z * hypot(m[8], m[9], m[10]));
|
|
3370
|
+
if (isOverlapping3D(pos, size, vec3(m[12], m[13], m[14]), worldSize))
|
|
3371
|
+
collected.push(o);
|
|
3372
|
+
}
|
|
3373
|
+
return collected;
|
|
3374
|
+
}
|
|
3375
|
+
|
|
3376
|
+
// how far along a ray an object is hit, or undefined for a miss; each one is tested as a sphere
|
|
3377
|
+
// around its mesh, or around a sprite's size3D, not triangle by triangle
|
|
3378
|
+
function render3DRaycastObject(ray, o)
|
|
3379
|
+
{
|
|
3380
|
+
if (o.destroyed || !(o instanceof EngineObject3D) || !(o.mesh || o.tileInfo)) return;
|
|
3381
|
+
const matrix = o.getMatrix(), mesh = o.mesh; // a sprite is picked by its size3D
|
|
3382
|
+
const radius = (mesh ? mesh.radius || mesh.computeRadius() : hypot(o.size3D.x, o.size3D.y) / 2) * render3DMaxScale(matrix.m);
|
|
3383
|
+
if (!(radius > 0)) return; // nothing to hit
|
|
3384
|
+
return raycastSphere(ray, matrix.getTranslation(), radius);
|
|
3385
|
+
}
|
|
3386
|
+
|
|
3387
|
+
/**
|
|
3388
|
+
* Collect every EngineObject3D a ray passes through, nearest first, the 3D twin of engineObjectsRaycast
|
|
3389
|
+
* - The ray has no end, so everything along it counts however far away it is
|
|
3390
|
+
* - Use render3D.pick for the nearest one on its own, with the distance to it
|
|
3391
|
+
* @param {Ray3D} ray - From render3D.screenToRay, or any ray
|
|
3392
|
+
* @param {Array<EngineObject>} [objects] - Defaults to every object; only those with a mesh or a sprite count
|
|
3393
|
+
* @return {Array<EngineObject3D>}
|
|
3394
|
+
* @memberof Render3D
|
|
3395
|
+
*/
|
|
3396
|
+
function engineObjectsRaycast3D(ray, objects=engineObjects)
|
|
3397
|
+
{
|
|
3398
|
+
const hits = [];
|
|
3399
|
+
for (const o of objects)
|
|
3400
|
+
{
|
|
3401
|
+
const distance = render3DRaycastObject(ray, o);
|
|
3402
|
+
if (distance !== undefined)
|
|
3403
|
+
hits.push({o, distance});
|
|
3404
|
+
}
|
|
3405
|
+
return hits.sort((a, b)=> a.distance - b.distance).map(hit=> hit.o);
|
|
3406
|
+
}
|
|
3407
|
+
|
|
3408
|
+
/**
|
|
3409
|
+
* Call a function for each EngineObject3D whose box overlaps a box
|
|
3410
|
+
* @param {Vector3} pos - Center of the box
|
|
3411
|
+
* @param {Vector3|number} size - Full size of the box, a number for a cube
|
|
3412
|
+
* @param {Function} callback
|
|
3413
|
+
* @param {Array<EngineObject>} [objects] - Defaults to every object
|
|
3414
|
+
* @memberof Render3D
|
|
3415
|
+
*/
|
|
3416
|
+
function engineObjectsCallback3D(pos, size, callback, objects=engineObjects)
|
|
3417
|
+
{ engineObjectsCollect3D(pos, size, objects).forEach(callback); }
|
|
3418
|
+
|
|
3419
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3420
|
+
/**
|
|
3421
|
+
* Light3D - A light that is an EngineObject3D, so it can move, follow a parent or be destroyed like anything else
|
|
3422
|
+
* - A point light: it lights what is near it and fades out by its radius, DirectionalLight3D shines from far away
|
|
3423
|
+
* - Only the sun, render3D.sunDirection, casts shadows and makes highlights, these light without either
|
|
3424
|
+
* - Only the 8 lights nearest the camera are used each frame
|
|
3425
|
+
* - radius is where the light fades out, and it fades fast, so a small radius wants a higher intensity
|
|
3426
|
+
* - intensity multiplies the color, above 1 for a light brighter than white
|
|
3427
|
+
* - radius is a world distance, so scale3D does not change it
|
|
3428
|
+
* - An alpha, an intensity or a radius of 0 switches it off, and a light that is off takes none of those slots
|
|
3429
|
+
* - Draws nothing itself, add a glow with drawSoftDisc or a small emissive mesh if it should be seen
|
|
3430
|
+
* @extends EngineObject3D
|
|
3431
|
+
* @memberof Render3D
|
|
3432
|
+
* @example
|
|
3433
|
+
* const torch = new Light3D(vec3(0, 3, 0), 10, hsl(.1, 1, .65));
|
|
3434
|
+
*/
|
|
3435
|
+
class Light3D extends EngineObject3D
|
|
3436
|
+
{
|
|
3437
|
+
/** Create a point light
|
|
3438
|
+
* @param {Vector3} [pos3D] - Where it is
|
|
3439
|
+
* @param {number} [radius] - Distance where the light fades to nothing
|
|
3440
|
+
* @param {Color} [color] - Light color, its alpha fades it
|
|
3441
|
+
* @param {number} [intensity] - Brightness, multiplies the color, above 1 is brighter than white */
|
|
3442
|
+
constructor(pos3D=vec3(), radius=5, color=WHITE, intensity=1)
|
|
3443
|
+
{
|
|
3444
|
+
super(pos3D, undefined, undefined, color);
|
|
3445
|
+
ASSERT(radius >= 0, 'light radius cannot be negative, 0 is an off switch like an alpha of 0');
|
|
3446
|
+
ASSERT(intensity >= 0, 'light intensity cannot be negative, 0 is an off switch');
|
|
3447
|
+
this.size3D = vec3(); // not a solid thing to pick or collect
|
|
3448
|
+
/** @property {number} - Distance where the light fades to nothing */
|
|
3449
|
+
this.radius = radius;
|
|
3450
|
+
/** @property {number} - Brightness, multiplies the color, above 1 is brighter than white */
|
|
3451
|
+
this.intensity = intensity;
|
|
3452
|
+
/** @property {boolean} - Shine from far away, from its position toward the origin, instead of out from its
|
|
3453
|
+
* position with a falloff; DirectionalLight3D sets it */
|
|
3454
|
+
this.directional = false;
|
|
3455
|
+
}
|
|
3456
|
+
|
|
3457
|
+
/** Lights draw nothing */
|
|
3458
|
+
render3D() {}
|
|
3459
|
+
}
|
|
3460
|
+
|
|
3461
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3462
|
+
/**
|
|
3463
|
+
* DirectionalLight3D - A Light3D that shines from far away with no falloff, like sunlight
|
|
3464
|
+
* - It shines from its position toward the origin, like a three.js DirectionalLight: only the direction to it
|
|
3465
|
+
* counts, so moving it or its parent swings the light around; parent it to a sun in the sky and it follows
|
|
3466
|
+
* - It cannot sit on the origin, since that leaves no direction
|
|
3467
|
+
* - Like every Light3D it casts no shadow and makes no highlight, only the sun, render3D.sunDirection, does
|
|
3468
|
+
* @extends Light3D
|
|
3469
|
+
* @memberof Render3D
|
|
3470
|
+
* @example
|
|
3471
|
+
* const fill = new DirectionalLight3D(vec3(-1, 1, 1), hsl(.6, .5, .3)); // from the back left and above
|
|
3472
|
+
*/
|
|
3473
|
+
class DirectionalLight3D extends Light3D
|
|
3474
|
+
{
|
|
3475
|
+
/** Create a directional light
|
|
3476
|
+
* @param {Vector3} [pos3D] - Where it shines from, toward the origin
|
|
3477
|
+
* @param {Color} [color] - Light color, its alpha fades it
|
|
3478
|
+
* @param {number} [intensity] - Brightness, multiplies the color, above 1 is brighter than white */
|
|
3479
|
+
constructor(pos3D=vec3(0, 1, 0), color=WHITE, intensity=1)
|
|
3480
|
+
{
|
|
3481
|
+
super(pos3D, 0, color, intensity);
|
|
3482
|
+
this.directional = true;
|
|
3483
|
+
}
|
|
3484
|
+
}
|
|
3485
|
+
|
|
3486
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3487
|
+
/**
|
|
3488
|
+
* CameraControl3D - Drag to turn the camera around a point, roll the wheel to zoom
|
|
3489
|
+
* - An EngineObject3D, so move its pos3D to follow something, or parent it to an object
|
|
3490
|
+
* - Destroy it to hand the camera back, and it stops driving the camera
|
|
3491
|
+
* - Set persistent to keep it when engineObjectsDestroy clears out a level
|
|
3492
|
+
* - Every part of it is a field, so a game can change the buttons, speeds and limits
|
|
3493
|
+
* @extends EngineObject3D
|
|
3494
|
+
* @memberof Render3D
|
|
3495
|
+
* @example
|
|
3496
|
+
* new CameraControl3D(vec3(0, 1, 0), 15); // look at a point from 15 units away
|
|
3497
|
+
*/
|
|
3498
|
+
class CameraControl3D extends EngineObject3D
|
|
3499
|
+
{
|
|
3500
|
+
/** Create a camera control, it drives render3D.camera every frame
|
|
3501
|
+
* @param {Vector3} [target] - The point to look at, its pos3D
|
|
3502
|
+
* @param {number} [distance] - How far the camera sits from the target
|
|
3503
|
+
* @param {number} [pitch] - Angle above the horizon, PI/2 looks straight down
|
|
3504
|
+
* @param {number} [idleSpin] - Turned each frame while not dragging, 0 holds still */
|
|
3505
|
+
constructor(target=vec3(), distance=10, pitch=.4, idleSpin=0)
|
|
3506
|
+
{
|
|
3507
|
+
super(target);
|
|
3508
|
+
this.size3D = vec3(); // not a solid thing to pick or collect
|
|
3509
|
+
/** @property {number} - How far the camera sits from the target */
|
|
3510
|
+
this.distance = distance;
|
|
3511
|
+
/** @property {number} - Angle above the horizon */
|
|
3512
|
+
this.pitch = pitch;
|
|
3513
|
+
/** @property {number} - Turned each frame while not dragging */
|
|
3514
|
+
this.idleSpin = idleSpin;
|
|
3515
|
+
/** @property {number} - Angle around the target, dragging changes it */
|
|
3516
|
+
this.yaw = 0;
|
|
3517
|
+
/** @property {number} - Mouse button that turns the camera, 0 is left and 2 is right */
|
|
3518
|
+
this.dragButton = 0;
|
|
3519
|
+
/** @property {number} - How far dragging a pixel turns the camera */
|
|
3520
|
+
this.dragSpeed = .01;
|
|
3521
|
+
/** @property {number} - How much one wheel notch zooms, 0 turns zooming off */
|
|
3522
|
+
this.zoomSpeed = .1;
|
|
3523
|
+
/** @property {Vector2} - Closest and furthest the wheel can zoom to */
|
|
3524
|
+
this.zoomRange = vec2(distance/4, distance*3);
|
|
3525
|
+
/** @property {Vector2} - Lowest and highest pitch, so it cannot tip over the top */
|
|
3526
|
+
this.pitchRange = vec2(-.2, 1.4);
|
|
3527
|
+
}
|
|
3528
|
+
|
|
3529
|
+
/** Read the mouse and put the camera on its orbit, called automatically each frame */
|
|
3530
|
+
update()
|
|
3531
|
+
{
|
|
3532
|
+
if (mouseIsDown(this.dragButton))
|
|
3533
|
+
{
|
|
3534
|
+
// the scene follows the drag
|
|
3535
|
+
this.yaw -= mouseDeltaScreen.x * this.dragSpeed;
|
|
3536
|
+
this.pitch += mouseDeltaScreen.y * this.dragSpeed;
|
|
3537
|
+
}
|
|
3538
|
+
else
|
|
3539
|
+
this.yaw += this.idleSpin;
|
|
3540
|
+
this.pitch = clamp(this.pitch, this.pitchRange.x, this.pitchRange.y);
|
|
3541
|
+
if (this.zoomSpeed && mouseWheel)
|
|
3542
|
+
this.distance = clamp(this.distance * (1 + sign(mouseWheel) * this.zoomSpeed), this.zoomRange.x, this.zoomRange.y);
|
|
3543
|
+
render3D.camera.orbit(this.getWorldPos3D(), this.distance, this.yaw, this.pitch);
|
|
3544
|
+
}
|
|
3545
|
+
|
|
3546
|
+
/** Camera controls draw nothing */
|
|
3547
|
+
render3D() {}
|
|
3548
|
+
}
|
|
3549
|
+
|
|
3550
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3551
|
+
/**
|
|
3552
|
+
* FirstPersonCamera3D - Look around with the mouse and move with the keys, with the camera at its position
|
|
3553
|
+
* - Click to capture the mouse so looking needs no button held, Esc lets it go; holding the button looks too, for touch
|
|
3554
|
+
* - WASD or the arrow keys walk level, or move the way it looks when fly is set
|
|
3555
|
+
* - An EngineObject3D that moves by velocity3D, so give it a size3D and call setCollision to walk into solid
|
|
3556
|
+
* objects instead of through them; walking keeps velocity3D.y, so render3D.gravity can pull it down
|
|
3557
|
+
* - Starts from wherever render3D.camera is, so it can take over from another camera without a jump
|
|
3558
|
+
* - Destroy it to hand the camera back
|
|
3559
|
+
* @extends EngineObject3D
|
|
3560
|
+
* @memberof Render3D
|
|
3561
|
+
* @example
|
|
3562
|
+
* const player = new FirstPersonCamera3D(vec3(0, 1.5, 5));
|
|
3563
|
+
* player.size3D = vec3(1); // bump into solid objects
|
|
3564
|
+
* player.collideAsSphere3D = true;
|
|
3565
|
+
* player.setCollision();
|
|
3566
|
+
*/
|
|
3567
|
+
class FirstPersonCamera3D extends EngineObject3D
|
|
3568
|
+
{
|
|
3569
|
+
/** Create a first person camera, it drives render3D.camera every frame
|
|
3570
|
+
* @param {Vector3} [pos3D] - Where the eye is, defaults to where the camera is now
|
|
3571
|
+
* @param {number} [yaw] - Radians around Y, defaults to the camera's
|
|
3572
|
+
* @param {number} [pitch] - Radians up from level, defaults to the camera's */
|
|
3573
|
+
constructor(pos3D=render3D.camera.pos, yaw=render3D.camera.rotation.y, pitch=render3D.camera.rotation.x)
|
|
3574
|
+
{
|
|
3575
|
+
super(pos3D);
|
|
3576
|
+
this.size3D = vec3(); // not a solid thing to pick or collect until it is given a size
|
|
3577
|
+
this.mass = 1; // so solids push it out, and render3D.gravity pulls on it
|
|
3578
|
+
/** @property {number} - Angle around Y, the mouse turns it */
|
|
3579
|
+
this.yaw = yaw;
|
|
3580
|
+
/** @property {number} - Angle up from level, the mouse tilts it */
|
|
3581
|
+
this.pitch = pitch;
|
|
3582
|
+
/** @property {number} - World units per frame at full speed */
|
|
3583
|
+
this.moveSpeed = .1;
|
|
3584
|
+
/** @property {number} - How far a pixel of mouse movement turns the view */
|
|
3585
|
+
this.lookSpeed = .003;
|
|
3586
|
+
/** @property {Vector2} - Lowest and highest pitch */
|
|
3587
|
+
this.pitchRange = vec2(-1.5, 1.5);
|
|
3588
|
+
/** @property {boolean} - Move the way it looks, up and down included, instead of walking level */
|
|
3589
|
+
this.fly = false;
|
|
3590
|
+
/** @property {boolean} - Capture the mouse on a click, so looking needs no button held */
|
|
3591
|
+
this.lockPointer = true;
|
|
3592
|
+
}
|
|
3593
|
+
|
|
3594
|
+
/** Read the mouse and keys and put the camera at the eye, called automatically each frame */
|
|
3595
|
+
update()
|
|
3596
|
+
{
|
|
3597
|
+
// a click captures the mouse, then it looks around while captured or while a button is held
|
|
3598
|
+
if (this.lockPointer && mouseWasPressed(0))
|
|
3599
|
+
pointerLockRequest();
|
|
3600
|
+
if (pointerLockIsActive() || mouseIsDown(0))
|
|
3601
|
+
{
|
|
3602
|
+
this.yaw -= mouseDeltaScreen.x * this.lookSpeed;
|
|
3603
|
+
this.pitch -= mouseDeltaScreen.y * this.lookSpeed;
|
|
3604
|
+
}
|
|
3605
|
+
this.pitch = clamp(this.pitch, this.pitchRange.x, this.pitchRange.y);
|
|
3606
|
+
|
|
3607
|
+
// the keys move it level, or the way it looks when flying, and walking keeps its fall
|
|
3608
|
+
const input = keyDirection();
|
|
3609
|
+
const move = vec3(input.x, 0, -input.y).clampLength(1).scale(this.moveSpeed)
|
|
3610
|
+
.rotateX(this.fly ? this.pitch : 0).rotateY(this.yaw);
|
|
3611
|
+
this.velocity3D = this.fly ? move : vec3(move.x, this.velocity3D.y, move.z);
|
|
3612
|
+
|
|
3613
|
+
// the camera sits at the eye, where this frame's physics left it
|
|
3614
|
+
render3D.camera.pos = this.getWorldPos3D();
|
|
3615
|
+
render3D.camera.rotation = vec3(this.pitch, this.yaw, 0);
|
|
3616
|
+
}
|
|
3617
|
+
|
|
3618
|
+
/** Let go of the mouse and stop driving the camera
|
|
3619
|
+
* @param {boolean} [immediate] */
|
|
3620
|
+
destroy(immediate)
|
|
3621
|
+
{
|
|
3622
|
+
this.lockPointer && pointerLockIsActive() && pointerLockExit();
|
|
3623
|
+
super.destroy(immediate);
|
|
3624
|
+
}
|
|
3625
|
+
|
|
3626
|
+
/** Camera controls draw nothing */
|
|
3627
|
+
render3D() {}
|
|
3628
|
+
}
|
|
3629
|
+
|
|
3630
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3631
|
+
/**
|
|
3632
|
+
* ParticleEmitter3D - Spawns camera facing particles, the 3D twin of ParticleEmitter
|
|
3633
|
+
* - Each particle is a flat square facing the camera, with a soft round dot when no tile is given
|
|
3634
|
+
* - Set trailTime to draw each particle as a streak along where it has been, for sparks
|
|
3635
|
+
* - Set angleSpeed to tumble them in the camera plane, which the 2D emitter takes as an argument
|
|
3636
|
+
* - Particles shoot out along the emitter's own up axis, turned by rotation3D
|
|
3637
|
+
* - emitConeAngle spreads them, PI sprays in every direction
|
|
3638
|
+
* - Speeds are per frame and sizes are world units, the same as the 2D emitter
|
|
3639
|
+
* - scale3D, its own or a parent's, grows the whole effect: the spawn area, the sizes, the speed and the fall
|
|
3640
|
+
* - gravity here is its own number added to velocity y each frame: it is neither the engine's 2D
|
|
3641
|
+
* gravity nor render3D.gravity, so an effect keeps its own fall wherever it is used
|
|
3642
|
+
* - An emitter with an emitTime destroys itself once its last particle is gone, like the 2D emitter
|
|
3643
|
+
* @extends EngineObject3D
|
|
3644
|
+
* @memberof Render3D
|
|
3645
|
+
* @example
|
|
3646
|
+
* // fire: a stream upward, yellow fading to transparent red, additive
|
|
3647
|
+
* 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);
|
|
3648
|
+
*/
|
|
3649
|
+
class ParticleEmitter3D extends EngineObject3D
|
|
3650
|
+
{
|
|
3651
|
+
/** Create a particle emitter
|
|
3652
|
+
* @param {Vector3} [pos3D] - World space position of the emitter
|
|
3653
|
+
* @param {number|Vector3} [emitSize] - Spawn area, a number for a sphere diameter or a vec3 for a box
|
|
3654
|
+
* @param {number} [emitTime] - How long to keep emitting, 0 is forever
|
|
3655
|
+
* @param {number} [emitRate] - Particles per second, 0 does not emit
|
|
3656
|
+
* @param {number} [emitConeAngle] - Half angle around the emit direction, PI is every direction
|
|
3657
|
+
* @param {TileInfo|TextureInfo} [tileInfo] - Tile to render particles with, or a whole texture, undefined is untextured
|
|
3658
|
+
* @param {Color} [colorStartA] - Color at start of life, randomized between the start colors
|
|
3659
|
+
* @param {Color} [colorStartB]
|
|
3660
|
+
* @param {Color} [colorEndA] - Color at end of life, randomized between the end colors
|
|
3661
|
+
* @param {Color} [colorEndB]
|
|
3662
|
+
* @param {number} [particleTime] - How long particles live in seconds
|
|
3663
|
+
* @param {number} [sizeStart] - Particle size at start of life
|
|
3664
|
+
* @param {number} [sizeEnd] - Particle size at end of life
|
|
3665
|
+
* @param {number} [speed] - Spawn speed in world units per frame
|
|
3666
|
+
* @param {number} [damping] - Per frame velocity multiplier, 1 is none
|
|
3667
|
+
* @param {number} [gravity] - Per frame change to velocity y, negative pulls down; its own number,
|
|
3668
|
+
* not render3D.gravity, so the 2D emitter's gravityScale has no equivalent here
|
|
3669
|
+
* @param {number} [fadeRate] - Fraction of life spent fading, half in and half out
|
|
3670
|
+
* @param {number} [randomness] - Extra randomness applied to speed, size and life
|
|
3671
|
+
* @param {boolean} [additive] - Additive blending */
|
|
3672
|
+
constructor(pos3D=vec3(), emitSize=0, emitTime=0, emitRate=100, emitConeAngle=PI, tileInfo,
|
|
3673
|
+
colorStartA=WHITE, colorStartB=WHITE, colorEndA=CLEAR_WHITE, colorEndB=CLEAR_WHITE,
|
|
3674
|
+
particleTime=.5, sizeStart=.1, sizeEnd=1, speed=.1, damping=1, gravity=0, fadeRate=.1, randomness=.2, additive=false)
|
|
3675
|
+
{
|
|
3676
|
+
super(pos3D, undefined, tileInfo);
|
|
3677
|
+
this.transparent = true;
|
|
3678
|
+
this.castShadow = false;
|
|
3679
|
+
this.size3D = vec3(); // not a solid thing to pick or collect
|
|
3680
|
+
|
|
3681
|
+
/** @property {number|Vector3} - Spawn area, a number for a sphere diameter or a vec3 for a box */
|
|
3682
|
+
this.emitSize = emitSize;
|
|
3683
|
+
/** @property {number} - How long to keep emitting, 0 is forever */
|
|
3684
|
+
this.emitTime = emitTime;
|
|
3685
|
+
/** @property {number} - Particles per second, 0 does not emit */
|
|
3686
|
+
this.emitRate = emitRate;
|
|
3687
|
+
/** @property {number} - Half angle around the emit direction, PI is every direction */
|
|
3688
|
+
this.emitConeAngle = emitConeAngle;
|
|
3689
|
+
/** @property {Color} - Color at start of life, randomized between the start colors */
|
|
3690
|
+
this.colorStartA = colorStartA.copy();
|
|
3691
|
+
/** @property {Color} - Color at start of life, randomized between the start colors */
|
|
3692
|
+
this.colorStartB = colorStartB.copy();
|
|
3693
|
+
/** @property {Color} - Color at end of life, randomized between the end colors */
|
|
3694
|
+
this.colorEndA = colorEndA.copy();
|
|
3695
|
+
/** @property {Color} - Color at end of life, randomized between the end colors */
|
|
3696
|
+
this.colorEndB = colorEndB.copy();
|
|
3697
|
+
/** @property {number} - How long particles live in seconds */
|
|
3698
|
+
this.particleTime = particleTime;
|
|
3699
|
+
/** @property {number} - Particle size at start of life */
|
|
3700
|
+
this.sizeStart = sizeStart;
|
|
3701
|
+
/** @property {number} - Particle size at end of life */
|
|
3702
|
+
this.sizeEnd = sizeEnd;
|
|
3703
|
+
/** @property {number} - Spawn speed in world units per frame */
|
|
3704
|
+
this.speed = speed;
|
|
3705
|
+
/** @property {number} - Per frame velocity multiplier */
|
|
3706
|
+
this.damping = damping;
|
|
3707
|
+
/** @property {number} - Per frame change to velocity y, its own number and not render3D.gravity */
|
|
3708
|
+
this.gravity = gravity;
|
|
3709
|
+
/** @property {number} - Fraction of life spent fading, half in and half out */
|
|
3710
|
+
this.fadeRate = fadeRate;
|
|
3711
|
+
/** @property {number} - Extra randomness applied to speed, size and life */
|
|
3712
|
+
this.randomness = randomness;
|
|
3713
|
+
/** @property {boolean} - Additive blending */
|
|
3714
|
+
this.additive = additive;
|
|
3715
|
+
/** @property {number} - Seconds of each particle's path to draw as a ribbon behind it, 0 draws billboards */
|
|
3716
|
+
this.trailTime = 0;
|
|
3717
|
+
/** @property {number} - Radians per frame each particle turns in the camera plane, either way; 0 is no spin */
|
|
3718
|
+
this.angleSpeed = 0;
|
|
3719
|
+
/** @property {number} - Per frame multiplier on that spin, 1 keeps it */
|
|
3720
|
+
this.angleDamping = 1;
|
|
3721
|
+
/** @property {Array<Object>} - Live particles
|
|
3722
|
+
* @type {Array<Object>} */
|
|
3723
|
+
this.particles = [];
|
|
3724
|
+
this.emitTimeBuffer = 0;
|
|
3725
|
+
}
|
|
3726
|
+
|
|
3727
|
+
/** Spawn new particles, move the live ones, and go away when done */
|
|
3728
|
+
update()
|
|
3729
|
+
{
|
|
3730
|
+
// one transform for the frame: where the emitter is, and how big the effect it makes is
|
|
3731
|
+
const matrix = this.getMatrix();
|
|
3732
|
+
this.worldPos3D = matrix.getTranslation(); // remembered for when the parent is destroyed
|
|
3733
|
+
const scale = render3DMaxScale(matrix.m);
|
|
3734
|
+
|
|
3735
|
+
// emit until the emit time is up, then wait for the last particle and go away
|
|
3736
|
+
if (!this.emitTime || this.getAliveTime() <= this.emitTime)
|
|
3737
|
+
{
|
|
3738
|
+
// a rate of zero is an emitter fed by hand, and the global scale only quiets it,
|
|
3739
|
+
// neither is a reason to stop counting down the emit time
|
|
3740
|
+
if (this.emitRate && particleEmitRateScale)
|
|
3741
|
+
{
|
|
3742
|
+
this.emitTimeBuffer += this.emitRate * particleEmitRateScale * timeDelta;
|
|
3743
|
+
for (; this.emitTimeBuffer >= 1; --this.emitTimeBuffer)
|
|
3744
|
+
this.emitParticle();
|
|
3745
|
+
}
|
|
3746
|
+
}
|
|
3747
|
+
else if (!this.particles.length)
|
|
3748
|
+
this.destroy();
|
|
3749
|
+
|
|
3750
|
+
// move the particles and drop the dead ones
|
|
3751
|
+
const particles = this.particles;
|
|
3752
|
+
for (let i = particles.length; i--;)
|
|
3753
|
+
{
|
|
3754
|
+
// damped first and gravity added after, the order the 2D particle uses, so the same
|
|
3755
|
+
// damping and gravity give the same arc in both
|
|
3756
|
+
const p = particles[i], v = p.velocity;
|
|
3757
|
+
v.x *= this.damping, v.y *= this.damping, v.z *= this.damping; // in place, this runs per particle
|
|
3758
|
+
v.y += this.gravity * scale; // a bigger effect has to fall faster to keep the same arc
|
|
3759
|
+
p.pos = p.pos.add(v);
|
|
3760
|
+
p.angle += p.angleVelocity *= this.angleDamping;
|
|
3761
|
+
if (this.trailTime)
|
|
3762
|
+
{
|
|
3763
|
+
// remember where it has been, oldest first
|
|
3764
|
+
const trail = p.trail || (p.trail = []);
|
|
3765
|
+
trail.push(p.pos);
|
|
3766
|
+
const extra = trail.length - this.trailTime / timeDelta;
|
|
3767
|
+
extra > 0 && trail.splice(0, extra);
|
|
3768
|
+
}
|
|
3769
|
+
if ((p.age += timeDelta) >= p.life)
|
|
3770
|
+
particles[i] = particles[particles.length - 1], particles.pop(); // swap with the last, order does not matter
|
|
3771
|
+
}
|
|
3772
|
+
}
|
|
3773
|
+
|
|
3774
|
+
/** Stop emitting, and go away once the particles already out have finished like the 2D emitter's do
|
|
3775
|
+
* @param {boolean} [immediate] */
|
|
3776
|
+
destroy(immediate)
|
|
3777
|
+
{
|
|
3778
|
+
if (immediate || !this.particles.length || this.destroyed)
|
|
3779
|
+
return super.destroy(immediate);
|
|
3780
|
+
this.emitTime = -1; // stops emitting, and update destroys it once the particles are gone
|
|
3781
|
+
render3DDetach(this); // the particles are in world space, they no longer need the parent
|
|
3782
|
+
}
|
|
3783
|
+
|
|
3784
|
+
/** Spawn one particle now */
|
|
3785
|
+
emitParticle()
|
|
3786
|
+
{
|
|
3787
|
+
const random = ()=> rand(1 - this.randomness, 1 + this.randomness);
|
|
3788
|
+
const matrix = this.getMatrix();
|
|
3789
|
+
// the whole effect grows with the emitter, not just the area the particles start in
|
|
3790
|
+
const scale = render3DMaxScale(matrix.m);
|
|
3791
|
+
|
|
3792
|
+
// spawn offset: inside a box or a sphere
|
|
3793
|
+
const size = this.emitSize;
|
|
3794
|
+
const offset = isVector3(size) ? vec3(rand(-.5, .5) * size.x, rand(-.5, .5) * size.y, rand(-.5, .5) * size.z)
|
|
3795
|
+
: randInSphere(size / 2);
|
|
3796
|
+
|
|
3797
|
+
// direction inside the cone around local +Y
|
|
3798
|
+
const direction = matrix.transformDirection(randVector3(1, this.emitConeAngle)).normalize();
|
|
3799
|
+
|
|
3800
|
+
this.particles.push({
|
|
3801
|
+
pos: matrix.transformPoint(offset),
|
|
3802
|
+
velocity: direction.scale(this.speed * random() * scale),
|
|
3803
|
+
colorStart: randColor(this.colorStartA, this.colorStartB, true),
|
|
3804
|
+
colorEnd: randColor(this.colorEndA, this.colorEndB, true),
|
|
3805
|
+
sizeStart: this.sizeStart * random() * scale,
|
|
3806
|
+
sizeEnd: this.sizeEnd * random() * scale,
|
|
3807
|
+
life: this.particleTime * random(),
|
|
3808
|
+
// a spinning particle starts anywhere and turns either way, one that is not stays at zero
|
|
3809
|
+
angle: this.angleSpeed ? rand(2*PI) : 0,
|
|
3810
|
+
angleVelocity: this.angleSpeed ? this.angleSpeed * random() * randSign() : 0,
|
|
3811
|
+
age: 0 });
|
|
3812
|
+
}
|
|
3813
|
+
|
|
3814
|
+
/** Draw the particles, as flat squares or as streaks when trailTime is set
|
|
3815
|
+
* - The whole emitter sorts as one thing, its particles are not sorted against each other */
|
|
3816
|
+
render3D()
|
|
3817
|
+
{
|
|
3818
|
+
if (render3D.transparentQueue)
|
|
3819
|
+
return render3D.queueTransparent(this.getWorldPos3D(), ()=> this.render3D());
|
|
3820
|
+
const fade = this.fadeRate / 2, texture = this.tileInfo || render3DSoftDot(); // no dot headless
|
|
3821
|
+
for (const p of this.particles)
|
|
3822
|
+
{
|
|
3823
|
+
const t = p.age / p.life;
|
|
3824
|
+
const alpha = t < fade ? t / fade : t > 1 - fade ? (1 - t) / fade : 1;
|
|
3825
|
+
const color = p.colorStart.lerp(p.colorEnd, t), size = lerp(p.sizeStart, p.sizeEnd, t);
|
|
3826
|
+
color.a *= alpha;
|
|
3827
|
+
const trail = p.trail;
|
|
3828
|
+
if (trail && trail.length > 1)
|
|
3829
|
+
{
|
|
3830
|
+
// a ribbon from the tail to the head, the tail thins and fades out
|
|
3831
|
+
const widths = [], colors = [];
|
|
3832
|
+
for (let i = 0; i < trail.length; ++i)
|
|
3833
|
+
{
|
|
3834
|
+
const s = (i + 1) / trail.length;
|
|
3835
|
+
widths.push(size * s);
|
|
3836
|
+
colors.push(color.scale(1, s));
|
|
3837
|
+
}
|
|
3838
|
+
render3D.drawRibbon(trail, widths, this.tileInfo, colors);
|
|
3839
|
+
}
|
|
3840
|
+
else if (texture)
|
|
3841
|
+
render3D.drawBillboard(p.pos, vec2(size), texture, color, p.angle);
|
|
3842
|
+
else
|
|
3843
|
+
render3D.drawSoftDisc(p.pos, size, color, undefined, 8); // no canvas for the dot, headless
|
|
3844
|
+
}
|
|
3845
|
+
}
|
|
3846
|
+
}
|
|
3847
|
+
|
|
3848
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3849
|
+
/**
|
|
3850
|
+
* Trail3D - A ribbon through where the object has been, thinning and fading with age
|
|
3851
|
+
* - Records its world position each frame it moves, so parent it to something that moves or set pos3D yourself
|
|
3852
|
+
* - The samples are world space, so width is a world width and scale3D does nothing to the ribbon
|
|
3853
|
+
* - Drawn unlit in the transparent stage, dies down on its own once the object stops
|
|
3854
|
+
* @extends EngineObject3D
|
|
3855
|
+
* @memberof Render3D
|
|
3856
|
+
* @example
|
|
3857
|
+
* const trail = new Trail3D(vec3(), 1, .3, undefined, hsl(.08, 1, .5), hsl(0, 1, .5, 0), true);
|
|
3858
|
+
* ball.addChild(trail); // follows the ball
|
|
3859
|
+
*/
|
|
3860
|
+
class Trail3D extends EngineObject3D
|
|
3861
|
+
{
|
|
3862
|
+
/** Create a trail
|
|
3863
|
+
* @param {Vector3} [pos3D]
|
|
3864
|
+
* @param {number} [lifeTime] - Seconds the ribbon takes to thin and fade from head to tail,
|
|
3865
|
+
* Infinity keeps every sample at full width and never drops one, so it grows as long as the object moves
|
|
3866
|
+
* @param {number} [width] - Width at the head, it thins to nothing at the tail
|
|
3867
|
+
* @param {TileInfo|TextureInfo} [tileInfo] - Tile or whole texture stretched along the trail, undefined is untextured
|
|
3868
|
+
* @param {Color} [color] - Color at the head
|
|
3869
|
+
* @param {Color} [colorEnd] - Color at the tail
|
|
3870
|
+
* @param {boolean} [additive] - Additive blending */
|
|
3871
|
+
constructor(pos3D=vec3(), lifeTime=1, width=.2, tileInfo, color=WHITE, colorEnd=CLEAR_WHITE, additive=false)
|
|
3872
|
+
{
|
|
3873
|
+
super(pos3D, undefined, tileInfo, color);
|
|
3874
|
+
this.transparent = true;
|
|
3875
|
+
this.additive = additive;
|
|
3876
|
+
this.castShadow = false;
|
|
3877
|
+
this.size3D = vec3(); // not a solid thing to pick or collect
|
|
3878
|
+
this.finishing = false; // set by destroy, the ribbon fades out then goes away
|
|
3879
|
+
|
|
3880
|
+
/** @property {number} - Seconds the ribbon takes to thin and fade from head to tail, Infinity never drops a sample */
|
|
3881
|
+
this.lifeTime = lifeTime;
|
|
3882
|
+
/** @property {number} - Width at the head */
|
|
3883
|
+
this.width = width;
|
|
3884
|
+
/** @property {Color} - Color at the tail */
|
|
3885
|
+
this.colorEnd = colorEnd.copy();
|
|
3886
|
+
/** @property {Vector3|undefined} - Direction across the ribbon, recorded with each sample, undefined faces the camera
|
|
3887
|
+
* @type {Vector3|undefined} */
|
|
3888
|
+
this.side = undefined;
|
|
3889
|
+
/** @property {Array<Object>} - Recorded samples, oldest first
|
|
3890
|
+
* @type {Array<Object>} */
|
|
3891
|
+
this.samples = [];
|
|
3892
|
+
}
|
|
3893
|
+
|
|
3894
|
+
/** Forget the trail so far, for when the object teleports */
|
|
3895
|
+
clear() { this.samples.length = 0; }
|
|
3896
|
+
|
|
3897
|
+
/** Stop recording, and go away once the ribbon has faded
|
|
3898
|
+
* @param {boolean} [immediate] */
|
|
3899
|
+
destroy(immediate)
|
|
3900
|
+
{
|
|
3901
|
+
if (immediate || !this.samples.length || this.destroyed || this.lifeTime == Infinity)
|
|
3902
|
+
return super.destroy(immediate);
|
|
3903
|
+
this.finishing = true;
|
|
3904
|
+
render3DDetach(this); // the samples are in world space, they no longer need the parent
|
|
3905
|
+
}
|
|
3906
|
+
|
|
3907
|
+
/** Record the position when it moved and drop old samples, called automatically each frame */
|
|
3908
|
+
update()
|
|
3909
|
+
{
|
|
3910
|
+
const samples = this.samples;
|
|
3911
|
+
if (!this.finishing)
|
|
3912
|
+
{
|
|
3913
|
+
const pos = this.worldPos3D = this.getWorldPos3D(), last = samples[samples.length - 1];
|
|
3914
|
+
if (!last || pos.distanceSquared(last.pos) > 1e-8)
|
|
3915
|
+
samples.push({pos, side: this.side?.copy(), time});
|
|
3916
|
+
}
|
|
3917
|
+
while (samples.length && time - samples[0].time > this.lifeTime)
|
|
3918
|
+
samples.shift();
|
|
3919
|
+
this.finishing && !samples.length && this.destroy();
|
|
3920
|
+
}
|
|
3921
|
+
|
|
3922
|
+
/** Draw the ribbon */
|
|
3923
|
+
render3D()
|
|
3924
|
+
{
|
|
3925
|
+
const samples = this.samples;
|
|
3926
|
+
if (samples.length < 2) return;
|
|
3927
|
+
const points = [], widths = [], colors = [], sides = this.side ? [] : undefined;
|
|
3928
|
+
for (const s of samples)
|
|
3929
|
+
{
|
|
3930
|
+
const age = clamp((time - s.time) / this.lifeTime);
|
|
3931
|
+
points.push(s.pos);
|
|
3932
|
+
widths.push(this.width * (1 - age));
|
|
3933
|
+
colors.push(this.color.lerp(this.colorEnd, age));
|
|
3934
|
+
sides?.push(s.side);
|
|
3935
|
+
}
|
|
3936
|
+
render3D.drawRibbon(points, widths, this.tileInfo, colors, sides);
|
|
3937
|
+
}
|
|
3938
|
+
}
|
|
3939
|
+
|
|
3940
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3941
|
+
// OBJ meshes
|
|
3942
|
+
|
|
3943
|
+
/**
|
|
3944
|
+
* Parse Wavefront OBJ text into a Mesh
|
|
3945
|
+
* - Reads v, vt, vn and f lines with convex polygons of any size, materials and groups are ignored
|
|
3946
|
+
* - Normals come from the file when every corner of a face has one, otherwise from the face
|
|
3947
|
+
* - Use mesh.center() and mesh.fit(size) to bring a model of unknown units to the origin
|
|
3948
|
+
* - Back faces are skipped like any mesh, set doubleSided for a model with open walls or single sided parts
|
|
3949
|
+
* @param {string} text
|
|
3950
|
+
* @param {boolean} [smooth] - Compute smooth normals when the file has none, defaults to render3D.smoothShading
|
|
3951
|
+
* @return {Mesh}
|
|
3952
|
+
* @memberof Render3D
|
|
3953
|
+
* @example
|
|
3954
|
+
* new EngineObject3D(vec3(), parseOBJ(objText).center().fit(4));
|
|
3955
|
+
*/
|
|
3956
|
+
function parseOBJ(text, smooth=render3D?.smoothShading)
|
|
3957
|
+
{
|
|
3958
|
+
const positions = [], normals = [], uvs = [], mesh = new Mesh;
|
|
3959
|
+
let fileNormals = false;
|
|
3960
|
+
|
|
3961
|
+
// OBJ indices count from 1, and a negative one counts back from the end of the list so far
|
|
3962
|
+
const lookup = (s, list)=> { const i = parseInt(s); return list[i < 0 ? list.length + i : i - 1]; };
|
|
3963
|
+
for (const line of text.split('\n'))
|
|
3964
|
+
{
|
|
3965
|
+
const parts = line.trim().split(/\s+/);
|
|
3966
|
+
switch (parts[0])
|
|
3967
|
+
{
|
|
3968
|
+
case 'v': positions.push(vec3(+parts[1], +parts[2], +parts[3])); break;
|
|
3969
|
+
case 'vn': normals.push(vec3(+parts[1], +parts[2], +parts[3])); break;
|
|
3970
|
+
case 'vt': uvs.push(vec2(+parts[1], 1 - +parts[2])); break; // OBJ v runs up, tiles run down
|
|
3971
|
+
case 'f':
|
|
3972
|
+
{
|
|
3973
|
+
const corners = parts.slice(1).map(c=> c.split('/'));
|
|
3974
|
+
if (corners.length < 3) break;
|
|
3975
|
+
const points = corners.map(c=> lookup(c[0], positions));
|
|
3976
|
+
ASSERT(points.every(isVector3), 'OBJ face uses a vertex index the file does not have', line);
|
|
3977
|
+
const uv = corners.map(c=> c[1] ? lookup(c[1], uvs) : RENDER3D_DEFAULT_UV);
|
|
3978
|
+
const hasNormals = corners.every(c=> c[2]);
|
|
3979
|
+
fileNormals ||= hasNormals;
|
|
3980
|
+
const n = hasNormals ? render3DPolygonStrip(corners.map(c=> lookup(c[2], normals)))
|
|
3981
|
+
: render3DFaceNormal(points[0], points[1], points[2], points[3]);
|
|
3982
|
+
mesh.addStrip(render3DPolygonStrip(points), n, render3DPolygonStrip(uv));
|
|
3983
|
+
}
|
|
3984
|
+
}
|
|
3985
|
+
}
|
|
3986
|
+
if (!fileNormals && smooth)
|
|
3987
|
+
mesh.computeNormals(true);
|
|
3988
|
+
return mesh;
|
|
3989
|
+
}
|
|
3990
|
+
|
|
3991
|
+
/**
|
|
3992
|
+
* Fetch and parse an OBJ file
|
|
3993
|
+
* @param {string} url
|
|
3994
|
+
* @param {boolean} [smooth] - Compute smooth normals when the file has none, defaults to render3D.smoothShading
|
|
3995
|
+
* @return {Promise<Mesh>}
|
|
3996
|
+
* @memberof Render3D
|
|
3997
|
+
* @example
|
|
3998
|
+
* const mesh = await loadOBJ('ship.obj'); // in an async gameInit
|
|
3999
|
+
*/
|
|
4000
|
+
async function loadOBJ(url, smooth=render3D?.smoothShading)
|
|
4001
|
+
{
|
|
4002
|
+
const response = await fetch(url);
|
|
4003
|
+
if (!response.ok)
|
|
4004
|
+
throw new Error('loadOBJ failed: ' + url);
|
|
4005
|
+
return parseOBJ(await response.text(), smooth);
|
|
4006
|
+
}
|