ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

WebGL 基础概念详解:从光栅化引擎到你的第一个着色程序(webgl-fundamentals)

WebGL 基础概念详解:从光栅化引擎到你的第一个着色程序(webgl-fundamentals)
  • 图形学

【免费下载链接】webgl-fundamentals

WebGL lessons that start with the basics

项目地址:https://gitcode.com/gh_mirrors/we/webgl-fundamentals
点击查看免费下载

导读

WebGL 常被误认为是一个 3D API,但本质上它只是一个光栅化引擎:它只负责根据你提供的代码绘制点、线和三角形。本篇文章基于 webgl-fundamentals 仓库中法语版首篇课程 webgl/lessons/fr/webgl-fundamentals.md 展开,讲解 WebGL 的核心运行机制——顶点着色器与片段着色器如何协作、裁剪空间(clipspace)坐标体系,并通过完整的 "Hello World" 示例带你写出第一个 WebGL 程序,最终在 2D 场景中实现像素坐标与裁剪空间的转换、坐标轴翻转以及批量随机矩形渲染。读完本文,你将能独立搭建一个最简 WebGL 渲染管线,并理解后续 3D 教程所需的全部底层概念。

WebGL 到底是什么:一个光栅化引擎,而非 3D API

很多人以为 "使用 WebGL 就能变出炫酷的 3D 效果"。事实并非如此。从源码角度看,WebGL 只是一个光栅化引擎——它只关心两件事:

  1. 裁剪空间(clipspace)中的坐标值
  2. 颜色值

你的任务,就是通过两个着色器把这两类数据交给 WebGL:

  • 顶点着色器(vertex shader):负责计算顶点位置,输出裁剪空间坐标(写入内置变量gl_Position)。
  • 片段着色器(fragment shader):负责为正在绘制的图元的每一个像素计算颜色(写入内置变量gl_FragColor)。

两者都使用 GLSL(GL 着色语言)编写,这是一种与 C/C++ 类似的强类型语言,详见 webgl-shaders-and-glsl。一对顶点着色器与片段着色器组合起来,称为一个program(着色程序)。

WebGL 在 GPU 上运行。几乎整个 WebGL API 都在做同一件事:为这两对函数的运行设置状态。每当你想要绘制一个物体,就需要设置一批状态,然后调用gl.drawArrays或gl.drawElements在 GPU 上执行着色器。

着色器获取数据的 4 种途径

为了让顶点/片段着色器能够访问数据,你必须把数据提供给 GPU。着色器获取数据有 4 种方式:

途径说明
属性(Attributes)与缓冲(Buffers)缓冲是上传到 GPU 的二进制数据数组,通常存放位置、法线、纹理坐标、顶点颜色等,你也可以放入任意数据。属性用于指定如何从缓冲中取出数据并交给顶点着色器:例如告诉某个属性从哪个缓冲取位置、取什么类型(3 个 32 位浮点数)、起始偏移量、以及相邻位置之间隔多少字节。注意缓冲不是随机访问的——顶点着色器会按指定次数执行,每次执行都从缓冲中顺序取下一个值赋给属性。
全局变量(Uniforms)在执行着色器之前设置的全局变量,运行期间全局有效。
纹理(Textures)可以在着色器中随机访问的数据数组。最常见的用途是存放图像数据,但纹理本质上只是数据,同样可以存放颜色以外的内容。
可变量(Varyings)顶点着色器向片段着色器传值的通道。根据渲染的是点、线还是三角形,顶点着色器设置的可变量会在片段着色器执行时被插值。

WebGL Hello World:从两个着色器开始

WebGL 只关心裁剪空间坐标与颜色,你只需提供这两个东西。无论画布多大,裁剪空间的坐标范围永远是 -1 到 +1。下面从一个最简单的顶点着色器开始:

// 一个属性,将从缓冲中接收数据 attribute vec4 a_position; // 所有着色器都有一个 main 函数 void main() { // gl_Position 是顶点着色器必须设置的特殊变量 gl_Position = a_position; }

如果用 JavaScript 代替 GLSL 来理解它的执行过程,可以想象成如下伪代码:

// *** 伪代码!! *** var positionBuffer = [ 0, 0, 0, 0, 0, 0.5, 0, 0, 0.7, 0, 0, 0, ]; var attributes = {}; var gl_Position; drawArrays(..., offset, count) { var stride = 4; var size = 4; for (var i = 0; i < count; ++i) { // 从 positionBuffer 复制接下来 4 个值给 a_position 属性 const start = offset + i * stride; attributes.a_position = positionBuffer.slice(start, start + size); runVertexShader(); // 运行顶点着色器 ... doSomethingWith_gl_Position(); } }

真实实现比这复杂(缓冲需要转换成二进制数据),但这个伪代码足以说明顶点着色器如何被反复执行。

接下来是片段着色器。片段着色器没有默认精度,所以必须先声明一个——mediump(中等精度)是很好的默认值:

// 片段着色器没有默认精度,因此需要选择一个。mediump 是很好的默认值 precision mediump float; void main() { // gl_FragColor 是片段着色器必须设置的特殊变量 gl_FragColor = vec4(1, 0, 0.5, 1); // 返回红紫色 }

这里gl_FragColor被设为(1, 0, 0.5, 1),分别对应红、绿、蓝、透明度。WebGL 中的颜色分量范围是 0 到 1,而不是 Canvas 2D 常见的 0 到 255。

第一个 WebGL 程序:上下文、编译与链接

有了两个着色器函数,接下来就可以真正使用 WebGL 了。

1. 获取 canvas 与 WebGL 上下文

<canvas id="c"></canvas>
var canvas = document.querySelector("#c"); var gl = canvas.getContext("webgl"); if (!gl) { // 该浏览器不支持 WebGL ... }

法文版教程中还提供了一种兼容写法:canvas.getContext("webgl") || canvas.getContext("experimental-webgl"),可用于旧浏览器回退。

2. 把 GLSL 源码放入非 JavaScript 的 script 标签

你可以用字符串拼接、AJAX 下载、多行模板字符串等方式创建 GLSL 字符串。本教程采用把着色器放进type="notjs"(法文版用x-shader/x-vertex与x-shader/x-fragment)的 script 标签中的方式,便于编辑与维护:

<script id="vertex-shader-2d" type="notjs"> attribute vec4 a_position; void main() { gl_Position = a_position; } </script> <script id="fragment-shader-2d" type="notjs"> precision mediump float; void main() { gl_FragColor = vec4(1, 0, 0.5, 1); } </script>

3. 编译着色器并链接成 program

以下是本仓库 webgl/webgl-fundamentals.html 中真实使用的编译与链接代码:

function createShader(gl, type, source) { var shader = gl.createShader(type); gl.shaderSource(shader, source); gl.compileShader(shader); var success = gl.getShaderParameter(shader, gl.COMPILE_STATUS); if (success) { return shader; } console.log(gl.getShaderInfoLog(shader)); gl.deleteShader(shader); } function createProgram(gl, vertexShader, fragmentShader) { var program = gl.createProgram(); gl.attachShader(program, vertexShader); gl.attachShader(program, fragmentShader); gl.linkProgram(program); var success = gl.getProgramParameter(program, gl.LINK_STATUS); if (success) { return program; } console.log(gl.getProgramInfoLog(program)); gl.deleteProgram(program); }

调用方式:

var vertexShaderSource = document.querySelector("#vertex-shader-2d").text; var fragmentShaderSource = document.querySelector("#fragment-shader-2d").text; var vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexShaderSource); var fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource); var program = createProgram(gl, vertexShader, fragmentShader);

从 webgl/resources/webgl-utils.js 的实现来看,createShaderFromScript会依据 script 标签的type属性(x-shader/x-vertex/x-shader/x-fragment)自动推断着色器类型;而createProgramFromScripts则把两个 script id 依次按顶点着色器、片段着色器编译并链接成一个 program(见loadShader与createProgram函数)。这也是后面所有示例统一使用webglUtils.createProgramFromScripts(gl, [idVs, idFs])的原因,详见 webgl-boilerplate。

4. 通过缓冲提供顶点数据

程序创建好后,需要把数据喂给a_position属性。第一步是查询属性的位置(属性与 uniform 位置的查询应在初始化阶段完成,而不是放在渲染循环里):

var positionAttributeLocation = gl.getAttribLocation(program, "a_position");

然后创建缓冲。WebGL 允许我们在全局绑定点上操作资源——可以把绑定点理解为 WebGL 内部的全局变量:先把资源绑定到某个绑定点,之后所有其他函数都通过这个绑定点引用该资源:

var positionBuffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); // 三个二维点 var positions = [ 0, 0, 0, 0.5, 0.7, 0, ]; gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);

