图形引擎实战:变体工具应用经验

前言

当前游戏画面的丰富度和精美程度日益上升,为了支持这一点,就需要大量的shader及变体。随着项目的推进,shader变体也在渐渐增多,为了保证渲染效果正常以及获取良好的运行效率,就需要对所需的变体进行收集。

什么是变体

在了解变体收集之前,首先需要了解一下什么是变体。例如在场景中有一片草地,我们希望其进行顶点动画,随风摇动,那么就需要添加额外的计算,为了适配各种配置的机型,可能会希望在低端机上关闭这个动画,不进行相关计算。为了达成这一点,需要两个shader进行切换,但实际上两个shader间的差异可能只有寥寥几行,新建一个显然不合适,当这种需求越来越多时,shader数量则会指数级增长。

为了避免这一点,unity的shader lab提供了使用不同的关键字组合来区分不使用不同代码片段的shader。我们可以使用#pragma multi_compile或者#pragma shader_feature来声明关键字,之后使用预处理指令#if defined进行不同的判断,例如上面的需求就可以这样完成:

// .shader
#if defined(VT)
    new_pos = ApplyVT(old_pos);
#endif

在c#脚本中,可以通过启用或关闭关键字来决定是否启用这个功能。

// .cs
material.EnableKeyword("VT");
material.DisableKeyword("VT");

至此,已经基本了解了变体的概念。那么现在再来谈谈声明关键字的两种方式。

首先是#pragma multi_compile ,当存在多个使用此声明的关键字组,那么最终生成的变体为各个关键字的全排列,这会导致变体数量的指数级增长:

#pragma multi_compile A B
#pragma multi_compile C D
#pragma multi_compile E F

最终会生成8个变体,这个增长速度显然是不能接受的。实际上可能有一些变体在实机上并不会用到,那么就会产生大量冗余的变体。#pragma shader_feature 就是为了缓解这个问题而产生。使用这个声明关键字,实机运行时只会存在实际使用的关键字组合。但这样又引出了另外一个问题:如何保证所有会在运行中使用的变体被正确的收集呢?这也就是本文所讨论的事。

变体收集

这里的变体收集,就是将项目下所有使用到的变体存入变体收集文件(.shadervariants)。如果使用unity直接构建的话,是不需要该文件来记录变体的引用信息。但是当存在热更需求时,这里一般会将全部的shader打进一个单独的Bundle中,将各种材质打到多个分散的Bundle包中,如果没有变体收集文件来记录变体引用信息,会导致无法将需要的变体打入Shader Bundle中。

了解了变体收集的必要性后,就先来看看unity提供的变体收集文件:

内容很简单,就是就是记录shader中每个pass的关键字引用信息。可以在选中shader后,从上方选取相应的关键字来构建自己所需要的变体。

很显然,这种手动的方式可能只适合类似于补漏的操作,项目一般都会使用大量的变体,手动一个一个添加明显不太现实,一是工作量庞大,再者容易出现漏变体的情况。所以我们需要一种自动化的变体收集,也就是常说的跑变体。

在Project Setting→Graphics下,我们可以看到下面的内容:

当需要开始进行变体收集时,可以先点击Clear清空当前已经追踪的变体,然后进行游戏,保证尽可能多的游戏内容能在视口内出现,之后保存进asset即可。但这样也容易导致变体的遗漏,毕竟一些犄角旮旯的地方可能会被忽视;再者当某个场景更新了小部分材质,那边就要重新跑一遍所以场景,这也是相当麻烦的。所以,我们需要一种自动化的变体收集方案,旨在尽可能地自动化收集项目下的大部分变体组合。

一般而言,自动化的变体收集有两种思路:

  1. 遍历所有材质和shader的关键字,自己进行组合构建变体;
  2. 遍历所有材质,将其放在某在场景中让unity进行渲染,实际上和之前提到的跑变体是一致的。

第一种方式有一些unity内置的关键字无法收集,可能会存在一些问题,所以这里选用第二种方式来进行收集。

自动化工具

对于第二种方式,让我们将这个问题分解成各个步骤来看:

  1. 遍历哪些材质?
  2. 放在什么场景中渲染?

首先要有一个材质来源,这个来源可以是整个项目下的所有材质,也可以是来自项目的资源表,也可以是build setting下选中的场景。这部分内容具有相同的特性,所以可以将其抽象为接口增加灵活性。

public interface IMaterialProvider
{
    public List<string> GetMaterialPaths();
}

接口很简单,返回会引用材质的资产路径或者材质路径本身。实现该接口,就可以自定义材质的来源。接下来我们在工具内部来解析所有资产所引用的材质。

