Skip to main content

@realitycollective/service-framework-babylon

Babylon.js render-loop bindings for the Reality Collective TypeScript Service Framework.

Provides the same renderTick contract and the same RuntimeAdapter seam as @realitycollective/service-framework-three. Services written against BaseService<TConfig> or against RuntimeAdapter run unchanged on either renderer.


Packages

PackageVersionDescription
@realitycollective/service-framework-babylonVersioned with the repository; see the current release line in the root READMEThis package

Quick start

import { ManualScheduler, ServiceManager, createServiceProfile, createServiceToken, BaseService } from "@realitycollective/service-framework";
import { BabylonRenderLoopBridge } from "@realitycollective/service-framework-babylon";
import { Engine, Scene } from "@babylonjs/core";

// 1. Create a scheduler and manager
const scheduler = new ManualScheduler();
const manager = new ServiceManager({ scheduler });

// 2. Register your services
manager.initializeProfile(createServiceProfile("my-app", [/* registrations */]));
manager.start();

// 3. Wire the bridge to the engine (engine created by your scene service)
const engine = new Engine(canvas, true);
const bridge = new BabylonRenderLoopBridge({ scheduler, host: engine });
bridge.start();

The bridge emits renderTick on the scheduler every frame. Services receive it through render(context: LifecycleContext) or by subscribing directly:

this.scheduler.subscribe("renderTick", ctx => {
// ctx.deltaTime - milliseconds since last frame (16 on first frame)
// ctx.frame - monotonically increasing frame counter
// ctx.source - "babylon"
});

Relationship to service-framework-three

Both bridges implement the identical renderTick contract. The difference is the engine API:

Three.jsBabylon.js
Loop APIrenderer.setAnimationLoop(cb)engine.runRenderLoop(cb)
TimestampProvided by browser as callback argRead from performance.now()
First-frame delta16 ms16 ms
Runtime adapterWebXRRuntimeAdapter, over navigator.xr and renderer.xrBabylonRuntimeAdapter, over WebXRDefaultExperience
Session negotiationnavigator.xr.requestSessionbaseExperience.enterXRAsync

Services that listen to renderTick, or that depend on RuntimeAdapter, are renderer-agnostic - only the bootstrap code changes.


WebXR runtime adapter

BabylonRuntimeAdapter gives a Babylon app the RuntimeAdapter seam from @realitycollective/service-framework: per-frame fan-out, capability flags, and a session facet. It is the Babylon counterpart of the three.js package's WebXRRuntimeAdapter, and behaves the same way, so a service written against RuntimeAdapter runs on either renderer and unit-tests headless against MockRuntimeAdapter.

The adapter orchestrates the entry points Babylon already provides - the experience helper for session negotiation, the session manager for the live XRSession, runRenderLoop for frames. It renders nothing and owns no scene state.

import { ServiceManager } from "@realitycollective/service-framework";
import { BabylonRuntimeAdapter } from "@realitycollective/service-framework-babylon";
import { Engine, Scene, WebXRDefaultExperience } from "@babylonjs/core";

const engine = new Engine(canvas, true);
const scene = new Scene(engine);
const manager = new ServiceManager();

// disableDefaultUI, because the app owns the button and the adapter owns the session.
const xr = await WebXRDefaultExperience.CreateAsync(scene, { disableDefaultUI: true });
const adapter = new BabylonRuntimeAdapter({
xr: xr.baseExperience,
host: engine,
scheduler: manager.scheduler
});

// The adapter now owns the render loop and emits renderTick itself. Do not also
// start a BabylonRenderLoopBridge: one loop owner is enough.
adapter.start();

document.querySelector("#enter-vr")?.addEventListener("click", async () => {
const result = await adapter.session.request("immersive-vr");

if (!result.ok) {
console.warn(`No session: ${result.reason}`);
}
});

Pass xr and omit host to keep the loop yourself; the app then calls adapter.emitFrame(timestamp, deltaSeconds) per frame. Pass host and omit scheduler to own the loop without emitting renderTick.

Session requests resolve with a result rather than throwing, because a host that cannot start a session is a normal runtime condition:

result.reasonMeaning
unsupportedNo xr was given, or isSessionSupportedAsync says the mode is unavailable
deniedThe user, the permission prompt or the permissions policy refused
timeoutNothing came back inside timeoutMs (default 10000)
errorAnything else, with the original rejection on result.error

A desktop build with no headset is the ordinary case, not a failure: build the same page, construct the adapter with xr: null (or omit it), and every request returns { ok: false, reason: "unsupported" } while capabilities stay at the all-false defaults. Services gate on getCapabilities() and run in 2D.

The session facet reports getState() as none, requesting, active or ending, and pushes changes through onStateChange. A session started outside the adapter - by Babylon's own enter-XR UI, for instance - is followed through onStateChangedObservable, so the facet is correct either way. onVisibilityChange reports the session's own visible, visible-blurred and hidden, and non-immersive while there is no session at all.

