写WebGL绕不开着色器。哪怕你已经会用gl.drawArrays画出三角形,只要想换颜色、做扭曲、加光照,立刻会发现卡在一段看不懂的字符串代码上——没错,那就是着色器。这套基础教程前两篇我们把初始化流程和绘制管线铺完了,这一篇专门聊着色器的原理和实际使用技巧,我尽量把"为什么这么写"讲透,而不是只丢给你一堆能跑但不敢改的代码。
先说清楚一件事:普通过程化编程里,代码在CPU上运行,逐行执行;但在WebGL里,着色器是你在JS里写好、以字符串形式传给GPU的一段GLSL代码,它在GPU里并行执行。这个并行模型决定了很多反直觉的行为,比如"每个顶点跑一遍顶点着色器""每个像素跑一遍片元着色器"。搞懂这套运行逻辑,剩下就是语法和工程组织问题。
1. 为什么非要写两个"万金油"着色器:GPU流水线的角色分工
1.1 一次drawCall背后发生了什么
当你在JS里执行gl.drawArrays(gl.TRIANGLES, 0, 3),WebGL并不是一句话就把三角形画出来了,而是按照一条固定流水线处理数据。这条流水线可以简化成三个阶段:顶点处理、图元装配、像素着色。
顶点处理阶段执行的就是顶点着色器(Vertex Shader)。每个顶点,不管多少,都会独立跑一遍这段代码。假设你画一个三角形,三个顶点各跑一次,这三个顶点之间没有任何通信,谁也不知道另外两个顶点的属性是什么,GPU把它们塞进大量的并行计算单元里同时跑,这就是"可编程渲染管线"的起点。
接着是图元装配:GPU把你给的三个顶点按绘制模式组合成三角形,做裁剪、剔除、背面判断等固定操作。这个阶段对你来说是黑盒,没有可编程入口。
最后一步是片元着色器(Fragment Shader)。光栅化阶段会把这个三角形拆成屏幕上覆盖的每一个像素点,每个像素都会执行一次片元着色器。一个分辨率1920x1080的窗口全屏渲染三角形,片元着色器就要跑约两百万次。这也是为什么片元着色器里不能写太重的循环——性能开销是像素级别的,不是顶点级别的。
1.2 顶点着色器:决定"顶点在哪"和"带着什么走"
顶点着色器的核心职责有两个:计算顶点的最终位置,以及准备一些数据递给后续阶段。
位置输出是强制性的,通过内置变量gl_Position发送给GPU。它必须是一个四维向量vec4,其中xy在裁剪空间中,z负责深度排序,w在透视投影中会被用作除法分母。很多初学者把坐标硬编码进去时直接写vec4(x, y, 0.0, 1.0),这里最后的1.0就是w,代表这个顶点的齐次坐标分量没做透视处理。
数据传递则依赖varying变量(WebGL2里叫out/in)。比如你想给三角形每个角涂不同颜色,顶点着色器可以读入顶点颜色属性,然后以varying方式传进片元着色器。GPU会自动对像素进行插值,三角形中间的颜色是三个顶点颜色的渐变混合。这是理解整个着色器数据流的关键。
1.3 片元着色器:像素界的调色师
片元着色器没有强制输出位置,它只有一个强制输出:颜色。通过内置变量gl_FragColor(WebGL2使用自定义输出变量)告诉GPU这个像素最终以什么颜色出现在屏幕上。
片元着色器能访问的数据来源有三个:由顶点着色器插值过来的varying、全局的uniform、以及纹理采样等等。它不能修改任何其他像素的状态,只能决定自己负责的那一个像素怎么着色。这个"不能串门"的特性让GPU可以疯狂并行,但你也别想在这里做任何依赖邻居像素的计算(那是后处理管线的事,得用帧缓冲绕一圈)。
1.4 为什么不能只用JS来画
你可能会问:既然JS也无脑算一遍每个像素,干嘛要把代码塞进字符串里交给GPU?答案在并行度和硬件优化上。CPU一个核同时能执行的算术不多,而GPU动辄几千个核心,处理纯数学任务时,几百万个片元着色器实例是同时跑的。尤其现代GPU还有SIMD和warp调度,大批量同质运算的吞吐比CPU高出好几个数量级。
另外,GPU对内存的访问模式做了专门优化。纹理贴图、深度测试、透明度混合这些操作都被固化到硬件管线里,着色器只需要定义颜色输出,后续硬件自动处理。你把这段逻辑搬回CPU,性能马上就垮掉。
2. GLSL语法速成:看懂顶点着色器和片元着色器里的每一行
2.1 变量类型:vec2、vec4、mat4与属性修饰符
GLSL(OpenGL Shading Language)是C语言风格的强类型语言,和JS最大的直观区别就是变量必须声明类型,且类型严格匹配。字符串、对象、数组这种高级类型一概没有,有的只是数学运算需要的向量、矩阵、采样器。
最常用的是向量类型:
vec2:两个浮点分量,通常用来表示坐标uv、宽高比;vec3:三个分量,颜色RGB、法线、三维坐标;vec4:四个分量,齐次坐标、RGBA颜色。
访问分量既可以用数组下标v[0],也可以用语法糖:v.rgba对应颜色字面,或v.xyzw对应空间字面,或者直接混合使用v.rgb提取三维部分,v.xy提取前二维。这在写代码时很顺手,比如gl_Position = vec4(a_position, 1.0),把vec2扩充成vec4。
矩阵类型mat3、mat4也常见。三维变换一般用mat4,因为能包含平移、旋转、缩放和透视投影。这里有个新手容易踩的坑:GLSL的矩阵是列主序存储,意味着从JS传入Float32Array时,应该按"一列一列"填数据,而不是按行读上去的直觉。后面我们讲uniform赋值时细说。
变量修饰符主要有三种:
attribute:顶点级输入,每个顶点都有自己的一份,只在顶点着色器里出现;uniform:全局常量,整次drawCall都相同,顶点和片元都能读,通常用来传时间、分辨率、变换矩阵;varying:由顶点着色器写、片元着色器读的插值通道。
2.2 一个能直接跑的Hello World:最小三角形着色器
我把最经典的"红绿蓝渐变三角形"拆开看。
顶点着色器:
attribute vec3 a_position; attribute vec3 a_color; varying vec3 v_color; void main() { v_color = a_color; gl_Position = vec4(a_position, 1.0); }片元着色器:
precision mediump float; varying vec3 v_color; void main() { gl_FragColor = vec4(v_color, 1.0); }这里有两件事需要解释。第一,片元着色器第一行precision mediump float是浮点精度声明。WebGL1里的片元着色器必须显式声明精度,不然部分浏览器直接编译报错。顶点着色器默认有highp精度可以省略,但片元不同,它的精度是可选的,不同平台默认值不一致,最稳妥的做法是每次都写precision highp float;,安卓老设备的片元性能跟不上再降为mediump。
第二,a_position是attribute,JS侧用vertexAttribPointer告诉GPU"这个缓冲区里的数据每三个浮点数组成一个顶点坐标"。a_color同理,每三个浮点数组成一个RGB颜色。GPU每处理一个顶点,同时拿到这两个属性,执行完main后,把v_color传给光栅化阶段。光栅化根据三角形内像素位置对v_color做线性插值,于是片元着色器拿到的v_color是渐变后的颜色,最终输出就是红绿蓝渐变三角形。
2.3 为什么vec4(a_position, 1.0)最后那个1.0不能乱填
gl_Position是一个vec4,它不只是"为了凑数多一个分量"。在3D渲染里,GLSL会把这个四维向量除以w得到标准化设备坐标。如果你做透视投影,w里边装着深度信息,用来产生近大远小的效果;如果你只是画2D图形,所有w都设成1.0,除法结果不变,位置数据就不会被扭曲。
一个很常见的错误是:写成vec4(a_position, 0.0)。除以0得到无穷大,编译器不一定报错,但屏幕上顶点会消失得无影无踪。这是个特别隐蔽的坑,排查起来非常费劲。所以我个人建议,2D场景统一写gl_Position = vec4(a_position, 1.0);。
3. 把着色器"喂"给WebGL:编译、链接、使用全流程与报错定位
3.1 着色器源码的组织:脚本标签还是模板字符串
GLSL代码在WebGL里就是纯字符串,没有单独的编译器入口。问题在于:这串代码放在哪?
老教程喜欢用<script type="x-shader/x-vertex">标签,把着色器代码嵌在HTML里,再通过document.getElementById().textContent读取。这种做法的好处是语法高亮和编辑器识别效果好,坏处是潜在问题不少:模板引擎混用时{{}}会被解析、多人协作代码容易散落在各个文件、脚本标签过多影响文档结构。
我现在的做法是直接用JavaScript模板字符串:
const vertexSource = ` attribute vec3 a_position; void main() { gl_Position = vec4(a_position, 1.0); } `;模板字符串的优势是跨行写起来方便、可以拼变量(比如动态生成宏开关)、不需要额外DOM。配合VS Code的Shader Languages Support扩展,模板字符串里的GLSL也能有高亮。要动态生成#define相关代码时,直接${someVar}插值就行,比脚本标签灵活得多。
3.2 编译链路:createShader、shaderSource、compileShader全流程
把字符串变成GPU上可执行的着色器对象,需要走一条固定链路:
function createShader(gl, type, source) { const shader = gl.createShader(type); gl.shaderSource(shader, source); gl.compileShader(shader); if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { const info = gl.getShaderInfoLog(shader); console.error('着色器编译失败:', info); gl.deleteShader(shader); throw new Error('shader compile error: ' + info); } return shader; } function createProgram(gl, vsSource, fsSource) { const vs = createShader(gl, gl.VERTEX_SHADER, vsSource); const fs = createShader(gl, gl.FRAGMENT_SHADER, fsSource); const program = gl.createProgram(); gl.attachShader(program, vs); gl.attachShader(program, fs); gl.linkProgram(program); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { const info = gl.getProgramInfoLog(program); console.error('程序链接失败:', info); gl.deleteProgram(program); return null; } gl.deleteShader(vs); gl.deleteShader(fs); return program; }有两个细节容易被忽略。第一,链接成功后最好立刻删除着色器对象。着色器在链接完成之后就不再单独需要了,留着也只是占GPU资源,删掉能减少显存泄露风险。第二,deleteShader不影响已经链接好的program,因为program在链接时会把自己的副本复制进内部,不需要担心删了shader程序就废了。
3.3 编译和链接到底有什么区别
很多人分不清compileShader和linkProgram这两步。
compileShader检查的是单个着色器源码的语法:有没有拼错关键字、变量类型是否匹配、是否引用了未定义函数。片元着色器忘了声明precision是在这一步报错的。
linkProgram做的是跨着色器的一致性检查:顶点着色器声明的varying v_color在片元着色器里是否存在?类型是否一致?attribute和uniform在顶点和片元里声明的是否匹配?两个着色器里同名变量但类型不同,必须在链接阶段才能发现。
所以当你看到Program link failed时,别急着翻源码语法,先检查两个着色器之间变量的"合同"对不对得上。
3.4 常见编译错误:版本声明与精度声明的三座大山
WebGL1使用GLSL ES 1.00,WebGL2对应3.00 ES。版本声明是个很微妙的点:新版GLSL不在每段代码开头强制写version时,旧版也不用写。如果你在第一行写了#version 300 es,那你用的是WebGL2语法,配套的是in/out,而不是attribute/varying;如果你不写,处理器按100默认处理,就只能用attribute。把WebGL2的in写法用到WebGL1里,编译直接报错。
实践经验是:先想清楚自己面向WebGL1还是WebGL2再决定语法体系。如果写WebGL1兼容代码,一律用attribute和varying;如果写WebGL2代码,用in和out,并且片元着色器输出要用自定义out变量,因为gl_FragColor在300 es里被删除了。
精度声明方面,WebGL1的片元着色器建议统一加precision mediump float;;WebGL2的片元着色器默认有精度但部分旧平台仍建议显式声明。顶点的attribute数据超过8个时也要小心,老设备有属性数量上限,一般编程场景够用,但了解限制在哪也不亏。
3.5 报错定位的三板斧
一旦屏幕上什么都没画,我的排查顺序是:
一是确认gl.getShaderInfoLog。编译失败时的日志已经精确到行号,比如ERROR: 0:8: 'v_color' : syntax error,意思是第8行v_color附近有语法错误。二是在编译成功后立刻console.log(program)并检查LINK_STATUS。很多问题是varying不一致,日志提示往往都给了变量名。三是用gl.getError()。这个异步错误队列能捕捉很多状态错误,比如你在上下文丢失后调用了绘制函数,或者在绑定缓冲区未完成时读了数据。
提示:最常见的"着色器编译失败"其实是写错了个标点。GLSL不像JS那么宽容,一行语句漏了分号、调用函数多了参数、字符串引号没闭合都会报错。遇到大数据量着色器时,建议先用
console.log(source)把源码完整打印出来,逐行扫一遍,省得猜。
4. 坐标转换的真相:html坐标系怎么变成WebGL坐标系
4.1 为什么两套坐标习惯"打架"
这个话题我能单独写一整篇,因为太多人栽在这。HTML里,Canvas的坐标系原点在左上角,x轴向右,y轴向下,单位是像素。你拿到一个div的宽度,从左边算偏移,没毛病。
WebGL完全不管这一套。它使用的裁剪空间坐标,原点在画布正中心,x轴向右,y轴向上,坐标范围从-1到1。也就是说,你脑子里"从HTML拿来的坐标值"不能直接塞进顶点着色器,必须做一步换算。
这个偏差在y轴是最容易翻车的:HTML的y往下是正,WebGL的y往下是负。你写一个顶点(100, 100),HTML预期的"右下方"会被WebGL解释成"右上方",整个图形上下颠倒。这也是为什么很多新手画完第一个三角形发现"怎么倒过来了"。
4.2 从像素域到裁剪域的通用公式
假定你的Canvas绘制缓冲区宽度是width、高度是height,坐标转换的标准公式是:
ndcX = (pixelX / width) * 2 - 1 ndcY = -(pixelY / height) * 2 + 1第一个公式很好理解:先把像素坐标归一化到0~1,然后等比例映射到-1~1。第二个公式多了负号,就是为了翻转HTML的y方向。
用代码实现,就是这样一个函数:
function htmlToNDC(canvas, clientX, clientY) { const rect = canvas.getBoundingClientRect(); const x = clientX - rect.left; const y = clientY - rect.top; const width = canvas.width; const height = canvas.height; return { x: (x / width) * 2 - 1, y: -((y / height) * 2 - 1) }; }注意这里的canvas.width和canvas.height,指的是绘制缓冲区的实际像素尺寸,不是CSS尺寸。如果CSS尺寸是200px宽,但canvas.width是400,那么CSS上一个像素对应两个缓冲像素。此时若用CSS尺寸计算,绘制位置会偏移一半。所以要么保证canvas.width和CSS宽度一致,要么统一用canvas.width / canvas.height计算。
4.3 实际案例:在Canvas中心画一个正方形
假设背景是黑色,我们要画一个以Canvas中心为原点、边长200像素的白色正方形。手算一次坐标更直观。
Canvas大小为800x600,那么中心点是(400, 300)。正方形四个顶点的HTML坐标是:
- 左上:(300, 200)
- 右上:(500, 200)
- 右下:(500, 400)
- 左下:(300, 400)
用上面的公式转成NDC:
- 左上:x = (300/800)*2-1 = -0.25,y = -((200/600)*2-1) = -(-0.3333) = 0.3333
- 右上:x = 0.25,y = 0.3333
- 右下:x = 0.25,y = -0.3333
- 左下:x = -0.25,y = -0.3333
顶点着色器里不做任何矩阵变换,直接把vec2转vec4输出,正方形就会像你预期的那样"老老实实"待在屏幕中央。如果你忘了y取反,正方形会跑到下半屏之外或者翻个方向。
4.4 devicePixelRatio是个隐藏的坐标杀手
移动端和Retina屏上还有个高频坑:CSS像素和物理像素不一致。canvas.width = 0如果等于CSS尺寸但没乘devicePixelRatio,在高分屏上整个画面是模糊的。很多项目会用:
const dpr = window.devicePixelRatio || 1; canvas.width = cssWidth * dpr; canvas.height = cssHeight * dpr;这时候坐标换算里的宽高就必须用canvas.width / canvas.height,而不是CSS尺寸。我之前接手过一个项目,所有坐标按CSS尺寸算,结果在高分屏上图形放大漂移,排查到最后一层居然是devicePixelRatio没处理。推荐封装一个带dpr的统一坐标函数,全年不踩坑。
提示:如果canvas宽度和高度是手动在CSS里写的,也可以直接用
canvas.getBoundingClientRect()的宽高替代上面代码中的canvas.width,但必须接受"物理像素和CSS像素一致"的前提。最省心的方式:让canvas的width/height始终等于CSS尺寸乘以dpr,换算时就直接用物理尺寸。
4.5 从坐标转换延伸到视口:gl.viewport做了什么
另一个容易被忽略的细节是gl.viewport(0, 0, canvas.width, canvas.height)。WebGL裁剪空间坐标最终要映射到屏幕的实际像素区域,这个映射就是viewport控制的。默认情况下它会取canvas的整个尺寸,但如果你的canvas只占页面的一部分,或者想做分屏渲染,就必须手动设置。
viewport和坐标转换的关系是:NDC坐标(-1到1)经过viewport变换后,才变成最终渲染的物理像素坐标。这个过程是GPU自动做的,但你要保证viewport的宽高和坐标转换公式用的宽高一致,否则会出现"算好的位置不对"的怪异现象。
我自己调试时,只要发现物体偏了方向或者偏移量不对,第一件事就是打印current viewport,第二件事就是检查canvas.width是不是和CSS尺寸一致。这两条清楚了,坐标问题基本解决一半。
5. 进阶使用技巧与调试经验:让着色器跑得更稳更顺
5.1 uniform动态更新与渲染循环
着色器真正的魅力是动态效果。把时间传到uniform里,每帧更新一次,就能做出动画。
在顶点着色器里加一个uniform:
uniform float u_time; void main() { float offset = sin(u_time) * 0.2; gl_Position = vec4(a_position.x + offset, a_position.y, 0.0, 1.0); }在JS侧,每次requestAnimationFrame循环里先获取uniform位置,再更新值:
const timeLoc = gl.getUniformLocation(program, 'u_time'); function tick() { const seconds = performance.now() / 1000; gl.uniform1f(timeLoc, seconds); gl.drawArrays(gl.TRIANGLES, 0, vertexCount); requestAnimationFrame(tick); } requestAnimationFrame(tick);这里有个重要约定:gl.uniform1f必须发生在useProgram(program)之后、drawArrays之前。很多人把uniform调用放在绑定缓冲区前,结果是值一直没变,动画平滑不动。如果你用了多个program,还得注意每次切换program后重新获取uniform location,因为每个program的location是不通用的。
5.2 用varying做顶点色渐变:一条线打通数据流
我们把这个例子升级一下。现在想画一个纯色到透明的渐变线条,数据从attribute进顶点着色器,varying传进片元,效果和代码都得清楚。
顶点着色器:
attribute vec2 a_position; uniform float u_time; varying float v_alpha; void main() { v_alpha = (a_position.x + 1.0) / 2.0; // 根据x坐标映射到0~1 gl_Position = vec4(a_position.x + sin(u_time) * 0.1, a_position.y, 0.0, 1.0); }片元着色器:
precision mediump float; varying float v_alpha; void main() { gl_FragColor = vec4(1.0, 0.0, 0.0, v_alpha); }这里v_alpha在顶点着色器里按顶点的x属性算出,然后GPU逐像素插值。你在片元着色器里直接拿到的v_alpha,其实是当前像素位置插值后的结果。通过这种方式,颜色、透明度、UV等属性都能从顶点平滑铺到整个图元表面。如果你想做条纹效果,可以在片元着色器里对插值结果做周期函数,比如fract(v_alpha * 10.0),这是后话。
5.3 调试着色器的三板斧:GLSL没有printf,怎么办
GPU并行环境里不能像CPU那样console.log某个像素,所以排查逻辑错误要靠别的思路。
第一板斧:把可疑值直接输出成颜色。这是最直观的调试手段。你觉得某个变量可能算错了,就把它除以一个已知最大值后放进gl_FragColor里,屏幕上会用颜色渐变告诉你值的大小范围。比如想查uniform是否成功传入,可以在片元着色器里临时写gl_FragColor = vec4(u_time, 0.0, 0.0, 1.0);,画面变红说明值传进来了,红色亮度反映值的量级。这套方法屡试不爽。
第二板斧:最小化复现。当复杂着色器出问题时,先注释掉大部分逻辑,只留最简单的单色输出。如果单色正常,说明编译和数据流没问题,问题出在新增的逻辑中。二分法缩小范围,在GPU调试上一样好用。
第三板斧:善用gl.getError()和上下文丢失监听。很多诡异现象(比如画面闪一下就没了)其实是GPU上下文丢失。在初始化时加一个canvas.addEventListener('webglcontextlost', e => e.preventDefault()),至少能在控制台看到明确提示,不至于以为是自己代码写得不对。
5.4 性能习惯:别在每帧里重新编译着色器
这是新手最容易出现的性能反模式:初始化一个函数里同时把program也每次draw之前重新创建一遍。createProgram涉及编译链接和驱动后端处理,开销不小,跑在每帧里会让帧率断崖下跌。
正确做法是:着色器代码只在首次渲染前编译链接一次,program缓存为全局变量。后续每帧只更新uniform和绑定缓冲区。
另外,uniform location获取也可以做缓存。gl.getUniformLocation虽然不像编译那么昂贵,但每个program、每个变量名都有自己的location,居然还可能在老设备上开销不小。我通常在program链接成功后立刻把所有location拉出来存进一个对象里:
const uniforms = {}; uniforms.u_time = gl.getUniformLocation(program, 'u_time'); uniforms.u_resolution = gl.getUniformLocation(program, 'u_resolution');这样每帧draw之前只需要做gl.uniform1f(uniforms.u_time, ...),不用反复向上下文问"时间变量在哪",性能明显更稳。
5.5 WebGL1与WebGL2的语法选择:一次说清楚
很多人在网上扒着色器代码时,发现有的代码用attribute,有的用in,混着改就报错。这里把关键差异列清楚:
| 项目 | WebGL1(GLSL 100) | WebGL2(GLSL 300 es) |
|---|---|---|
| 版本声明 | 不需要 | 可选,但建议#version 300 es |
| 顶点输入 | attribute vec3 a_position; | in vec3 a_position; |
| 顶点→片元 | varying vec3 v_color; | out vec3 v_color;(顶点) |
| 片元接收 | varying vec3 v_color; | in vec3 v_color;(片元) |
| 片元输出 | gl_FragColor | 自定义out vec4 outColor; |
如果你用WebGL2却忘了写#version 300 es,又用in/out语法,编译会直接提示未知写法;反过来,你用WebGL1却用in/out,同样报错。建议整个项目统一选一个GLSL版本体系,不要混搭。目前WebGL2在现代浏览器里支持度已经相当好,如果不需要兼容很老的环境,直接上WebGL2更省心,因为它支持纹理数组、uniform缓冲和更多精度选择。
我自己写项目时,着色器基本都放在独立的JS模块文件里,用模板字符串拼装,即便是多个program也尽量复用公共的createShader和createProgram函数。Debug版本和Release版本之间用#define DEBUG区分,某些详情输出只在Debug着色器里开启。这些工程化习惯看着繁琐,但真正遇到需要排查的项目时能省下大量时间。
最后再分享一个小细节:如果你发现屏幕上图形位置偏了半个像素,别急着怀疑坐标公式有错,先检查canvas元素的CSSbox-sizing和边框。Canvas的getBoundingClientRect()返回的是包含边框和padding的盒区域,而绘制坐标以content区域为基准。给canvas加了边框或圆角后,坐标换算套公式时会整体偏移。把canvas包在一个没有padding的容器里,算是从源头规避这个问题的最简单办法。坐标系统和着色器这套东西,无非就是"数据从哪来、算完往哪去"八个字,你想明白了,后面再复杂的滤镜和特效都只是在这条链路上加花样而已。