Turfjs+Three.js:地理数据的三维建模应用

在 WebGIS 开发中,三维可视化不再局限于地球 / 地图场景 ------ 纯三维建模(Three.js)能更灵活地展示地理数据的空间形态与分析结果。Turf.js 作为前端空间分析核心库,可完成地理坐标处理、距离计算、质心分析等基础操作;Three.js 则专注于三维几何体创建、材质渲染、交互控制,两者结合可实现 "地理数据 → 空间分析 → 三维建模 → 可视化着色" 的完整流程。本文将通过实战案例,带你掌握 Turf.js 与 Three.js 的结合使用方法,实现面要素三维地形建模、线要素三维路径生成、空间分析结果着色三大核心功能。

一、技术栈说明

  • 框架:Vue3(Composition API + <script setup>
  • 空间分析:Turf.js(@turf/turf v7+,核心 API:polygonlineStringcentroiddistance
  • 三维建模:Three.js r158+(核心能力:几何体创建、材质渲染、轨道控制、顶点着色)
  • 辅助控件:OrbitControls(Three.js 官方轨道控制器)
  • UI 组件库:Element Plus(卡片、栅格、输入框、按钮)
  • 样式:Less(模块化样式管理)
  • 核心功能:经纬度转平面坐标、面要素挤出三维地形、线要素生成平滑路径、按空间距离顶点着色、三维场景交互控制

二、环境搭建(复用前序环境)

若已完成前序文章的 Vue3 环境搭建,仅需新增 Three.js 依赖;若未搭建,执行以下命令:

bash 复制代码
# 1. 初始化Vue3项目(如需新建)
npm create vite@latest turf-three-demo -- --template vue
cd turf-three-demo
npm install

# 2. 安装核心依赖
npm install @turf/turf element-plus @element-plus/icons-vue three --save

三、核心功能实现:地理数据三维建模组件

1. 组件完整代码(可直接复用)

javascript 复制代码
<template>
    <div class="turf-three-modeler">
        <el-card class="panel">
            <div class="header">
                <h2>Turfjs+Three.js:地理数据的三维建模应用</h2>
                <div class="desc">
                    基于面要素创建三维地形;线要素生成三维路径;空间分析结果着色
                </div>
            </div>

            <!-- 控制面板 -->
            <div class="controls">
                <el-row :gutter="12">
                    <el-col :span="12">
                        <div class="control-card">
                            <div class="subtitle">
                                面要素坐标 (GeoJSON 坐标数组)
                            </div>
                            <el-input
                                v-model="polygonCoordsStr"
                                type="textarea"
                                :rows="6"
                                placeholder="示例: [[116.39,39.90],[116.41,39.90],[116.41,39.92],[116.39,39.92],[116.39,39.90]]"
                            />
                            <div class="inline">
                                <el-input-number
                                    v-model="terrainHeight"
                                    :min="10"
                                    :max="500"
                                    :step="10"
                                    size="small"
                                    class="inline-item"
                                    placeholder="地形高度"
                                />
                                <el-select
                                    v-model="colorMode"
                                    size="small"
                                    class="inline-item"
                                >
                                    <el-option
                                        label="按质心距离着色"
                                        value="centroid"
                                    />
                                    <el-option
                                        label="按路径距离着色"
                                        value="path"
                                    />
                                </el-select>
                                <el-button
                                    type="primary"
                                    size="small"
                                    @click="buildTerrain"
                                    class="inline-item"
                                    >生成地形</el-button
                                >
                            </div>
                        </div>
                    </el-col>
                    <el-col :span="12">
                        <div class="control-card">
                            <div class="subtitle">
                                线要素坐标 (GeoJSON 坐标数组)
                            </div>
                            <el-input
                                v-model="lineCoordsStr"
                                type="textarea"
                                :rows="6"
                                placeholder="示例: [[116.395,39.905],[116.405,39.915],[116.410,39.920]]"
                            />
                            <div class="inline">
                                <el-input-number
                                    v-model="pathRadius"
                                    :min="1"
                                    :max="50"
                                    :step="1"
                                    size="small"
                                    class="inline-item"
                                    placeholder="路径半径"
                                />
                                <el-button
                                    type="success"
                                    size="small"
                                    @click="buildPath"
                                    class="inline-item"
                                    >生成路径</el-button
                                >
                                <el-button
                                    type="warning"
                                    size="small"
                                    @click="resetScene"
                                    class="inline-item"
                                    >清空</el-button
                                >
                            </div>
                        </div>
                    </el-col>
                </el-row>
            </div>

            <!-- Three.js渲染容器 -->
            <div class="canvas-wrap">
                <div ref="threeEl" class="three-view"></div>
            </div>
        </el-card>
    </div>
</template>

<script setup>
/**
 * 组件意图:以最小投影近似将经纬度转为平面坐标,快速在 Three.js 中进行
 * 可视化挤出与路径建模;强调交互演示与空间距离着色效果,而非地理精确投影。
 */
import { ref, onMounted, onBeforeUnmount } from "vue";
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import {
    polygon as turfPolygon,
    lineString as turfLineString,
    centroid as turfCentroid,
    distance as turfDistance,
} from "@turf/turf";

// --- 1. DOM引用与Three.js核心对象管理 ---
const threeEl = ref(null); // Three.js渲染容器
let scene = null; // 场景
let camera = null; // 相机
let renderer = null; // 渲染器
let controls = null; // 轨道控制器
let terrainMesh = null; // 地形网格
let pathMesh = null; // 路径网格
let grid = null; // 网格辅助线
let light = null; // 环境光
let dirLight = null; // 方向光

// --- 2. 输入参数与状态管理 ---
// 面要素坐标字符串(便于粘贴GeoJSON坐标)
const polygonCoordsStr = ref(
    "[[116.39,39.90],[116.41,39.90],[116.41,39.92]," +
        "[116.39,39.92],[116.39,39.90]]"
);
// 线要素坐标字符串
const lineCoordsStr = ref(
    "[[116.395,39.905],[116.405,39.915],[116.410,39.920]]"
);
const terrainHeight = ref(120); // 地形高度
const pathRadius = ref(6); // 路径半径
const colorMode = ref("centroid"); // 着色模式:质心距离/路径距离

// 简易比例尺:经纬度转平面坐标(米级),牺牲精度换取演示简便
const SCALE = 100000;

// --- 3. 生命周期钩子 ---
onMounted(() => {
    initThree(); // 初始化Three.js场景
});

onBeforeUnmount(() => {
    disposeThree(); // 销毁Three.js资源,防止内存泄漏
});

/**
 * 初始化 Three.js 场景与交互控制
 * 统一在挂载时完成渲染器/相机/光照的创建,避免重复实例化带来的资源浪费
 * @throws {Error} 当容器尺寸不可用或 WebGL 初始化失败时可能抛错
 */
function initThree() {
    // 1. 创建场景
    scene = new THREE.Scene();
    scene.background = new THREE.Color(0xf7f7fb); // 浅蓝背景

    // 2. 创建相机
    const w = threeEl.value.clientWidth || 800;
    const h = threeEl.value.clientHeight || 600;
    camera = new THREE.PerspectiveCamera(60, w / h, 0.1, 50000);
    camera.position.set(0, -300, 300); // 初始视角

    // 3. 创建渲染器
    renderer = new THREE.WebGLRenderer({ antialias: true }); // 抗锯齿
    renderer.setSize(w, h);
    renderer.setPixelRatio(window.devicePixelRatio || 1); // 适配高清屏
    renderer.outputColorSpace = THREE.SRGBColorSpace; // 正确的颜色空间
    threeEl.value.appendChild(renderer.domElement);

    // 4. 创建轨道控制器(交互控制)
    controls = new OrbitControls(camera, renderer.domElement);
    controls.enableDamping = true; // 阻尼效果(平滑交互)
    controls.dampingFactor = 0.1;
    controls.target.set(0, 0, 50); // 控制器目标点

    // 5. 添加网格辅助线(便于观察空间位置)
    grid = new THREE.GridHelper(1200, 24, 0x999999, 0xdddddd);
    grid.position.set(0, 0, 0);
    scene.add(grid);

    // 6. 添加光照(增强3D效果)
    light = new THREE.AmbientLight(0xffffff, 0.6); // 环境光
    scene.add(light);
    dirLight = new THREE.DirectionalLight(0xffffff, 0.8); // 方向光
    dirLight.position.set(300, -300, 400);
    scene.add(dirLight);

    // 7. 启动渲染循环
    animate();
    // 8. 监听窗口大小变化(自适应)
    window.addEventListener("resize", handleResize);
}

/**
 * 释放 Three.js 相关资源
 * 在卸载阶段主动清理几何与材质,避免 WebGL 纹理与缓冲区泄漏
 */
function disposeThree() {
    window.removeEventListener("resize", handleResize);
    // 清理地形和路径网格
    if (terrainMesh) clearMesh(terrainMesh);
    if (pathMesh) clearMesh(pathMesh);
    // 移除辅助网格
    if (grid) scene.remove(grid);
    // 销毁控制器
    if (controls) controls.dispose();
    // 销毁渲染器
    if (renderer) {
        renderer.dispose();
        if (renderer.domElement && renderer.domElement.parentNode) {
            renderer.domElement.parentNode.removeChild(renderer.domElement);
        }
    }
    // 清空引用(便于GC回收)
    scene = null;
    camera = null;
    renderer = null;
    controls = null;
}

/**
 * 视口变化时同步相机与渲染器尺寸
 * 保持纵横比一致,避免画面被拉伸导致空间感失真
 */
function handleResize() {
    if (!renderer || !camera || !threeEl.value) return;
    const w = threeEl.value.clientWidth;
    const h = threeEl.value.clientHeight;
    camera.aspect = w / h;
    camera.updateProjectionMatrix(); // 更新相机投影矩阵
    renderer.setSize(w, h);
}

/**
 * 主渲染循环
 * 启用阻尼控制需要在每帧更新,保证交互平滑
 */
function animate() {
    if (!renderer) return;
    requestAnimationFrame(animate); // 递归调用(60fps)
    controls && controls.update(); // 更新控制器
    renderer.render(scene, camera); // 渲染场景
}

/**
 * 清空场景中的地形与路径
 * 确保重复构建时不产生叠加与内存占用
 */
function resetScene() {
    if (terrainMesh) {
        clearMesh(terrainMesh);
        terrainMesh = null;
    }
    if (pathMesh) {
        clearMesh(pathMesh);
        pathMesh = null;
    }
}

/**
 * 从场景移除并释放 Mesh 的几何与材质
 * Three 中的 GPU 资源需要显式 dispose,否则会逐帧累积
 * @param {THREE.Mesh} mesh - 目标网格
 */
function clearMesh(mesh) {
    scene.remove(mesh);
    mesh.geometry.dispose(); // 释放几何体
    // 释放材质(支持数组材质)
    if (Array.isArray(mesh.material)) {
        mesh.material.forEach((m) => m.dispose());
    } else {
        mesh.material.dispose();
    }
}

/**
 * 解析坐标字符串为数组
 * 允许用户以文本形式快速粘贴 GeoJSON 坐标
 * @param {string} str - 形如 [[lon,lat],...] 的字符串
 * @returns {Array<[number,number]>|null} 坐标数组或 null
 */
function parseCoords(str) {
    try {
        const arr = JSON.parse(str);
        if (!Array.isArray(arr)) return null;
        return arr;
    } catch (e) {
        console.error("坐标解析失败:", e);
        return null;
    }
}

/**
 * 经纬度转近似平面坐标(以首次点为本地原点)
 * 避免引入复杂投影,仅做可视化演示的相对坐标
 * @param {number} lon - 经度
 * @param {number} lat - 纬度
 * @param {[number,number]} origin - 原点经纬度
 * @returns {THREE.Vector2} 平面坐标
 */
function lonlatToXY(lon, lat, origin) {
    return new THREE.Vector2(
        (lon - origin[0]) * SCALE, // 经度转X坐标
        (lat - origin[1]) * SCALE  // 纬度转Y坐标
    );
}

/**
 * 将平面坐标还原为经纬度(相对原点)
 * 在着色计算中需回到地理空间以使用 Turf 的距离函数
 * @param {{x:number,y:number}} v - 平面坐标
 * @param {[number,number]} origin - 原点经纬度
 * @returns {[number,number]} 经纬度
 */
function vecToLonLat(v, origin) {
    return [v.x / SCALE + origin[0], v.y / SCALE + origin[1]];
}

/**
 * 生成棋盘格纹理
 * 侧面使用重复纹理以增强形体可读性,避免纯色过于单调
 * @param {number} [size=64] - 纹理尺寸
 * @param {number[]} [colors=[0x8ecae6,0x219ebc]] - 两种颜色
 * @returns {THREE.Texture} 纹理
 */
function genCheckerTexture(size = 64, colors = [0x8ecae6, 0x219ebc]) {
    const c = document.createElement("canvas");
    c.width = size;
    c.height = size;
    const ctx = c.getContext("2d");
    const s = size / 8; // 每个格子的大小
    // 绘制8x8棋盘格
    for (let y = 0; y < 8; y++) {
        for (let x = 0; x < 8; x++) {
            ctx.fillStyle =
                "#" + (x % 2 === y % 2 ? colors[0] : colors[1]).toString(16);
            ctx.fillRect(x * s, y * s, s, s);
        }
    }
    // 创建Three.js纹理
    const tex = new THREE.CanvasTexture(c);
    tex.wrapS = THREE.RepeatWrapping; // 水平重复
    tex.wrapT = THREE.RepeatWrapping; // 垂直重复
    tex.repeat.set(4, 4); // 重复次数
    tex.colorSpace = THREE.SRGBColorSpace;
    return tex;
}

/**
 * 基于面要素构建可挤出的三维地形
 * 用挤出顶面并着色演示空间分析结果在 3D 中的映射
 */
function buildTerrain() {
    // 1. 解析坐标字符串
    const coords = parseCoords(polygonCoordsStr.value);
    if (!coords || coords.length < 4) {
        console.warn("无效的面要素坐标,至少需要4个点");
        return;
    }
    const origin = coords[0]; // 以第一个点为原点

    // 2. 经纬度转平面坐标,创建Three.js形状
    const shapePts = coords.map((p) => lonlatToXY(p[0], p[1], origin));
    const shape = new THREE.Shape(
        shapePts.map((v) => new THREE.Vector2(v.x, v.y))
    );

    // 3. 挤出几何体(创建三维地形)
    const extrude = new THREE.ExtrudeGeometry(shape, {
        depth: terrainHeight.value, // 挤出高度
        bevelEnabled: false, // 禁用倒角(简化模型)
        steps: 1, // 步数(1步即可)
    });

    // 4. 添加UV属性(纹理映射)
    const uvAttr = new THREE.BufferAttribute(
        new Float32Array(extrude.attributes.position.count * 2),
        2
    );
    extrude.setAttribute("uv", uvAttr);

    // 5. 应用顶点着色(按空间距离)
    applyVertexColors(extrude, coords, origin);

    // 6. 创建材质(顶面顶点着色,侧面纹理)
    const tex = genCheckerTexture();
    const matTop = new THREE.MeshStandardMaterial({
        vertexColors: true, // 启用顶点颜色
    });
    const matSide = new THREE.MeshStandardMaterial({
        map: tex, // 侧面纹理
    });

    // 7. 创建网格并设置阴影
    const mesh = new THREE.Mesh(extrude, [matSide, matTop]);
    mesh.castShadow = true; // 投射阴影
    mesh.receiveShadow = true; // 接收阴影

    // 8. 居中放置(提升交互体验)
    const bbox = new THREE.Box3().setFromObject(mesh);
    const center = bbox.getCenter(new THREE.Vector3());
    mesh.position.z = 0;
    mesh.position.x -= center.x;
    mesh.position.y -= center.y;

    // 9. 替换旧地形
    if (terrainMesh) clearMesh(terrainMesh);
    terrainMesh = mesh;
    scene.add(terrainMesh);
}

/**
 * 基于线要素生成三维管道路径
 * 用 Catmull-Rom 曲线平滑路径,便于观察着色模式的路径距离效果
 */
function buildPath() {
    // 1. 解析坐标字符串
    const coords = parseCoords(lineCoordsStr.value);
    if (!coords || coords.length < 2) {
        console.warn("无效的线要素坐标,至少需要2个点");
        return;
    }

    // 2. 确定原点(优先用地形原点,否则用线第一个点)
    let origin = coords[0];
    if (!origin && terrainMesh) {
        origin = vecToLonLat(terrainMesh.position, [0, 0]);
    }

    // 3. 经纬度转三维坐标(Z轴为地形高度的60%)
    const pts = coords.map(
        (p) =>
            new THREE.Vector3(
                (p[0] - origin[0]) * SCALE,
                (p[1] - origin[1]) * SCALE,
                terrainHeight.value * 0.6 // 路径高度
            )
    );

    // 4. 创建平滑曲线(Catmull-Rom)
    const curve = new THREE.CatmullRomCurve3(pts, false, "centripetal", 0.5);

    // 5. 创建管状几何体
    const tube = new THREE.TubeGeometry(
        curve, // 曲线
        200, // 分段数(平滑度)
        pathRadius.value, // 半径
        16, // 圆周分段数
        false // 不闭合
    );

    // 6. 创建材质和网格
    const mat = new THREE.MeshStandardMaterial({ color: 0xff4d6d }); // 粉红色
    const mesh = new THREE.Mesh(tube, mat);

    // 7. 居中对齐
    const bbox = new THREE.Box3().setFromObject(mesh);
    const center = bbox.getCenter(new THREE.Vector3());
    mesh.position.x -= center.x;
    mesh.position.y -= center.y;

    // 8. 替换旧路径
    if (pathMesh) clearMesh(pathMesh);
    pathMesh = mesh;
    scene.add(pathMesh);
}

/**
 * 计算并写入顶面顶点颜色
 * 在顶面按空间距离归一化映射到渐变,以直观展示分析结果
 * @param {THREE.BufferGeometry} geometry - 挤出几何
 * @param {Array<[number,number]>} polyCoords - 面要素经纬度坐标
 * @param {[number,number]} origin - 原点经纬度
 */
function applyVertexColors(geometry, polyCoords, origin) {
    const positions = geometry.attributes.position;
    const count = positions.count;
    const colors = new Float32Array(count * 3); // 每个顶点3个颜色分量(RGB)

    // 1. 创建Turf面要素,计算质心
    const poly = turfPolygon([polyCoords]);
    const c = turfCentroid(poly); // 面要素质心

    // 2. 解析线要素(用于路径距离计算)
    let line = null;
    const lineCoords = parseCoords(lineCoordsStr.value);
    if (lineCoords && lineCoords.length >= 2) {
        line = turfLineString(lineCoords);
    }

    // 3. 计算每个顶点的距离值,并找到最大/最小值(用于归一化)
    let min = Infinity;
    let max = -Infinity;
    const values = new Array(count);

    for (let i = 0; i < count; i++) {
        const vx = positions.getX(i);
        const vy = positions.getY(i);
        const vz = positions.getZ(i);
        // 只处理顶面顶点(侧面不参与着色)
        if (Math.abs(vz - terrainHeight.value) > 1e-3) {
            values[i] = 0;
            continue;
        }
        // 平面坐标转回经纬度
        const lonlat = vecToLonLat({ x: vx, y: vy }, origin);
        let d = 0;
        // 按着色模式计算距离
        if (colorMode.value === "path" && line) {
            // 按到路径的最近距离计算
            let md = Infinity;
            for (let k = 0; k < line.geometry.coordinates.length; k++) {
                const lc = line.geometry.coordinates[k];
                const dd = turfDistance(lonlat, lc);
                if (dd < md) md = dd;
            }
            d = md;
        } else {
            // 按到质心的距离计算
            d = turfDistance(lonlat, c.geometry.coordinates);
        }
        values[i] = d;
        // 更新最大/最小值
        if (d < min) min = d;
        if (d > max) max = d;
    }

    // 4. 归一化距离值,并映射到颜色
    const norm = (v) => (max - min < 1e-9 ? 0 : (v - min) / (max - min));
    for (let i = 0; i < count; i++) {
        const t = norm(values[i]);
        const col = lerpColor(t); // 插值颜色
        // 写入颜色数组
        colors[i * 3] = col.r;
        colors[i * 3 + 1] = col.g;
        colors[i * 3 + 2] = col.b;
    }

    // 5. 将颜色属性添加到几何体
    geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
}

/**
 * 距离到颜色的分段线性插值
 * 使用三色渐变提升中间值的可感知性,比单一两端渐变更清晰
 * @param {number} t - [0,1] 的归一化值
 * @returns {THREE.Color} 颜色
 */
function lerpColor(t) {
    const c1 = new THREE.Color(0x2a9d8f); // 青绿色(近)
    const c2 = new THREE.Color(0xf4a261); // 橙黄色(中)
    const c3 = new THREE.Color(0xe76f51); // 砖红色(远)
    if (t < 0.5) {
        const k = t / 0.5;
        return new THREE.Color().lerpColors(c1, c2, k);
    }
    const k = (t - 0.5) / 0.5;
    return new THREE.Color().lerpColors(c2, c3, k);
}
</script>

<style scoped lang="less">
.turf-three-modeler {
    padding: 12px;

    .header {
        margin-bottom: 8px;

        h2 {
            margin: 0;
            font-size: 18px;
            color: #303133;
        }

        .desc {
            color: #666;
            font-size: 12px;
            margin-top: 4px;
        }
    }

    .controls {
        margin-top: 8px;

        .control-card {
            border: 1px solid #eee;
            border-radius: 6px;
            padding: 10px;

            .subtitle {
                font-weight: 600;
                margin-bottom: 6px;
                color: #606266;
                font-size: 14px;
            }

            .inline {
                display: flex;
                gap: 8px;
                align-items: center;
                margin-top: 8px;
                flex-wrap: wrap;

                .inline-item {
                    min-width: 120px;
                }
            }
        }
    }

    .canvas-wrap {
        margin-top: 12px;
        border: 1px solid #ddd;
        border-radius: 6px;
        overflow: hidden;

        .three-view {
            width: 100%;
            height: 560px;
        }
    }
}
</style>

2. 核心代码深度解析

(1)Turf.js 与 Three.js 的核心衔接逻辑
javascript 复制代码
// 1. Turf.js处理地理数据
const poly = turfPolygon([polyCoords]); // 创建面要素
const c = turfCentroid(poly); // 计算质心
const d = turfDistance(lonlat, c.geometry.coordinates); // 计算距离

// 2. 距离值归一化(0~1)
const norm = (v) => (v - min) / (max - min);

// 3. Three.js顶点着色
const col = lerpColor(norm(d)); // 距离映射到颜色
colors[i * 3] = col.r; // 红色分量
colors[i * 3 + 1] = col.g; // 绿色分量
colors[i * 3 + 2] = col.b; // 蓝色分量

// 4. 应用到几何体
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
  • Turf.js 职责:地理坐标处理、空间分析(质心、距离计算);
  • Three.js 职责:三维几何体创建、顶点颜色映射、可视化渲染;
  • 核心转换:将 Turf.js 计算的地理距离值,归一化后映射为 Three.js 顶点颜色,实现 "分析结果可视化"。
(2)经纬度到平面坐标的转换

为简化演示,采用简易投影(非精确地理投影):

javascript 复制代码
const SCALE = 100000; // 比例尺
function lonlatToXY(lon, lat, origin) {
    return new THREE.Vector2(
        (lon - origin[0]) * SCALE, // 经度差 × 比例尺 = X坐标
        (lat - origin[1]) * SCALE  // 纬度差 × 比例尺 = Y坐标
    );
}
  • 优点:实现简单,无需引入复杂投影库;
  • 适用场景:小范围地理数据可视化(如城市级);
  • 注意:大范围数据会有明显变形,生产环境需使用专业投影库(如 proj4js)。
(3)三维地形生成核心

使用 Three.js ExtrudeGeometry(挤出几何体)将二维面要素转为三维地形:

javascript 复制代码
const extrude = new THREE.ExtrudeGeometry(shape, {
    depth: terrainHeight.value, // 挤出高度
    bevelEnabled: false, // 禁用倒角
    steps: 1 // 步数
});
  • 双面材质设计
    • 顶面:顶点着色(展示空间分析结果);
    • 侧面:棋盘格纹理(增强 3D 立体感)。
(4)三维路径生成核心

使用CatmullRomCurve3平滑线要素,再用TubeGeometry生成管状路径

javascript 复制代码
// 创建平滑曲线
const curve = new THREE.CatmullRomCurve3(pts, false, "centripetal", 0.5);
// 创建管状几何体
const tube = new THREE.TubeGeometry(curve, 200, pathRadius.value, 16, false);
  • 200:曲线分段数(值越大越平滑);
  • 16:圆周分段数(值越大越接近圆形);
  • 路径高度设置为地形高度的 60%,避免与地形重叠。
(5)顶点着色核心逻辑

顶点着色是展示空间分析结果的核心,实现步骤:

  1. 筛选顶面顶点:仅处理 z 坐标等于地形高度的顶点;
  2. 计算距离值:按着色模式(质心 / 路径)计算每个顶点的距离;
  3. 归一化:将距离值映射到 0~1 范围;
  4. 颜色插值:将归一化值映射到三色渐变(青→橙→红);
  5. 应用颜色 :将颜色数组添加到几何体的color属性。

核心代码片段:

javascript 复制代码
// 只处理顶面顶点
if (Math.abs(vz - terrainHeight.value) > 1e-3) continue;

// 计算距离
let d = 0;
if (colorMode.value === "path" && line) {
    // 到路径的最近距离
    let md = Infinity;
    for (let k = 0; k < line.geometry.coordinates.length; k++) {
        const lc = line.geometry.coordinates[k];
        const dd = turfDistance(lonlat, lc);
        if (dd < md) md = dd;
    }
    d = md;
} else {
    // 到质心的距离
    d = turfDistance(lonlat, c.geometry.coordinates);
}

// 归一化并映射颜色
const t = norm(d);
const col = lerpColor(t);
colors[i * 3] = col.r;
colors[i * 3 + 1] = col.g;
colors[i * 3 + 2] = col.b;
(6)关键优化点
  1. 资源管理

    • 显式释放 GPU 资源:geometry.dispose()material.dispose()
    • 组件卸载时清理所有 Three.js 对象,防止内存泄漏;
    • 重复生成时先移除旧网格,避免叠加。
  2. 交互体验

    • 启用轨道控制器阻尼:enableDamping = true,实现平滑旋转 / 缩放;
    • 几何体居中对齐:通过包围盒计算中心,提升交互体验;
    • 网格辅助线:帮助用户判断空间位置。
  3. 性能优化

    • 禁用几何体倒角:bevelEnabled = false,减少顶点数量;
    • 合理的分段数:路径分段 200,圆周分段 16,平衡平滑度与性能;
    • 仅顶面着色:侧面使用纹理,减少计算量。

四、功能效果演示

1. 基础效果

  1. 启动项目后,页面显示 Three.js 三维场景(带网格辅助线);
  2. 点击 "生成地形":基于默认面坐标生成三维地形(高度 120),顶面按质心距离着色(近青、中远橙、远红);
  3. 点击 "生成路径":基于默认线坐标生成平滑三维路径(半径 6),悬浮在地形上方;
  4. 切换着色模式为 "按路径距离":地形顶面颜色按到路径的最近距离重新着色;
  5. 调整地形高度 / 路径半径:重新生成后参数生效;
  6. 交互控制:鼠标拖拽旋转场景,滚轮缩放,右键平移。

2. 交互效果

操作 效果
调整地形高度输入框 重新生成的地形高度变化
切换着色模式 地形顶面颜色映射规则变化
调整路径半径 路径粗细变化
粘贴自定义 GeoJSON 坐标 生成对应形状的地形 / 路径
点击 "清空" 移除地形和路径,保留场景

3. 关键参数示例

  • 地形高度:10~500(默认 120),值越大地形越高;
  • 路径半径:1~50(默认 6),值越大路径越粗;
  • 着色模式
    • 质心距离:颜色从质心向外渐变(近青远红);
    • 路径距离:颜色从路径向外渐变(近青远红)。

五、代码仓库地址

完整代码已上传至 Gitee,可直接克隆运行:https://gitee.com/YAY-404/turfjs-vue3-demo

六、专栏地址

本文已同步至 CSDN 专栏,可查看更多 Turf.js 实战内容:https://blog.csdn.net/m0_72065108/article/details/155226062

七、实战拓展方向

  1. 精确地理投影

    • 集成 proj4js,实现 WGS84 到 Web Mercator 的精确投影;
    • 支持自定义投影坐标系,适配不同区域的地理数据。
  2. 高级三维效果

    • 地形纹理映射:加载卫星影像纹理,贴合地形顶面;
    • 路径动态效果:添加路径动画(如流动光效);
    • 阴影优化:启用实时光影,增强立体感;
    • 材质自定义:支持用户选择地形 / 路径颜色。
  3. 更多空间分析可视化

    • 缓冲区着色:按缓冲区距离着色地形;
    • 坡度分析:计算地形坡度并着色;
    • 等高线生成:基于高度生成等高线;
    • 空间插值:基于离散点生成三维曲面。
  4. 交互能力扩展

    • 点选功能:点击地形显示对应经纬度和距离值;
    • 框选缩放:支持框选区域放大;
    • 数据导入:支持上传 GeoJSON 文件生成地形 / 路径;
    • 场景保存:支持导出当前场景为图片。
  5. 性能优化

    • 几何体简化:使用 Simplify.js 简化面要素顶点;
    • 层次细节(LOD):根据相机距离调整模型精度;
    • Web Worker:将 Turf.js 计算放入 Web Worker,避免阻塞主线程;
    • 批处理渲染:合并多个几何体,减少绘制调用。

八、常见问题排查

  1. Three.js 场景不显示

    • 原因:渲染容器未设置宽高,或初始化时机过早;
    • 解决方案:确保.three-view设置width: 100%; height: 560px,在onMounted中初始化。
  2. 坐标解析失败

    • 原因:坐标字符串格式错误(如缺少中括号、逗号);
    • 解决方案:检查坐标字符串是否符合 JSON 格式,示例:[[116.39,39.90],[116.41,39.90]]
  3. 顶点着色无效果

    • 原因:未启用vertexColors: true,或顶面顶点判断错误;
    • 解决方案:确保材质设置vertexColors: true,检查Math.abs(vz - terrainHeight.value) > 1e-3的判断逻辑。
  4. 性能卡顿

    • 原因:几何体分段数过高,或顶点数量过多;
    • 解决方案:降低TubeGeometry的分段数(如 200→100),简化面要素坐标。
  5. 内存泄漏

    • 原因:未调用dispose()释放几何体 / 材质;
    • 解决方案:在clearMesh函数中显式释放资源,组件卸载时调用disposeThree()

总结

本文通过 Turf.js + Three.js 的结合,实现了 "地理数据 → 空间分析 → 三维建模 → 可视化着色" 的完整流程:

  1. 掌握了 Three.js 核心功能(场景、相机、渲染器、几何体、材质、交互控制);
  2. 实现了 Turf.js 空间分析结果(质心、距离)到 Three.js 顶点颜色的映射;
  3. 完成了三大核心功能:面要素三维地形、线要素三维路径、空间距离着色;
  4. 梳理了 Three.js 资源管理、性能优化、交互体验的关键要点。
相关推荐
小南知更鸟2 小时前
前端静态项目快速启动:python -m http.server 4173 与 npx serve . 全解析
前端·python·http
@淡 定2 小时前
DDD领域事件详解:抽奖系统实战
开发语言·javascript·网络
CamilleZJ2 小时前
react-i18next+i18next使用
前端·i18next·react-i18next
汐泽学园2 小时前
基于Vue的幼儿绘本阅读启蒙网站设计与实现
前端·javascript·vue.js
mikan3 小时前
详解把老旧的vue2+vue-cli+node-sass项目升级为vite
前端·javascript
七宝三叔3 小时前
C# 上位机开发中的动态绑定与虚拟化
前端
安卓程序员_谢伟光3 小时前
如何监听System.exit(0)的调用栈
java·服务器·前端
爱上妖精的尾巴3 小时前
7-2 WPS JS宏 Object对象属性的查、改、增、删
前端·javascript·vue.js
小哀23 小时前
2025年总结: 我还在往前走
前端·后端·全栈