Flutter集成Unity实现3D场景交互

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);
                  
    }

另外还有撤销重新选点、服务端返回坐标回显在模型身上、模型预览控制、串口通信、刺激电流控制 等等功能内容较多,待整理好后再分享。

相关推荐
EnCi Zheng11 分钟前
M5-markconv自定义CSS样式指南 [特殊字符]
前端·css·python
kyriewen15 分钟前
你的网页慢,用户不说直接走——前端性能监控教你“读心术”
前端·性能优化·监控
广州华水科技15 分钟前
北斗GNSS变形监测在大坝安全监测中的应用与优势分析
前端
前端老石人27 分钟前
前端开发中的 URL 完全指南
开发语言·前端·javascript·css·html
CAE虚拟与现实27 分钟前
五一假期闲来无事,来个前段、后端的说明吧
前端·后端·vtk·three.js·前后端
Sarvartha38 分钟前
三目运算符
linux·服务器·前端
晓晨的博客1 小时前
ROS1录制的bag包转换为ROS2格式
前端·chrome
Wect1 小时前
LeetCode 72. 编辑距离:动态规划经典题解
前端·算法·typescript
donecoding1 小时前
别再让 pnpm 跟着 nvm 跑了!独立安装终极指南
前端·node.js·前端工程化
GISer_Jing1 小时前
AI全栈转型_TS后端学习路线
前端·人工智能·后端·学习