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

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

相关推荐
Σίσυφος19001 小时前
halcon 条形码、二维码识别、opencv识别
前端·数据库
学代码的小前端1 小时前
0基础学前端-----CSS DAY13
前端·css
css趣多多3 小时前
案例自定义tabBar
前端
姑苏洛言4 小时前
DeepSeek写微信转盘小程序需求文档,这不比产品经理强?
前端
林的快手4 小时前
CSS列表属性
前端·javascript·css·ajax·firefox·html5·safari
匹马夕阳4 小时前
ECharts极简入门
前端·信息可视化·echarts
API_technology5 小时前
电商API安全防护:JWT令牌与XSS防御实战
前端·安全·xss
yqcoder5 小时前
Express + MongoDB 实现在筛选时间段中用户名的模糊查询
java·前端·javascript
十八朵郁金香5 小时前
通俗易懂的DOM1级标准介绍
开发语言·前端·javascript
m0_528723816 小时前
HTML中,title和h1标签的区别是什么?
前端·html