@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
@@ -102,6 +102,41 @@ fn centreDistanceSq(light: u32, centre: vec3<f32>) -> f32 {
102
102
  return dot(d, d);
103
103
  }
104
104
 
105
+ /**
106
+ * Whether a spot's cone misses this froxel: \`coneBoxGap\` in \`clusteredLights.ts\`, the same
107
+ * arithmetic in the same order — the froxel's bounding sphere against the infinite cone, its apex
108
+ * included, which can keep a light the shader would not draw and never drop one it would. A light
109
+ * with no cone carries a cosine of -2 and never reaches the test.
110
+ */
111
+ fn outsideCone(light: u32, apex: vec3<f32>, lo: vec3<f32>, hi: vec3<f32>) -> bool {
112
+ let cosOuter = bitcast<f32>(lights[light * LIGHT_TEXELS + 3u].y);
113
+ if (cosOuter <= -1.0) {
114
+ return false;
115
+ }
116
+ let world = bitcast<vec3<f32>>(lights[light * LIGHT_TEXELS + 2u].yzw);
117
+ let a = (params.view * vec4<f32>(world, 0.0)).xyz;
118
+ let axis = vec3<f32>(a.x, a.y, -a.z);
119
+ let len = length(axis);
120
+ /* The shader measures the cone against the direction as given, so a long one is a wider cone. */
121
+ if (len <= 1e-6 || cosOuter / len <= -1.0) {
122
+ return false;
123
+ }
124
+ let cosA = cosOuter / len;
125
+ let sinA = sqrt(max(0.0, 1.0 - cosA * cosA));
126
+ let unit = axis / len;
127
+ let extent = (hi - lo) * 0.5;
128
+ let toCentre = (lo + hi) * 0.5 - apex;
129
+ let along = dot(toCentre, unit);
130
+ let lengthSq = dot(toCentre, toCentre);
131
+ let across = sqrt(max(lengthSq - along * along, 0.0));
132
+ let reach = sqrt(lengthSq);
133
+ var distance = cosA * across - along * sinA;
134
+ if (along < -sinA * reach) {
135
+ distance = reach;
136
+ }
137
+ return distance - length(extent) > 0.0;
138
+ }
139
+
105
140
  @compute @workgroup_size(64)
106
141
  fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
107
142
  let id = gid.x;
@@ -161,7 +196,9 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
161
196
 
162
197
  /* Increasing light index, which is what makes the list sorted without a sort. */
