立体感十足的数据可视化:我的WebGL 3D环状图制作分享

前言

在我们平常的大屏可视化需求很有可能会使用3d图表,一般可能直接就调echarts库,有点复杂性的threejs也能满足需求。那么我们今天用webgl写个饼图或环状图来练练手。

预览

立体感十足的数据可视化:我的WebGL 3D环状图制作分享

组装思想

立体感十足的数据可视化:我的WebGL 3D环状图制作分享

从网上找了个类似的饼图,我们在写这个图的时候,首先要明白最重要的是点的定位,一旦骨架确定后后面会很容易,而这里稍微复杂的也仅仅是三角函数确定圆上的坐标。

从最小单元出发,我们先来实现一个区域的3d扇形图,但是真的有必要写个3d扇形区么,我们继续拆分,一个3d扇形区,由顶面、底面、侧面、两块挡板组成。

所以我们从最简单的开始做起,实现一个2d扇形图,这就是为什么2d的基础如此重要,3d无非多了个z轴。

平面圆形

   function createCircleVertex (x, y, radius, n) {
      let positions = [x, y, 255, 255, 0, 1];
      for (let i = 0; i <= n; i++) {
        let angle = i * (Math.PI * 2 / n);
        positions.push(
          x + radius * Math.sin(angle),  // x
          y + radius * Math.cos(angle),  // y
          100, // r值
          0,   // g值
          100,   // b值
          1    // a值
        )
      }
      return positions;
    }

这里主要是n的值,我们知道圆是由大量的三角面组成的,所以n的值越大,意味着你的圆越圆滑,如果只有3个点,那么意味着你的圆是个三角形。通过传入弧长,通过三角函数得出x和y坐标点。这样画3d的圆环无非多了个坐标y。为什么不是z呢,看完下面的坐标系你就明白了。

这里将顶点和图元着色器的值放在了一个缓冲区,这样只要区分下加载方式就可以, 当然用索引也是一个好方法。

    gl.vertexAttribPointer(a_Position, 2, gl.FLOAT, false, 24, 0);
    gl.vertexAttribPointer(a_Color, 4, gl.FLOAT, false, 24, 8);

立体感十足的数据可视化:我的WebGL 3D环状图制作分享

最终的图形效果好像有点疑问了,为什么不是纯色呢,因为gpu渲染每个像素在光栅化的时候根据每个三角面的顶点做了插值处理,也就是渐变, 所以一旦我们把圆心点改为100,0,100, 1,你可以发现就是纯色了。

坐标系

坐标系是一个相对的概念,用以做参照更方便的观察,参照的目的是为了更好的处理控制点。所以你能看到比如模型坐标系,世界坐标系,屏幕坐标系,裁剪坐标系,透视坐标系等。

  1. 模型坐标系: 一个npc的身体往往不同部分往往需要单独控制,不然无法行走,我们的点完全可以参照npc的中心点做控制,一旦这个点移动,做相对位移就行。或者相对中心点做单独移动。
  2. 世界坐标系: 你可以理解为游戏场景无限大的空间,中心处就是原点
  3. 屏幕坐标系,也就是我们由gpu计算后光栅化的可视化界面,我们一般用css的定位,左上角为原点来写,但是gpu是裁剪坐标,无非跟glsl做个转换就行。
  4. 裁剪坐标系,在gpu中处理,也就是[-1, 1]的范围,超出区域将被裁剪
  5. 透视坐标系, 透视遵循近大远小的原则就可以,无非就是xyzw的w控制,当然透视你得考虑视口的近平面和远平面,这类似一个由小变大的方盒子,不在区域内的将被裁剪。

左右手坐标系原则

  1. 左手坐标系原则, 裁剪坐标系就是遵循左手坐标系

立体感十足的数据可视化:我的WebGL 3D环状图制作分享

  1. 右手坐标系原则, 我们平常在屏幕坐标系一般用右手坐标系, 所以我们需要将屏幕与裁剪做个转换,注意z轴方向是往外部的,y轴是向上的。

立体感十足的数据可视化:我的WebGL 3D环状图制作分享

