T.TAO
Back to Blog
/13 min read/Technical Art

Shader#1 从 Shader Canvas 开始学习 Shader

#TechnicalArt#ComputerGraphics
Shader#1 从 Shader Canvas 开始学习 Shader

获取资源

Shader Canvas 是我自己开发的一个轻量开源 Shader 编辑器,它使用的是 Metal Shading Language。由于 MSL 是特供 Apple 平台的着色器语言,Shader Canvas 并不能在 Windows 和 Linux 系统上运行,敬请谅解。

通常如果你要学习 Shader 相关的知识,Unity、Unreal 或是 Shader Toy 之类的平台都会是很不错的选择,但是它们的功能太过齐全,很容易让我们去关注 Shader 本身之外的那些东西——比如怎么去学习 ShaderLab 的语法,怎么去绑定资源…… 因此我制作了这样一个小型引擎,让我们尽可能地能从更简单的 primitive 出发,以及尽量少的与参数化设计本身无关的东西去打交道。

欢迎你对 Shader Canvas 提出修改意见!随时都可以从 GitHub 上 Fork 一个分支,创建一个更适合你工作流的 Shader 编辑器 —— 但这一条仅为我自己的建议:我建议尽可能精简这个编辑器的功能。

渲染管线

如果你是一个 Shader 开发的新手——新到连渲染管线的概念都没有听说过,那我会非常建议你先去系统性地学习计算机图形(尤其是实时渲染)相关的知识。这样能够帮助你有效地理解整个 shader 工作的原理。

如果你已经很熟悉 Shader,那我们已经可以来谈一谈开发 shader 需要在脑子里面构建一个什么样的思路。

着色器阶段,或者说数据流

无论我们使用什么样的图形 API,渲染管线上总会有那么几个阶段是一定存在的。我们鲜少直接与 GPU 进行交互,很多时候开发艺用 Shader 的需求都来自于游戏引擎或是特效引擎。

我个人会非常喜欢用数据流(Data Flow)这样的说法。想想,不管做材质也好、做后期处理效果也好,我们总是要针对某一个 primitive 进行操作。这些 primitive 的原始数据(它的顶点坐标、法线、纹理映射等)总之都来自于应用。这些数据最终流向屏幕上,才让我们看到不一样的视觉效果。

在图形学课堂上,我们都会被要求记忆一些着色模型的公式,什么NLN\cdot L, Blinn-Phong, 或者更高级的渲染方程。但不管怎么样,就拿最简单的 Lambert Diffuse 来说,NLN \cdot L的数据不会是从天而降的。NN被称作法线,这是primitive本身的属性,LL被称为光照向量,这是场景里灯光的位置与物体位置之间的向量,那这不仅是 primitive 本身的属性,这还是场景的属性。更进一步地,如果 NNLL 甚至不在一个坐标空间中,那点乘更是毫无意义。

说起来,每个面试官都会问你什么是点乘。请注意回答这个问题的时候一定要把面试官当成 5 岁的孩子,或是 80 岁的老奶奶。

假设你真的对 Shader 和渲染管线非常不熟悉,你总是要知道这些数据不能凭空出现的。如果你计算颜色的时候需要用到法线,那么你就必须要有一个东西给你法线的信息。下面你才应该去思考——这个法线的信息是如何来的呢?

模型(或者说应用、或者说 fbx/obj/usd 文件)能给你的永远只是它自己局部空间中的信息,所以这个空间也叫做模型空间。计算光照需要的是世界空间中的法线的话,那我们就需要一个变换 —— 这个变换本身的推理你应当很熟悉,但是模型数据坐标系变换本身一般是发生在顶点着色器。随后,又由于我们着色的对象是像素,而并非每一个像素上面都有顶点,所以我们才需要光栅化器将数据量从 O(V)O(V) 放大到 O(WH)O(WH),这里的 WHWH 显然就是屏幕的宽高。之后每一个像素上都有一个“虚构的”顶点了,然后我们才会用像素着色器或者说片元着色器去渲染颜色。

Shader Canvas 中的数据流

在 Shader Canvas 的左侧,我提供了一个名为 Data Flow 的面板。 Data Flow 这个面板帮助你决定你要在 Shader 中使用哪些数据。然而,这是最基础版本的 Shader Canvas,实际上你完全可以将其拓展为支持切线副切线等更多的数据。但为了尽可能聚焦于一些更简单的效果带来的可能性,我决定在我的数据流中,只去传递法线、uv、位置、时间和观测向量这些数据。

简单的效果

我觉得通常来说最简单的效果也许就是边缘光(Rim Light)了吧。 Rim Light 边缘光本身不需要任何的顶点变化,只需要理解通过 NVN\cdot V去“获取”边缘即可。

在 Shader Canvas 中,即使你不在 Layer 中添加任何的 Shader,它也会有一个基本的 Lambert Shading,像素也会被正常映射到屏幕空间上,所以你不用担心。如果你不需要更改顶点的位置,那么完全可以不用添加任何的 Vertex Shader。 Basic Editor

