reze-engine 0.58.1 → 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 (51) 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 +22 -1
  5. package/dist/engine.d.ts.map +1 -1
  6. package/dist/engine.js +99 -8
  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 +14 -9
  23. package/dist/shaders/lights.d.ts.map +1 -1
  24. package/dist/shaders/lights.js +118 -65
  25. package/dist/shaders/passes/composite.d.ts +1 -1
  26. package/dist/shaders/passes/composite.d.ts.map +1 -1
  27. package/dist/shaders/passes/composite.js +2 -0
  28. package/dist/shaders/passes/grid.d.ts.map +1 -1
  29. package/dist/shaders/passes/grid.js +2 -0
  30. package/dist/shaders/passes/particles.d.ts +2 -0
  31. package/dist/shaders/passes/particles.d.ts.map +1 -1
  32. package/dist/shaders/passes/particles.js +6 -0
  33. package/dist/shaders/passes/trails.d.ts.map +1 -1
  34. package/dist/shaders/passes/trails.js +2 -1
  35. package/dist/shaders/points-api.d.ts +11 -0
  36. package/dist/shaders/points-api.d.ts.map +1 -0
  37. package/dist/shaders/points-api.js +58 -0
  38. package/package.json +1 -1
  39. package/src/effect-points.ts +67 -0
  40. package/src/engine.ts +111 -7
  41. package/src/light-grid.ts +139 -0
  42. package/src/model.ts +12 -0
  43. package/src/pmx-loader.ts +13 -6
  44. package/src/shaders/cast-layout.ts +5 -3
  45. package/src/shaders/directives.ts +8 -0
  46. package/src/shaders/lights.ts +118 -65
  47. package/src/shaders/passes/composite.ts +2 -0
  48. package/src/shaders/passes/grid.ts +2 -0
  49. package/src/shaders/passes/particles.ts +6 -0
  50. package/src/shaders/passes/trails.ts +2 -1
  51. package/src/shaders/points-api.ts +66 -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"
@@ -114,9 +116,12 @@ import {
114
116
  buildParticleComputeShader,
115
117
  buildParticleRenderShader,
116
118
  PARTICLE_LIGHT_BINDING,
119
+ PARTICLE_POINTS_BINDING,
117
120
  particleEntryPoints,
118
121
  PARTICLE_STRIDE,
119
122
  } from "./shaders/passes/particles"
123
+ import { MAX_EFFECT_POINTS, POINTS_FLOATS } from "./shaders/points-api"
124
+ import { bonesWithPrefix, pointsData, writeBonePoint } from "./effect-points"
120
125
  import {
121
126
  SIM_FORMAT,
122
127
  GRID_MAX,
@@ -1447,6 +1452,10 @@ interface EffectParticles {
1447
1452
  renderBinds: [GPUBindGroup, GPUBindGroup]
1448
1453
  mirrorRenderBinds: [GPUBindGroup, GPUBindGroup]
1449
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
1450
1459
  }
1451
1460
 
1452
1461
  /**
@@ -2498,9 +2507,20 @@ export class Engine {
2498
2507
  * Allocated once at full size and zero-filled, so "no lights" is a count of
2499
2508
  * zero rather than an absent binding. */
2500
2509
  private lightsBuffer!: GPUBuffer
2501
- private lightsData!: Float32Array<ArrayBuffer>
2502
- /** 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. */
2503
2522
  private lightHeader = new Float32Array(LIGHT_HEADER)
2523
+ private lightHeaderWords = new Uint32Array(this.lightHeader.buffer)
2504
2524
  /** How many of the slots belong to the DOCUMENT. Effects get what follows. */
2505
2525
  private docLightCount = 0
2506
2526
  private castData!: Float32Array<ArrayBuffer>
@@ -4864,6 +4884,7 @@ export class Engine {
4864
4884
  paramsBuffer?.destroy()
4865
4885
  particles?.buffer.destroy()
4866
4886
  particles?.uniform.destroy()
4887
+ particles?.points?.buffer.destroy()
4867
4888
  grid?.textures[0].destroy()
4868
4889
  grid?.textures[1].destroy()
4869
4890
  grid?.uniform.destroy()
@@ -5248,6 +5269,17 @@ export class Engine {
5248
5269
  cover: particleEntryPoints(wgsl).cover,
5249
5270
  }
5250
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
5251
5283
  // Sparks want to spawn where a trail is, so the particle stages see the same
5252
5284
  // cast buffer the trail draw reads.
5253
5285
  const cast = {
@@ -5274,9 +5306,15 @@ export class Engine {
5274
5306
  }
5275
5307
 
5276
5308
  const computeModule = await compile(buildParticleComputeShader(src, cast), "particle compute")
5277
- 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
+ }
5278
5313
  const renderModule = await compile(buildParticleRenderShader(src, cast), "particle render")
5279
- 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
+ }
5280
5318
 
5281
5319
  // COPY_DST so a scheduled effect can empty its pool when its window starts.
5282
5320
  const buffer = this.device.createBuffer({
@@ -5321,6 +5359,9 @@ export class Engine {
5321
5359
  // author calls it or not. With no grid it is the 1x1 of zeroes.
5322
5360
  { binding: 8, visibility, texture: { sampleType: "float" as const } },
5323
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 } },
5324
5365
  // The scene's light, for rzShadow and rzWorldAmbient — the shading
5325
5366
  // stage only; the compute module compiles the stubs. See
5326
5367
  // scene-light-api.ts.
@@ -5354,6 +5395,7 @@ export class Engine {
5354
5395
  ...(params.buffer ? [{ binding: 7, resource: { buffer: params.buffer } }] : []),
5355
5396
  { binding: 8, resource: grid ? grid.read[parity] : this.simFallbackView },
5356
5397
  { binding: 9, resource: this.simSampler },
5398
+ { binding: PARTICLE_POINTS_BINDING, resource: { buffer: points?.buffer ?? this.pointsFallback } },
5357
5399
  ...(shadow
5358
5400
  ? [
5359
5401
  { binding: PARTICLE_LIGHT_BINDING, resource: { buffer: this.lightUniformBuffer } },
@@ -5429,6 +5471,7 @@ export class Engine {
5429
5471
  if (scoped) {
5430
5472
  buffer.destroy()
5431
5473
  uniform.destroy()
5474
+ points?.buffer.destroy()
5432
5475
  return { ok: false, diagnostics: [scoped.message] }
5433
5476
  }
5434
5477
  return {
@@ -5455,12 +5498,14 @@ export class Engine {
5455
5498
  renderBinds: bindFor(renderLayout, this.cameraUniformBuffer, true),
5456
5499
  mirrorRenderBinds: bindFor(renderLayout, this.mirrorCameraBuffer, true),
5457
5500
  }),
5501
+ points,
5458
5502
  },
5459
5503
  }
5460
5504
  } catch (e) {
5461
5505
  await this.device.popErrorScope()
5462
5506
  buffer.destroy()
5463
5507
  uniform.destroy()
5508
+ points?.buffer.destroy()
5464
5509
  return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)] }
5465
5510
  }
