全网最详Babylon.js入门教材(7)-场景的生命周期钩子

avatar
SugarTurbos Club 成员

Q:Babylon.js是什么?🤔️

Babylon.js 是一个强大的、开源的、基于 WebGLWebGPU3D引擎,用于在网页上创建和渲染 3D图形。它提供了一套丰富的 API和功能,包括物理引擎、粒子系统、骨骼动画、碰撞检测、光照和阴影等,可以帮助开发者快速创建复杂的 3D场景和交互。

Q:我为什么要写该系列的教材? 🤔️

因为公司业务的需要因而要在项目中使用到 Babylon.js,虽然官方的文档看起来覆盖面都挺全,且 playgroud 上的案例也都比较多,但一些具体的 API 或者功能属性也都没有特别多详细的介绍,包括很多使用方式的很多坑都得自己去源码中或者论坛上找。在将其琢磨完之后, 决定写一系列关于它的教材来帮助更多 babylon.js的使用者或者是期于学习 Web 3D的开发者。同时也是自己对其的一种巩固。

Babylon.js 中的场景(Scene)

通过上面的一些章节,我们终于将 3D 中一些较为重要的概念(网格、材质、光源、相机) 给大致的介绍了,接下来还是得花一些章节来详细介绍下 Babylon.js 中的 Scene

Scene是 Babylon.js 中的核心类之一,负责管理和渲染 3D 场景。它包含了所有的对象、光源、相机和材质,并提供了丰富的方法和属性来控制场景的行为和外观。学习了解 Scene才能让我们更好的“掌控全局”,主宰 3D 世界!

image.png

由于场景涉及到的内容比较多,我们拆分为几个章节来说,本章节主要聚集在场景的创建、销毁和渲染上。

场景创建销毁

和我们所熟悉的浏览器中的 DOM元素一样,Scene也有创建销毁的生命周期。

Scene 构造函数

首先 Scene的创建,大家已经不陌生了,通过 Scene的构造函数 new一个实例即可:

// 记得将 engine 传递进去
const scene = new BABYLON.Scene(engine);

在参数上,除了 engine这个参数,其实还有一个可选参数:

const scene = new BABYLON.Scene(engine, {
  useGeometryUniqueIdsMap: true,
  useMaterialMeshMap: true,
  useClonedMeshMap: true,
  virtual: false,
});

接口定义为:

/** 定义 Scene 类初始化参数的接口 */
export interface SceneOptions {
    /**
     * 定义场景是否应保持几何体的映射以通过 uniqueId 快速查找
     * 当几何体数量变得重要时,它将提高性能。
     */
    useGeometryUniqueIdsMap?: boolean;

    /**
     * 定义场景中的每个材质是否应保持引用网格的映射以快速处理
     * 当网格数量变得重要时,它将提高性能,但可能会消耗更多内存。
     */
    useMaterialMeshMap?: boolean;

    /**
     * 定义场景中的每个网格是否应保持引用克隆网格的映射以快速处理
     * 当网格数量变得重要时,它将提高性能,但可能会消耗更多内存。
     */
    useClonedMeshMap?: boolean;

    /** 定义场景的创建是否应影响引擎(例如 UtilityLayer 的场景) */
    virtual?: boolean;
}

对于新手来说,这个可选参数中的四个属性,基本不会用上,所以咱暂时也可以不用太关注它们。

Scene Ready

第一个要介绍的是 scene在场景已经准备好进行渲染和交互时相关的 API

scene.isReady()

scene.isReady()方法用于查看 scene是否准备好。

scene 准备好的概念通常包括以下几个方面:

  • 所有资源加载完成:场景中的所有纹理、材质、几何体和其他资源都已经加载并初始化完毕。
  • 所有子元素准备就绪:场景中的所有网格、光源、相机等子元素都已经准备好。
  • 所有必要的初始化操作完成:场景中的所有初始化操作(如着色器编译、缓冲区创建等)都已经完成。
scene.executeWhenReady()

我们可以通过 executeWhenReady() 方法注册一个回调函数,用于处理在 scene准备好后要做的事。

