littlejsengine 1.20.0 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -72,9 +72,11 @@ function render3DCanDraw()
72
72
  }
73
73
 
74
74
  // the draw state fields a batch is drawn under; lights and fog are not captured, they are read live at flush
75
- // the three functions below write them out by hand for speed, so a new field goes in all four
75
+ // the three functions below write them out by hand for speed, so a new field goes in all four; emissiveMapColor is
76
+ // compared by its values, so two objects with equal colors batch
76
77
  const RENDER3D_STATE_FIELDS = ['blend', 'additive', 'depthTest', 'depthWrite', 'cullBackFaces', 'mirrored', 'lighting',
77
- 'emissive', 'receiveShadow', 'specular', 'pixelated', 'shader'];
78
+ 'emissive', 'receiveShadow', 'specular', 'pixelated', 'shader', 'normalMap', 'normalScale', 'shininess',
79
+ 'reflectivity', 'emissiveMap', 'emissiveMapColor'];
78
80
 
79
81
  // a copy of the draw state in one fixed shape, the fields of RENDER3D_STATE_FIELDS written out so the
80
82
  // compare below stays a handful of direct reads, it runs for every instance drawn
@@ -83,7 +85,9 @@ function render3DCaptureBatchState()
83
85
  const r = render3D;
84
86
  return {blend: r.blend, additive: r.additive, depthTest: r.depthTest, depthWrite: r.depthWrite,
85
87
  cullBackFaces: r.cullBackFaces, mirrored: r.mirrored, lighting: r.lighting, emissive: r.emissive,
86
- receiveShadow: r.receiveShadow, specular: r.specular, pixelated: r.pixelated, shader: r.shader};
88
+ receiveShadow: r.receiveShadow, specular: r.specular, pixelated: r.pixelated, shader: r.shader,
89
+ normalMap: r.normalMap, normalScale: r.normalScale, shininess: r.shininess, reflectivity: r.reflectivity,
90
+ emissiveMap: r.emissiveMap, emissiveMapColor: (r.emissiveMapColor || WHITE).copy()}; // the caller may change it
87
91
  }
88
92
 
89
93
  // put a captured draw state back, written out the same way; the transparent stage does this for every queued draw
@@ -92,7 +96,9 @@ function render3DApplyBatchState(s)
92
96
  const r = render3D;
93
97
  r.blend = s.blend, r.additive = s.additive, r.depthTest = s.depthTest, r.depthWrite = s.depthWrite,
94
98
  r.cullBackFaces = s.cullBackFaces, r.mirrored = s.mirrored, r.lighting = s.lighting, r.emissive = s.emissive,
95
- r.receiveShadow = s.receiveShadow, r.specular = s.specular, r.pixelated = s.pixelated, r.shader = s.shader;
99
+ r.receiveShadow = s.receiveShadow, r.specular = s.specular, r.pixelated = s.pixelated, r.shader = s.shader,
100
+ r.normalMap = s.normalMap, r.normalScale = s.normalScale, r.shininess = s.shininess,
101
+ r.reflectivity = s.reflectivity, r.emissiveMap = s.emissiveMap, r.emissiveMapColor = s.emissiveMapColor;
96
102
  }
97
103
 
98
104
  // true when the current draw state differs from a captured one, so a pending batch must flush first
@@ -102,13 +108,20 @@ function render3DStateChanged(s)
102
108
  return r.blend !== s.blend || r.additive !== s.additive || r.depthTest !== s.depthTest
103
109
  || r.depthWrite !== s.depthWrite || r.cullBackFaces !== s.cullBackFaces || r.mirrored !== s.mirrored
104
110
  || r.lighting !== s.lighting || r.emissive !== s.emissive || r.receiveShadow !== s.receiveShadow
105
- || r.specular !== s.specular || r.pixelated !== s.pixelated || r.shader !== s.shader;
111
+ || r.specular !== s.specular || r.pixelated !== s.pixelated || r.shader !== s.shader
112
+ || r.normalMap !== s.normalMap || r.normalScale !== s.normalScale || r.shininess !== s.shininess
113
+ || r.reflectivity !== s.reflectivity || r.emissiveMap !== s.emissiveMap
114
+ || render3DColorChanged(r.emissiveMapColor || WHITE, s.emissiveMapColor);
115
+ }
116
+
117
+ // whether two colors differ in their rgb, for the batch key's emissiveMapColor
118
+ function render3DColorChanged(a, b) { return a.r !== b.r || a.g !== b.g || a.b !== b.b;
106
119
  }
107
120
 
108
121
  // whether a sphere is inside the view, or the shadow map's box during the shadow pass, without a vector
109
122
  function render3DSphereVisible(x, y, z, radius)
