bookmark_border[原创]UE技能系统 GAS 系列—1.基础概念

UE的技能系统 ( Gameplay Ability System 简称:GAS ) ,官方是以插件的形式嵌入在UE引擎中;你完全可以在自己的项目中用使用自己团队的”祖传”技能系统,但我还是推荐大型项目优先使用 GAS 。它的优点是:度高灵活、可复用性强;缺点是:过于庞大、不易上手

如果你是第一次接触 GAS,可能被一些专有名词给绕晕 :GameplayAbility(GA)、 AbilitySystemComponent(ASC) 、GameplayEffect (GE)、AttributeSet(AS)、 GameplayCue (GC); 那么,我就通俗易懂的解释一下这些概念

GameplayAbility(GA)

武当有一门神技《梯云纵》,施展以后就能一跃十丈,潇洒无边。这本秘籍就是GA ,我们来看看这本秘籍的内容:梯云纵(第一重)

神技心法:

  • 气沉丹田
  • 聚气成力

神技说明:

  • 每天只能施展一次
  • 每次施展消耗一点真气

神技威力:

  • 一跃而起,离地一丈
  • 形成雷鸣,方圆一里

AbilitySystemComponent(ASC)

想要习得上乘武功,就必须有习武的神经中枢(俗称:天赋),没有天赋只有秘籍那就是个王语嫣;而这个神经中枢就是ASC

因此想要施展技能,Character 必须挂上ASC 组件,神经中枢是通过 GiveAbility 融汇贯通了梯云纵的神技心法

ASC->GiveAbility(const FGameplayAbilitySpec& Spec);
FGameplayAbilitySpec 主要参数如下:
UGameplayAbility* GA;  //梯云纵
int32 Level;           //第一重
UObject* SourceObject; //谁习得梯云纵(自己)

GameplayEffect (GE)

融汇贯通了梯云纵心法,那是不是就可以出门行侠仗义,闯荡江湖了呢?还是得从长计议,神技虽然威力无边,但也有很大的自身限制,因为神技第一重每天只能使用一次。用GAS它是怎么实现的呢?那就要用到 GE 了

GE 通俗一点理解就是 Buff,DeBuff ;施展完一次梯云纵以后,GA会告知 ASC,请在小本本上写一行标记: CoolDown.Skill.TiYunZong ,然后24小时后删掉这行标记。伪代码如下:

//GE相当于一份配置文件,包含ASC要记的标记,持续时间等要素
UGameplayEffect* CooldownGE = GetCooldownGameplayEffect();
if (CooldownGE)
{
    const auto SpecHandle = MakeOutGESpec(CooldownGE->GetClass(), 1);
    ApplyGameplayEffectSpecToOwner(ActivationInfo, SpecHandle);
}

施展梯云纵之前,ASC 首先会去小本本检查是否有 CoolDown.Skill.TiYunZong 这个标签。如果有那就表示距离上次使用未超过一天时间不得再次施展,这就是GAS 进行技能冷却的原理

AttributeSet(AS)

施展梯云纵之前,中枢神经ASC还会去检查你体内是否有足够的真气,而真气就是 AS,也就是GAS中常说的属性点,比如: 真气(Mana),血量(Health) 等。那么扣一点属性点,是怎么实现的呢?同样是使用 GE 告知 ASC,你扣除身上的Mana属性值 1 点,瞬间完成。伪代码如下:

UGameplayEffect* CostGE = CostEnergyGE;
const auto SpecHandle = MakeOutGESpec(CostGE->GetClass(), 1);
if (SpecHandle.Data)
{
    //代码有删改,意思就是设置GE内对应tag扣的值
    SpecHandle.Data->SetSetByCallerMagnitude("Data.Energy.Mana", 1);
    ApplyGameplayEffectSpecToOwner(ActivationInfo, SpecHandle);
}

这样,就完成了属性点扣除。同样,在梯云纵施展之前,出了要检查是否有 CoolDown.Skill.TiYunZong 这个标签,ASC 还会去检查属性点 Mana 是否足够你施展完以后扣除,不够就不能施展;强行施展后会抽走全身精气走火入魔

GameplayCue (GC)

施展梯云纵的时候,由于它威力巨大,会在方圆一里产生巨大的雷鸣,震撼八方 宵小之辈。那么GAS 是如何实现的呢?也是通过 GE 告知 ASC,丢出去一个一次性暗器 ( 也就是GC ),它会播放一个3D环绕雷声(声音范围:方圆一里)以及雷电特效,播放完以后自动销毁 。GE 配置了丢哪个暗器(Gameplay Cue Tag),以及何时销毁(Has Duration)

总结

注意: ASC 组件不一定非得挂在 Character 身上,你也可以挂在 PlayerState 身上,甚至游戏内任何想要使用技能的 Actor 身上。GE 是 GAS 的灵魂,也是重中之重,很多效果都是通过 GE 去驱动。GAS 还有一个核心 — FGameplayTag,但这是UE4引擎本身的标签系统;如果没有 FGameplayTag 配合,GAS 就显得平平无奇;FGameplayTag + GAS 组成强大的技能系统,可以实现任何上天入地的炫酷技能

至此,我们可以基本上明白GAS内的各个名词的功能。但实际上,这才是梯云纵的第一层,想要达到大圆满境界,我们仍需努力。以下是我觉得比较好的一些 GAS 视频链接,适合新手登堂入室

GAS新手链接:

bookmark_border[原创]UE —UObject类智能指针

上文谈到C++ 标准智能指针,是为了让程序员从指针的 new 和 delete 中抽身,更专注逻辑本身的开发。UE4 针对非 UObject 对象也实现了一套类似的智能指针,至于具体实现原理,可查看知乎文章:UE4的智能指针 TSharedPtr

强如 Java 和 C# 等更“智能化”的高端语言已经实现了垃圾回收机制,恐怖如斯;UE虽身披 C++ 却心向 C# ,自己也捣鼓了一个低端版本的垃圾回收,针对UObject类型对象的智能指针,主要有:TWeakObjectPtr,TStrongObjectPtr

WeakObjectPtr

我们知道继承了 UObject,并且被标记了 UPROERTY 的对象 Obj ,会被 GC 追踪(会被GC追踪的对象还有其它情况,本文暂不讨论);因此我们在使用 Obj 之前一般都会有如下代码安全开道:

if (IsValid(Obj))
{
    //Use Obj do something
}

GC 的删除过程是分帧处理,但是会提前标记 PendingKill,所以 IsValid(Obj) 依然返回 false,确保我们的代码不会出问题。但是,如果我们的代码是在 GC 清理完全以后再调用呢?上面这段代码就可能会导致 Crash ,为什么呢?

//Object.h
FORCEINLINE bool IsValid(const UObject *Test)
{
    return Test && !Test->IsPendingKill();
}

//UObjectBaseUtility.h
FORCEINLINE bool IsPendingKill() const
{
    return GUObjectArray.IndexToObject(InternalIndex)->IsPendingKill();
}

Obj 指向的 UObject 对象被初始化出来后,会被插入到数组 GUObjectArray,位置就是 InternalIndex 。GC后,GUObjectArray 数组内 InternalIndex 的位置被放入新的 UObject 对象,因此 IsValid ( Obj ) 返回的结果可能是 true;因此IsValid 判断 Obj 指针有效,而实际上 Obj 指针指向的内存地址里的内容解引用会失败,导致Crash

因此,我们希望有一种新型的智能指针Smart_Ptr用来包装 裸指针Obj,它必须满足以下两点要求:

  • Smart_Ptr 不能影响 Obj 的 GC
  • Obj 所指向的对象被 GC 删除以后,IsValid(Smart_Ptr) 会百分百返回 :false

UE很巧妙的撸了个 WeakObjectPtr ,实现原理很简单,它的核心就两个参数:

//WeakObjectPtr.h
int32	ObjectIndex;
int32	ObjectSerialNumber;

//WeakObjectPtr.cpp
ObjectIndex = GUObjectArray.ObjectToIndex((UObjectBase*)Object);
ObjectSerialNumber = GUObjectArray.AllocateSerialNumber(ObjectIndex);

ObjectIndex 就是当前WeakObjectPtr 指向的对象在数组GUObjectArray内的索引,而ObjectSerialNumber 则是该对象当初在创建的时候的唯一序列码 SerialNumber (赋值和自增都是原子操作,线程安全);因为它不直接引用 Obj 指针 ,因此不会影响 GC,然后 ObjectSerialNumber 会跟 GUObjectArray 内 ObjectIndex 位置对象的 SerialNumber 对比,不一样就表示根本不是同一个对象,使用如下:

//Assignment
TWeakObjectPtr<UMyObject> WeakObj = Obj;

//use
if(WeakObj .Get())
{
    UMyObject* Obj = Cast<UMyObject>(WeakObj);
    //Use Obj do something
}

因此,对于一些会隔帧执行的代码(定时器回调、 委托等),如果有用到 UObject 对象,强烈建议使用 WeakObjectPtr,能减少很多隐藏的Crash,让你的代码更乐百氏( Robust )

StrongObjectPtr

相对 WeakObjectPtr 来说, StrongObjectPtr 用的并不多。它提供的是一种绝对的把控,被他包装的 UObject 类型对象(不标记 UPROPERTY ),不会被 GC 自动清理,只有 StrongObjectPtr 析构以后,它包装的 UObject 才会被下一次 GC 清理

因此实现这种指针封装,需要满足以下两点:

  • 持有该对象,并且保证它不被 GC
  • 告诉GC,我用完了你可以清理它

我们查看 StrongObjectPtr 的实现,关键就一个变量:ReferenceCollector,类型是 FInternalReferenceCollector,继承自 FGCObject 。( ReferenceCollector 是 UniquePtr类型指针,因此它不能复制,只能转移所有权(OwnerShip))

为什么继承了 FGCObject 就可以被 GC 追踪 ?我们先看它的实现(注意:为了代码简短,去除了部分内容)

//GCObject.h
class COREUOBJECT_API FGCObject
{
   static UGCObjectReferencer* GGCObjectReferencer;
   static void StaticInit(void)
   {
      if (GGCObjectReferencer == NULL)
      {
         GGCObjectReferencer = NewObject<UGCObjectReferencer>();
         GGCObjectReferencer->AddToRoot();
      }
   }

   FGCObject(void)
   {
       StaticInit();
       GGCObjectReferencer->AddObject(this); 
   }

   virtual ~FGCObject(void)
   {
       GGCObjectReferencer->RemoveObject(this);
   }

   virtual void AddReferencedObjects( FReferenceCollector& Collector ) = 0;
};

