<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Ankit</title>
    <description>The latest articles on Forem by Ankit (@adbhut).</description>
    <link>https://forem.com/adbhut</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3834720%2Fea223c26-5e24-4769-9fd3-236e4eb35788.jpg</url>
      <title>Forem: Ankit</title>
      <link>https://forem.com/adbhut</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/adbhut"/>
    <language>en</language>
    <item>
      <title>t.MaxFPS vs r.SetFramePace - The Two Knobs Every UE5 Android Dev Must Understand</title>
      <dc:creator>Ankit</dc:creator>
      <pubDate>Tue, 24 Mar 2026 16:59:34 +0000</pubDate>
      <link>https://forem.com/adbhut/tmaxfps-vs-rsetframepace-the-two-knobs-every-ue5-android-dev-must-understand-4md7</link>
      <guid>https://forem.com/adbhut/tmaxfps-vs-rsetframepace-the-two-knobs-every-ue5-android-dev-must-understand-4md7</guid>
      <description>&lt;p&gt;I'd been wrestling with the nitty-gritties of what &lt;em&gt;should&lt;/em&gt; be a straightforward problem — setting a specific frame rate on Android in Unreal Engine 5. Two console variables, &lt;code&gt;t.MaxFPS&lt;/code&gt; and &lt;code&gt;r.SetFramePace&lt;/code&gt;, kept tripping me up until I cracked open the engine and traced what they actually do under the hood.&lt;/p&gt;

&lt;p&gt;If you've ever stared at Unreal Insights wondering why your frames aren't hitting the target you set, or if the term "SwappyGL" sounds like a rejected Pokémon name to you — this one's for you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prerequisites:&lt;/strong&gt; A basic understanding of the 3 threads involved in UE gameplay (Game, Render, RHI) and familiarity with Unreal Insights.&lt;/p&gt;




&lt;h2&gt;
  
  
  t.MaxFPS
&lt;/h2&gt;

&lt;p&gt;In simple terms, this is the console variable that caps your FPS. Set it to anything greater than 0, and it enforces that cap. Set it to 0 (or below), and the cap is off — the engine runs as fast as it can.&lt;/p&gt;

&lt;p&gt;What it &lt;em&gt;really&lt;/em&gt; does is put the game thread to sleep. The sleep duration is calculated from how long the game thread was busy in the previous frame. The event to look for in a utrace is &lt;code&gt;STAT_FEngineLoop_UpdateTimeAndHandleMaxTickRate&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxvrpq4g6vivijtefx315.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxvrpq4g6vivijtefx315.png" alt="Sample game frame from Unreal Insights"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Let's say we set &lt;code&gt;t.MaxFPS 60&lt;/code&gt;. In the frame above (frame 11477), the game thread worked for 6.9 ms, so the next wait time shakes out like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Wait time = Expected frame time − Previous frame's work time
         = 16.6 ms − 6.9 ms
         = ~9.7 ms
         ≈ ~9.5 ms (after accounting for scheduling overhead)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And that's exactly the ~9.5 ms sleep you see in the trace. Simple arithmetic, big impact.&lt;/p&gt;

&lt;h2&gt;
  
  
  r.SetFramePace
&lt;/h2&gt;

&lt;p&gt;Sometimes, &lt;code&gt;t.MaxFPS&lt;/code&gt; alone isn't enough. Consider this: you want 90 FPS gameplay. You set &lt;code&gt;t.MaxFPS 90&lt;/code&gt;, the game thread happily ticks along at 90 Hz — but the display is still refreshing at 60 Hz. Each frame sits on screen for 16.66 ms regardless. Your RHI thread is presenting frames at 60 FPS no matter what the game thread thinks it's doing.&lt;/p&gt;

&lt;p&gt;This is where &lt;code&gt;r.SetFramePace&lt;/code&gt; comes to the rescue. Setting it to 90 tells the Android frame pacer to request a 90 Hz refresh rate from the display (if the device supports it). Put differently, &lt;code&gt;r.SetFramePace&lt;/code&gt; controls the &lt;em&gt;wait on the RHI thread&lt;/em&gt; — the presentation side of the pipeline.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F01upp8q09kdv6yfjrk63.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F01upp8q09kdv6yfjrk63.png" alt="When MaxFPS is set but FramePace is not"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the trace above, the RHI thread waits on &lt;code&gt;SwappyGL_swap&lt;/code&gt;, which only returns after 16.6 ms from the previous screen presentation — locked to the 60 Hz vsync interval.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj4a7omotdths2z6jf7fw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj4a7omotdths2z6jf7fw.png" alt="When FramePace is set along with MaxFPS"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Once &lt;code&gt;r.SetFramePace 90&lt;/code&gt; is also set, the vsync interval drops from ~16.6 ms to ~11.1 ms (since 1000 ms ÷ 90 = 11.1 ms). Now both the game thread &lt;em&gt;and&lt;/em&gt; the display are speaking the same language.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's SwappyGL?
&lt;/h3&gt;

