reze-engine 0.55.15 → 0.56.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.
Files changed (42) hide show
  1. package/dist/camera.d.ts +12 -0
  2. package/dist/camera.d.ts.map +1 -1
  3. package/dist/camera.js +20 -3
  4. package/dist/engine.d.ts +210 -5
  5. package/dist/engine.d.ts.map +1 -1
  6. package/dist/engine.js +1013 -104
  7. package/dist/model.d.ts +12 -2
  8. package/dist/model.d.ts.map +1 -1
  9. package/dist/model.js +47 -4
  10. package/dist/overlay.d.ts +0 -5
  11. package/dist/overlay.d.ts.map +1 -1
  12. package/dist/overlay.js +0 -5
  13. package/dist/reflection.d.ts +17 -4
  14. package/dist/reflection.d.ts.map +1 -1
  15. package/dist/reflection.js +93 -38
  16. package/dist/shaders/cast-api.d.ts +1 -1
  17. package/dist/shaders/cast-api.d.ts.map +1 -1
  18. package/dist/shaders/cast-api.js +7 -6
  19. package/dist/shaders/directives.d.ts +15 -0
  20. package/dist/shaders/directives.d.ts.map +1 -1
  21. package/dist/shaders/directives.js +5 -0
  22. package/dist/shaders/passes/composite.d.ts +1 -1
  23. package/dist/shaders/passes/composite.d.ts.map +1 -1
  24. package/dist/shaders/passes/ground.d.ts.map +1 -1
  25. package/dist/shaders/passes/ground.js +42 -6
  26. package/dist/shaders/passes/mirror.d.ts +32 -0
  27. package/dist/shaders/passes/mirror.d.ts.map +1 -0
  28. package/dist/shaders/passes/mirror.js +284 -0
  29. package/dist/shaders/passes/scene-contract.d.ts +6 -0
  30. package/dist/shaders/passes/scene-contract.d.ts.map +1 -1
  31. package/dist/shaders/passes/scene-contract.js +8 -0
  32. package/package.json +1 -1
  33. package/src/camera.ts +21 -4
  34. package/src/engine.ts +1081 -108
  35. package/src/model.ts +46 -4
  36. package/src/overlay.ts +0 -5
  37. package/src/reflection.ts +103 -37
  38. package/src/shaders/cast-api.ts +7 -6
  39. package/src/shaders/directives.ts +20 -0
  40. package/src/shaders/passes/ground.ts +42 -6
  41. package/src/shaders/passes/mirror.ts +289 -0
  42. package/src/shaders/passes/scene-contract.ts +14 -0
package/src/model.ts CHANGED
@@ -1164,11 +1164,53 @@ export class Model {
1164
1164
  return this.eyeTracking !== null
1165
1165
  }
1166
1166
 
