Page Allocation, Culling and Rasterizer

Explaining how the virtual shadow map works is difficult with my limited computer graphics skills, but I will do my best. Before I begin, I recommend reading the official documentation.

Virtual Shadow Maps have been developed with the following goals:
  • Significantly increase shadow resolution to match highly detailed Nanite geometry
  • Plausible soft shadows with reasonable, controllable performance costs
  • Provide a simple solution that works by default with limited amounts of adjustment needed
  • Replace the many Stationary Light shadowing techniques with a single, unified path
image
ℹ️
Our cube is a simple static mesh, not a Nanite mesh. So this part is about how VSM works with a normal static mesh.

Pages and page table

The core of the virtual texture (also known as the virtual shadow map) is the mapping between virtual pages and physical pages. Although we may have a large number of virtual pages, only the necessary information will be loaded into the physical pages.

In Unreal Engine 5.2's VSM, one of the most important data structures is a 1D page table, indexed by an ID. Each page is 128 x 128 pixel square.

image

However, in order to easily index the pages, Unreal Engine has chosen to place them in a hierarchical structure.

  • Page Table
    • VSM
      • Mip-level
        • pages

Obviously, we will have more than one virtual shadow map, so we will need a VSM ID. Each VSM will have multiple mipmap levels. We should divide each mipmap level into pages; higher levels (smaller sizes) will have fewer pages.

image

Source: https://docs.unity3d.com/2021.3/Documentation/Manual/texture-mipmaps-introduction.html

Finally, we will have one page, with each page containing 128 x 128 pixels. Therefore, we need to select one pixel with an internal uv.

In summary, to select a pixel on the physical page texture, we need a virtual shadow map id, a level, and a page address (the x and y coordinate of a page). With this information, we can calculate a 1D index. Then, we can select the corresponding page from the page table. From this element, we can determine the pixel offset in the physical page and add the internal uv to it.

I am using this image just to help you understand the concept, but be careful, Unreal Engine uses a 1D page table instead of a 2D one. We will see the real transfer steps later.

Process overview

The whole process can be treated as follows:

  • Allocate pages and build page info; initialize physical pages.
  • Culling Pass: Check the light sources and meshes, and determine how each mesh should be rendered into one or many pages.
  • Raster Pass: Render the scene, rasterize the triangles, and write the shadow depth into the physical page textures.

I know it is a little hard to understand. So let’s use our cube as an example.

Light source to view

image

Do you remember that we have a point light in front of the cube?

Because it is a point light, we need to render 6 views to generate the shadow depth.

However, we only need to render views number 1 and 5, which is obvious. Additionally, each view has many mip map levels, so we do not need to render them all. We can simply choose the appropriate level based on the pixel sizes on the screen.

After the culling, only three views need to be rendered: numbers 37, 43, and 47.

image

Instances

So unreal engine uses a compute shader to generate three instances for each view to render.

In the vertex shader, the instances are transformed based on each view’s shadow projection matrix.

image

Depth Pixel

Finally, in the pixel shader of the Raster Pass, the shader decodes the page info to get the page view index. It then loads the page info from the page table and obtains the real physical page texture's base offset. Adding the internal offset of the pixel inside the page, it uses an InterlockedMax to write the shadow depth to the physical page texture. The shadow depth will be used later for shadow rendering.

Cull and Raster Pass

This flow chart shows the details of what we talked.

image

Please take a look at:

  • Instances and views are used in this context.
  • The packed page view information is passed from the vertex shader to the pixel shader through a VsToPs parameter.
  • CalcPageOffset is the key to map from the view to the page table index.
  • The page table is used to get the physical page address.

Cull Pass

This is the process of the cull pass compute shader:

image

The following code features a double for-loop:

  • For each view:
    • Perform box-frustum culling.
    • For each mip level:
      • Check for overlap.
      • Mark the page.
      • Write view instances.

The core consists of two parts:

  • Box-frustum culling. There are many explanations available on how this works, so I will skip it.
  • Page overlap check. This checks if the current mesh is overlapping any valid page in the current mip level.

You may have a question:

❓
The cube overlaps both view 1 and 5, but why were only numbers 37 and 43 from view 1 and number 47 from view 5 selected?
image

As mentioned in previous steps (which are not discussed in this chapter), only pages 37, 43, and 47 have been marked as 'valid'. Therefore, only these pages can pass the validation check and undergo the overlap check.

Raster Pass

The process is already explained in this image.

However, let's take a closer look at CalcPageOffset.

image
  • The page table have 8k pre-allocated elements for single page shadow maps
  • So our virtual shadow map id should remove this parts, and then find the beginning of the pages by multiply VSM_PAGE_TABLE_SIZE.
    • VSM_PAGE_TABLE_SIZE is calculated by CalcVirtualShadowMapLevelOffsets
    • The mip 0 has 128 x 128 pages. ( each page has 128 x 128 pixels)
    • Then, the higher level will have half number of pages. For example, mip 1 has 64 x 64.
    • The max level is 8
    • So you can calculate the total number of the pages for each VSM by
    • 128∗128+64∗64+32∗32+16∗16...+1=21845128 * 128 + 64*64 + 32*32+16*16...+1 = 21845
  • Then we should calculate the mip level offsets
  • We begin by splitting the input vAddress (which is actually SvPosition) into two parts:
    • The higher part represents the page coordinate in the mip level.
    • The lower part represents the internal pixel offset inside the page, which is also the same offset in the physical page.
  • By combining the mip level offsets and the page coordinates, we can select a page, retrieve its information, and get the corresponding physical address.
  • We can then combine the physical address with the internal pixel offset to finally select the desired pixel in the physical texture.

Tips

If you want to debug yourself, remember to use the r.Shadow.Virtual.Cache 0 console command to disable the cache. Otherwise, you will not be able to capture the rendering draw calls.