reze-engine 0.58.0 → 0.58.2

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 (61) 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 +30 -1
  5. package/dist/engine.d.ts.map +1 -1
  6. package/dist/engine.js +149 -19
  7. package/dist/light-grid.d.ts +25 -0
  8. package/dist/light-grid.d.ts.map +1 -0
  9. package/dist/light-grid.js +91 -0
  10. package/dist/model.d.ts +9 -0
  11. package/dist/model.d.ts.map +1 -1
  12. package/dist/model.js +6 -0
  13. package/dist/pmx-loader.d.ts.map +1 -1
  14. package/dist/pmx-loader.js +11 -6
  15. package/dist/shaders/cast-api.d.ts +1 -1
  16. package/dist/shaders/cast-layout.d.ts +5 -3
  17. package/dist/shaders/cast-layout.d.ts.map +1 -1
  18. package/dist/shaders/cast-layout.js +5 -3
  19. package/dist/shaders/directives.d.ts +3 -0
  20. package/dist/shaders/directives.d.ts.map +1 -1
  21. package/dist/shaders/directives.js +5 -0
  22. package/dist/shaders/lights.d.ts +16 -19
  23. package/dist/shaders/lights.d.ts.map +1 -1
  24. package/dist/shaders/lights.js +121 -88
  25. package/dist/shaders/materials/common.d.ts.map +1 -1
  26. package/dist/shaders/materials/common.js +3 -17
  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 +4 -0
  30. package/dist/shaders/passes/grid.d.ts.map +1 -1
  31. package/dist/shaders/passes/grid.js +4 -0
  32. package/dist/shaders/passes/particles.d.ts +4 -0
  33. package/dist/shaders/passes/particles.d.ts.map +1 -1
  34. package/dist/shaders/passes/particles.js +13 -1
  35. package/dist/shaders/passes/trails.d.ts.map +1 -1
  36. package/dist/shaders/passes/trails.js +3 -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/dist/shaders/scene-light-api.d.ts +42 -0
  41. package/dist/shaders/scene-light-api.d.ts.map +1 -0
  42. package/dist/shaders/scene-light-api.js +144 -0
  43. package/dist/shaders/shadow-api.d.ts +29 -0
  44. package/dist/shaders/shadow-api.d.ts.map +1 -0
  45. package/dist/shaders/shadow-api.js +112 -0
  46. package/package.json +1 -1
  47. package/src/effect-points.ts +67 -0
  48. package/src/engine.ts +163 -18
  49. package/src/light-grid.ts +139 -0
  50. package/src/model.ts +12 -0
  51. package/src/pmx-loader.ts +13 -6
  52. package/src/shaders/cast-layout.ts +5 -3
  53. package/src/shaders/directives.ts +8 -0
  54. package/src/shaders/lights.ts +121 -88
  55. package/src/shaders/materials/common.ts +3 -17
  56. package/src/shaders/passes/composite.ts +4 -0
  57. package/src/shaders/passes/grid.ts +4 -0
  58. package/src/shaders/passes/particles.ts +14 -1
  59. package/src/shaders/passes/trails.ts +3 -1
  60. package/src/shaders/points-api.ts +66 -0
  61. package/src/shaders/scene-light-api.ts +148 -0
package/src/engine.ts CHANGED
@@ -59,6 +59,7 @@ import {
59
59
  type SceneFormats,
60
60
  } from "./shaders/passes/scene-contract"
