Web特效06------WebGL深挖:顶点着色器和片元着色器到底在干什么
上一篇我们把Canvas2D、WebGL、WebGPU三个原生API的抽象层级摆清楚了,也用一个最简三角形的demo,摸了一遍WebGL"写着色器→编译→绑定→绘制"的完整流程。但那个demo刻意做得极简------颜色是写死的,位置也是写死的,真正做特效需要的东西,都被我们一笔带过了。
这一篇就沿着这条线往下挖:顶点着色器和片元着色器到底是怎么分工协作的、attribute/uniform/varying这几种数据传递方式各自解决什么问题、JS里的数据怎么通过uniform实时传进shader、让画面动起来。裁剪空间坐标(NDC)和真实顶点坐标之间的变换,也会在这篇里补上------这是后面做旋转、缩放、3D场景绕不开的一环。这篇会比上一篇更"动手"一些:在上次三角形demo的基础上,加一个能让颜色随时间渐变、图形能旋转的进阶版本,让uniform到底怎么用变得直观。

一、从写死到动起来:这次我们把颜色和位置都交给变量
1. 上一篇的demo,其实是个"标本"
回忆一下上一篇画三角形的那段代码,摘两行出来看:
javascript
// 顶点着色器
gl_Position = vec4(aPosition, 0.0, 1.0);
// 片元着色器
gl_FragColor = vec4(0.22, 0.54, 0.86, 1.0); // 一个蓝色
这两行代码能跑,但严格说它更像一个"标本"------三角形的位置写死在JS的vertices数组里,颜色写死在片元着色器的vec4(0.22, 0.54, 0.86, 1.0)里。整个画面从第一帧到最后一帧,永远长一个样。这在现实里没什么用:你不可能真的为每一帧、每一个位置的变化都重新编译一次着色器程序。
2. 真正的特效,靠的是"同一段代码,喂不同的数据"
这里有一个GPU编程里很核心的思路,跟写JS函数很像:你不会为每个输入值单独写一个函数,而是写一个通用函数,调用的时候传参数进去。
javascript
// 类比:写死 vs 传参数
function drawFixedColor() {
return [0.22, 0.54, 0.86, 1.0]; // 永远是这个颜色
}
function drawColor(r, g, b, a) {
return [r, g, b, a]; // 每次调用可以传不同的颜色
}
WebGL里的做法是一样的:着色器程序本身只编译一次,但每一帧、每一次绘制,你可以往里面"喂"不同的数据------这一帧三角形转了5度,下一帧转了10度;这一帧是蓝色,下一帧渐变成了橙色。变的是数据,不变的是那段GLSL代码。
这也是为什么WebGL的性能潜力这么大:着色器程序编译一次,之后GPU可以拿着同一份指令,对海量不同的数据并行跑------重复利用同一套逻辑,正是并行计算的前提。
3. 这一篇要打通的,是"JS数据"到"GPU变量"这条路
写死的代码没有变量,动起来的代码离不开变量。这一篇要解决的核心问题就是:JS这边算出来的数据(比如"现在的时间""旋转了多少度""用户点击的位置"),怎么传进跑在GPU上的着色器里?
答案要靠三个关键字:attribute、uniform、varying。它们各自负责一段不同的"数据旅程"------有的负责把"每个顶点独有"的数据从JS送进顶点着色器,有的负责把"整次绘制共享"的数据(比如一个时间戳)送进去,还有的负责把顶点着色器算出来的东西,一路传递给片元着色器用。下一节我们就拆开看,两段着色器到底怎么分工协作,为引入这几个关键字打好铺垫。
二、顶点着色器与片元着色器:两段代码怎么分工协作
1. 一个管流水线的入口,一个管流水线的出口
上一篇的mermaid管线图里,顶点数据先经过顶点着色器,再经过光栅化,最后经过片元着色器。这两段着色器夹在流水线的两端,各自的职责分得很清楚:
- 顶点着色器(Vertex Shader) :管流水线的"入口"。它拿到一个顶点的原始数据(比如位置坐标),算出这个顶点最终应该出现在屏幕的哪个位置,输出到内置变量
gl_Position。它不知道、也不关心这个三角形最后会被涂成什么颜色。 - 片元着色器(Fragment Shader) :管流水线的"出口"。三角形被光栅化拆成一个个像素之后,每个像素都要单独跑一遍片元着色器,算出这个像素该是什么颜色,输出到
gl_FragColor。它不知道、也不关心这个像素原本属于哪几个顶点围成的三角形。
两者中间隔着"光栅化"这一步------把顶点连成的三角形,拆解成一个个覆盖到的像素格子。这一步是GPU固定的硬件行为,不需要你写代码。
2. 调用次数天差地别
这是最容易被忽略、但特别关键的一点:顶点着色器和片元着色器的调用次数完全不是一个量级。
一个三角形只有3个顶点,顶点着色器就只会被调用3次------每个顶点算一次位置。但这个三角形在屏幕上可能覆盖了几千甚至几万个像素,片元着色器就要被调用几千几万次------每个像素都要单独算一次颜色。

