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
@@ -11,18 +11,27 @@ import { subjectMaskApi } from "./cast-api"
11
11
  // renderer that bolted a second key light onto a toon shader has shipped. A
12
12
  // light here brightens; it does not restate the shading.
13
13
  //
14
- // So there are no per-light shadows, no area lights and no clustering. The loop
15
- // runs over the lights a scene HAS, not over the cap, so a scene with four pays
16
- // for four; the cap only bounds the buffer and the worst case.
14
+ // So there are no per-light shadows and no area lights. A fragment walks the
15
+ // DOCUMENT's lamps through a world-space grid (see light-grid.ts): its cell
16
+ // names the lamps that can reach it, so a stage rig of fifty costs each pixel
17
+ // the handful standing near it. The lamps an EFFECT emits are placed by a
18
+ // compute pass every frame, which the grid never sees, and are walked in full.
17
19
  //
18
- // LAYOUT. A 4-float header (count, then padding that keeps the records
19
- // vec4-aligned), then MAX_LIGHTS records of 16 floats — four vec4s:
20
+ // LAYOUT, in 32-bit words, read by the shader as vec4u. A 16-word header:
21
+ //
22
+ // [0] light count (f32) [1] how many are the document's (f32)
23
+ // [4..6] grid origin (f32) [7] 1 / cell edge (f32)
24
+ // [8..10] grid dims (u32) [12..15] the OUTSIDE mask (u32 bits)
25
+ //
26
+ // then MAX_LIGHTS records of 16 floats — four vec4s:
20
27
  //
21
28
  // [0..2] position, world space [3] radius
22
29
  // [4..6] colour PREMULTIPLIED by intensity [7] type
23
30
  // [8..10] aim, unit, pointing away from the light [11] cos of the outer angle
24
31
  // [12] cos of the inner angle [13..15] spare
25
32
  //
33
+ // then the grid: LIGHT_MASK_WORDS words of lamp bits per cell.
34
+ //
26
35
  // Colour carries intensity because nothing reads them apart: every use is the
27
36
  // product, and storing two numbers that are only ever multiplied is two numbers
28
37
  // that can disagree.
@@ -39,10 +48,11 @@ import { lyricsApi } from "./lyrics-api"
39
48
  import { clockApi, trailSlotsApi, viewportApi } from "./passes/hosted-api"
40
49
  import { idApi } from "./id-api"
41
50
  import { sceneLightApi, worldAmbientWgsl } from "./scene-light-api"
51
+ import { pointsApi } from "./points-api"
42
52
 
43
- /** Floats before the first record. One is the count; the rest keep the records
44
- * vec4-aligned, which is what lets a future pass read them as vec4s. */
45
- export const LIGHT_HEADER = 4
53
+ /** Words before the first record: the counts, the grid's placement and the
54
+ * outside mask, four vec4s — see the layout above. */
55
+ export const LIGHT_HEADER = 16
46
56
  /** Floats per light — see the layout above. */
47
57
  export const LIGHT_STRIDE = 16
48
58
  /**
@@ -56,15 +66,20 @@ export const LIGHT_STRIDE = 16
56
66
  * interior brings a hundred or more, and the ceilings of sixteen and then
57
67
  * forty-eight each cut a real scene in half. Past this the extras are dropped.
58
68
  *
59
- * What it does NOT buy: a scene that genuinely lights a fragment from a hundred
60
- * lamps pays for a hundred iterations of the loop. The distance test below
61
- * rejects most of them in a few instructions, which is what makes a rig of this
62
- * size affordable at all; clustering is what replaces the linear walk when a
63
- * scene wants every one of them close enough to matter.
69
+ * A fragment pays for the document lamps its grid cell names, so the cost
70
+ * follows how many reach a place rather than how many the scene holds. It is
71
+ * also why the cap is 128: one vec4u of bits per cell.
64
72
  */
65
73
  export const MAX_LIGHTS = 128