1167
- /** Where the eyes are on, as a unit direction from between them in model
1168
- * space — or null while they are the motion's own. See RzSubject.gaze. */
1167
+ /**
1168
+ * Where she looks: a unit direction in model space, or null for a model
1169
+ * without eye bones. See RzSubject.gaze.
1170
+ *
1171
+ * Exact while the eyes are on something (setEyeTracking). Otherwise the
1172
+ * motion's own eye turn UNDONE THROUGH THE RANGE MAP: an eye bone turns a
1173
+ * fraction of the way toward what it looks at — on an anime face a few
1174
+ * degrees of bone read as a long look, which is why the map exists — and
1175
+ * this gives back the whole of it, so a beam or a light can go where the
1176
+ * eye visibly looks rather than down the bone's axis. The inverse of the
1177
+ * map the eyes would be under, both eyes averaged.
1178
+ */
1169
1179
  getGaze(): Vec3 | null {
1170
- if (this.eyeTracking === null || !this.gazeDirSet) return null
1171
- return new Vec3(this.gazeDir.x, this.gazeDir.y, this.gazeDir.z)
1180
+ if (this.eyeTracking !== null && this.gazeDirSet) return new Vec3(this.gazeDir.x, this.gazeDir.y, this.gazeDir.z)
1181
+ const ni = this.runtimeSkeleton.nameIndex
1182
+ const head = ni["頭"]
1183
+ const left = ni["左目"]
1184
+ const right = ni["右目"]
1185
+ if (head === undefined || left === undefined || right === undefined) return null
1186
+ const worldMats = this.runtimeSkeleton.worldMatrices
1187
+ const hm = worldMats[head].values
1188
+ const hx = _eyeHx.setXYZ(hm[0], hm[1], hm[2]).normalizeInPlace()
1189
+ const hy = _eyeHy.setXYZ(hm[4], hm[5], hm[6]).normalizeInPlace()
1190
+ const hz = _eyeHz.setXYZ(hm[8], hm[9], hm[10]).normalizeInPlace()
1191
+ const range = this.eyeTracking?.range ?? 20
1192
+ const deg = 180 / Math.PI
1193
+ let yaw = 0
1194
+ let pitch = 0
1195
+ for (let e = 0; e < 2; e++) {
1196
+ const em = worldMats[e === 0 ? left : right].values
1197
+ // The eye looks down its local -Z: the third column, negated.
1198
+ const ax = _eyeDir.setXYZ(-em[8], -em[9], -em[10]).normalizeInPlace()
1199
+ const lx = ax.x * hx.x + ax.y * hx.y + ax.z * hx.z
1200
+ const ly = ax.x * hy.x + ax.y * hy.y + ax.z * hy.z
1201
+ const lz = -(ax.x * hz.x + ax.y * hz.y + ax.z * hz.z)
1202
+ const y = Math.atan2(lx, lz) * deg
1203
+ const p = Math.asin(Math.max(-1, Math.min(1, ly))) * deg
1204
+ // rangeMap ran 75° onto `range` and 45° onto its fractions; this runs back.
1205
+ yaw += Math.max(-75, Math.min(75, (y * 75) / range)) * 0.5
1206
+ pitch += (p > 0 ? Math.min(45, (p * 45) / (0.22 * range)) : Math.max(-45, (p * 45) / (0.3 * range))) * 0.5
1207
+ }
1208
+ const ry = yaw / deg
1209
+ const rp = pitch / deg
1210
+ const x = Math.sin(ry) * Math.cos(rp)
1211
+ const y = Math.sin(rp)
1212
+ const z = -Math.cos(ry) * Math.cos(rp)
1213
+ return new Vec3(hx.x * x + hy.x * y + hz.x * z, hx.y * x + hy.y * y + hz.y * z, hx.z * x + hy.z * y + hz.z * z).normalizeInPlace()
1172
1214
  }
1173
1215
 
1174
1216
  /** Where the eyes look, in MODEL space. Set each frame before update();
package/src/overlay.ts CHANGED
@@ -502,11 +502,6 @@ export const OVERLAY_STYLE = {
502
502
 
503
503
  /** Stroke for the material borders drawn over the mesh wireframe. */
504
504
  seamStrokePx: 3,
