Interactive Computer Graphice ( ch1 ~ ch2 )

on 2019-12-29

《交互式计算机图形学 基于 WEBGL 的自顶向下方法》 在直接使用容易上手的 Three.js 之前先学习一点计算机图形学基础,理解计算机是怎样利用 GPU 实现图形渲染的,了解一些通用的图形学常识。

计算机图形学基础

像素和帧缓存

图形系统的主要组成元素:

  1. 输入设备
  2. 中央处理设备 (CPU)
  3. 图形处理单元 (GPU)
  4. 存储器
  5. 帧缓存
  6. 输出设备

所有的现代图形系统都是基于光栅的,在这样的图形系统中,我们在输出设备上看到的图像是一个由图形系统产生的图形元素组成的阵列,图像元素也叫像素,像素阵列也被称为光栅。 每一个像素对应于图像中的一个位置,或者一块小的区域。这些像素都保存在一个称为帧缓存的存储区域中。帧缓存可以看成是图形系统的核心元素。帧缓存中像素的数目被称为分辨率,它决定了从图像中可以分辨出多少细节。帧缓存的深度或者精度是表示每个像素所用的比特数,它决定了诸如给定系统中可以表示多少种颜色之类的性质。深度为 8 比特的帧缓存可以表示 256 种颜色。在全彩色系统中,每个像素有 24 个比特。而 RGB 系统中,每个像素对应的三组比特位被分配给三种原色。高动态范围系统给每种原色分配 12 或更多的比特位。

CPU 与 GPU 在图形学中起到的作用

在简单的系统中,可能只有一个处理器,即系统的中央处理单元,常规的处理和图形处理都必须由这个处理器来完成。处理器要完成的图形处理功能主要是获取由应用程序生成的图元(线,圆,多边形)的属性,并为帧缓存中的像素赋值,以最佳的形式来表示这些图元。 例如一个三角形由他的三个顶点所确定,但为了通过连接顶点的三条边来显示这个三角形的轮廓,图形系统必须生成一组像素,这些像素在观察者看来构成了线段。从几何实体到帧缓存中像素的颜色和位置的转换称为光栅化或者扫描转换。 在早期的系统中,帧缓存是标准存储器中的一部分,可以被 CPU 直接访问。而今天几乎所有的图形系统都已经使用了专用的图形处理单元 GPU 来专门完成图形处理工作。这样的设计中,帧缓存可以被 GPU 直接访问,并且通常与 GPU 在同一块电路板上。

图像和成像系统

直到现在,占主导地位的显示器一直是阴极射线管 (CRT)。CRT 因电子轰击涂在管子上的磷光物质而发光。在磷光物质被电子束激发以后,典型的 CRT 发光只能持续很短的时间,一般是几毫秒。为了让人看见稳定的不闪烁的图像,电子束必须以足够高的速率重复扫描相同的路径,这就是刷新,刷新的速率就叫做刷新率

对象和观察者

对象在空间中的存在是不依赖于任何图像生成过程和任何观察者的。计算机图形学考虑的是合成的对象,我们通过指定各种几何图元的空间位置来定义对象。 每个成像系统必须提供一种从对象生成图像的方法。为了生成图像,必须有某人或某物在观察着对象,而观察者的角度和远近则决定着最终图像生成的效果。

虚拟照相机模型

现代计算机图形学在成像系统上则是参考光学成像系统的模型基础实现的,我们称之为虚拟照相机模型。即观察者是一个折叠暗箱照相机,图像是在后部的胶片平面上生成的。这与我们初中时便学过的针孔照相机的情形类似,但注意物体的成像相对于物体是倒立的,而在虚拟照相机模型中,我们可以通过投影平面的引入来将成像移到透镜前面来。在后面我们才会详细讨论这其中涉及的数学公式。 我们也发现并不是所有的物体都能够完整在平面上成像,视角就是用来描述这个限制的。我们可以通过设置投影平面的裁剪窗口来限制图形的成像,我们在后面可以看到这一步的的处理实际上是由顶点着色器来完成的。

图形绘制系统的体系结构

图形绘制系统流水线

顶点处理

