想象你走进一家米其林三星餐厅的后厨:几十位厨师同时开工,各有分工、互不干扰。GPU就是这样一间后厨,而着色器语言,就是写给这些“厨师”的菜单。想让游戏画面生动起来,你得学会用 HLSL 或 GLSL 写出两张最关键的“菜谱”——一张给负责切配的师傅(顶点着色器),决定食材的骨架形状;一张给负责摆盘上酱的师傅(片元着色器),决定每一口菜最终的颜色和光泽。HLSL 是微软 DirectX 系的菜谱方言,GLSL 是 OpenGL/Vulkan/WebGL 系的开源菜谱方言,两者像粤菜和川菜——技法相通,但用词、调料顺序各不相同。本文就从一间后厨的故事开始,带你走完从顶点变换、像素着色、纹理采样到光照计算的完整入门旅程。

岗位关联

对 AI 从业者来说,理解 Shader 是理解“GPU 并行计算”的最佳入口,也是走向 AI 推理优化、实时渲染、数字人特效等方向的底层基本功,让你在渲染管线和端侧推理场景中都能看懂核心代码。

目录
  1. 可视化演示
  2. 着色器概览:GPU后厨的入场券
  3. 顶点着色器:切配师傅的刀刃
  4. 片元着色器:摆盘师傅的酱汁
  5. 数据类型:装食材的容器规格
  6. 内置对象:后厨的现成调料包
  7. 纹理采样:从冰箱取食材
  8. 光照计算:调节灯光的火候
  9. 差异与编译:两套菜谱的转译
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

交互式展示正态分布的概率密度函数与采样直方图,通过滑块调节均值和标准差,实时观察分布形态变化与统计特征。

本节配有交互动效(进入视口后自动加载,约 3KB)。

概率密度函数 正态分布PDF呈钟形曲线,关于均值μ对称 σ越大分布越分散,曲线越平坦 f(x) = (1/σ√2π) · e^(-(x-μ)²/2σ²) 点击采样按钮观察随机样本的分布

着色器概览:GPU后厨的入场券

想象你第一天到那间米其林三星后厨报到。后厨不是一个人埋头苦干,而是一条有分工的流水线:有人检查食材,有人切配,有人掌勺,有人摆盘。GPU 的渲染管线也是如此——它把绘制一帧画面的工作拆成固定的几个大环节,而着色器(Shader)就是其中可以自由编程的“岗位说明书”。所谓着色器,本质上是一段跑在 GPU 上的小程序,它会被对每个顶点或每个像素重复执行,形成大规模并行。

为什么非要并行不可?因为一帧画面有几百万个顶点、上千万个像素,CPU 逐个处理会慢到无法接受。GPU 的做法是同时派出几百上千个“厨房学徒”,每人负责一个顶点或一个像素,互不干扰地执行同一份代码。这就是 GPU 并行特性的来源,也是 Shader 性能优化的底层逻辑——你写的代码,是在成千上万个线程里同时运行的。

着色器并非只有一种。最核心的两大岗位是顶点着色器(Vertex Shader)与片元着色器(Fragment/Pixel Shader),它们之间还隔着固定功能的“光栅化”阶段。而在写代码时,你又面临两大语言阵营:HLSL 是 DirectX 世界的官方菜谱,常用于 Unreal 与 Windows 平台;GLSL 是 OpenGL/Vulkan/WebGL 世界的开源菜谱,广泛用于移动端、网页与跨平台引擎。掌握任一一门,再迁移到另一门,会比你想象的简单。

回到后厨视角,你会发现 Shader 学习有一条清晰的主线:先认识流水线,再分别认识两个“师傅”的职责,最后储备食材(纹理)与调味(光照)。接下来两节,我们分别拜访这两位师傅。

顶点着色器⟶光栅化⟶片元着色器⟶颜色缓冲\displaystyle \text{顶点着色器} \longrightarrow \text{光栅化} \longrightarrow \text{片元着色器} \longrightarrow \text{颜色缓冲}
这四段就是 GPU 后厨的主线流程:先定形状,再切碎,再上色,最后端出屏幕。
  1. 顶点着色器逐个处理顶点坐标与属性,相当于把食材切成指定的形状。
    vout=Transform(vin)\displaystyle \mathbf{v}_{\text{out}} = \text{Transform}(\mathbf{v}_{\text{in}})
  2. 光栅化把三角形连成片,并拆成屏幕上的候选像素网格(片元),相当于传菜带把食材切成小块。
    片元=Rasterize(三角形)\displaystyle \text{片元} = \text{Rasterize}(\text{三角形})
  3. 片元着色器为每个片元计算最终 RGBA 颜色,相当于摆盘师傅给每一小块上酱。
    Cfrag=Shade(Cinterp)\displaystyle \mathbf{C}_{\text{frag}} = \text{Shade}(\mathbf{C}_{\text{interp}})
  4. 颜色缓冲经过深度测试与混合后写入屏幕缓冲,相当于出菜口把菜端给客人。
    像素屏幕=Blend(片元颜色,深度)\displaystyle \text{像素}_{\text{屏幕}} = \text{Blend}(\text{片元}_{\text{颜色}}, \text{深度})

