Dark mode is now active. You can revert to light mode at any time by toggling the light bulb button.
Latest posts in Computer Graphics
How does anisotropic texture filtering work?
Domagoj Pandza on May 25th, 2021
Computer Graphics
Show in single article view

For most people, anisotropic filtering is something you just flick on for instant gratification and then proceed to forget about it because the hardware does all the work automagically. Gamers crank it up to the maximum magic number of 16 in settings because it seemingly has no performance impact and programmers read that setting and just write it into the sampler's description. They know it makes things less blurry at oblique angles, but seldom how it does it.

I've been asked to make the problem more approachable numerous times; to provide a guided derivation which doesn't make people want to jump off a cliff. Specifically, the request was to skirt any mention of signal processing which is a tall order. I've been reluctant for many moons because postulating the problem clearly for people without a strong background is a non-trivial affair... But let's give it a shot.

Basics of texture mapping

Gotta be easy, right?

Early on, rasterizers only dealt with generating procedural detail across the surface of rasterized triangles. To add more realism, texture mapping was introduced. The idea was to encode surface details into a 2D array, an image, a texture that was stored in memory and accessed at runtime as needed. The contents of it can be anything you want, from material information to a drawing of Hello Kitty. One "wraps" it around the geometry by creating a mapping between it and the vertices. This is a non-trivial, challenging and even artistic process which involves creatively flattening a 3D mesh ("unwrapping") into a normalized 2D space with minimal seams, then storing those 2D coordinates of flattened vertices on the vertices as an attribute which we colloquially refer to as "UVs". Naturally, when accessing a texture, the normalized texture coordinates are first scaled by the dimensions of the texture being accessed. Depending on texture addressing mode, texture coordinates leaving the normalized range can be well-defined by allowing wrapping around (enabling tiling), edge clamping etc.

Vertices, and their attributes, get projected to the screen during rasterization. But before we can use these coordinates to actually access any texture, we need to interpolate them from the corners of the triangle being rasterized to the pixel we're currently processing. Correctly interpolating all the vertex attributes is of paramount importance, we have to account for perspective. Simply put, under the perspective transform, uniform steps in the image plane (iterating pixel by pixel of uniform width and height) do not necessarily map to uniform steps in the plane containing the triangle being interpolated. Triangle vertices in view space can have high differences in depth and, thus, we have to effectively interpolate linearly in the plane of the triangle. You can find my derivation here, but you can easily see the gist of it in the diagram below, the higher the tilt with respect to the image plane, the smaller the projection of the triangle. Naively interpolating on the projected triangle is thus wrong as $s \neq t$ .

By finding the appropriate transform for said interpolation and fully simplifying it, we discover that the inverse of depth interpolates linearly in screen space. Knowing how to correctly interpolate $z$ allows us to solve for interpolated vertex attributes because the interpolation factor $t = (z - z_0) / (z_1 - z_0)$ is shared by any attribute $a$ being interpolated: $t = (a - a_0) / (a_1 - a_0)$. By equating the two, you can derive $a$. Some rearranging ahead, turns out vertex attributes divided by the associated vertex's depth interpolate linearly in screen space as $a/z = (1-t)a_0/z_0 + ta_1/z_1$, recovering the final value comes down to multiplying by the interpolated $z$. As texture coordinates are vertex attributes, we have our means for interpolating texture coordinates to pixel centers.

For those of you with a background in projective geometry with a solid grasp of homogeneous coordinates, none of this is news. Things of note to remember about projective mappings of any kind is that while they do preserve lines, they do not preserve parallel lines or equispaced points meaning that squares are mapped to generic quadrilaterals. Those are as useful as gasoline while trying to put out a fire at a nursing home.

So, what now? Do you just point sample the texture with the correctly interpolated texture coordinates? Hahah, no. That's your one-way ticket to Aliasville. To understand why not, consider your screen. A two-dimensional grid of square pixels (assume they're square for simplicity). Each pixel has a non-zero square area. Each, thus, must faithfully represent the area of a triangle projected to it. The projected triangle area may be covered by thousands of texels all competing for the attention of the poor single pixel, depending on the geometric relationship (distance, orientation) between the image plane and the plane containing the triangle. Huge area far away or tilted in view space projects to a tiny area. In other words, the partial derivatives of the texture coordinates wrt screen coordinates at the particular $(x,y)$ are huge.

Theoretically (and ideally), we can project the screen space pixel square into texture space where it morphs into a quadrilateral which covers the texels that desire to be considered. The goal is to iterate over all the texels covered by this quadrilateral (and not one more because ✨blur✨) and filter them into a single value to be used for the pixel being lit up. As you might imagine, this is computationally expensive to do at runtime. Any kind of direct convolution like this is, by definition.

