《Unity主程手记》摘要记录(3)

 

作者:陆泽西-《Unity3D高级编程:主程手记》

第五章-3D模型与动画

5.1美术资源规范

模型面数并非越多越好,应该在一定数量下尽最大努力做大最好的美化。贴图同理。在性能和效果间取得平衡,规范可以防止制作美术资源时过度扩张。

5.1.1确定大小

影响因素:Mesh数量、贴图大小、骨骼和粒子数量、材质数量等。

  1. 根据场景确定。
    • 强调单一主角的场景,要求可以放宽,这类游戏的全部或者大量资源全部都要服务于主角,比如汤姆猫。
    • 卡牌策略类,场景固定,视角不移动。主要资源集中在场景和人物,特效上。因为同屏模型数量不会很多,因此标准也可以宽松一些。
    • 第三人称视角RPG,视角更广,看到的模型更多。对于开放世界的建筑模型,可以按大中小分类,每种类型的建筑模型要求不同的面数(7000/5000/3000),角色主贴图和副贴图的尺寸大小也要做相应限制,比如512x512和256x256。而小型部件要控制的更小。
    • 塔防、战争类游戏,比如部落冲突或者一些比较精致的SLG,俯视视角,同屏模型数量较多(部队,主城)。对每个模型的分配资源数量限制都要小一点。
    • 其他大型游戏除了定制规范外还需要一些别的手段做优化(LOD,AOI,动态加载和卸载,流程设计等)。
  2. 反推
    • 当一些模型差异大,无法通过标准统一,可以通过控制场景总面数来控制。比如场景总面数的上限要求在40万面,对场景中的每个元素进行规定,比如主角3000面,其他NPC相对少一些,高精细度的建筑物多分配一些,小物体少分配一些。贴图也可以用这样的方式来处理。规定一个内存上限,通常包括资源内存,业务代码分配,引擎运行时候占用,插件等。可以拿一些流行设备或者通用性比较强的设备作为标准参考。最关心的是资源内存,一般占用总体的80%左右。
  3. 自动检测
    • 压力测试
    • 一位追求速度的情况下只好在上线前做一波优化,或者上线以后再说。
    • 工具