顶点着色器:切配师傅的刀刃

切配师傅是后厨里第一个接手食材的人。他不在乎菜最后好不好看,只在乎每块食材的形状、大小、位置是否正确。顶点着色器扮演的正是这个角色:它逐个处理网格中的顶点,把物体的三维坐标一步步变换到屏幕能理解的空间,同时把法线、UV、颜色等属性传递给下游。它不管像素,只看点。

在具体代码里,顶点着色器的输入来自“顶点缓冲”,一个顶点通常包含位置、法线、纹理坐标等数据;输出则必须包含一个特殊变量——HLSL 中是 SV_Position,GLSL 中是 gl_Position,它表示顶点在裁剪空间里的坐标。为什么叫“裁剪空间”?因为超出这个范围的三角形会被 GPU 直接切掉,就像切配师傅把不符合规格的食材丢弃。

顶点着色器最有代表性的计算就是 MVP 变换:把物体的本地坐标依次经过模型矩阵(M)、视图矩阵(V)、投影矩阵(P),最终得到裁剪坐标。因为每个顶点的变换彼此独立,GPU 可以同时处理成千上万个顶点,这正是它被称为“逐顶点并行”的原因。

在后厨里,切配师傅的效率决定了整条流水线的速度。同理,顶点着色器里的计算越轻量,GPU 就能在更短时间内处理完几十万顶点。你写的每一行顶点代码,都会被成千上万次执行,所以这里的原则是:只做必要的坐标变换,把复杂计算留给片元阶段去斟酌。

vclip=P×V×M×vlocal\displaystyle \mathbf{v}_{\text{clip}} = \mathbf{P} \times \mathbf{V} \times \mathbf{M} \times \mathbf{v}_{\text{local}}
这是图形学里最常见的公式之一:一个点从“自己家”搬到“世界”,再挪到“摄像机眼前”,最后压进“屏幕裁剪框”。
  1. 本地坐标物体在建模软件里的原始坐标,相当于食材还没被切配时的自然形态。
    vlocal=(x,y,z,1)⊤\displaystyle \mathbf{v}_{\text{local}} = (x, y, z, 1)^\top
  2. 模型矩阵 M把物体放置在场景中的位置并调整大小、朝向,相当于把食材放到指定案板上。
    vworld=M×vlocal\displaystyle \mathbf{v}_{\text{world}} = \mathbf{M} \times \mathbf{v}_{\text{local}}
  3. 视图矩阵 V以相机为原点重新表达世界坐标,相当于从厨师的眼睛去观察案板。
    vview=V×vworld\displaystyle \mathbf{v}_{\text{view}} = \mathbf{V} \times \mathbf{v}_{\text{world}}
  4. 投影矩阵 P把三维视角压成二维裁剪坐标,相当于用相机取景框框住画面。
    vclip=P×vview\displaystyle \mathbf{v}_{\text{clip}} = \mathbf{P} \times \mathbf{v}_{\text{view}}

片元着色器:摆盘师傅的酱汁

切配完成之后,食材通过传菜带送到摆盘师傅面前。传菜带并非直接递上整块食材,而是把三角形切成了一格一格的“候选像素”——在图形学里,这些候选像素被称为片元(Fragment)。摆盘师傅(片元着色器)的工作,就是决定每一个片元最终的颜色:刷多少酱、撒多少盐、调多少亮度,都由他来掌控。

片元着色器接收的数据,是顶点着色器输出属性的“插值结果”。什么意思?假设三角形三个顶点分别是红、绿、蓝三种颜色,那么三角形内部的每个片元,颜色应当平滑地从红色过渡到绿色再到蓝色。GPU 使用重心坐标(Barycentric Coordinates)来计算每个片元相对三个顶点的权重,然后按权重混合颜色。这个自动插值机制,让你只需在顶点处提供颜色或 UV,就能得到一片连续渐变的表面。

这类插值对坐标、颜色、法线、UV 都成立,但有一个关键细节:屏幕空间的线性插值并不等于透视效果正确的插值。GPU 会先在裁剪空间对 顶点位置 / w 做插值,再把最终结果乘以 w 恢复,这就是透视校正插值,否则纹理会出现类似“远处奶茶杯扭曲”的失真。此外,片元输出并非直接写入屏幕:它还要经过深度测试(谁离相机更近谁可见)与颜色混合(半透明物体叠加),这两个环节通常是固定功能管道,不归着色器管,但决定了透明效果能否正确呈现。

摆盘师傅是画面质量的最后一道关口。你可以在这里实现美颜磨皮、风格化描边、发光特效——一切“看起来好看”的魔法,几乎都发生在片元着色器里。理解插值,你才真正理解了为什么 Shader 能在一张光滑的曲面上画出连续细腻的图案。

