本文假设你已经阅读了从基础概念开始的很多文章。 如果你还没有阅读过他们,请先从那里开始。
在关于最小的 WebGL 程序的文章中, 我们介绍了一些用极少的代码进行绘图的例子。 在这篇文章中,我们将讨论没有数据的绘图。
传统上, WebGL 应用将几何数据放入缓冲区。 然后它使用 attribute 将顶点数据从这些缓冲区拉到到着色器中,并将它们转换为裁剪空间。
传统 一词十分重要。上述只是绘图的传统方式。它绝不是必须要求。
WebGL 不在乎我们怎么做,它只关心我们的顶点着色器将裁剪空间下的坐标转换到gl_Position
。
所以,现在让我们只提供计数给 attribute,而不是顶点位置。
const numVerts = 20;
const vertexIds = new Float32Array(numVerts);
vertexIds.forEach((v, i) => {
vertexIds[i] = i;
});
const idBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, idBuffer);
gl.bufferData(gl.ARRAY_BUFFER, vertexIds, gl.STATIC_DRAW);
现在让我们编写顶点着色器,基于上面的计数来绘制一个顶点组成的圆。
attribute float vertexId;
uniform float numVerts;
#define PI radians(180.0)
void main() {
float u = vertexId / numVerts; // 取值 0 到 1
float angle = u * PI * 2.0; // 取值 0 到 2PI
float radius = 0.8;
vec2 pos = vec2(cos(angle), sin(angle)) * radius;
gl_Position = vec4(pos, 0, 1);
gl_PointSize = 5.0;
}
上面的代码应该是非常明了的。
vertexId
将从 0 计数到numVerts
。
在此基础上,我们为圆生成顶点位置。
如果我们停在这里,这个圆将是个椭圆,因为裁剪空间是标准化分布(从-1 到 1)到画布。 如果我们传递了分辨率,就会考虑到投影空间的-1 到 1 覆盖范围与画布上的-1 到 1 并不相同。
attribute float vertexId;
uniform float numVerts;
+uniform vec2 resolution;
#define PI radians(180.0)
void main() {
float u = vertexId / numVerts; // 取值 0 到 1
float angle = u * PI * 2.0; // 取值 0 到 2PI
float radius = 0.8;
vec2 pos = vec2(cos(angle), sin(angle)) * radius;
+ float aspect = resolution.y / resolution.x;
+ vec2 scale = vec2(aspect, 1);
+ gl_Position = vec4(pos * scale, 0, 1);
gl_PointSize = 5.0;
}
而我们的片段着色器可以只输出单一颜色。
precision mediump float;
void main() {
gl_FragColor = vec4(1, 0, 0, 1);
}
在我们 Javascript 代码的初始化阶段,我们将编译着色器并查找 attribuites 和 uniforms。
// setup GLSL program
const program = webglUtils.createProgramFromSources(gl, [vs, fs]);
const vertexIdLoc = gl.getAttribLocation(program, "vertexId");
const numVertsLoc = gl.getUniformLocation(program, "numVerts");
const resolutionLoc = gl.getUniformLocation(program, "resolution");
而为了渲染,我们将使用该程序,用顶点 id 设置我们的一个 attribute。 设置 "resolution "和 "numVerts "的 uniform,最后画出这些点。
gl.useProgram(program);
{
// 启用 attribute
gl.enableVertexAttribArray(vertexIdLoc);
// 绑定缓冲区 idBuffer .
gl.bindBuffer(gl.ARRAY_BUFFER, idBuffer);
// 告诉attribute如何从idBuffer中提取数据 (ARRAY_BUFFER)
const size = 1; // 每个指针有一个数据
const type = gl.FLOAT; // 数据类型 32bit floats
const normalize = false; // 不要归一化数据
const stride = 0; // 0 = 每次迭代都向前移动大小 size * sizeof(type),以获得下一个位置。
const offset = 0; // 缓冲区读取数据的起点位置
gl.vertexAttribPointer(vertexIdLoc, size, type, normalize, stride, offset);
}
// 告知着色器顶点数量
gl.uniform1f(numVertsLoc, numVerts);
// 告知着色器分辨率
gl.uniform2f(resolutionLoc, gl.canvas.width, gl.canvas.height);
const offset = 0;
gl.drawArrays(gl.POINTS, offset, numVerts);
然后我们得到组成一个圆所需的点。
这一技术有用吗?用一些创造性的代码,我们几乎不需要数据, 只需调用一次绘制请求就可以做出一个星空或简单的雨景。
让我们做一个雨景的效果,看看它是否有效。首先,我们将顶点着色器改为:
attribute float vertexId;
uniform float numVerts;
uniform float time;
void main() {
float u = vertexId / numVerts; // 取值 0 到 1
float x = u * 2.0 - 1.0; // -1 到 1
float y = fract(time + u) * -2.0 + 1.0; // 1.0 -> -1.0
gl_Position = vec4(x, y, 0, 1);
gl_PointSize = 5.0;
}
在这种情况下,我们不需要分辨率。
我们添加了名为"time"的 unifrom,它代表页面加载后经过的秒数。
对于'x',我们只让他从-1 到 1。
对于'y',我们使用time + u
,但fract
只返回小数部分,所以是一个从 0.0 到 1.0 的值。
通过把他扩展到 1.0 到-1.0,我们得到一个往复的 y ,而每个点的偏移是不同的。
让我们把片段着色器中的颜色改为蓝色:
precision mediump float;
void main() {
- gl_FragColor = vec4(1, 0, 0, 1);
+ gl_FragColor = vec4(0, 0, 1, 1);
}
然后在 JavaScript 中,我们需要查找时间的 uniform
// 准备GLSL程序
const program = webglUtils.createProgramFromSources(gl, [vs, fs]);
const vertexIdLoc = gl.getAttribLocation(program, 'vertexId');
const numVertsLoc = gl.getUniformLocation(program, 'numVerts');
-const resolutionLoc = gl.getUniformLocation(program, 'resolution');
+const timeLoc = gl.getUniformLocation(program, 'time');
然后我们需要通过创建一个渲染循环并设置time
uniform 将代码转换为动画。
+function render(time) {
+ time *= 0.001; // 转换到秒
+ webglUtils.resizeCanvasToDisplaySize(gl.canvas);
+ gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
gl.useProgram(program);
{
// 启用 attribute
gl.enableVertexAttribArray(vertexIdLoc);
// 绑定缓冲区 idBuffer .
gl.bindBuffer(gl.ARRAY_BUFFER, idBuffer);
// 告诉attribute如何从idBuffer中提取数据 (ARRAY_BUFFER)
const size = 1; // 每个指针有一个数据
const type = gl.FLOAT; // 数据类型 32bit floats
const normalize = false; // 不要归一化数据
const stride = 0; // 0 = 每次迭代都向前移动大小 size * sizeof(type),以获得下一个位置。
const offset = 0; // 缓冲区读取数据的起点位置
gl.vertexAttribPointer(
vertexIdLoc, size, type, normalize, stride, offset);
}
// 告知着色器顶点数量
gl.uniform1f(numVertsLoc, numVerts);
+ // 告知着色器时间
+ gl.uniform1f(timeLoc, time);
const offset = 0;
gl.drawArrays(gl.POINTS, offset, numVerts);
+ requestAnimationFrame(render);
+}
+requestAnimationFrame(render);
我们得到了屏幕上下落的点,但它们都是顺序的。我们需要增加一些随机性。 在 GLSL 中没有随机数发生器。相反,我们可以使用一个函数来生成一些看上去足够随机的数据。
这是一个:
// 哈希函数来自 https://www.shadertoy.com/view/4djSRW
// 提供一个 0 到 1 的值
// 返回一个 0 到 1 内看似随机的值
float hash(float p) {
vec2 p2 = fract(vec2(p * 5.3983, p * 5.4427));
p2 += dot(p2.yx, p2.xy + vec2(21.5351, 14.3137));
return fract(p2.x * p2.y * 95.4337);
}
我们可以像这样使用
void main() {
float u = vertexId / numVerts; // 取值 0 到 1
- float x = u * 2.0 - 1.0; // -1 到 1
+ float x = hash(u) * 2.0 - 1.0; // 随机位置
float y = fract(time + u) * -2.0 + 1.0; // 1.0 -> -1.0
gl_Position = vec4(x, y, 0, 1);
gl_PointSize = 5.0;
}
我们把之前的 0 到 1 的值传给hash
,它就会给我们一个 0 到 1 的伪随机值。
让我们还让这些点变得更小。
gl_Position = vec4(x, y, 0, 1);
- gl_PointSize = 5.0;
+ gl_PointSize = 2.0;
同时提高我们绘制的点数量。
-const numVerts = 20;
+const numVerts = 400;
如此,我们便得到了:
如果你非常仔细观察,你可以看到雨在重复进行。 找到任意一组点,会发现它们从底部落下,又从顶部出现。 但如果背景有更多的事情发生,例如这种廉价的雨水效果发生在一个 3D 游戏上, 那可能没有人会注意到它的重复性。
我们可以通过增加一点随机性来解决重复的问题。
void main() {
float u = vertexId / numVerts; // 取值 0 到 1
+ float off = floor(time + u) / 1000.0; // 每个点每秒钟变化
- float x = hash(u) * 2.0 - 1.0; // 随机位置
+ float x = hash(u + off) * 2.0 - 1.0; // 随机位置
float y = fract(time + u) * -2.0 + 1.0; // 1.0 -> -1.0
gl_Position = vec4(x, y, 0, 1);
gl_PointSize = 2.0;
}
上面的代码中我们添加了off
。因为我们通过floor
得到floor(time + u)
的值,
它有效地成为了第二个每秒每顶点变化一次的计时器。
这个偏移量与点在屏幕下落的代码是同步的,所以在点跳回屏幕顶部的同时,
一些小量被添加到正在传递的值hash
中,这意味着这个特定的点将得到一个新的随机数,
从而得到一个新的随机水平位置。
得到的结果是雨滴效果不会再循环了:
那么相对gl.POINTS
我们可以更进一步吗?当然可以!
让我们来绘制圆圈。要做到这一点,我们需要一些围绕中心点的三角形,就像切片的馅饼。 我们可以把每个三角形看成是围绕饼的边缘的 2 个点,以及中心的 1 个点。 然后我们对每一片饼都进行重复。
因此,首先我们要有一个计数器,在每个饼片上改变一次
float sliceId = floor(vertexId / 3.0);
然后我们需要一个计数器沿着圆的边缘如下变化:
0, 1, ?, 1, 2, ?, 2, 3, ?, ...
其中 ? 值其实并不重要,因为从上图来看,第 3 个值总是在中心位置(0,0), 所以我们可以直接乘以 0,不去考虑数值。
为了获得上述模式,可以这样做
float triVertexId = mod(vertexId, 3.0);
float edge = triVertexId + sliceId;
对于边缘的点和中心的点,我们需要这种模式。循环 2 点个在边缘,1 个在中心。
1, 1, 0, 1, 1, 0, 1, 1, 0, ...
我们可以通过以下方式获得该序列
float radius = step(triVertexId, 1.5);
当 a < b step(a, b)
返回 1,否则返回 0。 你可以把它看作
function step(a, b) {
return a < b ? 1 : 0;
}
当 triVertexId
小于 1.5 时 step(triVertexId, 1.5)
会返回 1。
对每个三角形的前两个顶点返回 true,对最后一个顶点返回 false。
我们可以这样得到一个圆的三角形顶点
float numSlices = 8.0;
float sliceId = floor(vertexId / 3.0);
float triVertexId = mod(vertexId, 3.0);
float edge = triVertexId + sliceId;
float angleU = edge / numSlices; // 0.0 to 1.0
float angle = angleU * PI * 2.0;
float radius = step(triVertexId, 1.5);
vec2 pos = vec2(cos(angle), sin(angle)) * radius;
把所有这些放在一起,让我们来试着画一个圆。
attribute float vertexId;
uniform float numVerts;
uniform vec2 resolution;
#define PI radians(180.0)
void main() {
float numSlices = 8.0;
float sliceId = floor(vertexId / 3.0);
float triVertexId = mod(vertexId, 3.0);
float edge = triVertexId + sliceId;
float angleU = edge / numSlices; // 0.0 到 1.0
float angle = angleU * PI * 2.0;
float radius = step(triVertexId, 1.5);
vec2 pos = vec2(cos(angle), sin(angle)) * radius;
float aspect = resolution.y / resolution.x;
vec2 scale = vec2(aspect, 1);
gl_Position = vec4(pos * scale, 0, 1);
}
注意,这里我们把 resolution
放回去了,所以我们不会得到一个椭圆。
对于一个分为八份的圆,我们需要 8 * 3 个顶点。
-const numVerts = 400;
+const numVerts = 8 * 3;
同时我们要绘制 TRIANGLES
而不是 POINTS
const offset = 0;
-gl.drawArrays(gl.POINTS, offset, numVerts);
+gl.drawArrays(gl.TRIANGLES, offset, numVerts);
那如果我们想画多个圆呢?
我们所要做的就是想出一个circleId
,我们可以用它来为每个圆圈挑选一些位置。
我们可以用它来为每个圆选取一些位置,这些位置对圆中的所有顶点都是一样的。
float numVertsPerCircle = numSlices * 3.0;
float circleId = floor(vertexId / numVertsPerCircle);
下面让我们绘制一组圆中的某一个圆。
首先让我们把上面的代码变成函数:
vec2 computeCircleTriangleVertex(float vertexId) {
float numSlices = 8.0;
float sliceId = floor(vertexId / 3.0);
float triVertexId = mod(vertexId, 3.0);
float edge = triVertexId + sliceId;
float angleU = edge / numSlices; // 0.0 to 1.0
float angle = angleU * PI * 2.0;
float radius = step(triVertexId, 1.5);
return vec2(cos(angle), sin(angle)) * radius;
}
这里是本文开头出现原始代码,用来绘制圆上的点。
float u = vertexId / numVerts; // 取值 0 到 1
float angle = u * PI * 2.0; // 取值 0 到 2PI
float radius = 0.8;
vec2 pos = vec2(cos(angle), sin(angle)) * radius;
float aspect = resolution.y / resolution.x;
vec2 scale = vec2(aspect, 1);
gl_Position = vec4(pos * scale, 0, 1);
我们只需要把vertexId
替换成circleId
,
并除以圆的数量而非顶点数。
void main() {
+ float circleId = floor(vertexId / numVertsPerCircle);
+ float numCircles = numVerts / numVertsPerCircle;
- float u = vertexId / numVerts; // 取值 0 到 1
+ float u = circleId / numCircles; // 取值 0 到 1
float angle = u * PI * 2.0; // 取值 0 到 2PI
float radius = 0.8;
vec2 pos = vec2(cos(angle), sin(angle)) * radius;
+ vec2 triPos = computeCircleTriangleVertex(vertexId) * 0.1;
float aspect = resolution.y / resolution.x;
vec2 scale = vec2(aspect, 1);
- gl_Position = vec4(pos * scale, 0, 1);
+ gl_Position = vec4((pos + triPos) * scale, 0, 1);
}
接下来我们只需要增加定点数量即可:
-const numVerts = 8 * 3;
+const numVerts = 8 * 3 * 20;
而现在我们有一个由 20 个圆组成的大圆。
然后理所当然我们也可以把同样的功能应用到上面雨景中,来让雨滴编程圆。 这也许没什么意义,所以我不打算继续进行, 但上述内容确实显示了在顶点着色器不利用数据绘制的流程。
上述技术可用于制作矩形或正方形,然后生成 UV 坐标, 将其传递给片段着色器,并对生成的几何体进行纹理映射。 这可能很适合用于落下的雪花或树叶, 通过应用我们在文章中使用的 3D 技术,使它们在 3D 中翻转。 的文章中所使用的 3D 技术。 3D perspective.
我想强调 上述技术 并不常见。 制作一个简单的粒子系统或上面的降雨效果可能还算常见,但大量的计算会降低性能表现。 通常来说,如果你追求性能表现,你应该尽可能减少要求计算机负担的工作, 如果有些东西可以在初始化时预先计算,并以某种形式传递给着色器,你就应该这样做。
作为例子,这里有一个极端的顶点着色器程序,它计算了一批立方体: (警告:有声音)
但若把“如果我没有数据,只有顶点 ID,我可以画出有趣的东西吗?” 看作益智谜题来挑战,还是非常有趣的。 事实上整个网站都是围绕只使用顶点 ID 来得到有趣的结果这一问题展开。 但是为了性能考虑,使用传统方法把方块的顶点数据传入缓冲区, 并使用 attribute 或其他方法读取,会快上许多。 这方面我们将在其他文章中继续讨论。
这里需要做一些取舍。对于上面的雨的例子,如果你确实想要那种效果,那么上面的代码是相当有效的。 在性能与效果的,存在着一种技术比另一种技术更有性能的界限。 通常来说,更传统的技术也更灵活,但你必须根据具体情况决定何时选择哪种方式。
这篇文章的重点在于介绍这些想法,并强调应多方面思考 WebGL 实际应当负担的工作。
同样,它只关心你在着色器中设置的gl_Position
和gl_FragColor
,而并不关心你是怎么做的。
接下来请阅读Shadertoy 着色器的运作方式.