这也是本节插图想说明的事:同一个三角形,顶点着色器在三个角上各跑一次,片元着色器要给内部铺满的每一个像素格子都跑一次。理解这个调用次数的数量级差异很重要------如果你在片元着色器里写了一段复杂的计算,它会被重复执行成千上万次,性能代价和写在顶点着色器里完全不是一回事。
3. 两段代码怎么"对上话"
顶点着色器和片元着色器是两段独立编译的代码,但它们之间需要传递信息------比如你想给三角形的每个顶点设置不同的颜色,让内部自然过渡出一个渐变效果,这个"渐变"的计算是谁来做?
答案是:顶点着色器给每个顶点算出一个颜色值,光栅化的时候,GPU会自动帮你在三个顶点的颜色之间做插值(Interpolation)------离哪个顶点近,颜色就更偏向哪个顶点,中间自然过渡。片元着色器拿到的,就是这个插值算好之后、属于当前像素的颜色。
javascript
// 顶点着色器:每个顶点输出一个颜色
attribute vec2 aPosition;
attribute vec3 aColor;
varying vec3 vColor; // 传给片元着色器的"信使"
void main() {
gl_Position = vec4(aPosition, 0.0, 1.0);
vColor = aColor; // 把颜色交给varying
}
// 片元着色器:接收插值后的颜色
precision mediump float;
varying vec3 vColor; // 名字要和顶点着色器里的一致
void main() {
gl_FragColor = vec4(vColor, 1.0); // 直接用插值后的颜色
}
代码里出现的varying,就是专门负责"顶点着色器算出的值,插值之后传给片元着色器"这条路径的关键字。它是这一篇要讲的三个关键字(attribute、uniform、varying)里,第一个登场的。
顶点着色器管起点,片元着色器管终点,varying管中间的插值传递------这条链路搞清楚之后,下一节我们把attribute和uniform也补齐,看JS这边的数据到底是怎么"喂"进这两段着色器的。
三、attribute、uniform、varying:三种数据各管一段旅程

1. 先分清楚:这三个关键字要解决的是不同的问题
上一节结尾提到了varying,这一节把attribute和uniform也补齐。三者都是GLSL里用来声明"数据从哪来"的关键字,但它们回答的是三个完全不同的问题:
attribute:这个数据,每个顶点都不一样吗?------如果是,用attribute。uniform:这个数据,这一次绘制里所有顶点/像素都一样吗?------如果是,用uniform。varying:这个数据,是顶点着色器算出来、需要传给片元着色器的吗?------如果是,用varying。
判断用哪个关键字,本质上就是回答"这份数据的作用范围有多大"。插图里三条颜色不同的路径,对应的正是这三条不同的作用范围。
2. attribute:从JS的顶点数组,一对一送进顶点着色器
attribute只能出现在顶点着色器里,它对应的数据来自JS这边一份按顶点组织的数组------最典型的就是上一篇demo里的顶点坐标:三个顶点,每个顶点一组独立的坐标值。
javascript
// JS这边:准备好每个顶点独有的数据
const positions = new Float32Array([
0.0, 0.5, // 顶点1
-0.5, -0.5, // 顶点2
0.5, -0.5, // 顶点3
]);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);
const aPosition = gl.getAttribLocation(program, 'aPosition');
gl.enableVertexAttribArray(aPosition);
gl.vertexAttribPointer(aPosition, 2, gl.FLOAT, false, 0, 0);
glsl
// 顶点着色器:接收"每个顶点独有"的数据
attribute vec2 aPosition;
void main() {
gl_Position = vec4(aPosition, 0.0, 1.0);
}
GPU在跑顶点着色器时,会自动按顶点把buffer里对应的一小段数据"喂"进aPosition------第一次调用拿到顶点1的坐标,第二次调用拿到顶点2的坐标,以此类推。除了位置,法线、UV坐标、每个顶点单独的颜色,都是典型的attribute数据。
3. uniform:这一次绘制里,所有顶点/像素共享的一份数据
attribute是"一人一份",uniform则是"全场共用一份"------顶点着色器和片元着色器都能读到同一个uniform变量,而且这次绘制里,不管跑多少次,这个值都不会变。
最典型的场景就是这篇要做的"动起来":时间、旋转角度,这些不属于某个具体顶点,而是整次绘制统一适用的一个值。
javascript
// JS这边:每一帧算一个新的时间值,传给uniform
const uTime = gl.getUniformLocation(program, 'uTime');
function render(now) {
gl.uniform1f(uTime, now * 0.001); // 传入当前时间(秒)
gl.drawArrays(gl.TRIANGLES, 0, 3);
requestAnimationFrame(render);
}
requestAnimationFrame(render);
glsl
// 顶点着色器:用uniform传进来的时间,让三角形转起来
attribute vec2 aPosition;
uniform float uTime;
void main() {
float angle = uTime; // 角度随时间变化
mat2 rotation = mat2(cos(angle), -sin(angle), sin(angle), cos(angle));
gl_Position = vec4(rotation * aPosition, 0.0, 1.0);
}
这里gl.uniform1f()就是JS往GPU那边"喂"uniform数据的方式------每一帧调用一次,把最新的时间值传进去,着色器代码本身完全不用变。这正好呼应第一节说的"同一段代码,喂不同的数据"。
插图里之所以uniform同时画了两条箭头(分别指向顶点着色器和片元着色器),是因为uniform在这两段着色器里都能直接读取------比如你可能既要用uTime控制顶点的旋转,也要用它控制片元着色器里颜色的渐变节奏。
4. varying:顶点算出来的值,插值之后才轮到片元着色器用
varying比较特殊,它不是从JS那边传进来的,而是顶点着色器算出来、光栅化的时候自动做插值、再交给片元着色器的一份数据。上一节末尾那段"三角形颜色渐变"的代码就是典型例子:三个顶点各自输出一个颜色,中间的每个像素拿到的都是插值之后的过渡色。
需要特别注意的是:varying变量的名字,必须在顶点着色器和片元着色器里完全对上,GPU是靠名字匹配的,写错一个字母,颜色就传不过去。
5. 三条数据旅程,对应三种真实需求
把这三个关键字和插图里的三条路径对上号:
| 关键字 | 数据来源 | 作用范围 | 典型用途 |
|---|---|---|---|
| attribute | JS的顶点数组 | 只在顶点着色器,每个顶点各不相同 | 顶点坐标、法线、每顶点颜色 |
| uniform | JS单次赋值 | 顶点+片元着色器都能读,这次绘制里全局不变 | 时间、变换矩阵、全局颜色 |
| varying | 顶点着色器算出 | 顶点着色器→(插值)→片元着色器 | 传递颜色、传递UV坐标等需要过渡的数据 |
三条路径打通之后,我们已经具备了让三角形"转起来、变色"所需要的全部数据传递手段。下一节就把裁剪空间坐标(NDC)和真实的旋转/位移变换关系讲清楚,为第五节的动手demo做最后的准备。
四、从NDC到真实坐标:变换是怎么发生的