163
198
  for (var light: u32 = 0u; light < params.lightCount; light = light + 1u) {
164
- let radius = bitcast<f32>(lights[light * LIGHT_TEXELS].w);
199
+ /* A rectangle carries its reach negated, which is how the lit pass tells it from a lamp. */
200
+ let signedRadius = bitcast<f32>(lights[light * LIGHT_TEXELS].w);
201
+ let radius = abs(signedRadius);
165
202
  let v = viewOf(light);
166
203
  if (v.z + radius <= 0.0) {
167
204
  continue;
@@ -169,6 +206,9 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
169
206
  if (distanceSqToBounds(v, lo, hi) > radius * radius) {
170
207
  continue;
171
208
  }
209
+ if (signedRadius >= 0.0 && outsideCone(light, v, lo, hi)) {
210
+ continue;
211
+ }
172
212
 
173
213
  if (count < MAX_LIGHTS_PER_CLUSTER) {
174
214
  indices[count] = light;
@@ -1,27 +1,5 @@
1
1
  import { SkinPaletteTexture } from './skinPaletteTexture.ts';
2
2
 
3
- /**
4
- * How many palettes one frame may set before the ring is full.
5
- *
6
- * The arithmetic rather than a round number: a character's palette is uploaded once per *pass*
7
- * that draws it, and a full profile draws the same character five times — the main pass, a planar
8
- * reflection, and the static, peel and dynamic shadow layers. So this is twenty-five characters at
9
- * once, which is a crowd rather than a cast.
10
- *
11
- * **What it costs is nothing until it is used.** A slot allocates its texture on the first frame
12
- * that reaches it and keeps it, so a scene with two characters holds two: a 24-joint rig is 96
13
- * texels of `rgba32float`, which is 1.5 KB, and a 96-joint humanoid is 6 KB.
14
- *
15
- * **What would make it wrong** is a genuine crowd — a stadium, a swarm of rigged birds — and the
16
- * fix is then the one `skinPalette.ts` names: one texture holding every palette with a per-draw
17
- * row offset, which spends a uniform and a shader change to stop spending textures.
18
- *
19
- * **A reconstructing frame spends one more per skinned mover**: last frame's pose is uploaded into
20
- * a slot of its own for the motion pass. So the same budget is about twelve such characters with a
21
- * reflection and three shadow layers each.
22
- */
23
- export const MAX_SKIN_PALETTES = 128;
24
-
25
3
  /**
26
4
  * Every joint palette a frame sets, each in its own texture, reused frame after frame.
27
5
  *
@@ -42,20 +20,53 @@ export const MAX_SKIN_PALETTES = 128;
42
20
  * draws separates them there by construction. The decision — a palette belongs to a draw — is one;
43
21
  * only what it takes to honour it differs, which is what `skinPalette.ts` says a binder is for.
44
22
  *
45
- * **A slot is written at most once per frame**, since every `setSkinPalette` takes a fresh one.
46
- * That is what makes the reallocation inside `SkinPaletteTexture` safe: a joint count can only
47
- * change a slot's texture between frames, never while a recorded draw is still pointing at it.
23
+ * **A palette set again with the same numbers is the slot it already has.** A rig is drawn in
24
+ * every pass of a frame — the main pass, a reflection, three shadow layers — and each one used to
25
+ * upload the same matrices into a slot of its own, so a crowd ran out five times sooner than it
26
+ * had characters. The ring now remembers the slot each array last went into and compares what it
27
+ * holds, so a rig posed once a frame spends one slot however many passes draw it. Identity alone
28
+ * would be wrong: a caller may pose every rig into one scratch array, and that array then holds a
29
+ * different rig at every set — the comparison is what tells the two apart.
30
+ *
31
+ * **It was capped at 128 until 4.8.6, and the cap declined draws.** A stage of three dozen idling
32
+ * extras beside two fighters filled it in one frame and every skinned draw past it was refused, so
33
+ * the consumer stood its crowd in the bind pose. The ring now holds as many palettes as a frame
34
+ * holds draws (`RenderQuality.drawsPerFrame`), since no frame can bind more palettes than it draws;
35
+ * a slot still allocates its texture only the first time a frame reaches it, so a scene with two
36
+ * characters holds two. **What it costs** is a texture and a cached bind group a slot, 1.5 KB for a
37
+ * 24-joint rig and 6 KB for a 96-joint humanoid. **What would make it wrong** is a crowd of
38
+ * thousands, where a texture a palette is the expensive half; the fix is then one texture holding
39
+ * every palette with a row offset per draw, which spends a per-draw uniform and a vertex-stage
40
+ * change to stop spending textures.
41
+ *
42
+ * **A slot is written at most once per frame**, since a palette that differs from what a slot
43
+ * holds always takes a fresh one. That is what makes the reallocation inside `SkinPaletteTexture`
44
+ * safe: a joint count can only change a slot's texture between frames, never while a recorded draw
45
+ * is still pointing at it.
48
46
  */
49
47
  export class SkinPaletteRing {
50
48
  private readonly slots: SkinPaletteTexture[] = [];
49
+ /** What each slot was last written with, for the comparison that lets a pass reuse it. */
50
+ private readonly held: Float32Array[] = [];
51
+ /** The slot each palette array last went into, by identity. Weak, so a caller's array can go. */
52
+ private readonly lastSlot = new WeakMap<Float32Array, number>();
51
53
  private used = 0;
54
+ private uploads = 0;
52
55
  private warnedFull = false;
53
56
 
57
+ /** `capacity` is how many palettes one frame may hold: one per draw at most. */
58
+ constructor(private readonly capacity: number) {}
59
+
54
60
  /** How many slots this frame has taken. */
55
61
  get count(): number {
56
62
  return this.used;
57
63
  }
58
64
 
65
+ /** How many palettes have been written to the device since the ring was made. */
66
+ get uploaded(): number {
67
+ return this.uploads;
68
+ }
69
+
59
70
  /**
60
71
  * Start a frame: every slot is free again.
61
72
  *
@@ -78,20 +89,23 @@ export class SkinPaletteRing {
78
89
  }
79
90
 
80
91
  /**
81
- * Upload a palette into a slot of its own and return that slot, or null when the ring is full.
92
+ * The slot this frame holds for these numbers, uploading them into a fresh one where none does,
93
+ * or null when the ring is full.
82
94
  *
83
- * **Null rather than reusing a slot**, because reusing one is the defect this class is named
84
- * after: the caller's character would silently take another character's pose. A caller that runs
85
- * out declines the draw and says so once.
95
+ * **Null rather than reusing a slot that holds something else**, because that is the defect this
96
+ * class is named after: the caller's character would silently take another character's pose. A
97
+ * caller that runs out declines the draw and says so once.
86
98
  */
87
99
  take(device: GPUDevice, palette: Float32Array): number | null {
88
- if (this.used >= MAX_SKIN_PALETTES) {
100
+ const known = this.lastSlot.get(palette);
101
+ if (known !== undefined && known < this.used && this.holds(known, palette)) return known;
102
+ if (this.used >= this.capacity) {
89
103
  if (!this.warnedFull) {
90
104
  this.warnedFull = true;
91
105
  console.warn(
92
- `WebGPU: more than ${MAX_SKIN_PALETTES} skin palettes in one frame; the rest of the ` +
93
- 'skinned draws are declined this frame rather than drawn in another rig’s pose. ' +
94
- 'See MAX_SKIN_PALETTES.',
106
+ `WebGPU: more than ${this.capacity} distinct skin palettes in one frame, which is ` +
107
+ 'RenderQuality.drawsPerFrame; the rest of the skinned draws are declined this frame ' +
108
+ 'rather than drawn in another rig’s pose.',
95
109
  );
96
110
  }
97
111
  return null;
@@ -103,6 +117,15 @@ export class SkinPaletteRing {
103
117
  this.slots[at] = slot;
104
118
  }
105
119
  slot.update(device, palette);
120
+ this.uploads += 1;
121
+ let copy = this.held[at];
122
+ if (copy === undefined || copy.length !== palette.length) {
123
+ /* Once per slot and joint count, the way the slot's own texture is: never per frame. */
124
+ copy = new Float32Array(palette.length);
125
+ this.held[at] = copy;
126
+ }
127
+ copy.set(palette);
128
+ this.lastSlot.set(palette, at);
106
129
  return at;
107
130
  }
108
131
 
@@ -114,6 +137,15 @@ export class SkinPaletteRing {
114
137
  dispose(): void {
115
138
  for (const slot of this.slots) slot.dispose();
116
139
  this.slots.length = 0;
140
+ this.held.length = 0;
117
141
  this.used = 0;
118
142
  }
143
+
144
+ /** Whether a slot was last written with exactly these numbers. */
145
+ private holds(slot: number, palette: Float32Array): boolean {
146
+ const copy = this.held[slot];
147
+ if (copy === undefined || copy.length !== palette.length) return false;
148
+ for (let i = 0; i < copy.length; i++) if (copy[i] !== palette[i]) return false;
149
+ return true;
150
+ }
119
151
  }
@@ -3,19 +3,21 @@
3
3
  * that spread them. `skinBlur.ts` is what the blur computes and why; this is where it runs.
4
4
  *
5
5
  * **What the renderer hands it.** Each skin draw, recorded into the frame with its diffuse taken
6
- * out, is recorded a second time here with the diffuse alone. When the frame first draws something
7
- * blended — or ends, if nothing is — the renderer ends its pass and calls `drawDiffuse`, which
8
- * replays them into a half-float target against the frame's own depth; resolves the depth; and
9
- * calls `spread`, which blurs across into a second target and then down, adding into the frame.
6
+ * out, is recorded twice more here: with the diffuse's light alone, and with its colour alone.
7
+ * When the frame first draws something blended — or ends, if nothing is — the renderer ends its
8
+ * pass and calls `drawDiffuse`, which replays them into two half-float targets against the frame's
9
+ * own depth; resolves the depth; and calls `spread`, which blurs the light across into a third
10
+ * target and then down, multiplied by the colour, adding into the frame.
10
11
  *
11
12
  * **The draws are commands, not calls**, for the reason `LatePass` keeps its own pool: the frame
12
13
  * graph's is spent at every flush, and these are replayed after the flush that ends the frame's
13
14
  * pass. Each holds the per-draw ring slot its frame half used, and those stay valid until the
14
15
  * encoder is replaced, which is after this has run.
15
16
  *
16
- * **What it costs**: every skin draw twice; two half-float targets the size of the frame, and a
17
- * third multisampled one under multisampling; a depth resolve; and two full-screen passes that
18
- * leave at once wherever no skin was drawn.
17
+ * **What it costs**: every skin draw three times — the frame's half, the light alone, and the
18
+ * colour alone, which the spread light is multiplied by; three half-float targets the size of the
19
+ * frame, and two multisampled ones under multisampling; a depth resolve; and two full-screen passes
20
+ * that leave at once wherever no skin was drawn.
19
21
  */
20
22
  import { SKIN_PROFILES } from '../../skinBlur.ts';
21
23
  import { SKINBLUR_BINDINGS, SKIN_BLUR_FRAG_WGSL } from '../../shaders/generated/skinBlur.wgsl.ts';
@@ -42,14 +44,20 @@ export class SkinScatterPass {
42
44
  private readonly linear: GPUSampler;
43
45
  private readonly nearest: GPUSampler;
44
46
  private readonly commands: CommandPool = createCommandPool(16);
47
+ /** The same draws again, through the pipelines that write the skin's colour alone. */
48
+ private readonly albedoCommands: CommandPool = createCommandPool(16);
45
49
  /** Each profile's scatter distance per channel, in metres: four floats a profile. */
46
50
  private readonly profiles = new Float32Array(SKIN_PROFILES * 4);
47
51
  private diffuse: GPUTexture | null = null;
48
52
  private diffuseMsaa: GPUTexture | null = null;
49
53
  private across: GPUTexture | null = null;
54
+ private albedo: GPUTexture | null = null;
55
+ private albedoMsaa: GPUTexture | null = null;
50
56
  private diffuseView: GPUTextureView | null = null;
51
57
  private diffuseMsaaView: GPUTextureView | null = null;
52
58
  private acrossView: GPUTextureView | null = null;
59
+ private albedoView: GPUTextureView | null = null;
60
+ private albedoMsaaView: GPUTextureView | null = null;
53
61
  private groupAcross: GPUBindGroup | null = null;
54
62
  private groupDown: GPUBindGroup | null = null;
55
63
  private groupsDepth: GPUTextureView | null = null;
@@ -59,6 +67,7 @@ export class SkinScatterPass {
59
67
  this.layout = createPostStageLayout(device, BINDINGS.uniforms, BINDINGS.uniformSize, [
60
68
  { binding: BINDINGS.textures.uSkin },
61
69
  { binding: BINDINGS.textures.uDepth, filterable: false },
70
+ { binding: BINDINGS.textures.uAlbedo },
62
71
  ]);
63
72
  this.uniforms = device.createBuffer({
64
73
  label: 'skin.blurUniforms',
@@ -89,6 +98,11 @@ export class SkinScatterPass {
89
98
  return this.commands.commands[takeCommand(this.commands)] as DrawCommand;
90
99
  }
91
100
 
101
+ /** A command for the same draw's colour half, filled by the caller. */
102
+ takeAlbedo(): DrawCommand {
103
+ return this.albedoCommands.commands[takeCommand(this.albedoCommands)] as DrawCommand;
104
+ }
105
+
92
106
  /** What one profile scatters, per channel, in metres: the last material to name it says. */
93
107
  setProfile(index: number, r: number, g: number, b: number): void {
94
108
  const at = Math.min(Math.max(Math.round(index), 0), SKIN_PROFILES - 1) * 4;
@@ -119,17 +133,21 @@ export class SkinScatterPass {
119
133
  });
120
134
  this.diffuse = make('skin.diffuse', 1);
121
135
  this.across = make('skin.across', 1);
136
+ this.albedo = make('skin.albedo', 1);
122
137
  this.diffuseMsaa = samples > 1 ? make('skin.diffuseMsaa', samples) : null;
138
+ this.albedoMsaa = samples > 1 ? make('skin.albedoMsaa', samples) : null;
123
139
  this.diffuseView = this.diffuse.createView();
124
140
  this.acrossView = this.across.createView();
141
+ this.albedoView = this.albedo.createView();
125
142
  this.diffuseMsaaView = this.diffuseMsaa?.createView() ?? null;
143
+ this.albedoMsaaView = this.albedoMsaa?.createView() ?? null;
126
144
  this.groupAcross = null;
127
145
  this.groupDown = null;
128
146
  }
129
147
 
130
148
  /**
131
- * Every kept draw's diffuse into the target, against the frame's own depth, which it reads and
132
- * does not write. Called with no pass open on `encoder`.
149
+ * Every kept draw's diffuse into the target, and its colour into the other, against the frame's
150
+ * own depth, which they read and do not write. Called with no pass open on `encoder`.
133
151
  */
134
152
  drawDiffuse(
135
153
  encoder: GPUCommandEncoder,
@@ -157,6 +175,26 @@ export class SkinScatterPass {
157
175
  issue(pass, this.commands.commands[i] as DrawCommand);
158
176
  }
159
177
  pass.end();
178
+
179
+ const colour = this.albedoView;
180
+ if (colour === null) return;
181
+ const albedoPass = encoder.beginRenderPass({
182
+ label: 'skin.albedo',
183
+ colorAttachments: [
184
+ {
185
+ view: this.albedoMsaaView ?? colour,
186
+ resolveTarget: this.albedoMsaaView === null ? undefined : colour,
187
+ clearValue: [0, 0, 0, 0],
188
+ loadOp: 'clear',
189
+ storeOp: this.albedoMsaaView === null ? 'store' : 'discard',
190
+ },
191
+ ],
192
+ depthStencilAttachment: { view: depth, depthLoadOp: 'load', depthStoreOp: 'store' },
193
+ });
194
+ for (let i = 0; i < this.albedoCommands.taken; i += 1) {
195
+ issue(albedoPass, this.albedoCommands.commands[i] as DrawCommand);
196
+ }
197
+ albedoPass.end();
160
198
  }
161
199
 
162
200
  /**
@@ -177,6 +215,7 @@ export class SkinScatterPass {
177
215
  ): void {
178
216
  const diffuse = this.diffuse;
179
217
  if (diffuse === null || this.diffuseView === null || this.acrossView === null) return;
218
+ if (this.albedoView === null) return;
180
219
  if (this.groupAcross === null || this.groupDown === null || this.groupsDepth !== depth) {
181
220
  this.groupAcross = this.group('skin.blurAcross', this.diffuseView, depth);
182
221
  this.groupDown = this.group('skin.blurDown', this.acrossView, depth);
@@ -244,6 +283,7 @@ export class SkinScatterPass {
244
283
  /** Spent at the frame's start: the kept draws were this frame's. */
245
284
  reset(): void {
246
285
  resetPool(this.commands);
286
+ resetPool(this.albedoCommands);
247
287
  }
248
288
 
249
289
  dispose(): void {
@@ -264,6 +304,8 @@ export class SkinScatterPass {
264
304
  { binding: BINDINGS.textures.uSkin.sampler, resource: this.linear },
265
305
  { binding: BINDINGS.textures.uDepth.texture, resource: depth },
266
306
  { binding: BINDINGS.textures.uDepth.sampler, resource: this.nearest },
307
+ { binding: BINDINGS.textures.uAlbedo.texture, resource: this.albedoView ?? skin },
308
+ { binding: BINDINGS.textures.uAlbedo.sampler, resource: this.nearest },
267
309
  ],
268
310
  });
269
311
  }
@@ -291,6 +333,8 @@ export class SkinScatterPass {
291
333
  too wherever a pixel is square. */
292
334
  f[base + fields.uFocal.offset / 4] = ((projection[5] as number) * height) / 2;
293
335
  f.set(this.profiles, base + fields.uProfiles.offset / 4);
336
+ /* The colour is applied once, on the axis that adds into the frame. */
337
+ f[base + fields.uApplyAlbedo.offset / 4] = axis;
294
338
  }
295
339
  this.device.queue.writeBuffer(this.uniforms, 0, f);
296
340
  }
@@ -299,11 +343,17 @@ export class SkinScatterPass {
299
343
  this.diffuse?.destroy();
300
344
  this.diffuseMsaa?.destroy();
301
345
  this.across?.destroy();
346
+ this.albedo?.destroy();
347
+ this.albedoMsaa?.destroy();
302
348
  this.diffuse = null;
303
349
  this.diffuseMsaa = null;
304
350
  this.across = null;
351
+ this.albedo = null;
352
+ this.albedoMsaa = null;
305
353
  this.diffuseView = null;
306
354
  this.diffuseMsaaView = null;
307
355
  this.acrossView = null;
356
+ this.albedoView = null;
357
+ this.albedoMsaaView = null;
308
358
  }
309
359
  }
@@ -1,6 +1,8 @@
1
1
  import type { SurfaceTextureOptions } from '../../surfaceTexture.ts';
2
2
  import { SURFACE_EFFECT_TEXELS, packSurfaceEffects } from '../../surfaceEffects.ts';
3
3
  import { toHalfFloats } from '../../halfFloat.ts';
4
+ import { isLightmapTexels } from '../../lightmap.ts';
5
+ import type { LightmapTexels } from '../../lightmap.ts';
4
6
  import { layerSize, refuseArrayUpdate, sourceSize } from '../../textureSource.ts';
5
7
  import { compressedLayers, planBlocks, refuseBlockUpdate } from '../../compressedSource.ts';
6
8
  import type {
@@ -193,17 +195,36 @@ export class GpuSurfaceTexture {
193
195
  constructor(
194
196
  private readonly device: GPUDevice,
195
197
  private readonly pipelines: PipelineCache,
196
- source: SurfaceSource | readonly SurfaceSource[],
198
+ source: SurfaceSource | readonly SurfaceSource[] | LightmapTexels,
197
199
  options: SurfaceTextureOptions = {},
198
200
  compressed: readonly CompressedTextureFormat[] = [],
199
201
  ) {
200
- const listed = (Array.isArray(source) ? source : [source]) as readonly SurfaceSource[];
202
+ /* A lightmap's page: half floats, uploaded as they are. See `lightmap.ts`. */
203
+ const baked = isLightmapTexels(source) ? source : null;
204
+ const listed = (
205
+ baked !== null ? [] : Array.isArray(source) ? source : [source]
206
+ ) as readonly SurfaceSource[];
201
207
  const blocks = compressedLayers(listed);
202
208
  const srgb = (options.colorSpace ?? 'linear') === 'srgb';
203
209
  /* `SRGB8_ALPHA8`'s equivalent. Decoded in the sampler, before filtering, which is the only
204
210
  place it is correct — `surfaceTexture.ts` makes the argument in full. */
205
- this.format = srgb ? 'rgba8unorm-srgb' : 'rgba8unorm';
206
- if (blocks !== null) {
211
+ this.format = baked !== null ? 'rgba16float' : srgb ? 'rgba8unorm-srgb' : 'rgba8unorm';
212
+ if (baked !== null) {
213
+ /* No chain: a page is read at its own resolution, and a level of it would blend regions. */
214
+ this.layers = baked.layers;
215
+ this.blockFormat = null;
216
+ this.mipmapped = false;
217
+ this.width = baked.width;
218
+ this.height = baked.height;
219
+ this.levels = 1;
220
+ 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
+ );
227
+ } else if (blocks !== null) {
207
228
  /* Refused before anything is allocated; the stored chain, or level 0 alone where no chain
208
229
  was asked for. See `compressedUpload.ts`. */
209
230
  const plan = planBlocks(blocks, srgb, compressed);
@@ -261,7 +282,7 @@ export class GpuSurfaceTexture {
261
282
  : 1,
262
283
  });
263
284
 
264
- if (blocks === null) this.upload(listed as readonly TexImageSource[]);
285
+ if (blocks === null && baked === null) this.upload(listed as readonly TexImageSource[]);
265
286
 
266
287
  /* Half floats, as `renderer.ts` uploads it: filterable, so the ordinary float layout takes it,
267
288
  and read by `textureLoad` alone, so the filter never runs. */
@@ -81,6 +81,75 @@ export function bloomProfileWarning(quality: {
81
81
  return null;
82
82
  }
83
83
 
84
+ /**
85
+ * How a frame's bloom answers the light it is given, beside the strength and threshold `setBloom`
86
+ * already takes. Absent, or a field absent, is the response every frame had before 4.8.6.
87
+ *
88
+ * **Why it exists: one band over the colour less the threshold washes a stage built for another
89
+ * engine.** Unreal's standard bloom keeps `saturate((L − threshold) / 2)` of a colour rather than
90
+ * the colour less the threshold, and spreads it over six bands, each in a tint of its own. A
91
+ * stage authored against that — a threshold of 0.1 and tints summing below one — came out white
92
+ * here, because subtracting so low a threshold keeps nearly every light in the frame at full
93
+ * strength. These are the two things that differ, as numbers.
94
+ */
95
+ export interface BloomResponse {
96
+ /**
97
+ * Scene units over which a colour comes in, from none of it at the threshold to all of it this
98
+ * far past. **0, the default, subtracts instead**: a colour keeps what it has past the threshold,
99
+ * the response since bloom shipped. Unreal's is 2 after exposure, so a frame exposed by `e` that
100
+ * wants it passes `2 / e`, as it passes the threshold over the exposure.
101
+ *
102
+ * Brightness is still the largest channel rather than luminance, for the reason
103
+ * `shaders/bloom.ts` gives; on a neutral colour the two are equal.
104
+ */
105
+ readonly ramp?: number;
106
+ /**
107
+ * Each level's colour, three numbers a level, finest first: the halo at a few pixels first and
108
+ * at a twentieth of the frame last. **White, the default, is the identity**; a level not named
109
+ * takes white. Not clamped above one, which is how a tint carries a strength past the 0..1 the
110
+ * dial gives — a stage asking for twice the light names tints twice as bright.
111
+ *
112
+ * At most `BLOOM_LEVELS` levels. A frame too small for that many uses the first it has room for.
113
+ */
114
+ readonly tints?: ArrayLike<number>;
115
+ }
116
+
117
+ /** Floats `resolveBloomResponse` writes: the ramp, then a tint a level. */
118
+ export const BLOOM_RESPONSE_FLOATS = 1 + BLOOM_LEVELS * 3;
119
+
120
+ /**
121
+ * A response as both backends bind it, into a caller-owned `out` of `BLOOM_RESPONSE_FLOATS`: the
122
+ * ramp, then `BLOOM_LEVELS` tints, white where none was named. Refuses a tint list that is not
123
+ * whole colours or names more levels than the pyramid has, naming the length — a caller error with
124
+ * one correct outcome. A ramp that is not a positive number is the subtraction; a tint channel
125
+ * that is not a number or is below zero is zero.
126
+ */
127
+ export function resolveBloomResponse(
128
+ response: BloomResponse | null | undefined,
129
+ out: Float32Array,
130
+ ): void {
131
+ const ramp = response?.ramp ?? 0;
132
+ out[0] = Number.isFinite(ramp) && ramp > 0 ? ramp : 0;
133
+ const tints = response?.tints;
134
+ if (tints !== undefined && (tints.length % 3 !== 0 || tints.length > BLOOM_LEVELS * 3)) {
135
+ throw new Error(
136
+ `setBloom: ${tints.length} tint values is not up to ${BLOOM_LEVELS} whole colours, three ` +
137
+ 'numbers a level, finest first',
138
+ );
139
+ }
140
+ for (let i = 0; i < BLOOM_LEVELS * 3; i++) {
141
+ const value = tints !== undefined && i < tints.length ? (tints[i] as number) : 1;
142
+ out[1 + i] = Number.isFinite(value) && value > 0 ? value : 0;
143
+ }
144
+ }
145
+
146
+ /** The response every frame had before 4.8.6: the subtraction, every level white. */
147
+ export function defaultBloomResponse(): Float32Array {
148
+ const out = new Float32Array(BLOOM_RESPONSE_FLOATS);
149
+ resolveBloomResponse(null, out);
150
+ return out;
151
+ }
152
+
84
153
  /** One level of the pyramid: level 0 is half the frame and each one after it half again. */
85
154
  export interface BloomLevelSize {
86
155
  readonly width: number;
@@ -159,7 +159,14 @@ export class BloomPass {
159
159
  * `threshold` is in scene units, so it only separates a light from white paint on a target
160
160
  * that kept the range. The caller owns that decision and the warning that goes with it.
161
161
  */
162
- run(scene: WebGLTexture, width: number, height: number, threshold: number): WebGLTexture | null {
162
+ run(
163
+ scene: WebGLTexture,
164
+ width: number,
165
+ height: number,
166
+ threshold: number,
167
+ /** The ramp, then each level's tint: `resolveBloomResponse`'s floats. */
168
+ response: Float32Array,
169
+ ): WebGLTexture | null {
163
170
  if (!this.ensureSize(width, height)) return null;
164
171
  const { gl } = this;
165
172
  const levels = this.levels;
@@ -182,6 +189,7 @@ export class BloomPass {
182
189
  gl.bindTexture(gl.TEXTURE_2D, scene);
183
190
  gl.uniform1i(this.prefilterUniforms['uSource'] ?? null, 0);
184
191
  gl.uniform1f(this.prefilterUniforms['uThreshold'] ?? null, threshold);
192
+ gl.uniform1f(this.prefilterUniforms['uRamp'] ?? null, response[0] ?? 0);
185
193
  this.uploadTexel(this.prefilterUniforms, width, height);
186
194
  gl.drawArrays(gl.TRIANGLES, 0, 3);
187
195
 
@@ -204,8 +212,13 @@ export class BloomPass {
204
212
  * every octave at once. Additive blending rather than a second target to ping-pong through:
205
213
  * a level is only ever read while a *different* level is bound, so there is no feedback.
206
214
  */
215
+ /*
216
+ * Each level's tint rides the blend: the level being added onto is multiplied by its own as the
217
+ * octave below is added, and the deepest by `uScale` on the first draw, the only one reading it
218
+ * before anything has been added to it. White everywhere is `ONE, ONE` exactly.
219
+ */
207
220
  gl.enable(gl.BLEND);
208
- gl.blendFunc(gl.ONE, gl.ONE);
221
+ gl.blendFunc(gl.ONE, gl.CONSTANT_COLOR);
209
222
  gl.useProgram(this.upsampleProgram);
210
223
  gl.uniform1i(this.upsampleUniforms['uSource'] ?? null, 0);
211
224
  gl.uniform1f(this.upsampleUniforms['uRadius'] ?? null, BLOOM_FILTER_RADIUS_UV);
@@ -213,12 +226,23 @@ export class BloomPass {
213
226
  const target = levels[index - 1];
214
227
  const source = levels[index];
215
228
  if (target === undefined || source === undefined) break;
229
+ const deepest = index === levels.length - 1;
230
+ const into = 1 + (index - 1) * 3;
231
+ const from = 1 + index * 3;
232
+ gl.blendColor(response[into] ?? 1, response[into + 1] ?? 1, response[into + 2] ?? 1, 1);
233
+ gl.uniform3f(
234
+ this.upsampleUniforms['uScale'] ?? null,
235
+ deepest ? (response[from] ?? 1) : 1,
236
+ deepest ? (response[from + 1] ?? 1) : 1,
237
+ deepest ? (response[from + 2] ?? 1) : 1,
238
+ );
216
239
  gl.bindFramebuffer(gl.FRAMEBUFFER, target.framebuffer);
217
240
  gl.viewport(0, 0, target.width, target.height);
218
241
  gl.bindTexture(gl.TEXTURE_2D, source.texture);
219
242
  gl.drawArrays(gl.TRIANGLES, 0, 3);
220
243
  }
221
244
 
245
+ gl.blendColor(0, 0, 0, 0);
222
246
  gl.disable(gl.BLEND);
223
247
  gl.bindVertexArray(null);
224
248
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);