littlejsengine 1.24.0 → 1.24.2
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/CHANGELOG.md +86 -1
- package/EDITOR.md +2 -0
- package/FAQ.md +13 -2
- package/README.md +2 -2
- package/REFERENCE.md +27 -10
- package/dist/littlejs.d.ts +104 -29
- package/dist/littlejs.esm.js +2499 -1979
- package/dist/littlejs.esm.min.js +3 -3
- package/dist/littlejs.js +2496 -1979
- package/dist/littlejs.min.js +2 -2
- package/dist/littlejs.release.js +2438 -1983
- package/package.json +1 -1
- package/plugins/box2d.js +1 -1
- package/plugins/gltf.js +62 -9
- package/plugins/lightSystem.js +1 -1
- package/plugins/math3d.js +1014 -1013
- package/plugins/medalSystem.js +7 -11
- package/plugins/particleEffects.js +2 -2
- package/plugins/pathFinder.js +2 -2
- package/plugins/pluginExport.js +1 -0
- package/plugins/render3d.js +134 -57
- package/plugins/render3dEditor.js +28 -12
- package/plugins/render3dExtras.js +32 -17
- package/plugins/render3dLevel.js +21 -12
- package/plugins/render3dVoxels.js +15 -8
- package/plugins/textureSheet.js +97 -3
- package/plugins/tweakables.js +1 -1
- package/plugins/tweenSystem.js +2 -2
- package/plugins/uiSystem.js +6 -2
- package/src/engine.js +34 -11
- package/src/engineAudio.js +13 -3
- package/src/engineDebug.js +4 -1
- package/src/engineDraw.js +29 -16
- package/src/engineEditor.js +99 -56
- package/src/engineExport.js +2 -0
- package/src/engineInput.js +17 -3
- package/src/engineMath.js +6 -2
- package/src/engineObject.js +675 -675
- package/src/engineParticles.js +1 -1
- package/src/engineSettings.js +21 -1
- package/src/engineTileLayer.js +142 -43
- package/src/engineUtilities.js +20 -8
- package/src/engineWebGL.js +12 -8
package/plugins/medalSystem.js
CHANGED
|
@@ -47,7 +47,6 @@ const medals = {};
|
|
|
47
47
|
|
|
48
48
|
// Engine internal variables not exposed to documentation
|
|
49
49
|
let medalsDisplayQueue = [], medalsSaveName, medalsDisplayTimeLast, medalsRenderAdded;
|
|
50
|
-
let medalsLoadWaiting = false; // medalsInit came before any medal, each one made reads its own unlock
|
|
51
50
|
|
|
52
51
|
///////////////////////////////////////////////////////////////////////////////
|
|
53
52
|
|
|
@@ -72,9 +71,7 @@ function medalsInit(saveName)
|
|
|
72
71
|
// check which local medals are unlocked in the save, and write the catalog back
|
|
73
72
|
function medalsLoad()
|
|
74
73
|
{
|
|
75
|
-
//
|
|
76
|
-
// it keeps waiting from then on, so loading again (a dropped Newgrounds session) does not drop medals still to come
|
|
77
|
-
medalsLoadWaiting ||= !Object.keys(medals).length;
|
|
74
|
+
// the saved unlocks of medals not made yet stay for them, a medal made late or behind a flag keeps its unlock
|
|
78
75
|
if (debugMedals || !medalsSaveName) return;
|
|
79
76
|
const saved = readSaveData(medalsSaveName);
|
|
80
77
|
ASSERT(Object.keys(saved).every(key=> isNumber(+key)),
|
|
@@ -131,7 +128,7 @@ function medalsForEach(callback)
|
|
|
131
128
|
function medalsReset()
|
|
132
129
|
{
|
|
133
130
|
medalsForEach(medal=> medal.isLocal() && (medal.unlocked = false));
|
|
134
|
-
if (
|
|
131
|
+
if (medalsSaveName && !debugMedals)
|
|
135
132
|
{
|
|
136
133
|
// the saved unlocks of medals not made yet are cleared too, they are read when those medals are made
|
|
137
134
|
const saved = readSaveData(medalsSaveName);
|
|
@@ -147,11 +144,10 @@ function medalsReset()
|
|
|
147
144
|
function medalsSave()
|
|
148
145
|
{
|
|
149
146
|
if (debugMedals || !medalsSaveName) return;
|
|
150
|
-
//
|
|
147
|
+
// every saved entry stays: a medal not made this time keeps its unlock for when it is, since a player given a
|
|
148
|
+
// medal back is better than one who lost it, and what is not a medal is the game's own, under the same name
|
|
151
149
|
const saved = readSaveData(medalsSaveName);
|
|
152
|
-
const data =
|
|
153
|
-
for (const key in saved) // what is not a medal is the game's own, saved under the same name, and stays
|
|
154
|
-
isNumber(+key) || (data[key] = saved[key]);
|
|
150
|
+
const data = {...saved};
|
|
155
151
|
medalsForEach(medal=> {
|
|
156
152
|
if (!medal.isLocal())
|
|
157
153
|
{
|
|
@@ -220,9 +216,9 @@ class Medal
|
|
|
220
216
|
if (src)
|
|
221
217
|
(this.image = new Image).src = src;
|
|
222
218
|
|
|
223
|
-
// add this to list of medals, unlocked if the save says so when medalsInit came
|
|
219
|
+
// add this to list of medals, unlocked if the save says so when medalsInit came first
|
|
224
220
|
medals[id] = this;
|
|
225
|
-
if (
|
|
221
|
+
if (medalsSaveName && !debugMedals && this.isLocal())
|
|
226
222
|
this.unlocked = !!readSaveData(medalsSaveName)[id]?.unlocked;
|
|
227
223
|
}
|
|
228
224
|
|
|
@@ -160,7 +160,7 @@ function particleEffectSanitize(raw)
|
|
|
160
160
|
{
|
|
161
161
|
// one line of text, line breaks and control characters made spaces
|
|
162
162
|
const text = typeof raw?.name === 'string' ? raw.name.replace(/[\x00-\x1f\x7f]+/g, ' ').trim() : '';
|
|
163
|
-
const name = text ? text.slice(0, 60) : 'Effect';
|
|
163
|
+
const name = text ? text.slice(0, 60).trim() : 'Effect'; // no space left where it was cut
|
|
164
164
|
const input = raw?.settings && typeof raw.settings === 'object' ? {...raw.settings} : {};
|
|
165
165
|
input.collideLevel ??= input.collideTiles; // its name before 1.20
|
|
166
166
|
// a library saved before shapes names its tile and no shape, it keeps its tile
|
|
@@ -301,7 +301,7 @@ function particleEffectShapeTile(name)
|
|
|
301
301
|
{
|
|
302
302
|
if (!particleEffectShapeTiles)
|
|
303
303
|
{
|
|
304
|
-
if (headlessMode || !glContext ||
|
|
304
|
+
if (headlessMode || !glContext || !canvasAvailable()) return;
|
|
305
305
|
// drawn at 4 times 32 pixel cells, so a big soft shape stays smooth when textures are pixelated
|
|
306
306
|
const cell = 32, r = 15, count = particleEffectShapes.length, res = 4;
|
|
307
307
|
const context = createCanvasContext(cell * count * res, cell * res);
|
package/plugins/pathFinder.js
CHANGED
|
@@ -83,8 +83,8 @@ class PathFinderNode
|
|
|
83
83
|
* const path = pf.findPath(player.pos, mousePos);
|
|
84
84
|
*
|
|
85
85
|
* // Bare grid with custom walkability:
|
|
86
|
-
* const
|
|
87
|
-
*
|
|
86
|
+
* const gridFinder = new PathFinder(vec2(50, 50));
|
|
87
|
+
* gridFinder.isWalkable = (x, y) => myGrid[y*50 + x] === 0;
|
|
88
88
|
*/
|
|
89
89
|
class PathFinder
|
|
90
90
|
{
|
package/plugins/pluginExport.js
CHANGED
package/plugins/render3d.js
CHANGED
|
@@ -637,6 +637,7 @@ class Render3DPlugin
|
|
|
637
637
|
this.viewProjection = new Matrix4;
|
|
638
638
|
/** @property {Matrix4} - This frame's light view projection for the shadow map */
|
|
639
639
|
this.shadowMatrix = new Matrix4;
|
|
640
|
+
this.gelAxes = [1, 0, 0, 1]; // the shadow light's right and up in its map's, for its gel
|
|
640
641
|
/** @property {Vector3} - Camera right axis this frame */
|
|
641
642
|
this.cameraRight = vec3(1, 0, 0);
|
|
642
643
|
/** @property {Vector3} - Camera up axis this frame */
|
|
@@ -1240,8 +1241,14 @@ class Render3DPlugin
|
|
|
1240
1241
|
// the near plane stays in front of the far one however small the light
|
|
1241
1242
|
const far = caster.radius, near = min(max(far / 500, .02), far / 2);
|
|
1242
1243
|
const fov = min(caster.coneAngle, RENDER3D_SHADOW_CONE_MAX) * 2 + .1;
|
|
1243
|
-
|
|
1244
|
-
|
|
1244
|
+
const view = Matrix4.lookAt(pos, pos.add(forward), up);
|
|
1245
|
+
this.shadowMatrix = Matrix4.perspective(fov, 1, near, far).multiply(view.copy().invert());
|
|
1246
|
+
// the map's own right and up are not the light's, which a gel is upright by: the four numbers that
|
|
1247
|
+
// turn the one into the other
|
|
1248
|
+
const v = view.m, l = render3DObjectMatrix(caster).m;
|
|
1249
|
+
const right = vec3(l[0], l[1], l[2]).normalize(), lightUp = vec3(l[4], l[5], l[6]).normalize();
|
|
1250
|
+
const mapRight = vec3(v[0], v[1], v[2]), mapUp = vec3(v[4], v[5], v[6]);
|
|
1251
|
+
this.gelAxes = [right.dot(mapRight), right.dot(mapUp), lightUp.dot(mapRight), lightUp.dot(mapUp)];
|
|
1245
1252
|
this.shadowPlanes = render3DFrustumPlanes(this.shadowMatrix);
|
|
1246
1253
|
// depth is not even with perspective: the lookup divides this by the distance squared, which makes
|
|
1247
1254
|
// the bias the same distance in the world near the light and far from it
|
|
@@ -1702,8 +1709,8 @@ function render3DFragmentSource(fragmentCode)
|
|
|
1702
1709
|
'uniform vec4 extraLightCones[' + RENDER3D_MAX_LIGHTS + '];' +
|
|
1703
1710
|
'uniform int extraLightCount;' +
|
|
1704
1711
|
'uniform vec3 cameraPos;' +
|
|
1705
|
-
'uniform vec4 materialParams,emissiveTint,skyTop,skyHorizon,skyBottom;' +
|
|
1706
|
-
'uniform sampler2D tex,normalTex,emissiveTex;' +
|
|
1712
|
+
'uniform vec4 materialParams,emissiveTint,skyTop,skyHorizon,skyBottom,gelAxes;' +
|
|
1713
|
+
'uniform sampler2D tex,normalTex,emissiveTex,gelTex;' +
|
|
1707
1714
|
'uniform bool premultipliedTexture;' + // is the texture a render target, which holds premultiplied color
|
|
1708
1715
|
'uniform highp sampler2DShadow shadowMap;' +
|
|
1709
1716
|
'in vec3 P,N;in vec2 T,L;in vec4 C,S;' +
|
|
@@ -1720,6 +1727,12 @@ function render3DFragmentSource(fragmentCode)
|
|
|
1720
1727
|
'for(int x=-1;x<=1;++x)for(int y=-1;y<=1;++y)' +
|
|
1721
1728
|
's+=texture(shadowMap,vec3(q.xy+vec2(x,y)*shadowParams.z,q.z));' +
|
|
1722
1729
|
'return s/9.;}' +
|
|
1730
|
+
// the gel the shadow light shines through, at this fragment's place in its view, upright as it looks out;
|
|
1731
|
+
// white with no gel, so a light without one is exactly as before
|
|
1732
|
+
'vec3 gel(){' +
|
|
1733
|
+
'if(S.w<=0.)return vec3(1);' +
|
|
1734
|
+
'vec2 g=S.xy/S.w,u=vec2(dot(gelAxes.xy,g),dot(gelAxes.zw,g))*.5+.5;' +
|
|
1735
|
+
'return texture(gelTex,vec2(u.x,1.-u.y)).rgb;}' +
|
|
1723
1736
|
// the normal map's normal here, in the frame that the position and texture coordinate change along across the
|
|
1724
1737
|
// screen, so a mesh needs no tangents; green points up the image and v runs down it, so up is -v;
|
|
1725
1738
|
// a mesh with no texture coordinates has no frame and keeps its own normal
|
|
@@ -1765,7 +1778,7 @@ function render3DFragmentSource(fragmentCode)
|
|
|
1765
1778
|
'vec4 K=extraLightCones[i];' +
|
|
1766
1779
|
'float k=clamp(dot(K.xyz,-v)-K.w,0.,1.);' +
|
|
1767
1780
|
'lc*=k*k*(3.-2.*k);' +
|
|
1768
|
-
'if(i==si)lc*=sh;' +
|
|
1781
|
+
'if(i==si)lc*=sh*gel();' +
|
|
1769
1782
|
'l+=lc*max(0.,ln);' +
|
|
1770
1783
|
'if(lightColor.a>0.)sp+=lc*pow(max(dot(reflect(-v,n),eye),0.),materialParams.y)*step(0.,ln);' +
|
|
1771
1784
|
'}' +
|
|
@@ -1813,6 +1826,8 @@ function render3DUseProgram(program)
|
|
|
1813
1826
|
gl.uniform1i(render3DUniform('shadowMap'), 1);
|
|
1814
1827
|
gl.uniform1i(render3DUniform('normalTex'), 2);
|
|
1815
1828
|
gl.uniform1i(render3DUniform('emissiveTex'), 3);
|
|
1829
|
+
gl.uniform1i(render3DUniform('gelTex'), 4);
|
|
1830
|
+
gl.uniform4fv(render3DUniform('gelAxes'), r.gelAxes);
|
|
1816
1831
|
const c = r.camera.pos;
|
|
1817
1832
|
gl.uniform3f(render3DUniform('cameraPos'), c.x, c.y, c.z);
|
|
1818
1833
|
gl.uniform1i(render3DUniform('extraLightCount'), r.lightCount);
|
|
@@ -2273,8 +2288,16 @@ function render3DRenderPass(after2D)
|
|
|
2273
2288
|
// may have left its own texture, which fails every draw; the shadow map goes back there even with shadows off
|
|
2274
2289
|
gl.activeTexture(gl.TEXTURE1);
|
|
2275
2290
|
gl.bindTexture(gl.TEXTURE_2D, r.shadowTexture || null);
|
|
2276
|
-
// the material maps' units start white, a 2D plugin may have left its own textures there
|
|
2291
|
+
// the material maps' units start white, a 2D plugin may have left its own textures there, and the shadow
|
|
2292
|
+
// light's gel goes on its unit for the pass, white when it has none
|
|
2277
2293
|
render3DSetMapUnits(r.whiteTexture);
|
|
2294
|
+
const gel = render3DTextureOf(render3DShadowCaster()?.gel)?.glTexture;
|
|
2295
|
+
if (gel)
|
|
2296
|
+
{
|
|
2297
|
+
gl.activeTexture(gl.TEXTURE4);
|
|
2298
|
+
gl.bindTexture(gl.TEXTURE_2D, gel);
|
|
2299
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
2300
|
+
}
|
|
2278
2301
|
gl.depthMask(true);
|
|
2279
2302
|
gl.clear(gl.DEPTH_BUFFER_BIT);
|
|
2280
2303
|
|
|
@@ -2313,12 +2336,12 @@ function render3DRenderPass(after2D)
|
|
|
2313
2336
|
}
|
|
2314
2337
|
}
|
|
2315
2338
|
|
|
2316
|
-
// put a texture on
|
|
2317
|
-
// forget what the map cache thought was bound; unit 0 is active after
|
|
2339
|
+
// put a texture on the material map units and the gel's with no sampler, white at the start of the pass and none at
|
|
2340
|
+
// its end, and forget what the map cache thought was bound; unit 0 is active after
|
|
2318
2341
|
function render3DSetMapUnits(texture)
|
|
2319
2342
|
{
|
|
2320
2343
|
const gl = glContext;
|
|
2321
|
-
for (const unit of [2, 3])
|
|
2344
|
+
for (const unit of [2, 3, 4])
|
|
2322
2345
|
{
|
|
2323
2346
|
gl.activeTexture(gl.TEXTURE0 + unit);
|
|
2324
2347
|
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
@@ -2682,17 +2705,63 @@ const render3DMeshBuffers = typeof FinalizationRegistry == 'undefined' ? undefin
|
|
|
2682
2705
|
glContext.deleteBuffer(indexBuffer);
|
|
2683
2706
|
});
|
|
2684
2707
|
|
|
2685
|
-
// the
|
|
2686
|
-
|
|
2708
|
+
// the points at one place, to a hundred thousandth, which smooth normals are summed by: each point's group, numbered
|
|
2709
|
+
// from 0, found by a number made from its place and checked against the place, so no text is made for a point
|
|
2710
|
+
function render3DPlaceGroups(points)
|
|
2711
|
+
{
|
|
2712
|
+
const groups = new Int32Array(points.length), first = new Map, next = [], places = [];
|
|
2713
|
+
for (let i = 0; i < points.length; ++i)
|
|
2714
|
+
{
|
|
2715
|
+
const p = points[i], x = round(p.x * 1e5), y = round(p.y * 1e5), z = round(p.z * 1e5);
|
|
2716
|
+
const hash = Math.imul(x, 73856093) ^ Math.imul(y, 19349663) ^ Math.imul(z, 83492791);
|
|
2717
|
+
let group = first.get(hash);
|
|
2718
|
+
while (group !== undefined && !(places[group*3] === x && places[group*3+1] === y && places[group*3+2] === z))
|
|
2719
|
+
group = next[group];
|
|
2720
|
+
if (group === undefined)
|
|
2721
|
+
{
|
|
2722
|
+
group = next.length;
|
|
2723
|
+
next.push(first.get(hash));
|
|
2724
|
+
first.set(hash, group);
|
|
2725
|
+
places.push(x, y, z);
|
|
2726
|
+
}
|
|
2727
|
+
groups[i] = group;
|
|
2728
|
+
}
|
|
2729
|
+
return {groups, count: next.length};
|
|
2730
|
+
}
|
|
2687
2731
|
|
|
2688
|
-
//
|
|
2689
|
-
// three faces evenly however
|
|
2690
|
-
|
|
2732
|
+
// the face normals meeting at each place added up, each weighted by its corner angle so a cube corner averages its
|
|
2733
|
+
// three faces evenly however they are cut into triangles; of a strip, every other triangle turned back, with no
|
|
2734
|
+
// indices, or of the index list; in numbers, with no vector made for a triangle or a corner
|
|
2735
|
+
function render3DSmoothNormalSums(points, indices)
|
|
2691
2736
|
{
|
|
2692
|
-
const
|
|
2693
|
-
const
|
|
2694
|
-
const
|
|
2695
|
-
|
|
2737
|
+
const strip = !indices, triangles = strip ? max(points.length - 2, 0) : indices.length / 3 | 0;
|
|
2738
|
+
const {groups, count} = render3DPlaceGroups(points);
|
|
2739
|
+
const sums = new Float64Array(count * 3), touched = new Uint8Array(count);
|
|
2740
|
+
let nx = 0, ny = 0, nz = 0;
|
|
2741
|
+
const addCorner = (a, b, c, group)=>
|
|
2742
|
+
{
|
|
2743
|
+
// the angle at corner a, then the face normal scaled by it added to the sum at a's place
|
|
2744
|
+
const ux = b.x - a.x, uy = b.y - a.y, uz = b.z - a.z, vx = c.x - a.x, vy = c.y - a.y, vz = c.z - a.z;
|
|
2745
|
+
const dot = ux*vx + uy*vy + uz*vz, lengths = (ux**2 + uy**2 + uz**2)**.5 * (vx**2 + vy**2 + vz**2)**.5;
|
|
2746
|
+
const angle = Math.acos(clamp(dot / (lengths || 1), -1, 1)), s = group * 3;
|
|
2747
|
+
sums[s] += nx * angle, sums[s+1] += ny * angle, sums[s+2] += nz * angle;
|
|
2748
|
+
touched[group] = 1;
|
|
2749
|
+
};
|
|
2750
|
+
for (let t = 0; t < triangles; ++t)
|
|
2751
|
+
{
|
|
2752
|
+
const i0 = strip ? t : indices[t*3], i1 = strip ? t + 1 : indices[t*3+1], i2 = strip ? t + 2 : indices[t*3+2];
|
|
2753
|
+
const a = points[i0], b = points[i1], c = points[i2];
|
|
2754
|
+
const ux = b.x - a.x, uy = b.y - a.y, uz = b.z - a.z, vx = c.x - a.x, vy = c.y - a.y, vz = c.z - a.z;
|
|
2755
|
+
const cx = uy*vz - uz*vy, cy = uz*vx - ux*vz, cz = ux*vy - uy*vx;
|
|
2756
|
+
const lengthSquared = cx**2 + cy**2 + cz**2;
|
|
2757
|
+
if (!lengthSquared) continue; // no area, like a triangle joining two strips
|
|
2758
|
+
const k = (strip && !(t & 1) ? -1 : 1) / lengthSquared**.5;
|
|
2759
|
+
nx = cx * k, ny = cy * k, nz = cz * k;
|
|
2760
|
+
addCorner(a, b, c, groups[i0]);
|
|
2761
|
+
addCorner(b, c, a, groups[i1]);
|
|
2762
|
+
addCorner(c, a, b, groups[i2]);
|
|
2763
|
+
}
|
|
2764
|
+
return {groups, sums, touched};
|
|
2696
2765
|
}
|
|
2697
2766
|
|
|
2698
2767
|
/**
|
|
@@ -3077,42 +3146,52 @@ class Mesh
|
|
|
3077
3146
|
// the triangles are listed: smooth normals add up around each position, weighted by the corner angle
|
|
3078
3147
|
// like the strip's, so vertices split apart at one place smooth back together and a mesh can go flat and
|
|
3079
3148
|
// smooth again; flat ones need a vertex per corner, so the vertices are split up first
|
|
3080
|
-
if (
|
|
3149
|
+
if (smooth)
|
|
3081
3150
|
{
|
|
3082
|
-
const
|
|
3083
|
-
this.
|
|
3084
|
-
|
|
3085
|
-
|
|
3151
|
+
const {groups, sums, touched} = render3DSmoothNormalSums(this.points, this.indices);
|
|
3152
|
+
this.normals = this.points.map((p, i)=>
|
|
3153
|
+
{
|
|
3154
|
+
const s = groups[i] * 3, x = sums[s], y = sums[s+1], z = sums[s+2];
|
|
3155
|
+
const lengthSquared = x**2 + y**2 + z**2, k = 1 / lengthSquared**.5;
|
|
3156
|
+
return touched[groups[i]] && lengthSquared ? vec3(x * k, y * k, z * k) : RENDER3D_DEFAULT_NORMAL;
|
|
3157
|
+
});
|
|
3158
|
+
this.dirty = true;
|
|
3159
|
+
return this;
|
|
3086
3160
|
}
|
|
3087
|
-
const
|
|
3161
|
+
const split = (a)=> this.indices.map(i=> a[i]);
|
|
3162
|
+
this.points = split(this.points), this.normals = split(this.normals);
|
|
3163
|
+
this.uvs = split(this.uvs), this.colors = split(this.colors);
|
|
3164
|
+
this.indices = this.indices.map((_, i)=> i);
|
|
3165
|
+
const points = this.points, indices = this.indices;
|
|
3088
3166
|
const normals = points.map(()=> RENDER3D_DEFAULT_NORMAL);
|
|
3089
3167
|
for (let t = 0; t < indices.length; t += 3)
|
|
3090
3168
|
{
|
|
3091
3169
|
const a = points[indices[t]], b = points[indices[t+1]], c = points[indices[t+2]];
|
|
3092
3170
|
const cross = b.subtract(a).cross(c.subtract(a));
|
|
3093
|
-
if (
|
|
3094
|
-
|
|
3095
|
-
if (!smooth)
|
|
3096
|
-
{
|
|
3097
|
-
// its own three corners
|
|
3098
|
-
normals[indices[t]] = normals[indices[t+1]] = normals[indices[t+2]] = normal;
|
|
3099
|
-
continue;
|
|
3100
|
-
}
|
|
3101
|
-
for (let j = 0; j < 3; ++j)
|
|
3102
|
-
render3DAddCornerNormal(sums, points[indices[t+j]], points[indices[t+(j+1)%3]],
|
|
3103
|
-
points[indices[t+(j+2)%3]], normal);
|
|
3171
|
+
if (cross.lengthSquared()) // its own three corners
|
|
3172
|
+
normals[indices[t]] = normals[indices[t+1]] = normals[indices[t+2]] = cross.normalize();
|
|
3104
3173
|
}
|
|
3105
|
-
this.normals =
|
|
3174
|
+
this.normals = normals;
|
|
3175
|
+
this.dirty = true;
|
|
3176
|
+
return this;
|
|
3177
|
+
}
|
|
3178
|
+
|
|
3179
|
+
// smooth normals are the sums at each place made unit length, or straight up for a place with none
|
|
3180
|
+
const points = this.points, n = points.length;
|
|
3181
|
+
if (smooth)
|
|
3182
|
+
{
|
|
3183
|
+
const {groups, sums, touched} = render3DSmoothNormalSums(points);
|
|
3184
|
+
this.normals = points.map((p, i)=>
|
|
3106
3185
|
{
|
|
3107
|
-
const s = sums
|
|
3108
|
-
return
|
|
3186
|
+
const s = groups[i] * 3, x = sums[s], y = sums[s+1], z = sums[s+2], l = (x**2 + y**2 + z**2)**.5;
|
|
3187
|
+
return !touched[groups[i]] ? vec3(0, 1, 0) : l ? vec3(x * (1/l), y * (1/l), z * (1/l)) : vec3();
|
|
3109
3188
|
});
|
|
3189
|
+
this.vertexKeys = undefined;
|
|
3110
3190
|
this.dirty = true;
|
|
3111
3191
|
return this;
|
|
3112
3192
|
}
|
|
3113
3193
|
|
|
3114
3194
|
// the outward normal of each triangle in the strip
|
|
3115
|
-
const points = this.points, n = points.length;
|
|
3116
3195
|
const faceNormals = [];
|
|
3117
3196
|
for (let i = 0; i + 2 < n; ++i)
|
|
3118
3197
|
{
|
|
@@ -3123,22 +3202,10 @@ class Mesh
|
|
|
3123
3202
|
faceNormals.push(normal.lengthSquared() ? normal.normalize(i & 1 ? 1 : -1) : undefined);
|
|
3124
3203
|
}
|
|
3125
3204
|
|
|
3126
|
-
// then hand those to the vertices
|
|
3205
|
+
// then hand those to the vertices: every triangle writes its own three corners, so the only vertices left
|
|
3206
|
+
// with the default are the repeats at the ends of a strip, which no triangle with any area uses
|
|
3127
3207
|
const normals = points.map(()=> RENDER3D_DEFAULT_NORMAL);
|
|
3128
|
-
|
|
3129
|
-
{
|
|
3130
|
-
// add up the face normals meeting at each position, each weighted by its corner angle so a cube
|
|
3131
|
-
// corner averages its three faces evenly however the strips cut them, then normalize
|
|
3132
|
-
const sums = new Map;
|
|
3133
|
-
faceNormals.forEach((f, i)=> f && [0, 1, 2].forEach(j=>
|
|
3134
|
-
render3DAddCornerNormal(sums, points[i+j], points[i+(j+1)%3], points[i+(j+2)%3], f)));
|
|
3135
|
-
for (let i = 0; i < n; ++i)
|
|
3136
|
-
normals[i] = (sums.get(render3DPlaceKey(points[i])) || RENDER3D_DEFAULT_NORMAL).normalize();
|
|
3137
|
-
}
|
|
3138
|
-
else
|
|
3139
|
-
// every triangle writes its own three corners, so the only vertices left with the default
|
|
3140
|
-
// are the repeats at the ends of a strip, which no triangle with any area uses
|
|
3141
|
-
faceNormals.forEach((f, i)=> f && (normals[i] = normals[i+1] = normals[i+2] = f));
|
|
3208
|
+
faceNormals.forEach((f, i)=> f && (normals[i] = normals[i+1] = normals[i+2] = f));
|
|
3142
3209
|
|
|
3143
3210
|
this.normals = normals;
|
|
3144
3211
|
this.vertexKeys = undefined; // flat normals tell entries at one place apart
|
|
@@ -4285,6 +4352,7 @@ function engineObjectsCollect3D(pos, size, objects=engineObjects, testCenters=fa
|
|
|
4285
4352
|
|
|
4286
4353
|
// how far along a ray an object is hit, or undefined for a miss; a mesh is hit on its triangles, in its own space,
|
|
4287
4354
|
// and a sprite as the quad it draws
|
|
4355
|
+
const render3DRaycastCenter = vec3();
|
|
4288
4356
|
function render3DRaycastObject(ray, o)
|
|
4289
4357
|
{
|
|
4290
4358
|
if (o.destroyed || !(o instanceof EngineObject3D)) return;
|
|
@@ -4297,7 +4365,8 @@ function render3DRaycastObject(ray, o)
|
|
|
4297
4365
|
// a mesh that changed since it was measured is measured again, an upload may not have come yet
|
|
4298
4366
|
const radius = (mesh.dirty || !mesh.radius ? mesh.computeRadius() : mesh.radius) * render3DMaxStretch(matrix.m);
|
|
4299
4367
|
if (!(radius > 0)) return; // nothing to hit
|
|
4300
|
-
|
|
4368
|
+
// its place read from the matrix into a vector kept for this, a pick tests every object and most miss
|
|
4369
|
+
const m = matrix.m, center = render3DRaycastCenter.set(m[12], m[13], m[14]);
|
|
4301
4370
|
const distance = raycastSphere(ray, center, radius);
|
|
4302
4371
|
if (distance === undefined) return;
|
|
4303
4372
|
|
|
@@ -4766,6 +4835,13 @@ class Light3D extends EngineObject3D
|
|
|
4766
4835
|
/** @property {number} - How fast the glow fades from its middle: 1 by default, .5 a wide haze, 2 a tight
|
|
4767
4836
|
* bright core */
|
|
4768
4837
|
this.glowFalloff = 1;
|
|
4838
|
+
/** @property {TextureInfo|TileInfo|undefined} - A gel: a picture the light shines through, like a stained glass
|
|
4839
|
+
* window or the leaves of a tree, cast along its cone in its colors, upright as the light looks out, the whole
|
|
4840
|
+
* texture of a TileInfo; only the spotlight that casts the shadows has one, with render3D.shadows on and it
|
|
4841
|
+
* as render3D.shadowLight, and only on what takes its shadows: an object with receiveShadow off is lit
|
|
4842
|
+
* without the gel; its alpha is not read, see through panes are dark
|
|
4843
|
+
* @type {TextureInfo|TileInfo|undefined} */
|
|
4844
|
+
this.gel = undefined;
|
|
4769
4845
|
this.additive = true; // the glow is added on, in the transparent stage; a light with none draws nothing
|
|
4770
4846
|
/** @type {LensFlare3D|undefined} */
|
|
4771
4847
|
this.flareObject = undefined;
|
|
@@ -4776,10 +4852,11 @@ class Light3D extends EngineObject3D
|
|
|
4776
4852
|
* @param {number} [count] - How many ghosts there are, besides the glow at the light
|
|
4777
4853
|
* @param {number} [intensity] - How bright it is
|
|
4778
4854
|
* @param {number} [saturation] - How colorful the ghosts are
|
|
4855
|
+
* @param {Color} [color] - Tints the flare, with the light's own color
|
|
4779
4856
|
* @return {LensFlare3D} - The flare, to change: light.addFlare().shapes = ['hex'] */
|
|
4780
|
-
addFlare(size, count, intensity, saturation)
|
|
4857
|
+
addFlare(size, count, intensity, saturation, color)
|
|
4781
4858
|
{
|
|
4782
|
-
const flare = new LensFlare3D(size, count, intensity, saturation);
|
|
4859
|
+
const flare = new LensFlare3D(size, count, intensity, saturation, color);
|
|
4783
4860
|
this.flare = flare;
|
|
4784
4861
|
return flare;
|
|
4785
4862
|
}
|
|
@@ -549,9 +549,11 @@ function editor3DLevelLoaded(level)
|
|
|
549
549
|
level.objects = editor3DCopy(saved.objects);
|
|
550
550
|
for (const name of editor3DLevelPartNames)
|
|
551
551
|
parts[name] ? level[name] = editor3DCopy(parts[name]) : delete level[name];
|
|
552
|
+
saved.stale && console.warn(`LittleJS editor: the edits brought back to ${record.fileName} are older than ` +
|
|
553
|
+
'the last ones, which storage had no room for');
|
|
552
554
|
}
|
|
553
|
-
else if (saved)
|
|
554
|
-
record.pending = saved;
|
|
555
|
+
else if (saved && isArray(saved.objects))
|
|
556
|
+
record.pending = saved; // one that is broken is passed by, and the next autosave writes over it
|
|
555
557
|
}
|
|
556
558
|
|
|
557
559
|
// called by level3DLoad for each object, with what its type made
|
|
@@ -662,17 +664,27 @@ function editor3DSetPart(name, part)
|
|
|
662
664
|
function editor3DPrefabsShow()
|
|
663
665
|
{
|
|
664
666
|
if (editor3DPrefabLevels.has(editor3DLevel)) return; // inside a prefab, the prefabs are the level's
|
|
665
|
-
const prefabs = editor3DLevelPart('prefabs') ?? {};
|
|
667
|
+
const prefabs = editor3DLevelPart('prefabs') ?? {}, removed = new Set;
|
|
666
668
|
for (const [name, prefab] of level3DPrefabs)
|
|
667
669
|
{
|
|
668
670
|
if (!prefab.fromLevel || prefabs[name]) continue;
|
|
669
671
|
level3DPrefabs.delete(name);
|
|
670
672
|
level3DTypes.delete(name);
|
|
673
|
+
removed.add(name);
|
|
671
674
|
}
|
|
672
675
|
level3DPrefabsAdd(prefabs);
|
|
673
|
-
// every object of a prefab's type, one whose type was not there when the objects were set too
|
|
676
|
+
// every object of a prefab's type, one whose type was not there when the objects were set too; an instance of a
|
|
677
|
+
// prefab taken out leaves the game, its object waiting in the level for the prefab to come back
|
|
674
678
|
for (const object of editor3DObjects())
|
|
675
|
-
|
|
679
|
+
{
|
|
680
|
+
if (level3DPrefabs.has(object.type))
|
|
681
|
+
editor3DMakeInstance(object);
|
|
682
|
+
else if (removed.has(object.type))
|
|
683
|
+
{
|
|
684
|
+
editor3DInstances.get(object.id)?.destroy?.();
|
|
685
|
+
editor3DInstances.delete(object.id);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
676
688
|
editor3DShadowLight();
|
|
677
689
|
}
|
|
678
690
|
|
|
@@ -913,7 +925,9 @@ function editor3DTerrainStore()
|
|
|
913
925
|
{
|
|
914
926
|
const map = editor3DTerrainMap(), terrain = editor3DLevelPart('terrain');
|
|
915
927
|
if (!map || !terrain) return;
|
|
916
|
-
|
|
928
|
+
// the heights changed to a thousandth, the rest as the level has them, so a stroke of paint rounds none
|
|
929
|
+
const before = terrain.heights;
|
|
930
|
+
terrain.heights = map.heights.map((row, z)=> row.map((v, x)=> v === before?.[z]?.[x] ? v : round(v * 1e3) / 1e3));
|
|
917
931
|
const paint = editor3DTerrainPaints.get(map);
|
|
918
932
|
if (!paint) return; // nothing painted since the level's was read
|
|
919
933
|
const used = paint.colors.map((hex, i)=> paint.cells.includes(i + 1));
|
|
@@ -1789,10 +1803,12 @@ function editor3DJSON(value)
|
|
|
1789
1803
|
// a level as the file has it: its version, what else it holds, and its objects, one to a line
|
|
1790
1804
|
function editor3DLevelJSON(level=editor3DLevel)
|
|
1791
1805
|
{
|
|
1806
|
+
// the level's parts after the rest, in one order, so the same level is the same file however its parts were made
|
|
1792
1807
|
const {littlejs3D, objects, ...rest} = level;
|
|
1793
1808
|
const lines = [`"littlejs3D": ${LEVEL3D_VERSION}`];
|
|
1794
|
-
|
|
1795
|
-
|
|
1809
|
+
const keys = Object.keys(rest).filter((key)=> !editor3DLevelPartNames.includes(key));
|
|
1810
|
+
for (const key of [...keys, ...editor3DLevelPartNames.filter((name)=> name in rest)])
|
|
1811
|
+
lines.push(JSON.stringify(key) + ': ' + editor3DJSON(rest[key]));
|
|
1796
1812
|
const list = (isArray(objects) ? objects : []).map((o)=> ' ' + editor3DJSON(o));
|
|
1797
1813
|
lines.push('"objects": [' + (list.length ? '\n' + list.join(',\n') + '\n ]' : ']'));
|
|
1798
1814
|
return '{\n ' + lines.join(',\n ') + '\n}\n';
|
|
@@ -2610,7 +2626,7 @@ function editor3DSetSelectedProperty(name, value)
|
|
|
2610
2626
|
{
|
|
2611
2627
|
const selected = editor3DSelected(), id = selected[0]?.id;
|
|
2612
2628
|
const defaults = selected[0] && level3DTypes.get(selected[0].type)?.defaults;
|
|
2613
|
-
const d = defaults && Object.
|
|
2629
|
+
const d = defaults && Object.prototype.hasOwnProperty.call(defaults, name) ? defaults[name] : undefined;
|
|
2614
2630
|
const sameType = isColor(d) ? isColor(value) : isVector3(d) ? isVector3(value) :
|
|
2615
2631
|
isVector2(d) ? isVector2(value) : typeof value === typeof d;
|
|
2616
2632
|
if (selected.length !== 1 || !editor3DPropertyEditable(d) || !sameType) return false;
|
|
@@ -3333,7 +3349,7 @@ function editor3DSceneUpdate(box)
|
|
|
3333
3349
|
input.onchange = ()=>
|
|
3334
3350
|
{
|
|
3335
3351
|
const n = parseFloat(input.value);
|
|
3336
|
-
|
|
3352
|
+
isFinite(n) && n >= 0 ? set(v = n) : input.value = v + ''; // one not taken shows what it has again
|
|
3337
3353
|
editor3DStrokeEnd();
|
|
3338
3354
|
input.blur();
|
|
3339
3355
|
};
|
|
@@ -3422,7 +3438,7 @@ function editor3DPropertiesUpdate(box)
|
|
|
3422
3438
|
input.onchange = ()=>
|
|
3423
3439
|
{
|
|
3424
3440
|
const v = inputs.map((i)=> parseFloat(i.value));
|
|
3425
|
-
if (v.every((n)=>
|
|
3441
|
+
if (v.every((n)=> isFinite(n)))
|
|
3426
3442
|
set(axes.length > 2 ? vec3(v[0], v[1], v[2]) : vec2(v[0], v[1]));
|
|
3427
3443
|
else
|
|
3428
3444
|
inputs.forEach((i, k)=> i.value = editor3DRound(value[axes[k]]) + '');
|
|
@@ -3464,7 +3480,7 @@ function editor3DPropertiesUpdate(box)
|
|
|
3464
3480
|
input.type = 'number';
|
|
3465
3481
|
input.step = 'any';
|
|
3466
3482
|
input.value = value + '';
|
|
3467
|
-
input.onchange = ()=> { const v = parseFloat(input.value);
|
|
3483
|
+
input.onchange = ()=> { const v = parseFloat(input.value); isFinite(v) && set(v); };
|
|
3468
3484
|
}
|
|
3469
3485
|
else if (typeof defaultValue === 'string')
|
|
3470
3486
|
{
|
|
@@ -28,7 +28,7 @@ function render3DDetach(o)
|
|
|
28
28
|
let render3DSoftDotTexture;
|
|
29
29
|
function render3DSoftDot()
|
|
30
30
|
{
|
|
31
|
-
if (render3DSoftDotTexture || !glContext ||
|
|
31
|
+
if (render3DSoftDotTexture || !glContext || !canvasAvailable()) return render3DSoftDotTexture;
|
|
32
32
|
const size = 32, context = createCanvasContext(size);
|
|
33
33
|
const gradient = context.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
|
|
34
34
|
for (const [stop, alpha] of [[0, 1], [.33, .9], [.67, .7], [1, 0]]) // the same falloff as a soft disc
|
|
@@ -50,7 +50,7 @@ function normalMapFromHeight(size, heightFunction, strength=1)
|
|
|
50
50
|
{
|
|
51
51
|
ASSERT(isVector2(size) && size.x >= 1 && size.y >= 1, 'normalMapFromHeight size must be a Vector2 of pixels');
|
|
52
52
|
const width = size.x | 0, height = size.y | 0;
|
|
53
|
-
if (
|
|
53
|
+
if (!canvasAvailable())
|
|
54
54
|
{
|
|
55
55
|
// headless, nothing to draw into; the right size so what reads it still works
|
|
56
56
|
const textureInfo = new TextureInfo(undefined, false, true);
|
|
@@ -1484,7 +1484,7 @@ function render3DFlareTile(shape)
|
|
|
1484
1484
|
{
|
|
1485
1485
|
if (!render3DFlareTiles)
|
|
1486
1486
|
{
|
|
1487
|
-
if (headlessMode || !glContext ||
|
|
1487
|
+
if (headlessMode || !glContext || !canvasAvailable()) return;
|
|
1488
1488
|
const cell = 128, shapes = render3DFlareShapes, context = createCanvasContext(cell * shapes.length, cell);
|
|
1489
1489
|
// each shape is how see-through it is from its middle out
|
|
1490
1490
|
const alpha =
|
|
@@ -1575,7 +1575,8 @@ function render3DFlareTile(shape)
|
|
|
1575
1575
|
* with the sun's own color; shapes says what its ghosts are, glowSize and ghostSize how big its parts are; or
|
|
1576
1576
|
* give it elements of your own, which may be tiles of the game's
|
|
1577
1577
|
* - visible is how much of the sun shows, 0 to 1, eased over fadeTime, there for a game to read; it is 0 while the
|
|
1578
|
-
* sun is off the screen, where nothing
|
|
1578
|
+
* sun is too far off the screen for the flare to show, about a seventh of the screen past its edge, where nothing
|
|
1579
|
+
* is tested
|
|
1579
1580
|
* - What hides the sun is found with a ray from the camera, against the level and every object that is not see
|
|
1580
1581
|
* through, each on the triangles of its mesh, see render3D.pick; turn it off with occlusion
|
|
1581
1582
|
* - It needs WebGL, and it draws nothing in the shadow of renderAfter2D
|
|
@@ -1622,7 +1623,8 @@ class LensFlare3D extends EngineObject3D
|
|
|
1622
1623
|
this.elements = undefined;
|
|
1623
1624
|
/** @property {Light3D|undefined} - A light the flare is of in place of the sun, a lamp or a spotlight: the
|
|
1624
1625
|
* flare is at the light and in its color, smaller from farther than the light reaches, hidden by what is
|
|
1625
|
-
* in front of the light,
|
|
1626
|
+
* in front of the light but its lamp, a mesh around it whose surface is near it, and a spotlight's
|
|
1627
|
+
* shows from inside its beam only; a DirectionalLight3D's is far
|
|
1626
1628
|
* away where it shines from, like the sun's; the flare is destroyed when its light is; light.addFlare
|
|
1627
1629
|
* sets this
|
|
1628
1630
|
* @type {Light3D|undefined} */
|
|
@@ -1695,7 +1697,7 @@ class LensFlare3D extends EngineObject3D
|
|
|
1695
1697
|
const source = this.flareSource(), center = mainCanvasSize.scale(.5);
|
|
1696
1698
|
const sun = source && render3D.worldToScreen(source.pos);
|
|
1697
1699
|
if (!sun || !center.x || !center.y) return;
|
|
1698
|
-
// it fades as the sun leaves the screen, gone
|
|
1700
|
+
// it fades as the sun leaves the screen, gone .3 of half the screen past the edge
|
|
1699
1701
|
const off = max(abs(sun.x - center.x) / center.x, abs(sun.y - center.y) / center.y);
|
|
1700
1702
|
const strength = clamp((1.3 - off) / .5) * this.intensity;
|
|
1701
1703
|
if (!(strength > 0)) return;
|
|
@@ -1761,19 +1763,30 @@ class LensFlare3D extends EngineObject3D
|
|
|
1761
1763
|
for (const map of maps)
|
|
1762
1764
|
if (map.raycast(ray, reach, (type)=> !map.blockType(type).seeThrough))
|
|
1763
1765
|
return true;
|
|
1764
|
-
// a light's lamp,
|
|
1765
|
-
// is
|
|
1766
|
-
|
|
1766
|
+
// a light's lamp, a mesh around the light, does not hide it: one whose box, in its own space, the light is in,
|
|
1767
|
+
// hit close to the light, as a shade or a globe is, and small, its thinnest side no more than twice that; a
|
|
1768
|
+
// room or a whole level holds its lights too, and its walls do hide them from outside, even one close behind
|
|
1769
|
+
const inside = (o)=>
|
|
1767
1770
|
{
|
|
1768
|
-
if (!
|
|
1769
|
-
const matrix = render3DObjectMatrix(o);
|
|
1770
|
-
|
|
1771
|
-
const
|
|
1771
|
+
if (!o.mesh || o instanceof HeightMap) return false;
|
|
1772
|
+
const matrix = render3DObjectMatrix(o), b = o.mesh.bounds || o.mesh.getBounds();
|
|
1773
|
+
const m = matrix.m, scale = (k)=> hypot(m[k], m[k + 1], m[k + 2]); // each axis's own stretch
|
|
1774
|
+
const thinnest = min((b.max.x - b.min.x) * scale(0), (b.max.y - b.min.y) * scale(4), (b.max.z - b.min.z) * scale(8));
|
|
1775
|
+
if (!matrix.determinant() || thinnest > near * 2) return false;
|
|
1776
|
+
const p = matrix.copy().invert().transformPoint(source.pos);
|
|
1772
1777
|
return p.x >= b.min.x && p.x <= b.max.x && p.y >= b.min.y && p.y <= b.max.y &&
|
|
1773
1778
|
p.z >= b.min.z && p.z <= b.max.z;
|
|
1774
1779
|
};
|
|
1775
|
-
const
|
|
1776
|
-
|
|
1780
|
+
const near = this.light && reach < Infinity ? min(2, this.light.radius * .4) : 0;
|
|
1781
|
+
let candidates = blockers.filter((o)=> !maps.some((map)=> map === o));
|
|
1782
|
+
for (let tries = 5; tries--;)
|
|
1783
|
+
{
|
|
1784
|
+
const hit = render3D.pick(ray, candidates);
|
|
1785
|
+
if (!hit || hit.distance >= reach) return false;
|
|
1786
|
+
if (!(reach - hit.distance <= near && inside(hit.object))) return true;
|
|
1787
|
+
candidates = candidates.filter((o)=> o !== hit.object); // its lamp, look past it
|
|
1788
|
+
}
|
|
1789
|
+
return true; // lamps on lamps, take it as hidden
|
|
1777
1790
|
}
|
|
1778
1791
|
|
|
1779
1792
|
/** Ease visible toward whether the sun shows, called automatically each frame; a flare that would not show
|
|
@@ -1834,9 +1847,10 @@ function parseOBJ(text, smooth=render3D?.smoothShading)
|
|
|
1834
1847
|
// OBJ indices count from 1, and a negative one counts back from the end of the list so far
|
|
1835
1848
|
const index = (s, list)=> { const i = parseInt(s); return i < 0 ? list.length + i : i - 1; };
|
|
1836
1849
|
const lookup = (s, list)=> list[index(s, list)];
|
|
1837
|
-
|
|
1850
|
+
// any line end, a line carried on by a backslash at its end, and a comment after what a line holds
|
|
1851
|
+
for (const line of text.replace(/\\\r?\n/g, ' ').split(/\r\n?|\n/))
|
|
1838
1852
|
{
|
|
1839
|
-
const parts = line.trim().split(/\s+/);
|
|
1853
|
+
const parts = line.replace(/#.*/, '').trim().split(/\s+/);
|
|
1840
1854
|
switch (parts[0])
|
|
1841
1855
|
{
|
|
1842
1856
|
case 'v': positions.push(vec3(+parts[1], +parts[2], +parts[3])); break;
|
|
@@ -1848,6 +1862,7 @@ function parseOBJ(text, smooth=render3D?.smoothShading)
|
|
|
1848
1862
|
if (corners.length < 3) break;
|
|
1849
1863
|
const facePoints = corners.map(c=> lookup(c[0], positions));
|
|
1850
1864
|
ASSERT(facePoints.every(isVector3), 'OBJ face uses a vertex index the file does not have', line);
|
|
1865
|
+
if (!facePoints.every(isVector3)) break; // a release build leaves the face out
|
|
1851
1866
|
const hasNormals = corners.every(c=> c[2]);
|
|
1852
1867
|
missingNormals ||= !hasNormals;
|
|
1853
1868
|
const faceNormal = hasNormals ? undefined : render3DFaceNormal(facePoints[0], facePoints[1], facePoints[2], facePoints[3]);
|