@driftengine/core 4.5.0 → 4.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/CHANGELOG.json +62 -0
  2. package/README.md +1 -1
  3. package/dist/geometry/boxCsg.d.ts +28 -0
  4. package/dist/geometry/boxCsg.js +139 -0
  5. package/dist/geometry/csg.d.ts +36 -0
  6. package/dist/geometry/csg.js +329 -0
  7. package/dist/geometry/meshBuilder.js +13 -0
  8. package/dist/geometry/meshConcat.js +1 -0
  9. package/dist/geometry/pixelFont.js +2 -0
  10. package/dist/geometry/solid.d.ts +61 -0
  11. package/dist/geometry/solid.js +152 -0
  12. package/dist/geometry/solidFlat.d.ts +35 -0
  13. package/dist/geometry/solidFlat.js +428 -0
  14. package/dist/geometry/solidHarness.d.ts +19 -0
  15. package/dist/geometry/solidHarness.js +66 -0
  16. package/dist/geometry/solidNormals.d.ts +17 -0
  17. package/dist/geometry/solidNormals.js +86 -0
  18. package/dist/geometry/solidRound.d.ts +41 -0
  19. package/dist/geometry/solidRound.js +323 -0
  20. package/dist/geometry/solidWriter.d.ts +16 -0
  21. package/dist/geometry/solidWriter.js +42 -0
  22. package/dist/index.d.ts +23 -2
  23. package/dist/index.js +13 -1
  24. package/dist/render/backend/api.d.ts +17 -3
  25. package/dist/render/backend/drawFog.d.ts +26 -0
  26. package/dist/render/backend/drawFog.js +30 -0
  27. package/dist/render/backend/materialChanges.d.ts +2 -1
  28. package/dist/render/backend/materialChanges.js +6 -1
  29. package/dist/render/backend/webgl2/instanced.d.ts +24 -5
  30. package/dist/render/backend/webgl2/instanced.js +72 -15
  31. package/dist/render/backend/webgl2/renderer.d.ts +122 -8
  32. package/dist/render/backend/webgl2/renderer.js +249 -128
  33. package/dist/render/backend/webgpu/buffers.d.ts +17 -0
  34. package/dist/render/backend/webgpu/buffers.js +34 -3
  35. package/dist/render/backend/webgpu/depthPass.js +5 -1
  36. package/dist/render/backend/webgpu/drawCommand.d.ts +5 -0
  37. package/dist/render/backend/webgpu/drawCommand.js +1 -0
  38. package/dist/render/backend/webgpu/flatPass.d.ts +4 -1
  39. package/dist/render/backend/webgpu/flatPass.js +38 -9
  40. package/dist/render/backend/webgpu/glassTintPass.js +2 -1
  41. package/dist/render/backend/webgpu/instanceCullPass.d.ts +34 -0
  42. package/dist/render/backend/webgpu/instanceCullPass.js +144 -0
  43. package/dist/render/backend/webgpu/instanced.d.ts +11 -1
  44. package/dist/render/backend/webgpu/instanced.js +21 -3
  45. package/dist/render/backend/webgpu/latePass.d.ts +6 -2
  46. package/dist/render/backend/webgpu/latePass.js +11 -4
  47. package/dist/render/backend/webgpu/motionPass.js +2 -1
  48. package/dist/render/backend/webgpu/postPass.d.ts +1 -1
  49. package/dist/render/backend/webgpu/renderer.d.ts +100 -10
  50. package/dist/render/backend/webgpu/renderer.js +478 -214
  51. package/dist/render/backend/webgpu/sdfTextPass.js +6 -1
  52. package/dist/render/backend/webgpu/shaders/instanceCull.wgsl.d.ts +18 -0
  53. package/dist/render/backend/webgpu/shaders/instanceCull.wgsl.js +59 -0
  54. package/dist/render/backend/webgpu/surfaceTexturePass.d.ts +17 -3
  55. package/dist/render/backend/webgpu/surfaceTexturePass.js +77 -38
  56. package/dist/render/driftLight/bake.d.ts +7 -0
  57. package/dist/render/driftLight/bake.js +61 -56
  58. package/dist/render/driftLight/denseField.d.ts +46 -0
  59. package/dist/render/driftLight/denseField.js +109 -0
  60. package/dist/render/driftLight/lightField.d.ts +10 -5
  61. package/dist/render/driftLight/lightField.js +25 -19
  62. package/dist/render/driftLight/presence.d.ts +50 -0
  63. package/dist/render/driftLight/presence.js +43 -0
  64. package/dist/render/driftLight/uniforms.d.ts +3 -3
  65. package/dist/render/driftLight/uniforms.js +5 -6
  66. package/dist/render/driftLight/worldLightField.d.ts +41 -0
  67. package/dist/render/driftLight/worldLightField.js +66 -0
  68. package/dist/render/fixtureAtlas.d.ts +20 -0
  69. package/dist/render/fixtureAtlas.js +49 -0
  70. package/dist/render/instanceCull.d.ts +63 -0
  71. package/dist/render/instanceCull.js +160 -0
  72. package/dist/render/instances.d.ts +15 -0
  73. package/dist/render/lightBudget.d.ts +22 -33
  74. package/dist/render/lightBudget.js +24 -35
  75. package/dist/render/lightGrid.d.ts +42 -0
  76. package/dist/render/lightGrid.js +87 -0
  77. package/dist/render/mesh.d.ts +23 -24
  78. package/dist/render/mesh.js +56 -68
  79. package/dist/render/occlusion.d.ts +10 -0
  80. package/dist/render/occlusion.js +36 -7
  81. package/dist/render/pointLightSelection.d.ts +7 -0
  82. package/dist/render/pointLightSelection.js +29 -4
  83. package/dist/render/rainField.d.ts +13 -0
  84. package/dist/render/rainField.js +27 -5
  85. package/dist/render/recon/lateDepth.d.ts +11 -0
  86. package/dist/render/recon/lateDepth.js +70 -0
  87. package/dist/render/recon/resolve.d.ts +34 -4
  88. package/dist/render/recon/resolve.js +161 -20
  89. package/dist/render/rendererHarness.js +4 -0
  90. package/dist/render/sceneTarget.d.ts +2 -0
  91. package/dist/render/sceneTarget.js +2 -0
  92. package/dist/render/shaders/depth.js +12 -9
  93. package/dist/render/shaders/flat/driftLight.d.ts +1 -1
  94. package/dist/render/shaders/flat/driftLight.js +32 -7
  95. package/dist/render/shaders/flat/index.js +5 -3
  96. package/dist/render/shaders/flat/main.d.ts +1 -1
  97. package/dist/render/shaders/flat/main.js +75 -23
  98. package/dist/render/shaders/flat/preamble.js +75 -20
  99. package/dist/render/shaders/flat/surfaceEffects.d.ts +29 -0
  100. package/dist/render/shaders/flat/surfaceEffects.js +152 -0
  101. package/dist/render/shaders/generated/depth.wgsl.d.ts +8 -8
  102. package/dist/render/shaders/generated/depth.wgsl.js +8 -8
  103. package/dist/render/shaders/generated/flat.wgsl.d.ts +1320 -1320
  104. package/dist/render/shaders/generated/flat.wgsl.js +1359 -1345
  105. package/dist/render/shaders/generated/lightVolume.wgsl.js +18 -4
  106. package/dist/render/shaders/generated/rush.wgsl.d.ts +21 -16
  107. package/dist/render/shaders/generated/rush.wgsl.js +21 -16
  108. package/dist/render/shaders/generated/sdfText.wgsl.d.ts +2 -2
  109. package/dist/render/shaders/generated/sdfText.wgsl.js +2 -2
  110. package/dist/render/shaders/generated/text.wgsl.d.ts +1 -1
  111. package/dist/render/shaders/generated/text.wgsl.js +1 -1
  112. package/dist/render/shaders/gi/driftLight.wgsl.d.ts +1 -1
  113. package/dist/render/shaders/gi/driftLight.wgsl.js +27 -4
  114. package/dist/render/shaders/recon/lateDepth.wgsl.js +38 -13
  115. package/dist/render/shaders/recon/resolve.wgsl.d.ts +8 -7
  116. package/dist/render/shaders/recon/resolve.wgsl.js +161 -27
  117. package/dist/render/shaders/rush.js +11 -1
  118. package/dist/render/shaders/sdfText.d.ts +1 -1
  119. package/dist/render/shaders/sdfText.js +3 -2
  120. package/dist/render/shaders/text.d.ts +1 -1
  121. package/dist/render/shaders/text.js +18 -9
  122. package/dist/render/surfaceEffects.d.ts +83 -0
  123. package/dist/render/surfaceEffects.js +75 -0
  124. package/dist/render/surfaceScene.d.ts +8 -0
  125. package/dist/render/surfaceScene.js +8 -0
  126. package/dist/render/surfaceTexture.d.ts +18 -2
  127. package/dist/render/surfaceTexture.js +68 -15
  128. package/dist/render/temporalAa.d.ts +25 -6
  129. package/dist/render/temporalAa.js +45 -15
  130. package/dist/render/textureSource.d.ts +30 -0
  131. package/dist/render/textureSource.js +53 -0
  132. package/dist/render/vertexDefaults.js +2 -0
  133. package/dist/world/hlod.d.ts +104 -0
  134. package/dist/world/hlod.js +214 -0
  135. package/package.json +6 -6
  136. package/src/geometry/boxCsg.ts +186 -0
  137. package/src/geometry/csg.ts +384 -0
  138. package/src/geometry/meshBuilder.ts +15 -0
  139. package/src/geometry/meshConcat.ts +1 -0
  140. package/src/geometry/pixelFont.ts +2 -0
  141. package/src/geometry/solid.ts +189 -0
  142. package/src/geometry/solidFlat.ts +522 -0
  143. package/src/geometry/solidHarness.ts +83 -0
  144. package/src/geometry/solidNormals.ts +103 -0
  145. package/src/geometry/solidRound.ts +443 -0
  146. package/src/geometry/solidWriter.ts +58 -0
  147. package/src/index.ts +51 -1
  148. package/src/render/backend/api.ts +21 -2
  149. package/src/render/backend/drawFog.ts +31 -0
  150. package/src/render/backend/materialChanges.ts +9 -2
  151. package/src/render/backend/webgl2/instanced.ts +80 -14
  152. package/src/render/backend/webgl2/renderer.ts +311 -150
  153. package/src/render/backend/webgpu/buffers.ts +49 -3
  154. package/src/render/backend/webgpu/depthPass.ts +5 -1
  155. package/src/render/backend/webgpu/drawCommand.ts +6 -0
  156. package/src/render/backend/webgpu/flatPass.ts +41 -11
  157. package/src/render/backend/webgpu/glassTintPass.ts +2 -1
  158. package/src/render/backend/webgpu/instanceCullPass.ts +165 -0
  159. package/src/render/backend/webgpu/instanced.ts +21 -3
  160. package/src/render/backend/webgpu/latePass.ts +12 -3
  161. package/src/render/backend/webgpu/motionPass.ts +2 -1
  162. package/src/render/backend/webgpu/renderer.ts +556 -239
  163. package/src/render/backend/webgpu/sdfTextPass.ts +6 -1
  164. package/src/render/backend/webgpu/shaders/instanceCull.wgsl.ts +61 -0
  165. package/src/render/backend/webgpu/surfaceTexturePass.ts +90 -50
  166. package/src/render/driftLight/bake.ts +85 -62
  167. package/src/render/driftLight/denseField.ts +149 -0
  168. package/src/render/driftLight/lightField.ts +33 -18
  169. package/src/render/driftLight/presence.ts +69 -0
  170. package/src/render/driftLight/uniforms.ts +8 -9
  171. package/src/render/driftLight/worldLightField.ts +83 -0
  172. package/src/render/fixtureAtlas.ts +63 -0
  173. package/src/render/instanceCull.ts +205 -0
  174. package/src/render/instances.ts +16 -0
  175. package/src/render/lightBudget.ts +25 -36
  176. package/src/render/lightGrid.ts +130 -0
  177. package/src/render/mesh.ts +64 -72
  178. package/src/render/occlusion.ts +51 -7
  179. package/src/render/pointLightSelection.ts +72 -4
  180. package/src/render/rainField.ts +39 -5
  181. package/src/render/recon/lateDepth.ts +83 -0
  182. package/src/render/recon/resolve.ts +206 -20
  183. package/src/render/rendererHarness.ts +4 -0
  184. package/src/render/sceneTarget.ts +4 -0
  185. package/src/render/shaders/depth.ts +12 -9
  186. package/src/render/shaders/flat/driftLight.ts +32 -7
  187. package/src/render/shaders/flat/index.ts +5 -3
  188. package/src/render/shaders/flat/main.ts +75 -23
  189. package/src/render/shaders/flat/preamble.ts +75 -20
  190. package/src/render/shaders/flat/surfaceEffects.ts +152 -0
  191. package/src/render/shaders/generated/depth.wgsl.ts +8 -8
  192. package/src/render/shaders/generated/flat.wgsl.ts +1361 -1345
  193. package/src/render/shaders/generated/lightVolume.wgsl.ts +20 -4
  194. package/src/render/shaders/generated/rush.wgsl.ts +21 -16
  195. package/src/render/shaders/generated/sdfText.wgsl.ts +2 -2
  196. package/src/render/shaders/generated/text.wgsl.ts +1 -1
  197. package/src/render/shaders/gi/driftLight.wgsl.ts +27 -4
  198. package/src/render/shaders/recon/lateDepth.wgsl.ts +38 -13
  199. package/src/render/shaders/recon/resolve.wgsl.ts +161 -27
  200. package/src/render/shaders/rush.ts +11 -1
  201. package/src/render/shaders/sdfText.ts +3 -2
  202. package/src/render/shaders/text.ts +18 -9
  203. package/src/render/surfaceEffects.ts +132 -0
  204. package/src/render/surfaceScene.ts +16 -0
  205. package/src/render/surfaceTexture.ts +110 -19
  206. package/src/render/temporalAa.ts +78 -14
  207. package/src/render/textureSource.ts +72 -0
  208. package/src/render/vertexDefaults.ts +2 -0
  209. package/src/world/hlod.ts +290 -0