createRingVertices 创建环的顶点

  function createRingVertices (
    innerRadians, // 内环半径
    outerRadians,// 外环半径
    xGrids = 20, // 弧形的精度
    startPI = 0,
    endPI = Math.PI * 1,
    y = 1
  ) {
    // 定义点的空间大小
    const numVertices = xGrids * 2 + 2; //顶点数量
    const positions = webglUtils.createAugmentedTypedArray(3, numVertices);
    const normals = webglUtils.createAugmentedTypedArray(3, numVertices);
    const texCoords = webglUtils.createAugmentedTypedArray(2, numVertices);
    const indices = webglUtils.createAugmentedTypedArray(3, xGrids * 2, Uint16Array); //索引数
    
    // 计算文本的位置
    let ringTextList = []
    let mid = xGrids >> 1

    for (let i = 0; i <= xGrids; i++) {
      let angle = (endPI - startPI) * (i / xGrids) + startPI;
      if (mid === i && y > 1) {
        let midRadians = innerRadians + (outerRadians - innerRadians) / 2
        textPointList.push([midRadians * Math.sin(angle), y, midRadians * Math.cos(angle), 1])
      }
      // 内环
      positions.push(
        innerRadians * Math.sin(angle),
        y,
        innerRadians * Math.cos(angle),
      );
      // 外环
      positions.push(
        outerRadians * Math.sin(angle),
        y,
        outerRadians * Math.cos(angle),
      )
      // normals.push(1, 1, 1)
      // texCoords.push(1, 1)
    }
    
    // 索引对应3个顶点xyz
    for (let i = 0; i < xGrids; i++) {
      let p0 = i * 2; // 第0个顶点
      let p1 = i * 2 + 1; // 第1个顶点
      let p2 = (i + 1) * 2 + 1; // 第3个顶点
      let p3 = (i + 1) * 2; // 第2个顶点
      if (i == xGrids) {
        p2 = 1;
        p3 = 0;
      }
      indices.push(p0, p1, p2, p2, p3, p0)
    }

    return {
      position: positions,
      normal: normals, // 法线
      texcoord: texCoords, // 纹理
      indices: indices,
    };
  }

实际真正在做的时候,我们会做大量的封装,这里只展示核心逻辑, 绘制2d圆的方法,后面我们只要做组装就行。纹理uv坐标和法向量后序会讲解。这样就完成顶部环形平面了,底部就修改y值就行,侧面、档板的面稍加计算就可得出。

逆矩阵

function drawScene (time) {
      time *= 0.0005;
      // ...
      var matrix = m4.identity();
      var aspect = gl.canvas.clientWidth / gl.canvas.clientHeight;
      // 投影矩阵
      var projectionMatrix =
        m4.perspective(fieldOfViewRadians, aspect, 1, 2000);
        
       // 视图矩阵
      var cameraPosition = [0, 10, 30]; //相机坐标
      var target = [0, 0, 0]; //目标点
      var up = [0, 1, 0]; //整体抬升相机和目标点
      var cameraMatrix = m4.lookAt(cameraPosition, target, up);
      
      // 逆矩阵
      var viewMatrix = m4.inverse(cameraMatrix);
      
      var translation = [1, -10, 1]
      var scale = [0.2, 0.2, 0.2]
      matrix = m4.multiply(projectionMatrix, viewMatrix);

      matrix = m4.translate(matrix, translation[0], translation[1], translation[2]);
      matrix = m4.scale(matrix, scale[0], scale[1], scale[2]);
      matrix = m4.yRotate(matrix, time)
      requestAnimationFrame(drawScene)
 }
 requestAnimationFrame(drawScene)

逆矩阵的目的仅仅是为了不用移动相机,通过计算物体的移动的矩阵,做个逆向的计算。这样我们就不用麻烦的调整相机位置,可以很方便的观察物体。

绘制文字

var canvas = document.getElementById('text')
canvas.style.width = window.innerWidth + 'px'
canvas.style.height = window.innerHeight + 'px'
var ctx = canvas.getContext('2d')
ctx.canvas.width = window.innerWidth
ctx.canvas.height = window.innerHeight

function drawText (textOpts) {
    ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
    ctx.font = '20px 宋体'
    ctx.strokeStyle = '#fff'
    // 绘制空心文字
    for (const opt of textOpts) {
      ctx.strokeText(opt.text, opt.x, opt.y)
    }
}

绘制文字一般我们可以用canvas、svg、操作dom来轻松绘制,只要传入对应的xy坐标即可, 坐标我们在绘制点的时候去取圆弧的中心值即可。当然这里要注意下canvas的坐标系和裁剪坐标系要做个转换。

