从Demo到Engine(四) -- Design a Material System part1
仅供个人学习使用,请勿转载,勿用于任何商业用途
作者:clayman
前言:材质,材质,材质,终于写到这个主题了。原以为一篇可以写完,结果写着写着就到2,3篇的篇幅了。与这系列的前几篇不同,文章中大部分设计和构想,都是我自己总结出的方案,并且还在不断改进中,难免有欠缺不妥之处,仅作为参考。文中所有代码均为伪代码。
在讲什么是材质系统之前,先看看什么不是材质系统。在论坛里看到过大量个人或者小团队写的引擎,宣称有强大的材质系统,实现了各种花哨的特效,再仔细看代码,每种特效都对应了一个特别的class。那么这就显然不是材质系统,更像一个材质库。对,材质系统不等于材质库。材质系统的目的是管理“所有”材质,为所有材质提供一个统一的容器。可惜人们常常把两者混为一谈,甚至本末倒置,开发引擎时把大量精力用于编写特效,却没有提供一个可扩展的框架。在我看来,特效甚至不属于引擎开发的范畴,而是游戏内容的一部分。那么材质系统应该是什么样呢?很简单,如果能用类似如下的伪代码管理所有特效,那么你就完成了一个材质系统:
mat.SetShaderParameter(); //update material parameter
mat.Apply(); //set material to device
嘿,这不是和DX里的Effect差不多吗?确实如此,因为两者都是以相同的目标来设计的。那为什么不直接使用Effect呢(这也是论坛里最常见的问题)。在我看来就算不考虑跨平台,Effect也有2个最重大的缺陷:
1. DX里的Effect既负责参数的管理更新,同时也是材质参数的容器。这显然违反了OO里的单一职责原则,带来的结果就是大大降低灵活性,这一点下面会讲到。
2. DX9下Effect更新参数的效率*非常,非常*低下。首先,Effect属于DX的扩展库D3DX,内部调用的仍然是SetXXXShader和SetXXXConstant这样的函数,并没有什么神奇之处。其次EffectPass.Begin会重新更新Effect中所记录的所有参数和渲染状态,不做任何冗余过滤。最后,CommitChange会重新更新某些没有发生变化的参数。处于性能考虑,任何有经验的开发人员都不会建议你使用Effect更新参数。DX10后续的版本中,由于引入了StateBlock和ConstantBuffer等概念,情况要好很多。
DX里的Effect完全就不可一用吗?当然不是,对于中小型项目来说,Effect仍然是一个很好的选择,可以节约大量开发时间。但对于引擎来说,就有几分力不从心了。如何编写一个更好的材质系统呢?从上面的伪代码来看,似乎很简单,但魔鬼尽在细节之中。
材质到底是什么呢?任何会引起物体表面视觉效果改变的属性都是材质的一部分,具体从程序角度来看,分为两部分:材质参数(颜色,反射折射率,纹理等等)以及Shader程序。知道了这一点,再看Effect的第一个缺陷,就更明显,他把两者紧密的绑定为一体,是一一对应的关系。但在实际应用中,对具有相同材质属性的物体,我们可以选择不同的光照模型(shader)来渲染,是一对多的关系,参数可以独立于shader存在。此外,参数是可变,易变的,是由上层游戏物体来决定的属性,不应该使用一个非常底层的类来储存。虽然用Effect也可以完成类似功能,但代码会非常丑陋,此外,把底层类暴露给上层结构也是非常不明智的选择。
再说一点关于材质创建 (主要是shader)的问题。 主流观点有两种,一种是传统的靠程序员全手写,另一种则是编写类似于Maya中HyperShader的Shading Tree系统,允许非专业程序员通过拖放节点,自动生成。显然,第二种方式看起来很吸引人,但我更倾向于前者,前段时间还在某论坛就这个问题和人进行了一些了讨论J。后者的优点并不是没有代价,编写这样一个系统非常复杂,很难在既保证灵活性的前提下又兼顾性能。更关键的问题是你需要一个自动化系统吗?对于Maya来说,主要用户是非程序员,因此,一个图形化创建系统是必须的。但对游戏开发来说,虽然有引擎宣称不懂编程也可以做游戏,大多是个噱头。特定游戏里会用到多少shader呢?不考虑变体的情况下,上百个已经是*非常*多了。考虑到shader程序通常不会太长,这个数量级,有经验的开发者编写起来并不会太费时间。对于各种shader变体,复制粘贴的方法也未尝不可。好了,用一周时间可以写完所有shader的情况下,是否值得花一个月时间编写一个你不知道是否可用的工具呢?更不要说以目前的水平,自动生成的代码完全不可能有手动优化的效率高。当然,由于DX11对动态生成shader有更好的支持,后者也许是未来的趋势。
回到材质系统的设计上来,前面说过应该分离参数和shader,最直接的设计就是创建两个类:
{
params;
metaMaterial;
}
Meta-Material
{
defaultParams
ShaderProgram;
}
Material储存每个物体不同的属性,和物体是一一对应的关系,也可以多个物体对应一个。Meta-material则主要用于保存shader以及默认参数,因此每种不同的meta-material只应该有一个实例。注意,以下类型都属于材质参数:数值(float, int, vector,matrix,array…..),布尔值,纹理,render state,sampler state。用这2类,可以编写类似的渲染代码:
foreach obj
{
material.Apply() //set per object parameter
DrawPrimitive();
}
简单吗?不,细节来了!Material.params应该保存哪些参数?Sampler state,render state(个别状态除外)通常来说属于shader属性,没必要保存在material中。Material最好只保存与物体表面属性相关的参数。由于可以随时改变meta-material,可能出现参数不匹配的情况,metaMaterial不一定用到material中的所有参数,也可能需要一些material中未定义的参数。如何实现Material.Apply()呢?
{
foreach parameter in material.paramCollection
material.metaMaterial.TrySetValue( semantic, parameter);
}
Material.Apply()
{
foreach shaderParam in material.metaMaterial.shaderParamCollection
shaderParam.SetValue(material.paramCollection.TryGetVale(shaderParam.semantic);
}
上面2种实现,一个采用push模型,一个采用pull模型,哪一种要好些?如果paramCollection中仅保存与defaultParam中不同的值,那么显然前者要好。但仅保存差异数据,会有一些副作用,比如:
defaultParam{ a = value1, b= value2};
Mat1{a = value3};
Mat2{b = value4};
如果先渲染mat1,那么渲染mat2时,默认的a值已经改变为了value3,但mat2仍然以为是value1。 一种可能的解决方案是添加PostRender函数:
foreach obj
{
material.Apply(); //set per object parameter
DrawPrimitive();
matrial.PostRender();
}
把修改过的参数恢复为默认值,但这也增加了程序代价。此外,还有第二个副作用,由于可以在任何时间修改替换metaMaterial,不同的metaMaterial会有不同的默认参数。解决这个问题,需要修改原来的mateial定义:
{
paramModifier;
MaterialTemplete;
}
MaterialTemplete
{
readOnlyDefaultParam;
}
MetaMaterial
{
shaderParams;
shaderProgram;
}
MetaMaterial.Apply()
foreach obj
{
if(currentMaterialTemplete != material.materialTemple)
materialTemple.Apply()
material.Apply();
DrawPrimitive();
matrial.PostRender();
}
未完待续,下一篇会展开讨论更多实现细节和性能优化........