A better way of horizonal cull?

This idea came up recently while I was optimizing my Rust Cesium implementation. Previously, I had been using the same fog culling approach as Cesium Native. However, during visual debugging, I noticed that this method is overly conservative in its culling behavior, and moreover, its empirical parameters are designed specifically for Earth—it doesn’t work for other celestial bodies like the Moon. So I started thinking about alternative approaches.

Let’s assume the computation is performed in an ECEF‑like coordinate system. The ellipsoid has an approximate radius r, and the distance from the camera to the ellipsoid is d. Using the four corners of the NDC as reference, we construct four rays and perform intersection tests with the ellipsoid:

  • If altitude <= 0 → no culling.
  • If all four corner rays intersect → far = max( the four near intersection distances ).
  • If any corner ray does not intersect → far = sqrt( d*d - r*r ).

From this, we can decide whether we need to add a fifth far plane to the culling volume for clipping. Below are the results of my experiments.








So far, the tests have shown no visual problems, so I’m sharing this method.

Hi @jiangheng90,
It’s an interesting idea, thanks for writing it up!

It sounds a little similar to the “horizon culling” technique we use in CesiumJS. That technique is described in detail here:

I didn’t port that over to cesium-native, though, because it didn’t seem to provide much benefit beyond what we were getting with the fog culling.

The trouble is that even an ellipsoid (and especially a sphere) is really quite a poor approximation of the Earth’s surface when you start to get close to it. By the time you make these relatively simple culling techniques conservative enough that you’re not losing real detail (e.g., distant mountains) in worst-case scenes, you’re also not gaining much in the way of culling. It’s less problematic when you’re viewing relatively far from the surface, but those views tend to have a lower rendering load anyway, so we’re less inclined to spend a lot of effort optimizing them.

Anyway, for your specific proposal, the best way to evaluate it would be to implement a way to dynamically toggle it on and off at runtime. Then, evaluate a bunch of scenes with it on and off to see 1) do you ever lose visible detail in the distance when it’s on?, and 2) how much culling benefit do we see from it? It’s important to try tricky scenes, like when the camera is below the ellipsoid (actually pretty common because the ellipsoid is tens of meters above sea level in parts of the world) or even when it’s below sea level, such as in Death Valley or when viewing undersea terrain.

Kevin

@Kevin_Ring Yes, I added a toggle switch on the development panel to dynamically test this feature while I was working on it. However, I don’t currently have the bandwidth to test a large number of scenes, so this will be a long‑term testing effort.

As for the issue of being close to the surface or even below the ellipsoid, I did take that into consideration. My approach is to uniformly shrink the ellipsoid radius by a certain value(I found you mention this in Computing the horizon occlusion point); I set the default to 1000 meters, and I also made this value configurable. After shrinking, the camera can be considered to always avoid getting too close to the virtual surface, and the culling benefit remains sufficiently large. Of course, it will still require extensive long‑term testing to determine a more appropriate default shrink value to avoid detail loss anywhere on Earth.

At first, I assumed that this approach was essentially the same as your 2013 idea. But later I realized that the biggest difference is that I use this distance to set up frustum clipping, rather than doing a direct boolean test. This gives us greater clipping tolerance, which is probably why I didn’t notice any obvious detail loss in my rough tests yesterday. Still, this approach will need long‑term validation.

@Kevin_Ring and this this the implementation of mine fog cull.

// fog_cull.rs
#[derive(Debug, Clone)]
struct FogDensityAtHeight {
    height: f64,
    density: f64,
}