可以来看看下面的这个案例:

console.log('scene is ready:', scene.isReady());

// 创建一个标准材质,并设置自发光贴图
const standardMat = new BABYLON.StandardMaterial('standardMat', scene);
standardMat.emissiveTexture = new BABYLON.Texture(
    'https://cdn.nlark.com/yuque/0/2024/png/451257/1721553497235-c398596c-37fc-4a7d-a3db-28da4c684601.png?x-oss-process=image%2Fformat%2Cwebp%2Fresize%2Cw_1024%2Climit_0',
    scene
);

// 创建球,并将材质设置给它
const sphere0 = BABYLON.MeshBuilder.CreateSphere('sphere0', {}, scene);
sphere0.material = standardMat;

// 在 ready 之后执行
scene.executeWhenReady(() => {
    console.log('scene is ready:', scene.isReady());
    console.log('the material is ready:', standardMat.isReady());
}, true);

打印结果为:

scene is ready: false

// 过了一段时间后打印
scene is ready: true
the material is ready: false

最后一个 the material is readytrue还是 false取决于上面的 Texture是否成功加载,由于我这里是在 babylonplayground 中加载的语雀的一个图片,存在跨域问题,所以加载失败了。不过 Texture的加载结果并不影响 scene.isReady()的结果,只要执行完了网格、纹理等的加载,scene.isReady()就算是 true

上面的案例可以看出,scene本身的创建也许是很快的,但是要等待场景里所有的东西准备好,达到能交互的状态还是需要一些时间的。

scene.whenReadyAsync()

你还可以通过异步的方式来监听到场景准备好这件事:

await scene.whenReadyAsync();

// todo...

从源码上说,它也就只是对 scene.executeWhenReady()做了一层封装:

/**
 * Returns a promise that resolves when the scene is ready
 * @param checkRenderTargets true to also check that the meshes rendered as part of a render target are ready (default: false)
 * @returns A promise that resolves when the scene is ready
 */
public whenReadyAsync(checkRenderTargets = false): Promise<void> {
    return new Promise((resolve) => {
        this.executeWhenReady(() => {
            resolve();
        }, checkRenderTargets);
    });
}
scene.onReadyObservable.add

除了通过 scene.executeWhenReady注册一个回调函数来处理场景创建完后的逻辑外,还可以直接通过 scene.onReadyObservable来注册多个回调函数。

对于 scene.onReadyObservable.add,首先它用于在渲染场景准备好之后触发事件,返回值是一个 Observable对象(它是 Babylon.js 内部封装的一个用于处理发布订阅的类,在后面有一个章节我们专门来说这部分),在此时你理解它会返回一个实例对象,这个实例对象能让我们更好的处理这个事件,例如对它进行解绑。

来看看使用:

// 方式一:调用 add 进行监听
const readyObserver1 = scene.onReadyObservable.add((scene) => {
    
});
// 返回的 readyObserver1 用于解绑
scene.onReadyObservable.remove(readyObserver1);
// or 调用自身的 remove 方法
readyObserver1.remove();

// 方式二:监听一次
const readyObserver1 = scene.onReadyObservable.addOnce((scene) => {
    
});

请注意,这里的 scene.onReadyObservable是可以允许你注册多个函数的,意思是你可以调用多次 scene.onReadyObservable.add()来注册不同的函数,然后每次调用返回你的那个 observer可以用来解绑。当然你可以使用 scene.onReadyObservable.clear()来清空所有注册的函数。

另一种解绑的方式是,调用 removeCallback来解绑:

// 注册
function readyFunc(scene) {
  // todo...
}
scene.onReadyObservable.add(readyFunc);

// 解绑:
scene.onReadyObservable.removeCallback(readyFunc);

onReadyObservable 和 executeWhenReady() 的区别

这俩其实没啥大的区别,甚至在 executeWhenReady()内部,用的其实就是 onReadyObservable

/**
 * Registers a function to be executed when the scene is ready
 * @param func - the function to be executed
 * @param checkRenderTargets true to also check that the meshes rendered as part of a render target are ready (default: false)
 */
