|
| 1 | +--- |
| 2 | +title: "@typegpu/radiance-cascades" |
| 3 | +--- |
| 4 | + |
| 5 | +import { Image } from 'astro:assets'; |
| 6 | +import basicPreview from '../../../assets/docs/typegpu-radiance-cascades-basic.png'; |
| 7 | +import generatedSdfPreview from '../../../assets/docs/typegpu-radiance-cascades-generated-sdf.png'; |
| 8 | +import customRayMarchingPreview from '../../../assets/docs/typegpu-radiance-cascades-custom-ray-marching.png'; |
| 9 | + |
| 10 | +The `@typegpu/radiance-cascades` package provides a small TypeGPU runner for computing 2D radiance cascades. |
| 11 | +It is designed for screen-space lighting where your scene can be described by: |
| 12 | + |
| 13 | +- an SDF callback sampled in normalized UV space, |
| 14 | +- a color callback sampled at hit points, |
| 15 | +- an output texture that stores the resolved radiance field. |
| 16 | + |
| 17 | +The runner owns the cascade textures and dispatches the compute passes in the right order. You provide the scene functions and call `run()` when the scene changes. |
| 18 | + |
| 19 | +:::note |
| 20 | +The package currently focuses on 2D radiance fields. It expects distances in UV-space units, where the visible domain usually spans `[0, 1]` on both axes. |
| 21 | +::: |
| 22 | + |
| 23 | +## Basic usage |
| 24 | + |
| 25 | +<div class="my-4 flex justify-center"> |
| 26 | + <Image src={basicPreview} alt="A simple radiance cascades scene lit from SDF surfaces" class="w-full max-w-[20rem] rounded-md" /> |
| 27 | +</div> |
| 28 | + |
| 29 | +```ts twoslash |
| 30 | +import * as rc from '@typegpu/radiance-cascades'; |
| 31 | +import * as sdf from '@typegpu/sdf'; |
| 32 | +import tgpu, { d, std } from 'typegpu'; |
| 33 | + |
| 34 | +const root = await tgpu.init(); |
| 35 | +const previewSize = { width: 512, height: 512 }; |
| 36 | + |
| 37 | +const sceneSdf = tgpu.fn([d.vec2f], d.f32)((uv) => { |
| 38 | + 'use gpu'; |
| 39 | + const circle = sdf.sdDisk(uv - d.vec2f(0.5), 0.18); |
| 40 | + const wall = sdf.sdRoundedBox2d(uv - d.vec2f(0.5, 0.82), d.vec2f(0.42, 0.03), 0.01); |
| 41 | + return sdf.opUnion(circle, wall); |
| 42 | +}); |
| 43 | + |
| 44 | +const surfaceColor = tgpu.fn([d.vec2f], d.vec3f)((uv) => { |
| 45 | + 'use gpu'; |
| 46 | + return std.mix(d.vec3f(1, 0.82, 0.5), d.vec3f(0.28, 0.52, 1), uv.x); |
| 47 | +}); |
| 48 | + |
| 49 | +const runner = rc.createRadianceCascades({ |
| 50 | + root, |
| 51 | + size: previewSize, |
| 52 | + sdfResolution: { width: 1024, height: 1024 }, |
| 53 | + sdf: sceneSdf, |
| 54 | + color: surfaceColor, |
| 55 | +}); |
| 56 | + |
| 57 | +runner.run(); |
| 58 | + |
| 59 | +const radianceView = runner.output.createView(d.texture2d()); |
| 60 | +const sampler = root.createSampler({ magFilter: 'linear', minFilter: 'linear' }); |
| 61 | + |
| 62 | +const renderPreview = tgpu.fn([d.vec2f], d.vec4f)((uv) => { |
| 63 | + 'use gpu'; |
| 64 | + const dist = sceneSdf(uv); |
| 65 | + const edge = std.max(std.fwidth(dist), 0.001); |
| 66 | + const surface = 1 - std.smoothstep(-edge, edge, dist); |
| 67 | + const radiance = std.textureSampleLevel(radianceView.$, sampler.$, uv, 0).xyz; |
| 68 | + |
| 69 | + const bg = std.mix(d.vec3f(0.04, 0.05, 0.07), d.vec3f(0.11, 0.15, 0.22), uv.y); |
| 70 | + const lit = bg + radiance * 1.55; |
| 71 | + const color = std.mix(lit, surfaceColor(uv), surface); |
| 72 | + |
| 73 | + return d.vec4f(std.min(color, d.vec3f(1)), 1); |
| 74 | +}); |
| 75 | +``` |
| 76 | + |
| 77 | +`size` controls the output texture resolution when the runner creates the texture for you. `sdfResolution` should match the resolution of the SDF source you are sampling from, or the resolution you use to think about texel-sized marching thresholds. |
| 78 | + |
| 79 | +## Using a generated SDF texture |
| 80 | + |
| 81 | +A common setup is to generate an SDF texture with [`@typegpu/sdf`](/TypeGPU/ecosystem/typegpu-sdf/) and feed it into the radiance runner. |
| 82 | + |
| 83 | +<div class="my-4 flex justify-center"> |
| 84 | + <Image src={generatedSdfPreview} alt="A radiance cascades scene using a generated SDF texture" class="w-full max-w-[24rem] rounded-md" /> |
| 85 | +</div> |
| 86 | + |
| 87 | +```ts |
| 88 | +import * as rc from '@typegpu/radiance-cascades'; |
| 89 | +import * as sdf from '@typegpu/sdf'; |
| 90 | +import tgpu, { d, std } from 'typegpu'; |
| 91 | + |
| 92 | +const root = await tgpu.init(); |
| 93 | +const floodSize = { width: 2048, height: 2048 }; |
| 94 | +const previewSize = { width: 512, height: 512 }; |
| 95 | + |
| 96 | +const sourceTexture = root |
| 97 | + .createTexture({ |
| 98 | + size: [floodSize.width, floodSize.height], |
| 99 | + format: 'rgba8unorm', |
| 100 | + }) |
| 101 | + .$usage('storage', 'sampled'); |
| 102 | + |
| 103 | +// Draw or bake the source mask into sourceTexture first. |
| 104 | +const sourceLayout = tgpu.bindGroupLayout({ |
| 105 | + source: { texture: d.texture2d() }, |
| 106 | +}); |
| 107 | +const sourceBindGroup = root.createBindGroup(sourceLayout, { |
| 108 | + source: sourceTexture.createView(), |
| 109 | +}); |
| 110 | + |
| 111 | +const floodRunner = sdf |
| 112 | + .createJumpFlood({ |
| 113 | + root, |
| 114 | + size: floodSize, |
| 115 | + classify: (coord) => { |
| 116 | + 'use gpu'; |
| 117 | + return std.textureLoad(sourceLayout.$.source, coord, 0).w > 0.5; |
| 118 | + }, |
| 119 | + getSdf: (_coord, size, signedDist) => { |
| 120 | + 'use gpu'; |
| 121 | + return signedDist / d.f32(std.min(size.x, size.y)); |
| 122 | + }, |
| 123 | + getColor: (_coord, _size, _signedDist, insidePx) => { |
| 124 | + 'use gpu'; |
| 125 | + const source = std.textureLoad(sourceLayout.$.source, insidePx, 0); |
| 126 | + return d.vec4f(source.xyz, 1); |
| 127 | + }, |
| 128 | + }) |
| 129 | + .with(sourceBindGroup); |
| 130 | + |
| 131 | +floodRunner.run(); |
| 132 | + |
| 133 | +const floodSdfView = floodRunner.sdfOutput.createView(); |
| 134 | +const floodColorView = floodRunner.colorOutput.createView(); |
| 135 | +const sampler = root.createSampler({ magFilter: 'linear', minFilter: 'linear' }); |
| 136 | + |
| 137 | +const runner = rc.createRadianceCascades({ |
| 138 | + root, |
| 139 | + size: previewSize, |
| 140 | + sdfResolution: floodSize, |
| 141 | + sdf: (uv) => { |
| 142 | + 'use gpu'; |
| 143 | + if (uv.x < 0 || uv.x > 1 || uv.y < 0 || uv.y > 1) { |
| 144 | + return 1; |
| 145 | + } |
| 146 | + return std.textureSampleLevel(floodSdfView.$, sampler.$, uv, 0).x; |
| 147 | + }, |
| 148 | + color: (uv) => { |
| 149 | + 'use gpu'; |
| 150 | + return std.textureSampleLevel(floodColorView.$, sampler.$, uv, 0).xyz; |
| 151 | + }, |
| 152 | +}); |
| 153 | + |
| 154 | +runner.run(); |
| 155 | +``` |
| 156 | + |
| 157 | +This is the same shape used by the [Radiance Cascades (with drawing)](/TypeGPU/examples#example=rendering--radiance-cascades-drawing) example: draw into a texture, rebuild an SDF with jump flooding, then update the radiance field from that SDF. |
| 158 | + |
| 159 | +## Output textures |
| 160 | + |
| 161 | +If you pass no `output`, the runner creates an `rgba16float` texture with `storage` and `sampled` usage and exposes it as `runner.output`. |
| 162 | + |
| 163 | +You can also pass your own output texture or storage texture view: |
| 164 | + |
| 165 | +```ts |
| 166 | +const output = root |
| 167 | + .createTexture({ |
| 168 | + size: [width, height], |
| 169 | + format: 'rgba16float', |
| 170 | + }) |
| 171 | + .$usage('storage', 'sampled'); |
| 172 | + |
| 173 | +const runner = rc.createRadianceCascades({ |
| 174 | + root, |
| 175 | + output, |
| 176 | + sdfResolution, |
| 177 | + sdf: sceneSdf, |
| 178 | + color: surfaceColor, |
| 179 | +}); |
| 180 | +``` |
| 181 | + |
| 182 | +When a storage view cannot provide its size, pass `size` explicitly alongside `output`. |
| 183 | + |
| 184 | +## Updating and cleanup |
| 185 | + |
| 186 | +The executor has a small lifecycle: |
| 187 | + |
| 188 | +- `runner.run()` dispatches all cascade passes and writes the current radiance field. |
| 189 | +- `runner.output` is the sampled/storage `rgba16float` result. |
| 190 | +- `runner.with(bindGroup)` returns an executor with an extra bind group attached to all internal passes. |
| 191 | +- `runner.destroy()` releases the cascade textures, and also releases the output texture if the runner created it. |
| 192 | + |
| 193 | +Use `with(bindGroup)` when your SDF, color, or ray marching callback reads resources that are not captured through TypeGPU accessors. |
| 194 | + |
| 195 | +## Custom ray marching |
| 196 | + |
| 197 | +By default, the runner uses `defaultRayMarch`, which sphere-traces through the supplied SDF and samples `color` at the first hit. |
| 198 | +For custom attenuation, translucent surfaces, or non-SDF sources, pass a `rayMarch` callback. |
| 199 | +This example adds near-surface haze and lets part of the light continue after a hit. |
| 200 | + |
| 201 | +<div class="my-4 flex justify-center"> |
| 202 | + <Image src={customRayMarchingPreview} alt="A radiance cascades scene with custom ray marching haze" class="w-full max-w-[24rem] rounded-md" /> |
| 203 | +</div> |
| 204 | + |
| 205 | +```ts |
| 206 | +const runner = rc.createRadianceCascades({ |
| 207 | + root, |
| 208 | + size, |
| 209 | + sdfResolution, |
| 210 | + sdf: sceneSdf, |
| 211 | + color: surfaceColor, |
| 212 | + rayMarch: (probePos, rayDir, startT, endT, eps, minStep, bias) => { |
| 213 | + 'use gpu'; |
| 214 | + let color = d.vec3f(); |
| 215 | + let transmittance = d.f32(1); |
| 216 | + let t = startT; |
| 217 | + |
| 218 | + for (let step = 0; step < 64; step++) { |
| 219 | + if (t > endT || transmittance < 0.02) { |
| 220 | + break; |
| 221 | + } |
| 222 | + |
| 223 | + const pos = probePos + rayDir * t; |
| 224 | + if (std.any(std.lt(pos, d.vec2f(0))) || std.any(std.gt(pos, d.vec2f(1)))) { |
| 225 | + break; |
| 226 | + } |
| 227 | + |
| 228 | + const signedDist = sceneSdf(pos); |
| 229 | + const stepSize = std.max(signedDist + bias, minStep); |
| 230 | + const haze = std.exp(-std.abs(signedDist) * 34) * stepSize * 18; |
| 231 | + const hazeColor = std.mix(d.vec3f(1, 0.38, 0.14), d.vec3f(0.22, 0.56, 1), pos.x); |
| 232 | + color += hazeColor * haze * transmittance; |
| 233 | + |
| 234 | + if (signedDist + bias <= eps) { |
| 235 | + color += surfaceColor(pos) * transmittance * 0.65; |
| 236 | + transmittance *= 0.35; |
| 237 | + break; |
| 238 | + } |
| 239 | + |
| 240 | + transmittance *= std.max(1 - haze * 0.08, 0.72); |
| 241 | + t += stepSize; |
| 242 | + } |
| 243 | + |
| 244 | + return rc.RayMarchResult({ |
| 245 | + color, |
| 246 | + transmittance, |
| 247 | + }); |
| 248 | + }, |
| 249 | +}); |
| 250 | +``` |
| 251 | + |
| 252 | +Custom ray marchers should return `RayMarchResult`, where `color` is accumulated radiance and `transmittance` is how much light should continue to the next cascade (`1` means no hit, `0` means fully blocked). |
| 253 | + |
| 254 | +## Advanced exports |
| 255 | + |
| 256 | +Most users only need `createRadianceCascades`. The package also exports lower-level pieces for custom runners and experiments: |
| 257 | + |
| 258 | +| Export | Purpose | |
| 259 | +| --- | --- | |
| 260 | +| `defaultRayMarch` | The built-in ray marcher used by the runner | |
| 261 | +| `RayMarchResult` | Return struct for custom ray marchers | |
| 262 | +| `getCascadeDim` | Computes internal cascade texture dimensions | |
| 263 | +| `sdfSlot`, `colorSlot`, `rayMarchSlot`, `sdfResolutionSlot` | Slots used by the internal cascade compute pass | |
| 264 | + |
| 265 | +See the [package source](https://github.com/software-mansion/TypeGPU/tree/main/packages/typegpu-radiance-cascades/src) for details that are not yet covered here. |
0 commit comments