Cpixel=w0C0+w1C1+w2C2\displaystyle \mathbf{C}_{\text{pixel}} = w_0 \mathbf{C}_0 + w_1 \mathbf{C}_1 + w_2 \mathbf{C}_2
片元颜色 = 三个顶点颜色的加权平均,权重就是该片元在三角形内的重心坐标。
  1. 三个顶点的颜色C0、C1、C2 是三角形三个顶点携带的颜色值,相当于三种基础酱汁。
    C0,C1,C2∈R3\displaystyle \mathbf{C}_0, \mathbf{C}_1, \mathbf{C}_2 \in \mathbb{R}^3
  2. 重心坐标权重w0、w1、w2 表示片元离三个顶点的“亲近程度”,且三者之和为 1。
    w0+w1+w2=1,0≤wi≤1\displaystyle w_0 + w_1 + w_2 = 1,\quad 0 \le w_i \le 1
  3. 加权混合把酱汁按比例混合,得到片元最终的 base 颜色。
    Cbase=w0C0+w1C1+w2C2\displaystyle \mathbf{C}_{\text{base}} = w_0 \mathbf{C}_0 + w_1 \mathbf{C}_1 + w_2 \mathbf{C}_2
  4. 透视校正在透视投影下,插值要在除以 w 的空间中进行,再乘回 w,避免纹理拉伸失真。
    Ccorrect=w0 C0/w0′+w1 C1/w1′+w2 C2/w2′w0/w0′+w1/w1′+w2/w2′ (形式示意)\displaystyle \mathbf{C}_{\text{correct}} = \frac{w_0\, \mathbf{C}_0 / w_0' + w_1\, \mathbf{C}_1 / w_1' + w_2\, \mathbf{C}_2 / w_2'}{w_0 / w_0' + w_1 / w_1' + w_2 / w_2'}\ \text{(形式示意)}

数据类型:装食材的容器规格

后厨的每个食材都需要合适的容器。大块牛肉要用密封箱,配菜要用托盘,酱汁要用小瓶。HLSL 与 GLSL 同样为数据提供了不同规格的“容器”,也就是数据类型。最基础的是标量(scalar):float(浮点数)、int(整数)、bool(布尔值)。它们是所有计算的原子单元,就像一粒米、一滴水。

但图形学处理的是三维空间里的位置、方向、颜色,单靠标量不够用。于是两大语言都提供了向量类型:HLSL 写作 float2 / float3 / float4,GLSL 写作 vec2 / vec3 / vec4。一个 float4 可以同时装下 (x, y, z, w) 坐标,也可以装 (r, g, b, a) 颜色。更强大的是 swizzle 操作:你可以用 .xyw、.gba 任意重排分量,比如 v.yx 能快速交换两个分量,这在编写 Blinn-Phong 光照时会频繁用到。

除了向量,矩阵也是图形学的常客。HLSL 的 float4x4 与 GLSL 的 mat4 都代表 4×4 矩阵,用于存放 MVP 变换等线性变换。由于 GPU 硬件针对 4 分量向量做了深度优化,矩阵乘法通常被拆成一连串向量点积,这也是为什么 Shader 里到处是 float4 和 vec4。最后还有一种特殊容器——采样器(Sampler):HLSL 中常用 SamplerState,GLSL 中常用 sampler2D,它本身不直接存纹理数据,而是指向显存中的纹理资源并提供过滤、寻址等采样设置。

选择正确的容器不仅让代码更清晰,还会影响性能。在移动端 GPU 上,使用 half(HLSL)或 mediump(GLSL)等半精度类型可以显著省电、提帧率,但也会牺牲精度。学会根据“食材精度需求”挑选容器规格,是 Shader 工程化的必修课。

v=(x,y,z,w),v.xy=(x,y),v.bgr=(b,g,r)\displaystyle \mathbf{v} = (x, y, z, w),\qquad \mathbf{v}.xy = (x, y),\qquad \mathbf{v}.bgr = (b, g, r)
向量可以像货柜一样按标签取货:xyzw 是空间名,rgba 是颜色名,两者底层完全等价。
  1. 四分量向量一个 float4 / vec4 同时存储四个标量,常用于坐标或颜色。
    v=(x,y,z,w)\displaystyle \mathbf{v} = (x, y, z, w)
  2. 分量别名rgba 与 xyzw 指向同一批分量,只是语义不同。
    v.a≡v.w,v.b≡v.z\displaystyle \mathbf{v}.a \equiv \mathbf{v}.w,\quad \mathbf{v}.b \equiv \mathbf{v}.z
  3. swizzle 重排通过任意排列分量构造新向量,硬件层面几乎零开销。
    v.zyx=(z,y,x)\displaystyle \mathbf{v}.zyx = (z, y, x)

内置对象:后厨的现成调料包

没有哪家后厨会要求厨师从研磨胡椒开始。GPU 同样为 Shader 提供了大量“现成调料包”——内置变量与内置函数。先看变量:图形 API 会把当前线程对应的顶点编号、片元坐标等信息送进来。HLSL 使用语义(Semantic)系统标记这些数据,例如 SV_VertexID 表示顶点编号,SV_Position 表示裁剪坐标,SV_Target 表示渲染目标输出;GLSL 则用内置变量,例如 gl_VertexID、gl_Position、gl_FragColor(现代 GLSL 更推荐自定义 out vec4 fragColor)。