public executeWhenReady(func: () => void, checkRenderTargets = false): void {
    this.onReadyObservable.addOnce(func);

    if (this._executeWhenReadyTimeoutId !== null) {
        return;
    }

    this._checkIsReady(checkRenderTargets);
}

Scene Dispose

关于场景的销毁,也有对应的 APIscene.dispose()

出于性能和内存考虑,当我们不再需要某个场景的时候,可以通过调用它来完成场景的销毁,用于释放场景及其所有资源,避免内存泄漏。

在其中,会将当前场景所有的动画停止,然后关于这个 scene的所有的监听都解绑,释放包括:网格、材质、纹理、光源、相机、动画、粒子系统、骨骼等等。

场景渲染相关

看完了创建和销毁,再来看看渲染。我们知道,使用 Babylon.js 成功渲染 3D 世界其中一个重要的步骤就是:

// 注册一个渲染循环,以便在每一帧都渲染场景
engine.runRenderLoop(function () {
  scene.render();
});

这个步骤在第一章节中有做过简单的介绍。那么此刻,我们来详细的看一下与场景渲染有关的这些 API。

scene.render()

第一个就是 scene.render()。它表示的就是渲染 scene 这个场景。

调用此方法,Babylon.js内部会做这么几件事:

1、更新动画和物理引擎

2、触发 onBeforeRenderObservable事件:在渲染之前通知所有观察者。(在下面会说)

3、渲染每个相机:对每个活动的相机进行渲染,并在渲染前后触发相应的事件。

4、触发 onAfterRenderObservable事件:在渲染之后通知所有观察者。

通过这些步骤,scene.render方法确保场景中的所有元素都被正确更新和渲染,并且在这个过程中也会有很多的钩子事件被抛出来,允许开发者在渲染过程的不同阶段插入自定义逻辑。

关于参数部分,它还有两个可选参数:

  • updateCameras:指示摄像机是否必须根据输入进行更新(默认为true)
  • ignoreAnimations:指示动画是否不应该执行(默认为 false)
updateCameras

这两个参数其实都很好理解。第一个的话,简单来说就是你当前场景中的相机的输入起不起作用(输入指的是鼠标、键盘呀这类操控相机视角的功能)。

正常情况下,在场景中创建一个相机,相机都默认会带有一些能够交互的行为,这一块在相机的那一章节会着重说哈。在此,我们可以简单理解为 updateCamerasfalse 的时候,相机的这些交互行为产生的结果都不会在此期间渲染。

那么交互行为产生的结果不被渲染,是否就表示相机的交互行为被禁用呢?这个还真不一定,我们可以来做一个小小的实验:

1、创建一个 ArcRotateCamera相机,这个相机能响应鼠标和键盘事件

2、监听该相机的 viewMatrixChangedObservable事件,这个事件表示相机视图矩阵更新时会通知订阅者,正常情况下,我们操作相机改变视图都会触发该事件

3、定义一个变量来动态调整 updateCameras 的参数

我想做的实验是:一开始,updateCamerasfalse,表示此时用鼠标操作相机操作不了,相机所有的操作都不会被更新渲染。然后我突然设置 updateCamerastrue,这时候,会发生什么呢?

思考一下,如果我们的假设是:updateCamerasfalse会禁用相机的交互行为,使得输入完全没用,那么突然设置 updateCamerastrue的时候,其实什么也不会发生,只不过后面你就可以操控相机了。

但还有一种假设是:updateCamerasfalse 并不会禁用相机的交互行为,这些交互行为依旧会被记入,只不过是不会渲染。等到啥时候 updateCamerastrue 的时候,再更新。

--------- 手动分割线 ---------