66
- /** Floats in the whole buffer. */
67
- export const LIGHTS_FLOATS = LIGHT_HEADER + MAX_LIGHTS * LIGHT_STRIDE
74
+ /** Words of lamp bits per grid cell — one bit per lamp the cap allows. */
75
+ export const LIGHT_MASK_WORDS = MAX_LIGHTS / 32
76
+ /** The grid's cell budget. 32k cells over a stage a couple of hundred units
77
+ * across is a cell of about four — finer than the lamps it separates. */
78
+ export const LIGHT_GRID_CELLS = 32768
79
+ /** Where the grid starts, in words. */
80
+ export const LIGHT_GRID_BASE = LIGHT_HEADER + MAX_LIGHTS * LIGHT_STRIDE
81
+ /** Words in the whole buffer: header, records, grid. */
82
+ export const LIGHTS_FLOATS = LIGHT_GRID_BASE + LIGHT_GRID_CELLS * LIGHT_MASK_WORDS
68
83
 
69
84
  /**
70
85
  * The RzLight struct, declared in EVERY module a user's source is spliced into.
@@ -164,7 +179,7 @@ ${viewportApi("viewU[6].w")}
164
179
  ${trailSlotsApi(cast.trailCount)}
165
180
  // The id accessors, stubbed: this module cannot read an attachment the
166
181
  // scene pass writes. See id-api.ts — the author's whole file compiles here.
167
- ${idApi(false, 0, 0) + castDistanceStub() + sceneLightApi(false, 0, 0)}
182
+ ${idApi(false, 0, 0) + castDistanceStub() + sceneLightApi(false, 0, 0) + pointsApi(false)}
168
183
  // The dials the author declared, if any. A lamp is exactly the thing someone
169
184
  // retunes — its colour and its reach — so an emitter reads params like every
170
185
  // other mount rather than being the one place a #param resolves to nothing.
@@ -229,51 +244,55 @@ fn lightEmitMain(@builtin(global_invocation_id) gid: vec3u) {
229
244
 
230
245
  /** The rz*Light accessors, with the buffer declared at the given binding. */