[MenuItem("校验工具/角色、模型、地形Prefab")]
static public void ModelPrefabValidate()
{
	//写入csv日志
    StreamWriter sw = new StreamWriter("模型Prefab检测报告.csv", false, System.Text.Encoding.UTF8);

    string[] allAssets = AssetDatabase.GetAllAssetPaths();
	foreach (string s in allAssets)
    {
    	if (UIAssetPost.IsInPath(s, ModelFbxAssetPost.Character_Prefab_path)
    		)
    	{
			GameObject obj = AssetDatabase.LoadAssetAtPath(s, typeof(GameObject)) as GameObject;

            //--检查Fbx,网格,设置
        	MeshFilter[] meshes = obj.GetComponentsInChildren<MeshFilter>();
        	if (meshes != null)
        	{
            	int vertexCount_sum = 0;

            	for(int i = 0 ; i < meshes.Length ; i++)
            	{
            		Mesh mesh = meshes[i].sharedMesh;
            		SkinnedMeshRenderer smr = meshes[i].GetComponent<SkinnedMeshRenderer>();
            		// BatchRenderer br = meshes[i].GetComponent<BatchRenderer>();

            		if(mesh == null)
            		{
            			str_record = string.Format("丢失Mesh ,{0} ,{1}", s, meshes[i].name);
            		}
            		else
            		{
	            		ModelImporter model_importer = null;
	            		string path_obj = null;
	            		UnityEngine.Object obj_fbx = null;

            			//检查fbx路径
            			path_obj = AssetDatabase.GetAssetPath(mesh);
            			obj_fbx = AssetDatabase.LoadAssetAtPath(path_obj, typeof(GameObject));

            			//检查fbx设置
            			model_importer = AssetImporter.GetAtPath(path_obj) as ModelImporter;
            			if(!model_importer.optimizeMesh)
            			{
            				str_record = string.Format("Fbx设置中 optimizeMesh off 没开起来 ,{0} ,{1}", path_obj, obj_fbx.name);
            			}
            			if(model_importer.importMaterials)
            			{
            				str_record = string.Format("Fbx设置中 importMaterials on 被开起来了 ,{0} ,{1}", path_obj, obj_fbx.name);
            			}
            			if(!model_importer.weldVertices)
            			{
            				str_record = string.Format("Fbx设置中 weldVertices off 没开起来 ,{0} ,{1}", path_obj, obj_fbx.name);
            			}
            			if(model_importer.importTangents != ModelImporterTangents.None)
            			{
            				str_record = string.Format("Fbx设置中 importTangents on 被开起来了 ,{0} ,{1}", path_obj, obj_fbx.name);
            			}
            			if(model_importer.importNormals != ModelImporterNormals.Import)
            			{
            				str_record = string.Format("Fbx设置中 importNormals off 没开起来 ,{0} ,{1}", path_obj, obj_fbx.name);
            			}
            			if(smr != null
            				&& model_importer.isReadable)
            			{
            				str_record = string.Format("Fbx设置中 isReadable on 开起来了 SkinnedMeshRenderer 即动画不能开write ,{0} ,{1}", path_obj, obj_fbx.name);
            			}
            			if(!path_obj.Contains("_write")
            				&& model_importer.isReadable)
            			{
            				str_record = string.Format("Fbx设置中 isReadable on 开起来了 但文件名没有 _write 后缀,{0} ,{1}", path_obj, obj_fbx.name);
            			}
            			if(path_obj.Contains("_write")
            				&& !model_importer.isReadable)
            			{
            				str_record = string.Format("Fbx设置中 isReadable off 没开起来 但文件名有 _write 后缀,{0} ,{1}", path_obj, obj_fbx.name);
            			}
            			vertexCount_sum += mesh.vertexCount;
            		}
            	}

            	if(vertexCount_sum > MESH_VERTEX_MAX)
        		{
        			str_record = string.Format("网格顶点数大于 {0},{1} ,{2}", MESH_VERTEX_MAX, s, vertexCount_sum);
        		}
        	}

        	...

			//检测命名是否合法
			if (!UIAssetPost.IsFileNameLegal(s))
			{
				str_record = string.Format("文件命名不合法, {0}", s);
			}
			...
    	}
	}
	...
}
  1. 借助第三方,比如UWA

5.2模型合并

Animation or Animator?

Unity已经不再对Animation进行维护。同时在性能上,Animator要优于Animation,且Animator的功能更多。

SubMesh

多个网格组成一个模型,一个网格也可以由多个子网格组成。

引擎运行时子网格也需要匹配一个材质球来渲染,网格可以拆分成多个子网格,也可以将多个子网格合并成一个网格。

为什么要制作子网格?

需要表现多种不同效果,比如一部分网格用A材质球表现,一份网格用B材质球表现;单独拆出一个部分网格用于使用频率较高的公共材质,减少重复劳动;复杂动画的情况下,出于性能考虑,拆出一部分模型,让他们单独成为模型动画的一部分。

缺点:DrawCall问题,可能会打断合批。

动态合并

将拥有相同材质的模型合并成一个模型和一个材质,从而减少向GPU的次数。

  • 动态合批

    顶点数小于900;如果shader使用了法线,顶点坐标,独立UV,那么要小于300个。

    合批物体的缩放比例要保持一致

    材质球必须一样

    多管线着色器会打断合批

    支持多灯光的前置渲染无法合批

    内置管线中的延迟渲染关闭了动态合批,因为他需要画两次

    所有多Pass的shader增加了渲染管道,不会动态合批

动态合批会消耗CPU将物体的顶点变换到世界空间。

要注意合批的开销和带来的性能提升,无脑合批并不是万能的。

  • 静态合批

    让引擎在离线状态下进行合批,将所有参与合批的物体放入世界空间,以材质球为分类标准分别合并,构建一个顶点和索引缓存,所有可见的物体就会被同一批drawCall提交,但消耗了更多内存。

    要参与静态合批的物体需要勾选static。

    本质上来说,静态合批并没有节省drawCall数量,而是减少了图形接口的状态转变带来的损耗。多数平台上批处理被限制在64000个顶点和64000个索引数量内(OpenGLES要求48000,MacOs要求32000)

总结一下,动态合批要求使用同一个材质,对顶点数量也有要求,缩放比例要一致,不能用多通道的shader。静态合批要求物体必须为static,不能移动旋转缩放,不能带动画。

动态合批规则多,静态合批限制大。在某些情况下可以自己手动实现模型合并。

模型合并程序

Mesh.CombineMeshes合并网格

MeshFilter类,保存网格数据

MeshRender类,网格绘制类型

“mesh和material都是实例型的变量,对mesh和material执行任何操作,都是额外复制一份后再重新赋值,即使只是get操作,也同样会执行复制操作。也就是说,对mesh和material进行操作后,就会变成另外一个实例,虽然看上去一样,但其实已是不同的实例了。sharedMesh和sharedMaterial与前面两个变量不同,它们是共享型的。多个3D模型可以共用同一个指定的sharedMesh和sharedMaterial,当你修改sharedMesh或sharedMaterial里面的参数时,指向同一个sharedMesh和sharedMaterial的多个模型就会同时改变效果。也就是说,sharedMesh和sharedMaterial发生改变后,所有使用sharedMesh和sharedMaterial资源的3D模型都会表现出相同的效果。”

网格、MeshFilter和MeshRender:XXX.fbx,其中存储了顶点,UV,顶点颜色,三角形,切线法线,骨骼权重等数据。网格被实例化后存储在MeshFilter内,其中包含mat和sharedMat两种了类型变量。MeshRender会获取MeshFilter中的数据进行渲染。

CombineInstance:合并数据实例类型。合并时为每个要参与合并的网格创建一个该类型的实例,放入必要数据(Mesh,SubMesh)索引等,然后将CombineInstance数组传给Mesh.CombineMeshes。

//建立合并数组
CombineInstance[] combine = new CombineInstance[mMeshFilter.Count];
//填入数据
for(int i = 0 ; i< mMeshFilter.Count ; i++)
{
    combine[i].mesh = mMeshFilter[i].sharedMesh;
    combine[i].transform = mMeshFilter.transform.localToWorldMatrix;
    combine[i].subMeshIndex = i;//标识Material的索引位置,可以为0,1,2等
}
//合并mesh
new_meshFilter.sharedMesh.CombineMeshes(combine);
//new_meshFilter.sharedMesh.CombineMeshes(combine,false);//也可以在合并后保留subMesh

5.3状态机

  • 场景切换,可以抽象或者继承为流程类型(Procedure),将游戏划分为几个具体的阶段,比如登录阶段,预加载阶段,主游戏阶段。
  • 角色行为:idle,move,die等
  • 宝箱、机关等物体的动画和逻辑
  • 构建AI:利用FSM可以构建一些相对简单的AI

ActorStateBase状态基类->多个子类实现->一个mgr指向当前状态并且调用相应逻辑(current,OnEnter,OnUpdate,OnLeave等)

5.4 3D模型变换

顶点->三角形->Meshs

索引三角形网格:顶点列表存储网格的所有顶点,索引列表存储形成三角形的索引。,每三个索引指向三个顶点,代表一个三角形。顶点除了位置信息外还包含UV,法线、切线、颜色等信息。三角形的索引顺序为顺时针

CPU和GPU的传输速率问题:

  • 缓存命中:如果要传入的顶点数据已经在缓存,则直接读取,否则就读入并且放到缓存里。
  • 三角带:以共享边为依据将所有三角形排开,一个顶点加上共享边可以构成三角形。
  • 三角扇

顶点索引是主流的三角形表达方式。

将所有顶点放入一个数组vertextArr,再用另一个数组保存所有顶点的集合indexArr,indexArr的元素代表vertexArr的元素下标,每三个组成一个三角形。

(0,0,0),(0,1,0),(1,1,0),(1,0,0)构成一个矩形网格。其索引数据为0,1,2,2,3,0。即(0,0,0),(0,1,0),(1,1,0)构成一个三角形,(0,1,0),(1,1,0),(1,0,0)构成一个三角形。

“我们再来完整地叙述一遍网格数据从制作到渲染的过程。首先,美术人员制作3D模型并导出成Unity3D能够识别的格式,即.fbx文件,其中已经包含了顶点和索引数据,然后在程序中将.fbx实例化成Unity3D的GameObject,它们身上附带的MeshFilter组件存储了网格的顶点数据和索引数据(我们也可以自己创建顶点数组和索引数组,以手动的方式输入顶点数据和索引数据,就如上文描述矩形网格那样)。MeshFilter可用于存储顶点和索引数据,MeshRender或SkinMeshRender可用于渲染模型,这些顶点数据通常都会与材质球结合,在渲染时一起送入图形卡,其中与我们预想的不一样的是,在送入时并不会由索引数据送入,而是由三个顶点一组组成的三角形顶点送入图形卡。接着由图形卡负责处理我们送入的数据,然后渲染帧缓存,并输出到屏幕。”

贴图渲染

矩形网格顶点:[(0,0,0),(0,1,0),(1,1,0),(1,0,0)]

索引集合:[0,1,2,2,3,0]

UV集合:[(0,0),(0,1),(1,1),(1,1),(1,0),(0,0)]表示两个三角形上的绘制范围。

模型切割

思路:提取要切割模型的顶点数据,分成切割后的左右两部分分别存储,使用两个新的渲染组件实例处理他们,然后加入一些碰撞和物理。

顶点该放在左边还是右边,如何判断顶点是否被切割:对于一个三角网格,遍历其所有顶点,每个顶点指向切割结束位置的方向与平面法线做点乘检查方向是否一致,大于0为一侧,小于0为另一侧。如果两个顶点组成的线段在做点乘时方向结果不一致,则说明线段与切割平面相交。对于切割线和线段的交点,顶点A到顶点B的方向上取比例t即为切割点的位置,(A-B)*t+A。求出t的方法是,求顶点A到切割线的距离以及顶点B到切割线的距离,然后将结果除以顶点A到切割线的距离即可。然后进行缝合,缝合方法有好几种,可以参考这里:github.com/hugoscurti/mesh-cutter。

模型扭曲

多应用于爆炸造成的地面凹陷,拉球,陶艺游戏,可拉伸的摇杆等。

思路:取出顶点数据,修改顶点位置再放回去。

  • 爆炸凹陷:找出需要处理爆炸的地面网格,找到所有爆炸范围内的顶点,进行顶点凹陷计算。以经纬度来表示地面,则计算公式为x=cosacosb,y=cosasinb,z=sina。a、b为经纬度
  • 球体拉伸和反弹:获取拉伸点,计算所有顶点与拉伸点的距离,距离越大偏移量越小。具体的偏移量随距离而形成一条衰减曲线,假设距离是d,拉伸的距离为f,偏移量结果为res,那么最简单的衰减公式为res=f/(d×d)。将结果乘以系数形成新的顶点坐标重新写入即可。拉伸后放开时,顶点需要回到原位置,简单的办法是缓存顶点的原位置。反弹衰减也是曲线关系,最简单的公式如res=d×d,或者更平滑点,如曲线公式res=(d/max)×(d-2)×k。
  • 制陶游戏:但鼠标或手指触碰到模型时,根据方向将一定范围内的顶点向触摸方向偏移,离触摸点最近的顶点偏移最大,越远的越小。

简化模型

简化模型的方法中,LOD较为常用。网格由点线面组成,减面等于减少点和线,如果简单粗暴的减少会引起模型形变而达不到LOD的目的,一个方法是将线上的两个顶点变为一个顶点。一种方案是基于二次项误差的边收缩算法

蒙皮骨骼

MeshRenderer和SkinnedMeshRenderer分别用于渲染模型和3D模型动画,他们的模型数据都存储在MeshFilter中。SkinnedMeshRenderer除了3D模型数据外,还会拿到骨骼数据和顶点权重数据。

MeshRender从MeshFilter中取出顶点数据、UV、颜色、法线等,结合自己的材质,发送给GPU。

SkinnedMeshRenderer也遵循同样的步骤。

模型要做动作,就要改变模型的顶点。两种方法:1、顶点动画。可以参照《戴森球计划》的优化方案。2、使用骨骼影响网格顶点,让模型形变。

https://imgse.com/i/v5pfRH

这是一个刚性层级式动画,他将模型拆成多个部位,按照层级节点的方式装上去。

图片

但问题是:关节连接处产生裂缝而穿帮。

蒙皮骨骼动画的数据主要由一些骨骼点和权重数据组成。游戏角色的骨骼数量通常不会超过100个。

