头顶显示文字、冒血数字、合并网格、合并贴图等实战技巧

Text Mesh 3D头顶文字

我们在“摄像机——3D坐标转换UI坐标”中学到了通过RectTransformUtility.ScreenPointToLocalPointInRectangle来显示血条和文字,这种做法其实并不太好。因为只要人物移动或者摄像机移动,就需要一直换算坐标,这将带来一定开销。

创建Test Mesh组件并将其直接挂载角色身上,这样头顶文字就会自动跟着角色移动,只要将此组件绑为角色的子对象即可。

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)

Text Mesh图文混排

富文本的Tag除了<quad>之外,都可以在UGUI系统中使用

图片数字

用来渲染各种伤害美术数字的方法。

我们先创建一个Sprite Atlas,用于将美术数字集合起来降低DrawCall。

美术字Atlas

然后使用下面的`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);
}
}
}
}

设置一个对象来挂载此脚本

UINumber脚本

这段代码比较简单,实际游戏中伤害数字、弹药等都是重复且频繁出现的对象,实际开发中是需要使用对象池的

游戏对象缓存池

单纯地创建游戏对象其实很快。但是由于游戏对象上会绑定脚本,这就会造成一些卡顿,因为每个脚本上的AwakeOnEnable方法会同步执行。如果有大量复用性强的游戏对象,一定要做好缓存机制。

缓存的原理其实很简单,只需要对外提供两个接口——获取和回收。

获取只需查看池子里有没有,没有则创建。回收则是将游戏对象放回池子,下次使用时就不用再创建新的了。

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;//Stack后进先出,这里声明了Dictionary变量,那么它包含的Stack集合也会被记录

public GameObjectPool(string name)
{
m_AssetObjects = new Dictionary<string, Stack<GameObject>>();
m_ObjectContainer = new GameObject("#Pool " + name + "#");//生成父对象池
m_ObjectContainer.SetActive(false);//父对象池是FALSE,一会儿Release放进来的子对象也是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);//将对象加回Stack
go.transform.SetParent(m_ObjectContainer.transform, false);
return;//跳出整个函数,即不执行下面的Destroy
}
}
Object.Destroy(go);
}

private GameObject GetObjectFromPool(string assetPath)
{
Stack<GameObject> objects;
if(!m_AssetObjects.TryGetValue(assetPath,out objects))//如果字典中没有此对象
{
objects = new Stack<GameObject>();//初始化此对象的Stack
m_AssetObjects[assetPath] = objects;//将此对象加入字典
}
return objects.Count > 0 ? objects.Pop() : null;//如果字典中有,则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");//生成Pool
}
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());//将对象从m_Objects转入m_Pool中的Stack里面
}
}
}

点击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];//使用Mesh.CombineMeshes时,需要先用此结构体指定一些参数
for (int i = 0; i < combineMeshes.Length; i++)
{
combines[i].mesh = combineMeshes[i].sharedMesh;
combines[i].transform = combineMeshes[i].transform.localToWorldMatrix;//Mesh的transform需要同时记录旋转以及位移信息,所以使用4*4矩阵
}
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)大小

一张完整的贴图是由一维数组表示的,并且排列顺序是从左到右,从下到上。

RGBA32数列存储顺序

游戏中的压缩贴图格式为了效率会使用其他的像素排列方式,比如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;
//从小图的(0,0)位置开始,复制至大图的(0,0),并且保持图片的宽和高分别是256*256
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;
}
}
/// <summary>
/// 组合Block
/// </summary>
/// <param name="src">source bytes array</param>
/// <param name="srcx">start x position of source</param>
/// <param name="srcy">start y position of source</param>
/// <param name="dst">destination bytes array</param>
/// <param name="dstx">writein x position of destination</param>
/// <param name="dsty">writein y position of destination</param>
/// <param name="width">source texture width</param>
/// <param name="height">source texture height</param>
/// <param name="block">每一个块的边长,一般是4</param>
/// <param name="bytes">每个block的Byte值</param>
/// <param name="maxWidth">目标大图的边长</param>
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++)
{
//定位左起每一行的Index
int dstindex = (dstbx + (dstby + i) * (maxWidth / block) * bytes);//每行1024/4*16 = 4096 Byte
int srcindex = (srcbx + (srcby + i) * (width / block) * bytes);//每行256/4*16 = 1024 Byte
//每次直接写入一行
Buffer.BlockCopy(src, srcindex, dst, dstindex, width / block * bytes);
}
}
}

应用

我们首先通过GetRawTextureData获取到贴图的压缩数据,接着再创建一个1024*1024的贴图,将之前的原始数据复制进去。

运行时合并贴图需要读取图片的原始数据,所以必须开启贴图的Read Enable/Write Enable属性(不需要开启也能读取,原因待查),这样内存占用会加倍。如果没有大量的换装需求,最好不要开启它,或者可以考虑打包的时候将贴图的原始数据写在本地,不再使用贴图资源。运行时根据原始数据,再合并成一个新的贴图,这样就可以不设置Read Enable/Write Enable属性了。