75行代码中最少的WebGL

现代OpenGL和更广泛的WebGL与我过去研究过的旧OpenGL有很大不同。我了解栅格化的工作原理,因此我对这些概念非常熟悉。但是,我阅读的每个教程都提供了抽象和辅助函数,这使我更难理解哪些部分属于OpenGL API本身。



要澄清的是,在实际应用中,诸如将这些位置和渲染功能划分为单独的类之类的抽象很重要。但是,由于逻辑单元之间的样板和数据传输,这些抽象将代码分散在不同的区域,并增加了冗余。我发现以线性代码流研究某个主题最方便,其中每一行都直接与此主题相关。



首先,我要感谢所使用教程的创建者以它为基础,我摆脱了所有抽象,直到获得了“最小可行程序”。我希望它可以帮助您开始使用现代OpenGL。这是我们将要做的:





等边三角形,顶部为绿色,左下方为黑色,右下方为红色,点之间插有颜色。黑色三角形的稍微亮些的版本[在Habré中翻译]。



初始化



在WebGL中,我们需要canvas绘制。当然,您肯定需要添加所有常用的HTML样板,样式等,但是canvas是最重要的。加载DOM之后,我们可以使用Javascript访问画布。



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

<script>
  document.addEventListener('DOMContentLoaded', () => {
    // All the Javascript code below goes here
  });
</script>


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



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

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


在实际程序中,初始化可以而且应该更详细。特别要提到的是包含了一个深度缓冲区,该缓冲区允许您基于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. (attribute) position. , , .
  2. Varying color. ( ) . .
  3. gl_Position. , , varying-. , ,


还有一个统一变量类型,它在所有顶点着色器调用中都是常量。此类制服用于变换矩阵之类的属性,对于一个几何元素的所有顶点而言,该常数将保持不变。



接下来,我们对片段着色器执行相同的操作-将其编译并将其传输到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.createBuffer()锚点 创建的唯一缓冲区将字节传输到GPU内存gl.ARRAY_BUFFER


对于顶点着色器中输入数据(属性)的每个变量,我们将有一个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剪辑空间我不会详细介绍截断空间(它与齐次坐标相关联),而您只需要知道X和Y在-1到+1范围内变化即可。由于顶点着色器仅按原样传递输入,因此我们可以直接在剪切空间中设置坐标。



然后,我们还将缓冲区绑定到顶点着色器中的变量之一。在代码中,我们执行以下操作:



  1. 我们position从上面创建的程序中获取变量描述符
  2. 我们指示OpenGL以gl.ARRAY_BUFFER特定参数(例如,偏移量和步幅为0)从锚点读取数据(以3为一组)。




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行代码并进行研究。





显示三角形所需的最终高度简化的步骤序列



对我而言,学习OpenGL的最难部分是在屏幕上显示最简单的图像所需的大量模板。由于栅格化框架要求我们提供3D渲染功能,并且与GPU的通信量很大,因此必须直接研究许多概念。希望本文以比其他教程更简单的方式向您展示了基础知识。



也可以看看:








也可以看看:






All Articles