国内除了腾讯在国外名气很大吗游戏还有其他游戏公司名气,份额大,可多举,平台系统不限制

腾讯守护成长的限制名单里却没有LOL和王者荣耀 _多玩新闻中心_多玩游戏网努力加载中,稍等...
暂无新消息
努力加载中,稍等...
已无更多消息...
这些人最近关注了你
努力加载中,稍等...
已无更多消息
努力加载中,稍等...
已无更多消息
本文通过对XLua的HoxFix使用原理的研究揭示出来这样的一套方法。这个方法的第一步:通过对C#的类与函数设置Hotfix标签。来标识需要支持热更的类和函数...
SteamVR_RenderModel.cs跟踪设备的渲染模型,就是将跟踪设备(特别是手柄)模型显示出来。在Steam...
?本文主要使用微软提供的一套C#的API函数,通过这些API函数,可以对已经编译过的.Net体系生成的EXE,DLL文件进行修改,而不是修改源码编译的方式,来完成新功能的加入、或者原有功能的修改。这个方式可以应用于修改没有源码DLL或EXE文件、批量修改或插入代码功能到DLL或EXE文件中。背景介绍
unity3d在苹果上的热更新,一直是业界热烈讨论的话题。我所在的项目正在考虑使用LUA作为热更新的实现方式。于是在这种情况下,HotFix实现的热更方式成为我们的一个选项。处于个人的好奇心,阅读的HotFix的实现方式。即使用在所有的带有HotFix的标签的类或函数上使用mono提供CIL实现出来一套代码注入方式。即在类中每个函数都注入一个静态函数变量,在代码执行的时候,优先判断静态函数变量是否为空,来判断是否执行LUA脚本函数。从而实现使用LUA来热更新已经在外网的功能。
在研究代码注入的过程中,发现C#的代码注入有两套实现方案。一套是微软自身提供的API函数的方式,这套方式操作起来比较容易。另外一套是Mono实现的API函数,与Unity关系比较密切。两套方式在本质上都是一样的,都是对中间语言的修改与操纵。
文本主要介绍微软自身提供的API函数的方式
的使用。比较利于了解和学习中间语言。另外,如果读者对于C++反汇编语比较熟悉,阅读会非常轻松。如果不熟悉,也没有关系。基础准备知识准备
MSIL: 即微软中间语言, 是一种属于通用语言架构和.NET框架的低阶(lowest-level)的人类可读的编程语言。目标为.NET框架的语言被编译成CIL,然后汇编成字节码。CIL类似一个面向对象的组合语言,并且它是完全基于堆栈的. ,由于C#和通用语言架构的标准化,在.Net开发平台下,所有语言(C?、VB.NET、J?、Managed C++)都会被编译为MSIL,再由CLR负责运行,字节码现在已经官方地成为了CIL。【维基百科定义】工具准备ILSpy version:ILSpy是一个开源Net的浏览器和反编译器。下载地址:http://ilspy.net/使用指南:能把C#生成二进制文件转换为MSIL 或者C# 任选一种,当想用Emit实现某一功能但是不知道怎么写时,可以先把该功能的C#代码写出来,再用ildasm.exe将其转换成MSIL,然后转化为C#先查看是否能够显示出来。 环境准备本节主要介绍如何能够构成一个可以测试代码注入的环境。怎么实现动态库调用本节主要描述如何调用使用代码注入方式生成的动态库,来验证注入代码时候能够得到正常的运行,并且能够得到正确的结果。 静态函数调用?12Type t = typeof(MyType);myType.InvokeMember(&SwitchMe&,BindingFlags.InvokeMethod,null,null,newobject[] { 2 })注释:SwitchMe(int num)是一个静态函数,InvokeMember可以直接返回SwitchMe的返回值 构造函数调用生成:?1234Objectobj = t.InvokeMember(null,
BindingFlags.DeclaredOnly |
BindingFlags.Public | BindingFlags.NonPublic |
BindingFlags.Instance | BindingFlags.CreateInstance, null, null, args);其中args是构造函数的参数列表,如果没有参数,可以为空。成员函数调用生成:?1234Strings = (String)t.InvokeMember(&ToString&,
BindingFlags.DeclaredOnly |
BindingFlags.Public | BindingFlags.NonPublic |
BindingFlags.Instance | BindingFlags.InvokeMethod, null, obj, null);其中ToString是成员函数名称,ToString没有参数。Obj是成员的This指针。 动态创建动态库这个主要介绍,如何使用C#代码生成动态库,而不是直接建立动态库工程来生成动态库,为后面修改动态库做铺垫。下面介绍如何生成动态库:第一步:首先建立AssemblyName,AssemblyName assemblyName = new AssemblyName(&Study&);第二步:建立AssemblyBuilderAssemblyBuilder assemblyBuilder = AppDomain.CurrentDomain.DefineDynamicAssembly(assemblyName, AssemblyBuilderAccess.RunAndSave);第三步:建立ModuleBuilderModuleBuilder moduleBuilder = assemblyBuilder.DefineDynamicModule(&StudyModule&,&StudyOpCodes.dll&);第四步:保存动态库StudyOpCodes.dllassemblyBuilder.Save(&StudyOpCodes.dll&);指令集合变量赋值与读取指令注入读取指令介绍:Ldloc
将指定索引处的局部变量加载到计算堆栈上Ldfld
查找对象中其引用当前位于计算堆栈的字段的值。Ldsfld
将静态字段的值推送到计算堆栈上。Ldflda
查找对象中其引用当前位于计算堆栈的字段的地址。 Ldarg
将函数的参数(由指定索引值引用)加载到堆栈上。如: OpCodes.Ldarg, argIndexLdarg_0
将函数的参数的索引为 0 的参数加载到计算堆栈上。Ldarg_1
将函数的参数索引为 1 的参数加载到计算堆栈上。Ldarg_2
将函数的参数索引为 2 的参数加载到计算堆栈上。Ldarg_3
将函数的参数索引为 3 的参数加载到计算堆栈 Ldstr
推送对元数据中存储的字符串的新对象引用Ldnull
将空引用(O 类型)推送到计算堆栈上。Ldobj
将地址指向的值类型对象复制到计算堆栈的顶部。Ldc.I4
将所提供的 int32 类型的值作为 int32 推送到计算堆栈上Ldc.I8
将所提供的 int64 类型的值作为 int64 推送到计算堆栈上Ldc.R4
将所提供的 float32 类型的值作为 F (float) 类型推送到计算堆栈上。Ldc.R8
将所提供的 float64 类型的值作为 F (float) 类型推送到计算堆栈上 写指令:Stloc
从计算堆栈的顶部弹出当前值并将其存储到指定索引处的局部变量列表中。Starg
将位于计算堆栈顶部的值存储到位于指定索引的参数槽中Stfld
用新值替换在对象引用或指针的字段中存储的值 常常成员变量指赋值语句使用Stsfld
来自计算堆栈的值替换静态字段的值Starg
将位于计算堆栈顶部的值存储到位于指定索引的参数槽中。Starg.S
将位于计算堆栈顶部的值存储在参数槽中的指定索引处(短格式)。写指令介绍: 建立一个静态类变量FieldBuilder fieldName = typeBuilder.DefineField(&StaticName&, typeof(string), FieldAttributes.Private | FieldAttributes.Static);建立一个成员类变量FieldBuilder fieldName = typeBuilder.DefineField(&Name&, typeof(string), FieldAttributes.Private);成员变量:FieldBuilder fieldUser =
typeBuilder.DefineField(&objUser&, typeof(Object), FieldAttributes.Private );读:读取this.objUserOpCodes.Ldarg_0)OpCodes.Ldfld, fieldUser //fieldUser为成员变量写:创建一个新的对象,并赋给指定的变量fieldUser,即相当于this.objUser= new Object();OpCodes.Ldarg_0OpCodes.Newobj, typeof(Object).GetConstructor(new Type[0]) //赋新值OpCodes.Stfld, fieldUser 示例子:string localName = “guo”LocalBuilder localName = ilOfShow.DeclareLocal(typeof(string));OpCodes.Ldstr, &guo&);OpCodes.Stloc, localName);静态变量:对于静态变量FieldBuilder fieldName = typeBuilder.DefineField(&Name&, typeof(string), FieldAttributes.Private | FieldAttributes.Static);读:class::Name
OpCodes.Ldfld, fieldName//fieldName为成员变量写:class::Name = &new Name&OpCodes.Ldstr &new Name&OpCodes.Stfld, Name 函数调用指令注入普通函数调用函数调用分两步:第一步:设置参数第二步:需要调用需要的函数第三步:对于函数有返回值,但友不需要赋值的情况,需要把返回值推出栈 OpCodes.Pop生成代码:MethodInfo WriteMethodInfo = typeof(System.Console).GetMethod(&WriteLine&, new Type[]
{ typeof(string) });
ilOfShow.Emit(OpCodes.Ldstr, &Test&);
ilOfShow.Emit(OpCodes.Call, WriteMethodInfo);C#代码
System.Console.WriteLine(&Test&);不定参数函数调用不定参数的调用方式是:把所有的参数封装成最基础的object数组,然后对于所有的值类型和枚举类型需要采用Box的去封装一下 涉及指令:
Box 将值类转换为对象引用 如:OpCodes.Box, 类型参数,后再完成赋值操作。Unbox_Any
从中提取数据 obj, ,将其装箱表示形式 OpCodes.Unbox_Any, 类型参数 函数返回值可直接使用
其过程分三步:1.
一个对象引用 obj 推送到堆栈上。2.
对象引用是从堆栈中弹出和取消装箱到指令中指定的类型。3.
生成的对象引用或值类型推送到堆栈上。注:unbox 指令将转换的对象引用 (类型 O),则为指针值类型装箱值类型,表示形式 (托管的指针,类型 &),将其未装箱的形式。 提供的值类型 (valType) 是表示类型的装箱对象中包含的值类型的元数据标记。与不同 Box, ,所需的对象中制作一份使用的值类型 unbox 不需要从对象复制的值类型。 通常,它只计算已存在的已装箱对象内的值类型的地址。InvalidCastException 如果该对象未被装箱为,则引发 valType。NullReferenceException 如果对象引用为空引用将引发。TypeLoadException 如果值类型,则引发 valType 找不到。 在 Microsoft 中间语言 (MSIL) 指令转换为本机代码,而不是在运行时,通常是检测到此问题。数组读取与写入指令注入数组创建:
第一步:设置数组的大小 OpCodes.Ldc_I4, paramsCount
第二步:new 出数组 OpCodes.Newarr, assembly.MainModule.Import(typeof(object))这里主要介绍数组的赋值,
第一步:需要把变量读入到栈上 OpCodes.Ldfld, fieldArray
第二步:把索引加入到栈上OpCodes.Ldc_I4, 1
第三步:把需要赋值内容加载到栈上OpCodes.Ldstr, &aaa&
第四步:用计算堆栈中的值替换给定索引处的数组元素,其类型在指令中指定OpCodes.Stelem_Ref需要介绍的数组指令:写数组指令:
用计算堆栈中的值替换给定索引处的数组元素,其类型在指令中指定。
Stelem.I 用计算堆栈上的 native int 值替换给定索引处的数组元素。
用计算堆栈上的 int8 值替换给定索引处的数组元素。
用计算堆栈上的 int16 值替换给定索引处的数组元素。
用计算堆栈上的 int32 值替换给定索引处的数组元素。
用计算堆栈上的 int64 值替换给定索引处的数组元素。
用计算堆栈上的 float32 值替换给定索引处的数组元素。
用计算堆栈上的 float64 值替换给定索引处的数组元素。读数组的指令:
按照指令中指定的类型,将指定数组索引中的元素加载到计算堆栈的顶部。
将位于指定数组索引处的 native int 类型的元素作为 native int 加载到计算堆栈的顶部。
将位于指定数组索引处的 int8 类型的元素作为 int32 加载到计算堆栈的顶部。
将位于指定数组索引处的 int16 类型的元素作为 int32 加载到计算堆栈的顶部。
将位于指定数组索引处的 int32 类型的元素作为 int32 加载到计算堆栈的顶部。
将位于指定数组索引处的 int64 类型的元素作为 int64 加载到计算堆栈的顶部。
将位于指定数组索引处的 float32 类型的元素作为 F 类型(浮点型)加载到计算堆栈的顶部。
将位于指定数组索引处的 float64 类型的元素作为 F 类型(浮点型)加载到计算堆栈的顶部。
Ldelem.Ref
将位于指定数组索引处的包含对象引用的元素作为 O 类型(对象引用)加载到计算堆栈的顶部。 生成代码:FieldBuilder fieldArray = typeBuilder.DefineField(&objUser&, typeof(string[]), FieldAttributes.Private);MethodBuilder ArrayMethod = typeBuilder.DefineMethod(&InitArray&, MethodAttributes.Public, null, new Type[] { typeof(int), typeof(string)});{
//objUser = new string[3];
ILGenerator ilOfShow = ArrayMethod.GetILGenerator();
ilOfShow.Emit(OpCodes.Ldarg_0);
ilOfShow.Emit(OpCodes.Ldc_I4, 3);
ilOfShow.Emit(OpCodes.Newarr, typeof(string));
ilOfShow.Emit(OpCodes.Stfld, fieldArray);
//this.objUser[0] = &aaa&;
ilOfShow.Emit(OpCodes.Ldarg_0);
ilOfShow.Emit(OpCodes.Ldfld, fieldArray);
ilOfShow.Emit(OpCodes.Ldc_I4, 0);
ilOfShow.Emit(OpCodes.Ldstr, &aaa&);
ilOfShow.Emit(OpCodes.Stelem_Ref);
//this.objUser[1] = &bbb&;
ilOfShow.Emit(OpCodes.Ldarg_0);
ilOfShow.Emit(OpCodes.Ldfld, fieldArray);
ilOfShow.Emit(OpCodes.Ldc_I4, 1);
ilOfShow.Emit(OpCodes.Ldstr, &bbb&);
ilOfShow.Emit(OpCodes.Stelem_Ref);
MethodInfo WriteMethodInfo = typeof(System.Console).GetMethod(&WriteLine&, new Type[]
{ typeof(string) });
ilOfShow.Emit(OpCodes.Ldarg_0);
ilOfShow.Emit(OpCodes.Ldfld, fieldArray);
ilOfShow.Emit(OpCodes.Ldc_I4, 1);
ilOfShow.Emit(OpCodes.Ldelem_Ref);
ilOfShow.Emit(OpCodes.Call, WriteMethodInfo);
ilOfShow.Emit(OpCodes.Ret);}C#代码?publicvoidInitArray(int num, string text){
this.objUser = newstring[3];
this.objUser[0] = &aaa&;
this.objUser[1] = &bbb&;
Console.WriteLine(this.objUser[1]);} if语句指令注入函数的使用常常是使用指令 解释如下:Brfalse如果 value 为 false、空引用(Visual Basic 中的 Nothing)或零,则将控制转移到目标指令。Brfalse.S如果 value 为 false、空引用或零,则将控制转移到目标指令。Brtrue如果 value 为 true、非空或非零,则将控制转移到目标指令。Brtrue.S如果 value 为 true、非空或非零,则将控制转移到目标指令(短格式)。另外还需要设置一个跳转的地点 即Label,即在需要的地方跳转到标识,标识真正生效是在MarkLabel的时候。微软对MarkLabel的解释送 在MSIL中标识当前指令流的位置给给定的标识。 如下图示例:生成代码:MethodBuilder myifMeBuilder = typeBuilder.DefineMethod(&IfCall&, MethodAttributes.Public | MethodAttributes.Static, null, new Type[] { typeof(int) });{MethodInfo ifMethodInfo = typeof(System.Console).GetMethod(&WriteLine&,new Type[] { typeof(string) });
MethodInfo ifStringMethodInfo = typeof(string).GetMethod(&IsNullOrEmpty&, new Type[] { typeof(string) });
ILGenerator ilOfShow = myifMeBuilder.GetILGenerator();
Label defaultCase = ilOfShow.DefineLabel();
ilOfShow.Emit(OpCodes.Ldstr, &this&);
ilOfShow.Emit(OpCodes.Call, ifStringMethodInfo);
ilOfShow.Emit(OpCodes.Brfalse_S, defaultCase);
ilOfShow.Emit(OpCodes.Ldstr, &Test&);
ilOfShow.Emit(OpCodes.Call, ifMethodInfo);
ilOfShow.MarkLabel(defaultCase);
ilOfShow.Emit(OpCodes.Ret);} C#代码?1234567public static void IfCall(int num){
if (string.IsNullOrEmpty(&this&))
Console.WriteLine(&Test&);
}}SwichCase语句调通指令注入Switch实现跳转表 包含两个有效指令标识Br.S无条件地将控制转移到目标指令注:在Switch中,并没有条件转移,而是按照跳转列表的方式,和判断的参数,直接分成1 到 128从而实现一个短地址跳转指令集合。然后把跳转控制参数做为一个索引,从而实现一个类似数组作为地址的跳转方式。当索引大于索引的最大值的时候,就不跳转,直接执行Switch下一条IL指令。生成代码:MethodBuilder mySwitchMeBuilder = typeBuilder.DefineMethod(&SwitchMe&,MethodAttributes.Public |MethodAttributes.Static,typeof(string),new Type[] { typeof(int) });{
ILGenerator ilOfShow = mySwitchMeBuilder.GetILGenerator();
Label defaultCase = ilOfShow.DefineLabel();
Label endOfMethod = ilOfShow.DefineLabel();
Label[] jumpTable = new Label[] { ilOfShow.DefineLabel(),ilOfShow.DefineLabel()};
ilOfShow.Emit(OpCodes.Ldarg_0);
ilOfShow.Emit(OpCodes.Switch, jumpTable);
// Branch on default case
ilOfShow.Emit(OpCodes.Br_S, defaultCase);
// Case arg0 = 0
ilOfShow.MarkLabel(jumpTable[0]);
ilOfShow.Emit(OpCodes.Ldstr, &are no bananas&);
ilOfShow.Emit(OpCodes.Br_S, endOfMethod);
// Case arg0 = 1
ilOfShow.MarkLabel(jumpTable[1]);
ilOfShow.Emit(OpCodes.Ldstr, &is one banana&);
ilOfShow.Emit(OpCodes.Br_S, endOfMethod);
// Default case
ilOfShow.MarkLabel(defaultCase);
ilOfShow.Emit(OpCodes.Ldstr, &are many bananas&);
ilOfShow.MarkLabel(endOfMethod);
ilOfShow.Emit(OpCodes.Ret);}C#代码:?publicstaticstringSwitchMe(int num){
string arg_23_0;
switch (num) {
arg_23_0 = &are no bananas&;
arg_23_0 = &is one banana&;
arg_23_0 = &are many bananas&;
return arg_23_0;}循环指令注入在IL中,循环指令是依靠if跳转指令实现,这里就不在赘述。类型处理指令注入建立一个新类名字为StudyOpCodes:TypeBuilder typeBuilder = moduleBuilder.DefineType(&StudyOpCodes&, TypeAttributes.Public);C#代码:publicclass StudyOpCodes{}建立一个构造函数ConstructorBuilder ctorMethod = typeBuilder.DefineConstructor(MethodAttributes.Public, CallingConventions.Standard, new Type[] { });{
ILGenerator ilOfShow = ctorMethod.GetILGenerator();
ilOfShow.Emit(OpCodes.Ldarg_0);//调用object的构造函数
ilOfShow.Emit(OpCodes.Call, typeof(object).GetConstructor(new Type[] { }));
ilOfShow.Emit(OpCodes.Nop);
ilOfShow.Emit(OpCodes.Ret);}C#代码:上面的生成的代码与默认的构造函数相同,所以不再列出成员变量,并new 一个新对象,并赋值在成员函数中,使用成员变量分两步第一步:由于成员函数默认的第一个参数是this,而实际在访问成员变量的时候,需要通过this去索引,所以需要OpCodes.Ldarg_0第二步:直接可以去取货设置成员变量。FieldBuilder fieldUser = typeBuilder.DefineField(&objUser&, typeof(Object), FieldAttributes.Private );MethodBuilder GetnewMethod = typeBuilder.DefineMethod(&CreaterNewObejcts&, MethodAttributes.Public, typeof(object), new Type[] { });{
ILGenerator ilOfShow = GetnewMethod.GetILGenerator();
ilOfShow.Emit(OpCodes.Ldarg_0);
ilOfShow.Emit(OpCodes.Newobj, typeof(Object).GetConstructor(new Type[0]));
ilOfShow.Emit(OpCodes.Stfld, fieldUser);ilOfShow.Emit(OpCodes.Ldarg_0);
ilOfShow.Emit(OpCodes.Ldfld, fieldUser);
ilOfShow.Emit(OpCodes.Ret);}C#代码?1234publicobjectCreaterNewObejcts(){
his.objUser = newobject();
returnthis.objU}建立Get与Set函数FieldBuilder fieldAge = typeBuilder.DefineField(&objectAge&, typeof(object), FieldAttributes.Private);MethodBuilder SetMethod = typeBuilder.DefineMethod(&SetObjectAge&, MethodAttributes.Public, null, new Type[] { typeof(float)});{//this.objectAge = (int)
ILGenerator ilOfShow = SetMethod.GetILGenerator();
ilOfShow.Emit(OpCodes.Ldarg_0);
ilOfShow.Emit(OpCodes.Ldarg_1);
ilOfShow.Emit(OpCodes.Box, typeof(int)); //俗称装箱 或者类型转化
ilOfShow.Emit(OpCodes.Stfld, fieldAge);
ilOfShow.Emit(OpCodes.Ret);}MethodBuilder GetMethod = typeBuilder.DefineMethod(&GetObjectAge&, MethodAttributes.Public, typeof(object), new Type[] { });{
//return this.objectA
ILGenerator ilOfShow = GetMethod.GetILGenerator();
ilOfShow.Emit(OpCodes.Ldarg_0);
ilOfShow.Emit(OpCodes.Ldfld, fieldAge);
ilOfShow.Emit(OpCodes.Ret);}C#代码?publicvoidSetObjectAge(float num){
this.objectAge = (int)} publicobjectGetObjectAge(){
returnthis.objectA}总结
本文通过C#使用IL语言注入的介绍,能够让大家能白代码注入的实现原理。同时清楚了C#与IL语言的转化关系,以便更够更高效的使用C#语言,就像了解C++的反汇编能够帮助大家对C++语言更了解一样。进一步能够使用代码注入的方式来实现自己需要的一些功能。
再提一点,在C与C++时代,黑客高手能够通过修改汇编指令,给一些应用增加一些新的功能,而不用了解其原理。那么在C#中也可实现这样的功能。比如自己写一个DLL,然后把这个DLL中指令复制到中间语言Dll或EXE中,然后修改IL指令,改变调用方式,来给目标DLL增加新功能。
作者:西川善司
翻译:Traceyang
史克威尔艾尼克斯(SQUARE ENIX 后简称SE)的次世代游戏引擎【Luminous Studio】,成为话题的,也许是先进的图形表现的印象,实际上AI部分,也是采用领先世代的技术来实现的。
SE在CEDEC2015上的会议【FINAL FANTASY XV
EPISODEDUSCAE 角色AI的意识决策系统】。这些以前都没有正式介绍过,Luminous Studio的AI系统的设计思想和结构也很有意思。本稿,是对这个会议概要的报告 游戏中的AI是什么三宅陽一郎(技术推进部,首席AI研究员,SQUARE ENIX)
会议最初登场的,是SE技术推进部的首席AI研究员,三宅陽一郎氏。三宅先生可以说是日本游戏AI界的第一人,在CEDEC 2015上除了本次的会议外,还负责着其他AI相关的会议。
三宅首先是以【游戏的人工智能是什么】为基本开始说明的。
三宅对它的定义是
【所谓的智能,是根据环境来协调自身运动的功能】,换句话说就是【理解环境,选择行动】是智能的基本行动。三宅认为智能是使自己协调环境来运动的功能
智能是经过【认识-&决策-&运动】的流程,给予世界影响(反应),对输入单纯的反应是微生物一样的智能,并看不出有多优秀。这里的认识,角色,行动的各个阶段,把这个这些结果进行记忆存储,就是【学习】,作为今后决策的参数数据,为了解释可以参考的机制也必须实现。
简而言之,就是把过去存储学习过去的经验来行动,变成更加高级的智能的行动。 关于人工智能的理想基本构造实例
那么,【游戏中的AI】,就是把这个【决策】的部分标准的简单模型来设计。这里决策制定的简单模型使用了【基于行为(Behavior)的AI】和【基于状态(State)的AI】两种。选择来构筑AI。 AI的决策模型,有几个标准的简易模型。Luminous Studio中,选择了基于行为的AI和基于状体的AI
基于行为AI的构筑方法有【行为树(Behavior Tree)】的概念。行为树,是擅长进行详细行动的适应性的决策模型。另一方面,基于状态的AI构筑方法有【状态机(State Machine)】的概念,状态机适应坚定的决策的AI构造。
Luminous Studio中,为了导入这两种方法的优点,决定开发混合型的AI系统。
游戏行为树的事例 游戏状态机的事例 状态机是阶层构造Luminous Studio的AI引擎【Luminous AI】的概要
作为游戏引擎Luminous Studio的一部分而实现的AI系统,是为了让程序员以外的非引擎的职员- 策划和美术师也可以容易使用来制作角色的智能和开发而制作的工具。
这样,首先是游戏AI必要的概念列表,把它进行分类,以哪些是必须要实现的要素来开始检讨设计方针。 必要概念的列表,分为11个种类
那么,近代游戏的AI,主要分为【Meta AI】,【CharacterAI】和【Navigation AI】3大要素。因此,大致的设计方针也遵从这个来进行。
Meta AI是,掌管游戏世界进展的全局AI,来控制具体的故事和事件进行的东西。Character AI,正如其名,是控制角色行动的AI,游戏爱好者们听到【游戏的AI】时的印象大多是这个类型。最后的Navigation AI,是让角色的移动时,选择合适的移动路径的来使用的。 游戏AI的三大要素
这次的AI开发项目中,把Luminous Studio的AI引擎整体称作【Luminous AI】,其中,角色(Character) AI称作【LumionusAI Graph】,导航AI称作【Luminous AINavigation】。 LuminousAI开发项目中,角色 AI【Lumionus AI Graph】,导航AI【Luminous AI Navigation】。
Lumionus AI Graph的定位是【开发角色的头脑的工具】,开发了把节点和节点链接制作流程图的工具。并不是用文本来编写代码或脚本,而是用图表工具来设计的方法,这样不是软件工程师也可以使用,以方便易懂为目标,这个也是最近游戏开发工具的标准。
另外,Luminous AI Navigation,是用Luminous AI Graph设计角色AI时,需要路径搜索功能时所呼出的结构。 分类的AI要素的大部分,都分配在了Lumionus AI Graph和Luminous AI Navigation里。现在并没有全部实现,有颜色的项目是目前已经实现的。 另外,Meta AI,因为是更大一些的概念,有每个游戏自己开发的,也有使用Luminous AI的工具来制作的。 例如,Luminous AI Graph,需要对应在Meta AI的内部构造化的功能也有用到。 Luminous AI Graph的基本功能
在三宅的基本架构的说明后,SE第二商务部的白神陽嗣和並木幸介登场,进行Luminous AI实现的工具和功能的介绍。 白神陽嗣氏(第二商务部,程序,SQUARE ENIX) 並木幸介氏(第二商务部,AI程序,SQUARE ENIX) 关于LuminousAI设计的4个课题
白神,並木两个人的解说,分为了4个主题,全部都是具体的Luminous AI的功能和考虑实际的AI设计的使用者更方便使用的。这里顺序的做说明。
第1个的主题是,行为树和状态机这两种不同构造的AI,如何进行组合实现的。
循环构造的状态机和树结构的行为树。把这两者整合好是很难的问题,开发组使用了大胆的实现方法,很好的整合了它们。具体的实现上,两个结构的共通要素的部分可以一起使用,独立要素做成图形构造,用相互阶层的构造来整合。
再稍微做些详细的说明,首先,AI的构成要素,也就是把描述逻辑的各个节点(功能集),作为零件,让状态机和行为树双方共有。 状态机和行为树的各单位节点,相同逻辑的部件必须要能共享。
然后,这里独特的是,把状态机和行为树构造不同的部分,分别的组合来实现。通过这个,构成循环结构状态机的节点的一部分,可以被行为树使用,相反的行为树的节点的一部分组合,也可以在状态机的循环构造中使用。 使状态机和行为树可以组合。通过这个,可以描述复杂的阶层图形构造。 LuminousAI Graph的画面。最上层的状态机中,包含的一部分节点是行为树。行为树包含的一部分节点是状态机。
第2个主题是,方便的进行游戏AI的功能扩展的方法。这里,【节点的图表构造】的阶层化构造有很大的贡献。
白神用下面的幻灯片为示例来说明。幻灯片的左图,是设计中的AI。链接线(Link)很多,非常复杂的状态机构造。左图的右侧,是AI攻击行动的纵向列举。
因此,追加一个攻击行动,只增加一个【新的攻击动作节点】是不行的。因为就像幻灯片右侧那样,进行攻击决策的每个节点,都要用链接线(右侧的红线)与这个新增加的节点连接,否则追加的节点就无法启动。决策节点很多的时候,要连的链接线也会增加,变成了非常冗长的工作。 左边的状态机AI,增加新的行动节点的时候,右边必须连接大量的链接线。
这样,把右侧的攻击节点阶层化,这样的话,每增加一个新的攻击行动,只需要在这个攻击节点的阶层里追加一个节点,攻击节点的阶层里增加1个链接线就可以了。而且,上层的状态机的链接线也很流畅,逻辑整体也更容易理解,可以说是一举两得。 通过节点的阶层化,可以比较容易的增加节点,增加节点,上层链接构造也没有变化。 第3个主题是,是游戏的流程(游戏进行方面)和AI的统一控制的方法。这里导入称作【黑板】(Blackboard)的概念来对应。使用黑板,可以自由的增加去除全局变量。
在Luminous AI Graph中,准备AI图表中使用的共享变量空间【Local Blackboardl】,和游戏进行相关的变量使用的【GlobalBlackboard】。 实现了AI方面可以获取游戏进行相关信息的结构的【黑板】功能 从黑板获取的信息,在节点内处理后返回值的函数那样来使用也是可以的
第4个主题是,制作相似构造的AI时,不需要反复的拷贝黏贴制作的节省手工作业的方法。具体的,准备了两种功能。
1个功能是,把通用性高的AI部件登陆为一个【Asset】。资源化的AI部件,可以在项目的AI制作者之间共享使用的同时,也可以给资源增加修改和优化,使用这个资源制作的所有AI,都可以更新到这个资源的最新状态。
编辑资源的动画,可以参考下。 LuminousAI中使用【Asset】功能让AI部件复用 左边制作的AI部件的【Asset】,和右边是一样的【Asset】。右边的Asset修改AI部件后,使用同样AI部件的左边Asset也同样反应了修改。
还有一个功能是,是根据【构造比较近似的AI,可以变更一部分节点】的需求而实现加入的功能。这个也叫做【Override】,只修改AI部件的一部分就变成了新的AI部件的功能。通过这个,AI部件增加变化,这个变更也会反映到用Override功能制作的AI部件上。通过这个,相似的AI的共通部分,就不需要再一个一个的修正了。 上面的幻灯片,是原来的状态机型的战斗AI。只变更这个AI的战斗,制作其他角色的AI时,把原来的AI的Override,AI的构造只变更这部分就可以了。
以上就是,Luminous AI Graph高实用性的方法,可以看出被大量的加入了。 加快FFXV开发而实现的Luminous AI的功能
接下来的白神氏,对Luminous AI在实际游戏组成过程中要求的3个功能组做了说明。这个基于Luminous Studio开发的游戏,当然那就是最终幻想XV(PS4 / Xbox One,以下简称 FFXV)。白神氏列举的3个功能,就是在FFXV开发工程中LuminousAI搭载的功能。
第1个功能,是制作的AI的调试功能(Debug)。
调试功能有2种,1个是通过Luminous AIGraph的工具画面上从AI图表,来确认实行的AI在那部分运行的【可视化节点调试】(Visual Node Debug)。还有一个是,就可以确认各种参数和运行日志的【游戏内调试窗口】(InGame Debug Window)。
两种AI调试,另外,绿色线框显示的角色是调试对象。 玩法(Gameplay)和事件之间,需要可以顺畅的推进AI角色行动的方法,导入了【Tary的插入实行】。
第2个功能,是指在GamePlay中行动的AI角色,可以流畅(无缝)的迁移到游戏事件的功能。这个是在实行的AI中插入执行内容,导入【Tray的插入实行】的结构来实现。 所谓的Tray,应该就是Luminous AI Graph的工作画面。
具体的流程是,分别准备通常AI和事件表演用的AI,在通常AI执行中需要产生事件时,把它插入来执行。插入产生时,把通常AI和事件表演用的AI进行替换,这个事件表演的AI结束后,再恢复成普通AI。通过这个,实现了【Gameplay→Event→Gameplay】的流畅迁移。
导入了插入执行的结构,利用黑板可以在游戏系统方面交换参数。
还有,使用前述的黑板(Blackboard)功能,根据状况设置黑板的参数,可以让事件表演的AI持有变化和环境适应能力
第3个功能是,为实现更聪明的AI,可以同时复数思考的功能。这个在状态机和行为树里,都实现了可以并列思考的【Parallel Start】(后面简称P-Start )节点。
Tray中设定复数的的P-Start节点,状态机和行为树中,都可以并列执行。 上面的幻灯片是状态机中,下面的幻灯片是行为树中指定P-Start节点的样子。
独特的一点是,设定P-Start到AI节点阶层,那么就会改变并列思考的等级。例如,把最上层设置P-Start,制作一直并列思考的AI。另外,把下面阶层设定为P-Start,就会移动到这个阶层下面执行,变得可以并行思考。
具体的举例,预先把最上层的【索敌】和【步行】用P-Start来设定,这样角色一边移动一边索敌。这里在索敌的下层,用P-Start设定增加有多个目标要攻击哪个【攻击对象选择】,和对选择的目标选择合适的攻击手段的【攻击方法选择】。那么,当索敌范围内进入多个目标,切入到战斗状态时,同时执行目标选择和攻击手段选择。 使用并行执行概念的阶层化,制作出可以一直并列思考的同时,也可以只在任意的状况下可以并行思考的AI。 FFXV中如何使用Luminous AI
继续对Luminous AI的功能做说明的並木氏,一边展示开发过程做在FFXV和Luminous AI Graph,一边对FFXV中实际的AI制作示例做说明。遗憾的是,这里的映像无法发布,这里使用公开的照片,总结一下概要。
译注:当时西川发文时,ppt和视频还没有公开,更多细节请看我翻译的全篇的PPT。
那么,在FFXV中,街区的居民NPC,进行大致的行动决定的是,是用编写的社会行动的脚本系统,和设定社会行动细节的基于Luminous AI Graph的AI组合来实现的。
NPC的AI控制
还有,角色的动画控制,并不是有行为树的决策AI直接播放动画,而是通过状态机那样的身体控制AI来控制。通过决策部分和身体控制的分离结构的实现,NPC可以辨认周围的环境,更加适应环境的自然的来运动。
以后的游戏表现上变的重要的【AI和动画的相互连携】,也导入到了Luminous AI里。 FFXV中的角色,被草木阻碍而推开,或者是接近岩石时手的附着,都是AI控制动作的。识别周围的环境,协作来动画。 视觉感应和目标检索功能。游戏世界的角色,敌我双方都用同等的条件来行动。
然后,Luminous AI在准备了【视觉感应】功能,可以对游戏世界实际看到的各个角色做识别,再根据视觉信息行动来设计AI。
选择攻击对象的【目标搜索】功能,使用视觉感应的子系统中,计算出看到的对象里的攻击优先顺序。 上面的画像是战斗中的画面,下面是AI的地形解析情况的调试显示。右下的金发的角色&Prompt&的AI,解析地形环境来检索对Behemoth(左上的怪物)的最佳射击位置的状况。绿红蓝的Heat Map,红色是危险的场所,绿色是候补点,右边看到的一个蓝色的,是AI计算出的最佳位置。因此Prompt向蓝色的地点突进。 怪物AI的制作中使用的Luminous AI Navigation功能。
另外,在怪物的动作控制上,积极使用了Lumionus AI Navigation的功能。特别是Lumionus AINavigation中实现的【Point Query System】(简称PQS)的位置检索系统和【Steering】系统,为怪物AI的构筑做了很大的贡献。
PQS,是用数据控制移动目标候补的结构,预先作成每个怪物的PQS数据,这样就制作了这个怪物固有的基本移动模式。所谓的PQS数据,是角色看到的移动目标的候补地点的数据群,通过这个可以,角色可以设定在目标的周围活动。通过这个技术,程序员分别制作怪物的步行/移动模式,可以加入到PQS数据中来制作。 通过PQS,给予移动目标候补数据来制作怪物移动的变化。
而引导(Steering),则是一边根据视线信息做回避行动一边移动的系统。给予群体行动的多个NPC同样的目的地的话,NPC之间容易产生冲突,这里利用Steering,可以回避障碍物的到达给定的目的地。
以上就是这次会议的报道,主要内容是宣传【SQUARE ENXI构筑的先进AI制作环境】,反过来说,也是想提示【现在游戏中的AI,所需要制作的】新标准。
回顾PS3和Xbox360时代的游戏,虽然图形品质有所提高,但动画和AI的新购的那个品质,不相称的游戏还是很多的。SE的Luminous Studio算是打破了这面墙吧。
另外,这个会议对今后想构筑新的AI系统的游戏开发者,可以说能得到很多的暗示。特别是状态机和行为树的相互阶层构造的结构,只要是听到,就会认识到是应用范围非常光,适用性很优秀的设计。各种感知系统和导航AI的组合的系统也有很大的发展余地。
Luminous AI现在还没完成,今后也会继续进化,在2016年发售的FFXV中,会遇到比现在发布的体验版【FINAL FANTASY XV -EPISODE DUSCAE-】中更聪明的AI角色们。现在开始体验乐趣吧。
Unity MMO游戏架构设计(一):角色设计接上篇已经介绍的两个类,这两个类并没有具体对象的行为表示,给人的感觉就是一个抽象的类,接下来它们的子类的编写,也就是具体的类了。在这里介绍一下设计思路,游戏中的角色和怪物也有共同的属性,比如Buff、Debuff、伤害、移动等等,将这些共同的属性可以放在我们已经规划好的BaseCharacter类中,该类主要实现的就是英雄和怪物的基础属性和方法,代码如下所示:[csharp] view plain copy public class BaseCharacter:BaseActor
public GameObject m_MP
/// 攻击特效最长持续时间
protected float m_AttackedEffTimeM
protected bool m_bCameraF
/// 标识碰撞后是否移动
protected bool m_bCollisionM
protected bool m_bM
protected int m_BulletUidC
protected CapsuleCollider m_CapsuleC
/// 移动朝向
protected Vector3 m_MoveD
/// 移动至向量位置
protected Vector3 m_MoveToP
/// 屏幕向量
protected Vector3 m_ScreenP
/// 打昏特效
protected GameObject m_StunE
protected float m_StunTimeM
/// 队伍Id
protected int m_TeamId;
protected float m_TurnTimeM
/// 角色状态
protected CharStat m_Stat = new CharStat();
/// Buff 列表
protected List m_Buffs = new List();
/// DeBuff 列表
protected List m_Debuffs = new List();
public override void Init(int uid)
base.Init(uid);
this.m_bMute =
this.m_FBX.SendMessage(&SetParent&, gameObject);
this.m_CapsuleCol = this.gameObject.GetComponent();
public override void Enable(Vector3 pos)
base.Enable(pos);
this.m_MoveDirection = Vector3.
this.m_BulletUidCount = 0;
this.m_bCameraFrustum =
this.m_AttackedEffTimeMax = 0f;
this.m_StunTimeMax = 0f;
this.m_bCollisionMove =
this.m_Buffs.Clear();
this.m_Debuffs.Clear();
protected override void UpdateObj(float delta)
base.UpdateObj(delta);
this.UpdateBuffs(delta);
this.UpdateDebuffs(delta);
this.CheckInCameraFrustum();
//if(this.GetCurState() != 13 && this.m_bCollisionMove &&
Application.loadedLevelName != &03_LobbyScene& &&
gameObject.GetComponent().velocity.magnitude
x.GetBuffType() == type);
if (buff != null)
return buff.GetValue();
return 0f;
/// 获取 DeBuff 值
protected float GetDeBuffValue(EnumDefine.DeBuffT type)
BaseDeBuff debuff = this.m_Debuffs.Find(x =& x.GetDebuffType() == type);
if (debuff != null)
return debuff.GetValue();
return 0f;
/// 检查是否在摄像机视锥体范围内
/// (视锥体(frustum),是指场景中摄像机的可见的一个锥体范围。它有上、下、左、右、近、远,共6个面组成。在视锥体内的景物可见,反之则不可见。为提高性能,只对其中与视锥体有交集的对象进行绘制。)
protected void CheckInCameraFrustum()
this.m_ScreenPos = Camera.main.WorldToScreenPoint(gameObject.transform.localPosition);
if (this.m_ScreenPos.z
Screen.width) || (this.m_ScreenPos.x = EnumDefine.GrowthT.Min) && (growthT = EnumDefine.BaseStatT.MIN) && (baseStateType = EnumDefine.OptT.Min && optionType
outDmgAttT = EnumDefine.DmgAttT.PS;
outValue = statusO
statusOption = this.GetStatusOption(EnumDefine.OptT.IDR);
if (statusOption & 0f)
outDmgAttT = EnumDefine.DmgAttT.IC;
outValue = statusO
statusOption = this.GetStatusOption(EnumDefine.OptT.FDR);
if (statusOption & 0f)
outDmgAttT = EnumDefine.DmgAttT.FR;
outValue = statusO
statusOption = this.GetStatusOption(EnumDefine.OptT.TDR);
if (statusOption & 0f)
outDmgAttT = EnumDefine.DmgAttT.TD;
outValue = statusO
outDmgAttT = EnumDefine.DmgAttT.M
outValue = 0f;
public virtual EnumDefine.AttackedEffT ResearchAttEffType(EnumDefine.AttackedEffT srcType)
return srcT
protected bool HasDebuff(EnumDefine.DeBuffT type)
return (this.m_Debuffs.Find(x =& x.GetDebuffType() == type) != null);
public bool IsDead()
return m_CurState == 13;
/// 播放受击效果
public void PlayAttackedEff(Vector3 attackerPos, int effId)
Vector3 vector = gameObject.transform.position - attackerP
vector.Normalize();
Vector3 pos = gameObject.transform.position - this.m_CapsuleCol.radius *
pos += this.m_CapsuleCol.
FxManager.PlayEff(effId, pos, 0f);
ShineEff(0.15f);
public virtual void PlayAttackedEff(Vector3 pos, int effId, EnumDefine.EffT efft = EnumDefine.EffT.HeroEffect) {
if (m_MPoint != null) {
GameObject effObj = FxManager.PlayEff(effId, GetDirection(), m_MPoint.transform.position, 0.5f, efft);
effObj.transform.parent = m_MPoint.
effObj.transform.localPosition = Vector3.
effObj.transform.localEulerAngles = Vector3.
//DebugLog.Log(&PlayAttackedEff:& + effId);
/// 一般用在Debuff状态下
public virtual void SelfDamage(float damage, float resultHp, int effId, int numEffId)
this.PlayAttackedEff(gameObject.transform.position, effId, EnumDefine.EffT.PublicEffect);
public enum eState
CriticalStiff,
//至命最后一击
StunSlide,
在主要的函数中都有代码注释,读者可以参考,给出类的实现,是给读者设计时做一个参考使用的。
对于HIT图形的知识很多人都是一知半解的,下面和大家介绍的是HIT图形的分析,其中包括HIT影子和辉光,用上了Bloom做法,效果还不错。影子这里讲真,一开始我把HIT的影子当成利用Projector实现动态阴影一样的做法,主要就是受到了这个图的“误导”。抓了下帧发现,一开始是只有角色绘制到一张depth texture在绘制影子的时候,只有部分场景能接受影子:我感觉是只有地面部分,像周围栏杆什么都是不受到动态阴影影响的。在绘制地面的时候有个传到shader的参数设为1,会读取shadowmap计算;否则是0,只要计算本身受到实时光和lightmap影响即可。UE4实现我找了下官方文档,估计HIT是直接使用了Use Modulated Shadows。这个应该是最原始的shadowmap的做法,根据Lighting for Mobile Platforms描述:Dynamic Shadowing是根据摄像机距离远近的多层CSM,而且能够和烘焙的静态阴影混合的很好Modulated Shadowing感觉就是简单粗暴的shadowmap计算之后叠加一个颜色上去…HIT游戏里能很明显的发现玩家阴影和静态场景阴影格格不入… 我看了下shader和对应usf文件,应该就是在计算完模型受到光照的影响之后,直接从shadowmap里做了四次texture2dLOD平均之后混合一下了事儿。Unity实现正好趁这个机会讨论了下对应Unity实现,然后发现我之前一直有两个知识点搞错了Unity中Shadow Caster是单独一个Pass,但是Shadow Receive是写在主Pass里的(我之前一直以为这也是单独的一个Pass)tex2DProj最后一个参数直接提交了额外的深度比较:Coordinates to perform the lookup. The value used in the projection should be passed as the last component of the coordinate vector. The value used in the shadow comparison, if present, should be passed as the next-to-last component of the coordinate vector.在搞清楚这俩的基础上,对照乐乐的【常见问题】对9.4节Unity的阴影的补充说明(重要)能比较轻松的看懂AutoLight.cginc里的代码:?53637#if defined(UNITY_NO_SCREENSPACE_SHADOWS) UNITY_DECLARE_SHADOWMAP(_ShadowMapTexture);#define TRANSFER_SHADOW(a) a._ShadowCoord = mul( unity_World2Shadow[0], mul( _Object2World, v.vertex ) ); inline fixed unitySampleShadow (unityShadowCoord4 shadowCoord){
#if defined(SHADOWS_NATIVE)
fixed shadow = UNITY_SAMPLE_SHADOW(_ShadowMapTexture, shadowCoord.xyz);
shadow = _LightShadowData.r + shadow * (1-_LightShadowData.r);
unityShadowCoord dist = SAMPLE_DEPTH_TEXTURE_PROJ(_ShadowMapTexture, shadowCoord);
// tegra is confused if we use _LightShadowData.x directly
// with &ambiguous overloaded function reference max(mediump float, float)&
half lightShadowDataX = _LightShadowData.x;
return max(dist & (shadowCoord.z/shadowCoord.w), lightShadowDataX);
#endif} #else // UNITY_NO_SCREENSPACE_SHADOWS sampler2D _ShadowMapT#define TRANSFER_SHADOW(a) a._ShadowCoord = ComputeScreenPos(a.pos); inline fixed unitySampleShadow (unityShadowCoord4 shadowCoord){
fixed shadow = tex2Dproj( _ShadowMapTexture, UNITY_PROJ_COORD(shadowCoord) ).r;} #endif这里做几个补充说明:UNITY_NO_SCREENSPACE_SHADOWS表示不使用屏幕空间阴影,所以移动平台看上半段就行SHADOWS_NATIVE宏表示硬件是否有原生shadow map支持,如果是ES2这种的话就走上半段的下半截,也就是取出深度信息之后自己比较 否则直接用UNITY_SAMPLE_SHADOW取就行了ps. PCF模糊是在shadow caster的时候做的,而不是receive阶段,参考Internal-PrePassCollectShadows.shader里的代码。pps. 这么看来Unity原生shadowmap的做法其实硬件特性利用的会比Projector好一些,咳咳,等他什么时候能让我更方便的控制模糊程度和范围我立刻弃暗投明转变立场,嗯嗯辉光这个是我比较感兴趣的部分,因为UE4的后处理一条处理下来非常流畅,见Teaser部分对比。我之前抄了下它的Bloom还~网上能找到一份资料Next-gen Mobile Rendering里Mobile Post Processing Pipeline介绍了整体的流程,主要看Bloom Filter Tree部分。我对照的看了下,它首先做了一次clamp+downsample(原图分辨率)到1/4大小,然后连着四次downsample分别是1/8,1/16,1/32,1/64;然后开始upsample,每次利用两个RT的内容混合回来。shader部分大概看了下确实是圆形的采样,和PPT里所说的正好对应:Standard hierarchical algorithm with some optimizations – Down-sample from 1:1/4 res first (shared with light shaft) – Then down-sample in 1:1/2 resolution passes – Single pass circle based filter (instead of 2 pass Gaussian)15 taps on circle during down-sampling7 taps for both circles during up-sample+merge pass顺便它原始的RenderTexture是RGBA16F格式,渲染的时候就考虑了HDR。usf里面的注释里是提到了64bpp HDR、32bpp HDR using Mosaic encoding和32bpp HDR using RGBA encoding三种,其中mosaic我一直没找到具体资料,希望懂的朋友不吝赐教。这样Bloom的好处在于不需要像Gaussian Blur或者SGX Blur需要每次横竖两个方向,而是每次都可以1/2分辨率往下降。正如PPT里所说的“Limited effect radius and less passes”。
Unity开发的游戏要做好性能优化,这样才能尽可能的少占用内存。本文给大家介绍的是Unity Sprite优化,主要是通过减少Setpass Calls(Draw calls)数来减少性能消耗,以这样的方式达到优化的目的。在网上找到几张图片,将其拖到Hierachry面板此时运行游戏,观察Game试图下的Stats窗口,此时Setpass calls数为4(包括场景的Setpass calls)我们的方法是将图片打包成一张图片,在游戏运行时,unity只会读取一遍sprite,减少了资源浪费,达到优化效果,步骤如下:选中需要打包的几张图片,选择为Sprite类型打开Windows-&Sprite Packer此时贴图都打包到了一张图片上,接下来运行游戏,会发现Setpass calls变为了2通过减少Setpass call达到了优化效果
腾讯光子工作室群前台主程&
微信扫码关注GAD官方公众号
超维星球孵化器
开源引擎LayaAir
WeTest腾讯质量开放平台

我要回帖

更多关于 腾讯地图市场份额 的文章

 

随机推荐