创建带地形的3D区域地块

6,480 阅读13分钟

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!

介绍

最近再次潜心投入到three.js官方开发文档的阅读中,特别是在对网格体(Mesh)材质及其细致入微的配置研究上,我取得了显著的进步,以往在材质渲染方面存在的疑惑与盲点渐渐被点亮,理解更加透彻。

在这过程中顺便攻克了一个长久以来困扰我的难题——如何在WebGL中,利用three.js实现既美观又逼真的带凹凸地形效果的立体行政区域地块展现。这不仅仅关乎简单的几何形状构建,更涉及到了如何巧妙运用材质属性,比如法线贴图(Normal Mapping)和高度贴图(Height Mapping),来模拟复杂的地形起伏与光照效果,使得虚拟的地块不仅边界清晰,而且表面细节丰富,立体感十足。

本文将分享这一过程,希望能帮助到同样在webGL学习道路上探索的开发者们,一同享受技术带来的创造乐趣与成就感。

displacementScale7.gif

准备数据

实现上文的效果需要准备一些基础数据,以下数据我们都可以通过开源的免费工具QGIS获取,感兴趣可以看看我之前的文章都有所提及,比如这个《如何从无到有创造GIS数据》

  1. 行政区域的边界坐标,需要geoJSON格式的边界信息,它决定了整个网格体的基础边界线、顶点、最终轮廓。

    Untitled.png

  2. 区域的地形高度图,储存了地图上每个点的高度信息,通常是灰度图,颜色越白,表示该点的z轴(朝上)位置越高。对应了THREE中的凹凸贴图(bumpMap)和位移贴图(displacementMap ),这两者的都是用于制造表面凹凸感的,本案例使用它来做位移贴图。

    Untitled 1.png

    凹凸贴图(或者法线图)只造成视觉上的起伏,表面实际上还是平整的,优点是开销比较小,缩放网格体时效果稳定,但尽显与特定角度有理想的效果

    而位移贴图会对网格体的顶点位置造成影响,它需要更多的顶点支持,性能开销相对大,能够创造出更加复杂和细节丰富的表面。

    displacementScale8.gif

  3. 卫星影像图,用于覆盖在最上面的纹理贴图,就是常见的位图,为了让最终效果更美观,建议可以适当调整图片的饱和度和对比度。

    %E5%8D%AB%E6%98%9F%E5%BD%B1%E5%83%8F.jpg

  4. 法线图,储存了地图上每个点的表面法线方向信息,配置了法线图的网格体,当光线照射到它表面时能够呈现相应的阴影效果,加强立体感。发线图可以使用photoshop在卫星影像图的基础上直接生成,操作步骤“菜单栏-滤镜-3D-生成法线”即可。

    %E6%B3%95%E7%BA%BF.jpg

  5. 透明度贴图,是一张灰度纹理,用于控制整个表面的不透明度。(黑色:完全透明;白色:完全不透明)它用于控制顶部网格体在视觉上的边缘形状,这张图可以使用“行政区域的边界坐标”动态创建。

    alphaMap.jpg

实现思路

1. 创建相关资源

创建相关的资源,根据区域边界的geoJSON数据创建矩形范围extRange(xy最大值坐标和xy最小值坐标),并根据配置的图片路径准备好各种纹理贴图。

网格体所需资源
topMesh普通贴图textureMap,法线贴图normalMap,位移贴图displacementMap,透明度贴图alphaMap
sideMesh普通贴图textureMap

2.创建顶部网格体

本文重点,创建顶部网格体,通过处理增加顶部网格体地形的立体感。

  1. 创建顶部几何体,我们通过extRange创建一个矩形平面,并对它进行几何体细分,目的是产生更多的顶点,为后续的地形效果做铺垫。至于为什么使用extRange创建矩形平面,而不是用区域边界的geoJSON数据创建多边形平面,是因为矩形平面细分后的顶点verts更加有规律和可控。

    %E6%8F%92%E5%9B%BE1.jpg

  2. 重要步骤,调整好顶部几何体的UV坐标,相当于告诉THREE外表的纹理应该怎么贴图。

    %E5%A3%B0%E6%98%8EUV%E5%9D%90%E6%A0%87.jpg

  3. 给顶部网格体上材质,我们找一个能够受到光照影响的材质,比如MeshStandardMaterial。这里配置材质的参数就要用到步骤1准备好的贴图:displacementMap可以影响顶点verts的位置高低造成真实凹凸感,法线贴图在光线作用下会呈现阴影效果,普通贴图则决定了网格体的皮肤外观。

    %E5%A4%9A%E5%B1%82%E5%8F%A0%E5%8A%A0.jpg

  4. 增加光照环境,这样法线贴图才能发挥作用

    %E6%9C%89%E6%97%A0%E5%85%89%E7%85%A7.jpg

