☰
片元着色器核心逻辑与实战:从渲染管线到呼吸光斑Demo
2026/10/2 5:45:08 网站建设 项目流程

老读者应该知道,这个系列已经写到第四篇了。前几篇咱们把WebGL的开发环境跑了起来,搞明白了什么是着色器,也把顶点着色器该怎么写讲透了。今天这篇,终于轮到片元着色器(也叫片段着色器)了。

片元着色器可以说是整条渲染管线里最“出活”的一环:屏幕上每一个像素最终显示成什么颜色,基本都归它管。很多新手朋友对Shader的理解卡在“每个像素都跑一遍代码”这句话上,觉得玄乎,又说不出到底怎么利用它。这篇文章我就把片元着色器的核心逻辑、输入来源、常用数学技巧从头到尾拆一遍,最后用一个完整的呼吸光斑Demo带你跑通全流程。代码不复杂,但每一步的“为什么”我都会讲透。

整个系列面向的还是零基础人群,但需要你至少能把一个三角形画到屏幕上。如果你之前连顶点着色器都没写过,建议先回头翻翻系列第一篇和第二篇,把绘制流程和着色器编译这部分补上,再回来读这篇,体验会顺畅很多。

1. 片元着色器到底是什么:从渲染管线里的并行染色说起

1.1 片元着色器在WebGL管线里的位置

先复习一下渲染流程。一次WebGL绘制调用,简单说就是:顶点数据进来,经过顶点着色器处理,完成坐标变换、裁剪、光栅化,然后生成一个又一个的“片元”,最后每个片元进入片元着色器,算出一个颜色,写进颜色缓冲。你可以把整条流程想成一条流水线,顶点着色器负责把零散的顶点整理成几何体的骨架,光栅化负责把骨架填满面片,而片元着色器就是给每个裁切出来的小方格上色。

这里的“片元”比“像素”稍微早一步。每个片元包含位置坐标、深度值、颜色相关的插值数据,经过各种测试(比如深度测试、透明度混合)之后,才有资格变成屏幕上真实显示的像素。片元着色器干的事情,就是拿到这个片元的信息,输出一个带透明度的颜色值。你可以不知道光栅化内部具体是怎么插值的,但必须清楚这个阶段的核心任务:拿到所有你需要的数据,最终算出一个RGBA颜色。

1.2 片元着色器的输入与输出模型

要理解片元着色器的核心逻辑,先要把它的输入和输出模型刻在脑子里。

片元着色器接收三类常见输入。第一类是varying变量,这是从顶点着色器传过来的,系统会按照片元在三角形中的位置对每个顶点的值做插值,比如UV坐标、法线、顶点颜色。第二类是uniform变量,这是全局常量,整个draw call过程中保持不变,比如时间、分辨率、光源位置、纹理采样器。第三类是片元的内置变量,最常见的gl_FragCoord,它给出当前片元在视口内的像素坐标,还有gl_FrontFacing表示片元是否属于正面。

输出就简单多了。在WebGL 1里,写入内置变量gl_FragColor即可;如果用了WebGL 2、GLSL ES 3.00,通常改成声明一个out vec4 outColor然后赋值。本质上就是输出一个vec4颜色值。

很多初学者写片元着色器时会犯一个思维错误:试图用循环去处理整个屏幕。可GPU不是这么工作的,你的着色器代码不是“对整个屏幕的遍历逻辑”,而是“对单个片元的处理函数”。这个函数会被GPU在成千上万个片元上并行执行,每个执行单元处理不同的片元,获得不同的插值输入,然后独立返回一个颜色。理解不了这一点,后续所有优化和调试都会很痛苦。

1.3 为什么说它是“核心逻辑”

顶点着色器主要管几何、坐标和形变,而片元着色器决定了画面最终呈现的一切:颜色、光照、材质感、细节、阴影、后处理效果。换句话说,顶点着色器负责“形状”,片元着色器负责“质感”。

你完全可以让一个顶点着色器什么都不干,只在片元着色器里疯狂堆数学公式,做出各种炫酷图案;但反过来,如果你片元着色器只会输出一个纯色,那画面基本没法看。Shader编程学习里最容易出效果、也最容易给人成就感的部分,恰恰就是片元着色器。因为它直接把代码和像素建立了一对一联系——你写一句颜色混合,屏幕上立刻就能看到变化,这种即时反馈是很多图形技术难以比拟的。

2. 片元着色器的三大输入与数学驱动逻辑

2.1 三个常用输入各自负责什么

片元着色器要真正落地,离不开三个输入配合。

