3D球场项目 (一)中,我们搞定了 3D 资产,也选定了落地框架。但是这不代表我们可以直接使用,这个作为的弹幕引擎的一部引入的 Cocos,是有一些定制逻辑在的。我们需要抽丝剥茧的先了解这个弹幕系统做了什么,再来制定具体的实施方案。

视频弹幕引擎

腾讯视频的弹幕引擎作为一个悠久的项目,支撑了腾讯视频大量的互动活动。弹幕引擎框架也是几经迭代,从最早的原生版本,到 PAG,再到现在的 Cocos Creator版本。

之所以想要使用 Cocos 来替换原来的方案,是因为有以下两大优势:

  • 能力强: 支持完善的图形渲染、运动、物理、粒子、3D、光照等游戏引擎能力,充分利用游戏引擎所提供的这些能力,可以在弹幕上实现丰富的玩法。
  • 跨平台: Cocos Creator 是一款高效、清凉、免费开源的跨平台图形引擎,支持所有主流平台支持,真正实现一次开发,全平台运行。框架迭代几乎都是使用 Cocos Creator 开发,少部分业务,如数据上报、客户端交互等,才需要做做少量适配工作,这极大的节省了人力;
  • 动态化: 动态发布新特性和配置能力,更方便配合运营做活动,不需要等 app 发版铺量;

弹幕引擎的工程划分,大致如下:

腾讯视频 APP
弹幕业务接入层 MagicDanmakuiOS
资源包
业务接入层
JS 注册绑定
动态化
playground
弹幕实现 MagicDanmaku
Assets 资源包
TypeScript 版弹幕组件
样式
轨道
特效
通信
素材
图片
视频
音频
二进制库
engine framework
external framework
引擎内核 cocos-engine
2D
3D
物理
粒子
引擎内核扩展 cocos-engine/native/external
webp
freetype

从上面的结构图来看,弹幕整体架构划分了 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 部分拿到主工程,复用已有逻辑来进行加载。

alt text

但是,我们很快发现这个方式行不通。可以看到,产物有一个 main.js文件,这里就是游戏的入口函数文件。前面提到了,主工程已经有了 MagicDanmaku 的相关资源,也就有同名的main.js函数。也许你会想到,3D 球场完全可以和 MagicDanmaku 不在同一个目录,但是尴尬之处是,我们发现,资源文件存放的路径是固定的,具体 iOS 工程来说,就是要放到 assets/CocosFiles 目录下,否则就会加载失败,失败的原因,我们在后面会讲。

就算解决了路径问题,我发现还要对引擎进行一些深度改造才能复用现在弹幕的 Cocos 加载逻辑。具体来说,MagicDanmakuiOS 封装了一个 CocosCreator 的核心管理类(不要跟上面的工具名),负责 iOS 平台入口(IOSPlatform/AppDelegateBridge)的启动和生命周期管理,以及渲染视图(CocosView)的创建与销毁。该类构成了整个引擎在 iOS 平台上的运行基础。这个管理类被设计成了单例,来对引擎的创建、加载、销毁来进行统一的管理.

如果我们梳理一下引擎初始化流程,大致如下:

引擎初始化流程
1
应用启动
iOS 应用进入入口,开始引擎初始化
2
[CocosCreator initialPlatform]
调用 BasePlatform::getPlatform() 创建 IOSPlatform 单例
3
IOSPlatform::init()
创建定时器并注册各功能模块:
MyTimer (fps=60) Accelerometer Battery Network Screen System Vibrator SystemWindowManager
4
[CocosCreator sharedInstance]
创建 CocosCreator 单例,初始化 AppDelegateBridge
5
[CocosCreator createWithRenderView:]
  • 设置 running = true
  • 记录 renderSize
  • 绑定 CocosViewMetalLayerManager
  • 调用 [AppDelegateBridge createWithBounds:]
随后调用 starLoopIfNeeded():获取 IOSPlatform 实例 → 创建主窗口 (mainWindowId) → platform->loop()
6
IOSPlatform::loop()
调用 cocos_main(0, nullptr),启动引擎主逻辑
7
[MyTimer resume]
创建 CADisplayLinkdispatch_source_timer,开始渲染循环
8
每帧调用 [MyTimer renderScene:]
通过 platform->runTask() 执行引擎主任务