505
- /** Stroke for the material under the pointer — the same self-occluding reveal
506
- * a pick draws, layered over the section-wide view rather than replacing it,
507
- * and heavier than either a pick's own edges or a seam so it still pops out
508
- * of a view that already has two other things drawn in it. */
509
- hoverStrokePx: 4,
510
505
  /** How strongly the triangulation draws under those borders.
511
506
  *
512
507
  * Both at full strength is one undifferentiated field of yellow — forty
package/src/reflection.ts CHANGED
@@ -2,18 +2,34 @@
2
2
  // shadow-cascades precedent.
3
3
  //
4
4
  // A planar reflection is not a second camera aimed by hand; it is the SAME
5
- // camera with the world reflected about the floor plane. Fold the reflection
5
+ // camera with the world reflected about the mirror's plane. Fold the reflection
6
6
  // into the view matrix and everything downstream is untouched: world positions
7
7
  // stay TRUE world positions, so sun, shadows and positional lights evaluate at
8
8
  // the unmirrored point — which is exactly what a mirror shows, an object lit
9
9
  // as it is, seen from a mirrored eye. The only other value that must mirror is
10
10
  // the eye itself, because specular reads the view direction from it.
11
11
  //
12
- // Winding: a reflection has determinant -1, so triangle orientation flips.
13
- // Every scene-pass pipeline that draws into the mirror culls "none", which is
14
- // what makes this legal without a flipped-frontFace pipeline set. The OUTLINE
15
- // culls "back" and is therefore skipped in the mirror — its hull would face
16
- // the wrong way and ink over the model.
12
+ // The plane is carried as (n, d) with n a UNIT normal and the surface at
13
+ // n·p + d = 0 — general from the start, because a mirror standing on the floor
14
+ // is the ordinary case and a floor is only the one plane that happens to be
15
+ // horizontal. The ground is (0, 1, 0, 0).
16
+ //
17
+ // WINDING: a reflection has determinant -1, so triangle orientation flips, and
18
+ // any pipeline that culls a face culls the WRONG one in the mirror pass.
19
+ //
20
+ // This note used to claim every scene-pass pipeline culls "none". That was
21
+ // never true, and each exception cost a round of "the mirror looks broken":
22
+ //
23
+ // ground culls "back" -> the floor vanished whole, and with it the grid
24
+ // and the received shadow, since the shadow is a
25
+ // layer of the ground shader
26
+ // outline culls "back" -> no ink line on the reflection at all
27
+ // eye culls "front" -> the back of the eyeball kept, its front discarded
28
+ //
29
+ // All three now build a mirror twin with the cull answered (the ground goes to
30
+ // "none", the outline to "front", the eye to "back"). Anything ADDED to the
31
+ // scene pass with a cullMode must do the same — grep cullMode in engine.ts,
32
+ // which is the whole list.
17
33
 
18
34
  /**
19
35
  * The debug view: the reflection target drawn over the finished frame — the
@@ -44,20 +60,66 @@ struct VSOut { @builtin(position) pos: vec4f, @location(0) uv: vec2f, };
44
60
  `
45
61
 
46
62
  /**
47
- * Reflection about the horizontal plane y = h, column-major.
63
+ * A plane as the mirror wants it: 4 numbers, (nx, ny, nz, d), surface at
64
+ * n·p + d = 0.
65
+ *
66
+ * Written into a caller-owned array rather than returned, because the engine
67
+ * holds one per mirror and rebuilds it whenever the mirror moves — which is
68
+ * every frame a gizmo is being dragged.
69
+ *
70
+ * `n` is normalized here rather than trusted: it arrives from a rotated quad's
71
+ * basis, and a scale baked into that basis would otherwise scale `d` against
72
+ * it and put the plane somewhere else entirely.
73
+ */
74
+ export function planeFromPointNormal(
75
+ px: number, py: number, pz: number,
76
+ nx: number, ny: number, nz: number,
77
+ out: Float32Array,
78
+ ): Float32Array {
79
+ const len = Math.hypot(nx, ny, nz) || 1
80
+ const x = nx / len
81
+ const y = ny / len
82
+ const z = nz / len
83
+ out[0] = x
84
+ out[1] = y
85
+ out[2] = z
86
+ // d places the plane through the point: n·p + d = 0.
87
+ out[3] = -(x * px + y * py + z * pz)
88
+ return out
89
+ }
90
+
91
+ /**
92
+ * The Householder reflection about (n, d), column-major.
48
93
  *
49
- * p' = (x, 2h - y, z)
94
+ * p' = p - 2 (n·p + d) n
50
95
  */
