初览项目优化

综述


开发工具的运用

游戏开发是团队合作,美术、程序、策划们都需要使用项目管理的工具。美术提交资源,策划提交文案,程序提交代码。一般主要使用的项目管理工具有 SVNGithub,可以选择其一,或者结合使用。以一个以前的开发为例,使用 SVN 作为项目管理工具,在项目的初期,没有问题,但是到了后期,项目要分版本开发。一旦项目上线后,要接不同渠道的 SDK,会接上很多个,刚开始也使用了 SVN 的 Branch 分支,但是每次更新速度都很慢,而且也不利于多人开发,被折磨之后将其换成了 Github,Git 非常适合程序协同开发以及产品版本管理,这点 SVN 是无法比拟的。当然美术和策划还是使用 SVN,相对来说,Github 操作比 SVN 复杂一些,而美术和策划也不需要像程序一样搞几十个版本,所以他们采用的版本管理工具还是 SVN,比较方便,更有效率。另外,项目对资源和代码逻辑做了分离,逻辑代码都是动态的绑定到资源对象上,这是因为资源经常变动,经常修改,如果上面挂脚本,一旦出现漏挂,会出现一些问题,还需要人去查找这样的问题,浪费时间,所以最好不要直接绑定逻辑脚本。对于程序使用 Git 的主干,我们程序开发把产品框架作为主干,每个模块的逻辑作为分支处理,这样参与逻辑开发的每个程序都可以起一个分支,逻辑写好了,测试完成,最后交给主程去合并,再测试。这样主程的重心就是把控代码质量,通过下面的图可以简单理解一下。
在这里插入图片描述

有了项目管理工具,还需要项目跟踪工具,策划、美术、程序在项目开发协作方面也需要配合,比如策划给美术和程序下达任务以及完成时间节点。作为策划,他要清楚美术和程序的进展情况,如果只是靠口头去传达,容易出现扯皮的事情发生,而且时间和质量不好把控,项目延误是肯定会发生的。这时就需要用到项目跟踪工具,可以使用 JIRA 或者禅道等等,策划会将任务传到 JIRA,主程去分配任务,程序领任务,策划可以定期在上面查看各个任务进度,及时发现任务是否有延期的隐患,及时纠正。这里也配一张图理解一下。
在这里插入图片描述

项目顺利进行后,再接着就是测试了。使用 Unity 去打包,如果工程很大,每次编译都需要耗费不少的时间,而且策划或者测试会不定期的让程序打个包给他看,这样容易打断程序员的开发思路,而且项目在每个节点上也需要去发包测试。总而言之,打包的次数会非常多,如果不将其工具化,程序员会浪费很多时间在这上面,每次打包,资源整合等都会耗费大量时间。鉴于手动打包的各种不利因素,自动打包工具应运而生,我们可以使用一个自动打包工具 Jenkins,它可以采用 Shell 脚本编程,自动更新 SVN 或者 Git 服务器上的内容,只需要点一下按钮,服务器就可以生成当前项目最新的包,很简单的操作,然后将其下载下来安装测试,非常的方便。这样,打包的事情策划和美术都可以操作。

最后,在项目开发时,程序会根据策划需求去写一些依附于 Unity 编辑器的工具,这个也是必须的,比如资源打包、文本文件处理、资源检查、包体大小检测、单元测试等。作为程序开发者,工具当然是越多越好,如果一款游戏只需要操作工具就可以生成,程序员只需要专心维护工具,而策划只需要专心使用工具,美术制作模型,一款产品很快就可以研发出来,类似生产流水线作业。


资源的优化

游戏资源的优化,无非是从模型的面数、材质的数量、骨骼动画的数量、特效的粒子数、图片的压缩、网格合并这些方面去入手,网上这方面的介绍比较多。这些操作可以帮助我们去解决掉部分的问题,但是除了这些还会有一些其他的问题。比如,我们场景中有很多草,或者说有很多透贴的树木,场景中有很多怪物,类似国战这中类型的游戏,两边都需要很多的 NPC 开战等等。这些 NPC 是不可以用网格合并的,因为它们是单个的个体,有自己的属性,是可以被击杀的。另外透贴的树木也是非常耗时的,但是这种问题也是可以解决的,可以利用 GPU 编程实现。

另外资源需要将其打包成 Assetbundle,一部分资源会上传到服务器,进去行资源的更新,这就要涉及AssetBundle 包体的数量、包体的大小、打包的依赖、内存的管理等等,作为开发者都需要认真考虑的。