这里有两个关键点:

  • new Float32Array(positions)把普通 JavaScript 数组转换为 WebGL 需要的强类型(32 位浮点)数据;gl.bufferData再把这些数据拷贝到 GPU 上的positionBuffer(因为上面把它绑定到了ARRAY_BUFFER绑定点)。
  • 最后一个参数gl.STATIC_DRAW是对 WebGL 的提示,说明该数据不太会频繁改动,WebGL 可据此做优化。

渲染:视图变换、清屏与绘制三角形

到目前为止的代码都是初始化代码(页面加载时执行一次)。下面的代码是渲染代码,每次要绘制时都会执行。

1. 调整画布大小并设置视口

画布(和图片一样)有两个尺寸:实际像素数(drawingbuffer)与 CSS 显示尺寸。你应该始终用 CSS 决定画布显示尺寸,然后用工具函数把内部像素数调整为显示尺寸。仓库所有示例都使用webglUtils.resizeCanvasToDisplaySize(gl.canvas)(实现见 webgl/resources/webgl-utils.js 的resizeCanvasToDisplaySize:读取clientWidth/clientHeight,若与canvas.width/height不一致则同步,还可传入window.devicePixelRatio作为倍率获得原生像素),详见 webgl-resizing-the-canvas。

接着告诉 WebGL 如何把裁剪空间映射回屏幕空间(像素):

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

这告诉 WebGL:裁剪空间 -1 到 +1 映射到 x 的0 ↔ canvas.width、y 的0 ↔ canvas.height。

2. 清屏、绑定程序与属性

// 清空画布:RGBA 全为 0,即透明 gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT); // 告诉 WebGL 使用我们的 program(一对着色器) gl.useProgram(program); // 打开属性 gl.enableVertexAttribArray(positionAttributeLocation); // 绑定位置缓冲 gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); // 告诉属性如何从 positionBuffer 中取数据 var size = 2; // 每次迭代 2 个分量 var type = gl.FLOAT; // 数据是 32 位浮点数 var normalize = false; // 不要归一化数据 var stride = 0; // 0 = 每次迭代前进 size * sizeof(type) 个字节取下一个位置 var offset = 0; // 从缓冲开头开始 gl.vertexAttribPointer( positionAttributeLocation, size, type, normalize, stride, offset);

一个隐藏细节:gl.vertexAttribPointer会把当前ARRAY_BUFFER绑定到该属性上。也就是说从此该属性就与positionBuffer绑定,之后即使把其他资源绑定到ARRAY_BUFFER绑定点,该属性依然会继续使用positionBuffer。

另一个值得注意的点:GLSL 中的a_position是vec4(4 个浮点),而我们设置size = 2。属性默认值依次为0, 0, 0, 1,所以该属性会从缓冲中取前两个值(x、y),z 和 w 分别使用默认值 0 和 1。

3. 调用 drawArrays

var primitiveType = gl.TRIANGLES; var offset = 0; var count = 3; gl.drawArrays(primitiveType, offset, count);

count为 3,意味着顶点着色器将执行 3 次:第一次a_position.x/y取缓冲前两个值,第二次取第二个值对,第三次取最后一个值对。因为图元类型是gl.TRIANGLES,每执行 3 次顶点着色器,WebGL 就依据 3 个gl_Position值绘制一个三角形。

4. 裁剪空间到屏幕空间的换算

我们的顶点着色器只是把缓冲数据直接拷贝到gl_Position,因此三角形位于裁剪空间坐标:

0, 0 0, 0.5 0.7, 0

若画布恰好是 400×300,则换算到屏幕空间为:

裁剪空间 -> 屏幕空间 0, 0 -> 200, 150 0, 0.5 -> 200, 225 0.7, 0 -> 340, 150

随后 WebGL 对每个待绘制的像素调用片段着色器。由于画布是每通道 8 位的,gl_FragColor = (1, 0, 0.5, 1)最终会写成[255, 0, 127, 255]。

为什么三角形从中间开始、向右上延伸?因为裁剪空间的 x 从 -1 到 +1,0 在中心,正值在右侧;y 方向上 -1 在底部、+1 在顶部,0 在中心,正值在上方。如果你想做 3D,就必须自己编写把 3D 坐标转换成裁剪空间的着色器——因为 WebGL 本身只是一个光栅化 API。以上示例的完整可运行版本见 webgl/webgl-fundamentals.html。

