@driftengine/core 4.8.6 → 4.9.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 (253) hide show
  1. package/CHANGELOG.json +132 -0
  2. package/README.md +1 -1
  3. package/dist/index.d.ts +7 -2
  4. package/dist/index.js +8 -1
  5. package/dist/render/anisotropicLobe.d.ts +10 -0
  6. package/dist/render/anisotropicLobe.js +19 -0
  7. package/dist/render/areaLights.d.ts +35 -0
  8. package/dist/render/areaLights.js +35 -0
  9. package/dist/render/atmosphere.d.ts +13 -0
  10. package/dist/render/atmosphere.js +9 -1
  11. package/dist/render/backend/api.d.ts +41 -3
  12. package/dist/render/backend/createRenderer.d.ts +6 -0
  13. package/dist/render/backend/createRenderer.js +4 -1
  14. package/dist/render/backend/webgl2/renderer.d.ts +140 -16
  15. package/dist/render/backend/webgl2/renderer.js +436 -44
  16. package/dist/render/backend/webgl2/sceneCaptureTarget.d.ts +29 -0
  17. package/dist/render/backend/webgl2/sceneCaptureTarget.js +96 -0
  18. package/dist/render/backend/webgpu/device.d.ts +7 -1
  19. package/dist/render/backend/webgpu/device.js +31 -3
  20. package/dist/render/backend/webgpu/flatPass.d.ts +16 -1
  21. package/dist/render/backend/webgpu/flatPass.js +58 -26
  22. package/dist/render/backend/webgpu/particlePass.js +2 -1
  23. package/dist/render/backend/webgpu/postPass.d.ts +51 -9
  24. package/dist/render/backend/webgpu/postPass.js +16 -2
  25. package/dist/render/backend/webgpu/reflectionSurfacePass.d.ts +30 -0
  26. package/dist/render/backend/webgpu/reflectionSurfacePass.js +100 -0
  27. package/dist/render/backend/webgpu/renderer.d.ts +166 -3
  28. package/dist/render/backend/webgpu/renderer.js +738 -83
  29. package/dist/render/backend/webgpu/sceneCaptureTarget.d.ts +24 -0
  30. package/dist/render/backend/webgpu/sceneCaptureTarget.js +59 -0
  31. package/dist/render/backend/webgpu/shaders/clusterBin.wgsl.d.ts +1 -1
  32. package/dist/render/backend/webgpu/surfaceTexturePass.d.ts +4 -1
  33. package/dist/render/backend/webgpu/surfaceTexturePass.js +34 -8
  34. package/dist/render/backend/webgpu/waterPass.d.ts +1 -1
  35. package/dist/render/clusteredLights.d.ts +23 -5
  36. package/dist/render/clusteredLights.js +35 -8
  37. package/dist/render/displayRange.d.ts +31 -0
  38. package/dist/render/displayRange.js +47 -0
  39. package/dist/render/driftLight/bake.d.ts +2 -0
  40. package/dist/render/driftLight/bake.js +1 -1
  41. package/dist/render/driftLight/falloff.d.ts +4 -2
  42. package/dist/render/driftLight/falloff.js +8 -2
  43. package/dist/render/filmicCurve.d.ts +74 -0
  44. package/dist/render/filmicCurve.js +203 -0
  45. package/dist/render/fog.d.ts +4 -1
  46. package/dist/render/fog.js +12 -2
  47. package/dist/render/frame/resources.d.ts +2 -2
  48. package/dist/render/frame/resources.js +6 -0
  49. package/dist/render/frameReflections.d.ts +40 -0
  50. package/dist/render/frameReflections.js +48 -0
  51. package/dist/render/gi/bounceLights.d.ts +3 -2
  52. package/dist/render/gi/bounceLights.js +8 -3
  53. package/dist/render/instanceCull.js +5 -0
  54. package/dist/render/instances.d.ts +19 -5
  55. package/dist/render/instances.js +9 -3
  56. package/dist/render/lightBudget.d.ts +12 -1
  57. package/dist/render/lightBudget.js +23 -3
  58. package/dist/render/lightmap.d.ts +23 -9
  59. package/dist/render/lightmap.js +40 -18
  60. package/dist/render/particlePool.d.ts +21 -0
  61. package/dist/render/particlePool.js +7 -0
  62. package/dist/render/pass.d.ts +49 -0
  63. package/dist/render/pointLightSelection.d.ts +13 -0
  64. package/dist/render/pointLightSelection.js +3 -1
  65. package/dist/render/reflectionSurfaceTargets.d.ts +28 -0
  66. package/dist/render/reflectionSurfaceTargets.js +99 -0
  67. package/dist/render/renderQuality.d.ts +47 -15
  68. package/dist/render/renderQuality.js +4 -0
  69. package/dist/render/rendererHarness.js +3 -0
  70. package/dist/render/rgb9e5.d.ts +22 -0
  71. package/dist/render/rgb9e5.js +47 -0
  72. package/dist/render/sceneCapture.d.ts +29 -0
  73. package/dist/render/sceneCapture.js +36 -0
  74. package/dist/render/sceneTarget.d.ts +5 -0
  75. package/dist/render/sceneTarget.js +24 -1
  76. package/dist/render/screenSpaceReflection.d.ts +32 -1
  77. package/dist/render/screenSpaceReflection.js +14 -0
  78. package/dist/render/shaders/arcane.d.ts +1 -1
  79. package/dist/render/shaders/bolt.d.ts +1 -1
  80. package/dist/render/shaders/bolt.js +7 -1
  81. package/dist/render/shaders/caustics.d.ts +1 -1
  82. package/dist/render/shaders/depth.js +11 -0
  83. package/dist/render/shaders/film.d.ts +1 -1
  84. package/dist/render/shaders/filmLook.js +2 -1
  85. package/dist/render/shaders/fire.d.ts +1 -1
  86. package/dist/render/shaders/flat/anisotropic.d.ts +1 -1
  87. package/dist/render/shaders/flat/anisotropic.js +37 -3
  88. package/dist/render/shaders/flat/areaLight.d.ts +1 -1
  89. package/dist/render/shaders/flat/areaLight.js +46 -0
  90. package/dist/render/shaders/flat/glassTint.d.ts +3 -2
  91. package/dist/render/shaders/flat/glassTint.js +5 -1
  92. package/dist/render/shaders/flat/index.d.ts +15 -0
  93. package/dist/render/shaders/flat/index.js +22 -2
  94. package/dist/render/shaders/flat/lobes.d.ts +6 -0
  95. package/dist/render/shaders/flat/lobes.js +49 -43
  96. package/dist/render/shaders/flat/main.d.ts +1 -1
  97. package/dist/render/shaders/flat/main.js +85 -22
  98. package/dist/render/shaders/flat/models.d.ts +5 -2
  99. package/dist/render/shaders/flat/models.js +100 -16
  100. package/dist/render/shaders/flat/overlay.d.ts +16 -0
  101. package/dist/render/shaders/flat/overlay.js +117 -0
  102. package/dist/render/shaders/flat/pointShadow.js +1 -1
  103. package/dist/render/shaders/flat/preamble.js +10 -3
  104. package/dist/render/shaders/flat/reflectionSurface.d.ts +21 -0
  105. package/dist/render/shaders/flat/reflectionSurface.js +61 -0
  106. package/dist/render/shaders/fog.d.ts +1 -1
  107. package/dist/render/shaders/fog.js +9 -2
  108. package/dist/render/shaders/fringe.d.ts +29 -0
  109. package/dist/render/shaders/fringe.js +55 -0
  110. package/dist/render/shaders/generated/arcane.wgsl.d.ts +1 -1
  111. package/dist/render/shaders/generated/arcane.wgsl.js +1 -1
  112. package/dist/render/shaders/generated/bolt.wgsl.d.ts +1 -1
  113. package/dist/render/shaders/generated/bolt.wgsl.js +1 -1
  114. package/dist/render/shaders/generated/caustics.wgsl.d.ts +1 -1
  115. package/dist/render/shaders/generated/caustics.wgsl.js +1 -1
  116. package/dist/render/shaders/generated/depth.wgsl.d.ts +4 -4
  117. package/dist/render/shaders/generated/depth.wgsl.js +4 -4
  118. package/dist/render/shaders/generated/film.wgsl.d.ts +1 -1
  119. package/dist/render/shaders/generated/film.wgsl.js +1 -1
  120. package/dist/render/shaders/generated/fire.wgsl.d.ts +1 -1
  121. package/dist/render/shaders/generated/fire.wgsl.js +1 -1
  122. package/dist/render/shaders/generated/flat.wgsl.d.ts +192 -16
  123. package/dist/render/shaders/generated/flat.wgsl.js +230 -54
  124. package/dist/render/shaders/generated/line.wgsl.d.ts +1 -1
  125. package/dist/render/shaders/generated/line.wgsl.js +1 -1
  126. package/dist/render/shaders/generated/particle.wgsl.d.ts +4 -4
  127. package/dist/render/shaders/generated/particle.wgsl.js +4 -4
  128. package/dist/render/shaders/generated/rush.wgsl.d.ts +56 -26
  129. package/dist/render/shaders/generated/rush.wgsl.js +56 -26
  130. package/dist/render/shaders/generated/scatter.wgsl.d.ts +1 -1
  131. package/dist/render/shaders/generated/scatter.wgsl.js +1 -1
  132. package/dist/render/shaders/generated/smoke.wgsl.d.ts +1 -1
  133. package/dist/render/shaders/generated/smoke.wgsl.js +1 -1
  134. package/dist/render/shaders/generated/ssrResolve.wgsl.d.ts +29 -0
  135. package/dist/render/shaders/generated/ssrResolve.wgsl.js +29 -0
  136. package/dist/render/shaders/generated/ssrTrace.wgsl.d.ts +32 -7
  137. package/dist/render/shaders/generated/ssrTrace.wgsl.js +32 -7
  138. package/dist/render/shaders/generated/water.wgsl.d.ts +23 -16
  139. package/dist/render/shaders/generated/water.wgsl.js +23 -16
  140. package/dist/render/shaders/gi/probeBake.wgsl.js +9 -5
  141. package/dist/render/shaders/gpudriven/fog.wgsl.d.ts +2 -2
  142. package/dist/render/shaders/gpudriven/fog.wgsl.js +11 -3
  143. package/dist/render/shaders/line.d.ts +1 -1
  144. package/dist/render/shaders/particle.d.ts +3 -3
  145. package/dist/render/shaders/recon/motion.wgsl.d.ts +1 -1
  146. package/dist/render/shaders/recon/motion.wgsl.js +13 -2
  147. package/dist/render/shaders/rush.js +54 -11
  148. package/dist/render/shaders/scatter.d.ts +1 -1
  149. package/dist/render/shaders/smoke.d.ts +1 -1
  150. package/dist/render/shaders/ssrResolve.d.ts +17 -0
  151. package/dist/render/shaders/ssrResolve.js +51 -0
  152. package/dist/render/shaders/ssrTrace.js +46 -1
  153. package/dist/render/shaders/water.d.ts +1 -1
  154. package/dist/render/shaders/water.js +12 -3
  155. package/dist/render/skinScatter.d.ts +3 -1
  156. package/dist/render/skinScatter.js +3 -1
  157. package/dist/render/ssrPass.d.ts +16 -0
  158. package/dist/render/ssrPass.js +107 -1
  159. package/dist/render/surfaceModel.d.ts +29 -8
  160. package/dist/render/surfaceModel.js +33 -9
  161. package/dist/render/surfaceOverlay.d.ts +132 -0
  162. package/dist/render/surfaceOverlay.js +85 -0
  163. package/dist/render/surfaceTexture.d.ts +44 -1
  164. package/dist/render/surfaceTexture.js +96 -5
  165. package/dist/render/temporalAa.d.ts +14 -0
  166. package/dist/render/temporalAa.js +20 -0
  167. package/dist/render/vertexDefaults.d.ts +17 -0
  168. package/dist/render/vertexDefaults.js +24 -0
  169. package/package.json +6 -6
  170. package/src/index.ts +22 -2
  171. package/src/render/anisotropicLobe.ts +29 -0
  172. package/src/render/areaLights.ts +58 -0
  173. package/src/render/atmosphere.ts +22 -1
  174. package/src/render/backend/api.ts +58 -2
  175. package/src/render/backend/createRenderer.ts +18 -1
  176. package/src/render/backend/webgl2/renderer.ts +548 -50
  177. package/src/render/backend/webgl2/sceneCaptureTarget.ts +130 -0
  178. package/src/render/backend/webgpu/device.ts +42 -3
  179. package/src/render/backend/webgpu/flatPass.ts +63 -26
  180. package/src/render/backend/webgpu/particlePass.ts +2 -1
  181. package/src/render/backend/webgpu/postPass.ts +18 -0
  182. package/src/render/backend/webgpu/reflectionSurfacePass.ts +114 -0
  183. package/src/render/backend/webgpu/renderer.ts +927 -100
  184. package/src/render/backend/webgpu/sceneCaptureTarget.ts +63 -0
  185. package/src/render/backend/webgpu/surfaceTexturePass.ts +44 -14
  186. package/src/render/clusteredLights.ts +41 -8
  187. package/src/render/displayRange.ts +61 -0
  188. package/src/render/driftLight/bake.ts +3 -1
  189. package/src/render/driftLight/falloff.ts +13 -2
  190. package/src/render/filmicCurve.ts +229 -0
  191. package/src/render/fog.ts +19 -3
  192. package/src/render/frame/resources.ts +6 -0
  193. package/src/render/frameReflections.ts +72 -0
  194. package/src/render/gi/bounceLights.ts +12 -3
  195. package/src/render/instanceCull.ts +4 -0
  196. package/src/render/instances.ts +26 -6
  197. package/src/render/lightBudget.ts +40 -2
  198. package/src/render/lightmap.ts +64 -23
  199. package/src/render/particlePool.ts +23 -0
  200. package/src/render/pass.ts +53 -1
  201. package/src/render/pointLightSelection.ts +20 -1
  202. package/src/render/reflectionSurfaceTargets.ts +105 -0
  203. package/src/render/renderQuality.ts +52 -15
  204. package/src/render/rendererHarness.ts +3 -0
  205. package/src/render/rgb9e5.ts +52 -0
  206. package/src/render/sceneCapture.ts +56 -0
  207. package/src/render/sceneTarget.ts +30 -2
  208. package/src/render/screenSpaceReflection.ts +38 -1
  209. package/src/render/shaders/bolt.ts +7 -1
  210. package/src/render/shaders/depth.ts +11 -0
  211. package/src/render/shaders/filmLook.ts +2 -1
  212. package/src/render/shaders/flat/anisotropic.ts +37 -3
  213. package/src/render/shaders/flat/areaLight.ts +46 -0
  214. package/src/render/shaders/flat/glassTint.ts +13 -2
  215. package/src/render/shaders/flat/index.ts +37 -2
  216. package/src/render/shaders/flat/lobes.ts +50 -43
  217. package/src/render/shaders/flat/main.ts +85 -22
  218. package/src/render/shaders/flat/models.ts +100 -16
  219. package/src/render/shaders/flat/overlay.ts +117 -0
  220. package/src/render/shaders/flat/pointShadow.ts +1 -1
  221. package/src/render/shaders/flat/preamble.ts +10 -3
  222. package/src/render/shaders/flat/reflectionSurface.ts +61 -0
  223. package/src/render/shaders/fog.ts +9 -2
  224. package/src/render/shaders/fringe.ts +59 -0
  225. package/src/render/shaders/generated/arcane.wgsl.ts +1 -1
  226. package/src/render/shaders/generated/bolt.wgsl.ts +1 -1
  227. package/src/render/shaders/generated/caustics.wgsl.ts +1 -1
  228. package/src/render/shaders/generated/depth.wgsl.ts +4 -4
  229. package/src/render/shaders/generated/film.wgsl.ts +1 -1
  230. package/src/render/shaders/generated/fire.wgsl.ts +1 -1
  231. package/src/render/shaders/generated/flat.wgsl.ts +230 -54
  232. package/src/render/shaders/generated/line.wgsl.ts +1 -1
  233. package/src/render/shaders/generated/particle.wgsl.ts +4 -4
  234. package/src/render/shaders/generated/rush.wgsl.ts +56 -26
  235. package/src/render/shaders/generated/scatter.wgsl.ts +1 -1
  236. package/src/render/shaders/generated/smoke.wgsl.ts +1 -1
  237. package/src/render/shaders/generated/ssrResolve.wgsl.ts +30 -0
  238. package/src/render/shaders/generated/ssrTrace.wgsl.ts +32 -7
  239. package/src/render/shaders/generated/water.wgsl.ts +23 -16
  240. package/src/render/shaders/gi/probeBake.wgsl.ts +9 -5
  241. package/src/render/shaders/gpudriven/fog.wgsl.ts +11 -3
  242. package/src/render/shaders/recon/motion.wgsl.ts +13 -2
  243. package/src/render/shaders/rush.ts +54 -11
  244. package/src/render/shaders/ssrResolve.ts +52 -0
  245. package/src/render/shaders/ssrTrace.ts +46 -1
  246. package/src/render/shaders/water.ts +12 -3
  247. package/src/render/skinScatter.ts +3 -1
  248. package/src/render/ssrPass.ts +139 -1
  249. package/src/render/surfaceModel.ts +47 -10
  250. package/src/render/surfaceOverlay.ts +195 -0
  251. package/src/render/surfaceTexture.ts +136 -9
  252. package/src/render/temporalAa.ts +20 -0
  253. package/src/render/vertexDefaults.ts +28 -0
