BDR-XR (Brain-Controlled Drone Racing in XR) is a WebXR application built with Babylon.js. It provides a 3D environment where a user can fly a virtual drone along a floating racetrack over water. The application is designed to integrate with an external EEG (brain-signal) pipeline: it subscribes to an MQTT broker and displays a live "power" value from that pipeline in the VR interface. The same power value can later be used to drive or modulate gameplay (e.g., automatic nudge or throttle).
The project supports two ways to run:
-
Web app (primary): Open
index.htmlin a browser (or serve the folder with a local server). You get the full 3D scene, drone, desktop and VR UI, and in-browser MQTT. You can fly the drone with the keyboard (desktop) or with VR controller thumbsticks (VR headset). -
Standalone MQTT client: Run
npm start(ornode mqtt_client.js) to subscribe to the same MQTT topic and log EEG power values in the terminal. This is useful for testing the data pipeline without the browser.
| File | Purpose |
|---|---|
| index.html | Entry point for the web app. Loads Babylon.js (core, loaders, GUI, materials library), the MQTT browser library, styles.css, and app.js (deferred). Contains a single fullscreen canvas used by Babylon for rendering. |
| app.js | Main application logic. Defines the XRScene class: creates the Babylon engine and scene, builds the world (sky, ground, track, water, finish line), creates the drone, 2D overlay GUI, and VR 3D UI, connects to MQTT and updates the VR "Power" button label, and sets up WebXR (VR) with camera follow and controller input. Also registers the per-frame logic for takeoff/landing, keyboard and VR stick movement, camera modes, and propeller animation. On DOMContentLoaded, a single XRScene instance is created. |
| mqtt_client.js | Standalone Node.js script. Connects to the same MQTT broker and topic as the web app, parses incoming JSON for processedData.powerValue, and logs it. Not loaded by the browser; run separately with Node for testing or logging. |
| styles.css | Styles the page: fullscreen canvas and layout for the XR button container. |
| package.json | Node project metadata. Declares the mqtt dependency and a start script that runs mqtt_client.js. Used only for the standalone MQTT client. |
| assets/floor.png | Diffuse texture for the racetrack surface. |
| assets/floor_bump.PNG | Bump texture for the racetrack surface. |
Data flow: An external connector (e.g., EEG processing service) publishes JSON messages to the MQTT topic bdrxr/connectorToWeb. Expected payload shape: { "processedData": { "powerValue": "0.000" } }. The web app parses this and stores the value in XRScene.latestPowerValue and updates the VR "Nudge" button text to Power: <value>%. The standalone mqtt_client.js only logs the value.
- index.html loads all scripts and the canvas. The only script that contains application logic is
app.js, loaded withdeferso it runs after the DOM is parsed. - In app.js, when the
DOMContentLoadedevent fires, the code creates one instance ofXRScene. - XRScene constructor obtains the canvas by ID, creates a
BABYLON.Enginebound to it, then callscreateScene()to build the 3D world. It starts the engine render loop (each frame callsthis.scene.render()), adds a window resize listener so the engine updates canvas dimensions, and then callsinitializeXR()andinitializeMQTT().
- initializeMQTT() in
app.jssets up the MQTT client used by the web app. It uses the same broker (HiveMQ Cloud over WSS) and topic (bdrxr/connectorToWeb) asmqtt_client.js. On connect, it subscribes to that topic. On eachmessageevent, it parses the payload as JSON; ifdata.processedData.powerValueexists, it assigns it tothis.latestPowerValueand, if the VR nudge button exists, sets its text toPower: <value>%. Other handlers log connection, error, close, and reconnect.
- createScene() creates a new
BABYLON.Sceneand defines shared dimensions as instance properties:trackWidth,trackLength,trackHeight,waterLevel,trackElevation. These are used for the track, camera, drone placement, and boundaries. - It adds a FreeCamera (mouse look only; keyboard movement is removed), a HemisphericLight, and then calls createDrone() to build the drone.
- It sets cameraMode (0 = stationary, 1 = follow, 2 = side view) and calls createGUI() for the 2D overlay and setupDroneControls() for the main per-frame logic.
- It builds a skybox (large box with a cubemap texture), ground (large plane below water with a tiled texture), the track (box with diffuse and bump textures from
assets/), left and right racing lines (thin strips on the track), water (plane withWaterMaterialand reflections), and the finish line (red strip near the end of the track). The camera’s initial position and target are set so the view looks down the track, and vertical look limits are applied. The finish line Z position is used later as the forward boundary for the drone.
- createDrone() creates a TransformNode (
droneContainer) as the root. It adds a box for the body and, in a loop, four arms and four propeller cylinders at the corners, all parented to the container. The container is positioned at the start of the track (slightly in front of the camera).initialDronePositionis stored for the Reset action;flyingHeightandisFlyingare set; andthis.droneis set to the container so the rest of the code moves the whole drone by updating this node.
- createGUI() creates a fullscreen AdvancedDynamicTexture and a horizontal StackPanel at the bottom-left with three buttons: Change View, Lift-Off, Reset Position.
- Change View cycles
cameraMode0 -> 1 -> 2 and updates the camera position/target and button label (Stationary / Follow / Side). In mode 0 the camera is reattached for mouse control; in 1 and 2 it is detached so the camera is driven by the follow logic. - Lift-Off toggles
isFlyingand the button label (Lift-Off / Land). - Reset sets the drone position to
initialDronePosition, setsisFlyingto false, and if camera mode is 0, resets the camera to the default position and target.
- initializeXR() calls
createDefaultXRExperienceAsyncwith the track mesh as the only floor mesh. It then calls createVRUI() to build the VR 3D panel and setupXRControllers() to wire controller input. - It subscribes to onStateChangedObservable. When the state becomes
IN_XR, it saves the current camera mode, forces mode to 1 (follow), sets the XR camera position behind the drone, and adds a onBeforeRenderObservable observer that each frame (while still in XR) computes a target position from the current camera mode (stationary or behind the drone) and lerps the XR camera to it. When the state becomesNOT_IN_XR, it restores the saved camera mode and removes that observer.
- createVRUI() defines a panelOffset used to position the 3D panel relative to the XR camera. Keydown handlers for Q/A, W/S, E/D adjust this offset for layout tuning.
- It creates a GUI3DManager and a PlanePanel with HolographicButton instances: Change View, Lift-Off, Reset, and Nudge. The Nudge button’s text is set to
Power: 0.000%initially and is updated by the MQTT message handler to showthis.latestPowerValue. - Change View in VR toggles only between mode 0 and 1 (stationary / follow) and updates the button text. Lift-Off and Reset behave like the 2D GUI.
- Nudge click: if the drone exists and is flying, it initializes
nudgeState(velocity, acceleration, deceleration, max velocity, target distance) and adds a onBeforeRenderObservable observer. That observer each frame accelerates then decelerates the drone along +Z, applies a small pitch and a tiny vertical wobble, clamps position to the finish line, and when the nudge is done (velocity near zero or distance reached), clears the observer. So the Nudge button triggers a short forward burst; the displayed power value is for feedback (and future use), not yet used to scale the nudge. - Another registerBeforeRender updates the panel’s position each frame to the XR camera position plus
panelOffset, so the panel follows the user in VR.
- setupXRControllers() disables default XR camera collision and clears default controller observables so the app fully controls the drone. On onControllerAddedObservable / onMotionControllerInitObservable it stores left and right controller references.
- A onBeforeRenderObservable observer runs only when the drone is flying. It reads the left controller thumbstick for forward/back (Z) and left/right (X), and the right thumbstick for up/down (Y). It lerps current velocities toward these inputs, applies them to
this.drone.position, and clamps position to the track (Z between start and finish, X within half track width). It also sets the drone’s pitch and roll from velocity for a simple tilt effect.
- setupDroneControls() creates a sceneRoot TransformNode (used in VR to move the world when the camera follows) and a single registerBeforeRender callback that runs every frame.
- Takeoff and landing: If
isFlyingand the drone is belowhoverHeight, it moves the drone up with a velocity that accelerates then decelerates near the target, plus slight random wobble. If not flying and the drone is above the landing height, it moves the drone down the same way. When landed, pitch and roll are zeroed. - Keyboard movement (only when flying): Arrow keys change velocity.z (forward/back) and velocity.x (left/right); Page Up/Down change velocity.y. Velocity is applied with acceleration and deceleration, and drone tilt is set from velocity. Position is updated and then clamped to the track Z and X limits.
- Camera follow: If mode is 1 (follow), in VR the code lerps
sceneRoot.positionso the world is centered relative to the drone (giving a “camera behind drone” feel); on desktop it lerps the camera position behind the drone and sets the target to the drone. If mode is 2 (side), same idea from the side. If mode is 0 and in VR, it lerpssceneRoot.positionback to zero (stationary world). - Propellers: For each of the four propeller meshes, rotation.y is incremented by a speed that depends on whether the drone is flying, transitioning (takeoff/landing), or idle, and on movement velocity.
- XR parenting: When the active camera is in XR, scene meshes (except camera and drone-related names) and the drone container are parented to
sceneRootso that movingsceneRootmoves the world. When not in XR, they are unparented andsceneRoot.positionis reset to zero.
- mqtt_client.js is a Node script. It requires the
mqttpackage, defines the same broker config and topic, and callsmqtt.connect(). Onconnectit subscribes to the topic. Onmessageit parses the payload as JSON and, ifprocessedData.powerValueexists, logs it. It also logs errors, close, and reconnect. This file is not referenced by the HTML; it is run separately for testing or logging the EEG stream.
- Web app: Serve the project folder with any static HTTP server (e.g., from the project root run
npx serveorpython3 -m http.server) and open the provided URL in a browser. For VR, use a WebXR-capable browser and headset. - Standalone MQTT: From the project root run
npm installthennpm start(ornode mqtt_client.js). - Modifying behavior: Change track size or layout in
createScene(dimensions and mesh positions). Change drone shape or start position increateDrone. Change movement speeds and boundaries insetupDroneControls(keyboard) andsetupXRControllers(VR). Change MQTT topic or broker ininitializeMQTTand inmqtt_client.js. To drive nudge or other behavior from the power value, usethis.latestPowerValueinside the nudge observer or elsewhere inapp.js.
The web app and the standalone MQTT client both use HiveMQ Cloud (WSS). Broker hostname, port, username, and password are set in initializeMQTT() in app.js and in mqtt_client.js. To use a different HiveMQ cluster, update the hostname (and optionally port) in both places; keep the same username and password, or obtain new credentials from the project maintainer and update the config accordingly.