231
246
  export function lightsApi(group: number, binding: number): string {
247
+ const R = LIGHT_HEADER / 4
248
+ const S = LIGHT_STRIDE / 4
232
249
  return /* wgsl */ `
233
- @group(${group}) @binding(${binding}) var<storage, read> _rzLights: array<f32>;
250
+ // vec4u, so a record is four loads and a cell's lamp bits are one. Floats come
251
+ // out through bitcast; the bits must NOT pass through f32 on the way, where a
252
+ // mask that happens to spell a NaN is not guaranteed to survive a load.
253
+ @group(${group}) @binding(${binding}) var<storage, read> _rzLights: array<vec4u>;
234
254
 
235
255
  const RZ_MAX_LIGHTS: u32 = ${MAX_LIGHTS}u;
236
256
 
237
257
  /** How many positional lights the scene has. Zero is the ordinary case. */
238
- fn rzLightCount() -> u32 { return min(u32(_rzLights[0]), RZ_MAX_LIGHTS); }
258
+ fn rzLightCount() -> u32 { return min(u32(bitcast<f32>(_rzLights[0].x)), RZ_MAX_LIGHTS); }
259
+
260
+ /** How many of them the document placed — the ones the grid indexes. */
261
+ fn _rzLightDocCount() -> u32 { return min(u32(bitcast<f32>(_rzLights[0].y)), rzLightCount()); }
262
+
263
+ /** One vec4 of light i's record. */
264
+ fn _rzLightVec(i: u32, k: u32) -> vec4f { return bitcast<vec4f>(_rzLights[${R}u + i * ${S}u + k]); }
239
265
 
240
266
  /** Light i's world position. */
241
- fn rzLightPos(i: u32) -> vec3f {
242
- let b = ${LIGHT_HEADER}u + i * ${LIGHT_STRIDE}u;
243
- return vec3f(_rzLights[b], _rzLights[b + 1u], _rzLights[b + 2u]);
244
- }
267
+ fn rzLightPos(i: u32) -> vec3f { return _rzLightVec(i, 0u).xyz; }
245
268
 
246
269
  /** How far light i reaches. Its falloff is zero AT this distance, not merely
247
- * small, so the light has a bound a cull can be derived from later. */
248
- fn rzLightRadius(i: u32) -> f32 { return _rzLights[${LIGHT_HEADER}u + i * ${LIGHT_STRIDE}u + 3u]; }
270
+ * small, which is the bound the grid is built from. */
271
+ fn rzLightRadius(i: u32) -> f32 { return _rzLightVec(i, 0u).w; }
249
272
 
250
273
  /** Light i's colour, already multiplied by its intensity. */
251
- fn rzLightColor(i: u32) -> vec3f {
252
- let b = ${LIGHT_HEADER}u + i * ${LIGHT_STRIDE}u + 4u;
253
- return vec3f(_rzLights[b], _rzLights[b + 1u], _rzLights[b + 2u]);
254
- }
274
+ fn rzLightColor(i: u32) -> vec3f { return _rzLightVec(i, 1u).xyz; }
255
275
 
256
276
  /** Where light i points, away from itself. The zero vector for a point light. */
257
- fn rzLightAim(i: u32) -> vec3f {
258
- let b = ${LIGHT_HEADER}u + i * ${LIGHT_STRIDE}u + 8u;
259
- return vec3f(_rzLights[b], _rzLights[b + 1u], _rzLights[b + 2u]);
260
- }
277
+ fn rzLightAim(i: u32) -> vec3f { return _rzLightVec(i, 2u).xyz; }
261
278
 
262
279
  /** The cosines a spot fades between: x its outer edge, y its inner one. A point
263
280
  * light stores (-1, -1), which saturates the cone term to 1. */
264
- fn rzLightCone(i: u32) -> vec2f {
265
- let b = ${LIGHT_HEADER}u + i * ${LIGHT_STRIDE}u + 11u;
266
- return vec2f(_rzLights[b], _rzLights[b + 1u]);
281
+ fn rzLightCone(i: u32) -> vec2f { return vec2f(_rzLightVec(i, 2u).w, _rzLightVec(i, 3u).x); }
282
+
283
+ /** The document lamps that can reach p: its grid cell's bits, or the outside
284
+ * mask beyond the grid. Written so a NaN position fails the inside test. */
285
+ fn _rzLightCellMask(p: vec3f) -> vec4u {
286
+ let g = bitcast<vec4f>(_rzLights[1]);
287
+ let dims = _rzLights[2].xyz;
288
+ let c = floor((p - g.xyz) * g.w);
289
+ if (!(all(c >= vec3f(0.0)) && all(c < vec3f(dims)))) { return _rzLights[3]; }
290
+ let ci = vec3u(c);
291
+ return _rzLights[${LIGHT_GRID_BASE / 4}u + (ci.z * dims.y + ci.y) * dims.x + ci.x];
267
292
  }
268
293
 
269
294
  /**
270
- * Every positional light's contribution at a surface point, as light — not as a
271
- * finished colour. Multiply by whatever the surface's albedo is.
272
- *
273
- * WITH NO LIGHTS THIS RETURNS EXACTLY ZERO and the loop never runs, so a scene
274
- * that declares none is arithmetically identical to one compiled before lights
275
- * existed. That is the property the whole feature is gated on: adding this to
276
- * every material must cost nothing until someone asks for a light.
295
+ * One light's contribution at a surface point.
277
296
  *
278
297
  * FALLOFF IS RELATIVE TO THE RADIUS, and deliberately not physical.
279
298
  *
@@ -287,33 +306,67 @@ fn rzLightCone(i: u32) -> vec2f {
287
306
  * zero, and the curve between them is the same shape whatever the scene's
288
307
  * scale. Both dials now mean what they say, which for a composer beats being
289
308
  * right about photons. (1 - t²)² — smooth at both ends, exactly 0 at the
290
- * radius, so the bound a cull could be derived from is still real.
309
+ * radius, so the bound the grid is built from is real.
310
+ */
311
+ fn _rzLightOne(i: u32, p: vec3f, n: vec3f) -> vec3f {
312
+ let pr = _rzLightVec(i, 0u);
313
+ let d = pr.xyz - p;
314
+ let dist = length(d);
315
+ // Out of reach before anything else is computed. The grid is conservative —
316
+ // a cell's bit means the lamp CAN reach part of it — so this still runs.
317
+ if (dist >= pr.w) { return vec3f(0.0); }
318
+ let toLight = d / max(dist, 1e-4);
319
+ // Facing the light, and nothing behind it. No wrap or half-lambert: this
320
+ // layer adds light, and a wrapped term would lift the shadow side, which is
321
+ // the ramp's business and not this one's.
322
+ let ndl = max(dot(n, toLight), 0.0);
323
+ if (ndl <= 0.0) { return vec3f(0.0); }
324
+ let t = clamp(dist / max(pr.w, 1e-4), 0.0, 1.0);
325
+ let falloff = 1.0 - t * t;
326
+ // How far inside the cone this point sits: 1 within the inner angle, 0 past
327
+ // the outer one, squared for the same soft edge the falloff has. A point
328
+ // light's (-1, -1) divides by the floor and clamps to 1, so it pays one
329
+ // dot product and no branch.
330
+ let cone = rzLightCone(i);
331
+ let aim = clamp((dot(-toLight, rzLightAim(i)) - cone.x) / max(cone.y - cone.x, 1e-4), 0.0, 1.0);
332
+ return rzLightColor(i) * (ndl * falloff * falloff * aim * aim);
333
+ }
334
+
335
+ /** The lamps named by one word of a cell's bits, lowest first. */
336
+ fn _rzLightWord(bits0: u32, base: u32, p: vec3f, n: vec3f) -> vec3f {
337
+ var acc = vec3f(0.0);
338
+ var bits = bits0;
339
+ loop {
340
+ if (bits == 0u) { break; }
341
+ let i = base + firstTrailingBit(bits);
342
+ bits = bits & (bits - 1u);
343
+ acc = acc + _rzLightOne(i, p, n);
344
+ }
345
+ return acc;
346
+ }
347
+
348
+ /**
349
+ * Every positional light's contribution at a surface point, as light — not as a
350
+ * finished colour. Multiply by whatever the surface's albedo is.
351
+ *
352
+ * WITH NO LIGHTS THIS RETURNS EXACTLY ZERO and neither walk runs, so a scene
353
+ * that declares none is arithmetically identical to one compiled before lights
354
+ * existed. That is the property the whole feature is gated on: adding this to
355
+ * every material must cost nothing until someone asks for a light.
291
356
  */
292
357
  fn rzLightsDiffuse(p: vec3f, n: vec3f) -> vec3f {
293
358
  var acc = vec3f(0.0);
294
359
  let count = rzLightCount();
295
- for (var i = 0u; i < count; i = i + 1u) {
296
- let d = rzLightPos(i) - p;
297
- let dist = length(d);
298
- // Out of reach before anything else is computed. The falloff is already
299
- // exactly zero at the radius, so this changes no pixel — it is what keeps a
300
- // stage rig's far lamps off the bill at every fragment they do not light.
301
- if (dist >= rzLightRadius(i)) { continue; }
302
- let toLight = d / max(dist, 1e-4);
303
- // Facing the light, and nothing behind it. No wrap or half-lambert: this
304
- // layer adds light, and a wrapped term would lift the shadow side, which is
305
- // the ramp's business and not this one's.
306
- let ndl = max(dot(n, toLight), 0.0);
307
- if (ndl <= 0.0) { continue; }
308
- let t = clamp(dist / max(rzLightRadius(i), 1e-4), 0.0, 1.0);
309
- let falloff = 1.0 - t * t;
310
- // How far inside the cone this point sits: 1 within the inner angle, 0 past
311
- // the outer one, squared for the same soft edge the falloff has. A point
312
- // light's (-1, -1) divides by the floor and clamps to 1, so it pays one
313
- // dot product and no branch.
314
- let cone = rzLightCone(i);
315
- let aim = clamp((dot(-toLight, rzLightAim(i)) - cone.x) / max(cone.y - cone.x, 1e-4), 0.0, 1.0);
316
- acc = acc + rzLightColor(i) * (ndl * falloff * falloff * aim * aim);
360
+ let docs = _rzLightDocCount();
361
+ // The document's lamps: only those the grid says can reach this cell.
362
+ if (docs > 0u) {
363
+ let m = _rzLightCellMask(p);
364
+ acc = acc + _rzLightWord(m.x, 0u, p, n) + _rzLightWord(m.y, 32u, p, n) +
365
+ _rzLightWord(m.z, 64u, p, n) + _rzLightWord(m.w, 96u, p, n);
366
+ }
367
+ // The effects' lamps, which move every frame on the GPU: all of them.
368
+ for (var i = docs; i < count; i = i + 1u) {
369
+ acc = acc + _rzLightOne(i, p, n);
317
370
  }
318
371
  return acc;
319
372
  }
@@ -7,6 +7,7 @@ import { EFFECT_ANCHORS, EFFECT_SUBJECTS, EFFECT_TRAIL_BASE, EFFECT_TRAIL_SAMPLE
7
7
  import { audioApi } from "../audio-api"
8
8
  import { idApi } from "../id-api"
9
9
  import { sceneLightApi } from "../scene-light-api"
10
+ import { pointsApi } from "../points-api"
10
11
  import { castDistanceApi, CAST_FIELD_DIV } from "./cast-distance"
11
12
  import { lyricsApi, lyricsTextApi } from "../lyrics-api"
12
13
  import { midiApi } from "../midi-api"
@@ -724,6 +725,7 @@ export function buildFieldShader(effect: CompositeEffectSource): string {
724
725
  trailSlotsApi(effect.trailCount) +
725
726
  idApi(effect.ids === true, 0, 23) +
726
727
  sceneLightApi(false, 0, 0) +
728
+ pointsApi(false) +
727
729
  // Distance to the cast, in SCREEN pixels: the field is half-res, so a field
728
730
  // texel is CAST_FIELD_DIV of them and the accessor scales on the way out.
729
731
  // An author writes the width they mean and never learns how it is built.
@@ -9,6 +9,7 @@ import { type CastLayout } from "./particles"
9
9
  import { clockApi, trailSlotsApi, viewportApi } from "./hosted-api"
10
10
  import { idApi } from "../id-api"
11
11
  import { sceneLightApi } from "../scene-light-api"
12
+ import { pointsApi } from "../points-api"
12
13
 
13
14
  // A persistent grid an effect can step and read: the one thing an effect could
14
15
  // not have before, which is MEMORY.
@@ -170,6 +171,7 @@ fn rzGrid(uv: vec2f) -> vec4f { return rzGridPrev(uv); }
170
171
  // id-api.ts. The author's whole file compiles here, so the names must exist.
171
172
  idApi(false, 0, 0) +
172
173
  sceneLightApi(false, 0, 0) +
174
+ pointsApi(false) +
173
175
  // Inlined rather than imported from cast-distance, and NOT for tidiness.
174
176
  //
175
177
  // This module and composite.ts import each other — grid takes
@@ -11,6 +11,7 @@ import { clockApi, EFFECT_MATH_API, PARTICLE_STRUCT_WGSL, trailSlotsApi, viewpor
11
11
  import { sceneIdFieldWgsl, sceneIdPadWgsl } from "./scene-contract"
12
12
  import { idApi } from "../id-api"
13
13
  import { sceneLightApi } from "../scene-light-api"
14
+ import { pointsApi } from "../points-api"
14
15
  // GPU particles for user effects: a compute step and an instanced quad draw.
15
16
  //
16
17
  // Its own shader MODULE rather than more source spliced into composite.ts, for
@@ -109,6 +110,8 @@ type ParticleSource = {
109
110
 
110
111
  /** Where the shading stage's five scene-light bindings start — see scene-light-api.ts. */
111
112
  export const PARTICLE_LIGHT_BINDING = 10
113
+ /** The effect's named points (`#points`), in both stages — see points-api.ts. */
114
+ export const PARTICLE_POINTS_BINDING = 15
112
115
 
113
116
  /** Bytes per particle. Explicitly padded — see the struct below. */
114
117
  export const PARTICLE_STRIDE = 48
@@ -212,6 +215,8 @@ ${src.paramsDecl}
212
215
  // Stubbed: this module cannot read an attachment the scene pass writes — see
213
216
  // id-api.ts. The author's whole file compiles here, so the names must exist.
214
217
  idApi(false, 0, 0) + castDistanceStub() + sceneLightApi(false, 0, 0) +
218
+ // The named points, for real: particleInit is where a flame is put on a wick.
219
+ pointsApi(true, 0, PARTICLE_POINTS_BINDING) +
215
220
  "\n// ── user effect ──\n" +
216
221
  src.wgsl +
217
222
  /* wgsl */ `
@@ -289,6 +294,7 @@ ${src.paramsDecl}
289
294
  // The sun's shadow and the world's ambient, for real: the shadow pass has
290
295
  // run by the time this draws, inside the scene pass. See scene-light-api.ts.
291
296
  sceneLightApi(true, 0, PARTICLE_LIGHT_BINDING) +
297
+ pointsApi(true, 0, PARTICLE_POINTS_BINDING) +
292
298
  "\n// ── user effect ──\n" +
293
299
  src.wgsl +
294
300
  /* wgsl */ `
@@ -11,6 +11,7 @@ import { clockApi, EFFECT_MATH_API, PARTICLE_STRUCT_WGSL, trailSlotsApi, viewpor
11
11
  import { sceneIdFieldWgsl, sceneIdPadWgsl } from "./scene-contract"
12
12
  import { idApi } from "../id-api"
13
13
  import { sceneLightApi } from "../scene-light-api"
14
+ import { pointsApi } from "../points-api"
14
15
  // Ribbons along a bone's recorded path, drawn as geometry.
15
16
  //
16
17
  // This is the effect the whole geometry path was built for. As a fullscreen
@@ -302,7 +303,7 @@ fn rzTurnRadius(a: vec3f, b: vec3f, c: vec3f) -> f32 {
302
303
  gridReadApi(0, 8, 9, src.gridSize) +
303
304
  // Stubbed: this module cannot read an attachment the scene pass writes — see
304
305
  // id-api.ts. The author's whole file compiles here, so the names must exist.
305
- idApi(false, 0, 0) + castDistanceStub() + sceneLightApi(false, 0, 0) +
306
+ idApi(false, 0, 0) + castDistanceStub() + sceneLightApi(false, 0, 0) + pointsApi(false) +
306
307
  "\n// ── user effect ──\n" +
307
308
  src.wgsl +
308
309
  /* wgsl */ `
@@ -0,0 +1,66 @@
1
+ // Named points on the models, for an effect that puts something at each one.
2
+ //
3
+ // #points flame
4
+ //
5
+ // gives the effect every bone whose name starts with `flame`, on every model in
6
+ // the scene — a stage, its parts, a prop, a character — as RzPoint: where the
7
+ // bone stands and where its tail points, in world space, this frame. A candle
8
+ // stage carries a bone per wick; a flame effect draws one flame on each, sized
9
+ // and stood up by the tail.
10
+ //
11
+ // BONES, because a bone is MMD's own word for a named point in a model. A stage
12
+ // that brings its candles as bones needs no side file, a hand-made stage can
13
+ // add them in any PMX editor, and a candle a character carries moves with the
14
+ // hand that holds it.
15
+ //
16
+ // Not the cast's anchors: those name up to sixteen bones on four characters,
17
+ // one bone per name. A stage is not a character, and fifty wicks share a
18
+ // prefix rather than each having a name worth declaring.
19
+
20
+ /** How many points one effect can read. A candlelit hall is fifty. */
21
+ export const MAX_EFFECT_POINTS = 256
22
+
23
+ /** Floats in a points buffer: a vec4 header (count), then two vec4s a point. */
24
+ export const POINTS_FLOATS = 4 + MAX_EFFECT_POINTS * 8
25
+
26
+ const STRUCT = /* wgsl */ `
27
+ /** One named point: where the bone stands, and where its tail points to. */
28
+ struct RzPoint {
29
+ pos: vec3f,
30
+ tip: vec3f,
31
+ }
32
+ `
33
+
34
+ /**
35
+ * rzPointCount / rzPoint. `bound` is false in every module that cannot read the
36
+ * buffer — the author's whole file is spliced into each module it has a mount
37
+ * in, so the names must resolve there too, and there they read as no points.
38
+ */
39
+ export function pointsApi(bound: boolean, group = 0, binding = 0): string {
40
+ if (!bound) {
41
+ return (
42
+ STRUCT +
43
+ /* wgsl */ `
44
+ fn rzPointCount() -> u32 { return 0u; }
45
+ fn rzPoint(i: u32) -> RzPoint { var p: RzPoint; return p; }
46
+ `
47
+ )
48
+ }
49
+ return (
50
+ STRUCT +
51
+ /* wgsl */ `
52
+ @group(${group}) @binding(${binding}) var<storage, read> _rzPoints: array<vec4f>;
53
+
54
+ /** How many named points the scene has for this effect, this frame. */
55
+ fn rzPointCount() -> u32 { return min(u32(_rzPoints[0].x), ${MAX_EFFECT_POINTS}u); }
56
+
57
+ /** Point i, world space. */
58
+ fn rzPoint(i: u32) -> RzPoint {
59
+ var p: RzPoint;
60
+ p.pos = _rzPoints[1u + i * 2u].xyz;
61
+ p.tip = _rzPoints[2u + i * 2u].xyz;
62
+ return p;
63
+ }
64
+ `
65
+ )
66
+ }