Skip to content

Web Workers für Spiellogik

Mit Web Workers läuft JavaScript in einem Hintergrund-Thread. Für Spiele heißt das: Physik, KI und prozedurale Generierung laufen, ohne dass Frames wegbrechen.

1) Wann Workers sinnvoll sind

Gute Kandidaten:

  • Physiksimulation
  • Pathfinding (A*, Navmesh)
  • KI-Entscheidungen
  • Prozedurale Generierung
  • Asset-Verarbeitung (Bildbearbeitung, Kompression)
  • Aufwendige Mathematik (FFT, Kollisionserkennung)

Weniger geeignet:

  • Rendering (Workers kommen nicht direkt an DOM oder Canvas)
  • Sehr kleine Aufgaben (der Thread-Overhead lohnt nicht)

2) Einen einfachen Worker erstellen

worker.js:

js
self.onmessage = (e) => {
  const { type, data } = e.data
  
  if (type === 'calculate') {
    const result = heavyCalculation(data)
    self.postMessage({ type: 'result', data: result })
  }
}

function heavyCalculation(input) {
  // Expensive work here
  return input * 2
}

main.js:

js
const worker = new Worker('worker.js')

worker.onmessage = (e) => {
  const { type, data } = e.data
  if (type === 'result') {
    console.log('Got result:', data)
  }
}

worker.postMessage({ type: 'calculate', data: 42 })

3) Inline-Workers (ohne eigene Datei)

js
function createInlineWorker(fn) {
  const blob = new Blob([`(${fn.toString()})()`], { type: 'text/javascript' })
  return new Worker(URL.createObjectURL(blob))
}

const worker = createInlineWorker(() => {
  self.onmessage = (e) => {
    const result = e.data * 2
    self.postMessage(result)
  }
})

4) Workers als ES-Module

Du kannst Worker-Code als ES-Modul schreiben und darin import statt importScripts nutzen. Übergib dazu { type: 'module' } an den Konstruktor:

js
const worker = new Worker('physics-worker.js', { type: 'module' })

Im Worker kannst du dann gemeinsame Helfer für Mathematik, Kollision oder KI genauso importieren wie im Haupt-Thread, die Spiellogik wird also nicht doppelt gepflegt. Modul-Workers werden in Chrome und Edge ab 80, Safari ab 15 und Firefox ab 114 unterstützt.

5) Beispiel Physik-Worker

physics-worker.js:

js
const bodies = []
const FIXED_DT = 1 / 60

self.onmessage = (e) => {
  const { type, data } = e.data
  
  switch (type) {
    case 'init':
      initWorld(data)
      break
    case 'step':
      step()
      break
    case 'addBody':
      bodies.push(data)
      break
  }
}

function initWorld(config) {
  // Initialize physics world
}

function step() {
  // Update physics
  for (const body of bodies) {
    body.vy += 9.8 * FIXED_DT // Gravity
    body.x += body.vx * FIXED_DT
    body.y += body.vy * FIXED_DT
  }
  
  // Send positions back
  self.postMessage({
    type: 'positions',
    data: bodies.map(b => ({ id: b.id, x: b.x, y: b.y, rotation: b.rotation }))
  })
}

6) Transferable Objects für Performance

Große Daten lassen sich ohne Kopieren übergeben:

js
// Main thread
const positions = new Float32Array(1000)
worker.postMessage(positions, [positions.buffer])
// positions is now unusable here (transferred)

// Worker
self.onmessage = (e) => {
  const positions = e.data
  // Work with positions
  self.postMessage(positions, [positions.buffer])
}

7) Pathfinding-Worker

pathfinding-worker.js:

js
let grid = null

self.onmessage = (e) => {
  const { type, data } = e.data
  
  if (type === 'setGrid') {
    grid = data
  }
  
  if (type === 'findPath') {
    const path = aStar(data.start, data.end, grid)
    self.postMessage({ type: 'path', id: data.id, path })
  }
}

function aStar(start, end, grid) {
  // A* implementation
  const openSet = [start]
  const cameFrom = new Map()
  const gScore = new Map()
  gScore.set(key(start), 0)
  
  while (openSet.length > 0) {
    // ... A* logic
  }
  
  return reconstructPath(cameFrom, end)
}

function key(pos) {
  return `${pos.x},${pos.y}`
}

8) Worker-Pool für parallele Aufgaben

js
class WorkerPool {
  constructor(workerUrl, size = navigator.hardwareConcurrency || 4) {
    this.workers = []
    this.queue = []
    this.available = []
    
    for (let i = 0; i < size; i++) {
      const worker = new Worker(workerUrl)
      worker.onmessage = (e) => this.handleResult(worker, e)
      this.workers.push(worker)
      this.available.push(worker)
    }
  }
  
  run(data) {
    return new Promise((resolve) => {
      const task = { data, resolve }
      
      if (this.available.length > 0) {
        this.dispatch(this.available.pop(), task)
      } else {
        this.queue.push(task)
      }
    })
  }
  
  dispatch(worker, task) {
    worker._currentTask = task
    worker.postMessage(task.data)
  }
  
  handleResult(worker, e) {
    const task = worker._currentTask
    task.resolve(e.data)
    
    if (this.queue.length > 0) {
      this.dispatch(worker, this.queue.shift())
    } else {
      this.available.push(worker)
    }
  }
  
  terminate() {
    this.workers.forEach(w => w.terminate())
  }
}

9) SharedArrayBuffer für Echtzeit-Synchronisation

Mit aktivierter Cross-Origin-Isolation kannst du Speicher teilen:

js
// Main thread
const shared = new SharedArrayBuffer(1024)
const positions = new Float32Array(shared)

worker.postMessage({ type: 'init', buffer: shared })

// Worker reads/writes directly to shared memory
// No postMessage overhead for position updates

10) OffscreenCanvas für Workers

Rendern im Worker. OffscreenCanvas ist inzwischen als Baseline breit verfügbar, in Chrome, Edge, Firefox und Safari (Safari unterstützt es ab 17.0 auf macOS und iOS):

js
// Main thread
const canvas = document.getElementById('game')
const offscreen = canvas.transferControlToOffscreen()
worker.postMessage({ canvas: offscreen }, [offscreen])

// Worker
self.onmessage = (e) => {
  const canvas = e.data.canvas
  const ctx = canvas.getContext('2d')
  
  function render() {
    ctx.clearRect(0, 0, canvas.width, canvas.height)
    // Draw...
    requestAnimationFrame(render)
  }
  render()
}

11) Fehlerbehandlung

js
worker.onerror = (e) => {
  console.error('Worker error:', e.message, e.filename, e.lineno)
}

// In worker
self.onerror = (e) => {
  self.postMessage({ type: 'error', message: e.message })
}

Verwandt

Externe Ressourcen