One request can add features of its own through SessionRequestOptions, which matters when an app swaps mode mid-session and the host's defaults were chosen for the mode it is leaving:

await adapter.session.request("immersive-ar", {
requiredFeatures: ["hit-test"],
optionalFeatures: ["plane-detection"],
});

They are merged over what sessionInit returned rather than replacing it: host entries come first, the request's are appended, and a feature named twice appears once. A request that names none passes the hook's result through untouched. The merge is the core's mergeSessionInit, shared with the three.js binding so the two cannot drift.

Capabilities come from the core's deriveCapabilities, re-derived when a session starts or ends and when its input sources change; subscribers are notified only when a flag actually changes. setCapabilities is a manual override layer on top, dropped by clearCapabilityOverrides(). refreshCapabilities() re-derives on demand, for an experience that carries no observables to push with.

Every Babylon type the adapter is written against is structural, so this package still imports @babylonjs/core nowhere and the adapter is tested with fakes. The shapes follow the Babylon 7 API, and anything a version might move or drop - the session manager, the observables, isSessionSupportedAsync - is optional and read through a guard.


Optional base class

BaseBabylonService<TConfig> is a convenience base for secondary services that receive an already-constructed engine and scene through their config:

import { BaseBabylonService, type BabylonServiceConfiguration } from "@realitycollective/service-framework-babylon";

interface MyConfig extends BabylonServiceConfiguration {
readonly meshName: string;
}

class MyService extends BaseBabylonService<MyConfig> {
override start(): void {
this.scheduler.subscribe("renderTick", ctx => this.onRenderTick(ctx));
}

override onRenderTick(): void {
const mesh = this.scene.getMeshByName(this.serviceConfig.meshName);
if (mesh) mesh.rotation.y += 0.01;
this.scene.render();
}
}

Services that own the engine (create it themselves) should extend BaseService<TConfig> directly.


Running tests

From the workspace root (src/com.realitycollective.service-framework.ts/):

npm test

Coverage gates this package. The root include list measures packages/service-framework-babylon/src/**/*.ts at the same 100% line, branch, function and statement thresholds as the core, client, IWSDK and three.js packages.

Running the example app

cd runtime-examples/facilities-viewer-example
npm install
npm run dev

Open http://localhost:5175 - you should see a rotating cube on a dark background.


Contributing

See the main repository contribution guide.

Live examples

License

MIT

Classes

ClassDescription
BabylonRenderLoopBridgeConnects a Babylon.js render loop to the service-framework scheduler's renderTick channel, making Babylon.js a first-class renderer alongside Three.js.
BabylonRuntimeAdapter-
BaseBabylonServiceConvenience base for secondary services that receive a pre-built Engine and Scene through their config (e.g. a model-loader service that runs alongside a scene service which already constructed the engine).

Interfaces

InterfaceDescription
BabylonEngineHostLikeMinimal contract that a Babylon.js Engine (or any compatible mock) must satisfy for the bridge to operate. Using an interface rather than a concrete Engine reference keeps the bridge tree-shakeable and trivially mockable in unit tests.
BabylonObservableLikeThe slice of a Babylon Observable<T> the adapter subscribes through.
BabylonRenderLoopBridgeOptions-
BabylonRuntimeAdapterOptions-
BabylonServiceConfigurationConfig shape required by BaseBabylonService.
BabylonSessionManagerLikeThe slice of Babylon's WebXRSessionManager the adapter reads.
BabylonXRExperienceLikeThe slice of Babylon's WebXRExperienceHelper the adapter drives - that is, WebXRDefaultExperience.baseExperience.
BabylonXRSessionLikeThe slice of the raw XRSession Babylon exposes. It extends the core's CapabilitySessionLike, so a live session goes straight to deriveCapabilities with no mapping.

Type Aliases

Type AliasDescription
BabylonObserverLikeThe handle a Babylon Observable hands back from add. The adapter only stores it and gives it back to remove, so its shape is irrelevant here.
BabylonWebXRState-
BabylonXREventListenerSession event callback. The adapter reads the session, not the event.
BabylonXRSessionEventTypeThe events an XRSession raises that this adapter listens for.

Variables

VariableDescription
BABYLON_SCENE_SERVICE_TOKENWell-known token for the primary Babylon.js scene service.
BABYLON_WEBXR_STATEBabylon's WebXRState enum, mirrored as a plain const so a consumer can read the adapter's state handling without importing @babylonjs/core. The values are Babylon 7's, and are what onStateChangedObservable reports.
DEFAULT_REFERENCE_SPACE_TYPEThe reference space a session is requested with when none is configured.
FIRST_FRAME_DELTA_MSDelta reported for the first frame, where there is no previous timestamp. The bridge and BabylonRuntimeAdapter both report it, so an app that swaps one loop owner for the other sees the same first frame.