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是绕不开的。
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拿到这个值后,会进行透视除法、视口变换,最终把顶点映射到屏幕上。如果你忘了设置它,整个渲染就崩了。
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()。功能一样,写法不同。
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程序那样可以打断点。怎么调试?我分享几个土办法:
- 输出颜色调试:把中间计算结果直接输出为颜色。比如你想看法线方向,就把
normalize(Normal)直接赋给FragColor。看到红绿蓝分布,基本就能判断数据对不对。 - 使用gl_FragCoord:这个内置变量记录了当前像素的屏幕坐标。可以用来画网格、画边界,辅助定位问题。
- RenderDoc工具:这是我最推荐的。它可以捕获一帧的渲染数据,查看每个着色器的输入输出,甚至能单步调试。Windows上免费,强烈建议装一个。
好了,这一章的内容就这些。着色器编程其实不难,关键是理解流水线的数据流向。顶点着色器处理顶点,片段着色器处理像素,中间的光栅化阶段自动帮你做插值。把这个模型刻在脑子里,后面学高级效果就轻松多了。
下一章咱们聊聊纹理映射和采样,那是让画面变丰富的关键。到时候我会分享一个我在移动端优化纹理带宽的实战案例,挺有意思的。