@driftengine/core 4.8.5 → 4.8.7

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 (214) hide show
  1. package/CHANGELOG.json +4392 -4250
  2. package/README.md +1 -1
  3. package/dist/index.d.ts +12 -4
  4. package/dist/index.js +12 -2
  5. package/dist/render/ambientHarmonics.d.ts +46 -0
  6. package/dist/render/ambientHarmonics.js +80 -0
  7. package/dist/render/anisotropicLobe.d.ts +10 -0
  8. package/dist/render/anisotropicLobe.js +19 -0
  9. package/dist/render/areaLights.d.ts +54 -0
  10. package/dist/render/areaLights.js +55 -0
  11. package/dist/render/backend/api.d.ts +43 -2
  12. package/dist/render/backend/webgl2/instanced.d.ts +2 -0
  13. package/dist/render/backend/webgl2/instanced.js +11 -2
  14. package/dist/render/backend/webgl2/renderer.d.ts +120 -11
  15. package/dist/render/backend/webgl2/renderer.js +356 -53
  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/webgl2/skinScatter.d.ts +14 -0
  19. package/dist/render/backend/webgl2/skinScatter.js +88 -26
  20. package/dist/render/backend/webgpu/buffers.js +2 -1
  21. package/dist/render/backend/webgpu/clusterBinner.js +3 -2
  22. package/dist/render/backend/webgpu/flatPass.d.ts +7 -5
  23. package/dist/render/backend/webgpu/flatPass.js +15 -13
  24. package/dist/render/backend/webgpu/particlePass.d.ts +37 -4
  25. package/dist/render/backend/webgpu/particlePass.js +81 -12
  26. package/dist/render/backend/webgpu/postPass.d.ts +6 -1
  27. package/dist/render/backend/webgpu/postPass.js +7 -2
  28. package/dist/render/backend/webgpu/renderer.d.ts +93 -8
  29. package/dist/render/backend/webgpu/renderer.js +483 -93
  30. package/dist/render/backend/webgpu/sceneCaptureTarget.d.ts +24 -0
  31. package/dist/render/backend/webgpu/sceneCaptureTarget.js +59 -0
  32. package/dist/render/backend/webgpu/shaders/clusterBin.wgsl.d.ts +1 -1
  33. package/dist/render/backend/webgpu/shaders/clusterBin.wgsl.js +41 -1
  34. package/dist/render/backend/webgpu/skinPaletteRing.d.ts +40 -28
  35. package/dist/render/backend/webgpu/skinPaletteRing.js +69 -32
  36. package/dist/render/backend/webgpu/skinScatterPass.d.ts +10 -2
  37. package/dist/render/backend/webgpu/skinScatterPass.js +59 -9
  38. package/dist/render/backend/webgpu/surfaceTexturePass.d.ts +5 -1
  39. package/dist/render/backend/webgpu/surfaceTexturePass.js +45 -5
  40. package/dist/render/backend/webgpu/waterPass.d.ts +1 -1
  41. package/dist/render/bloomChain.d.ts +44 -0
  42. package/dist/render/bloomChain.js +28 -0
  43. package/dist/render/bloomPass.d.ts +3 -1
  44. package/dist/render/bloomPass.js +16 -2
  45. package/dist/render/clusteredLights.d.ts +78 -6
  46. package/dist/render/clusteredLights.js +214 -13
  47. package/dist/render/driftLight/bake.d.ts +2 -0
  48. package/dist/render/driftLight/bake.js +1 -1
  49. package/dist/render/driftLight/falloff.d.ts +4 -2
  50. package/dist/render/driftLight/falloff.js +8 -2
  51. package/dist/render/fixtureAtlas.js +1 -1
  52. package/dist/render/gi/bounceLights.d.ts +3 -2
  53. package/dist/render/gi/bounceLights.js +8 -3
  54. package/dist/render/instanceCull.js +12 -0
  55. package/dist/render/instances.d.ts +47 -11
  56. package/dist/render/instances.js +24 -9
  57. package/dist/render/lightBudget.d.ts +38 -2
  58. package/dist/render/lightBudget.js +48 -5
  59. package/dist/render/lightmap.d.ts +116 -0
  60. package/dist/render/lightmap.js +140 -0
  61. package/dist/render/mesh.js +2 -1
  62. package/dist/render/particleBatch.d.ts +59 -3
  63. package/dist/render/particleBatch.js +53 -2
  64. package/dist/render/particleMaterial.d.ts +6 -1
  65. package/dist/render/particleMaterial.js +10 -2
  66. package/dist/render/particlePool.d.ts +32 -0
  67. package/dist/render/particlePool.js +9 -0
  68. package/dist/render/particleSort.d.ts +34 -0
  69. package/dist/render/particleSort.js +96 -0
  70. package/dist/render/pointLightSelection.d.ts +13 -0
  71. package/dist/render/pointLightSelection.js +3 -1
  72. package/dist/render/renderQuality.d.ts +8 -7
  73. package/dist/render/rendererHarness.js +3 -0
  74. package/dist/render/rgb9e5.d.ts +22 -0
  75. package/dist/render/rgb9e5.js +47 -0
  76. package/dist/render/sceneCapture.d.ts +29 -0
  77. package/dist/render/sceneCapture.js +36 -0
  78. package/dist/render/sceneTarget.d.ts +5 -0
  79. package/dist/render/sceneTarget.js +8 -1
  80. package/dist/render/screenSpaceReflection.d.ts +27 -1
  81. package/dist/render/screenSpaceReflection.js +9 -0
  82. package/dist/render/shaders/bloom.d.ts +8 -4
  83. package/dist/render/shaders/bloom.js +20 -3
  84. package/dist/render/shaders/bolt.d.ts +1 -1
  85. package/dist/render/shaders/bolt.js +7 -1
  86. package/dist/render/shaders/depth.js +11 -0
  87. package/dist/render/shaders/flat/anisotropic.d.ts +1 -1
  88. package/dist/render/shaders/flat/anisotropic.js +37 -3
  89. package/dist/render/shaders/flat/areaLight.d.ts +10 -0
  90. package/dist/render/shaders/flat/areaLight.js +142 -0
  91. package/dist/render/shaders/flat/glassTint.d.ts +3 -2
  92. package/dist/render/shaders/flat/glassTint.js +3 -1
  93. package/dist/render/shaders/flat/index.d.ts +5 -0
  94. package/dist/render/shaders/flat/index.js +46 -6
  95. package/dist/render/shaders/flat/lobes.d.ts +6 -0
  96. package/dist/render/shaders/flat/lobes.js +49 -43
  97. package/dist/render/shaders/flat/main.d.ts +1 -1
  98. package/dist/render/shaders/flat/main.js +158 -121
  99. package/dist/render/shaders/flat/models.d.ts +21 -6
  100. package/dist/render/shaders/flat/models.js +161 -17
  101. package/dist/render/shaders/flat/normalMap.d.ts +10 -0
  102. package/dist/render/shaders/flat/normalMap.js +25 -0
  103. package/dist/render/shaders/flat/preamble.js +84 -40
  104. package/dist/render/shaders/flat/skin.js +6 -5
  105. package/dist/render/shaders/generated/bloom.wgsl.d.ts +13 -3
  106. package/dist/render/shaders/generated/bloom.wgsl.js +13 -3
  107. package/dist/render/shaders/generated/bolt.wgsl.d.ts +1 -1
  108. package/dist/render/shaders/generated/bolt.wgsl.js +1 -1
  109. package/dist/render/shaders/generated/depth.wgsl.d.ts +4 -4
  110. package/dist/render/shaders/generated/depth.wgsl.js +4 -4
  111. package/dist/render/shaders/generated/flat.wgsl.d.ts +1647 -2044
  112. package/dist/render/shaders/generated/flat.wgsl.js +1685 -2082
  113. package/dist/render/shaders/generated/particle.wgsl.d.ts +115 -1
  114. package/dist/render/shaders/generated/particle.wgsl.js +115 -1
  115. package/dist/render/shaders/generated/skinBlur.wgsl.d.ts +12 -2
  116. package/dist/render/shaders/generated/skinBlur.wgsl.js +12 -2
  117. package/dist/render/shaders/generated/ssrTrace.wgsl.d.ts +11 -6
  118. package/dist/render/shaders/generated/ssrTrace.wgsl.js +11 -6
  119. package/dist/render/shaders/generated/water.wgsl.d.ts +23 -16
  120. package/dist/render/shaders/generated/water.wgsl.js +23 -16
  121. package/dist/render/shaders/gi/probeBake.wgsl.js +9 -5
  122. package/dist/render/shaders/particle.d.ts +17 -1
  123. package/dist/render/shaders/particle.js +133 -1
  124. package/dist/render/shaders/recon/motion.wgsl.d.ts +1 -1
  125. package/dist/render/shaders/recon/motion.wgsl.js +13 -2
  126. package/dist/render/shaders/skinBlur.js +25 -3
  127. package/dist/render/shaders/ssrTrace.js +20 -1
  128. package/dist/render/shaders/water.d.ts +1 -1
  129. package/dist/render/shaders/water.js +12 -3
  130. package/dist/render/skinBlur.d.ts +8 -5
  131. package/dist/render/skinBlur.js +8 -5
  132. package/dist/render/skinScatter.d.ts +3 -1
  133. package/dist/render/skinScatter.js +3 -1
  134. package/dist/render/ssrPass.js +1 -0
  135. package/dist/render/surfaceModel.d.ts +49 -8
  136. package/dist/render/surfaceModel.js +54 -8
  137. package/dist/render/surfaceTexture.d.ts +38 -1
  138. package/dist/render/surfaceTexture.js +37 -2
  139. package/package.json +6 -6
  140. package/src/index.ts +26 -3
  141. package/src/render/ambientHarmonics.ts +92 -0
  142. package/src/render/anisotropicLobe.ts +29 -0
  143. package/src/render/areaLights.ts +97 -0
  144. package/src/render/backend/api.ts +61 -1
  145. package/src/render/backend/webgl2/instanced.ts +12 -2
  146. package/src/render/backend/webgl2/renderer.ts +470 -59
  147. package/src/render/backend/webgl2/sceneCaptureTarget.ts +130 -0
  148. package/src/render/backend/webgl2/skinScatter.ts +120 -43
  149. package/src/render/backend/webgpu/buffers.ts +2 -1
  150. package/src/render/backend/webgpu/clusterBinner.ts +3 -1
  151. package/src/render/backend/webgpu/flatPass.ts +18 -13
  152. package/src/render/backend/webgpu/particlePass.ts +119 -12
  153. package/src/render/backend/webgpu/postPass.ts +7 -2
  154. package/src/render/backend/webgpu/renderer.ts +589 -113
  155. package/src/render/backend/webgpu/sceneCaptureTarget.ts +63 -0
  156. package/src/render/backend/webgpu/shaders/clusterBin.wgsl.ts +41 -1
  157. package/src/render/backend/webgpu/skinPaletteRing.ts +65 -33
  158. package/src/render/backend/webgpu/skinScatterPass.ts +59 -9
  159. package/src/render/backend/webgpu/surfaceTexturePass.ts +57 -6
  160. package/src/render/bloomChain.ts +69 -0
  161. package/src/render/bloomPass.ts +26 -2
  162. package/src/render/clusteredLights.ts +259 -12
  163. package/src/render/driftLight/bake.ts +3 -1
  164. package/src/render/driftLight/falloff.ts +13 -2
  165. package/src/render/fixtureAtlas.ts +1 -1
  166. package/src/render/gi/bounceLights.ts +12 -3
  167. package/src/render/instanceCull.ts +9 -0
  168. package/src/render/instances.ts +61 -12
  169. package/src/render/lightBudget.ts +88 -4
  170. package/src/render/lightmap.ts +243 -0
  171. package/src/render/mesh.ts +2 -1
  172. package/src/render/particleBatch.ts +95 -4
  173. package/src/render/particleMaterial.ts +11 -1
  174. package/src/render/particlePool.ts +36 -0
  175. package/src/render/particleSort.ts +133 -0
  176. package/src/render/pointLightSelection.ts +20 -1
  177. package/src/render/renderQuality.ts +8 -7
  178. package/src/render/rendererHarness.ts +3 -0
  179. package/src/render/rgb9e5.ts +52 -0
  180. package/src/render/sceneCapture.ts +56 -0
  181. package/src/render/sceneTarget.ts +18 -1
  182. package/src/render/screenSpaceReflection.ts +33 -1
  183. package/src/render/shaders/bloom.ts +20 -3
  184. package/src/render/shaders/bolt.ts +7 -1
  185. package/src/render/shaders/depth.ts +11 -0
  186. package/src/render/shaders/flat/anisotropic.ts +37 -3
  187. package/src/render/shaders/flat/areaLight.ts +142 -0
  188. package/src/render/shaders/flat/glassTint.ts +9 -2
  189. package/src/render/shaders/flat/index.ts +51 -6
  190. package/src/render/shaders/flat/lobes.ts +50 -43
  191. package/src/render/shaders/flat/main.ts +158 -121
  192. package/src/render/shaders/flat/models.ts +170 -21
  193. package/src/render/shaders/flat/normalMap.ts +25 -0
  194. package/src/render/shaders/flat/preamble.ts +84 -40
  195. package/src/render/shaders/flat/skin.ts +6 -5
  196. package/src/render/shaders/generated/bloom.wgsl.ts +13 -3
  197. package/src/render/shaders/generated/bolt.wgsl.ts +1 -1
  198. package/src/render/shaders/generated/depth.wgsl.ts +4 -4
  199. package/src/render/shaders/generated/flat.wgsl.ts +1685 -2082
  200. package/src/render/shaders/generated/particle.wgsl.ts +116 -1
  201. package/src/render/shaders/generated/skinBlur.wgsl.ts +12 -2
  202. package/src/render/shaders/generated/ssrTrace.wgsl.ts +11 -6
  203. package/src/render/shaders/generated/water.wgsl.ts +23 -16
  204. package/src/render/shaders/gi/probeBake.wgsl.ts +9 -5
  205. package/src/render/shaders/particle.ts +134 -1
  206. package/src/render/shaders/recon/motion.wgsl.ts +13 -2
  207. package/src/render/shaders/skinBlur.ts +25 -3
  208. package/src/render/shaders/ssrTrace.ts +20 -1
  209. package/src/render/shaders/water.ts +12 -3
  210. package/src/render/skinBlur.ts +8 -5
  211. package/src/render/skinScatter.ts +3 -1
  212. package/src/render/ssrPass.ts +1 -0
  213. package/src/render/surfaceModel.ts +82 -10
  214. package/src/render/surfaceTexture.ts +101 -3
