Tech-Art

Case study

Intermediate

Tutorial

Volumetric fog without the raymarching cost

10

Volumetric fog usually requires complex algorithms that raymarch through 3D space, evaluate shadow maps, and cache 3D data. However, this is true for systems that need to be universal and "automatic"

There is another option that has existed since the beginning of realtime computer graphics: fake it.

I will show you how I improved Unity's built-in fog by adding volumetric blobs around lights.


In this article:

  • I prototype the effect with additively blended spheres

  • I showcase the hard scene and near-plane intersection issues

  • I calculate analytical sphere density in a fragment shader

  • I clip the effect against the camera and scene depth

  • I assemble the final scene



___

Effect idea

Look at these photos. They were taken in fog.


Notice how each light illuminates the surrounding air. I imagine each light as a volumetric blob that lights the fog.


___

Original scene

I will recreate that effect in the GardenScene from Unity's URP Template.


I enabled exponential fog under Window > Rendering > Environment:


This is how the scene looks with fog. It creates a convincing foggy-night atmosphere.


But something is missing here. The lights don't illuminate the fog around them:


___

Implementing the volumetric fog

Let's fix it. My idea is to place a sphere at each light and use it to illuminate the fog. I will use additively rendered spheres as a cheap substitute for volumetric fog.

Let's start from a prototype.

Prototype

Let's test whether an additively rendered sphere works here.

I added a sphere with an unlit shader and placed it at the light's position.


Then I used a Fresnel effect to darken the sphere's edges based on the angle between the view direction and the sphere normal:

float sphereDensity = pow(dot(rayDirectionWS, -normalize(IN.normalWS)), 3.0);
return _BaseColor * sphereDensity * _BaseColor.a

float sphereDensity = pow(dot(rayDirectionWS, -normalize(IN.normalWS)), 3.0);
return _BaseColor * sphereDensity * _BaseColor.a

float sphereDensity = pow(dot(rayDirectionWS, -normalize(IN.normalWS)), 3.0);
return _BaseColor * sphereDensity * _BaseColor.a


Then I switched to additive blending with Blend One One:


It works and looks acceptable, though it has a few artifacts. I reduced the light's intensity:


I placed a sphere like this at each light in the scene:


I adjusted the effect's intensity. This is the result with the effect off and on:


It is simple and works. There are still issues to solve, but this prototype proves that the method can improve the mood and add a volumetric touch.


___

Current issues

However, I cannot use the current implementation because it has two important issues.

First issue - hard intersections with the scene. It is just a spherical mesh.


I could disable depth testing, but then the blobs would be visible through opaque obstacles.


Second issue - camera near plane intersections

When the camera passes through a sphere, it disappears as soon as its triangles move behind the camera. This would not be an issue in a game where the camera never enters the spheres. Here, it does.


Analytical approach

I need a solution that accounts for:

  • The scene depth of opaque objects

  • The current camera position, so the effect fades when the camera is inside the sphere and looks outward

My second idea is to compute the fog analytically in the fragment shader.

I will render the back faces of the sphere.


Then I will create a ray from the camera position and view direction. Its intersections with the sphere provide the entry and exit points.


I will assume that the distance between those points is proportional to the "fog" I want to render.


This makes the fog more intense when looking through the center of the sphere:


This analytical method detects when the camera is inside the sphere and calculates the distance from the camera to its surface. It removes the camera-triangle intersection issue, so the effect fades as the camera moves through the sphere.


To hide scene intersections, I can use the depth buffer to adjust the other end of the segment.


___

Analytical implementation

The next steps explain how I implemented this effect.


1. Unity setup

First, I need access to the depth buffer. I will ensure that Unity renders it so I can sample it later during transparent rendering.

I enabled it in the URP Renderer Asset:


I also used the Frame Debugger to confirm that Unity renders depth to the texture:



2. Shader setup

I created a transparent unlit URP shader that renders back faces without depth testing or depth writes. This excerpt shows the relevant ShaderLab configuration:

