<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://mateuszkolodziejczyk00.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://mateuszkolodziejczyk00.github.io/" rel="alternate" type="text/html" /><updated>2026-06-07T19:12:49+00:00</updated><id>https://mateuszkolodziejczyk00.github.io/feed.xml</id><title type="html">Mateusz Kołodziejczyk</title><subtitle></subtitle><author><name>Mateusz Kołodziejczyk</name></author><entry><title type="html">Variable Rate Tracing ReSTIR GI</title><link href="https://mateuszkolodziejczyk00.github.io/2025/06/18/variable-rate-tracing-restir-gi.html" rel="alternate" type="text/html" title="Variable Rate Tracing ReSTIR GI" /><published>2025-06-18T20:40:00+00:00</published><updated>2025-06-18T20:40:00+00:00</updated><id>https://mateuszkolodziejczyk00.github.io/2025/06/18/variable-rate-tracing-restir-gi</id><content type="html" xml:base="https://mateuszkolodziejczyk00.github.io/2025/06/18/variable-rate-tracing-restir-gi.html"><![CDATA[<h2 id="introduction">Introduction</h2>

<p>In this post, I’ll share my experiments combining ReSTIR and variable rate tracing (VRT) in my toy rendering engine. The idea is straightforward: ReSTIR’s spatial and temporal passes already find and reuse valuable samples for each pixel, so why not use them to fill the “missing rays” for pixels skipped by VRT in the current frame? Over the past few months, I’ve tested multiple ways to make these techniques work together. In this post, I’ll discuss what worked well, what didn’t, and why.</p>

<h2 id="variable-rate-tracing-vrt">Variable Rate Tracing (VRT)</h2>

<p>Before diving into the main topic, I’d like to provide some background on variable rate tracing (VRT). This technique has already been explored in several shipped titles, and there are a couple of presentations that share details about different implementations.</p>

<h3 id="battlefield-v">Battlefield V</h3>

<p>One of these presentations is <a href="https://developer.download.nvidia.com/video/gputechconf/gtc/2019/presentation/s91023-it-just-works-ray-traced-reflections-in-battlefield-v.pdf">It Just Works: Ray-Traced Reflections in Battlefield V</a>. The approach described there is to compute a specular and diffuse ratio. After that, the screen is divided into 16×16 tiles, and for each tile, they select the maximum ratio among all pixels in that tile. Next, they sum those maximum ratios from all tiles, and each tile’s final value is normalized by dividing it by that sum. This normalized value represents the fraction of the total ray budget allocated to that tile. To simplify ray allocation, they round the number of rays for each tile to a power of two. They use additional random numbers to decide whether to round up or down; otherwise, each tile would consistently trace too many or too few rays.</p>

<h3 id="avatar-frontiers-of-pandora">Avatar: Frontiers of Pandora</h3>

<p>A slightly different solution was proposed by the developers of Avatar: Frontiers of Pandora in <a href="https://gdcvault.com/play/1034763/Advanced-Graphics-Summit-Raytracing-in"><em>Raytracing in Snowdrop: An Optimized Lighting Pipeline for Consoles</em></a>. In this case, the variable rate is based on a computed “quality” value. First, they compute quality per pixel by considering luminance, hit distance, roughness, light variance, and surface specularity. Next, they compute both the average and the maximum pixel quality per 16×16 tile. They also reproject tile quality from the last frame and combine these three qualities to compute a final per-tile quality value, which determines the tracing rate for the next frame. Additionally, they use geometry normals to figure out a half-resolution axis.</p>

<h3 id="callisto-protocol">Callisto Protocol</h3>

<p>Another approach was introduced by the developers of <em>The Callisto Protocol</em>, described in <a href="https://advances.realtimerendering.com/s2023/index.html"><em>The Rendering of The Callisto Protocol</em></a>. In that presentation, the authors describe variable rate tracing for both shadows and reflections.</p>

<ul>
  <li>Shadows: They compute the variance of shadow occlusion within 8×4-pixel tiles, weighted by perceptual importance (the ratio between the tile’s luminance and the average scene luminance).</li>
  <li>Reflections: They use a similar combination of factors, first comparing linear reflection results to linear final scene values, then computing variance from those weighted samples to decide the tracing rate.</li>
</ul>

<p>In this implementation, for both shadows and reflections, the tracing rate is chosen among four modes: 1 ray per 1×1, 1×2, 2×1, or 2×2 pixels. Unlike the Battlefield V method, here the variable rate is calculated after tracing, then reprojected in the following frame, similarly to Avatar.</p>

<h3 id="different-implementations-of-variable-rate-shading">Different implementations of Variable Rate Shading</h3>

<p>Although not strictly related to ray tracing, I’d also like to mention <a href="https://www.unrealengine.com/en-US/blog/take-a-deep-dive-into-nanite-gpu-driven-materials"><em>Nanite GPU-Driven Materials</em></a>, which demonstrates excellent results using software variable rate shading. The authors replaced hardware VRS (8×8 tiles on AMD GPUs and 16×16 on NVIDIA) with a software approach that always uses 2×2 tiles. This yielded remarkable performance gains - an 18% reduction in shaded pixels.</p>

<p>Another example with small tiles appears in <a href="https://advances.realtimerendering.com/s2020/index.html"><em>Software-Based Variable Rate Shading in Call of Duty: Modern Warfare</em></a>. While it’s specific to rasterization (using MSAA and stencil tricks), it offers some interesting ideas for ray tracing. In particular, it discusses storing more than one VRS value. Because TAA (or other super-resolution techniques) often rely on jitter, information from a single frame may skip edges and produce an overly low shading rate, causing additional quality loss. To address that, the authors store a VRS mask for the last four frames - matching their jitter sequence length - and use this history to conservatively pick the shading rate for the next frame. They reproject the entire history in one step by packing all four frames into a single 8-bit texture (giving 2 bits per frame).</p>

<h3 id="offline-renderers">Offline Renderers</h3>

<p>Many offline renderers also use some form of adaptive tracing, though it differs from real-time approaches. Instead of tracing fewer rays in the current or subsequent frame, offline methods often employ a heuristic to stop casting additional rays once a pixel’s result seems converged. A great resource on this is <a href="https://advances.realtimerendering.com/s2018/index.htm"><em>Real-Time Rendering’s Next Frontier: Adopting Lessons from Offline Ray Tracing to Real-Time Ray Tracing for Practical Pipelines</em></a> by Matt Pharr. He describes computing per-pixel variance and stopping ray generation if that variance is low enough. He also stresses that each pixel should consider the variance of its neighbors, to prevent missing important directions if all samples in the current pixel “failed” to find a bright reflection.</p>

<p>A similar approach is used, for example, in Disney’s Hyperion renderer, as detailed in <a href="https://www.yiningkarlli.com/projects/hyperiondesign/hyperiondesign.pdf"><em>The Design and Evolution of Disney’s Hyperion Renderer</em></a>.</p>

<h3 id="others">Others</h3>

<p>Other great resources on variable rate tracing include <a href="https://interplayoflight.wordpress.com/2022/05/29/accelerating-raytracing-using-software-vrs/"><em>Accelerating Raytracing Using Software VRS</em></a> and <a href="https://www.youtube.com/watch?v=Sswuj7BFjGo"><em>Variable Rate Compute Shaders – Halving Deferred Lighting Time</em></a>.</p>

<h2 id="restir">ReSTIR</h2>

<p>ReSTIR is a technique that aggregates samples spatially and temporally to produce a single sample with an improved distribution. In this post, I’ll provide a high-level overview of how ReSTIR works. For more details, I recommend the following publications:</p>

<ul>
  <li><a href="https://intro-to-restir.cwyman.org/presentations/2023ReSTIR_Course_Notes.pdf">A Gentle Introduction To ReSTIR Path Reuse in Real-Time</a></li>
  <li><a href="https://interplayoflight.wordpress.com/2023/12/17/a-gentler-introduction-to-restir/">A gentler introduction to ReSTIR</a></li>
  <li><a href="https://research.nvidia.com/sites/default/files/pubs/2020-07_Spatiotemporal-reservoir-resampling/ReSTIR.pdf">Spatiotemporal reservoir resampling for real-time ray tracing with dynamic direct lighting</a></li>
  <li><a href="https://research.nvidia.com/publication/2021-06_restir-gi-path-resampling-real-time-path-tracing">ReSTIR GI: Path Resampling for Real-Time Path Tracing</a></li>
  <li><a href="https://dl.acm.org/doi/10.1145/3528223.3530158">Generalized resampled importance sampling: foundations of ReSTIR</a></li>
</ul>

<p>Additionally, GPU Zen 3 contains two excellent chapters on this topic: <em>The Evolution of the Real-Time Lighting Pipeline in Cyberpunk 2077</em> and <em>Diffuse Global Illumination</em>.</p>

<h3 id="ris">RIS</h3>

<p>The main building blocks of ReSTIR are Resampled Importance Sampling (RIS) and Weighted Reservoir Sampling (WRS). RIS is a technique that takes a sequence of N samples generated from some known distribution p(x) as input and selects one of these samples as if it were generated from a different distribution determined by a target function p^(x). (Note that p^(x) doesn’t have to be normalized).</p>

<p>Generally, we want p^(x) to be similar to f(x) - the actual function we’re trying to sample - but we often can’t use f(x) directly because it can be expensive to evaluate (for example, in direct or indirect illumination it might require tracing a visibility ray). That’s why in practice we typically use a cheaper proxy of f(x). Even if it’s just an approximation, this can still greatly improve the final sample distribution compared to the original p(x).</p>

<p>You might wonder: <em>Why not directly sample from the distribution proportional to p^(x) instead of going through RIS?</em> The reason is that in real-time graphics, it’s often computationally impractical or even impossible to sample directly from p^(x) - remember that p^(x) doesn’t have to be normalized. However, it’s usually much easier to compute the target function’s value for a few chosen candidates which is exactly what RIS leverages.</p>

<p>One more challenge is that, once we pick the final sample in RIS, we want to use it in a Monte Carlo estimator. That estimator requires the PDF from which the sample was generated. In RIS, we don’t have that PDF explicitly. We only have p^(x), which isn’t necessarily normalized and thus can’t be used as a PDF. Plus, computing the actual PDF is difficult: intuitively, if we pick sample <code class="language-plaintext highlighter-rouge">i</code> based on resampling weights, its probability depends on all the other candidates and also on the original p(x).</p>

<p>Because of that, we replace 1/pdf with a random value W, often called an unbiased contribution weight. On average, W should equal 1/pdf. (Note that W isn’t a simple function, it can vary each time we pick the same x because it depends on all candidates in the current resampling process).</p>

<p><strong>Example (Direct Illumination)</strong></p>

<p>Imagine we want to sample direct lighting. We could pick a candidate set by randomly choosing light sources from the entire scene. Then, for each pixel, we apply RIS to pick just one of those lights, improving its sampling distribution via p^(x). We might skip visibility tests at this step. In the end, RIS will output one chosen sample (with probability proportional to the weights derived from p^(x)), and we trace a visibility ray only for that final sample. That sample can then be used in our Monte Carlo estimator.</p>

<h3 id="wrs">WRS</h3>

<p>RIS works by first assigning resampling weights to all input samples and then picking one of those samples proportionally to those weights. Without WRS, you’d have to compute the sum of all weights, generate a random value between 0 and that sum, and then iterate over the samples again to find the chosen one. WRS helps optimize this process by using a small “reservoir” structure to select the final sample in a single pass.</p>

<p>A reservoir stores:</p>

<ul>
  <li>currently selected sample index</li>
  <li>cumulative weight of samples processed so far</li>
</ul>

<p>With WRS, RIS proceeds as follows for each sample:</p>

<ol>
  <li>Compute the resampling weight for the input sample.</li>
  <li>Generate a random value between 0 and 1 from a uniform distribution.</li>
  <li>Update the reservoir with the current sample, its weight, and the random value. During this update, the reservoir may replace the stored sample based on the random value and the ratio between the cumulative weight of processed samples and the new sample’s weight.</li>