合理的调配游戏资源,掌握方法非常重要。
Unity 提供了两种资源分类。一种是 Unity 内部加载资源使用的是实例化,实例化的资源要存放到 Unity 引擎的 Resources 文件夹中,程序通过 Resources.Load 函数加载;另一种是程序外部资源加载,使用的是打包文件 AssetBundle,大部分游戏上线产品都是通过加载外部资源实现的。

优化加载 AssetBundle
游戏包体的大小,单独程序是控制不了的,它同样需要美术协同配合。

Unity 支持三种 AssetBundle 打包的压缩方式,即 LZMA、LZ4,以及不压缩。

1.LZMA 压缩方式
这是一种默认的压缩形式,这种标准压缩格式是一个单一 LZMA 流序列化数据文件,并且在使用前需要解压缩整个包体。LZMA 压缩是比较流行的压缩格式,能使压缩后文件达到最小,但是解压相对缓慢,导致加载时需要较长的解压时间。

2.LZ4 压缩方式
Unity 支持 LZ4 压缩,能使得压缩量更大,而且在使用资源包前不需要解压整个包体。LZ4 压缩是一种“Chunk-based”算法,因此当对象从 LZ4 压缩包中加载时,只有这个对象的对应模块被解压即可,这速度更快,意味着不需要等待解压整个包体。LZ4 压缩格式是在 Unity5.3 版本中开始引入的,之前的版本不可用。

3.不压缩的方式
不压缩的方式打包后包体会很大,很占用空间,但是一旦 Assetbundle 下载下来,访问会非常快。不推荐这种方式打包,因为现在的加载功能做的很友好了,完全可以用加载界面来进行后台加载资源,不会消耗太长的时间。

资源打包 AssetBundle 使用 LZ4 压缩的话(BuildPipeline.BuildAssetBundles,第二个参数传递 BuildAssetBundleOptions.ChunkBasedCompression),打包默认的是 LZMA 压缩,具有最高的压缩比。而替换为 LZ4 压缩,压缩比没有 LZMA 高,但是加载速度大幅提高,这需要根据项目均衡考虑。

图集打包的优化
打包图集,需要注意它和 AB 的匹配关系。假设,如果三张图片指定了同一个图集,而又分别指定了不同的 AB 名,则三个 AB 里面都包含了此图集(三张图片),这样就会造成严重的资源浪费。在此再说一下 Unity 的内存管理,先看一下这张图:
在这里插入图片描述
官方说法是,Untiy 支持 LZ4 压缩,该压缩方式支持将 AB 中的个体资源分开压缩,这样就可以在 AB 加载资源时分别对资源进行解压而非整体解压。也就是说 AssetBundle.LoadFromFile 这个高效的 API 会从本地硬盘中加载未压缩或者采用 LZ4 压缩的 AB 包。在移动端,API 只会加载 AB 的 header,而不会把整个 AB 加载进内存,在进行诸如 AssetBundle.Load 这样的方法时才会根据头文件的信息,将硬盘中 AB 对应的数据加载出来,现在将多个资源打在一个 AB 包里将成为一个新的选择,将不用再担心加载 AB 且不立即释放 AB 所造成的额外内存开销,因为在移动端, AB 在内存中的镜像只是 AB 的 header 部分。

对于项目来讲,若是项目计划使用 UGUI 的自动图集功能,而自动图集功能需要将多张图片同时打包到一个 AB 中才能正常生成图集,这就需要项目有加载涵盖多个资源的 AB 且不即刻释放。

虽然 LoadFromFile 加载的 AB 内存开销已经很小了,但还是要注意同时加载大量 AB 的情况。

卸载 AssetBundle
知道何时加载或卸载一个 AssetBundle 是很重要的。如果一个 AssetBundle 被不恰当地卸载,它会导致内存中的对象复制。在某些情况下,不恰当地卸载 AssetBundle 也会导致不良行为,比如导致纹理丢失。

再说一下 UnLoad 卸载 AssetBundle 资源的 API 函数,这个 API 卸载被调用的是 AssetBundle 的头信息,它里面的参数指示是否要卸载从这个 AssetBundle 实例化的所有对象,如果选择 True,那么所有来自于这个 AssetBundle 的对象也将立即被卸载,即使它们目前正在活动场景中使用,这是个很危险的操作。

举个例子,假设一个材质 M 是从一个 AssetBundle 中加载的,并且假设 M 材质当前处于活动场景中。

