WebVR, часть 2: веб-воркеры и периферийные вычисления в браузере

Опубликовано: 2022-03-11

Наш астрофизический симулятор питается мощной ракетной смесью надежды, ажиотажа и доступа к новым вычислительным мощностям.

Мы можем получить доступ к этой вычислительной мощности с помощью веб-воркеров . Если вы уже знакомы с веб-воркерами, вы можете изучить код и перейти к WebAssembly, который будет обсуждаться в следующей статье.

JavaScript стал самым устанавливаемым, изучаемым и доступным языком программирования, потому что он привнес в статическую сеть несколько невероятно полезных функций:

  • Однопоточный цикл событий
  • Асинхронный код
  • Вывоз мусора
  • Данные без жесткой типизации

Однопоточность означает, что нам не нужно сильно беспокоиться о сложности и ловушках многопоточного программирования.

Асинхронность означает, что мы можем передавать функции как параметры, которые будут выполняться позже — как события в цикле событий.

Эти функции и огромные инвестиции Google в производительность движка Chrome V8 JavaScript, а также хорошие инструменты для разработчиков сделали JavaScript и Node.js идеальным выбором для архитектур микросервисов.

Однопоточное выполнение также отлично подходит для производителей браузеров, которым необходимо надежно изолировать и запускать все среды выполнения вкладок браузера, зараженных шпионским ПО, на нескольких ядрах компьютера.

Вопрос: Как одна вкладка браузера может получить доступ ко всем ядрам процессора вашего компьютера?
Ответ: Веб-работники!

Веб-воркеры и потоки

Веб-воркеры используют цикл событий для асинхронной передачи сообщений между потоками, обходя многие потенциальные ловушки многопоточного программирования.

Веб-воркеры также можно использовать для перемещения вычислений из основного потока пользовательского интерфейса. Это позволяет основному потоку пользовательского интерфейса обрабатывать клики, анимацию и управлять DOM.

Давайте посмотрим на код из репозитория проекта на GitHub.

Если вы помните нашу схему архитектуры, мы делегировали всю симуляцию nBodySimulator , чтобы он управлял веб-воркером.

Диаграмма архитектуры

Если вы помните из вступительного поста, в nBodySimulator есть функция step() , вызываемая каждые 33 мс симуляции. Он вызывает calculateForces() , затем обновляет позиции и перерисовывает.

 // Methods from class nBodySimulator /** * The simulation loop */ start() { // This is the simulation loop. step() calls visualize() const step = this.step.bind(this) setInterval(step, this.simulationSpeed) } /** * A step in the simulation loop. */ async step() { // Skip calculation if worker not ready. Runs every 33ms (30fps), expect it to skip. if (this.ready()) { await this.calculateForces() } else { console.log(`Skipping calculation: WorkerReady: ${this.workerReady} WorkerCalculating: ${this.workerCalculating}`) } // Remove any "debris" that has traveled out of bounds - this is for the button this.trimDebris() // Now Update forces. Reuse old forces if we skipped calculateForces() above this.applyForces() // Ta-dah! this.visualize() }

Вклад веб-работника заключается в размещении отдельного потока для WebAssembly. Будучи языком низкого уровня, WebAssembly понимает только целые числа и числа с плавающей запятой. Мы не можем передавать строки или объекты JavaScript — только указатели на «линейную память». Поэтому для удобства мы упаковываем наши «тела» в массив с плавающей запятой: arrBodies .

Мы вернемся к этому в нашей статье о WebAssembly и AssemblyScript.

Перемещение данных в/из веб-воркера
Перемещение данных в/из веб-воркера

Здесь мы создаем веб-воркер для запуска calculateForces() в отдельном потоке. Это происходит ниже, когда мы маршалируем тела (x, y, z, масса) в массив с плавающей запятой arrBodies , а затем this.worker.postMessage() в worker. Мы возвращаем обещание, которое работник разрешит позже в this.worker.onMessage() .

 // src/nBodySimulator.js /** * Use our web worker to calculate the forces to apply on our bodies. */ calculateForces() { this.workerCalculating = true this.arrBodies = [] // Copy data to array into this.arrBodies ... // return promise that worker.onmessage will fulfill const ret = new Promise((resolve, reject) => { this.forcesResolve = resolve this.forcesReject = reject }) // postMessage() to worker to start calculation // Execution continues in workerWasm.js worker.onmessage() this.worker.postMessage({ purpose: 'nBodyForces', arrBodies: this.arrBodies, }) // Return promise for completion // Promise is resolve()d in this.worker.onmessage() below. // Once resolved, execution continues in step() above - await this.calculateForces() return ret }