110
123
  {
111
- const planes = render3D.shadowPass ? render3D.shadowPlanes : render3D.frustumPlanes;
124
+ const planes = render3D.shadowPass && !render3D.depthPass ? render3D.shadowPlanes : render3D.frustumPlanes;
112
125
  for (let i = 0; i < planes.length; ++i)
113
126
  {
114
127
  const p = planes[i];
@@ -208,6 +221,15 @@ function render3DSetObjectState(o)
208
221
  r.emissive = emissive;
209
222
  r.additive = !!o?.additive;
210
223
  r.specular = o?.specular || 0;
224
+ const shininess = o?.shininess ?? 16, reflectivity = o?.reflectivity || 0;
225
+ ASSERT(isNumber(shininess) && shininess > 0, 'shininess must be a number above 0', shininess);
226
+ ASSERT(isNumber(reflectivity) && reflectivity >= 0 && reflectivity <= 1, 'reflectivity must be 0 to 1', reflectivity);
227
+ r.shininess = shininess;
228
+ r.reflectivity = reflectivity;
229
+ r.normalMap = o?.normalMap || undefined;
230
+ r.normalScale = o?.normalScale ?? 1;
231
+ r.emissiveMap = o?.emissiveMap || undefined;
232
+ r.emissiveMapColor = o?.emissiveMapColor || WHITE;
211
233
  r.receiveShadow = !o || o.receiveShadow;
212
234
  r.cullBackFaces = r.mirrored = false; // each mesh sets these as it draws
213
235
  r.pixelated = !!o?.pixelated;
@@ -441,6 +463,11 @@ class Render3DPlugin
441
463
  * flat faces */
442
464
  this.smoothShading = false;
443
465
 
466
+ /** @property {boolean} - Draw the camera's depth into a texture each frame for post processing:
467
+ * PostProcessPlugin hands it to its shader as iChannel2, read with sceneDepth(uv); off by default and free
468
+ * when off, on it draws the solid objects of the default layer once more, depth only */
469
+ this.depthTexture = false;
470
+
444
471
  // shadows
445
472
  /** @property {boolean} - Cast real shadows from the sun, off by default and free when off */
446
473
  this.shadows = false;
@@ -477,8 +504,23 @@ class Render3DPlugin
477
504
  this.cullBackFaces = false;
478
505
  this.mirrored = false;
479
506
  /** @property {number} - Strength of the highlight where the sun and the Light3D objects reflect, 0 is none and
480
- * 1 adds a light's full color at its brightest; its size is fixed */
507
+ * 1 adds a light's full color at its brightest; shininess sets its size */
481
508
  this.specular = 0;
509
+ /** @property {number} - The highlight's exponent, how small and sharp it is: 4 is broad like rubber, 100 sharp
510
+ * like polished metal; set from each object's shininess */
511
+ this.shininess = 16;
512
+ /** @property {TextureInfo|undefined} - Normal map for the next draws, set from each object's normalMap
513
+ * @type {TextureInfo|undefined} */
514
+ this.normalMap = undefined;
515
+ /** @property {number} - How strongly the normal map bends the surface, set from each object's normalScale */
516
+ this.normalScale = 1;
517
+ /** @property {number} - How much the surface reflects the sky, 0 to 1, set from each object's reflectivity */
518
+ this.reflectivity = 0;
519
+ /** @property {TextureInfo|undefined} - Emissive map for the next draws, set from each object's emissiveMap
520
+ * @type {TextureInfo|undefined} */
521
+ this.emissiveMap = undefined;
522
+ /** @property {Color} - Multiplies the emissive map, set from each object's emissiveMapColor */
523
+ this.emissiveMapColor = WHITE;
482
524
  /** @property {Shader|undefined} - Custom Shader for the next draws, set from each object's shader; undefined
483
525
  * draws with the plugin's own
484
526
  * @type {Shader|undefined} */
@@ -580,6 +622,13 @@ class Render3DPlugin
580
622
  /** @type {WebGLFramebuffer|undefined} */
581
623
  this.shadowFramebuffer = undefined;
582
624
  this.shadowTextureSize = 0;
625
+ /** @type {WebGLTexture|undefined} */
626
+ this.cameraDepthTexture = undefined; // the camera's depth, drawn when depthTexture is on
627
+ /** @type {WebGLFramebuffer|undefined} */
628
+ this.cameraDepthFramebuffer = undefined;
629
+ this.cameraDepthWidth = 0;
630
+ this.cameraDepthHeight = 0;
631
+ this.depthPass = false; // drawing the camera's depth, a shadow pass seen from the camera
583
632
  this.contextGeneration = 0; // counts context losses, a mesh uploaded under an older one uploads again
584
633
  this.uniforms = new Map; // uniform locations by program
585
634
  /** @type {Object<string, Array<number>>} */
@@ -1007,7 +1056,8 @@ class Render3DPlugin
1007
1056
  // a far plane at Infinity has no midpoint, so put it a long way out instead
1008
1057
  const {near, far} = this.camera;
1009
1058
  const radius = far == Infinity ? near * 1e4 : (near + far) / 2;
1010
- render3DWithState({lighting: false, blend: false, depthTest: false, depthWrite: false, fogEnd: 0, shader: undefined}, ()=>
1059
+ render3DWithState({lighting: false, blend: false, depthTest: false, depthWrite: false, fogEnd: 0, shader: undefined,
1060
+ emissiveMap: undefined}, ()=>
1011
1061
  this.drawMesh(this.sky, buildMatrix(this.camera.pos, undefined, vec3(radius))));
1012
1062
  }
1013
1063
 
@@ -1132,8 +1182,10 @@ class Render3DPlugin
1132
1182
  drawQuad(a, b, c, d, tileInfo, color=WHITE)
1133
1183
  {
1134
1184
  if (this.transparentQueue) // the queue replays later, so it keeps copies of what a caller may reuse
1185
+ {
1135
1186
  a = a.copy(), b = b.copy(), c = c.copy(), d = d.copy();
1136
1187
  color = isArray(color) ? color.map(k=> k.copy()) : color.copy();
1188
+ }
1137
1189
  this.drawStrip(render3DQuadStrip(a, b, c, d), render3DFaceNormal(a, b, c, d), RENDER3D_QUAD_UVS,
1138
1190
  render3DQuadValues(color), tileInfo);
1139
1191
  }
@@ -1432,7 +1484,9 @@ const RENDER3D_SNIPPET_NAMES =
1432
1484
  // uniforms: lightDir (xyz the way the sunlight travels, w = emissive, 1 or more skips the lighting),
1433
1485
  // lightColor (the sun's rgb, a = specular), ambientFog (rgb, a = fogEnd), fogColor (rgb, a = fogStart),
1434
1486
  // cameraPos, tex, shadowMap, shadowParams (x = shadows on, y = bias, z = blur step in texture space,
1435
- // w = how the draw finishes: 1 opaque and alpha tested, 0 blended, -1 additive)
1487
+ // w = how the draw finishes: 1 opaque and alpha tested, 0 blended, -1 additive),
1488
+ // materialParams (x = normal map scale, 0 skips it, y = shininess, z = reflectivity), emissiveTint (rgb, a = the
1489
+ // emissive map is on), skyTop, skyHorizon, skyBottom (what a reflection shows), normalTex, emissiveTex
1436
1490
  function render3DFragmentSource(fragmentCode)
1437
1491
  {
1438
1492
  return '#version 300 es\n' +
@@ -1441,7 +1495,8 @@ function render3DFragmentSource(fragmentCode)
1441
1495
  'uniform vec4 extraLights[' + RENDER3D_MAX_LIGHTS + '],extraLightColors[' + RENDER3D_MAX_LIGHTS + '];' +
1442
1496
  'uniform int extraLightCount;' +
1443
1497
  'uniform vec3 cameraPos;' +
1444
- 'uniform sampler2D tex;' +
1498
+ 'uniform vec4 materialParams,emissiveTint,skyTop,skyHorizon,skyBottom;' +
1499
+ 'uniform sampler2D tex,normalTex,emissiveTex;' +
1445
1500
  'uniform bool premultipliedTexture;' + // is the texture a render target, which holds premultiplied color
1446
1501
  'uniform highp sampler2DShadow shadowMap;' +
1447
1502
  'in vec3 P,N;in vec2 T,L;in vec4 C,S;' +
@@ -1456,6 +1511,17 @@ function render3DFragmentSource(fragmentCode)
1456
1511
  'for(int x=-1;x<=1;++x)for(int y=-1;y<=1;++y)' +
1457
1512
  's+=texture(shadowMap,vec3(q.xy+vec2(x,y)*shadowParams.z,q.z));' +
1458
1513
  'return s/9.;}' +
1514
+ // the normal map's normal here, in the frame that the position and texture coordinate change along across the
1515
+ // screen, so a mesh needs no tangents; green points up the image and v runs down it, so up is -v;
1516
+ // a mesh with no texture coordinates has no frame and keeps its own normal
1517
+ 'vec3 normalMapNormal(vec3 n){' +
1518
+ 'vec3 p1=dFdx(P),p2=dFdy(P);vec2 t1=dFdx(T),t2=dFdy(T);' +
1519
+ 'vec3 a=cross(p2,n),b=cross(n,p1),u=a*t1.x+b*t2.x,v=a*t1.y+b*t2.y;' +
1520
+ 'float k=max(dot(u,u),dot(v,v));' +
1521
+ 'if(k<=0.)return n;' +
1522
+ 'vec3 m=texture(normalTex,T).xyz*2.-1.;' +
1523
+ 'm.xy*=materialParams.x;' +
1524
+ 'return normalize((u*m.x-v*m.y)*inversesqrt(k)+n*m.z);}' +
1459
1525
  (fragmentCode ? RENDER3D_SNIPPET_NAMES + fragmentCode + '\n' : '') +
1460
1526
  'void main(){' +
1461
1527
  (fragmentCode ? 'vec4 t;mainImage(t,T);' : 'vec4 t=texture(tex,T);') +
@@ -1466,6 +1532,7 @@ function render3DFragmentSource(fragmentCode)
1466
1532
  'if(e<1.){' +
1467
1533
  'vec3 n=dot(N,N)>0.?normalize(N):vec3(0,1,0);' +
1468
1534
  'if(!gl_FrontFacing)n=-n;' + // only a double sided mesh shows a back face, light it on the side that is seen
1535
+ 'if(materialParams.x!=0.)n=normalMapNormal(n);' +
1469
1536
  'float nl=dot(n,-lightDir.xyz);' +
1470
1537
  'float s=shadow();' +
1471
1538
  // the ambient: one color, or blended from the ground color below to the sky color above by the way the face points
@@ -1484,15 +1551,24 @@ function render3DFragmentSource(fragmentCode)
1484
1551
  'float ln=dot(n,v);' +
1485
1552
  'vec3 lc=extraLightColors[i].rgb*extraLightColors[i].a*a*a;' +
1486
1553
  'l+=lc*max(0.,ln);' +
1487
- 'if(lightColor.a>0.)sp+=lc*pow(max(dot(reflect(-v,n),eye),0.),16.)*step(0.,ln);' +
1554
+ 'if(lightColor.a>0.)sp+=lc*pow(max(dot(reflect(-v,n),eye),0.),materialParams.y)*step(0.,ln);' +
1488
1555
  '}' +
1489
1556
  'c.rgb*=l*(1.-e)+e;' + // lit, blended toward its own color by how emissive it is
1490
1557
  // specular: the sun's only where its light hits and out of shadow, then the Light3D highlights,
1491
1558
  // skipped entirely when the strength is zero
1492
1559
  'if(lightColor.a>0.){' +
1493
1560
  'vec3 r=reflect(lightDir.xyz,n);' +
1494
- 'c.rgb+=lightColor.rgb*pow(max(dot(r,eye),0.),16.)*lightColor.a*step(0.,nl)*s*(1.-e)+sp*lightColor.a*(1.-e);' +
1561
+ 'c.rgb+=lightColor.rgb*pow(max(dot(r,eye),0.),materialParams.y)*lightColor.a*step(0.,nl)*s*(1.-e)+sp*lightColor.a*(1.-e);' +
1562
+ '}' +
1563
+ // the sky along the reflected view, more at a glancing angle (Schlick's Fresnel); sky light, so the sun's
1564
+ // shadow does not dim it
1565
+ 'if(materialParams.z>0.){' +
1566
+ 'vec3 w=normalize(P-cameraPos),q=reflect(w,n);' +
1567
+ 'float f=materialParams.z+(1.-materialParams.z)*pow(1.-max(dot(n,-w),0.),5.);' +
1568
+ 'c.rgb=mix(c.rgb,q.y>0.?mix(skyHorizon.rgb,skyTop.rgb,q.y):mix(skyHorizon.rgb,skyBottom.rgb,-q.y),f);' +
1495
1569
  '}}else c.rgb*=e;' + // fully emissive: its own color, or brighter, with no lighting to work out
1570
+ // the emissive map adds its light on top, lit or not
1571
+ 'if(emissiveTint.a>0.)c.rgb+=texture(emissiveTex,T).rgb*emissiveTint.rgb;' +
1496
1572
  'if(ambientFog.a>0.){' +
1497
1573
  'float z=distance(cameraPos,P);' +
1498
1574
  'c.rgb=mix(c.rgb,shadowParams.w<0.?vec3(0):fogColor.rgb,smoothstep(fogColor.a,ambientFog.a,z));' +
@@ -1519,6 +1595,8 @@ function render3DUseProgram(program)
1519
1595
  gl.uniformMatrix4fv(render3DUniform('lightViewProj'), false, r.shadowMatrix.m);
1520
1596
  gl.uniform1i(render3DUniform('tex'), 0);
1521
1597
  gl.uniform1i(render3DUniform('shadowMap'), 1);
1598
+ gl.uniform1i(render3DUniform('normalTex'), 2);
1599
+ gl.uniform1i(render3DUniform('emissiveTex'), 3);
1522
1600
  const c = r.camera.pos;
1523
1601
  gl.uniform3f(render3DUniform('cameraPos'), c.x, c.y, c.z);
1524
1602
  gl.uniform1i(render3DUniform('extraLightCount'), r.lightCount);
@@ -1611,6 +1689,8 @@ function render3DContextLost()
1611
1689
  render3DClearInstances();
1612
1690
  r.shadowFramebuffer = r.shadowTexture = undefined;
1613
1691
  r.shadowTextureSize = 0;
1692
+ r.cameraDepthFramebuffer = r.cameraDepthTexture = undefined;
1693
+ r.cameraDepthWidth = r.cameraDepthHeight = 0;
1614
1694
  r.streamCount = 0;
1615
1695
  ++r.contextGeneration; // every uploaded mesh is stale now, the soft dot is a TextureInfo the engine restores
1616
1696
  }
@@ -1689,25 +1769,68 @@ function render3DSampler(wrap, pixelated)
1689
1769
  }
1690
1770
 
1691
1771
  // bind the texture of a tile or texture, white when there is none or it is not loaded, with the 3D sampler that
1692
- // matches its wrap mode; the first time a texture is used in 3D it gets its mipmaps
1693
- function render3DBindTexture(tileInfo, state=render3D)
1772
+ // matches its wrap mode; the first time a texture is used in 3D it gets its mipmaps; on unit 0, the color texture,
1773
+ // or another unit, the material maps, after which unit 0 is active again
1774
+ function render3DBindTexture(tileInfo, state=render3D, unit=0)
1694
1775
  {
1695
1776
  const gl = glContext, r = render3D;
1696
1777
  const textureInfo = render3DTextureOf(tileInfo);
1697
1778
  const texture = textureInfo?.glTexture || r.whiteTexture;
1779
+ unit && gl.activeTexture(gl.TEXTURE0 + unit);
1698
1780
  gl.bindTexture(gl.TEXTURE_2D, texture);
1699
1781
  if (texture === r.whiteTexture || !r.mipmaps && !state.pixelated)
1700
- return gl.bindSampler(0, null); // the texture's own filtering, as in 2D; the white texel needs no mipmaps
1701
- // or anisotropy, and filtering it that way costs every untextured fragment
1702
- gl.bindSampler(0, render3DSampler(textureInfo?.wrap, state.pixelated));
1703
- glUpdateMipmaps(texture); // drawn into since its mipmaps were made
1704
- if (!state.pixelated && !glMipmappedTextures.has(texture)) // a hard edged draw never reads them
1782
+ gl.bindSampler(unit, null); // the texture's own filtering, as in 2D; the white texel needs no mipmaps
1783
+ // or anisotropy, and filtering it that way costs every untextured fragment
1784
+ else
1785
+ {
1786
+ gl.bindSampler(unit, render3DSampler(textureInfo?.wrap, state.pixelated));
1787
+ glUpdateMipmaps(texture); // drawn into since its mipmaps were made
1788
+ if (!state.pixelated && !glMipmappedTextures.has(texture)) // a hard edged draw never reads them
1789
+ {
1790
+ glMipmappedTextures.add(texture); // the core makes them again when the texture changes
1791
+ gl.generateMipmap(gl.TEXTURE_2D);
1792
+ }
1793
+ }
1794
+ unit && gl.activeTexture(gl.TEXTURE0);
1795
+ }
1796
+
1797
+ // the material's uniforms and maps for a draw: the normal map on unit 2 and the emissive map on unit 3, white when
1798
+ // unused and bound only when they change, and the sky a reflection shows, the colors setSky was given or the
1799
+ // ambient ones; with nothing set every part of it is skipped in the shader, and a map with no GL texture yet, or
1800
+ // one freed, counts as none, since the white texture in its place would bend every normal or light the surface
1801
+ function render3DSetMaterialUniforms(state)
1802
+ {
1803
+ const r = render3D, loaded = (map)=> render3DTextureOf(map)?.glTexture ? map : undefined;
1804
+ const normalMap = state.normalScale ? loaded(state.normalMap) : undefined, emissiveMap = loaded(state.emissiveMap);
1805
+ render3DUniform4f('materialParams', normalMap ? state.normalScale : 0, state.shininess, state.reflectivity, 0);
1806
+ const ec = state.emissiveMapColor || WHITE;
1807
+ emissiveMap ? render3DUniform4f('emissiveTint', ec.r, ec.g, ec.b, 1) : render3DUniform4f('emissiveTint', 0, 0, 0, 0);
1808
+ render3DBindMap(2, normalMap, state);
1809
+ render3DBindMap(3, emissiveMap, state);
1810
+ if (state.reflectivity > 0)
1705
1811
  {
1706
- glMipmappedTextures.add(texture); // the core makes them again when the texture changes
1707
- gl.generateMipmap(gl.TEXTURE_2D);
1812
+ const sky = r.sky && render3DSkyColors.get(r.sky), a = r.ambientColor, g = r.ambientGroundColor || a;
1813
+ const top = sky ? sky[0] : a, bottom = sky ? sky[2] : g;
1814
+ render3DUniform4f('skyTop', top.r, top.g, top.b, 1);
1815
+ render3DUniform4f('skyBottom', bottom.r, bottom.g, bottom.b, 1);
1816
+ sky ? render3DUniform4f('skyHorizon', sky[1].r, sky[1].g, sky[1].b, 1) :
1817
+ render3DUniform4f('skyHorizon', (a.r + g.r) / 2, (a.g + g.g) / 2, (a.b + g.b) / 2, 1);
1708
1818
  }
1709
1819
  }
1710
1820
 
1821
+ // what each material map unit has bound, index 2 and 3 the maps and 4 and 5 whether each is pixelated, forgotten at
1822
+ // the start and end of each pass
1823
+ let render3DBoundMaps = [];
1824
+
1825
+ // bind a material map to its unit, or white for none, only when it or its filtering changed since the last draw
1826
+ function render3DBindMap(unit, map, state)
1827
+ {
1828
+ const bound = render3DBoundMaps, pixelated = !!map && state.pixelated;
1829
+ if (bound[unit] === map && bound[unit + 2] === pixelated) return;
1830
+ bound[unit] = map, bound[unit + 2] = pixelated;
1831
+ render3DBindTexture(map, state, unit);
1832
+ }
1833
+
1711
1834
  // send a vec4 uniform of the main shader only when its value changed since the last send
1712
1835
  function render3DUniform4f(name, x, y, z, w)
1713
1836
  {
@@ -1845,6 +1968,7 @@ function render3DSetDrawUniforms(matrix, tileInfo, tint, uvRect, state=render3D)
1845
1968
  // a render target's texture holds premultiplied color, the blend writes it that way, so the shader undoes it
1846
1969
  const textureInfo = render3DTextureOf(tileInfo);
1847
1970
  render3DUniform1i('premultipliedTexture', +!!(textureInfo?.glTexture && glPremultipliedTextures.has(textureInfo.glTexture)));
1971
+ render3DSetMaterialUniforms(state);
1848
1972
  }
1849
1973
 
1850
1974
  // the six flat sides of the camera's visible box, each as [x, y, z, w] facing inward
@@ -1866,6 +1990,7 @@ function render3DFrustumPlanes(matrix)
1866
1990
  function render3DPreRender()
1867
1991
  {
1868
1992
  const r = render3D;
1993
+ editor3DCameraBegin(); // debug builds draw with the free camera or the 3D editor's, the game's is back after
1869
1994
  r.updateMatrices();
1870
1995
  r.shadowMapDrawn = false;
1871
1996
  render3DRenderPass(false);
@@ -1875,6 +2000,7 @@ function render3DPreRender()
1875
2000
  function render3DRender()
1876
2001
  {
1877
2002
  render3DRenderPass(true);
2003
+ editor3DCameraEnd();
1878
2004
  }
1879
2005
 
1880
2006
  // 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
@@ -1924,7 +2050,8 @@ function render3DRenderPass(after2D)
1924
2050
  // may have left its own texture, which fails every draw; the shadow map goes back there even with shadows off
1925
2051
  gl.activeTexture(gl.TEXTURE1);
1926
2052
  gl.bindTexture(gl.TEXTURE_2D, r.shadowTexture || null);
1927
- gl.activeTexture(gl.TEXTURE0);
2053
+ // the material maps' units start white, a 2D plugin may have left its own textures there
2054
+ render3DSetMapUnits(r.whiteTexture);
1928
2055
  gl.depthMask(true);
1929
2056
  gl.clear(gl.DEPTH_BUFFER_BIT);
1930
2057
 
@@ -1937,6 +2064,8 @@ function render3DRenderPass(after2D)
1937
2064
  render3DRenderShadowMap();
1938
2065
  r.shadowMapDrawn = true;
1939
2066
  }
2067
+ // the camera's depth for post processing, from the default layer
2068
+ r.depthTexture && isDefault && render3DRenderDepth();
1940
2069
  render3DUseProgram(r.program); // after the shadow map, so the light matrix it sends is this frame's
1941
2070
  r.renderStages(objects, isDefault);
1942
2071
  }