骨骼动画由骨骼点(带有相对空间坐标点的数据实体)组成,骨骼点为树形结构,可以有许多但只能有一个根节点。父节点变换子节点跟着一起变。

“在现代手机游戏中,每个人物骨骼动画的数量一般为30个左右,PC单机游戏中可达75个左右。骨骼数量越多,动画就越有动感,但同时也会消耗掉更多的运算量。”

bones变量用于存储所有的骨骼点,它在蒙皮网格中以transform的形式存在。每个骨骼点都可以影响一定范围内的顶点,一个顶点也可以收到多个骨骼点的影响。每个顶点都有影响它本身最多四个的顶点权重数据,bonesWeights用于存储所有顶点的骨骼权重值。

BoneWeight.boneIndex0到3分别代表被影响的骨骼点索引,而weight0到3表示骨骼点的权重,范围0-1。骨骼移动时,就会取BoneWeight实例来计算收到影响顶点的旋转、偏移和缩放。

制作蒙皮动画一般分为三步:使用建模软件(3DMAX,Maya等)构建一系列骨骼点->动画师通过模型软件制作动画导出到引擎的动画文件格式比如unity的fbx->Unity导入后直接调用相关api播放即可。

//新建个动画组件和蒙皮组件
gameObject.AddComponent<Animation>();
gameObject.AddComponent<SkinnedMeshRenderer>();
SkinnedMeshRenderer rend = GetComponent<SkinnedMeshRenderer>();
Animation anim = GetComponent<Animation>();

//新建个网格组件,并编入4个顶点形成一个矩形形状的网格
Mesh mesh = new Mesh();
mesh.vertices = new Vector3[] {new Vector3(-1, 0, 0), new Vector3(1, 0, 0), new Vector3(-1, 5, 0), new Vector3(1, 5, 0)};
mesh.uv = new Vector2[] {new Vector2(0, 0), new Vector2(1, 0), new Vector2(0, 1), new Vector2(1, 1)};
mesh.triangles = new int[] {0, 1, 2, 1, 3, 2};
mesh.RecalculateNormals();

//新建个漫反射的材质球
rend.material = new Material(Shader.Find("Diffuse"));

//为每个顶点定制相应的骨骼权重
BoneWeight[] weights = new BoneWeight[4];
weights[0].boneIndex0 = 0;
weights[0].weight0 = 1;
weights[1].boneIndex0 = 0;
weights[1].weight0 = 1;
weights[2].boneIndex0 = 1;
weights[2].weight0 = 1;
weights[3].boneIndex0 = 1;
weights[3].weight0 = 1;

//把骨骼权重赋值给网格组件
mesh.boneWeights = weights;

//创建新的骨骼点,设置骨骼点的位置,父节点,和位移旋转矩阵
Transform[] bones = new Transform[2];
Matrix4x4[] bindPoses = new Matrix4x4[2];

bones[0] = new GameObject("Lower").transform;
bones[0].parent = transform;
bones[0].localRotation = Quaternion.identity;
bones[0].localPosition = Vector3.zero;
bindPoses[0] = bones[0].worldToLocalMatrix * transform.localToWorldMatrix;

bones[1] = new GameObject("Upper").transform;
bones[1].parent = transform;
bones[1].localRotation = Quaternion.identity;
bones[1].localPosition = new Vector3(0, 5, 0);
bindPoses[1] = bones[1].worldToLocalMatrix * transform.localToWorldMatrix;

mesh.bindposes = bindPoses;

//把骨骼点和网格赋值给蒙皮组件
rend.bones = bones;
rend.sharedMesh = mesh;

//定制几个关键帧
AnimationCurve curve = new AnimationCurve();
curve.keys = new Keyframe[] {new Keyframe(0, 0, 0, 0), new Keyframe(1, 3, 0, 0), new Keyframe(2, 0.0F, 0, 0)};

//创建帧动画
AnimationClip clip = new AnimationClip();
clip.SetCurve("Lower", typeof(Transform), "m_LocalPosition.z", curve);

//把帧动画赋值给动画组件,并播放动画
anim.AddClip(clip, "test");
anim.Play("test");

换皮换装

