Skip to content

着色(着色频率、图形管线、纹理映射)

上一章讨论了局部光照模型:给定表面法线、光照方向、观察方向和材质参数,如何计算一个表面点的颜色。接下来还要回答另一个很实际的问题:这个颜色应该在哪里计算?

同一个 Blinn-Phong 公式,可以在每个三角形上算一次,也可以在每个顶点上算一次,还可以在每个片元上重新计算。计算位置不同,画面质量、性能成本和可表达的细节都会不同。这就是本章的第一条主线:着色频率 (Shading Frequency)

本章的第二条主线是把着色放回完整的图形管线中:顶点数据如何进入 GPU,经过顶点着色、图元装配、光栅化、片元着色,最后写入帧缓冲。最后,我们会引入纹理映射:让材质不再只是一个固定颜色,而是能从图像中读取空间变化的细节。


着色频率指的是“每隔多密集的几何或采样单位计算一次颜色”。在实时渲染中,常见的三种基础频率是:

  • Flat Shading:每个三角形只计算一次颜色,整个三角形使用同一个结果。
  • Gouraud Shading:每个顶点计算一次颜色,三角形内部通过插值得到片元颜色。
  • Phong Shading:每个片元插值法线,再在片元级别重新计算光照。

这里的 Phong Shading 容易和上一章的 Phong 镜面反射模型混淆。前者描述“光照在哪个频率上计算”,后者描述“镜面高光怎么算”。也就是说,可以使用 Blinn-Phong 光照模型,同时采用 Phong Shading 的片元级计算方式。


Flat Shading 会为整个三角形使用同一个法线和同一个光照结果。这个法线通常来自三角形面法线:

n^face=normalize((P1P0)×(P2P0))\hat{n}_{face} = normalize((P_1 - P_0) \times (P_2 - P_0))

然后使用上一章的光照模型计算颜色:

L=kaIa+kdIlmax(n^facel^,0)+ksIlmax(n^faceh^,0)pL = k_a I_a + k_d I_l \max(\hat{n}_{face} \cdot \hat{l}, 0) + k_s I_l \max(\hat{n}_{face} \cdot \hat{h}, 0)^p

由于整个三角形颜色相同,Flat Shading 的优点是成本低、形体结构清晰;缺点是面片边界非常明显。对于低模风格、调试法线、观察三角网格结构时,它反而很有用。

但如果目标是表现光滑球体、角色皮肤、抛光陶瓷这类连续表面,Flat Shading 会让模型显得像由许多硬折面拼起来。


Gouraud Shading 会先在三角形的三个顶点分别计算光照:

C0=shade(P0,N0),C1=shade(P1,N1),C2=shade(P2,N2)C_0 = shade(P_0, N_0), \quad C_1 = shade(P_1, N_1), \quad C_2 = shade(P_2, N_2)

光栅化之后,片元颜色通过重心坐标插值得到:

C=αC0+βC1+γC2C = \alpha C_0 + \beta C_1 + \gamma C_2

其中 α+β+γ=1\alpha + \beta + \gamma = 1。这种方法比 Flat Shading 平滑得多,因为颜色会在三角形内部渐变。早期硬件和固定管线时代,Gouraud Shading 非常常见,因为它能用较少的计算获得还不错的明暗过渡。

不过它有一个典型问题:高光可能丢失。如果一个小而亮的镜面高光落在三角形内部,但三个顶点都没有正好采到它,那么顶点颜色里就没有这个高光,插值后也不会凭空出现。


Phong Shading 会在顶点之间插值法线、位置等数据,然后在每个片元上重新执行光照公式:

N=normalize(αN0+βN1+γN2)N = normalize(\alpha N_0 + \beta N_1 + \gamma N_2)

接着使用插值得到的法线 NN、片元位置 PP、光源方向 LL、观察方向 VV 计算最终颜色。这种做法能让明暗变化更加连续,也能稳定表现小范围高光。

需要注意的是,法线插值后必须重新归一化。因为三个单位法线线性插值后,长度通常不再等于 11;如果直接拿去点乘,光照强度会被错误放大或缩小。

三个圆使用同一组光照参数。区别只在于计算发生的位置:Flat 每个三角形一次,Gouraud 每个顶点一次再插值,Phong 每个像素重新计算法线与光照。

