本文内容
在本文中我将介绍如何将Vuforia的视频共享给Agora系统,利用声网提供的免费平台将实时视频内容共享出去。这次的案例中涉及两个技术点,一个是Vuforia的视频接口获取,一个是Agora系统以外部视频源的方式进行实时视频的传输。同时我们也通过这个小案例来熟悉两款SDK的使用。
SDK简介
本篇文章我们将介绍两款SDK:
第一款是声网的SDK,主要功能是进行实时视音频的传输、消息传输,兼容 iOS、Android、Windows、macOS、Web、小程序、RTOS、Flutter、Electron、React Native、Unity、Unreal 等 20 大开发平台。对于注册用户,每个月均有10000分钟的免费时长,这对于普通开发者进行如那件的开发和测试已经是足够的了,并且实测在4G网络的情况下端到端延迟<400ms,开发测试也是十分良好的体验,同时还有免费的后台管理系统对应用进行监控,这个后面用到时候再细说。
第二款是Vuforia的SDK,这是一款增强现实的底层SDK,也是可以免费使用的,是以Licence授权形式授权用户使用,对于没有购买Licence的开发者在视频画面的左下角会有“Vufiria”标志,当让对于开发者来说问题不大。
开发环境
主机:windows 10
引擎:Unity 3D 2019.3.1、Visual Studio 2015
实现步骤
我们将通过如下方式进行本次项目的实现。首先Vuforia支持以注册事件的方式回调自身因为AR所独占的视频数据,而Agora本身支持独占相机资源也支持以外部视频源的方式进行直播,我们需要将这两部分结合起来。同时,做这个视频共享的目的还有一个,就是将Unity 3D渲染的3D效果也同步直播出去,这才是最根本的目的。
第一步: 导入SDK
(1)打开Unity 3D,创建工程。进入Unity的官方商城,分别获取到Vuforia的开发SDK和Agora的开发SDK
顺利导入之后,简单看一下结构:将所有相关内容作为一个Package放到一个文件夹是很好的习惯,不用担心里面的Plugins,系统会自动寻找其中的插件。基于Unity平台,目前Agora支持安卓、Iphone、Mac、Win32/64,不过还没有基于Unity3D平台的Html5的插件,期待未来能有实现(毕竟比较擅长Unity,其他不太熟悉)
使用Agora要使用licence,下面看一下注册和licence申请:
注册:输入相关信息
申请licence:成功登录尽可进入项目页面,在项目页面中即可创建项目(记得实名制哦)。
水晶球:项目的详细数据分析,这个功能目前我们还用不到,后面再介绍
(2)再来简单看一下需要配合来做的Vuforia。前面介绍过了,这是一款可以给开发者免费试用(加水印)的增强现实SDK,同样支持多平台的使用。
需要注意的是,我们同样需要申请licence,地址:https://developer.vuforia.com/。
打开“0-Main”场景,在下图的左下角点击“Open Vuforia…”即可进入到相关配置页面,即输入licence的界面。
(3)小结:熟悉了本教程所用的两种SDK及其结构、功能、注册和Licence申请,对于这两种SDK有各种技术能力,感兴趣的同学可以在官方网站多停留一会。
第二步: Vuforia的准备工作
这一步中我们将完成一个AR场景的搭建,完成Vuforia视频的获取、屏幕截屏作为后期Agora视频源的输入。
(1)搭建AR场景:打开上图中“0-Main”场景,完成licence输入,选中场景中“ImageTarget”,在右侧“Image Target Behaviour”中选中如下内置识别图
(注意:首次选择From Database 会提示导入数据,请不要拒绝^_^,其次记得电脑上查摄像头)。点击运行或者CTRL+P运行,将摄像头对准识别图(识别图在Assets/Editor/Vuforia/ImageTargetTextures/VuforiaMars_Images/)即可看到模型在图片上。
(2)编写代码:简单介绍一下,这个脚本中我们使用了Vuforia提供的注册回调机制,将自身从相机获取的视频共享出来。要注意一下这其中比较重要的像素格式,同样在Agora系统中也会有像素格式的问题。
A:透明度 R:红色 G:绿 B:蓝
ARGB_4444:每个像素占四位,即A=4,R=4,G=4,B=4,那么一个像素点占4+4+4+4=16位
ARGB_8888:每个像素占四位,即A=8,R=8,G=8,B=8,那么一个像素点占8+8+8+8=32位
RGB_565:每个像素占四位,即R=5,G=6,B=5,没有透明度,那么一个像素点占5+6+5=16位
GrayScale:像素占四位,即R=G=B=gray
如下,“TakeScreen”函数是对整个Scene中一个Camera场景进行截图,目的是获取渲染后的相机效果。
using System.Collections;
using System.Collections.Generic;
using agora_gaming_rtc;
using UnityEngine;
using UnityEngine.UI;
using Vuforia;
public class VuforiaImageShare : MonoBehaviour
{
public UnityEngine.UI.RawImage tex;
public UnityEngine.UI.RawImage screenImage;
public Camera renderCamera;
#region PRIVATE_MEMBERS
private PIXEL_FORMAT mPixelFormat = PIXEL_FORMAT.UNKNOWN_FORMAT;
private bool mFormatRegistered = false;
#endregion // PRIVATE_MEMBERS
#region MONOBEHAVIOUR_METHODS
//作为共享的图片数据
Texture2D mTexture;
Texture2D screenShot;
Rect mRect;
private RenderTexture renderTex;
//启用相机画面或者屏幕渲染画面
public bool useCam = true;
void Start()
{
//注册的图片返回格式,有多种格式可选GRAYSCALE/RGBA8888/RGB565等常见格式
#if UNITY_EDITOR
mPixelFormat = PIXEL_FORMAT.RGBA8888; // Need Grayscale for Editor 经测试,在Editor模式同样可以RGBA8888
#else
mPixelFormat = PIXEL_FORMAT.RGB888; // Use RGB888 for mobile
#endif
//注册回调
// Register Vuforia life-cycle callbacks:
VuforiaARController.Instance.RegisterVuforiaStartedCallback(OnVuforiaStarted);
VuforiaARController.Instance.RegisterTrackablesUpdatedCallback(OnTrackablesUpdated);
VuforiaARController.Instance.RegisterOnPauseCallback(OnPause);
#endregion // MONOBEHAVIOUR_METHODS
// 创建一个RenderTexture对象
renderTex = new RenderTexture((int)Screen.width, (int)Screen.height, 0);
}
void Update()
{
StartCoroutine(TakeScreen());
}
public Texture2D GetSharedImage()
{
if (useCam)
return mTexture;
return null;
}
private void OnVuforiaStarted()
{
// Vuforia has started, now register camera image format
if (CameraDevice.Instance.SetFrameFormat(mPixelFormat, true))
{
Debug.Log("Successfully registered pixel format " + mPixelFormat.ToString());
mFormatRegistered = true;
}
else
{
Debug.LogError(
"Failed to register pixel format " + mPixelFormat.ToString() +
"\n the format may be unsupported by your device;" +
"\n consider using a different pixel format.");
mFormatRegistered = false;
}
}
/// Called each time the Vuforia state is updated
/// unitunity
void OnTrackablesUpdated()
{
if (mFormatRegistered)
{
if (useCam)
{
Vuforia.Image image = CameraDevice.Instance.GetCameraImage(mPixelFormat);
if (image != null)
{
/* Debug.Log(
"\nImage Format: " + image.PixelFormat +
"\nImage Size: " + image.Width + "x" + image.Height +
"\nBuffer Size: " + image.BufferWidth + "x" + image.BufferHeight +
"\nImage Stride: " + image.Stride + "\n"
);*/
//当分辨率变化时候,进行调整
if (mTexture == null || mTexture.width != image.Width)
{
mTexture = new Texture2D((int)image.Width, (int)image.Height, TextureFormat.RGBA32, false);
}
byte[] pixels = image.Pixels;
if (pixels != null && pixels.Length > 0)
{
// Debug.Log("\nImage pixels: " + pixels.Length);
/* Debug.Log(
"\nImage pixels: " +
pixels[0] + ", " +
pixels[1] + ", " +
pixels[2] + ", ...\n"
);*/
// toast.text = "x: " + x + "y:" + y + "b:" + bytes.Length;
// 转化为Texture2D
image.CopyToTexture(mTexture);
// mTexture.SetPixels32(ImageToColor32(image));
// mTexture.SetPixels32(image);
// mTexture.Apply();
tex.texture = mTexture;
// byte[] bytes = mTexture.GetRawTextureData();
// ShareCam(image.Width, image.Height, bytes);
}
}
}
}
}
IEnumerator TakeScreen()
{
yield return new WaitForEndOfFrame();
if (renderCamera == null)
yield return 0;
// Debug.Log(_rt.width+"||"+_rt.height);
if (screenShot == null || screenShot.width != renderTex.width)
screenShot = new Texture2D(renderTex.width, renderTex.height, TextureFormat.RGBA32, false);
renderCamera.targetTexture = renderTex;
renderCamera.Render();
RenderTexture.active = renderTex;
screenShot.ReadPixels(new Rect(0, 0, renderTex.width, renderTex.height), 0, 0);
// 重置相关参数,以使用camera继续在屏幕上显示
renderCamera.targetTexture = null;
//ps: camera2.targetTexture = null;
RenderTexture.active = null; // JC: added to avoid errors
screenShot.Apply();
screenImage.texture = screenShot;
}
//RGBA8888 to RGBA32
Color32[] ImageToColor32(Vuforia.Image a)
{
Color32[] r = new Color32[a.BufferWidth * a.BufferHeight];
for (int i = 0; i < r.Length; i++)
{
r[i].b = a.Pixels[i * 3];
r[i].g = a.Pixels[i * 3 + 1];
r[i].r = a.Pixels[i * 3 + 2];
r[i].a = 1;
}
return r;
}
///
/// Called when app is paused / resumed
///
void OnPause(bool paused)
{
if (paused)
{
Debug.Log("App was paused");
UnregisterFormat();
}
else
{
Debug.Log("App was resumed");
RegisterFormat();
}
}
///
/// Register the camera pixel format
///
void RegisterFormat()
{
if (CameraDevice.Instance.SetFrameFormat(mPixelFormat, true))
{
Debug.Log("Successfully registered camera pixel format " + mPixelFormat.ToString());
mFormatRegistered = true;
}
else
{
Debug.LogError("Failed to register camera pixel format " + mPixelFormat.ToString());
mFormatRegistered = false;
}
}
///
/// Unregister the camera pixel format (e.g. call this when app is paused)
///
void UnregisterFormat()
{
Debug.Log("Unregistering camera pixel format " + mPixelFormat.ToString());
CameraDevice.Instance.SetFrameFormat(mPixelFormat, false);
mFormatRegistered = false;
}
}
将以上脚本挂在任一场景中GameObject上,拖入对应的参数脚本运行程序,可以看到左下角为屏幕实时渲染的截图(包含渲染过的3D模型),右下角为实时获取的Vuforia实时回调图片。
(3)小结:至此我们完成了对于Agora数据的录入准备工作,包括视频原始数据和经过渲染的屏幕数据(较重要),后期在视频会议、远程协助等方面可以有比较多的应用。
第三步: Agora应用
我们在这一步将熟悉Agora的流程,跑通demo,学习外部视频源输入的代码。
(1)学习Demo场景。在如下场景中,输入按照之前步骤在agora官网创建项目获得的AppID,点击运行即可进入视频房间(是不是很简单(^▽^))。
下面简单介绍几个重要函数:打开脚本“TestHelloUnityVideo”这里面的“loadEngine”即为Agora引擎重要的初始化步骤。
public void loadEngine(string appId)
{
// 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);
}
下面的“join”函数,为用户加入房间以及各种回调函数的设计:
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);
}
接下来的“leave”函数,切记在程序退出时记得关闭,不然可能会导致一些bug。
(2)接下来,我们要完成Agora系统的外置视频源输入,有以下内容:
(a)设置Agora的SDK为外置视频源模式,在“join”函数中, mRtcEngine.SetExternalVideoSource(true, false);
请确保你是在调用pushExternalVideoFrame前已调用 setExternalVideoSource, 并将参数 pushMode 设为 true ,不然调用本方法后会一直报错。
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();
// 配置外部视频源。
mRtcEngine.SetExternalVideoSource(true, false);
// join channel
mRtcEngine.JoinChannel(channel, null, 0);
// Optional: if a data stream is required, here is a good place to create it
int streamID = mRtcEngine.CreateDataStream(true, true);
}
(b)添加外部视频源推送函数,需要注意的是:
- VIDEO_PIXEL_FORMAT.VIDEO_PIXEL_RGBA;目前已经支持多种视频格式,在unity中不必讲RGBA再转换成BGRA(赞~)
- externalVideoFrame.cropLeft;等四个位置,这里是做视频的裁剪
- externalVideoFrame.rotation = 0;这里在移动端具有重要作用,作为视频的旋转设置
void ShareCam(Texture2D tex)
{
if (tex == null)
return;
byte[] bytes = tex.GetRawTextureData();
int size = Marshal.SizeOf(bytes[0]) * bytes.Length;
// 查询是否存在 IRtcEngine 实例。
IRtcEngine rtc = IRtcEngine.QueryEngine();
if (rtc != null)
{
// 创建外部视频帧。
ExternalVideoFrame externalVideoFrame = new ExternalVideoFrame();
// 设置视频帧 buffer 类型。
externalVideoFrame.type = ExternalVideoFrame.VIDEO_BUFFER_TYPE.VIDEO_BUFFER_RAW_DATA;
// 设置像素格式。
externalVideoFrame.format = ExternalVideoFrame.VIDEO_PIXEL_FORMAT.VIDEO_PIXEL_RGBA;
// 应用原始数据。
externalVideoFrame.buffer = bytes;
// 设置视频帧宽度(pixel)。
externalVideoFrame.stride = tex.width;
// 设置视频帧高度(pixel)。
externalVideoFrame.height = tex.height;
// 设置从哪侧移除视频帧的像素。
externalVideoFrame.cropLeft = 0;
externalVideoFrame.cropTop = 0;
externalVideoFrame.cropRight = 0;
externalVideoFrame.cropBottom = 0;
// 设置视频帧旋转角度: 0、90、180 或 270。
externalVideoFrame.rotation = 0;
// 使用视频时间戳增加 i。
externalVideoFrame.timestamp = i++;
// 推送外部视频帧。
int a = rtc.PushVideoFrame(externalVideoFrame);
}
}
(c)依据第二步我们所获得的视频图片数据源(两种哈),形成完整的Agroa外置视频源系统代码:
using System.Collections;
using System.Collections.Generic;
using agora_gaming_rtc;
using agora_utilities;
using UnityEngine;
using UnityEngine.UI;
using System.Globalization;
using System.Runtime.InteropServices;
public class AgoraVideoExtern : MonoBehaviour
{
public VuforiaImageShare vuforiaShare;
// instance of agora engine
private IRtcEngine mRtcEngine;
//输入创建项目所获得的的id
[SerializeField]
public string appId = "";
//输入视频房间名称
public string roomName="Agora";
public InputField inputChan;
int i = 1000;
// Start is called before the first frame update
void Start()
{
inputChan.text = roomName;
loadEngine(appId);
}
//加入房间按钮
public void JoinBtn()
{
join(roomName);
}
public void loadEngine(string appId)
{
// 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);
}
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();
// 配置外部视频源。
mRtcEngine.SetExternalVideoSource(true, false);
// join channel
mRtcEngine.JoinChannel(channel, null, 0);
// Optional: if a data stream is required, here is a good place to create it
int streamID = mRtcEngine.CreateDataStream(true, true);
}
void ShareCam(Texture2D tex)
{
if (tex == null)
return;
byte[] bytes = tex.GetRawTextureData();
int size = Marshal.SizeOf(bytes[0]) * bytes.Length;
// 查询是否存在 IRtcEngine 实例。
IRtcEngine rtc = IRtcEngine.QueryEngine();
if (rtc != null)
{
// 创建外部视频帧。
ExternalVideoFrame externalVideoFrame = new ExternalVideoFrame();
// 设置视频帧 buffer 类型。
externalVideoFrame.type = ExternalVideoFrame.VIDEO_BUFFER_TYPE.VIDEO_BUFFER_RAW_DATA;
// 设置像素格式。
externalVideoFrame.format = ExternalVideoFrame.VIDEO_PIXEL_FORMAT.VIDEO_PIXEL_RGBA;
// 应用原始数据。
externalVideoFrame.buffer = bytes;
// 设置视频帧宽度(pixel)。
externalVideoFrame.stride = tex.width;
// 设置视频帧高度(pixel)。
externalVideoFrame.height = tex.height;
// 设置从哪侧移除视频帧的像素。
externalVideoFrame.cropLeft = 0;
externalVideoFrame.cropTop = 0;
externalVideoFrame.cropRight = 0;
externalVideoFrame.cropBottom = 0;
// 设置视频帧旋转角度: 0、90、180 或 270。
externalVideoFrame.rotation = 0;
// 使用视频时间戳增加 i。
externalVideoFrame.timestamp = i++;
// 推送外部视频帧。
int a = rtc.PushVideoFrame(externalVideoFrame);
}
}
// implement engine callbacks
private void onJoinChannelSuccess(string channelName, uint uid, int elapsed)
{
Debug.Log("JoinChannelSuccessHandler: uid = " + uid);
// GameObject textVersionGameObject = GameObject.Find("VersionText");
// textVersionGameObject.GetComponent<Text>().text = "SDK Version : " + getSdkVersion();
}
// 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 + " elapsed = " + elapsed);
// 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
}
/*
// create a GameObject and assign to this new user
VideoSurface videoSurface = makeImageSurface(uid.ToString());
if (!ReferenceEquals(videoSurface, null))
{
// configure videoSurface
videoSurface.SetForUser(uid);
videoSurface.SetEnable(true);
videoSurface.SetVideoSurfaceType(AgoraVideoSurfaceType.RawImage);
videoSurface.SetGameFps(30);
}
*/
}
public VideoSurface makePlaneSurface(string goName)
{
GameObject go = GameObject.CreatePrimitive(PrimitiveType.Plane);
if (go == null)
{
return null;
}
go.name = goName;
// set up transform
go.transform.Rotate(-90.0f, 0.0f, 0.0f);
float yPos = Random.Range(3.0f, 5.0f);
float xPos = Random.Range(-2.0f, 2.0f);
go.transform.position = new Vector3(xPos, yPos, 0f);
go.transform.localScale = new Vector3(0.25f, 0.5f, .5f);
// configure videoSurface
VideoSurface videoSurface = go.AddComponent<VideoSurface>();
return videoSurface;
}
private const float Offset = 100;
public VideoSurface makeImageSurface(string goName)
{
GameObject go = new GameObject();
if (go == null)
{
return null;
}
go.name = goName;
// to be renderered onto
go.AddComponent<RawImage>();
// make the object draggable
go.AddComponent<UIElementDragger>();
GameObject canvas = GameObject.Find("Canvas");
if (canvas != null)
{
go.transform.parent = canvas.transform;
}
// set up transform
go.transform.Rotate(0f, 0.0f, 180.0f);
float xPos = Random.Range(Offset - Screen.width / 2f, Screen.width / 2f - Offset);
float yPos = Random.Range(Offset, Screen.height / 2f - Offset);
go.transform.localPosition = new Vector3(xPos, yPos, 0f);
go.transform.localScale = new Vector3(3f, 4f, 1f);
// configure videoSurface
VideoSurface videoSurface = go.AddComponent<VideoSurface>();
return videoSurface;
}
private void onUserOffline(uint uid, USER_OFFLINE_REASON reason)
{
// remove video stream
Debug.Log("onUserOffline: uid = " + uid + " reason = " + reason);
// this is called in main thread
GameObject go = GameObject.Find(uid.ToString());
if (!ReferenceEquals(go, null))
{
Object.Destroy(go);
}
}
public void unloadEngine()
{
Debug.Log("calling unloadEngine");
// delete
if (mRtcEngine != null)
{
IRtcEngine.Destroy(); // Place this call in ApplicationQuit
mRtcEngine = null;
}
}
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();
}
void OnApplicationQuit()
{
leave();
unloadEngine();
}
// Update is called once per frame
void Update()
{
if (mRtcEngine == null)
return;
ShareCam(vuforiaShare.GetSharedImage());
}
}
这一步我们将两个系统进行融合,下图是简单场景编写,详细内容会在附件连接中提供下载。下图为Agroa的AR识别场景,在开启后将可以看到AR场景并开启外部视频源的数据推送。
在此之前,我们需要将Agroa例程中的“SceneHome”输出为exe可运行程序(请记得licence和BuildSetting添加场景)。
导出后,请先运行编辑器中的AR场景,因为要抢占开发电脑的摄像头(agroa默认取第一个摄像头),创建好系统场景;
之后开启事先导出的Agroa的例程场景,输入频道号“Agora”即可,分别看一下只传输视频和传输AR视频的效果
下面是切换了AR渲染后的效果,可以明显看到在右侧客户端画面中,出现了渲染过的3D人物。
待解决的问题
(1)通过传送渲染过的相机,对计算能力的要求,目前本机I7 9700k,1050TI显卡,编辑器内可以达到60帧,是否有更高效的方法?
(2)仔细观察会发现,左侧屏幕中,Vuforia回调的视频画面是上下反转的,而传到左侧,又反转了一次;是什么问题该如何处理;
(3)仔细观察会发现,右侧原始画面在左侧展示时候,虽然压缩了但是明显的也被裁减了,这是为什么呢?
相关资源
论坛:
Agora开发者中心:https://docs.agora.io/cn
AgoraGit仓库:https://github.com/AgoraIO
本次源代码: