reze-engine 0.43.0 → 0.50.1

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 (140) hide show
  1. package/README.md +53 -429
  2. package/dist/animation.d.ts +26 -0
  3. package/dist/animation.d.ts.map +1 -1
  4. package/dist/animation.js +42 -0
  5. package/dist/camera.d.ts +3 -0
  6. package/dist/camera.d.ts.map +1 -1
  7. package/dist/camera.js +33 -8
  8. package/dist/engine.d.ts +841 -53
  9. package/dist/engine.d.ts.map +1 -1
  10. package/dist/engine.js +3498 -451
  11. package/dist/graph/registry.d.ts +2 -2
  12. package/dist/graph/registry.d.ts.map +1 -1
  13. package/dist/graph/registry.js +1 -1
  14. package/dist/graph/slots.d.ts +0 -1
  15. package/dist/graph/slots.d.ts.map +1 -1
  16. package/dist/graph/slots.js +37 -9
  17. package/dist/hdr.d.ts +18 -0
  18. package/dist/hdr.d.ts.map +1 -0
  19. package/dist/hdr.js +162 -0
  20. package/dist/ibl.d.ts +19 -0
  21. package/dist/ibl.d.ts.map +1 -0
  22. package/dist/ibl.js +113 -0
  23. package/dist/ik-solver.d.ts +2 -1
  24. package/dist/ik-solver.d.ts.map +1 -1
  25. package/dist/index.d.ts +5 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +10 -0
  28. package/dist/math.d.ts +20 -1
  29. package/dist/math.d.ts.map +1 -1
  30. package/dist/math.js +23 -16
  31. package/dist/midi-loader.d.ts +10 -0
  32. package/dist/midi-loader.d.ts.map +1 -0
  33. package/dist/midi-loader.js +247 -0
  34. package/dist/model.d.ts +19 -14
  35. package/dist/model.d.ts.map +1 -1
  36. package/dist/model.js +31 -2
  37. package/dist/param-track.d.ts +48 -0
  38. package/dist/param-track.d.ts.map +1 -0
  39. package/dist/param-track.js +80 -0
  40. package/dist/physics/types.d.ts.map +1 -1
  41. package/dist/physics/types.js +3 -0
  42. package/dist/reflection.d.ts +27 -0
  43. package/dist/reflection.d.ts.map +1 -0
  44. package/dist/reflection.js +93 -0
  45. package/dist/shaders/anchor-table.d.ts +56 -0
  46. package/dist/shaders/anchor-table.d.ts.map +1 -0
  47. package/dist/shaders/anchor-table.js +128 -0
  48. package/dist/shaders/cast-api.d.ts +2 -0
  49. package/dist/shaders/cast-api.d.ts.map +1 -0
  50. package/dist/shaders/cast-api.js +121 -0
  51. package/dist/shaders/cast-layout.d.ts +21 -0
  52. package/dist/shaders/cast-layout.d.ts.map +1 -0
  53. package/dist/shaders/cast-layout.js +20 -0
  54. package/dist/shaders/lights.d.ts +79 -0
  55. package/dist/shaders/lights.d.ts.map +1 -0
  56. package/dist/shaders/lights.js +269 -0
  57. package/dist/shaders/lyrics-api.d.ts +39 -0
  58. package/dist/shaders/lyrics-api.d.ts.map +1 -0
  59. package/dist/shaders/lyrics-api.js +187 -0
  60. package/dist/shaders/materials/common.d.ts +2 -4
  61. package/dist/shaders/materials/common.d.ts.map +1 -1
  62. package/dist/shaders/materials/common.js +87 -35
  63. package/dist/shaders/midi-api.d.ts +10 -0
  64. package/dist/shaders/midi-api.d.ts.map +1 -0
  65. package/dist/shaders/midi-api.js +114 -0
  66. package/dist/shaders/passes/composite.d.ts +28 -22
  67. package/dist/shaders/passes/composite.d.ts.map +1 -1
  68. package/dist/shaders/passes/composite.js +165 -138
  69. package/dist/shaders/passes/cull.d.ts +2 -0
  70. package/dist/shaders/passes/cull.d.ts.map +1 -0
  71. package/dist/shaders/passes/cull.js +138 -0
  72. package/dist/shaders/passes/field-blit.d.ts +26 -0
  73. package/dist/shaders/passes/field-blit.d.ts.map +1 -0
  74. package/dist/shaders/passes/field-blit.js +65 -0
  75. package/dist/shaders/passes/grid.d.ts +31 -0
  76. package/dist/shaders/passes/grid.d.ts.map +1 -0
  77. package/dist/shaders/passes/grid.js +169 -0
  78. package/dist/shaders/passes/ground.d.ts +13 -1
  79. package/dist/shaders/passes/ground.d.ts.map +1 -1
  80. package/dist/shaders/passes/ground.js +170 -25
  81. package/dist/shaders/passes/hosted-api.d.ts +57 -0
  82. package/dist/shaders/passes/hosted-api.d.ts.map +1 -0
  83. package/dist/shaders/passes/hosted-api.js +166 -0
  84. package/dist/shaders/passes/id-debug.d.ts +28 -0
  85. package/dist/shaders/passes/id-debug.d.ts.map +1 -0
  86. package/dist/shaders/passes/id-debug.js +74 -0
  87. package/dist/shaders/passes/particles.d.ts +10 -2
  88. package/dist/shaders/passes/particles.d.ts.map +1 -1
  89. package/dist/shaders/passes/particles.js +37 -109
  90. package/dist/shaders/passes/scene-contract.d.ts +128 -0
  91. package/dist/shaders/passes/scene-contract.d.ts.map +1 -0
  92. package/dist/shaders/passes/scene-contract.js +207 -0
  93. package/dist/shaders/passes/sim.d.ts +34 -0
  94. package/dist/shaders/passes/sim.d.ts.map +1 -0
  95. package/dist/shaders/passes/sim.js +169 -0
  96. package/dist/shaders/passes/trails.d.ts +14 -2
  97. package/dist/shaders/passes/trails.d.ts.map +1 -1
  98. package/dist/shaders/passes/trails.js +55 -90
  99. package/dist/shaders/score-api.d.ts +10 -0
  100. package/dist/shaders/score-api.d.ts.map +1 -0
  101. package/dist/shaders/score-api.js +114 -0
  102. package/dist/shadow-cascades.d.ts +45 -0
  103. package/dist/shadow-cascades.d.ts.map +1 -0
  104. package/dist/shadow-cascades.js +70 -0
  105. package/dist/vmd-loader.d.ts +3 -2
  106. package/dist/vmd-loader.d.ts.map +1 -1
  107. package/package.json +1 -1
  108. package/src/animation.ts +41 -0
  109. package/src/camera.ts +31 -8
  110. package/src/engine.ts +4015 -556
  111. package/src/graph/registry.ts +2 -2
  112. package/src/graph/slots.ts +37 -9
  113. package/src/hdr.ts +156 -0
  114. package/src/ibl.ts +115 -0
  115. package/src/ik-solver.ts +1 -1
  116. package/src/index.ts +12 -0
  117. package/src/math.ts +23 -17
  118. package/src/midi-loader.ts +246 -0
  119. package/src/model.ts +34 -4
  120. package/src/param-track.ts +83 -0
  121. package/src/physics/types.ts +4 -1
  122. package/src/reflection.ts +94 -0
  123. package/src/shaders/anchor-table.ts +147 -0
  124. package/src/shaders/cast-api.ts +123 -0
  125. package/src/shaders/cast-layout.ts +20 -0
  126. package/src/shaders/lights.ts +280 -0
  127. package/src/shaders/lyrics-api.ts +202 -0
  128. package/src/shaders/materials/common.ts +89 -35
  129. package/src/shaders/midi-api.ts +116 -0
  130. package/src/shaders/passes/composite.ts +182 -139
  131. package/src/shaders/passes/cull.ts +139 -0
  132. package/src/shaders/passes/grid.ts +178 -0
  133. package/src/shaders/passes/ground.ts +172 -25
  134. package/src/shaders/passes/hosted-api.ts +171 -0
  135. package/src/shaders/passes/id-debug.ts +75 -0
  136. package/src/shaders/passes/particles.ts +54 -112
  137. package/src/shaders/passes/scene-contract.ts +266 -0
  138. package/src/shaders/passes/trails.ts +77 -93
  139. package/src/shadow-cascades.ts +97 -0
  140. package/src/vmd-loader.ts +2 -2