如果调用了 AB.Unload(true),那么 M 将从场景中删除,销毁并卸载。但是,如果调用了 AB.Unload(false),那么 AB 的头信息将会被卸载,但是 M 材质将保留在场景中,并且仍然具有功能。调用 AssetBundle.Unload(false) 断开 M 和 AB 之间的链接,如果 AB 稍后再加载,那么 AB 中包含的对象的新副本将被加载到内存中。

如果稍后再次加载 AB,则将重新加载 AssetBundle 头信息的新副本。但是,材质 M 并没有从这个新的 AB 副本中加载,Unity 没有在 AB 和 M 的新副本之间建立任何链接。

如果调用 AB.LoadAsset() 来重载 M 材质,Unity 不会将 M 的旧副本解释为 AB 中数据的实例,所以 Unity 将加载 M 的新副本,在场景中会有两个相同的 M 副本。

AseetBundle 资源更新
最基本的资源更新流程,先加载本地的资源文件列表,然后获取服务器上的资源列表,根据 MD5 或者版本号比较二者,如果服务器上的资源是最新的,则下载服务器上的 AssetBundle 资源替换本地的资源,同时更新资源列表。在资源更新的时候,我们就要考虑是否压缩 AssetBundle ,需要考虑的问题有以下几个。

A。AssetBundle 的加载时间,当从本地存储或本地缓存加载时,未压缩的 AssetBundle 比压缩的 AssetBundle 要快得多。从远程服务器下载压缩的 AssetBundle 通常比下载未压缩的 AssetBundle更快。

B。AssetBundle 的构建时间,当压缩文件时, LZMA 和 LZ4 非常慢,而 Unity 编辑器在串行中处理 AssetBundle。所以具有大量 AssetBundle 的项目将花费大量的时间来压缩它们。

C。应用程序的大小,如果在应用程序中装载了 AssetBundle,压缩它们将减少应用程序的总大小,或者 AssetBundle 可以在安装后下载。

D。内存使用量,在 Unity 5.3 之前,所有 Unity 的解压机制都需要在解压之前将整个压缩的 AssetBundle 加载到内存中。如果内存使用很重要,可以使用未压缩的或 LZ4 压缩的 Assetbundle。

E。下载时间是,如果 AssetBundle 是大型的,或者用户处于带宽受限的环境中,比如通过在移动设备上下载,或者在低速或有度量的连接上下载,那么压缩可能是必需的。如果只有几十兆字节的数据被传送到高速连接的个人电脑上,就有可能忽略压缩。

Mono 内存回收
Mono 通过垃圾回收机制(Garbage Collect,简称GC)对内存进行管理。Mono 内存分为两部分,已用内存(used)和堆内存(heap),已用内存指的是 Mono 实际需要使用的内存,堆内存指的是 Mono 向操作系统申请的内存,两者的差值就是 Mono 的空闲内存。当Mono 需要分配内存时,会先查看空闲内存是否足够,如果足够的话,直接在空闲内存中分配,否则 Mono 会进行一次 GC 以释放更多的空闲内存,如果 GC 之后仍然没有足够的空闲内存,则 Mono 会向操作系统申请内存,并扩充堆内存,具体如下图所示。
在这里插入图片描述
GC 的主要作用在于从已用内存中找出那些不再需要使用的内存,并进行释放。Mono 中的 GC 主要有以下几个步骤:
1.停止所有需要 Mono 内存分配的线程。
2.遍历所有已用内存,找到那些不再需要使用的内存,并进行标记。
3.释放被标记的内存到空闲内存。
4.重新开始被停止的线程。

