
1. 片元着色器到底在干什么渲染管线的最后一公里如果你已经跟着前面几篇把WebGL环境搭起来、把一个三角形成功画到屏幕上那你其实已经接触过片元着色器了——只是可能没细想过它究竟负责什么。简单说顶点着色器决定这个三角形在哪片元着色器决定这个三角形长什么样。一个是建模骨架一个是给骨架刷漆上色两者配合才组成了屏幕上最终看到的画面。片元着色器Fragment Shader是GPU渲染管线的最后一站。CPU把顶点数据传给GPU顶点着色器处理完每个顶点的位置图元装配阶段把它们连成三角形光栅化阶段把三角形拆成一个个像素碎片——这些碎片就是我们说的片元fragment。片元着色器要对每个片元执行一次算出它的最终颜色然后写进颜色缓冲显示器再根据这些颜色点亮对应的像素点。换句话说你在屏幕上看到的每一个像素的颜色值几乎都经过片元着色器的计算。零基础的人最容易误解一个点片元跟像素不是一一对应的关系。一个片元是光栅化后、在执行片段着色之前的一个候选像素它可能被后面的深度测试、模板测试丢弃也可能因为多重采样产生多个采样点。但你可以先简化理解一次片元着色器调用最终会影响屏幕上的一小块显示区域。我们写Shader的大部分工作就是研究怎么给这个片元算出一个合理的颜色。还有一个很重要的心智模型片元着色器是被GPU并发执行的。它不是像普通代码那样一个for循环挨个处理像素而是几千个GPU核心同时对几千个片元跑同一段代码。这个特性带来两个结论第一你写的代码会在极短时间内被执行几百万次所以性能极其敏感能不做的计算尽量不要做第二每个片元的执行是相互独立的你不能在Shader里让某个片元去读取另一个片元算出来的结果那叫后处理/邻域采样需要把渲染结果存成纹理再采样是更高阶的技巧。所以片元着色器的核心逻辑说穿了就是一件事给你一个坐标你返回一个颜色。所有眼花缭乱的效果——渐变、噪点、波纹、光照、贴图……本质上都是坐标到颜色的映射关系。把这个心智模型装进脑子里后面学任何效果都会顺很多。2. 从零开始的第一段代码一个片元着色器长什么样2.1 GLSL语言快速热身片元着色器用的是GLSL ES语言WebGL 1.0对应GLSL ES 1.00WebGL 2.0对应GLSL ES 3.00。这套语言跟C语言长得很像但有不少带GPU特色的限制。对于零基础第一波最常用到的就是下面这些类别关键字/语法说明数据类型float浮点数、int整数、bool布尔基础类型向量vec2、vec3、vec42/3/4维浮点向量最常用的数据类型内置输出gl_FragColorWebGL1/ out vec4WebGL2片元最终颜色输出内置输入gl_FragCoord当前片元的窗口坐标从(0.5, 0.5)起算修饰符uniform从CPU传到GPU的全局常量每帧可更新修饰符varying / in、out从顶点着色器传入的值自动插值向量是Shader的一等公民。vec3可以存颜色rgb也可以存坐标xyz而且支持.x .y .z .w也支持.r .g .b .a两种命名指向同一块数据。还有更狠的.rgba可以和.xyzw混着用这就是GLSL特色的swizzle操作比如color.rgb取rgb三个分量pos.xy取x和y两个分量这在后面写效果时会频繁用到。2.2 让每个像素变成渐变色我建议零基础不要一上来就看那些几十行的复杂Shader先动手跑通一个最简单的渐变体会一下坐标驱动颜色到底是什么感觉。下面这套代码在WebGL 1.0里可以直接用// 片元着色器横向渐变 precision mediump float; void main() { // gl_FragCoord.x 是当前片元在canvas坐标系下的x坐标 // u_resolution.x 是canvas的宽度 // 两者的比值范围是0到1正好可以当颜色分量的强度 float red gl_FragCoord.x / u_resolution.x; // 输出颜色红蓝混合的渐变 gl_FragColor vec4(red, 0.0, 1.0 - red, 1.0); }这段代码里我故意用了一个还没声明的u_resolution实际使用需要先声明它precision mediump float; uniform vec2 u_resolution; void main() { float red gl_FragCoord.x / u_resolution.x; gl_FragColor vec4(red, 0.0, 1.0 - red, 1.0); }u_resolution是一个uniform变量类型是vec2x分量是canvas宽度y分量是高度。CPU端通过gl.uniform2f传进去。这样gl_FragCoord.x / u_resolution.x就是一个从0到1的比值——屏幕最左边是0最右边是1。这个0到1的区间就是Shader领域最核心的UV坐标几乎所有效果都要先想方设法把坐标归一化到0到1之间。2.3 uniform是从CPU到GPU的对话窗口我说一下uniform的定位因为零基础特别容易跟varying搞混。uniform是CPU传给GPU的全局常量每个片元执行时读到的uniform值一模一样它是所有人共享的信息varying则是从顶点着色器传下来的每个片元拿到的值不同它是每个人各自的插值结果。用打比方的方式说uniform像是老师写在黑板上的规则全班同学看到的是同一行字varying像是老师给每个同学发了一张不同的答题卡每张卡片上的起始分不一样。我们给整个画面做动画效果时通常用一个uniform float u_time来记录时间每帧在CPU端更新一下片元着色器里的每个像素都能看到这个时间然后各自的颜色映射逻辑就可以随时间变化。precision mediump float; uniform vec2 u_resolution; uniform float u_time; void main() { vec2 st gl_FragCoord.xy / u_resolution.xy; // 随时间变化的横向渐变红色强度随时间周期性摆动 float red (1.0 sin(u_time)) * 0.5; float blue st.x; gl_FragColor vec4(red, 0.0, blue, 1.0); }CPU端每次渲染循环里用gl.uniform1f(location, performance.now() / 1000.0)把秒数传进去屏幕上就能看到颜色在动了。这是Shader动画最基本的玩法时间进去颜色出来。3. 插值机制顶点着色器和片元着色器之间的桥梁3.1 从三个顶点到一个面的颜色渐变很多零基础困惑的是这个场景我只给三角形三个顶点指定了三个不同颜色为什么最后渲染出来整个三角形面都带平滑渐变谁把中间那些像素的颜色算出来的答案就是GPU硬件自动完成的线性插值。顶点着色器为每个顶点输出一份varying值光栅化阶段拿到三个顶点的varying值然后根据像素点在三角形内部的相对位置按比例混合出每个片元自己的varying值。这个混合比例就是前面说的重心坐标。举个例子开发一个三角形三个顶点颜色分别是红(1,0,0)、绿(0,1,0)、蓝(0,0,1)。顶点着色器把顶点的颜色通过varying vec3 vColor传给片元着色器片元着色器直接把它赋给输出。代码不负责算中间颜色但渲染结果是红绿蓝三色平滑过渡的渐变三角形。这就是插值在幕后起作用。3.2 为什么UV坐标习惯在顶点着色器里生成我们看到很多Shader教程里片元着色器使用的UV坐标不是直接拿gl_FragCoord算而是从顶点着色器里传入的varying vec2 vUv。两者的区别很重要gl_FragCoord是窗口坐标系下的绝对像素坐标不受物体缩放、旋转、位移的影响。顶点层面的UV是跟随模型本身的纹理坐标attribute经过插值后每个片元拿到的UV是相对的、局部的坐标通常0到1之间。做3D物体贴图时必须用vUv因为物体在屏幕上放大缩小时贴图要跟着物体走而不是固定在屏幕位置上。做全屏后处理、全屏渐变动画时则可以直接用gl_FragCoord除以分辨率模拟出一个全屏UV。两种用法对应两种完全不同的场景别搞混。一个经典的UV拆解案例是这样的。在顶点着色器里把每个顶点的attribute vec2 a_texCoord直接透传到片元着色器// 顶点着色器 attribute vec3 a_position; attribute vec2 a_texCoord; varying vec2 vUv; void main() { vUv a_texCoord; gl_Position vec4(a_position, 1.0); }// 片元着色器 precision mediump float; varying vec2 vUv; void main() { // 左下角是(0,0)右上角是(1,1) // 以UV的x分量作为红色强度、y分量作为绿色强度 gl_FragColor vec4(vUv.x, vUv.y, 0.0, 1.0); }这个算是最简单的UV可视化如果渲染出来一个方形左下角是黑色因为红色和绿色都为0右上角是黄色红色和绿色都拉满中间区域是从黑到黄的平滑过渡。看到这个效果说明你的UV插值链路是通的。3.3 透视校正插值一个容易忽略但必须知道的概念严格来说GPU做的不是简单的屏幕空间线性插值而是透视校正插值。因为在3D透视投影下如果直接在屏幕空间线性插值纹理坐标远处的纹理会被扭曲。GPU会在插值时引入顶点的w分量做修正保证透视投影下的纹理看起来是贴着模型表面的。零基础阶段不需要自己实现w分量的计算GPU会自动处理。但如果哪天你看到某个Shader在3D物体上出现纹理扭曲先别怀疑插值检查一下是不是UV坐标算错了、或者模型导出的UV有问题。透视校正这个东西知道你调用了varying就会自动生效就够了先把精力放在核心逻辑上。4. 核心逻辑实战几个看得见摸得着的基础效果4.1 用数学函数控制颜色边界step与smoothstep你会发现写片元着色器的过程本质是在用数学函数雕刻画面。很多效果都是从0到1的连续渐变开始的然后用一个阈值把它切成硬边界或柔边界。最常用的两个函数是step和smoothstep。step(edge, x)当x edge时返回0x edge时返回1。相当于一个无情的开关常用来画硬边缘。smoothstep(edge0, edge1, x)在edge0到edge1之间做平滑过渡小于edge0返回0大于edge1返回1中间是平滑的Hermite插值。常用来画柔边、做抗锯齿、制作遮罩过渡。下面这段代码画一个沿x方向扫过的亮带precision mediump float; uniform vec2 u_resolution; uniform float u_time; void main() { vec2 st gl_FragCoord.xy / u_resolution.xy; // 亮带中心位置随时间左右移动 float center 0.5 0.3 * sin(u_time); // 用smoothstep让亮带边缘有柔和的过渡 // 左边距center约0.05处开始渐入右边距center约0.05处渐出 float band smoothstep(center - 0.05, center, st.x) * smoothstep(center 0.05, center, st.x); // 背景是深蓝亮带用暖黄色 vec3 bg vec3(0.02, 0.03, 0.06); vec3 lit vec3(1.0, 0.85, 0.4); vec3 color mix(bg, lit, band); gl_FragColor vec4(color, 1.0); }这里的mix(a, b, t)是另一个高频函数做线性混合t0返回at1返回bt在中间就是两者的插值。上面代码里band在亮带区域算出来接近1所以颜色从背景切换到亮黄色在亮带之外接近0保持背景色。零基础可以把这三个函数组合起来想象成一个语音控制旋钮step是最暴力的开关smoothstep是带阻尼的旋钮mix是混合两个信号的调音台。4.2 用距离场画圆形坐标到形状的思维转换其实画一个圆不用贴图也不用复杂的几何计算只需要计算每个片元到圆心的距离。距离小于半径就是圆内距离大于半径就是圆外。这就是所谓的有符号距离场Signed Distance Field的入门版。precision mediump float; uniform vec2 u_resolution; void main() { // 转化到以中心为原点的坐标且保持宽高比一致 vec2 st gl_FragCoord.xy / u_resolution.xy; st.x * u_resolution.x / u_resolution.y; // 修正宽高比 st - 0.5; // 中心移到(0,0) // 计算片元到中心的距离 float d length(st); // 判断是否在半径0.3的圆内 float circle 1.0 - step(0.3, d); vec3 color vec3(circle); gl_FragColor vec4(color, 1.0); }这段代码里最关键的一步是st.x * u_resolution.x / u_resolution.y。不做这步画的圆在宽屏幕上会变成椭圆。因为屏幕的横向物理距离和纵向物理距离对应的像素数可能不同直接用原始像素坐标算距离会导致x方向被拉伸。这个修正宽高比的细节是新手最容易忽略、也是最容易导致圆变椭圆的坑。另外step(0.3, d)在圆外返回1然后1.0 - step(0.3, d)把圆内变成1、圆外变成0所以最终输出是白圆黑底。想画带抗锯齿的圆把硬step换成smoothstep就行float circle 1.0 - smoothstep(0.3 - 0.01, 0.3 0.01, d);。这0.01的过渡带就是你的抗锯齿宽度实现效果类似但比后处理的MSAA省资源得多。4.3 列一个常用函数清单零基础写Shader的词汇表刚开始自己写Shader时最痛苦的是知道效果但不知道用什么函数。我整理了一份我当年入门的词汇表背熟它们大部分基础效果的实现思路就会豁然开朗函数/操作作用常见用途step(edge, x)硬阈值开关画硬边、二进制遮罩smoothstep(e0, e1, x)平滑阈值过渡抗锯齿、柔边、遮罩渐变mix(a, b, t)线性混合颜色插值、两材质过渡clamp(x, min, max)限制到范围防止越界、安全归一化length(v)向量长度距离场、圆、光衰减dot(a, b)点积投影、光照计算、边界判断abs(x) / mod(x, n)绝对值 / 取模对称效果、重复图案fract(x)取小数部分循环纹理、分形、网格UVsin / cos三角函数周期性动画、波动效果这十来个函数就足以撑起初学者80%的需求。遇到我想画个波浪我想让颜色周期性变我想做网格这类想法先对照这张表找找有没有对应的数学工具比硬啃完整GLSL手册高效得多。5. 零基础最容易踩的坑编译失败与画面异常的排查思路5.1 精度声明和类型声明报错的常见元凶先说我见过最多的报错类型几乎每个新手都会踩一次precision qualifier相关错误。在WebGL 1.0里片元着色器如果没有自己声明精度很多设备会直接编译失败或者只有部分设备能编译通过。所以每段片元着色器第一行养成习惯写上precision mediump float;这行声明了float类型默认使用mediump中等精度。片元着色器里还有lowp、highp可选但mediump是兼容性和性能最平衡的选择。你还会遇到float和int混用的问题。比如float x 1;在GLSL里可能报错因为1是int类型需要写成1.0。GLSL的隐式类型转换比JavaScript严格得多记住浮点数一律要带小数点能少踩很多编译坑。WebGL会返回具体的编译错误信息。拿到错误后去看gl.getShaderInfoLog(shader)里返回的字符串它会明确告诉你哪个函数的第几行有什么问题。调试Shader时把错误信息打印到控制台是基本操作。5.2 画面全黑但不报错从变量值开始排查比编译失败更折磨人的是Shader编译通过了但屏幕上一片黑。这种情况十有八九是某个变量的值不在预期范围内。我建议的排查顺序是先输出一个纯色确定Shader本身在工作gl_FragColor vec4(1.0, 0.0, 0.0, 1.0);如果屏幕变红说明管线通了问题在变量计算。再用临时赋值检查uniform传值把某个依赖uniform的分量替换成固定值对比效果差异确定uniform是否为0或者没传进去。检查你的取值范围gl_FragColor的输出会自动clamp到0到1之间如果计算结果是负数或大于1画面会过曝或全黑看不出渐变细节。我强烈推荐用颜色调试的土办法。想知道某个中间变量float v是多少直接把它输出成颜色分量比如gl_FragColor vec4(vec3(v), 1.0);然后通过屏幕明暗判断v的高低。零基础别急着学高级调试工具这个土办法能解决90%的画面不对问题。5.3 性能片元着色器是一台每秒百万次的循环片元着色器执行次数实在太多了。假设你的canvas是1080p一次绘制就要跑大约200万次片元着色器。如果在Shader里写一个循环几百次的重计算帧率直接崩到个位数。零基础阶段务必记住几条性能红线避免在片元着色器里写复杂循环。如果要做重复图案优先用fract、mod拆解而不是for循环。避免在片元着色器里做条件分支。GPU对分支的优化很弱能用mix、step等数学函数完成的选择尽量别用if/else。中间计算结果能用mediump就不用highp省带宽和计算单元。有个很好的自检习惯做完一个效果后把canvas窗口分辨率调大一倍观察帧率是否明显下降。如果下降明显大概率Shader里有可以优化掉的重计算。5.4 一个完整的调试案例亮斑为何不移动我拿前面那个亮带效果举一个实际排查过程。假设你写完了代码把u_time传进去了但亮带静止不动。优先级最高的怀疑对象就是uniform传参。查看你的JavaScript代码是否每次渲染帧都调用了gl.uniform1f如果只调用了一次那之后时间虽然一直在走但GPU里存的uniform值还是最初的0。其次检查获取uniform location的时机——编译链接program之后再获取这个顺序错了会拿不到有效location导致后面赋值失败、uniform保持默认0。再往下看Shader代码center 0.5 0.3 * sin(u_time)如果u_time一直是0center恒定0.5亮带当然不动。所以排查时可以先在Shader里临时给u_time加上一个固定值比如sin(u_time 2.0)如果画面亮了或变化了说明Shader接收正常问题在CPU端没有更新如果画面还是静止问题就在uniform的初始化和传入环节。这种先区分CPU端还是GPU端问题的思路是你以后调试所有Shader问题的根本方法。6. 把片元着色器融入自己的项目一个完整的实践模板上面讲了很多零碎的知识点最后我把一个完整的、可以直接往项目里塞的基础模板串起来。你先在本地跑通这个模板再往里面替换你自己的坐标转颜色的逻辑会少走很多弯路。canvas idgl width800 height600/canvas script const canvas document.getElementById(gl); const gl canvas.getContext(webgl); // 顶点着色器一个全覆盖的三角形 const vsSource attribute vec2 a_position; void main() { gl_Position vec4(a_position, 0.0, 1.0); } ; // 片元着色器带时间和分辨率的颜色逻辑 const fsSource precision mediump float; uniform vec2 u_resolution; uniform float u_time; void main() { vec2 st gl_FragCoord.xy / u_resolution.xy; float r 0.5 0.5 * sin(u_time); float g 0.5 0.5 * cos(u_time st.x * 6.28); float b st.y; gl_FragColor vec4(r, g, b, 1.0); } ; function compileShader(type, source) { const shader gl.createShader(type); gl.shaderSource(shader, source); gl.compileShader(shader); if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { throw new Error(gl.getShaderInfoLog(shader)); } return shader; } function setup() { const vs compileShader(gl.VERTEX_SHADER, vsSource); const fs compileShader(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)) { throw new Error(gl.getProgramInfoLog(program)); } gl.useProgram(program); // 三个顶点组成的三角形覆盖整个视口 const positions new Float32Array([ -1, -1, 3, -1, -1, 3 ]); const buffer gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW); const aPosition gl.getAttribLocation(program, a_position); gl.enableVertexAttribArray(aPosition); gl.vertexAttribPointer(aPosition, 2, gl.FLOAT, false, 0, 0); const uResolution gl.getUniformLocation(program, u_resolution); const uTime gl.getUniformLocation(program, u_time); gl.uniform2f(uResolution, canvas.width, canvas.height); function render(now) { const seconds now * 0.001; gl.uniform1f(uTime, seconds); gl.viewport(0, 0, canvas.width, canvas.height); gl.clearColor(0, 0, 0, 1); gl.clear(gl.COLOR_BUFFER_BIT); gl.drawArrays(gl.TRIANGLES, 0, 3); requestAnimationFrame(render); } requestAnimationFrame(render); } setup(); /script这个模板我用了三个顶点组成一个覆盖全屏的大三角形。为什么不全用四个顶点画两个三角形因为三个顶点更省少处理一个三角形这是很多实际项目的标准做法。你只需要改接口往里传不同数据或者直接改片元着色器里的main函数就能在概念上完成一个全屏渲染器。到这里片元着色器的核心逻辑已经讲完了坐标进、颜色出中间全是数学函数在编排。下一步你可以试着把u_time加进去做动画或者把一张图片作为纹理传进来替换纯色输出一路往纹理采样和后处理的方向走。片元着色器是一个一旦玩起来就很难回去的东西——因为它能让你在像素级别掌控整个画面那种感觉确实很上瘾。