@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
@@ -20,4 +20,4 @@
20
20
  * sort. `scripts/cluster-check.mjs` is what holds that, and the 2026-08-17 rule about two
21
21
  * implementations of one decision is why it exists at all.
22
22
  */
23
- export declare const CLUSTER_BIN_WGSL = "\nconst CLUSTER_X: u32 = 16u;\nconst CLUSTER_Y: u32 = 9u;\nconst CLUSTER_Z: u32 = 24u;\nconst CLUSTER_COUNT: u32 = 3456u;\nconst CLUSTER_TEXELS: u32 = 20u;\nconst MAX_LIGHTS_PER_CLUSTER: u32 = 76u;\nconst LIGHT_TEXELS: u32 = 5u;\nconst LIGHT_REGION_TEXELS: u32 = 1600u;\nconst TABLE_WIDTH: u32 = 320u;\n\nstruct Params {\n view: mat4x4<f32>,\n /** near, far, tan(fovY/2), aspect. */\n frustum: vec4<f32>,\n lightCount: u32,\n pad0: u32,\n pad1: u32,\n pad2: u32,\n}\n\n@group(0) @binding(0) var<uniform> params: Params;\n/**\n * The records as `writeLightRecord` lays them out, as bits. **`u32`, not `f32`**, because one\n * slot holds two halves, and a zero weight read as a float would be a subnormal a GPU is free to\n * flush on the way through; read as bits, the copy into the table cannot change them.\n */\n@group(0) @binding(1) var<storage, read> lights: array<vec4<u32>>;\n/**\n * Write-only, which is all core WGSL offers and all the fixed-slot layout needs.\n *\n * A read-modify-write list would need a storage buffer and an atomic counter, and it is the reason\n * a cluster owns a fixed run rather than appending into a shared one.\n */\n@group(0) @binding(2) var table: texture_storage_2d<rgba32uint, write>;\n\n/** A flat texel index to its place in the table. TABLE_WIDTH divides both regions exactly. */\nfn texelAt(texel: u32) -> vec2<i32> {\n return vec2<i32>(i32(texel % TABLE_WIDTH), i32(texel / TABLE_WIDTH));\n}\n\nfn sliceNearDepth(slice: f32) -> f32 {\n return params.frustum.x * pow(params.frustum.y / params.frustum.x, slice / f32(CLUSTER_Z));\n}\n\n/** Squared distance from a point to an axis-aligned box, zero inside it. */\nfn distanceSqToBounds(p: vec3<f32>, lo: vec3<f32>, hi: vec3<f32>) -> f32 {\n let d = max(max(lo - p, p - hi), vec3<f32>(0.0));\n return dot(d, d);\n}\n\n/** A light's view-space centre, with depth positive along the view direction. */\nfn viewOf(light: u32) -> vec3<f32> {\n let world = bitcast<vec4<f32>>(lights[light * LIGHT_TEXELS]);\n let v = params.view * vec4<f32>(world.xyz, 1.0);\n return vec3<f32>(v.x, v.y, -v.z);\n}\n\n/**\n * Squared distance from a light's view-space centre to a point.\n *\n * **Squared, matching `centreDistanceSq` in `clusteredLights.ts` rather than taking a root.** The\n * ranking is identical either way, and a square root is one more operation for the two\n * implementations to round differently.\n */\nfn centreDistanceSq(light: u32, centre: vec3<f32>) -> f32 {\n let d = viewOf(light) - centre;\n return dot(d, d);\n}\n\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n let id = gid.x;\n\n /*\n * The light records, written by the first invocations that have one.\n *\n * **Two jobs in one kernel, and it is deliberate.** The records have to reach the same texture\n * the clusters do, because the lit pass reads both through one texture unit; writing them from\n * the CPU instead would mean a partial texture upload racing a dispatch over the same resource.\n * There are always more clusters than lights, so every record has an invocation.\n */\n if (id < params.lightCount) {\n let record = id * LIGHT_TEXELS;\n for (var t: u32 = 0u; t < LIGHT_TEXELS; t = t + 1u) {\n textureStore(table, texelAt(record + t), lights[record + t]);\n }\n }\n\n if (id >= CLUSTER_COUNT) {\n return;\n }\n\n let k = id / (CLUSTER_X * CLUSTER_Y);\n let j = (id - k * CLUSTER_X * CLUSTER_Y) / CLUSTER_X;\n let i = id - k * CLUSTER_X * CLUSTER_Y - j * CLUSTER_X;\n\n /* The AABB of the froxel's eight corners, exactly as `clusterViewBounds` derives it. */\n let zNear = sliceNearDepth(f32(k));\n let zFar = sliceNearDepth(f32(k + 1u));\n let xLo = -1.0 + 2.0 * f32(i) / f32(CLUSTER_X);\n let xHi = -1.0 + 2.0 * f32(i + 1u) / f32(CLUSTER_X);\n let yLo = -1.0 + 2.0 * f32(j) / f32(CLUSTER_Y);\n let yHi = -1.0 + 2.0 * f32(j + 1u) / f32(CLUSTER_Y);\n let halfHNear = zNear * params.frustum.z;\n let halfHFar = zFar * params.frustum.z;\n let halfWNear = halfHNear * params.frustum.w;\n let halfWFar = halfHFar * params.frustum.w;\n\n let lo = vec3<f32>(\n min(min(xLo * halfWNear, xLo * halfWFar), min(xHi * halfWNear, xHi * halfWFar)),\n min(min(yLo * halfHNear, yLo * halfHFar), min(yHi * halfHNear, yHi * halfHFar)),\n zNear,\n );\n let hi = vec3<f32>(\n max(max(xLo * halfWNear, xLo * halfWFar), max(xHi * halfWNear, xHi * halfWFar)),\n max(max(yLo * halfHNear, yLo * halfHFar), max(yHi * halfHNear, yHi * halfHFar)),\n zFar,\n );\n let centre = (lo + hi) * 0.5;\n\n var indices: array<u32, MAX_LIGHTS_PER_CLUSTER>;\n for (var n: u32 = 0u; n < MAX_LIGHTS_PER_CLUSTER; n = n + 1u) {\n indices[n] = 0u;\n }\n var count: u32 = 0u;\n\n /* Increasing light index, which is what makes the list sorted without a sort. */\n for (var light: u32 = 0u; light < params.lightCount; light = light + 1u) {\n let radius = bitcast<f32>(lights[light * LIGHT_TEXELS].w);\n let v = viewOf(light);\n if (v.z + radius <= 0.0) {\n continue;\n }\n if (distanceSqToBounds(v, lo, hi) > radius * radius) {\n continue;\n }\n\n if (count < MAX_LIGHTS_PER_CLUSTER) {\n indices[count] = light;\n count = count + 1u;\n continue;\n }\n\n /*\n * Full. Drop the farthest from the cluster's centre if this one is nearer.\n *\n * **The only float comparison that decides anything here**, and the reason the *order* is an\n * integer: a float rank cannot be relied on to agree between this and the JavaScript binner,\n * so it is confined to membership, where the conformance script forgives a light sitting\n * within epsilon of a boundary.\n */\n var worstSlot: i32 = -1;\n var worst = centreDistanceSq(light, centre);\n for (var n: u32 = 0u; n < MAX_LIGHTS_PER_CLUSTER; n = n + 1u) {\n let d = centreDistanceSq(indices[n], centre);\n if (d > worst) {\n worst = d;\n worstSlot = i32(n);\n }\n }\n if (worstSlot < 0) {\n continue;\n }\n indices[u32(worstSlot)] = light;\n\n /* Insertion sort over at most 28 integers, so the bytes stay ascending after a replacement. */\n for (var n: u32 = 1u; n < MAX_LIGHTS_PER_CLUSTER; n = n + 1u) {\n let value = indices[n];\n var m: i32 = i32(n) - 1;\n while (m >= 0 && indices[u32(m)] > value) {\n indices[u32(m) + 1u] = indices[u32(m)];\n m = m - 1;\n }\n indices[u32(m + 1)] = value;\n }\n }\n\n let base = LIGHT_REGION_TEXELS + id * CLUSTER_TEXELS;\n textureStore(table, texelAt(base), vec4<u32>(count, 0u, 0u, 0u));\n for (var t: u32 = 0u; t < CLUSTER_TEXELS - 1u; t = t + 1u) {\n textureStore(\n table,\n texelAt(base + 1u + t),\n vec4<u32>(indices[t * 4u], indices[t * 4u + 1u], indices[t * 4u + 2u], indices[t * 4u + 3u]),\n );\n }\n}\n";
23
+ export declare const CLUSTER_BIN_WGSL = "\nconst CLUSTER_X: u32 = 16u;\nconst CLUSTER_Y: u32 = 9u;\nconst CLUSTER_Z: u32 = 24u;\nconst CLUSTER_COUNT: u32 = 3456u;\nconst CLUSTER_TEXELS: u32 = 20u;\nconst MAX_LIGHTS_PER_CLUSTER: u32 = 76u;\nconst LIGHT_TEXELS: u32 = 5u;\nconst LIGHT_REGION_TEXELS: u32 = 1600u;\nconst TABLE_WIDTH: u32 = 320u;\n\nstruct Params {\n view: mat4x4<f32>,\n /** near, far, tan(fovY/2), aspect. */\n frustum: vec4<f32>,\n lightCount: u32,\n pad0: u32,\n pad1: u32,\n pad2: u32,\n}\n\n@group(0) @binding(0) var<uniform> params: Params;\n/**\n * The records as `writeLightRecord` lays them out, as bits. **`u32`, not `f32`**, because one\n * slot holds two halves, and a zero weight read as a float would be a subnormal a GPU is free to\n * flush on the way through; read as bits, the copy into the table cannot change them.\n */\n@group(0) @binding(1) var<storage, read> lights: array<vec4<u32>>;\n/**\n * Write-only, which is all core WGSL offers and all the fixed-slot layout needs.\n *\n * A read-modify-write list would need a storage buffer and an atomic counter, and it is the reason\n * a cluster owns a fixed run rather than appending into a shared one.\n */\n@group(0) @binding(2) var table: texture_storage_2d<rgba32uint, write>;\n\n/** A flat texel index to its place in the table. TABLE_WIDTH divides both regions exactly. */\nfn texelAt(texel: u32) -> vec2<i32> {\n return vec2<i32>(i32(texel % TABLE_WIDTH), i32(texel / TABLE_WIDTH));\n}\n\nfn sliceNearDepth(slice: f32) -> f32 {\n return params.frustum.x * pow(params.frustum.y / params.frustum.x, slice / f32(CLUSTER_Z));\n}\n\n/** Squared distance from a point to an axis-aligned box, zero inside it. */\nfn distanceSqToBounds(p: vec3<f32>, lo: vec3<f32>, hi: vec3<f32>) -> f32 {\n let d = max(max(lo - p, p - hi), vec3<f32>(0.0));\n return dot(d, d);\n}\n\n/** A light's view-space centre, with depth positive along the view direction. */\nfn viewOf(light: u32) -> vec3<f32> {\n let world = bitcast<vec4<f32>>(lights[light * LIGHT_TEXELS]);\n let v = params.view * vec4<f32>(world.xyz, 1.0);\n return vec3<f32>(v.x, v.y, -v.z);\n}\n\n/**\n * Squared distance from a light's view-space centre to a point.\n *\n * **Squared, matching `centreDistanceSq` in `clusteredLights.ts` rather than taking a root.** The\n * ranking is identical either way, and a square root is one more operation for the two\n * implementations to round differently.\n */\nfn centreDistanceSq(light: u32, centre: vec3<f32>) -> f32 {\n let d = viewOf(light) - centre;\n return dot(d, d);\n}\n\n/**\n * Whether a spot's cone misses this froxel: `coneBoxGap` in `clusteredLights.ts`, the same\n * arithmetic in the same order \u2014 the froxel's bounding sphere against the infinite cone, its apex\n * included, which can keep a light the shader would not draw and never drop one it would. A light\n * with no cone carries a cosine of -2 and never reaches the test.\n */\nfn outsideCone(light: u32, apex: vec3<f32>, lo: vec3<f32>, hi: vec3<f32>) -> bool {\n let cosOuter = bitcast<f32>(lights[light * LIGHT_TEXELS + 3u].y);\n if (cosOuter <= -1.0) {\n return false;\n }\n let world = bitcast<vec3<f32>>(lights[light * LIGHT_TEXELS + 2u].yzw);\n let a = (params.view * vec4<f32>(world, 0.0)).xyz;\n let axis = vec3<f32>(a.x, a.y, -a.z);\n let len = length(axis);\n /* The shader measures the cone against the direction as given, so a long one is a wider cone. */\n if (len <= 1e-6 || cosOuter / len <= -1.0) {\n return false;\n }\n let cosA = cosOuter / len;\n let sinA = sqrt(max(0.0, 1.0 - cosA * cosA));\n let unit = axis / len;\n let extent = (hi - lo) * 0.5;\n let toCentre = (lo + hi) * 0.5 - apex;\n let along = dot(toCentre, unit);\n let lengthSq = dot(toCentre, toCentre);\n let across = sqrt(max(lengthSq - along * along, 0.0));\n let reach = sqrt(lengthSq);\n var distance = cosA * across - along * sinA;\n if (along < -sinA * reach) {\n distance = reach;\n }\n return distance - length(extent) > 0.0;\n}\n\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n let id = gid.x;\n\n /*\n * The light records, written by the first invocations that have one.\n *\n * **Two jobs in one kernel, and it is deliberate.** The records have to reach the same texture\n * the clusters do, because the lit pass reads both through one texture unit; writing them from\n * the CPU instead would mean a partial texture upload racing a dispatch over the same resource.\n * There are always more clusters than lights, so every record has an invocation.\n */\n if (id < params.lightCount) {\n let record = id * LIGHT_TEXELS;\n for (var t: u32 = 0u; t < LIGHT_TEXELS; t = t + 1u) {\n textureStore(table, texelAt(record + t), lights[record + t]);\n }\n }\n\n if (id >= CLUSTER_COUNT) {\n return;\n }\n\n let k = id / (CLUSTER_X * CLUSTER_Y);\n let j = (id - k * CLUSTER_X * CLUSTER_Y) / CLUSTER_X;\n let i = id - k * CLUSTER_X * CLUSTER_Y - j * CLUSTER_X;\n\n /* The AABB of the froxel's eight corners, exactly as `clusterViewBounds` derives it. */\n let zNear = sliceNearDepth(f32(k));\n let zFar = sliceNearDepth(f32(k + 1u));\n let xLo = -1.0 + 2.0 * f32(i) / f32(CLUSTER_X);\n let xHi = -1.0 + 2.0 * f32(i + 1u) / f32(CLUSTER_X);\n let yLo = -1.0 + 2.0 * f32(j) / f32(CLUSTER_Y);\n let yHi = -1.0 + 2.0 * f32(j + 1u) / f32(CLUSTER_Y);\n let halfHNear = zNear * params.frustum.z;\n let halfHFar = zFar * params.frustum.z;\n let halfWNear = halfHNear * params.frustum.w;\n let halfWFar = halfHFar * params.frustum.w;\n\n let lo = vec3<f32>(\n min(min(xLo * halfWNear, xLo * halfWFar), min(xHi * halfWNear, xHi * halfWFar)),\n min(min(yLo * halfHNear, yLo * halfHFar), min(yHi * halfHNear, yHi * halfHFar)),\n zNear,\n );\n let hi = vec3<f32>(\n max(max(xLo * halfWNear, xLo * halfWFar), max(xHi * halfWNear, xHi * halfWFar)),\n max(max(yLo * halfHNear, yLo * halfHFar), max(yHi * halfHNear, yHi * halfHFar)),\n zFar,\n );\n let centre = (lo + hi) * 0.5;\n\n var indices: array<u32, MAX_LIGHTS_PER_CLUSTER>;\n for (var n: u32 = 0u; n < MAX_LIGHTS_PER_CLUSTER; n = n + 1u) {\n indices[n] = 0u;\n }\n var count: u32 = 0u;\n\n /* Increasing light index, which is what makes the list sorted without a sort. */\n for (var light: u32 = 0u; light < params.lightCount; light = light + 1u) {\n /* A rectangle carries its reach negated, which is how the lit pass tells it from a lamp. */\n let signedRadius = bitcast<f32>(lights[light * LIGHT_TEXELS].w);\n let radius = abs(signedRadius);\n let v = viewOf(light);\n if (v.z + radius <= 0.0) {\n continue;\n }\n if (distanceSqToBounds(v, lo, hi) > radius * radius) {\n continue;\n }\n if (signedRadius >= 0.0 && outsideCone(light, v, lo, hi)) {\n continue;\n }\n\n if (count < MAX_LIGHTS_PER_CLUSTER) {\n indices[count] = light;\n count = count + 1u;\n continue;\n }\n\n /*\n * Full. Drop the farthest from the cluster's centre if this one is nearer.\n *\n * **The only float comparison that decides anything here**, and the reason the *order* is an\n * integer: a float rank cannot be relied on to agree between this and the JavaScript binner,\n * so it is confined to membership, where the conformance script forgives a light sitting\n * within epsilon of a boundary.\n */\n var worstSlot: i32 = -1;\n var worst = centreDistanceSq(light, centre);\n for (var n: u32 = 0u; n < MAX_LIGHTS_PER_CLUSTER; n = n + 1u) {\n let d = centreDistanceSq(indices[n], centre);\n if (d > worst) {\n worst = d;\n worstSlot = i32(n);\n }\n }\n if (worstSlot < 0) {\n continue;\n }\n indices[u32(worstSlot)] = light;\n\n /* Insertion sort over at most 28 integers, so the bytes stay ascending after a replacement. */\n for (var n: u32 = 1u; n < MAX_LIGHTS_PER_CLUSTER; n = n + 1u) {\n let value = indices[n];\n var m: i32 = i32(n) - 1;\n while (m >= 0 && indices[u32(m)] > value) {\n indices[u32(m) + 1u] = indices[u32(m)];\n m = m - 1;\n }\n indices[u32(m + 1)] = value;\n }\n }\n\n let base = LIGHT_REGION_TEXELS + id * CLUSTER_TEXELS;\n textureStore(table, texelAt(base), vec4<u32>(count, 0u, 0u, 0u));\n for (var t: u32 = 0u; t < CLUSTER_TEXELS - 1u; t = t + 1u) {\n textureStore(\n table,\n texelAt(base + 1u + t),\n vec4<u32>(indices[t * 4u], indices[t * 4u + 1u], indices[t * 4u + 2u], indices[t * 4u + 3u]),\n );\n }\n}\n";
@@ -91,6 +91,41 @@ fn centreDistanceSq(light: u32, centre: vec3<f32>) -> f32 {
91
91
  return dot(d, d);
92
92
  }
