littlejsengine 1.22.0 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "littlejsengine",
3
- "version": "1.22.0",
3
+ "version": "1.23.0",
4
4
  "description": "LittleJS - Tiny and Fast HTML5 Game Engine",
5
5
  "main": "dist/littlejs.esm.js",
6
6
  "types": "dist/littlejs.d.ts",
@@ -425,13 +425,13 @@ class LightSystemPlugin
425
425
  // an automatic size follows the canvas, so reallocate the lightmap when
426
426
  // the canvas changed size, after the flush so the batch keeps its texture
427
427
  const size = lightSystem.textureSize;
428
- if (lightSystem.textureSizeAuto &&
429
- (size.x !== mainCanvasSize.x || size.y !== mainCanvasSize.y))
428
+ const wantX = glClampTextureSize(mainCanvasSize.x), wantY = glClampTextureSize(mainCanvasSize.y);
429
+ if (lightSystem.textureSizeAuto && (size.x !== wantX || size.y !== wantY))
430
430
  {
431
- lightSystem.textureSize = mainCanvasSize.copy();
431
+ lightSystem.textureSize = vec2(wantX, wantY);
432
432
  glContext.bindTexture(glContext.TEXTURE_2D, lightSystem.texture);
433
433
  glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA,
434
- mainCanvasSize.x, mainCanvasSize.y, 0,
434
+ wantX, wantY, 0,
435
435
  glContext.RGBA, glContext.UNSIGNED_BYTE, null);
436
436
  // put back the texture the engine tracks, a draw in renderLight must not sample the lightmap
437
437
  if (glActiveTexture)
@@ -571,8 +571,10 @@ class LightSystemPlugin
571
571
  this.shadowMapSize = glClampTextureSize(this.shadowMapSize);
572
572
  this.shadowTextureSize = glClampTextureSize(this.shadowTextureSize);
573
573
  const size = this.textureSize;
574
- if (size)
575
- this.textureSize = vec2(glClampTextureSize(size.x), glClampTextureSize(size.y));
574
+ if (!size) return;
575
+ const x = glClampTextureSize(size.x), y = glClampTextureSize(size.y);
576
+ if (x !== size.x || y !== size.y)
577
+ this.textureSize = vec2(x, y); // only when it is too large, this runs every frame
576
578
  }
577
579
 