第一个是varying插值变量。以全屏四边形为例,顶点着色器输出一个vUv,四个顶点分别是(0,0)、(1,0)、(1,1)、(0,1),光栅化之后,每个片元的vUv会在三角形内部自动插值。屏幕左下角的片元拿到接近(0,0)的值,右下角接近(1,0),顶部接近(0,1)。这一下UV坐标和屏幕位置就对应上了,画面上的渐变、贴图、图案全都依赖这种插值。

第二个是uniform全局变量。片元着色器里最常用的uniform大概是u_time(秒为单位的时间)和u_resolution(画布尺寸)。u_time让画面动起来,u_resolution帮我们把像素坐标换算成便于计算的归一化坐标。uniform从JavaScript宿主环境传进来,每次绘制前更新一次即可。

第三个是内置变量gl_FragCoord。它默认以像素为单位,表示当前片元在视口坐标系里的位置。如果想知道某个片元距离屏幕中心多远,直接用它减去(u_resolution * 0.5)就行。注意WebGL的坐标原点在左下角,y轴朝上,和很多人的习惯相反,这点必须时刻盯着,不然做出来的效果经常是上下颠倒的。

下面这个表是新手最容易混的,我直接给出来:

输入方式来源典型范围主要用途
varying vUv顶点着色器插值0.0 ~ 1.0纹理采样、图案生成、渐变
uniform u_resolutionJavaScript传入视口宽高像素值坐标归一化、区域划分
uniform u_timeJavaScript传入秒数,持续递增动画、周期波动
gl_FragCoord内置变量0 ~ width / 0 ~ height精确像素定位、后处理

2.2 归一化坐标:所有像素都放进0到1的盒子

写片元着色器,第一步习惯动作就是把坐标归一化。为什么?因为屏幕尺寸是不确定的。如果你直接用像素坐标算距离,1024宽度的画面和512宽度的画面会得到完全不同的结果。可是如果你把x除以width,y除以height,让所有坐标都落在0到1之间,那不管屏幕多大,视觉效果都能保持一致。

归一化之后,画面左下角是(0,0),右上角是(1,1),中心是(0.5,0.5)。这个坐标系虽然简单,却是后续几乎所有图案生成的基础。判断一个点是否在某个圆里,用distance(uv, center);做一个水平渐变,直接用uv.x;做一个纵向渐变,用uv.y;做一个对角渐变,用uv.x + uv.y。全部围绕这个0到1的小盒子展开。

用vUv还是gl_FragCoord除以分辨率,本质效果差不多,区别在于vUv是插值得到的,适合纹理采样;gl_FragCoord更精确,适合逐像素级别的后处理。日常写Demo,两者选一个顺手的就行,我个人习惯用vUv,算起来省一次除法。

2.3 用数学表达式驱动颜色:一个最简单的动态渐变

片元着色器的核心逻辑,说穿了就是“把坐标和时间变成颜色”。比如你写:

gl_FragColor = vec4(uv.x, uv.y, 0.0, 1.0);

屏幕左下角就是黑色,右上角就是黄色,因为R通道从左到右从0涨到1,G通道从下到上从0涨到1。这个简单的表达式已经把坐标信息可视化了。

再加上时间:

gl_FragColor = vec4(vUv.x, vUv.y, abs(sin(u_time)), 1.0);

B通道会随着时间做正弦波动,画面在蓝黑之间周期性闪烁。这种“用连续变化的数驱动连续变化的颜色”就是片元着色器动画的基本逻辑。

2.4 三个高频内置函数:smoothstep、fract、mix

片元着色器里的乐趣很大一部分来自数学函数。这里先介绍三个使用频率极高的函数,后面Demo和练习都会用到。

第一个是smoothstep(edge0, edge1, x),它做平滑阶跃。x小于edge0返回0,大于edge1返回1,中间用三次多项式做平滑过渡。它在生成圆、带边界、遮罩、抗锯齿的时候非常有用。如果你用if判断,边缘会有明显的锯齿和硬边;用smoothstep,边缘会变得柔和自然。

第二个是fract(x),取小数部分。它天然带周期,因为fract(x + 1)等于fract(x)。配合uv坐标,可以生成网格、条纹、重复图案。很多分形效果就是从fract开始的。

第三个是mix(a, b, t),线性插值混合。t等于0时返回a,等于1时返回b,中间平滑过渡。它用在颜色渐变、透明度控制、纹理融合上非常方便。比如把深蓝色和橙色按uv.y做mix,能迅速做一个有层次的背景。

