Unity开发之UI与Lua知识汇总!

Posted 游戏蛮牛

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Unity开发之UI与Lua知识汇总!相关的知识,希望对你有一定的参考价值。

A、UI相关知识


一、TextMeshPro

1、无论是文字放大缩小,都能够保持锐利清晰。美术效果提升明显。

2、阴影、描边等效果在shader实现,更加高效。UGUI的Text通过多画几遍文字的方式实现描边,文字一多,顶点可能就爆了。

3、几乎没有GCAlloc。该做的缓存都有做。大量结构都是struct。

4、文字生成速度很快。几乎不用担心界面打开卡顿的问题。也不用针对大量显示的数字做BMFont来优化性能。

5、缺点是2017的版本不支持运行时生成文字。所以我们消耗了两张2048+1张1024的图集来存放gb2312的文字。消耗内存10mb+。最新的版本已经支持运行时生成文字了。

6、TextMeshPro的使用会带来非常明显的文字渲染效果和效率的提升。新的项目一定要使用。


二、UI上显示3D模型

1、RenderTexture。

  处理特效问题,shader中不能有ColorMask,即不要使用Particle/Additive的shader。否则特效在角色模型之外的空场景会消失掉。

  注意混合因子应该是 Blend One OneMinusSrcAlpha。

  因为特效渲染的时候已经进行过alphablend。如果RT渲染的时候再进行alphaBlend会导致特效颜色变浅。

  另外就是在RT上渲染的特效,不再支持黑色背景的Additive的特效纹理。因为黑色背景是不透明的,直接渲染在场景上的时候,因为颜色值都是0,不会有显示问题。但是渲染到RT上的时候就会是黑色底了。

  最后,因为RT渲染的源因子是One,如果需要RectMask2D裁剪,就不能直接用设置alpha的方式来实现了,必须用clip。

2、直接挂3D模型,设置好sortingOrder。

  缺点是,需要处理多分辨率适配。可能某些分辨率下模型会显得不匹配。

3、UI上模型渲染效果做的处理

3.1、使用独立的UIModelLight和UIModelCamera。摄像机是透视投影。

3.2、模型渲染使用自定义的环境光(_CustomAmbientLightColor)。默认情况下UI中显示的模型受场景环境光(Lighting中设置的,不是场景中的方向光)影响。在阴暗的场景模型也会显得比较暗。使用统一的环境光颜色,可以在不同的场景中打开UI都显示一致的效果表现。

3.3、类似上面的环境光。模型的反射也是受场景的ReflectionProbe影响的,它会影响角色反射环境的表现。比如有的场景天空球是绿色的,那么角色身上的反射也就是偏绿的,越是金属或者光滑的地方,显示越明显。解决方法是,在角色模型的位置,放上一个ReflectionProbe的预制,这样角色的反射效果就只跟预制指定的cube相关了。


三、UI上显示特效

1、直接用ParticleSystem。设置好sortingOrder。最灵活自由。缺点是容易跟UI的层级产生冲突。

2、使用 SDParticleSystem。这个脚本模拟粒子发射 Image 控件。缺点是性能不好。优点是因为都是UGUI的控件,所以遮挡、裁剪等问题都不用关心。

3、使用 UIParticles。每个ParticleSystem挂一个这个脚本。它会把粒子系统的顶点和纹理,用MaskableGraphic渲染出来。同样不用担心遮挡、裁剪等问题。


4、UI上ParticleSystem对RectMask2D的支持。

4.1、#pragma multi_compile __ UNITY_UI_CLIP_RECT

4.2、col.a *= UnityGet2DClipping(i.worldPosition.xy, _ClipRect);

4.3、通过上面的计算,_ClipRect之外的区域透明度为0,就实现了裁剪需求。

4.4、_ClipRect是RectMask2D设置的裁剪区域。具体可以参考RectMask2D的实现。


5、UI的sortingOrder规划

5.1、1000是背景UI层

5.2、2000是正常UI层

5.3、3000是置顶UI层。

特效根据需要设置为这中间的任意值。比如正常UI的特效,就设置为2500。这样弹窗在3000层就可以正常遮挡住特效,不会有穿帮了。


6、之前设想过动态管理UI的sortingOrder。不过因为一些困难放弃了。相比起来,还是手动管理(大多数时候不需要管理)更加安全稳妥。

6.1、动态设置sortingOrder,要管理好UI上的特效的sortingOrder,而特效的order也是有实际含义的,内部也会有一些遮挡关系。所以要收集好所有ParticleSystem的sortingOrder进行排序。

6.2、更进一步的问题是,如果一个特效的go是非active的,那么设置sortingOrder无效。

