Skip to content

Dasar-dasar WebXR untuk game VR

WebXR menghadirkan VR dan AR ke browser. Pemain dapat menikmati game Anda melalui headset tanpa perlu mengunduh apa pun.

1) Periksa dukungan WebXR

js
async function checkXRSupport() {
  if (!navigator.xr) {
    return { vr: false, ar: false }
  }
  
  const vr = await navigator.xr.isSessionSupported('immersive-vr')
  const ar = await navigator.xr.isSessionSupported('immersive-ar')
  
  return { vr, ar }
}

// Usage
const support = await checkXRSupport()
if (support.vr) {
  showVRButton()
}

2) Memulai sesi VR

js
let xrSession = null
let xrRefSpace = null

async function startVR() {
  try {
    xrSession = await navigator.xr.requestSession('immersive-vr', {
      requiredFeatures: ['local-floor'],
      optionalFeatures: ['hand-tracking'],
    })
    
    xrSession.addEventListener('end', onSessionEnd)
    
    // Set up rendering
    const gl = canvas.getContext('webgl2', { xrCompatible: true })
    await xrSession.updateRenderState({
      baseLayer: new XRWebGLLayer(xrSession, gl),
    })
    
    // Get reference space
    xrRefSpace = await xrSession.requestReferenceSpace('local-floor')
    
    // Start render loop
    xrSession.requestAnimationFrame(onXRFrame)
  } catch (err) {
    console.error('Failed to start VR:', err)
  }
}

function onSessionEnd() {
  xrSession = null
  xrRefSpace = null
}

3) Loop rendering XR

js
function onXRFrame(time, frame) {
  const session = frame.session
  session.requestAnimationFrame(onXRFrame)
  
  const pose = frame.getViewerPose(xrRefSpace)
  if (!pose) return
  
  const glLayer = session.renderState.baseLayer
  gl.bindFramebuffer(gl.FRAMEBUFFER, glLayer.framebuffer)
  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
  
  // Render for each eye
  for (const view of pose.views) {
    const viewport = glLayer.getViewport(view)
    gl.viewport(viewport.x, viewport.y, viewport.width, viewport.height)
    
    // Get camera matrices
    const viewMatrix = view.transform.inverse.matrix
    const projectionMatrix = view.projectionMatrix
    
    // Render scene with these matrices
    renderScene(viewMatrix, projectionMatrix)
  }
}

4) Sumber input (pengontrol)

js
xrSession.addEventListener('inputsourceschange', (e) => {
  for (const source of e.added) {
    console.log('Controller added:', source.handedness) // 'left' or 'right'
  }
  for (const source of e.removed) {
    console.log('Controller removed:', source.handedness)
  }
})

function processInput(frame) {
  for (const source of xrSession.inputSources) {
    if (!source.gamepad) continue
    
    const gamepad = source.gamepad
    
    // Trigger (usually index 0)
    const triggerPressed = gamepad.buttons[0]?.pressed
    const triggerValue = gamepad.buttons[0]?.value || 0
    
    // Grip (usually index 1)
    const gripPressed = gamepad.buttons[1]?.pressed
    
    // Thumbstick
    const thumbstickX = gamepad.axes[2] || 0
    const thumbstickY = gamepad.axes[3] || 0
    
    // A/B buttons (index 4, 5)
    const buttonA = gamepad.buttons[4]?.pressed
    const buttonB = gamepad.buttons[5]?.pressed
  }
}

Jangan berasumsi setiap perangkat memiliki gamepad

Kode di atas membaca source.gamepad untuk trigger dan thumbstick. Cara ini berfungsi pada headset dengan pengontrol seperti Meta Quest, tetapi tidak melakukan apa pun di Apple Vision Pro (WebXR diaktifkan secara default di Safari pada visionOS 2), Samsung Galaxy XR, dan perangkat Android XR lainnya yang menggunakan tatapan-dan-cubitan atau pelacakan tangan sebagai input utama. Pada headset tersebut, source.gamepad bernilai null dan session.inputSources tetap kosong sampai pengguna benar-benar melakukan gerakan mencubit.

