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.

Mesh can come from a url (own GLB) or from a b3d-library via library + meshName. The full flight model is explained below the demo.

Demo

import { b3d, b3dAircraft, b3dRadar, b3dRadarBlip, b3dHud, b3dClouds, b3dFog, b3dLibrary, b3dDestroyable, b3dDeath, b3dLight, b3dSun, b3dSkybox, b3dGround, gameController, inputFocus } from 'tosijs-3d'
import { elements } from 'tosijs'
const { div } = elements

const RADAR_RANGE = 250 // nominal radar range (m); a profile-1 blip detects within it
const MAX_ALT = 300     // the aircraft's ceiling (its `ceiling`, default 300)

// A FACTORY — so a respawn is a genuinely new aircraft (with its radar), not a reset. The sim
// really emits a death and a spawn, which is the stream a narrative driver reads (see b3d-death).
// The HUD stays in the COCKPIT view only (hudChase defaults false); the chase view is clean.
const plane = () => b3dAircraft(
  { library: 'vehicles', meshName: 'scout', player: true, y: 0, vtolSpeed: 6, maxSpeed: 55 },
  b3dRadar({ range: RADAR_RANGE, coneDeg: 90, lockTime: 1.2, maxLocks: 2 }),
)
// A respawned aircraft is appended INSIDE the focus manager; it announces itself when ready
// (adoptIfVacant) and the manager takes it because it's driving nobody.
const focus = inputFocus(gameController(), plane())

// A target = a destroyable cube that is ALSO a radar-blip (nested, so the blip follows it).
// Faction picks the colour + whether the radar locks it: HOSTILE locks, NEUTRAL only shows.
function target({ faction, ...pos }) {
  const color = faction === 'hostile' ? '#d05050' : '#c7ad55'
  return b3dDestroyable(
    { meshName: 'drone', size: 2.4, color, capacity: 6, ...pos,
      explode: 'on', explodeForce: 8,
      deathBlast: 'on', blastDamage: 10, blastFullRadius: 2, blastRadius: 6 },
    b3dRadarBlip({ faction, profile: 1 }),
  )
}
function scatter(aerial) {
  const d = RADAR_RANGE * (0.5 + Math.random()) // 0.5x..1.5x range
  const az = (Math.random() - 0.5) * (170 * Math.PI / 180) // +/-85 deg around the nose (+Z)
  return target({
    faction: Math.random() < 0.65 ? 'hostile' : 'neutral',
    x: Math.sin(az) * d,
    z: Math.cos(az) * d,
    y: aerial ? MAX_ALT * (0.1 + Math.random() * 1.15) : 1.0 + Math.random() * 1.2,
  })
}
const air = Array.from({ length: 12 }, () => scatter(true))
const ground = Array.from({ length: 8 }, () => scatter(false))
const targets = [...air, ...ground]

const kills = div({ class: 'kills' }, `Targets down: 0 / ${targets.length}`)
let down = 0