6.3、个别情况下有强制修改界面或者是界面内部分元素遮挡关系的需求。如果是动态设置sortingOrder,那么就必须要知道之前这些界面的sortingOrder是什么。代码会越写越复杂。也更加容易出问题。

6.4、因为要处理特效和UI的穿插关系这么一个需求,而引发更多的不确定性和开发的复杂性,综合考虑是不合适的。


三、封装一个好用的UIButton UIToggle

  一个好用的基础控件,可以增加开发效率。我们是以控件为单位进行设计,而不是组件,不期望一个按钮想实现一些常用功能比如灰化,还要挂接一个灰化组件。虽然后者在设计上更加灵活,但是实际开发上前者更加高效。

1、UIButton实现的功能:

1.1、点击放大缩小的动画。

1.2、禁用灰化效果。ps,灰化效果会把当前材质替换为灰化效果材质。灰化计算公式:

  color.rbg = dot(color.rgb, float3(0.3, 0.59, 0.11)); // 灰化效果

1.3、双击、长按等事件支持。额外需要注意,如果没有绑定双击事件,点击应该立即响应点击事件。如果按照默认逻辑需要等0.3秒判断是否双击,点击按钮会有明显的延迟感。

1.4、禁止频繁点击。默认500ms下只能点击1次,超过的点击事件会忽略。

1.5、自动添加一个放大的UICollider。手机上按钮都比较小,扩大点击区域为1.2倍左右可以方便点击。

2、UIToggle

2.1、实现了三态效果。正常、勾选、禁用。三态指定不同的图片和文本控件。

2.2、默认情况下是通过canvasRenderer.SetAlpha来控制控件的显示或者隐藏,性能更好。不过个别情况下可能会有问题,比如TextMeshProUGUI可能会因为文字不同生成新的submesh,这个时候就穿帮了。

  所以安全情况应该是统一使用SetActive来控制不同状态控件的显示或者隐藏。

3、UICollider

这个比较简单,但是很实用。通过这个而不是透明的Image控制点击区域,可以减少overdraw,提高性能。


四、PSD导出到UGUI

  我们实现了部分的psd到UGUI的导出。主要解决几个痛点:

1、美术不用再出标注图。之前美术出的效果图都要再额外做个标注图,标注资源是哪张图片,字体颜色等等。

2、美术资源自动从资源库中获取导入。之前查找图片路径是非常机械化且繁琐的步骤。

3、prefab自动生成,图片位置自动对齐,不用程序一个像素一个像素的对位置了。


不过由于项目现实情况,我们没有实现直接psd导出prefab,直接使用的功能。prefab还是需要程序加工。导出的东西只是把之前最痛的几个点自动化处理了。这么做主要是因为:

1、不希望增加美术的学习成本和工作成本。如果要求美术把按钮命名为BtnXXX,把图片命名为ImageXXX。可能是一件容易出错的事情。

  同样,美术很多时候都使用中文命名。强制他们使用英文,哪怕是汉语拼音,都是一件可能无法推动的事情。

2、美术很多图层都是乱拼的,其结构与程序的思路差异很大。

3、万一出现错误,还是程序直接改prefab方便。psd最终还是一次性的东西。

4、有很多涉及到底层或者性能优化的东西,程序都不一定会注意,美术或者策划更加不可能知道。

五、其他

(一)、UI上显示雷达图 UICircleImage

1、OnPopulateMesh 接口里面填充顶点。可以实现任意形状。比如圆形、雷达、五角星等等。

2、VertexHelper.AddVert / AddTriangle


(二)、如何做不规则的点击

1、ICanvasRaycastFilter:IsRaycastLocationValid() 接口可以根据实际图片像素控制点击是否有效,点击判定可以精确到像素。不过如果是通过Texture.GetPixel 来获取像素点的话,性能比较低。

2、PolygonCollider2D 来实现自定义碰撞盒。这个性能更好。一般的不规则点击,或者防止大图遮挡等,都可以绑定这个组件来实现。


(三)、Mask和RectMask2D实现原理

1、RectMask2D:通过设置canvasRenderer.EnableRectClipping来实现矩形裁剪的效果。受其影响的控件的材质会设置上一个 _ClipRect 属性并开启 UNITY_UI_CLIP_RECT。然后shader中通过 UnityGet2DClipping 函数获取当前像素点是否在裁剪区域内。如果不在区域内,则返回0。最终alpha就被设置为0,从而不显示了。

2、Mask:利用模板测试,区域之外的裁剪掉。模板测试性能一般,且有兼容性问题,某些很古老的手机不一定支持。另外就是会打断合批。增加至少3个额外的DrawCall。

3、RectMask2D只能用于2D矩形裁剪,但是性能会高很多。所以UI中一般都使用RectMask2D。如果有不规则的裁剪需求,可以考虑Mask。