</ol>

<p>When all samples have been processed, the reservoir holds one selected sample, chosen proportionally to the resampling weights. This is great because it allows RIS to run in just one iteration over the candidate samples, without needing extra storage.</p>

<h3 id="temporal-and-spatial-reuse">Temporal and spatial reuse</h3>

<p>ReSTIR extends beyond RIS by incorporating spatial and temporal resampling steps. These build on the reservoirs created by RIS, enabling an exponential increase in the effective number of samples considered.</p>

<p>After applying RIS, the final reservoir contains the selected sample and the sum of the resampling weights for all candidates. Having that reservoir makes it possible to combine it with another reservoir that has aggregated different candidate samples in a separate RIS pass. Mathematically, this is equivalent to performing RIS on all samples used to generate both reservoirs - in other words, whenever you combine two reservoirs, you’re effectively performing one big RIS on the union of their candidate sets.</p>

<p>To leverage this, we store the final reservoir from frame N and reuse it in frame N+1 after generating new initial samples. Consequently, each pixel in frame N+1 considers samples from both frames N and N+1. Over time, this temporal resampling process refines the sample distribution and reduces variance.</p>

<p>Spatial resampling follows a similar idea, but instead of using the reservoir from the previous frame, it samples reservoirs from nearby pixels in screen space. Typically, this involves picking random samples from a neighborhood around the pixel and reusing reservoirs generated by temporal resampling in those neighbors. Because neighboring pixels can have different positions and materials, we also apply shift mappings to transform the selected samples from the neighbor’s domain to the target pixel’s domain.</p>

<p>This combination of temporal and spatial resampling is powerful because the number of samples considered by RIS grows exponentially, which significantly reduces variance in Monte Carlo integration.</p>

<h3 id="restir-gi">ReSTIR GI</h3>

<p>The ReSTIR GI algorithm works by sampling “sample points” (a term from <a href="https://research.nvidia.com/publication/2021-06_restir-gi-path-resampling-real-time-path-tracing"><em>ReSTIR GI: Path Resampling for Real-Time Path Tracing</em></a>). The first step is to pick a sample point for each “visible point” (again, from that paper, a visible point is simply a point seen by the camera). Typically, these visible points come from a G-buffer. To generate sample points, for each visible point we randomly sample a direction from a chosen PDF - this PDF might be uniform, cosine-weighted, or based on the BSDF. We then trace a ray from the visible point in that direction. The first intersection becomes our sample point, and we evaluate the outgoing radiance from that sample point back to the visible point.
After computing the initial sample point, the algorithm proceeds similarly to ReSTIR DI: it uses temporal and spatial resampling to improve the distribution of sample points for each visible point. As in DI, we can skip the visibility term during RIS to save time, although in ReSTIR GI this can introduce bias (more on that later). We also need to be careful during spatial resampling: because the reused visible point differs in position (and possibly normal) from the target visible point, the sample densities - and thus the PDFs - differ. To account for this, we apply a shift mapping’s Jacobian.
Unlike DI, ReSTIR GI is more prone to bias, especially during spatial resampling. This happens when a reused sample point is actually occluded from the perspective of the target visible point. If we resample it anyway, we introduce bias (intuitively, that sample doesn’t belong in the target’s sampling domain - if we traced the same direction, we’d encounter a nearer intersection). To fix this, we need to verify sample-point visibility during resampling. Most implementations handle this with hardware ray tracing, but cheaper tests are possible - for example, <a href="https://github.com/EmbarkStudios/kajiya"><em>kajiya</em></a> uses screen-space tracing.</p>

<h1 id="implementation">Implementation</h1>

<p>In this section, I’ll describe how I implemented ReSTIR GI and variable rate tracing in my engine. I’ll start by discussing the variable rate tracing approach, then explain how I use ReSTIR to fill the “holes” it leaves behind, and finally show how everything fits together in a single pipeline.</p>

<h2 id="variable-rate-tracing">Variable Rate Tracing</h2>

<h3 id="tile-size-and-texture-format">Tile Size and Texture Format</h3>

<p>The first decision for VRT is tile size, which determines the tracing rate. Most ray-tracing implementations use fairly large tiles (8×8 or 8×4). A similar choice applies to variable rate shading - hardware approaches use tiles of 8×8 on AMD GPUs or 16×16 on NVIDIA. However, some software implementations have shown impressive results with smaller 2×2 tiles.</p>

<p>In my implementation, I decided to try smaller 2×2 tiles as well, aiming to save as many rays as possible. However, I also wanted to allow very coarse tracing rates - potentially as low as 1 ray per 4×4 pixel block. This implies a tracing rate lower than one ray per tile, which has drawbacks. For one, it makes the logic for deciding which pixels trace rays more complex, since the decision doesn’t depend on just a single tile. For example, to enable a 4×4 rate, all four 2×2 tiles in that 4×4 region must allow it. Additionally, with smaller tiles, achieving an optimal distribution of processed pixels within a single ray-tracing group is harder.</p>

<p>My engine supports tracing rates of 1×1, 1×2, 2×1, 2×2, 4×2, 2×4, and 4×4. To keep the algorithm straightforward, I only allow tracing rates that send one ray for every four pixels in an axis, starting at coordinates that are multiples of four. For instance, you can trace one ray per 4×2 pixels starting at coordinates (0,0), but not at (2,0).</p>

<p>Supporting seven different tracing rates requires three bits, but for simplicity I added a fourth bit. Hence, the “variable rate mask” uses:</p>

<ul>
  <li>bit 0  -  2X rate</li>
  <li>bit 1  -  2Y rate</li>
  <li>bit 2  -  4X rate</li>
  <li>bit 3  -  4Y rate</li>
</ul>

<p>Additionally, if a tile supports (for example) a 4Y tracing rate, it sets 2Y to 0 - but 2Y tracing rate is still supported implicitly.
Ray-tracing techniques often change the ray direction every frame, making the generated signal different each time. This is particularly noticeable in ray-traced shadows (I reuse the same code for shadows, so some VRT implementation details are influenced by that). For instance, I use an edge-detection approach to pick the tracing rate for shadows, but in penumbra regions, it’s possible that in one frame some pixels in a tile see the occluder, while in the next frame none do. That would cause the tile to flip from “partially shadowed” to “not shadowed,” resulting in no edges and thus a lower tracing rate for the next frame. To avoid this, I use a method similar to Michał Drobot’s in <a href="https://advances.realtimerendering.com/s2020/index.html"><em>Software-Based Variable Rate Shading in Call of Duty: Modern Warfare</em></a>, where I store results from the last four frames and pick the final rate conservatively. Note that this shouldn’t be necessary if I used larger tiles.</p>

<h2 id="allocating-traces">Allocating traces</h2>

<p>In this context, allocation means creating a list of pixels that will trace rays. This list is later read by the indirect ray-tracing command.</p>

<h3 id="morton-code">Morton code</h3>

<p>Allocating traces coarser than a tile requires checking the allowed tracing rates in multiple tiles. For example, to allocate a <strong>4×4</strong> rate, all four <strong>2×2</strong> tiles in that 4×4 region must support it. To make neighbor-tile access easier, I reorder the pixels using <a href="https://en.wikipedia.org/wiki/Z-order_curve#Coordinate_values">Morton code</a>. As a result, when we dispatch one thread per pixel, all four pixels in the same tile end up in adjacent lanes. Moreover, the four tiles that form a 4×4 block also appear contiguously as well.</p>

<p>This arrangement enables certain optimizations. On NVIDIA GPUs, shaders are executed in warps of 32 lanes (threads). On AMD GPUs, a wavefront can be 32 or 64 lanes. In practice, that means a single warp/wavefront might process an <strong>8×4</strong> or <strong>8×8</strong> block of pixels, corresponding to <strong>4×2</strong> or <strong>4×4</strong> tiles, respectively. This grouping ensures that all tiles required for a 4×4 rate check are readily available within the same wave.  Additionally, indexing other tiles for 4x2 or 2x4 tracing rates requires only a few bit operations.
For instance, given the index of the current lane (<code class="language-plaintext highlighter-rouge">thread_idx</code>), we can find the tile index (<code class="language-plaintext highlighter-rouge">tile_idx</code>) via <code class="language-plaintext highlighter-rouge">thread_idx &gt;&gt; 2</code>.</p>

<p>Similarly:</p>

<ul>
  <li>To check if <strong>4×2</strong> rate is available, we look at the tile whose x-coordinate is divisible by 2 and its right neighbor. The left tile index is computed with:<code class="language-plaintext highlighter-rouge">(tile_idx) &amp; ~1u</code> and right by <code class="language-plaintext highlighter-rouge">(tile_idx ) &amp; ~1u + 1u</code>.</li>
  <li>For <strong>2×4</strong>, the upper tile in the column is <code class="language-plaintext highlighter-rouge">(tile_idx &amp; ~2u)</code> and the lower tile is <code class="language-plaintext highlighter-rouge">(tile_idx &amp; ~2u) + 1u</code>.</li>
  <li>For <strong>4×4</strong>, we need to check the VRT mask of four consecutive tiles, starting at an index divisible by 4. The first tile index in that group is <code class="language-plaintext highlighter-rouge">(tile_idx &amp; ~3u)</code>.</li>
</ul>

<p>Naturally, these rules only hold if we allow coarser tracing blocks to start at coordinates divisible by 4 (e.g. 4x4 block cannot cover pixels from (2, 2) to (6, 6)).</p>

<h3 id="algorithm">Algorithm</h3>

<p>This algorithm differs slightly between wave32 and wave64. Here, I’ll describe the wave32 variant, which I’m testing on my setup.</p>

<p>As mentioned, all work happens in a single compute shader. It launches 32×32 threads per group - each thread processes one pixel of the full-resolution texture. It uses rather large group size 32x32 (in next section I will elaborate a bit more on this) and each thread processes one pixel of full-resolution texture. The first step is to reorder the local thread IDs using a Morton code, which helps when checking neighbor tiles for coarser tracing rates. Next, the variable-rate mask is loaded per pixel. Then we need to handle the following cases:</p>

<ul>
  <li>If the fetched tracing rate is lower or equal to 2×2, we stick to that rate (no additional checks are needed).</li>
  <li>For coarser rates, the neighbors must be considered. In worst case, each thread needs access to the tracing rates of the 4×4 block of pixels (i.e., four 2×2 tiles).</li>
</ul>

<p>On GPUs, shaders are executed in SIMD way. On NVidia GPUs, each instruction is executed simultaneously for a warp that contains 32 lanes (threads). Given our Morton ordering, each warp processes an 8×4 block (i.e., 4×2 tiles). Final variable rate mask uses 4 bits (since we only need the worst-case data, not the entire history), so in total that’s 32 bits for all tiles processed in a warp. This means that we can gather these bits using a single <code class="language-plaintext highlighter-rouge">WaveActiveBitOr</code> intrinsic.</p>

<p>Here’s code that does it and also moves relevant 2x2 tiles masks to low 16bits.</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">struct</span> <span class="nc">CompactedMask4x4</span>
<span class="p">{</span>
	<span class="k">static</span> <span class="n">CompactedMask4x4</span> <span class="n">Create</span><span class="p">(</span><span class="n">uint</span> <span class="n">inMask</span><span class="p">)</span>
	<span class="p">{</span>
		<span class="n">CompactedMask4x4</span> <span class="n">instance</span><span class="p">;</span>
		<span class="n">instance</span><span class="p">.</span><span class="n">mask</span> <span class="o">=</span> <span class="n">inMask</span><span class="p">;</span>
		<span class="k">return</span> <span class="n">instance</span><span class="p">;</span>
	<span class="p">}</span>

	<span class="n">uint</span> <span class="k">operator</span><span class="p">[](</span><span class="n">in</span> <span class="n">uint</span> <span class="n">idx</span><span class="p">)</span>
	<span class="p">{</span>
		<span class="k">return</span> <span class="p">(</span><span class="n">mask</span> <span class="o">&gt;&gt;</span> <span class="p">(</span><span class="n">SPT_VARIABLE_RATE_BITS</span> <span class="o">*</span> <span class="n">idx</span><span class="p">))</span> <span class="o">&amp;</span> <span class="n">SPT_VARIABLE_RATE_MASK</span><span class="p">;</span>
	<span class="p">}</span>

	<span class="c1">// 4 morton ordered 2x2 tiles</span>
	<span class="n">uint</span> <span class="n">mask</span><span class="p">;</span>
<span class="p">};</span>

