头顶显示文字、冒血数字、合并网格、合并贴图等实战技巧
Text Mesh 3D头顶文字
我们在“摄像机——3D坐标转换UI坐标”中学到了通过RectTransformUtility.ScreenPointToLocalPointInRectangle
来显示血条和文字,这种做法其实并不太好。因为只要人物移动或者摄像机移动,就需要一直换算坐标,这将带来一定开销。
创建Test Mesh组件并将其直接挂载角色身上,这样头顶文字就会自动跟着角色移动,只要将此组件绑为角色的子对象即可。

其中characterSize = targetSizeInWorldUnits * 10.0f / fontSize。FontSize是加载的字体形状大小,characterSize是渲染出的网格大小,不过两者共同控制网格
Text Mesh图文混排
如果显示文字的同时还需要显示图标,那么可以使用Rich Text的功能。将需要参与图文混排的图片放入一个新材质中,并且绑定在Text Mesh的Materials数组中,写入富文本支持的tag即可
<quad material=1> <b><i><color=green>玩家名字</color></i></b> <quad material=1>
Rich Text | Unity UI | 1.0.0 (unity3d.com)

富文本的Tag除了<quad>
之外,都可以在UGUI系统中使用
图片数字
用来渲染各种伤害美术数字的方法。
我们先创建一个Sprite Atlas,用于将美术数字集合起来降低DrawCall。