&lt;p&gt;Every frame on Android, the RHI thread eventually calls &lt;code&gt;eglSwapBuffers&lt;/code&gt; to present the back buffer. In UE5, this goes through &lt;code&gt;FAndroidOpenGLFramePacer&lt;/code&gt;, which wraps the actual swap call. &lt;code&gt;SwappyGL_swap&lt;/code&gt; is one of the presentation strategies — and it's the smart one.&lt;/p&gt;

&lt;p&gt;When frame pacing is active, UE doesn't call &lt;code&gt;eglSwapBuffers&lt;/code&gt; directly. Instead, the call chain looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RHI Thread
│
└─ FAndroidOpenGLFramePacer::SwapBuffers()
   │
   └─ SwappyGL_swap(display, surface)
      │
      ├─ Reads Android Choreographer's vsync timestamps
      │   (the hardware vsync pulse from the display panel)
      │
      ├─ Computes TARGET presentation timestamp
      │   Target = LastPresentTime + DesiredFrameInterval
      │   e.g., 90 FPS on 90 Hz → interval = 11.1 ms
      │
      ├─ GPU finished EARLY (frame ready before target vsync):
      │   ├─ Sets EGL_ANDROID_presentation_time on the buffer
      │   │   ("don't show this until vsync T+N")
      │   ├─ Calls real eglSwapBuffers
      │   └─ BLOCKS the RHI thread until target vsync arrives
      │
      ├─ GPU finished ON TIME:
      │   ├─ Sets presentation timestamp for next vsync
      │   ├─ Calls real eglSwapBuffers
      │   └─ Returns almost immediately
      │
      └─ GPU finished LATE (missed target vsync):
          ├─ Detects the miss via Choreographer callback
          ├─ Adjusts pipeline mode (may add a frame of latency)
          ├─ Targets the NEXT valid vsync instead
          └─ Logs the miss for auto-swap-interval adjustment
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key thing SwappyGL provides is &lt;strong&gt;vsync phase-locking&lt;/strong&gt;. It doesn't just throttle — it actively aligns frame presentation to specific vsync boundaries using &lt;code&gt;EGL_ANDROID_presentation_time&lt;/code&gt;. This means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every frame displays for exactly the same duration (11.1 ms at 90 Hz).&lt;/li&gt;
&lt;li&gt;Frame N is guaranteed to replace Frame N−1 at a precise vsync tick.&lt;/li&gt;
&lt;li&gt;SurfaceFlinger (the compositor) knows in advance when each frame should appear.&lt;/li&gt;
&lt;li&gt;Input latency becomes predictable because the pipeline depth is fixed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In short, SwappyGL turns your frame timing from "best effort" into "contract with the display."&lt;/p&gt;

&lt;h3&gt;
  
  
  What Happens When Frame Pace Is Set to 0
&lt;/h3&gt;