@@ -0,0 +1,24 @@
1
+ /**
2
+ * A scene capture's attachments on WebGPU: the depth and, above one sample, the multisampled colour
3
+ * its pass draws into and resolves from — the capture's own texture being the resolve, since **a
4
+ * texture cannot be both multisampled and sampled**. At the world's sample count and format, because
5
+ * a capture draws the world with the world's pipelines: a pipeline whose count or format disagrees
6
+ * with its attachment is rejected at `finish` and takes the whole capture with it. Remade only if
7
+ * either has changed since. See `sceneCapture.ts` for what a capture is.
8
+ */
9
+ export declare class GpuSceneCaptureTarget {
10
+ private readonly device;
11
+ readonly width: number;
12
+ readonly height: number;
13
+ private depth;
14
+ private depthView;
15
+ private colorMsaa;
16
+ private colorMsaaView;
17
+ private key;
18
+ constructor(device: GPUDevice, width: number, height: number);
19
+ attachments(samples: number, format: GPUTextureFormat): {
20
+ depth: GPUTextureView;
21
+ colorMsaa: GPUTextureView | null;
22
+ };
23
+ dispose(): void;
24
+ }
@@ -0,0 +1,59 @@
1
+ import { DEPTH_FORMAT } from '../../depthConvention.js';
2
+ /**
3
+ * A scene capture's attachments on WebGPU: the depth and, above one sample, the multisampled colour
4
+ * its pass draws into and resolves from — the capture's own texture being the resolve, since **a
5
+ * texture cannot be both multisampled and sampled**. At the world's sample count and format, because
6
+ * a capture draws the world with the world's pipelines: a pipeline whose count or format disagrees
7
+ * with its attachment is rejected at `finish` and takes the whole capture with it. Remade only if
8
+ * either has changed since. See `sceneCapture.ts` for what a capture is.
9
+ */
10
+ export class GpuSceneCaptureTarget {
11
+ device;
12
+ width;
13
+ height;
14
+ depth = null;
15
+ depthView = null;
16
+ colorMsaa = null;
17
+ colorMsaaView = null;
18
+ key = '';
19
+ constructor(device, width, height) {
20
+ this.device = device;
21
+ this.width = width;
22
+ this.height = height;
23
+ }
24
+ attachments(samples, format) {
25
+ const key = `${samples}:${format}`;
26
+ if (this.depthView === null || key !== this.key) {
27
+ this.dispose();
28
+ this.key = key;
29
+ this.depth = this.device.createTexture({
30
+ label: 'capture.depth',
31
+ size: [this.width, this.height],
32
+ format: DEPTH_FORMAT,
33
+ sampleCount: samples,
34
+ usage: 0x10, // RENDER_ATTACHMENT
35
+ });
36
+ this.depthView = this.depth.createView();
37
+ this.colorMsaa =
38
+ samples > 1
39
+ ? this.device.createTexture({
40
+ label: 'capture.colorMsaa',
41
+ size: [this.width, this.height],
42
+ format,
43
+ sampleCount: samples,
44
+ usage: 0x10, // RENDER_ATTACHMENT
45
+ })
46
+ : null;
47
+ this.colorMsaaView = this.colorMsaa?.createView() ?? null;
48
+ }
49
+ return { depth: this.depthView, colorMsaa: this.colorMsaaView };
50
+ }
51
+ dispose() {
52
+ this.depth?.destroy();
53
+ this.colorMsaa?.destroy();
54
+ this.depth = null;
55
+ this.depthView = null;
56
+ this.colorMsaa = null;
57
+ this.colorMsaaView = null;
58
+ }
59
+ }
@@ -20,4 +20,4 @@
20
20
  * sort. `scripts/cluster-check.mjs` is what holds that, and the 2026-08-17 rule about two