@@ -34,12 +34,16 @@
34
34
  * fn reconMotion(x: i32, y: i32) -> vec4<f32> // uv, previous view depth, flag
35
35
  * fn reconHistory(x: i32, y: i32) -> vec4<f32> // output size, colour and gathered weight
36
36
  * fn reconHistoryBilinear(p: vec2<f32>) -> vec4<f32>
37
+ * fn reconFlicker(x: i32, y: i32) -> vec4<f32> // output size, last frame's flicker record
38
+ * fn reconSwing(x: i32, y: i32) -> f32 // output size, last frame's held depth swing
37
39
  * ```
38
40
  *
39
41
  * and the uniform `recon: ReconParams`, whose struct this module declares.
40
42
  */
41
43
  import { wgslSceneDepthToNdc } from '../../depthConvention.js';
42
44
  import { PLANAR_FROM, PLANAR_TO } from '../../recon/disocclusion.js';
45
+ import { DEPTH_SWING_WIDTH } from '../../recon/resolve.js';
46
+ import { FLICKER_BLEND, FLICKER_REPEAT, STILL_FROM, STILL_TO } from '../../temporalAa.js';
43
47
  /**
44
48
  * The parameters one resolve reads, as `ResolveFrame` minus its images.
45
49
  *
@@ -65,10 +69,15 @@ struct ReconParams {
65
69
  motionScale: f32,
66
70
  normalFloor: f32,
67
71
  normalCeiling: f32,
72
+ periodStart: u32,
73
+ antiFlicker: u32,
74
+ flickerMemory: f32,
75
+ flickerWidth: f32,
76
+ reprojection: mat4x4<f32>,
68
77
  }
69
78
  `;
70
79
  /** Floats in `ReconParams`, which is what a caller sizes its uniform buffer by. */
71
- export const RECON_PARAM_FLOATS = 72;
80
+ export const RECON_PARAM_FLOATS = 92;
72
81
  /** `SAMPLE_SPREAD` and `CONFIDENT_WEIGHT`, which the reference exports and this must not restate. */
73
82
  export const RECON_SAMPLE_SPREAD = 0.47;
74
83
  export const RECON_CONFIDENT_WEIGHT = 1;
@@ -85,8 +94,8 @@ export const RECON_WORKGROUP = 8;
85
94
  */
86
95
  export const RECON_HISTORY_FORMAT = 'rgba16float';
87
96
  /**
88
- * The arithmetic, as text. Defines `reconResolvePixel(ox, oy) -> vec4<f32>` — the next history's
89
- * colour and the weight it has gathered — and `reconSharpenAt(ox, oy) -> vec3<f32>`, the picture
97
+ * The arithmetic, as text. Defines `reconResolvePixel(ox, oy) -> ReconResolved` — the next
98
+ * history's colour and the weight it has gathered, and the pixel's next flicker record — and `reconSharpenAt(ox, oy) -> vec3<f32>`, the picture
90
99
  * shown, which reads the history the first has already written.
91
100
  */
