着色器 Shader 编程:从顶点到片元

FreeGuideOnline 最新 2026-06-17

着色器 Shader 编程:从顶点到片元

在计算机图形学的世界里,着色器(Shader)是赋予三维模型灵魂的画笔。它运行在图形处理器(GPU)上,决定着屏幕上每一个像素的颜色、光照与质感。本教程将带领你从零开始,理解着色器编程的核心概念,聚焦于图形渲染管线中最关键的两个阶段:顶点着色器片元着色器。无需复杂的前置知识,只需一颗对图形渲染充满好奇的心。

什么是着色器?

着色器是一段在 GPU 上并行执行的小程序。它不是用传统的 CPU 语言编写,而是使用专为图形设计的着色语言,例如 GLSL (OpenGL Shading Language)、HLSL (High-Level Shader Language) 或 MSL (Metal Shader Language)。本教程以应用最广泛的 GLSL 为例进行讲解。

着色器不是独立存在的,它运行在图形渲染管线之中。渲染管线可以看作一条流水线,三维场景的数据从一端流入,经过一系列处理,最终在屏幕上输出二维图像的像素。着色器就是这条流水线上可以自编程的处理阶段。

图形渲染管线简述

理解数据流向是学习着色器的关键。简化后的现代渲染管线主要步骤如下:

  1. 顶点数据传递
    CPU 将模型的顶点数据(位置、法线、纹理坐标、颜色等)通过顶点缓冲对象(VBO)发送到 GPU。

  2. 顶点着色器 (Vertex Shader)
    每个顶点都会调用一次该程序。主要负责将顶点的三维坐标变换到屏幕空间,并将需要插值的数据传递到下一阶段。

  3. 图元装配与光栅化
    顶点着色器处理后的顶点被组装成三角形等基本图元。光栅化过程将三角形所覆盖的屏幕区域分解为一个个离散的片元(片段,Fragment),可以近似理解为“候选像素”。

  4. 片元着色器 (Fragment Shader)
    为光栅化生成的每一个片元调用一次。计算该片元的最终颜色和透明度,通常在此阶段进行纹理采样和复杂的光照计算。

  5. 测试与混合
    片元经历深度测试、模板测试等筛选后,与帧缓冲中已有的颜色进行混合,最终写入像素。

本教程将聚焦于第2步和第4步,它们是着色器编程的核心。

顶点着色器:空间的魔术师

顶点着色器发挥作用的舞台是三维物体模型的每一个顶点。一个顶点可能包含多种属性:位置、颜色、法线方向、纹理坐标等。顶点着色器的首要任务就是处理这些属性,输出转换后的数据。

必须完成的职责

顶点着色器对每个传入的顶点只做一次操作,但有一个硬性规定:必须将顶点在裁剪空间中的坐标写入内置变量 gl_Position。如果缺少这一步,管线将无法正确进行下一步的图元装配。

其他所有的操作——例如将法线变换到世界空间、将纹理坐标直接传递下去——都是可选的辅助任务,服务于片元着色器的计算。

空间变换之旅

为了让一个三维物体最终显示在二维屏幕上,顶点位置需要经历一系列坐标空间的变换。通常借助矩阵乘法来实现:

  • 模型变换 (Model Matrix):将顶点从本地的模型空间坐标变换到世界空间,使其置身于整个场景之中。
  • 视图变换 (View Matrix):将世界空间坐标变换到视图空间(也称相机空间),使得坐标原点变为相机位置,摄像机朝向变为 Z 轴。
  • 投影变换 (Projection Matrix):将视图空间坐标变换到裁剪空间。这是一个透视的立方体空间,超出范围的部分将被裁剪掉。

在典型的顶点着色器中,你会看到类似这样的核心代码:

uniform mat4 modelMatrix;
uniform mat4 viewMatrix;
uniform mat4 projectionMatrix;

in vec3 vertexPosition; // 从 VBO 传入的顶点位置属性