静态指针 GGCObjectReferencer,用来管理所有继承了 GFGCObject 的指针对象;初始化执行如下两个方法

  • StaticInit() 内 AddToRoot;表示将 GGCObjectReferencer 设置为根节点,因此它永远不会被 GC 清理(除非调用 RemoveFromRoot)
  • AddObject(this) ,则是将自身放入 GGCObjectReferencer 内的ReferencedObjects 数组内

每一次GC的引用标记阶段,执行到 GGCObjectReferencer 指向的对象时,会执行它的 AddReferencedObjects 方法,而它会遍历调用 ReferencedObjects 数组内的所有 FGCObject 对象的 AddReferencedObjects 方法

//GCObjectReferencer.cpp
void UGCObjectReferencer::AddReferencedObjects(UObject* InThis, FReferenceCollector& Collector)
{	
    UGCObjectReferencer* This = CastChecked<UGCObjectReferencer>(InThis);
    for (FGCObject* Object : This->ReferencedObjects)
    {
	Object->AddReferencedObjects(Collector);
    }
}

我们来看看指针 ReferenceCollector 内的 AddReferencedObjects 方法:

//StrongObjectPtr.h
virtual void AddReferencedObjects(FReferenceCollector& Collector) override
{
   Collector.AddReferencedObject(Object);
}

Collector.AddReferencedObject( Object) 就是将 StrongObjectPtr 所修饰的 UObject 指针对象放入 GC 体系(被 Collector 引用,因此不会被 GC )

以上,我们解释了第一条:持有该对象,并且保证它不被 GC ;那怎么告诉 GC,我现在用完了,可以清理呢?

上面的 ~FGCObject 析构函数已经给到答案了,就是将 ReferenceCollector 从 GGCObjectReferencer 移除。那么下次 GC 引用标记阶段执行到 GGCObjectReferencer 指向的对象时,它的 ReferencedObjects内就没有我们的 ReferenceCollector,那么它的 AddReferencedObjects 方法就不会被执行到; 那么 ReferenceCollector 内的UObject* Object 就没人引用,那么 GC 就会清理它

其实,大部分情况下,都用不到 StrongObjectPtr ,因为 UPROPERTY 标记足够好用。然而它可以在任何地方使用,包括F类内,这也是它的优势

参考引用:

bookmark_border[原创]C++基础系列—智能指针

如果你熟悉 Java 或者 C# ,那么以下代码你肯定不陌生

class Students { public int value = 0; }

void main()
{
 Students a = new Students();
 Students b = a;
 a.value = 10;
//此时,变量 a 和变量 b 的 value 值都是 10
}

很简单,生成了一个Students对象,变量 a 和变量 b 都是对该对象的引用;但是在C++ 中,结果并不是这样

class Students
{
 public:
    int value = 0;
};

void main()
{
    Students a;
    Students b = a;
    a.value = 10;
    //此时,变量 a 的 value 是10,变量 b 的 value 仍是 0
}

编译器会为 class 默认生成 copy constructor 和 assignment operator,C++ 对象默认是可以拷贝的 ,因此 b = a,相当于拷贝了一份全新的对象。那要怎么实现类似Java 或 C# 的效果呢?C++ 就发明了指针这么个东西,它就是指向对象实际存储在内存中的地址,用法如下:

int main()
{
    Students* a = new Students();
    Students* b = a;
    a->value = 5;
    //此时,变量 a 和 b 的value 都是 5
    //delete a;
}

new做的事情就是,调用底层 malloc 获取 Students 类型大小的内存地址(一般会对字节补齐),然后调用 Students 的构造函数(如果没有,编译器会自动补全空构造),返回该对象的内存地址。从此,内存上的这一块地址就永远被占用,除非调用 delete 函数释放。但如果 delete 过早,其他函数还在用这个指针,就可能导致程序崩溃

void foo(Students* ptr)
{
    ptr->value = 10; //crash
}

void main()
{
    Students* ptr = new Students();
    delete ptr;
    foo(ptr);
}

使用裸指针(raw pointer)最大的问题就是,你不知道它当前的所有权(ownership)属于谁,因此也就很难判断应该在哪个位置 delete 它。C++ 编委会的巨佬们本着为了让程序员少掉头发的原则,设计了一个封装指针的类,同时赋予它一个响亮的名字: 智能指针( Smart Pointers )

既然也叫 “指针”,那么就要求它用起来跟普通指针没区别。因此这个封装裸指针的类重载了很多操作符,比如:* ,& ,bool ,== 等。针对各种使用场景,一共设计了三种智能指针类 :

  • std::unique_ptr(single ownership)
  • std::shared_ptr (shared ownership)
  • std::weak_ptr ( no ownership ,与 shared_ptr 伴生关系)

针对上面会崩溃的问题,我们代码改下如下:

void foo(std::unique_ptr<Students> ptr)
{
    ptr->value = 10; //no crash
}

void main()
{
    auto ptr =std::make_unique<Students>();
    foo(std::move(ptr)); 
}
// no memory leak

崩溃解决,同时也不会造成内存泄漏。构造 unique_ptr 的官方推荐的写法是: auto ptr = std::make_unique<class>(); 转移 ownership 的写法是 std::move(ptr); 为了能更详细的理解,我们来写个小实例,并打印相关讯息:

class Students
{
public:
    Students(){ std::cout << "Students初始化创建" << std::endl; }
    ~Students() { std::cout << "Students析构销毁" << std::endl; }
};

int foo(std::unique_ptr<Students> ptr2)
{
    std::cout << "进入foo函数" << std::endl;
    std::cout << "退出foo函数" << std::endl;
}

int main()
{
    std::cout << "进入main函数" << std::endl;
    //注释段1
    //{
        //std::cout << "进入main函数scope1" << std::endl;
        //auto ptr1 = std::make_unique<Students>();
        //std::cout << "退出main函数scope1" << std::endl;
    //}

    //注释段2
    //auto ptr2 = std::make_unique<Students>();
    //foo(std::move(ptr2));
    std::cout << "退出main函数" << std::endl;
}

打开注释段1,打印如下左边;打开注释段2,打印如下图右边

可以看出 ptr1 的 ownership 属于 block scope,scope代码执行完以后, ptr1 自动执行析构函数;ptr2 的 ownership 一开始属于 main 函数, 通过 std::move 转移给 foo 函数,因此一旦 foo 函数生命周期结束,ptr2 也会自动执行析构函数

看似很完美,然而 unique 的意思是唯一,也就是说这种智能指针的 ownership 只能唯一,因此如下代码,就会崩溃:

void foo(std::unique_ptr<Students> ptr)
{
    ptr->value = 10;
}

void bar(std::unique_ptr<Students> ptr)
{
    ptr->value = 10; //ptr is empty, crash
}

int main()
{
    auto ptr = std::make_unique<Students>();
    foo(std::move(ptr));
    bar(std::move(ptr));
}

原因是什么呢?std::move(ptr) 的意思就是把 ptr 的 ownership 从 main 函数转移给 foo 函数,转移后 ptr 也就没有 ownership了,自动析构了; 针对这种情况,我们就需要用到 shared_ptr , 使用规范如下:

跟 unique_ptr 相比,多了一个use_count。因为它就是用来记录,当前有多少个 ownership,为什么要记录个数呢?因为只有确定它最后一个 ownership 的生命周期结束以后,它才能自动析构。针对上面那个问题,我们用shared_ptr改写:

void foo(const std::shared_ptr<Students> ptr)
{
    puts("enter foo");
    std::cout << ptr.use_count() << std::endl;
    puts("exit foo");
}

int main()
{
    puts("enter main");
    const auto ptr = std::make_shared<Students>();
    std::cout << ptr.use_count() <<std::endl;
	
    {
	puts("enter scope");
	auto ptr_scope1 = ptr;
	std::cout << ptr.use_count() << std::endl;
	auto ptr_scope2 = std::move(ptr_scope1);//transfer ownership
	std::cout << ptr.use_count() << std::endl;
	puts("exit scope");
     }
	
    std::cout << ptr.use_count() << std::endl;
    foo(ptr);
    std::cout << ptr.use_count() << std::endl;
    puts("exit main");
}

打印结果如下:

也许下面这张图,你能理解的更透彻

shared_ptr 的 use_count 的增减做了防 data_race ,因此它默认支持多线程。目前来看,unique_ptr 和 shared_ptr 好像可以解决所有问题,为什么还需要一个 weak_ptr 呢?那么来看下面的情况:

class Students
{
public:
    Students() { puts("Students create"); }
    ~Students() { puts("Students destroy"); }
    std::shared_ptr<Students> next;
    //std::weak_ptr<Students> next;
};

int main()
{
    auto ptr_1 = std::make_shared<Students>();
    auto ptr_2 = std::make_shared<Students>();
    ptr_1->next = ptr_2;
    ptr_2->next = ptr_1;
    std::cout << ptr_1.use_count() << std::endl;
    std::cout << ptr_2.use_count() << std::endl;
}

//print:
//Student create
//Student create
//2
//2

哦豁,并没有打印 Student destroy;也即是说出现了 memory leak;智能指针失灵了?问题就出在赋值的那两句代码导致 use_count = 2,main函数退出后,use_count 仍然为1,因此不会执行析构。因此如果有一种指针,我想用shared_ptr,但又不想它 use_count 有变化,只是在用的时候,检查一下它还在不在,那不就完美了吗?因此,上面的注释打开,就能正确析构。详细用法如下:

    auto ptr = std::make_shared<Students>();
    std::cout << ptr.use_count() << std::endl;
    {
	//必须从 shared_ptr 构造(不会增加 shared_ptr 的 use_count)
	std::weak_ptr<Students> ptr_w = ptr;
		
	//必须从weak_ptr 重新转换成 shared_ptr 再使用
	//ptr的 use_cunt + 1
	std::shared_ptr<Students> ptr_w2s = ptr_w.lock();
	if(ptr_w2s)
	{
	    std::cout << ptr.use_count() << std::endl;
	}
    }	
    std::cout << ptr.use_count() << std::endl;
    //print: 1 2 1

打印结果与预期的结果一致。因此,很多时候,为了避免循环引用,一般shared_ptr 和 weak_ptr 配合使用。官方给出的使用建议是:

  • 尽量使用智能指针替代裸指针
  • 优先使用std::unique_ptr 而不是 shared_ptr (可以想想为什么?)
  • 尽可能 std::move(share_ptr)而不是直接 foo(share_ptr) (理由同上)

内容参考:

  1. B站 花花酱的表世界 Smart Pointers 智能指针 – C++ Weekly EP3
  2. 知乎用户 sin1080 关于 c++ 11 的shared_ptr多线程安全?的优秀回答
  3. C++ ownership semantics

bookmark_border[译文]5分钟系列—快速理解Multithreading(多线程)