3. 创建侧面网格体

使用区域边界给JSON数据创建侧边网格体,这个基本的操作可以看看古早的教程在高德地图实现可定制立体区块

4.创建顶部边界线

使用区域边界给JSON数据生成“边界线”网格体,以加强边界的“锐利感”,后续如果有多个区域拼接起来也是靠这个“边界线”区分地块的,另外这个边界线也可以做一些动画效果,比如光点沿着路线流动之类的。

5.其他装饰和补充

  1. 增加其他图层做装饰,比如激光图层、底部旋转的光圈图层等等,这个在下期分享。

  2. 增加数据图层,比如子区域行政中心的POI、分布热力图、柱状图等等,适当增加一些交互行为。

    displacementScale6.gif

核心代码实现

  1. 声明属性变量,规划整体业务逻辑

    class TerrainPolygonLayer extends Layer {
    	// 区域几何体的矩形范围盒子
    	_extRange = {
    	  minX: 0,
    	  minY: 0,
    	  maxX: 0,
    	  maxY: 0
    	}
    	
    	// 顶部网格体
    	_topMesh = null
    	// 缓存顶部网格体的相关属性
    	_topMeshProps = {}
    	
    	// 侧边网格体
    	_sideMesh = null
    	// 缓存侧边网格体的相关属性
    	_sideMeshProps = {}
    	
    	constructor (config) {
            // ...
            super(config)
            this.initData(conf.data)
    	}
    	
    	initData(){
            //使用高德的customCoords.lngLatsToCoords转换geoJSON地理坐标
    	}
    	
    	// Layer已准备好
    	async onReady () {
    	  this.initExtRange()
    	  await this.initTexture()
    	  this.createTopMesh()
    	  this.createSideMesh({ height: this._conf.altitude })
    	  if (this._conf.lineWidth > 0) {
    	    this.createEdge()
    	  }
    	  this.initLight()
    	}
    }
    
  2. 计算矩形范围

    initExtRange () {
      const positions = this._data[0].path
    
      let minX = positions[0][0]
      let minY = positions[0][1]
      let maxX = positions[0][0]
      let maxY = positions[0][1]
    
      for (let i = 0; i < positions.length; i += 3) {
        const [x, y] = positions[i]
        if (x < minX) {
          minX = x
        } else if (x > maxX) {
          maxX = x
        }
        if (y < minY) {
          minY = y
        } else if (y > maxY) {
          maxY = y
        }
      }
      this._extRange = { minX, minY, maxX, maxY }
    }
    
  3. 准备贴图资源

    async initTexture () {
      // 顶部网格体
      const topTextureArr = ['textureMap', 'normalMap', 'displacementMap']
      for (let i = 0; i < topTextureArr.length; i++) {
        const name = topTextureArr[i]
        const url = this._conf[`${name}URL`]
        const textureMap = await new THREE.TextureLoader().load(url)
        textureMap.wrapS = THREE.RepeatWrapping // 在U方向(水平)平铺
        textureMap.wrapT = THREE.RepeatWrapping // 在V方向(垂直)平铺
        this._topMeshProps[name] = textureMap
      }
      this._topMeshProps.alphaMap = this.generateAlphaMap()
    
      // 侧边网格体
      const texture = await new THREE.TextureLoader().load(this._conf.sideTextureMapURL)
      texture.wrapS = THREE.RepeatWrapping
      texture.wrapT = THREE.RepeatWrapping
      texture.offset.set(0, 1)
      this._sideMeshProps.textureMap = texture
    }
    
  4. 创建顶部网格体,侧边和顶部边界线的代码实现可以看往期文章在高德地图实现可定制立体区块。这里需要重点关注如何细分几何体,以及如何生成alphaMap

    createTopMesh () {
      const { normalScale, displacementScale } = this._conf
    
      const {
        textureMap,
        normalMap,
        displacementMap,
        alphaMap
      } = this._topMeshProps
    
      // 创建材质
      const material = new THREE.MeshStandardMaterial({
        side: THREE.DoubleSide,
        map: textureMap,
        normalMap,
        normalScale: new THREE.Vector2(normalScale, normalScale), // 法线贴图对材质的影响程度
        alphaMap,
        displacementMap,
        displacementScale,
        displacementBias: 0,
        wireframe: false,
        transparent: true,
        alphaTest: 0.1 // 该值必须大于0,以保证透明本图层的透明区域不会遮挡其他图层
      })
      // 几何体
      const geometry = this.generateTopGeometry()
    
      const mesh = new THREE.Mesh(geometry, material)
      mesh.position.set(0, 0, this._conf.altitude)
      this.scene.add(mesh)
    
      this._topMesh = mesh
    }
    
    // 获取顶部几何体
    generateTopGeometry () {
      const { segment } = this._conf
      const { minX, minY, maxX, maxY } = this._extRange
      const coords = [
        [minX, minY],
        [minX, maxY],
        [maxX, maxY],
        [maxX, minY]
      ]
      // 创建多边形
      const path = new THREE.Shape()
      coords.forEach(([x, y], index) => {
        if (index === 0) {
          path.moveTo(x, y)
        } else {
          path.lineTo(x, y)
        }
      })
      // 创建几何体
      let geometry = new THREE.ShapeGeometry(path)
    
      // 细分出更多顶点
      const tessellateModifier = new TessellateModifier(0.1, segment)
      geometry = tessellateModifier.modify(geometry)
    
      // 创建 UV 属性并将其设置到几何体
      const uvArray = this.getGeometryUV(geometry)
      const uvAttribute = new THREE.BufferAttribute(uvArray, 2)
      geometry.setAttribute('uv', uvAttribute)
    
      return geometry
    }
    
    /**
     * 通过边缘坐标数据获取灰度图纹理
     * @private
     */
    generateAlphaMap () {
      const polygonVertices = this._data[0].path.map(([x, y]) => {
        return new THREE.Vector3(x, y, 0)
      })
    
      // 行政区域的矩形范围
      const { minX, minY, maxX, maxY } = this._extRange
    
      // 计算缩放比例
      const maxDimension = Math.max(maxX - minX, maxY - minY)
      // 限制画布的最大宽和高
      const scale = this._CANVAS_MAX_LEN / maxDimension
      // 缩放多边形顶点的坐标
      const scaledVertices = polygonVertices.map(vertex => {
        const x = (vertex.x - minX) * scale
        const y = (vertex.y - minY) * scale
        return new THREE.Vector3(x, y, vertex.z)
      })
    
      // 计算调整后的画布大小
      const width = Math.ceil((maxX - minX) * scale)
      const height = Math.ceil((maxY - minY) * scale)
    
      const canvas = this.generateCanvas({width, height})
      const context = canvas.getContext('2d')
      // 绘制多边形
      context.fillStyle = '#000000' // 设置背景颜色为黑色
      context.fillRect(0, 0, width, height)
      context.fillStyle = '#FFFFFF' // 设置多边形颜色为白色
      context.beginPath()
      context.moveTo(scaledVertices[0].x, scaledVertices[0].y)
      for (let i = 1; i < scaledVertices.length; i++) {
        context.lineTo(scaledVertices[i].x, scaledVertices[i].y)
      }
      context.closePath()
      context.fill()
    
      return new THREE.CanvasTexture(canvas, null, THREE.RepeatWrapping, THREE.RepeatWrapping)
    }
    
    generateCanvas ({ width, height }) {
      // 创建画布和上下文
      const canvas = document.createElement('canvas')
      canvas.width = width
      canvas.height = height
      const context = canvas.getContext('2d')
    
      // Canvas调整为与UV坐标系一致
      // 坐标垂直翻转,原点定位在左下角
      context.translate(0, height)
      context.scale(1, -1)
      return canvas
    }
    
    /**
      * 获取几何体归一化之后的UV坐标
      * @param geometry
      * @return {Float32Array}
     */
    getGeometryUV (geometry) {
        // 获取所有顶点数量
        const vertexCount = geometry.attributes.position.count
        // 创建 UV 坐标数组
        const uvArray = new Float32Array(vertexCount * 2)
        const { minX, minY, maxX, maxY } = this._extRange
        // 设置 UV 坐标
        for (let i = 0; i < vertexCount; i++) {
          const vertexIndex = i * 2
          // UV坐标归一化
          const u = (geometry.attributes.position.getX(i) - minX) / (maxX - minX)
          const v = (geometry.attributes.position.getY(i) - minY) / (maxY - minY)
          uvArray[vertexIndex] = u
          uvArray[vertexIndex + 1] = v
        }
        return uvArray
    }
    
    
  5. 创建光照环境

     /**
     * 增加方向光照,使法线贴图产生效果
     */
    initLight () {
      const directionalLight = new THREE.DirectionalLight(0xffffff, this._conf.intensity)
      directionalLight.position.set(1, 1, 1)
      this.scene.add(directionalLight)
    }
    

