现代 OpenGL(以及名为WebGL的扩展)与我过去学习的传统 OpenGL 有很大不同。我了解栅格化的工作原理,所以对这些概念很满意。但是我所阅读的每篇教程都介绍了抽象和辅助函数,这使我很难理解哪些部分是 OpenGL API 的真正核心。

明确地说,在实际的应用程序中,把位置数据和渲染功能分离到单独的类这样的抽象很重要。但是,这些抽象把代码分布到了多个区域,并且由于模板的重复以及逻辑单元之间的数据传递而导致大量的开销。而我的最佳学习方式是线性代码流,其中每一行都是手头主题的核心。

首先,本文要归功于我所学过的教程。从这个基础开始,我剥离了所有抽象,直到有了一个“最小可行的程序”为止。希望这将帮助你使用现代OpenGL入门。这就我们要做的:

一个等边三角形,顶部为绿色,左下为黑色,右下为红色,中间有过渡颜色

初始化

要使用 WebGL,需要用 canvas 进行绘制。你肯定会想包括一些常用的 HTML 骨架、某些样式等,但是 canvas 才是最关键的。加载 DOM 后,我们将能够用 Javascript 访问画布。

<canvas id="container" width="500" height="500"></canvas>

<script>
  document.addEventListener('DOMContentLoaded', () => {
    // 所有的 Javascript 代码将会出现在这里
  });
</script>

我们可以通过画布的可访问性获得 WebGL 的渲染上下文,并将其初始化为透明色。 OpenGL 的世界中的颜色是RGBA,每个分量都在 01 之间。透明色是用于在重新绘制场景的帧的开始时绘制画布的颜色。

const canvas = document.getElementById('container');
const gl = canvas.getContext('webgl');

gl.clearColor(1, 1, 1, 1);

在实际的程序中,还可以进行更多的初始化。需要特别注意的是启用了“深度缓冲区(*depth buffer*)”,这将允许基于 Z 坐标对几何图形进行排序。对于只包含一个三角形的最简程序,我们将会忽略这种情况。

编译着色器

OpenGL 的核心是栅格化框架,在这里我们可以决定如何实现除栅格化之外的所有内容。这需要在 GPU 上至少运行两段代码:

  1. 为输入所执行的顶点着色器,每个输入都会对应输出一个3D位置(实际上是齐次坐标中的4D)。
  2. 为屏幕上的每个像素所执行的片段着色器,负责输出这个像素应该是哪种颜色。

在这两个步骤之间,OpenGL 从顶点着色器获取几何图形,并确定这个几何图形实际上覆盖了屏幕上的哪些像素。这是栅格化部分。

两种着色器通常都是用 GLSL(OpenGL 着色语言)编写的,然后将其编译为 GPU 的机器代码。机器代码随后被发送到 GPU,因此可以在渲染过程中运行。我不会把太多时间花在 GLSL 上,因为我只是在展示基础知识,但是这种语言与 C 很接近,着足以让大多数程序员感到熟悉。

首先,我们编译顶点着色器并将其发送到GPU。此处着色器的源代码被存储在字符串中,但是也可以从其他位置加载。最终,该字符串被发送到 WebGL API。

const sourceV = `
  attribute vec3 position;
  varying vec4 color;

  void main() {
    gl_Position = vec4(position, 1);
    color = gl_Position * 0.5 + 0.5;
  }
`;

const shaderV = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(shaderV, sourceV);
gl.compileShader(shaderV);

if (!gl.getShaderParameter(shaderV, gl.COMPILE_STATUS)) {
  console.error(gl.getShaderInfoLog(shaderV));
  throw new Error('Failed to compile vertex shader');
}

在这里的 GLSL 代码中有一些需要提到的变量:

  1. 一个名为 position属性。属性本质上是一个输入,并且为每个这样的输入调用着色器。
  2. 一种称为 colorvarying。这既是顶点着色器的输出(每个顶点着色器都有一个),也是片段着色器的输入。值被传递到片段着色器时,将根据栅格化的属性对值进行插值计算。
  3. gl_Position 值。本质上是顶点着色器的输出,如任何存在变化的值。这很特别,因为它用于确定需要去绘制哪些像素。

还有一个称为 uniform 的变量类型,该变量类型在多次调用顶点着色器时将会保持不变。这些 uniform 用于变换矩阵之类的属性,对于单个几何图形上的顶点来说,它们都是恒定的。

接下来,我们用片段着色器执行相同的操作,将其编译并发送到 GPU。注意,片段着色器现在可以读取顶点着色器中的 color 变量。

