littlejsengine 1.20.0 → 1.22.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.
@@ -38,6 +38,54 @@ function render3DSoftDot()
38
38
  return render3DSoftDotTexture = new TextureInfo(context.canvas);
39
39
  }
40
40
 
41
+ /** Make a normal map from a height at each pixel, for bumps and grooves with no image file: the slope at each pixel
42
+ * from its neighbors' heights, taken around the edges so the map tiles; set it as an object's normalMap
43
+ * @param {Vector2} size - In pixels
44
+ * @param {function(number, number): number} heightFunction - The height 0 to 1 at a pixel, x across and y down
45
+ * @param {number} [strength] - How steep the slopes are: a height change of 1 over one pixel leans the normal
46
+ * by strength
47
+ * @return {TextureInfo} - Wraps; headless it has no image
48
+ * @memberof Render3D */
49
+ function normalMapFromHeight(size, heightFunction, strength=1)
50
+ {
51
+ ASSERT(isVector2(size) && size.x >= 1 && size.y >= 1, 'normalMapFromHeight size must be a Vector2 of pixels');
52
+ const width = size.x | 0, height = size.y | 0;
53
+ if (typeof OffscreenCanvas == 'undefined')
54
+ {
55
+ // headless, nothing to draw into; the right size so what reads it still works
56
+ const textureInfo = new TextureInfo(undefined, false, true);
57
+ textureInfo.size = vec2(width, height), textureInfo.sizeInverse = vec2(1 / width, 1 / height);
58
+ return textureInfo;
59
+ }
60
+ const context = createCanvasContext(width, height);
61
+ const pixels = render3DNormalMapPixels(width, height, heightFunction, strength);
62
+ context.putImageData(new ImageData(pixels, width, height), 0, 0);
63
+ return new TextureInfo(context.canvas, true, true);
64
+ }
65
+
66
+ // the pixels of a normal map made from heights, rgba with the top row first: each normal leans away from the uphill
67
+ // side, green up the image as OpenGL has it, and a flat height is (128, 128, 255); rounded before they are stored,
68
+ // since the clamped array would round a flat 127.5 to even
69
+ function render3DNormalMapPixels(width, height, heightFunction, strength)
70
+ {
71
+ const heights = new Float32Array(width * height), pixels = new Uint8ClampedArray(width * height * 4);
72
+ for (let y = 0; y < height; ++y)
73
+ for (let x = 0; x < width; ++x)
74
+ heights[y * width + x] = heightFunction(x, y);
75
+ const h = (x, y)=> heights[mod(y, height) * width + mod(x, width)];
76
+ for (let y = 0; y < height; ++y)
77
+ for (let x = 0; x < width; ++x)
78
+ {
79
+ const dx = (h(x+1, y) - h(x-1, y)) / 2 * strength, dy = (h(x, y+1) - h(x, y-1)) / 2 * strength;
80
+ const k = 1 / hypot(dx, dy, 1), i = (y * width + x) * 4;
81
+ pixels[i] = round((-dx * k * .5 + .5) * 255);
82
+ pixels[i+1] = round((dy * k * .5 + .5) * 255);
83
+ pixels[i+2] = round((k * .5 + .5) * 255);
84
+ pixels[i+3] = 255;
85
+ }
86
+ return pixels;
87
+ }
88
+
41
89
  ///////////////////////////////////////////////////////////////////////////////
42
90
  // The rest of the shape builders: buildLathe, buildSphere, buildBox, buildGrid and buildSky live with the
43
91
  // renderer, since it hands those out itself
@@ -574,6 +622,22 @@ class HeightMap extends EngineObject3D
574
622
  * @ignore */
575
623
  levelRaycast3D(ray) { return this.raycast(ray); }
576
624
 
625
+ /** Where a short move goes under the surface, for a particle's move in one frame: the part of the move made
626
+ * before it, 0 to 1, and the surface normal there; undefined when it stays above, starts under, or is off the map
627
+ * @param {Vector3} from
628
+ * @param {Vector3} to
629
+ * @return {{distance: number, normal: Vector3}|undefined}
630
+ * @ignore */
631
+ levelSegment3D(from, to)
632
+ {
633
+ const m = this.pos3D, size = this.mapSize;
634
+ if (abs(to.x - m.x) > size.x / 2 || abs(to.z - m.z) > size.y / 2) return; // off the map
635
+ const a = from.y - this.getHeight(from.x, from.z), b = to.y - this.getHeight(to.x, to.z);
636
+ if (a < 0 || b >= 0) return; // above all the way, or under from the start
637
+ const distance = a / (a - b);
638
+ return {distance, normal: this.getNormal(from.x + (to.x - from.x) * distance, from.z + (to.z - from.z) * distance)};
639
+ }
640
+
577
641
  /** Keep an object above the ground, called by the engine for each object with collideLevel
578
642
  * @param {EngineObject3D} o
579
643
  * @param {Vector3} oldPos - Where it was before it moved
@@ -762,7 +826,7 @@ class FirstPersonCamera3D extends EngineObject3D
762
826
  /** @property {boolean} - Capture the mouse on a click, so looking needs no button held */