@@ -1952,6 +2081,7 @@ function render3DRenderPass(after2D)
1952
2081
  gl.depthMask(true);
1953
2082
  gl.frontFace(gl.CCW);
1954
2083
  gl.bindSampler(0, null); // back to the textures' own filtering for 2D
2084
+ render3DSetMapUnits(null); // the material maps leave no texture or sampler behind for 2D
1955
2085
  if (glActiveTexture)
1956
2086
  gl.bindTexture(gl.TEXTURE_2D, glActiveTexture);
1957
2087
  // ARRAY_BUFFER is not part of VAO state in WebGL2, so bindVertexArray alone would not restore it
@@ -1960,6 +2090,21 @@ function render3DRenderPass(after2D)
1960
2090
  }
1961
2091
  }
1962
2092
 
2093
+ // put a texture on both material map units with no sampler, white at the start of the pass and none at its end, and
2094
+ // forget what the map cache thought was bound; unit 0 is active after
2095
+ function render3DSetMapUnits(texture)
2096
+ {
2097
+ const gl = glContext;
2098
+ for (const unit of [2, 3])
2099
+ {
2100
+ gl.activeTexture(gl.TEXTURE0 + unit);
2101
+ gl.bindTexture(gl.TEXTURE_2D, texture);
2102
+ gl.bindSampler(unit, null);
2103
+ }
2104
+ gl.activeTexture(gl.TEXTURE0);
2105
+ render3DBoundMaps = [];
2106
+ }
2107
+
1963
2108
  // create the shadow map depth texture and framebuffer at a size, or keep them when the size matches
