3、着色器编程入门:GLSL/HLSL基础,顶点着色器与片段着色器

好,咱们今天聊聊着色器。很多刚入行的朋友觉得这东西玄乎,其实说白了,它就是跑在GPU上的一段小程序。你想想看,CPU上的程序是串行执行的,而GPU上的着色器是并行执行的——成千上万个线程同时跑,处理顶点或者像素。

我个人习惯把着色器理解成「GPU流水线上的工人」。顶点着色器是负责给每个顶点摆姿势的,片段着色器是负责给每个像素涂颜色的。嗯,咱们今天就围绕这两个核心角色展开。

3.1 GLSL与HLSL:两兄弟的异同

先说说语言本身。GLSL(OpenGL Shading Language)和HLSL(High-Level Shader Language)分别是OpenGL和DirectX的着色器语言。我在项目中两个都写过,说实话,核心思想一模一样,就是语法上有些差异。

特性 GLSL HLSL
所属API OpenGL / Vulkan DirectX
变量声明 in / out / uniform float4 / float3x4 / uniform
内置变量 gl_Position, gl_FragColor SV_Position, SV_Target
向量类型 vec2, vec3, vec4 float2, float3, float4
矩阵类型 mat2, mat3, mat4 float2x2, float3x3, float4x4

你看,本质上就是命名习惯不同。GLSL喜欢用vec,HLSL喜欢用float。我个人更偏爱GLSL一些,因为写起来更简洁。但如果你做Windows游戏开发,HLSL是绕不开的。

我的小建议: 初学者先学GLSL,语法更直观,调试工具也多。等GLSL玩熟了,转HLSL也就一两天的事。

3.2 顶点着色器:模型的骨架舞者

顶点着色器,顾名思义,处理的是顶点。每个顶点都会调用一次这个程序。它的核心任务就两个:计算顶点的最终位置,以及传递后续阶段需要的数据

我曾经在做一个地形渲染项目时,发现地形边缘有奇怪的撕裂。查了半天,原来是顶点着色器里模型矩阵和视图矩阵乘反了。嗯,这种错误很隐蔽,但后果很严重。

来看一个最基础的GLSL顶点着色器:

#version 330 core

// 输入:来自CPU的顶点数据
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
layout (location = 2) in vec2 aTexCoord;

// 输出:传递给片段着色器的数据
out vec3 FragPos;
out vec3 Normal;
out vec2 TexCoord;

// 统一变量:变换矩阵
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;

void main()
{
    // 计算世界空间中的位置
    FragPos = vec3(model * vec4(aPos, 1.0));
    
    // 传递法线和纹理坐标
    Normal = mat3(transpose(inverse(model))) * aNormal;
    TexCoord = aTexCoord;
    
    // 关键:设置裁剪空间位置
    gl_Position = projection * view * model * vec4(aPos, 1.0);
}

注意看最后一行——gl_Position是顶点着色器的「出口」。GPU拿到这个值后,会进行透视除法、视口变换,最终把顶点映射到屏幕上。如果你忘了设置它,整个渲染就崩了。

我曾经踩过的坑: 法线变换不能直接用model矩阵,要用逆转置矩阵。否则非均匀缩放时,法线方向会出错,光照效果就全歪了。

3.3 片段着色器:像素的调色师

片段着色器(也叫像素着色器)是每个像素都要执行的程序。它的输入来自顶点着色器的输出(经过光栅化插值),输出就是最终的颜色值。

你想想看,一个1080p的屏幕有200多万个像素,每个像素都要跑一遍片段着色器。所以这里的代码一定要精简,能算一次就别算两次。

来个简单的例子:

#version 330 core

// 输入:来自顶点着色器的插值数据
in vec3 FragPos;
in vec3 Normal;
in vec2 TexCoord;

// 输出:最终颜色
out vec4 FragColor;

// 统一变量
uniform vec3 lightPos;
uniform vec3 lightColor;
uniform vec3 viewPos;
uniform sampler2D ourTexture;

void main()
{
    // 从纹理采样
    vec4 texColor = texture(ourTexture, TexCoord);
    
    // 简单光照计算
    vec3 norm = normalize(Normal);
    vec3 lightDir = normalize(lightPos - FragPos);
    float diff = max(dot(norm, lightDir), 0.0);
    vec3 diffuse = diff * lightColor;
    
    // 环境光
    float ambientStrength = 0.1;
    vec3 ambient = ambientStrength * lightColor;
    
    // 最终颜色
    vec3 result = (ambient + diffuse) * texColor.rgb;
    FragColor = vec4(result, 1.0);
}

这里有个细节:texture()函数在GLSL里是采样纹理用的。HLSL里对应的是Texture2D.Sample()。功能一样,写法不同。

核心要点: 片段着色器里不要做太重的计算。比如循环、分支判断都要谨慎。GPU虽然并行能力强,但每个线程的资源是有限的。我见过有人把物理模拟放在片段着色器里跑,结果帧率直接掉到个位数。

3.4 数据传递:从CPU到GPU的桥梁

着色器本身是死的,它需要CPU给它喂数据。这个数据传递机制,就是uniform和attribute(GLSL)或者constant buffer和input layout(HLSL)。

uniform是全局的,所有顶点/像素共享。attribute是每个顶点独有的。举个例子:

  • uniform:光源位置、相机位置、时间变量、变换矩阵
  • attribute:顶点坐标、法线、纹理坐标、顶点颜色

在CPU端,你需要用glUniform*()系列函数设置uniform,用glVertexAttribPointer()设置attribute。HLSL那边则是通过UpdateSubresource()更新constant buffer。

我记得有一次,项目里纹理一直显示不对,查了两天发现是uniform名字拼写错了——GLSL里叫ourTexture,CPU端传的是ourTexture,但中间有个空格没去掉。嗯,这种低级错误最折磨人。

3.5 实战小技巧:调试着色器

着色器不像CPU程序那样可以打断点。怎么调试?我分享几个土办法:

  1. 输出颜色调试:把中间计算结果直接输出为颜色。比如你想看法线方向,就把normalize(Normal)直接赋给FragColor。看到红绿蓝分布,基本就能判断数据对不对。
  2. 使用gl_FragCoord:这个内置变量记录了当前像素的屏幕坐标。可以用来画网格、画边界,辅助定位问题。
  3. RenderDoc工具:这是我最推荐的。它可以捕获一帧的渲染数据,查看每个着色器的输入输出,甚至能单步调试。Windows上免费,强烈建议装一个。
我的习惯: 写新着色器时,先让它输出纯色(比如红色)。确认顶点位置正确后,再加纹理。最后加光照。一步一步来,出问题能立刻定位。

好了,这一章的内容就这些。着色器编程其实不难,关键是理解流水线的数据流向。顶点着色器处理顶点,片段着色器处理像素,中间的光栅化阶段自动帮你做插值。把这个模型刻在脑子里,后面学高级效果就轻松多了。

下一章咱们聊聊纹理映射和采样,那是让画面变丰富的关键。到时候我会分享一个我在移动端优化纹理带宽的实战案例,挺有意思的。