除了空闲内存不足时 Mono 会自动调用 GC 外,也可以在代码中调用 GC.Collect() 手动进行 GC。但是,GC 本身是比较耗时的操作,而且由于 GC 会暂停那些需要 Mono 内存分配的线程(C#代码创建的线程和主线程),因此无论是否在主线程中调用,GC 都会导致游戏一定程度上的卡顿,需要谨慎处理。另外,GC 释放的内存只会留给 Mono 使用,并不会交还给操作系统,因此 Mono 堆内存是只增不减的。这也就是为啥我们在项目中调用 GC 手动释放,但是查看我们的 Mono 内存还是只增不减的原因。

那么,Mono 是如何判断已用内存中哪些是不再需要使用的呢?是通过引用关系的方式来进行的。Mono 会跟踪每次内存分配的动作,并维护一个分配对象表,当 GC 的时候,以全局数据区和当前寄存器中的对象为根节点,按照引用关系进行遍历,对于遍历到的每一个对象,将其标记为活的(alive)。

由于 GC 以全局数据区和当前寄存器中的对象为根节点进行遍历,所以对象的被标记意味着该对象可以通过全局对象或者当前上下文访问到,而没有被标记的对象则意味着该对象无法通过任何途径访问到,即该对象“失联”了,GC 最终会将所有“失联”的对象内存进行回收。

Mono 已经有了完善的 GC 机制,但还会存在内存泄漏,只是此处的内存泄漏需要重新定义一下,我们把对象已经不再需要使用却没有被 GC 回收的情况称为 Mono 内存泄漏。Mono 内存泄漏会使空闲内存减少,GC 频繁,Mono 堆不断扩充,最终导致游戏内存占用的升高。

游戏中大部分 Mono 内存泄漏的情况都是由于静态对象的引用引起的,因此对于静态对象的使用需要特别注意,尽量少用静态对象,对于不再需要的对象将其引用设置为 null,使其可以被 GC 及时回收,但是由于游戏代码过于复杂,对象间的引用关系层层嵌套,真正操作起来难度很大。可以首先使用 腾讯的 Cube 工具进行分析,根据 Mono 内存趋势找出泄漏的具体场景,然后再使用快照对比功能进行详细分析。


代码的优化

一提起手机效率优化,都会感觉头疼,有时找了半天都无处下手,或者是根本不知道哪里出的问题,在此我们先从代码编程说起,编码的细节问题也是面试时经常遇到的,这里也举几个编码的例子,比如 string 字符串连接——“+”号连接,但是使用“+”号会产生副本,占用内存,而如果使用 StringBulider 中的 append 可以避免副本的产生;还有单例类和静态类的区别,有时程序员为了调用函数方便,代码中会涉及到很多静态类,静态类是常驻内存的,过多的使用也会占内存的,程序中不益多用;另外还有结构体与类的区别,值类型和引用类型等等。虽然这些都是一些细节问题或者说小问题,但是作为程序开发者,都必须要掌握的。另外,很多程序喜欢使用插件解决问题,插件是可以帮助解决问题但是也带来了隐患。举个例子,行为树 BehaveTree 插件,因为它内部为我们封装好了,我们只需要用它提供的可视化界面拖拖拽拽就可以完成行为的转换。在游戏中使用过多的行为树的话,因为在运行时它要不停的进行状态切换,同样在手机端会出现卡顿的情况。解决方案其实比较简单,简化行为树逻辑,同时使用 FSM 或者 Switch 语句代替。关于代码优化,就不得不提架构设计,这个是一直重点强调的问题。这里介绍常用的架构设计——ECS 架构,它是一个开源的框架,详情可以访问这个 网站

有时候,项目失败的因素主要包括:产品的设计不利,程序的逻辑模块耦合性太强,Bug 太多等等。除了产品的设计之外,主要原因还是由代码的编写造成的,作为程序员代码编写习惯以及代码模块设计都非常重要,稍有不慎就会导致项目上线后,在项目维护和更新方面出现大问题,最终导致项目失败。接下来,从关乎代码优化所涉及的技术点逐一了解一下,在代码编写之前,先进行框架搭建。

代码模块设计
那么为什么要进行代码框架搭建呢,我们知道如果对于一个需求直接硬编码是没有任何问题的,但是策划的需求是经常变化的,我们的硬编码能否应付了需求的变化?这个是程序员认真考虑的问题了,不能因为需求的频繁变更,我们程序员疲于应付,这样我们就陷进去了。这种问题的解决方案是利用设计模式搭建代码框架,游戏开发常用的设计模块有 MVC 模式,FSM 有限状态机和 State 状态模式以及 ECS 模式。

在实现代码架构时,要坚持一个原则:代码模块之间的耦合性要降低,另外将代码与资源进行分离。耦合性可以通过事件机制处理,利用事件消息机制将相关的模块结合起来,从而降低模块之间的依赖性。资源上不要绑定脚本,这样避免美术资源频繁的变更导致问题的出现。

代码框架的设计是代码优化的前提,代码编写的好坏直接影响内存的分配,下面说一下内存优化。

内存优化

代码编写时要重点关注的就是内存的分配问题,其实代码优化就是关于内存的优化,在这里先举个例子,在游戏开发中,为了实现热更新,我们在项目中加入 Lua 脚本,在 Lua 选择上可以选择使用 ulua 或者xlua。ulua 比 xlua 出来的早,所以前期使用的 ulua 比较多,点击 这里下载ulua。

xlua 是腾讯公司开发的,而且产品有团队维护着,点击 这里进行下载。

Unity 使用 Lua 时需要注意:

在 C# 中加入 Lua ,就暗示着把业务逻辑搬到了 Lua 中写。在 Mono 和 Lua 两边都存在业务逻辑且交叉引用的情况本身就是很不合理的。更多的情况是,Mono 负责和引擎底层沟通,所有的引擎对象都是由 Lua 通过中间层命令 C# 去创建的;当 Lua 层不再使用这些对象后,再通知删除。C# 本身并没有业务层去引用这些对象。Lua 和 C# 应该是清晰的上下层关系,而不应该是混杂在一起的并列关系。

所以推荐的做法是,只有 Lua 可以长期持有 Mono 中的 C# 对象,而 Mono 中只可以短期持有 Lua 层的对象(不超过游戏中的一帧)。这样,Lua 就有权利主动清理那些自己并不持有的本地对象而不需要通知 Mono 了,这种单边关系便不会产生循环引用。

Mono 中唯一可能长期持有的 Lua 对象唯有一些重要的回调函数,比如在每个游戏逻辑帧内都去调用一次 Lua 里定义好的 update 函数。而这种 Lua 函数对象,只需要让 Lua 自己长期保有引用(比如放在全局表里)就可以了。

即使真的想做出一套完备的 Mono 和 Lua 间的对象双向引用关系,推荐用最简单的方案,基础方案中不去考虑循环引用的问题,而可以单独写一个模块来解开潜在的循环引用,这个模块性能不是主要考虑问题,在合适的时候(比如 loading 场景时)启动检查即可。

代码优化小例

在编写类代码时,我们通常会使用 Singleton 单例模式和 Static 模式,很多人为了方便接口调用,会使用大量的 Static 类,由于 Static 类在使用时每次调用都会在内存中创建一次并且常驻内存,所以 Static 类一般用于数据的加载时使用,只调用一次,除此之外,其他情况下最好选择 Singleton 单例模式,因为它在程序运行时只保留一份。另外我们也会在类的编写中使用一些不继承 mono 的类,封装一些接口,用 interface 接口或者 abstract 抽象修饰,函数的方法在它们的子类中去具体实现。在使用 new 生成对象,如果出现每帧都要 new 对象,这里就需要注意了,new 同一个对象最好只需要 new 一次,new 了对象后,记得在其生命周期结束后将其释放掉。

在编程时通常会使用 if…else 语句或者 switch…case 语句,如果判断条件过多也会影响代码运行效率的,比如判断条件超过四个,这就要考虑是否合适了,而且这对于我们代码的维护非常不利。需要考虑替代方案了,首选方案是使用 FSM 有限状态机替换它们,在上面已经提到过,这样后面功能再扩展就非常方便了。

代码中经常遇到字符串的使用,为了方便,程序员通常选择的是 String,字符串的链接直接用“+”,对于 String 来说,它们在运行时会自动在内存中再拷贝一份,虽然占用内存不多,但是积少成多容易出现内存泄漏,内存的碎片也会随着增加,一旦遇到申请比较大的内存时,程序就会出现卡顿情况,选择替代方案是使用 StringBuilder,该类有函数接口 Append 用于链接字符串,而且 StringBuilder 运行速度快,同样要避免在循环语句或者是 Update 中进行字符串操作。

程序中避免频繁地进行 SetActive 操作,由于 SetActive 本身也有一定消耗,而且一些特殊的组件类似于:Text、MaskGraphic 等在 OnEnable 与 OnDisable 时有较为明显的消耗,建议在频繁进行 SetActive 的操作时采用先移出屏幕等待一段时间之后再将物体隐藏,保证不过度频繁地将物体重复 Active 或者 InActive。而在一些不适用于移出屏幕的物体,类似于 UI,考虑减少该类的操作,或者使用将 Text 设为空或者透明度设为0来避免调用 OnEnable 与 OnDisable 操作。

使用 gameObject.CompareTag(“XXX”) 而非 gameObject.tag,后者会产生额外的内存与性能消耗,数值计算方面,使用消耗更小的运算,例如1/5使用1*0.2来代替、用位运算代替简单乘除(不过在性能并不是非常敏感的地方可以忽略位运算这一条,毕竟可读性还是要的)。

减少多余的访问,例如 Dictionary 的使用中,我们常常会先用一次 ContainsKey 再进行访问,相当于访问了两次。使用 TryGet 替代会更好。

我们在写程序的时候,经常会用 List,其实很多不需要随机访问的情况下我们可以使用 Queue 或者 Stack 来代替 List。前者 O(1) 的复杂度会比后者好很多。类似的,List 的操作比 Dictionary 的操作要更廉价。使用 for 就不使用 foreach,foreach 产生的迭代器可以产生 GC 会影响性能。

代码命名规范这些最基本的,作为程序员必须要掌握的,见命知意,这里就不多说了。

另外函数代码的编写尽量简洁,一个函数代码行最好不要超过100行,功能函数要求的就是单一功能,不能两个功能混在一起,否则后期维护非常困难。

代码优化涉及的面非常广泛,平时养成代码 Review 的习惯非常重要,它可以帮助进行代码重构,找到不足并加以改进。


Shader 的合理使用

Shader运用
使用 Shader 去渲染可以提升游戏的品质,但是如果过渡的使用也会引起帧数的降低,卡帧卡顿情况的发生。举个例子,游戏中的模型有时会涉及双面显示,开发者一般的做法是在 Shader 中加一条语句 Cull off ,就可以开启双面显示。但是这对于 GPU 来说就是一种消耗的增加,如果在游戏中过多的使用,会引起帧数明显下降。其实有时候能美术解决的问题就不要用程序去解决了,这种显示只需要美术再做一个面就可以实现,而不是使用 Shader 去处理。还有在场景中大量使用透贴,也是一种消耗。

Shader 它不仅应用在游戏上,VR/AR 产品以及 H5 也同样在使用。另外,人们还把 Shader 渲染技术也应用到了动漫视频的渲染上,为了更加突出场景的真实性,比如国外的 3D 动漫产品应用了 BRDF(Bidirectional Reflectance Distribution Function,双向反射分布函数),BRDF 模型是绝大多数图形学算法中用于描述光反射现象的基本模型。还有次表面散射,Subsurface Scattering,简称 SSS / 3S,就是光射入半透明材质后在内部发生散射,最后射出物体并进入视野中产生的现象,是指光从表面进入物体经过内部散射,然后又通过物体表面的其他顶点出射的光线传递过程。还有一种应用在水面渲染的 Shader 技术——菲涅尔反射(Fresnel Reflectance)或者菲涅尔效果(Fresnel Effect),即当光入射到折射率不同的两个材质的分界面时,一部分光会被反射,而我们所看到的光线会根据我们的观察角度以不同强度反射的现象。

以上说的几种 Shader 渲染技术是比较核心的、更能提升游戏品质的技术,当然 Shader 渲染技术非常多,这里就不一一列举了,网上有非常多的资料学习。Shader 渲染在游戏中的使用是一把双刃剑,用不好会使产品运行效率低下,用好了提升产品运行效率。

代码变量声明的优化
在使用 Unity 自带的 Shader 或者网上提供的 Shader 的时候,Shader 中复杂的处理一般会涉及函数的使用,比如exp、log等,如果使用过多也会影响效率,解决办法是将它们适当地替换掉。如果你的产品面向的是高端机型,效率没什么影响,那么可以忽略。还有就是 Shader 中变量的声明,从精度来说,float、half、fixed 是依次降低的,但是它们的效率也是依次升高的,如果游戏品质在可以接受的情况下,可以适当的降低变量的精度,这样游戏运行效率会得到保证,同样 float2、half2、fixed2、float3、half3、 fixed3、float4、half4、fixed4 跟前面的类似。一般在项目开发中的实时阴影、残影、角色的透明处理等等,都是可以使用 Shader 编程去实现的。

一些小应用

Shader GPU Instancing 实例化
以动态生成角色为例介绍一下 GPU Instancing 技术。假设我们在场景中生成1000个角色,并要求这些角色是独立的个体,如果按照正常的实例化操作,游戏运行时会很卡,因为生成的角色自动播放动作,也有人可能会说,将这些角色进行合并,但是这不符合我们的条件——角色是独立的个体,正常的操作都是走不通的,只能考虑应用 GPU Instancing 实例化技术。使用 GPU Instancing 实例化操作运行效率会得到极大改善,Draw Call 数值也就是二十多,同时满足了生成的角色都是独立的个体这个条件。看一下这个完整的 Shader 代码:

 Shader "Instancing/AnimMapShader"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _AnimMap ("AnimMap", 2D) ="white" {}
        _AnimLen("Anim Length", Float) = 0
    }
        SubShader
        {
            Tags { "RenderType" = "Opaque" }
            LOD 100
            Cull off

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            //开启gpu instancing
            #pragma multi_compile_instancing


            #include "UnityCG.cginc"

            struct appdata
            {
                float2 uv : TEXCOORD0;
                UNITY_VERTEX_INPUT_INSTANCE_ID
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
                UNITY_VERTEX_INPUT_INSTANCE_ID
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;

            sampler2D _AnimMap;
            float4 _AnimMap_TexelSize;//x == 1/width

            float _AnimLen;


            v2f vert (appdata v, uint vid : SV_VertexID)
            {
                UNITY_SETUP_INSTANCE_ID(v);

                float f = _Time.y / _AnimLen;

                fmod(f, 1.0);

                float animMap_x = (vid + 0.5) * _AnimMap_TexelSize.x;
                float animMap_y = f;

                float4 pos = tex2Dlod(_AnimMap, float4(animMap_x, animMap_y, 0, 0));

                v2f o;
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                o.vertex = UnityObjectToClipPos(pos);
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                fixed4 col = tex2D(_MainTex, i.uv);
                return col;
            }
            ENDCG
        }
    }
}