578
580
  /** Draw a single Light's falloff blob into the currently bound lightmap.
@@ -0,0 +1,174 @@
1
+ /**
2
+ * LittleJS Parallax Plugin
3
+ * - A background layer that follows the camera by a part of its movement, so it looks far away
4
+ * - Repeats across the view, so a level of any width has a background
5
+ * - Draws mountains by default, or anything a game draws into it
6
+ * @namespace Parallax
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ ///////////////////////////////////////////////////////////////////////////////
12
+
13
+ /**
14
+ * A background layer with parallax: an image that follows the camera by a part of its movement and repeats across
15
+ * the view, drawn once into a canvas by a function, mountains when none is given
16
+ * - A far layer has a parallax near 1 and a low renderOrder, a near one a smaller parallax and a higher renderOrder
17
+ * - The image should meet itself at its left and right edges to repeat without a seam, as the mountains do
18
+ * @memberof Parallax
19
+ * @extends CanvasLayer
20
+ * @example
21
+ * // three ranges of mountains, the far ones lighter and slower
22
+ * for (let i = 3; i--;)
23
+ * new ParallaxLayer(vec2(0, i*2), vec2(40, 20), .9 - i*.2, -1e3 + i,
24
+ * parallaxMountains(hsl(.6, .3, .7 - i*.2), hsl(.6, .5, .2), i));
25
+ *
26
+ * // or draw your own image into the layer
27
+ * new ParallaxLayer(vec2(), vec2(40, 20), .5, -1e3, (context, size)=>
28
+ * {
29
+ * context.fillStyle = '#fff';
30
+ * context.fillRect(size.x/2 - 20, size.y/2 - 20, 40, 40);
31
+ * });
32
+ */
33
+ class ParallaxLayer extends CanvasLayer
34
+ {
35
+ /** Create a parallax layer and draw its image
36
+ * @param {Vector2} [pos] - Where the middle of the image is in the world when the camera is there too
37
+ * @param {Vector2} [size] - World size of one copy of the image
38
+ * @param {number|Vector2} [parallax] - How much of the camera's movement it follows, 0 stays with the world
39
+ * and 1 with the screen, a Vector2 to follow x and y by different amounts
40
+ * @param {number} [renderOrder] - Low to draw behind the game, far layers lowest
41
+ * @param {function(OffscreenCanvasRenderingContext2D, Vector2, ParallaxLayer): void} [drawFunction] - Draws
42
+ * the image, given the canvas context, its size in pixels and the layer; mountains when not given
43
+ * @param {Vector2} [canvasSize] - Size of the image in pixels */
44
+ constructor(pos=vec2(), size=vec2(32, 16), parallax=.5, renderOrder=-1e3, drawFunction=parallaxMountains(),
45
+ canvasSize=vec2(512, 256))
46
+ {
47
+ ASSERT(isNumber(parallax) || isVector2(parallax), 'parallax must be a number or a Vector2');
48
+ ASSERT(typeof drawFunction == 'function', 'drawFunction must be a function');
49
+ super(pos, size, 0, renderOrder, canvasSize);
50
+
51
+ /** @property {Vector2} - How much of the camera's movement it follows on each axis, 0 stays with the world
52
+ * and 1 with the screen */
53
+ this.parallax = isVector2(parallax) ? /** @type {Vector2} */ (parallax).copy() :
54
+ vec2(/** @type {number} */ (parallax));
55
+ /** @property {boolean} - Repeat the image across the view, left and right */
56
+ this.wrapX = true;
57
+ /** @property {boolean} - Repeat the image up and down the view */
58
+ this.wrapY = false;
59
+ /** @property {number} - How much the camera's zoom changes its size on the screen: 1 like the world, 0 not
60
+ * at all, the same on the screen at any zoom as it is at zoomScale */
61
+ this.zoomFollow = 1;
62
+ /** @property {number} - The camera scale its size is given for, used when zoomFollow is under 1 */
63
+ this.zoomScale = cameraScale;
64
+ /** @property {function(OffscreenCanvasRenderingContext2D, Vector2, ParallaxLayer): void} - Draws the
65
+ * image, redraw() after changing it */
66
+ this.drawFunction = drawFunction;
67
+ this.redraw();
68
+ }
69
+
70
+ /** Draw the image again with drawFunction, after changing it or what it draws from */
71
+ redraw()
72
+ {
73
+ const context = this.context;
74
+ if (!context) return; // headless
75
+ const size = vec2(this.canvas.width, this.canvas.height);
76
+ context.clearRect(0, 0, size.x, size.y);
77
+ this.drawFunction(context, size, this);
78
+ this.updateWebGL();
79
+ }
80
+
81
+ // how much smaller it is drawn than its size, from the camera's zoom and how much of it the layer follows
82
+ zoomFactor()
83
+ { return this.zoomFollow < 1 && cameraScale > 0 ? (this.zoomScale / cameraScale) ** (1 - this.zoomFollow) : 1; }
84
+
85
+ /** World size one copy of the image is drawn at now, its size unless zoomFollow is under 1
86
+ * @return {Vector2} */
87
+ getDrawSize() { return this.size.scale(this.zoomFactor()); }
88
+
89
+ /** Where each copy of the image is drawn now, in the world: one place, or with wrapping every place that
90
+ * shows in the view
91
+ * @return {Array<Vector2>} */
92
+ getDrawPositions()
93
+ {
94
+ // its place seen from the camera is cut down by the parallax, and by the zoom it does not follow
95
+ const zoom = this.zoomFactor(), size = this.size.scale(zoom), p = this.parallax;
96
+ const offset = this.pos.subtract(cameraPos);
97
+ const center = cameraPos.add(vec2(offset.x * (1 - p.x) * zoom, offset.y * (1 - p.y) * zoom));
98
+
99
+ // the copies that reach into the view; a turned camera sees as far as its corner each way
100
+ const view = mainCanvasSize.scale(.5 / (cameraScale || 1));
101
+ const reach = cameraAngle ? vec2(view.length()) : view;
102
+ const range = (wrap, axis)=>
103
+ {
104
+ if (!wrap || !(size[axis] > 0)) return [0, 0];
105
+ const from = cameraPos[axis] - reach[axis] - size[axis] / 2 - center[axis];
106
+ const to = cameraPos[axis] + reach[axis] + size[axis] / 2 - center[axis];
107
+ const first = ceil(from / size[axis]);
108
+ return [first, min(floor(to / size[axis]), first + 99)]; // a tiny image does not draw without end
109
+ };
110
+ const [x0, x1] = range(this.wrapX, 'x'), [y0, y1] = range(this.wrapY, 'y'), positions = [];
111
+ for (let y = y0; y <= y1; ++y)
112
+ for (let x = x0; x <= x1; ++x)
113
+ positions.push(vec2(center.x + x * size.x, center.y + y * size.y));
114
+ return positions;
115
+ }
116
+
117
+ // Render the layer, called automatically by the engine
118
+ render()
119
+ {
120
+ const size = this.getDrawSize();
121
+ for (const pos of this.getDrawPositions())
122
+ this.draw(pos, size, this.color, 0, this.mirror, this.additiveColor, false);
123
+ }
124
+ }
125
+
126
+ ///////////////////////////////////////////////////////////////////////////////
127
+
128
+ // the ridge of a range of mountains: a height for each column of an image and one more, the last the same as the
129
+ // first so the image repeats with no step; it wanders about the middle, turning now and then
130
+ function parallaxRidge(width, height, random)
131
+ {
132
+ const pointiness = .2, levelness = .005, slopeRange = height / 256; // how often it turns, how hard it is
133
+ const ridge = new Float32Array(width + 1), middle = height / 2; // pulled level, how steep it gets
134
+ let y = middle, slope = random.float(-slopeRange, slopeRange);
135
+ for (let x = 0; x <= width; ++x)
136
+ {
137
+ ridge[x] = y;
138
+ y += slope -= (y - middle) * levelness;
139
+ if (random.float() < pointiness)
140
+ slope = random.float(-slopeRange, slopeRange);
141
+ }
142
+ // take out the difference between its ends, a little from each column, and keep it inside the image
143
+ const drift = ridge[width] - ridge[0];
144
+ for (let x = 0; x <= width; ++x)
145
+ ridge[x] = clamp(ridge[x] - drift * x / width, 0, height);
146
+ return ridge;
147
+ }
148
+
149
+ /** A draw function for a ParallaxLayer: a range of mountains across the image, shaded from the color of their
150
+ * peaks down to the color of their feet, that repeats with no seam
151
+ * @param {Color} [topColor] - Color at the peaks
152
+ * @param {Color} [bottomColor] - Color at the bottom of the image
153
+ * @param {number} [seed] - The same seed gives the same mountains, random when not given
154
+ * @return {function(OffscreenCanvasRenderingContext2D, Vector2): void}
155
+ * @memberof Parallax */
156
+ function parallaxMountains(topColor=hsl(.6, .2, .6), bottomColor=hsl(.6, .3, .25), seed)
157
+ {
158
+ return (context, size)=>
159
+ {
160
+ const w = size.x, h = size.y;
161
+ const random = new RandomGenerator(seed === undefined ? randInt(1e9) + 1 : seed * 7919 + 1);
162
+ const ridge = parallaxRidge(w, h, random);
163
+
164
+ // a strip one pixel wide with the shading, stretched down each column from its peak
165
+ const strip = createCanvasContext(1, h);
166
+ for (let i = h; i--;)
167
+ {
168
+ strip.fillStyle = topColor.lerp(bottomColor, i / h).toString();
169
+ strip.fillRect(0, i, 1, 1);
170
+ }
171
+ for (let x = w; x--;)
172
+ ridge[x] < h && context.drawImage(strip.canvas, 0, 0, 1, h, x, ridge[x], 1, h - ridge[x]);
173
+ };
174
+ }
@@ -178,7 +178,9 @@ function particleEffectSanitize(raw)
178
178
  settings[setting.name] = value === '' || particleEffectShapes.includes(value) ? value : fallback;
