转载请注明出处为KlayGE游戏引擎,本文的永久链接为http://www.klayge.org/?p=3560

很多跨平台游戏引擎都有统一shader的需求。比如KlayGE从建立伊始,就强调一份代码跨多个平台,shader代码也不例外。如果需要对不同平台都分别写一遍shader,那样的工作量和可维护性都很糟糕。

既然有这样的需求,就必然会在技术上遇到一个问题,如何把一份代码编译成不同API上的shader。从目前的API上,我们至少需要应对HLSL/GLSL/ESSL,以后还有Vulkan加入战团。这里就打算探讨一下跨平台shader编译的情况,希望对大家有启发意义。

过去

刚有shader高级语言的时候,Cg是几乎唯一的shader语言。后来才在D3D9时代衍生出了HLSL,再往后有了GLSL和ESSL。所以自然而然一开始都会从Cg入手。在KlayGE发展的过程中,这还分为两个阶段。

运行中使用Cg

因为早期的Cg和HLSL几乎一样,我的做法是用HLSL写shader,在D3D上用HLSL编译器编译,OpenGL上用Cg编译器编译。遇到Cg不支持的个别语法,就用#ifdef隔开。一开始这个做法工作得还不错,得益于Cg的跨平台,Windows和Linux都能用全套Cg runtime来跑。不管是编译还是设置状态还是渲染,都通过Cg来实现。这样的系统工作流如下。

Cg Runtime

图中模糊边缘的组件表示比较不可靠的组件。比如有性能问题,或缺乏社区支持,或已经停止开发。后同。

这个做法的缺点也很明显。

  • 问题1,不支持AMD和Intel的显卡。当时Cg对OpenGL的支持是通过把Cg编译成GL的asm来实现的。而这个过程直接用到了NV的扩展,在其他厂商的卡上跑不了。
  • 问题2,CPU端的性能损失。即便在NV的卡上,用Cg runtime也会带来一定得性能损失。虽然随着Cg的升级,这个损失在一点点减少。但比起直接用API的,总是慢一截。
  • 问题3,发展速度。到了D3D10时代,HLSL和GLSL都支持GS,但Cg对其的支持慢了一步,而且仍然严重依赖于NV扩展。再往后的D3D11时代,干脆Cg就进入了龟速发展阶段。

离线使用Cg

既然用全套Cg runtime有诸多问题,那么能不能把Cg编译器离线使用呢?在某个版本的Cg里,加了glslv这样的profile,可以把Cg代码编译成GLSL。那么就可以在载入Cg代码后,立刻编译成GLSL保存下来。之后运行过程中完全不需要跟Cg打交道了。这么做解决了之前的问题1和2。因为用的都是GLSL和OpenGL,其他厂商的卡也能跑,因为Cg runtime造成的性能损失也不存在了。改进后的系统工作流如下。

Offline Cg

但是,问题3仍然存在。即便是编译成GLSL,还是需要受到Cg编译器能力的制约。没法跟上API的发展速度,结果只能用很保守的shader,再用#ifdef等方法仔细隔开。这也是之前很长一段时间,KlayGE里的OpenGL插件在功能上比D3D插件有所欠缺的原因。

另外又多了一个新问题:

  • 问题4,兼容性。Cg编译出来的GLSL,虽然说是“按照标准”,但在OpenGL世界,你还得多问一句“按照哪家的标准”。当然,既然Cg是NV的,就肯定是遵照NV的标准。那样的GLSL,还得自己做一遍解析,改掉一些地方才能用于AMD和Intel的卡。甚至有些内置的attribute,生成的时候多了个下划线前缀,也得自己修。

正因为有这些问题,大概在2011年的时候,我才会有放弃Cg,自己搞一套编译或转换系统的想法,从HLSL连到GLSL/ESSL。

现在

碰巧在打算放弃Cg的时候,看到了mesa的一个神奇commit,d3d1x for linux。这个commit是再给mesa增加D3D10/11的原生支持。虽然这个项目最后挂了,但给人们留下了一个叫d3d1xshader的库。这是第一个非官方的完整DX shader字节码(DXBC)解析的库。用它可以很容易做出来一个反汇编工具。以这个为基础,我们可以实现我设想的转换系统。

DXBC2GLSL