@@ -0,0 +1,75 @@
1
+ import { GROUND_MATERIAL_ID } from "./ground"
2
+
3
+ /**
4
+ * The id attachment, drawn so a person can look at it.
5
+ *
6
+ * The id buffer is correct or incorrect in ways nothing else in the frame can
7
+ * show: with no consumer, a perfect id buffer and a completely wrong one
8
+ * produce exactly the same picture. This pass exists so that "it works" is
9
+ * something seen rather than inferred.
10
+ *
11
+ * WHAT CORRECT LOOKS LIKE, and what each failure looks like instead:
12
+ *
13
+ * - Every material is ONE FLAT COLOUR, with hard edges. Ids are names, not
14
+ * quantities: a gradient anywhere, or fringing along an edge, means
15
+ * something is interpolating or resolving them — the two failures the
16
+ * no-resolve/no-blend rules exist to prevent.
17
+ * - Parts differ from each other. A whole model in one colour means the
18
+ * material index never made it into the uniform.
19
+ * - The FLOOR is a fixed light grey, the reserved id, and never shares a
20
+ * colour with a body part.
21
+ * - Where nothing was drawn is BLACK — id 0, the reserved nothing, which is
22
+ * also what the attachment clears to. Black *over geometry* means that draw
23
+ * wrote no id; colour where the sky should be means the clear is wrong.
24
+ *
25
+ * Colours come from a hash of the pair, so neighbouring materials land on
26
+ * unrelated hues rather than adjacent shades of one — the point is telling them
27
+ * apart, not ordering them.
28
+ */
29
+ export const ID_DEBUG_SHADER_WGSL = /* wgsl */ `
30
+ // Multisampled and NEVER resolved, so it is read the way it is written: one
31
+ // sample, by texel. Sample 0 is what every consumer of this attachment reads.
32
+ @group(0) @binding(0) var idTex: texture_multisampled_2d<u32>;
33
+
34
+ @vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
35
+ let x = f32((vi & 1u) << 2u) - 1.0;
36
+ let y = f32((vi & 2u) << 1u) - 1.0;
37
+ return vec4f(x, y, 0.0, 1.0);
38
+ }
39
+
40
+ /** Integer hash → 0..1. Distinct inputs land on unrelated outputs, which is the
41
+ * whole requirement: adjacent ids must not read as adjacent colours. */
42
+ fn hashId(x: u32) -> f32 {
43
+ var h = x * 747796405u + 2891336453u;
44
+ h = ((h >> ((h >> 28u) + 4u)) ^ h) * 277803737u;
45
+ return f32((h >> 22u) ^ h) / 4294967295.0;
46
+ }
47
+
48
+ @fragment fn fs(@builtin(position) fragCoord: vec4f) -> @location(0) vec4f {
49
+ let ids = textureLoad(idTex, vec2<i32>(fragCoord.xy), 0);
50
+ let materialId = ids.x;
51
+ let objectId = ids.y;
52
+
53
+ // Nothing drawn here. Black, and it must cover exactly the empty space.
54
+ if (materialId == 0u && objectId == 0u) {
55
+ return vec4f(0.0, 0.0, 0.0, 1.0);
56
+ }
57
+ // The floor, at the top of the range. Flat light grey so it is unmistakable
58
+ // and cannot be confused with a hashed colour.
59
+ if (materialId == ${GROUND_MATERIAL_ID}u) {
60
+ return vec4f(0.72, 0.72, 0.75, 1.0);
61
+ }
62
+ // Hue from the material, brightness from the object, so two models wearing
63
+ // the same material index still read apart.
64
+ let h = hashId(materialId * 1973u + 9277u);
65
+ let shade = 0.55 + 0.45 * hashId(objectId * 6151u + 1u);
66
+ // Cheap hue ramp: three offset cosines. Saturated on purpose — this is a
67
+ // diagnostic, not a look.
68
+ let rgb = vec3f(
69
+ 0.5 + 0.5 * cos(6.28318 * (h + 0.00)),
70
+ 0.5 + 0.5 * cos(6.28318 * (h + 0.33)),
71
+ 0.5 + 0.5 * cos(6.28318 * (h + 0.67)),
72
+ );
73
+ return vec4f(rgb * shade, 1.0);
74
+ }
75
+ `
@@ -1,4 +1,10 @@
1
+ import { RZ_LIGHT_STRUCT_WGSL } from "../lights"
1
2
  import { audioApi } from "../audio-api"