1. 顶点着色器输出的,从来不是像素坐标
第三节写gl_Position = vec4(aPosition, 0.0, 1.0)的时候,你可能没细想过一件事:这个输出值,单位到底是什么?
答案是:既不是像素,也不是CSS像素,而是一套被限定在-1到1之间的坐标系统 ,专业名字叫裁剪空间坐标(Clipping Space Coordinates),更常用的叫法是NDC(Normalized Device Coordinates,归一化设备坐标)。不管你的canvas实际是400×300像素,还是1920×1080像素,顶点着色器最终输出的x、y值都必须落在-1到1这个区间里------超出这个范围的部分,会在光栅化之前被直接裁掉,这也是"裁剪空间"这个名字的来历。
这也是为什么第二篇《坐标系统》里强调"画面≠数据"这件事,在WebGL这里体现得格外直接:GPU压根不关心你的canvas有多少像素,它只关心你给的坐标落没落在这个-1到1的盒子里。
2. 谁来负责"从NDC变回真实像素"
真正的映射工作,插图里已经画出来了:GPU在光栅化之前,会做一次视口变换(Viewport Transform) ,把NDC里的-1到1,按你设置的画布尺寸,重新映射回真实的像素坐标。这一步不需要你写任何代码,WebGL在你调用gl.viewport(0, 0, canvas.width, canvas.height)设置好画布尺寸后,会自动帮你完成。
但这中间藏着一个容易踩的坑------Y轴方向翻转了。NDC里Y轴向上为正(数学坐标系的习惯),而真实的像素画布坐标是Y轴向下为正、原点在左上角(这正是第二篇讲过的"屏幕坐标系")。GPU的视口变换会自动帮你处理这次翻转,你不需要手动去反转Y值------但理解这个翻转确实发生了,能帮你在后面调试"图形上下颠倒"这类问题时,少走弯路。
3. 旋转、缩放,都是在NDC这个盒子里发生的
第三节uniform那部分的旋转demo,用到了一个旋转矩阵:
glsl
float angle = uTime;
mat2 rotation = mat2(cos(angle), -sin(angle), sin(angle), cos(angle));
gl_Position = vec4(rotation * aPosition, 0.0, 1.0);
这里有个关键点:这个旋转,是在NDC这个-1到1的空间里发生的,不是在像素空间里发生的。 aPosition本身已经是-1到1范围内的坐标,乘上旋转矩阵之后,输出的gl_Position也还落在这个范围内------GPU负责的视口变换,是在这一切都算完之后才发生的最后一步。
这也是为什么图形变换(平移、旋转、缩放)几乎都习惯写成矩阵乘法的形式:不管是mat2(2×2矩阵,管旋转缩放)还是后面3D场景里会用到的mat4(4×4矩阵,管平移+旋转+缩放+透视投影),都是在这套统一的、以原点为中心的坐标空间里做运算,运算完了才一次性映射到真实画布------这样GPU才能把同一套矩阵运算,批量套用在成千上万个顶点上。
4. 小结:三层坐标,各司其职
| 坐标系统 | 范围 | 谁在用 | 由谁负责映射 |
|---|---|---|---|
| CSS像素坐标 | 0到canvas的CSS宽高 | HTML/CSS布局 | 浏览器 |
| NDC(裁剪空间坐标) | -1到1,Y轴向上 | 顶点着色器的输出 | 你在着色器里手动计算 |
| 真实像素坐标 | 0到canvas.width/height,Y轴向下 | 最终画在屏幕上的位置 | GPU的视口变换,自动完成 |
搞清楚这三层坐标各自的范围和职责,第五节的旋转+变色demo里所有的数值就都有据可依了。下一节我们就把前面三、四节讲的attribute、uniform、varying和这套NDC变换,全部揉进一个真正能跑起来、会转会变色的demo里。
五、动手做一个会转、会变色的三角形
前面四节攒下来的东西,这一节要全部用上:attribute传顶点位置、uniform传时间驱动旋转和变色、varying把顶点颜色插值传给片元着色器、旋转矩阵在NDC空间里做变换。拼起来,就是一个真正会转、会渐变的三角形。

