@driftengine/core 4.8.6 → 4.8.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/CHANGELOG.json +71 -0
  2. package/README.md +1 -1
  3. package/dist/index.d.ts +2 -2
  4. package/dist/index.js +1 -1
  5. package/dist/render/anisotropicLobe.d.ts +10 -0
  6. package/dist/render/anisotropicLobe.js +19 -0
  7. package/dist/render/areaLights.d.ts +35 -0
  8. package/dist/render/areaLights.js +35 -0
  9. package/dist/render/backend/api.d.ts +33 -3
  10. package/dist/render/backend/webgl2/renderer.d.ts +50 -14
  11. package/dist/render/backend/webgl2/renderer.js +141 -30
  12. package/dist/render/backend/webgl2/sceneCaptureTarget.d.ts +29 -0
  13. package/dist/render/backend/webgl2/sceneCaptureTarget.js +96 -0
  14. package/dist/render/backend/webgpu/particlePass.js +2 -1
  15. package/dist/render/backend/webgpu/renderer.d.ts +35 -1
  16. package/dist/render/backend/webgpu/renderer.js +155 -16
  17. package/dist/render/backend/webgpu/sceneCaptureTarget.d.ts +24 -0
  18. package/dist/render/backend/webgpu/sceneCaptureTarget.js +59 -0
  19. package/dist/render/backend/webgpu/shaders/clusterBin.wgsl.d.ts +1 -1
  20. package/dist/render/backend/webgpu/surfaceTexturePass.d.ts +4 -1
  21. package/dist/render/backend/webgpu/surfaceTexturePass.js +34 -8
  22. package/dist/render/backend/webgpu/waterPass.d.ts +1 -1
  23. package/dist/render/clusteredLights.d.ts +23 -5
  24. package/dist/render/clusteredLights.js +35 -8
  25. package/dist/render/driftLight/bake.d.ts +2 -0
  26. package/dist/render/driftLight/bake.js +1 -1
  27. package/dist/render/driftLight/falloff.d.ts +4 -2
  28. package/dist/render/driftLight/falloff.js +8 -2
  29. package/dist/render/gi/bounceLights.d.ts +3 -2
  30. package/dist/render/gi/bounceLights.js +8 -3
  31. package/dist/render/instanceCull.js +5 -0
  32. package/dist/render/instances.d.ts +19 -5
  33. package/dist/render/instances.js +9 -3
  34. package/dist/render/lightBudget.d.ts +12 -1
  35. package/dist/render/lightBudget.js +23 -3
  36. package/dist/render/lightmap.d.ts +23 -9
  37. package/dist/render/lightmap.js +40 -18
  38. package/dist/render/particlePool.d.ts +21 -0
  39. package/dist/render/particlePool.js +7 -0
  40. package/dist/render/pointLightSelection.d.ts +13 -0
  41. package/dist/render/pointLightSelection.js +3 -1
  42. package/dist/render/renderQuality.d.ts +8 -7
  43. package/dist/render/rendererHarness.js +3 -0
  44. package/dist/render/rgb9e5.d.ts +22 -0
  45. package/dist/render/rgb9e5.js +47 -0
  46. package/dist/render/sceneCapture.d.ts +29 -0
  47. package/dist/render/sceneCapture.js +36 -0
  48. package/dist/render/screenSpaceReflection.d.ts +27 -1
  49. package/dist/render/screenSpaceReflection.js +9 -0
  50. package/dist/render/shaders/bolt.d.ts +1 -1
  51. package/dist/render/shaders/bolt.js +7 -1
  52. package/dist/render/shaders/depth.js +11 -0
  53. package/dist/render/shaders/flat/anisotropic.d.ts +1 -1
  54. package/dist/render/shaders/flat/anisotropic.js +37 -3
  55. package/dist/render/shaders/flat/areaLight.d.ts +1 -1
  56. package/dist/render/shaders/flat/areaLight.js +46 -0
  57. package/dist/render/shaders/flat/glassTint.d.ts +3 -2
  58. package/dist/render/shaders/flat/glassTint.js +3 -1
  59. package/dist/render/shaders/flat/index.d.ts +5 -0
  60. package/dist/render/shaders/flat/index.js +16 -2
  61. package/dist/render/shaders/flat/lobes.d.ts +6 -0
  62. package/dist/render/shaders/flat/lobes.js +49 -43
  63. package/dist/render/shaders/flat/main.d.ts +1 -1
  64. package/dist/render/shaders/flat/main.js +57 -22
  65. package/dist/render/shaders/flat/models.d.ts +5 -2
  66. package/dist/render/shaders/flat/models.js +100 -16
  67. package/dist/render/shaders/flat/preamble.js +10 -3
  68. package/dist/render/shaders/generated/bolt.wgsl.d.ts +1 -1
  69. package/dist/render/shaders/generated/bolt.wgsl.js +1 -1
  70. package/dist/render/shaders/generated/depth.wgsl.d.ts +4 -4
  71. package/dist/render/shaders/generated/depth.wgsl.js +4 -4
  72. package/dist/render/shaders/generated/flat.wgsl.d.ts +16 -0
  73. package/dist/render/shaders/generated/flat.wgsl.js +54 -38
  74. package/dist/render/shaders/generated/ssrTrace.wgsl.d.ts +11 -6
  75. package/dist/render/shaders/generated/ssrTrace.wgsl.js +11 -6
  76. package/dist/render/shaders/generated/water.wgsl.d.ts +23 -16
  77. package/dist/render/shaders/generated/water.wgsl.js +23 -16
  78. package/dist/render/shaders/gi/probeBake.wgsl.js +9 -5
  79. package/dist/render/shaders/recon/motion.wgsl.d.ts +1 -1
  80. package/dist/render/shaders/recon/motion.wgsl.js +13 -2
  81. package/dist/render/shaders/ssrTrace.js +20 -1
  82. package/dist/render/shaders/water.d.ts +1 -1
  83. package/dist/render/shaders/water.js +12 -3
  84. package/dist/render/skinScatter.d.ts +3 -1
  85. package/dist/render/skinScatter.js +3 -1
  86. package/dist/render/ssrPass.js +1 -0
  87. package/dist/render/surfaceModel.d.ts +29 -8
  88. package/dist/render/surfaceModel.js +33 -9
  89. package/dist/render/surfaceTexture.d.ts +25 -1
  90. package/dist/render/surfaceTexture.js +27 -5
  91. package/package.json +6 -6
  92. package/src/index.ts +6 -2
  93. package/src/render/anisotropicLobe.ts +29 -0
  94. package/src/render/areaLights.ts +58 -0
  95. package/src/render/backend/api.ts +49 -2
  96. package/src/render/backend/webgl2/renderer.ts +192 -38
  97. package/src/render/backend/webgl2/sceneCaptureTarget.ts +130 -0
  98. package/src/render/backend/webgpu/particlePass.ts +2 -1
  99. package/src/render/backend/webgpu/renderer.ts +194 -19
  100. package/src/render/backend/webgpu/sceneCaptureTarget.ts +63 -0
  101. package/src/render/backend/webgpu/surfaceTexturePass.ts +44 -14
  102. package/src/render/clusteredLights.ts +41 -8
  103. package/src/render/driftLight/bake.ts +3 -1
  104. package/src/render/driftLight/falloff.ts +13 -2
  105. package/src/render/gi/bounceLights.ts +12 -3
  106. package/src/render/instanceCull.ts +4 -0
  107. package/src/render/instances.ts +26 -6
  108. package/src/render/lightBudget.ts +40 -2
  109. package/src/render/lightmap.ts +64 -23
  110. package/src/render/particlePool.ts +23 -0
  111. package/src/render/pointLightSelection.ts +20 -1
  112. package/src/render/renderQuality.ts +8 -7
  113. package/src/render/rendererHarness.ts +3 -0
  114. package/src/render/rgb9e5.ts +52 -0
  115. package/src/render/sceneCapture.ts +56 -0
  116. package/src/render/screenSpaceReflection.ts +33 -1
  117. package/src/render/shaders/bolt.ts +7 -1
  118. package/src/render/shaders/depth.ts +11 -0
  119. package/src/render/shaders/flat/anisotropic.ts +37 -3
  120. package/src/render/shaders/flat/areaLight.ts +46 -0
  121. package/src/render/shaders/flat/glassTint.ts +9 -2
  122. package/src/render/shaders/flat/index.ts +21 -2
  123. package/src/render/shaders/flat/lobes.ts +50 -43
  124. package/src/render/shaders/flat/main.ts +57 -22
  125. package/src/render/shaders/flat/models.ts +100 -16
  126. package/src/render/shaders/flat/preamble.ts +10 -3
  127. package/src/render/shaders/generated/bolt.wgsl.ts +1 -1
  128. package/src/render/shaders/generated/depth.wgsl.ts +4 -4
  129. package/src/render/shaders/generated/flat.wgsl.ts +54 -38
  130. package/src/render/shaders/generated/ssrTrace.wgsl.ts +11 -6
  131. package/src/render/shaders/generated/water.wgsl.ts +23 -16
  132. package/src/render/shaders/gi/probeBake.wgsl.ts +9 -5
  133. package/src/render/shaders/recon/motion.wgsl.ts +13 -2
  134. package/src/render/shaders/ssrTrace.ts +20 -1
  135. package/src/render/shaders/water.ts +12 -3
  136. package/src/render/skinScatter.ts +3 -1
  137. package/src/render/ssrPass.ts +1 -0
  138. package/src/render/surfaceModel.ts +47 -10
  139. package/src/render/surfaceTexture.ts +69 -9