语义系统就像后厨的“订单标签”。一个顶点输入是位置还是法线?一个输出是给光栅化器的坐标还是给下一阶段的颜色?语义标签让 GPU 能精确对接数据,而不会把盐当成糖。HLSL 里输入结构体常写 POSITION、NORMAL、TEXCOORD0,输出必须带 SV_Position;GLSL 里则通过 layout(location = 0) 或内置变量完成类似对接。

内置函数更是琳琅满目。数学类有 lerp(线性插值)、normalize(归一化)、dot(点积)、pow(幂)、saturate/clamp(截断);纹理类有 HLSL 的 tex2D 和 GLSL 的 texture;矩阵变换类有 HLSL 的 mul 和 GLSL 的 * 运算符。这些函数看起来零散,却是 Shader 世界的“基础调味料”——没有它们,你连最普通的漫反射都写不出来。

在真实项目中,你有多少种特效,就会用上多少种“调料包”。与其死记硬背每个函数,不如记住一个原则:能交给内置函数做的事,绝不自己手写——内置函数经过平台深度优化,比如 normalize 在大多数 GPU 上只需几条指令。

lerp⁡(a,b,t)=a+t (b−a)\displaystyle \operatorname{lerp}(a, b, t) = a + t\,(b - a)
lerp 是 Shader 里最常用的函数之一:从 a 到 b 按比例 t 取中间值,就像调配酱汁时一点点加辣。
  1. 起点a 是插值的起始值。
    a∈R\displaystyle a \in \mathbb{R}
  2. 终点b 是插值的结束值。
    b∈R\displaystyle b \in \mathbb{R}
  3. 比例t 通常取值 0 到 1,0 时输出 a,1 时输出 b。
    0≤t≤1\displaystyle 0 \le t \le 1
  4. 结果按比例混合得到中间值,常用于颜色过渡、位置移动、发光渐隐。
    lerp⁡(a,b,t)=a+t(b−a)\displaystyle \operatorname{lerp}(a, b, t) = a + t(b-a)

纹理采样:从冰箱取食材

再好的酱汁也离不开食材。纹理(Texture)就是 Shader 世界的“冰箱”,里面存放着颜色、法线、粗糙度等大量数据。想让片元着色器从冰箱里取出正确的食材,必须有坐标——这就是 UV 坐标。每个顶点除了位置,还携带一对 (u, v) 坐标,取值范围通常是 0 到 1,表示在纹理上的采样位置。图元内部的片元会通过插值得到自己的 UV,从而从纹理中读取对应颜色。

要完成采样,你需要两样东西:纹理本身和采样器(Sampler)。采样器定义“如何从纹理中取值”,包括寻址方式与过滤方式。寻址方式决定 UV 超出 0~1 范围时怎么办:Repeat(重复平铺)适合地板瓷砖,Clamp(边缘拉伸)适合 UI 贴图,Mirror(镜像)适合对称花纹。过滤方式则决定采样点的颜色如何计算:最近邻(Nearest)速度最快但有锯齿;双线性(Bilinear)取周围 4 个纹素加权平均,画面更平滑。

还有一个重要的工程技巧是多级渐远纹理(Mipmap)。想象一块棋盘格纹理贴在很远的墙面上:远处的像素密集到无法表现黑白格,就会闪烁抖动。Mipmap 的做法是预先生成一系列逐级缩小一半的纹理,采样时根据片元离摄像机的距离选择合适的层级,再在两个层级之间做三线性插值。这既减轻了闪烁,又降低了显存带宽压力,在移动端游戏里几乎是必选项。

在后厨里,取食材的动作看起来小,但每天要做几千次,所以聪明的大厨会提前把常用的食材分类码放。纹理采样也一样:合理设置 Mipmap、选择合适的过滤与寻址模式,能让你在画质和性能之间找到最佳平衡点。

C=(1−s)(1−t) C00+s(1−t) C10+(1−s)t C01+st C11\displaystyle \mathbf{C} = (1-s)(1-t)\,\mathbf{C}_{00} + s(1-t)\,\mathbf{C}_{10} + (1-s)t\,\mathbf{C}_{01} + s t\,\mathbf{C}_{11}
双线性过滤的公式:用 UV 的小数部分 s、t,把相邻四个纹素按距离加权混合。
  1. UV 偏移量s 是 U 方向的小数部分,t 是 V 方向的小数部分,表示落在四个纹素之间的位置。
    s=fraction⁡(u×宽度),t=fraction⁡(v×高度)\displaystyle s = \operatorname{fraction}(u \times \text{宽度}),\quad t = \operatorname{fraction}(v \times \text{高度})
  2. 四个纹素C00、C10、C01、C11 是采样点周围 2×2 的纹素颜色。
    Cij=纹理(i,j)\displaystyle \mathbf{C}_{ij} = \text{纹理} (i, j)
  3. U 方向两次插值先对上下两行分别做水平插值。
    Ctop=(1−s)C00+s C10,Cbottom=(1−s)C01+s C11\displaystyle \mathbf{C}_{\text{top}} = (1-s)\mathbf{C}_{00} + s\,\mathbf{C}_{10},\quad \mathbf{C}_{\text{bottom}} = (1-s)\mathbf{C}_{01} + s\,\mathbf{C}_{11}
  4. V 方向一次插值再把上下两行结果垂直插值,得到最终颜色。
    C=(1−t) Ctop+t Cbottom\displaystyle \mathbf{C} = (1-t)\,\mathbf{C}_{\text{top}} + t\,\mathbf{C}_{\text{bottom}}

