ReSTIR GI is the technique most real-time renderers now reach for when they want ray-traced diffuse bounce light: every pixel traces one cheap ray into the scene, keeps it as a candidate sample of incoming light, and then resamples among its own past candidates and its neighbours' so that each pixel ends up shading with the best of many samples while having traced only one. It was introduced by Ouyang and colleagues at HPG 2021, building on the original ReSTIR paper for direct light, and ours traces those rays at half resolution, because a ray per full-resolution pixel is more than the frame can pay for.
Half resolution comes with a question the papers do not have to answer: a half-resolution pixel covers four full-resolution texels of the depth and normal buffers, the ray has to start from one point, and every pass that later touches that ray has to agree on which point it was. In Breda, our rendering framework, they did not, which showed up as stripes in the candidate buffer and a slight darkening of the indirect light, and the fix took two changes four days apart: the first made the two passes that create candidates agree with each other, and the second made them agree with the rest of the pipeline. All of our GI passes now use the same texel, the top-left one of each 2×2 block, on every frame.
This post is about why that agreement matters more in ReSTIR than in an ordinary half-resolution effect, the ways to choose the texel, what we chose and what it costs, and how to find this class of bug. It does not cover the resampling maths, the denoiser or the radiance caches behind the gather, and our earlier post on the system is the place for the overall design.
A sample is stored without its origin
The GI system in breda-global-illumination runs its candidate and resampling passes at the resolution set by GI_PASS_RESOLUTION_SCALE, half by default, and its resolve and filters at full resolution. Of those, Prepare Diffuse Rays builds each ray's origin and direction, the trace passes shoot it and its shadow ray, Resolve Diffuse Rays packs the result into the candidate buffers, and then temporal resampling, two spatial passes, a visibility validation pass and a patch pass shuffle reservoirs around before the full-resolution Diffuse ReSTIR resolve turns them into irradiance.
What travels through all of that is not a ray in the usual sense. The candidate data holds, per pixel, the direction from the origin to the hit and the hit distance, packed into integers alongside the normals at both ends, so a sample is
where is the hit point, and are the stored direction and distance, and , the origin, is not stored anywhere. Every pass that needs the hit point, which is every pass that resamples, rebuilds from the depth buffer at the pixel the reservoir came from and adds to it.
That is a sensible encoding, since storing a world position per sample would cost another three floats in every history texture and the depth buffer already has the information, but it has a consequence that is easy to miss: if two passes rebuild from different texels, the second one moves the hit point by exactly the distance between the two origins, and a spatial neighbour that reuses the sample reconnects to the wrong place. On a flat floor that is one texel's footprint in world space and nobody will see it, and across a depth edge, where the four texels of a block can sit on a foreground object and the background behind it, it is the full depth difference between them.
Three ways to pick the texel
There are three common ways to decide which full-resolution texel a half-resolution pixel stands for, and the trade between them is mostly about how many places have to know the rule.
| Approach | How the origin is found | What it costs |
|---|---|---|
| Sample at the block centre | A UV at the centre of the half-resolution pixel, point sampled from the full-resolution buffers | Nothing to write, but the centre is the corner the four texels share, so the choice is left to rounding |
| Downsample the G-buffer first | A pass writes half-resolution depth and normals, by a fixed texel or a closest-depth rule, and every GI pass reads those | A pass and two textures, but the rule exists in exactly one place |
| An integer texel by rule, in every pass | Each pass computes the same full-resolution coordinate and loads it | No extra pass, but every pass has to compute the same thing, forever |
The third option comes in two flavours. A fixed offset, the same corner of every block on every frame, is the simplest thing that can be kept consistent. A rotating offset steps through the four texels of the block over four frames, the way checkerboard rendering does, so thin geometry that only covers one texel of a block is sampled one frame in four instead of never, and the temporal passes stitch the four together. Breda already has the helper for that in half_res_helpers.hlsl, and our ray-traced reflections use it with the frame index, so it was the obvious tool to reach for.
One 2×2 block, as the candidate passes saw it before the fix, in the fix as first merged, and now.
The stripes in the candidate buffer
The GI candidate passes were using the first option, and a comment in both of them already flagged part of the problem: QSV is invalid when used at half-res!, about the quad validity bits in the reprojection texture, which are only meaningful at the full-resolution texel they were computed for. Both prepare_diffuse_trace and resolve_diffuse_rays built their UV as and point sampled the full-resolution depth, normals and reprojection data with it.
With an even full-resolution size, that UV lands exactly on the shared corner of the four texels, and a point sampler asked for a texel at a coordinate exactly on its boundary returns one or the other according to rounding in the UV arithmetic and in the sampler, which need not come out the same across the screen, evidently did not, and was never a decision anyone made. With an odd size the UV drifts away from the corner across the screen, by up to a whole texel at the far edge, which is a different texel choice again. Accumulated over many frames, the candidate output showed it as stripes, and because the origins were wrong the rays were too, which darkened the indirect light slightly; the pull request is explicit that this was not the main cause of the darkening the team was chasing at the time.
The fix, merged on 2026-06-22, replaced the UVs with integer loads at a coordinate both passes compute the same way, and added the full resolution and an integer scale factor to their constants so they could:
uint2 fullResCoord = ;
float2 fullResUv = / bnd.constants.fullResolution;
float currentDepth = bnd.depthBuffer.;
Note the last argument, the frame index, which makes virtualFullResPixelCoordsFromScale rotate the offset through , , and on successive frames, the checkerboard flavour from the table. Both candidate passes now agreed with each other exactly, which is what the pull request's before and after captures of the accumulated candidates compare, and the comment in resolve_diffuse_rays says what the change was for: the candidate's depth and reprojection have to match the ray that was actually traced.
Agreeing with each other is not agreeing with the pipeline
The candidate passes are two of eight passes that turn a half-resolution coordinate into a full-resolution one, and the other six already had a rule of their own. The temporal pass, both spatial passes, the validation and patch passes and the full-resolution resolve all map a half-resolution coordinate to a full-resolution one the same way:
float centerDepth = bnd.depth.;
which is the top-left texel, offset , on every frame. So after the fix, the candidates were traced from the rotating texel and rebuilt everywhere else from the fixed one, and they agreed one frame in four. Validation frames, every third frame, had it worse, because a validation frame re-traces last frame's chosen sample from this frame's candidate origin to check whether its light has changed, and that sample's direction is relative to an origin the other passes rebuild at , so the validation ray started from the right texel only on frames where the index was a multiple of both three and four.
Which texel the candidate passes used on each of twelve frames, against the every later pass rebuilds from.
The animation below draws that for one row of the screen: the ray as traced, from the rotating texel, and the same direction and distance rebuilt from the top-left texel, with the error largest where a block straddles the edge of an object.