179
179
  else if (isNumber(value))
180
180
  {
181
- const clamped = clamp(value, setting.hardMin, setting.hardMax);
181
+ // an angle past half a turn comes around, a clamp would point it the other way
182
+ const wraps = setting.name === 'angle' && isFinite(value) && abs(value) > PI;
183
+ const clamped = clamp(wraps ? mod(value + PI, 2*PI) - PI : value, setting.hardMin, setting.hardMax);
182
184
  settings[setting.name] = setting.step >= 1 ? round(clamped) : clamped;
183
185
  }
184
186
  else
@@ -257,9 +259,11 @@ function particleEffectsGet(name) { return particleEffectsByName.get(String(name
257
259
  * @param {string} text
258
260
  * @return {Array<Object>}
259
261
  * @memberof ParticleEffects */
260
- function particleEffectsParse(text)
262
+ function particleEffectsParse(text) { return particleEffectsFromData(JSON.parse(text)); }
263
+
264
+ // a library file's data, or one effect's, into sanitized effects
265
+ function particleEffectsFromData(data)
261
266
  {
262
- const data = JSON.parse(text);
263
267
  const list = isArray(data?.effects) ? data.effects : data?.settings ? [data] : undefined;
264
268
  if (!list || !list.length)
265
269
  throw new Error('no effects in this file');
@@ -278,7 +282,7 @@ function particleEffectsText(effects) { return JSON.stringify({version:1, effect
278
282
  * @memberof ParticleEffects */
279
283
  async function particleEffectsLoad(url)
280
284
  {
281
- const effects = particleEffectsParse(JSON.stringify(await fetchJSON(url)));
285
+ const effects = particleEffectsFromData(await fetchJSON(url));
282
286
  particleEffectsAdd(effects);
283
287
  return effects;
284
288
  }
@@ -456,7 +460,13 @@ function particleEffectTileInfo(s)
456
460
  * @memberof ParticleEffects */
457
461
  function particleEffectApply(emitter, effect)
458
462
  {
459
- effect = particleEffectSanitize(effect); // an effect written by hand or changed since it was added
463
+ // an effect written by hand or changed since it was added
464
+ particleEffectSet(emitter, particleEffectSanitize(effect));
465
+ }
466
+
467
+ // set a 2D emitter to an effect that is sanitized already, as a play's is
468
+ function particleEffectSet(emitter, effect)
469
+ {
460
470
  const s = effect.settings, ended = emitter.emitTime < 0; // destroyed, its last particles going
461
471
  for (const setting of particleEffectSettings)
462
472
  if (!particleEffectIndirect.includes(setting.name))
@@ -492,15 +502,14 @@ function particleEffectResolve(nameOrEffect, options)
492
502
  * - Attach it to an object with addChild to follow it
493
503
  * @param {string|Object} nameOrEffect - A built-in or added effect's name, or an effect
494
504
  * @param {Vector2} [pos]
495
- * @param {Object} [options]
496
- * @param {number} [options.scale] - Grows the whole effect, the built-ins fit a one unit object at 1
497
- * @param {number} [options.hue] - Turns its colors around the color wheel, 1 is all the way
498
- * @param {number} [options.saturation] - Multiplies its saturation, 0 is grey
499
- * @param {number} [options.angle] - Direction, 0 is up; the effect's own angle when not given
500
- * @param {TileInfo|TextureInfo} [options.tileInfo] - The game's own art to draw with in place of the effect's shape,
501
- * tinted by its colors; a whole texture draws as one tile
502
- * @param {*} [options.settings] - Any effect setting by its name, emitTime, emitRate, speed and the rest, replacing
503
- * the effect's own for this play
505
+ * @param {Object} [options] - What to change for this play, each left out when not wanted:
506
+ * scale grows the whole effect, the built-ins fit a one unit object at 1;
507
+ * hue turns its colors around the color wheel, 1 is all the way;
508
+ * saturation multiplies its saturation, 0 is grey;
509
+ * angle is its direction, 0 is up, the effect's own angle when not given;
510
+ * tileInfo, a TileInfo or a TextureInfo, is the game's own art to draw with in place of the effect's shape,
511
+ * tinted by its colors, a whole texture drawn as one tile;
512
+ * and any effect setting by its name, emitTime, emitRate, speed and the rest, replaces the effect's own
504
513
  * @return {ParticleEmitter|undefined} - undefined when there is no such effect
505
514
  * @memberof ParticleEffects */
506
515
  function particleEffect(nameOrEffect, pos=vec2(), options={})
@@ -508,7 +517,7 @@ function particleEffect(nameOrEffect, pos=vec2(), options={})
508
517
  const effect = particleEffectResolve(nameOrEffect, options);
509
518
  if (!effect) return;
510
519
  const emitter = new ParticleEmitter(pos.copy());
511
- particleEffectApply(emitter, effect);
520
+ particleEffectSet(emitter, effect);
512
521
  emitter.scale = options.scale ?? 1;
513
522
  if (options.angle !== undefined)
514
523
  emitter.angle = options.angle;
@@ -572,7 +581,7 @@ function particleEffectFromEmitter(emitter, name='Effect')
572
581
  p.velocity.y += (p.pos.x - c.x) * s * .002;
573
582
  };
574
583
  b('wind').update3D = (p, s)=> p.velocity.x += s * .004 * p.scale * min(p.age / p.lifeTime, 1);
575
- // stick has no 3D push: particleEffect3D makes it the friction a particle lands with
584
+ // stick has no 3D push: particleEffectApply3D makes it the emitter's stick, its grip on landing
576
585
  }
577
586
 
578
587
  /** Set a 3D emitter to an effect, live, so a running one keeps its particles; its place, scale and flatten stay
@@ -581,7 +590,13 @@ function particleEffectFromEmitter(emitter, name='Effect')
581
590
  * @memberof ParticleEffects */
582
591
  function particleEffectApply3D(emitter, effect)
583
592
  {
584
- effect = particleEffectSanitize(effect); // an effect written by hand or changed since it was added
593
+ // an effect written by hand or changed since it was added
594
+ particleEffectSet3D(emitter, particleEffectSanitize(effect));
595
+ }
596
+
597
+ // set a 3D emitter to an effect that is sanitized already, as a play's is
598
+ function particleEffectSet3D(emitter, effect)
599
+ {
585
600
  const s = effect.settings, e = emitter;
586
601
  // a circle is a sphere and a rectangle a box as deep as it is wide
587
602
  e.emitSize = s.emitRect ? vec3(s.emitSize, s.emitHeight, s.emitSize) : s.emitSize;
@@ -594,8 +609,8 @@ function particleEffectApply3D(emitter, effect)
594
609
  'randomness', 'additive', 'gravityScale', 'angleSpeed', 'angleDamping', 'collideLevel', 'restitution'])
595
610
  e[name] = s[name];
596
611
  e.trailTime = s.trailScale / 60; // a stretch of speed times trailScale is a streak of that many frames
597
- const stick = effect.behaviors.find(b=> b.name == 'stick'); // in 3D, a grip on landing is less sliding
598
- e.friction = stick ? min(s.friction, 1 - stick.strength) : s.friction;
612
+ e.friction = s.friction;
613
+ e.stick = effect.behaviors.find(b=> b.name == 'stick')?.strength ?? 0; // in 3D the emitter grips on landing
599
614
  // a 2D emitter at angle a shoots along (sin a, cos a), a z turn r takes up to (-sin r, cos r), so r is -a
600
615
  e.rotation3D = vec3(0, 0, -s.angle);
601
616
  e.particleUpdateCallback = particleEffectUpdateCallback(effect.behaviors, true);
@@ -605,8 +620,8 @@ function particleEffectApply3D(emitter, effect)
605
620
  * - The same effect data as particleEffect, so the look carries across: a circle spawn area becomes a sphere and a
606
621
  * rectangle a box as deep as it is wide, both flat across the way it emits with options.flatten, a disc or a
607
622
  * sheet; a trail becomes a streak of the same length, and the settings the 3D emitter lacks (particleConeAngle,
608
- * randomColorLinear, velocityInheritance, localSpace) are left out; the stick behavior becomes the friction a
609
- * particle lands with
623
+ * randomColorLinear, velocityInheritance, localSpace) are left out; the stick behavior becomes the emitter's
624
+ * stick, its grip where a particle lands
610
625
  * @param {string|Object} nameOrEffect - A built-in or added effect's name, or an effect
611
626
  * @param {Vector3} [pos3D]
612
627
  * @param {Object} [options] - scale, hue, saturation, angle, tileInfo and any setting as particleEffect; angle turns it
@@ -618,7 +633,7 @@ function particleEffect3D(nameOrEffect, pos3D=vec3(), options={})
618
633
  const effect = particleEffectResolve(nameOrEffect, options);
619
634
  if (!effect) return;
620
635
  const e = new ParticleEmitter3D(pos3D.copy());
621
- particleEffectApply3D(e, effect);
636
+ particleEffectSet3D(e, effect);
622
637
  if (options.angle !== undefined)
623
638
  e.rotation3D = vec3(0, 0, -options.angle);
624
639
  e.scale3D = vec3(options.scale ?? 1);
@@ -116,6 +116,10 @@ export
116
116
  setScene,
117
117
  getScene,
118
118
 
119
+ // Parallax
120
+ ParallaxLayer,
121
+ parallaxMountains,
122
+
119
123
  // Path Finding
120
124
  PathFinder,
121
125
  PathFinderNode,
@@ -196,6 +200,10 @@ export
196
200
  level3DAddType,
197
201
  level3DAddMesh,
198
202
  level3DLoad,
203
+ level3DAddPrefab,
204
+ level3DLoadPrefab,
205
+ level3DSpawn,
206
+ Prefab3D,
199
207
  level3DVoxelSetup,
200
208
 
201
209
  // Three.js
@@ -433,12 +433,17 @@ function render3DShadowCaster()
433
433
  light.intensity > 0 && light.color.a > 0 ? light : undefined;
434
434
  }
435
435
 
436
+ // the widest cone a shadow map can look down, to each side; the light that casts the shadows lights no wider, or
437
+ // what is past the map's edge would be lit with nothing to shade it
438
+ const RENDER3D_SHADOW_CONE_MAX = 1.35;
439
+
436
440
  // a light's cone as the four numbers the shader gets: the way it shines, scaled so that its dot with the way to
437
441
  // a point, less the fourth number, is 0 at the edge of the cone and 1 where its fade starts; a light with no cone
438
442
  // gets numbers that make it 1 every way
439
443
  function render3DLightCone(light)
440
444
  {
441
- const angle = light.directional ? 0 : min(light.coneAngle, PI);
445
+ const widest = light === render3DShadowCaster() ? RENDER3D_SHADOW_CONE_MAX : PI;
446
+ const angle = light.directional ? 0 : min(light.coneAngle, widest);
442
447
  if (!(angle > 0)) return [0, 0, 0, -1];
443
448
  const outer = cos(angle), inner = cos(angle * (1 - clamp(light.coneSoftness)));
444
449
  const k = 1 / max(inner - outer, 1e-4), forward = light.getForward3D();
@@ -1225,8 +1230,9 @@ class Render3DPlugin
1225
1230
  // a spotlight's map looks down its cone with perspective, from just in front of it out to its radius
1226
1231
  const pos = caster.getWorldPos3D(), forward = caster.getForward3D();
1227
1232
  const up = abs(forward.y) > .99 ? vec3(0, 0, 1) : vec3(0, 1, 0);
1228
- const far = caster.radius, near = max(far / 500, .02);
1229
- const fov = min(min(caster.coneAngle, PI) * 2 + .1, 2.8);
1233
+ // the near plane stays in front of the far one however small the light
1234
+ const far = caster.radius, near = min(max(far / 500, .02), far / 2);
1235
+ const fov = min(caster.coneAngle, RENDER3D_SHADOW_CONE_MAX) * 2 + .1;
1230
1236
  this.shadowMatrix = Matrix4.perspective(fov, 1, near, far).multiply(
1231
1237
  Matrix4.lookAt(pos, pos.add(forward), up).invert());
1232
1238
  this.shadowPlanes = render3DFrustumPlanes(this.shadowMatrix);
@@ -4660,7 +4666,7 @@ class InstancedMesh3D extends EngineObject3D
4660
4666
  * - intensity multiplies the color, above 1 for a light brighter than white
4661
4667
  * - radius is a world distance, so scale3D does not change it
4662
4668
  * - An alpha, an intensity or a radius of 0 switches it off, and a light that is off takes none of those slots
4663
- * - Draws nothing itself, add a glow with drawSoftDisc or a small emissive mesh if it should be seen
4669
+ * - Draws nothing but its glow, when it has one; add a small emissive mesh if the lamp itself should be seen
4664
4670
  * @extends EngineObject3D
4665
4671
  * @memberof Render3D
4666
4672
  * @example
@@ -4709,8 +4715,10 @@ class Light3D extends EngineObject3D
4709
4715
  const r = render3D, c = this.color, pos = render3DObjectMatrix(this).getTranslation();
4710
4716
  const toCamera = r.camera.pos.subtract(pos), distance = toCamera.length();
4711
4717
  const at = distance ? pos.add(toCamera.scale(min(this.glow, distance) / 2 / distance)) : pos;
4712
- const color = rgb(c.r, c.g, c.b, c.a * min(this.intensity, 1));
4713
- r.drawBillboard(at, vec2(this.glow), engineGlowTexture(this.glowFalloff), color);
4718
+ const color = rgb(c.r, c.g, c.b, c.a * min(this.intensity, 1)), texture = engineGlowTexture(this.glowFalloff);
4719
+ // a browser that can not make the glow's texture draws no glow, not a plain square
4720
+ if (texture || !glContext)
4721
+ r.drawBillboard(at, vec2(this.glow), texture, color);
4714
4722
  }
4715
4723
  }
4716
4724
 
@@ -4720,7 +4728,7 @@ class Light3D extends EngineObject3D
4720
4728
  * - It shines from its position toward the origin, like a three.js DirectionalLight: only the direction to it
4721
4729
  * counts, so moving it or its parent swings the light around; parent it to a sun in the sky and it follows
4722
4730
  * - It cannot sit on the origin, since that leaves no direction
4723
- * - Like every Light3D it casts no shadow, only the sun, render3D.sunDirection, does
4731
+ * - It casts no shadow: the sun, render3D.sunDirection, does, or a spotlight set as render3D.shadowLight
4724
4732
  * @extends Light3D
4725
4733
  * @memberof Render3D
4726
4734
  * @example