5466
5511
  }
@@ -5591,6 +5636,7 @@ export class Engine {
5591
5636
  this.device.queue.writeBuffer(e.lights.uniform, 0, e.lights.data.buffer as ArrayBuffer)
5592
5637
  }
5593
5638
  this.lightHeader[0] = Math.min(next, MAX_LIGHTS)
5639
+ this.lightHeader[1] = this.docLightCount
5594
5640
  this.device.queue.writeBuffer(this.lightsBuffer, 0, this.lightHeader)
5595
5641
  }
5596
5642
 
@@ -5651,6 +5697,7 @@ export class Engine {
5651
5697
  p.counts[2] = p.count
5652
5698
  p.counts[3] = this.particleFrame++
5653
5699
  this.device.queue.writeBuffer(p.uniform, 0, p.data.buffer as ArrayBuffer)
5700
+ if (p.points) this.writePoints(p.points)
5654
5701
  const cp = encoder.beginComputePass({ label: "particles" })
5655
5702
  cp.setPipeline(p.compute)
5656
5703
  cp.setBindGroup(0, p.computeBinds[e.grid?.parity ?? 0])
@@ -5659,6 +5706,35 @@ export class Engine {
5659
5706
  }
5660
5707
  }
5661
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
+
5662
5738
  /** Draw the pool. Inside the scene pass, so it is depth-tested and pre-bloom. */
5663
5739
  private renderParticles(pass: GPURenderPassEncoder, view: "camera" | "mirror"): void {
5664
5740
  for (const e of this.effects) {
@@ -6051,6 +6127,7 @@ export class Engine {
6051
6127
  for (const e of this.effects) {
6052
6128
  e.particles?.buffer.destroy()
6053
6129
  e.particles?.uniform.destroy()
6130
+ e.particles?.points?.buffer.destroy()
6054
6131
  e.particles = null
6055
6132
  }
6056
6133
  }
@@ -7488,15 +7565,21 @@ export class Engine {
7488
7565
 
7489
7566
  // BEFORE the bind group below, which binds it. Full size from the start:
7490
7567
  // every material pipeline binds this, so sizing it to the light count would
7491
- // mean rebuilding bind groups whenever a scene gained a lamp. Zero-filled,
7492
- // and float 0 is a count of 0.
7493
- 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.
7494
7571
  this.lightsBuffer = this.device.createBuffer({
7495
7572
  label: "positional lights",
7496
7573
  size: this.lightsData.byteLength,
7497
7574
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
7498
7575
  })
7499
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
+ })
7500
7583
  // BEFORE the per-frame groups, which bind it. The composite needs this 1x1
7501
7584
  // stand-in too, and it used to be created with the composite's own
7502
7585
  // resources further down — far enough down that a material group built up
@@ -10047,6 +10130,20 @@ export class Engine {
10047
10130
  this.lightsData[b + 14] = 0
10048
10131
  this.lightsData[b + 15] = 0
10049
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)
10050
10147
  // The CPU copy is written either way; only the upload needs a device. A
10051
10148
  // scene whose lamps arrive before init keeps them, and init uploads them.
10052
10149
  if (list.length && this.device && this.lightsBuffer) {
@@ -10057,6 +10154,13 @@ export class Engine {
10057
10154
  LIGHT_HEADER * 4,
10058
10155
  list.length * LIGHT_STRIDE * 4,
10059
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
+ )
10060
10164
  }
10061
10165
  // The effects' bases move when the document's count does; the header —
10062
10166
  // count included — is written there.
@@ -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