@driftengine/core 4.8.4 → 4.8.6

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 (138) hide show
  1. package/CHANGELOG.json +4332 -4250
  2. package/dist/build/shaderComments.js +0 -0
  3. package/dist/index.d.ts +10 -2
  4. package/dist/index.js +11 -1
  5. package/dist/render/ambientHarmonics.d.ts +46 -0
  6. package/dist/render/ambientHarmonics.js +80 -0
  7. package/dist/render/areaLights.d.ts +19 -0
  8. package/dist/render/areaLights.js +20 -0
  9. package/dist/render/backend/api.d.ts +13 -2
  10. package/dist/render/backend/webgl2/instanced.d.ts +2 -0
  11. package/dist/render/backend/webgl2/instanced.js +11 -2
  12. package/dist/render/backend/webgl2/renderer.d.ts +74 -1
  13. package/dist/render/backend/webgl2/renderer.js +229 -37
  14. package/dist/render/backend/webgl2/skinScatter.d.ts +14 -0
  15. package/dist/render/backend/webgl2/skinScatter.js +88 -26
  16. package/dist/render/backend/webgpu/buffers.js +2 -1
  17. package/dist/render/backend/webgpu/clusterBinner.js +3 -2
  18. package/dist/render/backend/webgpu/flatPass.d.ts +7 -5
  19. package/dist/render/backend/webgpu/flatPass.js +15 -13
  20. package/dist/render/backend/webgpu/particlePass.d.ts +37 -4
  21. package/dist/render/backend/webgpu/particlePass.js +80 -12
  22. package/dist/render/backend/webgpu/postPass.d.ts +6 -1
  23. package/dist/render/backend/webgpu/postPass.js +7 -2
  24. package/dist/render/backend/webgpu/renderer.d.ts +58 -7
  25. package/dist/render/backend/webgpu/renderer.js +329 -78
  26. package/dist/render/backend/webgpu/shaders/clusterBin.wgsl.d.ts +1 -1
  27. package/dist/render/backend/webgpu/shaders/clusterBin.wgsl.js +41 -1
  28. package/dist/render/backend/webgpu/skinPaletteRing.d.ts +40 -28
  29. package/dist/render/backend/webgpu/skinPaletteRing.js +69 -32
  30. package/dist/render/backend/webgpu/skinScatterPass.d.ts +10 -2
  31. package/dist/render/backend/webgpu/skinScatterPass.js +59 -9
  32. package/dist/render/backend/webgpu/surfaceTexturePass.d.ts +2 -1
  33. package/dist/render/backend/webgpu/surfaceTexturePass.js +18 -4
  34. package/dist/render/bloomChain.d.ts +44 -0
  35. package/dist/render/bloomChain.js +28 -0
  36. package/dist/render/bloomPass.d.ts +3 -1
  37. package/dist/render/bloomPass.js +16 -2
  38. package/dist/render/clusteredLights.d.ts +55 -1
  39. package/dist/render/clusteredLights.js +182 -8
  40. package/dist/render/fixtureAtlas.js +1 -1
  41. package/dist/render/instanceCull.js +7 -0
  42. package/dist/render/instances.d.ts +32 -10
  43. package/dist/render/instances.js +17 -8
  44. package/dist/render/lightBudget.d.ts +26 -1
  45. package/dist/render/lightBudget.js +26 -3
  46. package/dist/render/lightmap.d.ts +102 -0
  47. package/dist/render/lightmap.js +118 -0
  48. package/dist/render/mesh.js +2 -1
  49. package/dist/render/particleBatch.d.ts +59 -3
  50. package/dist/render/particleBatch.js +53 -2
  51. package/dist/render/particleMaterial.d.ts +6 -1
  52. package/dist/render/particleMaterial.js +10 -2
  53. package/dist/render/particlePool.d.ts +11 -0
  54. package/dist/render/particlePool.js +2 -0
  55. package/dist/render/particleSort.d.ts +34 -0
  56. package/dist/render/particleSort.js +96 -0
  57. package/dist/render/sceneTarget.d.ts +5 -0
  58. package/dist/render/sceneTarget.js +8 -1
  59. package/dist/render/shaders/bloom.d.ts +8 -4
  60. package/dist/render/shaders/bloom.js +20 -3
  61. package/dist/render/shaders/flat/areaLight.d.ts +10 -0
  62. package/dist/render/shaders/flat/areaLight.js +96 -0
  63. package/dist/render/shaders/flat/index.js +30 -4
  64. package/dist/render/shaders/flat/main.d.ts +1 -1
  65. package/dist/render/shaders/flat/main.js +107 -105
  66. package/dist/render/shaders/flat/models.d.ts +16 -4
  67. package/dist/render/shaders/flat/models.js +64 -4
  68. package/dist/render/shaders/flat/normalMap.d.ts +10 -0
  69. package/dist/render/shaders/flat/normalMap.js +25 -0
  70. package/dist/render/shaders/flat/preamble.js +74 -37
  71. package/dist/render/shaders/flat/skin.js +6 -5
  72. package/dist/render/shaders/generated/bloom.wgsl.d.ts +13 -3
  73. package/dist/render/shaders/generated/bloom.wgsl.js +13 -3
  74. package/dist/render/shaders/generated/flat.wgsl.d.ts +1631 -2044
  75. package/dist/render/shaders/generated/flat.wgsl.js +1669 -2082
  76. package/dist/render/shaders/generated/particle.wgsl.d.ts +115 -1
  77. package/dist/render/shaders/generated/particle.wgsl.js +115 -1
  78. package/dist/render/shaders/generated/skinBlur.wgsl.d.ts +12 -2
  79. package/dist/render/shaders/generated/skinBlur.wgsl.js +12 -2
  80. package/dist/render/shaders/particle.d.ts +17 -1
  81. package/dist/render/shaders/particle.js +133 -1
  82. package/dist/render/shaders/skinBlur.js +25 -3
  83. package/dist/render/skinBlur.d.ts +8 -5
  84. package/dist/render/skinBlur.js +8 -5
  85. package/dist/render/surfaceModel.d.ts +21 -1
  86. package/dist/render/surfaceModel.js +22 -0
  87. package/dist/render/surfaceTexture.d.ts +14 -1
  88. package/dist/render/surfaceTexture.js +15 -2
  89. package/package.json +6 -6
  90. package/src/build/shaderComments.ts +0 -0
  91. package/src/index.ts +20 -1
  92. package/src/render/ambientHarmonics.ts +92 -0
  93. package/src/render/areaLights.ts +39 -0
  94. package/src/render/backend/api.ts +14 -1
  95. package/src/render/backend/webgl2/instanced.ts +12 -2
  96. package/src/render/backend/webgl2/renderer.ts +292 -35
  97. package/src/render/backend/webgl2/skinScatter.ts +120 -43
  98. package/src/render/backend/webgpu/buffers.ts +2 -1
  99. package/src/render/backend/webgpu/clusterBinner.ts +3 -1
  100. package/src/render/backend/webgpu/flatPass.ts +18 -13
  101. package/src/render/backend/webgpu/particlePass.ts +118 -12
  102. package/src/render/backend/webgpu/postPass.ts +7 -2
  103. package/src/render/backend/webgpu/renderer.ts +396 -95
  104. package/src/render/backend/webgpu/shaders/clusterBin.wgsl.ts +41 -1
  105. package/src/render/backend/webgpu/skinPaletteRing.ts +65 -33
  106. package/src/render/backend/webgpu/skinScatterPass.ts +59 -9
  107. package/src/render/backend/webgpu/surfaceTexturePass.ts +26 -5
  108. package/src/render/bloomChain.ts +69 -0
  109. package/src/render/bloomPass.ts +26 -2
  110. package/src/render/clusteredLights.ts +221 -7
  111. package/src/render/fixtureAtlas.ts +1 -1
  112. package/src/render/instanceCull.ts +5 -0
  113. package/src/render/instances.ts +40 -11
  114. package/src/render/lightBudget.ts +48 -2
  115. package/src/render/lightmap.ts +202 -0
  116. package/src/render/mesh.ts +2 -1
  117. package/src/render/particleBatch.ts +95 -4
  118. package/src/render/particleMaterial.ts +11 -1
  119. package/src/render/particlePool.ts +13 -0
  120. package/src/render/particleSort.ts +133 -0
  121. package/src/render/sceneTarget.ts +18 -1
  122. package/src/render/shaders/bloom.ts +20 -3
  123. package/src/render/shaders/flat/areaLight.ts +96 -0
  124. package/src/render/shaders/flat/index.ts +30 -4
  125. package/src/render/shaders/flat/main.ts +107 -105
  126. package/src/render/shaders/flat/models.ts +73 -8
  127. package/src/render/shaders/flat/normalMap.ts +25 -0
  128. package/src/render/shaders/flat/preamble.ts +74 -37
  129. package/src/render/shaders/flat/skin.ts +6 -5
  130. package/src/render/shaders/generated/bloom.wgsl.ts +13 -3
  131. package/src/render/shaders/generated/flat.wgsl.ts +1669 -2082
  132. package/src/render/shaders/generated/particle.wgsl.ts +116 -1
  133. package/src/render/shaders/generated/skinBlur.wgsl.ts +12 -2
  134. package/src/render/shaders/particle.ts +134 -1
  135. package/src/render/shaders/skinBlur.ts +25 -3
  136. package/src/render/skinBlur.ts +8 -5
  137. package/src/render/surfaceModel.ts +36 -1
  138. package/src/render/surfaceTexture.ts +41 -3
