reze-engine 0.57.6 → 0.58.0

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.
@@ -1,4 +1,5 @@
1
1
  import { castDistanceStub } from "./cast-distance"
2
+ import { gridReadApi } from "./grid"
2
3
  import { RZ_LIGHT_STRUCT_WGSL } from "../lights"
3
4
  import { audioApi } from "../audio-api"
4
5
  import { lyricsApi } from "../lyrics-api"
@@ -75,7 +76,9 @@ fn _rzCastLive() -> i32 {
75
76
  }
76
77
 
77
78
  /** How the author's quads combine with the scene. */
78
- type ParticleBlend = "alpha" | "additive"
79
+ /** `cutout` shades exactly like `alpha`; the difference is entirely in the
80
+ * pipeline — depth write on, alpha-to-coverage on. See directives.ts. */
81
+ type ParticleBlend = "alpha" | "additive" | "cutout"
79
82
 
80
83
  type ParticleSource = {
81
84
  /** The author's WGSL verbatim. */
@@ -89,6 +92,18 @@ type ParticleSource = {
89
92
  * rule reads the same dial the shading does, and rain's fall speed is used
90
93
  * in the compute half. */
91
94
  paramsDecl: string
95
+ /** The effect defines `particleCover(p, uv) -> f32`: the shape alone, cheap.
96
+ * A cutout then draws TWICE — coverage to depth first, then the shading at
97
+ * depthCompare equal — so the lighting runs only for the visible layer. A
98
+ * discard forbids the hardware's own early rejection on a tile-based GPU,
99
+ * where every stacked fragment is otherwise shaded in full; the prepass is
100
+ * the shape of that rejection, done by hand and cheaply. */
101
+ cover: boolean
102
+ /** The side of this effect's grid, or 0 when it declared none. Particles READ
103
+ * the grid; the kernel that steps it lives in the grid pass. A blade of grass
104
+ * bending where a foot pressed is the whole case: the bend has to persist and
105
+ * recover, which is memory, and memory is what the grid is for. */
106
+ gridSize: number
92
107
  }
93
108
 
94
109
  /** Bytes per particle. Explicitly padded — see the struct below. */
@@ -148,12 +163,14 @@ fn rzProject(p: vec3f) -> vec3f {
148
163
  fn rzCamPos() -> vec3f { return cam.camPos; }
149
164
  `
150
165
 
151
- /** Does the source define the particle contract? All three are required together. */
152
- export function particleEntryPoints(wgsl: string): { init: boolean; step: boolean; shade: boolean } {
166
+ /** Does the source define the particle contract? All three are required
167
+ * together; `cover` is optional and only means anything to a cutout. */
168
+ export function particleEntryPoints(wgsl: string): { init: boolean; step: boolean; shade: boolean; cover: boolean } {
153
169
  return {
154
170
  init: /\bfn\s+particleInit\s*\(/.test(wgsl),
155
171
  step: /\bfn\s+particleStep\s*\(/.test(wgsl),
156
172
  shade: /\bfn\s+particleShade\s*\(/.test(wgsl),
173
+ cover: /\bfn\s+particleCover\s*\(/.test(wgsl),
157
174
  }
158
175
  }
159
176
 
@@ -182,6 +199,11 @@ ${src.paramsDecl}
182
199
  audioApi(0, 4) +
183
200
  midiApi(0, 5) +
184
201
  lyricsApi(0, 6) +
202
+ // Always, even with no grid — then it samples a 1x1 of zeroes. Same reason
203
+ // composite compiles it unconditionally, plus one this module owns: an
204
+ // effect's whole file is spliced here, so a `gridStep` written for the grid
205
+ // pass has to RESOLVE here too, and the stubs that let it are in here.
206
+ gridReadApi(0, 8, 9, src.gridSize) +
185
207
  PRELUDE +
186
208
  // Stubbed: this module cannot read an attachment the scene pass writes — see
187
209
  // id-api.ts. The author's whole file compiles here, so the names must exist.
@@ -230,7 +252,13 @@ fn main(@builtin(global_invocation_id) gid: vec3u) {
230
252
  export function buildParticleRenderShader(src: ParticleSource, cast: CastLayout): string {
231
253
  return (
232
254
  `override BLOOM: bool = ${src.bloom ? "true" : "false"};
233
- override ADDITIVE: bool = ${src.blend === "additive" ? "true" : "false"};\n` +
255
+ override ADDITIVE: bool = ${src.blend === "additive" ? "true" : "false"};
256
+ override CUTOUT: bool = ${src.blend === "cutout" ? "true" : "false"};
257
+ // A cutout with a prepass: the depth is already written and this pass draws at
258
+ // depthCompare equal, so it neither discards nor masks — the depth test IS the
259
+ // coverage, per sample, and a shader with no discard keeps the hardware's
260
+ // early rejection.
261
+ override PREPASSED: bool = ${src.blend === "cutout" && src.cover ? "true" : "false"};\n` +
234
262
  PARTICLE_STRUCT_WGSL +
235
263
  CAMERA_STRUCT +
236
264
  PARTICLE_UNIFORMS +
@@ -245,6 +273,11 @@ ${src.paramsDecl}
245
273
  audioApi(0, 4) +
246
274
  midiApi(0, 5) +
247
275
  lyricsApi(0, 6) +
276
+ // Always, even with no grid — then it samples a 1x1 of zeroes. Same reason
277
+ // composite compiles it unconditionally, plus one this module owns: an
278
+ // effect's whole file is spliced here, so a `gridStep` written for the grid
279
+ // pass has to RESOLVE here too, and the stubs that let it are in here.
280
+ gridReadApi(0, 8, 9, src.gridSize) +
248
281
  PRELUDE +
249
282
  // Stubbed: this module cannot read an attachment the scene pass writes — see
250
283
  // id-api.ts. The author's whole file compiles here, so the names must exist.
@@ -301,6 +334,12 @@ fn vs(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VSOut
301
334
 
302
335
  struct FSOut {
303
336
  @location(0) color: vec4f,
337
+ // Which of the pixel's MSAA samples this fragment covers. Alpha-to-coverage
338
+ // by hand: the hardware kind reads alpha off the first colour target, and
339
+ // the HDR target is rg11b10, which has no alpha to read. Every particle
340
+ // writes it; a blended one writes all four, which is what not declaring it
341
+ // would have meant.
342
+ @builtin(sample_mask) samples: u32,
304
343
  // The scene's aux target: (bloom mask, coverage). Materials write it, so a
305
344
  // particle that skipped it would punch a hole in the mask of whatever it drew
306
345
  // over.
@@ -328,8 +367,25 @@ fn fs(in: VSOut) -> FSOut {
328
367
  c.a *= pu.weight;
329
368
  // Weight 0 therefore discards every fragment, so an effect faded out costs
330
369
  // nothing past the vertex stage even on the frame the draw is still issued.
331
- if (c.a <= 0.0) { discard; }
370
+ // Not after a prepass: that pass already decided coverage into depth, and
371
+ // a discard here would cost this pass the early rejection it exists for.
372
+ if (!PREPASSED && c.a <= 0.0) { discard; }
332
373
  var out: FSOut;
374
+ out.samples = 0xFu;
375
+ if (PREPASSED) {
376
+ c.a = 1.0;
377
+ } else if (CUTOUT) {
378
+ // Alpha becomes coverage: 0..4 of the pixel's samples, and the fragment is
379
+ // then opaque wherever it lands. The pattern is turned by pixel position
380
+ // so a half-covered edge is not the same two samples on every pixel along
381
+ // it, which is what makes a2c edges look dithered rather than stepped.
382
+ let n = u32(clamp(c.a, 0.0, 1.0) * 4.0 + 0.5);
383
+ if (n == 0u) { discard; }
384
+ let low = (1u << n) - 1u;
385
+ let turn = (u32(in.clip.x) + u32(in.clip.y)) & 3u;
386
+ out.samples = ((low << turn) | (low >> (4u - turn))) & 0xFu;
387
+ c.a = 1.0;
388
+ }
333
389
  // PREMULTIPLIED: the scene's colour target blends with srcFactor \"one\", so a
334
390
  // straight-alpha fragment would come out over-bright wherever it is
335
391
  // translucent — which is most of a soft particle.
@@ -350,6 +406,30 @@ fn fs(in: VSOut) -> FSOut {
350
406
  out.mask = vec4f(mg.x, mg.y, 0.0, c.a);
351
407
  ${sceneIdPadWgsl("out")} return out;
352
408
  }
409
+ ` +
410
+ (src.blend === "cutout" && src.cover
411
+ ? /* wgsl */ `
412
+ /**
413
+ * The prepass: the shape to depth, nothing to colour. Every output is still
414
+ * declared — the pipeline takes the colour targets at writeMask 0 — and the
415
+ * sample mask is exactly what the single-pass cutout would have written, so
416
+ * the shading pass's equal test lands on the same samples.
417
+ */
418
+ @fragment
419
+ fn fsDepth(in: VSOut) -> FSOut {
420
+ let p = particles[in.id];
421
+ let a = particleCover(p, in.uv) * pu.weight;
422
+ let n = u32(clamp(a, 0.0, 1.0) * 4.0 + 0.5);
423
+ if (n == 0u) { discard; }
424
+ let low = (1u << n) - 1u;
425
+ let turn = (u32(in.clip.x) + u32(in.clip.y)) & 3u;
426
+ var out: FSOut;
427
+ out.samples = ((low << turn) | (low >> (4u - turn))) & 0xFu;
428
+ out.color = vec4f(0.0);
429
+ out.mask = vec4f(0.0);
430
+ ${sceneIdPadWgsl("out")} return out;
431
+ }
353
432
  `
433
+ : "")
354
434
  )
355
435
  }
@@ -1,4 +1,5 @@
1
1
  import { castDistanceStub } from "./cast-distance"
2
+ import { gridReadApi } from "./grid"
2
3
  import { RZ_LIGHT_STRUCT_WGSL } from "../lights"
3
4
  import { audioApi } from "../audio-api"
4
5
  import { lyricsApi } from "../lyrics-api"
@@ -39,6 +40,9 @@ type TrailSource = {
39
40
  /** Additive, like most glowing ribbons, or straight alpha. */
40
41
  blend: "alpha" | "additive"
41
42
  bloom: boolean
43
+ /** The side of this effect's grid, or 0 when it declared none. See the note
44
+ * on the same field in particles.ts: a ribbon reads the grid, never steps it. */
45
+ gridSize: number
42
46
  }
43
47
 
44
48
  /**
@@ -291,6 +295,10 @@ fn rzTurnRadius(a: vec3f, b: vec3f, c: vec3f) -> f32 {
291
295
  audioApi(0, 4) +
292
296
  midiApi(0, 5) +
293
297
  lyricsApi(0, 6) +
298
+ // Always, with or without a grid — and required either way, because an
299
+ // effect's whole file is spliced here and a `gridStep` written for the grid
300
+ // pass has to resolve in this module too.
301
+ gridReadApi(0, 8, 9, src.gridSize) +
294
302
  // Stubbed: this module cannot read an attachment the scene pass writes — see
295
303
  // id-api.ts. The author's whole file compiles here, so the names must exist.
296
304
  idApi(false, 0, 0) + castDistanceStub() +