reze-engine 0.50.2 → 0.50.4

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 (42) hide show
  1. package/dist/engine.d.ts +294 -7
  2. package/dist/engine.d.ts.map +1 -1
  3. package/dist/engine.js +900 -109
  4. package/dist/shaders/cast-api.d.ts +1 -1
  5. package/dist/shaders/cast-api.d.ts.map +1 -1
  6. package/dist/shaders/cast-layout.d.ts +44 -1
  7. package/dist/shaders/cast-layout.d.ts.map +1 -1
  8. package/dist/shaders/cast-layout.js +44 -1
  9. package/dist/shaders/materials/common.d.ts.map +1 -1
  10. package/dist/shaders/materials/common.js +7 -1
  11. package/dist/shaders/materials/nodes.d.ts +1 -1
  12. package/dist/shaders/materials/nodes.d.ts.map +1 -1
  13. package/dist/shaders/materials/nodes.js +17 -9
  14. package/dist/shaders/passes/composite.d.ts +1 -1
  15. package/dist/shaders/passes/composite.d.ts.map +1 -1
  16. package/dist/shaders/passes/depth-prepass.d.ts +1 -1
  17. package/dist/shaders/passes/depth-prepass.d.ts.map +1 -1
  18. package/dist/shaders/passes/depth-prepass.js +52 -12
  19. package/dist/shaders/passes/ground.d.ts +16 -0
  20. package/dist/shaders/passes/ground.d.ts.map +1 -1
  21. package/dist/shaders/passes/ground.js +143 -49
  22. package/dist/shaders/passes/outline.d.ts +1 -1
  23. package/dist/shaders/passes/outline.d.ts.map +1 -1
  24. package/dist/shaders/passes/outline.js +12 -3
  25. package/dist/shaders/passes/particles.d.ts.map +1 -1
  26. package/dist/shaders/passes/particles.js +6 -2
  27. package/dist/shaders/passes/scene-contract.d.ts +38 -6
  28. package/dist/shaders/passes/scene-contract.d.ts.map +1 -1
  29. package/dist/shaders/passes/scene-contract.js +53 -16
  30. package/dist/shaders/passes/trails.d.ts.map +1 -1
  31. package/dist/shaders/passes/trails.js +36 -8
  32. package/package.json +2 -2
  33. package/src/engine.ts +952 -100
  34. package/src/shaders/cast-layout.ts +44 -1
  35. package/src/shaders/materials/common.ts +7 -1
  36. package/src/shaders/materials/nodes.ts +17 -9
  37. package/src/shaders/passes/depth-prepass.ts +53 -12
  38. package/src/shaders/passes/ground.ts +145 -49
  39. package/src/shaders/passes/outline.ts +14 -3
  40. package/src/shaders/passes/particles.ts +6 -2
  41. package/src/shaders/passes/scene-contract.ts +55 -16
  42. package/src/shaders/passes/trails.ts +36 -8
@@ -14,7 +14,50 @@
14
14
  * the whole engine failing to start on an import order nobody chose.
15
15
  */
16
16
  export const EFFECT_SUBJECTS = 4
17
- export const EFFECT_ANCHORS = 8
17
+ /**
18
+ * Bone anchors, for the WHOLE SCENE rather than per effect — this is an address
19
+ * space that every installed effect draws slots from, and an effect asking for
20
+ * one the table has already given away is told so and has it dropped.
21
+ *
22
+ * 8 was reachable, and quietly: two effects each wanting two hands, two feet and
23
+ * a head is ten, so the second one silently lost its ribbons to a diagnostic
24
+ * nobody was reading. 16 is double the headroom for 131KB of storage buffer,
25
+ * where 8 cost 66KB.
26
+ *
27
+ * The cost really is only that. The per-frame upload is bounded by the last
28
+ * TRAILED slot, not by this cap (see the writeBuffer in updateCastBuffer), so a
29
+ * scene using three anchors uploads three anchors' worth whatever this says.
30
+ * The CPU-side path rings are keyed by (model, slot) and allocated on use. And
31
+ * effects never index this directly — they loop to rzTrailCount / rzSubjectCount
32
+ * and read through rzAnchor/rzTrail, both of which bounds-check against it.
33
+ *
34
+ * It stays a compile-time constant because the accessors in cast-api.ts are
35
+ * interpolated into every effect module as WGSL literals; making it dynamic
36
+ * means resizing the buffer and recompiling every installed effect whenever the
37
+ * scene's anchor count grows, which is a different feature from raising a number
38
+ * that was never load-bearing.
39
+ */
40
+ export const EFFECT_ANCHORS = 16
41
+ /**
42
+ * Path samples kept per trailed anchor — 128 at the 60Hz sampling rate is a
43
+ * ~2.1 second ribbon.
44
+ *
45
+ * Briefly 256, and reverted with the reason, because the reason is the useful
46
+ * part: a ribbon's cost is not its geometry, it is its FRAGMENTS. It is a wide
47
+ * translucent strip blended additively into the HDR target at the pass's sample
48
+ * count, and it overlaps itself — so its cost tracks the screen AREA it covers,
49
+ * and a twice-as-long ribbon covers roughly twice as much.
50
+ *
51
+ * That lands very differently on the two backends. Overdraw and blend bandwidth
52
+ * are what a tile-based GPU pays for most and what an immediate-mode desktop GPU
53
+ * absorbs, which is why ribbons were reported as costing far more on Safari than
54
+ * on Chrome for the same scene. Doubling this doubles the one thing already
55
+ * known to be the bottleneck there.
56
+ *
57
+ * Raising it is still SAFE — effects loop to rzTrailCount and nothing breaks —
58
+ * it is simply not cheap, and the cost shows up on the slower of the two
59
+ * browsers rather than the one it would be measured on.
60
+ */
18
61
  export const EFFECT_TRAIL_SAMPLES = 128
