@plasius/gpu-lighting 0.2.3 → 0.2.5

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.md CHANGED
@@ -20,19 +20,22 @@ The format is based on **[Keep a Changelog](https://keepachangelog.com/en/1.1.0/
20
20
  - **Security**
21
21
  - (placeholder)
22
22
 
23
- ## [0.2.3] - 2026-06-14
23
+ ## [0.2.5] - 2026-06-15
24
24
 
25
25
  - **Added**
26
- - (placeholder)
26
+ - Added concrete volumetric WGSL kernels for `volumetricShadow` and
27
+ `froxelIntegrate`, covering froxel shadow history plus scattering/extinction
28
+ integration for the published realtime and reference profiles.
29
+ - Added concrete HDRI/IBL WGSL kernels for `irradianceConvolution`,
30
+ `specularPrefilter`, and `brdfLut`.
27
31
 
28
32
  - **Changed**
29
- - (placeholder)
33
+ - README now documents the delivered volumetrics and HDRI kernel scope with
34
+ technique-level descriptions instead of leaving those exported jobs implied.
30
35
 
31
36
  - **Fixed**
32
- - (placeholder)
33
-
34
- - **Security**
35
- - (placeholder)
37
+ - Package tests now fail if any exported `hybrid`, `volumetrics`, or `hdri`
38
+ job regresses to placeholder text or an empty/no-op `process_job` body.
36
39
 
37
40
  ## [0.2.2] - 2026-06-11
38
41
 
@@ -43,14 +46,86 @@ The format is based on **[Keep a Changelog](https://keepachangelog.com/en/1.1.0/
43
46
  - Added `sunlitBaseline` to environment lighting presets and wavefront
44
47
  lighting options so renderers can apply a time-of-day daylight floor at
45
48
  terminal path collisions without raising ambient residual colour.
46
- - (placeholder)
49
+ - Added an Eames screenshot capture runbook and browser-runtime helpers so
50
+ validation scripts can attach to an existing WebGPU-capable Chrome over CDP
51
+ instead of only launching a fresh Playwright Chromium profile.
52
+ - Added generic glTF material forwarding in the Eames validation loader so
53
+ authored specular, sheen, clearcoat, transmission, emissive, and IOR values
54
+ can flow into the shared wavefront renderer without model-name overrides.
55
+ - Added Eames validation HUD/result diagnostics for GPU worker-job throughput
56
+ so validation runs can report compute-dispatch jobs per frame, per second,
57
+ and per command submission.
58
+ - Added adaptive Eames validation frame budgeting so motion-oriented runs can
59
+ request a `frameTimeBudgetMs` and inspect delivered `rendered/target spp`
60
+ in the HUD and result payload.
61
+ - Added `@plasius/gpu-performance`-governed adaptive SPP control to the Eames
62
+ validation harness so frame-budgeted runs degrade and recover through a
63
+ shared quality-ladder contract instead of a demo-local policy alone.
47
64
 
48
65
  - **Changed**
49
66
  - Reduced ambient residual strength for the grass-field, forest, warehouse,
50
67
  and cavern environment preset families to avoid low-sample whitewash.
68
+ - Eames validation capture scripts now pin deterministic render settings by
69
+ default and write canvas-only PNG artifacts plus comparable black-pixel and
70
+ luminance metrics under `output/playwright/eames-environments/`.
71
+ - Eames validation page and capture entry points now accept up to `256 spp`,
72
+ and the validation boot timeout now scales with requested render workload
73
+ instead of failing at a fixed 60-second watchdog.
74
+ - `gpu-lighting` local typecheck coverage now includes the Eames validation
75
+ page, loader, and capture helpers instead of checking only the legacy demo
76
+ entry points.
77
+ - Eames validation mesh loading now preserves authored UVs and decoded
78
+ base-colour, metallic-roughness, normal, and occlusion maps so the shared
79
+ wavefront renderer can evaluate materially richer leather, wood, and chrome
80
+ surfaces.
81
+ - Eames validation meshes now forward decoded emissive maps as well so the
82
+ shared wavefront renderer can keep all material texture evaluation in the
83
+ GPU render path.
84
+ - Eames validation mesh building now preserves authored material values
85
+ generically instead of deriving chrome, leather, and wood behaviour from
86
+ material names.
87
+ - Eames validation page can now freeze its own canvas and POST the PNG back
88
+ to a local bridge endpoint, which provides a browser-driven screenshot
89
+ fallback when Playwright cannot own the Chromium process directly.
90
+ - Eames validation capture and reverse-pass debug entry points now share one
91
+ server-selection helper so port reuse, static serving, and bridge-ready
92
+ startup rules stay aligned across local runs and CI.
51
93
 
52
94
  - **Fixed**
53
- - (placeholder)
95
+ - Eames Playwright validation pages and capture scripts now surface import,
96
+ WebGPU bootstrap, and renderer startup diagnostics instead of hanging on the
97
+ initial HUD when a browser cannot complete setup.
98
+ - Eames validation renders now collect optional output-probe figures after the
99
+ frame render completes instead of coupling probe readback to the heavy
100
+ high-SPP render submission itself.
101
+ - Eames capture helpers now resolve browser profile and temporary output paths
102
+ through the host OS temp directory instead of assuming macOS-only
103
+ `/private/tmp`, which fixes Linux CI validation.
104
+ - Eames validation query parsing now preserves documented fallback defaults
105
+ when numeric URL params are omitted, which keeps standard captures out of
106
+ accidental reverse-pass debug mode.
107
+ - Eames validation motion renders now preserve the built-in adaptive
108
+ `frameTimeBudgetMs` default when the query parameter is omitted, so the
109
+ shared `@plasius/gpu-performance` quality ladder still engages on the
110
+ standard animated validation route.
111
+ - Animated source-marker captures now reuse the injected product-studio scene
112
+ helper during per-frame rebuilds instead of throwing when motion is enabled.
113
+ - Eames capture waits now scale with requested resolution, frame count, depth,
114
+ and SPP so higher-workload validation runs are not aborted at a stale fixed
115
+ timeout.
116
+ - Eames glTF validation materials now honor `KHR_texture_transform` offsets,
117
+ scales, and rotation by baking transformed texture maps during decode so
118
+ chair screenshots reflect the authored leather and wood layouts.
119
+ - The capture bridge now rejects non-loopback browser origins and confines
120
+ capture writes to `output/playwright/eames-environments/`, which closes the
121
+ browser-driven workspace overwrite path on the local upload endpoint.
122
+ - The Eames glTF loader now honors interleaved `bufferView.byteStride` values,
123
+ which keeps positions, normals, and UVs correct for legal strided assets.
124
+ - The capture bridge now serves static assets without a pre-stat/read race,
125
+ and browser-driven capture uploads are restricted to loopback bridge URLs.
126
+ - The standalone repo now ships the Eames demo asset set referenced by the
127
+ validation page so fresh checkouts can render the chair without external
128
+ workspace-only files.
54
129
 
55
130
  - **Security**
56
131
  - (placeholder)
@@ -389,7 +464,7 @@ The format is based on **[Keep a Changelog](https://keepachangelog.com/en/1.1.0/
389
464
  [0.1.16]: https://github.com/Plasius-LTD/gpu-lighting/releases/tag/v0.1.16
390
465
  [0.1.17]: https://github.com/Plasius-LTD/gpu-lighting/releases/tag/v0.1.17
391
466
  [0.1.19]: https://github.com/Plasius-LTD/gpu-lighting/releases/tag/v0.1.19
392
- [Unreleased]: https://github.com/Plasius-LTD/gpu-lighting/compare/v0.2.3...HEAD
467
+ [Unreleased]: https://github.com/Plasius-LTD/gpu-lighting/compare/v0.2.5...HEAD
393
468
  [0.2.0]: https://github.com/Plasius-LTD/gpu-lighting/releases/tag/v0.2.0
394
469
  [0.2.2]: https://github.com/Plasius-LTD/gpu-lighting/releases/tag/v0.2.2
395
- [0.2.3]: https://github.com/Plasius-LTD/gpu-lighting/releases/tag/v0.2.3
470
+ [0.2.5]: https://github.com/Plasius-LTD/gpu-lighting/releases/tag/v0.2.5
package/README.md CHANGED
@@ -42,6 +42,81 @@ For browser-only serving, the demo resolves `@plasius/gpu-shared` through an
42
42
  import map so the page stays on the published package surface rather than a
43
43
  package-private source path.
44
44
 
45
+ ## Screenshot Capture
46
+
47
+ The repo also carries the Eames-chair environment validation harness under
48
+ `demo/eames-environments/` plus tracked Playwright helpers under
49
+ `scripts/eames-environments/`. The referenced chair asset is tracked under
50
+ `data/models/eames-lounge-chair-ottoman/` so fresh repo checkouts can run the
51
+ validation page without depending on a parent monorepo checkout. Build
52
+ `gpu-performance`, `gpu-renderer`, and `gpu-lighting` first, then run:
53
+
54
+ ```bash
55
+ node scripts/eames-environments/capture.mjs
56
+ ```
57
+
58
+ For reverse-pass black-pixel diagnostics, run:
59
+
60
+ ```bash
61
+ node scripts/eames-environments/path-debug-capture.mjs
62
+ ```
63
+
64
+ If fresh Playwright Chromium bootstrap is unstable on macOS, start a
65
+ WebGPU-capable Chrome separately with remote debugging enabled and set
66
+ `PLASIUS_CAPTURE_CDP_URL=http://127.0.0.1:<port>` before running the capture
67
+ script. The validation page now reports bootstrap step, detail, and WebGPU
68
+ availability through `window.__plasiusCaptureState` and
69
+ `window.__plasiusCaptureError` so capture failures stop at a named phase rather
70
+ than hanging on the initial HUD.
71
+
72
+ For browser-controlled fallbacks, start
73
+ `node scripts/eames-environments/capture-bridge-server.mjs <port>` and open the
74
+ validation page with `captureBitmap=1` plus
75
+ `captureUploadPath=output/playwright/eames-environments/<name>.png`. If the page
76
+ is being served by a plain static server such as `python -m http.server`, also
77
+ pass
78
+ `captureUploadUrl=http://127.0.0.1:<port>/__plasius-capture`. The page will
79
+ freeze its own canvas and POST the PNG back to the bridge server once the
80
+ render completes. The browser-side upload helper now rejects non-loopback
81
+ capture endpoints so this fallback cannot be redirected at arbitrary remote
82
+ origins.
83
+
84
+ The main capture and reverse-pass debug capture entry points now share the same
85
+ server-selection helper, so local reuse, fresh static-server startup, and
86
+ bridge fallback all follow the same port and readiness rules across macOS and
87
+ Linux.
88
+
89
+ The capture scripts now pin deterministic validation settings unless explicitly
90
+ overridden:
91
+
92
+ - `PLASIUS_CAPTURE_MAX_DEPTH=8`
93
+ - `PLASIUS_CAPTURE_SPP=1`
94
+ - `PLASIUS_CAPTURE_FRAMES=1`
95
+ - `PLASIUS_CAPTURE_DENOISE=1`
96
+ - `PLASIUS_CAPTURE_MOTION=0`
97
+ - `PLASIUS_CAPTURE_FRAME_INDEX=777`
98
+ - `PLASIUS_CAPTURE_PROBE=1`
99
+
100
+ They save canvas-only PNGs plus per-capture JSON under
101
+ `output/playwright/eames-environments/`, including exact-black, near-black, and
102
+ average-luminance metrics so screenshot comparisons stay apples-to-apples. The
103
+ validation page now decouples optional probe readback from the heavy render
104
+ submission itself, which keeps higher-SPP screenshot validation more stable.
105
+ For motion or realtime validation, the page also accepts `frameTimeBudgetMs`
106
+ and will render at least one full-screen sample before adaptively spending the
107
+ rest of the per-frame budget on additional SPP passes. The HUD reports
108
+ `rendered/target spp` whenever the budgeted frame lands below the configured
109
+ ceiling. When `gpu-performance/dist/index.js` is available, the Eames harness
110
+ also routes that target through a `@plasius/gpu-performance` quality ladder fed
111
+ by `gpu-renderer`'s wavefront adaptive-sampling levels, so the requested SPP
112
+ becomes a release-grade ceiling rather than an ungoverned demo-local heuristic.
113
+ The Eames loader also preserves authored UVs, decoded base-colour,
114
+ metallic-roughness, normal, occlusion, and emissive maps, plus authored glTF
115
+ material factors such as clearcoat, sheen colour, specular colour,
116
+ transmission, and IOR when present. That keeps the validation scene on the
117
+ shared renderer path instead of relying on material-name-specific overrides for
118
+ leather, wood, and chrome.
119
+
45
120
  ## Usage (load one technique)
46
121
 
47
122
  ```js
@@ -250,6 +325,11 @@ The package now ships concrete WGSL contracts for:
250
325
  - `hybrid.screenTrace`: first-hit reflection tracing over the shared hybrid scene contracts
251
326
  - `hybrid.radianceCache`: irradiance history updates for cache-backed indirect reuse
252
327
  - `hybrid.finalGather`: cache + trace composition with temporal reuse for the hybrid GI path
328
+ - `volumetrics.volumetricShadow`: slice-aware Beer-Lambert shadow history for fog and shafts
329
+ - `volumetrics.froxelIntegrate`: froxel scattering/extinction integration with temporal stability
330
+ - `hdri.irradianceConvolution`: cosine-weighted diffuse environment convolution
331
+ - `hdri.specularPrefilter`: roughness-aware environment prefiltering for glossy IBL
332
+ - `hdri.brdfLut`: split-sum BRDF LUT integration for image-based lighting
253
333
  - `pathtracer.pathTrace`: analytic scene tracing, bounce integration, and sky fallback
254
334
  - `pathtracer.accumulate`: progressive history resolve with reset handling
255
335
  - `pathtracer.denoise`: spatial-temporal bilateral filtering for reference previews
@@ -279,12 +359,12 @@ graph.
279
359
  - `accumulate`
280
360
  - `denoise`
281
361
  - `volumetrics`
282
- - `froxelIntegrate`
283
- - `volumetricShadow`
362
+ - `froxelIntegrate`: accumulates participating-media scattering/extinction per froxel
363
+ - `volumetricShadow`: resolves directional shadow transmittance history per froxel
284
364
  - `hdri`
285
- - `irradianceConvolution`
286
- - `specularPrefilter`
287
- - `brdfLut`
365
+ - `irradianceConvolution`: builds diffuse irradiance from the environment source
366
+ - `specularPrefilter`: builds roughness-aware glossy environment mip data
367
+ - `brdfLut`: integrates the split-sum BRDF lookup surface for IBL
288
368
 
289
369
  ## Demo
290
370
 
@@ -1,3 +1,71 @@
1
- fn process_job() {
2
- // Placeholder BRDF LUT generation stage.
1
+ @group(0) @binding(0) var<uniform> iblPrecomputeParams: IblPrecomputeParams;
2
+ @group(0) @binding(1) var<storage, read_write> hdriBrdfLutOutput: array<vec4<f32>>;
3
+
4
+ fn lut_extent() -> u32 {
5
+ return max(iblPrecomputeParams.sample_count, 1u);
6
+ }
7
+
8
+ fn lut_index(pixel: vec2<u32>) -> u32 {
9
+ let extent = lut_extent();
10
+ return pixel.y * extent + pixel.x;
11
+ }
12
+
13
+ fn saturate(value: f32) -> f32 {
14
+ return clamp(value, 0.0, 1.0);
15
+ }
16
+
17
+ fn geometry_schlick_ggx(ndotv: f32, roughness: f32) -> f32 {
18
+ let alpha = max(roughness * roughness, 0.001);
19
+ let k = (alpha + 1.0) * (alpha + 1.0) / 8.0;
20
+ return ndotv / max(ndotv * (1.0 - k) + k, 0.001);
21
+ }
22
+
23
+ fn geometry_smith(ndotv: f32, ndotl: f32, roughness: f32) -> f32 {
24
+ return geometry_schlick_ggx(ndotv, roughness) * geometry_schlick_ggx(ndotl, roughness);
25
+ }
26
+
27
+ fn fresnel_schlick(cos_theta: f32) -> f32 {
28
+ return pow(1.0 - saturate(cos_theta), 5.0);
29
+ }
30
+
31
+ @compute @workgroup_size(8, 8, 1)
32
+ fn process_job(@builtin(global_invocation_id) global_id: vec3<u32>) {
33
+ let extent = lut_extent();
34
+ if (global_id.x >= extent || global_id.y >= extent) {
35
+ return;
36
+ }
37
+
38
+ let uv = (vec2<f32>(global_id.xy) + vec2<f32>(0.5)) / max(vec2<f32>(f32(extent)), vec2<f32>(1.0));
39
+ let ndotv = saturate(uv.x);
40
+ let roughness = clamp_roughness(uv.y + iblPrecomputeParams.roughness * 0.15);
41
+ let sample_total = max(iblPrecomputeParams.sample_count, 1u);
42
+ var integrated_brdf = vec2<f32>(0.0);
43
+ var sample_index = 0u;
44
+ loop {
45
+ if (sample_index >= sample_total) {
46
+ break;
47
+ }
48
+
49
+ let sample_u = (f32(sample_index) + 0.5) / f32(sample_total);
50
+ let sample_v = fract(f32(sample_index) * 0.61803398875);
51
+ let ndotl = saturate(sqrt(sample_u));
52
+ let vdoth = saturate(mix(ndotv, 1.0, sample_v));
53
+ let geometry = geometry_smith(ndotv, ndotl, roughness);
54
+ let fresnel = fresnel_schlick(vdoth);
55
+ integrated_brdf = integrated_brdf + vec2<f32>(
56
+ (1.0 - fresnel) * geometry,
57
+ fresnel * geometry
58
+ );
59
+
60
+ sample_index = sample_index + 1u;
61
+ }
62
+
63
+ integrated_brdf =
64
+ integrated_brdf / f32(sample_total) * max(iblPrecomputeParams.exposure_bias, 0.0001);
65
+ hdriBrdfLutOutput[lut_index(global_id.xy)] = vec4<f32>(
66
+ integrated_brdf.x,
67
+ integrated_brdf.y,
68
+ roughness,
69
+ ndotv
70
+ );
3
71
  }
@@ -1,3 +1,92 @@
1
- fn process_job() {
2
- // Placeholder irradiance convolution stage.
1
+ struct HdriConvolutionSample {
2
+ direction_radiance: vec4<f32>,
3
+ };
4
+
5
+ @group(0) @binding(0) var<uniform> iblPrecomputeParams: IblPrecomputeParams;
6
+ @group(0) @binding(1) var<storage, read> hdriEnvironmentInput: array<HdriConvolutionSample>;
7
+ @group(0) @binding(2) var<storage, read_write> hdriIrradianceOutput: array<vec4<f32>>;
8
+
9
+ fn face_extent() -> u32 {
10
+ return max(iblPrecomputeParams.sample_count, 1u);
11
+ }
12
+
13
+ fn face_pixel_index(pixel: vec2<u32>) -> u32 {
14
+ let extent = face_extent();
15
+ return pixel.y * extent + pixel.x;
16
+ }
17
+
18
+ fn safe_normalize(value: vec3<f32>) -> vec3<f32> {
19
+ let length_value = length(value);
20
+ if (length_value <= 0.000001) {
21
+ return vec3<f32>(0.0, 1.0, 0.0);
22
+ }
23
+ return value / length_value;
24
+ }
25
+
26
+ fn direction_from_texel(pixel: vec2<u32>, extent: u32) -> vec3<f32> {
27
+ let uv = (vec2<f32>(pixel) + vec2<f32>(0.5)) / max(vec2<f32>(f32(extent)), vec2<f32>(1.0));
28
+ let phi = (uv.x * 2.0 - 1.0) * 3.141592653589793;
29
+ let theta = uv.y * 3.141592653589793;
30
+ let sin_theta = sin(theta);
31
+ return safe_normalize(
32
+ vec3<f32>(
33
+ cos(phi) * sin_theta,
34
+ cos(theta),
35
+ sin(phi) * sin_theta
36
+ )
37
+ );
38
+ }
39
+
40
+ fn direction_to_index(direction: vec3<f32>, extent: u32) -> u32 {
41
+ let dir = safe_normalize(direction);
42
+ let phi = atan2(dir.z, dir.x);
43
+ let theta = acos(clamp(dir.y, -1.0, 1.0));
44
+ let u = fract(phi / (2.0 * 3.141592653589793) + 0.5);
45
+ let v = clamp(theta / 3.141592653589793, 0.0, 0.999999);
46
+ let pixel = vec2<u32>(
47
+ min(u32(floor(u * f32(extent))), extent - 1u),
48
+ min(u32(floor(v * f32(extent))), extent - 1u)
49
+ );
50
+ return face_pixel_index(pixel);
51
+ }
52
+
53
+ fn sample_environment(direction: vec3<f32>) -> vec3<f32> {
54
+ let index = direction_to_index(direction, face_extent());
55
+ return hdriEnvironmentInput[index].direction_radiance.xyz;
56
+ }
57
+
58
+ @compute @workgroup_size(8, 8, 1)
59
+ fn process_job(@builtin(global_invocation_id) global_id: vec3<u32>) {
60
+ let extent = face_extent();
61
+ if (global_id.x >= extent || global_id.y >= extent) {
62
+ return;
63
+ }
64
+
65
+ let normal = direction_from_texel(global_id.xy, extent);
66
+ var accumulated_irradiance = vec3<f32>(0.0);
67
+ var total_weight = 0.0;
68
+ var sample_index = 0u;
69
+ loop {
70
+ if (sample_index >= extent) {
71
+ break;
72
+ }
73
+
74
+ let sample_pixel = vec2<u32>(
75
+ (global_id.x + sample_index) % extent,
76
+ (global_id.y + sample_index * 3u) % extent
77
+ );
78
+ let sample_direction = direction_from_texel(sample_pixel, extent);
79
+ let cosine_weight = max(dot(normal, sample_direction), 0.0);
80
+ if (cosine_weight > 0.0) {
81
+ accumulated_irradiance =
82
+ accumulated_irradiance + sample_environment(sample_direction) * cosine_weight;
83
+ total_weight = total_weight + cosine_weight;
84
+ }
85
+
86
+ sample_index = sample_index + 1u;
87
+ }
88
+
89
+ let irradiance =
90
+ accumulated_irradiance / max(total_weight, 0.0001) * max(iblPrecomputeParams.exposure_bias, 0.0001);
91
+ hdriIrradianceOutput[face_pixel_index(global_id.xy)] = vec4<f32>(irradiance, 1.0);
3
92
  }
@@ -1,3 +1,111 @@
1
- fn process_job() {
2
- // Placeholder specular prefilter stage.
1
+ struct HdriSpecularSample {
2
+ direction_radiance: vec4<f32>,
3
+ };
4
+
5
+ @group(0) @binding(0) var<uniform> iblPrecomputeParams: IblPrecomputeParams;
6
+ @group(0) @binding(1) var<storage, read> hdriEnvironmentInput: array<HdriSpecularSample>;
7
+ @group(0) @binding(2) var<storage, read_write> hdriSpecularOutput: array<vec4<f32>>;
8
+
9
+ fn face_extent() -> u32 {
10
+ return max(iblPrecomputeParams.sample_count, 1u);
11
+ }
12
+
13
+ fn face_pixel_index(pixel: vec2<u32>) -> u32 {
14
+ let extent = face_extent();
15
+ return pixel.y * extent + pixel.x;
16
+ }
17
+
18
+ fn safe_normalize(value: vec3<f32>) -> vec3<f32> {
19
+ let length_value = length(value);
20
+ if (length_value <= 0.000001) {
21
+ return vec3<f32>(0.0, 1.0, 0.0);
22
+ }
23
+ return value / length_value;
24
+ }
25
+
26
+ fn direction_from_texel(pixel: vec2<u32>, extent: u32) -> vec3<f32> {
27
+ let uv = (vec2<f32>(pixel) + vec2<f32>(0.5)) / max(vec2<f32>(f32(extent)), vec2<f32>(1.0));
28
+ let phi = (uv.x * 2.0 - 1.0) * 3.141592653589793;
29
+ let theta = uv.y * 3.141592653589793;
30
+ let sin_theta = sin(theta);
31
+ return safe_normalize(
32
+ vec3<f32>(
33
+ cos(phi) * sin_theta,
34
+ cos(theta),
35
+ sin(phi) * sin_theta
36
+ )
37
+ );
38
+ }
39
+
40
+ fn direction_to_index(direction: vec3<f32>, extent: u32) -> u32 {
41
+ let dir = safe_normalize(direction);
42
+ let phi = atan2(dir.z, dir.x);
43
+ let theta = acos(clamp(dir.y, -1.0, 1.0));
44
+ let u = fract(phi / (2.0 * 3.141592653589793) + 0.5);
45
+ let v = clamp(theta / 3.141592653589793, 0.0, 0.999999);
46
+ let pixel = vec2<u32>(
47
+ min(u32(floor(u * f32(extent))), extent - 1u),
48
+ min(u32(floor(v * f32(extent))), extent - 1u)
49
+ );
50
+ return face_pixel_index(pixel);
51
+ }
52
+
53
+ fn sample_environment(direction: vec3<f32>) -> vec3<f32> {
54
+ let index = direction_to_index(direction, face_extent());
55
+ return hdriEnvironmentInput[index].direction_radiance.xyz;
56
+ }
57
+
58
+ fn importance_sample_hemisphere(normal: vec3<f32>, xi: vec2<f32>, roughness: f32) -> vec3<f32> {
59
+ let phi = 2.0 * 3.141592653589793 * xi.x;
60
+ let alpha = max(roughness * roughness, 0.001);
61
+ let cos_theta = sqrt((1.0 - xi.y) / max(1.0 + (alpha * alpha - 1.0) * xi.y, 0.001));
62
+ let sin_theta = sqrt(max(1.0 - cos_theta * cos_theta, 0.0));
63
+ let tangent_seed = select(vec3<f32>(0.0, 1.0, 0.0), vec3<f32>(1.0, 0.0, 0.0), abs(normal.y) > 0.9);
64
+ let tangent = safe_normalize(cross(tangent_seed, normal));
65
+ let bitangent = cross(normal, tangent);
66
+ return safe_normalize(
67
+ tangent * cos(phi) * sin_theta +
68
+ bitangent * sin(phi) * sin_theta +
69
+ normal * cos_theta
70
+ );
71
+ }
72
+
73
+ @compute @workgroup_size(8, 8, 1)
74
+ fn process_job(@builtin(global_invocation_id) global_id: vec3<u32>) {
75
+ let extent = face_extent();
76
+ if (global_id.x >= extent || global_id.y >= extent) {
77
+ return;
78
+ }
79
+
80
+ let normal = direction_from_texel(global_id.xy, extent);
81
+ let view_direction = normal;
82
+ let roughness = clamp_roughness(iblPrecomputeParams.roughness);
83
+ var prefiltered_radiance = vec3<f32>(0.0);
84
+ var importance_weight = 0.0;
85
+ var sample_index = 0u;
86
+ loop {
87
+ if (sample_index >= extent) {
88
+ break;
89
+ }
90
+
91
+ let xi = vec2<f32>(
92
+ (f32(sample_index) + 0.5) / f32(extent),
93
+ fract((f32(sample_index) * 0.7548776662466927) + (f32(global_id.x) + f32(global_id.y)) * 0.01)
94
+ );
95
+ let half_vector = importance_sample_hemisphere(normal, xi, roughness);
96
+ let sample_direction = reflect(-view_direction, half_vector);
97
+ let ndotl = max(dot(normal, sample_direction), 0.0);
98
+ if (ndotl > 0.0) {
99
+ let weight = mix(ndotl, pow(ndotl, 1.0 / max(roughness + 0.05, 0.05)), roughness);
100
+ prefiltered_radiance =
101
+ prefiltered_radiance + sample_environment(sample_direction) * weight;
102
+ importance_weight = importance_weight + weight;
103
+ }
104
+
105
+ sample_index = sample_index + 1u;
106
+ }
107
+
108
+ let resolved_radiance =
109
+ prefiltered_radiance / max(importance_weight, 0.0001) * max(iblPrecomputeParams.exposure_bias, 0.0001);
110
+ hdriSpecularOutput[face_pixel_index(global_id.xy)] = vec4<f32>(resolved_radiance, roughness);
3
111
  }
@@ -1,3 +1,106 @@
1
- fn process_job() {
2
- // Placeholder froxel integration stage for volumetric lighting.
1
+ struct VolumetricIntegrationParams {
2
+ light_direction: vec3<f32>,
3
+ history_blend: f32,
4
+ extinction_bias: f32,
5
+ ambient_boost: f32,
6
+ integration_step_scale: f32,
7
+ phase_bias: f32,
8
+ temporal_stability: f32,
9
+ reserved: f32,
10
+ };
11
+
12
+ struct FroxelMediumVoxel {
13
+ extinction_density: vec4<f32>,
14
+ inscattering_anisotropy: vec4<f32>,
15
+ };
16
+
17
+ struct VolumetricShadowHistory {
18
+ shadow_transmittance: vec4<f32>,
19
+ depth_phase: vec4<f32>,
20
+ };
21
+
22
+ struct FroxelIntegratedVoxel {
23
+ integrated_scattering: vec4<f32>,
24
+ integrated_extinction: vec4<f32>,
25
+ };
26
+
27
+ @group(0) @binding(0) var<uniform> froxelGridParams: FroxelGridParams;
28
+ @group(0) @binding(1) var<uniform> volumetricIntegrationParams: VolumetricIntegrationParams;
29
+ @group(0) @binding(2) var<storage, read> froxelMediumInput: array<FroxelMediumVoxel>;
30
+ @group(0) @binding(3) var<storage, read> froxelShadowInput: array<VolumetricShadowHistory>;
31
+ @group(0) @binding(4) var<storage, read_write> froxelIntegratedOutput: array<FroxelIntegratedVoxel>;
32
+
33
+ fn froxel_integrate_index(coord: vec3<u32>) -> u32 {
34
+ let plane = max(froxelGridParams.grid_width, 1u) * max(froxelGridParams.grid_height, 1u);
35
+ return coord.z * plane + coord.y * max(froxelGridParams.grid_width, 1u) + coord.x;
36
+ }
37
+
38
+ fn safe_normalize(value: vec3<f32>) -> vec3<f32> {
39
+ let length_value = length(value);
40
+ if (length_value <= 0.000001) {
41
+ return vec3<f32>(0.0, 1.0, 0.0);
42
+ }
43
+ return value / length_value;
44
+ }
45
+
46
+ fn henyey_greenstein(cos_theta: f32, anisotropy: f32) -> f32 {
47
+ let g = clamp(anisotropy, -0.85, 0.85);
48
+ let denominator = pow(max(1.0 + g * g - 2.0 * g * cos_theta, 0.001), 1.5);
49
+ return (1.0 - g * g) / max(12.566370614359172 * denominator, 0.001);
50
+ }
51
+
52
+ @compute @workgroup_size(4, 4, 4)
53
+ fn process_job(@builtin(global_invocation_id) global_id: vec3<u32>) {
54
+ if (
55
+ global_id.x >= froxelGridParams.grid_width ||
56
+ global_id.y >= froxelGridParams.grid_height ||
57
+ global_id.z >= froxelGridParams.grid_depth
58
+ ) {
59
+ return;
60
+ }
61
+
62
+ let index = froxel_integrate_index(global_id);
63
+ let medium = froxelMediumInput[index];
64
+ let shadow = froxelShadowInput[index];
65
+ let previous = froxelIntegratedOutput[index];
66
+ let light_direction = safe_normalize(volumetricIntegrationParams.light_direction);
67
+ let slice_depth = (f32(global_id.z) + 0.5) / max(f32(froxelGridParams.grid_depth), 1.0);
68
+ let density = max(medium.extinction_density.w + volumetricIntegrationParams.extinction_bias, 0.0);
69
+ let extinction = max(medium.extinction_density.xyz, vec3<f32>(0.0001));
70
+ let albedo = clamp(medium.inscattering_anisotropy.xyz, vec3<f32>(0.0), vec3<f32>(4.0));
71
+ let anisotropy = medium.inscattering_anisotropy.w;
72
+ let phase = henyey_greenstein(light_direction.y, anisotropy + volumetricIntegrationParams.phase_bias * 0.1);
73
+ let step_length =
74
+ max(volumetricIntegrationParams.integration_step_scale, 0.2) /
75
+ max(f32(froxelGridParams.grid_depth), 1.0);
76
+ let shadow_visibility = shadow.shadow_transmittance.w;
77
+ let ambient_term = vec3<f32>(0.08, 0.1, 0.14) * max(volumetricIntegrationParams.ambient_boost, 0.0);
78
+ let sample = MediumSample(
79
+ extinction,
80
+ (albedo * (0.25 + slice_depth * 0.5) + ambient_term) * shadow_visibility * phase
81
+ );
82
+ let transmittance = exp(-sample.extinction * density * step_length);
83
+ let integrated_scattering =
84
+ sample.inscattering *
85
+ froxelGridParams.scattering_strength *
86
+ (1.0 - transmittance) *
87
+ (1.0 + shadow.depth_phase.z * 0.2);
88
+ let integrated_extinction = sample.extinction * density * step_length;
89
+ let stability = clamp(
90
+ shadow.depth_phase.w * volumetricIntegrationParams.temporal_stability,
91
+ 0.0,
92
+ 1.0
93
+ );
94
+ let blend = clamp(volumetricIntegrationParams.history_blend, 0.0, 0.98) * stability;
95
+ let resolved_scattering =
96
+ previous.integrated_scattering.xyz * blend +
97
+ integrated_scattering * (1.0 - blend);
98
+ let resolved_extinction =
99
+ previous.integrated_extinction.xyz * blend +
100
+ integrated_extinction * (1.0 - blend);
101
+
102
+ froxelIntegratedOutput[index] = FroxelIntegratedVoxel(
103
+ vec4<f32>(resolved_scattering, shadow_visibility),
104
+ vec4<f32>(resolved_extinction, stability)
105
+ );
3
106
  }
@@ -1,3 +1,97 @@
1
- fn process_job() {
2
- // Placeholder volumetric shadow resolve stage.
1
+ struct VolumetricLightParams {
2
+ light_direction: vec3<f32>,
3
+ history_blend: f32,
4
+ light_color: vec3<f32>,
5
+ shadow_strength: f32,
6
+ slice_depth_scale: f32,
7
+ density_bias: f32,
8
+ phase_bias: f32,
9
+ jitter_amount: f32,
10
+ };
11
+
12
+ struct FroxelMediumVoxel {
13
+ extinction_density: vec4<f32>,
14
+ inscattering_anisotropy: vec4<f32>,
15
+ };
16
+
17
+ struct VolumetricShadowHistory {
18
+ shadow_transmittance: vec4<f32>,
19
+ depth_phase: vec4<f32>,
20
+ };
21
+
22
+ @group(0) @binding(0) var<uniform> froxelGridParams: FroxelGridParams;
23
+ @group(0) @binding(1) var<uniform> volumetricLightParams: VolumetricLightParams;
24
+ @group(0) @binding(2) var<storage, read> froxelMediumInput: array<FroxelMediumVoxel>;
25
+ @group(0) @binding(3) var<storage, read> froxelShadowHistory: array<VolumetricShadowHistory>;
26
+ @group(0) @binding(4) var<storage, read_write> froxelShadowOutput: array<VolumetricShadowHistory>;
27
+
28
+ fn froxel_shadow_index(coord: vec3<u32>) -> u32 {
29
+ let plane = max(froxelGridParams.grid_width, 1u) * max(froxelGridParams.grid_height, 1u);
30
+ return coord.z * plane + coord.y * max(froxelGridParams.grid_width, 1u) + coord.x;
31
+ }
32
+
33
+ fn safe_normalize(value: vec3<f32>) -> vec3<f32> {
34
+ let length_value = length(value);
35
+ if (length_value <= 0.000001) {
36
+ return vec3<f32>(0.0, 1.0, 0.0);
37
+ }
38
+ return value / length_value;
39
+ }
40
+
41
+ fn henyey_greenstein(cos_theta: f32, anisotropy: f32) -> f32 {
42
+ let g = clamp(anisotropy, -0.85, 0.85);
43
+ let denominator = pow(max(1.0 + g * g - 2.0 * g * cos_theta, 0.001), 1.5);
44
+ return (1.0 - g * g) / max(12.566370614359172 * denominator, 0.001);
45
+ }
46
+
47
+ @compute @workgroup_size(4, 4, 4)
48
+ fn process_job(@builtin(global_invocation_id) global_id: vec3<u32>) {
49
+ if (
50
+ global_id.x >= froxelGridParams.grid_width ||
51
+ global_id.y >= froxelGridParams.grid_height ||
52
+ global_id.z >= froxelGridParams.grid_depth
53
+ ) {
54
+ return;
55
+ }
56
+
57
+ let index = froxel_shadow_index(global_id);
58
+ let medium = froxelMediumInput[index];
59
+ let previous = froxelShadowHistory[index];
60
+ let light_direction = safe_normalize(volumetricLightParams.light_direction);
61
+ let slice_depth = (f32(global_id.z) + 0.5) / max(f32(froxelGridParams.grid_depth), 1.0);
62
+ let density = max(medium.extinction_density.w + volumetricLightParams.density_bias, 0.0);
63
+ let extinction = max(dot(medium.extinction_density.xyz, vec3<f32>(0.2126, 0.7152, 0.0722)), 0.0001);
64
+ let anisotropy = medium.inscattering_anisotropy.w;
65
+ let phase_weight = henyey_greenstein(light_direction.y, anisotropy);
66
+ let depth_scale = max(volumetricLightParams.slice_depth_scale, 0.25);
67
+ let shadow_distance = depth_scale * mix(0.35, 1.35, slice_depth);
68
+ let optical_depth = (density * 0.7 + extinction * 0.3) * shadow_distance;
69
+ let raw_transmittance = exp(-optical_depth);
70
+ let horizon_wrap = 0.35 + 0.65 * saturate(light_direction.y * 0.5 + 0.5);
71
+ let jitter = fract(
72
+ f32(global_id.x * 19u + global_id.y * 47u + global_id.z * 73u) * 0.61803398875
73
+ );
74
+ let visibility = clamp(
75
+ raw_transmittance * horizon_wrap * (1.0 - volumetricLightParams.jitter_amount * (jitter - 0.5)),
76
+ 0.0,
77
+ 1.0
78
+ );
79
+ let history_visibility = previous.shadow_transmittance.w;
80
+ let blend = clamp(volumetricLightParams.history_blend, 0.0, 0.98);
81
+ let shadow_transmittance = mix(visibility, history_visibility, blend);
82
+ let light_radiance =
83
+ volumetricLightParams.light_color *
84
+ shadow_transmittance *
85
+ max(volumetricLightParams.shadow_strength, 0.0) *
86
+ (0.55 + phase_weight * 0.45);
87
+ let depth_confidence = clamp(
88
+ slice_depth * 0.45 + shadow_transmittance * 0.35 + (1.0 - density) * 0.2,
89
+ 0.0,
90
+ 1.0
91
+ );
92
+
93
+ froxelShadowOutput[index] = VolumetricShadowHistory(
94
+ vec4<f32>(light_radiance, shadow_transmittance),
95
+ vec4<f32>(slice_depth, optical_depth, phase_weight, depth_confidence)
96
+ );
3
97
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plasius/gpu-lighting",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Advanced lighting WGSL modules and planning profiles for @plasius/gpu-worker.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -26,7 +26,7 @@
26
26
  "scripts": {
27
27
  "build": "tsup && cp -R src/techniques dist/techniques",
28
28
  "demo": "python3 -m http.server --directory ..",
29
- "typecheck": "node --check src/index.js && node --check demo/lighting-demo-config.js && node --check demo/main.js",
29
+ "typecheck": "node --check src/index.js && node --check demo/lighting-demo-config.js && node --check demo/main.js && node --check demo/eames-environments/page.js && node --check demo/eames-environments/eames-loader.js && node --check scripts/eames-environments/capture-runtime.mjs && node --check scripts/eames-environments/capture-server.mjs && node --check scripts/eames-environments/capture.mjs && node --check scripts/eames-environments/path-debug-capture.mjs && node --check scripts/eames-environments/capture-bridge-server.mjs",
30
30
  "audit:eslint": "eslint . --max-warnings=0",
31
31
  "audit:deps": "npm ls --all --omit=optional --omit=peer > /dev/null 2>&1 || true",
32
32
  "audit:npm": "npm audit --audit-level=high --omit=dev",
@@ -1,3 +1,71 @@
1
- fn process_job() {
2
- // Placeholder BRDF LUT generation stage.
1
+ @group(0) @binding(0) var<uniform> iblPrecomputeParams: IblPrecomputeParams;
2
+ @group(0) @binding(1) var<storage, read_write> hdriBrdfLutOutput: array<vec4<f32>>;
3
+
4
+ fn lut_extent() -> u32 {
5
+ return max(iblPrecomputeParams.sample_count, 1u);
6
+ }
7
+
8
+ fn lut_index(pixel: vec2<u32>) -> u32 {
9
+ let extent = lut_extent();
10
+ return pixel.y * extent + pixel.x;
11
+ }
12
+
13
+ fn saturate(value: f32) -> f32 {
14
+ return clamp(value, 0.0, 1.0);
15
+ }
16
+
17
+ fn geometry_schlick_ggx(ndotv: f32, roughness: f32) -> f32 {
18
+ let alpha = max(roughness * roughness, 0.001);
19
+ let k = (alpha + 1.0) * (alpha + 1.0) / 8.0;
20
+ return ndotv / max(ndotv * (1.0 - k) + k, 0.001);
21
+ }
22
+
23
+ fn geometry_smith(ndotv: f32, ndotl: f32, roughness: f32) -> f32 {
24
+ return geometry_schlick_ggx(ndotv, roughness) * geometry_schlick_ggx(ndotl, roughness);
25
+ }
26
+
27
+ fn fresnel_schlick(cos_theta: f32) -> f32 {
28
+ return pow(1.0 - saturate(cos_theta), 5.0);
29
+ }
30
+
31
+ @compute @workgroup_size(8, 8, 1)
32
+ fn process_job(@builtin(global_invocation_id) global_id: vec3<u32>) {
33
+ let extent = lut_extent();
34
+ if (global_id.x >= extent || global_id.y >= extent) {
35
+ return;
36
+ }
37
+
38
+ let uv = (vec2<f32>(global_id.xy) + vec2<f32>(0.5)) / max(vec2<f32>(f32(extent)), vec2<f32>(1.0));
39
+ let ndotv = saturate(uv.x);
40
+ let roughness = clamp_roughness(uv.y + iblPrecomputeParams.roughness * 0.15);
41
+ let sample_total = max(iblPrecomputeParams.sample_count, 1u);
42
+ var integrated_brdf = vec2<f32>(0.0);
43
+ var sample_index = 0u;
44
+ loop {
45
+ if (sample_index >= sample_total) {
46
+ break;
47
+ }
48
+
49
+ let sample_u = (f32(sample_index) + 0.5) / f32(sample_total);
50
+ let sample_v = fract(f32(sample_index) * 0.61803398875);
51
+ let ndotl = saturate(sqrt(sample_u));
52
+ let vdoth = saturate(mix(ndotv, 1.0, sample_v));
53
+ let geometry = geometry_smith(ndotv, ndotl, roughness);
54
+ let fresnel = fresnel_schlick(vdoth);
55
+ integrated_brdf = integrated_brdf + vec2<f32>(
56
+ (1.0 - fresnel) * geometry,
57
+ fresnel * geometry
58
+ );
59
+
60
+ sample_index = sample_index + 1u;
61
+ }
62
+
63
+ integrated_brdf =
64
+ integrated_brdf / f32(sample_total) * max(iblPrecomputeParams.exposure_bias, 0.0001);
65
+ hdriBrdfLutOutput[lut_index(global_id.xy)] = vec4<f32>(
66
+ integrated_brdf.x,
67
+ integrated_brdf.y,
68
+ roughness,
69
+ ndotv
70
+ );
3
71
  }
@@ -1,3 +1,92 @@
1
- fn process_job() {
2
- // Placeholder irradiance convolution stage.
1
+ struct HdriConvolutionSample {
2
+ direction_radiance: vec4<f32>,
3
+ };
4
+
5
+ @group(0) @binding(0) var<uniform> iblPrecomputeParams: IblPrecomputeParams;
6
+ @group(0) @binding(1) var<storage, read> hdriEnvironmentInput: array<HdriConvolutionSample>;
7
+ @group(0) @binding(2) var<storage, read_write> hdriIrradianceOutput: array<vec4<f32>>;
8
+
9
+ fn face_extent() -> u32 {
10
+ return max(iblPrecomputeParams.sample_count, 1u);
11
+ }
12
+
13
+ fn face_pixel_index(pixel: vec2<u32>) -> u32 {
14
+ let extent = face_extent();
15
+ return pixel.y * extent + pixel.x;
16
+ }
17
+
18
+ fn safe_normalize(value: vec3<f32>) -> vec3<f32> {
19
+ let length_value = length(value);
20
+ if (length_value <= 0.000001) {
21
+ return vec3<f32>(0.0, 1.0, 0.0);
22
+ }
23
+ return value / length_value;
24
+ }
25
+
26
+ fn direction_from_texel(pixel: vec2<u32>, extent: u32) -> vec3<f32> {
27
+ let uv = (vec2<f32>(pixel) + vec2<f32>(0.5)) / max(vec2<f32>(f32(extent)), vec2<f32>(1.0));
28
+ let phi = (uv.x * 2.0 - 1.0) * 3.141592653589793;
29
+ let theta = uv.y * 3.141592653589793;
30
+ let sin_theta = sin(theta);
31
+ return safe_normalize(
32
+ vec3<f32>(
33
+ cos(phi) * sin_theta,
34
+ cos(theta),
35
+ sin(phi) * sin_theta
36
+ )
37
+ );
38
+ }
39
+
40
+ fn direction_to_index(direction: vec3<f32>, extent: u32) -> u32 {
41
+ let dir = safe_normalize(direction);
42
+ let phi = atan2(dir.z, dir.x);
43
+ let theta = acos(clamp(dir.y, -1.0, 1.0));
44
+ let u = fract(phi / (2.0 * 3.141592653589793) + 0.5);
45
+ let v = clamp(theta / 3.141592653589793, 0.0, 0.999999);
46
+ let pixel = vec2<u32>(
47
+ min(u32(floor(u * f32(extent))), extent - 1u),
48
+ min(u32(floor(v * f32(extent))), extent - 1u)
49
+ );
50
+ return face_pixel_index(pixel);
51
+ }
52
+
53
+ fn sample_environment(direction: vec3<f32>) -> vec3<f32> {
54
+ let index = direction_to_index(direction, face_extent());
55
+ return hdriEnvironmentInput[index].direction_radiance.xyz;
56
+ }
57
+
58
+ @compute @workgroup_size(8, 8, 1)
59
+ fn process_job(@builtin(global_invocation_id) global_id: vec3<u32>) {
60
+ let extent = face_extent();
61
+ if (global_id.x >= extent || global_id.y >= extent) {
62
+ return;
63
+ }
64
+
65
+ let normal = direction_from_texel(global_id.xy, extent);
66
+ var accumulated_irradiance = vec3<f32>(0.0);
67
+ var total_weight = 0.0;
68
+ var sample_index = 0u;
69
+ loop {
70
+ if (sample_index >= extent) {
71
+ break;
72
+ }
73
+
74
+ let sample_pixel = vec2<u32>(
75
+ (global_id.x + sample_index) % extent,
76
+ (global_id.y + sample_index * 3u) % extent
77
+ );
78
+ let sample_direction = direction_from_texel(sample_pixel, extent);
79
+ let cosine_weight = max(dot(normal, sample_direction), 0.0);
80
+ if (cosine_weight > 0.0) {
81
+ accumulated_irradiance =
82
+ accumulated_irradiance + sample_environment(sample_direction) * cosine_weight;
83
+ total_weight = total_weight + cosine_weight;
84
+ }
85
+
86
+ sample_index = sample_index + 1u;
87
+ }
88
+
89
+ let irradiance =
90
+ accumulated_irradiance / max(total_weight, 0.0001) * max(iblPrecomputeParams.exposure_bias, 0.0001);
91
+ hdriIrradianceOutput[face_pixel_index(global_id.xy)] = vec4<f32>(irradiance, 1.0);
3
92
  }
@@ -1,3 +1,111 @@
1
- fn process_job() {
2
- // Placeholder specular prefilter stage.
1
+ struct HdriSpecularSample {
2
+ direction_radiance: vec4<f32>,
3
+ };
4
+
5
+ @group(0) @binding(0) var<uniform> iblPrecomputeParams: IblPrecomputeParams;
6
+ @group(0) @binding(1) var<storage, read> hdriEnvironmentInput: array<HdriSpecularSample>;
7
+ @group(0) @binding(2) var<storage, read_write> hdriSpecularOutput: array<vec4<f32>>;
8
+
9
+ fn face_extent() -> u32 {
10
+ return max(iblPrecomputeParams.sample_count, 1u);
11
+ }
12
+
13
+ fn face_pixel_index(pixel: vec2<u32>) -> u32 {
14
+ let extent = face_extent();
15
+ return pixel.y * extent + pixel.x;
16
+ }
17
+
18
+ fn safe_normalize(value: vec3<f32>) -> vec3<f32> {
19
+ let length_value = length(value);
20
+ if (length_value <= 0.000001) {
21
+ return vec3<f32>(0.0, 1.0, 0.0);
22
+ }
23
+ return value / length_value;
24
+ }
25
+
26
+ fn direction_from_texel(pixel: vec2<u32>, extent: u32) -> vec3<f32> {
27
+ let uv = (vec2<f32>(pixel) + vec2<f32>(0.5)) / max(vec2<f32>(f32(extent)), vec2<f32>(1.0));
28
+ let phi = (uv.x * 2.0 - 1.0) * 3.141592653589793;
29
+ let theta = uv.y * 3.141592653589793;
30
+ let sin_theta = sin(theta);
31
+ return safe_normalize(
32
+ vec3<f32>(
33
+ cos(phi) * sin_theta,
34
+ cos(theta),
35
+ sin(phi) * sin_theta
36
+ )
37
+ );
38
+ }
39
+
40
+ fn direction_to_index(direction: vec3<f32>, extent: u32) -> u32 {
41
+ let dir = safe_normalize(direction);
42
+ let phi = atan2(dir.z, dir.x);
43
+ let theta = acos(clamp(dir.y, -1.0, 1.0));
44
+ let u = fract(phi / (2.0 * 3.141592653589793) + 0.5);
45
+ let v = clamp(theta / 3.141592653589793, 0.0, 0.999999);
46
+ let pixel = vec2<u32>(
47
+ min(u32(floor(u * f32(extent))), extent - 1u),
48
+ min(u32(floor(v * f32(extent))), extent - 1u)
49
+ );
50
+ return face_pixel_index(pixel);
51
+ }
52
+
53
+ fn sample_environment(direction: vec3<f32>) -> vec3<f32> {
54
+ let index = direction_to_index(direction, face_extent());
55
+ return hdriEnvironmentInput[index].direction_radiance.xyz;
56
+ }
57
+
58
+ fn importance_sample_hemisphere(normal: vec3<f32>, xi: vec2<f32>, roughness: f32) -> vec3<f32> {
59
+ let phi = 2.0 * 3.141592653589793 * xi.x;
60
+ let alpha = max(roughness * roughness, 0.001);
61
+ let cos_theta = sqrt((1.0 - xi.y) / max(1.0 + (alpha * alpha - 1.0) * xi.y, 0.001));
62
+ let sin_theta = sqrt(max(1.0 - cos_theta * cos_theta, 0.0));
63
+ let tangent_seed = select(vec3<f32>(0.0, 1.0, 0.0), vec3<f32>(1.0, 0.0, 0.0), abs(normal.y) > 0.9);
64
+ let tangent = safe_normalize(cross(tangent_seed, normal));
65
+ let bitangent = cross(normal, tangent);
66
+ return safe_normalize(
67
+ tangent * cos(phi) * sin_theta +
68
+ bitangent * sin(phi) * sin_theta +
69
+ normal * cos_theta
70
+ );
71
+ }
72
+
73
+ @compute @workgroup_size(8, 8, 1)
74
+ fn process_job(@builtin(global_invocation_id) global_id: vec3<u32>) {
75
+ let extent = face_extent();
76
+ if (global_id.x >= extent || global_id.y >= extent) {
77
+ return;
78
+ }
79
+
80
+ let normal = direction_from_texel(global_id.xy, extent);
81
+ let view_direction = normal;
82
+ let roughness = clamp_roughness(iblPrecomputeParams.roughness);
83
+ var prefiltered_radiance = vec3<f32>(0.0);
84
+ var importance_weight = 0.0;
85
+ var sample_index = 0u;
86
+ loop {
87
+ if (sample_index >= extent) {
88
+ break;
89
+ }
90
+
91
+ let xi = vec2<f32>(
92
+ (f32(sample_index) + 0.5) / f32(extent),
93
+ fract((f32(sample_index) * 0.7548776662466927) + (f32(global_id.x) + f32(global_id.y)) * 0.01)
94
+ );
95
+ let half_vector = importance_sample_hemisphere(normal, xi, roughness);
96
+ let sample_direction = reflect(-view_direction, half_vector);
97
+ let ndotl = max(dot(normal, sample_direction), 0.0);
98
+ if (ndotl > 0.0) {
99
+ let weight = mix(ndotl, pow(ndotl, 1.0 / max(roughness + 0.05, 0.05)), roughness);
100
+ prefiltered_radiance =
101
+ prefiltered_radiance + sample_environment(sample_direction) * weight;
102
+ importance_weight = importance_weight + weight;
103
+ }
104
+
105
+ sample_index = sample_index + 1u;
106
+ }
107
+
108
+ let resolved_radiance =
109
+ prefiltered_radiance / max(importance_weight, 0.0001) * max(iblPrecomputeParams.exposure_bias, 0.0001);
110
+ hdriSpecularOutput[face_pixel_index(global_id.xy)] = vec4<f32>(resolved_radiance, roughness);
3
111
  }
@@ -1,3 +1,106 @@
1
- fn process_job() {
2
- // Placeholder froxel integration stage for volumetric lighting.
1
+ struct VolumetricIntegrationParams {
2
+ light_direction: vec3<f32>,
3
+ history_blend: f32,
4
+ extinction_bias: f32,
5
+ ambient_boost: f32,
6
+ integration_step_scale: f32,
7
+ phase_bias: f32,
8
+ temporal_stability: f32,
9
+ reserved: f32,
10
+ };
11
+
12
+ struct FroxelMediumVoxel {
13
+ extinction_density: vec4<f32>,
14
+ inscattering_anisotropy: vec4<f32>,
15
+ };
16
+
17
+ struct VolumetricShadowHistory {
18
+ shadow_transmittance: vec4<f32>,
19
+ depth_phase: vec4<f32>,
20
+ };
21
+
22
+ struct FroxelIntegratedVoxel {
23
+ integrated_scattering: vec4<f32>,
24
+ integrated_extinction: vec4<f32>,
25
+ };
26
+
27
+ @group(0) @binding(0) var<uniform> froxelGridParams: FroxelGridParams;
28
+ @group(0) @binding(1) var<uniform> volumetricIntegrationParams: VolumetricIntegrationParams;
29
+ @group(0) @binding(2) var<storage, read> froxelMediumInput: array<FroxelMediumVoxel>;
30
+ @group(0) @binding(3) var<storage, read> froxelShadowInput: array<VolumetricShadowHistory>;
31
+ @group(0) @binding(4) var<storage, read_write> froxelIntegratedOutput: array<FroxelIntegratedVoxel>;
32
+
33
+ fn froxel_integrate_index(coord: vec3<u32>) -> u32 {
34
+ let plane = max(froxelGridParams.grid_width, 1u) * max(froxelGridParams.grid_height, 1u);
35
+ return coord.z * plane + coord.y * max(froxelGridParams.grid_width, 1u) + coord.x;
36
+ }
37
+
38
+ fn safe_normalize(value: vec3<f32>) -> vec3<f32> {
39
+ let length_value = length(value);
40
+ if (length_value <= 0.000001) {
41
+ return vec3<f32>(0.0, 1.0, 0.0);
42
+ }
43
+ return value / length_value;
44
+ }
45
+
46
+ fn henyey_greenstein(cos_theta: f32, anisotropy: f32) -> f32 {
47
+ let g = clamp(anisotropy, -0.85, 0.85);
48
+ let denominator = pow(max(1.0 + g * g - 2.0 * g * cos_theta, 0.001), 1.5);
49
+ return (1.0 - g * g) / max(12.566370614359172 * denominator, 0.001);
50
+ }
51
+
52
+ @compute @workgroup_size(4, 4, 4)
53
+ fn process_job(@builtin(global_invocation_id) global_id: vec3<u32>) {
54
+ if (
55
+ global_id.x >= froxelGridParams.grid_width ||
56
+ global_id.y >= froxelGridParams.grid_height ||
57
+ global_id.z >= froxelGridParams.grid_depth
58
+ ) {
59
+ return;
60
+ }
61
+
62
+ let index = froxel_integrate_index(global_id);
63
+ let medium = froxelMediumInput[index];
64
+ let shadow = froxelShadowInput[index];
65
+ let previous = froxelIntegratedOutput[index];
66
+ let light_direction = safe_normalize(volumetricIntegrationParams.light_direction);
67
+ let slice_depth = (f32(global_id.z) + 0.5) / max(f32(froxelGridParams.grid_depth), 1.0);
68
+ let density = max(medium.extinction_density.w + volumetricIntegrationParams.extinction_bias, 0.0);
69
+ let extinction = max(medium.extinction_density.xyz, vec3<f32>(0.0001));
70
+ let albedo = clamp(medium.inscattering_anisotropy.xyz, vec3<f32>(0.0), vec3<f32>(4.0));
71
+ let anisotropy = medium.inscattering_anisotropy.w;
72
+ let phase = henyey_greenstein(light_direction.y, anisotropy + volumetricIntegrationParams.phase_bias * 0.1);
73
+ let step_length =
74
+ max(volumetricIntegrationParams.integration_step_scale, 0.2) /
75
+ max(f32(froxelGridParams.grid_depth), 1.0);
76
+ let shadow_visibility = shadow.shadow_transmittance.w;
77
+ let ambient_term = vec3<f32>(0.08, 0.1, 0.14) * max(volumetricIntegrationParams.ambient_boost, 0.0);
78
+ let sample = MediumSample(
79
+ extinction,
80
+ (albedo * (0.25 + slice_depth * 0.5) + ambient_term) * shadow_visibility * phase
81
+ );
82
+ let transmittance = exp(-sample.extinction * density * step_length);
83
+ let integrated_scattering =
84
+ sample.inscattering *
85
+ froxelGridParams.scattering_strength *
86
+ (1.0 - transmittance) *
87
+ (1.0 + shadow.depth_phase.z * 0.2);
88
+ let integrated_extinction = sample.extinction * density * step_length;
89
+ let stability = clamp(
90
+ shadow.depth_phase.w * volumetricIntegrationParams.temporal_stability,
91
+ 0.0,
92
+ 1.0
93
+ );
94
+ let blend = clamp(volumetricIntegrationParams.history_blend, 0.0, 0.98) * stability;
95
+ let resolved_scattering =
96
+ previous.integrated_scattering.xyz * blend +
97
+ integrated_scattering * (1.0 - blend);
98
+ let resolved_extinction =
99
+ previous.integrated_extinction.xyz * blend +
100
+ integrated_extinction * (1.0 - blend);
101
+
102
+ froxelIntegratedOutput[index] = FroxelIntegratedVoxel(
103
+ vec4<f32>(resolved_scattering, shadow_visibility),
104
+ vec4<f32>(resolved_extinction, stability)
105
+ );
3
106
  }
@@ -1,3 +1,97 @@
1
- fn process_job() {
2
- // Placeholder volumetric shadow resolve stage.
1
+ struct VolumetricLightParams {
2
+ light_direction: vec3<f32>,
3
+ history_blend: f32,
4
+ light_color: vec3<f32>,
5
+ shadow_strength: f32,
6
+ slice_depth_scale: f32,
7
+ density_bias: f32,
8
+ phase_bias: f32,
9
+ jitter_amount: f32,
10
+ };
11
+
12
+ struct FroxelMediumVoxel {
13
+ extinction_density: vec4<f32>,
14
+ inscattering_anisotropy: vec4<f32>,
15
+ };
16
+
17
+ struct VolumetricShadowHistory {
18
+ shadow_transmittance: vec4<f32>,
19
+ depth_phase: vec4<f32>,
20
+ };
21
+
22
+ @group(0) @binding(0) var<uniform> froxelGridParams: FroxelGridParams;
23
+ @group(0) @binding(1) var<uniform> volumetricLightParams: VolumetricLightParams;
24
+ @group(0) @binding(2) var<storage, read> froxelMediumInput: array<FroxelMediumVoxel>;
25
+ @group(0) @binding(3) var<storage, read> froxelShadowHistory: array<VolumetricShadowHistory>;
26
+ @group(0) @binding(4) var<storage, read_write> froxelShadowOutput: array<VolumetricShadowHistory>;
27
+
28
+ fn froxel_shadow_index(coord: vec3<u32>) -> u32 {
29
+ let plane = max(froxelGridParams.grid_width, 1u) * max(froxelGridParams.grid_height, 1u);
30
+ return coord.z * plane + coord.y * max(froxelGridParams.grid_width, 1u) + coord.x;
31
+ }
32
+
33
+ fn safe_normalize(value: vec3<f32>) -> vec3<f32> {
34
+ let length_value = length(value);
35
+ if (length_value <= 0.000001) {
36
+ return vec3<f32>(0.0, 1.0, 0.0);
37
+ }
38
+ return value / length_value;
39
+ }
40
+
41
+ fn henyey_greenstein(cos_theta: f32, anisotropy: f32) -> f32 {
42
+ let g = clamp(anisotropy, -0.85, 0.85);
43
+ let denominator = pow(max(1.0 + g * g - 2.0 * g * cos_theta, 0.001), 1.5);
44
+ return (1.0 - g * g) / max(12.566370614359172 * denominator, 0.001);
45
+ }
46
+
47
+ @compute @workgroup_size(4, 4, 4)
48
+ fn process_job(@builtin(global_invocation_id) global_id: vec3<u32>) {
49
+ if (
50
+ global_id.x >= froxelGridParams.grid_width ||
51
+ global_id.y >= froxelGridParams.grid_height ||
52
+ global_id.z >= froxelGridParams.grid_depth
53
+ ) {
54
+ return;
55
+ }
56
+
57
+ let index = froxel_shadow_index(global_id);
58
+ let medium = froxelMediumInput[index];
59
+ let previous = froxelShadowHistory[index];
60
+ let light_direction = safe_normalize(volumetricLightParams.light_direction);
61
+ let slice_depth = (f32(global_id.z) + 0.5) / max(f32(froxelGridParams.grid_depth), 1.0);
62
+ let density = max(medium.extinction_density.w + volumetricLightParams.density_bias, 0.0);
63
+ let extinction = max(dot(medium.extinction_density.xyz, vec3<f32>(0.2126, 0.7152, 0.0722)), 0.0001);
64
+ let anisotropy = medium.inscattering_anisotropy.w;
65
+ let phase_weight = henyey_greenstein(light_direction.y, anisotropy);
66
+ let depth_scale = max(volumetricLightParams.slice_depth_scale, 0.25);
67
+ let shadow_distance = depth_scale * mix(0.35, 1.35, slice_depth);
68
+ let optical_depth = (density * 0.7 + extinction * 0.3) * shadow_distance;
69
+ let raw_transmittance = exp(-optical_depth);
70
+ let horizon_wrap = 0.35 + 0.65 * saturate(light_direction.y * 0.5 + 0.5);
71
+ let jitter = fract(
72
+ f32(global_id.x * 19u + global_id.y * 47u + global_id.z * 73u) * 0.61803398875
73
+ );
74
+ let visibility = clamp(
75
+ raw_transmittance * horizon_wrap * (1.0 - volumetricLightParams.jitter_amount * (jitter - 0.5)),
76
+ 0.0,
77
+ 1.0
78
+ );
79
+ let history_visibility = previous.shadow_transmittance.w;
80
+ let blend = clamp(volumetricLightParams.history_blend, 0.0, 0.98);
81
+ let shadow_transmittance = mix(visibility, history_visibility, blend);
82
+ let light_radiance =
83
+ volumetricLightParams.light_color *
84
+ shadow_transmittance *
85
+ max(volumetricLightParams.shadow_strength, 0.0) *
86
+ (0.55 + phase_weight * 0.45);
87
+ let depth_confidence = clamp(
88
+ slice_depth * 0.45 + shadow_transmittance * 0.35 + (1.0 - density) * 0.2,
89
+ 0.0,
90
+ 1.0
91
+ );
92
+
93
+ froxelShadowOutput[index] = VolumetricShadowHistory(
94
+ vec4<f32>(light_radiance, shadow_transmittance),
95
+ vec4<f32>(slice_depth, optical_depth, phase_weight, depth_confidence)
96
+ );
3
97
  }