1. 顶点着色器:管旋转,也管每个顶点的颜色
glsl
attribute vec2 aPosition;
attribute vec3 aColor;
uniform float uTime;
varying vec3 vColor;
void main() {
float angle = uTime; // 旋转角度随时间变化
mat2 rotation = mat2(
cos(angle), -sin(angle),
sin(angle), cos(angle)
);
gl_Position = vec4(rotation * aPosition, 0.0, 1.0);
vColor = aColor; // 把每个顶点的颜色交给varying,插值后传给片元着色器
}
aPosition和aColor是attribute,每个顶点各自独立;uTime是uniform,这一帧里所有顶点共用同一个值;vColor是varying,负责把颜色接力传给片元着色器。
2. 片元着色器:接住插值后的颜色
glsl
precision mediump float;
varying vec3 vColor;
void main() {
gl_FragColor = vec4(vColor, 1.0);
}
片元着色器这边非常简单------它不用关心颜色是怎么插值出来的,GPU已经在光栅化阶段把这件事做完了,它只管把拿到的vColor直接输出。
3. JS这边:准备数据、每帧更新uTime
javascript
const canvas = document.getElementById('stage');
const gl = canvas.getContext('webgl');
function compileShader(gl, type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
return shader;
}
const vs = compileShader(gl, gl.VERTEX_SHADER, vertexSrc); // 上面的顶点着色器源码
const fs = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSrc); // 上面的片元着色器源码
const program = gl.createProgram();
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
gl.useProgram(program);
// 三个顶点的位置(NDC坐标,-1到1)
const positions = new Float32Array([
0.0, 0.6,
-0.6, -0.4,
0.6, -0.4,
]);
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);
const aPosition = gl.getAttribLocation(program, 'aPosition');
gl.enableVertexAttribArray(aPosition);
gl.vertexAttribPointer(aPosition, 2, gl.FLOAT, false, 0, 0);
// 三个顶点各自的颜色(红、绿、蓝三个角,片元着色器会自动插值过渡)
const colors = new Float32Array([
1.0, 0.2, 0.2,
0.2, 1.0, 0.3,
0.2, 0.4, 1.0,
]);
const colorBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
gl.bufferData(gl.ARRAY_BUFFER, colors, gl.STATIC_DRAW);
const aColor = gl.getAttribLocation(program, 'aColor');
gl.enableVertexAttribArray(aColor);
gl.vertexAttribPointer(aColor, 3, gl.FLOAT, false, 0, 0);
const uTime = gl.getUniformLocation(program, 'uTime');
function render(now) {
gl.viewport(0, 0, canvas.width, canvas.height);
gl.clearColor(1, 1, 1, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.uniform1f(uTime, now * 0.001); // 每帧更新时间,驱动旋转
gl.drawArrays(gl.TRIANGLES, 0, 3);
requestAnimationFrame(render);
}
requestAnimationFrame(render);
4. 每一行都能对上前四节讲过的概念
跑起来之后,这个三角形会一边旋转,一边在红、绿、蓝三个顶点色之间平滑过渡。拆开看,每一部分都能对应到前面讲过的东西:
| 代码里的这部分 | 对应第几节讲的概念 |
|---|---|
positions数组,每个顶点独立坐标 |
第三节:attribute |
colors数组,每个顶点独立颜色 |
第三节:attribute |
gl.uniform1f(uTime, ...)每帧更新 |
第三节:uniform |
vColor在两段着色器间传递 |
第二、三节:varying |
mat2(cos, -sin, sin, cos)旋转矩阵 |
第四节:NDC空间里的变换 |
positions里的值都在-1到1之间 |
第四节:裁剪空间坐标 |
从第一节那个"写死颜色和位置"的标本,到现在这个会转、会变色的三角形,attribute负责喂进每个顶点的个体数据,uniform负责喂进随时间变化的全局数据,varying负责把顶点算出的结果接力传给像素------这三条数据旅程,加上NDC空间里的矩阵变换,就是WebGL里做任何动态特效的最基本骨架。
5. 完整代码
javascript
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>WebGL 旋转渐变三角形</title>
<style>
body {
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
background: #f1efe8;
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 20px;
}
h1 {
font-size: 18px;
font-weight: 500;
color: #2c2c2a;
margin: 0 0 8px;
}
p {
font-size: 13px;
color: #5f5e5a;
margin: 0 0 20px;
}
canvas {
border: 0.5px solid #b4b2a9;
border-radius: 8px;
background: #ffffff;
}
</style>
</head>
<body>
<h1>WebGL 旋转渐变三角形</h1>
<p>attribute传位置和颜色 · uniform传时间驱动旋转 · varying插值传递颜色</p>
<canvas id="stage" width="500" height="400"></canvas>
<script>
const canvas = document.getElementById('stage');
const gl = canvas.getContext('webgl');
if (!gl) {
document.body.innerHTML += '<p style="color:#A32D2D;">当前浏览器不支持WebGL</p>';
} else {
// 顶点着色器:管旋转,也管每个顶点的颜色
const vertexSrc = `
attribute vec2 aPosition;
attribute vec3 aColor;
uniform float uTime;
varying vec3 vColor;
void main() {
float angle = uTime;
mat2 rotation = mat2(
cos(angle), -sin(angle),
sin(angle), cos(angle)
);
gl_Position = vec4(rotation * aPosition, 0.0, 1.0);
vColor = aColor;
}
`;
// 片元着色器:接住插值后的颜色
const fragmentSrc = `
precision mediump float;
varying vec3 vColor;
void main() {
gl_FragColor = vec4(vColor, 1.0);
}
`;
function compileShader(gl, type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error(gl.getShaderInfoLog(shader));
}
return shader;
}
const vs = compileShader(gl, gl.VERTEX_SHADER, vertexSrc);
const fs = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSrc);
const program = gl.createProgram();
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
gl.useProgram(program);
// 三个顶点的位置(NDC坐标,-1到1)
const positions = new Float32Array([
0.0, 0.6,
-0.6, -0.4,
0.6, -0.4,
]);
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);
const aPosition = gl.getAttribLocation(program, 'aPosition');
gl.enableVertexAttribArray(aPosition);
gl.vertexAttribPointer(aPosition, 2, gl.FLOAT, false, 0, 0);
// 三个顶点各自的颜色(红、绿、蓝三个角,片元着色器会自动插值过渡)
const colors = new Float32Array([
1.0, 0.2, 0.2,
0.2, 1.0, 0.3,
0.2, 0.4, 1.0,
]);
const colorBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
gl.bufferData(gl.ARRAY_BUFFER, colors, gl.STATIC_DRAW);
const aColor = gl.getAttribLocation(program, 'aColor');
gl.enableVertexAttribArray(aColor);
gl.vertexAttribPointer(aColor, 3, gl.FLOAT, false, 0, 0);
const uTime = gl.getUniformLocation(program, 'uTime');
function render(now) {
gl.viewport(0, 0, canvas.width, canvas.height);
gl.clearColor(1, 1, 1, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.uniform1f(uTime, now * 0.001);
gl.drawArrays(gl.TRIANGLES, 0, 3);
requestAnimationFrame(render);
}
requestAnimationFrame(render);
}
</script>
</body>
</html>
六、这一篇之后,离"做出一个特效"还差什么
第五节那个会转、会变色的三角形,看着挺好玩,但离你在别人网站上见过的那些WebGL特效------粒子爆炸、流体模拟、3D产品展示------还差得远。这一节盘点一下,中间还缺哪几块拼图。

