@driftengine/core 4.8.4 → 4.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/CHANGELOG.json +4332 -4250
  2. package/dist/build/shaderComments.js +0 -0
  3. package/dist/index.d.ts +10 -2
  4. package/dist/index.js +11 -1
  5. package/dist/render/ambientHarmonics.d.ts +46 -0
  6. package/dist/render/ambientHarmonics.js +80 -0
  7. package/dist/render/areaLights.d.ts +19 -0
  8. package/dist/render/areaLights.js +20 -0
  9. package/dist/render/backend/api.d.ts +13 -2
  10. package/dist/render/backend/webgl2/instanced.d.ts +2 -0
  11. package/dist/render/backend/webgl2/instanced.js +11 -2
  12. package/dist/render/backend/webgl2/renderer.d.ts +74 -1
  13. package/dist/render/backend/webgl2/renderer.js +229 -37
  14. package/dist/render/backend/webgl2/skinScatter.d.ts +14 -0
  15. package/dist/render/backend/webgl2/skinScatter.js +88 -26
  16. package/dist/render/backend/webgpu/buffers.js +2 -1
  17. package/dist/render/backend/webgpu/clusterBinner.js +3 -2
  18. package/dist/render/backend/webgpu/flatPass.d.ts +7 -5
  19. package/dist/render/backend/webgpu/flatPass.js +15 -13
  20. package/dist/render/backend/webgpu/particlePass.d.ts +37 -4
  21. package/dist/render/backend/webgpu/particlePass.js +80 -12
  22. package/dist/render/backend/webgpu/postPass.d.ts +6 -1
  23. package/dist/render/backend/webgpu/postPass.js +7 -2
  24. package/dist/render/backend/webgpu/renderer.d.ts +58 -7
  25. package/dist/render/backend/webgpu/renderer.js +329 -78
  26. package/dist/render/backend/webgpu/shaders/clusterBin.wgsl.d.ts +1 -1
  27. package/dist/render/backend/webgpu/shaders/clusterBin.wgsl.js +41 -1
  28. package/dist/render/backend/webgpu/skinPaletteRing.d.ts +40 -28
  29. package/dist/render/backend/webgpu/skinPaletteRing.js +69 -32
  30. package/dist/render/backend/webgpu/skinScatterPass.d.ts +10 -2
  31. package/dist/render/backend/webgpu/skinScatterPass.js +59 -9
  32. package/dist/render/backend/webgpu/surfaceTexturePass.d.ts +2 -1
  33. package/dist/render/backend/webgpu/surfaceTexturePass.js +18 -4
  34. package/dist/render/bloomChain.d.ts +44 -0
  35. package/dist/render/bloomChain.js +28 -0
  36. package/dist/render/bloomPass.d.ts +3 -1
  37. package/dist/render/bloomPass.js +16 -2
  38. package/dist/render/clusteredLights.d.ts +55 -1
  39. package/dist/render/clusteredLights.js +182 -8
  40. package/dist/render/fixtureAtlas.js +1 -1
  41. package/dist/render/instanceCull.js +7 -0
  42. package/dist/render/instances.d.ts +32 -10
  43. package/dist/render/instances.js +17 -8
  44. package/dist/render/lightBudget.d.ts +26 -1
  45. package/dist/render/lightBudget.js +26 -3
  46. package/dist/render/lightmap.d.ts +102 -0
  47. package/dist/render/lightmap.js +118 -0
  48. package/dist/render/mesh.js +2 -1
  49. package/dist/render/particleBatch.d.ts +59 -3
  50. package/dist/render/particleBatch.js +53 -2
  51. package/dist/render/particleMaterial.d.ts +6 -1
  52. package/dist/render/particleMaterial.js +10 -2
  53. package/dist/render/particlePool.d.ts +11 -0
  54. package/dist/render/particlePool.js +2 -0
  55. package/dist/render/particleSort.d.ts +34 -0
  56. package/dist/render/particleSort.js +96 -0
  57. package/dist/render/sceneTarget.d.ts +5 -0
  58. package/dist/render/sceneTarget.js +8 -1
  59. package/dist/render/shaders/bloom.d.ts +8 -4
  60. package/dist/render/shaders/bloom.js +20 -3
  61. package/dist/render/shaders/flat/areaLight.d.ts +10 -0
  62. package/dist/render/shaders/flat/areaLight.js +96 -0
  63. package/dist/render/shaders/flat/index.js +30 -4
  64. package/dist/render/shaders/flat/main.d.ts +1 -1
  65. package/dist/render/shaders/flat/main.js +107 -105
  66. package/dist/render/shaders/flat/models.d.ts +16 -4
  67. package/dist/render/shaders/flat/models.js +64 -4
  68. package/dist/render/shaders/flat/normalMap.d.ts +10 -0
  69. package/dist/render/shaders/flat/normalMap.js +25 -0
  70. package/dist/render/shaders/flat/preamble.js +74 -37
  71. package/dist/render/shaders/flat/skin.js +6 -5
  72. package/dist/render/shaders/generated/bloom.wgsl.d.ts +13 -3
  73. package/dist/render/shaders/generated/bloom.wgsl.js +13 -3
  74. package/dist/render/shaders/generated/flat.wgsl.d.ts +1631 -2044
  75. package/dist/render/shaders/generated/flat.wgsl.js +1669 -2082
  76. package/dist/render/shaders/generated/particle.wgsl.d.ts +115 -1
  77. package/dist/render/shaders/generated/particle.wgsl.js +115 -1
  78. package/dist/render/shaders/generated/skinBlur.wgsl.d.ts +12 -2
  79. package/dist/render/shaders/generated/skinBlur.wgsl.js +12 -2
  80. package/dist/render/shaders/particle.d.ts +17 -1
  81. package/dist/render/shaders/particle.js +133 -1
  82. package/dist/render/shaders/skinBlur.js +25 -3
  83. package/dist/render/skinBlur.d.ts +8 -5
  84. package/dist/render/skinBlur.js +8 -5
  85. package/dist/render/surfaceModel.d.ts +21 -1
  86. package/dist/render/surfaceModel.js +22 -0
  87. package/dist/render/surfaceTexture.d.ts +14 -1
  88. package/dist/render/surfaceTexture.js +15 -2
  89. package/package.json +6 -6
  90. package/src/build/shaderComments.ts +0 -0
  91. package/src/index.ts +20 -1
  92. package/src/render/ambientHarmonics.ts +92 -0
  93. package/src/render/areaLights.ts +39 -0
  94. package/src/render/backend/api.ts +14 -1
  95. package/src/render/backend/webgl2/instanced.ts +12 -2
  96. package/src/render/backend/webgl2/renderer.ts +292 -35
  97. package/src/render/backend/webgl2/skinScatter.ts +120 -43
  98. package/src/render/backend/webgpu/buffers.ts +2 -1
  99. package/src/render/backend/webgpu/clusterBinner.ts +3 -1
  100. package/src/render/backend/webgpu/flatPass.ts +18 -13
  101. package/src/render/backend/webgpu/particlePass.ts +118 -12
  102. package/src/render/backend/webgpu/postPass.ts +7 -2
  103. package/src/render/backend/webgpu/renderer.ts +396 -95
  104. package/src/render/backend/webgpu/shaders/clusterBin.wgsl.ts +41 -1
  105. package/src/render/backend/webgpu/skinPaletteRing.ts +65 -33
  106. package/src/render/backend/webgpu/skinScatterPass.ts +59 -9
  107. package/src/render/backend/webgpu/surfaceTexturePass.ts +26 -5
  108. package/src/render/bloomChain.ts +69 -0
  109. package/src/render/bloomPass.ts +26 -2
  110. package/src/render/clusteredLights.ts +221 -7
  111. package/src/render/fixtureAtlas.ts +1 -1
  112. package/src/render/instanceCull.ts +5 -0
  113. package/src/render/instances.ts +40 -11
  114. package/src/render/lightBudget.ts +48 -2
  115. package/src/render/lightmap.ts +202 -0
  116. package/src/render/mesh.ts +2 -1
  117. package/src/render/particleBatch.ts +95 -4
  118. package/src/render/particleMaterial.ts +11 -1
  119. package/src/render/particlePool.ts +13 -0
  120. package/src/render/particleSort.ts +133 -0
  121. package/src/render/sceneTarget.ts +18 -1
  122. package/src/render/shaders/bloom.ts +20 -3
  123. package/src/render/shaders/flat/areaLight.ts +96 -0
  124. package/src/render/shaders/flat/index.ts +30 -4
  125. package/src/render/shaders/flat/main.ts +107 -105
  126. package/src/render/shaders/flat/models.ts +73 -8
  127. package/src/render/shaders/flat/normalMap.ts +25 -0
  128. package/src/render/shaders/flat/preamble.ts +74 -37
  129. package/src/render/shaders/flat/skin.ts +6 -5
  130. package/src/render/shaders/generated/bloom.wgsl.ts +13 -3
  131. package/src/render/shaders/generated/flat.wgsl.ts +1669 -2082
  132. package/src/render/shaders/generated/particle.wgsl.ts +116 -1
  133. package/src/render/shaders/generated/skinBlur.wgsl.ts +12 -2
  134. package/src/render/shaders/particle.ts +134 -1
  135. package/src/render/shaders/skinBlur.ts +25 -3
  136. package/src/render/skinBlur.ts +8 -5
  137. package/src/render/surfaceModel.ts +36 -1
  138. package/src/render/surfaceTexture.ts +41 -3
