glass-gamepad (b3dGamepad)

A split on-screen ("glass") gamepad for touch contexts, as a Component (so it's both the overlay element and a virtual-gamepad GamepadSource, like gameController/keyboardGamepad). Instead of one controller body, the controls are grouped into independently-anchored clusters, each a small SVG pinned to a corner of the view:

Cluster Default anchor Controls
left bottom-left left stick, d-pad, left bumper/trigger
right bottom-right A/B/X/Y, right stick, right bumper/trigger
top top-center view, menu

Each cluster is loaded from a self-contained SVG (default /gamepad-left.svg, /gamepad-right.svg, /gamepad-top.svg) whose paths are labelled by id (copied to data-part on load). There's no outer shell — just the clusters.

Usually you don't place this yourself: set the gamepad attribute on tosi-b3d and it mounts one and wires it into the active input system. Placed directly, it's a GamepadSource whose poll() merges all clusters.

import { b3d, b3dGamepad, b3dSkybox } from 'tosijs-3d'
import { demoSun, patternGround } from 'demo-utils'
import { elements } from 'tosijs'
const { div, pre } = elements

// fade OFF in the demo: this page is where you look at the pad, and on a
// desktop it would hide itself the moment you moved the mouse to reach it
const pad = b3dGamepad({ fade: 'off' })
const readout = pre({ class: 'readout' })
let rover, mat

const scene = b3d(
  {
    sceneCreated(el, BABYLON) {
      // FIXED view (no attachControl) so the pad owns every pointer — you drag a stick, not the
      // camera. This is the rare demo where a hand-rolled camera is right: the pad is the subject.
      const cam = new BABYLON.ArcRotateCamera('cam', -Math.PI / 2, Math.PI / 3.4, 13, new BABYLON.Vector3(0, 0.6, 0), el.scene)
      el.setActiveCamera(cam)
      rover = BABYLON.MeshBuilder.CreateBox('rover', { width: 1.4, height: 0.8, depth: 1.4 }, el.scene)
      rover.position.y = 0.5
      mat = new BABYLON.StandardMaterial('rover-mat', el.scene)
      mat.diffuseColor = new BABYLON.Color3(0.35, 0.6, 0.95)
      rover.material = mat
      el.register?.({ meshes: [rover] }) // cast a shadow
      el.scene.registerBeforeRender(() => {
        const s = pad.poll()
        // left stick drives it around; right stick spins it; A hops; B/X/Y recolour it.
        rover.position.x = Math.max(-9, Math.min(9, rover.position.x + s.leftStickX * 0.12))
        rover.position.z = Math.max(-9, Math.min(9, rover.position.z - s.leftStickY * 0.12))
        rover.rotation.y += s.rightStickX * 0.06
        const lift = s.buttonA > 0.5 ? 1.7 : 0.5
        rover.position.y += (lift - rover.position.y) * 0.2
        if (s.buttonB > 0.5) mat.diffuseColor.set(0.95, 0.4, 0.35)
        else if (s.buttonX > 0.5) mat.diffuseColor.set(0.4, 0.9, 0.55)
        else if (s.buttonY > 0.5) mat.diffuseColor.set(0.95, 0.85, 0.35)
        const held = ['A', 'B', 'X', 'Y'].filter((b) => s['button' + b] > 0.5)
        readout.textContent =
          `L ${s.leftStickX.toFixed(2)},${s.leftStickY.toFixed(2)}   R ${s.rightStickX.toFixed(2)},${s.rightStickY.toFixed(2)}` +
          (held.length ? '   ' + held.join(' ') : '')
      })
    },
  },
  demoSun(),
  b3dSkybox({ timeOfDay: 11 }),
  patternGround({ size: 22 }),
)

// The pad clusters pin to the corners, OVER the scene — touch/drag them to drive the cube.
preview.append(div({ class: 'glass-stage' }, scene, pad, readout))
.glass-stage {
  position: relative;
  height: 100%;
  min-height: 340px;
  background: #0b0f14;
  border-radius: 8px;
  overflow: hidden;
}
.glass-stage tosi-b3d,
.glass-stage tosi-b3d-gamepad {
  position: absolute;
  inset: 0;
}
.glass-stage .readout {
  position: absolute;
  top: 10px;
  left: 12px;
  margin: 0;
  padding: 4px 8px;
  background: #222;
  border-radius: 5px;
  font-family: ui-monospace, monospace;
  font-size: 12px;
  color: #fff;
  white-space: pre;
  pointer-events: none;
}