<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.3.2">Jekyll</generator><link href="https://osor.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://osor.io/" rel="alternate" type="text/html" /><updated>2026-03-28T23:23:28+00:00</updated><id>https://osor.io/feed.xml</id><title type="html">osor.io</title><subtitle>Rubén Osorio&apos;s blog</subtitle><author><name>Rubén Osorio López</name></author><entry><title type="html">Rendering Crispy Text On The GPU</title><link href="https://osor.io/text.html" rel="alternate" type="text/html" title="Rendering Crispy Text On The GPU" /><published>2025-06-12T00:00:00+01:00</published><updated>2025-06-12T00:00:00+01:00</updated><id>https://osor.io/text</id><content type="html" xml:base="https://osor.io/text.html"><![CDATA[<p>
    <video loop="" autoplay=""> 
        <source src="text/meme_article_pink.mp4" type="video/mp4" />
    </video>
</p>

<p>It’s not the first time I’ve fallen down the rabbit-hole of rendering text in real time. Every time I’ve looked into it an inkling of dissatisfaction always remained, either aliasing, large textures, slow build times, minification/magnification, smooth movement, etc.</p>

<p>Last time I landed on a solution using <a href="https://github.com/Chlumsky/msdfgen">Multi-Channel Signed Distance Fields (SDFs)</a> which was working well. However there was still a few things that bothered me and I just needed a little excuse to jump back into the topic.</p>

<p>That excuse came in the form of getting a new monitor of all things. One of those new OLEDs that look so nice, but that have fringing issues because of their non-standard subpixel structure. I got involved in a <a href="https://github.com/microsoft/PowerToys/issues/25595">GitHub issue</a> discussing this and of course posted my <a href="https://github.com/microsoft/PowerToys/issues/25595#issuecomment-1870511297">unrequested take</a> on how to go about it. This was the last straw I needed to go try and implement glyph rendering again, this time with subpixel anti-aliasing.</p>

<p>Just to start things off, here is a test with a bunch of fonts, trying to test the most common styles, rounded, sharp, very thin lines, etc.</p>

<p><img src="text/lorem_ipsum_high_res.png" alt="" />
<em>This one is higher resolution, recommended to open in a new tab and visualize at native 100% zoom if possible</em></p>

<p>And a cheeky menu to show it in movement, along with a console and the previously show demo text.</p>

<p>
    <video loop="" autoplay=""> 
        <source src="text/other_text_example.mp4" type="video/mp4" />
    </video>
</p>

<p>An important <strong>disclaimer</strong> about showing images and videos in this post is that artifacts might show due to minification/magnification, pixel alignment, and even <a href="https://geometrian.com/resources/subpixelzoo/">subpixel structure</a>.</p>

<h1 id="but-why-though">But Why Though?</h1>

<p>There was a few things that were bothering me about using SDFs, the main ones being:</p>

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

<p>Certain fonts would struggle to render nicely, especially ones with thin features or lots of detail. SDFs represent “blobby” glyphs nicely, and even simple sharp glyphs if you go the multi-channel route. But at some point you need to increase resolution to get rid of the artifacts.</p>

<p>Here you can see an example of Miama switching between SDF and the new method, note how the thin features were often getting lost, as well as how the “f” was struggling due to its size.</p>

<p>
    <video loop="" autoplay=""> 
        <source src="text/comparing_miama_with_sdf_and_rendered_directly.mp4" type="video/mp4" />
    </video>
</p>

<h2 id="atlas-size">Atlas size</h2>

<p>The SDFs are generated offline then stored to an atlas. Even though SDFs require way less resolution for good output quality, you still need something. Especially for fonts with <strong>a lot</strong> of glyphs this was adding up. I even tried some fonts for Japanese and Chinese which I couldn’t realistically bake to a single atlas due to how big they would have been.</p>

<p>Here you can see the atlas for Miama, which even as a font for only Latin languages without that many special characters comes to a resolution of $4096\times1152$ with each glyph taking a $64\times64$ region.</p>

<p><img src="text/msdf_atlas_example_no_alpha.png" alt="" /></p>

<p>Having multiple fonts available at runtime was adding a significant memory cost and getting them in and out was some significant streaming bandwidth. And the more fonts the bigger the issue.</p>

<h2 id="flexibility">Flexibility</h2>

<p>In general, I found it fiddly to get around issues like minification or implementing new ideas like the subpixel anti-aliasing that kickstarted all this. For a while I also wanted to work with potentially any vector image, which would have required baking, so couldn’t be generated or edited at runtime.</p>

<h2 id="simplicity">Simplicity</h2>

<p>Working with intermediate steps that transform the source data is a raw increase in complexity of the whole system, even if some of that complexity is hidden by some library that could take the glyphs and bake them, it’s still there.</p>

<p>A solution that more directly takes the raw input in the form of the bezier curves that the glyph creator made would be conceptually simpler. Over time I’ve come to appreciate solutions that have less moving parts and where the flow from source data to the desired result is as simple and understandable as possible.</p>

<h1 id="what-now">What Now?</h1>

<p><img src="text/what_now.png" alt="" /></p>

<p>The idea is fairly simple, instead of baking anything to textures, grab the curves that define the <strong>currently visible</strong> glyphs themselves, send them to the GPU and <em>somehow</em> rasterize them. In a way you can see this as moving the necessary rasterization step that previously was offline to be done at runtime.</p>

<p>This would take much less storage compared to the cost per-glyph of a cell in an atlas, it would allow for them to look good at any resolution since we’re rendering the vector representation directly and it would play nice with things like subpixel anti-aliasing, where instead of computing coverage for a single pixel, we’d do it for each of the subpixel elements.</p>

<p>As a very short summary, the solution consist of loading the glyph curve data directly, rasterize them at runtime to an atlas and sample said atlas as required to render the visible glyphs.</p>

<p>The sauce here is keeping glyphs in the atlas as long as they keep being used in subsequent frames. This allows to accumulate and refine the rasterization results, to the extent of getting very high quality sub-pixel anti-aliasing.</p>

<p>I’ll give an overview of the whole pipeline here in execution order. From loading the raw font until they end up on the screen.</p>

<h2 id="processing-the-quadratic-bezier-curves">Processing the Quadratic Bezier Curves</h2>

<p>I’m using <a href="https://freetype.org/">FreeType</a> in an offline tool as an intermediary way to load any of the font formats they support. Then I traverse the curves of each glyph and store them in my asset format that will get passed to the GPU.</p>

<p>The glyphs may contain either <strong>lines</strong>, <strong>quadratic beziers</strong> (3 points) or <strong>cubic beziers</strong> (4 points). To allow for a simpler shader I convert all of these to quadratic beziers.</p>

<p>To transform a <strong>line</strong> to a quadratic bezier is fairly obvious, just create a new control point exactly in the middle of the two existing ones:</p>

<pre><code class="language-jai">// Given the two points for the line
p0 := /*...*/;
p1 := /*...*/;

// Create a new control point in the middle
m := lerp(p0, p1, 0.5);

// And create a quadratic bezier with those
new_curve(p0,m,p1);
</code></pre>

<p>Transforming a <strong>cubic bezier</strong> curve to a quadratic one implies lowering it’s order, which is necessarily a lossy process. In this case I’m choosing to always split the cubic bezier into two quadratics, which works well in all the fonts I’ve tried:</p>

<pre><code class="language-jai">// Given these cubic bezier points
p0 := /*...*/;
p1 := /*...*/;
p2 := /*...*/;
p3 := /*...*/;

// Calculate these extra control points
c0 := lerp(p0, p1, 0.75);
c1 := lerp(p3, p2, 0.75);
m  := lerp(c0, c1, 0.5);

// And create two quadratic bezier curves
new_curve(p0,c0,m);
new_curve(m,c1,p3);
</code></pre>

<p>Here you have a <a href="https://www.desmos.com/calculator/oxeoovjjwk">desmos graph</a> where you can move the points around and see the input cubic bezier and the resulting two quadratic ones.</p>

<iframe src="https://www.desmos.com/calculator/oxeoovjjwk?embed" width="1000" height="624"></iframe>
<p><br /></p>

<p>There’s much more interesting ways to do this split that would reduce the error further, but this works fairly well for the majority of cubic beziers found in the fonts I’ve tried. It’s also possible to use offline tools to do a higher quality transformation into a format that only has quadratic beziers like TrueType (.ttf) which would avoid this transformation altogether.</p>

<p>Here’s some of the points after being loaded, the blue points being the ones that define the beginning and end of the bezier curve (or <em>on</em> points) and the red ones being the middle point of each bezier, defining how it curves (or <em>off</em> points).</p>

<p>
    <video loop="" autoplay=""> 
        <source src="text/loading_glyph_curves.mp4" type="video/mp4" />
    </video>
</p>

<h2 id="calculating-coverage">Calculating Coverage</h2>

<p>Here I’m not doing anything particularly interesting or different than what you might find elsewhere. A ray is shot horizontally, left-to-right on a per-pixel basis, testing against the curves for intersections and accumulating a winding number to see if it’s considered outside (zero) or inside (non-zero). At the end of the day is “just” solving a quadratic equation.</p>

<p>My favorite explanation of the math behind this, with some extra neat diagrams, is in the <a href="https://github.com/GreenLightning/gpu-font-rendering#method">read-me of this GitHub repository by GreenLightning</a> explaining his GPU Font Rendering approach. It would also be a <strong>crime</strong> not to link to <a href="https://www.youtube.com/watch?v=SO83KQuuZvg">Sebastian Lague’s Rendering Text video</a> where he covers the principles behind glyph rasterization and his adventures making his solution better. If you’re interested in the source code as well, both of these links can sort you out.</p>

<p>Something worth mentioning is that there can be issues in this step due to inaccuracies on the intersection computation, as the links above already mention. Since I knew I would be accumulating hundreds of samples over time I chose not to do anything explicitly about that at this stage and this has proven to be the right decision so far.</p>

<p>Most of these inaccuracies happen when the samples are at a very specific height and these <em>can</em> still happen in my implementation. That said, maybe one or two samples out of a few hundreds can have incorrect coverage in the worst case but after averaging these are not visible.</p>

<p>At the time of writing I’m accumulating up to 512 samples per-glyph if it stays on screen. If a single sample goes wrong, that means that the pixel is outputting $1/512=0.00195$ or $511/512=0.99804$ instead of $0$ and $1$ respectively which is imperceptible in practice. Furthermore, you could have a threshold where you clamp to the extremes if the coverage is close, making these $0.002$ and $0.998$ be evaluated as $0$ and $1$ respectively.</p>

<p>For completeness, here’s the code to compute the coverage. It iterates over a bitset to access the relevant curves of the glyph and computes a winding number to then transform it to a coverage value. For a reference about how to compute the winding number I refer you again to <a href="https://github.com/GreenLightning/gpu-font-rendering#method">GreenLightning’s repository</a> who explains it wonderfully and provides sample code.</p>

<div class="language-hlsl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">u32</span> <span class="n">words</span><span class="p">[</span><span class="n">GLYPH_CURVE_WORD_COUNT</span><span class="p">]</span> <span class="o">=</span> <span class="cm">/* . . . */</span> <span class="c1">// Bitset marking which curves are relevant for this texel</span>