const scene = b3d(
  {
    gamepad: true,
    sceneCreated(el) {
      el.addEventListener('destroyed', () => {
        down += 1
        kills.textContent = `Targets down: ${down} / ${targets.length}`
      })
      // Drift the AIR targets so they move on radar but stay hittable.
      let t = 0
      el.scene.onBeforeRenderObservable.add(() => {
        t += el.scene.getEngine().getDeltaTime() / 1000
        air.forEach((d, i) => {
          if (d.dead) return
          d.x += Math.sin(t * 0.3 + i) * 0.02
          d.y += Math.sin(t * 0.6 + i * 2) * 0.01
        })
      })
    },
  },
  b3dLight({ y: 1, intensity: 0.45 }),
  b3dSun({ x: -0.6, y: -1, z: -0.4, intensity: 0.9, shadowTextureSize: 2048, shadowMaxZ: 300, activeDistance: 150, updateIntervalMs: 50 }),
  b3dSkybox({ timeOfDay: 10 }),
  b3dFog({ start: 200, end: 1200, color: '#cfe0f2' }),
  b3dGround({ meshName: 'ground_nocast', width: 900, height: 900, color: '#7d9b6e' }),
  b3dLibrary({ url: '/test-3.glb', type: 'vehicles' }),
  // Fly UP into the cloud layer — the whiteout is fog (stereo-safe) and reads insideCloud.
  b3dClouds({ model: '/cloud.glb', altitude: 120, thickness: 40, size: 60, coverage: 0.45, castShadows: true, seed: 4 }),
  b3dHud({}),
  // A nav waypoint far ahead: a positional blip (no mesh), always detectable (profile -1).
  b3dRadarBlip({ faction: 'waypoint', profile: -1, x: 0, y: 25, z: 300 }),
  ...targets,
  // DEATH NEEDS AN EXIT: fly into the ground (or get caught in a blast) and it burns, releases
  // input, orbits the wreck, then floats a Respawn panel — which appends a fresh aircraft.
  b3dDeath({ title: 'DOWN', spectate: 'chase', respawn() { focus.appendChild(plane()) } }),
  focus,
)
preview.append(scene, kills)
tosi-b3d { width: 100%; height: 100%; }
.kills {
  position: absolute; top: 10px; right: 10px; z-index: 10;
  padding: 6px 12px; border-radius: 4px;
  background: rgba(0, 0, 0, 0.55); color: #ffcf6a; font: 14px monospace;
}

Combat — radar, locks, guns & missiles

The aircraft carries a radar (a <tosi-b3d-radar> child) that paints every radar-blip in range on the HUD — red = hostile, tan = neutral, a diamond ahead is a waypoint — and builds a lock on the nearest hostile in front of you (up to two). Fly a target into the gun reticle (the ring ahead of the nose) and hold fire for the straight-ahead cannon; tap missile to send a guided round at your nearest lock (no lock ⇒ it flies ballistic). Neutrals show on radar but never lock. Your own missile shows as a faint friendly blip. Targets glow redder as they take damage, then explode.

Watch a contact FILL to read your lock. A lock isn't instant (lockTime) and it decays if the contact slips out of the acquisition cone, so the trace tells you where you stand in two different ways:

That's the decision the mechanic exists to force — stay on him, or break off. Neutrals never fill or go white, because they never lock.

Controls: on the glass pad, A = guns (hold), B = missile, right bumper = bomb. On the keyboard: Space = guns, F = missile, RShift = bomb. (Fly with W/S pitch, A/D bank, R/Q throttle.)

The Demo at the top of this page IS this combat scene — fly it (and crash it).

Flight model

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.

Attributes

Attribute Default Description
url '' GLB model URL (direct load — collapsed through the same canonical frame as a library load: author Blender-default, facing −Y, transforms applied)
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
hudChase false Show the flat DOM HUD overlay in chase view (cockpit uses the in-scene HUD)
hudSize 0.7 In-cockpit HUD plane size (metres)
hudForward 1.6 How far ahead of the pilot's eye the HUD floats (metres)
weapons 'on' 'off' disarms all weapons
gunRate 9 Cannon shots/sec while shoot is held
gunSpeed 130 Cannon muzzle speed (added to airspeed)
gunDamage 8 Per-shell warhead full damage
missileSpeed 55 Guided-missile cruise speed
missileTurnRate 3 Guided-missile agility (rad/sec)
missileDamage 30 Missile warhead full damage
bombDamage 45 Bomb warhead full damage
lockRange 140 Max range to acquire a missile target
lockConeDeg 35 Half-angle of the forward cone missiles lock within

Weapons (the combat slice)

Built on the pure combat toolkit (destroyable / warhead / launcher / guidance). Shells inherit the airframe's velocity, so your own motion leads the shot. Any b3d-destroyable in the scene takes the damage.

Control (default map) Weapon
Guns — A (held) Cannon: fast ballistic shells, small blast
Missile — B (tap) Homes on your nearest radar lock (else fires straight as a dumb rocket)
Bomb — right bumper (tap) Falls under gravity with your forward momentum; big blast

fireGuns(), dropBomb(), and fireMissile() are also callable directly (e.g. for an AI pilot). Set weapons="off" to disarm.

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.