嘿嘿,来看看代码:

  // 简单点,直接在 window 上定义一个变量用于操控 updateCameras
  window.flag = false;

  const createScene = function () {
    // 创建一个场景
    const scene = new BABYLON.Scene(engine);
    // 创建一个摄像机,并将其放置在场景中
    var camera = new BABYLON.ArcRotateCamera("Camera", 0, 0, 10, new BABYLON.Vector3(0, 0, 0), scene);
    // 摄像机指向场景的原点
    camera.setTarget(BABYLON.Vector3.Zero());
    // 使摄像机响应鼠标和键盘事件
    camera.attachControl(canvas, true);

    camera.onViewMatrixChangedObservable.add(() => {
      console.log('onViewMatrixChangedObservable');
    });
    // 创建一个简单的环境光源
    const light = new BABYLON.HemisphericLight("light", new BABYLON.Vector3(0, 1, 0), scene);
    // 创建一个立方体
    const box = BABYLON.MeshBuilder.CreateBox("box", { size: 1 }, scene);

    return scene;
  };

  // 创建场景
  const scene = createScene();
  // 注册一个渲染循环,以便在每一帧都渲染场景
  engine.runRenderLoop(function () {
    scene.render(window.flag);
  });

最终的效果就是:

1、一开始 window.flagfalse 的时候,怎么操作相机都没有反应,控制台除了初始时打印了一次 onViewMatrixChangedObservable,后面操作的时候没有任何的日志打印

2、我在控制台设置 window.flag = true的一瞬间,相机就突然疯狂旋转,把我前面的那些操作都执行了,并且控制台疯狂打印 onViewMatrixChangedObservable

看到这个结果,答案其实已经非常明显了。这些交互行为依旧会被记入,只不过是不会更新渲染,然后放开 updateCameras的时候,就都执行了。

事实上,我们如果去看 Scene 的源码(packages/dev/core/src/scene.ts),也能很快看到它的作用。无非就是要不要调用 camera.update()。虽然我们没有学过 camera.update() 是干啥的,但是大致也能猜出意思是是否要更新相机状态。

另一个参数 ignoreAnimations 在此就先不展开,我们了解它是控制动画执行的就行,在后面动画的章节会进行讲解。

场景渲染前

scene.onBeforeRenderObservable

作用:

上面谈到了调用 scene.render()的时候,也会有很多的钩子事件被抛出来,scene.onBeforeRenderObservable 就是其中的一个。

我们可以通过订阅这个事件,在渲染场景之前执行一些自定义逻辑,例如更新场景中的对象、处理输入或执行其他需要在渲染之前完成的操作。

使用方式:

// 方式一:调用 add 进行监听
const beforeRenderObserver1 = scene.onBeforeRenderObservable.add((scene) => {
    
});
// 返回的 beforeRenderObserver1 用于解绑
scene.onBeforeRenderObservable.remove(beforeRenderObserver1);
// or 调用自身的 remove 方法
beforeRenderObserver1.remove();

// 方式二:监听一次
const beforeRenderObserver1 = scene.onBeforeRenderObservable.addOnce((scene) => {
    
});

请注意,这里的 scene.onBeforeRenderObservable是可以允许你注册多个函数的,意思是你可以调用多次 scene.onBeforeRenderObservable.add()来注册不同的函数,然后每次调用返回你的那个 observer可以用来解绑。当然你可以使用 scene.onBeforeRenderObservable.clear()来清空所有注册的函数。和上面介绍过的 scene.onReadyObservable的使用是一样的。

另一种解绑的方式是,调用 removeCallback来解绑:

// 注册
function beforeRenderFunc(scene) {
  // todo...
}
scene.onBeforeRenderObservable.add(beforeRenderFunc);

// 解绑:
scene.onBeforeRenderObservable.removeCallback(beforeRenderFunc);

回调函数参数:

const beforeRenderObserver1 = scene.onBeforeRenderObservable.add((scene) => {
    // 这里可以拿到回调参数的返回值,当前场景的对象 scene
});

案例:

接下来一起来看个小例子:

我们在渲染场景之前,改变 boxy方向的角度:

// 创建一个立方体
const box = MeshBuilder.CreateBox('box', {}, scene);

// 订阅 onBeforeRenderObservable 事件
scene.onBeforeRenderObservable.add(() => {
    // 在渲染场景之前执行的逻辑
    box.rotation.y += 0.01; // 旋转立方体
    console.log('Before rendering the scene');
});