这三个函数单独看都很简单,组合起来就是无穷的花样。记住一点:GPU对数学运算的吞吐量非常变态,几千条指令的片元着色器在很多设备上都能轻松跑起来,这也是为什么可以在Shader里大胆堆表达式。

3. 完整Demo实操:写一个会呼吸的圆形光斑

3.1 准备三件套:HTML、JavaScript、GLSL

光讲理论不管用,下面直接做一个完整的Demo。目标是一个深蓝渐变背景,中央一个暖橙色圆形光斑,光斑半径随时间节奏呼吸变化,边缘带柔和光晕。

先建一个最简单的HTML页面:

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>片元着色器入门 Demo</title> <style> body { margin: 0; overflow: hidden; background: #000; } canvas { display: block; width: 100vw; height: 100vh; } </style> </head> <body> <canvas id="canvas"></canvas> <script src="main.js"></script> </body> </html>

3.2 完整代码与逐行解读

main.js代码如下。我没有用任何框架或库,纯WebGL原生接口,便于你看清每一步。

const canvas = document.getElementById('canvas'); const gl = canvas.getContext('webgl'); if (!gl) { alert('你的浏览器不支持 WebGL'); throw new Error('WebGL is not supported'); } function compileShader(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)); gl.deleteShader(shader); return null; } return shader; } const vsSource = ` attribute vec2 a_position; varying vec2 vUv; void main() { gl_Position = vec4(a_position, 0.0, 1.0); vUv = a_position * 0.5 + 0.5; } `; const fsSource = ` precision mediump float; uniform vec2 u_resolution; uniform float u_time; varying vec2 vUv; void main() { vec2 uv = vUv; // 背景渐变 vec3 bgColor = mix(vec3(0.08, 0.12, 0.25), vec3(0.25, 0.45, 0.8), uv.y); // 呼吸运动的半径 float radius = 0.25 + 0.08 * sin(u_time * 2.0); // 计算当前片元与圆心的距离 float dist = distance(uv, vec2(0.5, 0.5)); // 圆形主体:用 smoothstep 控制边缘柔化 float circle = 1.0 - smoothstep(radius - 0.02, radius + 0.02, dist); // 光晕 float halo = smoothstep(radius + 0.25, radius, dist) * 0.4; vec3 glowColor = vec3(1.0, 0.6, 0.3); vec3 color = mix(bgColor, glowColor, circle * 0.9); color += glowColor * halo; gl_FragColor = vec4(color, 1.0); } `; 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)) { console.error(gl.getProgramInfoLog(program)); } gl.useProgram(program); // 两个三角形拼成一个全屏四边形 const positions = new Float32Array([ -1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1 ]); const buffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW); const aPositionLoc = gl.getAttribLocation(program, 'a_position'); gl.enableVertexAttribArray(aPositionLoc); gl.vertexAttribPointer(aPositionLoc, 2, gl.FLOAT, false, 0, 0); const uResLoc = gl.getUniformLocation(program, 'u_resolution'); const uTimeLoc = gl.getUniformLocation(program, 'u_time'); function frame(time) { time *= 0.001; // 毫秒转秒 gl.uniform2f(uResLoc, canvas.width, canvas.height); gl.uniform1f(uTimeLoc, time); 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, 6); requestAnimationFrame(frame); } requestAnimationFrame(frame);

这段代码有几点值得展开说。

顶点着色器里有个很常见的技巧:用全屏四边形,顶点坐标从(-1,-1)到(1,1),因为gl_Position期望的归一化设备坐标范围是-1到1。vUv = a_position * 0.5 + 0.5把坐标映射到0到1区间。注意你并没有显式传UV坐标,它的值是在顶点着色器里根据位置算出来的,然后光栅化阶段会自动插值到每个片元。

片元着色器里有一个精度声明precision mediump float,这是WebGL 1里片元着色器必需的。不要删,很多新手删掉这句话以后会发现低端手机上画面直接花屏或者黑屏。

呼吸效果的实现其实只有一行:radius = 0.25 + 0.08 * sin(u_time * 2.0)。0.25是基础半径,0.08是振幅,也就是光斑半径在0.17到0.33之间来回浮动。2.0控制呼吸速度,改成4.0就会快一倍。

smoothstep这里的作用是关键。如果直接用dist < radius判断,圆和背景之间会产生强烈锯齿,而且边缘死板。用smoothstep(radius - 0.02, radius + 0.02, dist)以后,center生产。这一段代码的意思就是,在radius左右的很小范围内完成了从圆内到圆外的平滑过渡。0.02就是边缘柔化的宽度,太小等于没软化,太大边缘会糊掉。

