@driftengine/core 4.13.3 → 4.13.5

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 (153) hide show
  1. package/CHANGELOG.json +52 -0
  2. package/README.md +1 -1
  3. package/dist/index.d.ts +3 -2
  4. package/dist/render/anisotropicLobe.d.ts +5 -0
  5. package/dist/render/anisotropicLobe.js +13 -0
  6. package/dist/render/atmosphere.d.ts +79 -1
  7. package/dist/render/atmosphere.js +66 -19
  8. package/dist/render/backend/webgl2/renderer.d.ts +34 -2
  9. package/dist/render/backend/webgl2/renderer.js +44 -12
  10. package/dist/render/backend/webgpu/boltPass.d.ts +1 -1
  11. package/dist/render/backend/webgpu/causticsPass.d.ts +1 -1
  12. package/dist/render/backend/webgpu/clothSetPack.js +1 -0
  13. package/dist/render/backend/webgpu/clothSolvePack.d.ts +5 -2
  14. package/dist/render/backend/webgpu/clothSolvePack.js +2 -1
  15. package/dist/render/backend/webgpu/filmPass.d.ts +1 -1
  16. package/dist/render/backend/webgpu/gpuClothSet.js +1 -1
  17. package/dist/render/backend/webgpu/gpuDrivenPass.js +20 -3
  18. package/dist/render/backend/webgpu/linePass.d.ts +1 -1
  19. package/dist/render/backend/webgpu/mediumFields.d.ts +17 -0
  20. package/dist/render/backend/webgpu/mediumFields.js +74 -0
  21. package/dist/render/backend/webgpu/plumePass.d.ts +3 -3
  22. package/dist/render/backend/webgpu/postPass.d.ts +7 -2
  23. package/dist/render/backend/webgpu/renderer.d.ts +13 -3
  24. package/dist/render/backend/webgpu/renderer.js +86 -150
  25. package/dist/render/backend/webgpu/scatterPass.d.ts +1 -1
  26. package/dist/render/backend/webgpu/shaders/clothSolve.wgsl.d.ts +1 -1
  27. package/dist/render/backend/webgpu/shaders/clothSolve.wgsl.js +7 -5
  28. package/dist/render/backend/webgpu/waterPass.d.ts +1 -1
  29. package/dist/render/environmentMixing.d.ts +18 -0
  30. package/dist/render/environmentMixing.js +40 -0
  31. package/dist/render/fog.d.ts +43 -1
  32. package/dist/render/fog.js +87 -22
  33. package/dist/render/globalMedium.d.ts +112 -10
  34. package/dist/render/globalMedium.js +96 -1
  35. package/dist/render/globalMediumPass.d.ts +7 -3
  36. package/dist/render/globalMediumPass.js +22 -1
  37. package/dist/render/hairLobes.d.ts +27 -2
  38. package/dist/render/hairLobes.js +57 -4
  39. package/dist/render/lightBudget.d.ts +12 -0
  40. package/dist/render/lightBudget.js +24 -0
  41. package/dist/render/sceneTarget.d.ts +5 -3
  42. package/dist/render/sceneTarget.js +14 -8
  43. package/dist/render/shaders/arcane.d.ts +1 -1
  44. package/dist/render/shaders/bolt.d.ts +1 -1
  45. package/dist/render/shaders/caustics.d.ts +1 -1
  46. package/dist/render/shaders/film.d.ts +1 -1
  47. package/dist/render/shaders/film.js +4 -1
  48. package/dist/render/shaders/fire.d.ts +1 -1
  49. package/dist/render/shaders/flat/anisotropic.d.ts +8 -1
  50. package/dist/render/shaders/flat/anisotropic.js +25 -3
  51. package/dist/render/shaders/flat/hair.js +77 -27
  52. package/dist/render/shaders/flat/main.d.ts +1 -1
  53. package/dist/render/shaders/flat/main.js +23 -3
  54. package/dist/render/shaders/flat/models.js +12 -5
  55. package/dist/render/shaders/flat/preamble.js +7 -0
  56. package/dist/render/shaders/fog.d.ts +4 -4
  57. package/dist/render/shaders/fog.js +81 -32
  58. package/dist/render/shaders/generated/arcane.wgsl.d.ts +29 -39
  59. package/dist/render/shaders/generated/arcane.wgsl.js +29 -39
  60. package/dist/render/shaders/generated/bolt.wgsl.d.ts +26 -36
  61. package/dist/render/shaders/generated/bolt.wgsl.js +26 -36
  62. package/dist/render/shaders/generated/caustics.wgsl.d.ts +25 -35
  63. package/dist/render/shaders/generated/caustics.wgsl.js +25 -35
  64. package/dist/render/shaders/generated/film.wgsl.d.ts +28 -38
  65. package/dist/render/shaders/generated/film.wgsl.js +28 -38
  66. package/dist/render/shaders/generated/fire.wgsl.d.ts +28 -38
  67. package/dist/render/shaders/generated/fire.wgsl.js +28 -38
  68. package/dist/render/shaders/generated/flat.wgsl.d.ts +1466 -1546
  69. package/dist/render/shaders/generated/flat.wgsl.js +1489 -1569
  70. package/dist/render/shaders/generated/globalMedium.wgsl.d.ts +98 -12
  71. package/dist/render/shaders/generated/globalMedium.wgsl.js +98 -12
  72. package/dist/render/shaders/generated/line.wgsl.d.ts +25 -35
  73. package/dist/render/shaders/generated/line.wgsl.js +25 -35
  74. package/dist/render/shaders/generated/particle.wgsl.d.ts +100 -140
  75. package/dist/render/shaders/generated/particle.wgsl.js +100 -140
  76. package/dist/render/shaders/generated/rush.wgsl.d.ts +20 -10
  77. package/dist/render/shaders/generated/rush.wgsl.js +20 -10
  78. package/dist/render/shaders/generated/scatter.wgsl.d.ts +25 -35
  79. package/dist/render/shaders/generated/scatter.wgsl.js +25 -35
  80. package/dist/render/shaders/generated/smoke.wgsl.d.ts +28 -38
  81. package/dist/render/shaders/generated/smoke.wgsl.js +28 -38
  82. package/dist/render/shaders/generated/water.wgsl.d.ts +29 -39
  83. package/dist/render/shaders/generated/water.wgsl.js +29 -39
  84. package/dist/render/shaders/globalMedium.js +114 -10
  85. package/dist/render/shaders/gpudriven/blendRaster.wgsl.js +2 -1
  86. package/dist/render/shaders/gpudriven/fog.wgsl.d.ts +10 -4
  87. package/dist/render/shaders/gpudriven/fog.wgsl.js +77 -19
  88. package/dist/render/shaders/gpudriven/frameBlock.wgsl.d.ts +1 -1
  89. package/dist/render/shaders/gpudriven/frameBlock.wgsl.js +12 -1
  90. package/dist/render/shaders/gpudriven/shade.wgsl.js +3 -1
  91. package/dist/render/shaders/line.d.ts +1 -1
  92. package/dist/render/shaders/line.js +4 -2
  93. package/dist/render/shaders/localExposure.d.ts +42 -1
  94. package/dist/render/shaders/localExposure.js +73 -6
  95. package/dist/render/shaders/particle.d.ts +3 -3
  96. package/dist/render/shaders/particle.js +10 -6
  97. package/dist/render/shaders/scatter.d.ts +1 -1
  98. package/dist/render/shaders/scatter.js +4 -2
  99. package/dist/render/shaders/smoke.d.ts +1 -1
  100. package/dist/render/shaders/water.d.ts +1 -1
  101. package/dist/render/shaders/water.js +2 -1
  102. package/dist/render/surfaceModel.d.ts +23 -4
  103. package/dist/render/surfaceModel.js +12 -2
  104. package/package.json +6 -6
  105. package/src/index.ts +12 -2
  106. package/src/render/anisotropicLobe.ts +20 -0
  107. package/src/render/atmosphere.ts +153 -20
  108. package/src/render/backend/webgl2/renderer.ts +63 -13
  109. package/src/render/backend/webgpu/clothSetPack.ts +1 -0
  110. package/src/render/backend/webgpu/clothSolvePack.ts +6 -2
  111. package/src/render/backend/webgpu/gpuClothSet.ts +1 -1
  112. package/src/render/backend/webgpu/gpuDrivenPass.ts +20 -3
  113. package/src/render/backend/webgpu/mediumFields.ts +87 -0
  114. package/src/render/backend/webgpu/renderer.ts +109 -157
  115. package/src/render/backend/webgpu/shaders/clothSolve.wgsl.ts +7 -5
  116. package/src/render/environmentMixing.ts +48 -0
  117. package/src/render/fog.ts +125 -25
  118. package/src/render/globalMedium.ts +214 -11
  119. package/src/render/globalMediumPass.ts +29 -4
  120. package/src/render/hairLobes.ts +109 -3
  121. package/src/render/lightBudget.ts +32 -0
  122. package/src/render/sceneTarget.ts +15 -11
  123. package/src/render/shaders/film.ts +4 -1
  124. package/src/render/shaders/flat/anisotropic.ts +25 -3
  125. package/src/render/shaders/flat/hair.ts +77 -27
  126. package/src/render/shaders/flat/main.ts +23 -3
  127. package/src/render/shaders/flat/models.ts +12 -5
  128. package/src/render/shaders/flat/preamble.ts +7 -0
  129. package/src/render/shaders/fog.ts +81 -32
  130. package/src/render/shaders/generated/arcane.wgsl.ts +29 -39
  131. package/src/render/shaders/generated/bolt.wgsl.ts +26 -36
  132. package/src/render/shaders/generated/caustics.wgsl.ts +25 -35
  133. package/src/render/shaders/generated/film.wgsl.ts +28 -38
  134. package/src/render/shaders/generated/fire.wgsl.ts +28 -38
  135. package/src/render/shaders/generated/flat.wgsl.ts +1489 -1569
  136. package/src/render/shaders/generated/globalMedium.wgsl.ts +98 -12
  137. package/src/render/shaders/generated/line.wgsl.ts +25 -35
  138. package/src/render/shaders/generated/particle.wgsl.ts +100 -140
  139. package/src/render/shaders/generated/rush.wgsl.ts +20 -10
  140. package/src/render/shaders/generated/scatter.wgsl.ts +25 -35
  141. package/src/render/shaders/generated/smoke.wgsl.ts +28 -38
  142. package/src/render/shaders/generated/water.wgsl.ts +29 -39
  143. package/src/render/shaders/globalMedium.ts +114 -10
  144. package/src/render/shaders/gpudriven/blendRaster.wgsl.ts +2 -1
  145. package/src/render/shaders/gpudriven/fog.wgsl.ts +78 -19
  146. package/src/render/shaders/gpudriven/frameBlock.wgsl.ts +12 -1
  147. package/src/render/shaders/gpudriven/shade.wgsl.ts +3 -1
  148. package/src/render/shaders/line.ts +4 -2
  149. package/src/render/shaders/localExposure.ts +115 -6
  150. package/src/render/shaders/particle.ts +10 -6
  151. package/src/render/shaders/scatter.ts +4 -2
  152. package/src/render/shaders/water.ts +2 -1
  153. package/src/render/surfaceModel.ts +41 -6