光照计算:调节灯光的火候

一道菜端上桌后好不好吃,除了食材和酱汁,还取决于灯光氛围。着色器里的光照计算,就是模拟光线如何被物体表面反射到相机里的过程。最简单也最常用的是 Blinn-Phong 模型,它把光照拆成三个部分:环境光(Ambient)、漫反射(Diffuse)和镜面高光(Specular)。

环境光是一个常数项,模拟场景中经过多次反弹的环境亮度,保证物体背光面不至于全黑——就像后厨总有一盏常亮的吸顶灯。漫反射描述表面向各个方向均匀反射的光,强度与法线和光源方向的夹角相关。夹角越小,表面越亮,用数学表达就是法线 N 与光源方向 L 的点积,即 cos θ。当光线从侧面擦过表面时,cos θ 接近 0,表面就暗下来,这个现象在真实世界里随处可见。

镜面高光则描述光滑表面上的亮斑。Phong 模型用反射向量 R 与视线方向 V 的点积来控制高光,而 Blinn-Phong 做了一点巧妙的优化:改用“半程向量” H = normalize(L + V),高光项变成 (N · H)^α,其中 α 控制高光的锐利程度。α 越大,高光越集中,物体光泽感越强。这个改动让高光计算更快,而且视觉效果几乎一致,因此成为实时渲染的事实标准。

这就像后厨里调节射灯的角度与聚拢程度:漫反射是散开的基础照明,高光是一束集中的射灯,环境光则是永不熄灭的顶灯。把三者叠在一起,物体就有了立体感和材质感。

Iout=Ia+Id max⁡(0,N⋅L)+Is (N⋅H)α\displaystyle \mathbf{I}_{\text{out}} = \mathbf{I}_{a} + \mathbf{I}_{d}\,\max(0, \mathbf{N}\cdot\mathbf{L}) + \mathbf{I}_{s}\,(\mathbf{N}\cdot\mathbf{H})^{\alpha}
最终亮度 = 环境光 + 漫反射(受 N·L 控制)+ 镜面高光(受 N·H 的 α 次方控制)。
  1. 环境光Ia 是常数环境项,保证暗面也有基础亮度。
    Ia=const\displaystyle \mathbf{I}_{a} = \text{const}
  2. 漫反射项N·L 是法线与光源方向点积,等于 cos θ;max(0, ·) 确保背光面不会产生负光。
    Idmax⁡(0,N⋅L)\displaystyle \mathbf{I}_{d} \max(0, \mathbf{N}\cdot\mathbf{L})
  3. 半程向量H = normalize(L + V),把光源方向与视线方向相加并归一化,得到方向“最居中”的向量。
    H=L+V∥L+V∥\displaystyle \mathbf{H} = \frac{\mathbf{L} + \mathbf{V}}{\|\mathbf{L} + \mathbf{V}\|}
  4. 高光锐度(N·H) 的 α 次方,α 越大,偏离镜面方向的光衰减越快,高光越小越亮。
    (N⋅H)α,α≥1\displaystyle (\mathbf{N}\cdot\mathbf{H})^{\alpha},\quad \alpha \ge 1

差异与编译:两套菜谱的转译

如果你既会粤菜又懂川菜,会发现两者只是招式不同,底层逻辑一致。HLSL 与 GLSL 的关系也是这样。最直观的差异在语法:HLSL 用 float4,GLSL 用 vec4;HLSL 用 mul(matrix, vector) 做矩阵乘法,GLSL 直接用 matrix * vector;HLSL 输出用语义 SV_Position、SV_Target,GLSL 输出用内置变量 gl_Position 或自定义 out 变量。此外,HLSL 默认行向量约定,GLSL 默认列向量约定,反映在矩阵乘法的书写顺序上。

版本与着色器模型也有差异。HLSL 对应 DirectX 的 Shader Model,常见如 SM 5.0、SM 6.0,后者引入了光线追踪等高级能力;GLSL 按 OpenGL 版本迭代,从 1.20 到现在的 4.60,而 Vulkan 使用 SPIR-V 作为中间二进制格式。所谓中间格式,就像菜谱被翻译成后厨统一的操作指令:GLSL 和 HLSL 都可以先编译成 SPIR-V,再由 GPU 驱动翻译成硬件指令。这意味着,跨平台引擎(如 Unity、Unreal)可以把一份 Shader 源码编译成不同 API 对应的产物。

