Skip to content
Draft
Show file tree
Hide file tree
Changes from 39 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
cb752b3
Add example base
Dec 5, 2025
a37f7c5
Rewrite some functions to tgsl
Dec 5, 2025
3111af9
Rewrite more functions
Dec 5, 2025
a7f41c8
Add some of shaderToy's textures
Dec 8, 2025
752d336
Make shaders compile
Dec 8, 2025
afad28d
Fix types
Dec 8, 2025
d739ac3
Somewhat working shader
Dec 8, 2025
825eedb
Fix the example
Dec 10, 2025
4b3ef04
Move pipelineABC to another file
Dec 10, 2025
7074842
Move another pipeline
Dec 10, 2025
562cc4c
Add a resize observer
Dec 10, 2025
0b70082
Remove pipelineD
Dec 10, 2025
31bc3cb
Move another pipeline
Dec 10, 2025
f507005
Fit to container
Dec 10, 2025
f9b2e49
Presentation format
Dec 10, 2025
546bc66
Refactor
Dec 10, 2025
b851f8b
Add bilinear fix toggle
Dec 10, 2025
2ae1967
Fix texture precision loss
Dec 10, 2025
86129e2
Remove warns, rename common
Dec 10, 2025
bf5eb42
Add luminance postprocessing switch
Dec 10, 2025
bad3bc8
Add attribution
Dec 10, 2025
1063b86
Rename example
Dec 10, 2025
246a65e
Add example test
Dec 10, 2025
6ab1ccd
Add a thumbnail
Dec 10, 2025
0577257
Move pipelines.ts and root.ts to index.ts :(
Dec 10, 2025
a3ad1e8
Add number of cascades slider
Dec 10, 2025
6ab27a9
Update sdf system to no longer use pixel units
Dec 10, 2025
733427a
Add quality slider
Dec 11, 2025
ac3d321
Add support for scenes
Dec 11, 2025
330e8e4
Add hearts scene
Dec 11, 2025
afa0288
Add dots working in like 5fps
Dec 11, 2025
17b6198
Add prerender pipeline
Dec 11, 2025
1400ffd
Update quality slider
Dec 11, 2025
0899d43
Remove type errors
Dec 11, 2025
0adc69c
Rewrite to shellless
Dec 11, 2025
b5a060b
Adjust width
Dec 11, 2025
f0c478f
Tags, better dots
Dec 11, 2025
79e2210
Add mockMathRandom, fix tests
Dec 11, 2025
26c75a2
Merge remote-tracking branch 'origin/main' into docs/volumetric-gi-ex…
Dec 11, 2025
e040302
Update apps/typegpu-docs/src/examples/rendering/volumetric-radiance-c…
aleksanderkatan Dec 11, 2025
f30589d
Update tests
Dec 11, 2025
ac95f55
Merge branch 'main' into docs/volumetric-gi-example
aleksanderkatan Dec 18, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
// An implementation of "Radiance Cascades: A Novel Approach to Calculating Global Illumination."
// https://drive.google.com/file/d/1L6v1_7HY2X-LV3Ofb6oyTIxgEaP4LOI6/view
//
// This uses Radiance Cascades, or RC, to compute volumetric global illumination in 2D.
// Some tricks were pulled off with the help of youssef_afella to port with animations.
//
// RC is a method to parameterize the radiance function in a way where discrete ray intervals
// can be interpolated and made a continuous radiance field. RC has useful properties for
// global illumination where it produces fully converged radiance, or no noise. It can be
// implemented on top of any method for computing global illumination, in any number of dimensions.
//
// The algorithm has no dependency on scene complexity or the number of lights in the scene.
// Lights can be any size, and there are grid constructions that resolve 1px light sources like HRC.
//
// This particular implementation uses a simple grid and interpolator (manual bilinear), which introduces
// transmittance bias, or "ringing," and I employ some nonphysical tricks, "bilinear fix," to hide them.
// A separate issue exists with shadows where interpolation averages discontinuities to where low-variance
// or sharp shadows cannot be resolved. This can be fixed with a better probe construction like HRC.
//
// RC can be used for other purposes than just lighting. My favorite examples:
// - gravity simulator by @Suslik https://shadertoy.com/view/XcB3Ry
// - diffusion solver by AdrianM https://twitter.com/yaazarai/status/1994896819575558435
//
// Now that I have convinced you that RC is awesome and you must implement it into your game, onto the code:

import * as d from 'typegpu/data';
import * as std from 'typegpu/std';

// Behold, the one parameter of Radiance Cascades, BASE_INTERVAL_LENGTH.
// This is the minimum spatial resolution that the cascades can model at C0.
const BASE_INTERVAL_LENGTH = 0.3;

// Here is also a BASE_PROBE_SIZE (in px) to try out a sparser probe grid.
// I do a 2x2 or 4x4 probe grid for screen-space (3D) with later upscaling.
const BASE_PROBE_SIZE = 1;

// https://m4xc.dev/articles/fundamental-rc
const getIntervalScale = (cascadeIndex: number) => {
'use gpu';
if (cascadeIndex <= 0) {
return 0.0;
}
return d.f32(1 << d.u32(2 * cascadeIndex));
};

const getIntervalRange = (cascadeIndex: number) => {
'use gpu';
return d
.vec2f(
getIntervalScale(cascadeIndex),
getIntervalScale(cascadeIndex + 1),
)
.mul(BASE_INTERVAL_LENGTH);
};

export const coordToWorldPos = (coord: d.v2f, resolution: d.v2f) => {
'use gpu';
const center = resolution.mul(0.5);
const relative = coord.sub(center);
return relative.div(std.min(resolution.x, resolution.y) / 2);
};

const castInterval = (
scene: d.texture2d<d.F32>,
intervalStart: d.v2f,
intervalEnd: d.v2f,
cascadeIndex: number,
) => {
'use gpu';
const dir = intervalEnd.sub(intervalStart);
const steps = 16 << d.u32(cascadeIndex);
const stepSize = dir.div(d.f32(steps));

let radiance = d.vec3f(0);
let transmittance = d.f32(1.0);

for (let i = d.u32(0); i < steps; i++) {
const coord = intervalStart.add(stepSize.mul(d.f32(i)));
const sceneColor = std.textureLoad(scene, d.vec2i(coord), 0);
radiance = radiance.add(
sceneColor.xyz.mul(transmittance).mul(sceneColor.w),
);
transmittance *= 1.0 - sceneColor.w;
}

return d.vec4f(radiance, transmittance);
};

const mergeIntervals = (near: d.v4f, far: d.v4f) => {
'use gpu';
const radiance = near.xyz.add(far.xyz.mul(near.w));
return d.vec4f(radiance, near.w * far.w);
};

const getBilinearWeights = (ratio: d.v2f) => {
'use gpu';
return d.vec4f(
(1.0 - ratio.x) * (1.0 - ratio.y),
ratio.x * (1.0 - ratio.y),
(1.0 - ratio.x) * ratio.y,
ratio.x * ratio.y,
);
};

const getBilinearOffset = (offsetIndex: number) => {
'use gpu';
const offsets = [d.vec2i(0, 0), d.vec2i(1, 0), d.vec2i(0, 1), d.vec2i(1, 1)];
return offsets[offsetIndex];
};

// sampler2D cascadeTexture
export const castAndMerge = (
scene: d.texture2d<d.F32>,
texture: d.texture2d<d.F32>,
cascadeIndex: number,
fragCoord: d.v2f,
resolution: d.v2f,
bilinearFix: number,
cascadesNumber: number,
) => {
'use gpu';
// Probe parameters for cascade N
const probeSize = d.i32(BASE_PROBE_SIZE << d.u32(cascadeIndex));
const probeCenter = std.floor(fragCoord.div(d.f32(probeSize))).add(0.5);
const probePosition = probeCenter.mul(d.f32(probeSize));

// Interval parameters at cascade N
const dirCoord = std.mod(d.vec2i(fragCoord), probeSize);
const dirIndex = dirCoord.x + dirCoord.y * probeSize;
const dirCount = probeSize * probeSize;

// Interval direction at cascade N
const angle = 2.0 * Math.PI * ((d.f32(dirIndex) + 0.5) / d.f32(dirCount));
const dir = d.vec2f(std.cos(angle), std.sin(angle));

let radiance = d.vec4f(0, 0, 0, 1);

// Trace radiance interval at cascade N
const intervalRange = getIntervalRange(cascadeIndex);
const intervalStart = probePosition.add(dir.mul(intervalRange.x));
const intervalEnd = probePosition.add(dir.mul(intervalRange.y));
let destInterval = castInterval(
scene,
intervalStart,
intervalEnd,
cascadeIndex,
);

// Skip merge and only trace on the last cascade (computed back-to-front)
// This can instead merge with sky radiance or an envmap
if (cascadeIndex === cascadesNumber - 1) {
return destInterval;
}

// Merge cascade N+1 -> cascade N
const bilinearProbeSize = d.i32(BASE_PROBE_SIZE << d.u32(cascadeIndex + 1));
const bilinearBaseCoord = probePosition.div(d.f32(bilinearProbeSize)).sub(
0.5,
);
const ratio = std.fract(bilinearBaseCoord);
const weights = getBilinearWeights(ratio);
const baseIndex = d.vec2i(std.floor(bilinearBaseCoord));

// Merge with upper 4 probes from cascade N+1
// This could be done with hardware interpolation but OES_texture_float_linear support is spotty
// Ideally, a smaller float buffer format would be used like RGBA16F or RG11FB10F for cascades
for (let b = d.u32(0); b < 4; b++) {
// Probe parameters for cascade N+1
const baseOffset = getBilinearOffset(b);
const bilinearIndex = std.clamp(
baseIndex.add(baseOffset),
d.vec2i(0),
d.vec2i(resolution).div(bilinearProbeSize).sub(1),
);
const bilinearPosition = d.vec2f(bilinearIndex).add(0.5).mul(
d.f32(bilinearProbeSize),
);

// Cast 4 locally interpolated intervals at cascade N -> cascade N+1 (bilinear fix)
if (bilinearFix === 1) {
const intervalRange = getIntervalRange(cascadeIndex);
const intervalStart = probePosition.add(dir.mul(intervalRange.x));
const intervalEnd = bilinearPosition.add(dir.mul(intervalRange.y));
destInterval = castInterval(
scene,
intervalStart,
intervalEnd,
cascadeIndex,
);
}

// Sample and interpolate 4 probe directions
let bilinearRadiance = d.vec4f(0.0);
for (let dd = 0; dd < 4; dd++) {
// Fetch and merge with interval dd at probe b from cascade N+1
const baseDirIndex = dirIndex * 4;
const bilinearDirIndex = baseDirIndex + dd;
const bilinearDirCoord = d.vec2i(
bilinearDirIndex % bilinearProbeSize,
bilinearDirIndex / bilinearProbeSize,
);
const bilinearTexel = bilinearIndex.mul(bilinearProbeSize).add(
bilinearDirCoord,
);
const bilinearInterval = std.textureLoad(
texture,
bilinearTexel,
0,
);
bilinearRadiance = bilinearRadiance.add(
mergeIntervals(destInterval, bilinearInterval).mul(weights[b]),
);
}

// Average of 4 bilinear samples
radiance = radiance.add(bilinearRadiance.mul(0.25));
}

return radiance;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import * as d from 'typegpu/data';
import * as std from 'typegpu/std';

// ACES tonemapping fit for the sRGB color space
// https://github.com/TheRealMJP/BakingLab/blob/master/BakingLab/ACES.hlsl
export const tonemapACES = (colorArg: d.v3f): d.v3f => {
'use gpu';
let color = colorArg.xyz;

// sRGB => XYZ => D65_2_D60 => AP1 => RRT_SAT
const acesInputMat = d.mat3x3f(
0.59719,
0.07600,
0.02840,
0.35458,
0.90834,
0.13383,
0.04823,
0.01566,
0.83777,
);

// ODT_SAT => XYZ => D60_2_D65 => sRGB
const acesOutputMat = d.mat3x3f(
1.60475,
-0.10208,
-0.00327,
-0.53108,
1.10813,
-0.07276,
-0.07367,
-0.00605,
1.07602,
);

color = acesInputMat.mul(color);

// Apply RRT and ODT
const a = color.mul(color.add(0.0245786)).sub(0.000090537);
const b = color.mul(color.mul(0.983729).add(0.4329510)).add(0.238081);
color = a.div(b);

color = acesOutputMat.mul(color);

// Clamp to [0, 1]
return std.clamp(color, d.vec3f(0.0), d.vec3f(1.0));
};

export const gammaSRGB = (linearSRGB: d.v3f) => {
'use gpu';
const a = linearSRGB.mul(12.92);
const b = std.pow(linearSRGB, d.vec3f(1.0 / 2.4)).mul(1.055).sub(0.055);
const c = std.step(d.vec3f(0.0031308), linearSRGB);
return std.mix(a, b, c);
};

export const exposure = 1.0;
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<canvas data-fit-to-container></canvas>
<p
class="absolute px-2 py-1 rounded-xl top-2 mx-auto text-lg text-center text-white bg-black/50"
>
Port of "<a
class="text-purple-300"
href="https://www.shadertoy.com/view/wfyyDz"
target="_blank"
rel="noreferrer"
>2D Volumetric Radiance Cascades</a>" by codyjbennett
</p>
Loading
Loading