&lt;p&gt;When frame pacing is disabled, the path is much simpler — and much less coordinated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="n"&gt;RHI&lt;/span&gt; &lt;span class="n"&gt;Thread&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt;
&lt;span class="err"&gt;└─&lt;/span&gt; &lt;span class="n"&gt;FAndroidOpenGLFramePacer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;SwapBuffers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
   &lt;span class="err"&gt;│&lt;/span&gt;
   &lt;span class="err"&gt;├─&lt;/span&gt; &lt;span class="n"&gt;eglSwapInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;display&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="c1"&gt;// Set once at init: no vsync wait&lt;/span&gt;
   &lt;span class="err"&gt;│&lt;/span&gt;
   &lt;span class="err"&gt;└─&lt;/span&gt; &lt;span class="n"&gt;eglSwapBuffers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;display&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;surface&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// Direct call, no Swappy&lt;/span&gt;
      &lt;span class="err"&gt;│&lt;/span&gt;
      &lt;span class="err"&gt;├─&lt;/span&gt; &lt;span class="n"&gt;Queues&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;buffer&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;SurfaceFlinger&lt;/span&gt;
      &lt;span class="err"&gt;├─&lt;/span&gt; &lt;span class="n"&gt;Returns&lt;/span&gt; &lt;span class="n"&gt;IMMEDIATELY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;no&lt;/span&gt; &lt;span class="n"&gt;vsync&lt;/span&gt; &lt;span class="n"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="err"&gt;└─&lt;/span&gt; &lt;span class="n"&gt;No&lt;/span&gt; &lt;span class="n"&gt;presentation&lt;/span&gt; &lt;span class="n"&gt;timestamp&lt;/span&gt; &lt;span class="n"&gt;set&lt;/span&gt;
          &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;compositor&lt;/span&gt; &lt;span class="n"&gt;shows&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;next&lt;/span&gt; &lt;span class="n"&gt;available&lt;/span&gt; &lt;span class="n"&gt;vsync&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;whenever&lt;/span&gt; &lt;span class="n"&gt;that&lt;/span&gt; &lt;span class="n"&gt;is&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F046ih0s7g5t0ah33003a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F046ih0s7g5t0ah33003a.png" alt="RHI thread when not using frame pacing"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In this mode, the only throttle is the game thread via &lt;code&gt;t.MaxFPS&lt;/code&gt;. The RHI thread fires and forgets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;With SwappyGL&lt;/strong&gt; — frames are vsync-phase-locked. Capture a utrace and you'll see frame present times at nearly exact 11.1 ms intervals (on 90 Hz), with sub-millisecond jitter. The RHI thread shows periodic blocks inside &lt;code&gt;SwappyGL_swap&lt;/code&gt; as it holds frames until the right vsync. This is optimal for perceived smoothness.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Without SwappyGL&lt;/strong&gt; — frames arrive at SurfaceFlinger whenever the RHI finishes them. The compositor displays each at the next available vsync, which might be 8.3 ms or 16.6 ms after the previous present, depending on exactly when the frame landed. On a 90 Hz panel, this is usually fine (the frame almost always catches the next vsync). But on a 120 Hz panel, you'd see an alternating 8.3 ms / 16.6 ms pattern — technically correct frame rate, but visually inconsistent.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Disable Frame Pacing
&lt;/h2&gt;

&lt;p&gt;Here's where it gets interesting. Suppose you want 45 FPS gameplay, but the device only supports a 60 Hz refresh rate. The vsync interval is fixed at 16.6 ms, and your game thread needs 22.2 ms per frame to hit that 45 FPS target.&lt;/p&gt;

&lt;p&gt;The problem: 22.2 ms doesn't fit neatly into 16.6 ms vsync slots. The RHI thread starts missing vsync deadlines, and frames get presented in an ugly pattern — 33.3 ms, 16.6 ms, 16.6 ms, 33.3 ms, and so on. The user perceives this as &lt;strong&gt;micro-stutter&lt;/strong&gt;, and no amount of raw FPS fixes that feeling.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpv87s11rig9b029b70yg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpv87s11rig9b029b70yg.png" alt="Micro-stutter pattern"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And that's the &lt;em&gt;ideal&lt;/em&gt; case — assuming the render thread and game thread stay within budget. If gameplay is already lagging, the RHI thread misses vsync events even further. For example, Frame 2 might never be ready at V4 because the frame took just 1–2 ms extra to finish.&lt;/p&gt;

&lt;p&gt;In such cases, it's better to &lt;strong&gt;disable frame pacing entirely&lt;/strong&gt;, so that &lt;em&gt;every&lt;/em&gt; vsync interval becomes a valid presentation opportunity. The frames won't be phase-locked, but they also won't be forced into a pattern that guarantees stutter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;If you're building anything on UE5 for Android — whether it's a casual puzzler or a AAA battle royale — these two CVars are quietly some of the most important knobs in your performance toolkit.&lt;/p&gt;

&lt;p&gt;Think of it this way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;t.MaxFPS&lt;/code&gt;&lt;/strong&gt; controls &lt;em&gt;how fast your simulation ticks&lt;/em&gt; — the game thread budget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;r.SetFramePace&lt;/code&gt;&lt;/strong&gt; controls &lt;em&gt;how your frames reach the display&lt;/em&gt; — the presentation contract.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Getting them right means you're not leaving performance on the table. Getting them wrong means your game could be running at a perfectly good frame rate internally, while the user sees jank and stutter on screen — all because the display side wasn't in sync.&lt;/p&gt;

