aim

Where a character is pointing, as opposed to where it is facing. Pure — degrees and plain {x, y, z}, no Babylon — so the whole of walk-and-aim can be tested without a rig.

This is the input half of layered animation. bone-mask decides which bones the aim drives and animation-layers plays it; something still has to decide where the upper body is pointing, and that is this. It serves the player's look and an NPC's lead with the same code, which is the point: an NPC that aims through the same slew limit as a player is one whose misses are legible.

Demo — watch the shoulders lead and the feet follow

The white marker is the body's facing, the orange one is where it is aiming, and the red sphere is what it is aiming at. Nothing here is a character: it is the model on its own, so you can see the rule rather than an animation of it.

What to watch for:

import { b3d, b3dSun, b3dSkybox, b3dLight, b3dGround, sceneDelta, slider3d, label3d } from 'tosijs-3d'
import { aimToward, stepAim, bodyCatchUp, aimWobble, wrapDeg, aimAuthority } from 'tosijs-3d'
import { orbitCam } from 'tosijs-3d/demo-utils'
import { tosi } from 'tosijs'

const demo = tosi({ aimDemo: { free: 45, max: 90, slew: 200, rate: 120, orbit: 0.5, wobble: 0 } })
const s = demo.aimDemo

let readout = null

const panel = () => [
  label3d({ text: 'Aim' }),
  slider3d({ label: 'free twist', value: s.free, min: 0, max: 90, step: 5, showValue: 'always',
    handleChange: (v) => { s.free = Math.round(v) } }),
  slider3d({ label: 'max twist', value: s.max, min: 10, max: 150, step: 5, showValue: 'always',
    handleChange: (v) => { s.max = Math.round(v) } }),
  slider3d({ label: 'slew °/s', value: s.slew, min: 15, max: 600, step: 5, showValue: 'always',
    handleChange: (v) => { s.slew = Math.round(v) } }),
  slider3d({ label: 'body turn °/s', value: s.rate, min: 10, max: 360, step: 10, showValue: 'always',
    handleChange: (v) => { s.rate = Math.round(v) } }),
  slider3d({ label: 'target speed', value: s.orbit, min: -1.5, max: 1.5, step: 0.1, showValue: 'always',
    handleChange: (v) => { s.orbit = v } }),
  slider3d({ label: 'wobble °', value: s.wobble, min: 0, max: 12, step: 1, showValue: 'always',
    handleChange: (v) => { s.wobble = Math.round(v) } }),
  label3d({ text: 'white = facing · orange = aim · red = target', muted: true }),
]