763
827
  this.lockPointer = true;
764
828
  /** @property {number} - Speed of a jump in world units a frame, 0 for none; Space or gamepad button 0 jumps
765
- * while it stands on something, a height map or voxel map it collides with, see collideLevel */
829
+ * while it stands on something, a height map, a voxel map or a solid, see groundObject */
766
830
  this.jumpSpeed = 0;
767
831
  /** @property {number} - How far above its position the eye is, in its own space, so with a size3D the eye can
768
832
  * sit toward the top of the body instead of its middle; keep it under half the body's height, or the eye is
@@ -833,6 +897,51 @@ class FirstPersonCamera3D extends EngineObject3D
833
897
  const RENDER3D_PARTICLE_FLOATS = 21;
834
898
  // the position, color and size of the particle being drawn, shared by every emitter so the draw loop makes no objects
835
899
  const render3DParticlePos = vec3(), render3DParticleColor = new Color, render3DParticleSize = vec2();
900
+ // where a particle was and is, for the level collision
901
+ const render3DParticleFrom = vec3(), render3DParticleTo = vec3();
902
+
903
+ // the emitters inside a particle callback now, whose nested calls each get a view of their own
904
+ const render3DParticlesCalling = new Set;
905
+
906
+ // a particle as a ParticleEmitter3D's callbacks see it, set from the particle for each call
907
+ /** @param {ParticleEmitter3D} emitter
908
+ * @return {Particle3D} */
909
+ function render3DParticleView(emitter)
910
+ {
911
+ return {emitter, pos: vec3(), velocity: vec3(), age: 0, lifeTime: 0, scale: 1, destroyed: false,
912
+ destroy() { this.destroyed = true; }};
913
+ }
914
+
915
+ /**
916
+ * A particle as a ParticleEmitter3D's callbacks see it: one object the emitter reuses, set from the particle for each
917
+ * call and written back after it, so copy what you keep
918
+ * @typedef {Object} Particle3D
919
+ * @property {ParticleEmitter3D} emitter - The emitter it is in
920
+ * @property {Vector3} pos - Where it is, change it to move it
921
+ * @property {Vector3} velocity - How far it moves each frame
922
+ * @property {number} age - Seconds it has lived
923
+ * @property {number} lifeTime - Seconds it lives
924
+ * @property {number} scale - How much the emitter grows its effect, from its scale3D and its parents', as the 2D
925
+ * particle's scale
926
+ * @property {boolean} destroyed - Set by destroy
927
+ * @property {function(): void} destroy - End it this update, the destroy callback gets it
928
+ * @memberof Render3D
929
+ */
930
+
931
+ /**
932
+ * @callback Particle3DCallback - A function a ParticleEmitter3D calls with one of its particles
933
+ * @param {Particle3D} particle
934
+ * @memberof Render3D
935
+ */
936
+
937
+ /**
938
+ * @callback Particle3DCollideCallback - Decides whether a particle stops where it hits the level, a filter as in 2D
939
+ * @param {Particle3D} particle
940
+ * @param {EngineObject3D} level - The HeightMap or VoxelMap it hit
941
+ * @param {Vector3} pos - Where it hit
942
+ * @return {boolean|void} - true to stop it there; a callback that returns nothing lets it pass through
943
+ * @memberof Render3D
944
+ */
836
945
 
