reze-engine 0.55.0 → 0.55.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.
package/src/engine.ts CHANGED
@@ -2395,6 +2395,33 @@ export class Engine {
2395
2395
  }
2396
2396
  }
2397
2397
 
2398
+ /** Sensor grain: how much, and whether it moves. */
2399
+ private grain = { amount: 0, animated: true }
2400
+
2401
+ /**
2402
+ * Film grain over the rendered scene, 0–1.
2403
+ *
2404
+ * A property of a SENSOR, so it belongs to the camera rather than to any one
2405
+ * subject, and it lands on what the engine drew and on nothing else — never on
2406
+ * a background image or a backdrop video, which arrived with grain of their
2407
+ * own and would be graded rather than matched by a second helping.
2408
+ *
2409
+ * `animated` false freezes it. A still photograph's grain does not move, and
2410
+ * noise crawling over a frozen picture makes the rendering look more alive
2411
+ * than the thing it is standing in.
2412
+ *
2413
+ * Costs one hash per pixel in a pass that already runs, and nothing at all at
2414
+ * zero — the branch is on a uniform.
2415
+ */
2416
+ setFilmGrain(amount: number, animated = true): void {
2417
+ this.grain.amount = Math.min(Math.max(amount, 0), 1)
2418
+ this.grain.animated = animated
2419
+ if (this.device && this.compositeUniformBuffer) this.writeCompositeViewUniforms()
2420
+ }
2421
+ getFilmGrain(): Readonly<{ amount: number; animated: boolean }> {
2422
+ return this.grain
2423
+ }
2424
+
2398
2425
  setViewTransformOptions(patch: Partial<ViewTransformOptions>): void {
2399
2426
  const v = this.viewTransform
2400
2427
  if (patch.exposure !== undefined) v.exposure = patch.exposure
@@ -2436,8 +2463,11 @@ export class Engine {
2436
2463
  // compiler doesn't fold `pow(x, 1/g)` into identity when g=1, so also emit
2437
2464
  // a uniform branch that skips the pow entirely in the common case.
2438
2465
  u[1] = 1.0 / Math.max(v.gamma, 1e-4)
2439
- u[2] = 0.0
2440
- u[3] = 0.0
2466
+ u[2] = this.grain.amount
2467
+ // The seed. Zero means STILL: a plate that is one photograph has grain that
2468
+ // does not move, and CG noise crawling over a frozen picture makes the CG
2469
+ // look more alive than the footage — the opposite of the point.
2470
+ u[3] = this.grain.animated ? Math.floor(this.sceneClock * 24) % 1024 : 0
2441
2471
  u[4] = b.color.x
2442
2472
  u[5] = b.color.y
2443
2473
  u[6] = b.color.z
@@ -7580,19 +7610,73 @@ export class Engine {
7580
7610
  // A dedicated camera VMD (target / rotation / distance / fov animated). Motion VMDs loaded
7581
7611
  // via model.loadVmd never touch the camera — the camera shot is opt-in through here.
7582
7612
 
7613
+ /** Whether a loaded camera track is allowed to drive (setCameraVmdEnabled).
7614
+ * Held separately from `camera.vmdDriven` because that flag now answers to
7615
+ * two sources, and a track switched off must stay off when the other one
7616
+ * releases the camera. */
7617
+ private cameraVmdEnabled = true
7618
+ /** A pose pushed in from outside — see setCameraPose. Reapplied every frame,
7619
+ * so it outranks the orbit AND a loaded track for as long as it is set. */
7620
+ private cameraPoseOverride: CameraPose | null = null
7621
+
7622
+ /** The one place that decides who is holding the camera. An external pose
7623
+ * wins; a track drives when it is loaded and enabled; otherwise orbit. */
7624
+ private refreshCameraDrive(): void {
7625
+ this.camera.setVmdDriven(
7626
+ this.cameraPoseOverride !== null || (this.cameraVmdEnabled && this.cameraAnimation !== null),
7627
+ )
7628
+ }
7629
+
7630
+ /**
7631
+ * Aim the camera from outside — a solved match-move, a saved shot, a rig
7632
+ * driving the view from the host's own clock.
7633
+ *
7634
+ * The exact partner of `getCameraPose`, and the same five channels: the shot
7635
+ * as MMD states it, roll included. Orbit cannot express roll, so this is the
7636
+ * only way a tilted camera reaches the engine.
7637
+ *
7638
+ * Reapplied every frame while set, which makes it authoritative rather than
7639
+ * advisory — nothing the transport or a loaded track does moves it. Pass null
7640
+ * to release, and whatever was driving before takes the camera back.
7641
+ */
7642
+ setCameraPose(pose: CameraPose | null): void {
7643
+ if (pose) {
7644
+ // Copied, not held: a host reusing one object per frame is the normal
7645
+ // shape of a track, and storing the reference would make the value we
7646
+ // reapply depend on when the caller next touched theirs.
7647
+ this.cameraPoseOverride = {
7648
+ target: new Vec3(pose.target.x, pose.target.y, pose.target.z),
7649
+ rotation: new Vec3(pose.rotation.x, pose.rotation.y, pose.rotation.z),
7650
+ distance: pose.distance,
7651
+ fov: pose.fov,
7652
+ }
7653
+ } else {
7654
+ this.cameraPoseOverride = null
7655
+ }
7656
+ this.refreshCameraDrive()
7657
+ if (this.cameraPoseOverride) this.camera.setVmdPose(this.cameraPoseOverride)
7658
+ }
7659
+
7660
+ /** The pose currently forced from outside, or null when nothing is. */
7661
+ getCameraPoseOverride(): CameraPose | null {
7662
+ return this.cameraPoseOverride
7663
+ }
7664
+
7583
7665
  /** Load a camera VMD (dedicated camera file, or any VMD's camera block) and drive the shot
7584
7666
  * from it. Default-on once a non-empty track loads; toggle with setCameraVmdEnabled. */
7585
7667
  async loadCameraVmd(url: string): Promise<void> {
7586
7668
  const frames = await VMDLoader.loadCamera(url)
7587
7669
  this.cameraAnimation = frames.length ? new CameraAnimation(frames) : null
7588
- this.camera.setVmdDriven(this.cameraAnimation !== null)
7670
+ this.cameraVmdEnabled = true
7671
+ this.refreshCameraDrive()
7589
7672
  }
7590
7673
 
7591
7674
  /** Load a camera VMD from an already-fetched buffer (e.g. a File the user dropped). */
7592
7675
  loadCameraVmdFromBuffer(buffer: ArrayBuffer): void {
7593
7676
  const frames = VMDLoader.loadCameraFromBuffer(buffer)
7594
7677
  this.cameraAnimation = frames.length ? new CameraAnimation(frames) : null
7595
- this.camera.setVmdDriven(this.cameraAnimation !== null)
7678
+ this.cameraVmdEnabled = true
7679
+ this.refreshCameraDrive()
7596
7680
  }
7597
7681
 
7598
7682
  /**
@@ -7610,7 +7694,8 @@ export class Engine {
7610
7694
  */
7611
7695
  loadCameraClip(frames: CameraKeyframe[]): void {
7612
7696
  this.cameraAnimation = frames.length ? new CameraAnimation([...frames]) : null
7613
- this.camera.setVmdDriven(this.cameraAnimation !== null)
7697
+ this.cameraVmdEnabled = true
7698
+ this.refreshCameraDrive()
7614
7699
  }
7615
7700
 
7616
7701
  /** The loaded camera track as editable keyframes, or [] with none loaded.
@@ -7630,7 +7715,8 @@ export class Engine {
7630
7715
 
7631
7716
  /** Turn the loaded camera VMD on/off (falls back to orbit when off). No-op if none loaded. */
7632
7717
  setCameraVmdEnabled(enabled: boolean): void {
7633
- this.camera.setVmdDriven(enabled && this.cameraAnimation !== null)
7718
+ this.cameraVmdEnabled = enabled
7719
+ this.refreshCameraDrive()
7634
7720
  if (!enabled && this.cameraTargetModel) {
7635
7721
  // Follow resumes with a clean snap to bone + configured offset — one
7636
7722
  // predictable cut to the scene's framing, no easing from the shot.
@@ -7891,7 +7977,7 @@ export class Engine {
7891
7977
  /** Drop the loaded camera VMD and return to orbit control. */
7892
7978
  clearCameraVmd(): void {
7893
7979
  this.cameraAnimation = null
7894
- this.camera.setVmdDriven(false)
7980
+ this.refreshCameraDrive()
7895
7981
  }
7896
7982
 
7897
7983
  /**
@@ -7934,6 +8020,29 @@ export class Engine {
7934
8020
  return this.camera.getPosition()
7935
8021
  }
7936
8022
 
8023
+ /**
8024
+ * The live orbit, read in ONE call.
8025
+ *
8026
+ * A host that stores the shot has to be able to ask where the camera actually
8027
+ * IS, because a drag on the canvas moves this and nothing else — and a
8028
+ * document that never asks will happily write back the angle it last set,
8029
+ * discarding whatever the person just did with the mouse. Reading the four
8030
+ * separately invites a torn set across a frame boundary; this cannot tear.
8031
+ *
8032
+ * `target` is the orbit's own centre. While the engine is following a bone
8033
+ * that point rides the bone, so a caller storing a FOLLOW offset must keep its
8034
+ * own and take only the angles from here.
8035
+ */
8036
+ getCameraOrbit(): { alpha: number; beta: number; distance: number; target: Vec3 } {
8037
+ const c = this.camera
8038
+ return {
8039
+ alpha: c.alpha,
8040
+ beta: c.beta,
8041
+ distance: c.radius,
8042
+ target: new Vec3(c.target.x, c.target.y, c.target.z),
8043
+ }
8044
+ }
8045
+
7937
8046
  getCameraDistance(): number {
7938
8047
  return this.camera.radius
7939
8048
  }
@@ -7952,6 +8061,21 @@ export class Engine {
7952
8061
  setCameraBeta(b: number): void {
7953
8062
  this.camera.beta = b
7954
8063
  }
8064
+ /**
8065
+ * Roll the orbiting shot, radians — the lean alpha and beta cannot state.
8066
+ *
8067
+ * Tips the up vector about the eye→target line, so the camera stays exactly
8068
+ * where it was and keeps looking at exactly what it looked at. Everything the
8069
+ * orbit does still works underneath it: following a bone, dragging, zooming.
8070
+ *
8071
+ * A camera VMD carries its own roll and ignores this while it drives.
8072
+ */
8073
+ setCameraRoll(r: number): void {
8074
+ this.camera.roll = r
8075
+ }
8076
+ getCameraRoll(): number {
8077
+ return this.camera.roll
8078
+ }
7955
8079
  /** Vertical field of view in radians (default π/4). While a camera VMD
7956
8080
  * drives the view it animates fov itself; the orbit value set here is
7957
8081
  * restored when the VMD releases the camera. */
@@ -8081,6 +8205,16 @@ export class Engine {
8081
8205
  /** Mirror softness, 0–1: 0 a polished mirror, 1 the softest blur level,
8082
8206
  * scaled by how far the reflected geometry sits behind the surface. */
8083
8207
  mirrorBlur?: number
8208
+ /** How soft the received shadow's edge is, 0–1. 0 (default) is the sharp
8209
+ * kernel this has always used, to the bit; 1 spreads the taps fourteen
8210
+ * times as wide, which is the edge an overcast sky throws.
8211
+ *
8212
+ * A property of the LIGHT, applied where the light is received: the sun
8213
+ * in a scene is either a point source with a hard edge or a sky with
8214
+ * none, and a floor that always answers "hard" can only match one of
8215
+ * them. Above 0 the taps go from nine to sixteen, so leave it at 0 for
8216
+ * scenes that want the sharp edge and pay nothing. */
8217
+ shadowSoftness?: number
8084
8218
  }): void {
8085
8219
  // NOT YET, OR NEVER AGAIN — same race setAudioData documents. This call is
8086
8220
  // deferred a frame by useSceneSync's own rAF batching, and a hot reload
@@ -8104,6 +8238,7 @@ export class Engine {
8104
8238
  opacity: 1.0,
8105
8239
  mirror: false,
8106
8240
  mirrorBlur: 0,
8241
+ shadowSoftness: 0,
8107
8242
  ...options,
8108
8243
  }
8109
8244
  this.createGroundGeometry(opts.width, opts.height)
@@ -10520,6 +10655,7 @@ export class Engine {
10520
10655
  opacity: number
10521
10656
  mirror: boolean
10522
10657
  mirrorBlur: number
10658
+ shadowSoftness: number
10523
10659
  }) {
10524
10660
  const {
10525
10661
  diffuseColor,
@@ -10534,6 +10670,7 @@ export class Engine {
10534
10670
  opacity,
10535
10671
  mirror,
10536
10672
  mirrorBlur,
10673
+ shadowSoftness,
10537
10674
  } = opts
10538
10675
  // Shadow map is already created in setupPipelines()
10539
10676
  // 20 floats: 16 for the original block, then (mirrorBlur, pad, pad, pad)
@@ -10559,6 +10696,9 @@ export class Engine {
10559
10696
  this.groundMirror = gb[15]
10560
10697
  gb[16] = Math.min(Math.max(mirrorBlur, 0), 1)
10561
10698
  this.groundMirrorBlur = gb[16]
10699
+ // gb[18] — shadow edge softness. Was padding; the shader reads it as the
10700
+ // Vogel disk's radius, and 0 takes the sharp nine-tap path unchanged.
10701
+ gb[18] = Math.min(Math.max(shadowSoftness, 0), 1)
10562
10702
  // gb[17] — does the FAR cascade hold anything?
10563
10703
  //
10564
10704
  // It holds something only when a stage is loaded; that is what it exists for
@@ -11850,13 +11990,62 @@ export class Engine {
11850
11990
  }
11851
11991
 
11852
11992
  // World-space ray from camera through a canvas pixel. Uses WebGPU's NDC z ∈ [0,1].
11993
+ /**
11994
+ * Where a point on the canvas lands on a horizontal plane.
11995
+ *
11996
+ * `px,py` are canvas-relative pixels, top-left origin — what a pointer event
11997
+ * gives you after subtracting the element's rect. Returns null when the ray
11998
+ * cannot reach the plane: parallel to it, or pointing the other way, which is
11999
+ * what a click on the sky above the horizon is.
12000
+ *
12001
+ * The one primitive a placement UI needs. Dragging a thing across the floor is
12002
+ * otherwise three sliders in world units, which asks someone to guess numbers
12003
+ * that have no visible relation to the picture they are looking at — and it
12004
+ * throws away the property that makes pointing work at all: under perspective,
12005
+ * moving something further away makes it smaller by exactly the right amount,
12006
+ * so position and size stop being two controls to tune against each other.
12007
+ */
12008
+ groundPointAt(px: number, py: number, planeY = 0): Vec3 | null {
12009
+ const ray = this.buildMouseRay(px, py)
12010
+ if (!ray) return null
12011
+ // Parallel to the plane: no intersection, and a huge one is not an answer.
12012
+ if (Math.abs(ray.dir.y) < 1e-6) return null
12013
+ const t = (planeY - ray.origin.y) / ray.dir.y
12014
+ // Behind the camera — the plane is there, but not in this shot.
12015
+ if (!(t > 0) || !isFinite(t)) return null
12016
+ return new Vec3(ray.origin.x + ray.dir.x * t, planeY, ray.origin.z + ray.dir.z * t)
12017
+ }
12018
+
12019
+ /** Hand the pointer to something else — a placement drag, a gizmo, a host's own
12020
+ * overlay — so the orbit does not also act on it. */
12021
+ setCameraInputLocked(locked: boolean): void {
12022
+ this.camera?.setInputLocked(locked)
12023
+ }
12024
+
11853
12025
  private buildMouseRay(px: number, py: number): { origin: Vec3; dir: Vec3 } | null {
11854
12026
  if (!this.camera) return null
11855
12027
  const width = this.canvas.clientWidth
11856
12028
  const height = this.canvas.clientHeight
11857
- if (width <= 0 || height <= 0) return null
11858
- const ndcX = (px / width) * 2 - 1
11859
- const ndcY = -((py / height) * 2 - 1)
12029
+ if (width <= 0 || height <= 0 || this.canvas.width <= 0 || this.canvas.height <= 0) return null
12030
+ // THE PICTURE, NOT THE ELEMENT.
12031
+ //
12032
+ // The projection's aspect comes from the DRAWING BUFFER, while a pointer
12033
+ // arrives in the CSS box — and the two do not have to agree. The canvas is
12034
+ // laid out `object-contain`, so whenever they differ the rendered image sits
12035
+ // letterboxed inside the element with bars either side of it, and dividing
12036
+ // by the element's own size lands the ray somewhere the picture is not.
12037
+ // They disagree on every resize until the observer catches up, and
12038
+ // permanently wherever a host frames the canvas to a shape of its own.
12039
+ //
12040
+ // So: work out where the image actually sits, and take the ray from that.
12041
+ const bufAspect = this.canvas.width / this.canvas.height
12042
+ const boxAspect = width / height
12043
+ const imgW = bufAspect > boxAspect ? width : height * bufAspect
12044
+ const imgH = bufAspect > boxAspect ? width / bufAspect : height
12045
+ const ox = (width - imgW) / 2
12046
+ const oy = (height - imgH) / 2
12047
+ const ndcX = ((px - ox) / imgW) * 2 - 1
12048
+ const ndcY = -(((py - oy) / imgH) * 2 - 1)
11860
12049
  const view = this.camera.getViewMatrix()
11861
12050
  const proj = this.camera.getProjectionMatrix()
11862
12051
  const invVP = proj.multiply(view).inverse()
@@ -12333,8 +12522,13 @@ export class Engine {
12333
12522
  }
12334
12523
  }
12335
12524
 
12336
- // Drive the shot from the camera VMD (synced to the animated model's clock).
12337
- if (this.camera.vmdDriven && this.cameraAnimation) {
12525
+ // Who holds the shot this frame. An external pose is a statement about
12526
+ // where the camera IS, so it is reapplied rather than sampled — and it
12527
+ // outranks a loaded track, which is scene data.
12528
+ if (this.cameraPoseOverride) {
12529
+ this.camera.setVmdPose(this.cameraPoseOverride)
12530
+ } else if (this.camera.vmdDriven && this.cameraAnimation) {
12531
+ // Drive the shot from the camera VMD (synced to the animated model's clock).
12338
12532
  const pose = this.cameraAnimation.sample(this.transportTime())
12339
12533
  if (pose) this.camera.setVmdPose(pose)
12340
12534
  }
@@ -13635,6 +13829,12 @@ export class Engine {
13635
13829
  // clock is already per effect, which is the one that actually breaks
13636
13830
  // things (rzGridFrame()==0 is a grid's only chance to seed).
13637
13831
  u[24] = this.sceneClock - (this.effects[0]?.epochScene ?? 0)
13832
+ // The grain's seed rides the same per-frame refresh, because it is the
13833
+ // only thing that makes it move — a seed written once by its setter is a
13834
+ // still pattern welded to the picture. On the SCENE clock like everything
13835
+ // else here, so an export reproduces the editor exactly rather than
13836
+ // scattering differently at whatever rate the encoder ran.
13837
+ u[3] = this.grain.animated ? Math.floor((this.sceneClock * 24) % 1024) : 0
13638
13838
  u[26] = this.canvas.width
13639
13839
  u[27] = this.canvas.height
13640
13840
  // Camera world position (viewU[10]) — the other half of bgWorldPos. It
@@ -138,7 +138,7 @@ override APPLY_GAMMA: bool = true;
138
138
  // monotone-cubic (Fritsch–Carlson) fit through the same 14 anchors — same values, C1
139
139
  // continuity kills the banding — sampled with hardware linear filtering.
140
140
  @group(0) @binding(5) var filmicLut: texture_2d<f32>;
141
- // viewU[0] = (exposure, invGamma, _, _); viewU[1] = (tint.rgb, intensity)
141
+ // viewU[0] = (exposure, invGamma, grain amount, grain seed); viewU[1] = (tint.rgb, intensity)
142
142
  // viewU[2] = (background.rgb, mode) — display-space sRGB, composited UNDER the
143
143
  // scene post-tonemap. BASE-layer mode: 0 transparent (DOM shows),
144
144
  // 1 solid color, 2 = 360 equirect skybox sampled by view ray. A user
@@ -526,6 +526,23 @@ const COMPOSITE_BODY = /* wgsl */ `
526
526
  if (APPLY_GAMMA) {
527
527
  disp = pow(disp, vec3f(viewU[0].y));
528
528
  }
529
+ // ── Film grain, on the SCENE ONLY ─────────────────────────────────────────
530
+ //
531
+ // Applied here, before the background is composited under, so it rides on what
532
+ // the engine drew and nothing else. That placement is the whole point when the
533
+ // background is footage: the plate came off a real sensor and already carries
534
+ // its own grain, and a second helping over the top would grade the photograph
535
+ // rather than match it. A clean CG figure on a grainy plate is one of the
536
+ // loudest tells there is — the noise gives it away long before the geometry.
537
+ //
538
+ // Multiplicative and weighted toward the mid-tones, which is how film behaves:
539
+ // little grain in the blacks, and the highlights clip it off.
540
+ if (viewU[0].z > 0.0) {
541
+ let gp = fragCoord.xy + vec2f(viewU[0].w, viewU[0].w * 1.7);
542
+ let gn = fract(sin(dot(gp, vec2f(12.9898, 78.233))) * 43758.5453) - 0.5;
543
+ let glum = dot(disp, vec3f(0.2126, 0.7152, 0.0722));
544
+ disp = max(disp * (1.0 + gn * viewU[0].z * 4.0 * glum * (1.0 - glum)), vec3f(0.0));
545
+ }
529
546
  // Composite over the background in display space (premultiplied out). The
530
547
  // background is TWO layers: a base (transparent / solid color / 360 equirect)
531
548
  // and an optional user WGSL effect over-composited onto it.
@@ -10,6 +10,61 @@ import { WORLD_AMBIENT_WGSL } from "../lights"
10
10
  // the struct this returns depends on what the probe at init found, and a string
11
11
  // baked at import time cannot know that. Called once, when the module is built.
12
12
 
13
+ // How far the softest setting spreads the taps, in units of the sharp kernel's
14
+ // own step, and how many taps carry that spread. Sixteen is what keeps a wide
15
+ // disk reading as a penumbra instead of sixteen shadows.
16
+ const SOFT_TAPS = 16
17
+ const SOFT_MAX_SPREAD = 14
18
+
19
+ /**
20
+ * PCF taps for one cascade, emitted per shadow map.
21
+ *
22
+ * At softness 0 this is the 3x3 box the ground has always used — same offsets,
23
+ * same weights, same result to the bit. Above it the taps spread over a Vogel
24
+ * disk whose radius IS the penumbra: an overcast sky throws no edge, and a
25
+ * razor-edged shadow under one is the loudest thing wrong in a composite.
26
+ *
27
+ * The branch is on a uniform, so a scene at softness 0 genuinely takes the
28
+ * nine-tap side rather than masking the wide one — the same reasoning the
29
+ * shadowStrength branch above it already documents.
30
+ *
31
+ * A string rather than a WGSL function because a texture is the one handle type
32
+ * this shader has never passed as a parameter, and the file is already a
33
+ * template that interpolates its constants.
34
+ *
35
+ * `acc` must already be declared and zeroed; the caller reads it back
36
+ * normalised, so there is no `/ 9.0` left at the call site.
37
+ */
38
+ function pcfWgsl(map: string, uv: string, texel: string, z: string, acc: string, pad: string): string {
39
+ const golden = 2.39996323
40
+ return [
41
+ `if (material.shadowSoftness <= 0.0) {`,
42
+ ` let st = ${texel} * 2.0;`,
43
+ ` for (var y = -1; y <= 1; y++) {`,
44
+ ` for (var x = -1; x <= 1; x++) {`,
45
+ // ...Level, not the implicit-derivative form: identical on a single-mip
46
+ // shadow map, and legal inside a branch.
47
+ ` ${acc} += textureSampleCompareLevel(${map}, shadowSampler, ${uv} + vec2f(f32(x), f32(y)) * st, ${z});`,
48
+ ` }`,
49
+ ` }`,
50
+ ` ${acc} *= ${1 / 9};`,
51
+ `} else {`,
52
+ ` let radius = ${texel} * 2.0 * (1.0 + material.shadowSoftness * ${SOFT_MAX_SPREAD}.0);`,
53
+ ` for (var s = 0; s < ${SOFT_TAPS}; s++) {`,
54
+ ` let fs = f32(s);`,
55
+ // sqrt of the index spaces the ring radii evenly by AREA; the golden angle
56
+ // keeps successive taps from lining up into spokes.
57
+ ` let r = sqrt((fs + 0.5) * ${1 / SOFT_TAPS});`,
58
+ ` let a = fs * ${golden} + rot;`,
59
+ ` ${acc} += textureSampleCompareLevel(${map}, shadowSampler, ${uv} + vec2f(cos(a), sin(a)) * (r * radius), ${z});`,
60
+ ` }`,
61
+ ` ${acc} *= ${1 / SOFT_TAPS};`,
62
+ `}`,
63
+ ]
64
+ .map((l) => pad + l)
65
+ .join("\n")
66
+ }
67
+
13
68
  export function groundShaderWgsl(): string {
14
69
  return /* wgsl */ `
15
70
  struct CameraUniforms { view: mat4x4f, projection: mat4x4f, viewPos: vec3f, _p: f32, };
@@ -22,7 +77,7 @@ struct GroundShadowMat {
22
77
  gridLineColor: vec3f, mirror: f32,
23
78
  // farCascade: 1 while a stage is loaded, 0 otherwise. See the branch below —
24
79
  // with no stage the far map is never drawn into, so its taps are known.
25
- mirrorBlur: f32, farCascade: f32, _mb1: f32, _mb2: f32,
80
+ mirrorBlur: f32, farCascade: f32, shadowSoftness: f32, _mb2: f32,
26
81
  // Every shadow caster in one sphere, refreshed per frame. w = radius; 0 means
27
82
  // nothing casts, negative means "do not use this" (a rigid caster has no
28
83
  // sphere, so a scene with a stage keeps the taps). See rzShadowPossible.
@@ -152,6 +207,11 @@ ${sceneFsOutWgsl()}@fragment fn fs(i: VO) -> FSOut {
152
207
  // The same reasoning the noise tint below already got, applied to the term
153
208
  // that costs a hundred times more.
154
209
  if (material.shadowStrength > 0.0 && shadowPossible) {
210
+ // Per-pixel rotation for the soft disk, so its rings break up into fine noise
211
+ // rather than banding. Interleaved gradient noise: a function of the pixel
212
+ // alone, so a still camera gives a still shadow — the sharp path ignores it.
213
+ let ign = fract(52.9829189 * fract(dot(i.position.xy, vec2f(0.06711056, 0.00583715))));
214
+ let rot = ign * 6.28318530718;
155
215
  // The far cascade's taps, skipped entirely when nothing ever drew into it.
156
216
  //
157
217
  // This branch is the expensive one on a wide floor: it runs wherever the NEAR
@@ -165,32 +225,20 @@ ${sceneFsOutWgsl()}@fragment fn fs(i: VO) -> FSOut {
165
225
  if (material.farCascade > 0.0 && frustum < 1.0 && frustum1 > 0.0) {
166
226
  let suv1 = vec2f(ndc1.x * 0.5 + 0.5, 0.5 - ndc1.y * 0.5);
167
227
  let suv1_c = clamp(suv1, vec2f(0.02), vec2f(0.98));
168
- let st1 = ${1 / SHADOW_CASCADES[SHADOW_CASCADES.length - 1].mapSize} * 2.0;
169
228
  let compareZ1 = ndc1.z - 0.0035;
170
229
  var acc1 = 0.0;
171
- for (var y = -1; y <= 1; y++) {
172
- for (var x = -1; x <= 1; x++) {
173
- acc1 += textureSampleCompareLevel(shadowMapFar, shadowSampler, suv1_c + vec2f(f32(x), f32(y)) * st1, compareZ1);
174
- }
175
- }
176
- vis = mix(1.0, acc1 * (1.0 / 9.0), frustum1);
230
+ ${pcfWgsl("shadowMapFar", "suv1_c", `${1 / SHADOW_CASCADES[SHADOW_CASCADES.length - 1].mapSize}`, "compareZ1", "acc1", " ")}
231
+ vis = mix(1.0, acc1, frustum1);
177
232
  }
178
233
  if (frustum > 0.0) {
179
234
  let suv = vec2f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
180
235
  let suv_c = clamp(suv, vec2f(0.02), vec2f(0.98));
181
- let st = material.pcfTexel * 2.0;
182
236
  let compareZ = ndc.z - 0.0035;
183
237
  var acc = 0.0;
184
- for (var y = -1; y <= 1; y++) {
185
- for (var x = -1; x <= 1; x++) {
186
- // ...Level, not the implicit-derivative form: identical on a single-mip
187
- // shadow map, and legal inside this branch.
188
- acc += textureSampleCompareLevel(shadowMap, shadowSampler, suv_c + vec2f(f32(x), f32(y)) * st, compareZ);
189
- }
190
- }
238
+ ${pcfWgsl("shadowMap", "suv_c", "material.pcfTexel", "compareZ", "acc", " ")}
191
239
  // The base is whatever the far cascade decided, so the near border blends
192
240
  // cascade to cascade rather than snapping to lit mid-floor.
193
- vis = mix(vis, acc * (1.0 / 9.0), frustum);
241
+ vis = mix(vis, acc, frustum);
194
242
  }
195
243
  }
196
244