void main() {
    // 将顶点位置依次变换到裁剪空间
    gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(vertexPosition, 1.0);
}

实践中,常可将矩阵预先合并为一个 modelViewProjection 矩阵传入,以节省 GPU 的重复计算。

传递数据给片元着色器

顶点着色器和片元着色器之间的数据传递依靠 varying 变量(在 GLSL 中,通过 out 关键字在顶点着色器中声明,并在片元着色器中用对应的 in 关键字接收)。这些输出值会在光栅化阶段,根据三角形三个顶点的值和片元重心坐标自动进行透视校正插值

例如,我们要让一个彩色三角形平滑渐变,顶点着色器可这样写:

// 顶点着色器
in vec3 vertexColor; // 每个顶点携带的颜色
out vec3 fragColor;  // 输出给片元着色器的插值颜色

void main() {
    gl_Position = ...; // 位置计算
    fragColor = vertexColor;
}

对应的片元着色器只需接收插值后的颜色:

// 片元着色器
in vec3 fragColor;
out vec4 finalColor;

void main() {
    finalColor = vec4(fragColor, 1.0);
}

此时,三角形内部的颜色会自动在三个顶点的颜色之间平滑过渡,无需额外代码。

片元着色器:像素的画家

如果顶点着色器是在搭建模型的骨架,那么片元着色器就是在为模型的表面填充血肉。它决定着最终呈现在屏幕上的每个像素的颜色。

光照与材质

现实世界中的光照极其复杂,但在实时渲染中,我们通常使用简化的局部光照模型。最常见的是 Phong 光照模型 或其改进的 Blinn-Phong 模型,它将光照分解为三个基本成分:

  • 环境光 (Ambient):假设环境中存在无处不在的间接光照,为物体提供一个基色,防止阴影区域全黑。
  • 漫反射光 (Diffuse):表示光线照射到粗糙表面时向四面八方均匀反射的现象。其强度取决于表面法线与光线方向之间的夹角,遵循兰伯特余弦定理
  • 镜面反射光 (Specular):模拟光滑表面上的高光亮点。其强度取决于观察方向与光线反射方向的重合度。

在 GLSL 中实现 Blinn-Phong 光照的片元着色器代码框架如下(所有向量均在视图空间计算,且已归一化):

// 片元着色器(片段)
in vec3 fragNormal;   // 插值后的表面法线
in vec3 fragPos;      // 插值后的片元位置

uniform vec3 lightPos;     // 光源位置(视图空间)
uniform vec3 viewPos;      // 观察者位置(通常为原点 0,0,0)
uniform vec3 lightColor;
uniform vec3 objectColor;

out vec4 finalColor;

void main() {
    // 环境光强度
    float ambientStrength = 0.1;
    vec3 ambient = ambientStrength * lightColor;

    // 漫反射
    vec3 norm = normalize(fragNormal);
    vec3 lightDir = normalize(lightPos - fragPos);
    float diff = max(dot(norm, lightDir), 0.0);
    vec3 diffuse = diff * lightColor;

    // 镜面反射 (Blinn-Phong: 使用半程向量)
    float specularStrength = 0.5;
    vec3 viewDir = normalize(viewPos - fragPos);
    vec3 halfwayDir = normalize(lightDir + viewDir);
    float spec = pow(max(dot(norm, halfwayDir), 0.0), 32); // 32 是反光度参数
    vec3 specular = specularStrength * spec * lightColor;

    // 最终颜色合成
    vec3 result = (ambient + diffuse + specular) * objectColor;
    finalColor = vec4(result, 1.0);
}

纹理映射的艺术

仅靠单一的颜色过于单调,纹理(Texture)可以为模型表面填入丰富的图像细节。纹理坐标通常作为顶点属性,在顶点着色器中接收并传递给片元着色器进行插值。

在片元着色器中,使用采样器(Sampler)和内置函数 texture() 来获取纹理对应位置的纹素(texel)颜色。

