原作者:Meetric
本中文教程具有一定内容补充,但不会改变总体的框架和内容。
SCG-2026 课程主页:起源引擎图形学基础 2026
你好呀!欢迎来看我的 GMod 着色器教程!
本指南汇集了我过去几年制作 GMod 着色器所学到的一切。我做这个教程的目的是为初学者讲解着色器编程,尤其是起源引擎和 GMod 的着色器编程。
本指南假定你已掌握编程基础,例如基本语法、变量和条件判断等。
如果你不懂编程,建议先做几个 GLua 项目再回来看本指南,因为它比较有技术性而且有一定复杂度。
Tip
译者建议可以看看 CS50x 的前面几节课,主题分别是 C, Arrays, Algorithms, Memory 和 Data Structures。
Note
本指南并不涵盖关于 GMod 着色器和 HLSL 的所有内容,但我会尽力包含所有相关的重要部分。如果你有更新更好的创作,欢迎分享!欢迎创建 issue 或提交 pull request 并添加你自己的着色器示例。
Note
为了使本指南的例子正常运行,请在 gm_construct 图中加载游戏!!!
- GMod 着色器综合教程
- 前言
- 目录
- 什么是着色器?
- 着色器管线
- screenspace_general
- 入门
- [示例 1] — 你的第一个着色器
- [示例 2] — 像素着色器
- [示例 3] — 像素着色器常量
- [示例 4] - GPU 架构
- [示例 5] - 顶点着色器
- [示例 6] - 顶点着色器常量
- [示例 7] - 渲染目标 Render Target
- [示例 8] - 多重渲染目标 (MRT)
- [示例 9] - 深度缓冲
- [示例 10] - 模型上的着色器
- [示例 11] - 实例化网格 (IMeshes)
- [示例 12] - 点精灵
- [示例 13] - 3D 材质
- 完成啦!
你可能会问自己,着色器是什么?我为什么要关心这个问题?好吧,那你有没有想过游戏是如何展示出如此复杂的几何体和特效的呢?在你玩的任何游戏中,你的 GPU 上会一直运行一些代码来决定屏幕上每个像素的颜色。对,你没听错——每一个像素都有相应的代码在实时决定它的颜色,这正是我们今天要写的东西。
下面是一些很酷的着色器示例:
GMod 草地着色器(作者:Meetric):

GMod 视差遮蔽映射(Evgeny Akabenko):

GMod 体积云(Evgeny Akabenko):

Half Life: Alyx 液体着色器(Valve):

所有图形 API 都包含所谓的图形流水线(Graphics Pipeline),本质上是一组通用的、固定的流水线式阶段,它将 3D 场景数据转换为 2D 屏幕上显示的内容。
图形流水线示意图:

(图片来自 Vulkan Tutorial)
本指南不会深入到数学上的细节。目前你只需要知道:
- 你看到的每个模型都是由三角形和顶点组成的(在 Source 中可在控制台输入 mat_wireframe 将顶点网格可视化)。
- 每个顶点都会被发送到 GPU,在顶点着色器中完成将其变换到屏幕空间的操作。
- 顶点处理完成后会执行像素着色器(像素/片元着色器),像素着色器负责对光栅化后的像素进行填色,这一步由你写的着色器代码控制。
如果你之前已经了解过 .vmt 的工作方式,可以直接跳过本节。
Source 引擎使用一种名为 vmt 的自定义材质文件扩展,用来控制材质的各类参数(flag)。在本仓库中我们使用了名为 screenspace_general 的着色器,它允许我们指定自定义的顶点和像素着色器。
Note
一个材质可以包含很多 flag,但只能指定一个着色器。
尽管名字里有 "screenspace"(屏幕空间),但是 screenspace_general 在 CS:S 2015 引擎分支中并非真正的屏幕空间着色器,它更像一个用于测试的通用着色器。
更多 .vmt 相关信息:
https://developer.valvesoftware.com/wiki/VMT
更多 screenspace_general 相关信息:
https://developer.valvesoftware.com/wiki/Screenspace_General
screenspace_general 源码 (来自 CS:S 2015):
https://github.com/lua9520/source-engine-2018-cstrike15_src/blob/master/materialsystem/stdshaders/screenspace_general.cpp
首先,将本仓库克隆到 GarrysMod/garrysmod/addons 文件夹下。仓库内包含 13 个示例,可以在游戏内运行并跟随练习,每个示例讲解一个着色器具体主题。希望通过阅读本指南并在游戏中运行和修改相应的着色器,你能更好地理解其工作原理。
加载完成后,在控制台输入 shader_example 1 来查看第一个示例(会在屏幕上显示一个红色矩形)。虽然看上去很简单,但这是一切的开始。
要学习制作着色器,首先得知道如何编译它。本指南选择使用 ShaderCompile(支持 64 位),因为它仅仅是一个单程序,比普通的 Source 着色器编译环境的繁杂配置简单许多。
看完 shader_example 1 的效果后,我们先把目光移出 GMod 游戏,进入到 gmod_shader_guide/shaders 目录。所有着色器源码都在后缀为 .hlsl 的文件里面,目录中还有若干 .h 头文件,我们暂时不管它们,后面会用到。
着色器文件名格式很重要,可分为以下 5 部分:
example1— 着色器名称;_— 必须的分隔符;ps— 表示像素着色器(pixel shader),也可以是vs(顶点着色器);2x— 着色器版本,本指南使用兼容性更广的2x;.hlsl— 源文件扩展名,HLSL 是高级着色器语言(High-Level Shader Language)的缩写;
按照要求命名后,把 example1_ps2x.hlsl 拖到 build_single_shader.bat 上即可编译,生成的着色器中间代码会放入 fxc 目录,GMod 会从这里加载着色器。
编译后的着色器为 .vcs(Valve Compiled Shader),它是一种中间代码,显卡驱动会实时地把它翻译成 GPU 能读懂的机器指令。
重新进入游戏并在控制台键入 shader_example 1,如果能在屏幕左上角看到一个亮绿色的矩形,就说明编译成功。
若看到的还是红色矩形,说明旧着色器没被覆盖,请检查是否遗漏步骤或查看编译错误。
Note
修改着色器但不改 .vmt 时需要重启游戏来重新加载。
在你真正开始学习如何编辑 .vmt 之前,建议直接重启游戏,这是最简单的方法。添加启动选项 -noworkshop 有时会大大缩短加载时间。
Tip
当你开始写自己的着色器时,请尽量给它们取一个有辨识度的、不普通的名称,否则可能会出现名称冲突。
Note
Shader Model 30(SM 3.0) 在 Linux 系统上未经充分测试,某些功能可能无法按预期工作。
如果你在 Linux 上发现着色器相关问题,请提交 pull request 或在本仓库开 issue 以便记录和改进。
像素着色器(Pixel / Fragment shader)是逐像素运行的代码。在[示例 1]中我们学会了编译基础着色器,现在我们尝试修改一个已有的像素着色器。
先试着在控制台输入 shader_example 2 看看效果:
打开 gmod_shader_guide/shaders/example2_ps2x.hlsl,源码带有大量注释,你需要通过阅读它们来学习 HLSL 语法(类似 C/C++)。修改完成后别忘了重新编译!
如果想在游戏中热加载修改,先将编译输出在 gmod_shader_guide/shaders/fxc 的 IL 文件 example2_ps20b.vcs 重命名为 example2a_ps20b.vcs,然后修改对应材质文件(gmod_shader_guide/materials/gmod_shader_guide/example2.vmt)中的 $pixshader 所指的像素着色器文件为新的 $pixshader "example2a_ps20b",保存后查看效果。
Note
使用热加载时,同名着色器不能重复使用,需要给它更换新名称。比如我们刚刚修改的像素着色器名字叫 example2a_ps20b ,如果修改后再次热加载一遍,就要把名字改成 example2b_ps20b 或者类似的与上一次不同的名称。
Note
在保存 .vmt 着色器参数之前,请确保对应的 .vcs 着色器文件是存在的。
Tip
.vmt 中的 $ignorez 1 对于 screenspace 类着色器是必须的,否则可能无法正常渲染。
Tip
.vmt 中的 $vertextransform 1 确保坐标不在屏幕空间下,这在使用 render. 函数时很有用,因为那一系列函数都是世界空间下的。
到现在你应该对 HLSL 语法有了基本的了解,本节展示一个略复杂的像素着色器。在控制台输入 shader_example 3 查看效果(如上图)。
此着色器从纹理采样,并将游戏的全局时间 CurTime 作为输入传入以实现动画效果。每个 .vmt 文件代表一个材质及其着色器,我们通过在 .vmt 中设置全局数值来把数据传给着色器。
本示例使用 $c0_x 传入一个浮点数作为 CurTime,相关实现可在 gmod_shader_guide/lua/autorun/client/shader_examples.lua 的 example3 函数中看到。
注意:screenspace_general 可用的全局常量数量有限(参见本链接),但一般情况下已经够了。本示例在 .vmt 中还定义了 $c0_y(尽管 HLSL 源码中未用到),这是为了演示你可以在 .vmt 中放入额外参数并在着色器内以不同方式利用它们。
现在打开 example3.vmt 查看其参数,尝试修改 basetexture(例如 hunter/myplastic 或 phoenix_storms/wood)来观察变化;然后打开 example3_ps2x.hlsl 阅读其源码,尝试用未使用的 $c0_y 做些实验,看看能做出什么效果。
Tip
Source Engine 还有一些未记录的像素着色器常量,它们可以在 这里 找到。
其中大多数可能没什么用,但有些时候会派上用场。
我们已经了解像素着色器的基本语法和总体控制,是时候开始研究 GPU 架构与控制流了。
你需要把 GPU 当作一台完全不同的计算机来思考——事实上它确实是:GPU 有自己的处理器、显存、主板、固件,甚至独立散热。
与 CPU 相比,GPU 的工作方式截然不同,所以你需要以不同于常规的思路来考虑问题。
GPU 架构专为特定指令集设计,以实现更快的图形处理速度。GPU 在浮点运算方面表现极其出色。事实上,主流 GPU(2025 年, 以 RTX 5060 为例)每秒可执行 19 万亿次(即 19,000,000,000,000 次)浮点运算,这个速度远超顶级 CPU。
然而遗憾的是,这几乎是 GPU 的唯一优势。GPU 仅擅长高速浮点(及整数)运算,意思是它们速度惊人但功能受限(可理解为更笨的 CPU)。我们使用的 Shader Model 20b 标准甚至不支持双精度浮点运算。即便你设法实现了双精度运算,我仍建议避免使用——这类运算极其缓慢,且完全违背了 GPU 架构的设计初衷。
接下来我们谈谈控制流。在 CPU 执行的程序(如一般的 Lua、C++ 程序)中,if 语句并不算什么大问题。通过条件判断来控制代码执行通常对性能影响不大。
然而在 GPU 上,情况却截然相反。你应该尽可能避免使用 if 语句。具体原因有些复杂,但我会尽力解释。
在 GPU 中,称为“warp”的线程组(在 AMD GPU 上叫做“wavefront”)会在屏幕区域内启动异步计算。由于 GPU 架构特性(单指令多线程, SIMT),当出现分支时,这种分歧会导致未分支的线程挂起直至语句执行完毕,这会大大降低 GPU 的并行率,使执行性能降低,因为 if 语句的两侧是严格同步执行的—— GPU 并没有分支预测这种复杂控制逻辑,这就是为什么说 GPU 相较于 CPU 更笨。
以下是一个示例:
if (PIXEL.x < 2)
{
do_work_1();
}
else
{
do_work_2();
}
假设有一个包含 4 个线程的 warp,它们的线程 ID 分别是 0、1、2 和 3,并假设我们正在计算一行 4 个像素。当 GPU 执行到 if 语句时,线程 2 和 3 会被挂起,直到线程 0 和 1 完成 do_work_1() 的执行。随后,线程 0 和 1 被挂起,2 和 3 被重新激活;待 do_work_2() 完成后,所有线程重新激活并继续执行代码。这实际上使 do_work_1() 和 do_work_2() 的计算时间翻倍。
但请不要因此产生误解。使用 if 语句并不总是导致性能减半,这仅在最坏情况下成立——若所有线程都选择同一分支,效率并不会损失。
若以上解释仍令人困惑,那么只需记住:应尽可能避免分支,包括但不限于:if-else、continue 和 break 语句。
在本指南中,我们使用的是 Shader Model 20b,这个版本有点特别,因为所有循环都需要展开处理(详见:https://en.wikipedia.org/wiki/Loop_unrolling),并且不是动态的。
Shader Model 30 虽支持动态循环,但目前建议避免使用—— GPU 上跑无限循环会导致停机,轻则显卡驱动崩溃,重则需要重启整个系统。
为了进一步加深理解,请导航至 gmod_shader_guide/shaders 目录,查看 example4_ps2x.hlsl 着色器源文件。
既然我们已经掌握了像素着色器的基础知识,现在该深入学习顶点着色器了。
正如我在着色器管线中解释的那样,顶点着色器的主要职能是将 3D 坐标转换到 2D 屏幕上。就像像素着色器跑在每个像素上一样,每个顶点都运行顶点着色器代码,否则它们根本不会被最终呈现在屏幕上。
顶点着色器至关重要,因为它还向像素着色器传递信息。通常会传递诸如纹理坐标之类的结构,但具体传递什么完全取决于你。
在本顶点着色器示例中,我们将引入 Valve Helper Function。源代码位于你可能见过的 .h 文件中。
这些文件包含大量实用的函数和定义供我们调用。例如 cEyePos 函数可返回玩家当前的视角位置(这功能在各类着色器中均有广泛应用)。
现在,在 GMod 控制台输入shader_example 5,快速查看当前着色器的渲染效果。输出应如下所示:

接着查看example5_ps2x.hlsl文件,你可以自由探索并修改代码,看看会发生什么。
Note
这次就不需要 vmt 文件里的 $vertextransform 和 $ignorez 这俩 flag 了,因为我们现在不做屏幕空间的操作。
Note
你 不能 用顶点着色器去采样纹理,这是早期固定管线 GPU 架构的历史原因所致,有兴趣的话可以看 B 站课程了解。
Tip
在默认情况下,一个表面的正反两面都会被渲染。因为一般渲染背面并没有什么用而且徒增性能消耗,所以一般会在 vmt 里加个 $cull 的 flag 并设置为 1 来做背面剔除优化。
Tip
出于性能优化的考量,一般都尽可能地让运算跑在顶点着色器上,因为一般情况下像素着色器的运行次数远多于顶点着色器。
screenspace_general 没有为顶点着色器指定用于传入用户自定义数据的常量。
为了将元数据传入顶点着色器,我们需要通过修改现有常量来实现,因为没有专门的自定义常量可供传入。这种方法相当 hack,但我目前尚未发现其他可行方案。
我见过有人利用雾数据和投影矩阵实现,但针对当前场景我选择采用环境光照探针(ambient cube)。此方案兼容性强,最多可支持 18 个(对应 6 个面的 RGB 值)自定义输入参数。
若存在更优雅的实现方式,欢迎在此仓库提交 issue 或 pull request 以便完善文档!
看完shader_example 6的效果后,请打开example6_vs2x.hlsl和gmod_shader_guide/lua/autorun/client/shader_examples.lua以理解其工作原理。
Note
这里像素着色器代码复用 示例 5
我们将暂时绕开着色器的话题,重点讲解 Render Target 的概念——在实现自定义渲染管线时,它们至关重要。
渲染目标的概念其实很简单:它本质上就是可编辑的纹理。
除非另有说明(通过 IMAGE_FORMAT 指定),渲染目标默认拥有 4 个颜色通道(红、绿、蓝、透明度),这些概念你应已经相当熟悉。
shader_example 7 展示了在 16x16 渲染目标中可使用的不同 flag。

由于这个示例更侧重于说明,因此并未使用任何自定义着色器。鉴于我没有其他要补充的内容,我将记录一些关于渲染目标的发现,这些内容或许对一些人有所帮助。
Note
渲染目标没有 mipmap。
Note
在着色器中,无论渲染目标的 IMAGE_FORMAT 如何,都应返回 0.0 - 1.0 的颜色空间。
Note
Source 相当怪异,它会自动在渲染目标上执行伽马校正(包括 Alpha 通道!),这意味着若需获得真正的结果,你很可能需要在着色器中使用 $linearwrite 标志。知道这一点这对 UI 着色器制作尤为重要。
Note
启用 MSAA 时,MATERIAL_RT_DEPTH_SHARED 将失效,并自动设置为 MATERIAL_RT_DEPTH_SEPARATEMATERIAL_RT_DEPTH_SHARED。
Note
可以通过 IMaterial:SetTexture 将渲染目标作为一个采样器来输入。
多重渲染目标(MRT)是一种渲染技术,它允许着色器一次 pass 输出多个渲染目标,这意味这我们可以输出更多有用的数据,这些数据可能在后续的渲染阶段会用到。
示例 8 同时运行的两个不同的帧缓冲区(即渲染帧)后处理着色器。当你输入 shader_example 8 时,将看到两个渲染目标。上方为首个输出结果,下方为第二个输出结果。MRT 支持同时写入最多 4 个独立的渲染目标。
好,现在请打开 example8_ps2x.hlsl 来学习语法。
Note
进行 MRT 渲染时,请确保向渲染目标的输出和渲染上下文的分辨率一致(通常即屏幕分辨率),否则可能导致未定义行为。
Note
任何涉及内存访问的 GPU 操作都相当耗费资源,这包括(但不限于)所有纹理采样器函数(如 tex1D、tex2D、tex2Dlod 等)以及 MRT(显存带宽占用极高)。
这并非人人都需要的东西,但它在某些操作中会派上用场,因此接下来将介绍。
深度缓冲本质上就是一个存储像素深度值的渲染目标。它决定了哪些三角形可以绘制在其他三角形之上,深度值越低,表示三角形离屏幕越近。
在光栅化阶段,GPU 会自动计算三角形的深度,但我们实际上可以在任何像素着色器中使用 DEPTH0 语义覆盖此计算。
shader_example 9 正是此类情况的典型示例。这个绘制的球体仅使用了两个三角形(我已用线框将其勾勒出来),却能实现像素级精度。

查看 example9_vs2x.hlsl 和 example9_ps2x.hlsl 以进一步了解原理及其具体实现。
Note
若需实现深度测试,需在 .vmt 文件中将 $depthtest flag 设置为 1。
Note
DEPTH0 语义会禁用掉剔除优化,导致着色器过度绘制,这可能造成填充率过高并影响性能。尽量避免使用。
screenspace_general 存在缺陷,该缺陷导致着色器无法在 prop 物件上正常使用。

问题出在这行代码上。还记得之前讨论深度缓冲吗?这行代码本质上表示“无论如何都必须写入深度缓冲”,这意味着即使渲染时某个三角形比另一个更远,深度值仍会被写入。对于常规渲染操作而言,这会造成问题。
然而我们了解到,可以通过 DEPTH0 语义和 $depthtest 标志来覆盖此行为。虽然你能用这种方式修复,但我更倾向于采用一种更简单的方案,避免使用该方法(我曾简要提到过它并非理想方案)。
这个方案就是引入 render.OverrideDepthEnable ,它允许你覆盖深度写入行为。
控制台输入 shader_example 10 来看看效果,该效果会切换 render.OverrideDepthEnable 的状态:

这自然引出了一个问题:“如果我想在道具上使用着色器,就像普通材质那样呢?”
老实说,我对此没有解决办法。你仍需使用 DEPTH0 语义。
你还需要在 .vmt 文件中设置 $softwareskin 1、$vertexnormal 1 和 $model 1,以确保模型能正确渲染。
$softwareskin 会禁用法线压缩。虽然你可以在着色器中启用压缩(需要在 #include common_vs_fxc.h 之前执行 #define COMPRESSED_VERTS 1,然后在进行蒙皮操作前对模型空间法线调用 DecompressVertex_Normal), 但为了简化流程,建议规避此操作,只设置 .vmt 里的东西即可。
$vertexnormal 其实就是在说 “嘿!这个模型有法线!”,使得实体/ prop 能正常地渲染,否则材质就无法生效。
最后,$model 只是告诉游戏引擎你可以将材质应用到物理实体上(说实话我不太确定这个 flag 存在的意义——是为了性能考虑吗?还是让着色器加载更快?我不知道)。
我认为现在是时候转向 IMesh 了,这是一种程序化几何。
如果你还不了解,网格是由顶点和索引组成的,用于确定模型中的三角形。
IMeshes 是快速生成和渲染自定义几何体的绝佳方案。其强大之处在于可为网格中每个顶点添加自定义数据。
shader_example 11 仅展示了顶点着色与实例化网格的示例:

每个可见三角形代表一个网格在特定位置渲染的效果,此处采用 10x10 网格布局。
请注意该着色器还引入了 $vertexcolor,当处理包含顶点着色的网格时必须启用此 flag。
我同时将 $cull 设为 0,确保着色器在三角形两侧均能运行
您还可以通过 mesh.UserData 向着色器传递更多数据,例如接收 TANGENT 顶点输入。
渲染这些网格时请务必调用render.OverrideDepthEnable,否则将重现示例10中的问题
Note
尽量避免使用 IMesh:BuildFromTriangles, mesh.Begin(https://wiki.facepunch.com/gmod/mesh.Begin) 效率更高且内存开销更小。只需确保代码在mesh.Begin内部不报错,否则会导致崩溃(建议使用 pcall)。
Note
要为 IMesh 正确设置光照(使用 VertexLitGeneric 等着色器时),需渲染模型以强制 游戏引擎构建光照。
Note
此页面上所有关于类型无法使用的警告均不正确。这些类型均可正常运作。
本指南即将结束,这意味着接下来的示例实用性较低,但仍有写入文档的价值。
Source 引擎中的点精灵通过几何着色器在屏幕上显示。
请注意几何着色器与顶点着色器不同——后者仅能修改现有顶点,而几何着色器能创建顶点。
本例中点状精灵采用硬编码的几何着色器,我们可以加以利用。通过 MATERIAL_POINTS 基元生成网格,并在顶点着色器中指定 PSIZE 语义,即可创建专属点状精灵。
要让精灵尺寸看起来正确需要一些奇特的数学运算,但我认为我已经正确实现了。
虽然点精灵并非最强大的功能,但它能创造出相当酷炫的效果,例如 shader_example 12:

遗憾的是,这几乎是 Source 引擎中点精灵能实现的全部效果了,由于几何着色器本身其实是 DirectX 10 才有的特性,这里只能模拟出相当有限的效果。
Note
点精灵不知道为什么存在约 100 像素的尺寸限制,这使得它们在实际应用中几乎毫无用处。
Note
这个粒子复用了 示例 11 的像素着色器
还记得之前我们利用 UV 对纹理进行采样吗?其实你也可以在 3D 空间中采样纹理!这种技术被称为体积纹理,你可以把它想象成大量 2D 纹理层层堆叠而成。
这个概念相对简单,无需过多赘述。我提供了一个体积纹理的 .vtf 文件,它曾在我几年前的的云纹理着色器中使用过。红色通道表示最小斑点,绿色为中等斑点,蓝色为最大斑点。
以下是体积纹理的局部截取(为了控制文件大小,所以质量较低):

该方法可用于实现动态纹理——传统方式无法实现此功能(screenspace_general 不支持动态纹理)。
Note
这个示例在 AMD 显卡上可能无法运行,我不清楚具体原因。
若您能看到这里,说明您(希望如此)已阅读并理解了关于 GMod 着色器的所有知识(至少是我所知的全部内容)。 请注意,这并非 HLSL 的全面指南!着色器语言本身仍有大量内容等待探索,但这绝对是个不错的起点。
若需更多着色器示例,请查阅Source SDK中的着色器文件(以 .fxc 为后缀)
欢迎在 issue 问问题,我会尽力解答 :)
Happy shading!