&lt;p&gt;And the decision of &lt;em&gt;when to disable frame pacing&lt;/em&gt; — that's the nuance that separates "it works on my test device" from "it's smooth across 500 different Android SKUs." Not every device supports the refresh rate you want. When the math doesn't fit the vsync grid, sometimes the smartest move is to let go of phase-locking and let frames land where they can.&lt;/p&gt;

&lt;p&gt;The performance budget in mobile games is razor-thin. Every millisecond you save on frame pacing overhead or vsync misalignment is a millisecond you can spend on gameplay, physics, or that one particle effect your art director insists is "absolutely essential." Master these CVars, profile with Unreal Insights and Perfetto, and you'll squeeze every last drop from the hardware.&lt;/p&gt;

&lt;p&gt;Now go set those frame rates — and may your vsyncs never stutter. 🎮&lt;/p&gt;

</description>
      <category>unrealengine</category>
      <category>gamedev</category>
      <category>performance</category>
      <category>android</category>
    </item>
    <item>
      <title>Unreal Shadows: The Hidden Cascade Trap</title>
      <dc:creator>Ankit</dc:creator>
      <pubDate>Fri, 20 Mar 2026 18:10:53 +0000</pubDate>
      <link>https://forem.com/adbhut/light-and-shadow-settings-unreal-engine-298p</link>
      <guid>https://forem.com/adbhut/light-and-shadow-settings-unreal-engine-298p</guid>
      <description>&lt;p&gt;For someone who has been working with Unreal Engine for just about a year, the learning curve can feel pretty steep. The engine exposes a huge number of properties and console variables, and when you first encounter them, it can feel like opening a control panel with way too many buttons and absolutely no labels.&lt;/p&gt;

&lt;p&gt;In this blog, I'll talk about a fairly simple concept (although it felt quite complex to me when I first ran into it) and try to explain it in simpler terms. Think of this as me documenting the things I painfully figured out so that future-me doesn't have to go down the same rabbit hole again. And if this ends up helping someone else who is also staring at the same mysterious settings panel… well, that's a nice bonus.&lt;/p&gt;

&lt;h1&gt;
  
  
  The Problem
&lt;/h1&gt;

&lt;p&gt;We have a Directional Light set up in a large level that is responsible for casting shadows on a few selected actors. Interestingly, when these shadows were cast on a vehicle, they appeared sharp at close range but started getting noticeably blurrier just around 2–3 meters away from the camera.&lt;/p&gt;

&lt;p&gt;Naturally, the expectation was for the shadow to remain crisp and seamless, without this abrupt drop in quality.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzlv76mo7lgpouh8a19lk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzlv76mo7lgpouh8a19lk.png" alt="problem description" width="682" height="380"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Is the Quick Fix the Best Fix?
&lt;/h2&gt;

&lt;p&gt;After a quick look into the famous &lt;code&gt;BaseScalability.ini&lt;/code&gt; (which sits in the &lt;code&gt;Config&lt;/code&gt; folder of the Engine), I found a scalability group setting for shadows. Scalability Groups are divided into Low, Medium, High, Epic, and Cinematic — corresponding to identifiers &lt;code&gt;0&lt;/code&gt;, &lt;code&gt;1&lt;/code&gt;, &lt;code&gt;2&lt;/code&gt;, &lt;code&gt;3&lt;/code&gt;, and &lt;code&gt;Cine&lt;/code&gt; in the ini file.&lt;/p&gt;

&lt;p&gt;Since I was debugging this on a high-end device, I looked at the CVars under &lt;code&gt;[ShadowQuality@2]&lt;/code&gt; and tweaked &lt;code&gt;r.Shadow.DistanceScale&lt;/code&gt; to get a seamless, crisp shadow across the vehicle.&lt;/p&gt;

&lt;p&gt;But I wasn't in the mood to just make a quick change and walk away from the problem — especially since I'm usually the one breaking my head trying to squeeze out a few extra milliseconds of frame time so our devices don't start doubling as hand warmers while running the game.&lt;/p&gt;

&lt;p&gt;So instead, I decided to dig a little deeper and try to understand what these shadow-related settings actually mean. I'm definitely not going to cover all of them here (mostly because I don't know all of them), but I'll walk through the ones I managed to study and experiment with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shadow Settings
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;r.Shadow.CSM.MaxCascades&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Cascades are essentially distance ranges — the camera view is split into these ranges. Each cascade gets its own shadow map.&lt;/p&gt;