3
+ import { lyricsApi } from "../lyrics-api"
4
+ import { anchorAliasWgsl } from "../anchor-table"
5
+ import { midiApi } from "../midi-api"
6
+ import { CAST_API } from "../cast-api"
7
+ import { clockApi, EFFECT_MATH_API, PARTICLE_STRUCT_WGSL, trailSlotsApi, viewportApi } from "./hosted-api"
2
8
  // GPU particles for user effects: a compute step and an instanced quad draw.
3
9
  //
4
10
  // Its own shader MODULE rather than more source spliced into composite.ts, for
@@ -17,7 +23,20 @@ import { audioApi } from "../audio-api"
17
23
  // gets them subtly wrong in a way that only shows up on someone else's machine.
18
24
 
19
25
  /** Where the cast/trail history sits in the shared storage buffer. */
20
- export type CastLayout = { subjects: number; samples: number; base: number; trailBase: number; slots: number }
26
+ export type CastLayout = {
27
+ subjects: number
28
+ samples: number
29
+ base: number
30
+ trailBase: number
31
+ /** The scene's anchor CAP — the address space, not how many asked for trails. */
32
+ slots: number
33
+ /** How many of this effect's anchors asked for a trail. AUTHOR-VISIBLE as
34
+ * RZ_TRAIL_SLOTS: published effects loop over it, so its meaning is pinned
35
+ * even though the address space it used to share is now a separate number. */
36
+ trailCount: number
37
+ /** This effect's local slot → scene slot. Identity while one effect exists. */
38
+ alias: number[]
39
+ }
21
40
 
22
41
  /**
23
42
  * The trail accessors, in the PARTICLE module.
@@ -27,11 +46,15 @@ export type CastLayout = { subjects: number; samples: number; base: number; trai
27
46
  * particleInit reading the same recorded history the trail draws from. One
28
47
  * effect file, two mounts, one buffer.
29
48
  */
