Skip to content

Commit cf5203f

Browse files
feat(examples): add Spectral Caustics Prism Cathedral (#2826)
1 parent 7b07d5b commit cf5203f

12 files changed

Lines changed: 1261 additions & 0 deletions

File tree

examples/experimental/spectral-caustics/app.ts

Lines changed: 956 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<!doctype html>
2+
<head>
3+
<meta charset="UTF-8" />
4+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
5+
<title>Spectral Caustics: Prism Cathedral</title>
6+
<style>
7+
html,
8+
body {
9+
width: 100%;
10+
height: 100%;
11+
margin: 0;
12+
overflow: hidden;
13+
background: #02040a;
14+
color-scheme: dark;
15+
}
16+
</style>
17+
</head>
18+
<script type="module">
19+
import {luma} from '@luma.gl/core';
20+
import {makeAnimationLoop} from '@luma.gl/engine';
21+
import {webgpuAdapter} from '@luma.gl/webgpu';
22+
import AnimationLoopTemplate from './app.ts';
23+
24+
const supportsHighDynamicRange =
25+
typeof window.matchMedia === 'function' &&
26+
window.matchMedia('(dynamic-range: high)').matches;
27+
const device = luma.createDevice({
28+
id: 'spectral-caustics-prism-cathedral',
29+
adapters: [webgpuAdapter],
30+
createCanvasContext: supportsHighDynamicRange
31+
? {colorFormat: 'rgba16float', colorSpace: 'display-p3', toneMapping: 'extended'}
32+
: true
33+
});
34+
const animationLoop = makeAnimationLoop(AnimationLoopTemplate, {device});
35+
animationLoop.start();
36+
</script>
37+
<body></body>
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"name": "luma.gl-examples-experimental-spectral-caustics",
3+
"version": "9.4.0-alpha.3",
4+
"private": true,
5+
"type": "module",
6+
"scripts": {
7+
"start": "vite",
8+
"build": "tsc && vite build",
9+
"serve": "vite preview"
10+
},
11+
"dependencies": {
12+
"@luma.gl/core": "9.4.0-alpha.3",
13+
"@luma.gl/effects": "9.4.0-alpha.3",
14+
"@luma.gl/engine": "9.4.0-alpha.3",
15+
"@luma.gl/experimental": "9.4.0-alpha.3",
16+
"@luma.gl/shadertools": "9.4.0-alpha.3",
17+
"@luma.gl/tables": "9.4.0-alpha.3",
18+
"@luma.gl/webgpu": "9.4.0-alpha.3",
19+
"@math.gl/core": "^4.1.0"
20+
},
21+
"devDependencies": {
22+
"typescript": "^6.0.3",
23+
"vite": "^8.0.0"
24+
}
25+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ESNext",
4+
"useDefineForClassFields": true,
5+
"lib": ["DOM", "DOM.Iterable", "ESNext"],
6+
"allowJs": false,
7+
"skipLibCheck": true,
8+
"strict": true,
9+
"forceConsistentCasingInFileNames": true,
10+
"noEmit": true,
11+
"module": "ESNext",
12+
"moduleResolution": "Bundler",
13+
"isolatedModules": true
14+
},
15+
"include": ["."]
16+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import {defineConfig} from 'vite';
2+
3+
const alias = {
4+
'@luma.gl/core': `${__dirname}/../../../modules/core/src`,
5+
'@luma.gl/effects': `${__dirname}/../../../modules/effects/src`,
6+
'@luma.gl/engine': `${__dirname}/../../../modules/engine/src`,
7+
'@luma.gl/experimental': `${__dirname}/../../../modules/experimental/src`,
8+
'@luma.gl/shadertools': `${__dirname}/../../../modules/shadertools/src`,
9+
'@luma.gl/tables': `${__dirname}/../../../modules/tables/src`,
10+
'@luma.gl/webgpu': `${__dirname}/../../../modules/webgpu/src`
11+
};
12+
13+
export default defineConfig({resolve: {alias}, server: {open: true}});

