如何将直播视频推流的拖放操作嵌入 VR (Oculus Quest)

远程操作就一定很无聊吗?!你有没有想象过在 VR 中和朋友聊天?或是通过 VR 中了解另外一个世界?或是从工作无缝转换到 VR App 去逛一个 VR 贸易展?你是否对 Oculus Quest 感兴趣,却无从下手?

本指南能帮你迅速了解如何集成 Oculus Quest 并为声网直播视频推流创建一个拖放方案,此方案由声网低延迟全球网络提供支持。


安装 Fest

你需要:

Unity 编辑器 (Android 支持)

声网开发者账户

Oculus 开发者帐户

Oculus Quest


开始

首先,打开 Unity,创建一个名为声网 Quest Demo 的空白项目。


切换构建平台

因为我们是为 Oculus Quest 构建应用,所以要将平台更改为 Android,方法是 File > Build Settings,然后在 Platform 选项下选择 Android。

请务必将 Texture Compression (纹理压缩)设置为 ASTC。


安装 Fest

接下来,找到 Unity Asset Store(在场景视图中,单击 Asset Store 选项卡)导入并下载两个 asset:Oculus Integration声网 Video Chat SDK。Oculus 负责许多在 VR 领域起步的重要工作,声网则在全球通信方面提供支持。

下载 Oculus Integration 软件包时,系统会提示你更新 Spatializer 插件并重启你的 Unity Editor。请单击 Ok 并进行重启。


修改项目设置

首先,我们需要在播放器设置(Player Settings)中更改一些参数,因此找到 File > Build Settings > Player Settings,在 inspector 顶部,更新 Company Name 和 Product Name。

接下来,转到 Other Settings,取消 Auto Graphics API,也取消 Vulkan。

现在更改 Package Name 来匹配 Company 和 Project (你之前设置的),并将 Minimum API Level (最低 API 等级)更改为 API level 21。

最后,滑动鼠标到底部的 XR Settings。选中 Virtual Reality Supported,然后在 Virtual Reality SDKs 下选择 Oculus。


制作场景

这次我们不制作场景,而是使用在 Assets>Oculus>VR>Scenes>Room 中找到的 Oculus 预制场景,我们对预制场景进行修改,使其符合我们的需求。打开房间场景,这是一个装饰明亮的空房间,并配有一些简单的掌形。对于主要用于通信的场景来说,这是一个完美的起始场景。我会去掉几堵墙来打造我自己的场景,然后创建一个 Empty GameObject,作为我的视频屏幕的位置占位符。我将此对象命名为 VideoSpawn,然后将位置放置到摄像机的正前方,Z 位置为 1.92。


创建新素材

我们将在平面对象上显示传入的实时视频推流。我们将在平面上定义自己的素材。在 Resources folder 中,创建一个新素材 (Material) 并将其命名为 PlaneMaterial。选择 Mobile/Unlit 作为其着色器。

注意:如果跳过此步骤,那么在远程用户的实时视频推流启动时,你的 Plane 对象上会显示一个粉红色的 Texture。


创建一个声网拖放预制实例

我们需要一种让 App 与声网进行网络对话的方法,最快捷的方法是创建一个声网预制件,就是通过在场景中创建一个 Empty GameObject 并将其命名为 AgoraInstance。然后使用下面的代码片段并将其添加到新的 AgoraInstance 中。

using System.Collections;
using UnityEngine;
using UnityEngine.UI;
#if (UNITY_2018_3_OR_NEWER)
using UnityEngine.Android;
#endif
using agora_gaming_rtc;

public class QuestInterface : MonoBehaviour
{
    // PLEASE KEEP THIS App ID IN SAFE PLACE
    // Get your own App ID at https://dashboard.agora.io/
    [SerializeField]
    private string appId = "AGORA-APP-ID-HERE";
    [SerializeField]
    private string roomName = "room1";

    private int numUsers = 0;
    private bool connected;


    private static QuestInterface _AgoraInstance;

    public static QuestInterface Instance { get { return _AgoraInstance; } }


    private void Awake()
    {
        if (_AgoraInstance != null && _AgoraInstance != this)
        {
            Destroy(this.gameObject);
        }
        else
        {
            _AgoraInstance = this;
        }
        InitUI();
    }