static FOG_DENSITY_TABLE: [FogDensityAtHeight; 21] = [
    FogDensityAtHeight {
        height: 359.393,
        density: 2.0e-5,
    },
    FogDensityAtHeight {
        height: 800.749,
        density: 2.0e-4,
    },
    FogDensityAtHeight {
        height: 1275.6501,
        density: 1.0e-4,
    },
    FogDensityAtHeight {
        height: 2151.1192,
        density: 7.0e-5,
    },
    FogDensityAtHeight {
        height: 3141.7763,
        density: 5.0e-5,
    },
    FogDensityAtHeight {
        height: 4777.5198,
        density: 4.0e-5,
    },
    FogDensityAtHeight {
        height: 6281.2493,
        density: 3.0e-5,
    },
    FogDensityAtHeight {
        height: 12364.307,
        density: 1.9e-5,
    },
    FogDensityAtHeight {
        height: 15900.765,
        density: 1.0e-5,
    },
    FogDensityAtHeight {
        height: 49889.0549,
        density: 8.5e-6,
    },
    FogDensityAtHeight {
        height: 78026.8259,
        density: 6.2e-6,
    },
    FogDensityAtHeight {
        height: 99260.7344,
        density: 5.8e-6,
    },
    FogDensityAtHeight {
        height: 120036.3873,
        density: 5.3e-6,
    },
    FogDensityAtHeight {
        height: 151011.0158,
        density: 5.2e-6,
    },
    FogDensityAtHeight {
        height: 156091.1953,
        density: 5.1e-6,
    },
    FogDensityAtHeight {
        height: 203849.3112,
        density: 4.2e-6,
    },
    FogDensityAtHeight {
        height: 274866.9803,
        density: 4.0e-6,
    },
    FogDensityAtHeight {
        height: 319916.3149,
        density: 3.4e-6,
    },
    FogDensityAtHeight {
        height: 493552.0528,
        density: 2.6e-6,
    },
    FogDensityAtHeight {
        height: 628733.5874,
        density: 2.2e-6,
    },
    FogDensityAtHeight {
        height: 1_000_000.0,
        density: 0.0,
    },
];

#[inline]
pub fn compute_fog_density(height: f64) -> f64 {
    let table = &FOG_DENSITY_TABLE;

    let idx = match table.binary_search_by(|e| {
        e.height
            .partial_cmp(&height)
            .unwrap_or(std::cmp::Ordering::Less)
    }) {
        Ok(i) => i,
        Err(i) => i,
    };

    if idx >= table.len() {
        return table.last().unwrap().density;
    }

    if idx == 0 {
        return table[0].density;
    }

    let a = &table[idx - 1];
    let b = &table[idx];

    let height_a = a.height;
    let density_a = a.density;
    let height_b = b.height;
    let density_b = b.density;

    let t = ((height - height_a) / (height_b - height_a)).clamp(0.0, 1.0);

    density_a + t * (density_b - density_a)
}

#[inline]
pub fn is_visible_in_fog(distance: f64, fog_density: f64) -> bool {
    if fog_density <= 0.0 {
        return true;
    }

    let fog_scalar = distance * fog_density;
    (-fog_scalar * fog_scalar).exp() > 0.0
}

and here is max culling distance use the shrink ellipsoid

fn compute_max_culling_distance(position: DVec3, ellipsoid: &Ellipsoid) -> Option<f64> {
    let altitude = ellipsoid.cartesian_to_cartographic(position).altitude;
    if altitude <= 0.0 {
        return None;
    }
    let d = position.length();
    let r = d - altitude;
    Some(((d - r) * (d + r)).sqrt())
}

@Kevin_Ring Currently, our discussion lacks some means of validation, so I compiled a macOS release build of my project that includes ion quantized mesh + Bing Maps imagery. With this program, I found that the default shrink value of 1000m does indeed cause detail loss when viewing the snowy peaks of the Himalayas from the south. So I changed the default to 15000 and also ran comparison tests against fog culling. The conclusion is that fog culling also suffers from slight detail loss in some cases, but with the 15000 shrink value, my improved horizon clipping can preserve detail just as well as having no culling at all.

@Kevin_Ring I conducted more extensive testing, and the results were not as expected. In certain cases, fog culling performed well while horizon culling caused detail loss. Since I couldn’t quickly verify this in Cesium Native, I ran experiments on CesiumJS with Google Earth data. The results showed that the culling gain from horizon culling on CesiumJS was much lower than in my own project. I suspect this discrepancy may stem from differences in traversal pruning: I prune unloaded content and avoid further traversal, and culled tiles are not enqueued for loading (no requests, no refinement) – though I believe this behavior is similar in Cesium Native. However, I’m not certain. Given this, I think the horizon clipping method may only offer significant gains in my own project, and its potential benefit in Cesium Native remains unclear.