@@ -0,0 +1,202 @@
1
+ /**
2
+ * A baked lightmap: what a consumer hands over, how both backends lay it out, and what a surface
3
+ * reads from it — the backend-neutral half of `createLightmap` and `lightmapModel`, by the
4
+ * 2026-08-13 rule.
5
+ *
6
+ * **What it is for.** A stage built in another engine carries its static light baked: the bounce
7
+ * off its walls, the soft occlusion in its corners, most of the colour a reference render shows.
8
+ * The dynamic lights alone leave everything they do not reach flat. A consumer decodes the bake
9
+ * into pages — one irradiance image and one direction image each — and every lightmapped surface
10
+ * reads its own region of its page, at its second texture coordinates scaled and offset into it.
11
+ *
12
+ * **What a surface adds**, for its shading normal `n` (the normal map's, where it has one):
13
+ *
14
+ * d = direction * 2 - 1
15
+ * light = irradiance(uv) * max(0, dot(d.xyz, n) + d.w)
16
+ * diffuse += albedo * (1 - metal) * light
17
+ *
18
+ * Unreal's high-quality lightmap read, `GetLightMapColorHQ`, once its own vectors have decoded the
19
+ * page — the first-order spherical harmonic its direction carries, in this engine's axes. **Added
20
+ * to what the dynamic lights give**, and to the ambient: a consumer whose bake already holds the
21
+ * sky's light zeroes the ambient for those draws itself, with `setAmbientSH` and nine zeros.
22
+ *
23
+ * **A shading model, because everything it would otherwise take is spent.** The page binds where a
24
+ * model's map does and the region is the model's numbers (`lightmapModel`), so a lightmapped
25
+ * material is a pipeline constant of its own and nothing else pays for it; the surface is otherwise
26
+ * the standard model, unchanged. The second coordinates ride the grain and relief attributes
27
+ * (`MeshData.lightmapUvs`, stored below zero by `withLightmapUvs`), and an instance's region rides
28
+ * its tint and opacity (`MeshInstances.lightmapRegions`). **What it gives up**: a lightmapped
29
+ * surface has no procedural grain or relief, no other shading model, and an instanced batch with
30
+ * regions no tint. **What it does not do**: the GPU-driven pipeline and splats read no page.
31
+ */
32
+ import type { MeshData } from '@driftengine/drft';
33
+ import { toHalfFloats } from './halfFloat.ts';
34
+
35
+ /** One page as a consumer decoded it. */
36
+ export interface LightmapPage {
37
+ readonly width: number;
38
+ readonly height: number;
39
+ /**
40
+ * Linear irradiance, three floats a texel, row-major from the top, in the same units every other
41
+ * light here is in — a bake from another engine scaled by that engine's exposure, as its lights are.
42
+ */
43
+ readonly irradiance: Float32Array;
44
+ /**
45
+ * The direction the light arrives from, four bytes a texel: a first-order spherical harmonic in
46
+ * this engine's world axes, each value stored as `v * 0.5 + 0.5` — xyz the axis, w the constant.
47
+ */
48
+ readonly direction: Uint8Array;
49
+ }
50
+
51
+ /** Layers a page is uploaded as: the irradiance, then the direction. */
52
+ export const LIGHTMAP_LAYERS = 2;
53
+
54
+ /**
55
+ * Where a surface's region of its page is: `lightmapUv = uv2 * scale + bias`, as
56
+ * `[scaleU, scaleV, biasU, biasV]`. The whole page is the identity.
57
+ */
58
+ export type LightmapRegion = readonly [number, number, number, number];
59
+
60
+ /** The whole page. */
61
+ export const WHOLE_PAGE: LightmapRegion = [1, 1, 0, 0];
62
+
63
+ /** A page as both backends upload it: `layers` images of half-float RGBA, row-major from the top. */
64
+ export interface LightmapTexels {
65
+ readonly kind: 'lightmap-texels';
66
+ readonly width: number;
67
+ readonly height: number;
68
+ readonly layers: number;
69
+ readonly texels: Uint16Array;
70
+ }
71
+
72
+ /** Whether a surface texture's source is a page's texels rather than an image or blocks. */
73
+ export function isLightmapTexels(source: unknown): source is LightmapTexels {
74
+ return (
75
+ typeof source === 'object' &&
76
+ source !== null &&
77
+ (source as { kind?: unknown }).kind === 'lightmap-texels'
78
+ );
79
+ }
80
+
81
+ /**
82
+ * A page as both backends upload it: two layers of half-float RGBA, the irradiance with an alpha of
83
+ * one and the direction as the fractions its bytes are. Refuses a page whose arrays are not its
84
+ * size, naming the one that is short.
85
+ */
86
+ export function lightmapTexels(page: LightmapPage): LightmapTexels {
87
+ const { width, height } = page;
88
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
89
+ throw new Error(`createLightmap: a ${width} by ${height} page has nothing in it`);
90
+ }
91
+ const texels = width * height;
92
+ if (page.irradiance.length !== texels * 3) {
93
+ throw new Error(
94
+ `createLightmap: ${page.irradiance.length} irradiance floats for a ${width} by ${height} ` +
95
+ `page, which wants ${texels * 3}: three a texel`,
96
+ );
97
+ }
98
+ if (page.direction.length !== texels * 4) {
99
+ throw new Error(
100
+ `createLightmap: ${page.direction.length} direction bytes for a ${width} by ${height} ` +
101
+ `page, which wants ${texels * 4}: four a texel`,
102
+ );
103
+ }
104
+ const floats = new Float32Array(texels * 4 * LIGHTMAP_LAYERS);
105
+ for (let t = 0; t < texels; t++) {
106
+ floats[t * 4] = page.irradiance[t * 3] as number;
107
+ floats[t * 4 + 1] = page.irradiance[t * 3 + 1] as number;
108
+ floats[t * 4 + 2] = page.irradiance[t * 3 + 2] as number;
109
+ floats[t * 4 + 3] = 1;
110
+ const at = (texels + t) * 4;
111
+ for (let c = 0; c < 4; c++) floats[at + c] = (page.direction[t * 4 + c] as number) / 255;
112
+ }
113
+ return {
114
+ kind: 'lightmap-texels',
115
+ width,
116
+ height,
117
+ layers: LIGHTMAP_LAYERS,
118
+ texels: toHalfFloats(floats),
119
+ };
120
+ }
121
+
122
+ /**
123
+ * A mesh with its second coordinates moved onto the grain and relief streams they ride, or the
124
+ * mesh itself where it has none. Called by both backends' `createMesh`, before anything records
125
+ * which streams the mesh has.
126
+ *
127
+ * **Stored as `-1 - uv`, below zero, and that is what keeps the lanes safe to share.** Grain takes
128
+ * effect only above zero and relief is read through `max(relief, 0)`, so the same mesh drawn by a
129
+ * material that is not lightmapped is a mesh with no grain and no relief — rather than one mottled
130
+ * and bumped by its own coordinates, which is what storing them as they are drew. **What it gives
131
+ * up** is coordinates at or below −1, which read as nothing; a page's coordinates are from 0 to 1.
132
+ */
133
+ export function withLightmapUvs(data: MeshData): MeshData {
134
+ const uvs = data.lightmapUvs;
135
+ if (uvs === undefined) return data;
136
+ const vertices = uvs.length / 2;
137
+ const grain = new Float32Array(vertices);
138
+ const relief = new Float32Array(vertices);
139
+ for (let v = 0; v < vertices; v++) {
140
+ grain[v] = -1 - (uvs[v * 2] as number);
141
+ relief[v] = -1 - (uvs[v * 2 + 1] as number);
142
+ }
143
+ return { ...data, grain, relief, lightmapUvs: undefined };
144
+ }
145
+
146
+ /** A material's region, checked: four finite numbers, the whole page where none was named. */
147
+ export function lightmapRegionOf(region: LightmapRegion | undefined): LightmapRegion {
148
+ if (region === undefined) return WHOLE_PAGE;
149
+ let finite = region.length === 4;
150
+ for (let i = 0; i < region.length; i++) finite &&= Number.isFinite(region[i]);
151
+ if (!finite) {
152
+ throw new Error(
153
+ 'lightmapModel: a region is four numbers, [scaleU, scaleV, biasU, biasV], ' +
154
+ `and was given ${String(region)}`,
155
+ );
156
+ }
157
+ return region;
158
+ }
159
+
160
+ let warnedUnpaged = false;
161
+
162
+ /**
163
+ * Says once that a lightmapped material was set with no page. The lit stage adds nothing for one,
164
+ * which is the defined state; this is what keeps it from also being a silent one.
165
+ */
166
+ export function warnUnpagedLightmap(
167
+ model: { readonly kind: string } | null | undefined,
168
+ map: unknown,
169
+ ): void {
170
+ if (warnedUnpaged || model?.kind !== 'lightmap' || (map !== null && map !== undefined)) return;
171
+ warnedUnpaged = true;
172
+ console.warn(
173
+ 'setMaterial: a lightmapModel material with no modelMap reads no page and adds no light. ' +
174
+ 'Pass the page createLightmap returned as its modelMap.',
175
+ );
176
+ }
177
+
178
+ let warnedRegions = false;
179
+
180
+ /**
181
+ * Says once that an instanced batch and its material disagree about lightmap regions: a batch with
182
+ * regions drawn by a material that is not lightmapped, or a lightmapped one drawn without them. The
183
+ * regions ride the lane a tint and an opacity do (`MeshInstances.lightmapRegions`), so either draw
184
+ * reads one as the other: drawn, and wrong, which is why it is said rather than left.
185
+ */
186
+ export function warnRegionsWithoutPage(
187
+ model: { readonly kind: string } | null | undefined,
188
+ regions: Float32Array | undefined,
189
+ ): void {
190
+ if (warnedRegions) return;
191
+ const lightmapped = model?.kind === 'lightmap';
192
+ if (lightmapped === (regions !== undefined)) return;
193
+ warnedRegions = true;
194
+ console.warn(
195
+ lightmapped
196
+ ? 'drawInstanced: a lightmapModel material drawing a batch with no lightmapRegions reads ' +
197
+ 'its tints as regions. Give each instance its region of the page.'
198
+ : 'drawInstanced: a batch with lightmapRegions drawn by a material that is not a ' +
199
+ 'lightmapModel reads its regions as its tints and opacities. Draw it with its page, or ' +
200
+ 'without regions.',
201
+ );
202
+ }
@@ -400,7 +400,8 @@ export class Mesh {
400
400
  gl.vertexAttribDivisor(location, 1);
401
401
  }