In the 80s [notably Heckbert '86], a number of solutions performed the convolution offline, but with a twist. The unwieldy quadrilaterals are hard to handle mathematically, determining whether a texel is inside or out is non-trivial. Hence, Heckbert and those before him used to replace the projection with a local affine approximation, which approximates the unwieldy quadrilateral with a comparatively-tame parallelogram or an ellipse (if you modeled pixels as circles). This was still extremely expensive for realtime use, though, but it was doable. They used a weight LUT to actually figure out how each of the texels was weighted for the final filtered value to be computed. We can't afford the convolution at runtime, at all. We need to figure out a way to precompute this. At texture creation time, preferably.

Inventing "multum-in-parvo" maps

The multi-isotropic compromise

We can approximate a projection of the screen space pixel square (or circle) to a parallelogram (or ellipse) in texture space with a linear affine approximation, the details of which are the subject of next section. While that gives us the boundaries on the texels that require filtering, it's not particularly useful to us unless we can somehow prefilter the texture. It's not feasible to cover every possible "projected" parallelogram as you can see in the sample texture below:

Those are just a few examples and it is immediately obvious that no lookup structure can be devised here. What we could do is impose some restrictions on the filter shape, for example for it to be isotropic (same in every direction) and axis-aligned:

This kind of assumption makes creating the lookup structure a lot simpler, but it will not handle anisotropy well as a square naively approximating the parallelogram-shaped filter is going to capture texels outside of the filter shape, introducing overblurring in that direction. But if you think about it, we're not limited to picking just a single square. We can approximate a parallelogram-shaped filter by doing multiple square ones placed along the line of anisotropy (the "major axis" of the parallelogram):

To facilitate this isotropic prefilter structure, known as a mipmap, we create a hierarchy of textures where each level is quarter (or half if you're looking at dimensions individually) the resolution of the previous level and the data stored in it are the filtered values of all the $2 \times 2$ blocks of the previous level. This goes all the way to a level which is effectively a $1\times 1$ texture representing the filtered value of a $\textsf{w} \times \textsf{h}$ block of the texture. Since it's square and axis-aligned, you only need the center position (basically, the texture coordinates) and a single value to access the correct levels, the width of a square of texels we're filtering.

Reducing by $4$ on each level means it reduces by $2$ in each dimension. Given a width $w_f$ of square texels we're filtering (determined in the next section), we can take the $\log_2$ of $w_f$ to get a zero-based "index" into the levels. In quotes because it can have fractional parts, for example $\log(17) = 4.087$ meaning that we have to perform two bilinear samples on levels $4$ and $5$ and then use the fractional part $0.087$ to get the final filtered value, process called trilinear filtering. Final example, say $w_f$ turns out to be $2$, it means we want to access filtered $2 \times 2$ blocks. $\log_2(2)$ is $1$, meaning we want the first level where each texel represents filtered $2\times 2$ blocks of texels of the source texture (zeroth is the source texture) and so on. As we seldom want the actual blocks that were prefiltered and closer to those in-between, we perform bilinear filtering on the $4$ nearest texels each representing a filtered block of the source texture (size of block depends on level computed).

Rounding up texels

We're not going to hurt them, we just want to talk to them.

"Refactoring" update

I've incorporated some feedback over the past month for this article, hopefully increasing didactic value. I've also tried to use notation and shorthands that have engrained themselves over the decades, so mapping this article to reference final results can be easier while you're testing / getting a feel for everything. I'll try to design some diagrams when I feel better.

In the previous section, we found a way to prefilter our texture data and, from the diagrams, know that we need the line of anisotropy to determine the direction along which multiple isotropic mipmap samples are to be taken and the filter width $w_f$ from which we can compute the level(s) at which the mipmap will be sampled. According to the diagram, the line of anisotropy lies on the major axis of the ellipse drawn inside of our parallelogram of interest while the width $w_f$ can be derived from the distance from the extreme points on the ellipse along the minor axis. As you can clearly see, the ellipse can replace our parallelogram in the calculations and greatly simplify them as we only care about the extreme points on the ellipse (which are shared by the parallelogram). Thus, from now on, we'll use a circle instead of a square in all our calculations because ellipses intrinsically admit the notion of a major and minor axis.

As for the projection itself, it is reasonable to assume that we can approximate it with a locally-correct linear (affine) transform. To formulate such a transform, we need to determine how the texture coordinates change in the pixel's vicinity (the relationship between the two spaces). In other words, we're interested in computing the partial derivatives $\partial u/ \partial x$, $\partial u/ \partial y$, $\partial v/ \partial x$ and $\partial v/ \partial y$. Grouping these gives us two vectors indicating how $u$ and $v$ change in the XY plane around the point $(x,y)$, $\mathbf{dX}$ and $\mathbf{dY}$.

We don't actually compute partial derivatives proper on the GPU, pixel shaders instead execute in $2\times 2$ blocks which enables us to recover approximate screenspace partial derivatives via finite differencing (just subtracting), sufficient for practical applications dealing with discrete signals. By arranging our first-order partial derivative vectors as columns of a $2 \times 2$ matrix, we get a linear transform which approximately takes us to into texture space. This construct, when used more accurately, is known as the Jacobian (matrix):

$$\begin{split} \Large { \mathbf{J} = \begin{bmatrix} \frac{\partial u}{\partial x} & \frac{\partial v}{\partial x} \\ \frac{\partial u}{\partial y} & \frac{\partial v}{\partial y} \end{bmatrix}} \\ \end{split}$$

This linear transform, again, essentially approximates the reverse projection transform locally, we know how $(u,v)$ changes locally with respect to $(x,y)$. It is an approximation because we're assuming it'll be valid for more than just the point it was calculated on, ie the assumption of linearity is what is approximative.

So how do circles and ellipses work, again?

We've all dealt with circles at some point in our life, which are just a rotationally invariant kind of ellipse. The simplest formulation is usually $x^2 + y^2 = r^2$ which is trivial to understand. If you divide by $r^2$, you arrive at $(x/r)^2 + (y/r)^2 = 1$. You can see how the $r$ and $1$ on the right side constrict $x$ and $y$ isotropically from going beyond it. If you replace $r$ with $a$ and $b$, you can control the horizontal and vertical extent, effectively creating an ellipse centered on $(0,0)$ whose axes are aligned with the axes of the coordinate system:

$$\begin{split} \Large{\left(\frac{x}{a}\right)^2 + \left(\frac{y}{b}\right)^2 = 1} \\ \end{split}$$

This form is a bit bare-bones, but it gets the point across. The full, implicit equation for an arbitrary ellipse is:

$$\begin{split} ax^2 + bxy + cy^2 + dx + ey - f = 0 \\ \end{split}$$

$d$ and $e$ terms make the ellipse "leave" the origin and we're not particularly interested in them, so we drop them:

$$\begin{split} ax^2 + bxy + cy^2 - f = 0 \\ \end{split}$$

It can be converted into matrix form if you transform it a bit:

$$\begin{split} ax^2 + bxy + cy^2 &= f \\ ax^2 + \frac{b}{2}xy + \frac{b}{2}xy + cy^2 &= \\ x(ax + \frac{b}{2}y) + y(\frac{b}{2}x + cy) &= \\ \begin{bmatrix} x & y \end{bmatrix} \begin{bmatrix} a & b/2 \\ b/2 & c \end{bmatrix} \begin{bmatrix} x \\ y \end{bmatrix} &= \\ \end{split}$$

We have now shown that the following holds:

$$\begin{split} ax^2 + bxy + cy^2 = \mathbf{p}\mathbf{q}\mathbf{p}^\mathsf{t} = f \\ \end{split}$$

This is neat because we can apply transformations to our implicit ellipse parametrization as if it were any other primitive. However, there is a twist, we apply the inverse of whatever transform we have in mind. Intuition for this can be found in the way you translate something like a line to the right by actually moving the entire thing left on the $x$ term. Recall that $x$ and $y$ effectively go from $-\infty$ to $\infty$ and there's no transforming them. If we want to apply the transform $\mathbf{M}$ to facilitate $\mathbf{p'} = \mathbf{p}\mathbf{M}$, we plug in the inverse into a hypothetical ellipse $\mathbf{p'}\mathbf{Q'}\mathbf{p'}^\mathsf{T} = f'$ to yield:

$$\begin{split} \mathbf{p'}\mathbf{Q'}\mathbf{p'}^\mathsf{T} &= f'\\ \mathbf{pM^{-1}} \mathbf{Q'} \left(\mathbf{pM^{-1}}\right)^\mathsf{T} &= f'\\ \mathbf{pM^{-1}} \mathbf{Q'} \mathbf{M^{-1}}^\mathsf{T}\mathbf{p}^\mathsf{T} &= f' = f\\ \end{split}$$

We then have the transformed coefficient matrix specified as $\mathbf{Q} = \mathbf{M^{-1}} \mathbf{Q'} \mathbf{M^{-1}}^\mathsf{T}$. Also note that $f = f'$. Surely now you can see that one can start with a circle coefficient matrix (the identity matrix) and transform it into any ellipse. Further still, we can take a more generic, parametric formulation of the circle such as $\mathbf{p} = (\cos t, \sin t)$ and transform it arbitrarily then use $\mathbf{p}\mathbf{p}^{\textsf{T}} = \cos^2 t + \sin^2 t = 1$ to recover $\mathbf{Q}$ and $f$ of the implicit formulation.

Okay, now what?

What we're most interested in, though, is recovering the major and minor axis of the ellipse once we transform the parametric unit circle (representing our pixel) to the implicit ellipse using the finite difference relation clueing us in as to how texture space's $(u,v)$ changes locally with respect to screen space's $(x,y)$, our peasant-constructed Jacobian computed from $2\times 2$ blocks of pixels via finite differencing.

First things first, from the parametric unit circle to the implicit target ellipse. As mentioned, we have the Jacobian peasantly computed, so it comes down to merely transforming the points with it and recovering $\mathbf{Q}$ to get our $a, b, c, f$ coefficients:

$$\mathbf{p}\mathbf{p}^{\textsf{T}} = \cos^2 t + \sin^2 t = 1$$ $$\mathbf{p'M^{-1}} \mathbf{M^{-1}}^\mathsf{T}\mathbf{p'}^\mathsf{T} = 1 \iff \mathbf{p} = \mathbf{p'M^{-1}}$$ $$\mathbf{M^{-1}} \mathbf{M^{-1}}^\mathsf{T} = \frac{\begin{bmatrix} v_y & -v_x \\ -u_y & u_x \end{bmatrix} \begin{bmatrix} v_y & -u_y \\ -v_x & u_x \end{bmatrix}}{(u_xv_y - u_yv_x)^2} \iff M = \begin{bmatrix} u_x & v_x \\ u_y & v_y \end{bmatrix}$$

$f$ is in plain sight, it's just the denominator. The rest of them are a matrix multiply away, just map them to the $\mathbf{Q}$ entries:

$$\begin{split} a &= v_x^2 + v_y^2 \\ b &= -2(u_xv_x + u_yv_y) \\ c &= u_x^2 + u_y^2 \\ f &= (u_xv_y - u_yv_x)^2 \\ \end{split}$$

While we now have our circle "projected" to an ellipse in texture space, we need to find its major and minor axis that we'll be using for filtering (specifically, farthest points along those axes). The minor will be used to select the LOD of the mipmap and the major is our line of anisotropy we'll be sampling along at the LOD selected by the minor axis. Think back to our parametric circle prior to transform, it's a locus of points, yes? As we applied our linear transformation to it, the Jacobian, it elongated into an ellipse. There are four points on that ellipse which remained on the same two lines from the origin under the transform we performed, as if they were scaled by some value along a normalized direction which could replace the transform in that case. Yep, it's our major and minor direction "end points". This is a trivial (albeit tedious) eigenvalue and eigenvector determination problem.

Any ellipse centered around the origin can be viewed as the result of non-uniformly scaling and rotating a circle, meaning we can equate our Jacobian transform with the following:

$$\mathbf{M} = \mathbf{SR} = \begin{bmatrix} u_x & v_x \\ u_y & v_y \end{bmatrix} = \begin{bmatrix} a & 0 \\ 0 & b \end{bmatrix}\begin{bmatrix} \cos\theta & \sin\theta \\ -\sin\theta & \cos\theta \end{bmatrix}$$

The scaling is orthogonal while the rotation is orthonormal. As you know, the latter means rows are both mutually orthogonal and of unit length which makes its inverse equal to its transpose. Given that we define the transformed $\mathbf{Q}$ here as $\mathbf{M^{-1}}\mathbf{I}\mathbf{M^{-1}}^\mathsf{T}$, we have:

$$\mathbf{Q} = \mathbf{(SR)^{-1}}\mathbf{(SR)^{-1}}^\mathsf{T} = \mathbf{R}^{-1} \mathbf{S}^{-1} \mathbf{S^{-1}}^{\textsf{T}} \mathbf{R} = \mathbf{R}^{-1} \mathbf{S^{-2}} \mathbf{R}$$

Any well-behaved $n\times n$ matrix $\mathbf{A}$ encoding a linear transform will have $n$ eigenvalues and we should be able to find $n$ linearly independent eigenvectors. If you were to place all the eigenvectors as rows of a square matrix $\mathbf{E}$ and multiplied it with the associated matrix $\mathbf{A}$ the result would be a matrix where all the rows were multiplied with respective eigenvalues $\lambda_i$... As if you had built a diagonal square matrix containing eigenvalues $\mathbf{D}$ and multiplied it with $\mathbf{E}$ yielding $\mathbf{EA} = \mathbf{DE} \iff \mathbf{EAE^{-1}} = \mathbf{D}$. Going the "other way", we have $\mathbf{E^{-1}DE} = \mathbf{A}$. This leaves us with the following equivalence:

$$\mathbf{Q} = \mathbf{R}^{-1} \mathbf{S^{-2}} \mathbf{R} = \mathbf{E}^{-1} \mathbf{D} \mathbf{E}$$

Hence, $\mathbf{R} = \mathbf{E}$ and $\mathbf{S} = \mathbf{D}^{-1/2}$. Computing the eigenvalues for a generic $\mathbf{Q}$ amounts to:

$$\begin{bmatrix} x & y \end{bmatrix}\begin{bmatrix} (a - \lambda) & b/2 \\ b/2 & (c - \lambda) \end{bmatrix} = 0$$ $$(a - \lambda)(c - \lambda) - \frac{b^2}{4} = 0$$ $$\lambda^2 - (a+c)\lambda + \frac{4ac - b^2}{4} = 0$$ $$\lambda_{1/2} = \frac{a + c \pm \sqrt{(a+c)^2 - 4ac + b^2}}{2}$$ $$\lambda_{1/2} = \frac{a + c \pm \sqrt{(a-c)^2 + b^2}}{2}$$

It seems the square root and the $a \pm c$ terms will be following us around, so we might as well give them names. In the interest of keeping the result succinct, we can introduce the following substitutions $q = a + c, p = a - c, t = \sqrt{ p^2 + b^2}$. With $\mathbf{D}$, we easily resolve $\mathbf S$:

$$\mathbf{S} = \begin{bmatrix} \sqrt{\frac{2}{q + t}} & 0 \\ 0 & \sqrt{\frac{2}{q - t}} \end{bmatrix} \iff \mathbf{D} = \begin{bmatrix} \frac{q + t}{2} & 0 \\ 0 & \frac{q - t}{2} \end{bmatrix}$$

Going forward, remember that direction is our main concern, and as it aliases $\mathbf{R}$, we want them to be unit length and mutually orthogonal (a higher requirement than linear independence). Again, direction remains pristine. Thus, all shared magnitude-varying scalar factors will be dropped unceremoniously.

$$\begin{bmatrix} x & y \end{bmatrix}\begin{bmatrix} a & b/2 \\ b/2 & c \end{bmatrix} = \lambda_{1/2}\begin{bmatrix} x & y \end{bmatrix}$$ $$ax + \frac{b}{2}y = \lambda_{1/2}x \space \Rightarrow \space y = \frac{\pm t - p}{b}x$$ $$\frac{b}{2}x + cy = \lambda_{1/2}y \space \Rightarrow \space y = \frac{b}{\pm t + p}x$$

This nets us a pair of eigenvectors for each eigenvalue (remember the $\pm$):

$$\mathbf{e_{\lambda_{1/2}}} = \begin{bmatrix} b & \pm t - p \end{bmatrix}, \begin{bmatrix} \pm t + p & b \end{bmatrix}$$

Our goal is to normalize these vectors before moving forward, so let's do the first one of the first pair first... See what shakes out:

$$\begin{split} n_{\lambda_1,1} &= \sqrt{ (\pm t - p)^2 + b^2 } \\ &= \sqrt{ t^2 - 2tp + p^2 + b^2 } \\ &= \sqrt{ 2t^2 - 2tp } \\ &= \sqrt{2t}\sqrt{ t - p } \\ \end{split}$$

Similarly, you will find that $n_{\lambda_1,2} = \sqrt{2t}\sqrt{ t + p }$. Further, $n_{\lambda_2,i} = n_{\lambda_1,i}$ as the squaring action terminates the $\pm$ associated with $t$. But the biggest realization here is that $b$ doesn't contribute as a free term meaning that it has no bearing on the direction. We're then free to reuse the two distinct length calculations and recombine them into a single vector. Then, we can express the other vector as an implicit 90° rotation (as you know, swap the coordinates of the first one, negate one of them). This simplification will cost us some implicit sign information, but we can recover that from the sign of $b$. We have:

$$\mathbf{e_{\lambda_{1}}} = \begin{bmatrix} \sqrt{2t}\sqrt{ t + p } & \textsf{sgn}(b)\sqrt{2t}\sqrt{ t - p } \end{bmatrix}$$ $$\mathbf{e_{\lambda_{2}}} = \begin{bmatrix} -\textsf{sgn}(b)\sqrt{2t}\sqrt{ t - p } & \sqrt{2t}\sqrt{ t + p } \end{bmatrix}$$

As we isolated a scalar factor from all of them, we can drop it because it doesn't vary the direction:

$$\mathbf{e_{\lambda_{1}}} = \begin{bmatrix} \sqrt{ t + p } & \textsf{sgn}(b)\sqrt{ t - p } \end{bmatrix}$$ $$\mathbf{e_{\lambda_{2}}} = \begin{bmatrix} -\textsf{sgn}(b)\sqrt{ t - p } & \sqrt{ t + p } \end{bmatrix}$$

In this state, it becomes clear that the normalization factor of both is equivalent and it's $\sqrt{2t}$ which we will just prepend below. We set the two vectors as rows of $\mathbf{R}$ as it's equal to $\mathbf{E}$. Then we multiply it with $\mathbf{S}$. The rows of the resulting matrix represent the final directions for the major and minor axis.

$$\begin{split} \mathbf{M} = \mathbf{SR} &= \sqrt{\frac{1}{2t}} \begin{bmatrix} \sqrt{\frac{2}{q +t}} & 0 \\ 0 & \sqrt{\frac{2}{q - t}} \end{bmatrix} \begin{bmatrix} \sqrt{t + p} & \textsf{sgn}(b)\sqrt{t - p} \\ -\textsf{sgn}(b)\sqrt{t - p} & \sqrt{t + p} \end{bmatrix} \\ \end{split}$$

Therefore, our final expression for $\mathbf{M}$ is:

$$\begin{split} \large{ M = \begin{bmatrix} \sqrt{\frac{t+p}{t(q +t)}} & \textsf{sgn}(b)\sqrt{\frac{t-p}{t(q +t)}} \\ -\textsf{sgn}(b)\sqrt{\frac{t-p}{t(q - t)}} & \sqrt{\frac{t+p}{t(q - t)}} \end{bmatrix} }\\ \end{split}$$

From this we can calculate the farthest points of the ellipse along the normalized major and minor axis directions by factoring in $f$, to finalizing our boundary computation (if you can't figure out how $f$ works here, you're reading this article prematurely):

$$\mathbf{dX_{TS}} = \begin{bmatrix} \sqrt{f\frac{t+p}{t(q +t)}} & \textsf{sgn}(b)\sqrt{f\frac{t-p}{t(q +t)}} \end{bmatrix}$$ $$\mathbf{dY_{TS}} = \begin{bmatrix} -\textsf{sgn}(b)\sqrt{f\frac{t-p}{t(q - t)}} & \sqrt{f\frac{t+p}{t(q - t)}} \end{bmatrix}$$

These points can be interpreted as the transformed $\mathbf{dX}$ and $\mathbf{dY}$ vectors, hence the name. Doesn't really matter what you name them, though. Whichever of the two is longer can be thought of as defining the line of anisotropy. The shorter one, its length is more important. Specifically, you need to double it to get the width of the square filter we'll be using, the $\log_2$ of which nets us the mipmap level selection value. From there, $n$ trilinearly filtered samples are taken along the line of anisotropy (defined by the other vector) and filtered into a single value. $n$ is conservative, it is dynamically determined by the ratio of the anisotropy with a maximum defined by the user (and hardware). It tries to do it with least samples, unless the count of "least samples" is greater than the maximum anisotropy set by the user / programmer. At which point, it is clamped. The spacing of the samples is determined by the hardware, how much the axis-aligned squares end up overlapping affects weighing.

And in case you were wondering, yup, this is pretty much how Direct3D, OpenGL and Vulkan perform LOD and Line Of Anisotropy determination per specification. To properly get a feel for ellipses, I suggest you use these equations we derived on a graphing tool which are usually available online, my favorite is Desmos.

Advanced geometry processing with DX12 amplification and mesh shaders
Domagoj Pandza on May 19th, 2020
Computer Graphics
Show in single article view
Some of you with privileged access may have already seen this last year. Same article, just redated and declassified.

For advanced users only

Much like Direct3D 12 was designed to stand alongside Direct3D 11, amplification and meshlet shaders were introduced to stand alongside the conventional geometry processing pipeline and their flexibility results from increased developer responsibility. If you have "nothing interesting" to express with them, it is highly likely you will incur performance penalties trying to badly reinvent the old geometry pipe. This is not a free lunch or a silver bullet.

GPUs have come a long way since their introduction as "3D" graphics accelerators via highly-specialized and largely fixed-function hardware. These days, they're far more general and infinitely more programmable which makes them applicable to a wide array of computing problems. Hardware companies are especially keen on this emerging market and are consciously trying to distance themselves from the term Graphics Processing Unit in favor of Compute Accelerator. The jaded among you might attribute this to a marketing ploy, but there is actual merit it. While GPUs still have some fixed function hardware within their dedicated engine for graphics acceleration, they're largely generalized and massively-parallel compute powerhouses. In fact, the majority of the conventional graphics pipeline is implemented on the back of thousands of compute cores. We're almost 15 years away from the last time we spoke of discrete vertex and pixel pipelines.

Graphics APIs and the underlying "pipeline" have, unfortunately, failed to keep in lockstep with the evolution of GPUs, mostly out of comfortable complacency because the classic IMR graphics pipeline is, more or less, a success story which delivers great performance for everything but the most demanding of applications. To push boundaries beyond what's comfortable, Independent Hardware Vendors, Independent Software Vendors and platform holders have come together to enable next-generation graphics APIs such as Direct3D 12 and Vulkan in the mid 2010s. Even with all these advances, a big part of pushing more detail on screen remained in limbo, restructuring the geometry processing pipeline. For the past few years, this has been the hot topic among realtime computer graphics researchers.

The fruits of said labor have been available for two years at least, but adoption in the mainstream will be a tenuous one. This is especially true of personal computers where graphics hardware prices have been skyrocketing due to Nvidia's de facto monopoly. Today, I am going to give you a tour of what you can expect on the PC and, of course, Xbox Series X which will come with full support out of the (x)box. The same is true of PlayStation 5, which should help early adoption because AAA developers use consoles as a baseline and PCs as port targets.

Why change?

An overview of the current state of IMR architectures and why they had to evolve.

The coarse diagram found below this paragraph should be old hat to you by now. It is a high level overview of a basic IMR architecture with one of its crown jewels, the ubiquitous vertex shader. For simplicity, it's missing tessellation and geometry shading stages. We'll get to the former in due time while the latter should be bleached out of your mind.

A vertex shader, at its core, is nothing more than a wrapped compute kernel with special semantics and output requirements, executing on many cores. What predicates thread dispatch and data shoveling is, in D3D parlance, the input assembler stage.

Hardware companies have their own designations for the piece of fixed-function hardware associated with this stage, we will adopt Nvidia's terminology for the duration of this article. On Nvidia Pascal GPUs and below (Maxwell, Kepler, Fermi...) it is known as the Primitive Distributor which is a fairly apt name given that it takes the inherently serial index buffer and determines the best way to cut up (distribute) the supplied vertex buffer into batches which maximize utilization and on-die memory sharing once thread dispatch begins.

Once vertex processing ("shading") is finished, the next piece of fixed-function hardware hit before the rasterizer is the viewport clipping (and culling) stage. It takes the processed vertices and, as the name implies, performs fast clipping and culling operations against the configured viewport. While it is very good at what it does, it is preferable to perform culling before processing vertices. Like with the primitive distributor, fixed-function hardware needs to step down a bit in the interest of flexibility.

How the Primitive Distributor hurts you

First thing I need to impress upon you is that index buffers exist to avoid vertex duplication by generating index duplication. Depending on representation, indices can be as wide as \(32\) bits. Therefore, this is extremely wasteful during processing. To illustrate what the primitive distributor does, let index buffer \(\mathbf{IB}\) for primitive_topology::triangle_list contain the sequence of indices [7123, 7124, 7125, 7123, 7124, 7126, ...]. From here, we need to isolate the unique vertices referenced into a batch \(\mathbf{B_v}\) as [7123, 7124, 7125, 7126, ...], bear in mind we use the original indices to refer to the vertices for readability. Original because for every such vertex batch, a special primitive indexing batch is generated relative to it, allowing indices to be represented with significantly fewer bits (such as $8$ bits), \(\mathbf{B_i}\) as \([0, 1, 2, 0, 1, 3 \space ...]\).

Given that thread dispatch granularity on Nvidia GPUs is 32 threads (the size of a warp), to maximize utilization and performance, the primitive distributor will try to fill up as many 32-wide vertex and primitive batches as possible. While this gives you a clue on why processing tiny vertex buffers is quite expensive on a large scale, the real performance culprit is the fact that said index deduplication is performed every frame, dozens and even hundreds of times a second. That's bad, especially in the case of static geometry.

What are we to do?

Further embrace the compute accelerator nature of modern GPUs. Modify the hardware in such a way that it doesn't hinge on the Primitive Distributor and the misplaced viewport clipping/culing stage. The classic graphics pipeline can be implemented in terms of the more flexible system which doesn't have a set number of stages and it can be done by the runtime and the driver for backwards compatibility for the many people who can't beat the fixed function hardware on their own.

Don't do unnecessary work. Primitive Distributor's work can be largely kicked off to a build/bake step in the engine. Fundamentally, the Primitive Distributor will break up a mesh every time into a bunch of meshlets which are going to run in waves. The Primitive Distributor is also not magic, if your topology is trash, performance will suffer. Once it's relegated to a bake step, a lot more time can be spent on optimizing the meshlets for online processing, shortening the path to the rasterizer at runtime.

Arbitrary geometry representations follow naturally as you no longer have a requirement of providing conventional vertex and index buffers, allowing for novel and compact representations.

Amplification & meshlet shaders

Two shader stages replacing four (VS, DS, HS, GS)

As discussed in the last section, threads are dispatched on a hardware-specific granularity within which it is possible to share data and perform otherwise unavailable performance-enhancing operations known as wave intrinsics. Thus it's necessary to optimally break up a mesh into cache-friendly meshlets at bake time (as opposed to runtime). Furthermore, issuing thread groups sequentially, once per-vertex followed by an optional per-primitive stage is not very flexible. It is preferable to intermittently operate on both per-primitive and per-vertex data within each thread of execution in order to maximize opportunities to avoid work. As the old addage goes, the most performant operation will always be a NOP.

(De)amplified meshlet killed the vertex star

One thing that made vertex shaders such a success story was the tremendously simple programming model. Vertex comes in, processed vertex comes out. Geometry shaders are similarly simple as they perform work on a per-primitive basis. While they were a good-ish idea on paper, executing threads on a per-primitive basis and doing so after the fact is a somewhat limiting factor... Doubly so because anything a geometry shader can do, a compute shader can do better. Thus, it made sense to offer an alternative to vertex and geometry shader combos in the form of the novel mesh(let) shader. To account for tessellation, culling and instancing concerns, an aptly-named amplification shader precedes the meshlet shader which potentially amplifies or "dampens" input geometry while allowing early removal of offscreen surfaces before any expensive per-primitive and per-vertex processing takes place. In Nvidia parlance, it is also known as the task shader.

Put simply, the amplification shader determines just how many mesh(let) shaders will have to be executed. Remember how I told you that most of the graphics pipeline is basically a bunch of wrapped compute shaders with intermittent fixed-function stages? You can see it here clearly as now you have access to shared memory which means the usual thread-addressing IDs are fed into the shader (in HLSL, these would be sysvals such as SV_GroupThreadID and SV_GroupID). As the meshlet shader needs to produce some useful output, you have to write to two arrays, one for per-vertex data and the other for per-primitive data. There is flexibility in how much data you write but upper bounds must be specified ahead of time.

Specific limits under D3D12 Ultimate

Meshlet shaders under feature level 12_2 (DX12 Ultimate) require that the number of threads in a thread group is at most 128. The number of vertices and primitives you output is limited to at most 256 while vertex data itself with all the attributes should not exceed 32 KiB. There are also some limits on the amount of group shared memory, but the initial offering is fairly generous (at around 28 KiB).

For Nvidia hardware, it is recommended that you don't exceed 64 vertices output and 126 primitives. This is still way up from the original 32 on both counts. 126 being an "odd" number is thanks to allocation granularity (128 bytes) for primitive indices on Turing GPUs (the first generation). At 127, you're going into trigger a subsequent allocation of 128 bytes.

Just like D3D12 offered the developer more control over the GPU by having the runtime and driver step aside for you to make your own trade-offs, tessellation in the conventional sense is gone in this new model. This is good because the fixed-function tessellator has a predefined and immutable pattern used to generate more detail based on the hull shader's constrained output. Nowadays, for better or worse, both the inputs and outputs for tessellation are user-defined.

At this point, I am hoping it is becoming clear to you that this added flexibility may very well mean a performance catastrophe if you don't know what you're doing. Beating old fixed-function hardware is only possible if you know what you want and are willing to go above and beyond when it comes to availing yourself of the new programming model, especially taking advantage of the ability to perform culling ahead of time. If else, don't use this. If you are thinking of redoing the classic geometry pipeline in terms of amplification and meshlet shaders, you're missing the point.

The nitty-gritty details

Wouldn't be much of an article without diving into the technicalities

Amplification and meshlet shaders are easier to define in terms of their limits than their possibilities. It should be very clear to you by now that there is no catch-all code snippet I can give you to put into your application. With that caveat in mind, here's a simple, non-production, introductory meshlet shader in HLSL for you to get a feel for things:

// I hate CamelCasing.

struct ThreadInformation {
    uint3 ID                    : SV_DispatchThreadID;
    uint3 GroupRelativeID       : SV_GroupThreadID;
    uint3 GroupID               : SV_GroupID;
    uint LinearGroupID          : SV_GroupIndex;
};

[numthreads(64, 1, 1)] [outputtopology("triangle")]
void processMeshlet(in ThreadInformation threadInformation, out vertices Vertex outputVertices[64], out indices uint3 outputIndices[126]) {

    // Structured buffer, probably
    Meshlet meshlet = meshlets[threadInformation.belongingGroupID.x];
    SetMeshOutputCounts(meshlet.vertexCount, meshlet.primitiveCount);
    uint linearGroupRelativeID = threadInformation.LinearGroupID;

    // Per-vertex processing
    if(linearGroupRelativeID < meshlet.vertexCount) {

        // Get the associated vertex index and read from yet another structured buffer
        Vertex vertex = vertices[meshlet.vertexOffset + linearGroupThreadID]; 

        // ... Do your vertex processing.

    }

    // Per-primitive processing
    if(linearGroupRelativeID < meshlet.primitiveCount) {
        // Similarly, process and assemble primitives, store result into outputIndices.
    }

}

You will need to upgrade your compiler to get this to work, FXC has now been surpassed by DXC. While HLSL is still a soaring dumpster fire, it's actionable. Can't hold a candle to the C++14-based Metal Shading Language, though. However, with the advent of SPIR-V and going from DXBC to DXIL (thanks, Clang), you can produce your own shading language that does exactly what you want it to. Note that using FXC generated DXBC will incur load-time conversions to DXIL behind the scenes.

How do amplification and meshlet shaders "talk"?

In in DX12 Ultimate / HLSL parlance, we have payloads which are at most 16 KiB worth of data per triggering meshlet shader and is used instead of explicit structured buffers in the example above. Instead, those are moved to the amplification shader where input decoding of your custom geometry format is performed followed by culling computations of arbitrary complexity, tradeoffs are yours to make. A skeleton looks like this:

struct Payload {
    // User-defined and arbitrary, sky is the limit (16 KiB)
};

[numthreads(64, 1, 1)]
void prepareMeshletDispatch(ThreadInformation threadInformation) {

    Payload meshletPayload;

    //  Decode your geometry format, cull, prepare tessellation and instancing. 

    // Then dispatch a fresh meshlet for processing:
    DispatchMesh(numberOfThreadGroupsToDispatch, 1, 1, meshletPayload);

}

[numthreads(64, 1, 1)] [outputtopology("triangle")]
void processMeshlet(in ThreadInformation threadInformation, in payload Payload meshletPayload, 
                    out vertices Vertex outputVertices[64], out indices uint3 outputIndices[126]) {

    // Use whatever your payload is to generate work for the rasterizer

}

Again, how you write these two will highly depend on what kind of application you have. Careful thought and testing will be required to determine whether you benefit from this new approach at all. Don't pull a Deus Ex: Mankind Divided where the D3D12 implementation actually had worse performance than the D3D11 one.

How do I access this from the API?

Once this hits the API, a new method on the graphics command list interface will be available:

void ID3D12GraphicsCommandList6::DispatchMesh(UINT ThreadGroupCountX, UINT ThreadGroupCountY, UINT ThreadGroupCountZ);

Similarly, indirect meshlet dispatching will be supported via the upcoming D3D12_INDIRECT_ARGUMENT _TYPE_DISPATCH_MESH enumeration and D3D12_DISPATCH _MESH_ARGUMENTS structured member. As you can see, the majority of the big brain time will be sequestered inside of your geometry encoding scheme, meshlet preparation and the amplification and meshlet shaders.

What about rasterization ordering?

In the unlikely case of just meshlet shaders, total ordering is guaranteed which is built around the notion that you will be mostly dispatching 1D threadgroups. For each "primitive depth slice", every "primitive row" is sequenced top-to-bottom one after the other. Primitives within a row are fully ordered, low to high. Once you get to using amplification shaders which spawn threadgroups on their own, like every other algorithm which depends on effective indirect dispatch, total ordering is out the window. Partial ordering is available on the basis of the just discussed internal total ordering of every meshlet shader dispatched. Due to the complexity of amplification shader work and provisions for indirect dispatch, stronger guarantees cannot and don't need to be made.

Once beyond the rasterizer, old rules apply. If you need specific ordering imposed for the purposes of your algorithms inside of the pixel shader, your only recourse is using Raster Order Views ("PixelSync" hardware needs to be present).

Before I go, primitive attributes...

With amplification and meshlets shaders, it is now possible to define per-primitive attributes without actually hacking around with stuff like provoking vertices and the rain that brings down on your parade. Programmatically, it's trivial to add to your meshlet shaders as an out argument:

out primitives PerPrimitiveData outputPrimitives[PRIMITIVE_COUNT_MAX]

All there is to it, PerPrimitiveData is just an arbitrary, user-defined struct. On it, you can put either various system values pertinent to primitive processing or something you want to add as part of your primitive tagging scheme.

And with that, I'm out. I'll do a deep dive once this is out for the general public, you can hit me up on Discord to discuss details, best practices and approaches which make good use of this programming model. As always, have fun and don't melt your GPUs.

Revisiting virtual mipmaps and toroidal addressing
Domagoj Pandza on February 8th, 2020
Computer Graphics
Show in single article view

Back to the 2000s

Motivated by the latest Discord community confusion, I am revamping and republishing my article from late 2007 which tackled properly explaining clipmaps following Nvidia's botched attempt at doing the same regarding this old, but still relevant, concept brought forward by Tanner et al. at SIGGRAPH of 1998.

As of February 2020, the original Nvidia whitepaper has still not received any revisions. Even the most glaring errors remain, such as incorrect usage of the *= multiply-assign operator: clipTexCoord.x *= scaleFactor.x + 0.5f, the mathematical equivalent of $c_x ( s_x + 1/2 )$. The author wanted to express $c_x s_x + 1/2$.

It is no secret that a part of Nvidia's hardware rollout ritual is to dig up previously difficult-to-implement techniques and write up a demo, alongside a whitepaper, showcasing how they managed it with their latest architecture. They don't make a habit out of completely dropping the ball on the whitepaper. Alas, the one on clipmaps from February 2007 was one of them. "A clipmap can be defined as a partial representation of a mipmap pyramid which holds all information needed for texturing at every single frame.", begins the Nvidia whitepaper on the subject from early 2007.

A valid definition. It is, however, slippery without proper elaboration. Each level of a clipmap does not cover the same extent of the "source" level of the mipmap which it is virtualizing. The extent varies as the clipmap has the same number of texels across most of the levels of a conventional mipmap pyramid. Thus, you can't just take screen-space derivatives to compute a mip level and sample into it blindly, as the whitepaper lazily and partially outlines. This is, in fact, a key aspect of clipmaps and a key failing of the Nvidia whitepaper.

Multum In Parvo

But you don't need all of it at all times.

My preferred way of thinking about clipmaps is in line with Tanner et al. A clipmap is, for all intents and purposes, a virtual mipmap. Therefore, we shall first refresh ourselves on the merits of mipmaps and smoothly transition from there.

As you probably know, textures slapped across geometry come in a variety of sizes. Even the practical upper limit for non-virtual, fully resident textures is a staggering 16384x16384. These objects often end up projected to a very small area on the screen, invariably leading to aliasing as there may be thousands of texels competing for a single screen pixel. One could, ludicrously, filter this at runtime every single time... Or realize all of this can be precomputed at texture creation. Enter the mipmap.

A mipmap ("Multum In Parvo" means "much in little space") is a hierarchical structure representing a source texture (conventionally and historically, a "bitmap"). Each level is half the resolution of its predecessor, all the way down to 1x1. Any filter can be used to perform the downsampling, even a 2x2 box filter. A precomputed solution to avoiding aliasing. Mipmaps also have secondary interpretations which are equally valid. One is that they inherently serve as a texture level-of-detail solution which alleviates bandwidth issues by only accessing the optimal resolution of the texture wrt what can be seen given the camera distance to the surface.

This immediately leads us to the motivation behind clipmaps. Consider the opposite scenario. When the source texture is massive (eg. 65536x65536) and covers a large element in the game world which seldom (if ever) fits inside the view frustum (think terrain, planets, world-spanning acceleration structures), only aperture-sized parts of a mipmap are actually utilized and the available memory should be focused on those rather than being wasted on stuff that's offscreen at the moment. Thus, if one dynamically "clips" off the unneeded sections of the mipmap, you essentially convert an inherently static mipmap pyramid into a dynamic clip stack with a residual, potentially static mipmap pyramid stump, collectively known as a clipmap.

Each level of the clip stack has exactly the same amount of texels which means, when centered against the corresponding mip level of a mipmap, the extent will increase as you go towards the bottom. Once the extent becomes 1:1, it collapses back into a residual mipmap pyramid which is small enough to be constantly in memory. This is conventionally expressed as two separate resources in software, clip stack is represented as a texture array which is updated high-to-low from the CPU (streaming) while the residual mipmap pyramid is a potentially static, regular mipmapped resource.

As mentioned, given the top (level 0) of the clip stack has the same amount of texels as the bottom, it covers the smallest area of the source texture. This is intentional because even with oblique viewing angles, the perspective transform squashes and minimizes stuff in the distance, large areas in world space project to very small areas in screen space (mipmap's specialty). The classic mip level selection would actually try to sample individual clip levels out of bounds. Thus, we require an additional mechanism to account for selecting a proper clipmap level for the logical texture address.

How does it work?

Toroidal addressing and center tracking.

Naturally, the first thing that comes to mind is the clipmap update/streaming method. Every time you move, you offset everything to the side and all of it seemingly needs to be overwritten. However, consider moving just a little bit in any direction. Due to the coherency of the data in the mipmap, you only need to replace the parts of levels of the clipmap that are now outside of view. By moving the origin of the clipmap to the center and engaging the address_mode::wrap, you can actually load what's supposed to be on the right on the left side. The same works vertically. And is separable. This addressing is called toroidal in academic circles and is showcased below.

Selecting the correct clip stack level

Back to 2007...

Interestingly, the people behind the Nvidia whitepaper completely left out this section which I still remember frustrating me to no end. Figuring this out is a breeze now, but it wasn't a picnic for 14 year old Domagoj, wet behind the ears and in dire need of guidance which was nowhere to be found.

Before we can actually deal with transforming the logical mipmap coordinates into "physical" clip stack coordinates, we need to make sure that the input coordinates are not going to transform out of bounds, resulting in a repeating pattern. How do we compute the appropriate level? We will need to derive a function $l(?)$ which computes the appropriate level given the least amount of arguments.

The current stack center in normalized texture coordinates $\mathbf{C}$ will definitely be involved, the desired "logical" coordinates $\mathbf{P}$ and the ratio of the source image and the clip stack size $s$. For simplicity, we will derive this in one dimension as it works identically vertically.

To figure out this function, we take the difference $d_n = |\mathbf{P}_x - \mathbf{C}_x|$. Due to the whole wraparound business, we actually work with a maximum of $0.5$, so $d = \min(d_n, 1-d_n)$. At which $d$ do we need to jump to a higher level? Let's work out some concrete examples!

If our source image is four times as large as the clip stack, we have to move to level 1 when horizontal distance from the clip stack center $d$ reaches $0.125$ (remember, centered, so not $0.25$!), level 2 at $0.25$, level 3 at $0.5$, etc. If, on the other hand, $s=8$, then we'd have to hit $0.0625$ for level 1, $0.125$ for level 2 and so on. A pattern begins to emerge. As $d$ doubles, $l(d)$ changes logarithmically (base 2). Scale also appears to factor into it linearly, $l^s(d)$.

With all of this in mind, we define $l^s(d)$ as $\log_2(ksd)$ and look for $k$ which satisfies our requirements such as $l^4(0.125) = 1$:

$$l^4(0.125) = \log_2(k/2) = 1 \iff k/2 = 2^1$$

$$ \Large{ \implies k = 4}$$

Erring on the side of caution

In practice, it is best to not really sit on the razor edge, so a better choice of constant $k$ would be at least $4.1$. Nvidia used $4.2$ in their 2007 demo, left as a magic value with no elaboration. The slight offset forces conservative transition to a guaranteed within bounds access.

This finalizes our minimum clip stack level selection function as:

$$\Large { l^s(d) = \log_2(4sd) } $$

You compute both the vertical and horizontal level and choose the "worse" one. In case you haven't been paying attention, that's the larger one with less detail and higher extent. And in the end, you also compute the regular mipmap level selection and compare it against the clip stack level selection. Picking the "worse" one ensures there is neither aliasing nor out of bounds accesses. The attentive among you may notice that $l^s(d)$ dips below $0$ for some values. But then again, you also noticed that once we necessarily $\max$ it against the mipmap level selection function, the issue is eliminated.

Transforming coordinates for final clip stack sampling

The transformation of coordinates is rather pedestrian. As we move from the virtual mipmap space to the actual clip stack space, we know there is a constant $1/2$ offset. We also know that due to varying extents across levels, the most detailed level covers the least amount of area of the original texture.

At level 0, the extent of the clip stack level in normalized coordinates ends at $1/s$ (mipmap logical normalized coordinates). This is obviously $1$ in clip stack space for that particular level. Thus, we scale the coordinates by $s$. At each successive level, the extent doubles as the clip stack size remains constant, meaning we also divide by $2^i$ where $i$ is the previously chosen clip stack level. And finally, we add the center offset of $1/2$, producing the final transform:

$$\Large { \mathbf{P}' =\frac{s}{2^i}\mathbf{P} + \langle 1/2, 1/2 \rangle } $$

That's all there is to it. Falling back onto the residual mipmap pyramid is trivially predicated by the computed level: if it exceeds the clip stack depth, sample it. Happy coding.

Efficient GPU-based occlusion culling via early-Z and indirect dispatch
Domagoj Pandza on July 4th, 2019
Computer Graphics
Show in single article view

If a tree falls in a forest and no one is around to hear it, does it make a sound? This is a popular introductory question in philosophy, on which many have opined over the centuries. When it comes to virtual worlds, the answer is a resounding no. Out of sight, out of mind, out of processing. It will be logged, its state updated but it won't be made visible or audible for it is outside the view frustum (and audio range).

What of objects that are in the view frustum, but are completely occluded by some other, larger object? The problem of occlusion culling is a fundamental one in computer graphics and has been extensively researched, especially as it is continuously affected by advances in hardware and software.

A recap of recent history

...from the start of the century

In the early 2000s, central processors were still single-threaded and GPUs were considerably less programmable than they are today. The solution was introduced in the form of special hardware functionality to tackle occlusion culling on the GPU called "hardware occlusion queries". However, they require a ridiculously slow, error-prone, constant ping-pong match between the CPU and the GPU which considerably reduces the possible performance gain margins. The CPU has to configure the GPU for occlusion queries, submit the query data (render coarse geometry) and read back the results in order to update its indirection-heavy hierarchical structures dedicated to accelerating visibility determination and avoiding false negatives. And only then is it finally able to submit all the visible instances for rendering using the immediate mode APIs of the time (Direct3D 9.x / OpenGL 1.x+). The most successful approach of this generation of occlusion culling technology was called coherent hierarchical occlusion culling (commonly abbreviated as CHC++).

Coherent hierarchical occlusion culling in action

Sliding into the 2010s, more and more users had already acquired multithreaded microprocessors which boasted vectorization extensions as well. Tired of the cache-incoherent nature of hierarchical occlusion culling, hard-to-manage false negatives and overall latency of communicating back and forth with the GPU before even beginning to construct a frame, we turned to more CPU-centric, data-oriented methods for occlusion culling. Especially as it was already becoming more and more economical to just brute force "general" culling just by doing view frustum culling against large blocks directly on the CPUs and still run circles around methods like octree-based culling. The solution was a bit out there, we're essentially talking about software occlusion queries. They involved running a small rasterizer on the CPUs which generated a low resolution depth buffer which would then be used solely with bounding geometry to estimate occlusion by large, predefined static occluders. First commercially successful game (I am aware of) that opted for this method was DICE's Battlefield 3 running on the Frostbite engine. I have no doubt that this was directly motivated by the considerable number of Cell SPEs alongside the PPU on the Playstation 3.

Software-rasterized low resolution depth of large, static occluders for occlusion testing.

With the arrival of true indirect dispatch allowing the GPU to compute the drawing set on its own without CPU intervention, a shift back towards hardware-accelerated occlusion culling was made. This was further bolstered by advances in temporal reprojection approaches, leading to methods where the occlusion culled set was determined using a downsampled, hierarchical depth buffer from previous frames during the advent of "next-gen" graphics APIs around 2014 and 2015.

Around that time, I had a strong itch regarding whether or not I could mix early-z testing and conservative rasterization to utilize fast hardware paths of modern GPUs to do my occlusion culling for free because there are penalties to the hierarchical approach which I deemed undesirable. Unfortunately, conservative rasterization tier support on modern GPUs was and is so bad that even Maxwell 2.0 GPUs like the GTX 980 support only tier 1 which is inadequate for the task at hand. On the other hand, my hunch about early-z subversion was right on the money. And fortunately for me, I wasn't the only one to consider it which is always helpful to avoid a considerable amount of head-scratching and writing suboptimal implementations.

Are you still there?

The main question behind all culling.

We start with the depth buffer of the previous frame. Naturally, this means the first frame will have no occlusion culling. However, unlike temporal supersampling which takes a few frames to converge, we're ready to go on the second frame. Given that efficient occlusion culling methods use oriented bounding boxes and, at best, coarse meshes to represent entities in the scene, we don't need the full resolution provided by the depth buffer which would bog down the system and eliminate the advantage.

CPU-side occlusion culling implementations utilizing software rasterization like the one behind DICE's Battlefield 3 have shown that $1/16$ resolution is more than sufficient. It is also the second most reasonable guess given that the buffer needs to be downsampled isotropically and done in one step. Downsampling is done by taking the closest (maximum when inverted) value of each 4x4 block considered, which is par for the course when it comes to analyzing depth buffers. Especially if you have done depth-sensitive ray marchers for screenspace reflections or performed something like tile-based light culling.

// Optimized for readability
#define THREAD_GROUP_DIMENSION 8

// Organize your data as you like.

struct thread_execution_information {
    uint3 thread_id : SV_DispatchThreadID;
    uint3 thread_group_id : SV_GroupThreadID;
    uint3 group_id : SV_GroupID;
    uint group_index : SV_GroupIndex;
};

[numthreads(THREAD_GROUP_DIMENSION, THREAD_GROUP_DIMENSION, 1)]
void main(thread_execution_information executing_thread_information) {

    float2 const center_offset = float2(0.5f, 0.5f);
    float2 const thread_fid = float2(executing_thread_information.thread_id.xy);
    float2 const downsampled_buffer_dimensions = 0.25f * depth_buffer_dimensions;

    // This will move us to the center of each 4x4 block within the full resolution buffer.
    float2 const downsampled_center = (thread_fid + center_offset) / downsampled_buffer_dimensions;
    if(downsampled_center.x >= 1.0f || downsampled_center.y >= 1.0f) return; // Out of bounds

    // Full resolution buffer texel size, so we can get to the centers of the surrounding
    // 2x2 blocks on which we will apply D3D12_FILTER_MAXIMUM_MIN_MAG_POINT_MIP_LINEAR,
    // to ensure there are no zero weights which would discard them from the max comparison.
    float2 const full_resolution_texel_size = 1.0f / depth_buffer_dimensions; 
    
    float block_maximums[4];
    float2 block_center_offsets[4] = { 
        float2(-1.0f, -1.0f), float2( 1.0f, -1.0f), 
        float2( 1.0f,  1.0f), float2(-1.0f,  1.0f) 
    };

    [unroll]
    for(int i = 0; i < 4; ++i) {
        float2 const center = downsampled_center + full_resolution_texel_size * block_center_offsets[i];
        block_maximums[i] = depth_buffer.SampleLevel(depth_max_sampler, center, 0).r;
    }

    // Final manual maximum determination performed, ready for reprojection.
    float downsampled_depth = max(max(max(block_maximums[0], block_maximums[1]), block_maximums[2]), block_maximums[3]);

    // ...
Downsampling the depth buffer using D3D11.2+ "MinMaxFiltering" (Tied to FL12_0 / Tiled Resources Tier 2).

Regarding Min-Max Filtering

We're relying on functionality which was originally optional with Direct3D 11.2. Specifically, the ability to do min-max sampling which is now widely supported on Direct3D 12 capable hardware. Supporting transitional hardware with primarily FL11_1 capabilities will require you to implement the downsampling the old-fashioned way.

Onto the reprojection. Just like with temporal supersampling, we only have the information which is available in screenspace. Fast camera movements will generate erroneous areas due to a myriad of reasons, stuff leaving the screen, going behind it, coming onto it, objects moving in a way that information from previous frames is invalidated etc. Good news is that occlusion culling is far less sensitive to these types of errors but that doesn't mean a free lunch, unfortunately. Since there isn't a good way to select an alternative value in those areas, the only one that can be used semi-reliably is the downsampled depth we computed in the listing above.

Credit where credit is due

The nugget in the last paragraph is due to the folks behind Deus Ex's Dawn engine. Previously, I actually used a heuristic to "content-aware out" those gaps which turned out to be unnecessarily complex. Like I said, always pays to have multiple people on the same trail, willing to share their findings.

Furthermore, as depths are non-linear and inverted (avoiding z-fighting), another concern is stuff going behind the screen because that's where depth values may "explode" and corrupt our efficiency.

    // We're in texture space right now. In Direct3D, the origin is the top-left corner.
    // In order to use the inverse projection transform, we need to go back to normalized device coordinates.
    // Depth is in the 0 to 1 range, Y axis is flipped and XY go from [-1, 1].

    float2 xy_ndc = downsampled_center * float2(2.0f, -2.0f) - 1.0f;
    float4 position_ws = mul(previous_inverted_projection_view_transform, float4(xy_ndc, downsampled_depth, 1.0f));
    position_ws /= position_ws.w; // Complete the non-linear part of the transform.

    // Using the new world position and the current projection-view transfrom, we compute 
    // the new placement position in texture space to put the reprojected depth in.
    
    // First to NDC:
    float4 reprojected_position = mul(current_projection_view_transform, position_ws);
    reprojected_position /= reprojected_position.w;
    float depth = reprojected_position.w < 0.0f ? downsampled_depth : reprojected_position.z;

    // Then to texture space:
    reprojected_position.xy *= float2(0.5f, -0.5f); 
    reprojected_position.xy += 0.5f;

    // The downsampled depth buffer has all its values initialized to 0 by default, which precludes the possibility
    // of utilizing InterlockedMin, requiring us to do some inversion gymnastics here for this to work.

    uint intermediate_depth = asuint(1.0f - saturate(depth));

    // Safest thing is to overwrite the possible hole immediately, minimizing the error (but this is a coarse approximation)
    InterlockedMax(downsampled_depth_buffer[executing_thread_information.thread_id.xy], intermediate_depth);

    // Finally store in reprojected position as well.
    int2 reprojected_position_ps = int2(saturate(reprojected_position.xy) * downsampled_buffer_dimensions);
    InterlockedMax(downsampled_depth_buffer[reprojected_position_ps], intermediate_depth);


}
Temporal reprojection followed by cleanup on aisle 3.

With the reprojected, downsampled depth buffer in tow, we're ready to subvert age old depth prepass of forward-biased approaches to occlusion culling by placing the [earlydepthstencil] attribute on the pixel shader. As you (should) know, this will force early depth testing which predicates the execution of the pixel shader. Thus, as we draw our OBBs and coarse bounding geometries, only the visible ones will actually trigger the execution of the pixel shader.

In it, we we use a simple UAV to a linear structured buffer where the "structure" are integers, fundamentally behaving like an array of booleans. It's trivial to clear this structure to $0$ by using the appropriate ID3D12GraphicsCommandList :: ClearUnorderedAccessView* Direct3D 12 API, so the final shader looks satisfyingly simple. You just use the instance IDs to index into the preallocated visibility buffer and set it to $1$. Unordered access mechanism takes care of the boatload of incoming writes.

RWStructuredBuffer<uint> visibility_buffer : register(u0);

[earlydepthstencil]
void main(uint instance_id : SV_InstanceID) {
    visibility_buffer[instance_id] = 1; // Free lunch.
}
Simplest shader you'll ever write.

All that remains is to write a custom-tailored compute shader (for your tech) which operates on every instance that passed general culling, whatever your implementation of it might be. You go through each and check them against the visibility buffer. If you determine one is visible, generate the indirect drawing information and store it in the appropriate buffer which you'll trigger from the command list later on.

However, keep in mind that errors due to temporal reprojection and even failure to rasterize small OBBs (not touching an unobstructed pixel's center) are guaranteed to introduce false negatives. Thus, you should also maintain a list of occluded instances and redraw their OBBs against the freshly built full resolution depth buffer composed of all the possible occluders after indirectly dispatching the visible instances. This entails running through a near carbon-copy of the first pass, albeit considerably simplified.

The approach works for both forward and defer biased rendering systems and integrates seamlessly. I am personally still rolling a hybrid forward-biased renderer because of my preference for delivering the option of superb spatio-temporal supersampling and elegant handling of transparency. But if your platform has considerable constraints, nobody will judge you for going deferred.

Heuristic for energy compensation in single-scattering microfacet BSDFs
Domagoj Pandza on August 28th, 2018
Computer Graphics
Show in single article view

You're reading the series on handling multiple scatter events on a statistical microsurface

In this article, we dismantle the existing single-scattering microfacet BSDF model to find a quick and dirty heuristic which is good enough for practical, realtime applications. This means the primary focus here is energy compensation rather than simulating multiple scattering, even though we do manage to get a decent approximation of it.

This is one of those articles I've been wanting to write for a while now. My initial approach to correcting the issue, predecessor of the one described in the following text, was deemed too brazen in its approximation. I've had quite a while to rethink it. I still don't feel it's properly solved. What's the problem? Microfacet BSDFs have been losing energy for decades! Sweet clickbait, but it's true. Up to 60% of it at extreme microsurface roughness which has a serious impact on visual quality. If you're in the computer graphics research circles, this is not news to you.

Since 1976 (even 1967), we've knowingly assumed that only a single bounce on the statistical microsurface occurs. Every subsequent one is modeled as "lost to the void". The focus back then was making sure we don't create energy. For this purpose, a very reliable model for microsurface shadowing and masking was introduced by Smith in 1976. All of this led to us collectively creating and abetting a light trap. The price was mostly paid on dieletrics and conductors at high surface roughness levels, as they grew dimmer and duller. The following equation outlines the problem unequivocally:

$$\int_{\Omega^+} \frac{D(h)G(\omega_i, \omega_o, h)}{4 \langle w_i, n \rangle \langle w_o, n \rangle } \langle w_i, n \rangle d\omega_i \neq 1$$

In this series of articles, I'll share how I approached rectifying it since the beginning of this decade, as well as ease you into how I changed my approach over the years following research performed by Heitz, Turquin and others. Don't worry, we'll cover derivations and computations of all the concepts introduced. If you're not familiar with microfacet BRDFs, Torrance-Sparrow paper from 1967 is one of the rare papers which actually includes a decent mathematical derivation. Only prerequisite is that you're familiar with spherical trigonometry.

Energy loss as a function of increasing roughness $r$.

Approaching the problem...

...via savage mathematical manipulation

We omit the Fresnel factor to ensure perfect reflectance at any angle in order to observe and fix the problem. Assuming a constantly lit environment, this allows us to verify that, indeed, light falling onto the hemisphere above the evaluated point is not being completely reflected back into the environment by a single-scattering microfacet BRDF as roughness rises. My first idea was adhoc scaling as a function of roughness, however, I disliked the result.

From there, I tried changing my approach to restating the BRDF to account for the lost energy by just shooting the difference back out in every direction scaled by the distribution. Naturally, this isn't easy to justify physically as it is a mathematical transformation, we're not actually wading through a statistical microsurface. Heitz did in 2016, but we'll get to that later. The integral from the introduction can be restated as a function of how much reflectance is dished out in the direction $\omega$:

$$ E(\omega) = \int_{\Omega^+} \rho(\omega, \omega_i) \langle w_i, n \rangle d\omega_i$$

This allows us to specify the constraints necessary for our modified "multi-scattering" BSDF:

$$ E_{\mathbf{first}}^{F=1}(\omega) + E_{\mathbf{multi}}^{F=1}(\omega) = 1$$

Simple enough, let's work through it. From this point forward, the superscript will no longer appear because it's cumbersome.

On expecting unity

If you're confused why we're expecting $1$ here, you need to focus on the fact that we're doing our measurements in a constantly lit environment with a "material" where both single- and multi-scattering Fresnel is currently $1$. As there are no gaps in the environment, the entire lobe is perfectly saturated.

We expect it to scatter 100% of the incoming light through the solid angle centered on $\omega$, the direction of the camera/eye/sensor. From every direction within the lobe, it'll take a variable percentage of the incoming "light" which should, over the entire lobe, reach unity.

$$ \int_{\Omega^+} \left( \frac{D(h)G(\omega_i, \omega, h)}{4 \langle w_i, n \rangle \langle w, n \rangle } + \rho(\omega, \omega_i) \right) \langle w_i, n \rangle d\omega_i = 1$$

This "trick" enables us to see the percentage of light falling onto the specular lobe that gets scattered in the $\omega$ direction and the losses incurred by ignoring multiple scattering events. It is often referred to it as the albedo (here Fresnel is $1$). Thus, we need to figure out what $\rho$ should be.

Shuffle it around a bit, into a form where we'll be able to scam the integral into doing what we want. Just to emphasize for those who get bamboozled by the following sausage is that it is just $E_{\mathbf{multi}}(\omega) = 1 - E_{\mathbf{first}}(\omega)$ laid bare:

$$ \int_{\Omega^+} \rho(\omega, \omega_i) \langle w_i, n \rangle d\omega_i = 1 - \int_{\Omega^+} \frac{D(h)G(\omega_i, \omega, h)}{4 \langle w_i, n \rangle \langle w, n \rangle } \langle w_i, n \rangle d\omega_i$$

We need to fit a function $\rho$ which will resolve the left side to what we see on the right side, as such, it can be expressed as a product of a currently unknown number $N$ of factors, each potentially a function of $\omega$ and $\omega_i$:

$$\Large \rho(\omega, \omega_i) = \prod_{i=0}^{N} X_{i}(\omega, \omega_i) $$

It is not hard to guess what our first factor will be, pretty much the right side which will allow us to focus on forcing the integral to dissolve:

$$ \left[1 - E_{\mathbf{first}}(\omega)\right]_{\rho} \int_{\Omega^+} \left[\prod_{i=1}^{N} X_{i}(\omega, \omega_i)\right]_{\rho} \langle w_i, n \rangle d\omega_i = 1 - E_{\mathbf{first}}(\omega)$$

To get a grasp of what's going on, we're going to change our variables to deal with the unit hemisphere in a way which is directly computable. Furthermore, we also dole out the normalization factors necessary after we process the outer integral:

$$2\pi \left[ \frac{1 - E_{\mathbf{first}}(\theta)}{2\pi} \right]_{\rho} \int_{0}^{\pi/2} \left[ \prod_{i=3}^{N} X_{i}(\theta, \theta_i)\right]_{\rho} \cos(\theta_i) \sin(\theta_i) d\theta_i = 1 - E_{\mathbf{first}}(\theta)$$

Solid angles and surfaces

The infinitesimal patch of area on the unit hemisphere which subtends the solid angle centered around a direction $\omega$ can be visually derived to be $\sin(\theta) d\theta d\phi$. Range is trivially limited to a full sweep of the surface of the hemisphere. As we have no azimuthal dependence here, we can process the outer integral immediately.

Now, you might be tempted to guess the remainder of the product defining $\rho$ right away, to be something along the lines of:

$$ \rho(\theta, \theta_i) = \frac{1 - E_{\mathbf{first}}(\theta)}{\pi^2\sin(\theta_i)\cos(\theta_i)} $$

Cheeky, but no. You may be scratching your head as to why. You've just added a portion of the parametrization of the integral into the denominator of our multi-scattering function and its respective normalization. Change the variables from $d\theta_i$ to $du_i$ where $u_i(\theta_i) = \cos\theta_i$ and watch the house of cards crumble. Awkwardly floating sine alongside now irrelevant normalization factors. How about something like this instead:

$$\rho(\theta, \theta_i) = \frac{1 - E_{\mathbf{first}}(\theta)}{2\pi\int_{0}^{\pi/2} E(\theta_i) \cos(\theta_i) \sin(\theta_i) d\theta_i} E(\theta_i)$$

$$\frac{1 - E_{\mathbf{first}}(\theta)}{\int_{0}^{\pi/2} E(\theta_i) \cos(\theta_i) \sin(\theta_i) d\theta_i} \int_{0}^{\pi/2} E(\theta_i) \cos(\theta_i) \sin(\theta_i) d\theta_i = 1 - E_{\mathbf{first}}(\theta)$$

This was something I could precompute and put in the denominator as a normalization factor alongside $2\pi$, in my initial approach. While my intention was not to give any particular semantics to this particular integral, it looked like a weighted average of the albedo where the weights were a function of the cosine of the angle (essentially direction where light is shot out)... It's just missing the weight scaling factor:

$$\frac{\int_{0}^{\pi/2} E(\theta_i) \cos(\theta_i) \sin(\theta_i) d\theta_i}{\int_{0}^{\pi/2} \cos(\theta_i) \sin(\theta_i) d\theta_i} = \frac{\int_{0}^{\pi/2} E(\theta_i) \cos(\theta_i) \sin(\theta_i) d\theta_i}{-\int_{1}^{0} u du} = 2\int_{0}^{\pi/2} E(\theta_i) \cos(\theta_i) \sin(\theta_i) d\theta_i$$

And we can pretend the $2$ out of our normalization is the weight scaling factor, just call it $\bar{E}$:

$$\Large\rho(\theta, \theta_i) = \frac{1 - E_{\mathbf{first}}(\theta)}{\pi\bar{E}} E_{\mathbf{first}}(\theta_i)$$

What of the Fresnel factor in this approximate multiscattering framework?

Here's the deal, precise multiple "anything" is complex to do online, especially when a statistical microsurface is involved, as we're essentially talking about running a stochastic process for an accurate result. We simply cannot afford it. It's conceptually trivial, but implementation is nigh impossible. So, we give up? No, we just need "the gist" of the effect, visual plausability.

Thus, let's assume its behavior is diffuse (as in ideal Lambertian diffuse, view direction independent). An extreme approximation, but it's something. You take a Fresnel function and take its cosine-weighted average over the unit hemisphere, giving you a mean representative of the whole which can be used in this diffuse paradise of ours. Haha, I know, a stretch. But it really doesn't matter much, single-scattering Fresnel term is the most important one here.

$$\bar{F} = 2\int_{0}^{\pi/2} F(\theta_i) \cos(\theta_i) \sin(\theta_i) d\theta_i$$

Since we're not actually tracing through the microsurface, what percentage of reflected light do we assume escapes the microsurface? Conveniently, we previously ran into a possible value here, $\bar{E}$. It's the representative mean of the fraction of light (albedo) being shot in the view direction. So, it's the light that escapes the surface, right? So, let's say that's the percentage of light that escapes on every bounce, modulated by the average Fresnel factor. So, the first bounce would send out $(1- \bar{E})\bar{F}$ in every other direction (and we assume it hits the microsurface again). That means that the remaining $1 - \bar{E}$ percentage of light is coming in "tinted" for the second hit.

Every second bounce can then be modeled singularly as sending out an $\bar{E}\bar{F}$ percentage of light in the view direction... Of the incoming $(1 - \bar{E})\bar{F}$ percentage, $\bar{E}\bar{F}(1 - \bar{E})\bar{F}$ (remember, diffusely reflected from the first hit). Extend this to the $n$-th bounce $\bar{E}\bar{F}\left(1 - \bar{E}\right)^n\bar{F}^n$ and you've got yourself a trivial geometric series which neatly converges. Only correction? Yes, the first bounce has to go as it is accounted for in the single-scattering "regular" BSDF:

$$F_{\mathbf{multi}} = \bar{E}\bar{F} \sum_{i=1}^{\infty} \left(1 - \bar{E}\right)^i\bar{F}^i $$

$$F_{\mathbf{multi}} - \left(1 - \bar{E}\right)\bar{F} F_{\mathbf{multi}} = \bar{E}\bar{F}^2 \left(1 - \bar{E}\right) $$

$$F_{\mathbf{multi}} = \frac{\bar{E}\bar{F}^2 \left(1 - \bar{E}\right)}{1 - \left(1 - \bar{E}\right)\bar{F}}$$

While this is now not repeating the single-scattering Fresnel, it is messing up the multiscattering albedo when perfect reflectance is assumed ($\bar{F} = 1$) as it resolves to $1 - \bar{E}$ which means you need to scale it down by the same amount to get back to $1$ (expected behavior). And this concludes this particular approximation of multi-scattering Fresnel incorporation.

Putting it all together

...And drawing a few conclusions

With all of this in mind, we can now augment any microfacet BSDF with an energy compensation term which is essentially approximating multiple scattering:

$$f_{\mathbf{brdf}}(\omega, \omega_i) = \frac{ D(h)G(\omega_i, \omega, h)F(\omega_i, h)}{4 \langle w_i, n \rangle \langle w, n \rangle } + \frac{\bar{F}^2}{\pi} \frac{1 - E_{\mathbf{first}}(\omega)}{ 1 - (1 - \bar{E})\bar{F}} E_{\mathbf{first}}(\omega_i)$$

In the next article, we'll be covering how the integrals are precomputed and/or fitted into the budget of a realtime application (pun intended), especially incorporating Fresnel into the mix in a way where the look-up tables won't be "unnecessarily multidimensional" and prohibitively expensive. As it stands now, it's non-trivial to compute, to say the least. To give you a taste of what we'll be doing, here's the albedo function of the single-scattering microfacet BSDF (with $F=1$) encoded as a two dimensional image (horizontally a function of angle, vertically a function of roughness):

$E_{\mathbf{first}}(\omega)$ precomputed into a 2D texture. You could even get away with a LUT as small as 16x16 given how smooth it is.

We'll have to roll up our sleeves to get Fresnel into the equation (pun intended) in a way that isn't at odds with practicality that a realtime system needs. Once we finalize that, we will jump into the work of Turquin who had a pretty interesting hot take on an approach of this flavor (of which Sony's Imageworks people are also proponents of)... He, interestingly, opted in to variably scale the single-scattering BSDF instead of manually developing a secondary term like we just did. And if you'd like to see a "before/after" image, I've got you covered:

The difference is rather striking at high roughness!

The furnace differential at high roughness is the best argument for approximating multiscattering.

Vector graphics, scanlines and the highly programmable GPU
Domagoj Pandza on January 26th, 2018
Computer Graphics
Show in single article view

Things worth noting...

A no-op will always be faster than any other op. Hence, while the technique outlined here is ridiculously performant, I don't actually utilize the system in realtime as it is not necessary for most use cases (exceptions being animations). On my end, every static bit of UI is rasterized quickly at load time and again only if DPI changes are detected. That way you can ship light vector graphics data and generate a system specific collection of supersampled rasters that are calibrated for the highest DPI monitor in use.

With the ever-increasing pixel density of modern displays and the less than satisfying didactic coverage of vector graphics, there's been growing interest among my colleagues regarding my hardware-accelerated implementation. In this article, I will attempt to quickly outline a simplified, scanline-based approach I implemented for use with my custom GUI framework and, by extension, Police Line. Modest knowledge of Bezier curves is a prerequisite for the reader, some of which can be acquired in a previous article.

Commercial and academic state of the art

In other words, why bother?

Whenever I mention I wrote a custom, online vector graphics rasterizer, most guess it is based on ideas developed by Loop and Blinn in 2005 or Kerr in 2009. Alas, both approaches require runtime CPU-based precomputation which easily becomes the bottleneck when processing complex vector graphics.

The former triangulates the path vertices on the CPU to generate geometry that represents the filled interior while parametrically solving the partially covered boundary in what is effectively texture space on the GPU. The latter, used in Direct3D 11-based Direct2D, is not entirely dissimilar as it revolves around converging to the path interior through trapezoid tessellation, a costly step performed, once again, on the CPU. Which is to say, both guesses are wrong.

While there have been developments as late as 2019 which propose generalizing 2D patch primitives suitable for GPU processing, they still require a hefty precomputation of whichever paths one desires to rasterize. Managing that doesn't seem particularly enticing given that the resulting speed-up is not necessarily stellar compared to my preferred approach, parallelizing the conventional scanline method. I reckon the most recent paper on the parallelized scanline rasterizers was written by Li et al. in 2016.

The paper, while interesting, has issues. It somewhat tends to hand-wave away important details, implies more complicated mechanisms are at play than in actuality and the supplementary code is fairly bad, even for academics. To add insult to injury, the provided reference implementation is written against the vendor-locked CUDA which is hardly appropriate for a purported solution to a real world problem.

Vector graphics in the game industry

Current commercial landscape is also rather sad, the middleware of choice for AAA studios, Scaleform is notoriously expensive and slow with games such as the latest Spiderman burning up to 8 ms on the UI alone. Microsoft's Direct2D, as outlined by Kerr in 2009, has an expensive CPU-side geometry generation and tessellation step and, as an API, is almost universally hated. With the advent of Direct3D 12, it cannot be used without serious issues and complications that arise from the Direct3D11on12 "translation layer" as it was built on top of the traditional Direct3D 11 API. Once and if you try to combine it with DirectComposition, it pretty much implodes.

Scaleform in Borderlands

On the other end of the spectrum, offline rasterized UI graphics at multiple resolution "LoDs" seems to be the nominal approach for apps on Apple devices, but also many a game. The latter sometimes just cover the bare minimum (96 dpi) which makes returning to them later a blurry mess.

There are also alternative solutions that are neither here nor there suitable for text rendering, based on signed distance fields. To my knowledge, applying SDFs to text rendering was popularized by Valve back when they were still making games and is actively used today by at least Unreal Engine 4.

Why "vector graphics"?

Early on, vector graphics were tightly coupled with display hardware. As such, they were contrasted against raster graphics displays, thus indicating the freedom of movement of the underlying "electron gun". In conventional raster displays, you had scanlines along which the electron gun moved from left to right, filling every discrete point as it went by according to the video signal. Vector graphics displays on the other hand had the the ability to move the gun's target in any pattern they desired, enabling them to move from point to point to draw a line. By continuously altering the direction, you could also display curves easily. Hence the somewhat counterintuitive name at first glance. It is no accident that we use the term nowadays as it is exact in spirit, original vector graphics displays were resolution independent and scaled nicely. Even the drawing information was formulated as a less advanced version of what we have today.

Today, the first association with modern vector graphics is "Bézier curves" and "text rendering". Like vector graphics were originally introduced for the needs of the US military (analysis of radar data), Bézier curves at the core of our modern vector graphics were introduced targeting a higher dimension in service of the French automotive industry by two pioneers, Paul de Casteljau of Citroën and Pierre Bézier of Renault. Both were interested in allowing artists to utilize a wonderful new invention called the computer to shape the bodies of future cars in an intuitive way. The world would learn of this in the 1960s and 1970s, most notably at a graphics conference held by the University of Utah in 1972, the home of the Newell teapot.

Parallelizing the scanline

Implicitly. Bring your scissors.

In the conventional, horizontal scanline rasterization method, the idea is to sweep along the raster grid vertically, intersecting with path curves at each iteration and filling in the spans in-between according to the fill rule (even-odd or non-zero) applied to the accumulated winding number wrt any position on said span. Naturally, this requires the intersections to be ordered. For the longest time, this has been impossible to do on the GPU efficiently due to the method's sequential nature and complex, prolonged computations. It's not any one thing we devised in the meantime as much as it is the increased programmability of GPUs that made it possible. Not only are GPU programs now able to be much more expressive, they can also be far longer as instruction count limitations have virtually been removed.

Key observation one needs to make is that the spans between two sequential scanline intersections with curves belonging to the same path all have the same winding number and can be processed as a unit, ie be indirectly scheduled as a hardware-accelerated line draw. More importantly, these spans are completely determined by the two intersections on either end. Specifically, the pixels on the span boundary containing portions of said curves. We shall use OpenGL vernacular and call these boundary/edge fragments as multiple paths may go through the same pixel. It quickly becomes clear that accumulating all those fragments by intersecting all the individual curves with the pixel grid will set the foundation for our rasterization method.

As multiple curves may land onto the same pixel and generate separate boundary fragments, we will also need to merge fragments that intersect curves that belong to the same path as they need to be processed as a unit. As the paths do not necessarily cover every pixel grid line, it would be wrong to run a thread for every grid line. Our parallelization basis for this stage will thus be the individual curves of each path. This may set off a red flag for you. Rightly so, curves that are way too long will intersects thousands of grid lines. As a bake step, you can convert any long path curves to shorter curves as a function of arbitrary heuristics that are up to you and your project's needs.

At this stage, we need to perform numerical root finding to test each relevant grid line against each individual curve dispatched. Most root finding algorithms for "online usage", including the simplest bisection solver, require an interval where samples at the limits have opposing signs. To achieve this, we need to have the curve extremities parked within the axis aligned bounding box of each curve's start and end position to enumerate and process the relevant grid lines without multiple intersections. We need to guarantee convergence for the bisection solver. This means that our inner control points, the tangents, need to be within the said bounding box. In other words, making sure every grid line "within" the bounding box intersects the curve and intersects it once.

We do this by making each curve monotonic, ie only increasing or decreasing wrt either axis. Naturally, we seek the roots of the derivative of each curve to find positions where this trend is broken (ie $t$ where the derivative is $0$ wrt $x$/$y$, indicating a tipping point for the curve). For linear curves (lines), they're already monotonic and we do no work on them. Quadratic curve's derivative is linear, trivial to determine whether it intersects 0. Cubic curve's derivative is quadratic and there is the "famous" solution to find the roots (zero). We collect all these curves and test them against the relevant lines using a bisection solver, this way we can initialize the next iteration from the previous by iterating over the grid lines one-by-one. In the end, we combine the individual $t$s and sort them to get pairs of intersections whose middle will reveal the location of the newly generated boundary fragment.

After accruing the boundary fragments, we need to sort them to actually generate spans between them and separate them by scanline, to allow us to compute the winding number on each of them. We do this by performing stable sorts against the individual coordinates of the boundary fragment as well as the path ID to make sure the "painter's algorithm" is maintained. We also merge all the boundary fragments belonging to the same path ID and pixel. At this point, we can process each merged fragment on its own by running a line through the center and checking for intersections with the constituent subcurves. Depending on the direction of the curve, clockwise yields $1$, counter-clockwise yields $-1$. Once all the merged fragments have this small computation done, we can use a prefix sum (specifically, an exclusive scan) to calculate the winding numbers at each of the possible spans between them. Anywhere where the resulting prefix sum is odd or non-zero (according to whatever fill rule you choose), you render the span between the $i$th and $i+1$th fragment's location, if they're on the same scanline (non-inclusive).

Merged boundary fragments require special attention to actually get to the sample points. By extending vertical and horizontal "rays" (really line-curve tests, rays are merely descriptive) and counting crossings, we determine whether any of the MSAA sample points is covered or not (same deal, $0$ or $2n + 1$). In the end, you manually fill in MSAA coverage information and render all the boundary fragments indirectly as 1x1 lines.

Potential for precomputation and further optimization

The vertical and horizontal line-curve crossing tests for samples are separable and can be precomputed individually for all MSAA samples, stored into lookup tables. The vertical component is solely a function of the individual curve's intersection with the left edge of the boundary pixel. Horizontal is more complicated, but you can arbitrarily approximate each subcurve with lines going through the start, end and $n$ interim points. By deriving the half-plane from each such line, you can precompute and lookup the bitmask of all samples on the relevant side of the line. By repeating this for each line approximation and closing off the top and bottom, a bitwise and will give you the final bitmask. This is linearly combined with the vertical lookup and the main ray to get the final bitmask for the boundary fragment, to be used to fill out MSAA coverage when rendering the 1x1 line representing the fragment.

Fun. Got to go. Bye.

DWM, DXGI, swap chains, latency, throughput and you
Domagoj Pandza on March 12th, 2017
Computer Graphics
Show in single article view

Important caveat

Everything in this article is predicated on the assumed lack of hardware support for variable refresh rates which enable "hassle-free tear-free" experiences. This includes technologies like Nvidia's GSync and AMD's open-ended Freesync. While increasingly available, I still consider such displays to be in the enthusiast department and, as a developer, the only way to provide a tear-free experience without it is with an implementation that understands how Vsync works. If image quality matters to you or your customers. For this article, we assume a run-of-the-mill 60 Hz display.

Properly configuring and handling a swap chain is crucial to ensuring a responsive and pleasant interactive application. Whether you're developing a digital content creation tool, video playback software, simulation or game which requires fluid controls, there's a lot to configure in order to ensure smooth sailing. Unfortunately, rich options and poor documentation often mix poorly and result in mediocre performance, bordering on bad. This is further exacerbated by the lack of a silver bullet configuration, as each application has its own specific needs which map it to a different location on the throughput-latency trade-off chart.

A lot of the information in here is platform agnostic and applicable everywhere. However, the article is written from the perspective of a Windows desktop developer utilizing DWM, DXGI and DX APIs, handling all the caveats the majority platform entails. It is, however, not a tutorial and there is no hand holding.

Things have changed

For the better

Exclusive fullscreen mode, in the traditional sense, is gone in Windows 10. People are still polarized on the subject, I personally find it to be a step in the right direction. Anyone who owns a multi-display setup sighed in relief when they first noticed that Alt+Tab no longer resulted in epileptic blinking due to mode switching. Who could forget the agony of having your system gamma settings messed up or games crash after switching to another application because they weren't programmed to recover from an unsolicited mode change instigated by being put in the background. A game is an application like any other and the experience should be as smooth, if not smoother.

Due to ISV disagreement during the development of DirectX 12 (and DirectX Graphics Infrastructure which made it possible), Microsoft added an "immediate" mode which I'd recommend you avoid and always aim for a borderless window. We will not be discussing it here. We will also not be discussing VR as most people don't care about this at the time. In the future, it might warrant an article of its own. Same goes for variable refresh rate display technology.

So, why isn't everyone onboard? Everything on Windows 10 is presented to the screen solely through the compositor, a key component of the Desktop Window Manager. To ensure there is no tearing when presenting a buffer to the display, the swap happens exactly at the time of a vertical blanking of the screen (usually, every 16.67 ms). A non-negotiable* V-Sync. Unfortunately, somewhere along the way bad programmers happened and introduced a massive amount of input lag by misunderstanding how the swap chain behaved with V-Sync enabled which started a decades long prejudice against V-Sync among gamers, all thanks to a few popular, but technically inept games.

Gamers disabled it altogether and lowered settings to a point where they could consistently hit 200 frames per second. They ignored the screen tearing and the drop in rendering quality because it gave them a spring in their step in-game as all the unnecessarily computed frames were just discarded and the latest one was used, holding their newest inputs. As a developer using fullscreen exclusive mode, you didn't have to justify yourself to DWM, you would just say: "Here's the fresh buffer, read from it, I don't care if you were in the middle of another buffer." And... It's gone. Someone's bound to be upset.

The root of V-Sync prejudice

Spoiler alert: It's people being bad at computers

Why do some games have ridiculous input lag with V-Sync enabled, even when they complete frames in less than 16 ms? Generating a frame for any modern game running on a computer with recommended specs does not actually take less than 16.67 ms, no matter what the Steam FPS overlay tells you. The GPU time tells only a part of the story. CPUs are required to read inputs, update state and then define, encode and enqueue all the work that needs to be peformed subsequently on the GPU to get a complete frame to present to the screen. Optimally, this happens in less than 16.67 ms. So, if you were to press the move key right now in an optimal game (CPU time: 16ms, GPU time: 16ms), in the best case scenario your changes would be registered in just over 33 ms, hitting the (relative) second vertical blanking event provided that the CPU work began at exactly after the last vertical blanking event. To understand this a bit better, let's start "at the bottom" and work our way up.

With the approach outlined in the example diagram above, the CPUs are yawning while the GPU is filling Buffer 0. The solution to this is to introduce pipelining. The CPUs can begin work on a new frame immediately after a present is queued (implicitly by calling IDXGISwapChain1::Present1). This doesn't reduce latency, but it does increase throughput. Until now, we have only allowed a single present to be queued, hence why the CPUs didn't start working on the next frame immediately. This is the implicit minimum frame latency of 1. To allow for pipelining, we need to allow for at least two presents to be queued (our maximum frame latency) and match the numbers of available back buffers. Otherwise, we'd have to wait on a surface to become available. By default, which is what a lot of people use, the maximum frame latency is 3. We don't necessarily have to hit all 3 presents, these settings are there to ensure a smooth framerate at the expense of latency when hiccups happen. Hiccups are a result of deviations in CPU and GPU times combined with the shifting phase of CPU work with respect to a blanking event, incurring a miss of the next vertical blanking event as demonstrated in the image below.

While this has been neatly set up to happen right at the start for demonstration purposes, this will happen. The phase of CPU work with respect to vertical blanking events changes every frame and both CPU and GPU workloads vary from frame to frame, a third present will eventually find its way into the queue and demand its time on the screen. After that happens, it is highly unlikely a three frame latency will be undone (and even if it were, it would just happen again). Which increases your average baseline input latency to just over 50 ms. Furthermore, if a buffer is being used on screen, the GPU cannot proceed to work on it. This results in the GPU having gaps in the timeline as it delays work beyond the next blanking event, when the current front buffer's time is up. You might be thinking: "I'll just have 3 back buffers and that will fix it." That would actually only make things worse. Once the present queue is full, you will be able to encode the next frame, but once you inevitably attempt to present, you will cause a block inside of IDXGISwapChain1::Present1. It waits until the queue is reduced by one (next vertical blanking) and then inserts it at the end, returning back to user code. This vicious cycle would continue with frames constantly queueing after blocking, earning you an additional 16.67 ms latency penalty for a grand total of just over 66 ms on average.

50-60 ms is the usual input latency incurred by misunderstanding swap chains (usually leaving them on default), and that's when you're aiming at a compute budget that is less than 16 ms on both the CPU and the GPU. Games that are locked to 30 FPS (budget being 32 ms for each), you usually get more than double the latency. And this is just your baseline, self-inflicted input lag. On top of all this, there is the constant 60-80 ms latency of having the actual image transmitted and displayed on the screen. Console gamers have been living with latency reaching 200 ms for years. Most PC gamers feel comfortable with latency being around 100 ms, although they have no idea it's actually so high, they gauge it by feeling it. In the next section, we discuss how to bring baseline input latency down to 33 ms for games aiming at 60 FPS games without tearing (or 66 ms if you're going for 30 FPS, nobody's judging.)

Adapting to change

Finding an ideal middle ground

As we touched on in the beginning, Windows took the stance that nobody receives special treatment by default. All applications go to the screen through the DWM compositor, which presents content to the screen at vertical blanking events (a "non-negotiable" default V-Sync). One of the primary complaints about previous OS versions (and associated APIs and runtimes) was the unnecessary surface indirection involved with regular windows where the client area was filled on the GPU because one couldn't just pass a handle to the DX filled, resident surface on the GPU to DWM for composition on the GPU. Naturally, this roundtrip incurred unnecessary latency and had to go. With Windows 8, and more dominantly in Windows 10, this became the legacy model (so called BitBlt presentation model) in favor of a new, more sensible flip model which could directly pass the handle to the freshly minted resident buffer to DWM for compositing, opening the door to proper borderless fullscreen windows. However, one must do a little bit of configuration to get the best results when it comes to responsiveness.

If it hasn't been made abundantly clear until now, V-Sync is now implicit. You don't need to synchronize on any particular interval inside of IDXGISwapChain1::Present1, in fact, the semantics of it are different in the flip model. An interval of 0 indicates to the runtime that it should cancel the remaining time of the previously queued present should the next one finish computing on the GPU before the previous frame's time on screen is up (it could be told to stay on screen for up to $n$ intervals). This combined with a maximum frame latency of 2 enables you to hit up to 120 FPS and latency as low as 17 ms.

Reducing latency involves a few steps, the first one being reducing the maximum size of the present queue to 2 by calling IDXGISwapChain2::SetMaximumFrameLatency. As we've already mentioned, the maximum frame latency is set to 3 by default. Reducing from 3 to 2 will enable us to have a latency of 33 ms or lower, provided we fire off work at optimal times. To actually be able to queue two presents and not wait on a currently presenting front buffer, the swap chain needs to be created with 3 buffers (there is no longer an implicit windowed front buffer which was part of the indirection scheme). Two of the buffers will be always available for rendering while one will be used by DWM for composition.

So, what is the optimal time to start updating state and developing a frame? Right at the last possible microsecond where the CPU part can do its work and finish just before the next vertical blanking event. In practice, when you're releasing a new application, you have a very specific (although open) set of hardware which only has a lower bound defined by minimum hardware requirements at which it is still possible to have an interactive experience. Hardware on which both the CPUs and GPUs will pretty much be doing a lot of work, usually up to 15-16 ms each (or 30-32 ms if you're targeting a low-end console). Thus, the previously discussed optimal time will be around a vertical blanking event, so you might as well have the system wake you up to prepare the next frame once it happens, unless there are open slots in the present queue in which case it will call you immediately. This is demonstrated in the image below.

This is achieved in code by creating your swap chain with the DXGI_SWAP_CHAIN_FLAG_FRAME_LATENCY_WAITABLE_OBJECT flag, which is also a prerequisite for setting frame latency on the swap chain. This enables you to sleep on an associated, cleverly titled "waitable object" which only wakes you up at the optimal times described in the previous paragraph. The idea is that you're not supposed to be running a busy loop which would constantly start too soon or too late, incurring additional latency. You can invent this on your own, though. There is no magic to it, just track your timings carefully and sleep based on them.

And that's all there is to it, keeping it simple in the face of too many options. I implore you to give this a try, you will find a surprisingly nimble mouse and keyboard with no sacrifice in image quality. Until next time!

Regarding non-linear depth and texture swizzling
Domagoj Pandza on April 4th, 2007
Computer Graphics
Show in single article view

This is a refurbished version of two of my articles from 2007 in the interest of answering two questions that have been raised on Discord recently, mostly to be used as additional reading / reference for my newer articles.

On the non-linear depth buffer

The quickening of hidden surface removal

The way we transform depth in the perspective projection matrix comes off as weird if you've never written a software rasterizer before, especially one modeled around emulating the way actual hardware (ie a GPU) would do it. To address the elephant in the room: no, it's not mandatory. It's just useful, as you'll see in the next few paragraphs.

As you know, one of the fundamental considerations behind any rasterizer is interpolating depth stored at vertices across the surface of the triangle they define for the purposes of hidden surface removal, determining what lies on top of what in the final image. A naive approach would be to simply, and trivially, linearly interpolate it in screen space. The issue with this should be obvious, the triangle being rasterized can have any orientation with respect to the image plane meaning that its projection won't be a mere simple linear scale down unless the triangle lies in a plane that is head-on parallel with the image plane hence the interpolation based on factors derived in screen space will be incorrect. We thus want to find a relation which will allow us to effectively interpolate depth on the triangle in view space because, again, uniform steps in the image plane do not necessarily map to uniform steps in the plane containing the triangle. From the diagram below, the following can be derived:

$$\begin{split} z_0 + t(z_1 - z_0) = \frac{x_0^{'}z_0 + t(x_1^{'}z_1 - x_0^{'}z_0)}{x_0^{'} + s(x_1^{'} - x_0^{'})} \iff \frac{x'}{d} = \frac{x}{z} \\ \end{split}$$

A little bit of rearranging gets us $t$ in terms of $s$, our ticket to interpolating in view space:

$$t\left[ z_1 + s(z_0 - z_1) \right] = t\left[ (1-s)z_1 + sz_0 \right] = sz_0$$ $$t = \frac{sz_0}{(1-s)z_1 + sz_0} $$

Plugging it back in causes a healthy number of satisfying cancellations, leaving you with:

$$z = z_0 + \frac{sz_0(z_1 - z_0)}{(1-s)z_1 + sz_0} = \frac{z_0z_1}{(1-s)z_1 + sz_0} $$

You could call it quits here, but the right side yearns to be simplified:

$$z = \frac{z_0z_1}{(1-s)z_1 + sz_0} \Rightarrow \frac{1}{z} = (1-s)\frac{1}{z_0} + s\frac{1}{z_1}$$

Even better, trying to interpolate in the plane of the triangle revealed to us that $1/z$ interpolates linearly in screen space, a fundamental result in rasterization. Hardware likes linear interpolation. This is why we model our depth transform function encoded within the perspective projection matrix as $d(z) = A + B/z$ (following the $z$-divide from the basic linear scale-translate $Az + B$). Depending on graphics API, the range of the function has different limits. In D3D land, by default, depth is mapped from $[n,f]$ to $[0,1]$, to normalized device coordinates. When using single precision floating points, most resolution in this scenario is packed really up-close which makes the rest of the depth range fight for scraps, potentially leading to z-fighting. This is why most of us flip the depth test to the slightly unnatural greater_than (along with a near/far reversal), to enable clean rendering of longer distances.

It is important to note that the non-linear depth is not used for perspective-correct vertex attribute interpolation, as the transform destroys the necessary information. It is mainly used for ordering-related operations, primarily fast hidden surface removal. This allows the GPU to intercept unnecessary computations on geometry which will not be visible on-screen via early-Z checks, avoiding costly perspective-correct attribute interpolation and dispatching pixel shader workloads. Naturally, this also makes shadow mapping faster and simplifies implementation of certain shaders (such as finding edges) because depth discontinuities are trivial to detect.

On texture swizzling

Courtesy of Morton

The most common, and easiest to understand, method of "flattening" a 2D array of data is usually to store it row-by-row (row-major) or column-by-column (column-major). Pretty much any texture will have very long rows and columns, so any processing on a local block of texture data (such as bilinear filtering) will mean cache misses and DRAM page breaks.

What we want is to flatten the texture tile-by-tile (eg. 2x2) or even by doing tiles that contain smaller tiles (4x4 tiles containing 2x2 tiles etc). A naive way you may come up with is by using the modulus operator to define blocks, but that is hardly performant. In the diagram, you can see how a row-major texture looks like in memory by drawing lines through the rows and an example of what we'd like to see. Notice that both exhibit a Z-like pattern. That is crucial.

Assumptions made in this section

In all the examples, assume that I am talking about a power-of-2 sized 2D texture with a single channel with 1 byte worth of data unless explicitly stated otherwise. This makes addressing expressions simple and easy to digest. You can extrapolate from the intuition gained to see how it works on more elaborate configurations.

When accessing row-major data, we utilize a stride that helps us navigate between rows as the we $y$ coordinate increases. In the simplest case where each entry is $1$ byte long and rows are not padded for alignment concerns that stride is just the width of the texture, so you'd express it the desired linear address as $y*w + x$. Multiplication of integers is nothing but shifts and adds, so any number of coordinates with well defined layout of rows and slices can be fully linearized as a combination of shifts and adds. In the case of power-of-2 sized textures, it boils down to a single left shift and add. For a 256x256 row-major texture, from the bits of the individual coordinates you can form any address with $\mathrm{y_7y_6y_5y_4y_3y_2y_1y_0x_7x_6x_5x_4x_3x_2x_1x_0}$. After all, it's just the left shifted $y$ coordinate (multiplied by $256$) $\mathrm{y_7y_6y_5y_4y_3y_2y_1y_000000000}$ and the $x$ coordinate $\mathrm{00000000x_7x_6x_5x_4x_3x_2x_1x_0}$ added to it.

The linear address allows the $x$ coordinate to go all the way up to 255 and the $y$ dictates the offset for the row we're targeting. As you've seen, the Z pattern is already there, it's just extremely long. You can shorten that interval by bit twiddling. The simple act of permuting to $\mathrm{y_7y_6y_5y_4y_3y_2y_1x_7x_6x_5x_4x_3x_2x_1y_0x_0}$ creates rows of 2x2 tiles, making $(x,0)$ go $0, 1, 4, 5, 8, 9, 12, 13, 16, 17$... Any number of nested tiles are possible, including the pattern I drew in the diagram above with 2x2 tiles inside of 4x4 tiles. The top-level tile dimensions dictate padding requirements for the texture and thus how much memory will be wasted.

Methods for bit twiddling

How one actually interleaves the necessary coordinate bits to get the final linear address is both tricky and not. In software, it can get quite messy getting this to run fast, usually we utilize tricks with multiplication being just shifts and adds to actually goad the bits into spacing out and then adding them together to form the final address. Adding is often used instead of bitwise OR because there are special addressing modes for addition with two operands on most, if not all, relevant hardware architectures.

GPUs have special dedicated hardware for this as it's nothing but routing circuitry, it can be made extremely fast if the swizzle pattern is hardcoded. x86 CPUs have had "bitwise scatter/gather" instructions for quite a while, called PDEP/PEXT for deposit and extract. Intel has implemented the instruction in hardware since Haswell while AMD was doing it in very latent microcode all the way up to Zen2 (latency of 18, IIRC). Zen3 is the first generation of AMD CPUs with actual proper support for the two instructions, making them feasible and fast. In case you were writing a software rasterizer on your new 5950X for shits and giggles.