拆分骨骼数据文件和多个模型文件,保证骨骼动画没有问题,同一套骨骼可以用于多个换装模型,更换模型部件带来的dc问题可以考虑Mesh.CombineMeshes合并网格。

捏脸

更改骨骼节点的local坐标,为了防止动画数据强行恢复骨骼点,需要额外增加一些用于捏脸的骨骼点。

对于发色,眼睛颜色等,可以更换贴图,调整采样颜色。

而使用不同网格的线性插值的方案可以做面部表情。

优化

SkinnedMesh动画因为每帧都要计算,实际上是比较消耗CPU的。

CPU Skinning:多线程+SIMD加速SkinnedMesh计算,注意SIMD并没有减少运算量,只是加快了运算速度。

  • 顶点动画,把每帧的顶点位置写到文件里,给到shader,用shader做顶点位移,将计算过程从CPU转到GPU。比如风摆,旗子,水波。但是如果动画没有规律 ,较为复杂,则不适用。
  • 离线加速,因为每帧改变顶点位置,可以离线准备好每帧模型的网格数据。用内存换时间。
  • GPU Instancing / SRP Batcher
  • LOD,

5.5 资源

资源卸载的空置倒计时、池。

第六章-网络

6.1TCP和UDP

TCP:面向连接,三次握手,四次挥手。

  • ACK:TCP包头数据控制位,对数据进行确认
  • SYN:同步序列号,建立连接时置1
  • FIN:发送端完成发送任务位。

TCP的包头结构

UDP:非连接协议,不维护连接状态

  • TCP基于连接,UDP面向无连接
  • TCP资源开销多
  • TCP包头大,有状态,UDP包头小,无状态
  • TCP为流模式,UDP为数据报模式
  • TCP可以保证数据可靠性,UDP可能丢包
  • TCP保证数据连贯,UDP不保证

如果客户端间歇发起无状态的查询,并且偶尔发生的延迟可以容忍->HTTP/HTTPS

如果客户端和服务器都可以独立发包,但是偶尔发生的延迟可以容忍(比如在线的纸牌游戏、大部分MMO类游戏)->TCP/UDP

如果客户端和服务器都可以独立发包,而且无法忍受延迟(动作类游戏、MMO类游戏)->UDP

6.2 TCP

需要注意的点:建立连接、断线检测、协议、发送和接受缓冲队列、发送数据合并、线程死锁。

.Net的Socket网络库。

线程锁

lock(obj)
{
    message_queue.push(data);
}
lock(obj)
{
    var data = message_queue.pop(data);
}

缓冲队列

保证当前数据解析处理完毕,再从队列里拿下一个,没有就继续等待。

IMessage _msgCache = null;
private void Update()
{
    _msgCache = PopMsg();
    while(_msgCache != null)
    {
        //解析
    }
}

双队列结构

为了避免线程等待和资源争夺。将收到数据的队列数据给入处理数据的队列,然后清空收到的数据队列,这样无需等待消息包的解析就可以一直接受消息。

发送

发送时合并一部分数据报,提升效率。但要保证不要太大,因为失败一次就要全部重发。

协议和数据定义

  1. 双端都能接受的格式
  2. 数据包最小化
  3. 一定的校验能力
    • MD5
    • 奇偶校验
    • CRC校验
    • 加密算法(RSA,公钥私钥,非堆成加密)

断线检测

心跳包,间隔发送。

6.3 UDP

UDP不会自己校验重发;因为数据报的关系,丢包概率大;接受顺序不确定;因为没有状态,所以没有连接断开和连接确认机制。

连接确认机制

UDP自身没有三次握手的机制,但可以模拟,另外对于握手机制的第三步,其实可以省略。

//建立连接
SvrEndPoint = new IPEndPoint(IPAddress.Parse(host), port);
UdpClient = new UdpClient(host, port);
UdpClient.Connect(SvrEndPoint);
//启动线程接收数据
UdpClient.BeginReceive(ReceiveCallback, this);
void ReceiveCallback(IAsyncResult ar)
{
    Byte[] data = (mIPEndPoint == null) ?
        UdpClient.Receive(ref mIPEndPoint) :
        UdpClient.EndReceive(ar, ref mIPEndPoint);

    if (null != data)
        OnData(data);

    if (mUdpClient != null)
    {
        // try to receive again.
        mUdpClient.BeginReceive(ReceiveCallback, this);
    }
}