scripts/examples-typecheck.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const SUPPORTED_EXAMPLE_WORKSPACES = new Set([
2424
'experimental/gpu-frustum-culling',
2525
'experimental/gpu-trace-viewer',
2626
'experimental/gpu-sort',
27+
'experimental/spectral-caustics',
2728
'api/video-texture',
2829
'experimental/webxr-kaleidoscope',
2930
'integrations/hello-react',
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// luma.gl
2+
// SPDX-License-Identifier: MIT
3+
// Copyright (c) vis.gl contributors
4+
5+
import {describe, expect, test, vi} from 'vitest';
6+
import {Buffer, type Texture} from '@luma.gl/core';
7+
import type {AnimationProps} from '@luma.gl/engine';
8+
import {fromHalfFloat} from '@luma.gl/shadertools';
9+
import {getWebGPUTestDevice} from '@luma.gl/test-utils';
10+
import SpectralCausticsAnimationLoopTemplate, {
11+
type SpectralCausticsExampleProps
12+
} from '../../examples/experimental/spectral-caustics/app';
13+
14+
describe('Spectral Caustics: Prism Cathedral', () => {
15+
test('traces finite HDR caustics and renders them into the floating-point beauty target', async () => {
16+
const device = await getWebGPUTestDevice('max');
17+
if (!device) {
18+
return;
19+
}
20+
21+
const host = document.createElement('div');
22+
host.id = 'example-panel-host';
23+
document.body.append(host);
24+
let viewer: SpectralCausticsAnimationLoopTemplate | null = null;
25+
const width = 96;
26+
const height = 72;
27+
const mapSize = 64;
28+
try {
29+
viewer = new SpectralCausticsAnimationLoopTemplate({
30+
device,
31+
width,
32+
height,
33+
captureSize: 32,
34+
mapSize
35+
} as SpectralCausticsExampleProps);
36+
37+
expect(viewer.sceneColorTexture.format).toBe('rgba16float');
38+
expect(viewer.spectralCausticMap.format).toBe('rgba16float');
39+
40+
// This assertion targets the two offscreen HDR outputs. The shared test canvas can outlive
41+
// Dawn's external presentation instance as the complete SwiftShader suite advances through
42+
// independent files, so do not present the already-verified beauty target in this test.
43+
const presentationSpy = vi
44+
.spyOn(viewer.postprocessingRenderer, 'renderToScreen')
45+
.mockImplementation(() => {});
46+
try {
47+
viewer.onRender(makeAnimationProps(device, width, height));
48+
} finally {
49+
presentationSpy.mockRestore();
50+
}
51+
device.submit();
52+
53+
const causticXyz = await readRgba16FloatTexture(viewer.spectralCausticMap, mapSize, mapSize);
54+
expect(causticXyz.every(Number.isFinite)).toBe(true);
55+
expect(getMaximumRgb(causticXyz)).toBeGreaterThan(1);
56+
57+
const sceneColor = await readRgba16FloatTexture(viewer.sceneColorTexture, width, height);
58+
expect(sceneColor.every(Number.isFinite)).toBe(true);
59+
expect(getMaximumRgb(sceneColor)).toBeGreaterThan(1);
60+
} finally {
61+
viewer?.onFinalize();
62+
host.remove();
63+
}
64+
}, 30_000);
65+
});
66+
67+
function makeAnimationProps(
68+
device: AnimationProps['device'],
69+
width: number,
70+
height: number
71+
): AnimationProps {
72+
return {
73+
device,
74+
tick: 1000,
75+
time: 1000,
76+
width,
77+
height,
78+
aspect: width / height
79+
} as AnimationProps;
80+
}
81+
82+
async function readRgba16FloatTexture(
83+
texture: Texture,
84+
width: number,
85+
height: number
86+
): Promise<Float32Array> {
87+
if (texture.format !== 'rgba16float') {
88+
throw new Error(`Expected rgba16float texture, received ${texture.format}.`);
89+
}
90+
const readOptions = {width, height};
91+
const layout = texture.computeMemoryLayout(readOptions);
92+
const readback = texture.device.createBuffer({
93+
id: `${texture.id}-example-test-readback`,
94+
byteLength: layout.byteLength,
95+
usage: Buffer.COPY_DST | Buffer.MAP_READ
96+
});
97+
try {
98+
texture.readBuffer(readOptions, readback);
99+
texture.device.submit();
100+
const bytes = await readback.readAsync(0, layout.byteLength);
101+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
102+
const values = new Float32Array(width * height * 4);
103+
for (let yCoordinate = 0; yCoordinate < height; yCoordinate++) {
104+
for (let xCoordinate = 0; xCoordinate < width; xCoordinate++) {
105+
const valueOffset = (xCoordinate + width * yCoordinate) * 4;
106+
const byteOffset = yCoordinate * layout.bytesPerRow + xCoordinate * layout.bytesPerPixel;
107+
for (let channel = 0; channel < 4; channel++) {
108+
values[valueOffset + channel] = fromHalfFloat(
109+
view.getUint16(byteOffset + channel * Uint16Array.BYTES_PER_ELEMENT, true)
110+
);
111+
}
112+
}
113+
}
114+
return values;
115+
} finally {
116+
readback.destroy();
117+
}
118+
}
119+
120+
function getMaximumRgb(values: Float32Array): number {
121+
let maximum = 0;
122+
for (let valueOffset = 0; valueOffset < values.length; valueOffset += 4) {
123+
maximum = Math.max(
124+
maximum,
125+
values[valueOffset],
126+
values[valueOffset + 1],
127+
values[valueOffset + 2]
128+
);
129+
}
130+
return maximum;
131+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
title: 'Spectral Caustics: Prism Cathedral'
3+
description: Trace a faceted refractor into a six-band, geometry-derived HDR caustic on WebGPU.
4+
sidebar_custom_props:
5+
description: Trace a faceted refractor into a six-band, geometry-derived HDR caustic on WebGPU.
6+
backends: [webgpu]
7+
difficulty: advanced
8+
maturity: experimental
9+
topics: [compute, rendering, effects]
10+
---
11+
12+
import {SpectralCausticsExample} from '@site/src/examples';
13+
14+
<SpectralCausticsExample />
15+
16+
Prism Cathedral is a cinematic light installation: a slowly rotating faceted crystal hangs in a
17+
dark cathedral colonnade beneath an emissive aperture and overhead beam. Its geometry-derived
18+
rainbow caustic travels across the tiled floor while HDR crystal glints and bloom punctuate the
19+
otherwise restrained architecture. There are no scene controls to tune; the changing crystal
20+
orientation reveals how the transport responds to the actual refractor geometry.
21+
22+
Each frame captures the crystal's nearest and farthest surfaces from the light, then sends six
23+
CIE/D65-weighted visible-wavelength bands through both interfaces. The converging result lands on
24+
the planar receiver as additive Gaussian photon splats rather than an authored rainbow texture or
25+
analytic decal.
26+
27+
The example is built around the experimental `SpectralCausticsRenderer`. Its two light-space
28+
surface captures feed a WebGPU compute pass that applies Snell refraction, Cauchy-style dispersion,
29+
and Beer-Lambert absorption. The GPU-generated photons accumulate in a filterable `rgba16float`
30+
D65 XYZ map. Receiver shading converts that map to linear sRGB only once, at the point where it is
31+
combined with the scene's ordinary lighting.
32+
33+
The entire contribution remains linear and unclipped through the HDR scene target. Values above
34+
SDR white therefore survive into bloom and tone mapping, while overlapping wavelengths reconstruct
35+
bright white light and separated paths produce saturated spectral fringes. On a compatible display,
36+
the example requests luma.gl's high-dynamic-range canvas profile; other displays receive the same
37+
floating-point scene through the normal tone-mapped fallback.
38+
39+
This is a focused convex-refractor demonstration, not general ray tracing. One light-space view
40+
traces one closed convex object onto one planar receiver. The bounded screen-space exit search keeps
41+
the pass compact and composable, but concave, nested, open, or strongly self-occluding glass needs a
42+
different transport method. The renderer only contributes caustic lighting: the application still
43+
owns its crystal, receiver, camera, direct lighting, bloom, tone mapping, and command submission.
44+
45+
For the reusable API, capture contract, and receiver shader integration, see
46+
[`SpectralCausticsRenderer`](/docs/api-reference/experimental/spectral-caustics-renderer).

website/content/examples/table-of-contents.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@
3333
"label": "WebGPU",
3434
"items": [
3535
"showcase/lightstorm-megacity",
36+
{
37+
"type": "doc",
38+
"id": "experimental/spectral-caustics",
39+
"label": "Spectral Caustics: Prism Cathedral"
40+
},
3641
{
3742
"type": "doc",
3843
"id": "experimental/fluid-foundry",

website/src/examples.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import DOFApp from '../../examples/showcase/dof/app';
6262
import AdvancedEffectsApp from '../../examples/experimental/advanced-effects/app';
6363
import DeferredRenderingApp from '../../examples/experimental/deferred-rendering/app';
6464
import FluidFoundryApp from '../../examples/experimental/fluid-foundry/app';
65+
import SpectralCausticsApp from '../../examples/experimental/spectral-caustics/app';
6566
import ShadowMapApp from '../../examples/experimental/shadow-map/app';
6667
import ABufferApp from '../../examples/experimental/a-buffer/app';
6768

@@ -1017,6 +1018,19 @@ export const FluidFoundryExample: React.FC<WebsiteExampleProps> = props => (
10171018
/>
10181019
);
10191020

1021+
export const SpectralCausticsExample: React.FC<WebsiteExampleProps> = props => (
1022+
<LumaExample
1023+
id="spectral-caustics"
1024+
title="Spectral Caustics: Prism Cathedral"
1025+
directory="experimental"
1026+
template={SpectralCausticsApp}
1027+
config={exampleConfig}
1028+
devices={['webgpu']}
1029+
canvasContextProfile="high-dynamic-range"
1030+
{...props}
1031+
/>
1032+
);
1033+
10201034
export const ShadowMapExample: React.FC = props => (
10211035
<LumaExample
10221036
id="shadow-map"

0 commit comments

Comments
 (0)