reze-engine 0.58.1 → 0.58.3

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.
Files changed (54) hide show
  1. package/dist/effect-points.d.ts +30 -0
  2. package/dist/effect-points.d.ts.map +1 -0
  3. package/dist/effect-points.js +49 -0
  4. package/dist/engine.d.ts +25 -2
  5. package/dist/engine.d.ts.map +1 -1
  6. package/dist/engine.js +199 -35
  7. package/dist/graph/style-group.d.ts +7 -0
  8. package/dist/graph/style-group.d.ts.map +1 -1
  9. package/dist/light-grid.d.ts +25 -0
  10. package/dist/light-grid.d.ts.map +1 -0
  11. package/dist/light-grid.js +91 -0
  12. package/dist/model.d.ts +9 -0
  13. package/dist/model.d.ts.map +1 -1
  14. package/dist/model.js +6 -0
  15. package/dist/pmx-loader.d.ts.map +1 -1
  16. package/dist/pmx-loader.js +11 -6
  17. package/dist/shaders/cast-api.d.ts +1 -1
  18. package/dist/shaders/cast-layout.d.ts +5 -3
  19. package/dist/shaders/cast-layout.d.ts.map +1 -1
  20. package/dist/shaders/cast-layout.js +5 -3
  21. package/dist/shaders/directives.d.ts +3 -0
  22. package/dist/shaders/directives.d.ts.map +1 -1
  23. package/dist/shaders/directives.js +5 -0
  24. package/dist/shaders/lights.d.ts +14 -9
  25. package/dist/shaders/lights.d.ts.map +1 -1
  26. package/dist/shaders/lights.js +118 -65
  27. package/dist/shaders/passes/composite.d.ts +1 -1
  28. package/dist/shaders/passes/composite.d.ts.map +1 -1
  29. package/dist/shaders/passes/composite.js +2 -0
  30. package/dist/shaders/passes/grid.d.ts.map +1 -1
  31. package/dist/shaders/passes/grid.js +2 -0
  32. package/dist/shaders/passes/particles.d.ts +20 -1
  33. package/dist/shaders/passes/particles.d.ts.map +1 -1
  34. package/dist/shaders/passes/particles.js +36 -1
  35. package/dist/shaders/passes/trails.d.ts.map +1 -1
  36. package/dist/shaders/passes/trails.js +2 -1
  37. package/dist/shaders/points-api.d.ts +11 -0
  38. package/dist/shaders/points-api.d.ts.map +1 -0
  39. package/dist/shaders/points-api.js +58 -0
  40. package/package.json +1 -1
  41. package/src/effect-points.ts +67 -0
  42. package/src/engine.ts +215 -35
  43. package/src/graph/style-group.ts +12 -1
  44. package/src/light-grid.ts +139 -0
  45. package/src/model.ts +12 -0
  46. package/src/pmx-loader.ts +13 -6
  47. package/src/shaders/cast-layout.ts +5 -3
  48. package/src/shaders/directives.ts +8 -0
  49. package/src/shaders/lights.ts +118 -65
  50. package/src/shaders/passes/composite.ts +2 -0
  51. package/src/shaders/passes/grid.ts +2 -0
  52. package/src/shaders/passes/particles.ts +46 -2
  53. package/src/shaders/passes/trails.ts +2 -1
  54. package/src/shaders/points-api.ts +66 -0
package/dist/engine.js CHANGED
@@ -29,7 +29,8 @@ import { packHalf } from "./hdr";
29
29
  import { evalIrradianceSH, projectIrradianceSH, gradientIrradianceSH } from "./ibl";
30
30
  import { LYRIC_ATLAS_MAX_H, LYRIC_ATLAS_MAX_W, LYRICS_FLOATS, packLyrics } from "./shaders/lyrics-api";
31
31
  import { sceneTargets as sceneTargetsFor, sceneColorFormats, setMrtIds, mrtIdsEnabled, SCENE_ID_FORMAT, } from "./shaders/passes/scene-contract";
32
- import { LIGHT_HEADER, LIGHT_STRIDE, LIGHTS_FLOATS, MAX_LIGHTS, buildLightEmitShader, hasLightEmit, } from "./shaders/lights";
32
+ import { LIGHT_GRID_BASE, LIGHT_HEADER, LIGHT_STRIDE, LIGHTS_FLOATS, MAX_LIGHTS, buildLightEmitShader, hasLightEmit, } from "./shaders/lights";
33
+ import { buildLightGrid } from "./light-grid";
33
34
  import { groundShaderWgsl, GROUND_NOISE_BAKE_WGSL, GROUND_NOISE_SIZE } from "./shaders/passes/ground";
34
35
  import { outlineShaderWgsl, RZ_OUTLINE_DISSOLVE_OFFSET } from "./shaders/passes/outline";
35
36
  import { transparentDepthPrepassWgsl } from "./shaders/passes/depth-prepass";