核心问题

  1. 如何调整不规则面的纹理贴图,可以达到平铺、拉伸等效果?

    首先要保证UV坐标是正确的,否则无论怎么调整都是无效操作;

    如果要实现图像纹理的拉伸、位移、平铺等操作,可以参考three.js自带的基础网络材质Texture,使用Texture自带的几个参数可以解决纹理的操作问题。

    参数类型说明
    offsetVector2贴图单次重复中的起始偏移量,分别表示U和V。 一般范围是由0.0到1.0。
    repeatVector2决定纹理在表面的重复次数,两个方向分别表示U和V,repeat.set(s,t),s和t小于1时相当于把纹理图放大
    rotationnumber纹理将围绕中心点旋转多少度,单位为弧度(rad)。正值为逆时针方向旋转,默认值为0
    centerVector2旋转中心点。(0.5, 0.5)对应纹理的正中心。默认值为(0,0),即左下角
  2. 如何细分几何体?

    three.js自带了一个插件TessellateModifier,生成更多三角面和顶点。

    import { TessellateModifier } from 'three/addons/modifiers/TessellateModifier.js';
    
    /*
     * 细分几何体的专用工具
     * @param {Number} maxEdgeLength 最大边长超过该值都会被细分
     * @param {Number} maxIterations 最大迭代次数6
     */
    class TessellateModifier {
      
    	constructor( maxEdgeLength = 0.1, maxIterations = 6 ) {
            this.maxEdgeLength = maxEdgeLength;
            this.maxIterations = maxIterations;
    	}
    	modify( geometry ) {
    	    // 调用该方法对指定几何体进行面细分
    	}
    }
    	
    // 调用细分工具
    const tessellateModifier = new TessellateModifier(0.1, 5)
    geometry = tessellateModifier.modify(geometry)
    
  3. 细分后出现破面应该怎么办?

    TessellateModifier对于规则的面细分效果非常好,对不规则的多边形的细分会出现破面问题,原因是细分后会出现边缘线平行的状态,一旦调整顶点z轴高度造成高度不一致,该问题就会暴露出来。解决方法是再次调用一次细分。

  4. 为什么顶部面几何体使用的了行政区域geoJSON的矩形范围,而不是直接用多边形几何体?

    这个看具体情况,使用规则平面和不规则平面实现分别有优缺点,但不规则平面的最大问题是细分复杂的多边形几何体经常出现破面问题,且多边形越复杂,破面概率越高,归根结底是这个细分算法的局限性。

    image.png

  5. 凹凸贴图和法线贴图的区别

    法线贴图(normalMap)是一种特殊的纹理贴图,它存储了每个像素表面的法线方向信息。法线贴图通常使用RGB通道来表示法线方向。法线贴图可以模拟微小的凹凸表面细节,从而使光照对表面产生更加真实的效果。当光照计算使用法线贴图时,它会根据法线方向的变化来调整光照的强度和方向。

    凹凸贴图(bumpMap)也用于模拟表面的凹凸效果。与法线贴图不同,凹凸贴图使用灰度值来表示表面的高度变化。较亮的区域表示凸起,较暗的区域表示凹陷。凹凸贴图通过在顶点着色器中修改表面的顶点位置来模拟凹凸效果。这种方式比法线贴图更具性能要求,因为它需要对几何体进行修改。

