为我的离线渲染器制作一个着色语言
摘要
作者详细介绍了为其离线CPU渲染器SORT创建的自定义着色语言库——微型着色语言(TSL),并解释了其动机,包括学习、灵活性、Apple Silicon支持以及相比使用OSL减少依赖等。
暂无内容
查看缓存全文
缓存时间: 2026/06/11 14:00
# 为我的离线渲染器编写着色语言
来源:https://agraphicsguynotes.com/posts/making_a_shading_langauge_for_my_offline_renderer/
作为一名图形程序员,我通常不会花太多时间在那些与计算机图形学或游戏引擎并非严格相关的事情上。然而,去年我确实花了四个月的业余时间,为我的渲染器 SORT (https://sort-renderer.com/) 构建了一门着色语言,我称之为 Tiny Shading Language (TSL) (https://tiny-shading-language.com/)。一开始,由于缺乏对编译器工作原理的一般性了解(这不是图形程序员经常接触的东西),我不知道最终会怎样。但事实证明,这项工作并不需要太多精力,一个人几个月就能完成。在这篇博客中,我将简要介绍设计这门着色语言库时的一些想法。更具体地说,这篇博客是关于系统如何设计,以及它如何与离线 CPU 渲染器配合工作,而不是详细的语言实现。下图是 TSL 库自带的示例教程 (https://github.com/JiayinCao/Tiny-Shading-Language/tree/master/src/tsl_sample) 的截图。两个球体表面的图案是在 TSL 中以程序方式生成的。
## 动机
每当人们听说我编写了自己的着色语言时,他们首先问的总是:“既然已经有 OSL 了,你为什么还要自己做一个?”这是个好问题,我在动手之前也有同样的疑问超过半年。通常有几个原因:
- 通过编写自己的着色语言,我可以从零开始学习一切。这显然是我选择做这件事的最大原因。在制作过程中获得的知识对我未来的职业生涯会很有价值,至少应该会对我的工作产生一些间接影响。它应该能让我对编程语言编译器的工作原理有更深入的理解。
- 拥有自己的代码库,可以让我按照自己认为合适的方式修改库。仅这一点就比 OSL 提供更多的灵活性,因为我不熟悉 OSL 的实现。
- 由于苹果正在从 Intel 芯片过渡到 ARM,未来的 MacOS 将搭载 ARM 架构。在 ARM 上构建 OSL 也需要在 ARM 上构建其所有依赖项。如果任何依赖项中有特定于 x86 的实现,我也必须在 ARM 上找到替代实现。没有适用于 ARM 的 OSL,就无法将我的渲染器移植到 Apple Silicon。支持 Apple Silicon 在 OSL 的路线图中,但在这篇博客撰写时,还不清楚何时可用。
- OSL 大量使用一个名为 Open Image IO (https://sites.google.com/site/openimageio/home) 的库,这是另一个开源项目。而 OIIO 又依赖其他几个小型库,如 OpenExr (https://www.openexr.com/)、libpng (http://www.libpng.org/pub/png/libpng.html)、libtiff (http://www.libtiff.org/) 等等。一些基本数据结构只在 OIIO 中定义,因此将 OSL 与 OIIO 解耦需要相当多的工作。这是我考虑过的选项之一,但经过思考,由于本地修改,这样做也会使在我的渲染器中更新 OSL 变得非常困难。
- 依赖太多确实让 OSL 比预期的“重”。我原本期望只有一个 OSL 库作为依赖,但结果显然不是这样。最终还有其他库,并且在某些平台上,一些库还必须动态链接。理想情况下,我可以花更多时间确保所有依赖都是静态编译的,但这比听起来复杂得多。我不想花太多时间从源代码构建这些库。此外,一些预编译库是特定于 Ubuntu 操作系统的,这意味着我必须在另一个 Ubuntu 版本上再次编译。到头来,在解决了所有这些问题之后,OIIO 的特性在我的渲染器中甚至根本没有被使用,我也没计划使用它们(因为从头实现会很有趣),它纯粹是因为 OSL 需要而存在的依赖。
还有其他原因促使我实现自己的着色语言。然而,这些大多与 OSL 有关。我尽量避免评论 OSL,因为在这篇博客撰写时,我是索尼(顽皮狗)的员工。基于上述原因,我希望已经说清楚了我为什么决定实现自己的着色语言。当然,我完全清楚自己的实现远不如 OSL 健壮,因为 OSL 背后有一个团队,并且这项技术已经发展了十多年。因此,在做出决定后,我的下一个问题是:我一个人在几个月内是否可行?我当然不想偏离自己的轨道太远,毕竟我是一名图形程序员,没人指望我了解太多设计编译器的细节。
## 利用现有工作
下图展示了将编程语言编译成机器代码的一些基本阶段。
从头开始、全靠自己实现听起来很疯狂,而且我不太可能在几个月内完成所有工作。经过一些基础研究和学习,我找到了一些有用的工具,可以帮助我将着色语言变为现实。
- **Flex (https://en.wikipedia.org/wiki/Flex_(lexical_analyser_generator))**:Flex 是常用的词法分析器工具之一。它接收字符串流,并按照预定义的方式将字符串标记化。
- **Bison (https://en.wikipedia.org/wiki/GNU_Bison)**:Bison 是语法分析器。它接收 Flex 生成的标记,并生成抽象语法树 (https://en.wikipedia.org/wiki/Abstract_syntax_tree)。
- **LLVM (https://llvm.org/)**:LLVM 是低级虚拟机的缩写。它是一个复杂的基础设施,可以帮助将 LLVM IR 转换为不同目标架构(如 PC、x86 等)的机器代码。它还进行优化。
有了这些工具,听起来我只需要做以下事情:
- 为 Flex 制作一个配置文件,以标记化我的着色语言。
- 为 Bison 制作一个配置文件,使用 Flex 生成的标记并生成 AST。
- 使用 Bison 生成的 AST 生成 LLVM IR。
- 使用 LLVM 将 IR 编译为 JIT 机器代码。
这比完全靠自己编写要容易管理得多。这确实给了我一些信心,认为一个人可以完成。然而,除了基本的语言支持(即将我的着色语言转换为 JIT 机器代码)之外,这还远远不够,因为用于 CPU 光线追踪器的可编程着色语言与 GPU 的着色语言根本不同。该项目很大一部分工作是设计用户友好的接口,并使其很好地融入我的渲染器。这并不比仅仅将高级语言转换为机器代码的工作量少。这篇博客主要讨论后者。它不会提及前者的任何内容,因为互联网上有大量资源可以帮助解决这些主题。Kaleidoscope (https://llvm.org/docs/tutorial/MyFirstLanguageFrontend/index.html) 是 LLVM 库附带的一个很好的例子。
## 它在光线追踪器中适合什么位置
在深入讨论着色语言的细节之前,我想先做一些笔记,概述这个库在光线追踪器中是如何适应的,以便读者对它如何与系统其他部分配合有一个大致的概念。一个裸路径追踪算法大致如下:
1. 为每个像素样本生成主光线。
2. 找到与场景最近的交点。
3. 使用材质信息构建 BSDF。
4. 评估 BSDF 并更新吞吐量,将其累加到结果中。
5. 对 BSDF 进行重要性采样,如果需要,生成次级光线。
6. 回到步骤 2 并循环。当步骤 2 中未找到交点时,循环停止。
当然,这也是一个简化的流程,因为没有体积渲染和次表面散射。但这足够用来解释 TSL 适合什么位置。我想读者们此时都已经猜到了。TSL 执行应该发生在步骤 3,这正是着色语言的用途。在 PBRT 第三版中,有预定义的材质,其 BSDF 是硬编码的,BXDF 的参数通过 pbrt 输入文件暴露,灵活性有限。有了 TSL,光线追踪器可以在运行时翻译着色脚本,而无需自身重新编译,这提供了极大的灵活性。TSL 着色执行的目标是在目标 BSDF 中重建 BRDF,这个目标与 PBRT 通过其材质实现所做的非常相似,只是它不是硬编码在光线追踪器本身中的。着色编写可以在后期艺术家处理资产时进行。
## 语言系统设计
TSL 被设计得简单易用。该语言的语法非常类似 C,就像 GLSL、HLSL、OSL 一样。这对即使是新手着色作者也很友好。然而,CPU 光线追踪器着色语言与 GPU 着色语言有许多根本不同的差异,这对语言系统设计有很大影响。TSL 的工作方式与同样运行在 CPU 上的 OSL 非常相似。以下是 TSL 与 GPU 着色语言相比的一些主要差异:
- 在图形 API 中,在发出绘制调用之前,我们需要将信息从主机(CPU)发送到 GPU,通常包括常量缓冲区、顶点缓冲区、索引缓冲区等。一旦所有着色器输入设置完毕,连同其他渲染状态,就会发出绘制调用。根据具体情况,着色器执行次数可能高达数百万甚至更多。简而言之,所有内容一次设置完成,着色器通常大规模执行。而 TSL 的着色器设置和执行几乎总是一对一的。这是由 CPU 光线追踪器的本质决定的。CPU 的并行性与 GPU 不同,因为不同线程根本不同步,根本不存在 Warp 或 Wavefront。没有同步,由于不同线程几乎从不执行像 GPU 那样的相同指令,多次执行着色器几乎没有意义。SIMD 优化对于着色器来说非常不合适,因为没有同时执行四个着色器实例的情况。这仅在我的渲染器中成立。OSL 在其路线图中包含 SIMD,一些商业渲染器确实利用它来运行批处理着色器执行以获得更好的性能。
- 现代游戏引擎都支持材质图,这更像是为技术美术师提供的可视化编程语言工具,用于“编码”。然而,GPU 着色器编译器根本不知道这种东西。存在一种叫做“着色器构建器”的东西,它从材质图收集的不同着色器片段构建最终的着色器源代码。这些着色器通常被称为材质着色器。游戏引擎中另一种类型的着色器通常称为游戏内着色器,通常由图形程序员编写。GPU 着色器编译器接收着色器源代码,甚至不知道它是游戏内着色器还是组合的材质着色器。在离线渲染器中通常没有“游戏内”着色器。但材质着色器概念是支持表面各种材质外观的必要条件。与 GPU 着色器编译器不同,我选择在 TSL 库内部实现“着色器构建器”算法(就像 OSL 所做的那样),这样集成 TSL 的渲染器只需获取着色器片段(我称之为着色器单元模板),TSL 将负责构建最终的着色器代码,渲染器甚至没有机会看到它。这样做的优点是渲染器无需负责实现着色器构建器来支持类似着色图(shader-graph-like)的材质。
- 在 GPU 着色器中,输入是纹理、常量和顶点。输出通常是一个简单的平面数据结构,其中包含后续管线阶段的数据。TSL 的输入类似于 GPU 着色语言,它有全局常量和全局纹理句柄。但输出和着色器源代码的定义与 GPU 版本截然不同。在 TSL 中单独完成所有 BXDF 评估(像游戏引擎那样)通常不是一个好主意,原因有二:
- 首先,BXDF 不仅支持其评估,还支持重要性采样的接口。如果所有内容都在 TSL 中完成,这意味着着色图必须翻译成几种类型的着色器,这些着色器支持评估、重要性采样等。每当 BXDF 需要更多接口时,就需要在 TSL 中生成一种新类型的着色器。
- 其次,同样重要的是,许多渲染器已经拥有相当多 BXDF 的可靠实现。在 TSL 中完成所有工作意味着,对于集成 TSL 的渲染器,它们需要在 TSL 中重新实现它们在 BXDF 实现中已经做过的一切。更不用说 TSL 中不支持 stl,很难实现那些在 C++ 中很容易实现的复杂 BXDF。
基于上述原因,TSL 的输出被设计为像 OSL 一样的闭包树。闭包本质上表示渲染器中的 BXDF。与实时渲染引擎(大多使用 Microfacet 和 Lambert 作为着色模型)不同,离线渲染器的材质系统可能复杂得多,通常由 BSDF 定义。BSDF 由多个 BXDF 线性组合而成。更复杂的一点是,某些 BRDF 将其他 BSDF 作为输入参数,这实际上将 BSDF 转换成了树。在 TSL 中,它不是返回颜色值,而是生成一个闭包树,这与 BSDF 的定义完美匹配。这将在本文后面更详细地介绍。通过引入闭包树类型,TSL 真正需要评估的实际上是重建 BSDF 的参数。例如,Lambert 闭包的基础颜色。这里值得一提的是,闭包概念只是一个占位符,带有其输入参数值。它不一定与 BXDF 对应。在体积渲染的上下文中,它可以对应介质。事实上,它可以对应任何以后要评估的东西。不过在我的渲染器中,我只用它来评估 BXDF 和介质。
- 与大多数 GPU 着色语言不同,TSL 允许调用栈。事实上,TSL 最终会被解析为 CPU 可执行的 JIT 代码,没有理由不支持调用栈。拥有调用栈将允许着色作者比 GPU 着色器更容易地实现诸如遍历二叉树之类的算法。
- 由于 GPU 的本质,着色器执行总是在多个着色核心之间同步以获得更好性能。而在 CPU 光线追踪器中,跨多个线程的同步着色器评估没有太大好处。不过 TSL 将在其自己的线程内以同步方式执行。本质上,执行 TSL 着色器无非是从主机端调用一个函数,只不过机器代码是由 TSL 而不是 C++ 编译器编译的。
## 着色器单元模板
着色器单元模板是 TSL 着色器最基本的编译单元。OSL 中对应的概念称为着色器层(shader layer)。着色器单元模板只是一段将独立编译的着色器代码。例如,以下……
相似文章
ASM SHADER TOY – 它像Shader Toy,但用汇编语言编程
ASM SHADER TOY 是一款工具,让你使用汇编语言编写着色器,类似于流行的 Shader Toy 平台。
编写一个无绑定GPU抽象层
一位开发者分享了他们实现的无绑定GPU抽象层Loon GPU,它基于Vulkan 1.3和Metal 4,灵感来自Sebastian Aaltonen的《No Graphics API》博客文章。该库使用GPU指针、顶点拉取和无绑定纹理堆来简化现代图形API。
用500行纯C++实现软件渲染
一个教程系列,通过用500行C++从零构建软件渲染器(无需外部库),演示OpenGL、Vulkan、Metal和DirectX的工作原理。
Unsloth 登陆 Apple Silicon - 预公告
Unsloth,一款流行的 LLM 微调库,宣布即将支持 Apple Silicon 设备,将其优化能力扩展至 NVIDIA GPU 之外。
设计 Lispy 领域特定语言,第1部分:SCSS(2012)
本文以 SCSS(一种基于 Scheme 的 CSS 预处理器)为例,探讨 Lispy 领域特定语言的设计。讨论了 SCSS 如何将 CSS 表示为一等值及其实现的局限性。