&lt;p&gt;More cascades generally improve shadow quality at farther distances, but they also increase GPU cost.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;r.Shadow.MaxResolution&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Sets the maximum resolution allowed for dynamic shadow maps generated by lights. This directly affects how sharp the shadows appear.&lt;/p&gt;

&lt;p&gt;Higher resolution = sharper shadows, but also higher GPU memory usage and rendering cost.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;r.Shadow.MaxCSMResolution&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Sets the maximum resolution specifically for Cascaded Shadow Maps generated by Directional Lights (for example, the Sun).&lt;/p&gt;

&lt;p&gt;This is separate from &lt;code&gt;r.Shadow.MaxResolution&lt;/code&gt; and applies only to cascaded shadows.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;r.Shadow.RadiusThreshold&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Controls when objects stop casting dynamic shadows based on their screen size. If an object appears smaller than this threshold on screen, it will not cast a shadow.&lt;/p&gt;

&lt;p&gt;This helps reduce shadow rendering cost for tiny distant objects. Increasing this value can yield some performance gains, at the cost of small objects no longer casting shadows.&lt;/p&gt;

&lt;p&gt;Example: If the value is &lt;strong&gt;0.04&lt;/strong&gt;, any object occupying less than &lt;strong&gt;4% of the screen height&lt;/strong&gt; will not cast a shadow.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;r.Shadow.DistanceScale&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Scales the maximum distance from the camera where dynamic shadows are rendered.&lt;/p&gt;

&lt;p&gt;A lower value reduces the shadow rendering distance, while a higher value extends shadows further into the scene.&lt;/p&gt;

&lt;p&gt;This directly impacts how far the shadowed region extends and proportionally affects GPU cost as well.&lt;/p&gt;

&lt;p&gt;So yes — the quick "just increase the quality" fix wasn't really the best solution after all, was it?&lt;/p&gt;

&lt;h2&gt;
  
  
  Stop Looking at the Shadow — Maybe Check the Light?
&lt;/h2&gt;

&lt;p&gt;Now, let's look at a few properties of the Directional Light in our level.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fk6hmu77hqtuve5jqi9bj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fk6hmu77hqtuve5jqi9bj.png" alt="DirectionalLight Properties" width="457" height="202"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Cascaded Shadow Maps section of the Directional Light has the following:&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;DynamicShadowDistanceStationaryLight&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;How far from the camera dynamic shadows from the directional light are rendered. Within this distance, shadows are calculated in real time using CSMs. Beyond this distance, shadows switch to baked/static shadows (or disappear if none exist).&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;DynamicShadowCascades&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;How many cascades are used for the directional light's CSM shadows. Instead of one large shadow map, the camera view is divided into multiple distance slices, and each cascade gets its own shadow map.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;CascadeDistributionExponent&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Controls how the cascades are distributed across the shadow distance. The range is from &lt;strong&gt;1.0&lt;/strong&gt; to &lt;strong&gt;4.0&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This is the interesting one — it took me a while to wrap my head around the concept:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Low Exponent (1.0):&lt;/strong&gt; Cascades are spread more evenly across the distance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;High Exponent (4.0):&lt;/strong&gt; More cascades are concentrated close to the camera, with lower detail farther away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;Consider the following Directional Light setup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic Shadow Distance&lt;/strong&gt; = 20,000 units&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Num Cascades&lt;/strong&gt; = 4&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Distribution Exponent&lt;/strong&gt; = 3.0&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cascade boundaries are computed using this formula:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Boundary(i) = DynamicShadowDistance × (i / NumCascades) ^ DistributionExponent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;strong&gt;Exponent = 3.0&lt;/strong&gt;, the cascade boundaries work out to:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Feqepgp6lrrcmii3n9q1h.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Feqepgp6lrrcmii3n9q1h.png" alt=" " width="748" height="143"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cascade&lt;/th&gt;
&lt;th&gt;Formula&lt;/th&gt;
&lt;th&gt;Boundary (Unreal Units)&lt;/th&gt;
&lt;th&gt;Boundary (Meters)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;20000 × (1/4)³ = 20000 × 0.0156&lt;/td&gt;
&lt;td&gt;312.5&lt;/td&gt;
&lt;td&gt;3.12 m&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;20000 × (2/4)³ = 20000 × 0.125&lt;/td&gt;
&lt;td&gt;2,500.0&lt;/td&gt;
&lt;td&gt;25.00 m&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;20000 × (3/4)³ = 20000 × 0.4219&lt;/td&gt;
&lt;td&gt;8,437.5&lt;/td&gt;
&lt;td&gt;84.38 m&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;20000 × (4/4)³ = 20000 × 1.0&lt;/td&gt;
&lt;td&gt;20,000.0&lt;/td&gt;
&lt;td&gt;200.00 m&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And this would explain why at a distance of roughly 3 meters (312.5 Unreal units) from the camera, we saw a drop in shadow quality — that's exactly where the first cascade ends and the next one begins.&lt;/p&gt;