21
21
  * implementations of one decision is why it exists at all.
22
22
  */
23
- export declare const CLUSTER_BIN_WGSL = "\nconst CLUSTER_X: u32 = 16u;\nconst CLUSTER_Y: u32 = 9u;\nconst CLUSTER_Z: u32 = 24u;\nconst CLUSTER_COUNT: u32 = 3456u;\nconst CLUSTER_TEXELS: u32 = 20u;\nconst MAX_LIGHTS_PER_CLUSTER: u32 = 76u;\nconst LIGHT_TEXELS: u32 = 5u;\nconst LIGHT_REGION_TEXELS: u32 = 1600u;\nconst TABLE_WIDTH: u32 = 320u;\n\nstruct Params {\n view: mat4x4<f32>,\n /** near, far, tan(fovY/2), aspect. */\n frustum: vec4<f32>,\n lightCount: u32,\n pad0: u32,\n pad1: u32,\n pad2: u32,\n}\n\n@group(0) @binding(0) var<uniform> params: Params;\n/**\n * The records as `writeLightRecord` lays them out, as bits. **`u32`, not `f32`**, because one\n * slot holds two halves, and a zero weight read as a float would be a subnormal a GPU is free to\n * flush on the way through; read as bits, the copy into the table cannot change them.\n */\n@group(0) @binding(1) var<storage, read> lights: array<vec4<u32>>;\n/**\n * Write-only, which is all core WGSL offers and all the fixed-slot layout needs.\n *\n * A read-modify-write list would need a storage buffer and an atomic counter, and it is the reason\n * a cluster owns a fixed run rather than appending into a shared one.\n */\n@group(0) @binding(2) var table: texture_storage_2d<rgba32uint, write>;\n\n/** A flat texel index to its place in the table. TABLE_WIDTH divides both regions exactly. */\nfn texelAt(texel: u32) -> vec2<i32> {\n return vec2<i32>(i32(texel % TABLE_WIDTH), i32(texel / TABLE_WIDTH));\n}\n\nfn sliceNearDepth(slice: f32) -> f32 {\n return params.frustum.x * pow(params.frustum.y / params.frustum.x, slice / f32(CLUSTER_Z));\n}\n\n/** Squared distance from a point to an axis-aligned box, zero inside it. */\nfn distanceSqToBounds(p: vec3<f32>, lo: vec3<f32>, hi: vec3<f32>) -> f32 {\n let d = max(max(lo - p, p - hi), vec3<f32>(0.0));\n return dot(d, d);\n}\n\n/** A light's view-space centre, with depth positive along the view direction. */\nfn viewOf(light: u32) -> vec3<f32> {\n let world = bitcast<vec4<f32>>(lights[light * LIGHT_TEXELS]);\n let v = params.view * vec4<f32>(world.xyz, 1.0);\n return vec3<f32>(v.x, v.y, -v.z);\n}\n\n/**\n * Squared distance from a light's view-space centre to a point.\n *\n * **Squared, matching `centreDistanceSq` in `clusteredLights.ts` rather than taking a root.** The\n * ranking is identical either way, and a square root is one more operation for the two\n * implementations to round differently.\n */\nfn centreDistanceSq(light: u32, centre: vec3<f32>) -> f32 {\n let d = viewOf(light) - centre;\n return dot(d, d);\n}\n\n/**\n * Whether a spot's cone misses this froxel: `coneBoxGap` in `clusteredLights.ts`, the same\n * arithmetic in the same order \u2014 the froxel's bounding sphere against the infinite cone, its apex\n * included, which can keep a light the shader would not draw and never drop one it would. A light\n * with no cone carries a cosine of -2 and never reaches the test.\n */\nfn outsideCone(light: u32, apex: vec3<f32>, lo: vec3<f32>, hi: vec3<f32>) -> bool {\n let cosOuter = bitcast<f32>(lights[light * LIGHT_TEXELS + 3u].y);\n if (cosOuter <= -1.0) {\n return false;\n }\n let world = bitcast<vec3<f32>>(lights[light * LIGHT_TEXELS + 2u].yzw);\n let a = (params.view * vec4<f32>(world, 0.0)).xyz;\n let axis = vec3<f32>(a.x, a.y, -a.z);\n let len = length(axis);\n /* The shader measures the cone against the direction as given, so a long one is a wider cone. */\n if (len <= 1e-6 || cosOuter / len <= -1.0) {\n return false;\n }\n let cosA = cosOuter / len;\n let sinA = sqrt(max(0.0, 1.0 - cosA * cosA));\n let unit = axis / len;\n let extent = (hi - lo) * 0.5;\n let toCentre = (lo + hi) * 0.5 - apex;\n let along = dot(toCentre, unit);\n let lengthSq = dot(toCentre, toCentre);\n let across = sqrt(max(lengthSq - along * along, 0.0));\n let reach = sqrt(lengthSq);\n var distance = cosA * across - along * sinA;\n if (along < -sinA * reach) {\n distance = reach;\n }\n return distance - length(extent) > 0.0;\n}\n\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n let id = gid.x;\n\n /*\n * The light records, written by the first invocations that have one.\n *\n * **Two jobs in one kernel, and it is deliberate.** The records have to reach the same texture\n * the clusters do, because the lit pass reads both through one texture unit; writing them from\n * the CPU instead would mean a partial texture upload racing a dispatch over the same resource.\n * There are always more clusters than lights, so every record has an invocation.\n */\n if (id < params.lightCount) {\n let record = id * LIGHT_TEXELS;\n for (var t: u32 = 0u; t < LIGHT_TEXELS; t = t + 1u) {\n textureStore(table, texelAt(record + t), lights[record + t]);\n }\n }\n\n if (id >= CLUSTER_COUNT) {\n return;\n }\n\n let k = id / (CLUSTER_X * CLUSTER_Y);\n let j = (id - k * CLUSTER_X * CLUSTER_Y) / CLUSTER_X;\n let i = id - k * CLUSTER_X * CLUSTER_Y - j * CLUSTER_X;\n\n /* The AABB of the froxel's eight corners, exactly as `clusterViewBounds` derives it. */\n let zNear = sliceNearDepth(f32(k));\n let zFar = sliceNearDepth(f32(k + 1u));\n let xLo = -1.0 + 2.0 * f32(i) / f32(CLUSTER_X);\n let xHi = -1.0 + 2.0 * f32(i + 1u) / f32(CLUSTER_X);\n let yLo = -1.0 + 2.0 * f32(j) / f32(CLUSTER_Y);\n let yHi = -1.0 + 2.0 * f32(j + 1u) / f32(CLUSTER_Y);\n let halfHNear = zNear * params.frustum.z;\n let halfHFar = zFar * params.frustum.z;\n let halfWNear = halfHNear * params.frustum.w;\n let halfWFar = halfHFar * params.frustum.w;\n\n let lo = vec3<f32>(\n min(min(xLo * halfWNear, xLo * halfWFar), min(xHi * halfWNear, xHi * halfWFar)),\n min(min(yLo * halfHNear, yLo * halfHFar), min(yHi * halfHNear, yHi * halfHFar)),\n zNear,\n );\n let hi = vec3<f32>(\n max(max(xLo * halfWNear, xLo * halfWFar), max(xHi * halfWNear, xHi * halfWFar)),\n max(max(yLo * halfHNear, yLo * halfHFar), max(yHi * halfHNear, yHi * halfHFar)),\n zFar,\n );\n let centre = (lo + hi) * 0.5;\n\n var indices: array<u32, MAX_LIGHTS_PER_CLUSTER>;\n for (var n: u32 = 0u; n < MAX_LIGHTS_PER_CLUSTER; n = n + 1u) {\n indices[n] = 0u;\n }\n var count: u32 = 0u;\n\n /* Increasing light index, which is what makes the list sorted without a sort. */\n for (var light: u32 = 0u; light < params.lightCount; light = light + 1u) {\n /* A rectangle carries its reach negated, which is how the lit pass tells it from a lamp. */\n let signedRadius = bitcast<f32>(lights[light * LIGHT_TEXELS].w);\n let radius = abs(signedRadius);\n let v = viewOf(light);\n if (v.z + radius <= 0.0) {\n continue;\n }\n if (distanceSqToBounds(v, lo, hi) > radius * radius) {\n continue;\n }\n if (signedRadius >= 0.0 && outsideCone(light, v, lo, hi)) {\n continue;\n }\n\n if (count < MAX_LIGHTS_PER_CLUSTER) {\n indices[count] = light;\n count = count + 1u;\n continue;\n }\n\n /*\n * Full. Drop the farthest from the cluster's centre if this one is nearer.\n *\n * **The only float comparison that decides anything here**, and the reason the *order* is an\n * integer: a float rank cannot be relied on to agree between this and the JavaScript binner,\n * so it is confined to membership, where the conformance script forgives a light sitting\n * within epsilon of a boundary.\n */\n var worstSlot: i32 = -1;\n var worst = centreDistanceSq(light, centre);\n for (var n: u32 = 0u; n < MAX_LIGHTS_PER_CLUSTER; n = n + 1u) {\n let d = centreDistanceSq(indices[n], centre);\n if (d > worst) {\n worst = d;\n worstSlot = i32(n);\n }\n }\n if (worstSlot < 0) {\n continue;\n }\n indices[u32(worstSlot)] = light;\n\n /* Insertion sort over at most 28 integers, so the bytes stay ascending after a replacement. */\n for (var n: u32 = 1u; n < MAX_LIGHTS_PER_CLUSTER; n = n + 1u) {\n let value = indices[n];\n var m: i32 = i32(n) - 1;\n while (m >= 0 && indices[u32(m)] > value) {\n indices[u32(m) + 1u] = indices[u32(m)];\n m = m - 1;\n }\n indices[u32(m + 1)] = value;\n }\n }\n\n let base = LIGHT_REGION_TEXELS + id * CLUSTER_TEXELS;\n textureStore(table, texelAt(base), vec4<u32>(count, 0u, 0u, 0u));\n for (var t: u32 = 0u; t < CLUSTER_TEXELS - 1u; t = t + 1u) {\n textureStore(\n table,\n texelAt(base + 1u + t),\n vec4<u32>(indices[t * 4u], indices[t * 4u + 1u], indices[t * 4u + 2u], indices[t * 4u + 3u]),\n );\n }\n}\n";
23
+ export declare const CLUSTER_BIN_WGSL = "\nconst CLUSTER_X: u32 = 16u;\nconst CLUSTER_Y: u32 = 9u;\nconst CLUSTER_Z: u32 = 24u;\nconst CLUSTER_COUNT: u32 = 3456u;\nconst CLUSTER_TEXELS: u32 = 20u;\nconst MAX_LIGHTS_PER_CLUSTER: u32 = 76u;\nconst LIGHT_TEXELS: u32 = 6u;\nconst LIGHT_REGION_TEXELS: u32 = 1920u;\nconst TABLE_WIDTH: u32 = 320u;\n\nstruct Params {\n view: mat4x4<f32>,\n /** near, far, tan(fovY/2), aspect. */\n frustum: vec4<f32>,\n lightCount: u32,\n pad0: u32,\n pad1: u32,\n pad2: u32,\n}\n\n@group(0) @binding(0) var<uniform> params: Params;\n/**\n * The records as `writeLightRecord` lays them out, as bits. **`u32`, not `f32`**, because one\n * slot holds two halves, and a zero weight read as a float would be a subnormal a GPU is free to\n * flush on the way through; read as bits, the copy into the table cannot change them.\n */\n@group(0) @binding(1) var<storage, read> lights: array<vec4<u32>>;\n/**\n * Write-only, which is all core WGSL offers and all the fixed-slot layout needs.\n *\n * A read-modify-write list would need a storage buffer and an atomic counter, and it is the reason\n * a cluster owns a fixed run rather than appending into a shared one.\n */\n@group(0) @binding(2) var table: texture_storage_2d<rgba32uint, write>;\n\n/** A flat texel index to its place in the table. TABLE_WIDTH divides both regions exactly. */\nfn texelAt(texel: u32) -> vec2<i32> {\n return vec2<i32>(i32(texel % TABLE_WIDTH), i32(texel / TABLE_WIDTH));\n}\n\nfn sliceNearDepth(slice: f32) -> f32 {\n return params.frustum.x * pow(params.frustum.y / params.frustum.x, slice / f32(CLUSTER_Z));\n}\n\n/** Squared distance from a point to an axis-aligned box, zero inside it. */\nfn distanceSqToBounds(p: vec3<f32>, lo: vec3<f32>, hi: vec3<f32>) -> f32 {\n let d = max(max(lo - p, p - hi), vec3<f32>(0.0));\n return dot(d, d);\n}\n\n/** A light's view-space centre, with depth positive along the view direction. */\nfn viewOf(light: u32) -> vec3<f32> {\n let world = bitcast<vec4<f32>>(lights[light * LIGHT_TEXELS]);\n let v = params.view * vec4<f32>(world.xyz, 1.0);\n return vec3<f32>(v.x, v.y, -v.z);\n}\n\n/**\n * Squared distance from a light's view-space centre to a point.\n *\n * **Squared, matching `centreDistanceSq` in `clusteredLights.ts` rather than taking a root.** The\n * ranking is identical either way, and a square root is one more operation for the two\n * implementations to round differently.\n */\nfn centreDistanceSq(light: u32, centre: vec3<f32>) -> f32 {\n let d = viewOf(light) - centre;\n return dot(d, d);\n}\n\n/**\n * Whether a spot's cone misses this froxel: `coneBoxGap` in `clusteredLights.ts`, the same\n * arithmetic in the same order \u2014 the froxel's bounding sphere against the infinite cone, its apex\n * included, which can keep a light the shader would not draw and never drop one it would. A light\n * with no cone carries a cosine of -2 and never reaches the test.\n */\nfn outsideCone(light: u32, apex: vec3<f32>, lo: vec3<f32>, hi: vec3<f32>) -> bool {\n let cosOuter = bitcast<f32>(lights[light * LIGHT_TEXELS + 3u].y);\n if (cosOuter <= -1.0) {\n return false;\n }\n let world = bitcast<vec3<f32>>(lights[light * LIGHT_TEXELS + 2u].yzw);\n let a = (params.view * vec4<f32>(world, 0.0)).xyz;\n let axis = vec3<f32>(a.x, a.y, -a.z);\n let len = length(axis);\n /* The shader measures the cone against the direction as given, so a long one is a wider cone. */\n if (len <= 1e-6 || cosOuter / len <= -1.0) {\n return false;\n }\n let cosA = cosOuter / len;\n let sinA = sqrt(max(0.0, 1.0 - cosA * cosA));\n let unit = axis / len;\n let extent = (hi - lo) * 0.5;\n let toCentre = (lo + hi) * 0.5 - apex;\n let along = dot(toCentre, unit);\n let lengthSq = dot(toCentre, toCentre);\n let across = sqrt(max(lengthSq - along * along, 0.0));\n let reach = sqrt(lengthSq);\n var distance = cosA * across - along * sinA;\n if (along < -sinA * reach) {\n distance = reach;\n }\n return distance - length(extent) > 0.0;\n}\n\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n let id = gid.x;\n\n /*\n * The light records, written by the first invocations that have one.\n *\n * **Two jobs in one kernel, and it is deliberate.** The records have to reach the same texture\n * the clusters do, because the lit pass reads both through one texture unit; writing them from\n * the CPU instead would mean a partial texture upload racing a dispatch over the same resource.\n * There are always more clusters than lights, so every record has an invocation.\n */\n if (id < params.lightCount) {\n let record = id * LIGHT_TEXELS;\n for (var t: u32 = 0u; t < LIGHT_TEXELS; t = t + 1u) {\n textureStore(table, texelAt(record + t), lights[record + t]);\n }\n }\n\n if (id >= CLUSTER_COUNT) {\n return;\n }\n\n let k = id / (CLUSTER_X * CLUSTER_Y);\n let j = (id - k * CLUSTER_X * CLUSTER_Y) / CLUSTER_X;\n let i = id - k * CLUSTER_X * CLUSTER_Y - j * CLUSTER_X;\n\n /* The AABB of the froxel's eight corners, exactly as `clusterViewBounds` derives it. */\n let zNear = sliceNearDepth(f32(k));\n let zFar = sliceNearDepth(f32(k + 1u));\n let xLo = -1.0 + 2.0 * f32(i) / f32(CLUSTER_X);\n let xHi = -1.0 + 2.0 * f32(i + 1u) / f32(CLUSTER_X);\n let yLo = -1.0 + 2.0 * f32(j) / f32(CLUSTER_Y);\n let yHi = -1.0 + 2.0 * f32(j + 1u) / f32(CLUSTER_Y);\n let halfHNear = zNear * params.frustum.z;\n let halfHFar = zFar * params.frustum.z;\n let halfWNear = halfHNear * params.frustum.w;\n let halfWFar = halfHFar * params.frustum.w;\n\n let lo = vec3<f32>(\n min(min(xLo * halfWNear, xLo * halfWFar), min(xHi * halfWNear, xHi * halfWFar)),\n min(min(yLo * halfHNear, yLo * halfHFar), min(yHi * halfHNear, yHi * halfHFar)),\n zNear,\n );\n let hi = vec3<f32>(\n max(max(xLo * halfWNear, xLo * halfWFar), max(xHi * halfWNear, xHi * halfWFar)),\n max(max(yLo * halfHNear, yLo * halfHFar), max(yHi * halfHNear, yHi * halfHFar)),\n zFar,\n );\n let centre = (lo + hi) * 0.5;\n\n var indices: array<u32, MAX_LIGHTS_PER_CLUSTER>;\n for (var n: u32 = 0u; n < MAX_LIGHTS_PER_CLUSTER; n = n + 1u) {\n indices[n] = 0u;\n }\n var count: u32 = 0u;\n\n /* Increasing light index, which is what makes the list sorted without a sort. */\n for (var light: u32 = 0u; light < params.lightCount; light = light + 1u) {\n /* A rectangle carries its reach negated, which is how the lit pass tells it from a lamp. */\n let signedRadius = bitcast<f32>(lights[light * LIGHT_TEXELS].w);\n let radius = abs(signedRadius);\n let v = viewOf(light);\n if (v.z + radius <= 0.0) {\n continue;\n }\n if (distanceSqToBounds(v, lo, hi) > radius * radius) {\n continue;\n }\n if (signedRadius >= 0.0 && outsideCone(light, v, lo, hi)) {\n continue;\n }\n\n if (count < MAX_LIGHTS_PER_CLUSTER) {\n indices[count] = light;\n count = count + 1u;\n continue;\n }\n\n /*\n * Full. Drop the farthest from the cluster's centre if this one is nearer.\n *\n * **The only float comparison that decides anything here**, and the reason the *order* is an\n * integer: a float rank cannot be relied on to agree between this and the JavaScript binner,\n * so it is confined to membership, where the conformance script forgives a light sitting\n * within epsilon of a boundary.\n */\n var worstSlot: i32 = -1;\n var worst = centreDistanceSq(light, centre);\n for (var n: u32 = 0u; n < MAX_LIGHTS_PER_CLUSTER; n = n + 1u) {\n let d = centreDistanceSq(indices[n], centre);\n if (d > worst) {\n worst = d;\n worstSlot = i32(n);\n }\n }\n if (worstSlot < 0) {\n continue;\n }\n indices[u32(worstSlot)] = light;\n\n /* Insertion sort over at most 28 integers, so the bytes stay ascending after a replacement. */\n for (var n: u32 = 1u; n < MAX_LIGHTS_PER_CLUSTER; n = n + 1u) {\n let value = indices[n];\n var m: i32 = i32(n) - 1;\n while (m >= 0 && indices[u32(m)] > value) {\n indices[u32(m) + 1u] = indices[u32(m)];\n m = m - 1;\n }\n indices[u32(m + 1)] = value;\n }\n }\n\n let base = LIGHT_REGION_TEXELS + id * CLUSTER_TEXELS;\n textureStore(table, texelAt(base), vec4<u32>(count, 0u, 0u, 0u));\n for (var t: u32 = 0u; t < CLUSTER_TEXELS - 1u; t = t + 1u) {\n textureStore(\n table,\n texelAt(base + 1u + t),\n vec4<u32>(indices[t * 4u], indices[t * 4u + 1u], indices[t * 4u + 2u], indices[t * 4u + 3u]),\n );\n }\n}\n";
@@ -1,5 +1,6 @@
1
1
  import type { SurfaceTextureOptions } from '../../surfaceTexture.ts';