经过KlayGE团队成员林胜华的努力,一个称为DXBC2GLSL的库出现了。它可以解析DXBC,生成对应的GLSL和ESSL。但需要特别注意的是,因为官方的D3D shader编译器是个dll,只能在Windows上跑。在Mac和Linux上需要wine的帮助。这一部分是由钱康来完成的,用winegcc编译一个dll的wrapper exe,之后再通过wine调用那个exe,达到调用dll里的函数的能力。 这个把它当做一个新问题吧。

  • 问题5,在非Windows平台需要通过wine来执行。

有了这么个系统,跨平台shader编译工作流就变成了这样。

DXBC2GLSL

在DXBC2GLSL的基础上,我又增加了Hull shader和Domain shader的支持,所以OpenGL/OpenGLES插件在功能上已经基本赶上了D3D11的。虽说前面的4个问题都解决了。但还是引入了一个新的性能问题。

  • 问题6,生成质量/GPU端的性能损失。可以认为这里的GLSL是通过DXBC反编译得到的,质量并不是特别高。所以GPU端的运行效率也会下降。虽然说常规解决方案是用glsl-optimizer对生成的GLSL做进一步优化,但这个任务因为时间关系目前还没去做。

同时期还有个功能相同的库,叫HLSLcc。也是从d3d1xshader发展而来。

未来

说到底,造成这些问题的原因在于没有一个通用并开放的中间格式。否则用HLSL的前端,接到不同的后端生成不同的目标代码就解决了。UE4的做法是自己写了一个HLSL解析。这么做可以避免问题5,但因为HLSL经常会变,作为个人项目的KlayGE如果这么做会花很多时间在兼容性上。

这时候,Vulkan来了。不但有新API,还带来了一个新的shader中间格式,SPIR-V。而我认为这正是通往统一的跨平台shader编译路上最更要的一级台阶。

SPIR-V

SPIR-V和DXBC类似,都是一个shader的二进制中间语言。但比起DXBC,SPIR-V的标准是开放的,有相应的生态系统,解析起来容易得多。根据之前的工作流,我们可以设想一下用到了SPIR-V的话,未来的新工作流会是什么样子。

SPIR-V

看起来在结构上,比以前几个版本都要简练高效得多,所有不可靠的组件都已经去掉。问题5直接消失了。又因为很多新的OpenGL驱动实际上可以直接输入SPIR-V,老驱动上可以用官方的GLSL/ESSL生成器,所以问题6也消失了。

这个图景就好象一个巨大的拼图,但目前为止,我们还缺了几块没介绍。需要把它们凑齐。

HLSL->SPIR-V编译器

这件事情,已经有Khronos的人在做了Complete HLSL -> SPIR-V translator · Issue #362 · KhronosGroup/glslang,但终归不是最好的方法,进度和发展速度都缺乏管理。而因为现在微软开源的HLSL编译器Microsoft/DirectXShaderCompiler已经发布,我们可以在那基础上做一个SPIR-V的后端,解决HLSL编译的问题。

SPIR-V->GLSL/ESSL

这有KhronosGroup/SPIRV-Cross,是一个已经解决的问题。

还有什么可能

除了前面提到的工作流,我们其实还有一些可能的选项。

DXBC->SPIR-V

除了前面提到的从HLSL直接编译成SPIR-V,其实还可以从DXBC生成SPIR-V。这个难度比直接搞编译器小得多,但很可能做完之后发现HLSL->SPIR-V已经完善了。

SPIR-V->DXBC/DXIL

这个就更彻底了,连D3D上都用HLSL->SPIR-V,然后从SPIR-V转成D3D的DXBC或未来的DXIL。但这么做好处实在有限,并且损失了DXBC/DXIL的高效。换句话说,并没有什么原先不能解决的通过这样解决了,也没有什么原先能解决的通过这样解决得更好。

用GLSL/ESSL

这样就能都编译到SPIR-V,然后转成DXBC/DXIL。但问题和前面的一样,好处有限。

Vulkan取代OpenGL/OpenGLES

在未来某个时候,Vulkan一定会取代OpenGL和OpenGLES。到那时候,跨平台shader编译变得更简单了。

Vulkan

总结

本文整理了一下一个典型的跨平台shader编译需要需要走过的路程。到现在为止,我们其实离最终目标非常近了。在以后的KlayGE中,我希望能一步步补全这个拼图,让现有的shader编译系统更完善更高效。

Save