跨平台着色组件实现思考

背景

去年在artgl中实践了一种以着色组件(Shading component)的材质系统实现,用户通过编写独立的组件并组合这些组件形成完整的shader材质, 每个组件持有自己的材质数据,实现uniform等输入数据的上传,实现shader着色逻辑的注入。 今年年中,就是现在,我在做rendiation的跨平台渲染的场景描述层实现,其中材质的中间层系统被称之为着色抽象层,我认为基于着色组件的材质系统应该是一个非常好的材质框架设计方向,所以我尝试在rust中复刻及重新思考和打磨这一块的API设计,作为rendiation的着色抽象层的框架实现。

现在我还拿不出一个合理的令人兴奋的设计上的大幅优化方案,但是其中的思考值得记录

显式计算图还是着色语言?

在编写跨平台渲染引擎时,由于各个平台使用的着色语言不同,所以这一块是个麻烦。

第一种做法是自己设计一套着色语言,比如unity,自己再把它编译到各个不同平台的着色语言上。

第二种是自己使用任意的语言,但是统一编译到底层中间格式,比如spirv,然后只要支持spirv的平台就都可运行。

第三种是直接将引擎开发语言或其子集直接编译到各个不同平台的着色语言或中间格式。

第四种是用引擎开发语言实现一套计算图表示,使用开发语言完成计算过程描述,然后编译到各个不同平台的着色语言上。

这四种各有优劣:

第一种做法,需要自己设计语言,实现编译器前端和各个target的emitter,实现难度高成本大。用户学习成本高。但是毕竟语言都是自己发明的,定制能力强,很酷。

第二种做法,不是所有平台都有spirv的支持的,(看向webgl)。而各个语言到spirv等于一堆编译器前端,虽然都是有现成的,但是你只能编译期就确定shader,失去了运行时动态新加shader的能力,除非运行时把你要用的语言的编译器带上,局限大,通用性不佳。 好处就是实现简单。

第三种做法,额外好处1是不想引入新语言的学习成本,2是希望能复用引擎本身某些代码,另外实现上会容易一些(一般语言都会提供自己的parser)。但是也是局限性的问题,肯定是子集,但是万一有不能表达的东西呢?万一你本身语言就是没类型的呢(看向js)。

前三种都是基于着色语言的方案,第四种是计算图的方案。

我在artgl中,做的就是一个简单的计算图方案, 着色组件的对shader逻辑的扩展方式是直接对计算图进行扩展和修改,最后将图编译成shader。我曾经和我的室友开玩笑说我是搞不定编译器只能被迫做计算图,因为本质上,编译器从字符串搞出语法树,后一步的代码生成和优化也是转计算图的。但是后来我一想觉得事情没这么简单,计算图的方案虽然看起来不是那么高明但可能有额外的优势。

相比显示的计算图描述,语言方案本质上是计算图的字符串隐式描述,从语言得到计算图的实现成本是很高的(编译器前端+图生成)。 而为什么我们需要计算图呢,有了计算图能做什么呢,在我看来计算图才真的是核心。如果引擎层搞不到计算图,那么就很难将着色过程组件化,材质框架事实上是无从谈起的。 着色过程组件化中,每一个组件肯定是要可以依赖其他组件的输入的,这个依赖本质上是外部的输入或者某个计算的结果,除了计算图,没有其他更好的对这些依赖进行描述和定位的方式了。想象一下假设我们没有显式的计算图,而是shader的字符串代码,我们就很难描述我们这一段代码具体依赖另一段代码的中某个变量的依赖关系,难道用变量名吗?

所以我对最佳方案的思考是hybrid的:

1 用户可以在封闭的单元(atomic?)来使用任意的着色语言进行编写,比如一个function,input group,这个封闭单元的概念是单元的输入输出是确定的和暴露的,外部不会依赖单元的内部内容。
2 引擎层能将单元parse成计算图,得到单元内部对外部单元依赖
3 用户可以组装单元形成子图形成新的单元,或者直接完成计算图组织