@@ -1,3 +1,5 @@
1
+ import type { AreaLightBuffer } from './areaLights.ts';
2
+
1
3
  /**
2
4
  * The froxel table: what it looks like in memory, and the CPU binner that fills it.
3
5
  *
@@ -146,8 +148,29 @@ export const LIGHT_RECORD = {
146
148
  iesAxisX: 16,
147
149
  iesAxisY: 17,
148
150
  iesAxisZ: 18,
151
+ /**
152
+ * The light's lighting channels, a mask (`PointLightSet.lightChannels`), in the slot that was
153
+ * spare. A light naming channels other than the default is read as carrying a fixture, so its
154
+ * last texels are fetched; a plain light's are not, and the shader takes the default for it.
155
+ */
156
+ channels: 19,
149
157
  } as const;
150
158
 
159
+ /** The lighting channel every light and every surface is on unless it says otherwise. */
160
+ export const DEFAULT_LIGHT_CHANNELS = 1;
161
+
162
+ /**
163
+ * A light's channels as the shader compares them: a whole mask from 1 to 255, or the default where
164
+ * none was named or what was named is not one. The froxel record and the uniform path both write
165
+ * this, so a light named badly is on channel 1 in both rather than on two different guesses.
166
+ */
167
+ export function lightChannelsOf(value: number | undefined): number {
168
+ if (value === undefined || !Number.isInteger(value) || value < 1 || value > 255) {
169
+ return DEFAULT_LIGHT_CHANNELS;
170
+ }
171
+ return value;
172
+ }
173
+
151
174
  /** Texels the lit pass reads for a light whose record carries no fixture. */
