[c#]值类型内存分布

前言

最近公司对于gc考虑的很严格,希望堆上内存只在系统初始化的时候才分配,以减少因为gc导致的cpu卡顿。在这个基础上仔细看了一下值类型的内存分布,了解一下值类型在内存上究竟怎么分配的。

值类型

在定义值类型的变量的时候会直接算好占用多少内存。

public struct DataUnion
{
    public long _i64;
    public ulong _u64;
    public double _f64;
}

在定义的时候就已经开辟了24字节的内存保存,就算不存值内存也已经开辟了。
但是他有一个好处就是不会增加gc计算。

public struct DataUnion{
    public data _data
}

在struct中保存的是引用类型对象的引用,是固定大小的,根据操作系统的位数,32位的占4字节,64位的占8字节。每次传递的时候值类型里的数据都会被复制一份,所以引用也会被拷贝一份。

自定义内存布局方式

通过使用[StructLayout(LayoutKind.Explicit)]和[FieldOffset]特性,这个结构体可以在相同的内存位置上保存多种不同类型的数据,通过这个特性,我们能使用同一个结构保存不同类型的值。

[StructLayout(LayoutKind.Explicit)]
public struct SlateDataBase
{
    [FieldOffset(0)]
    public long _i64;
    [FieldOffset(0)]
    public bool _bool;
    [FieldOffset(0)]
    public ulong _u64;
    [fieldOffset(8)]
    public string _str;
    [FieldOffset(0)]
    public IntPtr  _next;
}

SlateDataBase _dataBase = new SlateDataBase(){_bool = false};
private SlateDataBase _dataBase1 = new SlateDataBase();

void main(){
    IntPtr ptr1 = Marshal.AllocHGlobal(Marshal.SizeOf<SlateDataBase>());
    Marshal.StructureToPtr(_dataBase,ptr1,false);
    _dataBase1._next = ptr1;
    SlateDataBase data = Marshal.PtrToStructure<SlateDataBase>(_dataBase1._next);
    data._bool;
}

上段代码使用了IntPtr来保存对象指针,这样避免了结构布局中的循环依赖问题。同时通过了StructLayout来控制内存布局。但是我知道有一个问题:

  1. 如果我分配的内存不是从0开始的,那保存的值会有问题

注意点

  1. 在使用StructLayout做内存布局的时候是在创建的时候就分配内存了,值类型可相互覆盖,所以都可以从统一位置做偏移,但是引用类型保存的是指针大小,它的大小和对齐方式跟值类型不一样,所以它不能覆盖值类型的内存,得重新取一块内存保存。
  2. 需要自己手动做InPtr所占内存的回收,而且使用的是指针很容易出内存问题。
  3. 内存大小是通过最大偏移加类型所占内存。上面所占内存为8字节+string所占(8字节)=16字节

值类型分配地方

一般来说,值类型所分配的地方有两个地方,一个是堆上,一个是栈上。那什么时候分配在堆上,什么时候分配在栈上呢?


public struct SData{
    public int _int;
}

public class Data{
    public int _int;
    public ulong _ulong;
}

public class Data1{
    public Data _data;
    public SData _sdata;
}

public Data1 _data1 = new Data1();

public void Main(){
    SData sdata = new SData();
    sdata._int = 10;
    _data1._sdata = sdata;
    sdata._int = 20;
    Data data = new Data();
    data._int = 20;
    _data1._data = data;
}

上面的示例代码值类型的struct分别在堆和栈上都分配了。
栈上分配:
在函数内创建的sdata是分配在栈上,它的什么周期是跟着函数体的结束而一起结束,内存也就被回收掉了。
堆上分配:
当我实例化了_data1这个引用类型对象的时候,它就在堆上分配了内存,分别是_data的指针大小+_sdata这个struct所占用的内存大小。所以这个时候_sdata是分配在堆上,当我把函数内的
sdata赋值给_sdata的时候,是把sdata的内容给复制过去。所以当我赋值完了之后把sdata的int改成20,实际上_sdata中的_int还是10。

引用类型

在定义引用类型的时候在没有实例化引用类型对象的时候是不会申请内存的。

public class Data{
    public long _i64; 
}

void main(){
    public Data data;
}

在使用_i64的时候需要创建一个Data的引用类型对象,相当于对_i64做了一次装箱操作。
当内部保存的类型是引用类型的时候,因为不会出现拷贝引用类型数据,所以引用不会被拷贝。

实例化

当我实例化的时候,就会在堆上分配引用类型中值类型所占用的内存,在堆上分配一个8字节的空间,然后把指针地址返回给data。data就通过这个地址找到这片空间找到里面的_i64所写入的内容。

gc回收

之前说过,不讲了。

泛型类型

根据上面的特性,在构造值类型的泛型时,使用struct来保存

public struct ValueVariable<T> where T : struct{
    private T _value;
}

public ValueVAriable<int> _int_value = 10;
public ValueVAriable<bool> _bool_value = false;

这样我在保存一个值类型的时候也是通过值类型保存,所以不会产生gc问题。
引用类型就可以直接使用object来保存。

不安全代码

暂无

【URP】Android上URP失效

前言

这篇主要是讲一下最近遇到在android上urp不生效的问题。

正文

在ab中资源不一致

问题原因

在做功能的时候,因为贪图方便,所以在pass里处理过的rt直接渲染保存到resource中的rt上,然后在ui上就直接使用这张resource上的rt。当把这个ui做成ab之后,就会发现ui上使用的rt是黑的。通过打印这两个rt的GetInstanceID之后发现,这两个id是不一样的。其实这个很好理解的,因为在打ab的时候,是会把当前这个ab所依赖的资源在不打包成ab的时候,会生成一份放在这个ab里,所以在这步的时候,这个ui所依赖的rt就已经和resource的rt不一样啦。为啥会出现resource里的资源不能被ab资源所依赖,而会产生冗余的资源呢?我的理解就是因为ab是属于一个单独的资源系统,他其实是可以脱离untiy单独使用的,它的使用场景可能和打ab的unity工程不是同一个,所以不能保证ab的使用场景一定能有resource的资源。

解决办法

  1. 在pass里保存的rt直接通过代码新建一个,然后在ui里直接调用这个rt去显示。
  2. 直接把urpasset的资源和所依赖的资源都打成ab包。

renderTexture 格式问题

问题原因

在通过新建RenderTexture去解决上面那个显示资源部一致的问题时,发现这张rt在有的时候会出现纹理不能写入。我通过把rt保存到手机里发现这张rt是一张全透的图片。

解决办法

  1. 修改创建RenderTexture的Depth为0可解决。

UI刷新问题

问题原因

因为在截屏处理rt的时机是我们在打开UI界面的时候,所以当Features处理完设置好rt的时候,我们的UI是不会做刷新的。

解决办法

  1. 设置一个回调函数,当Features处理好RT已经保存结束,回调到ui上调用SetMaterialDirty来强制刷新

结束

在android上显示urp失效找了我一个礼拜的时间,其实核心的问题都很简单,但是因为是多个原因混合在一起导致的问题,所以在找线索的时候花了很长时间。在查找问题的时候,我们首先需要明确问题出现的点,在这个问题,因为是多个问题混合在一起出现的,首先我先确认一点,就是相机的纹理是否能获取到;这里我做的事情就和写代码做一些日志输出一样,我把相机问题保存在手机里。然后很幸运,这个纹理是有的;这就能解决我很大的一个顾虑,不是urp底层问题。后面就开始查为啥显示不出来,就一个节点一个节点的输出保存。ui上使用的rt是不是有纹理,获取之后纹理为啥没有更新。这样一点一点查,最后就解决掉。遇到问题不能害怕这个问题,而是要做一次拆分,去了解功能的每个关键节点;去看看这些节点是不是有问题。

[UGUI]ugui中同一batch的遮挡顺序

前言

最近被人问到同一batch中ugui的前后顺序是怎么表现出来的,之前我只考虑过一帧是怎么前后顺序的渲染,所以这次我讲说一下一个batch中的图片渲染顺序

知识点

  1. 使用xcode的FPS截帧
  2. 渲染的点的坐标
  3. 渲染三角形的顺序
  4. 渲染三角形的uv顺序

正文

渲染图片的本质

在一般情况下,我们常说的渲染一张图片其实是记录下一张图片的四个顶点位置,通过mvp变换,从模型空间转换到屏幕空间,然后渲染到显示设备上。在渲染的时候,我们需要定义三角形的顺序,根据对应渲染设备所使用的坐标系,我们通过左手或者右手定则,来确定渲染的朝向。定义的uv坐标来确定这三个点所需要渲染的颜色在uv图上的什么位置。通过上面的概念我们就应该能知道渲染一个图所需要简单的数据结构了。

我们只考虑最上面的那张图我们来简单定义一下数据结构

vector3[]index   --点坐标
int[2*3] vertex     --三角形的渲染顺序
int[2*3] UV             --uv坐标id

通过上面的结构体,我们就能正常的渲染出一张图片出来的。

ugui合批本质

我这里所说的合批是ugui的动态合批。一般来说,你可以认为它就是问了把多个mesh合并成一个mesh传到gpu去渲染,那什么是mesh呢?其实就是我们上面所说的那个结构体。既然知道了它是什么,我们就好来理解mesh的合并了。首先在ugui我们会对渲染的物体进行一次排序(这个内容我已经拖更很久了,大家可以去看官方的一节课)然后通过这个排序结果,我们就可以开始记录合并的mesh内容了。如果是同一个材质,同一个贴图,并且是相邻的,那就记录一次合并,如果不可以,那就把上一次合并的结果callback到gpu做一次渲染,剩下的再进行合并记录。

同一批次的遮挡顺序

通过上面我们就可以知道,同一个批次里面,如果图片前后有遮挡关系;首先我们通过排序,记录下所有点的位置信息,然后通过顺序我们记录下来三角形的渲染顺序

在这张图片,我们就能看出来,其实在这个批次的渲染里,我们是记录下来所有点,和所有三角形的信息给gpu去渲染的,在这次合批中我们是不会进行三角形的剔除操作。就算是所有做了一次完全的遮挡,其实我们上传的数据也是做了一次全量的记录的。所以同一batch的遮挡就是在合批时候的排序顺序的关系。

总结

渲染的本质是通过三角形的排布来做纹理的展示,所有的优化方式都是围绕这三角形怎么减少呀,三角形的颜色该怎么计算出来,这样的方式来做优化的。了解本质,你才能更轻松的理解方案。

[lua]upvalue和闭包

概念

当一个函数内部A嵌套另一个函数B定义时,内部的函数体可以访问外部的函数的局部变量,这种特征我们称为词法定界,B中被A访问到的变量我们称为upvalue。而闭包是指一个函数加上它可以正确访问到的upvalue。技术上来讲,运行时,我们执行的是闭包,而不是函数,函数仅仅是闭包的一个原型声明;闭包在完全不同的上下文中也是很有用途的。因为函数被存储在普通的变量内我们可以很方便的重定义或预定义函数。通常当你需要原始函数有一个新的实现时可以重定义函数。upvalue你可以理解成是一块特殊的内存,独立管理内部数据的,所以可以在多个闭包之间提供一种数据共享的机制。

示例解释

upvalue

function f1(n)
    local function f2()
        print(n)
    end
    return f2
end
local g1 = f1(11)
g1()                    --打印11
local g2 = f2(22)
g2()                    --打印22
g1()                    --打印11

在上面的例子里,我们可以看出来当g1 = f1(11)这个被执行完的时候,局部变量n的生命周期应该是结束了的。但是因为它成为了f2函数的upvalue,所以它的生命周期跟着g1的生命周期一起了,被延长了。下面的g2是重新执行了一遍f2所以它重新包了一个n做upvalue,这个时候upvalue的数据没有共享。

闭包

function f1(n)
    local function f2()
        print(n)
    end
    n = n+10
    return f2
end
g1 = f1(10)
g1()                    --打印20

在上面的这个例子里,我们发现n在包在f2内部的时候,成为f2的upvalue,但f1还没执行完成n还是f1的局部变量,所以执行了n = n+10的这个的时候,n的变化也在f2执行的时候被表现出来的。

upvalue的数据共享

function f1(n)
    local function f2()
        print(n)
    end
    local function f3()
        n = n +10
    end
    n = n+1
    return f2,f3
end
local g2,g3 = f1(1)
g2()                        --打印2
g3()
g2()                        --打印12

从上面的例子我们可以发现,当f1执行结束之后,lua会把内部所引用的n做成一个upvalue来作为f2和f3的upvalue,所以当我们g3调用了n = n+10的时候,n的改变也就改了那共同upvalue内的n,所以可以被g2函数所打印出来变化。

function f1(n)
    local function f4()
        local function f2()
            print(n)
        end
        local function f3()
            n = n +10
        end
        return f2,f3
    end
    n = n+1
    return f4
end
local g1 = f1(1)
local g2,g3 = g1()
g2()                    --2
g3()
g2()                    --12

所以我们就很容易理解上面这个例子为啥还是一样的打印结果,因为upvalue在一个函数内就只会有一份相同的。

总结

闭包这个概念在很多语法中都有的,之前有一点点了解,最近在做一些lua的内存泄漏优化的时候,发现因为一些全局表的函数引用,导致了一些传入进去的值被引用到,没办法释放,引起了一些内存上的增长,所以我们在给函数传值的时候,需要注意到这个函数什么时候被释放的,是不是不会被释放掉,如果它在一个全局table中,那么它所引用到的值我们就需要手动释放。关于upvalue的lua中是怎么实现的,之后有机会讲解一下源码。

[资源管理]代码赋值资源管理

前言

在前几天做了一次友好的学术交流,在这次交流中,对方提出了一种关于资源管理的新的思路,就是自己管理asset直接释放ab。我在事后想了一下,感觉这个能省一部分的内存,但是其中的坑还是有点的,所以现在就能写一篇文章来介绍这个方案。

正文

方案

在这套方案里,我们在加载好asset的之后,我们就可以调用unload(false)把ab给卸载了,然后通过resource.unload(asset)把这个资源卸载掉,那这样的好处在哪里呢?这样的好处就在我们可以在内存中减少一部分的ab的内存。

注意点

  1. 在我上一期关于资源管理的文章上说过,我的最后一套方案对于ab的卸载还是要基于ab的依赖关系进行的。那是因为如果不做ab的依赖管理卸载,那如果ab还没卸载,但是它所依赖的ab被卸载了,那下一次再加载依赖的时候,它就没办法被自动依赖赋值上prefab上的。所以我们在使用这套方案的时候,ab是不会被ab包所依赖的
  2. 如果这个资源被ab包所依赖,那还会产生一个问题,当我们调用false卸载资源之后,下一次加载ab包的asset的时候,它所依赖的资源也被加载出来了,那这个依赖的asset就会被重新创建一个。出现内存的冗余资源。

结论

所以我们在使用这个方案的时候,最好就是为了一些头像icon这类的texture资源,然后通过我们手动维护这个asset的生命周期,这样能少一部分内存。而且icon这类资源基本上都是使用我们代码赋值的。使用这个方案的资源一定不能被ab所依赖。

[技巧]表情输入的特殊处理

前言

在和前同事聊天的时候,他因为在接入翻译,所以问了我一个问题,关于在聊天中加入了表情的翻译处理。这个问题的点在哪里呢?是因为我们一般做聊天表情包的时候,绝大多数的处理都是加入一个特殊字符表示表情的起始关键字符,然后通过一个特殊的规则来匹配表情。但是在翻译的时候,很有可能会把这个表情给翻译成一个单词。通过他的讲解,我发现了其实通过一个小处理就能完美的解决掉它。

正文

转义字符

在接触字符的时候,我们肯定知道字符里有个特殊的字符,它叫转义字符。在ASCII码都可以用“\”加数字来标识的,那这个特殊字符就是“\”。它的含义就是用它来做一个关键字,然后它和后接的数字来表示一个特殊的含义。通过“\”这个字符把它后接的字符的含义给改变了。我们就可以通过定义一个特殊的字符来表示我们的表情。可能会有人问了,这个不就是一个简单的关键字标识嘛,有啥好讲的。

特定环境出现的问题

那后面我就讲一个特殊的环境。如果我们定义\1代表的是一个笑脸。我们在输入的时候,肯定是玩家选择了一个笑脸然后在发给服务器的时候,我们把笑脸替换成\1来发送的,这个是一个简单的表情发送,但是如果玩家输入了一个\1怎么办呢?是不是我们还把它翻译成笑脸呢?微信里就是这样处理的,你发送一个特殊格式的字符它就给你转换成特定的表情了。那如果我们在游戏里表情是到达一个等级或者一个条件开放的。那玩家是不是就这样卡了一个bug。那我们就思考下怎么处理吧。

一种简单的处理

其实处理起来很简单的。我们在玩家输入的"\"之前在加入一个"\"。那玩家在输入的时候就变成了“\1”那我们在做处理的时候,通过"\"后接"\"就翻译成反斜杠的特殊含义,我们就把这个当一个正常的"\"输出,最后在另外一端看到的玩家输出就是"\1"了。这样的处理就正常表示了玩家的输入。

测试代码

static void zhuanyi(string str,List<string> strs)
        {
            if (string.IsNullOrEmpty(str))
            {
                return;
            }
            int index = str.IndexOf('a');
            if (index < 0)
            {
                strs.Add(str);
                return;
            }
            strs.Add(str.Substring(0,index+2));
            zhuanyi(str.Substring(index+2), strs);

        }
        static string zhuanyi1(List<string> strs)
        {
            string str = "";
            for(int i = 0; i < strs.Count; i++)
            {
                int index = strs[i].IndexOf('a');
                if (index > 0)
                {
                    str += strs[i].Substring(0, index);
                    string temp = strs[i].Substring(index + 1);
                    if(temp == "1")
                    {
                        str += "哈哈";
                    }
                    else
                    {
                        str += temp;
                    }
                }
                else
                {
                    string temp = strs[i].Substring(index + 1);
                    if (temp == "1")
                    {
                        str += "哈哈";
                    }
                    else
                    {
                        str += temp;
                    }
                }
            }
            return str;
        }

这就是一个简单的实现,没有优化过

结束

其实这个的处理是一个很简单的转义字符的特殊应用,我们可以在聊天呀,邮件呀,或者一些需要在文字里插入一些特殊含义内容的字符串里的应用。很多时候我们一些知道的知识点在应用的时候想不到。其实很大的一部分是我们只是把概念当成了概念,而没有转换成我们的智慧。

【基础】VO算法

前言

最近遇到了怪物在ai寻路的目标点为同一个的时候,会出现相互挤压的情况。所以开始研究群体避障算法。这片文章主要讲一下最基础的VO(Velocity Obstacle)算法。

正文

其实vo算法不是很难的,就是你得实时算它前面是不是会碰到物体,因为是简单的,所以单个物体只管自己的。我先晒一张经典的图

这张图在所以讲vo算法的文章里都会出现的,它直观的展示了vo是怎么算物品之间的碰撞。现在我就基于这个图来详细讲解下相关的知识点。

闵可夫斯基和

在这张图里我们会看到B里有一个实体圈,和一个虚线圈。然后你会发现生成的B⨁−A这个三角形实际上是从A的中心点PA为起点画的,所以可以理解那个虚拟的B圈实际上是把A圈给叠加上了。这里涉及到了一个集合知识点就是闵可夫斯基和(闵氏和)。
概念:两个图形A,B的闵氏和C={a+b|a∈A,b∈B}
其实就是从原点向着图形A内部的每一个点做向量,将图形B沿每个向量移动,最终位置的并集就是闵氏和。
我们来看一个图片吧

在这张图里a(A,B,C,D)b(A,C,E)。现在我们用它做一个闵氏和,先把b往DA向量方向移动,得到了一个(F,B,Q)这个图型,其他的向量也按这样的移动,我们最后就会得到一个并集成(F,G,I,M,L,J)这个图型。这个图形就是我们要算的a和b的闵氏和。
结合这个知识点,我们现在来看VO的那张图我们就很容易理解了,把B和A放在同一个圆心上,然后沿着圆的四周往外扩散A的半径向量。得到虚框B。

相对位移

当我们A沿着VA移动,B沿着VB移动的时候,我们要确定两个图形是否会碰撞到,因为我们是从a画的一个扇形,所以我们需要计算下A的相对于B的移动向量,这个就用向量相减就好了。算出一个VA-VB的向量,然后我们PA质点沿着VA-VB的方向画一个和B虚框相切的一个图形B⨁−A。因为实际计算不是求的相对速度,是VA的实际速度,所以我们需要加上VB。这样我们就得到了一个深色的三角形。

结论

当我们的速度不属于这个深色三角形内的时候,他们就不会发生碰撞。

补充

因为VO是把对象都当成一个独立的个体,所以它在计算的时候是实时计算的,当两个物体独立计算的时候,所以他们会单独计算自己的避障速度方向,当A,B沿着避障方向移动的时候,他们在下一次计算会发现自己不在对方的碰撞区域内,所以会修改方向沿着之前会碰撞的方向移动,再下一次计算的时候发现自己又在碰撞区域内了,又开始偏移移动,这样往复以后,就会出现抖动。后面我研究研究VO的优化算法,再讲解下怎么解决这个问题。

[Unity技巧]查Package包内的问题的小技巧

前言

这几天在查一个unity中inputsystem这个输入控件在有的手机端上出现ui的输入不响应,为了查找这个问题,我开始研究该怎么在package包内的文件输出一些关键日志,毕竟在手机端不太方便断点调试。在打印中我发现了两条路线,一个是用反射,这个是我最开始的方案,机缘巧合让我发现了可以把package包使用本地的文件打包进移动端。现在我就基于这两个方案讲讲我遇到的坑

正文

反射

因为我是为了获取到封装好的类中的一些数据,所以我所讲的只是我用到的一些功能,其他关于特性,动态赋值这些功能我应该不会讲。
当我们拿到一个对象,我们一开始想通过getType来获取它的类型,通过获取到的类型我们就可以通过它的实例来获取到它的一些属性值,比如我们想获取到变量的值,我们就可以通过type.GetFields(变量名).GetValue(实例)就能获取到变量的在当前这个实例中的数据。其他的什么属性呀,方法呀,都能这样获取到。
如果我们获取一个数组就有点麻烦了,首先我们得先判断这个对象是不是一个数组类型,如果是数组类型,我们就可以把这个对象转成迭代器(IEnumerable)这个迭代器是基础迭代器,它内部是object这个类型的,然后继续获取属性,获取对象,这样一点点的拿到我们想知道的数据。
在获取属性的时候记得要传入这个属性的类型,比如instance(实例化的),Public(公共),NonPublic(非公共)。

本地Package包


一个没有修改过的package包可以看到在他的说明左下角有个Develop这个选项,在我看来这个选项就是代表你可以把这个package包自定义开发。可以做成自己的自定义(可能会有些包不支持)。当我们选中了这个选项之后

这个包的位置就换到了Custom本地package了,它就被下载下来了,放到的路径在我们的Package路径下同名的文件夹下,然后我们重新加载我们的工程,就可以发现脚本的路径已经换掉了,还没有本地化之前的包是在Library/packageCache这里的。

然后我们去看Packages路径下的Packages-lock.json文件夹就会发现上面这样的修改。

结束语

我们在日常工作中经常会遇到需要去调试源码的情况,但是在手机端这种调试很麻烦,所以我们只能通过打印log去验证我们一些想法。调试麻烦不是说不能调试,我之前就在xcode上调试过unity工程,太麻烦了。

【unity】协程和迭代器

前言

在很早之前忘了是哪次面试还是什么时候,有人问我协程是什么,那时候我根本就没有这个概念,所以在网上找了一些资料,发现很多人都是讲协程和什么线程这种概念的区别,某一次我看到了某篇文章,上面详细讲解了一次协程的原理,和自己实现一个协程。那时候我也想自己去了解一下,但是因为各种各样的缘由,我在前段时间看书看到了迭代器这一章的时候,根据迭代器的定义,发现协程其实就是一个迭代器的一种实现。现在我就简单的基于协程的原理来通过迭代器实现下这个功能。

正文

迭代器

迭代器是一种设计模式,在所有讲设计模式的书上都有介绍的。它是对于遍历的一种封装,它依赖于一个叫迭代器的接口,我们通过实现这个迭代器的接口可以对各种集合的一个遍历,我们可以不用关系这个集合是数组,列表还是散列表的。

c#中的实现

  1. IEnumerable和IEnumerator
  2. IEnumerable<T>和IEnumerator<T>

上面的两个的区别是一个是基于泛型的接口还有一个是非泛型的接口

枚举源

借助c#语言的一个强大的功能,能够生成创建枚举源的方法。这些方法称为“迭代器方法”。迭代器方法用于定义请求如何在序列中生成对象。使用yield return 上下文关键字定义迭代器方法。可以在迭代器方法中使用多个离散yield return语句。迭代器有一个重要的限制:在同一个方法中不能同时使用return语句和yield return语句。

IEnumerable和IEnumerator

当我们自己实现一个迭代器的时候,我们需要创建两个类,分别继承IEnumerable和IEnumerator这两个接口,他们最重要的是IEnumerator,IEnumerable这个接口需要实现的方法是返回一个IEnumerator的对象,然后我们通过对IEnumerator进行遍历。在实现IEnumerator接口的时候我们需要实现一个是MoveNext这个方法,它是用来判断集合中是否还有下一个元素,Reset这个方法用来重置遍历,下一次从头遍历。Current这个方法是用来获取当前遍历的元素。Dispose这个方法是用来对迭代器进行回收。枚举器用来是读取集合中的数据,不能用来修改基础集合。在最开始的时候枚举数定位在集合中第一个元素的最前面,所以在reset的时候把这个枚举数定义为-1,当我们调用MoveNext的时候会判断当前的枚举数(++curindex)是否在集合的最后一位,如果已经在最后一位了,那就会返回一个false,然后current的值不变,如果不在最后一位,那就把current的值修改到下一个元素。因为是通过枚举数来确定是否在集合的最后一位,所以当我们对集合做出了修改,就会导致枚举器的行为是不确定的。在这里我发现了一个事情,就是默认的IEnumerator的current的返回值是一个object的类型,这个会不会就是人们常说的用foreach来遍历集合会有很高的消耗呢?毕竟如果是一个int这样的值类型会有一次拆装箱的操作。如果说被优化了,那优化点就是值类型的泛型他们单独实现。通过我查看了下他们的源码发现真的是这样的,他们对list的IEnumerator自己改成了一个Enumerator的一个结构(struct)内部实现的Current是一个T的泛型,所以这里就不会出现我看到的拆装箱的操作。

yield

  1. yield return <expression>
  2. yield break

使用yield return 语句可一次返回一个元素。
迭代器方法运行到yield return语句时,会返回一个expression,并保留当前在代码中的位置。下次调用迭代器函数的时候,将从该位置重新开始执行。
使用yield break来终止迭代器。
返回yield或IIEnumerable的迭代器的IEnumerator类型为object。所以当我们yield return返回一个int这样的值类型的时候会进行一次装箱操作。
yield break
是用来跳出迭代器的。

协程

    IEnumerator test2 = test();
                while (test2.MoveNext())
                {
                    Console.WriteLine(test2.Current);
                }
     IEnumerator test()
            {
                Console.WriteLine(DateTime.Now);
                yield return new test1();

                Console.WriteLine(DateTime.Now);
            }

            public class test1
            {
                public test1()
                {
                    System.Threading.Thread.Sleep(2000);
                }
            }

看到上面的代码有没一种很熟悉的感觉,这个是我实现的一个延时执行下一行代码的协程,在实现的时候,我一直在想,其实我们可以把expression当成是一个同步阻塞方法,当这个方法执行结束了之后,我们的迭代器再执行下面的方法。

结束语

万变不离其宗,掌握了原理其实很多东西都是很简单的。当前我就吃了研究不深的亏,很多东西都是用就完事了。现在回想起来,知识这个东西还是不能拖的,趁着热情还在,仔细研究透。

【插件】Wwise在unity中的使用

Wwise 内存管理

SoundBank

初始化Bank

我们现在打包出来的路径下能看到一个叫Init.bnk这个Bank,它包含了工程所有通用信息。是在生成SoundBank时自动生成的,每个工程只有一个。在启动游戏的时候加载的第一个Bank必须得是它。如果不是它会影响其他的Bank无法加载。

正常SoundBank

包含Event和播放他们所需要的对象和音频数据。如果当前的event所播放的音频数据是流播放数据,那它会记录到相应的.wem文件。

在Unity中使用SoundBank

使用标识类型

  1. 字符串

    • 提高代码的可读性
    • 把字符串转换成声音引擎使用的ID需要少量的CPU。
    • 声音引擎中不存储字符串。
  2. ID

    • 代码可读性不好
    • 声音引擎在内部使用ID,因此无需占用任何额外的CPU
    • 无需占用额为内存

LoadBank

我们使用AkSoundEngine.LoadBank来使用字符串加载SoundBank的时候,它会返回给我们一个SoundBank ID这个ID是存在SoundBank里的,所以这个ID和我们在wwise内看到的一样。

UnloadBank

使用UnloadBank来卸载SoundBank的时候,我们需要传入的是它的ID,根据上面的加载,我们需要好好保管这个ID,以便我们能正常的卸载ID。

通过内存加载

LoadBankMemoryView

这个方案其实就是通过使用Unity自带的本地资源二进制加载,然后通过AkSoundEngine.LoadBankMemoryView把这部分的数据序列化成声音引擎能使用的数据。它加载的资源是位于内存中的实际位置,不会拷贝资源,所以在使用的时候必须保证内存一直有效,直到卸载掉SoundBank。

LoadBankMemoryCopy()

也是通过序列化数据加载soundbank,这个和上面那个方法的不同之处,是会把数据拷贝到声音引擎的分配内存中,所以当数据被加载完成之后,就可以从unity中卸载掉。

PrepareBank

AkBankContent_All

这个是一个PrepareBank的一个枚举选项,使用这个选项的时候,在加载SoundBank的时候是把它所有的内容都加载进去,在加载媒体文件的时候,运用于类似于PrepareEvent的机制,先查询媒体文件在不在内存中,如果不在就加载媒体文件,如果存在了就不在加载。提高内存的利用率。

AkBankContent_StructureOnly

使用这个选项的加载方式,只会加载时间和结构元数据,会忽略SoundBank中的媒体文件。当SoundBank中不包含媒体文件的时候,它和上面的那种模式可以产生相同的结果。

清空SoundBank

调用ClearBanks()函数可以重置声音引擎中的内容。调用这个函数之后:

  • 所有声音停止播放。
  • 所有SoundBank均会被卸载,包括Init.Bank
  • 所有的State管理均会清空。

注意点

  1. 每个SoundBank只可加载一次。如果试图显式加载一个SoundBank两次的时候,它在第一次加载之后的返回值将会是AK_BankAlreadyLoaded,告诉你已经被加载过了。当你把媒体存在两个不同的SoundBank里,你就可以显示加载它两次,内存中就会出现两份资源,这个时候你就可以使用PrepareEvent的特性,让同一份媒体资源只在内存中存在一份。
  2. 在使用SoundBankID加载的时候,需要在设置中取消勾选“Use SoundBank Names”这个选项,然后生成出来的SoundBank就是ID.bnk格式的。如果使用Wwise自带的Unity插件,就需要注意,它里面初始化的Init.bnk。在使用ID格式是找不到的,所以需要把Init.bnk改成初始化bank的ID名字。
  3. 当把音效资源当成stream加载的时候,会在SoundBank中记录下来音效资源的流信息,在使用event的时候就会去使用Stream Manager来加载流音效资源播放。

流播放

有限的带宽往往会使得存储设备的访问速度成为游戏的瓶颈。因此,为了保持良好的数据传输请求顺序,游戏通常会集中访问 I/O。排序的依据是相对优先级、相对于传输大小的开销要求、吞吐量和延迟。音频一般需要大量的 I/O 资源:音乐和较长的声音在播放时通常从磁盘传输。这块后面单独写一个文章用来讲解,展示先放下不讲了。

使用wwise的event

AKGameObj

在播放wwise的时候,我们首先需要确定游戏对象,因为不管是听者还是发声器,我们都需要知道他们在世界坐标中的位置,通过调用AkSoundEngine.SetObjectPosition我们可以设置场景中物体的位置和朝向。这个物体记录到声音引擎中,当我们需要给一个听者或者一个发声器设置场景坐标的时候,就可以使用这个记录的信息。通过AkSoundEngine.RegisterGameObj这个方法,我们可以把场景中的物体在声音引擎中记录一个id,这样的话,我们就可以通过id找到这个物体。AkSoundEngine.UnregisterGameObj通过这个方法,我们就把这个物体从声音引擎里移除掉了。在wwise里去记录gameobject对应id是通过AkSoundEngine.GetAkGameObjectID这个方法来计算的。这样我们把unity场景中的一个物体的位置和旋转信息就在声音引擎通过id记录下来的。

AkAudioListener

对于声音我们需要一个收听音乐的节点,这个脚本就是为了给我们定义一个听者的信息。通过AkSoundEngine.AddDefaultListener来设置一个默认的听者,它将指派到所有的发声器。AkSoundEngine.AddListener可以给发声器设置一个听者,用来覆盖掉默认听者。RemoveListener和RemoveDefaultListener是用来取消听者设置。

AkEvent

播放wwise的事件调用的接口是AkSoundEngine.PostEvent它会返回一个id,这个id可以通过调用stopPlayingID来停止event的播放,但是在看unity内的代码的时候发现只在Windows做了拓展。还有一个停止Event的方法调用AkSoundEngine.ExecuteActionOnEvent传入枚举为stop

AkAmbient

当我们为了节省内存的时候,可以把使用相同Event实例只生成一个声音实例,以便节省内存,调用AkSoundEngine.SetMultiplePositions相当只用了一个声音实例却可以设置不同的位置。

AkEnvironment

当我们想给声音加一个Auxiliary Bus来达到一个混响的效果,我们可以调用AkSoundEngine.SetGameObjectAuxSendValues来添加一个混响的效果。

AkSwitch

当我们使用wwise内的音乐切换功能,相当于在同一组声音内根据某一种规则进行播放的切换。可以使用AkSoundEngine.SetSwitch传入组id和你要切换的id加上播放声音的对象。

Spatial Audio

这个是Wwise一个提供和空间音频相关联的服务的一个模块。普通的wwise提供了一些正常的根据位置,方向,角度的声音渐变效果,和一些声音左右声道的切换的效果,但是我们如果需要对声音进行一些反射,衍射和透射的效果的话,还是需要用到Spatial Audio这个模块。这些的处理很依赖游戏引擎,并且很消耗性能。

Room和Portal

通过Room和Portal进行一次简单的几何抽象,对发声体的声音传播进行准确的建模。对于房间驱动的声音传播,其主要特性为衍射,耦合以及混响的空间化。Room没有尺寸大小,他们通过Portal相互连通,并形成由房间和开口组成的网络。其他房间发出的声音可以通过开口传播至听者所在房间。

衍射

对于相邻Room内的每个发声体,Spatial Audio都会计算相连Portal最近边缘与Shadow Boundary的衍射角。通过衍射角映射衍射系数,通过获取这个系数,我们可以修改发声体的值,obstruction值或者Diffraction值。
还需要考虑到Room的漫反射能量。这部分的衍射也会同时计算出来,在Spatial Audio假定漫反射能量沿着Portal垂直方向从Room向外渗透。因此计算相对于Portal法向量的衍射角。

透射

当听者和发生源不在同一Room且两者无法直接通过Portal看到彼此时,Spatial Audio会向游戏对象应用透射损失系数,并据此对声音穿透墙壁的情形进行建模。通过设置Occlusion曲线或Transmission Loss内置参数。
Room之间的透射损失依据Room设置参数进行计算,并取听者所在Room和发声体所在Room当中的最大值。
若发声体被一个或多个几何表面阻挡,则应用几何构造的透射损失系数。若发声体和听者之间存在不止一个表面,则采用最大透射损失系数。
若声音即被几何构造所阻挡,又跟听者不在同一个room,则应用所有透射损失中的最大值。

在unity中使用

AkRoom

调用AkSoundEngine.SetRoom来设置room(gameobject的id),然后通过音频引擎获取到这个id的gameobject对象,从而得到坐标,设置room的朝向和辅助音频总线等信息,还有就是几何信息(比如collider这样的信息)。值得注意的一点,因为wwise的游戏对象和room的id都是通过AkSoundEngine.GetAkGameObjectID计算出来的,所以当这个对象被设置成游戏对象的时候,不要重复用在roomid。

AkRoomPortal

调用AkSoundEngine.SetRoomPortal来设置Portal的信息

  1. protal的id,通过这个id找到gameobject在wwise内注册的信息。
  2. 设置protal的collider的位置信息,中心点,正前方
  3. 设置protal的大小信息
  4. 设置protal是否激活状态
  5. 设置protal连接的两个room的id

AkRoomAwareObject

当wwise调用了SpatialAudio的时候,它会针对每个发声体和听者调用AkSoundEngine.SetGameObjectInRoom把他们设置到相应的room内。

使用AkEvent

我们可以将RoomID当成发声体的ID来设置PostEvent的gameobject对象。

Geometry

通过Geometry可以把三角形网格发送给Spatial Audio实现reflect模拟早期反射,和模拟声音从发声体到听者位置中产生的边缘衍射。

终结

上面我就讲解了下wwise的音频资源在unity内api的使用方案,其实很多声音的效果我们都是可以在wwise内做好的,比如声音的衰减方式,比如弹壳掉落地上的声音随机在左右声道播放,等等效果。它把很多需要我们在unity中自己实现的一些声音表现,使用wwise让音频制作人员给做好了,我们在游戏内就只需要播放一下声音的事件,和声音资源的管理方案。