在线预览地址:playground.babylonjs.com/#MJNICE#163…

效果如下:

在每帧渲染之前,我们都去改变 boxy方向的角度做递增,所以在真正执行渲染的时候,就像是给 box设置了一个旋转动画。

一般在这个阶段,我们可以用来:更新 Babylon.js节点对象的状态(位置、旋转、缩放等属性)

处理用户的输入、物理引擎更新、性能监控(在渲染之前收集性能数据,例如帧率、渲染时间等)等等。

scene.registerBeforeRender()

另外 scene还提供了 registerBeforeRenderunregisterBeforeRender来注册/解绑场景渲染前的回调。

作用其实和上面的 scene.onBeforeRenderObservable.add一样

毕竟,源码里也就是这么写的(下面为 Scene 源码,其中的 this表示当前的 scene对象):

/**
 * Registers a function to be called before every frame render
 * @param func defines the function to register
 */
public registerBeforeRender(func: () => void): void {
    this.onBeforeRenderObservable.add(func);
}

/**
 * Unregisters a function called before every frame render
 * @param func defines the function to unregister
 */
public unregisterBeforeRender(func: () => void): void {
    this.onBeforeRenderObservable.removeCallback(func);
}

(这里通过 scene.registerBeforeRender注册的回调函数,在其回调函数参数中实际上还是能拿到 scene对象的,只不过可能源码的 ts类型写错了...写成了 func: () => void,但话又说回来,这个参数其实也没啥用处,毕竟你都能调用 scene的方法了,那直接取回调函数作用域的 scene对象就行了)

scene.beforeRender

除了 scene.onBeforeRenderObservable.addscene.registerBeforeRender(),你还可以直接给 beforeRender赋值:

scene.beforeRender = function () {
  // 在渲染场景之前执行的逻辑
  box.rotation.y += 0.01; // 旋转立方体
  console.log('Before rendering the scene');
};

beforeRender在源码中,是一个 set,接收的是一个函数。

/** Sets a function to be executed before rendering this scene */
public set beforeRender(callback: Nullable<() => void>) {
    if (this._onBeforeRenderObserver) {
        this.onBeforeRenderObservable.remove(this._onBeforeRenderObserver);
    }
    if (callback) {
        this._onBeforeRenderObserver = this.onBeforeRenderObservable.add(callback);
    }
}

可以看到,它本质上用的也是 scene.onBeforeRenderObaservable.add。[哭笑~]

由于它注册进来的回调会被存到 scene实例上的私有变量 _onBeforeRenderObserver上,所以我们要清除它的话,只要设置 scene.beforeRendernull即可:

scene.beforeRender = null;

注意:scene.beforeRender与上面两种注册的方式最大的区别就是,重复赋值,只有最后一次赋值的回调函数才会生效,其他的都没啥差别。

例如:

scene.beforeRender = function () {
  box.rotation.x += 0.01;
};

scene.beforeRender = function () {
  box.rotation.y += 0.01;
};

// 重复赋值,只有这次生效
scene.beforeRender = function () {
  box.rotation.z += 0.01;
};

场景渲染后

有更新之前的钩子,那么肯定也有更新之后的,scene.onAfterRenderObservable就是这个钩子。

返回值、回调函数参数以及使用方式都是和场景渲染前一样,再次不多展开。

scene.onAfterRenderObservable.add((scene) => {
  // 场景渲染后
});

scene.registerAfterRender(() => {
  // 场景渲染后
});
scene.unregisterAfterRender(() => {
  // 场景渲染后
});


scene.afterRender = function () {
  // 场景渲染后
}

后语

知识无价,支持原创!这篇文章主要是让大家了解了一下 Babylon.js 中有关于 Scene的生命周期相关的事件和 API,在实际工程化中是必不可少的知识点,不过更多 Scene相关的内容记得关注下一章节哦!

喜欢霖呆呆的小伙伴还希望可以关注霖呆呆的公众号 LinDaiDai

我会不定时的更新一些前端方面的知识内容以及自己的原创文章🎉。

你的鼓励就是我持续创作的主要动力 😊。

其它相关文章推荐: