又到了写年度总结的日子了,蓦然回首,发现这已经是写Blog的第四年了。较之去年,今年可谓相当高产了:12个月里产出11篇,四舍五入就是月刊了(笑。可见今年相当充实,每个月都整了新活。工作方面也已过一年,特作总结,以为归纳。
今年内容输出上最大的变化就是吹响了进军3D的号角,以完成一款俯视角3D ARPG Demo为目标,对各项技术专题展开了研究。特别在图形效果方面,由于现实的需要,已经到了不得不掌握的时候。结果没想到学习起来相当顺利,以前一直觊觎而不得的知识在现实需求的推进下顺利掌握,果然还是得知行合一呀。
当然能够如此顺利,个人想来也与前些年对这块一直觊觎的成果吧。毕竟概念的东西还是翻来覆去看过不少,只欠一次实践的机会罢了。现在想来也是颇为后悔,若是当年抽出点时间写写渲染器啥的那就更好了。不过也只是想想而已,没有明确的需求与觉悟下,只凭一时兴起是难以坚持下去的,只能把目光放在未来了。
接触这块对我来说算是进入一个新的领域,也因此有幸结识了不少朋友。通过相互交流学到了不少,增长颇多见闻。也因此让我正视以技术美术作为职业的可能性,目前按个人标准看来还是不行的:效果实践的广度仍是不足,对于DCC软件的掌握、美术工作的流程与体验还不够深刻,对渲染管线、图形原理的认识与实践也不够。总体而言感觉只是初中级水平,还有很长的路要走。
世人常言TA分程序向与美术向,尽管我并不是很认可:我认为懂美术的TA才是真的TA,而所谓程序向TA只是人才不足的权宜之计,或者是图形程序的过渡罢了。而我确实没有动力与天分去成为美术,可见若是要继续往这条路走下去的话,就得靠拢图形程序了吧。当然从现状而论,作为Gameplay程序兼职TA与策划也许是个不错的组合拳,这也算是独立制作出身的优势了吧(笑
照目前来看的话,下一年Demo的重心将会回归Gameplay,文章产出应该会少很多吧(Gameplay的东西没有经过时间的验证就没有说服力)。当然TA这块也会继续走下去,还是挺有意思的,希望能有更多结合实际的机会吧。
至于工作相关的内容限于篇幅与细节便在此略过不谈,以上便是本人的2020年度总结了,且待明年的Blog吧。
无双草泥马
2020.12.31
地形系统挣扎录——从Blender到Unity
欢迎参与讨论,转载请注明出处。
前言
之前由前篇决定场景的制作模式为Tile流,这种面片堆砌的流派对于方方正正的场景(如室内)很有效。但是对于如山地草原一般的场景就很难受了:
可能粗看下去感觉还行,实际上是不禁细看的,地形是在方方正正的基础上揉捏而成,可以在斜边处可以到明显的采样变形,且草地边缘的石边是手动暴力贴上去的,制作起来非常的耗时且嗨奴。经此一役后美术决定引入业界更通用的场景制作方案——Terrain流:
如上图所示,所谓Terrain流就是非常常见的那种在场景编辑器对模型揉揉捏捏塑形,涂涂抹抹上色的制作流派。对于制作地形复杂、贴图混杂的场景可谓相当受用。那么按理来说直接使用现有的地形编辑工具不就好了?——若是那么简单便没有本篇喽。
首先经过一番实验后发现,由于美术风格、建模习惯等因素,决定采用传统建模方式,而非这些场景编辑器惯用的揉捏平面生成高度图。如此一来这些场景编辑器便都Pass,将目光放到了传统建模软件·Blender上……于是便有了本文的副标题:从Blender到Unity。Unity版本为2019.4(URP),Blender版本为2.9。
牛刀小试
由于这波算是造轮子了,没得现有的套件可蹭,所以还是先整点最基本的实现吧。在抛开建模那块,Terrain流的贴图着色说白了就是由1张索引贴图+若干张地形贴图组成,通过工具绘制索引贴图,最终根据索引采样对应的地形贴图,实现贴图混杂的显示效果:
大致Shader实现如下:
|
|
可见实现原理并不算复杂,将贴图的每个通道(RGBA)作为贴图的透明度值显示即可,但一切才刚刚开始……
图集 OR 纹理数组
首先第一个问题便是贴图的管理方式:根据上文代码可以看出,目前的地形贴图是一张张独立的存在。那么就会变成有多少张地形纹理就要开多少个口了,不利于环保且哈批。业界相关流行的解决方法有图集(Atlas)与纹理数组(TextureArray)两种。
所谓图集便是将各种贴图整合进一张大图里,按偏移采样,是很常见的做法:
相关的采样方法可以参考冯乐乐的地形纹理合并,大致如此:
|
|
大致思路为根据要采样的贴图索引(index),结合世界坐标的小数点(pos),得到对应的uv坐标。其中这里的0.484375
、0.0078125
为采样收缩的魔法数字(0.0078125=1/128,128位图集的宽高,0.484375=0.5-0.0078125),这么做的理由与瓦片地图一致:由于贴图的密集性,线性采样两者的贴图边缘会产生混色现象,注意红色框选的部分:
为了解决这个问题,于是选择人工收缩了采样范围。但相对而言,在镜头放大后还是能看得出贴图之间并非是严丝合缝的,毕竟采样已经不连续了,算是一个不大不小的缺陷吧。
而纹理数组则是个PS4时代以来的新玩意,在Unity官方文档中明确了支持的平台,可见一般手机设备对此不见得能够支持。但我们做的是PC端便无所谓了,用起来用起来!
纹理数组的原理很简单:一次性把多张贴图打包成新的数据,与一般贴图用法无异,只是采样的API有所不同,需要指定index。这么一来可就我可就不困了呀,拿先前的Shader改造下对比:
|
|
较之图集那花里胡哨的采样方式真是爽快多了,而且还没有合缝问题。美中不足在于Unity并没有提供直接的创建纹理数组的方法,需要自己撸一个,限于篇幅便不再列出,给个参考便可。
传统UV OR 世界坐标
从图集的采样算法可以看出是不便使用模型本身的uv的,而是要用世界坐标作为驱动代替。但现在决定使用纹理数组,那么这便成了个问题。使用传统UV采样在多数情况下并没有什么问题,但是在这种情况下便暴露了:
如图所示,山峰出现了很夸张的拉伸现象,这是UV划分精度不足导致的(整个山峰的面采样了一张贴图),对此可以通过划分UV解决,但规模一旦上去后,这会给美术带来不小的负担。那么来对比下世界坐标的情况:
虽然在采样上有点小瑕疵,但的确是好多了。由于世界坐标是三维的,而纹理采样是二维的。若是只按某两个维度进行采样,在某些面必然会发生问题:
于是我们可以根据法线判定面的朝向以决定使用世界坐标的哪两个轴,但在某些斜面下实际上需要用到两个平面维度的结果混合。于是干脆一步到底,根据法线三个轴的值决定三个平面维度(zy、xz、xy)的混合度,是为三向贴图(Tri-planar Mapping):
|
|
但由上图也看得出来,在某些情况下它并非是完美的(或者没必要)。且性能消耗大,采样次数翻了三倍。由此可见两者皆有其使用场合,故决定通过分支开关控制两者的切换。当然最理想的情况自然是让美术好好分UV(
界限突破
讨论了以上两个问题后,还有一个很明显的问题:若是使用RGBA四个通道代表四种地形贴图的透明度,那么首先可使用的地形贴图数量就太少了,并且需要同时对这四张贴图进行采样,若是加上三向贴图更是达到了恐怖的12次采样!这绝对是不可接受的,仔细想来,实际上多数情况只会有两种贴图混合,加上Demo的像素风格更是不会发生太多的混合现象。那么便可以改成同一像素最多采样两种地形贴图的方案了:
|
|
如上所示,新方案下RG通道代表地形贴图的索引值,B通道作为两者颜色的混合度(255则完全显示color1,反之亦然),如此便可只用到三个通道的前提下支持多种地形贴图,同时只采样2次,哪怕加上三向贴图也算在可以接受的程度了。
在Blender的战斗
以上Shader看着简单,到了Blender这边的Shader开发那麻烦可大了。由于可视化的因素,我们需要用特定颜色代表对应的索引值,且要选择通道明确的颜色,也就是:黑(RGB)、红(R)、绿(G)、蓝(B)、黄(RG)、紫(RB)、青(GB)。鉴于黑色的特性,将之作为地形的默认底色。其余颜色根据深浅与黑色进行混合。其中的关键便是如何识别单/双通道颜色的有效性,对此我选择双通道颜色值必须相差小于10才有效,反之则是值大的通道有效。
这看着也不算很复杂,可恶的是Blender的新版渲染引擎Cycles
并不支持代码形式的Shader(OGL),只支持连连看。而老板渲染引擎Eevee
只支持在渲染模式下看到结果,这样便达不到实时修改预览的效果了。于是乎化身为毛线团战士:
主要Blender内置的节点并不支持分支判断,于是只能老老实实把每种颜色的处理都弄上去加一块,使用比较节点取得0值来屏蔽不该显示的部分。除此之外便是贴图资源不能作为参数值存在,只能老老实实创建贴图节点在外头进行填写,可由于三向贴图的加入,每种贴图还得手动填三次,算上总和一共是21种了……
不过所幸效果还是不错的,以后再有这样也许可以考虑看看源码了……
脚本转换
在Blender绘制的贴图还无法直接用于游戏,毕竟游戏可顶不住这样的Shader写法。于是便需要一Python脚本将之转换为游戏Shader可直接食用的贴图。实现大致思路与Blender Shader无差,只是在CPU端便可暴力条件判断了,爽歪歪:
|
|
当然为了图片的可视化,索引不会按照原值输出,而是return math.floor(index * 255 / 6)
,自然游戏Shader那边也要做相应解码处理。
除此之外,由于索引贴图的设计因素无法达到传统素材做法的线性采样效果,只能如二图那般马赛克:
为了减少这种马赛克的感觉,便使用脚本判断像素周边有多少不相同索引的像素,以此按比例减少透明度:
另外诸如设置图片输出宽高,指定颜色对应的索引值等功能限于篇幅在此便不展开了。
后记
本篇的内容有点超乎我想象,也到了收获的时候了,最后来看看成果吧:
在本次地形系统的调研中断断续续挣扎了一个半月,可谓把各种坑都踩了一遍。属实离谱,当然也与最近项目较忙有关。目前看来效果与先前并无太大差距,主要在创作模式多了新的道路,相关美术效果仍会持续优化,期待由此开端最终会演进到怎样的程度呢?
Visual Effect Graph魔改录
欢迎参与讨论,转载请注明出处。
前言
前文提到关于粒子想实现一些东西,本篇便来还愿了。Demo使用的粒子系统并非传统的Particle System,而是基于GPU的Visual Effect Graph,下文简称VEG。
VEG的问题与近年来Unity新出的模块一样:有些功能就做了个壳没下文了,我需要的光照探针功能便是如此。尽管VEG在编辑器做了支持,但实际上功能是没实现的:
所幸Unity近年来的模块有个好处:开源,通过Package Manager下载的模块其本身已经是开源可修改的,若是对其进行一番研究搞不好就能自己实现想要的功能,不必再苦等官方?答案是没错的,我如愿以偿为VEG增加了三个功能,并将之开源了。
研究
根据上图可以发现,VEG特效的构成实际上就是Compute Shader + 一般Shader,它们是由VEG编辑器生成的,双击可查看生成后的代码。由此可见,VEG实际上与Shader Graph差不多,都是通过编辑器进行创作,最后生成相应的代码。
由于先前的经验,这类源码的入手点自然是找到编辑器定义属性的地方。比如设置Shader效果的Output部分,顺藤摸瓜便很快找到了:
以此类推,便找到了生成Shader的相关处:
如此路线便打通了,以上得到了两个重要的信息:每个不同的Output类型(Quad、Cube、Mesh……)都会有对应的Shader模板,表示我们加料时也要考虑到多种类型的情况。其次是区分了Legacy
与Universal
两个文件夹,可见分别对应Built-in与URP管线,毕竟他们使用的Shader库并不相同。如此VEG能在老管线使用,并且在HDRP有新功能就能理解了。
接受阴影
目前对于VEG最迫切需要的功能便是接受阴影了,无论实时阴影还是光照探针,VEG目前都是没有的。好在按照上文的路线通读一番后,发现追加接受阴影还是蛮容易的。
顺着上文继续走下去,看到了主Pass下有个名为VFXApplyColor
的插入片段在各类型的主Pass都有用到,可见是通用的着色过程。那么在这里加入阴影着色正好:
通过搜索其他函数的出处找到了Shaders/RenderPipeline/Universal/VFXCommon.hlsl
,到了这里便是熟悉的Shader编写环境了,Include的文件都是URP那套,写就完事了:
|
|
当然这函数还不能直接用,根据观察其他函数还会在Shaders/VFXCommonOutput.hlsl
针对VEG的环境做一层封装然后写到VFXApplyColor
里即可:
|
|
当然还要提供阴影相关的multi_compile
,搜索代码得知加在VFXPassForwardAdditionalPragma
片段,然后就可以看效果啦:
光照探针
接受实时阴影算是完成了,但还要考虑到烘焙阴影的情况,于是对于光照探针的支持也要考虑到。VEG关于光照探针的外围支持已经完备(根据光照设置、探针设置开启相关KEYWORD),欠缺的只是Shader相关的部分。
在不考虑GI,按照我在模型渲染一样的做法的前提下,只需要在VFXApplyShadow
加点料即可:
|
|
与之前的做法一样,distanceAttenuation
在光照探针下会变成光照计算的着色值,将之锁定在阴影强度-1之间即可。然后烘焙阴影,设置特效组件开启光照探针即可看到效果了:
比较遗憾的是,光照探针的计算是以GameObject为准的,而非以每个粒子为准,这也是没办法的事,只能尽量避免露馅了。
关于阴影还剩最后一个点没有做:在编辑器的开关设置,这个模仿其他属性添加变量,并在VFXParticleOutput.cs
的additionalDefines
变量里添加相关KEYWORD,最后在Shader里做判定即可:
水面问题
目前粒子特效在水面上显示会出现很明显的层次错误:
火焰实际上并没有进入水里,但是看着却变蓝了。这是因为水面的渲染时机在所有对象之后,并使用CameraColorTexture
进行显示。而此时火焰已在Texture里了,于是与水重叠的部分便被水渲染处理了。
基于这个问题可以很迅速的想到解法:利用RenderFeature的RenderObjects可以新建渲染批次,并将粒子主Pass的LightMode
改为新的批次即可:
试了下效果,问题的确解决了,但是……
但是水里的火焰消失了,这也是当然的,毕竟在水面渲染之前,火焰还没渲染呢。进一步思考后想到了个绝妙的方案:为粒子新增一个与主Pass一模一样的Pass,也就是目前的ParticlePost,保留原本的主Pass,将LightMode还原。当然只是如此的话会出现一个粒子渲染两次重叠起来的情况,而我们可以让ParticlePost只在与水面重叠时显示,这样便可解决重叠问题了。
为此我们要用上模板测试,让水面写入特定的模板值,然后在ParticlePost做判定(假设水面写入值为2):
限于篇幅,关于添加Pass的做法还请自行查阅源码。看看效果吧:
很棒很棒,这下算是解决粒子与水面的问题了。尽管在水面时事实上是有重叠的,看着效果还行就凑合吧。由此延伸可以说是半透明对象与水面的一种解决方案了。
最后是编辑器相关,只能写死数值显然是不好的,这里我使用了VEG提供的定义代码段功能,在VFXParticleOutput.cs
的additionalReplacements
变量添加,并在模板里调用即可:
后记
VEG较之传统粒子最大的优势便是运算放在GPU以及开放源码可供修改了吧,可惜必须在支持Compute Shader的设备上才能运作。这一点注定它在手游里很难用得上了,只能期待老手机早日淘汰了……
在URP实现水面效果
欢迎参与讨论,转载请注明出处。
前言
Demo的场景也到了做水面的时候了,在涉及技术之前首先要确定的是美术表达:当然大体上也就是卡通水与写实水的抉择,最终决定是做出《伊苏:起源》
那样的写实水(注重扭曲、透视、无形变),并在此之上现代化。
上色
首先我们先找个小池子作为试验场地——这样利于观测,那么很显然密室场景的熔岩就可以暂退了:
水面的本质很简单,它就是个面片而已(不论海浪)。最直接的第一步自然是上色:
上色之后自然是透视,把材质设置为Transparent
,调整下透明度:
很好,其实对于一些游戏的低画质,这个水面已经是成品了。当然这也太捞了,继续演进——
扭曲的准备
对于水面效果的重点自然是扭曲了,处于水中的部分都会因为光的折射而变化。当然我们实际做起来并不会遵照这些大道理,看着是那么回事就得了(图形学第一定律)。最简单的做法自然是把对象渲染完毕后的画面截获,水面材质再选取合适的画面部分显示,并基于此加入扭曲——
对于Built-In管线而言,想做到这点使用GrabPass
即可,这方面的实现在《Unity Shader入门精要》已有详细做法。可是由于其设计不符合SRP的哲学,在URP已经被毙了,于是我们只能另寻他法了。
当然实际上也没那么麻烦,思想已经有了,找到对应的实现方法即可:对象渲染完毕后的画面生成在URP可以通过管线设置文件勾选Opaque Texture
实现,然后便可在Shader声明_CameraColorTexture
调用。
当然仅仅如此会有个问题:此图的生成时机是渲染所有非透明(Opaque)对象后,对于具有透明度的对象(Transparent)的渲染时机是在此之后的,这样水面里将会看不到Transparent对象了。对于此有两个解决方案:
- 修改源码,将生成时机调到Transparent渲染之后。
- 利用
RenderFeature
自行在合适的时机生成画面Texture。
经过项目实际情况的考虑,我选择修改源码(具体修改在MyURP)。在Frame Debugger
可以看到渲染时机已经变为Transparent之后了:
做到这步只能算是准备好了子弹,接下来还要制造枪械:由于自带的Shader Pass的渲染时机并不在生成_CameraColorTexture
之后,所以我们需要利用RenderFeature
构建个渲染时机生成之后的环节。这里直接使用URP自带的Render Objects
即可满足:
如此只要Shader里Tag名为Grab
的Pass,都将会在此RenderFeature
进行渲染。接下来便是完成Shader:
|
|
Shader实现与一般形式十分相似,主要在于用上了_CameraColorTexture
以及ComputeScreenPos
函数,看看效果先:
看得出效果还是有所不同的,毕竟现在水面显示的不再是一层半透明蓝色了,而是原有画面的基础上调色。现在万事俱备,只欠东风了——
扭曲的实现
实现扭曲我们需要一张表达水面的法线贴图,或者噪声贴图也行。本质上是偏移UV,以产生扭曲的结果。我选择使用法线贴图,因为后续也有用到。
水面法线贴图的生产我并不了解,目前是随便找张不规则图形的基础上使用Unity自带的Create from Grayscale
生成的,效果居然还不错:
应用起来也很简单,获取法线贴图的xy数据加到screenPos.xy
即可。当然仅此而已的话水面是不会动的,所以我们还可以加个与时间挂钩的偏移值,以推动法线贴图的uv,便可产生动起来的效果:
|
|
不错不错,对于某些游戏而言,到了这步也算完成了。但还不够——
着色
目前有一个很明显的不足:虽然有了扭曲,但水面还是平平的一片蓝色,显然是缺乏明暗的体现。此时先前的法线贴图便可再次派上用场了:结合法线来做漫反射(Diffuse)效果。当然我们还不能直接使用取得的法线,还得将其转换至世界空间才行。
|
|
这里漫反射用的是半兰伯特(Half-Lambert),这是为了保证水面的亮度足够,看看效果:
嗯,有点味道了。再加个高光看看吧:
|
|
越来越有味了,不过感觉这种高光不够突出光点,加个Step试试:
|
|
不错不错,就这样吧,到实际场合看看。
反射
目前的效果如上,总的来说算是OK了,但感觉还是差了点什么……没错,就是反射。起初我很自然而然的脑补认为要让周边的岩石草木投射在水面,为此我尝试了各种方案(反射探针、反射摄像机、平面反射……)都不满意,最终发现这纯属脑补了。实际由于视角原因是达不到那样的效果的,能够反射的内容基本会与折射重叠。醒悟之后发现最合适的反射内容只有纵身跳入的人物以及天空罢了:前者的出现场合太少了,对于后者与其用各种反射手段,还不如直接弄张天空贴图完事。
|
|
弄了张天空贴图,结合扭曲所用的偏移值进行uv移动,使用_Fresnel
控制反射与折射的比例。注意这里的_Fresnel
仅仅是个0-1的参数,并非是真正的菲涅耳系数(由于视角关系根本用不到)。来对比下吧:
这样的假反射在美术上的意义主要是能让水的颜色没那么单调,并且由于贴图是移动的,也带来了更多的动感。
后记
最后加上点互动特效,有那么点意思了:
在加这波粒子特效时也遇到了不少问题,也多了一些想要实现的东西。限于篇幅只能留待日后了。
在Demo实装光照烘焙与探针
欢迎参与讨论,转载请注明出处。
前言
Demo目前的实时光影虽已完成,但考虑到不同的配置设备,还是得做出不同档次的光影方案。那么烘焙光照(Lightmapping)与光照探针(Light Probes)就免不了了。本文将结合项目实际需求,讲述遇到的问题及解决方案。项目引擎版本为2019.4,渲染管线为URP。
初步的烘焙
首先要做的自然是对Shader增加光照烘焙与探针的支持,照抄URP的SimpleLit Shader即可。大致要点如下:
- Shader添加multi_compile:
LIGHTMAP_ON
与_MIXED_LIGHTING_SUBTRACTIVE
,这表示Shader会参与光照贴图与混合光照 - 顶点着色器参数添加
half2 lightmapUV : TEXCOORD1;
,这是光照贴图的UV - 片元着色器参数添加
DECLARE_LIGHTMAP_OR_SH(lightmapUV, vertexSH, 1);
,这是URP自带的宏,根据LIGHTMAP_ON决定配置光照烘焙或探针的参数(lightmapUV or vertexSH),最后的参数1
决定是第几个TEXCOORD - 在顶点着色器调用
OUTPUT_LIGHTMAP_UV(lightmapUV, lightmapScaleOffset, OUT)
与OUTPUT_SH(normalWS, OUT)
宏,它们将根据情况配置lightmapUV与vertexSH - 在片元着色器对光照贴图或探针进行取色(
SAMPLE_GI(lmName, shName, normalWSName)
),最后将之加入到着色环节即可 - 若是想要烘焙模式下也能接受实时阴影,记得调用
MixRealtimeAndBakedGI
函数
总的来说都封装好了,照着拼凑而已。那么事不宜迟,直接按照默认的烘焙配置整个看看,记得要将GameObject的Static里的Contribute GI
勾选方可参与烘焙:
看着似乎还不错,那么对比下实时看看吧:
这么一看还是有不少差距的,必须要让烘焙与实时的效果高度接近才行呐——
ShadowMask
经过与烘焙设置一番斗智斗勇后,我发现我要的仅仅是让阴影烘焙,以节省阴影的运算罢了。什么全局光照、烘焙自带的着色等等都是不需要的。为此我尝试过不少骚操作:生成光照贴图后进行二值化处理、直接在Shader对烘焙色进行处理等……可惜这些方案都只是治标不治本,要么在流程上繁琐,要么性能不佳,要么无法应对所有情况。最终我把目光放在了烘焙三模式之一的ShadowMask,它将单独生成阴影贴图,那么若是我只用它,抛弃光照贴图,便可达到目的了。
不幸的是,URP并没有支持ShadowMask,官网显示仍处于In research状态。幸好网上有其他人做了实现ShadowMask的教程,顺便也学习了一波可编程渲染管线(SRP)的基础知识。经过研究发现,ShadowMask的添加并不复杂,甚至可以说是URP主动将之关闭了(严重怀疑是故意拖到后面做,显得有活干)。当然这么干了之后就表示需要维护自己的URP版本了,顺便将之开源了。
SRP本质上是开放了一个可供用户定制的表层,多数核心功能还是封装好的。ShadowMask也不例外,其生成附属于烘焙模块。我们要做的只是添加一些设置,以及相应的Shader支持罢了:
|
|
Shader方面要做的调整也不多,URP本身自带ShadowMask的贴图变量TEXTURE2D(unity_ShadowMask);
,其UV与光照贴图一致,复用即可。记得在Shader添加multi_compile SHADOWS_SHADOWMASK
以判别是否处于ShadowMask模式下。
|
|
大致要做的事情就这么多,烘焙设置除阴影方面外,能怎么快就怎么设置(反正也用不上光照贴图了),一般来说需要注意的有Bounces
要设为1,不然阴影会不完整。Flitering
将对阴影贴图做边缘柔和处理,Lightmap Resolution
与Lightmap Size
决定阴影质量,参考如下:
另外需要注意的是,阴影精度很大程度上取决于模型的大小,因为一个模型只能有一张光照/阴影贴图,在贴图大小定死上限的前提下,模型越大贴图的解析度自然越低。那么来看看效果吧,图一为实时,图二为烘焙:
效果可以说是高度接近了,干掉了光照贴图后着色变得完全一致,阴影贴图在合理的设置下也达到了高度接近实时的效果。坡肥!
光照探针
现在虽然实现了高度接近实时的阴影烘焙,但显而易见,当人物走向阴影处便会是这样的结果:
在某些游戏也许不太理会这种现象,但这也太捞了,光照探针便是为了解决这个问题而生的。通过在场景布置探针,将会根据动态对象附近的探针取色决定明暗度:
光照探针如果要手动布置那实在是太麻烦了,于是我使用了这个插件,通过简单的设置暴力的去平铺一波:
根据官方文档说法,探针数量与性能成反比(但越多越精确)。但此插件平铺并不会把探针置于模型内部,以及对比了下《使命召唤手游》的光照探针,感觉还行:
Shader方面没什么要改的,在URP获取光照函数GetMainLight()
本身自带了对光照探针的着色处理(附加在light.distanceAttenuation
中),由于不需要用到全局光照,之前的OUTPUT_SH
之类的都可以删了。当然有个现象需要注意下:
可以看到在暗处时实在是太黑了(也许是放弃了全局光照导致),于是我们加个约束,将暗值约束在光照Strength到1:
|
|
很好,这下可以说是大功告成了!
后记
最后演示下不同光影品质下的差别吧,分别为低、中、高:
话虽如此,可我发现目前直接把高品质光影扔到iPhone8下居然稳定59帧,太强了……
在URP实现局部后处理描边
欢迎参与讨论,转载请注明出处。
前言
最近在Demo开发的过程中,遇到了一个细节问题,场景模型之间的边界感很弱:
这样就会导致玩家难以分辨接下来面对的究竟是可以跳下去的台阶,亦或是要跳过去的台阶了。我们想到的解决方法便是给场景模型加个外描边,以此区分:
整挺好,于是本文就来介绍一下实现思路。首先按照惯性我们直接采用了与人物相同的法线外扩描边,但是效果却不尽人意:
这完全就牛头不对马嘴,既然老办法不好使那就看看后处理描边吧。不过由于Demo使用的渲染管线是URP,在后处理这块与原生完全不同。于是乎再一次踏上了踩坑之旅……
另附源码地址:https://github.com/MusouCrow/TypeOutline
RenderFeature
经过调查发现,URP除了Post-processing之外,并没有直接提供屏幕后处理的方案。而URP的Post-processing尚不稳定(与原生产生了版本分裂),所以还是去寻找更稳妥的方式。根据官方例程找到了实现屏幕后处理描边的方式,当然它们的描边实现方式很搓,并不适合我们项目。于是取其精华去其糟粕,发现了其实现后处理的关键:RenderFeature。
RenderFeature系属于URP的配置三件套之一的Forward Renderer,你可以在该配置文件里添加想要的RenderFeature,可以将它看做是一种自定义的渲染行为,通过CommandBuffer提交自己的渲染命令到任一渲染时点(如渲染不透明物体后、进行后处理之前)。URP默认只提供了RenderObjects这一RenderFeature,作用是使用特定的材质,在某个渲染时机,对某些Layer的对象进行一遍渲染。这显然不是我们所需要的,所幸官方例程里提供了我们想要的RenderFeature——Blit,它提供了根据材质、且材质可获取屏幕贴图,并渲染到屏幕上的功能:
|
|
如此这般便实现了经典的反色效果,只要引入Blit的相关代码,然后在Forward Renderer文件进行RenderFeature的相关配置,并实现Shader与材质,即可生效。较之原生在MonoBehaviour做这种事,URP的设计明显更为合理。
Outline
后处理部署完毕,接下来便是描边的实现了。按照正统的屏幕后处理做法,应该是基于一些屏幕贴图(深度、法线、颜色等),使用Sobel算子之类做边缘检测。然而也有一些杂技做法,如官方例程以及此篇。当然相同的是,它们都需要使用屏幕贴图作为依据来进行处理,不同的屏幕贴图会导致不一样的效果,如上文那篇就使用深度与法线结合的贴图,产生了内描边的效果。然而我们只需要外描边而已,所以使用深度贴图即可。
深度贴图在URP的获取相当简单,只需要在RenderPipelineAsset文件将Depth Texture
勾选,然后便可在后处理Shader通过_CameraDepthTexture
变量获取:
有了深度贴图,那么接下来逮着别人的Shader抄就完事了——然而那些杂技做法的效果通通不行:官方的更适合美式风格,上文那篇的做法在某些场合会产生奇怪的斑点。于是只好按照《UnityShader入门精要》的写法来了:
|
|
很棒,但是可以看到,身为一般物件的方砖也被描边了,可我们想要的只是场景描边而已——于是进入了最后的难题:对特定对象的后处理。
Mask
首先我们参考原生下的做法,利用模板测试的特性,对特定对象的Shader写入模板值,然后在后处理时根据模板值做判断是否处理,确实是个绝妙的做法——很可惜,在URP下我找不到能够生效的做法。根据上文那篇需要渲染出深度法线结合的屏幕贴图的需要,作者实现了一个新的RenderFeature:根据渲染对象们的某个Pass,渲染成一张新的屏幕贴图(可选择使用特定的材质,若不使用则是Pass的结果)。并可作为全局变量供后续的后处理Shader使用。我将之命名为RenderToTexture,这也是后处理常用的一种技术。
有了这个便有了新的想法:为所有渲染对象的Shader添加新的Pass(名为Mask),该Pass根据参数配置决定渲染成怎样的颜色(需要描边为白色,不需要为黑色)。如此渲染成屏幕贴图后便可作为描边Shader的参考(下称Mask贴图),决定是否需要描边:
注意要为Mask贴图的底色设置为非黑色,否则与底色接壤的物件会描边失败。那么见证成果吧:
|
|
很棒,这下一般物件不会被描边了,局部后处理描边完成!当然随后遇到一个新的问题:
这是因为透明(Transparent)模式下的对象按照通用做法是不会写入深度信息的(为了透明时能看到模型内部),然而我们描边需要的正是深度信息,由于树叶没有写入深度信息,所以在描边时当它不存在了,于是产生了这样的结果。解决方法也好办,在透明模式也写入深度信息(ZWrite)即可,毕竟我们的透明模型不需要看到内部,一举两得。
后记
其实期间还产生了投机心理,想着把角色自带的描边给废了,统一后处理,岂不美哉?很可惜搞出来的效果始终是不满意,法线外扩 is Good,没办法喽——
顺带一提,对于后处理的贴图创建记得将msaaSamples
属性设为1,否则就会进行抗锯齿处理,那可真的炸裂……
3D瓦片地图采坑录
欢迎参与讨论,转载请注明出处。
前言
由于Demo的场景风格主打像素风格(这里的像素风格指具备一定精度的风格,而非时下流行的马赛克),故决定使用瓦片(Tile)地图来实现。也就是这种东西:
游戏地图将由一个个规范化的单位图片拼接而成,是为瓦片。在早期的像素风格游戏可谓相当流行,因其构造成本低(无论从技术上还是美术上),而却能灵活拼接出各种各样的地图。于是我们也打算如此,但是在3D游戏下搞瓦片地图确是罕见得很。于是便开始了采坑之旅,遂成本文。
Sprytile
首先我们想到了在3D游戏下最接近瓦片地图思想的方案:Terrain,一般3D大地图都用类似方案:对一张平面地图进行各种揉捏形成地形,并在此之上涂抹各种图素。但可惜对于瓦片地图而言未免杀鸡用牛刀了,且在各方面都不能做到最适化,遂不考虑。
其次便是Unity官方自带的2D Tilemap Editor,对瓦片方面的需求倒是满足了,可惜3D瓦片并不只是在一张平面上进行,而通过多张平面旋转组成场景未免勉强,遂放弃。
既然在Unity这边行不通,那么便考虑生产端的Blender有什么合适的插件了。果不其然找到了Sprytile,一看就明白,就是它了:
使用准备好的tile图集,划分格子,在Blender以格子为单位进行填涂,每个格子将会是一个面,支持XYZ三个平面进行,在填涂完成后可作为正常模型进行各种操作。一切看起来是那么的美好,然后一路到了游戏后……
过滤之殇
以前篇的截图便可看出问题所在:
可以看到地板之间存在奇怪的黑线:
根据插件文档提供的Unity导出说明来看,必须要将地图贴图的过滤模式设置为Point
,即邻近过滤:
嗯,黑线果然消失了。那么问题便出在图片的过滤模式了,回去复习了下纹理过滤,答案昭然若揭:
由于瓦片地图的习惯会将相关图素集成一块,形成连续的图块:
那么在线性过滤下,图块的边缘像素在采样的时候将会混进相邻图块的颜色,于是那些奇怪的黑线便是这么来的。
也许读者会认为:不用线性过滤不就得了,毕竟插件作者也是这么认为的。可惜我们做的风格并不适合用邻近过滤,那将显得与人物画风差异过大且马赛克:
细心观察上图底部,这是开启抗锯齿(MSAA)导致的。查阅资料后发现这是MSAA的特点导致,如此哪怕我们想靠邻近过滤解决也是没门……当然也考虑过更换为其他抗锯齿的方式,但是效果都不甚喜人,于是开始寻觅解决之道。
无奈的解法
首先考虑对tile图片进行下手:既然边缘会采样到相邻图块的像素,那么将它们隔开不就得了,如此:
当然这么做的话要让Sprytile有所支持才行,好在它是开源的。Blender的插件改起来也还算容易,毕竟会在界面提示标识好功能函数名。结果还是翻车了:
瓦片之间出现了奇怪的透明点,仔细一想便明白了:透明像素一样会被采样到,所以会影响到透明度。于是迅速想到下个方案:让每个图块外扩边缘1像素,这样边缘的采样只会采样到相同颜色的像素:
很不错,黑线什么的都消失了,哪怕是开着MSAA。同时美术也在Blender琢磨出了一种方法:在不做任何处理的贴图的基础上,将瓦片地图模型的UV全部缩小一点(参考值:0.96),如此便不会直接采样到边缘像素,从而以牺牲了一点点边缘效果解决了问题。
两者的效果最终是差不多的,但都是不完美的,在编辑器里便一目了然:
可以看得出,格子之间的边界可谓泾渭分明,这是因为每个格子都是独立的mesh,它们并不是作为一个整体去渲染,也就不存在视为一个整体去采样。也就无法达到真正意义上2D游戏里要达到的效果(格子之间彻底融为一体)。要这么做有两种方式:第一种是根据瓦片地图的填涂情况最终生成大贴图与新模型,但这种方式相当不优化,并且会急剧增大包体。其次是仿照以前的2D游戏做法:游戏本身使用邻近过滤渲染,最后将渲染成图进行拉伸放大(放大方式采用线性过滤),但这是牺牲画面分辨率带来的。
经过以上总结可以看出,基本不存在非常完美的解决方案,只能矮子里拔大个了:UV缩小的方案从工序上最为简单,且显示效果也能接受(没对比过原版基本看不出太多异样)。
后记
一开始我们觉得这种瓦片地图1个格子就占2个三角形,面数会不会太高了。结果在参考其他游戏的情况时发现《闪之轨迹3》的一组垃圾桶的面数……
嗯,一组垃圾桶的面数都完爆我整个瓦片地图模型了,法老控牛逼!
顺带一提的是,刚才提到的格子边界问题在Unity官方的2D Tilemap Editor也是存在的,只能说是瓦片地图的局限性了,好在离得远也看不太出。
Demo的卡通渲染方案
欢迎参与讨论,转载请注明出处。
前言
本篇文章按理来说在三月便该发布了,因为插队原因延宕至今,不过好饭不怕晚,干就完了奥利给!阅读本文最好拥有一定的图形学知识,当然看个热闹也是好的。
游戏画面的风格是一开始便要定下的大事,这在古法2D主要通过素材本身及后期调色决定,没有太多文章可作。而在现代游戏(尤其是3D)则会通过Shader在原本的元素上进行加料,如通过基于物理的渲染(PBR)将模型凸显出金属、石头、布料等材质倾向。而在早期为了凸显3D模型的立体感,一般会采用经验总结出来的冯氏光照模型(Blinn-Phong),这也是许多3D软件的默认方案,那将会让我们的模型长成这样:
嗯,这有够雕塑风的,让我想起了当初名震一时的猴赛雷,有着异曲同工之妙:
由此可见,对于讲究卡通风格的游戏,这种通用的光照模型肯定得枪毙,于是本文才会诞生。对于这类非写实方向的渲染方案,业界称之为NPR。而往下细分则是日式卡通渲染,其中佼佼者当属《罪恶装备》系列,而《崩坏3rd》也是不少人在这方面的启蒙者。当然美术这一块没有绝对的风格一致,渲染也不例外,所以Demo里的卡通渲染方案乃是个人的方案,不代表业界的标准实现与效果。
Demo基于Unity2019.3开发,渲染管线为URP7.3.1,采用直接编写Shader的方式(HLSL),将一一介绍其中要点。本文所谓的卡通效果以日式2D赛璐璐风格为准,不论厚涂之类的风格。
着色
首先我们先抛开一切:冯氏光照不好那咱们就是了。直接把贴图显示了,什么料都不要加。
|
|
嗯,虽然很原始,但好歹没那股恶心感了,把投影也加上:
|
|
哎呀,有了投影瞬间立体起来了,开始有《塞尔达传说:风之杖》内味了:
要加投影记得添加Pass:ShadowCaster,并且获取光照信息也需要开启一定的宏,这些并非本文重点,详情请查阅URP的Shader实现。
但只是如此还不够:颜色太鲜艳了,看久了会累。那么有两种方案:调色与着色,调色则是进行总体的颜色调节,使之不要这么鲜艳,着色则是根据模型面对光的吸收度决定明暗。这里还是选择着色:它将会增强模型的立体感。
这里说的着色其实就是冯氏光照中的漫反射(Diffuse):当光照射到非平面的物体上,将根据与光的夹角决定吸收度(越是与光垂直的面越亮)。而在3D模型中,每个模型面都会往上发射一条射线,也就事实上构成了一条垂直于平面向量,这在数学中称之为法线(Normal)。我们可以使用向量点积(Dot)获取法线与光照方向之间的夹角,以此决定模型面的光亮程度。
|
|
啊这,这不是跟一开始差不多么?这是当然的,因为一开始便是冯氏光照的方案。其漫反射的思想其实并无问题,但原罪在于过渡太丰富了,每个模型面与光的夹角都不同,导致颜色都不同。整个模型看起来就过于立体,以至于产生了雕塑感。
而在日式2D卡通的世界里(尤其是赛璐璐),着色并不会有太详细的过渡,只是到了某个角度统一涂暗,反之为亮,最多在两者之间加点过渡而已。那么便基于此思想进行改造即可:
|
|
还不错,这下便为模型划分了明暗,并在两者之间做了过渡,这种方式称之为二值化。着色并没有采用很明显的暗色,只是想凸显一点立体感,以及让画面更柔和,不那么刺眼罢了。当然目前可以说是非常不明显了,这是有原因的,且待后续调色。
描边
接下来需要补上日式2D卡通不可或缺的一部分:描边(Outline),描边有助于划分物体,明确空间上的层次,并提供一定的风味。
关于描边的实现方式,业界主要有模型多画一遍并将边缘外扩以及屏幕后处理的方案。前者方案在日式游戏较为流行,优点在于实现简单,性能也还算过得去,缺点是必须开抗锯齿不然没眼看。后者实现方式多样,并且根据实现方式能达到不一样的效果(如一定程度的内描边),但有些更适合搭配延迟渲染(Deferred Rendering),而这代表着对显卡带宽与光照方案有要求。
另外在显示方案上也有区别,有追求任何缩放下描边大小不变的,也有自然派的。有让描边纯色的,也有要让描边根据贴图颜色决定的。本人采用的是模型外扩、自然缩放、根据贴图颜色决定的描边方案。
多显示一遍模型在Unity增加一个Pass即可,并且开启正面剔除(只显示背面,不然会干扰到正常模型)。并且在顶点着色器对模型顶点进行外扩,外扩的方向由所在模型面的法线决定。而颜色方面则在片元着色器根据贴图颜色进行置暗显示即可:
|
|
|
|
果不其然,没有抗锯齿的话就很搓,跟早期的跑跑卡丁车似的。安排一波MSAA8x:
哎,这就舒服多了,当然实际上由于小泥人的关系,4x和8x实际上看不出区别,而2x也算可以接受的效果,这么看来能耗也还好。当然关于描边实际上还有内描边这个大题,但小泥人不需要这么丰富的细节,这就很舒服。
发光
目前模型的显示还欠缺一些发光的元素,如一般头发和武器会有一些高光效果。这在冯氏光照称之为镜面光照(Specular):本质上与漫反射一样,只是由视角方向与光照方向相加,并与法线做点积获得两者的夹角系数,如此便可实现根据摄像机与光照运动结合决定模型高光的位置。
当然仅此而已是不够的,显而易见仅此而已的话将会如漫反射一般范围很大,而高光实际上只需要一点即可。实际上会将之范围缩小:
|
|
与之前一样,这样的高光过渡太强了,不够卡通,将之二值化:
|
|
这样的高光就更有手绘的感觉了,牛屎一块。但很显然对于头发而言光是一块牛屎高光是不够的,让美术自由的进行创作显然是更好的方案。于是引入了发光贴图(Emission),其本身很简单:就是在最后把发光贴图的内容显示出来即可。而之所以要单独划分贴图而不是画死在原贴图,在于要自由的控制透明度甚至曝光,以及让发光参与单独的光照运算(与高光类似的方法,摄像机视角与光照方向相加后与法线点积)。
到了目前仍缺一个日式2D卡通的一个特性:边缘光(Rim),一般为了表达物体处于光亮的环境下,属于光溢出的一种表达,有助于提升画面的层次感。实现原理也很简单:视角方向与法线点积,根据夹角系数取得当前视角下的模型边缘部分,为之加光即可。
|
|
发光的构成大致如此,目前也许看起来不够明显,实是尚未调色所致,且看下文。
调色
先来看看目前的效果:
首先是整体颜色风格不符合主题,这个场景属于有着岩浆的密室,应该符合昏暗以及灼热的色调,使用Split Toning进行调色:
嗯,至少色调上像样了,但还是缺乏灼热的感觉,上Bloom看看:
哎呀,看着只是稍微亮了点的样子,那是因为Bloom需要配合HDR使用,将颜色突破0-1的限制下进行运算,才能做到光溢出的效果:
唔……这溢出的实在是有限,因为目前还处于Linear颜色空间,显示器对于颜色会进行处理,使得颜色之间的区间变小(明暗不明显),需要转成Gamma才能抵消之:
成了,如此便得出了昏暗且灼热的场景风格,高对比度(亮者更亮、暗者更暗)的画面。
后记
这算是本人进入图形渲染的一个里程碑,感觉这的确是个美术活。技术不过是让你能进入赛道罢了,真正决定效果的还得看美术的理念。
移植贪吃蛇——从C#到C++
欢迎参与讨论,转载请注明出处。
前言
因为某些机缘巧合,引起了我对C++的重视。一时兴起,决定将两年前用Unity写的Snake进行移植。经过两周的抽空,总算是完成了。项目采用现代C++标准编写,采用CMake构建,图形库为SDL。由于本次的重点不在于图形这块,所以没有使用原版的素材,采用矩形代替。
在工程实现上除了基本的业务外,还实现了C#的event以及的Unity的GameObject与Component。
本文将从C#开发者的角度出发比较C++的不同点,最后总结其思想。由于本人在此之前从未有C++的工程经验,对于许多特性在此之前也是一知半解,对于一些事物的理解若有误还请指教。
低成本封装
首先最引我瞩目的便是C++的参数传递,形如这般的函数:
由于C++的引用参数string&
性质,将值传入时不会发生拷贝,而是等于直接使用原变量。可以有效降低封装抽象的成本,加上const
字段是为使得形如"123"
这样的常量区对象也能传入。
当然这在C#也并不是没有,ref便是如此。但这在C#并不会下意识去用,毕竟在C++若是不用指针或引用作为参数的话可是会直接拷贝新对象的,而在C#直接使用也不会造成很大的负担(值类型直接拷贝,引用类型用指针)。
其次便是C++的内联函数了,作为函数宏的替代品之一。可以在编译时将函数展开为具体的内容,节省了一次函数调用的消耗。但内联函数需写在头文件中,若是关联项多,修改后便会增加编译时长。且展开量过大也会增大代码量,增加编译时长。但不失为一个降低封装成本的手段。
明确的内存
其次与C#最大的不同便是对象的创建了,C++有着以下两种形式:
了解C++的自然晓得,前者在当前内存域下申请,后者在堆申请。而在C#则隐去了这个细节,而是设立固定的规则:
- 引用对象使用指针,原则上在堆申请,若对象的生命周期存在于申请的函数里,则在栈申请——是为逃逸分析。
- 值对象在当前内存域下申请,且由于不是指针,变量传递会产生拷贝。除非使用ref、in、out等参数关键字。
而C++的内存申请机制则带来了明确感,如在函数里申请生命周期只存在函数里的对象,需要明确的使用A a = A();
方式。且在构建类的时候,对于那些不使用A* a = new A();
创建方式的成员变量,其内存占用是明确的,在类对象申请内存的时候会一并申请,即这些成员变量在内存布局上可能是连续的。从这点来说可比C#要牛逼多了。
相似的容器
在容器方面,C++与C#大体看起来是相似的,当然在API的爽度而言还是C#更胜一筹(C++17拉近了不少)。但实际上还是存在一些细节上的不同,就比如我们常用的Key-Value容器:C++的std::map与C#的Dictionary在实现乃至功能上就不一样。实际上std::map对应C#的应该是SortedDictionary:它们都是基于红黑树实现,都是有序存储的表。而Dictionary则是基于哈希实现的,即我们俗称的哈希表,与之对应的是std::unordered_map。
通过命名能看出两种语言在这方面的倾向性:红黑树占用的内存更小,但查找和删除的时间复杂度都是O(logn),而哈希查找和删除的时间复杂度都是O(1)。实际使用的时候感觉还是得权衡利弊,不能贪图方便就一直用一套。std::set与HashSet这边也是类似的对应,以此类推。
在序列容器方面的对应倒是工整:std::vector对应List,都是不断扩容的数组容器。链表方面则是std::list对应LinkedList。但std::array却无对应了,硬要说的话就是与C#的原生数组对应,毕竟这个容器出现的意义就是弥补与C语言兼容的原生数组。
顺带一提,在使用std::vector时由于会出现扩容复制的问题,需要考虑好成员对象的拷贝方案,乃至于内存泄漏的问题。
智能的指针
内存管理是所有编程语言都无法绕开的点,绝大多数编程语言对于堆内存的管理都是采用垃圾回收的方式。而在C++的鸿蒙时代则与C语言一样,需要手动管理指向堆内存的指针。尽管也有std::auto_ptr这样的东西,但在功能上还不够全面。而手动管理内存将难以解决对象在多处被引用时将如何安全销毁的问题,为了实现这种机制也得做出不少妥协。
所幸随着时代的发展,现代C++迎来了智能指针,它基于引用计数的规则,将裸指针包装起来,当符合销毁条件后便可自动回收。智能指针有着几种具体的类实现,而其中最常用的是std::share_ptr,当它持有指针时将增加计数,反之同理将减少计数,最终归0销毁。但其较之垃圾回收有个致命的缺陷:相互引用时将一直保持计数,无法销毁。为此C++引入了std::weak_ptr:它不会增加计数,在计数归0时持有指针也随之销毁。如此对于相互引用的情况下,分清主次,合理分配share_ptr与weak_ptr即可解决无法销毁的问题。
智能指针在使用上总有一种外挂的感觉,需要成体系的去使用。不如内置的垃圾回收式语言来的方便,且写起来还是有一定的心智负担(相互引用),不过在性能而言较之垃圾回收更为优越(回收对象与时机都很明确,且是被动进行的)。
模板与泛型
C++的模板与C#的泛型表面上用起来很是相似,实则有所不同。以下对比两者的差异:
|
|
|
|
从实际使用体验与两者的命名可以看出,「模板」的本质是参数化代码生成,而「泛型」则是类型参数化。即泛型只是模板功能的一部分而已。模板能实现的其他功能,在C#则以其他方式代替了(如变长参数params)。
后记
从以上种种便能看出C++与C#在设计哲学上的不同,C#通过约束开发者行为从而达到更稳定健壮的结果,哪怕会失去一定的性能与灵活性,而C++则更依赖开发者自身的素质(如C++支持多重继承而C#仅仅支持单类+多接口继承)。
从个人的使用体验来看,现代C++并非不能作为业务开发语言。只是对开发者的素质要求较之一般语言更高,从招聘成本与项目稳定性而言是个问题。如此来看,除非有必要的性能敏感且需要一定封装的核心层(如游戏引擎),否则用C + 脚本语言或者C#/Java这类可上可下的语言是个更好的选择。
Shader Graph踩坑实录
欢迎参与讨论,转载请注明出处。
前言
Demo也到了做渲染的时候了,经过一番鏖战后,算是大体完成了。但随着后续需求的到来,发现这套纯代码的Shader方案对于扩展、复用等方面有着诸多不便。于是便打起了Shader Graph的主意……经过一番纠缠,于是有了本篇踩坑实录。另附源码地址,但本篇并不会对其做讲解。基于Unity2020.1.0a20,渲染管线为URP7.18。
优劣
首先要明确的是:Shader Graph不支持Builtin渲染管线,且尚未处于彻底成熟的阶段,哪怕是最新版本尚有不少缺陷。但瑕不掩瑜,且说其优劣:
- 优点
- 由于节点化与Sub Graph的存在,Shader的组装将变得相当容易,极大提升了模块化水平。
- 调整Property与Keyword变得相当方便,纯代码下将需要多些工序。
- 内置各种节点,降低了美术参与创作的门槛。
- 能够实时预览每个节点造成的变化预览,虽然对我而言没啥用。
- 缺点
- 编辑器尚不够稳定,经常会出现整个程序崩溃的情况。
- 生成的代码不够优化,比较暴力,存在各种分支判断、重复函数(也许最终会优化?)
- 由于Slot机制的原因,可能会出现很多运算集中在片元着色器中。
- 对于创建Pass并不友好,需要修改源码。
来龙
其实一般的浅度Shader Graph使用者并不会如我这般踩这么多坑:直接使用内置的Graph模板进行创作即可。不幸的是,如前文所言:对于创建Pass并不友好,需要修改源码。于是便开始了踩坑之旅……
在默认情况下,Unity的Package将保存在工程的Library/PackageCache目录下,这样子是不能直接修改源码的(Library目录下的东西属于可再生物,随时会被覆盖),需将之搬迁至工程的Packages目录下。
对于考虑到日后Shader Graph的版本升级情况,所以尽可能的不要修改原工程的内容,而是尽量新建文件。但考虑到Shader Graph源码下存在不少inner元素,直接在外面写自己的内容也并非彻底可行。只能直接在Shader Graph包下进行添加文件的方式了,这也是要将之移至Packages目录的原因。
我要做的事情相当明确:新建一个自定义的Graph类型。在URP下已经自带Unlit与PBR两种类型了,于是本人便基于Unlit Graph并结合先前实现的Shader的特性进行新类型的创作。
去脉
我们能接触到Unlit Graph创建的起点便是Project区下右键菜单的Create->Shader->Unlit Graph
了,直接在Shader Graph源码包下全局搜索Create/Shader/Unlit Graph
即可找到:
照葫芦画瓢在同目录下弄个新文件实现相同功能即可,这下我们便知道Unlit Graph的正主了:UnlitMasterNode
。经过研究发现,它决定了在编辑器下Unlit Master Node的样式:
但这只是个壳子罢了,根据代码中的IUnlitSubShader
为引,找到了其核心:
这个UniversalUnlitSubShader
的作用相当简单:根据编辑器的设置生成Shader代码。如上图便可看出定义Pass数据结构的行为,这也是诱使我来改源码的直接原因。在里面你将见到形如这般的代码:
|
|
如此情况便变得相当清晰了,只要清楚你想生成怎样的Shader代码,在摸熟了生成代码的API,便可自由地进行创作了。通过右键节点可以随时查看生成的代码情况:
注意事项
也许看似还算简单,但其中坑点还是有不少的:
- 不要尝试采用继承的形式去新建新类型,其本身代码就没打算让你这么做,必然会碰壁,除非改源码(如此便违反原则了)
- 做出了修改后,要到对应的Graph文件进行Save操作触发检测。
- 也许是检测的原因,有时候HLSL代码做出了修改后不会被识别到,需要换下行。
- Shader Graph生成的着色器参数有着自己的一套处理方式,务必参考自带的代码。
- Unlit Graph的主Pass并没有LightMode,想做背面Pass的时候要注意下。
- Unlit Graph将渲染模式、混合模式、剔除做成节点设置并不是一个好选择(无法让材质修改),推荐按照URP的方式做成材质属性。
- Shader Graph对于生成的Shader代码存在分支数限制,需要到
Preference->Shader Graph
进行修改上限。值得一提的是,全局Keyword与局部Keyword似乎是分别对待的。 - Shader Graph并不存在完整的环境,它是无法识别到一些渲染管线里的函数的。所以在编写Custom Shader的时候需要加上
#if SHADERGRAPH_PREVIEW
分支判定以处理在编辑模式下的情况:
|
|
后记
现在感觉游戏开发的未来方向就是连连看了,从这点来说UE4的确算是时代前沿。在编辑器里加入逻辑控制元素,让更多人能加入创作,尽可能地解放生产力,的确是游戏开发所需要的。