问题回顾
云端渲染模块是连接客户端与服务端、客户端与客户端的主要通信框架,其目的是协调各个客户端的操作,保证各个客户端之间的画面一致性。主要实现的功能如下:
- 车流一致。每个客户端的列车都遵循运行时刻表在同一指定时间到达。
- 客流一致。每个乘客的行走轨迹一致,在同一时间完成上下车。
- 实体一致。场景实例化的所有物体在每一个客户端都是相同的,同时创建,同时销毁。
- 数据一致。由于大部分计算过程都存在于客户端中,因此,客户端的场景和操作一致可以保证生成的数据也是一致的,避免了产生冲突数据。
- 视角独立。查看各个地铁站场景是客户端最易发的操作,该过程需要控制相机的移动,但必须保证其独立性,防止视角干涉、加重通信负担。
在之前的工作中,借助GDNET框架和navmeshagent寻路模块,我们在第一版的灵境胡同地铁站场景中实现了基本的帧同步功能,但仍然存在以下问题未被解决:
- 寻路同步问题。借助unity编辑器中的navmeshagent寻路模块进行寻路时,由于随机数函数不一致的问题,生成的路径难以精确同步,在多次运行后误差会累计,影响场景的正常运行。
- 视角干涉问题。由于相机也是场景中的实体之一,在云端渲染框架中同样会被同步到其他客户端,这就导致客户端无法分辨应该用哪个相机进行场景显示,多个客户端会抢夺同一个相机的控制权,导致场景混乱。
- 场景更新问题。在第一版灵境胡同站场景的基础上,大部分实体、预制体、贴图等都进行了更新,形成了第二版灵境胡同站场景,为了保证各部分开发的一致性,需要将云端渲染框架进行迁移,在新场景下实现各项功能。
寻路同步问题
在解决寻路同步问题时,我们调研了三种解决方案,分别是使用navmeshagent获取路径后控制移动、使用recastnavigation获取路径后控制移动、使用Astar算法获取路径后控制移动,三种方案由简到难逐个进行尝试,以减少开发成本。
使用navmeshagent获取路径后控制移动
路径获取
获取路径主要借助navmeshagent的公共函数CalculatePath(),计算出的路径被保存为NavMeshPath形式的变量,NavMeshPath类的属性中,corners保存路径的各个角点,是一个Vector3变量数组,通过连接各个角点即可以形成一条完整的路径。
using UnityEngine;
using UnityEngine.AI;
using System.Collections;
public class ExampleClass : MonoBehaviour
{
public Transform target;
private NavMeshAgent agent;
void Start()
{
agent = GetComponent<NavMeshAgent>();
NavMeshPath path = new NavMeshPath();
agent.CalculatePath(target.position, path);
if (path.status == NavMeshPathStatus.PathPartial)
{
}
}
}
控制移动
在获取到路径角点后,我们编写了控制人物沿路径移动的功能,并将其嵌入了云端渲染框架,保证乘客行走路径的一致。其核心代码如下所示:
public override void UpdateLogic()
{
base.UpdateLogic();
if (isMove)
{
OnMove();
}
}
private void OnMove()
{
if (index > points.Length - 1) return;
Vector3 offset = points[index] - transform.position;
transform.Translate(offset.normalized * Time.deltaTime * speed, Space.World);
offset.y = 0;
transform.forward = offset;
if (Vector3.Distance(points[index], transform.position) < 0.5f)
{
index++;
}
if (index > points.Length - 1)
{
isMove = false;
index = 0;
Debug.Log("到达目的地");
}
}
在完成上述过程后,我们编写了一个简单的示例场景进行了测试,测试中发现,该方案仍然难以实现寻路同步。经过debug排查,其原因是在计算路径时,其他客户端也会同步进行路径计算操作,这就导致两个路径发生了冲突,使寻路无法正常同步。我们随后尝试了将路径计算过程独立,但navmeshagent必须在每一个乘客上挂载,难以独立。但该方案中实现的控制移动功能是通用的,在其他方案的试验中得到了复用。
该部分共修改与新建C#脚本三个,包括Enemy.cs, PersonMove.cs, FindPath.cs
使用recastnavigation获取路径后控制移动
recastnavigation是业内先进的导航网格生成工具,可以完成场景寻路功能,主要使用C++编写,且代码在github完全开发,适合修改和使用。在使用该算法时,需要将其接入unity采用C#语言进行整合修改,实现路径计算功能。主要流程包括:
路径获取
1. recastDemo构建
该部分详细步骤在我的另一篇博客中进行了详细地记录(https://blog.csdn.net/qq_41281244/article/details/108686005),简要流程如下:
- 解压文件目录
- 配置SDL库与premake5
- 通过命令行控制premake编译recastnavigation为sln工程
- 运行并构建工程
recastDemo效果如下,该界面可以实现navmesh.obj文件的显示与渲染,寻路参数的测试与配置,导航网格bin文件的生成以及可视化的寻路测试,用于检验模型的可用性。
2. 桥接unity
该部分详细步骤在我的另一篇博客中进行了详细地记录(https://blog.csdn.net/qq_41281244/article/details/108686005),简要流程如下:
- 新建用于生成dll的RecastNavDll工程
- 编写C++文件打包各个函数目录
- 运行工程生成dll文件
- 编写桥接文件暴露方法给unity
桥接部分的代码如下,该过程实现了recastnavigation与unity的桥接,使场景中编写的C#脚本可以调用以C++编写的各个寻路相关方法。
public class RecastInterface
{
private const string RecastDLL = "RecastNavDll";
[DllImport(RecastDLL, CallingConvention = CallingConvention.Cdecl)]
private static extern bool recast_init();
[DllImport(RecastDLL, CallingConvention = CallingConvention.Cdecl)]
private static extern void recast_fini();
[DllImport(RecastDLL, CallingConvention = CallingConvention.Cdecl)]
private static extern bool recast_loadmap(int id, char[] path);
[DllImport(RecastDLL, CallingConvention = CallingConvention.Cdecl)]
private static extern bool recast_freemap(int id);
[DllImport(RecastDLL, CallingConvention = CallingConvention.Cdecl)]
private static extern int recast_findpath(int id, float[] spos, float[] epos);
[DllImport(RecastDLL, CallingConvention = CallingConvention.Cdecl)]
private static extern bool recast_smooth(int id, float step_size, float slop);
[DllImport(RecastDLL, CallingConvention = CallingConvention.Cdecl)]
private static extern int recast_raycast(int id, float[] spos, float[] epos);
[DllImport(RecastDLL, CallingConvention = CallingConvention.Cdecl)]
private static extern int recast_getcountpoly(int id);
[DllImport(RecastDLL, CallingConvention = CallingConvention.Cdecl)]
private static extern int recast_getcountsmooth(int id);
[DllImport(RecastDLL, CallingConvention = CallingConvention.Cdecl)]
private static extern IntPtr recast_getpathpoly(int id);
[DllImport(RecastDLL, CallingConvention = CallingConvention.Cdecl)]
private static extern IntPtr recast_getpathsmooth(int id);
[DllImport(RecastDLL, CallingConvention = CallingConvention.Cdecl)]
private static extern IntPtr recast_getfixposition(int id, float[] pos);
[DllImport(RecastDLL, CallingConvention = CallingConvention.Cdecl)]
private static extern IntPtr recast_gethitposition(int id);
}
之后我们进行了一些基本的测试,检验桥接后是否能正常寻路。
public static void BenchmarkSample()
{
BenchmarkHelper.Profile("寻路100000次", BenchmarkRecast, 100000);
}
private static void BenchmarkRecast()
{
if (RecastInterface.FindPath(100,
new System.Numerics.Vector3(-RandomHelper.RandomNumber(2, 50) - RandomHelper.RandFloat(),
RandomHelper.RandomNumber(-1, 5) + RandomHelper.RandFloat(), RandomHelper.RandomNumber(3, 20) + RandomHelper.RandFloat()),
new System.Numerics.Vector3(-RandomHelper.RandomNumber(2, 50) - RandomHelper.RandFloat(),
RandomHelper.RandomNumber(-1, 5) + RandomHelper.RandFloat(), RandomHelper.RandomNumber(3, 20) + RandomHelper.RandFloat())))
{
RecastInterface.Smooth(100, 2f, 0.5f);
{
int smoothCount = 0;
float[] smooths = RecastInterface.GetPathSmooth(100, out smoothCount);
List<Vector3> results = new List<Vector3>();
for (int i = 0; i < smoothCount; ++i)
{
Vector3 node = new Vector3(smooths[i * 3], smooths[i * 3 + 1], smooths[i * 3 + 2]);
results.Add(node);
}
}
}
}
3. 导航网格生成
unity场景导出的navmesh文件格式为obj格式,而recastnavigation可以接收的是bin格式的导航网格文件,recastDemo提供了构建bin文件的功能,但由于该exe程序难以嵌入unity中,因此我们尝试修改了recastDemo工程,将其传参模式简化,通过unity唤起自动控制其构建bin文件的操作流程,并隐藏了界面,使其成为后台服务。核心代码如下: obj网格生成
static void Export()
{
UnityEngine.Debug.Log("NavMesh Export Start");
NavMeshTriangulation navMeshTriangulation = NavMesh.CalculateTriangulation();
string path = "E:/findPath/recastnavigation-master2/recastnavigation/RecastDemo/Bin/Meshes/" + SceneManager.GetActiveScene().name + ".obj";
StreamWriter streamWriter = new StreamWriter(path);
for (int i = 0; i < navMeshTriangulation.vertices.Length; i++)
{
streamWriter.WriteLine("v " + navMeshTriangulation.vertices[i].x + " " + navMeshTriangulation.vertices[i].y + " " + navMeshTriangulation.vertices[i].z);
}
streamWriter.WriteLine("g pPlane1");
for (int i = 0; i < navMeshTriangulation.indices.Length;)
{
streamWriter.WriteLine("f " + (navMeshTriangulation.indices[i] + 1) + " " + (navMeshTriangulation.indices[i + 1] + 1) + " " + (navMeshTriangulation.indices[i + 2] + 1));
i = i + 3;
}
streamWriter.Flush();
streamWriter.Close();
AssetDatabase.Refresh();
UnityEngine.Debug.Log("NavMesh Export Success");
}
bin文件转化
if (showMenu)
{
if (imguiBeginScrollArea("Properties", width-250-10, 10, 250, height-20, &propScroll))
mouseOverMenu = true;
if (imguiCheck("Show Log", showLog))
showLog = !showLog;
if (imguiCheck("Show Tools", showTools))
showTools = !showTools;
imguiSeparator();
imguiLabel("Sample");
if (InputMap)
{
if (showSample)
{
showSample = false;
}
else
{
showSample = true;
showLevels = false;
showTestCases = false;
}
}
imguiSeparator();
imguiLabel("Input Mesh");
if (imguiButton(meshName.c_str()))
{
if (showLevels)
{
showLevels = false;
}
else
{
showSample = false;
showTestCases = false;
showLevels = true;
scanDirectory(meshesFolder, ".obj", files);
scanDirectoryAppend(meshesFolder, ".gset", files);
}
}
if (geom)
{
char text[64];
snprintf(text, 64, "Verts: %.1fk Tris: %.1fk",
geom->getMesh()->getVertCount()/1000.0f,
geom->getMesh()->getTriCount()/1000.0f);
imguiValue(text);
}
imguiSeparator();
if (geom && sample)
{
imguiSeparatorLine();
sample->handleSettings();
if (InputMap==false&&BuildComm==true)
{
ctx.resetLog();
if (!sample->handleBuild())
{
showLog = true;
logScroll = 0;
}
ctx.dumpLog("Build log %s:", meshName.c_str());
delete test;
test = 0;
BuildComm = false;
SaveComm = 1;
}
imguiSeparator();
}
if (sample)
{
imguiSeparatorLine();
sample->handleDebugMode();
}
imguiEndScrollArea();
}
后台调用
void Start()
{
UnityEngine.Debug.Log("当前应用:" + Process.GetCurrentProcess().ProcessName + " 进程ID: " + Process.GetCurrentProcess().Id);
}
void OnGUI()
{
if (GUI.Button(new Rect(10, 10, 200, 50), "寻路地图生成"))
{
Export();
if (File.Exists(@"E:\findPath\recastnavigation-master2\recastnavigation\RecastDemo\Bin\Meshes\灵境胡同站.obj"))
{
if (CheckProcess("RecastDemo"))
return;
else
StartProcess(outputPath);
bool Flag = true;
while (Flag == true)
{
if (File.Exists(@"E:\findPath\recastnavigation-master2\recastnavigation\RecastDemo\Bin\solo_navmesh.bin"))
{
KillProcess("RecastDemo");
break;
}
}
}
}
}
void StartProcess(string ApplicationPath)
{
UnityEngine.Debug.Log("打开本地应用");
Process foo = new Process();
foo.StartInfo.FileName = ApplicationPath;
foo.StartInfo.WindowStyle = ProcessWindowStyle.Hidden;
foo.StartInfo.WorkingDirectory = @"E:\findPath\recastnavigation-master2\recastnavigation\RecastDemo\Bin";
foo.Start();
}
bool CheckProcess(string processName)
{
bool isRunning = false;
Process[] processes = Process.GetProcesses();
int i = 0;
foreach (Process process in processes)
{
try
{
i++;
if (!process.HasExited)
{
if (process.ProcessName.Contains(processName))
{
UnityEngine.Debug.Log(processName + "正在运行");
isRunning = true;
continue;
}
else if (!process.ProcessName.Contains(processName) && i > processes.Length)
{
UnityEngine.Debug.Log(processName + "没有运行");
isRunning = false;
}
}
}
catch (Exception ep)
{
}
}
return isRunning;
}
void ListAllAppliction()
{
Process[] processes = Process.GetProcesses();
int i = 0;
foreach (Process process in processes)
{
try
{
if (!process.HasExited)
{
UnityEngine.Debug.Log("应用ID:" + process.Id + "应用名:" + process.ProcessName);
}
}
catch (Exception ep)
{
}
}
}
void KillProcess(string processName)
{
Process[] processes = Process.GetProcesses();
foreach (Process process in processes)
{
try
{
if (!process.HasExited)
{
if (process.ProcessName == processName)
{
process.Kill();
UnityEngine.Debug.Log("已杀死进程");
}
}
}
catch (System.InvalidOperationException)
{
}
}
}
4. 路径计算
使用recastnavigation计算路径主要有以下几个步骤:
- 寻路初始化:RecastInterface.Init();
- 加载bin格式地图:RecastInterface.LoadMap(10001, cc);
- 寻路计算:RecastInterface.FindPath(10001, spos, epos)
- 路径平滑化:float[] smooths = RecastInterface.GetPathSmooth(10001, out smoothCount);
- 路径导出:Vector3 node = new Vector3(-smooths[i * 3], smooths[i * 3 + 1], smooths[i * 3 + 2]);
IEnumerator FindPathRecast()
{
agent.enabled = true;
yield return null;
Vector3 spos = new Vector3(5.3f, 11.7f, 8.87f);
Vector3 epos = new Vector3(14.91f, 0f, 7f);
RecastInterface.Init();
string ss = @"E:\findPath\recastnavigation-master2\recastnavigation\RecastDemo\Bin\solo_navmesh.bin";
char[] cc = ss.ToCharArray();
RecastInterface.LoadMap(10001, cc);
if (RecastInterface.FindPath(10001, spos, epos))
{
RecastInterface.GetPathPoly(10001, out int polyCount);
RecastInterface.Smooth(10001, 0.1f, 0.5f);
{
int smoothCount;
float[] smooths = RecastInterface.GetPathSmooth(10001, out smoothCount);
List<Vector3> results = new List<Vector3>();
for (int i = 0; i < smoothCount; ++i)
{
Vector3 node = new Vector3(-smooths[i * 3], smooths[i * 3 + 1], smooths[i * 3 + 2]);
results.Add(node);
}
points = results.ToArray();
for (int i = 0; i < points.Length; i++)
{
Debug.Log("(" + points[i].x.ToString() + "," + points[i].y.ToString() + "," + points[i].z.ToString() + ")");
}
}
Debug.Log("路径计算成功!");
}
else
{
Debug.Log("路径计算失败!");
}
}
控制移动
该过程可直接复用上一部分的代码,完成控制移动过程。 第二种方案,使用recastnavigation进行寻路,在简单场景测试和灵境胡同站场景中均通过了测试,可以实现同步功能。因此,第三种方案我们仅进行了简单调研,其所能达到的效果与recastnavigation类似,但实现成本比较高,该方案被放弃。
相机干涉问题
为了解决该问题,我们修改了实现方法,从控制相机移动改为将相机挂载到物体上,控制物体移动,使相机跟随。同时,使用帧同步框架控制带有相机的物体生成,从而可以为每个客户端提供独立的视角。其实施流程分为两部分:
- 相机生成
switch (opt.cmd)
{
case Command.Input:
if (!players.ContainsKey(opt.name))
{
var p1 = Instantiate(player);
p1.name = opt.name;
if (p1.name == ClientBase.Instance.Identify)
{
var cam = FindObjectOfType<ARPGcamera>();
if (cam == null)
cam = UnityEngine.Camera.main.gameObject.AddComponent<ARPGcamera>();
cam.target = p1.transform;
}
players.Add(opt.name, p1);
}
var p = players[opt.name];
p.UpdateLogic(opt);
break;
}
- 移动控制
internal void UpdateLogic(Operation opt)
{
if (Input.GetKeyDown(KeyCode.Q))
{
AttackID = 1;
}
if (opt.index == 1)
{
var skill = Instantiate(skillObj, transform.position + new Vector3(0, 1f, 0), transform.rotation);
skill.actor = this;
GameScene.Instance.skills.Add(skill);
}
transform.Translate(opt.direction * 0.5f);
}
- 相机跟随移动
using UnityEngine;
public class ARPGcamera : MonoBehaviour
{
public Transform target;
public float targetHeight = 1.2f;
public float distance = 4.0f;
public float maxDistance = 20;
public float minDistance = 1.0f;
public float xSpeed = 500.0f;
public float ySpeed = 120.0f;
public float yMinLimit = -10;
public float yMaxLimit = 70;
public float zoomRate = 80;
public float rotationDampening = 3.0f;
public float x = 20.0f;
public float y = 0.0f;
public float aimAngle = 8;
public KeyCode key = KeyCode.Mouse1;
protected Quaternion aim;
protected Quaternion rotation;
private Vector3 position;
void LateUpdate()
{
if (!target)
return;
if (Input.GetKey(key)) {
x += Input.GetAxis("Mouse X") * xSpeed * 0.02f;
y -= Input.GetAxis("Mouse Y") * ySpeed * 0.02f;
}
distance -= (Input.GetAxis("Mouse ScrollWheel") * Time.deltaTime) * zoomRate * Mathf.Abs(distance);
distance = Mathf.Clamp(distance, minDistance, maxDistance);
y = ClampAngle(y, yMinLimit, yMaxLimit);
rotation = Quaternion.Euler(y, x, 0);
transform.rotation = rotation;
aim = Quaternion.Euler(y - aimAngle, x, 0);
position = target.position - (rotation * Vector3.forward * distance + new Vector3(0, -targetHeight, 0));
transform.position = position;
}
static float ClampAngle(float angle, float min, float max)
{
if (angle < -360)
angle += 360;
if (angle > 360)
angle -= 360;
return Mathf.Clamp(angle, min, max);
}
}
场景更新问题
迁移的实体、预制体、脚本
实体或预制体 | 脚本 |
---|
Canvas 1 | StartBattle.cs | GameObject | ClientManager.cs, GameScene.cs, CallApplication.cs | Camera | Player.cs | Man 1 | Enemy.cs | Main Camera | NewGlobalSettings.cs |
路径配置
名称 | 位置 | 变量 |
---|
RecastDemo路径 | CallApplication.cs | private static string outputPath | obj地图模型路径 | CallApplication.cs :: void OnGUI() | string objPath | bin地图模型路径 | CallApplication.cs :: void OnGUI() | string binPath | 工作目录路径 | CallApplication.cs :: void StartProcess(string ApplicationPath) | foo.StartInfo.WorkingDirectory | Meshes路径 | CallApplication.cs :: static void Export() | string path |
最终效果
帧同步寻路
框架可以保证多个客户端寻路路径同步、时间同步
加速同步
新客户端接入后框架可以进行加速渲染,驱动场景迅速同步到与所有客户端一致的画面
|