Community Tutorial: Cinematic Prestreaming Virtual Textures

I believe I solved my problem with oversubscription and it turns out it was a zero gradient issue after all!

It took a long time to figure out but the issue came from an interaction with my Nanite enabled landscape, my material, and Virtual Textures. With Nanite enabled the landscape would request a very high number of pages and keep them in memory even when out of view for longer than the time it took to free a page. Setting r.vt.FeedbackFactor 1 made this issue way more extreme. I was working with a few giant 16k physical memory pools where all of the pages were getting used up immediately, oversubscribing, and forcing a lower mip when starting a Movie Render Queue render.


r.vt.FeedbackFactor 1 and Landscape Nanite On


r.vt.FeedbackFactor 1 and Landscape Nanite Off

With Nanite disabled on my landscape and I was able to set r.vt.FeedbackFactor 1 again and run Movie Render Queue without mip bias getting maxed out.

I wasn’t able to reproduce this in a test environment and discovered that a node in the landscape material was clamping the world position for the calculated texture coordinate. The issue wasn’t presenting itself when Nanite was disabled but with it on the vertex positions became less precise and produced fragments with a zero gradient. Zero gradients as articulated in the virtual memory pools documentation create conditions for oversubscription.

This bug took me a while to catch and I felt like I had to do a lot of research to figure this out so I wanted to share some things I figured out along the way.

- The tools that helped me understand this bug -

Expanding my Virtual Texture Pools.

If you need to push the size of Virtual Texture Pools to extremes, I was able to allocate about three 16k physical memory textures worth.

I used Unreal Engine 5.3 so increasing pool size involved going into my BaseEngine.ini (for me under C:\Program Files\Epic Games\UE_5.3\Engine\Config) which wasn’t version control friendly but got the job done. Here I maxed out pools by setting the Size in Megabyte over 256, values higher than 1024 were invalidated and reset to default pool size.

[/Script/Engine.VirtualTexturePoolConfig]
; Configure VT physical memory pools. Useful commands to set these:
; "stat virtualtexturing" show dynamic use of the VT system including the cache loads
; "r.VT.ListPhysicalPool" list in-depth details on the allocated physical pools
; "r.VT.DumpPoolUsage" list in-depth details on asset usage of physical pools
; The default pool size and behavior is set here. It can be overridden in project config files.
+Pools=(SizeInMegabyte=64, bAllowSizeScale=False, bEnableResidencyMipMapBias=True, bPoolAutoGrowInEditor=True)
; Specific pools can be configured per tile size or format in project config files. Examples commented below:
+Pools=(Formats=(PF_DXT1), SizeInMegabyte=512, MinTileSize=0, MaxTileSize=9999, bAllowSizeScale=True, bEnableResidencyMipMapBias=True)
+Pools=(Formats=(PF_BC7), SizeInMegabyte=512, MinTileSize=0, MaxTileSize=9999, bAllowSizeScale=True, bEnableResidencyMipMapBias=True)
+Pools=(Formats=(PF_BC6H), SizeInMegabyte=512, MinTileSize=0, MaxTileSize=9999, bAllowSizeScale=True, bEnableResidencyMipMapBias=True)
;+Pools=(Formats=(PF_DXT5, PF_DXT5), SizeInMegabyte=100)

The maximum amount of pages you can have is limited by your tile size. With a tile size of 128 and border of 4 I was getting 14400 pages. Setting the border to 0 I get 16384 pages in my 16384x16384 memory texture. With the tile size 512 and border of 0 and I get 1024 pages. It seems like once you’re maxed out you can calculate the number of pages you’ll get with the equation:
pow(floor(16384 / TileSize), 2) – I haven’t figured out how borders factor in quite yet but that would be another way to push it.

One other trick here was changing the format of some of my VATs and increasing those pool sizes as well. I aimed for 8bit color formats: DXT1, BC7, and BC6H (8bit HDR). This allowed allocating memory beyond the limits of one physical memory texture for RGB data.

In 5.5 the message people get pretty often is “Resizing Texture Pools.” Switching to UE 5.5 made it much easier to iterate on the pool sizes but it was still ultimately limited to format and size of each format.


Screenshot borrowed from this thread.

CVARs for Fiddling Around

r.VT.Residency.Show 1 I had on most of the time. When studying oversubscription it helped me understand the amount of tiles available to me and how they were utilized. When iterating on pool size this gave me really immediate feedback as to the size I could work with. If you start up Movie Render Queue the overlay will stay over your render which can be really helpful.

r.VT.Residency.Notify 1 was mostly useful in 5.3. In 5.5 with auto pool expansion you get a message in the bottom right when pools are oversubscribed anyway. Combined with r.VT.Residency.Show it makes it clearer when you’re actually hitting a limit in 5.3.

r.VT.Borders 1 helped me see how VTs were animated and scaled in the scene. It also helped me spot areas where tiles weren’t getting dumped that should have.