下面让我们添加一个基本的 Fragment Shader。点击并清空默认提供的 Fragment Shader。 Clear Fragment Shader

如果你不熟悉 MSL,正好看看这个模板——而且你也可以看到,你只要在侧面写 fragment shader 就好了!不需要去补充任何其余的代码。

MSLfragment float4 fragment_main(VertexOut in [[stage_in]]) {
    return float4(1.0, 1.0, 1.0, 1.0);
}

其中,fragment是标记这个函数为片元着色器入口的关键字。[[stage_in]]表示in是一个经过插值的输入。特别说明一下,实际上返回类型并不需要是float4, 而且 Apple 似乎建议我们用half4来表示颜色。只是要稍微区分一下halffloat,以及 MSL 中很有可能在各种地方不支持隐性的强制类型转化。

数学原理

数学原理上 Rim Light 本质就是菲涅尔效应,而菲涅尔效应的数学公式

R(θ)=R0+(1R0)(1cosθ)5R(\theta) = R_0 + (1 - R_0)(1 - \cos \theta)^5

其中:

  • cosθ\cos \theta:通常用 (nv)(\mathbf{n} \cdot \mathbf{v})(法线向量和视线向量的点积)来计算。
  • R0R_0:是物质在正入射(即视角垂直于表面,θ=0\theta = 0)时的反射率。
  • pp:在标准的 Schlick 模型中这个幂次通常固定为 5,但有时候为了艺术效果,大家也会把它改成变量 pp 来控制边缘光的范围。

比较易读的 Schlick 近似公式为,

R(θ)=k(1(nv))5+ΔLR(\theta) = k(1 - (\mathbf{n}\cdot \mathbf{v}))^5 + \Delta L

其中,用艺术家们比较容易理解的方式,kk是个亮度系数,ΔL\Delta L是个透明度阈值。

实现

光是把公式写出来,代码就已经非常清楚了。

MSLfragment float4 fragment_main(VertexOut in [[stage_in]]) {

    float nDotV = dot(normalize(in.normalWS), normalize(in.viewDirWS));
    float rTheta = 1.0 * pow((1 - nDotV), 5.0) + 0.2;

    return float4(rTheta);
}

确实也能看到一个不错的效果: Rim Light Draft

够了吗?不够。对于 Technical Artist 来说,参数的灵活性很重要,我们肯定不希望把 1.0, 0.2 这样的 magic number 写进代码。Shader Canvas 提供了很方便的参数设置途径。你可以在左侧的 Parameters 中增加新的参数,比如添加一个名为 Bias 的 slider, Adding Bias 这会自动在你的 fragment shader 上方添加一行注释,

MSL//@param _Bias float 0.5 0.0 1.0

这样的语法帮你确定了数值类型、当前数值、最小值、最大值。你也可以去自行添加其它的参数看看效果。比如我添加了这些参数: Rim Light Params

MSL// @param _RimScale float 0.0
// @param _RimPower float 0.0
// @param _RimLight color 0.0 0.748 0.993
// @param _Bias float 0.5 0.0 1.0
fragment float4 fragment_main(VertexOut in [[stage_in]]) {

    float nDotV = dot(normalize(in.normalWS), normalize(in.viewDirWS));
    float rTheta = _RimScale * pow((1 - nDotV), _RimPower) + _Bias;

    return float4(float3(rTheta) * _RimLight, rTheta);
}

从技术层面上,我们可能还要考虑一下数值范围,比如NVN\cdot V有没有可能出现负值? 完全是可能的,我们也应该做一下钳制。

MSLfloat nDotV = saturate(dot(normalize(in.normalWS), normalize(in.viewDirWS)));

先加一点简单的后期

只是为了让你知道 Shader Canvas 是能提供一些基础后期处理效果的 —— 当然,后期处理本来也应该你的任务!你应当负责整个视觉效果的前期、中期和后期。未来我们再在别的 Blog 上处理一些全屏相关的内容吧。

目前你只需要点击一下 PP 按钮,增加一个 Fullscreen Layer。 Fullscreen Shader 这个效果还挺炫酷的是不是?打开 Fullscreen Layer 1(或者你也可以重命名成 Bloom)。跟着做一下下面的几个设置就可以使用一个默认的 Bloom 效果了。

Bloom

哦,背景图片可以在 Shader Canvas 的右下角设置!

什么?你觉得这个 Bloom 性能太糟糕了,质量太差效果太差了?没问题,相信你一定能用 Shader Canvas 写出更好的东西!

题外话

如果你非常不熟悉 Shader,尤其是 MSL,你可以尝试一下 Shader Canvas 的 Tutorial 功能。这个功能应该会帮助你快速上手 MSL。去试试吧。 iShot 2026 03 09 23.12.39