实际开发中的编译与调试也有明显差异。HLSL 通常用 DXC 编译器,报错信息里有行号和语义信息;GLSL 由驱动在运行时编译,报错依赖各家驱动的质量,调试更依赖 RenderDoc、PIX 等图形调试工具。跨平台适配时,常见做法是使用 Unity 的 ShaderLab、Unreal 的 Material 系统进行统一封装,由引擎自动生成平台对应的着色器代码;高端项目则会直接维护一套 HLSL,再用工具转译出 GLSL/SPIR-V。

回到后厨故事:你不需要同时精通两本菜谱才能做出好菜,但你需要知道它们的异同,才能在换厨房(换平台)时迅速适应。HLSL 与 GLSL 的语法差异是表面的,底层的 GPU 计算模型和渲染管线才是真正的“厨艺内功”。

SV_Position  ⟺  gl_Position\displaystyle \text{SV\_Position} \;\Longleftrightarrow\; \text{gl\_Position}
这两者都表示顶点的裁剪空间坐标,是 HLSL 与 GLSL 在输出语义上最直观的对应关系。
  1. HLSL 语义SV_Position 标记顶点着色器输出的裁剪坐标,供光栅化阶段使用。
    SV_Position∈R4\displaystyle \text{SV\_Position} \in \mathbb{R}^4
  2. GLSL 内置变量gl_Position 是 GLSL 顶点着色器必须写入的内置输出变量。
    gl_Position∈R4\displaystyle \text{gl\_Position} \in \mathbb{R}^4
  3. 语义等价二者在渲染管线中的角色一致,是“两种菜谱同一个做法”的最佳例证。
    SV_Position⟺gl_Position\displaystyle \text{SV\_Position} \Longleftrightarrow \text{gl\_Position}

AI 应用场景

游戏引擎中的风格化渲染

阶段:游戏渲染开发与实时视觉特效

解决的问题:传统内置材质只能提供普适的 PBR 效果,无法满足“原神式赛璐璐描边”“赛博霓虹光污染”等独特视觉风格。手写 Shader 能让你在顶点与片元阶段自由控制轮廓、色阶和发光范围,把美术创意直接变成像素级指令。

具体案例:技术美术(TA)在 Unity 中编写一个“角色描边 Shader”:顶点着色器沿法线方向把模型略微放大作为描边层,片元着色器用 smoothstep 对漫反射做色阶量化,再叠加菲涅尔边缘光。整个过程只需几十行 Shader 代码,就能让角色在卡通渲染中拥有清晰的轮廓线和手绘感。

直播美颜与实时滤镜

阶段:端侧推理与实时图像处理

解决的问题:CPU 逐像素处理 1080P 视频无法达到实时,美颜滤镜本质上是片元着色器的高频调用——对每个像素做肤色检测、磨皮混合、大眼瘦脸等操作,GPU 并行计算能轻松跑满 60 帧。

具体案例:一款直播 App 的美颜模块:先用轻量人脸检测模型定位关键点,再把磨皮操作写进片元着色器,对皮肤区域做双线性采样与高斯模糊混合,对非皮肤区域保持原样。Shader 让“每一帧的每一像素”都被并行处理,延迟降低到 5ms 以内。

跨平台渲染引擎的 Shader 统一

阶段:跨端渲染适配与性能优化

解决的问题:同一个游戏要发布到 Windows、iOS、Android 和 Web,各平台的图形 API 各异,手工维护多份 Shader 成本极高。通过 Unity/Unreal 的 Shader 封装层,或使用 SPIR-V 作为中间码,可以实现“一次编写,处处运行”。

具体案例:某跨平台 3D 应用在 iOS 上使用 Metal,在 Android 上使用 Vulkan。团队用 HLSL 编写全套材质,通过 ShaderConductor 转译为 SPIR-V,再在运行时分发到不同 API;遇到移动端精度问题时,用 half 与 mediump 精确控制每段计算精度,最终让中端手机帧率提升 30%。

Python 演示

python48 行
import math W, H = 60, 30CHARS = ' .:-=+*#%@' def clamp(v):    return max(0, min(255, int(v))) def barycentric(px, py, v0, v1, v2):    den = (v1[0]-v0[0]) * (v2[1]-v0[1]) - (v2[0]-v0[0]) * (v1[1]-v0[1])    if abs(den) < 1e-9:        return (-1, -1, -1)    w0 = ((v1[0]-px) * (v2[1]-py) - (v2[0]-px) * (v1[1]-py)) / den    w1 = ((v2[0]-px) * (v0[1]-py) - (v0[0]-px) * (v2[1]-py)) / den    w2 = ((v0[0]-px) * (v1[1]-py) - (v1[0]-px) * (v0[1]-py)) / den    return (w0, w1, w2) def vertex_shader(x, y, t):    c = math.cos(t)    s = math.sin(t)    return (x * c - y * s, x * s + y * c) def fragment_shader(w0, w1, w2, colors):    light = 0.55 + 0.45 * max(0.0, w1)    r = (colors[0][0]*w0 + colors[1][0]*w1 + colors[2][0]*w2) * light    g = (colors[0][1]*w0 + colors[1][1]*w1 + colors[2][1]*w2) * light    b = (colors[0][2]*w0 + colors[1][2]*w1 + colors[2][2]*w2) * light    return (clamp(r), clamp(g), clamp(b)) def render(t):    tri = [(-0.8, -0.6), (0.8, -0.6), (0.0, 0.9)]    colors = [(200, 80, 80), (80, 200, 80), (80, 120, 220)]    vs = [vertex_shader(x, y, t) for x, y in tri]    vs = [(int((x+1) * (W-1) / 2), int((1-y) * (H-1) / 2)) for x, y in vs]    out = [[' '] * W for _ in range(H)]    for py in range(H):        for px in range(W):            w0, w1, w2 = barycentric(px, py, vs[0], vs[1], vs[2])            if w0 >= 0 and w1 >= 0 and w2 >= 0:                r, g, b = fragment_shader(w0, w1, w2, colors)                lum = (r * 0.299 + g * 0.587 + b * 0.114) / 255                out[py][px] = CHARS[min(int(lum * len(CHARS)), len(CHARS)-1)]    return '\n'.join(''.join(row) for row in out) if __name__ == '__main__':    for i, t in enumerate([0.0, 0.6, 1.2]):        print('frame %d rotate %.1f' % (i, t))        print(render(t))
目的:用纯 Python 模拟一个迷你渲染管线——顶点着色器旋转三角形,光栅化器计算重心坐标,片元着色器做颜色插值与简单光照,最后以字符画输出三帧结果。
  1. vs = [vertex_shader(x, y, t) for x, y in tri]对三个顶点逐一施加旋转矩阵,模拟顶点着色器的逐顶点并行变换。
  2. w0, w1, w2 = barycentric(px, py, vs[0], vs[1], vs[2])对屏幕每个像素计算重心坐标,对应 GPU 光栅化阶段。
  3. if w0 >= 0 and w1 >= 0 and w2 >= 0:只有重心坐标全非负的像素才位于三角形内部,等效于光栅化中的 inside 测试。
  4. light = 0.55 + 0.45 * max(0.0, w1)用一个简单权重模拟光源方向,展示片元着色器如何逐像素调整亮度。
  5. out[py][px] = CHARS[...]把片元颜色转成亮度字符,形成可读的 ASCII 渲染结果。

常见误区

误区初学者觉得 HLSL 和 GLSL 只是 `float4` 与 `vec4`、`SV_Position` 与 `gl_Position` 的翻译差异,改改关键字就能互相移植。

正解两者在矩阵乘法约定上存在根本差异:HLSL 默认行主序、左乘行向量,GLSL 默认列主序、右乘列向量,这会导致矩阵数组的内存布局和乘法顺序不同。除此之外,语义系统、精度修饰符、内置函数行为也有细微差别。正确做法是理解各自约定,再用引擎封装或转换工具统一处理。

为何常见:入门教程常展示两边几乎相同的代码片段,让人误以为只是逐字对应,忽略了行主序与列主序这种隐蔽差异。

误区以为顶点着色器的输出值到了片元着色器仍是精确的原始值,直接把顶点位置拿去当片元位置用。

正解顶点着色器的输出(除 SV_Position 外)都会经过光栅化阶段自动插值。也就是说,片元着色器拿到的是插值后的中间值,而不是某个顶点的精确值。若想在片元中还原顶点原始值,需要额外编码或用 flat 修饰符禁用插值。

为何常见:初学者习惯用 CPU 的逐调用思维理解 GPU,忽略了三角形内部有无数片元需要“平滑过渡”这一关键机制。

误区既然 GPU 像 CPU 一样支持 `if/else`,那在着色器里写多层循环嵌套和分支应该没问题。

正解GPU 以 SIMT(单指令多线程)方式执行,同一组线程总是同步运行同一条指令。当一个组内线程走向不同分支时,GPU 会串行执行所有分支路径,再屏蔽无效线程,造成严重的吞吐下降。正确的做法是把分支搬到顶点阶段、用数学运算(如 step、smoothstep)替代分支,或尽量让相邻像素走相同路径。

为何常见:CPU 编程中分支跳转代价很低,这种直觉被错误迁移到 GPU 并行模型上。

误区贴一张纹理只是“读个颜色”,多采样几次无所谓;于是把大量采样写进片元着色器,忽略带宽成本。

正解纹理采样消耗的主要是显存带宽,而不是算力。在高分辨率屏幕上,每个片元多次采样会迅速打满带宽,导致帧率骤降。Mipmap、纹理压缩(如 ASTC/BC7)、减少采样次数都是常见的优化手段。尤其在高动态范围或复杂光照场景中,采样预算必须精打细算。

为何常见:开发者在桌面高端显卡上测试时感觉不到带宽压力,可一到移动端就立刻暴露瓶颈。

误区认为 Shader 输出什么颜色,屏幕就显示什么颜色,忽略深度测试与混合这两个固定阶段。

正解片元着色器的输出只是一个“候选颜色”。它还要经过深度测试决定是否被遮挡,经过混合(Alpha Blend)与颜色缓冲合并。如需半透明效果,必须正确设置混合模式(如 Alpha Blend)与深度写入开关,否则会出现透明排序错乱或物体互相穿透。