19
62
  /** vec4 slot where the trails begin — after the subjects and the anchors. */
20
63
  export const EFFECT_TRAIL_BASE = EFFECT_SUBJECTS * 3 + EFFECT_ANCHORS * EFFECT_SUBJECTS * 3
@@ -73,7 +73,13 @@ struct MaterialUniforms {
73
73
  };
74
74
 
75
75
  struct VertexOutput {
76
- @builtin(position) position: vec4f,
76
+ // @invariant: the opaque depth prepass rasterises this same skinned position
77
+ // through a DIFFERENT shader module, and the colour pass then depth-tests
78
+ // less-equal against what it wrote. Without invariance a backend is free to
79
+ // optimise the two position computations differently, and a one-ulp
80
+ // disagreement is a pixel of missing character. Invariance pins both to the
81
+ // same result; it costs only that freedom.
82
+ @builtin(position) @invariant position: vec4f,
77
83
  @location(0) normal: vec3f,
78
84
  @location(1) uv: vec2f,
79
85
  @location(2) worldPos: vec3f,
@@ -216,18 +216,26 @@ fn curve5(t0: f32, y0: f32, y1: f32, y2: f32, y3: f32, y4: f32) -> f32 {
216
216
  let t = clamp(t0, 0.0, 1.0) * 4.0;
217
217
  let i = min(floor(t), 3.0);
218
218
  let f = t - i;
219
- // A var, not a let: WGSL only allows a dynamic index on a REFERENCE, and a
220
- // let-bound array is a value. Indexing it with a runtime k is a compile error —
221
- // and because this file is concatenated into every material shader, that error
222
- // took every graph in the library down with it, not just curves.
223
- var ys = array<f32, 5>(y0, y1, y2, y3, y4);
219
+ // NO local array, deliberately. This used to build array<f32, 5> and index it
220
+ // with the runtime k and a dynamic index on function-local memory is the one
221
+ // construct this codebase has already caught Metal lowering to a
222
+ // per-invocation copy plus a switch (the filmic LUT note in composite.ts).
223
+ // rgb_curve calls this three times per node per pixel, so on a graph-heavy
224
+ // close-up that lowering was paid in the hottest loop the frame has. Four
225
+ // segments select cleanly: the values below are the SAME subtractions the
226
+ // array indexing produced, chosen by k instead of loaded through it —
227
+ // bit-identical results, no local memory, nothing for the backend to spill.
224
228
  let k = i32(i);
225
- let pa = ys[k];
226
- let pb = ys[k + 1];
229
+ let s0 = y1 - y0;
230
+ let s1 = y2 - y1;
231
+ let s2 = y3 - y2;
232
+ let s3 = y4 - y3;
233
+ let pa = select(select(y0, y1, k == 1), select(y2, y3, k == 3), k >= 2);
234
+ let pb = select(select(y1, y2, k == 1), select(y3, y4, k == 3), k >= 2);
227
235
  // Secants either side of each knot, clamped at the ends.
228
- let dPrev = select(ys[max(k, 1)] - ys[max(k, 1) - 1], pb - pa, k == 0);
229
236
  let dHere = pb - pa;
230
- let dNext = select(ys[min(k + 2, 4)] - ys[min(k + 1, 3)], pb - pa, k == 3);
237
+ let dPrev = select(s0, select(s1, s2, k == 3), k >= 2);
238
+ let dNext = select(select(s1, s2, k == 1), s3, k >= 2);
231
239
  let m0 = curve_slope(dPrev, dHere);
232
240
  let m1 = curve_slope(dHere, dNext);
233
241
  let f2 = f * f;
@@ -1,17 +1,36 @@
1
- // Depth-only prepass for the TRANSPARENT bucket. Transparent color draws keep
2
- // depth write OFF so self-overlapping sheer cloth blends both layers instead of
3
- // a triangle-order patchworkbut that leaves no depth record, so anything
4
- // drawn later (the outline hulls) shows straight through the fabric as black
5
- // shapes. This pass re-draws the transparent geometry depth-only AFTER its
6
- // color pass: solid-enough texels (alpha 0.5) write depth, so outlines get
7
- // occluded behind fabric exactly like they are behind opaque cloth, while
8
- // truly sheer texels (a veil) stay non-occluding.
1
+ // The depth-only prime, one module behind four pipelines.
2
+ //
3
+ // The oldest fps complaint the engine had zoom close and the frame drops, in
4
+ // every material generation was per-fragment shading TIMES layering: an MMD
5
+ // model at close-up is cloth over body over face under hair, author-order drawn,
6
+ // every buried layer fully shaded and then covered. This module lets depth go
7
+ // down FIRST so the colour passes shade each pixel once:
8
+ //
9
+ // · opaque prepass (CUTOFF 0.5) — plain auto-class opaque draws, before the
10
+ // opaque colour walk. See drawOpaqueDepthPrepass for who is in and why.
11
+ // · hair prime (CUTOFF 1.0, stencil not-equal) — between the non-hair and
12
+ // hair colour walks, fenced off the eye silhouette the see-through-hair
13
+ // stencil pass needs. See drawHairDepthPrime.
14
+ // · transparent solid prime (CUTOFF 1.0) — a translucent material's alpha-1
15
+ // texels, where over-blending is plain replacement and the buried work
16
+ // provably never shows. See drawTransparentSolidPrepass.
17
+ // · transparent depth prepass (CUTOFF 0.5, AFTER colour) — the original
18
+ // occupant, dormant: re-records sheer fabric's depth so outline hulls are
19
+ // occluded behind it. Kept for a future OIT path.
9
20
  //
10
21
  // Reuses mainPipelineLayout: camera g0b0, diffuseSampler g0b2, skinMats g1b0,
11
22
  // diffuse texture g2b0, material uniforms g2b1 — the same bind groups the
12
23
  // color draws already set, so drawing it costs no extra binding work.
24
+ //
25
+ // A FUNCTION rather than the constant it was, for the reason commonFsOutWgsl is
26
+ // one: the fragment outputs below depend on whether the device carries the id
27
+ // attachment, and that answer does not exist at import time. The constant could
28
+ // not have taken the outputs at all, which is most of why it did not have them.
13
29
 
14
- export const TRANSPARENT_DEPTH_PREPASS_WGSL = /* wgsl */ `
30
+ import { sceneFsOutWgsl, sceneIdPadWgsl } from "./scene-contract"
31
+
32
+ export function transparentDepthPrepassWgsl(): string {
33
+ return /* wgsl */ `
15
34
  struct CameraUniforms { view: mat4x4f, projection: mat4x4f, viewPos: vec3f, _p: f32, };
16
35
  struct MaterialUniforms {
17
36
  diffuseColor: vec3f,
@@ -25,7 +44,9 @@ struct MaterialUniforms {
25
44
  @group(2) @binding(1) var<uniform> material: MaterialUniforms;
26
45
 
27
46
  struct VSOut {
28
- @builtin(position) position: vec4f,
47
+ // @invariant, to the same end as the material VertexOutput: the colour pass
48
+ // must land on exactly the depths this wrote.
49
+ @builtin(position) @invariant position: vec4f,
29
50
  @location(0) uv: vec2f,
30
51
  };
31
52
 
@@ -50,8 +71,28 @@ struct VSOut {
50
71
  return o;
51
72
  }
52
73
 
53
- @fragment fn fs(in: VSOut) {
74
+ // Every attachment the pass carries, declared and then not written: the
75
+ // pipeline takes all of them at writeMask 0 (see sceneTargets), so what this
76
+ // returns is discarded by the hardware and only the depth write survives — which
77
+ // is the entire purpose of the pass. Declaring them anyway is what keeps the
78
+ // pipeline valid on a browser that requires an output per target rather than
79
+ // per WRITTEN target. Costs one dead struct store on a fragment that already
80
+ // runs, because the alpha test below needs it to.
81
+ ${sceneFsOutWgsl({ name: "PrepassOut", aux: "mask" })}
82
+ // The cutout threshold, per pipeline. 0.5 is the OPAQUE prime's "solid enough"
83
+ // — safe there because opaque colour replaces rather than blends. The
84
+ // TRANSPARENT prime overrides it to 1.0: a translucent fragment's blend reads
85
+ // what is behind it, so only a texel at EXACTLY alpha 1 — where over-blending
86
+ // collapses to plain replacement and the destination stops mattering — may
87
+ // claim depth ahead of its buried layers without changing the pixel.
88
+ override CUTOFF: f32 = 0.5;
89
+ @fragment fn fs(in: VSOut) -> PrepassOut {
54
90
  let a = material.alpha * textureSample(diffuseTexture, diffuseSampler, in.uv).a;
55
- if (a < 0.5) { discard; }
91
+ if (a < CUTOFF) { discard; }
92
+ var out: PrepassOut;
93
+ out.color = vec4f(0.0);
94
+ out.mask = vec4f(0.0);
95
+ ${sceneIdPadWgsl("out")} return out;
56
96
  }
57
97
  `
98
+ }
@@ -20,7 +20,13 @@ struct GroundShadowMat {
20
20
  fadeEnd: f32, shadowStrength: f32, pcfTexel: f32, gridSpacing: f32,
21
21
  gridLineWidth: f32, gridLineOpacity: f32, noiseStrength: f32, opacity: f32,
22
22
  gridLineColor: vec3f, mirror: f32,
23
- mirrorBlur: f32, _mb0: f32, _mb1: f32, _mb2: f32,
23
+ // farCascade: 1 while a stage is loaded, 0 otherwise. See the branch below —
24
+ // with no stage the far map is never drawn into, so its taps are known.
25
+ mirrorBlur: f32, farCascade: f32, _mb1: f32, _mb2: f32,
26
+ // Every shadow caster in one sphere, refreshed per frame. w = radius; 0 means
27
+ // nothing casts, negative means "do not use this" (a rigid caster has no
28
+ // sphere, so a scene with a stage keeps the taps). See rzShadowPossible.
29
+ casterSphere: vec4f,
24
30
  };
25
31
  // One view-projection per shadow cascade, inner to outer — same buffer and
26
32
  // same order the materials read.
@@ -42,33 +48,12 @@ struct MirrorVP { viewProj: mat4x4f, params: vec4f, };
42
48
  @group(0) @binding(9) var mirrorTex: texture_2d<f32>;
43
49
  @group(0) @binding(10) var linearSampler: sampler;
44
50
  @group(0) @binding(11) var mirrorDepth: texture_depth_multisampled_2d;
51
+ // The frost noise, PRE-BAKED — see GROUND_NOISE_BAKE_WGSL below. Sampled with
52
+ // the repeat sampler already at binding 10.
53
+ @group(0) @binding(12) var noiseTex: texture_2d<f32>;
45
54
  ${WORLD_AMBIENT_WGSL}
46
55
  ${lightsApi(0, 6)}
47
56
 
48
- fn hash2(p: vec2f) -> f32 {
49
- var p3 = fract(vec3f(p.x, p.y, p.x) * 0.1031);
50
- p3 += dot(p3, vec3f(p3.y + 33.33, p3.z + 33.33, p3.x + 33.33));
51
- return fract((p3.x + p3.y) * p3.z);
52
- }
53
- fn valueNoise(p: vec2f) -> f32 {
54
- let i = floor(p);
55
- let f = fract(p);
56
- let u = f * f * (3.0 - 2.0 * f);
57
- return mix(mix(hash2(i), hash2(i + vec2f(1.0, 0.0)), u.x),
58
- mix(hash2(i + vec2f(0.0, 1.0)), hash2(i + vec2f(1.0, 1.0)), u.x), u.y);
59
- }
60
- fn fbmNoise(p: vec2f) -> f32 {
61
- var v = 0.0;
62
- var a = 0.5;
63
- var pp = p;
64
- for (var i = 0; i < 4; i++) {
65
- v += a * valueNoise(pp);
66
- pp *= 2.0;
67
- a *= 0.5;
68
- }
69
- return v;
70
- }
71
-
72
57
  struct VO { @builtin(position) position: vec4f, @location(0) worldPos: vec3f, @location(1) normal: vec3f, };
73
58
  @vertex fn vs(@location(0) position: vec3f, @location(1) normal: vec3f, @location(2) uv: vec2f) -> VO {
74
59
  var o: VO; o.worldPos = position; o.normal = normal;
@@ -121,8 +106,63 @@ ${sceneFsOutWgsl()}@fragment fn fs(i: VO) -> FSOut {
121
106
  // The far cascade's taps run ONLY where the near one is fading or absent
122
107
  // (frustum < 1), so a pixel in the near core costs exactly what it did with
123
108
  // one cascade — and that core is where the camera usually looks.
109
+ // Can anything cast a shadow ONTO this point at all?
110
+ //
111
+ // The ground is vastly larger than what stands on it, and the answer for most
112
+ // of it is no. A point is shadowed only if the ray from it toward the light
113
+ // meets a caster, so testing that ray against one bounding sphere decides in a
114
+ // few ALU ops what nine hardware-bilinear depth comparisons would otherwise be
115
+ // spent discovering. Same pixels: outside the sphere vis stays 1.0, which is
116
+ // exactly what the taps return.
117
+ //
118
+ // Conservative in all three directions that matter. The sphere is a union of
119
+ // per-model bounds built from POSED bones plus the skin margin, so a jump or a
120
+ // flying skirt is inside it. A negative radius disables the test outright. And
121
+ // the halfspace check uses -R rather than 0, so a caster the point is standing
122
+ // inside still counts.
123
+ var shadowPossible = material.casterSphere.w > 0.0;
124
+ if (shadowPossible) {
125
+ let toLight = normalize(-light.lights[0].direction.xyz);
126
+ let toCaster = material.casterSphere.xyz - i.worldPos;
127
+ let along = dot(toCaster, toLight);
128
+ let perp = length(toCaster - toLight * along);
129
+ shadowPossible = along > -material.casterSphere.w && perp <= material.casterSphere.w;
130
+ } else {
131
+ // Negative radius is the opt-out, not "nothing casts".
132
+ shadowPossible = material.casterSphere.w < 0.0;
133
+ }
134
+
124
135
  var vis = 1.0;
125
- if (frustum < 1.0 && frustum1 > 0.0) {
136
+ // The whole cascade lookup, behind the strength dial.
137
+ //
138
+ // This is a FULL-COVERAGE draw and each branch below is nine comparison-sampler
139
+ // taps per pixel — eighteen where the cascades overlap — so on a retina canvas
140
+ // it is tens of millions of shadow-map fetches a frame. All of it was computed
141
+ // and then multiplied by material.shadowStrength, which is ZERO whenever the
142
+ // host turns the ground's shadow catcher off. Turning shadows off in the UI
143
+ // therefore cost exactly what leaving them on cost, which is what a report of
144
+ // "no fps gain from disabling shadows" looks like from the outside.
145
+ //
146
+ // shadowStrength is a uniform, so this branch is uniform across the draw:
147
+ // every invocation takes the same side and the taps are genuinely skipped
148
+ // rather than merely masked. The dark term below is (1.0 - vis) * strength, so
149
+ // with strength at zero the result is identical either way — a pure
150
+ // deletion of work, not a change of look.
151
+ //
152
+ // The same reasoning the noise tint below already got, applied to the term
153
+ // that costs a hundred times more.
154
+ if (material.shadowStrength > 0.0 && shadowPossible) {
155
+ // The far cascade's taps, skipped entirely when nothing ever drew into it.
156
+ //
157
+ // This branch is the expensive one on a wide floor: it runs wherever the NEAR
158
+ // cascade does not reach, and the near cascade is a 64-unit box around the
159
+ // camera target, so a floor receding to the horizon takes it almost
160
+ // everywhere. Nine comparison taps, per pixel, on the largest draw in the
161
+ // frame — against a map that is cleared unless a stage is in the scene.
162
+ //
163
+ // Cleared depth compares as "no occluder", so the skipped path leaves vis at
164
+ // 1.0, which is exactly what the taps returned. Free, not cheaper.
165
+ if (material.farCascade > 0.0 && frustum < 1.0 && frustum1 > 0.0) {
126
166
  let suv1 = vec2f(ndc1.x * 0.5 + 0.5, 0.5 - ndc1.y * 0.5);
127
167
  let suv1_c = clamp(suv1, vec2f(0.02), vec2f(0.98));
128
168
  let st1 = ${1 / SHADOW_CASCADES[SHADOW_CASCADES.length - 1].mapSize} * 2.0;
@@ -152,12 +192,27 @@ ${sceneFsOutWgsl()}@fragment fn fs(i: VO) -> FSOut {
152
192
  // cascade to cascade rather than snapping to lit mid-floor.
153
193
  vis = mix(vis, acc * (1.0 / 9.0), frustum);
154
194
  }
195
+ }
155
196
 
156
197
  // Frosted/matte micro-texture. Scenes leaving it at zero were still paying
157
198
  // sixteen hash rounds a pixel for a tint of exactly 1.
158
199
  var noiseTint = 1.0;
159
200
  if (material.noiseStrength != 0.0) {
160
- let noiseVal = fbmNoise(i.worldPos.xz * 3.0);
201
+ // One texture sample where four octaves of value noise used to be
202
+ // EVALUATED — sixteen hash rounds per pixel, on the largest draw in the
203
+ // frame. Bisection on the device that was slow pinned the ground's whole
204
+ // cost here: flat colour was smooth, the shader minus its shadow taps was
205
+ // still slow, and the shader minus only this was smooth. (0.33.2 built
206
+ // this same bake and measured no win — the cost then was the PCF. The
207
+ // instrument was right both times; the term changed.)
208
+ //
209
+ // The tile is the SAME fbm, baked once at init over a 64-p-unit period
210
+ // (~21 world units before it repeats — invisible at frost strengths), and
211
+ // the interior of the tile is bit-identical to the old evaluation because
212
+ // only lattice cells on the seam are wrapped. Sampled at level 0 with the
213
+ // repeat sampler: the old evaluation had no filtering either, so distant
214
+ // sparkle is unchanged rather than quietly "improved".
215
+ let noiseVal = textureSampleLevel(noiseTex, linearSampler, i.worldPos.xz * ${3 / 64}, 0.0).r;
161
216
  noiseTint = 1.0 + (noiseVal - 0.5) * material.noiseStrength;
162
217
  }
163
218
 
@@ -186,8 +241,8 @@ ${sceneFsOutWgsl()}@fragment fn fs(i: VO) -> FSOut {
186
241
  // reflection was rendered by the MIRROR camera, so projecting this
187
242
  // fragment's world position through that camera yields exactly the texel
188
243
  // where its reflection landed — projective mapping, no screen-space guess.
189
- // There is deliberately NO strength dial, and no dependence on the floor's
190
- // opacity either: mirror is on or off, and when it is on it SHOWS — the same
244
+ // There is deliberately NO strength dial: mirror is on or off, and how much
245
+ // of it shows is the SURFACE's own opacity covering it — the same
191
246
  // independence the grid won earlier. The branch is on a uniform, so the
192
247
  // whole cost vanishes for the scenes that leave it off.
193
248
  var reflShadowed = vec3f(0.0);
@@ -250,28 +305,18 @@ ${sceneFsOutWgsl()}@fragment fn fs(i: VO) -> FSOut {
250
305
  // and a catcher stacked on top would darken the same shadow twice.
251
306
  let catchA = dark * 0.65 * edgeFade * (1.0 - material.opacity) * (1.0 - material.mirror);
252
307
 
253
- // FOUR LAYERS, premultiplied, bottom to top: received shadow, ground
254
- // surface, reflection, grid. Each with its own coverage, none scaling
255
- // another.
256
- //
257
- // THE MIRROR SITS ABOVE THE SURFACE, and that ordering is the whole rule.
258
- // Underneath it, the floor's own opacity covered the reflection — and did it
259
- // inconsistently, because BOTH layers carry the radial edgeFade: at opacity
260
- // 1 the surface stopped covering wherever the plane fades out, so the
261
- // reflection returned along the rim and read as a bug that came and went
262
- // with the camera angle. What a mirror shows cannot depend on how solid the
263
- // thing it is made of happens to be.
264
- //
265
- // The catcher's order against it no longer matters — catchA already carries
266
- // (1 - mirror), so the two are never both present.
267
- var pm = vec3f(0.0);
268
- var cov = catchA;
269
- // Surface over the received shadow.
308
+ // FOUR LAYERS, premultiplied, bottom to top: reflection, received shadow,
309
+ // ground surface, grid. Each with its own coverage, none scaling another —
310
+ // ground opacity REVEALS the mirror rather than the mirror carrying a
311
+ // strength of its own.
312
+ var pm = reflShadowed * mirrA;
313
+ var cov = mirrA;
314
+ // Shadow catcher over the reflection-or-nothing: black, it covers.
315
+ pm = pm * (1.0 - catchA);
316
+ cov = catchA + cov * (1.0 - catchA);
317
+ // Surface over shadow.
270
318
  pm = baseColor * surfA + pm * (1.0 - surfA);
271
319
  cov = surfA + cov * (1.0 - surfA);
272
- // The reflection over the surface, at full strength whatever the opacity.
273
- pm = reflShadowed * mirrA + pm * (1.0 - mirrA);
274
- cov = mirrA + cov * (1.0 - mirrA);
275
320
  // Grid over surface. Unshadowed, as it has always been — a painted line reads
276
321
  // as paint, and darkening it was never what the mix did either.
277
322
  pm = material.gridLineColor * gridA + pm * (1.0 - gridA);
@@ -301,3 +346,54 @@ ${sceneIdWriteWgsl("out", `${GROUND_MATERIAL_ID}u`, `${GROUND_OBJECT_ID}u`)} re
301
346
  */
302
347
  export const GROUND_MATERIAL_ID = 0xffff
303
348
  export const GROUND_OBJECT_ID = 0xffff
349
+
350
+ /** Texel edge of the baked frost tile. 1024 over a 64-p-unit period is two
351
+ * texels per finest-octave noise cell — the bake resolves everything the
352
+ * evaluation produced. r8unorm: the value lives in [0,1] and feeds a 0.05
353
+ * tint; eight bits is already 5x below what that tint can show. */
354
+ export const GROUND_NOISE_SIZE = 1024
355
+
356
+ /**
357
+ * The frost fbm as a bake: identical math, periodic lattice.
358
+ *
359
+ * hash2/valueNoise/fbm are the ground's old helpers verbatim, with one change —
360
+ * each octave's lattice wraps at its own period (64 p-units at octave 0,
361
+ * doubling with frequency), so the tile is seamless under the repeat sampler.
362
+ * Wrapping only renames lattice cells on the seam; every interior cell hashes
363
+ * exactly as before, which is what makes the bake a relocation of the old
364
+ * pixels rather than a new look.
365
+ */
366
+ export const GROUND_NOISE_BAKE_WGSL = /* wgsl */ `
367
+ @vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
368
+ let x = f32((vi & 1u) << 2u) - 1.0;
369
+ let y = f32((vi & 2u) << 1u) - 1.0;
370
+ return vec4f(x, y, 0.0, 1.0);
371
+ }
372
+ fn hash2(p: vec2f) -> f32 {
373
+ var p3 = fract(vec3f(p.x, p.y, p.x) * 0.1031);
374
+ p3 += dot(p3, vec3f(p3.y + 33.33, p3.z + 33.33, p3.x + 33.33));
375
+ return fract((p3.x + p3.y) * p3.z);
376
+ }
377
+ fn wrapCell(i: vec2f, period: f32) -> vec2f { return i - period * floor(i / period); }
378
+ fn valueNoiseP(p: vec2f, period: f32) -> f32 {
379
+ let i = floor(p);
380
+ let f = fract(p);
381
+ let u = f * f * (3.0 - 2.0 * f);
382
+ return mix(mix(hash2(wrapCell(i, period)), hash2(wrapCell(i + vec2f(1.0, 0.0), period)), u.x),
383
+ mix(hash2(wrapCell(i + vec2f(0.0, 1.0), period)), hash2(wrapCell(i + vec2f(1.0, 1.0), period)), u.x), u.y);
384
+ }
385
+ @fragment fn fs(@builtin(position) pos: vec4f) -> @location(0) vec4f {
386
+ let p = pos.xy * (64.0 / ${GROUND_NOISE_SIZE}.0);
387
+ var v = 0.0;
388
+ var a = 0.5;
389
+ var pp = p;
390
+ var period = 64.0;
391
+ for (var o = 0; o < 4; o++) {
392
+ v += a * valueNoiseP(pp, period);
393
+ pp *= 2.0;
394
+ period *= 2.0;
395
+ a *= 0.5;
396
+ }
397
+ return vec4f(v, 0.0, 0.0, 1.0);
398
+ }
399
+ `
@@ -12,7 +12,14 @@
12
12
  // margins) — sheer fabric gets a proportional rim, never a solid black
13
13
  // hull, without dropping the author's edge flag.
14
14
 
15
- export const OUTLINE_SHADER_WGSL = /* wgsl */ `
15
+ // A FUNCTION rather than a constant, for the reason commonFsOutWgsl is one: the
16
+ // fragment outputs depend on whether the device carries the id attachment, and
17
+ // a string baked at import cannot know what the device said.
18
+
19
+ import { sceneFsOutWgsl, sceneIdPadWgsl } from "./scene-contract"
20
+
21
+ export function outlineShaderWgsl(): string {
22
+ return /* wgsl */ `
16
23
  struct CameraUniforms {
17
24
  view: mat4x4f,
18
25
  projection: mat4x4f,
@@ -91,7 +98,10 @@ struct VertexOutput {
91
98
  return output;
92
99
  }
93
100
 
94
- struct FSOut { @location(0) color: vec4f, @location(1) mask: vec4f };
101
+ // The id output is declared and padded, never written for real: a hull would
102
+ // overwrite the id of the body it traces, so the pipeline takes the id target at
103
+ // writeMask 0. Declared all the same — see sceneFsOutWgsl.
104
+ ${sceneFsOutWgsl({ name: "FSOut", aux: "mask" })}
95
105
  @fragment fn fs(input: VertexOutput) -> FSOut {
96
106
  // Rim alpha FOLLOWS the fabric's texture alpha instead of a hard alpha test:
97
107
  // MMD draws blend-material edges solid (only cutout materials alpha-test), so
@@ -106,6 +116,7 @@ struct FSOut { @location(0) color: vec4f, @location(1) mask: vec4f };
106
116
  var out: FSOut;
107
117
  out.color = vec4f(material.edgeColor.rgb, material.edgeColor.a * texA);
108
118
  out.mask = vec4f(1.0, 1.0, 0.0, out.color.a);
109
- return out;
119
+ ${sceneIdPadWgsl("out")} return out;
110
120
  }
111
121
  `
122
+ }
@@ -5,6 +5,7 @@ import { anchorAliasWgsl } from "../anchor-table"
5
5
  import { midiApi } from "../midi-api"
6
6
  import { CAST_API } from "../cast-api"
7
7
  import { clockApi, EFFECT_MATH_API, PARTICLE_STRUCT_WGSL, trailSlotsApi, viewportApi } from "./hosted-api"
8
+ import { sceneIdFieldWgsl, sceneIdPadWgsl } from "./scene-contract"
8
9
  // GPU particles for user effects: a compute step and an instanced quad draw.
9
10
  //
10
11
  // Its own shader MODULE rather than more source spliced into composite.ts, for
@@ -307,7 +308,10 @@ struct FSOut {
307
308
  // rejected outright — "reading alpha but it is missing from fragment output".
308
309
  // The material shaders declare vec4f here for the same reason.
309
310
  @location(1) mask: vec4f,
310
- }
311
+ // Declared whenever the pass carries the attachment, and padded rather than
312
+ // written: a particle is not a thing you would address by id, so its pipeline
313
+ // takes this target at writeMask 0. Declared anyway — see sceneFsOutWgsl.
314
+ ${sceneIdFieldWgsl()}}
311
315
 
312
316
  @fragment
313
317
  fn fs(in: VSOut) -> FSOut {
@@ -333,7 +337,7 @@ fn fs(in: VSOut) -> FSOut {
333
337
  // not geometry, was the banding that survived every geometry fix.
334
338
  let mg = select(vec2f(select(0.0, 1.0, BLOOM), 1.0), vec2f(select(0.0, c.a, BLOOM), c.a), ADDITIVE);
335
339
  out.mask = vec4f(mg.x, mg.y, 0.0, c.a);
336
- return out;
340
+ ${sceneIdPadWgsl("out")} return out;
337
341
  }
338
342
  `
339
343
  )
@@ -143,12 +143,14 @@ const ADD_PREMULTIPLIED: GPUBlendState = {
143
143
  }
144
144
 
145
145
  /**
146
- * Which classes actually WRITE an id, and so gain a fragment output for it.
146
+ * Which classes write a MEANINGFUL id.
147
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.
148
+ * Not the same question as which shaders declare the output every scene-pass
149
+ * shader declares all three now (see sceneFsOutWgsl). This set decides only
150
+ * whose id survives: writeMask 0xf here, writeMask 0 for everyone else, so the
151
+ * others compute an id nothing stores. Leaving the target off those pipelines
152
+ * is not available: every pipeline in a pass must agree with the pass's
153
+ * attachments.
152
154
  *
153
155
  * The ground is in because a mark placed by id needs the floor to have one.
154
156
  * Transparent fabric writes ids too — dissolving a dress needs the dress's own
@@ -184,11 +186,10 @@ const BLENDS: Record<Exclude<SceneRenderClass, "depth-prepass">, [GPUBlendState,
184
186
  export function sceneTargets(cls: SceneRenderClass, formats: SceneFormats): GPUColorTargetState[] {
185
187
  const targets: GPUColorTargetState[] =
186
188
  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.
189
+ ? // Format only, and writeMask 0 the pipeline writes no colour. It still
190
+ // DECLARES every output, which is the part that used to be missing; see
191
+ // the note on sceneFsOutWgsl about why writeMask 0 is not a licence to
192
+ // leave the output off.
192
193
  [
193
194
  { format: formats.hdr, writeMask: 0 },
194
195
  { format: formats.aux, writeMask: 0 },
@@ -226,12 +227,19 @@ export function sceneColorFormats(formats: SceneFormats): GPUTextureFormat[] {
226
227
  * author. The struct and the targets above have to agree on count and order,
227
228
  * and they now disagree in one file rather than in five.
228
229
  *
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.
230
+ * EVERY scene-pass shader takes this outline, particles, ribbons and the
231
+ * depth prepass included, none of which write a meaningful id (and the prepass
232
+ * no meaningful colour either). They did not, once. The reasoning was that a
233
+ * target at writeMask 0 needs no matching output: true of Dawn, and the reading
234
+ * of gpuweb#1918 this file used to assert. It is not a reading every browser
235
+ * shares, and the failure mode when a browser disagrees is the worst kind —
236
+ * createRenderPipeline does not throw, it returns a pipeline that is already
237
+ * invalid, and the pass that binds it is dropped whole. Missing geometry, clean
238
+ * console, and a bug that reproduces on one vendor's browser only.
239
+ *
240
+ * So the rule is now the strict one, and it costs nothing to hold: declare
241
+ * every output the pass has attachments for, and let writeMask decide what is
242
+ * kept. A shader with no id to write pads it — see sceneIdPadWgsl.
235
243
  */
236
244
  export function sceneFsOutWgsl(opts?: { name?: string; aux?: string }): string {
237
245
  const name = opts?.name ?? "FSOut"
@@ -264,3 +272,34 @@ ${id}};
264
272
  export function sceneIdWriteWgsl(out: string, material: string, object: string): string {
265
273
  return mrtIds ? ` ${out}.id = vec2u(${material}, ${object});\n` : ""
266
274
  }
275
+
276
+ /**
277
+ * Just the id FIELD, for a shader that keeps its own struct.
278
+ *
279
+ * Particles and ribbons declare their outputs by hand because the comments on
280
+ * their aux field are about their own blend and belong beside it — taking the
281
+ * whole struct from sceneFsOutWgsl would move that prose away from what it
282
+ * explains. They still need the id output when the pass has the attachment, so
283
+ * they splice this in and pad it with sceneIdPadWgsl.
284
+ */
285
+ export function sceneIdFieldWgsl(): string {
286
+ return mrtIds ? ` @location(2) id: vec2u,\n` : ""
287
+ }
288
+
289
+ /**
290
+ * The id assignment for a shader that has no id to give.
291
+ *
292
+ * The counterpart to the strict rule on sceneFsOutWgsl: outline hulls,
293
+ * particles, ribbons and the depth prepass all declare the output because the
294
+ * pass has the attachment, and all of them take writeMask 0, so what they
295
+ * assign is never stored. Zero, which is the reserved "nothing" id the pass
296
+ * clears to — so if one of these ever did reach the target, it would read as
297
+ * absence rather than as a plausible wrong answer pointing at material 0.
298
+ *
299
+ * A separate function from sceneIdWriteWgsl rather than a call to it with "0u"
300
+ * twice, because the two say different things: that one records an identity,
301
+ * this one satisfies a declaration.
302
+ */
303
+ export function sceneIdPadWgsl(out: string): string {
304
+ return mrtIds ? ` ${out}.id = vec2u(0u, 0u);\n` : ""
305
+ }