2
2
  import type { LightmapTexels } from '../../lightmap.ts';
3
+ import type { SceneCaptureTexels } from '../../sceneCapture.ts';
3
4
  import type { CompressedTextureFormat, SurfaceSource } from '../../compressedSource.ts';
4
5
  import type { PipelineCache } from './pipelineCache.ts';
5
6
  /** How many levels an image of this size has, counting the one it arrives with. */
@@ -63,11 +64,13 @@ export declare class GpuSurfaceTexture {
63
64
  effectsView: GPUTextureView | null;
64
65
  /** The block format a compressed texture holds, or null for one uploaded from images. */
65
66
  private readonly blockFormat;
66
- constructor(device: GPUDevice, pipelines: PipelineCache, source: SurfaceSource | readonly SurfaceSource[] | LightmapTexels, options?: SurfaceTextureOptions, compressed?: readonly CompressedTextureFormat[]);
67
+ constructor(device: GPUDevice, pipelines: PipelineCache, source: SurfaceSource | readonly SurfaceSource[] | LightmapTexels | SceneCaptureTexels, options?: SurfaceTextureOptions, compressed?: readonly CompressedTextureFormat[]);
67
68
  /** A view of every layer, as the shaders' `texture_2d_array` bindings read it. */
68
69
  private arrayView;
69
70
  /** The view every binding of this image holds. A new one when `update` changes the size. */
70
71
  get view(): GPUTextureView;
72
+ /** The only layer as a plain 2D view, which a scene capture's pass resolves into. */
73
+ layerView(): GPUTextureView | null;
71
74
  /**
72
75
  * Replace the pixels, keeping the handle and its sampler.
73
76
  *
@@ -1,6 +1,7 @@
1
1
  import { SURFACE_EFFECT_TEXELS, packSurfaceEffects } from '../../surfaceEffects.js';
2
2
  import { toHalfFloats } from '../../halfFloat.js';
3
3
  import { isLightmapTexels } from '../../lightmap.js';
4
+ import { isSceneCaptureTexels } from '../../sceneCapture.js';
4
5
  import { layerSize, refuseArrayUpdate, sourceSize } from '../../textureSource.js';
5
6
  import { compressedLayers, planBlocks, refuseBlockUpdate } from '../../compressedSource.js';
6
7
  import { createCompressedTexture } from './compressedUpload.js';
@@ -156,24 +157,44 @@ export class GpuSurfaceTexture {
156
157
  constructor(device, pipelines, source, options = {}, compressed = []) {
157
158
  this.device = device;
158
159
  this.pipelines = pipelines;
159
- /* A lightmap's page: half floats, uploaded as they are. See `lightmap.ts`. */
160
+ /* A scene capture's target: one empty layer the world is drawn into. See `sceneCapture.ts`. */
161
+ const capture = isSceneCaptureTexels(source) ? source : null;
162
+ /* A lightmap's page: two layers of bytes, uploaded as they are. See `lightmap.ts`. */
160
163
  const baked = isLightmapTexels(source) ? source : null;
161
- const listed = (baked !== null ? [] : Array.isArray(source) ? source : [source]);
164
+ const listed = (baked !== null || capture !== null ? [] : Array.isArray(source) ? source : [source]);
162
165
  const blocks = compressedLayers(listed);
163
166
  const srgb = (options.colorSpace ?? 'linear') === 'srgb';
164
167
  /* `SRGB8_ALPHA8`'s equivalent. Decoded in the sampler, before filtering, which is the only
165
- place it is correct — `surfaceTexture.ts` makes the argument in full. */
166
- this.format = baked !== null ? 'rgba16float' : srgb ? 'rgba8unorm-srgb' : 'rgba8unorm';
167
- if (baked !== null) {
168
+ place it is correct — `surfaceTexture.ts` makes the argument in full. A capture takes the
169
+ world's own format, which its pass resolves into. */
170
+ this.format =
171
+ capture !== null
172
+ ? pipelines.format
173
+ : srgb && baked === null
174
+ ? 'rgba8unorm-srgb'
175
+ : 'rgba8unorm';
176
+ if (capture !== null) {
177
+ this.layers = 1;
178
+ this.blockFormat = null;
179
+ this.mipmapped = false;
180
+ this.width = capture.width;
181
+ this.height = capture.height;
182
+ this.levels = 1;
183
+ this.texture = this.allocate();
184
+ }
185
+ else if (baked !== null) {
168
186
  /* No chain: a page is read at its own resolution, and a level of it would blend regions. */
169
- this.layers = baked.layers;
187
+ this.layers = baked.layers.length;
170
188
  this.blockFormat = null;
171
189
  this.mipmapped = false;
172
190
  this.width = baked.width;
173
191
  this.height = baked.height;
174
192
  this.levels = 1;
175
193
  this.texture = this.allocate();
176
- device.queue.writeTexture({ texture: this.texture }, baked.texels, { bytesPerRow: baked.width * 8, rowsPerImage: baked.height }, { width: baked.width, height: baked.height, depthOrArrayLayers: baked.layers });
194
+ /* A layer at a time, so a page's arrays are uploaded where they lie rather than joined. */
195
+ for (let layer = 0; layer < this.layers; layer++) {
196
+ device.queue.writeTexture({ texture: this.texture, origin: [0, 0, layer] }, baked.layers[layer], { bytesPerRow: baked.width * 4, rowsPerImage: baked.height }, { width: baked.width, height: baked.height, depthOrArrayLayers: 1 });
197
+ }
177
198
  }
178
199
  else if (blocks !== null) {
179
200
  /* Refused before anything is allocated; the stored chain, or level 0 alone where no chain
@@ -230,8 +251,9 @@ export class GpuSurfaceTexture {
230
251
  ? Math.max(1, Math.floor(options.anisotropy ?? DEFAULT_ANISOTROPY))
231
252
  : 1,
232
253
  });
233
- if (blocks === null && baked === null)
254
+ if (blocks === null && baked === null && capture === null) {
234
255
  this.upload(listed);
256
+ }
235
257
  /* Half floats, as `renderer.ts` uploads it: filterable, so the ordinary float layout takes it,
236
258
  and read by `textureLoad` alone, so the filter never runs. */
237
259
  const effects = options.effects ?? [];
@@ -255,6 +277,10 @@ export class GpuSurfaceTexture {
255
277
  get view() {
256
278
  return this.current;
257
279
  }
280
+ /** The only layer as a plain 2D view, which a scene capture's pass resolves into. */
281
+ layerView() {
282
+ return (this.texture?.createView({ dimension: '2d', baseArrayLayer: 0, arrayLayerCount: 1 }) ?? null);
283
+ }
258
284
  /**
259
285
  * Replace the pixels, keeping the handle and its sampler.
260
286
  *
@@ -11,7 +11,7 @@ import type { UniformFields } from './scatterPass.ts';
11
11
  export declare const WATER_VERT_FIELDS: UniformFields;
12
12
  export declare const WATER_VERT_SIZE: 208;
13
13
  export declare const WATER_FRAG_FIELDS: UniformFields;
14
- export declare const WATER_FRAG_SIZE: 1248;
14
+ export declare const WATER_FRAG_SIZE: 1504;
15
15
  /** The two meshes and the numbers the grid decided, behind the surface's opaque handle. */
16
16
  export interface GpuWater {
17
17
  /** The ocean: near sheet and far skirt in one buffer. */
@@ -85,7 +85,7 @@ export declare const MAX_CLUSTERED_LIGHTS = 320;
85
85
  * it cannot. That is what lets a light index the shadow uniforms by a number read from a texture,
86
86
  * and it is the same fact the octahedral array texture was built on.
87
87
  */
88
- export declare const LIGHT_TEXELS = 5;
88
+ export declare const LIGHT_TEXELS = 6;
89
89
  /**
90
90
  * Where each field of a light's record sits, as an offset in `uint`s from `lightBase`.
91
91
  *
@@ -115,7 +115,11 @@ export declare const LIGHT_TEXELS = 5;
115
115
  * **Reordered 2026-10-02 so a plain light's fields are the first eight**, which is what lets the
116
116
  * shader stop after two texels. Nine values are needed by every light and eight fit in two
117
117
  * texels, so the emitter size and the weight share slot 7 as two halves; see `packSizeAndWeight`.
118
- * Slot 19 is spare.
118
+ *
119
+ * **A sixth texel arrived in 4.8.7 for a light's own falloff exponent**, the fifth's last slot
120
+ * having gone to the channels: 6 KB more of a table near 300, read only for a light whose record
121
+ * carries a fixture, which a light with an exponent does. Slots 21 and 22 are a rectangle's barn
122
+ * doors and spare on a lamp; slot 23 is spare.
119
123
  */
120
124
  export declare const LIGHT_RECORD: {
121
125
  readonly positionX: 0;
@@ -146,6 +150,11 @@ export declare const LIGHT_RECORD: {
146
150
  * last texels are fetched; a plain light's are not, and the shader takes the default for it.
147
151
  */
148
152
  readonly channels: 19;
153
+ /** The light's own falloff exponent, or 0 for the frame's falloff. `PointLightSource.falloffExponent`. */
154
+ readonly falloffExponent: 20;
155
+ /** A rectangle's barn doors: the cosine of their angle from its normal, then their length in metres. */
156
+ readonly barnDoorCos: 21;
157
+ readonly barnDoorLength: 22;
149
158
  };
150
159
  /** The lighting channel every light and every surface is on unless it says otherwise. */
151
160
  export declare const DEFAULT_LIGHT_CHANNELS = 1;
@@ -155,10 +164,16 @@ export declare const DEFAULT_LIGHT_CHANNELS = 1;
155
164
  * this, so a light named badly is on channel 1 in both rather than on two different guesses.
156
165
  */
157
166
  export declare function lightChannelsOf(value: number | undefined): number;
167
+ /**
168
+ * A light's own falloff exponent as the shader reads it: a positive finite number, or 0 for the
169
+ * frame's falloff where none was named or what was named is not one. The record and the uniform
170
+ * path both write this, so a light named badly falls off by the frame's rule in both.
171
+ */
172
+ export declare function lightFalloffExponentOf(value: number | undefined): number;
158
173
  /** Texels the lit pass reads for a light whose record carries no fixture. */
159
174
  export declare const PLAIN_LIGHT_TEXELS = 2;
160
175
  /**
161
- * The bit of `sizeAndWeight` that says the record's last three texels hold something: the sign of
176
+ * The bit of `sizeAndWeight` that says the record's last four texels hold something: the sign of
162
177
  * the emitter size's half, which a size never needs.
163
178
  */
164
179
  export declare const LIGHT_FIXTURE_FLAG = 32768;
@@ -287,6 +302,8 @@ export interface ClusterLightSet {
287
302
  readonly iesProfiles?: Float32Array;
288
303
  /** One per light: its lighting channels. See `PointLightSet.lightChannels`. */
289
304
  readonly channels?: Float32Array;
305
+ /** One per light: its own falloff exponent, or 0. See `PointLightSource.falloffExponent`. */
306
+ readonly falloffExponents?: Float32Array;
290
307
  /**
291
308
  * The rectangles shaded through the table, after the `count` point lights: those of `areas` from
292
309
  * `areaFrom` on, `areaCount` of them. The ones before `areaFrom` are the fixed arm's, which is
@@ -310,6 +327,7 @@ export interface ClusterLightArrays {
310
327
  readonly lightIesAxes?: Float32Array;
311
328
  readonly lightCookies?: Float32Array;
312
329
  readonly lightChannels?: Float32Array;
330
+ readonly lightFalloffExponents?: Float32Array;
313
331
  readonly areaLights?: AreaLightBuffer | null;
314
332
  }
315
333
  /** A `ClusterLightSet` a renderer refills every frame. */
@@ -336,8 +354,8 @@ export declare function fillClusterLightSet(env: ClusterLightArrays, out: Cluste
336
354
  /** How many rectangles the fixed arm shades; the rest of the buffer is clustered. */
337
355
  areaFrom?: number): ClusterLightSet;
338
356
  /**
339
- * Whether a light's record needs its last three texels: it holds a shadow slot, a cone, a profile
340
- * or a cookie. Everything else is a plain light, read in `PLAIN_LIGHT_TEXELS`.
357
+ * Whether a light's record needs its last four texels: it holds a shadow slot, a cone, a profile,
358
+ * a cookie, channels of its own or a falloff exponent of its own. Everything else is a plain light, read in `PLAIN_LIGHT_TEXELS`.
341
359
  *
342
360
  * **`shadowSlots` is zero where the lit shader has no point shadows**, and both binners are handed
343
361
  * it that way: a slot is only read under `POINT_SHADOWS`, so a light holding one in a build without
@@ -84,7 +84,7 @@ export const MAX_CLUSTERED_LIGHTS = 320;
84
84
  * it cannot. That is what lets a light index the shadow uniforms by a number read from a texture,
85
85
  * and it is the same fact the octahedral array texture was built on.
86
86
  */
87
- export const LIGHT_TEXELS = 5;
87
+ export const LIGHT_TEXELS = 6;
88
88
  /**
89
89
  * Where each field of a light's record sits, as an offset in `uint`s from `lightBase`.
90
90
  *
@@ -114,7 +114,11 @@ export const LIGHT_TEXELS = 5;
114
114
  * **Reordered 2026-10-02 so a plain light's fields are the first eight**, which is what lets the
115
115
  * shader stop after two texels. Nine values are needed by every light and eight fit in two
116
116
  * texels, so the emitter size and the weight share slot 7 as two halves; see `packSizeAndWeight`.
117
- * Slot 19 is spare.
117
+ *
118
+ * **A sixth texel arrived in 4.8.7 for a light's own falloff exponent**, the fifth's last slot
119
+ * having gone to the channels: 6 KB more of a table near 300, read only for a light whose record
120
+ * carries a fixture, which a light with an exponent does. Slots 21 and 22 are a rectangle's barn
121
+ * doors and spare on a lamp; slot 23 is spare.
118
122
  */
119
123
  export const LIGHT_RECORD = {
120
124
  positionX: 0,
@@ -145,6 +149,11 @@ export const LIGHT_RECORD = {
145
149
  * last texels are fetched; a plain light's are not, and the shader takes the default for it.
146
150
  */
147
151
  channels: 19,
152
+ /** The light's own falloff exponent, or 0 for the frame's falloff. `PointLightSource.falloffExponent`. */
153
+ falloffExponent: 20,
154
+ /** A rectangle's barn doors: the cosine of their angle from its normal, then their length in metres. */
155
+ barnDoorCos: 21,
156
+ barnDoorLength: 22,
148
157
  };
149
158
  /** The lighting channel every light and every surface is on unless it says otherwise. */
150
159
  export const DEFAULT_LIGHT_CHANNELS = 1;
@@ -159,10 +168,18 @@ export function lightChannelsOf(value) {
159
168
  }
160
169
  return value;
161
170
  }
171
+ /**
172
+ * A light's own falloff exponent as the shader reads it: a positive finite number, or 0 for the
173
+ * frame's falloff where none was named or what was named is not one. The record and the uniform
174
+ * path both write this, so a light named badly falls off by the frame's rule in both.
175
+ */
176
+ export function lightFalloffExponentOf(value) {
177
+ return value !== undefined && Number.isFinite(value) && value > 0 ? value : 0;
178
+ }
162
179
  /** Texels the lit pass reads for a light whose record carries no fixture. */
163
180
  export const PLAIN_LIGHT_TEXELS = 2;
164
181
  /**
165
- * The bit of `sizeAndWeight` that says the record's last three texels hold something: the sign of
182
+ * The bit of `sizeAndWeight` that says the record's last four texels hold something: the sign of
166
183
  * the emitter size's half, which a size never needs.
167
184
  */
168
185
  export const LIGHT_FIXTURE_FLAG = 0x8000;
@@ -392,6 +409,7 @@ areaFrom = 0) {
392
409
  out.iesAxes = env.lightIesAxes;
393
410
  out.cookies = env.lightCookies;
394
411
  out.channels = env.lightChannels;
412
+ out.falloffExponents = env.lightFalloffExponents;
395
413
  return out;
396
414
  }
397
415
  /*
@@ -436,8 +454,8 @@ function distanceSqToBounds(x, y, z, b) {
436
454
  return dx * dx + dy * dy + dz * dz;
437
455
  }
438
456
  /**
439
- * Whether a light's record needs its last three texels: it holds a shadow slot, a cone, a profile
440
- * or a cookie. Everything else is a plain light, read in `PLAIN_LIGHT_TEXELS`.
457
+ * Whether a light's record needs its last four texels: it holds a shadow slot, a cone, a profile,
458
+ * a cookie, channels of its own or a falloff exponent of its own. Everything else is a plain light, read in `PLAIN_LIGHT_TEXELS`.
441
459
  *
442
460
  * **`shadowSlots` is zero where the lit shader has no point shadows**, and both binners are handed
443
461
  * it that way: a slot is only read under `POINT_SHADOWS`, so a light holding one in a build without
@@ -453,7 +471,8 @@ export function lightHasFixture(lights, light, shadowSlots) {
453
471
  (cone?.[light * 2 + 1] ?? POINT_LIGHT_COS_OUTER) !== POINT_LIGHT_COS_OUTER ||
454
472
  (lights.iesProfiles?.[light] ?? NO_IES_PROFILE) >= 0 ||
455
473
  (lights.cookies?.[light] ?? NO_IES_PROFILE) >= 0 ||
456
- lightChannelsOf(lights.channels?.[light]) !== DEFAULT_LIGHT_CHANNELS);
474
+ lightChannelsOf(lights.channels?.[light]) !== DEFAULT_LIGHT_CHANNELS ||
475
+ lightFalloffExponentOf(lights.falloffExponents?.[light]) > 0);
457
476
  }
458
477
  /**
459
478
  * The value of `uClustered` for a frame: 0 with clustering off, 1 when every light is plain, 2 when
@@ -525,6 +544,10 @@ export function writeLightRecord(lights, light, shadowSlots, out, at) {
525
544
  out[at + LIGHT_RECORD.iesAxisY] = floatBits(axes?.[light * 3 + 1] ?? 0);
526
545
  out[at + LIGHT_RECORD.iesAxisZ] = floatBits(axes?.[light * 3 + 2] ?? 0);
527
546
  out[at + LIGHT_RECORD.channels] = floatBits(lightChannelsOf(lights.channels?.[light]));
547
+ out[at + LIGHT_RECORD.falloffExponent] = floatBits(lightFalloffExponentOf(lights.falloffExponents?.[light]));
548
+ out[at + LIGHT_RECORD.barnDoorCos] = 0;
549
+ out[at + LIGHT_RECORD.barnDoorLength] = 0;
550
+ out[at + LIGHT_RECORD.barnDoorLength + 1] = 0;
528
551
  }
529
552
  /**
530
553
  * Which rectangle of the area buffer clustered light `light` is, for a light past the point
@@ -535,9 +558,9 @@ function areaSlot(lights, light) {
535
558
  }
536
559
  /**
537
560
  * A rectangle's record, which the shader tells from a lamp's by its **negative radius** — its
538
- * reach, negated — and reads to its fifth texel: sidedness where a lamp keeps its shadow slot,
561
+ * reach, negated — and reads to its sixth texel: sidedness where a lamp keeps its shadow slot,
539
562
  * the right axis where a spot keeps its direction, the half extents where its cone is, the up
540
- * axis where the azimuth is, and the channels last. Never shadowed: a rectangle's occlusion is
563
+ * axis where the azimuth is, the channels, and the barn doors in the sixth. Never shadowed: a rectangle's occlusion is
541
564
  * the fixed arm's, which is why the first `maxAreaLights` stay there.
542
565
  */
543
566
  function writeAreaRecord(lights, light, out, at) {
@@ -568,6 +591,10 @@ function writeAreaRecord(lights, light, out, at) {
568
591
  out[at + LIGHT_RECORD.iesAxisY] = floatBits(up?.[a * 3 + 1] ?? 1);
569
592
  out[at + LIGHT_RECORD.iesAxisZ] = floatBits(up?.[a * 3 + 2] ?? 0);
570
593
  out[at + LIGHT_RECORD.channels] = floatBits(DEFAULT_LIGHT_CHANNELS);
594
+ out[at + LIGHT_RECORD.falloffExponent] = 0;
595
+ out[at + LIGHT_RECORD.barnDoorCos] = floatBits(areas?.barnDoors?.[a * 2] ?? 0);
596
+ out[at + LIGHT_RECORD.barnDoorLength] = floatBits(areas?.barnDoors?.[a * 2 + 1] ?? 0);
597
+ out[at + LIGHT_RECORD.barnDoorLength + 1] = 0;
571
598
  }
572
599
  /** A clustered rectangle's reach, which its record carries negated. See `AreaLightSource.range`. */
573
600
  function areaReach(lights, light) {
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Which dynamic range the frame goes out in, and why: the one decision behind
3
+ * `RenderQuality.highDynamicRange`, made here so both backends answer it in the same words.
4
+ *
5
+ * **A high range is asked for, found, and held, or it is not had.** Three things must all be true:
6
+ * the profile asked; the frame has a composite holding light past white (`screenEffects` and
7
+ * `hdrScene`), since every other pass writes display values already clipped; and the display says
8
+ * it can show more than paper white. The backend then has to be able to configure such a canvas,
9
+ * which only it knows, and it may still answer standard after this says high.
10
+ *
11
+ * **What a high range gives up** is nothing a standard frame had: paper white stays where it is,
12
+ * and only what was above it — a lamp, a flash, a sunlit edge — goes on rising to the display's
13
+ * peak instead of rolling off at white. **What would make it wrong** is a display that reports a
14
+ * high range it cannot show, which a page cannot detect and the platform owns.
15
+ */
16
+ export type DisplayRange = 'high' | 'standard';
17
+ export interface DisplayRangeChoice {
18
+ readonly range: DisplayRange;
19
+ /** A sentence saying why, for `RendererApi.displayRangeReason`. */
20
+ readonly reason: string;
21
+ }
22
+ /** The display's own answer, as the browser gives it. A host without a DOM supplies its own. */
23
+ export declare function browserHighDynamicRange(): boolean;
24
+ /** The range a profile gets on a display, before the backend has tried to configure it. */
25
+ export declare function chooseDisplayRange(asked: boolean, composite: boolean, displayIsHigh: boolean): DisplayRangeChoice;
26
+ /**
27
+ * The display's peak over the frame's paper white, as `setDisplayLuminance` is handed it: at
28
+ * least 1, and 1 where the range is standard or a number is not one. Capped at 64, a thousand nits
29
+ * over a sixteen-nit white, which no display reaches and no curve should be asked to.
30
+ */
31
+ export declare function displayHeadroom(range: DisplayRange, paperWhite: number, peak: number): number;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Which dynamic range the frame goes out in, and why: the one decision behind
3
+ * `RenderQuality.highDynamicRange`, made here so both backends answer it in the same words.
4
+ *
5
+ * **A high range is asked for, found, and held, or it is not had.** Three things must all be true:
6
+ * the profile asked; the frame has a composite holding light past white (`screenEffects` and
7
+ * `hdrScene`), since every other pass writes display values already clipped; and the display says
8
+ * it can show more than paper white. The backend then has to be able to configure such a canvas,
9
+ * which only it knows, and it may still answer standard after this says high.
10
+ *
11
+ * **What a high range gives up** is nothing a standard frame had: paper white stays where it is,
12
+ * and only what was above it — a lamp, a flash, a sunlit edge — goes on rising to the display's
13
+ * peak instead of rolling off at white. **What would make it wrong** is a display that reports a
14
+ * high range it cannot show, which a page cannot detect and the platform owns.
15
+ */
16
+ /** The display's own answer, as the browser gives it. A host without a DOM supplies its own. */
17
+ export function browserHighDynamicRange() {
18
+ // platform: browser default — `CreateRendererOptions.highDynamicRangeDisplay` replaces it
19
+ return typeof matchMedia === 'function' && matchMedia('(dynamic-range: high)').matches;
20
+ }
21
+ /** The range a profile gets on a display, before the backend has tried to configure it. */
22
+ export function chooseDisplayRange(asked, composite, displayIsHigh) {
23
+ if (!asked)
24
+ return { range: 'standard', reason: 'highDynamicRange was not asked for' };
25
+ if (!composite) {
26
+ return {
27
+ range: 'standard',
28
+ reason: 'highDynamicRange needs screenEffects and hdrScene: without a composite holding light ' +
29
+ 'past white, every pass writes values already clipped to it',
30
+ };
31
+ }
32
+ if (!displayIsHigh) {
33
+ return { range: 'standard', reason: 'the display reports a standard dynamic range' };
34
+ }
35
+ return { range: 'high', reason: 'an extended-range canvas on a high dynamic range display' };
36
+ }
37
+ /**
38
+ * The display's peak over the frame's paper white, as `setDisplayLuminance` is handed it: at
39
+ * least 1, and 1 where the range is standard or a number is not one. Capped at 64, a thousand nits
40
+ * over a sixteen-nit white, which no display reaches and no curve should be asked to.
41
+ */
42
+ export function displayHeadroom(range, paperWhite, peak) {
43
+ if (range !== 'high')
44
+ return 1;
45
+ const ratio = peak / paperWhite;
46
+ return Number.isFinite(ratio) ? Math.min(Math.max(ratio, 1), 64) : 1;
47
+ }
@@ -26,6 +26,8 @@ export interface FieldLight extends LightReach {
26
26
  readonly b: number;
27
27
  /** The emitter's physical radius, which widens its penumbra. */
28
28
  readonly sourceRadius: number;
29
+ /** Its own falloff exponent, overriding the frame's above zero. See `pointLightShape`. */
30
+ readonly falloffExponent?: number;
29
31
  }
30
32
  /**
31
33
  * Fill `light` and `direction`, `BRICK_TEXELS * 4` floats each, x fastest, for brick `brick`.
@@ -66,7 +66,7 @@ export function sampleFieldLight(px, py, pz, lights, candidates, first, last, fa
66
66
  const dist = Math.hypot(dx, dy, dz);
67
67
  if (dist >= source.radius)
68
68
  continue;
69
- const shape = pointLightShape(dist, source.radius, falloff);
69
+ const shape = pointLightShape(dist, source.radius, falloff, source.falloffExponent);
70
70
  if (shape <= 0)
71
71
  continue;
72
72
  const seen = distance === null
@@ -9,8 +9,10 @@
9
9
  *
10
10
  * Both of the renderer's falloffs: `smooth`, the default, is a linear ramp squared, reaching zero at
11
11
  * the radius; `inverseSquare` is physical, windowed so it still reaches zero at the radius, and
12
- * floored at ten centimetres so a surface touching a light is not infinitely bright.
12
+ * floored at ten centimetres so a surface touching a light is not infinitely bright. And a light's
13
+ * own, which overrides both: `(1 - (d/R)^2)^n` with no distance term, for an exponent `n` above zero
14
+ * (`PointLightSource.falloffExponent`).
13
15
  */
14
16
  export type PointLightFalloff = 'smooth' | 'inverseSquare';
15
17
  /** The shader's `shape` for an unshaped point light: no cone, no photometric profile. */
16
- export declare function pointLightShape(dist: number, radius: number, falloff: PointLightFalloff): number;
18
+ export declare function pointLightShape(dist: number, radius: number, falloff: PointLightFalloff, exponent?: number): number;
@@ -9,10 +9,16 @@
9
9
  *
10
10
  * Both of the renderer's falloffs: `smooth`, the default, is a linear ramp squared, reaching zero at
11
11
  * the radius; `inverseSquare` is physical, windowed so it still reaches zero at the radius, and
12
- * floored at ten centimetres so a surface touching a light is not infinitely bright.
12
+ * floored at ten centimetres so a surface touching a light is not infinitely bright. And a light's
13
+ * own, which overrides both: `(1 - (d/R)^2)^n` with no distance term, for an exponent `n` above zero
14
+ * (`PointLightSource.falloffExponent`).
13
15
  */
14
16
  /** The shader's `shape` for an unshaped point light: no cone, no photometric profile. */
15
- export function pointLightShape(dist, radius, falloff) {
17
+ export function pointLightShape(dist, radius, falloff, exponent = 0) {
18
+ if (exponent > 0) {
19
+ const reach = dist / Math.max(radius, 1e-4);
20
+ return Math.min(1, Math.max(0, 1 - reach * reach)) ** exponent;
21
+ }
16
22
  if (falloff === 'inverseSquare') {
17
23
  const ratio = dist / Math.max(radius, 1e-4);
18
24
  const window = Math.min(1, Math.max(0, 1 - ratio ** 4));