61
61
  import {
62
+ LIGHT_GRID_BASE,
62
63
  LIGHT_HEADER,
63
64
  LIGHT_STRIDE,
64
65
  LIGHTS_FLOATS,
@@ -66,6 +67,7 @@ import {
66
67
  buildLightEmitShader,
67
68
  hasLightEmit,
68
69
  } from "./shaders/lights"
70
+ import { buildLightGrid } from "./light-grid"
69
71
  import { groundShaderWgsl, GROUND_NOISE_BAKE_WGSL, GROUND_NOISE_SIZE } from "./shaders/passes/ground"
70
72
  import { outlineShaderWgsl, RZ_OUTLINE_DISSOLVE_OFFSET } from "./shaders/passes/outline"
71
73
  import { transparentDepthPrepassWgsl } from "./shaders/passes/depth-prepass"
@@ -113,9 +115,13 @@ import {
113
115
  import {
114
116
  buildParticleComputeShader,
115
117
  buildParticleRenderShader,
118
+ PARTICLE_LIGHT_BINDING,
119
+ PARTICLE_POINTS_BINDING,
116
120
  particleEntryPoints,
117
121
  PARTICLE_STRIDE,
118
122
  } from "./shaders/passes/particles"
123
+ import { MAX_EFFECT_POINTS, POINTS_FLOATS } from "./shaders/points-api"
124
+ import { bonesWithPrefix, pointsData, writeBonePoint } from "./effect-points"
119
125
  import {
120
126
  SIM_FORMAT,
121
127
  GRID_MAX,
@@ -1446,6 +1452,10 @@ interface EffectParticles {
1446
1452
  renderBinds: [GPUBindGroup, GPUBindGroup]
1447
1453
  mirrorRenderBinds: [GPUBindGroup, GPUBindGroup]
1448
1454
  }
1455
+ /** `#points`: the bone-name prefix and the buffer its matches are written
1456
+ * to each frame. Null when the effect declared none — it then reads the
1457
+ * engine's empty fallback. */
1458
+ points: { prefix: string; buffer: GPUBuffer; data: Float32Array } | null
1449
1459
  }
1450
1460
 
1451
1461
  /**
@@ -2497,9 +2507,20 @@ export class Engine {
2497
2507
  * Allocated once at full size and zero-filled, so "no lights" is a count of
2498
2508
  * zero rather than an absent binding. */
2499
2509
  private lightsBuffer!: GPUBuffer
2500
- private lightsData!: Float32Array<ArrayBuffer>
2501
- /** Just the header, for rewriting the total without touching a record. */
2510
+ /** What a particle effect with no `#points` reads at its points binding: a
2511
+ * count of zero. One buffer for all of them — nothing ever writes it. */
2512
+ private pointsFallback!: GPUBuffer
2513
+ /** Per model, per prefix, the bones `#points` matched. A rig's bones never
2514
+ * change after load, so the names are walked once rather than every frame. */
2515
+ private pointBones = new WeakMap<Model, Map<string, number[]>>()
2516
+ /** The CPU copy, from construction rather than init: a host sets a scene's
2517
+ * lamps as soon as its state exists, which can be before the device. */
2518
+ private lightsData = new Float32Array(LIGHTS_FLOATS)
2519
+ /** The same bytes as words — the grid's lamp bits are u32, not floats. */
2520
+ private lightsWords = new Uint32Array(this.lightsData.buffer)
2521
+ /** Just the header: the counts and the grid's placement. */
2502
2522
  private lightHeader = new Float32Array(LIGHT_HEADER)
2523
+ private lightHeaderWords = new Uint32Array(this.lightHeader.buffer)
2503
2524
  /** How many of the slots belong to the DOCUMENT. Effects get what follows. */
2504
2525
  private docLightCount = 0
2505
2526
  private castData!: Float32Array<ArrayBuffer>
@@ -4863,6 +4884,7 @@ export class Engine {
4863
4884
  paramsBuffer?.destroy()
4864
4885
  particles?.buffer.destroy()
4865
4886
  particles?.uniform.destroy()
4887
+ particles?.points?.buffer.destroy()
4866
4888
  grid?.textures[0].destroy()
4867
4889
  grid?.textures[1].destroy()
4868
4890
  grid?.uniform.destroy()
@@ -5247,6 +5269,17 @@ export class Engine {
5247
5269
  cover: particleEntryPoints(wgsl).cover,
5248
5270
  }
5249
5271
  const prepass = src.blend === "cutout" && src.cover
5272
+ const points = d.points
5273
+ ? {
5274
+ prefix: d.points,
5275
+ buffer: this.device.createBuffer({
5276
+ label: `points "${d.points}"`,
5277
+ size: POINTS_FLOATS * 4,
5278
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
5279
+ }),
5280
+ data: pointsData(),
5281
+ }
5282
+ : null
5250
5283
  // Sparks want to spawn where a trail is, so the particle stages see the same
5251
5284
  // cast buffer the trail draw reads.
5252
5285
  const cast = {
@@ -5273,9 +5306,15 @@ export class Engine {
5273
5306
  }
5274
5307
 
5275
5308
  const computeModule = await compile(buildParticleComputeShader(src, cast), "particle compute")
5276
- if (Array.isArray(computeModule)) return { ok: false, diagnostics: computeModule }
5309
+ if (Array.isArray(computeModule)) {
5310
+ points?.buffer.destroy()
5311
+ return { ok: false, diagnostics: computeModule }
5312
+ }
5277
5313
  const renderModule = await compile(buildParticleRenderShader(src, cast), "particle render")
5278
- if (Array.isArray(renderModule)) return { ok: false, diagnostics: renderModule }
5314
+ if (Array.isArray(renderModule)) {
5315
+ points?.buffer.destroy()
5316
+ return { ok: false, diagnostics: renderModule }
5317
+ }
5279
5318
 
5280
5319
  // COPY_DST so a scheduled effect can empty its pool when its window starts.
5281
5320
  const buffer = this.device.createBuffer({
@@ -5300,7 +5339,7 @@ export class Engine {
5300
5339
  // Declaring one set of flags for both layouts is what made the pipeline
5301
5340
  // layout invalid, and the error surfaces later and unhelpfully as "invalid
5302
5341
  // due to a previous error".
5303
- const layoutFor = (storage: GPUBufferBindingType, visibility: number) =>
5342
+ const layoutFor = (storage: GPUBufferBindingType, visibility: number, shadow: boolean) =>
5304
5343
  this.device.createBindGroupLayout({
5305
5344
  entries: [
5306
5345
  { binding: 0, visibility, buffer: { type: storage } },
@@ -5320,13 +5359,28 @@ export class Engine {
5320
5359
  // author calls it or not. With no grid it is the 1x1 of zeroes.
5321
5360
  { binding: 8, visibility, texture: { sampleType: "float" as const } },
5322
5361
  { binding: 9, visibility, sampler: { type: "filtering" as const } },
5362
+ // The named points, ALWAYS, for the grid's reason: the accessor is
5363
+ // compiled in whether or not the effect declared #points.
5364
+ { binding: PARTICLE_POINTS_BINDING, visibility, buffer: { type: "read-only-storage" as const } },
5365
+ // The scene's light, for rzShadow and rzWorldAmbient — the shading
5366
+ // stage only; the compute module compiles the stubs. See
5367
+ // scene-light-api.ts.
5368
+ ...(shadow
5369
+ ? [
5370
+ { binding: PARTICLE_LIGHT_BINDING, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" as const } },
5371
+ { binding: PARTICLE_LIGHT_BINDING + 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" as const } },
5372
+ { binding: PARTICLE_LIGHT_BINDING + 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" as const } },
5373
+ { binding: PARTICLE_LIGHT_BINDING + 3, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" as const } },
5374
+ { binding: PARTICLE_LIGHT_BINDING + 4, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "comparison" as const } },
5375
+ ]
5376
+ : []),
5323
5377
  ],
5324
5378
  })
5325
5379
  /** One per GRID PARITY, for the reason rebuildFieldBindGroup gives: the grid
5326
5380
  * alternates which texture is current, and rebuilding a single group every
5327
5381
  * frame is waste for a change that only ever toggles between two known
5328
5382
  * states. Both entries are the same view when there is no grid. */
5329
- const bindFor = (layout: GPUBindGroupLayout, camera: GPUBuffer) =>
5383
+ const bindFor = (layout: GPUBindGroupLayout, camera: GPUBuffer, shadow: boolean) =>
5330
5384
  [0, 1].map((parity) =>
5331
5385
  this.device.createBindGroup({
5332
5386
  layout,
@@ -5341,12 +5395,22 @@ export class Engine {
5341
5395
  ...(params.buffer ? [{ binding: 7, resource: { buffer: params.buffer } }] : []),
5342
5396
  { binding: 8, resource: grid ? grid.read[parity] : this.simFallbackView },
5343
5397
  { binding: 9, resource: this.simSampler },
5398
+ { binding: PARTICLE_POINTS_BINDING, resource: { buffer: points?.buffer ?? this.pointsFallback } },
5399
+ ...(shadow
5400
+ ? [
5401
+ { binding: PARTICLE_LIGHT_BINDING, resource: { buffer: this.lightUniformBuffer } },
5402
+ { binding: PARTICLE_LIGHT_BINDING + 1, resource: { buffer: this.shadowLightVPBuffer } },
5403
+ { binding: PARTICLE_LIGHT_BINDING + 2, resource: this.shadowMapDepthViews[0] },
5404
+ { binding: PARTICLE_LIGHT_BINDING + 3, resource: this.shadowMapDepthViews[SHADOW_CASCADES.length - 1] },
5405
+ { binding: PARTICLE_LIGHT_BINDING + 4, resource: this.shadowComparisonSampler },
5406
+ ]
5407
+ : []),
5344
5408
  ],
5345
5409
  }),
5346
5410
  ) as [GPUBindGroup, GPUBindGroup]
5347
5411
 
5348
- const computeLayout = layoutFor("storage", GPUShaderStage.COMPUTE)
5349
- const renderLayout = layoutFor("read-only-storage", GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT)
5412
+ const computeLayout = layoutFor("storage", GPUShaderStage.COMPUTE, false)
5413
+ const renderLayout = layoutFor("read-only-storage", GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, true)
5350
5414
 
5351
5415
  // Additive keeps the destination and adds to it, and leaves alpha alone, so
5352
5416
  // a glow does not claim coverage it never occluded. The MASK sums with it —
@@ -5407,6 +5471,7 @@ export class Engine {
5407
5471
  if (scoped) {
5408
5472
  buffer.destroy()
5409
5473
  uniform.destroy()
5474
+ points?.buffer.destroy()
5410
5475
  return { ok: false, diagnostics: [scoped.message] }
5411
5476
  }
5412
5477
  return {
@@ -5422,23 +5487,25 @@ export class Engine {
5422
5487
  counts: uniformView.uints,
5423
5488
  compute,
5424
5489
  computeLayout,
5425
- computeBinds: bindFor(computeLayout, this.cameraUniformBuffer),
5490
+ computeBinds: bindFor(computeLayout, this.cameraUniformBuffer, false),
5426
5491
  depth,
5427
5492
  render,
5428
5493
  renderLayout,
5429
- renderBinds: bindFor(renderLayout, this.cameraUniformBuffer),
5430
- mirrorRenderBinds: bindFor(renderLayout, this.mirrorCameraBuffer),
5494
+ renderBinds: bindFor(renderLayout, this.cameraUniformBuffer, true),
5495
+ mirrorRenderBinds: bindFor(renderLayout, this.mirrorCameraBuffer, true),
5431
5496
  rebind: () => ({
5432
- computeBinds: bindFor(computeLayout, this.cameraUniformBuffer),
5433
- renderBinds: bindFor(renderLayout, this.cameraUniformBuffer),
5434
- mirrorRenderBinds: bindFor(renderLayout, this.mirrorCameraBuffer),
5497
+ computeBinds: bindFor(computeLayout, this.cameraUniformBuffer, false),
5498
+ renderBinds: bindFor(renderLayout, this.cameraUniformBuffer, true),
5499
+ mirrorRenderBinds: bindFor(renderLayout, this.mirrorCameraBuffer, true),
5435
5500
  }),
5501
+ points,
5436
5502
  },
5437
5503
  }
5438
5504
  } catch (e) {
5439
5505
  await this.device.popErrorScope()
5440
5506
  buffer.destroy()
5441
5507
  uniform.destroy()
5508
+ points?.buffer.destroy()
5442
5509
  return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)] }
5443
5510
  }
5444
5511
  }
@@ -5569,6 +5636,7 @@ export class Engine {
5569
5636
  this.device.queue.writeBuffer(e.lights.uniform, 0, e.lights.data.buffer as ArrayBuffer)
5570
5637
  }
5571
5638
  this.lightHeader[0] = Math.min(next, MAX_LIGHTS)
5639
+ this.lightHeader[1] = this.docLightCount
5572
5640
  this.device.queue.writeBuffer(this.lightsBuffer, 0, this.lightHeader)
5573
5641
  }
5574
5642
 
@@ -5629,6 +5697,7 @@ export class Engine {
5629
5697
  p.counts[2] = p.count
5630
5698
  p.counts[3] = this.particleFrame++
5631
5699
  this.device.queue.writeBuffer(p.uniform, 0, p.data.buffer as ArrayBuffer)
5700
+ if (p.points) this.writePoints(p.points)
5632
5701
  const cp = encoder.beginComputePass({ label: "particles" })
5633
5702
  cp.setPipeline(p.compute)
5634
5703
  cp.setBindGroup(0, p.computeBinds[e.grid?.parity ?? 0])
@@ -5637,6 +5706,35 @@ export class Engine {
5637
5706
  }
5638
5707
  }
5639
5708
 
5709
+ /**
5710
+ * Every bone matching an effect's `#points` prefix, on every visible model,
5711
+ * posed and placed this frame. Every frame rather than on load: a candle a
5712
+ * character carries moves with the hand, and a stage can be moved.
5713
+ *
5714
+ * The order is the models' order, then rig order — stable while nothing is
5715
+ * added or removed, so particle i stays on point i and a flame does not
5716
+ * jump to another wick between frames.
5717
+ */
5718
+ private writePoints(points: { prefix: string; buffer: GPUBuffer; data: Float32Array }): void {
5719
+ let n = 0
5720
+ for (const inst of this.modelInstances.values()) {
5721
+ const m = inst.model
5722
+ if (!m.visible) continue
5723
+ let byPrefix = this.pointBones.get(m)
5724
+ if (!byPrefix) this.pointBones.set(m, (byPrefix = new Map()))
5725
+ let bones = byPrefix.get(points.prefix)
5726
+ const rig = m.getSkeleton().bones
5727
+ if (!bones) byPrefix.set(points.prefix, (bones = bonesWithPrefix(rig, points.prefix)))
5728
+ for (const i of bones) {
5729
+ if (n >= MAX_EFFECT_POINTS) break
5730
+ const world = m.getBoneWorldMatrixAt(i)
5731
+ if (world) writeBonePoint(points.data, n++, world, rig[i].tail, m)
5732
+ }
5733
+ }
5734
+ points.data[0] = n
5735
+ this.device.queue.writeBuffer(points.buffer, 0, points.data.buffer as ArrayBuffer, 0, (4 + n * 8) * 4)
5736
+ }
5737
+
5640
5738
  /** Draw the pool. Inside the scene pass, so it is depth-tested and pre-bloom. */
5641
5739
  private renderParticles(pass: GPURenderPassEncoder, view: "camera" | "mirror"): void {
5642
5740
  for (const e of this.effects) {
@@ -6029,6 +6127,7 @@ export class Engine {
6029
6127
  for (const e of this.effects) {
6030
6128
  e.particles?.buffer.destroy()
6031
6129
  e.particles?.uniform.destroy()
6130
+ e.particles?.points?.buffer.destroy()
6032
6131
  e.particles = null
6033
6132
  }
6034
6133
  }
@@ -7387,8 +7486,10 @@ export class Engine {
7387
7486
  },
7388
7487
  })
7389
7488
 
7489
+ // The matrices, then one vec4: the caster sphere, which effects' rzShadow
7490
+ // tests before any tap. The materials and the ground bind the matrices only.
7390
7491
  this.shadowLightVPBuffer = this.device.createBuffer({
7391
- size: 64 * SHADOW_CASCADES.length,
7492
+ size: 64 * SHADOW_CASCADES.length + 16,
7392
7493
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
7393
7494
  })
7394
7495
  this.shadowCascadeVPBuffers = SHADOW_CASCADES.map((_, i) =>
@@ -7464,15 +7565,21 @@ export class Engine {
7464
7565
 
7465
7566
  // BEFORE the bind group below, which binds it. Full size from the start:
7466
7567
  // every material pipeline binds this, so sizing it to the light count would
7467
- // mean rebuilding bind groups whenever a scene gained a lamp. Zero-filled,
7468
- // and float 0 is a count of 0.
7469
- this.lightsData = new Float32Array(LIGHTS_FLOATS)
7568
+ // mean rebuilding bind groups whenever a scene gained a lamp. Uploaded from
7569
+ // the CPU copy, which holds any lamps set before the device existed; the
7570
+ // header after it, from the one writer that owns it.
7470
7571
  this.lightsBuffer = this.device.createBuffer({
7471
7572
  label: "positional lights",
7472
7573
  size: this.lightsData.byteLength,
7473
7574
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
7474
7575
  })
7475
7576
  this.device.queue.writeBuffer(this.lightsBuffer, 0, this.lightsData)
7577
+ this.allocateLightSlots()
7578
+ this.pointsFallback = this.device.createBuffer({
7579
+ label: "no points",
7580
+ size: 16,
7581
+ usage: GPUBufferUsage.STORAGE,
7582
+ })
7476
7583
  // BEFORE the per-frame groups, which bind it. The composite needs this 1x1
7477
7584
  // stand-in too, and it used to be created with the composite's own
7478
7585
  // resources further down — far enough down that a material group built up
@@ -10023,6 +10130,20 @@ export class Engine {
10023
10130
  this.lightsData[b + 14] = 0
10024
10131
  this.lightsData[b + 15] = 0
10025
10132
  }
10133
+ // The grid, from the records as STORED — normalised aim, clamped reach, the
10134
+ // cosines in f32 — so it indexes exactly what the shader will evaluate.
10135
+ const grid = buildLightGrid(
10136
+ list.map((_, i) => {
10137
+ const b = LIGHT_HEADER + i * LIGHT_STRIDE
10138
+ const d = this.lightsData
10139
+ 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] }
10140
+ }),
10141
+ )
10142
+ this.lightHeader.set(grid.origin, 4)
10143
+ this.lightHeader[7] = 1 / grid.cell
10144
+ this.lightHeaderWords.set(grid.dims, 8)
10145
+ this.lightHeaderWords.set(grid.outside, 12)
10146
+ this.lightsWords.set(grid.cells, LIGHT_GRID_BASE)
10026
10147
  // The CPU copy is written either way; only the upload needs a device. A
10027
10148
  // scene whose lamps arrive before init keeps them, and init uploads them.
10028
10149
  if (list.length && this.device && this.lightsBuffer) {
@@ -10033,6 +10154,13 @@ export class Engine {
10033
10154
  LIGHT_HEADER * 4,
10034
10155
  list.length * LIGHT_STRIDE * 4,
10035
10156
  )
10157
+ this.device.queue.writeBuffer(
10158
+ this.lightsBuffer,
10159
+ LIGHT_GRID_BASE * 4,
10160
+ this.lightsData.buffer as ArrayBuffer,
10161
+ LIGHT_GRID_BASE * 4,
10162
+ grid.cells.byteLength,
10163
+ )
10036
10164
  }
10037
10165
  // The effects' bases move when the document's count does; the header —
10038
10166
  // count included — is written there.
@@ -12380,6 +12508,22 @@ export class Engine {
12380
12508
  this.device.queue.writeBuffer(this.groundShadowMaterialBuffer, 40, gb.subarray(10, 11) as Float32Array<ArrayBuffer>)
12381
12509
  }
12382
12510
 
12511
+ /** The caster sphere as last written after the cascade matrices; NaN so the
12512
+ * first frame always writes. */
12513
+ private shadowCastersWritten = new Float32Array([NaN, NaN, NaN, NaN])
12514
+
12515
+ /**
12516
+ * The same sphere, into the cascade block after its matrices — rzShadow's
12517
+ * copy. There whether or not a ground exists: a lawn is often the only floor.
12518
+ */
12519
+ private writeShadowCasters(): void {
12520
+ const w = this.shadowCastersWritten
12521
+ const c = this.casterSphere
12522
+ if (w[0] === c[0] && w[1] === c[1] && w[2] === c[2] && w[3] === c[3]) return
12523
+ w.set(c)
12524
+ this.device.queue.writeBuffer(this.shadowLightVPBuffer, 64 * SHADOW_CASCADES.length, c as Float32Array<ArrayBuffer>)
12525
+ }
12526
+
12383
12527
  /**
12384
12528
  * Push this frame's caster sphere into the ground's uniform.
12385
12529
  *
@@ -15376,6 +15520,7 @@ export class Engine {
15376
15520
  if (hasModels) this.dispatchCull(encoder)
15377
15521
  // After the cull, which is what recomputes the spheres it unions.
15378
15522
  this.writeGroundCasterSphere()
15523
+ this.writeShadowCasters()
15379
15524
  this.writeGroundDress()
15380
15525
 
15381
15526
  // After the cull, because a rebuild there can reallocate the argument
@@ -0,0 +1,139 @@
1
+ import { LIGHT_GRID_CELLS, LIGHT_MASK_WORDS } from "./shaders/lights"
2
+
3
+ // Which document lamps can reach which part of the world, so a fragment walks
4
+ // only those.
5
+ //
6
+ // A uniform grid in WORLD space, built on the CPU whenever the document's lamps
7
+ // change. Each cell holds one bit per lamp — 128 lamps is one vec4u — set when
8
+ // the lamp's sphere, and its cone for a spot, can touch the cell. A fragment
9
+ // finds its cell from its world position and loops the set bits.
10
+ //
11
+ // WORLD SPACE rather than screen tiles, because the answer then belongs to the
12
+ // lamps and not to a camera: the mirror pass, an export at another resolution
13
+ // and a preview all read the same grid with no per-view work, and it is rebuilt
14
+ // only when a lamp moves, never per frame.
15
+ //
16
+ // THE BOX is where the lamps cluster — their positions padded by the median
17
+ // reach — not the union of every sphere. One stage spot reaching 300 units
18
+ // would otherwise stretch the box across the whole set, and a fixed cell budget
19
+ // spread over it leaves cells wider than the small lamps they are meant to
20
+ // separate. A lamp whose reach leaves the box also sets its bit in the OUTSIDE
21
+ // mask, which is what a fragment beyond the box reads, so nothing is lost at
22
+ // the edge; it is only coarser there.
23
+ //
24
+ // CONSERVATIVE, never exact: a bit may be set in a cell the lamp does not
25
+ // light, never missing from one it does. A missing bit is a hole in the light;
26
+ // a spare one is a few instructions.
27
+
28
+ /** One document lamp, as the grid sees it. */
29
+ export type GridLamp = {
30
+ x: number
31
+ y: number
32
+ z: number
33
+ radius: number
34
+ /** Unit aim, away from the lamp. Zero for a point lamp. */
35
+ ax: number
36
+ ay: number
37
+ az: number
38
+ /** Cosine of the half-angle. -1 for a point lamp, which covers everything. */
39
+ cosOuter: number
40
+ }
41
+
42
+ export type LightGrid = {
43
+ origin: [number, number, number]
44
+ /** Edge length of one cubic cell, world units. */
45
+ cell: number
46
+ dims: [number, number, number]
47
+ /** The lamps that reach past the box, for fragments outside it. */
48
+ outside: Uint32Array
49
+ /** LIGHT_MASK_WORDS words per cell, x fastest, then y, then z. */
50
+ cells: Uint32Array
51
+ }
52
+
53
+ function setBit(words: Uint32Array, base: number, lamp: number): void {
54
+ words[base + (lamp >> 5)] |= 1 << (lamp & 31)
55
+ }
56
+
57
+ export function buildLightGrid(lamps: GridLamp[], maxCells = LIGHT_GRID_CELLS): LightGrid {
58
+ const outside = new Uint32Array(LIGHT_MASK_WORDS)
59
+ const live = lamps
60
+ .map((l, i) => ({ l, i }))
61
+ .filter(({ l }) => l.radius > 0 && [l.x, l.y, l.z, l.radius].every(Number.isFinite))
62
+ if (!live.length) return { origin: [0, 0, 0], cell: 1, dims: [0, 0, 0], outside, cells: new Uint32Array(0) }
63
+
64
+ const radii = live.map(({ l }) => l.radius).sort((a, b) => a - b)
65
+ const pad = radii[radii.length >> 1]
66
+ const min = [Infinity, Infinity, Infinity]
67
+ const max = [-Infinity, -Infinity, -Infinity]
68
+ for (const { l } of live) {
69
+ const p = [l.x, l.y, l.z]
70
+ for (let k = 0; k < 3; k++) {
71
+ min[k] = Math.min(min[k], p[k] - pad)
72
+ max[k] = Math.max(max[k], p[k] + pad)
73
+ }
74
+ }
75
+ const extent = [max[0] - min[0], max[1] - min[1], max[2] - min[2]]
76
+ let cell = Math.cbrt((extent[0] * extent[1] * extent[2]) / maxCells)
77
+ let dims: [number, number, number]
78
+ // ceil() rounds every axis up, so the first guess can overshoot the budget by
79
+ // a few percent; widen the cell until it fits.
80
+ for (;;) {
81
+ dims = [0, 1, 2].map((k) => Math.max(1, Math.ceil(extent[k] / cell))) as [number, number, number]
82
+ if (dims[0] * dims[1] * dims[2] <= maxCells) break
83
+ cell *= 1.02
84
+ }
85
+ const cells = new Uint32Array(dims[0] * dims[1] * dims[2] * LIGHT_MASK_WORDS)
86
+ // Half the cell's diagonal: the sphere a spot's cone is tested against.
87
+ const halfDiagonal = (cell * Math.sqrt(3)) / 2
88
+
89
+ for (const { l, i } of live) {
90
+ // A hair of slack on the reach: the shader finds its cell in f32 from a
91
+ // reciprocal, the builder in f64 from a division, and a fragment on a cell
92
+ // face may land in either neighbour.
93
+ const r = l.radius + cell * 1e-4
94
+ const p = [l.x, l.y, l.z]
95
+ const lo = [0, 0, 0]
96
+ const hi = [0, 0, 0]
97
+ let leaves = false
98
+ for (let k = 0; k < 3; k++) {
99
+ const a = Math.floor((p[k] - r - min[k]) / cell)
100
+ const b = Math.floor((p[k] + r - min[k]) / cell)
101
+ if (a < 0 || b >= dims[k]) leaves = true
102
+ lo[k] = Math.max(a, 0)
103
+ hi[k] = Math.min(b, dims[k] - 1)
104
+ }
105
+ if (leaves) setBit(outside, 0, i)
106
+ if (lo[0] > hi[0] || lo[1] > hi[1] || lo[2] > hi[2]) continue
107
+
108
+ const spot = l.cosOuter > -1
109
+ const outer = Math.acos(Math.min(Math.max(l.cosOuter, -1), 1))
110
+ for (let z = lo[2]; z <= hi[2]; z++) {
111
+ for (let y = lo[1]; y <= hi[1]; y++) {
112
+ for (let x = lo[0]; x <= hi[0]; x++) {
113
+ const b0 = [min[0] + x * cell, min[1] + y * cell, min[2] + z * cell]
114
+ // The sphere against the cell's box: the nearest point of the box.
115
+ let d2 = 0
116
+ for (let k = 0; k < 3; k++) {
117
+ const q = Math.min(Math.max(p[k], b0[k]), b0[k] + cell)
118
+ d2 += (q - p[k]) * (q - p[k])
119
+ }
120
+ if (d2 > r * r) continue
121
+ if (spot) {
122
+ // The cone against the cell's bounding sphere: the cell can be lit
123
+ // when the angle to its centre is within the cone's half-angle plus
124
+ // the angle that sphere subtends.
125
+ const v = [b0[0] + cell / 2 - p[0], b0[1] + cell / 2 - p[1], b0[2] + cell / 2 - p[2]]
126
+ const len = Math.hypot(v[0], v[1], v[2])
127
+ if (len > halfDiagonal) {
128
+ const limit = outer + Math.asin(halfDiagonal / len)
129
+ const cos = (v[0] * l.ax + v[1] * l.ay + v[2] * l.az) / len
130
+ if (limit < Math.PI && Math.acos(Math.min(Math.max(cos, -1), 1)) > limit) continue
131
+ }
132
+ }
133
+ setBit(cells, ((z * dims[1] + y) * dims[0] + x) * LIGHT_MASK_WORDS, i)
134
+ }
135
+ }
136
+ }
137
+ }
138
+ return { origin: [min[0], min[1], min[2]], cell, dims, outside, cells }
139
+ }
package/src/model.ts CHANGED
@@ -140,6 +140,11 @@ export interface Bone {
140
140
  appendMove?: boolean
141
141
  /** 軸制限: this bone rotates ONLY about this axis (the twist bones use it). */
142
142
  fixedAxis?: [number, number, number]
143
+ /** Where the bone's tail sits at rest, as an offset from its head — the
144
+ * direction a PMX editor draws it pointing. PMX states it either as that
145
+ * offset or as another bone to point at; both arrive here as the offset.
146
+ * An effect reading named points takes it as the point's length and way. */
147
+ tail?: [number, number, number]
143
148
  /** 変形階層: MMD poses whole layers in order, not bones in array order.
144
149
  * Parsed but not yet honoured — distinct from Model's `deformOrder`, which is
145
150
  * this engine's parent-before-child traversal. */
@@ -1096,6 +1101,13 @@ export class Model {
1096
1101
  return this.runtimeSkeleton.worldMatrices[idx].values
1097
1102
  }
1098
1103
 
1104
+ /** A bone's posed matrix by INDEX — model space, column-major, the live
1105
+ * array. For walking bones that share a name, which the name lookup
1106
+ * collapses to one. Null past the end. */
1107
+ getBoneWorldMatrixAt(index: number): Float32Array | null {
1108
+ return this.runtimeSkeleton.worldMatrices[index]?.values ?? null
1109
+ }
1110
+
1099
1111
  // World bone origin (world matrix col3); unknown name → null
1100
1112
  getBoneWorldPosition(boneName: string): Vec3 | null {
1101
1113
  const idx = this.runtimeSkeleton.nameIndex[boneName]
package/src/pmx-loader.ts CHANGED
@@ -357,6 +357,8 @@ export class PmxLoader {
357
357
  ikLinks?: IKLink[]
358
358
  fixedAxis?: [number, number, number]
359
359
  deformLayer?: number
360
+ tailOffset?: [number, number, number]
361
+ tailBone?: number
360
362
  }
361
363
  const abs: AbsBone[] = new Array(count)
362
364
  // PMX 2.x bone flags (best-effort common masks)
@@ -378,14 +380,14 @@ export class PmxLoader {
378
380
  const deformLayer = this.getInt32() // 変形階層
379
381
  const flags = this.getUint16()
380
382
 
381
- // Tail: bone index or offset vector3
383
+ // Tail: bone index or offset vector3. Kept either way — the bone it
384
+ // names is resolved to an offset once every head is known.
385
+ let tailOffset: [number, number, number] | undefined
386
+ let tailBone: number | undefined
382
387
  if ((flags & FLAG_TAIL_IS_BONE) !== 0) {
383
- this.getNonVertexIndex(this.boneIndexSize)
388
+ tailBone = this.getNonVertexIndex(this.boneIndexSize)
384
389
  } else {
385
- // tail offset vec3
386
- this.getFloat32()
387
- this.getFloat32()
388
- this.getFloat32()
390
+ tailOffset = [this.getFloat32(), this.getFloat32(), this.getFloat32()]
389
391
  }
390
392
 
391
393
  // Append transform (inherit/ratio)
@@ -475,10 +477,14 @@ export class PmxLoader {
475
477
  ikIteration,
476
478
  ikLimitAngle,
477
479
  ikLinks,
480
+ tailOffset,
481
+ tailBone,
478
482
  }
479
483
  }
480
484
  for (let i = 0; i < count; i++) {
481
485
  const a = abs[i]
486
+ const to = a.tailBone !== undefined && a.tailBone >= 0 && a.tailBone < count ? abs[a.tailBone] : null
487
+ const tail: [number, number, number] | undefined = to ? [to.x - a.x, to.y - a.y, to.z - a.z] : a.tailOffset
482
488
  const boneData: Bone = {
483
489
  name: a.name,
484
490
  parentIndex: a.parent,
@@ -497,6 +503,7 @@ export class PmxLoader {
497
503
  ikIteration: a.ikIteration,
498
504
  ikLimitAngle: a.ikLimitAngle,
499
505
  ikLinks: a.ikLinks,
506
+ ...(tail ? { tail } : {}),
500
507
  }
501
508
  bones.push(boneData)
502
509
  }
@@ -23,8 +23,10 @@ export const EFFECT_SUBJECT_VEC4S = 4
23
23
  *
24
24
  * 8 was reachable, and quietly: two effects each wanting two hands, two feet and
25
25
  * a head is ten, so the second one silently lost its ribbons to a diagnostic
26
- * nobody was reading. 16 is double the headroom for 131KB of storage buffer,
27
- * where 8 cost 66KB.
26
+ * nobody was reading. 16 held until an effect traced a HAND: an outline through
27
+ * every finger reads fifteen bones a hand, thirty for the pair, before any other
28
+ * effect in the scene asks for one. 48 is that and room beside it, for 393KB of
29
+ * storage buffer where 16 cost 131KB.
28
30
  *
29
31
  * The cost really is only that. The per-frame upload is bounded by the last
30
32
  * TRAILED slot, not by this cap (see the writeBuffer in updateCastBuffer), so a
@@ -39,7 +41,7 @@ export const EFFECT_SUBJECT_VEC4S = 4
39
41
  * scene's anchor count grows, which is a different feature from raising a number
40
42
  * that was never load-bearing.
41
43
  */
42
- export const EFFECT_ANCHORS = 16
44
+ export const EFFECT_ANCHORS = 48
43
45
  /**
44
46
  * Path samples kept per trailed anchor — 128 at the 60Hz sampling rate is a
45
47
  * ~2.1 second ribbon.
@@ -57,6 +57,9 @@ export type EffectDirectives = {
57
57
  particles: number
58
58
  lights: number
59
59
  grid: number
60
+ /** `#points <prefix>`: every bone on every model whose name starts with it,
61
+ * as rzPoint(i) in the particle stages. Null when the file declares none. */
62
+ points: string | null
60
63
  bloom: boolean
61
64
  /** This effect takes the cast apart — the host reads the timing. */
62
65
  dissolve: boolean
@@ -111,6 +114,7 @@ const SPEC = {
111
114
  particles: 1,
112
115
  lights: 1,
113
116
  grid: 1,
117
+ points: 1,
114
118
  bloom: 0,
115
119
  dissolve: 0,
116
120
  mirror: 0,
@@ -168,6 +172,7 @@ export function parseDirectives(wgsl: string): DirectiveResult {
168
172
  particles: 0,
169
173
  lights: 0,
170
174
  grid: 0,
175
+ points: null,
171
176
  bloom: false,
172
177
  dissolve: false,
173
178
  ground: null,
@@ -285,6 +290,9 @@ export function parseDirectives(wgsl: string): DirectiveResult {
285
290
  case "dissolve":
286
291
  d.dissolve = true
287
292
  return
293
+ case "points":
294
+ d.points = args[0]
295
+ return
288
296
  case "mirror":
289
297
  d.mirror = true
290
298
  return