在上述代码中需要重点关注几个地方,首先需要在 CGPROGRAM 下面增加一句:

#pragma multi_compile_instancing

它表示开启 GPU Instancing,意思是告诉 GPU 我们要进行 GPU Instancing 实例化操作了。

当然,这只是第一步,还需要在定义的输入和输出结构体中分别增加下面的一行代码:

UNITY_VERTEX_INPUT_INSTANCE_ID

还有我们是进行 GPU 处理,所以需要在顶点着色器中对其进行实例化操作,增加一行语句:

UNITY_SETUP_INSTANCE_ID(v)

这样我们就完成了 Shader 代码中的 GPU Instancing 实例化操作,对于一些游戏中的大规模团战场景同样可以使用该方案实现,用它解决大规模树木、草的运行效率问题。Shader 能帮助我们解决问题,它也会给我们制造问题,通常使用的 Shader 在适配不同的机型时,或多或少都会遇到问题,所以我们要先从 Shader 自身找问题,针对 Shader 进行具体的优化。


在阴影渲染方面,我们解决的都是 3D 的实时阴影渲染实现,Shader 同样可以解决 2D 的实时阴影,以前程序处理 2D 阴影都是在下面加个面片代替阴影,使用 Shader 也可以满足 2D 实时阴影的需求,2D 阴影的实现比较简单,只需要将 Shader 挂接到 Sprite 上就可以了,来看一下代码:

Shader "Custom/Shadow" {
    Properties{
        _MainTex("Main Tex", 2D) = "white" {}
    _AlphaTex("AlphaTex",2D) = "white"{}
    _Offset("Offset", vector) = (0, 0, 0, 0)
    }
        CGINCLUDE
#include "UnityCG.cginc"  
        sampler2D _MainTex;
    float4 _Offset;
    sampler2D _AlphaTex;
    float4 _MainTex_ST;

    struct v2f {
        float4 pos : POSITION;
        float2 uv : TEXCOORD0;
    };

    v2f vert_normal(appdata_base v) {
        v2f o;
        o.pos = UnityObjectToClipPos(v.vertex);
        o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
        return o;
    }

    v2f vert_offset(appdata_base v) {
        v2f o;
        float4 pos = mul(unity_ObjectToWorld, v.vertex);
        o.pos = mul(UNITY_MATRIX_VP, pos + _Offset);
        o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
        return o;
    }

    float4 frag_normal(v2f i) : COLOR{
        float4 texcol = tex2D(_MainTex, i.uv);
        texcol.w = tex2D(_AlphaTex,i.uv)*texcol.w;
        return texcol;
    }

        float4 frag_color(v2f i) : COLOR{
        float4 c;
    c = tex2D(_MainTex, i.uv);
    c.w = tex2D(_AlphaTex,i.uv)*c.w;
    if (c.w >= 0.5)
    {
        c.r = 0;
        c.g = 0;
        c.b = 0;
        c.w = 0.5f;
    }

    return c;
    }
        ENDCG
        SubShader {
        Tags{ "Queue" = "Transparent" }
            Pass{
            ZWrite Off
            Blend SrcAlpha OneMinusSrcAlpha
            CGPROGRAM
            #pragma vertex vert_offset  
            #pragma fragment frag_color  
            ENDCG
        }
            Pass{
            ZWrite Off
            Blend SrcAlpha OneMinusSrcAlpha
            CGPROGRAM
            #pragma vertex vert_normal  
            #pragma fragment frag_normal  
            ENDCG
        }
    }
    FallBack "Diffuse"
}