152
175
  export const PLAIN_LIGHT_TEXELS = 2;
153
176
 
@@ -401,6 +424,16 @@ export interface ClusterLightSet {
401
424
  readonly cookies?: Float32Array;
402
425
  /** One per light: an index into the IES atlas, or −1. */
403
426
  readonly iesProfiles?: Float32Array;
427
+ /** One per light: its lighting channels. See `PointLightSet.lightChannels`. */
428
+ readonly channels?: Float32Array;
429
+ /**
430
+ * The rectangles shaded through the table, after the `count` point lights: those of `areas` from
431
+ * `areaFrom` on, `areaCount` of them. The ones before `areaFrom` are the fixed arm's, which is
432
+ * where a rectangle's shadow lives. Absent, there are none.
433
+ */
434
+ readonly areas?: AreaLightBuffer | null;
435
+ readonly areaFrom?: number;
436
+ readonly areaCount?: number;
404
437
  }
405
438
 
406
439
  /** Where `fillClusterLightSet` reads a frame's lights from: the arrays `Environment` carries. */
@@ -416,6 +449,8 @@ export interface ClusterLightArrays {
416
449
  readonly lightIesProfiles?: Float32Array;
417
450
  readonly lightIesAxes?: Float32Array;
418
451
  readonly lightCookies?: Float32Array;
452
+ readonly lightChannels?: Float32Array;
453
+ readonly areaLights?: AreaLightBuffer | null;
419
454
  }
420
455
 
421
456
  /** A `ClusterLightSet` a renderer refills every frame. */