stat virtualtexturing ultimately being able to see the amount of pages that were being requested at a time was the most helpful for me to see how I was getting to oversubscription. Changing r.vt.FeedbackFactor here helped me see how demanding it could be in terms of subscription and comparing page requests with max pages available in r.VT.Residency.Show was invaluable.


Feedback Factor of 1.


Feedback Factor of 16.


Feedback Factor of 128. Note how the pages visible have significantly decreased.

r.VT.DumpPoolUsage helped spot the most problematic virtual textures but I was initially distracted by the dimensions of the textures since they all had the same issue. It turned out their common factor was their materials and use of nanite. The most useful thing it showed me was that some of the most aggressively subscribed textures were hardly on screen.

r.vt.FeedbackFactor 1 and r.VT.MaxUploadsPerFrameInEditor 128 is a really quick way to max out pools if oversubscription is already an issue.


Pool getting oversubscribed.

Peeking Under the Hood with RenderDoc

Without RenderDoc I wouldn’t have understood that each format isn’t a separate Physical Memory Pool as intuitively. It involves some setup but once you’ve got it going it’s an invaluable tool in the absence of a built in frame debugger in UE.

Virtual Texture tiles are pooled together like tapestries and it’s totally worth opening up RenderDoc just to peek at them. It helped me understand which tiles were staying in memory at a glance in a way that other tools didn’t.


DXT1 Memory.


BC7 Memory.


BC6 Memory.

I won’t get into too much detail about this but basically:

  • Make a capture in UE5 with RenderDoc plugin and open it.
  • Open the Texture Viewer tab in the middle panel.
  • Click through the Timeline panel until you find your BasePass.
  • Find a draw call with a material that you know uses a virtual texture. (In my case above I clicked on LandscaleMaterialIntanceConstant_13 Landscape.)
  • In the far right panel click on Input and scroll down until you find your virtual texture!

If you don’t have time to setup RenderDoc I highly recommend Unsul’s virtual texture demo and writeup on the Three.js forums. It’s great for helping with an intuition around virtual textures.

screenshot from Unsul’s demo

- Some Room for Improvement in Unreal Engine -

Documentation for Oversubscription

I learned while studying this issue that oversubscription is an underdocumented but frequently cited issue in Unreal Engine. I came across many posts with no responses about blurry landscapes, frequent resizing texture pools messages, and tiles loading in slowly.

The official resource offers helpful starting points but is vague about causes stating: “Oversubscription can also come from unexpected sources…” and defining the issue by examples. It would be a huge step to expand on the root of oversubscription issues: why subscribe to tiles in the first place? If zero gradient UVs are a major issue why is that?

I really appreciate this tutorial for bringing it to light for me as a first step but more technical resources are needed here from Epic IMO.

Reducing Ambiguity between Streaming Texture Methods

Unreal Engines two main methods for streaming static texture data (leaving RVTs out for a sec) are Streaming Textures and Streaming Virtual Textures. From what I understand, Streaming Textures stream in mips of the whole texture generated in a pyramid (2048, 1024, 512, 256, etc…) and Streaming Virtual Textures stream in tiles generated from textures at different mip levels. Because they share “Streaming Textures” there are all kinds of places for users to trip.


Illustration from PC Magazine of Mip Mapping Pyramid


Illustration of Virtual Texturing from Stack Overflow

In movie render queue’s Game Overrides you’re presented with a dropdown titled Texture Streaming with options: Disable Streaming, Don’t Override, Fully Load Textures. Because Streaming Virtual Textures is a streaming technique for textures as well I had to figure out by testing that it wasn’t a setting that pertained to virtual textures. This only seems to work with pyramid based mip streaming. The only knob that effects virtual textures in Game Overrides as far as I can tell is Virtual Texture Feedback factor.

This ambiguity is also in many cvars and settings in the engine. r.Streaming.PoolSize for example only seems to refer to pyramid based mip streaming. I started to build an intuition for this by looking for commands in r.vt rather than the root of r but found other moments where they blend together. For example in the source code for VirtualTextureSystem, the logic that supports Cinematic PreStreaming: page requests, utilizes the Global Mip Bias from the Texture2D class.

This Global Mip Bias is not utilized at runtime otherwise.

Having one direction for the engine: either making controls for Streaming Virtual Textures much more distinct from pyramid based Streaming Textures or having them overlap consistently would save users so much headache.

Offering More Control in Scalability

I’m glad I figured out the bug that was causing this issue but I could have solved this much more quickly a couple of ways.

  • I would have loved to be able to set a minimum mip level to prioritize larger mips over tiny details.
  • I also would have loved a way to create more pools without having to hack my way around using different formats.

Thanks for reading through this I hope it helps you if you’re stuck.

TLDR

There was a sneaky clamp function in the texture coordinate of my landscape material that introduced oversubscription issues. Look out for anywhere your UVs aren’t varying spatially in your material or mesh (zero gradient).

The popular solution: expanding pool sizes, didn’t solve it for me but I explored it pretty thoroughly and outlined some extremes if you’re curious about pushing it.

2 Likes