顶点着色器传递纹理坐标:

in vec2 texCoord;       // 从VBO获取
out vec2 fragTexCoord;  // 传递给片元

void main() {
    gl_Position = ...;
    fragTexCoord = texCoord;
}

片元着色器进行纹理采样并结合光照:

in vec2 fragTexCoord;
uniform sampler2D mainTexture;

void main() {
    vec4 texColor = texture(mainTexture, fragTexCoord);
    // 可以将上一步计算的光照结果与 texColor.rgb 相乘
    // 示例:直接使用纹理颜色作为最终输出
    finalColor = texColor;
}

纹理不仅仅用来贴颜色。现代渲染中,法线贴图、高度贴图、金属/粗糙度贴图等都是在片元着色器中通过纹理采样来改变表面细节,创造远超几何细节的视觉效果。

编写你的第一个完整着色器对

为了让概念落地,我们来看一个最简化的可运行着色器对。它渲染一个带有纹理和基本漫反射光照的模型。

顶点着色器 (vertex.glsl):

#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
layout (location = 2) in vec2 aTexCoord;

uniform mat4 modelViewProjection;
uniform mat4 model;

out vec3 Normal;
out vec3 FragPos;
out vec2 TexCoord;

void main() {
    FragPos = vec3(model * vec4(aPos, 1.0));
    Normal = mat3(transpose(inverse(model))) * aNormal; // 处理非等比变换的法线
    TexCoord = aTexCoord;
    gl_Position = modelViewProjection * vec4(aPos, 1.0);
}

片元着色器 (fragment.glsl):

#version 330 core
in vec3 Normal;
in vec3 FragPos;
in vec2 TexCoord;

uniform sampler2D ourTexture;
uniform vec3 lightPos;
uniform vec3 viewPos;
uniform vec3 lightColor;

out vec4 FragColor;

void main() {
    // 环境光
    float ambientStrength = 0.2;
    vec3 ambient = ambientStrength * lightColor;

    // 漫反射
    vec3 norm = normalize(Normal);
    vec3 lightDir = normalize(lightPos - FragPos);
    float diff = max(dot(norm, lightDir), 0.0);
    vec3 diffuse = diff * lightColor;

    // 纹理采样
    vec3 objectColor = texture(ourTexture, TexCoord).rgb;

    // 组合
    vec3 result = (ambient + diffuse) * objectColor;
    FragColor = vec4(result, 1.0);
}

进阶学习与调试建议

着色器编程的难度在于其黑盒特性和并行执行方式。当屏幕一片漆黑或满屏紫色(表示错误)时,不要气馁。以下是一些实用建议:

  • 分步渲染:为了定位问题,可以先在片元着色器中直接输出固定颜色(如 FragColor = vec4(1.0, 0.0, 1.0, 1.0);)来验证顶点着色器是否正常通过了图元。
  • 可视化中间数据:将法线、纹理坐标作为颜色输出,直观检查数据传递是否正确。例如 FragColor = vec4(norm * 0.5 + 0.5, 1.0); 将法线映射到 0-1 范围来显示。
  • 善用工具:RenderDoc、NVIDIA Nsight Graphics、Xcode GPU Frame Debugger 等图形调试器允许你捕获一帧,查看每个阶段的输入输出,是深入了解管线的必备利器。
  • 持续实践:从 ShaderToy、GLSL Sandbox 等在线平台开始,在浏览器中快速试验片元着色器的创意效果。

从顶点到片元,你刚刚穿越了实时渲染的核心地带。着色器的世界广阔而深邃,每一次对光影的调整,每一次对纹理的合成,都是与 GPU 的精妙协作。掌握这些基础后,你便拥有了探索动态阴影、延迟渲染、屏幕后处理等高级技术的能力。现在,打开你的代码编辑器,创建第一个着色器文件,开始为虚拟世界注入色彩与生命吧。