92
101
  export const RECON_RESOLVE_CORE_WGSL = /* wgsl */ `
@@ -94,6 +103,11 @@ const RECON_SAMPLE_SPREAD: f32 = ${RECON_SAMPLE_SPREAD};
94
103
  const RECON_CONFIDENT_WEIGHT: f32 = ${RECON_CONFIDENT_WEIGHT}.0;
95
104
  const RECON_PLANAR_FROM: f32 = ${PLANAR_FROM};
96
105
  const RECON_PLANAR_TO: f32 = ${PLANAR_TO};
106
+ const RECON_FLICKER_BLEND: f32 = ${FLICKER_BLEND};
107
+ const RECON_FLICKER_REPEAT: f32 = ${FLICKER_REPEAT};
108
+ const RECON_STILL_FROM: f32 = ${STILL_FROM};
109
+ const RECON_STILL_TO: f32 = ${STILL_TO};
110
+ const RECON_DEPTH_SWING_WIDTH: f32 = ${DEPTH_SWING_WIDTH}.0;
97
111
 
98
112
  fn reconClampTexel(value: i32, size: i32) -> i32 {
99
113
  return clamp(value, 0, size - 1);
@@ -461,6 +475,10 @@ fn reconSurfaceAt(previous: bool, x: i32, y: i32, centre: f32) -> ReconSurface {
461
475
  struct ReconHistory {
462
476
  weight: f32,
463
477
  colour: vec4<f32>,
478
+ /* How far the surface moved, in output pixels; read only where the weight is not zero. */
479
+ moved: f32,
480
+ /* How far, relative, last frame's depth missed the one expected there; -1 where none was held. */
481
+ missed: f32,
464
482
  }
465
483
 
466
484
  /*
@@ -468,10 +486,12 @@ struct ReconHistory {
468
486
  * behind last frame's eye or off its picture, and otherwise what it gathered, scaled by whether it
469
487
  * is the same surface.
470
488
  */
471
- fn reconHistoryWeight(u: f32, v: f32, dx: i32, dy: i32) -> ReconHistory {
489
+ fn reconHistoryWeight(u: f32, v: f32, dx: i32, dy: i32, swing: f32) -> ReconHistory {
472
490
  var out: ReconHistory;
473
491
  out.weight = 0.0;
474
492
  out.colour = vec4<f32>(0.0);
493
+ out.moved = 0.0;
494
+ out.missed = -1.0;
475
495
  if (recon.hasHistory == 0u) { return out; }
476
496
  let rw = f32(recon.renderSize.x);
477
497
  let rh = f32(recon.renderSize.y);
@@ -484,20 +504,24 @@ fn reconHistoryWeight(u: f32, v: f32, dx: i32, dy: i32) -> ReconHistory {
484
504
  mv = motion.y;
485
505
  expected = motion.z;
486
506
  } else {
487
- let here = reconWorldAt(false, dx, dy);
488
- if (!(here.depth > 0.0)) { return out; }
489
- let clip = recon.previousViewProj * vec4<f32>(here.position, 1.0);
507
+ /* Through the one reprojection matrix the renderer multiplied: see resolve.ts. */
508
+ let du = (f32(dx) + 0.5 - recon.jitter.x) / rw;
509
+ let dv = (f32(dy) + 0.5 - recon.jitter.y) / rh;
510
+ let ndc = vec4<f32>(du * 2.0 - 1.0, dv * 2.0 - 1.0, reconDepth(dx, dy), 1.0);
511
+ let m = recon.inverseViewProj;
512
+ let hw = m[0][3] * ndc.x + m[1][3] * ndc.y + m[2][3] * ndc.z + m[3][3];
513
+ if (!(hw > 1e-12)) { return out; }
514
+ let clip = recon.reprojection * ndc;
490
515
  /*
491
516
  * A surface behind last frame's eye has a w that is not positive: the disocclusion trusts no
492
517
  * such depth, so it needs no refusal of its own here.
493
518
  */
494
- expected = clip.w;
495
- let du = (f32(dx) + 0.5 - recon.jitter.x) / rw;
496
- let dv = (f32(dy) + 0.5 - recon.jitter.y) / rh;
497
- mu = (clip.x / expected) * 0.5 + 0.5 - du;
498
- mv = (clip.y / expected) * 0.5 + 0.5 - dv;
519
+ expected = clip.w / hw;
520
+ mu = (clip.x / clip.w) * 0.5 + 0.5 - du;
521
+ mv = (clip.y / clip.w) * 0.5 + 0.5 - dv;
499
522
  }
500
523
 
524
+ out.moved = length(vec2<f32>(mu * f32(recon.outputSize.x), mv * f32(recon.outputSize.y)));
501
525
  let pu = u + mu;
502
526
  let pv = v + mv;
503
527
  if (!(pu >= 0.0 && pu <= 1.0 && pv >= 0.0 && pv <= 1.0)) { return out; }
@@ -544,13 +568,41 @@ fn reconHistoryWeight(u: f32, v: f32, dx: i32, dy: i32) -> ReconHistory {
544
568
  normalDot = 1.0 - planar * (1.0 - dot(hereNormal.normal, thereNormal.normal));
545
569
  }
546
570
  }
547
- let trust = reconDisocclusion(expected, held, length(vec2<f32>(mu, mv)), normalDot, here.slope);
571
+ if (held > 0.0 && expected > 0.0) { out.missed = abs(held - expected) / expected; }
572
+ /* A still pixel's own held depth swing: see resolve.ts. */
573
+ var swung = 0.0;
574
+ if (swing > 0.0) {
575
+ swung = RECON_DEPTH_SWING_WIDTH * swing *
576
+ (1.0 - reconSmoothstep(RECON_STILL_FROM, RECON_STILL_TO, out.moved));
577
+ }
578
+ let trust = reconDisocclusion(
579
+ expected,
580
+ held,
581
+ length(vec2<f32>(mu, mv)),
582
+ normalDot,
583
+ here.slope + swung,
584
+ );
548
585
  out.weight = out.colour.w * trust;
549
586
  return out;
550
587
  }
551
588
 
552
- /* One output pixel: the history's next colour, before sharpening, and the weight it has gathered. */
553
- fn reconResolvePixel(ox: i32, oy: i32) -> vec4<f32> {
589
+ /* temporalAa.ts's repeats: whether two periods' means are the same, one to zero. */
590
+ fn reconRepeats(mean: f32, previous: f32) -> f32 {
591
+ let apart = abs(mean - previous) / max(max(mean, previous), 1.0 / 255.0);
592
+ return 1.0 - reconSmoothstep(RECON_FLICKER_REPEAT, 2.0 * RECON_FLICKER_REPEAT, apart);
593
+ }
594
+
595
+ struct ReconResolved {
596
+ /* The history's next colour, before sharpening, and the weight it has gathered. */
597
+ colour: vec4<f32>,
598
+ /* The pixel's next flicker record: spread, this period's sum, last period's mean, repeats. */
599
+ record: vec4<f32>,
600
+ /* And its next held depth swing. */
601
+ swing: f32,
602
+ }
603
+
604
+ /* One output pixel: the history's next colour and weight, and its next flicker record. */
605
+ fn reconResolvePixel(ox: i32, oy: i32) -> ReconResolved {
554
606
  let rw = i32(recon.renderSize.x);
555
607
  let rh = i32(recon.renderSize.y);
556
608
  let ow = i32(recon.outputSize.x);
@@ -598,18 +650,68 @@ fn reconResolvePixel(ox: i32, oy: i32) -> vec4<f32> {
598
650
  accum = accum / gathered;
599
651
 
600
652
  /* The history, and the weight it still deserves; a tenth of each frame is always new. */
601
- let cap = (1.0 - recon.alpha) / recon.alpha;
602
- let history = reconHistoryWeight(u, v, dx, dy);
653
+ var swing = 0.0;
654
+ if (recon.antiFlicker != 0u) { swing = reconSwing(ox, oy); }
655
+ let history = reconHistoryWeight(u, v, dx, dy, swing);
656
+ var cap = (1.0 - recon.alpha) / recon.alpha;
657
+ var low = box.low;
658
+ var high = box.high;
659
+ var out: ReconResolved;
660
+ out.record = vec4<f32>(0.0);
661
+ out.swing = 0.0;
662
+ /* The anti-flicker, operation for operation resolve.ts's: see temporalAa.ts. */
663
+ if (recon.antiFlicker != 0u) {
664
+ let seen = dot(accum, vec3<f32>(0.2126, 0.7152, 0.0722));
665
+ let memory = recon.flickerMemory;
666
+ let width = recon.flickerWidth;
667
+ var held = vec4<f32>(0.0, seen * memory, seen, 0.0);
668
+ if (history.weight > 0.0) { held = reconFlicker(ox, oy); }
669
+ var still = 0.0;
670
+ if (history.weight > 0.0) {
671
+ still = 1.0 - reconSmoothstep(RECON_STILL_FROM, RECON_STILL_TO, history.moved);
672
+ }
673
+ let apart = abs(seen - held.z);
674
+ let within = apart <= width * held.x + 1.0 / 255.0;
675
+ let proven = held.w > 0.75 && within;
676
+ var widen = 0.0;
677
+ let base = 1.0 - recon.alpha;
678
+ var keep = base;
679
+ if (proven) {
680
+ widen = width * held.x * still;
681
+ keep = base + (RECON_FLICKER_BLEND - base) * still;
682
+ }
683
+ low = low - vec3<f32>(widen);
684
+ high = high + vec3<f32>(widen);
685
+ cap = keep / (1.0 - keep);
686
+ var sum = held.y;
687
+ var mean = held.z;
688
+ var repeated = 0.0;
689
+ if (within) { repeated = held.w; }
690
+ if (recon.periodStart != 0u) {
691
+ if (reconRepeats(sum, mean) > 0.5) { repeated = min(1.0, repeated + 0.5); } else { repeated = 0.0; }
692
+ mean = sum;
693
+ sum = 0.0;
694
+ }
695
+ out.record = vec4<f32>(
696
+ held.x + (apart - held.x) * memory,
697
+ sum + seen * memory,
698
+ mean,
699
+ repeated * still,
700
+ );
701
+ /* The peak carried on, halving over a period, whatever the test said. */
702
+ if (history.missed >= 0.0) { out.swing = max(history.missed, swing * pow(0.5, memory)); }
703
+ }
603
704
  let kept = min(cap, history.weight);
604
705
 
605
706
  let total = kept + gathered;
606
707
  var mixed = accum;
607
708
  if (kept > 0.0) {
608
- let clipped = reconClipToBox(reconToYCoCg(history.colour.xyz), box.low, box.high);
709
+ let clipped = reconClipToBox(reconToYCoCg(history.colour.xyz), low, high);
609
710
  mixed = reconAccumulate(accum, reconFromYCoCg(clipped), 1.0, gathered / total);
610
711
  }
611
712
  let known = min(1.0, total / RECON_CONFIDENT_WEIGHT);
612
- return vec4<f32>(sample * (1.0 - known) + mixed * known, total);
713
+ out.colour = vec4<f32>(sample * (1.0 - known) + mixed * known, total);
714
+ return out;
613
715
  }
614
716
 
615
717
  /*
@@ -648,8 +750,9 @@ fn reconSharpenAt(ox: i32, oy: i32) -> vec3<f32> {
648
750
  * doing its own bilinear so the comparison is arithmetic rather than a tolerance on the device's
649
751
  * sampler.
650
752
  *
651
- * Two entry points over one set of bindings. `resolveMain` writes four floats a pixel — the next
652
- * history — and `sharpenMain` reads that history back at binding 4 and writes three.
753
+ * Two entry points over one set of bindings. `resolveMain` writes nine floats a pixel — the next
754
+ * history, the next flicker record and the next depth swing — and `sharpenMain` reads that history back at binding 4
755
+ * and writes three.
653
756
  */
654
757
  export function reconResolveParityWgsl() {
655
758
  return /* wgsl */ `
