Raymarching Heightfields - Basics and Optimizations
- 23 hours ago
- 7 min read

Requirements
Before reading this article, I recommend having basic knowledge of the following topics:
Basics of raymarching – I personally liked these videos
Basic knowledge of Perlin noise terrain generation – I recommend this blog
Basic knowledge in compute shaders – for HLSL I recommend these
In this article we will:
Explain why raymarching heightfields is difficult
Implement a naïve solution
Analyze its problems: artifacts vs. performance
Introduce max-mip BVH traversal
Show how it improves performance
Discuss use cases
Introduction to Heightfield Raymarching
Raymarching a heightfield or heightmap is a simple way to generate or query procedural terrain. However, raymarching a heightfield is different from raymarching an SDF. Unlike SDFs, a heightfield does not provide the true distance to the closest surface. It only tells us how far we are vertically from the terrain.
This breaks the key assumption behind sphere tracing: we can no longer safely advance the ray by the returned value. Instead, the step size has to be estimated, which introduces a fundamental trade-off between performance and accuracy. If the step size is too large, rays can overshoot thin or steep features, causing visible artifacts. Reducing the step size improves accuracy, but it quickly becomes too expensive, especially for terrains with large height variation.
For this reason, rasterizing terrain can still be the faster choice. That said, raymarching heightfields is still useful for additional effects, such as shadows, ambient occlusion, visibility checks, or terrain intersection queries.
Heightfield Raymarching
Before moving to optimizations, let’s begin with a naïve approach, which may be enough for some cases.
To follow along, here is the heightmap I am using for my terrain. You can simply plug it into a Unity terrain and use the parameters below.


As mentioned earlier, raymarching terrain is not very different from raymarching SDFs at a high level. Here is the code I am using:
bool Raymarch(
float3 rOrigin, // Ray origin (camera or shadow start)
float3 rDirection, // Normalized ray direction
out float hitT, // Distance along ray where we hit terrain
out float terrainHeightAtHit, // World-space Y at hit point
float maxSteps, // Max number of iterations
float distanceForHit, // Hit threshold scaling factor
float maxStepPrecision) // Controls max step size relative to ray length
{
hitT = 0.0; // Start marching from t = 0
terrainHeightAtHit = 0.0;
// Compute where the ray enters and exits the terrain domain (XZ bounds)
float tEnter, tExitDomain;
if (!GetBoundsExit(rOrigin, rDirection, float2(0.0, 0.0), float2(_ChunkSize, _ChunkSize), tEnter, tExitDomain))
return false; // Ray never touches terrain bounds
float maxT = tExitDomain; // Clamp ray to terrain bounds
// Maximum allowed step size (prevents skipping large terrain features)
float maxStep = maxT * maxStepPrecision;
// Main raymarch loop
for (int i = 0; i < maxSteps && hitT < maxT; i++)
{
// Current point along the ray
float3 p = rOrigin + rDirection * hitT;
// Sample terrain height at this XZ position
float terrainY = SampleTerrainHeight(p.xz);
// Vertical distance from ray point to terrain
float h = p.y - terrainY;
// Hit condition:
// Instead of checking h <= 0, we allow a tolerance that grows with distance
// This avoids missing intersections when step sizes are large
if (h < distanceForHit * hitT)
{
terrainHeightAtHit = p.y;
return true; // We hit the terrain
}
// Advance along the ray
hitT += max(abs(h) * 0.7, maxStep);
}
// No hit found within bounds
return false;
}Here, GetHeight(x,z) is just a function that samples the heightmap texture.
Now, If the terrain maximum height was not that large, we would get this result:

This looks fine, but you as we increase the terrain height..

As you can see, holes and other artifacts already become visible. To fix this, we need to make the steps smaller as the terrain becomes taller. However, this quickly has a major impact on performance.

