@driftengine/core 4.2.1 → 4.4.0
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.
- package/CHANGELOG.json +272 -0
- package/README.md +1 -1
- package/dist/cinematic/cameraPath.d.ts +53 -0
- package/dist/cinematic/cameraPath.js +118 -0
- package/dist/environment/daylight.d.ts +77 -0
- package/dist/environment/daylight.js +77 -0
- package/dist/geometry/meshConcat.d.ts +9 -0
- package/dist/geometry/meshConcat.js +101 -0
- package/dist/index.d.ts +12 -2
- package/dist/index.js +14 -1
- package/dist/render/backend/createRenderer.d.ts +13 -1
- package/dist/render/backend/createRenderer.js +6 -5
- package/dist/render/backend/webgl2/renderer.d.ts +166 -1
- package/dist/render/backend/webgl2/renderer.js +424 -23
- package/dist/render/backend/webgpu/buffers.js +46 -8
- package/dist/render/backend/webgpu/depthPass.d.ts +81 -3
- package/dist/render/backend/webgpu/depthPass.js +75 -14
- package/dist/render/backend/webgpu/exposurePass.d.ts +39 -0
- package/dist/render/backend/webgpu/exposurePass.js +190 -0
- package/dist/render/backend/webgpu/fieldCompose.d.ts +11 -0
- package/dist/render/backend/webgpu/fieldCompose.js +13 -0
- package/dist/render/backend/webgpu/flatPass.d.ts +3 -1
- package/dist/render/backend/webgpu/flatPass.js +12 -5
- package/dist/render/backend/webgpu/particlePass.d.ts +8 -0
- package/dist/render/backend/webgpu/particlePass.js +1 -0
- package/dist/render/backend/webgpu/postPass.d.ts +12 -2
- package/dist/render/backend/webgpu/probeBake.d.ts +59 -3
- package/dist/render/backend/webgpu/probeBake.js +97 -7
- package/dist/render/backend/webgpu/probeTargets.d.ts +65 -0
- package/dist/render/backend/webgpu/probeTargets.js +180 -0
- package/dist/render/backend/webgpu/renderer.d.ts +176 -11
- package/dist/render/backend/webgpu/renderer.js +680 -250
- package/dist/render/backend/webgpu/scatterPass.js +7 -7
- package/dist/render/backend/webgpu/shaders/clusterBin.wgsl.d.ts +1 -1
- package/dist/render/backend/webgpu/skinPaletteRing.d.ts +4 -0
- package/dist/render/backend/webgpu/skinPaletteRing.js +9 -0
- package/dist/render/backend/webgpu/skyPass.js +5 -1
- package/dist/render/backend/webgpu/uniformRing.d.ts +31 -1
- package/dist/render/backend/webgpu/uniformRing.js +53 -3
- package/dist/render/bloomChain.d.ts +6 -0
- package/dist/render/bloomChain.js +8 -0
- package/dist/render/clusteredLights.d.ts +19 -11
- package/dist/render/clusteredLights.js +96 -45
- package/dist/render/cutoutCaster.d.ts +23 -0
- package/dist/render/cutoutCaster.js +20 -0
- package/dist/render/driftLight/bake.d.ts +34 -0
- package/dist/render/driftLight/bake.js +95 -0
- package/dist/render/driftLight/falloff.d.ts +16 -0
- package/dist/render/driftLight/falloff.js +23 -0
- package/dist/render/driftLight/layout.d.ts +59 -0
- package/dist/render/driftLight/layout.js +138 -0
- package/dist/render/driftLight/lightField.d.ts +108 -0
- package/dist/render/driftLight/lightField.js +179 -0
- package/dist/render/driftLight/uniforms.d.ts +28 -0
- package/dist/render/driftLight/uniforms.js +41 -0
- package/dist/render/driftLight/visibility.d.ts +18 -0
- package/dist/render/driftLight/visibility.js +47 -0
- package/dist/render/emptyTexture.d.ts +6 -0
- package/dist/render/emptyTexture.js +19 -0
- package/dist/render/exposurePass.d.ts +48 -0
- package/dist/render/exposurePass.js +172 -0
- package/dist/render/flameLight.d.ts +71 -0
- package/dist/render/flameLight.js +63 -0
- package/dist/render/gi/bounceLights.d.ts +34 -0
- package/dist/render/gi/bounceLights.js +66 -0
- package/dist/render/gi/fieldReach.d.ts +12 -0
- package/dist/render/gi/fieldReach.js +62 -0
- package/dist/render/gi/globalField.d.ts +8 -0
- package/dist/render/gi/globalField.js +31 -0
- package/dist/render/gi/probeTrace.d.ts +3 -1
- package/dist/render/gi/probeTrace.js +3 -1
- package/dist/render/gpuCapability.js +19 -4
- package/dist/render/lightBudget.d.ts +11 -0
- package/dist/render/lightBudget.js +11 -0
- package/dist/render/pointLightSelection.d.ts +16 -0
- package/dist/render/pointLightSelection.js +178 -83
- package/dist/render/probeGrid.d.ts +11 -1
- package/dist/render/probeGrid.js +5 -2
- package/dist/render/probeSweeps.d.ts +45 -0
- package/dist/render/probeSweeps.js +77 -0
- package/dist/render/reflectionProbe.d.ts +43 -1
- package/dist/render/reflectionProbe.js +17 -3
- package/dist/render/renderQuality.d.ts +3 -1
- package/dist/render/rendererHarness.d.ts +9 -0
- package/dist/render/rendererHarness.js +18 -3
- package/dist/render/sceneTarget.d.ts +20 -0
- package/dist/render/sceneTarget.js +62 -1
- package/dist/render/shaders/agx.d.ts +31 -0
- package/dist/render/shaders/agx.js +81 -0
- package/dist/render/shaders/ambientOcclusion.d.ts +24 -5
- package/dist/render/shaders/ambientOcclusion.js +120 -63
- package/dist/render/shaders/cutoutCoverage.d.ts +31 -0
- package/dist/render/shaders/cutoutCoverage.js +40 -0
- package/dist/render/shaders/depth.d.ts +25 -0
- package/dist/render/shaders/depth.js +69 -3
- package/dist/render/shaders/exposure.d.ts +52 -0
- package/dist/render/shaders/exposure.js +124 -0
- package/dist/render/shaders/filmLook.d.ts +47 -0
- package/dist/render/shaders/filmLook.js +98 -0
- package/dist/render/shaders/flat/directionalShadow.js +35 -4
- package/dist/render/shaders/flat/driftLight.d.ts +29 -0
- package/dist/render/shaders/flat/driftLight.js +75 -0
- package/dist/render/shaders/flat/index.js +5 -0
- package/dist/render/shaders/flat/lobes.js +13 -0
- package/dist/render/shaders/flat/main.d.ts +1 -1
- package/dist/render/shaders/flat/main.js +68 -9
- package/dist/render/shaders/flat/preamble.js +23 -5
- package/dist/render/shaders/flat/probeGrid.js +46 -3
- package/dist/render/shaders/generated/ambientOcclusion.wgsl.d.ts +1 -1
- package/dist/render/shaders/generated/ambientOcclusion.wgsl.js +1 -1
- package/dist/render/shaders/generated/depth.wgsl.d.ts +100 -0
- package/dist/render/shaders/generated/depth.wgsl.js +100 -0
- package/dist/render/shaders/generated/exposure.wgsl.d.ts +42 -0
- package/dist/render/shaders/generated/exposure.wgsl.js +48 -0
- package/dist/render/shaders/generated/flat.wgsl.d.ts +1800 -1344
- package/dist/render/shaders/generated/flat.wgsl.js +1816 -1360
- package/dist/render/shaders/generated/localExposure.wgsl.d.ts +18 -0
- package/dist/render/shaders/generated/localExposure.wgsl.js +24 -0
- package/dist/render/shaders/generated/rush.wgsl.d.ts +40 -10
- package/dist/render/shaders/generated/rush.wgsl.js +40 -10
- package/dist/render/shaders/generated/temporalResolve.wgsl.d.ts +1 -1
- package/dist/render/shaders/generated/temporalResolve.wgsl.js +1 -1
- package/dist/render/shaders/gi/driftLight.wgsl.d.ts +16 -0
- package/dist/render/shaders/gi/driftLight.wgsl.js +49 -0
- package/dist/render/shaders/gi/probeBake.wgsl.d.ts +29 -4
- package/dist/render/shaders/gi/probeBake.wgsl.js +190 -20
- package/dist/render/shaders/globalMedium.js +4 -0
- package/dist/render/shaders/localExposure.d.ts +35 -0
- package/dist/render/shaders/localExposure.js +138 -0
- package/dist/render/shaders/prefilter.d.ts +1 -1
- package/dist/render/shaders/prefilter.js +6 -0
- package/dist/render/shaders/rush.js +33 -5
- package/dist/render/shaders/tangentFrame.d.ts +1 -1
- package/dist/render/shaders/tangentFrame.js +15 -3
- package/dist/render/shaders/temporalResolve.js +39 -1
- package/dist/render/shadowCasters.d.ts +5 -3
- package/dist/render/shadowMap.js +13 -0
- package/dist/render/surfaceTexture.d.ts +5 -0
- package/package.json +6 -6
- package/scripts/gpuCompute.mjs +24 -3
- package/scripts/png.mjs +23 -12
- package/src/cinematic/cameraPath.ts +178 -0
- package/src/environment/daylight.ts +152 -0
- package/src/geometry/meshConcat.ts +100 -0
- package/src/index.ts +32 -0
- package/src/render/backend/createRenderer.ts +22 -6
- package/src/render/backend/webgl2/renderer.ts +462 -20
- package/src/render/backend/webgpu/buffers.ts +51 -8
- package/src/render/backend/webgpu/depthPass.ts +78 -10
- package/src/render/backend/webgpu/exposurePass.ts +234 -0
- package/src/render/backend/webgpu/fieldCompose.ts +15 -0
- package/src/render/backend/webgpu/flatPass.ts +10 -2
- package/src/render/backend/webgpu/particlePass.ts +9 -0
- package/src/render/backend/webgpu/probeBake.ts +143 -9
- package/src/render/backend/webgpu/probeTargets.ts +211 -0
- package/src/render/backend/webgpu/renderer.ts +807 -271
- package/src/render/backend/webgpu/scatterPass.ts +7 -7
- package/src/render/backend/webgpu/skinPaletteRing.ts +10 -0
- package/src/render/backend/webgpu/skyPass.ts +5 -1
- package/src/render/backend/webgpu/uniformRing.ts +49 -3
- package/src/render/bloomChain.ts +9 -0
- package/src/render/clusteredLights.ts +108 -63
- package/src/render/cutoutCaster.ts +44 -0
- package/src/render/driftLight/bake.ts +125 -0
- package/src/render/driftLight/falloff.ts +26 -0
- package/src/render/driftLight/layout.ts +177 -0
- package/src/render/driftLight/lightField.ts +227 -0
- package/src/render/driftLight/uniforms.ts +61 -0
- package/src/render/driftLight/visibility.ts +58 -0
- package/src/render/emptyTexture.ts +41 -0
- package/src/render/exposurePass.ts +186 -0
- package/src/render/flameLight.ts +135 -0
- package/src/render/gi/bounceLights.ts +68 -0
- package/src/render/gi/fieldReach.ts +74 -0
- package/src/render/gi/globalField.ts +39 -0
- package/src/render/gi/probeTrace.ts +3 -1
- package/src/render/gpuCapability.ts +18 -5
- package/src/render/lightBudget.ts +12 -0
- package/src/render/pointLightSelection.ts +191 -79
- package/src/render/probeGrid.ts +13 -2
- package/src/render/probeSweeps.ts +80 -0
- package/src/render/reflectionProbe.ts +54 -2
- package/src/render/renderQuality.ts +3 -1
- package/src/render/rendererHarness.ts +25 -3
- package/src/render/sceneTarget.ts +75 -1
- package/src/render/shaders/ambientOcclusion.ts +125 -63
- package/src/render/shaders/cutoutCoverage.ts +43 -0
- package/src/render/shaders/depth.ts +80 -3
- package/src/render/shaders/exposure.ts +128 -0
- package/src/render/shaders/filmLook.ts +111 -0
- package/src/render/shaders/flat/directionalShadow.ts +35 -4
- package/src/render/shaders/flat/driftLight.ts +75 -0
- package/src/render/shaders/flat/index.ts +5 -0
- package/src/render/shaders/flat/lobes.ts +13 -0
- package/src/render/shaders/flat/main.ts +68 -9
- package/src/render/shaders/flat/preamble.ts +23 -5
- package/src/render/shaders/flat/probeGrid.ts +46 -3
- package/src/render/shaders/generated/ambientOcclusion.wgsl.ts +1 -1
- package/src/render/shaders/generated/depth.wgsl.ts +103 -0
- package/src/render/shaders/generated/exposure.wgsl.ts +51 -0
- package/src/render/shaders/generated/flat.wgsl.ts +1816 -1360
- package/src/render/shaders/generated/localExposure.wgsl.ts +26 -0
- package/src/render/shaders/generated/rush.wgsl.ts +40 -10
- package/src/render/shaders/generated/temporalResolve.wgsl.ts +1 -1
- package/src/render/shaders/gi/driftLight.wgsl.ts +49 -0
- package/src/render/shaders/gi/probeBake.wgsl.ts +191 -20
- package/src/render/shaders/globalMedium.ts +4 -0
- package/src/render/shaders/localExposure.ts +154 -0
- package/src/render/shaders/prefilter.ts +6 -0
- package/src/render/shaders/rush.ts +33 -5
- package/src/render/shaders/tangentFrame.ts +15 -3
- package/src/render/shaders/temporalResolve.ts +39 -1
- package/src/render/shadowCasters.ts +5 -3
- package/src/render/shadowMap.ts +14 -0
- package/src/render/surfaceTexture.ts +5 -0
|
@@ -110,18 +110,18 @@ export function createScatterBindGroupLayout(device) {
|
|
|
110
110
|
label: 'scatter.layout',
|
|
111
111
|
entries: [
|
|
112
112
|
/*
|
|
113
|
-
* **
|
|
113
|
+
* **Both blocks are per draw.**
|
|
114
114
|
*
|
|
115
115
|
* The vertex block carries the gust and the trample field, and both arrive as arguments
|
|
116
116
|
* to `drawScatter` — so a scene bending its grass by one press field and its canopy by
|
|
117
117
|
* another gets whichever was written last for both, from a single buffer. That is not
|
|
118
118
|
* hypothetical elsewhere: the plume pass had exactly this and drew a flame bent by the
|
|
119
|
-
* smoke's wind response.
|
|
120
|
-
* batches the same wind and no trample, which is luck rather than a guarantee.
|
|
119
|
+
* smoke's wind response.
|
|
121
120
|
*
|
|
122
|
-
* The fragment block holds the camera, the sun, the point lights and the medium
|
|
123
|
-
* are settled per pass
|
|
124
|
-
*
|
|
121
|
+
* The fragment block holds the camera, the sun, the point lights and the medium. It was one
|
|
122
|
+
* buffer on the argument that those are settled per pass, and a pass is not an encoder: a
|
|
123
|
+
* probe records six passes in one, a mirror shares the frame's, and queue writes land before
|
|
124
|
+
* either runs. So every pass drew its blades from the last camera, and it is a ring too.
|
|
125
125
|
*/
|
|
126
126
|
{
|
|
127
127
|
binding: VERT_BINDING,
|
|
@@ -131,7 +131,7 @@ export function createScatterBindGroupLayout(device) {
|
|
|
131
131
|
{
|
|
132
132
|
binding: FRAG_BINDING,
|
|
133
133
|
visibility: VISIBILITY_FRAGMENT,
|
|
134
|
-
buffer: { type: 'uniform', minBindingSize: SCATTER_FRAG_SIZE },
|
|
134
|
+
buffer: { type: 'uniform', hasDynamicOffset: true, minBindingSize: SCATTER_FRAG_SIZE },
|
|
135
135
|
},
|
|
136
136
|
],
|
|
137
137
|
});
|
|
@@ -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 =
|
|
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/** Three vec4s a light, in exactly the record order `buildLightClusters` writes. */\n@group(0) @binding(1) var<storage, read> lights: array<vec4<f32>>;\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 = 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), bitcast<vec4<u32>>(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 = 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";
|
|
@@ -53,6 +53,10 @@ export declare class SkinPaletteRing {
|
|
|
53
53
|
* has just done — including for anything drawn after the previous `endFrame`.
|
|
54
54
|
*/
|
|
55
55
|
reset(): void;
|
|
56
|
+
/** Where the ring stands, to hand back to `rewind`. See `UniformRing.rewind` for when it is safe. */
|
|
57
|
+
mark(): number;
|
|
58
|
+
/** Give back every slot taken since `mark`, once the commands reading them are submitted. */
|
|
59
|
+
rewind(mark: number): void;
|
|
56
60
|
/**
|
|
57
61
|
* Upload a palette into a slot of its own and return that slot, or null when the ring is full.
|
|
58
62
|
*
|
|
@@ -58,6 +58,15 @@ export class SkinPaletteRing {
|
|
|
58
58
|
reset() {
|
|
59
59
|
this.used = 0;
|
|
60
60
|
}
|
|
61
|
+
/** Where the ring stands, to hand back to `rewind`. See `UniformRing.rewind` for when it is safe. */
|
|
62
|
+
mark() {
|
|
63
|
+
return this.used;
|
|
64
|
+
}
|
|
65
|
+
/** Give back every slot taken since `mark`, once the commands reading them are submitted. */
|
|
66
|
+
rewind(mark) {
|
|
67
|
+
if (mark < this.used)
|
|
68
|
+
this.used = mark;
|
|
69
|
+
}
|
|
61
70
|
/**
|
|
62
71
|
* Upload a palette into a slot of its own and return that slot, or null when the ring is full.
|
|
63
72
|
*
|
|
@@ -21,7 +21,11 @@ export function createSkyBindGroupLayout(device) {
|
|
|
21
21
|
{
|
|
22
22
|
binding: SKY_BINDING,
|
|
23
23
|
visibility: VISIBILITY_FRAGMENT,
|
|
24
|
-
|
|
24
|
+
/*
|
|
25
|
+
* A dynamic offset, because a frame draws more than one sky — a probe's six faces, a mirror
|
|
26
|
+
* and the view — and one buffer rewritten between them hands every draw the last write.
|
|
27
|
+
*/
|
|
28
|
+
buffer: { type: 'uniform', hasDynamicOffset: true, minBindingSize: SKY_UNIFORM_SIZE },
|
|
25
29
|
},
|
|
26
30
|
],
|
|
27
31
|
});
|
|
@@ -40,6 +40,11 @@ export declare class UniformRing {
|
|
|
40
40
|
private ints;
|
|
41
41
|
private capacity;
|
|
42
42
|
private used;
|
|
43
|
+
/**
|
|
44
|
+
* The byte below which the device already holds what the staging does: the end of the last
|
|
45
|
+
* flush, lowered by any write under it since. See `flush`.
|
|
46
|
+
*/
|
|
47
|
+
private clean;
|
|
43
48
|
/**
|
|
44
49
|
* `label` names the buffer on the device, and it is not decoration.
|
|
45
50
|
*
|
|
@@ -78,6 +83,20 @@ export declare class UniformRing {
|
|
|
78
83
|
get count(): number;
|
|
79
84
|
/** Start a frame. Slots are reused; nothing is freed. */
|
|
80
85
|
reset(): void;
|
|
86
|
+
/** Where the ring stands, to hand back to `rewind`. */
|
|
87
|
+
mark(): number;
|
|
88
|
+
/**
|
|
89
|
+
* Give back every slot taken since `mark`, for a caller that has already submitted the commands
|
|
90
|
+
* reading them.
|
|
91
|
+
*
|
|
92
|
+
* **Safe because the queue is ordered, and only then.** A `writeBuffer` issued after a submit
|
|
93
|
+
* cannot reach the commands in it, so slots a submitted encoder read may be rewritten by the next
|
|
94
|
+
* one. A slot taken by an encoder not yet submitted may not be: every draw pointing at it would
|
|
95
|
+
* read whatever overwrote it, which is the defect this class exists to prevent. What uses it is
|
|
96
|
+
* work that submits encoders of its own mid-frame — a probe bake — so the frame's rings are not
|
|
97
|
+
* spent by something the frame never draws.
|
|
98
|
+
*/
|
|
99
|
+
rewind(mark: number): void;
|
|
81
100
|
/**
|
|
82
101
|
* Take the next slot and return its byte offset, or null when the ring is full.
|
|
83
102
|
*
|
|
@@ -127,7 +146,18 @@ export declare class UniformRing {
|
|
|
127
146
|
* `Int32Array.set` from an `Int32Array` is an exact copy of every bit, whatever the bits mean.
|
|
128
147
|
*/
|
|
129
148
|
writeBlock(slot: number, block: Int32Array): void;
|
|
130
|
-
/**
|
|
149
|
+
/**
|
|
150
|
+
* Upload every slot taken this frame that the device does not already hold, in one write.
|
|
151
|
+
*
|
|
152
|
+
* **From the watermark, not from zero, because a frame flushes more than once.** Every submit in
|
|
153
|
+
* the middle of a frame flushes first, and a probe bake submits per face: sending the whole used
|
|
154
|
+
* range each time sent a courtyard's first slots two hundred times over, 2.3 GB in a two-second
|
|
155
|
+
* grid bake, measured. Queue writes are ordered, so bytes already sent and not written since are
|
|
156
|
+
* on the device for every command after them, and resending them changed nothing.
|
|
157
|
+
*
|
|
158
|
+
* What it gives up is one compare per write. What would make it wrong is a write that reached the
|
|
159
|
+
* staging without passing a method that lowers `clean` — which is why the staging stays private.
|
|
160
|
+
*/
|
|
131
161
|
flush(): void;
|
|
132
162
|
dispose(): void;
|
|
133
163
|
}
|
|
@@ -54,6 +54,11 @@ export class UniformRing {
|
|
|
54
54
|
ints;
|
|
55
55
|
capacity;
|
|
56
56
|
used = 0;
|
|
57
|
+
/**
|
|
58
|
+
* The byte below which the device already holds what the staging does: the end of the last
|
|
59
|
+
* flush, lowered by any write under it since. See `flush`.
|
|
60
|
+
*/
|
|
61
|
+
clean = 0;
|
|
57
62
|
/**
|
|
58
63
|
* `label` names the buffer on the device, and it is not decoration.
|
|
59
64
|
*
|
|
@@ -112,6 +117,7 @@ export class UniformRing {
|
|
|
112
117
|
return false;
|
|
113
118
|
this.buffer.destroy();
|
|
114
119
|
this.capacity = slots;
|
|
120
|
+
this.clean = 0;
|
|
115
121
|
this.staging = new ArrayBuffer(bytes);
|
|
116
122
|
this.floats = new Float32Array(this.staging);
|
|
117
123
|
this.ints = new Int32Array(this.staging);
|
|
@@ -129,6 +135,28 @@ export class UniformRing {
|
|
|
129
135
|
/** Start a frame. Slots are reused; nothing is freed. */
|
|
130
136
|
reset() {
|
|
131
137
|
this.used = 0;
|
|
138
|
+
this.clean = 0;
|
|
139
|
+
}
|
|
140
|
+
/** Where the ring stands, to hand back to `rewind`. */
|
|
141
|
+
mark() {
|
|
142
|
+
return this.used;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Give back every slot taken since `mark`, for a caller that has already submitted the commands
|
|
146
|
+
* reading them.
|
|
147
|
+
*
|
|
148
|
+
* **Safe because the queue is ordered, and only then.** A `writeBuffer` issued after a submit
|
|
149
|
+
* cannot reach the commands in it, so slots a submitted encoder read may be rewritten by the next
|
|
150
|
+
* one. A slot taken by an encoder not yet submitted may not be: every draw pointing at it would
|
|
151
|
+
* read whatever overwrote it, which is the defect this class exists to prevent. What uses it is
|
|
152
|
+
* work that submits encoders of its own mid-frame — a probe bake — so the frame's rings are not
|
|
153
|
+
* spent by something the frame never draws.
|
|
154
|
+
*/
|
|
155
|
+
rewind(mark) {
|
|
156
|
+
if (mark < this.used)
|
|
157
|
+
this.used = mark;
|
|
158
|
+
if (this.used * this.slotSize < this.clean)
|
|
159
|
+
this.clean = this.used * this.slotSize;
|
|
132
160
|
}
|
|
133
161
|
/**
|
|
134
162
|
* Take the next slot and return its byte offset, or null when the ring is full.
|
|
@@ -164,6 +192,8 @@ export class UniformRing {
|
|
|
164
192
|
}
|
|
165
193
|
/** Write floats into a slot, at a byte offset within it. */
|
|
166
194
|
writeFloats(slot, byteOffset, values) {
|
|
195
|
+
if (slot < this.clean)
|
|
196
|
+
this.clean = slot;
|
|
167
197
|
this.floats.set(values, (slot + byteOffset) / 4);
|
|
168
198
|
}
|
|
169
199
|
/**
|
|
@@ -175,10 +205,14 @@ export class UniformRing {
|
|
|
175
205
|
* buffer being reused.
|
|
176
206
|
*/
|
|
177
207
|
writeFloat(slot, byteOffset, value) {
|
|
208
|
+
if (slot < this.clean)
|
|
209
|
+
this.clean = slot;
|
|
178
210
|
this.floats[(slot + byteOffset) / 4] = value;
|
|
179
211
|
}
|
|
180
212
|
/** Write one integer into a slot. Separate because a `Float32Array` cannot hold an `i32`. */
|
|
181
213
|
writeInt(slot, byteOffset, value) {
|
|
214
|
+
if (slot < this.clean)
|
|
215
|
+
this.clean = slot;
|
|
182
216
|
this.ints[(slot + byteOffset) / 4] = value;
|
|
183
217
|
}
|
|
184
218
|
/**
|
|
@@ -194,13 +228,29 @@ export class UniformRing {
|
|
|
194
228
|
* `Int32Array.set` from an `Int32Array` is an exact copy of every bit, whatever the bits mean.
|
|
195
229
|
*/
|
|
196
230
|
writeBlock(slot, block) {
|
|
231
|
+
if (slot < this.clean)
|
|
232
|
+
this.clean = slot;
|
|
197
233
|
this.ints.set(block, slot / 4);
|
|
198
234
|
}
|
|
199
|
-
/**
|
|
235
|
+
/**
|
|
236
|
+
* Upload every slot taken this frame that the device does not already hold, in one write.
|
|
237
|
+
*
|
|
238
|
+
* **From the watermark, not from zero, because a frame flushes more than once.** Every submit in
|
|
239
|
+
* the middle of a frame flushes first, and a probe bake submits per face: sending the whole used
|
|
240
|
+
* range each time sent a courtyard's first slots two hundred times over, 2.3 GB in a two-second
|
|
241
|
+
* grid bake, measured. Queue writes are ordered, so bytes already sent and not written since are
|
|
242
|
+
* on the device for every command after them, and resending them changed nothing.
|
|
243
|
+
*
|
|
244
|
+
* What it gives up is one compare per write. What would make it wrong is a write that reached the
|
|
245
|
+
* staging without passing a method that lowers `clean` — which is why the staging stays private.
|
|
246
|
+
*/
|
|
200
247
|
flush() {
|
|
201
|
-
|
|
248
|
+
const end = this.used * this.slotSize;
|
|
249
|
+
if (end <= this.clean)
|
|
202
250
|
return;
|
|
203
|
-
|
|
251
|
+
const from = this.clean;
|
|
252
|
+
this.device.queue.writeBuffer(this.buffer, from, this.staging, from, end - from);
|
|
253
|
+
this.clean = end;
|
|
204
254
|
}
|
|
205
255
|
dispose() {
|
|
206
256
|
this.buffer.destroy();
|
|
@@ -45,6 +45,12 @@ export declare const BLOOM_FILTER_RADIUS_UV = 0.005;
|
|
|
45
45
|
* an exposure with no curve to be exposed into — where everything is switched on, nothing is
|
|
46
46
|
* wrong, and nothing happens.
|
|
47
47
|
*/
|
|
48
|
+
/**
|
|
49
|
+
* A threshold a frame asked for through `setBloom`, made safe: at least a thousandth of a scene
|
|
50
|
+
* unit, because zero blooms the whole frame including what is meant to be dark, and a
|
|
51
|
+
* non-number would reach the shader as one. Decided here so both backends answer the same.
|
|
52
|
+
*/
|
|
53
|
+
export declare function bloomThresholdOf(threshold: number): number;
|
|
48
54
|
export declare function bloomProfileWarning(quality: {
|
|
49
55
|
readonly bloom: number;
|
|
50
56
|
readonly screenEffects: boolean;
|
|
@@ -45,6 +45,14 @@ export const BLOOM_FILTER_RADIUS_UV = 0.005;
|
|
|
45
45
|
* an exposure with no curve to be exposed into — where everything is switched on, nothing is
|
|
46
46
|
* wrong, and nothing happens.
|
|
47
47
|
*/
|
|
48
|
+
/**
|
|
49
|
+
* A threshold a frame asked for through `setBloom`, made safe: at least a thousandth of a scene
|
|
50
|
+
* unit, because zero blooms the whole frame including what is meant to be dark, and a
|
|
51
|
+
* non-number would reach the shader as one. Decided here so both backends answer the same.
|
|
52
|
+
*/
|
|
53
|
+
export function bloomThresholdOf(threshold) {
|
|
54
|
+
return Number.isFinite(threshold) ? Math.max(threshold, 1e-3) : 1e-3;
|
|
55
|
+
}
|
|
48
56
|
export function bloomProfileWarning(quality) {
|
|
49
57
|
if (quality.bloom <= 0)
|
|
50
58
|
return null;
|
|
@@ -21,26 +21,34 @@ export declare const CLUSTER_Y = 9;
|
|
|
21
21
|
export declare const CLUSTER_Z = 24;
|
|
22
22
|
export declare const CLUSTER_COUNT: number;
|
|
23
23
|
/**
|
|
24
|
-
* Texels a cluster occupies: one for its count,
|
|
24
|
+
* Texels a cluster occupies: one for its count, nineteen holding four light indices each.
|
|
25
25
|
*
|
|
26
26
|
* **Fixed rather than packed, and that is what removes the atomics.** One invocation owns one
|
|
27
27
|
* cluster and writes only its own run, so nothing is appended to a shared list, there is no
|
|
28
28
|
* counter to increment atomically and no compaction pass. That is also what lets the GPU binner
|
|
29
29
|
* write through a *write-only* storage texture, which is all core WGSL gives.
|
|
30
30
|
*/
|
|
31
|
-
export declare const CLUSTER_TEXELS =
|
|
31
|
+
export declare const CLUSTER_TEXELS = 20;
|
|
32
32
|
/**
|
|
33
|
-
* How many lights one cluster can hold
|
|
33
|
+
* How many lights one cluster can hold: nineteen index texels at four indices each, 76.
|
|
34
34
|
*
|
|
35
|
-
* **Sixteen
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
35
|
+
* **Sixteen until a candlelit interior drew its froxels as rectangles.** The cap was pinned to
|
|
36
|
+
* `MAX_POINT_LIGHTS` so the one light loop kept one bound. Then a courtyard of candles, lanterns
|
|
37
|
+
* and braziers asked 461 of its 3,456 froxels for more than sixteen, and up to fifty. A full froxel
|
|
38
|
+
* keeps its nearest and drops the rest, and its neighbour keeps a different set, so the step
|
|
39
|
+
* between them drew as hard-edged, screen-aligned rectangles on every vault, on both backends.
|
|
40
|
+
*
|
|
41
|
+
* Seventy-six because it is the next cap the table's shape allows above what was measured: a run
|
|
42
|
+
* must divide the 320-texel row, and the two regions must end on one. What it costs is the table,
|
|
43
|
+
* 292 KB to 1.1 MB, and the loop's bound. The bound is the clustered arm's alone in effect: the
|
|
44
|
+
* fixed arm still leaves at its own count and never passes `MAX_LIGHTS`, so a consumer that does
|
|
45
|
+
* not cluster iterates what it did. What would make 76 wrong is content that asks more of one
|
|
46
|
+
* froxel, and the answer then is the next cap rather than a softer overflow: any overflow rule
|
|
47
|
+
* draws the cluster's edge.
|
|
40
48
|
*
|
|
41
49
|
* **It is not the old limit wearing a new hat.** Sixteen was a budget for the *whole scene*, chosen
|
|
42
|
-
* by distance with a contention band because somebody always had to be last.
|
|
43
|
-
*
|
|
50
|
+
* by distance with a contention band because somebody always had to be last. This is a different
|
|
51
|
+
* set in every froxel, out of `MAX_CLUSTERED_LIGHTS` in the scene.
|
|
44
52
|
*/
|
|
45
53
|
export declare const MAX_LIGHTS_PER_CLUSTER: number;
|
|
46
54
|
/**
|
|
@@ -149,7 +157,7 @@ export declare const LIGHT_REGION_TEXELS: number;
|
|
|
149
157
|
* beginning of a row.
|
|
150
158
|
*/
|
|
151
159
|
export declare const TABLE_WIDTH = 320;
|
|
152
|
-
/** Rows:
|
|
160
|
+
/** Rows: five of light records and 216 of cluster runs. 1.1 MB of `RGBA32UI`, allocated once. */
|
|
153
161
|
export declare const TABLE_HEIGHT: number;
|
|
154
162
|
/** The table, as the `Uint32Array` both a texture upload and a readback comparison want. */
|
|
155
163
|
export declare function createClusterTable(): Uint32Array;
|
|
@@ -21,26 +21,34 @@ export const CLUSTER_Y = 9;
|
|
|
21
21
|
export const CLUSTER_Z = 24;
|
|
22
22
|
export const CLUSTER_COUNT = CLUSTER_X * CLUSTER_Y * CLUSTER_Z;
|
|
23
23
|
/**
|
|
24
|
-
* Texels a cluster occupies: one for its count,
|
|
24
|
+
* Texels a cluster occupies: one for its count, nineteen holding four light indices each.
|
|
25
25
|
*
|
|
26
26
|
* **Fixed rather than packed, and that is what removes the atomics.** One invocation owns one
|
|
27
27
|
* cluster and writes only its own run, so nothing is appended to a shared list, there is no
|
|
28
28
|
* counter to increment atomically and no compaction pass. That is also what lets the GPU binner
|
|
29
29
|
* write through a *write-only* storage texture, which is all core WGSL gives.
|
|
30
30
|
*/
|
|
31
|
-
export const CLUSTER_TEXELS =
|
|
31
|
+
export const CLUSTER_TEXELS = 20;
|
|
32
32
|
/**
|
|
33
|
-
* How many lights one cluster can hold
|
|
33
|
+
* How many lights one cluster can hold: nineteen index texels at four indices each, 76.
|
|
34
34
|
*
|
|
35
|
-
* **Sixteen
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
35
|
+
* **Sixteen until a candlelit interior drew its froxels as rectangles.** The cap was pinned to
|
|
36
|
+
* `MAX_POINT_LIGHTS` so the one light loop kept one bound. Then a courtyard of candles, lanterns
|
|
37
|
+
* and braziers asked 461 of its 3,456 froxels for more than sixteen, and up to fifty. A full froxel
|
|
38
|
+
* keeps its nearest and drops the rest, and its neighbour keeps a different set, so the step
|
|
39
|
+
* between them drew as hard-edged, screen-aligned rectangles on every vault, on both backends.
|
|
40
|
+
*
|
|
41
|
+
* Seventy-six because it is the next cap the table's shape allows above what was measured: a run
|
|
42
|
+
* must divide the 320-texel row, and the two regions must end on one. What it costs is the table,
|
|
43
|
+
* 292 KB to 1.1 MB, and the loop's bound. The bound is the clustered arm's alone in effect: the
|
|
44
|
+
* fixed arm still leaves at its own count and never passes `MAX_LIGHTS`, so a consumer that does
|
|
45
|
+
* not cluster iterates what it did. What would make 76 wrong is content that asks more of one
|
|
46
|
+
* froxel, and the answer then is the next cap rather than a softer overflow: any overflow rule
|
|
47
|
+
* draws the cluster's edge.
|
|
40
48
|
*
|
|
41
49
|
* **It is not the old limit wearing a new hat.** Sixteen was a budget for the *whole scene*, chosen
|
|
42
|
-
* by distance with a contention band because somebody always had to be last.
|
|
43
|
-
*
|
|
50
|
+
* by distance with a contention band because somebody always had to be last. This is a different
|
|
51
|
+
* set in every froxel, out of `MAX_CLUSTERED_LIGHTS` in the scene.
|
|
44
52
|
*/
|
|
45
53
|
export const MAX_LIGHTS_PER_CLUSTER = (CLUSTER_TEXELS - 1) * 4;
|
|
46
54
|
/**
|
|
@@ -149,7 +157,7 @@ export const LIGHT_REGION_TEXELS = MAX_CLUSTERED_LIGHTS * LIGHT_TEXELS;
|
|
|
149
157
|
* beginning of a row.
|
|
150
158
|
*/
|
|
151
159
|
export const TABLE_WIDTH = 320;
|
|
152
|
-
/** Rows:
|
|
160
|
+
/** Rows: five of light records and 216 of cluster runs. 1.1 MB of `RGBA32UI`, allocated once. */
|
|
153
161
|
export const TABLE_HEIGHT = (LIGHT_REGION_TEXELS + CLUSTER_COUNT * CLUSTER_TEXELS) / TABLE_WIDTH;
|
|
154
162
|
/** The table, as the `Uint32Array` both a texture upload and a readback comparison want. */
|
|
155
163
|
export function createClusterTable() {
|
|
@@ -216,11 +224,37 @@ export function clusterViewBounds(i, j, k, near, far, tanHalfFovY, aspect, out)
|
|
|
216
224
|
out[2] = zNear;
|
|
217
225
|
out[5] = zFar;
|
|
218
226
|
}
|
|
227
|
+
/**
|
|
228
|
+
* `clusterViewBounds` for a slice whose depths and half extents the caller already has. The same
|
|
229
|
+
* arithmetic in the same order, so the same box to the bit; the binner's inner loop uses this.
|
|
230
|
+
*/
|
|
231
|
+
function sliceBounds(i, j, zNear, zFar, halfWNear, halfWFar, halfHNear, halfHFar, out) {
|
|
232
|
+
const xLo = -1 + (2 * i) / CLUSTER_X;
|
|
233
|
+
const xHi = -1 + (2 * (i + 1)) / CLUSTER_X;
|
|
234
|
+
const yLo = -1 + (2 * j) / CLUSTER_Y;
|
|
235
|
+
const yHi = -1 + (2 * (j + 1)) / CLUSTER_Y;
|
|
236
|
+
out[0] = Math.min(xLo * halfWNear, xLo * halfWFar, xHi * halfWNear, xHi * halfWFar);
|
|
237
|
+
out[3] = Math.max(xLo * halfWNear, xLo * halfWFar, xHi * halfWNear, xHi * halfWFar);
|
|
238
|
+
out[1] = Math.min(yLo * halfHNear, yLo * halfHFar, yHi * halfHNear, yHi * halfHFar);
|
|
239
|
+
out[4] = Math.max(yLo * halfHNear, yLo * halfHFar, yHi * halfHNear, yHi * halfHFar);
|
|
240
|
+
out[2] = zNear;
|
|
241
|
+
out[5] = zFar;
|
|
242
|
+
}
|
|
219
243
|
/*
|
|
220
244
|
* Scratch, at module scope, because `buildLightClusters` runs per frame on WebGL2 and the house
|
|
221
245
|
* rule forbids allocating there.
|
|
222
246
|
*/
|
|
223
247
|
const bounds = new Float32Array(6);
|
|
248
|
+
/**
|
|
249
|
+
* Where each slice begins, for the frame being binned: `sliceNearDepth` for every slice and the
|
|
250
|
+
* far plane, computed once. The same expression as a call per cluster, so the same numbers.
|
|
251
|
+
*
|
|
252
|
+
* **Once a binning rather than once a cluster**, which was two `pow` for every light and every
|
|
253
|
+
* cluster it might touch. A courtyard of candles within a few metres of the camera is 640,000 of
|
|
254
|
+
* those pairs, and the binning took 47 ms of a frame. On WebGL2 it runs every `bindMeshPass`, so a
|
|
255
|
+
* probe grid baked at night binned for a minute.
|
|
256
|
+
*/
|
|
257
|
+
const sliceDepths = new Float64Array(CLUSTER_Z + 1);
|
|
224
258
|
/** View-space light centres, kept for the overflow rule, which needs them after the fact. */
|
|
225
259
|
const viewX = new Float32Array(MAX_CLUSTERED_LIGHTS);
|
|
226
260
|
const viewY = new Float32Array(MAX_CLUSTERED_LIGHTS);
|
|
@@ -247,18 +281,6 @@ function distanceSqToBounds(x, y, z, b) {
|
|
|
247
281
|
const dz = z < (b[2] ?? 0) ? (b[2] ?? 0) - z : z > (b[5] ?? 0) ? z - (b[5] ?? 0) : 0;
|
|
248
282
|
return dx * dx + dy * dy + dz * dz;
|
|
249
283
|
}
|
|
250
|
-
/** Insertion sort over a cluster's index slots. At most 28 integers, so this is not a hot loop. */
|
|
251
|
-
function sortIndices(table, base, count) {
|
|
252
|
-
for (let n = 1; n < count; n++) {
|
|
253
|
-
const value = table[base + 4 + n] ?? 0;
|
|
254
|
-
let m = n - 1;
|
|
255
|
-
while (m >= 0 && (table[base + 4 + m] ?? 0) > value) {
|
|
256
|
-
table[base + 4 + m + 1] = table[base + 4 + m] ?? 0;
|
|
257
|
-
m--;
|
|
258
|
-
}
|
|
259
|
-
table[base + 4 + m + 1] = value;
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
284
|
/**
|
|
263
285
|
* Fill the table with this frame's light assignment, and answer how many lights it carried.
|
|
264
286
|
*
|
|
@@ -317,6 +339,8 @@ export function buildLightClusters(lights, view, near, far, tanHalfFovY, aspect,
|
|
|
317
339
|
* replaces; agreeing about the invisible bytes is what keeps the comparison exact.
|
|
318
340
|
*/
|
|
319
341
|
table.fill(0, LIGHT_REGION_TEXELS * 4);
|
|
342
|
+
for (let k = 0; k <= CLUSTER_Z; k++)
|
|
343
|
+
sliceDepths[k] = sliceNearDepth(k, near, far);
|
|
320
344
|
for (let light = 0; light < count; light++) {
|
|
321
345
|
const x = lights.positions[light * 3] ?? 0;
|
|
322
346
|
const y = lights.positions[light * 3 + 1] ?? 0;
|
|
@@ -404,8 +428,10 @@ export function buildLightClusters(lights, view, near, far, tanHalfFovY, aspect,
|
|
|
404
428
|
const radiusSq = radius * radius;
|
|
405
429
|
for (let k = kLo; k <= kHi; k++) {
|
|
406
430
|
/* The same two depths `clusterViewBounds` builds this slice's box from. */
|
|
407
|
-
const
|
|
408
|
-
const
|
|
431
|
+
const zNear = sliceDepths[k] ?? 0;
|
|
432
|
+
const zFar = sliceDepths[k + 1] ?? 0;
|
|
433
|
+
const halfHNear = zNear * tanHalfFovY;
|
|
434
|
+
const halfHFar = zFar * tanHalfFovY;
|
|
409
435
|
const halfWNear = halfHNear * aspect;
|
|
410
436
|
const halfWFar = halfHFar * aspect;
|
|
411
437
|
const iLo = tileOf(Math.min(xLo / halfWNear, xLo / halfWFar, xHi / halfWNear, xHi / halfWFar), CLUSTER_X);
|
|
@@ -414,10 +440,10 @@ export function buildLightClusters(lights, view, near, far, tanHalfFovY, aspect,
|
|
|
414
440
|
const jHi = tileOf(Math.max(yLo / halfHNear, yLo / halfHFar, yHi / halfHNear, yHi / halfHFar), CLUSTER_Y);
|
|
415
441
|
for (let j = jLo; j <= jHi; j++) {
|
|
416
442
|
for (let i = iLo; i <= iHi; i++) {
|
|
417
|
-
|
|
443
|
+
sliceBounds(i, j, zNear, zFar, halfWNear, halfWFar, halfHNear, halfHFar, bounds);
|
|
418
444
|
if (distanceSqToBounds(vx, vy, depth, bounds) > radiusSq)
|
|
419
445
|
continue;
|
|
420
|
-
insert(table, i + j * CLUSTER_X + k * CLUSTER_X * CLUSTER_Y, light,
|
|
446
|
+
insert(table, i + j * CLUSTER_X + k * CLUSTER_X * CLUSTER_Y, light, bounds);
|
|
421
447
|
}
|
|
422
448
|
}
|
|
423
449
|
}
|
|
@@ -430,37 +456,43 @@ function tileOf(ndc, tiles) {
|
|
|
430
456
|
return tile < 0 ? 0 : tile > tiles - 1 ? tiles - 1 : tile;
|
|
431
457
|
}
|
|
432
458
|
/**
|
|
433
|
-
*
|
|
459
|
+
* Each member's squared distance from its cluster's centre, slot for slot with the table, for the
|
|
460
|
+
* frame being binned. Allocated on the first binning, so a consumer that never clusters pays nothing.
|
|
461
|
+
*
|
|
462
|
+
* **Measured rather than assumed rare.** The overflow rule used to recompute the cluster's box and
|
|
463
|
+
* all sixteen members' distances on every insert into a full cluster, on the argument that full
|
|
464
|
+
* clusters are rare. Three hundred candles within a few metres of the camera fill 2,152 of the
|
|
465
|
+
* 3,456, and the binning spent 47 ms a frame there. Each distance is the same number whenever it is
|
|
466
|
+
* computed, so it is computed once, at the insert that placed the member.
|
|
467
|
+
*/
|
|
468
|
+
let memberDistance = null;
|
|
469
|
+
/**
|
|
470
|
+
* Put a light into a cluster, applying the overflow rule. `box` is the cluster's bounds, which the
|
|
471
|
+
* caller has just tested the light against.
|
|
434
472
|
*
|
|
435
473
|
* Lights arrive in increasing index, so the common path appends and the list is already sorted.
|
|
436
474
|
* The sort only runs on the rare path where a replacement broke that order.
|
|
437
475
|
*/
|
|
438
|
-
function insert(table, cluster, light,
|
|
476
|
+
function insert(table, cluster, light, box) {
|
|
439
477
|
const base = clusterBase(cluster);
|
|
440
478
|
const count = table[base] ?? 0;
|
|
479
|
+
const distances = (memberDistance ??= new Float64Array(CLUSTER_COUNT * MAX_LIGHTS_PER_CLUSTER));
|
|
480
|
+
const at = cluster * MAX_LIGHTS_PER_CLUSTER;
|
|
481
|
+
const cx = ((box[0] ?? 0) + (box[3] ?? 0)) * 0.5;
|
|
482
|
+
const cy = ((box[1] ?? 0) + (box[4] ?? 0)) * 0.5;
|
|
483
|
+
const cz = ((box[2] ?? 0) + (box[5] ?? 0)) * 0.5;
|
|
484
|
+
const mine = centreDistanceSq(light, cx, cy, cz);
|
|
441
485
|
if (count < MAX_LIGHTS_PER_CLUSTER) {
|
|
442
486
|
table[base + 4 + count] = light;
|
|
487
|
+
distances[at + count] = mine;
|
|
443
488
|
table[base] = count + 1;
|
|
444
489
|
return;
|
|
445
490
|
}
|
|
446
|
-
/*
|
|
447
|
-
* Full. Drop the farthest member if this one is nearer, measured from the cluster's own centre.
|
|
448
|
-
* Rare enough that recomputing the members' distances beats carrying 96,768 floats of scratch
|
|
449
|
-
* for a case most frames never reach.
|
|
450
|
-
*/
|
|
451
|
-
const k = Math.floor(cluster / (CLUSTER_X * CLUSTER_Y));
|
|
452
|
-
const j = Math.floor((cluster - k * CLUSTER_X * CLUSTER_Y) / CLUSTER_X);
|
|
453
|
-
const i = cluster - k * CLUSTER_X * CLUSTER_Y - j * CLUSTER_X;
|
|
454
|
-
clusterViewBounds(i, j, k, near, far, tanHalfFovY, aspect, bounds);
|
|
455
|
-
const cx = ((bounds[0] ?? 0) + (bounds[3] ?? 0)) * 0.5;
|
|
456
|
-
const cy = ((bounds[1] ?? 0) + (bounds[4] ?? 0)) * 0.5;
|
|
457
|
-
const cz = ((bounds[2] ?? 0) + (bounds[5] ?? 0)) * 0.5;
|
|
458
|
-
const mine = centreDistanceSq(light, cx, cy, cz);
|
|
491
|
+
/* Full. Drop the farthest member if this one is nearer, measured from the cluster's own centre. */
|
|
459
492
|
let worstSlot = -1;
|
|
460
493
|
let worst = mine;
|
|
461
494
|
for (let n = 0; n < MAX_LIGHTS_PER_CLUSTER; n++) {
|
|
462
|
-
const
|
|
463
|
-
const d = centreDistanceSq(held, cx, cy, cz);
|
|
495
|
+
const d = distances[at + n] ?? 0;
|
|
464
496
|
if (d > worst) {
|
|
465
497
|
worst = d;
|
|
466
498
|
worstSlot = n;
|
|
@@ -469,7 +501,26 @@ function insert(table, cluster, light, near, far, tanHalfFovY, aspect) {
|
|
|
469
501
|
if (worstSlot < 0)
|
|
470
502
|
return;
|
|
471
503
|
table[base + 4 + worstSlot] = light;
|
|
472
|
-
|
|
504
|
+
distances[at + worstSlot] = mine;
|
|
505
|
+
sortMembers(table, base, distances, at);
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Sort a full cluster's index slots ascending, carrying each member's distance with it. An insertion
|
|
509
|
+
* sort: sixteen integers, so this is not a hot loop.
|
|
510
|
+
*/
|
|
511
|
+
function sortMembers(table, base, distances, at) {
|
|
512
|
+
for (let n = 1; n < MAX_LIGHTS_PER_CLUSTER; n++) {
|
|
513
|
+
const value = table[base + 4 + n] ?? 0;
|
|
514
|
+
const distance = distances[at + n] ?? 0;
|
|
515
|
+
let m = n - 1;
|
|
516
|
+
while (m >= 0 && (table[base + 4 + m] ?? 0) > value) {
|
|
517
|
+
table[base + 4 + m + 1] = table[base + 4 + m] ?? 0;
|
|
518
|
+
distances[at + m + 1] = distances[at + m] ?? 0;
|
|
519
|
+
m--;
|
|
520
|
+
}
|
|
521
|
+
table[base + 4 + m + 1] = value;
|
|
522
|
+
distances[at + m + 1] = distance;
|
|
523
|
+
}
|
|
473
524
|
}
|
|
474
525
|
function centreDistanceSq(light, cx, cy, cz) {
|
|
475
526
|
const dx = (viewX[light] ?? 0) - cx;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which shadow casters cut their shadow out of their texture's alpha, decided once for both backends.
|
|
3
|
+
*
|
|
4
|
+
* **A caster with an albedo and a cutoff above zero is a cutout**: a leaf card, a chain link, a
|
|
5
|
+
* fence, whose shape is in its alpha and not in its geometry. The same test the colour pass makes
|
|
6
|
+
* when it discards, so a surface that is a hole in the picture is a hole in its shadow. Written once,
|
|
7
|
+
* here, because two backends deciding it separately is how they come to disagree about a leaf
|
|
8
|
+
* (AGENTS.md, 2026-08-13).
|
|
9
|
+
*
|
|
10
|
+
* Called per draw, so the answer is written into one object and returned again: a draw allocates
|
|
11
|
+
* nothing. What that gives up is holding two answers at once, which no caller does; the depth pass
|
|
12
|
+
* reads it and binds before it asks again.
|
|
13
|
+
*/
|
|
14
|
+
import type { SurfaceMaterial } from './surfaceTexture.ts';
|
|
15
|
+
/** A cutout caster's texture, its cutoff and its UV scale. */
|
|
16
|
+
export interface CutoutCaster<Texture> {
|
|
17
|
+
readonly albedo: Texture;
|
|
18
|
+
readonly cutoff: number;
|
|
19
|
+
readonly u: number;
|
|
20
|
+
readonly v: number;
|
|
21
|
+
}
|
|
22
|
+
/** The cutout `material` casts, or null for an ordinary caster. */
|
|
23
|
+
export declare function cutoutOf<Texture>(material: SurfaceMaterial<Texture> | null | undefined): CutoutCaster<Texture> | null;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const answer = {
|
|
2
|
+
albedo: null,
|
|
3
|
+
cutoff: 0,
|
|
4
|
+
u: 1,
|
|
5
|
+
v: 1,
|
|
6
|
+
};
|
|
7
|
+
/** The cutout `material` casts, or null for an ordinary caster. */
|
|
8
|
+
export function cutoutOf(material) {
|
|
9
|
+
if (material === null || material === undefined)
|
|
10
|
+
return null;
|
|
11
|
+
const albedo = material.albedo ?? null;
|
|
12
|
+
const cutoff = material.cutout ?? 0;
|
|
13
|
+
if (albedo === null || !(cutoff > 0))
|
|
14
|
+
return null;
|
|
15
|
+
answer.albedo = albedo;
|
|
16
|
+
answer.cutoff = cutoff;
|
|
17
|
+
answer.u = material.uScale ?? 1;
|
|
18
|
+
answer.v = material.vScale ?? 1;
|
|
19
|
+
return answer;
|
|
20
|
+
}
|