@@ -453,8 +488,17 @@ export function createClusterLightSet(): ClusterLightScratch {
453
488
  export function fillClusterLightSet(
454
489
  env: ClusterLightArrays,
455
490
  out: ClusterLightScratch,
491
+ /** How many rectangles the fixed arm shades; the rest of the buffer is clustered. */
492
+ areaFrom = 0,
456
493
  ): ClusterLightSet {
457
494
  out.count = Math.min(env.lightCount ?? 0, MAX_CLUSTERED_LIGHTS);
495
+ const areas = env.areaLights ?? null;
496
+ out.areas = areas;
497
+ out.areaFrom = areaFrom;
498
+ out.areaCount = Math.max(
499
+ 0,
500
+ Math.min((areas?.count ?? 0) - areaFrom, MAX_CLUSTERED_LIGHTS - out.count),
501
+ );
458
502
  out.positions = env.lightPositions;
459
503
  out.colors = env.lightColors;
460
504
  out.radii = env.lightRadii;
@@ -465,6 +509,7 @@ export function fillClusterLightSet(
465
509
  out.iesProfiles = env.lightIesProfiles;
466
510
  out.iesAxes = env.lightIesAxes;
467
511
  out.cookies = env.lightCookies;
512
+ out.channels = env.lightChannels;
468
513
  return out;
469
514
  }
470
515
 
@@ -526,13 +571,16 @@ export function lightHasFixture(
526
571
  light: number,
527
572
  shadowSlots: number,
528
573
  ): boolean {
574
+ /* A rectangle's record always carries its axes and extents past the second texel. */
575
+ if (light >= lights.count) return true;
529
576
  const cone = lights.coneCos;
530
577
  return (
531
578
  light < shadowSlots ||
532
579
  (cone?.[light * 2] ?? POINT_LIGHT_COS_INNER) !== POINT_LIGHT_COS_INNER ||
533
580
  (cone?.[light * 2 + 1] ?? POINT_LIGHT_COS_OUTER) !== POINT_LIGHT_COS_OUTER ||
534
581
  (lights.iesProfiles?.[light] ?? NO_IES_PROFILE) >= 0 ||
535
- (lights.cookies?.[light] ?? NO_IES_PROFILE) >= 0
582
+ (lights.cookies?.[light] ?? NO_IES_PROFILE) >= 0 ||
583
+ lightChannelsOf(lights.channels?.[light]) !== DEFAULT_LIGHT_CHANNELS
536
584
  );
537
585
  }
538
586
 
@@ -576,6 +624,10 @@ export function writeLightRecord(
576
624
  out: Uint32Array,
577
625
  at: number,
578
626
  ): void {
627
+ if (light >= lights.count) {
628
+ writeAreaRecord(lights, light, out, at);
629
+ return;
630
+ }
579
631
  const directions = lights.directions;
580
632
  const cone = lights.coneCos;
581
633
  const axes = lights.iesAxes;
@@ -612,7 +664,72 @@ export function writeLightRecord(
612
664
  out[at + LIGHT_RECORD.iesAxisX] = floatBits(axes?.[light * 3] ?? 0);
613
665
  out[at + LIGHT_RECORD.iesAxisY] = floatBits(axes?.[light * 3 + 1] ?? 0);
614
666
  out[at + LIGHT_RECORD.iesAxisZ] = floatBits(axes?.[light * 3 + 2] ?? 0);
615
- out[at + 19] = 0;
667
+ out[at + LIGHT_RECORD.channels] = floatBits(lightChannelsOf(lights.channels?.[light]));
668
+ }
669
+
670
+ /**
671
+ * Which rectangle of the area buffer clustered light `light` is, for a light past the point
672
+ * lights. See `ClusterLightSet.areas`.
673
+ */
674
+ function areaSlot(lights: ClusterLightSet, light: number): number {
675
+ return (lights.areaFrom ?? 0) + light - lights.count;
676
+ }
677
+
678
+ /**
679
+ * 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,
681
+ * 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
683
+ * the fixed arm's, which is why the first `maxAreaLights` stay there.
684
+ */
685
+ function writeAreaRecord(
686
+ lights: ClusterLightSet,
687
+ light: number,
688
+ out: Uint32Array,
689
+ at: number,
690
+ ): void {
691
+ const areas = lights.areas;
692
+ const a = areaSlot(lights, light);
693
+ const pos = areas?.positions;
694
+ const color = areas?.colors;
695
+ const right = areas?.right;
696
+ const up = areas?.up;
697
+ const sizes = areas?.sizes;
698
+ out[at + LIGHT_RECORD.positionX] = floatBits(pos?.[a * 3] ?? 0);
699
+ out[at + LIGHT_RECORD.positionY] = floatBits(pos?.[a * 3 + 1] ?? 0);
700
+ out[at + LIGHT_RECORD.positionZ] = floatBits(pos?.[a * 3 + 2] ?? 0);
701
+ out[at + LIGHT_RECORD.radius] = floatBits(-areaReach(lights, light));
702
+ out[at + LIGHT_RECORD.colorR] = floatBits(color?.[a * 3] ?? 0);
703
+ out[at + LIGHT_RECORD.colorG] = floatBits(color?.[a * 3 + 1] ?? 0);
704
+ out[at + LIGHT_RECORD.colorB] = floatBits(color?.[a * 3 + 2] ?? 0);
705
+ out[at + LIGHT_RECORD.sizeAndWeight] = packSizeAndWeight(0, 1, true);
706
+ out[at + LIGHT_RECORD.shadowSlot] = floatBits(areas?.twoSided[a] ?? 0);
707
+ out[at + LIGHT_RECORD.directionX] = floatBits(right?.[a * 3] ?? 1);
708
+ out[at + LIGHT_RECORD.directionY] = floatBits(right?.[a * 3 + 1] ?? 0);
709
+ out[at + LIGHT_RECORD.directionZ] = floatBits(right?.[a * 3 + 2] ?? 0);
710
+ out[at + LIGHT_RECORD.cosInner] = floatBits(sizes?.[a * 2] ?? 0);
711
+ out[at + LIGHT_RECORD.cosOuter] = floatBits(sizes?.[a * 2 + 1] ?? 0);
712
+ out[at + LIGHT_RECORD.iesProfile] = floatBits(NO_IES_PROFILE);
713
+ out[at + LIGHT_RECORD.cookie] = floatBits(NO_IES_PROFILE);
714
+ out[at + LIGHT_RECORD.iesAxisX] = floatBits(up?.[a * 3] ?? 0);
715
+ out[at + LIGHT_RECORD.iesAxisY] = floatBits(up?.[a * 3 + 1] ?? 1);
716
+ out[at + LIGHT_RECORD.iesAxisZ] = floatBits(up?.[a * 3 + 2] ?? 0);
717
+ out[at + LIGHT_RECORD.channels] = floatBits(DEFAULT_LIGHT_CHANNELS);
718
+ }
719
+
720
+ /** A clustered rectangle's reach, which its record carries negated. See `AreaLightSource.range`. */
721
+ function areaReach(lights: ClusterLightSet, light: number): number {
722
+ const areas = lights.areas;
723
+ const a = areaSlot(lights, light);
724
+ const named = areas?.ranges?.[a] ?? 0;
725
+ if (named > 0) return named;
726
+ const sizes = areas?.sizes;
727
+ return Math.hypot(sizes?.[a * 2] ?? 0, sizes?.[a * 2 + 1] ?? 0);
728
+ }
729
+
730
+ /** How many lights the table carries: the point lights and the clustered rectangles after them. */
731
+ export function clusteredLightTotal(lights: ClusterLightSet): number {
732
+ return Math.min(lights.count + (lights.areaCount ?? 0), MAX_CLUSTERED_LIGHTS);
616
733
  }
617
734
 
618
735
  /**
@@ -670,7 +787,7 @@ export function buildLightClusters(
670
787
  table: Uint32Array,
671
788
  shadowSlots: number,
672
789
  ): number {
673
- const count = Math.min(lights.count, MAX_CLUSTERED_LIGHTS);
790
+ const count = clusteredLightTotal(lights);
674
791
 
675
792
  /*
676
793
  * The whole cluster region, not just the counts.
@@ -686,10 +803,14 @@ export function buildLightClusters(
686
803
  for (let k = 0; k <= CLUSTER_Z; k++) sliceDepths[k] = sliceNearDepth(k, near, far);
687
804
 
688
805
  for (let light = 0; light < count; light++) {
689
- const x = lights.positions[light * 3] ?? 0;
690
- const y = lights.positions[light * 3 + 1] ?? 0;
691
- const z = lights.positions[light * 3 + 2] ?? 0;
692
- const radius = lights.radii[light] ?? 0;
806
+ /* A rectangle past the lamps is placed by its centre and binned by its reach. */
807
+ const area = light >= lights.count;
808
+ const from = area ? (lights.areas?.positions ?? lights.positions) : lights.positions;
809
+ const slot = area ? areaSlot(lights, light) : light;
810
+ const x = from[slot * 3] ?? 0;
811
+ const y = from[slot * 3 + 1] ?? 0;
812
+ const z = from[slot * 3 + 2] ?? 0;
813
+ const radius = area ? areaReach(lights, light) : (lights.radii[light] ?? 0);
693
814
 
694
815
  /* The record, as the shader reads it. World space; see the note above. */
695
816
  writeLightRecord(lights, light, shadowSlots, table, lightBase(light));
@@ -706,6 +827,40 @@ export function buildLightClusters(
706
827
  /* Entirely behind the camera, so no cluster can reach it. */
707
828
  if (depth + radius <= 0) continue;
708
829
 
830
+ /*
831
+ * **A spot is binned by its cone as well as its sphere.** A 10° spot reaching thirty metres
832
+ * touches every froxel its sphere does and lights a sliver of them, and a stage rigged with
833
+ * hundreds of such fixtures put every one of them in nearly every froxel near the floor: 313
834
+ * lights cost 11.7 ms of a 1600x900 frame. The cone is tested against each froxel's bounding
835
+ * sphere in view space, which is conservative, by the construction `coneBoxGap` describes.
836
+ */
837
+ const cosOuter = lights.coneCos?.[light * 2 + 1] ?? POINT_LIGHT_COS_OUTER;
838
+ const directions = lights.directions;
839
+ let coneCos = POINT_LIGHT_COS_OUTER;
840
+ let coneSin = 0;
841
+ let axisX = 0;
842
+ let axisY = 0;
843
+ let axisZ = 0;
844
+ if (!area && directions !== undefined && cosOuter > -1) {
845
+ const dx = directions[light * 3] ?? 0;
846
+ const dy = directions[light * 3 + 1] ?? 0;
847
+ const dz = directions[light * 3 + 2] ?? 0;
848
+ const ax = (view[0] ?? 0) * dx + (view[4] ?? 0) * dy + (view[8] ?? 0) * dz;
849
+ const ay = (view[1] ?? 0) * dx + (view[5] ?? 0) * dy + (view[9] ?? 0) * dz;
850
+ const az = -((view[2] ?? 0) * dx + (view[6] ?? 0) * dy + (view[10] ?? 0) * dz);
851
+ const length = Math.sqrt(ax * ax + ay * ay + az * az);
852
+ /* The shader measures the cone against the direction as given, so a long one is a wider
853
+ cone: `dot(L, d) >= c` is `dot(L, d / |d|) >= c / |d|`. */
854
+ if (length > 1e-6 && cosOuter / length > -1) {
855
+ coneCos = cosOuter / length;
856
+ coneSin = Math.sqrt(Math.max(0, 1 - coneCos * coneCos));
857
+ axisX = ax / length;
858
+ axisY = ay / length;
859
+ axisZ = az / length;
860
+ }
861
+ }
862
+ const spot = coneCos > -1;
863
+
709
864
  /*
710
865
  * The slices the sphere could possibly touch. The tiles are derived per slice, below.
711
866
  *
@@ -765,6 +920,12 @@ export function buildLightClusters(
765
920
  for (let i = iLo; i <= iHi; i++) {
766
921
  sliceBounds(i, j, zNear, zFar, halfWNear, halfWFar, halfHNear, halfHFar, bounds);
767
922
  if (distanceSqToBounds(vx, vy, depth, bounds) > radiusSq) continue;
923
+ if (
924
+ spot &&
925
+ coneBoxGap(vx, vy, depth, axisX, axisY, axisZ, coneCos, coneSin, bounds) > 0
926
+ ) {
927
+ continue;
928
+ }
768
929
  insert(table, i + j * CLUSTER_X + k * CLUSTER_X * CLUSTER_Y, light, bounds);
769
930
  }
770
931
  }
@@ -773,6 +934,59 @@ export function buildLightClusters(
773
934
  return count;
774
935
  }
775
936
 
937
+ /**
938
+ * How far a froxel lies outside a spot's cone, in metres, or a negative number where the cone
939
+ * reaches it: positive is a froxel the light cannot shade. Everything in view space with depth
940
+ * positive, as the binner holds it; `axis` is unit length and `cosA`, `sinA` are the outer cone's.
941
+ *
942
+ * **The froxel's bounding sphere against the infinite cone**, after Wronski's cone test for
943
+ * clustered spots. With `V` from the apex to the sphere's centre at an angle `θ` from the axis and
944
+ * `A` the cone's half-angle, the centre's distance from the cone is `|V| sin(θ − A)` — which is
945
+ * `cosA |V⊥| − (V·axis) sinA` — while `θ − A` is under a right angle, and `|V|` past it, where the
946
+ * nearest point of the cone is its apex. Inside the cone it is negative. So the gap is exact for
947
+ * the sphere, and the sphere holds the froxel: the test can only keep a light, never drop one the
948
+ * shader would draw. The apex case is what drops a froxel behind a spot whose sphere reaches it,
949
+ * which the formula alone underestimates.
950
+ *
951
+ * **What it gives up** is tightness at the near end of the frustum, where a froxel is a long thin
952
+ * box whose sphere is much larger than it: a spot grazing such a froxel is kept. **What would make
953
+ * it wrong** is a profile showing those near froxels hold the cost; testing the box's corners is
954
+ * the tight version, at eight dot products a pair.
955
+ *
956
+ * Exported so the conformance page can tell a disagreement at the cone's edge from a real one; the
957
+ * GPU binner writes the same arithmetic in WGSL.
958
+ */
959
+ export function coneBoxGap(
960
+ px: number,
961
+ py: number,
962
+ pz: number,
963
+ axisX: number,
964
+ axisY: number,
965
+ axisZ: number,
966
+ cosA: number,
967
+ sinA: number,
968
+ box: Float32Array,
969
+ ): number {
970
+ const b0 = box[0] ?? 0;
971
+ const b1 = box[1] ?? 0;
972
+ const b2 = box[2] ?? 0;
973
+ const b3 = box[3] ?? 0;
974
+ const b4 = box[4] ?? 0;
975
+ const b5 = box[5] ?? 0;
976
+ const ex = (b3 - b0) * 0.5;
977
+ const ey = (b4 - b1) * 0.5;
978
+ const ez = (b5 - b2) * 0.5;
979
+ const vx = (b0 + b3) * 0.5 - px;
980
+ const vy = (b1 + b4) * 0.5 - py;
981
+ const vz = (b2 + b5) * 0.5 - pz;
982
+ const along = vx * axisX + vy * axisY + vz * axisZ;
983
+ const lengthSq = vx * vx + vy * vy + vz * vz;
984
+ const across = Math.sqrt(Math.max(lengthSq - along * along, 0));
985
+ const reach = Math.sqrt(lengthSq);
986
+ const distance = along < -sinA * reach ? reach : cosA * across - along * sinA;
987
+ return distance - Math.sqrt(ex * ex + ey * ey + ez * ez);
988
+ }
989
+
776
990
  /** An NDC coordinate to a tile index, clamped. */
777
991
  function tileOf(ndc: number, tiles: number): number {
778
992
  const tile = Math.floor((ndc + 1) * 0.5 * tiles);
@@ -12,7 +12,7 @@
12
12
  * only ever reaches forward — to the next plane, and from the last to the repeat of the first — so
13
13
  * past its last row it meets the texture's edge and the clamp, never a cookie. A cookie's own
14
14
  * half-texel inset keeps it inside its band on both axes. The shader finds the bands from
15
- * `textureSize` and `uIesAtlasRows`, so the fold cost no uniform.
15
+ * `textureSize` and `uFixtureShape.x`, so the fold cost no uniform.
16
16
  *
17
17
  * **What it gives up**: every texel is four half floats where a profile needed one, a few kilobytes
18
18
  * a scene; and setting either table rebuilds the whole texture, which a consumer does once per set.
@@ -71,6 +71,11 @@ export function cullInstances(
71
71
  out.tints[kept * 3] = tints[i * 3] as number;
72
72
  out.tints[kept * 3 + 1] = tints[i * 3 + 1] as number;
73
73
  out.tints[kept * 3 + 2] = tints[i * 3 + 2] as number;
74
+ if (out.alphas !== undefined) out.alphas[kept] = data.alphas?.[i] ?? 1;
75
+ const regions = data.lightmapRegions;
76
+ if (regions !== undefined && out.lightmapRegions !== undefined) {
77
+ for (let k = 0; k < 4; k++) out.lightmapRegions[kept * 4 + k] = regions[i * 4 + k] as number;
78
+ }
74
79
  kept++;
75
80
  }
76
81
  out.count = kept;
@@ -10,10 +10,11 @@
10
10
  * plant. This one carries a full transform, because a vehicle pitches and rolls on its suspension
11
11
  * and a yaw cannot say so.
12
12
  *
13
- * **What it gives up** is a matrix per instance where a scatter spends five floats: 76 bytes
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 else — an opacity, a morph weight — which wants another attribute, and the sixteen
16
- * WebGL2 guarantees are already spent.
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.
17
18
  */
18
19
  export interface MeshInstances {
19
20
  /**
@@ -30,6 +31,26 @@ export interface MeshInstances {
30
31
  * — the alternative is a second batch per colour, which is the thing this exists to avoid.
31
32
  */
32
33
  readonly tints: Float32Array;
34
+ /**
35
+ * One float each, the instance's opacity from 0 to 1, which a blended draw
36
+ * (`drawTranslucentInstanced`) multiplies into the batch's own. **Absent, or an instance past its
37
+ * end, is 1** — wholly opaque, which is what every instance was before 4.8.6.
38
+ *
39
+ * What lets one draw carry particles fading at different rates: without it a consumer drew a
40
+ * batch per opacity level, each a draw and a material of its own. It is the instance's twin of
41
+ * the per-vertex alpha lane (`MeshData.channel`), multiplied in where that lane is.
42
+ */
43
+ readonly alphas?: Float32Array;
44
+ /**
45
+ * Four floats each, the instance's region of its lightmap page: `[scaleU, scaleV, biasU, biasV]`,
46
+ * read by a draw whose material is a `lightmapModel` — instances of one mesh in one material sit
47
+ * in different regions of one page, each applied before the material's own region. **They ride
48
+ * the tint and the opacity**, which a lightmapped instance gives up (see `lightmap.ts`): where
49
+ * these are given, those are not read. **A lightmapped batch needs them**, since a bake is
50
+ * different light at each instance; one drawn without them reads its tints as its regions, and
51
+ * either mismatch is said once on the console.
52
+ */
53
+ readonly lightmapRegions?: Float32Array;
33
54
  /** How many instances the arrays hold. Fixed at creation; the buffers are sized from it. */
34
55
  readonly capacity: number;
35
56
  /** How many are live. The rest of the buffer is neither uploaded nor drawn. */
@@ -47,6 +68,7 @@ export function createMeshInstances(capacity: number): MeshInstances {
47
68
  return {
48
69
  models: new Float32Array(capacity * 16),
49
70
  tints: new Float32Array(capacity * 3),
71
+ alphas: new Float32Array(capacity).fill(1),
50
72
  capacity,
51
73
  count: 0,
52
74
  };
@@ -54,13 +76,14 @@ export function createMeshInstances(capacity: number): MeshInstances {
54
76
 
55
77
  /**
56
78
  * Floats one instance occupies in the interleaved vertex buffer: sixteen of matrix, three of
57
- * tint, and one of padding.
79
+ * tint, and the opacity.
58
80
  *
59
- * **Padded to twenty so the stride is 80 bytes rather than 76.** A vertex buffer's stride must be
60
- * a multiple of four on both backends, which 76 already is — the padding is for the *attribute*
61
- * offsets: the tint sits at byte 64 and a three-float attribute ending at 76 leaves the next
62
- * instance's first column starting there, which is legal but puts every second instance on an
63
- * offset no driver aligns well. One wasted float an instance is 4 bytes against 76.
81
+ * **Twenty so the stride is 80 bytes rather than 76.** A vertex buffer's stride must be a multiple
82
+ * of four on both backends, which 76 already is — the twentieth float was padding for the
83
+ * *attribute* offsets: the tint sits at byte 64 and a three-float attribute ending at 76 leaves the
84
+ * next instance's first column starting there, which is legal but puts every second instance on an
85
+ * offset no driver aligns well. Since 4.8.6 it carries the instance's opacity, read with the tint
86
+ * as one four-float attribute, so the opacity cost no attribute and no byte.
64
87
  */
65
88
  export const INSTANCE_FLOATS = 20;
66
89
 
@@ -68,7 +91,7 @@ export const INSTANCE_FLOATS = 20;
68
91
  export const INSTANCE_STRIDE = INSTANCE_FLOATS * 4;
69
92
 
70
93
  /**
71
- * Interleave `count` instances into `out`, matrix then tint, at `INSTANCE_FLOATS` apart.
94
+ * Interleave `count` instances into `out`, matrix, tint and opacity, at `INSTANCE_FLOATS` apart.
72
95
  *
73
96
  * **Interleaved rather than two buffers** so a batch's upload is one `writeBuffer` and an
74
97
  * instance's colour sits beside the matrix that places it. Shared by both backends, because
@@ -78,16 +101,22 @@ export const INSTANCE_STRIDE = INSTANCE_FLOATS * 4;
78
101
  * Writes nothing beyond `count`, and allocates nothing: `out` is the caller's staging array.
79
102
  */
80
103
  export function packInstances(instances: MeshInstances, out: Float32Array): void {
81
- const { models, tints } = instances;
104
+ const { models, tints, alphas, lightmapRegions } = instances;
82
105
  const count = Math.min(instances.count, instances.capacity);
83
106
  for (let i = 0; i < count; i += 1) {
84
107
  const at = i * INSTANCE_FLOATS;
85
108
  const m = i * 16;
86
109
  for (let c = 0; c < 16; c += 1) out[at + c] = models[m + c] as number;
110
+ if (lightmapRegions !== undefined) {
111
+ /* The region in the tint's and the opacity's place. See `lightmapRegions`. */
112
+ for (let c = 0; c < 4; c += 1) out[at + 16 + c] = lightmapRegions[i * 4 + c] as number;
113
+ continue;
114
+ }
87
115
  const t = i * 3;
88
116
  out[at + 16] = tints[t] as number;
89
117
  out[at + 17] = tints[t + 1] as number;
90
118
  out[at + 18] = tints[t + 2] as number;
119
+ out[at + 19] = alphas?.[i] ?? 1;
91
120
  }
92
121
  }
93
122
 
@@ -6,6 +6,7 @@ import {
6
6
  POINT_LIGHT_COS_OUTER,
7
7
  lampSourceRadius,
8
8
  lampWeight,
9
+ lightChannelsOf,
9
10
  } from './clusteredLights.ts';
10
11
 
11
12
  /**
@@ -256,12 +257,22 @@ export const CLOTH_REST_TEXTURE_UNIT = CLOTH_PARTICLES_TEXTURE_UNIT + 1;
256
257
  export const MODEL_MAP_TEXTURE_UNIT = CLOTH_REST_TEXTURE_UNIT + 1;
257
258
 
258
259
  /**
259
- * The two units skin's screen-space blur reads on WebGL2 — its target and the frame's depth — past
260
+ * The units skin's screen-space blur reads on WebGL2 — its target and the frame's depth — past
260
261
  * every unit a lit program binds, so spreading skin in the middle of a frame unbinds nothing the
261
262
  * draws after it sample. WebGL2 guarantees thirty-two.
262
263
  */
263
264
  export const SKIN_BLUR_TEXTURE_UNIT = MODEL_MAP_TEXTURE_UNIT + 1;
264
265
  export const SKIN_BLUR_DEPTH_UNIT = SKIN_BLUR_TEXTURE_UNIT + 1;
266
+ /** And the skin's colour, which the blur's second axis multiplies the spread light by. */
267
+ export const SKIN_BLUR_ALBEDO_UNIT = SKIN_BLUR_DEPTH_UNIT + 1;
268
+
269
+ /**
270
+ * A sprite particle's image and the frame's depth copy its soft edge reads, on WebGL2: past every
271
+ * unit a lit program binds, for the skin blur's reason — a particle is drawn in the middle of a
272
+ * frame, and the draws after it must find their textures where they left them.
273
+ */
274
+ export const PARTICLE_SPRITE_UNIT = SKIN_BLUR_ALBEDO_UNIT + 1;
275
+ export const PARTICLE_DEPTH_UNIT = PARTICLE_SPRITE_UNIT + 1;
265
276
 
266
277
  /**
267
278
  * A cookie's tile, in texels a side.
@@ -380,6 +391,16 @@ export interface PointLightSet {
380
391
  * drawn for one spot works in another with a different angle.
381
392
  */
382
393
  readonly lightCookies?: Float32Array;
394
+ /**
395
+ * One per light: the lighting channels it lights, as a bitmask in a float — 1, 2, 3 … 255.
396
+ * **Absent, or a short array, is channel 1 for every light**, which is every surface's default
397
+ * too, so a scene that never names a channel lights everything with everything as it did.
398
+ *
399
+ * A light shades a surface only where its mask and the surface's `SurfaceMaterial.lightChannels`
400
+ * share a bit. What it is for: a rig of lights that belongs to some draws and not others — a
401
+ * character's own key and rim lights, which the floor around the character must not take.
402
+ */
403
+ readonly lightChannels?: Float32Array;
383
404
  }
384
405
 
385
406
  /** Weights are optional per consumer; a short array means every light is fully present. */
@@ -485,7 +506,8 @@ export function bindPointLights(
485
506
  gl.uniform3fv(uniforms['uLightDir[0]'] ?? null, r.directions);
486
507
  gl.uniform2fv(uniforms['uLightCone[0]'] ?? null, r.coneCos);
487
508
  gl.uniform1fv(uniforms['uLightIesProfile[0]'] ?? null, r.iesProfiles);
488
- gl.uniform3fv(uniforms['uLightIesAxis[0]'] ?? null, r.iesAxes);
509
+ /* The azimuth and, in w, the channels. See `axesAndChannels`. */
510
+ gl.uniform4fv(uniforms['uLightIesAxis[0]'] ?? null, r.axesAndChannels);
489
511
  gl.uniform1fv(uniforms['uLightCookie[0]'] ?? null, r.cookies);
490
512
  }
491
513
 
@@ -561,6 +583,12 @@ export interface ResolvedPointLights {
561
583
  iesProfiles: Float32Array;
562
584
  iesAxes: Float32Array;
563
585
  cookies: Float32Array;
586
+ /**
587
+ * Owned, four a light: the photometric azimuth in xyz and the light's channels in w, which is how
588
+ * `uLightIesAxis` carries them — the channels in a lane the axis left, rather than in a uniform
589
+ * array of their own, which would cost a row a light of a budget that has none to give.
590
+ */
591
+ readonly axesAndChannels: Float32Array;
564
592
  /**
565
593
  * Owned, `MAX_POINT_LIGHTS` long: the emitter sizes and weights as the froxel record carries
566
594
  * them, at half precision. `sourceRadii` and `weights` point here once a light set resolves, so
@@ -585,6 +613,7 @@ const glLights: ResolvedPointLights = {
585
613
  iesProfiles: NO_PROFILES,
586
614
  iesAxes: NO_IES_AXES,
587
615
  cookies: NO_COOKIES,
616
+ axesAndChannels: new Float32Array(MAX_POINT_LIGHTS * 4),
588
617
  heldSourceRadii: new Float32Array(MAX_POINT_LIGHTS),
589
618
  heldWeights: new Float32Array(MAX_POINT_LIGHTS),
590
619
  };
@@ -619,6 +648,7 @@ export function resolvePointLights(
619
648
  out.iesProfiles = NO_PROFILES;
620
649
  out.iesAxes = NO_IES_AXES;
621
650
  out.cookies = NO_COOKIES;
651
+ packAxesAndChannels(NO_IES_AXES, undefined, out.axesAndChannels);
622
652
  return out;
623
653
  }
624
654
  /*
@@ -711,5 +741,21 @@ export function resolvePointLights(
711
741
  out.iesProfiles = iesProfiles;
712
742
  out.iesAxes = iesAxes;
713
743
  out.cookies = cookies;
744
+ packAxesAndChannels(iesAxes, lights.lightChannels, out.axesAndChannels);
714
745
  return out;
715
746
  }
747
+
748
+ /** The azimuth and the channels, four a light, as `uLightIesAxis` holds them. */
749
+ function packAxesAndChannels(
750
+ axes: Float32Array,
751
+ channels: Float32Array | undefined,
752
+ out: Float32Array,
753
+ ): void {
754
+ const named = (channels?.length ?? 0) >= MAX_POINT_LIGHTS ? channels : undefined;
755
+ for (let light = 0; light < MAX_POINT_LIGHTS; light++) {
756
+ out[light * 4] = axes[light * 3] ?? 0;
757
+ out[light * 4 + 1] = axes[light * 3 + 1] ?? 0;
758
+ out[light * 4 + 2] = axes[light * 3 + 2] ?? 0;
759
+ out[light * 4 + 3] = lightChannelsOf(named?.[light]);
760
+ }
761
+ }