(四)、一个高效的 FindChild 接口

1、通过遍历子节点递归。然后直接通过 transform.Find(path) 这个接口获取控件。而不是用gameObject.name 进行比对。因为gameObject.name和gameObject.tag都是有GCAlloc的。调用次数多了性能开销很大。

    public static Transform FindChild(Transform t, string name)
{
// 如果当前节点就能够找到的话,直接返回
Transform ret = t.Find(name);
if (ret != null)
{
return ret;
}

// 否则遍历每个子节点
int count = t.childCount;
for (int i = 0; i < count; ++i)
{
var child = t.GetChild(i);
Transform ct = FindChild(child, name);
if (ct != null) return ct;
}
return null;
}


B. Lua相关知识

一、lua语言基础

1、metatable

2、pairs、ipairs、table.sort

3、table的内存(数组结构和哈希结构)

4、字符串缓存(字符串常量是共享的。这个5.3版本有调整,40字节一下的短字符串才是共享的,长字符串还是保持独立内存)。所以配置文件中存在大量重复的字符串并不是很耗内存。

  反而是配置中存在大量的数字或者是嵌套的table的时候,非常耗内存。

5、lua本身的协程不支持常用的 WaitForSeconds 的功能。xlua通过Coroutine_Runner.cs 这个文件实现了这个功能。


二、lua和C#如何进行交互

1、通过lua state堆栈进行交互。

2、C#通过 lua_pushnumber 、lua_pushboolean、lua_pushstring、lua_pushlstring等接口传递参数。然后通过lua_pcall 调用函数。

3、lua调用C#,在C#的wrap函数中,通过lua_tonumber、lua_tostring等接口获取参数。执行后的结果可以通过lua_pushXXX 返回给lua。

4、lua_pushstring 传递一个字符串给lua。内部会使用strlen计算字符串长度。\0结尾。

  lua_pushlstring 传递一个buffer给lua。指定长度。

5、userdata。C#或者C++的类对象传递给lua,使用的是userdata。在XLua中ObjectTranslator就负责维护这些userdata。

6、C#怎么导出类型给lua的。

  通过Registry (注册表,LUA_REGISTRYINDEX)。这是一个全局table。只能被C代码访问。xlua把CS这个对象放在LUA_REGISTRYINDEX上,并通过生成的wrap文件,把所有C#类型都放在CS对象上。于是lua中就可以通过 CS.XXX访问C#的对象了。比如CS.UnityEngine.GameObject或者CS.Actor。

7、C#怎么调用lua的函数。

  函数应该通过字符串名字获取到,或者通过lua使用参数传递过来。这个LuaFunction对象可以保存起来。

  通过lua_getref把函数放到栈顶,通过lua_pushXXX把参数压栈,然后通过lua_pcall执行函数。如果有返回值则通过lua_toXXX在-1(栈顶位置)获取到lua的返回值,在xlua中返回的一般都是TResult对象。最后通过lua_settop恢复栈顶。

8、通过luaState.AddBuildin接口,可以添加C模块或者自定义模块。

三、lua面向对象

1、通过metatable实现class

2、lua访问一个table会先看有没有对应的字段(可以是变量或者函数),如果没有的话,会查找metable的__index。这个索引可以是一个function,也可以是一个table。如果__index也无法取到对应的变量则返回nil。

  另外一个是__newindex。它会在给table中不存在的字段赋值的时候调用。

3、class会返回一个table。它的metatable会指定为父类类型。这个返回的table就是一个lua中定义的类型了,我们会给它添加各种函数定义。

  当new的时候,会返回一个新的table,它的metatable会设置为class返回的这个类型table。这样当访问一个函数的时候,就会从当前对象的table--》类型table--》父类的table,这样的顺序去查找函数。从而实现面向对象中常用的多态(override)。

4、另外需要注意,我们只有函数是放在类型table上的,这个是所有对象共用的。而变量字段是通过ctor的递归调用直接赋值到对象table上的。也就是说变量是归对象的,函数是归类型的。

  这么设计符合直觉,有利于减少内存,也不用担心父子类之间变量冲突。

5、调用父类的正确写法。比如 Hero 继承自 Player 继承自 Animal

  self.super.Move(self, x, y, z)。而不能直接 self.super:Move(x, y, z)。因为后者由于lua的语法糖,传递给Move的参数是super,而实际上这个时候我们期望传递的是self。

  当有三层继承的调用的时候,上述写法也是有问题的。因为第二层调用依然是self.super.Move,会形成死循环。这个时候正确的调用方式是使用类型名调用。比如 Player.Move(self, x, y, z)。


四、实际项目经验

1、使用luajit2.1的版本。luajit性能很高,比lua可能高5~10倍。但是也要小心极端情况下可能会有jit失效的问题。这个时候代码无法jit,而又重复尝试jit,反而会导致性能降低。