837
946
  /**
838
947
  * ParticleEmitter3D - Spawns camera facing particles, the 3D twin of ParticleEmitter
@@ -843,9 +952,14 @@ const render3DParticlePos = vec3(), render3DParticleColor = new Color, render3DP
843
952
  * - emitConeAngle spreads them, PI sprays in every direction
844
953
  * - Speeds are per frame and sizes are world units, the same as the 2D emitter
845
954
  * - scale3D, its own or a parent's, grows the whole effect: the spawn area, the sizes, the speed and the fall
846
- * - gravity here is its own number added to velocity y each frame: it is neither the engine's 2D
847
- * gravity nor render3D.gravity, so an effect keeps its own fall wherever it is used
955
+ * - gravity here is its own number added to velocity y each frame, so an effect keeps its own fall wherever it is
956
+ * used, the same as the 2D emitter's gravity; gravityScale adds a share of render3D.gravity on top, as the 2D
957
+ * emitter's gravityScale adds the engine's gravity
848
958
  * - An emitter with an emitTime destroys itself once its last particle is gone, like the 2D emitter
959
+ * - Callbacks as the 2D emitter's: particleCreateCallback, particleUpdateCallback, particleCollideCallback and
960
+ * particleDestroyCallback, each given a Particle3D, one object the emitter reuses for every particle and call
961
+ * - collideLevel, off by default, has particles hit the height maps and voxel maps, bouncing by restitution and
962
+ * sliding by friction
849
963
  * @extends EngineObject3D
850
964
  * @memberof Render3D
851
965
  * @example
@@ -870,8 +984,8 @@ class ParticleEmitter3D extends EngineObject3D
870
984
  * @param {number} [sizeEnd] - Particle size at end of life
871
985
  * @param {number} [speed] - Spawn speed in world units per frame
872
986
  * @param {number} [damping] - Per frame velocity multiplier, 1 is none
873
- * @param {number} [gravity] - Per frame change to velocity y, negative pulls down; its own number,
874
- * not render3D.gravity, so the 2D emitter's gravityScale has no equivalent here
987
+ * @param {number} [gravity] - Per frame change to velocity y, negative pulls down; its own number, and
988
+ * gravityScale adds a share of render3D.gravity on top
875
989
  * @param {number} [fadeRate] - Fraction of life spent fading, half in and half out
876
990
  * @param {number} [randomness] - Extra randomness applied to speed, size and life
877
991
  * @param {boolean} [additive] - Additive blending */
@@ -886,6 +1000,9 @@ class ParticleEmitter3D extends EngineObject3D
886
1000
 
887
1001
  /** @property {number|Vector3} - Spawn area, a number for a sphere diameter or a vec3 for a box */
888
1002
  this.emitSize = emitSize;
1003
+ /** @property {boolean} - Flatten the spawn area across the way it emits, its own up: a sphere becomes a disc
1004
+ * and a box a flat rectangle, for rain from a sheet of sky or flames from a patch of ground */
1005
+ this.emitFlat = false;
889
1006
  /** @property {number} - How long to keep emitting, 0 is forever */
890
1007
  this.emitTime = emitTime;
891
1008
  /** @property {number} - Particles per second, 0 does not emit */
@@ -912,13 +1029,17 @@ class ParticleEmitter3D extends EngineObject3D
912
1029
  this.damping = damping;
913
1030
  /** @property {number} - Per frame change to velocity y, its own number and not render3D.gravity */
914
1031
  this.gravity = gravity;
1032
+ /** @property {number} - Share of render3D.gravity added each frame on top of its own gravity, 0 by default so
1033
+ * an effect keeps its own fall wherever it is used; 1 falls with the world, like debris */
1034
+ this.gravityScale = 0;
915
1035
  /** @property {number} - Fraction of life spent fading, half in and half out */
916
1036
  this.fadeRate = fadeRate;
917
1037
  /** @property {number} - Extra randomness applied to speed, size and life */
918
1038
  this.randomness = randomness;
919
1039
  /** @property {boolean} - Additive blending */
920
1040
  this.additive = additive;
921
- /** @property {number} - Seconds of each particle's path to draw as a ribbon behind it, 0 draws billboards */
1041
+ /** @property {number} - Seconds of each particle's path to draw as a ribbon behind it, 0 draws billboards;
1042
+ * with engineVariableStep it keeps trailTime * frameRate updates of path */
922
1043
  this.trailTime = 0;
923
1044
  /** @property {number} - Radians per frame each particle turns in the camera plane, either way; 0 is no spin */
924
1045
  this.angleSpeed = 0;
@@ -934,6 +1055,25 @@ class ParticleEmitter3D extends EngineObject3D
934
1055
  this.trailData = undefined;
935
1056
  /** @property {number} - Trail points kept per particle, from trailTime */
936
1057
  this.trailMax = 0;
