b3d-biped

Animated humanoid character controller. Loads a GLB model with skeletal animations and drives it via ControlInput.

Demo

import { b3d, b3dBiped, b3dLight, b3dSkybox, b3dGround, label3d, select3d, slider3d } from 'tosijs-3d'
import { orbitCam } from 'tosijs-3d/demo-utils'
import { elements, tosi } from 'tosijs'

const animations = [
  'idle', 'walk', 'run', 'sneak', 'climb', 'walkBackwards',
  'jump', 'running-jump', 'salute', 'wave',
  'tread-water', 'swim', 'talk', 'look', 'dance', 'pickup', 'pilot',
]

const { bipedDemo } = tosi({
  bipedDemo: {
    animation: 'run',
    speed: 0.25,
  }
})

const biped = b3dBiped({
  url: '/omnidude.glb',
  animation: bipedDemo.animation,
  animationSpeed: bipedDemo.speed,
})

preview.append(
  b3d(
    {
      scenePanel: () => [
        label3d({ text: 'Biped' }),
        select3d({ label: 'animation', value: bipedDemo.animation, options: animations }),
        slider3d({ label: 'speed', value: bipedDemo.speed, min: 0, max: 2, step: 0.1 }),
      ],
      sceneCreated(el, BABYLON) {
        const camera = orbitCam(el, {
          alpha: -Math.PI / 2, beta: Math.PI / 3.5, radius: 2, target: [0, 0.5, 0],
        })
        camera.lowerRadiusLimit = 1.5
        camera.upperRadiusLimit = 10
      },
    },
    b3dLight({ y: 1, intensity: 0.7 }),
    b3dSkybox({ timeOfDay: 10 }),
    b3dGround({ width: 10, height: 10 }),
    biped,
  ),
)
tosi-b3d { width: 100%; height: 100%; }

Demo — aiming and shooting

WASD to move, mouse / right stick to aim, F or the trigger to shoot the cans off the wall. The white marker is where he is FACING; the orange one is where he is AIMING, and they are different things — which is the whole point of aiming="on".

Watch the feet. Standing, the shoulders hold a twist of up to aimFreeYaw and the legs stay planted, because that is a pose a person holds. Start walking and the band narrows, so the body comes round to the aim — which also means walking forward walks where you are looking, the strafing-shooter feel, out of one number rather than a movement mode.

The launcher is simply NESTED. It fires along the character's aim rather than along its own mesh, because a gun parented to a hand inherits whatever the animation is doing with that hand.

import { assetUrl, b3d, b3dBiped, b3dLauncher, b3dDestroyable, b3dLight, b3dSun, b3dSkybox, b3dGround, inputFocus, sceneDelta, label3d, slider3d, ualAnimationStates } from 'tosijs-3d'
import { tosi } from 'tosijs'

const demo = tosi({ shootDemo: { free: 45, rate: 6 } })
const s = demo.shootDemo

// Nested, so `x`/`y`/`z` are where it rides ON HIM. NEGATIVE x is his right:
// facing -Z with +Y up, a character's right hand is at -X, and the positive
// value hung it off his left shoulder.
const gun = b3dLauncher({
  x: -0.28, y: 1.15, z: 0.5,
  muzzleSpeed: 45, fireRate: 6, gravity: -2, projRadius: 0.08,
  ammo: 999, reloadRate: 40, damage: 25, projColor: '#ffdd66',
})

// Assigned as a statement, not inline in the scene — the shape the rest of
// these demos use, and one the doc transpiler is known to be happy with.
// The UAL rig, because aiming is the demo: at 1.83m the gun sits at 1.25 where
// it was placed, and `aimFreeYaw` is a real shoulder angle rather than an angle
// on a figure half the intended size.
const hero = b3dBiped(
  {
    url: assetUrl('quaternius/UAL1_core.glb', 2),
    animationStates: ualAnimationStates(),
    player: true, cameraType: 'follow', aiming: 'on', aimFreeYaw: 45,
  },
  gun
)

const cans = []
for (let i = 0; i < 7; i++) {
  cans.push(b3dDestroyable({
    x: -4.5 + i * 1.5, y: 1.6, z: -9,
    size: 0.45, color: '#cc5533',
  }))
}