51
- export function reflectionAboutY(h: number): Float32Array {
52
- // prettier-ignore
53
- return new Float32Array([
54
- 1, 0, 0, 0,
55
- 0, -1, 0, 0,
56
- 0, 0, 1, 0,
57
- 0, 2 * h, 0, 1,
58
- ])
96
+ export function reflectionAboutPlane(plane: ArrayLike<number>, out?: Float32Array): Float32Array {
97
+ const nx = plane[0], ny = plane[1], nz = plane[2], d = plane[3]
98
+ const m = out ?? new Float32Array(16)
99
+ m[0] = 1 - 2 * nx * nx
100
+ m[1] = -2 * ny * nx
101
+ m[2] = -2 * nz * nx
102
+ m[3] = 0
103
+ m[4] = -2 * nx * ny
104
+ m[5] = 1 - 2 * ny * ny
105
+ m[6] = -2 * nz * ny
106
+ m[7] = 0
107
+ m[8] = -2 * nx * nz
108
+ m[9] = -2 * ny * nz
109
+ m[10] = 1 - 2 * nz * nz
110
+ m[11] = 0
111
+ m[12] = -2 * d * nx
112
+ m[13] = -2 * d * ny
113
+ m[14] = -2 * d * nz
114
+ m[15] = 1
115
+ return m
59
116
  }
60
117
 
118
+ /** Scratch for the product below — this module is called once per mirror per
119
+ * frame and allocating a matrix each time is a garbage-collector visit for
120
+ * nothing. */
121
+ const R = new Float32Array(16)
122
+
61
123
  /**
62
124
  * Fill a camera-uniform block for the mirror pass from the live one.
63
125
  *
@@ -69,26 +131,30 @@ export function reflectionAboutY(h: number): Float32Array {
69
131
  * view' = view × R (column-vector convention, matching `projection * view *
70
132
  * pos` in the vertex shaders); projection unchanged; eye reflected.
71
133
  */
72
- export function buildMirrorCamera(camera: Float32Array, planeY: number, out: Float32Array): Float32Array {
73
- const v = camera
74
- const h = planeY
75
- // view × R where R = reflectionAboutY(h). R only touches column 1 (scaled by
76
- // -1) and adds 2h·col1 to the translation — write the product directly
77
- // rather than through a generic multiply, so the arithmetic is exact and the
78
- // cost is a handful of ops.
79
- for (let i = 0; i < 16; i++) out[i] = v[i]
80
- out[4] = -v[4]
81
- out[5] = -v[5]
82
- out[6] = -v[6]
83
- out[7] = -v[7]
84
- out[12] = v[12] + 2 * h * v[4]
85
- out[13] = v[13] + 2 * h * v[5]
86
- out[14] = v[14] + 2 * h * v[6]
87
- out[15] = v[15] + 2 * h * v[7]
88
- for (let i = 16; i < 32; i++) out[i] = v[i]
89
- out[32] = v[32]
90
- out[33] = 2 * h - v[33]
91
- out[34] = v[34]
92
- out[35] = v[35]
134
+ export function buildMirrorCamera(
135
+ camera: Float32Array,
136
+ plane: ArrayLike<number>,
137
+ out: Float32Array,
138
+ ): Float32Array {
139
+ reflectionAboutPlane(plane, R)
140
+ // view × R, column-major: out.col_j = Σ_k view.col_k · R[j*4 + k].
141
+ for (let j = 0; j < 4; j++) {
142
+ const b0 = R[j * 4 + 0], b1 = R[j * 4 + 1], b2 = R[j * 4 + 2], b3 = R[j * 4 + 3]
143
+ for (let r = 0; r < 4; r++) {
144
+ out[j * 4 + r] =
145
+ camera[0 + r] * b0 + camera[4 + r] * b1 + camera[8 + r] * b2 + camera[12 + r] * b3
146
+ }
147
+ }
148
+ for (let i = 16; i < 32; i++) out[i] = camera[i]
149
+ // The eye through the same reflection, so specular reads the mirrored view
150
+ // direction. Written from the plane directly rather than through R: it is one
151
+ // dot product, and going via the matrix would round-trip it through nine
152
+ // multiplies to reach the same number.
153
+ const nx = plane[0], ny = plane[1], nz = plane[2], d = plane[3]
154
+ const t = 2 * (nx * camera[32] + ny * camera[33] + nz * camera[34] + d)
155
+ out[32] = camera[32] - t * nx
156
+ out[33] = camera[33] - t * ny
157
+ out[34] = camera[34] - t * nz
158
+ out[35] = camera[35]
93
159
  return out
94
160
  }
@@ -56,12 +56,13 @@ struct RzSubject {
56
56
  */
57
57
  dissolve: f32,
58
58
  /**
59
- * Where she is LOOKING, when her eyes are on something (Engine.setEyeTracking):
60
- * a unit direction from between the eyes toward it, in world space. The eyes
61
- * themselves turn only part of the way there by design, so an effect that
62
- * should go where the gaze goes — a beam, a light — takes this rather than
63
- * an eye bone's axis. Zero, with looking false, while the eyes are the
64
- * motion's own.
59
+ * Where she LOOKS: a unit direction from between the eyes, in world space.
60
+ * Exact while her eyes are on something (Engine.setEyeTracking); otherwise
61
+ * the motion's own eye turn undone through the range map — an eye bone
62
+ * turns a fraction of the way toward what it looks at, and this gives back
63
+ * the whole of it. An effect that should go where the look goes — a beam,
64
+ * a light — takes this rather than an eye bone's axis, and it holds in
65
+ * either mode. Zero, with looking false, only for a model without eyes.
65
66
  */
66
67
  gaze: vec3f,
67
68
  looking: bool,
@@ -55,6 +55,21 @@ export type EffectDirectives = {
55
55
  bloom: boolean
56
56
  /** This effect takes the cast apart — the host reads the timing. */
57
57
  dissolve: boolean
58
+ /**
59
+ * This effect IS a mirror: a plane in the scene showing a true reflection of
60
+ * it, folded and re-rendered by the engine.
61
+ *
62
+ * A mount with no shader, and the second of them — `#lights` was the first.
63
+ * The reasoning is identical: a planar reflection re-renders every model from
64
+ * a folded camera, which is a PASS, and no mount is a pass. So the effect
65
+ * declares the mirror and the engine reflects, exactly as a lighting rig
66
+ * declares lamps and the engine shades.
67
+ *
68
+ * The plane comes from the effect's own `#param` dials, by name — see the
69
+ * engine's MIRROR_DIALS. That is what makes it an effect rather than a switch:
70
+ * it publishes, forks, schedules and fades like every other one.
71
+ */
72
+ mirror: boolean
58
73
  /**
59
74
  * How long ONE firing of this effect lasts, in seconds. 0 = undeclared.
60
75
  *
@@ -84,6 +99,7 @@ const SPEC = {
84
99
  grid: 1,
85
100
  bloom: 0,
86
101
  dissolve: 0,
102
+ mirror: 0,
87
103
  duration: 1,
88
104
  } as const
89
105
 
@@ -139,6 +155,7 @@ export function parseDirectives(wgsl: string): DirectiveResult {
139
155
  grid: 0,
140
156
  bloom: false,
141
157
  dissolve: false,
158
+ mirror: false,
142
159
  duration: 0,
143
160
  }
144
161
  const errors: string[] = []
@@ -235,6 +252,9 @@ export function parseDirectives(wgsl: string): DirectiveResult {
235
252
  case "dissolve":
236
253
  d.dissolve = true
237
254
  return
255
+ case "mirror":
256
+ d.mirror = true
257
+ return
238
258
  case "duration": {
239
259
  // SECONDS, like every other time a directive states (see #dissolve).
240
260
  // The document above works in frames and converts once; an author
@@ -123,6 +123,26 @@ struct MirrorVP { viewProj: mat4x4f, params: vec4f, };
123
123
  // The frost noise, PRE-BAKED — see GROUND_NOISE_BAKE_WGSL below. Sampled with
124
124
  // the repeat sampler already at binding 10.
125
125
  @group(0) @binding(12) var noiseTex: texture_2d<f32>;
126
+ // The mirror pass's resolved aux. .g is accumulated alpha — the reflection's
127
+ // coverage. It lives here rather than in the colour target's own alpha because
128
+ // the HDR format is rg11b10ufloat wherever the device allows it, and that
129
+ // format has no alpha channel at all.
130
+ @group(0) @binding(13) var mirrorMask: texture_2d<f32>;
131
+ // THE MIRROR'S OWN PLANE, and whether this draw is the one inside the mirror
132
+ // pass. A mirror shows nothing behind itself, and the floor is the geometry
133
+ // most often behind one: stand a pane up and half the floor is behind it, lay
134
+ // it facing UP and the whole floor is. Without this the reflection carries a
135
+ // grid that the mirror cannot see.
136
+ //
137
+ // A uniform rather than a branch on some global, because the two ground bind
138
+ // groups already differ — the camera's binds a block set to 0 and the
139
+ // mirror's binds the live plane. No per-pass write, and nothing to get wrong
140
+ // about which pass is running.
141
+ // The flag is named on, not active: WGSL RESERVES active, and a reserved
142
+ // word in a struct field fails the whole module — taking the ground
143
+ // pipeline, and every style group and effect compiled after it, with it.
144
+ struct MirrorClip { plane: vec4f, on: f32, _p0: f32, _p1: f32, _p2: f32, };
145
+ @group(0) @binding(14) var<uniform> clip: MirrorClip;
126
146
  ${WORLD_AMBIENT_WGSL}
127
147
  ${lightsApi(0, 6)}
128
148
 
@@ -137,6 +157,14 @@ ${sceneFsOutWgsl()}@fragment fn fs(i: VO) -> FSOut {
137
157
  let gp = i.worldPos.xz / material.gridSpacing;
138
158
  let gridDeriv = fwidth(gp);
139
159
 
160
+ // The clip, straight after the derivatives and before anything else branches.
161
+ // EPSILON rather than zero: a pane lying flat on the floor is coplanar with
162
+ // it, and a floor reflecting itself at zero distance is z-fighting, not a
163
+ // reflection.
164
+ if (clip.on > 0.5 && dot(clip.plane.xyz, i.worldPos) + clip.plane.w < 0.01) {
165
+ discard;
166
+ }
167
+
140
168
  var out: FSOut;
141
169
  let n = normalize(i.normal);
142
170
  let centerDist = length(i.worldPos.xz);
@@ -315,6 +343,11 @@ ${pcfWgsl("shadowMap", "suv_c", "material.pcfTexel", "compareZ", "acc", " ",
315
343
  // independence the grid won earlier. The branch is on a uniform, so the
316
344
  // whole cost vanishes for the scenes that leave it off.
317
345
  var reflShadowed = vec3f(0.0);
346
+ // How much of this texel the reflection pass actually covered. The target
347
+ // clears TRANSPARENT, so this is 1 where something reflected and 0 over the
348
+ // empty sky — which is what stops a floor mirror from painting the backdrop
349
+ // onto itself and wiping out the ground's own colour.
350
+ var reflA = 0.0;
318
351
  if (material.mirror > 0.0) {
319
352
  let mc = mirrorVP.viewProj * vec4f(i.worldPos, 1.0);
320
353
  let mndc = mc.xyz / max(mc.w, 1e-6);
@@ -349,10 +382,11 @@ ${pcfWgsl("shadowMap", "suv_c", "material.pcfTexel", "compareZ", "acc", " ",
349
382
  let BLUR_SPAN = 14.0;
350
383
  lod = material.mirrorBlur * 5.0 * clamp(height / BLUR_SPAN, 0.0, 1.0);
351
384
  }
352
- let refl = textureSampleLevel(mirrorTex, linearSampler, muv, lod).rgb;
385
+ let refl = textureSampleLevel(mirrorTex, linearSampler, muv, lod);
353
386
  // Still under the received shadow: a polished floor in shade shows a dim
354
387
  // reflection, and a mirror that ignored the shadow would glow in it.
355
- reflShadowed = refl * (1.0 - dark * 0.65);
388
+ reflShadowed = refl.rgb * (1.0 - dark * 0.65);
389
+ reflA = textureSampleLevel(mirrorMask, linearSampler, muv, 0.0).g;
356
390
  }
357
391
  // THREE LAYERS, composited premultiplied, bottom to top: the shadow the
358
392
  // catcher receives, the ground surface, and the grid.
@@ -364,15 +398,17 @@ ${pcfWgsl("shadowMap", "suv_c", "material.pcfTexel", "compareZ", "acc", " ",
364
398
  // alpha, and the grid is simply on or off.
365
399
  let surfA = edgeFade * material.opacity;
366
400
  let gridA = gridLine * material.gridLineOpacity * edgeFade;
367
- // The mirror is opaque across the ground's extent when on — it always shows
368
- // SOMETHING, because the backdrop rides in the reflection target itself.
369
- let mirrA = material.mirror * edgeFade;
401
+ // The mirror covers only what it actually REFLECTS. The backdrop no longer
402
+ // rides in the reflection target — it clears transparent — so the floor keeps
403
+ // its own colour everywhere the cast is not standing, which is the difference
404
+ // between an MMD stage floor and a mirror of the sky.
405
+ let mirrA = material.mirror * edgeFade * reflA;
370
406
  // As opacity drops the received shadow becomes a translucent dark layer
371
407
  // (Blender's Shadow Catcher), so models still feel grounded on a photo or a
372
408
  // 360 backdrop. Colourless by construction: it darkens by covering. The
373
409
  // MIRROR subsumes it: the reflection already carries the received shadow,
374
410
  // and a catcher stacked on top would darken the same shadow twice.
375
- let catchA = dark * 0.65 * edgeFade * (1.0 - material.opacity) * (1.0 - material.mirror);
411
+ let catchA = dark * 0.65 * edgeFade * (1.0 - material.opacity) * (1.0 - mirrA);
376
412
 
377
413
  // FOUR LAYERS, premultiplied, bottom to top: reflection, received shadow,
378
414
  // ground surface, grid. Each with its own coverage, none scaling another —