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
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
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
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)
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:
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
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
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
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
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
// 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
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
- Dasar-dasar WebGL
- Memulai dengan WebGPU
- Penanganan input game
- Tumpukan teknologi game web pada 2026 — posisi WebXR dalam lanskap teknologi yang lebih luas
- Three.js + USDC di browser — memuat aset 3D untuk adegan XR
Sumber Daya Eksternal
- MDN: WebXR Device API — referensi API lengkap
- Immersive Web — contoh WebXR, alat, dan tabel dukungan browser
- Panduan WebXR Three.js — konten VR dengan Three.js