b3d-aircraft

Fly-by-wire VTOL controller — a forgiving "drone that becomes a plane" rather than a simulation. The stick commands an ATTITUDE (bank + pitch); the craft eases toward it and self-levels when you let go, banking swings the heading (a coordinated turn), and the velocity simply chases where the nose points. The model is pure and unit-tested in fly-by-wire.

You're in PLANE mode (trigger = forward thrust) if you're fast enough (vtolSpeed) OR above hoverCeiling — so you take off VERTICALLY, and once you clear the ceiling the trigger converts to forward thrust and you fly (gaining altitude by flying, not by hovering higher). Above the ceiling the brake also can't stall you below vtolSpeed, so you can't just decelerate back into a hover up high — you must fly DOWN below the ceiling, slow to a hover, and descend vertically to land (or land conventionally). Below the ceiling the regime is speed-based, so slowing to a hover gives you the vertical trigger back.

Set vtolSpeed to 0 for a pure aeroplane with no hover regime.

Inputs: left stick = pitch + turn (bank), right stick X = aux roll, triggers = lift/throttle (the dual-purpose axis above), right stick Y = camera zoom.

Mesh can come from a url (own GLB) or from a b3d-library via library + meshName.

Demo

import { b3d, b3dAircraft, b3dLibrary, b3dLight, b3dSun, b3dSkybox, b3dGround, gameController, inputFocus } from 'tosijs-3d'
import { elements } from 'tosijs'
const { div, span } = elements

const aircraft = b3dAircraft({
  library: 'vehicles', meshName: 'scout',
  // Start parked on the ground. The model is rested on the surface via its
  // computed bounding box, so y is the height of its belly — y: 0 = grounded.
  player: true, y: 0,
  // Below this forward speed it hovers (triggers = up/down); above it flies like
  // a plane (triggers = throttle). Set to 0 for a pure aeroplane.
  vtolSpeed: 6, maxSpeed: 50,
})

const hud = div({ class: 'hud' },
  span({ class: 'hud-speed' }),
  span({ class: 'hud-alt' }),
  span({ class: 'hud-throttle' }),
  span({ class: 'hud-mode' }),
  span({ class: 'hud-warn' }),
)

const controls = div({ class: 'controls' },
  'W/S: pitch | A/D: turn (bank) | \u2190/\u2192: roll | R: up / faster | Q: down / slower'
)

// Scatter reference markers on the ground. Registering them makes them shadow
// casters, so there are always crisp ground shadows for depth/scale cues — the
// aircraft's own shadow is small and far-offset when it's high up.
function addMarkers(scene) {
  scene.sceneCreated = (owner, BABYLON) => {
    const mat = new BABYLON.StandardMaterial('marker-mat', owner.scene)
    mat.diffuseColor = new BABYLON.Color3(0.2, 0.5, 0.8)
    const boxes = []
    for (let i = 0; i < 40; i++) {
      const x = (Math.random() - 0.5) * 200
      const z = (Math.random() - 0.5) * 200
      const box = BABYLON.MeshBuilder.CreateBox('marker' + i, { size: 2, height: 1 + Math.random() * 4 }, owner.scene)
      box.position.set(x, 0, z)
      box.material = mat
      box.receiveShadows = true
      boxes.push(box)
    }
    owner.register({ meshes: boxes })
  }
  return scene
}