在绘制流水线的第一个模块中,对各个顶点的处理是彼此独立的。这个模块的主要功能是执行坐标变换,这个模块也计算每个顶点的颜色值并改变每个顶点的其他属性。 在成像的过程中有很多步骤是可以被抽象成计算机中的坐标变换的。比如在虚拟照相机模型中的观察的实现实际上就是把对象从其被定义的坐标系下的表示转换成观察坐标系下的表示,而最终在图像输出的时候坐标同样需要再次变换到显示器坐标系下的表示。 坐标系的多次变换可以表示为矩阵的相乘或者级联。而投影变换也是在这里处理,通过一个 4 X 4 矩阵来实现。 对于顶点颜色和光线模型的指定也是在这里来实现的。

裁剪和图元组装

裁剪必须针对逐个图元执行,而不是针对逐个顶点,在裁剪执行之前则必须把顶点组装成图元。因此这个阶段的输出是一些其投影可以被成像的图元。

光栅化

由裁剪模块得到的图元依旧是用顶点表示的,为了生成帧缓存中的像素还必须做进一步处理。例如,如果三个顶点确定了一个由单色填充的三角形,光栅化模块就必须确定在帧缓存中有哪些像素位于这个三角形的内部。光栅化模块对每个图元输出一组片元,我们可以把片元看成是携带相关信息的潜在像素,片元所携带的信息包括它的颜色和位置,使用这些信息来更新帧缓存中对应位置的像素。片元还可以携带深度信息,这样在绘制流水线后面的阶段就可以确定某个片元是否位于其他片元的后面,这些片元都对应同一个像素。

片元处理

绘制流水线的最后一个模块利用光栅化生成的片元来更新帧缓存中的像素。如果应用程序生成的是三维数据,那么一些片元可能是不可见的,因为他们所定义的表面在其他表面的后面。

图形学编程

在真正编写图形系统之前,我们先来整理一下需要解决的问题。即怎样将一个图形对象显示到输出系统中。

  1. 用什么颜色来绘制?
  2. 图像出现在屏幕上的什么位置?
  3. 图像会有多大?
  4. 如何在显示器上创建一块区域,也就是一个窗口来显示我们的图像?
  5. 无限大的绘图表面有多大部分会显示在屏幕上?
  6. 图像会在屏幕上持续显示多长时间?

WebGL 依旧是在 OpenGL 的基础上建立起来的,因此如果直接使用浏览器的 WebGL API 编写图形系统程序的话,同样需要使用 GLSL 来编写着色器,并且需要在 JavaScript 代码和 GLSL 代码之间通信。GLSL 类似于 C 语言,其中包含了许多拓展的数据类型和常量。

图元和颜色

三角形是 WebGL 所支持的唯一的多边形,因为在几何学中,我们通常可以使用三角剖分来划分所有多边形,甚至可以用三角形来模拟球面。 颜色方面,我们主要关心计算机图形学发展过程中的两种颜色模型,一种是现代计算机常用的 RGB 颜色模型,其采用了加色模型来实现大量颜色值的缓存和生成。 而因为早期图形系统的帧缓存的位深度很有限,早期的颜色模型有些类似画家的调色板,通过索引来取固定数量的颜色值来进行调和。

摄像机和观察者

现在我们已经定义好了所有的图形信息,接下来要解决的问题就是确切的指定这些对象中的那些要显示在屏幕上。 类似于显示世界中的摄影师,我们只需要定义好对象和虚拟照相机的一系列参数就可以得到图像,我们并不需要关心具体的实现细节,但是了解这些细节会让我们在定义照相机参数的时候更加游刃有余。 我们最初只会使用默认的正投影模型来显示图像,之后我们会看到通过在顶点着色器中使用观察矩形与顶点进行简单的矩阵运算就可以实现图形的观察方向,显示大小等属性的改变。

应用程序架构

我们直接通过一些通用的代码片段来展示一个基本的 WebGL 程序的架构。

顶点着色器和片元着色器