对于一个资产,可以使用AssetDatabase.GetDependencies(string pathName, bool recursive) 函数来获取给定路径所依赖的资源路径,之后再筛选材质文件即可。当传入的资产路径很多时,这个步骤实际上会耗费大量的时间,所以这里需要寻找加速的方法。使用缓存可以说是较为常用的加速思路了,幸运的是,这里也可以进行缓存加速。

查阅相关文档后得知,unity为每个资源维护了一个资源引用的哈希,使用AssetDatabase.GetAssetDependencyHash(string pathName) 函数来获取,通过判断该哈希是否发生变化,就可以判断对应的资产引用信息是否有变化。有了这个就可以开始接下来的工作,首先我们需要一个资源引用信息的数据结构:

struct DependencyData
{
    public string _name;
    public Hash128 _dep_hash;
    public List<uint> _dep_list;
    public DependencyData(string name, Hash128 hash, List<uint> dep)
    {
        _name = name;
        _dep_hash = hash;
        _dep_list = dep;
    }
}

结构很简单,一个字符串记录资源的名称,也就是路径,一个哈希来记录其引用哈希,再有一个整数列表来记录其实际引用的资源路径。其实可以注意到,资源实际引用的路径有很多都是重复的,为了避免不必要的文件读写耗费,使用整数来索引路径即可。接下来的工作就比较简单,工具开始运行时,首先从磁盘上加载引用信息,然后每一个资源路径,如果在缓存中,直接返回即可,否则就添加或者更新缓存。这时候整个引用的解析速度已经很快了,那么是否可以更快呢?

可以注意到此时收集到的路径并不完全属于材质,所以还需要筛选,这属于普通的字符串操作,完全可以使用并行操作。在项目中,并行会带来三到四倍的速度提升。

在引入了缓冲和多线程之后,引用的解析时间从原有的十五分钟缩短到数秒之内,当然这个时间会受到缓存更新数量的影响,不过这个提升也可以说是相当令人满意了。材质收集完毕后,我们就要确定在什么场景里渲染。场景可以根据其光照模式来进行分类,比如根据light map的烘焙方式来区分,需要哪些烘焙方式,就将材质放置对应内的场景内渲染。

至此,前面所提到的两个问题都已经被解答,那么在收集工作开始之前,还有一项比较重要的事:现在很多材质可能会引用相同的shader并且使用一致的关键字,也就是一致的变体,这些冗余可能会在开发过程中产生很多,首先需要对他们进行去重,否则会影响收集速度,更甚者导致内存溢出直至崩溃。对于材质,我们可以将其分为两大类:静态和动态。所谓静态材质,就是指在游戏运行过程中,不会有从脚本控制某个关键字启用或者关闭的操作,一般而言用于场景的材质居多;动态则会存在这样的需求,一般为人物或者特效。对于这两类材质,会有稍微不同的收集策略。为了使用方便和确保每个材质都被渲染了一帧,这里使用编辑器协程来完成整个收集过程。

进入场景之后,首先创建一系列的小球,作为材质在场景内的载体。之后,根据这些小球的包围盒计算摄像机的视椎体,从而让所有小球都能存在于摄像机的视野内。对于每一个场景,可能会存在一些相对于场景的全局变化,我们可以将这些变化封装为可调用对象,在渲染单个场景时迭代这些对象即可。对于静态材质,直接使用加载出来的材质渲染即可。对于动态材质,我们需要根据某个配置表,来开启或者关闭某个关键字,该配置表的结构也比较简单,只需要存放shader名称和需要修改的关键字列表即可。当然为了确保冗余的变体最少,配置表是需要手动配置的。至此,项目下所有变体的收集工作就完成了。下面用伪代码来演示一下整个工作流程。

for(s : all_scene)
{
     while(scene_state!=empty)
    {
         scene_state().apply();//应用场景灯光环境
         for(mat : all_materials)//首先静态和动态材质走一遍相同的流程
                show_one_frame()
         for(dynamic_mat : dynamic_materials)
         {
        //读取配置表中的关键字应用到动态材质
              for(keywords : runtime_keywors_config[dynamic_mat.shader])
                {
                        keywords.apply(dynamic_mat);
            show_one_frame()
                }
         }
         scene_state().move_next();
    }
}

最后再介绍一下辅以该工具的编辑器。

主要实现了对于变体收集文件的增删改,一般用于工具收集完成后手动调整,再者就是当变体数量很多时,整个界面也不会卡顿,基本满足查看的要求。

总结

变体收集工具至此基本完成,在实现过程中学习和借鉴了很多人的思路和实现,并将其和项目需求相结合。当然工具还存在着很多不出之处,不过主体已经搭建完成,剩下的内容根据使用情况满满调整即可。

欢迎加入我们!

感兴趣的同学可以投递简历至:CYouEngine@cyou-inc.com

#我的成功项目解析##引擎开发工程师##图形引擎实战##游戏开发#
全部评论

相关推荐

2 1 评论
分享
牛客网
牛客企业服务