开发预览 更新于 2026-05-10

Terrain3D 3D 地形

three.js 实现的高程网格 3D 地形渲染。water-depth 蓝色色阶 / 陆地 elevation / 冲淤 delta 三套内置色阶;OrbitControls + 光照 + 点击拾取;three 作为 optional peerDependency 按需安装。

基础用法

heightData(行序展开的 number[]Float32Array)+ width × height 网格尺寸。组件自动求范围、把高度映射到颜色与垂直挤压。Cesium / 复杂 GIS 平台可以输出符合这个格式的高程网格直接灌进来。

# 用 3D 组件前必须安装 three(peerDep,可选)
pnpm add three
背景 视口
src/App.vue
<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

属性类型默认说明
heightDatanumber[] | Float32Array行序展开的高程数据,长度 width × height
widthnumber网格列数
heightnumber网格行数
bounds{ minX, maxX, minY, maxY }坐标边界(可选,用于业务侧反算实际经纬度)
minHeight / maxHeightnumber自动强制色阶上下限;不传时从数据求 min/max
exaggerationnumber1垂直夸张倍数
colorScale'depth' | 'elevation' | 'delta' | 'viridis' | Fn'depth'内置色阶或自定义函数
wireframebooleanfalse叠加线框
camera{ pitch?, yaw?, zoom? }初始相机角度(degrees)与距离倍数
autoRotatebooleanfalse自动绕场景旋转
showGridbooleanfalse底部网格辅助
backgroundstringoklch(13%)canvas 容器背景色
rendererWidth / rendererHeightnumber | string100% × 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 地形 的讨论

0
0 / 600
正在加载评论...