package/src/render/fog.ts CHANGED
@@ -17,7 +17,12 @@
17
17
  * one argument instead, and the backends keep their own packing.
18
18
  */
19
19
 
20
- import { resolveAtmosphere, type Atmosphere, type ResolvedAtmosphere } from './atmosphere.ts';
20
+ import {
21
+ createResolvedAtmosphere,
22
+ resolveAtmosphere,
23
+ type Atmosphere,
24
+ type ResolvedAtmosphere,
25
+ } from './atmosphere.ts';
21
26
 
22
27
  /** A colour as three numbers, matching `render/orderIndependent.ts`'s own `Rgb`. */
23
28
  export type Rgb = readonly [number, number, number];
@@ -46,6 +51,25 @@ export interface FogOptions {
46
51
  */
47
52
  readonly near: number;
48
53
  readonly far: number;
54
+ /** The medium's second layer: its extinction per metre at the camera's height. Absent, none. */
55
+ readonly density2?: number;
56
+ /** And its reciprocal scale height. */
57
+ readonly heightFalloff2?: number;
58
+ /**
59
+ * The least of a surface the haze must leave, `Atmosphere.fogMaxOpacity`'s complement. Absent or 0,
60
+ * no floor.
61
+ */
62
+ readonly floor?: number;
63
+ /** Metres past which the haze hides nothing. Absent or 0, none. */
64
+ readonly cutoff?: number;
65
+ /** The sun's glow in the haze: its colour. Absent or black, none. */
66
+ readonly glowColour?: Rgb;
67
+ /** The glow's lobe: the cosine of the angle from its light, to this power. */
68
+ readonly glowExponent?: number;
69
+ /** Surface → the glow's light, a unit vector. */
70
+ readonly glowDirection?: Rgb;
71
+ /** Metres past the haze's start before the glow begins. */
72
+ readonly glowStart?: number;
49
73
  }