3D 实时阴影的渲染对于 GPU 的效率影响是非常大的,我们通常使用的方式是利用 Unity 自身的 Projector 组件实现 3D 的实时阴影效果,如果游戏中大量使用 Projector 对效率的影响也是很大的。另外,Projector 组件不能处理物体对阴影的遮挡,接受阴影的物体和产生阴影的物体有穿透时会穿帮。为了避免上述问题,我们使用 RenderTexture 和 Camera 相结合进行实时阴影的渲染,如这样:
在这里插入图片描述
它实现的原理是通过摄像机将角色实时的渲染到角色下面的面片上,在这里要有个问题需要注意,如果角色身上有透明贴图,渲染在地面上的阴影就会出现透明,影响阴影效果,Shader 的实现代码如下:

Shader "Custom/ShadowShader"
{
    Properties
    {
        _Color("Main Color", Color) = (1,1,1,1)
        _MainTex("Base (RGB)", 2D) = "white"{}
        _Cutoff("Base Alpha cutoff", Range(.0,.9)) = .2
    } 

    SubShader
    {
        Tags{"Queue"="Transparent - 100"}
        LOD 200

        Lighting Off
        ZWrite Off
        Cull Off
        Blend SrcAlpha OneMinusSrcAlpha

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include"UnityCG.cginc"

            struct appdata_t
            {
                float4 vertex : POSITION;
                float4 color : COLOR;
                float2 texcoord : TEXCOORD0;
            };

            struct v2f
            {
                float4 vertex : SV_POSITION;
                float4 color : COLOR;
                float2 texcoord : TEXCOORD0;
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;
            float _Cutoff;

            v2f vert(appdata_t v)
            {
                v2f o;
                o.vertex = mul(UNITY_MATRIX_MVP, v.vertex);
                o.color = v.color;
                o.texcoord = TRANSFORM_TEX(v.texcoord, _MainTex);
                return o;
            }

            float4 _Color;
            half4 frag(v2f i) : SV_Target
            {
                half4 col = tex2D(_MainTex, i.texcoord);
                if(col.a < _Cutoff)
                {
                    clip(col.a - _Cutoff);
                }
                else
                {
                    col.rgb = col.rgb * float3(0, 0,0);
                    col.rgb = col.rgb + _Color;
                    col.a = _Color.a;
                }
                return col;
            }
            ENDCG

        }
    }
}