几个设计点:
1 用户可以有选择/发明 着色语言的自由,但是要能转成引擎内的计算图表示
2 引擎内的计算图可以自由反向输出着色语言,是IR
3 计算图只本质上描述计算的依赖关系,本质只是对计算单元做link,不设计具体着色能力/feature/语言设计
4 材质框架本质基于计算图依赖/link的能力完成组件化,组合能力,重用,抽象

shader是应该编译期生成还是运行期生成?

这里的shader指最终交给驱动的东西,可以是spirv,也可是代码。一种看法认为这些东西属于产品的asset,相当于图片等资源,是编译构建时就应该制作完成的,而也不乏能看到实现是所有shader全部是运行时生成的情况。

运行时生成是必不可少的能力,如果缺少这个则无法支持用户自定义着色。但大部分或者全部的运行时生成一方面会引入额外的overhead,(虽然生成pso开销会更高),另一方面, 我新看到的重要一点是纯的依赖运行时生成的方法不太稳定可靠,说明白就是缺少编译期的检查,使得shader生成逻辑完全依赖外部的运行时测试(虽然事实上这个是起码要有的事情)。

所以我概括为着色框架最好能支持在编译期支持有限的检查机制以防止运行时shader编译失败的可能,这个是为生产力上锦上添花。

实现这一特性,两种approach,工程性的和技术性的。工程性的很简单,本质就是测试工作,这个不用讨论,而技术性的方案倒是有些意思。

回到上一个topic,假设你是全盘使用语言描述,那么本质上你实现这个语言的language server就能完整解决。假设使用的是全盘计算图的方案,那么就很有挑战了。如果框架的语言是动态语言,那么这个需求就搞不定了。如果是静态语言,也非常难做。要实现这一功能,核心是要为每一个node做输出输出的类型标记,这个直接导致写起来非常不ergonomic,更麻烦的是这些node很难使用统一容器存储,不然又涉及不安全的类型转化。这个目前做了一些尝试并没有太好的进展。退而求其次,每一个着色组件是存在外部依赖的,但是这个外部依赖能否做静态类型检查呢?这个我的确尝试出了一个方案,但是也不ergonomic。主要做法类似于Iterator的思想:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
struct BaseShading {
graph: ShaderGraph,
}

pub struct PositionAttributeInput<T> {
before: T,
attribute_input_node: Node,
}

impl<T> PositionAttributeInput<T> {
pub fn new(before: T) -> Self {
todo!()
}
}

pub trait MVPTransformInput{
fn position(&self) -> Node;
}

// deref into inner type
impl MVPTransformInput for PositionAttributeInput<BaseShading>{
fn position(&self) -> Node {
todo!()
}
}

pub struct MVPTransform<T: MVPTransformInput> {
before: T,
mvp_uniform: Node,
}

impl<T: MVPTransformInput> MVPTransform<T> {
pub fn new(before: impl MVPTransformInput) -> Self {
todo!()
}
}

fn test() {
let base = BaseShading::new();
let base_with_position = PositionAttributeInput::new(base);
let mvp_trans_formed = MVPTransform::new(base_with_position);
}

这种做法其实是使用泛型容器反复嵌套来合成一个struct,每一个容器都是一个component,component的节点依赖使用trait定义,用户再通过impl trait的方式实现依赖链接。这个解法其实已经非常好了,但是不ergonomic的原因在于如果着色组件切分很细,那么嵌套会非常多,实现链接的代码会写的很丑陋,类型也会很长。

我们其实需要的是一种做法,就是能在编译期随机组合几个struct成为新的struct,这种需求估计要使用marco来解。我暂时没想到什么写法能够糅合指定任意的class,但是退而求其次还是可以搞泛型是数组的容器:

1
2
3
4
pub struct Combine2<A, B>{...}
pub struct Combine3<A, B, C>{...}
pub struct Combine4<A, B, C, D>{...}
...

其中链接的约束可以这样搞?

1
2
3
4
5
6
pub struct Combine4<A, B, C, D>
where
B: Link<B, (&A)>,
C: Link<C, (&A, &B)>,
D: Link<D, (&A, &B, &C)>
{...}

如果要涉及到实际shader生成和编译,那似乎要依赖const function等编译期计算相关的东西了/