1964
2109
  function render3DUpdateShadowMap(size)
1965
2110
  {
@@ -2036,6 +2181,65 @@ function render3DRenderShadowMap()
2036
2181
  }
2037
2182
  }
2038
2183
 
2184
+ // the camera's depth texture and its framebuffer at a size, made again when the canvas changes size; depth values
2185
+ // are not filtered, so it is read texel by texel
2186
+ function render3DUpdateCameraDepth(width, height)
2187
+ {
2188
+ const gl = glContext, r = render3D;
2189
+ if (r.cameraDepthTexture && r.cameraDepthWidth === width && r.cameraDepthHeight === height) return;
2190
+ r.cameraDepthTexture && gl.deleteTexture(r.cameraDepthTexture);
2191
+ r.cameraDepthFramebuffer && gl.deleteFramebuffer(r.cameraDepthFramebuffer);
2192
+ const texture = r.cameraDepthTexture = gl.createTexture();
2193
+ gl.bindTexture(gl.TEXTURE_2D, texture);
2194
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.DEPTH_COMPONENT24, width, height, 0, gl.DEPTH_COMPONENT, gl.UNSIGNED_INT, null);
2195
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
2196
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
2197
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
2198
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
2199
+ gl.bindTexture(gl.TEXTURE_2D, null);
2200
+ const framebuffer = r.cameraDepthFramebuffer = gl.createFramebuffer();
2201
+ gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
2202
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, texture, 0);
2203
+ gl.drawBuffers([gl.NONE]); // depth only
2204
+ gl.readBuffer(gl.NONE);
2205
+ ASSERT(gl.checkFramebufferStatus(gl.FRAMEBUFFER) == gl.FRAMEBUFFER_COMPLETE, 'camera depth framebuffer is incomplete');
2206
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
2207
+ r.cameraDepthWidth = width, r.cameraDepthHeight = height;
2208
+ }
2209
+
2210
+ // draw the camera's depth for post processing: the solid objects from the camera with the shadow map's depth only
2211
+ // shader, into a texture the size of the canvas; what is see through is cut by its alpha as it is for shadows
2212
+ function render3DRenderDepth()
2213
+ {
2214
+ const gl = glContext, r = render3D, width = glCanvas.width, height = glCanvas.height;
2215
+ render3DUpdateCameraDepth(width, height);
2216
+ gl.bindFramebuffer(gl.FRAMEBUFFER, r.cameraDepthFramebuffer);
2217
+ gl.viewport(0, 0, width, height);
2218
+ gl.clear(gl.DEPTH_BUFFER_BIT);
2219
+ gl.useProgram(r.shadowShader);
2220
+ gl.uniformMatrix4fv(render3DUniform('viewProj', r.shadowShader), false, r.viewProjection.m);
2221
+ gl.enable(gl.DEPTH_TEST);
2222
+ gl.depthMask(true);
2223
+ gl.disable(gl.BLEND);
2224
+ gl.disable(gl.CULL_FACE);
2225
+
2226
+ r.shadowPass = r.depthPass = true;
2227
+ try
2228
+ {
2229
+ const solids = render3DLayerObjects(!!r.renderAfter2D).filter(o=> !o.additive && (!o.transparent || o.tileInfo));
2230
+ render3DDrawObjects(solids);
2231
+ r.onRenderOpaque?.();
2232
+ r.flush();
2233
+ render3DFlushInstances();
2234
+ }
2235
+ finally
2236
+ {
2237
+ r.shadowPass = r.depthPass = false;
2238
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
2239
+ gl.viewport(0, 0, width, height);
2240
+ }
2241
+ }
2242
+
2039
2243
  ///////////////////////////////////////////////////////////////////////////////
