X3D:现代Web的声明式3D技术

现代Web技术使开发人员能够创建干净而视觉丰富的用户体验,这些体验被所有主流浏览器作为标准进行广泛支持。那么,如何为Web编写基于标准的可视化程序呢?对3D图形的支持到底又有哪些呢?让我们首先回顾HTML标准中支持的两种主要方法:SVG和Canvas。

SVG:可伸缩的矢量图形

SVG本身是基于XML的一种独立的数据格式,用于声明式的2D矢量图形。但是,它也可以嵌入到HTML文档中,这是所有主流浏览器都支持的。

让我们考虑一个例子,如何使用SVG绘制一个可调整大小的圆:

<html style="height: 100%; width: 100%">
  <body style="height: 100%; width: 100%; margin: 0px">
    <svg style="height: 100%; width: 100%; display: block" viewBox="0 0 100 100">
      <circle cx="50" cy="50" r="25" fill="red" stroke="black"
              vector-effect="non-scaling-stroke" />
    </svg>
  </body>
</html>

想要理解这段代码很容易!我们只是向浏览器描述了要绘制什么(与传统HTML文档非常相似)。它保留了这个描述,并负责如何在屏幕上绘制它。

当浏览器窗口调整大小或缩放时,它将重新缩放图像,而不会丢失图像的任何质量(因为图像是根据形状定义的,而不是根据像素定义的)。当SVG元素被JavaScript代码修改时,它还会自动重新绘制图像,这使得SVG特别适合与JavaScript库(如D3)一起使用,D3将数据绑定到DOM中的元素,从而能够创建从简单图表到更奇特的交互式数据可视化的任何内容。

这种声明性方法也称为保留模式图形绘制(retained-mode graphics rendering)。

画布

canvas元素只是在网页上提供了一个可以绘图的区域。使用JavaScript代码,首先从画布获取上下文,然后使用提供的API,定义绘制图像的函数。

const canvas = document.getElementById(id);
const context = canvas.getContext(contextType);

// call some methods on context to draw onto the canvas

当脚本执行时,图像立即绘制成了底层位图的像素,浏览器不保留绘制方式的任何信息。为了更新绘图,需要再次执行脚本。重新缩放图像时,也会触发更新绘图,否则,浏览器只会拉伸原始位图,导致图像明显模糊或像素化。

这种函数式方法也称为即时模式图形绘制(immediate-mode graphics rendering)。

上下文:2D

首先让我们考虑2D绘制的上下文,它提供了一个用于在画布上绘制2D图形的高级API。

让我们来看一个例子,看看如何使用它来绘制我们可调整大小的圆:

<html style="height: 100%; width: 100%">
  <body style="height: 100%; width: 100%; margin: 0px">
    <canvas id="my-canvas" style="height: 100%; width: 100%; display: block"></canvas>
    <script>
      const canvas = document.getElementById("my-canvas");
      const context = canvas.getContext("2d");
      
      function render() {
        // Size the drawing surface to match the actual element (no stretch).
        canvas.height = canvas.clientHeight;
        canvas.width = canvas.clientWidth;

        context.beginPath();

        // Calculate relative size and position of circle in pixels.
        const x = 0.5 * canvas.width;
        const y = 0.5 * canvas.height;
        const radius = 0.25 * Math.min(canvas.height, canvas.width);

        context.arc(x, y, radius, 0, 2 * Math.PI);
        
        context.fillStyle = "red";
        context.fill();
        
        context.strokeStyle = "black";
        context.stroke();
      }
      
      render();
      addEventListener("resize", render);
    </script>
  </body>
</html>

同样,这非常简单,但肯定比前面的示例更冗长!我们必须自己根据画布的当前大小,以像素为单位计算圆的半径和中心位置。这也意味着我们必须监听缩放的事件并相应地重新绘制。

那么,既然更加复杂,为什么还要使用这种方法而不是SVG呢?在大多数情况下,你可能不会使用该方法。然而,这给了你对渲染的内容更多的控制。对于要绘制更多对象的、更复杂的动态可视化,它可能比更新DOM中的大量元素,并让浏览器来决定何时呈现和呈现什么,带来更好的性能。

上下文:WebGL

大多数现代浏览器也支持webgl上下文。这为您提供了使用WebGL标准绘制硬件加速图形的底层API,尽管这需要GPU支持。它可以用来渲染2D,更重要的是,也可以用来渲染本篇博客所说的3D图形。

现在让我们来看一个例子,看看如何使用WebGL渲染我们的圆圈:

<html style="height: 100%; width: 100%">
  <body style="height: 100%; width: 100%; margin: 0px">
    <canvas id="my-canvas" style="height: 100%; width: 100%; display: block"></canvas>
    <script>
      const canvas = document.getElementById("my-canvas");
      const context = canvas.getContext("webgl");

      const redColor = new Float32Array([1.0, 0.0, 0.0, 1.0]);
      const blackColor = new Float32Array([0.0, 0.0, 0.0, 1.0]);

      // Use an orthogonal projection matrix as we're rendering in 2D.
      const projectionMatrix = new Float32Array([
        1.0, 0.0, 0.0, 0.0,
        0.0, 1.0, 0.0, 0.0,
        0.0, 0.0, 0.0, 0.0,
        0.0, 0.0, 0.0, 1.0,
      ]);

      // Define positions of the vertices of the circle (in clip space).
      const radius = 0.5;
      const segmentCount = 360;
      const positions = [0.0, 0.0];
      for (let i = 0; i < segmentCount + 1; i++) {
      	positions.push(radius * Math.sin(2 * Math.PI * i / segmentCount));
        positions.push(radius * Math.cos(2 * Math.PI * i / segmentCount));
      }

      const positionBuffer = context.createBuffer();
      context.bindBuffer(context.ARRAY_BUFFER, positionBuffer);
      context.bufferData(context.ARRAY_BUFFER, new Float32Array(positions), context.STATIC_DRAW);

      // Create shaders and program.
      const vertexShader = context.createShader(context.VERTEX_SHADER);
      context.shaderSource(vertexShader, `
        attribute vec4 position;
        uniform mat4 projection;

        void main() {
          gl_Position = projection * position;
        }
      `);
      context.compileShader(vertexShader);
      
      const fragmentShader = context.createShader(context.FRAGMENT_SHADER);
      context.shaderSource(fragmentShader, `
        uniform lowp vec4 color;

        void main() {
          gl_FragColor = color;
        }
      `);
      context.compileShader(fragmentShader);

      const program = context.createProgram();
      context.attachShader(program, vertexShader);
      context.attachShader(program, fragmentShader);
      context.linkProgram(program);

      const positionAttribute = context.getAttribLocation(program, 'position');

      const colorUniform = context.getUniformLocation(program, 'color');
      const projectionUniform = context.getUniformLocation(program, 'projection');
      
      function render() {
        // Size the drawing surface to match the actual element (no stretch).
        canvas.height = canvas.clientHeight;
        canvas.width = canvas.clientWidth;

        context.viewport(0, 0, canvas.width, canvas.height);

        context.useProgram(program);

        // Scale projection to maintain 1:1 ratio between height and width on canvas.
        projectionMatrix[0] = canvas.width > canvas.height ? canvas.height / canvas.width : 1.0;
        projectionMatrix[5] = canvas.height > canvas.width ? canvas.width / canvas.height : 1.0;
        context.uniformMatrix4fv(projectionUniform, false, projectionMatrix);

        const vertexSize = 2;
        const vertexCount = positions.length / vertexSize;

        context.bindBuffer(context.ARRAY_BUFFER, positionBuffer);
        context.vertexAttribPointer(positionAttribute, vertexSize, context.FLOAT, false, 0, 0);
        context.enableVertexAttribArray(positionAttribute);

        context.uniform4fv(colorUniform, redColor);
        context.drawArrays(context.TRIANGLE_FAN, 0, vertexCount);

        context.uniform4fv(colorUniform, blackColor);
        context.drawArrays(context.LINE_STRIP, 1, vertexCount - 1);
      }

      render();
      addEventListener("resize", render);
    </script>
  </body>
</html>

复杂度升级得相当快!在我们渲染任何东西之前,要做很多设置。我们必须使用顶点列表,将圆定义为由小三角形组成的一个序列。我们还必须定义一个投影矩阵,将我们的3D模型(一个平面圆)投影到2D画布上。然后,我们必须编写“着色器”(用一种称为GLSL的语言),在GPU上编译并运行,以确定顶点的位置和颜色。

但是,额外的复杂性和较底层的API,确实能够让我们更好地控制2D图形绘制的性能(如果我们真的需要的话)。它还为我们提供了渲染3D可视化的能力,即使我们还没有考虑过这样的例子。

面向声明式的3D图形

现在我们已经了解了WebGL,并了解了如何使用它来绘制一个圆。随着我们进入3D图形的世界,下一个步骤就是使用它来绘制一个球体。然而,这增加了另一层次的复杂性,因为我们将要思考,如何使用一组顶点来表示球面。我们还需要添加一些灯光效果,这样我们就可以看到一个球体的轮廓,而不是从任何角度都只能看到一个平坦的红色圆圈。

我们还看到,对于绝对性能并不重要的场景,SVG等简单而简洁的声明式方法可以发挥多大的作用。它们还可以让我们使用D3这样的库,轻松地生成与数据连接起来的可视化。所以,如果我们能以类似的方式表示基于Web的3D图形,那不是更好吗?

遗憾的是,目前HTML中的标准还不支持这个操作。但也许还有另一种方法……