Сверху браузер GET index.html , который запускает main.js , который создает new nBodySimulator() и в его конструкторе мы находим setupWebWorker() .

n-тело-wasm-холст

 // nBodySimulator.js /** * Our n-body system simulator */ export class nBodySimulator { constructor() { this.setupWebWorker() ...

Наш new nBodySimulator() находится в основном потоке пользовательского интерфейса, а setupWebWorker() создает веб-воркер, workerWasm.js из сети.

 // nBodySimulator.js // Main UI thread - Class nBodySimulator method setupWebWorker() { // Create a web worker (separate thread) that we'll pass the WebAssembly module to. this.worker = new Worker("workerWasm.js"); // Console errors from workerWasm.js this.worker.onerror = function (evt) { console.log(`Error from web worker: ${evt.message}`); } ...

В new Worker() браузер извлекает и запускает workerWasm.js в отдельной среде выполнения JavaScript (и потоке) и начинает передавать сообщения.

Затем workerWasm.js становится частью WebAssembly, но на самом деле это всего лишь одна this.onmessage() , содержащая оператор switch() .

Помните, что веб-воркеры не могут получить доступ к сети, поэтому основной поток пользовательского интерфейса должен передать скомпилированный код WebAssembly в веб-воркер в виде сообщения resolve("action packed") . Мы углубимся в это в следующем посте.

 // workerWasm.js - runs in a new, isolated web worker runtime (and thread) this.onmessage = function (evt) { // message from UI thread var msg = evt.data switch (msg.purpose) { // Message: Load new wasm module case 'wasmModule': // Instantiate the compiled module we were passed. ... // Tell nBodySimulation.js we are ready this.postMessage({ purpose: 'wasmReady' }) return // Message: Given array of floats describing a system of bodies (x, y, z, mass), // calculate the Grav forces to be applied to each body case 'nBodyForces': ... // Do the calculations in this web worker thread synchronously const resultRef = wasm.nBodyForces(dataRef); ... // See nBodySimulation.js' this.worker.onmessage return this.postMessage({ purpose: 'nBodyForces', arrForces }) } }

Возвращаясь к setupWebWorker() нашего класса nBodySimulation , мы прослушиваем сообщения веб-воркера, используя тот же onmessage() + switch() .

 // Continuing class nBodySimulator's setupWebWorker() in the main UI thread // Listen for messages from workerWasm.js postMessage() const self = this this.worker.onmessage = function (evt) { if (evt && evt.data) { // Messages are dispatched by purpose const msg = evt.data switch (msg.purpose) { // Worker's reply that it has loaded the wasm module we compiled and sent. Let the magic begin! // See postmessage at the bottom of this function. case 'wasmReady': self.workerReady = true break // wasm has computed forces for us // Response to postMessage() in nBodySimulator.calculateForces() above case 'nBodyForces': self.workerCalculating = false // Resolve await this.calculateForces() in step() above if (msg.error) { self.forcesReject(msg.error) } else { self.arrForces = msg.arrForces self.forcesResolve(self.arrForces) } break } } } ...

В этом примере calculateForces() создает и возвращает промис, сохраняющий resolve() и reject() как self.forcesReject() и self.forcesResolve() .

Таким образом, worker.onmessage() может разрешить промис, созданный в calculateForces() .