50
74
 
51
75
  /**
@@ -82,15 +106,37 @@ export function mediumFog(fog: FogOptions, dist: number, pointY: number): number
82
106
  * is perfectly clear, which Beer-Lambert can never be, and height falloff has no meaning here
83
107
  * because a ramp is defined on distance alone. `shaders/fog.ts` argues the whole of it.
84
108
  */
109
+ /* Past the cutoff the air hides nothing, in either mode; the water column keeps its own law. */
110
+ const cutoff = fog.cutoff ?? 0;
111
+ const cut = cutoff > 0 && dist > cutoff;
112
+ const floor = fog.floor ?? 0;
85
113
  if (fog.mode === LINEAR_FOG) {
86
114
  /* A consumer asking for a hard cut writes `far === near`; the guard is why that is a cut. */
87
115
  const span = Math.max(fog.far - fog.near, 1e-4);
88
- const ramp = Math.min(1, Math.max(0, (dist - fog.near) / span));
116
+ const ramp = cut ? 0 : Math.min(Math.min(1, Math.max(0, (dist - fog.near) / span)), 1 - floor);
89
117
  if (fog.underwaterFactor <= 0) return ramp;
90
118
  const wetLinear = fog.underwaterDensity * dist;
91
119
  return mix(ramp, 1 - Math.pow(2, -wetLinear * wetLinear * 1.442695), fog.underwaterFactor);
92
120
  }
93
121
 
122
+ /*
123
+ * Beer-Lambert through both layers, leaving at least `floor` of the surface: a floor on what
124
+ * survives rather than a ceiling on the depth, so the curve keeps its shape above it.
125
+ */
126
+ const air = cut ? 0 : 1 - Math.max(Math.exp(-hazeDepth(fog, dist, pointY)), floor);
127
+ if (fog.underwaterFactor <= 0) return air;
128
+ /* Water is its own medium and keeps its own curve, so crossing the surface changes nothing
129
+ about how the water column reads. */
130
+ const wet = fog.underwaterDensity * dist;
131
+ return mix(air, 1 - Math.pow(2, -wet * wet * 1.442695), fog.underwaterFactor);
132
+ }
133
+
134
+ /**
135
+ * The haze's optical depth from where it begins along the ray to a point at `pointY`, `dist` metres
136
+ * away: each layer's density at the eye carried over what is left of the ray, the second only where
137
+ * there is one. `mediumFog` and `mediumGlow` both integrate it.
138
+ */
139
+ export function hazeDepth(fog: FogOptions, dist: number, pointY: number): number {
94
140
  /*
95
141
  * **Where the medium begins along the ray**, `near` — the linear mode's slot, which this mode
96
142
  * never read before `Atmosphere.fogStart`. The ray is integrated from that point on: the density
@@ -106,16 +152,55 @@ export function mediumFog(fog: FogOptions, dist: number, pointY: number): number
106
152
  * `t = 0` the quotient is 0/0, and 1e-4 of a scale height is 8 mm.
107
153
  */
108
154
  const denom = Math.abs(rest) < 1e-4 ? 1e-4 : rest;
109
- const air =
110
- 1 -
111
- Math.exp(
112
- (-fog.density * Math.exp(-t * before) * (dist - start) * (1 - Math.exp(-denom))) / denom,
113
- );
114
- if (fog.underwaterFactor <= 0) return air;
115
- /* Water is its own medium and keeps its own curve, so crossing the surface changes nothing
116
- about how the water column reads. */
117
- const wet = fog.underwaterDensity * dist;
118
- return mix(air, 1 - Math.pow(2, -wet * wet * 1.442695), fog.underwaterFactor);
155
+ let depth =
156
+ (fog.density * Math.exp(-t * before) * (dist - start) * (1 - Math.exp(-denom))) / denom;
157
+ const density2 = fog.density2 ?? 0;
158
+ if (density2 > 0) {
159
+ const t2 = (pointY - fog.eyeY) * (fog.heightFalloff2 ?? 0);
160
+ const rest2 = t2 * (1 - before);
161
+ const denom2 = Math.abs(rest2) < 1e-4 ? 1e-4 : rest2;
162
+ depth +=
163
+ (density2 * Math.exp(-t2 * before) * (dist - start) * (1 - Math.exp(-denom2))) / denom2;
164
+ }
165
+ return depth;
166
+ }
167
+
168
+ /**
169
+ * The sun's light the haze scatters toward the eye along a ray to a point at `pointY`, `dist`
170
+ * metres away in the unit direction `toPoint`, into `out`: added after the fog, never mixed. A lobe
171
+ * of `cos^exponent` about the light, by as much of the ray past the glow's start as the haze hides.
172
+ * None in a linear ramp, past the cutoff, or under water.
173
+ */
174
+ export function mediumGlow(
175
+ fog: FogOptions,
176
+ dist: number,
177
+ pointY: number,
178
+ toPoint: ArrayLike<number>,
179
+ out: [number, number, number],
180
+ ): [number, number, number] {
181
+ out[0] = 0;
182
+ out[1] = 0;
183
+ out[2] = 0;
184
+ const colour = fog.glowColour;
185
+ if (fog.mode === LINEAR_FOG || colour === undefined) return out;
186
+ if (Math.max(colour[0], colour[1], colour[2]) <= 0) return out;
187
+ const cutoff = fog.cutoff ?? 0;
188
+ if (cutoff > 0 && dist > cutoff) return out;
189
+ const span = dist - Math.min(fog.near, dist);
190
+ if (span <= 0) return out;
191
+ const perMetre = hazeDepth(fog, dist, pointY) / span;
192
+ const hidden = 1 - Math.exp(-perMetre * Math.max(span - (fog.glowStart ?? 0), 0));
193
+ const toward = fog.glowDirection ?? [0, 1, 0];
194
+ const cosine =
195
+ (toPoint[0] as number) * toward[0] +
196
+ (toPoint[1] as number) * toward[1] +
197
+ (toPoint[2] as number) * toward[2];
198
+ const lobe = Math.pow(Math.min(1, Math.max(0, cosine)), fog.glowExponent ?? 1);
199
+ const share = lobe * hidden * (1 - fog.underwaterFactor);
200
+ out[0] = colour[0] * share;
201
+ out[1] = colour[1] * share;
202
+ out[2] = colour[2] * share;
203
+ return out;
119
204
  }
120
205
 
121
206
  /** `FogOptions` with its fields writable, which is what a caller holds and refills a frame at a time. */
@@ -130,6 +215,14 @@ export interface FogTarget {
130
215
  mode: number;
131
216
  near: number;
132
217
  far: number;
218
+ density2: number;
219
+ heightFalloff2: number;
220
+ floor: number;
221
+ cutoff: number;
222
+ glowColour: [number, number, number];
223
+ glowExponent: number;
224
+ glowDirection: [number, number, number];
225
+ glowStart: number;
133
226
  }
134
227
 
135
228
  /** A target for `atmosphereFog`, allocated once, outside the frame loop. */
@@ -145,22 +238,19 @@ export function createFogTarget(): FogTarget {
145
238
  mode: MEDIUM_FOG,
146
239
  near: 0,
147
240
  far: 1,
241
+ density2: 0,
242
+ heightFalloff2: 0,
243
+ floor: 0,
244
+ cutoff: 0,
245
+ glowColour: [0, 0, 0],
246
+ glowExponent: 1,
247
+ glowDirection: [0, 1, 0],
248
+ glowStart: 0,
148
249
  };
149
250
  }
150
251
 
151
252
  /** `resolveAtmosphere`'s own target, reused, because this runs a frame at a time. */
152
- const RESOLVED: ResolvedAtmosphere = {
153
- fogColor: new Float32Array(3),
154
- underwaterColor: new Float32Array(3),
155
- fogDensity: 0,
156
- fogHeightFalloff: 0,
157
- fogEyeY: 0,
158
- fogMode: 0,
159
- fogNear: 0,
160
- fogFar: 1,
161
- underwaterFogDensity: 0,
162
- underwaterFactor: 0,
163
- };
253
+ const RESOLVED: ResolvedAtmosphere = createResolvedAtmosphere();
164
254
 
165
255
  /**
166
256
  * The haze an `Environment` makes at a camera's height, as `GpuDrivenView.fog` takes it.
@@ -177,7 +267,7 @@ const RESOLVED: ResolvedAtmosphere = {
177
267
  * on the forward path; pass the same.
178
268
  */