2、luajit对应的lua版本是5.1。支持少量5.2的功能。不支持5.3的int。不支持_Env。

3、luajit在ios下没有开启jit功能。因为苹果政策问题,关键api没有权限调用。不过多数情况luajit依然要比lua快,所以在iOS下我们也是使用luajit。

4、ios下编译的是64位bytecode。不兼容32位的cpu。所以iPad1,iPhone5及以下的机型要么舍弃掉。要么同时再编译一份32位的bytecode,运行时根据机器cpu决定读取哪个脚本文件。我们的选择是放弃iPad1这样的老旧设备。

5、编辑器下使用luajit,总是会导致编辑器崩溃。原因未知。换成lua5.1的版本就好了。

  也就是说我们对外发布使用的是luajit,编辑器下使用的是lua5.1。

6、我们lua代码做了几层加密。luajit编译为bytecode是一层,aes加密是一层,lz4压缩是一层。

  因为aes和lz4都是非常快的算法,尤其是在读取的时候,所以性能上问题不大。

7、我们之前的lua代码倾向是打包在固定的几个文件的ab包内。不过后来随着代码量的逐渐增大,gui.ab、config.ab和logic.ab越来越大。更新一个版本补丁最小也要八九兆。后面这三个目录还是按照子目录打包。目的是减少更新补丁的大小。

8、随着代码量增大,我们打包的时候编译lua可能要占用三分钟时间。所以打包的时候维护了一个当前lua.bytes的缓存,记录了每个lua文件的md5。如果md5一致的话,就不重新编译。这样节约了打包的时间。

五、Lua代码热加载(Hot Reload)

1、热加载不同于热更新,服务器可以用来不关服修正线上问题(动态更新),客户端主要方便开发时不关闭游戏就重载更新后的代码,提高开发效率。

2、检测哪些文件发生改变(被修改了),这个原本使用的是C#的FileSystemWatcher,不过貌似很不稳定。经常导致Unity卡死,原因未知。

  也考虑过做一个VSCode的插件,然后使用socket通知Unity,不过感觉方案比较复杂,别人使用起来也比较复杂。

  最后的解决方案是使用Go(或其他语言)实现一个独立的监控文件改变的进程。Unity开启新进程监控文件变化。进程之间通过stdout的返回值进行通信。最终可以准确识别的文件的改变,也不会导致Unity卡死。

3、获取到的变化文件列表,不要在子线程处理。统一丢到主线程通知lua处理。

4、package.loaded[xxx] = nil。先把已加载文件的缓存清空。

5、重新require文件,替换保存的upvalue。

6、最终达成的效果是,某个函数添加了日志或者修改了逻辑,会自动修改生效,而不用重启游戏。

7、补充说明的是,只有全局变量有这么处理的必要。像我们的界面是local变量,那么只要关掉界面的时候把 package.loaded 置空,再次打开界面就可以重新加载修改后的文件。


六、Lua的调试

1、不同的IDE插件(VSCode+luaidelite,或者IDEA+EmmyLua),有不同的LuaDebug.lua的代码。

  但是本质上都是游戏运行时开启一个socket,设置断点的时候就直接sleep掉主线程。等插件继续运行游戏的时候,就是socket通知游戏,取消sleep。

2、各种变量信息可以通过 debug.getinfo 获取。


七、Lua的性能分析

1、推荐 Miku-LuaProfiler。提供了可视化的Unity窗口界面,看着非常直观。

2、本质上是使用 debug.sethook 监控函数的执行,在开始和结束的位置打点,最后统计分析哪些是耗时函数。

3、内存分析

3.1、善用 collectgarbage("count"),获取当前的lua内存。

3.2、做内存分析之前,先执行 collectgarbage("stop"),停止GC,否则运行过程中可能触发gc导致数据不准确。

3.3、在切换场景或者其他必要情景,执行 collectgarbage("collect"),进行gc。


来源知乎专栏:Unity开发启示录

以上是关于Unity开发之UI与Lua知识汇总!的主要内容,如果未能解决你的问题,请参考以下文章

Unity 基础 之 代码动态监听UI交互组件汇总

Unity 基础 之 代码动态监听UI交互组件汇总

Unity3D热更新之LuaFramework篇[05]--Lua脚本调用c#以及如何在Lua中使用Dotween

Unity开发日记--Lua开发游戏UI界面

游戏开发解答Unity使用lua将table转为树结构,以多级折叠内容列表的UI形式展现(树结构 | UGUI | 折叠展开 | lua)

游戏开发解答Unity使用lua将table转为树结构,以多级折叠内容列表的UI形式展现(树结构 | UGUI | 折叠展开 | lua)