然后使用下面的`UINumbers脚本
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| using UnityEngine;
public class UINumber : MonoBehaviour { [SerializeField] private Sprite[] numberRes; [TextArea(2,4)] [SerializeField] private string text; private void OnGUI() { if (GUILayout.Button("生成数字")) Refresh(); } void Refresh() { for (int i = 0; i < text.Length; i++) { int a; if(int.TryParse(text[i].ToString(),out a)) { SpriteRenderer spriteRenderer = new GameObject().AddComponent<SpriteRenderer>(); spriteRenderer.sprite = numberRes[a]; spriteRenderer.gameObject.SetActive(true); spriteRenderer.gameObject.name = a.ToString(); spriteRenderer.transform.SetParent(transform, false); spriteRenderer.transform.localPosition = new Vector3(i * 0.2f, 0f, 0f); } } } }
|
设置一个对象来挂载此脚本

这段代码比较简单,实际游戏中伤害数字、弹药等都是重复且频繁出现的对象,实际开发中是需要使用对象池的
游戏对象缓存池
单纯地创建游戏对象其实很快。但是由于游戏对象上会绑定脚本,这就会造成一些卡顿,因为每个脚本上的Awake
和OnEnable
方法会同步执行。如果有大量复用性强的游戏对象,一定要做好缓存机制。
缓存的原理其实很简单,只需要对外提供两个接口——获取和回收。
获取只需查看池子里有没有,没有则创建。回收则是将游戏对象放回池子,下次使用时就不用再创建新的了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59
| using System.Collections; using System.Collections.Generic; using UnityEngine;
public class GameObjectPool { private GameObject m_ObjectContainer; private readonly Dictionary<string, Stack<GameObject>> m_AssetObjects;
public GameObjectPool(string name) { m_AssetObjects = new Dictionary<string, Stack<GameObject>>(); m_ObjectContainer = new GameObject("#Pool " + name + "#"); m_ObjectContainer.SetActive(false); }
public GameObject GetObject(string assetPath) { GameObject go = GetObjectFromPool(assetPath); if(go == null) { go = Object.Instantiate<GameObject>(Resources.Load<GameObject>(assetPath)); go.AddComponent<PoolObject>().value = assetPath; } return go; }
public void ReleaseObject(GameObject go) { PoolObject comp = go.GetComponent<PoolObject>(); if(comp != null && comp.value != null) { Stack<GameObject> objects; if(m_AssetObjects.TryGetValue(comp.value,out objects)) { objects.Push(go); go.transform.SetParent(m_ObjectContainer.transform, false); return; } } Object.Destroy(go); }
private GameObject GetObjectFromPool(string assetPath) { Stack<GameObject> objects; if(!m_AssetObjects.TryGetValue(assetPath,out objects)) { objects = new Stack<GameObject>(); m_AssetObjects[assetPath] = objects; } return objects.Count > 0 ? objects.Pop() : null; }
class PoolObject : MonoBehaviour { public string value; } }
|
使用的时候,需要创建一个自己的对象池
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| using System.Collections.Generic; using UnityEngine;
public class GameObjectPoolTest : MonoBehaviour { private GameObjectPool m_Pool = null; private Stack<GameObject> m_Objects = null;
private void Awake() { m_Objects = new Stack<GameObject>(); m_Pool = new GameObjectPool("Cube"); } private void OnGUI() { if (GUILayout.Button("<size=50>Get</size>")) { GameObject go = m_Pool.GetObject("Cube"); go.transform.SetParent(transform, false); m_Objects.Push(go); } if (GUILayout.Button("<size=50>Release</size>")) { m_Pool.ReleaseObject(m_Objects.Pop()); } } }
|
点击Get按钮即可从缓存池获取对象,如果缓存池中没有,则创建新的对象。
点击Release按钮则将创建过的对象放回至缓存池中,并且设置它为隐藏状态

游戏资源缓存池
Unity内部自带一些资源缓存池。例如,立方体对象引用了一张贴图资源,当它首次被实例化进游戏中时,贴图资源会自动读取。如果此时立方体对象被释放掉,贴图则被Unity临时缓存着,下次如果又用到了这个贴图,就不会再重新读取了,这将加快资源的访问速度。另外,这个贴图会在下一次垃圾回收或者切换场景时被系统回收掉。对于比较重要的游戏资源,也需要做资源缓存池,其代码的实现思路和游戏对象缓存池类似。
运行时合并网格
由于换装系统需要身体部分拆开,换完组合后DrawCall数量就上去了。为了避免这种情况,我们可以在运行时合并网格和贴图。Unity引擎计算DrawCall的方法比较简单,同Mesh、同材质、同Shader、同参数、同贴图就是一个DrawCall。模型换装不影响骨骼和动画,只需要合并Mesh,就可以让骨骼动画复用,最终将合并后的Mesh重新赋值给Skinned Mesh Renderer即可。
身体和头部如果它们分开的话,是两个Mesh,此时就算它们共用了形同的材质和贴图,也会进行多次DrawCall,所以需要将各部位合并成一个Mesh。如果还需要合并贴图以及骨骼动画,那么还需要保证UV、骨骼权重和骨骼绑定姿势的正确合并。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| using System.Collections; using System.Collections.Generic; using UnityEngine;
public class CombineMeshesAtRuntime : MonoBehaviour { public SkinnedMeshRenderer[] combineMeshes; public SkinnedMeshRenderer skinnedMeshRenderer;
private void Awake() { CombineInstance[] combines = new CombineInstance[combineMeshes.Length]; for (int i = 0; i < combineMeshes.Length; i++) { combines[i].mesh = combineMeshes[i].sharedMesh; combines[i].transform = combineMeshes[i].transform.localToWorldMatrix; } var mesh = new Mesh(); mesh.name = "combine"; mesh.CombineMeshes(combines); skinnedMeshRenderer.sharedMesh = mesh; } }
|
通过代码将不同模型的部位合并在一起,首先将需要合并的Mesh放入CombineInstance
数组中并进行一些设置,最终使用Mesh.CombineMeshes
方法合并它们。

Unity对static静态物体也是自动合并Mesh的,它也可以在运行时合并,但是合并后子节点下的Mesh是无法发生位移变化的。这里的强制动态合并是用在游戏中发生移动的物体。
游戏中的换装系统也不一定非得使用动态合并Mesh的方法。如果换的部件比较少,完全可以用对个Skinned Mesh Renderer的方式来做。如果换的部件比较多,就一定要动态合并。
运行时合并贴图
和美术人员可以约定好贴图每一部分的含义,比如图片左半部分是身体,右半部分是头、武器等,运行时根据实际情况将贴图合并在一起,重新组成一张贴图。
如果是RGBA32格式的贴图,那么每个像素都是由32位来表示的,分别代表RGBA,其中每个颜色通道的取值范围是0~255(2的八次方,也就是一个通道八位(bit),一个八位占一个字节(Byte),四个通道一共32位四个字节)。一个像素占用4字节(Byte)大小
一张完整的贴图是由一维数组表示的,并且排列顺序是从左到右,从下到上。

游戏中的压缩贴图格式为了效率会使用其他的像素排列方式,比如DXT5、ETC2、RGBA8和ASTC_RGBA_4x4它们的像素排列格式是4x4为一个单位进行排列,总的排列顺序依然是从左到右,从下到上。它们平均下来,一个像素占用1字节(Byte)大小

我们将一张256*256带透明通道的压缩贴图合并到一张1024*1024贴图的左下角处,并且保持原有的压缩格式


1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56
| using UnityEngine; using UnityEngine.UI; using System;
public class CombineTextureAtRuntime : MonoBehaviour { public Texture2D texture;
private void Awake() { if(texture.format == TextureFormat.DXT5 || texture.format == TextureFormat.ETC2_RGBA8 || texture.format == TextureFormat.ASTC_4x4) { byte[] data = new byte[1024 * 1024]; int blcokBytes = 16; CombineBlocks(texture.GetRawTextureData(), 0, 0, data, 0, 0, 256, 256, 4, blcokBytes, 1024); var combinedTex = new Texture2D(1024, 1024, texture.format, false); combinedTex.LoadRawTextureData(data); combinedTex.Apply(); GetComponent<RawImage>().texture = combinedTex; } } void CombineBlocks(byte[] src,int srcx,int srcy,byte[] dst,int dstx,int dsty,int width,int height,int block,int bytes,int maxWidth) { var srcbx = srcx / block; var srcby = srcy / block;
var dstbx = dstx / block; var dstby = dsty / block;
for (int i = 0; i < height/block; i++) { int dstindex = (dstbx + (dstby + i) * (maxWidth / block) * bytes); int srcindex = (srcbx + (srcby + i) * (width / block) * bytes); Buffer.BlockCopy(src, srcindex, dst, dstindex, width / block * bytes); } } }
|

我们首先通过GetRawTextureData
获取到贴图的压缩数据,接着再创建一个1024*1024的贴图,将之前的原始数据复制进去。
运行时合并贴图需要读取图片的原始数据,所以必须开启贴图的Read Enable/Write Enable属性(不需要开启也能读取,原因待查),这样内存占用会加倍。如果没有大量的换装需求,最好不要开启它,或者可以考虑打包的时候将贴图的原始数据写在本地,不再使用贴图资源。运行时根据原始数据,再合并成一个新的贴图,这样就可以不设置Read Enable/Write Enable属性了。