//握手包
SendConnectRequest();
//暂时不接收其他包知道服务器返回确认连接
StopSendNormalPackage();
StopReceiveNormalPackage();
//等待连接确认
Void OnData(byte[] data)
{
     If( !IsConnected )
    {
        If( IsConnectResponse(data) )
        {
            OnEvent( Event.ConnectSuccess );
            IsConnected = true;
        }
        Return;
    }
    ProcessNormalData(data);
}

数据校验和重发

可以模拟TCP的Seq、Ack校验和重发机制。

丢包问题

  1. 异步收包导致包体丢失。
  2. 发送的数据包太大,控制报文长度,让每个报文长度小于MTU
  3. 发包频率太快,建立缓冲。

6.4封装HTTP

.Net库和Unity封装的库皆可实现(注意2018后Unity用UnityWebRequest取代了WWW)

HTTP是应用层协议,因此他要求底层协议是可靠的,它依赖于TCP,因为它本身并没有检测等机制。

void StartRequest(string url, WWWForm wwwform, Callback _callback)
{
    this.web_request = UnityWebRequest.POST(url, wwwform);
    this.Callback =  _callback;
    this.web_request.SendWebRequest();
}
void Update()
{
    if(web_request != null)
    {
        if(web_request.isDone)
        {
            ProcessResponse(web_request);
            web_request.Dispose();
            web_request = null;
        }
    }
}

HTTP分为header和body两部分,header包括请求方式,host等信息,body是消息体信息。以下是一个典型的HTTP服务器返回消息。

    HTTP/1.1 200
    Date:Mon,31Dec200104:25:57GMT
    Server:Apache/1.3.14(Unix)
    Content-type:text/html
    Last-modified:Tue,17Apr200106:46:28GMT
    Content-length:xxx
    
    //这里HEADS 和 BODY用空行隔开
    body content

多次和连续请求引起的问题

返回数据无法保证先后,回调不可预知。

  • 多个连接发出请求后,等到所有数据都拿到后再执行逻辑
  • 逐个发起,逐个处理,保证顺序
  • 多连接和逐个发起混合使用

6.5 协议原理

典型的协议包格式包括Json,MsgPack,Protobuf,或者自定义格式。

  • 自定义二进制流格式:不具备通用性,一个典型的自定义二进制流格式包括三部分: 数据大小 协议编号 具体数据
    class Msg
    {
        uint size;
        uint msg_id;
        byte[] datas;
    }
    

校验消息大小->获取消息ID->根据消息ID决定数据读取方式(比如每隔多少字节获取多少数据,转换成何种类型,由消息类型决定)

  • ProtoBuf:消息定义(.proto)

    序列化和反序列化:使用协议字段后的编号作为KV的变量映射,然后在生成class时写死;反序列化:一段switch/case->switch(tag) case 1: xxx = input.ReadString(); break; case 2:xx = input.readInt32();

    使用protobuf要注意协议修改时的兼容问题,在原有协议新加字段要注意optional和repeated限定符,原有字段不要移除required等。

    但protobuf也有缺点,比如无法表达复杂结构,但在大部分情况下也已够用。

6.6 网络同步

状态同步、广播同步、帧同步

片面地讲,状态同步=同步数据,帧同步=同步逻辑帧数据包和指令

还有一些技术点可以看守望团队GDC2017技术分享。

同步快进:

客户端卡了,堆积了过量的数据包:可以一次将缓存队列的消息包全部执行,但可能会导致卡顿。或者分批次执行固定帧。通过内存快照来进行模拟也是一种方式。

精度问题

帧同步的一些逻辑放在了客户端来做,理论上来说只要所有客户端和服务器帧数保持一致,业务逻辑代码一致,就能得出一样的结果。但如果因为平台差异导致浮点数精度不一致,那么就可能出现问题,比如服务器算出来是1.0,给到客户端变成了0.999999999999。

可以用定点数,或者统一乘除小数位转换为int来解决这个问题(15.5->15.5*10000->155000)。

同步锁

每个客户端间隔一定时间向服务器或主机发送心跳包,如果一人网络状态不好,则所有人等待,直到恢复。(星际1、大乱斗)