preview.append(
  b3d(
    {
      style: 'width:100%;height:100%',
      scenePanelOpen: true,
      scenePanel: panel,
      sceneCreated(el) {
        // Target offset so the rig sits RIGHT of centre: the settings panel
        // covers the left half, and a demo hidden behind its own controls is a
        // demo nobody looks at.
        orbitCam(el, { alpha: 1.1, beta: 0.72, radius: 12, target: [5, 0.9, 0] })

        // The body: a post, with a long white marker for its facing.
        const body = el.make.cylinder({ diameter: 0.8, height: 1.4, color: '#8899aa' })
        body.position.y = 0.7
        const facing = el.make.box({ width: 0.22, height: 0.22, depth: 3, color: '#ffffff', glow: 0.25 })
        facing.parent = body
        facing.position.set(0, -0.2, 1.5)
        // The aim marker is NOT parented to the body — it carries body + twist,
        // which is the whole point: they are two different angles.
        const aimMark = el.make.box({ width: 0.2, height: 0.2, depth: 4.6, color: '#ff9944', glow: 0.5 })
        const target = el.make.sphere({ diameter: 0.5, color: '#ee3344', glow: 0.6 })

        let bodyYaw = 0
        let aim = { yawDeg: 0, pitchDeg: 0 }
        let t = 0
        let angle = 0

        el.scene.onBeforeRenderObservable.add(() => {
          const dt = sceneDelta(el.scene)
          t += dt
          angle += Number(s.orbit) * dt
          const r = 5
          target.position.set(Math.sin(angle) * r, 0.9, Math.cos(angle) * r)

          const limits = {
            yawFree: Number(s.free), yawMax: Number(s.max),
            pitchUp: 60, pitchDown: 75,
          }
          // Where the target is, as an aim this body could hold.
          const want = aimToward(bodyYaw, {
            x: target.position.x, y: target.position.y - 1.4, z: target.position.z,
          }, limits)
          // A bad shooter cannot hold still — deterministic, so it replays.
          const w = aimWobble(t, Number(s.wobble))
          const wanted = { yawDeg: want.yawDeg + w.yawDeg, pitchDeg: want.pitchDeg + w.pitchDeg }

          aim = stepAim(aim, wanted, dt, { slewDeg: Number(s.slew), limits })
          const caught = bodyCatchUp(aim.yawDeg, dt, Number(s.rate), limits)
          bodyYaw = wrapDeg(bodyYaw + caught.bodyTurnDeg)
          aim = { yawDeg: caught.yawDeg, pitchDeg: aim.pitchDeg }

          body.rotation.y = (bodyYaw * Math.PI) / 180
          const aimYaw = ((bodyYaw + aim.yawDeg) * Math.PI) / 180
          aimMark.rotation.set((aim.pitchDeg * Math.PI) / 180, aimYaw, 0)
          aimMark.position.set(Math.sin(aimYaw) * 2.3, 1.2, Math.cos(aimYaw) * 2.3)

          if (readout) {
            readout.textContent =
              `twist ${aim.yawDeg.toFixed(0)}°   pitch ${aim.pitchDeg.toFixed(0)}°   ` +
              `body ${bodyYaw.toFixed(0)}°   layer authority ${aimAuthority(aim, limits).toFixed(2)}`
          }
        })
      },
    },
    b3dSkybox({ timeOfDay: 10 }),
    b3dSun({ shadowMaxZ: 60, activeDistance: 40 }),
    b3dLight({ intensity: 0.5, groundColor: '#4b5348' }),
    b3dGround({ meshName: 'ground_nocast', width: 30, height: 30, color: '#7c8a6a', texture: 'noise', textureTiles: 5 })
  )
)

readout = document.createElement('div')
readout.style.cssText = 'position:absolute;bottom:8px;right:8px;font:12px monospace;background:#0009;color:#fff;padding:4px 8px;border-radius:4px'
preview.append(readout)
.preview { height: 100%; position: relative; }

Aim is RELATIVE to the body, and that is the whole design

The stored value is the twist between the hips and the shoulders, not a world direction. Everything falls out of that choice:

Two thresholds, which are a hysteresis band wearing a costume

yawFree is the twist you can hold with your feet planted. yawMax is what a spine will do. Between them the body turns to catch up at a rate you choose; beyond yawMax it is dragged, because the alternative is a character whose head is on backwards.

MOBILITY-DESIGN.md predicted this shape from the other direction: cover should "expect hysteresis and budget for it from the start", after isSwimming flickered at its threshold. Here the band is not a fix for flicker, it is the behaviour — but it is the same structure, and for the same reason. One threshold would make a character snap round the instant you nudged the stick.

Positive is RIGHT, and positive is DOWN

Yaw increases clockwise seen from above (matching atan2(x, z) and the biped's own turn), pitch is positive nose-down (matching swim-aim and RotationYawPitchRoll). Both conventions are already load-bearing elsewhere in this repo, and every orientation bug here has come from a conversion at a boundary — so there is no conversion at this boundary.

The NPC path is two calls, deliberately not one

const dir = interceptLead(muzzle, speed, targetPos, targetVel)   // guidance
const want = dir && aimToward(bodyYawDeg, dir)                   // here
aim = stepAim(aim, want, dt, { slewDeg: skill * 240 })           // here

guidance solves where to point; this solves how fast you get there and how far you can twist. Keeping them apart is what lets a turret and a soldier share the first and disagree about the second — and it keeps this module free of imports, so it stays testable against nothing.

slewDeg is the stupidity dial. AI-DESIGN.md argues the interesting investment is the LOW end of the skill range, and a slow slew is the most legible bad-shooter behaviour there is: the aim visibly trails the target, so a player can see they are being missed rather than merely surviving. Pair it with aimWobble for a shooter who cannot hold still.