1058
+ /** @property {boolean} - Particles hit the level, the height maps and voxel maps, bouncing by restitution and
1059
+ * sliding along by friction; off by default, it tests each particle's move against the level every frame */
1060
+ this.collideLevel = false;
1061
+ /** @property {Particle3DCallback|undefined} - Called with each particle as it is made
1062
+ * @type {Particle3DCallback|undefined} */
1063
+ this.particleCreateCallback = undefined;
1064
+ /** @property {Particle3DCallback|undefined} - Called with each particle each update, after it moves
1065
+ * @type {Particle3DCallback|undefined} */
1066
+ this.particleUpdateCallback = undefined;
1067
+ /** @property {Particle3DCollideCallback|undefined} - Decides if a particle stops where it hits the level,
1068
+ * with collideLevel on; a callback that returns nothing lets it through
1069
+ * @type {Particle3DCollideCallback|undefined} */
1070
+ this.particleCollideCallback = undefined;
1071
+ /** @property {Particle3DCallback|undefined} - Called with each particle as it goes, its life over or destroyed
1072
+ * @type {Particle3DCallback|undefined} */
1073
+ this.particleDestroyCallback = undefined;
1074
+ /** @property {Particle3D} - The particle the callbacks get, one object for every particle and call
1075
+ * @type {Particle3D} */
1076
+ this.particleView = render3DParticleView(this);
937
1077
  /** @property {Vector3|undefined} - Where the emitter was at its last update, for when its parent is destroyed
938
1078
  * @type {Vector3|undefined} */
939
1079
  this.worldPos3D = undefined;
@@ -947,6 +1087,7 @@ class ParticleEmitter3D extends EngineObject3D
947
1087
  const matrix = render3DObjectMatrix(this); // the object's own, read only
948
1088
  this.worldPos3D = matrix.getTranslation(); // remembered for when the parent is destroyed
949
1089
  const scale = render3DMaxScale(matrix.m);
1090
+ this.particleView.scale = scale; // the callbacks read it from the view instead of working it out each time
950
1091
 
951
1092
  // emit until the emit time is up, then wait for the last particle and go away
952
1093
  if (!this.emitTime || this.getAliveTime() <= this.emitTime)
@@ -963,8 +1104,9 @@ class ParticleEmitter3D extends EngineObject3D
963
1104
  else if (!this.particleCount)
964
1105
  this.destroy();
965
1106
 
966
- // the trail storage follows trailTime, a change starts every trail over
967
- const trailMax = this.trailTime ? max(1, round(this.trailTime / timeDelta)) : 0;
1107
+ // the trail storage follows trailTime at the fixed rate, a change starts every trail over, so a varying
1108
+ // timeDelta can not; with engineVariableStep a trail keeps that many updates
1109
+ const trailMax = this.trailTime ? max(1, round(this.trailTime * frameRate)) : 0;
968
1110
  if (trailMax !== this.trailMax)
969
1111
  {
970
1112
  this.trailMax = trailMax;
@@ -974,16 +1116,27 @@ class ParticleEmitter3D extends EngineObject3D
974
1116
  }
975
1117
 
976
1118
  // move the particles and drop the dead ones, all in the typed array: this runs for every particle every frame
977
- const F = RENDER3D_PARTICLE_FLOATS, data = this.particleData, trail = this.trailData;
978
- const damping = this.damping, gravity = this.gravity * scale, angleDamping = this.angleDamping; // a bigger effect has to fall faster to keep the same arc
1119
+ // a callback can emit, which grows the arrays when they are full, so they are read again after each one
1120
+ const F = RENDER3D_PARTICLE_FLOATS;
1121
+ let data = this.particleData, trail = this.trailData;
1122
+ const reread = ()=> { data = this.particleData, trail = this.trailData; };
1123
+ // its own fall and a share of the world's, grown by the scale: a bigger effect has to fall faster to keep the
1124
+ // same arc
1125
+ const damping = this.damping, angleDamping = this.angleDamping, g = render3D.gravity, share = this.gravityScale;
1126
+ const gravityX = g.x * share * scale, gravityY = (this.gravity + g.y * share) * scale;
1127
+ const gravityZ = g.z * share * scale;
1128
+ const collideLevel = this.collideLevel && render3DLevel.length;
1129
+ const updateCallback = this.particleUpdateCallback, destroyCallback = this.particleDestroyCallback;
979
1130
  for (let i = this.particleCount; i--;)