从裁剪空间到像素坐标:引入 uniform 分辨率

对于 2D 工作,我们更愿意直接用像素而不是裁剪空间来指定位置。因此改造顶点着色器,让它替我们把像素坐标转换为裁剪空间:

<script id="vertex-shader-2d" type="notjs"> attribute vec2 a_position; // 改为 vec2,只用 x 和 y uniform vec2 u_resolution; void main() { // 把像素坐标从 0.0 缩放到 1.0 vec2 zeroToOne = a_position / u_resolution; // 从 0->1 转换到 0->2 vec2 zeroToTwo = zeroToOne * 2.0; // 从 0->2 转换到 -1->+1(裁剪空间) vec2 clipSpace = zeroToTwo - 1.0; gl_Position = vec4(clipSpace, 0, 1); } </script>

改动要点:

  • a_position改成vec2,因为只需要 x、y 两个分量(vec2类似vec4但只有 x 和 y)。
  • 新增 uniformu_resolution。使用前要先查询其位置:var resolutionUniformLocation = gl.getUniformLocation(program, "u_resolution");

现在可以把顶点数据从裁剪空间改成像素坐标。这次绘制一个由两个三角形组成的矩形(每个三角形 3 个点,共 6 个点):

var positions = [ 10, 20, 80, 20, 10, 30, 10, 30, 80, 20, 80, 30, ]; gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);

设置程序后,通过gl.uniform2f设置分辨率——gl.useProgram就像之前的gl.bindBuffer一样设置"当前程序",之后所有gl.uniformXXX都作用于当前程序:

gl.useProgram(program); ... // 设置分辨率 gl.uniform2f(resolutionUniformLocation, gl.canvas.width, gl.canvas.height);

要绘制 2 个三角形,顶点着色器需要执行 6 次,因此count改为6:

var primitiveType = gl.TRIANGLES; var offset = 0; var count = 6; gl.drawArrays(primitiveType, offset, count);

运行效果见 webgl/webgl-2d-rectangle.html。注意:该示例及后续所有示例都使用 webgl/resources/webgl-utils.js 中提供的编译链接函数,避免每处都重复样板代码。

翻转 Y 轴:让原点回到左上角

你会发现矩形出现在区域底部。这是因为 WebGL 规定正 Y 向上、负 Y 向下,裁剪空间左下角是 (-1, -1),而我们没有改动任何符号,所以 (0, 0) 成了左下角。想要得到 2D 图形 API 传统的左上角原点,只需翻转裁剪空间的 y 坐标:

gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1);

翻转后矩形就出现在预期位置,效果见 webgl/webgl-2d-rectangle-top-left.html。

批量绘制:把矩形封装成函数,加入随机颜色

接下来把定义矩形的代码封装成函数,以便绘制不同尺寸的矩形,同时让颜色也可配置。先在片段着色器中加入颜色 uniform:

<script id="fragment-shader-2d" type="notjs"> precision mediump float; uniform vec4 u_color; void main() { gl_FragColor = u_color; } </script>

然后查询 uniform 位置并循环绘制 50 个随机位置、随机颜色的矩形:

var colorUniformLocation = gl.getUniformLocation(program, "u_color"); ... // 绘制 50 个随机颜色、随机位置的矩形 for (var ii = 0; ii < 50; ++ii) { // 设置一个随机矩形(会写入 positionBuffer,因为它是最后绑定到 ARRAY_BUFFER 绑定点的缓冲) setRectangle( gl, randomInt(300), randomInt(300), randomInt(300), randomInt(300)); // 设置随机颜色 gl.uniform4f(colorUniformLocation, Math.random(), Math.random(), Math.random(), 1); // 绘制矩形 gl.drawArrays(gl.TRIANGLES, 0, 6); } // 返回 0 到 range - 1 之间的随机整数 function randomInt(range) { return Math.floor(Math.random() * range); } // 用定义矩形的值填充缓冲 function setRectangle(gl, x, y, width, height) { var x1 = x; var x2 = x + width; var y1 = y; var y2 = y + height; // 注意:gl.bufferData(gl.ARRAY_BUFFER, ...) 会影响当前绑定到 ARRAY_BUFFER // 绑定点的缓冲。目前我们只有一个缓冲;如果有多个缓冲, // 需要先把目标缓冲绑定到 ARRAY_BUFFER 再调用。 gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ x1, y1, x2, y1, x1, y2, x1, y2, x2, y1, x2, y2]), gl.STATIC_DRAW); }

