@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.
- package/CHANGELOG.json +4332 -4250
- package/dist/build/shaderComments.js +0 -0
- package/dist/index.d.ts +10 -2
- package/dist/index.js +11 -1
- package/dist/render/ambientHarmonics.d.ts +46 -0
- package/dist/render/ambientHarmonics.js +80 -0
- package/dist/render/areaLights.d.ts +19 -0
- package/dist/render/areaLights.js +20 -0
- package/dist/render/backend/api.d.ts +13 -2
- package/dist/render/backend/webgl2/instanced.d.ts +2 -0
- package/dist/render/backend/webgl2/instanced.js +11 -2
- package/dist/render/backend/webgl2/renderer.d.ts +74 -1
- package/dist/render/backend/webgl2/renderer.js +229 -37
- package/dist/render/backend/webgl2/skinScatter.d.ts +14 -0
- package/dist/render/backend/webgl2/skinScatter.js +88 -26
- package/dist/render/backend/webgpu/buffers.js +2 -1
- package/dist/render/backend/webgpu/clusterBinner.js +3 -2
- package/dist/render/backend/webgpu/flatPass.d.ts +7 -5
- package/dist/render/backend/webgpu/flatPass.js +15 -13
- package/dist/render/backend/webgpu/particlePass.d.ts +37 -4
- package/dist/render/backend/webgpu/particlePass.js +80 -12
- package/dist/render/backend/webgpu/postPass.d.ts +6 -1
- package/dist/render/backend/webgpu/postPass.js +7 -2
- package/dist/render/backend/webgpu/renderer.d.ts +58 -7
- package/dist/render/backend/webgpu/renderer.js +329 -78
- package/dist/render/backend/webgpu/shaders/clusterBin.wgsl.d.ts +1 -1
- package/dist/render/backend/webgpu/shaders/clusterBin.wgsl.js +41 -1
- package/dist/render/backend/webgpu/skinPaletteRing.d.ts +40 -28
- package/dist/render/backend/webgpu/skinPaletteRing.js +69 -32
- package/dist/render/backend/webgpu/skinScatterPass.d.ts +10 -2
- package/dist/render/backend/webgpu/skinScatterPass.js +59 -9
- package/dist/render/backend/webgpu/surfaceTexturePass.d.ts +2 -1
- package/dist/render/backend/webgpu/surfaceTexturePass.js +18 -4
- package/dist/render/bloomChain.d.ts +44 -0
- package/dist/render/bloomChain.js +28 -0
- package/dist/render/bloomPass.d.ts +3 -1
- package/dist/render/bloomPass.js +16 -2
- package/dist/render/clusteredLights.d.ts +55 -1
- package/dist/render/clusteredLights.js +182 -8
- package/dist/render/fixtureAtlas.js +1 -1
- package/dist/render/instanceCull.js +7 -0
- package/dist/render/instances.d.ts +32 -10
- package/dist/render/instances.js +17 -8
- package/dist/render/lightBudget.d.ts +26 -1
- package/dist/render/lightBudget.js +26 -3
- package/dist/render/lightmap.d.ts +102 -0
- package/dist/render/lightmap.js +118 -0
- package/dist/render/mesh.js +2 -1
- package/dist/render/particleBatch.d.ts +59 -3
- package/dist/render/particleBatch.js +53 -2
- package/dist/render/particleMaterial.d.ts +6 -1
- package/dist/render/particleMaterial.js +10 -2
- package/dist/render/particlePool.d.ts +11 -0
- package/dist/render/particlePool.js +2 -0
- package/dist/render/particleSort.d.ts +34 -0
- package/dist/render/particleSort.js +96 -0
- package/dist/render/sceneTarget.d.ts +5 -0
- package/dist/render/sceneTarget.js +8 -1
- package/dist/render/shaders/bloom.d.ts +8 -4
- package/dist/render/shaders/bloom.js +20 -3
- package/dist/render/shaders/flat/areaLight.d.ts +10 -0
- package/dist/render/shaders/flat/areaLight.js +96 -0
- package/dist/render/shaders/flat/index.js +30 -4
- package/dist/render/shaders/flat/main.d.ts +1 -1
- package/dist/render/shaders/flat/main.js +107 -105
- package/dist/render/shaders/flat/models.d.ts +16 -4
- package/dist/render/shaders/flat/models.js +64 -4
- package/dist/render/shaders/flat/normalMap.d.ts +10 -0
- package/dist/render/shaders/flat/normalMap.js +25 -0
- package/dist/render/shaders/flat/preamble.js +74 -37
- package/dist/render/shaders/flat/skin.js +6 -5
- package/dist/render/shaders/generated/bloom.wgsl.d.ts +13 -3
- package/dist/render/shaders/generated/bloom.wgsl.js +13 -3
- package/dist/render/shaders/generated/flat.wgsl.d.ts +1631 -2044
- package/dist/render/shaders/generated/flat.wgsl.js +1669 -2082
- package/dist/render/shaders/generated/particle.wgsl.d.ts +115 -1
- package/dist/render/shaders/generated/particle.wgsl.js +115 -1
- package/dist/render/shaders/generated/skinBlur.wgsl.d.ts +12 -2
- package/dist/render/shaders/generated/skinBlur.wgsl.js +12 -2
- package/dist/render/shaders/particle.d.ts +17 -1
- package/dist/render/shaders/particle.js +133 -1
- package/dist/render/shaders/skinBlur.js +25 -3
- package/dist/render/skinBlur.d.ts +8 -5
- package/dist/render/skinBlur.js +8 -5
- package/dist/render/surfaceModel.d.ts +21 -1
- package/dist/render/surfaceModel.js +22 -0
- package/dist/render/surfaceTexture.d.ts +14 -1
- package/dist/render/surfaceTexture.js +15 -2
- package/package.json +6 -6
- package/src/build/shaderComments.ts +0 -0
- package/src/index.ts +20 -1
- package/src/render/ambientHarmonics.ts +92 -0
- package/src/render/areaLights.ts +39 -0
- package/src/render/backend/api.ts +14 -1
- package/src/render/backend/webgl2/instanced.ts +12 -2
- package/src/render/backend/webgl2/renderer.ts +292 -35
- package/src/render/backend/webgl2/skinScatter.ts +120 -43
- package/src/render/backend/webgpu/buffers.ts +2 -1
- package/src/render/backend/webgpu/clusterBinner.ts +3 -1
- package/src/render/backend/webgpu/flatPass.ts +18 -13
- package/src/render/backend/webgpu/particlePass.ts +118 -12
- package/src/render/backend/webgpu/postPass.ts +7 -2
- package/src/render/backend/webgpu/renderer.ts +396 -95
- package/src/render/backend/webgpu/shaders/clusterBin.wgsl.ts +41 -1
- package/src/render/backend/webgpu/skinPaletteRing.ts +65 -33
- package/src/render/backend/webgpu/skinScatterPass.ts +59 -9
- package/src/render/backend/webgpu/surfaceTexturePass.ts +26 -5
- package/src/render/bloomChain.ts +69 -0
- package/src/render/bloomPass.ts +26 -2
- package/src/render/clusteredLights.ts +221 -7
- package/src/render/fixtureAtlas.ts +1 -1
- package/src/render/instanceCull.ts +5 -0
- package/src/render/instances.ts +40 -11
- package/src/render/lightBudget.ts +48 -2
- package/src/render/lightmap.ts +202 -0
- package/src/render/mesh.ts +2 -1
- package/src/render/particleBatch.ts +95 -4
- package/src/render/particleMaterial.ts +11 -1
- package/src/render/particlePool.ts +13 -0
- package/src/render/particleSort.ts +133 -0
- package/src/render/sceneTarget.ts +18 -1
- package/src/render/shaders/bloom.ts +20 -3
- package/src/render/shaders/flat/areaLight.ts +96 -0
- package/src/render/shaders/flat/index.ts +30 -4
- package/src/render/shaders/flat/main.ts +107 -105
- package/src/render/shaders/flat/models.ts +73 -8
- package/src/render/shaders/flat/normalMap.ts +25 -0
- package/src/render/shaders/flat/preamble.ts +74 -37
- package/src/render/shaders/flat/skin.ts +6 -5
- package/src/render/shaders/generated/bloom.wgsl.ts +13 -3
- package/src/render/shaders/generated/flat.wgsl.ts +1669 -2082
- package/src/render/shaders/generated/particle.wgsl.ts +116 -1
- package/src/render/shaders/generated/skinBlur.wgsl.ts +12 -2
- package/src/render/shaders/particle.ts +134 -1
- package/src/render/shaders/skinBlur.ts +25 -3
- package/src/render/skinBlur.ts +8 -5
- package/src/render/surfaceModel.ts +36 -1
- 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
|
+
}
|
package/src/render/mesh.ts
CHANGED
|
@@ -400,7 +400,8 @@ export class Mesh {
|
|
|
400
400
|
gl.vertexAttribDivisor(location, 1);
|
|
401
401
|
}
|
|
402
402
|
gl.enableVertexAttribArray(15);
|
|
403
|
-
|
|
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,
|
|
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
|
|
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(
|
|
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
|
|
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
|
`;
|