<span class="n">CompactedMask4x4</span> <span class="n">GetVariableRateIn4x4Block</span><span class="p">(</span><span class="n">in</span> <span class="n">uint</span> <span class="n">vrMask</span><span class="p">,</span> <span class="n">out</span> <span class="n">uint</span> <span class="n">outTileIdxIn4x4Quad</span><span class="p">)</span>
<span class="p">{</span>
	<span class="n">outTileIdxIn4x4Quad</span> <span class="o">=</span> <span class="n">WaveGetLaneIndex</span><span class="p">()</span> <span class="o">/</span> <span class="mi">4</span><span class="p">;</span>
	<span class="k">const</span> <span class="n">uint</span> <span class="n">blockOffset</span> <span class="o">=</span> <span class="n">outTileIdxIn4x4Quad</span> <span class="o">*</span> <span class="n">SPT_VARIABLE_RATE_BITS</span><span class="p">;</span>

	<span class="k">const</span> <span class="n">uint</span> <span class="n">variableRateMaskShifted</span> <span class="o">=</span> <span class="n">vrMask</span> <span class="o">&lt;&lt;</span> <span class="n">blockOffset</span><span class="p">;</span>

	<span class="n">uint</span> <span class="n">compactedMask</span> <span class="o">=</span> <span class="n">WaveActiveBitOr</span><span class="p">(</span><span class="n">variableRateMaskShifted</span><span class="p">);</span>

	<span class="k">if</span><span class="p">(</span><span class="n">outTileIdxIn4x4Quad</span> <span class="o">&gt;=</span> <span class="mi">4</span><span class="p">)</span>
	<span class="p">{</span>
		<span class="c1">// handle right 4x4 quad</span>
		<span class="n">outTileIdxIn4x4Quad</span> <span class="o">-=</span> <span class="mi">4</span><span class="p">;</span>
		<span class="n">compactedMask</span> <span class="o">=</span> <span class="n">compactedMask</span> <span class="o">&gt;&gt;</span> <span class="p">(</span><span class="mi">4</span> <span class="o">*</span> <span class="n">SPT_VARIABLE_RATE_BITS</span><span class="p">);</span>
	<span class="p">}</span>

	<span class="c1">// mask right quad out for the left quad</span>
	<span class="n">compactedMask</span> <span class="o">&amp;=</span> <span class="p">((</span><span class="mi">1u</span> <span class="o">&lt;&lt;</span> <span class="p">(</span><span class="mi">4</span> <span class="o">*</span> <span class="n">SPT_VARIABLE_RATE_BITS</span><span class="p">))</span> <span class="o">-</span> <span class="mi">1</span><span class="p">);</span>

	<span class="k">return</span> <span class="n">CompactedMask4x4</span><span class="o">::</span><span class="n">Create</span><span class="p">(</span><span class="n">compactedMask</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>A compacted mask contains all data in Morton order. The first row occupies bits [0:7], and the second row occupies bits [8:15]. Within each row, the first column is bits [0:3] and [8:11], and the second column is bits [4:7] and [12:15]. Thus, we can easily manipulate these bits to check 4×4, 4×2, or 2×4 tracing requirements by shifting appropriately based on the pixel’s row and column.</p>

<p>In essence, the final algorithm is:</p>

<ol>
  <li>Compute the compacted 4×4 mask.</li>
  <li>Check if 4×4 is supported by all pixels in the 4×4 block.</li>
  <li>If 4×4 isn’t supported, check if 4×2 is supported by the current row.</li>
  <li>If 4×2 isn’t supported, check 2×4. (We must also ensure that no other tile in the column has already chosen 4×2, since 4×2 takes priority over 2×4).</li>
</ol>

<p>Here’s HLSL code that does it:</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">uint</span> <span class="nf">ComputeVariableRateMask</span><span class="p">(</span><span class="n">in</span> <span class="n">uint</span> <span class="n">requestedVRMask</span><span class="p">)</span>
<span class="p">{</span>
	<span class="c1">// Early our if current mask is lower than 2x2. In that case, this thread doesn't care about neighbors</span>
	<span class="k">if</span><span class="p">(</span><span class="n">requestedVRMask</span> <span class="o">&lt;=</span> <span class="n">SPT_VARIABLE_RATE_2X2</span><span class="p">)</span>
	<span class="p">{</span>
		<span class="k">return</span> <span class="n">requestedVRMask</span><span class="p">;</span>
	<span class="p">}</span>

	<span class="n">uint</span> <span class="n">tileIdxInQuad</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
	<span class="k">const</span> <span class="n">CompactedMask4x4</span> <span class="n">compactedMask4x4</span> <span class="o">=</span> <span class="n">GetVariableRateIn4x4Block</span><span class="p">(</span><span class="n">requestedVRMask</span><span class="p">,</span> <span class="n">OUT</span> <span class="n">tileIdxInQuad</span><span class="p">);</span>

	<span class="n">uint</span> <span class="n">finalMask</span> <span class="o">=</span> <span class="n">requestedVRMask</span><span class="p">;</span>
	<span class="k">if</span><span class="p">(</span><span class="n">requestedVRMask</span> <span class="o">&gt;</span> <span class="n">SPT_VARIABLE_RATE_2X2</span><span class="p">)</span>
	<span class="p">{</span>
		<span class="k">const</span> <span class="n">uint</span> <span class="n">rowIdx</span> <span class="o">=</span> <span class="n">tileIdxInQuad</span> <span class="o">&gt;&gt;</span> <span class="mi">1u</span><span class="p">;</span>
		<span class="k">const</span> <span class="n">uint</span> <span class="n">colIdx</span> <span class="o">=</span> <span class="n">tileIdxInQuad</span> <span class="o">&amp;</span> <span class="mi">1u</span><span class="p">;</span>

		<span class="c1">// First row uses first 8 bits, second row uses next 8 bits</span>
		<span class="c1">// First column uses bits 0-3 and 8-11, second column uses bits 4-7 and 12-15</span>

		<span class="c1">// offset to get next pixel in column/row in 2x2 block</span>
		<span class="k">const</span> <span class="n">uint</span> <span class="n">bitsOffsetPerRow</span> <span class="o">=</span> <span class="n">SPT_VARIABLE_RATE_BITS</span> <span class="o">*</span> <span class="mi">2u</span><span class="p">;</span>
		<span class="k">const</span> <span class="n">uint</span> <span class="n">bitsOffsetPerCol</span> <span class="o">=</span> <span class="n">SPT_VARIABLE_RATE_BITS</span><span class="p">;</span>

		<span class="c1">// offsets for current lane</span>
		<span class="k">const</span> <span class="n">uint</span> <span class="n">bitShiftForCurrentRow</span> <span class="o">=</span> <span class="n">rowIdx</span> <span class="o">*</span> <span class="n">bitsOffsetPerRow</span><span class="p">;</span>
		<span class="k">const</span> <span class="n">uint</span> <span class="n">bitShiftForCurrentCol</span> <span class="o">=</span> <span class="n">colIdx</span> <span class="o">*</span> <span class="n">bitsOffsetPerCol</span><span class="p">;</span>

		<span class="c1">// masks for 1st column and 1st row (need to be shifted for current pixel if it's in different column/row)</span>
		<span class="k">const</span> <span class="n">uint</span> <span class="n">firstRow4XRequiredMask</span> <span class="o">=</span> <span class="n">SPT_VARIABLE_RATE_4X</span> <span class="o">|</span> <span class="p">(</span><span class="n">SPT_VARIABLE_RATE_4X</span> <span class="o">&lt;&lt;</span> <span class="n">bitsOffsetPerCol</span><span class="p">);</span>
		<span class="k">const</span> <span class="n">uint</span> <span class="n">firstCol4YRequiredMask</span> <span class="o">=</span> <span class="n">SPT_VARIABLE_RATE_4Y</span> <span class="o">|</span> <span class="p">(</span><span class="n">SPT_VARIABLE_RATE_4Y</span> <span class="o">&lt;&lt;</span> <span class="n">bitsOffsetPerRow</span><span class="p">);</span>

		<span class="k">const</span> <span class="kt">bool</span> <span class="n">supports4X</span> <span class="o">=</span> <span class="n">HasAllBits</span><span class="p">(</span><span class="n">compactedMask4x4</span><span class="p">.</span><span class="n">mask</span><span class="p">,</span> <span class="n">firstRow4XRequiredMask</span> <span class="o">&lt;&lt;</span> <span class="n">bitShiftForCurrentRow</span><span class="p">);</span>
		<span class="k">const</span> <span class="kt">bool</span> <span class="n">supports4Y</span> <span class="o">=</span> <span class="n">HasAllBits</span><span class="p">(</span><span class="n">compactedMask4x4</span><span class="p">.</span><span class="n">mask</span><span class="p">,</span> <span class="n">firstCol4YRequiredMask</span> <span class="o">&lt;&lt;</span> <span class="n">bitShiftForCurrentCol</span><span class="p">);</span>

		<span class="k">const</span> <span class="n">uint</span> <span class="n">all4x4</span> <span class="o">=</span> <span class="n">SPT_VARIABLE_RATE_4X4</span> 
						  <span class="o">|</span> <span class="p">(</span><span class="n">SPT_VARIABLE_RATE_4X4</span> <span class="o">&lt;&lt;</span> <span class="p">(</span><span class="n">SPT_VARIABLE_RATE_BITS</span><span class="p">))</span>
						  <span class="o">|</span> <span class="p">(</span><span class="n">SPT_VARIABLE_RATE_4X4</span> <span class="o">&lt;&lt;</span> <span class="p">(</span><span class="n">SPT_VARIABLE_RATE_BITS</span> <span class="o">*</span> <span class="mi">2</span><span class="p">))</span>
						  <span class="o">|</span> <span class="p">(</span><span class="n">SPT_VARIABLE_RATE_4X4</span> <span class="o">&lt;&lt;</span> <span class="p">(</span><span class="n">SPT_VARIABLE_RATE_BITS</span> <span class="o">*</span> <span class="mi">3</span><span class="p">));</span>

		<span class="k">const</span> <span class="kt">bool</span> <span class="n">supports4X4</span> <span class="o">=</span> <span class="p">(</span><span class="n">compactedMask4x4</span><span class="p">.</span><span class="n">mask</span> <span class="o">==</span> <span class="n">all4x4</span><span class="p">);</span>

		<span class="n">finalMask</span> <span class="o">=</span> <span class="n">ClampTo2x2VariableRate</span><span class="p">(</span><span class="n">requestedVRMask</span><span class="p">);</span>

		<span class="c1">// First, check 4x4</span>
		<span class="k">if</span> <span class="p">(</span><span class="n">supports4X4</span><span class="p">)</span>
		<span class="p">{</span>
			<span class="n">finalMask</span> <span class="o">|=</span> <span class="n">SPT_VARIABLE_RATE_4X4</span><span class="p">;</span>
			<span class="n">finalMask</span> <span class="o">&amp;=</span> <span class="o">~</span><span class="n">SPT_VARIABLE_RATE_2X2</span><span class="p">;</span>
		<span class="p">}</span>
		<span class="k">else</span> <span class="k">if</span><span class="p">(</span><span class="n">supports4X</span><span class="p">)</span> <span class="c1">// 4X has higher priority than 4Y</span>
		<span class="p">{</span>
			<span class="c1">// 4X2Y</span>
			<span class="n">finalMask</span> <span class="o">|=</span> <span class="n">SPT_VARIABLE_RATE_4X</span><span class="p">;</span>
			<span class="n">finalMask</span> <span class="o">&amp;=</span> <span class="o">~</span><span class="n">SPT_VARIABLE_RATE_2X</span><span class="p">;</span>
		<span class="p">}</span>
		<span class="k">else</span> <span class="k">if</span><span class="p">(</span><span class="n">supports4Y</span><span class="p">)</span>
		<span class="p">{</span>
			<span class="c1">// 4X has higher priority than 4Y</span>
			<span class="c1">// because of that, we need to check if 4X is supported and allow 4Y only if 4X is not supported for all rows</span>
			<span class="k">const</span> <span class="kt">bool</span> <span class="n">anyRowSupports4X</span> <span class="o">=</span> <span class="n">HasAllBits</span><span class="p">(</span><span class="n">compactedMask4x4</span><span class="p">.</span><span class="n">mask</span><span class="p">,</span> <span class="n">firstRow4XRequiredMask</span><span class="p">)</span> <span class="o">||</span> <span class="n">HasAllBits</span><span class="p">(</span><span class="n">compactedMask4x4</span><span class="p">.</span><span class="n">mask</span><span class="p">,</span> <span class="n">firstRow4XRequiredMask</span> <span class="o">&lt;&lt;</span> <span class="n">bitsOffsetPerRow</span><span class="p">);</span>

			<span class="k">if</span><span class="p">(</span><span class="o">!</span><span class="n">anyRowSupports4X</span><span class="p">)</span>
			<span class="p">{</span>
				<span class="n">finalMask</span> <span class="o">|=</span> <span class="n">SPT_VARIABLE_RATE_4Y</span><span class="p">;</span>
				<span class="n">finalMask</span> <span class="o">&amp;=</span> <span class="o">~</span><span class="n">SPT_VARIABLE_RATE_2Y</span><span class="p">;</span>
			<span class="p">}</span>
		<span class="p">}</span>
	<span class="p">}</span>

	<span class="k">return</span> <span class="n">finalMask</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Once the tracing rate is determined, we must decide exactly which pixel in the block will trace the ray. There are various approaches - <em>The Callisto Protocol</em> presentation mentions picking individual samples based on depth. I initially tried something similar, but it’s tricky to extend to coarser rates (like 4×4). Also, the method I use to fill missing rays expects that <em>all</em> pixels eventually generate a ray. So, I opted for a hardcoded sequence that loops over all pixels. For lower rates (up to 2×2), it’s a simple loop. For coarser rates, the loop changes each frame to pick a pixel from a different tile.</p>

<h3 id="reprojection">Reprojection</h3>

<p>Reprojection is based on motion vectors and uses only the single nearest sample. I tested a “worst case” approach where I also checked the four pixels surrounding the reprojected UVs, but I didn’t notice a meaningful quality improvement. Additionally, I use low tracing rate (2x1) to pixels that were off-screen in the previous frame. Since the average tracing rate in most scenes hovers around 30%, this helps maintain steadier frame times. If a specific piece of geometry really needs more rays, it will get them in the following frame.</p>

<p>I also experimented with doing a depth test during reprojection and using a higher tracing rate if that test failed, but in my scenes it didn’t seem necessary, so it’s disabled by default.</p>

<h2 id="coherence-during-tracing">Coherence during tracing</h2>

<p>Using small tiles can reduce tracing coherence because each tile can allocate at most four traces, meaning a single warp during the tracing pass will have to process traces for several different tiles (potentially far from each other). To compensate, I use larger groups (32×32) and ensure that all traces from each group are placed contiguously in the output buffer by using single atomic increment of the buffer offset per group, which on my GPU results in decent spatial coherence. In practice, traces end up appended in an order similar to how groups are launched. That might be somewhat hardware-dependent, so additional sorting or ray binning could be beneficial.</p>

<h2 id="heuristic">Heuristic</h2>

<p>Final variable rate is computed based on roughness, brightness and variance estimation of specular and diffuse reflections (after multiplying by BRDF) as well as specular and diffuse influence. Those values are used to compute per pixel “noise level”. After that, per tile variable rate mask is computed based on only maximum noise level of pixels within that tile.</p>

<h3 id="specular-and-diffuse-influence">Specular and Diffuse Influence</h3>

<p>Specular and diffuse influence values each range from 0 to 1, indicating what fraction of a pixel’s final color comes from specular or diffuse reflections. These influences are computed after compositing reflections with direct lighting and volumetric fog. One benefit is that if an area is lit primarily by strong direct light (e.g., sunlight), reflections contribute much less to the final color, making the reflection influence low. The same logic applies in cases with dense fog that absorbs or scatters most of the light. This concept is similar to the “perceptual importance” described in <em>The Rendering of The Callisto Protocol</em>.</p>

<h3 id="variance-estimation">Variance Estimation</h3>

<p>Variance estimation is computed separately for diffuse and specular lighting. It’s based on temporal variance from the <a href="https://cg.ivd.kit.edu/publications/2017/svgf/svgf_preprint.pdf">SVGF denoiser</a>, and also includes spatial variance for a few frames after disocclusion (again, like SVGF). However, as noted by Matt Pharr in <a href="https://advances.realtimerendering.com/s2018/index.htm">“Real-Time rendering’s next frontier: Adopting lessons from offline ray tracing to real-time ray tracing for practical pipelines”</a>, a pixel’s variance might appear low simply because we never happened to sample a bright reflection. One solution to this problem is to propagate high variance values to neighbor pixels. I use separable 7x7 bilateral max filter (separating it is not correct but it’s way faster and for me it’s a good enough approximation).  I also apply <a href="https://en.wikipedia.org/wiki/Bessel%27s_correction">Bessel’s correction</a> beforehand to keep the variance unbiased with lower sample counts. Moreover, the variance used as input to those passes isn’t just the raw temporal variance - it’s actually taken from the first A-Trous pass of the denoiser (see section 4.2 in the SVGF paper). One nice property of that pass is it assumes uncorrelated samples among neighboring pixels and  decreases variance more as the sample count increases. Consequently, this approach introduces additional spatial blurring and implicitly accounts for geometric complexity - if neighboring pixels can reuse geometry, the variance is lower.</p>

<p>Because I separate variance for diffuse and specular reflections, it might seem redundant, but it’s actually important. I generate only one ray per pixel, randomly chosen to be either specular or diffuse. For example, on non-metal surfaces, the diffuse lobe is used most of the time. If the surface is smooth, that distribution is terrible for specular reflections, which increases specular variance. However, since the influence of specular is low for rough surfaces (except at grazing angles), it doesn’t hurt the final image as much.</p>

<h3 id="computing-the-noise-value">Computing the Noise Value</h3>

<p>After calculating the necessary parameters, I compute the pixel’s noise value. Currently, I use two approaches depending on roughness:</p>

<ul>
  <li>
    <p><strong>Low Roughness (Mirror-Like)</strong></p>

    <p>I use a simple edge-detection function (from <em>Software-Based Variable Rate Shading in Call of Duty: Modern Warfare</em>) based on specular reflection brightness. This works fine for non-metals with very low roughness, because I always generate rays in mirror directions (skipping the diffuse lobe). It isn’t perfect - diffuse reflections become directional - but with one ray per sample, it’s the best compromise I’ve found- otherwise, when ray is generated from diffuse BRDF, I don’t have any specular data. A similar approach was used in <a href="https://www.nvidia.com/en-us/on-demand/session/gdc24-gdc1003/">Alan Wake 2</a>, where the authors multiplied diffuse probability by roughness.</p>
  </li>
  <li>
    <p><strong>Higher Roughness</strong>
For rougher surfaces, I compute a more involved “noise” metric:</p>
    <ol>
      <li>Compute standard deviation (from the variance) for both diffuse and specular.</li>
      <li>Compute specular boost value. This is a multiplier for noise value from specular reflections if material has low roughness. I use it because ReSTIR has a harder time reusing samples for narrow specular lobes so usually those areas require more rays.</li>
      <li>Compute Absolute Noise: For each of specular and diffuse, multiply the estimated std. dev. by the respective influence. (Specular gets an extra boost from step 3)</li>
      <li>Compute Relative Noise: Take the absolute noise and divide by the reflections brightness.</li>
      <li>Blend relative and absolute noise. Weighting more toward relative noise helps in very bright reflections (since they typically have larger variance). However, it may overestimate in darker areas. I found a 0.3–0.6 blend (absolute to relative) gave good results in various scenes.</li>
      <li>Add the specular and diffuse noise to get the final pixel noise</li>
    </ol>
  </li>
</ul>

<p>After computing this noise value for every pixel, I pick pixel with maximum noise in each tile and then choose the tracing rate based on thresholds. Also, the decision between 2×1 vs. 1×2 (and 4×2 vs. 2×4) depends on depth gradients, similar to the approach in <a href="https://gdcvault.com/play/1034763/Advanced-Graphics-Summit-Raytracing-in"><em>Raytracing in Snowdrop: An Optimized Lighting Pipeline for Consoles</em></a>.</p>

<h2 id="ray-tracing">Ray tracing</h2>

<p>The trace-allocation shader first creates a buffer holding the pixel coordinates of rays. The next step is to compute each ray’s direction. Sample distribution is chosen based on either the diffuse or specular BRDF, using probabilities same as in Section 7.9.1 of <em>GPU Zen 3</em>. Once a valid direction is generated, it’s encoded via octahedron mapping and stored in a buffer alongside its PDF value.</p>

<p>With the directions computed, an indirectly dispatched ray-gen shader handles the actual ray tracing. If a ray hits a surface, the material data is written to a specialized ray-tracing G-buffer. A separate shader then shades only those pixels whose rays found a valid hit; pixels that missed simply sample a sky texture in a different pass.</p>

<p>Reflected surfaces are shaded much like normal G-buffer shading, but I use a light cache (in my case, it’s just DDGI probes with full light transport, similar to [<em>Dynamic Diffuse Global Illumination Resampling](https://d1qx31qr3h6wln.cloudfront.net/publications/majercik21dynamic.pdf))</em> for additional GI bounces. I also artificially increase the material roughness to lower variance in the final result. After shading, the results are stored in an <code class="language-plaintext highlighter-rouge">initial_reservoir</code> buffer. Any pixels not traced in this pass end up with invalid initial reservoirs.</p>

<h3 id="temporal-resampling">Temporal Resampling</h3>

<p>Next, ReSTIR evaluation begins with temporal resampling. My implementation reprojects samples based on camera motion vectors and includes a few specific tweaks.</p>

<p>First is that max allowed age of history reservoirs is different depending on roughness - materials with smaller roughness allow lower age - this is done to avoid case when same sample is used in multiple frames in a row - this is dangerous especially with variable rate if pixel didn’t generate initial reservoir, as it can decrease variance without introducing any new data through RIS, which can reduce tracing rate and therefore also reduce quality. Having lower maximum age decreases chances of such situation. Additionally, history length is increased for pixels that were not traced this frame - that’s because those pixels don’t have any initial reservoirs, so I assume that it’s better to have anything (even if it’s a bit older) than nothing. I also experimented with keeping an unbounded history for these pixels, but this significantly increased error. I think that the problem was this artificial reduction of variance caused by reusing same sample.</p>

<p>Using biased form of ReSTIR sometimes can remove small indirect shadows as described in https://github.com/EmbarkStudios/kajiya/blob/main/docs/gi-overview.md. In that article, author suggest using weighted result from raw tracing data and reservoir after ReSTIR. I’ve tried this approach in my project, but I didn’t see much benefit - likely because I replace initial reservoirs with history reservoirs for untraced pixels. Instead, I aggressively reduce reservoir’s max age based on its hit distance: closer intersections have a lower max age. I get this distance from the denoiser, which tracks it for specular reprojection. Currently, I exclude this mechanism for materials with very low roughness, as indirect shadows in such cases are usually not important. Additionally, reducing the max age for these reservoirs can make it harder to fill in missing data from VRT, since spatial reuse becomes less reliable at low roughness.</p>

<p>Without distance-based max age</p>

<p><img src="/assets/images/posts/variable-rate-tracing-restir-gi/2025-02-09_20-18-21.png" alt="2025-02-09_20-18-21.png" /></p>

<p>With distance-based max age</p>

<p><img src="/assets/images/posts/variable-rate-tracing-restir-gi/2025-02-09_20-18-31.png" alt="2025-02-09_20-18-31.png" /></p>

<p>Another important thing is that each reservoir can store flags such as “Validated,” which indicates a sample doesn’t need another visibility ray to confirm validity. I automatically set this flag for initial reservoirs, and I remove it only when a reservoir is updated during spatial resampling. Typically, I leave it set for temporal updates - except in one reservoir per 2×2 tile if the current tracing rate is coarser than 2×2. This way, we trace visibility rays only for up to 25% reservoirs resampled spatially which is a tradeoff between performance and faster reaction time to changes in the scene.</p>

<h3 id="boiling-filter">Boiling Filter</h3>

<p>After temporal resampling, I apply a boiling filter similar to the one in NVIDIA’s <a href="https://github.com/NVIDIAGameWorks/RTXGI">RTXGI</a>. It detects extremely bright samples by comparing a sample’s brightness to the average brightness of an 8×8 block. This helps prevent a single rare, bright sample (with very low PDF) from accumulating a large weight and propagating across neighboring pixels, which can cause “boiling” artifacts.</p>

<h3 id="spatial-resampling">Spatial Resampling</h3>

<p>I then run spatial resampling. By default it’s a single pass, checking three samples per pixel. It’s largely standard ReSTIR with small modifications:</p>

<ul>
  <li>Only pixels that actually traced rays this frame are considered during resampling. This gives untraced pixels a better chance to find fresh data.</li>
  <li>Because I use only three samples, the reservoir tracks a “sampling range” to preserve it between frames. If  the tested sample can be reused, this range is expanded, if it fails, range is shrunk. This range is also significantly reduced if final visibility check for given sample fails.</li>
  <li>Inspired by <a href="https://github.com/EmbarkStudios/kajiya/blob/main/docs/gi-overview.md"><em>kajiya</em></a>, all pixels in a wave test similar neighbors for a higher cache-hit ratio, and I do a screen-space trace for quick visibility testing.</li>
</ul>

<p>To enable sampling of only traced pixels, the trace-allocation step produces another full-resolution <code class="language-plaintext highlighter-rouge">uint8</code> texture. Each pixel in this texture stores its variable rate (in 4 bits) and the local offset (2 bits for x, 2 bits for y) that identifies which exactly pixel was traced this frame. That offset might refer to the same tile or a neighboring tile if the rate is coarser than 4×4. I use a bitwise operation like: <code class="language-plaintext highlighter-rouge">coords &amp; ~(variable_rate - 1u)</code> to find the block’s origin, exploiting the alignment property (e.g., 4×4 blocks start at multiples of 4).</p>

<h3 id="second-tracing-pass-experiments">Second Tracing Pass Experiments</h3>

<p>At some point I also tried a second tracing pass to handle disocclusions. After temporal resampling, any pixel lacking a valid reservoir would schedule extra rays if a warp had too many invalid reservoirs. It worked for disocclusions but was quite expensive, mostly because those rays were extremally incoherent. I eventually abandoned it in favor of a simpler fallback and optional depth test during variable-rate reprojection, which was faster and looked just as good.</p>

<h3 id="final-visibility-test">Final Visibility Test</h3>

<p>When both temporal and spatial resampling are complete, I run an extra visibility check on any reservoir lacking the <code class="language-plaintext highlighter-rouge">validated</code> flag (generally those will be reserviors updated during spatial resampling, plus some updated in temporal pass). This step traces a ray from the visible point to the reservoir’s sample point. If the sample is occluded, it cannot be used as it doesn’t belong to the sampling domain of the visible point, so reservoir is replaced with the old <code class="language-plaintext highlighter-rouge">initial_reservoir</code> (even if it’s invalid), and I shrink its spatial range to improve odds of finding a valid sample next frame.</p>

<p>Lastly, I perform Monte Carlo integration. Here’s where the final fallback for VRT can be used: if a pixel still has no valid reservoir, it just uses the result from the nearest traced pixel. (I reuse the entire integrated result, not only sample point, because the sample point alone might be invalid for this pixel’s geometry). I then demodulate specular and feed the results into a denoiser that handles diffuse and specular reflections separately. This denoiser is based on [<em>ReLAX: A Denoiser Tailored to Work with the ReSTIR Algorithm](https://www.nvidia.com/en-us/on-demand/session/gtcspring21-s32759/). It</em> also exports variance estimation as described in previous section.</p>

<p>After denoising, I composite reflections alongside volumetric fog and direct lighting in another shader. This is also a moment when reflection influence textures are created. Finally, I generate the variable rate texture for the next frame using the previously described heuristic.</p>

<h2 id="whole-pipeline">Whole pipeline</h2>

<p>Below is a diagram illustrating all the passes and their data flow. Black squares represent compute dispatches, blue squares are indirect dispatches, and green squares are indirect ray-gen shader dispatches.</p>

<p>Continuous lines show dependencies within a single frame.</p>

<p>Dashed lines show dependencies where the source is from frame N, and the destination is in frame N+1.</p>

<p><img src="/assets/images/posts/variable-rate-tracing-restir-gi/image.png" alt="image.png" /></p>

<h3 id="resolve-process">Resolve process</h3>

<p>The following images were captured in a single frame while the camera was moving to the right.</p>

<ol>
  <li>Tracing Rate Visualization
    <ul>
      <li>Red: 1×1</li>
      <li>Yellow: 1×2 or 2×1</li>
      <li>Green: 2×2</li>
      <li>Blue: 4×2 or 2×4</li>
      <li>Gray: 4×4</li>
    </ul>
  </li>
</ol>

<p><img src="/assets/images/posts/variable-rate-tracing-restir-gi/2025-02-16_23-43-57.png" alt="2025-02-16_23-43-57.png" /></p>

<ol>
  <li>
    <p>Initial Reservoirs After tracing</p>

    <p>Next image shows data in reservoirs after tracing. Black pixels were not traced this frame.</p>
  </li>
</ol>

<p><img src="/assets/images/posts/variable-rate-tracing-restir-gi/2025-02-16_23-45-15.png" alt="2025-02-16_23-45-15.png" /></p>

<ol>
  <li>
    <p>Reservoirs after temporal resampling</p>

    <p>In the next image, most of the missing pixels have already been filled in, except for the area on the right side of the column (which was disoccluded this frame) and the water surface in the fountain (it has zero roughness, so it doesn’t use resampling). Some missing reservoirs also appear near the trees - likely due to jitter.</p>
  </li>
</ol>

<p><img src="/assets/images/posts/variable-rate-tracing-restir-gi/2025-02-16_23-45-26.png" alt="2025-02-16_23-45-26.png" /></p>

<ol>
  <li>
    <p>Reservoirs after spatial resampling</p>

    <p>This image shows the state of reservoirs after spatial resampling. Many of the invalid reservoirs from the temporal pass are now updated - noticeably near the tree and fountain, and on the newly revealed wall and floor. There’s still some missing data on the disoccluded portion of the tree, because the complex geometry lowers the chance of finding spatially reusable samples.</p>
  </li>
</ol>

<p><img src="/assets/images/posts/variable-rate-tracing-restir-gi/2025-02-16_23-45-44.png" alt="2025-02-16_23-45-44.png" /></p>

<ol>
  <li>
    <p>Final reservoirs</p>

    <p>The next image shows the final reservoir data. By this point, any remaining missing data has been replaced with tracing pixels from the appropriate variable-rate block. Additionally, reservoirs with occluded sample points were replaced by their initial reservoirs. Notice the water surface now also has filled data.</p>
  </li>
</ol>

<p><img src="/assets/images/posts/variable-rate-tracing-restir-gi/2025-02-16_23-46-03.png" alt="2025-02-16_23-46-03.png" /></p>

<p>Next, you can see the denoised diffuse results.</p>

<p><img src="/assets/images/posts/variable-rate-tracing-restir-gi/2025-02-16_23-46-52.png" alt="2025-02-16_23-46-52.png" /></p>

<p>Finally, here’s the composited lighting after tonemapping.</p>

<p><img src="/assets/images/posts/variable-rate-tracing-restir-gi/2025-02-16_23-47-15.png" alt="2025-02-16_23-47-15.png" /></p>

<p>Below is a link to a video showcasing how everything looks in practice:</p>

<p><a href="https://www.youtube.com/watch?v=KCaOl12h75g">https://www.youtube.com/watch?v=KCaOl12h75g</a></p>

<h1 id="results">Results</h1>

<h2 id="variable-rate-tracing-performance">Variable Rate Tracing performance</h2>

<p>VRT savings depend heavily on scene complexity and materials. Scenes dominated by rough metals or non-metal materials tend to benefit more as ReSTIR’s sample reuse is more effective. On top of that, alpha-tested geometry which uses any-hit shaders tends to magnify performance gains even more because it’s especially expensive to ray-trace.</p>

<p>Below, I’ll show the tracing rate and timing results for a few scenes. Note that in my project, the proportion of time spent on different stages differs from a typical game engine. For example, I evaluate a detailed material model during tracing, making it relatively expensive. In contrast, many production engines do simpler lookups or store aggregated material data (e.g., <a href="https://advances.realtimerendering.com/s2024/content/EA-GIBS2/Apers_Advances-s2024_Shipping-Dynamic-GI.pdf">Frostbite</a> uses a single color per mesh-material subset; <a href="https://www.youtube.com/watch?v=nTZpKD600eQ">Far Cry 6</a> stores material properties per vertex; <a href="https://gdcvault.com/play/1034763/Advanced-Graphics-Summit-Raytracing-in">Avatar</a> uses one material per mesh). Hence, my timing gains might be larger than in a typical engine, so I think that percentage of tracing pixels might be more representative than gains in milliseconds. Also, my “full rate” mode still has some VRT-friendly design choices baked in (e.g. it still executes “traces allocation” shader), which can further amplify the perceived savings.</p>

<p>All screenshots were taken at 4K resolution with DLSS upscaling from Full HD (i.e., 50% of output resolution) on an RTX 4090 laptop. Note that ReSTIR timings also include the final visibility pass, whose cost depends on the tracing rate.</p>

<h3 id="san-miguel">San Miguel</h3>

<p>San Miguel is an example of a scene that benefits significantly - it has almost entirely non-metal surfaces plus large alpha-tested geometry (trees), which are expensive for ray tracing (I’m not using micromaps). In this scene, VRT saved 78.77% of traced rays and 38.22% of the RT reflection time.</p>

<p>Frame:</p>

<p><img src="/assets/images/posts/variable-rate-tracing-restir-gi/2025-02-16_22-58-12.png" alt="2025-02-16_22-58-12.png" /></p>

<p>Tracing Rate Tiles:</p>

<p><img src="/assets/images/posts/variable-rate-tracing-restir-gi/2025-02-16_22-58-50.png" alt="2025-02-16_22-58-50.png" /></p>

<div class="performance-grid">
  <div class="performance-card">
    <h4>With VRT</h4>
    <div class="performance-metric">
      <div class="performance-metric__head">
        <span class="performance-metric__label">Overall time</span>
        <span class="performance-metric__value">5.82 ms</span>
      </div>
      <div class="performance-metric__bar"><div class="performance-metric__fill" style="--value: 61.78;"></div></div>
    </div>
    <div class="performance-metric">
      <div class="performance-metric__head">
        <span class="performance-metric__label">Tracing + shading</span>
        <span class="performance-metric__value">1.23 ms</span>
      </div>
      <div class="performance-metric__bar"><div class="performance-metric__fill" style="--value: 25.95;"></div></div>
    </div>
    <div class="performance-metric">
      <div class="performance-metric__head">
        <span class="performance-metric__label">ReSTIR</span>
        <span class="performance-metric__value">2.18 ms</span>
      </div>
      <div class="performance-metric__bar"><div class="performance-metric__fill" style="--value: 96.04;"></div></div>
    </div>
    <div class="performance-metric">
      <div class="performance-metric__head">
        <span class="performance-metric__label">Denoising</span>
        <span class="performance-metric__value">2.23 ms</span>
      </div>
      <div class="performance-metric__bar"><div class="performance-metric__fill" style="--value: 99.55;"></div></div>
    </div>
    <div class="performance-metric">
      <div class="performance-metric__head">
        <span class="performance-metric__label">Tracing pixels</span>
        <span class="performance-metric__value">22.23%</span>
      </div>
      <div class="performance-metric__bar"><div class="performance-metric__fill" style="--value: 22.23;"></div></div>
    </div>
  </div>
  <div class="performance-card">
    <h4>Without VRT</h4>
    <div class="performance-metric">
      <div class="performance-metric__head">
        <span class="performance-metric__label">Overall time</span>
        <span class="performance-metric__value">9.42 ms</span>
      </div>
      <div class="performance-metric__bar"><div class="performance-metric__fill" style="--value: 100;"></div></div>
    </div>
    <div class="performance-metric">
      <div class="performance-metric__head">
        <span class="performance-metric__label">Tracing + shading</span>
        <span class="performance-metric__value">4.74 ms</span>
      </div>
      <div class="performance-metric__bar"><div class="performance-metric__fill" style="--value: 100;"></div></div>
    </div>
    <div class="performance-metric">
      <div class="performance-metric__head">
        <span class="performance-metric__label">ReSTIR</span>
        <span class="performance-metric__value">2.27 ms</span>
      </div>
      <div class="performance-metric__bar"><div class="performance-metric__fill" style="--value: 100;"></div></div>
    </div>
    <div class="performance-metric">
      <div class="performance-metric__head">
        <span class="performance-metric__label">Denoising</span>
        <span class="performance-metric__value">2.24 ms</span>
      </div>
      <div class="performance-metric__bar"><div class="performance-metric__fill" style="--value: 100;"></div></div>
    </div>
    <div class="performance-metric">
      <div class="performance-metric__head">
        <span class="performance-metric__label">Tracing pixels</span>
        <span class="performance-metric__value">100%</span>
      </div>
      <div class="performance-metric__bar"><div class="performance-metric__fill" style="--value: 100;"></div></div>
    </div>
  </div>
</div>

<h3 id="pica-pica">Pica Pica</h3>

<p>The Pica Pica scene (diorama from <a href="https://github.com/SEED-EA/pica-pica-assets"><em>SEED-EA/pica-pica-assets</em></a>) is small and has fairly simple geometry, so tracing is inherently fast. However, the materials pose a challenge for resampling: the walls are very smooth (roughness around 0.1), so ReSTIR struggles to find good reservoirs for specular reflections, especially at grazing angles. In the frame below, the heuristic traces more rays in areas of the wall with higher Fresnel term, while in other spots (where diffuse dominates) fewer rays are used. In This case VRT saved 78.21% of rays. Overall, the time savings here are around 19.32%, mainly because the baseline tracing cost is already low.</p>

<p>Frame:</p>

<p><img src="/assets/images/posts/variable-rate-tracing-restir-gi/2025-02-16_23-15-58.png" alt="2025-02-16_23-15-58.png" /></p>

<p>Tracing Rate Tiles:</p>

<p><img src="/assets/images/posts/variable-rate-tracing-restir-gi/2025-02-16_23-16-19.png" alt="2025-02-16_23-16-19.png" /></p>

<div class="performance-grid">
  <div class="performance-card">
    <h4>With VRT</h4>
    <div class="performance-metric">
      <div class="performance-metric__head">
        <span class="performance-metric__label">Overall time</span>
        <span class="performance-metric__value">4.05 ms</span>
      </div>
      <div class="performance-metric__bar"><div class="performance-metric__fill" style="--value: 80.68;"></div></div>
    </div>
    <div class="performance-metric">
      <div class="performance-metric__head">
        <span class="performance-metric__label">Tracing + shading</span>
        <span class="performance-metric__value">0.42 ms</span>
      </div>
      <div class="performance-metric__bar"><div class="performance-metric__fill" style="--value: 28.57;"></div></div>
    </div>
    <div class="performance-metric">
      <div class="performance-metric__head">
        <span class="performance-metric__label">ReSTIR</span>
        <span class="performance-metric__value">1.39 ms</span>
      </div>
      <div class="performance-metric__bar"><div class="performance-metric__fill" style="--value: 100;"></div></div>
    </div>
    <div class="performance-metric">
      <div class="performance-metric__head">
        <span class="performance-metric__label">Denoising</span>
        <span class="performance-metric__value">2.04 ms</span>
      </div>
      <div class="performance-metric__bar"><div class="performance-metric__fill" style="--value: 100;"></div></div>
    </div>
    <div class="performance-metric">
      <div class="performance-metric__head">
        <span class="performance-metric__label">Tracing pixels</span>
        <span class="performance-metric__value">21.79%</span>
      </div>
      <div class="performance-metric__bar"><div class="performance-metric__fill" style="--value: 21.79;"></div></div>
    </div>
  </div>
  <div class="performance-card">
    <h4>Without VRT</h4>
    <div class="performance-metric">
      <div class="performance-metric__head">
        <span class="performance-metric__label">Overall time</span>
        <span class="performance-metric__value">5.02 ms</span>
      </div>
      <div class="performance-metric__bar"><div class="performance-metric__fill" style="--value: 100;"></div></div>
    </div>
    <div class="performance-metric">
      <div class="performance-metric__head">
        <span class="performance-metric__label">Tracing + shading</span>
        <span class="performance-metric__value">1.47 ms</span>
      </div>
      <div class="performance-metric__bar"><div class="performance-metric__fill" style="--value: 100;"></div></div>
    </div>
    <div class="performance-metric">
      <div class="performance-metric__head">
        <span class="performance-metric__label">ReSTIR</span>
        <span class="performance-metric__value">1.39 ms</span>
      </div>
      <div class="performance-metric__bar"><div class="performance-metric__fill" style="--value: 100;"></div></div>
    </div>
    <div class="performance-metric">
      <div class="performance-metric__head">
        <span class="performance-metric__label">Denoising</span>
        <span class="performance-metric__value">2.00 ms</span>
      </div>
      <div class="performance-metric__bar"><div class="performance-metric__fill" style="--value: 98.04;"></div></div>
    </div>
    <div class="performance-metric">
      <div class="performance-metric__head">
        <span class="performance-metric__label">Tracing pixels</span>
        <span class="performance-metric__value">100%</span>
      </div>
      <div class="performance-metric__bar"><div class="performance-metric__fill" style="--value: 100;"></div></div>
    </div>
  </div>
</div>

<h3 id="sun-temple">Sun Temple</h3>

<p>Sun Temple consists primarily of rough, metallic surfaces - an advantageous scenario for ReSTIR, because the higher roughness broadens the specular lobe, making reuse easier. In this scene, VRT saved 71.46% or rays and around 27.18% of the time.</p>

<p>Frame:</p>

<p><img src="/assets/images/posts/variable-rate-tracing-restir-gi/2025-02-16_23-27-37.png" alt="2025-02-16_23-27-37.png" /></p>

<p>Tracing Rate Tiles:</p>

<p><img src="/assets/images/posts/variable-rate-tracing-restir-gi/2025-02-16_23-27-52.png" alt="2025-02-16_23-27-52.png" /></p>

<div class="performance-grid">
  <div class="performance-card">
    <h4>With VRT</h4>
    <div class="performance-metric">
      <div class="performance-metric__head">
        <span class="performance-metric__label">Overall time</span>
        <span class="performance-metric__value">4.85 ms</span>
      </div>
      <div class="performance-metric__bar"><div class="performance-metric__fill" style="--value: 72.82;"></div></div>
    </div>
    <div class="performance-metric">
      <div class="performance-metric__head">
        <span class="performance-metric__label">Tracing + shading</span>
        <span class="performance-metric__value">1.10 ms</span>
      </div>
      <div class="performance-metric__bar"><div class="performance-metric__fill" style="--value: 36.91;"></div></div>
    </div>
    <div class="performance-metric">
      <div class="performance-metric__head">
        <span class="performance-metric__label">ReSTIR</span>
        <span class="performance-metric__value">1.53 ms</span>
      </div>
      <div class="performance-metric__bar"><div class="performance-metric__fill" style="--value: 100;"></div></div>
    </div>
    <div class="performance-metric">
      <div class="performance-metric__head">
        <span class="performance-metric__label">Denoising</span>
        <span class="performance-metric__value">2.05 ms</span>
      </div>
      <div class="performance-metric__bar"><div class="performance-metric__fill" style="--value: 100;"></div></div>
    </div>
    <div class="performance-metric">
      <div class="performance-metric__head">
        <span class="performance-metric__label">Tracing rays</span>
        <span class="performance-metric__value">29.54%</span>
      </div>
      <div class="performance-metric__bar"><div class="performance-metric__fill" style="--value: 29.54;"></div></div>
    </div>
  </div>
  <div class="performance-card">
    <h4>Without VRT</h4>
    <div class="performance-metric">
      <div class="performance-metric__head">
        <span class="performance-metric__label">Overall time</span>
        <span class="performance-metric__value">6.66 ms</span>
      </div>
      <div class="performance-metric__bar"><div class="performance-metric__fill" style="--value: 100;"></div></div>
    </div>
    <div class="performance-metric">
      <div class="performance-metric__head">
        <span class="performance-metric__label">Tracing + shading</span>
        <span class="performance-metric__value">2.98 ms</span>
      </div>
      <div class="performance-metric__bar"><div class="performance-metric__fill" style="--value: 100;"></div></div>
    </div>
    <div class="performance-metric">
      <div class="performance-metric__head">
        <span class="performance-metric__label">ReSTIR</span>
        <span class="performance-metric__value">1.51 ms</span>
      </div>
      <div class="performance-metric__bar"><div class="performance-metric__fill" style="--value: 98.69;"></div></div>
    </div>
    <div class="performance-metric">
      <div class="performance-metric__head">
        <span class="performance-metric__label">Denoising</span>
        <span class="performance-metric__value">2.03 ms</span>
      </div>
      <div class="performance-metric__bar"><div class="performance-metric__fill" style="--value: 99.02;"></div></div>
    </div>
    <div class="performance-metric">
      <div class="performance-metric__head">
        <span class="performance-metric__label">Tracing rays</span>
        <span class="performance-metric__value">100%</span>
      </div>
      <div class="performance-metric__bar"><div class="performance-metric__fill" style="--value: 100;"></div></div>
    </div>
  </div>
</div>

<h2 id="quality">Quality</h2>

<p>Quality of VRT depends on scene characteristic. For example, on San Miguel scene, which has mostly rough, non-metal materials, images rendered with and without VRT are very similar. Differences are most visible in places where there are strong indirect shadows - sometimes they are way softer in version with VRT and sometimes they are even missing.</p>

<p>Pica pica is quite interesting example as it mostly VRT works well but it can break a bit when looking at geometry at grazing angles. Walls in that scene are very smooth but not metallic. Because of that mostly diffuse reflections matter so usually algorithm picks less rays. But on grazing angles specular reflections become visible (especially because specular reflections are very easy to distinguish because of low roughness) but sometimes heuristic doesn’t trace enough rays to cover both diffuse and specular at angles at which both contribute more or less equally.</p>

<p>Additionally, below I added comparison on Pica Pica with all materials set to fully metallic. This usually results in higher tracing rates as sample points are harder to reuse.</p>

<p>Lastly, there is Sun Temple which generally looks similar but there are some differences when using VRT, especially on the edges of a room, where it takes more rays to find window or geometry exposed to directional light. In those cases, VRT sometimes picks low tracing rates and misses those important samples because of small amount of rays traced every frame. This kind of feeds itself because it results in lower variance and therefore even lower tracing rate.</p>

<p>Below are comparisons from different scenes. Each comparison starts with an interactive slider: the left side shows the full-rate reference and the right side shows the variable-rate result. Under that, I include the NVIDIA FLIP error visualization together with the key statistics. All of these images were rendered at full HD, without anti-aliasing, upscaling, or soft ray-traced shadows to isolate the error caused specifically by VRT.</p>

<h3 id="san-miguel-1">San Miguel 1</h3>

<div class="image-compare" style="--split: 50%;">
  <div class="image-compare__labels">
    <span>Full rate</span>
    <span>Variable rate</span>
  </div>
  <div class="image-compare__viewport">
    <img class="image-compare__base" src="/assets/images/posts/variable-rate-tracing-restir-gi/2025-02-16_20-28-39.png" alt="San Miguel 1 rendered with full-rate tracing" loading="lazy" />
    <img class="image-compare__overlay" src="/assets/images/posts/variable-rate-tracing-restir-gi/2025-02-16_20-28-55.png" alt="San Miguel 1 rendered with variable-rate tracing" loading="lazy" />
    <div class="image-compare__divider"></div>
    <div class="image-compare__handle"></div>
    <input class="image-compare__range" type="range" min="0" max="100" value="50" aria-label="San Miguel 1 comparison slider" oninput="this.parentElement.parentElement.style.setProperty('--split', this.value + '%')" />
  </div>
</div>

<div class="comparison-detail-grid">
  <div>
    <img src="/assets/images/posts/variable-rate-tracing-restir-gi/SanMiguel1.png" alt="San Miguel 1 FLIP error visualization" loading="lazy" />
  </div>
  <div class="comparison-stats">
    <h4>Stats</h4>
    <h5>Tracing coverage</h5>
    <dl>
      <dt>Variable rate</dt>
      <dd>19.11% tracing pixels</dd>
      <dt>Full rate</dt>
      <dd>100% tracing pixels</dd>
    </dl>
    <h5>FLIP error</h5>
    <dl>
      <dt>Mean</dt>
      <dd>0.034320</dd>
      <dt>Weighted median</dt>
      <dd>0.043663</dd>
      <dt>1st weighted quartile</dt>
      <dd>0.029751</dd>
      <dt>3rd weighted quartile</dt>
      <dd>0.060431</dd>
      <dt>Min</dt>
      <dd>0.000000</dd>
      <dt>Max</dt>
      <dd>0.603303</dd>
    </dl>
  </div>
</div>

<h3 id="san-miguel-2">San Miguel 2</h3>

<div class="image-compare" style="--split: 50%;">
  <div class="image-compare__labels">
    <span>Full rate</span>
    <span>Variable rate</span>
  </div>
  <div class="image-compare__viewport">
    <img class="image-compare__base" src="/assets/images/posts/variable-rate-tracing-restir-gi/2025-02-16_20-31-29.png" alt="San Miguel 2 rendered with full-rate tracing" loading="lazy" />
    <img class="image-compare__overlay" src="/assets/images/posts/variable-rate-tracing-restir-gi/2025-02-16_20-31-54.png" alt="San Miguel 2 rendered with variable-rate tracing" loading="lazy" />
    <div class="image-compare__divider"></div>
    <div class="image-compare__handle"></div>
    <input class="image-compare__range" type="range" min="0" max="100" value="50" aria-label="San Miguel 2 comparison slider" oninput="this.parentElement.parentElement.style.setProperty('--split', this.value + '%')" />
  </div>
</div>

<div class="comparison-detail-grid">
  <div>
    <img src="/assets/images/posts/variable-rate-tracing-restir-gi/SanMiguel2.png" alt="San Miguel 2 FLIP error visualization" loading="lazy" />
  </div>
  <div class="comparison-stats">
    <h4>Stats</h4>
    <h5>Tracing coverage</h5>
    <dl>
      <dt>Variable rate</dt>
      <dd>32.82% tracing pixels</dd>
      <dt>Full rate</dt>
      <dd>100% tracing pixels</dd>
    </dl>
    <h5>FLIP error</h5>
    <dl>
      <dt>Mean</dt>
      <dd>0.041690</dd>
      <dt>Weighted median</dt>
      <dd>0.053420</dd>
      <dt>1st weighted quartile</dt>
      <dd>0.036576</dd>
      <dt>3rd weighted quartile</dt>
      <dd>0.070499</dd>
      <dt>Min</dt>
      <dd>0.000000</dd>
      <dt>Max</dt>
      <dd>0.4100000</dd>
    </dl>
  </div>
</div>

<h3 id="sponza">Sponza</h3>

<div class="image-compare" style="--split: 50%;">
  <div class="image-compare__labels">
    <span>Full rate</span>
    <span>Variable rate</span>
  </div>
  <div class="image-compare__viewport">
    <img class="image-compare__base" src="/assets/images/posts/variable-rate-tracing-restir-gi/2025-02-16_20-42-31.png" alt="Sponza rendered with full-rate tracing" loading="lazy" />
    <img class="image-compare__overlay" src="/assets/images/posts/variable-rate-tracing-restir-gi/2025-02-16_20-42-50.png" alt="Sponza rendered with variable-rate tracing" loading="lazy" />
    <div class="image-compare__divider"></div>
    <div class="image-compare__handle"></div>
    <input class="image-compare__range" type="range" min="0" max="100" value="50" aria-label="Sponza comparison slider" oninput="this.parentElement.parentElement.style.setProperty('--split', this.value + '%')" />
  </div>
</div>

<div class="comparison-detail-grid">
  <div>
    <img src="/assets/images/posts/variable-rate-tracing-restir-gi/Sponza.png" alt="Sponza FLIP error visualization" loading="lazy" />
  </div>
  <div class="comparison-stats">
    <h4>Stats</h4>
    <h5>Tracing coverage</h5>
    <dl>
      <dt>Variable rate</dt>
      <dd>23.87% tracing pixels</dd>
      <dt>Full rate</dt>
      <dd>100% tracing pixels</dd>
    </dl>
    <h5>FLIP error</h5>
    <dl>
      <dt>Mean</dt>
      <dd>0.035069</dd>
      <dt>Weighted median</dt>
      <dd>0.048289</dd>
      <dt>1st weighted quartile</dt>
      <dd>0.032267</dd>
      <dt>3rd weighted quartile</dt>
      <dd>0.071649</dd>
      <dt>Min</dt>
      <dd>0.000000</dd>
      <dt>Max</dt>
      <dd>0.382221</dd>
    </dl>
  </div>
</div>

<h3 id="pica--pica">Pica  Pica</h3>

<div class="image-compare" style="--split: 50%;">
  <div class="image-compare__labels">
    <span>Full rate</span>
    <span>Variable rate</span>
  </div>
  <div class="image-compare__viewport">
    <img class="image-compare__base" src="/assets/images/posts/variable-rate-tracing-restir-gi/3.png" alt="Pica Pica rendered with full-rate tracing" loading="lazy" />
    <img class="image-compare__overlay" src="/assets/images/posts/variable-rate-tracing-restir-gi/4.png" alt="Pica Pica rendered with variable-rate tracing" loading="lazy" />
    <div class="image-compare__divider"></div>
    <div class="image-compare__handle"></div>
    <input class="image-compare__range" type="range" min="0" max="100" value="50" aria-label="Pica Pica comparison slider" oninput="this.parentElement.parentElement.style.setProperty('--split', this.value + '%')" />
  </div>
</div>

<div class="comparison-detail-grid">
  <div>
    <img src="/assets/images/posts/variable-rate-tracing-restir-gi/PicaPica1.png" alt="Pica Pica FLIP error visualization" loading="lazy" />
  </div>
  <div class="comparison-stats">
    <h4>Stats</h4>
    <h5>Tracing coverage</h5>
    <dl>
      <dt>Variable rate</dt>
      <dd>21.04% tracing pixels</dd>
      <dt>Full rate</dt>
      <dd>100% tracing pixels</dd>
    </dl>
    <h5>FLIP error</h5>
    <dl>
      <dt>Mean</dt>
      <dd>0.023469</dd>
      <dt>Weighted median</dt>
      <dd>0.028744</dd>
      <dt>1st weighted quartile</dt>
      <dd>0.018740</dd>
      <dt>3rd weighted quartile</dt>
      <dd>0.043563</dd>
      <dt>Min</dt>
      <dd>0.000066</dd>
      <dt>Max</dt>
      <dd>0.289845</dd>
    </dl>
  </div>
</div>

<h3 id="pica-pica-fully-metallic">Pica Pica (fully metallic)</h3>

<div class="image-compare" style="--split: 50%;">
  <div class="image-compare__labels">
    <span>Full rate</span>
    <span>Variable rate</span>
  </div>
  <div class="image-compare__viewport">
    <img class="image-compare__base" src="/assets/images/posts/variable-rate-tracing-restir-gi/3%201.png" alt="Pica Pica metallic scene rendered with full-rate tracing" loading="lazy" />
    <img class="image-compare__overlay" src="/assets/images/posts/variable-rate-tracing-restir-gi/4%201.png" alt="Pica Pica metallic scene rendered with variable-rate tracing" loading="lazy" />
    <div class="image-compare__divider"></div>
    <div class="image-compare__handle"></div>
    <input class="image-compare__range" type="range" min="0" max="100" value="50" aria-label="Pica Pica metallic comparison slider" oninput="this.parentElement.parentElement.style.setProperty('--split', this.value + '%')" />
  </div>
</div>

<div class="comparison-detail-grid">
  <div>
    <img src="/assets/images/posts/variable-rate-tracing-restir-gi/PicaPica2.png" alt="Pica Pica metallic FLIP error visualization" loading="lazy" />
  </div>
  <div class="comparison-stats">
    <h4>Stats</h4>
    <h5>Tracing coverage</h5>
    <dl>
      <dt>Variable rate</dt>
      <dd>24.75% tracing pixels</dd>
      <dt>Full rate</dt>
      <dd>100% tracing pixels</dd>
    </dl>
    <h5>FLIP error</h5>
    <dl>
      <dt>Mean</dt>
      <dd>0.034796</dd>
      <dt>Weighted median</dt>
      <dd>0.044308</dd>
      <dt>1st weighted quartile</dt>
      <dd>0.029361</dd>
      <dt>3rd weighted quartile</dt>
      <dd>0.064693</dd>
      <dt>Min</dt>
      <dd>0.000032</dd>
      <dt>Max</dt>
      <dd>0.334454</dd>
    </dl>
  </div>
</div>

<h3 id="sun-temple-1">Sun Temple 1</h3>

<div class="image-compare" style="--split: 50%;">
  <div class="image-compare__labels">
    <span>Full rate</span>
    <span>Variable rate</span>
  </div>
  <div class="image-compare__viewport">
    <img class="image-compare__base" src="/assets/images/posts/variable-rate-tracing-restir-gi/2025-02-16_19-46-02.png" alt="Sun Temple 1 rendered with full-rate tracing" loading="lazy" />
    <img class="image-compare__overlay" src="/assets/images/posts/variable-rate-tracing-restir-gi/2025-02-16_19-46-27.png" alt="Sun Temple 1 rendered with variable-rate tracing" loading="lazy" />
    <div class="image-compare__divider"></div>
    <div class="image-compare__handle"></div>
    <input class="image-compare__range" type="range" min="0" max="100" value="50" aria-label="Sun Temple 1 comparison slider" oninput="this.parentElement.parentElement.style.setProperty('--split', this.value + '%')" />
  </div>
</div>

<div class="comparison-detail-grid">
  <div>
    <img src="/assets/images/posts/variable-rate-tracing-restir-gi/SunTemple1.png" alt="Sun Temple 1 FLIP error visualization" loading="lazy" />
  </div>
  <div class="comparison-stats">
    <h4>Stats</h4>
    <h5>Tracing coverage</h5>
    <dl>
      <dt>Variable rate</dt>
      <dd>19.57% tracing rays</dd>
      <dt>Full rate</dt>
      <dd>100% tracing rays</dd>
    </dl>
    <h5>FLIP error</h5>
    <dl>
      <dt>Mean</dt>
      <dd>0.043754</dd>
      <dt>Weighted median</dt>
      <dd>0.054918</dd>
      <dt>1st weighted quartile</dt>
      <dd>0.034874</dd>
      <dt>3rd weighted quartile</dt>
      <dd>0.089520</dd>
      <dt>Min</dt>
      <dd>0.000086</dd>
      <dt>Max</dt>
      <dd>0.274971</dd>
    </dl>
  </div>
</div>

<h3 id="sun-temple-2">Sun Temple 2</h3>

<div class="image-compare" style="--split: 50%;">
  <div class="image-compare__labels">
    <span>Full rate</span>
    <span>Variable rate</span>
  </div>
  <div class="image-compare__viewport">
    <img class="image-compare__base" src="/assets/images/posts/variable-rate-tracing-restir-gi/3%202.png" alt="Sun Temple 2 rendered with full-rate tracing" loading="lazy" />
    <img class="image-compare__overlay" src="/assets/images/posts/variable-rate-tracing-restir-gi/4%202.png" alt="Sun Temple 2 rendered with variable-rate tracing" loading="lazy" />
    <div class="image-compare__divider"></div>
    <div class="image-compare__handle"></div>
    <input class="image-compare__range" type="range" min="0" max="100" value="50" aria-label="Sun Temple 2 comparison slider" oninput="this.parentElement.parentElement.style.setProperty('--split', this.value + '%')" />
  </div>
</div>

<div class="comparison-detail-grid">
  <div>
    <img src="/assets/images/posts/variable-rate-tracing-restir-gi/SunTemple2.png" alt="Sun Temple 2 FLIP error visualization" loading="lazy" />
  </div>
  <div class="comparison-stats">
    <h4>Stats</h4>
    <h5>Tracing coverage</h5>
    <dl>
      <dt>Variable rate</dt>
      <dd>27.05% tracing rays</dd>
      <dt>Full rate</dt>
      <dd>100% tracing rays</dd>
    </dl>
    <h5>FLIP error</h5>
    <dl>
      <dt>Mean</dt>
      <dd>0.041493</dd>
      <dt>Weighted median</dt>
      <dd>0.050049</dd>
      <dt>1st weighted quartile</dt>
      <dd>0.034952</dd>
      <dt>3rd weighted quartile</dt>
      <dd>0.071882</dd>
      <dt>Min</dt>
      <dd>0.000000</dd>
      <dt>Max</dt>
      <dd>0.294806</dd>
    </dl>
  </div>
</div>

<h3 id="large-vs-small-blocks">Large vs small blocks</h3>

<p>I also compared small (2×2) and large (8×8) tiles directly. As described in the heuristic section, the final tile tracing rate depends on the single pixel with the highest noise value. This naturally means smaller tiles will yield fewer traced pixels overall. However, very small tiles are harder to implement (e.g., allocating 4×4 traces requires accessing multiple tiles, it’s more unpredictable, and therefore it needs a longer history), and they use more memory. So I wanted to see if the potential savings justified the extra complexity.</p>

<p>Some prior work - like <a href="https://www.unrealengine.com/en-US/blog/take-a-deep-dive-into-nanite-gpu-driven-materials"><em>Nanite GPU-Driven Materials</em></a> and <a href="https://advances.realtimerendering.com/s2020/index.html"><em>Software-Based Variable Rate Shading in Call of Duty: Modern Warfare</em></a> - reported excellent results with small tiles for shading, but I haven’t seen much about small tiles for ray tracing, so I ran a quick comparison myself. In both cases, the heuristic is identical, relying on the single pixel with the highest noise. Below are two examples showing how the percentage of traced pixels differs in two scenes: Sponza, which is extremely simple and presumably wouldn’t benefit much from smaller tiles, and San Miguel, which has many fine details in the trees that often create higher tracing rates in single tiles. I expected little difference in Sponza and a huge difference in San Miguel, but in practice, the smaller tiles helped a lot in both scenes.</p>

<h3 id="sponza-1">Sponza</h3>

<p>Small tiles</p>

<p>21.38% tracing pixels</p>

<p><img src="/assets/images/posts/variable-rate-tracing-restir-gi/2025-02-16_22-38-41.png" alt="2025-02-16_22-38-41.png" /></p>

<p>Large tiles</p>

<p>30.54%</p>

<p><img src="/assets/images/posts/variable-rate-tracing-restir-gi/2025-02-16_22-39-43.png" alt="2025-02-16_22-39-43.png" /></p>

<p>Frame:</p>

<p><img src="/assets/images/posts/variable-rate-tracing-restir-gi/2025-02-16_22-39-57.png" alt="2025-02-16_22-39-57.png" /></p>

<p>Sponza has very simple geometry, which implies a fairly low and uniform tracing rate in theory. ReSTIR passes can reuse plenty of samples because there aren’t many small geometric details. Even so, using larger tiles with my heuristic resulted in 42.84% more rays traced compared to smaller tiles.</p>

<h3 id="san-miguel-1">San Miguel</h3>

<p>Small tiles:</p>

<p>25.75% tracing pixels</p>

<p><img src="/assets/images/posts/variable-rate-tracing-restir-gi/2025-02-16_22-44-23.png" alt="2025-02-16_22-44-23.png" /></p>

<p>Large tiles:</p>

<p>42.01% tracing pixels</p>

<p><img src="/assets/images/posts/variable-rate-tracing-restir-gi/2025-02-16_22-45-14.png" alt="2025-02-16_22-45-14.png" /></p>

<p>Frame:</p>

<p><img src="/assets/images/posts/variable-rate-tracing-restir-gi/2025-02-16_22-45-28.png" alt="2025-02-16_22-45-28.png" /></p>

<p>San Miguel is more complex. The large tree in the middle, along with various curved surfaces and fine details (like chairs), creates more depth discontinuities which often results in very different tracing rates. In my tests, larger tiles traced 63.15% more rays than smaller ones.</p>

<h2 id="things-to-improve">Things To Improve</h2>

<p>There are still a few areas that could be improved. First, variance estimation is currently done during denoising. This was the easiest approach, since the denoiser already handles reprojection and variance tracking. However, it limits flexibility, especially when using third-party denoisers like DLSS RR, which act a bit like a black boxes. It also ties the variance to the kernel size used in the first spatial pass (as SVGF kind of shrinks variance for next passes based on number of samples that were currently used for filtering), making the whole thing harder to tweak and less predictable.</p>

<p>Another issue is handling small, bright spots on the scene. These often require many rays to be able to find them, but with VRT, the tracing rate might drop too low to catch them. If no rays hit these spots, variance drops, and the rate lowers even further. This can darken the scene or introduce noise.</p>

<h1 id="conclusions">Conclusions</h1>

<p>In this blog post, I described how I integrated variable rate tracing (VRT) with ReSTIR in my personal project. I also tested the approach across various scenes and materials, comparing both quality and performance against a full-rate baseline.</p>

<h3 id="references">References:</h3>

<ul>
  <li><a href="https://developer.download.nvidia.com/video/gputechconf/gtc/2019/presentation/s91023-it-just-works-ray-traced-reflections-in-battlefield-v.pdf">It Just Works: Ray-Traced Reflections in Battlefield V</a></li>
  <li><a href="https://gdcvault.com/play/1034763/Advanced-Graphics-Summit-Raytracing-in">Raytracing in Snowdrop: An Optimized Lighting Pipeline for Consoles</a></li>
  <li><a href="https://gdcvault.com/play/1034763/Advanced-Graphics-Summit-Raytracing-in">Raytracing in Snowdrop: An Optimized Lighting Pipeline for Consoles</a></li>
  <li><a href="https://advances.realtimerendering.com/s2023/index.html">“The Rendering of The Callisto Protocol”</a></li>
  <li><a href="https://www.unrealengine.com/en-US/blog/take-a-deep-dive-into-nanite-gpu-driven-materials">Nanite GPU-Driven Materials</a></li>
  <li><a href="https://advances.realtimerendering.com/s2020/index.html">“Software-Based Variable Rate Shading in Call of Duty: Modern Warfare”</a></li>
  <li><a href="https://advances.realtimerendering.com/s2018/index.htm">“Real-Time rendering’s next frontier: Adopting lessons from offline ray tracing to real-time ray tracing for practical pipelines”</a></li>
  <li><a href="https://www.yiningkarlli.com/projects/hyperiondesign/hyperiondesign.pdf">“The Design and Evolution of Disney’s Hyperion Renderer”</a></li>
  <li><a href="https://interplayoflight.wordpress.com/2022/05/29/accelerating-raytracing-using-software-vrs/">Accelerating raytracing using software VRS</a></li>
  <li><a href="https://www.youtube.com/watch?v=Sswuj7BFjGo">Variable Rate Compute Shaders - Halving Deferred Lighting Time</a></li>
  <li><a href="https://intro-to-restir.cwyman.org/presentations/2023ReSTIR_Course_Notes.pdf">A Gentle Introduction To ReSTIR Path Reuse in Real-TIme</a></li>
  <li><a href="https://interplayoflight.wordpress.com/2023/12/17/a-gentler-introduction-to-restir/">A gentler introduction to ReSTIR</a></li>
  <li><a href="https://research.nvidia.com/sites/default/files/pubs/2020-07_Spatiotemporal-reservoir-resampling/ReSTIR.pdf">Spatiotemporal reservoir resampling for real-time ray tracing with dynamic direct lighting</a></li>
  <li><a href="https://research.nvidia.com/publication/2021-06_restir-gi-path-resampling-real-time-path-tracing">ReSTIR GI: Path Resampling for Real-Time Path Tracing</a></li>
  <li><a href="https://dl.acm.org/doi/10.1145/3528223.3530158">Generalized resampled importance sampling: foundations of ReSTIR</a></li>
  <li>GPU Zen 3</li>
  <li><a href="https://github.com/EmbarkStudios/kajiya/blob/main/docs/gi-overview.md">Kajiya Global illumination overview</a></li>
  <li><a href="https://cg.ivd.kit.edu/publications/2017/svgf/svgf_preprint.pdf">SVGF denoiser</a></li>
  <li><a href="https://en.wikipedia.org/wiki/Bessel%27s_correction">Bessel’s correction</a></li>
  <li><a href="https://advances.realtimerendering.com/s2024/content/EA-GIBS2/Apers_Advances-s2024_Shipping-Dynamic-GI.pdf">Frostbite</a></li>
  <li><a href="https://www.youtube.com/watch?v=nTZpKD600eQ">Far Cry 6</a></li>
  <li><a href="https://github.com/NVIDIAGameWorks/RTXGI">RTXGI</a></li>
  <li><a href="https://www.nvidia.com/en-us/on-demand/session/gtcspring21-s32759/">“ReLAX: A Denoiser Tailored to Work with the ReSTIR Algorithm”</a></li>
</ul>]]></content><author><name>Mateusz Kołodziejczyk</name></author><category term="global-illumination" /><category term="ray-tracing" /><summary type="html"><![CDATA[Introduction]]></summary></entry></feed>