光晕那行smoothstep(radius + 0.25, radius, dist),注意edge0比edge1大,这是反向用法,会产生从内到外递减的光晕效果。这个技巧以后调光晕、雾、阴影的时候会反复用到。

3.3 uniform传值与动画循环的关键点

JavaScript端有几个细节新手特别容易踩坑。

canvas.width和canvas.height是画布内部的实际像素尺寸,而css里写100vw和100vh是布局尺寸。如果你在高DPI屏幕上发现画面模糊,多半是canvas尺寸没按devicePixelRatio设置。简单做法是初始化时:

const dpr = window.devicePixelRatio || 1; canvas.width = window.innerWidth * dpr; canvas.height = window.innerHeight * dpr;

这样就能让画面跟上设备物理分辨率。别忘了viewport也要保持一致,我在frame里每次都调用了一次gl.viewport(0, 0, canvas.width, canvas.height),避免窗口resize以后出现只画半屏的问题。

uniform变量的location最好在初始化阶段缓存起来,不要每帧都用getUniformLocation查询一次。虽然现代浏览器做了很多优化,但这是个好习惯,尤其项目大起来以后能省不少性能开销。

requestAnimationFrame每次会往回调函数里传一个DOMHighResTimeStamp参数,单位是毫秒。我把它乘以0.001转成秒。为什么要转成秒?因为sin函数的参数如果在毫秒级别,会瞬间高频闪烁,肉眼根本看不清。统一用秒以后,u_time跨度为多少秒画面就会推算多少秒的动画,很好理解。

3.4 效果扩展:叠加波纹与多色渐变

如果觉得光斑太单调,可以马上动手扩展。核心就几个公式,我在本地试过,效果很不错。

第一,在背景上加水平波纹。你可以在计算bgColor之前插入:

float wave = sin((uv.x + u_time * 0.5) * 20.0) * 0.05 + 1.0; bgColor *= wave;

这里的20.0控制波纹频率,0.05控制强度,u_time控制流动方向。整体效果是背景在横向有微弱的明暗流动。

第二,给光斑叠加多个颜色。不要只用一种暖橙色,可以按照角度混合第二层冷色:

float angle = atan(uv.y - 0.5, uv.x - 0.5); vec3 secondColor = vec3(0.4, 0.8, 1.0); float mixFactor = sin(angle + u_time) * 0.5 + 0.5; glowColor = mix(glowColor, secondColor, mixFactor);

atan函数返回的是当前片元相对于圆心的角度,配合sin做周期变化,光斑的颜色会围绕圆心旋转变化,看上去华丽很多。

扩展的时候记住一点:所有代码都是在“对单个片元”说话,你要做的是写出“在任意输入坐标下都正确”的规则,GPU会在每个片元上各自执行这套规则。这种思维方式一旦打通,Shader能力会上一个很大的台阶。

4. 排查实录:片元着色器最常见的六个坑

4.1 屏幕全黑:先查编译日志,不要猜

全黑是片元着色器最常遇到的问题。我的习惯是,只要画面没反应,第一步就打开浏览器控制台,看有没有红色报错。如果着色器语法写错、数据类型不匹配、变量名少个u,getShaderInfoLog和getProgramInfoLog会把错误信息打出来。

一个快速定位技巧:把片元着色器临时改成gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0)。如果画面变成纯红,说明管线基本没毛病,问题出在原来的计算逻辑上;如果还是黑色,就检查编译、链接、attribute、uniform这些上游环节。这种做法能把排查范围缩一半以上,我遇到难调的Shader都会先用这招。

4.2 颜色不对或花屏:精度和数据类型的锅

WebGL1中片元着色器默认没有精度声明,需要手动写precision mediump float。如果漏了,在部分浏览器里会直接编译失败。而精度如果写得太低,比如mediump,在低端GPU上做复杂光照时会丢失低位精度,出现明显色带。做后处理或者颜色细分比较多的效果时,建议用precision highp float。代价是低端设备性能会下降一点,但画面正确性优先。

另一个隐蔽问题是整数和浮点数的隐式转换。GLSL对类型要求很严格,比如uniform sampler2D被错误绑定,或者把int直接传给float参数,都会编译报错。该加.0的地方不要省,比如vec2(0.5, 0.5)写成vec2(0, 0)在某些驱动上会提示类型不匹配。

4.3 uniform没生效:先查名字和激活状态

很多新手把uniform传进Shader,却发现一点反应都没有。排查顺序是:先确认片元着色器里所有uniform都确实被使用了。只要某个uniform在Shader里没有被引用,编译器就会把它优化掉,getUniformLocation返回null。你往null上uniform赋值,JS不报错,但什么都不会发生。