var clipspace = m4.transformVector(matrix, point);
clipspace[0] /= clipspace[3];
clipspace[1] /= clipspace[3];
var pixelX = (clipspace[0] * 0.5 + 0.5) * gl.canvas.width;
var pixelY = (clipspace[1] * -0.5 + 0.5) * gl.canvas.height;
textOptList.push({ text: object.opt.h, x: pixelX, y: pixelY })

通过matrix的矩阵和point之前弧形的中心值的顶点坐标,可以反推出坐标,然后除以w,最后反推根据canvas宽高得到屏幕坐标系的xy,这样我们的文字就可以跟随环移动了。

立体感十足的数据可视化:我的WebGL 3D环状图制作分享

绘制饼图

   function getObjectsToDrawObj (opt = { ir: 80, or: 100, w: 20, spi: Math.PI, epi: Math.PI * 2, h: 40, color: [0.5, 1, 0.5, 1] }) {
      const { ir, or, w, spi, epi, h, color } = opt
      const createRingWithVertexColorsBufferInfo = createFlattenedFunc(createSphereVertices, 200)
      const createRingWithVertexColorsBufferInfoRadius = createFlattenedFunc(createSphereVerticesRadius, 100)
      const createRingWithVertexColorsBufferInfoGuard = createFlattenedFunc(createSphereVerticesGuard, 80)

      const topPlane = createRingWithVertexColorsBufferInfo(gl, ir, or, w, spi, epi, h);
      const bottomPlane = createRingWithVertexColorsBufferInfo(gl, ir, or, w, spi, epi, 1);
      const guardOuterPlane = createRingWithVertexColorsBufferInfoGuard(gl, ir, or, w, spi, epi, h, 'outer');
      const guardInnerPlane = createRingWithVertexColorsBufferInfoGuard(gl, ir, or, w, spi, epi, h, 'inner');
      const outerPlane = createRingWithVertexColorsBufferInfoRadius(gl, or, w, spi, epi, h);
      const innerPlane = createRingWithVertexColorsBufferInfoRadius(gl, ir, w, spi, epi, h);
      const planeTypes = [topPlane, bottomPlane, guardOuterPlane, guardInnerPlane, outerPlane, innerPlane]
      planes.push(planeTypes)
      planesCompNum = planeTypes.length

      for (const plane of planeTypes) {
        let uniformInfo = {
          u_colorMult: color,
          u_matrix: m4.identity(),
        }
        sphereUniformsList.push(uniformInfo)
        objectsToDraw.push({
          programInfo: programInfo,
          bufferInfo: plane,
          uniforms: uniformInfo,
          opt: opt
        })
      }
    }
    getObjectsToDrawObj({ ir: 0, or: 90, w: 20, spi: 0, epi: Math.PI * 0.4, h: 30, color: [0.3, 1, 0.5, 1] })
    getObjectsToDrawObj({ ir: 0, or: 90, w: 20, spi: Math.PI * 0.4, epi: Math.PI * 1, h: 20, color: [0.5, 0.5, 0.8, 1] })
    getObjectsToDrawObj({ ir: 0, or: 90, w: 30, spi: Math.PI * 1, epi: Math.PI * 2, h: 40, color: [0.9, 0.3, 0.3, 1] })

饼图跟环形图区别,也仅仅就是内半径,修改ir为0,就ok了。

立体感十足的数据可视化:我的WebGL 3D环状图制作分享

闲聊

最近在思考,好像webgl门槛有点高,看的人不是很多,由于webgl的一些数学、图形理论确实是很头疼,包括自己在写的时候很容易因为一个小的细节而琢磨很久,其实webgl的现成的封装库有很多,玩玩webgl能更好的理解本质,又未尝不是一个好处。

如果觉得文章对你有帮助,不要忘了一键三连 👍

附录

  1. 内卷年代,是该学学WebGL了 – 掘金 (juejin.cn)
  2. 为什么我的WebGL开发这么丝滑 🌊 – 掘金 (juejin.cn)
  3. 卷不动?学学简单的webGL矩阵算法 – 掘金 (juejin.cn)

原文链接:https://juejin.cn/post/7218924538183581733 作者:谦宇

(0)
上一篇 2023年4月7日 上午10:54
下一篇 2023年4月7日 上午11:04

相关推荐

发表回复

登录后才能评论