preview.append(
  b3d(
    {
      style: 'width:100%;height:100%',
      gamepad: 'left_stick,right_stick,B,Y', // Y toggles first person
      scenePanel: () => [
        label3d({ text: 'Aim & shoot' }),
        slider3d({
          label: 'free twist', value: s.free, min: 0, max: 90, step: 5, showValue: 'always',
          handleChange: (v) => { s.free = Math.round(v); hero.aimFreeYaw = Math.round(v) },
        }),
        slider3d({
          label: 'fire rate', value: s.rate, min: 1, max: 12, step: 1, showValue: 'always',
          handleChange: (v) => { gun.fireRate = Math.round(v) },
        }),
      ],
      sceneCreated(el, BABYLON) {
        // No camera of our own: a `player` biped brings a follow camera, and a
        // second one is not merely redundant — an extra active camera makes
        // every scene observer run again, which is the 2x-time bug in person.

        // Two markers, because the claim is that facing and aim are DIFFERENT.
        const bar = (name, color) => {
          const m = BABYLON.MeshBuilder.CreateBox(name, { width: 0.09, height: 0.09, depth: 2.4 }, el.scene)
          const mat = new BABYLON.StandardMaterial(name + '-m', el.scene)
          mat.diffuseColor = BABYLON.Color3.FromHexString(color)
          mat.emissiveColor = BABYLON.Color3.FromHexString(color).scale(0.4)
          m.material = mat
          m.isPickable = false
          return m
        }
        const facingBar = bar('facing', '#ffffff')
        const aimBar = bar('aim', '#ff9944')

        el.scene.onBeforeRenderObservable.add(() => {
          sceneDelta(el.scene)
          if (!hero || !hero.entries || !hero.entries.rootNodes[0]) return
          const root = hero.entries.rootNodes[0]
          const p = root.absolutePosition
          const f = root.forward
          facingBar.position.set(p.x + f.x * 1.4, 1.0, p.z + f.z * 1.4)
          facingBar.rotation.set(0, Math.atan2(f.x, f.z), 0)
          const d = hero.aimDirection
          const o = hero.aimOrigin
          aimBar.position.set(o.x + d.x * 1.2, o.y + d.y * 1.2, o.z + d.z * 1.2)
          aimBar.rotation.set(Math.asin(-d.y), Math.atan2(d.x, d.z), 0)
        })
      },
    },
    b3dSkybox({ timeOfDay: 10 }),
    b3dSun({ shadowMaxZ: 60, activeDistance: 40 }),
    b3dLight({ intensity: 0.5, groundColor: '#4b5348' }),
    b3dGround({ meshName: 'ground_nocast', width: 40, height: 40, color: '#7f8a6c', texture: 'noise', textureTiles: 6 }),
    ...cans,
    inputFocus(hero)
  )
)
.preview { height: 100%; }

Attributes

Attribute Default Description
url '' GLB model URL
skin '' Optional albedo texture URL applied to the skin material (reskin)
scale 1 Uniform scale applied to the loaded model
animation '' Current animation state name
animationSpeed 1 Playback speed multiplier (0–2)
player false Whether this biped receives input
cameraType 'none' 'follow', 'xr', or 'none'
turnSpeed 180 Degrees per second
forwardSpeed 2 Walk speed
runSpeed 5 Sprint speed
backwardSpeed 1 Backward speed
cameraHeightOffset 1 Camera height above target
cameraTargetHeight 0.75 Height of the point the camera looks at
cameraMinFollowDistance 2 Closest follow distance
cameraMaxFollowDistance 5 Furthest follow distance

Animations

The biped automatically transitions between animation states based on input: idle, walk, run, walkBackwards, sneak, jump, swim, dance, pilot, etc.

Animation names in the GLB must match these names.

Usage

import { b3d, b3dBiped, gameController, inputFocus } from 'tosijs-3d'

document.body.append(
  b3d({},
    inputFocus(
      gameController(),
      b3dBiped({
        url: '/character.glb',
        player: true,
        cameraType: 'follow',
        initialState: 'idle',
      })
    )
  )
)