完整效果见 webgl/webgl-2d-rectangles.html。这段代码在法文原文档中的creerRectangle/entierAleatoir与之对应,且该示例中的着色器已包含clipSpace * vec2(1, -1)的 Y 轴翻转(a_position为vec2)。

为什么 WebGL 其实是"简单"的 API

希望你能体会到:WebGL 做的事情本身很简单——它只是执行两个用户提供的函数(顶点着色器与片段着色器),然后绘制三角形、直线或点。做 3D 之所以变复杂,是因为复杂是由开发者以更复杂着色器的形式加进去的。WebGL API 本身只是一个概念上相当简单的光栅化器。

本文只演示了 1 个属性与 2 个 uniform 的数据供给。实际应用中常常有多个属性、大量 uniform;文章开头提到的可变量(varyings)和纹理(textures)会在后续课程中出现。

另外需要提醒:对于大多数应用,像setRectangle这样每次更新缓冲数据的做法并不常见。这里用它是因为最易于讲解——它展示了像素坐标输入与 GLSL 内的小规模数学运算。更常见的定位、旋转、缩放物体的方式,请继续阅读 webgl-2d-translation。

深入理解:type="notjs"与x-shader/x-*的含义

为什么着色器要放在非 JavaScript 的 script 标签里?<script>标签默认按 JavaScript 解释:不写type,或写type="javascript"/type="text/javascript",浏览器都会把内容当 JS 执行;只要type是其他任意值,浏览器就会忽略该标签的内容。换句话说,type="notjs"、x-shader/x-vertex、x-shader/x-fragment对浏览器而言都没有特殊含义。

利用这一特性,可以把着色器源码外置到 script 标签中,便于阅读和编辑。否则你必须写冗长的字符串拼接:

var shaderSource = "void main() {\n" + " gl_FragColor = vec4(1,0,0,1);\n" + "}";

或用 AJAX 异步加载(慢且异步)。更现代的做法是使用多行模板字符串:

var shaderSource = ` void main() { gl_FragColor = vec4(1,0,0,1); } `;

多行模板字符串在支持 WebGL 的浏览器中都可用,但非常老的浏览器不支持;如果需要在老浏览器上降级,就不要用它,或者借助转译器处理。

此时,一个类似createProgramFromScripts的函数会按指定 id 找到这些标签、取出内容,把第一个 id 的内容作为顶点着色器、第二个作为片段着色器编译并链接。该函数是 webgl-boilerplate 所描述的、几乎所有 WebGL 程序都需要的基础代码的一部分,其完整实现可在 webgl/resources/webgl-utils.js 的createShaderFromScript、loadShader、createProgram、createProgramFromScripts中看到——编译失败时它会打印带行号的错误信息并删除着色器对象,链接失败时同样打印日志并删除 program。

下一步学习路线

  • 如果你对 WebGL 完全陌生,还不清楚 GLSL、着色器或 GPU 的工作方式,先读 webgl-how-it-works 了解 WebGL 的真正运行原理,也可以查看交互式状态图。
  • 至少粗略读一遍 webgl-boilerplate(示例中反复使用的样板代码),并浏览 webgl-drawing-multiple-things 了解更典型 WebGL 应用的组织结构——遗憾的是几乎所有示例都只绘制一个物体,没有展示这种结构。
  • 如果你对图像处理感兴趣,接着读 webgl-image-processing(法文版对应 webgl-image-processing.md)。
  • 如果你想学习平移、旋转、缩放并最终进入 3D,从 webgl-2d-translation(法文版 webgl-2d-translation.md)开始。

至此,你已经完成了 WebGL 的第一课:理解它是光栅化引擎而非 3D API,掌握顶点/片段着色器的职责,亲手编译链接了一个 program,并通过属性、缓冲与 uniform 完成了一次完整绘制。接下来的一切——图像处理、平移旋转、最终的三维场景——都建立在这套基础之上。

  • 图形学

【免费下载链接】webgl-fundamentals

WebGL lessons that start with the basics

项目地址:https://gitcode.com/gh_mirrors/we/webgl-fundamentals
点击查看免费下载

相关推荐

上一篇:openMVS实战案例:从无人机航拍到文物数字化的应用
下一篇:wfdb-python常见问题解答:新手入门避坑指南

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进