980
1131
  {
981
1132
  // damped first and gravity added after, the order the 2D particle uses, so the same
982
1133
  // damping and gravity give the same arc in both
983
1134
  const k = i * F;
984
- const vx = data[k+3] *= damping, vy = data[k+4] = data[k+4] * damping + gravity, vz = data[k+5] *= damping;
1135
+ const vx = data[k+3] = data[k+3] * damping + gravityX, vy = data[k+4] = data[k+4] * damping + gravityY;
1136
+ const vz = data[k+5] = data[k+5] * damping + gravityZ;
985
1137
  data[k] += vx, data[k+1] += vy, data[k+2] += vz;
986
1138
  data[k+18] += data[k+19] *= angleDamping;
1139
+ collideLevel && (this.particleCollide(k, data[k] - vx, data[k+1] - vy, data[k+2] - vz), reread());
987
1140
  const t = i * trailMax * 3;
988
1141
  if (trailMax)
989
1142
  {
@@ -995,9 +1148,11 @@ class ParticleEmitter3D extends EngineObject3D
995
1148
  trail[j] = data[k], trail[j+1] = data[k+1], trail[j+2] = data[k+2];
996
1149
  data[k+20] = n + 1;
997
1150
  }
1151
+ updateCallback && (this.particleCall(updateCallback, k), reread());
998
1152
  if ((data[k+17] += timeDelta) >= data[k+16])
999
1153
  {
1000
1154
  // dead: the last particle takes its slot, trail and all, order does not matter
1155
+ destroyCallback && (this.particleCall(destroyCallback, k), reread());
1001
1156
  const last = --this.particleCount, kl = last * F;
1002
1157
  if (i !== last)
1003
1158
  {
@@ -1026,10 +1181,18 @@ class ParticleEmitter3D extends EngineObject3D
1026
1181
  // the whole effect grows with the emitter, not just the area the particles start in
1027
1182
  const scale = render3DMaxScale(matrix.m);
1028
1183
 
1029
- // spawn offset: inside a box or a sphere
1030
- const size = this.emitSize, box = /** @type {Vector3} */ (size);
1031
- const offset = isVector3(size) ? vec3(rand(-.5, .5) * box.x, rand(-.5, .5) * box.y, rand(-.5, .5) * box.z)
1032
- : randInSphere(/** @type {number} */ (size) / 2);
1184
+ // spawn offset: inside a box or a sphere, or flat across the emitter's up, a flat rectangle or a disc
1185
+ const size = this.emitSize, box = /** @type {Vector3} */ (size), flat = this.emitFlat;
1186
+ let offset;
1187
+ if (isVector3(size))
1188
+ offset = vec3(rand(-.5, .5) * box.x, flat ? 0 : rand(-.5, .5) * box.y, rand(-.5, .5) * box.z);
1189
+ else if (flat)
1190
+ {
1191
+ const disc = randInCircle(/** @type {number} */ (size) / 2);
1192
+ offset = vec3(disc.x, 0, disc.y);
1193
+ }
1194
+ else
1195
+ offset = randInSphere(/** @type {number} */ (size) / 2);
1033
1196
 
1034
1197
  // direction inside the cone around local +Y
1035
1198
  const direction = matrix.transformDirection(randVector3(1, this.emitConeAngle)).normalize();
@@ -1064,6 +1227,60 @@ class ParticleEmitter3D extends EngineObject3D
1064
1227
  data[k+18] = this.angleSpeed ? rand(2*PI) : 0;
1065
1228
  data[k+19] = this.angleSpeed ? this.angleSpeed * random() * randSign() : 0;
1066
1229
  data[k+20] = 0; // trail points
1230
+ this.particleCreateCallback && this.particleCall(this.particleCreateCallback, k);
1231
+ }
1232
+
1233
+ // call a particle callback with the particle at k: the emitter's one Particle3D is set from its numbers, and
1234
+ // what the callback changes is written back; a particle it destroys has lived its life, the update removes it
1235
+ particleCall(callback, k, level, pos)
1236
+ {
1237
+ // a callback that emits runs the create callback inside itself, which gets a view of its own so this one
1238
+ // keeps its particle; what the callback changes is written to the arrays as they are after it, since an
1239
+ // emit into a full emitter grows them
1240
+ const nested = render3DParticlesCalling.has(this);
1241
+ const view = nested ? render3DParticleView(this) : this.particleView, data = this.particleData;
1242
+ view.pos.set(data[k], data[k+1], data[k+2]);
1243
+ view.velocity.set(data[k+3], data[k+4], data[k+5]);
1244
+ view.age = data[k+17], view.lifeTime = data[k+16], view.destroyed = false;
1245
+ render3DParticlesCalling.add(this);
1246
+ let result;
1247
+ try { result = callback(view, level, pos); }
1248
+ finally { nested || render3DParticlesCalling.delete(this); }
1249
+ const out = this.particleData;
1250
+ out[k] = view.pos.x, out[k+1] = view.pos.y, out[k+2] = view.pos.z;
1251
+ out[k+3] = view.velocity.x, out[k+4] = view.velocity.y, out[k+5] = view.velocity.z;
1252
+ if (view.destroyed)
1253
+ out[k+17] = max(out[k+17], out[k+16] - timeDelta); // aged past its life this update
1254
+ return result;
1255
+ }
1256
+
1257
+ // stop the particle at k where its last move went into the level, from where it was: back at the surface, a hair
1258
+ // off it, its speed into it turned around by restitution and its speed along it kept by friction, unless the
1259
+ // collide callback lets it through; one that starts inside is let go, as a 2D one is
1260
+ particleCollide(k, x, y, z)
1261
+ {
1262
+ let data = this.particleData;
1263
+ const from = render3DParticleFrom.set(x, y, z);
1264
+ const to = render3DParticleTo.set(data[k], data[k+1], data[k+2]);
1265
+ let hit, level;
1266
+ for (const l of render3DLevel)
1267
+ {
1268
+ const h = l.destroyed ? undefined : l.levelSegment3D(from, to);
1269
+ if (h && (!hit || h.distance < hit.distance))
1270
+ hit = h, level = l;
1271
+ }
1272
+ if (!hit) return;
1273
+ const point = from.lerp(to, hit.distance);
1274
+ const callback = this.particleCollideCallback;
1275
+ if (callback && (!this.particleCall(callback, k, level, point.copy()) || this.particleView.destroyed))
1276
+ return; // let through, or destroyed by the callback
1277
+ data = this.particleData; // as the callback left it, an emit may have grown it
1278
+
1279
+ const n = hit.normal, v = vec3(data[k+3], data[k+4], data[k+5]), into = n.scale(v.dot(n));
1280
+ const restitution = max(this.restitution, level.restitution), friction = max(this.friction, level.friction);
1281
+ const out = v.subtract(into).scale(friction).subtract(into.scale(restitution)), p = point.add(n.scale(1e-3));
1282
+ data[k] = p.x, data[k+1] = p.y, data[k+2] = p.z;
1283
+ data[k+3] = out.x, data[k+4] = out.y, data[k+5] = out.z;
1067
1284
  }
1068
1285
 
1069
1286
  /** Draw the particles, as flat squares or as streaks when trailTime is set
@@ -1245,6 +1462,195 @@ class Trail3D extends EngineObject3D
1245
1462
  }
1246
1463
  }
1247
1464
 
1465
+ ///////////////////////////////////////////////////////////////////////////////
1466
+ // lens flare
1467
+
1468
+ // the shapes a flare is made of, white on clear on one smooth texture made the first time it is asked for: a soft
1469
+ // glow, a flat disc with a brighter rim, and a ring; undefined headless or without a canvas
1470
+ let render3DFlareTiles;
1471
+ function render3DFlareTile(shape)
1472
+ {
1473
+ if (!render3DFlareTiles)
1474
+ {
1475
+ if (headlessMode || !glContext || typeof OffscreenCanvas == 'undefined') return;
1476
+ const cell = 128, shapes = ['glow', 'disc', 'ring'], context = createCanvasContext(cell * shapes.length, cell);
1477
+ // each shape is how see-through it is from its middle out
1478
+ const alpha =
1479
+ {
1480
+ glow: (t)=> engineGlowAlpha(t, 1),
1481
+ disc: (t)=> t < .8 ? .4 + .1 * t : t < .92 ? .48 + (t - .8) * 3 : (1 - t) / .08 * .84,
1482
+ ring: (t)=> max(0, 1 - abs(t - .86) / .12),
1483
+ };
1484
+ shapes.forEach((shape, i)=>
1485
+ {
1486
+ const x = i * cell + cell / 2, y = cell / 2, r = cell / 2 - 2, steps = 32;
1487
+ const gradient = context.createRadialGradient(x, y, 0, x, y, r);
1488
+ for (let k = 0; k <= steps; ++k)
1489
+ gradient.addColorStop(k / steps, 'rgba(255,255,255,' + clamp(alpha[shape](k / steps)).toFixed(4) + ')');
1490
+ context.fillStyle = gradient;
1491
+ context.beginPath();
1492
+ context.arc(x, y, r, 0, 2 * PI);
1493
+ context.fill();
1494
+ });
1495
+ const texture = new TextureInfo(context.canvas, true, false, false); // smooth even in a pixel art game
1496
+ render3DFlareTiles = new Map(shapes.map((shape, i)=>
1497
+ [shape, new TileInfo(vec2(i * cell, 0), vec2(cell), texture)]));
1498
+ }
1499
+ return render3DFlareTiles.get(shape) || render3DFlareTiles.get('glow');
1500
+ }
1501
+
1502
+ /**
1503
+ * LensFlare3D - The sun's lens flare, the old kind: a glow at the sun and a row of discs and rings of different
1504
+ * sizes along the line from the sun through the middle of the screen
1505
+ * - Make one and it shows, over the 3D scene and under what the game draws after, a HUD; destroy it to take it away
1506
+ * - It follows render3D.sunDirection, and fades out as the sun leaves the screen or goes behind something
1507
+ * - flareSize, count, intensity and saturation set its look, seed picks another arrangement, and its color tints it,
1508
+ * with the sun's own color; or give it elements of your own
1509
+ * - visible is how much of the sun shows, 0 to 1, eased over fadeTime, there for a game to read
1510
+ * - What hides the sun is found with a ray from the camera, against the level and every object that is not see
1511
+ * through, each as the box around its mesh, see render3D.pick; turn it off with occlusion
1512
+ * - It needs WebGL, and it draws nothing in the shadow of renderAfter2D
1513
+ * @extends EngineObject3D
1514
+ * @memberof Render3D
1515
+ * @example
1516
+ * new LensFlare3D; // the sun flares
1517
+ * new LensFlare3D(1.5, 10, .7, 0); // bigger, 10 ghosts, dimmer, all one color
1518
+ */
1519
+ class LensFlare3D extends EngineObject3D
1520
+ {
1521
+ /** Create the sun's lens flare
1522
+ * @param {number} [size] - Scales every part of it, 1 by default
1523
+ * @param {number} [count] - How many ghosts there are along the line, besides the glow at the sun
1524
+ * @param {number} [intensity] - How bright it is
1525
+ * @param {number} [saturation] - How colorful the ghosts are, 0 for all the flare's own color, 1 a rainbow
1526
+ * @param {Color} [color] - Tints the whole flare, with the sun's color */
1527
+ constructor(size=1, count=7, intensity=1, saturation=1, color=WHITE)
1528
+ {
1529
+ super(vec3(), undefined, undefined, color);
1530
+ this.size3D = vec3(); // not a thing to pick or collect
1531
+ /** @property {number} - Scales every part of the flare */
1532
+ this.flareSize = size;
1533
+ /** @property {number} - How many ghosts there are along the line, besides the glow at the sun */
1534
+ this.count = count;
1535
+ /** @property {number} - How bright it is */
1536
+ this.intensity = intensity;
1537
+ /** @property {number} - How colorful the ghosts are, 0 for all the flare's own color, 1 a rainbow */
1538
+ this.saturation = saturation;
1539
+ /** @property {number} - Picks the arrangement of the ghosts, another seed is another flare */
1540
+ this.seed = 1;
1541
+ /** @property {Array<{at: number, size: number, color: Color, shape: string}>|undefined} - The parts of the
1542
+ * flare, to set your own in place of the ones made from count, seed and saturation: at is where along the
1543
+ * line, 0 the sun, 1 the middle of the screen, 2 as far past it; size is across, as a part of the screen's
1544
+ * height; shape is glow, disc or ring
1545
+ * @type {Array<{at: number, size: number, color: Color, shape: string}>|undefined} */
1546
+ this.elements = undefined;
1547
+ /** @property {boolean} - Fade out when something is between the camera and the sun */
1548
+ this.occlusion = true;
1549
+ /** @property {number} - Seconds the flare takes to fade out or in when the sun is hidden or shows again */
1550
+ this.fadeTime = .15;
1551
+ /** @property {number} - How much of the sun shows, 0 hidden or behind the camera to 1 in plain view, eased */
1552
+ this.visible = 1;
1553
+ this.renderOrder = 1e9; // over the game's sprites, it is light in the lens
1554
+ this.madeKey = '';
1555
+ /** @type {Array<{at: number, size: number, color: Color, shape: string}>} */
1556
+ this.made = [];
1557
+ }
1558
+
1559
+ /** The parts of the flare: the elements set by hand, or the ones made from count, seed and saturation, a glow
1560
+ * and a core at the sun and the ghosts, made again when one of those changes
1561
+ * @return {Array<{at: number, size: number, color: Color, shape: string}>} */
1562
+ getElements()
1563
+ {
1564
+ if (this.elements) return this.elements;
1565
+ const key = [this.count, this.seed, this.saturation].join();
1566
+ if (key !== this.madeKey)
1567
+ {
1568
+ const random = new RandomGenerator(this.seed * 7919 + 1), s = clamp(this.saturation);
1569
+ const made = this.made = [
1570
+ {at: 0, size: .7, color: hsl(0, 0, 1, .5), shape: 'glow'},
1571
+ {at: 0, size: .25, color: hsl(0, 0, 1, .9), shape: 'glow'}];
1572
+ for (let i = 0; i < this.count; ++i)
1573
+ {
1574
+ // spread along the line, each a place of its own, the far ones bigger
1575
+ const at = (i + random.float(.2, .8)) / max(this.count, 1) * 1.9 + .2;
1576
+ const pick = random.float(), shape = pick < .5 ? 'disc' : pick < .8 ? 'ring' : 'glow';
1577
+ made.push({at, size: random.float(.04, .1) * (1 + at), shape,
1578
+ color: hsl(random.float(), s * .9, .6, random.float(.15, .4))});
1579
+ }
1580
+ this.madeKey = key;
1581
+ }
1582
+ return this.made;
1583
+ }
1584
+
1585
+ /** Where the sun is on the screen, in pixels like mousePosScreen, undefined when it is behind the camera
1586
+ * @return {Vector2|undefined} */
1587
+ getSunScreenPos()
1588
+ {
1589
+ const direction = render3D.sunDirection;
1590
+ if (!direction.lengthSquared()) return;
1591
+ return render3D.worldToScreen(render3D.camera.pos.add(direction.normalize(100)));
1592
+ }
1593
+
1594
+ /** The parts of the flare as they are drawn now: each one's place on the screen, its size in pixels and its
1595
+ * color, dimmed by how much of the sun shows; empty when there is nothing to draw
1596
+ * @return {Array<{pos: Vector2, size: number, color: Color, shape: string}>} */
1597
+ getScreenElements()
1598
+ {
1599
+ const sun = this.getSunScreenPos(), center = mainCanvasSize.scale(.5);
1600
+ if (!sun || !center.x || !center.y) return [];
1601
+ // it fades as the sun leaves the screen, gone when it is a third of the screen past the edge
1602
+ const off = max(abs(sun.x - center.x) / center.x, abs(sun.y - center.y) / center.y);
1603
+ const strength = this.visible * clamp((1.3 - off) / .5) * this.intensity;
1604
+ if (!(strength > 0)) return [];
1605
+ const tint = this.color.multiply(render3D.sunColor), height = mainCanvasSize.y * this.flareSize;
1606
+ return this.getElements().map((e)=>
1607
+ {
1608
+ const c = e.color.multiply(tint);
1609
+ // along the line and past the middle, a lerp would stop there
1610
+ return {pos: sun.add(center.subtract(sun).scale(e.at)), size: e.size * height, shape: e.shape,
1611
+ color: rgb(c.r, c.g, c.b, c.a * strength)};
1612
+ });
1613
+ }
1614
+
1615
+ /** Is something between the camera and the sun: the level, or an object that is not see through
1616
+ * @return {boolean} */
1617
+ isSunHidden()
1618
+ {
1619
+ const direction = render3D.sunDirection;
1620
+ if (!direction.lengthSquared()) return true;
1621
+ const blockers = engineObjects.filter((o)=> o !== this && o instanceof EngineObject3D && !o.transparent &&
1622
+ !o.additive);
1623
+ return !!render3D.pick(new Ray3D(render3D.camera.pos, direction.normalize()), blockers);
1624
+ }
1625
+
1626
+ /** Ease visible toward whether the sun shows, called automatically each frame */
1627
+ update()
1628
+ {
1629
+ super.update();
1630
+ const shows = !!this.getSunScreenPos() && !(this.occlusion && this.isSunHidden());
1631
+ const step = this.fadeTime > 0 ? timeDelta / this.fadeTime : 1;
1632
+ this.visible = clamp(this.visible + (shows ? step : -step));
1633
+ }
1634
+
1635
+ /** Draw the flare over the 3D scene, added onto it, called automatically in the 2D pass */
1636
+ render()
1637
+ {
1638
+ if (headlessMode || !glEnable) return;
1639
+ const elements = this.getScreenElements();
1640
+ if (!elements.length) return;
1641
+ setAdditiveBlendMode(true);
1642
+ for (const e of elements)
1643
+ {
1644
+ const tileInfo = render3DFlareTile(e.shape);
1645
+ tileInfo && drawTile(e.pos, vec2(e.size), tileInfo, e.color, 0, false, undefined, true, true);
1646
+ }
1647
+ setAdditiveBlendMode(false);
1648
+ }
1649
+
1650
+ /** A flare has nothing to draw in the 3D pass */
1651
+ render3D() {}
1652
+ }
1653
+
1248
1654
  ///////////////////////////////////////////////////////////////////////////////
1249
1655
  // OBJ meshes
1250
1656