babylon.quarks 0.20.0 → 0.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.
@@ -47,7 +47,7 @@ import {BatchedRenderer, VFXBatchSettings} from './BatchedRenderer';
47
47
  import {ensureEnvAtlasFromCube, getCachedEnvAtlas} from './envAtlas';
48
48
  import {ensureTriangleIndices} from './geometryUtil';
49
49
  import {ParticleEmitter} from './ParticleEmitter';
50
- import {RenderMode} from './VFXBatch';
50
+ import {MeshLighting, MeshRim, normalizeMeshRim, RenderMode} from './VFXBatch';
51
51
 
52
52
  export interface BurstParameters {
53
53
  time: number;
@@ -62,6 +62,7 @@ type StoreBackedParticle = Particle & {storeIndex: number; setStoreIndex(index:
62
62
 
63
63
  const UP = new Vector3(0, 0, 1);
64
64
  const tempQ = new Quaternion();
65
+ const IDENTITY_QUATERNION = new Quaternion();
65
66
  const tempV = new Vector3();
66
67
  const tempV2 = new Vector3();
67
68
  const PREWARM_FPS = 60;
@@ -75,6 +76,30 @@ const MAX_SIMULATION_STEPS_PER_FRAME = 8;
75
76
  // and keeps the well-tested "half a step is no step yet" behavior.
76
77
  const ROUND_TIE_BIAS = 1e-9;
77
78
 
79
+ /** Built-in behaviors that write a particle's size / colour, and those that touch neither. */
80
+ const SIZE_BEHAVIORS = new Set(['SizeOverLife', 'SizeBySpeed']);
81
+ const COLOR_BEHAVIORS = new Set(['ColorOverLife', 'ColorBySpeed']);
82
+ const INERT_FOR_SIZE_AND_COLOR = new Set([
83
+ 'ApplyForce',
84
+ 'Noise',
85
+ 'TurbulenceField',
86
+ 'GravityForce',
87
+ 'RotationOverLife',
88
+ 'Rotation3DOverLife',
89
+ 'RotationBySpeed',
90
+ 'SpeedOverLife',
91
+ 'FrameOverLife',
92
+ 'ForceOverLife',
93
+ 'VelocityOverLife',
94
+ 'InheritVelocity',
95
+ 'OrbitOverLife',
96
+ 'WidthOverLength',
97
+ 'ChangeEmitDirection',
98
+ 'EmitSubParticleSystem',
99
+ 'LimitSpeedOverLife',
100
+ 'ApplyCollision',
101
+ ]);
102
+
78
103
  const DEFAULT_POSITIONS = new Float32Array([-0.5, -0.5, 0, 0.5, -0.5, 0, 0.5, 0.5, 0, -0.5, 0.5, 0]);
79
104
  const DEFAULT_UVS = new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]);
80
105
 
@@ -142,6 +167,10 @@ export interface ParticleSystemParameters {
142
167
  pivot?: Vector3;
143
168
  /** Fraction of billboard particles mirrored along U (x) and V (y), see {@link ParticleSystem.flip}. */
144
169
  flip?: Vector2;
170
+ /** How mesh particles are lit, see {@link ParticleSystem.lighting}. Default `'fixed'`. */
171
+ lighting?: MeshLighting;
172
+ /** Rim (Fresnel) term on mesh particles, see {@link ParticleSystem.rim}. Default none. */
173
+ rim?: Partial<MeshRim>;
145
174
  scene?: Scene;
146
175
  }
147
176
 
@@ -259,6 +288,8 @@ export class ParticleSystem implements IParticleSystem {
259
288
  private previousEmitterPos?: Vector3;
260
289
  private tempEmitterPos: Vector3 = new Vector3();
261
290
  private temp: Vector3 = new Vector3();
291
+ /** Emitter matrix moved along the step's path, see spawnAlongPath. */
292
+ private pathMatrix: Matrix4 = new Matrix4();
262
293
  private normalMatrix: Matrix3 = new Matrix3();
263
294
  private memory: GeneratorMemory = [];
264
295
  private listeners: {[event: string]: Array<(event: ParticleSystemEvent) => void>} = {};
@@ -271,6 +302,12 @@ export class ParticleSystem implements IParticleSystem {
271
302
  /** Behaviors the cached plan was built from, compared by identity. */
272
303
  private fusionPlan?: Array<Behavior>;
273
304
  private fusionSteps: Array<FusionStep> = [];
305
+ /**
306
+ * Whether any behavior in the plan can change a particle's size or colour
307
+ * after birth. Only then does a step have to snapshot them for the renderer.
308
+ */
309
+ private planChangesSize = true;
310
+ private planChangesColor = true;
274
311
  /** @internal **/
275
312
  _renderer?: BatchedRenderer;
276
313
 
@@ -304,6 +341,9 @@ export class ParticleSystem implements IParticleSystem {
304
341
  if (state.burstIndex < this.emissionBursts.length) {
305
342
  return true;
306
343
  }
344
+ if (this.hasPendingBurstWaves(state)) {
345
+ return true;
346
+ }
307
347
  if (state.time >= this.duration) {
308
348
  return false;
309
349
  }
@@ -400,6 +440,44 @@ export class ParticleSystem implements IParticleSystem {
400
440
  this.neededToUpdateRender = true;
401
441
  }
402
442
 
443
+ /**
444
+ * How mesh particles are lit: `'fixed'` (the default — the renderer's
445
+ * light, see {@link BatchedRenderer.setMeshLighting}), `'unlit'` (texture
446
+ * times colour, for emissive meshes) or `'scene'` (the scene's first
447
+ * directional or hemispheric light). Saved with the material in the effect
448
+ * JSON. Other render modes ignore it.
449
+ */
450
+ get lighting(): MeshLighting {
451
+ return this.rendererSettings.lighting ?? 'fixed';
452
+ }
453
+
454
+ set lighting(lighting: MeshLighting) {
455
+ this.rendererSettings.lighting = ParticleSystem.validLighting(lighting);
456
+ this.neededToUpdateRender = true;
457
+ }
458
+
459
+ /**
460
+ * Rim (Fresnel) term on mesh particles — a glowing edge (`mode: 'add'`) or
461
+ * an outline (`'mix'`), see {@link MeshRim}. Undefined (the default) draws
462
+ * none. Fields left out of an assigned rim take their defaults; assign a
463
+ * new object to change it, editing the returned one does not reach the
464
+ * renderer. Saved with the material in the effect JSON. Other render modes
465
+ * ignore it.
466
+ */
467
+ get rim(): MeshRim | undefined {
468
+ return this.rendererSettings.rim;
469
+ }
470
+
471
+ set rim(rim: Partial<MeshRim> | undefined) {
472
+ this.rendererSettings.rim = normalizeMeshRim(rim);
473
+ this.neededToUpdateRender = true;
474
+ }
475
+
476
+ /** `lighting` if it is one of the modes, else the default. */
477
+ private static validLighting(lighting: unknown): MeshLighting {
478
+ return lighting === 'unlit' || lighting === 'scene' ? lighting : 'fixed';
479
+ }
480
+
403
481
  private static nonZeroPivot(v: Vector3 | undefined): Vector3 | undefined {
404
482
  return v && (v.x !== 0 || v.y !== 0 || v.z !== 0) ? v.clone() : undefined;
405
483
  }
@@ -563,6 +641,8 @@ export class ParticleSystem implements IParticleSystem {
563
641
  reflectionAtlas: null,
564
642
  layerMask: parameters.layerMask ?? 0x0fffffff,
565
643
  pivot: ParticleSystem.nonZeroPivot(parameters.pivot),
644
+ lighting: ParticleSystem.validLighting(parameters.lighting),
645
+ rim: normalizeMeshRim(parameters.rim),
566
646
  };
567
647
  this.flip = ParticleSystem.nonZeroFlip(parameters.flip);
568
648
  if (this.rendererSettings.renderMode === RenderMode.Mesh && !this.rendererSettings.instancingNormals) {
@@ -821,7 +901,9 @@ export class ParticleSystem implements IParticleSystem {
821
901
  this.startRotation.startGen(particle.memory);
822
902
  if (isMeshMode) {
823
903
  if (!(sprite.rotation instanceof Quaternion)) {
824
- sprite.rotation = new Quaternion();
904
+ // Assigning a quaternion gives the particle a row in the
905
+ // store's quaternion column; the value is overwritten below.
906
+ sprite.rotation = IDENTITY_QUATERNION;
825
907
  }
826
908
  // A recycled particle can still carry the turn of whatever
827
909
  // it was last time; the renderer would draw a fraction of it
@@ -893,6 +975,9 @@ export class ParticleSystem implements IParticleSystem {
893
975
  for (let j = 0; j < behaviorCount; j++) {
894
976
  behaviors[j].initialize(particle, this);
895
977
  }
978
+ // Every generator has claimed its slot by now; give the compiled
979
+ // behavior passes a column copy of the samples.
980
+ this.store.recordMemory((particle as StoreBackedParticle).storeIndex, particle.memory);
896
981
  }
897
982
  }
898
983
 
@@ -921,6 +1006,7 @@ export class ParticleSystem implements IParticleSystem {
921
1006
  this.emissionState.isBursting = false;
922
1007
  this.emissionState.burstIndex = 0;
923
1008
  this.emissionState.burstWaveIndex = 0;
1009
+ this.emissionState.burstWaves = undefined;
924
1010
  this.emissionState.time = 0;
925
1011
  this.emissionState.waitEmiting = 0;
926
1012
  this.behaviors.forEach((behavior) => behavior.reset());
@@ -1079,6 +1165,22 @@ export class ParticleSystem implements IParticleSystem {
1079
1165
  }
1080
1166
  this.fusionPlan = behaviors.slice();
1081
1167
  this.fusionSteps = planBehaviorFusion(behaviors);
1168
+ let changesSize = false;
1169
+ let changesColor = false;
1170
+ for (let i = 0; i < behaviors.length; i++) {
1171
+ const type = behaviors[i].type;
1172
+ if (SIZE_BEHAVIORS.has(type)) {
1173
+ changesSize = true;
1174
+ } else if (COLOR_BEHAVIORS.has(type)) {
1175
+ changesColor = true;
1176
+ } else if (!INERT_FOR_SIZE_AND_COLOR.has(type)) {
1177
+ // Unknown to this list, so it may write anything.
1178
+ changesSize = true;
1179
+ changesColor = true;
1180
+ }
1181
+ }
1182
+ this.planChangesSize = changesSize;
1183
+ this.planChangesColor = changesColor;
1082
1184
  return this.fusionSteps;
1083
1185
  }
1084
1186
 
@@ -1104,18 +1206,29 @@ export class ParticleSystem implements IParticleSystem {
1104
1206
  // held before, and so is drawn at the emitter instead of jumping.
1105
1207
  const columns = this.store;
1106
1208
  const vector3Rows = particleCount * 3;
1209
+ const steps = this.behaviorSteps();
1210
+ // Size and colour are only snapshotted when some behavior can change
1211
+ // them; otherwise the columns stay what spawn wrote and the renderer
1212
+ // reads them as they are.
1213
+ const snapshotSize = this.planChangesSize;
1214
+ const snapshotColor = this.planChangesColor;
1215
+ columns.useSizeSnapshot(snapshotSize);
1216
+ columns.useColorSnapshot(snapshotColor);
1107
1217
  columns.previousPosition.set(columns.position.subarray(0, vector3Rows));
1108
- columns.previousSize.set(columns.size.subarray(0, vector3Rows));
1218
+ if (snapshotSize) {
1219
+ columns.previousSize.set(columns.size.subarray(0, vector3Rows));
1220
+ }
1109
1221
  if (stretched) {
1110
1222
  if (columns.keepPreviousVelocity()) {
1111
1223
  this.rebindParticles();
1112
1224
  }
1113
1225
  columns.previousVelocity.set(columns.velocity.subarray(0, vector3Rows));
1114
1226
  }
1115
- columns.previousColor.set(columns.color.subarray(0, particleCount * 4));
1227
+ if (snapshotColor) {
1228
+ columns.previousColor.set(columns.color.subarray(0, particleCount * 4));
1229
+ }
1116
1230
 
1117
1231
  this.emitterShape.update(this, delta);
1118
- const steps = this.behaviorSteps();
1119
1232
  for (let s = 0; s < steps.length; s++) {
1120
1233
  const step = steps[s];
1121
1234
  const stepBehaviors = step.behaviors;
@@ -1129,7 +1242,7 @@ export class ParticleSystem implements IParticleSystem {
1129
1242
  continue;
1130
1243
  }
1131
1244
  if (step.run !== undefined) {
1132
- step.run(behaviors, particles, particleCount, delta);
1245
+ step.run(behaviors, particles, particleCount, delta, this.store);
1133
1246
  continue;
1134
1247
  }
1135
1248
  const behavior = stepBehaviors[0];
@@ -1146,34 +1259,111 @@ export class ParticleSystem implements IParticleSystem {
1146
1259
  }
1147
1260
  }
1148
1261
 
1149
- // Newborns were snapshotted above with spawn size/color (startSize /
1150
- // startColor) before SizeOverLife / ColorOverLife ran. That false jump
1151
- // is then extrapolated by the renderer when simulationResidual is
1152
- // negative (common with rounded fixed-step) and flashes the start
1153
- // appearance at the emitter. Collapse it so birth matches t=0 on the
1154
- // curves. Age is still 0 here — integration below ages them.
1262
+ const notifyDeaths = this.hasListeners('particleDied');
1263
+ const store = this.store;
1264
+ const scalars = store.scalars;
1265
+ const stride = ParticleStore.SCALAR_STRIDE;
1266
+ let liveParticleCount = this.particleNum;
1267
+
1268
+ if (!isTrailMode) {
1269
+ // One pass over the columns does three things the sprite path needs,
1270
+ // none of which touches a particle object until a death:
1271
+ //
1272
+ // Newborns were snapshotted above with their spawn size and colour,
1273
+ // before SizeOverLife / ColorOverLife ran. That false jump would be
1274
+ // extrapolated by the renderer when the residual is negative (common
1275
+ // with the rounded fixed step) and flash the start appearance at the
1276
+ // emitter, so the snapshot is collapsed onto the curves' t=0 value.
1277
+ // Age is still 0 at that point; the same iteration then ages it.
1278
+ //
1279
+ // Position is integrated along the velocity.
1280
+ //
1281
+ // A particle whose age reached its life is swapped with the last live
1282
+ // one, rows included, so slot k always owns row k and the live
1283
+ // particles stay one contiguous range the renderer copies as is. The
1284
+ // survivor moved into the slot has not been visited yet, so the slot
1285
+ // is processed again.
1286
+ const positions = store.position;
1287
+ const velocities = store.velocity;
1288
+ const prevSize = store.previousSize;
1289
+ const curSize = store.size;
1290
+ const prevColor = store.previousColor;
1291
+ const curColor = store.color;
1292
+ const collapseSize = snapshotSize;
1293
+ const collapseColor = snapshotColor;
1294
+ for (let i = 0; i < liveParticleCount; i++) {
1295
+ const scalar = i * stride;
1296
+ let age = scalars[scalar + ParticleStore.AGE];
1297
+ const offset = i * 3;
1298
+ if (age === 0) {
1299
+ if (collapseSize) {
1300
+ prevSize[offset] = curSize[offset];
1301
+ prevSize[offset + 1] = curSize[offset + 1];
1302
+ prevSize[offset + 2] = curSize[offset + 2];
1303
+ }
1304
+ if (collapseColor) {
1305
+ const o4 = i * 4;
1306
+ prevColor[o4] = curColor[o4];
1307
+ prevColor[o4 + 1] = curColor[o4 + 1];
1308
+ prevColor[o4 + 2] = curColor[o4 + 2];
1309
+ prevColor[o4 + 3] = curColor[o4 + 3];
1310
+ }
1311
+ }
1312
+ const step = delta * scalars[scalar + ParticleStore.SPEED_MODIFIER];
1313
+ positions[offset] += velocities[offset] * step;
1314
+ positions[offset + 1] += velocities[offset + 1] * step;
1315
+ positions[offset + 2] += velocities[offset + 2] * step;
1316
+ age += delta;
1317
+ scalars[scalar + ParticleStore.AGE] = age;
1318
+ if (age >= scalars[scalar + ParticleStore.LIFE]) {
1319
+ const last = liveParticleCount - 1;
1320
+ const particle = particles[i];
1321
+ const survivor = particles[last] as StoreBackedParticle;
1322
+ particles[i] = survivor;
1323
+ particles[last] = particle;
1324
+ if (i !== last) {
1325
+ store.swapRows(i, last);
1326
+ survivor.setStoreIndex(i);
1327
+ (particle as StoreBackedParticle).setStoreIndex(last);
1328
+ }
1329
+ liveParticleCount--;
1330
+ i--;
1331
+ if (notifyDeaths) {
1332
+ this.fire({type: 'particleDied', particleSystem: this, particle: particle});
1333
+ }
1334
+ }
1335
+ }
1336
+ this.particleNum = liveParticleCount;
1337
+ this.notifyFinished();
1338
+ return;
1339
+ }
1340
+
1341
+ // Trails: the newborn collapse, then motion, then the trail history,
1342
+ // then removal, each over the particle objects the history lives on.
1155
1343
  if (particleCount > 0) {
1156
1344
  const prevSize = columns.previousSize;
1157
1345
  const curSize = columns.size;
1158
1346
  const prevColor = columns.previousColor;
1159
1347
  const curColor = columns.color;
1160
- const scalars = columns.scalars;
1161
- const stride = ParticleStore.SCALAR_STRIDE;
1162
1348
  for (let i = 0, o3 = 0, o4 = 0, s = 0; i < particleCount; i++, o3 += 3, o4 += 4, s += stride) {
1163
1349
  if (scalars[s + ParticleStore.AGE] !== 0) {
1164
1350
  continue;
1165
1351
  }
1166
- prevSize[o3] = curSize[o3];
1167
- prevSize[o3 + 1] = curSize[o3 + 1];
1168
- prevSize[o3 + 2] = curSize[o3 + 2];
1169
- prevColor[o4] = curColor[o4];
1170
- prevColor[o4 + 1] = curColor[o4 + 1];
1171
- prevColor[o4 + 2] = curColor[o4 + 2];
1172
- prevColor[o4 + 3] = curColor[o4 + 3];
1352
+ if (snapshotSize) {
1353
+ prevSize[o3] = curSize[o3];
1354
+ prevSize[o3 + 1] = curSize[o3 + 1];
1355
+ prevSize[o3 + 2] = curSize[o3 + 2];
1356
+ }
1357
+ if (snapshotColor) {
1358
+ prevColor[o4] = curColor[o4];
1359
+ prevColor[o4 + 1] = curColor[o4 + 1];
1360
+ prevColor[o4 + 2] = curColor[o4 + 2];
1361
+ prevColor[o4 + 3] = curColor[o4 + 3];
1362
+ }
1173
1363
  }
1174
1364
  }
1175
1365
 
1176
- const followLocalOrigin = isTrailMode && (this.rendererEmitterSettings as TrailSettings).followLocalOrigin;
1366
+ const followLocalOrigin = (this.rendererEmitterSettings as TrailSettings).followLocalOrigin;
1177
1367
  if (followLocalOrigin) {
1178
1368
  const emitterMatrix = this.emitter.matrixWorld;
1179
1369
  for (let i = 0; i < particleCount; i++) {
@@ -1187,12 +1377,8 @@ export class ParticleSystem implements IParticleSystem {
1187
1377
  particle.age += delta;
1188
1378
  }
1189
1379
  } else {
1190
- // Live particles own rows [0, particleNum), so integrate straight
1191
- // over the columns. Nothing in here touches a particle object.
1192
- const positions = this.store.position;
1193
- const velocities = this.store.velocity;
1194
- const scalars = this.store.scalars;
1195
- const stride = ParticleStore.SCALAR_STRIDE;
1380
+ const positions = store.position;
1381
+ const velocities = store.velocity;
1196
1382
  for (let i = 0, offset = 0, scalar = 0; i < particleCount; i++, offset += 3, scalar += stride) {
1197
1383
  const step = delta * scalars[scalar + ParticleStore.SPEED_MODIFIER];
1198
1384
  positions[offset] += velocities[offset] * step;
@@ -1202,7 +1388,7 @@ export class ParticleSystem implements IParticleSystem {
1202
1388
  }
1203
1389
  }
1204
1390
 
1205
- if (isTrailMode) {
1391
+ {
1206
1392
  const trailSettings = this.rendererEmitterSettings as TrailSettings;
1207
1393
  const dieWithParticle = trailSettings.dieWithParticle;
1208
1394
  // A world-space system records world positions already.
@@ -1230,23 +1416,15 @@ export class ParticleSystem implements IParticleSystem {
1230
1416
  }
1231
1417
  }
1232
1418
 
1233
- const notifyDeaths = this.hasListeners('particleDied');
1234
- const store = this.store;
1235
- const scalars = store.scalars;
1236
- const scalarStride = ParticleStore.SCALAR_STRIDE;
1237
- let liveParticleCount = this.particleNum;
1238
1419
  for (let i = 0; i < liveParticleCount; i++) {
1239
- const particle = particles[i];
1240
- const scalar = i * scalarStride;
1420
+ const particle = particles[i] as TrailParticle;
1421
+ const scalar = i * stride;
1241
1422
  const dead = scalars[scalar + ParticleStore.AGE] >= scalars[scalar + ParticleStore.LIFE];
1242
- if (dead && (!isTrailMode || (particle as TrailParticle).historyCount === 0)) {
1423
+ if (dead && particle.historyCount === 0) {
1243
1424
  const last = liveParticleCount - 1;
1244
1425
  const survivor = particles[last] as StoreBackedParticle;
1245
1426
  particles[i] = survivor;
1246
1427
  particles[last] = particle;
1247
- // Move the rows along with the particles so slot k always owns
1248
- // row k. Live particles then stay one contiguous range, which is
1249
- // what lets the renderer copy them without gathering.
1250
1428
  if (i !== last) {
1251
1429
  store.swapRows(i, last);
1252
1430
  survivor.setStoreIndex(i);
@@ -1277,11 +1455,142 @@ export class ParticleSystem implements IParticleSystem {
1277
1455
  }
1278
1456
  }
1279
1457
 
1458
+ /** Waves a burst fires per loop: its `cycle`, and never fewer than one. */
1459
+ private static burstWaveCount(burst: BurstParameters): number {
1460
+ return burst.cycle >= 1 ? Math.floor(burst.cycle) : 1;
1461
+ }
1462
+
1463
+ /** Seconds between a burst's waves; a missing or negative interval fires them all at once. */
1464
+ private static burstInterval(burst: BurstParameters): number {
1465
+ return burst.interval > 0 ? burst.interval : 0;
1466
+ }
1467
+
1468
+ /**
1469
+ * Fires the burst waves that are due by `emissionState.time`.
1470
+ *
1471
+ * Wave `w` of a burst is due at `burst.time + w * burst.interval`, and the
1472
+ * burst fires `cycle` of them, each rolling `probability` on its own — the
1473
+ * way Unity repeats a burst. Bursts start in time order, as they always
1474
+ * have; their repeats are tracked per burst so a burst that is still
1475
+ * repeating never holds back a later one. Repeats falling past the
1476
+ * system's duration are dropped (a looping system starts them over on its
1477
+ * next loop), matching Unity, which never emits a burst cycle beyond the
1478
+ * end of the duration.
1479
+ */
1480
+ private emitBursts(emissionState: EmissionState, emitterMatrix: Matrix4): void {
1481
+ const bursts = this.emissionBursts;
1482
+ const burstCount = bursts.length;
1483
+ const time = emissionState.time;
1484
+ const waves = (emissionState.burstWaves ??= []);
1485
+ while (emissionState.burstIndex < burstCount && bursts[emissionState.burstIndex].time <= time) {
1486
+ waves[emissionState.burstIndex] = 0;
1487
+ emissionState.burstIndex++;
1488
+ }
1489
+ const started = emissionState.burstIndex;
1490
+ for (let b = 0; b < started; b++) {
1491
+ const burst = bursts[b];
1492
+ const cycles = ParticleSystem.burstWaveCount(burst);
1493
+ let wave = waves[b] ?? 0;
1494
+ if (wave >= cycles) {
1495
+ continue;
1496
+ }
1497
+ const interval = ParticleSystem.burstInterval(burst);
1498
+ while (wave < cycles) {
1499
+ const waveTime = burst.time + wave * interval;
1500
+ if (wave > 0 && waveTime > this.duration) {
1501
+ wave = cycles;
1502
+ break;
1503
+ }
1504
+ if (waveTime > time) {
1505
+ break;
1506
+ }
1507
+ emissionState.burstWaveIndex = wave;
1508
+ this.fireBurstWave(burst, emissionState, emitterMatrix);
1509
+ wave++;
1510
+ }
1511
+ waves[b] = wave;
1512
+ }
1513
+ }
1514
+
1515
+ /** Spawns one wave of `burst`, if its probability roll passes. */
1516
+ private fireBurstWave(burst: BurstParameters, emissionState: EmissionState, emitterMatrix: Matrix4): void {
1517
+ if (Math.random() >= burst.probability) {
1518
+ return;
1519
+ }
1520
+ const rawCount = burst.count.genValue(this.memory, this.time);
1521
+ const qualityFactor = this.qualityFactor;
1522
+ const count = qualityFactor >= 0.999 ? rawCount : Math.floor(rawCount * qualityFactor);
1523
+ emissionState.isBursting = true;
1524
+ emissionState.burstParticleCount = count;
1525
+ this.spawn(count, emissionState, emitterMatrix);
1526
+ emissionState.isBursting = false;
1527
+ }
1528
+
1529
+ /** True while a burst that has started still has a wave due within the duration. */
1530
+ private hasPendingBurstWaves(state: EmissionState): boolean {
1531
+ const waves = state.burstWaves;
1532
+ if (waves === undefined) {
1533
+ return false;
1534
+ }
1535
+ const bursts = this.emissionBursts;
1536
+ for (let b = 0; b < state.burstIndex && b < bursts.length; b++) {
1537
+ const burst = bursts[b];
1538
+ const wave = waves[b] ?? 0;
1539
+ if (
1540
+ wave < ParticleSystem.burstWaveCount(burst) &&
1541
+ (wave === 0 || burst.time + wave * ParticleSystem.burstInterval(burst) <= this.duration)
1542
+ ) {
1543
+ return true;
1544
+ }
1545
+ }
1546
+ return false;
1547
+ }
1548
+
1549
+ /**
1550
+ * Spawns `count` particles owed to emission over distance, each where the
1551
+ * emitter crossed its metre mark on the way from `from` to where
1552
+ * `emitterMatrix` has it now, rather than all of them at the end of the
1553
+ * path: a fast emitter then leaves a continuous trail instead of one clump
1554
+ * per step.
1555
+ *
1556
+ * `carried` is how far the emitter had already gone past its last mark
1557
+ * before this step, so the marks keep their spacing across steps. Only the
1558
+ * position is interpolated; the emitter's current rotation and scale are
1559
+ * used for every particle.
1560
+ */
1561
+ private spawnAlongPath(
1562
+ count: number,
1563
+ emissionState: EmissionState,
1564
+ emitterMatrix: Matrix4,
1565
+ from: Vector3,
1566
+ carried: number,
1567
+ spacing: number,
1568
+ length: number
1569
+ ): void {
1570
+ const matrix = this.pathMatrix.copy(emitterMatrix);
1571
+ const target = emitterMatrix.elements;
1572
+ const elements = matrix.elements;
1573
+ const tx = target[12];
1574
+ const ty = target[13];
1575
+ const tz = target[14];
1576
+ for (let k = 1; k <= count; k++) {
1577
+ const travelled = k * spacing - carried;
1578
+ const f = travelled <= 0 ? 0 : travelled >= length ? 1 : travelled / length;
1579
+ elements[12] = from.x + (tx - from.x) * f;
1580
+ elements[13] = from.y + (ty - from.y) * f;
1581
+ elements[14] = from.z + (tz - from.z) * f;
1582
+ this.spawn(1, emissionState, matrix);
1583
+ }
1584
+ }
1585
+
1280
1586
  emit(delta: number, emissionState: EmissionState, emitterMatrix: Matrix4) {
1281
1587
  if (emissionState.time > this.duration) {
1282
1588
  if (this.looping) {
1283
1589
  emissionState.time -= this.duration;
1284
1590
  emissionState.burstIndex = 0;
1591
+ if (emissionState.burstWaves !== undefined) {
1592
+ emissionState.burstWaves.length = 0;
1593
+ }
1285
1594
  for (let i = 0; i < this.behaviors.length; i++) {
1286
1595
  this.behaviors[i].reset();
1287
1596
  }
@@ -1297,39 +1606,41 @@ export class ParticleSystem implements IParticleSystem {
1297
1606
  if (this.worldSpace) {
1298
1607
  this.normalMatrix.getNormalMatrix(emitterMatrix);
1299
1608
  }
1300
- const emissionBursts = this.emissionBursts;
1301
- const emissionBurstCount = emissionBursts.length;
1609
+ const emissionBurstCount = this.emissionBursts.length;
1302
1610
  const qualityFactor = this.qualityFactor;
1303
1611
 
1304
- while (
1305
- emissionState.burstIndex < emissionBurstCount &&
1306
- emissionBursts[emissionState.burstIndex].time <= emissionState.time
1307
- ) {
1308
- const burst = emissionBursts[emissionState.burstIndex];
1309
- if (Math.random() < burst.probability) {
1310
- const rawCount = burst.count.genValue(this.memory, this.time);
1311
- const count = qualityFactor >= 0.999 ? rawCount : Math.floor(rawCount * qualityFactor);
1312
- emissionState.isBursting = true;
1313
- emissionState.burstParticleCount = count;
1314
- this.spawn(count, emissionState, emitterMatrix);
1315
- emissionState.isBursting = false;
1316
- }
1317
- emissionState.burstIndex++;
1612
+ if (emissionBurstCount > 0) {
1613
+ this.emitBursts(emissionState, emitterMatrix);
1318
1614
  }
1319
1615
 
1616
+ // Particles owed to the distance travelled this step, spawned along it.
1617
+ let pathSpawn = 0;
1618
+ let pathSpacing = 0;
1619
+ let pathCarried = 0;
1620
+ let pathLength = 0;
1621
+ const previousWorldPos = emissionState.previousWorldPos;
1320
1622
  if (!this.emitEnded) {
1321
1623
  const timeRatio = emissionState.time / this.duration;
1322
1624
  emissionState.waitEmiting += delta * this.emissionOverTime.genValue(this.memory, timeRatio) * qualityFactor;
1323
1625
 
1324
- const previousWorldPos = emissionState.previousWorldPos;
1325
1626
  if (previousWorldPos !== undefined) {
1326
1627
  this.temp.set(emitterMatrix.elements[12], emitterMatrix.elements[13], emitterMatrix.elements[14]);
1327
- emissionState.travelDistance += previousWorldPos.distanceTo(this.temp);
1628
+ pathLength = previousWorldPos.distanceTo(this.temp);
1629
+ pathCarried = emissionState.travelDistance;
1630
+ emissionState.travelDistance += pathLength;
1328
1631
  const emitPerMeter = this.emissionOverDistance.genValue(this.memory, timeRatio) * qualityFactor;
1329
1632
  if (emissionState.travelDistance * emitPerMeter > 0) {
1330
1633
  const count = Math.floor(emissionState.travelDistance * emitPerMeter);
1331
1634
  emissionState.travelDistance -= count / emitPerMeter;
1332
- emissionState.waitEmiting += count;
1635
+ // A world-space particle stays where it is born, so it is born
1636
+ // where the emitter passed its mark. A local-space one moves
1637
+ // with the emitter and has no path to be spread along.
1638
+ if (this.worldSpace && pathLength > 0) {
1639
+ pathSpawn = count;
1640
+ pathSpacing = 1 / emitPerMeter;
1641
+ } else {
1642
+ emissionState.waitEmiting += count;
1643
+ }
1333
1644
  }
1334
1645
  }
1335
1646
  }
@@ -1341,6 +1652,17 @@ export class ParticleSystem implements IParticleSystem {
1341
1652
  this.spawn(totalSpawn, emissionState, emitterMatrix);
1342
1653
  emissionState.waitEmiting -= totalSpawn;
1343
1654
  }
1655
+ if (pathSpawn > 0) {
1656
+ this.spawnAlongPath(
1657
+ pathSpawn,
1658
+ emissionState,
1659
+ emitterMatrix,
1660
+ previousWorldPos!,
1661
+ pathCarried,
1662
+ pathSpacing,
1663
+ pathLength
1664
+ );
1665
+ }
1344
1666
 
1345
1667
  if (emissionState.previousWorldPos === undefined) emissionState.previousWorldPos = new Vector3();
1346
1668
  emissionState.previousWorldPos.set(
@@ -1517,6 +1839,8 @@ export class ParticleSystem implements IParticleSystem {
1517
1839
  layerMask: json.layers,
1518
1840
  pivot: json.pivot ? new Vector3(json.pivot[0] ?? 0, json.pivot[1] ?? 0, json.pivot[2] ?? 0) : undefined,
1519
1841
  flip: json.flip ? new Vector2(json.flip[0] ?? 0, json.flip[1] ?? 0) : undefined,
1842
+ lighting: materialMeta?.lighting,
1843
+ rim: materialMeta?.rim,
1520
1844
  });
1521
1845
  ps.behaviors = (json.behaviors ?? [])
1522
1846
  .map((behaviorJson) => {
@@ -1604,6 +1928,9 @@ export class ParticleSystem implements IParticleSystem {
1604
1928
  texture: textureUUID,
1605
1929
  reflectionAtlas: reflectionAtlasUUID,
1606
1930
  reflectionLevel: this.rendererSettings.reflectionLevel,
1931
+ // Written only when not the default, so existing effects serialise as before.
1932
+ ...(this.lighting !== 'fixed' ? {lighting: this.lighting} : {}),
1933
+ ...(this.rim ? {rim: {...this.rim, color: [...this.rim.color]}} : {}),
1607
1934
  sourceMaterial: this.materialRef ?? undefined,
1608
1935
  };
1609
1936
  return materialUUID;
@@ -1767,6 +2094,8 @@ export class ParticleSystem implements IParticleSystem {
1767
2094
  layerMask: this.rendererSettings.layerMask,
1768
2095
  pivot: this.pivot,
1769
2096
  flip: this.flip,
2097
+ lighting: this.lighting,
2098
+ rim: this.rim,
1770
2099
  scene: this.emitter.getScene(),
1771
2100
  });
1772
2101
  }