    // Use this for initialization
    private ArrayList permissionList = new ArrayList();

    // Start is called before the first frame update
    void Start()
    {
#if (UNITY_2018_3_OR_NEWER)
        permissionList.Add(Permission.Microphone);
#endif
    }

    void InitUI()
    {
        GameObject.Find("QuitButton").GetComponent<Button>().onClick.AddListener(OnQuit);
    }
    private void CheckPermission()
    {
#if (UNITY_2018_3_OR_NEWER)
        foreach (string permission in permissionList)
        {
            if (Permission.HasUserAuthorizedPermission(permission))
            {
                if (!connected)
                    onJoinRoomClicked();
            }
            else
            {
                Permission.RequestUserPermission(permission);
            }
        }
# endif
    }

    // Update is called once per frame
    void Update()
    {
#if (UNITY_2018_3_OR_NEWER)
        CheckPermission();
#endif
    }

    private void onJoinRoomClicked()
    {
        if (!connected)
        {
            connected = true;
            loadEngine();
        }
        join(roomName);
        onSceneHelloVideoLoaded();
    }



    public void onLeaveButtonClicked()
    {

        if (connected)
        {
            leave();
            unloadEngine();
            connected = false;
        }
    }

    void OnApplicationPause(bool paused)
    {
        if (paused)
        {
            if (IRtcEngine.QueryEngine() != null)
            {
                IRtcEngine.QueryEngine().DisableVideo();
            }
        }
        else
        {
            if (IRtcEngine.QueryEngine() != null)
            {
                IRtcEngine.QueryEngine().EnableVideo();
            }
        }
    }

    void OnApplicationQuit()
    {
        IRtcEngine.Destroy();
    }


    // load agora engine
    public void loadEngine()
    {
        // start sdk
        Debug.Log("initializeEngine");
        if (mRtcEngine != null)
        {
            Debug.Log("Engine exists. Please unload it first!");
            return;
        }

        // init engine
        mRtcEngine = IRtcEngine.getEngine(appId);

        // enable log
        mRtcEngine.SetLogFilter(LOG_FILTER.DEBUG | LOG_FILTER.INFO | LOG_FILTER.WARNING | LOG_FILTER.ERROR | LOG_FILTER.CRITICAL);
    }

    // unload agora engine
    public void unloadEngine()
    {
        Debug.Log("calling unloadEngine");

        // delete
        if (mRtcEngine != null)
        {
            IRtcEngine.Destroy();
            mRtcEngine = null;
        }
    }

    public void join(string channel)
    {
        Debug.Log("calling join (channel = " + channel + ")");
        if (mRtcEngine == null)
            return;

        // set callbacks (optional)
        mRtcEngine.OnJoinChannelSuccess = onJoinChannelSuccess;
        mRtcEngine.OnUserJoined = onUserJoined;
        mRtcEngine.OnUserOffline = onUserOffline;

        // enable video
        mRtcEngine.EnableVideo();

        // allow camera output callback
        mRtcEngine.EnableVideoObserver();

        // join channel
        mRtcEngine.JoinChannel(channel, null, 0);


        Debug.Log("initializeEngine done");
    }

    public void leave()
    {
        Debug.Log("calling leave");

        if (mRtcEngine == null)
            return;

        // leave channel
        mRtcEngine.LeaveChannel();
        // deregister video frame observers in native-c code
        mRtcEngine.DisableVideoObserver();

    }

    public string getSdkVersion()
    {
        return IRtcEngine.GetSdkVersion();
    }

    // accessing GameObject in Scnene1
    // set video transform delegate for statically created GameObject
    public void onSceneHelloVideoLoaded()
    {
        GameObject go = GameObject.Find("VideoSpawn");
        if (ReferenceEquals(go, null))
        {
            Debug.Log("BBBB: failed to find VideoQuad");
            return;
        }
    }

    // instance of agora engine
    public IRtcEngine mRtcEngine;

    // implement engine callbacks
    private void onJoinChannelSuccess(string channelName, uint uid, int elapsed)
    {
        Debug.Log("JoinChannelSuccessHandler: uid = " + uid);
    }