再看变量名是否拼写一致,JavaScript里的uTime和GLSL里的u_time是两码事。还有GetUniformLocation返回值:如果程序链接成功但返回-1,基本就是这个变量进了死代码优化或者名字不匹配。

4.4 纹理坐标上下颠倒的老问题

做全屏效果时,如果片元着色器对vUv直接采样纹理,经常发现图片上下颠倒。原因在于WebGL的纹理坐标原点在左下角,而图片数据解析时习惯从上往下排列。解决办法有两种:一是给UV的y做1.0 - uv.y;二是在顶点着色器里把vUv的y翻转一次。网上很多朋友卡在这个问题上几个小时,其实原理就这么简单。

4.5 动画闪得厉害:时间单位没有换算

忘记把毫秒转秒是很常见的问题。u_time如果是毫秒级别,sin(uv.x * 10.0 + u_time)会产生极高频闪烁,看起来就像画面在乱跳。我的建议是所有时间相关计算里统一用秒,并且给速度系数起个清晰的名字,比如speed、frequency,方便后续调节。

4.6 多绘制却只有第一个物体有效:状态残留问题

如果你在同一个场景里绘制多个物体,要注意状态管理。比如一个片元着色器用了blend,另一个没有,切换时必须显式调用gl.enable或gl.disable。attribute位置、uniform值、采样器绑定都是同样的道理。很多奇怪的花屏问题,最后都定位到状态残留上。

5. 从核心逻辑到进阶方向:掌握片元着色器的下一步

5.1 片元着色器的三个进阶主题

看完上面的Demo,你已经掌握了片元着色器的基本闭环:输入坐标和uniform,通过数学表达式算出颜色,输出到片元。接下来想走得更远,沿着三个方向去探索最合适。

第一个方向是SDF(有向距离场)。SDF用距离函数描述各种几何体,比如球体、立方体、圆环,然后通过组合、变形、取交并集,生成极其丰富的三维感画面。前面用的distance函数其实就是SDF的最朴素形态,以后你会接触到更复杂的SDF,它是ShaderToy上大佬作品的核心武器。

第二个方向是纹理采样。texture2D函数可以把外部图片或数据纹理读进Shader里,片元着色器的输入从单纯数学坐标升级为像素查找。配合uniform sampler2D,可以做后处理、滤镜、图像融合,这也是网页端实现美颜、模糊、描边、色调映射的基础。

第三个方向是噪声和分形。用伪随机配合fract、floor、dot等函数生成噪声纹理,再做多层级分形叠加,可以得到云朵、水波、大理石、火焰一类的自然质感。这里的核心逻辑依然是“坐标到颜色的映射”,只是表达式复杂度成倍上升,但这个基础你已经具备了。

5.2 动手练习:三个可以立刻上手的小作业

光看不练绝对不行,我帮你设计三个由易到难的练习,全部基于今天这篇的基础知识就能完成。

第一个:在现有Demo基础上,把圆形改成菱形。思路是计算uv.x和uv.y变形,把distance换成manhattan distance,也就是abs(uv.x - 0.5) + abs(uv.y - 0.5),你会立刻看到菱形出现。

第二个:做一个棋盘格背景。用floor(uv.x * 8.0)和floor(uv.y * 8.0)的奇偶性交替输出两种颜色,加一点smoothstep柔化边界,可以做出很漂亮的网格地面。

第三个:做一个沿着u_time移动的渐变条纹。核心公式是sin((uv.x - u_time) * 频率),配合mix做出流动感。这三个练习做完,你对片元着色器的核心逻辑手感会非常扎实。

5.3 我的个人体会

我个人做完这个Demo之后最大的体会是:片元着色器其实是一个“规则编程”,难点不在语法,而在你脑海里能不能可视化出函数的形状。sin函数长什么样,smoothstep在坐标轴上的曲线是什么形态,abs函数会产生什么样的对称效果。这些不需要背,多玩几次,用颜色把函数本身可视化出来,你就真正上手了。

接下来可以试着去读ShaderToy上的热门代码,一上来不需要看全,重点找那些只用了gl_FragCoord和uniform的片段,对照着今天讲的输入和函数,一点一点拆。ShaderToy大部分代码会改用uv做变量名,本质其实就是咱们的vUv或者归一化坐标,一旦能连续读懂几个作品的思路,你的片元着色器水平会肉眼可见地上一个台阶。

下一篇我准备继续讲uniform和纹理采样的配合,顺便把如何用Shader写一个可以实时调整参数的后处理滤镜这件事说透。到时候见。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询