渲染管线与Shader实战:从漫反射到Blinn-Phong
发布时间:2026/10/4 15:17:09 锦皓数字建站

我最早接触“着色”这个概念的时候完全是一头雾水。那时候拿到一个图形学作业想给一个模型加个高光翻来覆去调了半天材质参数结果渲染出来该黑的地方还是黑镜面反射亮得像是打了荧光。后来才明白问题压根不在参数上而在渲染流程里数据在 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 * lightColorN 是表面法线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 会帮你处理大部分矩阵你只需要关注数据传递和光照公式。顶点着色器vertexShadervarying 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; }片元着色器fragmentShaderprecision 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整个图形学世界基本就对你打开了。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。