Если вы помните функцию step() нашего цикла моделирования:

 /** * This is the simulation loop. */ async step() { // Skip calculation if worker not ready. Runs every 33ms (30fps), expect it to skip. if (this.ready()) { await this.calculateForces() } else { console.log(`Skipping calculation: WorkerReady: ${this.workerReady} WorkerCalculating: ${this.workerCalculating}`) }

Это позволяет нам пропустить calculateForces() и повторно применить предыдущие силы, если WebAssembly все еще выполняет вычисления.

Эта пошаговая функция срабатывает каждые 33 мс. Если веб-воркер не готов, он применяет и рисует предыдущие силы. Если метод calculateForces() определенного шага работает после начала следующего шага, на следующем шаге будут применяться силы из позиции предыдущего шага. Эти предыдущие силы либо достаточно похожи, чтобы выглядеть «правильно», либо происходят так быстро, что могут быть непонятны пользователю. Этот компромисс увеличивает воспринимаемую производительность, даже если это не рекомендуется для реальных полетов человека в космос.

Можно ли это улучшить? Да! Альтернативой setInterval для нашей пошаговой функции является requestAnimationFrame() .

Для моих целей этого достаточно для изучения Canvas, WebVR и WebAssembly. Если вы считаете, что что-то можно добавить или заменить, не стесняйтесь комментировать или связаться с нами.

Если вы ищете современный, законченный дизайн физического движка, ознакомьтесь с Matter.js с открытым исходным кодом.

Что насчет веб-сборки?

WebAssembly — это переносимый двоичный файл, который работает в браузерах и системах. WebAssembly может быть скомпилирован из многих языков (C/C++/Rust и т. д.). Для себя я хотел попробовать AssemblyScript — язык, основанный на TypeScript, который является языком, основанным на JavaScript, потому что он полностью черепаховый.

AssemblyScript компилирует код TypeScript в переносимый бинарный «объектный код», который «точно вовремя» скомпилирован в новую высокопроизводительную среду выполнения под названием Wasm. При компиляции TypeScript в двоичный файл .wasm можно создать удобочитаемый «текст веб-сборки» .wat , описывающий двоичный файл.

Последняя часть setupWebWorker() наш следующий пост о WebAssembly и показывает, как преодолеть ограничения веб-воркера на доступ к сети. Мы извлекаем fetch() файл wasm в основном потоке пользовательского интерфейса, а затем «точно вовремя» компилируем его в родной модуль wasm. Мы отправляем сообщение postMessage() этого модуля как сообщение веб-воркеру:

 // completing setupWebWorker() in the main UI thread … // Fetch and compile the wasm module because web workers cannot fetch() WebAssembly.compileStreaming(fetch("assembly/nBodyForces.wasm")) // Send the compiled wasm module to the worker as a message .then(wasmModule => { self.worker.postMessage({ purpose: 'wasmModule', wasmModule }) }); } }

workerWasm.js создает экземпляр этого модуля, чтобы мы могли вызывать его функции:

 // wasmWorker.js - web worker onmessage function this.onmessage = function (evt) { // message from UI thread var msg = evt.data switch (msg.purpose) { // Message: Load new wasm module case 'wasmModule': // Instantiate the compiled module we were passed. wasm = loader.instantiate(msg.wasmModule, importObj) // Throws // Tell nBodySimulation.js we are ready this.postMessage({ purpose: 'wasmReady' }) return case 'nBodyForces': ... // Do the calculations in this thread synchronously const resultRef = wasm.nBodyForces(dataRef);

Вот как мы получаем доступ к функциям WebAssembly. Если вы посмотрите на неотредактированный исходный код, вы заметите, что ... — это набор кода управления памятью для передачи наших данных в dataRef и наших результатов из resultRef . Управление памятью в JavaScript? Захватывающе!

Мы более подробно рассмотрим WebAssembly и AssemblyScript в следующем посте.

Границы выполнения и общая память

Здесь есть еще о чем поговорить, а именно о границах выполнения и общей памяти.

Четыре копии данных о наших телах
Четыре копии данных о наших телах

Статья о WebAssembly очень тактична, так что здесь хорошее место, чтобы поговорить о средах выполнения. JavaScript и WebAssembly являются «эмулируемыми» средами выполнения. Как реализовано, каждый раз, когда мы пересекаем границу времени выполнения, мы делаем копию данных нашего тела (x, y, z, масса). Хотя копирование памяти дешево, это не зрелая высокопроизводительная конструкция.

К счастью, много очень умных людей работают над созданием спецификаций и реализаций этих передовых браузерных технологий.

В JavaScript есть SharedArrayBuffer для создания объекта общей памяти, который исключит копию postMessage() из (2) -> (3) при вызове и onmessage() arrForces из (3) -> (2) в результате. .

WebAssembly также имеет дизайн линейной памяти, в котором может размещаться общая память для nBodyForces() из (3) -> (4). Веб-воркер также может передать общую память для массива результатов.

Присоединяйтесь к нам в следующий раз, чтобы совершить захватывающее путешествие в области управления памятью JavaScript.