<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Traverse Research - Medium]]></title>
        <description><![CDATA[Traverse Research is a rendering R&amp;D company located in Breda, The Netherlands - Medium]]></description>
        <link>https://blog.traverseresearch.nl?source=rss----2cf229d54ed9---4</link>
        <image>
            <url>https://cdn-images-1.medium.com/proxy/1*TGH72Nnw24QL3iV9IOm4VA.png</url>
            <title>Traverse Research - Medium</title>
            <link>https://blog.traverseresearch.nl?source=rss----2cf229d54ed9---4</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Mon, 20 Jul 2026 06:46:55 GMT</lastBuildDate>
        <atom:link href="https://blog.traverseresearch.nl/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[Dynamic diffuse global illumination]]></title>
            <link>https://blog.traverseresearch.nl/dynamic-diffuse-global-illumination-b56dc0525a0a?source=rss----2cf229d54ed9---4</link>
            <guid isPermaLink="false">https://medium.com/p/b56dc0525a0a</guid>
            <category><![CDATA[restir]]></category>
            <category><![CDATA[diffuse-gi]]></category>
            <category><![CDATA[path-tracing]]></category>
            <category><![CDATA[gis]]></category>
            <category><![CDATA[ray-tracing]]></category>
            <dc:creator><![CDATA[Darius Bouma]]></dc:creator>
            <pubDate>Sat, 30 Dec 2023 16:33:53 GMT</pubDate>
            <atom:updated>2024-01-14T15:39:06.371Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fMGEvQ_LKxCWhnEcH-i0bQ.png" /></figure><iframe src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fwww.youtube.com%2Fembed%2FkoNL9eb7llE%3Ffeature%3Doembed&amp;display_name=YouTube&amp;url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DkoNL9eb7llE&amp;key=a19fcc184b9711e1b4764040d3dc5c07&amp;type=text%2Fhtml&amp;schema=youtube" width="854" height="480" frameborder="0" scrolling="no"><a href="https://medium.com/media/fa52eeace133d72a2d28b174e0600d49/href">https://medium.com/media/fa52eeace133d72a2d28b174e0600d49/href</a></iframe><p>This blogpost will cover how we set up our dynamic diffuse global illumination system in our rendering framework at <a href="https://traverse.nl/">Traverse Research</a>. It’s completely <a href="https://research.nvidia.com/sites/default/files/pubs/2020-07_Spatiotemporal-reservoir-resampling/ReSTIR.pdf">ReSTIR</a> based, but we won’t be focussing as much on how ReSTIR math works, instead we’ll focus more on the tricks, techniques and things we tried with setting up this system.</p><h3>Performance breakdown</h3><p>Timings were measured on an RTX 3080 running at a native resolution of 1440p while running the scene <a href="https://sketchfab.com/3d-models/flying-world-battle-of-the-trash-god-350a9b2fac4c4430b883898e7d3c431f">Flying world — Battle of the Trash god</a>.</p><p>Camera position: [7.8699145, 2.2485592, -7.960048]<br>Camera look at position: [7.840394, 2.066986, -8.942983]</p><p>In total we use the following number of rays:</p><ul><li>Indirect diffuse: 0.25 diffuse rays/pixel + 0.25 shadow rays/pixel + 0.25 validation rays/pixel</li><li>Irradiance cache: An upper bound of 65535 * (4 diffuse + 4 validation + 8 shadow rays)</li></ul><h4>Indirect diffuse — 1.97ms</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/380/1*fbcVzLtoqF8A4Eo-Y1vMtA.png" /></figure><h4>Denoiser — 697μs</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/375/1*kePocSwYWk9R5w21BBvofg.png" /></figure><h4>Irradiance cache — 473μs</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/377/1*gkJGMyElmTMYZZ7lk4jMAQ.png" /></figure><h4>Candidate rays</h4><p>For estimating indirect diffuse, we start off by tracing diffuse rays at half resolution. If we look at the ReSTIR GI paper, we should consider sampling the hemisphere uniformly rather than cosine-weighted. This will avoid low sampling probabilities on big lighting contributors at grazing angles, which can result in a much higher variance.</p><p>As for generating the diffuse rays, it’s generally a good idea to use blue noise. Though combining other noise techniques or combinations of them can also produce decent samples, if for example you want to abuse sampling patterns during spatial/temporal reuse or filtering.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*tsHc7vOUehdFMIAymqbJdA.png" /><figcaption>Blue noise generated candidates</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*pBQRvUZUQTdI7VjwiD_V1A.png" /><figcaption>Blue noise + interleaved gradient noise generated candidates</figcaption></figure><p>If we’ve hit a surface, we sample the indirect lighting and direct lighting at the hit point. The indirect lighting comes from the irradiance cache in form of L1 spherical harmonics, while the direct lighting is estimated by tracing a shadow ray towards light sources.</p><p>Once we measured the incoming radiance from our candidate rays, we store the following information:</p><ul><li>Sample direction</li><li>Hit distance</li><li>Incoming lighting</li><li>Sample hit surface normal</li></ul><p>This may seem like a lot of data to store for each candidate, however we can compress this data down to a total of 4 uints. We encode the sample direction and hit normal as <a href="https://jcgt.org/published/0003/02/01/">octahedron</a>, for the incoming lighting we use <a href="https://github.com/microsoft/DirectX-Graphics-Samples/blob/master/MiniEngine/Core/Shaders/PixelPacking_RGBE.hlsli"><em>R9G9B9E5_SHAREDEXP</em></a><em> </em>and finally we store the hit distance as full float.</p><p>NOTE: Since we are tracing rays uniformly over the hemisphere, we essentially get “free” ray-traced ambient occlusion, which may be useful to use in subsequent restir and denoising passes!</p><h4>Temporal reuse</h4><p>Temporal reuse is the pass where we need to be the most careful with sampling, reprojection needs to be accurate and rejection heurstics need to be relatively strict as all of our subsequent passes and frames rely on this pass. If our reprojected history is valid for reuse, we merge it with the candidate reservoir.</p><p>The output is still pretty noisy though as we clamp our reservoirs to a maximum sample count of 12.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*2ln4MK23jgb181CjUQxBwg.png" /><figcaption>Temporal reuse</figcaption></figure><p>A technique that we can use to reduce variance further is <a href="https://twitter.com/more_fps/status/1457749362025459715">permutation sampling</a>, the idea is pretty simple; we use a global jitter and afterwards xor the reprojected pixel index by a value, which results in an ordered spatial exchange of history reservoirs.</p><p>This is kind of the same idea as running a spatial search in temporal reuse. However if we were to use naive spatial resampling here, we would get massive correlations between pixels as multiple pixels would be resampling the same neighbor reservoirs, resulting in “clumps” of bright samples. The most ideal pattern seems to be a global xor value that changes every frame in a range of [1..3], though a naive xor of 2 or 3 works fine as well.</p><p>We chose to gather multiple permutation samples each temporal reuse pass, while exponentially decreasing the sampled reservoir M clamp. While this does initially make the temporal reservoirs less accurate, it helps a <em>lot</em> with disocclusion. Detail is recovered by using guiding masks and ReSTIR validity frames. Alternatively you can also choose to only run a set of permutation samples when the center reservoir is starved for samples (for example a newly disoccluded pixel).</p><p>Since the permutation samples came from a different origin we must make sure that we apply a jacobian here.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*655TwvPUGVqjjBPYTDlxVw.png" /><figcaption>Permutation sampling</figcaption></figure><p>We don’t know if the merged reservoirs had a visible sample, naively merging these reservoirs in this case results in loss of contact shadows. The correct way of preventing this would be to simply trace the visibility of these samples, but this is too expensive as we have a very limited ray budget. Instead we can use an indirect shadow guiding factor generated by the previous frame, and reject permutation samples if their “shadow” values differ too much, more on this in the spatial reuse section.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*vaJnRv3Wvz4nFnPI25vT6Q.png" /><figcaption>Permutation sampling with indirect shadow guiding</figcaption></figure><p>Smaller details and shadows are better preserved temporally with indirect shadow guiding. Not being careful here can result in much more visible disocclusion/movement artifacts in dynamic scenes, which are very difficult to filter.</p><p>As for guiding/rejection techniques we’ve tried to use an <a href="https://gpuopen.com/download/publications/Efficient_Spatial_Resampling_Using_the_PDF_Similarity.pdf#i3D2023">average sampling direction</a> from the selected reservoirs, <a href="https://github.com/EmbarkStudios/kajiya/blob/c17ec213b75b12092de01cd69c3b29bb39949ce3/docs/gi-overview.md#indirect-diffuse-23ms">SSAO masks</a> or even denoise the RTAO we got for free earlier on. All of which have their advantages and disadvantages.</p><p>There may be other ways of steering sampling/rejecting samples, visualizing specific sampling data produced by resampling may give some ideas on what you can (ab)use to refine rejection heuristics!</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*pPIvJMXxxeH6oxtYrcwliA.png" /><figcaption>Average reservoir sampling direction visualized</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*1M1dM-EOkiq0vg0ARzuLLQ.png" /><figcaption>Reservoir W visualized</figcaption></figure><h4>Spatial reuse</h4><p>For spatial reuse we run two separate kernels, the first kernel does a large spatial search with a radius around 4% (42 pixels) of the rendering resolution. The second kernel runs the same logic but instead takes less samples and runs at roughly 2%(20 pixels) of the rendering resolution, though this can be smaller depending on how low the sample count of the center reservoir is.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*c7LYju4ptN1ucRuCcZeoYw.png" /><figcaption>ReSTIR spatial reuse result</figcaption></figure><p>Both these spatial reuse passes don’t do anything fancy besides merging reservoirs that survive the rejection heuristics. In order to reject “bad” reservoirs, we compare normals and measure the <a href="https://www.nvidia.com/en-us/on-demand/session/gtcsj20-s22699/">tangent plane distance</a> between the center and picked reservoirs. Finally we also apply the jacobian, if the reservoir has survived these checks and does not have a rediculous jacobian, we go ahead and merge the picked reservoir.</p><p>Using the tangent plane distance here is essential to avoid excessively rejecting neighbor samples. When only using depth rejection, we tend to get a lot of “boiling” noise on surfaces that have a grazing angle with respect to the view point.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/706/1*MxfluSMrske1OqNbjS_BKg.png" /><figcaption>Left: Naive depth rejection — Right: Tangent plane distance</figcaption></figure><p>So what happened to all the indirect shadows and details? We do not use guiding masks here to reject reservoir reuse, nor do we test visibility for each reused reservoir. Testing visibility for each reuse candidate is way too expensive if we were to use ray tracing as we would need to trace an additional 10–15 rays per pixel, though there have been some alternatives using <a href="https://github.com/EmbarkStudios/kajiya/blob/main/docs/gi-overview.md#indirect-diffuse-23ms">screen-space ray marching</a>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*lL9p3PCeGUCb_NBntJ8BLw.png" /><figcaption>Testing visibility for each spatial reuse candidate</figcaption></figure><p>Instead we keep the spatial reuse simple and light-weight. Afterwards we only test visibility for the final surviving reservoir. If the sample is occluded, the result sample will simply have 0 contribution. This is obviously biased and introduces some noise, but in reality provides a nice tradeoff between quality and performance.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*QCQocIs8Pcycs0Nwlq9tjg.png" /><figcaption>Testing visibility for the final surviving reservoir after spatial reuse</figcaption></figure><p>Now that we’ve tested the visibility of all of the surviving reservoirs, which tend to be specifically towards indirect light sources with the highest contribution, we may find the results of these visibility tests to be useful for more than just validating reservoir visibility. Let’s see what this actually looks like if we were to treat it like a shadow mask:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Uk1iQ3UmXir53fhJG4QHig.png" /><figcaption>Treating final visibility traces as a shadow mask</figcaption></figure><p>This seems to <em>exactly</em> mark the regions where we would need to be extra careful with spatial exchanges and filtering! We definitely don’t want to “smear” out our indirect shadows in subsequent passes and frames, now we can just measure the shadowing difference between pixels and reject based on that.</p><p>Ideally we would have a denoised version of these indirect shadows:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*UbKWgWWGninSDML_3LKpjg.png" /><figcaption>Accumulated indirect shadows</figcaption></figure><p>However doing an extremely cheap spatio-temporal blur with adding some bias towards shadow hits that have a shallow angle actually appears to be more than enough. We only care about the difference between pixels rather than actually modulating the final indirect lighting.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*t0g583i5A86g2iN4dCjMFA.png" /><figcaption>“Denoised” indirect shadow guiding factor</figcaption></figure><p>Additionally, in the future we’d like to explore this idea and try storing the occluded direction instead. Doing so might prove useful as a way to estimate visibility during resampling. This way we can be more specific on what samples get shared rather than only comparing their indirect shadow factor.</p><p>Consider the following situation:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*FYcPavV_UFL3nXScs2cfUg.png" /><figcaption>Green had a visible sample and has a guiding factor of 0, red had its final sample occluded and now has a guiding factor of 1.</figcaption></figure><p>In this scenario we would reject sharing reservoirs between green and red, as their guiding factor differs too much. This is a problem since the green sample is actually visible from the red marked surface.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*wySW1iMXEoYA9nOaMESdmg.png" /><figcaption>We reject potential reuse from the red origin marked with the blue line</figcaption></figure><p>If we were to make the guiding factor consider directionality here, we would still reject any samples that would roughly come from the occluded direction, while being able to reuse samples that are more likely to be visible from other directions.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*LzeBZxfVDqPgt5HW400NPQ.png" /><figcaption>With directionality, we could potentially reuse the green sample from the red surface, indicated with the blue line</figcaption></figure><h4>Resolving to full resolution</h4><p>Now that we have validated our reservoirs after spatial reuse, we can resolve them to full resolution. We do this by taking 4 samples per pixel in a small screenspace radius. Furthermore we reject samples that differ too much in the indirect shadowing factor, this helps to preserve sharp indirect shadows.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*BpR4gi43_O3DCWKoJhJzoA.png" /><figcaption>Resolved reservoirs with only testing visibility for the final sample</figcaption></figure><p>We observe quite a lot of noise still being present here, which is the result of us being cheap about testing visibility. If we compare this to the resolved reservoirs that actually had all their visibility traced during the spatial exchange passes, we may be able to be less aggressive during denoising, however we decided that the additional cost of tracing many more shadow rays is not worth it.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*JZ93orgBKw2wJuER2J6Hrw.png" /><figcaption>Resolved reservoirs with tested visibility during spatial exchange</figcaption></figure><p>For denoising we use a very basic spatio-temporal filter. We initially tried filtering in YCbCr here, but this gave some undesired “boiling” artifacts in the indirect shadow regions. Instead we we decide to use AABB color clipping to avoid hue shifts when lighting condition changes. Doing so resulted in much more stable indirect shadows.</p><p>Once again we use the indirect shadow guiding mask here to manipulate the accumulation rate and history rejection; indirect shadows from occluded visibility rays tend to be significantly more noisy compared to those which were not occluded, we accumulate at different rates based on those properties. If the indirect shadow factor shows a very large change, the accumulation rate gets changed accordingly to be more reactive.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*oRgkAgek5a82_Z4gszuJbQ.png" /><figcaption>Temporal reprojection</figcaption></figure><p>Most remaining noise is then cleaned up with a small bilateral filter.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*klH-Hx9nwVqLmn3XSWTUgg.png" /><figcaption>Final cleanup</figcaption></figure><p>Once we have the denoised indirect lighting, we simply modulate it with the albedo, let TAA take care of any remaining noise and we’re done!</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*UMGFC-oDybsw8X_1ZwhsGg.png" /><figcaption>Final output</figcaption></figure><p>A quick note on handling alpha tested geometry, we found that masking out alpha tested materials for all the diffuse traces, and enabling alpha testing for the final visibility test produced the best quality vs performance. Limiting the number of alpha tests a ray can evaluate may help with performance.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6nG9KbRh0vJKbmXxC2zdhA.png" /><figcaption>Test alpha in diffuse trace + test alpha in final visibility trace</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*dCJA1PedjQgeoUDpKr2iXw.png" /><figcaption>Force opaque in diffuse trace + test alpha in final visibility trace</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*8B2hOI5Tcb-VUtB-xt-hWQ.png" /><figcaption>Mask out alpha in diffuse trace + test alpha in visibility trace</figcaption></figure><h4>ReSTIR validation</h4><p>To keep the scene responsive to light changes, once every three frames a ReSTIR validation frame is executed to re-measure the selected reservoir’s data. If the hit position or incoming radiance of the sample is different, we scale the reservoir M down accordingly. This will make the indirect diffuse reservoirs much more responsive.</p><p>However if we would <em>only </em>trace validation rays during such a validation frame, newly disoccluded pixels would not get traced at all. More importantly we would be wasting validation on pixels that possibly have gone off-screen. To solve this issue, we create a split in the tracing kernel. We start by reprojecting the history pixel location to the current frame, if the reprojected pixel location falls outside of the current screen bounds, we simply trace regular diffuse rays for that pixel, otherwise we proceed with tracing validation rays, as we have a history available that will be re-used in the current frame.</p><h3>Irradiance cache</h3><p>To estimate “infinite” bounces we will need a world-space irradiance cache that can be queried from any point in our rendering pipeline. Initially we went for a clipmap that holds an indirection to the underlying data. The clipmap irradiance cache is largely inspired by the irradiance cache used in <a href="https://github.com/EmbarkStudios/kajiya/blob/main/docs/gi-overview.md">kajiya</a>, with some minor differences.</p><p>To give a quick recap: each entry in the irradiance cache places a probe at some distance away from its initial hit position. The probe is represented as a 4x4 octahedron map, where 4 diffuse rays, 4 validation rays and 8 shadow rays total are being traced from every single frame. Each ray samples the irradiance cache that it’s constructing, which leads to infinite bounces.</p><p>As an experiment we tried using a very lossy but cheap way to approximate shadow rays for all rays that query an irradiance cache entry. We did this by tracing a single shadow ray per irradiance cache entry and use use this result for all rays that would query it. This way we hoped to get around tracing additional shadow rays towards light sources from our diffuse bounces, but turned out to be <em>extremely</em> biased as we now mark the entire cache entry as lit or unlit. In the end it really was not worth it at all as we would only be tracing a relatively small amount of shadow rays less per frame in total, while getting significantly less accurate indirect lighting.</p><p>For each entry in the 4x4 octahedron map we store the same information as our diffuse candidate rays:</p><ul><li>Incoming radiance</li><li>Reservoir</li><li>Sample direction</li><li>Hit distance</li></ul><p>Just like in our indirect diffuse kernel, all of this info is again compressed the same way to a total of 4x4x4 uints per cache entry.</p><p>After tracing all the candidate rays, we apply temporal reuse to keep track of the most important samples. During each frame we also select 4 reservoirs to perform ReSTIR validation on, these reservoirs are the most frames away from getting new candidate samples. In a 4x4 octahedron map, this would be the set of samples of frame index <em>(currentFrameIdx + numberOfTraceFrames / 2) % numberOfTraceFrames </em>where<em> numberOfTraceFrames </em>is 4.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/362/1*bNkoVNERWqCrIiqqtsvxrQ.png" /><figcaption>Diffuse candidate rays highlighted in green. Validation rays highlighed in red.</figcaption></figure><p>Let’s assume we are at <em>frame 3</em>, in the upcoming frame we would provide new diffuse candidates for <em>frame 0.</em> In the previous frame we provided diffuse candidates for <em>frame 2, </em>which means that the last frame to receive any updates is<em> frame 1</em>. This way we keep all of the 16 samples in our entry from not being stale for longer than 1 frame total.</p><p>Once we are done tracing the diffuse and validation rays, we build the spherical harmonics of the cache entry out of all valid samples.</p><p>For sampling the irradiance cache, we only have to load a packed L1 spherical harmonic, which is stored in a compressed format of 128 bits. Alternatively if bandwidth is an issue, you can also choose to go for YCoCg spherical harmonics which can be compressed to 96 bits.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*TRXE-hyV9Lfixw3kkiQgVA.gif" /><figcaption>Irradiance cache visualized</figcaption></figure><p>You may notice the irradiance cache entries are “wobbly”, this is not an artistic choice but actually has some purpose behind it. Imagine the following situation:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/406/1*pT7NIv39WUiIugwaOo08XQ.png" /><figcaption>Two irradiance cache entries located at exactly the same distance to a vertex</figcaption></figure><p>When multiple irradiance cache entries are located at exactly the same distance to a vertex(red), which entry will we get when we query the position of the geometry? The answer to this question is <em>yes..</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*N7546CbZdBzBT2tpeEoECQ.gif" /><figcaption>Issue visualized</figcaption></figure><p>To get around this issue we can modify the lookup function of our cache entry by applying a <a href="https://www.youtube.com/watch?v=oQLmC0e-hpg&amp;t=666s">periodic shift</a>. What this does is drastically minimize the size of intersections between a given triangle and cache entries.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*bbC0opwaAmlAw9cM8oZTdQ.gif" /><figcaption>Same scenario but we apply a periodic shift in our cache lookup</figcaption></figure><p>There are problems with naively running a clipmap irradiance cache, one of the major ones being light leaking. There’s nothing stopping a ray from both the front and back of a surface from querying the same irradiance.</p><p>A good solution to this issue is a <a href="https://github.com/EmbarkStudios/kajiya/blob/main/docs/gi-overview.md#ranking-system">ranking system</a>, which assigns a rank to the ray that queries the irradiance cache at a given position. Rays that originate from the screen have a ranking score of 1. Irradiance cache entries that were allocated with this rank will trace rays with a ranking score of 2 etc. New tracing origins will only be considered if the voting ray has an equal or lower score compared to the rank the entry currently has.</p><p>Let’s take a closer look at the scenarios where these light leaks happen though, we can observe that they mainly appear on places where the ray towards the query position is smaller than the size of a cache entry.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*nF3hlfyW32V5zdvD7eGCEQ.png" /><figcaption>Light leaking by incorrectly sampling</figcaption></figure><p>As an experiment, we tried to just reject queries that meet this condition to see if this would be sufficient as well. This way we can let the indirect diffuse ReSTIR samples prioritise light that comes in from an angle that would exit the enclosed area, as the weight of those close hits would be 0.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*otCyepE2CpsAg5PuSwGiWw.png" /><figcaption>Light leaks gone by rejecting queries from hit distances smaller than a cache entry</figcaption></figure><p>Looking at the results we notice quite a bit of darkening happening in several locations, where light otherwise would be bouncing around. This works around the light leaking problem decently, but still would not stop location votes from ending up outside of the geometry.</p><h3>Future work</h3><h4>Visibility caching</h4><p>One of the “holy grails” of ReSTIR would be to efficiently cache and estimate visibility so we would not have to explicitly trace shadow rays for somewhat reliable resampling, <a href="https://www.youtube.com/watch?v=LRWWa4SwKuw">DDGI does something along those lines with radial gaussian depth</a> but lacks directionality which is essential for sample visibility.</p><p><a href="https://arxiv.org/pdf/2301.11376.pdf">Visibility bitmasks</a> sounds promising if we could somehow convert them into spherical bitmasks and/or store a bit more information besides boolean visibility information, but this quickly would consume more memory than we might hope for. Additionally updating visibility with canonical rays may not be ideal; let’s assume we were to use spherical bitmasks at a resolution of 16×16 slices, we would need to test 256 directions to fully fill in the visibility mask. Perhaps using “spherical visibility masks” per N×N region of pixels might speed things up.</p><h4>Denoising</h4><p>Ideally we want resolve the reservoirs to spherical harmonics, and interpolate them to full resolution after the spatio-temporal denoiser in a separate pass. This should in theory greatly reduce blurriness in the final output, especially when the scene has aggressive normal mapping or geometric complexity. This might also allow for running the tracing and reuse passes at an even lower resolution, as interpolating the coefficients is trivial.</p><h4>Irradiance cache</h4><p>A clipmap irradiance cache works great, but quickly runs into memory footprint issues when trying to use a higher resolution. The main reason for this is that we store indices in a 3D texture, which wastes a lot of memory on empty space.</p><p>As an alternative, a GPU hash grid can be used as this opens up more <em>potential</em> for higher resolution irradiance/DI/path caches.</p><p>Hopefully this post gave you a bit more insight on what techniques and tradeoffs can be used when building a ReSTIR-based dynamic GI system. Trying to preserve micro detail while still keeping a clean and responsive image has proven to be <em>extremely</em> difficult without tracing an excessive amount of rays, but not completely impossible.</p><p>If you have any questions on specifics of this blog post that I did not cover, don’t hesitate to reach out on <a href="https://mastodon.gamedev.place/@DBouma">mastodon</a> or <a href="https://twitter.com/dbalthazr">X</a>.</p><p>Finally I highly recommend checking out the links below as they provided <em>all</em> the foundations to build a dynamic GI system!</p><h3>Resources</h3><p><a href="https://github.com/EmbarkStudios/kajiya/blob/main/docs/gi-overview.md">https://github.com/EmbarkStudios/kajiya/blob/main/docs/gi-overview.md</a></p><p><a href="https://www.nvidia.com/en-us/on-demand/session/gtcsj20-s22699/">https://www.nvidia.com/en-us/on-demand/session/gtcsj20-s22699/</a></p><p><a href="https://research.nvidia.com/publication/2021-06_restir-gi-path-resampling-real-time-path-tracing">https://research.nvidia.com/publication/2021-06_restir-gi-path-resampling-real-time-path-tracing</a></p><p><a href="https://developer.download.nvidia.com/video/gputechconf/gtc/2019/presentation/s9985-exploring-ray-traced-future-in-metro-exodus.pdf">https://developer.download.nvidia.com/video/gputechconf/gtc/2019/presentation/s9985-exploring-ray-traced-future-in-metro-exodus.pdf</a></p><p><a href="https://gpuopen.com/download/publications/GPUOpen2022_GI1_0.pdf">https://gpuopen.com/download/publications/GPUOpen2022_GI1_0.pdf</a></p><p><a href="https://research.nvidia.com/labs/rtr/publication/wyman2021rearchitecting/">https://research.nvidia.com/labs/rtr/publication/wyman2021rearchitecting/</a></p><p><a href="https://gpuopen.com/download/publications/Efficient_Spatial_Resampling_Using_the_PDF_Similarity.pdf">https://gpuopen.com/download/publications/Efficient_Spatial_Resampling_Using_the_PDF_Similarity.pdf</a></p><p><a href="https://www.youtube.com/watch?v=Uea9Wq1XdA4">https://www.youtube.com/watch?v=Uea9Wq1XdA4</a></p><p><a href="https://www.youtube.com/watch?v=2GYXuM10riw">https://www.youtube.com/watch?v=2GYXuM10riw</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=b56dc0525a0a" width="1" height="1" alt=""><hr><p><a href="https://blog.traverseresearch.nl/dynamic-diffuse-global-illumination-b56dc0525a0a">Dynamic diffuse global illumination</a> was originally published in <a href="https://blog.traverseresearch.nl">Traverse Research</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Denoising raytraced images using OIDN]]></title>
            <link>https://blog.traverseresearch.nl/denoising-raytraced-images-using-oidn-f6566d605453?source=rss----2cf229d54ed9---4</link>
            <guid isPermaLink="false">https://medium.com/p/f6566d605453</guid>
            <category><![CDATA[denoising]]></category>
            <category><![CDATA[rendering]]></category>
            <category><![CDATA[machine-learning]]></category>
            <category><![CDATA[rust]]></category>
            <dc:creator><![CDATA[Matej Kalocai]]></dc:creator>
            <pubDate>Fri, 29 Dec 2023 22:23:58 GMT</pubDate>
            <atom:updated>2025-03-12T11:50:30.673Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fMGEvQ_LKxCWhnEcH-i0bQ.png" /></figure><p>In this blog post, I’ll write about how I recreated the <a href="https://github.com/OpenImageDenoise/oidn">OIDN</a> U-Net DNN using Rust and HLSL compute shaders, and about my time as an Intern at <a href="https://traverse.nl">Traverse Research</a>.</p><p>For some background, I’m Matt, a 2nd year Games programming student at the <a href="https://www.buas.nl/en">Breda University of Applied Sciences (BUAS)</a>. I’ve been programming for a couple of years, but this was the first time I’ve properly dipped my toes into machine learning, compute shaders, and working with a professional codebase.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/512/1*F-eAEDPzuULTqiIUG52YJQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/512/1*tyj1z9R-465NMeo0QtBpcQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/512/1*bb_Qhiff4LqnIS-rn9b2Tw.png" /><figcaption>From left to right: noisy, denoised, and ground truth. (Using only a color image as input, kindly provided by our lecturer at <a href="https://www.buas.nl/">BUAS</a> <a href="https://jacco.ompf2.com/about-me/">Jacco Bikker</a>)</figcaption></figure><h3>What is OIDN</h3><p>For brevity and clarity, I’ll quote Intel’s description of OIDN, which is “an open-source library of high-performance, high-quality denoising filters for images rendered with ray tracing”. The specific part I’ll be writing about is what the network is, the parts that are required for it to work, and how I went about optimizing my implementation.</p><h3>The Network</h3><p>Simplified down, the OIDN U-Net is just a series of weighted convolutions with extra steps, almost exactly like how you would implement a basic Gaussian blur. It’s called a U-Net because we downsample as we get further along (moving down), and then start upsampling halfway through (moving up), incorporating previous layers to maintain detail, which ends up making a U-shaped network if viewed as a diagram, otherwise it is just a regular neural network.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*GBMCHvdV_1sW0LnAJCD7Xw.png" /><figcaption>Diagram of the OIDN U-Net. Source: <a href="https://maxliani.wordpress.com/2023/04/07/dnnd-3-the-u-net-architecture/">https://maxliani.wordpress.com/2023/04/07/dnnd-3-the-u-net-architecture/</a></figcaption></figure><p>In total, the OIDN U-Net has a whopping 43 operations, but of those, there are only 4 different types, convolution, max pooling, upsampling, and ReLU activation.</p><p>I’ll now go over each of the unique operations, in order of complexity.</p><h4>ReLU</h4><p>ReLU or “Rectified Linear Unit” is way simpler than the name makes it out to be, a ReLU activation simply returns the input value if it’s positive, otherwise returns 0 or the common max(0, input). That’s it.</p><h4><strong>Max Pooling and Upsampling</strong></h4><p>In our case, pooling is synonymous with downsampling to half resolution, so we take a 2x2 area of our image (a window if you will), select the highest value in the window (Max), and output that as our max pooled value. For upsampling we do something similarly simple, we just take each of our input values, and create a 2x2 area of the same value, doubling our resolution.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/750/1*E2cBWvUSTguJPF6VSyPYYg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/750/1*gaD6SJ6kQNVOclE_WkwLNQ.png" /><figcaption>Source: <a href="https://computersciencewiki.org/index.php/File:MaxpoolSample2.png">https://computersciencewiki.org/images/8/8a/MaxpoolSample2.png?20180226194350</a></figcaption></figure><h4>Convolution</h4><p>Convolutions in the context of images work slightly differently than they do in neural networks. Traditionally, you have a window with weights, that you slide over the image and multiply all the pixels in the area the window covers by their appropriate weights, and then sometimes add a value (called bias). With image filtering most of the time you apply the same weights to all channels of the image, and you usually only have 3 channels (RGB).</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/284/1*XWY_4IomfB4QmE4NAuft4w.png" /><figcaption>Traditional Gaussian filter kernel. Source:<a href="https://media.geeksforgeeks.org/wp-content/uploads/20201216123116/3x3GaussianKernel.png">https://media.geeksforgeeks.org/wp-content/uploads/20201216123116/3x3GaussianKernel.png</a></figcaption></figure><p>In our network, there are different sets of weights for each convolution (of which there are 15 total) and each convolution has a different amount of in and out channels (160 in and 112 out at its peak) which also have their weights. So we have up to 160x112x3x3 (161,280) unique weights to use at one time. Luckily for us, this does not complicate the convolution code much, just makes executing it a bit more time-consuming. We iterate over the entire image using a different weight kernel for each channel, but we also have a different set for each output channel, which we then sum up.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*zv31eWU4mgCObjiD5a6GCQ.gif" /><figcaption>Source: <a href="https://medium.com/swlh/a-comprehensive-guide-to-convolution-neural-network-86f931e55679">https://medium.com/swlh/a-comprehensive-guide-to-convolution-neural-network-86f931e55679</a></figcaption></figure><p>So for example at the start is a 4x32x3x3 kernel, where we apply a different 3x3 kernel to each of the 4 input channels from our image, and then sum up the resulting values to form one output channel value, we then do it 32 times total, taking the next set of weights each time and we have our new convolution output. One thing to mention is that the convolution should use zero-padding since we want to maintain resolution throughout the entire U-Net.</p><h4>Weights</h4><p>One thing I haven’t explained yet is where we get our weights from. Well observant reader, you are in luck, because as well as providing the source for their denoising neural network, Intel provides a set of float16 (and float32) weights to be used with it. Unluckily the header part of it is in a proprietary format, but is fairly simple to deconstruct with the <a href="https://github.com/OpenImageDenoise/oidn">source</a> provided.</p><h3>Optimizing</h3><p>With all of the operations made, and weights parsed and loaded, we should now be able to denoise images at will, as long as our will is to wait 200 milliseconds for a 512x512 denoised image. The neural network is made up of 43 individual steps, but with a bit of profiling it seems that Convolutions take up most of the time (a whopping 97.7% of total time), which is of no surprise because we are doing a couple hundred million operations per convolution operation and that does not include the IO of loading the weights or input image.</p><h4>Early exits</h4><p>Since we are doing a convolution in 4 dimensions, we end up doing a lot of looping, where some of those end up being outside the input image and we pad with a zero value. However, since we are summing up the results, we can simply skip the current loop, as adding 0 and simply skipping the add yields the same result.</p><h4>Preloading input and weights into shared memory</h4><p>Another thing to consider is that since we are using convolutions to go over a 3x3 area of the image, some of our input pixels might get read multiple times (up to 9x), which if we are accessing them from global GPU memory is slower than it needs to be. So to fix that we load an 18x18 tile of inputs (16x16 but we also need neighbors for the convolution) into shared memory for one input channel at a time, and convolve over that (which is 7.1x less global reads). For our weights, since they are also shared for the channel, we can do the same so we don’t have to read them per thread.</p><h4>Removing branching</h4><p>Earlier I mentioned that we can simply early exit a loop if we find ourselves outside the image, but branching on the GPU can be expensive at times and in our case replacing the branching, and multiplying our weighed result by the condition of the if statement instead, we save ourselves 16ms.</p><h3>Implementation in the breda-nn framework</h3><p>Luckily for me, I didn’t have to implement any of the neural network foundation code, just the compute shaders, their “operations” and the U-Net itself. I was provided with a framework for creating neural networks, which was very easy to use once you started to understand how they work.</p><p>I could simply create a new Operation by implementing the respective rust trait and then creating functions for the shape of data we’re inputting, outputting, and which shaders to execute.</p><pre>impl Operation for Upsample2D {<br>    fn create_inference_resources(<br>        &amp;self,<br>        input_shape: &amp;Shape,<br>        persistent_store: &amp;mut RenderGraphPersistentStore,<br>    ) -&gt; OperationResources {<br>        OperationResources {<br>            input_shape: *input_shape,<br>            auxiliary: vec![],<br>            output: Tensor::empty(<br>                &quot;upsample2D_forward_output&quot;,<br>                self.output_shape(input_shape),<br>                false,<br>                persistent_store,<br>            ),<br>        }<br>    }<br><br>    fn inference_forward(<br>        &amp;self,<br>        input: &amp;[&amp;Tensor],<br>        resources: &amp;OperationResources,<br>        _parameters: &amp;[Tensor],<br>        shader_db: &amp;dyn ShaderDatabase,<br>        render_graph: &amp;mut RenderGraph,<br>    ) {<br>        let input = input[0];<br>        let forward_output = &amp;resources.output;<br><br>        let dispatch_size = resources.input_shape.w * resources.input_shape.h;<br><br>        let constants = [<br>            forward_output.shape().w,<br>            forward_output.shape().h,<br>            forward_output.shape().c,<br>        ];<br><br>        ComputePass::new(&quot;upsample2d-forward&quot;, render_graph)<br>            .constants_buffer(&amp;constants)<br>            .read(input)<br>            .write(forward_output)<br>            .dispatch(<br>                &amp;shader_db.get_pipeline(&quot;upsample2d-forward&quot;),<br>                dispatch_size.div_ceil(GROUP_SIZE),<br>                1,<br>                1,<br>            );<br>    }<br><br>    fn create_training_resources(...) -&gt; OperationResources {...}<br><br>    fn create_trainable_parameters(...) -&gt; Vec&lt;Tensor&gt; {...}<br><br>    fn training_forward(...) {...}<br><br>    fn training_backward(...) {...}<br>}</pre><p>Once I implemented each operation (pooling/upsampling/convolution) I could then create an operation that encompassed all of the aforementioned ones, executed in the right order, and the input/output shapes being passed through so each operation automatically gets resized to the correct size.</p><p>I think that without this neural network foundation, and some very handy built-in support for comparing our results with <a href="https://pytorch.org/">PyTorch</a> by loading and running <a href="https://onnx.ai/">ONNX</a> models exported from PyTorch directly in breda-nn, it would’ve taken me way longer to get this up and running.</p><pre>info!(&quot;upsample inference:&quot;);<br>run_onnx_inference_test(<br>    &quot;./apps/breda-nn-test/assets/models/upsample_inference.onnx&quot;,<br>    &amp;*shader_db,<br>    device.as_ref(),<br>);<br><br>info!(&quot;max pooling inference:&quot;);<br>run_onnx_inference_test(<br>    &quot;./apps/breda-nn-test/assets/models/maxpool_inference.onnx&quot;,<br>    &amp;*shader_db,<br>    device.as_ref(),<br>);</pre><h3>Test on G-Buffer data</h3><p>OIDN also can enhance image denoising by utilizing auxiliary g-buffer data, including normals and albedo. This approach significantly improves the reconstruction quality compared to only supplying a noisy image.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*yiR7VRGkVeYWa8WYT-54tw.png" /><figcaption>OIDN input, from left to right: rendered image, normals, and albedo</figcaption></figure><p>The result below is from one of the examples shown on the <a href="https://www.openimagedenoise.org/gallery.html">OIDN website</a> (Mazda scene by <a href="https://evermotion.org/">Evermotion</a>) and denoised in our framework.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*NH-JVqR77-8dgE47.png" /><figcaption>Denoised result running in real-time in our framework.</figcaption></figure><h3>Conclusion</h3><p>Overall I’d say that OIDN is an interesting bit of machine learning, and getting the opportunity to implement it has taught me a lot about neural networks and compute on GPU. I am happy with the result I’ve achieved, but in hindsight, I think I could’ve done better.</p><p>The internship at Traverse has been great, even at the interview, the people here were nice and welcoming. The atmosphere in the office is always great and asking for help and/or advice is easy and encouraged. The codebase is well structured and there&#39;s always someone ready to help out if you get stuck or are unsure about something. In conclusion, I’d say if you ever get the opportunity to be at Traverse, I wholeheartedly recommend it.</p><p>I’d also like to extend a special thanks to <a href="https://maxliani.wordpress.com/">Max Liani</a> for the <a href="https://maxliani.wordpress.com/2023/03/17/dnnd-1-a-deep-neural-network-dive/">articles</a> about his journey implementing OIDN in his raytracer. They are well-written and were fundamental in my understanding of machine learning and OIDN.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=f6566d605453" width="1" height="1" alt=""><hr><p><a href="https://blog.traverseresearch.nl/denoising-raytraced-images-using-oidn-f6566d605453">Denoising raytraced images using OIDN</a> was originally published in <a href="https://blog.traverseresearch.nl">Traverse Research</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Neural Radiance Fields]]></title>
            <link>https://blog.traverseresearch.nl/neural-radiance-fields-309a49f65dad?source=rss----2cf229d54ed9---4</link>
            <guid isPermaLink="false">https://medium.com/p/309a49f65dad</guid>
            <category><![CDATA[graphics]]></category>
            <category><![CDATA[machine-learning]]></category>
            <category><![CDATA[nerf]]></category>
            <dc:creator><![CDATA[Jason de Wolff]]></dc:creator>
            <pubDate>Wed, 27 Dec 2023 16:17:54 GMT</pubDate>
            <atom:updated>2023-12-27T16:17:54.141Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fMGEvQ_LKxCWhnEcH-i0bQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*OY_C-nNhZwOewHgBAl6qYQ.png" /><figcaption>Angel Angelov visualized as a NeRF (Breda Framework)</figcaption></figure><p>Hi there, I’m Jason de Wolff. Currently, I am in my 2nd year at Breda University of Applied Sciences, I was offered to do an internship here at <a href="https://traverse.nl">Traverse Research </a>during the summer break. During these 8 weeks, my objective would be to recreate the rendering/inference of a NeRF inside the Breda framework. Obviously, I said yes.</p><p>This blog post goes over the basics of rendering a Neural Radiance Field (NeRF) inside the Breda rendering framework. It will also give an inside into the life of a traverse intern.</p><p>NeRF was first introduced <a href="https://www.matthewtancik.com/nerf">here</a>. Later NVidia published their <a href="https://nvlabs.github.io/instant-ngp/">Instant-NGP</a> paper which significantly sped up the training process, NGP standing for neural graphics primitive. Our implementation is primarily based on the Instant-NGP paper.</p><h4>Multi-resolution hash encoding</h4><p>The main contributor to speeding up the training process is the multi-resolution hash encoding, which allows the neural network to learn details relatively well and quickly. The encoding consists of a sparse voxel representation, where each corner of the voxel grid is stored in a flat one-dimensional buffer. The encoding receives as input a 3D position which is used to sample and interpolate 8 vertices from the sparse 3D grid. This is done for <em>L </em>amount of layers, in our case <em>16</em>. Each layer holds <em>N </em>parameters, in our case between <em>2¹⁴</em> and <em>2²⁴</em>, depending on the size of the scene. Each layer has an increasing voxel size, determined by the <em>per_level_scale </em>parameter, in our case set to <em>1.5</em>. The resolution of layer 0 is defined by <em>base_resolution</em>, set to <em>16</em>.</p><p>The indexing into these parameters is done linearly when the resolution of the current layer doesn’t exceed the number of parameters it holds. Otherwise, a <a href="https://www.researchgate.net/profile/Matthias-Teschner/publication/2909661_Optimized_Spatial_Hashing_for_Collision_Detection_of_Deformable_Objects/links/54a95f140cf2eecc56e6c2c8/Optimized-Spatial-Hashing-for-Collision-Detection-of-Deformable-Objects.pdf?_tp=eyJjb250ZXh0Ijp7ImZpcnN0UGFnZSI6InB1YmxpY2F0aW9uRGV0YWlsIiwicGFnZSI6InB1YmxpY2F0aW9uRGV0YWlsIn19">spatial hash function</a> is used. Hash collisions are not treated as the neural network is responsible for countering these artifacts. Concatenating the outputs of the positional encoding reduces the artifacts further.</p><p>Every corner holds multiple values referred to as features, we use <em>n_features = 2</em>. These features are the actual learnable parameters. The interpolated corner values of every layer and its features are concatenated together and form the input of a dense Multi-Layer Perceptron (MLP).</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*AWUWldpANAUaQl2JruKupA.png" /><figcaption>Multi-resolution hash encoding, from <a href="https://nvlabs.github.io/instant-ngp/assets/mueller2022instant.pdf">instant</a>NGP</figcaption></figure><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/d6e9adaa568a21791dbe1d9569ba3195/href">https://medium.com/media/d6e9adaa568a21791dbe1d9569ba3195/href</a></iframe><h4>Model setup</h4><p>The NeRF from Instant-NGP consists out of 2 MLPs, a density, and a RGB MLP. The density MLP takes the encoded position as input and outputs 16 values, the first of which represents log-space density. The input of our RGB MLP takes the 16 output values from the density MLP and concatenates it with the spherically encoded ray direction (NeRFs are rendered using ray marching). This results in 32 input values for the RGB MLP. In our setup, we use 1 hidden layer of 64 neurons for the density network and 2 hidden layers of 64 neurons for the RGB network. The RGB network outputs an RGB color which uses a sigmoid activation function.</p><h4>Rendering</h4><p>A NeRF can be encapsulated by a bounding box. We march rays through this box with an exponentially increasing step size. At every step, we use an occupancy grid to get a rough idea if we need to take a sample there or not. If the occupancy grid hints that there’s something at this position, we query our entire model with the 3D position as input. The total query looks the following: (positional encoding -&gt; density MLP + dir encoding) -&gt; RGB MLP.</p><p>The occupancy grid helps speed up performance by a lot, as each query is very expensive. All samples along a ray are blended together to form the final output color for the corresponding pixel.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*UjwQQP8BjrKcZYQq-IGzNQ.png" /><figcaption>Expected color for a ray, from <a href="https://arxiv.org/pdf/2003.08934">NeRF</a></figcaption></figure><p>Because the rendering is done with classical rays, effects such as depth of field or motion blur can be applied if desired.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1000/1*k7XxB9om-XMFeY0JVEjETA.png" /><figcaption>From left to right: density network, density + rgb network, occupancy grid (Breda Framework)</figcaption></figure><h4>Breda framework</h4><p>All of this is implemented inside the Breda framework. This framework uses a <a href="https://blog.traverseresearch.nl/bindless-rendering-setup-afeb678d77fc">bindless approach</a> to improve usability. It can be run either on our Vulkan or DX12 backend. To give an example of how easy it is to get a compute shader running, the following code executes a shader that plots all payloads from a buffer onto the render target texture.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/ca9430af56fda6bf5fa9270791b86c1a/href">https://medium.com/media/ca9430af56fda6bf5fa9270791b86c1a/href</a></iframe><p>All compute and render passes are compiled in the <a href="https://blog.traverseresearch.nl/render-graph-101-f42646255636">render graph</a>, which optimally reorders all passes for execution. After the render graph has been compiled it can be executed. And all of this, without the user needing to know any specific knowledge about the rendering API being used.</p><h4>Conclusion</h4><p>There’s a lot left to be done. We still want to implement training from 360 images and speed up rendering performance. By this time recent papers have also shown the potential for relighting NeRFs in real-time. For now, this serves as a solid foundation to work from.</p><p>As an intern, I had a lot of fun working at Traverse. The company culture is very nice and the codebase is a joy to work with. If you get the chance to work with these smart people, you should ;)</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=309a49f65dad" width="1" height="1" alt=""><hr><p><a href="https://blog.traverseresearch.nl/neural-radiance-fields-309a49f65dad">Neural Radiance Fields</a> was originally published in <a href="https://blog.traverseresearch.nl">Traverse Research</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Creating a Directed Acyclic Graph from a Mesh]]></title>
            <link>https://blog.traverseresearch.nl/creating-a-directed-acyclic-graph-from-a-mesh-1329e57286e5?source=rss----2cf229d54ed9---4</link>
            <guid isPermaLink="false">https://medium.com/p/1329e57286e5</guid>
            <category><![CDATA[directed-acyclic-graph]]></category>
            <category><![CDATA[simplification]]></category>
            <category><![CDATA[ray-tracing]]></category>
            <category><![CDATA[rendering]]></category>
            <category><![CDATA[level-of-detail]]></category>
            <dc:creator><![CDATA[Sylvester Hesp]]></dc:creator>
            <pubDate>Tue, 26 Dec 2023 16:09:21 GMT</pubDate>
            <atom:updated>2023-12-26T16:09:21.510Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/700/0*7wgq_3K8hH1OzAW4.png" /></figure><p>In this post, we’d like to walk you through the steps of creating a <em>Directed Acyclic Graph</em>, or DAG, that we use at <a href="https://traverse.nl">Traverse Research</a> for our in-house rendering framework, <em>Breda</em>, to do mirco-polygon rendering. This is achieved by creating meshlets out of a larger mesh and iteratively joining and simplifying them. It can be used to stream, cull and render meshes at different levels of detail. While the general idea is similar to Unreal’s <em>Nanite</em>, their implementation is tailored to rasterization, while our focus will be primarily placed on raytracing. However, you will find that the steps as explained in this post are equally applicable to both.</p><p>This post assumes that the reader is somewhat familiar with mesh rendering on different levels of detail and rendering nomenclature, has a very rough idea about what Nanite tries to achieve, but otherwise requires no in-depth knowledge about Nanite or similar systems.</p><h3>Introduction: Level of Detail, and the DAG</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*5ZrUztHe_Ba4twHLySrdlQ.png" /><figcaption>Stanford Buddha in three different levels of detail</figcaption></figure><p>There are several techniques to render meshes at several <em>levels of detail</em>, or LODs. Discussing them is beyond the scope of this post, but suffice to say is that most of them work on meshes as a whole. This isn’t particularly convenient for very large meshes such as landscapes and structures. You can split them up into smaller <em>meshlets</em>, but you’ll need to take extra care that any adjacent polygons sharing an edge between the meshlets will not cause any visible seams. This typically means that you somehow ensure that the polygons of either side use binary identical vertices, and that the edges between the verts are not more subdivided for one side than the other.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/323/1*LhqpbkXXKaeLbb7lIrwrBw.png" /><figcaption>Adjacent polygons showing a seam as the right triangle is more subdivided than the left</figcaption></figure><p>One way to solve this problem is to never simplify the <em>boundary</em> of a meshlet. You could separate your mesh into a list of meshlets, and then recursively take two adjacent meshlets, combine them, and simplify them. You will end up with a tree structure, where each node in the tree is a simplified version of its two children combined. The collection of all leaf nodes represents the mesh at the highest level of detail, while the root node is the entire mesh at the lowest possible resolution.</p><p>But herein lies a problem. Remember how we never simplified the boundaries of meshlets. Of course, when pairing up adjacent meshlets, those edges that were considered a boundary at a higher resolution are now on the interior of the combined pair, so the edges do eventually end up simplified. However, if we consider the root node of the tree and its two children, the edges adjoining the two children have never been simplified. They are still at the resolution of the original mesh.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/868/1*-X3DEKsgiJsRsXoXMJ-kEg.png" /><figcaption>Heavily LODed terrain that shows a high-resolution split between two nodes</figcaption></figure><p>This is an inherent problem of tree structures. Because of their nature, you will always be able to draw a line separating the left side of the tree from the right.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/725/1*T5K4WL8y-_c1Ladzg4RX_w.png" /><figcaption>The red dashed line separates the left subtree from the right</figcaption></figure><p>What we need is some data structure where we can have connections between the nodes that straddle this line of separation. This is where the <em>Directed Acyclic Graph</em>, or DAG, comes in. It is a type of graph where the edges always point in a certain direction (hence “directed”), and when walking the edges from an arbitrary starting position you will never end up at the node where you started (hence “acyclic”). This would then look something like this:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/779/1*7qinvVNpQwVHtgpkgvGLuA.png" /><figcaption>A directed acyclic graph</figcaption></figure><p>Strictly speaking, a DAG is a generalization of a tree — all trees are DAGs, but not all DAGs are trees. But a DAG allows for more connections between the nodes than is possible in a tree. Where a non-root node in the tree points to a single parent, a non-root node in the DAG can point to multiple “parent” nodes. This means diamond shapes are now possible. For example, in the picture above, notice how node 0 connects to nodes 5 and 7, which in turn both connect to node 18. Any border that 5 and 7 share, has been simplified before as there is no way to draw a line separating their child nodes.</p><p>But how to we achieve multiple parents? What does this mean in the context for our mesh? Well, remember how a connection of multiple child nodes to a parent means those nodes got merged into a parent. Having multiple parents for the same set of child nodes would therefore imply that they got merged into both parents, but the parents are somehow distinct. We achieve this by not only merging the nodes, but also splitting the result.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*_EFxgrYA1C4Y66sx6oEOeg.png" /></figure><p>If we target a specific triangle count for each meshlet after the split, we need to merge more than two meshlets, the exact number depending on the simplification factor. For example, with a simplification factor of 0.5 (meaning a meshlet after simplification will have half the number of triangles), we need to merge 4 adjacent meshlets. To put it in other words, in order to target a fixed number of triangles per meshlet at each LOD, the simplification factor <em>s</em> is a function of the number of input <em>i</em> and output <em>o</em> meshlets: <em>s = o / i</em></p><p>We feel that 4 inputs and 2 outputs, with a simplification factor of 0.5, is a decent balance. Your mileage may vary.</p><p>We should also consider that some nodes might never merge. This might be because of their spatial separation or other metrics you might use to consider nodes for merging. The DAG should be able to accommodate for this situation, and that means it should support multiple root nodes. Similarly, the number of meshlets will unlikely be always divisible by the amount of meshlets you want to merge, so the number of children for a non-leaf node in the DAG should not be a constant.</p><h3>Creating the DAG</h3><p>Now that we have a general idea of what our DAG is going to look like, we need to define some parameters. We’ve already discussed that we like to merge 4 meshlets and then split them into 2. Furthermore, we need to decide on a target triangle count per meshlet. For our particular use case at Traverse Research, we have chosen 1024. This value is not set in stone and still very subject to change. Since our framework heavily relies on raytracing, we need to generate <em>bottom level acceleration structures</em> (BLASes) to be able to ray-trace the mesh, so we need to find a balance between triangle count, memory overhead and runtime performance. A low triangle count results in a finer LOD granularity, but more BLAS memory overhead. It also results in a larger DAG, which might affect performance of the determination of the active cut. However, for other purposes, fewer or more triangles might be a better fit. Again, YMMV.</p><p>The algorithm to create a DAG out of a high-resolution mesh consists of the following steps:</p><pre>// first, transform the mesh in a list of meshlets<br>let meshlets = generate_meshlets(mesh)<br><br>// and put them as leaf nodes in the dag<br>dag.add_leafs(meshlets)<br><br>// track all the borders between the meshlets<br>find_borders(meshlets)<br><br>// iteratively merge-simplify-split<br>while we can group meshlets:<br>  for group in partition(meshlets):<br>    // merge the meshlets in the group<br>    let meshlet = merge(group)<br><br>    // remove parts of border that is now on the inside of the merged meshlet<br>    update_border(meshlet)<br><br>    // simplify the merged meshlet<br>    simplify(meshlet)<br><br>    // split the simplified meshlet<br>    let parts = split(meshlet)<br><br>    // split the borders<br>    split_borders(parts)<br><br>    // write the result to the dag<br>    dag.add_parents(group, parts)</pre><h4>Third party tools</h4><p>For our implementation, we have opted to use two open-source libraries.</p><p>The first is <a href="https://github.com/zeux/meshoptimizer">Meshoptimizer</a>, which is a C library (<a href="https://github.com/gwihlidal/meshopt-rs">Rust bindings available</a>) to simplify the mesh, generate meshlets, and other useful mesh processing tools. Beware that its meshlet generation is specifically tailored to store the meshlets using very compact data format, and it therefore only supports at most 126 tris and 64 verts, which unfortunately is too low for our own goals.</p><p>For our simplification needs, we need to be able to specify which edges to lock to prevent the meshlet border from being simplified. <a href="https://github.com/zeux/meshoptimizer/pull/601">A PR has been submitted</a> to add this to the API, but unfortunately has not yet been merged at time of writing (Rust bindings exposing this feature available <a href="https://github.com/Traverse-Research/meshopt-rs/tree/vertex-lock">here</a>)</p><p>We also use <a href="https://github.com/KarypisLab/METIS">METIS</a>, a C library (<a href="https://github.com/LIHPC-Computational-Geometry/metis-rs">Rust bindings available</a>) to partition a graph. We use this library to partition the list of meshlets into groups of N meshlets. In our experience, using METIS hasn’t always seemed very intuitive. In the last chapter of this post, we will go more in-depth on the relevant API functions and how to use them.</p><h3>Generating the initial list of meshlets</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*21Bszvj5ZfX2FuLL3SbBsA.png" /><figcaption>Generated meshlets for the Stanford Buddha</figcaption></figure><p>Given that the meshlet generator of the Meshoptimizer is not suitable for our triangle count, we use METIS here as well to partition the initial mesh into groups of 1024 triangles. One downside is that METIS only works on the <em>topology</em> of the graph; it has no knowledge about special coordinates of the vertices. This is usually fine for one large continuous mesh of roughly equally sized triangles, but if your input has an irregular shape, you might want to use a different algorithm. This is especially true if you want to group triangles in meshlets based on other properties, such as face normals.</p><p>Special care should be taken with parts of the mesh that are disjoint, especially when using METIS. You can merge disjoint parts into a single meshlet, but as METIS has no knowledge about spatial separation, our suggestion is to keep those separate and only use METIS to work on continuous meshes.</p><p>As we want to partition the graph of meshlets using METIS, we need to keep track of meshlet connectivity. We also need to track the borders shared between the meshlets, as this is both important for generating an optimal partition and for locking the border during simplification.</p><h3>Tracking meshlet borders</h3><p>Tracking borders might not be a straightforward problem, as we need to distinguish between the original mesh border that is allowed to be simplified, and the borders that we create by partitioning the mesh into meshlets.</p><p>For our algorithm, we define an <em>edge</em> as the connection between two vertices. As we’re working with vertex indices, it’s convenient to encode an edge simply as a tuple of two vertex indices: <em>(i0, i1)</em>, where <em>i0 &lt; i1</em>. The latter condition ensures that we can always uniquely identify the same edge, regardless of the order of vertex indices in the triangle. This way we can use it as a key in some kind of associated data structure such as a hash map.</p><p>Furthermore, we define a <em>border</em> as a list of <em>edges</em> that is shared between a pair of <em>meshlets</em>. A meshlet can have multiple borders (as it connects to multiple adjacent meshlets), but two meshlets only share a single border.</p><h4>Initial border computation</h4><p>To generate the initial borders for all meshlets:</p><pre>// create a map of edges that maps an edge to a list of meshlets<br>define edge_map: map of edge -&gt; list of meshlet<br>for meshlet in meshlets:<br>  for edge in edges(meshlet):<br>    edge_map[edge].add(meshlet)<br><br>// create a map of borders that maps a meshlet pair to a list of edges<br>define border_map: map of (meshlet, meshlet) -&gt; list of edge<br>for (edge, meshlets) in edge_map:<br>  for meshlet_pair in all pairs in meshlets:<br>    border_map[meshlet_pair].add(edge)<br><br>// now that border_map contains a list of edges per pair of meshlets,<br>// we can add it to the meshlets<br>for (meshlet_pair, edges) in border_map:<br>  let b = new Border(edges, meshlet_pair)<br>  meshlet_pair[0].add_border(b)<br>  meshlet_pair[1].add_border(b)</pre><p>With this code, we have generated an initial list of borders for each meshlet. As you might have spotted, both meshlets refer to the same border object. The reason for this becomes apparent as we split the meshlets after simplification. We also use these border objects as connectivity information; the meshlet pair of a border are connected to each other in the graph. So, in a sense, these borders themselves form the edges of the meshlet connectivity graph.</p><h4>Updating the borders during merge-simplify-split</h4><p>In the next phase of our algorithm, we iteratively find meshlet groups by partitioning the meshlet graph, and then for each group we merge the meshlets into one bigger meshlet, the <em>merged meshlet</em>, simplify the resulting meshlet into the <em>simplified meshlet</em>, and then split that meshlet into two parts, the <em>split meshlets</em>.</p><p>During merging, the inner border disappears. For the merged meshlet, we can collect all borders from the individual meshlets in the group, and remove those borders where both of the meshlet pairs are within the group.</p><pre>define merged_borders: list of Border<br>for meshlet in group:<br>  for border in meshlet.borders:<br>    let (a, b) = border.meshlet_pair<br>    if not group.contains(a) or not group.contains(b):<br>      merged_borders.add(border)</pre><p>After simplification, the outer border of the simplified meshlet remains untouched, as we locked it for simplification. However, as we split the simplified meshlet, we are very likely split an existing border! We need functionality to split a border, while keeping intact the connectivity information of the new split meshlets with its original adjacent meshlets. Also, we need to generate a whole new border for the split between the split meshlets.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*8AW4RJ2_A_TylhrCJBlehQ.png" /><figcaption>Another diagram showing the merge-simply-split process, this time highlighting some of the border cases. In the merged meshlet in the upper right, we see an inner border that can be removed highlighted in teal. The final split meshlets in the lower left show both a new border in orange and an original border (that was originally part of the green mesh) that is being split in green.</figcaption></figure><p>The general idea to solving this problem is to generate two sets of edges, <em>A</em> and <em>B</em>, one for each split meshlet. We can then walk over the borders of the merged meshlet, calculating the intersection of the border edges with either edge set. If either 0 or all of the border edges are in set A, the border remains unsplit. If some are in set A and some in set B, we need to split the border and update the references. The new border between the split meshlets is simply the intersection of A and B.</p><pre>// first, let’s define some supporting functions<br><br>// this function checks whether one of the pair of meshlets is<br>// contained in the group, and if so, it updates its reference to<br>// the new meshlet<br>fn update_meshlet(border, group, new_meshlet):<br>  if group.contains(border.meshlet_pair[0]):<br>    border.meshlet_pair[0] = new_meshlet<br>  else:<br>    border.meshlet_pair[1] = new_meshlet<br><br><br>// this function splits the border into two, based on a set of edges belonging to<br>// split_meshlets[0].<br>// it also requires a group of meshlets that contains one of the elements<br>// of the original border’s meshlet pairs, and a new pair of split meshlets<br>fn split_border(border, edges, group, split_meshlets):<br>  // remove all edges not contained in the ‘edges’ set, and move them to a separate list<br>  let other_edges = border.edges.retain(edges)<br>  let other_border = new Border(other_edges, border.meshlet_pair)<br>  update_meshlet(border, group, split_meshlets[0])<br>  split_meshlets[0].add_border(border)<br>  update_meshlet(other_border, group, split_meshlets[1])<br>  split_meshlets[1].add_border(other_border)<br><br><br>// update all borders<br>let edges_a = set of edges in split_meshlets[0]<br>let edges_b = set of edges in split_meshlets[1]<br><br>let b = new Border(intersection(edges_a, edges_b), split_meshlets)<br>split_meshlet[0].add_border(b);<br>split_meshlet[1].add_border(b);<br><br>for border in merged_borders:<br>  let i = intersection(edges_a, border.edges)<br>  if count(i) = count(border.edges):<br>    // border is completely in A<br>    // this function checks which of the meshlet pair is in the group, and then<br>    // updates that reference accordingly<br>    update_meshlet(border, group, split_meshlets[0])<br>    split_meshes[0].add_border(border);<br>  else if count(i) = 0:<br>    // border is completely in B<br>    update_meshlet(border, group, split_meshlets[1])<br>    split_meshes[1].add_border(border);<br>  else:<br>    // this function splits the border based on an edge set, and<br>    // updates internal references. After this call, the adjacent meshlet not<br>    // part of the group will contain both pieces of split border<br>    split_border(border, a, group, split_meshlets)</pre><p>After all groups of meshlets have been merged, simplified and split, the borders are all updated and will point to the new split meshlets.</p><p>And there we have it. After we have determined there are no more meshlets to group, we have filled the entire DAG, and the root nodes are now heavily simplifed representations of the mesh.</p><h3>Determining the cut of the DAG</h3><p>Now that we generated the DAG, we need to select which nodes we’re interested in rendering. These node are what’s called the <em>cut</em> of the DAG. We need some kind of metric to determine which DAG nodes are below the quality threshold (0) and which are above (1). But remember, this is not a tree. Because of the nature of the DAG, there are many paths from a root to a certain node, so we cannot simply recursively walk the DAG and evaluate nodes as we encounter them. For an efficient (and parallelizable) implementation, we should be able to walk over all connections between parent and child in arbitrary order, and evaluate whether that child should render, without considering any other information.</p><p>To generate a correct cut, our evaluation function should adhere to some conditions:</p><ul><li>Given an arbitrary path from a root to a leaf node, the evaluation function should only flip from 0 to 1 once. All ancestors above the flip should report 0, all descendants below the flip should report 1. In other words, the evaluation of nodes should always be monotonically increasing.</li><li>All parents of a single node should always evaluate the same. Remember that these parents represent the split parts of a merged &amp; simplified piece of mesh. If the evaluation function determines that parent A should render but parent B should not, and all the children of parent B render instead, then there will be visible overlap between parent A itself and parent B’s children.</li></ul><p>Given these specifications, the cut is then simply defined as each child node that evaluates to 1 where its parents evaluates to 0.</p><p>Finding a good and correct metric for the evaluation function is hard. For rasterization, you could determine the projected on-screen error given an error metric calculated during simplification. With raytracing, this is a harder problem because you can’t easily evaluate an object’s projected screen size. So for us this is still an open question.</p><h3>Conclusion</h3><p>This concludes this blog post. We went over the DAG creation algorithm, together with some more in-depth techniques to track mesh borders, and how you would go about to selecting a cut.</p><p>Here’s a little demo of what we did at Traverse:</p><iframe src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fwww.youtube.com%2Fembed%2FVOb7nzeKDp0%3Ffeature%3Doembed&amp;display_name=YouTube&amp;url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DVOb7nzeKDp0&amp;image=https%3A%2F%2Fi.ytimg.com%2Fvi%2FVOb7nzeKDp0%2Fhqdefault.jpg&amp;key=a19fcc184b9711e1b4764040d3dc5c07&amp;type=text%2Fhtml&amp;schema=youtube" width="640" height="480" frameborder="0" scrolling="no"><a href="https://medium.com/media/0f54dd08f70810bb4732463820b17ea8/href">https://medium.com/media/0f54dd08f70810bb4732463820b17ea8/href</a></iframe><p>This post has been written by Sylvester Hesp at Traverse Research. If you have any questions or remarks, feel free to reach out through <a href="https://traverse.nl">our website</a>.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=1329e57286e5" width="1" height="1" alt=""><hr><p><a href="https://blog.traverseresearch.nl/creating-a-directed-acyclic-graph-from-a-mesh-1329e57286e5">Creating a Directed Acyclic Graph from a Mesh</a> was originally published in <a href="https://blog.traverseresearch.nl">Traverse Research</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Beyond SAH — Building Optimal BVHs]]></title>
            <link>https://blog.traverseresearch.nl/beyond-sah-building-optimal-bvhs-fdef1878d6ed?source=rss----2cf229d54ed9---4</link>
            <guid isPermaLink="false">https://medium.com/p/fdef1878d6ed</guid>
            <category><![CDATA[ray-tracing]]></category>
            <category><![CDATA[3d]]></category>
            <category><![CDATA[bvh]]></category>
            <category><![CDATA[computer-graphics]]></category>
            <category><![CDATA[rendering]]></category>
            <dc:creator><![CDATA[Athos Van Kralingen]]></dc:creator>
            <pubDate>Mon, 25 Dec 2023 12:35:10 GMT</pubDate>
            <atom:updated>2023-12-28T14:10:56.197Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*-PBno5gObCMigc5D.png" /></figure><h3>Beyond SAH — Building Optimal BVHs</h3><p>At <a href="https://traverse.nl">Traverse Research</a> we are always looking to improve ray-tracing performance. A large factor for optimizing this is building the right and best acceleration structure, usually a BVH nowadays. In the past year, we looked into which way we can built the best-performing BVH in the form of a master’s thesis. In this blogpost we will summarize some of our findings, but first, let’s briefly recap the most common method. The entirety of the research can be found <a href="https://github.com/Traverse-Research/Assessing-alternatives-to-SAH"><strong><em>here</em></strong></a>, which includes the collected raw-data for what we discuss here.</p><p>In order to follow along, we expect you to be familiar with the concept of BVHs and along with that know what a <em>top-level acceleration structure </em>(TLAS) and <em>bottom-level acceleration structure</em> (BLAS) is. Having some understanding of how BVHs and similar acceleration structures are constructed helps, but is not strictly required.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*FMppVcoCucDAL1USmtH5CA.png" /><figcaption>A simple BVH in 2D</figcaption></figure><h3>BVHs and SAH — A recap</h3><p>A common method for constructing a high-quality BVH is using the Surface Area Heuristic. One of the earliest source for using this is from MacDonald &amp; Booth in “Heuristics for Ray Tracing Using Space Subdivision”. The idea is based on two main principles:</p><ol><li>We want to minimize the probability of intersection for BHV nodes that are expensive to test (i.e. contain many primitives)</li><li>The probability of intersection of a node is proportional to its surface area, under certain conditions.</li></ol><p>From this, a cost function can be derived that helps find a BVH with minimum cost. As such, we have a <em>Surface Area Heuristic </em>(SAH) to minimize, which looks something like this:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/387/1*LUEolF-crab9bf-W8Ku1lQ.png" /><figcaption>The cost of an interior node in a BVH. <strong>Ct </strong>is the cost of traversing an interior node, i.e. of two ray-aabb intersections, <strong>Ci </strong>the cost of performing a ray-primitive intersection test, <strong>SAl/r</strong> the respective surface areas of the left and right child nodes, <strong>SAp</strong> the surface area of the parent node and <strong>Tl/r</strong> the respective number of primitves in the left and right child nodes.</figcaption></figure><p>Most sane people do this in a greedy manner¹ where already established costs will not be re-evaluated as we go down the hierarchy. This would require evaluating all potential combinations of BVH nodes, which for any useful mesh is infeasible. The minimum cost BVH is in practice rather a local than global minimum in practice.</p><p>If you want to read more on how to bring this into practice, a detailed approach to implementation can be followed from the <a href="https://jacco.ompf2.com/2022/04/18/how-to-build-a-bvh-part-2-faster-rays/">excellent blog series by Jacco Bikker</a>.</p><p><em>[1] Our linked research also looks into the not-so-sane side: computing the global minimum cost and the implications compared to greedy computations.</em></p><h3>Limitations of SAH</h3><p>Using SAH to optimize the construction of BVHs is widely used as it can boost trace performance significantly for the cost it adds during in construction. However, the relation between the surface area and probability of intersection exploited in SAH assumes the following:</p><ol><li>Ray origins and directions are uniformly distributed.</li><li>Ray origins lie outside the scene’s bounding box.</li><li>Rays do not terminate inside the scene.</li></ol><p>The first assumption is a crude simplification of how we perform ray tracing. Our camera or <em>primary rays</em> follow a perspective distribution with a single origin, <em>diffuse materials </em>generally produce cosine-distributed rays and effects like <em>ambient-occlusion </em>also limits the distributions and directions. Only <em>shadow rays</em> will arguably not follow such a standard pattern and can be considered to be uniformly random to some extent. This generally better approximations than SAH may exist based on the distribution one particular type of ray follows.</p><p>The second assumption is generally true in practice for a BLAS, but with the exception of primary rays, ray origins will generally lie inside of a TLAS rather than outside of it, so again, using SAH won’t necessarily give us the optimal BVH here.</p><p>Then finally, rays not terminating in the scene is rarely the case in practice, as it is would imply all our rays are multi-hit rather than first-or-any hit rays. This affects the intersection probability for obstructed geometry, as rays starting outside the scene are much less likely to hit them or cannot hit those at all.</p><h3>Alternatives</h3><p>Clearly, there is a potential for building even better BVHs than with SAH for the cost function. Let’s briefly discuss some alternatives that were researched over the years:</p><h4><a href="http://dx.doi.org/10.2312/egs.20091046"><em>Scene-interior Ray Origin Metric </em>(Fabianowski et al., 2009)</a></h4><p>Remember how SAH assumes that the ray origins lie <em>outside</em> the scene’s bounding box? Fabianowski et al. changed heuristic with the assumption that ray origins always lie <em>inside </em>the scene bounding box. This heuristic’s definition is however more complex and the resulting integral needs to be approximated for our discrete scenes. The two approximations they suggest both split the scene around the BVH node and approximate the solid angle (i.e. the visible surface area) from a set of vantage points in the divided space, as illustrated below.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/322/1*Nu1F3LgJMYZk-ZXOEr0ulQ.png" /><figcaption>Calculate the (approximate) probability of intersection between a BVH node and a ray that originates from within the scene. <strong><em>V</em> </strong>is the volume, <strong>N</strong> the BVH node’s AABB, <strong>S </strong>the scene AABB, and <strong>R</strong>1–26 are the 26 boxes/regions induced by the extended faces of <strong>N</strong>, and <strong>a</strong> the visible surface area of <strong>N</strong> from the center of <strong>Ri</strong>, calculated from the solid angle.</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/674/1*GHXxGtxSmmEKUbuQG22jBw.png" /><figcaption>Visualizing some of the vantage points from which we calculate the visible surface area, i.e. the solid angle, of aabb onto the point x1–26.</figcaption></figure><p>This is a relatively simple solution that can theoretically eliminate one of the incorrect assumptions made by SAH for the majority of rays cast. The used form of measurement in the results is not ideal as it is in FPS, but the estimation is that ray-tracing performance can be increased by up to 7%.</p><h4><a href="https://www.researchgate.net/publication/2409489_Rectilinear_BSP_Trees_For_Preferred_Ray_Sets">Preferred Ray Distributions (Havran &amp; Bittner, 1999)</a></h4><p>Instead of assuming that all our rays are uniformly distributed in origin and direction, the different types of rays can be broken down and a BVH can be fitted to its (estimated) distribution. Havran &amp; Bittner did exactly this for various ray distributions, such as for primary rays or with spherical distribution².</p><p><em>[2] This spherical distribution can be seen as the basis for the above mentioned scene-interior ray origin metric; the latter will generally be superior for any practical use-cases.</em></p><p>Let’s take some observations in mind when we think of what casting rays looks like if we have a perspective-projection camera that shoots rays into the scene:</p><ol><li>Objects closer to the camera have a higher probability of being intersected than those far away, as the camera rays diverge.</li><li>Any geometry outside the frustum around the camera rays will never be intersected and therefore has an intersection probability of zero.</li></ol><p>These observations are used to adjust SAH by assuming the probability of intersection is proportional to the surface area of the geometry or BVH node once <em>projected on the camera plane</em>. To get this, simply project the vertices of the AABB of the BVH node onto the camera plane using the perspective projection matrix. Clip off any of the surface area that lies outside of the frustrum (or camera plane when projected), and you have the probability of intersection for that BVH node.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*lO-PGch2gjYK616O6nk1XA.png" /><figcaption>The perspective projection of the BVH node onto the camera plane. Image from Havran &amp; Bittner, 2009.</figcaption></figure><p>Using this for as cost function reduces the authors’ intersection tests and traversal steps by 29% and 16% respectively, though their scenes are rather dated and do not really match modern day working sets.</p><h4><a href="http://dx.doi.org/10.1145/1980462.1980475">Ray Distribution Heuristic (Havran &amp; Bittner, 2009)</a></h4><p>Monte-Carlo is the bread and butter for the average graphics programmer that does anything ray-or-path-tracing related, so not why use it for building the BVH? After all, we are looking to minimize the probability of intersection. Assuming we know which rays will be cast and given the geometry, the probability of intersection can be calculated for a candidate BVH node by using the rays that are to be cast to determine how often it will be intersected. This forms the <em>Ray Distribution Heuristic (RDH)</em> from Bittner &amp; Havran. The probability of intersection is then the ratio of rays passing through the respective child node relative to the parent node, similar to the surface area ratio for SAH.</p><p>The rays used for construction don’t need to be the exact rays we use later on during ray-tracing, as that would also lead to enormous construction time for the BVH. Subsampling patterns (e.g. subsampling the primary rays by emulating a lower resolution) or subsampling the rays over time can both still yield speed-ups, assuming the rays that will be cast do not drastically change.</p><p>Applying this heuristic to kd-tree construction yields estimated speed-ups of up to 15% for tracing primary rays, but adding diffuse, shadow and other rays showed no gains. It’s not unlikely this comes from an “overfitting” problem, as the distribution becomes more uniform as more distributions are combined and SAH uses amore accurate estimate of the combined ray distribution.</p><h4><a href="https://doi.org/10.1016/j.cag.2012.02.013">Occlusion Surface Area Heuristic (Vinkler et al., 2012</a>)</h4><p>Aside from use-cases like shadow rays and translucency, rays are generally not traced further than their first intersection, which diminishes, as illustrated, the probability of intersection for geometry behind the first hit. Vinkler et al. therefore modified the SAH-based BVH build to include a factor of occlusion: <em>Occlusion Surface Area Heuristic (OSAH)</em>. Leaving numerous additionally formed conditions out, the core cost function basically boils down to estimating the intersection probability through a weighted blend between the ratio of occluded triangles for the node and its SAH cost.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/514/1*SJZiA1SVyDa0CG86VZ2dOg.png" /><figcaption>The probability of intersection for a node in with OSAH. Based on some weight (w), we combine SAH (the right part) with the ratio of number of visibile triangles within a node (Nv)</figcaption></figure><p>Their reported performance increases require covering SBVHs and such, but the tracing performance increases by around 28% for their tested scenes.</p><p>A consequence of OSAH, similar to RDH, is that it requires tracing rays during the construction of the BVH, as rays are traced to estimate how occluded geometry is. This means we want to have a BVH to construct one; our personal approach here is to just build a BVH once using SAH which is then used for occlusion queries to build OSAH-based BVHs.</p><h3>The Best Heuristic</h3><p>So we know some alternatives to SAH for BVH construction, but what’s actually the best? One problem is that while SAH is almost always included in performance comparisons, there is none between the listed improvements. Each research tests different scenes, viewpoints and metrics, so it’s hard to compare them directly from the published works.</p><p>This led us to our research, which compares the above discussed heuristics in a single framework in 8 different scenes. Since performance characteristics can differ between different ray types, this is measured separately per ray type for primary rays, diffuse bounces, shadow rays and ambient occlusion. Note that while it would be interesting to distinguish between TLAS and BLAS performance, this is not something we yet researched.</p><p>Let’s move on to some results from testing this with a CPU path-tracer for a simple stanford dragon scene in which we move the camera around:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1012/1*IRkin-WEWzSg5NHLvq4fMw.png" /><figcaption>The Stanford Dragon rendered in Breda, 871,306 tris. This rendering is not the output of CPU-based path-tracer.</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*W9hOEOWKS4TOshXaObrB1A.png" /><figcaption>Ray-tracing performance per type of ray. The Interior-Scene Ray Origin Metric uses both the paper’s described approximations, a high quality and fast one. Preferred Ray Sets Specifically implements the correction for perspective projection of the camera. Combined is the average per ray for all rays cast in a frame; most frames contain less diffuse bounces than primary rays, for example.</figcaption></figure><p>Evidently, the other heuristics are not much better. Only OSAH seems to steadily outperform SAH for all ray distributions here here, despite the occlusion being relatively low for this scene. RDH was meant to be the ideal case: we measure exactly what the probability of intersection is for a given set of rays and then we trace those rays, again, with the RDH-built BVH. The problem is that we have a scene with only little interesting going on. Only part of the scene is covered and it seems that RDH simply has some bad frames as the performance does seem pretty similar for the majority of it.</p><p>We can make some sense of these performance observations: the interior-scene ray origin, for example, is one we do not expect to excel in scenes where rays originate from outside the scene bounding box, which is the case here for primary rays. Still, evenfor the other ray types it’s slower than SAH.</p><p>The results change if we move to an indoor scene, such as the Cathedral Sibenik:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ASKjwEL9hyTMjVpOlqSqPw.png" /><figcaption>The Sibenik Cathedral rendered in Breda, 75,283 tris. This rendering is not the output of the CPU-based path-tracer.</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6w7SSAb2u4T-4_zDEFZP8w.png" /><figcaption>‘sRay-tracing performance per type of ray. The Interior-Scene Ray Origin Metric uses both the paper’s described approximations, a high quality and fast one. Preferred Ray Sets Specifically implements the correction for perspective projection of the camera. Combined is the average per ray for all rays cast in a frame; most frames contain less diffuse bounces than primary rays, for example.</figcaption></figure><p>This scene has significantly more occlusion and is rendered in our fly-through (primarily) from the inside and as a result we get slightly better performance with the Interior-Scene Ray Origin Metric. Especially RDH works best here and SAH becomes a bit more middle of the pack.</p><p>The perspective adjustment with Peferred Ray Sets does not even seem viable for primary rays in contrast, which is interesting given the seeming soundness of its definition. This is discussed in more detail in the linked thesis, but the problem lies the impact of breaking the assumption of SAH that rays start outside the scene has a far greater impact than for SAH itself.</p><p>Note that the other tested scenes are discussed in detail in the linked thesis.</p><h3>Conclusion</h3><p>The take-away here is that the best heuristic is quite scene-dependent. We found that higher occlusion factors generally lead to better performance with OSAH, that SAH is generally a good default but rarely yields the best ray-tracing performance, and that there can be correlation factors that are not yet well defined that correlate the performance to a particular heuristic.</p><p>With OSAH often being a superior alternative in the tested scenes to SAH, why not use this by default? One not-yet-discussed aspect is the performance of constructing the BVH. RDH, OSAH and the Preferred Ray Sets method all depend on the camera viewpoint or even the exact set of rays that will be cast. This means that changing the viewpoint or set of rays requires a rebuild of the BVH. For a TLAS this can be acceptable, but this is undesirable for BLASes.</p><p>Even then a single build has the performance problem in that the operations are far more expensive than simply calculating the surface area, too expensive to build every frame. There is some potential of reducing the input set for RDH and OSAH, but while keeping in mind a minimum in optimization efforts, building with SAH is still much faster.</p><p>Is there no future in these alternative heuristics at all? We actually do think there is one. The Interior-Scene Ray Origin Metric, for example, can give a speed-up up to nine percent over SAH in some scenes and is rarely slower in ray-tracing, especially for rays other than primary rays, which we do not cast in Breda. Evaluating the heuristic is more expensive, but we think much can be optimized and the fast approximation of the heuristic is of comparable quality already. We highly recommend reading the paper by Fabianowski et al. as it is a relatively simple heuristic to understand and integrate in your own BVH builder.</p><p>Another possible way to benefit from these heuristics might even be to use separate BVHs for different ray types, as primary rays are usually faster for SAH than the Interior-Scene Ray Origin Metric. Other combinations may very well be worth it to deliver the, hypothetically, fastest tracer. Ideally we would be able to configure what heuristic or configure for which use-case a BVH is built when specifying this to the respective APIs, so hopefully some day this will be available.</p><p>There are many more directions to go with this. Which ray distributions do we use for construction with RDH and does it make a difference? Can we re-use the BVH from previous frames for cost evaluations that are view-or-ray dependent? Can we combine the heuristics in different ways such that they, as combination, counter some of the assumptions made in SAH? What about heuristics specific to a certain type of ray distribution, such as shadow rays? Several of these are also explored in the thesis, so if this caught your interest, we definitely suggest you give it a read! Ultimately we hope to dive into the others as well and explore new methods for boosting ray-tracing performance!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=fdef1878d6ed" width="1" height="1" alt=""><hr><p><a href="https://blog.traverseresearch.nl/beyond-sah-building-optimal-bvhs-fdef1878d6ed">Beyond SAH — Building Optimal BVHs</a> was originally published in <a href="https://blog.traverseresearch.nl">Traverse Research</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Ray tracing animated crowds]]></title>
            <link>https://blog.traverseresearch.nl/ray-tracing-animated-crowds-bc0e775c74ad?source=rss----2cf229d54ed9---4</link>
            <guid isPermaLink="false">https://medium.com/p/bc0e775c74ad</guid>
            <category><![CDATA[ray-tracing]]></category>
            <category><![CDATA[crowd-simulation]]></category>
            <category><![CDATA[rendering]]></category>
            <category><![CDATA[animation]]></category>
            <category><![CDATA[gpu]]></category>
            <dc:creator><![CDATA[Jan Kind]]></dc:creator>
            <pubDate>Sun, 24 Dec 2023 12:48:10 GMT</pubDate>
            <atom:updated>2023-12-24T12:48:09.939Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*pMnrrvRJD3tHEDrRegOFLQ.png" /><figcaption>Traverse Research</figcaption></figure><p>Animating large crowds in a ray-traced environment on the GPU</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*cUkVKCY3ioRpfaBbCyThhg.png" /><figcaption>A crowd in the Breda framework from our GDC demo.</figcaption></figure><h3>What it takes to render crowds</h3><p>Crowd simulations have been common in games for a while now, but they usually only had to work for rasterization. Now that ray tracing is becoming more popular, new challenges have appeared. This blog post covers how we implemented animations and crowds in our testing framework “Breda” here at <a href="https://traverse.nl">Traverse Research</a>.</p><p>There are various challenges that need to be tackled while implementing such a system. First of all, the large number of vertex animations means we need an optimal GPU implementation to update all the meshes into their animated poses. Since these crowds will be used in both our path tracer and hybrid renderer, we also must take acceleration structure building into account for the animated meshes. These require a persistent set of vertices in their animated pose, which means that we will need a duplicate vertex buffer for each animated instance!</p><p>Other than that we should keep in mind that we’ll need motion vectors for the animated geometry, and ideally have the ability to reuse as much of the animation state as possible to make the crowds perform well.</p><h3>Our animation system</h3><p>Our animation system consists of a few basic types and concepts. We import most of our animation data from glTF, and store it in our own asset format. This format still closely resembles glTF.</p><p><strong>Animations</strong></p><p>An animation in our engine is defined as a set of channels, where each channel has an output type (translation, rotation or scale). A channel defines the values for that output type for each key time-point in the animation. Every channel has an output index.</p><p>We apply animations to animation samples to sample one specific time point for that animation.</p><p><strong>Animation Samples</strong></p><p>Animation samples contain the state of an animation for one time-point. The channels in an animation directly write these values into the animation sample. A sample can contain an arbitrary number of outputs, where each output is the combination of a rotation, translation and scale. These can be combined into a transformation matrix later.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/618/1*GwcYssf5N5_lJPXYkEwiIQ.png" /><figcaption>Reading the channels in an animation for a specific time point. The outputs are written to the animation sample.</figcaption></figure><p>Note that not all outputs in the sample have every type of channel. In this example, if we were to apply output 1 to a transform, we would only override the rotation and translation. We keep the scale from the original transform.</p><p><strong>Skeletons</strong></p><p>A skeleton is defined by a set of bones that are parented together to form a hierarchy. Each bone has a default position, called the bind pose (or T-pose). Skeletons and their bones are in local space, because we will want to reuse the same skeleton for multiple meshes at different locations. This requires a conversion step from glTF, as that format defines them in world-space.</p><p><strong>Skeleton Samples</strong></p><p>These are similar to animation samples, in the sense that they contain the state of the bones in a skeleton for a specific time-point. In fact, each skeleton sample contains an animation sample! When we animate a skeleton, we first apply the chosen animation to this animation sample. Each transform output of the animation sample corresponds to a bone, and we apply the rotation, translation and scale overrides to that bone. We now have a set of bones in their animated state.</p><p>Next up we apply the hierarchy of the bones. This involves multiplying the bone transforms in the right order. E.g.: the hand bone is attached to the arm, which is attached to the shoulder.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/556/1*CHtgh8UciDvhwHKJIRm94Q.png" /><figcaption>Applying an animation sample to a skeleton, and parenting the bones.</figcaption></figure><p><strong>Vertex Buffers</strong></p><p>Now that we have the final position of each bone in our skeleton sample, we can apply the bones to the vertices in a vertex buffer. Because we need to build a bottom level acceleration structure, these vertices must be persistent. We can’t rely on the old rasterization trick of transforming the vertices during the draw calls.</p><p>Actually, there is still one thing we have to do before we can apply the bones to the vertices. We must first undo the bind-pose by applying the inverse-bind-matrix (ibm) to each vertex. To understand why, you have to realize that the vertices in their bind pose are already transformed by the bones. If we were to directly apply the new bone position, we would essentially have applied that bone twice. The ibm of a bone will transform that bone back to the origin of the world. If we apply it to a vertex, then we are moving that vertex back towards the origin (0,0,0), which means the vertex position is now relative to the bone. At this point, we can apply the new position of our animated bones to the vertices, and they will correctly end up relative to the bones.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/435/1*v8Hg_rxdfxSozhT10hXhFg.png" /><figcaption>The inverse bind matrix for a bone makes vertices relative to that bone.</figcaption></figure><p>In the example above you can see this happening. If we move the foot of the skeleton, then we want to move the vertices in the leg along the long green vector (the transform of the bone). However, the small red vector was already applied (the bind pose of the bone). First we undo the bind pose, then we can apply the new bone position.</p><p>In our implementation, we pre-apply the ibm to each bone after we chain the bones together.</p><p>Now, applying the bones to the vertices is fairly straightforward. First we read the vertices from the bind-pose vertex buffer in a compute shader, and then we multiply them with the corresponding bones. Each vertex can define up to four bone indices, and a weight for each of those bones.</p><p>Finally we write the animated vertex away to the animated vertex buffer.</p><h3>World Infrastructure &amp; Crowds</h3><p>The previous section discussed what the animated data and update logic looks like, but it did not yet touch on how we actually set up these animations in the world. Most importantly, it did not involve anything about crowds yet.</p><p><strong>Hierarchy</strong></p><p>Central in our framework is a hierarchy of nodes that all have a transform. These represent the objects in our world. Each node can have an animated vertex buffer attached to it, but a node’s transform can also be animated directly.</p><p>Vertex buffers are not directly attached to nodes, but rather we attach a small container (we call them “meshes”) composed of references to a vertex buffer, skeleton sample, bottom level acceleration structure and materials. These “meshes” can be composed separately, and reused between multiple nodes.</p><p>Attaching a mesh to a node means we update our draw calls and add an acceleration structure instance to our TLAS.</p><p><strong>Node Animation</strong></p><p>As mentioned before, we can also directly animate nodes in the world. Think for example of rotating objects. We don’t have to apply vertex animation here, we just need to override a node’s transform in the hierarchy directly. This is why we can bind nodes in our hierarchy to a specific animation sample. We gather these, and apply the transforms in a compute pass.</p><p><strong>Skeleton Parenting</strong></p><p>We’d very much like it if our crowd actors could hold items in their hands, or wear hats. To do this, we should be able to parent nodes to bones in a skeleton. But this is a problem, because our skeletons are not actually a part of the hierarchy. Instead they are in local space so that we can reuse them. Fortunately this is simple to solve. We can provide nodes with a skeleton sample and bone index, and patch the node’s transform in a compute pass that reads and applies the right bone.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/546/1*gY_ICee2XNOsor5xV5SeNg.png" /><figcaption>An example hierarchy that contains animated nodes, a mesh using skeletal animation and a node that is attached to a specific bone within a skeleton.</figcaption></figure><p><strong>Crowd Instancing</strong></p><p>A hierarchical approach like this allows us to reuse a lot of animation, and forms the backbone of our crowd system. For example, we can decide to load a single skeleton, and animate it ten times at different time points. We can then apply those ten skeleton samples to 100 unique vertex buffers. Those 100 vertex buffers can be combined with different sets of materials to create many unique “meshes”. Each mesh can then be added to one or more nodes to create large crowds with limited animation updates.</p><p>Because we need to make a full copy of the vertex buffer for each unique animated instance, reusing these also saves a lot of memory.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/778/1*Sa1PvIDuMMWdywkCh72E7g.png" /><figcaption>Instancing for crowds to limit the amount of skeleton and vertex updates, while still having lots of variation.</figcaption></figure><p><strong>Double Buffering for motion vectors</strong></p><p>Since we apply all sorts of graphics effects, we often need motion vectors so that we can retrieve temporal information from the previous frame. This goes for vertex animated geometry as well. Fortunately, we already need to keep a copy of the animated vertex data around for ray tracing. Double buffering the positions for those buffers is a small extra cost that allows us to then always retrieve the previous frames’ vertex positions for a specific mesh. Subtract those from the current positions, and you have your motion vectors.</p><p><strong>BLAS building and refitting</strong></p><p>Every time we update a vertex buffer in the animation system, the bottom level acceleration structure that uses the buffer must also be updated. This can be done by either rebuilding it completely, or by performing a refit. If the changes in geometry are small enough, a refit is often cheaper and still gives good tracing performance. In our framework we rebuild once every N frames, and do refits for the rest. We do this because the BLAS quality deteriorates over time while refitting. A heuristic can be used here to determine when a rebuild should happen.</p><p>The rebuilds and refits can be batched in most modern graphics APIs. This gives the driver the opportunity to limit the number of dispatches for better performance.</p><p><strong>Other world stuff</strong></p><p>This sums up the main tasks in our world update regarding animation. Of course, there’s more engineering around it. Sorting the draw calls and setting up our TLAS requires extra work. We have some logic in place that ensures that animation updates always run at least once so that we never have any uninitialized state.</p><p>Animating a crowd with many instances of the same mesh, but with different materials, also comes with some downsides. Draw call sorting depends on the material, so you’ll have to split up your instanced draw calls, and change pipeline state. BLASes require you to specify geometry flags that tell you whether a triangle is opaque or not, which means you’ll have to build new BLASes when the opacity of the materials between instances changes. Solving these is not in the scope of this blog post, but be warned that you will run into these!</p><h3>Compute on the GPU</h3><p>Now that we have all the theory down, we are still left with a considerable amount of work that the GPU has to do. How do we implement this optimally?</p><p><strong>Compute work</strong></p><p>Our animation system does a total of 7 compute dispatches, regardless of how many animated objects we have.</p><ol><li><em>Read animation channels and write to animation samples.</em></li><li><em>Apply animation samples to bones.</em></li><li><em>Apply the skeleton hierarchy to the bones.</em></li><li><em>Apply the ibm to each bone, and calculate the normal matrix.</em></li><li><em>Apply bones to vertex buffers.</em></li><li><em>Apply animation samples to nodes.</em></li><li><em>Apply bones to nodes on the hierarchy.</em></li></ol><p><strong>Optimizing &amp; bindless rendering</strong></p><p>The main optimization that we can do is to batch as much work as possible. Launching a compute pass per skeleton to update its bones would be bad for performance because it comes with some overhead. Furthermore, for small skeletons it would mean that a large portion of the threads would be idle.</p><p>The same thing goes for all the other passes: we have many small buffers containing similar data. If all this data had been in a single large buffer, then a single update step would have been easy. Fortunately for us, we have a bindless rendering setup that allows us to dynamically access any buffer from the GPU. This means that we can launch a single compute dispatch that goes over all the buffers, and in turn all the data in those buffers.</p><p>If you would like to know more about our bindless implementation, check out our blog post series by Darius Bouma on setting up a bindless renderer. <a href="https://blog.traverseresearch.nl/bindless-rendering-setup-afeb678d77fc">https://blog.traverseresearch.nl/bindless-rendering-setup-afeb678d77fc</a></p><p>Now, the technique I used to batch all these operations is by building a prefix sum. I’ve described a fast implementation of this algorithm in my previous blog post, which you can find here: <a href="https://blog.traverseresearch.nl/fast-cdf-generation-on-the-gpu-for-light-picking-5c50b97c552b">https://blog.traverseresearch.nl/fast-cdf-generation-on-the-gpu-for-light-picking-5c50b97c552b</a></p><p>Quickly summarized, we want to know the total number of operations we have to perform. For example, if we have 50 skeletons in separate buffers with 10 bones each, then we have a total of 500 bones that need to be updated. We would need to launch a compute pass with 500 threads, each of which will update a single bone.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/728/1*l9SwplcrIKEzc24O5bR8gA.png" /></figure><p>By calculating a prefix sum, we can use each thread’s global index to deduce the skeleton and bone index to update. This is done using a binary search in the prefix sum array. This array stores for each buffer how many elements came before it. For example, the first buffer has seen 0 elements. The second buffer has seen all elements in the first buffer, and so forth.</p><p>We can repeat this prefix sum implementation for each pass in our animation system. It can be done for the animation channels, the bone buffers and any other data that is spread out over many small buffers.</p><h3>Shortcomings / Future work</h3><p>Overall we are very happy with the implementation we came up with, though there are a few shortcomings still which I’ll list here.</p><p>Firstly, we don’t have access to the world space transforms of all our actors, because animations happen on the GPU. This was not a big problem for our demo, but in a more game-like scenario you’d quickly run into issues here. Proposed solutions are to mimic parts of the animation pipeline on the CPU depending on which transform you need to know, or to do a read-back (which would mean your transforms are always a frame behind).</p><p>We also didn’t tackle animation blending or bone labeling. These were simply not a priority for us, but are on the backlog. Perhaps we’ll release a blog post in the future to cover these.</p><p>Lastly, our crowds can still benefit from some changes. For example, adding LODs to not just the crowd actors, but also their skeletons and animations could improve performance. This was however not in scope for us at the time, as it would likely require a lot of manual tweaking by an artist. The same thing goes for adding more variation to the clothing worn and objects held by actors in the crowd. An interesting talk on these topics can be found here: <a href="https://www.youtube.com/watch?v=Rz2cNWVLncI">https://www.youtube.com/watch?v=Rz2cNWVLncI</a>.</p><p>I hope this was helpful! Feel free to leave a comment if you have any questions, and I’ll do my best to answer them.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=bc0e775c74ad" width="1" height="1" alt=""><hr><p><a href="https://blog.traverseresearch.nl/ray-tracing-animated-crowds-bc0e775c74ad">Ray tracing animated crowds</a> was originally published in <a href="https://blog.traverseresearch.nl">Traverse Research</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[An update to our Render Graph]]></title>
            <link>https://blog.traverseresearch.nl/an-update-to-our-render-graph-17ca4154fd23?source=rss----2cf229d54ed9---4</link>
            <guid isPermaLink="false">https://medium.com/p/17ca4154fd23</guid>
            <category><![CDATA[graph]]></category>
            <category><![CDATA[algorithms]]></category>
            <category><![CDATA[rendering]]></category>
            <category><![CDATA[gpu]]></category>
            <dc:creator><![CDATA[Brian]]></dc:creator>
            <pubDate>Fri, 22 Dec 2023 21:31:56 GMT</pubDate>
            <atom:updated>2023-12-22T21:31:56.115Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fMGEvQ_LKxCWhnEcH-i0bQ.png" /></figure><p>Let’s start with some general stuff first; this will be an introductory post about the Render Graph developed at <a href="https://traverse.nl/">Traverse Research</a>, intended for people already falmiliar with the basics of GPU programming. The main concepts used are (render or compute) passes, (texture and buffer) resources, and barriers.</p><p>Additionally, back in April 2022 our dear Manon wrote a blog post about our render graph too! You can find it <a href="https://blog.traverseresearch.nl/render-graph-101-f42646255636">here</a>. Compared to her post, one main thing has changed: we changed the way in which we group the passes. For those who haven’t read that post yet I’ll do a quick recap of the render graph. For those who have you can skip straight down to “<a href="#b7c1">Using the implicit chronological order</a>”.</p><p>To start things off, I’ll reiterate something you’re already familiar with: we need to place barriers between our passes in order to execute the passes in the correct order and avoid race conditions between the resource reads and writes. Ideally, this number of barriers is as little as possible to reduce the total time we’re waiting.</p><h3>🔍 Our in house renderer</h3><p>At <a href="https://traverse.nl/">Traverse Research</a> we have our own in-house renderer. This renderer creates many different kinds of passes in a plethora of different systems which all result in a single frame being rendered. Since we’re rendering a single frame, we want to dispatch our passes to the GPU in one single go. Furthermore, each of these systems doesn’t know what other systems exist and what passes they create, nor do we even want to be concerned with that. Ideally we can provide some sub-system with our inputs and get all of its outputs back and ready for us to use in any of our following passes. This means we can’t make some smart manual decisions about where to place our barriers.</p><h3>♥️ Making our life easier and faster</h3><p>So, we need some kind of way to tackle these issues. Let us start with the simplest: being able to give all our passes to the GPU at once. This can be achieved by creating a centralized place to which we can add our resources and passes. We’ll call this our Render Graph (you’ll understand the name at the end of this). Various other renderers have a similar concept, although the name may vary. With everything in a single place, I’ll leave it up for the reader to imagine how we can then pass everything to the GPU in a single go.</p><p>At this stage we aren’t doing any work on the GPU yet, so we aren’t limited by the limitations of a GPU just yet. We can still reason about any chronological ordering between our passes. However we also mentioned that when we call a sub-system, we just want to provide it some input and get some output resource back, without having to reason about all of the passes that the sub-system might be creating. Creating a chronological ordering between the passes themselves is thus off the table. So what do we actually have that we <em>can </em>use?</p><p>We can do both reads and writes to our resources. Reading does not change the actual data of said resource, but we can safely assume writing always will (otherwise why are you saying you’re going to write to it?). What we can do is adding a version number to each resource. When we read a resource in a pass, we explicitly state which version we would like to read. When writing to a resource we do everything as we would for reading the resource, but afterwards we increment the version, after all this is now different data! Any passes using the resource afterwards will use the now incremented version number. As a more real-world example: we have a canvas on which we painted yesterday and we’re going to paint on it again today. That means tomorrow it’ll also be different than how we started today. Whilst what we have painted on the canvas is different throughout the three days (version 1, 2 and 3), the actual frame and cloth of the canvas stayed the same.</p><p>By adding these version numbers to our resources, each of the passes have specific versioned resource dependencies. These dependencies implicitly describe the ordering that we will need for all of our passes! If you’re not directly following: the dependencies can be followed to link the passes to one another. To know what passes need to be executed before a specific pass, we look at all of its versioned resources used and for each resource find the pass that did the actual version increment. If we were to look at all of our passes from a global overview, we could see that our passes form a large graph network, hence the name Render Graph.</p><p>In our graph, the starting and ending points are always resources. When a resource is created it’s version number is 0, even when no data is passed to initialise the buffer with. A pass filling this uninitialized resource is still writing to it and thus incrementing the version number. At the other end of our graph we also end up with some output resource, after all adding resources that only read but don’t output anything themselves don’t do much for our render.</p><p>To make things a bit more visual, let’s look at a simple case. We have got two separate systems creating some output texture. These textures are combined in a new pass, after which the output texture is blurred. This will give us the following graph:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*mwwH98fH-bMGgjp9EAPndw.png" /><figcaption>Partial Render Graph of our blurring pass</figcaption></figure><p>Just as a small note: this graph cannot contain “loops”; a situation in which we can follow any number of arrows and end up at the same location. This is the same for any system interacting with a GPU that does not contain race conditions. The official term for our graph is a Directed Acyclic Graph (DAG).</p><h3>🏭 Using the implicit chronological order</h3><p>We’ve got a graph with implicit dependencies, but where to place the barriers between our passes? We’ve mentioned that our initial knowledge of our Render Graph is mainly about the resources and their versions. For now we’ll assume each resource can truly only be written to by a single pass, i.e. there’s only a single pass doing a write operation to a specific resource and version combination. For the usage of a versioned resource, we can identify a few different cases: it’s only read by one or more passes, it’s only written to by one pass, and it’s read by one or more passes and written by one pass.</p><p>In all three cases we need to place a barrier between the pass doing the version increment and the passes using the version incremented resource. For the first and second cases there’s really not much more to do. The third one, however, has a small caveat: if we would use the same barrier for the reads as we would for the write, the write could happen before (or at the same time, being a race condition) the read operations, invalidating the data we expected to be there for the read. Thus, we need to make sure any read operations happen <em>before</em> any write operations. This can probably be solved in multiple ways, but we opted in our implementation to add an “invisible” dependency link from the reading passes to the writing pass. Let’s look at a visual example:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1022/1*3bkxV1Fj4yEc0lhn3v_D5w.png" /><figcaption>Partial Render Graph with more passes without barriers</figcaption></figure><p>In this example I have labelled each pass with what resources are used as input, as well as their usage and version. If I am not mistaken, the graph covers all different cases. Adding barriers to it would result in the following:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*OUFW5Zjnu_bSzxab64lrfQ.png" /><figcaption>Partial Render Graph with more passes with barriers</figcaption></figure><p>Another way of looking at this is that we’re slicing the render graph into groups, where each group contains one or more passes which can be executed in any order (or all at the same time) without barriers.</p><h3>🧑‍💻 Translating logic to an algorithm</h3><p>In our system there are various code paths that do generate passes, but turn out to be unused for the actual output, these are mostly alternative (debugging) versions of the main render output. We do know which output we want for this frame, but we do not immediately know which passes become unused. Removing these unused passes is what we call dead stripping. The versioned resource which must be executed we’ll call root resources.</p><p>If we were to start at the beginning/bottom of our graph, we’d have to do quite a bit of tracking in order to determine the path we have taken so far even touches a root resource. However, if we would flip our logic and start at the end/top of our graph, we can just use the root handles as our beginning and get any dead stripping done for free! After all, the passes are unused so we won’t even touch those.</p><p>Actually iterating the graph can be done in multiple ways, however we’ve opted to do it breadth first search style as this is super simple to implement. A tl;dr on breadth first search: we have some starting point(s) which we add to a queue. We pop the first element from the queue, with which we can now do whatever we want. Then each of its dependencies it added back to the queue. Whether each or only some of its dependencies is added depends on the implementation. In our case we also keep track of which passes we have already seen during the iteration, so we don’t visit a single pass twice. As the name suggests, contrary to depth first search (in which a stack is used), this algorithm processes elements closer to the root before continuing with elements that have a higher “depth”. As a fun bonus the depth value is thus monotonic (i.e. never decreases).</p><p>We also need a good definition of when a pass is deemed “ready to be executed”. In our implementation we’ve solved this by doing an additional iteration of the graph. In this iteration we count how many passes use any of the outputs the selected pass creates. During the iteration in which we place passes into groups we can check this dependency count. When this value is anything other than zero this means there are passes which rely on output of this pass which we haven’t handled yet. Since we’re iterating the graph “backwards”, this means we should skip this pass for now and will visit it again later through some other pass dependency.</p><p>The last thing on our list is placing the passes into actual groups. Here too we iterate the graph starting at the root passes in a breadth first search manner. Since we’ve already done our setup and determined the (indirect) dependency count for each pass, there are only a few minor things left to do. Firstly we need to check for each dependency of the selected pass whether its count is zero. If it is, the dependency needs to be added to the queue, if it isn’t, its dependency count is decremented. The group to which this pass belongs is then determined by the depth we’re currently at. In our implementation we add all our root passes to the first group and, during iteration, set the depth value for the dependency passes to the next depth value of the current pass.</p><p>So to put the entire algorithm together, we get the following:</p><ol><li>Create a list mapping each versioned resource to a pass.</li><li>Create a list mapping each pass to all of its dependencies (i.e. passes that should execute before this pass).</li><li>Add all passes which read a versioned resource to the pass that writes to said versioned resource as artificial dependencies to the list just created.</li><li>Create a list of root passes from the root versioned resources.</li><li>Iterate the graph and find the dependency count per pass.</li><li>Iterate the graph and place each pass in a group when the dependency count reaches zero.</li></ol><h3>⌛ Is this it?</h3><p>So now you’ve seen a general overview of the design of our Render Graph and a rough idea how its implementation is done. There are still a few things left untouched, such as async compute and automatic resource transitions, but those might be touched upon at some later point!</p><p>Additionally, if you’re looking for a more information about it, I’d also point you to <a href="http://themaister.net/blog/2017/08/15/render-graphs-and-vulkan-a-deep-dive">Maister’s blog post</a>, who also built a Render Graph, but has a slightly different approach to the blog post!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=17ca4154fd23" width="1" height="1" alt=""><hr><p><a href="https://blog.traverseresearch.nl/an-update-to-our-render-graph-17ca4154fd23">An update to our Render Graph</a> was originally published in <a href="https://blog.traverseresearch.nl">Traverse Research</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Intro to rendering of animated volumetric models in Breda]]></title>
            <link>https://blog.traverseresearch.nl/intro-to-rendering-of-animated-volumetric-models-in-breda-6e942466e6df?source=rss----2cf229d54ed9---4</link>
            <guid isPermaLink="false">https://medium.com/p/6e942466e6df</guid>
            <category><![CDATA[game-development]]></category>
            <category><![CDATA[data-structures]]></category>
            <category><![CDATA[gpu]]></category>
            <category><![CDATA[volumetric]]></category>
            <category><![CDATA[rendering]]></category>
            <dc:creator><![CDATA[Jasper de Winther]]></dc:creator>
            <pubDate>Thu, 14 Dec 2023 12:28:36 GMT</pubDate>
            <atom:updated>2023-12-14T12:38:20.511Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fMGEvQ_LKxCWhnEcH-i0bQ.png" /></figure><p>In this post, we’ll go over the basic building blocks of our real-time volumetric rendering process in the Breda graphics framework (developed at <a href="https://traverse.nl/">Traverse Research</a>). This post is aimed at people experienced with general graphics algorithms like ray marching, writing shaders and some experience with bit manipulations. The following design goals were made very early in the development process and have guided the implementation so far:</p><p>— Loading of OpenVDB files<br> — Index space of 4096³<br> — Animation playback<br> — Voxels represent floating point data<br> — Small memory footprint to have enough space left over for other assets<br> — Needs to be ray traced in real time on an RTX 3070<br> — Multiscattering and emission support (not in this blog post)</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ZFUlqG_6jT4IowUCtom7nw.png" /><figcaption><a href="https://disneyanimation.com/resources/clouds/">Disney cloud</a> rendered using the Breda framework</figcaption></figure><h3>Our data structure</h3><p>Our data structure is a trimmed down version of the <a href="https://www.openvdb.org/">VDB</a> data structure. We load VDB files using our custom parser called <a href="https://github.com/Traverse-Research/vdb-rs">vdb-rs</a>, and then load our structure into a format that includes only the features that we require. As with most VDB implementations we use the 5–4–3 structure which means that our top level node has an index space of 32³ (1&lt;&lt;5), our first internal node has an index space of 16³ (1&lt;&lt;4) and our last internal node has an index space of 8³ (1&lt;&lt;3). These combined result in an index space of 4096³ (1&lt;&lt;5+4+3). From now on, the different nodes will be referred to by their size, so Node5 is the top level node and Node3 the bottom level node. Below is a schematic of the structure:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/561/1*qREtZi5h34hqDyBdALt0Og.png" /></figure><p>These nodes are split up into 2 parts: a header, and a bitmask. Below is a description of one of these nodes, here the size of the bitmask is dependent on the level at which this node is located in the structure:</p><pre>struct Node{<br>  child_pointer: u32,   // Points to the start of N_CHILDREN child nodes<br>  tile_value: f32,      // When the entire region is homogeneous<br>  min_value: f32,       // Minimum value of all nodes below it<br>  max_value: f32,       // Maximum value of all nodes below it<br>  mean_value: f32,      // Mean value of all nodes below it<br>  // Mask where every bit determines if one child node either <br>  // contains data or not<br>  child_mask: [u32; N_CHILDREN / 32],<br>}</pre><p>All of these nodes have to be stored somewhere in a GPU friendly way, and thus we create a structure which we can upload to the GPU. This structure contains the following information:</p><pre>struct Volume{<br>  constants: Vec&lt;VolumeConstants&gt;,<br>  nodes5: Vec&lt;Node5&gt;,<br>  nodes4: Vec&lt;Node4&gt;,<br>  nodes3: Vec&lt;Node3&gt;,<br>  voxel_data: VoxelData,<br>}</pre><p>As can be seen, we have vectors for the different node types. Additionally, we have a vector of constants, this contains information about the translation, scale and aabb of our volume. The reason why this is a vector is because these constants can be unique per animation frame. The last piece of the puzzle is our voxel data, which is stored in a large buffer containing the actual floating point data that the Node3 point to. This data is stored in bricks of 8³ voxels. Since this is by far the part of our data structure that consumes the most memory, our goal was to optimize it as much as possible. This is achieved by first changing the layout from a flat array (where each brick is stored as 8³ consecutive values) to a 3d texture layout, and then by storing the values in such a way that each 4 values in the z direction (of the brick) are stored as the RGBA components of our 3d texture. This allows us to run BC7 block compression and end up with 2 bits per voxel (floating point value). Below is a slice of the resulting texture:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*DpQtdoa1grGVamvy46H47A.png" /></figure><p>Here each chunk of 8² pixels is half of an 8³ chunk, two of these slices make up the entire chunk. As can be seen, there are visible edges, corners and swirls in our data, even at this incredibly small scale.</p><h3>Animations</h3><p>As said earlier, we support animated volumes. These animations consist of sequences of VDB files which can be exported by programs like EmberGen or Houdini, or downloaded directly from <a href="https://jangafx.com/software/embergen/download/free-vdb-animations/">JangaFX’s website</a> (like we did in our examples below). You might have noticed that our Node5’s are also stored in a vector, but each of those nodes already has our desired index space of 4096³. So, instead, each of our animation frames has a unique Node5. This way, when switching to the next animation frame, we only have to change the starting point of our structure traversal.</p><h3>Structure sampling</h3><p>Raymarching through volumetric space requires sampling the data structure a lot of times. This will likely be the hot-loop of our shader, so this has to be fast.</p><p>Sampling our structure requires checking if a child node is valid, and then follow the pointer down the tree. If the child node is *not* valid, we return the tile value, which indicates the value of all nodes beneath it. Once we are sure that the voxel exists, by checking the bit in our Node3, we sample our voxel data texture. Below is our code for doing that, note that all multiply, division and modulo operations are done using powers of 2, thus these are compiled to bit shifts and bitwise and operations.</p><pre>float getVoxel(uint3 position) {<br>  // Assume we are in the aabb of the volume<br>  uint indexNode5 = computeIndexNode5(position);<br>  if (!getBitNode5(indexNode5, animationFrame)) {<br>    return tileValueNode5(animationFrame);<br>  }<br><br>  uint indexNode4 = computeIndexNode4(position);<br>  uint node5ChildPointer = getChildPointerNode5(animationFrame);<br>  uint node4Index = node5ChildPointer * kNode5Children + indexNode5;<br>  if (!getBitNode4(indexNode4, node4Index)) {<br>    return tileValueNode4(node4Index);<br>  }<br><br>  uint indexNode3 = computeIndexNode3(position);<br>  uint node4ChildPointer = getChildPointerNode4(node4Index);<br>  uint node3Index = node4ChildPointer * kNode4Children + indexNode4;<br>  if (!getBitNode3(indexNode3, node3Index)) {<br>    return tileValueNode3(node3Index);<br>  }<br><br>  uint node3ChildPointer = getChildPointerNode3(node3Index);<br><br>  // Our texture supports a maximum of kMaxBricksInDim^3 bricks, <br>  // so wrap the indices to that region<br>  // node3ChildPointer to brick texture coordinates<br>  uint3 brickIndex;<br>  brickIndex.x = (node3ChildPointer % (kMaxBricksInDim));<br>  brickIndex.y = (node3ChildPointer / kMaxBricksInDim) % kMaxBricksInDim;<br>  brickIndex.z = node3ChildPointer / (kMaxBricksInDim * kMaxBricksInDim);<br>  brickIndex *= uint3(8, 8, 2); // multiplied by brick size, <br>                                // each z texel stores 4 values<br><br>  // Offset inside brick<br>  uint3 texel;<br>  texel.x = position.x % 8;<br>  texel.y = position.y % 8;<br>  texel.z = (position.z &gt;&gt; 2) % 2;<br>  uint3 toSample = brickIndex | texel;<br><br>  // Position sample correctly in texture<br>  float4 chunkData = voxelData.load3D&lt;float4&gt;(toSample);<br><br>  // Grab correct z slice out of float4<br>  return chunkData[position.z &amp; 3];<br>}</pre><p>One function that needs further explaining is <em>computeIndexNode</em>. This is exactly the same as the <em>internalOffset </em>calculation in the OpenVDB paper. As input we get a position, then for every dimension we select only the bits that are relevant to the layer we are calculating the offset for. Then we place the selected bits into a single unsigned integer which is the the local index. Below is a code example which does this:</p><pre>uint computeIndexNode5(uint3 position) {<br>  uint3 selected = (position &amp; kNode5SLog) &gt;&gt; (kNode3Log + kNode4Log);<br>  selected.x &lt;&lt;= kNode5Log + kNode5Log;<br>  selected.y &lt;&lt;= kNode5Log;<br>  selected.z &lt;&lt;= 0;<br>  return selected.x | selected.y | selected.z;<br>}</pre><p>There are a number of constants: <em>kNode5SLog </em>describes the log2 span of our Node5 (3+4+5=12) and the different <em>kNodeXLog </em>are simply the X values (so <em>kNode5Log </em>= 5).</p><h3>Simple rendering</h3><p>To get some simple rendering going with our data structure, we can employ techniques commonly used in the game industry, as seen -in example- in 2.5D clouds rendering. For an in-depth overview of those methods, the challenges faced, and the solutions employed, we refer the reader to the extensive work available from <a href="https://sebh.github.io/publications/">Sébastien Hillaire</a> as well as the various <a href="https://www.guerrilla-games.com/read/nubis-realtime-volumetric-cloudscapes-in-a-nutshell">Nubis-related</a> talks from Guerrilla Games.</p><p>Participating media is composed of particles which either scatter or absorb photons. Due to the extremely high amount of particles in a volume, this is all modelled through a statistical process in which the different kinds of particles are described by absorption (<strong>μₐ)</strong> and scattering (<strong>μₛ)</strong> coefficients.</p><p>A beam of light travelling through a differential volume element can undergo the following processes:<br> — Absorption<br> — Out-scattering<br> — In-scattering</p><p>The change of radiance travelling towards the ray direction must also account for emission, but we ignore it for this article to keep things contained.</p><p>We also focus on a single in-scattering event for the time being: the amount of light scattered back to the view as a result of the interaction between a light (in our case, the sun) and the media.</p><p>Absorption and out-scattering “remove” photons from the light ray travelling towards our eye, either because photons are absorbed or because they bounce off of the volume particles and away from the view. This process is a function of both the absorption and scattering coefficients, which -summed up- make up the <strong>extinction coefficient</strong>.<br>This can be represented with a single differential process, integrated along the ray direction, which gives us the <strong>transmittance </strong>along the ray as a function of the extinction coefficient. This is the <a href="https://en.wikipedia.org/wiki/Beer%E2%80%93Lambert_law">Beer Lambert law</a>.</p><p>In-scattering is a function of both the scattering coefficient and the phase function. The phase function describes the angular distribution of scattered rays for a volume, and is conceptually similar to a BRDF for surfaces.</p><p>We now have all the tools to understand the volume rendering equation</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fff_epp9Hp_7cOgVKhII9g.png" /></figure><p>which tells us that the incoming radiance from point <strong>x</strong> towards the camera (<strong>ω</strong>) is the result of an integration process (with upper bound the surface point <strong>z</strong>) in which -at each step- the in-scattered radiance is modulated by the transmittance accumulated up to that step, and by the scattering coefficient at <strong>y</strong>. The exitant radiance at the surface <strong>z</strong> also contributes to the final output and is modulated by the total transmittance accumulated along the ray (the volume “translucency”).</p><p>We can approximate this integral as a Riemann sum, using ray marching.<br>Notice that transmittance is multiplicative along a ray</p><p>T(a→c) = T(a→b) <strong>· </strong>T(b→c)</p><p>which means that we can simply accumulate transmittance that way at each step of our integration.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fPJQ9C5AJ3lU6817krLdIw.png" /></figure><p>With all that said, a simple (and naive) sampling routine would look like this:</p><pre>for each pixel {<br><br>  TraceContext ctx = computePrimaryRay();<br><br>  float t0, t1;<br>  computeVolumeIntersectionBounds(out t0, out t1);<br><br>  // Variable needed for our final output<br>  float3 transmittance = 1.0;<br>  float3 scatteredLight = 0.0;<br><br>  // Results of our surface passes<br>  float3 reflectedRadiance = loadReflectedRadiance();<br><br>  // Better approach: start offset on view-aligned planes<br>  for each step with `stepSize` starting at t0 {<br><br>    // Compute scattering location<br>    float3 pos = ctx.origin + ctx.dir * (t0 + steppingDist);<br><br>    // Sample medium density at location<br>    float density = volume.sampleDensity(pos);<br><br>    if (density &gt; 0.0) {<br>      // Calculate absorption, scattering and extinction coefficients<br>      VolumeCoefficients coeff = volume.getCoefficients(density);<br><br>      // Compute direct lighting<br>      float3 directLighting;<br>      {<br>        // Secondary volumetric trace towards the light<br>        float3 volumetricShadow = traceShadowRay(pos, dirToSun);<br><br>        // Compute phase function value<br>        float phaseFunction = volume.phaseFunctionEval(dot(dirToSun, -ctx.dir));<br><br>        directLighting = SUN_INTENSITY * phaseFunction * volumetricShadow;<br>      }<br><br>      float3 incomingLight = coeff.scattering * directLighting;<br><br>      float3 stepTransmittance = exp(-coeff.extinction * stepSize);<br><br>      // Improved integration for lower sample counts as presented by Sébastien Hillaire<br>      float3 integrationStep = (incomingLight - incomingLight * stepTransmittance) / coeff.extinction;<br>      scatteredLight += integrationStep * transmittance;<br><br>      // Accumulate transmittance<br>      transmittance *= stepTransmittance;<br>    }<br>  }<br><br>  float3 result = reflectedRadiance * transmittance + scatteredLight;<br>  writeOutput(result);<br>}</pre><h3>Conclusion</h3><p>This blog post went over the foundation of our real time volume renderer. Below is a demo of the renderer of a standalone volume. We can easily play back animations or manually change to the desired animation frame.</p><iframe src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fwww.youtube.com%2Fembed%2FpUkvbD0j8lM%3Ffeature%3Doembed&amp;display_name=YouTube&amp;url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DpUkvbD0j8lM&amp;image=https%3A%2F%2Fi.ytimg.com%2Fvi%2FpUkvbD0j8lM%2Fhqdefault.jpg&amp;key=a19fcc184b9711e1b4764040d3dc5c07&amp;type=text%2Fhtml&amp;schema=youtube" width="854" height="480" frameborder="0" scrolling="no"><a href="https://medium.com/media/1337653fbaf24d22c2ca4c17a2365678/href">https://medium.com/media/1337653fbaf24d22c2ca4c17a2365678/href</a></iframe><p>Below, we see a render from our Breda renderer. Here we placed a volumetric animation inside the Bistro scene.</p><iframe src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fwww.youtube.com%2Fembed%2FuZ53LaWfU9c%3Ffeature%3Doembed&amp;display_name=YouTube&amp;url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DuZ53LaWfU9c&amp;image=https%3A%2F%2Fi.ytimg.com%2Fvi%2FuZ53LaWfU9c%2Fhqdefault.jpg&amp;key=a19fcc184b9711e1b4764040d3dc5c07&amp;type=text%2Fhtml&amp;schema=youtube" width="854" height="480" frameborder="0" scrolling="no"><a href="https://medium.com/media/0c57326f9bc1163e0b43cf41c209d540/href">https://medium.com/media/0c57326f9bc1163e0b43cf41c209d540/href</a></iframe><p>This blog post was written by <a href="https://twitter.com/EmilioLaiso">Emilio Laiso</a> and <a href="https://twitter.com/R__Winther">Rosalie de Winther</a> at Traverse Research. If you still have any questions, feel free to reach out on Twitter or directly to Traverse <a href="https://traverse.nl/inquiries">through our website</a>.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=6e942466e6df" width="1" height="1" alt=""><hr><p><a href="https://blog.traverseresearch.nl/intro-to-rendering-of-animated-volumetric-models-in-breda-6e942466e6df">Intro to rendering of animated volumetric models in Breda</a> was originally published in <a href="https://blog.traverseresearch.nl">Traverse Research</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Fast CDF generation on the GPU for light picking]]></title>
            <link>https://blog.traverseresearch.nl/fast-cdf-generation-on-the-gpu-for-light-picking-5c50b97c552b?source=rss----2cf229d54ed9---4</link>
            <guid isPermaLink="false">https://medium.com/p/5c50b97c552b</guid>
            <dc:creator><![CDATA[Jan Kind]]></dc:creator>
            <pubDate>Thu, 08 Dec 2022 10:54:57 GMT</pubDate>
            <atom:updated>2022-12-09T01:57:33.305Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*iC--oi1cmyOgobOWVw5iSA.png" /><figcaption>Traverse Research</figcaption></figure><h4><strong>Introduction</strong></h4><p>In this blog post, I’ll take a deep dive into how we build our CDF for light sampling on the GPU at Traverse Research. I’ll give a quick rundown of what everything is, why you need it and finally how we made it run fast on the GPU.</p><p>The first few sections are an introduction to concepts that may not be clear to everyone, so feel free to skip ahead to the part where I dive into optimizing the GPU algorithm if you already have a good understanding of CDFs!</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Hk_ff5T2RLnix12oc__p_w.png" /><figcaption>CDF light sampling in the Breda framework with ReSTIR for direct and indirect light</figcaption></figure><h4>What is a CDF?</h4><p>CDF stands for “cumulative distribution function”. In probability theory, it is a function that can tell you the probability that a random variable has taken on a certain value. This is done by summing the value of the probability density function (PDF) for each value that the random variable can assume.</p><p>The below diagram might make it a bit clearer if I’ve confused you up until this point: The red line is the value of the CDF for a random variable. The random variable can take on the values of A, B, C and D (X-axis), and the Y axis represents the value of the PDF for each of those values.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/315/0*5ZQyLx-pZlGAlaL4" /></figure><p>Personally I prefer to look at the CDF the way it is represented in code: an inclusive prefix sum array, where each element is the sum of the value of all elements that came before it (including its own value). The below diagram shows the PDF and CDF for the four values our random variable can assume in array form:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/165/0*-7hHSHSzHbTrmznk" /></figure><p>If you are interested in reading more about probability theory, then I recommend <a href="https://jacco.ompf2.com/2019/12/11/probability-theory-for-physically-based-rendering/">this blog post</a> by Jacco Bikker.</p><h4><strong>Where and how are CDFs used?</strong></h4><p>With all that out of the way, you may be wondering why you would need a CDF. Well, it turns out that they are great when you want to stochastically select an element from a set proportional to its weight, and relative to the weight of all other elements in the set.</p><p>This is possible, because a useful property of CDFs is that you can invert them: rather than querying the CDF what value corresponds to a known element, you randomly generate a CDF value and find which element corresponds to it. That way you can select your samples with a priority for the important ones (the ones where the PDF gives a higher value).</p><p>A common scenario where this occurs is any algorithm that relies on monte carlo integration. If your dataset is too big to fully consider each iteration, then integrating over a few stochastic samples each cycle will eventually also converge to the correct result. By not taking random samples, but stochastically selecting based on weights you will converge to the right result faster. The stronger the relation between the importance of an element and its weight, the faster it will converge.</p><p>There are many algorithms where this applies, but for the rest of this blog I will be focusing on our use case: sampling lights in a real time ray tracer.</p><h4><strong>Light sampling algorithm</strong></h4><p>Let’s translate all that previous information to our use-case. We are running a real-time ray tracer with a scene that contains many lights. For every pixel, we want to know per light how much it contributes to the irradiance. In our actual implementation, we use monte carlo integration and we feed our selected light samples into a ReSTIR implementation, but for simplicity I will assume that we are only going to consider a single light per pixel, and that we are letting the image converge over time.</p><p>The algorithm is fairly simple, and works as follows:</p><ol><li>For every pixel, pick a single light using the CDF.</li><li>Calculate the irradiance that the light contributes to the pixel, and divide it by the relative weight of the light.</li><li>Average the light contribution per pixel over many frames to get the correct result.</li></ol><p><strong>Step 1: Inverting the CDF<br></strong>In an earlier section, I talked about inverting the CDF. This is exactly what we are going to do for step 1 of the algorithm! Let’s take the CDF from a previous example, and invert it. Pretend that A, B, C and D are the lights in our scene. Each light has a radiance, which we are going to use as its weight. This means lights that emit more energy will be stochastically selected by the CDF more often (since they are probably more important than weaker lights).</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/304/0*V_edlIJyKg58Pium" /></figure><p>First we generate a random float between 0 and 1. Let’s say it landed on 0.75. In the image below, you can see we map this random float to the lights in the CDF and pick whichever one it landed on. In this case, that would be light C. Mapping the random float to the CDF is straightforward: multiply the random float with the sum of all light weights (which is the last element in the CDF) and then do a binary search in the array to find the element that contains the resulting value.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/243/0*srTyFvIjlRTQYT3M" /></figure><p>In this example, you would be multiplying 0.75 with 5.5, which gives you 4.125. Binary searching would yield you element C because it contains all values between 3.5 and 4.5.</p><p><strong>Step 2: Calculating Irradiance<br></strong>Cool, we have selected a light! We can now shade our pixel, but there’s still one important step: we must divide the resulting irradiance by the relative weight of our chosen light. Since we did not pick our lights uniformly, some lights will be overrepresented and others will seldom occur. Ignoring this problem would mean we are creating energy out of thin air, which is highly illegal. Fortunately for us, we can calculate the probability of having picked any one light by dividing its weight by the total weight sum of all lights.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/305/0*jaCYtHeYbttx1Ygz" /></figure><p>We divide the shading result by the pick chance of our light. This will scale the light’s contribution up to compensate for all the lights that we did not pick this frame, proportional to how much the light contributes to the total light power in the scene. Light B would be scaled up less than light A, but B also gets picked more often. In the end this cancels out, and you are left with the same result that you would get from fully random light selection, but you needed less iterations to get to that result.</p><p><strong>Step 3: Algorithm Integration &amp; Conclusion<br></strong>The result from one frame of this algorithm would be noisy, but if you average the results of a few frames it will quickly start to converge. For this scenario where we only have four lights, the algorithm does not make a large difference. But in modern path tracers, having hundreds of thousands of lights is a common scenario. That’s when this algorithm greatly reduces the complexity of shading.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*HjkFxVhiBQ9KMJTeI3pIjQ.png" /><figcaption>CDF light sampling in the Breda framework with ReSTIR for direct and indirect light</figcaption></figure><h4><strong>CPU CDF Implementation</strong></h4><p>Now that we have all the theory out of the way, it’s time to actually build our CDF. On the CPU, a naive implementation of an inclusive prefix sum array is very simple:</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/d7460fbd2daf8155e4fcd626f3de6fee/href">https://medium.com/media/d7460fbd2daf8155e4fcd626f3de6fee/href</a></iframe><p>After executing, the CDF would contain <em>[1.0, 6.0, 8.5, 11.6, 12.6, 14.7]</em>.</p><p>The real downside to this implementation is that it is single-threaded, and building it takes O(N) time. If we had 16 elements we wanted to calculate the sum of, we would need to perform 15 additions in sequential order since the next addition always depends on the previous result.</p><p>We can then start using the CDF by stochastically selecting elements using some random number generation and a binary search like in the following code snippet:</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/b477d683a1fe1cccb0a2f748f4a6b07d/href">https://medium.com/media/b477d683a1fe1cccb0a2f748f4a6b07d/href</a></iframe><h4><strong>Parallel prefix sum</strong></h4><p>How do we make the build process faster than O(N)? Introducing: the parallel prefix sum! An algorithm that can calculate the prefix sum of an array of values using many threads at the same time, bringing the complexity down to O(log N). This algorithm requires a large amount of threads to be available at the same time, so it’s a perfect fit for the GPU.</p><p>The algorithm works by building a tree of local summed values in multiple passes. When the tree is completed, each element contains the sum of the elements below it. The top of the tree will contain the sum of all elements.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/466/0*C68sA6vi1Li08uDt" /></figure><p>In our example, we will be building a binary tree from an array of 16 elements, so the algorithm needs 4 (<em>log2(16)</em>) passes. Each arrow represents a thread adding two values together and storing the result. If you count the arrows, you can see that we still need a total of 15 additions to get the sum of our 16 values. The difference is that we can run the additions largely in parallel! During the first pass, we perform 8 additions, but if we have 8 threads performing them at the same time, the total time taken is the same as doing one addition. Given that we have enough threads, the total runtime will be log2(N) additions where N is the amount of elements we want to sum.</p><p>Now that we have summed our values in tree format, we still need to flatten it into an array where each element contains the prefix sum at the index corresponding to the original weights array.</p><p>This requires one more pass where we launch a thread for each element in our weights array (16 threads in this case). Each thread remembers its element index, and that is where it will eventually write in the CDF array. We traverse the tree top-down, and our goal is to sum all values we find that are only composed of elements that have an index smaller than or equal to the current thread’s element index.</p><p>The logic looks as follows:</p><ul><li>Is the sum stored at the current tree node composed only of elements with an index smaller than or equal to the current thread’s element index? Then add the sum to our total. If the value already contains the current element index, terminate. We have found our prefix sum. If not, go down the right node of the parent node and repeat the algorithm.</li><li>If the sum stored at the current tree node was composed of elements that lie to the right of our current thread’s element index, then we do not sum this value. Instead go down the node on the left and repeat the algorithm for that node.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/466/0*MIJGIuxYolf5R7Jh" /></figure><p>The image above illustrates this logic for the thread that has been tasked with finding the prefix sum for the sixth element in our array.</p><ol><li>It starts at the top of the tree, and realizes that the value there (27) also contains the sum of elements that came after index number six. It goes down the node on the left.</li><li>It checks if the value here (15) lies entirely to the left, but sees that it also contains elements seven and eight, which come after six. It goes down the tree on the left.</li><li>Finally! The sum here (7) is only composed of elements one, two, three and four. It adds 7 to our total sum. This sum does not yet contain our own element (six) so we go down the parents right node.</li><li>The value we find here (8) also sums elements that lie to the right of our current element. We go down the node on the left.</li><li>We find the sum of elements five and six (4). We add it to our total sum, which adds up to 11. Because the current node’s sum includes our current element (six) we can terminate the algorithm and write the prefix sum of index six: 11.</li></ol><p>When all threads have finished executing, we are left with our prefix sum array. Knowing all this, it should be somewhat straightforward to implement the parallel prefix sum algorithm in a compute shader. There are however a few fun optimizations we can make!</p><h4><strong>Single Pass Tree Builder</strong></h4><p>First of all, the algorithm requires multiple shader passes, one for each layer of the tree to be more precise. There needs to be an explicit synchronization between these passes because each layer reads from the previous one. Adding these execution barriers into our GPU timeline is slow, especially when we get near the top of the tree where we only need a few threads to do a few operations. Doing multiple compute passes also adds extra overhead.</p><p>Luckily, we can build the entire tree in a single compute pass by using a few HLSL synchronization tricks. This approach is based on <a href="https://gist.github.com/sebbbi/6cfbec7ab343924dad9b7ee48ef3ba6c">a code snippet</a> by Sebastian Aaltonen. I’ve made a greatly simplified version of our CDF tree building shader to showcase how it works:</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/1b31dbbbd96dc6c7e83a0a04827fbcfd/href">https://medium.com/media/1b31dbbbd96dc6c7e83a0a04827fbcfd/href</a></iframe><p>We start by launching enough thread groups to build the first layer of the tree: one thread for every 2 elements in our CDF array. Each thread will take the two elements from the previous tree layer, and then write a single element away to the next layer in the tree. We then kill off half our thread groups, since we only need to do half the operations in the next layer of our binary tree. Repeat this process until we have only a single thread that writes the top of the tree away.</p><p>The main issue with doing this is that every layer of the tree depends on the values in the previous having been written, so we must ensure that this writing has finished before we start reading.</p><p>The synchronization works because we keep re-using the same thread groups. The thread group that merges the two outputs from the previous layer, is the same thread group that wrote one of those values away in the first place. The other value was written by another thread group, but that group incremented the same atomic value as our current group. Since only the last group to write survives, we know that all data is there when we read it.</p><p>Only the first thread in each thread group updates the atomic in our atomics buffer, and writes the result away to group shared memory. This way all threads within that group can read the value after synchronizing.</p><p>You may have also noticed that I marked the tree buffer as “globallycoherent”. This is required because thread groups will be reading values written by other thread groups within the same shader invocation. That means that changes aren’t guaranteed to be written away to global memory from the local caches by the time we read again. The globallycoherent keyword ensures that any changes we make will be visible to other thread groups.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/315/0*5y2hbS_inzwRR4Xk" /></figure><p>To make all this a bit more intuitive, here’s an example of the thread group lifetimes for a tree that is being built for an array containing 8 weights.</p><ul><li>We start with four thread groups, each consisting of a single thread for simplicity.</li><li>Group 1 sums weight ‘2’ and ‘3’ and stores the result. It increments the atomic, and finds that it is the first to do so. It terminates.</li><li>Group 0 sums weight `0` and `1` and stores the result. When incrementing the atomic, it reads back a value of 1, which means the other thread group it’s paired with has already finished executing. It survives because it knows it is the last group to finish.</li><li>In the next iteration group 0 now reads the value it previously wrote, and the value group 1 wrote. From another part of the tree, group 3 is summing its own values and finishes executing first, so it terminates.</li><li>Group 0 is now the last living group. During the last iteration of the tree building, it sums the value it wrote previously and the value written by group 3.</li></ul><h4><strong>Optimal GPU usage</strong></h4><p>We now have a single compute shader invocation that can build our entire CDF tree, but we are not utilizing the GPU in a very efficient way. Having a single thread per thread group is far from optimal, so we should take a closer look at the hardware. It turns out that most modern GPUs schedule thread groups in smaller batches (warps or waves) of around 4 to 64 threads. If we make our thread groups the same size, we will be 100% utilizing the hardware’s thread lanes!</p><p>The next optimization involves wave intrinsics. GPUs execute shader code at exactly the same time for all threads within a wave. Special operations built into HLSL use this to allow you to read variables from other threads in your wave, since they are already loaded into memory anyways. We will be using the powerful “WavePrefixSum” intrinsic, which calculates the prefix sum for a variable for all threads in our wave with a lower index than the current thread.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/436d2a01630a0c6bc92d2d42adf80cf2/href">https://medium.com/media/436d2a01630a0c6bc92d2d42adf80cf2/href</a></iframe><p>I’ve changed the previous code snippet a little bit to show how we would use this. Rather than one thread per group, we now have 32 (the most optimal number differs per GPU). Each thread loads a single element into memory, and the wave intrinsic calculates the sum incredibly fast. The last thread in our group then writes this sum away for the next level of the tree.</p><p>As you may have noticed, our tree is no longer base-2, but base-32. This is great because it means our tree isn’t as deep as it was before. We won’t need as much synchronization and we can process more elements in parallel. This does require some changes to the rest of our algorithm: the offsets for each tree layer will change, as well as the offsets for our atomics. Our thread groups are also no longer writing to an atomic in pairs of two, but in batches of 32. Each batch will be writing to the same atomic, and only the last one to write will survive. This makes sense: one out of every 32 thread groups survives, just like how we have 32 times fewer elements whenever we go up one level in our tree.</p><p>Similarly, the down-sweep pass that flattens the tree into an array will now also need to be updated to consider 32 children per node instead of just two.</p><p><strong>Bonus optimization: </strong>since your wave size and tree base are powers of two, you can bit shift indices to the left and right instead of doing multiplication, division or modulo operations. This is a quite common scenario in this algorithm because you will often be calculating the offsets into the tree buffer for a given layer and thread index.</p><h4><strong>Layering multiple CDFs</strong></h4><p>That is pretty much the entire algorithm out of the way! I did however skip over one important detail of our ray tracing mesh light implementation. Up until this point I’ve been assuming that all lights are neatly laid out in a single array. Of course it can’t be that easy. In our actual implementation, any triangle of a mesh can be emissive, so when we pick a light we need to know the mesh and triangle index.</p><p>The solution here is to add more CDFs. We can build a prefix sum array for the number of emissive triangles in each mesh, and then inverse this CDF by flattening it into an array.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/390/0*Ihka6HGg20Ifidpi" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/435/0*Jtcs72vPI3NxJFut" /></figure><p>We can now use this flattened array as input for our light CDF tree shader. It will just involve a little bit of extra work for the threads that would normally only read the weights from the bottom level of the tree: they’ll have to load the triangle light using the indices in this buffer, and calculate that weight themselves (and store it for the bottom of the tree).</p><h4><strong>Floating point precision</strong></h4><p>While this algorithm is very fast, it does suffer from a few problems. The most important one is floating point precision. In a scene with a million lights, calculating the prefix sum means adding a lot of floats together. You end up with quite large numbers towards the end, which means lights with tiny weights may not actually change the sum at all anymore. They will thus never be considered. This problem can be mostly mitigated by sorting lights from low to high weight, but such a sorting pass would itself also take up time and may not be worth it.</p><h4><strong>Conclusion &amp; Timings</strong></h4><p>And that is how we stochastically select our lights at Traverse Research! In scenes with <em>~100.000</em> emissive triangles the whole process of breaking down our meshes and building the CDF for individual emissive triangles takes about <em>0.07</em> milliseconds on an <em>AMD RX 6800 and 0.054 milliseconds on an Nvidia RTX 3080.</em></p><p>I hope this blog post was informative, and inspired you to use these techniques to speed up your own shaders. This was my first time writing a blog post, so feel free to send me some constructive feedback!</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*stdIWFoOIhguYt1OYEjbPw.png" /><figcaption>CDF light sampling in the Breda framework with ReSTIR for direct and indirect light</figcaption></figure><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=5c50b97c552b" width="1" height="1" alt=""><hr><p><a href="https://blog.traverseresearch.nl/fast-cdf-generation-on-the-gpu-for-light-picking-5c50b97c552b">Fast CDF generation on the GPU for light picking</a> was originally published in <a href="https://blog.traverseresearch.nl">Traverse Research</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Our layered material model]]></title>
            <link>https://blog.traverseresearch.nl/our-layered-material-model-c2da17c44306?source=rss----2cf229d54ed9---4</link>
            <guid isPermaLink="false">https://medium.com/p/c2da17c44306</guid>
            <dc:creator><![CDATA[Hannes Vernooij]]></dc:creator>
            <pubDate>Tue, 06 Dec 2022 14:26:33 GMT</pubDate>
            <atom:updated>2022-12-06T15:57:34.040Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*pyU0Wi3PGSql0Xvscm9ccA.png" /></figure><p>This blog post is aimed towards people with an interest in graphics who have knowledge about BRDFs and the math involved. In this post we will briefly go over how the layered material model we implemented works.</p><p>Layered material models are an expandable solution to achieve more complex visual appearances like coatings. The core idea is to combine multiple lobes by treating them as a stack of layers where light reflects off and refracts through. We based our implementation on the <a href="https://www.cg.tuwien.ac.at/research/publications/2007/weidlich_2007_almfs/weidlich_2007_almfs-paper.pdf">Weidlich-Wilkie</a> material model, mainly to determine how layers interact with each other using simplifications from the paper to drastically reduce the computational complexity. For conductors we use measured data to get a more realistic approximation. We used three wavelengths to approximate the entire spectrum of light and use measured indices of refraction and extinction coefficients for our simplified Fresnel derivation. Simple BRDFs often hack conductors using Schlick’s Fresnel approximation with an eye-balled color that represents the conductor as F0.</p><h3>Fresnel and our conductor model</h3><p>We use two derivations of the Fresnel equations together with measured data for results closer to reality; one for dielectrics and one for conductors. For both derivations we assume unpolarized light, therefore the ratio of reflection and transmission depend on the average of p- and s-polarization of the incident ray. Since the per-wavelength varying extinction coefficient has a great impact on the visual appearance of conductors we use a slightly more complex derivation than we are using for dielectrics with an extinction coefficient factor. Since we are not working with a spectral renderer we had to pick frequencies to sample so we decided to go with three wavelengths to approximate the entire spectrum, <em>0.63 µm</em> (red), <em>0.532 µm</em> (green) and <em>0.465 µm</em> (blue). The derivation to be used is selected based on the layer’s metallic factor, when this is zero it is a dielectric, otherwise it is treated as a conductor. Using metalness textures can lead to situations in which both derivations are linearly interpolated based on the metallic factor. Blending Fresnel for conductors and dielectrics is not physically plausible but is supported since it is often encountered in PBR assets and clamping or rounding the metallic factor would lead to loss of detail. Because we are approximating the entire spectrum with only three wavelengths, we use a lookup table with indices of refraction and extinction coefficients per wavelength for the following measured materials: <em>aluminum</em>, <em>brass</em>, <em>copper</em>, g<em>old</em>, <em>iron</em>, <em>lead</em>, <em>mercury</em>, <em>platinum</em>, <em>silver </em>and <em>titanium</em>. Our data set is obtained from <a href="https://refractiveindex.info/">https://refractiveindex.info/</a> of which we use the metal section of the 3D shelf.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*PnD4-YTrVGGwJICHeNShYA.png" /><figcaption>from left to right: Aluminum, brass, copper, gold and iron</figcaption></figure><p>For composing layered materials, we currently support GGX reflection, GGX transmission and Lambertian diffuse lobes. We support up to 4 components and enforce that conductors and diffuse lobes can only exist as bottom layer. Scattering within layers is ignored, and outgoing rays that encounter total internal reflection are invalidated. The invalidation of total internal reflection layers can cause energy loss, but this is compensated by an energy compensation factor.</p><h3>Supported lobes</h3><p>The Lambertian diffuse reflection lobe can only be used as bottom layer and represents an ideal diffuse material with a user specified base color. This layer represents a cheap approximation of scattering below the surface, causing the surface to look the same independently of the view direction. This layer only has a single base color parameter specifying the reflectance of the diffuse layer. This can be represented as a RGB color value or SRGB texture.</p><p>The GGX specular reflection lobe can be used to represent dielectric coatings on top of diffuse layers, but can also be used to model conductors. This is all determined by a metallic factor, which should either be zero or one. When metal-roughness maps upload values between zero and one it becomes a blend between dielectrics and conductors which is not physically plausible. We still support conductor dielectric blends in textures since it is commonly encountered in PBR metalness textures. Conductors in our layered model are assumed to not transmit energy since all energy is absorbed before it reaches the next layer.</p><p>The GGX specular transmission lobe is used to represent transparent materials, this adds a GGX lobe below the stack of layers. For indirect illumination we currently render the result of this lobe to a different texture since it cannot be handled by our indirect specular reflection denoiser in our hybrid renderer.</p><h3>The layered BRDF</h3><p>To sample the BRDF as a whole we sample a direction in all lobes individually and importance sample one based on a heuristic that determines which sample will likely have a high contribution. For diffuse lobes this returns the luminance of the diffuse albedo and the specular component will return the luminance of the Fresnel component. The heuristic for the Fresnel component can in the future be improved by taking the context of the layer into account, reducing the weight of samples from which a large fraction of incoming radiance has been absorbed by the layers above.</p><p>When a sample has been taken the probability density has to be calculated. Since a sample can be valid for all lobes, the total probability density needs to be calculated for the entire BRDF. When imagining a GGX specular reflection lobe on top of a Lambertian diffuse lobe, all samples taken on the GGX lobe are also valid for the diffuse lobe.</p><p>Re-using samples for all lobes makes our <em>sampleBrdf </em>and <em>brdfPdf </em>functions slightly more complex than when dealing with a single layer at a time. When we sample a BRDF we use random numbers to sample a direction in a lobe for each layer and a random number to sample one of the sampled directions that will be traced. Each sample will then get a weight assigned based on is luminance as mentioned before. A CDF is built using the weight for each layer and the third random number is used to sample one of these layers, only the sampled layer is traced. These weights are then normalized and sampled as CDF to determine which layer will be stored and traced.</p><p>The probability density<em> </em>function needs to account for the importance sampling of layers when calculating the probability density in order to converge to the correct result. This requires the <em>brdfPdf </em>function to be aware of the probability that the layer is sampled on the lobe. Since the sample can be valid for multiple lobes we multiply the sum of probability densities for sampling the sampled direction in each individual lobe by the probability density of sampling the lobe itself to get the total probability density that the direction is sampled. We validated our implementation by comparing the converged result to a result converged with next event estimation.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*QVfAF9fWJD1bc_jFRfqLEQ.png" /><figcaption>A shader ball with, from left to right, 2, 3 and 4 layers.</figcaption></figure><p>Calculating the reflectance of the BRDF consists out of several steps. We first calculate the reflectance of the top layer with given incoming and outgoing directions, then the absorption factor is calculated to attenuate the transmitted fraction of the incoming radiance. After that both the incoming and outgoing directions are refracted for the evaluation of the next layer. This uses Snell’s law with the index of refraction of the layer the light is refracted through. The total reflectance of all individual lobes summed results in the total reflectance of the BRDF.</p><p>Hopefully this post gave you some insight in how we tried to achieve more realistic layered materials by briefly going over our conductor model and by describing how separate components can be combined to achieve more complex results.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=c2da17c44306" width="1" height="1" alt=""><hr><p><a href="https://blog.traverseresearch.nl/our-layered-material-model-c2da17c44306">Our layered material model</a> was originally published in <a href="https://blog.traverseresearch.nl">Traverse Research</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
    </channel>
</rss>