179
269
  export function atmosphereFog(
180
- atmosphere: Atmosphere,
270
+ atmosphere: Atmosphere & { readonly directionalDir?: ArrayLike<number> },
181
271
  eyeY: number,
182
272
  underwater: boolean,
183
273
  out: FogTarget,
@@ -197,5 +287,15 @@ export function atmosphereFog(
197
287
  out.mode = medium.fogMode;
198
288
  out.near = medium.fogNear;
199
289
  out.far = medium.fogFar;
290
+ out.density2 = medium.fogLayer2[0] as number;
291
+ out.heightFalloff2 = medium.fogLayer2[1] as number;
292
+ out.floor = medium.fogLayer2[2] as number;
293
+ out.cutoff = medium.fogLayer2[3] as number;
294
+ for (let c = 0; c < 3; c++) {
295
+ out.glowColour[c] = medium.fogGlow[c] as number;
296
+ out.glowDirection[c] = medium.fogGlowDir[c] as number;
297
+ }
298
+ out.glowExponent = medium.fogGlow[3] as number;
299
+ out.glowStart = medium.fogGlowDir[3] as number;
200
300
  return out;
201
301
  }
@@ -12,6 +12,12 @@
12
12
  * only one of the two that can put the shape of a doorway on a floor.
13
13
  */
14
14
 
15
+ import type { Vec3 } from '../math/color.ts';
16
+ import type { FogLayer } from './atmosphere.ts';
17
+ import { MAX_POINT_LIGHTS } from './lightBudget.ts';
18
+ import type { ResolvedPointLights } from './lightBudget.ts';
19
+ import type { ResolvedPointShadows } from './pointShadowImage.ts';
20
+
15
21
  /**
16
22
  * The most steps a march may take, which is the shader's own loop bound.
17
23
  *
@@ -41,6 +47,12 @@ export const MAX_GLOBAL_MEDIUM_STEPS = 64;
41
47
  */
42
48
  export const WEAK_GPU_MEDIUM_STEPS = 16;
43
49
 
50
+ /**
51
+ * The most lamps the march lights the medium with, which is the lit stage's own per-pixel set:
52
+ * the lamps a frame shades with uniforms, each with the shadow it already has.
53
+ */
54
+ export const MAX_MEDIUM_LAMPS = MAX_POINT_LIGHTS;
55
+
44
56
  /**
45
57
  * How thick the air is, this frame.
46
58
  *
@@ -58,13 +70,15 @@ export interface GlobalMediumOptions {
58
70
  */
59
71
  readonly density: number;
60
72
  /**
61
- * How much of what the medium takes out of a ray comes back as light rather than as heat, 0 to 1.
73
+ * How much of what the medium takes out of a ray comes back as light rather than as heat, 0 to 1,
74
+ * or a colour, a share a channel.
62
75
  *
63
76
  * Near 1 for water droplets, which is fog, cloud and steam; lower for smoke, which is soot and
64
77
  * absorbs most of what it intercepts. It is the difference between a white fog that a torch
65
- * lights up and a black smoke that swallows the same torch.
78
+ * lights up and a black smoke that swallows the same torch. A colour is a medium that keeps more
79
+ * of one light than another: a faintly blue haze returns the sky's blue a little more readily.
66
80
  */
67
- readonly albedo: number;
81
+ readonly albedo: number | Vec3;
68
82
  /**
69
83
  * Henyey-Greenstein anisotropy, -1 back to 1 forward. 0 is isotropic.
70
84
  *
@@ -75,14 +89,59 @@ export interface GlobalMediumOptions {
75
89
  */
76
90
  readonly anisotropy: number;
77
91
  /**
78
- * Where the march stops, in metres. Beyond it the medium is not sampled and nothing is added.
92
+ * Where the medium ends, in metres: past it the air neither scatters nor absorbs.
79
93
  *
80
- * A bound on the *integral*, not on the fog: extinction is still integrated over the whole
81
- * distance a ray travels, so a far wall behind this bound is still obscured. What stops is the
82
- * search for light to scatter, which is what costs — and past a few extinction lengths there is
83
- * no transmittance left for a sample to contribute through.
94
+ * **This said extinction went on past it, and the march never did that**: it integrates from
95
+ * the eye to here or to the surface, whichever is nearer, and nothing after. Which is what a
96
+ * medium standing for the near part of the environment's haze wants: the haze takes the rest of
97
+ * the ray when its `fogStart` is this distance, and the two never lay one stretch of air twice.
98
+ * **What it gives up** is a medium of its own beyond the reach, which the haze has to be.
84
99
  */
85
100
  readonly maxDistance: number;
101
+ /**
102
+ * Reciprocal scale height, per metre: `density` is the extinction at `baseY`, thinning above it
103
+ * as the haze does (`Atmosphere.fogHeightFalloff`). Absent or 0, the medium is as thick
104
+ * everywhere, as it always was.
105
+ */
106
+ readonly heightFalloff?: number;
107
+ /** The height `density` is measured at. Default 0. */
108
+ readonly baseY?: number;
109
+ /**
110
+ * A second layer, summed with the first, its own density at its own height thinning by its own
111
+ * falloff: `Atmosphere.fogLayer2`'s shape, so a medium standing for the near part of a two-layer
112
+ * haze can be that haze.
113
+ */
114
+ readonly layer2?: FogLayer | null;
115
+ /** How strongly the sun scatters in it, as a share of how strongly it lights a surface. Default 1. */
116
+ readonly sunScatter?: number;
117
+ /** The same for the sky's fill, which lights a shadowed medium from every side. Default 1. */
118
+ readonly skyScatter?: number;
119
+ /**
120
+ * The same for the lamps, each through its own shadow and times its own `lightMediumScatter`.
121
+ * **Default 0**: a medium was the sun's and the sky's until there was this, and a scene that
122
+ * never asks keeps that picture and that cost. The lamps are the lit stage's per-pixel set,
123
+ * shaped by their cones; a photometric profile and a cookie shape the pool on a surface and not
124
+ * the shaft in the air.
125
+ */
126
+ readonly lampScatter?: number;
127
+ }
128
+
129
+ /** A medium with every field filled in and clamped: what both backends' passes are handed. */
130
+ export interface ResolvedGlobalMedium {
131
+ readonly density: number;
132
+ /** A colour, three shares. */
133
+ readonly albedo: readonly [number, number, number];
134
+ readonly anisotropy: number;
135
+ readonly maxDistance: number;
136
+ readonly heightFalloff: number;
137
+ readonly baseY: number;
138
+ /** The second layer's density at its own height, 0 for none, its falloff and that height. */
139
+ readonly density2: number;
140
+ readonly heightFalloff2: number;
141
+ readonly baseY2: number;
142
+ readonly sunScatter: number;
143
+ readonly skyScatter: number;
144
+ readonly lampScatter: number;
86
145
  }