49
+ /** Shared with the grid pass, which reads the same buffer for the same reason:
50
+ * a kernel that displaces fog has to know where the dancer's feet are. */
30
51
  function castApi(cast: CastLayout): string {
31
- return `
32
- const RZ_SUBJECTS: i32 = ${cast.subjects};
33
- const RZ_SAMPLES: i32 = ${cast.samples};
34
- const RZ_TRAIL_SLOTS: i32 = ${cast.slots};
52
+ return (
53
+ // rzSubjectCount FIRST, because CAST_API is written against it and this
54
+ // module has no view uniform to read the engine's count out of. Scanning
55
+ // for a subject whose bounding sphere has a radius is the same answer by a
56
+ // different route — the seam is named at the top of cast-api.ts.
57
+ `
35
58
  fn rzSubjectCount() -> i32 {
36
59
  var n = 0;
37
60
  for (var i = 0; i < RZ_SUBJECTS; i++) {
@@ -39,23 +62,17 @@ fn rzSubjectCount() -> i32 {
39
62
  }
40
63
  return n;
41
64
  }
42
- fn rzTrailCount(subject: i32, slot: i32) -> i32 {
43
- if (subject < 0 || subject >= RZ_SUBJECTS || slot < 0 || slot >= RZ_TRAIL_SLOTS) { return 0; }
44
- return i32(_rzCast[${cast.base} + (slot * RZ_SUBJECTS + subject) * 3 + 2].w);
45
- }
46
- /** Sample i of a path: xyz where it was, w how many seconds ago. i = 0 is now. */
47
- fn rzTrail(subject: i32, slot: i32, i: i32) -> vec4f {
48
- let n = rzTrailCount(subject, slot);
49
- if (i < 0 || i >= n) { return vec4f(0.0); }
50
- return _rzCast[${cast.trailBase} + (slot * RZ_SUBJECTS + subject) * RZ_SAMPLES + i];
51
- }
52
- `
65
+ ` +
66
+ CAST_API +
67
+ trailSlotsApi(cast.trailCount) +
68
+ anchorAliasWgsl(cast.alias)
69
+ )
53
70
  }
54
71
 
55
72
  /** How the author's quads combine with the scene. */
56
- export type ParticleBlend = "alpha" | "additive"
73
+ type ParticleBlend = "alpha" | "additive"
57
74
 