原文链接:A gentle introduction to multithreading

前言

随着计算机硬件 ( hardware ) 的更新迭代以及操作系统 ( operating systems ) 的智能化,使得现代计算机能够同时处理更多任务;从而让我们的应用程序执行速度更快,响应时间更短

因此编写软件的时候,我们可以利用并发处理越来越多的事情;但凡事都有代价,这要求我们对并发的实现原理要有更深入的了解。让我们用 threads (线程)来开启并发的魔法之旅吧

Processes and threads ( 进程与线程 ) : 字如其意

目前主流的操作系统都能同时运行多个程序,这也是为什么你能用 Chrome 浏览器看这篇文章的同时,还能用网易云音乐听歌。因为每个程序都运行在一个所谓 进程 的环境中;操作系统为每个进程提供不同的硬件支持 ( 包括 CPU,内存,显存,输入输出等)

让操作系统同一时间执行多个任务,并不是只有启动多个进程这一个办法。每个进程可以在其内部同时开启多个 线程 用来并发执行任务。你可以把线程看作是进程的一部分,每个进程启动的时候,至少会开启一个线程,一般称这个线程为:主线程。程序员可以根据实际需求,开启或者停止其他线程用来执行任务;我们经常说的多线程,指的就是在一个进程中,开启多个线程

我们用来听歌的网易云音乐,就是个多线程程序。主线程用来渲染UI界面,子线程1 用来播放音乐,子线程2 用来读取歌词并显示等。你可以通俗的把操作系统看成是进程的容器 ( 其实更像是管理员 ) ,进程是线程的容器,示例图如下:

进程和线程的区别

操作系统为每个进程分配一块独立的内存空间;默认情况下,进程之间不共享内存空间 ( 也就是不能相互访问 ) ,比如:Chrome浏览器无法访问网易云音乐的运行内存,反之亦然。除非使用进程通讯技术 inter-process communication (IPC)

与进程不同,线程能够共享父进程的内存空间。比如: 网易云音乐的UI界面线程可以访问音乐播放线程的内容,反之亦然。同时,线程占用资源更少,创建/切换 速度更快,也有人将线程称为:  lightweight processes (轻量级进程)

因此,相对启动多进程(通讯不方便,更消耗资源),启用多线程才是同时处理多任务的最佳实现,而且程序能运行的更快

多线程用处

为什么需要多线程?因为并发执行任务能大大节省时间提高效率。举个不太恰当的例子:比如你下载一部电影,如果单线程下载,需要1小时;但是多线程下载,就可以根据线程数去下载不同的片段,多任务同时进行,那么就可以很快下完

多线程太完美了,真的就这么简单吗?以下几个问题你要好好考虑:

  • 不是所有程序都要用到多线程。如果你的程序本身就需要顺序执行;或者频繁等待用户输入,那就没有必要多线程
  • 线程不是越多越好。要根据具体任务的类型思考和设计,是否增加线程
  • 多线程并不保证真正做到并发执行。这还要取决硬件是否支持

最后一点请注意,并不是所有硬件设备都支持同一时刻执行多任务,只不过操作系统让它看起来像,这个后面会介绍

我们先把 并发(Concurrency) 看作是多任务看起来是同时执行;而 并行(Parallelism) 是看作是同一时刻处理多任务。举个简单的例子来理解并发和并行

并发:吃饭的时候,来了电话;停止吃饭,接电话

并行:吃饭的时候,来了电话;边吃饭边打电话(并行也不一定高效,吃饭的时候说话对方有可能听不清楚,导致打电话时长增加)

并发(Concurrency),并行(Parallelism)

CPU是程序被执行的地方,它由很多部分组成,其中最核心的部分称之为:Core (核心),每个核心同一时刻只能执行一个操作。硬件设计如此,因此操作系统为了能最大化利用核心,发明了很多先进技术,使得用户能够运行多个进程,其中最重要的技术就是:抢占式多任务处理机制。抢占是指中断当前任务,切换到另一个任务,稍后再切回当前任务继续执行

因此,如果你的硬件是单核CPU,那么操作系统的工作就是将单核的算力分配到不同的进程或线程上,这些进程或者线程会被轮询执行;这样就会给我们造成一个假象:同时多个程序被执行 或者一个程序同时执行多个任务(多线程)。虽然这满足了并发,但实际上并未真正并行

如今CPU早就多核心,每个核心同一时刻都能进行一个操作,这也意味着真正的 并行 是可能的。操作系统会自动检测CPU的核心数量,并为每个核心配置进程/线程。一个进程/线程运行在哪个CPU核心对我们来说是完全透明的,而且一旦所有核心都很忙,那么抢占式多任务处理就会启动。这也是为什么计算机能同时开启的进程数量远超CPU核心数的原因

对于单核CPU多线程有意义吗?

在单核CPU上,真正实现并行是不可能的。不过,多线程依然是有意义的,因为抢占式任务处理的存在,它可以使得应用程序保持响应,即使其中一个线程执行缓慢或阻塞的任务

举个例子:假如你的应用要从磁盘读取某个大文件,此时如果是单线程,那么整个应用会未响应状态。如果用多线程,那么就可以线程A去读取文件,线程B刷新UI界面(实时观测读取该文件的进度)

多线程,多问题

依上文可知,线程会共享其父进程的内存空间;这就使得同个进程内的多线程之间可以很方便的进行数据交互。多个线程从同一块内存地址中读取数据没有任何问题,但是一旦某个线程进行写操作,而其他线程进行读操作时就容易出现如下问题:

  • Data Race(数据竞争):线程A 对一块内存地址的内容形成写操作,而 线程B 又同时读取这块内存地址存储的值,那么就有可能读到的是脏数据
  • Race Condition(竞争条件):线程执行的顺序是不可控的,而我们程序又希望按照指定的顺序执行。比如:我们要求 线程B 只能在 线程A 完成写入操作后再读取数据

如果一段代码,很多线程同时调用它而且不会有 数据竞争 和 竞争条件 ,那么我们称这段代码 thread-safe(线程安全)

数据竞争的根本原因

CPU内核一次只能执行一条机器指令。因其不可分割性,我们称这种指令为 原子指令(它不能被分解成更多步骤的操作)。希腊单词 atom(ἄτομος;atomos)的意思是不可切割的

因不可分割的特性,原子操作天然的线程安全:当 线程A 的写操作具备原子性时,其他线程在 写操作指令 完成之前则无法执行 读操作指令;反之亦然,因此不会发生数据竞争

可问题就是,大部分的操作指令都不是原子指令。即使是像 i++ 这样简单赋值也是由多个原子指令组成 (可以思考以下,为什么它不是原子操作,面试官很喜欢问的一个问题),从而使赋值本身作为一个整体是非原子的。因此,如果一个线程B 读取 i 而线程A 执行赋值,就会触发数据竞争

竞争条件的根本原因

抢占式多任务处理机制使得操作系统能够完全掌控和管理线程:它可以根据调度算法启动、暂停和中止线程;作为程序的开发者,你并不能控制线程的执行时间和顺序。实际上,你并不能保证如下代码会按照指定的顺序执行(一般是指从上到下)

writer_thread.start()
reader_thread.start()

运行多次,你就会发现有时 writer 线程先启动,有时 reader 线程先启动。如果你的程序需要保证先执行写操作再执行读操作,那么你肯定会遇到竞争条件

这种行为被称为不确定性:结果每次都会改变,你无法预测。调试受竞争条件影响的程序非常烦人,因为产生的bug是非必现的,而且有可能在你的计算机运行不会出问题,而在别人的计算机上就会有问题

多线程与并发

