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, b3dHud, 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' }),
// Drop in the gauge HUD — the player aircraft drives it automatically (speed,
// altitude vs `ceiling`, and the pitch/roll horizon).
b3dHud({}),
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;
}
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:
- acquiring — the glyph fills with white, from nothing to half, as the lock builds, while the outline stays the faction colour. Hold the nose on him and watch it fill; let him drift wide and watch it drain back.
- locked — the outline snaps to white, and the fill hands back the faction colour, bolder. Deliberately a different KIND of change, so you read it instantly in peripheral vision instead of squinting at how full a fill is — and because the two channels trade jobs, a locked contact never stops telling you what it is.
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.)
import { b3d, b3dAircraft, b3dRadar, b3dRadarBlip, b3dHud, b3dLibrary, b3dDestroyable, b3dLight, b3dSun, b3dSkybox, b3dGround, gameController, inputFocus } from 'tosijs-3d'
import { elements } from 'tosijs'
const { div } = elements
// The aircraft with an attached radar: 250m nominal range, front hemisphere, ~1.2s to
// lock, up to 2 locks. Its state is surfaced on the HUD (the radar itself has no UI).
const RADAR_RANGE = 250 // nominal radar range (m); a profile-1 blip detects within it
const MAX_ALT = 300 // the aircraft's max altitude (its `ceiling`, default 300)
const aircraft = b3dAircraft({
library: 'vehicles', meshName: 'scout',
player: true, y: 0, vtolSpeed: 6, maxSpeed: 55,
hudChase: true, // show the HUD (and its radar) in the chase view, not just cockpit
}, b3dRadar({ range: RADAR_RANGE, coneDeg: 90, lockTime: 1.2, maxLocks: 2 }))
// A target = a destroyable cube that's ALSO a radar-blip (nested, so the blip follows
// the cube). Faction picks the colour + whether the radar will lock it: HOSTILE locks,
// NEUTRAL only shows. capacity 6 ≈ one cannon burst or one missile.
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 }),
)
}
// Scatter targets across a wide forward arc, 0.5×–1.5× radar range out — so some sit
// BEYOND radar range and only appear as you close on them. AERIAL targets span 0.1×–
// 1.25× the aircraft's max altitude (a few above its ceiling → radar contacts you can
// only reach with a missile); GROUND targets sit on the deck.
function scatter(aerial) {
const d = RADAR_RANGE * (0.5 + Math.random()) // 0.5×..1.5× range
const az = (Math.random() - 0.5) * (170 * Math.PI / 180) // ±85° 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}`
})
// Gently 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.5 }),
b3dSun({ x: -0.6, y: -1, z: -0.4, intensity: 0.9, shadowTextureSize: 2048, shadowMaxZ: 300 }),
b3dSkybox({ timeOfDay: 10 }),
b3dGround({ meshName: 'ground_nocast', width: 900, height: 900, color: '#7d9b6e' }),
b3dLibrary({ url: '/test-2.glb', type: 'vehicles' }),
b3dHud({}),
// A NAV WAYPOINT: a positional blip (no mesh), always detectable (profile -1),
// shown far ahead on the HUD as a waypoint diamond.
b3dRadarBlip({ faction: 'waypoint', profile: -1, x: 0, y: 25, z: 300 }),
...targets,
inputFocus(gameController(), aircraft),
)
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;
}
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.
- Hover / drone (slow, below the ceiling): right trigger climbs, left trigger descends. Let go and it bleeds back to a stationary hover.
- Plane (fast): right trigger speeds up, left trigger slows down; speed holds
steady when you let go. Holding throttle past
maxSpeedenters afterburner (up toafterburnerSpeed); release and it bleeds back tomaxSpeed. Pitch is climb/dive, the turn stick banks to turn. Slow back belowvtolSpeedand the triggers return to up/down. Banking off level costs a little altitude.
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) |
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)
airspeed: number— current forward speed (m/s)altitude: number— height above groundvtolActive: boolean— true in the hover regime (belowvtolSpeed)pullUp: boolean— true when ground collision predicted within ~5sgrounded: boolean— true when settled on the ground (wheels/rolling resistance)crashed: boolean— true after a hard/inverted ground impact; fires acrashevent
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.