查看原文
其他

大力教育:教具游戏原理公开,你想知道的一切都在这里

C姐 COCOS 2022-06-10

在两周前举办的 Cocos 「十周年系列沙龙」北京站,来自字节跳动的高级软件工程师林良喜、渲染引擎开发工程师黄俊铭、高级算法工程师郭冠军为现场开发者分享了大力教育教具游戏联合团队制作的教具游戏技术实现方案,深受开发者喜爱。


在征得同意后,我们将通过本文对几位的演讲进行梳理总结,从 AI 识别原理、定制化渲染引擎以及游戏实现三个方面进行分享,把他们宝贵的技术经验分享给因为各种原因没能去到沙龙现场的开发者伙伴们!


字节跳动布局线上教育成立大力教育品牌,旗下有多款已公开的产品,其中瓜瓜龙启蒙是专为 2-8 岁孩子打造的在线教育产品,内容涵盖英语、思维和语文等多学科的趣味 AI 互动课程,多维度体系化教学,全面助力孩子成长。


    3-6 岁儿童正处于动手敏感期,孩子数学思维的启蒙须高效把握好这个时期,充分调动起他们的多重感官,才能更好地培养孩子的数感。与市面上传统的 AI 启蒙课程相比,瓜瓜龙教具游戏玩法更加新颖,一改屏幕点选、拖动等课程交互形式,升级为结合实体智能教具动手实操。


    通过瓜瓜龙启蒙的独家“黑科技”,孩子边听课边将题目的正确答案从教具中挑选出来摆放在“底座”上,iPad 和手机上方的“棱镜”均可进行智能识别立即判断答案是否正确,给到孩子及时反馈。那么这些是如何实现的呢?



    AI 教具识别


    AI 教具识别的原理简单而言分为三步:

    1. 摄像头拍摄原始的镜头数据,原始数据传给 AI 识别模块;

    1. 通过 AI 模块进行算法识别,对摄像头原始数据处理生成结构化的数据;

    1. 结构化的数据通过客户端传给游戏 JS 运行环境。



    教具游戏设计的挑战在于配套了 28 类卡牌,每类卡牌又由几十个卡牌组成,需要对这 28 类卡牌进行识别和定位,同时让卡牌定位算法的召回率高于 99% 和识别算法的精度大于 99%。

    AI 识别这里的原理概括就是通过输入原始的图片数据,经过两类的神经网络:分类和定位对卡牌进行定位和分类。


    我们 AI 算法识别第一个面临的难点就是数据采集成本高,原始数据会出现目标阻挡、卡牌破损、多余卡牌、以及其他极端环境等问题。


    为了解决这个困难,我们采用了数据合成策略的方法,也就是在理想的情况下采集了卡牌的数据和计算出了卡牌的定位和分类信息,然后把这些卡牌通过一定的变形合成之后,与随机背景合成形成新的训练样本,从而提升了神经网络的效果(召回率)。


    AI 算法识别遇到的第二个难点就是识别结果的“晃动”,前后两帧的定位框晃动导致识别的类别会晃动,由于识别结果会实时地显示在游戏中,识别结果晃动会让用户体验变差。


    解决这个问题的方法比较简单,我们通过串联前后帧的信息来抹平前后帧晃动的情况,具体是通过缓存前一帧的灰度图和框信息来实现的。

    帧差法检测上一帧卡牌位置是否发生变化,如果没发生变化,保留框,同时合并上一帧剩余检测框和当前帧检测框,做 nms 进行过滤 。


    AI 算法识别遇到的第三个难点是检测模型性能要求高的问题,我们的检测模型需要达到 60 FPS 的标准且内存占用在 10M 以下。


    我们这里采用的解决方案是用了学术界性能和效果都不错的、一个比较小型的网络 ShuffleNet V2,同时通过神经网络结构搜索(NAS)和对网络权重的 INT8 量化使得单帧耗时在 17ms 以下同时召回率在 99.5% 以上。

    AI 算法遇到的第四个难点是除了提升召回率之外,如何提升类别识别的精度,且精度需要在 99% 以上。

    我们同样把采集的数据贴在各种背景上后合成了各种分类数据。另外也会针对手遮挡、环境光等各种 badcase 单独采集分类数据进行模型迭代。在模型充分迭代 10 次左右以及在各种极端光线下测试通过后才会上线使用。

    AI 算法遇到的第五个难点是减少其他卡牌识别结果的干扰。由于用户无法直接看到摄像头的视野,因此视野内很容易出现非目标的卡牌,进而被识别。我们这里采用环境监测的方法来分割出用户的桌垫,把桌垫外的卡牌识别结果过滤掉。


    同样其他环境问题,比如光线、棱镜遮挡有无桌垫环境问题也是通过采集对应的数据训练不同的分类模型,当环境出现异常时把实时的环境监测结果反馈给游戏,让游戏提示用户调整识别环境。



    渲染引擎


    在教具游戏场景下,底层游戏渲染引擎也扮演着一个重要的角色,支持游戏在教育业务场景下高效开发以及高性能运行。

    MiniGameLite 是教具业务场景下使用的快速游戏集成解决方案,我们将从方案背景、技术原理、部分技术问题优化以及未来展望几个方面给大家介绍。


    第一部分是方案背景,首先我们提出一个问题,游戏如何与业务快速结合

    业务方会提出以下几点诉求:

    • 需要将游戏嵌入到业务中,业务可以随意放在任意位置;

    • 需要快速地开发游戏,有一套完整的工具链路,可以快速集成到业务中;

    • 需要定制游戏的接口去满足业务的需求,比如教具场景下要通知游戏消息去做一些互动;

    • 需要更高性能、占用内存更小的游戏。


    MiniGameLite 快速小游戏集成解决方案就解决了以上诉求:

    • 提供 View 视图级别接入,快速嵌入业务;

    • 支持运行小游戏,对接小游戏生态,具有完整的工具链路;

    • 提供业务方定制小游戏通道的能力,定制业务接口;

    • 使用自研的渲染引擎,渲染链路更短更高效。



    第二部分想跟大家分享背后的技术原理,首先从整体架构图看 MiniGameLite 方案部分实现的功能以及位置。

    最上层是 Cocos 等游戏引擎承载的小游戏,与小游戏生态对接;向下是对接 Android 、 iOS 业务方;中间就是 MiniGameLite 整体的解决方案,从上往下看,最上方是支持字节小游戏接口(比如 tt.createCanvas、tt.getSystemInfoSync、gl.clearColor 等),并支持 MiniGameLite 定制的消息通道接口,往下是 JS 引擎,Android 使用 V8 引擎, iOS 使用 JavaScriptCore 引擎,再往下是 JavaScript Binding,连接底层核心实现,比如很重要的一部分 WebGL 到 OpenGL 的 Binding,紧接是 MiniGameLite 的核心模块,包含核心小游戏渲染层、音频模块、文件系统、网络等,接下来是  MiniGameLite 平台接口层,提供给业务方简单易用的接口,比如开始游戏、暂停恢复游戏等。

    我们拆解以上的几个技术特点简单讲解,首先是 MiniGameLite 的视图级别接入。

    MiniGameLite 提供简单的接口,指定游戏 ID 以及游戏视图,游戏将画面渲染到对应的视图上,不同于普通单进程全屏小游戏,MiniGameLite 支持任意进程指定视图渲染,支持快速嵌入到业务中。

    游戏将画面渲染到 Canvas 中,然后在 Android 绘制到 SurfaceView 或者 TextureView 上,iOS 绘制到 CAEAGLLayer 上,最后嵌入到业务中。

    接下来讲一下定制消息通道。背后的技术原理也比较简单,MiniGameLite 提供了游戏层以及业务层的消息通道,平台层假如要通知游戏业务层消息,就是平台层调用 JS 方法通知游戏方,游戏方通过暴露的消息通道接口监听函数收到消息,游戏业务需要通知平台层消息时,平台层预先注入实现好的 JS 方法,游戏方通过调用 JS 方法通知平台层。

    关于 MiniGameLite 高性能渲染的部分,我们可以对比下浏览器渲染链路,浏览器支持了其他 DOM 元素的渲染,整体渲染链路比较复杂,包含样式计算、重绘、重排等。

    MiniGameLite 专注于支持小游戏 Canvas 的渲染,整体渲染链路比较简单,主要的工作是做了 WebGL 到 OpenGL 的 Binding 工作,减少了不必要的开销,在某些 API 的使用上链路更短,避免了 DOM 元素带来的消耗(比如 DrawImage API 等)。

    在 MiniGameLite 高性能渲染链路以外,我们还可以介绍下 MiniGameLite 在调用 WebGL 方面使用共享内存的优化。


    举一个很简单的例子,比如创建一个 canvas 使用 clearColor 将它染成红色,这里的 clearColor 就是一个很常见的 WebGL 状态。

    我们是不是需要在每次调用的时候直接调用到底层的 OpenGL 函数呢?答案不是的,我们使用共享内存的方式维护了 GL 的状态,不会每次调用 clearColor 的时候调用 OpenGL 函数,而是在真正绘制时对比当前上下文状态是否与对应上下文状态是否有更新,假如有更新再去应用更新,这样也高效地维护了 WebGL 状态,减少无用的更新。

    不过对于游戏开发者而言,引擎底层的优化是一方面,游戏开发者需要更关注 drawcall 的使用,比如可以采用批处理等方式进行优化。
    第三部分我们讲述下我们遇到的几个技术问题以及优化的方式。

    由于我们面向的是小游戏开发者,假如小游戏开发者没有及时释放 GL 资源,GL 资源将成为内存杀手,这时候对于引擎层需要怎么做呢


    我们知道 JS 引擎有垃圾回收机制,利用这一点我们也可以完成 GL 资源的垃圾回收,比如 V8 引擎,我们可以使用它的 SetWeak 方法,它将在对象引用只剩下一个弱持久引用时调用回调函数。

    利用这一点我们就可以对我们的 WebGL 资源挂载上 finalize,当对象不再被引用时,我们自动调用析构,释放 GL 资源。

    再分享我们遇到的一个技术问题,在某些机型上,底层 OpenGL 纹理异步释放速度很慢,在某些场景下某些游戏频繁地申请纹理资源(并且调用了释放),但是由于释放速度较慢,申请纹理资源过快就会导致 GL OOM 的问题。懒加载、复用缓存是优化的常见手段,为了解决这个问题,我们针对特定场景下尝试了以下优化:


    首先,我们延迟了创建纹理的调用,createTexture 不立即创建纹理而是等到 Bind 使用时。

    其次,我们在释放纹理时不会真正地调用底层纹理的释放,而是将纹理放置于纹理池中,供下次创建循环使用,这样也就解决了频繁申请纹理的问题。

    不过对于游戏而言,关于内存的使用,引擎层需要关注,游戏开发者也需要更多的关注:游戏可以优化纹理使用,可以使用压缩、合图或者使用压缩纹理的方式进行优化;游戏可以提早及时释放游戏资源,防止达到内存峰值。


    最后一部分我们讲述 MiniGameLite 快速小游戏集成方案的未来展望。

    首先我们会打造更高性能、更小体积的业务 SDK,其次我们在尝试打造更强大的物理引擎,目前也与 Cocos 合作,下沉物理引擎到 Native 层增强小游戏的性能。

    最后是字节能力赋能,我们将结合字节跳动内部特效、算法生态,游戏结合摄像头特效、摄像头 AR 等能力赋能更多业务场景。


    游戏实现


    我们提供线上教育服务,游戏跟随课程上线,随着课程开展,游戏需求量越来越大。

    为了适应不同教学情况和满足小朋友好奇心,我们需要丰富游戏玩法,为了更吸引小朋友,满足小朋友动手需求,我们接入了教具识别。因此,我们需要解决的问题是,大量的、不同种类的教具游戏开发。

    我们统计了现有游戏玩法,把它们组合起来,抽出可以被复用的部分,设计游戏模板。在游戏模版中,根据功能不同分成三块,分别是状态机,适配器和教具识别。
    为了大家更好理解,首先从这三张图出发,这里用点数游戏-抓老鼠的三张图作为示例。

    第一张图是关卡地图,游戏以闯关的模式进行,通过关卡后才能进行下一关。

    第二张图是进入关卡后,开始游戏,这个游戏的玩法是,使用对应点数卡牌回答问题,回答正确可以阻止老鼠进入屋子,根据回答状态,我们可以分为未回答、回答正确、回答错误一次、回答错误两次、回答错误三次、超时未回答等,每个状态都有对应逻辑。

    例如在未回答时,老鼠从主路走向中间的岔路,这时游戏开启摄像头,接收教具数据,等待用户答题。当识别到正确教具,用户回答正确,游戏状态变为回答正确,这时游戏中出现了一只道具手拍向老鼠,结束当前题目,开启下一道题。

    我们把游戏中的每一个流程都当成一种状态,为此设计了游戏状态机,用来管理游戏中的状态 ,状态流转类似 JS 中的 Promise,状态只会正向流转,一旦发生改变,状态将不可逆。

    用状态机管理游戏有一个好处,可以使抽象逻辑更加清晰。因为游戏玩法太多了,每个流程对应的功能可能都不一样,那么我们可以把它理解为一个状态,规定这个状态的输入输出,而中间是如何处理的,我们可以先不管,由具体逻辑负责,下面会讲到相关内容。
    围绕模板,分析玩法,我们把核心逻辑抽离出来,设计关卡管理器,根据游戏状态,设计了状态机。

    如图所示,游戏开始,我们进入关卡,关卡开始、读取状态机、状态机开始,开始接收识别数据,状态机根据识别数据改变状态,游戏处理对应状态,状态机达到退出条件,状态机结束,销毁当前状态机,查询是否有下一个状态机。

    如果有,继续执行下一个状态机,游戏进度加一;如果没有下一个状态机,结束当前关卡,游戏跳转至下一关卡,根据关卡类型,执行对应状态机,游戏重新走一遍关卡流程;如果没有下一个关卡,游戏通关,通关后,结束游戏。

    在这一过程中,状态机承担了主要的游戏逻辑,由状态机控制状态流转,状态流转时控制状态功能。

    这个流程的基本思路是,从玩法出发,定义需要用到的状态,根据用户的输入,改变状态,流转状态,最终达到退出游戏条件。而模板开发在这一过程中,只需要去实现对应状态功能。

    由于有多种玩法,因此在设计状态机时,我们只需要确定核心状态,然后在适配器上实现新玩法带来的新状态,就可以在同一份模板上实现不同游戏种类玩法。
    游戏玩法多样性由适配器完成,根据状态机核心流程,我们定义了一个基础适配器,玩法适配器继承了基础适配器,在基础适配器上开发。

    当我们要开发一个新的玩法时,只需要开发新的适配器,实现对应玩法,即可快速生成一套玩法模板。

    由于新玩法往往与其他游戏不同,我们需要根据玩法的特性,重写状态接口,使游戏状态满足游戏玩法。因为状态接口和游戏状态一一对应,因此,当状态发生流转时,对应的游戏玩法也随之发生改变。

    在一些玩法里面,可能存在别的玩法不需要的能力,例如编程题中需要把教具信息转化成程序指令、教具拖拽题需要拖动指定教具到正确区域,实现玩法时,我们需要增加 feature 能力完成对应功能,feature 是指特定玩法功能,程序一般在状态执行时调用对应的 feature,或者预留在代码里,根据用户输入触发对应 feature,通过 bridge 动态调用。
    我们所处业务的时间很紧张,每周都有大量游戏上线,行业和业务发展十分迅猛,过不了多久就会有大量线上游戏。量变形成质变,怎么管理大量线上游戏成为一个难题,bridge 在这个背景下诞生。

    我们把游戏分成三大模块,状态机、适配器和教具识别,其中状态机代表视图层,控制游戏表现,适配器代表控制层,控制游戏逻辑,教具识别代表数据层,表示用户输入。

    我们把适配器和教具识别单独抽离出来,创建一个新工程,用来维护birdge。控制逻辑被抽离之后,可以简化状态机代码,同时游戏逻辑更加清晰。

    状态机只处理对应视图层,完成对应状态表现,视图层由逻辑层控制,开发者不用过多关注视图层,可以更加专注游戏逻辑开发。

    为了能更好地管理游戏,抽离出来的 bridge 单独维护,所有游戏共用一份 bridge,通过 bridge 管理海量线上游戏。这样,如果遇到逻辑变更或者修复,我们只需要维护一份 birdge,即可影响所有游戏。

    那么,birdge怎么和状态机结合呢

    考虑游戏会和多端通信(minigamelite、AI lab 等),我们最终使用事件派发驱动游戏。

    根据模块划分,bridge 需要对接状态机和 AI lab,AI lab 通过派发事件与 bridge 通信、传输教具信息,bridge 处理信息后通过事件驱动状态机流转状态,从而控制游戏逻辑。使用事件驱动,我们可以很方便在模块之间通信,开发者只需要根据用户输入和当前状态判断下一个状态,通过指定事件控制状态流转。
    遇到难题,假设我们有个通用组件叫瓜瓜币,是游戏内的积分,产品觉得这个组件不够 Q,需要把它改得更加适合小朋友,这个时候我们的麻烦来了,怎么解决游戏更新?

    我们预留了解决方案,组件开发和动态下发。

    下面向大家分享我们组件仓库的开发历史,一开始我们也经历 copy 代码的过程,但相信大家也知道这样的痛苦。当时还没规划组件仓库、结合 Cocos,我们寻找市面上能够满足组件开发的技术,后来我们选择了 git subModule,一定程度上缓解 copy 代码带来的不便。

    但是因为子模块维护操作比较麻烦,容易被误修改等,我们后来开发组件仓库时,最终使用了 @byted/cetus 管理组件仓库。

    cetus 是我们的一个工具包,它是一个基于 git 的团队项目代码管理工具,它适合内部的包管理,它可以直接使用 git 仓库作为依赖,cetus 仅需要很少的配置就可以实现仓库的管理,在配置中,mode 表示组件开发模式,我们选择克隆模式拉取整个仓库,方便修改和提交,path 表示组件存放路径,remoteUrl 表示拉取的组件仓库地址,version 表示拉取版本的代码,可以使用分支名或 commit hash,配置完成后,执行 ct,空格 i,即可在对应目录下看到相应的组件文件。通过 cetus,我们可以很便捷开发和维护组件。


    组件开发完后,我们会把通过测试的组件打包,发布到 CDN,提供游戏使用。
    游戏通过组件仓库 id 加载仓库下的 uuid-to-mtime.json 文件,这个文件比较特殊,存放了仓库下面所有资源,包含了 uuid 与资源路径映射关系,我们可以根据它获取对应仓库所有资源。

    拿到 uuid-to-mtime.json 文件后,我们还需要改造数据,由 relativePath 映射 uuid。因为一个仓库里面包含很多组件,对于开发者而言,相对路径对我们来说会更清晰些,我们通过相对路径获取远程仓库内指定组件。

    当请求的组件路径命中 relativePath 时,我们可以获取到对应组件的 uuid,根据 Cocos 项目结构,我们可以很容易通过 uuid 解析出资源存放在 library 中的路径,再通过路径加载指定组件的资源。至此,游戏完成动态加载。

    至此,教育游戏的痛点难点我们都解决了,下面简单介绍下我们用到的优化手段。

    关于性能优化,我们主要从包大小、内存、drawCall 入手。

    优化包大小的手段有很多,我们主要从资源入手,图片、音频压缩,优化合图空白区域,优化图片像素格式,使用 jpg 格式,使用九宫格、平铺或拉伸方式满足大图需求。

    对于字体文件,优先考虑使用位图,如果需要引入字体文件,使用 fontmin 去除未被使用的字符。在这么多资源中,setting.js 文件大小最容易被忽略,如果不注意资源放置,setting.js 文件可以增大很大,为了减少 setting.js 文件大小,避免在 resources 放置不需要动态加载的资源,碎图合并成一张。

    除了业务相关资源,引擎中不需要引用的模块也可以适当删减,去掉未使用的引擎模块不单能够减少包体积,还可以加快构建速度。

    内存优化这里主要做了 3 个操作,对于静态资源,可以勾选自动释放资源让引擎处理。针对动态资源,需要计算动态资源引用次数,当资源引用次数为 0 时释放资源。

    图片的分辨率尽量满足 2 的幂次方,Cocos的渲染方式基于 OpenGL 的,OpenGL 载入纹理图片时,所用内存会自动扩张到 2 的 N 次方,图片分辨率满足 2 的幂次方,可以避免因扩张内存导致内存浪费,在分辨率满足 2 的幂次方下,碎图铺满合图可以更有效利用内存。

    进行 drawCall 优化前我们需要明白 drawCall 是什么,drawCall 是 cpu 对图形绘制接口的调用,CPU 通过调用图形库接口,命令 GPU 进行渲染操作。每一次绘制 CPU 都要调用 DrawCall,而在调动 DrawCall 前,CPU 还要进行很多准备工作:检测渲染状态、提交渲染所需要的数据、提交渲染所需要的状态。

    而 GPU 本身具有很强大的计算能力,可以很快就处理完渲染任务。当 DrawCall 过多,CPU 就会很多额外开销用于准备工作,CPU 本身负载,而这时 GPU 可能闲置了。由于 drawCall 过高,导致 frame time 过高,fps 变低,用户感觉体验是游戏变卡了,甚至掉帧,那么我们优化的时候就是减少 drawCall,尽量把小的 drawCall 合并到一个大的 drawCall 中,渲染合批。

    我们主要的方法是合图,把纹理状态,材质,混合模式一致的图片合张一张大图,减少渲染次数,使用位图代替 Label,避免 Label 和 Sprite 相互打断。
    还有一些常用的优化,如使用对象池,设置合适的游戏帧率,降低物理引擎步长等,可以根据项目需要设置,通过比较小的改动带来比较明显的优化。

    以上就是大力教育所有关于教具的原理介绍喔,字节跳动布局线上教育成立大力教育品牌,旗下有多款已公开的产品。


    教具游戏是大力教育综合字节内部各种技术积累进行的一个项目,大力教育结合 Cocos 还有很多应用场景,对 Cocos 开发者也提供了很多非常不错的机会,对教育行业感兴趣同学可以联系 jiangdailin@bytedance.com 投递简历喔。



    最后,再次感谢林良喜黄俊铭郭冠军三位大大的倾情分享「十周年系列沙龙」仍在继续前进,12 月 26 日广州站已经在路上,尚未报名的童鞋戳链接冲鸭!

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存