@@ -3,6 +3,27 @@ import { particleShaders, type ParticleMaterial } from './particleMaterial.ts';
3
3
  import { PARTICLE_BLADES, PARTICLE_INDICES, PARTICLE_VERTS } from './shaders/particle.ts';
4
4
  import type { ParticleInstances } from './particlePool.ts';
5
5
  import { blendCovering, blendKeeping } from './sceneCoverage.ts';
6
+ import type { SurfaceTexture } from './surfaceTexture.ts';
7
+ import { PARTICLE_SPRITE_UNIT } from './lightBudget.ts';
8
+ import { createParticleSort, sortBackToFront, type ParticleSort } from './particleSort.ts';
9
+
10
+ /** The vertex stage's `uCameraFacing` for a facing: the cross 0, the camera 1, the travel 2. */
11
+ export function facingCode(facing: ParticleFacing): number {
12
+ return facing === 'cross' ? 0 : facing === 'velocity' ? 2 : 1;
13
+ }
14
+
15
+ /**
16
+ * A sprite's frame and half-height, two floats a particle, into `out` — the stream the vertex
17
+ * stage reads at location 10. Shared by both backends, so the two read one layout.
18
+ */
19
+ export function packSpriteStream(data: ParticleInstances, count: number, out: Float32Array): void {
20
+ const frames = data.frames;
21
+ const heights = data.heights;
22
+ for (let i = 0; i < count; i++) {
23
+ out[i * 2] = frames?.[i] ?? 0;
24
+ out[i * 2 + 1] = heights?.[i] ?? 0;
25
+ }
26
+ }
6
27
 