Untuk satu interaksi yang dijamin tersedia di setiap perangkat, yaitu "tindakan utama", dengarkan event select alih-alih memeriksa tombol secara terus-menerus. WebXR mengirimkan selectstart, select, dan selectend baik untuk trigger pengontrol, cubitan tangan, maupun tatapan-dan-cubitan Vision Pro, sehingga satu handler dapat menangani semuanya:

js
xrSession.addEventListener('selectstart', (e) => {
  // e.inputSource is the controller, hand, or transient pinch
  // e.frame lets you read its targetRaySpace pose
})
xrSession.addEventListener('select', (e) => {
  // primary action completed (trigger pulled, pinch released on target)
})
xrSession.addEventListener('selectend', (e) => {})

Pertahankan kode gamepad untuk pergerakan dengan thumbstick dan tombol tambahan pada headset dengan pengontrol, tetapi lindungi kode tersebut dengan if (source.gamepad) dan arahkan interaksi inti "pilih/pegang/aktifkan" melalui event ini agar game tetap berfungsi pada perangkat yang dikendalikan dengan tangan dan tatapan.

5) Posisi pengontrol

js
function getControllerPose(frame, source) {
  if (!source.gripSpace) return null
  
  const pose = frame.getPose(source.gripSpace, xrRefSpace)
  if (!pose) return null
  
  return {
    position: pose.transform.position,
    orientation: pose.transform.orientation,
    matrix: pose.transform.matrix,
  }
}

function renderControllers(frame) {
  for (const source of xrSession.inputSources) {
    const pose = getControllerPose(frame, source)
    if (pose) {
      renderControllerModel(pose, source.handedness)
    }
  }
}

6) Ray casting untuk menunjuk

js
function getControllerRay(frame, source) {
  if (!source.targetRaySpace) return null
  
  const pose = frame.getPose(source.targetRaySpace, xrRefSpace)
  if (!pose) return null
  
  const origin = pose.transform.position
  const direction = {
    x: -pose.transform.matrix[8],
    y: -pose.transform.matrix[9],
    z: -pose.transform.matrix[10],
  }
  
  return { origin, direction }
}

function checkRayIntersection(ray, objects) {
  let closest = null
  let closestDist = Infinity
  
  for (const obj of objects) {
    const dist = rayIntersectBox(ray, obj.boundingBox)
    if (dist !== null && dist < closestDist) {
      closest = obj
      closestDist = dist
    }
  }
  
  return closest
}

7) Pergerakan teleportasi

js
class TeleportSystem {
  constructor() {
    this.targetPosition = null
    this.isAiming = false
  }
  
  update(frame, inputSources) {
    for (const source of inputSources) {
      if (source.handedness !== 'left') continue
      
      const gamepad = source.gamepad
      const thumbstickY = gamepad?.axes[3] || 0
      
      if (thumbstickY < -0.5) {
        // Aiming
        this.isAiming = true
        const ray = getControllerRay(frame, source)
        this.targetPosition = this.findTeleportTarget(ray)
      } else if (this.isAiming) {
        // Release - teleport
        if (this.targetPosition) {
          player.position.x = this.targetPosition.x
          player.position.z = this.targetPosition.z
        }
        this.isAiming = false
        this.targetPosition = null
      }
    }
  }
  
  findTeleportTarget(ray) {
    // Intersect with ground plane
    if (ray.direction.y >= 0) return null
    
    const t = -ray.origin.y / ray.direction.y
    if (t < 0 || t > 10) return null
    
    return {
      x: ray.origin.x + ray.direction.x * t,
      y: 0,
      z: ray.origin.z + ray.direction.z * t,
    }
  }
  
  render() {
    if (this.isAiming && this.targetPosition) {
      renderTeleportMarker(this.targetPosition)
    }
  }
}

8) Pergerakan mulus

