React WebGL-Anwendungen mit mehreren Threads

React WebGL-Anwendungen mit mehreren Threads
26/4/2024

JavaScript, die einzige Programmiersprache, die von Webbrowsern nativ ausgeführt wird, ist von Haus aus single-threaded. Das bedeutet, dass alle Webanwendungen in einem einzigen Thread ausgeführt werden. Dies ist für alle Standardfälle völlig ausreichend, aber manchmal gibt es Situationen, in denen große, teure Berechnungen so viel Zeit in Anspruch nehmen, dass die Anwendung nicht mehr auf den Benutzer reagiert. Typische Fälle sind Anwendungen, die komplexe 3D-Szenen berechnen. Glücklicherweise sind die OffscreenCanvas- und Web Worker-Technologien da, um dies zu ändern.

Sehen Sie sich diese Beispiele an, wie sie die Benutzerfreundlichkeit von WebGL-Anwendungen verbessern können.

Es gibt Online-Ressourcen, die darüber informieren, wie man OffscreenCanvas verwendet, sogar mit React (@react-three/offscreen). Aber die Technologie ist noch neu, und es ist immer noch unüblich, Frontend-Anwendungen als multithreaded zu betrachten. Die meisten Tutorials, die man online findet, gehen von einer isolierten WebGL-Anwendung aus, was bedeutet, dass sie keine Informationen mit dem Haupt-Thread austauscht.

Aus meiner Erfahrung kann ich sagen - dies ist eine sehr unwahrscheinliche Situation. Wir haben bei SABO mehrere komplexe Anwendungen mit React und WebGL implementiert.Und jedes Mal haben beide Teile ihre Ansichten aus den gleichen Daten gerendert!

In der Praxis werden Sie also nach einer Möglichkeit suchen, den Anwendungsstatus zwischen mehreren Threads zu synchronisieren.

Dieser Artikel befasst sich mit dem wahrscheinlicheren Szenario, bei dem sowohl React DOM als auch WebGL Teile der Anwendung Lese-/Schreibzugriff auf den Anwendungsstatus benötigen.

Beispiel

Stellen Sie sich eine Funktion zum Prüfen und Konfigurieren eines 3D-Objekts vor. Sie könnte wie folgt aussehen:

Durch das Verschieben von WebGL in einen Worker könnten wir folgendes erreichen:

  • React DOM-Anwendung, die auf dem Hauptthread läuft.
  • React Three Fiber-Anwendung im Worker-Modus.

Lassen Sie uns nun die Möglichkeiten beschreiben:

  • Zu Beginn haben wir ein Objekt mit einigen Attributen.
  • Attribute werden im React DOM in einem Seitenpanel angezeigt und können bearbeitet werden.
  • Das Objekt wird in WebGL in der Hauptansicht basierend auf seinen Attributen angezeigt.
  • Um zusätzlich Schreibvorgänge auf der WebGL-Seite einzuführen, nehmen wir an, dass ein Klick auf ein Objekt in WebGL auch einige seiner Attribute ändert. In einer realen Anwendung könnte dies ein Drag und Drop sein, das die Position des Objekts ändert.

Problem

Es sollte inzwischen klar sein, dass der WebGL-Code benachrichtigt werden sollte, wenn wir das Attribut im Panel ändern und umgekehrt.

Im Allgemeinen können Sie von einem Thread aus nicht auf den Speicher eines anderen Threads zugreifen. Dies ist eine Designentscheidung, um thread-sichere Operationen zu gewährleisten. Worker sollen über Messages kommunizieren.

Deshalb wäre dereinfachste Ansatz die Implementierung einer nachrichtenbasierten Kommunikationzwischen zwei Workern. Doch bevor wir dazu kommen, möchte ich einen fortschrittlicheren Ansatz erwähnen, der darin besteht, den Anwendungsstatus in SharedArrayBuffer zu speichern.

Der SharedArrayBuffer ermöglicht den gemeinsamen Zugriff und unterstützt atomare Operationen aus verschiedenen Kontexten (Hauptthread oder Worker), ohne ihn zu kopieren. Die Datenstruktur selbst wird jedoch zur Darstellung eines generischen binären Rohdaten Puffers verwendet. Für die meiste Zeit ist dieser zu niedrig angesiedelt. Nichtsdestotrotz ist dies die Art und Weise, wie eine Manifold-Multithread-Enginedies erledigt.

SharedArrayBuffer ist die speicher- und geschwindigkeitseffizienteste, aber weit weniger benutzerfreundliche Methode. Auch mit Proxy-Objekte wie bitECS (die Sie mit Daten als JS-Objekte arbeiten können) erhalten Sie nirgends in der Nähe von geliebten JSON-Strukturen.

Nun, lassen Sieuns zurück zur nachrichtenbasierten Kommunikation und zu einer praktikablen Lösung kommen.

Lösung

Die Kernidee ist, einen gemeinsamen Anwendungsspeicher zu verwenden. Jeder Teil wird seine neigenen lokalen Speicher haben und wir werden eine dünne Synchronisationsschicht zwischen den Teilen über die Broadcast Channel API schreiben. Am Ende sollte sich keiner der Teile um den anderen kümmern, die Synchronisation wird nahtlos sein.