87
146
 
88
147
  /**
@@ -107,17 +166,62 @@ export const DEFAULT_GLOBAL_MEDIUM: GlobalMediumOptions = {
107
166
  * function by zero, which is a NaN that a half-float target spreads over the whole screen rather
108
167
  * than reporting; a `maxDistance` of zero is a march with no length that still costs its steps.
109
168
  */
110
- export function resolveGlobalMedium(options: Partial<GlobalMediumOptions>): GlobalMediumOptions {
169
+ export function resolveGlobalMedium(options: Partial<GlobalMediumOptions>): ResolvedGlobalMedium {
111
170
  const d = DEFAULT_GLOBAL_MEDIUM;
171
+ const albedo = options.albedo ?? d.albedo;
172
+ const share = (value: number | undefined): number =>
173
+ Math.min(1, Math.max(0, Number.isFinite(value) ? (value as number) : 0));
174
+ const layer2 = options.layer2 ?? null;
175
+ const density2 = layer2 === null ? 0 : Math.max(0, layer2.density);
176
+ const finite = (value: number | undefined, fallback: number): number =>
177
+ value !== undefined && Number.isFinite(value) ? value : fallback;
112
178
  return {
113
179
  density: Math.max(0, options.density ?? d.density),
114
- albedo: Math.min(1, Math.max(0, options.albedo ?? d.albedo)),
180
+ albedo:
181
+ typeof albedo === 'number'
182
+ ? [share(albedo), share(albedo), share(albedo)]
183
+ : [share(albedo[0]), share(albedo[1]), share(albedo[2])],
115
184
  /* Short of the poles, where `(1 - g^2)` over `(1 + g^2 - 2g)^1.5` is 0/0. */
116
185
  anisotropy: Math.min(0.95, Math.max(-0.95, options.anisotropy ?? d.anisotropy)),
117
186
  maxDistance: Math.max(1e-3, options.maxDistance ?? d.maxDistance),
187
+ heightFalloff: finite(options.heightFalloff, 0),
188
+ baseY: finite(options.baseY, 0),
189
+ density2,
190
+ heightFalloff2: layer2 === null ? 0 : finite(layer2.heightFalloff, 0),
191
+ baseY2: layer2 === null ? 0 : finite(layer2.baseY, 0),
192
+ sunScatter: Math.max(0, finite(options.sunScatter, 1)),
193
+ skyScatter: Math.max(0, finite(options.skyScatter, 1)),
194
+ lampScatter: Math.max(0, finite(options.lampScatter, 0)),
118
195
  };
119
196
  }
120
197
 
198
+ /** `setGlobalMedium`'s object form: a density, and any of the rest. */
199
+ export type GlobalMediumSettings = Partial<GlobalMediumOptions> & { readonly density: number };
200
+
201
+ /**
202
+ * `setGlobalMedium`'s two forms, its four numbers or the object, read one way for both backends:
203
+ * a density that is not a number is none.
204
+ */
205
+ export function resolveGlobalMediumSettings(
206
+ density: number | GlobalMediumSettings,
207
+ albedo?: number | Vec3,
208
+ anisotropy?: number,
209
+ maxDistance?: number,
210
+ ): ResolvedGlobalMedium {
211
+ if (typeof density === 'object') {
212
+ return resolveGlobalMedium({
213
+ ...density,
214
+ density: Number.isFinite(density.density) ? density.density : 0,
215
+ });
216
+ }
217
+ return resolveGlobalMedium({
218
+ density: Number.isFinite(density) ? density : 0,
219
+ albedo,
220
+ anisotropy,
221
+ maxDistance,
222
+ });
223
+ }
224
+
121
225
  /**
122
226
  * Whether this frame draws the medium at all.
123
227
  *
@@ -125,7 +229,10 @@ export function resolveGlobalMedium(options: Partial<GlobalMediumOptions>): Glob
125
229
  * `density: 0` is pixel-identical to the pass being absent is only true if neither backend runs a
126
230
  * composite at zero, and a second copy of this test is where that stops being true.
127
231
  */
128
- export function mediumActive(options: GlobalMediumOptions, steps: number): boolean {
232
+ export function mediumActive(
233
+ options: Pick<GlobalMediumOptions, 'density'>,
234
+ steps: number,
235
+ ): boolean {
129
236
  return options.density > 0 && steps > 0;
130
237
  }
131
238
 
@@ -152,3 +259,99 @@ export function mediumTargetSize(
152
259
  height: Math.max(1, Math.ceil(Math.max(1, height) / divisor)),
153
260
  };
154
261
  }
262
+
263
+ /**
264
+ * The lamps as the march reads them: eight vectors a lamp, each array `MAX_MEDIUM_LAMPS` of them.
265
+ *
266
+ * **Decided here and bound twice**, on the split `resolvePointLights` draws: which lamps light the
267
+ * medium, how bright and through which maps is arithmetic both backends run, and a backend only
268
+ * uploads the arrays. A lamp's shadows are the lit stage's own, the static map and the live one,
269
+ * read where the frame left them, so the shaft in the air and the pool on the floor are cut by the
270
+ * same casters.
271
+ */
272
+ export interface ResolvedMediumLamps {
273
+ /** How many lamps light the medium: none while `lampScatter` is 0. */
274
+ count: number;
275
+ /** 1 for inverse-square, 0 for the smooth falloff: the frame's own rule. */
276
+ falloff: number;
277
+ /** Where each lamp is, and how far it reaches. */
278
+ readonly position: Float32Array;
279
+ /** Its colour, times its presence and its scatter, and its own falloff exponent (0 for none). */
280
+ readonly color: Float32Array;
281
+ /** The direction it shines, a spot's axis: `lightDirections`. */
282
+ readonly aim: Float32Array;
283
+ /** Its cone's two cosines, inner and outer, as the lit stage takes them. */
284
+ readonly cone: Float32Array;
285
+ /** Its static shadow: the layer (−1 for none), the near plane, and how present the map is. */
286
+ readonly shadow: Float32Array;
287
+ /** The static map's projection: its origin and its far plane. */
288
+ readonly shadowAt: Float32Array;
289
+ /** Its live shadow, by the same layout, and the live map's projection. */
290
+ readonly live: Float32Array;
291
+ readonly liveAt: Float32Array;
292
+ }
293
+
294
+ /** A target for `resolveMediumLamps`, made once outside the frame loop. */
295
+ export function createResolvedMediumLamps(): ResolvedMediumLamps {
296
+ const vectors = (): Float32Array => new Float32Array(MAX_MEDIUM_LAMPS * 4);
297
+ return {
298
+ count: 0,
299
+ falloff: 0,
300
+ position: vectors(),
301
+ color: vectors(),
302
+ aim: vectors(),
303
+ cone: vectors(),
304
+ shadow: vectors(),
305
+ shadowAt: vectors(),
306
+ live: vectors(),
307
+ liveAt: vectors(),
308
+ };
309
+ }
310
+
311
+ /**
312
+ * The frame's lamps as the medium is lit by them, into `out`: the lit stage's resolved lamps and
313
+ * their resolved shadows, a lamp's scatter `lampScatter` times its own `lightMediumScatter` (1 where
314
+ * absent). Allocates nothing; a lamp past the count keeps whatever it held, which nothing reads.
315
+ */
316
+ export function resolveMediumLamps(
317
+ medium: ResolvedGlobalMedium,
318
+ lights: ResolvedPointLights,
319
+ shadows: ResolvedPointShadows | null,
320
+ scatter: Float32Array | undefined,
321
+ out: ResolvedMediumLamps,
322
+ ): ResolvedMediumLamps {
323
+ const count = medium.lampScatter > 0 ? Math.min(lights.count, MAX_MEDIUM_LAMPS) : 0;
324
+ out.count = count;
325
+ out.falloff = lights.falloff;
326
+ const own = scatter !== undefined && scatter.length >= count ? scatter : undefined;
327
+ for (let i = 0; i < count; i++) {
328
+ const at = i * 4;
329
+ const strength =
330
+ medium.lampScatter * Math.max(0, own?.[i] ?? 1) * (lights.weights[i] as number);
331
+ for (let c = 0; c < 3; c++) {
332
+ out.position[at + c] = lights.positions[i * 3 + c] as number;
333
+ out.color[at + c] = (lights.colors[i * 3 + c] as number) * strength;
334
+ out.aim[at + c] = lights.directions[i * 3 + c] as number;
335
+ }
336
+ out.position[at + 3] = lights.radii[i] as number;
337
+ out.color[at + 3] = lights.conesAndFalloffs[at + 2] as number;
338
+ out.aim[at + 3] = 0;
339
+ out.cone[at] = lights.conesAndFalloffs[at] as number;
340
+ out.cone[at + 1] = lights.conesAndFalloffs[at + 1] as number;
341
+ out.cone[at + 2] = 0;
342
+ out.cone[at + 3] = 0;
343
+ out.shadow[at] = shadows === null ? -1 : (shadows.layers[i] as number);
344
+ out.shadow[at + 1] = shadows === null ? 0 : (shadows.near[i] as number);
345
+ out.shadow[at + 2] = shadows === null ? 0 : (shadows.presence[i] as number);
346
+ out.shadow[at + 3] = 0;
347
+ out.live[at] = shadows === null ? -1 : (shadows.liveLayers[i] as number);
348
+ out.live[at + 1] = shadows === null ? 0 : (shadows.liveNear[i] as number);
349
+ out.live[at + 2] = shadows === null ? 0 : (shadows.liveWeights[i] as number);
350
+ out.live[at + 3] = 0;
351
+ for (let k = 0; k < 4; k++) {
352
+ out.shadowAt[at + k] = shadows === null ? 1 : (shadows.projections[at + k] as number);
353
+ out.liveAt[at + k] = shadows === null ? 1 : (shadows.liveProjections[at + k] as number);
354
+ }
355
+ }
356
+ return out;
357
+ }
@@ -2,14 +2,14 @@ import { mat4 } from 'gl-matrix';
2
2
  import type { ReadonlyMat4 } from 'gl-matrix';
3
3
 
4
4
  import { mediumTargetSize } from './globalMedium.ts';
5
- import type { GlobalMediumOptions } from './globalMedium.ts';
5
+ import type { ResolvedGlobalMedium, ResolvedMediumLamps } from './globalMedium.ts';
6
6
  import { DEPTH_01_TO_CLIP } from './lightVolumeDraw.ts';
7
7
  import { compileProgram, uniformLocations } from './shader.ts';
8
8
  import { FULLSCREEN_VERT } from './shaders/fullscreen.ts';
9
9
  import { MEDIUM_FRAG, MEDIUM_UPSAMPLE_FRAG } from './shaders/globalMedium.ts';
10
10
  import { blendCovering, blendTransmitting } from './sceneCoverage.ts';
11
11
 
12
- /** What the march needs about the light, which is the sun and the sky and nothing else. */
12
+ /** What the march needs about the light: the sun, the sky, and the lamps that scatter. */
13
13
  export interface MediumLight {
14
14
  /** Surface → sun, normalised. `Environment.directionalDir` unchanged. */
15
15
  readonly sunDir: ArrayLike<number>;
@@ -25,6 +25,10 @@ export interface MediumLight {
25
25
  /** The sun's static, moving and peeled maps, as one array. See `shadowMap.ts`. */
26
26
  readonly sunShadows: WebGLTexture;
27
27
  readonly peeledEnabled: boolean;
28
+ /** The lamps that scatter, as `resolveMediumLamps` lays them out: none while `lampScatter` is 0. */
29
+ readonly lamps: ResolvedMediumLamps;
30
+ /** The lamps' shadow array, the lit stage's; the placeholder where there is none. */
31
+ readonly pointShadows: WebGLTexture;
28
32
  }
29
33
 
30
34
  /**
@@ -172,7 +176,7 @@ export class GlobalMediumPass {
172
176
  frameHeight: number,
173
177
  half: boolean,
174
178
  steps: number,
175
- options: GlobalMediumOptions,
179
+ options: ResolvedGlobalMedium,
176
180
  viewProjection: ReadonlyMat4,
177
181
  projection: ReadonlyMat4,
178
182
  eye: ArrayLike<number>,
@@ -214,6 +218,9 @@ export class GlobalMediumPass {
214
218
  gl.activeTexture(gl.TEXTURE1);
215
219
  gl.bindTexture(gl.TEXTURE_2D_ARRAY, light.sunShadows);
216
220
  gl.uniform1i(u['uSunShadows'] ?? null, 1);
221
+ gl.activeTexture(gl.TEXTURE2);
222
+ gl.bindTexture(gl.TEXTURE_2D_ARRAY, light.pointShadows);
223
+ gl.uniform1i(u['uPointShadows'] ?? null, 2);
217
224
 
218
225
  gl.uniformMatrix4fv(u['uDepthToWorld'] ?? null, false, this.depthToWorld);
219
226
  gl.uniform3f(u['uCameraPos'] ?? null, eye[0] ?? 0, eye[1] ?? 0, eye[2] ?? 0);
@@ -236,7 +243,10 @@ export class GlobalMediumPass {
236
243
  light.ambient[2] ?? 0,
237
244
  );
238
245
  gl.uniform1f(u['uDensity'] ?? null, options.density);
239
- gl.uniform1f(u['uAlbedo'] ?? null, options.albedo);
246
+ gl.uniform2f(u['uHeight'] ?? null, options.heightFalloff, options.baseY);
247
+ gl.uniform3f(u['uLayer2'] ?? null, options.density2, options.heightFalloff2, options.baseY2);
248
+ gl.uniform3f(u['uAlbedo'] ?? null, options.albedo[0], options.albedo[1], options.albedo[2]);
249
+ gl.uniform2f(u['uScatter'] ?? null, options.sunScatter, options.skyScatter);
240
250
  gl.uniform1f(u['uAnisotropy'] ?? null, options.anisotropy);
241
251
  gl.uniform1f(u['uMaxDistance'] ?? null, options.maxDistance);
242
252
  gl.uniform1i(u['uSteps'] ?? null, steps);
@@ -248,6 +258,20 @@ export class GlobalMediumPass {
248
258
  light.movingLightViewProj as Float32Array,
249
259
  );
250
260
  gl.uniform1i(u['uPeeledShadowEnabled'] ?? null, light.peeledEnabled ? 1 : 0);
261
+ const lamps = light.lamps;
262
+ gl.uniform1i(u['uLampCount'] ?? null, lamps.count);
263
+ gl.uniform1i(u['uLampFalloff'] ?? null, lamps.falloff);
264
+ /* As `name[0]`, the only spelling an array uniform answers to: see `bindPointShadows`. */
265
+ if (lamps.count > 0) {
266
+ gl.uniform4fv(u['uLampPosition[0]'] ?? null, lamps.position);
267
+ gl.uniform4fv(u['uLampColor[0]'] ?? null, lamps.color);
268
+ gl.uniform4fv(u['uLampAim[0]'] ?? null, lamps.aim);
269
+ gl.uniform4fv(u['uLampCone[0]'] ?? null, lamps.cone);
270
+ gl.uniform4fv(u['uLampShadow[0]'] ?? null, lamps.shadow);
271
+ gl.uniform4fv(u['uLampShadowAt[0]'] ?? null, lamps.shadowAt);
272
+ gl.uniform4fv(u['uLampLive[0]'] ?? null, lamps.live);
273
+ gl.uniform4fv(u['uLampLiveAt[0]'] ?? null, lamps.liveAt);
274
+ }
251
275
 
252
276
  gl.drawArrays(gl.TRIANGLES, 0, 3);
253
277
 
@@ -257,6 +281,7 @@ export class GlobalMediumPass {
257
281
  for (const unit of [gl.TEXTURE3, gl.TEXTURE2, gl.TEXTURE1, gl.TEXTURE0]) {
258
282
  gl.activeTexture(unit);
259
283
  gl.bindTexture(gl.TEXTURE_2D, null);
284
+ gl.bindTexture(gl.TEXTURE_2D_ARRAY, null);
260
285
  }
261
286
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
262
287
  return true;