为何常见:新手只关注“上色”这一步,把渲染管线理解成单段流水,忽略了深度与混合这两个固定功能阶段的存在。

面试题

Q1. 顶点着色器在渲染管线中主要负责什么?

单选题 · easy

  • A.计算每个像素的最终颜色
  • B.处理顶点的坐标变换← 正确答案
  • C.管理GPU显存分配
  • D.负责纹理过滤选择

答案:B。顶点着色器逐顶点运行,负责把本地坐标变换到裁剪空间,像切配师傅定形状;A 是片元着色器的职责,C 属于驱动或引擎层,D 是纹理采样单元的工作。

Q2. GLSL 中片元着色器的经典输出变量是哪个?

单选题 · mid

  • A.gl_Position
  • B.gl_FragColor← 正确答案
  • C.SV_Target
  • D.gl_VertexID

答案:B。gl_FragColor 是经典 GLSL 片元着色器输出;gl_Position 是顶点输出,SV_Target 是 HLSL 输出,gl_VertexID 是顶点输入编号。

Q3. 光栅化阶段自动完成的数据传递机制是哪种?

单选题 · mid

  • A.全局变量直接共享
  • B.按重心坐标插值← 正确答案
  • C.由CPU回拷贝数据
  • D.串行逐像素处理

答案:B。顶点着色器输出的属性会按片元在三角形内的重心坐标自动插值,像传菜带上酱料自然过渡;全局变量在 GPU 中不跨阶段共享,CPU 回拷贝更是性能大忌。

Q4. 关于 mipmap 多级渐远纹理,下列说法正确的是?

多选题 · mid

  • A.可减轻远处纹理闪烁← 正确答案
  • B.能降低显存带宽压力← 正确答案
  • C.需生成逐级缩小序列← 正确答案
  • D.会自动增强光照效果

答案:ABC。mipmap 预生成 1/2、1/4 等逐级缩小的纹理序列,远处几何体采样高层级,既减少摩尔纹闪烁,也减轻带宽压力;它与光照计算无关,D 错误。

Q5. HLSL 中的 float4 类型代表什么?

单选题 · easy

  • A.四个浮点数组成的向量← 正确答案
  • B.四次浮点运算指令
  • C.一种四通道纹理格式
  • D.第四代着色器模型

答案:A。float4 是一个四分量向量,可存坐标 xyzw 或颜色 rgba,是图形学最常用的数据容器;它不是指令数、纹理格式或版本号。

Q6. 关于 Blinn-Phong 光照模型,正确的是?

多选题 · hard

  • A.使用半程向量简化反射光计算← 正确答案
  • B.半程向量由光源与视线方向相加归一化得到← 正确答案
  • C.高光项与法线和光源的点积成正比
  • D.漫反射项与法线和光源的点积成正比← 正确答案

答案:ABD。Blinn-Phong 用 H = normalize(L + V) 代替反射向量,高光项为 (N·H)^α,漫反射为 N·L 截断到非负。C 错在高光项用的是 N·H 而非 N·L,两者物理含义不同。

Q7. HLSL 中矩阵乘向量通常使用哪个内置函数?

单选题 · mid

  • A.mul 函数完成矩阵乘← 正确答案
  • B.dot 函数只算标量积
  • C.lerp 函数插值两个量
  • D.normalize 函数归一化

答案:A。HLSL 约定矩阵乘法用 mul(matrix, vector);dot 计算点积返回标量,lerp 做线性插值,normalize 处理向量方向,均不用于矩阵乘法。

Q8. SPIR-V 在 Vulkan 生态中的角色是什么?

单选题 · extreme

  • A.HLSL 的专有二进制中间格式
  • B.可移植的着色器中间字节码← 正确答案
  • C.一种纹理压缩格式
  • D.GPU寄存器结构定义

答案:B。SPIR-V 是 Vulkan/OpenGL 使用的可移植着色器中间字节码,HLSL 与 GLSL 都可编译为它,再由驱动翻译成硬件指令;它不是 HLSL 专有格式,也与纹理压缩和寄存器结构无关。

Q9. 纹理采样中 UV 坐标的常规取值范围是?

单选题 · easy

  • A.归一化到0和1之间← 正确答案
  • B.对称映射到负一和正一
  • C.按像素大小取0到255
  • D.由硬件决定任意范围

答案:A。UV 坐标归一化到 0~1,与纹理分辨率无关,便于跨分辨率复用;超出范围的行为由 Repeat/Clamp 等寻址模式决定。

Q10. GPU 着色器中尽量少用复杂分支的原因包括?

多选题 · hard

  • A.并行线程可能全部执行各分支← 正确答案
  • B.分支发散会拉低吞吐效率← 正确答案
  • C.所有GPU完全不支持条件判断
  • D.分支会让寄存器数量翻倍

答案:AB。GPU 按 SIMT 执行,分支发散时硬件会串行执行每个分支路径再屏蔽无关线程,因此吞吐下降;GPU 支持分支但代价高,C 绝对化错误,D 也非主要原因。