Skip to content

VR 게임을 위한 WebXR 기초

WebXR는 VR과 AR을 브라우저로 가져옵니다. 플레이어는 아무것도 다운로드하지 않고도 헤드셋에서 여러분의 게임을 즐길 수 있습니다.

1) 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 }
}

// 사용법
const support = await checkXRSupport()
if (support.vr) {
  showVRButton()
}

2) 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)
    
    // 렌더링 설정
    const gl = canvas.getContext('webgl2', { xrCompatible: true })
    await xrSession.updateRenderState({
      baseLayer: new XRWebGLLayer(xrSession, gl),
    })
    
    // 참조 공간 가져오기
    xrRefSpace = await xrSession.requestReferenceSpace('local-floor')
    
    // 렌더 루프 시작
    xrSession.requestAnimationFrame(onXRFrame)
  } catch (err) {
    console.error('Failed to start VR:', err)
  }
}

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

3) 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)
  
  // 각 눈마다 렌더링
  for (const view of pose.views) {
    const viewport = glLayer.getViewport(view)
    gl.viewport(viewport.x, viewport.y, viewport.width, viewport.height)
    
    // 카메라 행렬 가져오기
    const viewMatrix = view.transform.inverse.matrix
    const projectionMatrix = view.projectionMatrix
    
    // 이 행렬들로 씬 렌더링
    renderScene(viewMatrix, projectionMatrix)
  }
}

4) 입력 소스 (컨트롤러)

js
xrSession.addEventListener('inputsourceschange', (e) => {
  for (const source of e.added) {
    console.log('Controller added:', source.handedness) // 'left' 또는 '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
    
    // 트리거 (보통 인덱스 0)
    const triggerPressed = gamepad.buttons[0]?.pressed
    const triggerValue = gamepad.buttons[0]?.value || 0
    
    // 그립 (보통 인덱스 1)
    const gripPressed = gamepad.buttons[1]?.pressed
    
    // 썸스틱
    const thumbstickX = gamepad.axes[2] || 0
    const thumbstickY = gamepad.axes[3] || 0
    
    // A/B 버튼 (인덱스 4, 5)
    const buttonA = gamepad.buttons[4]?.pressed
    const buttonB = gamepad.buttons[5]?.pressed
  }
}

모든 기기에 게임패드가 있다고 가정하지 마세요

위 코드는 트리거와 썸스틱을 읽기 위해 source.gamepad를 사용합니다. 이는 Meta Quest 같은 컨트롤러 헤드셋에서는 동작하지만, Apple Vision Pro(visionOS 2 Safari에서 WebXR가 기본으로 켜져 있습니다)나 Samsung Galaxy XR을 비롯한 다른 Android XR 기기에서는 아무 일도 하지 않습니다. 이런 기기들의 기본 입력은 시선과 핀치(gaze-and-pinch)나 손 추적입니다. 이런 헤드셋에서는 source.gamepad가 null이고, 사용자가 실제로 핀치하기 전까지 session.inputSources는 비어 있습니다.

모든 기기가 보장하는 단 하나의 인터랙션인 "기본 동작(primary action)"에 대해서는, 버튼을 폴링하는 대신 select 이벤트를 받으세요. WebXR는 컨트롤러 트리거, 손 핀치, Vision Pro의 시선과 핀치에 대해 모두 동일하게 selectstart, select, selectend를 전달하므로, 하나의 핸들러로 전부 처리할 수 있습니다.

js
xrSession.addEventListener('selectstart', (e) => {
  // e.inputSource는 컨트롤러, 손, 또는 일시적인 핀치입니다
  // e.frame으로 해당 targetRaySpace 포즈를 읽을 수 있습니다
})
xrSession.addEventListener('select', (e) => {
  // 기본 동작 완료 (트리거를 당김, 대상에서 핀치를 놓음)
})
xrSession.addEventListener('selectend', (e) => {})

컨트롤러 헤드셋의 썸스틱 이동과 추가 버튼을 위해 게임패드 코드는 유지하되, if (source.gamepad)로 감싸세요. 그리고 핵심인 "선택/잡기/실행" 인터랙션은 이 이벤트들을 통해 처리해서, 손과 시선으로 조작하는 기기에서도 게임이 동작하도록 하세요.

5) 컨트롤러 위치

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) 포인팅을 위한 레이 캐스팅

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) 텔레포트 이동

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) {
        // 조준 중
        this.isAiming = true
        const ray = getControllerRay(frame, source)
        this.targetPosition = this.findTeleportTarget(ray)
      } else if (this.isAiming) {
        // 놓음 - 텔레포트
        if (this.targetPosition) {
          player.position.x = this.targetPosition.x
          player.position.z = this.targetPosition.z
        }
        this.isAiming = false
        this.targetPosition = null
      }
    }
  }
  
  findTeleportTarget(ray) {
    // 바닥 평면과 교차
    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) 부드러운 이동

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
    
    // 데드존 적용
    if (Math.abs(moveX) < 0.1) moveX = 0
    if (Math.abs(moveY) < 0.1) moveY = 0
    
    // 이동 방향에 머리 방향 사용
    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] }
    
    // XZ 평면으로 정규화
    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) VR 편안함 가이드라인

js
// 이동 중 비네트로 멀미 줄이기
function renderComfortVignette(movementSpeed) {
  const intensity = Math.min(movementSpeed / 5, 0.5)
  if (intensity < 0.1) return
  
  // 속도가 빠를수록 짙어지는 어두운 가장자리 렌더링
  renderVignette(intensity)
}

// 스냅 턴
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도
    player.rotation += angle
    snapTurnCooldown = 0.3 // 빠른 연속 회전 방지
  }
}

10) 완전한 WebXR 설정

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
    
    // 게임 로직 업데이트
    this.teleport.update(frame, this.session.inputSources)
    
    // 렌더링
    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) {
    // 여기에 렌더링 코드 작성
  }
  
  onEnd() {
    this.session = null
  }
}

관련 글

외부 자료