@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,11 +34,11 @@ import type { FieldLight } from './bake.ts';
34
34
  import type { PointLightFalloff } from './falloff.ts';
35
35
  import { BRICK_SAMPLES, layoutLightField } from './layout.ts';
36
36
  import type { LightFieldLayout } from './layout.ts';
37
+ import { FieldPresence } from './presence.ts';
38
+ import type { DriftLightVolumes } from './presence.ts';
37
39
 
38
40
  /** Seconds the summed light takes to fade in once the field is whole. */
39
41
  export const FIELD_FADE_SEC = 0.5;
40
- /** How fast the shading radius grows back, per second, when the choice reaches further again. */
41
- const RADIUS_GROWTH_PER_SEC = 2;
42
42
  /** Bricks along each axis of the atlas: 256 texels a side, the least 3D texture WebGL2 promises. */
43
43
  const ATLAS_BRICKS: readonly [number, number, number] = [32, 64, 64];
44
44
  /** A brick's footprint in the atlas: its light block and its direction block side by side. */
@@ -66,7 +66,7 @@ export interface LightFieldOptions {
66
66
  readonly fadeSec?: number;
67
67
  }
68
68
 
69
- export class LightField {
69
+ export class LightField implements DriftLightVolumes {
70
70
  readonly layout: LightFieldLayout;
71
71
  /** Texels on each axis of the atlas. */
72
72
  readonly atlasSize: readonly [number, number, number];
@@ -82,18 +82,15 @@ export class LightField {
82
82
  readonly fadeSec: number;
83
83
  /** The summed light's brightness, for a scene that dims all its lamps at once: dawn, a switch. */
84
84
  scale = 1;
85
- /** Where the frame's choice was centred, which the shader measures the radius from. */
86
- readonly centre = new Float32Array(3);
87
85
 
88
86
  private readonly lights: readonly FieldLight[];
89
87
  private readonly falloff: PointLightFalloff;
90
88
  private readonly distance: ((x: number, y: number, z: number) => number) | null;
91
89
  private readonly lightBlock = new Float32Array(BRICK_TEXELS * 4);
92
90
  private readonly directionBlock = new Float32Array(BRICK_TEXELS * 4);
91
+ private readonly showing: FieldPresence;
93
92
  private baked = 0;
94
93
  private taken = 0;
95
- private eased = 0;
96
- private shown = 0;
97
94
 
98
95
  constructor(lights: readonly LightFieldSource[], options: LightFieldOptions) {
99
96
  for (const light of lights) light.inLightField = true;
@@ -110,6 +107,7 @@ export class LightField {
110
107
  this.falloff = options.falloff;
111
108
  this.band = options.band ?? 2;
112
109
  this.fadeSec = Math.max(0, options.fadeSec ?? FIELD_FADE_SEC);
110
+ this.showing = new FieldPresence(this.fadeSec);
113
111
  const fields = options.fields ?? [];
114
112
  this.occluded = fields.length > 0;
115
113
  this.distance = this.occluded ? distanceToInstances(fields) : null;
@@ -143,12 +141,37 @@ export class LightField {
143
141
 
144
142
  /** The radius, in metres from `centre`, inside which lights are shaded exactly. */
145
143
  get radius(): number {
146
- return this.eased;
144
+ return this.showing.radius;
147
145
  }
148
146
 
149
147
  /** How much of the summed light is in the frame, 0 until the field is whole, then up to 1. */
150
148
  get presence(): number {
151
- return this.shown;
149
+ return this.showing.presence;
150
+ }
151
+
152
+ /** Where the frame's choice was centred, which the shader measures the radius from. */
153
+ get centre(): Float32Array {
154
+ return this.showing.centre;
155
+ }
156
+
157
+ get empty(): boolean {
158
+ return this.layout.count === 0;
159
+ }
160
+
161
+ get indexDims(): readonly [number, number, number] {
162
+ return this.layout.dims;
163
+ }
164
+
165
+ get index(): Uint32Array {
166
+ return this.layout.index;
167
+ }
168
+
169
+ get sampleOrigin(): readonly [number, number, number] {
170
+ return this.layout.origin;
171
+ }
172
+
173
+ get signedSpacing(): number {
174
+ return this.layout.spacing;
152
175
  }
153
176
 
154
177
  /** The texel at which brick `brick`'s light block starts; its direction block is 4 along x. */
@@ -195,15 +218,7 @@ export class LightField {
195
218
  * which is only less exact. While nothing is summed the radius simply follows.
196
219
  */
197
220
  follow(complete: number, x: number, y: number, z: number, dtSec: number): void {
198
- this.centre[0] = x;
199
- this.centre[1] = y;
200
- this.centre[2] = z;
201
- const showing = this.shown > 0;
202
- if (!showing || complete < this.eased) this.eased = complete;
203
- else this.eased += (complete - this.eased) * (1 - Math.exp(-RADIUS_GROWTH_PER_SEC * dtSec));
204
- if (this.ready) {
205
- this.shown = this.fadeSec > 0 ? Math.min(1, this.shown + dtSec / this.fadeSec) : 1;
206
- }
221
+ this.showing.follow(this.ready, complete, x, y, z, dtSec);
207
222
  }
208
223
 
209
224
  /** Write the brick just baked into its place in the atlas, as half floats. */
@@ -0,0 +1,69 @@
1
+ /**
2
+ * How much of a DriftLight field is in the frame, and the radius inside which the frame's exact
3
+ * lights take over from it — the part every kind of field shares, kept once.
4
+ *
5
+ * **The radius shrinks at once and grows back slowly.** A pixel inside it that a left-out light
6
+ * reaches would be shaded short, so it cannot lag a shrink; a pixel it has not reached yet takes the
7
+ * summed light, which is only less exact, so a grow may ease. While nothing is summed the radius
8
+ * simply follows. The presence fades in over `fadeSec` once the field is whole, so a field
9
+ * completing in view does not pop — and a held clock never finishes a fade, so a field behind a
10
+ * loading screen asks for none.
11
+ */
12
+
13
+ /** How fast the shading radius grows back, per second, when the choice reaches further again. */
14
+ const RADIUS_GROWTH_PER_SEC = 2;
15
+
16
+ export class FieldPresence {
17
+ /** Where the frame's choice was centred, which the shader measures the radius from. */
18
+ readonly centre = new Float32Array(3);
19
+ private eased = 0;
20
+ private shown = 0;
21
+
22
+ constructor(readonly fadeSec: number) {}
23
+
24
+ /** The radius, in metres from `centre`, inside which lights are shaded exactly. */
25
+ get radius(): number {
26
+ return this.eased;
27
+ }
28
+
29
+ /** How much of the summed light is in the frame, 0 until the field is whole, then up to 1. */
30
+ get presence(): number {
31
+ return this.shown;
32
+ }
33
+
34
+ /** Follow the frame's choice: `complete` from the selection, centred at (x, y, z). */
35
+ follow(ready: boolean, complete: number, x: number, y: number, z: number, dtSec: number): void {
36
+ this.centre[0] = x;
37
+ this.centre[1] = y;
38
+ this.centre[2] = z;
39
+ if (this.shown <= 0 || complete < this.eased) this.eased = complete;
40
+ else this.eased += (complete - this.eased) * (1 - Math.exp(-RADIUS_GROWTH_PER_SEC * dtSec));
41
+ if (ready) this.shown = this.fadeSec > 0 ? Math.min(1, this.shown + dtSec / this.fadeSec) : 1;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * What a backend uploads and binds for a field of either kind, and what the uniforms are made of.
47
+ *
48
+ * A sparse field's index finds its bricks; a dense one brings a 1×1×1 index of nothing, so the
49
+ * upload and the bind are one code path, and says it is dense by a negative spacing.
50
+ */
51
+ export interface DriftLightVolumes {
52
+ readonly ready: boolean;
53
+ /** Nothing summed at all: nothing to upload. */
54
+ readonly empty: boolean;
55
+ readonly indexDims: readonly [number, number, number];
56
+ readonly index: Uint32Array;
57
+ readonly atlasSize: readonly [number, number, number];
58
+ /** Half floats, four channels a texel, x fastest. */
59
+ readonly atlas: Uint16Array;
60
+ readonly presence: number;
61
+ readonly radius: number;
62
+ readonly band: number;
63
+ readonly scale: number;
64
+ readonly centre: Float32Array;
65
+ /** The world position of the first sample. */
66
+ readonly sampleOrigin: readonly [number, number, number];
67
+ /** Metres between samples, negative for a dense volume: the shader's mode. */
68
+ readonly signedSpacing: number;
69
+ }
@@ -14,12 +14,12 @@
14
14
  * from the centre, gets a smaller radius and more of the volume, which is the safe direction.
15
15
  */
16
16
 
17
- import type { LightField } from './lightField.ts';
17
+ import type { DriftLightVolumes } from './presence.ts';
18
18
 
19
19
  export interface DriftLightUniforms {
20
20
  /** How much of the summed light is in, the radius, the band, the scale. */
21
21
  readonly light: Float32Array;
22
- /** The first brick's first sample, and the metres between samples. */
22
+ /** The first sample, and the metres between samples — negative for a dense volume. */
23
23
  readonly origin: Float32Array;
24
24
  }
25
25
 
@@ -35,16 +35,15 @@ export function createDriftLightUniforms(): DriftLightUniforms {
35
35
  * being drawn.
36
36
  */
37
37
  export function resolveDriftLight(
38
- field: LightField | null,
38
+ field: DriftLightVolumes | null,
39
39
  probePass: boolean,
40
40
  eye: ArrayLike<number>,
41
41
  out: DriftLightUniforms,
42
42
  ): void {
43
- if (field === null || !field.ready || field.presence <= 0 || field.layout.count === 0) {
43
+ if (field === null || !field.ready || field.presence <= 0 || field.empty) {
44
44
  out.light.fill(0);
45
45
  return;
46
46
  }
47
- const { layout } = field;
48
47
  const offset = Math.hypot(
49
48
  (eye[0] ?? 0) - (field.centre[0] as number),
50
49
  (eye[1] ?? 0) - (field.centre[1] as number),
@@ -54,8 +53,8 @@ export function resolveDriftLight(
54
53
  out.light[1] = probePass ? 0 : Math.max(0, field.radius - offset);
55
54
  out.light[2] = field.band;
56
55
  out.light[3] = field.scale;
57
- out.origin[0] = layout.origin[0];
58
- out.origin[1] = layout.origin[1];
59
- out.origin[2] = layout.origin[2];
60
- out.origin[3] = layout.spacing;
56
+ out.origin[0] = field.sampleOrigin[0];
57
+ out.origin[1] = field.sampleOrigin[1];
58
+ out.origin[2] = field.sampleOrigin[2];
59
+ out.origin[3] = field.signedSpacing;
61
60
  }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * A world's DriftLight: a dense volume baked offline (`bakeDenseField`), handed to the renderer
3
+ * whole, standing in for every fixed light past the frame's exact choice.
4
+ *
5
+ * **Whole from the start**, because nothing is baked in the page: `ready` is true on construction
6
+ * and the volume fades in over `fadeSec` from the first `follow`. The lights it summed are the
7
+ * scene's to mark `inLightField`, so the selection knows not to count them twice — a volume carries
8
+ * no list of the lights it came from.
9
+ *
10
+ * **Light and direction side by side along y**, the short axis: a city is 256 samples across and a
11
+ * few dozen up, and a 3D texture promises 256 on every axis, so stacking the two there keeps both
12
+ * inside one texture and one sampler. The shader keeps each lookup inside its own half.
13
+ */
14
+
15
+ import { halfBits } from '../halfFloat.ts';
16
+ import type { DenseLightVolume } from './denseField.ts';
17
+ import { FieldPresence } from './presence.ts';
18
+ import type { DriftLightVolumes } from './presence.ts';
19
+
20
+ export interface WorldLightFieldOptions {
21
+ /** Metres over which exact and summed light crossfade at the shading radius. Two by default. */
22
+ readonly band?: number;
23
+ /** Seconds the volume fades in over; zero behind a loading screen, whose clock may be held. */
24
+ readonly fadeSec?: number;
25
+ }
26
+
27
+ /** The index a dense volume binds in the sparse one's place: one cell, no brick. */
28
+ const NO_INDEX = new Uint32Array(1);
29
+
30
+ export class WorldLightField implements DriftLightVolumes {
31
+ readonly ready = true;
32
+ readonly empty = false;
33
+ readonly indexDims: readonly [number, number, number] = [1, 1, 1];
34
+ readonly index = NO_INDEX;
35
+ readonly atlasSize: readonly [number, number, number];
36
+ readonly atlas: Uint16Array;
37
+ readonly band: number;
38
+ readonly sampleOrigin: readonly [number, number, number];
39
+ readonly signedSpacing: number;
40
+ /** The summed light's brightness, for a scene that dims all its lamps at once. */
41
+ scale = 1;
42
+ private readonly showing: FieldPresence;
43
+
44
+ constructor(volume: DenseLightVolume, options: WorldLightFieldOptions = {}) {
45
+ const [x, y, z] = volume.dims;
46
+ this.atlasSize = [x, y * 2, z];
47
+ this.atlas = new Uint16Array(x * y * 2 * z * 4);
48
+ for (let k = 0; k < z; k++) {
49
+ for (let j = 0; j < y; j++) {
50
+ for (let i = 0; i < x; i++) {
51
+ const from = (i + x * (j + y * k)) * 4;
52
+ const light = (i + x * (j + 2 * y * k)) * 4;
53
+ const direction = (i + x * (j + y + 2 * y * k)) * 4;
54
+ for (let c = 0; c < 4; c++) {
55
+ this.atlas[light + c] = halfBits(volume.light[from + c] as number);
56
+ this.atlas[direction + c] = halfBits(volume.direction[from + c] as number);
57
+ }
58
+ }
59
+ }
60
+ }
61
+ this.band = options.band ?? 2;
62
+ this.sampleOrigin = volume.origin;
63
+ this.signedSpacing = -volume.spacing;
64
+ this.showing = new FieldPresence(Math.max(0, options.fadeSec ?? 0.5));
65
+ }
66
+
67
+ get radius(): number {
68
+ return this.showing.radius;
69
+ }
70
+
71
+ get presence(): number {
72
+ return this.showing.presence;
73
+ }
74
+
75
+ get centre(): Float32Array {
76
+ return this.showing.centre;
77
+ }
78
+
79
+ /** Follow the frame's choice, as `LightField.follow` does. */
80
+ follow(complete: number, x: number, y: number, z: number, dtSec: number): void {
81
+ this.showing.follow(true, complete, x, y, z, dtSec);
82
+ }
83
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * What a light fixture throws, in one texture: its spot cookies in the top band, its photometric
3
+ * profiles in the rows below.
4
+ *
5
+ * **One binding for two tables, because the lit stage had one sampler left.** The cookie atlas and
6
+ * the photometric atlas were two small float textures read with `textureLod`, and the widest lit
7
+ * shader bound fifteen of the sixteen fragment samplers WebGL2 guarantees — so the next sampler had
8
+ * to be found by folding two into one, which is what this is. Both describe a fixture's emission
9
+ * pattern, and both are 128 texels across, which is what lets them share a texture exactly.
10
+ *
11
+ * **Cookies on top, profiles at the bottom, and the order is load-bearing.** A profile's row blend
12
+ * only ever reaches forward — to the next plane, and from the last to the repeat of the first — so
13
+ * past its last row it meets the texture's edge and the clamp, never a cookie. A cookie's own
14
+ * half-texel inset keeps it inside its band on both axes. The shader finds the bands from
15
+ * `textureSize` and `uIesAtlasRows`, so the fold cost no uniform.
16
+ *
17
+ * **What it gives up**: every texel is four half floats where a profile needed one, a few kilobytes
18
+ * a scene; and setting either table rebuilds the whole texture, which a consumer does once per set.
19
+ */
20
+ import { IES_ATLAS_WIDTH } from './iesProfile.ts';
21
+ import { COOKIE_TILE } from './lightBudget.ts';
22
+
23
+ export interface FixtureAtlasLayout {
24
+ readonly width: number;
25
+ readonly height: number;
26
+ /** Rows of cookie tiles at the top: `COOKIE_TILE`, or 0 with none loaded. */
27
+ readonly cookieRows: number;
28
+ /** The first row of the profiles, below the cookies. */
29
+ readonly iesTop: number;
30
+ }
31
+
32
+ /** The atlas for `iesRows` rows of profile and `cookieTiles` cookies. */
33
+ export function fixtureAtlasLayout(iesRows: number, cookieTiles: number): FixtureAtlasLayout {
34
+ const cookieRows = cookieTiles > 0 ? COOKIE_TILE : 0;
35
+ return {
36
+ width: Math.max(IES_ATLAS_WIDTH, COOKIE_TILE * cookieTiles),
37
+ height: cookieRows + iesRows,
38
+ cookieRows,
39
+ iesTop: cookieRows,
40
+ };
41
+ }
42
+
43
+ /**
44
+ * The profiles' rows as the atlas holds them: `data` one float a texel, `width` across and `rows`
45
+ * down, spread into the red channel of a four-channel image the atlas's size, the rest ones. The
46
+ * cookie band is left zero for the backend to fill from its images.
47
+ */
48
+ export function fixtureAtlasTexels(
49
+ layout: FixtureAtlasLayout,
50
+ ies: { readonly data: Float32Array; readonly width: number; readonly height: number },
51
+ ): Float32Array {
52
+ const out = new Float32Array(layout.width * layout.height * 4);
53
+ for (let row = 0; row < ies.height; row++) {
54
+ for (let x = 0; x < ies.width; x++) {
55
+ const at = ((layout.iesTop + row) * layout.width + x) * 4;
56
+ out[at] = ies.data[row * ies.width + x] as number;
57
+ out[at + 1] = 1;
58
+ out[at + 2] = 1;
59
+ out[at + 3] = 1;
60
+ }
61
+ }
62
+ return out;
63
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Which instances of a batch can be seen: the decision both backends make, and the reference the
3
+ * WebGPU compute cull is held to.
4
+ *
5
+ * **An instance's bounds are the mesh's, placed by its matrix** — the mesh's bounding sphere with its
6
+ * centre through the model matrix and its radius scaled by the largest axis, exactly as
7
+ * `boundsVisible` treats one mesh — so a batch needs no bounds of its own and a caller supplies none.
8
+ * The test is `sphereInFrustum`'s: kept unless wholly beyond one plane.
9
+ *
10
+ * WebGL2 runs `cullInstances` on the CPU; WebGPU runs `CULL_INSTANCES_WGSL`, the same arithmetic, on
11
+ * the device. Both keep what this keeps: `gpu-parity.mjs` compares them on a device. What this gives
12
+ * up is shape: a long thin mesh is culled by the sphere around it, which keeps it in more views than
13
+ * its box would. What would make that wrong is a batch of long thin things seen end on, where a box
14
+ * test pays; nothing in the engine draws those instanced today.
15
+ *
16
+ * Per frame and allocation-free: everything writes into arrays the caller owns.
17
+ */
18
+ import type { MeshInstances } from './instances.ts';
19
+ import type { Bounds } from '../math/bounds.ts';
20
+ import { boxInFrustum } from '../math/frustum.ts';
21
+ import type { FrustumPlanes } from '../math/frustum.ts';
22
+
23
+ /** How many planes a frustum holds, four floats each. */
24
+ const PLANES = 6;
25
+
26
+ /**
27
+ * Indices a culling batch must draw, all its instances together, before culling it instance by
28
+ * instance pays; below it the batch is drawn whole once its box is seen (`batchBoxVisible`).
29
+ *
30
+ * **What a per-instance cull costs is fixed a batch, and what it saves grows with the batch.** On
31
+ * WebGPU it is a dispatch and an indirect draw, and Chrome validates every indirect draw in its GPU
32
+ * process — measured at 8 µs apiece in a city frame whose six hundred small prop batches took one
33
+ * each, 52 fps against 70 with those draws gone. On WebGL2 it is a loop over the instances and an
34
+ * upload of the survivors. A few thousand indices cost a device less than either.
35
+ *
36
+ * What it gives up is the instances of a small batch that stand out of view once its box is in it,
37
+ * which the device transforms and clips for nothing. What would make it wrong is a device whose
38
+ * vertex stage limits the frame while the CPU idles — where this wants to come down.
39
+ */
40
+ export const INSTANCE_CULL_MIN_INDICES = 1 << 14;
41
+
42
+ /**
43
+ * Whether a culling batch of `count` instances of a mesh of `indexCount` indices is culled instance
44
+ * by instance, or drawn whole once its box is seen. See `INSTANCE_CULL_MIN_INDICES`.
45
+ */
46
+ export function cullsInstances(count: number, indexCount: number): boolean {
47
+ return count * indexCount >= INSTANCE_CULL_MIN_INDICES;
48
+ }
49
+
50
+ /**
51
+ * Keep the instances of `data` whose spheres meet `frustum`, compacted in order into `out`, and
52
+ * return how many. `out` must hold as many as `data` does; its count is set.
53
+ */
54
+ export function cullInstances(
55
+ data: MeshInstances,
56
+ local: Bounds,
57
+ frustum: FrustumPlanes,
58
+ out: MeshInstances,
59
+ ): number {
60
+ const count = Math.min(data.count, data.capacity, out.capacity);
61
+ const models = data.models;
62
+ const tints = data.tints;
63
+ let kept = 0;
64
+ for (let i = 0; i < count; i++) {
65
+ sphereOf(models, i, local, scratch);
66
+ if (!sphereMeets(frustum, scratch)) continue;
67
+ /* Copied float by float: a subarray would be a view allocated per instance, per frame. */
68
+ const from = i * 16;
69
+ const to = kept * 16;
70
+ for (let k = 0; k < 16; k++) out.models[to + k] = models[from + k] as number;
71
+ out.tints[kept * 3] = tints[i * 3] as number;
72
+ out.tints[kept * 3 + 1] = tints[i * 3 + 1] as number;
73
+ out.tints[kept * 3 + 2] = tints[i * 3 + 2] as number;
74
+ kept++;
75
+ }
76
+ out.count = kept;
77
+ return kept;
78
+ }
79
+
80
+ /** `sphereInFrustum` over a packed (x, y, z, radius): kept unless wholly beyond one plane. */
81
+ function sphereMeets(frustum: FrustumPlanes, sphere: Float32Array): boolean {
82
+ const x = sphere[0] as number;
83
+ const y = sphere[1] as number;
84
+ const z = sphere[2] as number;
85
+ const radius = sphere[3] as number;
86
+ for (let plane = 0; plane < PLANES; plane++) {
87
+ const at = plane * 4;
88
+ const distance =
89
+ (frustum[at] as number) * x +
90
+ (frustum[at + 1] as number) * y +
91
+ (frustum[at + 2] as number) * z +
92
+ (frustum[at + 3] as number);
93
+ if (distance < -radius) return false;
94
+ }
95
+ return true;
96
+ }
97
+
98
+ /**
99
+ * The world box around every instance's sphere, for testing a whole batch at once: (minX, minY,
100
+ * minZ, maxX, maxY, maxZ) into `out`. A batch holding nothing writes a min above its max.
101
+ *
102
+ * **A box, not a sphere, and the difference is the whole point for a flat batch.** A region of props
103
+ * a hundred metres square has a sphere seventy metres deep underground, where nothing occludes it,
104
+ * so a sphere test never finds a region hidden. The box stays as flat as the props are.
105
+ */
106
+ export function instancesBox(data: MeshInstances, local: Bounds, out: Float32Array): void {
107
+ const count = Math.min(data.count, data.capacity);
108
+ if (count === 0) {
109
+ out[0] = 1;
110
+ out[1] = 1;
111
+ out[2] = 1;
112
+ out[3] = -1;
113
+ out[4] = -1;
114
+ out[5] = -1;
115
+ return;
116
+ }
117
+ let minX = Infinity;
118
+ let minY = Infinity;
119
+ let minZ = Infinity;
120
+ let maxX = -Infinity;
121
+ let maxY = -Infinity;
122
+ let maxZ = -Infinity;
123
+ for (let i = 0; i < count; i++) {
124
+ sphereOf(data.models, i, local, scratch);
125
+ const r = scratch[3] as number;
126
+ minX = Math.min(minX, (scratch[0] as number) - r);
127
+ minY = Math.min(minY, (scratch[1] as number) - r);
128
+ minZ = Math.min(minZ, (scratch[2] as number) - r);
129
+ maxX = Math.max(maxX, (scratch[0] as number) + r);
130
+ maxY = Math.max(maxY, (scratch[1] as number) + r);
131
+ maxZ = Math.max(maxZ, (scratch[2] as number) + r);
132
+ }
133
+ out[0] = minX;
134
+ out[1] = minY;
135
+ out[2] = minZ;
136
+ out[3] = maxX;
137
+ out[4] = maxY;
138
+ out[5] = maxZ;
139
+ }
140
+
141
+ const scratch = new Float32Array(4);
142
+
143
+ /** Instance `i`'s world sphere into `out` as (x, y, z, radius). */
144
+ function sphereOf(models: Float32Array, i: number, local: Bounds, out: Float32Array): void {
145
+ const m = i * 16;
146
+ const cx = local.centre[0] ?? 0;
147
+ const cy = local.centre[1] ?? 0;
148
+ const cz = local.centre[2] ?? 0;
149
+ const m0 = models[m] as number;
150
+ const m1 = models[m + 1] as number;
151
+ const m2 = models[m + 2] as number;
152
+ const m4 = models[m + 4] as number;
153
+ const m5 = models[m + 5] as number;
154
+ const m6 = models[m + 6] as number;
155
+ const m8 = models[m + 8] as number;
156
+ const m9 = models[m + 9] as number;
157
+ const m10 = models[m + 10] as number;
158
+ out[0] = m0 * cx + m4 * cy + m8 * cz + (models[m + 12] as number);
159
+ out[1] = m1 * cx + m5 * cy + m9 * cz + (models[m + 13] as number);
160
+ out[2] = m2 * cx + m6 * cy + m10 * cz + (models[m + 14] as number);
161
+ out[3] =
162
+ local.radius *
163
+ Math.sqrt(
164
+ Math.max(
165
+ m0 * m0 + m1 * m1 + m2 * m2,
166
+ m4 * m4 + m5 * m5 + m6 * m6,
167
+ m8 * m8 + m9 * m9 + m10 * m10,
168
+ ),
169
+ );
170
+ }
171
+
172
+ /** What the batch test asks of an occlusion buffer. `OcclusionBuffer` and both renderers answer it. */
173
+ export interface BoxOccluder {
174
+ occludedBox(min: ArrayLike<number>, max: ArrayLike<number>): boolean;
175
+ }
176
+
177
+ /**
178
+ * Whether a batch's box (`instancesBox`) can be seen at all: inside the frustum, and not behind the
179
+ * declared occluders. One test for a whole region's props, run before any instance is looked at.
180
+ */
181
+ export function batchBoxVisible(
182
+ box: Float32Array,
183
+ frustum: FrustumPlanes,
184
+ occluder: BoxOccluder | null,
185
+ ): boolean {
186
+ const minX = box[0] as number;
187
+ const minY = box[1] as number;
188
+ const minZ = box[2] as number;
189
+ const maxX = box[3] as number;
190
+ const maxY = box[4] as number;
191
+ const maxZ = box[5] as number;
192
+ if (minX > maxX) return false;
193
+ if (!boxInFrustum(frustum, minX, minY, minZ, maxX, maxY, maxZ)) return false;
194
+ if (occluder === null) return true;
195
+ boxMin[0] = minX;
196
+ boxMin[1] = minY;
197
+ boxMin[2] = minZ;
198
+ boxMax[0] = maxX;
199
+ boxMax[1] = maxY;
200
+ boxMax[2] = maxZ;
201
+ return !occluder.occludedBox(boxMin, boxMax);
202
+ }
203
+
204
+ const boxMin = new Float32Array(3);
205
+ const boxMax = new Float32Array(3);
@@ -90,3 +90,19 @@ export function packInstances(instances: MeshInstances, out: Float32Array): void
90
90
  out[at + 18] = tints[t + 2] as number;
91
91
  }
92
92
  }
93
+
94
+ /** How a batch made by `createInstanced` behaves. */
95
+ export interface InstancedOptions {
96
+ /**
97
+ * Cull the camera draw: the whole batch skipped when its instances' sphere is out of view or behind
98
+ * the declared occluders, and otherwise each instance kept only where its sphere meets the view —
99
+ * on the CPU on WebGL2, as compute into an indirect draw on WebGPU — once the batch is large enough
100
+ * to repay that (`cullsInstances`); a smaller one is drawn whole. Shadow, mirror-free motion and
101
+ * blended draws still see every instance. Off by default, because a batch small enough to draw whole
102
+ * pays for nothing it would save.
103
+ *
104
+ * **For a batch spread over space** — a prop across a region, a crowd, traffic. A batch in one
105
+ * place gains only the whole-batch test, which it could as well do itself.
106
+ */
107
+ readonly cull?: boolean;
108
+ }