在上述代码中有条判断语句,这个主要目的是将大于某个值的颜色裁剪掉,保证投射的阴影颜色一致。


算法的运用

在游戏开发中,核心玩法很多都是基于算法实现的,作为程序员要学会学以致用,将书本知识用到产品开发中。在游戏开发中,经常使用的算法,包括我们大学时学习的数据结构。作为程序员必备的技能,产品开发中,我们经常会用到队列、栈这些算法。我们要使用算法,就必须了解这些数据结构的特性,比如队列是先进先出,栈是后进先出。

另外用得比较复杂、使用最多的是 A*算法和 AI 算法。作为大部头的 MMO 产品,A*算法是必须要掌握的,它不同于 Unity 自带的 NavMesh 算法,NavMesh 算法对 3D 场景应用的非常好,但是对 2D 游戏并不能完全满足需求。游戏服务器也需要 A*算法的支持,这样才可以做到服务器与客户端之间的同步。插值运算,比如常用的抛物线插值,角色同步时的线性插值,刀光拖尾的 Bezier 曲线插值或者 B 样条曲线插值等。同样,我们还需要重点掌握一些知识,这些知识帮助我们解决了很多问题——向量计算和矩阵计算。关于向量计算,举个例子,我们在使用导弹攻击目标时,导弹要一直跟踪目标,这就要求导弹的头一直对准物体,这会涉及到向量的点乘和叉乘运算。矩阵转换,同样也很重要,矩阵运算除了在固定流水线和可编程流水线应用外,我们还会用它做一些颜色格式的转换,比如摄像机获取到的视频数据格式是 YUV,我们要将其转化成 RGB 在 Unity 中显示出来。

猜你喜欢

转载自blog.csdn.net/THIOUSTHIOUS/article/details/86522709