Four days later, in the change that switched the validation heuristic to luminance, both candidate passes started passing 0 instead of the frame index, which turns the helper into the fixed rule and makes every pass agree on every frame. That change's description is about the validation heuristic and does not mention the offset at all, so we cannot point at a discussion, though the code it produced is an arrangement in which all eight passes rebuild the same origin, and that is reason enough.
What we chose, and what it gives up
We ended up on a fixed texel computed in every pass, the third row of the table without the rotation, and we think that is right for now, for a reason that has little to do with image quality: the rule already lived in six passes, so making the other two match was a two-line change, while moving all eight to a rotating offset would have meant touching every resampling pass, the history reprojection and the validation, each of which would need to know which texel last frame's sample came from as well as this frame's.
The cost is the thing the rotation was buying. Three of the four texels in every block are never a ray origin, so a feature that is one full-resolution texel wide and falls on the wrong three gets its indirect light only by reconnection from neighbours, through the full-resolution resolve, which shades every pixel with its own depth and normal but takes its samples from the half-resolution reservoirs. That is the same trade every fixed-offset half-resolution effect makes, and we have not yet seen it in a capture, though we have not gone looking with thin geometry either.
The second option in the table, a downsampled G-buffer, is the one we would pick if we were starting again. It costs a small pass and two half-resolution textures, and in exchange the rule exists once, which makes this whole class of bug impossible rather than merely fixed, and lets a closest-depth or a rotating rule be tried by changing one shader.
How we isolated the candidate path
A GI bug that darkens the image slightly is hard to find in a full frame, because the screen-space gather is only one of the paths light takes: behind it sits a backing radiance cache, a clipmap, a hash grid or a neural radiance cache in our case, which supplies the bounces after the first, and any of those can hide a fault in the gather or be mistaken for it.
The same change added a fourth option to GlobalIlluminationBackingCache, Disabled, which drops all three caches so the gather contributes exactly its one next event estimation bounce, direct light at the hit and nothing beyond. With that, the screen-space path can be compared against the path-traced ground truth with its bounce count set to two, which computes the same thing by brute force, and any difference is the gather's fault rather than a cache's. That comparison is cheap to keep around and we would build it first next time, before the cache, because the cache is the part that makes everything else hard to see.
The same pull request also fixed ViewRayInfo::calculateFromUvAndDepth, which built a world-space view direction by transforming a homogeneous view-space vector, with , so the camera's translation leaked into a direction. The function had no callers when it was fixed, but it is the same kind of mistake as the origin, a coordinate whose meaning was assumed rather than stated, and it is worth searching for in your own helpers while you are looking.
What is still open
- The temporal pass is half a texel off. It loads depth from the texel but builds the centre's world position with the half-resolution UV, which is the block's centre, while the spatial passes already add
0.5 / resolutionScaleto land on the texel's own centre. The error is half a full-resolution texel on screen, small on flat surfaces and larger on steep ones. - The GI is still too dark. The follow-up change says so in as many words, with the neural radiance cache as the comparison, and the origin fix was never claimed to be the main cause.
- Thin features. As above, we have not tested a fixed offset against thin geometry, and the rotating offset's helper is still there if we need it, on the condition that every pass learns it at once.
Where to go next
If you are building half-resolution ReSTIR GI, read the ReSTIR GI paper for the Jacobian that reusing a sample at a neighbouring pixel needs, because that is exactly the step a wrong origin breaks, and our earlier post on the system for the passes around it. Then, before tuning anything, search your shaders for every place a half-resolution coordinate becomes a full-resolution one and check that they all compute the same thing, including the history and validation paths, which are the ones most likely to have been written separately.
The cheapest debug view we know of for this is the one in the pull request: accumulate the raw candidate buffer over many frames with the camera still, which removes the noise and leaves only the structure, and any pattern that lines up with the half-resolution grid is two passes disagreeing about where the rays started. What we would tell anyone before they start is that in an encoding where the origin is never stored, the rule for rebuilding it is part of the data format, and it deserves to live in one function or one texture, not to be re-derived by every pass that happens to need it.