2040
2244
  // Strips: every strip repeats its first point once at the start and its last
2041
2245
  // point once at the end. Those repeats make flat triangles with no area, which
@@ -2189,18 +2393,26 @@ function render3DStripTriangles(count, remap, place)
2189
2393
  }
2190
2394
 
2191
2395
  // a mesh's packed vertex data for the GPU, one vertex per entry of a layout, which is the strip index of each,
2192
- // written into data when given, the buffer an earlier call returned for the same layout
2396
+ // written into data when given, the buffer an earlier call returned for the same layout; it measures the mesh's
2397
+ // radius and box as it goes, as computeRadius does, over the vertices once instead of every strip entry
2193
2398
  function render3DMeshVertexData(mesh, vertices, data=new ArrayBuffer(vertices.length * RENDER3D_VERTEX_BYTES))
2194
2399
  {
2195
2400
  const count = vertices.length;
2196
2401
  const floats = new Float32Array(data), ints = new Uint32Array(data);
2402
+ let r = 0, x0 = Infinity, y0 = Infinity, z0 = Infinity, x1 = -Infinity, y1 = -Infinity, z1 = -Infinity;
2197
2403
  for (let j = 0; j < count; ++j)
2198
2404
  {
2199
2405
  // a hand built mesh may leave normals, uvs and colors empty
2200
2406
  const i = vertices[j], p = mesh.points[i], uv = mesh.uvs[i] || RENDER3D_DEFAULT_UV;
2201
- render3DWriteVertex(floats, ints, j * RENDER3D_VERTEX_FLOATS, p.x, p.y, p.z,
2407
+ const x = p.x, y = p.y, z = p.z;
2408
+ render3DWriteVertex(floats, ints, j * RENDER3D_VERTEX_FLOATS, x, y, z,
2202
2409
  mesh.normals[i] || RENDER3D_DEFAULT_NORMAL, uv.x, uv.y, (mesh.colors[i] || WHITE).rgbaInt());
2410
+ r = max(r, x*x + y*y + z*z);
2411
+ x0 = min(x0, x), y0 = min(y0, y), z0 = min(z0, z);
2412
+ x1 = max(x1, x), y1 = max(y1, y), z1 = max(z1, z);
2203
2413
  }
2414
+ mesh.radius = r ** .5;
2415
+ mesh.bounds = count ? {min: vec3(x0, y0, z0), max: vec3(x1, y1, z1)} : {min: vec3(), max: vec3()};
2204
2416
  return data;
2205
2417
  }
2206
2418
 
@@ -2712,8 +2924,8 @@ class Mesh
2712
2924
  * @return {Mesh} */
2713
2925
  upload()
2714
2926
  {
2715
- this.computeRadius();
2716
- if (!render3D?.program || !glContext) return this;
2927
+ // the packing below measures the radius and box, with nothing to pack to they are measured here
2928
+ if (!render3D?.program || !glContext) return this.computeRadius(), this;
2717
2929
  const gl = glContext, layout = this.vertexLayout;
2718
2930
  // no layout when dynamicDraw was turned on after an upload, the next upload makes one
2719
2931
  if (this.dynamicDraw && layout && this.buffer && this.contextGeneration === render3D.contextGeneration)
@@ -3215,25 +3427,30 @@ function buildSky(topColor=hsl(.6, .8, .55), horizonColor=hsl(.6, 1, .9), bottom
3215
3427
  // a sphere turned inside out so it is seen from within, each point colored by how high it is
3216
3428
  const mesh = buildSphere(2, sides, rings, true).flipNormals();
3217
3429
  mesh.colors = mesh.points.map(p=> p.y < 0 ? horizonColor.lerp(bottomColor, -p.y) : horizonColor.lerp(topColor, p.y));
3430
+ render3DSkyColors.set(mesh, [topColor.copy(), horizonColor.copy(), bottomColor.copy()]);
3218
3431
  return mesh;
3219
3432
  }
3220
3433
 
3434
+ // the top, horizon and bottom colors of each sky dome buildSky made, which a reflection shows while that dome is
3435
+ // render3D.sky, whether setSky set it or the game did
3436
+ const render3DSkyColors = new WeakMap;
3437
+
3221
3438
  ///////////////////////////////////////////////////////////////////////////////
3222
3439
  /**
3223
3440
  * EngineObject3D - An EngineObject with a 3D transform and a mesh
3224
3441
  * - Set pos3D, rotation3D and scale3D instead of the 2D pos, size and angle
3225
3442
  * - Gets update, children, timers, destroy and renderOrder from EngineObject
3226
- * - velocity3D is added to pos3D each frame, along with render3D.gravity and damping once it has a mass
3443
+ * - velocity3D is added to pos3D each frame, slowed by damping, and render3D.gravity pulls it once it has a mass
3227
3444
  * - Objects face -Z, the same way the camera does, so lookAt turns them to face a point
3228
3445
  * - The 2D pos and velocity are still there but nothing draws them
3229
- * - These inherited fields are 2D only and do nothing here: angle, angleVelocity, angleDamping,
3230
- * additiveColor, drawSize, mirror, clampSpeed, friction and groundObject
3446
+ * - These inherited fields are 2D only and do nothing here: angle, angleVelocity, additiveColor, drawSize and mirror;
3447
+ * damping, angleDamping, clampSpeed, friction and groundObject work as in 2D, on velocity3D and angleVelocity3D
3231
3448
  * - The inherited shader works here as in 2D, and with emissive at 1 its snippet does its own lighting
3232
3449
  * - Set sync2D for a 2D game with 3D looks, pos and angle then drive pos3D and rotation3D,
3233
3450
  * which is the one way those 2D fields reach a 3D object
3234
3451
  * - setCollision takes the same flags as in 2D, but the solid collision happens in 3D against size3D
3235
- * - The solid box is axis aligned in the world, rotation3D is ignored like angle is in 2D, so give a turned wall a
3236
- * size3D along the world axes
3452
+ * - The solid box turns with rotation3D, so a turned wall or a ramp collides as it looks; resting on one no steeper
3453
+ * than groundAngle stands there
3237
3454
  * - Its tile and raycast halves are 2D only so they default off here, and a child sits solid collision out
3238
3455
  * - A sync2D object collides in 2D instead, which needs the 2D size set as well as size3D
3239
3456
  * - setMesh swaps the mesh and frees the old one, for text and terrain that get built again
@@ -3281,7 +3498,7 @@ class EngineObject3D extends EngineObject
3281
3498
  /** @property {Vector3} - Added to pos3D each frame by the engine before update, like the 2D velocity, no super
3282
3499
  * call needed; damping and render3D.gravity act on it once the object has a mass */
3283
3500
  this.velocity3D = vec3();
3284
- /** @property {Vector3} - Added to rotation3D each frame by the engine before update, angleDamping is 2D only */
3501
+ /** @property {Vector3} - Added to rotation3D each frame by the engine before update, slowed by angleDamping */
3285
3502
  this.angleVelocity3D = vec3();
3286
3503
  /** @property {Mesh|undefined} - Mesh to draw
3287
3504
  * @type {Mesh|undefined} */
@@ -3312,8 +3529,27 @@ class EngineObject3D extends EngineObject
3312
3529
  * lamps and glowing things, between is partly self lit, and above 1 is brighter than its color, for bloom */
3313
3530
  this.emissive = 0;
3314
3531
  /** @property {number} - Strength of the highlight where the sun and the Light3D objects reflect, 0 is none and
3315
- * 1 adds a light's full color at its brightest; its size is fixed */
3532
+ * 1 adds a light's full color at its brightest; shininess sets its size */
3316
3533
  this.specular = 0;
3534
+ /** @property {number} - The highlight's exponent, how small and sharp it is: 4 is broad like rubber, 16 the
3535
+ * default, 100 sharp like polished metal; shows only with specular above 0 */
3536
+ this.shininess = 16;
3537
+ /** @property {TextureInfo|undefined} - A normal map that bends the surface at each texel so it catches the
3538
+ * light like bumps and grooves, green pointing up the image as OpenGL and glTF have it; read at the color
3539
+ * texture's coordinates, see normalMapFromHeight to make one in code
3540
+ * @type {TextureInfo|undefined} */
3541
+ this.normalMap = undefined;
3542
+ /** @property {number} - How strongly the normal map bends the surface, 0 turns it off, as glTF's scale */
3543
+ this.normalScale = 1;
3544
+ /** @property {number} - How much it reflects the sky, 0 none and 1 a mirror of it; the edges seen at a
3545
+ * glancing angle reflect more either way, as water and glass do */
3546
+ this.reflectivity = 0;
3547
+ /** @property {TextureInfo|undefined} - A texture of where it glows, added on top of the lit surface so it
3548
+ * shows in the dark, like lit windows; read at the color texture's coordinates
3549
+ * @type {TextureInfo|undefined} */
3550
+ this.emissiveMap = undefined;
3551
+ /** @property {Color} - Multiplies the emissive map, as glTF's emissiveFactor */
3552
+ this.emissiveMapColor = WHITE;
3317
3553
  /** @property {boolean} - Draw into the shadow map when render3D.shadows is on; sprites and cut out textures
3318
3554
  * cast their outline, an object faded below half its alpha casts nothing, a see through one casts only when
3319
3555
  * textured, and additive objects never cast */
@@ -3321,6 +3557,9 @@ class EngineObject3D extends EngineObject
3321
3557
  /** @property {boolean} - Collide as the sphere that fits size3D instead of as the size3D box, so it rolls
3322
3558
  * around corners */
3323
3559
  this.collideAsSphere3D = false;
3560
+ /** @property {number} - The steepest slope it stands on, in radians from level, PI/4 by default: resting on
3561
+ * a solid within this of flat sets groundObject and holds it still, steeper it slides down */
3562
+ this.groundAngle = PI / 4;
3324
3563
  /** @property {boolean} - Darkened by the shadow map when render3D.shadows is on */
3325
3564
  this.receiveShadow = true;
3326
3565
  /** @property {boolean|undefined} - Draw this object over the 2D scene, undefined uses render3D.renderAfter2D
@@ -3351,10 +3590,32 @@ class EngineObject3D extends EngineObject
3351
3590
  'a sync2D object collides in 2D, so give it a 2D size as well as a size3D', this.size);
3352
3591
  if (this.sync2D)
3353
3592
  super.updatePhysics();
3593
+ ASSERT(isNumber(this.groundAngle) && this.groundAngle >= 0 && this.groundAngle < PI / 2,
3594
+ 'groundAngle must be 0 to less than PI/2, a slope from level', this.groundAngle);
3595
+ // what it stands on is found again each frame, by the level and by the solids it rests on; a sync2D
3596
+ // object's is the 2D physics'
3597
+ const ground = this.groundObject;
3598
+ this.sync2D || (this.groundObject = undefined);
3599
+ if (this.clampSpeed && !this.sync2D && (this.collideSolidObjects || this.collideLevel && this.mass))
3600
+ {
3601
+ // each axis within objectMaxSpeed, as in 2D, so a fast object does not pass through a thin wall; only
3602
+ // for what collides, anything else moves as fast as it is told
3603
+ const v = this.velocity3D, s = objectMaxSpeed;
3604
+ v.x = clamp(v.x, -s, s), v.y = clamp(v.y, -s, s), v.z = clamp(v.z, -s, s);
3605
+ }
3354
3606
  // a moving object keeps out of the level, the height maps and voxel maps, from where it was before it moved
3355
3607
  const oldPos = this.collideLevel && this.mass && !this.sync2D ? this.pos3D.copy() : undefined;
3356
3608
  render3DMove(this);
3357
- oldPos && render3DCollideLevel(this, oldPos);
3609
+ if (ground && this.mass && !this.sync2D)
3610
+ {
3611
+ // sliding on what it stood on slows by friction, the less grippy of the two, relative to that one's own
3612
+ // speed so a moving platform carries it, as in 2D
3613
+ const friction = max(this.friction, ground.friction), v = this.velocity3D;
3614
+ const moving = ground instanceof EngineObject3D ? ground.velocity3D : undefined;
3615
+ const gx = moving?.x ?? 0, gz = moving?.z ?? 0;
3616
+ v.x = gx + (v.x - gx) * friction, v.z = gz + (v.z - gz) * friction;
3617
+ }
3618
+ oldPos && render3DCollideLevel(this, oldPos, ground);
3358
3619
  // the engine only runs this for objects that own where they are, a child rides along with its parent
3359
3620
  if (this.collideSolidObjects && !this.sync2D)
3360
3621
  render3DCollideSolid(this);
@@ -3387,7 +3648,7 @@ class EngineObject3D extends EngineObject
3387
3648
 
3388
3649
  /** Set how this object collides, the same flags as in 2D
3389
3650
  * - Solid collision happens in 3D here, against size3D boxes or spheres; a child sits it out
3390
- * - The boxes are axis aligned in the world, rotation3D is ignored
3651
+ * - The boxes turn with rotation3D, against height maps and voxel maps an object is still its upright box
3391
3652
  * - A sync2D object collides in 2D instead, against the 2D size, so set that as well as size3D
3392
3653
  * @param {boolean} [collideSolidObjects] - Take part in solid collision
3393
3654
  * @param {boolean} [isSolid] - Block other objects, a pair where neither one blocks passes through;
@@ -3575,27 +3836,29 @@ function render3DObjectMatrix(o)
3575
3836
  return o.worldMatrix;
3576
3837
  }
3577
3838
 
3578
- // move an object by its 3D velocities, an object with mass falling with render3D.gravity and slowing by its damping
3839
+ // move an object by its 3D velocities, each slowed by its damping, an object with mass falling with render3D.gravity
3579
3840
  function render3DMove(o)
3580
3841
  {
3581
3842
  // the vectors change in place, as the 2D object's do: this runs for every object every frame
3843
+ // a sync2D object's damping is the 2D physics', its 3D velocities are its own to set
3582
3844
  const p = o.pos3D, v = o.velocity3D, r = o.rotation3D, a = o.angleVelocity3D;
3845
+ const d = o.sync2D ? 1 : o.damping, e = o.sync2D ? 1 : o.angleDamping;
3846
+ // damped first and gravity added after, the order EngineObject.updatePhysics uses,
3847
+ // so the same mass, damping and gravity fall the same way in both
3848
+ v.x *= d, v.y *= d, v.z *= d;
3583
3849
  if (o.mass && !o.sync2D) // a 2D driven object gets the 2D gravity instead
3584
3850
  {
3585
- // damped first and gravity added after, the order EngineObject.updatePhysics uses,
3586
- // so the same mass, damping and gravity fall the same way in both
3587
- const g = render3D.gravity, s = o.gravityScale, d = o.damping;
3588
- v.x = v.x * d + g.x * s, v.y = v.y * d + g.y * s, v.z = v.z * d + g.z * s;
3851
+ const g = render3D.gravity, s = o.gravityScale;
3852
+ v.x += g.x * s, v.y += g.y * s, v.z += g.z * s;
3589
3853
  }
3590
3854
  p.x += v.x, p.y += v.y, p.z += v.z;
3591
- r.x += a.x, r.y += a.y, r.z += a.z;
3855
+ r.x += a.x *= e, r.y += a.y *= e, r.z += a.z *= e;
3592
3856
  }
3593
3857
 
3594
3858
  // keep an object that moved out of the level's solid geometry, clearing what it stood on for the level to set again
3595
- function render3DCollideLevel(o, oldPos)
3859
+ // ground is what it stood on last frame, which a height map keeps it on going down a slope
3860
+ function render3DCollideLevel(o, oldPos, ground)
3596
3861
  {
3597
- const ground = o.groundObject;
3598
- o.groundObject = undefined;
3599
3862
  for (const level of render3DLevel)
3600
3863
  level.destroyed || level.levelCollide3D(o, oldPos, level === ground);
3601
3864
  }
@@ -3612,7 +3875,23 @@ function render3DSolidShape(o)
3612
3875
  const kx = abs(k.x), ky = abs(k.y), kz = abs(k.z);
3613
3876
  if (o.collideAsSphere3D)
3614
3877
  return {pos: o.pos3D.copy(), radius: max(s.x, s.y, s.z) / 2 * max(kx, ky, kz)};
3615
- return {pos: o.pos3D.copy(), size: vec3(s.x * kx, s.y * ky, s.z * kz)};
3878
+ // a turned box has its axes, a solid is never a child so its rotation is the world's; an upright one has none, and
3879
+ // neither does a sprite, whose rotation turns how it faces the camera, not its box
3880
+ const axes = !render3DIsSprite(o) && isTurned3D(o.rotation3D) ? render3DSolidAxes(o) : undefined;
3881
+ return {pos: o.pos3D.copy(), size: vec3(s.x * kx, s.y * ky, s.z * kz), axes};
3882
+ }
3883
+
3884
+ // a turned solid's three axes, worked out when its rotation changes and kept until then, so the pairs it is tested
3885
+ // against each frame do not each work them out again
3886
+ const render3DSolidAxesCache = new WeakMap;
3887
+ function render3DSolidAxes(o)
3888
+ {
3889
+ const r = o.rotation3D, kept = render3DSolidAxesCache.get(o);
3890
+ if (kept && kept.x === r.x && kept.y === r.y && kept.z === r.z)
3891
+ return kept.axes;
3892
+ const axes = boxAxes3D(r);
3893
+ render3DSolidAxesCache.set(o, {x: r.x, y: r.y, z: r.z, axes});
3894
+ return axes;
3616
3895
  }
3617
3896
 
3618
3897
  // how far a solid shape can reach from its own center, for a quick reject before the exact test
@@ -3630,15 +3909,30 @@ function render3DSolidReach(o)
3630
3909
  // what it takes to move shape a clear of shape b, whichever pair of shapes they are, or undefined for no touch
3631
3910
  function render3DSolidPush(a, b)
3632
3911
  {
3912
+ // a turned box is tested by its kept axes, the upright pairs as they always were
3913
+ const sphereBox = (sphere, box)=> box.axes ? collideSphereOrientedBox3D(sphere.pos, sphere.radius, box.pos,
3914
+ box.size, box.axes) : collideSphereBox(sphere.pos, sphere.radius, box.pos, box.size);
3633
3915
  if (!a.size) // a is a sphere
3634
- return b.size ? collideSphereBox(a.pos, a.radius, b.pos, b.size)
3635
- : collideSphereSphere(a.pos, a.radius, b.pos, b.radius);
3916
+ return b.size ? sphereBox(a, b) : collideSphereSphere(a.pos, a.radius, b.pos, b.radius);
3636
3917
  if (!b.size) // only b is, so push b out of a and turn it around
3637
3918
  {
3638
- const push = collideSphereBox(b.pos, b.radius, a.pos, a.size);
3919
+ const push = sphereBox(b, a);
3639
3920
  return push && push.scale(-1);
3640
3921
  }
3641
- return collideBoxBox3D(a.pos, a.size, b.pos, b.size);
3922
+ return a.axes || b.axes ? collideOrientedBoxes3D(a.pos, a.size, a.axes ?? BOX_WORLD_AXES, b.pos, b.size,
3923
+ b.axes ?? BOX_WORLD_AXES) : collideBoxBox3D(a.pos, a.size, b.pos, b.size);
3924
+ }
3925
+
3926
+ // a sprite, a tile with no mesh, which faces the camera however it is turned
3927
+ const render3DIsSprite = (o)=> !o.mesh && !!o.tileInfo;
3928
+
3929
+ // whether a push leaves a box by one of its faces, along one of its axes, not by an edge or a corner; a sphere has
3930
+ // no face to stand on
3931
+ function render3DOnFace(push, shape)
3932
+ {
3933
+ if (!shape.size) return false;
3934
+ const n = push.normalize(), axes = shape.axes ?? BOX_WORLD_AXES;
3935
+ return max(abs(n.dot(axes[0])), abs(n.dot(axes[1])), abs(n.dot(axes[2]))) > 1 - 1e-6;
3642
3936
  }
3643
3937
 
3644
3938
  // push a solid object out of the solids before it in the engine's list of them, so each pair is resolved once:
@@ -3664,7 +3958,8 @@ function render3DCollideSolid(a)
3664
3958
  if (dx*dx + dy*dy + dz*dz > reach*reach)
3665
3959
  continue;
3666
3960
 
3667
- const push = render3DSolidPush(shapeA, render3DSolidShape(b));
3961
+ const shapeB = render3DSolidShape(b);
3962
+ let push = render3DSolidPush(shapeA, shapeB);
3668
3963
  if (!push) continue;
3669
3964
 
3670
3965
  // both objects hear about it, and either one can take the touch over
@@ -3672,6 +3967,19 @@ function render3DCollideSolid(a)
3672
3967
  const resolveB = b.collideWithObject(a, push.scale(-1));
3673
3968
  if (!resolveA || !resolveB) continue;
3674
3969
 
3970
+ // standing: resting on a box's face within the upper one's groundAngle of level holds it there, the push
3971
+ // turned straight up, as far as it takes to leave the surface, so it does not creep down a ramp; only what
3972
+ // moves stands, and a sphere, an edge or a corner is nothing to stand on, what rests there rolls off
3973
+ const lengthSquared = push.lengthSquared(), up = push.y / lengthSquared ** .5;
3974
+ const aStands = up > 0 && a.mass && up >= cos(a.groundAngle) && render3DOnFace(push, shapeB);
3975
+ const bStands = up < 0 && b.mass && -up >= cos(b.groundAngle) && render3DOnFace(push, shapeA);
3976
+ if (aStands || bStands)
3977
+ {
3978
+ aStands ? a.groundObject = b : b.groundObject = a;
3979
+ if (push.x || push.z) // one straight up already is used as it is
3980
+ push = vec3(0, lengthSquared / push.y, 0);
3981
+ }
3982
+
3675
3983
  // heavier objects move less, mass 0 stays put; then bounce apart when moving toward each other
3676
3984
  const total = a.mass + b.mass;
3677
3985
  const weightA = !a.mass ? 0 : !b.mass ? 1 : b.mass / total;
@@ -3692,8 +4000,8 @@ function render3DCollideSolid(a)
3692
4000
 
3693
4001
  /**
3694
4002
  * Collect the EngineObject3D objects whose boxes overlap a sphere or a box, the 3D twin of engineObjectsCollect
3695
- * - Boxes are axis aligned around the world position, rotation3D is ignored; lights, emitters and trails have no size
3696
- * and are never collected
4003
+ * - Each object is its size3D box at its world position, turned as it is turned; lights, emitters and trails have no
4004
+ * size and are never collected
3697
4005
  * @param {Vector3} pos - Center of the area
3698
4006
  * @param {Vector3|number} size - Diameter of a sphere if a number, 0 for a point, full size of a box if a Vector3
3699
4007
  * @param {Array<EngineObject>} [objects] - Defaults to every object
@@ -3715,9 +4023,27 @@ function engineObjectsCollect3D(pos, size, objects=engineObjects, testCenters=fa
3715
4023
  if (!(s.x || s.y || s.z)) continue;
3716
4024
  const center = vec3(m[12], m[13], m[14]);
3717
4025
  const worldSize = testCenters ? vec3() : render3DWorldSize(o, m);
4026
+ // a turned box is tested as turned, its axes read off the matrix, its columns with the scale taken out
4027
+ const turned = !testCenters && !render3DIsSprite(o) && (m[1] || m[2] || m[4] || m[6] || m[8] || m[9]);
4028
+ const axis = (i)=> vec3(m[i], m[i+1], m[i+2]).normalize();
4029
+ let axes = turned ? [axis(0), axis(4), axis(8)] : undefined;
4030
+ if (axes && (abs(axes[0].dot(axes[1])) > 1e-6 || abs(axes[0].dot(axes[2])) > 1e-6 ||
4031
+ abs(axes[1].dot(axes[2])) > 1e-6))
4032
+ {
4033
+ // a turned child of an unevenly scaled parent is sheared, its edges no longer square, which the box
4034
+ // tests do not take; the upright box around it stands in, which may take a little more but never misses
4035
+ const e = axes.map((a, i)=> a.scale(i ? i > 1 ? worldSize.z / 2 : worldSize.y / 2 : worldSize.x / 2));
4036
+ const reach = (c)=> 2 * (abs(e[0][c]) + abs(e[1][c]) + abs(e[2][c]));
4037
+ worldSize.set(reach('x'), reach('y'), reach('z'));
4038
+ axes = undefined;
4039
+ }
3718
4040
  let hit;
3719
- if (box)
4041
+ if (box && axes)
4042
+ hit = !!collideOrientedBoxes3D(pos, box, BOX_WORLD_AXES, center, worldSize, axes);
4043
+ else if (box)
3720
4044
  hit = isOverlapping3D(pos, box, center, worldSize);
4045
+ else if (axes)
4046
+ hit = !!collideSphereOrientedBox3D(pos, radiusSquared ** .5, center, worldSize, axes);
3721
4047
  else
3722
4048
  {
3723
4049
  // a sphere against the nearest point of the box
@@ -4040,10 +4366,53 @@ class Light3D extends EngineObject3D
4040
4366
  /** @property {boolean} - Shine from far away, from its position toward the origin, instead of out from its
4041
4367
  * position with a falloff; DirectionalLight3D sets it */
4042
4368
  this.directional = false;
4369
+ /** @property {number} - Size of a soft hazy glow drawn over the light, like a lamp at night, 0 for none; it
4370
+ * is added onto what is behind it, and what is in front of the light hides it */
4371
+ this.glow = 0;
4372
+ /** @property {number} - How fast the glow fades from its middle: 1 by default, .5 a wide haze, 2 a tight
4373
+ * bright core */
4374
+ this.glowFalloff = 1;
4375
+ this.additive = true; // the glow is added on, in the transparent stage; a light with none draws nothing
4043
4376
  }
4044
4377
 
4045
- /** Lights draw nothing */
4046
- render3D() {}
4378
+ /** Draw the glow, a quad facing the camera with a soft round glow on it, pulled toward the camera by half its
4379
+ * size so a lamp at the light does not cut into it, but never past the camera */
4380
+ render3D()
4381
+ {
4382
+ if (!(this.glow > 0) || this.directional) return;
4383
+ const r = render3D, c = this.color, pos = render3DObjectMatrix(this).getTranslation();
4384
+ const toCamera = r.camera.pos.subtract(pos), distance = toCamera.length();
4385
+ const at = distance ? pos.add(toCamera.scale(min(this.glow, distance) / 2 / distance)) : pos;
4386
+ const color = rgb(c.r, c.g, c.b, c.a * min(this.intensity, 1));
4387
+ r.drawBillboard(at, vec2(this.glow), render3DGlow(this.glowFalloff), color);
4388
+ }
4389
+ }
4390
+
4391
+ // how strong a light's glow is at a distance from its middle, 0 there to 1 at the edge: a bell, full in the middle
4392
+ // and nothing at the edge, fading faster the higher the falloff
4393
+ function render3DGlowAlpha(r, falloff)
4394
+ {
4395
+ const k = 3.5 * falloff, edge = Math.exp(-k);
4396
+ return (Math.exp(-k * r * r) - edge) / (1 - edge);
4397
+ }
4398
+
4399
+ // the soft round glow of the lights, one texture for each falloff, rounded to a tenth so a changing falloff makes
4400
+ // only a few, each made once from a canvas; undefined headless or without a canvas
4401
+ const render3DGlowTextures = new Map;
4402
+ function render3DGlow(falloff=1)
4403
+ {
4404
+ ASSERT(isNumber(falloff) && falloff > 0, 'glowFalloff must be a number above 0');
4405
+ const key = max(round(falloff * 10), 1) / 10;
4406
+ let texture = render3DGlowTextures.get(key);
4407
+ if (texture || !glContext || typeof OffscreenCanvas == 'undefined') return texture;
4408
+ const size = 64, context = createCanvasContext(size), steps = 16;
4409
+ const gradient = context.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
4410
+ for (let i = 0; i <= steps; ++i)
4411
+ gradient.addColorStop(i / steps, 'rgba(255,255,255,' + render3DGlowAlpha(i / steps, key).toFixed(4) + ')');
4412
+ context.fillStyle = gradient;
4413
+ context.fillRect(0, 0, size, size);
4414
+ render3DGlowTextures.set(key, texture = new TextureInfo(context.canvas));
4415
+ return texture;
4047
4416
  }
4048
4417
 
4049
4418
  ///////////////////////////////////////////////////////////////////////////////