Echarts 3D Earth "冰山一角"揭秘 | 创作者训练营第二期

2,454 阅读3分钟

1.“冰山一角”

image.png 在使用echarts-gl地球的时候,会发现球体边缘是不规则的,例子见 Echarts GL Earth。代码很简单,只使用了贴图,并没有导入模型,“冰山一角”的凹凸如何形成引起了我的兴趣,于是开始了顺藤摸瓜之旅。

2.echarts-gl觅芳踪

安装好依赖之后,开始了debug之路。

  1. 注释掉heightTexture后,冰山一角凹凸性消失,形成了完美球,见下图。 image.png 于是可以推断该效果由heightTexture 配置项影响。

  2. 在echarts-gl中搜索heightTexture

image.png

发现了函数getDisplacementTexture,使用该函数的地方有四处,通过debug,最关键一处在这里(下图)。

        ecModel.eachComponent('globe', function (globeModel, idx) {
            var globe = globeModel.coordinateSystem;

            // Update displacement data
            var displacementTextureValue = globeModel.getDisplacementTexture(); // 获取置换纹理
            var displacementScale = globeModel.getDisplacemenScale(); // 获取置换比例

            if (globeModel.isDisplacementChanged()) {
                if (globeModel.hasDisplacement()) {
                    var immediateLoaded = true;
                    __WEBPACK_IMPORTED_MODULE_5__util_graphicGL__["a" /* default */].loadTexture(displacementTextureValue, api, function (texture) {
                        var img = texture.image;
                        var displacementData = getDisplacementData(img, displacementScale); //获取置换数据
                        globeModel.setDisplacementData(displacementData.data, displacementData.width, displacementData.height);
                        if (!immediateLoaded) {
                            // Update layouts
                            api.dispatchAction({
                                type: 'globeUpdateDisplacment'
                            });
                        }
                    });
                    immediateLoaded = false;
                }
                else {
                    globe.setDisplacementData(null, 0, 0);
                }

                globe.setDisplacementData(
                    globeModel.displacementData, globeModel.displacementWidth, globeModel.displacementHeight
                );
            }
        });

这里其实核心在于设置displacementData,于是重点在如何生成displacementData与如何使用displacementData

3.生成displacementData,从上面的代码块可知其通过getDisplacementData函数生成,函数定义如下:

function getDisplacementData(img, displacementScale) {
    var canvas = document.createElement('canvas');
    var ctx = canvas.getContext('2d');
    var width = img.width;
    var height = img.height;
    canvas.width = width;
    canvas.height = height;
    ctx.drawImage(img, 0, 0, width, height);
    var rgbaArr = ctx.getImageData(0, 0, width, height).data;

    var displacementArr = new Float32Array(rgbaArr.length / 4);
    for (var i = 0; i < rgbaArr.length / 4; i++) {
        var x = rgbaArr[i * 4]; // 取红色分量
        displacementArr[i] = x / 255 * displacementScale;
    }
    return {
        data: displacementArr,
        width: width,
        height: height
    };
}

该函数将图片(也就是heightTexture提供的图片)画在canvas画布上,然后拿到ImageData,ImageData是一个一维数组,包含图片的像素信息,如下图。 image.png 由于每一个像素占四个位置(rgba四个分量),所以displacementArr的长度是rgbaArr.length / 4,然后取了红色(red, i * 4)分量, 其实取蓝色分量(i * 4 + 1)、绿色分量(i * 4 + 2)都可以,或者取三者的平均值也可以,效果之后些许不同,与置换纹理图片相关。最后得到的data如下图:

image.png

  1. 使用displacementData。通过跟踪,摸到了_doDisplaceVertices这里,顾名思义,这个函数要做的是处理置换顶点,相当于将原geometry的顶点信息修改掉(怪不得会有凹凸感,原来是几何体的形状改变了。webgl的核心就是顶点着色器与片元着色器,这一部分知识可以看这里)。
    _doDisplaceVertices: function (geometry, globe) {
        // 置换顶点
        var positionArr = geometry.attributes.position.value; // 顶点的位置信息
        var uvArr = geometry.attributes.texcoord0.value; // uv坐标信息

        var originalPositionArr = geometry.__originalPosition; // 原始顶点位置信息
        if (!originalPositionArr || originalPositionArr.length !== positionArr.length) {
            originalPositionArr = new Float32Array(positionArr.length);
            originalPositionArr.set(positionArr);
            geometry.__originalPosition = originalPositionArr;
        }

        var width = globe.displacementWidth;
        var height = globe.displacementHeight;
        var data = globe.displacementData; // 我们的置换数据
        // 遍历顶点信息,更新每个位置坐标
        for (var i = 0; i < geometry.vertexCount; i++) {
            var i3 = i * 3; // 为什么乘以3, 因为每个顶点占3位
            var i2 = i * 2; // 为什么乘以2, 因为每个uv坐标占2位
            // 原始位置
            var x = originalPositionArr[i3 + 1];
            var y = originalPositionArr[i3 + 2];
            var z = originalPositionArr[i3 + 3];

            // 根据uv坐标在placementData中取scale值
            var u = uvArr[i2++];
            var v = uvArr[i2++];

            var j = Math.round(u * (width - 1));
            var k = Math.round(v * (height - 1));
            var idx = k * width + j;  
            var scale = data ? data[idx] : 0; // 每个位置的偏移量比例
            
            // 更新顶点信息,每一个点在原始点位置增加一个偏移量,这个偏移量由heightTexture图片某个像素的red值决定。
            positionArr[i3 + 1] = x + x * scale;
            positionArr[i3 + 2] = y + y * scale;
            positionArr[i3 + 3] = z + z * scale;
        }

        geometry.generateVertexNormals(); // 生成顶点法向量
        // 数据标记与更新包围盒
        geometry.dirty(); 
        geometry.updateBoundingBox(); 
    },

uv贴图可见Three.js电子书第八章

到这里,基本的逻辑跟踪就结束了。

  1. 原始的uv坐标信息在生成SphereGeometry的时候就已经确定放在attributes中了(根据widthSegmentswidthSegments生成),echarts-gl依赖的底层库是claygl,感兴趣可以查看其构造函数。

3.three.js入场

于是我想着用three.js来实现类似的效果。

three.js来构建三维就像拍一部电影,场景演员组灯光组摄像组导演组缺一不可,只需要按照顺序执行就好,简单来讲可以按下图顺序按部就班。

      // atlast
      window.onload = async function () {
        initScene(); // 场景准备就绪
        await initMesh(); // 演员准备就绪
        initLight(); // 灯光组准备就绪
        initCamera(); // 摄影组准备就绪
        initRenderer(); // 导演剪辑渲染镜头给观众

        animate(); // 开启动画Action 大家开始动
      };

贴图素材(先给需要的玩家,或者去echarts gallery抓包,掘金有水印)

earth.jpg

earth-high.jpg

普通贴图完整代码:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <style>
      html,
      body {
        height: 100%;
        margin: 0;
        padding: 0;
      }
    </style>
    <script src="three.js"></script>
    <script src="OrbitControls.js"></script>
  </head>
  <body>
    <div id="chart" style="height: 100%;"></div>
    <script>
      let container = document.getElementById("chart");
      let width = container.clientWidth; 
      let height = container.clientHeight;
      let SCENE, CAMERA, RENDERER;

      const ImageLoader = new THREE.ImageLoader();

      function initScene() {
        SCENE = new THREE.Scene();
      }

      async function initMesh() {
        let axisHelper = new THREE.AxesHelper(250);
        SCENE.add(axisHelper);

        let geometry = new THREE.SphereGeometry(5, 40, 40); // 3,2

        let img = await ImageLoader.load("./earth.jpg");

        let texture = new THREE.Texture(img);

        texture.needsUpdate = true;
        
        let material = new THREE.MeshStandardMaterial({
          map: texture,
        });

        let sphere = new THREE.Mesh(geometry, material);
        SCENE.add(sphere);
      }

      function initLight() {
        //点光源
        let point = new THREE.PointLight(0xffffff);
        point.position.set(400, 0, 0); //点光源位置
        SCENE.add(point); //点光源添加到场景中
        //环境光
        let ambient = new THREE.AmbientLight(0xffffff);
        SCENE.add(ambient);
      }

      function initCamera() {
        let k = width / height; //窗口宽高比
        let s = 10; //三维场景显示范围控制系数,系数越大,显示的范围越大

        //创建相机 CAMERA
        CAMERA = new THREE.OrthographicCamera(-s * k, s * k, s, -s, 1, 1000);
        CAMERA.position.set(200, 100, 100); //设置相机位置
        CAMERA.lookAt(SCENE.position); //设置相机方向(指向的场景对象)
      }

      function initRenderer() {
        // RENDERER
        RENDERER = new THREE.WebGLRenderer({
          antialias: true,
          alpha: true,
        });
        RENDERER.setSize(width, height); //设置渲染区域尺寸
        RENDERER.setClearColor(0x00000, 1); //设置背景颜色
        container.appendChild(RENDERER.domElement); //body元素中插入canvas对象

        new THREE.OrbitControls(CAMERA, RENDERER.domElement);
      }

      function animate() {
        RENDERER.render(SCENE, CAMERA); //执行渲染操作
        requestAnimationFrame(animate);
      }

      window.onload = async function () {
        initScene(); // 场景准备就绪
        await initMesh(); // 演员准备就绪
        initLight(); // 灯光组准备就绪
        initCamera(); // 摄影组准备就绪
        initRenderer(); // 导演剪辑渲染镜头给观众

        animate(); // 开启动画Action 大家开始动
      };
    </script>
  </body>