1. 纹理(Texture):让顶点不只是纯色
现在的三角形每个顶点只有一个颜色值,靠插值过渡。但真实场景里,大部分表面不是纯色渐变,而是贴了一张图片------木纹、砖墙、人物贴图。这就要用到纹理(Texture):把一张图片上传到GPU,在片元着色器里,根据每个像素对应的UV坐标,去这张图片上"采样"出对应位置的颜色,而不是用插值算出来的颜色。UV坐标本身也是一种attribute数据,和这一篇讲的位置、颜色走的是同一条路。
2. 批量绘制:一次drawArrays画一个三角形,远远不够
现实中的特效往往是成百上千个粒子、成千上万个顶点同时在动,不可能每个物体都单独调一次gl.drawArrays()------那样CPU和GPU之间的通信开销会先把性能拖垮。这就要用到批量绘制 的思路:把所有物体的顶点数据一次性传给GPU,用一次绘制调用画出全部,或者用实例化绘制(Instanced Drawing)------同一份几何形状,配合一份"每个实例各不相同"的数据(位置、颜色、大小),一次调用画出成千上万个副本。前面提到的粒子特效,基本都建立在这个技术上。
3. 3D变换:从"转个圈"到"真正的三维空间"
这一篇的旋转矩阵mat2只处理了2D平面内的旋转。真正的3D场景需要三件套:模型矩阵 (物体自己在世界里的位置朝向)、视图矩阵 (相机在看哪里)、投影矩阵 (怎么把三维空间"拍扁"成屏幕上的二维画面,比如近大远小的透视效果)。这三个矩阵通常合并成一个mat4,乘在顶点位置上------比第四节讲的NDC变换更进一步,但底层逻辑是相通的:都是在着色器里,用矩阵运算把顶点位置从一个坐标空间搬到另一个坐标空间。
4. 混合与粒子:让画面"叠"起来,而不只是"盖"住
现在的三角形是完全不透明的,后画的直接盖住先画的。真正的特效经常需要半透明叠加------烟雾、光晕、火焰边缘------这需要开启混合(Blending),让新画的颜色和已经画在画布上的颜色按透明度做加权叠加,而不是简单覆盖。粒子特效本质上就是"批量绘制+混合"的组合:成千上万个半透明的小色块,叠加出云雾、火花、光效这些视觉效果。
小结
纹理解决"表面质感",批量绘制解决"数量级",3D变换解决"空间维度",混合解决"透明叠加"------这四块补齐之后,你手上的工具已经足够拼出绝大多数常见的WebGL特效了。下一篇,我们从纹理开始,把"给三角形贴一张图"这件事讲透。
七、关于八荒启
八荒启是一家专注于交互体验产品与解决方案的品牌,持续探索交互技术在教育教学、产品展示、过程模拟、操作训练和数据可视化等场景中的应用。
我们不仅分享技术实现,也持续创作和沉淀交互动画、数字作品、开发教程、项目案例与行业解决方案,希望通过交互技术,让复杂事物变得更加容易理解、探索、操作和创造。
八荒启,专为交互动画而生
让复杂事物可探索、可操作、可反馈
如果你正在寻找交互作品、学习相关技术,或者希望把一个想法转化为可实际操作的交互项目,欢迎访问八荒启官网了解更多案例与服务。
- 官方网站:https://bahuangqi.com
- 主要内容:交互动画、3D 可视化、教育互动、仿真模拟与技术教程
- 定制服务:可通过官网提交需求或联系人工客服进行评估
感谢阅读。如果本文对你有所帮助,欢迎点赞、收藏和关注,我们会继续分享更多交互作品与项目实践。