const scene = addMarkers(b3d(
  // On-screen glass gamepad (touch) wired into the input system: left stick
  // pitch/roll, right trigger throttle, etc. via aircraftMapping.
  { gamepad: true },
  // Ambient fill kept low so the directional sun's shadows actually read.
  b3dLight({ y: 1, intensity: 0.4 }),
  // Cascaded shadows cover the whole camera view with a sensible depth range,
  // which suits an aerial scene (aircraft high above a large ground plane).
  // shadowMaxZ spans altitude→ground; activeDistance keeps the aircraft a
  // caster; the low updateIntervalMs keeps caster gating responsive in flight.
  b3dSun({
    x: -0.6, y: -1, z: -0.4,
    intensity: 0.9,
    shadowTextureSize: 2048,
    shadowMaxZ: 300,
    activeDistance: 150,
    updateIntervalMs: 50,
  }),
  b3dSkybox({ timeOfDay: 10 }),
  // `_nocast` so the huge ground only RECEIVES shadows. If it also cast,
  // the sun's auto-fit shadow frustum would stretch to 500 units and the
  // aircraft's shadow would shrink to sub-pixel (i.e. invisible).
  b3dGround({ meshName: 'ground_nocast', width: 500, height: 500, color: '#7d9b6e' }),
  b3dLibrary({ url: '/test-2.glb', type: 'vehicles' }),
  inputFocus(
    gameController(),
    aircraft,
  ),
))

function updateHud() {
  const speedEl = hud.querySelector('.hud-speed')
  const altEl = hud.querySelector('.hud-alt')
  const modeEl = hud.querySelector('.hud-mode')
  const warnEl = hud.querySelector('.hud-warn')
  const throttleEl = hud.querySelector('.hud-throttle')
  speedEl.textContent = `Speed: ${aircraft.airspeed.toFixed(0)} m/s`
  altEl.textContent = `Alt: ${aircraft.altitude.toFixed(0)} m`
  throttleEl.textContent = `Throttle: ${(aircraft.throttleLevel * 100).toFixed(0)}%`
  modeEl.textContent = aircraft.vtolActive ? 'VTOL' : 'FLIGHT'
  const warnings = []
  if (aircraft.stalling) warnings.push('STALL')
  if (aircraft.pullUp) warnings.push('PULL UP')
  warnEl.textContent = warnings.join(' | ')
  warnEl.style.color = warnings.length ? '#ff4444' : 'white'
  requestAnimationFrame(updateHud)
}

preview.append(scene, hud, controls)
requestAnimationFrame(updateHud)
tosi-b3d { width: 100%; height: 100%; }
.hud {
  position: absolute;
  bottom: 10px;
  left: 10px;
  display: flex;
  gap: 16px;
  padding: 8px 16px;
  background: rgba(0, 0, 0, 0.6);
  color: white;
  border-radius: 6px;
  font: 14px monospace;
  z-index: 10;
}
.controls {
  position: absolute;
  top: 10px;
  left: 10px;
  padding: 6px 12px;
  background: rgba(0, 0, 0, 0.5);
  color: #ccc;
  border-radius: 4px;
  font: 12px monospace;
  z-index: 10;
}

Attributes

Attribute Default Description
url '' GLB model URL (direct load)
library '' Library type to source mesh from
meshName '' Node name to instantiate from library
enterable false Whether a biped can enter
maxSpeed 50 Normal top speed (m/s) — the cruise cap a released throttle settles at
afterburnerSpeed 75 Speed ceiling while the throttle is held past maxSpeed; releasing bleeds back to maxSpeed. ≤ maxSpeed disables afterburner.
acceleration 12 Throttle / lean authority (speed change rate)
vtolSpeed 6 Forward ground speed splitting hover (below) from plane (above). 0 = pure aeroplane, no hover regime.
hoverCeiling 50 Height above ground above which the trigger is forward thrust regardless of speed (take off vertically, then fly) and the brake can't stall you below vtolSpeed. Below it, slowing to a hover gives the vertical trigger back for a vertical landing. 0 = off.
groundY 0 Assumed ground-plane height (a floor in addition to any terrain colliders)
crashSpeed 8 Vertical impact speed (m/s) above which a ground contact is a crash

API (read-only properties for HUD binding)

On the ground the wings hold level and the turn stick taxi-steers; pulling back rotates for takeoff (or a VTOL lifts straight up on the right trigger). A contact faster than crashSpeed, or banked/inverted, crashes instead of lands.