正如Mike Bostock(D3的创建者)在POC(Proof of Concept)中所演示的,在DOM中定义2D“素描”的定制化XML表示,并将其与一些JavaScript代码结合,使用2D上下文将其绘制到画布上,这相对来说会更加简单。

这意味着在所有主流浏览器上运行的声明式3D真正需要的是:

  • 基于XML格式的3D模型声明;
  • 使用webgl上下文将它们绘制到画布上的JavaScript代码。

X3D ——拼图中缺失的这块?

X3D是表示3D模型的ISO标准,是虚拟现实建模语言(VRML)的后续标准。它可以表示为各种编码,包括JSON和XML。后者特别适合嵌入到HTML文档中。它由Web3D联盟维护,他们希望它能像SVG一样在HTML5中得到原生支持。

目前有两种被Web3D联盟认可的JavaScript开源X3D实现:X3DOMX_ite

X3DOM是由弗劳恩霍夫计算机图形研究所IGD(The Fraunhofer Institute for Computer Graphics Research IGD)开发的,IGD本身也是Web3D联盟的成员。为了使用它,您只需要在HTML页面中包含X3DOM JavaScript代码和样式表。

让我们来看看用X3D和X3DOM绘制圆圈的例子:

<html style="height: 100%; width: 100%">
  <head>
    <script type="text/javascript" src="http://www.x3dom.org/release/x3dom-full.js"></script>
    <link rel="stylesheet" type="text/css" href="http://www.x3dom.org/release/x3dom.css">
    <style>x3d > canvas { display: block; }</style>
  </head>
  <body style="height: 100%; width: 100%; margin: 0px">
    <x3d style="height: 100%; width: 100%">
      <scene>
        <orthoviewpoint></orthoviewpoint>
        <shape>
          <appearance>
            <material diffuseColor="1 0 0"></material>
          </appearance>
          <disk2d outerRadius="0.5"></disk2d>
        </shape>
        <shape>
          <appearance>
            <material emissiveColor="0 0 0"></material>
          </appearance>
          <circle2d radius="0.5"></circle2d>
        </shape>
      </scene>
    </x3d>
  </body>
</html>

这比WebGL示例更容易接受一些!但是,如果您将X3DOM圆与我们的WebGL版本进行比较,您会注意到圆周看起来不那么光滑。这是因为X3DOM库对形状的近似只使用了32条线段。而我们的WebGL绘制中选择了360条线段。我们对要渲染什么有一个更简单的描述,但同时也会放弃对如何渲染的一些控制。

现在是时候走出我们的“平面”世界,渲染一些3D的东西了!如前所述,让我们来看看一个球体的绘制:

<html style="height: 100%; width: 100%">
  <head>
    <script type="text/javascript" src="http://www.x3dom.org/release/x3dom-full.js"></script>
    <link rel="stylesheet" type="text/css" href="http://www.x3dom.org/release/x3dom.css">
    <style>x3d > canvas { display: block; }</style>
  </head>
  <body style="height: 100%; width: 100%; margin: 0px">
    <x3d style="height: 100%; width: 100%">
      <scene>
        <orthoviewpoint></orthoviewpoint>
        <navigationinfo headlight="false"></navigationinfo>
        <directionallight direction="1 -1 -1" on="true" intensity="1.0"></directionallight>
        <shape>
          <appearance>
            <material diffuseColor="1 0 0"></material>
          </appearance>
          <sphere radius="0.5"></sphere>
        </shape>
      </scene>
    </x3d>
  </body>
</html>

这又是很直接的。我们使用一个XML元素定义了一个球体,该元素具有单一属性:半径。为了看到球体的轮廓,我们还调整了光线,移除了与观察者头部对齐的默认光源,并用与我们视角成一定角度的定向光替换它。这不需要为球体的表面定义一个复杂的网格或者编写一个着色器来控制光照效果。

X3DOM还提供了开箱即用的导航功能,允许您旋转、平移和缩放模型。根据您正在编写的应用程序的类型,还可以使用各种不同的控制方案和导航模式。

结论

就是这样!我们已经看到可以使用X3D和X3DOM库来编写声明式的3D图形应用,这些图形将在大多数现代Web浏览器中运行。这是一种比直接深钻WebGL更简单的Web 3D图形入门方法,代价是增加对底层绘制的控制。如果您有兴趣了解这个库的更多信息,官方X3DOM文档中有一些教程

在我的下一篇博客文章中,我将演示如何将X3DOM与D3结合起来生成动态3D图表。

原文链接: https://blog.scottlogic.com/2019/08/27/declarative-3d-for-the-modern-web.html

  • 发表于:
  • 本文为 InfoQ 中文站特供稿件
  • 首发地址https://www.infoq.cn/article/ejgz7RfKbfdhVYwwzu9F

扫码关注云+社区

领取腾讯云代金券