【性能】ugui对象不可视

前言

在很早之前我做过一次性能分析,分析物体在各种不可视方案下的性能开销但是这个是在对于Gameobject的分析,现在在做ugui的界面显隐的时候,又多出了好几种方案,现在来最这几种方案做一个分析。

正文

测试代码还是和以前一样,对物体进行10万次显隐操作。

隐藏控件

Canvas

在ugui中,界面的渲染都是基于canvas的,所以当我们隐藏掉canvas这个控件的时候,界面也将看不见,所以我们先来做一次canvas的控件显隐操作

因为我的子物体有6个image所以当canvas被隐藏的时候,它会去回调image的渲染回调,所以我们可以看到调用image.OnCanvasHierarchyChanged调用了120万次。回调中因为会用到一些对象,所以它的GC压力很大。

Image

对于单一的物体,我们以Image为例,它的渲染是基于它的Image这个控件,所以当我们把控件隐藏了,它将不再被渲染

这里我们看到它的主要开销是OnEnable和OnDisable这两个方法。

设置透明

CanvasGroup

CanvasGroup是用来控制子控件的alpha等一些界面属性的,所以当我们把它的alpha设置成0的时候,子节点的alpha的值也就变成0了。

看到ugui放出来的CanvasGroup的代码可以看到它有一部分的实现在c++里的,当CanvasGroup的alpha的值发生变化,应该是要发送消息告诉子节点的透明度的变化,所以这里的120万的调用我们猜测是因为子节点有六个,所以要抛消息到所有的子节点上。

image

当image的alpha的值被设置成0的时候,我们就看不到这个组件了

可以看到它就只需调用自身,没有其他的函数调用。

设置大小

这个就在之前的测试里做过的,因为大小为0的时候,它将不会被渲染出来,所以也就不会可视化出来。改变的是矩阵。

结论

在我看来我觉得还是设置大小是最优的显隐方案,用代码测试一次就好了。大家一起来看看还有什么情况是没有测试到的呢。

[设计模式]单例模式初探

前言

最近在审核自己以前的代码,看来下自己之前为了方便,很多小的功能都用单例模式来进行编写。因为已经距离现在有一定的时间了,有些东西没有考虑清楚,今天我就初级的讲一下单例的几个坑。

正文

我需要一个对象

当我们在使用一个引用对象的时候,我们需要把它实例化,然后才能使用,实例化的概念就是在内存上给你分配一块内存,然后根据对象的构造函数,往这块内存里写数据,最后把这块内存的引用返回给你。所以当我们实例化之后,我们就在内存上占用了一块内存。

我是个渣男

当我们这个对象在使用完了之后,我已经不再需要它了,那我就需要让它消失,把它所占用的内存给回归到内存池里去,可以让别的对象所用。但是我不想每次都要自己去管理让它被销毁。它能不能自觉点,我不需要的时候就去自我销毁,减少我的工作量呢?在现在很多高级语言里,他们的虚拟机都给我们集成了这项功能,它的名字就叫GC(垃圾回收)。当我需要一个对象的时候,我就向托管堆里去申请一块内存来使用,当我们不在使用的时候,我们把对这块内存的引用给释放了。然后通过root节点找到它引用对象的再引用,一直找到最后的引用节点(就是一层一层的递归寻找)把它们标记成被引用,然后当GC回收的时候,它们找到这块内存的标记是引用就会过滤掉,把那些不再被引用到的内存给回收掉了。正是因为在打标记的时候需要做递归寻找,这就是为啥会说GC有很大的消耗的原因,需要我们管理好引用对象的生成。

我觉得你很有用,你可以一直跟着我

当我们需要经常使用一个对象的时候,我不想经常性的实例化这个对象,我希望它能永远的存在。这个时候静态类就出现了,我们可以直接通过它这个类名去直接调用它里面的方法,它的所有方法和变量都应该做成静态的。当一个静态对象的出现,它就进化成root节点之一。将从它开始查找打标记。

你不要一开始就出现

当我们把一个类定义成静态类的时候,它里面的所有变量,属性,方法都将成为静态的,也就是说只要程序一启动,它就把它所需要的内存所占用了。有的时候,我们可能会在很晚的时候调用它,或者在这一次应用启动的时候都不会调用它里面的方法,那它在启动的时候就分配内存,就会造成内存泄漏了。这个时候我们就可以在在普通类里声明一个静态变量,它的类型就是这个类。给一个静态属性,当我们调用这个属性的时候才开始实例化这个变量,这个时候才开始分配内存,当它是一个静态属性的时候,它还是一个能永远存在内存中的root节点。

你是一个成熟的对象,用完就要放手

当我们生成了一个静态属性的时候,它是一个root节点,所以它所引用的引用也会一直在内存中,不会被释放。所以对象我们在使用完了之后要把它释放掉,我们就要定义好这种类里的方法都不需要缓存数据的。要不然内存爆炸的锅就要你来背一背了。

哦!同时出生了这么多个

当我们使用了多线程的时候,就会发生这样的事,这个对象被同时要求做好几件事,当第一次调用的时候,它就会同时生成好几份对象,因为在调用的时候它是没有实例化的,所以我们就会同时进行实例化,那它就同时会创建好几个对象。这个时候我们就要把这个方法设置为同步方法。让它在同一时间只能被一次调用。

你干活可真慢

当我们把这个获取对象的方法设置成同步方法的时候,它在同一时间只能被调用一次,当我们在多线程的时候,其实我们只是为了规避它被创建多次,这个时候,我们可以把它的同步方法放在第一次创建生成对象的时候,当对象已经存在了,那就算被同步使用,其实也不会对结果产生什么差异,毕竟我们在这里是尽量使用方法内部的变量 ,而不会去使用类内的局部变量。

总结

class Singlecase{
    private static Singlecase instance = null;
    private static object locker = null object();
    public static Singlecase Instance{
        get{
            if(instance == null){
                lock(locker){
                    if(instance == null){
                        instance = new Singlecase();
                    }
                }
                return instance;
            }
        }
    }
}

上面的实例代码就是单例模式,单例模式在我的理解力就是为了能做一个全局的工具类,为了做一些通用的简单计算,不涉及一些数据的保存,只是为了一些数据处理。当我们使用单例模式的时候,需要保证我们这个类对一个不需要的对象,一直保持着引用,而使它的内存没有被回收掉。

【Unity】IL2CPP初探

前言

在上一篇文章发出来之后,一大佬和我说,他详细的看了好几遍,完全不知道我在说什么。给我提了一个建议,就是用问题带领读者去思考,我所讲的知识是什么。这次我就按这个思路来写一篇文章。试试效果怎样。

正文

一堆数字的困惑

不知道大家有没有看过这样的一种错误堆栈信息,全都是一些数字,看不出来它所代表的类和函数,还有函数中的哪行代码的问题。然后脑袋里有一个大大的问号,这个究竟是啥呀,该怎么解析呢?这个东西就是elf文件的符号,它需要靠一张符号表来解析成我们人类可以认识的内容。选项现在我们来看看它的字典是什么?

符号表

大家应该知道,真正在计算机上跑起来的东西是一组组二进制文件。我们所编写的什么c#这样的语言其实是属于高级语言,它需要通过一系列的编译变成机器所能执行的机器码。当不同的文件相互糅合的过程,就是将它们之间对地址的引用,或者可以说是对函数和变量的地址的引用进行合并。那怎么来进行这一步的合并呢?人们就想到了这样的一种方法,把每一个文件都解析出一张表,用来记录这个文件中所用到的所有符号以及它们所对应的符号值。当在链接的时候,文件就会去互相寻找自己需要的符号来完成链接。这张表就是符号表。

符号表长啥样子呢?

在汇编阶段,就是生成目标文件的时候,就会产生这张符号表,每一个生成的elf文件都有符号表。符号表中的符号和源码中的变量名和函数名是一一对应的(这种对应关系不是说它们名字是一样的,而是说它们的一种映射关系,在c++编译的时候偶他们生成的就不是完全一样的)。这里是一段测试代码:

//main.c

void test2();
int main(){ 
    test2();
    return 0;
}

//test.c
static int a;
int b ;
static void test1(){
    return;
}

void test2(){
    return;
}

生成目标文件之后,用read -s test.o main.o命令即可查看其中的符号表,如下:

Symbol table '.symtab' contains 11 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS main.c
     2: 0000000000000000     0 SECTION LOCAL  DEFAULT    1 
     3: 0000000000000000     0 SECTION LOCAL  DEFAULT    3 
     4: 0000000000000000     0 SECTION LOCAL  DEFAULT    4 
     5: 0000000000000000     0 SECTION LOCAL  DEFAULT    6 
     6: 0000000000000000     0 SECTION LOCAL  DEFAULT    7 
     7: 0000000000000000     0 SECTION LOCAL  DEFAULT    5 
     8: 0000000000000000    21 FUNC    GLOBAL DEFAULT    1 main
     9: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND _GLOBAL_OFFSET_TABLE_
    10: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND test2 //注意这里,Ndx是UND的,且value为0