js
function updateSmoothLocomotion(frame, inputSources, dt) {
  for (const source of inputSources) {
    if (source.handedness !== 'left') continue
    
    const gamepad = source.gamepad
    if (!gamepad) continue
    
    const moveX = gamepad.axes[2] || 0
    const moveY = gamepad.axes[3] || 0
    
    // Apply deadzone
    if (Math.abs(moveX) < 0.1) moveX = 0
    if (Math.abs(moveY) < 0.1) moveY = 0
    
    // Get head direction for movement
    const pose = frame.getViewerPose(xrRefSpace)
    if (!pose) continue
    
    const headMatrix = pose.transform.matrix
    const forward = { x: -headMatrix[8], z: -headMatrix[10] }
    const right = { x: headMatrix[0], z: headMatrix[2] }
    
    // Normalize to XZ plane
    const len = Math.sqrt(forward.x ** 2 + forward.z ** 2)
    forward.x /= len
    forward.z /= len
    
    const speed = 3 * dt
    player.position.x += (forward.x * -moveY + right.x * moveX) * speed
    player.position.z += (forward.z * -moveY + right.z * moveX) * speed
  }
}

9) Panduan kenyamanan VR

js
// Vignette during movement to reduce motion sickness
function renderComfortVignette(movementSpeed) {
  const intensity = Math.min(movementSpeed / 5, 0.5)
  if (intensity < 0.1) return
  
  // Render dark edges that increase with speed
  renderVignette(intensity)
}

// Snap turning
let snapTurnCooldown = 0

function handleSnapTurn(gamepad, dt) {
  snapTurnCooldown -= dt
  
  const thumbstickX = gamepad.axes[2] || 0
  
  if (Math.abs(thumbstickX) > 0.7 && snapTurnCooldown <= 0) {
    const angle = Math.sign(thumbstickX) * (Math.PI / 4) // 45 degrees
    player.rotation += angle
    snapTurnCooldown = 0.3 // Prevent rapid turns
  }
}

10) Penyiapan WebXR lengkap

js
class VRGame {
  constructor(canvas) {
    this.canvas = canvas
    this.gl = canvas.getContext('webgl2', { xrCompatible: true })
    this.session = null
    this.refSpace = null
    this.teleport = new TeleportSystem()
  }
  
  async checkSupport() {
    if (!navigator.xr) return false
    return await navigator.xr.isSessionSupported('immersive-vr')
  }
  
  async start() {
    this.session = await navigator.xr.requestSession('immersive-vr', {
      requiredFeatures: ['local-floor'],
    })
    
    await this.session.updateRenderState({
      baseLayer: new XRWebGLLayer(this.session, this.gl),
    })
    
    this.refSpace = await this.session.requestReferenceSpace('local-floor')
    this.session.addEventListener('end', () => this.onEnd())
    this.session.requestAnimationFrame((t, f) => this.onFrame(t, f))
  }
  
  onFrame(time, frame) {
    this.session.requestAnimationFrame((t, f) => this.onFrame(t, f))
    
    const dt = (time - this.lastTime) / 1000
    this.lastTime = time
    
    // Update game logic
    this.teleport.update(frame, this.session.inputSources)
    
    // Render
    const pose = frame.getViewerPose(this.refSpace)
    if (!pose) return
    
    const glLayer = this.session.renderState.baseLayer
    this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, glLayer.framebuffer)
    this.gl.clear(this.gl.COLOR_BUFFER_BIT | this.gl.DEPTH_BUFFER_BIT)
    
    for (const view of pose.views) {
      const vp = glLayer.getViewport(view)
      this.gl.viewport(vp.x, vp.y, vp.width, vp.height)
      this.render(view.transform.inverse.matrix, view.projectionMatrix)
    }
    
    this.teleport.render()
  }
  
  render(viewMatrix, projectionMatrix) {
    // Your rendering code here
  }
  
  onEnd() {
    this.session = null
  }
}

Terkait

Sumber Daya Eksternal