Lately, I've been deep into developing Neovim plugins for Unreal Engine, which ironically involves more configuration than actual coding. To switch things up, I decided to dive back into the engine itself—not by writing C++, but by playing with the Material Editor. My goal: to create a custom Depth of Field (DoF) post-process effect from scratch.
At first, I tried using a Cine Camera with a telephoto lens and a low F-stop. While it produced a beautiful, cinematic bokeh, the camera had to be too far from the player character, making it impractical for gameplay. That's when I decided to implement it as a post-process effect using HLSL.
In this article, I'll walk you through the process, from modifying the core formula to implementing it in HLSL and even experimenting with custom bokeh shapes.
Making the Circle of Confusion (CoC) Formula More Intuitive
The core of any DoF effect is the Circle of Confusion (CoC). It's a value that determines how large and blurry an out-of-focus point on the screen should be.
The standard, physically-based formula for CoC is:
- : focal length of the lens
- : F-number
- v: distance to the focus plane
- : distance to the subject
While this formula is accurate, I find it a bit unintuitive for real-time tweaking, especially with the F-number (
) in the denominator. So, I opted for a modified formula that uses the aperture size directly:
- : Aperture size
- Other variables are the same.
This version makes more sense to me from an artist's perspective: a larger aperture ( ) directly results in a larger CoC. Simple and effective.
Controlling the Blur Range with a Focus Region
Next, I needed to define a "focus region" to control the extent of the blur. If you only blur based on the exact focus distance, the in-focus area can feel too narrow and sharp.
To solve this, I implemented a simple range check in the shader:
- : Scene Depth
- : Focus Distance
- : The width of the area to be considered in focus
If this check is true, the pixel is within the focus region, and no blur is applied. For pixels outside this range, we apply the blur based on our CoC calculation. This gives us clear control over what's sharp and what's blurry.
Implementing Disk Sampling in HLSL with the Golden Angle
To create the blur effect, we need to sample surrounding pixels in a circular pattern—a technique called "disk blur." To do this efficiently and uniformly, I used a method based on the Golden Angle.
Using the Golden Angle ensures that our sample points are evenly distributed, avoiding clumps or artifacts, even with a low sample count. It's the same principle that nature uses to arrange seeds in a sunflower head.
Here is the HLSL function to generate these sample points:
#define GOLDEN_ANGLE 2.3999632297 // (2 * PI) / (Golden Ratio^2)
/**
* Generates a sample point on a disk using the Golden Angle.
* @param Index The current sample index (starting from 0).
* @param NumSamples The total number of samples.
* @return A 2D coordinate normalized to the [-1, 1] range.
*/
float2 GetRandomDiskSample(float Index, float NumSamples)
{
float Radius = sqrt(Index / NumSamples);
float Theta = Index * GOLDEN_ANGLE;
float2 SamplePoint;
SamplePoint.x = Radius * cos(Theta);
SamplePoint.y = Radius * sin(Theta);
return SamplePoint;
}
The sqrt(Index / NumSamples) part distributes the samples from the center outwards, while Index * GOLDEN_ANGLE rotates each point, ensuring they don't overlap and cover the disk uniformly.
The Final Shader Code and Applying a Bokeh Shape
Putting it all together, here is the core logic of the final shader. I also added an option to use a texture to define the bokeh shape for a more realistic effect.
// Struct to hold our functions
struct Functions
{
float2 GetRandomDiskSample(float Index, float NumSamples)
{
float Radius = sqrt(Index / NumSamples);
float Theta = Index * 2.3999632297; // GOLDEN_ANGLE
float2 SamplePoint;
SamplePoint.x = Radius * cos(Theta);
SamplePoint.y = Radius * sin(Theta);
return SamplePoint;
}
};
Functions f;
// Calculate the blur radius for this pixel from the CoC value (0-1) and max blur size
float CoC_Radius = CoC_Value * MaxBlurSize;
float4 FinalColor = float4(0, 0, 0, 0);
float TotalWeight = 0;
// Sample surrounding pixels
for (int i = 0; i < NumSamples; i++)
{
// 1. Generate an offset coordinate for sampling
float2 Offset = f.GetRandomDiskSample(i, NumSamples) * CoC_Radius;
// 2. Fetch the color from the scene texture
float4 SampledColor = SceneTextureFetch(SceneTex.ID, UV + Offset);
// 3. (Optional) Calculate weight using a bokeh shape texture
// Convert offset from [-1, 1] to UV coordinates [0, 1]
float2 BokehUV = (Offset / CoC_Radius) * 0.5 + 0.5;
// Sample the bokeh shape texture and use its intensity as a weight
float BokehWeight = length(BokehShapeTex.Sample(BokehShapeTexSampler, BokehUV));
// 4. Add the weighted color to the final color
FinalColor += SampledColor * BokehWeight;
TotalWeight += BokehWeight;
}
// Calculate the weighted average to get the final color
if (TotalWeight > 0)
{
FinalColor /= TotalWeight;
}
return FinalColor;
I tried to replicate the hexagonal shape of an aperture blade, similar to Unreal Engine's default DoF, by using the BokehShapeTex to weight the samples.
However, to be honest, using a bokeh shape texture didn't make a huge visual impact. The shape is hard to discern with a low sample count or a small blur radius. For performance-critical applications, you can probably skip this step without losing much.
Conclusion
This was a fun experiment in creating a custom DoF effect using post-process materials and HLSL. Here are the key takeaways:
- Modified the CoC formula to be more artist-friendly than the physically-based version.
- Implemented a focus region to gain precise control over the blurred and sharp areas.
- Used the Golden Angle for disk sampling to achieve a clean and efficient blur.
I hope this breakdown is helpful for anyone else looking to expand their rendering skills!
About the Assets
All assets used in the video were created using free (at the time) assets that I've collected over the years.
About the YouTube Video
In addition to the custom DoF effect, the final video also includes adjustments for Color Grading and Chromatic Aberration to achieve the final look.
Recent screenshot
Even close-ups have a nice miniature feel to them.
2015/10/16
📌 Motivation (Why this matters)
Tilt-shift depth of field often looks flat when foreground and background blur are treated identically.
To enhance miniature realism, I introduced signed CoC values to distinguish between front and back blur directions.
🧠 The Problem with Absolute CoC
Most DOF implementations use abs(CoC) for blur radius, which loses directional context.
This means foreground and background blur behave the same, reducing spatial depth.
✅ The Fix: Signed CoC
By preserving the sign of (FocalDistance - SceneDepth), we can encode whether the pixel is in front or behind the focal plane.
float Sign = sign(FocalDistance - SceneDepth);
float CoC = Sign * (A * f * abs(FocalDistance - SceneDepth)) / SceneDepth;
float NormalizedCoC = saturate(abs(CoC) / MaxCoC) * Sign;
This gives us a normalized CoC in the range [-1, 1], where negative values represent foreground blur.
🎨 Blur Direction and Bokeh Shape
In the blur pass, we use the sign to flip sampling direction and optionally invert the BokehShape texture:
float2 Offset = Sample * CoC_Radius * Sign;
float2 BokehUV = Sample * 0.5 + 0.5;
if (Sign < 0.0) BokehUV = 1.0 - BokehUV;
This allows foreground and background blur to have distinct visual characteristics.

🌌 Artistic Impact
- Foreground blur can feel more expansive, like memory or warmth.
- Background blur can feel tighter, like distance or mystery.
- Combined, they create a poetic sense of depth and miniature realism.
🎥 Demo
I’ve published a short demo video showcasing this effect:
UE Tiltshift for city-sample
🚀 What’s Next
- Dynamic aperture animation
- Bokeh shape blending based on CoC sign
- Color shift for foreground vs background blur

Top comments (0)