// test.o的符号表

Symbol table '.symtab' contains 12 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS test.c
     2: 0000000000000000     0 SECTION LOCAL  DEFAULT    1 
     3: 0000000000000000     0 SECTION LOCAL  DEFAULT    2 
     4: 0000000000000000     0 SECTION LOCAL  DEFAULT    3 
     5: 0000000000000004     4 OBJECT  LOCAL  DEFAULT    3 a
     6: 0000000000000000     7 FUNC    LOCAL  DEFAULT    1 test1
     7: 0000000000000000     0 SECTION LOCAL  DEFAULT    5 
     8: 0000000000000000     0 SECTION LOCAL  DEFAULT    6 
     9: 0000000000000000     0 SECTION LOCAL  DEFAULT    4 
    10: 0000000000000000     4 OBJECT  WEAK   DEFAULT    3 b
    11: 0000000000000007     7 FUNC    GLOBAL DEFAULT    1 test2

其中,value叫符号值,对于变量和函数而言,符号值就是他们的地址,size是一个符号值所占字节数,type是符号的类型,像变量的类型就是OBJECT,函数的类型就是FUNC。Bind列是符号的作用域,LOCAL表示是局部符号,GLOBAL表示是全局符号,WEAK表示是弱符号等等。Vis列表示符号的可见性,一般用的比较少。Ndx列
表示符号所属的段的index(.text段,,data段等等)。Name列就是刚才说的与变量函数名一一对应的符号名。
注意一下,在没有链接之前,main.o并不知道test2函数的定义和地址,所以在main.o的符号表里将test2标记为UND(undefine的意思),地址值也缺省为0000000000000000,等到链接的时候寻找到定义了test2函数的目标文件的符号表,就提取出它的地址值。
现在用gcc test.0 main.o将它们链接到一起。导出a.out的符号表

//a.out的符号表

Symbol table '.dynsym' contains 6 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND _ITM_deregisterTMCloneTab
     2: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __libc_start_main@GLIBC_2.2.5 (2)
     3: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND __gmon_start__
     4: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND _ITM_registerTMCloneTable
     5: 0000000000000000     0 FUNC    WEAK   DEFAULT  UND __cxa_finalize@GLIBC_2.2.5 (2)

Symbol table '.symtab' contains 66 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
    ......
    31: 00000000000005f0     0 FUNC    LOCAL  DEFAULT   13 frame_dummy
    32: 0000000000200df0     0 OBJECT  LOCAL  DEFAULT   18 __frame_dummy_init_array_
    33: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS test.c
    34: 0000000000201018     4 OBJECT  LOCAL  DEFAULT   23 a
    35: 00000000000005fa     7 FUNC    LOCAL  DEFAULT   13 test1
    36: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS main.c
    37: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS crtstuff.c
    38: 0000000000000834     0 OBJECT  LOCAL  DEFAULT   17 __FRAME_END__
    49: 0000000000201010     0 NOTYPE  GLOBAL DEFAULT   22 _edata
    50: 0000000000000694     0 FUNC    GLOBAL DEFAULT   14 _fini
    51: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __libc_start_main@@GLIBC_
    52: 0000000000201000     0 NOTYPE  GLOBAL DEFAULT   22 __data_start
    53: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND __gmon_start__
    54: 0000000000201008     0 OBJECT  GLOBAL HIDDEN    22 __dso_handle
    55: 00000000000006a0     4 OBJECT  GLOBAL DEFAULT   15 _IO_stdin_used
    56: 0000000000000620   101 FUNC    GLOBAL DEFAULT   13 __libc_csu_init
    57: 0000000000201020     0 NOTYPE  GLOBAL DEFAULT   23 _end
    58: 00000000000004f0    43 FUNC    GLOBAL DEFAULT   13 _start
    59: 0000000000201010     0 NOTYPE  GLOBAL DEFAULT   23 __bss_start
    60: 0000000000000608    21 FUNC    GLOBAL DEFAULT   13 main
    61: 0000000000000601     7 FUNC    GLOBAL DEFAULT   13 test2 //重点关注
    62: 0000000000201010     0 OBJECT  GLOBAL HIDDEN    22 __TMC_END__

这里看到有两个符号表,一个是.dynsym,一个是.symtab。这里简单介绍一下,.dynsym是动态符号表,是动态链接的时候用到的,而.symtab是静态符号表,金泰链接时用到的。

那在Unity生成的符号表是啥格式的呢?

通过上面我们知道了,我们要把二进制的那串信息解析成我们能看到的,需要用到符号表来对应起来,那我们就开始去Unity里看看这个小可爱藏在哪里的。

Android 的符号表

官方库的符号表
除了上面去找直接到unity的安装路径下找符号表,我们还能在打包之后在Temp\StagingArea\libs[architecture]路径下的。
我们还可以通过打包选项选择Create symbols.zip之后,打完包的符号表会生成一个.zip文件放在apk同级路径下。

IOS的符号表

当我们在xcode中使用Archive来编译的时候,会生成一个.xcarchive文件,这个文件可以在window页签里点击organizer这个选项看到。然后再.xcarchive文件右键显示包内内容,我们就能看到一个.dsym文件。这个就是我们需要的ios的符号表。

人工找太麻烦了

有些时候我们在使用第三方的bug记录工具的时候,需要上传这个符号表,通过堆栈的解析,我们就可以在第三方的工具上看到错误的类和函数信息。但是如果每次我们都通过手动找到文件,然后去上传。且不说我们上传的版本是不是和我们发布的版本一样,每次都要靠人工去操作,这是对人力的一个极大的浪费。这个时候,我们就可以通过Jenkins这样的流水线的工具来自动搞定它。对于androi的符号表我们还好操作,毕竟它出包了以后,会放在一个固定的路径下。但是ios你就能发现,通过直接生成的.xcarchive文件是放在一个默认的文件夹下,它的名字是根据时间来变化的。年月日时分秒都上了,这如果我们需要匹配到文件名那得循环遍历了,虽然能够解决,但是处理方法及其的不优雅。那我们是不是可以直接把.xcarchive文件生成到我们指定的路径下呢?稍微一查,嘿嘿,我找到了,可以用过官方的编译命令行来处理这件事xcodebuild -exportArchive 这个命令指定.xarchive的生成路径,那后面我们直接拼接固定路径就能找到.dsym文件了。

让我们去定位问题吧

我也不会解析符号,都是通过第三方的工具来解析,网上一堆,就不说了。右手问题,左手是字典,这翻译很快了吧。

怎么会找不到方法

通过翻译之后的堆栈,我们定位到问题是找不到一个静态方法,通过对比代码,我们发现这块用了反射,而且在mono环境下却不会出现。难道是因为打包的姿势不对?那我们去看看IL2CPP究竟是个什么样的可爱玩意。

IL

