Flutter集成Unity实现3D场景交互
Unity 作为3D界的常规开发工具,其学习成本和难度皆低于UE5,是移动端开发常规3D场景的首选途径,也是本篇文章内容涉及到的关键点之一。
本篇文章缘起于一个理疗仪App项目,技术实现方式是 Flutter + Unity + Blender,整个项目涉及到UnityWidget 与 Uinty 场景的开发,涉及的知识面较广泛,近期总结了一些经验,通过这篇文章分享给大家,希望与大家共勉、一起进步。
废话不多说,先看效果:
开发环境
makefile
Flutter: flutter_windows_3.19.6-stable
dart: 3.3.4
Flutter UnityWidget 插件版本:
yaml
flutter_unity_widget: 2022.2.1
Unity 软件版本:
yaml
2022.3.24f1
进入正文之前,先给大家首先简单介绍一下Flutter 的 UnityWidget 组件
插件地址:flutter_unity_widget | Flutter package (pub.dev)
UnityWidget 支持将unity嵌入到flutter的页面中, 组件支持多种回调监听
php
UnityWidget(
fullscreen: false,
onUnityCreated: onUnityCreated,
onUnityMessage: onUnityMessage,
onUnitySceneLoaded: onUnitySceneLoaded,
runImmediately: true,
onUnityUnloaded: (){
debugPrint("onUnityUnloaded");
},
useAndroidViewSurface: false,
borderRadius: const BorderRadius.all(Radius.circular(70)),
),
场景创建结束后会触发 onUnityCreated 回调 ,并携带UnityWidgetController参数,初始化UnityWidgetController对象就是在这个回调中执行
csharp
late UnityWidgetController _unityWidgetController;
void onUnityCreated(controller) async {
///初始化控制器
_unityWidgetController = controller;
}
Unity端发送信息,会触发onUnityMessage 回调,携带 dynamic message参数,因为Unity端的脚本开发语言是C#,为了更方便的发送和接收数据,需要分别在Flutter创建Message消息体和在Unity端创建Message消息体,消息字段参数根据实际情况添加 ,最后通过序列化和反序列化对数据进行解析使用。
项目中因为涉及到与人体模型交互,所以定义的传递的信息会包括点击的人体坐标、身体部位名称、上下左右前后、已绘制的理疗点数量等等
kotlin
///dart 文件
class Message {
int Id;
String X;
String Y;
String BodyPart;
String Msg;
String OtherValue;
Message(this.Id, this.X, this.Y , this.BodyPart , this.Msg , this.OtherValue);
factory Message.fromJson(Map<String, dynamic> json) {
return Message(json['Id'], json['X'], json['Y'] , json['BodyPart'] , json['Msg'] , json['OtherValue']);
}
Map<String, dynamic> toJson() => {
'Id': Id,
'X': X,
'Y': Y,
'BodyPart': BodyPart,
'Msg': Msg,
'OtherValue' : OtherValue,
};
}
///C# 文件
using Newtonsoft.Json;
public class Message
{
public int Id { get; set; }
public string X { get; set; }
public string Y { get; set; }
public string BodyPart { get; set; }
public string Msg { get; set; }
public string OtherValue { get; set; }
public Message(int id, string x, string y , string bodyPart , string msg , string other)
{
this.Id = id;
this.X = x;
this.Y = y;
this.BodyPart = bodyPart;
this.Msg = msg;
this.OtherValue = other;
}
public string ToJson()
{
return JsonConvert.SerializeObject(this);
}
public static Message FromJson(string json)
{
return JsonConvert.DeserializeObject<Message>(json);
}
}
Unity 端发送消息给Flutter端
比如用户通过点击人体模型的胸部,经过Unity端碰撞检测成功后匹配到坐标在人体的某个部位和上下前后左右,然后在对应的人体模型坐标处绘制一个红色的球体,最后发送消息到上层Flutter 告知用户已成功添加疼痛点、Flutter端通过解析回调的消息体参数更新UI显示当前已选择的理疗点数量并判断是否达到数量限制
ini
Message message = new Message(0, 0.025585 , 0.452512 , "chest" , "upper_forward" , 1);
string newMsg = message.ToJson();
UnityMessageManager.Instance.SendMessageToFlutter(newMsg);
Flutter 端解构Unity端发送消息回调
arduino
void onUnityMessage(message) {
Map<String,dynamic> map = jsonDecode(message);
Message receivedMessage = Message.fromJson(map);
///使用解析Unity端发送的信息,根据自己定义的规则解析上面的消息可以得知
///receivedMessage.X X坐标为:0.025585
///receivedMessage.Y Y坐标为:0.452512
///receivedMessage.BodyPart 部位为 chest 对应胸部
///receivedMessage.Msg 正面上下半身为 upper_forward 对应上半身正面
///receivedMessage.OtherValue 其他参数为1 对应理疗点数量
}
接下来就是如何创建Unity项目关联到当前的Flutter项目
在Flutter项目目录下创建Unity项目
1、在Flutter项目的根目录下新建unity文件夹
2、打开Unity Hub 选择新建项目,项目路径选择第一步中创建的unity文件夹路径
3、项目创建完成之后,前往Flutter UnityWidget 插件的仓库下载unity集成Flutter需要的unitypackages,官方提供了3个版本
下载地址:flutter-unity-view-widget/unitypackages at master · juicycleff/flutter-unity-view-widget · GitHub
4、Unity 中添加第3步下载的unitypackages
5、导入成功后可以在project 栏看到对应的示例FlutterUnityIntegration文件夹,顶部菜单栏也会新增一个Flutter的导出模块,到这里集成就完成了。
6、配置Unity项目设置
上图右上角的0 1 2 3 4 代表对应场景的位置,0则是启动显示的第一个场景,下面的Add Open Scenes 按钮适用于项目中新建的场景还没添加到场景集合中,可以选中拖拽调整对应场景的顺序
Graphics APIs 根据设备的实际情况进行选择,默认是Vulkan,能够提供更高的性能,而OpenGLES3 则能提供更换的兼容性,其他的配置根据实际情况调整
选择支持的设备Cpu和Android 版本 以及脚本编译方式
7、点击Flutter 菜单栏的 Export Android(Release) 等待编译导项目到Flutter
Unity核心场景与交互
一、Unity场景搭建
第一步:场景控制
在Assets/Scene 目录下通过菜单右键Create > Scene ,就可以成功创建一个场景了
接着在场景中添加一个空的游戏对象,当前场景在项目中类似于待机页面,创建一个c#脚本挂载在空的游戏对象上,用于接收Flutter端发送的消息,这里的主要作用是用于处理场景切换,比如:App端在用户扫码登录之后获取用户性别,将性别信息发送到Unity层,App跳转模型展示页面,App页面开始会先显示上面的空场景,相机的颜色设为白色,App的Unity组件就行显示白色,空游戏对象上挂载的脚本收到消息后切换到对应性别的模型场景(也可以在跳转页面之前切换场景)
arduino
///切换场景,MaleScene 对应创建的场景名称
SceneManager.LoadScene("MaleScene");
第二步:模型选点
男性或女性模型的差别只在于模型外观,都支持 双指横移缩放、单指左右、上下旋转和点击。
App端使用UnityWidget组件铺满整个Body,用户进行选点
less
@override
Widget build(BuildContext context) {
return PopScope(
canPop: false,
onPopInvoked: (didPop){
return ;
},
child: Scaffold(
backgroundColor: const Color(0xFFF3EFEB),
body : Stack(
children: [
Positioned.fill(
child: UnityWidget(
fullscreen: false,
onUnityCreated: onUnityCreated,
onUnityMessage: onUnityMessage,
onUnitySceneLoaded: onUnitySceneLoaded,
runImmediately: true,
onUnityUnloaded: (){},
useAndroidViewSurface: false,
borderRadius: const BorderRadius.all(Radius.circular(70)),
),
),
],
),
),
);
}
选点逻辑:
选点操作整个过程是实时的,所以整个过程的逻辑处理都是在void Update(){} 方法中执行,并且只在单指点击的时候才会执行后续逻辑
1、先判断手指数量
kotlin
///手指数量不等于1 结束
if(Input.touchCount != 1)
{
return;
}
2、检测碰撞
ini
///单指并且手指离开屏幕视为选点结束
if(Input.touchCount == 1 && touch.phase == TouchPhase.Ended){
// 从摄像机发射射线
Camera cam = Camera.main;
Ray ray = cam.ScreenPointToRay(touch.position);
RaycastHit hit;
if (Physics.Raycast(ray, out hit)){
///检测到碰撞获取碰撞点坐标
Vector3 point = hit.point;
///设置坐标值精度
string formattedXValue = String.Format("{0:0.000000}", hit.textureCoord.x);
string formattedYValue = String.Format("{0:0.000000}", hit.textureCoord.y);
//检查碰撞体是否包含MeshColider,如果没有MeshColider => textureCoord 会是0,那么碰撞将没有意义
Renderer render = hit.transform.GetComponent<Renderer>();
MeshCollider meshCollider = hit.collider as MeshCollider;
if (render == null || meshCollider == null){
Debug.Log("碰撞体不包含MeshColider");
return;
}
}
}
场景中的主相机Tag设置为MainCamera
3、匹配碰撞点最近的Uv坐标
因为模型精度的缘故,总的三角点数量只有21000多个,碰撞点可能不在三角点上,所有需要匹配到离碰撞点最近的Uv坐标进行代替
前提: 模型包含Mesh网格,如下图
获取mesh数据
ini
MeshFilter meshFilter = GetComponent<MeshFilter>();
Mesh mesh = meshFilter.mesh;
匹配最近Uv顶点坐标
因为直接导入Fbx模型(多个部分组成)到场景中是无法直接控制整体的,比如旋转、缩放等等操作,所以目前的解决方案是:新建一个空的游戏对象(命名为:control),然后将模型挂载在这个control上,control位于模型的中心,直接操作control达到控制模型的效果。
因此,进行坐标匹配前需要对坐标进行转换,先获取父组件的变换矩阵 , 然后将顶点坐标转换到父组件的坐标系中,从而得到对应的坐标
ini
public Float3Vector getNearUvTopPoint(Float3Vector orignalUv , Vector3 point , Mesh mesh){
// 获取网格的顶点UV坐标和3D坐标
Vector2[] uvs = mesh.uv;///所有Uv顶点坐标
Vector3[] vertices = mesh.vertices;
float minDistance = float.MaxValue;
Float3Vector nearestUV = orignalUv;
for (int i = 0; i < vertices.Length; i++)
{
Vector3 parentVertex = parentVecterTransform(vertices[i]);//转为父坐标系的坐标
float distance = Vector3.Distance(point,parentVertex );
if (distance < minDistance)
{
minDistance = distance;
nearestUV = new Float3Vector(mesh.uv[i].x ,mesh.uv[i].y);
}
}
// 输出最近的顶点的UV坐标
Debug.Log("最近碰撞顶点UV坐标: " + nearestUV);
return nearestUV;
}
///关键之处
public Vector3 parentVecterTransform(Vector3 vertex){
// 获取父组件的变换矩阵
Matrix4x4 parentMatrix = model.transform.localToWorldMatrix;
// 将顶点坐标转换到父组件的坐标系中
Vector3 transformedVertex = parentMatrix.MultiplyPoint(vertex);
return transformedVertex;
}
4、身体部位匹配、上下半身匹配 、 前后匹配
准备工作:根据人体身体部位大概划分区域、上下、前后
上图是Fbx格式人体模型的Uv形态,在Blender上通过python脚本批量输出坐标集 ,比如 head.txt , hand.txt , foot.txt , 下方有Python 脚本参考。
Unity脚本,大部分都是通过新建类继承自MonoBehaviour , Flame 框架创建游戏场景也是如此
scala
//Flame创建游戏场景时需要继承FlameGame
class TestGame extends FlameGame with HasGameRef{
@override
void update(double dt) {
super.update(dt);
}
@override
Future<void> onLoad() async {}
@override
Future<void> render (Canvas canvas) async {
super.render(canvas);
}
}
csharp
//创建Unity脚本则需要集成MonoBehaviour
class UnityEngine.MonoBehaviour
MonoBehaviour is a base class that many Unity scripts derive from
public class MyScript : MonoBehaviour {
void Start(){
//初始化
}
void Update(){
///每帧执行
}
}
创建单例读取本地的身体Uv坐标文件并解析
csharp
public class UvCheckInstance : MonoBehaviour
{
private static readonly object padlock = new object();
private static UvCheckInstance instance;
private UvCheckInstance(){}
public static UvCheckInstance Instance
{
get
{
lock (padlock)
{
if (instance == null)
{
//instance = new UvCheckInstance();
// 尝试在场景中找到单例对象
instance = FindObjectOfType<UvCheckInstance>();
// 如果场景中没有找到,则创建一个新的实例
if (instance == null)
{
GameObject go = new GameObject("UvCheckInstance");
instance = go.AddComponent<UvCheckInstance>();
// 确保单例在加载新场景时不被销毁
DontDestroyOnLoad(go);
}
}
return instance;
}
}
}
private void Awake()
{
if (instance != null && instance != this)
{
Destroy(this.gameObject);
return;
}
instance = this;
DontDestroyOnLoad(this.gameObject);
}
///解析.txt文件 => fileName.txt文件 在项目Resources文件夹中的名字,txt文件保存对应身体部位的所有Uv坐标值
public void parseFile(String fileName , List<UvMap> target){
// 获取Assets文件夹下的txt坐标文件路径
TextAsset fileAsset = Resources.Load<TextAsset>(fileName);
// 检查文件是否存在
if(target != null){
target.Clear();
}
if (fileAsset != null)
{
// 解析文件内容并转换为List<Vector2>,模型身体部位划分坐标是通过 Blender软件和Python脚本进行输出的,格式:x,y\n,下方有python脚本
string[] lines = fileAsset.text.Split('\n'); // 按换行符分割字符串
foreach (string line in lines)
{
try
{
///判断是否包含逗号
if(line.Contains(',') == false){
continue ;
}
string[] values = line.Split(','); // 按逗号分割字符串
float x = float.Parse(values[0], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture);
float y = float.Parse(values[1], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture);
string uvMapKey = FormatDecimal(x,4).ToString("0.0000").Substring(2) + FormatDecimal(y,4).ToString("0.0000").Substring(2);///取小数0. 后面的四位
target.Add(new UvMap(uvMapKey, new Float3Vector(x, y)));
}
catch (FormatException e)
{
Console.WriteLine("Error parsing float: " + e.Message);
return;
}
catch (OverflowException e)
{
Console.WriteLine("Value is out of range: " + e.Message);
return;
}
}
uvCount += target.Count;
writeDebugText("UV顶点数据:"+ fileName + "UV坐标数量:" + target.Count);
}
else
{
Debug.LogError(fileName + " file not found");
}
Debug.Log("UV坐标总数量:" + uvCount);
}
}
///因为需要z坐标匹配,且Vector2存在精度问题,所以封装一下
public class UvMap{
public String uvKey { get; set; }
public Float3Vector vectors { get; set; }
public UvMap(String key, Float3Vector values)
{
uvKey = key;
vectors = values;
}
}
public class Float3Vector
{
public float X { get; set; }
public float Y { get; set; }
public Float3Vector(float x, float y)
{
X = x;
Y = y;
}
public override string ToString()
{
return $"({X}, {Y})";
}
}
通过Blender和Python脚本,批量输出模型Uv坐标
python
import bpy
import bmesh
# 获取活动对象
obj = bpy.context.active_object
# 确保对象是网格类型
if obj.type == 'MESH':
# 切换到对象模式以操作网格数据
bpy.ops.object.mode_set(mode='OBJECT')
# 创建BMesh对象
me = obj.data
bm = bmesh.new()
bm.from_mesh(me)
dicUV = {}
# 确保UV层存在
# 确保UV层存在
if bm.loops.layers.uv:
uv_layer = bm.loops.layers.uv.active
# 文件保存路径 改为本地电脑window端桌面路径
# 打开文件写入UV坐标和3维坐标
with open("/Users/fj/Desktop/newUv.txt", "w") as f:
for face in bm.faces:
for loop in face.loops:
vert = loop.vert
if vert.select: # 检查顶点是否被选中
uv = loop[uv_layer].uv
# 获取3维坐标
co = vert.co
key = f"{uv.x}, {uv.y}"
if key not in dicUV:
# 写入UV坐标和3维坐标
f.write(f"{uv.x:.6f},{uv.y:.6f}\n")
dicUV[f"{uv.x}, {uv.y}"] = "mark"
print("Selected UV and vertex coordinates have been exported to uv_coordinates3.txt")
else:
print("No UV layer found")
# 释放BMesh对象
bm.free()
else:
print("Active object is not a mesh")
修改上面脚本文件保存的路径,然后复制脚本到Blender的脚本栏,在Uv 编辑栏选择需要输出的Uv坐标点或者左边选择需要输出的Uv坐标点,然后点击右边的三角符号运行脚本,就可以将选中的所有Uv坐标值输出到保存的文件中,得到多个身体部位Uv坐标集
标准的纹理贴图展开Uv二维坐标范围都是在(0,0)到 (1,1)之间,正常是不会超过这个范围
上述工作准备完毕之后,就可以开始匹配部位和判断上下、前后了。
point: 经过匹配最近Uv坐标的值
vertices: 对应身体部位文件解析出来的列表数据
考虑到效率的问题,vertices 里的每一项都是封装的Map格式,包含一个由x、y坐标拼接的uvKey, 一个float类型的Vecter2 ,这里使用封装的Float3Vector代替,匹配时只需匹配uvKey即可
csharp
public class UvMap{
public String uvKey { get; set; }
public Float3Vector vectors { get; set; }
public UvMap(String key, Float3Vector values)
{
uvKey = key;
vectors = values;
}
}
public class Float3Vector
{
public float X { get; set; }
public float Y { get; set; }
public Float3Vector(float x, float y)
{
X = x;
Y = y;
}
public override string ToString()
{
return $"({X}, {Y})";
}
}
ini
bool IsPointInPolygon(Float3Vector point, List<UvMap> vertices)
{
float float6X = FormatDecimal(point.X, 4);
float float6Y = FormatDecimal(point.Y, 4);
for (int i = 0; i < vertices.Count; i++)
{
///需要匹配的UvKey
string matchUvMapKey = float6X.ToString("0.0000").Substring(2) + float6Y.ToString("0.0000").Substring(2);
//writeDebugText("匹配UvKey:" + matchUvMapKey + " 遍历UvKey:" + vertices[i].uvKey);
if(vertices[i].uvKey == matchUvMapKey){
return true;
}
continue;
}
return false;
}
这样就可以判断出碰撞点最近的Uv坐标在那个部位列表里,就可以封装数据了。
判断上半身、下半身、正面、背面 的原理是一样的。
5、最后一步、绘制球体
因为需求是最多选择3个点,那么就涉及到数量判断、绘制以及销毁重绘,因为每一个坐标都是唯一的,所以可以使用坐标值拼接成字符串作为球体的名称 , 销毁时也需要用到这个名称
arduino
String renderBallName = point.x + point.y + point.z + "";
销毁已绘制球体
scss
void destoryBall(String name){
//model 为场景中添加的模型对象
if (model.transform.Find(name))
{
// 移除球体
Destroy(model.transform.Find(name).gameObject);
}
}
绘制球体,先创建一个预设球体 ball ,将ball 拖到脚本spherePrefab参数上,注意spherePrefab 需要设为public 才会显示在场景参数里
scss
public GameObject spherePrefab; // 球体预设
private void drawBall(Vector3 point, Float3Vector uv ,string backOrForward){
//数量达到限制,销毁最后一个重新绘制球体
//ballLimit 由Flutter层控制,初始为1
if(circleList.Count >= ballLimit ){
destoryBall(circleList[circleList.Count - 1]);
circleList.RemoveAt(circleList.Count - 1);
}
String renderName = point.x + point.y + point.z + "";
circleList.Add(renderName);
//在模型点击位置改为生成球体
GameObject sphere = Instantiate(spherePrefab, point, Quaternion.identity);
//以点击的坐标命名球体
sphere.name = renderName;
// 将sphere附加到模型上
sphere.transform.parent = model.transform;
//将新创建的球体添加到列表中
//spheres.Add(sphere);
Debug.Log($"球体绘制结束,发送球体绘制部位信息到Flutter ");
///发送消息到上层
sendBodyInfoMessae(bodyPartName , uv.X , uv.Y , backOrForward);
}
另外还有撤销重新选点、服务端返回坐标回显在模型身上、模型预览控制、串口通信、刺激电流控制 等等功能内容较多,待整理好后再分享。