93
93
 
94
+ /**
95
+ * Whether a spot's cone misses this froxel: \`coneBoxGap\` in \`clusteredLights.ts\`, the same
96
+ * arithmetic in the same order — the froxel's bounding sphere against the infinite cone, its apex
97
+ * included, which can keep a light the shader would not draw and never drop one it would. A light
98
+ * with no cone carries a cosine of -2 and never reaches the test.
99
+ */
100
+ fn outsideCone(light: u32, apex: vec3<f32>, lo: vec3<f32>, hi: vec3<f32>) -> bool {
101
+ let cosOuter = bitcast<f32>(lights[light * LIGHT_TEXELS + 3u].y);
102
+ if (cosOuter <= -1.0) {
103
+ return false;
104
+ }
105
+ let world = bitcast<vec3<f32>>(lights[light * LIGHT_TEXELS + 2u].yzw);
106
+ let a = (params.view * vec4<f32>(world, 0.0)).xyz;
107
+ let axis = vec3<f32>(a.x, a.y, -a.z);
108
+ let len = length(axis);
109
+ /* The shader measures the cone against the direction as given, so a long one is a wider cone. */
110
+ if (len <= 1e-6 || cosOuter / len <= -1.0) {
111
+ return false;
112
+ }
113
+ let cosA = cosOuter / len;
114
+ let sinA = sqrt(max(0.0, 1.0 - cosA * cosA));
115
+ let unit = axis / len;
116
+ let extent = (hi - lo) * 0.5;
117
+ let toCentre = (lo + hi) * 0.5 - apex;
118
+ let along = dot(toCentre, unit);
119
+ let lengthSq = dot(toCentre, toCentre);
120
+ let across = sqrt(max(lengthSq - along * along, 0.0));
121
+ let reach = sqrt(lengthSq);
122
+ var distance = cosA * across - along * sinA;
123
+ if (along < -sinA * reach) {
124
+ distance = reach;
125
+ }
126
+ return distance - length(extent) > 0.0;
127
+ }
128
+
94
129
  @compute @workgroup_size(64)