    // When a remote user joined, this delegate will be called. Typically
    // create a GameObject to render video on it
    private void onUserJoined(uint uid, int elapsed)
    {
        Debug.Log("onUserJoined: uid = " + uid);
        // this is called in main thread

        // find a game object to render video stream from 'uid'
        GameObject go = GameObject.Find(uid.ToString());
        if (!ReferenceEquals(go, null))
        {
            return; // reuse
        }

        numUsers++;
        PutUser(uid);
    }

    void PutUser(uint uid)
    { 
        // create a GameObject and assigne to this new user
        GameObject   go = GameObject.CreatePrimitive(PrimitiveType.Plane);
        if (!ReferenceEquals(go, null))
        {
            go.name = uid.ToString();

            // configure videoSurface
            VideoSurface o = go.AddComponent<VideoSurface>();
            o.SetForUser(uid);
            o.SetEnable(true);

            // Adjust view transform
            var videoQuadPos = GameObject.Find("VideoSpawn").transform.position;
            go.transform.position = videoQuadPos + new Vector3(numUsers * 0.95f, 0, 0);
            go.transform.localScale = new Vector3(0.1f, 0.5f, 0.1f);
            go.transform.Rotate(-90.0f, 1.0f, 0.0f);

            AssignShader(go);
        }
    }

    void AssignShader(GameObject go)
    {
        Material material = Resources.Load<Material>("PlaneMaterial");
        MeshRenderer mesh = go.GetComponent<MeshRenderer>();
        if (mesh != null)
        {
            mesh.material = material;
        }
    }
    // When remote user is offline, this delegate will be called. Typically
    // delete the GameObject for this user
    private void onUserOffline(uint uid, USER_OFFLINE_REASON reason)
    {
        // remove video stream
        Debug.Log("onUserOffline: uid = " + uid);
        // this is called in main thread
        GameObject go = GameObject.Find(uid.ToString());
        if (!ReferenceEquals(go, null))
        {
            Destroy(go);
        }
        numUsers--;
    }

    void OnQuit()
    {
#if UNITY_EDITOR
        UnityEditor.EditorApplication.isPlaying = false;
#else
      Application.Quit();
#endif
    }

   
}  

现在,你可以将其拖动到层级结构中,另存为预制件。每当你将其放入场景中时,它都会在激活预制件时自动创建一个声网实例并加入一个场景。

你可以在 Inspector 中添加声网 App ID 和要测试的房间名称。


修改 Android 清单

你需要修改 manifest 文件。由于 Oculus Quest 没有摄像头(没有此 Demo 可用的摄像头),因此我们将删除文件夹 Assets> Plugins> Android> AgoraRtcEngineKit.plugin 的第9行中的摄像头的使用请求(android:name=”android.permission.CAMERA”/>)。


设备测试

现在要进行测试。因为声网不只适用于 Unity,所以你有很多选择。你可以只创建一个样本声网视频场景。如果你用的是 Mac 系统,可以查看这里。如果是 windows 系统,可以查看这里。为方便起见,也可以使用 Web demo 以远程用户身份进行测试。

现在你已经有了一些实时视频,返回 Unity Editor 并选择 File> Build Settings。将修改后的房间场景拖放到 Scenes to Build 一栏。注意,新的 Oculus 有自己的构建脚本,因此你不能只要在 Unity Editor Build 窗口上单击 Build And Run,而是要转到 Oculus 菜单,然后选择 Build And Run。屏幕截图如下:

短暂的加载序列后,你的 app 就会部署到 Oculus Quest 头显设备中。

恭喜你!如果你想在 Oculus Quest 头显设备中运行 App 而且无须重建,请在头显设备内部进入 Library>UnkownSources 以查看你的 App。


其他资源

适用于 Unity 的 声网.io Video SDK 可在 Unity 资产商店中找到。

在多人通话 Demo 获取 Web app 的源代码 。

完整的 API 文档可在声网文档中心中找到

要获得技术支持,请使用声网 Dashboard 提交工单 。

完整的 Oculus 文档可在 Oculus 文档获得。



原文作者:Rick Cheng
原文链接:https://www.agora.io/en/blog/how-to-embed-drag-drop-video-streaming-vr-oculus-quest/
推荐阅读
相关专栏
SDK 教程
127 文章
本专栏仅用于分享音视频相关的技术文章,与其他开发者和声网 研发团队交流、分享行业前沿技术、资讯。发帖前,请参考「社区发帖指南」,方便您更好的展示所发表的文章和内容。