学习U3D的主要目的是希望为实验室和自己开发一个虚拟空战的视景系统,经过一个月左右的学习和开发,目前已经完成了初步的版本(完成后又放飞自我了一段时间,自我批评中…),那么这篇主要介绍一下开发的这个系统,记录下思路和实现的基本过程。
本文仅供参考,工程不打算开源。
首先分析下需求。
空战演示有两种类型:一类是从飞机驾驶员的角度进行的,也就是我们从画面上看到的应该是驾驶舱中的重要仪表(多功能显示器、HUD等)和舱外的画面。这类研究中,我们只关注单个飞机上功能的设计和实现。比如,我们可以研究更好的飞机数据显示方案、研究辅助驾驶系统等等。另一类则是从上帝视角进行的,这也是现在比较热点的研究:协同和集群。主要目的是演示多机之间的配合,研究多机协同和决策算法等。
所以,我们希望开发的系统要支持驾驶舱内部视角,支持仪表的开发;也要支持上帝视角,从全局观察多个飞机的情况。
为了让软件功能更丰富,我们希望能够支持多种飞机类型,支持编辑建筑物和山地,支持显示路径规划的结果,支持在屏幕上绘制仪表,支持导弹、机炮射击、爆炸等效果。
总结起来,可以概括为:
这些内容通过U3D都是可以实现的。很多细节也是边开发边想清楚的,上面的讨论只是总体的方向。实际上,开发一个系统,很多时候最初并没有特别清晰的设计,只是有大概的想法和目标,细节方面很多都需要在实践中完善。
个人能力有限,不追求画面多么精致,能够基本支持预期的功能即可。系统的总体结构参考FlightGear软件,也就是基于通信的方式。
仿照FlightGear,让应用程序和视景演示程序分离,中间通过UDP进行连接,这有很多好处:首先软件耦合度低,视景部分完全独立,作为一个exe程序存在,可以放到任何机器上运行,研究的部分则可以使用任何语言开发(目前只提供了C++的接口,可以仿照C++接口实现其它语言的接口,或者包装C++接口),这样可以根据实际需要采用合适的语言,比如研究AI可能就使用Python了;其次,可以实现分布式结构,比如我们研究多机之间对抗,每个飞机在不同的计算机上运行,那么在局域网内,我们用一台机器作为视景环境,其它机器都把数据发送到这个演示的机器上就可以了。
视景软件的使用者只需要了解UDP消息的定义即可,其余都不必关心。因此这样的架构适用性是比较广的。此外,在第一小节中也进行了介绍,就是视景软件支持定制化显示功能,可以通过通信绘制UI界面,从而实现场景中数据的显示,个性化仪表的绘制等功能,可拓展性比较好。
由于不需要进行无线通信,也只在局域网下工作,因此不需要特别考虑通信效率的问题,那么为了便于理解和开发,直接采用了JSON的数据结构,其本质是一个字符串。
数据格式为:
{“msg_id”:…, “msg_type”:…, “msg_body”:{[…:…, ]…}}
其中,…表示省略的内容,根据实际数据填充,[]表示可选项,[]…表示可以重复多次的可选项,大括号和冒号是JSON规定的符号。msg_id字段表示消息的id号码,每条消息原则上应该使用递增的号码,msg_type字段表示消息的类型,msg_body字段表示消息的内容,其本身是json子对象,包含若干个特殊的字段。
需要注意,以上数据格式是字符串,因此在编程中直接写成ASCII字符串,注意字符串中的引号需要转义。
举个例子,对于battle_field_config类型的消息,其消息的格式如下:
"{\"msg_id\":0,\"msg_type\":\"battle_field_config\",\"msg_body\":{\"central_x\":0,\"central_y\":0,\"nswidth\":1000,\"wewidth\":1000}}"
为了直观,我们写出格式化的形式(实际发送还是按照上面的紧凑形式,这个只是便于查看):
{
"msg_id": 0,
"msg_type": "battle_field_config",
"msg_body": {
"central_x": 0,
"central_y": 0,
"nswidth": 1000,
"wewidth": 1000
}
}
这条消息的编号为0,那么下个消息的编号应该为1,消息类型是battle_field_config,也就是战场总体配置,消息体包含战场中心坐标和战场南北、东西范围。
目前系统支持的消息有如下几类:
消息类型 | 功能 |
---|---|
battle_field_config | 战场中心坐标、战场范围 |
building_info | 增/删/缩放/移动一个建筑物 |
mountain_info | 增/删/缩放/移动一个山 |
plane_info | 增/删/移动一个飞机 |
weapon_info_missile | 增/删/移动一个导弹 |
weapon_info_net | 增/删/移动一个拦截网 |
weapon_info_bullet | 某飞机发射子弹 |
weapon_info_laser | 某飞机发射激光 |
explosion | 产生一个爆炸 |
ui_draw_line | ui绘制-线 |
ui_draw_circle | ui绘制-椭圆 |
ui_draw_filledquad | ui绘制-填充矩形 |
ui_draw_text | ui绘制-文字 |
ui_destroy | ui删除 |
scene_draw_line | 3d绘制-线 |
scene_destroy | 3d删除 |
message | 提示信息 |
其中,JSON我们直接采用成熟的库,C++代码使用CJsonObject,C#代码使用SimpleJSON。
首先必须强调,这些东西可以比较容易地用任何语言替换,这里只是举例说明。
为了演示系统的效果,除了接口的部分,还实现了UDP通信、飞机模型(3dof)、导弹模型(3dof)的功能。
代码量应该不超过3000行,很多代码都是以前积累下来的。
UDP通信部分,直接使用了Windows下Socket通信方案,我把它稍微封装了下,使用起来非常简单,首先包含头文件comm_tools.h,然后:
// 0. 定义数据接收线程函数,注意如果接收失败,则 num = -1
void socket_recv_thread_func(char* data, int num){
...}
// 1. 实例化类,下面四种选一个
CSocketTool iSocketTool("127.0.0.1", 5000, TCP_CLIENT); // TCP 客户端
CSocketTool iSocketTool("127.0.0.1", 5000, IP_CLIENT); // UDP 客户端
CSocketTool iSocketTool(5000, TCP_SERVER); // TCP 服务端
CSocketTool iSocketTool(5000, IP_SERVER); // UDP 服务端
// 2. 连接
if (!iSocketTool.Connect()){
cout << "socket connect failed." << endl; exit(0);}
// 3. 创建数据接收线程
if (!iSocketTool.CreateRecvThread(socket_recv_thread))
{
cout << "recv thread create failed." << endl; exit(0);}
// 4. 在需要的地方发送数据
if (!iSocketClient.Send(data)){
cout << "data send failed." << endl; exit(0);}
模型部分,采用了最简单的三自由度模型,使用过载进行控制,并使用龙格库塔法进行插值运算。
核心代码如下:
// 对于固定翼飞机
SPlaneModelState CPlaneModelIn3Dof_FixedWing::Run(double _nx, double _nz, double _ny)
{
// 下面的模型 nz 和 ny 和之前的定义是相反的,所以形参把顺序变了
// 限制过载
if (sqrt(_nx * _nx + _ny * _ny + _nz * _nz) <= this->dMaxOverload){
// 过载柔性更新
nx = alpha * nx + (1 - alpha) * _nx;
ny = alpha * ny + (1 - alpha) * _ny;
nz = alpha * nz + (1 - alpha) * _nz;
}
// 简化变量
double v = sPlaneCurState.v;
double atti[2] = {
sPlaneCurState.pathpitch, sPlaneCurState.pathyaw};
double K[5][4];
double dAtt[3];
double dPos[3];
// 四阶龙格库塔法 - [pitch_dot, yaw_dot, pos_north, pos_up, pos_east]
v = v + dTimeStep * G * nx;
K[0][0] = G*(ny - cos(atti[0]))/v;
K[1][0] = G*nz/v/cos(atti[0]);
K[2][0] = v*cos(atti[0])*cos(atti[1]);
K[3][0] = v*sin(atti[0]);
K[4][0] = -v*cos(atti[0])*sin(atti[1]);
K[0][1] = G*(ny - cos(atti[0] + K[0][0]*dTimeStep/2))/v;
K[1][1] = G*nz/v/cos(atti[0] + K[0][0]*dTimeStep/2);
K[2][1] = v*cos(atti[0] + K[0][0]*dTimeStep/2)*cos(atti[1] + K[1][0]*dTimeStep/2);
K[3][1] = v*sin(atti[0] + K[0][0]*dTimeStep/2);
K[4][1] = -v*cos(atti[0] + K[0][0]*dTimeStep/2)*sin(atti[1] + K[1][0]*dTimeStep/2);
K[0][2] = G*(ny - cos(atti[0] + K[0][1]*dTimeStep/2))/v;
K[1][2] = G*nz/v/cos(atti[0] + K[0][1]*dTimeStep/2);
K[2][2] = v*cos(atti[0] + K[0][1]*dTimeStep/2)*cos(atti[1] + K[1][1]*dTimeStep/2);
K[3][2] = v*sin(atti[0] + K[0][1]*dTimeStep/2);
K[4][2] = -v*cos(atti[0] + K[0][1]*dTimeStep/2)*sin(atti[1] + K[1][1]*dTimeStep/2);
K[0][3] = G*(ny - cos(atti[0] + K[0][2]*dTimeStep))/v;
K[1][3] = G*nz/v/cos(atti[0] + K[0][2]*dTimeStep);
K[2][3] = v*cos(atti[0] + K[0][2]*dTimeStep)*cos(atti[1] + K[1][2]*dTimeStep);
K[3][3] = v*sin(atti[0] + K[0][2]*dTimeStep);
K[4][3] = -v*cos(atti[0] + K[0][2]*dTimeStep)*sin(atti[1] + K[1][2]*dTimeStep);
dAtt[0] = dTimeStep*(K[0][0] + 2*K[0][1] + 2*K[0][2] + K[0][3])/6.0;
dAtt[1] = dTimeStep*(K[1][0] + 2*K[1][1] + 2*K[1][2] + K[1][3])/6.0;
dPos[0] = dTimeStep*(K[2][0] + 2*K[2][1] + 2*K[2][2] + K[2][3])/6.0;
dPos[1] = -dTimeStep*(K[3][0] + 2*K[3][1] + 2*K[3][2] + K[3][3])/6.0;
dPos[2] = -dTimeStep*(K[4][0] + 2*K[4][1] + 2*K[4][2] + K[4][3])/6.0;
// 更新飞机状态
// 限速
if ( v < 0 ) v = 0;
if (v > this->dMaxSpeed) v = this->dMaxSpeed;
sPlaneCurState.v = v;
sPlaneCurState.pathpitch += dAtt[0];
sPlaneCurState.pathyaw += dAtt[1];
// 约束俯仰角,规范偏航角取值
// if(fabs(sPlaneCurState.pathpitch) > PI/2 )sPlaneCurState.pathpitch = sign(sPlaneCurState.pathpitch)*PI/2;
sPlaneCurState.pathpitch = AngleTrimInPI(sPlaneCurState.pathpitch);
sPlaneCurState.pathyaw = AngleTrimInPI(sPlaneCurState.pathyaw);
sPlaneCurState.vx = sPlaneCurState.v * cos(sPlaneCurState.pathpitch) * cos(sPlaneCurState.pathyaw);
sPlaneCurState.vy = sPlaneCurState.v * cos(sPlaneCurState.pathpitch) * sin(sPlaneCurState.pathyaw);
sPlaneCurState.vz = -sPlaneCurState.v * sin(sPlaneCurState.pathpitch);
sPlaneCurState.pitch = sPlaneCurState.pathpitch;
sPlaneCurState.yaw = sPlaneCurState.pathyaw;
double sumN = sqrt(
pow(nx, 2) + pow(ny, 2) + pow(nz, 2)
);
sPlaneCurState.roll = nz / (sumN > 0.01 ? sumN : 0.01) / 4.0 * PI;
sPlaneCurState.roll = sign(sPlaneCurState.roll) * min(PI / 4.0, fabs(sPlaneCurState.roll));
sPlaneCurState.x += dPos[0];
sPlaneCurState.y += dPos[2];
sPlaneCurState.z += dPos[1];
xy_to_latlon(sPlaneCurState.x, sPlaneCurState.y, sPlaneCurState.lat, sPlaneCurState.lon);
sPlaneCurState.alt = -sPlaneCurState.z;
return this->sPlaneCurState;
}
// 对于旋翼飞机
SPlaneModelState CPlaneModelIn3Dof_RotorCraft::Run(double _vx, double _vy, double _vz, double _vyaw)
{
// 限速
if (sqrt(_vx * _vx + _vy * _vy + _vz * _vz) <= this->dMaxSpeed){
// 机体速度柔性更新
vx = alpha * vx + (1 - alpha) * _vx;
vy = alpha * vy + (1 - alpha) * _vy;
vz = alpha * vz + (1 - alpha) * _vz;
_vyaw = AngleTrimInPI(_vyaw);
vyaw = vyaw + (1 - alpha * 0.5) * AngleTrimInPI(_vyaw - vyaw); // 0.5 的目的是加快偏航的变化速率
}
// 机体速度转换为地面速度
this->sPlaneCurState.pathyaw += vyaw * this->dTimeStep;
this->sPlaneCurState.vx = vx * cos(this->sPlaneCurState.pathyaw) - vy * sin(this->sPlaneCurState.pathyaw);
this->sPlaneCurState.vy = vx * sin(this->sPlaneCurState.pathyaw) + vy * cos(this->sPlaneCurState.pathyaw);
this->sPlaneCurState.vz = vz;
this->sPlaneCurState.v = sqrt(
pow(this->sPlaneCurState.vx, 2) +
pow(this->sPlaneCurState.vy, 2) +
pow(this->sPlaneCurState.vz, 2)
);
// 其它角度更新
this->sPlaneCurState.pathpitch = 0;
this->sPlaneCurState.pitch = 0;
this->sPlaneCurState.yaw = this->sPlaneCurState.pathyaw;
this->sPlaneCurState.roll = 0;
this->sPlaneCurState.pathyaw = AngleTrimInPI(this->sPlaneCurState.pathyaw);
this->sPlaneCurState.pathpitch = AngleTrimInPI(this->sPlaneCurState.pathpitch);
this->sPlaneCurState.pitch = AngleTrimInPI(this->sPlaneCurState.pitch);
this->sPlaneCurState.yaw = AngleTrimInPI(this->sPlaneCurState.yaw);
this->sPlaneCurState.roll = AngleTrimInPI(this->sPlaneCurState.roll);
// 位置更新
this->sPlaneCurState.x += this->sPlaneCurState.vx * this->dTimeStep;
this->sPlaneCurState.y += this->sPlaneCurState.vy * this->dTimeStep;
this->sPlaneCurState.z += this->sPlaneCurState.vz * this->dTimeStep;
xy_to_latlon(sPlaneCurState.x, sPlaneCurState.y, sPlaneCurState.lat, sPlaneCurState.lon);
this->sPlaneCurState.alt = -this->sPlaneCurState.z;
return this->sPlaneCurState;
}
消息构造部分,直接封装成为一个类,成员函数输入参数,输出生成的消息字符串,这个消息字符串直接用udp发送出去即可。类的定义为:
class CSimInterface{
// 所有的字符串都是用内置的静态字段
public:
CSimInterface();
// 生成消息
// 战场信息配置:战场中心坐标,南北范围,东西范围
string MsgBattleFieldConfig(double central_x, double central_y, double nswidth = 10000, double wewidth = 10000);
// 每次发送一个建筑的信息
string MsgBuildingInfo(int id, string type, string status, double x, double y, double alt, double nswidth = 5, double wewidth = 20, double height = 50);
// 每次发送一个山的信息
string MsgMountainInfo(int id, string type, string status, double x, double y, double alt, double nswidth = 5, double wewidth = 20, double height = 50);
// 每次发送一个飞机的信息
string MsgPlaneInfo(int id, string name, string type, string status, double x, double y, double alt, double pitch = 0, double roll = 0, double yaw = 0);
// 每次发送一个武器的信息
string MsgWeaponInfo_Missle(int id, string name, string type, string status, double x, double y, double alt, double pitch = 0, double yaw = 0);
// 每次发送一个武器的信息
string MsgWeaponInfo_Net(int id, string name, string type, string status, double x, double y, double alt, double pitch = 0, double yaw = 0);
// 每次发送一个武器的信息 : 方向向量是机体坐标系下的
string MsgWeaponInfo_Bullet(string type, int parrent_id, double vec_x = 0, double vec_y = 0, double vec_z = 0);
// 每次发送一个武器的信息 : 方向向量是机体坐标系下的
string MsgWeaponInfo_Laser(string type, int parrent_id, double vec_x = 0, double vec_y = 0, double vec_z = 0);
// 每次发送一个独立的爆炸信息,该爆炸立即起爆
string MsgExplosion(double scale, double x, double y, double alt);
// 每次发送一条UI绘图信息,用于自定义 UI 显示,以屏幕中心为原点
string MsgUiDrawLine(int groupid, int startx, int starty, int endx, int endy, string color);
string MsgUiDrawCircle(int groupid, int centerx, int centery, int radiusx, int radiusy, string color);
string MsgUiDrawFilledQuad(int groupid, int p1x, int p1y, int p2x, int p2y, int p3x, int p3y, int p4x, int p4y, string color);
string MsgUiDrawText(int groupid, int startx, int starty, string text, string color, int fontSize);
string MsgUiGroupDestroy(int groupid);
// 每次发送一条场景画线信息,用于航机规划结果的显示
string MsgSceneDrawLine(int groupid, double startx, double starty, double startz, double endx, double endy, double endz, string color, double width);
string MsgSceneLineGroupDestroy(int groupid);
// 发送显示在状态栏的消息
string MsgMessage(string message);
// 解析消息 : 确认回令,暂时不需要使用
int MsgReceived(string msg);
int NewObjectId();
private:
int cur_msg_id;
int cur_obj_id;
};
此外,还需要写一些工具函数,比如WSG84坐标和经纬度的转换,机体坐标系和地面坐标系的转换等,具体可以参考飞控书籍。
这一块的工作量很大,大概涉及:场景的构思和搭建、模型的搜索修改和绘制、界面设计和交互模式设计、粒子特效设计、各种功能性脚本的C#代码实现。代码量在5500行左右吧,其中至少1500行代码是从各种地方摘过来的。
本部分就不详细介绍了,下面两个图给出了Hierarchy面板和Project面板中的情况。
远程配置建筑物,移动视角显示:
单机姿态演示:
单机轨迹演示:
比较全面的效果演示见b站视频。
这个项目看起来比较复杂,实际开发起来感觉还好。最后达成的效果已经满足了自己的预想,实现的系统在开发过程中添加了很多之前没有想到的功能,总体来说是非常丰富的,适用于各种研究场景,还可以在这个基础上进行更多的扩展。
学习的过程要和做项目结合才能激励自己向前,反馈明确,目标明确,掌握好节奏,才能最终成事。
(边做边搜索,参考的内容实在太多了,这里罗列一些最重要的吧,主要是教程和参考文档。)
[1].微软C#入门
[2]..NET API
[1].中文手册
[2].英文手册
[3].资源商店
[4].教程1,教程2,教程3,教程4
[5].粒子特效
[6].Shader
[1].3D模型