@@ -661,6 +764,16 @@ export function reconResolveParityWgsl() {
661
764
  ${RECON_PARAMS_WGSL}
662
765
  @group(0) @binding(5) var<uniform> recon: ReconParams;
663
766
  @group(0) @binding(6) var<storage, read_write> output: array<f32>;
767
+ @group(0) @binding(7) var flickerTexture: texture_2d<f32>;
768
+ @group(0) @binding(8) var swingTexture: texture_2d<f32>;
769
+
770
+ fn reconFlicker(x: i32, y: i32) -> vec4<f32> {
771
+ return textureLoad(flickerTexture, vec2<i32>(x, y), 0);
772
+ }
773
+
774
+ fn reconSwing(x: i32, y: i32) -> f32 {
775
+ return textureLoad(swingTexture, vec2<i32>(x, y), 0).x;
776
+ }
664
777
 
665
778
  fn reconScene(x: i32, y: i32) -> vec3<f32> {
666
779
  return textureLoad(sceneTexture, vec2<i32>(x, y), 0).xyz;
@@ -718,11 +831,16 @@ ${RECON_RESOLVE_CORE_WGSL}
718
831
  fn resolveMain(@builtin(global_invocation_id) id: vec3<u32>) {
719
832
  if (id.x >= recon.outputSize.x || id.y >= recon.outputSize.y) { return; }
720
833
  let value = reconResolvePixel(i32(id.x), i32(id.y));
721
- let at = (id.y * recon.outputSize.x + id.x) * 4u;
722
- output[at] = value.x;
723
- output[at + 1u] = value.y;
724
- output[at + 2u] = value.z;
725
- output[at + 3u] = value.w;
834
+ let at = (id.y * recon.outputSize.x + id.x) * 9u;
835
+ output[at] = value.colour.x;
836
+ output[at + 1u] = value.colour.y;
837
+ output[at + 2u] = value.colour.z;
838
+ output[at + 3u] = value.colour.w;
839
+ output[at + 4u] = value.record.x;
840
+ output[at + 5u] = value.record.y;
841
+ output[at + 6u] = value.record.z;
842
+ output[at + 7u] = value.record.w;
843
+ output[at + 8u] = value.swing;
726
844
  }
727
845
 
728
846
  @compute @workgroup_size(${RECON_WORKGROUP}, ${RECON_WORKGROUP})
@@ -772,6 +890,12 @@ ${RECON_PARAMS_WGSL}
772
890
  @group(0) @binding(6) var<uniform> recon: ReconParams;
773
891
  @group(0) @binding(7) var resolved: texture_storage_2d<rgba16float, write>;
774
892
  /* Not "target": it is a reserved keyword in WGSL, as "meta" was in the cluster cull. */
893
+ /* Last frame's flicker records, and where this frame's go: a pair beside the histories. */
894
+ @group(0) @binding(8) var flickerTexture: texture_2d<f32>;
895
+ @group(0) @binding(9) var flickerOut: texture_storage_2d<rgba16float, write>;
896
+ /* And each pixel's held depth swing, a pair of its own. */
897
+ @group(0) @binding(10) var swingTexture: texture_2d<f32>;
898
+ @group(0) @binding(11) var swingOut: texture_storage_2d<r32float, write>;
775
899
 
776
900
  /* Rows flip here: the reference's row 0 is the bottom one and a texture's is the top. */
777
901
  fn reconRenderRow(y: i32) -> i32 {
@@ -804,6 +928,14 @@ fn reconHistory(x: i32, y: i32) -> vec4<f32> {
804
928
  return textureLoad(historyTexture, vec2<i32>(x, reconOutputRow(y)), 0);
805
929
  }
806
930
 
931
+ fn reconFlicker(x: i32, y: i32) -> vec4<f32> {
932
+ return textureLoad(flickerTexture, vec2<i32>(x, reconOutputRow(y)), 0);
933
+ }
934
+
935
+ fn reconSwing(x: i32, y: i32) -> f32 {
936
+ return textureLoad(swingTexture, vec2<i32>(x, reconOutputRow(y)), 0).x;
937
+ }
938
+
807
939
  /* A texel coordinate becomes the uv of its centre, with the row taken from the other end. */
808
940
  fn reconSceneBilinear(p: vec2<f32>) -> vec3<f32> {
809
941
  let size = vec2<f32>(f32(recon.renderSize.x), f32(recon.renderSize.y));
@@ -823,7 +955,9 @@ fn resolveMain(@builtin(global_invocation_id) id: vec3<u32>) {
823
955
  if (id.x >= recon.outputSize.x || id.y >= recon.outputSize.y) { return; }
824
956
  let y = i32(recon.outputSize.y) - 1 - i32(id.y);
825
957
  let value = reconResolvePixel(i32(id.x), y);
826
- textureStore(resolved, vec2<i32>(i32(id.x), i32(id.y)), value);
958
+ textureStore(resolved, vec2<i32>(i32(id.x), i32(id.y)), value.colour);
959
+ textureStore(flickerOut, vec2<i32>(i32(id.x), i32(id.y)), value.record);
960
+ textureStore(swingOut, vec2<i32>(i32(id.x), i32(id.y)), vec4<f32>(value.swing, 0.0, 0.0, 0.0));
827
961
  }
828
962
 
829
963
  @compute @workgroup_size(${RECON_WORKGROUP}, ${RECON_WORKGROUP})
@@ -102,6 +102,16 @@ uniform float uMotionMax;
102
102
  */
103
103
  uniform sampler2D uAo;
104
104
  uniform float uAoStrength;
105
+ /**
106
+ * Where this frame's jitter put the picture the occlusion was measured from, in this pass's uv.
107
+ *
108
+ * The occlusion is measured from the frame's own depth, which a temporal resolve or a
109
+ * reconstruction jittered, and it is applied here to a picture those have already taken the jitter
110
+ * out of — so read at the pixel's own uv it moved by the jitter every frame, and every crevice
111
+ * shimmered by up to half a render texel. Read where the jitter put it, it holds still. Zero for an
112
+ * unjittered frame.
113
+ */
114
+ uniform vec2 uAoOffset;
105
115
 
106
116
  /**
107
117
  * The bloom chain's top level, already thresholded, blurred and summed by its own pass.
@@ -339,7 +349,7 @@ void main() {
339
349
  * strength, so the parameter reads as "how much of this occlusion", and 0 is exactly the
340
350
  * frame that existed before the effect did.
341
351
  */
342
- float ao = mix(1.0, textureLod(uAo, vUv, 0.0).r, uAoStrength);
352
+ float ao = mix(1.0, textureLod(uAo, vUv + uAoOffset, 0.0).r, uAoStrength);
343
353
 
344
354
  /*
345
355
  * Distance from the centre, corrected so the falloff is a circle on screen rather than
@@ -6,4 +6,4 @@
6
6
  * the alpha test is not uniform control flow.
7
7
  */
8
8
  export declare const SDF_TEXT_VERT = "#version 300 es\nprecision highp float;\n\nlayout(location = 0) in vec3 aPosition;\nlayout(location = 1) in vec2 aUv;\n\nuniform mat4 uViewProj;\nuniform mat4 uModel;\n\nout vec2 vUv;\n\nvoid main() {\n vUv = aUv;\n gl_Position = uViewProj * uModel * vec4(aPosition, 1.0);\n}\n";
9
- export declare const SDF_TEXT_FRAG = "#version 300 es\nprecision highp float;\n\nin vec2 vUv;\n\nuniform sampler2D uAtlas;\nuniform vec3 uColor;\nuniform float uOpacity;\n/* Texels the field spans, from the atlas metrics. Scales the antialiasing band. */\nuniform float uDistanceRange;\nuniform vec2 uAtlasSize;\n\nout vec4 outColor;\n\n/** 0 none, 1 sRGB, 2 ACES then sRGB. See renderQuality.ts. */\nuniform int uOutputTransform;\n/** Scales the scene into the tone curve. 1 is the reference grade. See renderQuality.ts. */\nuniform float uOutputExposure;\n\n/*\n * ACES: the reference fit, not the cheap one.\n *\n * This was Narkowicz's single rational function, on the argument that the difference from\n * the reference curve is invisible next to the gain. That argument holds for a look and\n * fails for a *match*, which is what this option is for \u2014 aces exists so a world ported\n * from three.js reads the same, and three.js applies the full fit: into AP1 through\n * ACESInputMat, the RRT and ODT together, then back out through ACESOutputMat.\n *\n * The two curves are not a shade apart. Narkowicz is markedly brighter through the\n * midtones, which is exactly where an interior scene lives, and it is a large part of a\n * measured ~1.7x that survived correcting the colour space and the missing Lambert pi.\n * An approximation of a standard is the wrong thing to hold when the standard is the\n * specification.\n */\nvec3 rrtAndOdtFit(vec3 v) {\n vec3 a = v * (v + 0.0245786) - 0.000090537;\n vec3 b = v * (0.983729 * v + 0.4329510) + 0.238081;\n return a / b;\n}\n\nvec3 acesFilmic(vec3 x) {\n /*\n * Exposure first, because a tone curve is only defined relative to one.\n *\n * The reference RRT and ODT are graded for a dark cinema. Every renderer that ships ACES\n * scales its input to suit whatever it is actually shown on, and the factor each one\n * picks is a *look*, not a standard - so this engine takes no position and reads it from\n * the caller. Neutral by default, which is the reference grade unmodified. three.js's own\n * pick is 1 / 0.6, for a consumer matching one.\n */\n x *= uOutputExposure;\n // Columns, as GLSL reads them: sRGB -> XYZ -> D65_2_D60 -> AP1 -> RRT_SAT.\n const mat3 ACES_INPUT = mat3(\n 0.59719, 0.07600, 0.02840,\n 0.35458, 0.90834, 0.13383,\n 0.04823, 0.01566, 0.83777\n );\n // ODT_SAT -> XYZ -> D60_2_D65 -> sRGB.\n const mat3 ACES_OUTPUT = mat3(\n 1.60475, -0.10208, -0.00327,\n -0.53108, 1.10813, -0.07276,\n -0.07367, -0.00605, 1.07602\n );\n return clamp(ACES_OUTPUT * rrtAndOdtFit(ACES_INPUT * x), 0.0, 1.0);\n}\n\n/*\n * Linear to sRGB. The piecewise form rather than a plain pow: the linear toe near black\n * is where banding lives in an 8-bit buffer, and the cheap approximation puts it in the\n * wrong place.\n */\nvec3 linearToSrgb(vec3 c) {\n vec3 low = c * 12.92;\n vec3 high = 1.055 * pow(max(c, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055;\n return mix(high, low, step(c, vec3(0.0031308)));\n}\n\nvec3 applyOutputTransform(vec3 c) {\n if (uOutputTransform == 0) return c;\n if (uOutputTransform == 2) c = acesFilmic(c);\n return linearToSrgb(c);\n}\n\n\n/*\n * The middle of the three channels.\n *\n * A single-channel field rounds every corner off at small sizes, because one distance\n * cannot describe two edges meeting. Three channels carry the corner and the median\n * recovers it.\n */\nfloat median(vec3 rgb) {\n return max(min(rgb.r, rgb.g), min(max(rgb.r, rgb.g), rgb.b));\n}\n\nvoid main() {\n vec3 sampled = textureLod(uAtlas, vUv, 0.0).rgb;\n float distance = median(sampled) - 0.5;\n\n /*\n * The band is one pixel wide *in screen space*, which is what lets one atlas serve every\n * size. A constant band is tuned for one size and is wrong at all the others.\n */\n vec2 texelsPerPixel = fwidth(vUv) * uAtlasSize;\n float pixelRange = max(0.5 * (texelsPerPixel.x + texelsPerPixel.y), 0.0001);\n float coverage = clamp(distance * uDistanceRange / pixelRange + 0.5, 0.0, 1.0);\n\n outColor = vec4(applyOutputTransform(uColor), coverage * uOpacity);\n}\n";
9
+ export declare const SDF_TEXT_FRAG = "#version 300 es\nprecision highp float;\n\nin vec2 vUv;\n\n/* An array of one: every surface texture is an array, and an atlas is read at layer 0. */\nuniform mediump sampler2DArray uAtlas;\nuniform vec3 uColor;\nuniform float uOpacity;\n/* Texels the field spans, from the atlas metrics. Scales the antialiasing band. */\nuniform float uDistanceRange;\nuniform vec2 uAtlasSize;\n\nout vec4 outColor;\n\n/** 0 none, 1 sRGB, 2 ACES then sRGB. See renderQuality.ts. */\nuniform int uOutputTransform;\n/** Scales the scene into the tone curve. 1 is the reference grade. See renderQuality.ts. */\nuniform float uOutputExposure;\n\n/*\n * ACES: the reference fit, not the cheap one.\n *\n * This was Narkowicz's single rational function, on the argument that the difference from\n * the reference curve is invisible next to the gain. That argument holds for a look and\n * fails for a *match*, which is what this option is for \u2014 aces exists so a world ported\n * from three.js reads the same, and three.js applies the full fit: into AP1 through\n * ACESInputMat, the RRT and ODT together, then back out through ACESOutputMat.\n *\n * The two curves are not a shade apart. Narkowicz is markedly brighter through the\n * midtones, which is exactly where an interior scene lives, and it is a large part of a\n * measured ~1.7x that survived correcting the colour space and the missing Lambert pi.\n * An approximation of a standard is the wrong thing to hold when the standard is the\n * specification.\n */\nvec3 rrtAndOdtFit(vec3 v) {\n vec3 a = v * (v + 0.0245786) - 0.000090537;\n vec3 b = v * (0.983729 * v + 0.4329510) + 0.238081;\n return a / b;\n}\n\nvec3 acesFilmic(vec3 x) {\n /*\n * Exposure first, because a tone curve is only defined relative to one.\n *\n * The reference RRT and ODT are graded for a dark cinema. Every renderer that ships ACES\n * scales its input to suit whatever it is actually shown on, and the factor each one\n * picks is a *look*, not a standard - so this engine takes no position and reads it from\n * the caller. Neutral by default, which is the reference grade unmodified. three.js's own\n * pick is 1 / 0.6, for a consumer matching one.\n */\n x *= uOutputExposure;\n // Columns, as GLSL reads them: sRGB -> XYZ -> D65_2_D60 -> AP1 -> RRT_SAT.\n const mat3 ACES_INPUT = mat3(\n 0.59719, 0.07600, 0.02840,\n 0.35458, 0.90834, 0.13383,\n 0.04823, 0.01566, 0.83777\n );\n // ODT_SAT -> XYZ -> D60_2_D65 -> sRGB.\n const mat3 ACES_OUTPUT = mat3(\n 1.60475, -0.10208, -0.00327,\n -0.53108, 1.10813, -0.07276,\n -0.07367, -0.00605, 1.07602\n );\n return clamp(ACES_OUTPUT * rrtAndOdtFit(ACES_INPUT * x), 0.0, 1.0);\n}\n\n/*\n * Linear to sRGB. The piecewise form rather than a plain pow: the linear toe near black\n * is where banding lives in an 8-bit buffer, and the cheap approximation puts it in the\n * wrong place.\n */\nvec3 linearToSrgb(vec3 c) {\n vec3 low = c * 12.92;\n vec3 high = 1.055 * pow(max(c, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055;\n return mix(high, low, step(c, vec3(0.0031308)));\n}\n\nvec3 applyOutputTransform(vec3 c) {\n if (uOutputTransform == 0) return c;\n if (uOutputTransform == 2) c = acesFilmic(c);\n return linearToSrgb(c);\n}\n\n\n/*\n * The middle of the three channels.\n *\n * A single-channel field rounds every corner off at small sizes, because one distance\n * cannot describe two edges meeting. Three channels carry the corner and the median\n * recovers it.\n */\nfloat median(vec3 rgb) {\n return max(min(rgb.r, rgb.g), min(max(rgb.r, rgb.g), rgb.b));\n}\n\nvoid main() {\n vec3 sampled = textureLod(uAtlas, vec3(vUv, 0.0), 0.0).rgb;\n float distance = median(sampled) - 0.5;\n\n /*\n * The band is one pixel wide *in screen space*, which is what lets one atlas serve every\n * size. A constant band is tuned for one size and is wrong at all the others.\n */\n vec2 texelsPerPixel = fwidth(vUv) * uAtlasSize;\n float pixelRange = max(0.5 * (texelsPerPixel.x + texelsPerPixel.y), 0.0001);\n float coverage = clamp(distance * uDistanceRange / pixelRange + 0.5, 0.0, 1.0);\n\n outColor = vec4(applyOutputTransform(uColor), coverage * uOpacity);\n}\n";
@@ -27,7 +27,8 @@ precision highp float;
27
27
 
28
28
  in vec2 vUv;
29
29
 
30
- uniform sampler2D uAtlas;
30
+ /* An array of one: every surface texture is an array, and an atlas is read at layer 0. */
31
+ uniform mediump sampler2DArray uAtlas;
31
32
  uniform vec3 uColor;
32
33
  uniform float uOpacity;
33
34
  /* Texels the field spans, from the atlas metrics. Scales the antialiasing band. */
@@ -49,7 +50,7 @@ float median(vec3 rgb) {
49
50
  }
50
51
 
51
52
  void main() {
52
- vec3 sampled = textureLod(uAtlas, vUv, 0.0).rgb;
53
+ vec3 sampled = textureLod(uAtlas, vec3(vUv, 0.0), 0.0).rgb;
53
54
  float distance = median(sampled) - 0.5;
54
55
 
55
56
  /*
@@ -13,5 +13,5 @@
13
13
  * gameplay camera is doing — but it still wants perspective, or a rotating
14
14
  * letter shears instead of turning.
15
15
  */
16
- export declare const TEXT_VERT = "#version 300 es\nprecision highp float;\n\nlayout(location = 0) in vec3 aCorner;\nlayout(location = 1) in vec3 aNormal;\n/** Cell position in pixels, relative to the string's origin. */\nlayout(location = 2) in vec2 aCell;\n/** Which character this cell belongs to, for staggered animation. */\nlayout(location = 3) in float aCharIndex;\n\nuniform vec2 uViewport;\n/** Where the string's baseline-left sits, in pixels from the top left. */\nuniform vec2 uOrigin;\nuniform float uCellSize;\n/** Distance from the virtual camera; larger flattens the perspective. */\nuniform float uDepth;\n\n/** 0 before a character has arrived, 1 once it has settled. */\nuniform float uReveal;\n/** How many characters the stagger spans \u2014 the whole line, not this piece. */\nuniform float uCharCount;\n/**\n * Where this piece starts within that line.\n *\n * A line drawn in several pieces \u2014 words around a keycap \u2014 must stagger as one\n * sentence. Without this each piece restarts at zero, so the second word begins\n * arriving before the first has finished and they sit at different heights\n * through the whole animation.\n */\nuniform float uCharOffset;\n/** Radians of entry rotation, decaying to zero as a character settles. */\nuniform float uSpin;\n/** Extra scale at the moment of arrival \u2014 the overshoot. */\nuniform float uPunch;\n/** Vertical bob amplitude in pixels, and its phase. */\nuniform float uBob;\nuniform float uTime;\n/**\n * Clip space, as the backend drawing this defines it. Identity on WebGL2.\n *\n * This draw builds its own clip position from pixels and never multiplies by a camera, so\n * the correction every other draw carries in its view-projection cannot reach it \u2014 the same\n * gap that left the sky unprojecting through the wrong space. Two things are wrong without\n * it on WebGPU, and only one of them is visible: the generated vertex shader negates Y, so\n * the message renders upside down; and z here is a narrow slice about zero, which is inside\n * OpenGL's [-1, 1] and *outside* WebGPU's [0, 1], so half of every glyph is clipped away.\n */\nuniform mat4 uClipCorrection;\n\nout vec3 vNormal;\nout float vDepth;\nout float vChar;\n\n/**\n * Spring settle: overshoots once and comes back.\n *\n * A character that eases into place reads as an element appearing; one that\n * overshoots and settles reads as an object arriving. That difference is most\n * of what makes text feel like part of a game rather than part of a page.\n */\nfloat settle(float t) {\n if (t <= 0.0) return 0.0;\n if (t >= 1.0) return 1.0;\n float d = 1.0 - t;\n return 1.0 - d * d * cos(t * 12.0) ;\n}\n\nmat2 rot(float a) {\n float s = sin(a);\n float c = cos(a);\n return mat2(c, -s, s, c);\n}\n\nvoid main() {\n // Stagger: each character starts a fraction of the reveal later than the\n // one before it, so the message assembles left to right.\n float span = max(uCharCount, 1.0);\n float start = ((aCharIndex + uCharOffset) / span) * 0.55;\n float local = clamp((uReveal - start) / max(0.45, 1.0 - start), 0.0, 1.0);\n float eased = settle(local);\n\n // Arrives spinning about its own horizontal axis and dropping into place.\n float spin = uSpin * (1.0 - eased);\n float rise = (1.0 - eased) * uCellSize * 6.0;\n float punch = 1.0 + uPunch * (1.0 - abs(eased * 2.0 - 1.0));\n\n vec3 local3 = aCorner * uCellSize * 0.5 * punch;\n local3.yz = rot(spin) * local3.yz;\n\n vec2 cell = aCell * uCellSize;\n float bob = sin(uTime * 3.1 + aCharIndex * 0.7) * uBob;\n\n // Pixels, y down from the top-left corner of the viewport.\n vec2 screen = uOrigin + vec2(cell.x, -cell.y) + vec2(0.0, rise - bob);\n vec3 view = vec3(screen + local3.xy, -uDepth + local3.z);\n\n // A hand-built perspective divide. The near plane is the viewport itself, so\n // a cell at depth uDepth is exactly its pixel size on screen.\n float invDepth = uDepth / max(0.001, -view.z);\n vec2 ndc = ((view.xy * invDepth) / uViewport) * 2.0 - 1.0;\n // Depth from the cube's own extrusion, mapped into a narrow slice of the\n // range so a message sorts against itself without ever reaching the scene.\n float depth = clamp(-local3.z / max(1.0, uCellSize * 2.0), -1.0, 1.0) * 0.4;\n gl_Position = uClipCorrection * vec4(ndc.x, -ndc.y, depth, 1.0);\n\n vec3 n = aNormal;\n n.yz = rot(spin) * n.yz;\n vNormal = n;\n vDepth = local3.z;\n vChar = eased;\n}";
16
+ export declare const TEXT_VERT = "#version 300 es\nprecision highp float;\n\nlayout(location = 0) in vec3 aCorner;\nlayout(location = 1) in vec3 aNormal;\n/** Cell position in pixels, relative to the string's origin. */\nlayout(location = 2) in vec2 aCell;\n/** Which character this cell belongs to, for staggered animation. */\nlayout(location = 3) in float aCharIndex;\n\nuniform vec2 uViewport;\n/** Where the string's baseline-left sits, in pixels from the top left. */\nuniform vec2 uOrigin;\nuniform float uCellSize;\n/** How far each cell is seen from, head-on; larger flattens the perspective. */\nuniform float uDepth;\n\n/** 0 before a character has arrived, 1 once it has settled. */\nuniform float uReveal;\n/** How many characters the stagger spans \u2014 the whole line, not this piece. */\nuniform float uCharCount;\n/**\n * Where this piece starts within that line.\n *\n * A line drawn in several pieces \u2014 words around a keycap \u2014 must stagger as one\n * sentence. Without this each piece restarts at zero, so the second word begins\n * arriving before the first has finished and they sit at different heights\n * through the whole animation.\n */\nuniform float uCharOffset;\n/** Radians of entry rotation, decaying to zero as a character settles. */\nuniform float uSpin;\n/** Extra scale at the moment of arrival \u2014 the overshoot. */\nuniform float uPunch;\n/** Vertical bob amplitude in pixels, and its phase. */\nuniform float uBob;\nuniform float uTime;\n/**\n * Clip space, as the backend drawing this defines it. Identity on WebGL2.\n *\n * This draw builds its own clip position from pixels and never multiplies by a camera, so\n * the correction every other draw carries in its view-projection cannot reach it \u2014 the same\n * gap that left the sky unprojecting through the wrong space. Two things are wrong without\n * it on WebGPU, and only one of them is visible: the generated vertex shader negates Y, so\n * the message renders upside down; and z here is a narrow slice about zero, which is inside\n * OpenGL's [-1, 1] and *outside* WebGPU's [0, 1], so half of every glyph is clipped away.\n */\nuniform mat4 uClipCorrection;\n\nout vec3 vNormal;\nout float vDepth;\nout float vChar;\n\n/**\n * Spring settle: overshoots once and comes back.\n *\n * A character that eases into place reads as an element appearing; one that\n * overshoots and settles reads as an object arriving. That difference is most\n * of what makes text feel like part of a game rather than part of a page.\n */\nfloat settle(float t) {\n if (t <= 0.0) return 0.0;\n if (t >= 1.0) return 1.0;\n float d = 1.0 - t;\n return 1.0 - d * d * cos(t * 12.0) ;\n}\n\nmat2 rot(float a) {\n float s = sin(a);\n float c = cos(a);\n return mat2(c, -s, s, c);\n}\n\nvoid main() {\n // Stagger: each character starts a fraction of the reveal later than the\n // one before it, so the message assembles left to right.\n float span = max(uCharCount, 1.0);\n float start = ((aCharIndex + uCharOffset) / span) * 0.55;\n float local = clamp((uReveal - start) / max(0.45, 1.0 - start), 0.0, 1.0);\n float eased = settle(local);\n\n // Arrives spinning about its own horizontal axis and dropping into place.\n float spin = uSpin * (1.0 - eased);\n float rise = (1.0 - eased) * uCellSize * 6.0;\n float punch = 1.0 + uPunch * (1.0 - abs(eased * 2.0 - 1.0));\n\n vec3 local3 = aCorner * uCellSize * 0.5 * punch;\n local3.yz = rot(spin) * local3.yz;\n\n vec2 cell = aCell * uCellSize;\n float bob = sin(uTime * 3.1 + aCharIndex * 0.7) * uBob;\n\n // The cell's centre in pixels, y down from the top-left corner of the viewport.\n vec2 centre = uOrigin + vec2(cell.x, -cell.y) + vec2(0.0, rise - bob);\n\n // A hand-built perspective divide about the cell's own centre, so every cube\n // is seen head-on: its front face at depth zero is exactly its cell, and at\n // rest its sides are behind it wherever on the screen it is drawn.\n //\n // It divided about the viewport's top-left corner until 2026-09-30, which\n // shifted a front face by its distance from that corner times z / uDepth and\n // showed the side it uncovered. Near the corner that was nothing; across the\n // screen it widened every stroke by up to a pixel either side and closed the\n // one-cell gaps a 5x7 face reads by. What this gives up is the extruded look\n // text far from the corner had at rest; what would make it wrong is a caller\n // wanting that look, which is then one offset for every cell, not a position.\n float invDepth = uDepth / max(0.001, uDepth - local3.z);\n vec2 screen = centre + local3.xy * invDepth;\n vec2 ndc = (screen / uViewport) * 2.0 - 1.0;\n // Depth from the cube's own extrusion, mapped into a narrow slice of the\n // range so a message sorts against itself without ever reaching the scene.\n float depth = clamp(-local3.z / max(1.0, uCellSize * 2.0), -1.0, 1.0) * 0.4;\n gl_Position = uClipCorrection * vec4(ndc.x, -ndc.y, depth, 1.0);\n\n vec3 n = aNormal;\n n.yz = rot(spin) * n.yz;\n vNormal = n;\n vDepth = local3.z;\n vChar = eased;\n}";
17
17
  export declare const TEXT_FRAG = "#version 300 es\nprecision highp float;\n\nin vec3 vNormal;\nin float vDepth;\nin float vChar;\n\nuniform vec3 uColor;\n/** Self-illumination on top of the shaded colour. */\nuniform float uGlow;\nuniform float uAlpha;\n\nout vec4 fragColor;\n\nvoid main() {\n /*\n * A fixed key light rather than the world's. Text has to stay legible at\n * midnight and at noon, and lighting it with the scene would make a\n * notification unreadable on exactly the night runs where it matters most.\n * The light only exists to give the cubes an edge.\n */\n vec3 key = normalize(vec3(-0.35, 0.7, 0.6));\n float lambert = 0.55 + 0.45 * max(dot(normalize(vNormal), key), 0.0);\n\n vec3 lit = uColor * lambert;\n // The glow rides the arrival: brightest as a character lands, settling after.\n vec3 emissive = uColor * uGlow * (0.45 + 0.55 * vChar);\n fragColor = vec4(lit + emissive, uAlpha);\n}";
@@ -27,7 +27,7 @@ uniform vec2 uViewport;
27
27
  /** Where the string's baseline-left sits, in pixels from the top left. */
28
28
  uniform vec2 uOrigin;
29
29
  uniform float uCellSize;
30
- /** Distance from the virtual camera; larger flattens the perspective. */
30
+ /** How far each cell is seen from, head-on; larger flattens the perspective. */
31
31
  uniform float uDepth;
32
32
 
33
33
  /** 0 before a character has arrived, 1 once it has settled. */
@@ -105,14 +105,23 @@ void main() {
105
105
  vec2 cell = aCell * uCellSize;
106
106
  float bob = sin(uTime * 3.1 + aCharIndex * 0.7) * uBob;
107
107
 
108
- // Pixels, y down from the top-left corner of the viewport.
109
- vec2 screen = uOrigin + vec2(cell.x, -cell.y) + vec2(0.0, rise - bob);
110
- vec3 view = vec3(screen + local3.xy, -uDepth + local3.z);
111
-
112
- // A hand-built perspective divide. The near plane is the viewport itself, so
113
- // a cell at depth uDepth is exactly its pixel size on screen.
114
- float invDepth = uDepth / max(0.001, -view.z);
115
- vec2 ndc = ((view.xy * invDepth) / uViewport) * 2.0 - 1.0;
108
+ // The cell's centre in pixels, y down from the top-left corner of the viewport.
109
+ vec2 centre = uOrigin + vec2(cell.x, -cell.y) + vec2(0.0, rise - bob);
110
+
111
+ // A hand-built perspective divide about the cell's own centre, so every cube
112
+ // is seen head-on: its front face at depth zero is exactly its cell, and at
113
+ // rest its sides are behind it wherever on the screen it is drawn.
114
+ //
115
+ // It divided about the viewport's top-left corner until 2026-09-30, which
116
+ // shifted a front face by its distance from that corner times z / uDepth and
117
+ // showed the side it uncovered. Near the corner that was nothing; across the
118
+ // screen it widened every stroke by up to a pixel either side and closed the
119
+ // one-cell gaps a 5x7 face reads by. What this gives up is the extruded look
120
+ // text far from the corner had at rest; what would make it wrong is a caller
121
+ // wanting that look, which is then one offset for every cell, not a position.
122
+ float invDepth = uDepth / max(0.001, uDepth - local3.z);
123
+ vec2 screen = centre + local3.xy * invDepth;
124
+ vec2 ndc = (screen / uViewport) * 2.0 - 1.0;
116
125
  // Depth from the cube's own extrusion, mapped into a narrow slice of the
117
126
  // range so a message sorts against itself without ever reaching the scene.
118
127
  float depth = clamp(-local3.z / max(1.0, uCellSize * 2.0), -1.0, 1.0) * 0.4;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * What each layer of a surface array does beyond its picture: rooms behind its windows, windows that
3
+ * light by night, wear, animation, and whether rain wets it.
4
+ *
5
+ * **Per layer, because a city region is one mesh.** Its facades are told apart by the layer each
6
+ * vertex carries (`MeshData.layers`), so an effect's parameters cannot be a per-draw uniform — two facades of one
7
+ * draw would share them. They are a table beside the array, six texels a layer, read at the vertex's
8
+ * layer with `texelFetch`, and they bind whenever the array does.
9
+ *
10
+ * **Everything off is all zeros**, so a layer a scene names no effect for — and every layer of an
11
+ * array given no table — costs the shader one size query and nothing else.
12
+ *
13
+ * Layout, six RGBA texels a layer, half floats on the GPU (every whole number here is under 2,048,
14
+ * which a half holds exactly):
15
+ * - 0: window cells across and up, glass mask (1: glass where the albedo's alpha is 0), seed;
16
+ * - 1: room layer (0: none; the layer plus one), room depth (0 to 1 of a cell), share of rooms lit,
17
+ * window glow;
18
+ * - 2: window colour, then 1 where rain leaves the surface dry;
19
+ * - 3: dust, grime, streaks, the metres over which wear fades out with distance (0: never);
20
+ * - 4: scroll along u and v in repeats a second, pulse rate in hertz, pulse depth;
21
+ * - 5: flicker, flipbook frames, flipbook frames a second, the metres past which emission fades
22
+ * (0: never).
23
+ */
24
+ /** Texels a layer takes in the table. */
25
+ export declare const SURFACE_EFFECT_TEXELS = 6;
26
+ export interface SurfaceLayerEffect {
27
+ /**
28
+ * Windows across and up one repeat of the layer, for everything that works window by window:
29
+ * rooms, lit windows. With `glass`, a window is a hole in the albedo — glass where its alpha is 0,
30
+ * wall where it is 1 — which is how a facade is drawn, and the other way round would lose the
31
+ * wall's colour, which does not survive an upload at alpha 0.
32
+ */
33
+ readonly windows?: {
34
+ readonly cells: readonly [number, number];
35
+ readonly glass?: boolean;
36
+ /** Varies which windows light and which rooms show, between facades wearing one layer. */
37
+ readonly seed?: number;
38
+ /** How bright a lit window glows, 0 for none. */
39
+ readonly glow?: number;
40
+ /**
41
+ * The light a window shows where there is no room behind it. **A vertex that names an emissive
42
+ * colour overrides it** — the window's colour, or a tint on the room where there is one — which
43
+ * is how buildings sharing a layer each keep their own light: a window layer's vertex emissive
44
+ * is otherwise unused, so the colour there, strength folded in, is free to say it.
45
+ */
46
+ readonly colour?: readonly [number, number, number];
47
+ };
48
+ /** A room seen in parallax behind each window, drawn from another layer of the same array. */
49
+ readonly interior?: {
50
+ readonly roomLayer: number;
51
+ /** How deep a room is, as a share of a window's width. 0.5 unless stated. */
52
+ readonly depth?: number;
53
+ /** The share of rooms lit, 0 to 1. 1 unless stated. */
54
+ readonly lit?: number;
55
+ };
56
+ /** Procedural wear, each 0 to 1, fading out past `fade` metres (never, unless stated). */
57
+ readonly wear?: {
58
+ readonly dust?: number;
59
+ readonly grime?: number;
60
+ readonly streaks?: number;
61
+ readonly fade?: number;
62
+ };
63
+ /** Motion on the caller's clock (`Environment.surfaceTime`), so a held clock holds it. */
64
+ readonly animation?: {
65
+ /** Repeats a second along u and v. */
66
+ readonly scroll?: readonly [number, number];
67
+ /** Emission rising and falling: hertz, and how far it falls, 0 to 1. */
68
+ readonly pulse?: readonly [number, number];
69
+ /** Emission stuttering, 0 to 1. */
70
+ readonly flicker?: number;
71
+ /** Successive layers as frames: how many from this one, and how fast. */
72
+ readonly flipbook?: readonly [number, number];
73
+ /** Metres past which emission fades out, 0 for never. */
74
+ readonly fade?: number;
75
+ };
76
+ /** Rain leaves it dry: a covered walkway, an interior floor. */
77
+ readonly dry?: boolean;
78
+ }
79
+ /**
80
+ * The table for `effects`, one entry a layer (`undefined` for a layer with none), as floats: six
81
+ * RGBA texels a layer, a row a layer.
82
+ */
83
+ export declare function packSurfaceEffects(effects: readonly (SurfaceLayerEffect | undefined)[]): Float32Array;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * What each layer of a surface array does beyond its picture: rooms behind its windows, windows that
3
+ * light by night, wear, animation, and whether rain wets it.
4
+ *
5
+ * **Per layer, because a city region is one mesh.** Its facades are told apart by the layer each
6
+ * vertex carries (`MeshData.layers`), so an effect's parameters cannot be a per-draw uniform — two facades of one
7
+ * draw would share them. They are a table beside the array, six texels a layer, read at the vertex's
8
+ * layer with `texelFetch`, and they bind whenever the array does.
9
+ *
10
+ * **Everything off is all zeros**, so a layer a scene names no effect for — and every layer of an
11
+ * array given no table — costs the shader one size query and nothing else.
12
+ *
13
+ * Layout, six RGBA texels a layer, half floats on the GPU (every whole number here is under 2,048,
14
+ * which a half holds exactly):
15
+ * - 0: window cells across and up, glass mask (1: glass where the albedo's alpha is 0), seed;
16
+ * - 1: room layer (0: none; the layer plus one), room depth (0 to 1 of a cell), share of rooms lit,
17
+ * window glow;
18
+ * - 2: window colour, then 1 where rain leaves the surface dry;
19
+ * - 3: dust, grime, streaks, the metres over which wear fades out with distance (0: never);
20
+ * - 4: scroll along u and v in repeats a second, pulse rate in hertz, pulse depth;
21
+ * - 5: flicker, flipbook frames, flipbook frames a second, the metres past which emission fades
22
+ * (0: never).
23
+ */
24
+ /** Texels a layer takes in the table. */
25
+ export const SURFACE_EFFECT_TEXELS = 6;
26
+ /**
27
+ * The table for `effects`, one entry a layer (`undefined` for a layer with none), as floats: six
28
+ * RGBA texels a layer, a row a layer.
29
+ */
30
+ export function packSurfaceEffects(effects) {
31
+ const out = new Float32Array(effects.length * SURFACE_EFFECT_TEXELS * 4);
32
+ effects.forEach((effect, layer) => {
33
+ if (effect === undefined)
34
+ return;
35
+ const at = layer * SURFACE_EFFECT_TEXELS * 4;
36
+ const windows = effect.windows;
37
+ if (windows !== undefined) {
38
+ out[at] = windows.cells[0];
39
+ out[at + 1] = windows.cells[1];
40
+ out[at + 2] = windows.glass === true ? 1 : 0;
41
+ out[at + 3] = windows.seed ?? 0;
42
+ out[at + 7] = windows.glow ?? 0;
43
+ const colour = windows.colour ?? [1, 0.82, 0.55];
44
+ out[at + 8] = colour[0];
45
+ out[at + 9] = colour[1];
46
+ out[at + 10] = colour[2];
47
+ }
48
+ const interior = effect.interior;
49
+ if (interior !== undefined) {
50
+ out[at + 4] = interior.roomLayer + 1;
51
+ out[at + 5] = interior.depth ?? 0.5;
52
+ out[at + 6] = interior.lit ?? 1;
53
+ }
54
+ out[at + 11] = effect.dry === true ? 1 : 0;
55
+ const wear = effect.wear;
56
+ if (wear !== undefined) {
57
+ out[at + 12] = wear.dust ?? 0;
58
+ out[at + 13] = wear.grime ?? 0;
59
+ out[at + 14] = wear.streaks ?? 0;
60
+ out[at + 15] = wear.fade ?? 0;
61
+ }
62
+ const animation = effect.animation;
63
+ if (animation !== undefined) {
64
+ out[at + 16] = animation.scroll?.[0] ?? 0;
65
+ out[at + 17] = animation.scroll?.[1] ?? 0;
66
+ out[at + 18] = animation.pulse?.[0] ?? 0;
67
+ out[at + 19] = animation.pulse?.[1] ?? 0;
68
+ out[at + 20] = animation.flicker ?? 0;
69
+ out[at + 21] = animation.flipbook?.[0] ?? 0;
70
+ out[at + 22] = animation.flipbook?.[1] ?? 0;
71
+ out[at + 23] = animation.fade ?? 0;
72
+ }
73
+ });
74
+ return out;
75
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The scene's surface clock and weather, decided once for both backends: the four numbers the lit
3
+ * stage reads as `uSurfaceScene`. Each backend only uploads them, so the two cannot disagree about
4
+ * what a wetness of 1.4 means — it means 1, as a share of lit windows past one means all of them.
5
+ */
6
+ import type { Environment } from './backend/webgl2/renderer.ts';
7
+ /** Fill `out`: seconds on the caller's clock, wetness, the lit share, the late share. */
8
+ export declare function resolveSurfaceScene(env: Environment, out: Float32Array): void;
@@ -0,0 +1,8 @@
1
+ const unit = (value) => Math.min(1, Math.max(0, value ?? 0));
2
+ /** Fill `out`: seconds on the caller's clock, wetness, the lit share, the late share. */
3
+ export function resolveSurfaceScene(env, out) {
4
+ out[0] = env.surfaceTime ?? 0;
5
+ out[1] = unit(env.wetness);
6
+ out[2] = unit(env.litWindows);
7
+ out[3] = unit(env.lateWindows);
8
+ }