着色器一般使用特定的着色器语言编写,一般可以通过 html 来引入源代码的文本片段并通过 WebGL API 来对着色器进行编译和通信,而在现代的前端代码架构中,我们同样可以通过 webpack 的 loader 来将着色器文件通过模块的方式引入并打包。

// 顶点着色器,简单的引入顶点信息
attribute vec4 vPosition;

void main()
{
  gl_PointSize = 1.0;
  gl_Position = vPosition;
}
// 片元着色器,简单的设定图形绘制的颜色
precision mediump float;

void main()
{
  gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}

我们可以通过 JavaScript WebGL API 来引入并编译生成着色器程序。

export function createProgram(gl, vertex, fragment) {
  // 顶点编译器的定义和编译
  const vertShdr = gl.createShader(gl.VERTEX_SHADER);
  gl.shaderSource(vertShdr, vertex);
  gl.compileShader(vertShdr);
  // 片元着色器的定义和编译
  const fragShdr = gl.createShader(gl.FRAGMENT_SHADER);
  gl.shaderSource(fragShdr, fragment);
  gl.compileShader(fragShdr);
  // 生成着色器程序,并链接
  const program = gl.createProgram();
  gl.attachShader(program, vertShdr);
  gl.attachShader(program, fragShdr);
  gl.linkProgram(program);

  return program;
}
// 之后我们这样使用着色器程序
gl.useProgram(program);

Show me the code

我们接下来的任务是描述对象,并将对象的顶点数据写入到缓冲区

// 递归的 Sierpinski 镂垫程序
const points = [];
const numTimesToSubdivide = 5;

function divideTriangle(a, b, c, count = numTimesToSubdivide) {
  if(count <= 0) {
    points.push(a, b, c);
  } else {
    const ab = vec2.lerp(a, b, 0.5);
    const ac = vec2.lerp(a, c, 0.5);
    const bc = vec2.lerp(b, c, 0.5);

    —count;

    divideTriangle(a, ab, ac, count);
    divideTriangle(c, ac, bc, count);
    divideTriangle(b, bc, ab, count);
  }
}

const bufferId = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, bufferId);
// buffer 中的 data 需要转换成 Float32Array 类型,就像我们在 node 中处理 buffer 一样,buffer 中的数据类型要是具体的。
gl.bufferData(gl.ARRAY_BUFFER, pointsToBuffer(points), gl.STATIC_DRAW);

接下来,通知着色器程序如何读取缓冲区中的数据,也就是建立通信。

// 从着色器中找到顶点数据属性
const vPosition = gl.getAttribLocation(program, 'vPosition');

// 每组数据数量
const numComponents = 2;
const type = gl.FLOAT;
// 数据是否归一化
const normalize = false;
// 数据是连续的
const stride = 0;
// 数据在缓冲区的偏移量
const offset = 0;
// 描述顶点着色器中顶点数组的数据格式
gl.vertexAttribPointer(
  vPositionnumComponents,
  type,
  normalize,
  stride,
  offset,
);

// 启用顶点数组
gl.enableVertexAttribArray(vPosition);

接下来万事俱备,绘制图形就完了。

// 清屏
gl.clear(gl.COLOR_BUFFER_BIT);
// 根据给定模式 绘制
gl.drawArrays(gl.TRIANGLES, 0, points.length);

Tips

目前我们的程序中,帧缓存中和顶点着色器中都只有顶点数组一种数据,因此我们可以看到数据在缓冲区的偏移量为 0 。在之后的例子中我们可以看到透视相机矩阵和裁剪矩阵也会出现在顶点着色器中,他们会以另外一种数据类型的方式呈现。 uniform,这种数据意味着在图形可能存在的变换过程中不会发生改变,也就不必存在于 buffer 中,因为我们知道顶点数据存在 buffer 中是为了 GPU 可以通过 buffer 中的数据来完成可能的变换,而不需要重复生成顶点数据。而对于 uniform 数据,我们不必再通过 buffer 来传递给着色器,只需要类似这样的一行代码即可。

// 传递一个 4 x 4 照相机透视矩阵
gl.uniformMatrix4fv(
  programInfo.uniformLocations.projectionMatrix,
  false,
  projectionMatrix
);

Reference

部分实例代码来自月影团长