I created terrain tiles using this script:
import gzip
import json
import os
import tempfile
import numpy as np
import rasterio as rio
from quantized_mesh_encoder import encode
from rasterio.transform import from_bounds
from rasterio.warp import calculate_default_transform, reproject, Resampling
from models.is_intersect_contains import Rectangle, check_relationship, get_intersection
# ---------- 参数 ----------
DEM_FILE = r'D:\tmp\dem\input\DEM_4326.tif' # 原始 DEM
OUT_DIR = r'D:\tmp\dem\output' # 输出根目录
MIN_ZOOM = 0 # 最小层级
MAX_ZOOM = 12 # 最大层级(12 级约 50 m 分辨率)
TARGET_SIZE = 65
# ---------------------------
WGS84 = {'init': 'EPSG:4326'}
def ensure_wgs84(src_path):
"""返回 WGS84 的临时路径(如已是 4326 则原样返回)"""
with rio.open(src_path) as src:
if src.crs == 'EPSG:4326':
return src_path
tmp = tempfile.NamedTemporaryFile(suffix='.tif', delete=False).name
transform, width, height = calculate_default_transform(src.crs, WGS84, src.width, src.height, *src.bounds)
meta = src.meta.copy()
meta.update({'crs': WGS84, 'transform': transform, 'width': width, 'height': height})
with rio.open(tmp, 'w', **meta) as dst:
for i in range(1, src.count + 1):
reproject(source=rio.band(src, i),
destination=rio.band(dst, i),
src_transform=src.transform,
src_crs=src.crs,
dst_transform=transform,
dst_crs=WGS84,
resampling=Resampling.bilinear)
return tmp
def tile_bounds(x, y, z):
"""返回某TMS瓦片的经纬度边界 [west, south, east, north]"""
n_col = 2.0 ** (z + 1)
n_row = 2.0 ** z
x_step_col = 360.0 / n_col
y_step_row = 180.0 / n_row
x_start_west = -180.0 + x_step_col * x
x_end_east = x_start_west + x_step_col
y_start_south = -90.0 + y_step_row * y
y_end_north = y_start_south + y_step_row
# print(f'自己计算的值:{x_start_west, y_start_south, x_end_east, y_end_north}')
return [x_start_west, y_start_south, x_end_east, y_end_north]
def raster_range_in_TMS(global_bounds, z):
min_lon, min_lat, max_lon, max_lat = global_bounds
n_col = 2 ** (z + 1)
n_row = 2 ** z
x_step_col = 360 / n_col
y_step_row = 180 / n_row
x_min = int((180 + min_lon) // x_step_col)
y_min = int((90 + min_lat) // y_step_row)
x_max = int((180 + max_lon) // x_step_col)
y_max = int((90 + max_lat) // y_step_row)
return x_min, y_min, x_max, y_max
# 裁剪与重采样
def clip_and_resample(dataset, tile_bounds, data_bounds, target_size):
t_rect = Rectangle(tile_bounds[0], tile_bounds[1], tile_bounds[2], tile_bounds[3])
r_rect = Rectangle(data_bounds[0], data_bounds[1], data_bounds[2], data_bounds[3])
check_result = check_relationship(t_rect, r_rect)
# 切片边界包含数据边界或二者相同
if check_result == 1 or check_result == 2:
window = dataset.window(data_bounds[0], data_bounds[1], data_bounds[2], data_bounds[3])
dst_array = read_tif(dataset, window, tile_bounds, target_size)
# 数据边界包含切片边界或与二者相交
elif check_result == 3 or check_result == 5:
new_bounds = get_intersection(tile_bounds, data_bounds)
window = dataset.window(new_bounds[0], new_bounds[1], new_bounds[2], new_bounds[3])
dst_array = read_tif(dataset, window, tile_bounds, target_size)
else:
return None
return dst_array
def read_tif(dataset, window, tile_bounds, target_size):
# print(f'数据的nodata值为:{dataset.nodata}')
data = dataset.read(1, window=window, boundless=True, fill_value=dataset.nodata)
if dataset.nodata is not None:
data = np.where(np.isclose(data, dataset.nodata), 0, data)
data = np.where(data < -20000, 0, data)
if data.size == 0 or np.all((data == 0)):
return None
# 目标transform(覆盖整个瓦片)
dst_transform = from_bounds(tile_bounds[0],
tile_bounds[1],
tile_bounds[2],
tile_bounds[3],
target_size,
target_size)
# 创建一个纯零数组,行列为target_size,数据类型为32位浮点型
dst_array = np.zeros((target_size, target_size), dtype=np.float32)
reproject(
source=data,
destination=dst_array,
src_transform=dataset.window_transform(window),
src_crs=dataset.crs,
dst_transform=dst_transform,
dst_crs=dataset.crs,
resampling=Resampling.bilinear
)
dst_array = np.nan_to_num(dst_array, nan=0.0)
return dst_array
def build_layer_json(out_dir, minz, maxz, bounds, available_list):
"""生成 Cesium 必需的 layer.json"""
layer = {
"tilejson": "1.0",
"name": "terrain",
"version": "1.0.0",
"scheme": "tms",
"tiles": ["{z}/{x}/{y}.terrain"],
"minzoom": minz,
"maxzoom": maxz,
"bounds": bounds,
# "valid_bounds": bounds,
"projection": "EPSG:4326",
"format": "quantized-mesh-1.0",
# "metadataAvailability": 10,
"available": [available_list]
}
with open(os.path.join(out_dir, 'layer.json'), 'w') as f:
json.dump(layer, f, indent=2)
def numpy_grid_to_terrain(height_map, bounds):
rows, cols = height_map.shape
west, south, east, north = bounds
# --- 1. Positions (不变) ---
lons = np.linspace(west, east, cols)
lats = np.linspace(north, south, rows)
xx, yy = np.meshgrid(lons, lats)
positions = np.column_stack((
xx.flatten(),
yy.flatten(),
height_map.flatten()
))
# --- 2. Indices (改进版:生成 N行3列) ---
# 这里的 rows/cols 是顶点数,格子数要减 1
h_cells = rows - 1
w_cells = cols - 1
# 生成左上角顶点的索引网格
# 这里的 i, j 对应格子的行和列
i = np.arange(h_cells)
j = np.arange(w_cells)
jj, ii = np.meshgrid(j, i)
# 计算每个格子左上角的全局索引 k
# k 是一个 (h_cells, w_cells) 的矩阵
k = ii * cols + jj
# 定义每个格子的四个顶点索引 (利用广播机制)
# top_left = k
# top_right = k + 1
# bottom_left = k + cols
# bottom_right = k + cols + 1
# 构建两个三角形 (逆时针 CCW)
# Triangle 1: TopLeft -> BottomLeft -> TopRight
# 结果 shape: (h_cells, w_cells, 3)
t1 = np.stack([k, k + cols, k + 1], axis=-1)
# Triangle 2: TopRight -> BottomLeft -> BottomRight
# 结果 shape: (h_cells, w_cells, 3)
t2 = np.stack([k + 1, k + cols, k + cols + 1], axis=-1)
# --- 关键步骤:交替合并 ---
# 我们希望结果是:[Cell0_T1, Cell0_T2, Cell1_T1, Cell1_T2, ...]
# 使用 dstack 将它们堆叠在一起,然后 reshape
# combined shape: (h_cells, w_cells, 2, 3) -> 每个格子有2个三角形,每个三角形3个点
combined = np.stack([t1, t2], axis=2)
# Reshape 成 (N, 3)
# N = 总格子数 * 2
indices = combined.reshape(-1, 3)
# 确保是整数类型 (uint32 或 uint16)
indices = indices.astype(np.uint32)
# --- 3. 编码 ---
print(f"Indices Shape: {indices.shape}") # 现在这里会显示 (8192, 3) 之类的
print(f"第一行三角形: {indices[0]}") # 方便调试查看
with open('output_v2.terrain', 'wb') as f:
# ⚠️ 注意:如果 encode 函数报错,说明它强制要求 1D 数组。
# 此时只需在传参时加 .flatten() 即可,不影响上面的逻辑构建。
encode(f, positions, indices.flatten())
return positions, indices
def main():
os.makedirs(OUT_DIR, exist_ok=True)
wgs84_path = ensure_wgs84(DEM_FILE)
with rio.open(wgs84_path) as ds:
global_bounds = [ds.bounds.left, ds.bounds.bottom, ds.bounds.right, ds.bounds.top]
print(f'栅格范围是:{global_bounds}')
available_list = []
for z in range(MIN_ZOOM, MAX_ZOOM + 1):
print(f'=== zoom {z} ===')
# 计算当前层级瓦片范围
x_min, y_min, x_max, y_max = raster_range_in_TMS(global_bounds, z)
print(f'X范围:{x_min,x_max},Y范围{y_min,y_max}')
available_dict = {"startX": x_min, "startY": y_min, "endX": x_max, "endY": y_max}
available_list.append(available_dict)
tile_count = 0
for x in range(x_min, x_max + 1):
for y in range(y_min, y_max + 1):
# 根据当前层级的x,y,x获取当前的切片的实际范围。
bounds = tile_bounds(x, y, z)
# 判断切片范围与数据范围的位置关系,获取正确的“原始数据数组”
grid_data = clip_and_resample(ds, bounds, global_bounds, TARGET_SIZE)
if grid_data is None:
continue
# print(z, x, y, arr.shape, np.unique(arr))
# 构造三角形
# 1. 将 NaN 替换为 0 (防止编码报错)
grid_data = np.nan_to_num(grid_data, nan=0.0)
positions, indices = numpy_grid_to_terrain(grid_data, bounds)
out_path_dir = os.path.join(OUT_DIR, str(z), str(x))
os.makedirs(out_path_dir, exist_ok=True)
file_path = os.path.join(out_path_dir, f'{y}.terrain')
with gzip.open(file_path, 'wb') as f:
encode(f, positions, indices=indices)
tile_count += 1
if tile_count % 10 == 0:
print(f' 已生成 {tile_count} 个瓦片...', end='\r')
print(f'Zoom {z} 完成,共生成 {tile_count} 个瓦片')
# 如果进行了栅格坐标系转换,则移除新建栅格
if wgs84_path != DEM_FILE:
os.remove(wgs84_path)
build_layer_json(OUT_DIR, MIN_ZOOM, MAX_ZOOM, global_bounds, available_list)
print('all done!')
if __name__ == '__main__':
main()
Then deploy the generated tiles to Nginx for access by Cesium. The Nginx configuration is as follows:
server {listen 80;server_name localhost;
location /terrain/ {
alias /usr/share/nginx/html/;
autoindex on;
# CORS设置
add_header Access-Control-Allow-Origin * always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
# layer.json
location ~* \.json$ {
add_header Content-Type application/json;
add_header Access-Control-Allow-Origin * always;
}
# terrain文件 - 关键修复
location ~* \.terrain$ {
add_header Content-Type "application/vnd.quantized-mesh;extensions=octvertexnormals" always;
add_header Access-Control-Allow-Origin * always;
add_header Content-Encoding gzip always;
# gzip on;
}
}
}
Load this terrain in Cesium. The code is as follows:
// main.js
import './style.css'
import * as Cesium from 'cesium'
import './widgets.css'
window.CESIUM_BASE_URL = '/Cesium/'
/* 2. 创建 Viewer,把地形挂进去 */
const viewer = new Cesium.Viewer('cesiumContainer', {
homeButton: false,
sceneModePicker: false,
baseLayerPicker: false,
geocoder: false,
navigationHelpButton: false,
vrButton: false,
fullscreenButton: false,
timeline: false,
animation: false,
infoBox: true
})
const terrainProvider = await Cesium.CesiumTerrainProvider.fromUrl('http://localhost:8080/terrain');
viewer.terrainProvider = terrainProvider
// 隐藏 logo
viewer.cesiumWidget.creditContainer.style.display = 'none'
/* 3. 飞到你的数据区域(示例:西安附近) */
viewer.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(108.86, 35.65, 20000),
orientation: {
heading: Cesium.Math.toRadians(0),
pitch: Cesium.Math.toRadians(-45),
roll: 0
}
})
At this point, the browser can load the base map, layer.json, and 0/1/0.terrain, but no matter how much you zoom in, it cannot load the next level of terrain tiles. Why is this?
layer.json
{
"tilejson": "1.0",
"name": "terrain",
"version": "1.0.0",
"scheme": "tms",
"tiles": [
"{z}/{x}/{y}.terrain"
],
"minzoom": 0,
"maxzoom": 15,
"bounds": [
108.75213517708231,
35.50852907780295,
108.97661924097244,
35.801902431140846
],
"projection": "EPSG:4326",
"format": "quantized-mesh-1.0",
"available": [
[
{
"startX": 1,
"startY": 0,
"endX": 1,
"endY": 0
},
{
"startX": 3,
"startY": 1,
"endX": 3,
"endY": 1
},
{
"startX": 6,
"startY": 2,
"endX": 6,
"endY": 2
},
{
"startX": 12,
"startY": 5,
"endX": 12,
"endY": 5
},
{
"startX": 25,
"startY": 11,
"endX": 25,
"endY": 11
},
{
"startX": 51,
"startY": 22,
"endX": 51,
"endY": 22
},
{
"startX": 102,
"startY": 44,
"endX": 102,
"endY": 44
},
{
"startX": 205,
"startY": 89,
"endX": 205,
"endY": 89
},
{
"startX": 410,
"startY": 178,
"endX": 410,
"endY": 178
},
{
"startX": 821,
"startY": 357,
"endX": 821,
"endY": 357
},
{
"startX": 1642,
"startY": 714,
"endX": 1643,
"endY": 715
},
{
"startX": 3285,
"startY": 1428,
"endX": 3287,
"endY": 1431
},
{
"startX": 6570,
"startY": 2856,
"endX": 6575,
"endY": 2862
},
{
"startX": 13141,
"startY": 5712,
"endX": 13151,
"endY": 5725
},
{
"startX": 26282,
"startY": 11424,
"endX": 26303,
"endY": 11450
},
{
"startX": 52565,
"startY": 22848,
"endX": 52606,
"endY": 22901
}
]
]
}