&lt;p&gt;If we change the Distribution Exponent to &lt;strong&gt;2.0&lt;/strong&gt;, the cascades shift like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl20jbafjhdoqeg00ws8d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl20jbafjhdoqeg00ws8d.png" alt=" " width="757" height="141"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cascade&lt;/th&gt;
&lt;th&gt;Formula&lt;/th&gt;
&lt;th&gt;Boundary (Unreal Units)&lt;/th&gt;
&lt;th&gt;Boundary (Meters)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;20000 × (1/4)² = 20000 × 0.0625&lt;/td&gt;
&lt;td&gt;1,250.0&lt;/td&gt;
&lt;td&gt;12.50 m&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;20000 × (2/4)² = 20000 × 0.25&lt;/td&gt;
&lt;td&gt;5,000.0&lt;/td&gt;
&lt;td&gt;50.00 m&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;20000 × (3/4)² = 20000 × 0.5625&lt;/td&gt;
&lt;td&gt;11,250.0&lt;/td&gt;
&lt;td&gt;112.50 m&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;20000 × (4/4)² = 20000 × 1.0&lt;/td&gt;
&lt;td&gt;20,000.0&lt;/td&gt;
&lt;td&gt;200.00 m&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This means the quality drop is pushed out to &lt;strong&gt;12.5 meters&lt;/strong&gt; instead of 3.12 meters — roughly &lt;strong&gt;4× farther&lt;/strong&gt; from the camera.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trade-off?
&lt;/h3&gt;

&lt;p&gt;Of course there is a trade-off!&lt;/p&gt;

&lt;p&gt;The max CSM resolution of &lt;strong&gt;2048&lt;/strong&gt; was originally being used for a relatively small coverage distance of &lt;strong&gt;3.12 meters&lt;/strong&gt;. After changing the cascade distribution, the same resolution now covers a much larger range of &lt;strong&gt;12.5 meters&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;What this means in practice is that the &lt;strong&gt;shadow texel density&lt;/strong&gt; (texels per world unit) decreases. In simpler terms, the same number of texels now has to cover a larger area of the world, which naturally makes the shadow appear softer.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgz3p6gc6rdn8zr85u687.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgz3p6gc6rdn8zr85u687.png" alt="Texel density calculation" width="469" height="375"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7a9zhvzp81anf9nkfspu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7a9zhvzp81anf9nkfspu.png" alt="trade-off" width="749" height="185"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Important Insight&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This does &lt;strong&gt;not&lt;/strong&gt; mean the entire scene suddenly becomes &lt;strong&gt;4× blurrier&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In reality, &lt;strong&gt;only Cascade 1 changes dramatically&lt;/strong&gt; because its coverage area increases significantly. Meanwhile, &lt;strong&gt;Cascades 2–4 actually get sharper&lt;/strong&gt; when using a distribution exponent of &lt;strong&gt;2&lt;/strong&gt;, since more shadow resolution is now distributed across a wider near-field range, and the far cascades cover proportionally less area than before.&lt;/p&gt;




&lt;p&gt;That's pretty much the rabbit hole I went down while trying to understand why a shadow decided to become blurry a few meters away from the camera.&lt;/p&gt;

&lt;p&gt;The takeaway for me was simple: many of these Unreal settings don't exist in isolation. Changing one value often shifts the balance somewhere else — sometimes improving one cascade while quietly hurting another. Understanding that trade-off is far more useful than blindly pushing everything to higher values.&lt;/p&gt;

&lt;p&gt;More importantly, the next time I see a shadow doing something strange, at least I'll know where to start looking.&lt;/p&gt;

&lt;p&gt;And hopefully, future-me will thank present-me for writing this down.&lt;/p&gt;

</description>
      <category>unrealengine</category>
      <category>gamedev</category>
      <category>shadowsettings</category>
      <category>performance</category>
    </item>
  </channel>
</rss>