多线程编程,数据竞争 和 竞争条件 是经常会出现的问题。容纳多个并发线程的技术称之为: concurrency control (并发控制),操作系统或者一些编程语言为我们提供了一些解决方案,比较通用的是如下几种:

  • synchronization 同步: 确保同一时刻仅有一个线程在使用资源。将代码的特定部分作上标记,这样多个并发线程就不会同时执行这段代码,也不会让共享数据变得混乱
  • atomic operations  原子操作: 借助操作系统提供特殊指令,将非原子操作(如:赋值操作)转化为原子操作 ( 比如C#的 Interlocked.Increment )
  • immutable data 不可变数据: 当数据被标记为不可变(ReadOnly),只允许线程从中读取数据,而不允许线程改变数据内容

以上三种只是比较常规的做法,C# 内关于多线程的解决,还有一些其他方案,比如:监视器(Monitor)、互斥锁(lock)、读写锁(ReadWriteLock)、互斥(Mutex)、信号量(Semaphore)、事件(AutoResetEvent/ManualResetEvent)等

bookmark_border[译文]5分钟系列-快速理解Hash Table(哈希表)

原文链接 : What is a Hash Table?

什么是哈希表?

哈希表是用来存储同种类型 键/值对 的数据结构,它使得存储在哈希表内的 键/值对 能被非常高效的查找到。最典型的哈希表就是衣柜内的抽屉 :假如 第一格存放袜子,第二格存放T恤,第三格存放内衣。那么要找某一双袜子,你只要去第一格就能快速找到。 key(键)就是抽屉索引,value (值) 就是抽屉内存放的衣物

了解哈希表有什么用处?

  • 你可以大概了解哈希映射(hash map)、关联数组( associative array )、字典 ( dictionary ) 等这些常用的数据结构如何实现
  • 可以明白哪些数据比较适合用哈希表来存储

5分钟带你走入哈希表

假如我们现在要存储用户信息,同时还可以根据名字查找到对应用户的信息

用户信息

我们大可以将这三个用户存在一个数组内,查找的时候遍历数组对比名字,就能找到。看起来没什么问题,假如有成千上万个用户呢?那最差情况是不是得遍历所有用户挨个对比名字才能找到我们想要的用户信息,这显然效率低下

创建哈希表

为了能使用哈希表,存储的对象必须要有一个 唯一 的东西当作key,我们称它为:ID,假设用户的名字作为 ID 。哈希表的工作原理是将对象存储在对应的桶(buckets)内

一个哈希表选择多少个桶,各种编程语言内的集合类存储结构对桶的数量都有着不同的考量,这是个很大的话题,本文暂且就用4个桶来阐述哈希表的基本原理

每个用户该存储在哪个桶内呢?这是很关键的一步。你当然可以随便放入一个桶内,但下次拿的时候,你能想起来你放在哪个桶内吗?所以,此时需要一种算法,能根据 ID 迅速找到对应的桶号( 这就是 Hash Function 哈希函数 )

假设 a= 0, b=1, c=2 依次类推(不区分大小写),Ada = 0+3+0 = 3 ,那么 用户ada 就放入3号桶

同理,当我们需要获取 Ada 的相关信息的时候,就可以根据相同的算法,快速定位到 3 号桶,从而拿到他/她的信息 。让我们来存放第二个用户:Grace

哦吼,29超过了我们最大的桶编号,咋个办 ? 一个比较通用的办法是将 29 与桶的数量进行求余运算

一群小朋友玩躲猫猫的游戏,但是一开始谁最先找呢?就用到 “点兵点将 骑马打仗 有钱喝酒 没钱滚蛋 ”这个顺口溜,小伙伴站一排,然后通过这个顺口溜,一个字一个人,从头到尾数,人数不够就又从头来,点到 谁是 “蛋” 字,谁就开始找,这就是最原始的”求余”

29 % 4 = 1(也可以表示为:29 mod 4 = 1),因此用户 Grace 放入1号桶内,结果如下:

冲突

一个好的哈希函数的终极目标就是让每一个桶内只存放一个用户信息,让我们来存放用户 Tim 试试

结果显示,用户 Tim 应该放入 3 号桶,但是 3 号桶已经存储了 Ada 的相关信息,这就是所谓冲突

针对冲突,常用的方法有如下方法:

  • 发现当前桶已经存储了信息,就存入下一号桶内,如果下一号桶也存储了信息,就继续下去直到有空的桶。上文 3 号桶已经存储了信息,那么我们就找下一号桶,因为没有4号桶,所以就跳到0号桶存入
  • 每个桶是一个数组/链表,可以存储多个对象,求余结果一样,放到同一个桶内
  • 增加桶的数量,这样就能大大降低冲突的概率

因此,哈希表最重要的是哈希函数,一个好的哈希函数能让信息均匀的存入每个桶内(但是冲突永远无法避免,只能减少概率)。最坏的哈希函数导致的结果可能是所有的对象都放在同一个桶内,这就跟直接放入数组内没有什么区别了

关于哈希表的更详细信息,可以参考我翻译的数据结构系列文章:数据结构

课外延申

bookmark_border[译文]5分钟系列-快速理解Huffman Coding(霍夫曼编码)

原文链接What is Huffman Coding?

什么是霍夫曼编码 ( Huffman Coding )

霍夫曼编码是很多压缩算法的基础,比如著名的 DEFLATE (常用的图片格式 png 就用到了 DEFLATE ) 和 Gzip

为什么要了解霍夫曼编码?

有没有偶然的瞬间,或是通勤途中的地铁上,抑或是入眠前的思绪畅游,脑海中有如下疑问:

  • 如果做到无损压缩数据?
  • 为什么同一个文件,不同的压缩算法会有不结果(压缩率,压缩/解压时间)?
  • Gzip 是如何工作的?

5 分钟带你走入哈夫曼编码

压缩

假设我们想压缩一段字符串 (哈夫曼编码可以压缩任意数据,本文只是讲解基本原理,选用字符串最容易理解)

通常一段文本中,有些字符出现的频率会比另外一些字符更高;而哈夫曼编码就正是利用了这一点,对这段文本中出现的全部字符重新编码,让出现频率更高的字符占用更少的空间从而达到压缩的目的

就用 Yoda 大师的经典名言 “do or do not” 来当作示例,这句话一共 12 个字符。按照计算机默认编码格式 (关于编码格式,你可以参考UTF百科),每个英文字符占用 8 比特 (bit) , 一共占用96比特 ;那么采用霍夫曼编码以后一共占用多少比特呢?

首先,我们得先构建哈夫曼树。出现频率最高的字符,就距离树的根节点最近。依次类推,下图就是字符串 “do or do not” 的哈夫曼树

最常见的字符 ‘o’和 ‘ ‘ (空格) 距离根节点只有 2 步,而最不常见的 t 则距离根节点有 3 步。哈夫曼编码最神奇的事情来了,我们存储的不再是字符本身,而且存储从根节点到达它的路径。具体什么意思呢?

我们从根节点开始,然后沿着树像要编码的字符前进。如果走了左侧路径,则标记为 0,走了右侧路径,我们则标记为 1

因此,字符 d 的编码为:’100′ ,而字符 d 的默认编码是:’ 01100100 ‘,整个字符串编码以后的结果如下:

最终需要消耗 96 比特的字符串,采用哈夫曼编码以后,只需要 29 字节,足足压缩了2/3

解压

怎么解压呢?就是照着存储路径,依次从哈夫曼树拿到该路径对应的真实字符

聪明的你是不是早已想到,如果我只把压缩后的数据发给别人,别人没有对应的哈夫曼树,就没法解压。是的,大概来说有 3 种办法:

  • 将哈夫曼树和压缩后的文本一起发给对方,就可以根据你发的哈夫曼树来解压
  • 可以双方都同意同一颗已知的哈夫曼树,压缩和解压就可以都用它
  • 发送足够的信息,对方可以根据这些信息构建出哈夫曼树从而达到解压的目的(Gzip的工作方式),但是请注意:同样的信息,也有可能构建出不同的哈夫曼树,因此发送的信息要确保构建的哈夫曼树一致

课堂疑问

  • 上面的示例,为什么哈夫曼树是 4 层 ?所有的哈夫曼树都是 4 层吗?
  • 一大段 中文文本 和 英文文本 ,压缩比例会一样吗 ?

课外延申

bookmark_border[原创]Unity面试经验分享

面试的这一个月时间,拿了几家大厂的offer(包含自己最心仪的公司),即将去新的工作岗位,所以总结一下这一个月的面经,希望对读到这篇文章的你,有些许帮助

letcode 肯定要刷一刷,这属于送分题。你不刷,到时候问你就只能挠头,刷完以后,你再临场发挥就有思路了。哪怕面试官问的题,你没刷到,你也不至于犯憷。最起码,如果是数组那么左右指针试试?

刷题也有技巧,不是照着 letcode 来一遍,力扣探索页 就有很多大厂高频算题,你可以把微软、腾讯、字节、还有初级中级的题都刷刷;30分钟以内想不出来,建议直接找答案(我是用 js 刷的,vscode + js 简直不要太爽)

下面就是一些我被问到,我觉得可以跟大家分享的问题,具体是哪个厂的面试官问题,就不方便说了(以下答案,是我自己的理解,如有错误还请告知,以免误人)

问:A*算法了解吗?大概说说

A*是比较经典的寻路算法,它的重点就是启发函数:总代价 = 当前点到起点的代价+ 当前点到终点的代价(代价可以是距离或者权重等)。算法的核心是两个列表,开启列表和关闭列表,未走过的节点放入开启列表,已走过的节点放入关闭列表。每个节点的核心元素:当前点的总代价,当前点的父节点

问:C# 字典的具体实现讲讲?跟哈希表有什么区别?

字典的实现,主要是两个数组;桶数组以及对象数组。对象是个结构体(hashcode,同哈希值的下一个对象的下标索引,key,value);如果 new 字典的时候传入了字典大小,那么会初始化,否则要等到添加对象的时候初始化

ADD:有个 freeCount和freeList来标记,当前对象数组,是否有空位置。如果有,优先放入空位置。然后判断哈希值,如果对应的桶内已经有值,那么头插法;桶的索引值变成当前新添加对象所在对象数组的索引,而它的next就是桶原来的索引值

REMOVE:最巧妙的地方,删除以后,该对象的next值等于freeList,然后新的freeList等于这个被删的对象的索引,这样的话,根据freeList就能找到所有被删以后的空位置

扩展:注意,字典没有容量因子的说法,一定要对象数组满了才会扩容。而且它扩容是当前长度*2,然后选中大于这个值的素数,之所以用素数,是减少哈希冲突。当哈希冲突次数超过100以后,才会执行重哈希

问:谈谈 Unity 的 GC

标记阶段:收集器从根对象开始进行遍历,对从根对象 ( 正在运行的局部变量、静态变量、重写 Finalize 方法的变量、正在调用的函数传递的参数 ) 可以访问到的对象的同步索引块标标记为可达

清除阶段:收集器会对堆内存从头到尾进行线性的遍历,如果发现某个对象没有标记则将其回收(回收之前,会先执行它的 Finalize() 方法,如果重写了话)

缺点:容易造成内存碎片化

2019.3增加了增量GC优点:避免出现GC时CPU峰值,导致掉帧或者卡顿 ; 缺点:1. 因为是分帧执行遍历,所以得采用写屏障(可能导致bug),2. GC总时常变长 

CG触发:1.向托管堆申请连续内存块,但是托管堆中的连续内存块大小不足2.GC 自动触发3.手动调用GC

CG优化: Cache变量,使用对象池,闭包,匿名函数和协程会有托管堆内存分配,配置表拆分,字符串,手动调用GC

问: Editor 和真机环境下,profiler 看内存是否有差异?为什么?

有差异,一般有以下原因:

  • 一些引擎API在Editor和真机环境下,实现原理不一致。比如加载ab.LoadFromFile, Editor下会加载整个AB,而真机只加载AB头部;GetComponent在Editor会造成GC Alloc, 真机不会
  • 我们自己写的或者第三方插件代码实现不一致
  • 编辑器可能缓存了某些资源(比如你选中某个纹理,该纹理会一直在内存中)

问:谈谈资源卸载?

非托管资源,手动卸载;托管资源,清空引用

  • 1. 文件,网络套接字,数据库连接等采用Dispose + Finalize() 释放 ;
  • 2. 资源(纹理,网格, ab中LoadAsset() 等) 用Resources.UnloadAsset(obj) 来卸载
  • 3. AssetBundle内存 (非托管内存 ),如果有指向该资源的变量( 假设名为:ab),则可使用  ab.Unload ( false/true ) 来卸载
  • 4. 最后调用Resources.UnloadUnusedAssets()来释放(此方法非常耗时)

问为什么说 MOBA 要用帧同步? 帧同步用什么网络协议?以及为什么?

moba类游戏小兵多玩家少且固定;对实时性、流畅性 、公平性要求较高。用帧同步的优势是回放制作方便,流量消耗少,制作离线战斗方便 ( 新手战斗 ),而难度则如下:

  • 1. 保证一致性
  • 2. 开发难度比较高,经常会有以主角为主类的想法。也有可能会直接拿玩家的位置等属性作为参数等的情况 ( 一定要注意逻辑代码和表现分离 )
  • 3. 流畅性(性能,网络)

一般来说,都会采用UDP协议,因为帧同步方案,会有高频次的上报和下发逻辑帧信息,所以对网络要求很高 而它的优点协议简单,包头小。缺点是无序,不可靠,所以需要额外信息来保证传输数据的可靠性。因此,我们需要在传输的数据上增加一个包头。用于确保数据的可靠、有序主要使用两种策略来决定是否需要重传数据包 : 超时重传、快速重传

超时重传:发送数据包在一定的时间内没有收到相应的ACK,等待一定的时间,就会重新发送。这个等待时间被称为RTO,即重传超时时间

快速重传:明确丢了哪个包,那么不用等超时,直接快速重传

问:帧同步有遇到过不同步的问题吗?怎么解决?

原则就是:就是保证客户端的输入一致,计算一致

  1. 主要的点就是随机数(服务器发随机种子)和浮点数运算(采用定点数)
  2. 战斗逻辑不要使用一些排序不固定的容器,比如:Dictionary
  3. 逻辑层不要使用协程(协程是根据Unity的Update去驱动,而不是我们自己的帧逻辑)
  4. 第三方插件的某些实现导致不同步(比如:Update不受帧控制,导致某些计算两个客户端不是在同一个逻辑帧,就会导致异常)
  5. 逻辑层和表现层一定不能交叉调用,表现层根据Update去轮询逻辑层的帧

问:说说深度缓冲(z-buffer)与w缓冲(w-buffer)

z-buffer 保存的是经过投影变换后的 z 坐标,投影后物体会产生近大远小的效果,所以距离眼睛比较近的地方,z 坐标的分辨率比较大,而远处的分辨率则比较小。所以投影后的 z 坐标在其值域上,对于离开眼睛的物理距离变化来说,不是线性变化的(即非均匀分布),这样的一个好处是近处的物体得到了较高的深度分辨率,但是远处物体的深度判断可能会出错

w-buffer 保存的是经过投影变换后的 w 坐标,而 w 坐标通常跟世界坐标系中的 z 坐标成正比,所以变换到投影空间中之后,其值依然是线性分布的,这样无论远处还是近处的物体,都有相同的深度分辨率,这是它的优点,当然,缺点就是不能用较高的深度分辨率来表现近处的物体

问:什么是法线贴图,缺点是什么?为什么法线贴图一般都是偏蓝色?

法线贴图是简单的视觉欺骗,一旦凹凸太明显的模型,使用了法线贴图,太靠近的时候,就穿帮了。法线贴图主要适用于凹凸不太明显,细节很多,需要表现实时光照效果,不会太靠近观察的物体的情况

生成法线贴图,一般都是采取纹理的灰度图。根据两个像素间的灰度差,形成U,V两个向量,然后两个向量的叉积就是法线的方向。在切线坐标系里,定义顺序是Tangent、Binormal、Normal。也就是说,Normal处于z这个方向。而对于一个三角形而言,绝大多数时候,法线值都是垂直于这个面的。显而易见,法线贴图的法线值大多数时候是接近于(0,0,1)的,当然是接近于蓝色了

问:为什么 png,jpg 不能直接用,而要采用etc,astc格式?

jpg , png 是针对硬盘的压缩格式,使用的时候需要CPU解压。最大的问题他们都是基于整张图片的压缩,像素与像素之间在解码过程中存在依赖关系,无法实现单个像素级别的解析;而且,png,jpg 解码以后都是 RGBA 的纹理格式,无法减少显存的占用率。而 etc 和 astc 这种是针对图形接口而设计的压缩格式,不需要 CPU 解压而 GPU 可以直接采样

问:AOP , IOC 懂吗?简单说说

 AOP:与传统OOP对比,面向切面,传统的OOP开发中代码逻辑是自上而下的,在自上而下的过程中会产生一些横切性的问题,这些横切性的问题和我们主业务逻辑关系不大,会散落在代码的各个地方,造成难以维护,AOP的思想就是把业务逻辑和横切的问题进行分离,从而达到解耦的目的,是代码的重用性和开发效率提高

IOC :主要是针对接口,抽象编程,而不是具体实现。要做到绝对的依赖注入,就要配置文件,使用反射,针对接口,外部dll实现功能(这一块,可以去B站找找相关的视频,那个老师废话很多,但是得耐心看下去)

问:UGUI  优化

Canvas负责将其子节点的UI元素的网格合并,并生成相应的渲染命令发送到Unity的图形管道,当UI发生了变化,它就要执行一次Batch给GPU进行渲染。Canvas只影响其子节点,但是不会影响子Canvas

重建:

  1. 重新计算一个Layout组件子节点的适当位置,或者可能的大小
  2. Graphic发生了变化,比如:大小,旋转,文字的变化,图片的修改等,都会引起Rebuild

优化:

  1. 动静分离
  2. 少对Canvas增加、删除、显示、隐藏;少对Canvas进行颜色,材质,纹理等的改变
  3. 预加载UI模块
  4. 不直接关闭,设置UI界面为其他的layer
  5. 不需要进行事件接收的组件,取消勾选Raycaster Target
  6. 不适用富文本的Text,取消勾选Rich Text,不使用Best Fit
  7. 用TextMashPro 代替OutLine,shadow组件

问:讲讲UGUI的Draw Call

UGUI 主要是根据 depth 来判断 Draw Call 数量(还有纹理,材质,shader等)

  1. 按照 Hierarchy 节点顺序,从上往下进行深度分析(深度优先)
  2. 且没有任何其他渲染元素与它相交深度 = 0
  3. 有其他元素跟它相交,则找到相交渲染元素的最大深度的渲染元素,判断是否能够与它合批,如果可以,则它等于最大深度,否则深度= 最大深度+ 1
  4. 按照 Hierarchy 顺序来的对深度进行排序,然后根据材质的 Instance ID,纹理的 Instance ID 排序

优化点:

  1. 一个Canvas组建下的元素才会合批,不同Canvas不会合批
  2. 有时候会为了合并层级,我们需要给Text垫高层级
  3. 一个节点的RectTransform的值不会影响合批,Image和rawImage如果引用了相同的texture,可以合批

问: Lua GC 的垃圾回收机制?

lua使用的是经典的标记清扫算法;Lua所有类型的对象都统一为Tvalue;所有动态分配的对象串连成一个链表(或多个);Lua里的注册表,主线程等,这些根集对象再去引用其他对象,由此展开成对象的关系结构

Lua的垃圾回收周期共分为四个阶段:标记、整理、清扫、收尾
标记阶段:Lua会首先将根集合中的对象标记为活跃,然后将可以通过根节点访问到的对象也标记为活跃
整理阶段:Lua会遍历所有的userdata,找出未被标记且有__gc元方法的userdata,将它们标记为活跃,并放入单独的列表中。再根据所有的弱引用table,删除那些未被标记为活跃的key或者value
清扫阶段:Lua遍历所有对象,如果当前对象未被标记,就收集它,否则清除它的标记
收尾阶段:根据上面生成的userdata列表来调用终结函数(类似C#的析构函数)

问:Lua 如何实现协程?

线程:抢占式多任务机制,是一个相对独立的、可调度的执行单元,是系统独立调度和分配CPU的基本单位。它由操作系统来决定执行哪个任务,在运行过程中需要调度,休眠挂起,上下文切换等系统开销,而且最关键还要使用同步机制保证多线程的运行结果正确

协程:协作式多任务机制,协程之间通过函数调用来完成一个既定的任务。它由程序自己决定执行哪个任务,只涉及到控制权的交换(通过resume-yield),同一时刻只有一个协程在运行,而且无法外部停止。通俗来说,协程就是可以用同步的方式,写出异步的代码

协程(Coroutine)拥有4种状态:

  • 运行(running)如果在协程的函数中调用status,传入协程自身的句柄,那么执行到这里的时候才会返回运行状态
  • 挂起(suspended)调用了yeild或还没开始运行,那么就是挂起状态
  • 正常(normal)如果协程A重启协程B时,协程A处于的状态为正常状态
  • 停止(dead)如果一个协程发生错误结束,或正常终止。那么就处于死亡状态(不可以再重启)

Lua的协程是一种非对称式协程,又或叫半协程,因为它提供了两种传递程序控制权的操作:1. 重启调用协程,通过coroutine.resume实现;2. 挂起协程并将程序控制权返回给协程的调用者,即通过coroutine.yield实现。对称式协程,只有一种传递程序控制权的操作,即将控制权直接传递给指定的协程

协程(Coroutine)具有两个非常重要的特性:1. 私有数据在协程间断式运行期间一直有效;2. 协程每次yield后让出控制权,下次被resume后从停止点开始继续执行

问:lua中的闭包懂吗?说说看

闭包主要由2个元素组成;1. 函数原型:一段可执行代码。在Lua中可以是lua_CFunction,也可以是lua自身的虚拟机指令 2.上下文环境:在Lua里主要是Upvalues和env

  1. Upvalues是在函数闭包生成的时候(运行到function时)绑定的
  2. Upvalues在闭包还没关闭前(即函数返回前),是对栈的引用,这样做的目的是可以在函数里修改对应的值从而修改Upvalues的值
  3. 闭包关闭后(即函数退出后),Upvalues不再是指针,而是值

问:类(class)和结构(struct)的区别是什么?它们对性能有影响吗?在自定义类型时,您如何选择是类还是结构?

Class是引用类型,Struct是值类型。Struct不可以被继承,但是可以Override它基类的方法;也可以实现接口。比如:如果想用Struct当作字典的key,但是又想避免有装箱和拆箱等操作,则可以实现接口IEquatable的Equals方法,则可以避免装箱和拆箱操作,以下是一些详细的区别

  1. 值类型对象未装箱和已装箱两种形式,而引用类型总是处于已装箱形式
  2. 值类型从System.ValueType派生,重写了Equals和GetHashCode,由于默认的实现存在性能问题,所以在自定义值类型的时候,如果有用到,应重写这两个方法
  3. 自定义的值类型不应该有虚方法
  4. 引用类型的变量的值是堆上的一个对象的地址,值类型包含的是值
  5. 引用类型赋值是复制内存地址,而值类型赋值则是逐一字段复制
  6. 未装箱的值类型,不在堆上分配内存。而引用类型在堆上分配内存
  7. 因为未装箱的值类型,没有同步索引块,所以不支持多线程同步
  8. 虽然未装箱的值类型,没有类型对象指针,但是仍可以调用由类型继承或重写的虚方法(比如:Equals,GetHashCode或ToString),但是请注意:调用这些方法可能会产生装箱。具体如下:
  9. 如果结构体重写了虚方法,并且虚方法内没有内部调用父类的方法,那么结构体实例调用这个重写的虚方法就不会装箱
  10. 如果调用父类的非虚方法,那么一定会产生装箱,一定得需要一个类型对象指针,以定位父类类型的方法表才能执行该方法

以下情况适合值类型:

  1. 类型具有基元类型的行为,意思就是结构十分简单,属性没有可变类型
  2. 类型不会派生子类,也不从其他类继承
  3. 类型的实例较小 ( 因为值作为参数传递或者作为方法返回值的时候,会拷贝整个结构,对性能有损害,所以类型的实例大小是个重要考虑指标 )

关于值类型的装箱问题,发生装箱操作时,在内部发生的事情如下:

  1. 在托管堆中分配好内存,分配的内存量是值类型各个字段需要的内存量加上两个额外(类型对象指针和同步索引块)需要的内存量
  2. 将值类型的字段复制到新分配的内存中
  3. 返回对象的内存地址,现在值对象变成了引用类型

问:泛型的作用是什么?它有什么优势?它对性能有影响吗?

面向对象的好处是代码重用,对一个类来说,可以继承基类的所有能力,同时可以重写虚方法,或者添加一些新方法,就可以定制该类的行为。而泛型,则是支持另外一种形式的代码重用,即”算法重用”

泛型具有如下优势:1. 源代码保护  2. 类型安全(在编译期就能过滤不符合的类型数据)3. 更佳的性能 ( CLR不再需要执行装箱操作;也不需要类型转换,这对于提高代码运行速度很有帮助 )

问:抽象类和接口有什么区别?使用时有什么需要注意的吗?如何选择是定义一个抽象类,还是接口?什么是接口的“显式实现”?

接口:描述的是Can-do,是一类行为,而不是针对具体某个类。比如: IDispose,IEnumerable等都是描述一类行为

抽象类:为了继承而存在的,如果你定义了一个抽象类,却不去继承它,那就没有任何意义。抽象类不可用来实例化

拿个具体的事例,比如说动物Animal ,这是一个高度抽象的概念。说到动物,有些人想到的是狗,有些人想到的是猫。所以Animal这个类没办法具体实例化出来,应该设计为抽象类,不继承它就没有意义。那有些动物会飞,有些动物会游,这表示有些动物具有某些行为,应该设计成接口,比如:Ifly, Iswim等

在考虑是使用抽象类还是接口的时候,还要考虑易用性和后期版本控制。因为如果要修改接口(比如,添加新的方法),那么实现了该接口的所有类都必须实现这个新方法。所以改动很大 接口的显示实现,是指继承的接口的方法 与类本身的方法重名而采取的措施,就是将定义方法的那个接口的名称作为方法名的前缀(例如:IDispose.Dispose)。注意,在C#中定义一个显示接口方法时,不可以指定可访问性(比如:public或privtae),也不能标记为virtual,所以它不能被重写。这是因为显示实现的方法并非是该类类型的对象模型的一部分,它是将一个接口连接到一个类型上

问:谈谈 Unity 中的协程

协程其实是分帧执行的类似语法糖。协程方法,在编译以后,会生成一个对应名字的类,继承了IEnumerator<object>, IEnumerator, Idisposable 这三个接口。最主要实现了 MoveNext() 方法,Current 属性。而实现原理则是在MonoBehaviour 内的Update和LateUpdate方法之间,根据Current的值是否调用该类的MoveNext()方法

当MonoBehaviour被摧毁以后,协程也会被终止。但是enabled =false,协程会继续执行

协程的使用规范:需要的时候使用,用完就销毁。不要用对象池保存,因为协程会引用MonoBehaviour类(包括变量,方法等),所以如果不销毁,这些引用会一直存在,从而导致无法被GC。有点类似闭包,以及委托

问:说说关于 Unity 内存,显存,CPU 的优化

这一部分太庞大了,如有有需要可以邮件我,我会把整理过的文档发给你们。邮箱地址:lingzelove2008@163.com

bookmark_border[原创]C#中Dispose和 Finalize

要讨论这两个方法,首先要知道C#的 GC,因为有了 GC,所以开发者才能专注于应用程序的功能;然而 GC 也有局限性,它只能释放托管内存中的对象;像文件句柄,网络套接字,数据库链接这种非托管资源,GC 就无法帮你释放了。如果不释放这些资源,就会导致应用程序一直引用它们,而其他应用程序就无法对它们进行操作(比如:当你打开Word 文件,然后删除它的时候就会弹出:操作无法完成,因为文件已经在 Microsoft Office Word中打开)

C# 内的 Dispose() 和 Finalize() 方法,就是释放对象中非托管资源的方法。如果你想在自定义的类里面实现 Dispose() 方法,那么就得引用 IDisposable 接口;你可能见过使用 using 的代码,它其实就是try/finally格式的语法糖,在finally内会主动调用 Dispose() 方法,所以当你遇到非托管资源的操作,using是个很安全的做法。当然,你也可以在使用完该对象以后,主动调用它的 Dispose() 方法

using(FileStream file = new FileStream("path", FileMode.Open, FileAccess.Read))   
{  
    //Do something with file   
}  

finailze() 是 object 类的内置方法;注意:你查看 object 类源码的时候,并不能查找到 finalize 方法,它是析构方法” ~Object() “编译产生的。如下图,析构方法 ~FinalizeTest() 编译成IL代码以后,就变成了 Finalize():

源码和IL代码

那么问题来了,既然有 Dispose() 方法,为什么还需要 Finalize() 方法呢? 因为有时候持有非托管资源的变量可能被用在很多地方,我们无法显示判断在何时调用 Dispose() 方法释放该资源。只有在 GC 的时候,我们判断引用该非托管资源的变量已经不被任何其他对象引用,这时候才是合适的释放时机。然而GC过程是内部实现的,我们无法在 GC 过程中手动调用 Dispose() 方法,所以 Finalize() 方法的用处就来了,GC 会自动调用 Finalize() 方法,我们只要把释放非托管内存的代码写在 Finalize() 方法中,就能释放非托管内存了

那么 Finalize() 是怎么被调用的呢? 当程序运行以后,GC会收集实现了 Finalize() 方法的对象,并把他们放入一个队列。当某个对象可以释放内存的时候:GC会把它所持有的非托管内存释放,并从队列中移除;但请注意,该对象在托管堆中并未释放。只有在下一次 GC 的时候,才会从托管堆中释放该对象

至此,我们已经了解了 Dispose() 和 Finalize() 方法,那是不是可以让他们两完美组合呢?既可以让用户手动调用,也可以做个托底,让GC自动调用。肯定可以的,代码如下:

using System;
public class FinailzeTest : IDisposable
{
    //GC自动调用
    ~FinailzeTest() { Dispose(false); }

    //用户手动调用
    public void Dispose() { Dispose(true); }

    protected virtual void Dispose(bool disposing)
    {
        //释放托管资源 
        if (disposing) { managedObject = null; }
        //释放非托管资源
        unManagedObjectDispose();
        //告诉GC, 把该类对象从列表中移除, 到时候不再执行Finalize()方法
        if (disposing)
        {
            GC.SuppressFinalize(this);
        }
    }
}

如果用户没有主动调用 Dispose() ,未能及时释放托管和非托管资源,那么在垃圾回收时,会执行Finalize(),释放非托管资源(注意:非托管资源并未被释放)
;如果用户主动调用了 Dispose(),就能及时释放了托管和非托管资源,而且垃圾回收的时候不会执行 Finalize() 方法,提高了性能

好了,关于 Dispose() 和 Finalize() 方法,就写到这,以后有新发现,再更新!

bookmark_border[原创]何为利率市场化?

何为利率

说到利率,首先就要清楚两个概念:货币的时间价值和利息。现实生活中,一般来说现在的100块钱比一年后的100块钱更有价值,因为可以把现在拥有的100块钱存进银行,一年后从银行取出来的货币总额将大于100块钱。两者的差额就是通常所说的利息。对于这种现象,金融理论用”货币的时间价值”进行概括。所谓货币的时间价值(Time Value of Money),就是指同等金额的货币其现在的价值要大于其未来的价值,利息就是货币时间价值的体现

为什么货币具有时间价值? 西方经济学的解释:人类本性中,存在着不耐心等待而现在就想要消费的欲望。如果货币的所有者将其持有的货币进行投资(存银行也是一种投资)或者借给他人,他就必须牺牲当前的消费,对此,他会要求对其当前消费的推迟给予一定的补偿,补偿金额的多少与当前消费推迟的时间长短同向变动。因此货币的时间价值来源于对当前消费推迟的时间补偿

我们知道利息是借贷关系中资金借入方支付给资金贷出方的报酬。由于本金数量会对利息总额产生重要影响,这使得我们无法通过比较利息额来衡量货币时间价值的高低,因此,我们要引入一个新的概念来具体衡量货币时间价值——利率。它是指借贷期满的利息总额与贷出本金总额的比例。按照大白话来理解利率:就是借钱贵不贵,利率高,借钱的代价就高,利率低,借钱的代价就低

知道了利率我们就可以开始讨论各种名头的利率了,最重要的一个当属国之重器:基准利率(Benchmark Interest Rate),是在多种利率并存的条件下起决定作用的利率,其他利率会随其变动而发生相应的变化。我国的基准利率是:央行对商业银行及其他金融机构的存贷款利率,又称法定利率。可能你还听过上海银行间同业拆借利率(SHIBOR),它就是我国货币市场的基准利率,后面会多次谈到它

利率管制

为什么要搞利率市场化? 要回答这个问题,得先搞清楚两个问题:1. 什么是利率市场化? 2. 利率以前为什么没有市场化?

利率市场化是指金融机构在货币市场经营融资的利率由市场供求来决定。它包括利率决定、利率传导、利率结构和利率管理的市场化。即是货币当局将利率的决定权交给市场,由市场主体自主决定利率,货币当局则通过运用货币政策工具,间接影响和决定市场利率,以达到货币政策目标

利率之前为什么没有市场化呢?建国初期,出于对当时国际国内政治、经济等因素的全面考虑,我国领导人高瞻远瞩的选择了优先发展重工业的”赶超战略“。而重工业作为资本密集型产业,具有三个主要的特征:建设周期长、投资规模大、大部分设备需要从国外引进。而当时我国资金短缺、外汇短缺、资金动员能力弱。所以如果依靠市场机制来配置资源,是不可能把资金引入重工业部门的。为了解决这一问题,就不得不做出一些政策来降低市场机制的作用,而利率管制,就是这些政策里面重要的一个

当时的做法是中央银行进行利率管制,制定较低的存款利率和贷款利率。居民以较低的存款利率在银行存钱,银行以较低的利率优先向重工业相关的企业提供资金,助其快速工业化。实践证明,当时这种做法效果是不错的,在短时间内按照国家意志建立了重工业体系,为后续的经济腾飞打下了坚实的工业基础。但是随着我国经济的发展,这样做的弊端也逐渐显现

第一,因为银行的贷款完全是以政策为导向,不是按照科学的信贷风控来决策的,所以不良贷款率会较高;其次就是很多银行未训练出真正意义的经营管理能力,尤其是风险控制、客户服务,以及最重要的定价能力; 第二,因为产业部门市场化程度提高,银行再向其提供低利率资金,就会助长过度投资、过度杠杆; 第三,过低利率不能覆盖借款人风险,所以银行不愿意把信贷投放给中小企业(后两个问题以及这两个问题衍生出的问题,至今仍困扰着我国)

利率双轨

在中国经济市场化过程中,产品市场上同一产品存在计划垄断性定价和市场定价两种价格,称为”价格双轨制”。与之相仿,在金融市场中,也存在受管制的存贷款利率和完全由市场供求决定的市场化利率,这便是“利率双轨制

  随着市场化进程的加快,产品市场的价格已完成并轨,实现了由市场供求决定价格。金融市场的改革则相对滞后,所以利率双轨制问题仍然存在。银行根据央行公布的存贷款基准利率去和客户定价;央行通过政策工具调节货币市场利率。贷款利率与货币市场利率各行其是

在利率两轨制下,不管央行怎么调节货币市场利率,都不会明显影响存贷款利率。比如,政策当局想降低企业融资成本时,央行通过货币政策工具引导货币市场利率下行,但贷款利率却基本不动。因此,最理想的办法是实现利率两轨并一轨,实现从货币市场利率向存贷款利率的有效传导

利率体系

要明白利率的有效传导,就要了解我国目前的利率体系。我国的利率体系可分为:央行利率、金融市场利率和商业银行存贷款利率三个层次(如下图)。目前利率的双轨制问题是,央行的利率体系能影响金融市场利率。但目前我国社会融资的主要模式仍然是“间接融资”(也就是商业银行直接给企业放贷),而商业银行的贷款利率则是由被管制的”存贷款利率”决定,所以央行的利率体系并不能影响到商业银行的存贷款利率

为了应对双轨制利率,央行得采用两套不同的对策来应对银行存贷款市场与货币债券资金市场的问题

利率传导

针对所以央行的利率体系并不能影响到商业银行的存贷款利率问题,2019年8月人民银行宣布改革完善LPR形成机制推出了贷款市场报价利率(Loan Prime Rate, LPR),LPR的报价是在MLF利率基础上加点形成的。共有18家银行每月根据MLF等市场利率报出LPR,去掉最高值和最低之后形成的价格就是每月公布的LPR利率。央行可以调整MLF利率来影响LPR利率,而商业银行的贷款利率则是在LPR基础上加点,因此新的传导利率则打通。通过LPR利率,我们可以预知,MLF利率以后可能成为新的基准利率,至少它有这个潜力

货币市场有SHIBOR作为基准利率,而商业银行利率体系有LPR作为贷款基准利率。因此央行调节金融市场的可用货币政策工具越来越多,也越来越灵活。其实理想化的利率传导应该如下图:

① 首先,央行制定政策利率,即投放基础货币的各种工具(再贷款、逆回购、MLF、SLF等)的利率

② 然后,政策利率影响货币市场利率。比如SLF作为利率走廊上限,能够稳定市场利率

③ 货币市场利率会影响银行存款利率,因为理想模型中,如果市场利率足够低,银行就没必要更高成本去拉存款

④ 银行综合了各种负债成本之后,再考虑其他因素,与客户确定贷款利率

我们发现上述整个传导过程中,我们用不到存贷款基准利率,也用不到LPR……换言之,这两个基准利率似乎并不是未来成熟模式下的必需品(只是最后签合同时可以选它们为锚,也可以不选它们)

一二点,央行制定策略,没啥问题。利率传导卡在第三点,银行可以从货币市场获取同业负债,也可以从存款市场获取存款,理论上两种获取货币的代价应该一致,如果差别太大,比如货币市场利率过低,而存款利率过高,那么就没必要继续高息揽存。但现实中,即使货币市场利率再低,高息揽存现象依然屡禁不绝,背后是什么原因?因为我们实施了很多专门针对存款的考核,比如存贷比、同业负债不得超1/3等之类的,这就导致了银行无论如何也必须拉存款

第四点,银行内部只有建立了完善的FTP系统,才能实现负债成本向贷款利率的有效传导。否则,货币市场利率、存款利率变动之后,贷款也不会跟着及时变动。FTP是利率传导的真正最后一公里

第三点不可能一下子要求放开所有要求,而第四点才是最关键的,银行内部的FTP才是利率传导的真正最后一公里,也是利率并轨的最大阻碍,因此,正如央行自己说的,利率并轨工作非一日之功,需稳妥推进。

总结

目前我国利率市场化,已到了最后一公里。但这一公里才是整个利率市场化最难啃的骨头,一些经济学家给出的建议是:

一、建立一套完整的中央银行利率调整框架。一是优化货币政策的目标,突出价格稳定目标;而是进一步完善货币政策决策机能;三是健全货币政策操作系统;四是建立利率走廊机制

二、强化金融市场基准利率培育,健全市场基准利率体系。一方面进一步优化Shibor报价生成机制。另一方面,加速研究完善LPR报价质量考核评估指标体系

三、完善金融机构的定价能力,提高微观经济主体的利率敏感性

四、加速推进配套改革

bookmark_border[译文]Unity性能优化—图形渲染最佳实践

原文地址: Fixing Performance Problems – 2019.3

引言

本文我们将学习Unity在渲染每一帧幕后所发生的事情,以及有哪些问题会导致渲染卡顿,以及怎么解决这些问题

阅读本文之前,首先要明白对于提升渲染性能,没有一招鲜的办法。影响渲染性能的因素太多:包括游戏类型、设备硬件、操作系统等。重要的是我们通过观察,实践得到数据然后分析数据从而有针对性的解决问题。本文包含一些常见的渲染性能问题和解决办法以及一些扩展链接供你更深层次的了解其原理;当然,很有可能你们游戏中出现的性能问题,这篇文章并没有指出;不过,这篇文章你还是值得看看的,理解事物的本质对解决问题很有帮助

渲染简介

开始之前我们快速的过一遍,看看Unity渲染每一帧时发生了什么,理解整个流程以及每个过程发生的时机会对我们解决性能问题有帮助。渲染流程如下:

  • CPU(central processing unit)计算哪些物体需要渲染以及为这些物体设置渲染状态
  • CPU 发送图形渲染指令给 GPU(graphics processing unit)
  • GPU 渲染物体

“渲染管线(rendering pipeline)”通常用来描述渲染的过程;这十分贴切,高效的渲染就像车间的流水线,无停顿,高效率。渲染每一帧的过程中,CPU都会做如下工作:

  • 检测场景中的每一个物体是否需要渲染;一个物体被渲染需要符合一定的条件。比如:物体必须处于摄像机的可视范围内(注意:哪怕只有一部分处于可视范围,仍然需要渲染整个物体),不在范围内的则剔除,如果想了解更多关于物体剔除方面的内容,请查阅:Understanding the View Frustum
  • CPU收集和计算将要被渲染的物体的相关信息并发送命令给GPU(包括将网格和纹理等传送到显存,以及设置渲染状态,调用图形API),这个过程称之为:Draw Calls
  • CPU 给每一个 Draw Call 创建的数据包,称之为:Batch(批次),批次有时候会包含一些 Draw Call 以外的数据,但这些数据对性能没有什么影响,因此本文将不会讨论这些数据

每一个批次至少包含一个 Draw Call,CPU 针对批次会做如下事情:

  • CPU会发出更改指令,让 GPU 更改渲染状态,这个指令称之为:SetPass Call,SetPass Call 告诉 GPU 使用哪些设置去渲染网格。SetPass Call指令只有在渲染下一个网格的设置和渲染上一个网格的设置不一样时,才会发出
  • CPU通过 Draw Call 命令告诉 GPU,就按照上一次 SetPass Call 的渲染设置去渲染指定的网格
  • 有些情况下,一个批次可能需要不止一个pass,pass是一段shader代码,并且新的 pass 需要更改渲染状态,对于批次中的每个 pass ,CPU必须发送新的 SetPass Call 指令,然后再次发送 Draw Call 通知 GPU 按照新设置的渲染状态去渲染网格

同时,GPU也会做如下事情:

  • GPU 按照 CPU 发送到 Command buffer 内的指令顺序处理
  • 如果当前指令是 SetPass Call,那么GPU更新渲染状态
  • 如果当前指令是 Draw Call,那么GPU根据上一次设置的渲染状态来渲染网格。渲染网格的过程有很多阶段,这里就不一一阐述,不过你可以了解一下:顶点着色器(Vertex Shader)是用来处理网格顶点的,而片元着色器(Fagment Shader)是用来处理每一个像素的
  • 这个过程会重复执行,直到Command buffer内的指令都被执行完毕

大概了解了Unity渲染的简要流程,让我们来考虑渲染的过程中可能会出现的问题

渲染问题

关于渲染,最重要的一点是:每一帧之内,CPU和GPU都要按时完成自己的任务。他们中任何一个任务超时的话,那就会造成渲染问题。渲染问题一般有两个基本原因:1. CPU 约束,渲染过程中,CPU 为每一帧渲染准备数据花费的时间太长,导致渲染瓶颈 2. GPU 约束,渲染数量过于膨大,导致 GPU 渲染一帧需要花费的时间过长

有果必有因,在性能出问题的时候,找出导致性能问题的原因才是首要任务。针对不同的问题,我们才能给出不同的解决方案。修复性能问题,其实也是一项平衡性的工作,比如:牺牲内存用于提高 CPU 性能,牺牲游戏画质解决渲染瓶颈。我们会使用Unity自带的两个工具来定位问题:ProfilerFrame Debugger

CPU瓶颈

基本上,渲染每一帧的过程中,CPU 就干三件事儿:1. 决定渲染哪些物体 2. 为渲染准备好数据以及设置渲染状态 3.发送图形渲染API 。 这三类工作包含很多独立任务,这些任务可能是通过多线程完成的;当这些任务被分配到不同的线程执行时,我们称之为:多线程渲染

Unity的渲染过程中,有三类线程参与:主线程(main thread)、渲染线程(render thread)、辅助线程(worker threads)。主线程用于我们游戏中主要的 CPU 任务(也包括一些渲染任务),渲染线程主要是用来给 GPU 发送指令的,而辅助线程则用来处理单独的任务,比如:物体剔除和网格蒙皮计算。哪些任务执行在哪个线程,取决于我们游戏运行设备的硬件以及游戏的设置。比如:设备的CPU核心数量越多,我们就会线程越多的辅助线程。正是由于这个原因,在目标设备上进行性能分析十分有必要,在不同的设备上,我们的游戏表现可能有天壤之别

由于多线程渲染非常复杂而且非常依赖硬件条件,所以在尝试提升性能的时候,我们要理解是那些任务导致的CPU瓶颈。如果游戏卡顿是因为剔除物体是否在摄像机是窗内的线程超时,那么我们减少 Draw Call 对提升游戏体验,并没有什么卵用

注意:不是所有平台都支持多线程渲染,WebGL就不支持。在不支持多线程的平台,所有的任务都是在同一个线程中执行。如果在这种平台碰到CPU瓶颈问题,那么就试着去优化所有可能对CPU性能有帮助的点

Graphics Jobs

Project Settings ->Player->Other Setting->Rendering-> Graphics jobs 选项可以让Unity将那些本该由主线程处理的渲染任务分配到辅助线程中。在可以使用该功能的平台上,它将带来显著的性能提升(可以开启和关闭这个功能来做性能对比,该功能目前仍是预览版)

发送指令到GPU

发送命令到GPU这个过程,一般是CPU瓶颈的最常见原因。大多数平台这个任务是由渲染线程完成的,个别平台是在辅助线程(比如:PS4)

而最耗时的操作是 SetPass Call 指令,如果C PU 性能问题是因为发送指令到 GPU 引起的,那么减少 SetPass Calls 的数量通常是最有效的改善性能的办法。我们可以通过 Profiler 查看到 SetPass Call 和 批次的数量。多少 SetPass Call 会造成性能问题,这个和游戏运行设备的硬件有很大关系,在高端PC上可以发送的 SetPass Call 数量远大于移动设备

SetPass Call 数量以及对应批次数量取决很多因素,稍后会详细介绍。然而一般来说:

  • 减少批次数量 或者 让更多物体共用相同的渲染状态,通常会减少 SetPass Call 数量
  • 减少 SetPass Call 数量,通常能提升 CPU 性能

减少批次能提升 CPU 性能,即便减少批次并没有减少 SetPass Call 的数量。因为CPU 能够更有效的处理单个批次。一般来说有以下方式,能减少批次和 SetPass Call 的数量:

  • 减少需要渲染的物体数量,能减少批次和SetPass Call 的数量
  • 减少每个物体被渲染的次数,能减少 SetPass Call 的数量
  • 合并需要渲染的物体,可以减少渲染批次

减少需要渲染的物体数量

这是减少批次和SetPass Call最简单的方法了,有以下方法可以降低渲染物体的数量:

  • 直接减少场景中的可见物体数量。比如:场景中有很多人物需要渲染,我们可以选择性的只渲染一部分人
  • 使用摄像机的 Far Clipping Planes 属性来降低摄像机的绘制范围。这个属性表示距离摄像机多远的物体不再被渲染
  • 通过 Occlusion culling 技术来关闭被其他物体完全遮挡的物体,这样就不用渲染被遮挡的物体了。请注意:这个功能不适用于所有场景,但是在某些场景它确实带来很可观的性能提升。另外,我们可以通过手动关闭物体来实现自己的遮挡剔除。比如:如果我们场景中包含一些过场切换才会出现的物体,那么在过场播放之前或者结束以后,就应该手动隐藏他们以减少需要渲染的物体。手动剔除往往比Unity 提供的动态剔除更高效

减少每个物体渲染的次数

实时光,阴影,反射等效果可以极大的提高游戏的真实感;但其实这些效果都非常消耗性能,因为这些效果会导致物体被渲染多次

我们对游戏的渲染路径的设置,对这些功能的性能消耗也有实质性的影响。渲染路径:表示绘制场景的时候,渲染计算的执行顺序;不同的渲染路径最主要的区别是他们怎么处理实时光,阴影和反射。通常来说,如果我们的游戏运行在比较高端的设备上,并且运用了实时光,阴影和反射,那么延迟渲染(Deferred Rendering)是比较好的选择。向前渲染(Forward Rendering)适用于不使用以上功能的低端设备。实时光,阴影和反射是个比较复杂的功能,最好能研究相关主题充分了解实现原理,可参考:灯光和渲染-2019.3

不管选择哪种渲染路径,实时光,阴影,反射都会极大的影响性能,所以优化他们十分有必要:

  • 动态光照是个非常复杂的主题,它超出了本文的讨论范畴,你可以参考这篇文章去深入了解:Shadow troubleshooting
  • 动态光照非常消耗性能,当我们场景中包含很多静态物体时,我们可以利用烘焙技术去预先计算场景的光照,生成光照贴图,详细可以点击:Lighting
  • 如果我们想使用实时阴影,你可以通过这篇文章来提交性能:Shadow Cascades。文章介绍了阴影的设置,以及这些设置怎么影响性能
  • 反射探头创建真实的反射,但是会影响合批。因此我们最好最小化的使用它,反射探头的优化请参考:Reflection Probe performance

合批物体

一个批次可以包含多个物体的数据,为了能合并批次,物体必须满足如下条件:

  • 共享相同的材质
  • 一样的渲染状态(比如:纹理,shader等)

合并物体确实可以提高性能;同时我们也要分析,合并物体带来的性能提高会不会反而造成其他方面更大的性能消耗。关于合批可以参考我上一篇文章:静态批处理、动态批处理、GPU Instancing

纹理图集(Texture Atlasing),是把大量的小尺寸的纹理合并成一张大的纹理。它通常在2D游戏以及UI系统中使用,Unity内置了图集工具:Sprite Packer

我们也可以手动合并共享材质和纹理的网格(可通过编辑器直接操作,也可以在运行时调用API操作),但是我们必须意识到,有可能我们手动合并以后,本来会被剔除而无需渲染的物体,因为共享相同的网格而必须渲染,而这些物体可能还有光照,阴影等更加消耗性能的操作,所以要权衡利弊

在脚本中,我们必须小心使用Renderer.material,这个接口会拷贝材质,并返回拷贝后的引用。这样做也会破坏合批,因为它和其他物体不再有相同的材质引用了。如果我们一定要访问合批物体的材质,应该使用Renderer.shareMaterial

蒙皮网格

SkinnedMeshRenderers通常用在网格动画中,渲染蒙皮的任务通常在主线程或者单独的辅助线程(取决于游戏的设置以及目标设备硬件)。渲染蒙皮是个很消耗性能的动作,如果在Profiler中看到是渲染蒙皮对CPU造成性能瓶颈,这里有几个方法我们可以改进性能

  • 考虑是否真的有必要使用蒙皮网格渲染组件,如果物体并不需要运动,尽可能使用普通的网格渲染组件(MeshRenderer)
  • 如果我们只在某些时刻运动物体,我们应该用细节较少的网格,(SkinnedMeshRenderers组件有一个函数BakeMesh,可以用匹配的动作创建一个网格)
  • 关于蒙皮网格的优化,可以参考:Skinned Mesh Renderer。(蒙皮网格消耗是在每个顶点上,因此,使用顶点较少的模型以及减少骨骼数量也可以提高性能)
  • 在某些平台,蒙皮可以被GPU快速处理。如果目标设备GPU比较强,则可以对当前平台开启 GPU Skinning

GPU瓶颈

如果游戏是GPU性能限制,那么首先就得找到GPU瓶颈原因。一般GPU性能最常见的问题是填充率限制,尤其是移动平台。当然显存带宽和顶点处理也可能影响

填充率

填充率是指 GPU 在屏幕上每秒可以渲染的像素数量;如果我们游戏是因为填充率导致的GPU性能问题,那么意味着我们游戏每帧尝试绘制的像素数量超过了 GPU 的处理能力,检查是否填充率引起的GPU性能问题其实很简单:

  • 打开Profiler,注意GPU时间
  • 重新设置渲染分辨率 Screen.SetResolution(width,height,true)
  • 重新打开Profiler,如果GPU性能提升,那么大概率就是填充率的原因了

如果是填充率问题,那么我们有如下几个方法解决这个问题

  • 片元着色器是告诉 GPU 怎么去绘制每一个像素的shader代码,如果这段代码效率低,那么就容易发生性能问题,复杂的片元着色器是很常见的引起填充率问题的原因
  • 如果我们游戏使用了Unity内置的Shader,那么应该使用针对移动平台的 the mobile shaders
  • 如果游戏使用的是Unity的Standard Shader,那么要理解 Unity 编译这些shader是基于当前材质设置,只有那些当前使用的功能才会被编译。这意味着,移除 detail maps可以减少片元着色器的复杂度
  • 如果使用的是定制的shader,那么应该尽量优化它。优化shader是个很大的话题,可以参考:Optimizations
  • Overdraw 是指相同位置的像素被绘制了多次。一般发生在某个物体在其他物体之上。为了理解 Overdraw,我们必须理解 Unity 在场景中绘制物体的顺序。物体的 shader 决定了物体的绘制顺序,通常由 render queue 属性决定。最常见引起 Overdraw 的因素是透明材质,未优化的粒子以及重叠的UI元素。关于Overdraw优化,可以参考:Optimizing Unity UI

显存带宽(Memory bandwidth)

显存带宽是指GPU读写专用内存的速度,如果我们的游戏受限于显存带宽,通常意味着我们使用的纹理太大了,我们可以用如下方法检测是否是显存带宽问题:

  • 打开Profiler,并关注GPU各项数据
  • Project Settings->Quality->Texture Quality,设置纹理质量,降低当前平台的纹理质量
  • 重新打开Profiler,重新查看GPU各项数据。如果性能改善,那么就是显存带宽问题

如果是显存带宽问题,那么我们需要降低纹理的内存占用,针对不同游戏通常有不同的解决方案,这里我们提供几个优化纹理的方法

  • 纹理压缩技术可以同时极大的降低纹理在内存中的占用。Texture Import Setting讲述了纹理压缩格式和各种设置的详细信息
  • Mipmaps,多级渐远纹理是 Unity 对远处物体使用低分辨率纹理。Unity场景视图中的 The Mipmaps Draw Mode 允许我们查看哪些物体适用多级渐远纹理。其实,Mipmap最主要的目的是为了提高质量,如果没有mipmap 纹理很大,采样频率却很小的情况下,模型看去来质量会很差(相邻的两个屏幕像素采样的纹素差的很远,此时会大大降低缓存命中率)

顶点处理

顶点处理是指 GPU 处理网格中的每一个顶点,顶点处理的消耗主要受两个因素的影响:顶点数量以及操作每个顶点的复杂度。有一些方法可以优化这个:

  • 降低网格复杂度
  • 使用法线贴图模拟更高几何复杂度的网格,关于法线贴图可以参考:Normal map
  • 如果游戏未使用法线贴图,在网格导入的设置中,可以关闭顶点的切线,这可以降低每个顶点的数据量
  • LOD,当物体远离摄像机的时候,降低物体网格的复杂度的技术,可以有效的降低 GPU 需要渲染的顶点数量,并且不会影响视觉表现,具体细节请参考:LODGroup
  • 顶点着色器,是一段shder代码,降低它的复杂度,可以提升性能