在第六步中,主引擎启动,Cocos 的 JS 引擎这个时候也会把 main.js 被加载进来,JS 引擎初始化流程图大概是这样的:

JS 引擎初始化完整流程
1
iOS 应用启动 → 渲染视图就绪
[CocosCreator createWithRenderView:][AppDelegateBridge createWithBounds:]IOSPlatform::loop()
2
cocos_main(argc, argv)
CC_REGISTER_APPLICATION(Game) 宏展开生成入口,最终调用 CC_APPLICATION_MANAGER()->createApplication<Game>(argc, argv),创建 Game 实例。
3
Game::init()
  • 配置 _windowInfo
  • 配置 _debuggerInfo
  • 设置 _xxteaKey
4
BaseGame::init()
  • 调用 cc_load_all_plugins()
  • 配置调试器(如果启用)
5
CocosApplication::init()
  • _engine->init():BaseEngine 初始化(渲染设备、管线等)
  • 获取主窗口 _systemWindow
  • 注册引擎事件监听 ON_START / ON_PAUSE / ON_RESUME / ON_CLOSE
6
ScriptEngine 初始化
  • se::ScriptEngine::getInstance()
  • jsb_init_file_operation_delegate():设置文件读取代理
  • se->setExceptionCallback(...):设置 JS 异常回调
7
jsb_register_all_modules()
按顺序注册以下模块:
1. global_variables 2. all_engine 3. all_cocos_manual 4. platform_bindings 5. all_gfx 6. all_network 7. all_assets 8. all_pipeline 9. all_scene 10. javascript_objc_bridge 11. script_native_bridge
8
se->start() 内部流程
  • 创建 V8 Isolate / Context
  • 初始化全局对象 globalObj
  • 调用所有 beforeInitHook
  • 初始化 NativePtrToObjectMap
  • 调用所有注册回调
  • 调用所有 afterInitHook
  • 启动调试器(如果配置)
9
BaseGame::init() 继续
  • setXXTeaKey(_xxteaKey)
  • runScript('jsb-adapter/web-adapter.js')
  • runScript('main.js')
10
Game 初始化完成,进入主循环

流程图里面的第 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 "";
}
在 iOS 平台上,路径解析采用平台特定的优化策略

展开 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 的仓库耦合在一起的情况下,我们必须要改以下地方:

  1. 修改 Cocos Engine的 BaseGame 类,使其支持传入自定义路径。由于存在层层调用,那么这一连串的函数调用我们都要修改;
  2. 修改 CocosCreator,使其支持传入资源路径,并支持多实例。
  3. 以前由于是单例,之前业务方只需要关心视图级别的接入(CocosView)问题就好了,只要视图的创建和销毁没有问题,那么就没有别的问题。引擎会在首次启动时创建,后面就不会再销毁。现在由于引入了多业务场景,业务方还需要维护引擎的创建和销毁,避免出现其他问题。

除了上述技术复杂性外(引擎大部分都是 C++代码,我们 C++经验不足),多引擎的频繁创建和销毁也会造成体验的下降,每次场景切换都需要重新启动引擎,而引擎初始化是耗时最长的环节。弹幕是现在体育 APP 的核心场景,我们要保证线上弹幕场景的稳定和体验。处于以上种种考虑,在一个客户端放入两个 Cocos 工程的方案被放弃了,我们于是放弃了 3D 球场部分作为一个独立的项目存在,将其整合进了MagicDanmaku仓库中,在业务的层面控制加载哪一个。

如何实现加载弹幕或者球场项目呢?Cocos 有一个 scene 的概念,有点类似 iOS 中的 StoryBoard。一个 Scene文件,对应一整个独立界面 / 关卡(主菜单、战斗、设置)。Scene是根节点,下面挂 LayerSpriteUI 控件等。 同一时间只能有一个 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";
}
由于 Cocos 打包产物的这些文件在**只读的 App 包(Bundle)**里,被系统权限 + 代码签名双重保护,运行时绝对不让写。所有我们是不能直接通过修改代码的方式来决定加载哪一个 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);
    });
}
业务层可以在进入到业务场景后,通过 Cocos引擎提供的函数,调用这一全局函数,从而实现切换 scene 的效果。

时序图如下:

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 球场怎么整合到现有工程中了。下一步,我们就要解决怎么让项目里面的元素动起来了。