95
130
  fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
96
131
  let id = gid.x;
@@ -150,7 +185,9 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
150
185
 
151
186
  /* Increasing light index, which is what makes the list sorted without a sort. */
152
187
  for (var light: u32 = 0u; light < params.lightCount; light = light + 1u) {
153
- let radius = bitcast<f32>(lights[light * LIGHT_TEXELS].w);
188
+ /* A rectangle carries its reach negated, which is how the lit pass tells it from a lamp. */
189
+ let signedRadius = bitcast<f32>(lights[light * LIGHT_TEXELS].w);
190
+ let radius = abs(signedRadius);
154
191
  let v = viewOf(light);
155
192
  if (v.z + radius <= 0.0) {
156
193
  continue;
@@ -158,6 +195,9 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
158
195
  if (distanceSqToBounds(v, lo, hi) > radius * radius) {
159
196
  continue;
160
197
  }
198
+ if (signedRadius >= 0.0 && outsideCone(light, v, lo, hi)) {
199
+ continue;
200
+ }
161
201
 
162
202
  if (count < MAX_LIGHTS_PER_CLUSTER) {
163
203
  indices[count] = light;
@@ -1,24 +1,3 @@
1
- /**
2
- * How many palettes one frame may set before the ring is full.
3
- *
4
- * The arithmetic rather than a round number: a character's palette is uploaded once per *pass*
5
- * that draws it, and a full profile draws the same character five times — the main pass, a planar
6
- * reflection, and the static, peel and dynamic shadow layers. So this is twenty-five characters at
7
- * once, which is a crowd rather than a cast.
8
- *
9
- * **What it costs is nothing until it is used.** A slot allocates its texture on the first frame
10
- * that reaches it and keeps it, so a scene with two characters holds two: a 24-joint rig is 96
11
- * texels of `rgba32float`, which is 1.5 KB, and a 96-joint humanoid is 6 KB.
12
- *
13
- * **What would make it wrong** is a genuine crowd — a stadium, a swarm of rigged birds — and the
14
- * fix is then the one `skinPalette.ts` names: one texture holding every palette with a per-draw
15
- * row offset, which spends a uniform and a shader change to stop spending textures.
16
- *
17
- * **A reconstructing frame spends one more per skinned mover**: last frame's pose is uploaded into
18
- * a slot of its own for the motion pass. So the same budget is about twelve such characters with a
19
- * reflection and three shadow layers each.
20
- */
21
- export declare const MAX_SKIN_PALETTES = 128;
22
1
  /**
23
2
  * Every joint palette a frame sets, each in its own texture, reused frame after frame.
24
3
  *
@@ -39,16 +18,46 @@ export declare const MAX_SKIN_PALETTES = 128;
39
18
  * draws separates them there by construction. The decision — a palette belongs to a draw — is one;
40
19
  * only what it takes to honour it differs, which is what `skinPalette.ts` says a binder is for.
41
20
  *
42
- * **A slot is written at most once per frame**, since every `setSkinPalette` takes a fresh one.
43
- * That is what makes the reallocation inside `SkinPaletteTexture` safe: a joint count can only
44
- * change a slot's texture between frames, never while a recorded draw is still pointing at it.
21
+ * **A palette set again with the same numbers is the slot it already has.** A rig is drawn in
22
+ * every pass of a frame — the main pass, a reflection, three shadow layers — and each one used to
23
+ * upload the same matrices into a slot of its own, so a crowd ran out five times sooner than it
24
+ * had characters. The ring now remembers the slot each array last went into and compares what it
25
+ * holds, so a rig posed once a frame spends one slot however many passes draw it. Identity alone
26
+ * would be wrong: a caller may pose every rig into one scratch array, and that array then holds a
27
+ * different rig at every set — the comparison is what tells the two apart.
28
+ *
29
+ * **It was capped at 128 until 4.8.6, and the cap declined draws.** A stage of three dozen idling
30
+ * extras beside two fighters filled it in one frame and every skinned draw past it was refused, so
31
+ * the consumer stood its crowd in the bind pose. The ring now holds as many palettes as a frame
32
+ * holds draws (`RenderQuality.drawsPerFrame`), since no frame can bind more palettes than it draws;
33
+ * a slot still allocates its texture only the first time a frame reaches it, so a scene with two
34
+ * characters holds two. **What it costs** is a texture and a cached bind group a slot, 1.5 KB for a
35
+ * 24-joint rig and 6 KB for a 96-joint humanoid. **What would make it wrong** is a crowd of
36
+ * thousands, where a texture a palette is the expensive half; the fix is then one texture holding
37
+ * every palette with a row offset per draw, which spends a per-draw uniform and a vertex-stage
38
+ * change to stop spending textures.
39
+ *
40
+ * **A slot is written at most once per frame**, since a palette that differs from what a slot
41
+ * holds always takes a fresh one. That is what makes the reallocation inside `SkinPaletteTexture`
42
+ * safe: a joint count can only change a slot's texture between frames, never while a recorded draw
43
+ * is still pointing at it.
45
44
  */
46
45
  export declare class SkinPaletteRing {
46
+ private readonly capacity;
47
47
  private readonly slots;
48
+ /** What each slot was last written with, for the comparison that lets a pass reuse it. */
49
+ private readonly held;
50
+ /** The slot each palette array last went into, by identity. Weak, so a caller's array can go. */
51
+ private readonly lastSlot;
48
52
  private used;
53
+ private uploads;
49
54
  private warnedFull;
55
+ /** `capacity` is how many palettes one frame may hold: one per draw at most. */
56
+ constructor(capacity: number);
50
57
  /** How many slots this frame has taken. */
51
58
  get count(): number;
59
+ /** How many palettes have been written to the device since the ring was made. */
60
+ get uploaded(): number;
52
61
  /**
53
62
  * Start a frame: every slot is free again.
54
63
  *
@@ -62,14 +71,17 @@ export declare class SkinPaletteRing {
62
71
  /** Give back every slot taken since `mark`, once the commands reading them are submitted. */
63
72
  rewind(mark: number): void;
64
73
  /**
65
- * Upload a palette into a slot of its own and return that slot, or null when the ring is full.
74
+ * The slot this frame holds for these numbers, uploading them into a fresh one where none does,
75
+ * or null when the ring is full.
66
76
  *
67
- * **Null rather than reusing a slot**, because reusing one is the defect this class is named
68
- * after: the caller's character would silently take another character's pose. A caller that runs
69
- * out declines the draw and says so once.
77
+ * **Null rather than reusing a slot that holds something else**, because that is the defect this
78
+ * class is named after: the caller's character would silently take another character's pose. A
79
+ * caller that runs out declines the draw and says so once.
70
80
  */
71
81
  take(device: GPUDevice, palette: Float32Array): number | null;
72
82
  /** The view a draw binds for a slot, or null for a slot nothing has uploaded into. */
73
83
  view(slot: number): GPUTextureView | null;
74
84
  dispose(): void;
85
+ /** Whether a slot was last written with exactly these numbers. */
86
+ private holds;
75
87
  }
@@ -1,25 +1,4 @@
1
1
  import { SkinPaletteTexture } from './skinPaletteTexture.js';
2
- /**
3
- * How many palettes one frame may set before the ring is full.
4
- *
5
- * The arithmetic rather than a round number: a character's palette is uploaded once per *pass*
6
- * that draws it, and a full profile draws the same character five times — the main pass, a planar
7
- * reflection, and the static, peel and dynamic shadow layers. So this is twenty-five characters at
8
- * once, which is a crowd rather than a cast.
9
- *
10
- * **What it costs is nothing until it is used.** A slot allocates its texture on the first frame
11
- * that reaches it and keeps it, so a scene with two characters holds two: a 24-joint rig is 96
12
- * texels of `rgba32float`, which is 1.5 KB, and a 96-joint humanoid is 6 KB.
13
- *
14
- * **What would make it wrong** is a genuine crowd — a stadium, a swarm of rigged birds — and the
15
- * fix is then the one `skinPalette.ts` names: one texture holding every palette with a per-draw
16
- * row offset, which spends a uniform and a shader change to stop spending textures.
17
- *
18
- * **A reconstructing frame spends one more per skinned mover**: last frame's pose is uploaded into
19
- * a slot of its own for the motion pass. So the same budget is about twelve such characters with a
20
- * reflection and three shadow layers each.
21
- */
22
- export const MAX_SKIN_PALETTES = 128;
23
2
  /**
24
3
  * Every joint palette a frame sets, each in its own texture, reused frame after frame.
25
4
  *
@@ -40,18 +19,52 @@ export const MAX_SKIN_PALETTES = 128;
40
19
  * draws separates them there by construction. The decision — a palette belongs to a draw — is one;
41
20
  * only what it takes to honour it differs, which is what `skinPalette.ts` says a binder is for.
42
21
  *
43
- * **A slot is written at most once per frame**, since every `setSkinPalette` takes a fresh one.
44
- * That is what makes the reallocation inside `SkinPaletteTexture` safe: a joint count can only
45
- * change a slot's texture between frames, never while a recorded draw is still pointing at it.
22
+ * **A palette set again with the same numbers is the slot it already has.** A rig is drawn in
23
+ * every pass of a frame — the main pass, a reflection, three shadow layers — and each one used to
24
+ * upload the same matrices into a slot of its own, so a crowd ran out five times sooner than it
25
+ * had characters. The ring now remembers the slot each array last went into and compares what it
26
+ * holds, so a rig posed once a frame spends one slot however many passes draw it. Identity alone
27
+ * would be wrong: a caller may pose every rig into one scratch array, and that array then holds a
28
+ * different rig at every set — the comparison is what tells the two apart.
29
+ *
30
+ * **It was capped at 128 until 4.8.6, and the cap declined draws.** A stage of three dozen idling
31
+ * extras beside two fighters filled it in one frame and every skinned draw past it was refused, so
32
+ * the consumer stood its crowd in the bind pose. The ring now holds as many palettes as a frame
33
+ * holds draws (`RenderQuality.drawsPerFrame`), since no frame can bind more palettes than it draws;
34
+ * a slot still allocates its texture only the first time a frame reaches it, so a scene with two
35
+ * characters holds two. **What it costs** is a texture and a cached bind group a slot, 1.5 KB for a
36
+ * 24-joint rig and 6 KB for a 96-joint humanoid. **What would make it wrong** is a crowd of
37
+ * thousands, where a texture a palette is the expensive half; the fix is then one texture holding
38
+ * every palette with a row offset per draw, which spends a per-draw uniform and a vertex-stage
39
+ * change to stop spending textures.
40
+ *
41
+ * **A slot is written at most once per frame**, since a palette that differs from what a slot
42
+ * holds always takes a fresh one. That is what makes the reallocation inside `SkinPaletteTexture`
43
+ * safe: a joint count can only change a slot's texture between frames, never while a recorded draw
44
+ * is still pointing at it.
46
45
  */
47
46
  export class SkinPaletteRing {
47
+ capacity;
48
48
  slots = [];
49
+ /** What each slot was last written with, for the comparison that lets a pass reuse it. */
50
+ held = [];
51
+ /** The slot each palette array last went into, by identity. Weak, so a caller's array can go. */
52
+ lastSlot = new WeakMap();
49
53
  used = 0;
54
+ uploads = 0;
50
55
  warnedFull = false;
56
+ /** `capacity` is how many palettes one frame may hold: one per draw at most. */
57
+ constructor(capacity) {
58
+ this.capacity = capacity;
59
+ }
51
60
  /** How many slots this frame has taken. */
52
61
  get count() {
53
62
  return this.used;
54
63
  }
64
+ /** How many palettes have been written to the device since the ring was made. */
65
+ get uploaded() {
66
+ return this.uploads;
67
+ }
55
68
  /**
56
69
  * Start a frame: every slot is free again.
57
70
  *
@@ -72,19 +85,23 @@ export class SkinPaletteRing {
72
85
  this.used = mark;
73
86
  }
74
87
  /**
75
- * Upload a palette into a slot of its own and return that slot, or null when the ring is full.
88
+ * The slot this frame holds for these numbers, uploading them into a fresh one where none does,
89
+ * or null when the ring is full.
76
90
  *
77
- * **Null rather than reusing a slot**, because reusing one is the defect this class is named
78
- * after: the caller's character would silently take another character's pose. A caller that runs
79
- * out declines the draw and says so once.
91
+ * **Null rather than reusing a slot that holds something else**, because that is the defect this
92
+ * class is named after: the caller's character would silently take another character's pose. A
93
+ * caller that runs out declines the draw and says so once.
80
94
  */
81
95
  take(device, palette) {
82
- if (this.used >= MAX_SKIN_PALETTES) {
96
+ const known = this.lastSlot.get(palette);
97
+ if (known !== undefined && known < this.used && this.holds(known, palette))
98
+ return known;
99
+ if (this.used >= this.capacity) {
83
100
  if (!this.warnedFull) {
84
101
  this.warnedFull = true;
85
- console.warn(`WebGPU: more than ${MAX_SKIN_PALETTES} skin palettes in one frame; the rest of the ` +
86
- 'skinned draws are declined this frame rather than drawn in another rig’s pose. ' +
87
- 'See MAX_SKIN_PALETTES.');
102
+ console.warn(`WebGPU: more than ${this.capacity} distinct skin palettes in one frame, which is ` +
103
+ 'RenderQuality.drawsPerFrame; the rest of the skinned draws are declined this frame ' +
104
+ 'rather than drawn in another rig’s pose.');
88
105
  }
89
106
  return null;
90
107
  }
@@ -95,6 +112,15 @@ export class SkinPaletteRing {
95
112
  this.slots[at] = slot;
96
113
  }
97
114
  slot.update(device, palette);
115
+ this.uploads += 1;
116
+ let copy = this.held[at];
117
+ if (copy === undefined || copy.length !== palette.length) {
118
+ /* Once per slot and joint count, the way the slot's own texture is: never per frame. */
119
+ copy = new Float32Array(palette.length);
120
+ this.held[at] = copy;
121
+ }
122
+ copy.set(palette);
123
+ this.lastSlot.set(palette, at);
98
124
  return at;
99
125
  }
100
126
  /** The view a draw binds for a slot, or null for a slot nothing has uploaded into. */
@@ -105,6 +131,17 @@ export class SkinPaletteRing {
105
131
  for (const slot of this.slots)
106
132
  slot.dispose();
107
133
  this.slots.length = 0;
134
+ this.held.length = 0;
108
135
  this.used = 0;
109
136
  }
137
+ /** Whether a slot was last written with exactly these numbers. */
138
+ holds(slot, palette) {
139
+ const copy = this.held[slot];
140
+ if (copy === undefined || copy.length !== palette.length)
141
+ return false;
142
+ for (let i = 0; i < copy.length; i++)
143
+ if (copy[i] !== palette[i])
144
+ return false;
145
+ return true;
146
+ }
110
147
  }
@@ -10,14 +10,20 @@ export declare class SkinScatterPass {
10
10
  private readonly linear;
11
11
  private readonly nearest;
12
12
  private readonly commands;
13
+ /** The same draws again, through the pipelines that write the skin's colour alone. */
14
+ private readonly albedoCommands;
13
15
  /** Each profile's scatter distance per channel, in metres: four floats a profile. */
14
16
  private readonly profiles;
15
17
  private diffuse;
16
18
  private diffuseMsaa;
17
19
  private across;
20
+ private albedo;
21
+ private albedoMsaa;
18
22
  private diffuseView;
19
23
  private diffuseMsaaView;
20
24
  private acrossView;
25
+ private albedoView;
26
+ private albedoMsaaView;
21
27
  private groupAcross;
22
28
  private groupDown;
23
29
  private groupsDepth;
@@ -26,13 +32,15 @@ export declare class SkinScatterPass {
26
32
  get pending(): boolean;
27
33
  /** A command for one skin draw's diffuse half, filled by the caller. */
28
34
  take(): DrawCommand;
35
+ /** A command for the same draw's colour half, filled by the caller. */
36
+ takeAlbedo(): DrawCommand;
29
37
  /** What one profile scatters, per channel, in metres: the last material to name it says. */
30
38
  setProfile(index: number, r: number, g: number, b: number): void;
31
39
  /** The targets at the frame's size and sample count, made again only when either changes. */
32
40
  size(width: number, height: number, samples: number): void;
33
41
  /**
34
- * Every kept draw's diffuse into the target, against the frame's own depth, which it reads and
35
- * does not write. Called with no pass open on `encoder`.
42
+ * Every kept draw's diffuse into the target, and its colour into the other, against the frame's
43
+ * own depth, which they read and do not write. Called with no pass open on `encoder`.
36
44
  */
37
45
  drawDiffuse(encoder: GPUCommandEncoder, depth: GPUTextureView, issue: (pass: GPURenderPassEncoder, command: DrawCommand) => void, timestampWrites: GPURenderPassTimestampWrites | undefined): void;
38
46
  /**
@@ -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.js';
21
23
  import { SKINBLUR_BINDINGS, SKIN_BLUR_FRAG_WGSL } from '../../shaders/generated/skinBlur.wgsl.js';
@@ -37,14 +39,20 @@ export class SkinScatterPass {
37
39
  linear;
38
40
  nearest;
39
41
  commands = createCommandPool(16);
42
+ /** The same draws again, through the pipelines that write the skin's colour alone. */
43
+ albedoCommands = createCommandPool(16);
40
44
  /** Each profile's scatter distance per channel, in metres: four floats a profile. */
41
45
  profiles = new Float32Array(SKIN_PROFILES * 4);
42
46
  diffuse = null;
43
47
  diffuseMsaa = null;
44
48
  across = null;
49
+ albedo = null;
50
+ albedoMsaa = null;
45
51
  diffuseView = null;
46
52
  diffuseMsaaView = null;
47
53
  acrossView = null;
54
+ albedoView = null;
55
+ albedoMsaaView = null;
48
56
  groupAcross = null;
49
57
  groupDown = null;
50
58
  groupsDepth = null;
@@ -53,6 +61,7 @@ export class SkinScatterPass {
53
61
  this.layout = createPostStageLayout(device, BINDINGS.uniforms, BINDINGS.uniformSize, [
54
62
  { binding: BINDINGS.textures.uSkin },
55
63
  { binding: BINDINGS.textures.uDepth, filterable: false },
64
+ { binding: BINDINGS.textures.uAlbedo },
56
65
  ]);
57
66
  this.uniforms = device.createBuffer({
58
67
  label: 'skin.blurUniforms',
@@ -80,6 +89,10 @@ export class SkinScatterPass {
80
89
  take() {
81
90
  return this.commands.commands[takeCommand(this.commands)];
82
91
  }
92
+ /** A command for the same draw's colour half, filled by the caller. */
93
+ takeAlbedo() {
94
+ return this.albedoCommands.commands[takeCommand(this.albedoCommands)];
95
+ }
83
96
  /** What one profile scatters, per channel, in metres: the last material to name it says. */
84
97
  setProfile(index, r, g, b) {
85
98
  const at = Math.min(Math.max(Math.round(index), 0), SKIN_PROFILES - 1) * 4;
@@ -106,16 +119,20 @@ export class SkinScatterPass {
106
119
  });
107
120
  this.diffuse = make('skin.diffuse', 1);
108
121
  this.across = make('skin.across', 1);
122
+ this.albedo = make('skin.albedo', 1);
109
123
  this.diffuseMsaa = samples > 1 ? make('skin.diffuseMsaa', samples) : null;
124
+ this.albedoMsaa = samples > 1 ? make('skin.albedoMsaa', samples) : null;
110
125
  this.diffuseView = this.diffuse.createView();
111
126
  this.acrossView = this.across.createView();
127
+ this.albedoView = this.albedo.createView();
112
128
  this.diffuseMsaaView = this.diffuseMsaa?.createView() ?? null;
129
+ this.albedoMsaaView = this.albedoMsaa?.createView() ?? null;
113
130
  this.groupAcross = null;
114
131
  this.groupDown = null;
115
132
  }
116
133
  /**
117
- * Every kept draw's diffuse into the target, against the frame's own depth, which it reads and
118
- * does not write. Called with no pass open on `encoder`.
134
+ * Every kept draw's diffuse into the target, and its colour into the other, against the frame's
135
+ * own depth, which they read and do not write. Called with no pass open on `encoder`.
119
136
  */
120
137
  drawDiffuse(encoder, depth, issue, timestampWrites) {
121
138
  const target = this.diffuseView;
@@ -139,6 +156,26 @@ export class SkinScatterPass {
139
156
  issue(pass, this.commands.commands[i]);
140
157
  }
141
158
  pass.end();
159
+ const colour = this.albedoView;
160
+ if (colour === null)
161
+ return;
162
+ const albedoPass = encoder.beginRenderPass({
163
+ label: 'skin.albedo',
164
+ colorAttachments: [
165
+ {
166
+ view: this.albedoMsaaView ?? colour,
167
+ resolveTarget: this.albedoMsaaView === null ? undefined : colour,
168
+ clearValue: [0, 0, 0, 0],
169
+ loadOp: 'clear',
170
+ storeOp: this.albedoMsaaView === null ? 'store' : 'discard',
171
+ },
172
+ ],
173
+ depthStencilAttachment: { view: depth, depthLoadOp: 'load', depthStoreOp: 'store' },
174
+ });
175
+ for (let i = 0; i < this.albedoCommands.taken; i += 1) {
176
+ issue(albedoPass, this.albedoCommands.commands[i]);
177
+ }
178
+ albedoPass.end();
142
179
  }
143
180
  /**
144
181
  * Across into the second target, then down and added into the frame's colour — `frame` the
@@ -149,6 +186,8 @@ export class SkinScatterPass {
149
186
  const diffuse = this.diffuse;
150
187
  if (diffuse === null || this.diffuseView === null || this.acrossView === null)
151
188
  return;
189
+ if (this.albedoView === null)
190
+ return;
152
191
  if (this.groupAcross === null || this.groupDown === null || this.groupsDepth !== depth) {
153
192
  this.groupAcross = this.group('skin.blurAcross', this.diffuseView, depth);
154
193
  this.groupDown = this.group('skin.blurDown', this.acrossView, depth);
@@ -197,6 +236,7 @@ export class SkinScatterPass {
197
236
  /** Spent at the frame's start: the kept draws were this frame's. */
198
237
  reset() {
199
238
  resetPool(this.commands);
239
+ resetPool(this.albedoCommands);
200
240
  }
201
241
  dispose() {
202
242
  this.releaseTargets();
@@ -215,6 +255,8 @@ export class SkinScatterPass {
215
255
  { binding: BINDINGS.textures.uSkin.sampler, resource: this.linear },
216
256
  { binding: BINDINGS.textures.uDepth.texture, resource: depth },
217
257
  { binding: BINDINGS.textures.uDepth.sampler, resource: this.nearest },
258
+ { binding: BINDINGS.textures.uAlbedo.texture, resource: this.albedoView ?? skin },
259
+ { binding: BINDINGS.textures.uAlbedo.sampler, resource: this.nearest },
218
260
  ],
219
261
  });
220
262
  }
@@ -236,6 +278,8 @@ export class SkinScatterPass {
236
278
  too wherever a pixel is square. */
237
279
  f[base + fields.uFocal.offset / 4] = (projection[5] * height) / 2;
238
280
  f.set(this.profiles, base + fields.uProfiles.offset / 4);
281
+ /* The colour is applied once, on the axis that adds into the frame. */
282
+ f[base + fields.uApplyAlbedo.offset / 4] = axis;
239
283
  }
240
284
  this.device.queue.writeBuffer(this.uniforms, 0, f);
241
285
  }
@@ -243,11 +287,17 @@ export class SkinScatterPass {
243
287
  this.diffuse?.destroy();
244
288
  this.diffuseMsaa?.destroy();
245
289
  this.across?.destroy();
290
+ this.albedo?.destroy();
291
+ this.albedoMsaa?.destroy();
246
292
  this.diffuse = null;
247
293
  this.diffuseMsaa = null;
248
294
  this.across = null;
295
+ this.albedo = null;
296
+ this.albedoMsaa = null;
249
297
  this.diffuseView = null;
250
298
  this.diffuseMsaaView = null;
251
299
  this.acrossView = null;
300
+ this.albedoView = null;
301
+ this.albedoMsaaView = null;
252
302
  }
253
303
  }
@@ -1,4 +1,5 @@
1
1
  import type { SurfaceTextureOptions } from '../../surfaceTexture.ts';
2
+ import type { LightmapTexels } from '../../lightmap.ts';
2
3
  import type { CompressedTextureFormat, SurfaceSource } from '../../compressedSource.ts';
3
4
  import type { PipelineCache } from './pipelineCache.ts';
4
5
  /** How many levels an image of this size has, counting the one it arrives with. */
@@ -62,7 +63,7 @@ export declare class GpuSurfaceTexture {
62
63
  effectsView: GPUTextureView | null;
63
64
  /** The block format a compressed texture holds, or null for one uploaded from images. */
64
65
  private readonly blockFormat;
65
- constructor(device: GPUDevice, pipelines: PipelineCache, source: SurfaceSource | readonly SurfaceSource[], options?: SurfaceTextureOptions, compressed?: readonly CompressedTextureFormat[]);
66
+ constructor(device: GPUDevice, pipelines: PipelineCache, source: SurfaceSource | readonly SurfaceSource[] | LightmapTexels, options?: SurfaceTextureOptions, compressed?: readonly CompressedTextureFormat[]);
66
67
  /** A view of every layer, as the shaders' `texture_2d_array` bindings read it. */
67
68
  private arrayView;
68
69
  /** The view every binding of this image holds. A new one when `update` changes the size. */
@@ -1,5 +1,6 @@
1
1
  import { SURFACE_EFFECT_TEXELS, packSurfaceEffects } from '../../surfaceEffects.js';
2
2
  import { toHalfFloats } from '../../halfFloat.js';
3
+ import { isLightmapTexels } from '../../lightmap.js';
3
4
  import { layerSize, refuseArrayUpdate, sourceSize } from '../../textureSource.js';
4
5
  import { compressedLayers, planBlocks, refuseBlockUpdate } from '../../compressedSource.js';
5
6
  import { createCompressedTexture } from './compressedUpload.js';
@@ -155,13 +156,26 @@ export class GpuSurfaceTexture {
155
156
  constructor(device, pipelines, source, options = {}, compressed = []) {
156
157
  this.device = device;
157
158
  this.pipelines = pipelines;
158
- const listed = (Array.isArray(source) ? source : [source]);
159
+ /* A lightmap's page: half floats, uploaded as they are. See `lightmap.ts`. */
160
+ const baked = isLightmapTexels(source) ? source : null;
161
+ const listed = (baked !== null ? [] : Array.isArray(source) ? source : [source]);
159
162
  const blocks = compressedLayers(listed);
160
163
  const srgb = (options.colorSpace ?? 'linear') === 'srgb';
161
164
  /* `SRGB8_ALPHA8`'s equivalent. Decoded in the sampler, before filtering, which is the only
162
165
  place it is correct — `surfaceTexture.ts` makes the argument in full. */
163
- this.format = srgb ? 'rgba8unorm-srgb' : 'rgba8unorm';
164
- if (blocks !== null) {
166
+ this.format = baked !== null ? 'rgba16float' : srgb ? 'rgba8unorm-srgb' : 'rgba8unorm';
167
+ if (baked !== null) {
168
+ /* No chain: a page is read at its own resolution, and a level of it would blend regions. */
169
+ this.layers = baked.layers;
170
+ this.blockFormat = null;
171
+ this.mipmapped = false;
172
+ this.width = baked.width;
173
+ this.height = baked.height;
174
+ this.levels = 1;
175
+ this.texture = this.allocate();
176
+ device.queue.writeTexture({ texture: this.texture }, baked.texels, { bytesPerRow: baked.width * 8, rowsPerImage: baked.height }, { width: baked.width, height: baked.height, depthOrArrayLayers: baked.layers });
177
+ }
178
+ else if (blocks !== null) {
165
179
  /* Refused before anything is allocated; the stored chain, or level 0 alone where no chain
166
180
  was asked for. See `compressedUpload.ts`. */
167
181
  const plan = planBlocks(blocks, srgb, compressed);
@@ -216,7 +230,7 @@ export class GpuSurfaceTexture {
216
230
  ? Math.max(1, Math.floor(options.anisotropy ?? DEFAULT_ANISOTROPY))
217
231
  : 1,
218
232
  });
219
- if (blocks === null)
233
+ if (blocks === null && baked === null)
220
234
  this.upload(listed);
221
235
  /* Half floats, as `renderer.ts` uploads it: filterable, so the ordinary float layout takes it,
222
236
  and read by `textureLoad` alone, so the filter never runs. */