const sourceF = `
  precision mediump float;
  varying vec4 color;

  void main() {
    gl_FragColor = color;
  }
`;

const shaderF = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(shaderF, sourceF);
gl.compileShader(shaderF);

if (!gl.getShaderParameter(shaderF, gl.COMPILE_STATUS)) {
  console.error(gl.getShaderInfoLog(shaderF));
  throw new Error('Failed to compile fragment shader');
}

最后,顶点着色器和片段着色器都被链接到单个 OpenGL 程序中。

const program = gl.createProgram();
gl.attachShader(program, shaderV);
gl.attachShader(program, shaderF);
gl.linkProgram(program);

if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
  console.error(gl.getProgramInfoLog(program));
  throw new Error('Failed to link program');
}

gl.useProgram(program);

我们告诉 GPU,上面所定义的着色器就是我们要运行的着色器。所以剩下事情的就是创建输入,并让 GPU 在这些输入上进行运算。

将输入数据发送到 GPU

输入的数据将会存储在 GPU 的内存中,并从那里进行处理。与其对每个输入进行单独的绘制调用(一次仅传输一个相关数据),不如将整个输入传输到 GPU 并从那里读取。 (传统 OpenGL 一次只能传输一份数据,从而导致性能下降。)

OpenGL 提供了一种被称为“顶点缓冲对象”(VBO)的抽象。我仍在试图完全弄清楚它的工作原理,但是最终,我们将会使用抽象来进行以下操作:

  1. 将一系列字节存储在 CPU 的内存中。
  2. 用通过 gl.createBuffe() 创建的唯一缓冲区和 gl.ARRAY_BUFFER 的绑定点(binding point)将字节传输到 GPU 的内存。

尽管在顶点着色器中每个输入变量(属性)都有一个 VBO,但也可以把一个 VBO 用于多个输入。

const positionsData = new Float32Array([
  -0.75, -0.65, -1,
   0.75, -0.65, -1,
   0   ,  0.65, -1,
]);

const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, positionsData, gl.STATIC_DRAW);

通常你将会用对程序有意义的任何坐标来指定几何图形,然后在顶点着色器中使用一系列转换将它们转换为 OpenGL 的“剪辑空间(*clip space*)”。我不会介绍剪辑空间的详细信息(它们与同构坐标有关),但是现在,X 和Y 在 -1+1 之间变化。由于顶点着色器仅按原样传递输入数据,因此可以直接在剪辑空间中指定坐标。

接下来,我们还会把缓冲区与顶点着色器中的变量之一相关联:

  1. 从上面创建的程序中获取 position 变量的句柄。
  2. 告诉 OpenGL 从 gl.ARRAY_BUFFER 绑定点读取数据,每批 3 个,其特殊参数如 offsetstride 为零。
const attribute = gl.getAttribLocation(program, 'position');
gl.enableVertexAttribArray(attribute);
gl.vertexAttribPointer(attribute, 3, gl.FLOAT, false, 0, 0);

请注意,我们可以创建 VBO 并将其与“顶点着色器”属性相关联,因为要一个接一个地做。如果我们将这两个功能分开(例如一次性创建所有 VBO,然后将它们与各个属性相关联),则需要在将每个 VBO 与对应的属性相关联之前调用 gl.bindBuffer(...)

绘制!

最后,按照我们想要的方式设置 GPU 内存中的所有数据,我们可以告诉 OpenGL 清除屏幕并在设置的阵列上运行程序。作为栅格化的一部分(确定哪些像素被顶点覆盖),我们告诉 OpenGL 将 3 个一组的顶点视为三角形。

gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLES, 0, 3);

以线性方式进行设置确实意味着可以一次就能使程序运行。在任何实际的应用中,我们都会以结构化的方式存储数据,在数据发生变化时将其发送到 GPU,并在每一帧进行绘制。


将所有内容放在一起,下图显示了在屏幕上显示第一个三角形的最小概念集。即使这样,该图还是被大大简化了,所以你最好配合本文所介绍的 75 行代码放在一起进行研究。

完整的处理流程:首先创建着色器,通过 VBO 将数据传输到 GPU,把两者关联在一起,然后 GPU 在再将所有内容组装成最终的图像。

最后的步骤,尽管经过了简化,但完整描述了三角形所需的步骤顺序

对我而言,学习 OpenGL 的难点在于获得屏幕上最基本图像所需的大量模板。由于栅格化框架要求我们提供 3D 渲染功能,并且与 GPU 的通信非常冗长,所以有很多概念需要预先学习。我希望本文所展示的基础知识比其他教程更简单!