@@ -3,6 +3,8 @@ import { SURFACE_EFFECT_TEXELS, packSurfaceEffects } from '../../surfaceEffects.
3
3
  import { toHalfFloats } from '../../halfFloat.ts';
4
4
  import { isLightmapTexels } from '../../lightmap.ts';
5
5
  import type { LightmapTexels } from '../../lightmap.ts';
6
+ import { isSceneCaptureTexels } from '../../sceneCapture.ts';
7
+ import type { SceneCaptureTexels } from '../../sceneCapture.ts';
6
8
  import { layerSize, refuseArrayUpdate, sourceSize } from '../../textureSource.ts';
7
9
  import { compressedLayers, planBlocks, refuseBlockUpdate } from '../../compressedSource.ts';
8
10
  import type {
@@ -195,35 +197,54 @@ export class GpuSurfaceTexture {
195
197
  constructor(
196
198
  private readonly device: GPUDevice,
197
199
  private readonly pipelines: PipelineCache,
198
- source: SurfaceSource | readonly SurfaceSource[] | LightmapTexels,
200
+ source: SurfaceSource | readonly SurfaceSource[] | LightmapTexels | SceneCaptureTexels,
199
201
  options: SurfaceTextureOptions = {},
200
202
  compressed: readonly CompressedTextureFormat[] = [],
201
203
  ) {
202
- /* A lightmap's page: half floats, uploaded as they are. See `lightmap.ts`. */
204
+ /* A scene capture's target: one empty layer the world is drawn into. See `sceneCapture.ts`. */
205
+ const capture = isSceneCaptureTexels(source) ? source : null;
206
+ /* A lightmap's page: two layers of bytes, uploaded as they are. See `lightmap.ts`. */
203
207
  const baked = isLightmapTexels(source) ? source : null;
204
208
  const listed = (
205
- baked !== null ? [] : Array.isArray(source) ? source : [source]
209
+ baked !== null || capture !== null ? [] : Array.isArray(source) ? source : [source]
206
210
  ) as readonly SurfaceSource[];
207
211
  const blocks = compressedLayers(listed);
208
212
  const srgb = (options.colorSpace ?? 'linear') === 'srgb';
209
213
  /* `SRGB8_ALPHA8`'s equivalent. Decoded in the sampler, before filtering, which is the only
210
- place it is correct — `surfaceTexture.ts` makes the argument in full. */
211
- this.format = baked !== null ? 'rgba16float' : srgb ? 'rgba8unorm-srgb' : 'rgba8unorm';
212
- if (baked !== null) {
214
+ place it is correct — `surfaceTexture.ts` makes the argument in full. A capture takes the
215
+ world's own format, which its pass resolves into. */
216
+ this.format =
217
+ capture !== null
218
+ ? pipelines.format
219
+ : srgb && baked === null
220
+ ? 'rgba8unorm-srgb'
221
+ : 'rgba8unorm';
222
+ if (capture !== null) {
223
+ this.layers = 1;
224
+ this.blockFormat = null;
225
+ this.mipmapped = false;
226
+ this.width = capture.width;
227
+ this.height = capture.height;
228
+ this.levels = 1;
229
+ this.texture = this.allocate();
230
+ } else if (baked !== null) {
213
231
  /* No chain: a page is read at its own resolution, and a level of it would blend regions. */
214
- this.layers = baked.layers;
232
+ this.layers = baked.layers.length;
215
233
  this.blockFormat = null;
216
234
  this.mipmapped = false;
217
235
  this.width = baked.width;
218
236
  this.height = baked.height;
219
237
  this.levels = 1;
220
238
  this.texture = this.allocate();
221
- device.queue.writeTexture(
222
- { texture: this.texture },
223
- baked.texels,
224
- { bytesPerRow: baked.width * 8, rowsPerImage: baked.height },
225
- { width: baked.width, height: baked.height, depthOrArrayLayers: baked.layers },
226
- );
239
+ /* A layer at a time, so a page's arrays are uploaded where they lie rather than joined. */
240
+ for (let layer = 0; layer < this.layers; layer++) {
241
+ device.queue.writeTexture(
242
+ { texture: this.texture, origin: [0, 0, layer] },
243
+ baked.layers[layer] as Uint8Array<ArrayBuffer>,
244
+ { bytesPerRow: baked.width * 4, rowsPerImage: baked.height },
245
+ { width: baked.width, height: baked.height, depthOrArrayLayers: 1 },
246
+ );
247
+ }
227
248
  } else if (blocks !== null) {
228
249
  /* Refused before anything is allocated; the stored chain, or level 0 alone where no chain
229
250
  was asked for. See `compressedUpload.ts`. */
@@ -282,7 +303,9 @@ export class GpuSurfaceTexture {
282
303
  : 1,
283
304
  });
284
305
 
285
- if (blocks === null && baked === null) this.upload(listed as readonly TexImageSource[]);
306
+ if (blocks === null && baked === null && capture === null) {
307
+ this.upload(listed as readonly TexImageSource[]);
308
+ }
286
309
 
287
310
  /* Half floats, as `renderer.ts` uploads it: filterable, so the ordinary float layout takes it,
288
311
  and read by `textureLoad` alone, so the filter never runs. */
@@ -315,6 +338,13 @@ export class GpuSurfaceTexture {
315
338
  return this.current;
316
339
  }
317
340
 
341
+ /** The only layer as a plain 2D view, which a scene capture's pass resolves into. */
342
+ layerView(): GPUTextureView | null {
343
+ return (
344
+ this.texture?.createView({ dimension: '2d', baseArrayLayer: 0, arrayLayerCount: 1 }) ?? null
345
+ );
346
+ }
347
+
318
348
  /**
319
349
  * Replace the pixels, keeping the handle and its sampler.
320
350
  *
@@ -92,7 +92,7 @@ export const MAX_CLUSTERED_LIGHTS = 320;
92
92
  * it cannot. That is what lets a light index the shadow uniforms by a number read from a texture,
93
93
  * and it is the same fact the octahedral array texture was built on.
94
94
  */
95
- export const LIGHT_TEXELS = 5;
95
+ export const LIGHT_TEXELS = 6;
96
96
 
97
97
  /**
98
98
  * Where each field of a light's record sits, as an offset in `uint`s from `lightBase`.
@@ -123,7 +123,11 @@ export const LIGHT_TEXELS = 5;
123
123
  * **Reordered 2026-10-02 so a plain light's fields are the first eight**, which is what lets the
124
124
  * shader stop after two texels. Nine values are needed by every light and eight fit in two
125
125
  * texels, so the emitter size and the weight share slot 7 as two halves; see `packSizeAndWeight`.
126
- * Slot 19 is spare.
126
+ *
127
+ * **A sixth texel arrived in 4.8.7 for a light's own falloff exponent**, the fifth's last slot
128
+ * having gone to the channels: 6 KB more of a table near 300, read only for a light whose record
129
+ * carries a fixture, which a light with an exponent does. Slots 21 and 22 are a rectangle's barn
130
+ * doors and spare on a lamp; slot 23 is spare.
127
131
  */
128
132
  export const LIGHT_RECORD = {
129
133
  positionX: 0,
@@ -154,6 +158,11 @@ export const LIGHT_RECORD = {
154
158
  * last texels are fetched; a plain light's are not, and the shader takes the default for it.
155
159
  */
156
160
  channels: 19,
161
+ /** The light's own falloff exponent, or 0 for the frame's falloff. `PointLightSource.falloffExponent`. */
162
+ falloffExponent: 20,
163
+ /** A rectangle's barn doors: the cosine of their angle from its normal, then their length in metres. */
164
+ barnDoorCos: 21,
165
+ barnDoorLength: 22,
157
166
  } as const;
158
167
 
159
168
  /** The lighting channel every light and every surface is on unless it says otherwise. */
@@ -171,11 +180,20 @@ export function lightChannelsOf(value: number | undefined): number {
171
180
  return value;
172
181
  }
173
182
 
183
+ /**
184
+ * A light's own falloff exponent as the shader reads it: a positive finite number, or 0 for the
185
+ * frame's falloff where none was named or what was named is not one. The record and the uniform
186
+ * path both write this, so a light named badly falls off by the frame's rule in both.
187
+ */
188
+ export function lightFalloffExponentOf(value: number | undefined): number {
189
+ return value !== undefined && Number.isFinite(value) && value > 0 ? value : 0;
190
+ }
191
+
174
192
  /** Texels the lit pass reads for a light whose record carries no fixture. */
175
193
  export const PLAIN_LIGHT_TEXELS = 2;
176
194
 
177
195
  /**
178
- * The bit of `sizeAndWeight` that says the record's last three texels hold something: the sign of
196
+ * The bit of `sizeAndWeight` that says the record's last four texels hold something: the sign of
179
197
  * the emitter size's half, which a size never needs.
180
198
  */
181
199
  export const LIGHT_FIXTURE_FLAG = 0x8000;
@@ -426,6 +444,8 @@ export interface ClusterLightSet {
426
444
  readonly iesProfiles?: Float32Array;
427
445
  /** One per light: its lighting channels. See `PointLightSet.lightChannels`. */
428
446
  readonly channels?: Float32Array;
447
+ /** One per light: its own falloff exponent, or 0. See `PointLightSource.falloffExponent`. */
448
+ readonly falloffExponents?: Float32Array;
429
449
  /**
430
450
  * The rectangles shaded through the table, after the `count` point lights: those of `areas` from
431
451
  * `areaFrom` on, `areaCount` of them. The ones before `areaFrom` are the fixed arm's, which is
@@ -450,6 +470,7 @@ export interface ClusterLightArrays {
450
470
  readonly lightIesAxes?: Float32Array;
451
471
  readonly lightCookies?: Float32Array;
452
472
  readonly lightChannels?: Float32Array;
473
+ readonly lightFalloffExponents?: Float32Array;
453
474
  readonly areaLights?: AreaLightBuffer | null;
454
475
  }
455
476
 
@@ -510,6 +531,7 @@ export function fillClusterLightSet(
510
531
  out.iesAxes = env.lightIesAxes;
511
532
  out.cookies = env.lightCookies;
512
533
  out.channels = env.lightChannels;
534
+ out.falloffExponents = env.lightFalloffExponents;
513
535
  return out;
514
536
  }
515
537
 
@@ -559,8 +581,8 @@ function distanceSqToBounds(x: number, y: number, z: number, b: Float32Array): n
559
581
  }
560
582
 
561
583
  /**
562
- * Whether a light's record needs its last three texels: it holds a shadow slot, a cone, a profile
563
- * or a cookie. Everything else is a plain light, read in `PLAIN_LIGHT_TEXELS`.
584
+ * Whether a light's record needs its last four texels: it holds a shadow slot, a cone, a profile,
585
+ * a cookie, channels of its own or a falloff exponent of its own. Everything else is a plain light, read in `PLAIN_LIGHT_TEXELS`.
564
586
  *
565
587
  * **`shadowSlots` is zero where the lit shader has no point shadows**, and both binners are handed
566
588
  * it that way: a slot is only read under `POINT_SHADOWS`, so a light holding one in a build without
@@ -580,7 +602,8 @@ export function lightHasFixture(
580
602
  (cone?.[light * 2 + 1] ?? POINT_LIGHT_COS_OUTER) !== POINT_LIGHT_COS_OUTER ||
581
603
  (lights.iesProfiles?.[light] ?? NO_IES_PROFILE) >= 0 ||
582
604
  (lights.cookies?.[light] ?? NO_IES_PROFILE) >= 0 ||
583
- lightChannelsOf(lights.channels?.[light]) !== DEFAULT_LIGHT_CHANNELS
605
+ lightChannelsOf(lights.channels?.[light]) !== DEFAULT_LIGHT_CHANNELS ||
606
+ lightFalloffExponentOf(lights.falloffExponents?.[light]) > 0
584
607
  );
585
608
  }
586
609
 
@@ -665,6 +688,12 @@ export function writeLightRecord(
665
688
  out[at + LIGHT_RECORD.iesAxisY] = floatBits(axes?.[light * 3 + 1] ?? 0);
666
689
  out[at + LIGHT_RECORD.iesAxisZ] = floatBits(axes?.[light * 3 + 2] ?? 0);
667
690
  out[at + LIGHT_RECORD.channels] = floatBits(lightChannelsOf(lights.channels?.[light]));
691
+ out[at + LIGHT_RECORD.falloffExponent] = floatBits(
692
+ lightFalloffExponentOf(lights.falloffExponents?.[light]),
693
+ );
694
+ out[at + LIGHT_RECORD.barnDoorCos] = 0;
695
+ out[at + LIGHT_RECORD.barnDoorLength] = 0;
696
+ out[at + LIGHT_RECORD.barnDoorLength + 1] = 0;
668
697
  }
669
698
 
670
699
  /**
@@ -677,9 +706,9 @@ function areaSlot(lights: ClusterLightSet, light: number): number {
677
706
 
678
707
  /**
679
708
  * A rectangle's record, which the shader tells from a lamp's by its **negative radius** — its
680
- * reach, negated — and reads to its fifth texel: sidedness where a lamp keeps its shadow slot,
709
+ * reach, negated — and reads to its sixth texel: sidedness where a lamp keeps its shadow slot,
681
710
  * the right axis where a spot keeps its direction, the half extents where its cone is, the up
682
- * axis where the azimuth is, and the channels last. Never shadowed: a rectangle's occlusion is
711
+ * axis where the azimuth is, the channels, and the barn doors in the sixth. Never shadowed: a rectangle's occlusion is
683
712
  * the fixed arm's, which is why the first `maxAreaLights` stay there.
684
713
  */
685
714
  function writeAreaRecord(
@@ -715,6 +744,10 @@ function writeAreaRecord(
715
744
  out[at + LIGHT_RECORD.iesAxisY] = floatBits(up?.[a * 3 + 1] ?? 1);
716
745
  out[at + LIGHT_RECORD.iesAxisZ] = floatBits(up?.[a * 3 + 2] ?? 0);
717
746
  out[at + LIGHT_RECORD.channels] = floatBits(DEFAULT_LIGHT_CHANNELS);
747
+ out[at + LIGHT_RECORD.falloffExponent] = 0;
748
+ out[at + LIGHT_RECORD.barnDoorCos] = floatBits(areas?.barnDoors?.[a * 2] ?? 0);
749
+ out[at + LIGHT_RECORD.barnDoorLength] = floatBits(areas?.barnDoors?.[a * 2 + 1] ?? 0);
750
+ out[at + LIGHT_RECORD.barnDoorLength + 1] = 0;
718
751
  }
719
752
 
720
753
  /** A clustered rectangle's reach, which its record carries negated. See `AreaLightSource.range`. */
@@ -32,6 +32,8 @@ export interface FieldLight extends LightReach {
32
32
  readonly b: number;
33
33
  /** The emitter's physical radius, which widens its penumbra. */
34
34
  readonly sourceRadius: number;
35
+ /** Its own falloff exponent, overriding the frame's above zero. See `pointLightShape`. */
36
+ readonly falloffExponent?: number;
35
37
  }
36
38
 
37
39
  /**
@@ -115,7 +117,7 @@ export function sampleFieldLight(
115
117
  const dz = source.z - pz;
116
118
  const dist = Math.hypot(dx, dy, dz);
117
119
  if (dist >= source.radius) continue;
118
- const shape = pointLightShape(dist, source.radius, falloff);
120
+ const shape = pointLightShape(dist, source.radius, falloff, source.falloffExponent);
119
121
  if (shape <= 0) continue;
120
122
  const seen =
121
123
  distance === null
@@ -9,13 +9,24 @@
9
9
  *
10
10
  * Both of the renderer's falloffs: `smooth`, the default, is a linear ramp squared, reaching zero at
11
11
  * the radius; `inverseSquare` is physical, windowed so it still reaches zero at the radius, and
12
- * floored at ten centimetres so a surface touching a light is not infinitely bright.
12
+ * floored at ten centimetres so a surface touching a light is not infinitely bright. And a light's
13
+ * own, which overrides both: `(1 - (d/R)^2)^n` with no distance term, for an exponent `n` above zero
14
+ * (`PointLightSource.falloffExponent`).
13
15
  */
14
16
 
15
17
  export type PointLightFalloff = 'smooth' | 'inverseSquare';
16
18
 
17
19
  /** The shader's `shape` for an unshaped point light: no cone, no photometric profile. */
18
- export function pointLightShape(dist: number, radius: number, falloff: PointLightFalloff): number {
20
+ export function pointLightShape(
21
+ dist: number,
22
+ radius: number,
23
+ falloff: PointLightFalloff,
24
+ exponent = 0,
25
+ ): number {
26
+ if (exponent > 0) {
27
+ const reach = dist / Math.max(radius, 1e-4);
28
+ return Math.min(1, Math.max(0, 1 - reach * reach)) ** exponent;
29
+ }
19
30
  if (falloff === 'inverseSquare') {
20
31
  const ratio = dist / Math.max(radius, 1e-4);
21
32
  const window = Math.min(1, Math.max(0, 1 - ratio ** 4));
@@ -23,15 +23,20 @@
23
23
  */
24
24
 
25
25
  import type { PointLightSet } from '../lightBudget.ts';
26
- import { POINT_LIGHT_COS_INNER, POINT_LIGHT_COS_OUTER } from '../clusteredLights.ts';
26
+ import {
27
+ POINT_LIGHT_COS_INNER,
28
+ POINT_LIGHT_COS_OUTER,
29
+ lightFalloffExponentOf,
30
+ } from '../clusteredLights.ts';
27
31
 
28
32
  /** Lights the probe bake shades a hit with. */
29
33
  export const MAX_BOUNCE_LIGHTS = 32;
30
34
  /**
31
35
  * Floats a light takes: where it is and how far it reaches; its colour at its weight and the cosine
32
- * of its inner cone; the way it points and the cosine of its outer cone.
36
+ * of its inner cone; the way it points and the cosine of its outer cone; and its own falloff
37
+ * exponent, 0 for the frame's, in a fourth row of its own (`PointLightSource.falloffExponent`).
33
38
  */
34
- export const BOUNCE_LIGHT_FLOATS = 12;
39
+ export const BOUNCE_LIGHT_FLOATS = 16;
35
40
 
36
41
  export function createBounceLights(): Float32Array {
37
42
  return new Float32Array(MAX_BOUNCE_LIGHTS * BOUNCE_LIGHT_FLOATS);
@@ -62,6 +67,10 @@ export function resolveBounceLights(lights: PointLightSet | null, out: Float32Ar
62
67
  out[at + 9] = lights.lightDirections?.[i * 3 + 1] ?? 0;
63
68
  out[at + 10] = lights.lightDirections?.[i * 3 + 2] ?? 0;
64
69
  out[at + 11] = lights.lightConeCos?.[i * 2 + 1] ?? POINT_LIGHT_COS_OUTER;
70
+ out[at + 12] = lightFalloffExponentOf(lights.lightFalloffExponents?.[i]);
71
+ out[at + 13] = 0;
72
+ out[at + 14] = 0;
73
+ out[at + 15] = 0;
65
74
  count++;
66
75
  }
67
76
  return count;
@@ -76,6 +76,10 @@ export function cullInstances(
76
76
  if (regions !== undefined && out.lightmapRegions !== undefined) {
77
77
  for (let k = 0; k < 4; k++) out.lightmapRegions[kept * 4 + k] = regions[i * 4 + k] as number;
78
78
  }
79
+ const cells = data.uvRegions;
80
+ if (cells !== undefined && out.uvRegions !== undefined) {
81
+ for (let k = 0; k < 4; k++) out.uvRegions[kept * 4 + k] = cells[i * 4 + k] as number;
82
+ }
79
83
  kept++;
80
84
  }
81
85
  out.count = kept;
@@ -12,9 +12,9 @@
12
12
  *
13
13
  * **What it gives up** is a matrix per instance where a scatter spends five floats: 80 bytes
14
14
  * against 44, and no wind. **What would make it wrong** is a caller wanting per-instance
15
- * anything beyond an opacity — a morph weight, a texture cell — which wants another attribute,
16
- * and the sixteen WebGL2 guarantees are already spent. The opacity rides the float the stride was
17
- * padded with, which is why it costs nothing.
15
+ * anything beyond an opacity and a texture cell — a morph weight, say — which wants another
16
+ * attribute, and the sixteen WebGL2 guarantees are already spent. The opacity rides the float the
17
+ * stride was padded with and the cell the matrix's bottom row, which is why neither costs a byte.
18
18
  */
19
19
  export interface MeshInstances {
20
20
  /**
@@ -51,6 +51,20 @@ export interface MeshInstances {
51
51
  * either mismatch is said once on the console.
52
52
  */
53
53
  readonly lightmapRegions?: Float32Array;
54
+ /**
55
+ * Four floats each, the instance's cell of its texture: `[scaleU, scaleV, offsetU, offsetV]`,
56
+ * applied to the mesh's coordinates before the material's own scale and offset, in every pass
57
+ * that reads them — so one draw carries particles in different cells of a flipbook, or props
58
+ * wearing different tiles of one atlas. **Absent, or past `count`, is the whole texture**,
59
+ * `[1, 1, 0, 0]`, which is what every instance read before 4.8.7.
60
+ *
61
+ * **It rides the matrix's bottom row**, which an instance's placement — a turn, a scale and a
62
+ * move — leaves at `[0, 0, 0, 1]`, and which every stage drawing an instance rebuilds as that.
63
+ * So a placement must be affine, as every placement of a solid object is; a projective one is
64
+ * drawn as its affine part. A culled batch (`cullInstances`) carries the cells only into a
65
+ * target that has the array.
66
+ */
67
+ readonly uvRegions?: Float32Array;
54
68
  /** How many instances the arrays hold. Fixed at creation; the buffers are sized from it. */
55
69
  readonly capacity: number;
56
70
  /** How many are live. The rest of the buffer is neither uploaded nor drawn. */
@@ -75,8 +89,8 @@ export function createMeshInstances(capacity: number): MeshInstances {
75
89
  }
76
90
 
77
91
  /**
78
- * Floats one instance occupies in the interleaved vertex buffer: sixteen of matrix, three of
79
- * tint, and the opacity.
92
+ * Floats one instance occupies in the interleaved vertex buffer: sixteen of matrix, the bottom row
93
+ * of which carries the texture cell, three of tint, and the opacity.
80
94
  *
81
95
  * **Twenty so the stride is 80 bytes rather than 76.** A vertex buffer's stride must be a multiple
82
96
  * of four on both backends, which 76 already is — the twentieth float was padding for the
@@ -101,12 +115,18 @@ export const INSTANCE_STRIDE = INSTANCE_FLOATS * 4;
101
115
  * Writes nothing beyond `count`, and allocates nothing: `out` is the caller's staging array.
102
116
  */
103
117
  export function packInstances(instances: MeshInstances, out: Float32Array): void {
104
- const { models, tints, alphas, lightmapRegions } = instances;
118
+ const { models, tints, alphas, lightmapRegions, uvRegions } = instances;
105
119
  const count = Math.min(instances.count, instances.capacity);
106
120
  for (let i = 0; i < count; i += 1) {
107
121
  const at = i * INSTANCE_FLOATS;
108
122
  const m = i * 16;
109
123
  for (let c = 0; c < 16; c += 1) out[at + c] = models[m + c] as number;
124
+ /* The texture cell in the bottom row an affine placement leaves free. See `uvRegions`. */
125
+ const cell = i * 4;
126
+ out[at + 3] = uvRegions?.[cell] ?? 1;
127
+ out[at + 7] = uvRegions?.[cell + 1] ?? 1;
128
+ out[at + 11] = uvRegions?.[cell + 2] ?? 0;
129
+ out[at + 15] = uvRegions?.[cell + 3] ?? 0;
110
130
  if (lightmapRegions !== undefined) {
111
131
  /* The region in the tint's and the opacity's place. See `lightmapRegions`. */
112
132
  for (let c = 0; c < 4; c += 1) out[at + 16 + c] = lightmapRegions[i * 4 + c] as number;
@@ -1,4 +1,5 @@
1
1
  /** Fixed point-light budget shared by CPU selection and the flat shader. */
2
+ import { MAX_AREA_LIGHTS, packAreaSizesAndDoors } from './areaLights.ts';
2
3
  import type { AreaLightBuffer } from './areaLights.ts';
3
4
  import type { ResolvedAreaShadows } from './areaShadowSet.ts';
4
5
  import {
@@ -7,6 +8,7 @@ import {
7
8
  lampSourceRadius,
8
9
  lampWeight,
9
10
  lightChannelsOf,
11
+ lightFalloffExponentOf,
10
12
  } from './clusteredLights.ts';
11
13
 
12
14
  /**
@@ -401,11 +403,20 @@ export interface PointLightSet {
401
403
  * character's own key and rim lights, which the floor around the character must not take.
402
404
  */
403
405
  readonly lightChannels?: Float32Array;
406
+ /**
407
+ * One per light: its own falloff exponent, `(1 - (d/R)^2)^n` with no distance term, or 0 for the
408
+ * frame's falloff. **Absent, or a short array, is 0 for every light**, so a scene that names none
409
+ * falls off as it did. See `PointLightSource.falloffExponent`.
410
+ */
411
+ readonly lightFalloffExponents?: Float32Array;
404
412
  }
405
413
 
406
414
  /** Weights are optional per consumer; a short array means every light is fully present. */
407
415
  const FULLY_PRESENT: Float32Array = new Float32Array(MAX_POINT_LIGHTS).fill(1);
408
416
 
417
+ /** The fixed arm's rectangles as `uAreaLightSize` holds them; refilled per bind, allocated once. */
418
+ const AREA_SIZES_AND_DOORS = new Float32Array(MAX_AREA_LIGHTS * 4);
419
+
409
420
  /** No size at all, which is the behaviour every scene had before a light could have one. */
410
421
  const POINT_SOURCES: Float32Array = new Float32Array(MAX_POINT_LIGHTS);
411
422
  /**
@@ -504,7 +515,8 @@ export function bindPointLights(
504
515
  * every lamp switched off.
505
516
  */
506
517
  gl.uniform3fv(uniforms['uLightDir[0]'] ?? null, r.directions);
507
- gl.uniform2fv(uniforms['uLightCone[0]'] ?? null, r.coneCos);
518
+ /* The cone and, in z, the light's own falloff exponent. See `conesAndFalloffs`. */
519
+ gl.uniform4fv(uniforms['uLightCone[0]'] ?? null, r.conesAndFalloffs);
508
520
  gl.uniform1fv(uniforms['uLightIesProfile[0]'] ?? null, r.iesProfiles);
509
521
  /* The azimuth and, in w, the channels. See `axesAndChannels`. */
510
522
  gl.uniform4fv(uniforms['uLightIesAxis[0]'] ?? null, r.axesAndChannels);
@@ -535,7 +547,9 @@ export function bindAreaLights(
535
547
  gl.uniform3fv(uniforms['uAreaLightColor[0]'] ?? null, lights.colors);
536
548
  gl.uniform3fv(uniforms['uAreaLightRight[0]'] ?? null, lights.right);
537
549
  gl.uniform3fv(uniforms['uAreaLightUp[0]'] ?? null, lights.up);
538
- gl.uniform2fv(uniforms['uAreaLightSize[0]'] ?? null, lights.sizes);
550
+ /* The half extents and, in zw, the barn doors. See `packAreaSizesAndDoors`. */
551
+ packAreaSizesAndDoors(lights, AREA_SIZES_AND_DOORS);
552
+ gl.uniform4fv(uniforms['uAreaLightSize[0]'] ?? null, AREA_SIZES_AND_DOORS);
539
553
  gl.uniform1fv(uniforms['uAreaLightTwoSided[0]'] ?? null, lights.twoSided);
540
554
  }
541
555
 
@@ -589,6 +603,12 @@ export interface ResolvedPointLights {
589
603
  * array of their own, which would cost a row a light of a budget that has none to give.
590
604
  */
591
605
  readonly axesAndChannels: Float32Array;
606
+ /**
607
+ * Owned, four a light: the cone's two cosines, then the light's own falloff exponent, as
608
+ * `uLightCone` carries them — a `vec2` array costs a whole row a light on WebGL2 anyway, so the
609
+ * exponent rides the half it left rather than a row of its own.
610
+ */
611
+ readonly conesAndFalloffs: Float32Array;
592
612
  /**
593
613
  * Owned, `MAX_POINT_LIGHTS` long: the emitter sizes and weights as the froxel record carries
594
614
  * them, at half precision. `sourceRadii` and `weights` point here once a light set resolves, so
@@ -614,6 +634,7 @@ const glLights: ResolvedPointLights = {
614
634
  iesAxes: NO_IES_AXES,
615
635
  cookies: NO_COOKIES,
616
636
  axesAndChannels: new Float32Array(MAX_POINT_LIGHTS * 4),
637
+ conesAndFalloffs: new Float32Array(MAX_POINT_LIGHTS * 4),
617
638
  heldSourceRadii: new Float32Array(MAX_POINT_LIGHTS),
618
639
  heldWeights: new Float32Array(MAX_POINT_LIGHTS),
619
640
  };
@@ -649,6 +670,7 @@ export function resolvePointLights(
649
670
  out.iesAxes = NO_IES_AXES;
650
671
  out.cookies = NO_COOKIES;
651
672
  packAxesAndChannels(NO_IES_AXES, undefined, out.axesAndChannels);
673
+ packConesAndFalloffs(OPEN_CONES, undefined, out.conesAndFalloffs);
652
674
  return out;
653
675
  }
654
676
  /*
@@ -742,9 +764,25 @@ export function resolvePointLights(
742
764
  out.iesAxes = iesAxes;
743
765
  out.cookies = cookies;
744
766
  packAxesAndChannels(iesAxes, lights.lightChannels, out.axesAndChannels);
767
+ packConesAndFalloffs(coneCos, lights.lightFalloffExponents, out.conesAndFalloffs);
745
768
  return out;
746
769
  }
747
770
 
771
+ /** The cone and the falloff exponent, four a light, as `uLightCone` holds them. */
772
+ function packConesAndFalloffs(
773
+ cones: Float32Array,
774
+ exponents: Float32Array | undefined,
775
+ out: Float32Array,
776
+ ): void {
777
+ const named = (exponents?.length ?? 0) >= MAX_POINT_LIGHTS ? exponents : undefined;
778
+ for (let light = 0; light < MAX_POINT_LIGHTS; light++) {
779
+ out[light * 4] = cones[light * 2] ?? POINT_LIGHT_COS_INNER;
780
+ out[light * 4 + 1] = cones[light * 2 + 1] ?? POINT_LIGHT_COS_OUTER;
781
+ out[light * 4 + 2] = lightFalloffExponentOf(named?.[light]);
782
+ out[light * 4 + 3] = 0;
783
+ }
784
+ }
785
+
748
786
  /** The azimuth and the channels, four a light, as `uLightIesAxis` holds them. */
749
787
  function packAxesAndChannels(
750
788
  axes: Float32Array,
@@ -30,17 +30,19 @@
30
30
  * regions no tint. **What it does not do**: the GPU-driven pipeline and splats read no page.
31
31
  */
32
32
  import type { MeshData } from '@driftengine/drft';
33
- import { toHalfFloats } from './halfFloat.ts';
33
+ import { packRgb9e5 } from './rgb9e5.ts';
34
34
 
35
35
  /** One page as a consumer decoded it. */
36
36
  export interface LightmapPage {
37
37
  readonly width: number;
38
38
  readonly height: number;
39
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.
40
+ * Linear irradiance, row-major from the top, in the same units every other light here is in — a
41
+ * bake from another engine scaled by that engine's exposure, as its lights are. Either three
42
+ * floats a texel, or one word a texel already packed as rgb9e5 (`rgb9e5.ts`), which is how such a
43
+ * bake usually arrives and which uploads with no conversion at all.
42
44
  */
43
- readonly irradiance: Float32Array;
45
+ readonly irradiance: Float32Array | Uint32Array;
44
46
  /**
45
47
  * The direction the light arrives from, four bytes a texel: a first-order spherical harmonic in
46
48
  * this engine's world axes, each value stored as `v * 0.5 + 0.5` — xyz the axis, w the constant.
@@ -60,13 +62,25 @@ export type LightmapRegion = readonly [number, number, number, number];
60
62
  /** The whole page. */
61
63
  export const WHOLE_PAGE: LightmapRegion = [1, 1, 0, 0];
62
64
 
63
- /** A page as both backends upload it: `layers` images of half-float RGBA, row-major from the top. */
65
+ /**
66
+ * A page as both backends upload it: two layers of four bytes a texel, row-major from the top. The
67
+ * first is the irradiance's rgb9e5 word, byte by byte from its lowest; the second is the direction.
68
+ *
69
+ * **Eight bytes a texel, and the shader filters it.** Half floats were sixteen: a stage of 49 pages
70
+ * and 21.8 million texels held 348 MB, where this holds 174. The price is that the irradiance cannot
71
+ * be filtered as it is stored — a bilinear blend of two words with different exponents is not the
72
+ * blend of the colours they hold — so the lit stage fetches a texel's four neighbours, decodes each
73
+ * and blends the colours (`modelBaked` in `shaders/flat/models.ts`): four fetches a layer where one
74
+ * filtered sample was, on lightmapped surfaces alone. **What would make it wrong** is every device
75
+ * a page reaches sampling `rgb9e5ufloat`, which WebGL2 does as `RGB9_E5` too; it would then be a
76
+ * texture of its own, filtered by the device, and a binding the lit stage has no room for.
77
+ */
64
78
  export interface LightmapTexels {
65
79
  readonly kind: 'lightmap-texels';
66
80
  readonly width: number;
67
81
  readonly height: number;
68
- readonly layers: number;
69
- readonly texels: Uint16Array;
82
+ /** Each layer's bytes, in layer order: the irradiance, then the direction. */
83
+ readonly layers: readonly Uint8Array[];
70
84
  }
71
85
 
72
86
  /** Whether a surface texture's source is a page's texels rather than an image or blocks. */
@@ -79,9 +93,9 @@ export function isLightmapTexels(source: unknown): source is LightmapTexels {
79
93
  }
80
94
 
81
95
  /**
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.
96
+ * A page as both backends upload it (see `LightmapTexels`). Refuses a page whose arrays are not its
97
+ * size, naming the one that is short. Packed irradiance is read where it lies, with no copy; float
98
+ * irradiance is packed once, here.
85
99
  */
86
100
  export function lightmapTexels(page: LightmapPage): LightmapTexels {
87
101
  const { width, height } = page;
@@ -89,10 +103,14 @@ export function lightmapTexels(page: LightmapPage): LightmapTexels {
89
103
  throw new Error(`createLightmap: a ${width} by ${height} page has nothing in it`);
90
104
  }
91
105
  const texels = width * height;
92
- if (page.irradiance.length !== texels * 3) {
106
+ const irradiance = page.irradiance;
107
+ const packed = irradiance instanceof Uint32Array;
108
+ const wanted = packed ? texels : texels * 3;
109
+ if (irradiance.length !== wanted) {
93
110
  throw new Error(
94
- `createLightmap: ${page.irradiance.length} irradiance floats for a ${width} by ${height} ` +
95
- `page, which wants ${texels * 3}: three a texel`,
111
+ `createLightmap: ${irradiance.length} irradiance ${packed ? 'words' : 'floats'} for a ` +
112
+ `${width} by ${height} page, which wants ${wanted}: ` +
113
+ (packed ? 'one rgb9e5 word a texel' : 'three a texel'),
96
114
  );
97
115
  }
98
116
  if (page.direction.length !== texels * 4) {
@@ -101,24 +119,47 @@ export function lightmapTexels(page: LightmapPage): LightmapTexels {
101
119
  `page, which wants ${texels * 4}: four a texel`,
102
120
  );
103
121
  }
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;
122
+ let words: Uint32Array;
123
+ if (packed) {
124
+ words = irradiance;
125
+ } else {
126
+ words = new Uint32Array(texels);
127
+ for (let t = 0; t < texels; t++) {
128
+ words[t] = packRgb9e5(
129
+ irradiance[t * 3] as number,
130
+ irradiance[t * 3 + 1] as number,
131
+ irradiance[t * 3 + 2] as number,
132
+ );
133
+ }
112
134
  }
113
135
  return {
114
136
  kind: 'lightmap-texels',
115
137
  width,
116
138
  height,
117
- layers: LIGHTMAP_LAYERS,
118
- texels: toHalfFloats(floats),
139
+ layers: [littleEndianBytes(words), page.direction],
119
140
  };
120
141
  }
121
142
 
143
+ /** Whether this platform lays a word out lowest byte first, which the shader's decode assumes. */
144
+ const LITTLE_ENDIAN = new Uint8Array(new Uint32Array([1]).buffer)[0] === 1;
145
+
146
+ /**
147
+ * A page's words as bytes, lowest first: a view of the same memory on every little-endian platform,
148
+ * which is every one a browser runs on, and a reordered copy anywhere else.
149
+ */
150
+ function littleEndianBytes(words: Uint32Array): Uint8Array {
151
+ const bytes = new Uint8Array(words.buffer, words.byteOffset, words.length * 4);
152
+ if (LITTLE_ENDIAN) return bytes;
153
+ const swapped = new Uint8Array(bytes.length);
154
+ for (let i = 0; i < bytes.length; i += 4) {
155
+ swapped[i] = bytes[i + 3] as number;
156
+ swapped[i + 1] = bytes[i + 2] as number;
157
+ swapped[i + 2] = bytes[i + 1] as number;
158
+ swapped[i + 3] = bytes[i] as number;
159
+ }
160
+ return swapped;
161
+ }
162
+
122
163
  /**
123
164
  * A mesh with its second coordinates moved onto the grain and relief streams they ride, or the
124
165
  * mesh itself where it has none. Called by both backends' `createMesh`, before anything records