@@ -41,7 +42,9 @@ import { boneOverlay, boneMarkerPositions, buildOverlayShapes, jointOverlay, lin
41
42
  import { BLOOM_BLIT_SHADER_WGSL, BLOOM_DOWNSAMPLE_SHADER_WGSL, BLOOM_UPSAMPLE_SHADER_WGSL, } from "./shaders/passes/bloom";
42
43
  import { AGX_LUT_GZ, AGX_LUT_SIZE } from "./shaders/agx-lut";
43
44
  import { buildCompositeShader, EFFECT_SCENE_API, buildFieldShader, EFFECT_ANCHORS, EFFECT_SUBJECTS, EFFECT_TRAIL_BASE, EFFECT_TRAIL_SAMPLES, } from "./shaders/passes/composite";
44
- import { buildParticleComputeShader, buildParticleRenderShader, PARTICLE_LIGHT_BINDING, particleEntryPoints, PARTICLE_STRIDE, } from "./shaders/passes/particles";
45
+ import { buildParticleComputeShader, buildParticleRenderShader, PARTICLE_LIGHT_BINDING, PARTICLE_POINTS_BINDING, particleEntryPoints, PARTICLE_INDIRECT_BINDING, PARTICLE_INDIRECT_BYTES, PARTICLE_INDIRECT_DRAW_OFFSET, PARTICLE_STRIDE, } from "./shaders/passes/particles";
46
+ import { MAX_EFFECT_POINTS, POINTS_FLOATS } from "./shaders/points-api";
47
+ import { bonesWithPrefix, pointsData, writeBonePoint } from "./effect-points";
45
48
  import { SIM_FORMAT, GRID_MAX, buildSimShader, gridEntryPoint, } from "./shaders/passes/grid";
46
49
  import { buildCastResolveShader, buildCastSeedShader, buildCastStepShader, castDistanceUsed, CAST_COVERAGE_FORMAT, CAST_DIST_FORMAT, CAST_FIELD_DIV, CAST_SEED_FORMAT, } from "./shaders/passes/cast-distance";
47
50
  import { buildTrailShader, trailEntryPoints, TRAIL_SUBDIVISIONS } from "./shaders/passes/trails";
@@ -1077,8 +1080,17 @@ export class Engine {
1077
1080
  * effect is on 今汐" into a bit the shaders can read. */
1078
1081
  this.castSlotOf = new Map();
1079
1082
  this.effects = [];
1080
- /** Just the header, for rewriting the total without touching a record. */
1083
+ /** Per model, per prefix, the bones `#points` matched. A rig's bones never
1084
+ * change after load, so the names are walked once rather than every frame. */
1085
+ this.pointBones = new WeakMap();
1086
+ /** The CPU copy, from construction rather than init: a host sets a scene's
1087
+ * lamps as soon as its state exists, which can be before the device. */
1088
+ this.lightsData = new Float32Array(LIGHTS_FLOATS);
1089
+ /** The same bytes as words — the grid's lamp bits are u32, not floats. */
1090
+ this.lightsWords = new Uint32Array(this.lightsData.buffer);
1091
+ /** Just the header: the counts and the grid's placement. */
1081
1092
  this.lightHeader = new Float32Array(LIGHT_HEADER);
1093
+ this.lightHeaderWords = new Uint32Array(this.lightHeader.buffer);
1082
1094
  /** How many of the slots belong to the DOCUMENT. Effects get what follows. */
1083
1095
  this.docLightCount = 0;
1084
1096
  /** Last frame's anchor world positions, for velocity. Keyed model id → slot. */
@@ -1151,7 +1163,9 @@ export class Engine {
1151
1163
  // GPU texture can't be read back cheaply, and PMX diffuse alpha is usually 1.0
1152
1164
  // even for see-through cloth: the translucency lives in the texture).
1153
1165
  this.textureAlphaCache = new Map();
1154
- this.mipBlitPipeline = null;
1166
+ /** One per format: a render pipeline is bound to its target's format, and
1167
+ * material textures are sRGB while a group's data maps are not. */
1168
+ this.mipBlitPipelines = new Map();
1155
1169
  this.mipBlitSampler = null;
1156
1170
  this._nextDefaultModelId = 0;
1157
1171
  // IK and physics enabled at engine level (same for all models)
@@ -3044,6 +3058,8 @@ export class Engine {
3044
3058
  e.particles.computeBinds = b.computeBinds;
3045
3059
  e.particles.renderBinds = b.renderBinds;
3046
3060
  e.particles.mirrorRenderBinds = b.mirrorRenderBinds;
3061
+ if (e.particles.live && b.countBinds)
3062
+ e.particles.live.binds = b.countBinds;
3047
3063
  }
3048
3064
  if (e.grid)
3049
3065
  e.grid.binds = [this.gridBindGroup(e.grid, 0), this.gridBindGroup(e.grid, 1)];
@@ -3568,6 +3584,8 @@ export class Engine {
3568
3584
  paramsBuffer?.destroy();
3569
3585
  particles?.buffer.destroy();
3570
3586
  particles?.uniform.destroy();
3587
+ particles?.points?.buffer.destroy();
3588
+ particles?.live?.indirect.destroy();
3571
3589
  grid?.textures[0].destroy();
3572
3590
  grid?.textures[1].destroy();
3573
3591
  grid?.uniform.destroy();
@@ -3921,8 +3939,20 @@ export class Engine {
3921
3939
  paramsDecl: params.wgsl(EFFECT_PARAMS_BINDING),
3922
3940
  gridSize: grid?.size ?? 0,
3923
3941
  cover: particleEntryPoints(wgsl).cover,
3942
+ live: particleEntryPoints(wgsl).count,
3924
3943
  };
3925
3944
  const prepass = src.blend === "cutout" && src.cover;
3945
+ const points = d.points
3946
+ ? {
3947
+ prefix: d.points,
3948
+ buffer: this.device.createBuffer({
3949
+ label: `points "${d.points}"`,
3950
+ size: POINTS_FLOATS * 4,
3951
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
3952
+ }),
3953
+ data: pointsData(),
3954
+ }
3955
+ : null;
3926
3956
  // Sparks want to spawn where a trail is, so the particle stages see the same
3927
3957
  // cast buffer the trail draw reads.
3928
3958
  const cast = {
@@ -3948,11 +3978,15 @@ export class Engine {
3948
3978
  return diagnostics.length ? diagnostics : module;
3949
3979
  };
3950
3980
  const computeModule = await compile(buildParticleComputeShader(src, cast), "particle compute");
3951
- if (Array.isArray(computeModule))
3981
+ if (Array.isArray(computeModule)) {
3982
+ points?.buffer.destroy();
3952
3983
  return { ok: false, diagnostics: computeModule };
3984
+ }
3953
3985
  const renderModule = await compile(buildParticleRenderShader(src, cast), "particle render");
3954
- if (Array.isArray(renderModule))
3986
+ if (Array.isArray(renderModule)) {
3987
+ points?.buffer.destroy();
3955
3988
  return { ok: false, diagnostics: renderModule };
3989
+ }
3956
3990
  // COPY_DST so a scheduled effect can empty its pool when its window starts.
3957
3991
  const buffer = this.device.createBuffer({
3958
3992
  label: "particle pool",
@@ -3969,13 +4003,23 @@ export class Engine {
3969
4003
  });
3970
4004
  const uniformBytes = new ArrayBuffer(32);
3971
4005
  const uniformView = { floats: new Float32Array(uniformBytes), uints: new Uint32Array(uniformBytes) };
4006
+ // The live count's arguments — written by rzCount, read by the step's
4007
+ // dispatch and the quads' draws. Zero until the first step, so a pool
4008
+ // drawn before it has stepped draws nothing rather than everything.
4009
+ const indirect = src.live
4010
+ ? this.device.createBuffer({
4011
+ label: "particle indirect",
4012
+ size: PARTICLE_INDIRECT_BYTES,
4013
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.INDIRECT,
4014
+ })
4015
+ : null;
3972
4016
  // Visibility is per LAYOUT, not shared: a read_write storage buffer may not be
3973
4017
  // visible to the vertex stage at all (WebGPU forbids it — a vertex shader
3974
4018
  // that could write memory has no defined ordering against the rasteriser).
3975
4019
  // Declaring one set of flags for both layouts is what made the pipeline
3976
4020
  // layout invalid, and the error surfaces later and unhelpfully as "invalid
3977
4021
  // due to a previous error".
3978
- const layoutFor = (storage, visibility, shadow) => this.device.createBindGroupLayout({
4022
+ const layoutFor = (storage, visibility, shadow, withIndirect) => this.device.createBindGroupLayout({
3979
4023
  entries: [
3980
4024
  { binding: 0, visibility, buffer: { type: storage } },
3981
4025
  { binding: 1, visibility, buffer: { type: "uniform" } },
@@ -3994,6 +4038,9 @@ export class Engine {
3994
4038
  // author calls it or not. With no grid it is the 1x1 of zeroes.
3995
4039
  { binding: 8, visibility, texture: { sampleType: "float" } },
3996
4040
  { binding: 9, visibility, sampler: { type: "filtering" } },
4041
+ // The named points, ALWAYS, for the grid's reason: the accessor is
4042
+ // compiled in whether or not the effect declared #points.
4043
+ { binding: PARTICLE_POINTS_BINDING, visibility, buffer: { type: "read-only-storage" } },
3997
4044
  // The scene's light, for rzShadow and rzWorldAmbient — the shading
3998
4045
  // stage only; the compute module compiles the stubs. See
3999
4046
  // scene-light-api.ts.
@@ -4006,13 +4053,21 @@ export class Engine {
4006
4053
  { binding: PARTICLE_LIGHT_BINDING + 4, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "comparison" } },
4007
4054
  ]
4008
4055
  : []),
4056
+ // The live count's arguments, for the COUNT kernel's layout alone. The
4057
+ // step's layout must not carry them: a bound group's entries count
4058
+ // toward a pass's usage whether the shader reads them or not, and a
4059
+ // buffer may not be writable storage and indirect arguments in one
4060
+ // pass.
4061
+ ...(withIndirect
4062
+ ? [{ binding: PARTICLE_INDIRECT_BINDING, visibility: GPUShaderStage.COMPUTE, buffer: { type: "storage" } }]
4063
+ : []),
4009
4064
  ],
4010
4065
  });
4011
4066
  /** One per GRID PARITY, for the reason rebuildFieldBindGroup gives: the grid
4012
4067
  * alternates which texture is current, and rebuilding a single group every
4013
4068
  * frame is waste for a change that only ever toggles between two known
4014
4069
  * states. Both entries are the same view when there is no grid. */
4015
- const bindFor = (layout, camera, shadow) => [0, 1].map((parity) => this.device.createBindGroup({
4070
+ const bindFor = (layout, camera, shadow, withIndirect) => [0, 1].map((parity) => this.device.createBindGroup({
4016
4071
  layout,
4017
4072
  entries: [
4018
4073
  { binding: 0, resource: { buffer } },
@@ -4025,6 +4080,7 @@ export class Engine {
4025
4080
  ...(params.buffer ? [{ binding: 7, resource: { buffer: params.buffer } }] : []),
4026
4081
  { binding: 8, resource: grid ? grid.read[parity] : this.simFallbackView },
4027
4082
  { binding: 9, resource: this.simSampler },
4083
+ { binding: PARTICLE_POINTS_BINDING, resource: { buffer: points?.buffer ?? this.pointsFallback } },
4028
4084
  ...(shadow
4029
4085
  ? [
4030
4086
  { binding: PARTICLE_LIGHT_BINDING, resource: { buffer: this.lightUniformBuffer } },
@@ -4034,10 +4090,12 @@ export class Engine {
4034
4090
  { binding: PARTICLE_LIGHT_BINDING + 4, resource: this.shadowComparisonSampler },
4035
4091
  ]
4036
4092
  : []),
4093
+ ...(withIndirect && indirect ? [{ binding: PARTICLE_INDIRECT_BINDING, resource: { buffer: indirect } }] : []),
4037
4094
  ],
4038
4095
  }));
4039
- const computeLayout = layoutFor("storage", GPUShaderStage.COMPUTE, false);
4040
- const renderLayout = layoutFor("read-only-storage", GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, true);
4096
+ const computeLayout = layoutFor("storage", GPUShaderStage.COMPUTE, false, false);
4097
+ const countLayout = indirect ? layoutFor("storage", GPUShaderStage.COMPUTE, false, true) : null;
4098
+ const renderLayout = layoutFor("read-only-storage", GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, true, false);
4041
4099
  // Additive keeps the destination and adds to it, and leaves alpha alone, so
4042
4100
  // a glow does not claim coverage it never occluded. The MASK sums with it —
4043
4101
  // otherwise an additive effect could never reach the bloom gate. Both live
@@ -4050,11 +4108,23 @@ export class Engine {
4050
4108
  const cutout = src.blend === "cutout";
4051
4109
  this.device.pushErrorScope("validation");
4052
4110
  try {
4111
+ const computePipelineLayout = this.device.createPipelineLayout({ bindGroupLayouts: [computeLayout] });
4053
4112
  const compute = await this.device.createComputePipelineAsync({
4054
4113
  label: "particle compute pipeline",
4055
- layout: this.device.createPipelineLayout({ bindGroupLayouts: [computeLayout] }),
4114
+ layout: computePipelineLayout,
4056
4115
  compute: { module: computeModule, entryPoint: "main" },
4057
4116
  });
4117
+ const live = indirect && countLayout
4118
+ ? {
4119
+ pipeline: await this.device.createComputePipelineAsync({
4120
+ label: "particle count pipeline",
4121
+ layout: this.device.createPipelineLayout({ bindGroupLayouts: [countLayout] }),
4122
+ compute: { module: computeModule, entryPoint: "rzCount" },
4123
+ }),
4124
+ indirect,
4125
+ binds: bindFor(countLayout, this.cameraUniformBuffer, false, true),
4126
+ }
4127
+ : null;
4058
4128
  const renderLayoutObj = this.device.createPipelineLayout({ bindGroupLayouts: [renderLayout] });
4059
4129
  // The prepass, when the effect gave the cutout a cheap shape: coverage to
4060
4130
  // depth, colour targets at writeMask 0 (the depth-prepass class), and
@@ -4096,6 +4166,8 @@ export class Engine {
4096
4166
  if (scoped) {
4097
4167
  buffer.destroy();
4098
4168
  uniform.destroy();
4169
+ points?.buffer.destroy();
4170
+ indirect?.destroy();
4099
4171
  return { ok: false, diagnostics: [scoped.message] };
4100
4172
  }
4101
4173
  return {
@@ -4111,17 +4183,20 @@ export class Engine {
4111
4183
  counts: uniformView.uints,
4112
4184
  compute,
4113
4185
  computeLayout,
4114
- computeBinds: bindFor(computeLayout, this.cameraUniformBuffer, false),
4186
+ computeBinds: bindFor(computeLayout, this.cameraUniformBuffer, false, false),
4115
4187
  depth,
4116
4188
  render,
4117
4189
  renderLayout,
4118
- renderBinds: bindFor(renderLayout, this.cameraUniformBuffer, true),
4119
- mirrorRenderBinds: bindFor(renderLayout, this.mirrorCameraBuffer, true),
4190
+ renderBinds: bindFor(renderLayout, this.cameraUniformBuffer, true, false),
4191
+ mirrorRenderBinds: bindFor(renderLayout, this.mirrorCameraBuffer, true, false),
4120
4192
  rebind: () => ({
4121
- computeBinds: bindFor(computeLayout, this.cameraUniformBuffer, false),
4122
- renderBinds: bindFor(renderLayout, this.cameraUniformBuffer, true),
4123
- mirrorRenderBinds: bindFor(renderLayout, this.mirrorCameraBuffer, true),
4193
+ computeBinds: bindFor(computeLayout, this.cameraUniformBuffer, false, false),
4194
+ renderBinds: bindFor(renderLayout, this.cameraUniformBuffer, true, false),
4195
+ mirrorRenderBinds: bindFor(renderLayout, this.mirrorCameraBuffer, true, false),
4196
+ countBinds: countLayout ? bindFor(countLayout, this.cameraUniformBuffer, false, true) : null,
4124
4197
  }),
4198
+ points,
4199
+ live,
4125
4200
  },
4126
4201
  };
4127
4202
  }
@@ -4129,6 +4204,8 @@ export class Engine {
4129
4204
  await this.device.popErrorScope();
4130
4205
  buffer.destroy();
4131
4206
  uniform.destroy();
4207
+ points?.buffer.destroy();
4208
+ indirect?.destroy();
4132
4209
  return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)] };
4133
4210
  }
4134
4211
  }
@@ -4249,6 +4326,7 @@ export class Engine {
4249
4326
  this.device.queue.writeBuffer(e.lights.uniform, 0, e.lights.data.buffer);
4250
4327
  }
4251
4328
  this.lightHeader[0] = Math.min(next, MAX_LIGHTS);
4329
+ this.lightHeader[1] = this.docLightCount;
4252
4330
  this.device.queue.writeBuffer(this.lightsBuffer, 0, this.lightHeader);
4253
4331
  }
4254
4332
  /** Run every effect's lightEmit, before the pass that reads the result. */
@@ -4311,13 +4389,62 @@ export class Engine {
4311
4389
  p.counts[2] = p.count;
4312
4390
  p.counts[3] = this.particleFrame++;
4313
4391
  this.device.queue.writeBuffer(p.uniform, 0, p.data.buffer);
4392
+ if (p.points)
4393
+ this.writePoints(p.points);
4394
+ const bind = p.computeBinds[e.grid?.parity ?? 0];
4395
+ if (p.live) {
4396
+ // The frame's live count first, in a pass of its own: a buffer may
4397
+ // not be written as storage and read as indirect arguments inside one
4398
+ // pass, and a pass is the synchronization scope.
4399
+ const count = encoder.beginComputePass({ label: "particle count" });
4400
+ count.setPipeline(p.live.pipeline);
4401
+ count.setBindGroup(0, p.live.binds[e.grid?.parity ?? 0]);
4402
+ count.dispatchWorkgroups(1);
4403
+ count.end();
4404
+ }
4314
4405
  const cp = encoder.beginComputePass({ label: "particles" });
4315
4406
  cp.setPipeline(p.compute);
4316
- cp.setBindGroup(0, p.computeBinds[e.grid?.parity ?? 0]);
4317
- cp.dispatchWorkgroups(Math.ceil(p.count / 64));
4407
+ cp.setBindGroup(0, bind);
4408
+ if (p.live)
4409
+ cp.dispatchWorkgroupsIndirect(p.live.indirect, 0);
4410
+ else
4411
+ cp.dispatchWorkgroups(Math.ceil(p.count / 64));
4318
4412
  cp.end();
4319
4413
  }
4320
4414
  }
4415
+ /**
4416
+ * Every bone matching an effect's `#points` prefix, on every visible model,
4417
+ * posed and placed this frame. Every frame rather than on load: a candle a
4418
+ * character carries moves with the hand, and a stage can be moved.
4419
+ *
4420
+ * The order is the models' order, then rig order — stable while nothing is
4421
+ * added or removed, so particle i stays on point i and a flame does not
4422
+ * jump to another wick between frames.
4423
+ */
4424
+ writePoints(points) {
4425
+ let n = 0;
4426
+ for (const inst of this.modelInstances.values()) {
4427
+ const m = inst.model;
4428
+ if (!m.visible)
4429
+ continue;
4430
+ let byPrefix = this.pointBones.get(m);
4431
+ if (!byPrefix)
4432
+ this.pointBones.set(m, (byPrefix = new Map()));
4433
+ let bones = byPrefix.get(points.prefix);
4434
+ const rig = m.getSkeleton().bones;
4435
+ if (!bones)
4436
+ byPrefix.set(points.prefix, (bones = bonesWithPrefix(rig, points.prefix)));
4437
+ for (const i of bones) {
4438
+ if (n >= MAX_EFFECT_POINTS)
4439
+ break;
4440
+ const world = m.getBoneWorldMatrixAt(i);
4441
+ if (world)
4442
+ writeBonePoint(points.data, n++, world, rig[i].tail, m);
4443
+ }
4444
+ }
4445
+ points.data[0] = n;
4446
+ this.device.queue.writeBuffer(points.buffer, 0, points.data.buffer, 0, (4 + n * 8) * 4);
4447
+ }
4321
4448
  /** Draw the pool. Inside the scene pass, so it is depth-tested and pre-bloom. */
4322
4449
  renderParticles(pass, view) {
4323
4450
  for (const e of this.effects) {
@@ -4326,14 +4453,21 @@ export class Engine {
4326
4453
  continue;
4327
4454
  const parity = e.grid?.parity ?? 0;
4328
4455
  const bind = view === "mirror" ? p.mirrorRenderBinds[parity] : p.renderBinds[parity];
4456
+ // The live count's quads when the effect declared one, else the pool's.
4457
+ const draw = () => {
4458
+ if (p.live)
4459
+ pass.drawIndirect(p.live.indirect, PARTICLE_INDIRECT_DRAW_OFFSET);
4460
+ else
4461
+ pass.draw(6, p.count);
4462
+ };
4329
4463
  if (p.depth) {
4330
4464
  pass.setPipeline(p.depth);
4331
4465
  pass.setBindGroup(0, bind);
4332
- pass.draw(6, p.count);
4466
+ draw();
4333
4467
  }
4334
4468
  pass.setPipeline(p.render);
4335
4469
  pass.setBindGroup(0, bind);
4336
- pass.draw(6, p.count);
4470
+ draw();
4337
4471
  }
4338
4472
  }
4339
4473
  /**
@@ -4699,6 +4833,8 @@ export class Engine {
4699
4833
  for (const e of this.effects) {
4700
4834
  e.particles?.buffer.destroy();
4701
4835
  e.particles?.uniform.destroy();
4836
+ e.particles?.points?.buffer.destroy();
4837
+ e.particles?.live?.indirect.destroy();
4702
4838
  e.particles = null;
4703
4839
  }
4704
4840
  }
@@ -6062,15 +6198,21 @@ export class Engine {
6062
6198
  this.bakeFilmicLut();
6063
6199
  // BEFORE the bind group below, which binds it. Full size from the start:
6064
6200
  // every material pipeline binds this, so sizing it to the light count would
6065
- // mean rebuilding bind groups whenever a scene gained a lamp. Zero-filled,
6066
- // and float 0 is a count of 0.
6067
- this.lightsData = new Float32Array(LIGHTS_FLOATS);
6201
+ // mean rebuilding bind groups whenever a scene gained a lamp. Uploaded from
6202
+ // the CPU copy, which holds any lamps set before the device existed; the
6203
+ // header after it, from the one writer that owns it.
6068
6204
  this.lightsBuffer = this.device.createBuffer({
6069
6205
  label: "positional lights",
6070
6206
  size: this.lightsData.byteLength,
6071
6207
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
6072
6208
  });
6073
6209
  this.device.queue.writeBuffer(this.lightsBuffer, 0, this.lightsData);
6210
+ this.allocateLightSlots();
6211
+ this.pointsFallback = this.device.createBuffer({
6212
+ label: "no points",
6213
+ size: 16,
6214
+ usage: GPUBufferUsage.STORAGE,
6215
+ });
6074
6216
  // BEFORE the per-frame groups, which bind it. The composite needs this 1x1
6075
6217
  // stand-in too, and it used to be created with the composite's own
6076
6218
  // resources further down — far enough down that a material group built up
@@ -8418,10 +8560,23 @@ export class Engine {
8418
8560
  this.lightsData[b + 14] = 0;
8419
8561
  this.lightsData[b + 15] = 0;
8420
8562
  }
8563
+ // The grid, from the records as STORED — normalised aim, clamped reach, the
8564
+ // cosines in f32 — so it indexes exactly what the shader will evaluate.
8565
+ const grid = buildLightGrid(list.map((_, i) => {
8566
+ const b = LIGHT_HEADER + i * LIGHT_STRIDE;
8567
+ const d = this.lightsData;
8568
+ return { x: d[b], y: d[b + 1], z: d[b + 2], radius: d[b + 3], ax: d[b + 8], ay: d[b + 9], az: d[b + 10], cosOuter: d[b + 11] };
8569
+ }));
8570
+ this.lightHeader.set(grid.origin, 4);
8571
+ this.lightHeader[7] = 1 / grid.cell;
8572
+ this.lightHeaderWords.set(grid.dims, 8);
8573
+ this.lightHeaderWords.set(grid.outside, 12);
8574
+ this.lightsWords.set(grid.cells, LIGHT_GRID_BASE);
8421
8575
  // The CPU copy is written either way; only the upload needs a device. A
8422
8576
  // scene whose lamps arrive before init keeps them, and init uploads them.
8423
8577
  if (list.length && this.device && this.lightsBuffer) {
8424
8578
  this.device.queue.writeBuffer(this.lightsBuffer, LIGHT_HEADER * 4, this.lightsData.buffer, LIGHT_HEADER * 4, list.length * LIGHT_STRIDE * 4);
8579
+ this.device.queue.writeBuffer(this.lightsBuffer, LIGHT_GRID_BASE * 4, this.lightsData.buffer, LIGHT_GRID_BASE * 4, grid.cells.byteLength);
8425
8580
  }
8426
8581
  // The effects' bases move when the document's count does; the header —
8427
8582
  // count included — is written there.
@@ -9849,15 +10004,19 @@ export class Engine {
9849
10004
  const src = wrapped ? entry.source : entry;
9850
10005
  const width = Math.max(1, "naturalWidth" in src ? src.naturalWidth : src.width);
9851
10006
  const height = Math.max(1, "naturalHeight" in src ? src.naturalHeight : src.height);
10007
+ const mipLevelCount = wrapped && entry.mipmaps ? Math.floor(Math.log2(Math.max(width, height))) + 1 : 1;
9852
10008
  const tex = this.device.createTexture({
9853
10009
  label: `group map: ${group.id}`,
9854
10010
  size: [width, height],
10011
+ mipLevelCount,
9855
10012
  // Colour maps decode to linear on sample, the way material textures do;
9856
10013
  // data maps must not, or every threshold packed in their channels moves.
9857
10014
  format: wrapped && entry.srgb ? "rgba8unorm-srgb" : "rgba8unorm",
9858
10015
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
9859
10016
  });
9860
10017
  this.device.queue.copyExternalImageToTexture({ source: src }, { texture: tex, premultipliedAlpha: wrapped && entry.premultiplied === true }, [width, height]);
10018
+ if (mipLevelCount > 1)
10019
+ this.generateMipmaps(tex, mipLevelCount);
9861
10020
  return tex;
9862
10021
  });
9863
10022
  }
@@ -12127,31 +12286,35 @@ export class Engine {
12127
12286
  // Bilinear box-filter downsample per level. Reads srgb view (hardware linearizes on sample,
12128
12287
  // re-encodes on write), so intensities are filtered in linear space — matching EEVEE/Blender.
12129
12288
  generateMipmaps(texture, mipLevelCount) {
12130
- if (!this.mipBlitPipeline || !this.mipBlitSampler) {
12289
+ if (!this.mipBlitSampler) {
12131
12290
  this.mipBlitSampler = this.device.createSampler({
12132
12291
  magFilter: "linear",
12133
12292
  minFilter: "linear",
12134
12293
  addressModeU: "clamp-to-edge",
12135
12294
  addressModeV: "clamp-to-edge",
12136
12295
  });
12296
+ }
12297
+ let pipeline = this.mipBlitPipelines.get(texture.format);
12298
+ if (!pipeline) {
12137
12299
  const module = this.device.createShaderModule({
12138
12300
  label: "mipmap blit",
12139
12301
  code: MIPMAP_BLIT_SHADER_WGSL,
12140
12302
  });
12141
- this.mipBlitPipeline = this.device.createRenderPipeline({
12142
- label: "mipmap blit pipeline",
12303
+ pipeline = this.device.createRenderPipeline({
12304
+ label: `mipmap blit pipeline (${texture.format})`,
12143
12305
  layout: "auto",
12144
12306
  vertex: { module, entryPoint: "vs" },
12145
- fragment: { module, entryPoint: "fs", targets: [{ format: "rgba8unorm-srgb" }] },
12307
+ fragment: { module, entryPoint: "fs", targets: [{ format: texture.format }] },
12146
12308
  primitive: { topology: "triangle-list" },
12147
12309
  });
12310
+ this.mipBlitPipelines.set(texture.format, pipeline);
12148
12311
  }
12149
12312
  const encoder = this.device.createCommandEncoder({ label: "mipgen" });
12150
12313
  for (let level = 1; level < mipLevelCount; level++) {
12151
12314
  const srcView = texture.createView({ baseMipLevel: level - 1, mipLevelCount: 1 });
12152
12315
  const dstView = texture.createView({ baseMipLevel: level, mipLevelCount: 1 });
12153
12316
  const bindGroup = this.device.createBindGroup({
12154
- layout: this.mipBlitPipeline.getBindGroupLayout(0),
12317
+ layout: pipeline.getBindGroupLayout(0),
12155
12318
  entries: [
12156
12319
  { binding: 0, resource: srcView },
12157
12320
  { binding: 1, resource: this.mipBlitSampler },
@@ -12162,7 +12325,7 @@ export class Engine {
12162
12325
  { view: dstView, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: "clear", storeOp: "store" },
12163
12326
  ],
12164
12327
  });
12165
- pass.setPipeline(this.mipBlitPipeline);
12328
+ pass.setPipeline(pipeline);
12166
12329
  pass.setBindGroup(0, bindGroup);
12167
12330
  pass.draw(3);
12168
12331
  pass.end();
@@ -14987,12 +15150,13 @@ Engine.JIGGLE_DAMPING_SCALE = 0.5;
14987
15150
  * prefix sum and readback a compacted draw list would need every frame.
14988
15151
  */
14989
15152
  /** Ceiling for `#particles`. Past this an author is asking for a stall. */
14990
- // Raised for the lawn: 65536 blades at real density is a 40-unit patch and
14991
- // an 80-unit one wants four hundred thousand. A cutout pool's cost is
15153
+ // Raised for the lawn: an 80-unit field at 36 blades a unit wants seven
15154
+ // hundred thousand, and a turf one three million. A cutout pool's cost is
14992
15155
  // bounded by the pixels it covers, not by the count — the step is one
14993
- // compute invocation per slot, the pool buffer is 48 bytes each (24 MB at
14994
- // the cap), and a culled blade is a degenerate quad the rasteriser drops.
14995
- Engine.MAX_PARTICLES = 524288;
15156
+ // compute invocation per slot, the pool buffer is 48 bytes each (96 MiB at
15157
+ // the cap, under the 128 MiB a storage binding may be without asking for
15158
+ // more), and a culled blade is a degenerate quad the rasteriser drops.
15159
+ Engine.MAX_PARTICLES = 2097152;
14996
15160
  /**
14997
15161
  * The field layer: user background/foreground mounts, ONE TARGET PAIR PER
14998
15162
  * RESOLUTION. Index 0 is full, index 1 is half — coarsest last, so the
@@ -75,6 +75,13 @@ export type GroupImage = {
75
75
  source: GroupImageSource;
76
76
  srgb?: boolean;
77
77
  premultiplied?: boolean;
78
+ /**
79
+ * Give it a mip chain. For a map TILED across a surface — a relief map, a
80
+ * water's ripples — whose far tiles otherwise shrink to a pixel each and
81
+ * sample as noise that sparkles with every camera move. Off by default: a
82
+ * ramp read at a computed coordinate would blur at its own terminator.
83
+ */
84
+ mipmaps?: boolean;
78
85
  };
79
86
  export type GroupDiagnostic = {
80
87
  groupId: string;
@@ -1 +1 @@
1
- {"version":3,"file":"style-group.d.ts","sourceRoot":"","sources":["../../src/graph/style-group.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,UAAU,CAAA;AACvD,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAC5D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,WAAW,CAAA;AAE1C,MAAM,MAAM,UAAU,GAAG;IACvB,wFAAwF;IACxF,EAAE,EAAE,MAAM,CAAA;IACV;2CACuC;IACvC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,2EAA2E;IAC3E,SAAS,EAAE,MAAM,EAAE,CAAA;IACnB,sFAAsF;IACtF,KAAK,EAAE,WAAW,CAAA;IAClB,uEAAuE;IACvE,WAAW,CAAC,EAAE,WAAW,CAAA;IACzB,wEAAwE;IACxE,SAAS,CAAC,EAAE,SAAS,CAAA;IACrB;;;;;;;;;;;;OAYG;IACH,MAAM,CAAC,EAAE,CAAC,UAAU,GAAG,gBAAgB,GAAG,IAAI,CAAC,EAAE,CAAA;IACjD;;;;;;;;;;;;OAYG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,UAAU,GAAG,gBAAgB,GAAG,IAAI,CAAC,EAAE,CAAC,CAAA;CAC5E,CAAA;AAED,MAAM,MAAM,gBAAgB,GAAG,WAAW,GAAG,gBAAgB,GAAG,iBAAiB,CAAA;AAEjF;;;;;;;;;;;;GAYG;AACH;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,UAAU,GAAG;IAAE,MAAM,EAAE,gBAAgB,CAAC;IAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IAAC,aAAa,CAAC,EAAE,OAAO,CAAA;CAAE,CAAA;AAE9F,MAAM,MAAM,eAAe,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,UAAU,EAAE,CAAC;IAAC,EAAE,EAAE,OAAO,CAAA;CAAE,CAAA;AAEzF,MAAM,MAAM,sBAAsB,GAAG;IACnC,sCAAsC;IACtC,EAAE,EAAE,OAAO,CAAA;IACX,MAAM,EAAE,eAAe,EAAE,CAAA;IACzB,iEAAiE;IACjE,gBAAgB,EAAE,MAAM,EAAE,CAAA;IAC1B,kFAAkF;IAClF,SAAS,EAAE,MAAM,EAAE,CAAA;CACpB,CAAA;AAED,MAAM,MAAM,qBAAqB,GAAG;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,WAAW,EAAE,UAAU,EAAE,CAAC;IAAC,OAAO,EAAE,SAAS,EAAE,CAAA;CAAE,CAAA"}
1
+ {"version":3,"file":"style-group.d.ts","sourceRoot":"","sources":["../../src/graph/style-group.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,UAAU,CAAA;AACvD,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAC5D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,WAAW,CAAA;AAE1C,MAAM,MAAM,UAAU,GAAG;IACvB,wFAAwF;IACxF,EAAE,EAAE,MAAM,CAAA;IACV;2CACuC;IACvC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,2EAA2E;IAC3E,SAAS,EAAE,MAAM,EAAE,CAAA;IACnB,sFAAsF;IACtF,KAAK,EAAE,WAAW,CAAA;IAClB,uEAAuE;IACvE,WAAW,CAAC,EAAE,WAAW,CAAA;IACzB,wEAAwE;IACxE,SAAS,CAAC,EAAE,SAAS,CAAA;IACrB;;;;;;;;;;;;OAYG;IACH,MAAM,CAAC,EAAE,CAAC,UAAU,GAAG,gBAAgB,GAAG,IAAI,CAAC,EAAE,CAAA;IACjD;;;;;;;;;;;;OAYG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,UAAU,GAAG,gBAAgB,GAAG,IAAI,CAAC,EAAE,CAAC,CAAA;CAC5E,CAAA;AAED,MAAM,MAAM,gBAAgB,GAAG,WAAW,GAAG,gBAAgB,GAAG,iBAAiB,CAAA;AAEjF;;;;;;;;;;;;GAYG;AACH;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,UAAU,GAAG;IACvB,MAAM,EAAE,gBAAgB,CAAA;IACxB,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB;;;;;OAKG;IACH,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB,CAAA;AAED,MAAM,MAAM,eAAe,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,UAAU,EAAE,CAAC;IAAC,EAAE,EAAE,OAAO,CAAA;CAAE,CAAA;AAEzF,MAAM,MAAM,sBAAsB,GAAG;IACnC,sCAAsC;IACtC,EAAE,EAAE,OAAO,CAAA;IACX,MAAM,EAAE,eAAe,EAAE,CAAA;IACzB,iEAAiE;IACjE,gBAAgB,EAAE,MAAM,EAAE,CAAA;IAC1B,kFAAkF;IAClF,SAAS,EAAE,MAAM,EAAE,CAAA;CACpB,CAAA;AAED,MAAM,MAAM,qBAAqB,GAAG;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,WAAW,EAAE,UAAU,EAAE,CAAC;IAAC,OAAO,EAAE,SAAS,EAAE,CAAA;CAAE,CAAA"}
@@ -0,0 +1,25 @@
1
+ /** One document lamp, as the grid sees it. */
2
+ export type GridLamp = {
3
+ x: number;
4
+ y: number;
5
+ z: number;
6
+ radius: number;
7
+ /** Unit aim, away from the lamp. Zero for a point lamp. */
8
+ ax: number;
9
+ ay: number;
10
+ az: number;
11
+ /** Cosine of the half-angle. -1 for a point lamp, which covers everything. */
12
+ cosOuter: number;
13
+ };
14
+ export type LightGrid = {
15
+ origin: [number, number, number];
16
+ /** Edge length of one cubic cell, world units. */
17
+ cell: number;
18
+ dims: [number, number, number];
19
+ /** The lamps that reach past the box, for fragments outside it. */
20
+ outside: Uint32Array;
21
+ /** LIGHT_MASK_WORDS words per cell, x fastest, then y, then z. */
22
+ cells: Uint32Array;
23
+ };
24
+ export declare function buildLightGrid(lamps: GridLamp[], maxCells?: number): LightGrid;
25
+ //# sourceMappingURL=light-grid.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"light-grid.d.ts","sourceRoot":"","sources":["../src/light-grid.ts"],"names":[],"mappings":"AA2BA,8CAA8C;AAC9C,MAAM,MAAM,QAAQ,GAAG;IACrB,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,MAAM,CAAA;IACT,MAAM,EAAE,MAAM,CAAA;IACd,2DAA2D;IAC3D,EAAE,EAAE,MAAM,CAAA;IACV,EAAE,EAAE,MAAM,CAAA;IACV,EAAE,EAAE,MAAM,CAAA;IACV,8EAA8E;IAC9E,QAAQ,EAAE,MAAM,CAAA;CACjB,CAAA;AAED,MAAM,MAAM,SAAS,GAAG;IACtB,MAAM,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAA;IAChC,kDAAkD;IAClD,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAA;IAC9B,mEAAmE;IACnE,OAAO,EAAE,WAAW,CAAA;IACpB,kEAAkE;IAClE,KAAK,EAAE,WAAW,CAAA;CACnB,CAAA;AAMD,wBAAgB,cAAc,CAAC,KAAK,EAAE,QAAQ,EAAE,EAAE,QAAQ,SAAmB,GAAG,SAAS,CAkFxF"}
@@ -0,0 +1,91 @@
1
+ import { LIGHT_GRID_CELLS, LIGHT_MASK_WORDS } from "./shaders/lights";
2
+ function setBit(words, base, lamp) {
3
+ words[base + (lamp >> 5)] |= 1 << (lamp & 31);
4
+ }
5
+ export function buildLightGrid(lamps, maxCells = LIGHT_GRID_CELLS) {
6
+ const outside = new Uint32Array(LIGHT_MASK_WORDS);
7
+ const live = lamps
8
+ .map((l, i) => ({ l, i }))
9
+ .filter(({ l }) => l.radius > 0 && [l.x, l.y, l.z, l.radius].every(Number.isFinite));
10
+ if (!live.length)
11
+ return { origin: [0, 0, 0], cell: 1, dims: [0, 0, 0], outside, cells: new Uint32Array(0) };
12
+ const radii = live.map(({ l }) => l.radius).sort((a, b) => a - b);
13
+ const pad = radii[radii.length >> 1];
14
+ const min = [Infinity, Infinity, Infinity];
15
+ const max = [-Infinity, -Infinity, -Infinity];
16
+ for (const { l } of live) {
17
+ const p = [l.x, l.y, l.z];
18
+ for (let k = 0; k < 3; k++) {
19
+ min[k] = Math.min(min[k], p[k] - pad);
20
+ max[k] = Math.max(max[k], p[k] + pad);
21
+ }
22
+ }
23
+ const extent = [max[0] - min[0], max[1] - min[1], max[2] - min[2]];
24
+ let cell = Math.cbrt((extent[0] * extent[1] * extent[2]) / maxCells);
25
+ let dims;
26
+ // ceil() rounds every axis up, so the first guess can overshoot the budget by
27
+ // a few percent; widen the cell until it fits.
28
+ for (;;) {
29
+ dims = [0, 1, 2].map((k) => Math.max(1, Math.ceil(extent[k] / cell)));
30
+ if (dims[0] * dims[1] * dims[2] <= maxCells)
31
+ break;
32
+ cell *= 1.02;
33
+ }
34
+ const cells = new Uint32Array(dims[0] * dims[1] * dims[2] * LIGHT_MASK_WORDS);
35
+ // Half the cell's diagonal: the sphere a spot's cone is tested against.
36
+ const halfDiagonal = (cell * Math.sqrt(3)) / 2;
37
+ for (const { l, i } of live) {
38
+ // A hair of slack on the reach: the shader finds its cell in f32 from a
39
+ // reciprocal, the builder in f64 from a division, and a fragment on a cell
40
+ // face may land in either neighbour.
41
+ const r = l.radius + cell * 1e-4;
42
+ const p = [l.x, l.y, l.z];
43
+ const lo = [0, 0, 0];
44
+ const hi = [0, 0, 0];
45
+ let leaves = false;
46
+ for (let k = 0; k < 3; k++) {
47
+ const a = Math.floor((p[k] - r - min[k]) / cell);
48
+ const b = Math.floor((p[k] + r - min[k]) / cell);
49
+ if (a < 0 || b >= dims[k])
50
+ leaves = true;
51
+ lo[k] = Math.max(a, 0);
52
+ hi[k] = Math.min(b, dims[k] - 1);
53
+ }
54
+ if (leaves)
55
+ setBit(outside, 0, i);
56
+ if (lo[0] > hi[0] || lo[1] > hi[1] || lo[2] > hi[2])
57
+ continue;
58
+ const spot = l.cosOuter > -1;
59
+ const outer = Math.acos(Math.min(Math.max(l.cosOuter, -1), 1));
60
+ for (let z = lo[2]; z <= hi[2]; z++) {
61
+ for (let y = lo[1]; y <= hi[1]; y++) {
62
+ for (let x = lo[0]; x <= hi[0]; x++) {
63
+ const b0 = [min[0] + x * cell, min[1] + y * cell, min[2] + z * cell];
64
+ // The sphere against the cell's box: the nearest point of the box.
65
+ let d2 = 0;
66
+ for (let k = 0; k < 3; k++) {
67
+ const q = Math.min(Math.max(p[k], b0[k]), b0[k] + cell);
68
+ d2 += (q - p[k]) * (q - p[k]);
69
+ }
70
+ if (d2 > r * r)
71
+ continue;
72
+ if (spot) {
73
+ // The cone against the cell's bounding sphere: the cell can be lit
74
+ // when the angle to its centre is within the cone's half-angle plus
75
+ // the angle that sphere subtends.
76
+ const v = [b0[0] + cell / 2 - p[0], b0[1] + cell / 2 - p[1], b0[2] + cell / 2 - p[2]];
77
+ const len = Math.hypot(v[0], v[1], v[2]);
78
+ if (len > halfDiagonal) {
79
+ const limit = outer + Math.asin(halfDiagonal / len);
80
+ const cos = (v[0] * l.ax + v[1] * l.ay + v[2] * l.az) / len;
81
+ if (limit < Math.PI && Math.acos(Math.min(Math.max(cos, -1), 1)) > limit)
82
+ continue;
83
+ }
84
+ }
85
+ setBit(cells, ((z * dims[1] + y) * dims[0] + x) * LIGHT_MASK_WORDS, i);
86
+ }
87
+ }
88
+ }
89
+ }
90
+ return { origin: [min[0], min[1], min[2]], cell, dims, outside, cells };
91
+ }