在上面的组件中,Flat 的三角形边界最明显;Gouraud 的亮度过渡变得平滑,但高光会被顶点采样“平均掉”;Phong 则在每个像素重新计算法线与光照,因此高光位置和形状更稳定。


三种着色频率没有绝对优劣,选择取决于画面目标和性能预算。

方式计算位置优点局限
Flat Shading每个三角形成本低,面结构清楚面片感明显,不适合光滑表面
Gouraud Shading每个顶点比 Flat 平滑,成本较低小高光和细节容易丢失
Phong Shading每个片元明暗和高光更准确片元计算成本更高

在现代实时渲染中,绝大多数材质会在片元级别或更复杂的屏幕采样级别执行主要着色。但 Flat 和 Gouraud 仍然有价值:Flat 常用于风格化表现和调试,Gouraud 可以用于非常轻量的移动场景、粒子或远处低细节对象。


理解着色频率之后,可以把它放回 GPU 图形管线中观察。一个简化的实时渲染管线可以写成:

  1. 输入顶点数据:CPU 将顶点位置、法线、颜色、UV、索引等数据提交给 GPU。
  2. 顶点着色器:每个顶点独立执行,通常完成坐标变换,并输出后续阶段需要插值的数据。
  3. 图元装配:GPU 按索引或顶点顺序把顶点组成点、线或三角形。
  4. 裁剪与屏幕映射:丢弃视锥体外的部分,并把 NDC 坐标映射到屏幕坐标。
  5. 光栅化:判断三角形覆盖哪些像素采样点,生成候选片元,并为每个片元插值属性。
  6. 片元着色器:每个片元计算颜色,常见操作包括光照、纹理采样、法线贴图、透明度判断等。
  7. 输出合并:执行深度测试、模板测试、混合等操作,把结果写入颜色缓冲和深度缓冲。

GPU 图形管线中的数据流

这张流程图可以帮助区分两件事:顶点着色器处理的是顶点频率的数据,光栅化器负责把顶点输出插值到片元频率,片元着色器再用这些插值结果计算最终颜色。

前面几章其实已经分别看过这些阶段的一部分:MVP 变换发生在顶点处理阶段,光栅化负责把三角形离散成片元,深度测试决定候选片元能否覆盖已有结果,而本章的着色讨论的是“颜色如何在这些阶段中产生”。


顶点着色器 (Vertex Shader) 的最核心任务是把模型顶点变换到裁剪空间:

gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0);

除了位置,顶点着色器还会准备片元着色器需要的数据。例如:

worldPosition = vec3(modelMatrix * vec4(position, 1.0));
worldNormal = normalize(normalMatrix * normal);
texCoord = uv;

这些输出不会只停留在顶点上。光栅化生成片元时,GPU 会自动对它们进行插值。也就是说,片元着色器拿到的 worldPositionworldNormaltexCoord,通常已经是当前片元对应的插值结果。


片元着色器 (Fragment Shader / Pixel Shader) 的目标是为当前片元输出颜色。它经常会做这些事情:

  • 归一化插值得到的法线。
  • 根据片元位置计算光照方向和观察方向。
  • 从纹理中读取基础颜色、粗糙度、法线等材质数据。
  • 计算漫反射、高光、透明度或自发光。
  • 在需要时丢弃片元,例如透明裁剪材质中的 discard

一个非常简化的片元着色器可以写成:

vec3 N = normalize(worldNormal);
vec3 L = normalize(lightPosition - worldPosition);
vec3 V = normalize(cameraPosition - worldPosition);
vec3 H = normalize(L + V);
vec3 baseColor = texture(baseColorMap, texCoord).rgb;
float diffuse = max(dot(N, L), 0.0);
float specular = pow(max(dot(N, H), 0.0), shininess);
outColor = vec4(baseColor * (ambient + diffuse) + specularColor * specular, 1.0);

这里的 texture(baseColorMap, texCoord) 就是本章后半部分要引入的纹理采样。


如果一个物体只有固定的 baseColor,那么它的表面只能呈现单一材质颜色。即使光照模型再完整,也很难表现木纹、砖缝、皮肤斑点、布料花纹、金属划痕这些空间细节。

纹理 (Texture) 本质上是一份可以在着色器中查询的数据。最常见的是二维图像:给定一个二维坐标,读取对应位置的颜色。纹理让材质参数可以随表面位置变化:

  • 基础颜色纹理控制物体不同位置的颜色。
  • 法线纹理让平面也能表现细小凹凸。
  • 粗糙度纹理控制高光在不同位置的扩散程度。
  • 金属度、透明度、自发光等也可以由纹理提供。