7
28
  /**
8
29
  * One draw call for a live particle pool: one camera-facing quad per particle, or a
@@ -26,9 +47,11 @@ export type ParticleBlend = 'additive' | 'alpha';
26
47
  * `'camera'` is one quad facing the viewer, and the default. `'cross'` is the pair of
27
48
  * world-fixed blades this batch drew before the option existed — see `uCameraFacing` in
28
49
  * `shaders/particle.ts` for the artifact that made the default the other one, and for what
29
- * the cross still buys a caller who wants it back.
50
+ * the cross still buys a caller who wants it back. `'velocity'` faces the camera with its up along
51
+ * the particle's travel across the view — a streak, a spark stretched by its motion — and a
52
+ * particle at rest faces the camera as `'camera'` does.
30
53
  */
31
- export type ParticleFacing = 'camera' | 'cross';
54
+ export type ParticleFacing = 'camera' | 'cross' | 'velocity';
32
55
 
33
56
  export interface ParticleBatchOptions {
34
57
  /**
@@ -78,6 +101,41 @@ export interface ParticleBatchOptions {
78
101
  * shader's own comment on `uCameraFacing` lays out.
79
102
  */
80
103
  facing?: ParticleFacing;
104
+ /**
105
+ * The image a `'sprite'` draws: a surface texture, its first layer. **Required by the sprite** and
106
+ * ignored by the procedural materials. Colour is the particle's times the image's, unlit.
107
+ */
108
+ texture?: SurfaceTexture;
109
+ /**
110
+ * A `'sprite'`'s flipbook: the image as `[columns, rows]` cells, numbered across then down from
111
+ * the top-left, a particle's `frames` naming one. `[1, 1]`, the default, is the whole image.
112
+ */
113
+ cells?: readonly [number, number];
114
+ /**
115
+ * Whether a `'sprite'` blends from a particle's cell toward the next by the fraction of its
116
+ * frame, so a slow flipbook moves rather than steps. Off by default: one cell, sampled once.
117
+ */
118
+ blendCells?: boolean;
119
+ /**
120
+ * Metres over which a `'sprite'` fades where it meets the opaque scene: the hard line a flat card
121
+ * draws where it cuts a floor or a body, softened. **0, the default, is a hard edge.** It reads a
122
+ * copy of the frame's depth, which needs `screenEffects`; without one the edge stays hard and
123
+ * the renderer says so once.
124
+ */
125
+ softDepth?: number;
126
+ /** Metres in front of the eye over which a `'sprite'` fades in. 0, the default, is no fade. */
127
+ cameraFade?: number;
128
+ /**
129
+ * Metres each particle is moved toward the eye before it is drawn, so a sprite born inside a
130
+ * surface is drawn in front of it. 0 by default. Every material reads it.
131
+ */
132
+ cameraOffset?: number;
133
+ /**
134
+ * Whether the live particles are drawn farthest first, the order an alpha blend assumes. Off
135
+ * by default, because sorting costs a pass over the particles on the CPU each frame and an
136
+ * additive blend is a sum that does not care. See `particleSort.ts`.
137
+ */
138
+ sort?: boolean;
81
139
  /**
82
140
  * Another batch whose compiled program this one should reuse.
83
141
  *
@@ -118,6 +176,17 @@ export class ParticleBatch {
118
176
  private readonly ageBuffer: WebGLBuffer;
119
177
  private readonly seedBuffer: WebGLBuffer;
120
178
  private readonly velocityBuffer: WebGLBuffer;
179
+ private readonly spriteBuffer: WebGLBuffer;
180
+ private readonly spriteStaging: Float32Array;
181
+ /** Room to draw farthest first, where the batch asked. See `ParticleBatchOptions.sort`. */
182
+ private readonly order: ParticleSort | null;
183
+ /** The sprite's own: its image, its flipbook and its fades. See `ParticleBatchOptions`. */
184
+ readonly texture: SurfaceTexture | null;
185
+ readonly cells: readonly [number, number];
186
+ readonly blendCells: boolean;
187
+ readonly softDepth: number;
188
+ readonly cameraFade: number;
189
+ readonly cameraOffset: number;
121
190
  private readonly blend: ParticleBlend;
122
191
  private readonly stretchSec: number;
123
192
  private readonly erosion: number;
@@ -152,6 +221,17 @@ export class ParticleBatch {
152
221
  this.coreGain = options.coreGain ?? 1;
153
222
  this.fog = options.fog ?? false;
154
223
  this.facing = options.facing ?? 'camera';
224
+ this.texture = options.texture ?? null;
225
+ this.cells = options.cells ?? [1, 1];
226
+ this.blendCells = options.blendCells === true;
227
+ this.softDepth = Math.max(0, options.softDepth ?? 0);
228
+ this.cameraFade = Math.max(0, options.cameraFade ?? 0);
229
+ this.cameraOffset = options.cameraOffset ?? 0;
230
+ this.order = options.sort === true ? createParticleSort(capacity) : null;
231
+ this.spriteStaging = new Float32Array(capacity * 2);
232
+ if (options.material === 'sprite' && this.texture === null) {
233
+ throw new Error("createParticles: a 'sprite' draws an image, and was given no `texture`");
234
+ }
155
235
 
156
236
  const corners = new Float32Array(PARTICLE_VERTS * 2);
157
237
  const blades = new Float32Array(PARTICLE_VERTS);
@@ -189,6 +269,7 @@ export class ParticleBatch {
189
269
  this.ageBuffer = this.attach(gl, 7, capacity, 1, 1, shaders.label);
190
270
  this.seedBuffer = this.attach(gl, 8, capacity, 1, 1, shaders.label);
191
271
  this.velocityBuffer = this.attach(gl, 9, capacity * 3, 3, 1, shaders.label);
272
+ this.spriteBuffer = this.attach(gl, 10, capacity * 2, 2, 1, shaders.label);
192
273
 
193
274
  const indexBuffer = gl.createBuffer();
194
275
  if (indexBuffer === null) throw new Error(`${shaders.label}: createBuffer failed`);
@@ -204,9 +285,12 @@ export class ParticleBatch {
204
285
  * Only the live prefix of each array, which is what the pool's compaction
205
286
  * exists for: a pool at a tenth of its capacity uploads a tenth of the bytes.
206
287
  */
207
- upload(gl: WebGL2RenderingContext, data: ParticleInstances): void {
288
+ upload(gl: WebGL2RenderingContext, given: ParticleInstances, eye: ArrayLike<number>): void {
289
+ const data = this.order === null ? given : sortBackToFront(given, eye, this.order);
208
290
  const n = data.count;
209
291
  if (n === 0) return;
292
+ packSpriteStream(data, n, this.spriteStaging);
293
+ this.send(gl, this.spriteBuffer, this.spriteStaging, n * 2);
210
294
  this.send(gl, this.positionBuffer, data.positions, n * 3);
211
295
  this.send(gl, this.sizeBuffer, data.sizes, n);
212
296
  this.send(gl, this.spinBuffer, data.spins, n);
@@ -221,7 +305,14 @@ export class ParticleBatch {
221
305
  bindMaterial(gl: WebGL2RenderingContext): void {
222
306
  const u = this.uniforms;
223
307
  gl.uniform1f(u['uStretchSec'] ?? null, this.stretchSec);
224
- gl.uniform1f(u['uCameraFacing'] ?? null, this.facing === 'camera' ? 1 : 0);
308
+ gl.uniform1f(u['uCameraFacing'] ?? null, facingCode(this.facing));
309
+ gl.uniform1f(u['uCameraOffset'] ?? null, this.cameraOffset);
310
+ /* The sprite's flipbook and its image; the fades are the renderer's, which owns the depth. */
311
+ if (this.texture !== null) {
312
+ gl.uniform4f(u['uCells'] ?? null, this.cells[0], this.cells[1], this.blendCells ? 1 : 0, 0);
313
+ this.texture.bind(gl, PARTICLE_SPRITE_UNIT);
314
+ gl.uniform1i(u['uSprite'] ?? null, PARTICLE_SPRITE_UNIT);
315
+ }
225
316
  gl.uniform1f(u['uErosion'] ?? null, this.erosion);
226
317
  gl.uniform1f(u['uCoreGain'] ?? null, this.coreGain);
227
318
  /* Only the mote program declares uFogEnabled, so this is a no-op — not a leak into the
@@ -2,6 +2,7 @@ import {
2
2
  PARTICLE_MOTE_FRAG,
3
3
  PARTICLE_SMOKE_FRAG,
4
4
  PARTICLE_SPARK_FRAG,
5
+ PARTICLE_SPRITE_FRAG,
5
6
  PARTICLE_VERT,
6
7
  } from './shaders/particle.ts';
7
8
  import {
@@ -9,6 +10,7 @@ import {
9
10
  PARTICLE_MOTE_FRAG_WGSL,
10
11
  PARTICLE_SMOKE_FRAG_WGSL,
11
12
  PARTICLE_SPARK_FRAG_WGSL,
13
+ PARTICLE_SPRITE_FRAG_WGSL,
12
14
  PARTICLE_VERT_WGSL,
13
15
  } from './shaders/generated/particle.wgsl.ts';
14
16
 
@@ -27,7 +29,7 @@ import {
27
29
  * document that. Naming them makes the pairing explicit instead of leaving it to a caller to
28
30
  * match a source with the constants that suit it.
29
31
  */
30
- export type ParticleMaterial = 'smoke' | 'spark' | 'mote';
32
+ export type ParticleMaterial = 'smoke' | 'spark' | 'mote' | 'sprite';
31
33
 
32
34
  /** Both compilations of one material, and what the generator recorded about each stage. */
33
35
  export interface ParticleShaders {
@@ -66,6 +68,14 @@ const MATERIALS: Readonly<Record<ParticleMaterial, ParticleShaders>> = {
66
68
  vertexWgsl: PARTICLE_VERT_WGSL,
67
69
  fragmentWgsl: PARTICLE_MOTE_FRAG_WGSL,
68
70
  },
71
+ /* An image a consumer supplies, not a shape this engine draws: see `PARTICLE_SPRITE_FRAG`. */
72
+ sprite: {
73
+ label: 'particle:sprite',
74
+ vertexSource: PARTICLE_VERT,
75
+ fragmentSource: PARTICLE_SPRITE_FRAG,
76
+ vertexWgsl: PARTICLE_VERT_WGSL,
77
+ fragmentWgsl: PARTICLE_SPRITE_FRAG_WGSL,
78
+ },
69
79
  };
70
80
 
71
81
  /** What the generator recorded about the shared vertex stage. */
@@ -38,10 +38,44 @@ export interface ParticleInstances {
38
38
  seeds: Float32Array;
39
39
  /** Current velocity, three floats each: what a streak is stretched along. */
40
40
  velocities: Float32Array;
41
+ /**
42
+ * A sprite's flipbook frame, one float each: its whole part names a cell of the image, counted
43
+ * across then down from the top-left, and its fraction is how far toward the next a batch that
44
+ * blends cells has come. Read by the `'sprite'` material alone. Absent is frame 0.
45
+ */
46
+ frames?: Float32Array;
47
+ /**
48
+ * A sprite's half-height in metres, one float each, beside `sizes`' half-width — a sprite need
49
+ * not be square. Read by the `'sprite'` material alone; absent or 0 is as tall as it is wide.
50
+ */
51
+ heights?: Float32Array;
41
52
  count: number;
42
53
  capacity: number;
43
54
  }
44
55
 
56
+ /**
57
+ * Floats one particle takes in a buffer a caller's own compute writes (`DeviceParticles`), in this
58
+ * order: position 3, half-width 1, roll 1, colour 3, opacity 1, age 1, seed 1, velocity 3, a
59
+ * sprite's frame 1 and its half-height 1 — `ParticleInstances`' streams, interleaved as the pool's
60
+ * own upload interleaves them. 64 bytes a particle.
61
+ */
62
+ export const DEVICE_PARTICLE_FLOATS = 16;
63
+
64
+ /**
65
+ * Particles a caller's own compute shader wrote into a buffer of its own (`registerCompute`), drawn
66
+ * where they lie by `drawDeviceParticles` rather than read back and uploaded again. WebGPU only.
67
+ *
68
+ * **Not sorted**, as a pool's are where its batch asks: the device holds them, so an alpha-blended
69
+ * set that must be drawn far to near sorts itself. A slot with a half-width of 0 draws nothing, which
70
+ * is how a fixed-size buffer holds fewer live particles than it has room for.
71
+ */
72
+ export interface DeviceParticles {
73
+ /** A buffer with `VERTEX` usage, `DEVICE_PARTICLE_FLOATS` floats a particle. */
74
+ readonly buffer: GPUBuffer;
75
+ /** How many particles from its start to draw; no more than the buffer holds. */
76
+ readonly count: number;
77
+ }
78
+
45
79
  function createParticleInstances(capacity: number): ParticleInstances {
46
80
  return {
47
81
  positions: new Float32Array(capacity * 3),
@@ -52,6 +86,8 @@ function createParticleInstances(capacity: number): ParticleInstances {
52
86
  ages: new Float32Array(capacity),
53
87
  seeds: new Float32Array(capacity),
54
88
  velocities: new Float32Array(capacity * 3),
89
+ frames: new Float32Array(capacity),
90
+ heights: new Float32Array(capacity),
55
91
  count: 0,
56
92
  capacity,
57
93
  };
@@ -0,0 +1,133 @@
1
+ /**
2
+ * A pool's particles, farthest from the eye first, for a blended batch that asks to be sorted.
3
+ *
4
+ * **Why a blended batch wants it.** An alpha blend is order-dependent: a near puff drawn before a
5
+ * far one is covered by it, and a cloud of them drawn in the order they were born flickers as
6
+ * that order and the camera disagree. Back to front is the order the blend assumes. Additive
7
+ * blending is a sum and needs none of it, which is why sorting is the batch's choice.
8
+ *
9
+ * **Backend-neutral, as a copy**: the sorted particles are written into a second set of streams the
10
+ * batch owns, and each backend uploads that set exactly as it uploads an unsorted one — so the
11
+ * order is one decision, not two, by the 2026-08-13 rule. A merge sort into preallocated scratch,
12
+ * so nothing is allocated per frame however many particles there are; stable, so two particles at
13
+ * one distance keep their order and do not trade places from frame to frame.
14
+ *
15
+ * **What it gives up** is order between batches and within one particle: two sorted batches still
16
+ * draw one after the other, and two crossing cards cut through each other. **What would make it
17
+ * wrong** is a field of particles large enough that sorting it on the CPU costs a frame; the GPU
18
+ * sorts it then.
19
+ */
20
+ import type { ParticleInstances } from './particlePool.ts';
21
+
22
+ /** What sorting a batch keeps between frames: the order, its keys and the sorted copy. */
23
+ export interface ParticleSort {
24
+ readonly order: Uint32Array;
25
+ readonly scratch: Uint32Array;
26
+ readonly keys: Float32Array;
27
+ readonly sorted: ParticleInstances;
28
+ }
29
+
30
+ /** Room to sort `capacity` particles, allocated once with the batch. */
31
+ export function createParticleSort(capacity: number): ParticleSort {
32
+ return {
33
+ order: new Uint32Array(capacity),
34
+ scratch: new Uint32Array(capacity),
35
+ keys: new Float32Array(capacity),
36
+ sorted: {
37
+ positions: new Float32Array(capacity * 3),
38
+ sizes: new Float32Array(capacity),
39
+ spins: new Float32Array(capacity),
40
+ colors: new Float32Array(capacity * 3),
41
+ alphas: new Float32Array(capacity),
42
+ ages: new Float32Array(capacity),
43
+ seeds: new Float32Array(capacity),
44
+ velocities: new Float32Array(capacity * 3),
45
+ frames: new Float32Array(capacity),
46
+ heights: new Float32Array(capacity),
47
+ count: 0,
48
+ capacity,
49
+ },
50
+ };
51
+ }
52
+
53
+ /**
54
+ * `data`'s live particles into `sort.sorted`, farthest from `eye` first, and that copy returned.
55
+ * At most the sort's capacity are kept.
56
+ */
57
+ export function sortBackToFront(
58
+ data: ParticleInstances,
59
+ eye: ArrayLike<number>,
60
+ sort: ParticleSort,
61
+ ): ParticleInstances {
62
+ const count = Math.min(data.count, sort.order.length);
63
+ const { order, keys } = sort;
64
+ const ex = eye[0] ?? 0;
65
+ const ey = eye[1] ?? 0;
66
+ const ez = eye[2] ?? 0;
67
+ for (let i = 0; i < count; i++) {
68
+ const dx = (data.positions[i * 3] as number) - ex;
69
+ const dy = (data.positions[i * 3 + 1] as number) - ey;
70
+ const dz = (data.positions[i * 3 + 2] as number) - ez;
71
+ keys[i] = dx * dx + dy * dy + dz * dz;
72
+ order[i] = i;
73
+ }
74
+ mergeSortFarFirst(order, sort.scratch, keys, count);
75
+
76
+ const out = sort.sorted;
77
+ for (let k = 0; k < count; k++) {
78
+ const i = order[k] as number;
79
+ copy3(data.positions, i, out.positions, k);
80
+ copy3(data.colors, i, out.colors, k);
81
+ copy3(data.velocities, i, out.velocities, k);
82
+ out.sizes[k] = data.sizes[i] as number;
83
+ out.spins[k] = data.spins[i] as number;
84
+ out.alphas[k] = data.alphas[i] as number;
85
+ out.ages[k] = data.ages[i] as number;
86
+ out.seeds[k] = data.seeds[i] as number;
87
+ (out.frames as Float32Array)[k] = data.frames?.[i] ?? 0;
88
+ (out.heights as Float32Array)[k] = data.heights?.[i] ?? 0;
89
+ }
90
+ out.count = count;
91
+ return out;
92
+ }
93
+
94
+ function copy3(from: Float32Array, i: number, to: Float32Array, k: number): void {
95
+ to[k * 3] = from[i * 3] as number;
96
+ to[k * 3 + 1] = from[i * 3 + 1] as number;
97
+ to[k * 3 + 2] = from[i * 3 + 2] as number;
98
+ }
99
+
100
+ /** A bottom-up merge sort of `order` by `keys`, largest first, stable, through `scratch`. */
101
+ function mergeSortFarFirst(
102
+ order: Uint32Array,
103
+ scratch: Uint32Array,
104
+ keys: Float32Array,
105
+ count: number,
106
+ ): void {
107
+ let from = order;
108
+ let to = scratch;
109
+ for (let width = 1; width < count; width *= 2) {
110
+ for (let start = 0; start < count; start += 2 * width) {
111
+ const middle = Math.min(start + width, count);
112
+ const end = Math.min(start + 2 * width, count);
113
+ let a = start;
114
+ let b = middle;
115
+ let at = start;
116
+ while (a < middle && b < end) {
117
+ /* `>=` keeps the left run's particle first on a tie, which is what makes this stable. */
118
+ if ((keys[from[a] as number] as number) >= (keys[from[b] as number] as number)) {
119
+ to[at++] = from[a++] as number;
120
+ } else {
121
+ to[at++] = from[b++] as number;
122
+ }
123
+ }
124
+ while (a < middle) to[at++] = from[a++] as number;
125
+ while (b < end) to[at++] = from[b++] as number;
126
+ }
127
+ const swap = from;
128
+ from = to;
129
+ to = swap;
130
+ }
131
+ /* Copied by hand: a subarray would be a view allocated every frame. */
132
+ if (from !== order) for (let i = 0; i < count; i++) order[i] = from[i] as number;
133
+ }
@@ -1,6 +1,10 @@
1
1
  /** Allocation-free selection and packing of point lights for a fixed shader budget. */
2
2
 
3
- import { POINT_LIGHT_COS_INNER, POINT_LIGHT_COS_OUTER } from './clusteredLights.ts';
3
+ import {
4
+ POINT_LIGHT_COS_INNER,
5
+ POINT_LIGHT_COS_OUTER,
6
+ lightFalloffExponentOf,
7
+ } from './clusteredLights.ts';
4
8
  import { MAX_POINT_LIGHTS, POINT_SHADOW_POOL } from './lightBudget.ts';
5
9
  import { gatherLights } from './lightGrid.ts';
6
10
  import type { LightGrid } from './lightGrid.ts';
@@ -198,6 +202,14 @@ export interface PointLightSource {
198
202
  * whose uniform budget has none to spare.
199
203
  */
200
204
  inLightField?: boolean;
205
+ /**
206
+ * How the light fades with distance when it names its own way: `(1 - (d/R)^2)^n` with no distance
207
+ * term, `R` its radius and `n` this. Unreal's falloff for a light without inverse-square falloff,
208
+ * so a rig authored there — a fill of brightness 160 across 164 m, a key at exponent 8 — lights
209
+ * the space it was authored for rather than one that ends a few metres out. Absent or 0, the
210
+ * frame's own falloff (`pointLightFalloff`), which is every light before 4.8.7.
211
+ */
212
+ falloffExponent?: number;
201
213
  }
202
214
 
203
215
  export interface PointLightBuffer {
@@ -256,6 +268,11 @@ export interface PointLightBuffer {
256
268
  readonly directions: Float32Array;
257
269
  /** Two per slot: the cosine of the inner cone angle, then of the outer. */
258
270
  readonly coneCos: Float32Array;
271
+ /**
272
+ * One per slot: the light's own falloff exponent, or 0 for the frame's falloff. See
273
+ * `PointLightSource.falloffExponent`.
274
+ */
275
+ readonly falloffExponents: Float32Array;
259
276
  /** One per slot: a row of the photometric atlas, or −1 for a light with no profile. */
260
277
  readonly iesProfiles: Float32Array;
261
278
  /** Internal insertion-sort storage; exposed only to keep one flat object. */
@@ -307,6 +324,7 @@ export function createPointLightBuffer(capacity: number = MAX_POINT_LIGHTS): Poi
307
324
  weights: new Float32Array(shaded),
308
325
  directions: new Float32Array(shaded * 3),
309
326
  coneCos: new Float32Array(shaded * 2),
327
+ falloffExponents: new Float32Array(shaded),
310
328
  /* −1 rather than 0, because row 0 is a real fixture and a light that asked for none must not
311
329
  be shaped by whichever profile happened to load first. */
312
330
  iesProfiles: new Float32Array(shaded).fill(-1),
@@ -587,6 +605,7 @@ function choose(
587
605
  * Restricting it to spots would be a smaller feature for no saving.
588
606
  */
589
607
  out.iesProfiles[slot] = light.iesProfile ?? -1;
608
+ out.falloffExponents[slot] = lightFalloffExponentOf(light.falloffExponent);
590
609
  }
591
610
  out.count = chosen;
592
611
 
@@ -219,13 +219,14 @@ export interface RenderQuality {
219
219
  * **`'pre-integrated'`, the default**: the lit stage's fit alone — a terminator softened and
220
220
  * reddened by the surface's curvature, light through thin parts, a penumbra each channel crosses
221
221
  * as far as it travels — at no cost beyond the skin pipeline itself. **`'screen-space'`** adds
222
- * Burley's blur in the picture as well: a skin draw writes its diffuse to a target of its own (it
223
- * is drawn twice to do so), which is spread across the pixels its scatter distance covers and
224
- * added back before anything blended is drawn, so light crosses a shadow's edge and a nostril's
225
- * rim in the picture rather than in a fit. See `skinBlur.ts`.
226
- *
227
- * Construction-time: two half-float targets the size of the frame, and a multisampled one under
228
- * multisampling, made at the first skin draw. It needs the composite (`screenEffects`), which is
222
+ * Burley's blur in the picture as well: a skin draw writes the light its diffuse is made of to a
223
+ * target of its own and its colour to another (it is drawn three times to do so), and the light is
224
+ * spread across the pixels its scatter distance covers, multiplied by the colour and added back
225
+ * before anything blended is drawn, so light crosses a shadow's edge and a nostril's rim in the
226
+ * picture rather than in a fit. See `skinBlur.ts`.
227
+ *
228
+ * Construction-time: three half-float targets the size of the frame, and two multisampled ones
229
+ * under multisampling, made at the first skin draw. It needs the composite (`screenEffects`), which is
229
230
  * where the frame is still linear light to add to; without one, skin is pre-integrated and the
230
231
  * renderer says so once. What would make the default wrong is a close-up face that reads as wax
231
232
  * at a shadow's edge — that is what the blur is for.
@@ -119,6 +119,9 @@ export function recordingGl(
119
119
  ONE: 1,
120
120
  ONE_MINUS_SRC_ALPHA: 0x0303,
121
121
  TRIANGLES: 0x0004,
122
+ /* The winding a pass names, so a test can tell which one it chose. */
123
+ CW: 0x0900,
124
+ CCW: 0x0901,
122
125
  TEXTURE0: 0x84c0,
123
126
  DEPTH_BUFFER_BIT: 0x00000100,
124
127
  DEPTH_TEST: 0x0b71,
@@ -0,0 +1,52 @@
1
+ /**
2
+ * rgb9e5: three 9-bit mantissas over one shared 5-bit exponent, an unsigned float colour in four
3
+ * bytes. `EXT_texture_shared_exponent`'s encoding, the one a GPU's `rgb9e5ufloat` reads, written
4
+ * out so a lightmap page can hold its irradiance in four bytes a texel against a half float's eight.
5
+ *
6
+ * **What it gives up**: precision in the smaller channels, which share the largest one's exponent —
7
+ * a channel a thousandth of its neighbour keeps about one bit — and anything negative or past
8
+ * 65,408, which it clamps. Light is neither, and a bake's colours rarely span more than a few
9
+ * octaves within one texel, so a lightmap loses nothing visible; a value that needs each channel
10
+ * exact wants a format of its own.
11
+ *
12
+ * The bits, low to high: red in 0–8, green in 9–17, blue in 18–26, the exponent in 27–31, biased by
13
+ * 15, and a value is `mantissa * 2^(exponent - 15 - 9)`.
14
+ */
15
+
16
+ const BIAS = 15;
17
+ const MANTISSA_BITS = 9;
18
+ const MANTISSA_VALUES = 1 << MANTISSA_BITS;
19
+ const MAX_EXPONENT = 31;
20
+
21
+ /** The largest value the format holds: 511/512 of 2^16. */
22
+ export const RGB9E5_MAX = ((MANTISSA_VALUES - 1) / MANTISSA_VALUES) * 2 ** (MAX_EXPONENT - BIAS);
23
+
24
+ const clampChannel = (v: number): number => (v > 0 ? Math.min(v, RGB9E5_MAX) : 0);
25
+
26
+ /** One colour packed into its 32 bits, rounding to nearest; NaN and negatives are zero. */
27
+ export function packRgb9e5(red: number, green: number, blue: number): number {
28
+ const r = clampChannel(red);
29
+ const g = clampChannel(green);
30
+ const b = clampChannel(blue);
31
+ const largest = Math.max(r, g, b);
32
+ let exponent = Math.max(-BIAS - 1, Math.floor(Math.log2(largest))) + 1 + BIAS;
33
+ let scale = 2 ** (exponent - BIAS - MANTISSA_BITS);
34
+ /* Rounding the largest channel up to 512 needs one more exponent, which the spec allows for. */
35
+ if (Math.floor(largest / scale + 0.5) === MANTISSA_VALUES) {
36
+ exponent += 1;
37
+ scale *= 2;
38
+ }
39
+ /* Inline rather than a helper: a page packs millions of texels, and a closure a call is one each. */
40
+ const mr = Math.min(MANTISSA_VALUES - 1, Math.floor(r / scale + 0.5));
41
+ const mg = Math.min(MANTISSA_VALUES - 1, Math.floor(g / scale + 0.5));
42
+ const mb = Math.min(MANTISSA_VALUES - 1, Math.floor(b / scale + 0.5));
43
+ return (mr | (mg << 9) | (mb << 18) | (exponent << 27)) >>> 0;
44
+ }
45
+
46
+ /** A packed colour's three channels, into `out[at…at+2]`. */
47
+ export function unpackRgb9e5(bits: number, out: { [index: number]: number }, at = 0): void {
48
+ const scale = 2 ** ((bits >>> 27) - BIAS - MANTISSA_BITS);
49
+ out[at] = (bits & 511) * scale;
50
+ out[at + 1] = ((bits >>> 9) & 511) * scale;
51
+ out[at + 2] = ((bits >>> 18) & 511) * scale;
52
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * A scene capture: a texture the world is drawn into from a camera of the caller's, which a material
3
+ * then shows like any image — a screen in the world showing what a camera elsewhere sees, a monitor,
4
+ * a security feed. The planar reflection's pass, for a caller's camera rather than a mirrored one.
5
+ *
6
+ * **It holds radiance, not display pixels**, as a probe does: drawn with the output transform held
7
+ * off, at the colour format a probe takes — half floats under `hdrScene`, bytes otherwise — and
8
+ * sampled linear, so a material showing it as its `emissive` emits the light the capture saw and the
9
+ * frame's grade then applies once, to the screen as to everything around it.
10
+ *
11
+ * **What it gives up**: it is the mesh pass alone, as a probe bake is — the sky, water, particles and
12
+ * anything else a scene draws after its world are in the capture only if the callback draws them;
13
+ * no temporal resolve, bloom or other effect after the world runs on it; and it has no mip chain, so
14
+ * a screen seen small and far shimmers where a mipmapped image would not.
15
+ */
16
+
17
+ /** What a capture's texture is made from, in place of an image: its size and colour format. */
18
+ export interface SceneCaptureTexels {
19
+ readonly kind: 'scene-capture';
20
+ readonly width: number;
21
+ readonly height: number;
22
+ /** Half floats, which hold light past one; bytes otherwise. */
23
+ readonly float: boolean;
24
+ }
25
+
26
+ /** The largest side a capture may have, the size every WebGL2 device must take. */
27
+ export const MAX_SCENE_CAPTURE_SIZE = 4096;
28
+
29
+ /** A capture's description, refused by name where a side is not a whole number of pixels it can be. */
30
+ export function sceneCaptureTexels(
31
+ width: number,
32
+ height: number,
33
+ float: boolean,
34
+ ): SceneCaptureTexels {
35
+ for (const [name, side] of [
36
+ ['width', width],
37
+ ['height', height],
38
+ ] as const) {
39
+ if (!Number.isInteger(side) || side < 1 || side > MAX_SCENE_CAPTURE_SIZE) {
40
+ throw new Error(
41
+ `createSceneCapture: ${name} must be a whole number from 1 to ${MAX_SCENE_CAPTURE_SIZE}, ` +
42
+ `not ${String(side)}`,
43
+ );
44
+ }
45
+ }
46
+ return { kind: 'scene-capture', width, height, float };
47
+ }
48
+
49
+ /** Whether a texture source is a capture's description rather than an image. */
50
+ export function isSceneCaptureTexels(source: unknown): source is SceneCaptureTexels {
51
+ return (
52
+ typeof source === 'object' &&
53
+ source !== null &&
54
+ (source as { kind?: unknown }).kind === 'scene-capture'
55
+ );
56
+ }
@@ -497,6 +497,15 @@ export class SceneTarget {
497
497
  * must reject it in the same place or the accumulation and the revealage cover different
498
498
  * fragments.
499
499
  */
500
+ /** The frame's size in pixels, as its targets are allocated. */
501
+ get frameWidth(): number {
502
+ return this.width;
503
+ }
504
+
505
+ get frameHeight(): number {
506
+ return this.height;
507
+ }
508
+
500
509
  /**
501
510
  * The depth the frame draws against, for a second framebuffer to share: skin's diffuse half,
502
511
  * which tests against it and writes none. The multisampled renderbuffer where the frame is
@@ -783,6 +792,8 @@ export class SceneTarget {
783
792
  readonly strength: number;
784
793
  /** In scene units, which is why this wants `keepsRange`. See `BloomPass`. */
785
794
  readonly threshold: number;
795
+ /** The ramp and each level's tint, as `resolveBloomResponse` writes them. */
796
+ readonly response: Float32Array;
786
797
  },
787
798
  /*
788
799
  * Depth of field, between the camera smear and the occlusion in the shader's own order. Like
@@ -966,7 +977,13 @@ export class SceneTarget {
966
977
  let bloomTexture: WebGLTexture | null = null;
967
978
  if (bloom !== undefined && scene !== null) {
968
979
  this.bloom ??= new BloomPass(gl);
969
- bloomTexture = this.bloom.run(scene, this.width, this.height, bloom.threshold);
980
+ bloomTexture = this.bloom.run(
981
+ scene,
982
+ this.width,
983
+ this.height,
984
+ bloom.threshold,
985
+ bloom.response,
986
+ );
970
987
  }
971
988
 
972
989
  /*