402
402
  gl.enableVertexAttribArray(15);
403
- gl.vertexAttribPointer(15, 3, gl.FLOAT, false, stride, 64);
403
+ /* The tint and, in w, the opacity: one attribute for both. See `INSTANCE_FLOATS`. */
404
+ gl.vertexAttribPointer(15, 4, gl.FLOAT, false, stride, 64);
404
405
  gl.vertexAttribDivisor(15, 1);
405
406
  gl.bindVertexArray(null);
406
407
  return vao;
@@ -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,6 +38,17 @@ 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
  }
@@ -52,6 +63,8 @@ function createParticleInstances(capacity: number): ParticleInstances {
52
63
  ages: new Float32Array(capacity),
53
64
  seeds: new Float32Array(capacity),
54
65
  velocities: new Float32Array(capacity * 3),
66
+ frames: new Float32Array(capacity),
67
+ heights: new Float32Array(capacity),
55
68
  count: 0,
56
69
  capacity,
57
70
  };
@@ -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
+ }
@@ -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
  /*
@@ -27,7 +27,9 @@
27
27
  * 3. **Upsample**, additively back up the chain through a tent filter.
28
28
  *
29
29
  * Deliberately absent, because they were asked against by name: lens dirt, anamorphic streaks
30
- * and ghosting. One threshold, one radius, one strength.
30
+ * and ghosting. One threshold, one radius, one strength — and, since 4.8.6, how a colour comes in
31
+ * past the threshold and a tint for each octave, which is how another engine's stage is matched
32
+ * rather than how this one looks: see `BloomResponse` in `bloomChain.ts`.
31
33
  */
