Hi,
Cesium version: 1.131
I try to create a DSM (Digital Surface Model) as raster out of the existing 3D Scene in Cesium. The scene has 3D Tiles, Terrain, and GLTF objects in it and i would like to create a DSM for a ceratin extent out of it. The raster should have different resolution like 5x5m, 1x1m or 10x10m. The extent could be easily 1kmx1km. I started with scene.sampleHeightMostDetailed(), but for bigger extents it takes really long and that is why i am looking for a faster approach.
I tried so far:
Direct depth buffer sampling with world position reconstruction, which reconstructs world positions from the depth buffer and extracts heights.It works like that
-
Setting up an orthographic camera positioned above the target area looking straight down
-
Createe a post-process shader that:
-
Reads depth from Cesium’s depth buffer using
czm_readDepth(czm_globeDepthTexture, ...) -
Reconstructs world-space positions from depth using
czm_windowToEyeCoordinates()+czm_inverseView -
Extracts geodetic height from those world positions (ECEF coordinates)
-
Encodes the height values into RGB channels for readback
-
-
Render the scene with the post-process stage applied to get the encoded height data
-
Reads back pixels from the framebuffer using the WebGL context
-
Decoding the RGB pixels back into height values and calculates statistics
Essentially, it’s leveraging Cesium’s depth buffer and built-in matrix uniforms (czm_inverseView, etc.) to convert rendered pixels into elevation data for the geographic extent.
here is a code snippet:
const worldPositionShader = `
uniform sampler2D colorTexture;
uniform float u_minHeight;
uniform float u_heightRange;
in vec2 v_textureCoordinates;
void main() {
// Read depth using Cesium's built-in function
float depth = czm_readDepth(czm_globeDepthTexture, v_textureCoordinates);
if (depth >= 0.9999) {
// No geometry
out_FragColor = vec4(1.0, 0.0, 1.0, 0.0);
return;
}
// Reconstruct window coordinates
vec2 windowCoord = v_textureCoordinates * czm_viewport.zw + czm_viewport.xy;
// Get raw depth for position reconstruction
float rawDepth = texture(czm_globeDepthTexture, v_textureCoordinates).r;
// Convert window coordinates to eye space
vec4 eyePosition = czm_windowToEyeCoordinates(windowCoord, rawDepth);
// Transform from eye space to world space
vec4 worldPosition = czm_inverseView * eyePosition;
worldPosition /= worldPosition.w;
// Convert Cartesian world position to Cartographic (lon, lat, height)
// We need the height above ellipsoid
vec3 posECEF = worldPosition.xyz;
// Approximate height calculation using ellipsoid radius
// For WGS84: semi-major axis = 6378137.0, semi-minor = 6356752.3142
float a = 6378137.0;
float b = 6356752.3142;
// Distance from Earth center
float r = length(posECEF);
// Approximate geodetic height (simplified, assumes roughly spherical)
// For more accuracy, we'd need full geodetic conversion
float lat = asin(posECEF.z / r);
float N = a / sqrt(1.0 - (1.0 - (b*b)/(a*a)) * sin(lat) * sin(lat));
float height = r - N;
// Adjust for latitude (rough approximation)
float cosLat = cos(lat);
if (cosLat > 0.001) {
height = length(posECEF.xy) / cosLat - N;
}
// Normalize and encode height
float heightNormalized = (height - u_minHeight) / u_heightRange;
heightNormalized = clamp(heightNormalized, 0.0, 1.0);
float encoded = heightNormalized * 16777215.0;
float red = floor(encoded / 65536.0);
float green = floor(mod(encoded, 65536.0) / 256.0);
float blue = mod(encoded, 256.0);
out_FragColor = vec4(red / 255.0, green / 255.0, blue / 255.0, 1.0);
}
`;
const { minHeightRange = -1000, maxHeightRange = 10000 } = config;
const heightRange = maxHeightRange - minHeightRange;
postProcessStage = new PostProcessStage({
fragmentShader: worldPositionShader,
uniforms: {
// eslint-disable-next-line @typescript-eslint/naming-convention
u_minHeight: minHeightRange,
// eslint-disable-next-line @typescript-eslint/naming-convention
u_heightRange: heightRange,
},
});
scene.postProcessStages.add(postProcessStage);
The second approach i tried is creating a DSM (Digital Surface Model) raster from the current Cesium scene by using an off-screen framebuffer with proper logarithmic depth decoding. It works like that:
-
Saves the camera state and scene settings
-
Enables depth features - Turns on
depthTestAgainstTerrainanduseDepthPickingso the depth buffer gets populated -
Positions camera above the target area looking straight down (keeps perspective frustum, not orthographic)
-
Waits for terrain tiles to load at the new camera position, then renders several frames to ensure the depth buffer is populated
-
Creates a post-process shader that:
-
Reads depth directly from
gl_FragCoord.z -
Uses
czm_windowToEyeCoordinates()to convert window-space depth to eye-space position -
Transforms to world space with
czm_inverseView -
Calculates ellipsoid height from the world position
-
Encodes the height into RGB channels
-
-
Adds the shader as a post-process stage to the scene and renders
-
Reads back the framebuffer pixels using the WebGL context
-
Decodes RGB pixels back to height values and calculates statistics
-
Restores the original camera and scene state
/**
* Post-process shader that reconstructs world position from GL depth and extracts ellipsoid height.
*
* PostProcessStage doesn’t provide depth texture access by default.
* Instead, we use gl_FragCoord.z to read the depth value directly from the depth buffer.
*
* For perspective projection:
* 1. Read depth directly from gl_FragCoord (window-space depth)
* 2. Use czm_windowToEyeCoordinates to get eye-space position
* 3. Transform to world coordinates using czm_inverseView
* 4. Calculate the ellipsoid height from the world position
*
* => encode the height into RGB channels for readback (24-bit precision).
*/
const depthToHeightShaderV2 = `
uniform sampler2D colorTexture;
uniform float u_minHeight;
uniform float u_heightRange;
uniform vec3 u_radiiSquared;
in vec2 v_textureCoordinates;
// Calculate height above ellipsoid from world position
float getEllipsoidHeight(vec3 worldPos, vec3 radiiSquared) {
// Normalize to ellipsoid surface
vec3 oneOverRadiiSquared = 1.0 / radiiSquared;
// Find the closest point on ellipsoid
vec3 pos = worldPos;
float beta = 1.0 / sqrt(
pos.x * pos.x * oneOverRadiiSquared.x +
pos.y * pos.y * oneOverRadiiSquared.y +
pos.z * pos.z * oneOverRadiiSquared.z
);
vec3 surfacePoint = pos * beta;
float surfaceHeight = length(surfacePoint);
float actualHeight = length(worldPos);
return actualHeight - surfaceHeight;
}
void main() {
// WORKAROUND: Since PostProcessStage doesn't expose depth texture,
// use the fragment's own depth value (gl_FragCoord.z)
// This only works if the post-process stage writes to the same framebuffer
float depth = gl_FragCoord.z;
// Check for sky/no geometry
if (depth >= 0.9999 || depth <= 0.0001) {
out_FragColor = vec4(1.0, 0.0, 1.0, 0.0); // Magenta = no data
return;
}
// Get window coordinates from texture coordinates
vec2 windowCoord = v_textureCoordinates * czm_viewport.zw + czm_viewport.xy;
// Convert window position + depth to eye coordinates
// czm_windowToEyeCoordinates expects the depth value from the depth buffer
vec4 eyeCoord = czm_windowToEyeCoordinates(windowCoord, depth);
// Transform from eye space to world space
vec4 worldCoord = czm_inverseView * eyeCoord;
vec3 worldPos = worldCoord.xyz / worldCoord.w;
// Calculate ellipsoid height
float elevationMeters = getEllipsoidHeight(worldPos, u_radiiSquared);
// Normalize and encode to RGB
float heightNormalized = (elevationMeters - u_minHeight) / u_heightRange;
heightNormalized = clamp(heightNormalized, 0.0, 1.0);
// Encode to 24-bit RGB
float encoded = heightNormalized * 16777215.0;
float r = floor(encoded / 65536.0);
float g = floor(mod(encoded, 65536.0) / 256.0);
float b = mod(encoded, 256.0);
out_FragColor = vec4(r / 255.0, g / 255.0, b / 255.0, 1.0);
}
`;
// Get ellipsoid radii squared for height calculation
const { ellipsoid } = scene.globe;
const { radii } = ellipsoid;
const radiiSquared = new Cartesian3(
radii.x * radii.x,
radii.y * radii.y,
radii.z * radii.z,
);
// Wait for terrain tiles to load at the new camera position
// eslint-disable-next-line no-console
console.log('Waiting for terrain tiles to load...');
// Helper to wait for tiles
const waitForTiles = async (maxWaitMs: number): Promise<void> => {
const startTime = Date.now();
let continueLoop = true;
while (continueLoop) {
scene.render();
const { tilesLoaded } = scene.globe;
const tilesToRender =
(scene.globe as any)._surface?._tilesToRender?.length ?? 0;
if (tilesLoaded && tilesToRender > 0) {
// eslint-disable-next-line no-console
console.log(`Tiles loaded. Tiles to render: ${tilesToRender}`);
continueLoop = false;
} else if (Date.now() - startTime > maxWaitMs) {
// eslint-disable-next-line no-console
console.log('Timeout waiting for tiles, proceeding anyway...');
continueLoop = false;
} else {
// eslint-disable-next-line no-await-in-loop
await new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, 100);
});
}
}
};
await waitForTiles(3000);
// Render several more frames to ensure depth buffer is populated
// eslint-disable-next-line no-console
console.log('Rendering frames to populate depth buffer...');
for (let i = 0; i < 5; i += 1) {
scene.render();
// eslint-disable-next-line no-await-in-loop
await new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, 50);
});
}
// Debug: Check if globe depth texture exists
// eslint-disable-next-line @typescript-eslint/no-explicit-any, no-underscore-dangle
const frameState = (scene as any)._frameState;
// eslint-disable-next-line no-console
console.log('Frame state useLogDepth:', frameState?.useLogDepth);
// Check multiple places where depth might be stored
// eslint-disable-next-line @typescript-eslint/no-explicit-any, no-underscore-dangle
const globeDepth = (scene as any)._globeDepth;
// eslint-disable-next-line @typescript-eslint/no-explicit-any, no-underscore-dangle
const view = (scene as any)._view;
// eslint-disable-next-line no-underscore-dangle
const viewGlobeDepth = view?._globeDepth;
// eslint-disable-next-line no-console
console.log('Scene._globeDepth exists:', !!globeDepth);
// eslint-disable-next-line no-console
console.log('Scene._view exists:', !!view);
// eslint-disable-next-line no-console
console.log('View._globeDepth exists:', !!viewGlobeDepth);
if (viewGlobeDepth) {
// eslint-disable-next-line no-console, no-underscore-dangle
console.log('View globe depth framebuffer:', viewGlobeDepth._framebuffer);
// eslint-disable-next-line no-console, no-underscore-dangle
console.log('View globe depth texture:', viewGlobeDepth._colorTexture);
}
// Also check picking depth
// eslint-disable-next-line @typescript-eslint/no-explicit-any, no-underscore-dangle
const pickingDepth = (scene as any)._picking?.pickDepth;
// eslint-disable-next-line no-console
console.log('Picking depth exists:', !!pickingDepth);
// Create post-process stage with updated uniforms
postProcessStage = new PostProcessStage({
fragmentShader: depthToHeightShaderV2,
uniforms: {
// eslint-disable-next-line @typescript-eslint/naming-convention
u_minHeight: minHeightRange,
// eslint-disable-next-line @typescript-eslint/naming-convention
u_heightRange: heightRange,
// eslint-disable-next-line @typescript-eslint/naming-convention
u_radiiSquared: radiiSquared,
},
});
// Add post-process stage
scene.postProcessStages.add(postProcessStage);
// Render with the post-process stage
scene.render();
// Wait a frame for async operations and render again
await new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, 100);
});
scene.render();
But for both approaches i am getting DSM statistics like this:
DSM created with statistics:
Min Height: -1000.00 m
Max Height: -1000.00 m
Center Height: -1000.00 m
Average Height: -1000.00 m
Valid Pixels: 1048576 / 1048576
So it does not work out. It is more or less R&D and just a prototype figuring out if that would be possible. Has anyone an idea if that would be kind of possible or is it just a stupid idea to create a DSM export like that not using sampleHeightMostDetailed()?
Many thanks in advance. Every comment is highly welcome ![]()
BR Thomas