Thankfully, we can do better.
Using Max-Mips as a Traversal BVH
A more recent way of optimizing heightfield raymarching is to use maximum mipmaps as a BVH-like traversal structure. For more detailed information, you can read the paper "Maximum mipmaps for fast, accurate, and scalable dynamic height field rendering" by Art Tevs, Ivo Ihrke and Hans-Peter Seidel.
1) Building the hierarchy
The first step is to build the hierarchy. The creation of the mipmap resource depends on the graphics API, so I will leave that part out. However, the mip chain specifically needs to store maximum values. If the API you are using generates standard filtered mipmaps, you will still need to write the max-mip generation yourself, for example with a compute shader.
One advantage of this algorithm is that mip generation is very fast, even for large textures.
This compute shader works by checking the maximum value of four adjacent pixels in mip n and storing it in mip n + 1, generating a mip chain that contains only the highest points.
// Each thread computes ONE pixel in the destination mip
uint2 dst = id.xy;
uint2 dims;
_DstTex.GetDimensions(dims.x, dims.y);
if (id.x >= dims.x || id.y >= dims.y)
return;
float2 uv = (float2(id.xy) + 0.5) / float2(dims.x, dims.y);
// Destination mip is half resolution of source (minimum 1)
uint dstWidth = max(1, _SrcMipSizeX / 2u);
uint dstHeight = max(1, _SrcMipSizeY / 2u);
// Kill threads that are outside valid destination range
if (dst.x >= dstWidth || dst.y >= dstHeight)
return;
// Map this destination pixel to a 2x2 block in the source mip
// (each dst pixel represents a region of 4 src pixels)
uint2 src00 = dst * 2; // (2x, 2y)
uint2 src10 = src00 + uint2(1, 0); // (2x+1, 2y)
uint2 src01 = src00 + uint2(0, 1); // (2x, 2y+1)
uint2 src11 = src00 + uint2(1, 1); // (2x+1, 2y+1)
// Clamp coordinates in case of odd texture sizes (e.g. 129 → 64)
// Prevents reading outside the source texture
src10.x = min(src10.x, (uint) (_SrcMipSizeX - 1));
src01.y = min(src01.y, (uint) (_SrcMipSizeY - 1));
src11.x = min(src11.x, (uint) (_SrcMipSizeX - 1));
src11.y = min(src11.y, (uint) (_SrcMipSizeY - 1));
// Read the 4 height values from the finer mip
float h00 = _SrcTex.Load(int3(src00, _SrcMip));
float h10 = _SrcTex.Load(int3(src10, _SrcMip));
float h01 = _SrcTex.Load(int3(src01, _SrcMip));
float h11 = _SrcTex.Load(int3(src11, _SrcMip));
// Store the MAX height of this region in the coarser mip
_DstTex[dst] = max(max(h00, h10), max(h01, h11));2) The traversal algorithm
The referenced paper explains the general traversal algorithm:
Shoot a ray, starting at the coarsest mip level.
If the ray is above the cell’s maximum height:
Skip the entire region.
Move to a coarser mip level to avoid getting stuck in expensive detail.
Otherwise:
If the mip level is not 0 yet:
Move to a finer mip level.
Continue traversal.
If mip level 0 is reached:
Perform a more detailed search inside the texel, such as binary search.
The paper leaves the exact grid traversal method up to the reader. In my implementation, I found that DDA works best for this case. For more information about DDA, I recommend ScratchAPixel's article and this interactive DDA blog post.
bool TraverseHeightfieldMaxMip_Pseudo(
float3 ro,
float3 rd,
out float hitT)
{
hitT = 0;
// 1. Intersect ray with chunk XZ bounds
float tEnter, tExit;
if (!RayIntersectsChunkXZ(ro, rd, tEnter, tExit))
return false;
float tBase = max(tEnter, 0.0);
float tRemaining = tExit - tBase;
// Move origin to first valid point inside the chunk
float3 origin = ro + rd * tBase;
// 2. Start from coarse mip
int mip = TOP_MIP;
uint2 mipSize = GetMipSize(mip);
float2 cellSize = chunkSize / mipSize;
// 3. Initialize DDA over the mip grid
float2 deltaT;
float nextTX;
float nextTZ;
InitDDA(
origin.xz,
rd.xz,
cellSize,
deltaT,
nextTX,
nextTZ
);
float t = 0.0;
float tEnterCell = 0.0;
float tExitCell = 0.0;
// 4. Walk through the heightfield
for (int step = 0; step < MAX_STEPS && t < tRemaining; step++)
{
float3 p = origin + rd * t;
int2 cell = GetCellAtMip(p.xz, mip);
float cellMaxHeight = LoadHeightMip(cell, mip);
// DDA: find where this ray segment exits the current cell
if (nextTX < nextTZ)
{
tExitCell = nextTX + EPSILON;
nextTX += deltaT.x;
}
else
{
tExitCell = nextTZ + EPSILON;
nextTZ += deltaT.y;
}
tExitCell = min(tExitCell, tRemaining);
// Height range of the ray inside this DDA cell
float y0 = origin.y + rd.y * tEnterCell;
float y1 = origin.y + rd.y * tExitCell;
float rayMinY = min(y0, y1);
// Coarse mip: use max height to skip empty space
if (mip > 0)
{
bool rayAboveWholeCell =
rayMinY > cellMaxHeight + DISTANCE_FOR_HIT;
if (rayAboveWholeCell)
{
// The whole mip cell is below the ray segment.
// No possible hit, so skip to the next DDA cell.
tEnterCell = tExitCell;
t = tEnterCell;
}
else
{
// The ray may intersect terrain inside this coarse cell.
// Descend to a finer mip and restart DDA from current point.
float consumed = t;
origin = p;
tBase += consumed;
tRemaining -= consumed;
mip--;
mipSize = GetMipSize(mip);
cellSize = chunkSize / mipSize;
t = 0.0;
tEnterCell = 0.0;
tExitCell = 0.0;
InitDDA(
origin.xz,
rd.xz,
cellSize,
deltaT,
nextTX,
nextTZ
);
}
}
// Mip 0: precise terrain test
else
{
bool rayNearHeight =
rayMinY <= cellMaxHeight + DISTANCE_FOR_HIT;
if (rayNearHeight)
{
bool found = RefineHeightfieldHit(
origin,
rd,
tBase,
tEnterCell,
tExitCell,
hitT
);
if (found)
return true;
}
// Done with this fine segment.
// Move origin to segment exit.
float consumed = tExitCell;
float3 exitPoint = origin + rd * consumed;
origin = exitPoint;
tBase += consumed;
tRemaining -= consumed;
// Go back up to a coarser mip so we can skip faster again
mip = min(mip + 1, TOP_MIP);
mipSize = GetMipSize(mip);
cellSize = chunkSize / mipSize;
t = 0.0;
tEnterCell = 0.0;
tExitCell = 0.0;
InitDDA(
origin.xz,
rd.xz,
cellSize,
deltaT,
nextTX,
nextTZ
);
}
}
return false;
}Performance results
Optimized with Max Mip traversal | Not optimized |
9.074 ms | 55.066 ms |
Conclusion
Raymarching heightfields is useful, but it has a major limitation: a heightmap only gives vertical distance, not the true distance to the surface. Because of this, naïve raymarching can either miss terrain features or become very expensive when using smaller steps.
Maximum mipmaps solve this by acting like a simple acceleration structure. Coarse mip levels store the maximum height of larger terrain regions, allowing the ray to skip areas that are safely below it. The traversal only moves to finer levels when there is a possible intersection.
During my internship, this optimization was especially useful for terrain shadow raymarching, where many rays need to test against large heightfield chunks. In my test, the max-mip traversal reduced the cost from about 55 ms to about 9 ms, while keeping the result much more stable and accurate.
Overall, max-mip traversal makes heightfield raymarching far more practical for real-time terrain effects such as shadows, ambient occlusion, visibility checks, and terrain intersection queries.


.png)




.png)

Comments