SubShader
{
	Tags
	{
		"RenderType" = "Transparent"
		"RenderPipeline" = "UniversalPipeline"
		"Queue" = "Transparent+5"
	}

	Pass
	{
		Blend One One
		Cull Front
		ZTest Always
		ZWrite Off

		// HLSL program omitted

SubShader
{
	Tags
	{
		"RenderType" = "Transparent"
		"RenderPipeline" = "UniversalPipeline"
		"Queue" = "Transparent+5"
	}

	Pass
	{
		Blend One One
		Cull Front
		ZTest Always
		ZWrite Off

		// HLSL program omitted

SubShader
{
	Tags
	{
		"RenderType" = "Transparent"
		"RenderPipeline" = "UniversalPipeline"
		"Queue" = "Transparent+5"
	}

	Pass
	{
		Blend One One
		Cull Front
		ZTest Always
		ZWrite Off

		// HLSL program omitted


3. Vertex shader and interpolators

The vertex shader is simple. It outputs the world-space and clip-space positions, which are all I need to calculate the sphere intersection and sample the scene depth.

Varyings vert(Attributes IN)
{
	Varyings OUT;
	OUT.positionWS = TransformObjectToWorld(IN.positionOS.xyz); // World space
	OUT.positionCS_SV = TransformWorldToHClip(OUT.positionWS.xyz); // Clip space (for GPU)
	OUT.positionCS = OUT.positionCS_SV; // Clip space (interpolator)
	return OUT;
}

half4 frag(Varyings IN) : SV_Target
{
	return frac(IN.positionWS.xyzz

Varyings vert(Attributes IN)
{
	Varyings OUT;
	OUT.positionWS = TransformObjectToWorld(IN.positionOS.xyz); // World space
	OUT.positionCS_SV = TransformWorldToHClip(OUT.positionWS.xyz); // Clip space (for GPU)
	OUT.positionCS = OUT.positionCS_SV; // Clip space (interpolator)
	return OUT;
}

half4 frag(Varyings IN) : SV_Target
{
	return frac(IN.positionWS.xyzz

Varyings vert(Attributes IN)
{
	Varyings OUT;
	OUT.positionWS = TransformObjectToWorld(IN.positionOS.xyz); // World space
	OUT.positionCS_SV = TransformWorldToHClip(OUT.positionWS.xyz); // Clip space (for GPU)
	OUT.positionCS = OUT.positionCS_SV; // Clip space (interpolator)
	return OUT;
}

half4 frag(Varyings IN) : SV_Target
{
	return frac(IN.positionWS.xyzz

I also tested whether the interpolators work correctly.


In the next steps, I will prepare all the data needed to calculate the effect:

  • Ray origin and direction

  • Sphere position and radius

  • Distance to the scene


4. Ray reconstruction

I use the current camera position and the world-space position of the sphere's surface to calculate the ray.

Note: This ray reconstruction assumes a perspective camera. An orthographic camera needs a per-pixel ray origin on the near plane and a direction based on the camera's forward vector.

float3 rayOriginWS = _WorldSpaceCameraPos.xyz;
float3 rayDirectionWS = normalize(IN.positionWS.xyz - rayOriginWS);

return rayDirectionWS.xyzz

float3 rayOriginWS = _WorldSpaceCameraPos.xyz;
float3 rayDirectionWS = normalize(IN.positionWS.xyz - rayOriginWS);

return rayDirectionWS.xyzz

float3 rayOriginWS = _WorldSpaceCameraPos.xyz;
float3 rayDirectionWS = normalize(IN.positionWS.xyz - rayOriginWS);

return rayDirectionWS.xyzz

I usually check calculated values by displaying them as colors on the object. In this case, rayDirectionWS should match the colors of the world-space axes.


5. Getting the sphere position and radius

I will use the model matrix to get the sphere's radius and position. This matrix transforms the mesh from object space to world space, so it contains its offset and scale.

The object's position is stored in the matrix's fourth column. The X-axis scale equals the length of the vector in the first column.

float3 sphereCenterWS = UNITY_MATRIX_M._m03_m13_m23;
float sphereRadiusWS = length(UNITY_MATRIX_M._m00_m10_m20) * 0.5;

return sphereRadiusWS

float3 sphereCenterWS = UNITY_MATRIX_M._m03_m13_m23;
float sphereRadiusWS = length(UNITY_MATRIX_M._m00_m10_m20) * 0.5;

return sphereRadiusWS

float3 sphereCenterWS = UNITY_MATRIX_M._m03_m13_m23;
float sphereRadiusWS = length(UNITY_MATRIX_M._m00_m10_m20) * 0.5;

return sphereRadiusWS

Note: I assume that the source sphere has a diameter of one unit and uses a uniform scale, with the same value on each axis.

I tested this visually. The sphere gets brighter as I make it larger.


6. Scene depth

The final ingredient is scene depth. In Unity, I can access the depth texture by including this HLSL file:

#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl"


It provides the SampleSceneDepth(float2 screenUV) method for sampling the raw depth value. The URP shader template also includes Core.hlsl, which provides the reconstruction helpers used below.

I will calculate the world-space distance to the scene by reconstructing the opaque surface's world-space position from the raw depth value.

A depth value is created by transforming a world-space position into clip space with the view-projection matrix. URP's ComputeWorldSpacePosition helper uses the inverse view-projection matrix to convert the sampled depth back to world space:

// Calculate screen UV from the fragment's screen-space position
float2 screenUV = GetNormalizedScreenSpaceUV(IN.positionCS_SV);

// Get the raw depth value
float rawDepth = SampleSceneDepth(screenUV);

// Convert OpenGL depth to the NDC range expected by the inverse VP matrix
#if !UNITY_REVERSED_Z
	rawDepth = lerp(UNITY_NEAR_CLIP_VALUE, 1.0, rawDepth);
#endif

// Reconstruct the scene's world-space position
float3 reconstructedSceneWS = ComputeWorldSpacePosition(
	screenUV,
	rawDepth,
	UNITY_MATRIX_I_VP
);

// Calculate the distance from the camera to the scene
float distanceToScene = distance(reconstructedSceneWS, rayOriginWS);

return pow(distanceToScene * 0.1, 2.0

// Calculate screen UV from the fragment's screen-space position
float2 screenUV = GetNormalizedScreenSpaceUV(IN.positionCS_SV);

// Get the raw depth value
float rawDepth = SampleSceneDepth(screenUV);

// Convert OpenGL depth to the NDC range expected by the inverse VP matrix
#if !UNITY_REVERSED_Z
	rawDepth = lerp(UNITY_NEAR_CLIP_VALUE, 1.0, rawDepth);
#endif

// Reconstruct the scene's world-space position
float3 reconstructedSceneWS = ComputeWorldSpacePosition(
	screenUV,
	rawDepth,
	UNITY_MATRIX_I_VP
);

// Calculate the distance from the camera to the scene
float distanceToScene = distance(reconstructedSceneWS, rayOriginWS);

return pow(distanceToScene * 0.1, 2.0

// Calculate screen UV from the fragment's screen-space position
float2 screenUV = GetNormalizedScreenSpaceUV(IN.positionCS_SV);

// Get the raw depth value
float rawDepth = SampleSceneDepth(screenUV);

// Convert OpenGL depth to the NDC range expected by the inverse VP matrix
#if !UNITY_REVERSED_Z
	rawDepth = lerp(UNITY_NEAR_CLIP_VALUE, 1.0, rawDepth);
#endif

// Reconstruct the scene's world-space position
float3 reconstructedSceneWS = ComputeWorldSpacePosition(
	screenUV,
	rawDepth,
	UNITY_MATRIX_I_VP
);

// Calculate the distance from the camera to the scene
float distanceToScene = distance(reconstructedSceneWS, rayOriginWS);

return pow(distanceToScene * 0.1, 2.0


Note: This example targets a standard non-XR camera. For XR, use URP's stereo-aware screen-space and texture-sampling macros. Pixels without opaque geometry use the far-plane depth, so they do not shorten the sphere segment.


7. Sphere intersections

Now I have everything needed to calculate the effect:

  • Ray origin and direction

  • Sphere position and radius

  • Distance to the scene

I will start by creating a function called SphereDensity. It takes these parameters and returns the sphere's fog density.

float sphereDensity = SphereDensity(
	rayOriginWS, rayDirectionWS,
	sphereCenterWS, sphereRadiusWS,
	distanceToScene);

// Mix the sphere density with the material color
return sphereDensity * _BaseColor.rgba * _BaseColor.a

float sphereDensity = SphereDensity(
	rayOriginWS, rayDirectionWS,
	sphereCenterWS, sphereRadiusWS,
	distanceToScene);

// Mix the sphere density with the material color
return sphereDensity * _BaseColor.rgba * _BaseColor.a

float sphereDensity = SphereDensity(
	rayOriginWS, rayDirectionWS,
	sphereCenterWS, sphereRadiusWS,
	distanceToScene);

// Mix the sphere density with the material color
return sphereDensity * _BaseColor.rgba * _BaseColor.a


I will start by calculating the intersections. I used a formula from Inigo Quilez's collection of intersection functions.

float SphereDensity(float3 rayOrigin, float3 rayDirection, float3 sphereCenter, float sphereRadius, float sceneDistance)
{
	// Normalize the space to the unit-sphere
	float normalizedSceneDistance = sceneDistance / sphereRadius;
	float3 toSphereCenter = (rayOrigin - sphereCenter) / sphereRadius;

	// Find intersection with the sphere
	float b = dot(rayDirection, toSphereCenter);
	float c = dot(toSphereCenter, toSphereCenter) - 1.0;
	float h = b * b - c;

	// Return 0 when there is no intersection
	if (h < 0.0)
		return 0.0;

	h = sqrt(h);
	float t1 = -b - h; // Distance to the closer intersection
	float t2 = -b + h; // Distance to the further intersection

	// Segment distance (half, because it is a unit sphere)
	return (t2 - t1) * 0.5

float SphereDensity(float3 rayOrigin, float3 rayDirection, float3 sphereCenter, float sphereRadius, float sceneDistance)
{
	// Normalize the space to the unit-sphere
	float normalizedSceneDistance = sceneDistance / sphereRadius;
	float3 toSphereCenter = (rayOrigin - sphereCenter) / sphereRadius;

	// Find intersection with the sphere
	float b = dot(rayDirection, toSphereCenter);
	float c = dot(toSphereCenter, toSphereCenter) - 1.0;
	float h = b * b - c;

	// Return 0 when there is no intersection
	if (h < 0.0)
		return 0.0;

	h = sqrt(h);
	float t1 = -b - h; // Distance to the closer intersection
	float t2 = -b + h; // Distance to the further intersection

	// Segment distance (half, because it is a unit sphere)
	return (t2 - t1) * 0.5

float SphereDensity(float3 rayOrigin, float3 rayDirection, float3 sphereCenter, float sphereRadius, float sceneDistance)
{
	// Normalize the space to the unit-sphere
	float normalizedSceneDistance = sceneDistance / sphereRadius;
	float3 toSphereCenter = (rayOrigin - sphereCenter) / sphereRadius;

	// Find intersection with the sphere
	float b = dot(rayDirection, toSphereCenter);
	float c = dot(toSphereCenter, toSphereCenter) - 1.0;
	float h = b * b - c;

	// Return 0 when there is no intersection
	if (h < 0.0)
		return 0.0;

	h = sqrt(h);
	float t1 = -b - h; // Distance to the closer intersection
	float t2 = -b + h; // Distance to the further intersection

	// Segment distance (half, because it is a unit sphere)
	return (t2 - t1) * 0.5


This part is now complete:


This is how it looks:


This is not the smooth result I expected, so I will adjust it with a pow function.

return pow((t2 - t1) * 0.5, 4.0); // Power of 4 already looks nice
return pow((t2 - t1) * 0.5, 4.0); // Power of 4 already looks nice
return pow((t2 - t1) * 0.5, 4.0); // Power of 4 already looks nice


8. Clamping to camera position and scene depth

Now I need to handle cases where the camera or an opaque surface is inside the sphere. I will clamp the segment's start or end.


Because sphere intersections are measured as distances along the ray, I need to ensure that the start is never less than 0.

For the scene, I can clamp the farther intersection to the scene distance.

// If the sphere is behind the camera, or fully behind the scene - skip
if (t2 < 0.0 || t1 > normalizedSceneDistance)
	return 0.0;

// Clamp the segment to the camera and scene depth
t1 = max(t1, 0.0);
t2 = min(t2, normalizedSceneDistance);

// Segment distance (half, because it is a unit sphere)
return pow((t2 - t1) * 0.5, 4.0

// If the sphere is behind the camera, or fully behind the scene - skip
if (t2 < 0.0 || t1 > normalizedSceneDistance)
	return 0.0;

// Clamp the segment to the camera and scene depth
t1 = max(t1, 0.0);
t2 = min(t2, normalizedSceneDistance);

// Segment distance (half, because it is a unit sphere)
return pow((t2 - t1) * 0.5, 4.0

// If the sphere is behind the camera, or fully behind the scene - skip
if (t2 < 0.0 || t1 > normalizedSceneDistance)
	return 0.0;

// Clamp the segment to the camera and scene depth
t1 = max(t1, 0.0);
t2 = min(t2, normalizedSceneDistance);

// Segment distance (half, because it is a unit sphere)
return pow((t2 - t1) * 0.5, 4.0

It works, but something looks wrong. The effect appears close to the sphere's outer shell.


The problem is the pow(..., 4.0) expression that smooths the sphere. It makes the distance scale nonlinearly. When the scene depth or camera shortens the calculated distance, the change is nonlinear, but it should be linear.


9. Fixing the math

I want to keep the nonlinear calculation for the sphere while making the camera and scene affect it linearly.

My idea is to calculate the ratio between the original and clamped segments, then use it to scale the fog intensity.


Here is the implementation:

// Clamp the segment, but save into new variables
float t1Clipped = max(t1, 0.0);
float t2Clipped = min(t2, normalizedSceneDistance);

// Calculate the ratio between the clipped and unclipped segments
float linearFactor = (t2Clipped - t1Clipped) / max(t2 - t1, 0.001);

// Fog density - multiplied by the factor
return pow((t2 - t1) * 0.5, 4.0) * linearFactor

// Clamp the segment, but save into new variables
float t1Clipped = max(t1, 0.0);
float t2Clipped = min(t2, normalizedSceneDistance);

// Calculate the ratio between the clipped and unclipped segments
float linearFactor = (t2Clipped - t1Clipped) / max(t2 - t1, 0.001);

// Fog density - multiplied by the factor
return pow((t2 - t1) * 0.5, 4.0) * linearFactor

// Clamp the segment, but save into new variables
float t1Clipped = max(t1, 0.0);
float t2Clipped = min(t2, normalizedSceneDistance);

// Calculate the ratio between the clipped and unclipped segments
float linearFactor = (t2Clipped - t1Clipped) / max(t2 - t1, 0.001);

// Fog density - multiplied by the factor
return pow((t2 - t1) * 0.5, 4.0) * linearFactor

This is how it looks. All the issues are now fixed.


10. Assembling the scene

The final step is assembling the scene. I placed about 42 spheres with this shader and adjusted their intensity.


___

Result

Here is the final result:


Before:


After:


___

Summary

This effect fakes local volumetric fog with additively blended spheres. The fragment shader intersects the camera ray with each sphere, clips the segment against the camera and scene depth, then shapes the result into a smooth density. This avoids hard scene and near-plane intersections without raymarching through a volume.

The sphere intersection is based on Inigo Quilez's analytical intersection functions.

In the next article, I will show you how I optimize this effect.

Join the community

If you are interested in learning more,
join the community discussions on Saturday at 15:00 CET

We meet weekly, discussing the rendering and optimization problems.
Join our mastermind sessions, chill discussions and small online conference events.

Join the community

If you are interested in learning more,
join the community discussions on Saturday at 15:00 CET

We meet weekly, discussing the rendering and optimization problems.
Join our mastermind sessions, chill discussions and small online conference events.

I write expert content on optimizing Unity games, customizing rendering pipelines, and enhancing the Unity Editor.

Copyright © 2026 Jan Mróz | Procedural Pixels

I write expert content on optimizing Unity games, customizing rendering pipelines, and enhancing the Unity Editor.

Copyright © 2026 Jan Mróz | Procedural Pixels

I write expert content on optimizing Unity games, customizing rendering pipelines, and enhancing the Unity Editor.

Copyright © 2026 Jan Mróz | Procedural Pixels