JMS55's Blog Bevy, Rust, Graphics, etc Zola 2025-12-27T00:00:00+00:00 https://jms55.github.io/atom.xml Realtime Raytracing in Bevy 0.18 (Solari) 2025-12-27T00:00:00+00:00 2025-12-27T00:00:00+00:00 Unknown https://jms55.github.io/posts/2025-12-27-solari-bevy-0-18/ <h2 id="introduction">Introduction<a class="zola-anchor" href="#introduction" aria-label="Anchor link for: introduction" style="visibility: hidden;"></a> </h2> <p>It's been approximately three months since <a href="https://jms55.github.io/posts/2025-09-20-solari-bevy-0-17/">my last post</a>, which means it's time to talk about all the work I've been doing for the upcoming release of Bevy 0.18!</p> <p>Like last time, this cycle has seen me focused entirely on Solari - Bevy's next-gen, fully dynamic raytraced lighting system, allowing artists and developers to get high quality lighting - without having to spend any time on static baking.</p> <figure> <img src="headline.png" > <figcaption><p>PICA PICA using Solari in Bevy 0.18</p> </figcaption> </figure> <p>Before getting into what's changed in this release, let's take a quick look back at where Solari was in Bevy 0.17.</p> <h2 id="recap-of-0-17">Recap of 0.17<a class="zola-anchor" href="#recap-of-0-17" aria-label="Anchor link for: recap-of-0-17" style="visibility: hidden;"></a> </h2> <p>Bevy 0.17 saw the initial release of Solari, with the following components:</p> <ul> <li>Direct diffuse lighting via <strong>ReSTIR DI</strong></li> <li>Indirect diffuse lighting final gather via <strong>ReSTIR GI</strong></li> <li>Multi-bounce indirect diffuse lighting via a world-space <strong>irradiance cache</strong> (world cache)</li> <li>Denoising, anti-aliasing, and upscaling via <strong>DLSS-RR</strong></li> </ul> <p>ReSTIR DI handles the first bounce of lighting, ReSTIR GI handles the second bounce of lighting, and the world cache handles all subsequent bounces.</p> <p>Summed together and denoised we get full, pathtraced lighting, close to the quality of a offline movie-quality pathtracer - but running much, much faster due to heavy temporal and spatial amortization.</p> <p>Or at least, that's the theory.</p> <p>In practice, all the amortization and shortcuts gives up some accuracy (making the result biased) in order to improve performance.</p> <p>My goal with Solari is to get <em>as close as possible</em> to the offline reference (zero bias), while getting "good enough" performance for realtime. Going into the 0.18 dev cycle, improving quality was my main priority.</p> <p>To that end, Bevy 0.18 brings many quality (and some performance!) improvements to Solari:</p> <ul> <li>Specular material support</li> <li>Fixed the loss of brightness in the scene compared to the reference</li> <li>Reduced correlations and bias from ReSTIR resampling</li> <li>Reduced GI lag</li> <li>Improved performance on larger scenes</li> </ul> <h2 id="specular-materials">Specular Materials<a class="zola-anchor" href="#specular-materials" aria-label="Anchor link for: specular-materials" style="visibility: hidden;"></a> </h2> <p>In Bevy 0.17, Solari only supported diffuse materials. Diffuse materials were easier to get started with, as they don't depend on the incident light direction - they scatter the same no matter what direction the light is coming from.</p> <p>Of course, games want more than just purely diffuse materials. Most PBR materials combine a diffuse lobe (Burley in Bevy's standard renderer, Lambert in Solari) with a specular lobe (usually GGX).</p> <p>In Bevy 0.18, Solari now supports specular materials using a multiscattering GGX lobe, which gets added to the diffuse lobe.</p> <figure> <img src="dragons_realtime.png" > <figcaption><p>Metallic and non-metallic meshes of varying roughness</p> </figcaption> </figure> <h3 id="brdf-evaluation">BRDF Evaluation<a class="zola-anchor" href="#brdf-evaluation" aria-label="Anchor link for: brdf-evaluation" style="visibility: hidden;"></a> </h3> <p>First, let's take a look at the material BRDF itself:</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#859900;">#</span><span>import bevy_pbr::lighting::</span><span style="color:#657b83;">{</span><span style="color:#cb4b16;">F_AB</span><span>, </span><span style="color:#cb4b16;">D_GGX</span><span>, V_SmithGGXCorrelated, fresnel, specular_multiscatter</span><span style="color:#657b83;">} </span><span style="color:#859900;">#</span><span>import bevy_pbr::pbr_functions::</span><span style="color:#657b83;">{</span><span>calculate_diffuse_color, calculate_F0</span><span style="color:#657b83;">} </span><span style="color:#859900;">#</span><span>import bevy_render::maths::</span><span style="color:#cb4b16;">PI </span><span style="color:#859900;">#</span><span>import bevy_solari::scene_bindings::ResolvedMaterial </span><span> </span><span style="color:#268bd2;">fn </span><span style="color:#b58900;">evaluate_brdf</span><span style="color:#657b83;">( </span><span> </span><span style="color:#268bd2;">world_normal</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span> </span><span style="color:#268bd2;">wo</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span> </span><span style="color:#268bd2;">wi</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span> </span><span style="color:#268bd2;">material</span><span>: ResolvedMaterial, </span><span style="color:#657b83;">) </span><span>-&gt; vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt; </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#268bd2;">let</span><span> diffuse_brdf </span><span style="color:#657b83;">= </span><span style="color:#859900;">evaluate_diffuse_brdf</span><span style="color:#657b83;">(</span><span>material.base_color, material.metallic</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> specular_brdf </span><span style="color:#657b83;">= </span><span style="color:#859900;">evaluate_specular_brdf</span><span style="color:#657b83;">( </span><span> world_normal, </span><span> wo, </span><span> wi, </span><span> material.base_color, </span><span> material.metallic, </span><span> material.reflectance, </span><span> material.perceptual_roughness, </span><span> material.roughness, </span><span> </span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#859900;">return</span><span> diffuse_brdf </span><span style="color:#657b83;">+</span><span> specular_brdf; </span><span style="color:#657b83;">} </span><span> </span><span style="color:#268bd2;">fn </span><span style="color:#b58900;">evaluate_diffuse_brdf</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">base_color</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span style="color:#268bd2;">metallic</span><span>: </span><span style="color:#268bd2;">f32</span><span style="color:#657b83;">) </span><span>-&gt; vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt; </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#268bd2;">let</span><span> diffuse_color </span><span style="color:#657b83;">= </span><span style="color:#859900;">calculate_diffuse_color</span><span style="color:#657b83;">(</span><span>base_color, metallic, </span><span style="color:#6c71c4;">0.0</span><span>, </span><span style="color:#6c71c4;">0.0</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#859900;">return</span><span> diffuse_color </span><span style="color:#657b83;">/ </span><span style="color:#cb4b16;">PI</span><span>; </span><span style="color:#657b83;">} </span><span> </span><span style="color:#268bd2;">fn </span><span style="color:#b58900;">evaluate_specular_brdf</span><span style="color:#657b83;">( </span><span> </span><span style="color:#268bd2;">N</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span> </span><span style="color:#268bd2;">V</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span> </span><span style="color:#268bd2;">L</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span> </span><span style="color:#268bd2;">base_color</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span> </span><span style="color:#268bd2;">metallic</span><span>: </span><span style="color:#268bd2;">f32</span><span>, </span><span> </span><span style="color:#268bd2;">reflectance</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span> </span><span style="color:#268bd2;">perceptual_roughness</span><span>: </span><span style="color:#268bd2;">f32</span><span>, </span><span> </span><span style="color:#268bd2;">roughness</span><span>: </span><span style="color:#268bd2;">f32</span><span>, </span><span style="color:#657b83;">) </span><span>-&gt; vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt; </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#268bd2;">let</span><span> H </span><span style="color:#657b83;">= </span><span style="color:#859900;">normalize</span><span style="color:#657b83;">(</span><span>L </span><span style="color:#657b83;">+</span><span> V</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> NdotL </span><span style="color:#657b83;">= </span><span style="color:#859900;">saturate</span><span style="color:#657b83;">(</span><span style="color:#859900;">dot</span><span style="color:#657b83;">(</span><span>N, L</span><span style="color:#657b83;">))</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> NdotH </span><span style="color:#657b83;">= </span><span style="color:#859900;">saturate</span><span style="color:#657b83;">(</span><span style="color:#859900;">dot</span><span style="color:#657b83;">(</span><span>N, H</span><span style="color:#657b83;">))</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> LdotH </span><span style="color:#657b83;">= </span><span style="color:#859900;">saturate</span><span style="color:#657b83;">(</span><span style="color:#859900;">dot</span><span style="color:#657b83;">(</span><span>L, H</span><span style="color:#657b83;">))</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> NdotV </span><span style="color:#657b83;">= </span><span style="color:#859900;">max</span><span style="color:#657b83;">(</span><span style="color:#859900;">dot</span><span style="color:#657b83;">(</span><span>N, V</span><span style="color:#657b83;">)</span><span>, </span><span style="color:#6c71c4;">0.0001</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span> </span><span style="color:#268bd2;">let </span><span style="color:#cb4b16;">F0 </span><span style="color:#657b83;">=</span><span> calculate_F0</span><span style="color:#657b83;">(</span><span>base_color, metallic, reflectance</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> F_ab </span><span style="color:#657b83;">= </span><span style="color:#cb4b16;">F_AB</span><span style="color:#657b83;">(</span><span>perceptual_roughness, NdotV</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span> </span><span style="color:#268bd2;">let</span><span> D </span><span style="color:#657b83;">= </span><span style="color:#cb4b16;">D_GGX</span><span style="color:#657b83;">(</span><span>roughness, NdotH</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> Vs </span><span style="color:#657b83;">=</span><span> V_SmithGGXCorrelated</span><span style="color:#657b83;">(</span><span>roughness, NdotV, NdotL</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> F </span><span style="color:#657b83;">= </span><span style="color:#859900;">fresnel</span><span style="color:#657b83;">(</span><span style="color:#cb4b16;">F0</span><span>, LdotH</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#859900;">return specular_multiscatter</span><span style="color:#657b83;">(</span><span>D, Vs, F, </span><span style="color:#cb4b16;">F0</span><span>, F_ab, </span><span style="color:#6c71c4;">1.0</span><span style="color:#657b83;">)</span><span>; </span><span style="color:#657b83;">} </span></code></pre> <p>Diffuse is nearly the same as in Solari 0.17, except the diffuse BRDF was changed so that it returns 0 for metallic materials, as metallic materials have no diffuse lobe.</p> <p>For specular, a lot of the code can be reused from <code>bevy_pbr</code>, so the BRDF evaluation is only a couple of lines of function calls.</p> <p>One thing though that tripped me up is that special care must be taken to avoid NaNs.</p> <p>In addition to clamping <code>NdotV</code> in the BRDF, we also limit roughness to 0.001 when loading materials, as zero roughness materials cause NaNs in the visibility function.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#586e75;">// Clamp roughness to prevent NaNs </span><span>m.perceptual_roughness </span><span style="color:#657b83;">= </span><span style="color:#859900;">clamp</span><span style="color:#657b83;">(</span><span>m.perceptual_roughness, </span><span style="color:#6c71c4;">0.0316227766</span><span>, </span><span style="color:#6c71c4;">1.0</span><span style="color:#657b83;">)</span><span>; </span><span style="color:#586e75;">// Clamp roughness to 0.001 </span><span>m.roughness </span><span style="color:#657b83;">=</span><span> m.perceptual_roughness </span><span style="color:#657b83;">*</span><span> m.perceptual_roughness; </span></code></pre> <h3 id="brdf-sampling">BRDF Sampling<a class="zola-anchor" href="#brdf-sampling" aria-label="Anchor link for: brdf-sampling" style="visibility: hidden;"></a> </h3> <p>Given this is a pathtracer, we don't just want to evaluate the BRDF; we also want to importance sample it to choose directions that would contribute a lot of outgoing light.</p> <p>There are a couple of different methods to sample the overall BRDF for non-metallic materials that have both a diffuse and specular lobe, but let's skip that for now and just discuss sampling each individually.</p> <p>Sampling the diffuse (Lambert) BRDF is pretty simple - it's just a cosine-weighted hemisphere (code from Solari 0.17):</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#586e75;">// https://www.realtimerendering.com/raytracinggems/unofficial_RayTracingGems_v1.9.pdf#0004286901.INDD%3ASec28%3A303 </span><span style="color:#268bd2;">fn </span><span style="color:#b58900;">sample_cosine_hemisphere</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">normal</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span style="color:#268bd2;">rng</span><span>: ptr&lt;function, </span><span style="color:#268bd2;">u32</span><span>&gt;</span><span style="color:#657b83;">) </span><span>-&gt; vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt; </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#268bd2;">let</span><span> cos_theta </span><span style="color:#657b83;">= </span><span style="color:#6c71c4;">1.0 </span><span style="color:#657b83;">- </span><span style="color:#6c71c4;">2.0 </span><span style="color:#657b83;">* </span><span style="color:#859900;">rand_f</span><span style="color:#657b83;">(</span><span>rng</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> phi </span><span style="color:#657b83;">= </span><span style="color:#cb4b16;">PI_2 </span><span style="color:#657b83;">* </span><span style="color:#859900;">rand_f</span><span style="color:#657b83;">(</span><span>rng</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> sin_theta </span><span style="color:#657b83;">= </span><span style="color:#859900;">sqrt</span><span style="color:#657b83;">(</span><span style="color:#859900;">max</span><span style="color:#657b83;">(</span><span style="color:#6c71c4;">1.0 </span><span style="color:#657b83;">-</span><span> cos_theta </span><span style="color:#657b83;">*</span><span> cos_theta, </span><span style="color:#6c71c4;">0.0</span><span style="color:#657b83;">))</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> x </span><span style="color:#657b83;">=</span><span> normal.x </span><span style="color:#657b83;">+</span><span> sin_theta </span><span style="color:#657b83;">* </span><span style="color:#859900;">cos</span><span style="color:#657b83;">(</span><span>phi</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> y </span><span style="color:#657b83;">=</span><span> normal.y </span><span style="color:#657b83;">+</span><span> sin_theta </span><span style="color:#657b83;">* </span><span style="color:#859900;">sin</span><span style="color:#657b83;">(</span><span>phi</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> z </span><span style="color:#657b83;">=</span><span> normal.z </span><span style="color:#657b83;">+</span><span> cos_theta; </span><span> </span><span style="color:#859900;">return vec3</span><span style="color:#657b83;">(</span><span>x, y, z</span><span style="color:#657b83;">)</span><span>; </span><span style="color:#657b83;">} </span></code></pre> <p>With the pdf being <code>cos_theta / PI</code>.</p> <p>Sampling the specular (GGX) BRDF, however, is much more complicated.</p> <p>The current state of the art paper for sampling a GGX distribution is "Bounded VNDF Sampling for Smith–GGX Reflections" by Kenta Eto and Yusuke Tokuyoshi:</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#586e75;">// https://gpuopen.com/download/Bounded_VNDF_Sampling_for_Smith-GGX_Reflections.pdf (Listing 1) </span><span style="color:#268bd2;">fn </span><span style="color:#b58900;">sample_ggx_vndf</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">wi_tangent</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span style="color:#268bd2;">roughness</span><span>: </span><span style="color:#268bd2;">f32</span><span>, </span><span style="color:#268bd2;">rng</span><span>: ptr&lt;function, </span><span style="color:#268bd2;">u32</span><span>&gt;</span><span style="color:#657b83;">) </span><span>-&gt; vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt; </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#859900;">if</span><span> roughness </span><span style="color:#657b83;">&lt;= </span><span style="color:#6c71c4;">0.001 </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#859900;">return vec3</span><span style="color:#657b83;">(-</span><span>wi_tangent.xy, wi_tangent.z</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#657b83;">} </span><span> </span><span> </span><span style="color:#268bd2;">let</span><span> i </span><span style="color:#657b83;">=</span><span> wi_tangent; </span><span> </span><span style="color:#268bd2;">let</span><span> rand </span><span style="color:#657b83;">= </span><span style="color:#859900;">rand_vec2f</span><span style="color:#657b83;">(</span><span>rng</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> i_std </span><span style="color:#657b83;">= </span><span style="color:#859900;">normalize</span><span style="color:#657b83;">(</span><span style="color:#859900;">vec3</span><span style="color:#657b83;">(</span><span>i.xy </span><span style="color:#657b83;">*</span><span> roughness, i.z</span><span style="color:#657b83;">))</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> phi </span><span style="color:#657b83;">= </span><span style="color:#cb4b16;">PI_2 </span><span style="color:#657b83;">*</span><span> rand.x; </span><span> </span><span style="color:#268bd2;">let</span><span> a </span><span style="color:#657b83;">=</span><span> roughness; </span><span> </span><span style="color:#268bd2;">let</span><span> s </span><span style="color:#657b83;">= </span><span style="color:#6c71c4;">1.0 </span><span style="color:#657b83;">+ </span><span style="color:#859900;">length</span><span style="color:#657b83;">(</span><span style="color:#859900;">vec2</span><span style="color:#657b83;">(</span><span>i.xy</span><span style="color:#657b83;">))</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> a2 </span><span style="color:#657b83;">=</span><span> a </span><span style="color:#657b83;">*</span><span> a; </span><span> </span><span style="color:#268bd2;">let</span><span> s2 </span><span style="color:#657b83;">=</span><span> s </span><span style="color:#657b83;">*</span><span> s; </span><span> </span><span style="color:#268bd2;">let</span><span> k </span><span style="color:#657b83;">= (</span><span style="color:#6c71c4;">1.0 </span><span style="color:#657b83;">-</span><span> a2</span><span style="color:#657b83;">) *</span><span> s2 </span><span style="color:#657b83;">/ (</span><span>s2 </span><span style="color:#657b83;">+</span><span> a2 </span><span style="color:#657b83;">*</span><span> i.z </span><span style="color:#657b83;">*</span><span> i.z</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> b </span><span style="color:#657b83;">= </span><span style="color:#859900;">select</span><span style="color:#657b83;">(</span><span>i_std.z, k </span><span style="color:#657b83;">*</span><span> i_std.z, i.z </span><span style="color:#657b83;">&gt; </span><span style="color:#6c71c4;">0.0</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> z </span><span style="color:#657b83;">= </span><span style="color:#859900;">fma</span><span style="color:#657b83;">(</span><span style="color:#6c71c4;">1.0 </span><span style="color:#657b83;">-</span><span> rand.y, </span><span style="color:#6c71c4;">1.0 </span><span style="color:#657b83;">+</span><span> b, </span><span style="color:#657b83;">-</span><span>b</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> sin_theta </span><span style="color:#657b83;">= </span><span style="color:#859900;">sqrt</span><span style="color:#657b83;">(</span><span style="color:#859900;">saturate</span><span style="color:#657b83;">(</span><span style="color:#6c71c4;">1.0 </span><span style="color:#657b83;">-</span><span> z </span><span style="color:#657b83;">*</span><span> z</span><span style="color:#657b83;">))</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> o_std </span><span style="color:#657b83;">= </span><span style="color:#859900;">vec3</span><span style="color:#657b83;">(</span><span>sin_theta </span><span style="color:#657b83;">* </span><span style="color:#859900;">cos</span><span style="color:#657b83;">(</span><span>phi</span><span style="color:#657b83;">)</span><span>, sin_theta </span><span style="color:#657b83;">* </span><span style="color:#859900;">sin</span><span style="color:#657b83;">(</span><span>phi</span><span style="color:#657b83;">)</span><span>, z</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> m_std </span><span style="color:#657b83;">=</span><span> i_std </span><span style="color:#657b83;">+</span><span> o_std; </span><span> </span><span style="color:#268bd2;">let</span><span> m </span><span style="color:#657b83;">= </span><span style="color:#859900;">normalize</span><span style="color:#657b83;">(</span><span style="color:#859900;">vec3</span><span style="color:#657b83;">(</span><span>m_std.xy </span><span style="color:#657b83;">*</span><span> roughness, m_std.z</span><span style="color:#657b83;">))</span><span>; </span><span> </span><span style="color:#859900;">return </span><span style="color:#6c71c4;">2.0 </span><span style="color:#657b83;">* </span><span style="color:#859900;">dot</span><span style="color:#657b83;">(</span><span>i, m</span><span style="color:#657b83;">) *</span><span> m </span><span style="color:#657b83;">-</span><span> i; </span><span style="color:#657b83;">} </span><span> </span><span style="color:#586e75;">// https://gpuopen.com/download/Bounded_VNDF_Sampling_for_Smith-GGX_Reflections.pdf (Listing 2) </span><span style="color:#268bd2;">fn </span><span style="color:#b58900;">ggx_vndf_pdf</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">wi_tangent</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span style="color:#268bd2;">wo_tangent</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span style="color:#268bd2;">roughness</span><span>: </span><span style="color:#268bd2;">f32</span><span style="color:#657b83;">) </span><span>-&gt; </span><span style="color:#268bd2;">f32 </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#268bd2;">let</span><span> i </span><span style="color:#657b83;">=</span><span> wi_tangent; </span><span> </span><span style="color:#268bd2;">let</span><span> o </span><span style="color:#657b83;">=</span><span> wo_tangent; </span><span> </span><span style="color:#268bd2;">let</span><span> m </span><span style="color:#657b83;">= </span><span style="color:#859900;">normalize</span><span style="color:#657b83;">(</span><span>i </span><span style="color:#657b83;">+</span><span> o</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> ndf </span><span style="color:#657b83;">= </span><span style="color:#cb4b16;">D_GGX</span><span style="color:#657b83;">(</span><span>roughness, </span><span style="color:#859900;">saturate</span><span style="color:#657b83;">(</span><span>m.z</span><span style="color:#657b83;">))</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> ai </span><span style="color:#657b83;">=</span><span> roughness </span><span style="color:#657b83;">*</span><span> i.xy; </span><span> </span><span style="color:#268bd2;">let</span><span> len2 </span><span style="color:#657b83;">= </span><span style="color:#859900;">dot</span><span style="color:#657b83;">(</span><span>ai, ai</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> t </span><span style="color:#657b83;">= </span><span style="color:#859900;">sqrt</span><span style="color:#657b83;">(</span><span>len2 </span><span style="color:#657b83;">+</span><span> i.z </span><span style="color:#657b83;">*</span><span> i.z</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#859900;">if</span><span> i.z </span><span style="color:#657b83;">&gt;= </span><span style="color:#6c71c4;">0.0 </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#268bd2;">let</span><span> a </span><span style="color:#657b83;">=</span><span> roughness; </span><span> </span><span style="color:#268bd2;">let</span><span> s </span><span style="color:#657b83;">= </span><span style="color:#6c71c4;">1.0 </span><span style="color:#657b83;">+ </span><span style="color:#859900;">length</span><span style="color:#657b83;">(</span><span>i.xy</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> a2 </span><span style="color:#657b83;">=</span><span> a </span><span style="color:#657b83;">*</span><span> a; </span><span> </span><span style="color:#268bd2;">let</span><span> s2 </span><span style="color:#657b83;">=</span><span> s </span><span style="color:#657b83;">*</span><span> s; </span><span> </span><span style="color:#268bd2;">let</span><span> k </span><span style="color:#657b83;">= (</span><span style="color:#6c71c4;">1.0 </span><span style="color:#657b83;">-</span><span> a2</span><span style="color:#657b83;">) *</span><span> s2 </span><span style="color:#657b83;">/ (</span><span>s2 </span><span style="color:#657b83;">+</span><span> a2 </span><span style="color:#657b83;">*</span><span> i.z </span><span style="color:#657b83;">*</span><span> i.z</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#859900;">return</span><span> ndf </span><span style="color:#657b83;">/ (</span><span style="color:#6c71c4;">2.0 </span><span style="color:#657b83;">* (</span><span>k </span><span style="color:#657b83;">*</span><span> i.z </span><span style="color:#657b83;">+</span><span> t</span><span style="color:#657b83;">))</span><span>; </span><span> </span><span style="color:#657b83;">} </span><span> </span><span style="color:#859900;">return</span><span> ndf </span><span style="color:#657b83;">* (</span><span>t </span><span style="color:#657b83;">-</span><span> i.z</span><span style="color:#657b83;">) / (</span><span style="color:#6c71c4;">2.0 </span><span style="color:#657b83;">*</span><span> len2</span><span style="color:#657b83;">)</span><span>; </span><span style="color:#657b83;">} </span></code></pre> <p>There are two tricky things to note with these functions:</p> <ul> <li>Inputs and outputs are in tangent space, and not world space</li> <li><code>wo</code> and <code>wi</code> are defined from the BRDF's perspective, which is typically opposite to how you think about it in a pathtracer</li> </ul> <p>So in practice you call them like so:</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#586e75;">// https://jcgt.org/published/0006/01/01/paper.pdf </span><span style="color:#268bd2;">let </span><span style="color:#cb4b16;">TBN </span><span style="color:#657b83;">= </span><span style="color:#859900;">orthonormalize</span><span style="color:#657b83;">(</span><span>surface.world_normal</span><span style="color:#657b83;">)</span><span>; </span><span style="color:#268bd2;">let</span><span> T </span><span style="color:#657b83;">= </span><span style="color:#cb4b16;">TBN</span><span style="color:#657b83;">[</span><span style="color:#6c71c4;">0</span><span style="color:#657b83;">]</span><span>; </span><span style="color:#268bd2;">let</span><span> B </span><span style="color:#657b83;">= </span><span style="color:#cb4b16;">TBN</span><span style="color:#657b83;">[</span><span style="color:#6c71c4;">1</span><span style="color:#657b83;">]</span><span>; </span><span style="color:#268bd2;">let</span><span> N </span><span style="color:#657b83;">= </span><span style="color:#cb4b16;">TBN</span><span style="color:#657b83;">[</span><span style="color:#6c71c4;">2</span><span style="color:#657b83;">]</span><span>; </span><span> </span><span style="color:#586e75;">// Convert input from world space to tangent space </span><span style="color:#268bd2;">let</span><span> wo_tangent </span><span style="color:#657b83;">= </span><span style="color:#859900;">vec3</span><span style="color:#657b83;">(</span><span style="color:#859900;">dot</span><span style="color:#657b83;">(</span><span>wo, T</span><span style="color:#657b83;">)</span><span>, </span><span style="color:#859900;">dot</span><span style="color:#657b83;">(</span><span>wo, B</span><span style="color:#657b83;">)</span><span>, </span><span style="color:#859900;">dot</span><span style="color:#657b83;">(</span><span>wo, N</span><span style="color:#657b83;">))</span><span>; </span><span style="color:#586e75;">// Swapped wo and wi </span><span style="color:#268bd2;">let</span><span> wi_tangent </span><span style="color:#657b83;">= </span><span style="color:#859900;">sample_ggx_vndf</span><span style="color:#657b83;">(</span><span>wo_tangent, surface.material.roughness, </span><span style="color:#859900;">&amp;</span><span>rng</span><span style="color:#657b83;">)</span><span>; </span><span style="color:#586e75;">// Convert output from tangent space to world space </span><span style="color:#268bd2;">let</span><span> wi </span><span style="color:#657b83;">=</span><span> wi_tangent.x </span><span style="color:#657b83;">*</span><span> T </span><span style="color:#657b83;">+</span><span> wi_tangent.y </span><span style="color:#657b83;">*</span><span> B </span><span style="color:#657b83;">+</span><span> wi_tangent.z </span><span style="color:#657b83;">*</span><span> N; </span><span> </span><span style="color:#586e75;">// Swapped wo and wi </span><span style="color:#268bd2;">let</span><span> pdf </span><span style="color:#657b83;">= </span><span style="color:#859900;">ggx_vndf_pdf</span><span style="color:#657b83;">(</span><span>wo_tangent, wi_tangent, surface.material.roughness</span><span style="color:#657b83;">)</span><span>; </span></code></pre> <p>One final thing to note is this line of code I added to <code>sample_ggx_vndf</code>, which doesn't appear in the paper:</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#859900;">if</span><span> roughness </span><span style="color:#657b83;">&lt;= </span><span style="color:#6c71c4;">0.001 </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#859900;">return vec3</span><span style="color:#657b83;">(-</span><span>wi_tangent.xy, wi_tangent.z</span><span style="color:#657b83;">)</span><span>; </span><span style="color:#657b83;">} </span></code></pre> <p>Remember how earlier we clamped roughness to 0.001? Well that means we can no longer render perfect mirrors.</p> <p>To get around this, when importance sampling the specular BRDF for a material with a roughness of 0.001, we just treat it like a perfect mirror and reflect the incident light direction around the Z axis.</p> <p>This restores mirror-like behavior, while still preventing NaNs in BRDF evaluation.</p> <p><figure> <img src="without_mirror_fix.png" > <figcaption><p>Without the fix - a little blurry</p> </figcaption> </figure> <figure> <img src="with_mirror_fix.png" > <figcaption><p>With the fix - perfect mirror!</p> </figcaption> </figure> </p> <h3 id="specular-di">Specular DI<a class="zola-anchor" href="#specular-di" aria-label="Anchor link for: specular-di" style="visibility: hidden;"></a> </h3> <p>Now that we've covered Solari's updated material BRDF, let's talk about how lighting has changed.</p> <p>First up - specular direct lighting.</p> <h4 id="status-quo">Status Quo<a class="zola-anchor" href="#status-quo" aria-label="Anchor link for: status-quo" style="visibility: hidden;"></a> </h4> <p>To recap: For direct lighting, Solari is using ReSTIR DI.</p> <p>We take a series of random initial samples from light sources, and use RIS to choose the best one. This is essentially fancy next event estimation (NEE).</p> <p>We then do some temporal and spatial resampling to share good samples between frames/pixels.</p> <p>Finally, we shade using the final selected sample (which in Bevy 0.17 used only the diffuse BRDF).</p> <h4 id="changes">Changes<a class="zola-anchor" href="#changes" aria-label="Anchor link for: changes" style="visibility: hidden;"></a> </h4> <p>To add support for specular materials, there's a couple of different places that we should modify:</p> <ol> <li>Account for the specular BRDF in the target function during initial resampling</li> <li>Account for the specular BRDF in the target functions during temporal and spatial resampling</li> <li>Trace a BRDF ray during initial sampling and combine it with the NEE samples using multiple importance sampling (MIS) <ul> <li>This is the only way to sample DI for zero-roughness mirror surfaces</li> <li>Improves quality for glossy (mid-roughness) surfaces</li> <li>Improves quality for area lights that are very close to the surface</li> </ul> </li> <li>Account for the specular BRDF during shading of the final selected sample</li> </ol> <p>For Bevy 0.18, I ended up spending most of my time on GI, so for DI I only did #4.</p> <p>#1 and #2 are tricky because the whole point of ReSTIR is to share samples across pixels. But for specular, samples are not (easily) shareable, as unlike the diffuse lobe, a strong source of light for pixel A might be outside the specular lobe of pixel B and have zero contribution.</p> <p>Maybe in practice it's not a big deal, or maybe using a second set of reservoirs for specular would help, but for now I've chosen to skip these, and treat all surfaces (including metallic ones) as purely diffuse during resampling.</p> <p>#3 requires an extra raytrace, which costs a lot of performance, and so again I've skipped it.</p> <p>When I get more time to experiment, I'll play around with these and see if any of them work well.</p> <p>So to sum it up, for DI, all I did was swap <code>albedo / PI</code> with a call to <code>evaluate_brdf()</code> during the final shading step.</p> <h3 id="diffuse-gi-changes">Diffuse GI Changes<a class="zola-anchor" href="#diffuse-gi-changes" aria-label="Anchor link for: diffuse-gi-changes" style="visibility: hidden;"></a> </h3> <p>Indirect lighting is where specular gets much more interesting.</p> <p>First off, as far as the world cache is concerned, all surfaces are diffuse only, with no specular lobe. This means that when you query the cache, you treat the query point as a diffuse surface. When updating cache entries, you also treat the cache point as a diffuse surface.</p> <p>For per-pixel GI, Solari splits the lighting calculations into two seperate passes - one for the diffuse lobe, and one for the specular lobe.</p> <p>The diffuse lobe is handled by the existing ReSTIR GI pass. ReSTIR GI resampling is exactly the same as in Bevy 0.17 - like DI, only the final shading changes.</p> <p>For the ReSTIR GI final shading step, we're still shading using only the diffuse lobe, but now we need to skip shading metallic pixels that don't have a diffuse lobe.</p> <figure> <img src="diffuse_gi.png" > <figcaption><p>Diffuse GI only - metallic surfaces are black because they don't have any diffuse contribution</p> </figcaption> </figure> <h3 id="specular-gi">Specular GI<a class="zola-anchor" href="#specular-gi" aria-label="Anchor link for: specular-gi" style="visibility: hidden;"></a> </h3> <p>The specular lobe, on the other hand, is handled by an entirely new dedicated specular GI pass.</p> <p>The basic structure of the pass looks like this (simplified):</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#268bd2;">let</span><span> surface </span><span style="color:#657b83;">= </span><span style="color:#859900;">load_from_gbuffer</span><span style="color:#657b83;">(</span><span>pixel_id</span><span style="color:#657b83;">)</span><span>; </span><span style="color:#268bd2;">let</span><span> wo </span><span style="color:#657b83;">= </span><span style="color:#859900;">normalize</span><span style="color:#657b83;">(</span><span>view.world_position </span><span style="color:#657b83;">-</span><span> surface.world_position</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span>var radiance: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;; </span><span>var wi: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;; </span><span style="color:#859900;">if</span><span> surface.material.roughness </span><span style="color:#657b83;">&gt; </span><span style="color:#6c71c4;">0.4 </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#586e75;">// Surface is very rough, reuse the ReSTIR GI reservoir </span><span> </span><span style="color:#268bd2;">let</span><span> gi_reservoir </span><span style="color:#657b83;">=</span><span> gi_reservoirs_a</span><span style="color:#657b83;">[</span><span>pixel_index</span><span style="color:#657b83;">]</span><span>; </span><span> wi </span><span style="color:#657b83;">= </span><span style="color:#859900;">normalize</span><span style="color:#657b83;">(</span><span>gi_reservoir.sample_point_world_position </span><span style="color:#657b83;">-</span><span> surface.world_position</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span> radiance </span><span style="color:#657b83;">=</span><span> gi_reservoir.radiance </span><span style="color:#657b83;">*</span><span> gi_reservoir.unbiased_contribution_weight; </span><span style="color:#657b83;">} </span><span style="color:#859900;">else </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#586e75;">// Surface is glossy or mirror-like, trace a new path </span><span> </span><span style="color:#268bd2;">let</span><span> wi_tangent </span><span style="color:#657b83;">= </span><span style="color:#859900;">sample_ggx_vndf</span><span style="color:#657b83;">(</span><span>wo_tangent, surface.material.roughness, </span><span style="color:#859900;">&amp;</span><span>rng</span><span style="color:#657b83;">)</span><span>; </span><span> wi </span><span style="color:#657b83;">=</span><span> wi_tangent.x </span><span style="color:#657b83;">*</span><span> T </span><span style="color:#657b83;">+</span><span> wi_tangent.y </span><span style="color:#657b83;">*</span><span> B </span><span style="color:#657b83;">+</span><span> wi_tangent.z </span><span style="color:#657b83;">*</span><span> N; </span><span> </span><span> </span><span style="color:#268bd2;">let</span><span> pdf </span><span style="color:#657b83;">= </span><span style="color:#859900;">ggx_vndf_pdf</span><span style="color:#657b83;">(</span><span>wo_tangent, wi_tangent, surface.material.roughness</span><span style="color:#657b83;">)</span><span>; </span><span> radiance </span><span style="color:#657b83;">= </span><span style="color:#859900;">trace_glossy_path</span><span style="color:#657b83;">(</span><span>surface.world_position, wi, </span><span style="color:#859900;">&amp;</span><span>rng</span><span style="color:#657b83;">) /</span><span> pdf; </span><span style="color:#657b83;">} </span><span> </span><span style="color:#586e75;">// Final shading </span><span style="color:#268bd2;">let</span><span> brdf </span><span style="color:#657b83;">= </span><span style="color:#859900;">evaluate_specular_brdf</span><span style="color:#657b83;">(</span><span>surface.world_normal, wo, wi, surface.material</span><span style="color:#859900;">...</span><span style="color:#657b83;">)</span><span>; </span><span style="color:#268bd2;">let</span><span> cos_theta </span><span style="color:#657b83;">= </span><span style="color:#859900;">saturate</span><span style="color:#657b83;">(</span><span style="color:#859900;">dot</span><span style="color:#657b83;">(</span><span>wi, surface.world_normal</span><span style="color:#657b83;">))</span><span>; </span><span>radiance </span><span style="color:#657b83;">*=</span><span> brdf </span><span style="color:#657b83;">*</span><span> cos_theta </span><span style="color:#657b83;">*</span><span> view.exposure; </span></code></pre> <p>For rough surfaces, the specular lobe is wide enough to approximate the diffuse lobe. We can just skip tracing any new rays, and reuse the ReSTIR GI sample directly. This saves a lot of performance, with minimal quality loss.</p> <p>For glossy or mirror surfaces, we need to trace a new path, following the best direction from importance sampling the GGX distribution.</p> <p>The full code for <code>trace_glossy_path</code> is a bit long, so I'm just going to link to the <a rel="nofollow noreferrer" href="https://github.com/bevyengine/bevy/blob/64c7bec4068aa063bfaa2cddcb90733f0e081cf8/crates/bevy_solari/src/realtime/specular_gi.wgsl#L71-L150">source on GitHub</a>.</p> <figure> <img src="specular_gi.png" > <figcaption><p>Specular GI only</p> </figcaption> </figure> <p>The basic outline is:</p> <ul> <li>We trace up to three bounces (after three bounces, the quality loss from skipping further bounces is minimal)</li> <li>Lighting comes from any of: hitting an emissive surface, NEE, or terminating in the world cache</li> <li>Each bounce samples the GGX distribution to find the next bounce direction (if the surface was rough enough, we would have terminated in the world cache - more on this in a second)</li> </ul> <p>It's essentially just a standard pathtracer, except with theortically higher coherence from always following the specular lobe.</p> <p>However, there are many subtle details that took me some time to figure out:</p> <ul> <li>Emissive contributions are skipped on the first bounce, as ReSTIR DI handles those paths</li> <li>We only query the world cache when hitting a rough surface (otherwise reflections would show the grid-like world cache)</li> <li>We skip NEE for mirror surfaces</li> <li>We apply MIS between the emissive contribution and the NEE contribution</li> </ul> <p>And there are still some large remaining issues:</p> <ul> <li>NEE is using entirely random samples, which leads to noisy reflections</li> <li>Glossy surfaces don't have any sort of path guiding to choose good directions, which also leads to noisy reflections</li> <li>No specular motion vectors to aid the denoiser leads to ghosting when objects in reflections move around</li> <li>Terminating in the world cache still leads to quality issues sometimes, especially on curved surfaces</li> </ul> <p>Specular motion vectors are something I plan to work on. I just need to spend some more time understanding the theory.</p> <p>As for improving sampling during the path trace, this is technically what ReSTIR PT was invented to solve. However, ReSTIR PT is also very performance intensive. I'm not convinced it's the path we should go down for Solari.</p> <p>I have some other ideas in mind for improving sampling, which I'll talk about at the end of this post.</p> <h2 id="energy-loss-bug">Energy Loss Bug<a class="zola-anchor" href="#energy-loss-bug" aria-label="Anchor link for: energy-loss-bug" style="visibility: hidden;"></a> </h2> <p>One of the big problems Solari had in 0.17 was overall energy loss compared to a pathtraced reference.</p> <p>At the time, I chalked it up to an inherent limitation of the world cache and moved on.</p> <p>However, while experimenting with various things this cycle, I realized that not only was it not due to the world cache, but DI also was losing energy, and not just GI!</p> <p>After many painful days of narrowing down the issue, I tracked it down to the <a href="https://jms55.github.io/posts/2025-12-27-solari-bevy-0-18/@posts/2025-09-20-solari-bevy-0-17/#light-tile-presampling">light tile code</a>, which was shared between DI and the world cache.</p> <p>The rgb9e5 packing of the light radiance I was doing did not have enough bits to encode the light, and so energy was being lost.</p> <p>The fix (thanks to @SparkyPotato) was to apply a log2-based encoding to the radiance before packing. This allocates more bits towards the values that human perception cares about, and less bits towards the values that we have a harder time seeing.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#268bd2;">fn </span><span style="color:#b58900;">pack_resolved_light_sample</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">sample</span><span>: ResolvedLightSample</span><span style="color:#657b83;">) </span><span>-&gt; ResolvedLightSamplePacked </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#859900;">return</span><span> ResolvedLightSamplePacked</span><span style="color:#657b83;">( </span><span> </span><span style="color:#586e75;">// ... </span><span> </span><span style="color:#859900;">vec3_to_rgb9e5_</span><span style="color:#657b83;">(</span><span style="color:#859900;">log2</span><span style="color:#657b83;">(</span><span>sample.radiance </span><span style="color:#657b83;">*</span><span> view.exposure </span><span style="color:#657b83;">+ </span><span style="color:#6c71c4;">1.0</span><span style="color:#657b83;">))</span><span>, </span><span> </span><span style="color:#586e75;">// ... </span><span> </span><span style="color:#657b83;">)</span><span>; </span><span style="color:#657b83;">} </span><span> </span><span style="color:#268bd2;">fn </span><span style="color:#b58900;">unpack_resolved_light_sample</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">packed</span><span>: ResolvedLightSamplePacked, </span><span style="color:#268bd2;">exposure</span><span>: </span><span style="color:#268bd2;">f32</span><span style="color:#657b83;">) </span><span>-&gt; ResolvedLightSample </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#859900;">return</span><span> ResolvedLightSample</span><span style="color:#657b83;">( </span><span> </span><span style="color:#586e75;">// ... </span><span> </span><span style="color:#657b83;">(</span><span style="color:#859900;">exp2</span><span style="color:#657b83;">(</span><span style="color:#859900;">rgb9e5_to_vec3_</span><span style="color:#657b83;">(</span><span>packed.radiance</span><span style="color:#657b83;">)) - </span><span style="color:#6c71c4;">1.0</span><span style="color:#657b83;">) /</span><span> exposure, </span><span> </span><span style="color:#586e75;">// ... </span><span> </span><span style="color:#657b83;">)</span><span>; </span><span style="color:#657b83;">} </span></code></pre> <p>With this fix, we're much closer to matching the reference.</p> <p><figure> <img src="energy_loss.png" > <figcaption><p>Energy loss due to poor encoding of radiance in light tiles</p> </figcaption> </figure> <figure> <img src="energy_loss_fixed.png" > <figcaption><p>Correct energy due to a better encoding</p> </figcaption> </figure> </p> <h2 id="resampling-correlations-bias">Resampling Correlations/Bias<a class="zola-anchor" href="#resampling-correlations-bias" aria-label="Anchor link for: resampling-correlations-bias" style="visibility: hidden;"></a> </h2> <p>One of the problems I wasn't able to solve in Bevy 0.17 was ReSTIR DI correlations introducing artifacts when denoising with DLSS-RR.</p> <figure> <img src="di_correlations.png" > <figcaption><p>Correlations from ReSTIR DI confusing the denoiser</p> </figcaption> </figure> <p>For ReSTIR GI, I was able to solve this with permutation sampling during temporal reuse. But for ReSTIR DI, trying to use permutation sampling lead to artifacts on shadow penumbras due to the way I was doing visibility reuse.</p> <figure> <img src="di_permutation_artifacts.png" > <figcaption><p>Visibility reuse messing up shadows when using permutation sampling</p> </figcaption> </figure> <p>I played with resampling ordering a bit more this cycle, and was able to come up with a solution.</p> <p>In Bevy 0.17, the whole ReSTIR DI algorithm looked like this:</p> <ol> <li>Initial sampling</li> <li>Test visibility of initial sample</li> <li>Temporal resampling</li> <li>Choose spatial sample</li> <li>Test visibility of spatial sample</li> <li>Spatial resampling</li> <li>Store final reservoir for next frame temporal reuse</li> <li>Final shading</li> </ol> <p>In Bevy 0.18, it now looks like this:</p> <ol> <li>Initial sampling</li> <li>Test visibility of initial sample</li> <li>Temporal resampling</li> <li>Choose spatial sample</li> <li>Spatial resampling</li> <li>Store final reservoir for next frame temporal reuse</li> <li>Test visibility of final reservoir</li> <li>Final shading</li> </ol> <p>The two big differences are:</p> <ul> <li>The second visibility test was moved from the spatial sample, to the final sample after all resampling steps</li> <li>The second visibility test is performed for the final shading, but is <em>not</em> fed forward for next frame's temporal resampling</li> </ul> <p>Moving the second visibility test from the spatial sample only to after all resampling was the key change.</p> <p>Before permutation sampling, it was ok to not re-test visibility for the temporal sample. The the light was visible to the pixel last frame, it's probably still visible this frame. Same for if the light was not visible last frame. When this assumption is wrong, e.g. for moving objects, it just led to a 1-frame lag in shadows that's almost unnoticable - an acceptable tradeoff.</p> <p>With permutation sampling, we can no longer trust that the visibility of the temporal sample is correct to reuse. The temporal sample now may come from a neighboring pixel, and at shadow pneumbras, the visibility is changing very frequently. It's no longer safe to reuse visibility, even on static scenes - we must retest visibility.</p> <p>The best way to test visibility without using extra ray traces is to move it right before shading of the final sample, where incorrect visibility would show up on screen</p> <p>The second change (not feeding forward the second visibility test to the next frame) is not strictly necessary, but keeps direct lighting unbiased.</p> <p>If you were to feed forward the second visibility test, the following might happen:</p> <ol> <li>A pixel checks visibility and finds that the light is occluded, setting the reservoir's contribution to 0</li> <li>The reservoir is stored for reuse next frame</li> <li>&lt;Next frame&gt;</li> <li>The reservoir is reused temporally for the same pixel (say that the initial sample happened to also be 0 contribution)</li> <li>The reservoir is reused spatially by a different pixel, which sees that it has zero contribution, and does not choose it via resampling <ul> <li>Except since this is a different pixel, the light is not occluded, and the sample should have had non-zero contribution!</li> </ul> </li> </ol> <p>Reusing visibility like this leads to bias in the form of shadows that "halo" objects, expanding further out than they should.</p> <figure> <img src="di_feed_forward_bad.png" > <figcaption><p>Feeding forward final visibility leads to over-shadowing artifacts</p> </figcaption> </figure> <p>While so far I've only talked about DI resampling, these changes actually apply to GI resampling too. Solari's ReSTIR GI pass now uses the same modified ordering as the DI resampling, which fixes indirect shadow artifacts. It's just that incorrect shadow edges are not as obvious with GI as they are for DI, so it's less important.</p> <p>One final note on DI resampling: like we were doing with ReSTIR GI, we now use the balance heuristic for ReSTIR DI resampling, instead of constant MIS weights. This makes a small difference (hence why I never noticed it until now), but it <em>does</em> slightly increase emissive light brightness, matching the pathtraced reference better.</p> <figure> <img src="di_good_018.png" > <figcaption><p>Final ReSTIR DI output after all the changes</p> </figcaption> </figure> <h2 id="world-cache-improvements">World Cache Improvements<a class="zola-anchor" href="#world-cache-improvements" aria-label="Anchor link for: world-cache-improvements" style="visibility: hidden;"></a> </h2> <p>The world cache is the oldest part of Solari - it was copied nearly wholesale from my original prototype three years ago, without any real changes in Bevy 0.17 except for the addition of the LOD system.</p> <p>Because of this, it was also the jankiest part of Solari.</p> <p>As I started testing on more complex scenes, it became clear that there were significant problems:</p> <ul> <li>On the cornell box scene, it worked fine.</li> <li>On the PICA PICA scene, it worked ok when conditions were static, but under dynamic conditions the GI was fairly laggy.</li> <li>On Bistro, performance wasn't good, especially as you started moving around the scene.</li> </ul> <p>In Bevy 0.18, I spent a large amount of time fixing these issues.</p> <h3 id="cache-lag">Cache Lag<a class="zola-anchor" href="#cache-lag" aria-label="Anchor link for: cache-lag" style="visibility: hidden;"></a> </h3> <p>In the PICA PICA scene, if you turn off all the lights, it would take a good while for the light to completely fade. The reason being that: A) the world cache samples itself, recursively propagating light around the scene for a while, and B) the exponential blend between new and current radiance samples keeps the old radiance around for a decent amount of time.</p> <video style="max-width: 100%; margin: var(--gap) var(--gap) 0 var(--gap); border-radius: 6px;" controls> <source src="gi_lag.mp4" type="video/mp4"> </video> <center> <p><em>Laggy GI with fixed blend factor</em></p> </center> <p>To combat this, we could increase the blend factor, to keep the lighting responsive. However that would lead to way more noise and instability under static lighting conditions.</p> <p>What we really need is an adaptive blend factor, which <a rel="nofollow noreferrer" href="https://bsky.app/profile/gboisse.bsky.social/post/3m5blga3ftk2a">Guillaume Boissé</a> was kind enough to share with me.</p> <p>We keep track of the change in luminance between frames, and use that to compute an adaptive blend factor.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#268bd2;">let</span><span> old_radiance </span><span style="color:#657b83;">=</span><span> world_cache_radiance</span><span style="color:#657b83;">[</span><span>cell_index</span><span style="color:#657b83;">]</span><span>; </span><span style="color:#268bd2;">let</span><span> new_radiance </span><span style="color:#657b83;">=</span><span> world_cache_active_cells_new_radiance</span><span style="color:#657b83;">[</span><span>active_cell_id.x</span><span style="color:#657b83;">]</span><span>; </span><span style="color:#268bd2;">let</span><span> luminance_delta </span><span style="color:#657b83;">=</span><span> world_cache_luminance_deltas</span><span style="color:#657b83;">[</span><span>cell_index</span><span style="color:#657b83;">]</span><span>; </span><span> </span><span style="color:#586e75;">// https://bsky.app/profile/gboisse.bsky.social/post/3m5blga3ftk2a </span><span style="color:#268bd2;">let</span><span> sample_count </span><span style="color:#657b83;">= </span><span style="color:#859900;">min</span><span style="color:#657b83;">(</span><span>old_radiance.a </span><span style="color:#657b83;">+ </span><span style="color:#6c71c4;">1.0</span><span>, </span><span style="color:#cb4b16;">WORLD_CACHE_MAX_TEMPORAL_SAMPLES</span><span style="color:#657b83;">)</span><span>; </span><span style="color:#268bd2;">let</span><span> alpha </span><span style="color:#657b83;">= </span><span style="color:#859900;">abs</span><span style="color:#657b83;">(</span><span>luminance_delta</span><span style="color:#657b83;">) / </span><span style="color:#859900;">max</span><span style="color:#657b83;">(</span><span style="color:#859900;">luminance</span><span style="color:#657b83;">(</span><span>old_radiance.rgb</span><span style="color:#657b83;">)</span><span>, </span><span style="color:#6c71c4;">0.001</span><span style="color:#657b83;">)</span><span>; </span><span style="color:#268bd2;">let</span><span> max_sample_count </span><span style="color:#657b83;">= </span><span style="color:#859900;">mix</span><span style="color:#657b83;">(</span><span style="color:#cb4b16;">WORLD_CACHE_MAX_TEMPORAL_SAMPLES</span><span>, </span><span style="color:#6c71c4;">1.0</span><span>, </span><span style="color:#859900;">pow</span><span style="color:#657b83;">(</span><span style="color:#859900;">saturate</span><span style="color:#657b83;">(</span><span>alpha</span><span style="color:#657b83;">)</span><span>, </span><span style="color:#6c71c4;">1.0 </span><span style="color:#657b83;">/ </span><span style="color:#6c71c4;">8.0</span><span style="color:#657b83;">))</span><span>; </span><span style="color:#268bd2;">let</span><span> blend_amount </span><span style="color:#657b83;">= </span><span style="color:#6c71c4;">1.0 </span><span style="color:#657b83;">/ </span><span style="color:#859900;">min</span><span style="color:#657b83;">(</span><span>sample_count, max_sample_count</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> blended_radiance </span><span style="color:#657b83;">= </span><span style="color:#859900;">mix</span><span style="color:#657b83;">(</span><span>old_radiance.rgb, new_radiance, blend_amount</span><span style="color:#657b83;">)</span><span>; </span><span style="color:#268bd2;">let</span><span> blended_luminance_delta </span><span style="color:#657b83;">= </span><span style="color:#859900;">mix</span><span style="color:#657b83;">(</span><span>luminance_delta, </span><span style="color:#859900;">luminance</span><span style="color:#657b83;">(</span><span>blended_radiance</span><span style="color:#657b83;">) - </span><span style="color:#859900;">luminance</span><span style="color:#657b83;">(</span><span>old_radiance.rgb</span><span style="color:#657b83;">)</span><span>, </span><span style="color:#6c71c4;">1.0 </span><span style="color:#657b83;">/ </span><span style="color:#6c71c4;">8.0</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span>world_cache_radiance</span><span style="color:#657b83;">[</span><span>cell_index</span><span style="color:#657b83;">] = </span><span style="color:#859900;">vec4</span><span style="color:#657b83;">(</span><span>blended_radiance, sample_count</span><span style="color:#657b83;">)</span><span>; </span><span>world_cache_luminance_deltas</span><span style="color:#657b83;">[</span><span>cell_index</span><span style="color:#657b83;">] =</span><span> blended_luminance_delta; </span></code></pre> <p>Now GI is stable under static conditions, but reacts pretty fast under dynamic conditions. It's not perfect - we're still heavily relying on temporal accumulation and denoising - but it's a heck of a lot better.</p> <video style="max-width: 100%; margin: var(--gap) var(--gap) 0 var(--gap); border-radius: 6px;" controls> <source src="gi_less_lag.mp4" type="video/mp4"> </video> <center> <p><em>Less-laggy GI with adaptive blend factor</em></p> </center> <p>Once again, thanks a ton to Guillaume Boissé for this code! I was struggling to come up with something myself, and this perfectly solved my problem!</p> <h3 id="cache-lifetimes">Cache Lifetimes<a class="zola-anchor" href="#cache-lifetimes" aria-label="Anchor link for: cache-lifetimes" style="visibility: hidden;"></a> </h3> <p>While Solari was working great on smaller scenes, on larger scenes like Bistro, performance was much worse.</p> <p>The world cache update pass was taking way too long, and worse, as I moved around the scene, it got worse and worse.</p> <p>The reason is that since cache entries sample each other (in order to get multibounce lighting), they were keeping each other alive forever. So once you stepped into an area, it would forever be present in the world cache, even when you left the area.</p> <p>The solution (thanks to @IsaacSM and @NthTensor) ended up being pretty simple!</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#268bd2;">fn </span><span style="color:#b58900;">query_world_cache</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">world_position</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span style="color:#268bd2;">world_normal</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span style="color:#268bd2;">view_position</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span style="color:#268bd2;">cell_lifetime</span><span>: </span><span style="color:#268bd2;">u32</span><span>, </span><span style="color:#268bd2;">rng</span><span>: ptr&lt;function, </span><span style="color:#268bd2;">u32</span><span>&gt;</span><span style="color:#657b83;">) </span><span>-&gt; vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt; </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#268bd2;">let</span><span> cell_size </span><span style="color:#657b83;">= </span><span style="color:#859900;">get_cell_size</span><span style="color:#657b83;">(</span><span>world_position, view_position</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span> </span><span style="color:#268bd2;">let</span><span> world_position_quantized </span><span style="color:#657b83;">= </span><span>bitcast&lt;vec3&lt;</span><span style="color:#268bd2;">u32</span><span>&gt;&gt;</span><span style="color:#657b83;">(</span><span style="color:#859900;">quantize_position</span><span style="color:#657b83;">(</span><span>world_position, cell_size</span><span style="color:#657b83;">))</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> world_normal_quantized </span><span style="color:#657b83;">= </span><span>bitcast&lt;vec3&lt;</span><span style="color:#268bd2;">u32</span><span>&gt;&gt;</span><span style="color:#657b83;">(</span><span style="color:#859900;">quantize_normal</span><span style="color:#657b83;">(</span><span>world_normal</span><span style="color:#657b83;">))</span><span>; </span><span> var key </span><span style="color:#657b83;">= </span><span style="color:#859900;">compute_key</span><span style="color:#657b83;">(</span><span>world_position_quantized, world_normal_quantized</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> checksum </span><span style="color:#657b83;">= </span><span style="color:#859900;">compute_checksum</span><span style="color:#657b83;">(</span><span>world_position_quantized, world_normal_quantized</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span> </span><span style="color:#859900;">for </span><span style="color:#657b83;">(</span><span>var i </span><span style="color:#657b83;">=</span><span> 0u; i </span><span style="color:#657b83;">&lt; </span><span style="color:#cb4b16;">WORLD_CACHE_MAX_SEARCH_STEPS</span><span>; i</span><span style="color:#657b83;">++) { </span><span> </span><span style="color:#268bd2;">let</span><span> existing_checksum </span><span style="color:#657b83;">=</span><span> atomicCompareExchangeWeak</span><span style="color:#657b83;">(</span><span style="color:#859900;">&amp;</span><span>world_cache_checksums</span><span style="color:#657b83;">[</span><span>key</span><span style="color:#657b83;">]</span><span>, </span><span style="color:#cb4b16;">WORLD_CACHE_EMPTY_CELL</span><span>, checksum</span><span style="color:#657b83;">)</span><span>.old_value; </span><span> </span><span> </span><span style="color:#586e75;">// Cell already exists or is empty - reset lifetime </span><span> </span><span style="color:#859900;">if</span><span> existing_checksum </span><span style="color:#657b83;">==</span><span> checksum </span><span style="color:#859900;">||</span><span> existing_checksum </span><span style="color:#657b83;">== </span><span style="color:#cb4b16;">WORLD_CACHE_EMPTY_CELL </span><span style="color:#657b83;">{ </span><span style="color:#859900;">#</span><span>ifndef </span><span style="color:#cb4b16;">WORLD_CACHE_QUERY_ATOMIC_MAX_LIFETIME </span><span> atomicStore</span><span style="color:#657b83;">(</span><span style="color:#859900;">&amp;</span><span>world_cache_life</span><span style="color:#657b83;">[</span><span>key</span><span style="color:#657b83;">]</span><span>, cell_lifetime</span><span style="color:#657b83;">)</span><span>; </span><span style="color:#859900;">#else </span><span> atomicMax</span><span style="color:#657b83;">(</span><span style="color:#859900;">&amp;</span><span>world_cache_life</span><span style="color:#657b83;">[</span><span>key</span><span style="color:#657b83;">]</span><span>, cell_lifetime</span><span style="color:#657b83;">)</span><span>; </span><span style="color:#859900;">#</span><span>endif </span><span> </span><span style="color:#657b83;">} </span><span> </span><span> </span><span style="color:#859900;">if</span><span> existing_checksum </span><span style="color:#657b83;">==</span><span> checksum </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#586e75;">// Cache entry already exists - get radiance </span><span> </span><span style="color:#859900;">return</span><span> world_cache_radiance</span><span style="color:#657b83;">[</span><span>key</span><span style="color:#657b83;">]</span><span>.rgb; </span><span> </span><span style="color:#657b83;">} </span><span style="color:#859900;">else if</span><span> existing_checksum </span><span style="color:#657b83;">== </span><span style="color:#cb4b16;">WORLD_CACHE_EMPTY_CELL </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#586e75;">// Cell is empty - initialize it </span><span> world_cache_geometry_data</span><span style="color:#657b83;">[</span><span>key</span><span style="color:#657b83;">]</span><span>.world_position </span><span style="color:#657b83;">=</span><span> world_position; </span><span> world_cache_geometry_data</span><span style="color:#657b83;">[</span><span>key</span><span style="color:#657b83;">]</span><span>.world_normal </span><span style="color:#657b83;">=</span><span> world_normal; </span><span> </span><span style="color:#859900;">return vec3</span><span style="color:#657b83;">(</span><span style="color:#6c71c4;">0.0</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#657b83;">} </span><span style="color:#859900;">else </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#586e75;">// Collision - linear probe to next entry </span><span> key </span><span style="color:#657b83;">+=</span><span> 1u; </span><span> </span><span style="color:#657b83;">} </span><span> </span><span style="color:#657b83;">} </span><span> </span><span> </span><span style="color:#859900;">return vec3</span><span style="color:#657b83;">(</span><span style="color:#6c71c4;">0.0</span><span style="color:#657b83;">)</span><span>; </span><span style="color:#657b83;">} </span></code></pre> <p>When a ReSTIR GI or specular GI pixel is querying the world cache, nothing has changed. We still perform <code>atomicStore(&amp;world_cache_life[key], WORLD_CACHE_CELL_LIFETIME)</code>, resetting the lifetime of the queried cache entry.</p> <p>However when a world cache entry is querying another entry during the world cache update pass, the algorithm changes.</p> <p>Now we're instead doing <code>atomicMax(&amp;world_cache_life[key], cell_lifetime_of_querier)</code>.</p> <p>When the camera is in a given area, ReSTIR GI and specular GI pixels will reset world cache entries to their max lifetime. Then during world cache update the next frame, those world cache entries will copy their max lifetime to other entries nearby.</p> <p>However once the camera moves away from the area, there will be no more pixels querying the world cache. When world cache entries go to query each other, they'll copy over their current lifetimes (which are decaying each frame). After a couple of frames, all the world cache entries in the area will go dead.</p> <p>No more performance wasted on areas the camera will never see!</p> <h3 id="misc-cache-tweaks">Misc Cache Tweaks<a class="zola-anchor" href="#misc-cache-tweaks" aria-label="Anchor link for: misc-cache-tweaks" style="visibility: hidden;"></a> </h3> <figure> <img src="bistro_trace.png" > <figcaption><p>NSight trace of Bistro showing an expensive and spiky world cache update</p> </figcaption> </figure> <p>Finally, I tweaked a bunch of other things based on my testing in Bistro:</p> <ul> <li>Limited indirect rays sent from cache entries during the world cache update step to a max of 50 meters - This prevents long raytraces from holding up the whole threadgroup, improving performance, and prevents far-away samples from influencing the cache, reducing variance.</li> <li>Switched the world cache update workgroup size from 1024 to 64 threads - Much more appropriate for raytracing workloads. This fixed some really weird GPU usage traces I was seeing in NSight.</li> <li>Make the world cache transition LODs faster - In a large scene like Bistro, we had way too many cache entries for far-away areas.</li> </ul> <p>Combined, these changes brought the world cache update step from 1.42ms to a much more reasonable 0.09ms in Bistro.</p> <h2 id="what-s-next">What's Next<a class="zola-anchor" href="#what-s-next" aria-label="Anchor link for: what-s-next" style="visibility: hidden;"></a> </h2> <p>This blog post just scratched the surface of the past three months. I didn't even cover stuff I tried that didn't work out; but I'm a little sick of writing this and want to get something posted rather than spend a month perfecting it.</p> <p>So with that, let's talk about the future!</p> <p>Solari has improved a ton in these past three months, but of course there's still more work to be done!</p> <h3 id="general-improvements">General Improvements<a class="zola-anchor" href="#general-improvements" aria-label="Anchor link for: general-improvements" style="visibility: hidden;"></a> </h3> <p>First, some general issues carrying over from my last blog post:</p> <ul> <li>Feature parity for things like skinned and morphed meshes, alpha masks, transparent materials, support for more types of light sources, etc still need implementing.</li> <li>Solari is still NVIDIA only in practice due to relying on DLSS-RR (FSR-RR <em>did</em> release since my last blog post, but to my immense sadness is currently DirectX 12 only - no Vulkan support. AMD employees - please reach out!)</li> <li>Shader execution reordering (blocked on wgpu support) and half-resolution GI (on top of DLSS upscaling) would bring major performance improvements.</li> </ul> <h3 id="sampling-improvements">Sampling Improvements<a class="zola-anchor" href="#sampling-improvements" aria-label="Anchor link for: sampling-improvements" style="visibility: hidden;"></a> </h3> <p>The other major improvements I want to make are to sampling quality. There are a bunch of different papers and techniques I want to experiment with.</p> <h4 id="di-sampling">DI Sampling<a class="zola-anchor" href="#di-sampling" aria-label="Anchor link for: di-sampling" style="visibility: hidden;"></a> </h4> <p>For DI, our initial sampling is totally random, which is pretty terrible. The minimal improvement would be to build a <a rel="nofollow noreferrer" href="https://blog.traverseresearch.nl/fast-cdf-generation-on-the-gpu-for-light-picking-5c50b97c552b">global CDF</a> of lights in the scene. A better, but much more complex and expensive method would be to build the <a rel="nofollow noreferrer" href="https://gpuopen.com/download/Hierarchical_Light_Sampling_with_Accurate_Spherical_Gaussian_Lighting.pdf">spherical gaussian light tree</a> I mentioned in my last post.</p> <p>However, we can get even better results by building and caching a <em>local</em> distribution of lights at discrete points in the scene.</p> <p><a rel="nofollow noreferrer" href="https://www.yiningkarlli.com/projects/cachepoints.html">Disney's Hyperion renderer</a> uses a set of randomly traced candidate paths to pick cache points in the scene, and estimates both the unshadowed light contribution and visibility of a light at each cache point, stored as a small CDF table.</p> <p>Unreal Engine's <a rel="nofollow noreferrer" href="https://advances.realtimerendering.com/s2025/content/MegaLights_Stochastic_Direct_Lighting_2025.pdf">MegaLights</a> uses a similar idea in screen space. @SparkyPotato has been <a rel="nofollow noreferrer" href="https://github.com/bevyengine/bevy/pull/21366">experimenting with a world-space equivalent</a> of the idea in Solari.</p> <p>Unlike Disney's cache points, we already have a good way to discretize the scene - the spatial hashing we use for the GI world cache. We're already sampling DI at each world cache cell - why not additionally build up a CDF, and use that to improve light sampling for ReSTIR DI and Specular GI NEE? Or, we could go the other way, and splat each per-pixel sample from ReSTIR DI and Specular GI NEE back into the world cache. Or... both? Lots of things to experiment with (and not enough time!)</p> <p>Lastly, <a rel="nofollow noreferrer" href="https://ishaanshah.xyz/risltc">linearly-transformed cosines</a> (LTCs) are a promising avenue to explore to improve resampling quality.</p> <h4 id="gi-sampling">GI Sampling<a class="zola-anchor" href="#gi-sampling" aria-label="Anchor link for: gi-sampling" style="visibility: hidden;"></a> </h4> <p>Like DI, we can also improve our GI sampling.</p> <p>Recently, realtime pathtracing research has been retracing (pun intended) the steps that offline pathtracing took, and researching path guiding techniques. There are several promising avenues to explore:</p> <ul> <li><a rel="nofollow noreferrer" href="https://www.lalber.org/2025/04/markov-chain-path-guiding">Markov chain resampling</a></li> <li><a rel="nofollow noreferrer" href="https://research.nvidia.com/labs/rtr/publication/zeng2025restirpg">ReSTIR PT splatting</a></li> <li><a rel="nofollow noreferrer" href="https://uni-tuebingen.de/fakultaeten/mathematisch-naturwissenschaftliche-fakultaet/fachbereiche/informatik/lehrstuehle/computergrafik/lehrstuhl/veroeffentlichungen/robust-fitting-of-parallax-aware-mixtures-for-path-guiding">Parallax-aware vMF mixtures</a></li> </ul> <p>All three techniques share the same basic idea: build a local distribution of incident light in world-space, stored as a combination of several vMF lobes. The main differences are how samples get fed into the distribution, and the exact steps used to update the distribution.</p> <p>It's the same exact idea as improving DI sampling - discretize to world space, estimate a local distribution, use for sampling. And again the same questions arise - using a small set of candidate paths traced from the camera and splatting into the cache; build on top of the existing cache update pass; both?</p> <p>The same questions also apply to the world irradiance cache itself. Currently the cache is updated in a dedicated pass at the start of the frame, sampling from a fixed point for each active cache cell. Other caches like NVIDIA's <a rel="nofollow noreferrer" href="https://github.com/NVIDIA-RTX/SHARC">SHARC</a>, NVIDIA's <a rel="nofollow noreferrer" href="https://research.nvidia.com/publication/2021-06_real-time-neural-radiance-caching-path-tracing">NRC</a> and AMD's <a rel="nofollow noreferrer" href="https://gpuopen.com/manuals/fsr_sdk/techniques/radiance-cache/#training-the-cache">FSR Radiance Caching</a> all splat candidate paths traced from the camera.</p> <p>Lots of room for experimentation.</p> <p>Additionally as a final note on GI quality, currently one of Solari's worst form of artifacts is GI light leaks on the edges of objects. While hashing the surface normal helps, on curved surfaces and corners, it's not a perfect solution.</p> <p>And it's actually very easy to identify the cases where this happens. Light leaks tend to occur when the length of the ray querying the cache is less than the size of the cache cell.</p> <p>I tried both sampling last frame's screen-space texture, and <a rel="nofollow noreferrer" href="https://gboisse.github.io/posts/this-is-us">hashing <code>ray_t &lt; cell_size</code></a>, but unfortunately neither helped. More experimentation is needed.</p> <h4 id="specular">Specular<a class="zola-anchor" href="#specular" aria-label="Anchor link for: specular" style="visibility: hidden;"></a> </h4> <p>Specular DI and GI in Solari 0.18 was a pretty initial implementation, and as I've mentioned throughout the post, there's a lot that could be improved.</p> <ul> <li>Specular motion vectors are not implemented, so mirror and glossy indirect reflections can have ghosting. <ul> <li>I need to implement either <a rel="nofollow noreferrer" href="https://developer.nvidia.com/blog/rendering-perfect-reflections-and-refractions-in-path-traced-games">"Rendering Perfect Reflections and Refractions in Path-Traced Games"</a> or <a rel="nofollow noreferrer" href="https://zheng95z.github.io/publications/trmv21">"Temporally Reliable Motion Vectors for Real-time Ray Tracing"</a>.</li> </ul> </li> <li>Local light sampling would greatly help NEE quality for specular GI. Currently, NEE is heavily undersampled. DLSS-RR does its best, but you can see some cross-stitch patterns on glossy reflections where the denoiser is struggling.</li> <li>Path guiding for GI would help with tracing glossy paths.</li> <li>Experimenting with ReSTIR for both specular DI and specular GI.</li> <li>Potentially terminating a specular GI path into the world cache sooner based on total roughness/cone-spread of the path.</li> </ul> <h2 id="results">Results<a class="zola-anchor" href="#results" aria-label="Anchor link for: results" style="visibility: hidden;"></a> </h2> <p>All results were captured on an RTX 3080 locked to base clocks in NSight, at 1600x900 upscaled to 3200x1800 via DLSS-RR.</p> <h3 id="pica-pica">PICA PICA<a class="zola-anchor" href="#pica-pica" aria-label="Anchor link for: pica-pica" style="visibility: hidden;"></a> </h3> <p><figure> <img src="pica_pica_realtime.png" > <figcaption><p>PICA PICA - Solari realtime</p> </figcaption> </figure> <figure> <img src="pica_pica_reference.png" > <figcaption><p>PICA PICA - Pathraced reference (ignore the black noise - it's a bug)</p> </figcaption> </figure> </p> <h3 id="bistro">Bistro<a class="zola-anchor" href="#bistro" aria-label="Anchor link for: bistro" style="visibility: hidden;"></a> </h3> <p><figure> <img src="bistro_realtime.png" > <figcaption><p>Bistro - Solari realtime (ignore the foilage - Solari doesn't support alpha masks yet)</p> </figcaption> </figure> <figure> <img src="bistro_reference.png" > <figcaption><p>Bistro - Pathraced reference</p> </figcaption> </figure> </p> <h3 id="dragons">Dragons<a class="zola-anchor" href="#dragons" aria-label="Anchor link for: dragons" style="visibility: hidden;"></a> </h3> <p><figure> <img src="dragons_realtime.png" > <figcaption><p>Dragons - Solari realtime</p> </figcaption> </figure> <figure> <img src="dragons_reference.png" > <figcaption><p>Dragons - Pathraced reference</p> </figcaption> </figure> </p> <h3 id="cornell-box">Cornell Box<a class="zola-anchor" href="#cornell-box" aria-label="Anchor link for: cornell-box" style="visibility: hidden;"></a> </h3> <p><figure> <img src="cornell_box_realtime.png" > <figcaption><p>Cornell Box - Solari realtime</p> </figcaption> </figure> <figure> <img src="cornell_box_reference.png" > <figcaption><p>Cornell Box - Pathraced reference</p> </figcaption> </figure> </p> <h3 id="performance">Performance<a class="zola-anchor" href="#performance" aria-label="Anchor link for: performance" style="visibility: hidden;"></a> </h3> <table><thead><tr><th style="text-align: center">Pass</th><th style="text-align: center">PICA PICA (ms)</th><th style="text-align: center">Bistro (ms)</th><th style="text-align: center">Dragons (ms)</th><th style="text-align: center">Cornell Box (ms)</th></tr></thead><tbody> <tr><td style="text-align: center">Presample Light Tiles</td><td style="text-align: center">0.03</td><td style="text-align: center">0.09</td><td style="text-align: center">0.02</td><td style="text-align: center">0.02</td></tr> <tr><td style="text-align: center">World Cache: Decay Cells</td><td style="text-align: center">0.01</td><td style="text-align: center">0.02</td><td style="text-align: center">0.02</td><td style="text-align: center">0.01</td></tr> <tr><td style="text-align: center">World Cache: Compaction P1</td><td style="text-align: center">0.04</td><td style="text-align: center">0.04</td><td style="text-align: center">0.04</td><td style="text-align: center">0.04</td></tr> <tr><td style="text-align: center">World Cache: Compaction P2</td><td style="text-align: center">0.01</td><td style="text-align: center">0.01</td><td style="text-align: center">0.01</td><td style="text-align: center">0.01</td></tr> <tr><td style="text-align: center">World Cache: Write Active Cells</td><td style="text-align: center">0.01</td><td style="text-align: center">0.02</td><td style="text-align: center">0.01</td><td style="text-align: center">0.01</td></tr> <tr><td style="text-align: center">World Cache: Sample Lighting</td><td style="text-align: center">0.03</td><td style="text-align: center">0.66</td><td style="text-align: center">0.05</td><td style="text-align: center">0.03</td></tr> <tr><td style="text-align: center">World Cache: Blend New Samples</td><td style="text-align: center">0.01</td><td style="text-align: center">0.03</td><td style="text-align: center">0.01</td><td style="text-align: center">0.01</td></tr> <tr><td style="text-align: center">ReSTIR DI: Initial + Temporal</td><td style="text-align: center">0.28</td><td style="text-align: center">1.89</td><td style="text-align: center">0.39</td><td style="text-align: center">0.22</td></tr> <tr><td style="text-align: center">ReSTIR DI: Spatial + Shade</td><td style="text-align: center">0.18</td><td style="text-align: center">1.06</td><td style="text-align: center">0.23</td><td style="text-align: center">0.16</td></tr> <tr><td style="text-align: center">ReSTIR GI: Initial + Temporal</td><td style="text-align: center">0.30</td><td style="text-align: center">2.28</td><td style="text-align: center">0.80</td><td style="text-align: center">0.29</td></tr> <tr><td style="text-align: center">ReSTIR GI: Spatial + Shade</td><td style="text-align: center">0.31</td><td style="text-align: center">1.37</td><td style="text-align: center">0.56</td><td style="text-align: center">0.27</td></tr> <tr><td style="text-align: center">Specular GI</td><td style="text-align: center">0.61</td><td style="text-align: center">0.35</td><td style="text-align: center">0.31</td><td style="text-align: center">0.09</td></tr> <tr><td style="text-align: center">DLSS-RR: Copy Inputs From GBuffer</td><td style="text-align: center">0.04</td><td style="text-align: center">0.08</td><td style="text-align: center">0.05</td><td style="text-align: center">0.04</td></tr> <tr><td style="text-align: center">DLSS-RR</td><td style="text-align: center">6.10</td><td style="text-align: center">6.16</td><td style="text-align: center">6.08</td><td style="text-align: center">6.07</td></tr> <tr><td style="text-align: center"><strong>Total</strong></td><td style="text-align: center"><strong>7.96</strong></td><td style="text-align: center"><strong>14.06</strong></td><td style="text-align: center"><strong>8.58</strong></td><td style="text-align: center"><strong>7.27</strong></td></tr> </tbody></table> Realtime Raytracing in Bevy 0.17 (Solari) 2025-09-20T00:00:00+00:00 2025-09-20T00:00:00+00:00 Unknown https://jms55.github.io/posts/2025-09-20-solari-bevy-0-17/ <p>Lighting a scene is hard! Anyone who's tried to make a 3D scene look good knows the frustration of placing light probes, tweaking shadow cascades, and trying to figure out why their materials don't look quite right.</p> <p>Over the past few years, real-time raytracing has gone from a research curiosity to a shipping feature in major game engines, promising to solve many of these problems by simulating how light actually behaves.</p> <p>With the release of v0.17, <a rel="nofollow noreferrer" href="https://bevy.org">Bevy</a> now joins the club with experimental support for hardware raytracing!</p> <video style="max-width: 100%; margin: var(--gap) var(--gap) 0 var(--gap); border-radius: 6px;" controls> <source src="solari_recording.mp4" type="video/mp4"> </video> <center> <p><em><a rel="nofollow noreferrer" href="https://github.com/SEED-EA/pica-pica-assets">PICA PICA scene by SEED</a></em></p> </center> <p>Try it out yourself:</p> <pre data-lang="bash" style="background-color:#002b36;color:#839496;" class="language-bash "><code class="language-bash" data-lang="bash"><span style="color:#b58900;">git</span><span> clone https://github.com/bevyengine/bevy </span><span style="color:#859900;">&amp;&amp; cd</span><span> bevy </span><span style="color:#b58900;">git</span><span> checkout release-0.17.0 </span><span style="color:#b58900;">cargo</span><span> run</span><span style="color:#268bd2;"> --release --example</span><span> solari</span><span style="color:#268bd2;"> --features</span><span> bevy_solari,https </span><span style="color:#586e75;"># Optionally setup DLSS support for NVIDIA GPUs following https://github.com/bevyengine/dlss_wgpu?tab=readme-ov-file#downloading-the-dlss-sdk </span><span style="color:#b58900;">cargo</span><span> run</span><span style="color:#268bd2;"> --release --example</span><span> solari</span><span style="color:#268bd2;"> --features</span><span> bevy_solari,https,dlss </span></code></pre> <h2 id="introduction">Introduction<a class="zola-anchor" href="#introduction" aria-label="Anchor link for: introduction" style="visibility: hidden;"></a> </h2> <p>Back in 2023, I <a rel="nofollow noreferrer" href="https://github.com/bevyengine/bevy/pull/10000">started</a> a project I called Solari to integrate hardware raytracing into Bevy's rendering pipeline. I was experimenting with <a rel="nofollow noreferrer" href="https://youtu.be/2GYXuM10riw">Lumen</a>-style screen space probes for global illumination, and later extended it to use <a rel="nofollow noreferrer" href="https://radiance-cascades.com">radiance cascades</a>.</p> <p>These techniques, while theoretically sound, proved challenging to use in practice. Screen space probes were tricky to get good quality out of (reusing and reprojecting the same probe across multiple pixels is hard!), and radiance cascades brought its own set of artifacts and performance costs.</p> <p>On top of the algorithmic challenges, the ecosystem simply wasn't ready. Wgpu's raytracing support existed only as a work-in-progress PR that never got merged upstream. Maintaining a fork of wgpu (and by extension, Bevy) was time-consuming and unsustainable. After months of dealing with these challenges, I shelved the project.</p> <p>In the 2 years since, I've learned a bunch more, raytracing has been upstreamed into wgpu, and raytracing algorithms have gotten much more developed. I've restarted the project with a new approach (ReSTIR, DLSS-RR), and soon it will be released as an official Bevy plugin!</p> <p>In this post, I'll be doing a frame breakdown of how Solari works in Bevy 0.17, why I made certain choices, some of the challenges I faced, and some of the issues I've yet to solve.</p> <h2 id="why-raytracing-for-bevy">Why Raytracing for Bevy?<a class="zola-anchor" href="#why-raytracing-for-bevy" aria-label="Anchor link for: why-raytracing-for-bevy" style="visibility: hidden;"></a> </h2> <p>Before we start, I think it's fair to ask why an "indie" game engine needs high-end raytracing features that requires an expensive graphics card. The answer comes from my own experience learning 3D graphics.</p> <p>Back when I was a teenager experimenting with small 3D games in Godot, I had a really hard time figuring out why my lighting looked so bad. Metallic objects didn't look reflective, scenes felt flat, and everything just looked wrong compared to the games I was playing.</p> <p>I didn't understand that I was missing indirect light, proper reflections, and accurate shadows - I had no idea I was supposed to bake lighting.</p> <p>This is the core problem that raytracing solves for indie developers. Even if not all players have hardware capable of running ray-traced effects, having a reference implementation of what lighting is <em>supposed</em> to look like is incredibly valuable.</p> <p>With fully dynamic global illumination, reflections, shadows, and direct lighting, developers can see how their scenes should be lit. Then they can work backwards to replicate those results with baked lighting, screen-space techniques, and other less performance-intensive approximations.</p> <p>Without that reference, it's really hard to know what you're missing or how to improve your lighting setup. Raytracing provides the ground truth that other techniques are trying to approximate.</p> <p>Additionally, hardware is advancing all the time. Five years ago, raytracing was much less widespread than today. If you start developing a new game today with a 3-4 year lead time, raytracing is probably going to be even more common by the time you're ready to release it. Solari was in large part designed as a foward-looking rendering system.</p> <p>There's also the practical consideration that if Bevy ever wants to attract AAA game developers, we need these kinds of systems. Recent AAA games like <a rel="nofollow noreferrer" href="https://advances.realtimerendering.com/s2025/content/SOUSA_SIGGRAPH_2025_Final.pdf">DOOM: The Dark Ages</a> and <a rel="nofollow noreferrer" href="https://intro-to-restir.cwyman.org/presentations/2023ReSTIR_Course_Cyberpunk_2077_Integration.pdf">Cyberpunk 2077</a> rely heavily on raytracing, and artists working on these types of projects expect their tools to support similar techniques.</p> <p>And honestly? It's just cool, and something I love working on :)</p> <h2 id="frame-breakdown">Frame Breakdown<a class="zola-anchor" href="#frame-breakdown" aria-label="Anchor link for: frame-breakdown" style="visibility: hidden;"></a> </h2> <p>In its initial release, Solari supports raytraced diffuse direct (DI) and indirect lighting (GI). Light can come from either <a rel="nofollow noreferrer" href="https://docs.rs/bevy/0.16.1/bevy/prelude/struct.StandardMaterial.html#structfield.emissive">emissive</a> triangle meshes, or analytic <a rel="nofollow noreferrer" href="https://docs.rs/bevy/0.16.1/bevy/pbr/struct.DirectionalLight.html">directional lights</a>. Everything is fully realtime and dynamic, with no baking required.</p> <p>Direct lighting is handled via ReSTIR DI, while indirect lighting is handled by a combination of ReSTIR GI and a world-space irradiance cache. Denoising is handled by DLSS Ray Reconstruction.</p> <p>As opposed to coarse screen-space probes, per-pixel ReSTIR brings much better detail, along with being <em>considerably</em> easier to get started with. I had my first prototype working in a weekend.</p> <p>While I won't be covering ReSTIR from first principles (that could be its own entire blog post), <a rel="nofollow noreferrer" href="https://intro-to-restir.cwyman.org">A Gentle Introduction to ReSTIR: Path Reuse in Real-time</a> and <a rel="nofollow noreferrer" href="https://interplayoflight.wordpress.com/2023/12/17/a-gentler-introduction-to-restir">A gentler introduction to ReSTIR</a> are both really great resources. If you haven't played with ReSTIR before, I suggest giving them a skim before continuing with this post. Or continue anyways, and just admire the pretty pixels :)</p> <p>Onto the frame breakdown!</p> <h3 id="gbuffer-raster">GBuffer Raster<a class="zola-anchor" href="#gbuffer-raster" aria-label="Anchor link for: gbuffer-raster" style="visibility: hidden;"></a> </h3> <p>The first step of Solari is also the most boring: rasterize a standard GBuffer.</p> <p><figure> <img src="gbuffer_base_color.png" > <figcaption><p>Base color</p> </figcaption> </figure> <figure> <img src="gbuffer_normals.png" > <figcaption><p>Normals</p> </figcaption> </figure> <figure> <img src="gbuffer_position.png" > <figcaption><p>Position reconstructed from depth buffer</p> </figcaption> </figure> </p> <h4 id="why-raster">Why Raster?<a class="zola-anchor" href="#why-raster" aria-label="Anchor link for: why-raster" style="visibility: hidden;"></a> </h4> <p>The GBuffer pass remains completely unchanged from standard Bevy (it's the same plugin). This might seem like a missed opportunity - after all, I could have used raytracing for primary visibility instead of rasterization - but I decided to stick with rasterization here.</p> <p>By using raster for primary visibility, I maintain the option for people to use low-resolution proxy meshes in the raytracing scene, while still getting high quality meshes and textures in the primary view. The raster meshes can be full resolution with all their geometric detail, while the raytracing acceleration structure contains simplified versions that are cheaper to trace against.</p> <p>Rasterization also works better with other Bevy features like <a rel="nofollow noreferrer" href="https://jms55.github.io/tags/virtual-geometry">Virtual Geometry</a>.</p> <h4 id="attachments">Attachments<a class="zola-anchor" href="#attachments" aria-label="Anchor link for: attachments" style="visibility: hidden;"></a> </h4> <p>Bevy's GBuffer uses quite a bit of packing. The main attachment is a <code>Rgba32Uint</code> texture with each channel storing multiple values:</p> <ul> <li><strong>First channel</strong>: sRGB base color and perceptual roughness packed as 4x8unorm</li> <li><strong>Second channel</strong>: Emissive color stored as pre-exposed Rgb9e5</li> <li><strong>Third channel</strong>: Reflectance, metallic, baked diffuse occlusion (unused by Solari), and an unused slot, again packed as 4x8unorm</li> <li><strong>Fourth channel</strong>: World-space normal encoded into 24 bits via <a rel="nofollow noreferrer" href="https://www.jcgt.org/published/0003/02/01">octahedral encoding</a>, plus 8 bits of flags meant for Bevy's default deferred shading (unused by Solari)</li> </ul> <p>There's also a second <code>Rg16Float</code> attachment for motion vectors, and of course the depth attachment.</p> <h4 id="drawing">Drawing<a class="zola-anchor" href="#drawing" aria-label="Anchor link for: drawing" style="visibility: hidden;"></a> </h4> <p>The GBuffer rendering itself uses <code>multi_draw_indirect</code> to draw several meshes at once, using <a rel="nofollow noreferrer" href="https://crates.io/crates/offset-allocator">sub-allocated</a> buffers. Culling is done on the GPU using <a href="https://jms55.github.io/posts/2024-06-09-virtual-geometry-bevy-0-14/#culling-first-pass">two-pass occlusion culling</a> against a hierarchal depth buffer. Textures are handled bindlessly, and we try to minimize overall pipeline permutations.</p> <p>These combined techniques keep draw call overhead and per-pixel overdraw fairly low, even for complex scenes.</p> <h3 id="restir-di">ReSTIR DI<a class="zola-anchor" href="#restir-di" aria-label="Anchor link for: restir-di" style="visibility: hidden;"></a> </h3> <p>In order to calculate direct lighting (light emitted by a light source, bouncing off a surface, and then hitting the camera), for each pixel, we need to loop over every light and point on those lights, and then calculate the light's contribution, as well as whether or not the light is visible.</p> <p>This is very expensive, so realtime applications tend to approximate it by averaging many individual light samples. If you choose those samples well, you can get an approximate result that's very close to the real thing, without tons of expensive calculations.</p> <p>To quickly estimate direct lighting, Solari uses a pretty standard ReSTIR DI setup.</p> <p>ReSTIR DI randomly selects points on lights, and then shares the random samples between pixels based in order to choose the best light (most contribution to the image) for a given pixel.</p> <h4 id="di-structure">DI Structure<a class="zola-anchor" href="#di-structure" aria-label="Anchor link for: di-structure" style="visibility: hidden;"></a> </h4> <p>Reservoirs store the light sample, confidence weight, and unbiased contribution weight (acting as the sample's PDF).</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#268bd2;">struct </span><span style="color:#b58900;">Reservoir </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#268bd2;">sample</span><span>: LightSample, </span><span> </span><span style="color:#268bd2;">confidence_weight</span><span>: </span><span style="color:#268bd2;">f32</span><span>, </span><span> </span><span style="color:#268bd2;">unbiased_contribution_weight</span><span>: </span><span style="color:#268bd2;">f32</span><span>, </span><span style="color:#657b83;">} </span></code></pre> <p>Direct lighting is handled in two compute dispatches. The first pass does initial and temporal resampling, while the second pass does spatial resampling and shading.</p> <h4 id="di-initial-resampling">DI Initial Resampling<a class="zola-anchor" href="#di-initial-resampling" aria-label="Anchor link for: di-initial-resampling" style="visibility: hidden;"></a> </h4> <p>Initial sampling uses 32 samples from a light tile (more on this later), and chooses the brightest one via resampling importance sampling (RIS), using constant MIS weights.</p> <p>32 samples per pixel is often overkill for scenes with a small number of lights. As this is one of the most expensive parts of Solari, I'm planning on letting users control this number in a future release.</p> <p>After choosing the best sample, we trace a ray to test visibility, setting the unbiased contribution weight to 0 in the case of occlusion.</p> <blockquote class="callout note no-title"> <div class="icon"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18"><path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM11 7H13V9H11V7ZM11 11H13V17H11V11Z" fill="currentColor"></path></svg> </div> <div class="content"> <p>All raytracing in Solari is handled via inline ray queries. Wgpu does not yet support raytracing pipelines, so I haven't gotten a chance to play around with them.</p> </div> </blockquote> <p><figure> <img src="noisy_di_one_sample.png" > <figcaption><p>One candidate sample DI</p> </figcaption> </figure> <figure> <img src="noisy_di_32_samples.png" > <figcaption><p>32 candidate sample DI, one sample chosen via RIS</p> </figcaption> </figure> </p> <h4 id="di-temporal-resampling">DI Temporal Resampling<a class="zola-anchor" href="#di-temporal-resampling" aria-label="Anchor link for: di-temporal-resampling" style="visibility: hidden;"></a> </h4> <p>A temporal reservoir is then obtained via motion vectors and last frame's pixel data. We validate the reprojection using the <code>pixel_dissimilar</code> heuristic. We also need to check that the temporal light sample still exists in the current frame (i.e. the light has not been despawned).</p> <p>Additionally, the chosen light from last frame might no longer be visible this frame, e.g. if an object moved behind a wall. We could trace an additional ray here to validate visibility, but it's cheaper to just assume that the temporal light sample is still visible from the current pixel this frame.</p> <p>Reusing temporal visibility saves one raytrace, at the cost of shadows for moving objects being delayed by 1 frame, and some slighty darker/wider shadows. Overall the artifacts are not very noticable, so I find that it's well worth reusing visibility for the temporal reservoir resampling.</p> <p>The initial and temporal reservoir are then merged together using constant MIS weights. I tried using the balance heuristic, but didn't notice much difference for DI, and constant MIS weights are much cheaper.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#586e75;">// Reject if tangent plane difference difference more than 0.3% or angle between normals more than 25 degrees </span><span style="color:#268bd2;">fn </span><span style="color:#b58900;">pixel_dissimilar</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">depth</span><span>: </span><span style="color:#268bd2;">f32</span><span>, </span><span style="color:#268bd2;">world_position</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span style="color:#268bd2;">other_world_position</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span style="color:#268bd2;">normal</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span style="color:#268bd2;">other_normal</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;</span><span style="color:#657b83;">) </span><span>-&gt; </span><span style="color:#268bd2;">bool </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#586e75;">// https://developer.download.nvidia.com/video/gputechconf/gtc/2020/presentations/s22699-fast-denoising-with-self-stabilizing-recurrent-blurs.pdf#page=45 </span><span> </span><span style="color:#268bd2;">let</span><span> tangent_plane_distance </span><span style="color:#657b83;">= </span><span style="color:#859900;">abs</span><span style="color:#657b83;">(</span><span style="color:#859900;">dot</span><span style="color:#657b83;">(</span><span>normal, other_world_position </span><span style="color:#657b83;">-</span><span> world_position</span><span style="color:#657b83;">))</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> view_z </span><span style="color:#657b83;">= -</span><span style="color:#859900;">depth_ndc_to_view_z</span><span style="color:#657b83;">(</span><span>depth</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span> </span><span style="color:#859900;">return</span><span> tangent_plane_distance </span><span style="color:#657b83;">/</span><span> view_z </span><span style="color:#657b83;">&gt; </span><span style="color:#6c71c4;">0.003 </span><span style="color:#859900;">|| dot</span><span style="color:#657b83;">(</span><span>normal, other_normal</span><span style="color:#657b83;">) &lt; </span><span style="color:#6c71c4;">0.906</span><span>; </span><span style="color:#657b83;">} </span></code></pre> <h4 id="di-spatial-resampling">DI Spatial Resampling<a class="zola-anchor" href="#di-spatial-resampling" aria-label="Anchor link for: di-spatial-resampling" style="visibility: hidden;"></a> </h4> <p>The second pass handles spatial resampling. We choose one random pixel within a 30 pixel-radius disk, and borrow its reservoir. We use the same <code>pixel_dissimilar</code> heuristic as the temporal pass to validate the spatial reservoir.</p> <p>We must also trace a ray to test visibility, as the reservoir comes from a neighboring pixel, and we cannot assume that the same light sample visible at the neighbor pixel is also visible for the current pixel.</p> <p>Unlike a lot of other ReSTIR implementations, we only ever use 1 spatial sample. Using more than 1 sample does not tend to improve quality, and increases performance costs. We cannot, however, skip spatial resampling entirely. Having a source of new samples from other pixels is crucial to prevent artifacts from temporal resampling.</p> <figure> <img src="spatial_baseline.jpg" > <figcaption><p>1 random spatial sample, 6.4 ms</p> </figcaption> </figure> <p>Spatial sampling is probably the least well-researched part of ReSTIR. I tried a couple of other schemes, including trying to reuse reservoirs across a workgroup/subgroup similar to <a rel="nofollow noreferrer" href="https://iribis.github.io/publication/2025_Stratified_Histogram_Resampling">Histogram Stratification for Spatio-Temporal Reservoir Sampling</a>, but none of them worked out well.</p> <p>Subgroups-level resampling was very cheap, but had tiling artifacts, and was not easily portable to different machines with different amounts of threads per workgroup.</p> <figure> <img src="spatial_subgroup.jpg" > <figcaption><p>Subgroup-level spatial resampling, 7.3 ms</p> </figcaption> </figure> <p>Workgroup-level resampling had much better quality, but was twice as expensive compared to 1 spatial sample, and introduced correlations that broke the denoiser.</p> <figure> <img src="spatial_workgroup.jpg" > <figcaption><p>Workgroup-level spatial resampling, 12 ms</p> </figcaption> </figure> <p>In the end, I stuck with the 1 random spatial sample I described above.</p> <p>The reservoir produced by the first pass and the spatial reservoir are combined with the same routine that we used for merging initial and temporal reservoirs.</p> <h4 id="di-shading">DI Shading<a class="zola-anchor" href="#di-shading" aria-label="Anchor link for: di-shading" style="visibility: hidden;"></a> </h4> <p>Once the final reservoir is produced, we can use its chosen light sample to shade the pixel, producing the final direct lighting.</p> <p>I did try out shading the pixel using all 3 samples (initial, temporal, and spatial), weighed by their resampling probabilities as <a rel="nofollow noreferrer" href="https://cwyman.org/papers/hpg21_rearchitectingReSTIR.pdf">Rearchitecting Spatiotemporal Resampling for Production</a> suggests, but had noisier results compared to shading using the final reservoir only. I'm not sure if I messed up the implementation or what.</p> <p>Overall the DI pass uses two raytraces per pixel (1 initial, 1 spatial).</p> <figure> <img src="noisy_di.png" > <figcaption><p>DI with 32 initial candidates, 1 temporal resample, and 1 spatial resample</p> </figcaption> </figure> <h3 id="restir-gi">ReSTIR GI<a class="zola-anchor" href="#restir-gi" aria-label="Anchor link for: restir-gi" style="visibility: hidden;"></a> </h3> <p>Indirect lighting (light emitted by a light source, bouncing off more than 1 surface, and then hitting the camera) is even more expensive to calculate than direct lighting, as you need to trace multiple bounces of each ray to calculate the lighting for a given path.</p> <p>To quickly estimate indirect lighting, Solari uses ReSTIR GI, with a very similar setup to the previous ReSTIR DI.</p> <p>Where as ReStir DI picks the best light, ReSTIR GI randomly selects directions in a hemisphere, and then shares the random samples between pixels in order to choose the best 1-bounce <em>path</em> for a given pixel.</p> <h4 id="gi-structure">GI Structure<a class="zola-anchor" href="#gi-structure" aria-label="Anchor link for: gi-structure" style="visibility: hidden;"></a> </h4> <p>Reservoirs store the cached radiance bouncing off of the sample point, sample point geometry info, confidence weight, and unbiased contribution weight.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#268bd2;">struct </span><span style="color:#b58900;">Reservoir </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#268bd2;">radiance</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span> </span><span style="color:#268bd2;">sample_point_world_position</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span> </span><span style="color:#268bd2;">sample_point_world_normal</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span> </span><span style="color:#268bd2;">confidence_weight</span><span>: </span><span style="color:#268bd2;">f32</span><span>, </span><span> </span><span style="color:#268bd2;">unbiased_contribution_weight</span><span>: </span><span style="color:#268bd2;">f32</span><span>, </span><span style="color:#657b83;">} </span></code></pre> <p>I tried some basic packing schemes for the GI reservoir (Rgb9e5 radiance, octahedral-encoded normals), but didn't find that it meaningfully reduced GI costs. Reservoir memory bandwidth is not a big bottleneck compared to raytracing and reading mesh/texture data for ray intersections.</p> <p>I have heard that people had good results storing reservoirs as struct-of-arrays instead of array-of-structs, so I'll likely revist this topic at some point.</p> <p>ReSTIR GI again uses two compute dispatches, with the first pass doing initial and temporal resampling, and the second pass doing spatial resampling and shading.</p> <h4 id="gi-initial-sampling">GI Initial Sampling<a class="zola-anchor" href="#gi-initial-sampling" aria-label="Anchor link for: gi-initial-sampling" style="visibility: hidden;"></a> </h4> <p>GI samples are much more expensive to generate than DI samples (tracing paths is more expensive than looping over a list of light sources), so for initial sampling, we only generate 1 sample.</p> <p>We start by tracing a ray along a random direction chosen from a uniform hemisphere distribution. At some point I also want to try using <a rel="nofollow noreferrer" href="https://github.com/electronicarts/fastnoise">spatiotemporal blue noise</a>. Although DLSS-RR recommends white noise, the docs do say that blue noise with a sufficiently long period can also work.</p> <p>At the ray's hit point, we need to obtain an estimate of the incoming irradiance, which becomes the outgoing radiance towards the current pixel, i.e. the path's contribution.</p> <figure> <img src="noisy_gi_one_sample.png" > <figcaption><p>One sample GI</p> </figcaption> </figure> <p>To obtain irradiance, we query the world cache at the hit point (more on this later).</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#268bd2;">fn </span><span style="color:#b58900;">generate_initial_reservoir</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">world_position</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span style="color:#268bd2;">world_normal</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span style="color:#268bd2;">rng</span><span>: ptr&lt;function, </span><span style="color:#268bd2;">u32</span><span>&gt;</span><span style="color:#657b83;">) </span><span>-&gt; Reservoir </span><span style="color:#657b83;">{ </span><span> var reservoir </span><span style="color:#657b83;">= </span><span style="color:#859900;">empty_reservoir</span><span style="color:#657b83;">()</span><span>; </span><span> </span><span> </span><span style="color:#268bd2;">let</span><span> ray_direction </span><span style="color:#657b83;">= </span><span style="color:#859900;">sample_uniform_hemisphere</span><span style="color:#657b83;">(</span><span>world_normal, rng</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> ray_hit </span><span style="color:#657b83;">= </span><span style="color:#859900;">trace_ray</span><span style="color:#657b83;">(</span><span>world_position, ray_direction, </span><span style="color:#cb4b16;">RAY_T_MIN</span><span>, </span><span style="color:#cb4b16;">RAY_T_MAX</span><span>, </span><span style="color:#cb4b16;">RAY_FLAG_NONE</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span> </span><span style="color:#859900;">if</span><span> ray_hit.kind </span><span style="color:#657b83;">== </span><span style="color:#cb4b16;">RAY_QUERY_INTERSECTION_NONE </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#859900;">return</span><span> reservoir; </span><span> </span><span style="color:#657b83;">} </span><span> </span><span> </span><span style="color:#268bd2;">let</span><span> sample_point </span><span style="color:#657b83;">= </span><span style="color:#859900;">resolve_ray_hit_full</span><span style="color:#657b83;">(</span><span>ray_hit</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span> </span><span style="color:#586e75;">// Direct lighting is handled by ReSTIR DI </span><span> </span><span style="color:#859900;">if all</span><span style="color:#657b83;">(</span><span>sample_point.material.emissive </span><span style="color:#657b83;">!= </span><span style="color:#859900;">vec3</span><span style="color:#657b83;">(</span><span style="color:#6c71c4;">0.0</span><span style="color:#657b83;">)) { </span><span> </span><span style="color:#859900;">return</span><span> reservoir; </span><span> </span><span style="color:#657b83;">} </span><span> </span><span> reservoir.unbiased_contribution_weight </span><span style="color:#657b83;">= </span><span style="color:#859900;">uniform_hemisphere_inverse_pdf</span><span style="color:#657b83;">()</span><span>; </span><span> reservoir.sample_point_world_position </span><span style="color:#657b83;">=</span><span> sample_point.world_position; </span><span> reservoir.sample_point_world_normal </span><span style="color:#657b83;">=</span><span> sample_point.world_normal; </span><span> reservoir.confidence_weight </span><span style="color:#657b83;">= </span><span style="color:#6c71c4;">1.0</span><span>; </span><span> </span><span> reservoir.radiance </span><span style="color:#657b83;">= </span><span style="color:#859900;">query_world_cache</span><span style="color:#657b83;">(</span><span>sample_point.world_position, sample_point.geometric_world_normal, view.world_position</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span> </span><span style="color:#268bd2;">let</span><span> sample_point_diffuse_brdf </span><span style="color:#657b83;">=</span><span> sample_point.material.base_color </span><span style="color:#657b83;">/ </span><span style="color:#cb4b16;">PI</span><span>; </span><span> reservoir.radiance </span><span style="color:#657b83;">*=</span><span> sample_point_diffuse_brdf; </span><span> </span><span> </span><span style="color:#859900;">return</span><span> reservoir; </span><span style="color:#657b83;">} </span></code></pre> <h4 id="gi-temporal-and-spatial-resampling">GI Temporal and Spatial Resampling<a class="zola-anchor" href="#gi-temporal-and-spatial-resampling" aria-label="Anchor link for: gi-temporal-and-spatial-resampling" style="visibility: hidden;"></a> </h4> <p>Temporal reservoir selection for GI is a little different from DI.</p> <p>In addition to reprojecting based on motion vectors, we jitter the reprojected location by a few pixels in either direction using <a rel="nofollow noreferrer" href="https://www.amazon.com/GPU-Zen-Advanced-Rendering-Techniques/dp/B0DNXNM14K">permutation sampling</a>. This essentially adds a small spatial component to the temporal resampling, which helps break up temporal correlations.</p> <figure> <img src="no_permutation_sampling.png" > <figcaption><p>No permutation sampling: The denoiser (DLSS-RR) produces blotchy noise</p> </figcaption> </figure> <p>I also tried permutation sampling for ReSTIR DI, and while it did reduce correlation artifacts, it also added even worse artifacts because we reuse visibility, which becomes very obvious under permutation sampling. Tracing an extra ray to validate visibility would fix this, but I'm not quite ready to pay that performance cost.</p> <figure> <img src="di_permutation_sampling.png" > <figcaption><p>DI: Permutation sampling and visibility reuse do not work well together</p> </figcaption> </figure> <p>Spatial reservoir selection for GI is identical to DI.</p> <p>Reservoir merging for GI uses the balance heuristic for MIS weights, and includes the BRDF contribution, as I found that unlike for DI, these make a significant quality difference. The balance heuristic is not much more expensive here, as we are only ever merging two reservoirs at a time.</p> <h4 id="gi-jacobian">GI Jacobian<a class="zola-anchor" href="#gi-jacobian" aria-label="Anchor link for: gi-jacobian" style="visibility: hidden;"></a> </h4> <p>Additionally, since both temporal and spatial resampling use neighboring pixels, we need to add a Jacobian determinant to the MIS weights to account for the change in sampling domain.</p> <p>The Jacobian proved to be the absolute hardest part of ReSTIR GI for me. While it makes the GI more correct, it also adds a lot of noise in corners. Worse, the Jacobian tends to make the GI calculations "explode" into super high numbers that result in overflow to <code>inf</code>, which then spreads over the entire screen due to resampling and denoising.</p> <p>The best solution I have found to reduce artifacts from the Jacobian is to reject neighbor samples when the Jacobian is greater than 2 (i.e., a neighboring sample reused at the current pixel would have more than 2x the contribution it originally did). While this somewhat works, there are still issues with stability. If I leave Solari running for a couple of minutes in the same spot, it will eventually lead to overflow. I haven't yet figured out how to prevent this.</p> <p>Using the balance heuristic (and factoring in the two Jacobians) for MIS weights when resampling also helped a lot with fighting the noise introduced by the Jacobian.</p> <h4 id="gi-shading">GI Shading<a class="zola-anchor" href="#gi-shading" aria-label="Anchor link for: gi-shading" style="visibility: hidden;"></a> </h4> <p>Once the final reservoir is produced, we can use it to shade the pixel, producing the final indirect lighting.</p> <p>Since we're using DLSS-RR for denoising, we can simply add the GI on top of the existing framebuffer (holding the DI). There's no need to write to a separate buffer for use with a separate denoising process, unlike a lot of other GI implementations.</p> <p>Overall the GI pass uses two raytraces per pixel (1 initial, 1 spatial), same as DI.</p> <figure> <img src="noisy_gi.png" > <figcaption><p>GI with 1 initial candidate, 1 temporal resample, and 1 spatial resample</p> </figcaption> </figure> <h3 id="interlude-what-is-restir-doing">Interlude: What is ReSTIR Doing?<a class="zola-anchor" href="#interlude-what-is-restir-doing" aria-label="Anchor link for: interlude-what-is-restir-doing" style="visibility: hidden;"></a> </h3> <p>I have heard ReSTIR described as a signal <em>amplifier</em>. If you feed it decent samples, it's likely to produce a good sample. If you feed it good samples, it's likely to produce a great sample.</p> <p>The better your initial sampling, the better ReSTIR does. The quality of your final result heavily depends on the quality of the initial samples you feed into it.</p> <p>For this reason, it's important that you spend time improving the initial sampling process. This could take the form of generating more initial samples, or improving your sampling strategy.</p> <p>For ReSTIR DI, taking more initial samples is viable, as samples are just random lights, and are fairly cheap to generate.</p> <p>For ReSTIR GI, even 1 initial sample is already expensive, as each sample involves tracing a ray. Instead of increasing initial sample count, we'll have to be smart about <em>how</em> we obtain that 1 sample.</p> <p>In the next two sections of the frame breakdown, we will discuss how I improved initial sampling for ReSTIR DI and GI.</p> <h3 id="light-tile-presampling">Light Tile Presampling<a class="zola-anchor" href="#light-tile-presampling" aria-label="Anchor link for: light-tile-presampling" style="visibility: hidden;"></a> </h3> <p>While generating initial samples for ReSTIR DI is fairly cheap, when we start taking 32 or more samples per pixel, the memory bandwidth costs quickly add up. In order to make 32 samples per pixel viable, we'll need a way to improve our cache coherency.</p> <p>In this section, we will generate some light tile buffers, following section 5 of <a rel="nofollow noreferrer" href="https://cwyman.org/papers/hpg21_rearchitectingReSTIR.pdf">Rearchitecting Spatiotemporal Resampling for Production</a>.</p> <h4 id="light-sampling-apis">Light Sampling APIs<a class="zola-anchor" href="#light-sampling-apis" aria-label="Anchor link for: light-sampling-apis" style="visibility: hidden;"></a> </h4> <p>Before I can explain light tiles, we first need to talk about Solari's shader API for working with light sources.</p> <p>Bevy stores light sources as a big list of objects on the GPU. All emissive meshes and directional lights get collected by the CPU, and put in this list.</p> <p>When calculating radiance emitted by a light source, Bevy works with specific light <em>samples</em> - not the whole light at once. A <code>LightSample</code> uniquely identifies a specific subset of the light source, e.g. a specific point on an emissive mesh.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#268bd2;">struct </span><span style="color:#b58900;">LightSample </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#268bd2;">light_id</span><span>: </span><span style="color:#268bd2;">u16</span><span>, </span><span> </span><span style="color:#268bd2;">triangle_id</span><span>: </span><span style="color:#268bd2;">u16</span><span>, </span><span style="color:#586e75;">// Unused for directional lights </span><span> </span><span style="color:#268bd2;">seed</span><span>: </span><span style="color:#268bd2;">u32</span><span>, </span><span style="color:#657b83;">} </span><span> </span><span style="color:#268bd2;">fn </span><span style="color:#b58900;">generate_random_light_sample</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">rng</span><span>: ptr&lt;function, </span><span style="color:#268bd2;">u32</span><span>&gt;</span><span style="color:#657b83;">) </span><span>-&gt; LightSample </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#268bd2;">let</span><span> light_count </span><span style="color:#657b83;">=</span><span> arrayLength</span><span style="color:#657b83;">(</span><span style="color:#859900;">&amp;</span><span>light_sources</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> light_id </span><span style="color:#657b83;">= </span><span style="color:#859900;">rand_range_u</span><span style="color:#657b83;">(</span><span>light_count, rng</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span> </span><span style="color:#268bd2;">let</span><span> light_source </span><span style="color:#657b83;">=</span><span> light_sources</span><span style="color:#657b83;">[</span><span>light_id</span><span style="color:#657b83;">]</span><span>; </span><span> </span><span> var triangle_id </span><span style="color:#657b83;">=</span><span> 0u; </span><span> </span><span style="color:#859900;">if</span><span> light_source.kind </span><span style="color:#657b83;">!= </span><span style="color:#cb4b16;">LIGHT_SOURCE_KIND_DIRECTIONAL </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#268bd2;">let</span><span> triangle_count </span><span style="color:#657b83;">=</span><span> light_source.kind </span><span style="color:#657b83;">&gt;&gt;</span><span> 1u; </span><span> triangle_id </span><span style="color:#657b83;">= </span><span style="color:#859900;">rand_range_u</span><span style="color:#657b83;">(</span><span>triangle_count, rng</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#657b83;">} </span><span> </span><span> </span><span style="color:#268bd2;">let</span><span> seed </span><span style="color:#657b83;">= </span><span style="color:#859900;">rand_u</span><span style="color:#657b83;">(</span><span>rng</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span> </span><span style="color:#859900;">return</span><span> LightSample</span><span style="color:#657b83;">(</span><span>light_id, triangle_id, seed</span><span style="color:#657b83;">)</span><span>; </span><span style="color:#657b83;">} </span></code></pre> <p>The light ID points to the overall light source object in the big list of lights.</p> <p>The seed is used to initialize a random number generator (RNG). For directional lights, the RNG is used to choose a direction within a cone. For emissive meshes, the RNG is used to choose a specific point on the triangle identified by the triangle ID.</p> <p>A <code>LightSample</code> can be resolved, giving some info on its properties:</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#268bd2;">struct </span><span style="color:#b58900;">ResolvedLightSample </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#268bd2;">world_position</span><span>: vec4&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span style="color:#586e75;">// w component is 0.0 for directional lights, and 1.0 for emissive meshes </span><span> </span><span style="color:#268bd2;">world_normal</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span> </span><span style="color:#268bd2;">emitted_radiance</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span> </span><span style="color:#268bd2;">inverse_pdf</span><span>: </span><span style="color:#268bd2;">f32</span><span>, </span><span style="color:#657b83;">} </span><span> </span><span style="color:#268bd2;">fn </span><span style="color:#b58900;">resolve_light_sample</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">light_sample</span><span>: LightSample, </span><span style="color:#268bd2;">light_source</span><span>: LightSource</span><span style="color:#657b83;">) </span><span>-&gt; ResolvedLightSample </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#859900;">if</span><span> light_source.kind </span><span style="color:#657b83;">== </span><span style="color:#cb4b16;">LIGHT_SOURCE_KIND_DIRECTIONAL </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#268bd2;">let</span><span> directional_light </span><span style="color:#657b83;">=</span><span> directional_lights</span><span style="color:#657b83;">[</span><span>light_source.id</span><span style="color:#657b83;">]</span><span>; </span><span> </span><span> </span><span style="color:#268bd2;">let</span><span> direction_to_light </span><span style="color:#657b83;">= </span><span style="color:#859900;">sample_cone</span><span style="color:#657b83;">(</span><span>directional_light</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span> </span><span style="color:#859900;">return</span><span> ResolvedLightSample</span><span style="color:#657b83;">( </span><span> </span><span style="color:#859900;">vec4</span><span style="color:#657b83;">(</span><span>direction_to_light, </span><span style="color:#6c71c4;">0.0</span><span style="color:#657b83;">)</span><span>, </span><span> </span><span style="color:#657b83;">-</span><span>direction_to_light, </span><span> directional_light.luminance, </span><span> directional_light.inverse_pdf, </span><span> </span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#657b83;">} </span><span style="color:#859900;">else </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#268bd2;">let</span><span> triangle_count </span><span style="color:#657b83;">=</span><span> light_source.kind </span><span style="color:#657b83;">&gt;&gt;</span><span> 1u; </span><span> </span><span style="color:#268bd2;">let</span><span> triangle_id </span><span style="color:#657b83;">=</span><span> light_sample.light_id </span><span style="color:#859900;">&amp;</span><span> 0xFFFFu; </span><span> </span><span style="color:#268bd2;">let</span><span> barycentrics </span><span style="color:#657b83;">= </span><span style="color:#859900;">triangle_barycentrics</span><span style="color:#657b83;">(</span><span>light_sample.seed</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span> </span><span style="color:#586e75;">// Interpolates and transforms vertex positions, UVs, etc, and samples material textures </span><span> </span><span style="color:#268bd2;">let</span><span> triangle_data </span><span style="color:#657b83;">= </span><span style="color:#859900;">resolve_triangle_data_full</span><span style="color:#657b83;">(</span><span>light_source.id, triangle_id, barycentrics</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span> </span><span style="color:#859900;">return</span><span> ResolvedLightSample</span><span style="color:#657b83;">( </span><span> </span><span style="color:#859900;">vec4</span><span style="color:#657b83;">(</span><span>triangle_data.world_position, </span><span style="color:#6c71c4;">1.0</span><span style="color:#657b83;">)</span><span>, </span><span> triangle_data.world_normal, </span><span> triangle_data.material.emissive.rgb, </span><span> </span><span style="color:#268bd2;">f32</span><span style="color:#657b83;">(</span><span>triangle_count</span><span style="color:#657b83;">) *</span><span> triangle_data.triangle_area, </span><span> </span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#657b83;">} </span><span style="color:#657b83;">} </span></code></pre> <p>And finally a <code>ResolvedLightSample</code> can be used to calculate the received radiance at a point from the light sample, also known as the unshadowed light contribution:</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#268bd2;">struct </span><span style="color:#b58900;">LightContribution </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#268bd2;">received_radiance</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span> </span><span style="color:#268bd2;">inverse_pdf</span><span>: </span><span style="color:#268bd2;">f32</span><span>, </span><span> </span><span style="color:#268bd2;">wi</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span style="color:#657b83;">} </span><span> </span><span style="color:#268bd2;">fn </span><span style="color:#b58900;">calculate_resolved_light_contribution</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">resolved_light_sample</span><span>: ResolvedLightSample, </span><span style="color:#268bd2;">ray_origin</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span style="color:#268bd2;">origin_world_normal</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;</span><span style="color:#657b83;">) </span><span>-&gt; LightContribution </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#268bd2;">let</span><span> ray </span><span style="color:#657b83;">=</span><span> resolved_light_sample.world_position.xyz </span><span style="color:#657b83;">- (</span><span>resolved_light_sample.world_position.w </span><span style="color:#657b83;">*</span><span> ray_origin</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> light_distance </span><span style="color:#657b83;">= </span><span style="color:#859900;">length</span><span style="color:#657b83;">(</span><span>ray</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> wi </span><span style="color:#657b83;">=</span><span> ray </span><span style="color:#657b83;">/</span><span> light_distance; </span><span> </span><span> </span><span style="color:#268bd2;">let</span><span> cos_theta_origin </span><span style="color:#657b83;">= </span><span style="color:#859900;">saturate</span><span style="color:#657b83;">(</span><span style="color:#859900;">dot</span><span style="color:#657b83;">(</span><span>wi, origin_world_normal</span><span style="color:#657b83;">))</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> cos_theta_light </span><span style="color:#657b83;">= </span><span style="color:#859900;">saturate</span><span style="color:#657b83;">(</span><span style="color:#859900;">dot</span><span style="color:#657b83;">(-</span><span>wi, resolved_light_sample.world_normal</span><span style="color:#657b83;">))</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> light_distance_squared </span><span style="color:#657b83;">=</span><span> light_distance </span><span style="color:#657b83;">*</span><span> light_distance; </span><span> </span><span> </span><span style="color:#268bd2;">let</span><span> received_radiance </span><span style="color:#657b83;">=</span><span> resolved_light_sample.emitted_radiance </span><span style="color:#657b83;">*</span><span> cos_theta_origin </span><span style="color:#657b83;">* (</span><span>cos_theta_light </span><span style="color:#657b83;">/</span><span> light_distance_squared</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span> </span><span style="color:#859900;">return</span><span> LightContribution</span><span style="color:#657b83;">(</span><span>received_radiance, resolved_light_sample.inverse_pdf, wi</span><span style="color:#657b83;">)</span><span>; </span><span style="color:#657b83;">} </span></code></pre> <p>Notably, only the first and second steps (generating a <code>LightSample</code>, resolving it into a <code>ResolvedLightSample</code>) involve branching based on the type of light (directional or emissive). Calculating the light contribution involves no branching.</p> <h4 id="presampling-lights">Presampling Lights<a class="zola-anchor" href="#presampling-lights" aria-label="Anchor link for: presampling-lights" style="visibility: hidden;"></a> </h4> <p>The straightforward way to implement ReSTIR DI initial sampling is to perform the whole light sampling process (generate -&gt; resolve -&gt; calculate contribution) all in one shader.</p> <p>Indeed, for my first ReSTIR DI prototype, this is what I did - but performance was terrible.</p> <p>By generating the light sample, resolving it, and then calculating its contribution all in the same shader, we're introducing a lot of divergent branches and incoherent memory accesses. If there's one thing GPUs hate, it's divergence. GPUs perform better when all threads in a group are executing the same branch and don't need masking, and when the threads are all accessing similar memory locations that are likely in a nearby cache.</p> <p>Instead, we can separate out the steps. Generating a bunch of random light samples and resolving them can be performed ahead of time, by a separate shader. We can then pack the resolved samples and store them in a buffer.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#268bd2;">fn </span><span style="color:#b58900;">pack_resolved_light_sample</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">sample</span><span>: ResolvedLightSample</span><span style="color:#657b83;">) </span><span>-&gt; ResolvedLightSamplePacked </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#859900;">return</span><span> ResolvedLightSamplePacked</span><span style="color:#657b83;">( </span><span> sample.world_position.x, </span><span> sample.world_position.y, </span><span> sample.world_position.z, </span><span> </span><span style="color:#859900;">pack2x16unorm</span><span style="color:#657b83;">(</span><span style="color:#859900;">octahedral_encode</span><span style="color:#657b83;">(</span><span>sample.world_normal</span><span style="color:#657b83;">))</span><span>, </span><span> </span><span style="color:#859900;">vec3_to_rgb9e5_</span><span style="color:#657b83;">(</span><span>sample.radiance </span><span style="color:#657b83;">*</span><span> view.exposure</span><span style="color:#657b83;">)</span><span>, </span><span> sample.inverse_pdf </span><span style="color:#657b83;">* </span><span style="color:#859900;">select</span><span style="color:#657b83;">(</span><span style="color:#6c71c4;">1.0</span><span>, </span><span style="color:#657b83;">-</span><span style="color:#6c71c4;">1.0</span><span>, sample.world_position.w </span><span style="color:#657b83;">== </span><span style="color:#6c71c4;">0.0</span><span style="color:#657b83;">)</span><span>, </span><span> </span><span style="color:#657b83;">)</span><span>; </span><span style="color:#657b83;">} </span><span> </span><span style="color:#268bd2;">fn </span><span style="color:#b58900;">unpack_resolved_light_sample</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">packed</span><span>: ResolvedLightSamplePacked, </span><span style="color:#268bd2;">exposure</span><span>: </span><span style="color:#268bd2;">f32</span><span style="color:#657b83;">) </span><span>-&gt; ResolvedLightSample </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#859900;">return</span><span> ResolvedLightSample</span><span style="color:#657b83;">( </span><span> </span><span style="color:#859900;">vec4</span><span style="color:#657b83;">(</span><span>packed.world_position_x, packed.world_position_y, packed.world_position_z, </span><span style="color:#859900;">select</span><span style="color:#657b83;">(</span><span style="color:#6c71c4;">1.0</span><span>, </span><span style="color:#6c71c4;">0.0</span><span>, packed.inverse_pdf </span><span style="color:#657b83;">&lt; </span><span style="color:#6c71c4;">0.0</span><span style="color:#657b83;">))</span><span>, </span><span> </span><span style="color:#859900;">octahedral_decode</span><span style="color:#657b83;">(</span><span style="color:#859900;">unpack2x16unorm</span><span style="color:#657b83;">(</span><span>packed.world_normal</span><span style="color:#657b83;">))</span><span>, </span><span> </span><span style="color:#859900;">rgb9e5_to_vec3_</span><span style="color:#657b83;">(</span><span>packed.radiance</span><span style="color:#657b83;">) /</span><span> exposure, </span><span> </span><span style="color:#859900;">abs</span><span style="color:#657b83;">(</span><span>packed.inverse_pdf</span><span style="color:#657b83;">)</span><span>, </span><span> </span><span style="color:#657b83;">)</span><span>; </span><span style="color:#657b83;">} </span></code></pre> <p>We call these presampled sets of lights "light tiles". Following the paper, we perform a compute dispatch to generate a fixed 128 tiles (these are not screen-space tiles), each with 1024 samples (<code>ResolvedLightSamplePacked</code>).</p> <p>Samples are generated completely randomly, without any info about the scene - there is no spatial heuristic or any way of identifying "good" samples.</p> <p>ReSTIR DI initial sampling can now pick a random tile, and then random samples within the tile, and use <code>calculate_resolved_light_contribution()</code> to calculate their radiance.</p> <p>With light tiles, we have much higher cache hit rates when sampling lights, which greatly improves our performance. In fact, even more than the actual raytracing - light sampling is by far the biggest performance bottleneck in Solari.</p> <h3 id="world-cache">World Cache<a class="zola-anchor" href="#world-cache" aria-label="Anchor link for: world-cache" style="visibility: hidden;"></a> </h3> <p>With light tiles accelerating initial sampling for ReSTIR DI, it's time to talk about how we accelerate initial sampling for ReSTIR GI.</p> <p>Unlike DI, where generating more samples is relatively cheap, for GI we can only afford 1 sample. However, unlike DI, GI is a lot more forgiving of inaccuracies. GI just has to be "mostly correct".</p> <p>We can take advantage of that fact by sharing the same work amongst multiple pixels, via the use of a world-space irradiance cache.</p> <p><img src="https://jms55.github.io/posts/2025-09-20-solari-bevy-0-17/world_cache_close.png" alt="world_cache_close" /></p> <p>The world cache voxelizes the world, storing accumulated irradiance (light hitting the surface) at each voxel.</p> <p>When sampling indirect lighting in ReSTIR GI, rather than having to trace additional rays towards light sources to estimate the irradiance, we can simply lookup the irradiance at the given voxel.</p> <p>The world cache both amortizes the cost of the GI pass, and reduces variance, especially for newly-disoccluded pixels for which the screen-space ReSTIR GI has no temporal history.</p> <p>Adding the world cache both significantly improved quality, and halved the time spent on the initial GI sampling.</p> <h4 id="cache-querying">Cache Querying<a class="zola-anchor" href="#cache-querying" aria-label="Anchor link for: cache-querying" style="visibility: hidden;"></a> </h4> <p>The world cache uses <a rel="nofollow noreferrer" href="https://arxiv.org/pdf/1902.05942v1">spatial hashing</a> to discretize the world. Unlike other options such as <a rel="nofollow noreferrer" href="https://github.com/EmbarkStudios/kajiya/blob/main/docs/gi-overview.md#irradiance-cache-055ms">clipmaps</a>, <a rel="nofollow noreferrer" href="https://advances.realtimerendering.com/s2022/SIGGRAPH2022-Advances-Lumen-Wright%20et%20al.pdf#page=59">cards</a>, or <a rel="nofollow noreferrer" href="https://gpuopen.com/download/GDC2024_GI_with_AMD_FidelityFX_Brixelizer.pdf">bricks</a>, spatial hashing requires no explicit build step, and automatically adapts to scene geometry while having minimal light leaks.</p> <p>With spatial hashing, a given descriptor (e.g. <code>{position, normal}</code>) hashes to a <code>u32</code> key. This key corresponds to an index within a fixed-size buffer, which holds whatever values you want to store in the hashmap - in our case, irradiance.</p> <p>Either the entry that you're querying corresponds to some existing entry (same checksum), and you can return the value, or the entry does not exist (empty checksum), and you can initialize the entry by writing the checksum to it.</p> <p>The checksum is the same descriptor, hashed to a different key via a different hash function, and is used to detect hash collisions.</p> <p>The <code>query_world_cache()</code> function below is what ReSTIR GI uses to lookup irradiance at the hit point for raytraces.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#268bd2;">fn </span><span style="color:#b58900;">query_world_cache</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">world_position</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span style="color:#268bd2;">world_normal</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span style="color:#268bd2;">view_position</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;</span><span style="color:#657b83;">) </span><span>-&gt; vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt; </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#268bd2;">let</span><span> cell_size </span><span style="color:#657b83;">= </span><span style="color:#859900;">get_cell_size</span><span style="color:#657b83;">(</span><span>world_position, view_position</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span> </span><span style="color:#268bd2;">let</span><span> world_position_quantized </span><span style="color:#657b83;">= </span><span>bitcast&lt;vec3&lt;</span><span style="color:#268bd2;">u32</span><span>&gt;&gt;</span><span style="color:#657b83;">(</span><span style="color:#859900;">quantize_position</span><span style="color:#657b83;">(</span><span>world_position, cell_size</span><span style="color:#657b83;">))</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> world_normal_quantized </span><span style="color:#657b83;">= </span><span>bitcast&lt;vec3&lt;</span><span style="color:#268bd2;">u32</span><span>&gt;&gt;</span><span style="color:#657b83;">(</span><span style="color:#859900;">quantize_normal</span><span style="color:#657b83;">(</span><span>world_normal</span><span style="color:#657b83;">))</span><span>; </span><span> </span><span> var key </span><span style="color:#657b83;">= </span><span style="color:#859900;">compute_key</span><span style="color:#657b83;">(</span><span>world_position_quantized, world_normal_quantized</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> checksum </span><span style="color:#657b83;">= </span><span style="color:#859900;">compute_checksum</span><span style="color:#657b83;">(</span><span>world_position_quantized, world_normal_quantized</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span> </span><span style="color:#859900;">for </span><span style="color:#657b83;">(</span><span>var i </span><span style="color:#657b83;">=</span><span> 0u; i </span><span style="color:#657b83;">&lt; </span><span style="color:#cb4b16;">WORLD_CACHE_MAX_SEARCH_STEPS</span><span>; i</span><span style="color:#657b83;">++) { </span><span> </span><span style="color:#268bd2;">let</span><span> existing_checksum </span><span style="color:#657b83;">=</span><span> atomicCompareExchangeWeak</span><span style="color:#657b83;">(</span><span style="color:#859900;">&amp;</span><span>world_cache_checksums</span><span style="color:#657b83;">[</span><span>key</span><span style="color:#657b83;">]</span><span>, </span><span style="color:#cb4b16;">WORLD_CACHE_EMPTY_CELL</span><span>, checksum</span><span style="color:#657b83;">)</span><span>.old_value; </span><span> </span><span style="color:#859900;">if</span><span> existing_checksum </span><span style="color:#657b83;">==</span><span> checksum </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#586e75;">// Cache entry already exists - get irradiance and reset cell lifetime </span><span> atomicStore</span><span style="color:#657b83;">(</span><span style="color:#859900;">&amp;</span><span>world_cache_life</span><span style="color:#657b83;">[</span><span>key</span><span style="color:#657b83;">]</span><span>, </span><span style="color:#cb4b16;">WORLD_CACHE_CELL_LIFETIME</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#859900;">return</span><span> world_cache_irradiance</span><span style="color:#657b83;">[</span><span>key</span><span style="color:#657b83;">]</span><span>.rgb; </span><span> </span><span style="color:#657b83;">} </span><span style="color:#859900;">else if</span><span> existing_checksum </span><span style="color:#657b83;">== </span><span style="color:#cb4b16;">WORLD_CACHE_EMPTY_CELL </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#586e75;">// Cell is empty - reset cell lifetime so that it starts getting updated next frame </span><span> atomicStore</span><span style="color:#657b83;">(</span><span style="color:#859900;">&amp;</span><span>world_cache_life</span><span style="color:#657b83;">[</span><span>key</span><span style="color:#657b83;">]</span><span>, </span><span style="color:#cb4b16;">WORLD_CACHE_CELL_LIFETIME</span><span style="color:#657b83;">)</span><span>; </span><span> world_cache_geometry_data</span><span style="color:#657b83;">[</span><span>key</span><span style="color:#657b83;">]</span><span>.world_position </span><span style="color:#657b83;">=</span><span> world_position; </span><span> world_cache_geometry_data</span><span style="color:#657b83;">[</span><span>key</span><span style="color:#657b83;">]</span><span>.world_normal </span><span style="color:#657b83;">=</span><span> world_normal; </span><span> </span><span style="color:#859900;">return vec3</span><span style="color:#657b83;">(</span><span style="color:#6c71c4;">0.0</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#657b83;">} </span><span style="color:#859900;">else </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#586e75;">// Collision - jump to another entry </span><span> key </span><span style="color:#657b83;">= </span><span style="color:#859900;">wrap_key</span><span style="color:#657b83;">(</span><span style="color:#859900;">pcg_hash</span><span style="color:#657b83;">(</span><span>key</span><span style="color:#657b83;">))</span><span>; </span><span> </span><span style="color:#657b83;">} </span><span> </span><span style="color:#657b83;">} </span><span> </span><span> </span><span style="color:#859900;">return vec3</span><span style="color:#657b83;">(</span><span style="color:#6c71c4;">0.0</span><span style="color:#657b83;">)</span><span>; </span><span style="color:#657b83;">} </span></code></pre> <p>In Solari, the descriptor is a combination of the <code>world_position</code> of the query point, the <code>geometric_world_normal</code> (shading normal is too detailed) of the query point, and a LOD factor that's used to reduce cell count for far-away query points.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#268bd2;">fn </span><span style="color:#b58900;">quantize_position</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">world_position</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span style="color:#268bd2;">quantization_factor</span><span>: </span><span style="color:#268bd2;">f32</span><span style="color:#657b83;">) </span><span>-&gt; vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt; </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#859900;">return floor</span><span style="color:#657b83;">(</span><span>world_position </span><span style="color:#657b83;">/</span><span> quantization_factor </span><span style="color:#657b83;">+ </span><span style="color:#6c71c4;">0.0001</span><span style="color:#657b83;">)</span><span>; </span><span style="color:#657b83;">} </span><span> </span><span style="color:#268bd2;">fn </span><span style="color:#b58900;">quantize_normal</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">world_normal</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;</span><span style="color:#657b83;">) </span><span>-&gt; vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt; </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#859900;">return floor</span><span style="color:#657b83;">(</span><span>world_normal </span><span style="color:#657b83;">+ </span><span style="color:#6c71c4;">0.0001</span><span style="color:#657b83;">)</span><span>; </span><span style="color:#657b83;">} </span><span> </span><span style="color:#268bd2;">fn </span><span style="color:#b58900;">compute_key</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">world_position</span><span>: vec3&lt;</span><span style="color:#268bd2;">u32</span><span>&gt;, </span><span style="color:#268bd2;">world_normal</span><span>: vec3&lt;</span><span style="color:#268bd2;">u32</span><span>&gt;</span><span style="color:#657b83;">) </span><span>-&gt; </span><span style="color:#268bd2;">u32 </span><span style="color:#657b83;">{ </span><span> var key </span><span style="color:#657b83;">= </span><span style="color:#859900;">pcg_hash</span><span style="color:#657b83;">(</span><span>world_position.x</span><span style="color:#657b83;">)</span><span>; </span><span> key </span><span style="color:#657b83;">= </span><span style="color:#859900;">pcg_hash</span><span style="color:#657b83;">(</span><span>key </span><span style="color:#657b83;">+</span><span> world_position.y</span><span style="color:#657b83;">)</span><span>; </span><span> key </span><span style="color:#657b83;">= </span><span style="color:#859900;">pcg_hash</span><span style="color:#657b83;">(</span><span>key </span><span style="color:#657b83;">+</span><span> world_position.z</span><span style="color:#657b83;">)</span><span>; </span><span> key </span><span style="color:#657b83;">= </span><span style="color:#859900;">pcg_hash</span><span style="color:#657b83;">(</span><span>key </span><span style="color:#657b83;">+</span><span> world_normal.x</span><span style="color:#657b83;">)</span><span>; </span><span> key </span><span style="color:#657b83;">= </span><span style="color:#859900;">pcg_hash</span><span style="color:#657b83;">(</span><span>key </span><span style="color:#657b83;">+</span><span> world_normal.y</span><span style="color:#657b83;">)</span><span>; </span><span> key </span><span style="color:#657b83;">= </span><span style="color:#859900;">pcg_hash</span><span style="color:#657b83;">(</span><span>key </span><span style="color:#657b83;">+</span><span> world_normal.z</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#859900;">return wrap_key</span><span style="color:#657b83;">(</span><span>key</span><span style="color:#657b83;">)</span><span>; </span><span style="color:#657b83;">} </span><span> </span><span style="color:#268bd2;">fn </span><span style="color:#b58900;">compute_checksum</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">world_position</span><span>: vec3&lt;</span><span style="color:#268bd2;">u32</span><span>&gt;, </span><span style="color:#268bd2;">world_normal</span><span>: vec3&lt;</span><span style="color:#268bd2;">u32</span><span>&gt;</span><span style="color:#657b83;">) </span><span>-&gt; </span><span style="color:#268bd2;">u32 </span><span style="color:#657b83;">{ </span><span> var key </span><span style="color:#657b83;">= </span><span style="color:#859900;">iqint_hash</span><span style="color:#657b83;">(</span><span>world_position.x</span><span style="color:#657b83;">)</span><span>; </span><span> key </span><span style="color:#657b83;">= </span><span style="color:#859900;">iqint_hash</span><span style="color:#657b83;">(</span><span>key </span><span style="color:#657b83;">+</span><span> world_position.y</span><span style="color:#657b83;">)</span><span>; </span><span> key </span><span style="color:#657b83;">= </span><span style="color:#859900;">iqint_hash</span><span style="color:#657b83;">(</span><span>key </span><span style="color:#657b83;">+</span><span> world_position.z</span><span style="color:#657b83;">)</span><span>; </span><span> key </span><span style="color:#657b83;">= </span><span style="color:#859900;">iqint_hash</span><span style="color:#657b83;">(</span><span>key </span><span style="color:#657b83;">+</span><span> world_normal.x</span><span style="color:#657b83;">)</span><span>; </span><span> key </span><span style="color:#657b83;">= </span><span style="color:#859900;">iqint_hash</span><span style="color:#657b83;">(</span><span>key </span><span style="color:#657b83;">+</span><span> world_normal.y</span><span style="color:#657b83;">)</span><span>; </span><span> key </span><span style="color:#657b83;">= </span><span style="color:#859900;">iqint_hash</span><span style="color:#657b83;">(</span><span>key </span><span style="color:#657b83;">+</span><span> world_normal.z</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#859900;">return</span><span> key; </span><span style="color:#657b83;">} </span></code></pre> <figure> <img src="world_cache_far.png" > <figcaption><p>World cache from further away, showing LOD</p> </figcaption> </figure> <h4 id="cache-decay">Cache Decay<a class="zola-anchor" href="#cache-decay" aria-label="Anchor link for: cache-decay" style="visibility: hidden;"></a> </h4> <p>In order to maintain the world cache, we need a series of passes to decay and update active entries.</p> <p>The first compute dispatch checks every entry in the hashmap, decaying their "life" count by 1. Each entry's life is initialized when the entry is created, and is reset when queried.</p> <p>When an entry reaches 0 life, we clear out the entry, freeing up a space for future voxels to use.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#859900;">@</span><span>compute </span><span style="color:#859900;">@workgroup_size</span><span style="color:#657b83;">(</span><span style="color:#6c71c4;">1024</span><span>, </span><span style="color:#6c71c4;">1</span><span>, </span><span style="color:#6c71c4;">1</span><span style="color:#657b83;">) </span><span style="color:#268bd2;">fn </span><span style="color:#b58900;">decay_world_cache</span><span style="color:#657b83;">(</span><span>@builtin</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">global_invocation_id</span><span style="color:#657b83;">) </span><span style="color:#268bd2;">global_id</span><span>: vec3&lt;</span><span style="color:#268bd2;">u32</span><span>&gt;</span><span style="color:#657b83;">) { </span><span> var life </span><span style="color:#657b83;">=</span><span> world_cache_life</span><span style="color:#657b83;">[</span><span>global_id.x</span><span style="color:#657b83;">]</span><span>; </span><span> </span><span style="color:#859900;">if</span><span> life </span><span style="color:#657b83;">&gt;</span><span> 0u </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#586e75;">// Decay and write new life </span><span> life </span><span style="color:#657b83;">-=</span><span> 1u; </span><span> world_cache_life</span><span style="color:#657b83;">[</span><span>global_id.x</span><span style="color:#657b83;">] =</span><span> life; </span><span> </span><span> </span><span style="color:#586e75;">// Clear cells that become dead </span><span> </span><span style="color:#859900;">if</span><span> life </span><span style="color:#657b83;">==</span><span> 0u </span><span style="color:#657b83;">{ </span><span> world_cache_checksums</span><span style="color:#657b83;">[</span><span>global_id.x</span><span style="color:#657b83;">] = </span><span style="color:#cb4b16;">WORLD_CACHE_EMPTY_CELL</span><span>; </span><span> world_cache_irradiance</span><span style="color:#657b83;">[</span><span>global_id.x</span><span style="color:#657b83;">] = </span><span style="color:#859900;">vec4</span><span style="color:#657b83;">(</span><span style="color:#6c71c4;">0.0</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#657b83;">} </span><span> </span><span style="color:#657b83;">} </span><span style="color:#657b83;">} </span></code></pre> <h4 id="cache-compact">Cache Compact<a class="zola-anchor" href="#cache-compact" aria-label="Anchor link for: cache-compact" style="visibility: hidden;"></a> </h4> <p>The next three dispatches compact and count the total number of active entries in the world cache. This produces a dense array of indices of active entries, as well as indirect dispatch parameters for the next two passes.</p> <p>The code is just a standard parallel prefix-sum, so I'm going to skip showing it.</p> <h4 id="cache-update">Cache Update<a class="zola-anchor" href="#cache-update" aria-label="Anchor link for: cache-update" style="visibility: hidden;"></a> </h4> <p>Now that we know the list of active entries in the world cache (and can perform indirect dispatches to process each active entry), it's time to update the irradiance estimate for each voxel.</p> <p>The first part of the update process is taking new samples of the scene's lighting.</p> <p>Two rays are traced per voxel: a direct light sample, and an indirect light sample.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#859900;">@</span><span>compute </span><span style="color:#859900;">@workgroup_size</span><span style="color:#657b83;">(</span><span style="color:#6c71c4;">1024</span><span>, </span><span style="color:#6c71c4;">1</span><span>, </span><span style="color:#6c71c4;">1</span><span style="color:#657b83;">) </span><span style="color:#268bd2;">fn </span><span style="color:#b58900;">sample_irradiance</span><span style="color:#657b83;">(</span><span>@builtin</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">workgroup_id</span><span style="color:#657b83;">) </span><span style="color:#268bd2;">workgroup_id</span><span>: vec3&lt;</span><span style="color:#268bd2;">u32</span><span>&gt;, @builtin</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">global_invocation_id</span><span style="color:#657b83;">) </span><span style="color:#268bd2;">active_cell_id</span><span>: vec3&lt;</span><span style="color:#268bd2;">u32</span><span>&gt;</span><span style="color:#657b83;">) { </span><span> </span><span style="color:#859900;">if</span><span> active_cell_id.x </span><span style="color:#657b83;">&lt;</span><span> world_cache_active_cells_count </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#586e75;">// Get voxel data </span><span> </span><span style="color:#268bd2;">let</span><span> cell_index </span><span style="color:#657b83;">=</span><span> world_cache_active_cell_indices</span><span style="color:#657b83;">[</span><span>active_cell_id.x</span><span style="color:#657b83;">]</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> geometry_data </span><span style="color:#657b83;">=</span><span> world_cache_geometry_data</span><span style="color:#657b83;">[</span><span>cell_index</span><span style="color:#657b83;">]</span><span>; </span><span> var rng </span><span style="color:#657b83;">=</span><span> cell_index </span><span style="color:#657b83;">+</span><span> constants.frame_index; </span><span> </span><span> </span><span style="color:#586e75;">// Sample direct lighting via RIS (1st ray) </span><span> var new_irradiance </span><span style="color:#657b83;">= </span><span style="color:#859900;">sample_random_light_ris</span><span style="color:#657b83;">(</span><span>geometry_data.world_position, geometry_data.world_normal, workgroup_id.xy, </span><span style="color:#859900;">&amp;</span><span>rng</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span> </span><span style="color:#586e75;">// Sample indirect lighting via BRDF sampling + world cache querying (2nd ray) </span><span> </span><span style="color:#268bd2;">let</span><span> ray_direction </span><span style="color:#657b83;">= </span><span style="color:#859900;">sample_cosine_hemisphere</span><span style="color:#657b83;">(</span><span>geometry_data.world_normal, </span><span style="color:#859900;">&amp;</span><span>rng</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> ray_hit </span><span style="color:#657b83;">= </span><span style="color:#859900;">trace_ray</span><span style="color:#657b83;">(</span><span>geometry_data.world_position, ray_direction, </span><span style="color:#cb4b16;">RAY_T_MIN</span><span>, </span><span style="color:#cb4b16;">RAY_T_MAX</span><span>, </span><span style="color:#cb4b16;">RAY_FLAG_NONE</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#859900;">if</span><span> ray_hit.kind </span><span style="color:#657b83;">!= </span><span style="color:#cb4b16;">RAY_QUERY_INTERSECTION_NONE </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#268bd2;">let</span><span> ray_hit </span><span style="color:#657b83;">= </span><span style="color:#859900;">resolve_ray_hit_full</span><span style="color:#657b83;">(</span><span>ray_hit</span><span style="color:#657b83;">)</span><span>; </span><span> new_irradiance </span><span style="color:#657b83;">+=</span><span> ray_hit.material.base_color </span><span style="color:#657b83;">* </span><span style="color:#859900;">query_world_cache</span><span style="color:#657b83;">(</span><span>ray_hit.world_position, ray_hit.geometric_world_normal, view.world_position</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#657b83;">} </span><span> </span><span> world_cache_active_cells_new_irradiance</span><span style="color:#657b83;">[</span><span>active_cell_id.x</span><span style="color:#657b83;">] =</span><span> new_irradiance; </span><span> </span><span style="color:#657b83;">} </span><span style="color:#657b83;">} </span></code></pre> <p>The direct light sample is chosen via RIS, and uses the same presampled light tiles that we're going to use for ReSTIR DI. It's basically the same process as ReSTIR DI initial candidate sampling.</p> <p>I've thought about using ReSTIR (well, ReTIR, without the spatial resampling part) for the world cache, but it's not something I've tried yet.</p> <p>The indirect light sample is a little more interesting.</p> <p>In order to estimate indirect lighting, we trace a ray using a cosine-hemisphere distribution. At the ray hit point, we query the world cache.</p> <p>You might be thinking "Wait, aren't we <em>updating</em> the cache? But we're also sampling from the same cache in order to... update it?"</p> <p>By having the cache sample from itself, we form a full path tracer, where tracing the path is spread out across multiple frames (for performance).</p> <p>As an example: In frame 5, world cache cell A samples a light source. In frame 6, a different world cache cell B samples cell A. In frame 7, yet another world cache cell C samples cell B. We've now formed a multi-bounce path <code>light source-&gt;A-&gt;B-&gt;C</code>, and once ReSTIR GI gets involved, <code>light source-&gt;A-&gt;B-&gt;C-&gt;primary surface-&gt;camera</code>.</p> <p>By having the cache sample itself, we get full-length multi-bounce paths, instead of just single-bounce paths. In indoor scenes that make heavy use of indirect lighting, the difference is pretty dramatic.</p> <p><figure> <img src="cornell_box_no_multi_bounce.png" > <figcaption><p>Single-bounce lighting</p> </figcaption> </figure> <figure> <img src="cornell_box_multi_bounce.png" > <figcaption><p>Multi-bounce lighting</p> </figcaption> </figure> </p> <h4 id="cache-blend">Cache Blend<a class="zola-anchor" href="#cache-blend" aria-label="Anchor link for: cache-blend" style="visibility: hidden;"></a> </h4> <p>The second and final step of the world cache update process is to blend the new light samples with the existing irradiance samples, giving us an estimate of the overall irradiance via temporal accumulation. If you've ever seen code for temporal antialiasing, this should look pretty familiar.</p> <p>The blending factor is based on the total sample count of voxel, capped at a max value. New voxels without any existing irradiance estimate use more of the new sample's contribution, while existing voxels with existing irradiance estimates use less of the new sample.</p> <p>Choosing the max sample count is a tradeoff between having the cache be stable and low-variance, and having the cache be responsive to changes in the scene's lighting.</p> <p>It's also important to note that this is a separate compute dispatch from the previous dispatch we used for sampling lighting. If the passes were combined, we would have data races from voxels writing new irradiance estimates at the same time other voxels were querying them.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#859900;">@</span><span>compute </span><span style="color:#859900;">@workgroup_size</span><span style="color:#657b83;">(</span><span style="color:#6c71c4;">1024</span><span>, </span><span style="color:#6c71c4;">1</span><span>, </span><span style="color:#6c71c4;">1</span><span style="color:#657b83;">) </span><span style="color:#268bd2;">fn </span><span style="color:#b58900;">blend_new_samples</span><span style="color:#657b83;">(</span><span>@builtin</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">global_invocation_id</span><span style="color:#657b83;">) </span><span style="color:#268bd2;">active_cell_id</span><span>: vec3&lt;</span><span style="color:#268bd2;">u32</span><span>&gt;</span><span style="color:#657b83;">) { </span><span> </span><span style="color:#859900;">if</span><span> active_cell_id.x </span><span style="color:#657b83;">&lt;</span><span> world_cache_active_cells_count </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#268bd2;">let</span><span> cell_index </span><span style="color:#657b83;">=</span><span> world_cache_active_cell_indices</span><span style="color:#657b83;">[</span><span>active_cell_id.x</span><span style="color:#657b83;">]</span><span>; </span><span> </span><span> </span><span style="color:#268bd2;">let</span><span> old_irradiance </span><span style="color:#657b83;">=</span><span> world_cache_irradiance</span><span style="color:#657b83;">[</span><span>cell_index</span><span style="color:#657b83;">]</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> new_irradiance </span><span style="color:#657b83;">=</span><span> world_cache_active_cells_new_irradiance</span><span style="color:#657b83;">[</span><span>active_cell_id.x</span><span style="color:#657b83;">]</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> sample_count </span><span style="color:#657b83;">= </span><span style="color:#859900;">min</span><span style="color:#657b83;">(</span><span>old_irradiance.a </span><span style="color:#657b83;">+ </span><span style="color:#6c71c4;">1.0</span><span>, </span><span style="color:#cb4b16;">WORLD_CACHE_MAX_TEMPORAL_SAMPLES</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span> </span><span style="color:#268bd2;">let</span><span> blended_irradiance </span><span style="color:#657b83;">= </span><span style="color:#859900;">mix</span><span style="color:#657b83;">(</span><span>old_irradiance.rgb, new_irradiance, </span><span style="color:#6c71c4;">1.0 </span><span style="color:#657b83;">/</span><span> sample_count</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span> world_cache_irradiance</span><span style="color:#657b83;">[</span><span>cell_index</span><span style="color:#657b83;">] = </span><span style="color:#859900;">vec4</span><span style="color:#657b83;">(</span><span>blended_irradiance, sample_count</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#657b83;">} </span><span style="color:#657b83;">} </span></code></pre> <h3 id="dlss-ray-reconstruction">DLSS Ray Reconstruction<a class="zola-anchor" href="#dlss-ray-reconstruction" aria-label="Anchor link for: dlss-ray-reconstruction" style="visibility: hidden;"></a> </h3> <p>Once we have our noisy estimate of the scene, we run it through DLSS-RR to upscale, antialias, and denoise it.</p> <p><figure> <img src="noisy_full.png" > <figcaption><p>Noisy and aliased image</p> </figcaption> </figure> <figure> <img src="denoised_full.png" > <figcaption><p>Denoised and antialiased image</p> </figcaption> </figure> <figure> <img src="pathtraced.png" > <figcaption><p>Pathtraced reference</p> </figcaption> </figure> </p> <p>While ideally we would be able to configure DLSS-RR to read directly from our GBuffer, we unfortunately need a small pass to first copy from the GBuffer to some standalone textures. DLSS-RR will read these textures as inputs to help guide the denoising pass.</p> <p>DLSS-RR is called via the <a rel="nofollow noreferrer" href="https://crates.io/crates/dlss_wgpu">dlss_wgpu</a> wrapper I wrote, which is integrated into bevy_anti_alias as a Bevy plugin.</p> <blockquote class="callout note no-title"> <div class="icon"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18"><path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM11 7H13V9H11V7ZM11 11H13V17H11V11Z" fill="currentColor"></path></svg> </div> <div class="content"> <p>The dlss_wgpu crate is standalone, and can also be used by non-Bevy projects that are using wgpu!</p> </div> </blockquote> <p><figure> <img src="denoised_di.png" > <figcaption><p>Denoised and antialiased image - DI only</p> </figcaption> </figure> <figure> <img src="denoised_gi.png" > <figcaption><p>Denoised and antialiased image - GI only</p> </figcaption> </figure> </p> <h2 id="performance">Performance<a class="zola-anchor" href="#performance" aria-label="Anchor link for: performance" style="visibility: hidden;"></a> </h2> <h3 id="numbers">Numbers<a class="zola-anchor" href="#numbers" aria-label="Anchor link for: numbers" style="visibility: hidden;"></a> </h3> <p>Timings for all scenes were measured on an RTX 3080, rendered at 1600x900, and upscaled to 3200x1800 using DLSS-RR performance mode.</p> <p><figure> <img src="pica_pica_perf.png" > <figcaption><p>PICA PICA</p> </figcaption> </figure> <figure> <img src="bistro_perf.png" > <figcaption><p>Bistro</p> </figcaption> </figure> <figure> <img src="cornell_box_perf.png" > <figcaption><p>Cornell Box</p> </figcaption> </figure> </p> <!-- | Pass | PICA PICA Duration (ms) | Bistro Duration (ms) | Cornell Box Duration (ms) | Dependent On | |:---------------------------------:|:-----------------------:|:--------------------:|:-------------------------:|:------------:| | Presample Light Tiles | 0.02761 | 0.08403 | 0.02436 | Negligible | | World Cache: Decay Cells | 0.01508 | 0.02007 | 0.01484 | Negligible | | World Cache: Compaction P1 | 0.03823 | 0.04357 | 0.03776 | Negligible | | World Cache: Compaction P2 | 0.00862 | 0.00903 | 0.00858 | Negligible | | World Cache: Write Active Cells | 0.01451 | 0.01942 | 0.00138 | Negligible | | World Cache: Sample Lighting | 0.06009 | 2.09000 | 0.05367 | World size | | World Cache: Blend New Samples | 0.01286 | 0.06737 | 0.01272 | Negligible | | ReSTIR DI: Initial + Temporal | 1.25000 | 1.85000 | 1.28000 | Pixel count | | ReSTIR DI: Spatial + Shade | 0.18628 | 0.65952 | 0.18127 | Pixel count | | ReSTIR GI: Initial + Temporal | 0.36913 | 2.75000 | 0.32722 | Pixel count | | ReSTIR GI: Spatial + Shade | 0.44301 | 0.59905 | 0.45791 | Pixel count | | DLSS-RR: Copy Inputs From GBuffer | 0.04185 | 0.06789 | 0.03517 | Pixel count | | DLSS-RR | 5.75000 | 6.29000 | 5.82000 | Pixel count | | Total | 8.21727 | 14.54995 | 8.25488 | N/A | --> <table><thead><tr><th style="text-align: center">Pass</th><th style="text-align: center">PICA PICA Duration (ms)</th><th style="text-align: center">Bistro Duration (ms)</th><th style="text-align: center">Cornell Box Duration (ms)</th><th style="text-align: center">Dependent On</th></tr></thead><tbody> <tr><td style="text-align: center">Presample Light Tiles</td><td style="text-align: center">0.03</td><td style="text-align: center">0.08</td><td style="text-align: center">0.02</td><td style="text-align: center">Negligible</td></tr> <tr><td style="text-align: center">World Cache: Decay Cells</td><td style="text-align: center">0.02</td><td style="text-align: center">0.02</td><td style="text-align: center">0.01</td><td style="text-align: center">Negligible</td></tr> <tr><td style="text-align: center">World Cache: Compaction P1</td><td style="text-align: center">0.04</td><td style="text-align: center">0.04</td><td style="text-align: center">0.04</td><td style="text-align: center">Negligible</td></tr> <tr><td style="text-align: center">World Cache: Compaction P2</td><td style="text-align: center">0.01</td><td style="text-align: center">0.01</td><td style="text-align: center">0.01</td><td style="text-align: center">Negligible</td></tr> <tr><td style="text-align: center">World Cache: Write Active Cells</td><td style="text-align: center">0.01</td><td style="text-align: center">0.02</td><td style="text-align: center">0.01</td><td style="text-align: center">Negligible</td></tr> <tr><td style="text-align: center">World Cache: Sample Lighting</td><td style="text-align: center">0.06</td><td style="text-align: center">2.09</td><td style="text-align: center">0.05</td><td style="text-align: center">World size</td></tr> <tr><td style="text-align: center">World Cache: Blend New Samples</td><td style="text-align: center">0.01</td><td style="text-align: center">0.07</td><td style="text-align: center">0.01</td><td style="text-align: center">Negligible</td></tr> <tr><td style="text-align: center">ReSTIR DI: Initial + Temporal</td><td style="text-align: center">1.25</td><td style="text-align: center">1.85</td><td style="text-align: center">1.28</td><td style="text-align: center">Pixel count</td></tr> <tr><td style="text-align: center">ReSTIR DI: Spatial + Shade</td><td style="text-align: center">0.19</td><td style="text-align: center">0.66</td><td style="text-align: center">0.18</td><td style="text-align: center">Pixel count</td></tr> <tr><td style="text-align: center">ReSTIR GI: Initial + Temporal</td><td style="text-align: center">0.37</td><td style="text-align: center">2.75</td><td style="text-align: center">0.33</td><td style="text-align: center">Pixel count</td></tr> <tr><td style="text-align: center">ReSTIR GI: Spatial + Shade</td><td style="text-align: center">0.44</td><td style="text-align: center">0.60</td><td style="text-align: center">0.46</td><td style="text-align: center">Pixel count</td></tr> <tr><td style="text-align: center">DLSS-RR: Copy Inputs From GBuffer</td><td style="text-align: center">0.04</td><td style="text-align: center">0.07</td><td style="text-align: center">0.04</td><td style="text-align: center">Pixel count</td></tr> <tr><td style="text-align: center">DLSS-RR</td><td style="text-align: center">5.75</td><td style="text-align: center">6.29</td><td style="text-align: center">5.82</td><td style="text-align: center">Pixel count</td></tr> <tr><td style="text-align: center">Total</td><td style="text-align: center">8.22</td><td style="text-align: center">14.55</td><td style="text-align: center">8.25</td><td style="text-align: center">N/A</td></tr> </tbody></table> <h3 id="upscaling-benefits">Upscaling Benefits<a class="zola-anchor" href="#upscaling-benefits" aria-label="Anchor link for: upscaling-benefits" style="visibility: hidden;"></a> </h3> <p>While DLSS-RR is quite expensive, it still ends up saving performance overall.</p> <p>Without upscaling, we would have 4x as many pixels total, meaning ReSTIR DI and GI would be ~4x as expensive. After that, we would need a separate denoising process (usually two separate processes, one for direct and one for indirect), a separate shading pass to apply the denoised lighting, and then an antialiasing method.</p> <p>Total performance costs would be higher than using the unified upscaling + denoising + antialiasing pipeline that DLSS-RR provides.</p> <p>DLSS-RR also performs much better on the newer Ada and Blackwell GPUs.</p> <p><img src="https://jms55.github.io/posts/2025-09-20-solari-bevy-0-17/dlss_rr_perf.png" alt="dlss_rr_perf" /></p> <h3 id="nsight-trace">NSight Trace<a class="zola-anchor" href="#nsight-trace" aria-label="Anchor link for: nsight-trace" style="visibility: hidden;"></a> </h3> <p>Looking at a GPU trace, our main ReSTIR DI/GI passes are primarily memory bound.</p> <p>The ReSTIR DI initial and temporal pass is mainly limited by loads from global memory (blue bar), which source-code level profiling reveals to come from loading <code>ResolvedLightSamplePacked</code> samples from light tiles during initial sampling.</p> <p>The ReSTIR DI spatial and shade pass, and both ReSTIR GI passes, are limited by raytracing throughput (yellow bar).</p> <figure> <img src="nsight_trace.png" > <figcaption><p>NSight Graphics GPU Trace</p> </figcaption> </figure> <p>There are typically three ways to improve memory-bound shaders:</p> <ol> <li>Loading less data</li> <li>Improving cache hit rate</li> <li>Hiding the latency</li> </ol> <p>For ReSTIR DI initial sampling, this would correspond to:</p> <ol> <li>Taking less than 32 initial samples (viable, depending on the scene)</li> <li>Can't do this - we're already hitting 95% L2 cache throughput</li> <li>Would need to increase <a rel="nofollow noreferrer" href="https://gpuopen.com/learn/occupancy-explained">occupancy</a></li> </ol> <p>Unfortunately, the only real optimization I think we could do is hiding the latency by improving the occupancy. More threads for the GPU to swap between when while waiting for memory loads to finish = finishing the overall workload faster.</p> <p>NSight shows that we have a mediocre 32 out of a hardware maximum of 48 warps occupied, limited by the "registers per thread limiter". I.e. our shader code uses too many registers per thread, and NSight does not have enough register space to allocate additional warps.</p> <p>Source-code level profiling shows that the majority of live registers are consumed by the <a rel="nofollow noreferrer" href="https://github.com/bevyengine/bevy/blob/8b36cca28c4ea00425e1414fd88c8b82297e2b96/crates/bevy_solari/src/scene/raytracing_scene_bindings.wgsl#L177-L215">triangle resolve function</a>, which maps a point on a mesh to surface data like position, normal, material properties, etc. I'm not really sure how to reduce register usage here.</p> <p>For the other 3 passes limited by raytracing throughput, we have the same issue. Not a ton we can do besides hiding the latency, which runs into the same issue with register count and occupancy.</p> <p>For GI specifically though, there <em>is</em> a way I have thought of to do less work, again at the cost of worse quality depending on the scene.</p> <p>For the world cache, rather than trace rays for every active cell, we could do it for a random subset of cells each frame (up to some maximum), to help limit the cost of updating many cache entries.</p> <p>For the ReSTIR GI passes, we could perform them at quarter resolution (half the pixels along each axis). GI is not particuarly important to have exactly per-pixel data, so we can calculate it at a lower resolution, and then <a rel="nofollow noreferrer" href="https://www.nvidia.com/en-us/on-demand/session/gdc25-gdc1002">upscale</a> (timestamp 17:22). This upscaling would be in addition to the the DLSS-RR upscaling.</p> <h2 id="future-work">Future Work<a class="zola-anchor" href="#future-work" aria-label="Anchor link for: future-work" style="visibility: hidden;"></a> </h2> <p>As always, the first release of a new plugin is just the start. I still have a ton of ideas for future improvements to Solari!</p> <h3 id="feature-parity">Feature Parity<a class="zola-anchor" href="#feature-parity" aria-label="Anchor link for: feature-parity" style="visibility: hidden;"></a> </h3> <p>In terms of feature parity with Bevy's standard renderer, the most important missing feature is support for specular, transparent, and alpha-masked materials.</p> <p>I've been actively prototyping specular material support, and with any luck will be writing about it in a future blog post on Solari changes in Bevy v0.18.</p> <p>Custom material support is another big one, although it's blocked on raytracing pipeline support in wgpu (which would also unlock shader execution reordering!).</p> <p>Support for skinned meshes first needs some work done in Bevy to add GPU-driven skinning, but would be a great feature to add.</p> <p>Finally, Solari is eventually going to want to support more types of lights such as point lights, spot lights, and image-based lighting.</p> <h3 id="light-sampling">Light Sampling<a class="zola-anchor" href="#light-sampling" aria-label="Anchor link for: light-sampling" style="visibility: hidden;"></a> </h3> <p>Light sampling in Solari is currently purely random (not even uniformly random!), and there's big opportunities to improve it.</p> <p>Having a large number of lights in the scenes is <em>theoretically</em> viable with ReSTIR, but in practice Solari is not yet there. We need some sort of spatial/visibility-aware sampling to improve the quality of our initial candidate samples.</p> <p>One approach another Bevy developer is exploring is using <a rel="nofollow noreferrer" href="https://gpuopen.com/download/Hierarchical_Light_Sampling_with_Accurate_Spherical_Gaussian_Lighting.pdf">spherical gaussian light trees</a>.</p> <p>Another promising direction is copying from the recently released <a rel="nofollow noreferrer" href="https://advances.realtimerendering.com/s2025/content/MegaLights_Stochastic_Direct_Lighting_2025.pdf">MegaLights</a> presentation, and adding visible light lists. I want to experiment with implementing light lists in world space, so that it can also be used to improve our GI.</p> <h3 id="chromatic-restir">Chromatic ReSTIR<a class="zola-anchor" href="#chromatic-restir" aria-label="Anchor link for: chromatic-restir" style="visibility: hidden;"></a> </h3> <p>Another problem is that overlapping lights of similar brightness, but different chromas (R,G,B) tend to pose a problem for ReSTIR. ReSTIR can only select a single sample, but in this case, there are multiple overlapping lights.</p> <p>One approach I've been prototyping to solve this is using <a rel="nofollow noreferrer" href="https://suikasibyl.github.io/files/vvmc/paper.pdf">ratio control variates</a> (RCV). The basic idea (if I understand the paper correctly) is that you apply a vector-valued (R,G,B) weight to your lighting integral, based on the fraction of light a given sample contributes, divided by the overall light in the scene.</p> <p>E.g. if you sample a pure red light, but the scene has a large amount of blue and green light, then you downweight the sample's red contribution, and upweight its blue and green contributions.</p> <p>The paper gives a scheme involving precomputing (offline) the total amount of light in the scene ahead of time, using light trees. We could easily add RCV support if we go ahead with adding light trees to Solari.</p> <p>But another option I've been testing (without much luck yet) is to learn an <em>online</em> estimate of the total light in the scene. The idea is that each reservoir keeps track of the total amount of light it sees per channel as you do initial sampling and resampling between reservoirs. When it comes time to shade the final selected sample, you can use this estimate with RCV to weight the sample appropriately.</p> <p>We'll see if I can get it working!</p> <h3 id="gi-quality">GI Quality<a class="zola-anchor" href="#gi-quality" aria-label="Anchor link for: gi-quality" style="visibility: hidden;"></a> </h3> <p>While the world cache greatly improves GI quality and performance, it also brings its own set of downsides.</p> <p>The main one is that when we create a cache entry, we set its world-space position and normal. Every frame when the cache entry samples lighting, it uses that position and normal for sampling. The position and normal are fixed, and can never be updated.</p> <p>This means that if a bad position or normal that poorly represents the cache voxel is chosen when initializing the voxel, then it's stuck with that. This leads to weird artifacts that I haven't figured out how to solve, like some screenshots having orange lighting around the robot, and others not.</p> <p>Another unsolved problem is overall loss of energy. Compare the below screenshots of the current Solari scheme to a different scheme where instead of terminating in the world cache, the GI system traces an additional ray towards a random light.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#586e75;">// Baseline scheme using the world cache </span><span>reservoir.radiance </span><span style="color:#657b83;">= </span><span style="color:#859900;">query_world_cache</span><span style="color:#657b83;">(</span><span>sample_point.world_position, sample_point.geometric_world_normal, view.world_position</span><span style="color:#657b83;">)</span><span>; </span><span>reservoir.unbiased_contribution_weight </span><span style="color:#657b83;">= </span><span style="color:#859900;">uniform_hemisphere_inverse_pdf</span><span style="color:#657b83;">()</span><span>; </span><span> </span><span style="color:#586e75;">// Alternate scheme sampling and tracing a ray towards 1 random light </span><span style="color:#268bd2;">let</span><span> direct_lighting </span><span style="color:#657b83;">= </span><span style="color:#859900;">sample_random_light</span><span style="color:#657b83;">(</span><span>sample_point.world_position, sample_point.world_normal, rng</span><span style="color:#657b83;">)</span><span>; </span><span>reservoir.radiance </span><span style="color:#657b83;">=</span><span> direct_lighting.radiance; </span><span>reservoir.unbiased_contribution_weight </span><span style="color:#657b83;">=</span><span> direct_lighting.inverse_pdf </span><span style="color:#657b83;">* </span><span style="color:#859900;">uniform_hemisphere_inverse_pdf</span><span style="color:#657b83;">()</span><span>; </span></code></pre> <figure> <img src="no_world_cache.png" > <figcaption><p>Alternate GI scheme, without the world cache</p> </figcaption> </figure> <p>Despite the alternate scheme having higher variance and no multibounce pathtracing, it's actually <em>brighter</em> than using the world cache. For some reason, the voxelized nature of the world cache leads to a loss of energy.</p> <p>I've been thinking about trying out reprojecting the last frame to get multi bounce for rays that hit within the camera's view, instead of always relying on the world cache. That might mitigate some of the energy loss.</p> <p>Finally, the biggest problem with GI in general is both the overall lack of stability, and the slow reaction time to scene changes. The voxelized nature of the world cache, combined with how ReSTIR amplifies samples, means that bright outliers (e.g. world cache voxels much bighter than their neighbors) lead to temporal instability as shown below.</p> <p><img src="https://jms55.github.io/posts/2025-09-20-solari-bevy-0-17/gi_outlier.png" alt="gi_outlier" /></p> <p>While we could slow down the temporal accumulation speed to improve stability, that would slow down how fast Solari can react to changes in the scene's lighting. Our goal is realtime, <em>fully</em> dynamic lighting. Not sorta realtime, but actual realtime.</p> <p>Unfortunately the lack of validation rays in the ReSTIR GI temporal pass, combined with the recursive nature of the world cache, means that Solari already takes a decent amount of time to react to changes. Animated and moving light sources in particular leave trails behind in the GI. Slowing down the temporal accumulation speed would make it even worse.</p> <p>Going forwards with the project, I'm looking to mitigate all of these problems.</p> <p>While it would be more expensive, one option I've considered is combining the alternate sampling scheme with some kind of world-space feedback mechanism like the MegaLights visible light list I described above. The GI pass could trace an additional ray towards a light instead of sampling the world cache. If the light is visible, we could add it to a list stored in a world-space voxel, to be fed back into the (GI or DI) light sampling for future frames.</p> <h3 id="denoising-options">Denoising Options<a class="zola-anchor" href="#denoising-options" aria-label="Anchor link for: denoising-options" style="visibility: hidden;"></a> </h3> <p>While Solari currently requires a NVIDIA GPU, the DLSS-RR integration is a separate plugin from Solari. Users can optionally choose to bring their own denoiser.</p> <p>In the future, whenever they release them, I'm hoping to add support for <a rel="nofollow noreferrer" href="https://web.archive.org/web/20250822144949/https://www.amd.com/en/products/graphics/technologies/fidelityfx/super-resolution.html#upcoming">AMD's FSR Ray Regeneration</a>, whatever XeSS extension <a rel="nofollow noreferrer" href="https://community.intel.com/t5/Blogs/Tech-Innovation/Client/Neural-Image-Reconstruction-for-Real-Time-Path-Tracing/post/1688192">Intel</a> eventually releases, and potentially even <a rel="nofollow noreferrer" href="https://developer.apple.com/documentation/metalfx/mtl4fxtemporaldenoisedscaler">Apple's MTL4FXTemporalDenoisedScaler</a>. Even <a rel="nofollow noreferrer" href="https://newsroom.arm.com/news/arm-announces-arm-neural-technology">ARM</a> is working on a neural-network based denoiser!</p> <p>Writing a denoiser from scratch is a lot of work, but it would also be nice to add <a rel="nofollow noreferrer" href="https://developer.download.nvidia.com/video/gputechconf/gtc/2020/presentations/s22699-fast-denoising-with-self-stabilizing-recurrent-blurs.pdf">ReBLUR</a> as a fallback for users of other GPUs.</p> <h2 id="thank-you">Thank You<a class="zola-anchor" href="#thank-you" aria-label="Anchor link for: thank-you" style="visibility: hidden;"></a> </h2> <p>If you've read this far, thank you, I hope you've enjoyed it! (to be fair, I can't imagine you got this far if you didn't enjoy reading it...)</p> <p>Solari represents the culmination of a significant amount of research, development, testing, refining, and more than a few tears over the last three years of my spare time. Not just from me, but also from the shoulders of all the research and work it stands on. I couldn't be more proud of what I've made.</p> <p>Like the rest of Bevy, Solari is also free and open source, forever.</p> <p>If you find Solari useful, consider <a rel="nofollow noreferrer" href="https://github.com/sponsors/JMS55">donating</a> to help fund future development.</p> <h2 id="further-reading">Further Reading<a class="zola-anchor" href="#further-reading" aria-label="Anchor link for: further-reading" style="visibility: hidden;"></a> </h2> <ul> <li><a rel="nofollow noreferrer" href="https://intro-to-restir.cwyman.org">A Gentle Introduction to ReSTIR: Path Reuse in Real-time</a></li> <li><a rel="nofollow noreferrer" href="https://interplayoflight.wordpress.com/2023/12/17/a-gentler-introduction-to-restir">A gentler introduction to ReSTIR</a></li> <li><a rel="nofollow noreferrer" href="https://research.nvidia.com/labs/rtr/publication/bitterli2020spatiotemporal">Spatiotemporal Reservoir Resampling for Real-time Ray Tracing with Dynamic Direct Lighting</a></li> <li><a rel="nofollow noreferrer" href="https://research.nvidia.com/publication/2021-06_restir-gi-path-resampling-real-time-path-tracing">ReSTIR GI: Path Resampling for Real-Time Path Tracing</a></li> <li><a rel="nofollow noreferrer" href="https://cwyman.org/papers/hpg21_rearchitectingReSTIR.pdf">Rearchitecting Spatiotemporal Resampling for Production</a></li> <li><a rel="nofollow noreferrer" href="https://blog.traverseresearch.nl/dynamic-diffuse-global-illumination-b56dc0525a0a">Dynamic diffuse global illumination</a></li> <li><a rel="nofollow noreferrer" href="https://github.com/EmbarkStudios/kajiya/blob/main/docs/gi-overview.md">Kajiya global illumination overview</a></li> <li><a rel="nofollow noreferrer" href="https://advances.realtimerendering.com/s2025/content/SOUSA_SIGGRAPH_2025_Final.pdf">Fast as Hell: idTech8 Global Illumination</a></li> <li><a rel="nofollow noreferrer" href="https://advances.realtimerendering.com/s2022/SIGGRAPH2022-Advances-Lumen-Wright%20et%20al.pdf">Lumen: Real-time Global Illumination in Unreal Engine 5</a></li> <li><a rel="nofollow noreferrer" href="https://advances.realtimerendering.com/s2025/content/MegaLights_Stochastic_Direct_Lighting_2025.pdf">MegaLights: Stochastic Direct Lighting in Unreal Engine 5</a></li> <li><a rel="nofollow noreferrer" href="https://gpuopen.com/download/GPUOpen2022_GI1_0.pdf">GI-1.0: A Fast Scalable Two-Level Radiance Caching Scheme for Real-Time Global Illumination</a></li> </ul> Bevy's Fifth Birthday - Progress and Production Readiness 2025-09-03T00:00:00+00:00 2025-09-03T00:00:00+00:00 Unknown https://jms55.github.io/posts/2025-09-03-bevy-fifth-birthday/ <blockquote> <p>Written in response to <a rel="nofollow noreferrer" href="https://bevy.org/news/bevys-fifth-birthday">Bevy's Fifth Birthday</a>.</p> </blockquote> <h3 id="introduction">Introduction<a class="zola-anchor" href="#introduction" aria-label="Anchor link for: introduction" style="visibility: hidden;"></a> </h3> <p>Welcome to the review of my third year of Bevy development!</p> <p>After three years, I'm still enjoying Bevy as much as ever. Not only that, but development is the smoothest it's ever been!</p> <p>As is my usual writing style, this post is going to be a bit dry and disjointed, and maybe not the most hype-oriented. It's not exactly what I aimed for when I started writing, but it seems to be how I end up writing things :). I <em>did</em> try using an LLM to help with writing, but it was way too fawning, so in the end I've written this by hand. Perfect is the enemy of good and all that.</p> <p>While I <em>am</em> really excited about Bevy, this year and every year, I'll leave hyping Bevy up to others, so go read their blog posts once they're posted to <a rel="nofollow noreferrer" href="https://bevy.org">https://bevy.org</a>! Consider this post more a brain dump of my own experiences, rather than on Bevy as a project.</p> <p>Anyways, let's talk about how this year went.</p> <h3 id="my-stuff">My Stuff<a class="zola-anchor" href="#my-stuff" aria-label="Anchor link for: my-stuff" style="visibility: hidden;"></a> </h3> <p>The Bevy community has collectively landed a metric truckload of features and improvements this year. Like, just a mind-blowingly large amount.</p> <p>The Bevy 0.15, 0.16, and 0.17 release notes do a good job of highlighting what's new in each release, so I'm going to give a brief overview of just the (rendering) work I did this year that I'm particularly proud of.</p> <p>The biggest project for me this year has been the massive amounts of improvements I (@JMS55), @atlv24, and @SparkyPotato have landed for virtual geometry. When I wrote about Bevy's fourth birthday, we had landed the initial virtual geometry feature in Bevy 0.14. Since then, we've made numerous improvements to asset deserialization, compression, rasterization performance, LOD selection, LOD building, and culling in Bevy 0.15-0.17.</p> <p>I'm optimistic that this year will be the year that we add streaming, improve CPU performance, and fix the remaining culling bugs. With any luck with asset processing (more on this later), we'll finally take virtual geometry out of experimental status later this year!</p> <p><img src="https://jms55.github.io/posts/2025-09-03-bevy-fifth-birthday/meshlet.png" alt="Virtual geometry scene with thousands of dragon meshes" /></p> <blockquote> <p>Side note: Unlike the last few releases, I won't be writing a blog post about virtual geometry for Bevy 0.17. I didn't work on the BVH-culling PR for Bevy 0.17 (that was all @atlv24 and @SparkyPotato) due to a combination of burnout and life getting in the way. While I've taken a break from virtual geometry, I've started <em>another</em> huge project: Solari.</p> </blockquote> <p>Bevy Solari is a brand new crate for raytraced lighting coming in Bevy 0.17. While most of the work was technically done in Bevy 0.17, its origins trace back to a ~2 year old project. I've mentioned in past blog posts how I started it, and then later abandoned it (which lead to me starting virtual geometry) after poor results, and issues with keeping forks of bevy/wgpu/naga_oil up to date.</p> <p>Now that I'm taking a break from virtual geometry, and due to the introduction of some new algorithms and research papers, along with 2 additional years of learning under my belt and upstreamed raytracing in wgpu, I've restarted the project with a completely new approach. I'm really <em>super</em> excited to share what we have so far, so expect a more detailed blog post about this soon!</p> <p><img src="https://jms55.github.io/posts/2025-09-03-bevy-fifth-birthday/solari.png" alt="Solari demo scene" /></p> <p>Like with Solari, DLSS integration is another abandoned project that I've revived thanks to work done in wgpu to enable interopt with underlying graphics APIs like Vulkan. Bevy 0.17 will be shipping support for DLSS (and DLSS-RR), alongside it's existing anti-aliasing options in MSAA, FXAA, SMAA, and TAA. NVIDIA users now have a great option for anti-aliasing, and much cheaper rendering via upscaling.</p> <p>I also wanted to add FSR4 support, but sadly FSR4 was released as a DirectX-only SDK, without any Vulkan support. This would have meant redoing a lot of work, and wasn't going to be done in time for Bevy 0.17 (and I don't own an RX 9070 XT). Still, eventually we could add support for FSR and XeSS (and potentially MetalFX), now that the infrastructure for temporal upscaling is in place.</p> <p><img src="https://jms55.github.io/posts/2025-09-03-bevy-fifth-birthday/dlss.jpg" alt="DLSS demo" /></p> <p>The last major feature I landed this year was hooking up our existing GPU timestamps to Tracy, the profiling tool we use. Now Bevy users can see combined CPU and GPU bottlenecks in one place, which is super useful!</p> <p><img src="https://jms55.github.io/posts/2025-09-03-bevy-fifth-birthday/tracy.png" alt="Tracy screenshot showing GPU timings" /></p> <p>This year I would also like to shout-out several contributors (besides @altv24 and @SparkyPotato) I've been working with: @cart and @alice-i-cecile of course, as well as (in no particular order) @mockersf, @tychedelia, @Elabajaba, @DGriffin91, @IceSentry, @mate-h, @ecoskey, @NthTensor, @viridia, @pcwalton, and @ickshonpe, as well as @cwfitzgerald and @Vecvec for their work on wgpu. Without their help, none of this would have been possible!</p> <h3 id="unexpected-improvements">Unexpected Improvements<a class="zola-anchor" href="#unexpected-improvements" aria-label="Anchor link for: unexpected-improvements" style="visibility: hidden;"></a> </h3> <p>Along with the usual headline features, there's also been a lot of work done by the Bevy community this year that has ended up suprising me.</p> <p>The major one would be required components. I was fairly skeptical of them when they were first introduced, and thought it wasn't really an "ECS" way of doing this. In retrospect, I was totally wrong. As a user, using required components is <em>way</em> more pleasant than the older bundle-based API, and is easier to get started with. It ends up being <em>easier</em> to explain to users to spawn and query an entity with a <code>Camera</code> component, rather than spawning with a <code>CameraBundle</code> and then querying for a <code>Camera</code>. As a plugin author, required components give me some peace of mind knowing that users can't easily add a component without adding its dependencies (and make the API a little nicer compared to bundles). There <em>is</em> still some rough edges to sort out, mainly making some sort of priority system for required components to override each other (e.g. <code>Camera</code> requiring <code>Msaa::Sample4</code>, and then <code>TemporalAntiAliasing</code> being able to override that with <code>Msaa::Off</code>), but overall it was an unexpectedly nice improvement. Cart absolutely cooked with this change.</p> <p>Retained rendering, and the other GPU-driven rendering parts have also worked out really well. Again some sharp edges with the APIs (mainly cleaning up render world entities being hard to write and easy to mess up), but these were hugely foundational changes, that overall landed really smoothly! I don't think the Bevy of 1-2 years ago would have landed these so easily, and they've <em>drastically</em> improved performance.</p> <p>The introduction of working groups was, to some extent, a big contributor of this. In the past I've complained about review speed for PRs, but this year it's been <em>way</em> better. Working groups give a set of built-in reviewers for larger projects, and Alice's Monday merge train often ends up being the final maintainer-review-once-over before merging, which keeps PRs merging smoothly. It's been working quite well!</p> <p>Similarly, having release notes in the main Bevy repo, and required as part of submitting PRs (something I've been advocating for!) has made the release process for Bevy 0.17 <em>so much easier</em>. Rather than having to crunch out release notes, changelogs, and showcases out at the end of the cycle (when we're all burnt out from writing PRs), writing them incrementally as a part of the PR process has been a huge time stress relief. As an unexpected benefit, it also makes reviewing PRs much easier, as it forces the author to write a good user-facing description of the changes for reviewers.</p> <h3 id="next-year">Next Year?<a class="zola-anchor" href="#next-year" aria-label="Anchor link for: next-year" style="visibility: hidden;"></a> </h3> <p>The end of the birthday post is usually where I take some time to talk about what I'm planning to work on for the next year, but honestly I don't have a ton of plans at the moment.</p> <p>Continuing virtual geometry and Solari is a given, but otherwise I don't have any other concrete goals in terms of features.</p> <p>Neural-compressed textures would be cool, but is maybe a little too-researchy, and requires better asset processing APIs.</p> <p>Writing more blog posts would be great, but I probably don't have it in me to write these more frequently. I <em>have</em> been using a <a rel="nofollow noreferrer" href="https://bsky.app/profile/jms5517.bsky.social">Bluesky page</a> to document short progress snippets as I work on PRs, so maybe follow that if you're interested in my content.</p> <p>I <em>would</em> like to write more documentation this year though - both API docs, and module docs / Bevy book content. As Bevy is getting increasingly mature, docs have become one of the bigger sticking points. Rendering in particular needs a lot more docs, both because it's under-documented, and because it's a fairly arcane subject.</p> <p>When I first started making 3d games, and later when I started working on rendering, I had absolutely no clue what to do. How to light a scene, how to write a custom material, what's important for rendering performance, how do I write my own rendering feature &lt;FOO&gt;, and more are all questions that would greatly benefit from some longer-form written documents.</p> <p>As we start to run out of major rendering features, putting my energy towards writing more docs seems like a good way to move Bevy closer to being production-ready. I've already <a rel="nofollow noreferrer" href="https://github.com/bevyengine/bevy-website/pull/2195">started writing some stuff</a>.</p> <h3 id="production-ready-what-s-missing">Production Ready - What's Missing?<a class="zola-anchor" href="#production-ready-what-s-missing" aria-label="Anchor link for: production-ready-what-s-missing" style="visibility: hidden;"></a> </h3> <p>So instead of writing my plans for next year, let's talk about what I think Bevy is missing (besides docs). I don't necessarily plan or not plan on working on any of this myself, but here are the things that I feel make it hard to say "Just use Bevy, duh!"</p> <p><strong>UI</strong> continues to be a weak point. While <code>bevy_ui</code> is a great foundation for rendering UI (in large part to @ickshonpe's and the taffy team's heroic efforts), no third-party crate (including my own bevy_dioxus) has proven out a good high-level API for declaring and updating UI trees. BSN is coming soon, but it only solves the declarative part of UI, and not the reactivity part. Until we resolve this, it's hard to reccomend Bevy for UI-heavy games and apps, and more importantly, we can't build the-</p> <p><strong>Editor</strong> absence continues to be a big, big hole for Bevy. Not just in terms of being production ready, but I think the first release with an official editor is going to get an exponential influx of new users, and eventually new contributors. Working on the Solari demo scene has made me feel the lack of an editor badly. It was quite frustrating trying to get the materials correct for everything without an editor. I <em>did</em> work on a <a rel="nofollow noreferrer" href="https://github.com/bevyengine/bevy_editor_prototypes/pull/167">prototype</a> scene tree + inspector using a third-party BSN crate, but it was exceedingly difficult to write and understand, and I gave up on it. I'm really excited to work on the editor, but I'm going to hold off until reactive UI lands.</p> <p><strong>Asset processing</strong> is another big bottleneck. Hard to say Bevy is production ready when the only texture compressor it has is an outdated version of BasisU. While cart added some asset processing APIs with Assets V2, it's clunky and dosen't support enough features. Trying to write a glTF -&gt; virtual geometry processor <a rel="nofollow noreferrer" href="https://github.com/bevyengine/bevy/pull/13431">proved to be unfeasible</a>. Once cart is done with BSN and reactivity, I would like to see him go back to this area. I would also like to move away from recommending that users ship their games with glTF/glb scenes, and instead provide some kind of glTF -&gt; BSN + seperate image/mesh assets importer, that we can then run further asset processing on. This is another area that would greatly benefit from having an Editor.</p> <p><strong>Animation</strong> isn't something I know a ton about, but after recently trying it out in Bevy, I can definitely say it's lacking. The API is quite clunky, with too many confusingly-named components and amount of entities needed, and not enough features.</p> <p><strong>Custom Materials</strong> in Bevy are currently servicable, but not enjoyable. Users have a lot of power, but that's because we don't really provide much in the way of customizable abstractions. Mostly on the shader side, but also partly on the Rust side, with users having to resort to <code>MeshTag</code> and <code>ShaderStorageBuffer</code> to get good performance. The Material API should be completely redesigned, unified across 3d/2d/UI, and made much easier to use for common use-cases. We've been throwing around ideas in the #rendering-dev channel on Discord, but nothing concrete yet. This is a good area to get involved in!</p> <p>Overall, I do see paths to improving all of these areas over the next year (or likely two for animations and editor). I am a little disappointed with how long it has taken to land BSN, mostly with the opaqueness of the process (which cart has talked about, so I'm not going to repeat here), but I'm hopeful about the next steps!</p> <p>See you next year!</p> Virtual Geometry in Bevy 0.16 2025-03-27T00:00:00+00:00 2025-03-27T00:00:00+00:00 Unknown https://jms55.github.io/posts/2025-03-27-virtual-geometry-bevy-0-16/ <h2 id="introduction">Introduction<a class="zola-anchor" href="#introduction" aria-label="Anchor link for: introduction" style="visibility: hidden;"></a> </h2> <p>Bevy 0.16 is releasing soon, and as usual it's time for me to write about the progress I've made on virtual geometry over the last couple of months.</p> <p>Due to a combination of life being busy, and taking an ongoing break from the project to work on other stuff (due to burnout), I haven't gotten as much done as the last two releases. This will be a much shorter blog post than usual.</p> <h2 id="metis-based-triangle-clustering">METIS-based Triangle Clustering<a class="zola-anchor" href="#metis-based-triangle-clustering" aria-label="Anchor link for: metis-based-triangle-clustering" style="visibility: hidden;"></a> </h2> <p>PR <a rel="nofollow noreferrer" href="https://github.com/bevyengine/bevy/pull/16947">#16947</a> improves the DAG quality.</p> <p>I've said it before, and I'll say it again - DAG quality is the most important part of virtual geometry (and the hardest to get right).</p> <p>Before, in order to group triangles into meshlets, I was simply relying on meshoptimizer's <code>meshopt_buildMeshlets()</code> function. It works pretty good for the general use case of splitting meshes into meshlets, but for virtual geometry, it's not ideal.</p> <p>Meshoptimizer prioritizes generating nice clusters for culling and vertex reuse, but for virtual geometry, we want to ensure that meshlets share as few vertices as possible. Less shared vertices between meshlets means less locked vertices when simplifying, which leads to better DAG quality.</p> <p>Minimizing shared vertices between meshlets when clustering triangles is the same problem as minimizing shared vertices between meshlet groups when grouping meshlets. We will once again use METIS to partition a graph, where nodes are triangles, edges connect adjacent triangles, and edge weights are the count of shared vertices between the triangles.</p> <p>From there it was just a lot of experimentation and tweaking parameters in order to get METIS to generate good meshlets. The secret ingredients I discovered for good clustering are:</p> <ol> <li>Set UFactor to 1 in METIS's options (did you know METIS has an options struct?), to ensure as little imbalance between partitions as possible.</li> <li>Undershoot the partition count a little. Otherwise METIS will tend to overshoot and give you too many triangles per meshlet. For 128 max triangles per cluster, I set <code>partition_count = number_of_triangles.div_ceil(126)</code>.</li> </ol> <p>With this, we get a nicer quality DAG. Up until now, I've been plagued by tiny &lt;10 triangles meshlets that tend to get "stuck" and not simplify into higher LOD levels. Now we get nice and even meshlets that simplify well as we build the higher LOD levels.</p> <center> <p><img src="https://jms55.github.io/posts/2025-03-27-virtual-geometry-bevy-0-16/old_dag.png" alt="Old DAG" /> <em>Old DAG</em></p> <p><img src="https://jms55.github.io/posts/2025-03-27-virtual-geometry-bevy-0-16/new_dag.png" alt="New DAG" /> <em>New DAG</em></p> </center> <p>I'm still not done working on DAG quality - I haven't considered spatial positions of triangles/meshlets for grouping things yet - but this was a great step forwards.</p> <h2 id="texture-atomics">Texture Atomics<a class="zola-anchor" href="#texture-atomics" aria-label="Anchor link for: texture-atomics" style="visibility: hidden;"></a> </h2> <p>PR <a rel="nofollow noreferrer" href="https://github.com/bevyengine/bevy/pull/17765">#17765</a> improves the runtime performance.</p> <p>Thanks once again to @atlv24's work on wgpu/naga, we now have access to atomic operations on u64/u32 storage textures!</p> <p>Instead of using a plain GPU buffer to store our visbuffer, and buffer atomics to rasterize, we'll now use a R64Uint/R32Uint storage texture, and use texture atomics for rasterization.</p> <p>Things get a little bit faster, mostly due to cache behaviors for texture-like access patterns being better with actual textures instead of buffers.</p> <h3 id="faster-depth-resolve">Faster Depth Resolve<a class="zola-anchor" href="#faster-depth-resolve" aria-label="Anchor link for: faster-depth-resolve" style="visibility: hidden;"></a> </h3> <p>The real win however, was actually an entirely unrelated change I made in the same PR.</p> <p>After rasterizing to the visbuffer texture (packed depth + cluster ID + triangle ID), there are two fullscreen triangle render passes to read from the visbuffer and write depth to both an actual depth texture, and the "material depth" texture discussed in previous posts.</p> <p>Lets look at the material depth resolve shader:</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#859900;">@</span><span>fragment </span><span style="color:#268bd2;">fn </span><span style="color:#b58900;">resolve_material_depth</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">in</span><span>: FullscreenVertexOutput</span><span style="color:#657b83;">) </span><span>-&gt; </span><span style="color:#859900;">@builtin</span><span style="color:#657b83;">(</span><span>frag_depth</span><span style="color:#657b83;">) </span><span style="color:#268bd2;">f32 </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#268bd2;">let</span><span> visibility </span><span style="color:#657b83;">=</span><span> textureLoad</span><span style="color:#657b83;">(</span><span>meshlet_visibility_buffer, vec2&lt;</span><span style="color:#268bd2;">u32</span><span>&gt;</span><span style="color:#657b83;">(</span><span style="color:#859900;">in</span><span>.position.xy</span><span style="color:#657b83;">))</span><span>.r; </span><span> </span><span> </span><span style="color:#268bd2;">let</span><span> depth </span><span style="color:#657b83;">=</span><span> visibility </span><span style="color:#657b83;">&gt;&gt;</span><span> 32u; </span><span> </span><span style="color:#859900;">if</span><span> depth </span><span style="color:#657b83;">==</span><span> 0lu </span><span style="color:#657b83;">{</span><span> discard; </span><span style="color:#657b83;">} </span><span style="color:#586e75;">// This line is new </span><span> </span><span> </span><span style="color:#268bd2;">let</span><span> cluster_id </span><span style="color:#657b83;">= </span><span style="color:#268bd2;">u32</span><span style="color:#657b83;">(</span><span>visibility</span><span style="color:#657b83;">) &gt;&gt;</span><span> 7u; </span><span> </span><span style="color:#268bd2;">let</span><span> instance_id </span><span style="color:#657b83;">=</span><span> meshlet_cluster_instance_ids</span><span style="color:#657b83;">[</span><span>cluster_id</span><span style="color:#657b83;">]</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> material_id </span><span style="color:#657b83;">=</span><span> meshlet_instance_material_ids</span><span style="color:#657b83;">[</span><span>instance_id</span><span style="color:#657b83;">]</span><span>; </span><span> </span><span style="color:#859900;">return </span><span style="color:#268bd2;">f32</span><span style="color:#657b83;">(</span><span>material_id</span><span style="color:#657b83;">) / </span><span style="color:#6c71c4;">65535.0</span><span>; </span><span style="color:#657b83;">} </span></code></pre> <p>For pixels where depth is 0 (i.e. the background, i.e. no meshes covering that pixel), we don't need to write depth out. The textures are already cleared to zero by the render pass setup.</p> <p>Adding this single line to discard the background fragments doubled the performance of the resolve depth/material depth passes in the demo scene.</p> <h3 id="issues-with-clearing">Issues With Clearing<a class="zola-anchor" href="#issues-with-clearing" aria-label="Anchor link for: issues-with-clearing" style="visibility: hidden;"></a> </h3> <p>In Bevy we cache resource between frames, and so at the start of the frame, we need to clear the visbuffer texture back to zero to prepare it for use during the frame.</p> <p>Wgpu has some simple <code>CommandEncoder::clear_buffer()</code> and <code>CommandEncoder::clear_texture()</code> commands. But their behavior under the hood might be a little unintuitive if you've never used Vulkan before.</p> <p>When I initially switched the visbuffer from a buffer to a storage texture, and switched the clear from <code>CommandEncoder::clear_buffer()</code> to <code>CommandEncoder::clear_texture()</code>, I profiled and was shocked to see this:</p> <center> <p><img src="https://jms55.github.io/posts/2025-03-27-virtual-geometry-bevy-0-16/slow_clear.png" alt="Slow frame trace" /></p> </center> <p>0.68ms spent on a single vkCmdCopyBufferToImage, just to clear the texture. Before, using buffers, it was a simple vkCmdFillBuffer that took 0.01ms. What's going on?</p> <p>Well, under the hood, <code>CommandEncoder::clear_texture()</code> maps to one of the following operations:</p> <ol> <li>If the texture was created with the <code>TextureUsages::RENDER_ATTACHMENT</code> bit set, create a render pass with no draws and fragment load op = clear in order to clear the texture.</li> <li>Otherwise allocate a big buffer filled with zeros, and then use vkCmdCopyBufferToImage to copy zeros to fill the texture.</li> </ol> <p>Option #1 is out since R64Uint/R32Uint textures don't support the <code>TextureUsages::RENDER_ATTACHMENT</code> bit, and of course as we've found out, option #2 is horribly slow.</p> <p>The <em>best</em> option would be to use vkClearColorImage to clear the texture, which should be a similar fast path in the driver to using vkCmdFillBuffer with zeros, but wgpu neither uses vkClearColorImage internally, nor exposes it to users.</p> <p>So instead I wrote a custom compute pass (and all the CPU-side boilerplate that that entails) to manually zero the texture, like so:</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#859900;">#</span><span>ifdef </span><span style="color:#cb4b16;">MESHLET_VISIBILITY_BUFFER_RASTER_PASS_OUTPUT </span><span style="color:#859900;">@group</span><span style="color:#657b83;">(</span><span style="color:#6c71c4;">0</span><span style="color:#657b83;">) </span><span style="color:#859900;">@binding</span><span style="color:#657b83;">(</span><span style="color:#6c71c4;">0</span><span style="color:#657b83;">)</span><span> var meshlet_visibility_buffer: texture_storage_2d&lt;r64uint, write&gt;; </span><span style="color:#859900;">#else </span><span style="color:#859900;">@group</span><span style="color:#657b83;">(</span><span style="color:#6c71c4;">0</span><span style="color:#657b83;">) </span><span style="color:#859900;">@binding</span><span style="color:#657b83;">(</span><span style="color:#6c71c4;">0</span><span style="color:#657b83;">)</span><span> var meshlet_visibility_buffer: texture_storage_2d&lt;r32uint, write&gt;; </span><span style="color:#859900;">#</span><span>endif </span><span>var&lt;push_constant&gt; view_size: vec2&lt;</span><span style="color:#268bd2;">u32</span><span>&gt;; </span><span> </span><span style="color:#859900;">@</span><span>compute </span><span style="color:#859900;">@workgroup_size</span><span style="color:#657b83;">(</span><span style="color:#6c71c4;">16</span><span>, </span><span style="color:#6c71c4;">16</span><span>, </span><span style="color:#6c71c4;">1</span><span style="color:#657b83;">) </span><span style="color:#268bd2;">fn </span><span style="color:#b58900;">clear_visibility_buffer</span><span style="color:#657b83;">(</span><span>@builtin</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">global_invocation_id</span><span style="color:#657b83;">) </span><span style="color:#268bd2;">global_id</span><span>: vec3&lt;</span><span style="color:#268bd2;">u32</span><span>&gt;</span><span style="color:#657b83;">) { </span><span> </span><span style="color:#859900;">if any</span><span style="color:#657b83;">(</span><span>global_id.xy </span><span style="color:#657b83;">&gt;=</span><span> view_size</span><span style="color:#657b83;">) { </span><span style="color:#859900;">return</span><span>; </span><span style="color:#657b83;">} </span><span> </span><span style="color:#859900;">#</span><span>ifdef </span><span style="color:#cb4b16;">MESHLET_VISIBILITY_BUFFER_RASTER_PASS_OUTPUT </span><span> textureStore</span><span style="color:#657b83;">(</span><span>meshlet_visibility_buffer, global_id.xy, </span><span style="color:#859900;">vec4</span><span style="color:#657b83;">(</span><span>0lu</span><span style="color:#657b83;">))</span><span>; </span><span style="color:#859900;">#else </span><span> textureStore</span><span style="color:#657b83;">(</span><span>meshlet_visibility_buffer, global_id.xy, </span><span style="color:#859900;">vec4</span><span style="color:#657b83;">(</span><span>0u</span><span style="color:#657b83;">))</span><span>; </span><span style="color:#859900;">#</span><span>endif </span><span style="color:#657b83;">} </span></code></pre> <p>Still not as fast as vkClearColorImage likely is, but much faster than 0.68ms.</p> <h3 id="texture-atomic-results">Texture Atomic Results<a class="zola-anchor" href="#texture-atomic-results" aria-label="Anchor link for: texture-atomic-results" style="visibility: hidden;"></a> </h3> <p>Overall perf improvement is about 0.42ms faster in a very simple demo scene.</p> <center> <p><img src="https://jms55.github.io/posts/2025-03-27-virtual-geometry-bevy-0-16/buffer_trace.png" alt="Old frame trace" /> <em>Old frame trace</em></p> <p><img src="https://jms55.github.io/posts/2025-03-27-virtual-geometry-bevy-0-16/texture_trace.png" alt="New frame trace" /> <em>New frame trace</em></p> </center> <h2 id="upcoming">Upcoming<a class="zola-anchor" href="#upcoming" aria-label="Anchor link for: upcoming" style="visibility: hidden;"></a> </h2> <p>And that's it for virtual geometry stuff I worked on during Bevy 0.16.</p> <p>In related but non-Bevy news, Nvidia revealed their blackwell RTX 50 series GPUs, with some exciting <a rel="nofollow noreferrer" href="https://github.com/nvpro-samples/build_all?tab=readme-ov-file#mega-geometry">new meshlet/virtual geometry stuff</a>!</p> <ul> <li>New raytracing APIs (not rasterization!) for meshlet-based acceleration structures (CLAS) that are cheaper to build <ul> <li>And on blackwell, CLAS's use a compressed (but sadly opaque) memory format</li> </ul> </li> <li>New demos using CLAS's for animated geometry, dynamic tesselation, and even full Nanite-style virtual geometry!</li> <li>New libraries for generating raytracing-friendly meshlets (i.e. optimized for bounding-box size), and virtual geometry oriented DAGs of meshlets</li> </ul> <p>One of the biggest issues with Nanite (besides aggregate geometry like foilage) is that it came about right when realtime raytracing was starting to pick up. Until now, it hasn't been clear how to integrate virtual geometry with raytracing (beyond rasterizing the geometry to a gbuffer, so at least you get more primary visibility detail). These new APIs resolve that issue.</p> <p>Meshoptimizer v0.23 also released recently, with some new APIs (<code>meshopt_buildMeshletsFlex</code>, <code>meshopt_partitionClusters</code>, <code>meshopt_computeSphereBounds</code>) that I need to try out for DAG building at some point.</p> <p>Finally of course, I need to work on BVH-based culling for Bevy's virtual geometry. As I went over in my last post, culling is the biggest bottleneck at the moment. I did start working on it during the 0.16 dev cycle, but burned out before the end. We'll see what happens this cycle.</p> <p>Enjoy Bevy 0.16!</p> Virtual Geometry in Bevy 0.15 2024-11-14T00:00:00+00:00 2024-11-14T00:00:00+00:00 Unknown https://jms55.github.io/posts/2024-11-14-virtual-geometry-bevy-0-15/ <h2 id="introduction">Introduction<a class="zola-anchor" href="#introduction" aria-label="Anchor link for: introduction" style="visibility: hidden;"></a> </h2> <p><img src="https://jms55.github.io/posts/2024-11-14-virtual-geometry-bevy-0-15/cover.png" alt="Screenshot of some megascans in Bevy 0.15" /></p> <center> <p><em>Original scene by <a rel="nofollow noreferrer" href="https://discord.com/channels/691052431525675048/1302853333387575340/1302853473997422623">Griffin</a>. Slightly broken due to lack of double-sided material support.</em></p> </center> <p>It's been a little over 5 months <a href="https://jms55.github.io/posts/2024-06-09-virtual-geometry-bevy-0-14/">since my last post</a> where I talked about the very early prototype of virtual geometry I wrote for Bevy 0.14.</p> <p>While it's still not production ready, the improved version of virtual geometry that will ship in Bevy 0.15 (which is releasing soon) is a very large step in the right direction!</p> <p>In this blog post I'll be going over all the virtual geometry PRs merged since my last post, in chronological order. At the end, I'll do a performance comparison of Bevy 0.15 vs 0.14, and finally discuss my roadmap for what I'm planning to work on in Bevy 0.16 and beyond.</p> <p>Like last time, a lot of the larger architectural changes are copied from Nanite based on the SIGGRAPH presentation, which you should watch if you want to learn more.</p> <p>It's going to be another super long read, so grab some snacks and strap in!</p> <h2 id="arseny-kapoulkine-s-contributions">Arseny Kapoulkine's Contributions<a class="zola-anchor" href="#arseny-kapoulkine-s-contributions" aria-label="Anchor link for: arseny-kapoulkine-s-contributions" style="visibility: hidden;"></a> </h2> <p>PRs <a rel="nofollow noreferrer" href="https://github.com/bevyengine/bevy/pull/13904">#13904</a>, <a rel="nofollow noreferrer" href="https://github.com/bevyengine/bevy/pull/13913">#13913</a>, and <a rel="nofollow noreferrer" href="https://github.com/bevyengine/bevy/pull/14038">#14038</a> improve the performance of the Mesh to MeshletMesh converter, and makes it more deterministic. These were written by Arseny Kapoulkine (author of meshoptimizer, the library I use for mesh simplification and meshlet building). Thanks for the contributions!</p> <p>PR <a rel="nofollow noreferrer" href="https://github.com/bevyengine/bevy/pull/14042">#14042</a>, also by Kapoulkine, fixed a bug with how we calculate the depth pyramid mip level to sample at for occlusion culling.</p> <p>These PRs were actually shipped in Bevy 0.14, but were opened after I published my last post, hence why I'm covering them now.</p> <h2 id="faster-meshletmesh-loading">Faster MeshletMesh Loading<a class="zola-anchor" href="#faster-meshletmesh-loading" aria-label="Anchor link for: faster-meshletmesh-loading" style="visibility: hidden;"></a> </h2> <p>PR <a rel="nofollow noreferrer" href="https://github.com/bevyengine/bevy/pull/14193">#14193</a> improves performance when loading MeshletMesh assets from disk.</p> <p>Previously I was using the <code>bincode</code> and <code>serde</code> crates to serialize and deserialize MeshletMeshes. All I had to do was slap <code>#[derive(Serialize, Deserialize)]</code> on the type, and then I could use <code>bincode::serialize_into()</code> to turn my asset into a slice of bytes for writing to disk, and <code>bincode::deserialize_from()</code> in order to turn a slice of bytes loaded from disk back into my asset type. Easy.</p> <p>Unfortunately, that ease of use came with a good bit of performance overhead. Specifically in the deserializing step, where bytes get turned into the asset type. Deserializing the 5mb Stanford Bunny asset I was using for testing took a depressingly long 77ms on my Ryzen 5 2600 CPU.</p> <p>Thinking about the code flow more, we <em>already</em> have an asset -&gt; bytes step. After the asset is loaded into CPU memory, we serialize it <em>back</em> into bytes so that we can upload it to GPU memory. For this, we use the <code>bytemuck</code> crate which provides functions for casting slices of data that are <code>Pod</code> (plain-old-data, i.e. just numbers, which all of our asset data is) to slices of bytes, without any real overhead.</p> <p>Why not simply use bytemuck to cast our asset data to slices of bytes, and write that? Similarly for reading from disk, we can simply cast the slice of bytes back to our asset type.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#268bd2;">fn </span><span style="color:#b58900;">write_slice</span><span>&lt;T: Pod&gt;</span><span style="color:#657b83;">( </span><span> </span><span style="color:#268bd2;">field</span><span>: </span><span style="color:#859900;">&amp;</span><span>[T], </span><span> </span><span style="color:#268bd2;">writer</span><span>: </span><span style="color:#859900;">&amp;</span><span style="color:#93a1a1;">mut</span><span> dyn Write, </span><span style="color:#657b83;">) </span><span>-&gt; </span><span style="color:#859900;">Result</span><span>&lt;</span><span style="color:#657b83;">()</span><span>, MeshletMeshSaveOrLoadError&gt; </span><span style="color:#657b83;">{ </span><span> writer.</span><span style="color:#859900;">write_all</span><span style="color:#657b83;">(</span><span style="color:#859900;">&amp;</span><span style="color:#657b83;">(</span><span>field.</span><span style="color:#859900;">len</span><span style="color:#657b83;">() </span><span style="color:#859900;">as </span><span style="color:#268bd2;">u64</span><span style="color:#657b83;">)</span><span>.</span><span style="color:#859900;">to_le_bytes</span><span style="color:#657b83;">())</span><span style="color:#859900;">?</span><span>; </span><span> writer.</span><span style="color:#859900;">write_all</span><span style="color:#657b83;">(</span><span>bytemuck::cast_slice</span><span style="color:#657b83;">(</span><span>field</span><span style="color:#657b83;">))</span><span style="color:#859900;">?</span><span>; </span><span> </span><span style="color:#859900;">Ok</span><span style="color:#657b83;">(()) </span><span style="color:#657b83;">} </span><span> </span><span style="color:#268bd2;">fn </span><span style="color:#b58900;">read_slice</span><span>&lt;T: Pod&gt;</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">reader</span><span>: </span><span style="color:#859900;">&amp;</span><span style="color:#93a1a1;">mut</span><span> dyn Read</span><span style="color:#657b83;">) </span><span>-&gt; </span><span style="color:#859900;">Result</span><span>&lt;Arc&lt;</span><span style="color:#657b83;">[</span><span>T</span><span style="color:#657b83;">]</span><span>&gt;, std::io::Error&gt; </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#268bd2;">let</span><span> len </span><span style="color:#657b83;">= </span><span style="color:#859900;">read_u64</span><span style="color:#657b83;">(</span><span>reader</span><span style="color:#657b83;">)</span><span style="color:#859900;">? as </span><span style="color:#268bd2;">usize</span><span>; </span><span> </span><span> </span><span style="color:#268bd2;">let </span><span style="color:#93a1a1;">mut</span><span> data: Arc&lt;</span><span style="color:#657b83;">[</span><span>T</span><span style="color:#657b83;">]</span><span>&gt; </span><span style="color:#657b83;">= </span><span>std::iter::repeat_with</span><span style="color:#657b83;">(</span><span>T::zeroed</span><span style="color:#657b83;">)</span><span>.</span><span style="color:#859900;">take</span><span style="color:#657b83;">(</span><span>len</span><span style="color:#657b83;">)</span><span>.</span><span style="color:#859900;">collect</span><span style="color:#657b83;">()</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> slice </span><span style="color:#657b83;">= </span><span>Arc::get_mut</span><span style="color:#657b83;">(</span><span style="color:#859900;">&amp;</span><span style="color:#93a1a1;">mut</span><span> data</span><span style="color:#657b83;">)</span><span>.</span><span style="color:#859900;">unwrap</span><span style="color:#657b83;">()</span><span>; </span><span> reader.</span><span style="color:#859900;">read_exact</span><span style="color:#657b83;">(</span><span>bytemuck::cast_slice_mut</span><span style="color:#657b83;">(</span><span>slice</span><span style="color:#657b83;">))</span><span style="color:#859900;">?</span><span>; </span><span> </span><span> </span><span style="color:#859900;">Ok</span><span style="color:#657b83;">(</span><span>data</span><span style="color:#657b83;">) </span><span style="color:#657b83;">} </span></code></pre> <p>These two functions are all we need to read and write asset data. <code>write_slice()</code> takes a slice of asset data, writes the length of the slice, and then casts the slice to bytes and writes it to disk. <code>read_slice()</code> reads the length of the slice from disk, allocates an atomically reference counted buffer of that size, and then reads from disk to fill the buffer, casting it back into the asset data type.</p> <p>Writing the entire asset to disk now looks like this:</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#859900;">write_slice</span><span style="color:#657b83;">(</span><span style="color:#859900;">&amp;</span><span>asset.vertex_data, </span><span style="color:#859900;">&amp;</span><span style="color:#93a1a1;">mut</span><span> writer</span><span style="color:#657b83;">)</span><span style="color:#859900;">?</span><span>; </span><span style="color:#859900;">write_slice</span><span style="color:#657b83;">(</span><span style="color:#859900;">&amp;</span><span>asset.vertex_ids, </span><span style="color:#859900;">&amp;</span><span style="color:#93a1a1;">mut</span><span> writer</span><span style="color:#657b83;">)</span><span style="color:#859900;">?</span><span>; </span><span style="color:#859900;">write_slice</span><span style="color:#657b83;">(</span><span style="color:#859900;">&amp;</span><span>asset.indices, </span><span style="color:#859900;">&amp;</span><span style="color:#93a1a1;">mut</span><span> writer</span><span style="color:#657b83;">)</span><span style="color:#859900;">?</span><span>; </span><span style="color:#859900;">write_slice</span><span style="color:#657b83;">(</span><span style="color:#859900;">&amp;</span><span>asset.meshlets, </span><span style="color:#859900;">&amp;</span><span style="color:#93a1a1;">mut</span><span> writer</span><span style="color:#657b83;">)</span><span style="color:#859900;">?</span><span>; </span><span style="color:#859900;">write_slice</span><span style="color:#657b83;">(</span><span style="color:#859900;">&amp;</span><span>asset.bounding_spheres, </span><span style="color:#859900;">&amp;</span><span style="color:#93a1a1;">mut</span><span> writer</span><span style="color:#657b83;">)</span><span style="color:#859900;">?</span><span>; </span></code></pre> <p>And reading it back from disk looks like this:</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#268bd2;">let</span><span> vertex_data </span><span style="color:#657b83;">= </span><span style="color:#859900;">read_slice</span><span style="color:#657b83;">(</span><span>reader</span><span style="color:#657b83;">)</span><span style="color:#859900;">?</span><span>; </span><span style="color:#268bd2;">let</span><span> vertex_ids </span><span style="color:#657b83;">= </span><span style="color:#859900;">read_slice</span><span style="color:#657b83;">(</span><span>reader</span><span style="color:#657b83;">)</span><span style="color:#859900;">?</span><span>; </span><span style="color:#268bd2;">let</span><span> indices </span><span style="color:#657b83;">= </span><span style="color:#859900;">read_slice</span><span style="color:#657b83;">(</span><span>reader</span><span style="color:#657b83;">)</span><span style="color:#859900;">?</span><span>; </span><span style="color:#268bd2;">let</span><span> meshlets </span><span style="color:#657b83;">= </span><span style="color:#859900;">read_slice</span><span style="color:#657b83;">(</span><span>reader</span><span style="color:#657b83;">)</span><span style="color:#859900;">?</span><span>; </span><span style="color:#268bd2;">let</span><span> bounding_spheres </span><span style="color:#657b83;">= </span><span style="color:#859900;">read_slice</span><span style="color:#657b83;">(</span><span>reader</span><span style="color:#657b83;">)</span><span style="color:#859900;">?</span><span>; </span><span> </span><span style="color:#859900;">Ok</span><span style="color:#657b83;">(</span><span>MeshletMesh </span><span style="color:#657b83;">{ </span><span> vertex_data, </span><span> vertex_ids, </span><span> indices, </span><span> meshlets, </span><span> bounding_spheres, </span><span style="color:#657b83;">}) </span></code></pre> <p>Total load time from disk to CPU memory for our 5mb MeshletMesh went from 102ms down to 12ms, an 8.5x speedup.</p> <h2 id="software-rasterization">Software Rasterization<a class="zola-anchor" href="#software-rasterization" aria-label="Anchor link for: software-rasterization" style="visibility: hidden;"></a> </h2> <p>PR <a rel="nofollow noreferrer" href="https://github.com/bevyengine/bevy/pull/14623">#14623</a> improves our visbuffer rasterization performance for clusters that appear small on screen (i.e. almost all of them). I rewrote pretty much the entire virtual geometry codebase in this PR, so this is going to be a really long section.</p> <h3 id="motivation">Motivation<a class="zola-anchor" href="#motivation" aria-label="Anchor link for: motivation" style="visibility: hidden;"></a> </h3> <p>If you remember the frame breakdown from the last post, visbuffer rasterization took the largest chunk of our frame time. Writing out a buffer of cluster + triangle IDs to render in the culling pass, and then doing a single indirect draw over the total count of triangles does not scale very well.</p> <p>The buffer used a lot of memory (4 bytes per non-culled triangle). The GPU's primitive assembler can't keep up with the sheer number of vertices we're sending it as we're not using indexed triangles (to save extra memory and time spent writing out an index buffer), and therefore lack a vertex cache. And finally the GPU's rasterizer just performs poorly with small triangles, and we have a <em>lot</em> of small triangles.</p> <p>Current GPU rasterizers expect comparatively few triangles that each cover many pixels. They have performance optimizations aimed at that kind of workload like shading 2x2 quads of pixels at a time and tile binning of triangles. Meanwhile, our virtual geometry renderer is aimed at millions of tiny triangles that only cover a pixel each. We need a rasterizer aimed at being efficient over the number of triangles; not the number of covered pixels per triangle.</p> <p>We need a custom rasterizer algorithm, written in a compute shader, that does everything the GPU's hardware rasterizer does, but with the extra optimizations stripped out.</p> <h3 id="preparation">Preparation<a class="zola-anchor" href="#preparation" aria-label="Anchor link for: preparation" style="visibility: hidden;"></a> </h3> <p>Before we get to the actual software rasterizer, there's a bunch of prep work we need to do first. Namely, redoing our entire hardware rasterizer setup.</p> <p>In Bevy 0.14, we were writing out a buffer of triangles from the culling pass, and issuing a single indirect draw to rasterize every triangle in the buffer. We're going to throw all that out, and go with a completely new scheme.</p> <p>First, we need a buffer for to store a bunch of cluster IDs (the ones we want to rasterize). We'll have users give a fixed size for this buffer on startup, based on the maximum number of clusters they expect to have visible in a frame in any given scene (not the amount pre-culling and LOD selection).</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span>MeshletPlugin </span><span style="color:#657b83;">{</span><span> cluster_buffer_slots: </span><span style="color:#6c71c4;">8192 </span><span style="color:#657b83;">} </span><span> </span><span>render_device.</span><span style="color:#859900;">create_buffer</span><span style="color:#657b83;">(</span><span style="color:#859900;">&amp;</span><span>BufferDescriptor </span><span style="color:#657b83;">{ </span><span> label: </span><span style="color:#859900;">Some</span><span style="color:#657b83;">(</span><span>&quot;</span><span style="color:#2aa198;">meshlet_raster_clusters</span><span>&quot;</span><span style="color:#657b83;">)</span><span>, </span><span> size: cluster_buffer_slots </span><span style="color:#859900;">as </span><span style="color:#268bd2;">u64 </span><span style="color:#657b83;">* </span><span>size_of::&lt;</span><span style="color:#268bd2;">u32</span><span>&gt;</span><span style="color:#657b83;">() </span><span style="color:#859900;">as </span><span style="color:#268bd2;">u64</span><span>, </span><span> usage: BufferUsages::</span><span style="color:#cb4b16;">STORAGE</span><span>, </span><span> mapped_at_creation: </span><span style="color:#b58900;">false</span><span>, </span><span style="color:#657b83;">})</span><span>; </span></code></pre> <p>Next, we'll setup two indirect commands in some buffers. One for hardware raster, one for software raster. For hardware raster, we're going to hardcode the vertex count to 64 (the maximum number of triangles per meshlet) times 3 (vertices per triangle) total vertices. We'll also initialize the instance count to zero.</p> <p>This was a sceme I described in my last post, but purposefully avoided due to the lackluster performance. However, now that we're adding a software rasterizer, I expect that almost all clusters will be software rasterized. Therefore some performance loss for the hardware raster is acceptable, as it should be rarely used. In return, we'll get to use a nice trick in the next step.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span>render_device.</span><span style="color:#859900;">create_buffer_with_data</span><span style="color:#657b83;">(</span><span style="color:#859900;">&amp;</span><span>BufferInitDescriptor </span><span style="color:#657b83;">{ </span><span> label: </span><span style="color:#859900;">Some</span><span style="color:#657b83;">(</span><span>&quot;</span><span style="color:#2aa198;">meshlet_hardware_raster_indirect_args</span><span>&quot;</span><span style="color:#657b83;">)</span><span>, </span><span> contents: DrawIndirectArgs </span><span style="color:#657b83;">{ </span><span> vertex_count: </span><span style="color:#6c71c4;">64 </span><span style="color:#657b83;">* </span><span style="color:#6c71c4;">3</span><span>, </span><span> instance_count: </span><span style="color:#6c71c4;">0</span><span>, </span><span> first_vertex: </span><span style="color:#6c71c4;">0</span><span>, </span><span> first_instance: </span><span style="color:#6c71c4;">0</span><span>, </span><span> </span><span style="color:#657b83;">} </span><span> .</span><span style="color:#859900;">as_bytes</span><span style="color:#657b83;">()</span><span>, </span><span> usage: BufferUsages::</span><span style="color:#cb4b16;">STORAGE </span><span style="color:#859900;">| </span><span>BufferUsages::</span><span style="color:#cb4b16;">INDIRECT</span><span>, </span><span style="color:#657b83;">})</span><span>; </span><span> </span><span>render_device.</span><span style="color:#859900;">create_buffer_with_data</span><span style="color:#657b83;">(</span><span style="color:#859900;">&amp;</span><span>BufferInitDescriptor </span><span style="color:#657b83;">{ </span><span> label: </span><span style="color:#859900;">Some</span><span style="color:#657b83;">(</span><span>&quot;</span><span style="color:#2aa198;">meshlet_software_raster_indirect_args</span><span>&quot;</span><span style="color:#657b83;">)</span><span>, </span><span> contents: DispatchIndirectArgs </span><span style="color:#657b83;">{</span><span> x: </span><span style="color:#6c71c4;">0</span><span>, y: </span><span style="color:#6c71c4;">1</span><span>, z: </span><span style="color:#6c71c4;">1 </span><span style="color:#657b83;">}</span><span>.</span><span style="color:#859900;">as_bytes</span><span style="color:#657b83;">()</span><span>, </span><span> usage: BufferUsages::</span><span style="color:#cb4b16;">STORAGE </span><span style="color:#859900;">| </span><span>BufferUsages::</span><span style="color:#cb4b16;">INDIRECT</span><span>, </span><span style="color:#657b83;">})</span><span>; </span></code></pre> <p>In the culling pass, after LOD selection and culling, we're going to replace the the triangle buffer writeout code with something new.</p> <p>First we need to decide if the cluster is going to be software rasterized, or hardware rasterized. For this, my current heuristic is to take the cluster's screen-space AABB size we already calculated for occlusion culling, and check how big it is. If it's small (currently &lt; 64 pixels on both axis), then it should be software rasterized. If it's large, then it gets hardware rasterized.</p> <p>At some point, when I have some better test scenes setup, I'll need to experiment with this parameter and see if I get better results with a different number.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#268bd2;">let</span><span> cluster_is_small </span><span style="color:#657b83;">= </span><span style="color:#859900;">all</span><span style="color:#657b83;">(</span><span style="color:#859900;">vec2</span><span style="color:#657b83;">(</span><span>aabb_width_pixels, aabb_height_pixels</span><span style="color:#657b83;">) &lt; </span><span style="color:#859900;">vec2</span><span style="color:#657b83;">(</span><span style="color:#6c71c4;">64.0</span><span style="color:#657b83;">))</span><span>; </span></code></pre> <p>Finally, the culling pass needs to output a list of clusters for both software and hardware rasterization. For this, I'm going to borrow a trick from Unreal's Nanite I learned from this <a rel="nofollow noreferrer" href="https://www.elopezr.com/a-macro-view-of-nanite">frame breakdown</a>.</p> <p>Instead of allocating two buffers (one for SW raster, one for HW raster), we have the one <code>meshlet_raster_clusters</code> buffer that we'll share between them, saving memory. Software rasterized clusters will be added starting from the left side of the buffer, while hardware rasterized clusters will be added from the right side of the buffer. As long as the buffer is big enough, they'll never overlap.</p> <p>Software rasterized clusters will increment the previously created indirect dispatch (1 workgroup per cluster), while hardware rasterized clusters will increment the previously created indirect draw (one draw instance per cluster).</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span>var buffer_slot: </span><span style="color:#268bd2;">u32</span><span>; </span><span style="color:#859900;">if</span><span> cluster_is_small </span><span style="color:#859900;">&amp;&amp;</span><span> not_intersects_near_plane </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#586e75;">// Append this cluster to the list for software rasterization </span><span> buffer_slot </span><span style="color:#657b83;">=</span><span> atomicAdd</span><span style="color:#657b83;">(</span><span style="color:#859900;">&amp;</span><span>meshlet_software_raster_indirect_args.x, 1u</span><span style="color:#657b83;">)</span><span>; </span><span style="color:#657b83;">} </span><span style="color:#859900;">else </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#586e75;">// Append this cluster to the list for hardware rasterization </span><span> buffer_slot </span><span style="color:#657b83;">=</span><span> atomicAdd</span><span style="color:#657b83;">(</span><span style="color:#859900;">&amp;</span><span>meshlet_hardware_raster_indirect_args.instance_count, 1u</span><span style="color:#657b83;">)</span><span>; </span><span> buffer_slot </span><span style="color:#657b83;">=</span><span> constants.meshlet_raster_cluster_rightmost_slot </span><span style="color:#657b83;">-</span><span> buffer_slot; </span><span style="color:#657b83;">} </span><span>meshlet_raster_clusters</span><span style="color:#657b83;">[</span><span>buffer_slot</span><span style="color:#657b83;">] =</span><span> cluster_id; </span></code></pre> <h3 id="hardware-rasterization-and-atomicmax">Hardware Rasterization and atomicMax<a class="zola-anchor" href="#hardware-rasterization-and-atomicmax" aria-label="Anchor link for: hardware-rasterization-and-atomicmax" style="visibility: hidden;"></a> </h3> <p>We can now perform the indirect draw for hardware rasterization, and an indirect dispatch for software rasterization.</p> <p>In the hardware rasterization pass, since we're now spawning <code>MESHLET_MAX_TRIANGLES * 3</code> vertices per cluster, we now need extra vertex shader invocations to write NaN triangle positions to ensure the extra triangles gets discarded.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#859900;">@</span><span>vertex </span><span style="color:#268bd2;">fn </span><span style="color:#b58900;">vertex</span><span style="color:#657b83;">(</span><span>@builtin</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">instance_index</span><span style="color:#657b83;">) </span><span style="color:#268bd2;">instance_index</span><span>: </span><span style="color:#268bd2;">u32</span><span>, @builtin</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">vertex_index</span><span style="color:#657b83;">) </span><span style="color:#268bd2;">vertex_index</span><span>: </span><span style="color:#268bd2;">u32</span><span style="color:#657b83;">) </span><span>-&gt; VertexOutput </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#268bd2;">let</span><span> cluster_id </span><span style="color:#657b83;">=</span><span> meshlet_raster_clusters</span><span style="color:#657b83;">[</span><span>meshlet_raster_cluster_rightmost_slot </span><span style="color:#657b83;">-</span><span> instance_index</span><span style="color:#657b83;">]</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> meshlet_id </span><span style="color:#657b83;">=</span><span> meshlet_cluster_meshlet_ids</span><span style="color:#657b83;">[</span><span>cluster_id</span><span style="color:#657b83;">]</span><span>; </span><span> var meshlet </span><span style="color:#657b83;">=</span><span> meshlets</span><span style="color:#657b83;">[</span><span>meshlet_id</span><span style="color:#657b83;">]</span><span>; </span><span> </span><span> </span><span style="color:#268bd2;">let</span><span> triangle_id </span><span style="color:#657b83;">=</span><span> vertex_index </span><span style="color:#657b83;">/</span><span> 3u; </span><span> </span><span style="color:#859900;">if</span><span> triangle_id </span><span style="color:#657b83;">&gt;= </span><span style="color:#859900;">get_meshlet_triangle_count</span><span style="color:#657b83;">(</span><span style="color:#859900;">&amp;</span><span>meshlet</span><span style="color:#657b83;">) { </span><span style="color:#859900;">return dummy_vertex</span><span style="color:#657b83;">()</span><span>; </span><span style="color:#657b83;">} </span><span> </span><span> </span><span style="color:#586e75;">// ... </span><span style="color:#657b83;">} </span></code></pre> <p>In the fragment shader, instead of writing to a bound render target, we're now going to do an <code>atomicMax()</code> on a storage buffer to store the rasterized visbuffer result. The reason is that we'll need to do the same for the software rasterization pass (because compute shaders don't have access to render targets), so to keep things simple and reuse the same bind group and underlying texture state between the rasterization passes, we're going to stick to using the atomicMax trick for the hardware rasterization pass as well. The Nanite slides describe this in more detail if you want to learn more.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#859900;">@</span><span>fragment </span><span style="color:#268bd2;">fn </span><span style="color:#b58900;">fragment</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">vertex_output</span><span>: VertexOutput</span><span style="color:#657b83;">) { </span><span> </span><span style="color:#268bd2;">let</span><span> frag_coord_1d </span><span style="color:#657b83;">= </span><span style="color:#268bd2;">u32</span><span style="color:#657b83;">(</span><span>vertex_output.position.y</span><span style="color:#657b83;">) * </span><span style="color:#268bd2;">u32</span><span style="color:#657b83;">(</span><span>view.viewport.z</span><span style="color:#657b83;">) + </span><span style="color:#268bd2;">u32</span><span style="color:#657b83;">(</span><span>vertex_output.position.x</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span> </span><span style="color:#268bd2;">let</span><span> depth </span><span style="color:#657b83;">= </span><span>bitcast&lt;</span><span style="color:#268bd2;">u32</span><span>&gt;</span><span style="color:#657b83;">(</span><span>vertex_output.position.z</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> visibility </span><span style="color:#657b83;">= (</span><span style="color:#268bd2;">u64</span><span style="color:#657b83;">(</span><span>depth</span><span style="color:#657b83;">) &lt;&lt;</span><span> 32u</span><span style="color:#657b83;">) </span><span style="color:#859900;">| </span><span style="color:#268bd2;">u64</span><span style="color:#657b83;">(</span><span>vertex_output.packed_ids</span><span style="color:#657b83;">)</span><span>; </span><span> atomicMax</span><span style="color:#657b83;">(</span><span style="color:#859900;">&amp;</span><span>meshlet_visibility_buffer</span><span style="color:#657b83;">[</span><span>frag_coord_1d</span><span style="color:#657b83;">]</span><span>, visibility</span><span style="color:#657b83;">)</span><span>; </span><span style="color:#657b83;">} </span></code></pre> <p>Special thanks to <a rel="nofollow noreferrer" href="https://github.com/atlv24">@atlv24</a> for adding 64-bit integers and atomic u64 support in wgpu 22, specifically so that I could use it here.</p> <p>Note that there's a couple of improvements we could make here still, pending on support in wgpu and naga for some missing features:</p> <ul> <li>R64Uint texture atomics would both be faster than using buffers, and a bit more ergonomic to sample from and debug. This is hopefully coming in wgpu 24, again thanks to @atlv24.</li> <li>Async compute would let us overlap the hardware and software rasterization passes, which would be safe since they're both writing to the same texture/buffer using atomics, which is another reason to stick with atomics for hardware raster.</li> <li>Wgpu currently requires us to bind an empty render target for the hardware raster, even though we don't ever write to it, which is a waste of VRAM. Ideally we wouldn't need any bound render target.</li> <li>And of course if we had mesh shaders, I wouldn't use a regular draw at all.</li> </ul> <h3 id="rewriting-the-indirect-dispatch">Rewriting the Indirect Dispatch<a class="zola-anchor" href="#rewriting-the-indirect-dispatch" aria-label="Anchor link for: rewriting-the-indirect-dispatch" style="visibility: hidden;"></a> </h3> <p>Before we get to software rasterization (soon, I promise!), we first have to deal with one final problem.</p> <p>We're expecting to deal with a <em>lot</em> of visible clusters. For each software rasterized cluster, we're going to increment the X dimension of an indirect dispatch, with 1 workgroup per cluster. On some GPUs (mainly AMD), you're limited to 65536 workgroups per dispatch dimension, which is too low. We need to do the same trick we've done in the past of turning a 1d dispatch into a higher dimension dispatch (in this case 2d), and then reinterpreting it back as a 1d dispatch ID in the shader.</p> <p>Since this is an indirect dispatch, we'll need to run a single-thread shader after the culling pass and before software rasterization, to do the 1d -&gt; 2d remap of the indirect dispatch arguments on the GPU.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#859900;">@</span><span>compute </span><span style="color:#859900;">@workgroup_size</span><span style="color:#657b83;">(</span><span style="color:#6c71c4;">1</span><span>, </span><span style="color:#6c71c4;">1</span><span>, </span><span style="color:#6c71c4;">1</span><span style="color:#657b83;">) </span><span style="color:#268bd2;">fn </span><span style="color:#b58900;">remap_dispatch</span><span style="color:#657b83;">() { </span><span> meshlet_software_raster_cluster_count </span><span style="color:#657b83;">=</span><span> meshlet_software_raster_indirect_args.x; </span><span> </span><span> </span><span style="color:#859900;">if</span><span> meshlet_software_raster_cluster_count </span><span style="color:#657b83;">&gt;</span><span> max_compute_workgroups_per_dimension </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#268bd2;">let</span><span> n </span><span style="color:#657b83;">= </span><span style="color:#268bd2;">u32</span><span style="color:#657b83;">(</span><span style="color:#859900;">ceil</span><span style="color:#657b83;">(</span><span style="color:#859900;">sqrt</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">f32</span><span style="color:#657b83;">(</span><span>meshlet_software_raster_cluster_count</span><span style="color:#657b83;">))))</span><span>; </span><span> meshlet_software_raster_indirect_args.x </span><span style="color:#657b83;">=</span><span> n; </span><span> meshlet_software_raster_indirect_args.y </span><span style="color:#657b83;">=</span><span> n; </span><span> </span><span style="color:#657b83;">} </span><span style="color:#657b83;">} </span></code></pre> <h3 id="the-software-rasterizer">The Software Rasterizer<a class="zola-anchor" href="#the-software-rasterizer" aria-label="Anchor link for: the-software-rasterizer" style="visibility: hidden;"></a> </h3> <p>Finally, we can do software rasterization.</p> <p>The basic idea is to have a compute shader workgroup with size equal to the max triangles per meshlet.</p> <p>Each thread within the workgroup will load 1 vertex of the meshlet, transform it to screen-space, and then write it to workgroup shared memory and issue a barrier.</p> <p>After the barrier, the workgroup will switch to handling triangles, with one thread per triangle. First each thread will load the 3 indices for its triangle, and then load the 3 vertices from workgroup shared memory based on the indices.</p> <p>Once each thread has the 3 vertices for its triangle, it can compute the position/depth gradients across the triangle, and screen-space bounding box around the triangle.</p> <p>Each thread can then iterate the bounding box (Like Nanite does, choosing to either iterate each pixel or iterate scanlines, based on the bounding box sizes across the subgroup), writing pixels to the visbuffer as it goes using the same atomicMax() method that we used for hardware rasterization.</p> <p>One notable difference to the Nanite slides is that for the scanline variant, I needed to check if the pixel center was within the triangle for each pixel in the scanline, which the slides don't show. Not sure if the slides just omitted it for brevity or what, but I got artifacts if I left the check out.</p> <p>There's also some slight differences between my shader and the GPU rasterizer - I didn't implement absolutely every detail. Notably I skipped fixed-point math and the top-left rule. I should implement these in the future, but for now I haven't seen any issues from skipping them.</p> <h3 id="material-and-depth-resolve">Material and Depth Resolve<a class="zola-anchor" href="#material-and-depth-resolve" aria-label="Anchor link for: material-and-depth-resolve" style="visibility: hidden;"></a> </h3> <p>In Bevy 0.15, after the visbuffer rasterization, we have two final steps.</p> <p>The resolve depth pass reads the visbuffer (which contains packed depth), and writes the depth to the actual depth texture of the camera.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#586e75;">/// This pass writes out the depth texture. </span><span style="color:#859900;">@</span><span>fragment </span><span style="color:#268bd2;">fn </span><span style="color:#b58900;">resolve_depth</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">in</span><span>: FullscreenVertexOutput</span><span style="color:#657b83;">) </span><span>-&gt; </span><span style="color:#859900;">@builtin</span><span style="color:#657b83;">(</span><span>frag_depth</span><span style="color:#657b83;">) </span><span style="color:#268bd2;">f32 </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#268bd2;">let</span><span> frag_coord_1d </span><span style="color:#657b83;">= </span><span style="color:#268bd2;">u32</span><span style="color:#657b83;">(</span><span style="color:#859900;">in</span><span>.position.y</span><span style="color:#657b83;">) *</span><span> view_width </span><span style="color:#657b83;">+ </span><span style="color:#268bd2;">u32</span><span style="color:#657b83;">(</span><span style="color:#859900;">in</span><span>.position.x</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> visibility </span><span style="color:#657b83;">=</span><span> meshlet_visibility_buffer</span><span style="color:#657b83;">[</span><span>frag_coord_1d</span><span style="color:#657b83;">]</span><span>; </span><span> </span><span style="color:#859900;">return </span><span>bitcast&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">u32</span><span style="color:#657b83;">(</span><span>visibility </span><span style="color:#657b83;">&gt;&gt;</span><span> 32u</span><span style="color:#657b83;">))</span><span>; </span><span style="color:#657b83;">} </span></code></pre> <p>The resolve material depth pass has the same role in Bevy 0.15 that it did in Bevy 0.14, where it writes the material ID of each pixel to a depth texture, so that we can later abuse depth testing to ensure we shade the correct pixels during each material draw in the material shading pass.</p> <p>However, you may have noticed that unlike the rasterization pass in Bevy 0.14, the new rasterization passes write only depth and cluster + triangle IDs, and not material IDs. During the rasterization pass, where we want to write only the absolute minimum amount of information per pixel (cluster ID, triangle ID, and depth) that we have to.</p> <p>Because of this, the resolve material depth pass can no longer read the material ID texture and copy it directly to the material depth texture. There's now a new step at the start to first load the material ID based on the visbuffer.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#586e75;">/// This pass writes out the material depth texture. </span><span style="color:#859900;">@</span><span>fragment </span><span style="color:#268bd2;">fn </span><span style="color:#b58900;">resolve_material_depth</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">in</span><span>: FullscreenVertexOutput</span><span style="color:#657b83;">) </span><span>-&gt; </span><span style="color:#859900;">@builtin</span><span style="color:#657b83;">(</span><span>frag_depth</span><span style="color:#657b83;">) </span><span style="color:#268bd2;">f32 </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#268bd2;">let</span><span> frag_coord_1d </span><span style="color:#657b83;">= </span><span style="color:#268bd2;">u32</span><span style="color:#657b83;">(</span><span style="color:#859900;">in</span><span>.position.y</span><span style="color:#657b83;">) *</span><span> view_width </span><span style="color:#657b83;">+ </span><span style="color:#268bd2;">u32</span><span style="color:#657b83;">(</span><span style="color:#859900;">in</span><span>.position.x</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> visibility </span><span style="color:#657b83;">=</span><span> meshlet_visibility_buffer</span><span style="color:#657b83;">[</span><span>frag_coord_1d</span><span style="color:#657b83;">]</span><span>; </span><span> </span><span> </span><span style="color:#268bd2;">let</span><span> depth </span><span style="color:#657b83;">=</span><span> visibility </span><span style="color:#657b83;">&gt;&gt;</span><span> 32u; </span><span> </span><span style="color:#859900;">if</span><span> depth </span><span style="color:#657b83;">==</span><span> 0lu </span><span style="color:#657b83;">{ </span><span style="color:#859900;">return </span><span style="color:#6c71c4;">0.0</span><span>; </span><span style="color:#657b83;">} </span><span> </span><span> </span><span style="color:#268bd2;">let</span><span> cluster_id </span><span style="color:#657b83;">= </span><span style="color:#268bd2;">u32</span><span style="color:#657b83;">(</span><span>visibility</span><span style="color:#657b83;">) &gt;&gt;</span><span> 7u; </span><span> </span><span style="color:#268bd2;">let</span><span> instance_id </span><span style="color:#657b83;">=</span><span> meshlet_cluster_instance_ids</span><span style="color:#657b83;">[</span><span>cluster_id</span><span style="color:#657b83;">]</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> material_id </span><span style="color:#657b83;">=</span><span> meshlet_instance_material_ids</span><span style="color:#657b83;">[</span><span>instance_id</span><span style="color:#657b83;">]</span><span>; </span><span> </span><span> </span><span style="color:#586e75;">// Everything above this line is new - the shader used to just load the </span><span> </span><span style="color:#586e75;">// material_id from another texture </span><span> </span><span> </span><span style="color:#859900;">return </span><span style="color:#268bd2;">f32</span><span style="color:#657b83;">(</span><span>material_id</span><span style="color:#657b83;">) / </span><span style="color:#6c71c4;">65535.0</span><span>; </span><span style="color:#657b83;">} </span></code></pre> <h3 id="retrospect">Retrospect<a class="zola-anchor" href="#retrospect" aria-label="Anchor link for: retrospect" style="visibility: hidden;"></a> </h3> <p>Software rasterization is a lot of complexity, learning, and work - I spent a lot of time researching how the GPU rasterizer works, rewrote a <em>lot</em> of code, and just writing the software rasterization shader itself and getting it bug-free took a week or two of effort. As you'll see later, I missed a couple of (severe) bugs, which will need correcting.</p> <p>The upside is that performance is a <em>lot</em> better than my previous method (I'll show some numbers at the end of this post), and we can have thousands of tiny triangles on screen without hurting performance.</p> <p>My advice to others working on virtual geometry is to skip software raster until close to the end. If you have mesh shaders, stick with those. From what I've heard from other projects, software raster is only a 10-20% performance improvement over mesh shaders in most scenes, unless you really crank the tiny triangle count (which is admittedly a goal, but not an immediate priority).</p> <p>If like me, you don't have mesh shaders, then I would still probably stick with only hardware rasterization until you've exhausted other, more fundamental areas to work on, like culling and DAG building. However, I would learn from my mistakes, and not spend so much time trying to get hardware rasterization to be fast. Just stick to writing out a list of visible cluster IDs in the culling shader and have the vertex shader ignore extra triangles, instead of trying to get clever with writing out a buffer of visible triangles and drawing the minimum number of vertices. You'll eventually add software rasterization, and then the hardware rasterization performance won't be so important.</p> <p>If you do want to implement a rasterizer in software (for virtual geometry, or otherwise), check out the below resources that were a big help for me in learning rasterization and the related math.</p> <ul> <li><a rel="nofollow noreferrer" href="https://kristoffer-dyrkorn.github.io/triangle-rasterizer">A fast and precise triangle rasterizer, by Kristoffer Dyrkorn</a></li> <li><a rel="nofollow noreferrer" href="https://fgiesen.wordpress.com/2013/02/06/the-barycentric-conspirac">The barycentric conspiracy, by Fabian Giesen</a></li> <li><a rel="nofollow noreferrer" href="https://www.youtube.com/watch?v=k5wtuKWmV48">Triangle Rasterization, by pikuma</a></li> </ul> <h2 id="larger-meshlet-sizes">Larger Meshlet Sizes<a class="zola-anchor" href="#larger-meshlet-sizes" aria-label="Anchor link for: larger-meshlet-sizes" style="visibility: hidden;"></a> </h2> <p>PR <a rel="nofollow noreferrer" href="https://github.com/bevyengine/bevy/pull/15023">#15023</a> has a bunch of small improvements to virtual geometry.</p> <p>The main change is switching from a maximum 64 vertices and 64 triangles (<code>64v:64t</code>) to 255 vertices and 128 triangles per meshlet (<code>255v:128t</code>). I found that having a less than or equal <code>v:t</code> ratio leads to most meshlets having less than <code>t</code> triangles, which we don't want. Having a <code>2v:t</code> ratio leads to more fully-filled meshlets, and I went with <code>255v:128t</code> (which is nearly the same as Nanite, minus the fact that meshoptimizer only supports meshlets with up to 255 vertices) over <code>128v:64t</code> after some performance testing.</p> <p>Note that this change involved some other work, such as adjusting software and hardware raster to work with more triangles, software rasterization looping if needed to load 2 vertices per thread instead of 1, using another bit per triangle ID when packing cluster + triangle IDs to accomodate triangles up to 127, etc.</p> <p>The other changes I made were:</p> <ul> <li>Setting the target error when simplifying triangles to <code>f32::MAX</code> (no point in capping it for continuous LOD, gives better simplification results)</li> <li>Adjusting the threshold to allow less-simplified meshes to still count as having been simplified enough (gets us closer to <code>log2(lod_0_meshlet_count)</code> total LOD levels)</li> <li>Setting <code>group_error = max(group_error, all_child_errors)</code> instead of <code>group_error += max(all_child_errors)</code> (not really sure if this is more or less correct)</li> </ul> <h2 id="screenspace-derived-tangents">Screenspace-derived Tangents<a class="zola-anchor" href="#screenspace-derived-tangents" aria-label="Anchor link for: screenspace-derived-tangents" style="visibility: hidden;"></a> </h2> <p>PR <a rel="nofollow noreferrer" href="https://github.com/bevyengine/bevy/pull/15084">#15084</a> calculates tangents at runtime, instead of precomputing them and storing them as part of the MeshletMesh asset.</p> <p>Virtual geometry isn't just about rasterizing huge amounts of high-poly meshes - asset size is also a <em>big</em> factor. GPUs only have so much memory, disks only have so much space, and transfer speeds from disk to RAM and RAM to VRAM are only so fast (as we discovered in the last post).</p> <p>Looking at our asset data, right now we're storing 48 bytes per vertex, with a single set of vertices shared across all meshlets in a meshlet mesh.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#268bd2;">struct </span><span style="color:#b58900;">MeshletVertex </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#268bd2;">position</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span> </span><span style="color:#268bd2;">normal</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span> </span><span style="color:#268bd2;">uv</span><span>: vec2&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span> </span><span style="color:#268bd2;">tangent</span><span>: vec4&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span style="color:#657b83;">} </span></code></pre> <p>An easy way to reduce the amount of data per asset is to just remove the explicitly-stored tangents, and instead calculate them at runtime. In the visbuffer resolve shader function, rather then loading 3 vertex tangents and interpolating across the triangle, we can instead calculate the tangent based on UV derivatives across the triangle.</p> <p>The tangent derivation I used was <a rel="nofollow noreferrer" href="https://jcgt.org/published/0009/03/04">"Surface Gradient–Based Bump Mapping Framework"</a> from Morten S. Mikkelsen (author of the <a rel="nofollow noreferrer" href="http://www.metalliandy.com/mikktspace/tangent_space_normal_maps.html">mikktspace</a> standard). It's a really cool paper that provides a framework for using normal maps in many more scenarios than just screen-space based tangents. Definitely give it a further read.</p> <p>I used the code from this <a rel="nofollow noreferrer" href="https://www.jeremyong.com/graphics/2023/12/16/surface-gradient-bump-mapping">blog post</a> by Jeremy Ong, which also does a great job motivating and explaining the paper.</p> <p>The only issue I ran into is that the <code>tangent.w</code> always came out with the wrong sign compared to the existing mikktspace-tangents I had as a reference. I double checked my math and coordinate space handiness a couple of times, but could never figure out what was wrong. I ended up just inverting the sign after calculating the tangent. If anyone knows what I did wrong, please open an <a rel="nofollow noreferrer" href="https://github.com/bevyengine/bevy/issues">issue</a>!</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#586e75;">// https://www.jeremyong.com/graphics/2023/12/16/surface-gradient-bump-mapping/#surface-gradient-from-a-tangent-space-normal-vector-without-an-explicit-tangent-basis </span><span style="color:#268bd2;">fn </span><span style="color:#b58900;">calculate_world_tangent</span><span style="color:#657b83;">( </span><span> </span><span style="color:#268bd2;">world_normal</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span> </span><span style="color:#268bd2;">ddx_world_position</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span> </span><span style="color:#268bd2;">ddy_world_position</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span> </span><span style="color:#268bd2;">ddx_uv</span><span>: vec2&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span> </span><span style="color:#268bd2;">ddy_uv</span><span>: vec2&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span style="color:#657b83;">) </span><span>-&gt; vec4&lt;</span><span style="color:#268bd2;">f32</span><span>&gt; </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#586e75;">// Project the position gradients onto the tangent plane </span><span> </span><span style="color:#268bd2;">let</span><span> ddx_world_position_s </span><span style="color:#657b83;">=</span><span> ddx_world_position </span><span style="color:#657b83;">- </span><span style="color:#859900;">dot</span><span style="color:#657b83;">(</span><span>ddx_world_position, world_normal</span><span style="color:#657b83;">) *</span><span> world_normal; </span><span> </span><span style="color:#268bd2;">let</span><span> ddy_world_position_s </span><span style="color:#657b83;">=</span><span> ddy_world_position </span><span style="color:#657b83;">- </span><span style="color:#859900;">dot</span><span style="color:#657b83;">(</span><span>ddy_world_position, world_normal</span><span style="color:#657b83;">) *</span><span> world_normal; </span><span> </span><span> </span><span style="color:#586e75;">// Compute the jacobian matrix to leverage the chain rule </span><span> </span><span style="color:#268bd2;">let</span><span> jacobian_sign </span><span style="color:#657b83;">= </span><span style="color:#859900;">sign</span><span style="color:#657b83;">(</span><span>ddx_uv.x </span><span style="color:#657b83;">*</span><span> ddy_uv.y </span><span style="color:#657b83;">-</span><span> ddx_uv.y </span><span style="color:#657b83;">*</span><span> ddy_uv.x</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span> var world_tangent </span><span style="color:#657b83;">=</span><span> jacobian_sign </span><span style="color:#657b83;">* (</span><span>ddy_uv.y </span><span style="color:#657b83;">*</span><span> ddx_world_position_s </span><span style="color:#657b83;">-</span><span> ddx_uv.y </span><span style="color:#657b83;">*</span><span> ddy_world_position_s</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span> </span><span style="color:#586e75;">// The sign intrinsic returns 0 if the argument is 0 </span><span> </span><span style="color:#859900;">if</span><span> jacobian_sign </span><span style="color:#657b83;">!= </span><span style="color:#6c71c4;">0.0 </span><span style="color:#657b83;">{ </span><span> world_tangent </span><span style="color:#657b83;">= </span><span style="color:#859900;">normalize</span><span style="color:#657b83;">(</span><span>world_tangent</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#657b83;">} </span><span> </span><span> </span><span style="color:#586e75;">// The second factor here ensures a consistent handedness between </span><span> </span><span style="color:#586e75;">// the tangent frame and surface basis w.r.t. screenspace. </span><span> </span><span style="color:#268bd2;">let</span><span> w </span><span style="color:#657b83;">=</span><span> jacobian_sign </span><span style="color:#657b83;">* </span><span style="color:#859900;">sign</span><span style="color:#657b83;">(</span><span style="color:#859900;">dot</span><span style="color:#657b83;">(</span><span>ddy_world_position, </span><span style="color:#859900;">cross</span><span style="color:#657b83;">(</span><span>world_normal, ddx_world_position</span><span style="color:#657b83;">)))</span><span>; </span><span> </span><span> </span><span style="color:#859900;">return vec4</span><span style="color:#657b83;">(</span><span>world_tangent, </span><span style="color:#657b83;">-</span><span>w</span><span style="color:#657b83;">)</span><span>; </span><span style="color:#586e75;">// TODO: Unclear why we need to negate this to match mikktspace generated tangents </span><span style="color:#657b83;">} </span></code></pre> <p>At the cost of a few extra calculations in the material shading pass, and some slight inaccuracies compared to explicit tangents (mostly on curved surfaces), we save 16 bytes per vertex, both on disk (although LZ4 compression means we might be saving less in practice), and in memory.</p> <p>16 bytes might not sound like a lot, but our high-poly meshes have a <em>lot</em> of vertices, so the savings are significant, especially in combination with the next PR.</p> <table><thead><tr><th>Explicit Tangents (0.14)</th><th>Implicit tangents (0.15)</th></tr></thead><tbody> <tr><td><img src="https://jms55.github.io/posts/2024-11-14-virtual-geometry-bevy-0-15/explicit_tangents.png" alt="Explicit tangents in Bevy 0.14" /></td><td><img src="https://jms55.github.io/posts/2024-11-14-virtual-geometry-bevy-0-15/implicit_tangents.png" alt="Implicit tangents in bevy 0.15" /></td></tr> </tbody></table> <p>Also of note is that while trying to debug the sign issue, I found that The Forge had published an <a rel="nofollow noreferrer" href="https://github.com/ConfettiFX/The-Forge/blob/9d43e69141a9cd0ce2ce2d2db5122234d3a2d5b5/Common_3/Renderer/VisibilityBuffer2/Shaders/FSL/vb_shading_utilities.h.fsl#L90-L150">updated version</a> of their partial derivatives calculations, fixing a small bug. I updated my WGSL port to match.</p> <h2 id="compressed-per-meshlet-vertex-data">Compressed Per-Meshlet Vertex Data<a class="zola-anchor" href="#compressed-per-meshlet-vertex-data" aria-label="Anchor link for: compressed-per-meshlet-vertex-data" style="visibility: hidden;"></a> </h2> <p>PR <a rel="nofollow noreferrer" href="https://github.com/bevyengine/bevy/pull/15643">#15643</a> stores copies of the overall mesh's vertex attribute data per-meshlet, and then heavily compresses it.</p> <h3 id="motivation-1">Motivation<a class="zola-anchor" href="#motivation-1" aria-label="Anchor link for: motivation-1" style="visibility: hidden;"></a> </h3> <p>The whole idea behind virtual geometry is that you only pay (as much as possible, it's of course not perfect) for the geometry currently needed on screen. Zoomed out? You pay the rasterization cost for only a few triangles at a higher LOD, and not for the entire mesh. Part of the mesh occluded? It gets culled. But continuing on with the theme from the last PR, memory usage is also a big cost. We might be able to render a large scene of high poly meshes with clever usage of LODs and culling, but can we afford to <em>store</em> all that mesh data to begin with in our GPU's measily 8-12gb of VRAM? (not even accounting for space taken up by material textures which will reduce our budget even further).</p> <p>The way we fix this is with streaming. Rather than keep everything in memory all the time, you have the GPU write requests of what data it needs to a buffer, read that back onto the CPU, and then load the requested data from disk into a fixed-size GPU buffer. If the GPU no longer needs a piece of data, you mark that section of the buffer as free space, and can write new data to it as new requests come in.</p> <p>Typical implementations of mesh streaming stream discrete LOD levels, but our goal is to be much more fine-grained. Keeping with the theme of only paying for the cluster data you need actually need to render the current frame, we want to stream individual meshlets, not whole LOD levels (in practice, Nanite streams fixed-size pages of meshlet data, and not individual meshlets). This presents a problem with our current implementation: since all meshlets reference the same set of vertex data, we have no simple way of unloading or loading vertex data for a single meshlet. While I'm not going to tackle streaming in Bevy 0.15, in this PR I'll be changing the way we store vertex data to solve this problem and unblock streaming in the future.</p> <p>Up until now, each MeshletMesh has had one set of vertex data shared between all meshlets within the mesh. Each meshlet has a local index buffer, mapping triangles to meshlet-local vertex IDs, and then a global index buffer mapping meshlet-local vetex IDs to actual vertex data from the mesh. E.g. triangle corner X within a meshlet points to vertex ID Y within a meshlet which points to vertex Z within the mesh.</p> <p>In order to support streaming, we're going to move to a new scheme. We will store a copy of vertex data for each meshlet, concatenated together into one slice. All the vertex data for meshlet 0 will be stored as one contiguous slice, with all the vertex data for meshlet 1 stored contiguously after it, and all the vertex data for meshlet 2 after <em>that</em>, etc.</p> <p>Each meshlet's local index buffer will point directly into vertices within the meshlet's vertex data slice, stored as an offset relative to the starting index of the meshlet's vertex data slice within the overall buffer. E.g. triangle corner X within a meshlet points to vertex Y within the meshlet directly.</p> <p>Besides unblocking streaming, this scheme is also much simpler to reason about, uses less dependent memory reads, and works much nicer with our software rasterization pass where each thread in the workgroup is loading a single meshlet vertex into workgroup shared memory.</p> <p>That was a lot of background and explanation for what's really a rather simple change, so let me finally get to the main topic of this PR: the problem with duplicating vertex data per meshlet is that we've just increased the size of our MeshletMesh asset by a thousandfold.</p> <p>The solution is quantization and compression.</p> <h3 id="position-compression">Position Compression<a class="zola-anchor" href="#position-compression" aria-label="Anchor link for: position-compression" style="visibility: hidden;"></a> </h3> <p>Meshlets compress pretty well. Starting with vertex positions, there's no reason we need to store a full <code>vec3&lt;f32&gt;</code> per vertex. Most meshlets tend to enclose a fairly small amount of space. Instead of storing vertex positions as coordinates relative to the mesh center origin, we can instead store them in some coordinate space relative to the meshlet bounds.</p> <p>For each meshlet, we'll iterate over all of its vertex positions, and calculate the min and max value for each of the X/Y/Z axis. Then, we can remap each position relative to those bounds by doing <code>p -= min</code>. The positions initially range from <code>[min, max]</code>, and then range from <code>[0, max - min]</code> after remapping. We can store the <code>min</code> values for each of the X/Y/Z axis (as a full <code>f32</code> each) in the meshlet metadata, and in the shader reverse the remapping by doing <code>p += min</code>.</p> <p>Our first (albeit small) saving become apparent: at the cost of 12 extra bytes in the meshlet metadata, we save 3 bits per vertex position due to no longer needing a bit for the sign for each of the X/Y/Z values, as <code>[0, max - min]</code> is never going to contain any negative numbers. We technically now only need a hypothetical <code>f31</code> per axis.</p> <p>However, there's a another trick we can perform. If we take the ceiling of the log2 of a range of floating point values <code>ceil(log2(max - min + 1))</code>, we get the minimum number of bits we need to store any value in that range. Rather than storing meshlet vertex positions as a list of <code>vec3&lt;f32&gt;</code>s, we could instead store them as a packed list of bits (a bitstream).</p> <p>E.g. if we determine that we need 4/7/3 bits for the X/Y/Z ranges of the meshlet, we could store a list of bits where bits 0..4 are for vertex 0 axis X, bits 4..11 are for vertex 0 axis Y, bits 11..14 are for vertex 0 axis Z, bits 14..18 are for vertex 1 axis X, bits 18..25 are for vertex 1 axis Y, etc.</p> <p>Again we can store the bit size (as a <code>u8</code>) for each of the X/Y/Z axis within the meshlet's metadata, at a cost of 3 extra bytes. We'll use this later in our shaders to figure out how many bits to read from the bistream for each of the meshlet's vertices.</p> <p>In practice, if you try this out as-is, you're probably going to end up with fairly large bit sizes per axis, and not actually save any space vs using <code>vec3&lt;f32&gt;</code>. This is due to the large amount of precision we have in our vertex positions (a full <code>f32</code>), which leads to a lot of precision needed in the range, and therefore a large bit size.</p> <p>The final trick up our sleeves is that we don't actually <em>need</em> all this precision. If we know that our meshlet's vertices range from 10.2041313123 to 84.382543538, do we really need to know that a vertex happens to be stored at <em>exactly</em> 57.594392822? We could pick some arbitrary amount of precision to round each of our vertices to, say four decimal places, resulting in 57.5944. Less precision means a less precise range, which means our bit size will be smaller.</p> <center> <p><img src="https://jms55.github.io/posts/2024-11-14-virtual-geometry-bevy-0-15/quantize_error.png" alt="Too much quantization" /> <em>Don't quantize too much, or you'll get bugs!</em></p> </center> <p>Better yet, lets pick some factor <code>q = 2^p</code>, where <code>p</code> is some arbitrary <code>u8</code> integer. Now, lets snap each vertex to the nearest point on the grid that's a multiple of <code>1/q</code>, and then store the vertex as the number of "steps" of size <code>1/q</code> that we took from the origin to reach the snapped vertex position (a fixed-point representation). E.g. if we say <code>p = 4</code>, then we're quantizing to a grid with a resolution of <code>1/16</code>, so <code>v = 57.594392822</code> would snap to <code>v = 57.625</code> (throwing away some unnecessary precision) and we would store that as <code>v = round(57.594392822 / (1/16)) = i32(57.594392822 * 16 + 0.5) = 922</code>. This is once again easily reversible in our shader so long as we have our factor <code>p</code>: <code>922 / 2^4 = 57.625</code>.</p> <p>The factor <code>p</code> we choose is not particularly important. I set it to 4 by default (with an additional factor to convert from Bevy's meters to the more appropriate-for-this-use-case unit of centimeters), but users can choose a good value themselves if 4 is too high (unnecessary precision = larger bit sizes and therefore larger asset sizes), or too low (visible mesh deformity from snapping the vertices too-coarsely). Nanite has an automatic heuristic that I assume is based on some kind of triangle surface area to mesh size ratio, but also lets users choose <code>p</code> manually. The important thing to note is that you should <em>not</em> choose <code>p</code> per-meshlet, i.e. <code>p</code> should be the same for every meshlet within the mesh. Otherwise, you'll end up with cracks between meshlets.</p> <p>Finally, we can combine all three of these tricks. We can quantize our meshlet's vertices, find the per-axis min/max values and remap to a better range, and then store as a packed bitstream using the minimum number of bits for the range. The final code to compress a meshlet's vertex positions is below.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#268bd2;">let</span><span> quantization_factor </span><span style="color:#657b83;">= </span><span> </span><span style="color:#657b83;">(</span><span style="color:#6c71c4;">1 </span><span style="color:#657b83;">&lt;&lt;</span><span> vertex_position_quantization_factor</span><span style="color:#657b83;">) </span><span style="color:#859900;">as </span><span style="color:#268bd2;">f32 </span><span style="color:#657b83;">* </span><span style="color:#cb4b16;">CENTIMETERS_PER_METER</span><span>; </span><span> </span><span style="color:#268bd2;">let </span><span style="color:#93a1a1;">mut</span><span> min_quantized_position_channels </span><span style="color:#657b83;">= </span><span>IVec3::</span><span style="color:#cb4b16;">MAX</span><span>; </span><span style="color:#268bd2;">let </span><span style="color:#93a1a1;">mut</span><span> max_quantized_position_channels </span><span style="color:#657b83;">= </span><span>IVec3::</span><span style="color:#cb4b16;">MIN</span><span>; </span><span> </span><span style="color:#586e75;">// Lossy vertex compression </span><span style="color:#268bd2;">let </span><span style="color:#93a1a1;">mut</span><span> quantized_positions </span><span style="color:#657b83;">= [</span><span>IVec3::</span><span style="color:#cb4b16;">ZERO</span><span>; </span><span style="color:#6c71c4;">255</span><span style="color:#657b83;">]</span><span>; </span><span style="color:#859900;">for </span><span style="color:#657b83;">(</span><span>i, vertex_id</span><span style="color:#657b83;">) </span><span style="color:#859900;">in</span><span> meshlet_vertex_ids.</span><span style="color:#859900;">iter</span><span style="color:#657b83;">()</span><span>.</span><span style="color:#859900;">enumerate</span><span style="color:#657b83;">() { </span><span> </span><span style="color:#268bd2;">let</span><span> position </span><span style="color:#657b83;">= </span><span style="color:#859900;">...</span><span>; </span><span> </span><span> </span><span style="color:#586e75;">// Quantize position to a fixed-point IVec3 </span><span> </span><span style="color:#268bd2;">let</span><span> quantized_position </span><span style="color:#657b83;">= (</span><span>position </span><span style="color:#657b83;">*</span><span> quantization_factor </span><span style="color:#657b83;">+ </span><span style="color:#6c71c4;">0.5</span><span style="color:#657b83;">)</span><span>.</span><span style="color:#859900;">as_ivec3</span><span style="color:#657b83;">()</span><span>; </span><span> quantized_positions</span><span style="color:#657b83;">[</span><span>i</span><span style="color:#657b83;">] =</span><span> quantized_position; </span><span> </span><span> </span><span style="color:#586e75;">// Compute per X/Y/Z-channel quantized position min/max for this meshlet </span><span> min_quantized_position_channels </span><span style="color:#657b83;">=</span><span> min_quantized_position_channels.</span><span style="color:#859900;">min</span><span style="color:#657b83;">(</span><span>quantized_position</span><span style="color:#657b83;">)</span><span>; </span><span> max_quantized_position_channels </span><span style="color:#657b83;">=</span><span> max_quantized_position_channels.</span><span style="color:#859900;">max</span><span style="color:#657b83;">(</span><span>quantized_position</span><span style="color:#657b83;">)</span><span>; </span><span style="color:#657b83;">} </span><span> </span><span style="color:#586e75;">// Calculate bits needed to encode each quantized vertex position channel based on the range of each channel </span><span style="color:#268bd2;">let</span><span> range </span><span style="color:#657b83;">=</span><span> max_quantized_position_channels </span><span style="color:#657b83;">-</span><span> min_quantized_position_channels </span><span style="color:#657b83;">+ </span><span style="color:#6c71c4;">1</span><span>; </span><span style="color:#268bd2;">let</span><span> bits_per_vertex_position_channel_x </span><span style="color:#657b83;">= </span><span style="color:#859900;">log2</span><span style="color:#657b83;">(</span><span>range.x </span><span style="color:#859900;">as </span><span style="color:#268bd2;">f32</span><span style="color:#657b83;">)</span><span>.</span><span style="color:#859900;">ceil</span><span style="color:#657b83;">() </span><span style="color:#859900;">as </span><span style="color:#268bd2;">u8</span><span>; </span><span style="color:#268bd2;">let</span><span> bits_per_vertex_position_channel_y </span><span style="color:#657b83;">= </span><span style="color:#859900;">log2</span><span style="color:#657b83;">(</span><span>range.y </span><span style="color:#859900;">as </span><span style="color:#268bd2;">f32</span><span style="color:#657b83;">)</span><span>.</span><span style="color:#859900;">ceil</span><span style="color:#657b83;">() </span><span style="color:#859900;">as </span><span style="color:#268bd2;">u8</span><span>; </span><span style="color:#268bd2;">let</span><span> bits_per_vertex_position_channel_z </span><span style="color:#657b83;">= </span><span style="color:#859900;">log2</span><span style="color:#657b83;">(</span><span>range.z </span><span style="color:#859900;">as </span><span style="color:#268bd2;">f32</span><span style="color:#657b83;">)</span><span>.</span><span style="color:#859900;">ceil</span><span style="color:#657b83;">() </span><span style="color:#859900;">as </span><span style="color:#268bd2;">u8</span><span>; </span><span> </span><span style="color:#586e75;">// Lossless encoding of vertex positions in the minimum number of bits per channel </span><span style="color:#859900;">for</span><span> quantized_position </span><span style="color:#859900;">in</span><span> quantized_positions.</span><span style="color:#859900;">iter</span><span style="color:#657b83;">()</span><span>.</span><span style="color:#859900;">take</span><span style="color:#657b83;">(</span><span>meshlet_vertex_ids.</span><span style="color:#859900;">len</span><span style="color:#657b83;">()) { </span><span> </span><span style="color:#586e75;">// Remap [range_min, range_max] IVec3 to [0, range_max - range_min] UVec3 </span><span> </span><span style="color:#268bd2;">let</span><span> position </span><span style="color:#657b83;">= (</span><span>quantized_position </span><span style="color:#657b83;">-</span><span> min_quantized_position_channels</span><span style="color:#657b83;">)</span><span>.</span><span style="color:#859900;">as_uvec3</span><span style="color:#657b83;">()</span><span>; </span><span> </span><span> </span><span style="color:#586e75;">// Store as a packed bitstream </span><span> vertex_positions.</span><span style="color:#859900;">extend_from_bitslice</span><span style="color:#657b83;">( </span><span> </span><span style="color:#859900;">&amp;</span><span>position.x.view_bits::&lt;Lsb0&gt;</span><span style="color:#657b83;">()[</span><span style="color:#859900;">..</span><span>bits_per_vertex_position_channel_x </span><span style="color:#859900;">as </span><span style="color:#268bd2;">usize</span><span style="color:#657b83;">]</span><span>, </span><span> </span><span style="color:#657b83;">)</span><span>; </span><span> vertex_positions.</span><span style="color:#859900;">extend_from_bitslice</span><span style="color:#657b83;">( </span><span> </span><span style="color:#859900;">&amp;</span><span>position.y.view_bits::&lt;Lsb0&gt;</span><span style="color:#657b83;">()[</span><span style="color:#859900;">..</span><span>bits_per_vertex_position_channel_y </span><span style="color:#859900;">as </span><span style="color:#268bd2;">usize</span><span style="color:#657b83;">]</span><span>, </span><span> </span><span style="color:#657b83;">)</span><span>; </span><span> vertex_positions.</span><span style="color:#859900;">extend_from_bitslice</span><span style="color:#657b83;">( </span><span> </span><span style="color:#859900;">&amp;</span><span>position.z.view_bits::&lt;Lsb0&gt;</span><span style="color:#657b83;">()[</span><span style="color:#859900;">..</span><span>bits_per_vertex_position_channel_z </span><span style="color:#859900;">as </span><span style="color:#268bd2;">usize</span><span style="color:#657b83;">]</span><span>, </span><span> </span><span style="color:#657b83;">)</span><span>; </span><span style="color:#657b83;">} </span></code></pre> <h3 id="position-decoding">Position Decoding<a class="zola-anchor" href="#position-decoding" aria-label="Anchor link for: position-decoding" style="visibility: hidden;"></a> </h3> <p>Before this PR, our meshlet metadata was this 16-byte type:</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#93a1a1;">pub </span><span style="color:#268bd2;">struct </span><span style="color:#b58900;">Meshlet </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#586e75;">/// The offset within the parent mesh&#39;s [`MeshletMesh::vertex_ids`] buffer where the indices for this meshlet begin. </span><span> </span><span style="color:#93a1a1;">pub </span><span style="color:#268bd2;">start_vertex_id</span><span>: </span><span style="color:#268bd2;">u32</span><span>, </span><span> </span><span style="color:#586e75;">/// The offset within the parent mesh&#39;s [`MeshletMesh::indices`] buffer where the indices for this meshlet begin. </span><span> </span><span style="color:#93a1a1;">pub </span><span style="color:#268bd2;">start_index_id</span><span>: </span><span style="color:#268bd2;">u32</span><span>, </span><span> </span><span style="color:#586e75;">/// The amount of vertices in this meshlet. </span><span> </span><span style="color:#93a1a1;">pub </span><span style="color:#268bd2;">vertex_count</span><span>: </span><span style="color:#268bd2;">u32</span><span>, </span><span> </span><span style="color:#586e75;">/// The amount of triangles in this meshlet. </span><span> </span><span style="color:#93a1a1;">pub </span><span style="color:#268bd2;">triangle_count</span><span>: </span><span style="color:#268bd2;">u32</span><span>, </span><span style="color:#657b83;">} </span></code></pre> <p>With all the custom compression, we need to store some more info, giving us this carefully-packed 32-byte type (a little bit bigger, but reducing size for vertices is much more important than reducing the size of the meshlet metadata):</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#93a1a1;">pub </span><span style="color:#268bd2;">struct </span><span style="color:#b58900;">Meshlet </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#586e75;">/// The bit offset within the parent mesh&#39;s [`MeshletMesh::vertex_positions`] buffer where the vertex positions for this meshlet begin. </span><span> </span><span style="color:#93a1a1;">pub </span><span style="color:#268bd2;">start_vertex_position_bit</span><span>: </span><span style="color:#268bd2;">u32</span><span>, </span><span> </span><span style="color:#586e75;">/// The offset within the parent mesh&#39;s [`MeshletMesh::vertex_normals`] and [`MeshletMesh::vertex_uvs`] buffers </span><span> </span><span style="color:#586e75;">/// where non-position vertex attributes for this meshlet begin. </span><span> </span><span style="color:#93a1a1;">pub </span><span style="color:#268bd2;">start_vertex_attribute_id</span><span>: </span><span style="color:#268bd2;">u32</span><span>, </span><span> </span><span style="color:#586e75;">/// The offset within the parent mesh&#39;s [`MeshletMesh::indices`] buffer where the indices for this meshlet begin. </span><span> </span><span style="color:#93a1a1;">pub </span><span style="color:#268bd2;">start_index_id</span><span>: </span><span style="color:#268bd2;">u32</span><span>, </span><span> </span><span style="color:#586e75;">/// The amount of vertices in this meshlet. </span><span> </span><span style="color:#93a1a1;">pub </span><span style="color:#268bd2;">vertex_count</span><span>: </span><span style="color:#268bd2;">u8</span><span>, </span><span> </span><span style="color:#586e75;">/// The amount of triangles in this meshlet. </span><span> </span><span style="color:#93a1a1;">pub </span><span style="color:#268bd2;">triangle_count</span><span>: </span><span style="color:#268bd2;">u8</span><span>, </span><span> </span><span style="color:#586e75;">/// Unused (needed to satisfy alignment rules). </span><span> </span><span style="color:#93a1a1;">pub </span><span style="color:#268bd2;">padding</span><span>: </span><span style="color:#268bd2;">u16</span><span>, </span><span> </span><span style="color:#586e75;">/// Number of bits used to to store the X channel of vertex positions within this meshlet. </span><span> </span><span style="color:#93a1a1;">pub </span><span style="color:#268bd2;">bits_per_vertex_position_channel_x</span><span>: </span><span style="color:#268bd2;">u8</span><span>, </span><span> </span><span style="color:#586e75;">/// Number of bits used to to store the Y channel of vertex positions within this meshlet. </span><span> </span><span style="color:#93a1a1;">pub </span><span style="color:#268bd2;">bits_per_vertex_position_channel_y</span><span>: </span><span style="color:#268bd2;">u8</span><span>, </span><span> </span><span style="color:#586e75;">/// Number of bits used to to store the Z channel of vertex positions within this meshlet. </span><span> </span><span style="color:#93a1a1;">pub </span><span style="color:#268bd2;">bits_per_vertex_position_channel_z</span><span>: </span><span style="color:#268bd2;">u8</span><span>, </span><span> </span><span style="color:#586e75;">/// Power of 2 factor used to quantize vertex positions within this meshlet. </span><span> </span><span style="color:#93a1a1;">pub </span><span style="color:#268bd2;">vertex_position_quantization_factor</span><span>: </span><span style="color:#268bd2;">u8</span><span>, </span><span> </span><span style="color:#586e75;">/// Minimum quantized X channel value of vertex positions within this meshlet. </span><span> </span><span style="color:#93a1a1;">pub </span><span style="color:#268bd2;">min_vertex_position_channel_x</span><span>: </span><span style="color:#268bd2;">f32</span><span>, </span><span> </span><span style="color:#586e75;">/// Minimum quantized Y channel value of vertex positions within this meshlet. </span><span> </span><span style="color:#93a1a1;">pub </span><span style="color:#268bd2;">min_vertex_position_channel_y</span><span>: </span><span style="color:#268bd2;">f32</span><span>, </span><span> </span><span style="color:#586e75;">/// Minimum quantized Z channel value of vertex positions within this meshlet. </span><span> </span><span style="color:#93a1a1;">pub </span><span style="color:#268bd2;">min_vertex_position_channel_z</span><span>: </span><span style="color:#268bd2;">f32</span><span>, </span><span style="color:#657b83;">} </span></code></pre> <p>To fetch a single vertex from the bitstream (we we bind as an array of <code>u32</code>s), we can use this function:</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#268bd2;">fn </span><span style="color:#b58900;">get_meshlet_vertex_position</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">meshlet</span><span>: ptr&lt;function, Meshlet&gt;, </span><span style="color:#268bd2;">vertex_id</span><span>: </span><span style="color:#268bd2;">u32</span><span style="color:#657b83;">) </span><span>-&gt; vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt; </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#586e75;">// Get bitstream start for the vertex </span><span> </span><span style="color:#268bd2;">let</span><span> unpacked </span><span style="color:#657b83;">=</span><span> unpack4xU8</span><span style="color:#657b83;">((*</span><span>meshlet</span><span style="color:#657b83;">)</span><span>.packed_b</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> bits_per_channel </span><span style="color:#657b83;">=</span><span> unpacked.xyz; </span><span> </span><span style="color:#268bd2;">let</span><span> bits_per_vertex </span><span style="color:#657b83;">=</span><span> bits_per_channel.x </span><span style="color:#657b83;">+</span><span> bits_per_channel.y </span><span style="color:#657b83;">+</span><span> bits_per_channel.z; </span><span> var start_bit </span><span style="color:#657b83;">= (*</span><span>meshlet</span><span style="color:#657b83;">)</span><span>.start_vertex_position_bit </span><span style="color:#657b83;">+ (</span><span>vertex_id </span><span style="color:#657b83;">*</span><span> bits_per_vertex</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span> </span><span style="color:#586e75;">// Read each vertex channel from the bitstream </span><span> var vertex_position_packed </span><span style="color:#657b83;">= </span><span style="color:#859900;">vec3</span><span style="color:#657b83;">(</span><span>0u</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#859900;">for </span><span style="color:#657b83;">(</span><span>var i </span><span style="color:#657b83;">=</span><span> 0u; i </span><span style="color:#657b83;">&lt;</span><span> 3u; i</span><span style="color:#657b83;">++) { </span><span> </span><span style="color:#268bd2;">let</span><span> lower_word_index </span><span style="color:#657b83;">=</span><span> start_bit </span><span style="color:#657b83;">/</span><span> 32u; </span><span> </span><span style="color:#268bd2;">let</span><span> lower_word_bit_offset </span><span style="color:#657b83;">=</span><span> start_bit </span><span style="color:#859900;">&amp;</span><span> 31u; </span><span> var next_32_bits </span><span style="color:#657b83;">=</span><span> meshlet_vertex_positions</span><span style="color:#657b83;">[</span><span>lower_word_index</span><span style="color:#657b83;">] &gt;&gt;</span><span> lower_word_bit_offset; </span><span> </span><span style="color:#859900;">if</span><span> lower_word_bit_offset </span><span style="color:#657b83;">+</span><span> bits_per_channel</span><span style="color:#657b83;">[</span><span>i</span><span style="color:#657b83;">] &gt;</span><span> 32u </span><span style="color:#657b83;">{ </span><span> next_32_bits </span><span style="color:#657b83;">|=</span><span> meshlet_vertex_positions</span><span style="color:#657b83;">[</span><span>lower_word_index </span><span style="color:#657b83;">+</span><span> 1u</span><span style="color:#657b83;">] &lt;&lt; (</span><span>32u </span><span style="color:#657b83;">-</span><span> lower_word_bit_offset</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#657b83;">} </span><span> vertex_position_packed</span><span style="color:#657b83;">[</span><span>i</span><span style="color:#657b83;">] =</span><span> extractBits</span><span style="color:#657b83;">(</span><span>next_32_bits, 0u, bits_per_channel</span><span style="color:#657b83;">[</span><span>i</span><span style="color:#657b83;">])</span><span>; </span><span> start_bit </span><span style="color:#657b83;">+=</span><span> bits_per_channel</span><span style="color:#657b83;">[</span><span>i</span><span style="color:#657b83;">]</span><span>; </span><span> </span><span style="color:#657b83;">} </span><span> </span><span> </span><span style="color:#586e75;">// Remap [0, range_max - range_min] vec3&lt;u32&gt; to [range_min, range_max] vec3&lt;f32&gt; </span><span> var vertex_position </span><span style="color:#657b83;">= </span><span>vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;</span><span style="color:#657b83;">(</span><span>vertex_position_packed</span><span style="color:#657b83;">) + </span><span style="color:#859900;">vec3</span><span style="color:#657b83;">( </span><span> </span><span style="color:#657b83;">(*</span><span>meshlet</span><span style="color:#657b83;">)</span><span>.min_vertex_position_channel_x, </span><span> </span><span style="color:#657b83;">(*</span><span>meshlet</span><span style="color:#657b83;">)</span><span>.min_vertex_position_channel_y, </span><span> </span><span style="color:#657b83;">(*</span><span>meshlet</span><span style="color:#657b83;">)</span><span>.min_vertex_position_channel_z, </span><span> </span><span style="color:#657b83;">)</span><span>; </span><span> </span><span> </span><span style="color:#586e75;">// Reverse vertex quantization </span><span> </span><span style="color:#268bd2;">let</span><span> vertex_position_quantization_factor </span><span style="color:#657b83;">=</span><span> unpacked.w; </span><span> vertex_position </span><span style="color:#657b83;">/= </span><span style="color:#268bd2;">f32</span><span style="color:#657b83;">(</span><span>1u </span><span style="color:#657b83;">&lt;&lt;</span><span> vertex_position_quantization_factor</span><span style="color:#657b83;">) * </span><span style="color:#cb4b16;">CENTIMETERS_PER_METER</span><span>; </span><span> </span><span> </span><span style="color:#859900;">return</span><span> vertex_position; </span><span style="color:#657b83;">} </span></code></pre> <p>This could probably be written better - right now we're doing a minimum of 3 <code>u32</code> reads (1 per channel), but there's a good chance that a single <code>u32</code> read will contain the data for all 3 channels of the vertex. Something to optimize in the future.</p> <h3 id="other-attributes">Other Attributes<a class="zola-anchor" href="#other-attributes" aria-label="Anchor link for: other-attributes" style="visibility: hidden;"></a> </h3> <p>Now that we've done positions, lets talk about how to handle other vertex attributes.</p> <p>Tangents we already removed in the last PR.</p> <p>For UVs, I currently store them uncompressed. I could have maybe used half-precision floating point values, but I am wary of artifacts resulting from the reduced precision, so for right now it's a full <code>vec2&lt;f32&gt;</code>. This is a big opportunity for future improvement.</p> <p>Normals are a bit more interesting. They start as <code>vec3&lt;f32&gt;</code>. I first perform an octahedral encoding on them, bringing them down to a <code>vec2&lt;f32&gt;</code> near-losessly. I then give up some precision to reduce the size even further by using <code>pack2x16snorm()</code>, bringing it down to a <code>vec2&lt;f16&gt;</code>, or a packed <code>u32</code>. These operations are easily reversed in the shader using the built-in <code>unpack2x16snorm()</code> function, and then the simple octahedral decode step.</p> <p>I <em>did</em> try a bitstream encoding similiar to what I did for positions, but couldn't get any smaller sizes than a simple <code>pack2x16snorm()</code>. I think with more time and motivation (I was getting burnt out by the end of this), I could have probably figured out a good variable-size octahedral encoding for normals as well. Something else to investigate in the future.</p> <h3 id="results">Results<a class="zola-anchor" href="#results" aria-label="Anchor link for: results" style="visibility: hidden;"></a> </h3> <p>After all this, how much memory savings did we get?</p> <p>Disk space is practically unchanged (maybe 2% smaller at best), but memory savings on a test mesh went from <code>110 MB</code> before this PR (without duplicating the vertex data per-meshlet at all), to <code>64 MB</code> after this PR (copying and compressing vertex data per-meshlet). This is a huge savings (<code>42%</code> smaller), with room for future improvements! I'll definitely be coming back to this at some point in the future.</p> <p>Additional references:</p> <ul> <li><a rel="nofollow noreferrer" href="https://advances.realtimerendering.com/s2021/Karis_Nanite_SIGGRAPH_Advances_2021_final.pdf#page=128">https://advances.realtimerendering.com/s2021/Karis_Nanite_SIGGRAPH_Advances_2021_final.pdf#page=128</a></li> <li><a rel="nofollow noreferrer" href="https://arxiv.org/abs/2404.06359">https://arxiv.org/abs/2404.06359</a> (also compresses the index buffer, not just vertices!)</li> <li><a rel="nofollow noreferrer" href="https://daniilvinn.github.io/2024/05/04/omniforce-vertex-quantization.html">https://daniilvinn.github.io/2024/05/04/omniforce-vertex-quantization.html</a></li> <li><a rel="nofollow noreferrer" href="https://gpuopen.com/download/publications/DGF.pdf">https://gpuopen.com/download/publications/DGF.pdf</a> (more focused on raytracing than rasterization)</li> </ul> <h2 id="improved-lod-selection-heuristic">Improved LOD Selection Heuristic<a class="zola-anchor" href="#improved-lod-selection-heuristic" aria-label="Anchor link for: improved-lod-selection-heuristic" style="visibility: hidden;"></a> </h2> <p>PR <a rel="nofollow noreferrer" href="https://github.com/bevyengine/bevy/pull/15846">#15846</a> changes how we select the LOD cut.</p> <p>Previously, I was building a bounding sphere around each group with radius based on the group error, and then projecting that to screen space to get the visible error in pixels.</p> <p>That method worked, but isn't entirely watertight. Where you place the bounding sphere center in the group is kind of arbitrary, right? And how do you ensure that the error projection is perfectly monotonic, if you have these random bounding spheres in each group?</p> <p>Arseny Kapoulkine once again helped me out here. As part of meshoptimizer, they started experimenting with their <a rel="nofollow noreferrer" href="https://github.com/zeux/meshoptimizer/blob/d93419ced5956307f41333c500c8037c8b861d59/demo/nanite.cpp">nanite.cpp</a> demo. In this PR, I copied his code for LOD cut selection.</p> <p>To determine the group bounding sphere, you simply build a new bounding sphere enclosing all of the group's childrens' bounding spheres. The first group you build out of LOD 0 uses the LOD 0 culling bounding spheres around each meshlet. This way, you ensure that both the error (using the existing method of taking the max error among the group and group children), <em>and</em> the bounding sphere are monotonic. Error is no longer stored in the radius of the bounding sphere, and is instead stored as a seperate f16 (lets us pack both group and parent group error into a single u32, and the lost precision is irrelevant). This also gave me the opportunity to clean up the code now that I understand the theory better, and clarify the difference between meshlets and meshlet groups better.</p> <p>For projecting the error at runtime, we now use the below function. I can't claim to understand how it works that well (and it's been a few weeks since I last looked at it), but it does work. The end result is that we get more seamless LOD changes, and our mesh to meshlet mesh converter is more robust (it used to crash on larger meshes, due to a limitation in the code for how I calculated group bounding spheres).</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#586e75;">// https://github.com/zeux/meshoptimizer/blob/1e48e96c7e8059321de492865165e9ef071bffba/demo/nanite.cpp#L115 </span><span style="color:#268bd2;">fn </span><span style="color:#b58900;">lod_error_is_imperceptible</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">lod_sphere</span><span>: MeshletBoundingSphere, </span><span style="color:#268bd2;">simplification_error</span><span>: </span><span style="color:#268bd2;">f32</span><span>, </span><span style="color:#268bd2;">world_from_local</span><span>: mat4x4&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span style="color:#268bd2;">world_scale</span><span>: </span><span style="color:#268bd2;">f32</span><span style="color:#657b83;">) </span><span>-&gt; </span><span style="color:#268bd2;">bool </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#268bd2;">let</span><span> sphere_world_space </span><span style="color:#657b83;">= (</span><span>world_from_local </span><span style="color:#657b83;">* </span><span style="color:#859900;">vec4</span><span style="color:#657b83;">(</span><span>lod_sphere.center, </span><span style="color:#6c71c4;">1.0</span><span style="color:#657b83;">))</span><span>.xyz; </span><span> </span><span style="color:#268bd2;">let</span><span> radius_world_space </span><span style="color:#657b83;">=</span><span> world_scale </span><span style="color:#657b83;">*</span><span> lod_sphere.radius; </span><span> </span><span style="color:#268bd2;">let</span><span> error_world_space </span><span style="color:#657b83;">=</span><span> world_scale </span><span style="color:#657b83;">*</span><span> simplification_error; </span><span> </span><span> var projected_error </span><span style="color:#657b83;">=</span><span> error_world_space; </span><span> </span><span style="color:#859900;">if</span><span> view.clip_from_view</span><span style="color:#657b83;">[</span><span style="color:#6c71c4;">3</span><span style="color:#657b83;">][</span><span style="color:#6c71c4;">3</span><span style="color:#657b83;">] != </span><span style="color:#6c71c4;">1.0 </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#586e75;">// Perspective </span><span> </span><span style="color:#268bd2;">let</span><span> distance_to_closest_point_on_sphere </span><span style="color:#657b83;">= </span><span style="color:#859900;">distance</span><span style="color:#657b83;">(</span><span>sphere_world_space, view.world_position</span><span style="color:#657b83;">) -</span><span> radius_world_space; </span><span> </span><span style="color:#268bd2;">let</span><span> distance_to_closest_point_on_sphere_clamped_to_znear </span><span style="color:#657b83;">= </span><span style="color:#859900;">max</span><span style="color:#657b83;">(</span><span>distance_to_closest_point_on_sphere, view.clip_from_view</span><span style="color:#657b83;">[</span><span style="color:#6c71c4;">3</span><span style="color:#657b83;">][</span><span style="color:#6c71c4;">2</span><span style="color:#657b83;">])</span><span>; </span><span> projected_error </span><span style="color:#657b83;">/=</span><span> distance_to_closest_point_on_sphere_clamped_to_znear; </span><span> </span><span style="color:#657b83;">} </span><span> projected_error </span><span style="color:#657b83;">*=</span><span> view.clip_from_view</span><span style="color:#657b83;">[</span><span style="color:#6c71c4;">1</span><span style="color:#657b83;">][</span><span style="color:#6c71c4;">1</span><span style="color:#657b83;">] * </span><span style="color:#6c71c4;">0.5</span><span>; </span><span> projected_error </span><span style="color:#657b83;">*=</span><span> view.viewport.w; </span><span> </span><span> </span><span style="color:#859900;">return</span><span> projected_error </span><span style="color:#657b83;">&lt; </span><span style="color:#6c71c4;">1.0</span><span>; </span><span style="color:#657b83;">} </span></code></pre> <p>An interesting side note, finding the minimal bounding sphere around a set of other bounding sphere turns out to be a very difficult problem. Kaspar Fischer's thesis <a rel="nofollow noreferrer" href="https://citeseerx.ist.psu.edu/document?repid=rep1&amp;type=pdf&amp;doi=f7688a9174e880437e2f467add73905245f4c88c">"The smallest enclosing balls of balls"</a> covers the math, and it's very complex. I copied Kapoulkine's approximate, much simpler method.</p> <h2 id="improved-mesh-to-meshletmesh-conversion">Improved Mesh to MeshletMesh Conversion<a class="zola-anchor" href="#improved-mesh-to-meshletmesh-conversion" aria-label="Anchor link for: improved-mesh-to-meshletmesh-conversion" style="visibility: hidden;"></a> </h2> <p>PR <a rel="nofollow noreferrer" href="https://github.com/bevyengine/bevy/pull/15886">#15886</a> brings more improvements to the mesh to meshlet mesh converter.</p> <p>Following on from the last PR, I again took a bunch of improvements from the meshoptimizer nanite.cpp demo:</p> <ul> <li>Consider only the vertex position (and ignore things like UV seams) when determining meshlet groups</li> <li>Add back stuck meshlets that either failed to simplify, or failed to group, to the processing queue to try again at a later LOD. Dosen't seem to be much of an improvement though.</li> <li>Provide a seed to METIS to make the meshlet mesh conversion fully deterministic. I didn't realize METIS even had options before now.</li> <li>Target groups of 8 meshlets instead of 4. This improved simplification quality a lot! Nanite does groups of size 8-32, probably based on some kind of heuristic, which is probably worth experimenting with in the future.</li> <li>Manually lock only vertices belonging to meshlet group borders, instead of the full toplogical group border that meshoptimizer's <code>LOCK_BORDER</code> flag does.</li> </ul> <p>With all of these changes combined, we can finally reliably get down to a single meshlet (or at least 1-3 meshlets for larger meshes) at the highest LOD!</p> <p>The last item on the list in particular is a <em>huge</em> improvement. With meshoptimizer's <code>LOCK_BORDER</code> flag, the entire edge of the mesh will be locked. That means that at the most simplified LOD level, the entire border of the original mesh will be preserved. You will pretty much never be able to reduce down to 1 meshlet with this constraint. Using manual vertex locks to only lock vertices belonging to shared edges between meshlets (regardless of whether or not they're on the original mesh border) fixes this issue.</p> <h2 id="faster-fill-cluster-buffers-pass">Faster Fill Cluster Buffers Pass<a class="zola-anchor" href="#faster-fill-cluster-buffers-pass" aria-label="Anchor link for: faster-fill-cluster-buffers-pass" style="visibility: hidden;"></a> </h2> <p>PR <a rel="nofollow noreferrer" href="https://github.com/bevyengine/bevy/pull/15955">#15955</a> improves the speed of the fill cluster buffers pass.</p> <h3 id="speeding-up">Speeding Up<a class="zola-anchor" href="#speeding-up" aria-label="Anchor link for: speeding-up" style="visibility: hidden;"></a> </h3> <p>At this point, I improved rasterization performance, meshlet mesh building, and asset storage and loading. The Bevy 0.15 release was coming up, people were winding down features in favor of testing the release candidates, and I wasn't going to have the time (or, the motivation) to do another huge PR.</p> <p>While looking at some small things I could improve, I ended up talking with Kirill Bazhenov about how he manages per-instance (entity) GPU data in his <a rel="nofollow noreferrer" href="https://www.youtube.com/watch?v=8gwPw1fySMU">Esoterica</a> renderer.</p> <p>To recap the problem we had in the last post, uploading 8 bytes (instance ID + meshlet ID) per cluster to the GPU was way too expensive. The solution I came up with was to dispatch a compute shader thread per cluster, have it perform a binary search on an array of per-instance data to find the instance and meshlet it belongs to, and then write out the instance and meshlet IDs. This way, we only had to upload 8 bytes per <em>instance</em> to the GPU, and then the cluster -&gt; instance ID + meshlet ID write outs would be VRAM -&gt; VRAM writes, which are much faster than RAM -&gt; VRAM uploads. This was the fill cluster buffers pass in Bevy 0.14.</p> <p>It's not <em>super</em> fast, but it's also not the bottleneck, and so for a while I was fine leaving it as-is. Kirill, however, showed me a much better way.</p> <p>Instead of having our compute shader operate on a list of clusters, and write out the two IDs per cluster, we can turn the scheme on its head. We can instead have the shader operate on a list of <em>instances</em>, and write out the two IDs for each cluster within the instance. After all, each instance already has the list of meshlets it has, so writing out the cluster (an instance of a meshlet) is easy!</p> <p>Instead of dispatching one thread per cluster, now we're going to dispatch one workgroup per instance, with each workgroup having 1024 threads (the maximum allowed). Instead of uploading a prefix-sum of meshlet counts per instance, now we're going to upload just a straight count of meshlets per instance (we're still only uploading 8 bytes per instance total).</p> <p>In the shader, each workgroup can load the 8 bytes of data we uploaded for the instance it's processing.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#268bd2;">let</span><span> instance_id </span><span style="color:#657b83;">=</span><span> workgroup_id.x; </span><span style="color:#268bd2;">let</span><span> instance_meshlet_count </span><span style="color:#657b83;">=</span><span> meshlet_instance_meshlet_counts</span><span style="color:#657b83;">[</span><span>instance_id</span><span style="color:#657b83;">]</span><span>; </span><span style="color:#268bd2;">let</span><span> instance_meshlet_slice_start </span><span style="color:#657b83;">=</span><span> meshlet_instance_meshlet_slice_starts</span><span style="color:#657b83;">[</span><span>instance_id</span><span style="color:#657b83;">]</span><span>; </span></code></pre> <p>Then, the first thread in each workgroup can reserve space in the output buffers for its instance's clusters via an atomic counter, and broadcast the start index to the rest of the workgroup.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span>var&lt;workgroup&gt; cluster_slice_start_workgroup: </span><span style="color:#268bd2;">u32</span><span>; </span><span> </span><span style="color:#586e75;">// Reserve cluster slots for the instance and broadcast to the workgroup </span><span style="color:#859900;">if</span><span> local_invocation_index </span><span style="color:#657b83;">==</span><span> 0u </span><span style="color:#657b83;">{ </span><span> cluster_slice_start_workgroup </span><span style="color:#657b83;">=</span><span> atomicAdd</span><span style="color:#657b83;">(</span><span style="color:#859900;">&amp;</span><span>meshlet_global_cluster_count, instance_meshlet_count</span><span style="color:#657b83;">)</span><span>; </span><span style="color:#657b83;">} </span><span style="color:#268bd2;">let</span><span> cluster_slice_start </span><span style="color:#657b83;">=</span><span> workgroupUniformLoad</span><span style="color:#657b83;">(</span><span style="color:#859900;">&amp;</span><span>cluster_slice_start_workgroup</span><span style="color:#657b83;">)</span><span>; </span></code></pre> <p>Finally, we can have the workgroup loop over its instance's clusters, and for each one, write out its instance ID (which we already have, since it's just the workgroup ID) and meshlet ID (the instance's first meshlet ID, plus the loop counter). Each thread will handle 1 cluster, and the workgroup as a whole will loop enough times to write out all of the instance's clusters.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#586e75;">// Loop enough times to write out all the meshlets for the instance given that each thread writes 1 meshlet in each iteration </span><span style="color:#859900;">for </span><span style="color:#657b83;">(</span><span>var clusters_written </span><span style="color:#657b83;">=</span><span> 0u; clusters_written </span><span style="color:#657b83;">&lt;</span><span> instance_meshlet_count; clusters_written </span><span style="color:#657b83;">+=</span><span> 1024u</span><span style="color:#657b83;">) { </span><span> </span><span style="color:#586e75;">// Calculate meshlet ID within this instance&#39;s MeshletMesh to process for this thread </span><span> </span><span style="color:#268bd2;">let</span><span> meshlet_id_local </span><span style="color:#657b83;">=</span><span> clusters_written </span><span style="color:#657b83;">+</span><span> local_invocation_index; </span><span> </span><span style="color:#859900;">if</span><span> meshlet_id_local </span><span style="color:#657b83;">&gt;=</span><span> instance_meshlet_count </span><span style="color:#657b83;">{ </span><span style="color:#859900;">return</span><span>; </span><span style="color:#657b83;">} </span><span> </span><span> </span><span style="color:#586e75;">// Find the overall cluster ID in the global cluster buffer </span><span> </span><span style="color:#268bd2;">let</span><span> cluster_id </span><span style="color:#657b83;">=</span><span> cluster_slice_start </span><span style="color:#657b83;">+</span><span> meshlet_id_local; </span><span> </span><span> </span><span style="color:#586e75;">// Find the overall meshlet ID in the global meshlet buffer </span><span> </span><span style="color:#268bd2;">let</span><span> meshlet_id </span><span style="color:#657b83;">=</span><span> instance_meshlet_slice_start </span><span style="color:#657b83;">+</span><span> meshlet_id_local; </span><span> </span><span> </span><span style="color:#586e75;">// Write results to buffers </span><span> meshlet_cluster_instance_ids</span><span style="color:#657b83;">[</span><span>cluster_id</span><span style="color:#657b83;">] =</span><span> instance_id; </span><span> meshlet_cluster_meshlet_ids</span><span style="color:#657b83;">[</span><span>cluster_id</span><span style="color:#657b83;">] =</span><span> meshlet_id; </span><span style="color:#657b83;">} </span></code></pre> <p>The shader is now very efficient - the workgroup as a whole, once it reserves space for its clusters, is just repeatedly performing contiguous reads from and writes to global GPU memory.</p> <p>Overall, in a test scene with 1041 instances with 32217 meshlets per instance, we went from 0.55ms to 0.40ms, a small 0.15ms savings. NSight now shows that we're at 95% VRAM throughput, and that we're bound by global memory operations. The speed of this pass is now basically dependent on our GPU's bandwidth - there's not much I could do better, short of reading and writing less data entirely.</p> <h3 id="hitting-a-bump">Hitting a Bump<a class="zola-anchor" href="#hitting-a-bump" aria-label="Anchor link for: hitting-a-bump" style="visibility: hidden;"></a> </h3> <p>In the process of testing this PR, I ran into a rather confusing bug. The new fill cluster buffers pass worked on some smaller test scenes, but spawning 1042 instances with 32217 meshlets per instance (cliff mesh) lead to the below glitch. It was really puzzling - only some instances would be affected (concentrated in the same region of space), and the clusters themselves appeared to be glitching and changing each frame.</p> <p><img src="https://jms55.github.io/posts/2024-11-14-virtual-geometry-bevy-0-15/cluster_limit.png" alt="Glitched mesh" /></p> <p>Debugging the issue was complicated by the fact that the rewritten fill cluster buffers code is no longer deterministic. Clusters get written in different orders depending on how the scheduler schedules workgroups, and the order of the atomic writes. That meant that every time I clicked on a pass in RenderDoc to check it's output, the output order would completely change as RenderDoc replayed the entire command stream up until that point.</p> <p>Since using a debugger wasn't stable enough to be useful, I tried to think the logic through. My first thought was that my rewritten code was subtly broken, but testing on mainline showed something alarming - the issue persisted. Testing several old PRs showed that it went back for several PRs. It couldn't have been due to any recent code changes.</p> <p>It took me a week or so of trial and error, and debugging on mainline (which did have a stable output order since it used the old fill cluster buffers shader), but I eventually made the following observations:</p> <ul> <li>1041 cliffs: rendered correctly</li> <li>1042 cliffs: did <em>not</em> render correctly, with 1 glitched instance</li> <li>1041 + N cliffs: the last N being spawned glitched out</li> <li>1042+ instances of a different mesh with much less meshlets than the cliff: <em>did</em> render correctly</li> <li>1042+ cliffs on the PR before I increased meshlet size to 255v/128t: rendered correctly</li> </ul> <p>The issue turned out to be overflow of cluster ID. The output of the culling pass, and the data we store in the visbuffer, is cluster ID + triangle ID packed together in a single u32. After increasing the meshlet size, it was 25 bits for the cluster ID, and 7 bits for the triangle ID (2^7 = 128 triangles max).</p> <p>Doing the math, 1042 instances * 32217 meshlets = 33570114 clusters. 2^25 - 33570114 = -15682. We had overflowed the cluster limit by 15682 clusters. This meant that the cluster IDs we were passing around were garbage values, leading to glitchy rendering on any instances we spawned after the first 1041.</p> <p>Obviously this is a problem - the whole point of virtual geometry is to make rendering independent of scene complexity, yet now we have a rather low limit of 2^25 clusters in the scene.</p> <p>The solution is to never store data per cluster in the scene, and only store data per <em>visible</em> cluster in the scene, i.e. clusters post LOD selection and culling. Not necessarily visible on screen, but visible in the sense that we're going to rasterize them. Doing so would require a large amount of architectural changes, however, and is not going to be a simple and easy fix. For now, I've documented the limitation, and merged this PR confident that it's not a regression.</p> <h2 id="software-rasterization-bugfixes">Software Rasterization Bugfixes<a class="zola-anchor" href="#software-rasterization-bugfixes" aria-label="Anchor link for: software-rasterization-bugfixes" style="visibility: hidden;"></a> </h2> <p>PR <a rel="nofollow noreferrer" href="https://github.com/bevyengine/bevy/pull/16049">#16049</a> fixes some glitches in the software rasterizer.</p> <p>While testing out some scenes to prepare for the release, I discovered some previously-missed bugs with software rasterization. When zooming in to the scene, sometimes triangles would randomly glitch and cover the whole screen, leading to massive slowdowns (remember the software rasterizer is meant to operate on small triangles only). Similarly, when zooming out, sometimes there would be single stray pixels rendered that didn't belong. These issues didn't occur with only hardware rasterization enabled.</p> <center> <p><img src="https://jms55.github.io/posts/2024-11-14-virtual-geometry-bevy-0-15/stray_pixels.png" alt="Stray pixels glitch" /> <em>Stray pixels on the tops of the pawns and king.</em></p> </center> <p>The stray pixels turned out to be due to two issues. The first bug is in how I calculated the bounding box around each triangle. I wasn't properly accounting for triangles that would be partially on-screen, and partially off-screen. I changed my bounding box calculations to stick to floating point, and clamped negative bounds to 0 to fix. The second bug is that I didn't perform any backface culling in the software rasterizer, and ignoring it does not lead to valid results. If you want a double-sided mesh, then you need to explicitly check for backfacing triangles and invert them. If you want backface culling (I do), then you need to reject the triangle if it's backfacing. Ignoring it turned out to not be an option - skipping backface culling earlier turned out to have bitten me :).</p> <center> <p><img src="https://jms55.github.io/posts/2024-11-14-virtual-geometry-bevy-0-15/fullscreen_triangle.png" alt="Fullscreen triangle glitch" /> <em>The large green and orange triangles aren't supposed to be there.</em></p> </center> <p>The fullscreen triangles was trickier to figure out, but I ended up narrowing it down to near plane clipping. Rasterization math, specifically the homogenous divide, has a <a rel="nofollow noreferrer" href="https://en.wikipedia.org/wiki/Singularity_(mathematics)">singularity</a> when z = 0. Normally, the way you solve this is by clipping to the near plane, which is a frustum plane positioned slightly in front of z = 0. As long as you provide the plane, GPU rasterizers handle near plane clipping for you automatically. In my software rasterizer, however, I had of course not accounted for near plane clipping. That meant that we were getting Nan/Infinity vertex positions due to the singularity during the homogenous divide, which led to the garbage triangles we were seeing.</p> <p>Proper near plane clipping is somewhat complicated (slow), and should not be needed for most clusters. Rather than have our software rasterizer handle near plane clipping, we're instead going to have the culling pass detect which clusters intersect the near plane, and put them in the hardware rasterization queue regardless of size. The fix for this is just two extra lines.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#586e75;">// Before </span><span style="color:#859900;">if</span><span> cluster_is_small </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#586e75;">// Software raster </span><span style="color:#657b83;">} </span><span style="color:#859900;">else </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#586e75;">// Hardware raster </span><span style="color:#657b83;">} </span><span> </span><span style="color:#586e75;">// After </span><span style="color:#268bd2;">let</span><span> not_intersects_near_plane </span><span style="color:#657b83;">= </span><span style="color:#859900;">dot</span><span style="color:#657b83;">(</span><span>view.frustum</span><span style="color:#657b83;">[</span><span>4u</span><span style="color:#657b83;">]</span><span>, culling_bounding_sphere_center</span><span style="color:#657b83;">) &gt;</span><span> culling_bounding_sphere_radius; </span><span style="color:#859900;">if</span><span> cluster_is_small </span><span style="color:#859900;">&amp;&amp;</span><span> not_intersects_near_plane </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#586e75;">// Software raster </span><span> </span><span style="color:#657b83;">} </span><span style="color:#859900;">else </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#586e75;">// Hardware raster </span><span style="color:#657b83;">} </span></code></pre> <p>With these changes, software raster is now visibly bug-free.</p> <h2 id="normal-aware-lod-selection">Normal-aware LOD Selection<a class="zola-anchor" href="#normal-aware-lod-selection" aria-label="Anchor link for: normal-aware-lod-selection" style="visibility: hidden;"></a> </h2> <p>PR <a rel="nofollow noreferrer" href="https://github.com/bevyengine/bevy/pull/16111">#16111</a> improves how we calculate the LOD cut to account for vertex normals.</p> <p>At the end of the Bevy 0.15's development cycle, meshoptimizer 0.22 was released, bringing some simplification improvements. Crucially, it greatly improves <code>meshopt_simplifyWithAttributes()</code>.</p> <p>I now use this function to pass vertex normals into the simplifier, meaning that the deformation error the simplifier outputs (which we feed directly into the LOD cut selection shader) accounts for not only position deformation, but also normal deformation.</p> <p>Without this change, before this PR, visualizing the pixel positions was near-seamless as the LOD cut changed when you zoomed in or out. Pixel normals, however, had visible differences between LOD cuts. After this PR, normals are now near-seamless too.</p> <p>There's still work to be done in this area - I'm not currently accounting for UV coordinate deformation, and the weights I chose for position vs normal influence are completely arbitrary. The Nanite presentation talks about this problem a lot - pre-calculating an error amount that perfectly accounts for every aspect of human perception, for meshes with arbitrary materials, is a <em>really</em> hard problem. The best we can do is spend time tweaking heuristics, which I'll leave for a future PR.</p> <h2 id="results-bevy-0-14-vs-0-15">Results: Bevy 0.14 vs 0.15<a class="zola-anchor" href="#results-bevy-0-14-vs-0-15" aria-label="Anchor link for: results-bevy-0-14-vs-0-15" style="visibility: hidden;"></a> </h2> <p>Finally, I'd like to compare Bevy v0.14 to (what will soon release as) v0.15.</p> <p>The test scene we'll be comparing is 3375 instances of the Stanford bunny mesh arranged in a 15x15x15 cube, running at a resolution of 2240x1260 on an RTX 3080 locked to base clocks.</p> <p>As an additional test scene, we'll also be looking at 847 instances of the <a rel="nofollow noreferrer" href="https://www.fab.com/listings/e16b2143-5512-4460-bd0c-9270c4c6df51">Huge Icelandic Lava Cliff</a> quixel megascan asset arranged in an 11x11x7 rectangular prism. This asset was too big to process in Bevy v0.14, so for this scene we'll only be looking at data from Bevy v0.15.</p> <center style="display: flex; flex-direction: column;"> <p><img src="https://jms55.github.io/posts/2024-11-14-virtual-geometry-bevy-0-15/0.14.png" alt="Bunny scene screenshot v0.14" /> <em>Bunny scene in Bevy v0.14.</em> <img src="https://jms55.github.io/posts/2024-11-14-virtual-geometry-bevy-0-15/0.15.png" alt="Bunny scene screenshot v0.15" /> <em>Bunny scene in Bevy v0.15.</em> <img src="https://jms55.github.io/posts/2024-11-14-virtual-geometry-bevy-0-15/cliffs.png" alt="Cliff scene screenshot v0.15" /> <em>Cliff scene in Bevy v0.15.</em></p> <h3 id="gpu-timings">GPU Timings<a class="zola-anchor" href="#gpu-timings" aria-label="Anchor link for: gpu-timings" style="visibility: hidden;"></a> </h3> <p>GPU timings to render the visbuffer (so excluding shading, and any CPU work)</p> <table style="border-collapse:collapse;border-color:#ccc;border-spacing:0;border:none" class="tg"><thead> <tr><th style="background-color:#f0f0f0;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:large;font-weight:normal;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="font-weight:bold;color:#333;background-color:#F0F0F0">Pass</span></th><th style="background-color:#f0f0f0;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:large;font-weight:normal;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="font-weight:bold;color:#333;background-color:#F0F0F0">Bunny v0.14</span></th> <th style="background-color:#f0f0f0;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:large;font-weight:normal;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="font-weight:bold;color:#333;background-color:#F0F0F0">Bunny v0.15</span></th><th style="background-color:#f0f0f0;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:large;font-weight:normal;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="font-weight:bold;color:#333;background-color:#F0F0F0">Cliff v0.15</span></th></tr> </thead> <tbody> <tr><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">Fill Cluster Buffers</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">0.30</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">0.12</span></td> <td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">0.31</span></td></tr> <tr><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">Culling First</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">0.99</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">0.19</span></td> <td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">1.27</span></td></tr> <tr><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">Software Raster First</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">N/A</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">0.42</span></td> <td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">0.34</span></td></tr> <tr><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">Hardware Raster First</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">3.44</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">&lt; 0.01</span></td> <td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">0.02</span></td></tr> <tr><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">Downsample Depth</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">0.03</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">0.03</span></td> <td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">0.05</span></td></tr> <tr><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">Culling Second</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">0.14</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">0.06</span></td> <td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">0.19</span></td></tr> <tr><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">Software Raster Second</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">N/A</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">&lt; 0.01</span></td> <td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">&lt; 0.01</span></td></tr> <tr><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">Hardware Raster Second</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">&lt; 0.01</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">&lt; 0.01</span></td> <td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">&lt; 0.01</span></td></tr> <tr><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">Resolve Depth</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">N/A</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">0.04</span></td> <td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">0.05</span></td></tr> <tr><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">Resolve Material Depth</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">0.04</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">0.04</span></td> <td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">0.04</span></td></tr> <tr><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">Downsample Depth</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">0.03</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">0.03</span></td> <td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">0.05</span></td></tr> <tr><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="font-weight:bold;color:#333;background-color:#FFF">Total</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="font-weight:bold;color:#333;background-color:#FFF">4.97 ms</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="font-weight:bold;color:#333;background-color:#FFF">0.93 ms</span></td> <td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="font-weight:bold;color:#333;background-color:#FFF">2.32 ms</span></td></tr> </tbody></table> <h3 id="dag-layout">DAG Layout<a class="zola-anchor" href="#dag-layout" aria-label="Anchor link for: dag-layout" style="visibility: hidden;"></a> </h3> <table style="border-collapse:collapse;border-color:#ccc;border-spacing:0;border:none" class="tg"><thead> <tr><th style="background-color:#f0f0f0;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:large;font-weight:normal;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal" colspan="3"><span style="font-weight:bold;color:#333;background-color:#F0F0F0">Bunny v0.14</span></th><th style="background-color:#f0f0f0;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:large;font-weight:normal;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal" colspan="3"><span style="font-weight:bold;color:#333;background-color:#F0F0F0">Bunny v0.15</span></th> <th style="background-color:#f0f0f0;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:large;font-weight:normal;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal" colspan="3"><span style="font-weight:bold;color:#333;background-color:#F0F0F0">Cliff v0.15</span></th></tr> </thead> <tbody> <tr><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="font-weight:bold;color:#333;background-color:#F9F9F9">LOD Level</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="font-weight:bold;color:#333;background-color:#F9F9F9">Meshlets</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="font-weight:bold;color:#333;background-color:#F9F9F9">Meshlets With 64 Triangles (full)</span></td> <td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="font-weight:bold;color:#333;background-color:#F9F9F9">LOD Level</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="font-weight:bold;color:#333;background-color:#F9F9F9">Meshlets</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="font-weight:bold;color:#333;background-color:#F9F9F9">Meshlets With 128 Triangles (full)</span></td> <td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="font-weight:bold;color:#333;background-color:#F9F9F9">LOD Level</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="font-weight:bold;color:#333;background-color:#F9F9F9">Meshlets</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="font-weight:bold;color:#333;background-color:#F9F9F9">Meshlets With 128 Triangles (full)</span></td></tr> <tr><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">0</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">2251</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">2250</span></td> <td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">0</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">1126</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">1125</span></td> <td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">0</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">15616</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">15615</span></td></tr> <tr><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">1</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">1320</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">931</span></td> <td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">1</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">608</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">517</span></td> <td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">1</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">7944</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">7610</span></td></tr> <tr><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">2</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">672</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">383</span></td> <td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">2</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">310</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">251</span></td> <td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">2</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">4306</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">3535</span></td></tr> <tr><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">3</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">373</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">172</span></td> <td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">3</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">162</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">129</span></td> <td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">3</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">2200</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">1728</span></td></tr> <tr><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">4</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">173</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">47</span></td> <td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">4</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">80</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">61</span></td> <td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">4</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">1109</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">844</span></td></tr> <tr><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">5</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">74</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">15</span></td> <td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">5</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">38</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">29</span></td> <td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">5</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">552</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">425</span></td></tr> <tr><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">6</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">19</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">4</span></td> <td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">6</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">20</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">15</span></td> <td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">6</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">282</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">214</span></td></tr> <tr><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"></td> <td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">7</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">10</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">7</span></td> <td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">7</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">139</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">105</span></td></tr> <tr><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"></td> <td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">8</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">5</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">3</span></td> <td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">8</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">69</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">51</span></td></tr> <tr><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"></td> <td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">9</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">3</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">2</span></td> <td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">9</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">35</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">26</span></td></tr> <tr><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"></td> <td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">10</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">2</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">1</span></td> <td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">10</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">18</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">13</span></td></tr> <tr><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"></td> <td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">11</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">1</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">1</span></td> <td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">11</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">9</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">6</span></td></tr> <tr><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"></td> <td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">12</span></td> <td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">5</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">3</span></td></tr> <tr><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"></td> <td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">13</span></td> <td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">2</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">1</span></td></tr> <tr><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"></td> <td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">14</span></td> <td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">2</span></td><td style="background-color:#fff;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">1</span></td></tr> </tbody></table> <h3 id="disk-usage">Disk Usage<a class="zola-anchor" href="#disk-usage" aria-label="Anchor link for: disk-usage" style="visibility: hidden;"></a> </h3> <table style="border-collapse:collapse;border-color:#ccc;border-spacing:0;border:none" class="tg"><thead> <tr><th style="background-color:#f0f0f0;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:large;font-weight:normal;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="font-weight:bold;color:#333;background-color:#F0F0F0">Bunny v0.14</span></th><th style="background-color:#f0f0f0;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:large;font-weight:normal;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="font-weight:bold;color:#333;background-color:#F0F0F0">Bunny v0.15</span></th> <th style="background-color:#f0f0f0;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:large;font-weight:normal;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="font-weight:bold;color:#333;background-color:#F0F0F0">Cliff v0.15</span></th></tr> </thead> <tbody><tr><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">5.05 MB</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">3.61 MB</span></td><td style="background-color:#f9f9f9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">49.83 MB</span></td></tr></tbody></table> <h3 id="memory-usage">Memory Usage<a class="zola-anchor" href="#memory-usage" aria-label="Anchor link for: memory-usage" style="visibility: hidden;"></a> </h3> <table style="border-collapse:collapse;border-color:#ccc;border-spacing:0;border:none" class="tg"><thead><tr><th style="background-color:#F0F0F0;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:large;font-weight:bold;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal" colspan="2"><span style="font-weight:bold">Bunny v0.14</span></th><th style="background-color:#F0F0F0;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:large;font-weight:bold;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal" colspan="2"><span style="font-weight:bold">Bunny v0.15</span></th><th style="background-color:#F0F0F0;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:large;font-weight:bold;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal" colspan="2"><span style="font-weight:bold">Cliff v0.15</span></th></tr></thead> <tbody> <tr><td style="background-color:#F9F9F9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;font-weight:bold;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="font-weight:bold">Data Type</span></td><td style="background-color:#F9F9F9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;font-weight:bold;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="font-weight:bold">Size (bytes)</span></td><td style="background-color:#F9F9F9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;font-weight:bold;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="font-weight:bold">Data Type</span></td> <td style="background-color:#F9F9F9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;font-weight:bold;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="font-weight:bold">Size (bytes)</span></td><td style="background-color:#F9F9F9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;font-weight:bold;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="font-weight:bold">Data Type</span></td><td style="background-color:#F9F9F9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;font-weight:bold;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="font-weight:bold">Size (bytes)</span></td></tr> <tr><td style="background-color:#FFF;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">Vertex Data</span></td><td style="background-color:#FFF;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">3505296</span></td><td style="background-color:#FFF;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">Vertex Positions</span></td> <td style="background-color:#FFF;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">590132</span></td><td style="background-color:#FFF;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">Vertex Positions</span></td><td style="background-color:#FFF;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">8537220</span></td></tr> <tr><td style="background-color:#F9F9F9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">Vertex IDs</span></td><td style="background-color:#F9F9F9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">3651840</span></td><td style="background-color:#F9F9F9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">Vertex Normals</span></td> <td style="background-color:#F9F9F9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">788476</span></td><td style="background-color:#F9F9F9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">Vertex Normals</span></td><td style="background-color:#F9F9F9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">10851996</span></td></tr> <tr><td style="background-color:#FFF;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"></td><td style="background-color:#FFF;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"></td><td style="background-color:#FFF;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">Vertex UVs</span></td> <td style="background-color:#FFF;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">1576952</span></td><td style="background-color:#FFF;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">Vertex UVs</span></td><td style="background-color:#FFF;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">21703992</span></td></tr> <tr><td style="background-color:#F9F9F9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">Indices</span></td><td style="background-color:#F9F9F9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">2738880</span></td><td style="background-color:#F9F9F9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">Indices</span></td> <td style="background-color:#F9F9F9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">1374336</span></td><td style="background-color:#F9F9F9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">Indices</span></td><td style="background-color:#F9F9F9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">19245696</span></td></tr> <tr><td style="background-color:#FFF;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">Meshlets</span></td><td style="background-color:#FFF;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">16 * 4882 = 78112</span></td><td style="background-color:#FFF;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">Meshlets</span></td> <td style="background-color:#FFF;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">32 * 2365 = 75680</span></td><td style="background-color:#FFF;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">Meshlets</span></td><td style="background-color:#FFF;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">32 * 32288 = 1033216</span></td></tr> <tr><td style="background-color:#F9F9F9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">Bounding Spheres</span></td><td style="background-color:#F9F9F9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">234336</span></td><td style="background-color:#F9F9F9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">Bounding Spheres</span></td> <td style="background-color:#F9F9F9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">113520</span></td><td style="background-color:#F9F9F9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">Bounding Spheres</span></td><td style="background-color:#F9F9F9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#F9F9F9">1549824</span></td></tr> <tr><td style="background-color:#FFF;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"></td><td style="background-color:#FFF;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"></td><td style="background-color:#FFF;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">Simplification Errors</span></td> <td style="background-color:#FFF;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">9460</span></td><td style="background-color:#FFF;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">Simplification Errors</span></td><td style="background-color:#FFF;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="color:#333;background-color:#FFF">129152</span></td></tr> <tr><td style="background-color:#F9F9F9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;font-weight:bold;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="font-weight:bold">Total</span></td><td style="background-color:#F9F9F9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;font-weight:bold;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="font-weight:bold">10.2 MB</span></td><td style="background-color:#F9F9F9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;font-weight:bold;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="font-weight:bold">Total</span></td> <td style="background-color:#F9F9F9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;font-weight:bold;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="font-weight:bold">4.5 MB</span></td><td style="background-color:#F9F9F9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;font-weight:bold;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="font-weight:bold">Total</span></td><td style="background-color:#F9F9F9;border-color:#ccc;border-style:solid;border-width:0px;color:#333;font-family:Arial, sans-serif;font-size:14px;font-weight:bold;overflow:hidden;padding:10px 5px;text-align:center;vertical-align:top;word-break:normal"><span style="font-weight:bold">63.0 MB</span></td></tr> </tbody></table> </center> <h3 id="discussion-asset">Discussion - Asset<a class="zola-anchor" href="#discussion-asset" aria-label="Anchor link for: discussion-asset" style="visibility: hidden;"></a> </h3> <p>First, lets compare the DAG layout between the Stanford bunny in Bevy 0.14 and 0.15. In Bevy 0.14, with 64 triangles max per meshlet, we start with 2250 meshlets at LOD 0. In Bevy 0.15, with 128 triangles max per meshlet, we have exactly half as many at 1125.</p> <p>In Bevy 0.14, the DAG has 7 levels, ending with 19 meshlets. In Bevy 0.15, the DAG has 12 levels, ending at a single meshlet! For an ideal DAG, we want half as many meshlets at each LOD level, resulting in half as many triangles at each level. That means that with 1125 meshlets at LOD level 0, we want <code>ceil(log2(1125)) = 11</code> additional levels, for 12 total. In Bevy 0.15, we have 12! Meanwhile in Bevy 0.14, we also want 12 levels, but fall short at only 7 levels. We clearly improved the DAG structure compared to the previous version.</p> <p>Comparing meshlet fill rate (percentage of meshlets with the maximum number of triangles), both versions have an almost 100% fill rate at LOD 0 (the mesh is probably not a perfect multiple of the max triangle count). Meshoptimizer does a great job of equally partitioning triangles for the initial mesh.</p> <p>However, looking at further LOD levels, Bevy 0.14 performs very badly, going down to an abysmal 20% fill rate at the lowest. Bevy 0.15 is a lot better, with the worst fill rate being 76%, and the variance being a lot lower. It's still not perfect - a lot of the time we still have to deal with stuck triangles that never get simplified when processing complex meshes - but it's good progress!</p> <p>Memory and disk size are also much lower in Bevy 0.15 than Bevy 0.14, although a lot of this (but not all) comes down to the ~half as many overall meshlets in the DAG, meaning that there's less data to store in the first place. Still, adding up the vertex info for Bevy 0.14 (vertex data + vertex IDs = <code>7.16 MB</code>) and for Bevy 0.15 (vertex positions + normals + UVs = <code>2.956 MB</code>) shows a clear reduction in memory usage for the same amount of triangles in the original mesh.</p> <h3 id="discussion-performance">Discussion - Performance<a class="zola-anchor" href="#discussion-performance" aria-label="Anchor link for: discussion-performance" style="visibility: hidden;"></a> </h3> <p>Of course, asset size dosen't matter if performance is worse. After all, we could skip the additional LOD levels entirely to save on the cost of storing them, but we would get much worse runtime performance.</p> <p>The good news is that comparing the bunny scene in Bevy 0.14 to Bevy 0.15, rendering got almost 5x faster!</p> <p>Rasterization is the big immediate win. We were spending 3.44 ms on it in Bevy 0.14, and now only 0.42 ms on it in Bevy 0.15! Some of this comes down to software raster being faster than our non-mesh shader hardware raster, but a lot of it comes down to our improved DAG creation and LOD selection code. DAG building is really, really important - a huge chunk of your runtime performance comes down to building a good DAG, before you even start rendering!</p> <p>Culling (which is also LOD selection) got a little bit faster as well, going from 0.99 ms to 0.19 ms in the first pass, and 0.14 to 0.06 ms in the second pass. The culling pass no longer has to write out a list of triangles for visible clusters - now it's just writing a single cluster ID for each visible cluster, which is much faster.</p> <p>The other big win for culling is that with ~half as many meshlets to process, we only have to do half the work, as evidenced by the second pass performing a little over twice as well (the second pass here is basically just measuring overhead from spawning threads per cluster, since it's doing a single read + early-out for every single cluster as occlusion culling is near-perfect in static scene like this).</p> <p>Looking at the cliff scene with a much larger amount of meshlets and triangles, concentrated into much fewer instances, we can see some interesting results. Rasterization is actually <em>faster</em> in this scene than the bunny scene by 0.08 ms, but the first culling pass takes a whopping 1.27 ms, up from only 0.19 ms. Ouch. We ideally want similiar timings no matter the type of scene, so that artists don't have to care about things like number of triangles per mesh, but we're not quite there yet. Culling is the clear bottleneck.</p> <p>Finally, fill cluster buffers got a little bit faster as well, going down from 0.30 ms to 0.12 ms, with a good chunk of the performance again coming from having half as many total clusters in the scene.</p> <h2 id="roadmap">Roadmap<a class="zola-anchor" href="#roadmap" aria-label="Anchor link for: roadmap" style="visibility: hidden;"></a> </h2> <p>I got a lot done in Bevy 0.15, but there's still a <em>ton</em> left to do for Bevy 0.16 and beyond.</p> <p>The major, immediate priority (once I'm rested and ready to work on virtual geometry again) will be improving the culling/LOD selection pass. While cluster selection (I should rename the pass to that, that's a good name now that I think of it) is an <a rel="nofollow noreferrer" href="https://en.wikipedia.org/wiki/Embarrassingly_parallel">embarrassingly parallel</a> problem in theory, in practice, having to dispatch a thread per cluster in the scene is an enormous waste of time. There can be million of clusters in the scene, and divergence and register usage on top of the sheer number of threads needed means that this pass is currently the biggest bottleneck.</p> <p>The fix is to (like Nanite does) traverse a BVH (tree) of clusters, where we only need to process clusters up until they would be the wrong LOD, and then can immediately stop processing their children. Doing tree traversal on a GPU is very tricky, and doing it maximally efficient depends on <a rel="nofollow noreferrer" href="https://arxiv.org/pdf/2109.06132v1">undefined behavior</a> of GPU schedulers that not all GPUs have, so I expect to spend a lot of time tweaking this once I get something working.</p> <p>The second major priority is getting rid of the need for the fill cluster buffers pass entirely. Besides letting us reclaim some more performance, the big win is that we could do away with the need to allocate buffers to hold instance ID + cluster ID per cluster in the scene, instead letting us store this data per <em>visible</em> (post LOD selection/culling) cluster in the scene. Besides the obvious memory savings, it also saves us from running into the cluster ID limit issue that was limiting our scene size before. We would no longer need a unique ID for each cluster in the scene - just a unique ID for visible clusters only, post culling and LOD selection, which is a much smaller amount.</p> <p>Besides cluster selection improvements, and improving on existing stuff, other big areas I could work on include:</p> <ul> <li>Streaming of meshlet vertex data (memory savings)</li> <li>Disk-oriented asset compression (disk and load time savings)</li> <li>Rendering clusters for all views at once (performance savings for shadow views)</li> <li>Material shader optimizations (I haven't spent any time at all on this yet)</li> <li>Occlusion culling fixes (I plan to port Hans-Kristian Arntzen's Granite renderer's <a rel="nofollow noreferrer" href="https://github.com/Themaister/Granite/blob/7543863d2a101faf45f897d164b72037ae98ff74/assets/shaders/post/hiz.comp">HiZ shader</a> to WGSL)</li> <li>Tooling to make working with MeshletMeshes easier</li> <li>Testing and improving CPU performance for large amounts of instances</li> </ul> <p>With any luck, in another few months I'll be writing about some of these topics in the post for Bevy 0.16. See you then!</p> <h2 id="appendix">Appendix<a class="zola-anchor" href="#appendix" aria-label="Anchor link for: appendix" style="visibility: hidden;"></a> </h2> <p>Further resources on Nanite-style virtual geometry:</p> <ul> <li><a rel="nofollow noreferrer" href="https://advances.realtimerendering.com/s2021/Karis_Nanite_SIGGRAPH_Advances_2021_final.pdf">https://advances.realtimerendering.com/s2021/Karis_Nanite_SIGGRAPH_Advances_2021_final.pdf</a></li> <li><a rel="nofollow noreferrer" href="https://github.com/jglrxavpok/Carrot">https://github.com/jglrxavpok/Carrot</a></li> <li><a rel="nofollow noreferrer" href="https://github.com/LVSTRI/IrisVk">https://github.com/LVSTRI/IrisVk</a></li> <li><a rel="nofollow noreferrer" href="https://github.com/pettett/multires">https://github.com/pettett/multires</a></li> <li><a rel="nofollow noreferrer" href="https://github.com/Scthe/nanite-webgpu">https://github.com/Scthe/nanite-webgpu</a></li> <li><a rel="nofollow noreferrer" href="https://github.com/ShawnTSH1229/SimNanite">https://github.com/ShawnTSH1229/SimNanite</a></li> <li><a rel="nofollow noreferrer" href="https://github.com/SparkyPotato/radiance">https://github.com/SparkyPotato/radiance</a></li> <li><a rel="nofollow noreferrer" href="https://github.com/zeux/meshoptimizer/blob/master/demo/nanite.cpp">https://github.com/zeux/meshoptimizer/blob/master/demo/nanite.cpp</a></li> </ul> Bevy's Fourth Birthday - A Year of Meshlets 2024-08-30T00:00:00+00:00 2024-08-30T00:00:00+00:00 Unknown https://jms55.github.io/posts/2024-08-30-bevy-fourth-birthday/ <blockquote> <p>Written in response to <a rel="nofollow noreferrer" href="https://bevyengine.org/news/bevys-fourth-birthday">Bevy's Fourth Birthday</a>.</p> </blockquote> <h3 id="introduction">Introduction<a class="zola-anchor" href="#introduction" aria-label="Anchor link for: introduction" style="visibility: hidden;"></a> </h3> <p>The subtitle of this post is "Bevy's Fourth Birthday; Already???". I feel like I <em>just</em> wrote Bevy's <em>third</em> birthday reflections only a couple of months ago... time flies!</p> <p>It's been an awesome year, with a lot accomplished. Lets talk about that.</p> <h3 id="a-year-11-months-of-meshlets"><del>A Year</del> 11 Months of Meshlets<a class="zola-anchor" href="#a-year-11-months-of-meshlets" aria-label="Anchor link for: a-year-11-months-of-meshlets" style="visibility: hidden;"></a> </h3> <p>What have I been doing in Bevy in the last year? The answer is learning and reimplementing the techniques behind Nanite (virtual geometry). That's mostly it.</p> <p>No really - the first commit I can find related to Bevy's meshlet feature (I need a better name for it...) is dated September 30th 2023. That's a little less than 2 months since the time I wrote Bevy's third birthday post, and around 11 months before the time of this writing.</p> <p>I <em>did</em> work on some other stuff - PCF being the most notable feature, along with some optimizations like async pipeline compilation to prevent shader stutter, and some experimental work that didn't pan out like solari and an improved render graph. But the large majority of my time spent has been on meshlets. In fact, this is going to be the third post on my blog in total - the first being Bevy's third birthday post, and the second being a huge writeup on my initial learnings from implementing meshlets.</p> <p>And I'm going to say it - I'm really proud of my work on this. It's an absolutely <em>massive</em> project spanning so many different concepts. It's been immensely rewarding, but also immensely draining. I've felt like quitting at times, and questioned the value it provides given that it's an AAA focused feature for a non-AAA-ready engine. But I've stuck with the project, and right now I can say that it's been worth it. Maybe it's not production ready yet (it's definitely not). Maybe there's still a ton of major things left to do, let alone optimize and tweak. Maybe occlusion culling is broken and I'm really avoiding looking at it because it's going to be painful to debug and fix; who can say?</p> <p>But I've learned a lot (really, a <em>lot</em>). It got referenced during a SIGGRAPH 2024 Advances in Real-Time Rendering in Games presentation. Brian Karis (the author of Nanite) mentioned that they enjoyed my blog post explaining it. Getting recognition and seeing people enjoy it has been awesome! And most of all, I'm immensely proud of myself and the work I put into it. Meshlets has been a journey, but a worthwhile one.</p> <p>Needless to say, in the next year, expect even more meshlet work. A lot has already been done since my last blog post - you'll see some of that when Bevy 0.15 releases. Hopefully I'll continue to be able to avoid burnout.</p> <h3 id="bevy-in-general">Bevy in General<a class="zola-anchor" href="#bevy-in-general" aria-label="Anchor link for: bevy-in-general" style="visibility: hidden;"></a> </h3> <p>Bevy 0.12, 0.13, and 0.14 were all released in the last year, and have brought an absolutely massive amount of improvements. Nice job everyone! Bevy is not just one person, or even 10, and I think that has really shown this year more than ever.</p> <p>Unlike last year, I don't have much I want to discuss in depth here, but there's a few things I want to talk about, in no particular order.</p> <p>Alice was hired (thank you sponsors) as Bevy's project manager. She's done an amazing job helping push forward PRs, coordinate developers, and generally get things done. I think I speak for all the Bevy devs when I say getting things done is nice. I'm looking forwards to more of that next year - thanks Alice!</p> <p>One thing I'd like to see from project management going forwards is closing 90% of our open PRs. We have hundreds of open PRs, some dating back to 2021. There's no way any of that is getting merged, and in my opinion, we should be closing old PRs and converting stuff we still want to open issues. The more open PRs we have, the harder it is for maintainers and new contributors to help review and push forward work. A <em>lot</em> of PRs end up rotting, and we're losing contributors sadly. I've started this process for PRs labeled rendering, but there's still a lot left to do, and a bunch of PRs that need SME or maintainer decisions. Gamedev is a bit unique given the extremely wide range of subjects (rendering, physics, assets, game logic, artist tooling, etc), but maybe there are things we can learn from other large open source projects. Blender, Godot, others - any advice for us?</p> <p>One of Bevy's most requested features (including from me) is a GUI program (editor) for modifying, inspecting, and profiling scenes. A couple of months ago I volunteered to help coordinate and push forward editor-related work, and then uhh, pretty much stopped working on it a few weeks after. Turns out, I didn't have the motivation to do both editor work, and meshlet work. Meshlet work ended up winning out. Sorry to everyone I let down on that. I <em>am</em> still excited to work on the editor, but unfortunately I've realized I'm not so motivated to work on more foundational work such as scene editing, asset processing, and especially UI frameworks. These subjects tend to circular around with out much real progress, and turns out I am not a leader able to push forward discussions in these areas.</p> <p>Side note, I <em>also</em> released my own UI framework this year (competing with the tens of other Bevy UI projects). It's called <a rel="nofollow noreferrer" href="https://github.com/JMS55/bevy_dioxus/blob/main/examples/demo.rs">bevy_dioxus</a>, and it builds on top of the excellent Dioxus library to provide reactivity. Rendering is handled by spawning bevy_ui entities. No documentation, but it's a fairly small amount of fairly clean code, and it's usable and integrates well with Bevy's ECS. No reinventing the wheel here. For a few weeks work, I'm pretty happy with the process of making it and how it turned out.</p> <p>Rendering is in pretty good shape now. Still lots more to implement or improve, but it's pretty usable! Going forwards, it would be nice to put more focus on documentation, ease of use, and ergonomics. The Material/AsBindGroup API is pretty footgun-y and not always performant, and there's a general lack of documentation for lower-level APIs besides "ask existing developers how to use things". A new render graph that automatically handled resources could help a lot with this, along with more asset-driven material APIs, and there's been some interest and design work in these space that I'm looking forward to.</p> <p>Assets and asset processing needs a <em>lot</em> of work. Ignoring the editor (which will need to build on these APIs), Bevy still needs a lot of work on extending the asset processing API, and implementing asset workflows for baking lighting, compressing textures, etc. A real battle-tested end-to-end asset workflow, from artists to developers to built game, really needs developing. I'm hoping that this will be a bigger focus next year, in parallel with the editor.</p> <h3 id="solarn-t">Solarn't<a class="zola-anchor" href="#solarn-t" aria-label="Anchor link for: solarn-t" style="visibility: hidden;"></a> </h3> <p>Last year I demoed a realtime, fully dynamic raytraced GI solution I called Bevy Solari... and now a year later I've written nothing else on it, and a lot about virtual geometry. What gives? Well, I did work on it for a few months more after that blog post, but the project kind of died for a variety of reasons.</p> <p>I was using a custom, somewhat buggy fork of wgpu/naga/naga_oil, and it became very difficult to constantly rebase on top of Bevy's and those project's upstream branches. The approach I was using (screen space probes based on Lumen and GI-1.0, and later screen space radiance cascades) started souring on me for complexity and quality reasons. My world space radiance cache was completely broken (I've sinced learned what I was doing wrong, thanks Darius Bouma!) and I lost motivation to work on it. And finally I ended up starting meshlets, and later transitioned all of my time to it. So, Solari is dead, at least for now.</p> <p>Nowadays I feel like ReSTIR-based techniques (ReSTIR DI and GI plus screen space denoisers) hold much more promise. DDGI is also a great solution that I initially discarded for quality reasons, but its pretty simple to implement, very easy to scale up or down in cost, and gives fairly decent results all things considered. DDGI is probably worth another consideration, not even necessarily for being the main Bevy Solari project, but as an easier project to start with, and more scalable alternative to ReSTIR. No reason both could not coexist.</p> <p>If raytracing gets upstreamed into wgpu, I would happily pick this project back up, particularly as I start to feel the need for a break from meshlets.</p> <h3 id="writing">Writing<a class="zola-anchor" href="#writing" aria-label="Anchor link for: writing" style="visibility: hidden;"></a> </h3> <p>Last year I finally started a blog... but didn't end up writing much. Or I did, but 80% of it was concentrated into one really long, really time-consuming post. It took something like a month to write.</p> <p>I also wrote a rather long post on reddit's /r/rust about things I disliked in Rust (after using it for so long, and recently using a lot of Java developing an enterprise application) (I do still love Rust, that hasn't changed). Surprisingly to me, a lot of people liked it and it sparked some interesting discussions. Seperate from the post's contents, people also asked me if I had a blog where they could read more of my writing. I of course, had to tell them that yes I do, but it only has two posts, one of which is extremely niche and technical, so there's not much all that much to read.</p> <p>Needless to say, this year, I'd like to try to blog more. I'm going to try to get more writing out, and focus less on quality and spending so much time editing. Starting of course, with this post.</p> <p>With my new focus on spending less time writing, I'm ending this post now without trying to find a conclusion that flows better. See everyone next year!</p> Virtual Geometry in Bevy 0.14 2024-06-09T00:00:00+00:00 2024-06-09T00:00:00+00:00 Unknown https://jms55.github.io/posts/2024-06-09-virtual-geometry-bevy-0-14/ <h1 id="introduction">Introduction<a class="zola-anchor" href="#introduction" aria-label="Anchor link for: introduction" style="visibility: hidden;"></a> </h1> <p>The 0.14 release of the open source <a rel="nofollow noreferrer" href="https://bevyengine.org">Bevy</a> game engine is coming up, and with it, the release of an experimental virtual geometry feature that I've been working on for several months.</p> <p>In this blog post, I'm going to give a technical deep dive into Bevy's new "meshlet" feature, what improvements it brings, techniques I tried that did or did not work out, and what I'm looking to improve on in the future. There's a lot that I've learned (and a <em>lot</em> of code I've written and rewritten multiple times), and I'd like to share what I learned in the hope that it will help others.</p> <p><img src="https://jms55.github.io/posts/2024-06-09-virtual-geometry-bevy-0-14/showcase.png" alt="Example scene for the meshlet renderer" /></p> <p>This post is going to be <em>very</em> long, so I suggest reading over it (and the Nanite slides) a couple of times to get a general overview of the pieces involved, before spending any time analyzing individual steps. At the time of this writing, my blog theme dosen't have a table of contents sidebar that follows you as you scroll the page. I apologize for that. If you want to go back and reference previous sections as you read this post, I suggest using multiple browser tabs.</p> <p>I'd also like to take a moment to thank <a rel="nofollow noreferrer" href="https://github.com/LVSTRI">LVSTRI</a> and <a rel="nofollow noreferrer" href="https://jglrxavpok.github.io">jglrxavpok</a> for sharing their experiences with virtual geometry, <a rel="nofollow noreferrer" href="https://github.com/atlv24">atlv24</a> for their help in several areas, especially for their work adding some missing features I needed to wgpu/naga, other Bevy developers for testing and reviewing my PRs, Unreal Engine (Brian Karis, Rune Stubbe, Graham Wihlidal) for their <em>excellent</em> and highly detailed <a rel="nofollow noreferrer" href="https://advances.realtimerendering.com/s2021/Karis_Nanite_SIGGRAPH_Advances_2021_final.pdf">SIGGRAPH presentation</a>, and many more people than I can name who provided advice on this project.</p> <p>Code for this feature can be found <a rel="nofollow noreferrer" href="https://github.com/JMS55/bevy/tree/ca2c8e63b9562f88c8cd7e1d88a17a4eea20aaf4/crates/bevy_pbr/src/meshlet">on github</a>.</p> <p>If you're already familiar with Nanite, feel free to skip the next few sections of background info until you get to the Bevy-specific parts.</p> <h2 id="why-virtual-geometry">Why Virtual Geometry?<a class="zola-anchor" href="#why-virtual-geometry" aria-label="Anchor link for: why-virtual-geometry" style="visibility: hidden;"></a> </h2> <p>Before talking about what virtual geometry <em>is</em>, I think it's worth looking at what problems it is trying to <em>solve</em>.</p> <p>Lets go over the high level steps your typical <a rel="nofollow noreferrer" href="https://www.advances.realtimerendering.com/s2015/aaltonenhaar_siggraph2015_combined_final_footer_220dpi.pdf">pre-2015 renderer</a> would perform to render some basic geometry. I've omitted some steps that aren't relevant to this post such as uploading mesh and texture data, shadow map rendering, lighting, and other shading details.</p> <p>First, on the CPU:</p> <ul> <li>Frustum culling of instances outside of the camera's view</li> <li>Choosing the appropriate level of detail (LOD) for each instance</li> <li>Sorting and batching instances into multiple draw lists</li> <li>Recording draw calls into command buffers for each draw list</li> </ul> <p>Then, on the GPU:</p> <ul> <li>Setting up GPU state according to the command buffers</li> <li>Transforming vertices and rasterizing triangles</li> <li>Depth testing triangle fragments</li> <li>Shading visible fragments</li> </ul> <p>Now lets try taking this renderer, and feeding it a dense cityscape made of 500 million triangles, and 150 thousand instances of different meshes.</p> <p>It's going to be slow. Why? Lets look at some of the problems:</p> <ul> <li>Frustum culling lets us skip preparing or drawing instances that are outside the camera's frustum, but what if you have an instance that's only partially visible? The GPU still needs to transform, clip, and process all vertices in the mesh. Or, what if the entire scene is in the camera's frustum?</li> <li>If one instance is in front of another, it's a complete waste to draw an instance to the screen that will later be completely drawn over by another (overdraw).</li> <li>Sorting, batching, and encoding the command buffers for all those instances are going to be slow. Each instance likely has a different vertex and index buffer, different set of textures to bind, different shader (pipeline) for vertex and fragment processing, etc.</li> <li>The GPU will spend time spinning down and spinning back up as it switches state between each draw call.</li> </ul> <p>Now, it's no longer 2015, there are a variety of techniques (some from before 2015, that I purposefully left out) to alleviate a lot of these issues. Deferred shading or a depth-only prepass means overdraw is less costly, bindless techniques and ubershaders reduce state switching, multi-draw can reduce draw count, etc.</p> <p>However, there are some more subtle issues that come up:</p> <ul> <li>Storing all that mesh data in memory takes too much VRAM. Modern mid-tier desktop GPUs tend to have 8-12 GB of VRAM, which means all your mesh data and 4k textures need to be able to fit in that amount of storage.</li> <li>LODs were one of the steps that were meant to help reduce the amount of geometry you were feeding a GPU. However, they come with some downsides: 1) The transition between LODs tends to be noticable, even with a crossfade effect, 2) Artists need to spend time producing and tweaking LODs from their initial high-poly meshes, and 3) Like frustum culling, they don't help with the worst case of simply being close to a lot of high-poly geometry, unless you're willing to cap out at a lower resolution than the artist's original mesh.</li> </ul> <p>There's also another issue I've saved for last. Despite all the culling and batching and LODs, we still have <em>too much</em> geometry to draw every frame. We need a better way to deal with it than simple LODs.</p> <h2 id="what-is-virtual-geometry">What is Virtual Geometry?<a class="zola-anchor" href="#what-is-virtual-geometry" aria-label="Anchor link for: what-is-virtual-geometry" style="visibility: hidden;"></a> </h2> <p>With the introduction of Unreal Engine 5 in 2021 came the introduction of a new technique called <a rel="nofollow noreferrer" href="https://dev.epicgames.com/documentation/en-us/unreal-engine/nanite-virtualized-geometry-in-unreal-engine">Nanite</a>. Nanite is a system where you can preprocess your non-deforming opaque meshes, and at runtime be able to very efficiently render them, largely solving the above problems with draw counts, memory limits, high-poly mesh rasterization, and the deficiencies of traditional LODs.</p> <p>Nanite works by first splitting your base mesh into a series of meshlets - small, independent clusters of triangles. Nanite then takes those clusters, groups clusters together, and simplifies the groups into a smaller set of <em>new</em> clusters. By repeating this process, you get a tree of clusters where the leaves of the tree form the base mesh, and the root of the tree forms a simplified approximation of the base mesh.</p> <p>Now at runtime, we don't just have to render one level (LOD) of the tree. We can choose specific clusters from different levels of the tree so that if you're close to one part of the mesh, it'll render many high resolution clusters. If you're far from a different part of the mesh, however, then that part will use a couple low resolution clusters that are cheaper to render. Unlike traditional LODs, which are all or nothing, part of the mesh can be low resolution, part of the mesh can be high resolution, and a third part can be somewhere in between - all at the time same time, all on a very granular level.</p> <p>Additionally, the transitions between LODs can be virtually imperceptible and extremely smooth, without extra rendering work. Traditional LODs typically have to hide transitions with crossfaded opacity between two levels.</p> <p>Combine this LOD technique with some per-cluster culling, a visibility buffer, streaming in and out of individual cluster data to prevent high memory usage, a custom rasterizer, and a whole bunch of others parts, and you end up with a renderer that <em>can</em> deal with a scene made of 500 million triangles.</p> <p>I mentioned before that meshes have to be opaque, and can't deform or animate (for the initial release of Nanite in Unreal Engine 5.0 this is true, but it's an area Unreal is working to improve). Nanite isn't perfect - there are still limitations. But the ceiling of what's feasible is a lot higher.</p> <h2 id="virtual-geometry-in-bevy">Virtual Geometry in Bevy<a class="zola-anchor" href="#virtual-geometry-in-bevy" aria-label="Anchor link for: virtual-geometry-in-bevy" style="visibility: hidden;"></a> </h2> <p>Now that the background is out of the way, lets talk about Bevy. For Bevy 0.14, I've written an initial implementation that largely copies the basic ideas of how Nanite works, without implementing every single optimization and technique. Currently, the feature is called meshlets (likely to change to virtual_geometry or something else in the future). In a minute, I'll get into the actual frame breakdown and code for meshlets, but first lets start with the user-facing API.</p> <p>Users wanting to use meshlets should compile with the <code>meshlet</code> cargo feature at runtime, and <code>meshlet_processor</code> cargo feature for preprocessing meshes (again, more on how that works later) into the special meshlet-specific format the meshlet renderer uses.</p> <p>Enabling the <code>meshlet</code> feature unlocks a new module: <code>bevy::pbr::experimental::meshlet</code>.</p> <p>First step, add <code>MeshletPlugin</code> to your app:</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span>app.</span><span style="color:#859900;">add_plugins</span><span style="color:#657b83;">(</span><span>MeshletPlugin</span><span style="color:#657b83;">)</span><span>; </span></code></pre> <p>Next, preprocess your <code>Mesh</code> into a <code>MeshletMesh</code>. Currently, this needs to be done manually via <code>MeshletMesh::from_mesh()</code> (again, you need the <code>meshlet_processor</code> feature enabled). This step is <em>very</em> slow, and should be done once ahead of time, and then saved to an asset file. Note that there are limitations on the types of meshes and materials supported, make sure to read the docs.</p> <p>I'm in the <a rel="nofollow noreferrer" href="https://github.com/bevyengine/bevy/pull/13431">middle of working on</a> an asset processor system to automatically convert entire glTF scenes, but it's not quite ready yet. For now, you'll have to come up with your own asset processing and management system.</p> <p>Now, spawn your entities. In the same vein as <code>MeshMaterialBundle</code>, there's a <code>MeshletMeshMaterialBundle</code>, which uses a <code>MeshletMesh</code> instead of the typical <code>Mesh</code>.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span>commands.</span><span style="color:#859900;">spawn</span><span style="color:#657b83;">(</span><span>MaterialMeshletMeshBundle </span><span style="color:#657b83;">{ </span><span> meshlet_mesh: meshlet_mesh_handle.</span><span style="color:#859900;">clone</span><span style="color:#657b83;">()</span><span>, </span><span> material: material_handle.</span><span style="color:#859900;">clone</span><span style="color:#657b83;">()</span><span>, </span><span> transform: Transform::default</span><span style="color:#657b83;">()</span><span>.</span><span style="color:#859900;">with_translation</span><span style="color:#657b83;">(</span><span>Vec3::new</span><span style="color:#657b83;">(</span><span>x </span><span style="color:#859900;">as </span><span style="color:#268bd2;">f32 </span><span style="color:#657b83;">/ </span><span style="color:#6c71c4;">2.0</span><span>, </span><span style="color:#6c71c4;">0.0</span><span>, </span><span style="color:#6c71c4;">0.3</span><span style="color:#657b83;">))</span><span>, </span><span> </span><span style="color:#859900;">..default</span><span style="color:#657b83;">() </span><span style="color:#657b83;">})</span><span>; </span></code></pre> <p>Lastly, a note on materials. Meshlet entities use the same <code>Material</code> trait as regular mesh entities. There are 3 new methods that meshlet entities use however: <code>meshlet_mesh_fragment_shader</code>, <code>meshlet_mesh_prepass_fragment_shader</code>, and <code>meshlet_mesh_deferred_fragment_shader</code>.</p> <p>Notice that there is no access to vertex shaders. Meshlet rendering uses a hardcoded vertex shader that cannot be changed.</p> <p>Fragment shaders for meshlets are mostly the same as fragment shaders for regular mesh entities. The key difference is that instead of this:</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#859900;">@</span><span>fragment </span><span style="color:#268bd2;">fn </span><span style="color:#b58900;">fragment</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">vertex_output</span><span>: VertexOutput</span><span style="color:#657b83;">) </span><span>-&gt; </span><span style="color:#859900;">@location</span><span style="color:#657b83;">(</span><span style="color:#6c71c4;">0</span><span style="color:#657b83;">) </span><span>vec4&lt;</span><span style="color:#268bd2;">f32</span><span>&gt; </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#586e75;">// ... </span><span style="color:#657b83;">} </span></code></pre> <p>You should use this:</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#859900;">#</span><span>import bevy_pbr::meshlet_visibility_buffer_resolve::resolve_vertex_output </span><span> </span><span style="color:#859900;">@</span><span>fragment </span><span style="color:#268bd2;">fn </span><span style="color:#b58900;">fragment</span><span style="color:#657b83;">(</span><span>@builtin</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">position</span><span style="color:#657b83;">) </span><span style="color:#268bd2;">frag_coord</span><span>: vec4&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;</span><span style="color:#657b83;">) </span><span>-&gt; </span><span style="color:#859900;">@location</span><span style="color:#657b83;">(</span><span style="color:#6c71c4;">0</span><span style="color:#657b83;">) </span><span>vec4&lt;</span><span style="color:#268bd2;">f32</span><span>&gt; </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#268bd2;">let</span><span> vertex_output </span><span style="color:#657b83;">= </span><span style="color:#859900;">resolve_vertex_output</span><span style="color:#657b83;">(</span><span>frag_coord</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#586e75;">// ... </span><span style="color:#657b83;">} </span></code></pre> <h1 id="mesh-conversion">Mesh Conversion<a class="zola-anchor" href="#mesh-conversion" aria-label="Anchor link for: mesh-conversion" style="visibility: hidden;"></a> </h1> <p>We're now going to start the portion of this blog post going into how everything is implemented.</p> <p>The first step, before we can render anything, is to convert all meshes to meshlet meshes. I talked about the user-facing API earlier on, but in this section we'll dive into what <code>MeshletMesh::from_mesh()</code> is doing under the hood in <code>from_mesh.rs</code>.</p> <p>This section will be a bit dry, lacking commentary on why I did things, in favor of just describing the algorithm itself. The reason is that I don't have many unique insights into the conversion process. The steps taken are pretty much just copied from Nanite (except Nanite does it better). If you're interested in understanding this section in greater detail, definitely check out the original Nanite presentation.</p> <p>Feel free to skip ahead to the frame breakdown section if you are more interested in the runtime portion of the renderer.</p> <p>The high level steps for converting a mesh are as follows:</p> <ol> <li>Build LOD 0 meshlets</li> <li>For each meshlet, find the set of all edges making up the triangles within the meshlet</li> <li>For each meshlet, find the set of connected meshlets (sharing an edge)</li> <li>Divide meshlets into groups of roughly 4</li> <li>For each group of meshlets, build a new list of triangles approximating the original group</li> <li>For each simplified group, break them apart into new meshlets</li> <li>Repeat steps 3-7 using the set of new meshlets, until we run out of meshlets to simplify</li> </ol> <p><img src="https://jms55.github.io/posts/2024-06-09-virtual-geometry-bevy-0-14/build_steps.png" alt="Nanite LOD build steps" /></p> <h2 id="build-lod-0-meshlets">Build LOD 0 Meshlets<a class="zola-anchor" href="#build-lod-0-meshlets" aria-label="Anchor link for: build-lod-0-meshlets" style="visibility: hidden;"></a> </h2> <p>We're starting with a generic triangle mesh, so the first step is to group its triangles into an initial set of meshlets. No simplification or modification of the mesh is involved - we're simply splitting up the original mesh into a set meshlets that would render exactly the same.</p> <p>The crate <code>meshopt-rs</code> provides Rust bindings to the excellent <code>meshoptimizer</code> library, which provides a nice <code>build_meshlets()</code> function for us that I've wrapped into <code>compute_meshlets()</code>.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#586e75;">// Split the mesh into an initial list of meshlets (LOD 0) </span><span style="color:#268bd2;">let</span><span> vertex_buffer </span><span style="color:#657b83;">=</span><span> mesh.</span><span style="color:#859900;">get_vertex_buffer_data</span><span style="color:#657b83;">()</span><span>; </span><span style="color:#268bd2;">let</span><span> vertex_stride </span><span style="color:#657b83;">=</span><span> mesh.</span><span style="color:#859900;">get_vertex_size</span><span style="color:#657b83;">() </span><span style="color:#859900;">as </span><span style="color:#268bd2;">usize</span><span>; </span><span style="color:#268bd2;">let</span><span> vertices </span><span style="color:#657b83;">= </span><span>VertexDataAdapter::new</span><span style="color:#657b83;">(</span><span style="color:#859900;">&amp;</span><span>vertex_buffer, vertex_stride, </span><span style="color:#6c71c4;">0</span><span style="color:#657b83;">)</span><span>.</span><span style="color:#859900;">unwrap</span><span style="color:#657b83;">()</span><span>; </span><span style="color:#268bd2;">let </span><span style="color:#93a1a1;">mut</span><span> meshlets </span><span style="color:#657b83;">= </span><span style="color:#859900;">compute_meshlets</span><span style="color:#657b83;">(</span><span style="color:#859900;">&amp;</span><span>indices, </span><span style="color:#859900;">&amp;</span><span>vertices</span><span style="color:#657b83;">)</span><span>; </span></code></pre> <p>We also need some bounding spheres for each meshlet. The culling bounding sphere is straightforward - <code>compute_meshlet_bounds()</code>, again from <code>meshopt-rs</code>, will give us a bounding sphere encompassing the meshlet that we can use for frustum and occlusion culling later on.</p> <p>The <code>self_lod</code> and <code>parent_lod</code> bounding spheres need a lot more explanation.</p> <p>As we simplify each group of meshlets into new meshlets, we will deform the mesh slightly. That deformity adds up over time, eventually giving a very visibly different mesh from the original. However, when viewing the very simplified mesh from far away, due to perspective the difference will be much less noticable. While we would want to view the original (or close to the original) mesh close-up, at longer distances we can get away with rendering a much simpler version of the mesh without noticeable differences.</p> <p>So, how to choose the right LOD level, or in our case, the right LOD tree cut? The LOD cut will be based on the simplification error of each meshlet along the cut, with the goal being to select a cut that is imperceptibly different from the original mesh at the distance we're viewing the mesh at.</p> <p>For reasons I'll get into later during the runtime section, we're going to treat the error as a bounding sphere around the meshlet, with the radius being the error. We're also going to want two of these: one for the current meshlet itself, and one for the less-simplified group of meshlets that we simplified into the current meshlet (the current meshlet's parents in the LOD tree).</p> <p>LOD 0 meshlets, being the original representation of the mesh, have no error (0.0). They also have no set of parent meshlets, which we will represent with an infinite amount of error (f32::MAX), again for reasons I will get into later.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#268bd2;">let </span><span style="color:#93a1a1;">mut</span><span> bounding_spheres </span><span style="color:#657b83;">=</span><span> meshlets </span><span> .</span><span style="color:#859900;">iter</span><span style="color:#657b83;">() </span><span> .</span><span style="color:#859900;">map</span><span style="color:#657b83;">(|</span><span style="color:#268bd2;">meshlet</span><span style="color:#657b83;">| </span><span style="color:#859900;">compute_meshlet_bounds</span><span style="color:#657b83;">(</span><span>meshlet, </span><span style="color:#859900;">&amp;</span><span>vertices</span><span style="color:#657b83;">)) </span><span> .</span><span style="color:#859900;">map</span><span style="color:#657b83;">(</span><span>convert_meshlet_bounds</span><span style="color:#657b83;">) </span><span> .</span><span style="color:#859900;">map</span><span style="color:#657b83;">(|</span><span style="color:#268bd2;">bounding_sphere</span><span style="color:#657b83;">| </span><span>MeshletBoundingSpheres </span><span style="color:#657b83;">{ </span><span> self_culling: bounding_sphere, </span><span> self_lod: MeshletBoundingSphere </span><span style="color:#657b83;">{ </span><span> center: bounding_sphere.center, </span><span> radius: </span><span style="color:#6c71c4;">0.0</span><span>, </span><span> </span><span style="color:#657b83;">}</span><span>, </span><span> parent_lod: MeshletBoundingSphere </span><span style="color:#657b83;">{ </span><span> center: bounding_sphere.center, </span><span> radius: </span><span style="color:#268bd2;">f32</span><span>::</span><span style="color:#cb4b16;">MAX</span><span>, </span><span> </span><span style="color:#657b83;">}</span><span>, </span><span> </span><span style="color:#657b83;">}) </span><span> .collect::&lt;</span><span style="color:#859900;">Vec</span><span>&lt;</span><span style="color:#859900;">_</span><span>&gt;&gt;</span><span style="color:#657b83;">()</span><span>; </span></code></pre> <h2 id="find-meshlet-edges">Find Meshlet Edges<a class="zola-anchor" href="#find-meshlet-edges" aria-label="Anchor link for: find-meshlet-edges" style="visibility: hidden;"></a> </h2> <p>Now that we have our initial set of meshlets, we can start simplifying.</p> <p>The first step is to find the set of triangle edges that make up each meshlet. This can be done with a simple loop over triangles, building a hashset of edges where each edge is ordered such that the smaller numbered vertex comes before the larger number vertex. This ensures that we don't accidentally add both (v1, v2) and (v2, v1), which conceptually are the same edge. Each triangle has 3 vertices and 3 edges.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#268bd2;">let </span><span style="color:#93a1a1;">mut</span><span> meshlet_triangle_edges </span><span style="color:#657b83;">= </span><span>HashMap::new</span><span style="color:#657b83;">()</span><span>; </span><span style="color:#859900;">for</span><span> i </span><span style="color:#859900;">in</span><span> meshlet.triangles.</span><span style="color:#859900;">chunks</span><span style="color:#657b83;">(</span><span style="color:#6c71c4;">3</span><span style="color:#657b83;">) { </span><span> </span><span style="color:#268bd2;">let</span><span> v0 </span><span style="color:#657b83;">=</span><span> meshlet.vertices</span><span style="color:#657b83;">[</span><span>i</span><span style="color:#657b83;">[</span><span style="color:#6c71c4;">0</span><span style="color:#657b83;">] </span><span style="color:#859900;">as </span><span style="color:#268bd2;">usize</span><span style="color:#657b83;">]</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> v1 </span><span style="color:#657b83;">=</span><span> meshlet.vertices</span><span style="color:#657b83;">[</span><span>i</span><span style="color:#657b83;">[</span><span style="color:#6c71c4;">1</span><span style="color:#657b83;">] </span><span style="color:#859900;">as </span><span style="color:#268bd2;">usize</span><span style="color:#657b83;">]</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> v2 </span><span style="color:#657b83;">=</span><span> meshlet.vertices</span><span style="color:#657b83;">[</span><span>i</span><span style="color:#657b83;">[</span><span style="color:#6c71c4;">2</span><span style="color:#657b83;">] </span><span style="color:#859900;">as </span><span style="color:#268bd2;">usize</span><span style="color:#657b83;">]</span><span>; </span><span> </span><span> meshlet_triangle_edges.</span><span style="color:#859900;">insert</span><span style="color:#657b83;">((</span><span>v0.</span><span style="color:#859900;">min</span><span style="color:#657b83;">(</span><span>v1</span><span style="color:#657b83;">)</span><span>, v0.</span><span style="color:#859900;">max</span><span style="color:#657b83;">(</span><span>v1</span><span style="color:#657b83;">)))</span><span>; </span><span> meshlet_triangle_edges.</span><span style="color:#859900;">insert</span><span style="color:#657b83;">((</span><span>v0.</span><span style="color:#859900;">min</span><span style="color:#657b83;">(</span><span>v2</span><span style="color:#657b83;">)</span><span>, v0.</span><span style="color:#859900;">max</span><span style="color:#657b83;">(</span><span>v2</span><span style="color:#657b83;">)))</span><span>; </span><span> meshlet_triangle_edges.</span><span style="color:#859900;">insert</span><span style="color:#657b83;">((</span><span>v1.</span><span style="color:#859900;">min</span><span style="color:#657b83;">(</span><span>v2</span><span style="color:#657b83;">)</span><span>, v1.</span><span style="color:#859900;">max</span><span style="color:#657b83;">(</span><span>v2</span><span style="color:#657b83;">)))</span><span>; </span><span style="color:#657b83;">} </span></code></pre> <h2 id="find-connected-meshlets">Find Connected Meshlets<a class="zola-anchor" href="#find-connected-meshlets" aria-label="Anchor link for: find-connected-meshlets" style="visibility: hidden;"></a> </h2> <p>Next, we need to find the meshlets that connect to each other.</p> <p>A meshlet will be considered as connected to another meshlet if both meshlets share at least one edge.</p> <p>In the previous step, we built a set of edges for each meshlet. Finding if two meshlets share any edges can be done by simply taking the intersection of their two edge sets, and checking if the resulting set is not empty.</p> <p>We will also store the <em>amount</em> of shared edges between two meshlets, giving a heuristic for how "connected" each meshlet is to another. This is simply the size of the intersection set.</p> <p>Overally, we will build a list per meshlet, containing tuples of (meshlet_id, shared_edge_count) for each meshlet connected to the current meshlet.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#859900;">for </span><span style="color:#657b83;">(</span><span>meshlet_id1, meshlet_id2</span><span style="color:#657b83;">) </span><span style="color:#859900;">in</span><span> simplification_queue.</span><span style="color:#859900;">tuple_combinations</span><span style="color:#657b83;">() { </span><span> </span><span style="color:#268bd2;">let</span><span> shared_edge_count </span><span style="color:#657b83;">=</span><span> triangle_edges_per_meshlet</span><span style="color:#657b83;">[</span><span style="color:#859900;">&amp;</span><span>meshlet_id1</span><span style="color:#657b83;">] </span><span> .</span><span style="color:#859900;">intersection</span><span style="color:#657b83;">(</span><span style="color:#859900;">&amp;</span><span>triangle_edges_per_meshlet</span><span style="color:#657b83;">[</span><span style="color:#859900;">&amp;</span><span>meshlet_id2</span><span style="color:#657b83;">]) </span><span> .</span><span style="color:#859900;">count</span><span style="color:#657b83;">()</span><span>; </span><span> </span><span> </span><span style="color:#859900;">if</span><span> shared_edge_count </span><span style="color:#657b83;">!= </span><span style="color:#6c71c4;">0 </span><span style="color:#657b83;">{ </span><span> connected_meshlets_per_meshlet </span><span> .</span><span style="color:#859900;">get_mut</span><span style="color:#657b83;">(</span><span style="color:#859900;">&amp;</span><span>meshlet_id1</span><span style="color:#657b83;">) </span><span> .</span><span style="color:#859900;">unwrap</span><span style="color:#657b83;">() </span><span> .</span><span style="color:#859900;">push</span><span style="color:#657b83;">((</span><span>meshlet_id2, shared_edge_count</span><span style="color:#657b83;">))</span><span>; </span><span> connected_meshlets_per_meshlet </span><span> .</span><span style="color:#859900;">get_mut</span><span style="color:#657b83;">(</span><span style="color:#859900;">&amp;</span><span>meshlet_id2</span><span style="color:#657b83;">) </span><span> .</span><span style="color:#859900;">unwrap</span><span style="color:#657b83;">() </span><span> .</span><span style="color:#859900;">push</span><span style="color:#657b83;">((</span><span>meshlet_id1, shared_edge_count</span><span style="color:#657b83;">))</span><span>; </span><span> </span><span style="color:#657b83;">} </span><span style="color:#657b83;">} </span></code></pre> <h2 id="partition-meshlets-into-groups">Partition Meshlets Into Groups<a class="zola-anchor" href="#partition-meshlets-into-groups" aria-label="Anchor link for: partition-meshlets-into-groups" style="visibility: hidden;"></a> </h2> <p>Now that we know which meshlets are connected, the next step is to group them together. We're going to aim for 4 meshlets per group, although there's no way of guaranteeing that.</p> <p>How should we determine which meshlets go in which group?</p> <p>You can view the connected meshlet sets as a graph. Each meshlet is a node, and bidirectional edges connect one meshlet to another in the graph if we determined that they were connected earlier. The weight of each edge is the amount of shared edges between the two meshlet nodes.</p> <p>Partitioning the meshlets into groups is now a matter of partitioning the graph. I use the <code>metis-rs</code> crate which provides Rust bindings to the <code>METIS</code> library. The edge weights will be used so that meshlets with a high shared edge count are more likely to be group together.</p> <p>The code to format this data for metis is a bit complicated, but in the end we have a list of groups, where each group is a list of meshlets.</p> <h2 id="simplify-groups">Simplify Groups<a class="zola-anchor" href="#simplify-groups" aria-label="Anchor link for: simplify-groups" style="visibility: hidden;"></a> </h2> <p>Now for an important step, and the most tricky.</p> <p>We take each group, and merge the triangle lists of the underlying meshlets together into one large list of triangles, forming a new mesh.</p> <p>Now, we can simplify this new mesh into a lower-resolution (faster to render) version. Meshopt again provides a helpful <code>simplify()</code> function for us. Finally, less triangles to render!</p> <p>In addition to the new mesh, we get an "error" value, describing how much the mesh deformed by when simplifying.</p> <p>The quadratic error metric (QEM) returned from simplifying is a somewhat meaningless value, but we can use <code>simplify_scale()</code> to get an object-space value. This value is <em>still</em> fairly meaningless, but we can treat it as the maximum amount of object-space distance a vertex was displaced by during simplification.</p> <p>The error represents displacement from the meshlets we simplified, but we want the displacement from the original (LOD 0) meshlets. We can add the max error of the meshlets that went into building the current meshlet group (child nodes of the parent node that we're currently building in the LOD tree) to make the error relative to LOD 0.</p> <p>If this all feels handwavy to you, that's because it is. And this is vertex positions only; we haven't even considered UV error during simplification, or how the mesh's eventual material influences perceptual differences between LOD levels. Perceptual simplification is very much an unsolved problem in computer graphics, and for now Bevy only uses positions for simplification.</p> <p>You'll have to take my word for it that using the error like this works. You'll see how it gets used to pick the LOD level during runtime in a later section. For now, we'll take the group error and build a bounding sphere out of it, and assign it as the parent LOD bounding sphere for the group's (parent node, higher LOD) underlying meshlets (child nodes, lower LOD).</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#586e75;">// Simplify the group to ~50% triangle count </span><span style="color:#268bd2;">let </span><span style="color:#859900;">Some</span><span style="color:#657b83;">((</span><span>simplified_group_indices, </span><span style="color:#93a1a1;">mut</span><span> group_error</span><span style="color:#657b83;">)) = </span><span> </span><span style="color:#859900;">simplify_meshlet_groups</span><span style="color:#657b83;">(</span><span>group_meshlets, </span><span style="color:#859900;">&amp;</span><span>meshlets, </span><span style="color:#859900;">&amp;</span><span>vertices, lod_level</span><span style="color:#657b83;">) </span><span style="color:#859900;">else </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#859900;">continue</span><span>; </span><span style="color:#657b83;">}</span><span>; </span><span> </span><span style="color:#586e75;">// Add the maximum child error to the parent error to make parent error cumulative from LOD 0 </span><span style="color:#586e75;">// (we&#39;re currently building the parent from its children) </span><span>group_error </span><span style="color:#657b83;">+=</span><span> group_meshlets.</span><span style="color:#859900;">iter</span><span style="color:#657b83;">()</span><span>.</span><span style="color:#859900;">fold</span><span style="color:#657b83;">(</span><span>group_error, </span><span style="color:#657b83;">|</span><span style="color:#268bd2;">acc</span><span>, </span><span style="color:#268bd2;">meshlet_id</span><span style="color:#657b83;">| { </span><span> acc.</span><span style="color:#859900;">max</span><span style="color:#657b83;">(</span><span>bounding_spheres</span><span style="color:#657b83;">[*</span><span>meshlet_id</span><span style="color:#657b83;">]</span><span>.self_lod.radius</span><span style="color:#657b83;">) </span><span style="color:#657b83;">})</span><span>; </span><span> </span><span style="color:#586e75;">// Build a new LOD bounding sphere for the simplified group as a whole </span><span style="color:#268bd2;">let </span><span style="color:#93a1a1;">mut</span><span> group_bounding_sphere </span><span style="color:#657b83;">= </span><span style="color:#859900;">convert_meshlet_bounds</span><span style="color:#657b83;">(</span><span style="color:#859900;">compute_cluster_bounds</span><span style="color:#657b83;">( </span><span> </span><span style="color:#859900;">&amp;</span><span>simplified_group_indices, </span><span> </span><span style="color:#859900;">&amp;</span><span>vertices, </span><span style="color:#657b83;">))</span><span>; </span><span>group_bounding_sphere.radius </span><span style="color:#657b83;">=</span><span> group_error; </span><span> </span><span style="color:#586e75;">// For each meshlet in the group set their parent LOD bounding sphere to that of the simplified group </span><span style="color:#859900;">for</span><span> meshlet_id </span><span style="color:#859900;">in</span><span> group_meshlets </span><span style="color:#657b83;">{ </span><span> bounding_spheres</span><span style="color:#657b83;">[*</span><span>meshlet_id</span><span style="color:#657b83;">]</span><span>.parent_lod </span><span style="color:#657b83;">=</span><span> group_bounding_sphere; </span><span style="color:#657b83;">} </span></code></pre> <h2 id="split-groups">Split Groups<a class="zola-anchor" href="#split-groups" aria-label="Anchor link for: split-groups" style="visibility: hidden;"></a> </h2> <p>Finally, the last step is to take the large mesh formed from simplifying the entire meshlet group, and split it into a set of brand new meshlets.</p> <p>This is in fact the same process as splitting the original mesh into meshlets.</p> <p>If everything went optimally, we should have gone from the original 4 meshlets per group, to 2 new meshlets per group with 50% less triangles overall.</p> <p>For each new meshlet, we'll calculate a bounding sphere for culling, assign the self_lod bounding sphere as that of the group, and the parent_lod bounding sphere again as uninitialized.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#586e75;">// Build new meshlets using the simplified group </span><span style="color:#268bd2;">let</span><span> new_meshlets_count </span><span style="color:#657b83;">= </span><span style="color:#859900;">split_simplified_groups_into_new_meshlets</span><span style="color:#657b83;">( </span><span> </span><span style="color:#859900;">&amp;</span><span>simplified_group_indices, </span><span> </span><span style="color:#859900;">&amp;</span><span>vertices, </span><span> </span><span style="color:#859900;">&amp;</span><span style="color:#93a1a1;">mut</span><span> meshlets, </span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#586e75;">// Calculate the culling bounding sphere for the new meshlets and set their LOD bounding spheres </span><span style="color:#268bd2;">let</span><span> new_meshlet_ids </span><span style="color:#657b83;">= (</span><span>meshlets.</span><span style="color:#859900;">len</span><span style="color:#657b83;">() -</span><span> new_meshlets_count</span><span style="color:#657b83;">)</span><span style="color:#859900;">..</span><span>meshlets.</span><span style="color:#859900;">len</span><span style="color:#657b83;">()</span><span>; </span><span>bounding_spheres.</span><span style="color:#859900;">extend</span><span style="color:#657b83;">( </span><span> new_meshlet_ids </span><span> .</span><span style="color:#859900;">map</span><span style="color:#657b83;">(|</span><span style="color:#268bd2;">meshlet_id</span><span style="color:#657b83;">| { </span><span> </span><span style="color:#859900;">compute_meshlet_bounds</span><span style="color:#657b83;">(</span><span>meshlets.</span><span style="color:#859900;">get</span><span style="color:#657b83;">(</span><span>meshlet_id</span><span style="color:#657b83;">)</span><span>, </span><span style="color:#859900;">&amp;</span><span>vertices</span><span style="color:#657b83;">) </span><span> </span><span style="color:#657b83;">}) </span><span> .</span><span style="color:#859900;">map</span><span style="color:#657b83;">(</span><span>convert_meshlet_bounds</span><span style="color:#657b83;">) </span><span> .</span><span style="color:#859900;">map</span><span style="color:#657b83;">(|</span><span style="color:#268bd2;">bounding_sphere</span><span style="color:#657b83;">| </span><span>MeshletBoundingSpheres </span><span style="color:#657b83;">{ </span><span> self_culling: bounding_sphere, </span><span> self_lod: group_bounding_sphere, </span><span> parent_lod: MeshletBoundingSphere </span><span style="color:#657b83;">{ </span><span> center: group_bounding_sphere.center, </span><span> radius: </span><span style="color:#268bd2;">f32</span><span>::</span><span style="color:#cb4b16;">MAX</span><span>, </span><span> </span><span style="color:#657b83;">}</span><span>, </span><span> </span><span style="color:#657b83;">})</span><span>, </span><span style="color:#657b83;">)</span><span>; </span></code></pre> <p>We can repeat this whole process several times, ideally getting down to a single meshlet forming the root of the LOD tree. In practice, my current code can't get to that point for most meshes.</p> <h1 id="frame-breakdown">Frame Breakdown<a class="zola-anchor" href="#frame-breakdown" aria-label="Anchor link for: frame-breakdown" style="visibility: hidden;"></a> </h1> <p>With the asset processing part out of the way, we can finally move onto the more interesting runtime code section.</p> <p>The frame capture we'll be looking at is this scene with 3092 copies of the Stanford Bunny. Five of the bunnies are using unique PBR materials (they're hiding in the top middle), while the rest use the same debug material that visualizes the clusters/triangles of the mesh. Each bunny is made of 144,042 triangles at LOD 0, with 4936 meshlets total in the LOD tree.</p> <p>GPU timings were measured on a RTX 3080 locked to base clock speeds (so not as fast as you would actually get in practice), rendering at 2240x1260, averaged over 10 frames.</p> <blockquote> <p>Clusters visualization <img src="https://jms55.github.io/posts/2024-06-09-virtual-geometry-bevy-0-14/clusters.png" alt="Clusters visualization" /> Triangles visualization <img src="https://jms55.github.io/posts/2024-06-09-virtual-geometry-bevy-0-14/triangles.png" alt="Triangles visualization" /></p> </blockquote> <blockquote> <p>NSight profile <img src="https://jms55.github.io/posts/2024-06-09-virtual-geometry-bevy-0-14/nsight.png" alt="NSight profile" /></p> </blockquote> <p>The frame can be broken down into the following passes:</p> <ol> <li>Fill cluster buffers (0.22ms)</li> <li>Cluster culling first pass (0.49ms)</li> <li>Raster visbuffer first pass (1.85ms +/- 0.33ms)</li> <li>Build depth pyramid for second pass (0.03ms)</li> <li>Cluster culling second pass (0.11ms)</li> <li>Raster visbuffer second pass (&lt; 0.01ms)</li> <li>Copy material depth (0.04ms)</li> <li>Material shading (timings omitted as this is a poor test for materials)</li> <li>Build depth pyramid for next frame (0.03ms)</li> </ol> <p>Total GPU time is ~2.78ms +/- 0.33ms.</p> <p>There's a lot to cover, so I'm going to try and keep it fairly brief in each section. The high level concepts of all of these passes (besides the first pass) are copied from Nanite, so check out their presentation for further details. I'll be trying to focus more on the lower level code and reasons why I implemented things the way I did. My first attempt at a lot of these passes had bugs, and was way slower. The details and data flow is what takes the concept from a neat tech demo, to an actually usable and scalable renderer.</p> <h2 id="terminology">Terminology<a class="zola-anchor" href="#terminology" aria-label="Anchor link for: terminology" style="visibility: hidden;"></a> </h2> <p>First, some terminology:</p> <ul> <li><code>asset buffers</code> - When a new MeshletMesh asset is loaded, we copy the buffers it's made of into large suballocated buffers. All the vertex data, meshlet data, bounding spheres, etc for multiple MeshletMesh assets are packed together into one large buffer per data type.</li> <li><code>instance</code> - A single Bevy entity with a MeshletMesh and Material.</li> <li><code>instance uniform</code> - A transform matrix and mesh flags for an instance.</li> <li><code>material</code> - A combination of pipeline and bind group used for shading fragments.</li> <li><code>meshlet</code> - A single meshlet from within a MeshletMesh asset, pointing to data within the asset buffers (more or less).</li> <li><code>cluster</code> - A single renderable piece of an entity. Each cluster is associated with an instance and a meshlet. <ul> <li>All of our shaders will operate on clusters, and <em>not</em> on meshlets. You can think of these like an instance of a meshlet for a specific entity, in the same way you can have an instance of a class in object-oriented programming languages.</li> <li>Up to this point I've been using meshlet and cluster interchangeably. From now on, they have seperate, defined meanings.</li> </ul> </li> <li><code>view</code> - A perspective or orthographic camera with an associated depth buffer and optional color output. The main camera is a view, and additional views can be dynamically generated for e.g. rendering shadowmaps.</li> <li><code>id</code> - A u32 index into a buffer.</li> </ul> <h2 id="fill-cluster-buffers">Fill Cluster Buffers<a class="zola-anchor" href="#fill-cluster-buffers" aria-label="Anchor link for: fill-cluster-buffers" style="visibility: hidden;"></a> </h2> <p>Now the first pass we're going to look at might be surprising.</p> <p>Over the course of the frame, for each cluster we will need its instance (giving us a transform and material), along with its meshlet (giving us vertex data and bounding spheres).</p> <p>While the cluster itself is implicit (each thread or workgroup of a shader will handle one cluster, with the global thread/workgroup ID being the cluster ID), we need some method of telling the GPU what the instance and meshlet for each cluster is.</p> <p>I.e., we need an array of instance IDs and meshlet IDs such that we can do <code>let cluster_instance = instances[cluster_instance_ids[cluster_id]]</code> and <code>let cluster_meshlet = meshlets[cluster_meshlet_ids[cluster_id]]</code>.</p> <p>The naive method would be to simply write out these two buffers from the CPU and transfer them to the GPU. This was how I implemented it initially, and it worked fine for my simple initial test scene with a single bunny, but I very quickly ran into performance problems when trying to scale up to rendering 3000 bunnies.</p> <p>Each ID is a 4-byte u32, and it's two IDs per cluster. That's 8 bytes per cluster.</p> <p>With 3092 bunnies in the scene, and 4936 meshlets per bunny, that's 8 * 3092 * 4936 bytes total = ~122.10 MBs total.</p> <p>For dedicated GPUs, uploading data from the system's RAM to the GPU's VRAM is done over PCIe. PCIe x16 Gen3 max bandwidth is 16 GB/s.</p> <p>Ignoring data copying costs and other overhead, and assuming max PCIe bandwidth, that would mean it would take ~7.63ms to upload cluster data. That's 7.63 / 16.6 = ~46% of our frame budget gone at 60fps, before we've even rendered anything! Obviously, we need a better method.</p> <hr /> <p>Instead of uploading per-cluster data, we're going to stick to uploading only per-instance data. Specifically, two buffers called <code>instance_meshlet_counts_prefix_sum</code> and <code>instance_meshlet_slice_starts</code>. Each buffer will be an array of integers, with an entry per instance.</p> <p>The former will contain a prefix sum (calculated on the CPU while writing out the buffer) of how many meshlets each instance is made of. The latter will contain the index of where in the meshlet asset buffer each instance's list of meshlets begin.</p> <p>Now we're uploading only 8 bytes per <em>instance</em>, and not per <em>cluster</em>, which is much, much cheaper. Looking back at our scene, we're uploading 3092 * 8 bytes total = ~0.025 MBs total. This is a <em>huge</em> improvement over the ~122.10 MBs from before.</p> <p>Once the GPU has this data, we can have the GPU write out the <code>cluster_instance_ids</code> and <code>cluster_meshlet_ids</code> buffers from a compute shader. Max VRAM bandwidth on my RTX 3080 is a whopping 760.3 GB/s; ~47.5x faster than the 16 GB/s of bandwidth we had over PCIe.</p> <p>Each thread of the compute shader will handle one cluster, and do a binary search over the prefix sum array to find to what instance it belongs to.</p> <p>Binary search might seem surprising - it's multiple dependent divergent memory accesses within a thread, and one of the biggest performance metrics for GPU code is cache efficiency. However, it's very coherent <em>across</em> threads within the subgroup, and scales extremely well (O log n) with the number of instances in the scene. In practice, while it could be improved, the performance of this pass has not been a bottleneck.</p> <p>Now that we know what instance the cluster belongs to, it's trivial to calculate the meshlet index of the cluster within the instance's meshlet mesh asset. Adding that to the instance's meshlet_slice_start using the other buffer we uploaded gives us the global meshlet index within the overall meshlet asset buffer. The thread can then write out the two calculated IDs for the cluster.</p> <p>This is the only pass that runs once per-frame. The rest of the passes all run once per-view.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#586e75;">/// Writes out instance_id and meshlet_id to the global buffers for each cluster in the scene. </span><span> </span><span style="color:#859900;">@</span><span>compute </span><span style="color:#859900;">@workgroup_size</span><span style="color:#657b83;">(</span><span style="color:#6c71c4;">128</span><span>, </span><span style="color:#6c71c4;">1</span><span>, </span><span style="color:#6c71c4;">1</span><span style="color:#657b83;">) </span><span style="color:#586e75;">// 128 threads per workgroup, 1 cluster per thread </span><span style="color:#268bd2;">fn </span><span style="color:#b58900;">fill_cluster_buffers</span><span style="color:#657b83;">( </span><span> @builtin</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">workgroup_id</span><span style="color:#657b83;">) </span><span style="color:#268bd2;">workgroup_id</span><span>: vec3&lt;</span><span style="color:#268bd2;">u32</span><span>&gt;, </span><span> @builtin</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">num_workgroups</span><span style="color:#657b83;">) </span><span style="color:#268bd2;">num_workgroups</span><span>: vec3&lt;</span><span style="color:#268bd2;">u32</span><span>&gt;, </span><span> @builtin</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">local_invocation_id</span><span style="color:#657b83;">) </span><span style="color:#268bd2;">local_invocation_id</span><span>: vec3&lt;</span><span style="color:#268bd2;">u32</span><span>&gt; </span><span style="color:#657b83;">) { </span><span> </span><span style="color:#586e75;">// Calculate the cluster ID for this thread </span><span> </span><span style="color:#268bd2;">let</span><span> cluster_id </span><span style="color:#657b83;">=</span><span> local_invocation_id.x </span><span style="color:#657b83;">+</span><span> 128u </span><span style="color:#657b83;">* </span><span style="color:#859900;">dot</span><span style="color:#657b83;">(</span><span>workgroup_id, </span><span style="color:#859900;">vec3</span><span style="color:#657b83;">(</span><span>num_workgroups.x </span><span style="color:#657b83;">*</span><span> num_workgroups.x, num_workgroups.x, 1u</span><span style="color:#657b83;">))</span><span>; </span><span> </span><span style="color:#859900;">if</span><span> cluster_id </span><span style="color:#657b83;">&gt;=</span><span> cluster_count </span><span style="color:#657b83;">{ </span><span style="color:#859900;">return</span><span>; </span><span style="color:#657b83;">} </span><span> </span><span> </span><span style="color:#586e75;">// Binary search to find the instance this cluster belongs to </span><span> var left </span><span style="color:#657b83;">=</span><span> 0u; </span><span> var right </span><span style="color:#657b83;">=</span><span> arrayLength</span><span style="color:#657b83;">(</span><span style="color:#859900;">&amp;</span><span>meshlet_instance_meshlet_counts_prefix_sum</span><span style="color:#657b83;">) -</span><span> 1u; </span><span> </span><span style="color:#859900;">while</span><span> left </span><span style="color:#657b83;">&lt;=</span><span> right </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#268bd2;">let</span><span> mid </span><span style="color:#657b83;">= (</span><span>left </span><span style="color:#657b83;">+</span><span> right</span><span style="color:#657b83;">) /</span><span> 2u; </span><span> </span><span style="color:#859900;">if</span><span> meshlet_instance_meshlet_counts_prefix_sum</span><span style="color:#657b83;">[</span><span>mid</span><span style="color:#657b83;">] &lt;=</span><span> cluster_id </span><span style="color:#657b83;">{ </span><span> left </span><span style="color:#657b83;">=</span><span> mid </span><span style="color:#657b83;">+</span><span> 1u; </span><span> </span><span style="color:#657b83;">} </span><span style="color:#859900;">else </span><span style="color:#657b83;">{ </span><span> right </span><span style="color:#657b83;">=</span><span> mid </span><span style="color:#657b83;">-</span><span> 1u; </span><span> </span><span style="color:#657b83;">} </span><span> </span><span style="color:#657b83;">} </span><span> </span><span style="color:#268bd2;">let</span><span> instance_id </span><span style="color:#657b83;">=</span><span> right; </span><span> </span><span> </span><span style="color:#586e75;">// Find the meshlet ID for this cluster within the instance&#39;s MeshletMesh </span><span> </span><span style="color:#268bd2;">let</span><span> meshlet_id_local </span><span style="color:#657b83;">=</span><span> cluster_id </span><span style="color:#657b83;">-</span><span> meshlet_instance_meshlet_counts_prefix_sum</span><span style="color:#657b83;">[</span><span>instance_id</span><span style="color:#657b83;">]</span><span>; </span><span> </span><span> </span><span style="color:#586e75;">// Find the overall meshlet ID in the global meshlet buffer </span><span> </span><span style="color:#268bd2;">let</span><span> meshlet_id </span><span style="color:#657b83;">=</span><span> meshlet_id_local </span><span style="color:#657b83;">+</span><span> meshlet_instance_meshlet_slice_starts</span><span style="color:#657b83;">[</span><span>instance_id</span><span style="color:#657b83;">]</span><span>; </span><span> </span><span> </span><span style="color:#586e75;">// Write results to buffers </span><span> meshlet_cluster_instance_ids</span><span style="color:#657b83;">[</span><span>cluster_id</span><span style="color:#657b83;">] =</span><span> instance_id; </span><span> meshlet_cluster_meshlet_ids</span><span style="color:#657b83;">[</span><span>cluster_id</span><span style="color:#657b83;">] =</span><span> meshlet_id; </span><span style="color:#657b83;">} </span></code></pre> <h2 id="culling-first-pass">Culling (First Pass)<a class="zola-anchor" href="#culling-first-pass" aria-label="Anchor link for: culling-first-pass" style="visibility: hidden;"></a> </h2> <p>I mentioned earlier that frustum culling is not sufficent for complex scenes. With meshlets, we're going to have a <em>lot</em> of geometry in view at once. Rendering all of that is way too expensive, and unnecessary. It's a complete waste to spend time rendering a bunch of detailed rocks and trees, only to draw a wall in front of it later on (overdraw).</p> <p>Two pass occlusion culling is the method that we're going to use to reduce overdraw. We're going to start by drawing all the clusters that actually contributed to the rendered image last frame, under the assumption that those are a good approximation of what will contribute to the rendered image <em>this</em> frame. That's the first pass. Then, we can build a depth pyramid, and use that to cull all the clusters that we didn't look at in the first pass, i.e. that didn't render last frame. The clusters that survive the culling get drawn. That's the second pass.</p> <p>In the example with the wall with the rocks and trees behind it, we could see that last frame the wall clusters contributed pixels to the final image, but none of the rock or tree clusters did. Therefore in the first pass, we would draw only the wall, and then build a depth pyramid from the resulting depth. In the second pass, we would test the remaining clusters (all the trees and rocks) against the depth pyramid, and see that they would still be occluded by the wall, and therefore we can skip drawing them. If there were some new rocks that came into view as we peeked around the corner, they'd be drawn here. The second pass functions as a cleanup pass, for rendering the objects that we missed in the first pass.</p> <p>Done correctly, two pass occlusion culling reduces the amount of clusters we draw in an average frame, saving rendering time without any visible artifacts.</p> <h3 id="initial-cluster-processing">Initial Cluster Processing<a class="zola-anchor" href="#initial-cluster-processing" aria-label="Anchor link for: initial-cluster-processing" style="visibility: hidden;"></a> </h3> <p>Before we start looking at the algorithm steps and code, I'd like to note that this shader is very performance and bug sensitive. I've written and rewritten it several times. While the concepts are simple, it's easy to break the culling, and the choices in data management that we make here affect the rest of the rendering pipeline quite significantly.</p> <p>This is going to be a long and complicated shader, so let's dive into it.</p> <p>The first pass of occlusion culling is another compute shader dispatch with one thread per cluster. A minor detail that I didn't mention last time we saw this pattern, is that with millions of clusters in a scene, you would quickly hit the limit of the maximum number of workgroups you can spawn per dispatch dimension if you did a 1d dispatch over all clusters. To work around this, we instead we do a 3d dispatch with each dimension of size <code>ceil(cbrt(workgroup_count))</code>. We can then swizzle the workgroup and thread indices back to 1d in the shader.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#859900;">@</span><span>compute </span><span style="color:#859900;">@workgroup_size</span><span style="color:#657b83;">(</span><span style="color:#6c71c4;">128</span><span>, </span><span style="color:#6c71c4;">1</span><span>, </span><span style="color:#6c71c4;">1</span><span style="color:#657b83;">) </span><span style="color:#586e75;">// 128 threads per workgroup, 1 cluster per thread </span><span style="color:#268bd2;">fn </span><span style="color:#b58900;">cull_meshlets</span><span style="color:#657b83;">( </span><span> @builtin</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">workgroup_id</span><span style="color:#657b83;">) </span><span style="color:#268bd2;">workgroup_id</span><span>: vec3&lt;</span><span style="color:#268bd2;">u32</span><span>&gt;, </span><span> @builtin</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">num_workgroups</span><span style="color:#657b83;">) </span><span style="color:#268bd2;">num_workgroups</span><span>: vec3&lt;</span><span style="color:#268bd2;">u32</span><span>&gt;, </span><span> @builtin</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">local_invocation_id</span><span style="color:#657b83;">) </span><span style="color:#268bd2;">local_invocation_id</span><span>: vec3&lt;</span><span style="color:#268bd2;">u32</span><span>&gt;, </span><span style="color:#657b83;">) { </span><span style="color:#586e75;">// Calculate the cluster ID for this thread </span><span style="color:#268bd2;">let</span><span> cluster_id </span><span style="color:#657b83;">=</span><span> local_invocation_id.x </span><span style="color:#657b83;">+</span><span> 128u </span><span style="color:#657b83;">* </span><span style="color:#859900;">dot</span><span style="color:#657b83;">(</span><span>workgroup_id, </span><span style="color:#859900;">vec3</span><span style="color:#657b83;">(</span><span>num_workgroups.x </span><span style="color:#657b83;">*</span><span> num_workgroups.x, num_workgroups.x, 1u</span><span style="color:#657b83;">))</span><span>; </span><span style="color:#859900;">if</span><span> cluster_id </span><span style="color:#657b83;">&gt;=</span><span> arrayLength</span><span style="color:#657b83;">(</span><span style="color:#859900;">&amp;</span><span>meshlet_cluster_meshlet_ids</span><span style="color:#657b83;">) { </span><span style="color:#859900;">return</span><span>; </span><span style="color:#657b83;">} </span></code></pre> <p>Once we know what cluster this thread should process, the next step is to check instance culling. Bevy has the concept of render layers, where certain entities only render for certain views. Before rendering, we uploaded a bitmask of whether each instance was visible for the current view or not. In the shader, we'll just check that bitmask, and early-out if the cluster belongs to an instance that should be culled.</p> <p>The instance ID can be found via indexing into the per-cluster data buffer that we computed in the previous pass (fill cluster buffers).</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#586e75;">// Check for instance culling </span><span style="color:#268bd2;">let</span><span> instance_id </span><span style="color:#657b83;">=</span><span> meshlet_cluster_instance_ids</span><span style="color:#657b83;">[</span><span>cluster_id</span><span style="color:#657b83;">]</span><span>; </span><span style="color:#268bd2;">let</span><span> bit_offset </span><span style="color:#657b83;">=</span><span> instance_id </span><span style="color:#657b83;">%</span><span> 32u; </span><span style="color:#268bd2;">let</span><span> packed_visibility </span><span style="color:#657b83;">=</span><span> meshlet_view_instance_visibility</span><span style="color:#657b83;">[</span><span>instance_id </span><span style="color:#657b83;">/</span><span> 32u</span><span style="color:#657b83;">]</span><span>; </span><span style="color:#268bd2;">let</span><span> should_cull_instance </span><span style="color:#657b83;">= </span><span style="color:#268bd2;">bool</span><span style="color:#657b83;">(</span><span>extractBits</span><span style="color:#657b83;">(</span><span>packed_visibility, bit_offset, 1u</span><span style="color:#657b83;">))</span><span>; </span><span style="color:#859900;">if</span><span> should_cull_instance </span><span style="color:#657b83;">{ </span><span style="color:#859900;">return</span><span>; </span><span style="color:#657b83;">} </span></code></pre> <p>Assuming the cluster's instance was not culled, we can now start fetching the rest of the cluster's data for culling. The instance ID we found also gives us access to the instance uniform, and we can fetch the meshlet ID the same way we did the instance ID. With these two indices, we can also fetch the culling bounding sphere for the cluster's meshlet, and convert it from local to world-space.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#586e75;">// Calculate world-space culling bounding sphere for the cluster </span><span style="color:#268bd2;">let</span><span> instance_uniform </span><span style="color:#657b83;">=</span><span> meshlet_instance_uniforms</span><span style="color:#657b83;">[</span><span>instance_id</span><span style="color:#657b83;">]</span><span>; </span><span style="color:#268bd2;">let</span><span> meshlet_id </span><span style="color:#657b83;">=</span><span> meshlet_cluster_meshlet_ids</span><span style="color:#657b83;">[</span><span>cluster_id</span><span style="color:#657b83;">]</span><span>; </span><span style="color:#268bd2;">let</span><span> world_from_local </span><span style="color:#657b83;">= </span><span style="color:#859900;">affine3_to_square</span><span style="color:#657b83;">(</span><span>instance_uniform.world_from_local</span><span style="color:#657b83;">)</span><span>; </span><span style="color:#268bd2;">let</span><span> world_scale </span><span style="color:#657b83;">= </span><span style="color:#859900;">max</span><span style="color:#657b83;">(</span><span style="color:#859900;">length</span><span style="color:#657b83;">(</span><span>world_from_local</span><span style="color:#657b83;">[</span><span style="color:#6c71c4;">0</span><span style="color:#657b83;">])</span><span>, </span><span style="color:#859900;">max</span><span style="color:#657b83;">(</span><span style="color:#859900;">length</span><span style="color:#657b83;">(</span><span>world_from_local</span><span style="color:#657b83;">[</span><span style="color:#6c71c4;">1</span><span style="color:#657b83;">])</span><span>, </span><span style="color:#859900;">length</span><span style="color:#657b83;">(</span><span>world_from_local</span><span style="color:#657b83;">[</span><span style="color:#6c71c4;">2</span><span style="color:#657b83;">])))</span><span>; </span><span style="color:#268bd2;">let</span><span> bounding_spheres </span><span style="color:#657b83;">=</span><span> meshlet_bounding_spheres</span><span style="color:#657b83;">[</span><span>meshlet_id</span><span style="color:#657b83;">]</span><span>; </span><span>var culling_bounding_sphere_center </span><span style="color:#657b83;">=</span><span> world_from_local </span><span style="color:#657b83;">* </span><span style="color:#859900;">vec4</span><span style="color:#657b83;">(</span><span>bounding_spheres.self_culling.center, </span><span style="color:#6c71c4;">1.0</span><span style="color:#657b83;">)</span><span>; </span><span>var culling_bounding_sphere_radius </span><span style="color:#657b83;">=</span><span> world_scale </span><span style="color:#657b83;">*</span><span> bounding_spheres.self_culling.radius; </span></code></pre> <p>A simple frustum test lets us cull out of view clusters (an early return means the cluster is culled).</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#586e75;">// Frustum culling </span><span style="color:#859900;">for </span><span style="color:#657b83;">(</span><span>var i </span><span style="color:#657b83;">=</span><span> 0u; i </span><span style="color:#657b83;">&lt;</span><span> 6u; i</span><span style="color:#657b83;">++) { </span><span> </span><span style="color:#859900;">if dot</span><span style="color:#657b83;">(</span><span>view.frustum</span><span style="color:#657b83;">[</span><span>i</span><span style="color:#657b83;">]</span><span>, culling_bounding_sphere_center</span><span style="color:#657b83;">) +</span><span> culling_bounding_sphere_radius </span><span style="color:#657b83;">&lt;= </span><span style="color:#6c71c4;">0.0 </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#859900;">return</span><span>; </span><span> </span><span style="color:#657b83;">} </span><span style="color:#657b83;">} </span></code></pre> <h3 id="lod-selection">LOD Selection<a class="zola-anchor" href="#lod-selection" aria-label="Anchor link for: lod-selection" style="visibility: hidden;"></a> </h3> <p>Now that we know if a cluster is in view, the next question we need to ask is "Is this cluster's meshlet part of the right cut of the LOD tree?"</p> <p>The goal is to select the set of simplified meshlets such that at the distance we're viewing them from, they have less than 1 pixel of geometric difference from the original set of meshlets at LOD 0 (the base mesh). Note that we're accounting <em>only</em> for geometric differences, and not taking into account material or lighting differences. Doing so is a <em>much</em> harder problem.</p> <p>So, the question is then "how do we determine if the group this meshlet belongs to has less than 1 pixel of geometric error?"</p> <p>When building the meshlet groups during asset preprocessing, we stored the group error relative to the base mesh as the radius of the bounding sphere. We can convert this bounding sphere from local to world-space, project it to view-space, and then check how many pixels on the screen it takes up. If it's less than 1 pixel, then the cluster is imperceptibly different. We're essentially answering the question "if the mesh deformed by X meters, how many pixels of change is that when viewed from the current camera"?</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#586e75;">// https://stackoverflow.com/questions/21648630/radius-of-projected-sphere-in-screen-space/21649403#21649403 </span><span style="color:#268bd2;">fn </span><span style="color:#b58900;">lod_error_is_imperceptible</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">sphere_center</span><span>: vec3&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;, </span><span style="color:#268bd2;">sphere_radius</span><span>: </span><span style="color:#268bd2;">f32</span><span style="color:#657b83;">) </span><span>-&gt; </span><span style="color:#268bd2;">bool </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#268bd2;">let</span><span> d2 </span><span style="color:#657b83;">= </span><span style="color:#859900;">dot</span><span style="color:#657b83;">(</span><span>sphere_center, sphere_center</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> r2 </span><span style="color:#657b83;">=</span><span> sphere_radius </span><span style="color:#657b83;">*</span><span> sphere_radius; </span><span> </span><span style="color:#268bd2;">let</span><span> sphere_diameter_uv </span><span style="color:#657b83;">=</span><span> view.clip_from_view</span><span style="color:#657b83;">[</span><span style="color:#6c71c4;">0</span><span style="color:#657b83;">][</span><span style="color:#6c71c4;">0</span><span style="color:#657b83;">] *</span><span> sphere_radius </span><span style="color:#657b83;">/ </span><span style="color:#859900;">sqrt</span><span style="color:#657b83;">(</span><span>d2 </span><span style="color:#657b83;">-</span><span> r2</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> view_size </span><span style="color:#657b83;">= </span><span style="color:#268bd2;">f32</span><span style="color:#657b83;">(</span><span style="color:#859900;">max</span><span style="color:#657b83;">(</span><span>view.width, view.height</span><span style="color:#657b83;">))</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> sphere_diameter_pixels </span><span style="color:#657b83;">=</span><span> sphere_diameter_uv </span><span style="color:#657b83;">*</span><span> view_size; </span><span> </span><span style="color:#859900;">return</span><span> sphere_diameter_pixels </span><span style="color:#657b83;">&lt; </span><span style="color:#6c71c4;">1.0</span><span>; </span><span style="color:#657b83;">} </span></code></pre> <p>Knowing if the cluster has imperceptible error is not sufficent by itself. Say you have 4 sets of meshlets - the original one (group 0), and 3 progressively simplified versions (groups 1-3). If group 2 has imperceptible error for the current view, then so would groups 1 and 0. In fact, group 0 will <em>always</em> have imperceptible error, given that it <em>is</em> the base mesh.</p> <p>Given multiple sets of imperceptibly different meshlets, the best set to select is the one made of the fewest triangles (most simplified), which is the highest LOD.</p> <p>Since we're processing each cluster in parallel, we can't communicate between them to choose the correct LOD cut. Instead, we can use a neat trick. We can design a procedure where each cluster evaluates some data, and decides independently whether it's at the correct LOD, in a way that's consistent across all the clusters.</p> <p>The Nanite slides go into the theory more, but it boils down to checking if error is imperceptible for the current cluster, <em>and</em> that its <em>parent's</em> error is <em>not</em> imperceptible. I.e. this is the most simple cluster we can choose with imperceptible error, and going up to it's even more simple parent would cause visible error.</p> <p>We can take the two LOD bounding spheres (the ones containing simplification error) for each meshlet, transform them to view-space, check if the error for each one is imperceptible or not, and then early-out if this cluster is not part of the correct LOD cut.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#586e75;">// Calculate view-space LOD bounding sphere for the meshlet </span><span style="color:#268bd2;">let</span><span> lod_bounding_sphere_center </span><span style="color:#657b83;">=</span><span> world_from_local </span><span style="color:#657b83;">* </span><span style="color:#859900;">vec4</span><span style="color:#657b83;">(</span><span>bounding_spheres.self_lod.center, </span><span style="color:#6c71c4;">1.0</span><span style="color:#657b83;">)</span><span>; </span><span style="color:#268bd2;">let</span><span> lod_bounding_sphere_radius </span><span style="color:#657b83;">=</span><span> world_scale </span><span style="color:#657b83;">*</span><span> bounding_spheres.self_lod.radius; </span><span style="color:#268bd2;">let</span><span> lod_bounding_sphere_center_view_space </span><span style="color:#657b83;">= (</span><span>view.view_from_world </span><span style="color:#657b83;">* </span><span style="color:#859900;">vec4</span><span style="color:#657b83;">(</span><span>lod_bounding_sphere_center.xyz, </span><span style="color:#6c71c4;">1.0</span><span style="color:#657b83;">))</span><span>.xyz; </span><span> </span><span style="color:#586e75;">// Calculate view-space LOD bounding sphere for the meshlet&#39;s parent </span><span style="color:#268bd2;">let</span><span> parent_lod_bounding_sphere_center </span><span style="color:#657b83;">=</span><span> world_from_local </span><span style="color:#657b83;">* </span><span style="color:#859900;">vec4</span><span style="color:#657b83;">(</span><span>bounding_spheres.parent_lod.center, </span><span style="color:#6c71c4;">1.0</span><span style="color:#657b83;">)</span><span>; </span><span style="color:#268bd2;">let</span><span> parent_lod_bounding_sphere_radius </span><span style="color:#657b83;">=</span><span> world_scale </span><span style="color:#657b83;">*</span><span> bounding_spheres.parent_lod.radius; </span><span style="color:#268bd2;">let</span><span> parent_lod_bounding_sphere_center_view_space </span><span style="color:#657b83;">= (</span><span>view.view_from_world </span><span style="color:#657b83;">* </span><span style="color:#859900;">vec4</span><span style="color:#657b83;">(</span><span>parent_lod_bounding_sphere_center.xyz, </span><span style="color:#6c71c4;">1.0</span><span style="color:#657b83;">))</span><span>.xyz; </span><span> </span><span style="color:#586e75;">// Check LOD cut (meshlet error imperceptible, and parent error not imperceptible) </span><span style="color:#268bd2;">let</span><span> lod_is_ok </span><span style="color:#657b83;">= </span><span style="color:#859900;">lod_error_is_imperceptible</span><span style="color:#657b83;">(</span><span>lod_bounding_sphere_center_view_space, lod_bounding_sphere_radius</span><span style="color:#657b83;">)</span><span>; </span><span style="color:#268bd2;">let</span><span> parent_lod_is_ok </span><span style="color:#657b83;">= </span><span style="color:#859900;">lod_error_is_imperceptible</span><span style="color:#657b83;">(</span><span>parent_lod_bounding_sphere_center_view_space, parent_lod_bounding_sphere_radius</span><span style="color:#657b83;">)</span><span>; </span><span style="color:#859900;">if !</span><span>lod_is_ok </span><span style="color:#859900;">||</span><span> parent_lod_is_ok </span><span style="color:#657b83;">{ </span><span style="color:#859900;">return</span><span>; </span><span style="color:#657b83;">} </span></code></pre> <h3 id="occlusion-culling-test">Occlusion Culling Test<a class="zola-anchor" href="#occlusion-culling-test" aria-label="Anchor link for: occlusion-culling-test" style="visibility: hidden;"></a> </h3> <p>We've checked if the cluster is in view (frustum and render layer culling), as well as if it's part of the correct LOD cut. It's now time for the actual occlusion culling part of the first of the two passes for two pass occlusion culling.</p> <p>Our goal in the first pass is to render only clusters that were visible last frame. One possible method would be to store another bitmask of whether each cluster was visible in the current frame, and read from it in the next frame. The problem with this is that it uses a good chunk of memory, and more importantly, does not play well with LODs. Before I implemented LODs I used this method, but with LODs, a cluster that was visible last frame might not be part of the LOD cut in this frame and therefore incorrect to render.</p> <p>Instead of explicitly storing whether a cluster is visible, we're instead going to occlusion cull the clusters against the depth pyramid from the <em>previous</em> frame. We can take the culling bounding sphere of the cluster, project it to view-space using the previous frame's set of transforms, and then project it to a screen-space axis-aligned bounding box (AABB). We can then compare the view-space depth of the bounding sphere's extents with every pixel of the depth buffer that the AABB we calculated covers. If all depth pixels show that there is geometry in front of the bounding sphere, then the mesh was not visible last frame, and therefore should not be rendered in the first occlusion culling pass.</p> <p>Of course sampling every pixel an AABB covers would be extremely expensive, and cache inefficient. Instead we'll use a depth <em>pyramid</em>, which is a mipmapped version of the depth buffer. Each pixel in MIP 1 corresponds to the min of 4 pixels from MIP 0, each pixel in MIP 2 corresponds to the min of 4 pixels from MIP 1, etc down to a 1x1 layer. Now we only have to sample 4 pixels for each AABB, choosing the mip level that best fits the AABB onto a 2x2 quad. Don't worry about how we generate the depth pyramid for now, we'll talk about that more later.</p> <p>If any of that was confusing, read up on occlusion culling and depth pyramids. The important takeaway is that we're using the previous frame's depth pyramid in the first occlusion culling pass to find which clusters would have been visible last frame.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#586e75;">// Project the culling bounding sphere to view-space for occlusion culling </span><span style="color:#268bd2;">let</span><span> previous_world_from_local </span><span style="color:#657b83;">= </span><span style="color:#859900;">affine3_to_square</span><span style="color:#657b83;">(</span><span>instance_uniform.previous_world_from_local</span><span style="color:#657b83;">)</span><span>; </span><span style="color:#268bd2;">let</span><span> previous_world_from_local_scale </span><span style="color:#657b83;">= </span><span style="color:#859900;">max</span><span style="color:#657b83;">(</span><span style="color:#859900;">length</span><span style="color:#657b83;">(</span><span>previous_world_from_local</span><span style="color:#657b83;">[</span><span style="color:#6c71c4;">0</span><span style="color:#657b83;">])</span><span>, </span><span style="color:#859900;">max</span><span style="color:#657b83;">(</span><span style="color:#859900;">length</span><span style="color:#657b83;">(</span><span>previous_world_from_local</span><span style="color:#657b83;">[</span><span style="color:#6c71c4;">1</span><span style="color:#657b83;">])</span><span>, </span><span style="color:#859900;">length</span><span style="color:#657b83;">(</span><span>previous_world_from_local</span><span style="color:#657b83;">[</span><span style="color:#6c71c4;">2</span><span style="color:#657b83;">])))</span><span>; </span><span>culling_bounding_sphere_center </span><span style="color:#657b83;">=</span><span> previous_world_from_local </span><span style="color:#657b83;">* </span><span style="color:#859900;">vec4</span><span style="color:#657b83;">(</span><span>bounding_spheres.self_culling.center, </span><span style="color:#6c71c4;">1.0</span><span style="color:#657b83;">)</span><span>; </span><span>culling_bounding_sphere_radius </span><span style="color:#657b83;">=</span><span> previous_world_from_local_scale </span><span style="color:#657b83;">*</span><span> bounding_spheres.self_culling.radius; </span><span style="color:#268bd2;">let</span><span> culling_bounding_sphere_center_view_space </span><span style="color:#657b83;">= (</span><span>view.view_from_world </span><span style="color:#657b83;">* </span><span style="color:#859900;">vec4</span><span style="color:#657b83;">(</span><span>culling_bounding_sphere_center.xyz, </span><span style="color:#6c71c4;">1.0</span><span style="color:#657b83;">))</span><span>.xyz; </span><span> </span><span style="color:#268bd2;">let</span><span> aabb </span><span style="color:#657b83;">= </span><span style="color:#859900;">project_view_space_sphere_to_screen_space_aabb</span><span style="color:#657b83;">(</span><span>culling_bounding_sphere_center_view_space, culling_bounding_sphere_radius</span><span style="color:#657b83;">)</span><span>; </span><span style="color:#586e75;">// Halve the view-space AABB size as the depth pyramid is half the view size </span><span style="color:#268bd2;">let</span><span> depth_pyramid_size_mip_0 </span><span style="color:#657b83;">= </span><span>vec2&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;</span><span style="color:#657b83;">(</span><span>textureDimensions</span><span style="color:#657b83;">(</span><span>depth_pyramid, </span><span style="color:#6c71c4;">0</span><span style="color:#657b83;">)) * </span><span style="color:#6c71c4;">0.5</span><span>; </span><span style="color:#268bd2;">let</span><span> width </span><span style="color:#657b83;">= (</span><span>aabb.z </span><span style="color:#657b83;">-</span><span> aabb.x</span><span style="color:#657b83;">) *</span><span> depth_pyramid_size_mip_0.x; </span><span style="color:#268bd2;">let</span><span> height </span><span style="color:#657b83;">= (</span><span>aabb.w </span><span style="color:#657b83;">-</span><span> aabb.y</span><span style="color:#657b83;">) *</span><span> depth_pyramid_size_mip_0.y; </span><span style="color:#586e75;">// Note: I&#39;ve seen people use floor instead of ceil here, but it seems to result in culling bugs. </span><span style="color:#586e75;">// The max(0, x) is also important to prevent out of bounds accesses. </span><span style="color:#268bd2;">let</span><span> depth_level </span><span style="color:#657b83;">= </span><span style="color:#859900;">max</span><span style="color:#657b83;">(</span><span style="color:#6c71c4;">0</span><span>, </span><span style="color:#268bd2;">u32</span><span style="color:#657b83;">(</span><span style="color:#859900;">ceil</span><span style="color:#657b83;">(</span><span style="color:#859900;">log2</span><span style="color:#657b83;">(</span><span style="color:#859900;">max</span><span style="color:#657b83;">(</span><span>width, height</span><span style="color:#657b83;">)))))</span><span>; </span><span style="color:#268bd2;">let</span><span> depth_pyramid_size </span><span style="color:#657b83;">= </span><span>vec2&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;</span><span style="color:#657b83;">(</span><span>textureDimensions</span><span style="color:#657b83;">(</span><span>depth_pyramid, depth_level</span><span style="color:#657b83;">))</span><span>; </span><span style="color:#268bd2;">let</span><span> aabb_top_left </span><span style="color:#657b83;">= </span><span>vec2&lt;</span><span style="color:#268bd2;">u32</span><span>&gt;</span><span style="color:#657b83;">(</span><span>aabb.xy </span><span style="color:#657b83;">*</span><span> depth_pyramid_size</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#586e75;">// Note: I&#39;d use a min sampler reduction here if it were available in wgpu. </span><span style="color:#586e75;">// textureGather() can&#39;t be used either, as it dosen&#39;t let you specify a mip level. </span><span style="color:#268bd2;">let</span><span> depth_quad_a </span><span style="color:#657b83;">=</span><span> textureLoad</span><span style="color:#657b83;">(</span><span>depth_pyramid, aabb_top_left, depth_level</span><span style="color:#657b83;">)</span><span>.x; </span><span style="color:#268bd2;">let</span><span> depth_quad_b </span><span style="color:#657b83;">=</span><span> textureLoad</span><span style="color:#657b83;">(</span><span>depth_pyramid, aabb_top_left </span><span style="color:#657b83;">+ </span><span style="color:#859900;">vec2</span><span style="color:#657b83;">(</span><span>1u, 0u</span><span style="color:#657b83;">)</span><span>, depth_level</span><span style="color:#657b83;">)</span><span>.x; </span><span style="color:#268bd2;">let</span><span> depth_quad_c </span><span style="color:#657b83;">=</span><span> textureLoad</span><span style="color:#657b83;">(</span><span>depth_pyramid, aabb_top_left </span><span style="color:#657b83;">+ </span><span style="color:#859900;">vec2</span><span style="color:#657b83;">(</span><span>0u, 1u</span><span style="color:#657b83;">)</span><span>, depth_level</span><span style="color:#657b83;">)</span><span>.x; </span><span style="color:#268bd2;">let</span><span> depth_quad_d </span><span style="color:#657b83;">=</span><span> textureLoad</span><span style="color:#657b83;">(</span><span>depth_pyramid, aabb_top_left </span><span style="color:#657b83;">+ </span><span style="color:#859900;">vec2</span><span style="color:#657b83;">(</span><span>1u, 1u</span><span style="color:#657b83;">)</span><span>, depth_level</span><span style="color:#657b83;">)</span><span>.x; </span><span style="color:#268bd2;">let</span><span> occluder_depth </span><span style="color:#657b83;">= </span><span style="color:#859900;">min</span><span style="color:#657b83;">(</span><span style="color:#859900;">min</span><span style="color:#657b83;">(</span><span>depth_quad_a, depth_quad_b</span><span style="color:#657b83;">)</span><span>, </span><span style="color:#859900;">min</span><span style="color:#657b83;">(</span><span>depth_quad_c, depth_quad_d</span><span style="color:#657b83;">))</span><span>; </span><span> </span><span style="color:#586e75;">// Check whether or not the cluster would be occluded if drawn </span><span>var cluster_visible: </span><span style="color:#268bd2;">bool</span><span>; </span><span style="color:#859900;">if</span><span> view.clip_from_view</span><span style="color:#657b83;">[</span><span style="color:#6c71c4;">3</span><span style="color:#657b83;">][</span><span style="color:#6c71c4;">3</span><span style="color:#657b83;">] == </span><span style="color:#6c71c4;">1.0 </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#586e75;">// Orthographic </span><span> </span><span style="color:#268bd2;">let</span><span> sphere_depth </span><span style="color:#657b83;">=</span><span> view.clip_from_view</span><span style="color:#657b83;">[</span><span style="color:#6c71c4;">3</span><span style="color:#657b83;">][</span><span style="color:#6c71c4;">2</span><span style="color:#657b83;">] + (</span><span>culling_bounding_sphere_center_view_space.z </span><span style="color:#657b83;">+</span><span> culling_bounding_sphere_radius</span><span style="color:#657b83;">) *</span><span> view.clip_from_view</span><span style="color:#657b83;">[</span><span style="color:#6c71c4;">2</span><span style="color:#657b83;">][</span><span style="color:#6c71c4;">2</span><span style="color:#657b83;">]</span><span>; </span><span> cluster_visible </span><span style="color:#657b83;">=</span><span> sphere_depth </span><span style="color:#657b83;">&gt;=</span><span> occluder_depth; </span><span style="color:#657b83;">} </span><span style="color:#859900;">else </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#586e75;">// Perspective </span><span> </span><span style="color:#268bd2;">let</span><span> sphere_depth </span><span style="color:#657b83;">= -</span><span>view.clip_from_view</span><span style="color:#657b83;">[</span><span style="color:#6c71c4;">3</span><span style="color:#657b83;">][</span><span style="color:#6c71c4;">2</span><span style="color:#657b83;">] / (</span><span>culling_bounding_sphere_center_view_space.z </span><span style="color:#657b83;">+</span><span> culling_bounding_sphere_radius</span><span style="color:#657b83;">)</span><span>; </span><span> cluster_visible </span><span style="color:#657b83;">=</span><span> sphere_depth </span><span style="color:#657b83;">&gt;=</span><span> occluder_depth; </span><span style="color:#657b83;">} </span></code></pre> <h3 id="result-writeout">Result Writeout<a class="zola-anchor" href="#result-writeout" aria-label="Anchor link for: result-writeout" style="visibility: hidden;"></a> </h3> <p>We're finally at the last step of the first occlusion culling pass/dispatch. As a reminder, everything from after the fill cluster buffers step until the end of this section has all been one shader. I warned you it would be long!</p> <p>The last step for this pass is to write out the results of what clusters should render. This pass is just a compute shader - it dosen't actually render anything. We're just going to fill out the arguments for a single indirect draw command (more on this in the next pass).</p> <p>First, before we get to the indirect draw, we need to write out another piece of data. The second occlusion culling pass later will want to operate only on clusters in view, that passed the LOD test, and that were <em>not</em> drawn in the first pass. That means we didn't early return during the frustum culling or LOD test, and that cluster_visible was false from the occlusion culling test.</p> <p>In order for the second occlusion pass to know which clusters satisfy these conditions, we'll write out another bitmask of 1 bit per cluster, with clusters that the second occlusion pass should operate on having their bit set to 1. An atomicOr takes care of setting each cluster's bit in parallel amongst all threads.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#586e75;">// Write if the cluster should be occlusion tested in the second pass </span><span style="color:#859900;">if !</span><span>cluster_visible </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#268bd2;">let</span><span> bit </span><span style="color:#657b83;">=</span><span> 1u </span><span style="color:#657b83;">&lt;&lt;</span><span> cluster_id </span><span style="color:#657b83;">%</span><span> 32u; </span><span> atomicOr</span><span style="color:#657b83;">(</span><span style="color:#859900;">&amp;</span><span>meshlet_second_pass_candidates</span><span style="color:#657b83;">[</span><span>cluster_id </span><span style="color:#657b83;">/</span><span> 32u</span><span style="color:#657b83;">]</span><span>, bit</span><span style="color:#657b83;">)</span><span>; </span><span style="color:#657b83;">} </span></code></pre> <p>Now we have the final step of filling out the indirect draw data for the clusters that we <em>do</em> want to draw in the first pass.</p> <p>We can do an atomicAdd on the DrawIndirectArgs::vertex_count with the meshlet's vertex count (triangle count * 3). This does two things:</p> <ol> <li>Adds more vertex invocations to the indirect draw for this cluster's triangles</li> <li>Reserves space in a large buffer for all of this cluster's triangles to write out a per-triangle number</li> </ol> <p>With the draw_triangle_buffer space reserved, we can then fill it with an encoded u32 integer: 26 bits for the cluster ID, and 6 bits for the triangle ID within the cluster's meshlet. 6 bits gives us 2^6 = 64 possible values, which is perfect as when we were building meshlets during asset preprocessing, we limited each meshlet to max 64 vertices and 64 triangles.</p> <p>During vertex shading in the next pass, each vertex invocation will be able to use this buffer to know what triangle and cluster it belongs to.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#586e75;">// Append a list of this cluster&#39;s triangles to draw if not culled </span><span style="color:#859900;">if</span><span> cluster_visible </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#268bd2;">let</span><span> meshlet_triangle_count </span><span style="color:#657b83;">=</span><span> meshlets</span><span style="color:#657b83;">[</span><span>meshlet_id</span><span style="color:#657b83;">]</span><span>.triangle_count; </span><span> </span><span style="color:#268bd2;">let</span><span> buffer_start </span><span style="color:#657b83;">=</span><span> atomicAdd</span><span style="color:#657b83;">(</span><span style="color:#859900;">&amp;</span><span>draw_indirect_args.vertex_count, meshlet_triangle_count </span><span style="color:#657b83;">*</span><span> 3u</span><span style="color:#657b83;">) /</span><span> 3u; </span><span> </span><span> </span><span style="color:#268bd2;">let</span><span> cluster_id_packed </span><span style="color:#657b83;">=</span><span> cluster_id </span><span style="color:#657b83;">&lt;&lt;</span><span> 6u; </span><span> </span><span style="color:#859900;">for </span><span style="color:#657b83;">(</span><span>var triangle_id </span><span style="color:#657b83;">=</span><span> 0u; triangle_id </span><span style="color:#657b83;">&lt;</span><span> meshlet_triangle_count; triangle_id</span><span style="color:#657b83;">++) { </span><span> draw_triangle_buffer</span><span style="color:#657b83;">[</span><span>buffer_start </span><span style="color:#657b83;">+</span><span> triangle_id</span><span style="color:#657b83;">] =</span><span> cluster_id_packed </span><span style="color:#859900;">|</span><span> triangle_id; </span><span> </span><span style="color:#657b83;">} </span><span style="color:#657b83;">} </span></code></pre> <h2 id="raster-first-pass">Raster (First Pass)<a class="zola-anchor" href="#raster-first-pass" aria-label="Anchor link for: raster-first-pass" style="visibility: hidden;"></a> </h2> <p>We've now determined what to draw, so it's time to draw it.</p> <p>As I mentioned in the previous section, we're doing a single draw_indirect() call to rasterize every single cluster at once, using the DrawIndirectArgs buffer we filled out in the previous pass.</p> <p>We're going to render to a few different render targets:</p> <ul> <li>Depth buffer</li> <li>Visibility buffer (optional, not rendered for shadow map views)</li> <li>Material depth (optional, not rendered for shadow map views)</li> </ul> <p>The depth buffer is straightforward. The visibility buffer is a R32Uint texture storing the cluster ID + triangle ID packed together in the same way as during the culling pass. Material depth is a R16Uint texture storing the material ID. The visibility buffer and material depth textures will be used in a later pass for shading.</p> <p>Note that it would be better to skip writing material depth here, and write it out as part of the later copy material depth pass. This pass is going to change in the near future when I add software rasterization however (more on this in a second), so for now I've left it as-is.</p> <p>I won't show the entire shader, but getting the triangle data to render for each vertex is fairly straightforward. The vertex invocation index can be used to index into the draw_triangle_buffer that we wrote out during the culling pass, giving us a packed cluster ID and triangle ID. The vertex invocation index % 3 gives us which vertex within the triangle this is, and then we can lookup the cluster's meshlet and instance data as normal. Vertex data can be obtained by following the tree of indices using the index ID and meshlet info.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#859900;">@</span><span>vertex </span><span style="color:#268bd2;">fn </span><span style="color:#b58900;">vertex</span><span style="color:#657b83;">(</span><span>@builtin</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">vertex_index</span><span style="color:#657b83;">) </span><span style="color:#268bd2;">vertex_index</span><span>: </span><span style="color:#268bd2;">u32</span><span style="color:#657b83;">) </span><span>-&gt; VertexOutput </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#268bd2;">let</span><span> packed_ids </span><span style="color:#657b83;">=</span><span> draw_triangle_buffer</span><span style="color:#657b83;">[</span><span>vertex_index </span><span style="color:#657b83;">/</span><span> 3u</span><span style="color:#657b83;">]</span><span>; </span><span> </span><span> </span><span style="color:#268bd2;">let</span><span> cluster_id </span><span style="color:#657b83;">=</span><span> packed_ids </span><span style="color:#657b83;">&gt;&gt;</span><span> 6u; </span><span> </span><span style="color:#268bd2;">let</span><span> meshlet_id </span><span style="color:#657b83;">=</span><span> meshlet_cluster_meshlet_ids</span><span style="color:#657b83;">[</span><span>cluster_id</span><span style="color:#657b83;">]</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> meshlet </span><span style="color:#657b83;">=</span><span> meshlets</span><span style="color:#657b83;">[</span><span>meshlet_id</span><span style="color:#657b83;">]</span><span>; </span><span> </span><span> </span><span style="color:#268bd2;">let</span><span> triangle_id </span><span style="color:#657b83;">=</span><span> extractBits</span><span style="color:#657b83;">(</span><span>packed_ids, 0u, 6u</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> index_id </span><span style="color:#657b83;">= (</span><span>triangle_id </span><span style="color:#657b83;">*</span><span> 3u</span><span style="color:#657b83;">) + (</span><span>vertex_index </span><span style="color:#657b83;">%</span><span> 3u</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> index </span><span style="color:#657b83;">= </span><span style="color:#859900;">get_meshlet_index</span><span style="color:#657b83;">(</span><span>meshlet.start_index_id </span><span style="color:#657b83;">+</span><span> index_id</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> vertex_id </span><span style="color:#657b83;">=</span><span> meshlet_vertex_ids</span><span style="color:#657b83;">[</span><span>meshlet.start_vertex_id </span><span style="color:#657b83;">+</span><span> index</span><span style="color:#657b83;">]</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> vertex </span><span style="color:#657b83;">= </span><span style="color:#859900;">unpack_meshlet_vertex</span><span style="color:#657b83;">(</span><span>meshlet_vertex_data</span><span style="color:#657b83;">[</span><span>vertex_id</span><span style="color:#657b83;">])</span><span>; </span><span> </span><span> </span><span style="color:#268bd2;">let</span><span> instance_id </span><span style="color:#657b83;">=</span><span> meshlet_cluster_instance_ids</span><span style="color:#657b83;">[</span><span>cluster_id</span><span style="color:#657b83;">]</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> instance_uniform </span><span style="color:#657b83;">=</span><span> meshlet_instance_uniforms</span><span style="color:#657b83;">[</span><span>instance_id</span><span style="color:#657b83;">]</span><span>; </span><span> </span><span> </span><span style="color:#586e75;">// ... </span><span style="color:#657b83;">} </span></code></pre> <p><img src="https://jms55.github.io/posts/2024-06-09-virtual-geometry-bevy-0-14/depth_buffer.png" alt="Depth buffer" /> <img src="https://jms55.github.io/posts/2024-06-09-virtual-geometry-bevy-0-14/visbuffer.png" alt="Visibility buffer" /> <img src="https://jms55.github.io/posts/2024-06-09-virtual-geometry-bevy-0-14/material_depth.png" alt="Material depth" /></p> <blockquote> <p>Quad overdraw from Renderdoc <img src="https://jms55.github.io/posts/2024-06-09-virtual-geometry-bevy-0-14/quad_overdraw.png" alt="Quad overdraw from Renderdoc" /> Triangle size from Renderdoc <img src="https://jms55.github.io/posts/2024-06-09-virtual-geometry-bevy-0-14/triangle_size.png" alt="Triangle size from Renderdoc" /></p> </blockquote> <hr /> <p>With the overview out of the way, the real topic to discuss for this pass is "why a single draw indirect?" There are several other possibilities I could have gone with:</p> <ul> <li>Mesh shaders</li> <li>Single draw indexed indirect after writing out an index buffer during the culling pass</li> <li>Single draw indirect, with a cluster ID buffer, snapping extra vertex invocations to NaN</li> <li>Multi draw indirect with a sub-draw per cluster</li> <li>Multi draw indirect with a sub-draw per meshlet triangle count bin</li> <li>Software rasterization</li> </ul> <p>Mesh shaders are sadly not supported by wgpu, so that's out. They would be the best option for taking advantage of GPU hardware.</p> <p>Single draw indexed indirect was what I originally used. It's about 10-20% faster (if I remember correctly, it's been a while) than the non-indexed variant I use now. However, that means we would need to allocate an index buffer for our worst case usage at 12 bytes/triangle. That's extremely expensive for the amount of geometry we want to deal with, and you'd quickly run into buffer size limits (~2gb on most platforms). You could dynamically allocate a new buffer size based on amount of rendered triangles after culling with some CPU readback and some heuristics, but that's more complicated and still very memory hungry. Single draw indirect with the 4 bytes/triangle draw_triangle_buffer that I ended up using is still expensive, but good enough to scrape by for now.</p> <p>Single draw indirect with a buffer of cluster IDs is also an option. Each meshlet has max 64 triangles, so we could spawn cluster_count * 64 * 3 vertex invocations. Vertex invocation index / (64 * 3) would give you an index into the cluster ID buffer, and triangle ID is easy to recover via some simple arithmetic. At 4 bytes/cluster, this option is <em>much</em> cheaper in memory than any of the previous methods. The problem is how to handle excess vertex invocations. Not all meshlets will have a full 64 triangles. It's easy enough to have each vertex invocation check the meshlet's triangle count, and if it's not needed, write out a NaN position, causing the GPU to ignore the triangle. The problem is that this performed very poorly when I tested it. All those dummy NaN triangles took up valuable fixed-function time that the GPU could have spent processing other triangles. Maybe performance would be better if I were able to get meshlets much closer to the max triangle count, or halving the max triangle count to 32 per meshlet to spawn less dummy triangles, but I ended up not pursuing this method.</p> <p>Multi draw is also an option. We could write out a buffer with 1 DrawIndirectArgs per cluster, giving 16 bytes/cluster. Each sub-draw would contain exactly the right amount of vertex invocations per cluster. Each vertex invocation would be able to recover their cluster ID via the instance_id builtin, as we would set DrawIndirectArgs::first_instance to the cluster ID. On the CPU, this would still be a single draw call. In practice, I found this still performed poorly. While we are no longer bottlenecked by the GPU having to process dummy triangles, now the GPU's command processor has to process all these sub-commands. At 1 sub-command per cluster, that's a <em>lot</em> of commands. Like the fixed 64 vertex invocations per cluster path, we're again bottlenecked on something that isn't actual rasterization work.</p> <p>An additional idea I thought of while writing this section is to bin each cluster by its meshlet triangle count. All clusters whose meshlets have 10 triangles would go in one bin, 12 triangles in a second bin, 46 triangles in a third bin, etc, for 63 bins total (we would never have a meshlet with 0 triangles). We could then write out a DrawIndirectArgs and list of cluster IDs per bin, and do a single multi_draw_indirect() call on the CPU, similiar to the last section. I haven't tested it out, but this seems like a decent option in theory. I believe Nanite does something similiar in recent versions of Unreal Engine 5 in order to support different types of vertex shaders.</p> <p>Finally, we could use software rasterization. We could write out a list of cluster IDs, spawn 1 workgroup per cluster, and have each workgroup manually rasterize the cluster via some linear algebra, bypassing fixed-function GPU hardware entirely. This is what Nanite does for over 90% of their clusters. Only large clusters and clusters needing depth clipping are rendered via hardware draws. Not only is this one of the most memory efficent options, it's faster than hardware draws for the majority of clusters (hence why Nanite uses it so heavily). Unfortunately, wgpu once again lacks support for a needed feature, this time 64bit texture atomics. The good news is that @atlv24 is working on adding support for this feature, and I'm looking forward to implementing software rendering in a future release of Bevy.</p> <h2 id="downsample-depth">Downsample Depth<a class="zola-anchor" href="#downsample-depth" aria-label="Anchor link for: downsample-depth" style="visibility: hidden;"></a> </h2> <p>With the first of the two passes of two pass occlusion culling rendered, it's time to prepare for the second pass. Namely, we need to generate a new depth pyramid based on the depth buffer we just rendered.</p> <p>For generating the depth pyramid, I ported the FidelityFX Single Pass Downsampler (SPD) to Bevy. SPD lets us perform the downsampling very efficiently, entirely in a single compute dispatch. You could use multiple raster passes, but that's extremely expensive in both CPU time (command recording and wgpu resource tracking), and GPU time (bandwidth reading/writing between passes, pipeline bubbles as the GPU spins up and down between passes).</p> <p>For now, we're actually using two compute dispatches, not one. Wgpu lacks support for globallycoherent buffers, so we have to split the dispatch in two to ensure writes made by the first are visible to the second. I also did not implement the subgroup version of SPD, as wgpu lacked support at the time (it has it now, minus quad operations, which SPD does need). Still very fast despite these small deficiencies.</p> <p>One important note is that we need to ensure that the depth pyramid is conservative. For non-power-of-two depth textures, for instance, we might need special handling of the downsampling. Same for when we sample the depth pyramid during occlusion culling. I haven't done anything special to handle this, but it seems to work well enough. I'm not entirely confident in the edge cases here though.</p> <p><img src="https://jms55.github.io/posts/2024-06-09-virtual-geometry-bevy-0-14/depth_pyramid.png" alt="Depth pyramid" /></p> <h2 id="culling-second-pass">Culling (Second Pass)<a class="zola-anchor" href="#culling-second-pass" aria-label="Anchor link for: culling-second-pass" style="visibility: hidden;"></a> </h2> <p>The second culling pass is where we decide whether to render the rest of the clusters - the ones that we didn't think were a good set of occluders for the scene, and decided to hold off on rendering.</p> <p>This culling pass is much the same as the first, with a few key differences:</p> <ul> <li>We skip frustum and LOD culling, as we did it the first time</li> <li>We operate only on the clusters that we explicitly marked as second pass candidates during the first culling pass <ul> <li>We're still doing a large 3d dispatch over all clusters in the scene, but we can early-out for the clusters that are not second pass candidates</li> </ul> </li> <li>We use the current transforms for occlusion culling, instead of last frame's</li> <li>We occlusion cull using the depth pyramid generated from the previous pass</li> </ul> <p>By doing this, we can skip drawing any clusters that would be occluded by the existing geometry that we rendered in the first pass.</p> <p>As a result of this pass, we have another DrawIndirectArgs we can use to draw the remaining clusters.</p> <h2 id="raster-second-pass">Raster (Second Pass)<a class="zola-anchor" href="#raster-second-pass" aria-label="Anchor link for: raster-second-pass" style="visibility: hidden;"></a> </h2> <p>This pass is identical to the first raster pass, just with the new set of clusters from the second culling pass.</p> <p>Given that the camera and scene is static in the example frame that we're looking at, the first pass perfectly calculated occlusion, and there is nothing to actually render in this pass.</p> <h2 id="copy-material-depth">Copy Material Depth<a class="zola-anchor" href="#copy-material-depth" aria-label="Anchor link for: copy-material-depth" style="visibility: hidden;"></a> </h2> <p>For reasons we'll get to in the material shading pass, we need to copy the R16Uint material depth texture we rasterized earlier to an actual Depth16Unorm depth texture. A simple fullscreen triangle pass with a sample and a divide performs the copy.</p> <p>I mentioned earlier that ideally we wouldn't write out the material depth during the rasterization pass. It would be better to instead write it out during this pass, by sampling the visibility buffer, looking up the material ID from the cluster ID, and then writing it out to the depth texture directly. I intend to switch to this method in the near future.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#859900;">#</span><span>import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput </span><span> </span><span style="color:#859900;">@group</span><span style="color:#657b83;">(</span><span style="color:#6c71c4;">0</span><span style="color:#657b83;">) </span><span style="color:#859900;">@binding</span><span style="color:#657b83;">(</span><span style="color:#6c71c4;">0</span><span style="color:#657b83;">)</span><span> var material_depth: texture_2d&lt;</span><span style="color:#268bd2;">u32</span><span>&gt;; </span><span> </span><span style="color:#586e75;">/// This pass copies the R16Uint material depth texture to an actual Depth16Unorm depth texture. </span><span> </span><span style="color:#859900;">@</span><span>fragment </span><span style="color:#268bd2;">fn </span><span style="color:#b58900;">copy_material_depth</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">in</span><span>: FullscreenVertexOutput</span><span style="color:#657b83;">) </span><span>-&gt; </span><span style="color:#859900;">@builtin</span><span style="color:#657b83;">(</span><span>frag_depth</span><span style="color:#657b83;">) </span><span style="color:#268bd2;">f32 </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#859900;">return </span><span style="color:#268bd2;">f32</span><span style="color:#657b83;">(</span><span>textureLoad</span><span style="color:#657b83;">(</span><span>material_depth, vec2&lt;</span><span style="color:#268bd2;">i32</span><span>&gt;</span><span style="color:#657b83;">(</span><span style="color:#859900;">in</span><span>.position.xy</span><span style="color:#657b83;">)</span><span>, </span><span style="color:#6c71c4;">0</span><span style="color:#657b83;">)</span><span>.r</span><span style="color:#657b83;">) / </span><span style="color:#6c71c4;">65535.0</span><span>; </span><span style="color:#657b83;">} </span></code></pre> <h2 id="material-shading">Material Shading<a class="zola-anchor" href="#material-shading" aria-label="Anchor link for: material-shading" style="visibility: hidden;"></a> </h2> <p>At this point we have the visibility buffer texture containing packed cluster and triangle IDs per pixel, and the material depth texture containing the material ID as a floating point depth value.</p> <p>Now, it's time to apply materials to the frame in a set of "material shading" draws. Note that we're not necessarily rendering a lit and shaded scene. The meshlet feature works with all of Bevy's existing rendering modes (forward, forward + prepass, and deferred). For instance, we could be rendering a GBuffer here, or a normal and motion vector prepass.</p> <h3 id="vertex-shader">Vertex Shader<a class="zola-anchor" href="#vertex-shader" aria-label="Anchor link for: vertex-shader" style="visibility: hidden;"></a> </h3> <p>For each material, we will perform one draw call of a fullscreen triangle.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#586e75;">// 1 fullscreen triangle draw per material </span><span style="color:#859900;">for </span><span style="color:#657b83;">(</span><span>material_id, material_pipeline_id, material_bind_group</span><span style="color:#657b83;">) </span><span style="color:#859900;">in</span><span> meshlet_view_materials.</span><span style="color:#859900;">iter</span><span style="color:#657b83;">() { </span><span> </span><span style="color:#859900;">if</span><span> meshlet_gpu_scene.</span><span style="color:#859900;">material_present_in_scene</span><span style="color:#657b83;">(</span><span>material_id</span><span style="color:#657b83;">) { </span><span> </span><span style="color:#859900;">if </span><span style="color:#268bd2;">let </span><span style="color:#859900;">Some</span><span style="color:#657b83;">(</span><span>material_pipeline</span><span style="color:#657b83;">) =</span><span> pipeline_cache.</span><span style="color:#859900;">get_render_pipeline</span><span style="color:#657b83;">(*</span><span>material_pipeline_id</span><span style="color:#657b83;">) { </span><span> </span><span style="color:#268bd2;">let</span><span> x </span><span style="color:#657b83;">= *</span><span>material_id </span><span style="color:#657b83;">* </span><span style="color:#6c71c4;">3</span><span>; </span><span> render_pass.</span><span style="color:#859900;">set_render_pipeline</span><span style="color:#657b83;">(</span><span>material_pipeline</span><span style="color:#657b83;">)</span><span>; </span><span> render_pass.</span><span style="color:#859900;">set_bind_group</span><span style="color:#657b83;">(</span><span style="color:#6c71c4;">2</span><span>, material_bind_group, </span><span style="color:#859900;">&amp;</span><span style="color:#657b83;">[])</span><span>; </span><span> render_pass.</span><span style="color:#859900;">draw</span><span style="color:#657b83;">(</span><span>x</span><span style="color:#859900;">..</span><span style="color:#657b83;">(</span><span>x </span><span style="color:#657b83;">+ </span><span style="color:#6c71c4;">3</span><span style="color:#657b83;">)</span><span>, </span><span style="color:#6c71c4;">0</span><span style="color:#859900;">..</span><span style="color:#6c71c4;">1</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span style="color:#657b83;">} </span><span> </span><span style="color:#657b83;">} </span><span style="color:#657b83;">} </span></code></pre> <p>Note that we're not drawing the typical 0..3 vertices for a fullscreen triangle. Instead, we're drawing 0..3 for the first material, 3..6 for the second material, 6..9 for the third material, etc.</p> <p>In the vertex shader (which is hardcoded for all materials), we can derive the material_id of the draw from the vertex index, and then use that to set the depth of the triangle.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#859900;">@</span><span>vertex </span><span style="color:#268bd2;">fn </span><span style="color:#b58900;">vertex</span><span style="color:#657b83;">(</span><span>@builtin</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">vertex_index</span><span style="color:#657b83;">) </span><span style="color:#268bd2;">vertex_input</span><span>: </span><span style="color:#268bd2;">u32</span><span style="color:#657b83;">) </span><span>-&gt; </span><span style="color:#859900;">@builtin</span><span style="color:#657b83;">(</span><span>position</span><span style="color:#657b83;">) </span><span>vec4&lt;</span><span style="color:#268bd2;">f32</span><span>&gt; </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#268bd2;">let</span><span> vertex_index </span><span style="color:#657b83;">=</span><span> vertex_input </span><span style="color:#657b83;">%</span><span> 3u; </span><span> </span><span style="color:#268bd2;">let</span><span> material_id </span><span style="color:#657b83;">=</span><span> vertex_input </span><span style="color:#657b83;">/</span><span> 3u; </span><span> </span><span> </span><span style="color:#268bd2;">let</span><span> material_depth </span><span style="color:#657b83;">= </span><span style="color:#268bd2;">f32</span><span style="color:#657b83;">(</span><span>material_id</span><span style="color:#657b83;">) / </span><span style="color:#6c71c4;">65535.0</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> uv </span><span style="color:#657b83;">= </span><span>vec2&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;</span><span style="color:#657b83;">(</span><span style="color:#859900;">vec2</span><span style="color:#657b83;">(</span><span>vertex_index </span><span style="color:#657b83;">&gt;&gt;</span><span> 1u, vertex_index </span><span style="color:#859900;">&amp;</span><span> 1u</span><span style="color:#657b83;">)) * </span><span style="color:#6c71c4;">2.0</span><span>; </span><span> </span><span> </span><span style="color:#859900;">return vec4</span><span style="color:#657b83;">(</span><span style="color:#859900;">uv_to_ndc</span><span style="color:#657b83;">(</span><span>uv</span><span style="color:#657b83;">)</span><span>, material_depth, </span><span style="color:#6c71c4;">1.0</span><span style="color:#657b83;">)</span><span>; </span><span style="color:#657b83;">} </span></code></pre> <p>The material's pipeline depth comparison function will be set to equals, so we only shade fragments for which the depth of the triangle is equal to the depth in the depth buffer. The depth buffer attached here is the material depth texture we rendered earlier. Thus, each fullscreen triangle draw per material will only shade the fragments for that material.</p> <p>Note that this is pretty inefficent if you have many materials. Each fullscreen triangle will cost an entire screen's worth of depth comparisons. In the future I'd like to switch to compute-shader based material shading.</p> <h3 id="fragment-shader">Fragment Shader<a class="zola-anchor" href="#fragment-shader" aria-label="Anchor link for: fragment-shader" style="visibility: hidden;"></a> </h3> <p>Now that we've determined what fragments to shade, it's time to apply the material's shader code to those fragments. Each fragment can sample the visibility buffer, recovering the cluster ID and triangle ID. Like before, this provides us access to the rest of the instance and mesh data.</p> <p>The remaining tricky bit is that since we're not actually rendering a mesh in the draw call, and are using a single triangle just to cover some fragments to shade, we don't have automatic interpolation of vertex attributes within a mesh triangle or screen-space derivatives for mipmapped texture sampling.</p> <p>To compute this data ourselves, each fragment can load all 3 vertices of its mesh triangle, and compute the barycentrics and derivatives manually. Big thanks to The Forge for this code.</p> <p>In Bevy, all the visibility buffer loading, data loading and unpacking, vertex interpolation calculations, etc is wrapped up in the <code>resolve_vertex_output()</code> function for ease of use.</p> <pre data-lang="rust" style="background-color:#002b36;color:#839496;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#586e75;">/// Load the visibility buffer texture and resolve it into a VertexOutput. </span><span style="color:#268bd2;">fn </span><span style="color:#b58900;">resolve_vertex_output</span><span style="color:#657b83;">(</span><span style="color:#268bd2;">frag_coord</span><span>: vec4&lt;</span><span style="color:#268bd2;">f32</span><span>&gt;</span><span style="color:#657b83;">) </span><span>-&gt; VertexOutput </span><span style="color:#657b83;">{ </span><span> </span><span style="color:#268bd2;">let</span><span> packed_ids </span><span style="color:#657b83;">=</span><span> textureLoad</span><span style="color:#657b83;">(</span><span>meshlet_visibility_buffer, vec2&lt;</span><span style="color:#268bd2;">i32</span><span>&gt;</span><span style="color:#657b83;">(</span><span>frag_coord.xy</span><span style="color:#657b83;">)</span><span>, </span><span style="color:#6c71c4;">0</span><span style="color:#657b83;">)</span><span>.r; </span><span> </span><span style="color:#268bd2;">let</span><span> cluster_id </span><span style="color:#657b83;">=</span><span> packed_ids </span><span style="color:#657b83;">&gt;&gt;</span><span> 6u; </span><span> </span><span style="color:#268bd2;">let</span><span> meshlet_id </span><span style="color:#657b83;">=</span><span> meshlet_cluster_meshlet_ids</span><span style="color:#657b83;">[</span><span>cluster_id</span><span style="color:#657b83;">]</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> meshlet </span><span style="color:#657b83;">=</span><span> meshlets</span><span style="color:#657b83;">[</span><span>meshlet_id</span><span style="color:#657b83;">]</span><span>; </span><span> </span><span style="color:#268bd2;">let</span><span> triangle_id </span><span style="color:#657b83;">=</span><span> extractBits</span><span style="color:#657b83;">(</span><span>packed_ids, 0u, 6u</span><span style="color:#657b83;">)</span><span>; </span><span> </span><span> </span><span style="color:#586e75;">// ... </span><span> </span><span> </span><span style="color:#586e75;">// https://github.com/ConfettiFX/The-Forge/blob/2d453f376ef278f66f97cbaf36c0d12e4361e275/Examples_3/Visibility_Buffer/src/Shaders/FSL/visibilityBuffer_shade.frag.fsl#L83-L139 </span><span> </span><span style="color:#268bd2;">let</span><span> partial_derivatives </span><span style="color:#657b83;">= </span><span style="color:#859900;">compute_partial_derivatives</span><span style="color:#657b83;">( </span><span> </span><span style="color:#859900;">array</span><span style="color:#657b83;">(</span><span>clip_position_1, clip_position_2, clip_position_3</span><span style="color:#657b83;">)</span><span>, </span><span> frag_coord_ndc, </span><span> view.viewport.zw, </span><span> </span><span style="color:#657b83;">)</span><span>; </span><span> </span><span> </span><span style="color:#586e75;">// ... </span><span> </span><span> </span><span style="color:#268bd2;">let</span><span> world_position </span><span style="color:#657b83;">= </span><span style="color:#859900;">mat3x4</span><span style="color:#657b83;">(</span><span>world_position_1, world_position_2, world_position_3</span><span style="color:#657b83;">) *</span><span> partial_derivatives.barycentrics; </span><span> </span><span style="color:#268bd2;">let</span><span> uv </span><span style="color:#657b83;">= </span><span style="color:#859900;">mat3x2</span><span style="color:#657b83;">(</span><span>vertex_1.uv, vertex_2.uv, vertex_3.uv</span><span style="color:#657b83;">) *</span><span> partial_derivatives.barycentrics; </span><span> </span><span> </span><span style="color:#268bd2;">let</span><span> ddx_uv </span><span style="color:#657b83;">= </span><span style="color:#859900;">mat3x2</span><span style="color:#657b83;">(</span><span>vertex_1.uv, vertex_2.uv, vertex_3.uv</span><span style="color:#657b83;">) *</span><span> partial_derivatives.ddx; </span><span> </span><span style="color:#268bd2;">let</span><span> ddy_uv </span><span style="color:#657b83;">= </span><span style="color:#859900;">mat3x2</span><span style="color:#657b83;">(</span><span>vertex_1.uv, vertex_2.uv, vertex_3.uv</span><span style="color:#657b83;">) *</span><span> partial_derivatives.ddy; </span><span> </span><span> </span><span style="color:#586e75;">// ... </span><span style="color:#657b83;">} </span></code></pre> <h2 id="downsample-depth-again">Downsample Depth (Again)<a class="zola-anchor" href="#downsample-depth-again" aria-label="Anchor link for: downsample-depth-again" style="visibility: hidden;"></a> </h2> <p>Lastly, for next frame's first culling pass, we're going to need the previous frame's depth pyramid. This is where we'll generate it. We'll use the same exact process that we used for the first depth downsample, but this time we'll use the depth buffer generated as a result of the second raster pass, instead of the first.</p> <h1 id="future-work">Future Work<a class="zola-anchor" href="#future-work" aria-label="Anchor link for: future-work" style="visibility: hidden;"></a> </h1> <p>And with that we're done with the frame breakdown. I've covered all the major steps and shaders of how virtual geometry will work in Bevy 0.14. I did skip some of the CPU-side data management, but it's fairly boring and subject to a rewrite soon anyways.</p> <p>However, Bevy 0.14 is just the start. There's tons of improvements I'm hoping to implement in a future version, such as:</p> <ul> <li>Major improvements to the rasterization passes via software rasterization, and trying out my multi draw with bins idea for hardware raster</li> <li>Copying Nanite's idea of culling and LOD selection via persistent threads. This should let us eliminate the separate fill_cluster_buffers step, speedup culling, and remove the need for large 3d dispatches over all clusters in the scene</li> <li>Compressing asset vertex data by using screen-derived tangents and octahedral-encoded normals, and possibly position/UV quantization</li> <li>Performance, quality, reliability, and workflow improvements for the mesh to meshlet mesh asset preprocessing</li> <li>Compute-based material shading passes instead of the fullscreen triangle method, and possibly software variable rate shading, inspired by Unreal Engine 5.4's <a rel="nofollow noreferrer" href="https://www.unrealengine.com/en-US/blog/take-a-deep-dive-into-nanite-gpu-driven-materials">GPU-driven Nanite materials</a> and <a rel="nofollow noreferrer" href="http://filmicworlds.com/blog/visibility-buffer-rendering-with-material-graphs">this set of blog posts</a> from John Hable</li> <li>Streaming in and out asset data from/to disk instead of keeping all of it in memory all the time</li> </ul> <p>With any luck, and a lot of hard work, I'll be back for another blog post about all these changes in the future. Until then, enjoy Bevy 0.14!</p> Bevy's Third Birthday - Reflections on Rendering 2023-09-12T00:00:00+00:00 2023-09-12T00:00:00+00:00 Unknown https://jms55.github.io/posts/2023-09-12-bevy-third-birthday/ <blockquote> <p>Written in response to <a rel="nofollow noreferrer" href="https://bevyengine.org/news/bevys-third-birthday">Bevy's Third Birthday</a>.</p> </blockquote> <h1 id="introduction">Introduction<a class="zola-anchor" href="#introduction" aria-label="Anchor link for: introduction" style="visibility: hidden;"></a> </h1> <blockquote> <p>You can skip this section if you're only interested in hearing about Bevy - we'll get to that in a minute.</p> </blockquote> <h2 id="who-am-i">Who am I?<a class="zola-anchor" href="#who-am-i" aria-label="Anchor link for: who-am-i" style="visibility: hidden;"></a> </h2> <p>Hi, I'm JMS55, and I've been working on Bevy's 3D renderer for the past ~10 months.</p> <p>I've also been involved in the Rust gamedev community for a long time:</p> <ul> <li>I have been using Rust since pre-1.0 (around ~7 years ago).</li> <li>Tried out Piston when it first came out; same with Amythest.</li> <li>Contributed a (very tiny) bit to <a rel="nofollow noreferrer" href="https://veloren.net">Veloren</a>.</li> <li><a rel="nofollow noreferrer" href="https://github.com/JMS55/botnet#botnet">Wrote a demo</a> for a cool RTS simulation kind of game where you program your units via Rust-compiled-to-WASM (and would love to get back to it at some point), using Wasmtime and Macroquad.</li> <li><a rel="nofollow noreferrer" href="https://github.com/JMS55/sandbox#sandbox">Wrote a falling sand game</a> using pixels, wgpu, and imgui-rs (I also tried egui and yakui). I wrote the shaders for it in GLSL - this was before wgpu started using WGSL!</li> </ul> <p><a rel="nofollow noreferrer" href="https://github.com/parasyte/pixels">Pixels</a> was the first time I ever made a non-trivial contribution to an open source library. Now, I'm working on Bevy pretty much daily!</p> <h2 id="contributing-to-bevy">Contributing to Bevy<a class="zola-anchor" href="#contributing-to-bevy" aria-label="Anchor link for: contributing-to-bevy" style="visibility: hidden;"></a> </h2> <p>First, some overall thoughts on my experience contributing to Bevy.</p> <p>It's been exceedingly rewarding to work on an open source project with this kind of community. Before Bevy, I mainly worked on my own projects, and inevitably got burnt out. It's hard to maintain motivation when you're the only one involved. It's been super energizing getting to bounce ideas off of the other amazing developers working on Bevy!</p> <p>Additionally, seeing code I write translate directly into real-world use is awesome. Thank you to all the other developers and users of Bevy!</p> <p>If you're a user of Bevy, and are thinking about getting involved in Bevy's development, I highly recommended it! One of the great things about Bevy is it's modularity and focus on ECS. "Engine code" and "user code" are not substantially different. If you've used Bevy before, chances are you can write code <em>for</em> Bevy.</p> <p>Quoting Cart, "Bevy users <em>are</em> bevy developers, they just don't know it yet". The developers and community are super friendly. Feel free to join our <a rel="nofollow noreferrer" href="https://discord.com/invite/bevy">Discord</a>, pick a topic you find interesting - say, #rendering-dev :) - and starting asking lots of questions!</p> <h2 id="post-overview">Post overview<a class="zola-anchor" href="#post-overview" aria-label="Anchor link for: post-overview" style="visibility: hidden;"></a> </h2> <p>With that out of the way: this blog post will be my reflection on nearly a year of Bevy development (Bevy 0.9-dev to 0.12-dev). Specifically, bevy_pbr, bevy_core_pipeline, and bevy_render, along with some related crates such as wgpu and naga. I (mostly) won't be talking about the ECS, 2D renderer, UI, and other areas of Bevy.</p> <p>I'll be covering what I (and others) worked on, what went well, important items we need to spend time developing, and some new features I'm excited to work on in the coming months.</p> <h1 id="this-year">This Year<a class="zola-anchor" href="#this-year" aria-label="Anchor link for: this-year" style="visibility: hidden;"></a> </h1> <h2 id="what-we-achieved">What we achieved<a class="zola-anchor" href="#what-we-achieved" aria-label="Anchor link for: what-we-achieved" style="visibility: hidden;"></a> </h2> <p>This year, I've worked on and merged the following features:</p> <ul> <li>Bloom (In collaboration with others) (Bevy 0.9, 0.10)</li> <li>EnvironmentMapLight (IBL) (Bevy 0.10)</li> <li>Temporal antialiasing (TAA) (Bevy 0.11)</li> <li>Screen-space ambient occlusion (SSAO) (Bevy 0.11)</li> <li>Skybox (Bevy 0.11)</li> </ul> <p>I've also worked on, and either didn't end up merging or am still working on:</p> <ul> <li>Percentage-closer filtering (PCF) for smoothing out the edges of shadows</li> <li>Automated EnvironmentMapLight generation (replacing glTF-IBL-Sampler)</li> <li>Support for AMD's FSR and Nvidia's DLSS upscalers</li> <li>Multithreaded rendering for improved performance</li> <li>Clear coat layer for StandardMaterial</li> <li>GPU pass timing overlay for profiling the renderer (GPU timestamps)</li> <li>Ergonomic improvements for the renderer internals</li> <li>Real-time fully dynamic global illumination (more on this later!)</li> </ul> <p>and many other smaller PRs not interesting enough to mention, new examples contributed, discussion posts and conversations, bug investigations, performance profiling sessions, and reviewing other peoples' PRs.</p> <p><img src="https://jms55.github.io/posts/2023-09-12-bevy-third-birthday/ssao.png" alt="ssao" /> <img src="https://jms55.github.io/posts/2023-09-12-bevy-third-birthday/taa.png" alt="taa" /> <img src="https://jms55.github.io/posts/2023-09-12-bevy-third-birthday/bloom.png" alt="bloom" /></p> <p>Additional major rendering features that we merged, but that I did not directly work on include:</p> <ul> <li>Fast approximate antialiasing (FXAA)</li> <li>Depth and normal prepasses</li> <li>Cascaded shadow maps (CSM)</li> <li>Fog effects</li> <li>Better tonemapping</li> <li>Morph targets</li> <li>A complete revamp of rendering system sets</li> <li>Ergonomic improvements for render node APIs</li> <li>Many performance improvements</li> </ul> <h2 id="things-i-feel-went-well">Things I feel went well<a class="zola-anchor" href="#things-i-feel-went-well" aria-label="Anchor link for: things-i-feel-went-well" style="visibility: hidden;"></a> </h2> <p>Overall, I'm fairly satisfied both with what Bevy has accomplished, and what I've personally learned and accomplished this year.</p> <p>Bevy has gone from "we have some basic PBR shaders with analytic direct lighting" to ~70% of the way to a fully production-ready, indie game-usable renderer with much fewer caveats, and much fancier lighting and post processing!</p> <p>Take Slime Rancher, a hit indie game from 2017. <a rel="nofollow noreferrer" href="https://pixelalchemy.dev/posts/a-frame-of-slime-rancher">This post</a> goes into detail on the tricks and rendering techniques the game used to achieve its graphics. Bevy 0.11 has support for almost all of the listed techniques! The only thing we're missing are decals, and refraction (although there's a PR open that implements screen-space refaction!).</p> <p>I would specifically like to note the <em>amount</em> of people working on rendering features, and how it's increased over time. It's a great sign to see that rendering isn't the domain of only 1 or 2 dedicated developers. Rather, we have a fairly large amount of people contributing major rendering features and improvements.</p> <p>Too often, I feel rendering is seen as a kind of opaque witchcraft. To some extent, I feel that perception is true. Writing a shader (GPU program) is not like writing a program for the CPU in Rust. The graphics APIs themselves (in our case, wgpu) are not the most intuitive, and are often subject to compatibility or performance constraints that lead to poor ergonomics. Furthermore, even if you can write a shader, and know the graphics APIs, it's not always clear <em>how</em> to assemble all that together into a performant, compatible, ergonomic renderer.</p> <p>Here's where the "but" comes: I don't think it's that much worse than programming any other part of a game engine in general. Designing a complete UI system, or an ECS, or a physics library, etc, is rarely a simple one person job. Designing a rendering engine is much the same.</p> <p>Bevy has been able to consistently attract new rendering developers, often with little or no professional rendering experience. To me, that's an encouraging sign that we're doing something right. We may not rival Unity's or Godot's renderers <em>now</em>, but in another year, I'm confident that we'll surpass them in a few areas, and at least match them in most of the important ones :)</p> <h2 id="things-i-feel-we-need-to-work-on">Things I feel we need to work on<a class="zola-anchor" href="#things-i-feel-we-need-to-work-on" aria-label="Anchor link for: things-i-feel-we-need-to-work-on" style="visibility: hidden;"></a> </h2> <p>Now that we've covered what I felt went well, it's time to talk about things we need to improve on. These are pain points either I or other developers have consistently faced, or things I've seen users brought up many times.</p> <p>In no specific order, here are some things I feel we need to prioritize.</p> <h3 id="more-dynamic-comprehensive-and-accessible-test-scenes">More dynamic, comprehensive, and accessible test scenes<a class="zola-anchor" href="#more-dynamic-comprehensive-and-accessible-test-scenes" aria-label="Anchor link for: more-dynamic-comprehensive-and-accessible-test-scenes" style="visibility: hidden;"></a> </h3> <p>Most rendering development is currently done with either dedicated Bevy example scenes, or Lumberyard's Bistro or Intel's Sponza scenes. The former tend to be too simple for more intensive rendering tests, and the latter are difficult to setup, and don't have the dynamism a real game would have. Furthermore, we don't have any scenes that excercise <em>all</em> of Bevy's rendering features at once, and how they might interact.</p> <p>It would be great to get more test scenes that are easy to setup and tweak, demonstrate many of Bevy's rendering features working in tandem, and overall provide real-world uses cases that we can test against, rather than toy scenes. Currently, thoroughly exercising a new rendering feature or performance change requires almost as much work as writing the feature itself. To some extent, I'm asking us to develop a small game, focused on polished rendering and animation.</p> <h3 id="performance">Performance<a class="zola-anchor" href="#performance" aria-label="Anchor link for: performance" style="visibility: hidden;"></a> </h3> <p>A fact that is probably surprising to developers without much experience in rendering is that Bevy's renderer performance is currently heavily CPU-limited - not GPU-limited, as you might expect. There's two factor to this:</p> <ol> <li>Bevy is inefficent with how it stores and uses rendering data</li> <li>Bevy makes too many draw calls, and does too much state binding changes between draws</li> </ol> <p>In order to become a serious renderer, we'll need to dramatically improve our CPU performance. Thankfully, we have a <em>lot</em> of changes in progress towards this goal. Many core parts of the renderer that have been neglected in favor of working on new features are being revamped and improved. Expect large performance gains in Bevy 0.12, and probably 0.13.</p> <p>Long-term, we'll want to support GPU-driven rendering, where the GPU handles almost all of the rendering work. An extreme example of this kind of architecture is Unreal Engine's Nanite, which is capable of rendering micro-poly meshes. We (almost certainly) won't go <em>that</em> far, but implementing 60% of the techniques (bindless, draw indirect, compute-based rasterizer, compute-based fustrum culling, two pass occlusion culling, and also asset streaming) should give us 90% of the benefit, and allow complex scenes with many orders of magnitude greater amounts of meshes. This is an exciting area to work on, and there's a lot to do!</p> <h3 id="documentation">Documentation<a class="zola-anchor" href="#documentation" aria-label="Anchor link for: documentation" style="visibility: hidden;"></a> </h3> <p>While performance and new features have been steadily improving, documentation, not so much. The docs for bevy_render, bevy_core_pipeline, and bevy_pbr are, uhh, sparse at best. Frequent questions I see include "how do I do &lt;custom kind of rendering&gt;, which should be a fairly routine kind of extension, but I have no idea where to start integrating it with Bevy", "what shader imports are available", "what does this error mean", and "my rendering looks bad / performance is bad, how do I improve this and understand why?"</p> <p>We need to write more API docs, more module docs, and more long-form guides on how Bevy's renderer is structured and how to achieve common tasks. This is something I've been wanting to work on, but much like blogging, I've discovered writing clear, useful docs is quite hard. This is a great area of new Bevy devs to get involved in!</p> <blockquote> <p>As an aside, this is my first blog post. It's been something I've been meaning to do for many years, but never actually gotten around to doing. Writing a blog post is a <em>lot</em> of work, and there's the temptation to polish the writing until it's perfect. Doing so would leave me no time to actually work on rendering!, so I'm going ahead and publishing this despite the fact that it's not perfect :)</p> </blockquote> <h3 id="ease-of-use">Ease of use<a class="zola-anchor" href="#ease-of-use" aria-label="Anchor link for: ease-of-use" style="visibility: hidden;"></a> </h3> <p>Similarly to the above section, while our features may be pretty good, internally, they're not that great to write. The main rendering pass APIs are too abstract, and go through many traits, generic systems, and levels of indirection that greatly complicate understanding the renderer.</p> <p>Writing new post processing or lighting passes involves a <em>lot</em> of boilerplate, especially around bind groups and resource/pipeline creation.</p> <p>These two things are also a large barrier to entry in getting new contributors to work on rendering.</p> <p>Finally, the core Material code is brittle and not very extensible. A Bevy user's options are to either use StandardMaterial, or write a completely custom material from scratch. There's no easy way to do something like take the StandardMaterial, but animate the texture UVs according to this shader fragment, and then pass the result into some other shader fragment. Furthermore, writing the shader for a custom material involves some complicated coordination between vertex and fragment stage input and output types, and data bindings. Mismatched bindings or types is a common source of confusing errors for authors of custom materials. People have floated some ideas on how to improve this, but not a ton of concrete code yet. It's something we'll need to work on going forwards.</p> <h3 id="review-speed">Review speed<a class="zola-anchor" href="#review-speed" aria-label="Anchor link for: review-speed" style="visibility: hidden;"></a> </h3> <p>We have too many open PRs, and not enough reviewers! Reviews take a <em>long</em> time. Part of this is the fact that like I talked about above, rendering boilerplate can get pretty gnarly. Reviewing a rendering PR often involves one session to review the CPU-side code changes, and another entirely to review the GPU-side code. If we can improve rendering boilerplate, we will not only make it easier to write new features, but shorten the (currently fairly substantial) review time each feature has to go through before it can be merged.</p> <p>Another issue is testing. Testing rendering does not lend itself well to unit tests. You need a variety of scenes, setups, and specific GPU and OS platforms (platform-specific bugs are sadly common, and feature support and performance widely varies). This all slows down reviews, and often we miss fairly impactful rendering bugs anyways.</p> <h3 id="ecosystem-investment">Ecosystem investment<a class="zola-anchor" href="#ecosystem-investment" aria-label="Anchor link for: ecosystem-investment" style="visibility: hidden;"></a> </h3> <p>First, I'd like to thank the maintainers of the wgpu and naga crates, of which bevy_render sits atop, for their awesome work. Bevy's renderer would not be possible without them!</p> <p>These crates, however, form an entirely new graphics API/toolchain, with a focus on wide compatibility and API safety. They don't currently support some of the latest GPU features such as ray tracing, mesh shaders, threadgroup/wave/warp intrinsics, async compute, and mutable binding arrays (bindless textures), or have specific caveats. This is totally understandable after all - not many developers want or need these things, and it's not WebGPU's focus.</p> <p>The solution is of course for us to invest more time in writing those features and helping them out ourselves :). I'm not sure how we foster it, but it would be great to see more investment in wgpu, naga, and naga_oil from Bevy's developers.</p> <h3 id="bevy-editor">Bevy editor<a class="zola-anchor" href="#bevy-editor" aria-label="Anchor link for: bevy-editor" style="visibility: hidden;"></a> </h3> <p>This isn't quite rendering related, but like everyone else, I'm eagerly awaiting bevy_editor. It'll be super useful for testing out rendering features, as clicking through GUI buttons is much easier than writing a system to manually toggle several features on and off with keypresses and rendering an in-game UI to show the enabled settings.</p> <p>I'm also really looking forward to <em>developing</em> Bevy's editor. I originally joined this project to do just that, and somehow ended up working on rendering instead! I mentioned before my Rust gamedev experience, but I also have a lot of experience with Rust UI dev and UI dev in general.</p> <p>The key missing parts are twofold:</p> <ul> <li>An ergonomic, reactive, pretty, capable, and scalable UI system</li> <li>Concrete direction on how the editor will actually operate (as a seperate process with message passing, as a bevy_app plugin to the game process, etc)</li> </ul> <p>I'm interested in doing the work of designing the UI for the editor and writing all the UI code and features, but not so much figuring out the basic foundations. Hopefully others will take on this task :)</p> <h1 id="things-i-m-excited-to-work-on-in-the-next-year">Things I'm excited to work on in the next year(?)<a class="zola-anchor" href="#things-i-m-excited-to-work-on-in-the-next-year" aria-label="Anchor link for: things-i-m-excited-to-work-on-in-the-next-year" style="visibility: hidden;"></a> </h1> <p>Finally, I'd like to mention some things I'm excited to work on! Some of these I've already talked about, and others less so:</p> <ul> <li>FSR / DLSS</li> <li>Procedural skybox</li> <li>Bevy editor</li> <li>GPU driven rendering</li> <li>Profiling tools and system/entity tracing and statistics</li> <li>OpenPBR material</li> <li>Raytraced direct lighting (ReSTIR DI / RTX DI)</li> <li>Screen-space reflections, indirect lighting, and SSAO improvements</li> <li>Global illumination</li> </ul> <h2 id="bevy-solari">Bevy Solari<a class="zola-anchor" href="#bevy-solari" aria-label="Anchor link for: bevy-solari" style="visibility: hidden;"></a> </h2> <p>It's not something I've mentioned at all yet, but one of the things I've been spending a <em>lot</em> of time on the past several months is a project I'm calling bevy_solari.</p> <p>Bevy currently has support for direct lighting - i.e., simulating the light coming from a light source, and hitting a surface. In real life however, light dosen't just stop at the first surface it hits. Light bounces around a scene, leading to mirror or blurry reflections, color bleeding, micro-shadows, and more. Simulating these many bounces of light is called global illumination (GI), and tends to be very expensive and slow to do in real time. Without GI, however, lighting tends to look kinda off, and a lot less prettier.</p> <p>Most games tend to approximate global illumination via baked static lighting methods such as lightmaps, irradiance volumes, and environment maps, as well as very limited dynamic methods such as planar reflections, light/reflection probes, and screen-space raytracing. Of these, Bevy currently only supports environment maps and SSAO, although I know that some people are working on implementing the other methods.</p> <p>Thanks to recent advances in GPU hardware and algorithm development, however, fully dynamic, real time global illumination has become feasible. The field is rapidly developing, but there's been many promising approaches including Tomasz Stachowiak's Kajiya, DDGI, Unreal Engine's Lumen (as seen in Unreal's Lumen in the Land of Nanite demo, as well as Fornite), Nvidia's ReSTIR GI / RTX GI (as seen in Cyberpunk 2077), AMD's GI-1.0, and Alexander Sannikov's Radiance Cascades (as seen in the recent Path of Exile 2). It's a <em>super</em> exciting area of research, and something I've been having an absolute (and sometimes frustrating!) blast learning.</p> <p>There's too much literature and detail to cover here (it deserves, and may eventually get, its own blog post), but suffice it to say that I've been working on my own GI system for Bevy inspired by many of these techniques. It utilizes GPU hardware-accelerated raytracing, and is targeted at high end GPUs. It's not going to be released any time soon, partially due to wgpu lacking official raytracing support, and partially due to the massive amount of work and experimentation I still need to do. However, it's open source, so feel free to try the <a rel="nofollow noreferrer" href="https://github.com/JMS55/bevy/tree/solari">demo example here</a>. Run <code>cargo run --example solari</code> from the repo root.</p> <p>Below are some static screenshots of the renderer, but keep in mind that this is all running in realtime on a Nvidia RTX 3080 GPU, and is fully dynamic with movable camera, lights, and objects :)</p> <blockquote> <p>Bevy Solari in a cornell box scene - with GI <img src="https://jms55.github.io/posts/2023-09-12-bevy-third-birthday/solari.png" alt="bevy_solari in a cornell box scene" /></p> </blockquote> <blockquote> <p>Direct light only - no GI <img src="https://jms55.github.io/posts/2023-09-12-bevy-third-birthday/solari_direct_only.png" alt="bevy_solari - direct light only - no GI" /></p> </blockquote> <blockquote> <p>Indirect irradiance debug view <img src="https://jms55.github.io/posts/2023-09-12-bevy-third-birthday/solari_indirect.png" alt="bevy_solari - indirect irradiance debug view" /></p> </blockquote> <blockquote> <p>World irradiance cache debug view <img src="https://jms55.github.io/posts/2023-09-12-bevy-third-birthday/solari_world_cache.png" alt="bevy_solari - world irradiance cache debug view" /></p> </blockquote>