开发者实践:做一个双人视频社交小游戏,“甩头”才能玩

    技术2024-11-28  18

    在 RTC 2020 编程挑战赛春季赛中。我们还有一个获奖团队,思路新颖,开发了一款基于双人视频聊天场景的小游戏——“拿头玩”。在视频聊天过程中即可开启游戏。通过人脸识别算法识别转头方向,实现以“接锅”和“甩锅”为主题的玩法。目前实现了Android版本。

    我们邀请了这支团队,分享了他们的开发历程:

    项目初心

    颈椎问题是困扰所有办公族的难题,大多数人工作中很难有机会能起身动一动,回到家里也会因为疲倦而放弃做一些颈椎康复的运动。所以我们想设计一款游戏,让大家在休息的时候可以通过游戏的形式活动颈椎,舒缓疼痛。我们选择了职场中的“甩锅”和“接锅”的场景,来作为游戏中的元素,希望能增加玩家的代入感。此外,我们还添加了截图分享模块,方便游戏进行传播。

    主要功能

    经过了5天的设计和开发,我们最终完成了《拿头玩》这个作品,下面来分享一下它的主要功能和其中的代码细节。

    视频聊天模块的搭建

    视频聊天模块主要是使用声网的音视频sdk,它可以快速的开发出一个基本的视频对话模块,核心代码如下:

    //onCreate val rtcEngine = RtcEngine.create(this, AppConfig.appKey, object : IRtcEngineEventHandler() { override fun onFirstRemoteVideoDecoded(uid: Int,width: Int,height: Int,elapsed: Int) { setupRemoteVideo(uid) } } //setup private fun setupRemoteVideo(uid: Int) { val remoteView = RtcEngine.CreateRendererView(baseContext) remoteView.setZOrderMediaOverlay(true) container.addView(remoteView) rtcEngine.setupRemoteVideo(VideoCanvas(remoteView, VideoCanvas.RENDER_MODE_HIDDEN, uid)) }

    视频帧数据的获取和处理

    为了进行下一步的人脸识别,我们需要获取到视频帧数据,对帧数据进行预处理。在阅读声网提供的文档和demo后,我们搭建了一个简单的apm-plugin插件,通过这个插件,就可以得到视频聊天过程中的裸数据了。首先我们创建apm-plugin-packet-processing.cpp文件,然后通过CMakeLists.txt配置编译参数:

    cmake_minimum_required(VERSION 3.4.1) add_library( apm-plugin-packet-processing SHARED apm-plugin-packet-processing.cpp) include_directories(../cpp/include) //这里需要导入sdk中的.h文件 ... target_link_libraries( apm-plugin-packet-processing ${log-lib})

    然后我们定义两个jni方法来注册和反注册裸数据的回调:

    JNIEXPORT void JNICALL Java_com_zero_game_utils_frame_VideoFrameHandler_doRegisterProcessing (JNIEnv *env, jobject obj) { if (!rtcEngine) { return; } else { agora::util::AutoPtr<agora::media::IMediaEngine> mediaEngine; mediaEngine.queryInterface(rtcEngine, agora::AGORA_IID_MEDIA_ENGINE); s_packetObserver = *new AgoraVideoFrameObserver(jvm, env, env->NewGlobalRef(obj)); mediaEngine->registerVideoFrameObserver(&s_packetObserver); } } JNIEXPORT void JNICALL Java_com_zero_game_utils_frame_VideoFrameHandler_doUnregisterProcessing (JNIEnv *env, jobject obj) { if (!rtcEngine) { return; } else { agora::util::AutoPtr<agora::media::IMediaEngine> mediaEngine; mediaEngine.queryInterface(rtcEngine, agora::AGORA_IID_MEDIA_ENGINE); s_packetObserver.release(); mediaEngine->registerVideoFrameObserver(nullptr); } }

    agora::media::IVideoFrameObserver这个接口就是声网sdk提供的视频帧回调,只要实现它即可:

    class AgoraVideoFrameObserver : public agora::media::IVideoFrameObserver { public: AgoraVideoFrameObserver() { } AgoraVideoFrameObserver(JavaVM *vm, JNIEnv *env, jobject jobj) { //... } // 获取本地摄像头采集到的视频帧 virtual bool onCaptureVideoFrame(VideoFrame &videoFrame) override { //processVideoFrame(videoFrame) return true; } // 获取远端用户发送的视频帧 virtual bool onRenderVideoFrame(unsigned int uid, VideoFrame &videoFrame) override { return true; } // 获取本地视频编码前的视频帧 virtual bool onPreEncodeVideoFrame(VideoFrame &videoFrame) override { return true; } void release() { //... } };

    由于Android平台中摄像头返回的裸数据是YUV420编码,所以我们还要转换为提供给人脸识别模块的rgba格式才行,最后通过jni方法将数据传递到java层,进行后续的处理:

    int width = videoFrame.width; int height = videoFrame.height; int index = 0; char *rgba = new char[width * height * 4]; unsigned char *ybase = static_cast<unsigned char *>(videoFrame.yBuffer); unsigned char *ubase = static_cast<unsigned char *>(videoFrame.uBuffer);; unsigned char *vbase = static_cast<unsigned char *>(videoFrame.vBuffer);; for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { //YYYYYYYYUUVV u_char Y = ybase[x + y * width]; u_char U = ubase[y / 2 * width / 2 + (x / 2)]; u_char V = vbase[y / 2 * width / 2 + (x / 2)]; int r = static_cast<int>(Y + 1.402 * (V - 128)); if (r > 255) { r = 255; } if (r < 0) { r = 0; } int g = static_cast<int>(Y - 0.34413 * (U - 128) - 0.71414 * (V - 128)); if (g > 255) { g = 255;} if (g < 0) { g = 0; } int b = static_cast<int>(Y + 1.772 * (U - 128)); if (b > 255) { b = 255; } if (b < 0) { b = 0; } rgba[index++] = static_cast<char>(r); //R rgba[index++] = static_cast<char>(g); //G rgba[index++] = static_cast<char>(b); //B rgba[index++] = static_cast<char>(255); } } jbyte buf[width * height * 4]; int i = 0; for (i = 0; i < width * height * 4; i++) { buf[i] = rgba[i]; } jbyteArray jarrRV = env->NewByteArray(width * height * 4); env->SetByteArrayRegion(jarrRV, 0, width * height * 4, buf); env->CallVoidMethod(jobj, jSendMethodId, jarrRV, width, height, videoFrame.rotation); env->DeleteLocalRef(jarrRV);

    人脸识别和方向检测

    人脸识别主要使用的是MLKit,通过Firebase即可简单配置使用,在上一个环节中,我们把源数据通过jni传到了java层,现在我们需要将它转化成bitmap对象然后传给MLKit中提供的VisionFaceDetector。

    val bitmap = Bitmap.createBitmap(color,width,height,Bitmap.Config.ARGB_8888) //裸数据还需要进行旋转和水平翻转 val matrix = Matrix() matrix.postRotate(rotation.toFloat()) matrix.postScale(-1.0f, 1.0f) val rotationBitmap = Bitmap.createBitmap(bitmap, 0, 0, width, height, matrix, true) val image = FirebaseVisionImage.fromBitmap(rotationBitmap) val detect = FirebaseVision.getInstance().getVisionFaceDetector(highAccuracyOpts) detect.detectInImage(image) .addOnSuccessListener { val leftEye = face.getLandmark(FirebaseVisionFaceLandmark.LEFT_EYE) val rightEye = face.getLandmark(FirebaseVisionFaceLandmark.RIGHT_EYE) val nose = face.getLandmark(FirebaseVisionFaceLandmark.NOSE_BASE) //获取到左眼、右眼和鼻子的位置 val leftEyeNose = euclidean(leftEye,nose)//计算鼻子到左眼的距离 val rightEyeNode = euclidean(rightEye,nose)//计算鼻子到右眼的距离 val ratio = min(leftEyeNose,rightEyeNose) / max(leftEyeNose,rightEyeNose) if (ratio > 0.7 && ratio < 1) { //左右眼离鼻子的比例在0.7-1.0之间我们认为没有转头 FaceState.FRONT } else { if (rightHalfFace > leftHalfFace) { //右边眼睛到鼻子距离大于左边的,我们认为转向了左边 FaceState.LEFT } else { //反之右边 FaceState.RIGHT } } }

    实现了转头识别后,配合上UI和动画,我们就可以使游戏中的人偶跟随我们的转头方向运动了。

    游戏流程控制

    由于游戏是在两端同时进行的,所以我们需要进行端对端的数据传递,我们采用的是声网提供的消息传输方案。通过实时传递游戏过程中的指令,对双方游戏画面进行控制,传递的指令包括:游戏开始,游戏结束,向左转头,向右转头,没有转头以及实时分数等。

    //发送方 streamId = rtcEngine.createDataStream(true, true) rtcEngine.sendStreamMessage(streamId, "left".toByteArray()) //接收方 object : IRtcEngineEventHandler override fun onStreamMessage(uid: Int, s: Int, data: ByteArray?) { data?.let { val string = String(it) when (string) { "left" -> { //处理游戏 } "right"->{ //处理游戏 } ..... } }

    《拿头玩》这个项目是一个起点,基于它的框架,其实可以快速地添加到各种app中,形成一个额外的小游戏模块。将“接锅”“甩锅”的替换成“接优惠券”、“采集素材”等不同元素,可以扩展它的使用场景。通过提供更多有趣的包装,可以有效实现社交裂变引流。

    项目 Github地址 :http://dwz.date/btxB

    Processed: 0.047, SQL: 9