待改进内容

  1. 支持多个行政区域数据

    目前的代码仅仅为了做技术调研,仅支持单个行政区域。后续如果要调整为支持多个行政区域的数据,贴图方面可以需要做一些处理。比如给广东省的下级市构建可单独交互的网格体,每个网格体需要单独贴图。如果把每个市的地形贴图、法线贴图、卫星影像贴图单独拆分,这样的工作太繁琐了,应该可以共享一个大贴图,并通过给每个网格体调整贴图的offset、repeat、center等参数即可。

    ea1656e84c468819ed08a5bb2dec061c28361fef735357-eeDJ4F_fw658.webp

  2. 带地形的顶部网格体和侧面网格体的边缘接缝问题。

    一开始我还很奇怪找遍了网络,为什么带地形的区域块都只是用凹凸贴图和法线贴图实现视觉上的凹凸感,并没有几个案例是做了一个真正的凹凸面的,等自己真正去实现的时候才发现了难度所在。

    如果使用位移贴图去影响顶部面的顶点,那么边缘区域的顶点也会跟着受影响而变得高低不一致,这样一来,我们的侧边网格体也需要同步调整顶点高低才能适应它,否则顶部和侧边的接缝就会非常明显割裂。

    displacementScale4.gif

    对于这个问题,我一开始使用了“位移图叠加行政边界,重新计算顶点高度”的方法去解决,但法线效果不理想,还是会有接缝空隙,原因是顶部面的顶点和侧边的顶点并不是一一对应的,而问题是我目前拿不到alphaMap影响后的顶部面的边缘顶点newVerts,如果可以的话通过newVerts生成侧面网格体应该就没啥问题了。

    为了收缩战线,我还是想了一个比较鸡贼的办法,就是直接把位移图displacementMap往内收缩了一下,这样才能保证区域边缘的所有顶点z轴高度值是一致的,就非常好处理了,留了个坑等后面再说。

相关链接

Threejs中给自定义形状进行UV贴图

Three.js官方示例 位移贴图的使用演示