<span class="n">uint4</span> <span class="n">addend</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
<span class="k">for</span> <span class="p">(</span><span class="n">int</span> <span class="n">tick_offset</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">tick_offset</span> <span class="o">&lt;</span> <span class="n">parameters</span><span class="p">.</span><span class="n">tick_increment</span><span class="p">;</span> <span class="o">++</span><span class="n">tick_offset</span><span class="p">)</span>
<span class="p">{</span>
    <span class="kt">float2</span> <span class="n">subpixel_offset</span> <span class="o">=</span> <span class="n">quasirandom_float2</span><span class="p">(</span><span class="n">parameters</span><span class="p">.</span><span class="n">tick</span> <span class="o">+</span> <span class="n">tick_offset</span><span class="p">);</span>
    <span class="kt">float2</span> <span class="n">pixel_offset_r</span> <span class="o">=</span> <span class="nb">lerp</span><span class="p">(</span><span class="n">per_frame</span><span class="p">.</span><span class="n">subpixel_layout</span><span class="p">.</span><span class="n">r_min</span><span class="p">,</span> <span class="n">per_frame</span><span class="p">.</span><span class="n">subpixel_layout</span><span class="p">.</span><span class="n">r_max</span><span class="p">,</span> <span class="n">subpixel_offset</span><span class="p">);</span>
    <span class="kt">float2</span> <span class="n">pixel_offset_g</span> <span class="o">=</span> <span class="nb">lerp</span><span class="p">(</span><span class="n">per_frame</span><span class="p">.</span><span class="n">subpixel_layout</span><span class="p">.</span><span class="n">g_min</span><span class="p">,</span> <span class="n">per_frame</span><span class="p">.</span><span class="n">subpixel_layout</span><span class="p">.</span><span class="n">g_max</span><span class="p">,</span> <span class="n">subpixel_offset</span><span class="p">);</span>
    <span class="kt">float2</span> <span class="n">pixel_offset_b</span> <span class="o">=</span> <span class="nb">lerp</span><span class="p">(</span><span class="n">per_frame</span><span class="p">.</span><span class="n">subpixel_layout</span><span class="p">.</span><span class="n">b_min</span><span class="p">,</span> <span class="n">per_frame</span><span class="p">.</span><span class="n">subpixel_layout</span><span class="p">.</span><span class="n">b_max</span><span class="p">,</span> <span class="n">subpixel_offset</span><span class="p">);</span>

    <span class="kt">float2</span> <span class="n">uv_r</span> <span class="o">=</span> <span class="p">(</span><span class="n">local_texel_coordinates_subpixel</span> <span class="o">+</span> <span class="n">pixel_offset_r</span><span class="p">)</span> <span class="o">/</span> <span class="n">parameters</span><span class="p">.</span><span class="n">size_in_pixels</span><span class="p">;</span>
    <span class="kt">float2</span> <span class="n">uv_g</span> <span class="o">=</span> <span class="p">(</span><span class="n">local_texel_coordinates_subpixel</span> <span class="o">+</span> <span class="n">pixel_offset_g</span><span class="p">)</span> <span class="o">/</span> <span class="n">parameters</span><span class="p">.</span><span class="n">size_in_pixels</span><span class="p">;</span>
    <span class="kt">float2</span> <span class="n">uv_b</span> <span class="o">=</span> <span class="p">(</span><span class="n">local_texel_coordinates_subpixel</span> <span class="o">+</span> <span class="n">pixel_offset_b</span><span class="p">)</span> <span class="o">/</span> <span class="n">parameters</span><span class="p">.</span><span class="n">size_in_pixels</span><span class="p">;</span>

    <span class="kt">float2</span> <span class="n">em_r</span> <span class="o">=</span> <span class="nb">lerp</span><span class="p">(</span><span class="n">glyph</span><span class="p">.</span><span class="n">bbox_em_top_left</span><span class="p">,</span> <span class="n">glyph</span><span class="p">.</span><span class="n">bbox_em_bottom_right</span><span class="p">,</span> <span class="n">uv_r</span><span class="p">);</span>
    <span class="kt">float2</span> <span class="n">em_g</span> <span class="o">=</span> <span class="nb">lerp</span><span class="p">(</span><span class="n">glyph</span><span class="p">.</span><span class="n">bbox_em_top_left</span><span class="p">,</span> <span class="n">glyph</span><span class="p">.</span><span class="n">bbox_em_bottom_right</span><span class="p">,</span> <span class="n">uv_g</span><span class="p">);</span>
    <span class="kt">float2</span> <span class="n">em_b</span> <span class="o">=</span> <span class="nb">lerp</span><span class="p">(</span><span class="n">glyph</span><span class="p">.</span><span class="n">bbox_em_top_left</span><span class="p">,</span> <span class="n">glyph</span><span class="p">.</span><span class="n">bbox_em_bottom_right</span><span class="p">,</span> <span class="n">uv_b</span><span class="p">);</span>

    <span class="kt">float3</span> <span class="n">winding_number</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
    <span class="k">for</span> <span class="p">(</span><span class="n">int</span> <span class="n">word_index</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">word_index</span> <span class="o">&lt;</span> <span class="n">GLYPH_CURVE_WORD_COUNT</span><span class="p">;</span> <span class="o">++</span><span class="n">word_index</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">u32</span> <span class="n">remaining_bits</span> <span class="o">=</span> <span class="n">words</span><span class="p">[</span><span class="n">word_index</span><span class="p">];</span>
        <span class="k">while</span> <span class="p">(</span><span class="n">remaining_bits</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="n">int</span> <span class="n">bit_index</span> <span class="o">=</span> <span class="nb">firstbitlow</span><span class="p">(</span><span class="n">remaining_bits</span><span class="p">);</span>
            <span class="n">int</span> <span class="n">local_curve_index</span> <span class="o">=</span> <span class="p">(</span><span class="n">word_index</span> <span class="o">*</span> <span class="mi">32</span><span class="p">)</span> <span class="o">+</span> <span class="n">bit_index</span><span class="p">;</span>
            <span class="n">remaining_bits</span> <span class="o">^=</span> <span class="p">(</span><span class="mi">1u</span> <span class="o">&lt;&lt;</span> <span class="n">bit_index</span><span class="p">);</span>
            <span class="n">int</span> <span class="n">global_curve_index</span> <span class="o">=</span> <span class="n">glyph</span><span class="p">.</span><span class="n">curve_offset</span> <span class="o">+</span> <span class="n">local_curve_index</span><span class="p">;</span>
            <span class="n">int</span> <span class="n">first_point_index</span> <span class="o">=</span> <span class="n">global_curve_index</span> <span class="o">*</span> <span class="mi">2</span><span class="p">;</span>
            <span class="p">{</span>
                <span class="kt">float2</span> <span class="n">p0</span> <span class="o">=</span> <span class="n">point_buffer</span><span class="p">[</span><span class="n">first_point_index</span><span class="p">];</span>
                <span class="kt">float2</span> <span class="n">p1</span> <span class="o">=</span> <span class="n">point_buffer</span><span class="p">[</span><span class="n">first_point_index</span> <span class="o">+</span> <span class="mi">1</span><span class="p">];</span>
                <span class="kt">float2</span> <span class="n">p2</span> <span class="o">=</span> <span class="n">point_buffer</span><span class="p">[</span><span class="n">first_point_index</span> <span class="o">+</span> <span class="mi">2</span><span class="p">];</span>
                <span class="n">winding_number</span><span class="p">.</span><span class="n">r</span> <span class="o">+=</span> <span class="n">compute_winding_number</span><span class="p">(</span><span class="n">p0</span><span class="p">,</span> <span class="n">p1</span><span class="p">,</span> <span class="n">p2</span><span class="p">,</span> <span class="n">em_r</span><span class="p">);</span>
                <span class="n">winding_number</span><span class="p">.</span><span class="n">g</span> <span class="o">+=</span> <span class="n">compute_winding_number</span><span class="p">(</span><span class="n">p0</span><span class="p">,</span> <span class="n">p1</span><span class="p">,</span> <span class="n">p2</span><span class="p">,</span> <span class="n">em_g</span><span class="p">);</span>
                <span class="n">winding_number</span><span class="p">.</span><span class="n">b</span> <span class="o">+=</span> <span class="n">compute_winding_number</span><span class="p">(</span><span class="n">p0</span><span class="p">,</span> <span class="n">p1</span><span class="p">,</span> <span class="n">p2</span><span class="p">,</span> <span class="n">em_b</span><span class="p">);</span>
            <span class="p">}</span>
        <span class="p">}</span>
    <span class="p">}</span>
    <span class="kt">float3</span> <span class="n">coverage</span> <span class="o">=</span> <span class="nb">saturate</span><span class="p">(</span><span class="n">winding_number</span><span class="p">);</span>
    <span class="n">addend</span> <span class="o">+=</span> <span class="n">uint4</span><span class="p">(</span><span class="n">coverage</span><span class="p">,</span> <span class="mi">1</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This <code class="language-c highlighter-rouge"><span class="n">addend</span></code> simply gets added to the previous value on for that texel on the atlas, which will be explained later.</p>

<p>For the <code class="language-c highlighter-rouge"><span class="n">quasirandom_float2</span></code> I’m using the fantastic <a href="https://extremelearning.com.au/unreasonable-effectiveness-of-quasirandom-sequences/">$R_2$ sequence presented in by Martin Roberts</a>. In <a href="https://www.shadertoy.com/view/4dtBWH">this shadertoy</a> you can see how it distributes the sample points to provide some very good coverage over time.</p>

<h2 id="accelerating-curve-access">Accelerating Curve Access</h2>

<p>A good optimization to make here is to split the glyph in some horizontal bands and store which curves of the glyph touch each band. The rasterization code is tracing only horizontally, so with this we can massively reduce the set of curves that each texel will have to test against. To do this I have a bunch of bits per-band per-glyph that represent which local curves to the glyph are present in the band.</p>

<p>Here is a visualization of which curves are on the different bands, highlighted in yellow. You can imagine how a ray traced from left to right of the glyph can just intersect the relevant curves.</p>

<p>
    <video loop="" autoplay=""> 
        <source src="text/showing_bands.mp4" type="video/mp4" />
    </video>
</p>

<p>You get some great wins by having each texel loop over the curves relevant for that band. However, this can be made faster by accessing bands uniformly per-wave, meaning that all the code that handles iterating over curves can be scalarized, and so are the curve reads (meaning they can happen once per-wave and not once per-thread on the wave). That would look something like this:</p>

<div class="language-hlsl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">int</span> <span class="n">this_thread_band_index</span> <span class="o">=</span> <span class="nb">clamp</span><span class="p">(</span><span class="n">int</span><span class="p">(</span><span class="nb">floor</span><span class="p">(</span><span class="n">uv_y</span> <span class="o">*</span> <span class="n">BAND_COUNT</span><span class="p">)),</span> <span class="mi">0</span><span class="p">,</span> <span class="n">BAND_COUNT</span><span class="o">-</span><span class="mi">1</span><span class="p">);</span>
<span class="n">min_band_index</span> <span class="o">=</span> <span class="n">WaveActiveMin</span><span class="p">(</span><span class="n">this_thread_band_index</span><span class="p">);</span>
<span class="n">max_band_index</span> <span class="o">=</span> <span class="n">WaveActiveMax</span><span class="p">(</span><span class="n">this_thread_band_index</span><span class="p">);</span>
<span class="k">for</span> <span class="p">(</span><span class="n">int</span> <span class="n">band_index</span> <span class="o">=</span> <span class="n">min_band_index</span><span class="p">;</span> <span class="n">band_index</span> <span class="o">&lt;=</span> <span class="n">max_band_index</span><span class="p">;</span> <span class="o">++</span><span class="n">band_index</span><span class="p">)</span>
<span class="p">{</span>
    <span class="cm">/* . . . Add the curves for this band to be intersected against . . . */</span>
<span class="p">}</span>
</code></pre></div></div>

<p>And since I’m rasterizing this in compute into an atlas, I can decide which texel each thread is writing to, so I reorganize the threads to be packed horizontally, in row-major order, so the range of bands that each wave touches is minimized compared to other indexing methods like “classic” quads or Morton codes. Here is an example of how the threads are distributed. Using a $9\times11$ glyph and 16-thread waves for simplicity:</p>

<p><img src="text/wave_distribution.png" alt="" /></p>

<p>To distribute the threads like this would be as simple as:</p>

<div class="language-hlsl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">int2</span> <span class="n">total_texel_size</span> <span class="o">=</span> <span class="n">parameters</span><span class="p">.</span><span class="n">texel_bottom_right</span> <span class="o">-</span> <span class="n">parameters</span><span class="p">.</span><span class="n">texel_top_left</span><span class="p">;</span>
<span class="kt">int2</span> <span class="n">local_texel_coordinates_raw</span> <span class="o">=</span> <span class="kt">int2</span><span class="p">(</span><span class="n">thread_id</span> <span class="o">%</span> <span class="n">total_texel_size</span><span class="p">.</span><span class="n">x</span><span class="p">,</span> <span class="n">thread_id</span> <span class="o">/</span> <span class="n">total_texel_size</span><span class="p">.</span><span class="n">x</span><span class="p">);</span>
<span class="k">if</span> <span class="p">(</span><span class="nb">any</span><span class="p">(</span><span class="n">local_texel_coordinates_raw</span> <span class="o">&gt;</span> <span class="n">total_texel_size</span><span class="p">))</span>
<span class="p">{</span>
    <span class="k">return</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="atlas-packing">Atlas Packing</h2>

<p>I started by rasterizing to the screen directly, however computing high quality anti-aliasing every frame as they were being output to the final target was a significant cost.</p>

<p>Thinking about how to get around this it also became obvious that most rendered text stays on screen for many frames, with the same size and position, even as you’re reading this you’re probably not scaling the text, or smoothly scrolling.</p>

<p>Besides this, the same glyph will often appear more than once on screen at the exact same size (just look at how many “e”s there are in this sentence alone). So why bother rendering it multiple times? (Subpixel positioning is a thing and we’ll go back to that later)</p>

<p>So I grabbed the two most well-worn tools in the graphics tool belt, <em>atlases</em> and <em>temporal accumulation</em>.</p>

<p>
    <video> 
        <source src="text/meme_article_pink_atlas.mp4" type="video/mp4" />
    </video>
</p>

<p>The idea here is to have an atlas that packs the glyphs reasonably well, if a glyph we want is not on the atlas, we allocate a chunk of it and start rasterizing into it, if a glyph we want is already there, we just use it. At some point in the frame we go over al the glyphs in the atlas and decide whether we keep it (and maybe refine it with more samples) or if it’s not being used and we should free that space.</p>

<p>The atlas will keep in-use glyphs resident all the time, so if text on the screen hasn’t changed for a while, we have nothing to compute there, all the glyphs are ready and we just slap them onto the screen later. There is a cost of adding new glyphs, but we can spread this cost over many frames as we’ll discuss later.</p>

<p>Some notes about this, the inputs to the atlas do have to take a couple things into account that might not be immediately obvious. At the time of writing, if we equate this atlas to a hash-map, the “key” is the following:</p>

<pre><code class="language-jai">Glyph_Key :: struct
{
    font : Font;
    glyph_index : int;

    // u24.8 fixed point
    quantized_size_in_pixels_x : u32;
    quantized_size_in_pixels_y : u32;

    // u0.8 fixed point
    quantized_subpixel_offset_x : u8;
    quantized_subpixel_offset_y : u8;
}
</code></pre>

<p>The font, index of the glyph inside the font and the size are somewhat expected. We also need the subpixel offset though, which is the fractional of the pixel position (as in <code class="language-c highlighter-rouge"><span class="n">frac</span><span class="p">(</span><span class="n">pixel_position</span><span class="p">)</span></code>). You might want to place the glyph at any position on the screen, not necessarily aligned with the pixel grid, or you might want to smoothly move text (e.g. scrolling). If we didn’t take this into account, then all the anti-aliasing we’re doing would only be valid for a single subpixel position.</p>

<p>Note the usage of fixed point too. This helps collapsing nearby fractional positions and sizes to the same values. Using floating point directly would often generate different values bit-wise, even if mathematically they should have been the same. Using 8 bits for the fractional part offers more than enough resolution for smooth positions and sizes. If moving a single of this $1/256$ increments within the pixel changed the resulting value it would often be displayed in 8 bits per-component render targets or monitor outputs.</p>

<p>That said, you <em>could</em> decide that this is a trade-off you’re willing to make and say that all of your glyphs should be positioned on a pixel boundary. In my experience, slowly moving text looks awful this way since you see it jump from integer pixel boundary to pixel boundary. I wanted to use this as my solution for all text so it’s not something I went for.</p>

<p>Here you can see a comparison between subpixel positioning, aligned to the pixel grid and aligned to a half-resolution pixel grid to simulate seeing this in a monitor that’s half the resolution than the one you’re using.</p>

<p>
    <!--
    ffmpeg -framerate 60 -start_number 28772 -i "swapchain_copy_%d.png" -vf "crop=1000:312:0:312/2" -pix_fmt yuv420p -crf 1 video_crf_1.mp4
    -->
    <video loop="" autoplay=""> 
        <source src="text/subpixel_movement.mp4" type="video/mp4" />
    </video>
</p>

<p>Zooming into the 1-pixel aligned word makes the stepping even more obvious.</p>

<p>
    <!--
    ffmpeg -framerate 60 -start_number 28772 -i "swapchain_copy_%d.png" -vf "crop=200:124:400:200,scale=iw*5:ih*5:flags=neighbor" -pix_fmt yuv420p -crf 1 video_crf_1.mp4
    -->
    <video loop="" autoplay=""> 
        <source src="text/subpixel_movement_zoomed_aligned.mp4" type="video/mp4" />
    </video>
</p>

<p>Where if we let the glyphs fall in subpixel positions the movement is dramatically smoother.</p>

<p>
    <!--
    ffmpeg -framerate 60 -start_number 28772 -i "swapchain_copy_%d.png" -vf "crop=200:124:165:200,scale=iw*5:ih*5:flags=neighbor" -pix_fmt yuv420p -crf 1 video_crf_1.mp4
    -->
    <video loop="" autoplay=""> 
        <source src="text/subpixel_movement_zoomed_not_aligned.mp4" type="video/mp4" />
    </video>
</p>

<p>That said, it’s still possible to optimize for cases where you know you will do a lot of static text, for example, if you’re doing a text editor and want to use a monospaced font you can force the spacing between characters to be rounded to pixel boundaries. This way every glyph will have the same subpixel offset and always hit the atlas cache for the same glyph.</p>

<p>If also aligning the line breaks to the output pixel grid you get even better reuse, since the same glyphs in a monospaced font in different lines will also hit the same entry on the atlas. See how only new glyphs in the block of text allocate a new entry.</p>

<p>
    <video> 
        <source src="text/monospaced_font_glyph_reuse.mp4" type="video/mp4" />
    </video>
</p>

<h3 id="z-order">Z-Order</h3>

<p>A great way I found to place the glyphs somewhat nicely packed at runtime was to use Z-Order Packing and a bitset for free cells within the atlas.</p>

<p>Z-Order curves (via Morton codes) allows you to think of the cells as a long 1D array, allocating a contiguous slice of this 1D array will give you a square in the resulting 2D atlas as long as you’re allocating a power of two number of cells.</p>

<p>A free bit in the bitset represents a free cell, in this case a $16\times16$ texel cell.</p>

<p>When a glyph wants to find a spot, it rounds up its size to the next power of two, so a glyph that needs a $25\times29$ will end up allocating a chunk that’s $32\times32$. This would require 4 $16\times16$ cells, so it’ll look for 4 contiguous free bits and set them, then return the 2D location of that first free cell using Morton codes to go from 1D to 2D.</p>

<p>Note that these contiguous bits also have to be aligned to the number of bits, that is, if looking for 4 free bits, those could start in index 0, 4, 8, 12, etc. If the free bits went from bit 3 to bit 6, when looking at those 4 cells they wouldn’t form a contiguous square.</p>

<p>The code would look something like this:</p>

<pre><code class="language-jai">size := /*...*/

max_size_dimension := max(size.x, size.y);
aligned_size := max(BASE_SLOT_SIZE, align_to_next_power_of_2(max(max_size_dimension, 0)));
slot_size := aligned_size / BASE_SLOT_SIZE;
bits_needed := slot_size * slot_size;
assert(is_power_of_2(bits_needed));

index := find_free_contiguous_bits_aligned(bitset, bits_needed);

base_slot_coordinates := decode_morton2_16(xx index);
top_left_texel_coordinates := base_slot_coordinates * BASE_SLOT_SIZE;
</code></pre>

<p>And here there’s a visualization of the order the glyphs go in as well as what happens when some of they get removed and those free cells get reused for future glyphs if they fit.</p>

<p>
    <video> 
        <source src="text/atlas_packing_demo_order.mp4" type="video/mp4" />
    </video>
</p>

<h3 id="transposing-z-order">Transposing Z-Order</h3>

<p>The eagle eyed among you that have worked with Z-Order in 2D before might have noticed that this is packing in a transposed Z-Order (so… mirrored N-Order?).</p>

<p>This is because most long and thin glyphs that use the Latin alphabet are vertical, and transposing Z-Order allows to allocate two cells together to form a vertical rectangular section. This makes glyphs for stuff like “l”, “j”, “i” or “1” take half the space.</p>

<p>That said, in cases where most long and thin glyphs are horizontal, for example most of the Arabic languages, the standard Z-Order is more suited.</p>

<p>To do this the code above would be modified to not just use the maximum size of each dimension when calculating the <code class="language-c highlighter-rouge"><span class="n">bits_needed</span></code>.</p>

<pre><code class="language-jai">aligned_size := ixy(max(cast(s32)BASE_SLOT_SIZE, align_to_next_power_of_2(max(size.x, 0))),
                    max(cast(s32)BASE_SLOT_SIZE, align_to_next_power_of_2(max(size.y, 0))));
slot_size := aligned_size / BASE_SLOT_SIZE;
slot_size.y = max(slot_size.x, slot_size.y);
slot_size.x = max(slot_size.x, slot_size.y / 2);
bits_needed := slot_size.x * slot_size.y;
assert(is_power_of_2(bits_needed));
</code></pre>

<p>And transposing the final coordinates is simply swapping the result.</p>

<pre><code class="language-jai">base_slot_coordinates := decode_morton2_16(xx index);
base_slot_coordinates.x, base_slot_coordinates.y = base_slot_coordinates.y, base_slot_coordinates.x;
top_left_texel_coordinates := base_slot_coordinates * BASE_SLOT_SIZE;
</code></pre>

<p>Here you can see the same demo but allocating glyphs that are double the height.</p>

<p>
    <video> 
        <source src="text/atlas_packing_demo_vertical.mp4" type="video/mp4" />
    </video>
</p>

<h2 id="temporal-accumulation">Temporal Accumulation</h2>

<p>Glyphs staying in the atlas allows to keep throwing samples at them and refine the results further. This way the final result can have very high quality anti-aliasing without having to cast a significant amount of samples when the glyph just appears.</p>

<p>Let’s look at the intro video slowed down and with a full black background to better visualize the glyph output. Also using the Nacelle typeface on its ultra-light variant, to better show thin features.</p>

<p>
    <video loop="" autoplay=""> 
        <source src="text/meme_article_bw.mp4" type="video/mp4" />
    </video>
</p>

<p>Even in this slowed-down case it’s hard to see the glyphs visibly refining as you’re reading the text since the results are already fairly high quality. The trick here is that every glyph that first appears gets 8 samples-per-pixel on that first frame, then 4 samples next frame, then 2 and finally 1 every frame afterwards until it reaches a total of 512 samples.</p>

<p>This guarantees a pretty good quality when a glyph first shows up, which is important on smoothly moving or resizing glyphs. Since they do the equivalent of getting initialized every frame.</p>

<p>Another factor that makes this looks better is subpixel anti-aliasing, which will be touched upon in a further section.</p>

<p>When disabling this and just doing a single sample per-pixel every frame, with no subpixel anti-aliasing the slowed down results are as follows.</p>

<p>
    <video loop="" autoplay=""> 
        <source src="text/meme_article_bw_1spp.mp4" type="video/mp4" />
    </video>
</p>

<p>It’s more obvious how samples keep getting added. Also very interesting how the glyphs appear to shift in positioning. That’s because the initial samples are not at the center of the pixel. That’s fixed by placing the initial samples optimizing for this case, but that’d defeat part of the point of this visualization.</p>

<p>Even in this case, with a single sample and the shifting text it’s still not as dramatically visible as I would have imagined, showcasing how well the refinement idea and temporal accumulation works in principle.</p>

<p>Zooming in on a word in particular demonstrates how on the first frame the glyph has no anti-aliasing at all and the results are either black or white, then it keeps refining and shifting position until getting to a better final result with a few dozen samples.</p>

<p>
    <video loop=""> 
        <source src="text/zoom_reveal_raw.mp4" type="video/mp4" />
    </video>
</p>

<p>And for completeness, with all the quality optimizations on, starting with 8 samples and with subpixel anti-aliasing that word looks like this.</p>

<p>
    <video loop=""> 
        <source src="text/zoom_reveal.mp4" type="video/mp4" />
    </video>
</p>

<p>This system is also easily tunable to achieve the required levels of quality and performance. Some of the knobs to twist would be:</p>

<ul>
  <li>How many samples/rays to add every frame.</li>
  <li>Increase samples on the first few frames of a glyph or not.</li>
  <li>Having a cap of “total samples” allowed per-frame to keep cost bounded.</li>
  <li>Time-slice the update of existing glyphs, that is, adding samples every few frames instead of every frame.</li>
</ul>

<p>Another note is that the cost of casting a ray scales linearly with the amount of curves it’s going to have to intersect for a given glyph. So for more precise cost-gating it might be worth to use that as a metric instead. Meaning that you’d allow to do a certain number of intersected curves per-frame.</p>

<p>It’s worth mentioning that performance hasn’t been a concern in my experience with this system so far. The full-screen of text of the intro peaks at about 0.1 milliseconds in my 9070 at 4k. And that cost quickly tapers down to zero when glyphs have reached the max number of samples (set at the time of writing to 512 but can be easily lowered).</p>

<p>Overall this system works <em>shockingly</em> well. Most text presented to users often stays on screen completely static, which lets it converge to high quality. Even as it shows up, the speed at which we look at words and read them is orders of magnitude slower that the time it takes a glyph to look very good. In general, I’ve found it imperceptible that the text is converging over time while at the same time it always looks nicely anti-aliased.</p>

<h2 id="subpixel-anti-aliasing-and-fringing">Subpixel Anti-Aliasing and Fringing</h2>

<p>The gist of subpixel anti-aliasing is start thinking of the individual red, green and blue subpixel elements that for your monitor pixel as individual sample points, or rather, sample areas. Roughly you can consider the subpixel elements to be the actual “pixels” you want to render into.</p>

<p>In a traditional RGB LCD layout like the following, your horizontal resolution effectively triples. In traditional 4k you’d go from $3840\times2160$ to $3840\times6480$.</p>

<p><img src="text/subpixel_zoo_rgb_structure.png" alt="" />
<em>Image from <a href="https://geometrian.com/resources/subpixelzoo/">Subpixel Zoo</a></em></p>

<p>Getting all this effective resolution is great! And since the light is getting mixed from neighboring pixels, there’s no reason to get bad color fringing.</p>

<p>As I’ve already hinted at though, the monitor I’m using is far from this 3 vertical stripes of red, green and blue, and looks like this instead.</p>

<p><img src="text/rtings_subpixel_layout_oled_g9.jpg" alt="" />
<em>Image from <a href="https://www.rtings.com/monitor/reviews/samsung/odyssey-oled-g9-g95sc-s49cg95">RTings Review of Oled G9</a></em></p>

<p>Which causes problematic fringing. And this is far from being the worst case out there, with monitors having wild arrangements like some of the ones you can see in <a href="https://geometrian.com/resources/subpixelzoo/">Subpixel Zoo</a>. A notorious recent one is <a href="https://github.com/microsoft/PowerToys/issues/25595#issuecomment-1512405626">LG WOLED having a red-white-blue-green</a> structure, so it has an extra white-only subpixel and has the green and blue ones swapped from the standard order.</p>

<p>To show a more direct comparison on my current monitor. A default red-green-blue subpixel structure made of equal vertical rectangles would look like this. With very visible green fringing on top and magenta at the bottom.</p>

<p><img src="text/text_with_fringing.png" alt="" /></p>

<p>Whereas if I set the subpixel structure on the solution presented in this article to match the one on my monitor it looks like this. Where even with subpixel anti-aliasing on there’s next to no fringing while keeping a very smooth result.</p>

<p><img src="text/text_without_fringing.png" alt="" /></p>

<p>The big payoff! Finally rendering good looking text with subpixel anti-aliasing and no color fringing.</p>

<p>To achieve this I’ve set up a little editor where I could play with the subpixel elements position, the inner white square is the pixel, and each of the colored quads represent where I’m sampling the results of each subpixel element. Note that it’s going out of bounds of the pixel, which I’ll touch on in the next section.</p>

<p><img src="text/subpixel_antialiasing_oled.png" alt="" /></p>

<p>If zooming in you can see how most of those pixels we’re sending to the monitor are not white, in fact there’s very few that are $RGB(1,1,1)$.</p>

<p><img src="text/subpixel_antialiasing_zoomed.png" alt="" /></p>

<p>But when they’re outputting on the monitor, light from all the subpixels blends in such a way that the result is a smooth white output. Getting the desired anti-aliasing effect and better representing the intended shape of the glyph.</p>

<p><img src="text/subpixel_antialiasing_zoomed_monitor_picture.png" alt="" /></p>

<p>Note that a lot of these features are only one to one-and-a-half pixels wide. They also often fall in-between pixel cells since I’m not doing any <a href="https://learn.microsoft.com/en-us/typography/truetype/hinting">hinting</a>. This is picked on purpose as a hard example for the renderer to handle and to show the effectiveness of good subpixel anti-aliasing.</p>

<h3 id="overlapping-subpixels">Overlapping Subpixels</h3>

<p>As I was trying to match my subpixel structure I’ve found that overlapping the subpixel elements would give more accurate results. Which intuitively makes sense since light naturally mixes and diffuses slightly from the subpixel elements, so the sampled area for a given subpixel will be larger than the subpixel itself physically is. Almost behaving like a tiny point light.</p>

<p>So naturally you might expect a setup like this.</p>

<p><img src="text/subpixel_antialiasing_rgb.png" alt="" /></p>

<p>However letting the subpixel elements overlap each other gives better results. Also here you can see two examples of a “classic” LCD subpixel arrangement. If you’re seeing this on a screen with this arrangement it’s probably the best quality anti-aliasing you’d see in this whole article. Because all the other captures have been done with my monitor’s subpixel structure arrangement.</p>

<p><img src="text/subpixel_antialiasing_rgb_expanded.png" alt="" /></p>

<p>Note that the areas also should bleed outside the pixel itself because they are surrounded by (normally) identical pixels with identical subpixel elements. Light is not only bleeding and mixing with the light from a single pixel, but also with the neighboring subpixels.</p>

<p>As I was writing this article I found the <a href="https://medium.com/@evanwallace/easy-scalable-text-rendering-on-the-gpu-c3f4d782c5ac">Easy Scalable Text Rendering article by Evan Wallace</a> which suggests needing to blur horizontally after rendering with subpixel anti-aliasing. Interestingly this is effectively the same thing as considering the subpixel elements themselves to be bigger and overlapping.</p>

<h1 id="a-plea">A Plea</h1>

<p>I <em>really</em> wish that having access to arbitrary subpixel structures of monitors was possible, perhaps given via the common display protocols. This would enhance subpixel anti-aliasing in general and text specifically, even in monitors that have “standard” orders, since you can be more fine-grained for the specific hardware.</p>

<p>This would also give freedom to display manufacturers to not have to fear trying an otherwise better subpixel structure because of issues with text rendering. Samsung changed their subpixel structure on QD-OLED to try to minimize issues like this from G8 to G9. And still on LG’s WOLED and Samsung’s QD-OLED fringing is commonly cited as one of the most notorious problems on monitors that use them.</p>

<p>It’s is just software, we can fix this, they shouldn’t be forced to change hardware to account for the failures of software.</p>

<h1 id="final-words">Final Words</h1>

<p>Good user interfaces and especially great text is a soft spot of mine. It has the potential to carry the perceived quality of a product to an degree that’s sometimes underrated. A prime example of this is the fantastic work that Atlus consistently puts out in the <a href="https://www.youtube.com/watch?v=4d6x1CIgLSc">Persona</a> series or more recently <a href="https://www.youtube.com/watch?v=L4ypdFi8zo8">Metaphor: ReFantazio</a>. I also have to mention <a href="https://www.youtube.com/watch?v=BhNoX5F81iw">Nier: Automata</a> as a personal favorite.</p>

<p>And it makes sense! Games will often present you with text that’s meant to grab your attention. When a text box, a menu, a title, an announcement or anything in-between shows up in a game there’s an implied focus point put on it. It looking sub-par can impact the experience as much as a badly rendered 3D scene would. So it follows that this aspect of the presentation should get their fair share of love as well.</p>

<p>I hope you’ve found this useful! I’d love to see more attempts to make glyph rendering in real time better and in this fashion I wish this comes across as a good motivator for more people to go tackle this.</p>

<p>As always, if you have any comments or there’s any questions please reach out! You can find me in most places as some variation of “osor_io” or “osor-io” as well as with the links at the bottom of the page.</p>

<p>Cheers! 🍻</p>

<!-- # References -->

<!--

Useful ffmpeg commands to turn a bunch of frames into a video

- General when recorded at 60fps:
    ffmpeg -framerate 60 -i "swapchain_copy_%d.png" -pix_fmt yuv420p -crf 1 video_crf_1.mp4

- To crop and zoom:
    ffmpeg -framerate 3 -start_number 112 -i "swapchain_copy_%03d.png" -vf "crop=98:30:185:175,scale=iw*8:ih*8:flags=neighbor" -pix_fmt yuv420p -crf 1 video_crf_1.mp4

-->]]></content><author><name>Rubén Osorio López</name></author><summary type="html"><![CDATA[]]></summary></entry><entry><title type="html">Implementing Order-Independent Transparency</title><link href="https://osor.io/OIT.html" rel="alternate" type="text/html" title="Implementing Order-Independent Transparency" /><published>2024-11-05T00:00:00+00:00</published><updated>2024-11-05T00:00:00+00:00</updated><id>https://osor.io/OIT</id><content type="html" xml:base="https://osor.io/OIT.html"><![CDATA[<p>Hello! This will be a first attempt at coming back to writing some blog posts about interesting topics I end up rabbitholing about. All the older stuff has been sadly lost to time (and “time” here mostly means a bad squarespace website).</p>

<p>On-and-off I’ve been looking at some ways to handle transparency in my home code and just like the previous $N-1$ times I ended up wanting some sort of Order-Independent Transparency solution. However, time $N$ seemed as good of a time as any to actually try to implement something usable. So here are some ideas and the current state I’ve gotten to at the time of writing.</p>

<!--

ffmpeg -framerate 120 -i "frame_%01d.png" -pix_fmt yuv420p -crf 0 video_crf_0.mp4 && ffmpeg -framerate 120 -i "frame_%01d.png" -pix_fmt yuv420p -crf 10 video_crf_10.mp4

ffmpeg -framerate 120 -i "frame_%01d.png" -crf 15 looping_transparent_balls_crf_15.webm

-->

<p>
    <video controls="" loop="" autoplay=""> 
        <source src="OIT/looping_frames_main.mp4" type="video/mp4" />
    </video>
</p>

<h1 id="what-is-oit-why-does-it-matter">What is OIT? Why does it matter?</h1>

<p>The reasoning for wanting Order-Independent Transparency comes from Order-<strong>Dependent</strong> Transparency being the way transparency rendering ends up being implemented in most computer graphics scenarios, and certainly in the majority of real-time rendering contexts.</p>

<p>The most natural way to achieve plausible-looking blending of transparent objects in computer graphics has been to draw the objects sorted from back-to-front. This is because when you’re drawing an object that’s partially letting you see what’s behind it, the easiest way to do it is to have the light (color) of the background already available so you can obscure it by some ratio, and then add the light of the object on top. This percentage is what people will often refer to as an “alpha”.</p>

<p>This imposes strict ordering when rendering all the objects in your scene to be farthest-to-closest, commonly referred to as the <a href="https://en.wikipedia.org/wiki/Painter%27s_algorithm">Painter’s Algorithm</a>.</p>

<p>Well, it turns out we really don’t like that! This ordering spawns a myriad of problems that have annoyed us real-time rendering people for a while:</p>

<ul>
  <li>This <strong>requires to sort</strong> everything that you’re going to draw based on distance to the camera. This has a performance cost in terms of doing the sort itself, but also in terms of doing the actual rendering. Without going into too much detail yet, current GPUs really really like to render the same kind of objects and the same kind of materials all at once. If you have to sort your objects, you can’t be rendering all your bottles first, then all your smoke particles, etc. If the order is bottle -&gt; smoke -&gt; bottle -&gt; smoke, you need to draw them in that order.</li>
  <li>Even with correct sorting of all your objects, the <strong>results might still be incorrect</strong>! For example if an object is inside another object or if they overlap there would be pixels where an object should be rendered first and others where the other object should be rendered first. Think of an ice cube inside a glass of water.</li>
  <li>It can get really expensive to draw every pixel of all the transparent objects. Opaque rendering can easily optimize this by only really shading the closest opaque pixel that is visible in the end. With traditional back-to-front transparency this is not possible because the next object being rendered might need the result of the previous object since light is able to pass through it, therefore needing to render all of them. This situation where we draw into the same pixel more than once is referred to as <strong>overdraw</strong>. It’s easy to end up in situations where you have to shade and blend multiple screens worth of pixels, consequently killing performance.</li>
</ul>

<p>An Order-Independent Transparency solution allows to render transparency in any order (shocker, I know). Most of the time OIT is thought about in the context of correctness, since scenes like the ice cube in the glass or smoke inside a car can look very jarring. In this sense, OIT would give fully a correct-looking end-result per-pixel.</p>

<p>However, depending on the implementation, it could also come with performance gains. The sorting of every object is now not required so that cost goes away, plus you’d be able to draw your transparent objects in whichever order is faster (e.g. all of the same objects/materials drawn together). Some solutions even allow to cut on overdraw, since they can avoid drawing transparency that would never be visible because there’s other transparent objects in front that fully occlude it.</p>

<p>Finally, I personally consider the possible simplicity gains on the whole codebase to be important. Without OIT, you often end up with complicated interfaces that have to take transparent draws from a bunch of systems, sort them and then dynamically dispatch the draws in the correct order, with callbacks to each system’s custom code, etc. With some OIT solutions you might be able to write conceptually simpler and more performant code such as:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">draw_ghosts</span><span class="p">();</span>
<span class="n">draw_particles</span><span class="p">();</span>
<span class="n">draw_glasses_of_wine</span><span class="p">();</span>
<span class="n">draw_the_ice_cubes_in_the_glasses_of_wine</span><span class="p">();</span>
<span class="n">draw_whatever_other_transparent_draws_with_a_very_fancy_shader</span><span class="p">();</span>
</code></pre></div></div>

<h1 id="polychrome-transmittance">Polychrome Transmittance</h1>

<p>When we say that light is “going through a surface” there’s two broad ways in which we can categorize this.</p>

<p>If the matter of the medium is mostly opaque but it’s leaving room for the light to pass through unencumbered we would call this phenomenon <strong>partial coverage</strong>. You can think of this sort of surface as having “tiny holes” where the some of the light can sneak through without ever interacting with it. Examples of this could be some fabrics or meshes of some kind.</p>

<p>When light is going <em>through</em> a medium then we would call that <strong>transmission</strong>. Here light interacts with the medium in more complex ways than just passing through or not. Notably, for our purposes, the medium could be letting some frequencies of light through more than others. Examples of this could be liquids, plastics, etc.</p>

<p>Both of these are really well explained by Morgan McGuire in his <a href="https://youtu.be/rVh-tnsJv54?si=6hY7Nfj-_1aSzGXy&amp;t=215">this part of his presentation about transparency in Siggraph 2016</a></p>

<p>Partial coverage is what we’ve mostly been modeling in real-time rendering and what most people is talking about when mentioning alpha blending or alpha compositing. In this case we simply occlude light by a single “alpha”, the ratio of light that’s blocked.</p>

<p>But if we only model partial coverage this doesn’t accurately represent all those other types of media such as transparent colored plastics, tinted glass, some liquids, etc. And this is a real shame since we’re missing out on all the eye-candy that transmission would have given us.</p>

<p>This is why for my transparency solution I wanted to support polychrome transmittance, where we’d handle how light is getting transmitted differently in multiple frequencies. Aaaaand of course these frequencies will be just red, green and blue because this is real time graphics and those are the ones <a href="https://en.wikipedia.org/wiki/Trichromacy">we mostly care about anyways</a>.</p>

<p>For the purposes of an implementation, polychrome transmittance could be considered a superset of partial coverage, since we could always just say that light is being transmitted by the same ratio in all light components, hence simulating the behavior partial coverage. Here is how our test scene would look like if simulating partial coverage via monochrome transmittance:</p>

<p>
    <video controls="" loop="" autoplay=""> 
        <source src="OIT/looping_frames_monochrome.mp4" type="video/mp4" />
    </video>
</p>

<p>However, handling polychrome transmittance can impose extra requirements compared to traditional monochrome alpha, which would only require us to handle how the visibility of a single channel changes. In a way, it can even triple the computation and even potential memory requirements of an OIT solution. A lot of OIT solutions could be made cheaper or at least be simplified if only monochrome transmittance is desired by simply running the logic and storing single values instead of three.</p>

<h1 id="how-i-didnt-do-it">How I didn’t do it</h1>

<p>There’s a few ways to approach OIT that I didn’t quite like the trade-offs of. This doesn’t mean they aren’t the right approach for your use-case, or even that they won’t become the one true way to go in the future as the landscape of real-time rendering evolves.</p>

<h3 id="raytraced-transparency">Raytraced Transparency</h3>

<p>Here you simply trace a ray against all your transparent geometry while accumulating transmittance and luminance (or radiance if you wanna get radiometric). When you shade something, you multiply the resulting luminance by the current transmittance and accumulate the transmittance to use on the next point to shade. After there’s nothing left to hit, you read the luminance of your opaque layer and obscure it with the accumulated transmittance. Something like this:</p>

<div class="language-hlsl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">float3</span> <span class="n">luminance</span> <span class="o">=</span> <span class="mi">0</span><span class="p">.</span><span class="mi">0</span><span class="p">;</span>
<span class="kt">float3</span> <span class="n">transmittance</span> <span class="o">=</span> <span class="mi">1</span><span class="p">.</span><span class="mi">0</span><span class="p">;</span>

<span class="n">Ray</span> <span class="n">ray</span> <span class="o">=</span> <span class="n">init_ray_from_eye_into_the_scene</span><span class="p">(</span><span class="cm">/*...*/</span><span class="p">);</span>
<span class="k">while</span> <span class="p">(</span><span class="n">ray</span><span class="p">.</span><span class="n">hit_stuff</span><span class="p">())</span>
<span class="p">{</span>
    <span class="n">Shaded_Hit</span> <span class="n">hit</span> <span class="o">=</span> <span class="n">shade</span><span class="p">(</span><span class="n">ray</span><span class="p">);</span>
    <span class="n">luminance</span> <span class="o">+=</span> <span class="n">hit</span><span class="p">.</span><span class="n">luminance</span> <span class="o">*</span> <span class="n">transmittance</span><span class="p">;</span>
    <span class="n">transmittance</span> <span class="o">*=</span> <span class="n">hit</span><span class="p">.</span><span class="n">transmittance</span><span class="p">;</span>
<span class="p">}</span>

<span class="kt">float3</span> <span class="n">opaque_layer_luminance</span> <span class="o">=</span> <span class="n">backbuffer</span><span class="p">.</span><span class="n">Sample</span><span class="p">(</span><span class="cm">/*...*/</span><span class="p">);</span>
<span class="k">return</span> <span class="n">luminance</span> <span class="o">+</span> <span class="p">(</span><span class="n">opaque_layer_luminance</span> <span class="o">*</span> <span class="n">transmittance</span><span class="p">);</span>
</code></pre></div></div>

<p>This is nice to read, and even if it looks very didactic, a real implementation <a href="https://interplayoflight.wordpress.com/2023/07/15/raytraced-order-independent-transparency/">can look pretty much like that</a>. It also supports extra phenomena like refraction very naturally. In principle I really like this.</p>

<p>The main issue is with how much heavy lifting that <code class="language-c highlighter-rouge"><span class="n">shade</span><span class="p">(</span><span class="n">ray</span><span class="p">)</span></code> call is doing. This needs to handle shading of <em>any</em> type of transparent surface you want to have in your renderer, the same code-path would need to be able to shade materials that range from opaque glass geometry to smoke particles.</p>

<p>This requires that you make a single shader that supports all the different shading models you need, and it’ll get fatter and slower as time goes on (hurting code size, register usage, etc.). And you are still effectively shading things in the order they are per-pixel, meaning that you can’t do any optimizations where you batch per-shader/material.</p>

<p>There’s other limitations, like keeping an acceleration structure for all your transparent geometry (including particles), but depending on the context these are manageable.</p>

<p>That said, if you can keep the complexity of this shading path in check, this might be a solution to consider. Even more if hardware and graphics APIs evolve towards handling this type of branching better.</p>

<h3 id="per-pixel-lists">Per-Pixel Lists</h3>

<p>The idea here is also simple, you render all your transparency however you want, but instead of blending it to the screen, you add it to some sort of list that you keep for each pixel. You’d need to add the luminance, the transmittance and the depth:</p>

<div class="language-hlsl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">float3</span> <span class="n">luminance</span>     <span class="o">=</span> <span class="cm">/*...*/</span>
<span class="kt">float3</span> <span class="n">transmittance</span> <span class="o">=</span> <span class="cm">/*...*/</span>
<span class="n">float</span>  <span class="n">depth</span>         <span class="o">=</span> <span class="cm">/*...*/</span>
<span class="n">add_to_list</span><span class="p">(</span><span class="n">luminance</span><span class="p">,</span> <span class="n">transmittance</span><span class="p">,</span> <span class="n">depth</span><span class="p">);</span>
</code></pre></div></div>

<p>After you’re done, you take this list, sort it based on the depth and add up all the results in a loop that looks kind of similar to the raytracing one:</p>

<div class="language-hlsl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">sort</span><span class="p">(</span><span class="n">list</span><span class="p">);</span> <span class="c1">// Could be in place, in a separate pass, or even done as you're adding elements to the list</span>

<span class="kt">float3</span> <span class="n">luminance</span>     <span class="o">=</span> <span class="mi">0</span><span class="p">.</span><span class="mi">0</span><span class="p">;</span>
<span class="kt">float3</span> <span class="n">transmittance</span> <span class="o">=</span> <span class="mi">1</span><span class="p">.</span><span class="mi">0</span><span class="p">;</span>
<span class="k">for</span> <span class="p">(</span><span class="n">int</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">list</span><span class="p">.</span><span class="n">count</span><span class="p">;</span> <span class="o">++</span><span class="n">i</span><span class="p">)</span> 
<span class="p">{</span>
    <span class="n">luminance</span> <span class="o">+=</span> <span class="n">list</span><span class="p">[</span><span class="n">i</span><span class="p">].</span><span class="n">luminance</span> <span class="o">*</span> <span class="n">transmittance</span><span class="p">;</span> 
    <span class="n">transmittance</span> <span class="o">*=</span> <span class="n">list</span><span class="p">[</span><span class="n">i</span><span class="p">].</span><span class="n">transmittance</span><span class="p">;</span>
<span class="p">}</span>

<span class="kt">float3</span> <span class="n">opaque_layer_luminance</span> <span class="o">=</span> <span class="n">backbuffer</span><span class="p">.</span><span class="n">Sample</span><span class="p">(</span><span class="cm">/*...*/</span><span class="p">);</span>
<span class="k">return</span> <span class="n">luminance</span> <span class="o">+</span> <span class="p">(</span><span class="n">opaque_layer_luminance</span> <span class="o">*</span> <span class="n">transmittance</span><span class="p">);</span>
</code></pre></div></div>

<p>This works well, and it lets you render all transparency in any order you want, batching to your heart’s content. The main problem here is that these lists can get <em>very</em> big and you need to account for a single pixel having many transparent surfaces wanting to contribute to it. It’s not unlikely at all to have a stack of 20+ particles on top of each other, all faint enough that you can still see through all of them. The longer these lists can get, the more time time will be spent sorting them and the more memory they would require.</p>

<p>Let’s say you’re rendering at 1440p, maybe you encode luminance in <a href="https://en.wikipedia.org/wiki/RGBE_image_format">R9G9B9E5</a>, transmittance in R8G8B8 plus depth in a single float16. That’s 9 bytes per item on the list. If you want to support 16 elements per-pixel that’s $\frac{2560\times1440\times9\times16)}{(1024\times1024)} = 506.25$ MB which is half a GB for just the lists. Plus you’d need to make these ~3.6 million lists sorted, either as you add or with a separate sorting pass afterwards. And 16 elements might look like many, but it’s not hard to reach at all if doing particles.</p>

<p>There’s many flavors of this, keeping linked-lists, only considering the closest N elements, having all items come from a shared buffer for all pixels, etc. Due to the nature of having to keep a list, they all inherently require the memory necessary to hold and sort as many items as you’ll need.</p>

<p>If the need to handle many overlapping transparent surfaces is not a requirement for you though, this might be worth considering!</p>

<h1 id="how-i-did-do-it">How I did do it</h1>

<p>The key problem of achieving order-independence is that when you render a surface you don’t know what could be in-between that surface and your eye. You just don’t have the information about how much light from the surface you just shaded is going to make it to the viewer.</p>

<p>This is made explicit in the code for raytraced transparency, every time you shade a surface you have the variable <code class="language-c highlighter-rouge"><span class="n">transmittance</span></code> holding quite literally the ratio of how much light any surface we find at that moment is going to reach the eye.</p>

<p>It would be really good if we could just™ know the transmittance of the path in front of the surface we’re shading. The approach I’ll describe here attempts to do essentially that. It generates a function of transmittance over depth per-pixel. This then can be used to render transparency while occluding the luminance that reaches the eye by sampling that function.</p>

<p>This is what approaches like <a href="https://briansharpe.files.wordpress.com/2018/07/moment-transparency-av.pdf">Moment Transparency</a> or <a href="https://cg.cs.uni-bonn.de/backend/v1/files/publications/Muenstermann2018-MBOIT.pdf">Moment-Based Order-Independent Transparency</a> do. The challenge here is to create this function of transmittance over depth in a way that’s accurate and still doesn’t break the bank in terms of performance, memory usage, etc.</p>

<p>The simplest form of this idea would involve two passes, first generating the transmittance-over-depth function that we’ve mentioned, then do a second pass where we render all the transparency using the transmittance information at each point. That said, to help with the representation of transmittance it’s useful to render depth bounds so we can distribute its precision to where there’s going to be relevant information.</p>

<p>The representation I’m using to generate this transmittance-over-depth function, and the general approach, is the one from <a href="https://arxiv.org/abs/2201.00094">Wavelet Transparency</a> where they use <a href="https://en.wikipedia.org/wiki/Haar_wavelet">Haar wavelets</a> to encode it. I can’t recommend this paper enough and if you want to dive deep into the mathematics of how you can use wavelets to represent <a href="https://en.wikipedia.org/wiki/Monotonic_function">monotonically non-increasing</a> functions like this, definitely go give it a read! This representation I’m sure can be useful for many other applications.</p>

<h2 id="wave-what">Wave-what?</h2>

<p>To give a back-of-the-napkin explanation of wavelets it’s easier if we start with <a href="https://en.wikipedia.org/wiki/Fourier_series">Fourier series</a>. With them you can represent a function as a sum of sinusoidal waves, so you can encode your annoyingly infinite function as a set of coefficients that represent these individual waves. This transformation process is called the <a href="https://en.wikipedia.org/wiki/Fourier_transform">Fourier Transform</a>. With only a few coefficients you can get a pretty good representation of the original function which you can store, process, sample later, etc.</p>

<p>So why not use this to represent our transmittance? Well these are kind of bad at representing localized events in a function. And transmittance over depth changes sharply at arbitrary points.</p>

<p>Fourier series being bad for this makes some intuitive sense since you’re adding these infinite waves on top of waves at every $t=(-\infty, +\infty)$. If you then wanted to represent a sharp unique change in the middle of the function and nowhere else it’s not easy to see what waves you could add to do so. If this sounds foreign, there’s an amazing explanation of Fourier series by <a href="https://www.youtube.com/watch?v=r6sGWTCMz2k">3Blue1Brown here</a>.</p>

<p>Here you can see what happens if we just add the top three sinusoidal functions together, we get this nicely continuous function, but it would be really hard to represent a particular shape in the middle of it.</p>

<iframe src="https://www.desmos.com/calculator/ed5wstofeg?embed" width="1000" height="624"></iframe>

<p>Wavelet bases also represent signals through the amplitude of some coefficients, but this signals are localized in time, they are quite literally a “little wave” at specific $t$. By composing these piece-wise signals we can represent localized phenomena much easier and with much less coefficients. You can see here how if we add these three top wavelets together we can represent something way more localized in time.</p>

<iframe src="https://www.desmos.com/calculator/mwmehxtih4?embed" width="1000" height="624"></iframe>

<p>This example is using Morlet wavelets but there’s <a href="https://www.mathworks.com/help/wavelet/gs/introduction-to-the-wavelet-families.html">many more</a> you could use. One of them being the <a href="https://en.wikipedia.org/wiki/Haar_wavelet">Haar wavelets</a> that the paper uses. The coolest among them being the <a href="https://en.wikipedia.org/wiki/Ricker_wavelet">Mexican hat wavelet</a> of course.</p>

<p>Another hugely recommended watch that introduces signal processing, time and frequency domains, Fourier and then wavelets is <a href="https://www.youtube.com/watch?v=jnxqHcObNK4">Artem Kirsanov’s video on Wavelets</a>. The book <a href="https://wavelet-tour.github.io/">A Wavelet Tour of Signal Processing</a> was also really good. The first chapter is available for free and does a good job of introducing some of these concepts.</p>

<p>Hopefully this gives some intuition of what’s happening when we try to encode the arbitrary function of transmittance over depth (depth being our “time” axis) using wavelets. This is a huge field and one that requires to build on previous knowledge at many steps. If you want to understand this better I recommend giving the references linked above a read/watch and then go back to the <a href="https://arxiv.org/abs/2201.00094">paper</a> to see how it’s used in practice.</p>

<p>With this in hand, we can go into how the different passes will generate and use this wavelet representation.</p>

<h2 id="computing-depth-bounds">Computing Depth Bounds</h2>

<p>I start with rendering transparency draws outputting linear depth, keeping the minimum and maximum values for each pixel. There’s nothing particularly interesting about this pass, I’m rendering to a two component target with a blend-state that just keeps the maximum value, then just something simple like the following will do:</p>

<div class="language-hlsl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">float2</span> <span class="nf">pixel_shader</span><span class="p">(</span><span class="kt">float4</span> <span class="n">clip_position</span><span class="p">)</span>
<span class="p">{</span>
    <span class="n">float</span> <span class="n">linear_depth</span> <span class="o">=</span> <span class="n">device_depth_to_linear_depth</span><span class="p">(</span><span class="n">clip_position</span><span class="p">.</span><span class="n">z</span><span class="p">);</span>
    <span class="k">return</span> <span class="kt">float2</span><span class="p">(</span><span class="o">-</span><span class="n">linear_depth</span><span class="p">,</span> <span class="n">linear_depth</span><span class="p">);</span> <span class="c1">// Storing negated minimum distance so "max" blending keeps the right values</span>
<span class="p">}</span>
</code></pre></div></div>

<p>You could store device depth instead and avoid the extra transformation, but have in mind that depending on how you’re handling your device depth, normalizing a depth value to use with your transparency might need to account for it being non-linear.</p>

<p>For our example scene in this post, the depth bounds look like this, with minimum and maximum respectively. The visualized range here goes from 35 to 140 meters so it’s clearer to see a black-to-white value.</p>

<table>
  <tbody>
    <tr>
      <td><img src="OIT/depth_bounds_min.png" alt="" /></td>
      <td><img src="OIT/depth_bounds_max.png" alt="" /></td>
    </tr>
  </tbody>
</table>

<p>This extra pass might be a concern if you’re going to be rendering a lot of transparency, or if your draws involve expensive computations for just outputting depth (e.g. heavy vertex shader animation). However, since all we want to do here is to bound the space per-pixel where we’re writing alpha, you could easily do lower detail draws or even simple bounding boxes or spheres. You could even render the bounds at a lower resolution to help with bandwidth and memory costs.</p>

<h2 id="generating-transmittance">Generating Transmittance</h2>

<p>In this pass, I’m rendering all the transparency through a shader that only outputs transmittance, which is cheaper than fully shading them. From the given transmittance and depth, a given set of coefficients are generated, which are added to the existing coefficients stored in a render target. The shader would be running something like this:</p>

<div class="language-hlsl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">void</span> <span class="nf">pixel_shader</span><span class="p">(</span><span class="kt">float4</span> <span class="n">clip_position</span><span class="p">)</span>
<span class="p">{</span>
    <span class="n">float</span> <span class="n">depth</span>         <span class="o">=</span> <span class="cm">/*...*/</span><span class="p">;</span>
    <span class="n">float</span> <span class="n">transmittance</span> <span class="o">=</span> <span class="cm">/*...*/</span><span class="p">;</span>
    <span class="n">add_transmittance</span><span class="p">(</span><span class="n">clip_position</span><span class="p">.</span><span class="n">xy</span><span class="p">,</span> <span class="n">depth</span><span class="p">,</span> <span class="n">transmittance</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>At the time of writing I’m storing the coefficients in a texture-array at render resolution. The number of coefficients (length of the texture array) is determined by the rank of the Haar wavelets where rank $N$ requires $2^{N+1}$. For this blog post I was using rank 3, but lower ranks are a very sensible approach for a big reduction in texture size (and hence less memory usage, bandwidth, etc.).</p>

<p>An important note in this regard is that a single transmittance event doesn’t need to write to all the coefficients, only to $Rank+2$ of them, so even at higher ranks it’s not as bandwidth intensive at it would initially seem. Similarly, when sampling later only $Rank+1$ coefficients are necessary for a single sample.</p>

<p>These coefficients are positive floating point numbers possibly outside the 0-1 range, so an appropriate format is required. I’m using <code class="language-c highlighter-rouge"><span class="n">R9G9B9E5</span></code> which shares a 5-bit exponent between the three 9-bit mantissas for red, green and blue, each of the channels for RGB transmittance. If you only wanted to do monochrome transmittance you could simplify this to a smaller format that only stores a single floating point value, or swizzle them together and reduce the texture-array length.</p>

<p>The key feature that makes this part order-independent is that the coefficients are purely additive. It might be obvious to some readers, but what is breaking order-independent in classic methods is that the order of operations affects the result, so if you’re doing traditional alpha blending (denoted here by $\diamond$), it’s not the same doing $(a \diamond b \diamond c)$ than $(b \diamond a \diamond c)$. However, since we’re only adding coefficients together, we would get the same result doing $(a + b + c)$ and $(b + a + c)$ due to addition being commutative (besides possible floating-point representation differences).</p>

<p>Here is a visualization of the transmittance (sampled at far-depth) when adding each of the spheres in the example scene. You can see how the order in which they are being added is arbitrary.</p>

<p>
    <video controls=""> 
        <source src="OIT/appearing_frames_transmittance.mp4" type="video/mp4" />
    </video>
</p>

<p>Something you would encounter while implementing this with the same setup, is that <code class="language-c highlighter-rouge"><span class="n">R9G9B9E5</span></code> cannot be used as a render target to output to (in D3D12 at least). So we need to perform the addition manually. Sadly we can’t simply <code class="language-c highlighter-rouge"><span class="n">InterlockedAdd</span></code> into them either. To solve this, what I’m doing is casting the <code class="language-c highlighter-rouge"><span class="n">R9G9B9E5</span></code> to a <code class="language-c highlighter-rouge"><span class="n">R32_UINT</span></code> target and doing a <a href="https://en.wikipedia.org/wiki/Compare-and-swap">Compare-And-Swap</a> loop to read the value, increment it by the <code class="language-c highlighter-rouge"><span class="n">addend</span></code> and store it again atomically. This could look something like this:</p>

<div class="language-hlsl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">int</span>    <span class="n">coefficient_index</span>  <span class="o">=</span> <span class="cm">/*...*/</span><span class="p">;</span>
<span class="kt">float3</span> <span class="n">coefficient_addend</span> <span class="o">=</span> <span class="cm">/*...*/</span><span class="p">;</span>

<span class="kt">RWTexture2DArray</span><span class="o">&lt;</span><span class="n">uint</span><span class="o">&gt;</span> <span class="k">texture</span> <span class="o">=</span> <span class="cm">/*...*/</span><span class="p">;</span>
<span class="kt">int2</span> <span class="n">coordinates</span> <span class="o">=</span> <span class="cm">/*...*/</span><span class="p">;</span>
<span class="kt">int3</span> <span class="n">array_coordinates</span> <span class="o">=</span> <span class="kt">int3</span><span class="p">(</span><span class="n">coordinates</span><span class="p">,</span> <span class="n">coefficient_index</span><span class="p">);</span>
<span class="n">int</span> <span class="n">attempt</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
<span class="n">uint</span> <span class="n">read_value</span> <span class="o">=</span> <span class="k">texture</span><span class="p">[</span><span class="n">array_coordinates</span><span class="p">];</span>
<span class="n">uint</span> <span class="n">current_value</span><span class="p">;</span>
<span class="k">do</span>
<span class="p">{</span>
    <span class="n">current_value</span> <span class="o">=</span> <span class="n">read_value</span><span class="p">;</span>
    <span class="n">uint</span> <span class="n">new_value</span> <span class="o">=</span> <span class="n">pack_r9g9b9e5</span><span class="p">(</span><span class="n">unpack_r9g9b9e5</span><span class="p">(</span><span class="n">current_value</span><span class="p">)</span> <span class="o">+</span> <span class="n">coefficient_addend</span><span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">new_value</span> <span class="o">==</span> <span class="n">current_value</span><span class="p">)</span> <span class="k">break</span><span class="p">;</span>
    <span class="nb">InterlockedCompareExchange</span><span class="p">(</span><span class="k">texture</span><span class="p">[</span><span class="n">array_coordinates</span><span class="p">],</span> <span class="n">current_value</span><span class="p">,</span> <span class="n">new_value</span><span class="p">,</span> <span class="n">read_value</span><span class="p">);</span>
<span class="p">}</span>
<span class="k">while</span> <span class="p">(</span><span class="o">++</span><span class="n">attempt</span> <span class="o">&lt;</span> <span class="mi">1024</span> <span class="o">&amp;&amp;</span> <span class="n">read_value</span> <span class="o">!=</span> <span class="n">current_value</span><span class="p">);</span> <span class="c1">// Without the attempt check, DXC was generating horrific code, many orders of magnitude slower than with it for some reason</span>
</code></pre></div></div>

<p>For the transformation of transmittance and depth into the additive coefficients, and the code to later sample the resulting coefficients into a transmittance at a given depth, you should go read the original <a href="https://arxiv.org/abs/2201.00094">Wavelet Transparency</a> paper. There’s more details about the process described here and a better explanation than what I could ever do here.</p>

<p>However, I’ll put here my simplified version of the code to generate and sample coefficients I used for this post. Huge thanks to Max for his blessing and please go read their paper!</p>

<p>I’ll leave out of the snippets the coefficient addition and sampling, the addition would be something like the logic above, or any replacement that allows safe accumulation into the different coefficients. The sample would most likely be just a traditional texture sample. With that said, my coefficient generation code goes as follows:</p>

<div class="language-hlsl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">template</span><span class="o">&lt;</span><span class="kr">typename</span> <span class="n">floatN</span><span class="p">,</span> <span class="kr">typename</span> <span class="n">Coefficients_Type</span><span class="o">&gt;</span>
<span class="kt">void</span> <span class="nf">add_event_to_wavelets</span><span class="p">(</span><span class="k">inout</span> <span class="n">Coefficients_Type</span> <span class="n">coefficients</span><span class="p">,</span> <span class="n">floatN</span> <span class="n">signal</span><span class="p">,</span> <span class="n">float</span> <span class="n">depth</span><span class="p">)</span>
<span class="p">{</span>
    <span class="n">depth</span> <span class="o">*=</span> <span class="n">float</span><span class="p">(</span><span class="n">TRANSPARENCY_WAVELET_COEFFICIENT_COUNT</span><span class="o">-</span><span class="mi">1</span><span class="p">)</span> <span class="o">/</span> <span class="n">TRANSPARENCY_WAVELET_COEFFICIENT_COUNT</span><span class="p">;</span>

    <span class="n">int</span> <span class="n">index</span> <span class="o">=</span> <span class="nb">clamp</span><span class="p">(</span><span class="n">int</span><span class="p">(</span><span class="nb">floor</span><span class="p">(</span><span class="n">depth</span> <span class="o">*</span> <span class="n">TRANSPARENCY_WAVELET_COEFFICIENT_COUNT</span><span class="p">)),</span> <span class="mi">0</span><span class="p">,</span> <span class="n">TRANSPARENCY_WAVELET_COEFFICIENT_COUNT</span> <span class="o">-</span> <span class="mi">1</span><span class="p">);</span>
    <span class="n">index</span> <span class="o">+=</span> <span class="n">TRANSPARENCY_WAVELET_COEFFICIENT_COUNT</span> <span class="o">-</span> <span class="mi">1</span><span class="p">;</span>

    <span class="p">[</span><span class="nb">unroll</span><span class="p">]</span>
    <span class="k">for</span> <span class="p">(</span><span class="n">int</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="p">(</span><span class="n">TRANSPARENCY_WAVELET_RANK</span><span class="o">+</span><span class="mi">1</span><span class="p">);</span> <span class="o">++</span><span class="n">i</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">int</span> <span class="n">power</span> <span class="o">=</span> <span class="n">TRANSPARENCY_WAVELET_RANK</span> <span class="o">-</span> <span class="n">i</span><span class="p">;</span>
        <span class="n">int</span> <span class="n">new_index</span> <span class="o">=</span> <span class="p">(</span><span class="n">index</span> <span class="o">-</span> <span class="mi">1</span><span class="p">)</span> <span class="o">&gt;&gt;</span> <span class="mi">1</span><span class="p">;</span>
        <span class="n">float</span> <span class="n">k</span> <span class="o">=</span> <span class="n">float</span><span class="p">((</span><span class="n">new_index</span> <span class="o">+</span> <span class="mi">1</span><span class="p">)</span> <span class="o">&amp;</span> <span class="p">((</span><span class="mi">1u</span> <span class="o">&lt;&lt;</span> <span class="n">power</span><span class="p">)</span> <span class="o">-</span> <span class="mi">1</span><span class="p">));</span>

        <span class="n">int</span> <span class="n">wavelet_sign</span> <span class="o">=</span> <span class="p">((</span><span class="n">index</span> <span class="o">&amp;</span> <span class="mi">1</span><span class="p">)</span> <span class="o">&lt;&lt;</span> <span class="mi">1</span><span class="p">)</span> <span class="o">-</span> <span class="mi">1</span><span class="p">;</span>
        <span class="n">float</span> <span class="n">wavelet_phase</span> <span class="o">=</span> <span class="p">((</span><span class="n">index</span> <span class="o">+</span> <span class="mi">1</span><span class="p">)</span> <span class="o">&amp;</span> <span class="mi">1</span><span class="p">)</span> <span class="o">*</span> <span class="nb">exp2</span><span class="p">(</span><span class="o">-</span><span class="n">power</span><span class="p">);</span>
        <span class="n">floatN</span> <span class="n">addend</span> <span class="o">=</span> <span class="nb">mad</span><span class="p">(</span><span class="nb">mad</span><span class="p">(</span><span class="o">-</span><span class="nb">exp2</span><span class="p">(</span><span class="o">-</span><span class="n">power</span><span class="p">),</span> <span class="n">k</span><span class="p">,</span> <span class="n">depth</span><span class="p">),</span> <span class="n">wavelet_sign</span><span class="p">,</span> <span class="n">wavelet_phase</span><span class="p">)</span> <span class="o">*</span> <span class="nb">exp2</span><span class="p">(</span><span class="n">power</span> <span class="o">*</span> <span class="mi">0</span><span class="p">.</span><span class="mi">5</span><span class="p">)</span> <span class="o">*</span> <span class="n">signal</span><span class="p">;</span>
        <span class="n">coefficients</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="n">new_index</span><span class="p">,</span> <span class="n">addend</span><span class="p">);</span>

        <span class="n">index</span> <span class="o">=</span> <span class="n">new_index</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="n">floatN</span> <span class="n">addend</span> <span class="o">=</span> <span class="nb">mad</span><span class="p">(</span><span class="n">signal</span><span class="p">,</span> <span class="o">-</span><span class="n">depth</span><span class="p">,</span> <span class="n">signal</span><span class="p">);</span>
    <span class="n">coefficients</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="n">TRANSPARENCY_WAVELET_COEFFICIENT_COUNT</span> <span class="o">-</span> <span class="mi">1</span><span class="p">,</span> <span class="n">addend</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The counterpart of that code would then take the array of coefficients and evaluate them at a given normalized depth, which you can see in the following snippet:</p>

<div class="language-hlsl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">template</span><span class="o">&lt;</span><span class="kr">typename</span> <span class="n">floatN</span><span class="p">,</span> <span class="kr">typename</span> <span class="n">Coefficients_Type</span><span class="o">&gt;</span>
<span class="n">floatN</span> <span class="nf">evaluate_wavelet_index</span><span class="p">(</span><span class="k">in</span> <span class="n">Coefficients_Type</span> <span class="n">coefficients</span><span class="p">,</span> <span class="n">int</span> <span class="n">index</span><span class="p">)</span>
<span class="p">{</span>
    <span class="n">floatN</span> <span class="n">result</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>

    <span class="n">index</span> <span class="o">+=</span> <span class="n">TRANSPARENCY_WAVELET_COEFFICIENT_COUNT</span> <span class="o">-</span> <span class="mi">1</span><span class="p">;</span>
    <span class="p">[</span><span class="nb">unroll</span><span class="p">]</span>
    <span class="k">for</span> <span class="p">(</span><span class="n">int</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="p">(</span><span class="n">TRANSPARENCY_WAVELET_RANK</span><span class="o">+</span><span class="mi">1</span><span class="p">);</span> <span class="o">++</span><span class="n">i</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">int</span> <span class="n">power</span> <span class="o">=</span> <span class="n">TRANSPARENCY_WAVELET_RANK</span> <span class="o">-</span> <span class="n">i</span><span class="p">;</span>
        <span class="n">int</span> <span class="n">new_index</span> <span class="o">=</span> <span class="p">(</span><span class="n">index</span> <span class="o">-</span> <span class="mi">1</span><span class="p">)</span> <span class="o">&gt;&gt;</span> <span class="mi">1</span><span class="p">;</span>
        <span class="n">floatN</span> <span class="n">coeff</span> <span class="o">=</span> <span class="n">coefficients</span><span class="p">.</span><span class="k">sample</span><span class="p">(</span><span class="n">new_index</span><span class="p">);</span>
        <span class="n">int</span> <span class="n">wavelet_sign</span> <span class="o">=</span> <span class="p">((</span><span class="n">index</span> <span class="o">&amp;</span> <span class="mi">1</span><span class="p">)</span> <span class="o">&lt;&lt;</span> <span class="mi">1</span><span class="p">)</span> <span class="o">-</span> <span class="mi">1</span><span class="p">;</span>
        <span class="n">result</span> <span class="o">-=</span> <span class="nb">exp2</span><span class="p">(</span><span class="n">float</span><span class="p">(</span><span class="n">power</span><span class="p">)</span> <span class="o">*</span> <span class="mi">0</span><span class="p">.</span><span class="mi">5</span><span class="p">)</span> <span class="o">*</span> <span class="n">coeff</span> <span class="o">*</span> <span class="n">wavelet_sign</span><span class="p">;</span>
        <span class="n">index</span> <span class="o">=</span> <span class="n">new_index</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="n">result</span><span class="p">;</span>
<span class="p">}</span>
<span class="kr">template</span><span class="o">&lt;</span><span class="kr">typename</span> <span class="n">floatN</span><span class="p">,</span> <span class="kr">typename</span> <span class="n">Coefficients_Type</span><span class="o">&gt;</span>
<span class="n">floatN</span> <span class="nf">evaluate_wavelets</span><span class="p">(</span><span class="k">in</span> <span class="n">Coefficients_Type</span> <span class="n">coefficients</span><span class="p">,</span> <span class="n">float</span> <span class="n">depth</span><span class="p">)</span>
<span class="p">{</span>
    <span class="n">floatN</span> <span class="n">scale_coefficient</span> <span class="o">=</span> <span class="n">coefficients</span><span class="p">.</span><span class="k">sample</span><span class="p">(</span><span class="n">TRANSPARENCY_WAVELET_COEFFICIENT_COUNT</span> <span class="o">-</span> <span class="mi">1</span><span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span><span class="nb">all</span><span class="p">(</span><span class="n">scale_coefficient</span> <span class="o">==</span> <span class="mi">0</span><span class="p">))</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="n">depth</span> <span class="o">*=</span> <span class="n">float</span><span class="p">(</span><span class="n">TRANSPARENCY_WAVELET_COEFFICIENT_COUNT</span><span class="o">-</span><span class="mi">1</span><span class="p">)</span> <span class="o">/</span> <span class="n">TRANSPARENCY_WAVELET_COEFFICIENT_COUNT</span><span class="p">;</span>

    <span class="n">float</span> <span class="n">coefficient_depth</span> <span class="o">=</span> <span class="n">depth</span> <span class="o">*</span> <span class="n">TRANSPARENCY_WAVELET_COEFFICIENT_COUNT</span><span class="p">;</span>
    <span class="n">int</span> <span class="n">index</span> <span class="o">=</span> <span class="nb">clamp</span><span class="p">(</span><span class="n">int</span><span class="p">(</span><span class="nb">floor</span><span class="p">(</span><span class="n">coefficient_depth</span><span class="p">)),</span> <span class="mi">0</span><span class="p">,</span> <span class="n">TRANSPARENCY_WAVELET_COEFFICIENT_COUNT</span> <span class="o">-</span> <span class="mi">1</span><span class="p">);</span>

    <span class="n">floatN</span> <span class="n">a</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
    <span class="n">floatN</span> <span class="n">b</span> <span class="o">=</span> <span class="n">scale_coefficient</span> <span class="o">+</span> <span class="n">evaluate_wavelet_index</span><span class="o">&lt;</span><span class="n">floatN</span><span class="p">,</span> <span class="n">Coefficients_Type</span><span class="o">&gt;</span><span class="p">(</span><span class="n">coefficients</span><span class="p">,</span> <span class="n">index</span><span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">index</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span> <span class="n">a</span> <span class="o">=</span> <span class="n">scale_coefficient</span> <span class="o">+</span> <span class="n">evaluate_wavelet_index</span><span class="o">&lt;</span><span class="n">floatN</span><span class="p">,</span> <span class="n">Coefficients_Type</span><span class="o">&gt;</span><span class="p">(</span><span class="n">coefficients</span><span class="p">,</span> <span class="n">index</span> <span class="o">-</span> <span class="mi">1</span><span class="p">);</span> <span class="p">}</span>

    <span class="n">float</span> <span class="n">t</span> <span class="o">=</span> <span class="n">coefficient_depth</span> <span class="o">&gt;=</span> <span class="n">TRANSPARENCY_WAVELET_COEFFICIENT_COUNT</span> <span class="o">?</span> <span class="mi">1</span><span class="p">.</span><span class="mi">0</span> <span class="o">:</span> <span class="nb">frac</span><span class="p">(</span><span class="n">coefficient_depth</span><span class="p">);</span>
    <span class="n">floatN</span> <span class="n">signal</span> <span class="o">=</span> <span class="nb">lerp</span><span class="p">(</span><span class="n">a</span><span class="p">,</span> <span class="n">b</span><span class="p">,</span> <span class="n">t</span><span class="p">);</span> <span class="c1">// You can experiment here with different types of interpolation as well</span>
    <span class="k">return</span> <span class="n">signal</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Note that you can get some good gains by further optimizing this, especially by combining the multiple calls to <code class="language-c highlighter-rouge"><span class="n">evaluate_wavelet_index</span><span class="p">(...)</span></code> into the same loop, thus avoiding redundant samples. But the versions further optimized are a bit less clear and they can be a great candidate for a further post about squeezing this further and making it faster 🦹. That said, an example further along in this post shows <code class="language-c highlighter-rouge"><span class="n">evaluate_wavelets</span></code> with the merged loops 😉.</p>

<p>The code above could encode any additive signal, so they could be used for other purposes as well. Something worth pointing out too is that <code class="language-c highlighter-rouge"><span class="n">depth</span></code> here is already normalized between the transparency min and max depth bounds. With that in mind, here’s a couple wrappers to add and sample transmittance specifically:</p>

<div class="language-hlsl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">template</span><span class="o">&lt;</span><span class="kr">typename</span> <span class="n">floatN</span><span class="p">,</span> <span class="kr">typename</span> <span class="n">Coefficients_Type</span><span class="o">&gt;</span>
<span class="kt">void</span> <span class="nf">add_transmittance_event_to_wavelets</span><span class="p">(</span><span class="k">inout</span> <span class="n">Coefficients_Type</span> <span class="n">coefficients</span><span class="p">,</span> <span class="n">floatN</span> <span class="n">transmittance</span><span class="p">,</span> <span class="n">float</span> <span class="n">depth</span><span class="p">)</span>
<span class="p">{</span>
    <span class="n">floatN</span> <span class="n">absorbance</span> <span class="o">=</span> <span class="o">-</span><span class="nb">log</span><span class="p">(</span><span class="nb">max</span><span class="p">(</span><span class="n">transmittance</span><span class="p">,</span> <span class="mi">0</span><span class="p">.</span><span class="mo">00001</span><span class="p">));</span> <span class="c1">// transforming the signal from multiplicative transmittance to additive absorbance</span>
    <span class="n">add_event_to_wavelets</span><span class="p">(</span><span class="n">coefficients</span><span class="p">,</span> <span class="n">absorbance</span><span class="p">,</span> <span class="n">depth</span><span class="p">);</span>
<span class="p">}</span>

<span class="kr">template</span><span class="o">&lt;</span><span class="kr">typename</span> <span class="n">floatN</span><span class="p">,</span> <span class="kr">typename</span> <span class="n">Coefficients_Type</span><span class="o">&gt;</span>
<span class="n">floatN</span> <span class="nf">evaluate_transmittance_wavelets</span><span class="p">(</span><span class="k">in</span> <span class="n">Coefficients_Type</span> <span class="n">coefficients</span><span class="p">,</span> <span class="n">float</span> <span class="n">depth</span><span class="p">)</span>
<span class="p">{</span>
    <span class="n">floatN</span> <span class="n">absorbance</span> <span class="o">=</span> <span class="n">evaluate_wavelets</span><span class="o">&lt;</span><span class="n">floatN</span><span class="o">&gt;</span><span class="p">(</span><span class="n">coefficients</span><span class="p">,</span> <span class="n">depth</span><span class="p">);</span>
    <span class="k">return</span> <span class="nb">saturate</span><span class="p">(</span><span class="nb">exp</span><span class="p">(</span><span class="o">-</span><span class="n">absorbance</span><span class="p">));</span> <span class="c1">// undoing the transformation from absorbance back to transmittance</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="transmittance-function-examples">Transmittance Function Examples</h3>

<p>After adding up all the coefficients, we effectively have recreated the full function of transmittance over the depth. Let’s look at some pixels that range from having 1 to 4 surfaces to see how the transmittance falls at the depths where the surface is. Note that this is plotting the transmittance over the normalized depth within the depth bounds we have computed in the beginning, which means that towards the left, transmittance will always be $(1,1,1)$ and towards the right it will always be the same as the last value plotted.</p>

<p>In the left image there is a single magenta ball, which has a transmittance of $(1,0,1)$ because it’s letting through the red and blue channels, while fully occluding green light. The plot is showing how the green light quickly goes to zero and all that’s left is a slightly darkened magenta.</p>

<p>To the right of it you can see two overlapping cyan balls, with a transmittance of $(0,1,1)$. These show how multiple overlapping surfaces of the same color keep decreasing transmittance at each event, so the transmittance after both of them is an even darker cyan. This is what we would expect, the first ball is occluding all red light, and letting through a percentage of the green and blue. Then the second ball is occluding the remaining green and blue light by yet another percentage (and fully occluding red light, but that was already zero).</p>

<table>
  <tbody>
    <tr>
      <td><img src="OIT/plotting_transmittance_events_1.png" alt="" /></td>
      <td><img src="OIT/plotting_transmittance_events_2.png" alt="" /></td>
    </tr>
  </tbody>
</table>

<p>Here there’s a couple of more complicated cases. First one where there is a magenta, yellow and cyan ball in that order. In this case, each are letting through only 2 of the red, green and blue channels. After light has gone through all 3, every channel must have been fully occluded at some point, so no light can possibly be going through all 3 balls, hence the transmittance falling to zero at the end of the plot.</p>

<p>This also hints at why I chose these colors for the balls in the example scene, it makes it easier to visualize how the 3 channels fall if we use the 3 primary colors of a subtractive color system, since they naturally generate known results when combined, and eventually zero/black.</p>

<table>
  <tbody>
    <tr>
      <td><img src="OIT/plotting_transmittance_events_3.png" alt="" /></td>
      <td><img src="OIT/plotting_transmittance_events_4.png" alt="" /></td>
    </tr>
  </tbody>
</table>

<h2 id="shading-transparency">Shading Transparency</h2>

<p>Now we can go over the transparency draws in a “traditional” forward-rendering way, shade them fully and occlude them by the transmittance in front before blending them in.</p>

<p>We’re rendering our transparent draws on top of the fully shaded opaque layer, so we would first need to obscure the opaque layer by the transmittance of every transparent draw in front, this could be done with a full-screen pass in various ways. An easy one would be to set up multiplicative blending and do the following:</p>

<div class="language-hlsl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">float3</span> <span class="nf">fullscreen_pixel_shader</span><span class="p">()</span>
<span class="p">{</span>
    <span class="kt">float4</span> <span class="n">clip_position</span> <span class="o">=</span> <span class="cm">/*...*/</span><span class="p">;</span>
    <span class="n">float</span> <span class="n">depth</span> <span class="o">=</span> <span class="o">+</span><span class="n">FLOAT32_INFINITE</span><span class="p">;</span>

    <span class="kt">float3</span> <span class="n">transmittance_in_front_of_opaque_layer</span> <span class="o">=</span> <span class="n">sample_transmittance</span><span class="p">(</span><span class="n">clip_position</span><span class="p">.</span><span class="n">xy</span><span class="p">,</span> <span class="n">depth</span><span class="p">);</span>
    <span class="k">return</span> <span class="n">transmittance_in_front_of_opaque_layer</span><span class="p">;</span> <span class="c1">// final_rgb = this.rgb * render_target.rgb</span>
<span class="p">}</span>
</code></pre></div></div>

<p>After running a shader like the above, the opaque layer with the applied transmittance on top of it would look as follows on our example scene:</p>

<p><img src="OIT/opaque_layer_occluded_by_transmittance.png" alt="" /></p>

<p>Then I go over then transparent draws in any order. Now the blending can be fully additive since we’re only adding the light that the current draw is contributing to the final image. The necessary occlusion for the current draw is handled by the <code class="language-c highlighter-rouge"><span class="n">transmittance_in_front</span></code>, and the occlusion of whatever is behind the current draw will be handled by those draws that are behind whenever they do their shading.</p>

<p>The code for these draws could look like the following, with a purely additive blending state:</p>

<div class="language-hlsl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">float3</span> <span class="nf">pixel_shader</span><span class="p">(</span><span class="kt">float4</span> <span class="n">clip_position</span><span class="p">)</span>
<span class="p">{</span>
    <span class="n">float</span>  <span class="n">depth</span>           <span class="o">=</span> <span class="cm">/*...*/</span><span class="p">;</span>
    <span class="kt">float3</span> <span class="n">lighting_result</span> <span class="o">=</span> <span class="cm">/*...*/</span><span class="p">;</span>

    <span class="kt">float3</span> <span class="n">transmittance_in_front</span> <span class="o">=</span> <span class="n">sample_transmittance</span><span class="p">(</span><span class="n">clip_position</span><span class="p">.</span><span class="n">xy</span><span class="p">,</span> <span class="n">depth</span><span class="p">);</span>
    <span class="k">return</span> <span class="n">lighting_result</span> <span class="o">*</span> <span class="n">transmittance_in_front</span><span class="p">;</span> <span class="c1">// final_rgb = this.rgb + render_target.rgb</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Similarly to the transmittance, we can visualize how the spheres now get rendered in an arbitrary order. Each of them adding their light occluded by whatever is in front. Note how this visually resolves ordering due to how we’re interpreting whatever is more occluded as being behind and vice-versa.</p>

<p>
    <video controls=""> 
        <source src="OIT/appearing_frames_light.mp4" type="video/mp4" />
    </video>
</p>

<p>And after this, we’re done! Every draw is contributing to the final image by the right amount, and all the passes we’ve done could have rendered each transparency draw in any order.</p>

<h1 id="extra-sauce">Extra Sauce</h1>

<h2 id="overdraw-prevention">Overdraw Prevention</h2>

<p>We’ve also gone through <del>not-that-much</del> <em>all this</em> pain to generate our transmittance-over-depth function, why not take advantage of it? Here is a good way I found to put it to good use.</p>

<p>After we’ve generated the transmittance, we can take a look at the resulting function and see if it ever becomes zero, if it does, that means that nothing after that point in the depth range is visible, so let’s just write to a depth buffer and use that for doing transparency shading!</p>

<p>Opaque rendering does just this, because you know that the transmittance after a fully opaque pixel is zero, you can happily write depth and after that, you don’t need to do any shading for depths that lay behind. Transparency rendering is a super-set of that, where transmittance is not binary, but once it’s zero, we’re at the same situation.</p>

<p>By writing depth and testing against that depth we’re avoiding potentially a lot of shading that would have been useless. This is a classic problem with transparency, you have 30 smoke particles that are so dense that you only see the closest 5 or 6 layers, but if you’re following a traditional transparency pipeline you’re having to shade them all. All that work shading the first few ones was wasted, since none of that contributed to the final image.</p>

<p>To do this, after the transmittance generation step, you could run a full-screen draw like the following, where we look for a depth at which it’s safe to consider transmittance to be so low that anything behind won’t contribute to the final result. This could of course be done in many other ways, but the general idea remains the same:</p>

<div class="language-hlsl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">float</span> <span class="nf">pixel_shader</span><span class="p">()</span> <span class="o">:</span> <span class="nb">SV_DEPTH</span>
<span class="p">{</span>
    <span class="kt">float4</span> <span class="n">clip_position</span> <span class="o">=</span> <span class="cm">/*...*/</span><span class="p">;</span>
    <span class="n">float</span> <span class="n">threshold</span> <span class="o">=</span> <span class="mi">0</span><span class="p">.</span><span class="mo">0001</span><span class="p">;</span>

    <span class="c1">//</span>
    <span class="c1">// If transmittance an infinite depth is above the threshold, it doesn't ever become</span>
    <span class="c1">// zero, so we can bail out.</span>
    <span class="c1">//</span>
    <span class="kt">float3</span> <span class="n">transmittance_at_far_depth</span> <span class="o">=</span> <span class="n">sample_transmittance</span><span class="p">(</span><span class="n">clip_position</span><span class="p">.</span><span class="n">xy</span><span class="p">,</span> <span class="o">+</span><span class="n">FLOAT32_INFINITY</span><span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span><span class="nb">all</span><span class="p">(</span><span class="n">transmittance_at_far_depth</span> <span class="o">&lt;=</span> <span class="n">threshold</span><span class="p">))</span>
    <span class="p">{</span>
        <span class="n">float</span> <span class="n">normalized_depth_at_zero_transmittance</span> <span class="o">=</span> <span class="mi">1</span><span class="p">.</span><span class="mi">0</span><span class="p">;</span>
        <span class="n">float</span> <span class="n">sample_depth</span> <span class="o">=</span> <span class="mi">0</span><span class="p">.</span><span class="mi">5</span><span class="p">;</span>
        <span class="n">float</span> <span class="n">delta</span> <span class="o">=</span> <span class="mi">0</span><span class="p">.</span><span class="mi">25</span><span class="p">;</span>

        <span class="c1">//</span>
        <span class="c1">// Quick &amp; Dirty way to binary search through the transmittance function</span>
        <span class="c1">// looking for a value that's below the threshold.</span>
        <span class="c1">//</span>
        <span class="n">int</span> <span class="n">steps</span> <span class="o">=</span> <span class="mi">6</span><span class="p">;</span>
        <span class="k">for</span> <span class="p">(</span><span class="n">int</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">steps</span><span class="p">;</span> <span class="o">++</span><span class="n">i</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="kt">float3</span> <span class="n">transmittance</span> <span class="o">=</span> <span class="n">sample_transmittance</span><span class="p">(</span><span class="n">clip_position</span><span class="p">.</span><span class="n">xy</span><span class="p">,</span> <span class="n">sample_depth</span><span class="p">);</span>
            <span class="k">if</span> <span class="p">(</span><span class="nb">all</span><span class="p">(</span><span class="n">transmittance</span> <span class="o">&lt;=</span> <span class="n">threshold</span><span class="p">))</span>
            <span class="p">{</span>
                <span class="n">normalized_depth_at_zero_transmittance</span> <span class="o">=</span> <span class="n">sample_depth</span><span class="p">;</span>
                <span class="n">sample_depth</span> <span class="o">-=</span> <span class="n">delta</span><span class="p">;</span>
            <span class="p">}</span>
            <span class="k">else</span>
            <span class="p">{</span>
                <span class="n">sample_depth</span> <span class="o">+=</span> <span class="n">delta</span><span class="p">;</span>
            <span class="p">}</span>
            <span class="n">delta</span> <span class="o">*=</span> <span class="mi">0</span><span class="p">.</span><span class="mi">5</span><span class="p">;</span>
        <span class="p">}</span>

        <span class="c1">//</span>
        <span class="c1">// Searching inside the transparency depth bounds, so have to transform that to</span>
        <span class="c1">// a world-space linear-depth and that into a device depth we can output into</span>
        <span class="c1">// the currently bound depth buffer.</span>
        <span class="c1">//</span>
        <span class="n">float</span> <span class="n">device_depth</span> <span class="o">=</span> <span class="n">device_depth_from_normalized_transparency_depth</span><span class="p">(</span><span class="n">normalized_depth_at_zero_transmittance</span><span class="p">);</span>

        <span class="k">return</span> <span class="n">device_depth</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Note how here we have to do <code class="language-c highlighter-rouge"><span class="k">if</span> <span class="p">(</span><span class="n">all</span><span class="p">(</span><span class="n">transmittance</span> <span class="o">&lt;=</span> <span class="n">threshold</span><span class="p">))</span></code> meaning that we’re checking that all the components of our polychrome transmittance have become zero. If all components are not zero it means that there’s still some light frequencies that can be visible behind a given depth.</p>

<p>Here is a visualization of where this code has written depth due to finding a point at which transmittance became zero. You can see how this triggers in the areas where multiple spheres overlap, especially if they are different colors because they would each be occluding light from different frequencies.</p>

<p>
    <video controls="" loop="" autoplay=""> 
        <source src="OIT/looping_frames_zero_transmittance.mp4" type="video/mp4" />
    </video>
</p>

<p>In the checkerboard pixels, we are avoiding doing any shading for some of the transparent surfaces that lay after a certain depth.</p>

<h2 id="occluding-opaque-layer">Occluding Opaque Layer</h2>

<p>We can extend this previous idea further. If we generate transmittance before we do the shading of our opaque layer, it means that we know about this transmittance-over-depth when we’re shading the opaque layer as well. So first thing we can do is to also sample this transparency depth we wrote and avoid shading the opaque draws that will be fully occluded by transparent draws.</p>

<p>This requires the slightly unusual approach of doing a lot of your transparency rendering before your opaque stuff, but there’s not much going against that. One thing that’s useful to do <em>before</em> any transparency rendering is to get some sort of depth you can use. Lots of renderers already do a depth-prepass, or a GBuffer or generate a VBuffer (<a href="https://jcgt.org/published/0002/02/04/">visibility buffer</a>), etc. All of which generate a depth buffer before doing any shading.</p>

<p>In my case I render a <a href="https://jcgt.org/published/0002/02/04/">visibility buffer</a> first (including depth), then I generate the transmittance and write depth at zero transmittance (to a separate depth buffer, cause you probably want to keep the opaque-only depth buffer for other stuff). The I use that to test against when shading both the opaque layer and the transparent draws.</p>

<p>This adds some more savings to those pathological cases for transparency mentioned in the previous section. However, if transmittance is not quite reaching zero you can still put this information to good use, for example, by using <a href="https://wickedengine.net/2020/09/06/variable-rate-shading-first-impressions/">variable-rate shading</a> to make those pixels cheaper, since they’re going to be partially occluded anyway and might not need that high-frequency detail.</p>

<h2 id="separate-compositing-pass">Separate Compositing Pass</h2>

<p>Instead of rendering directly on top of the shaded opaque layer, you can render to a separate target instead and composite transparency on top of opaque in a separate pass.</p>

<p>This decouples transparency rendering from opaque almost completely. We’re still using the opaque layer’s depth buffer to initially test against but this is often already available even before opaque shading in any sort of “deferred” renderer. Forward renderers still often have a depth pre-pass that we could use as well.</p>

<p>This opens the possibility of rendering transparency at a lower resolution than opaque, this could even be made dynamic depending on the cost of transparency in a given scene. This could help handle classic frame-time spikes that happen when lots of low-frequency detail transparency fills the screen, such as big explosion or smoke effects.</p>

<p>Also allows to run dedicated full-screen passes on the transparency results only. For example extra transparency anti-aliasing or de-noising.</p>

<p>During compositing, I’m also seeing how much the opaque result is different from the composited final result and storing that in the alpha channel of the result lighting buffer. This can be used as a value that represents how much of the opaque layer is visible, which comes in handy tremendously during Post-FX, especially for things that rely on motion vectors and/or depth from the opaque draws such as temporal anti-aliasing, depth-of-field, etc.</p>

<p>This is also good to pass to various up-scaling technologies in the market to allow them to handle transparency more appropriately. A good example of this are the <a href="https://gpuopen.com/manuals/fidelityfx_sdk/fidelityfx_sdk-page_techniques_super-resolution-temporal/#id17">reactive</a> and <a href="https://gpuopen.com/manuals/fidelityfx_sdk/fidelityfx_sdk-page_techniques_super-resolution-temporal/#id23">transparency</a> masks for <a href="https://gpuopen.com/manuals/fidelityfx_sdk/fidelityfx_sdk-page_techniques_super-resolution-temporal">AMD’s FidelityFX Super-Resolution</a>.</p>

<p>Deferring the blending on top of the opaque layer also opens up the possibility of writing extra data during the shading pass to do more effects during composition. For example we could output a diffusion factor or refraction deltas like <a href="https://casual-effects.com/research/McGuire2017Transparency/index.html">Phenomenological Transparency</a> does and apply those effects at the compositing stage.</p>

<p>Something that should be noted is if you’re doing memory aliasing of the rendering resources used throughout your frame, you might want to move things around. What you wouldn’t want is to have the big coefficients array be kept alive during all your opaque shading passes. If you do want to generate transmittance before opaque shading, it’s a good idea to resolve transparency before opaque as well and store all you need to do during the final compositing pass after you’ve shaded the opaque layer. This would make the largest memory requirement to be quite short lived in the frame, likely reducing the upper bound for memory requirements.</p>

<p>A way to separate the passes would be to just remove the pass that previously occluded the opaque layer. Then have the transparency draws output something on their alpha channel to see where they wrote something.</p>

<div class="language-hlsl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">float4</span> <span class="nf">pixel_shader</span><span class="p">(</span><span class="kt">float4</span> <span class="n">clip_position</span><span class="p">)</span>
<span class="p">{</span>
    <span class="n">float</span>  <span class="n">depth</span>           <span class="o">=</span> <span class="cm">/*...*/</span><span class="p">;</span>
    <span class="kt">float3</span> <span class="n">lighting_result</span> <span class="o">=</span> <span class="cm">/*...*/</span><span class="p">;</span>

    <span class="kt">float3</span> <span class="n">transmittance_in_front</span> <span class="o">=</span> <span class="n">sample_transmittance</span><span class="p">(</span><span class="n">clip_position</span><span class="p">.</span><span class="n">xy</span><span class="p">,</span> <span class="n">depth</span><span class="p">);</span>
    <span class="n">lighting_result</span> <span class="o">*=</span> <span class="n">transmittance_in_front</span><span class="p">;</span>

    <span class="k">return</span> <span class="kt">float4</span><span class="p">(</span><span class="n">lighting_result</span><span class="p">,</span> <span class="mi">1</span><span class="p">.</span><span class="mi">0</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>And we can use this result, along with this alpha channel to help us early out in the compositing pass, which looks something like this:</p>

<div class="language-hlsl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">float4</span> <span class="nf">fullscreen_pixel_shader</span><span class="p">()</span>
<span class="p">{</span>
    <span class="kt">float4</span> <span class="n">clip_position</span> <span class="o">=</span> <span class="cm">/*...*/</span><span class="p">;</span>

    <span class="kt">float4</span> <span class="n">transparent_layer</span> <span class="o">=</span> <span class="n">sample_transparency_result</span><span class="p">(</span><span class="n">clip_position</span><span class="p">.</span><span class="n">xy</span><span class="p">);</span> <span class="c1">// Potentially lower resolution</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">transparent_layer</span><span class="p">.</span><span class="n">w</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">.</span><span class="mi">0</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="kt">float3</span> <span class="n">transmittance_in_front_of_opaque_layer</span> <span class="o">=</span> <span class="n">sample_transmittance</span><span class="p">(</span><span class="n">clip_position</span><span class="p">.</span><span class="n">xy</span><span class="p">,</span> <span class="n">depth</span><span class="p">);</span>

        <span class="kt">float3</span> <span class="n">opaque_layer</span> <span class="o">=</span> <span class="n">sample_opaque_result</span><span class="p">(</span><span class="n">clip_position</span><span class="p">.</span><span class="n">xy</span><span class="p">);</span>
        <span class="kt">float3</span> <span class="n">composited_light</span> <span class="o">=</span> <span class="p">(</span><span class="n">opaque_layer</span> <span class="o">*</span> <span class="n">transmittance_in_front_of_opaque_layer</span><span class="p">)</span> <span class="o">+</span> <span class="n">transparent_layer</span><span class="p">.</span><span class="n">rgb</span><span class="p">;</span>

        <span class="n">float</span> <span class="n">approximate_opaque_layer_visibility</span> <span class="o">=</span> <span class="n">luminance</span><span class="p">(</span><span class="n">transmittance_in_front_of_opaque_layer</span><span class="p">);</span> <span class="c1">// Could be done in a million other ways, depending on the usage</span>

        <span class="k">return</span> <span class="kt">float4</span><span class="p">(</span><span class="n">composited_light</span><span class="p">,</span> <span class="n">approximate_opaque_layer_visibility</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="applying-noise-to-transmittance">Applying noise to transmittance</h2>

<p>As much as the wavelet coefficients do a great job of representing transmittance, they can suffer from imprecision especially when a transmittance event falls in between certain depth ranges, even more so the lower the wavelet’s rank. To get around this I’m injecting the left half of triangle noise both when writing transmittance and when sampling it.</p>

<p>It only injects noise that moves the depth location towards zero, to avoid transmittance events self-occluding. This noise is also not applied towards the extremes of the depth function. Done via sampling a <a href="https://www.desmos.com/calculator/9t3fgpjln3">tent-like function</a> with the normalized depth.</p>

<p>More investigation could be done about where is the best place to inject noise, and which type of noise is the best for this purpose (e.g. blue noise tends to be just™ good at all these things).</p>

<p>Depending on the type of noise, strength, frame-rate and even type of scenes, it might be beneficial to run a dedicated de-noising pass on the transparency results. This would be made possible by decoupling transparency results from opaque and compositing later, as explained in the previous section. Alternatively, it might be that an existing Temporal Anti-Aliasing pass in your renderer would already be doing a good job of softening that noise.</p>

<p>This is an example of a scene that has a ton of overlap in a way that forces these artifacts, and how it looks when injecting noise on the transmittance function input followed by the denoised result. You might want to open these in a new tab to get a better look!</p>

<table>
  <tbody>
    <tr>
      <td>Raw</td>
      <td>With added noise</td>
      <td>Denoised</td>
    </tr>
    <tr>
      <td><img src="OIT/lots_of_overlap_raw.png" alt="" /></td>
      <td><img src="OIT/lots_of_overlap_with_noise.png" alt="" /></td>
      <td><img src="OIT/lots_of_overlap_denoised.png" alt="" /></td>
    </tr>
  </tbody>
</table>

<h2 id="avoiding-self-occlusion">Avoiding Self-Occlusion</h2>

<p>A neat trick that came from a conversation with <a href="https://bsky.app/profile/adrien-t.bsky.social">@adrien-t</a> (thanks! 😊) is to remove the contribution of the surface that’s sampling the transmittance in front of itself, but that also has contributed to transmittance in a previous pass. In this case, there could be some self-occlusion issues because of how the limited number of coefficients represent the function over depth.</p>

<p>The realization here is that, in the shading pass, we can take the generated coefficients and modify them to effectively create a local transmittance-over-depth function that doesn’t include the event that’s currently doing the sampling. Since the coefficients are additive, we can just repeat the logic as if we were to add the coefficients, but subtracting instead.</p>

<p>Here are some screenshots comparing the results when removing the contribution of the surface that’s sampling transmittance or not. These are with any injected noise and denoiser removed, with rank 2, and with a few extra spheres forced to overlap.</p>

<table>
  <tbody>
    <tr>
      <td>Avoiding Self-Occlusion <strong>Off</strong></td>
      <td>Avoiding Self-Occlusion <strong>On</strong></td>
    </tr>
    <tr>
      <td><img src="OIT/avoiding_self_occlusion_0_off.png" alt="" /></td>
      <td><img src="OIT/avoiding_self_occlusion_0_on.png" alt="" /></td>
    </tr>
    <tr>
      <td><img src="OIT/avoiding_self_occlusion_1_off.png" alt="" /></td>
      <td><img src="OIT/avoiding_self_occlusion_1_on.png" alt="" /></td>
    </tr>
    <tr>
      <td><img src="OIT/avoiding_self_occlusion_2_off.png" alt="" /></td>
      <td><img src="OIT/avoiding_self_occlusion_2_on.png" alt="" /></td>
    </tr>
  </tbody>
</table>

<p>Spent a good while trying to find the worst cases of self-occlusion and making a scene that would make them most noticeable. In most cases you aren’t likely to see them as obvious as here, but it’s still a great trick to have in mind and it noticeably improves the quality overall.</p>

<p>Implementing this is slightly simpler once you have merged the two calls to <code class="language-c highlighter-rouge"><span class="n">evaluate_wavelet_index</span></code> into the same loop, so if you have to subtract the contribution of the sampled surface, you only have to do it once per coefficient. Here’s a snippet of how <code class="language-c highlighter-rouge"><span class="n">evaluate_wavelets</span></code> might look like afterwards, note that <code class="language-c highlighter-rouge"><span class="n">evaluate_wavelet_index</span></code> is not necessary now:</p>

<div class="language-hlsl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">template</span><span class="o">&lt;</span><span class="kr">typename</span> <span class="n">floatN</span><span class="p">,</span> <span class="kr">typename</span> <span class="n">Coefficients_Type</span><span class="p">,</span> <span class="n">bool</span> <span class="n">REMOVE_SIGNAL</span> <span class="o">=</span> <span class="nb">true</span><span class="o">&gt;</span>
<span class="n">floatN</span> <span class="nf">evaluate_wavelets</span><span class="p">(</span><span class="k">in</span> <span class="n">Coefficients_Type</span> <span class="n">coefficients</span><span class="p">,</span> <span class="n">float</span> <span class="n">depth</span><span class="p">,</span> <span class="n">floatN</span> <span class="n">signal</span> <span class="o">=</span> <span class="mi">0</span><span class="p">)</span>
<span class="p">{</span>
    <span class="n">floatN</span> <span class="n">scale_coefficient</span> <span class="o">=</span> <span class="n">coefficients</span><span class="p">.</span><span class="k">sample</span><span class="p">(</span><span class="n">TRANSPARENCY_WAVELET_COEFFICIENT_COUNT</span> <span class="o">-</span> <span class="mi">1</span><span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span><span class="nb">all</span><span class="p">(</span><span class="n">scale_coefficient</span> <span class="o">==</span> <span class="mi">0</span><span class="p">))</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">REMOVE_SIGNAL</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">floatN</span> <span class="n">scale_coefficient_addend</span> <span class="o">=</span> <span class="nb">mad</span><span class="p">(</span><span class="n">signal</span><span class="p">,</span> <span class="o">-</span><span class="n">depth</span><span class="p">,</span> <span class="n">signal</span><span class="p">);</span>
        <span class="n">scale_coefficient</span> <span class="o">-=</span> <span class="n">scale_coefficient_addend</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="n">depth</span> <span class="o">*=</span> <span class="n">float</span><span class="p">(</span><span class="n">TRANSPARENCY_WAVELET_COEFFICIENT_COUNT</span><span class="o">-</span><span class="mi">1</span><span class="p">)</span> <span class="o">/</span> <span class="n">TRANSPARENCY_WAVELET_COEFFICIENT_COUNT</span><span class="p">;</span>

    <span class="n">float</span> <span class="n">coefficient_depth</span> <span class="o">=</span> <span class="n">depth</span> <span class="o">*</span> <span class="n">TRANSPARENCY_WAVELET_COEFFICIENT_COUNT</span><span class="p">;</span>
    <span class="n">int</span> <span class="n">index_b</span> <span class="o">=</span> <span class="nb">clamp</span><span class="p">(</span><span class="n">int</span><span class="p">(</span><span class="nb">floor</span><span class="p">(</span><span class="n">coefficient_depth</span><span class="p">)),</span> <span class="mi">0</span><span class="p">,</span> <span class="n">TRANSPARENCY_WAVELET_COEFFICIENT_COUNT</span> <span class="o">-</span> <span class="mi">1</span><span class="p">);</span>
    <span class="n">bool</span> <span class="n">sample_a</span> <span class="o">=</span> <span class="n">index_b</span> <span class="o">&gt;=</span> <span class="mi">1</span><span class="p">;</span>
    <span class="n">int</span> <span class="n">index_a</span> <span class="o">=</span> <span class="n">sample_a</span> <span class="o">?</span> <span class="p">(</span><span class="n">index_b</span> <span class="o">-</span> <span class="mi">1</span><span class="p">)</span> <span class="o">:</span> <span class="n">index_b</span><span class="p">;</span>

    <span class="n">index_b</span> <span class="o">+=</span> <span class="n">TRANSPARENCY_WAVELET_COEFFICIENT_COUNT</span> <span class="o">-</span> <span class="mi">1</span><span class="p">;</span>
    <span class="n">index_a</span> <span class="o">+=</span> <span class="n">TRANSPARENCY_WAVELET_COEFFICIENT_COUNT</span> <span class="o">-</span> <span class="mi">1</span><span class="p">;</span>

    <span class="n">floatN</span> <span class="n">b</span> <span class="o">=</span> <span class="n">scale_coefficient</span><span class="p">;</span>
    <span class="n">floatN</span> <span class="n">a</span> <span class="o">=</span> <span class="n">sample_a</span> <span class="o">?</span> <span class="n">scale_coefficient</span> <span class="o">:</span> <span class="mi">0</span><span class="p">;</span>

    <span class="p">[</span><span class="nb">unroll</span><span class="p">]</span>
    <span class="k">for</span> <span class="p">(</span><span class="n">int</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="p">(</span><span class="n">TRANSPARENCY_WAVELET_RANK</span><span class="o">+</span><span class="mi">1</span><span class="p">);</span> <span class="o">++</span><span class="n">i</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">int</span> <span class="n">power</span> <span class="o">=</span> <span class="n">TRANSPARENCY_WAVELET_RANK</span> <span class="o">-</span> <span class="n">i</span><span class="p">;</span>

        <span class="n">int</span> <span class="n">new_index_b</span> <span class="o">=</span> <span class="p">(</span><span class="n">index_b</span> <span class="o">-</span> <span class="mi">1</span><span class="p">)</span> <span class="o">&gt;&gt;</span> <span class="mi">1</span><span class="p">;</span>
        <span class="n">int</span> <span class="n">wavelet_sign_b</span> <span class="o">=</span> <span class="p">((</span><span class="n">index_b</span> <span class="o">&amp;</span> <span class="mi">1</span><span class="p">)</span> <span class="o">&lt;&lt;</span> <span class="mi">1</span><span class="p">)</span> <span class="o">-</span> <span class="mi">1</span><span class="p">;</span>
        <span class="n">floatN</span> <span class="n">coeff_b</span> <span class="o">=</span> <span class="n">coefficients</span><span class="p">.</span><span class="k">sample</span><span class="p">(</span><span class="n">new_index_b</span><span class="p">);</span>
        <span class="k">if</span> <span class="p">(</span><span class="n">REMOVE_SIGNAL</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="n">float</span> <span class="n">wavelet_phase_b</span> <span class="o">=</span> <span class="p">((</span><span class="n">index_b</span> <span class="o">+</span> <span class="mi">1</span><span class="p">)</span> <span class="o">&amp;</span> <span class="mi">1</span><span class="p">)</span> <span class="o">*</span> <span class="nb">exp2</span><span class="p">(</span><span class="o">-</span><span class="n">power</span><span class="p">);</span>
            <span class="n">float</span> <span class="n">k</span> <span class="o">=</span> <span class="n">float</span><span class="p">((</span><span class="n">new_index_b</span> <span class="o">+</span> <span class="mi">1</span><span class="p">)</span> <span class="o">&amp;</span> <span class="p">((</span><span class="mi">1u</span> <span class="o">&lt;&lt;</span> <span class="n">power</span><span class="p">)</span> <span class="o">-</span> <span class="mi">1</span><span class="p">));</span>
            <span class="n">floatN</span> <span class="n">addend</span> <span class="o">=</span> <span class="nb">mad</span><span class="p">(</span><span class="nb">mad</span><span class="p">(</span><span class="o">-</span><span class="nb">exp2</span><span class="p">(</span><span class="o">-</span><span class="n">power</span><span class="p">),</span> <span class="n">k</span><span class="p">,</span> <span class="n">depth</span><span class="p">),</span> <span class="n">wavelet_sign_b</span><span class="p">,</span> <span class="n">wavelet_phase_b</span><span class="p">)</span> <span class="o">*</span> <span class="nb">exp2</span><span class="p">(</span><span class="n">power</span> <span class="o">*</span> <span class="mi">0</span><span class="p">.</span><span class="mi">5</span><span class="p">)</span> <span class="o">*</span> <span class="n">signal</span><span class="p">;</span>
            <span class="n">coeff_b</span> <span class="o">-=</span> <span class="n">addend</span><span class="p">;</span>
        <span class="p">}</span>
        <span class="n">b</span> <span class="o">-=</span> <span class="nb">exp2</span><span class="p">(</span><span class="n">float</span><span class="p">(</span><span class="n">power</span><span class="p">)</span> <span class="o">*</span> <span class="mi">0</span><span class="p">.</span><span class="mi">5</span><span class="p">)</span> <span class="o">*</span> <span class="n">coeff_b</span> <span class="o">*</span> <span class="n">wavelet_sign_b</span><span class="p">;</span>
        <span class="n">index_b</span> <span class="o">=</span> <span class="n">new_index_b</span><span class="p">;</span>

        <span class="k">if</span> <span class="p">(</span><span class="n">sample_a</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="n">int</span> <span class="n">new_index_a</span> <span class="o">=</span> <span class="p">(</span><span class="n">index_a</span> <span class="o">-</span> <span class="mi">1</span><span class="p">)</span> <span class="o">&gt;&gt;</span> <span class="mi">1</span><span class="p">;</span>
            <span class="n">int</span> <span class="n">wavelet_sign_a</span> <span class="o">=</span> <span class="p">((</span><span class="n">index_a</span> <span class="o">&amp;</span> <span class="mi">1</span><span class="p">)</span> <span class="o">&lt;&lt;</span> <span class="mi">1</span><span class="p">)</span> <span class="o">-</span> <span class="mi">1</span><span class="p">;</span>
            <span class="n">floatN</span> <span class="n">coeff_a</span> <span class="o">=</span> <span class="p">(</span><span class="n">new_index_a</span> <span class="o">==</span> <span class="n">new_index_b</span><span class="p">)</span> <span class="o">?</span> <span class="n">coeff_b</span> <span class="o">:</span> <span class="n">coefficients</span><span class="p">.</span><span class="k">sample</span><span class="p">(</span><span class="n">new_index_a</span><span class="p">);</span> <span class="c1">// No addend here on purpose, the original signal didn't contribute to this coefficient</span>
            <span class="n">a</span> <span class="o">-=</span> <span class="nb">exp2</span><span class="p">(</span><span class="n">float</span><span class="p">(</span><span class="n">power</span><span class="p">)</span> <span class="o">*</span> <span class="mi">0</span><span class="p">.</span><span class="mi">5</span><span class="p">)</span> <span class="o">*</span> <span class="n">coeff_a</span> <span class="o">*</span> <span class="n">wavelet_sign_a</span><span class="p">;</span>
            <span class="n">index_a</span> <span class="o">=</span> <span class="n">new_index_a</span><span class="p">;</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="n">float</span> <span class="n">t</span> <span class="o">=</span> <span class="n">coefficient_depth</span> <span class="o">&gt;=</span> <span class="n">TRANSPARENCY_WAVELET_COEFFICIENT_COUNT</span> <span class="o">?</span> <span class="mi">1</span><span class="p">.</span><span class="mi">0</span> <span class="o">:</span> <span class="nb">frac</span><span class="p">(</span><span class="n">coefficient_depth</span><span class="p">);</span>

    <span class="k">return</span> <span class="nb">lerp</span><span class="p">(</span><span class="n">a</span><span class="p">,</span> <span class="n">b</span><span class="p">,</span> <span class="n">t</span><span class="p">);</span>
<span class="p">}</span>

</code></pre></div></div>

<p>I’ve put all of the code related to removing the contribution of the provided signal under <code class="language-c highlighter-rouge"><span class="n">REMOVE_SIGNAL</span></code>, so it’s both easy to find and potentially to remove if you want just the merged loop code.</p>

<h2 id="dynamic-rank-selection">Dynamic Rank Selection</h2>

<p>Not all scenes have the same transparency complexity and so they could get away with using lower ranks (meaning lower coefficient counts, memory and bandwidth usage) for a similar or even exact result.</p>

<p>We can use the initial depth pass to get an idea of the complexity of the transmittance function for a given pixel or tile of pixels. Then dynamically select a desired rank for that section and allocate that from a shared coefficient pool. If memory and bandwidth is a concern, especially at higher resolutions, this could help with alleviate that issue.</p>

<p>You could even allocate a smaller pool than necessary for the whole screen to be at the max allowed rank. In that case, the rank allocation pass would need to be able to detect overflow and fall back to a lower maximum rank.</p>

<p>Here you can see how in the example scene, the majority of output pixels have either no transparency at all or just one event (green). Only a few areas have two (yellow) or more (red) overlapping surfaces, and these would be the ones that would make use of the higher coefficient counts.</p>

<p><img src="OIT/counting_transparency_events_per_pixel.png" alt="" /></p>

<h1 id="overview-of-the-final-algorithm">Overview of the Final Algorithm</h1>

<p>Here is the outline of the current algorithm I’m running at the time of writing. It puts together the base idea of generating transmittance and shade later with some of the improvements mentioned above.</p>

<p>The resources that are explicit to this algorithm are the following. Currently all run at native render resolution, that is, the same resolution as the opaque pass:</p>

<ul>
  <li><strong>Transparency depth buffer</strong>: Single <code class="language-c highlighter-rouge"><span class="n">D32_FLOAT</span></code> texture.</li>
  <li><strong>Transparency depth bounds</strong>: Single <code class="language-c highlighter-rouge"><span class="n">R16G16_FLOAT</span></code> texture.</li>
  <li><strong>Transparency coefficients array</strong>: <code class="language-c highlighter-rouge"><span class="n">R9G9B9E5_SHAREDEXP</span></code> texture array of length 8 for rank 2, or length 16 for rank 3.</li>
  <li><strong>Transparency lighting buffer</strong>: Single <code class="language-c highlighter-rouge"><span class="n">R16G16B16A16_FLOAT</span></code> texture;</li>
</ul>

<p>And this is a high level view of what a single frame does to handle transparency:</p>

<ul>
  <li><em>Generate depth for the opaque layer (not covered here)</em>: In my case this comes from my <a href="https://jcgt.org/published/0002/02/04/">visibility buffer</a> pass.</li>
  <li><strong>Initialize the transparency buffers</strong>:
    <ul>
      <li>The transparency depth buffer is initialized to be a copy of opaque depth.</li>
      <li>The transparency depth bounds min/max are set to $(-\infty, 0)$ (minus infinite because min is inverted so we can max-blend into it).</li>
      <li>The coefficients texture array all gets cleared to zeroes.</li>
    </ul>
  </li>
  <li><strong>Render transparency depth</strong>: Go through all the transparency draws to generate only depth, writing min/max depth to the bounds.</li>
  <li><strong>Render transmittance</strong>: Going through all the transparency draws again but generating transmittance, this time generating the coefficients that get added to the coefficient texture array.</li>
  <li><strong>Writing depth at zero transmittance</strong>: Look for pixels where transmittance reaches zero and write depth to the transparency depth buffer at that point.</li>
  <li><strong>Shade transparency</strong>: Final draw for all transparent surfaces with full shading. This samples the transmittance previously generated and also depth tests against the transparency depth buffer, preventing us from shading surfaces that would have contributed zero to the final result. This renders into the transparency-only lighting buffer.</li>
  <li><em>Shade the opaque layer (not covered here)</em>: Resolving the <a href="https://jcgt.org/published/0002/02/04/">visibility buffer</a> to have a final opaque layer lighting result. This uses the transparency depth buffer to avoid shading pixels that will be fully covered later.</li>
  <li><strong>Composite</strong> the transparency lighting buffer on top of the opaque layer, occluding the opaque layer first via the transmittance at maximum depth.</li>
</ul>

<p>Note that all three of the rendering passes depth test against the transparency depth buffer. The first two could use the opaque depth, since at that point they are a copy of each other.</p>

<p>I’m also not currently running the denoising pass explicitly on the transparency results before compositing since my current temporal anti-aliasing solution already helps significantly. That said, as I add more complex draws with more overlapping surfaces (such as with particles) I anticipate it being a better idea to enable transparency denoising.</p>

<p>A test that proved useful when implementing this was to force opaque draws to go through this algorithm to verify that both ordering looks correct and there’s no light leaking through that should have been fully occluded. Here’s the example scene with all the balls having $(0,0,0)$ transmittance. You can see how it looks pretty much like opaque rendering would, which is the desired result.</p>

<p>
    <video controls="" loop="" autoplay=""> 
        <source src="OIT/looping_frames_opaque.mp4" type="video/mp4" />
    </video>
</p>

<h1 id="performance">Performance</h1>

<p>In the table in this section you can see the performance characteristics of the simple implementation I’m running at the time of writing for the example scene at the beginning of this post. This is running at $2560\times1440$ in a 3080 slightly under-clocked and rendering 200 transparent spheres.</p>

<p>The example scene just has a single directional light which makes the shading pass take a similar amount to transmittance generation, if we add 100 point lights scattered around the spheres we can see how only the shading pass becomes more expensive.</p>

<table>
  <thead>
    <tr>
      <th>Rank</th>
      <th>Extra Lights</th>
      <th>Depth Bounds</th>
      <th>Clearing Coefficients</th>
      <th>Generating Transmittance</th>
      <th>Writing Overdraw Depth</th>
      <th>Shading Transparency</th>
      <th>Composite Transparency</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>3</td>
      <td>0</td>
      <td>0.13ms</td>
      <td>0.17ms</td>
      <td>0.35ms</td>
      <td>0.06ms</td>
      <td>0.27ms</td>
      <td>0.04ms</td>
    </tr>
    <tr>
      <td>3</td>
      <td>100</td>
      <td>0.13ms</td>
      <td>0.17ms</td>
      <td>0.35ms</td>
      <td>0.06ms</td>
      <td>0.90ms</td>
      <td>0.04ms</td>
    </tr>
    <tr>
      <td>2</td>
      <td>100</td>
      <td>0.13ms</td>
      <td>0.09ms</td>
      <td>0.30ms</td>
      <td>0.06ms</td>
      <td>0.87ms</td>
      <td>0.04ms</td>
    </tr>
    <tr>
      <td>1</td>
      <td>100</td>
      <td>0.13ms</td>
      <td>0.05ms</td>
      <td>0.26ms</td>
      <td>0.05ms</td>
      <td>0.83ms</td>
      <td>0.04ms</td>
    </tr>
  </tbody>
</table>

<p>The numbers here aren’t terribly useful, since it heavily depends on how much work you would need to do in the different passes. For example, depending on the amount of vertex work you need to do in each draw, having to do multiple passes might affect your use case differently. Similarly, other types of draws like particles might behave way different.</p>

<p>In this implementation I’m also using the normal and view direction to vary the transmittance of the surface, this makes that pass need to do a significant amount of work more compared to just sampling transmittance and outputting it. If I remove using the normal for the transmittance the pass goes from 0.35ms to 0.17ms. This is a good example of the variability in cost depending on how much work each of the steps requires for a given use case.</p>

<p>As mentioned above, you could even do transparency at a lower resolution and upscale when compositing, or even do dynamic resolution on transparency.</p>

<p>Changing rank to 2 is also a very solid option and in this scene is essentially imperceptible, although the more surfaces contribute to a single pixel the more you would be able to see the difference. However, by adding noise and possibly a denoiser pass before compositing, the results might be indistinguishable in the majority of scenes.</p>

<p>I also haven’t made any real attempts at heavily optimizing this implementation. I might end up doing a deep dive into it with Nsight or Radeon GPU Profiler, which could be a good candidate to add in a further post about this technique!</p>

<p>And just because I find it satisfying to look at, here is the scene with the 100 lights:</p>

<p>
    <video controls="" loop="" autoplay=""> 
        <source src="OIT/looping_frames_with_lights.mp4" type="video/mp4" />
    </video>
</p>

<h1 id="conclusion--final-comments">Conclusion &amp; Final Comments</h1>

<p>I hope this motivates some people to go and implement some form of OIT and further develop these or new techniques! On my side I look forward to do further blog posts expanding on the ideas presented here or investigating new ones.</p>

<p>I think it would be really interesting to do a continuation of this post with more complex scenes, including elements like particles or volumetrics. It took me a while to write this post and it’s gotten longer than expected, so even now there’s interesting parts I would like to add about good ways of implementing volumetrics into this, marrying transparency with (separate? 🙃) temporal filters, etc. That said, I should eventually stop writing this at some point, and now feels like the right time.</p>

<p>Let me know if any of this is helpful, if there’s any questions, etc. It’s the first post on this site so there may or may not be a comments section. But regardless you can find me in most places as some variation of “osor_io” or “osor-io” and there should be links at the bottom of the page as well.</p>

<p>Cheers! 🍻</p>

<!-- # References -->]]></content><author><name>Rubén Osorio López</name></author><summary type="html"><![CDATA[Hello! This will be a first attempt at coming back to writing some blog posts about interesting topics I end up rabbitholing about. All the older stuff has been sadly lost to time (and “time” here mostly means a bad squarespace website).]]></summary></entry></feed>