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.
Top comments (0)