58
- export type ParticleSource = {
75
+ type ParticleSource = {
59
76
  /** The author's WGSL verbatim. */
60
77
  wgsl: string
61
78
  /** Simultaneous particles. Fixed at install; the pool recycles rather than grows. */
@@ -68,33 +85,6 @@ export type ParticleSource = {
68
85
  /** Bytes per particle. Explicitly padded — see the struct below. */
69
86
  export const PARTICLE_STRIDE = 48
70
87
 
71
- /**
72
- * The particle record, laid out by hand.
73
- *
74
- * `age` and `life` sit in the padding that vec3f alignment would waste anyway
75
- * (a vec3f occupies 12 bytes but aligns the next field to 16), so the struct is
76
- * 48 bytes rather than the 64 a naive ordering costs. At 4096 particles that is
77
- * 192KB instead of 256KB, and it is read every frame by both stages.
78
- *
79
- * `life <= 0` means "not alive" and is what the pool checks to recycle a slot,
80
- * so a freshly zeroed buffer is entirely dead and every particle is born on the
81
- * first step rather than needing a separate seeding pass.
82
- */
83
- const PARTICLE_STRUCT = /* wgsl */ `
84
- struct Particle {
85
- pos: vec3f,
86
- age: f32,
87
- vel: vec3f,
88
- life: f32,
89
- size: f32,
90
- rot: f32,
91
- seed: f32,
92
- // Aspect along the direction of travel. 1 or less is a square billboard; a
93
- // raindrop is 10 or 20. Zero-initialised, so an effect that never sets it gets
94
- // the square it expects.
95
- stretch: f32,
96
- }
97
- `
98
88
 
99
89
  const CAMERA_STRUCT = /* wgsl */ `
100
90
  struct CameraU {
@@ -115,72 +105,21 @@ struct ParticleU {
115
105
  `
116
106
 
117
107
  /**
118
- * The shared prelude, everything `rz`-prefixed.
108
+ * This module's half of the prelude: the camera, from its own uniform.
119
109
  *
120
- * Not convenience — correctness. Every effect written against the old contract
121
- * re-derived its own hash and its own falloff, which is duplicated code and
122
- * duplicated bugs; `rzFalloff` in particular has COMPACT SUPPORT (it reaches
123
- * exactly zero at r), because an exponential glow that never quite reaches zero
124
- * has to be culled somewhere, and culling it wherever it "looks close enough"
125
- * is what put a visible hard edge on the first halo effect.
110
+ * The rest — hashes, noise, falloff, clock, viewport — is shared with every
111
+ * other module that hosts an effect's source, because an author's helper must
112
+ * mean the same thing in whichever one it lands in.
126
113
  */
127
- const PRELUDE = /* wgsl */ `
128
- fn rzHash11(x: f32) -> f32 {
129
- var p = fract(x * 0.1031);
130
- p = p * (p + 33.33);
131
- return fract(p * (p + p));
132
- }
133
- fn rzHash21(p: vec2f) -> f32 {
134
- var p3 = fract(vec3f(p.x, p.y, p.x) * 0.1031);
135
- p3 = p3 + dot(p3, p3.yzx + 33.33);
136
- return fract((p3.x + p3.y) * p3.z);
137
- }
138
- fn rzHash31(p: vec3f) -> f32 {
139
- var p3 = fract(p * 0.1031);
140
- p3 = p3 + dot(p3, p3.zyx + 31.32);
141
- return fract((p3.x + p3.y) * p3.z);
142
- }
143
- /** Three independent randoms from one seed — the usual need when spawning. */
144
- fn rzHash13(x: f32) -> vec3f {
145
- return vec3f(rzHash11(x), rzHash11(x + 17.13), rzHash11(x + 41.71));
146
- }
147
- fn rzValueNoise(p: vec3f) -> f32 {
148
- let i = floor(p);
149
- let f = fract(p);
150
- let u = f * f * (3.0 - 2.0 * f);
151
- let n000 = rzHash31(i + vec3f(0.0, 0.0, 0.0));
152
- let n100 = rzHash31(i + vec3f(1.0, 0.0, 0.0));
153
- let n010 = rzHash31(i + vec3f(0.0, 1.0, 0.0));
154
- let n110 = rzHash31(i + vec3f(1.0, 1.0, 0.0));
155
- let n001 = rzHash31(i + vec3f(0.0, 0.0, 1.0));
156
- let n101 = rzHash31(i + vec3f(1.0, 0.0, 1.0));
157
- let n011 = rzHash31(i + vec3f(0.0, 1.0, 1.0));
158
- let n111 = rzHash31(i + vec3f(1.0, 1.0, 1.0));
159
- let x00 = mix(n000, n100, u.x);
160
- let x10 = mix(n010, n110, u.x);
161
- let x01 = mix(n001, n101, u.x);
162
- let x11 = mix(n011, n111, u.x);
163
- return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);
164
- }
165
- /** Divergence-free flow — the standard drifting-air force. Snow and mist want this. */
166
- fn rzCurlNoise(p: vec3f) -> vec3f {
167
- let e = 0.1;
168
- let dx = vec3f(e, 0.0, 0.0);
169
- let dy = vec3f(0.0, e, 0.0);
170
- let dz = vec3f(0.0, 0.0, e);
171
- let x0 = rzValueNoise(p - dx); let x1 = rzValueNoise(p + dx);
172
- let y0 = rzValueNoise(p - dy); let y1 = rzValueNoise(p + dy);
173
- let z0 = rzValueNoise(p - dz); let z1 = rzValueNoise(p + dz);
174
- return normalize(vec3f((y1 - y0) - (z1 - z0), (z1 - z0) - (x1 - x0), (x1 - x0) - (y1 - y0)) + vec3f(1e-6));
175
- }
176
- /** Compact-support falloff: 1 at the centre, exactly 0 at r, smooth between. */
177
- fn rzFalloff(d: f32, r: f32) -> f32 {
178
- let x = clamp(d / max(r, 1e-6), 0.0, 1.0);
179
- let f = 1.0 - x;
180
- return f * f * f;
181
- }
182
- fn rzTime() -> f32 { return pu.time; }
183
- fn rzViewportHeight() -> f32 { return cam.targetHeight; }
114
+ const PRELUDE =
115
+ // The hashes, the noise and rzFalloff, identical in every module that hosts an
116
+ // effect's source; then the clock and the viewport, which cannot be, because
117
+ // this module keeps both in its own uniforms.
118
+ EFFECT_MATH_API +
119
+ clockApi("pu.time", "pu.dt") +
120
+ viewportApi("cam.targetHeight") +
121
+ /* wgsl */ `
122
+ ${RZ_LIGHT_STRUCT_WGSL}
184
123
  fn rzCameraPos() -> vec3f { return cam.camPos; }
185
124
  fn rzCameraRight() -> vec3f { return vec3f(cam.view[0][0], cam.view[1][0], cam.view[2][0]); }
186
125
  fn rzCameraUp() -> vec3f { return vec3f(cam.view[0][1], cam.view[1][1], cam.view[2][1]); }
@@ -191,7 +130,6 @@ fn rzProject(p: vec3f) -> vec3f {
191
130
  let w = max(clip.w, 1e-4);
192
131
  return vec3f(clip.xy / w * 0.5 + 0.5, clip.w);
193
132
  }
194
- fn rzDt() -> f32 { return pu.dt; }
195
133
  fn rzCamPos() -> vec3f { return cam.camPos; }
196
134
  `
197
135
 
@@ -235,7 +173,7 @@ export function particleEntryPoints(wgsl: string): { init: boolean; step: boolea
235
173
  */
236
174
  export function buildParticleComputeShader(src: ParticleSource, cast: CastLayout): string {
237
175
  return (
238
- PARTICLE_STRUCT +
176
+ PARTICLE_STRUCT_WGSL +
239
177
  CAMERA_STRUCT +
240
178
  PARTICLE_UNIFORMS +
241
179
  `
@@ -246,6 +184,8 @@ export function buildParticleComputeShader(src: ParticleSource, cast: CastLayout
246
184
  ` +
247
185
  castApi(cast) +
248
186
  audioApi(0, 4) +
187
+ midiApi(0, 5) +
188
+ lyricsApi(0, 6) +
249
189
  PRELUDE +
250
190
  "\n// ── user effect ──\n" +
251
191
  src.wgsl +
@@ -292,7 +232,7 @@ export function buildParticleRenderShader(src: ParticleSource, cast: CastLayout)
292
232
  return (
293
233
  `override BLOOM: bool = ${src.bloom ? "true" : "false"};
294
234
  override ADDITIVE: bool = ${src.blend === "additive" ? "true" : "false"};\n` +
295
- PARTICLE_STRUCT +
235
+ PARTICLE_STRUCT_WGSL +
296
236
  CAMERA_STRUCT +
297
237
  PARTICLE_UNIFORMS +
298
238
  `
@@ -303,6 +243,8 @@ override ADDITIVE: bool = ${src.blend === "additive" ? "true" : "false"};\n` +
303
243
  ` +
304
244
  castApi(cast) +
305
245
  audioApi(0, 4) +
246
+ midiApi(0, 5) +
247
+ lyricsApi(0, 6) +
306
248
  PRELUDE +
307
249
  "\n// ── user effect ──\n" +
308
250
  src.wgsl +
@@ -0,0 +1,266 @@
1
+ /**
2
+ * The scene pass's attachment contract, in one place.
3
+ *
4
+ * Everything drawn INSIDE the scene pass — models, ground, outline hulls,
5
+ * particles, ribbons, and the transparent depth prepass — shares one set of
6
+ * attachments, and therefore one set of formats, blends and write masks. That
7
+ * agreement used to be restated at every pipeline that joins the pass and in
8
+ * every shader that writes to it, which is why adding an attachment was
9
+ * dangerous rather than tedious: a class left behind does not fail loudly, it
10
+ * fails as a validation error naming a pipeline that was never edited.
11
+ *
12
+ * So the contract is DATA here, and the pipelines ask for it by render class.
13
+ * Adding the id attachment (MRT) becomes one edit in this file plus a per-class
14
+ * decision about whether that class writes it — which is the shape of the
15
+ * question, and now the shape of the code.
16
+ *
17
+ * WHAT IS NOT HERE. Two passes look like they belong and do not:
18
+ * - the FIELD pass has its own pair of rgba16float targets, its own blend and
19
+ * no MSAA. It is a different pass with a different contract.
20
+ * - the gizmo and selection-edge pipelines draw to the SWAPCHAIN in their own
21
+ * unmultisampled pass, so the presentation format is the whole of their
22
+ * contract. The plan listed gizmo as a scene render class; the code says
23
+ * otherwise, and the code is right.
24
+ */
25
+
26
+ /** The scene pass's attachments, as the engine has them at init. Passed in
27
+ * rather than imported: hdr is chosen per device (rg11b10ufloat where it is
28
+ * available and blendable, rgba16float otherwise). */
29
+ export type SceneFormats = {
30
+ /** The HDR colour attachment, @location(0). */
31
+ hdr: GPUTextureFormat
32
+ /** The aux attachment, @location(1) — (bloom mask, coverage). */
33
+ aux: GPUTextureFormat
34
+ }
35
+
36
+ /**
37
+ * The id attachment's format: (material index, object index), one u16 each.
38
+ *
39
+ * Two 16-bit channels rather than one 32-bit: 32-bit formats are not
40
+ * multisamplable, and this attachment is multisampled with the rest of the
41
+ * pass. Uint targets take no blend at all per spec, which is exactly right —
42
+ * an averaged id is not an id.
43
+ */
44
+ export const SCENE_ID_FORMAT: GPUTextureFormat = "rg16uint"
45
+
46
+ /**
47
+ * Whether the scene pass carries the id attachment.
48
+ *
49
+ * Runtime rather than a compile-time constant, and mutable, because it is not
50
+ * only a decision — it is a CAPABILITY. Multisampled rg16uint has to be probed
51
+ * on the device (see the engine's init), and a device that cannot do it must
52
+ * leave this off. That forces the shaders to be assembled after the probe,
53
+ * which is why the two shader modules that gain an output stopped being
54
+ * module-level constants: a string baked at import cannot know what the device
55
+ * said.
56
+ *
57
+ * Set ONCE at init, before any pipeline or shader module is built. Nothing
58
+ * reads it per frame.
59
+ */
60
+ let mrtIds = false
61
+
62
+ /** Called by the engine at init, after probing the device. */
63
+ export function setMrtIds(on: boolean): void {
64
+ mrtIds = on
65
+ }
66
+
67
+ export function mrtIdsEnabled(): boolean {
68
+ return mrtIds
69
+ }
70
+
71
+ /**
72
+ * What is being drawn, which is the only thing that varies.
73
+ *
74
+ * The classes differ ONLY in blend and write mask; formats are the pass's, not
75
+ * the draw's. A class here is a thing with a reason, not a pipeline name — two
76
+ * pipelines that blend the same way share one.
77
+ */
78
+ type SceneRenderClass =
79
+ /** Models, opaque and transparent. Straight alpha over. */
80
+ | "material"
81
+ /** The shadow-catcher floor. Blends PREMULTIPLIED, not like a material: its
82
+ * coverage is a lit surface plus a colourless shadow layer, so it weights
83
+ * its own colour before the blend sees it. */
84
+ | "ground"
85
+ /** Backface-expanded hulls. Also a material blend — it is geometry. */
86
+ | "outline"
87
+ /** Particles and ribbons in their default, non-additive mode. */
88
+ | "particle"
89
+ /** Particles declaring `// @blend additive` — LIGHT rather than matter, so
90
+ * colour sums and alpha is left alone: a glow must not claim coverage it
91
+ * never occluded. The aux target sums with it, which is what lets an
92
+ * additive effect reach the bloom gate at all. */
93
+ | "particle-additive"
94
+ /** Ribbons. Additive colour like the above, but premultiplied by the
95
+ * fragment's own alpha on the way in (src-alpha, one) rather than added
96
+ * whole, and an ordinary alpha-over aux so a ribbon's mask does not
97
+ * saturate along every overlap. */
98
+ | "trail"
99
+ /** The transparent depth prepass: it exists to write DEPTH after the fabric's
100
+ * colour blended, so an outline drawn later is occluded behind it. It must
101
+ * therefore write no colour at all — the targets exist only to make the
102
+ * pipeline compatible with the pass it joins. */
103
+ | "depth-prepass"
104
+
105
+ const ALPHA_OVER: GPUBlendState = {
106
+ color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" },
107
+ alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
108
+ }
109
+
110
+ /** Colour sums; alpha is not touched (src zero, dst one). */
111
+ const ADD_KEEP_ALPHA: GPUBlendState = {
112
+ color: { srcFactor: "one", dstFactor: "one", operation: "add" },
113
+ alpha: { srcFactor: "zero", dstFactor: "one", operation: "add" },
114
+ }
115
+
116
+ /** Both channels sum. rg8unorm clamps at 1, which is the saturation alpha-over
117
+ * would have reached anyway. */
118
+ const ADD_BOTH: GPUBlendState = {
119
+ color: { srcFactor: "one", dstFactor: "one", operation: "add" },
120
+ alpha: { srcFactor: "one", dstFactor: "one", operation: "add" },
121
+ }
122
+
123
+ /**
124
+ * OVER for something that arrives ALREADY premultiplied: take the source whole
125
+ * and let it displace the destination by its own coverage.
126
+ *
127
+ * The ground needs this and nothing else does. Every other class writes a
128
+ * straight colour and an alpha, and the src-alpha factor premultiplies it once
129
+ * on the way in. The ground cannot: its coverage is the SUM of a lit surface
130
+ * and a colourless shadow-catcher layer, so it has to weight its own colour by
131
+ * the surface's share before it gets here. Handed to the src-alpha blend, that
132
+ * weighting happened a second time.
133
+ */
134
+ const PREMULTIPLIED_OVER: GPUBlendState = {
135
+ color: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
136
+ alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
137
+ }
138
+
139
+ /** Additive, premultiplied by the fragment's alpha as it writes. */
140
+ const ADD_PREMULTIPLIED: GPUBlendState = {
141
+ color: { srcFactor: "src-alpha", dstFactor: "one", operation: "add" },
142
+ alpha: { srcFactor: "zero", dstFactor: "one", operation: "add" },
143
+ }
144
+
145
+ /**
146
+ * Which classes actually WRITE an id, and so gain a fragment output for it.
147
+ *
148
+ * Everything else keeps its shader exactly as it is and takes the id target at
149
+ * writeMask 0 — legal, specified, and free. The alternative (leaving the target
150
+ * off those pipelines) is not available: every pipeline in a pass must agree
151
+ * with the pass's attachments.
152
+ *
153
+ * The ground is in because a mark placed by id needs the floor to have one.
154
+ * Transparent fabric writes ids too — dissolving a dress needs the dress's own
155
+ * pixels, and last write wins there, which is a documented choice rather than a
156
+ * consequence. Outline hulls, particles and ribbons are OUT: they are not
157
+ * things you would ever address by id, and a hull would overwrite the id of the
158
+ * body it traces.
159
+ */
160
+ const WRITES_ID = new Set<SceneRenderClass>(["material", "ground"])
161
+
162
+ /** The blends each class writes its two attachments with. */
163
+ const BLENDS: Record<Exclude<SceneRenderClass, "depth-prepass">, [GPUBlendState, GPUBlendState]> = {
164
+ material: [ALPHA_OVER, ALPHA_OVER],
165
+ // PREMULTIPLIED colour, alone among the classes — see the blend's own note.
166
+ // The aux is ordinary alpha-over: the ground writes its mask unweighted, like
167
+ // everything else, and coverage is what the blend applies.
168
+ ground: [PREMULTIPLIED_OVER, ALPHA_OVER],
169
+ outline: [ALPHA_OVER, ALPHA_OVER],
170
+ particle: [ALPHA_OVER, ALPHA_OVER],
171
+ "particle-additive": [ADD_KEEP_ALPHA, ADD_BOTH],
172
+ trail: [ADD_PREMULTIPLIED, ALPHA_OVER],
173
+ }
174
+
175
+ /**
176
+ * The colour targets a scene-pass pipeline of this class declares, in
177
+ * attachment order.
178
+ *
179
+ * Fresh objects every call, deliberately: a caller that mutated a shared
180
+ * descriptor would change every pipeline built after it, and the ones built
181
+ * before would keep the old value — a difference that only shows up as one
182
+ * pipeline blending unlike its neighbours.
183
+ */
184
+ export function sceneTargets(cls: SceneRenderClass, formats: SceneFormats): GPUColorTargetState[] {
185
+ const targets: GPUColorTargetState[] =
186
+ cls === "depth-prepass"
187
+ ? // Format only, and writeMask 0. Note the asymmetry this leans on, which
188
+ // is the same one the id target leans on below: a target the shader has
189
+ // no output for is legal at writeMask 0 (gpuweb#1918), while an output
190
+ // with no target is NOT governed (gpuweb#5341). This is the specified
191
+ // direction, and it is the only one this file ever uses.
192
+ [
193
+ { format: formats.hdr, writeMask: 0 },
194
+ { format: formats.aux, writeMask: 0 },
195
+ ]
196
+ : (() => {
197
+ const [color, aux] = BLENDS[cls]
198
+ return [
199
+ { format: formats.hdr, blend: { color: { ...color.color }, alpha: { ...color.alpha } } },
200
+ { format: formats.aux, blend: { color: { ...aux.color }, alpha: { ...aux.alpha } } },
201
+ ]
202
+ })()
203
+ if (mrtIds) targets.push({ format: SCENE_ID_FORMAT, writeMask: WRITES_ID.has(cls) ? 0xf : 0 })
204
+ return targets
205
+ }
206
+
207
+ /**
208
+ * The pass's colour attachments, in order — for the things that describe the
209
+ * pass itself rather than a draw within it.
210
+ *
211
+ * A render bundle declares the formats it will be replayed into and is rejected
212
+ * against a pass that does not match, so the bundle encoder has to move in
213
+ * lockstep with the targets above. It restated the list independently until
214
+ * this existed, which made it the one consumer an MRT change would have missed
215
+ * — a bundle is recorded once and replayed, so the failure would have arrived
216
+ * at replay, naming the bundle rather than the attachment that changed.
217
+ */
218
+ export function sceneColorFormats(formats: SceneFormats): GPUTextureFormat[] {
219
+ return mrtIds ? [formats.hdr, formats.aux, SCENE_ID_FORMAT] : [formats.hdr, formats.aux]
220
+ }
221
+
222
+ /**
223
+ * The fragment-output struct a scene-pass shader returns.
224
+ *
225
+ * Emitted rather than written out per file so that the attachment list has one
226
+ * author. The struct and the targets above have to agree on count and order,
227
+ * and they now disagree in one file rather than in five.
228
+ *
229
+ * Only the shaders that will GAIN an output take this today — the materials
230
+ * (hand-written and graph-generated alike, through COMMON_FS_OUT_WGSL) and the
231
+ * ground. Outline, particles and ribbons keep their own declarations on
232
+ * purpose: they never write the id, so they would take an `id: false` argument
233
+ * forever, and their structs carry comments about their own blend that belong
234
+ * where they are.
235
+ */
236
+ export function sceneFsOutWgsl(opts?: { name?: string; aux?: string }): string {
237
+ const name = opts?.name ?? "FSOut"
238
+ const aux = opts?.aux ?? "mask"
239
+ // No @interpolate here, deliberately. The plan called for
240
+ // `@location(2) @interpolate(flat)`, and that attribute is only legal on a
241
+ // vertex OUTPUT or a fragment INPUT — a fragment output is neither, so it
242
+ // would not compile. It is also unnecessary: the id is read from the per-draw
243
+ // uniform, not carried across the triangle as a varying, so there is no
244
+ // interpolation to suppress. (A varying carrying it WOULD need flat, since an
245
+ // integer varying must be.)
246
+ //
247
+ // vec2u for rg16uint: the output type must be compatible with the format, and
248
+ // uint targets take no blend, which is what makes last-write-wins the rule.
249
+ const id = mrtIds ? ` @location(2) id: vec2u,\n` : ""
250
+ return `struct ${name} {
251
+ @location(0) color: vec4f,
252
+ @location(1) ${aux}: vec4f,
253
+ ${id}};
254
+ `
255
+ }
256
+
257
+ /**
258
+ * The line a fragment shader assigns its id with, or nothing when ids are off.
259
+ *
260
+ * Emitted rather than written into each shader for the same reason as the
261
+ * struct: with ids off there must be no assignment either, and a shader cannot
262
+ * ask the device what it supports.
263
+ */
264
+ export function sceneIdWriteWgsl(out: string, material: string, object: string): string {
265
+ return mrtIds ? ` ${out}.id = vec2u(${material}, ${object});\n` : ""
266
+ }