</html>

这样可以得到一个完美球

image.png

通过群友提示,three.jsMeshStandardMaterial材质有displacementMap(置换贴图配置),这里只需要修改initMesh

      async function initMesh() {
        let axisHelper = new THREE.AxesHelper(250);
        SCENE.add(axisHelper);

        let geometry = new THREE.SphereGeometry(5, 40, 40); // 3,2

        let img = await ImageLoader.load("./earth.jpg");

        let heightImg = await ImageLoader.load("./earth-high.jpg");

        let texture = new THREE.Texture(img);

        let heightTexture = new THREE.Texture(heightImg);

        texture.needsUpdate = true;
        
        let material = new THREE.MeshStandardMaterial({
          displacementMap: heightTexture,
          displacementScale: 1.1,
          displacementBias: 4,
          map: texture,
        });

        material.displacementMap.needsUpdate = true; // very important

        let sphere = new THREE.Mesh(geometry, material);
        SCENE.add(sphere);
      }

注意需要添加material.displacementMap.needsUpdate = true,不然置换贴图将不生效。

置换贴图(displacementMap)

image.png 这要不仅可以看到“冰山一角”,也可以看到“世界屋脊”。 于是我又尝试了法线贴图凹凸贴图

法线贴图(normalMap)

image.png

凹凸贴图(bumpMap)

image.png

4.置换贴图 vs 凹凸贴图 vs 法线贴图

  • 置换贴图:改变了Geometry的顶点位置,会产生大量的三角面,计算量极大,吃计算机配置(显卡、内存、CPU),效果最好,因为其实真正的模拟,凸出与凹入都会与光照阴影结合。

  • 凹凸贴图:凹凸贴图光照和材质在3D模型的表面制造出凹凸不平的质感的错觉。使用灰度(Grayscale)图和简单的光影技巧在对象的表面人为地制造这种质感,而不是真的在其表面扣出一个个的凸起和凹入。当灰度值在50%附近时,物体表面几乎不会有什么细节变化。当灰度值变亮(白),表面细节呈现为凸出,当灰度值变暗(黑),表面细节呈现为凹入。用凹凸贴图实现模型的微小细节非常棒。比如,皮肤上的毛孔和褶皱。

  • 法线贴图:法线贴图就是在原物体的凹凸表面的每个点上均作法线,通过RGB颜色通道来标记法线的方向,你可以把它理解成与原凹凸表面平行的另一个不同的表面,但实际上它又只是一个光滑的平面。对于视觉效果而言,它的效率比原有的凹凸表面更高,若在特定位置上应用光源,可以让细节程度较低的表面生成高细节程度的精确光照方向和反射效果。(百度百科)

如何抉择:小细节用法线贴图或者凹凸贴图,大的轮廓使用置换贴图。

5.最后

claygl vs three

dead game dota2 based on claygl example

image.png

dead game dota2 based on claygl code 里面有英雄模型数据哟