Terrain3D 3D 地形
three.js 实现的高程网格 3D 地形渲染。water-depth 蓝色色阶 / 陆地 elevation / 冲淤 delta 三套内置色阶;OrbitControls + 光照 + 点击拾取;three 作为 optional peerDependency 按需安装。
English translation pending This page hasn't been translated yet — falling back to Chinese. PRs welcome on GitHub.
基础用法
传 heightData(行序展开的 number[] 或 Float32Array)+ width × height 网格尺寸。组件自动求范围、把高度映射到颜色与垂直挤压。Cesium / 复杂 GIS 平台可以输出符合这个格式的高程网格直接灌进来。
# 用 3D 组件前必须安装 three(peerDep,可选)
pnpm add three
背景 视口
<script setup lang="ts">
import { computed, ref } from 'vue';
import { CfTerrain3D, type Terrain3DColorScale } from '@chufix-design/maps-vue';
const COLS = 96;
const ROWS = 72;
function generateRiverbed(): number[] {
const data = new Array<number>(COLS * ROWS);
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const u = c / (COLS - 1);
const v = r / (ROWS - 1);
const channel = Math.sin((v - 0.5) * Math.PI * 1.4) * 0.4 + (v - 0.5) * 1.2;
const dist = u - 0.5 - channel;
const depth = -20 - Math.exp(-dist * dist * 25) * 26;
const dune = Math.sin(u * 16) * Math.cos(v * 14) * 1.2;
const noise = (Math.sin(u * 53 + v * 71) + Math.cos(u * 91 - v * 37)) * 0.4;
const bank = Math.max(0, 1 - Math.exp(-Math.abs(dist) * 4)) * 22;
data[r * COLS + c] = depth + dune + noise + bank;
}
}
return data;
}
const data = ref<number[]>(generateRiverbed());
const colorScale = ref<Terrain3DColorScale>('depth');
const exaggeration = ref(1.4);
const wireframe = ref(false);
const autoRotate = ref(true);
const picked = ref('点击地形选取采样点');
function regenerate() {
data.value = generateRiverbed().map((v) => v + (Math.random() - 0.5) * 2);
}
function onPick(p: { x: number; y: number; z: number; row: number; col: number }) {
const idx = p.row * COLS + p.col;
const depth = data.value[idx];
picked.value = `row ${p.row} · col ${p.col} · 水深 ${depth.toFixed(1)} m`;
}
const exaggLabel = computed(() => `垂直夸张 ${exaggeration.value.toFixed(1)}×`);
</script>
<template>
<div class="t3-demo">
<div class="t3-demo__controls">
<label>
<span>色阶</span>
<select v-model="colorScale">
<option value="depth">depth · 水深蓝</option>
<option value="elevation">elevation · 陆地绿黄</option>
<option value="delta">delta · 冲淤红蓝</option>
<option value="viridis">viridis</option>
</select>
</label>
<label>
<span>{{ exaggLabel }}</span>
<input type="range" min="0.4" max="3" step="0.1" v-model.number="exaggeration" />
</label>
<label class="t3-demo__check">
<input type="checkbox" v-model="wireframe" />
线框
</label>
<label class="t3-demo__check">
<input type="checkbox" v-model="autoRotate" />
自动旋转
</label>
<button type="button" class="t3-demo__btn" @click="regenerate">重新生成河床</button>
</div>
<CfTerrain3D
:height-data="data"
:width="COLS"
:height="ROWS"
:color-scale="colorScale"
:exaggeration="exaggeration"
:wireframe="wireframe"
:auto-rotate="autoRotate"
:renderer-height="540"
show-grid
@pick="onPick"
/>
<code class="t3-demo__pick">{{ picked }}</code>
</div>
</template>
<style scoped>
.t3-demo {
display: grid;
gap: 12px;
}
.t3-demo__controls {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
padding: 10px 12px;
background: var(--bg-1);
border: 1px solid var(--line-1);
border-radius: var(--r-3);
color: var(--fg-2);
font-size: var(--t-12);
}
.t3-demo__controls label {
display: inline-flex;
align-items: center;
gap: 6px;
}
.t3-demo__controls select {
height: 28px;
padding: 0 8px;
background: var(--bg-0);
border: 1px solid var(--line-2);
border-radius: var(--r-2);
color: var(--fg-1);
font-size: var(--t-12);
font-family: inherit;
}
.t3-demo__controls input[type='range'] {
width: 120px;
accent-color: var(--accent-1);
}
.t3-demo__check {
cursor: pointer;
}
.t3-demo__btn {
padding: 4px 12px;
background: var(--accent-1);
color: var(--fg-on-accent, #fff);
border: 0;
border-radius: var(--r-2);
font-size: var(--t-12);
cursor: pointer;
font-family: inherit;
}
.t3-demo__btn:hover {
background: var(--accent-2);
}
.t3-demo__pick {
align-self: start;
padding: 6px 10px;
background: var(--bg-inset);
border-radius: var(--r-2);
font-size: var(--t-12);
color: var(--fg-2);
}
</style> <script setup>
import { computed, ref } from 'vue';
import { CfTerrain3D } from '@chufix-design/maps-vue';
const COLS = 96;
const ROWS = 72;
function generateRiverbed(): number[] {
const data = new Array<number>(COLS * ROWS);
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const u = c / (COLS - 1);
const v = r / (ROWS - 1);
const channel = Math.sin((v - 0.5) * Math.PI * 1.4) * 0.4 + (v - 0.5) * 1.2;
const dist = u - 0.5 - channel;
const depth = -20 - Math.exp(-dist * dist * 25) * 26;
const dune = Math.sin(u * 16) * Math.cos(v * 14) * 1.2;
const noise = (Math.sin(u * 53 + v * 71) + Math.cos(u * 91 - v * 37)) * 0.4;
const bank = Math.max(0, 1 - Math.exp(-Math.abs(dist) * 4)) * 22;
data[r * COLS + c] = depth + dune + noise + bank;
}
}
return data;
}
const data = ref<number[]>(generateRiverbed());
const colorScale = ref<Terrain3DColorScale>('depth');
const exaggeration = ref(1.4);
const wireframe = ref(false);
const autoRotate = ref(true);
const picked = ref('点击地形选取采样点');
function regenerate() {
data.value = generateRiverbed().map((v) => v + (Math.random() - 0.5) * 2);
}
function onPick(p: { x: number; y: number; z: number; row: number; col: number }) {
const idx = p.row * COLS + p.col;
const depth = data.value[idx];
picked.value = `row ${p.row} · col ${p.col} · 水深 ${depth.toFixed(1)} m`;
}
const exaggLabel = computed(() => `垂直夸张 ${exaggeration.value.toFixed(1)}×`);
</script>
<template>
<div class="t3-demo">
<div class="t3-demo__controls">
<label>
<span>色阶</span>
<select v-model="colorScale">
<option value="depth">depth · 水深蓝</option>
<option value="elevation">elevation · 陆地绿黄</option>
<option value="delta">delta · 冲淤红蓝</option>
<option value="viridis">viridis</option>
</select>
</label>
<label>
<span>{{ exaggLabel }}</span>
<input type="range" min="0.4" max="3" step="0.1" v-model.number="exaggeration" />
</label>
<label class="t3-demo__check">
<input type="checkbox" v-model="wireframe" />
线框
</label>
<label class="t3-demo__check">
<input type="checkbox" v-model="autoRotate" />
自动旋转
</label>
<button type="button" class="t3-demo__btn" @click="regenerate">重新生成河床</button>
</div>
<CfTerrain3D
:height-data="data"
:width="COLS"
:height="ROWS"
:color-scale="colorScale"
:exaggeration="exaggeration"
:wireframe="wireframe"
:auto-rotate="autoRotate"
:renderer-height="540"
show-grid
@pick="onPick"
/>
<code class="t3-demo__pick">{{ picked }}</code>
</div>
</template>
<style scoped>
.t3-demo {
display: grid;
gap: 12px;
}
.t3-demo__controls {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
padding: 10px 12px;
background: var(--bg-1);
border: 1px solid var(--line-1);
border-radius: var(--r-3);
color: var(--fg-2);
font-size: var(--t-12);
}
.t3-demo__controls label {
display: inline-flex;
align-items: center;
gap: 6px;
}
.t3-demo__controls select {
height: 28px;
padding: 0 8px;
background: var(--bg-0);
border: 1px solid var(--line-2);
border-radius: var(--r-2);
color: var(--fg-1);
font-size: var(--t-12);
font-family: inherit;
}
.t3-demo__controls input[type='range'] {
width: 120px;
accent-color: var(--accent-1);
}
.t3-demo__check {
cursor: pointer;
}
.t3-demo__btn {
padding: 4px 12px;
background: var(--accent-1);
color: var(--fg-on-accent, #fff);
border: 0;
border-radius: var(--r-2);
font-size: var(--t-12);
cursor: pointer;
font-family: inherit;
}
.t3-demo__btn:hover {
background: var(--accent-2);
}
.t3-demo__pick {
align-self: start;
padding: 6px 10px;
background: var(--bg-inset);
border-radius: var(--r-2);
font-size: var(--t-12);
color: var(--fg-2);
}
</style> import { useState } from 'react';
import { CfTerrain3D } from '@chufix-design/react';
export default function Demo() {
import { CfTerrain3D, type Terrain3DColorScale } from '@chufix-design/maps-vue';
const COLS = 96;
const ROWS = 72;
function generateRiverbed(): number[] {
const data = new Array<number>(COLS * ROWS);
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const u = c / (COLS - 1);
const v = r / (ROWS - 1);
const channel = Math.sin((v - 0.5) * Math.PI * 1.4) * 0.4 + (v - 0.5) * 1.2;
const dist = u - 0.5 - channel;
const depth = -20 - Math.exp(-dist * dist * 25) * 26;
const dune = Math.sin(u * 16) * Math.cos(v * 14) * 1.2;
const noise = (Math.sin(u * 53 + v * 71) + Math.cos(u * 91 - v * 37)) * 0.4;
const bank = Math.max(0, 1 - Math.exp(-Math.abs(dist) * 4)) * 22;
data[r * COLS + c] = depth + dune + noise + bank;
}
}
return data;
}
const [data, setData] = useState<number[]>(generateRiverbed());
const [colorScale, setColorScale] = useState<Terrain3DColorScale>('depth');
const [exaggeration, setExaggeration] = useState(1.4);
const [wireframe, setWireframe] = useState(false);
const [autoRotate, setAutoRotate] = useState(true);
const [picked, setPicked] = useState('点击地形选取采样点');
function regenerate() {
setData(generateRiverbed().map((v) => v + (Math.random() - 0.5) * 2));
}
function onPick(p: { x: number; y: number; z: number; row: number; col: number }) {
const idx = p.row * COLS + p.col;
const depth = data[idx];
setPicked(`row ${p.row} · col ${p.col} · 水深 ${depth.toFixed(1)} m`);
}
const exaggLabel = `垂直夸张 ${exaggeration.toFixed(1)}×`;
return (
<>
<div className="t3-demo">
<div className="t3-demo__controls">
<label>
<span>色阶</span>
<select value={colorScale} onChange={setColorScale}>
<option value="depth">depth · 水深蓝</option>
<option value="elevation">elevation · 陆地绿黄</option>
<option value="delta">delta · 冲淤红蓝</option>
<option value="viridis">viridis</option>
</select>
</label>
<label>
<span>{exaggLabel}</span>
<input type="range" min="0.4" max="3" step="0.1" v-model.number="exaggeration" />
</label>
<label className="t3-demo__check">
<input type="checkbox" value={wireframe} onChange={setWireframe} />
线框
</label>
<label className="t3-demo__check">
<input type="checkbox" value={autoRotate} onChange={setAutoRotate} />
自动旋转
</label>
<button type="button" className="t3-demo__btn" onClick={regenerate}>重新生成河床</button>
</div>
<CfTerrain3D heightData={data} width={COLS} height={ROWS} colorScale={colorScale} exaggeration={exaggeration} wireframe={wireframe} autoRotate={autoRotate} rendererHeight={540} showGrid onPick={onPick} />
<code className="t3-demo__pick">{picked}</code>
</div>
</>
);
} import { useState } from 'react';
import { CfTerrain3D } from '@chufix-design/react';
export default function Demo() {
import { CfTerrain3D } from '@chufix-design/maps-vue';
const COLS = 96;
const ROWS = 72;
function generateRiverbed(): number[] {
const data = new Array<number>(COLS * ROWS);
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const u = c / (COLS - 1);
const v = r / (ROWS - 1);
const channel = Math.sin((v - 0.5) * Math.PI * 1.4) * 0.4 + (v - 0.5) * 1.2;
const dist = u - 0.5 - channel;
const depth = -20 - Math.exp(-dist * dist * 25) * 26;
const dune = Math.sin(u * 16) * Math.cos(v * 14) * 1.2;
const noise = (Math.sin(u * 53 + v * 71) + Math.cos(u * 91 - v * 37)) * 0.4;
const bank = Math.max(0, 1 - Math.exp(-Math.abs(dist) * 4)) * 22;
data[r * COLS + c] = depth + dune + noise + bank;
}
}
return data;
}
const [data, setData] = useState<number[]>(generateRiverbed());
const [colorScale, setColorScale] = useState<Terrain3DColorScale>('depth');
const [exaggeration, setExaggeration] = useState(1.4);
const [wireframe, setWireframe] = useState(false);
const [autoRotate, setAutoRotate] = useState(true);
const [picked, setPicked] = useState('点击地形选取采样点');
function regenerate() {
setData(generateRiverbed().map((v) => v + (Math.random() - 0.5) * 2));
}
function onPick(p: { x: number; y: number; z: number; row: number; col: number }) {
const idx = p.row * COLS + p.col;
const depth = data[idx];
setPicked(`row ${p.row} · col ${p.col} · 水深 ${depth.toFixed(1)} m`);
}
const exaggLabel = `垂直夸张 ${exaggeration.toFixed(1)}×`;
return (
<>
<div className="t3-demo">
<div className="t3-demo__controls">
<label>
<span>色阶</span>
<select value={colorScale} onChange={setColorScale}>
<option value="depth">depth · 水深蓝</option>
<option value="elevation">elevation · 陆地绿黄</option>
<option value="delta">delta · 冲淤红蓝</option>
<option value="viridis">viridis</option>
</select>
</label>
<label>
<span>{exaggLabel}</span>
<input type="range" min="0.4" max="3" step="0.1" v-model.number="exaggeration" />
</label>
<label className="t3-demo__check">
<input type="checkbox" value={wireframe} onChange={setWireframe} />
线框
</label>
<label className="t3-demo__check">
<input type="checkbox" value={autoRotate} onChange={setAutoRotate} />
自动旋转
</label>
<button type="button" className="t3-demo__btn" onClick={regenerate}>重新生成河床</button>
</div>
<CfTerrain3D heightData={data} width={COLS} height={ROWS} colorScale={colorScale} exaggeration={exaggeration} wireframe={wireframe} autoRotate={autoRotate} rendererHeight={540} showGrid onPick={onPick} />
<code className="t3-demo__pick">{picked}</code>
</div>
</>
);
} ⚠ Astro 中必须用 client:only="vue"
CfTerrain3D 在 mount 阶段动态 import('three') 并创建 WebGL 上下文,无法在 SSR 时执行;和 Tooltip / Modal / Toaster 一样,docs / 静态站点里嵌入演示必须用 client:only="vue"(React 端 client:only="react"),不能用 client:load。
内置色阶
| 色阶 | 适用场景 |
|---|---|
depth | 水深 / 海底 — 深蓝→浅蓝→白 |
elevation | 陆地高程 — 绿→黄褐→灰白雪线 |
delta | 变化量(冲淤)— 红色下降、蓝色上升(围绕中点 0.5 反对称) |
viridis | 通用感知线性 |
也可以传函数 (t: number) => { r, g, b } 全自定义;t ∈ [0, 1] 按 height 归一化后传入。
API
Props
| 属性 | 类型 | 默认 | 说明 |
|---|---|---|---|
heightData | number[] | Float32Array | — | 行序展开的高程数据,长度 width × height |
width | number | — | 网格列数 |
height | number | — | 网格行数 |
bounds | { minX, maxX, minY, maxY } | — | 坐标边界(可选,用于业务侧反算实际经纬度) |
minHeight / maxHeight | number | 自动 | 强制色阶上下限;不传时从数据求 min/max |
exaggeration | number | 1 | 垂直夸张倍数 |
colorScale | 'depth' | 'elevation' | 'delta' | 'viridis' | Fn | 'depth' | 内置色阶或自定义函数 |
wireframe | boolean | false | 叠加线框 |
camera | { pitch?, yaw?, zoom? } | — | 初始相机角度(degrees)与距离倍数 |
autoRotate | boolean | false | 自动绕场景旋转 |
showGrid | boolean | false | 底部网格辅助 |
background | string | oklch(13%) | canvas 容器背景色 |
rendererWidth / rendererHeight | number | string | 100% × 480 | 容器尺寸 |
Events
| 事件 | 载荷 | 说明 |
|---|---|---|
ready | — | 渲染器就绪 |
pick | { x, y, z, row, col } | 点击地形拾取,坐标 + 网格行列 |
与 Cesium / Mapbox GL 的关系
- Cesium: 完整地球级 GIS 平台(瓦片 / 地形 / 影像 / 时间轴 / 坐标变换),bundle ~10 MB。适合做完整 GIS 应用。
- CfTerrain3D: 单一关注「一块高程网格的 3D 可视化」,不做地图投影 / 瓦片 / 时间轴,~60 KB(不含 three peerDep)。适合做仪表盘里的水深 / 冲淤 / DEM 单卡视图。
需要完整 Cesium 集成请直接 npm i cesium 自用;CfTerrain3D 只在你已有 height 网格数据时用。
反馈与讨论
Terrain3D 3D 地形 · Discussion