Zunächst habe ich mich für valtio entschieden, um den Anwendungsstatus zu speichern, und bald werden Sie sehen, warum. Falls Sie damit nicht vertraut sind, handeltes sich um eine Bibliothek, die ein veränderbares, Proxy-basiertes reaktives Zustandsmanagement bietet. 

Speicher

Dies ist der Grundspeicher, den ich verwendet habe:


import { proxy } from 'valtio'

export const store = proxy({
  name: 'Suzi',
  color: '#FFA500'
})

Ich habe diesen dann mit meinem Inspektionspanel in React DOM verbunden:


import { useSnapshot } from 'valtio'
import { store } from './store'
import { Layout, Inspector } from './dom'
import { Canvas } from './webgl'

const setName = (name: string) => (store.name = name)
const setColor = (color: string) => (store.color = color)

export const App = () => {
	const snapshot = useSnapshot(store)

	return (
		<Layout>
			<Canvas />
			<Inspector 
				name={snapshot.name}
				onNameChange={setName}
				color={snapshot.color}
				onColorChange={setColor}
			/>
		</Layout>
	)
}

Und ebenfalls zumeiner 3D-Szene:


import { store } from '../store'
import { invertColor } from '../utils'
import { Stage, Suzi, Label } from './components'

// example write operation
const invertAttributes = () => {
	store.color = invertColor(store.color)
	store.name = `Inverted ${store.name}`
}

export const Scene = () => {
	const snapshot = useSnapshot(store)
	
	return (
		<Stage>
			<Suzi color={snapshot.color} onClick={invertAttributes} />
			<Label>{snapshot.name}</Label>
		</Stage>
	)
}

Synchronisierung

Ich habe mich für valtio entschieden, weil es schöne serialisierbare Patches für die Listener bereitstellt, die Sie an die Subscribe-Funktion übergeben.

Damit können wir andere Threads über Änderungen, die auf unserer Seite vorgenommen wurden, informieren.


import { subscribe } from 'valtio'

// const store = ...

export function syncStore() {
	const channel = new BroadcastChannel ('sync')

	subscribe(store, (ops) => {
		channel.postMessage ({ ops })
	})
}

Schritt für Schritt am Beispiel:

  1. Im Inspector ändern wir den Namen von "Suzi" in "Bob".
  2. Valtio ruft unseren Listener mit dem Argument ops auf, das etwa so aussehen könnte: ["set", "name", "Bob", "Suzi"], mehr über das Format später.
  3. Wir senden dann eine Nachricht an den anderen Thread mit den durchgeführten Operationen, damit er dies ein seinem lokalen Speicher anwenden kann.

Der Betrieb von Valtio besteht aus:

  • Betriebsart
  • Pfad der betroffenen Property
  • Neuer Wert (für die Operation "set")
  • Alter Wert (für die Operation "set")

Es gibt noch mehr, aber wir brauchen jetzt nicht mehr.

Nun wollen wir Nachrichten mit ops (Operations) verarbeiten, wenn sie von aus dem anderen Thread empfangen werden.


channel.onmessage = (event) => {
	event.data.ops.forEach(([type, path, newValue]) => {
	  switch (type) {
		case 'set':
		  setByPath(store, path, newValue)
		  break
		case 'delete':
		  deleteByPath(store, path)
		  break
	  }
	})
}

Um den neuen Wert auf einen Teilbaum unseres Speichers anzuwenden, führen wir Hilfsfunktionen ein, die das Speicherobjekt entsprechend dem Pfad durchlaufen.


function setByPath(obj: any, path: (string | symbol)[], value: any) {
  const last = path.pop()
  let current = obj
  for (const p of path) {
    current = current[p]
  }
  current[last!] = value
}

function deleteByPath(obj: any, path: (string | symbol)[]) {
  const last = path.pop()
  let current = obj
  for (const p of path) {
    current = current[p]
  }
  delete current[last!]
}

Das war's! Unsere Synchronisierungslogik ist fertig. Rufen Sie die Funktion im Hauptthread und im Render-Worker auf, um die Synchronisierung zu starten.

Wenn sich nun auf einem Thread etwas im Speicher ändert, erhält der andere Thread ein Array von Operationen und wendet es auf seine lokale Version des Speichers an.

Diese Lösung geht zu Lasten des Speichers und ist nicht für jedes Szenario geeignet, aber sie löst das Problem auf elegante und einfache Weise.

Ergebnis

 

Sie können die bereitgestellte Version hier auf Netlify selbst testen oder den Code unter evstinik/multithreaded-react-webgl-example herunterladen.

Einige Inhalte wurden in diesem Dokument deaktiviert

Teilen:
Nikita ist ein Senior Frontend-Entwickler, der sich seit 2006 intensiv mit Programmierung beschäftigt. Nicht nur mit der Entwicklung komplexer SPA-Webanwendungen, sondern auch mit kleinen und mittleren iOS-Anwendungen. Schneller Lerner, innovativer Geist, sympathischer Teamkollege und angeblich der größte Geek bei SABO.

Article collaborators

SABO Newsletter icon

SABO NEWSLETTER

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.

SABO Mobile IT

Für unsere Kunden aus der Industrie entwickeln wir spezialisierte Software zur Umsetzung von Industry 4.0. IoT, Machine Learning und Künstliche Intelligenz ermöglichen uns, signifikante Effizienzsteigerungen bei unseren Kunden zu erzielen.
Über uns