我最早接触“着色”这个概念的时候,完全是一头雾水。那时候拿到一个图形学作业,想给一个模型加个高光,翻来覆去调了半天材质参数,结果渲染出来该黑的地方还是黑,镜面反射亮得像是打了荧光。后来才明白,问题压根不在参数上,而在渲染流程里:数据在 GPU 里走了一条什么样的路、每一步由谁来算、算完又交给谁,这些没搞清楚,调再久也是瞎撞。图形学里真正上手的门槛,其实不在数学公式本身,而在理解 Rendering Pipeline 与 Shader 这一整套协作机制。这篇文章就是把这条路从头走一遍,从整体流程到具体着色器代码,到最常见的坑和排查办法,给正在学图形学或者刚接触 Shader 的人一个可以照着做、能落地的东西。
适合看的读者比较广:学计算机图形学课程的学生、做前端想学 WebGL 的开发者、用 Blender 或 MagicaVoxel 等工具但想知道背后原理的玩家,都行。你只要会一点 C 语言或者 JavaScript,能写一点 GLSL 就完全够用。至于线性代数,最多用到点乘和矩阵乘向量,我会把每一步拆开讲。
1. 先把脑子里的坐标系摆正:想理解 Rendering Pipeline,先分清谁在干活
1.1 我当年是怎么被“着色”卡住的
很多图形学教材讲着色,上来就是 Lambert、Phong、Blinn-Phong 一大堆光照模型,公式列了整整一页,看得人直冒汗。我当时就是这样:背得下公式,但一写 Shader 就不知道颜色从哪来。
后来卡在一个非常丢人的地方。我在顶点着色器里给每个顶点算了一个颜色,以为三角形表面就会自动平滑过渡。结果画面确实是“过渡”了,但那些三角形的棱角怎么都消不掉,每个三角形里都有一条明显的分界线。我以为是三角形细分不够,拉高了细分,问题稍微好一点,还是不对劲。
直到我把法线从顶点着色器挪到片元着色器里,在像素这一层做光照计算,整个画面才算真正“光滑”了。那一刻我才意识到:着色不是一件事,它分两个阶段,顶点阶段和片元阶段。不懂这两个阶段的分工,等于揣着一本菜谱,却不知道炒菜和摆盘到底该轮到谁。
1.2 渲染管线的分工和 Shader 的位置
Rendering Pipeline 简单说就是“从模型数据到屏幕像素”的流水线。我习惯把它分成这么几段:
- 输入装配:把顶点坐标、法线、UV 这些数据从内存里搬进 GPU。
- 顶点着色:每个顶点跑一次自己的程序,决定顶点位置在哪,顺便算一些需要插值的量。
- 几何/细分阶段:这部分是可选的,做曲面的细分、几何体增删。
- 光栅化:把三角形变成像素,按覆盖区域生成片元。
- 片元着色:每个像素跑一次自己的程序,决定这个像素最终颜色。
- 输出合并:处理深度测试、透明度混合,最后写入帧缓冲。
Shader 这个词,准确地讲应该叫“可编程着色器”,它嵌在顶点着色和片元着色这两个阶段里。顶点着色器最大的特点是“按顶点跑”,片元着色器是“按像素跑”。一个高精度的球体有几万个顶点,但屏幕上有几百万个像素,所以片元着色器的执行次数远多于顶点着色器,这也是为什么如果没插值,光照会显得很“脆”。
真正让画面变平滑的,不是把法线放到片元着色器里以后 GPU 自动帮你做了什么高深操作,而是 GPU 在顶点着色器和片元着色器之间自动做线性插值:顶点上处理好数据,光栅化阶段会把它们均匀地“抹”到三角形内部的每一个像素上。光照在像素上做,每个像素的明暗变化就是连续的,于是就看到光滑曲面了。
这个认识非常关键。因为常见的性能优化原则里,能放顶点着色器就不放片元着色器,说的就是在精度和性能之间做取舍。
2. 着色原理拆解:光照、法线和坐标空间
2.1 N dot L:用点乘“认方向”
谈到着色,就绕不开光照模型。最普通的光照,是漫反射模型,也叫 Lambert 光照模型。公式非常简单:
diffuse = max(0, dot(N, L)) * baseColor * lightColor
N 是表面法线,L 是从表面上这个点指向光源的方向。dot是点乘,得到两个向量夹角的余弦值。两个方向都是单位向量时,夹角为 0 度,余弦是 1,光直直打过来,表面最亮;夹角到 90 度,余弦为 0,光擦着表面滑过去,全黑;超过 90 度说明光在背面,此时用max(0, ...)直接裁掉,防止出现负值颜色。
为什么光刚好垂直于表面时最亮?因为同样的光通量,垂直照射时能量集中在最小面积上,单位面积收到的光最多。斜着照,同样的能量摊在更大的面积上,单位面积就暗了。这就是为什么中午太阳在头顶时影子最短、地面最亮,傍晚太阳快落山时,同样一片草地看起来也偏暗偏黄。计算机图形学里的漫反射,本质上在模仿这个物理事实。
真正写代码的时候,最容易翻车的是向量方向。我刚写时经常把 N 和 L 的方向搞反,或者忘了归一化,结果亮度忽高忽低。还可以直接在代码里写dot(normalize(normalVec), normalize(lightVec)),把归一化写进参数里,安全得多。
2.2 从公式到代码:一个最简漫反射着色器
我建议不要一上来就上 PBR(基于物理的渲染),先用最笨的固定式漫反射把流程跑通。下面这段 GLSL 片元着色器,是所有光照效果的地基:
precision mediump float; varying vec3 vNormal; varying vec3 vWorldPos; uniform vec3 uLightPos; uniform vec3 uBaseColor; void main() { vec3 N = normalize(vNormal); vec3 L = normalize(uLightPos - vWorldPos); float diff = max(dot(N, L), 0.0); vec3 ambient = uBaseColor * 0.1; vec3 diffuse = uBaseColor * diff * 1.0; vec3 color = ambient + diffuse; gl_FragColor = vec4(color, 1.0); }这里vNormal和vWorldPos是在顶点着色器里算完、然后插值传到片元着色器的。uLightPos是光源在世界坐标里的位置,uBaseColor是物体本身的颜色。环境光直接乘一个 0.1 的小系数,模拟场景里无处不在的微弱光线,避免背光面变成死黑。
动手跑这块代码之前,建议用gl_FragColor = vec4(vNormal.xyz, 1.0);直接渲染法线。你会看到整个模型像被涂成了万花筒一样的彩色。这一步不是瞎玩,它能帮你快速验证法线数据到底有没有传对。如果模型某些部分出现了不正常的黑块,那基本就是法线方向本身有问题,比如模型没导入切线空间数据,或者顶点法线没有经过变换。
2.3 着色模型选择,先搞懂“模仿”和“物理”的区别
初学者往往被一堆光照模型名称劝退。Lambert、Phong、Blinn-Phong、GGX、PBR……五花八门。我的看法是:如果只是想理解原理,Blinn-Phong 是性价比最高的一个。它是 Phong 的改进版,把高光计算从“反射向量和视线方向”改成“半程向量和法线”,效果差不多,性能更好,写起来也更不容易出错。再加一个 Blinn-Phong 高光项,这能让物体表面亮起来,不再是全哑光。
但你要清楚,所有这些模型,本质上都是物理现象的“模仿”,而不是真实物理。真实世界里的材质极其复杂,有次表面散射、有多层高光、有微表面自遮挡。PBR 之所以在游戏里流行,是因为它在“足够真实”和“实时计算能撑住”之间找了个平衡点。学习顺序我比较推荐:Lambert -> Blinn-Phong -> Cook-Torrance / PBR。一步到位学 PBR 也不是不行,就是容易陷进一堆约等式里,最后不知道自己为什么这么算。
3. 动手写 Shader:从 GLSL 到实际渲染
3.1 Shader 的标准结构:声明、入口函数、精度
不管是在 WebGL、OpenGL、Unity 还是 MagicaVoxel 里,Shader 的基本结构都差不多。拿 GLSL 举例,一个标准的片元着色器长这样:
precision highp float; varying vec2 vUv; uniform sampler2D uTexture; void main() { vec4 texColor = texture2D(uTexture, vUv); gl_FragColor = texColor; }第一行precision声明浮点精度。WebGL 里如果不写,有些 GPU 会直接编译报错,新手最容易栽在这。varying是从顶点着色器传过来的插值数据,比如 UV、法线、世界坐标。uniform是应用传到 GPU 的全局常量,每一帧都可以更新,比如光源位置、相机位置、时间。
顶点着色器同样有两个内置输出:gl_Position,必须赋值,因为管线后面要靠这个值做裁剪和光栅化;还有一个可选输出是varying,用来把数据传给片元着色器。一个最简单的顶点着色器是:
attribute vec3 aPosition; uniform mat4 uMVP; varying vec2 vUv; void main() { gl_Position = uMVP * vec4(aPosition, 1.0); }这里uMVP是模型-视图-投影矩阵,把物体本地坐标一步步变换到裁剪空间。初学者容易忽略的一点:着色器里根本没有“全局的世界坐标系”概念,所有坐标全靠矩阵算出来。模型中心点在哪、相机在哪、用正交还是透视,这些信息都藏在矩阵里。你给 Shader 传正确的矩阵,它才能把顶点放到正确的位置。
3.2 一个完整的 Blinn-Phong 示例,直接抄作业
下面我给一个可以直接跑在 Three.js 里的 Shader 示例。Three.js 会帮你处理大部分矩阵,你只需要关注数据传递和光照公式。
顶点着色器vertexShader:
varying vec3 vNormal; varying vec3 vWorldPos; void main() { vNormal = normalize(normalMatrix * normal); vec4 worldPosition = modelMatrix * vec4(position, 1.0); vWorldPos = worldPosition.xyz; gl_Position = projectionMatrix * viewMatrix * worldPosition; }片元着色器fragmentShader:
precision highp float; varying vec3 vNormal; varying vec3 vWorldPos; uniform vec3 uCameraPos; uniform vec3 uLightPos; uniform vec3 uLightColor; uniform vec3 uBaseColor; uniform float uShininess; void main() { vec3 N = normalize(vNormal); vec3 L = normalize(uLightPos - vWorldPos); vec3 V = normalize(uCameraPos - vWorldPos); // 漫反射 float diff = max(dot(N, L), 0.0); vec3 diffuse = uBaseColor * uLightColor * diff; // Blinn-Phong 高光 vec3 H = normalize(L + V); float spec = pow(max(dot(N, H), 0.0), uShininess); vec3 specular = uLightColor * spec * 0.5; // 环境光 vec3 ambient = uBaseColor * 0.05; vec3 finalColor = ambient + diffuse + specular; gl_FragColor = vec4(finalColor, 1.0); }里面值得注意的几个细节。normalMatrix是模型矩阵逆矩阵的转置,作用是保证法线在模型被缩放旋转后仍然垂直于表面。如果用 modelMatrix 直接乘法线,遇到非均匀缩放(X 轴拉长、Y 轴不变这种)很容易得到错误的法线方向。很多黑面问题,追根溯源就出在这。
高光部分用的是半程向量 H。它等于 L 加 V 之后归一化,相当于“光的方向”和“视线方向”的中间方向。如果 H 跟法线 N 越接近,说明光线正好往视线方向反射,高光越亮。uShininess控制高光范围,数值越小高光面积越大越柔,数值越大高光越小越锐利。做金属材质可以给到 100 左右,做塑料可以给到 20 到 30,做皮肤甚至只要几就够了。
3.3 MagicaVoxel Shader:当图形学工具开始“可编程”
很多人可能没想过,MagicaVoxel 这种以体素建模出名的工具,里面的 Shader 编辑器也是一个特别好的图形学入门练习场。它的着色器语法和 GLSL 不完全一样,但思路一模一样:对每一个片元做计算,根据体素坐标、法线、颜色等输入,输出最终颜色。
举个例子,在 MagicaVoxel 里可以写一个简单的“横条纹”着色器:
float stripes = floor(voxel.y * 4.0) / 4.0; col = vec4(stripes, stripes, stripes, 1.0);这里面voxel.y是当前体素在 Y 轴上的位置。乘 4、取整、再除 4,就把 Y 坐标按层压成阶梯状,每一层颜色都一样,形成条纹。这比在三维建模软件里手动画纹理快多了,而且改动一个数字就能看到全模型变化。
建议玩体素的人多去研究这里面的属性名,比如voxel、normal、col、id。它们其实就是图形学里最常见的几种顶点数据:位置、法线、基础颜色、材质 ID。把这些在 MagicaVoxel 里玩熟,再回头去看 GLSL 里的attribute和varying,会感觉非常亲切,因为原理完全一致。我甚至有段时间就是拿它练手,学会了之后再转回 WebGL 写正式 Shader,心里一点也不慌。
4. 实操环节:渲染一个可调光照的交互场景
4.1 搭建最简渲染环境
写 Shader 不能只在心里理解,必须能在屏幕上看到变化。我个人建议初学者用 Three.js 搭一个最简场景,因为它的矩阵、缓冲、相机都由框架处理,专注点全在 Shader 上。
一个最小可用流程大概是:
- 创建一个场景和透视相机,相机放远一点。
- 放一个球体模型,赋予
ShaderMaterial,传入上面写的顶点着色器和片元着色器。 - 用一个方向光或者点光源控制光照,把光源位置通过
uniforms传进去。 - 每一帧更新
uCameraPos为相机的世界坐标,uLightPos为光源的世界坐标。 - 允许通过鼠标拖动改变光源位置,实时观察高光变化。
这块实现起来代码不算少,但思路清楚。真正要花时间的是调uniforms的命名对应关系。一旦少传一个、写错一个名字,整个画面就变紫红色或者直接黑屏。所以调试的时候,我建议先给 uniforms 写死几个数,比如光源位置直接固定为(5.0, 5.0, 5.0),确认效果后再接上交互逻辑。
4.2 从效果反推问题:为什么画面不像预期
场景跑起来以后,一般会遇到这么几类“看似玄学”的现象,我把它们整理成一张速查表,后面排查问题可以对照着看。
| 现象 | 原因 | 排查方向 |
|---|---|---|
| 模型全黑 | 法线方向错误或没传 | 先渲染法线颜色,检查法线值 |
| 高光一块亮斑不动 | 半程向量方向有问题 | 检查 V 向量是否用世界坐标,有没有归一化 |
| 高光闪烁 | 深度冲突或法线精度不够 | 加大近裁剪面距离,提高精度 |
| 颜色整体偏暗 | 环境光系数太小或者伽马校正没做 | 调大环境光,查看是否已有 gamma 转换 |
| 物体像纸片一样没有立体感 | 整个 Shader 只有环境光,没有漫反射 | 检查uLightPos的传值是否正确 |
| 用 MagicaVoxel 预览时条纹错位 | 体素坐标对应关系没搞清 | 打印不同变量,对比坐标方向 |
这表不是为了让你背,而是给一个排查思路。图形学调试有个通用原则:一次只验证一件事。不要同时调光源方向、法线方向和材质颜色,那样出了问题根本分不清是哪个环节的锅。我会先在片元着色器里输出vNormal,确认法线正确后再接光照公式。这样搞,八成的问题都能在十分钟里定位到。
4.3 理解插值:Varying 的隐形作用
再回到开头说的插值。为什么片元着色器里拿到的vNormal已经不是严格的单位向量了?因为光栅化器对三角形的三个顶点的法线做线性插值,法线会平滑变化,但线性插值不保证长度不变。所以在片元着色器里,使用vNormal之前一定要normalize。这句看起来多余,其实是保护性操作,千万不能省。
同样,颜色、UV、世界坐标这些都是经过插值到的片元。这也是为什么在顶点着色器里算好颜色、然后传给片元着色器的时候,画面总是比逐像素计算粗糙一些:顶点着色器只在顶点处执行,颜色在三角形内部是靠插值“糊”过去的,光照上没有像素级精细。要拿到细腻的渲染效果,光照的计算至少应该在片元阶段做。现代游戏引擎和图形 API 里的法线贴图、PBR 材质,都是在片元这个层面玩的花样。
4.4 性能意识:Shader 里的每一行都有代价
写 Shader 不是越聪明越好,它是拿电费换画面的。顶点着色器跑的次数是顶点数,片元着色器跑的次数是像素数。复杂场景里,片元着色器的调用次数是千万级的。所以不要在片元着色器里写复杂循环、大量纹理采样或者不必要的分支,这是在给自己挖坑。
我做性能优化时常用的几个原则是:能移进顶点着色器的计算,就尽量移进去;能在应用程序里算好的矩阵,就别在 Shader 里反复乘;能合并到一张纹理里的贴图,就不要拆成三四张;能提前归一化的向量,就提前归一化。极端情况下,片元着色器里少一次texture2D调用,帧率能从 30 提到 60。
但这里也要说一句公道话:性能优化不是第一节课就该做的事,先把画面调对,再考虑把帧率拉满。这是我从踩坑里学到的,很多真正成套的项目,是先跑通,再压性能,最后才做美术打磨。
5. 进阶路线:从“抄代码”到“自己设计 Shader”
5.1 着色器其实是一个“函数”
如果要我把 Shader 浓缩成一句话,那就是:它是一个映射函数。输入顶点数据或像素数据,输出位置或颜色。理解了这一点,再看什么 PBR、毛发渲染、水面波动、卡通描边,都会觉得顺理成章。
想提高设计 Shader 的能力,比较有效的练习是做“效果分解”。看到一个好看的渲染效果,不要急着找别人的代码,而是先问自己:这个效果要哪些输入变量?它的输出颜色大概是由几个部分叠加出来的?哪部分是漫反射、哪部分是高光、哪部分可能是贴图采样?拆成三五段之后,再动手写,效率会高很多。
孔令德老师那本《计算机图形学基础教程》配套的图形学习题,虽然看起来是课堂练习,其实特别适合用来做这种训练。那些画直线、填充多边形、做光照的题目,本质上是让你盯着像素层面琢磨数据的流动和计算顺序。我当时是把它当成一本“Shader 思维练习题册”在用:每做一道题,就在心里想这个算法如果用 Shader 实现该怎么拆、需要几个 Pass。做几章之后,再看 Shader 就完全没有了那种边界感。
5.2 用两三个独立例子建立全局观
我自己带过的新人里,进步最快的,往往不是一开始就搞懂全部数学公式的人,而是能亲手写出两三个完整例子的人。所以这里再推荐三个小练习,全做完,你的图形学基础基本就稳了。
第一个练习:做一个随着时间变化的渐变色圆环。涉及 UV 坐标、长度计算、极坐标,能把 Shader 里的数学和屏幕像素对应起来。
第二个练习:给一个球体加一个动态移动的点光源,实现 Blinn-Phong 光照。涉及法线变换、向量归一化、uniform 通信,是最典型的光照场景。
第三个练习:在 MagicaVoxel 里写一个自定义 Shader,比如让模型的每个体素根据高度呈现不同颜色。这一步把体素数据的读取、分支判断、颜色映射串起来,能体会到“规则定义画面”的乐趣。
三个练习做完,你会发现自己对“着色”这件事的理解从“调参数”变成了“搭公式、控流程”。这个转变,是真正入门的标志。
6. 写在最后的个人体会
我现在回头看,学图形学最容易掉进去的坑,不是数学不好,而是太早追求复杂。很多人一上来就研究延迟渲染、全局光照、光线追踪、GPU 粒子,结果基础 Shader 还没写利索,遇到问题根本不知道从哪下手。着色器这个东西,说透了一点不神秘:它只是一个跑的特别快的函数,负责把数据变成颜色。只要顺着 Rendering Pipeline 走一遍顶点着色、光栅化、片元着色,你手里的每个模型都会开始“听话”。
如果让我给一个最实在的建议,那就是:先别查资料,自己动手把那个 Blinn-Phong 球体渲染出来。哪怕只是把一个白色球体放到黑色背景里,拖动光源看到高光跟着动,也比看十篇教程有用。等这个球体“活了”,再往上叠加纹理、法线贴图、PBR,整个图形学世界基本就对你打开了。