在3D球场项目 (一)中,我们搞定了 3D 资产,也选定了落地框架。但是这不代表我们可以直接使用,这个作为的弹幕引擎的一部引入的 Cocos,是有一些定制逻辑在的。我们需要抽丝剥茧的先了解这个弹幕系统做了什么,再来制定具体的实施方案。
视频弹幕引擎
腾讯视频的弹幕引擎作为一个悠久的项目,支撑了腾讯视频大量的互动活动。弹幕引擎框架也是几经迭代,从最早的原生版本,到 PAG,再到现在的 Cocos Creator版本。
之所以想要使用 Cocos 来替换原来的方案,是因为有以下两大优势:
- 能力强: 支持完善的图形渲染、运动、物理、粒子、3D、光照等游戏引擎能力,充分利用游戏引擎所提供的这些能力,可以在弹幕上实现丰富的玩法。
- 跨平台: Cocos Creator 是一款高效、清凉、免费开源的跨平台图形引擎,支持所有主流平台支持,真正实现一次开发,全平台运行。框架迭代几乎都是使用 Cocos Creator 开发,少部分业务,如数据上报、客户端交互等,才需要做做少量适配工作,这极大的节省了人力;
- 动态化: 动态发布新特性和配置能力,更方便配合运营做活动,不需要等 app 发版铺量;
弹幕引擎的工程划分,大致如下:
从上面的结构图来看,弹幕整体架构划分了 3 层:
- 引擎内核层: 是 Cocos Creator 引擎内核仓库,内部依赖了第三方库仓库,其中 Cocos Engine 是官方 3.8.3 版本的 fork版本,进行了一些定制化修改(比如单 Metal View复用)等等; external framework 同样也是官方版本的 fork版本,包括但不限于 webp、SSL、Freetype 等;
- 跨端弹幕层 MagicDanmaku 是一份 Cocos Creator工程,用于存在业务 TS 代码、Assets 资源,弹幕和我们后续的 3D 球场的业务逻辑,都存放在这里;
- 业务接入层 MagicDanmakuiOS 是将上面 MagicDanmaku 打包后的产物接入到 iOS 工程的组件,用于输入业务数据、桥接/注册 JS 层逻辑、动态化能力实现;
MagicDanmaku 工程探索
我们起初新建一个 Cocos 项目来进行开发,计划是仅拿出打包工程的 JS 及 assets 部分拿到主工程,复用已有逻辑来进行加载。