因此,纹理不是“贴图装饰”这么简单。更准确地说,纹理是一种把二维或三维空间坐标映射到材质数据的函数。

value=texture(sampler,uv)value = texture(sampler, uv)

为了从二维纹理中读取数据,模型表面需要携带一组二维坐标,通常记作 (u,v)(u, v)。这就是 UV 坐标

可以把 UV 理解为“模型表面到图片平面的地址”。三角形的每个顶点保存一个 UV:

(P0,UV0),(P1,UV1),(P2,UV2)(P_0, UV_0), \quad (P_1, UV_1), \quad (P_2, UV_2)

光栅化时,片元的 UV 会通过重心坐标插值得到:

UV=αUV0+βUV1+γUV2UV = \alpha UV_0 + \beta UV_1 + \gamma UV_2

片元着色器再用这个 UV 去纹理中采样颜色:

vec3 baseColor = texture(baseColorMap, uv).rgb;

于是,一个三角形不再只能拥有一个颜色,而是可以把图片上的不同位置映射到三角形内部的不同片元上。整个模型由许多带 UV 的三角形组成,就能把一张或多张纹理图像铺到复杂表面上。


在三维透视投影中,直接在屏幕空间线性插值 UV 会产生错误:远处和近处的纹理拉伸关系会不自然。原因是透视投影不是线性变换,深度变化会影响屏幕上的插值比例。

现代 GPU 会对顶点输出属性执行透视正确插值 (Perspective-Correct Interpolation)。直观地说,它不是简单插值 uuvv,而是先考虑裁剪空间中的 ww

u=αu0w0+βu1w1+γu2w2α1w0+β1w1+γ1w2u = \frac{\alpha \frac{u_0}{w_0} + \beta \frac{u_1}{w_1} + \gamma \frac{u_2}{w_2}}{\alpha \frac{1}{w_0} + \beta \frac{1}{w_1} + \gamma \frac{1}{w_2}}

vv 也同理。这样可以保证纹理在透视下看起来像真正贴在三维表面上,而不是像屏幕上的平面图案被错误拉伸。


纹理映射听起来像“用 UV 查一张图片”,但实际采样还会遇到几个问题。

首先,UV 可能落在像素之间。此时需要决定读取最近的纹素,还是在周围纹素之间插值。这对应最近邻过滤和双线性过滤。

其次,一个屏幕像素可能覆盖纹理中的大片区域。远处地面、墙面、细密网格最容易出现闪烁和摩尔纹。为了解决这个问题,渲染器通常会使用 Mipmap:为同一张纹理预先生成多级低分辨率版本,并根据屏幕覆盖范围选择合适层级。

最后,UV 可能超出 [0,1][0, 1] 范围。此时可以选择重复、镜像重复或夹取到边缘:

  • Repeat:纹理像瓷砖一样重复铺开。
  • Mirrored Repeat:每隔一段镜像翻转一次,减少接缝感。
  • Clamp to Edge:超出范围后使用边缘颜色。

这些采样规则会在后续高级纹理映射章节中继续展开。本章先建立最核心的理解:纹理采样是片元着色器读取材质数据的过程,而 UV 是读取地址。


本章把上一章的光照模型放回了图形管线,并引入了纹理映射的入口。请重点记住以下结论:

  1. 着色频率描述颜色或光照计算发生的密度:Flat 每个三角形一次,Gouraud 每个顶点一次,Phong 每个片元一次。
  2. Gouraud Shading 插值的是顶点颜色,因此小范围高光可能丢失;Phong Shading 插值法线并在片元级重新计算光照,通常能得到更稳定的明暗和高光。
  3. 顶点着色器负责逐顶点处理,常见任务是坐标变换与输出待插值属性;片元着色器负责逐片元计算颜色,常见任务是光照和纹理采样。
  4. 纹理是一种可查询的数据源,不只存颜色,也可以存法线、粗糙度、金属度、透明度等材质参数。
  5. UV 坐标是模型表面访问二维纹理的地址;在透视投影下,UV 等属性需要透视正确插值,才能看起来真正贴在三维表面上。

下一章会继续深入纹理映射,讨论插值细节、高级纹理采样、法线贴图与纹理如何参与更复杂的材质表达。