来我们来进行一波说文解字,头两个单词是IL,那我们去查下IL是个啥东西。通过微软官方的解释,IL是微软出品的一种基于.NET平台的中间语言。那我们来讲讲为啥需要这个中间语言。因为机器能认识的语言是汇编语言,这个语言太晦涩难懂了,入个门都要好长时间,对于当代这么快节奏的社会来说,平常人根本不能忍受,所以这种苦难还是那些牛人去体验吧。所以微软就出了一个稍微能让人看懂的语言IL语言,它比汇编要好认一点,最起码有一些高级特性,泛型呀,类呀方法呀,继承,字符串等等都能认识。那我这种垃圾码农就很容易写出我想要的代码。然后通过解释器,把IL转换成汇编,让CPU去跑吧,反正我才不管你汇编语言是啥玩意,也才不管你机器的CPU架构是啥玩意。某一天,我觉得IL语言也写的很不爽,不自由了。这个时候微软掏出了高级语言(c#,vb,F#),它帮我们把很多需要关注的点(内存管理等等)给管理起来的,我们就可以只去写逻辑(这个时候大部分人就可以进化成逻辑狗),去关注算法怎么实现,不去管理内存究竟是怎么管理的,不需要知道内存怎么回收的,反正高级语言都给你安排的明明白白了。

IL2CPP

那我们知道了IL是什么,那我们来看看IL2CPP是干啥吃的,它是把我们的IL语言编译成机器能跑的机器码。可能有人会问了,上面讲了IL就是会被转换成汇编呀,那现在这步和上面那个步骤有啥区别吗?其实也没事区别上面讲的是一张实时的编译,就是在运行的时候在把IL编译成机器码,而IL2CPP就直接在离线的时候把IL编译成机器码,一个是离线操作,一个是实时操作的。那我们知道了离线操作,和实时操作,那我们来看IL2CPP是怎么把我们的IL编程机器码了。

它通过了一个叫il2cpp.exe这个离线编译器把IL编译成c++的语言,然后再去生成对应平台的机器码。c++那被优化的可太好了,各个平台的c++编译器都优化到极致了,所以相当于站在巨人的肩膀上,歇一口气,我们很多事都可以少做了,直接用现成的。

那丢方法的原因在哪里呢?

通过上面我们了解到,我们的代码是通过AOT来编译的,那在AOT编译的时候,对于那种没有直接引用的方法或者类(比如反射等),它不知道要不要生成机器码,毕竟离线的也就是做一次扫描代码的活,又不是真的跑了一遍代码。所以当它觉得你没有用,它就会通过代码托管剥离来删除它觉得你没有用到的代码。你说它为啥还自作主张去删减代码呀,这不是吃饱了撑的。其实它这样做也是为了能加快我们打包的效率,毕竟代码少了转换成c++的时间也就变少了,构建机器码也就变快了。那我们能做什么操作来明确告知它我们这个代码是要用的,你不能删除。

我告诉你这块代码我要用,上帝来了也不能动

那我明确使用这个方法,扫描的时候,就可以知道这个代码块被引用了,不能被删除了,还有一个通过一个叫Link.xml的文件配置不能被删除的代码块。

结束语

也不知道这样的写的风格大家认不认可。快给一点建议呀!

【Unity】关于unity中IL2CPP遇到的一些情况

前言

在很早之间因为ios和android需要支持arm64位的时候,我就已经在打包的时候接触到了IL2CPP,但是那个时候也就只是为了能打出一个arm64位的包,所以对于它的一些原理都没有去了解过,碰到的一些问题也没有去详细解答,用了最蠢的方式来规避问题,刚好最近也用到了IL2CPP所以在这里记录下,最近对它的一些理解

IL2CPP的前世

首先我们需要知道Unity的跨平台的一些知识点,为啥Unity出的包能在ios和android这两个平台上跑,而我们不需要做啥特殊的操作,只需要在选择出包平台的时候,选对应的打包平台。这是因为在unity会把我们所编写的c#翻译成CIL(公共中间语言),然后再汇编成字节码。

CIL

CIL是一种属于通用语言框架和.NET架构的低阶的人类可读的编程语言,它是有微软公司所提供的。目标是把.NET框架的语言(c#,VB等等)被编译成CIL然后在汇编成字节码。CIL类似一个面向对象的汇编语言,并且它是完全属于堆栈的。它运行在虚拟机上。是一种独立于具体CPU和平台的指令集。然后由JIT编译器转换为特定的计算机环境特定的机械码。这个是在执行CIL之前完成的。它的转换的根据需求转换的,并不是编译整个CIL代码。

上图展示了CIL的创建和转换为机械码的过程。

  • 源代码在CLR的编译时由特定的语言编译器转换为CIL,并且元数据也在这个时候生成。元数据包含代码中类型的定义和签名,运行时信息等内容。
  • CIL是一个用于安全,部署,版本控制等编译代码库,它有两种类型,进程程序集(EXE)和库程序集(DLL)。
  • JIT编译器将CIL转换为特定与JIT编译器运行的计算机环境的机械代码。按需转换。
  • JIT编译器转换的机器代码由计算机处理器执行。

这块内容其实我也没有详细去研究(虽然我买了这本书,但是包装壳还没有拆,等我看完再水一篇)。但是看完上面的这些基础的信息,我们应该对于C#的跨平台有个简单的了解,它通过CIL这个中间语言来打包成程序集,然后再通过JIT来转换到各个平台所能跑的机械码。所以跨平台的重要点就是CIL和解释CIL的编译器。

关键点

但是在ios平台上机器码被禁止映射到内存中,就是不能把JIT实时编译出来的机械码放在一个地方,所以就不能实时在平台上跑CIL的程序集。
所以在这个时候,我们就需要提前做好机器码的编译(AOT)。所以在这个时候,我们打包成功以后,我们在包里的代码就已经变成了机器码了。

Unity中的IL2CPP干了啥

AOT编译器

IL2CPP AOT编译器名为il2cpp.exe。它是由c#编写的受托管的可执行程序,它接受我们在Unity中通过Mono编译器生成的CIL,并把它生成指定平台下的c++代码。
它的工具链如下:

运行时库

它还支持虚拟机的运行时库。几乎都是用c++代码实现的这个库。运行时库的名字叫ilbil2cpp,它作为链接到播放器可执行文件的静态库提供。这个是IL2CPP技术的主要优势,简单且可移植的运行时库。它还有一个关键部分就是垃圾收集器(GC)。使用的是libgc一种BoehmDemers-Weiser垃圾收集器。但是libil2cpp的设计允许我们使用其他垃圾收集器(GC)。

为啥转成CPP呢

  1. 运行效率快:官方实验数据,换成IL2CPP以后,程序运行效率有了1.5-2.0倍的提升。

  2. Mono VM在各个平台移植,维护非常耗时,有时候不可能完成。

  3. 可以利用现成的在各个平台的c++编译器对代码进行编译期优化,这样可以进一步减少最终游戏的尺寸并提高游戏运行速度。

  4. 由于去除了IL加载和动态解析的工作,使得IL2CPP VM可以做的很小,并且使游戏载入时间缩短。

脚本限制

System.Reflection.Emit

AOT平台无法实现System.Reflection.Emit命名空间中的任何方法。System.Reflection的其余部分是可接收的。只要编译器可以推断反射使用的代码需要在运行时存在。

序列化

AOT平台可能会由于使用了反射而遇到序列化和反序列化问题。如果仅通过反射讲某个类型或方法作为序列化或反序列化的一部分使用,则AOT编译器无法检测到需要为该类型或方法生成代码。

通用虚拟方法

如果使用泛型方法,编译器必须做一些额外的工作,才能将编写的代码扩展到设备上执行的代码。如果使用虚拟方法,将运行时而不是编译时确定行为,存在虚拟方法时,编译器可在不完成明显的地方轻松要求从源代码生成运行时代码。

从原生代码调用托管代码

需要编组到c函数指针以便可以从原生代码调用的托管方法会在AOT平台上有一些限制:

  • 托管方法必须是静态方法。
  • 托管方法必须具有[MonoPInvokeCallback]属性

    线程

有些平台不支持使用线程,因此任何使用System.Threading命名空间的托管代码都将在运行时失败。此外..NET类库的某些部分存在对线程的隐私依赖。

异常过滤

IL2CPP不支持c#异常过滤器。应该将依赖于异常过滤器的代码修改为正确的catch块。

TypedReference

IL2CPP不支持System.TypedReference类型和__makerefc#关键字

MarshalAs 和 FieldOffset 属性

IL2CPP 不支持在运行时反射 MarhsalAs 和 FieldOffset 属性。它在编译时支持这些属性。应正确使用它们以进行正确的平台调用编组。

动态关键字

IL2CPP 不支持 C# dynamic 关键字。此关键字需要 JIT 编译,而 IL2CPP 无法实现。

代码托管剥离

托管代码剥离将从构建中删除未使用的代码,从而可以显著减小最终构建大小。使用 IL2CPP 脚本后端时,托管代码剥离还可以减少构建时间,因为需要转换为 C++ 并进行编译的代码减少。托管代码剥离将从托管程序集(包括从项目中的 C# 脚本构建的程序集、包含在包和插件中的程序集以及 .NET 框架中的程序集)中删除代码。

托管代码剥离的工作方式是对项目中的代码进行静态分析,检测出在执行过程中永远无法访问的类、类成员甚至函数的某些部分。可以通过 Player Settings 窗口中的 Managed Stripping Level 设置(在 Optimization 部分)来控制 Unity 删除无法访问的代码的激进程度。

重要信息:当代码(或插件中的代码)使用反射来动态查找类或成员时,代码剥离工具不能总是检测出项目是否正在使用这些类或成员,因此可能会删除它们。要声明某个项目正在使用这样的代码,请使用 link.xml 文件或 Preserve 属性。

Unity代码的托管剥离级别设置

使用项目的 Player Settings 中的 Managed Stripping Level 选项来控制 Unity 删除未使用代码的激进程度。

C#所引用的代码剥离

UnityLinker

Unity 构建过程使用一个名为 UnityLinker 的工具来剥离托管代码。UnityLinker 是 Mono IL Linker 的一个定制版本,专为 Unity 设计。UnityLinker 基于我们的项目分叉,此分叉密切跟踪上游 IL Linker 项目。(请注意,该分叉中未维护 UnityLinker 的 Unity 引擎特有自定义部分。)

UnityLinker 的工作方式

UnityLinker 将分析项目中的所有程序集。首先标记顶级、根类型、方法、属性、字段等,例如,向场景中的游戏对象添加的 MonoBehaviour 派生类便是根类型。然后,UnityLinker 分析已标记为要进行识别的根,并标记这些根所依赖的托管代码。完成此静态分析后,所有剩余的未标记代码都无法通过应用程序代码中的任何执行路径来访问,并将从程序集中删除。

请注意,这一过程不会对代码进行混淆处理。

反射和代码剥离

UnityLinker 不能总是检测出项目中的代码通过反射时来引用其他代码的实例,因此可能会误删除实际在使用的代码。将 Managed Stripping Level 设置从 Low 提升为 High 时,代码剥离导致游戏中发生意外行为变化的风险也会增加。这种行为变化小到细微的逻辑变化,大到调用缺失方法造成的崩溃。

UnityLinker 能够检测和处理一些反射模式。如需查看该工具可以处理的最新模式的示例。但是,如果使用的不仅仅是简单的反射,必须给 UnityLinker 一些提示,说明哪些类不应该被处理。可以通过 link.xml 文件和 Preserve 属性的形式提供这些提示:

Preserve 属性 — 直接在源代码中标记要保留的元素。
link.xml 文件 — 声明应如何保留程序集中的元素。

UnityLinker 在分析程序集中未使用的代码时,会将使用属性或 link.xml 文件保留的每个元素视为根元素。

Preserve 属性

在源代码中使用 [Preserve] 属性可防止 UnityLinker 剥离该代码如下属性:

  • Assembly:保留程序集内的所有类型(就好像您为每个类型输入了 [Preserve] 属性一样)。要为程序集分配 Preserve 属性,请将该属性声明放在程序集包含的任何 C# 文件中,但需在所有命名空间声明之外

  • Type:保留类型及其默认构造函数。

  • Method:保留方法、其声明类型、返回类型及其所有参数的类型。

  • Property:保留属性、其声明类型、值类型、getter 方法以及 setter 方法。

  • Field:保留字段、其声明类型和字段类型。

  • Event:保留事件、其声明类型、返回类型、add 方法以及 remove 方法。

  • Delegate:保留委派类型及其所有方法。

请注意,相比使用 Preserve 属性,在 link.xml 文件中标记代码实体可以提供更强的控制。

托管堆栈跟踪

使用发布版本配置时,IL2CPP 可能会生成缺少一个或多个托管方法的调用堆栈。这是因为 C++ 编译器已经内联了缺少的方法。方法内联通常对运行时的性能有好处,但可能会使调用堆栈更难理解。IL2CPP 始终在调用堆栈上提供至少一个托管方法。此方法便是发生异常的方法。调用堆栈上还包括其他未内联的方法。IL2CPP调用堆栈不包含源代码的行号信息,我们需要通过一个叫符号表的文件来对应起来

符号表

其实吧这个东西我也不太清楚是为了什么,网上查到的资料说,这个东西就用来保存函数地址映射信息的中转文件。当程序崩溃的时候,会生成一份崩溃日志,这个日志里所记载的东西就是app出错的函数内存地址,而这些函数地址是可以在符号表中找到具体的文件名,函数名和行号信息。每个app在导出的时候都会生成对应的符号表。

总结

现在我就说说我因为IL2CPP所遇到的一些问题

  1. 在之前使用pb做配置二进制序列化的时候,因为在反序列化时候,使用了反射的功能,导致在IL2CPP中解析出错,那时候,我没有仔细去研究为啥会出现问题,因为这是第一次升级IL2CPP的版本,所以我很强制的和同事一起写了脚本,把反射的所有方法都给翻译出来,强行当c#脚本调用。

  2. 在bugly上看崩溃日志的时候,发现在IL2CPP版本中显示的堆栈信息是字符码,不像之前使用MONO一样显示的函数名这样的信息。那时候知道有符号码这样的东西,但是经常上传的符号码和奔溃的版本不匹配,导致解析还是有问题。

  3. 在含有第三方插件的工程导出的时候,因为少做了一个Link.xml的代码裁剪过滤,导致因为第三方库使用了反射功能使得在IL2CPP的包上出现错误。

[Unity]unity中的音频插件Wwise

基本类型

这个类型是按照projectExplorer中来划分的

  1. Audio(音频对象)

  2. Event(事件)

  3. SundBank

  4. GameSyncs

基本结构

Audio

声音对象(Actor-Mixer Hierarchy)

它可以是语音(Sound Voice),也可以是音效(Sound SFX)。每个声音对象都包含一个源。这个源在wwise中有两种:音频源和插件源。
Sound Voice:这个是用于对白
Sound SFX:这个是用于Actor-Mixer Hierarchy内所有其他声音
音频源:是一个独立层次,将导入的音频文件和声音对象分割开来。音频源链接到导入到工程中的音频文件。

分组

将不同的声音对象编为一组,就能统一设置整个组的属性和行为了。

  1. 用Container(容器)来按照特定行为(随机,顺序或者切换等)来播放一组对象。
  2. Actor-Mixer(角色混音器)通常用来设定一组的整体属性(音量,音高)。
    Container处于Actor-Mixer Hierarchy中的第二层级,可以说它们是父对象和子对象。可以使用容器来为声音和其他容器进行分组。通过在容器内“嵌套”容器,可以得到多样化的结果,模拟真实声音表现。
    Actor-Mixer它比容器高一个层次。也可以说,Actor-Mixer可以是容器的父对象,但反之不可以的。Actor-Mixer可以是任意数量的声音,容器以及其他Actor-Mixer的父对象。
容器

鉴于游戏中的音频需要按照不同行为播放,可以选择不同的Container类型:Random(随机),Sequence(序列),Switch(切换)和Blend(混合)。

  1. 随机播放容器:可以进行标准随机选择,也可以进行洗牌选择,还能为随机容器内的每个对象指派一个权重值。
  2. 随机和序列播放都包含多个对象,所以必须得指定播放模式
    • Step-每次播放容器时,仅播放容器内的一个对象。
    • Continuous-每次播放容器时,会完整播放容器内的所有对象。
  3. 由于可能有多个游戏对象使用同一容器,因此需要决定游戏中一个容器的所有实例是作为一个对象处理,还是单个实例单独处理。
    • Global全局-将游戏内使用的所有该容器作为一个对象进行处理,因此可以针对所有游戏对象避免重复声音或者语音对象。
    • Game object游戏对象-将该容器的各个实例最为单独实体进行处理。即不会在所有游戏对象间共用声音内容。
  4. 在Switch Container中Switch或State和RTPC(实时参数控制)被用来代表各个不同条件。每个切换Switch/State/RTPC对应于与该条件有关的声音对象将会播放。
  5. 在Switch Container中切换变化的时候可以选择播放行为
    • Play-决定是每次切换被触发时都重新播放对象,还是仅当触发的Switch/State发生变化时才重新播放。
    • Across Switches-对于被指派至多个切换开关的同一对象,当触发新的Switch/State时是否继续播放。
    • Fade In-触发的新的Switch/State时,新对象是否应用淡入。
    • Fade Out-触发的新的Switch/State时,已有对象是否应用淡出。
  6. 在混合容器里可以添加:Blend Container,Random Container,Sequence Containers,声音,switch Container,Voices(语音)
  7. 在混合容器里可以同时播放多个对象。在Blend Track中启用交叉淡变会改变这些对象的听感和振动。
  8. 在使用交叉淡变过度时,需要注意以下:
    • 音频文件长度必须大于或等于0.2秒,交叉变淡时间最短为0.1秒。
    • 从声音A交叉变淡至声音B时,声音引擎所允许的淡变时间最长为音频文件A长度的一半。如果设置的值超过了,会自动调整到最大值。
    • 如果使用RTPC设置容器的音高值,或者在播放的时候触发了Set Pitch事件动作,则对声音应用交叉变淡时可能会产生意外结果。
    • 对源插件应用交叉淡变时,无法确定源的结束时间,那么淡变可能会被忽略。
    • 当Switch Container作为Sequence Container的子容器时,会根据指派给切换开关的对象数量,区别应用交叉淡变过度。
    • 交叉淡变期间,声音引擎会使用两个不同的声部。
    • 在低于音量阈值或超过播放数量限制时,Play from Beginning和Resume虚声部行为会影响声音持续时间,这不在交叉淡变时间机制考虑范围之内。
    • 当声部的音量低于阈值时,声部会变成虚声部。对于任何声音,会使用其所有音频通道的实际有效音量与阈值相比较。这个有效音量包括Actor-Mixer Hierarchy,淡变过度,互动音乐过渡,RTPC,状态,定位和衰减等所有的音量影响。
  9. 同时播放的流越多,要求的Pre-fetch(预取时间)越多。
  10. 在使用“Global”来避免声音重复,但是如果容器包含的声音数量有限,并且同时要播放很多该容器,可能会出现重复问题。所以我们要确保容器内的声音对象至少为播放实例数量的两倍。
  11. 在使用Blend Container播放的时候,因为该容器内的所有对象都会同时播放,因此会消耗大量内存,可以使用虚声部设置来降低CPU占用,但是如果频繁地进行短暂的交叉变淡,则可能会导致出现错误。
  12. Switch Container和Blend Container均可配合RTPC使用,并会产生相似的效果。如果希望在不同时间播放不同的对象,并且不需要交叉变淡,那就可以使用switch Container。如果希望一直播放所有对象,并且需要交叉变淡那就可以使用Blend Container。

输出总线(Master-Mixer Hierarchy)

总线是一种用来对工程中对象进行分组的机制,目的是管理混音和最终声音输出。可以为工程中的通路定义相对属性,状态,RTPC以及效果器。
这里有三种类型:

  1. Master Audio Bus(主音频总线):它决定了音频的最终输出。
  2. Audio Bus(音频总线):可分组在Master Audio Bus之下,以有助于组织和提供混音。可以在总线上加效果器。
  3. Auxiliary Bus(辅助总线):可分组到任何辅助总线或音频总线之下。可以对辅助总线应用效果器。在辅助总线中无法做闪避,HDR混音和声部调节。
    Actor-Mixer Hierarchy的声音会被默认连通到Master Audio Bus,但是可以在修改到我们自己建立的总线上去。
定义总线的相对属性

相对属性是积累的,总线的属性值会累加到其下的子对象中

  1. Bus Volume-总线音量,用于直接调整总线的音量大小。
  2. Voice Volume-声部音量,总线中正在播放的音频对象所应用的衰减。
  3. Voice Pitch-声部音高,总线中正在播放的音频对象的播放速度。
闪避信号

在游戏的不同时刻,希望某些对象比其它对象更加突出。可以修改以下属性和行为来控制信号闪避方式:

  1. Ducking volume(闪避音量)
  2. Fade out(淡出)
  3. Fade in(淡入)
  4. Curve shape(曲线形状)
  5. Maximum ducking volume(最大闪避音量)
    总线无法要求自己或其直接父总线闪避

定位

  • 局部环境音-发声体保持在一个位置上。
  • 非局部环境声音-发声体可移动,并不依附于某个特定游戏对象。
  • 移动对象声音-发声体随着某个特定游戏对象一起移动。
  • 游戏界面声音-此声音与特定游戏界面要素或者其它保持在屏幕固定位置上的道具相关联。

声音和音乐对象选择是否3D空间化的区别在于如何将源声道映射至输出扬声器。不空间化,那么输出声道将于源声道完全匹配。空间化,则可将各个输入声道输出至环绕声环境中的任何扬声器。在运行中可通过Positioning Type的RTPC更改定位方法。无论是否使用空间化,都提供衰减功能,即模拟音频信号在发声体原理或背离听者时的自然衰减。
虚发声体:
为了计算源对各个扬声器的声道贡献大小,“虚发声体”以半圆形状环绕听者设置。半圆的大小取决于散布(100%代表整圆,0%代表听者正前方的点)。然后将半圆划分为与声道数量相同的,大小相等的区域,源的各个原声声道都作用于指定的区域。一旦完成,将计算各个虚发声体对各个扬声器的实际贡献。

空间定位

空间化确定游戏3D环境中对象的实际位置或定位。
两种空间化:

  • Position(位置):仅使用游戏对象的三维定位信息。
  • Position+orientation(位置+朝向):同时使用游戏对象的三维定位和朝向信息。
    三种定位类型:
  • Emitter(发声体):由实时游戏位置数据来定义“发声体”游戏对象的空间定位。在选择Emitter时,WWise中所定义的传播属性将直接关联至游戏中听者和发声体的位置或朝向。
  • Emitter with Automation(发声体自动化):通过在Wwise中创建路径来定义“发声体”游戏对象的空间定位。定义忽略游戏对象位置和衰减的一组固定的传播行为。
  • Listener with Automation(听者自动化):通过在Wwise中创建路径来定义“听者”游戏对象的空间定位。定义忽略游戏对象位置和衰减的一组固定的传播行为。

Attenuation(衰减)

除了空间化外,还可以定义对象的衰减设置。衰减设置用于模拟当信号原理听者时的自然衰减。
衰减基于以下两个属性:

  • Distance attenuation(距离衰减):根据发声体和听者之间的距离来影响信号的强度。
  • Cone Attenuation(声椎衰减):根据发声体相对于听者的朝向来影响信号的强度。
    距离衰减使用一系列曲线进行定义。这些曲线将距离值映射到音量等Wwise属性值。通过定义曲线各个点的属性,可以在对象远离听者时控制对象的音量衰减。
    锥形衰减使用一系列夹角进行定义。这些夹角用于定义声源前方,侧方和背后的区域。通过定义声源周围的这些不同区域,可以更具游戏中的朝向来模拟对象的衰减。
    Attenuation ShareSet

当游戏中的许多对象拥有相同的衰减属性,因此可以先创建一份衰减,然后使用Attenuation ShareSet在工程中的许多对象之间共享。

定义各种对象属性的衰减曲线

可以为以下Wwise属性创建衰减曲线:

  • Output Bus Volume-输出总线音量。连接到音频输出总线的信号的衰减或振幅。
  • Auxiliary Send Volumes-发送到游戏定义和用户定义的Auxiliary Bus的信号的衰减或振幅。
  • Low-Pass filter-根据指定值来衰减高频的递归滤波器。低通滤波器的单位代表已经应用的低通滤波比例,0表示无低通滤波,100代表最大衰减。
  • High-pass filter-高通滤波器。根据指定值来衰减低频的递归滤波器。高通滤波器的单位代表已应用的高通滤波比例,其中0代表无高通滤波,100代表最大衰减。
  • Spread-散布。扩散到附近扬声器的音频量或百分比,以使声音能够随着距离的增加从低扩散的点声源变为完全扩散的传播源。值0表示某扬声器附件的发声体的所有声道只通过该扬声器播放。值100表示将扩散声源的声道,以便通过所有扬声器都能够听到或感觉得到。
  • Focus-聚焦。百分比值,用于收缩由扩散值生成的虚拟发声器。焦距0%表示虚发声器保持不变,值越高,各个虚发声器越靠近源声道的原点。
使用锥形边界模拟方向性

在现实中声音通常具有一定的方向性。在wwise中模拟声音的方向可以使用声锥。声锥使用不同的夹角模拟声音在特定方向上的传播。当听者一道这些夹角外卖时,Output Bus Volume将衰减。
以下角定义声锥的区域:

  • Inner angle:内角。此夹角定义的区域中输出总线音量不衰减,也没有低通滤波效果发生。
  • Outer angle:外角。此夹角定义的区域中输出总线音量发生衰减,低通滤波效果保持在最高水平。
    Output Bus Volume在过度区域中发生滚降。在无衰减发生的内角边界和达到最大衰减值的外角边界之间,使用线性插值法对音量进行衰减。在外角定义的区域中,音量衰减始终等于最大衰减值。
Speaker Panning和3D Spatialization交叉淡变

当我们需要做一种从声像摆位的环境过渡到需要空间化的环境。好比玩家在一个房间里听音乐,然后从房间走到户外,户外在播放环境音的情况。在做交叉淡变时,必须同时计算两种定位类型,因此消耗的运行时CPU资源也比只选择一种类型时更多一些。

Spread的影响
Spread的衰减曲线可以根据距离来做修改。这个距离是发声体和听者之间的距离长度。

朝向的影响

受到两种不同朝向因素的影响:

  • 发声体相对位置
  • 发声体相对朝向
    发声体的朝向仅影响多声道声源。

Focus

Focus(聚焦)参数能够在应用旋转和扩散之前,将每个输入声道的虚声源集中在一起。在对离散多声道文件进行摆位时,Focus参数用于减少声道信号在其他输出通道中的泄漏。但是不适用于Ambisonics声像摆位,因为其设计即包含输出声道间的泄漏,尽管是均匀泄漏。因此,当声源的声道配置是Ambisonic时,将会忽略Focus值。

斜面声源在平面声道配置中的摆位

虚声源坐标在扬声器平面上的投影,用于计算摆位。
听者的旋转会影响到:

  • 发声体声场的相对朝向;
  • 点声源的入射角。

Height Spread的效果

在典型的Distance Spread曲线设计中,声源距离听者越远,使用的Spread值越小(反之亦然)。这样的话,在距离较近时,声源会像个圆;在距离较远时,就会像个点。在声源靠近时,其射入方向会突然改变,听起来可能不太自然。
在声源位于听者上方或者下方很远的位置并被摆位到2D声道上时,也会出现类似的失真。在距离较远的情况下,Spread曲线会估算出一个较小的值;这时声源若穿过听者所在平面上的路径,其声像就会从一侧突然摆位到另一侧。对此可在对象从听者上方或下方穿过时,借助四周的扬声器来对声音进行散布处理,以此在第三维度上没有扬声器的情况下传达高度的变化。

使用动画路径定义空间定位

设置预定义位置时,无论听者在游戏中的位置和朝向如何都会遵循以下情况:

  • 声音将总是通过相同的扬声器播放。
  • 通过相同的电机感受到振动。
    使用动画路径定义定位信息。动画路径由每次定义源位置的若干个控制点构成。如果是创建了多个点后,对象则将随着时间沿着路径进行运动。

优先级

在游戏中,可以同时播放很多对象,对象的数量甚至有可能超出由项目团队设置的数量上限。为了有效管理播放对象的数量,必须规定同时最多可以播放多少个对象,已经哪些对象会被优先播放。
三个属性可以确定在游戏中将同时播放哪些对象:

  • Playback limit-限制同时运行播放的对象数量(不包含虚声部)
  • Playback priority-一个对象相对于另一个对象的重要性。
  • Volume threshold-低于这一特定音量的对象将不会播放。
    声音引擎的各个内存池设置内存阈值,当启动之后,声音引擎会定期检查所用内存百分比是否低于指定的阈值。如果超过了,将会忽略优先级比较低的声音,用来播放优先级比较高的声音。
    当音量降低至音量阈值,或当声音的数量超出Playback Limit上限时,会为对象执行以下操作:
  • 继续播放
  • 终止
  • 移至虚声部列表
    虚声部列表是一种虚拟环境,在这个环境中,声音引擎会监视列表里的声音的特定参数,但不会执行声音处理。

Virtual Voices:

在决定使用哪个虚声部的设定之前,需要了解下它们的内存占用和CPU占用

  • Play from beginning-该选项仅使用少量内存和CPU,但如果对声音进行了流播放,则从虚声部返回时可能存在延迟。
  • Play from elapsed time-改选项可以节省部分CPU和内存,但如果流播放声音,则声音从虚声部返回的时候可能出现延迟。
  • Resume-改选项使用较少的CPU,但会占用较大的内存,因为当声音从虚声部返回时将会保留内存缓冲区。
    通过以下方式来管理工程内同时播放的各种对象:
  • 限制每个游戏对象可播放的实例数量,或对于Actor-Mixer Hierarchy\Interactive Music Hierarchy 对象及值对象,在全局范围内限制其可以播放的实例数量。
  • 限制可通过特定总线的对象;
  • 限制整个游戏中的对象总数。
    当在Actor-Mixer 或者Interactive Music层级设置了播放数量限制,如果子对象忽略了父级对象的Playback Limit那么子对象的数量不记录在父对象限制里;可播放的实例数量是它们的总和。
    由于各个对象的优先级已经在角色混音器或者Interactive Music层上进行了指定,因此总线上不存在播放优先级设置。

Event

Wwise使用Event(事件)来驱动游戏中的声音,音乐,对话和振动。由此提供了两种Event

  • 动作Event
  • Dialogue events(对白事件)
    动作事件包含若干个动作(Action),这些动作将指定Wwise对象是否播放,暂停,停止等。
    Event中的每个动作都有相应的作用范围设定。作用范围确定Evnet动作是应用于全局所有GameObject还是触发该事件的特定GameObject。全局的GameObject的话相当于应对的是总线。
    在创建动作事件后,这些事件可通过打包成SoundBank集成到游戏引擎中,在相应的条件下调用这些事件。由于游戏引擎使用名称或ID去使用,所以在创建完事件之后,就可以集成到游戏中去,之后就算进行事件内容的微调,只要名称和ID没有更改,就不需要额外的编程。

将游戏事件和Wwise中的事件进行匹配

使用SoundBank定义文件来跟踪哪些时间已经集成到游戏中,确实哪些事件以及还需要在Wwise中创建哪些事件。

以编程方式停止,暂停和恢复声音

事件信息存储在声音引擎的默认内存池中。为了避免占用默认内存池中的过多空间,可以使用SDK中的ExecuteActionOnEvent()功能,以编程方式停止,暂停和恢复声音。

Game Object

Game Object(游戏对象)是Wwise中的核心概念,因为声音引擎中被触发的每个Event都与一个GameObject相关联。Game Object通常指的是游戏中能够发出声音的特定对象或元素。
Wwise为每个GameObject 存储了各种信息:

  • 与GameObject相关联的音频对象的属性偏置值,包括音量和音高。
  • 3D位置和朝向
  • Game Sync信息,包括State,switch和RTPC
  • 环境效果
  • 声障和声笼
    在使用GameObject之前,程序员需要在代码中注册GameObject。

Listener

Listener(听者)在游戏中代表话筒。Listener在游戏3D空间中拥有位置和朝向。在游戏期间,Listener的坐标与GameObject的位置进行比较,以便将与GameObject相关联的3D声音指定给相应的扬声器,来模拟3D环境。

GameSyncs

在完成初步的游戏设计后,您可以开始考虑如何使用被称为 Game Sync(游戏同步体)的 Wwise 元素来串接和处理互动音频中的变化和替换行为,这些行为也是游戏内容的一部分

State

使用State可以优化声音和音乐素材,允许为同样的声音灵活创建不同的“Mixing Snapshot”(混音快照),响应游戏中的变化并改变全局属性。通过改变声音或音乐对象的属性,无需添加新素材就可以创造性地匹配各种游戏场景。

使用State

State需要隶属于StateGroup(状态组),才能供Wwise对象使用。可以按逻辑将各种State划分StateGroup来简化管理。
在同一个StateGroup内各State之间能平滑过渡,可以定义状态过渡的时长。
State可以通过两种机制集成到游戏中。一种集成机制是调用含Set State动作的事件,另一种是调用State Group和State本身。

Switch

在使用层次结构组织对象之外,Wwise中的Switch可以简化声音,音乐和振动对象的组织。Switch代表游戏中特定元素的不同条件,可以用来管理这些条件下的相应对象。将特定条件下的对象指派到特定Switch,这样播放该游戏元素时,将播放当前switch对应的对象。
将Game Parameter值映射到Switch
可以使用Game Parameter的值来驱动Switch的切换,在映射之前需要创建并定义游戏参数。

RTPC

在游戏中实现一些动态效果,可能会需要将特定对象的属性与游戏中的某些参数值绑定。在Wwise中可以使用实时参数控制(RTPC)来实现这一点。使用曲线沿线上的一系列点创建RTPC。该曲线表示Game Parameter和Wwise中音频属性之间的关系。当游戏中的Game Parameter发生变化时,Wwise使用RTPC曲线来确定相应的属性值。
对于Wwise中的对象,总线,效果器,衰减和切换开关使用RTPC。
在将RTPC作用于现有属性值的时候,最终的属性值采用以下两种方式中的一种:

  • Absolute:将使用RTPC确定的值,忽略对象现有的属性值。
  • Relative:RTPC确定的值将与对象的现有属性值相加。
    绝对和相对设置为预先定义,无法更改。

Trigger

一种Wwise元素,跟其他Game Sync一样会被游戏调用。触发器会定义Wwise将做出怎样的特定响应来反应游戏中的情节变化。Trigger在响应游戏突发事件时,将播放Stinger(插播乐句)。Stinger是一种短乐句,它会与当前音乐叠加并混合播放,以音乐的形式来对游戏做出响应。

SoundBank

有两类Bank

  1. Initialization (Init) bank: 基础的init.bnk,它是初始化库。一种特殊的库,其中包含有关工程的所有通信信息,每次wwise生成SoundBank都会自动创建init.bnk。只需要在游戏开始前加载一次,就能得到游戏期间工程的所有通用信息。所以它必须得是最先加载的声音包。
  2. SoundBank:这个文件中同时包含事件数据,声音,音乐和振动结构数据或音频文件。它可以在需要使用内部信息的时候才加载出来,在不用的时候去卸载掉。提高平台内存的利用率。

内容

  1. Event data:事件数据
  2. structure data:声音,音乐或振动结构数据
  3. Media files:媒体文件

加载

  1. LoadBank
    此方法显式加载SoundBank中的所有内容,而不验证媒体文件是否已经加载到内存。这可能导致同一媒体文件被多次加载到内存中。
  2. PrepareBank
    用于加载SoundBank中的所有内容,此方法不是立即加载媒体文件,而是通过使用PrepareEvent()机制来将所有媒体加载到内存中。通过使用此机制加载媒体,Wwise首先查看媒体文件是否已经存在内存中,然后再加载它。这可以避免内存中出现媒体文件重复,从而将内存占用保持在最低水平。
  3. PrepareEvent,PrepareGameSync
    在动作事件被游戏调用前,使用PrepareEvent来prepare这些事件。prepare事件时将从文件系统中加载所有被引用的媒体文件,如果所有被引用的结构元数据尚未加载,则还将从SoundBank中加载这些结构元数据,当动作事件不再需要的时候,可以将它Unprepare(接触预备),相应媒体文件于是将从内存中被清除掉。这可以避免内存中出现重复文件。

注意

使用Prepare相关的加载方式会降低媒体加载到内存中的速度。读取时间延长是因为声音引擎需要搜索磁盘。

结论

Wwise的核心由五个主要组件构成的

  • Audio object(音频对象)

  • Event(事件)

  • Game Sync(游戏同步体)

  • Game Object(游戏对象)

  • Listener(听者)

Event 和 Game Sync 是 Wwise 和游戏中都不可分割的两个组件。这两个组件在游戏中负责驱动音频,在 Wwise 的音频素材和游戏中的组件之间架起必要的桥梁。

【系统】Unity3D中的AssetBundle资源管理器(终)

前言

水不动了,一篇小小的资源管理器已经水了好几篇了,太让人难堪了。但是也让我知道了,沉淀没有想象的那么容易,就在以为自己思考的很全面的时候,测试下来又会冒出很多问题。

正文

问题

这次遇到的问题是在以下情况下出现的,上次我们用的异步做资源加载,但有一种情况我没有考虑到(也是我实际使用时碰到的一种情况)我们需要给一个 sprite 赋值一张图片,如果是在异步的情况下,这个资源回调还没有加载回来时,又因为刷新问题需要给这个 sprite 重新赋值一张图片,如果这时旧的图片在新的图片前面加载出来,那么最终显示的图片就是我们不想要的旧图片了。这也是异步的加载的一个缺陷,我们不能保证加载回来的顺序。

解决办法

我会在加载的时候给加载的接口一个当前需要赋值的对象的索引 id —— 这个索引 id 是唯一的。将这个 id 传到资源管理器里做一次判断,如果这个唯一索引已经对应上了一个 assetname 的话,就把之前记录下来的 assetname 所对应的 asset 卸载,然后在资源管理器里和需要加载的 asset 做一次绑定。这样的话,就能保证所加载的资源不会因为异步加载的顺序所产生一些显示错误。

更新

说明

之前在讲异步加载的时候,有一个特性我遗漏了,就是异步相当于开了一个线程池来同时加载所有的东西,这样在设备高速发展的今天,我们能高效的运用多核技术,使我们的游戏更流畅的运行。

技术更新

unity 的异步接口有个很有用的功能,就是如果你直接去获取 assetbundleCreaterequest 中的 assetbundle 属性 或者 assetbundleRequest 中的 asset 属性时,它在获取这个资源的时候,属于阻塞获取,除非 assetbundle,asset 加载回来,否则进程会一直阻塞在这里。但是我们说了,异步加载其实是线程池在跑的,所以这个阻塞不会影响到其他资源已经创建的加载。我们可以根据这个特性,来做一个同步接口,这个接口的好处就是,我们可以避免在打开 ui 界面的时候 因为资源没有加载 而实例化界面对象所产生的显示问题。这是这个特性的一种好的使用方案。

还有一种使用方案是,我们可以在界面打开的时候预加载一组 AssetbundleRequest 的对象,它是异步方法,所以当我们在界面打开的时候,它可能就已经加载了一部分了。打开的时候,我们可以直接去获取它的 asset,如果没有加载成功,它就会一直阻塞在这里,直到加载成功返回,这样做的好处是能让我们感觉界面打开的更流畅。

后语

这个资源管理器本来是为了沉淀自己的技术而做的一个技术文档,却没想到最后成了每周完成一篇blog的任务了。但是在写这篇文章的时候,我也感觉到了自己对于技术的把握还是差了一点,有些东西还是要多看、多学,很多很有用的特性都可能是自己一下子想不出来的。但是后期的文章,我将改变下方案,我会把周更变成月更,这样给自己更多的时间去对于相关的技术做一个查缺补漏。

【系统】Unity3D中的AssetBundle资源管理器(三)

前言

上一次讲到了用asset做资源管理,但是在asset的管理的时候,我们是同步把所需要的asset全部都加载出来的,前段时间看了下别人的管理机制,发现可以用unity的资源加载依赖来做这件事,就是不用我们自己加载asset,而是改用记录asset的依赖就好了。

正文

基于unity自身的依赖加载

在以前我们了解过,unity在加载一个资源的时候,如果它所依赖的assetbundle已经被打开了,那当我们加载它的时候,unity会自动把它所依赖资源一起加载出来,让asset能正常使用。在这个情况下,那当我们在根据asset来记录依赖的时候,我们只需要把它依赖的asset所在的assetbundle给加载出来,然后去对asset做一个引用计数的记录,这样的话,我们就可以省下去做加载依赖的asset这步的操作。在上一次我们所测试下来的结果,让我在这个版本做的方案就是,对assetbundle的卸载使用true这个参数,让它完全卸载掉。所以asset对于我来说就是一个做依赖关系绑定的用途。

改用异步的方式来做资源加载

在体验游戏的时候,我们可能最讨厌的就是界面卡顿,当点一个按钮需要打开一个界面的时候,我们发现,界面需要很久才出现,在等待的时候,我们会产生点击失败的感觉,这样很影响用户体验了。这种卡顿在我之前的方案里,很大一部分是因为我使用的是同步加载接口,它会阻塞住线程,等待资源全部加载回来才结束。这样的结果不是我们想要的表现方式。所以我改成用异步的方案来加载资源。但是在加载的时候,因为是异步,所以也会出现加载资源回来的顺序不一定是按照我们加载之前的顺序回来的。而且要抵消全部加载回来才显示的问题,我们可以做一个UI表现的动效,来抹平加载时间内的UI没有资源展示。

加载方案

当我们加载一个UI界面的时候,我们可以用对这个UI界面所需要的资源进行一个大的分类,如果是那种滑动列表内的资源,我们可以做一个优先级比较低的加载顺序,然后滑动列表加载进来的时候,做一个进入动画。那界面上顺序比较高的是哪个呢?我认为应该是玩家第一眼就能见到的界面,当我们在加载回来之后就可以直接显示了,那些一开始让玩家见不到面的资源,就可以在播动效的时候,再加载回来,其实不会慢很多,但是我们这样做就可以及时响应玩家的操作。我们需要给资源做一个引用列表,哪几个需要显示的prefab在引用它,还要记录一个状态,如果在加载中,所有需要引用的资源直接添加到引用列表里,如果它已经加载成功了,那就直接返回回调。

骚操作(我一般叫奇巧淫技)

在unity自带的资源依赖管理里,我们在加载出来一个prefab的时候如果它的资源没有加载成功,但是我们现实出来,它是会变紫红色的,但是,如果我们在接下来的时间内能加载出它依赖的asset所在的assetbundle的话,unity就会自动把资源赋值上去,正常现实的。所以在加载如果玩家看不到的资源,我们就可以一开始不用理会,只要第一眼能看到的资源以及加载成功了,我们就可以直接显示prefab。这里我就不知道如果出现这样的情况在unity底层会不会有cpu的消耗,一直在找丢失的资源。

卸载方案

如果我们使用了异步来做资源管理的话,会出现一个问题,就是我们如果这个资源还在加载的时候,我们就已经要把它卸载了,那我们就要在资源加载结束的时候做一个判断,这个资源是不是还用。所以我们需要给这个资源的回调做一个管理,如果不需要的话,我们就把回调事件-1,当资源加载回来之后,发现自己没有任何一个资源需要引用了,那它就把自己的状态变成卸载状态,等待下一次的回收。

资源的引用计数的问题

在之前我们所做的所有方案都是在一个理想的状态下做的,如果我们生成的一个prefab的资源,它被clone了,直接走unity的实例化,然后再把它给赋值到一个其他的地方,这个时候我们是不知道它真实的生命周期,这样的情况下,我如果默认它的生命死亡时间是跟某一界面,或者某一模块相关的话,那我们卸载了资源,它在别的地方的实体就会出现紫红色,然后出现问题。所以这种情况下,我现在想到的办法就是框架限制,资源的生命周期就限制死了,不能其他的地方使用我这个里面的资源,你要用自己去加载去。这样的话就能暂时解决了。其实这种方案也是有问题的,靠自觉,不知道什么时候就会出现问题的。只能等下一次解决了。

【unity】优化-cpu

CPU耗时

类中存在空的updata,lateupdate和FixedUpdate这样的方法

出现问题的主要原因

  1. 这些方法是native层对托管层的调用,c++与c#之间的通信本身就存在一定的开销
  2. 当这些方法的调用,Unity会进行一系列的安全监测(比如保证GameObject没有被销毁)导致cpu时间的消耗

这个类中的方法存在Camera.main的调用

这个方法获取属性的方法实际上是一个Get的方法,每次调用的时候都会去寻找场景中第一个tag为“MainCamera”的相机,并且返回。这个寻找是遍历所有的tag,并且不会被缓存的。

该类的方法中存在ComputeBuffer.GetData的调用

这个是从GPU的buffer中读取对应的计算结果并输入到相应的数据中,由于整个过程是一个同步的操作,所以调用的时候会堵塞调用线程,直到GPU返回数据为止。

该类中存在对纹理SetPixels的调用

SetPixels用于对纹理特定的mipmap层的像素进行修改,它会将一组数据的像素值赋值到贴图的指定mipmap层,调用Apply()后将像素传到显卡。

该类的方法中存在GameObject.SendMessage调用

这个方法会遍历Gameobject上所有的组件,以及组件中的所有函数,会导致很高的cpu消耗。

该类的方法中存在GetComponentsInChindren调用/GetCompoentsInParent调用

这两个的使用都会涉及到较大范围内的搜索遍历。Unity在GameObject的GetComponentsInChildren的方法是支持传入一个List的,这个是可以减少堆内存的分配。

调用了FindObjectsOfType调用

这个会对于场景中的GameObject和Component进行遍历,并将与目标type类型相同的组件以数组的方式返回。这个方法还会产生对堆内存的分配。

存在Reflection相关函数的调用

在调用反射相关的方法时,需要获取类型与函数等信息,并且进行参数校验,错误处理,安全性检查等。还会造成堆内存的分配。

存在对Renderer进行Material/Materials的获取

如果对renderer类型调用.material和.materials,那么unity就会生成新的材质球实例。所以我们可以使用MaterialPropertyBlock来替代Material属性的操作。使用相同的Shader但是Material实例不同的GameObject是无法进行合批操作,所以dc增加了,也就增加了cpu的消耗。
每次调用.material都会生成一个新的Material实例,并且GameObject销毁后,Material的实例是没有办法进行自动销毁的。所以需要手动调用“UnloadUnusedAssets"来进行卸载,就会添加性能开销;如果管理不好的话,内存也会很高。

Ugui中UpdateBatches占用耗时较高

在ugui的UI元素刷新是基于canvas下的,所以当我们ui上的数据做SetActive的时候是会把Canvas下的所有元素都触发了,可以看到Rendering.UpdateBatches的调用会比较多

Ugui中的Rebatch优化

Rebatch发生在c++层面,指的是Canvas分析UI节点生成最优批次的过程,节点数过多会导致算法(贪心)的耗时比较长。对应SetVerticesDirty,当一个canvas中包含的mesh发生改变就会触发(setactivity,transform,颜色,文本内容等),canvas独立处理,互相不影响的,消耗在meshes按照深度和重叠情况排序,共享材质的检测。
优化点:

  1. Canvas动静分离,合理规划,不能太多(会提高DC的消耗)
  2. 减少节点层次和数量,合批计算量小,速度快。
  3. 使用相同材质贴图的ui尽量保持深度相同。

Ugui中的Rebuild

rebuild发生在c#层面,是ugui库中的layout组件调整RectTransform尺寸,Graphic组件更新material,以及mask执行Cull的过程,耗时和发生变化的节点数量基本呈现线性相关。

只有LayoutGroup的直接子节点,并且是 Graphic类型的(比如 Image 和 Text)会触发SetLayoutDirty。

Graphic改变的原因包括,基本的大小、旋转以及文字的变化、图片的修改等等,对应SetMaterialDirty。
优化点:

  1. 少用layout

堆内存优化

  • 无法及时的释放内存。
  • 过多的分配次数会到时内存中的碎片过多,从而无法开辟所需要的连续内存。
  • 内存的GC会照成卡顿

存在.tag的调用

获取tag的时候实际上是调用了get_tag()函数,从native层返回一个字符串,字符串的返回会造成堆内存的分配,unity也没有通过缓存的方法来做优化。

该类中存在对纹理的GetPixels()/GetPixels32调用

一般是为了获取指定mipmap层的全部像素信息,而图片上的像素往往是很庞大的。
会在堆内存上分配内存,用来存储纹理数据的像素信息,而且引擎不会对其进行缓存。

使用了Linq相关的函数调用

linq在执行过程中会产生一些临时变量,而且会用到委托。如果使用委托作为条件的判断方式,时间开销就会很高,并且会造成一定的堆内存分配。

存在对Renderer进行sharedMaterials的获取

对.sharedMaterials的调用,依旧会分配堆内存,每次调用都会分配一个用来存放Material的索引的数组。

存在Input.touches调用

.touches的实现是每次都会new一个数组touches,从而造成一定的堆内存分配。

存在对TextAsset调用

在获取bytes属性的时候,Unity会从Native层获取字节数组(byte[]),从而分配一定的堆内存

C#和lua的穿插引用

c#层会维护一个cache来引用那些被lua访问过的C#层对象,这是为了防止当lua中再次访问该c#对象时,这个对象已经被c#的gc给回收掉了。但是如果lua始终保持着对某个c#层对象的引用,那讲会导致无法被释放。造成堆内存泄漏。

【算法】如何分析一个排序算法

排序算法的执行效率

  1. 最好情况,最坏情况,平均情况时间复杂度
    第一,有些算法会区分,为了好对比,我们最好都要做一下区分。
    第二,对于要排序的数据,有的接近有序,有的是完全无序的。有序度的不同,对于排序的执行时间肯定有影响的。

  2. 时间复杂度的系数,常数,低阶
    时间复杂度一般都是数据规模n很大的时候的一个增长趋势,所以在这个时候它就会忽略系数,常数,低阶。但是我们一般排序的数据规模10,100,1000,这样很小的规模的数,所以我们要考虑系数,常数,低阶。

  3. 比较次数和交换(或移动)次数
    基本比较的排序算法,会设计两种操作。一种是比较两个数的大小,一种是交换两个数的位置。所以我们在分析排序算法的时候这两个都要考虑。

排序算法的内存消耗

算法的内存消耗可以通过空间复杂度来衡量,但是针对排序算法的时候,我们新加了一个概念,原地排序。原地排序就是特指空间复杂度为o(1)的排序算法

排序稳定性

针对排序算法还有一个很重要的指标,就是稳定性。这个概念就是说待排序的元素中有值相等的元素,在排序之后相等元素的先后顺序不会发生改变
发生改变的排序算法叫不稳定排序算法
不发生改变的排序算法叫稳定排序算法
为什么排序的稳定性比较重要呢?一般来说我们实际使用的时候,很有可能对一组数据做两次排序。如果排序算法不稳定的话,那我们第二次排序就会覆盖掉第一次排序所产生我们需要的结果。

[lua]关于lua中函数点和冒号的区别

前言

在lua中创建一个可以被外部访问的函数有两种方式,一种是table.function一种是table:function这两种函数的调用也是可以使用点和冒号两种方式调用的。

第一种使用table.function创建函数

local f = {}
function f.test(self,x,y) 
    print(self)
    print(x)
    print(y)
end
return f

使用点去访问

local f = require(f)
f.test(f,3,4)

打印出来的值是,self为f这个对象,x为3,y为4

使用冒号去访问

local f = require(f)
f:test(3,4)

打印出来的self还是这个对象,x为3,y为4

结论

当我们在创建一个方法的时候,我们时候点创建然后用冒号访问的时候,会默认把当前所被使用的对象给传进来。

第二种使用table:function创建函数

local f = {}
function f:test(x,y)
    print(self)
    print(x)
    print(y)
end
return f

使用点去访问

local f = require("test")
t.test(3,4)

打印出来的值为:3,4,nil;

local f = require("test")
t.test(f,x,y)

这个时候打印出来值为f,3,4

使用冒号去访问

local f = require("test")
t:test(3,4)

打印出来的是:f,3,4

结论

在这个测试中可以表明,使用冒号创建一个函数的时候,在使用点去调用的时候,会默认在第一个参数前加一个self的参数。