32
34
 
33
35
  /**
@@ -128,6 +130,11 @@ uniform sampler2D uSource;
128
130
  uniform vec2 uTexel;
129
131
  /** In scene units. 1.0 is "brighter than white", which is the useful place to start. */
130
132
  uniform float uThreshold;
133
+ /**
134
+ * Scene units over which a colour comes in past the threshold, or 0 to subtract the threshold
135
+ * instead. See \`BloomResponse.ramp\`.
136
+ */
137
+ uniform float uRamp;
131
138
 
132
139
  out vec4 fragColor;
133
140
 
@@ -144,6 +151,8 @@ ${BLOOM_BRIGHTNESS}
144
151
  vec3 fetch(vec2 uv) {
145
152
  vec3 c = min(textureLod(uSource, uv, 0.0).rgb, vec3(BLOOM_CEILING));
146
153
  float bright = brightness(c);
154
+ /* A ramp keeps a share of the colour, all of it a ramp's width past the line. */
155
+ if (uRamp > 0.0) return c * clamp((bright - uThreshold) / uRamp, 0.0, 1.0);
147
156
  return c * (max(bright - uThreshold, 0.0) / max(bright, 1e-4));
148
157
  }
149
158
 
@@ -194,7 +203,9 @@ ${BLOOM_BOXES}
194
203
  /**
195
204
  * Stage three: a 3x3 tent, added into the level above.
196
205
  *
197
- * **Additive rather than a mix, and each octave counts the same.** A lone bright pixel arrives
206
+ * **Additive rather than a mix, and each octave counts the same** unless a tint says otherwise:
207
+ * the level being added onto is multiplied by its own tint as it is, by the blend, and the deepest
208
+ * by `uScale` on the first draw. White everywhere is exactly the sum. A lone bright pixel arrives
198
209
  * at level n divided by four to the n, spread over four to the n pixels, so summing the levels
199
210
  * lifts its own pixel by about a third and lays a long shallow tail around it. That falloff is
200
211
  * what a real glare looks like; a single gaussian gives a disc with an end to it.
@@ -209,6 +220,12 @@ precision highp float;
209
220
  in vec2 vUv;
210
221
 
211
222
  uniform sampler2D uSource;
223
+ /**
224
+ * What the level being read is multiplied by: its tint, on the first draw up from the deepest
225
+ * level, and white on every other — each other level's tint is applied to the level below as it
226
+ * is added onto, by the blend. See \`BloomResponse.tints\`.
227
+ */
228
+ uniform vec3 uScale;
212
229
  /** How far the tent reaches, in UV. One number for the whole chain. */
213
230
  uniform float uRadius;
214
231
 
@@ -227,6 +244,6 @@ void main() {
227
244
  vec3 i = textureLod(uSource, vUv + vec2( r, -r), 0.0).rgb;
228
245
 
229
246
  vec3 sum = e * 4.0 + (b + d + f + h) * 2.0 + (a + c + g + i);
230
- fragColor = vec4(sum * 0.0625, 1.0);
247
+ fragColor = vec4(sum * 0.0625 * uScale, 1.0);
231
248
  }
232
249
  `;