但是,我们很快发现这个方式行不通。可以看到,产物有一个 main.js文件,这里就是游戏的入口函数文件。前面提到了,主工程已经有了 MagicDanmaku 的相关资源,也就有同名的main.js函数。也许你会想到,3D 球场完全可以和 MagicDanmaku 不在同一个目录,但是尴尬之处是,我们发现,资源文件存放的路径是固定的,具体 iOS 工程来说,就是要放到 assets/CocosFiles 目录下,否则就会加载失败,失败的原因,我们在后面会讲。
就算解决了路径问题,我发现还要对引擎进行一些深度改造才能复用现在弹幕的 Cocos 加载逻辑。具体来说,MagicDanmakuiOS 封装了一个 CocosCreator 的核心管理类(不要跟上面的工具名),负责 iOS 平台入口(IOSPlatform/AppDelegateBridge)的启动和生命周期管理,以及渲染视图(CocosView)的创建与销毁。该类构成了整个引擎在 iOS 平台上的运行基础。这个管理类被设计成了单例,来对引擎的创建、加载、销毁来进行统一的管理.
如果我们梳理一下引擎初始化流程,大致如下:
[CocosCreator initialPlatform]BasePlatform::getPlatform() 创建 IOSPlatform 单例IOSPlatform::init()[CocosCreator sharedInstance]CocosCreator 单例,初始化 AppDelegateBridge[CocosCreator createWithRenderView:]- 设置
running = true - 记录
renderSize - 绑定
CocosView到MetalLayerManager - 调用
[AppDelegateBridge createWithBounds:]
starLoopIfNeeded():获取 IOSPlatform 实例 → 创建主窗口 (mainWindowId) → platform->loop()IOSPlatform::loop()cocos_main(0, nullptr),启动引擎主逻辑[MyTimer resume]CADisplayLink 或 dispatch_source_timer,开始渲染循环[MyTimer renderScene:]platform->runTask() 执行引擎主任务在第六步中,主引擎启动,Cocos 的 JS 引擎这个时候也会把 main.js 被加载进来,JS 引擎初始化流程图大概是这样的:
[CocosCreator createWithRenderView:] →
[AppDelegateBridge createWithBounds:] →
IOSPlatform::loop()cocos_main(argc, argv)CC_REGISTER_APPLICATION(Game) 宏展开生成入口,最终调用 CC_APPLICATION_MANAGER()->createApplication<Game>(argc, argv),创建 Game 实例。Game::init()- 配置
_windowInfo - 配置
_debuggerInfo - 设置
_xxteaKey
BaseGame::init()- 调用
cc_load_all_plugins() - 配置调试器(如果启用)
CocosApplication::init()_engine->init():BaseEngine 初始化(渲染设备、管线等)- 获取主窗口
_systemWindow - 注册引擎事件监听
ON_START/ON_PAUSE/ON_RESUME/ON_CLOSE
se::ScriptEngine::getInstance()jsb_init_file_operation_delegate():设置文件读取代理se->setExceptionCallback(...):设置 JS 异常回调
jsb_register_all_modules()se->start() 内部流程- 创建 V8
Isolate/Context - 初始化全局对象
globalObj - 调用所有
beforeInitHook - 初始化
NativePtrToObjectMap - 调用所有注册回调
- 调用所有
afterInitHook - 启动调试器(如果配置)
BaseGame::init() 继续setXXTeaKey(_xxteaKey)runScript('jsb-adapter/web-adapter.js')runScript('main.js')
流程图里面的第 9 步,BaseGame 通过 runScript('main.js') 回去执行入口函数,那么 Cocos 是怎么找到main.js的呢?
于是我进一步查看 Cocos 引擎代码,在 iOS 上,引擎是这么做的:
展开 FileUtils::fullPathForFilename 路径查找核心逻辑
// FileUtils.cpp - 路径查找核心逻辑
ccstd::string FileUtils::fullPathForFilename(const ccstd::string &filename) const {
if (filename.empty()) {
return "";
}
// 绝对路径直接规范化处理
if (isAbsolutePath(filename)) {
return normalizePath(filename);
}
// 缓存机制优化性能
auto cacheIter = _fullPathCache.find(filename);
if (cacheIter != _fullPathCache.end()) {
return cacheIter->second;
}
ccstd::string fullpath;
// 遍历搜索路径数组进行文件查找
for (const auto &searchIt : _searchPathArray) {
fullpath = this->getPathForFilename(filename, searchIt);
if (!fullpath.empty()) {
_fullPathCache.emplace(filename, fullpath);
return fullpath;
}
}
return "";
}
展开 FileUtilsApple::getFullPathForDirectoryAndFilename 实现
// FileUtilsApple.mm - iOS 平台路径解析实现
ccstd::string FileUtilsApple::getFullPathForDirectoryAndFilename(
const ccstd::string &directory, const ccstd::string &filename) const {
if (directory[0] != '/') {
NSString *dirStr = [NSString stringWithUTF8String:directory.c_str()];
// 处理相对路径符号 "../"
auto theIdx = directory.find("..");
if (theIdx != ccstd::string::npos && theIdx > 0) {
NSMutableArray<NSString *> *pathComps = [NSMutableArray arrayWithArray:[dirStr pathComponents]];
NSUInteger idx = [pathComps indexOfObject:@".."];
while (idx != NSNotFound && idx > 0) {
[pathComps removeObjectAtIndex:idx]; // 移除 ".."
[pathComps removeObjectAtIndex:idx - 1]; // 移除前置目录
idx = [pathComps indexOfObject:@".."];
}
dirStr = [NSString pathWithComponents:pathComps];
}
// 利用 NSBundle 进行资源路径查找
NSString *fullpath = [pimpl_->getBundle() pathForResource:[NSString stringWithUTF8String:filename.c_str()]
ofType:nil
inDirectory:dirStr];
if (fullpath != nil) {
return [fullpath UTF8String];
}
} else {
// 绝对路径直接验证文件存在性
ccstd::string fullPath = directory + filename;
if ([s_fileManager fileExistsAtPath:[NSString stringWithUTF8String:fullPath.c_str()]]) {
return fullPath;
}
}
return "";
}
可以看出,引擎会对传入的路径检查是否是全路径,如果是,则直接加载;如果不是,则会遍历搜索路径数组进行文件查找,然后拼接上找到的路径。,这里的 assets/CocosFiles 就是查找目录之一,在只传入main.js的前提下,路径会被补全成assets/CocosFiles/main.js。这也是我们为什么把球场的打包产物放到别的目录下不成功的原因。
经过上面的探索,我们可以得出结论,如果我们想不跟 MagicDanmaku 的仓库耦合在一起的情况下,我们必须要改以下地方:
- 修改 Cocos Engine的
BaseGame类,使其支持传入自定义路径。由于存在层层调用,那么这一连串的函数调用我们都要修改; - 修改
CocosCreator,使其支持传入资源路径,并支持多实例。 - 以前由于是单例,之前业务方只需要关心视图级别的接入(CocosView)问题就好了,只要视图的创建和销毁没有问题,那么就没有别的问题。引擎会在首次启动时创建,后面就不会再销毁。现在由于引入了多业务场景,业务方还需要维护引擎的创建和销毁,避免出现其他问题。
除了上述技术复杂性外(引擎大部分都是 C++代码,我们 C++经验不足),多引擎的频繁创建和销毁也会造成体验的下降,每次场景切换都需要重新启动引擎,而引擎初始化是耗时最长的环节。弹幕是现在体育 APP 的核心场景,我们要保证线上弹幕场景的稳定和体验。处于以上种种考虑,在一个客户端放入两个 Cocos 工程的方案被放弃了,我们于是放弃了 3D 球场部分作为一个独立的项目存在,将其整合进了MagicDanmaku仓库中,在业务的层面控制加载哪一个。
如何实现加载弹幕或者球场项目呢?Cocos 有一个 scene 的概念,有点类似 iOS 中的 StoryBoard。一个 Scene文件,对应一整个独立界面 / 关卡(主菜单、战斗、设置)。Scene是根节点,下面挂 Layer、Sprite、UI 控件等。
同一时间只能有一个 Scene 活跃,由 Director(导演) 负责切换。
鉴于弹幕已经使用了main.scene,我们的 3D 球场项目就只能重命名为tennis.scene。但是不同的是,iOS 中你可以删除工程里面Main Interface,通过代码来实现动态加载哪个 scene。Cocos 在最后的打包中,是必须要指定一个 scene 作为 main scene。但是也支持动态修改,我们可以通过覆盖配置,比如这样:3.x 用 overrideSetting
// main.ts
import { _decorator, game, director } from 'cc';
game.init({
overrideSettings: {
launch: {
// 动态决定启动场景
launchScene: getStartSceneName()
}
}
});
function getStartSceneName(): string {
let isPlayerPage = true; // 从本地存储/配置读
return isPlayerPage ? "main" : " tennis";
}
scene, 虽然我们也可以改成读配置的形式,但是上面说过了,main.js只会在引擎启动的时候加载一次,后面除非销毁再重新加载,否则不会重新执行 main.js,因此我们还需要一个新的方案。
Ccoco还支持启动后直接 loadScene,比如:
// 游戏启动后
import { director } from 'cc';
let isPlayerPage = true;
let startScene = isPlayerPage ? "main" : "tennis";
director.loadScene(startScene, () => {
console.log("启动场景加载完成");
});
如果每次我们都是先进 main.scene,再来决定是否要切换到tennis.scene的话,除了不必要的节点加载外,视觉上可能存在一闪而过弹幕元素。这里我决定再引入一个无 UI 的纯路由 scene-route.scene,它的业务逻辑很简单,启动后,全局注册一个 JS 方法,核心实现如下:展开 scene-route.scene 全局路由方法实现
private static loadScene(sceneName: string, startTime?: number, bundleEndTime?: number, extParams?: string) {
const sceneStartTime = Date.now();
// 通知原生端场景即将加载
this.notifyNativeSceneWillLoad(sceneName);
// 扩展引擎的 loadScene 方法以支持进度回调
director.loadScene(sceneName, (finished: number, total: number, item) => {
this.notifyNativeSceneLoadProgress(sceneName, finished, total);
}, (error: null | Error) => {
if (error) {
Logger.e(tag, `场景加载失败: ${error}`);
this.notifyNativeSceneLoadFailed(sceneName, error.message);
return;
}
const sceneEndTime = Date.now();
const sceneDuration = sceneEndTime - sceneStartTime;
Logger.i(tag, `场景[${sceneName}]加载成功,耗时: ${sceneDuration}ms`);
// 性能指标统计
if (startTime && bundleEndTime) {
const totalDuration = sceneEndTime - startTime;
const bundleDuration = bundleEndTime - startTime;
console.log(tag, `场景[${sceneName}]性能分析:`);
console.log(tag, `Bundle加载耗时: ${bundleDuration}ms`);
console.log(tag, `场景加载耗时: ${sceneDuration}ms`);
console.log(tag, `总耗时: ${totalDuration}ms`);
}
// 通知原生层 scene 加载完毕
this.notifyNativeSceneLoaded(sceneName);
});
}
时序图如下:
sequenceDiagram
autonumber
participant N as 原生业务层
participant CC as Cocos Creator
participant SR as Scene Router
participant SM as QSSceneManager
participant D as cc.director
Note over N,CC: 引擎启动阶段(iOS native 侧)
N->>CC: +initialPlatform 启动引擎
N->>CC: +createRenderView:sceneConfig: 加载视图
CC->>SR: 进入到 scene-route.scene
SR-->>CC: 通知引擎,加载完毕
CC-->>N: sceneRouterIsLoaded
Note over N,D: 切换 scene 阶段
N->>SR: -loadScene: 切换 scene
SR->>SM: +loadScene
SM->>D: -loadScene,进入到弹幕或者球场
Note over N,D: 加载生命周期回调(JS 侧)
SR->>CC: +sceneWillLoad
CC-->>N: -cocosView:sceneWillLoad:
D-->>SM: onProgress
SM->>CC: +sceneLoadProgress:finished:total:
CC-->>N: -cocosView:sceneLoadProgress:finished:total:
D-->>SM: onLaunched
SM->>CC: sceneIsLoaded:
CC-->>N: -cocosView:didLoadScene:
SM->>CC: sceneLoadFailed:error:
CC-->>N: -cocosView:didFaileLoadScene:error:
好了,到这一步,我们基本研究完毕了 3D 球场怎么整合到现有工程中了。下一步,我们就要解决怎么让项目里面的元素动起来了。