Skip to content

Instantly share code, notes, and snippets.

@surma
Last active June 17, 2022 18:53
Show Gist options
  • Star 15 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save surma/83878d60b1edb0bb7d0cfd46c8b8cc56 to your computer and use it in GitHub Desktop.
Save surma/83878d60b1edb0bb7d0cfd46c8b8cc56 to your computer and use it in GitHub Desktop.
Moving a Three.JS-based WebXR app to a worker
node_modules
build
package-lock.json
/**
* @author mrdoob / http://mrdoob.com/
*/
import { BufferGeometry, Float32BufferAttribute } from "three";
var BoxLineGeometry = function(
width,
height,
depth,
widthSegments,
heightSegments,
depthSegments
) {
BufferGeometry.call(this);
width = width || 1;
height = height || 1;
depth = depth || 1;
widthSegments = Math.floor(widthSegments) || 1;
heightSegments = Math.floor(heightSegments) || 1;
depthSegments = Math.floor(depthSegments) || 1;
var widthHalf = width / 2;
var heightHalf = height / 2;
var depthHalf = depth / 2;
var segmentWidth = width / widthSegments;
var segmentHeight = height / heightSegments;
var segmentDepth = depth / depthSegments;
var vertices = [];
var x = -widthHalf,
y = -heightHalf,
z = -depthHalf;
for (var i = 0; i <= widthSegments; i++) {
vertices.push(x, -heightHalf, -depthHalf, x, heightHalf, -depthHalf);
vertices.push(x, heightHalf, -depthHalf, x, heightHalf, depthHalf);
vertices.push(x, heightHalf, depthHalf, x, -heightHalf, depthHalf);
vertices.push(x, -heightHalf, depthHalf, x, -heightHalf, -depthHalf);
x += segmentWidth;
}
for (var i = 0; i <= heightSegments; i++) {
vertices.push(-widthHalf, y, -depthHalf, widthHalf, y, -depthHalf);
vertices.push(widthHalf, y, -depthHalf, widthHalf, y, depthHalf);
vertices.push(widthHalf, y, depthHalf, -widthHalf, y, depthHalf);
vertices.push(-widthHalf, y, depthHalf, -widthHalf, y, -depthHalf);
y += segmentHeight;
}
for (var i = 0; i <= depthSegments; i++) {
vertices.push(-widthHalf, -heightHalf, z, -widthHalf, heightHalf, z);
vertices.push(-widthHalf, heightHalf, z, widthHalf, heightHalf, z);
vertices.push(widthHalf, heightHalf, z, widthHalf, -heightHalf, z);
vertices.push(widthHalf, -heightHalf, z, -widthHalf, -heightHalf, z);
z += segmentDepth;
}
this.setAttribute("position", new Float32BufferAttribute(vertices, 3));
};
BoxLineGeometry.prototype = Object.create(BufferGeometry.prototype);
BoxLineGeometry.prototype.constructor = BoxLineGeometry;
export { BoxLineGeometry };
/**
* @author mvilledieu / http://github.com/mvilledieu
*/
if (/(Helio)/g.test(navigator.userAgent) && "xr" in navigator) {
console.log("Helio WebXR Polyfill (Lumin 0.98.0)");
if ("isSessionSupported" in navigator.xr) {
const tempIsSessionSupported = navigator.xr.isSessionSupported.bind(
navigator.xr
);
navigator.xr.isSessionSupported = function(/*sessionType*/) {
// Force using immersive-ar
return tempIsSessionSupported("immersive-ar");
};
}
if (
"isSessionSupported" in navigator.xr &&
"requestSession" in navigator.xr
) {
const tempRequestSession = navigator.xr.requestSession.bind(navigator.xr);
navigator.xr.requestSession = function(/*sessionType*/) {
return new Promise(function(resolve, reject) {
var sessionInit = {
optionalFeatures: ["local-floor", "bounded-floor"]
};
tempRequestSession("immersive-ar", sessionInit)
.then(function(session) {
resolve(session);
})
.catch(function(error) {
return reject(error);
});
});
};
}
}
<!DOCTYPE html>
<title>three.js vr - ball shooter</title>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=no"
/>
<style>
html,
body {
margin: 0;
padding: 0;
}
</style>
<script type="module" src="./main.js"></script>
import "./HelioWebXRPolyfill.js";
import * as THREE from "three";
import * as Comlink from "comlink";
import { BoxLineGeometry } from "./BoxLineGeometry.js";
import { VRButton } from "./VRButton.js";
var camera, scene, renderer;
var controller1, controller2;
var room;
// Field of View
var fov = 80;
// Number of balls;
var ballCount = getNumBalls();
// Radius of one ball
var radius = 0.08;
// Size of the room
var roomSize = 6;
// Loss of velocity when bouncing of walls
var dampening = 0.8;
var worker = new Worker("./worker.js");
var BallShooter = Comlink.wrap(worker);
var ballShooter;
var positions;
var balls;
init().then(() => animate());
function getNumBalls() {
const def = 200;
const param = new URLSearchParams(document.location.search).get("balls");
if (!param) {
return def;
}
const numeric = parseInt(param);
if (Number.isNaN(numeric)) {
return def;
}
return numeric;
}
async function init() {
ballShooter = await new BallShooter({
numBalls: ballCount,
roomSize,
radius,
dampening
});
scene = new THREE.Scene();
scene.background = new THREE.Color(0x505050);
camera = new THREE.PerspectiveCamera(
fov,
window.innerWidth / window.innerHeight,
0.1,
10
);
camera.position.set(roomSize / 2, roomSize, roomSize / 2);
camera.lookAt(-roomSize / 2, 0, -roomSize / 2);
room = new THREE.LineSegments(
new BoxLineGeometry(roomSize, roomSize, roomSize, 10, 10, 10),
new THREE.LineBasicMaterial({ color: 0x808080 })
);
room.geometry.translate(0, roomSize / 2, 0);
scene.add(room);
var light = new THREE.HemisphereLight(0xffffff, 0x444444);
light.position.set(1, 1, 1);
scene.add(light);
var geometry = new THREE.IcosahedronBufferGeometry(radius, 2);
balls = new THREE.InstancedMesh(
geometry,
new THREE.MeshLambertMaterial({
color: 0xff8000
}),
ballCount
);
// ThreeJS doesn't support frustrum culling for InstancedMesh yet.
balls.frustumCulled = false;
room.add(balls);
positions = await ballShooter.getPositions();
updateBallPositions();
await ballShooter.setCallback(Comlink.proxy(buffer => (positions = buffer)));
//
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.xr.enabled = true;
document.body.appendChild(renderer.domElement);
//
document.body.appendChild(VRButton.createButton(renderer));
// controllers
function onSelectStart() {
ballShooter.startShootingGun(this.userData.id);
}
function onSelectEnd() {
ballShooter.stopShootingGun(this.userData.id);
}
controller1 = renderer.xr.getController(0);
controller1.addEventListener("selectstart", onSelectStart);
controller1.addEventListener("selectend", onSelectEnd);
controller1.addEventListener("connected", function(event) {
this.add(buildController(event.data));
});
controller1.addEventListener("disconnected", function() {
this.remove(this.children[0]);
});
controller1.userData.id = 0;
scene.add(controller1);
controller2 = renderer.xr.getController(1);
controller2.addEventListener("selectstart", onSelectStart);
controller2.addEventListener("selectend", onSelectEnd);
controller2.addEventListener("connected", function(event) {
this.add(buildController(event.data));
});
controller2.addEventListener("disconnected", function() {
this.remove(this.children[0]);
});
controller2.userData.id = 1;
scene.add(controller2);
//
window.addEventListener("resize", onWindowResize, false);
window.addEventListener("keydown", ev => {
if (ev.code !== "Space") {
return;
}
ev.preventDefault();
ballShooter.startShootingGun(0);
});
window.addEventListener("keyup", ev => {
if (ev.code !== "Space") {
return;
}
ev.preventDefault();
ballShooter.stopShootingGun(0);
});
}
function buildController(data) {
switch (data.targetRayMode) {
case "tracked-pointer":
var geometry = new THREE.BufferGeometry();
geometry.setAttribute(
"position",
new THREE.Float32BufferAttribute([0, 0, 0, 0, 0, -1], 3)
);
geometry.setAttribute(
"color",
new THREE.Float32BufferAttribute([0.5, 0.5, 0.5, 0, 0, 0], 3)
);
var material = new THREE.LineBasicMaterial({
vertexColors: true,
blending: THREE.AdditiveBlending
});
return new THREE.Line(geometry, material);
case "gaze":
var geometry = new THREE.RingBufferGeometry(0.02, 0.04, 32).translate(
0,
0,
-1
);
var material = new THREE.MeshBasicMaterial({
opacity: 0.5,
transparent: true
});
return new THREE.Mesh(geometry, material);
}
}
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
function vectorIsNull(v) {
return v.x === 0 && v.y === 0 && v.z === 0;
}
const tmp1 = [0, 0, 0];
const tmp2 = [0, 0, 0, 0];
const defaultRotation = new THREE.Quaternion()
.setFromUnitVectors(new THREE.Vector3(0, 0, -1), new THREE.Vector3(0, 1, 0))
.toArray();
function handleController(controller) {
if (vectorIsNull(controller.position)) {
ballShooter.setGun(controller.userData.id, [0, 1, 0], defaultRotation);
} else {
ballShooter.setGun(
controller.userData.id,
controller.position.toArray(tmp1, 0),
controller.quaternion.toArray(tmp2, 0)
);
}
}
async function updateBallPositions() {
balls.instanceMatrix.array.set(positions);
balls.instanceMatrix.needsUpdate = true;
}
//
function animate() {
renderer.setAnimationLoop(render);
ballShooter.start();
}
async function render() {
handleController(controller1);
handleController(controller2);
updateBallPositions();
//
renderer.render(scene, camera);
}
{
"name": "omt-ball",
"version": "0.0.1",
"description": "",
"main": "BoxLineGeometry.js",
"scripts": {
"build": "rollup -c",
"serve": "http-server -c0 build"
},
"keywords": [],
"author": "Surma <surma@surma.link>",
"license": "Apache-2.0",
"dependencies": {
"@rollup/plugin-node-resolve": "^7.0.0",
"@surma/rollup-plugin-off-main-thread": "^1.1.1",
"comlink": "^4.2.0",
"rollup": "^1.29.1",
"three": "^0.112.1"
},
"devDependencies": {
"http-server": "^0.12.1"
}
}
import nodeResolve from "@rollup/plugin-node-resolve";
import omt from "@surma/rollup-plugin-off-main-thread";
import fs from "fs";
export default {
input: "main.js",
output: {
dir: "build",
format: "amd"
},
plugins: [
nodeResolve(),
omt(),
{
async writeBundle() {
await fs.promises.copyFile("index.html", "./build/index.html");
}
}
]
};
/**
* @author mrdoob / http://mrdoob.com
* @author Mugen87 / https://github.com/Mugen87
*/
var VRButton = {
createButton: function(renderer, options) {
if (options && options.referenceSpaceType) {
renderer.xr.setReferenceSpaceType(options.referenceSpaceType);
}
function showEnterVR(/*device*/) {
var currentSession = null;
function onSessionStarted(session) {
session.addEventListener("end", onSessionEnded);
renderer.xr.setSession(session);
button.textContent = "EXIT VR";
currentSession = session;
}
function onSessionEnded(/*event*/) {
currentSession.removeEventListener("end", onSessionEnded);
button.textContent = "ENTER VR";
currentSession = null;
}
//
button.style.display = "";
button.style.cursor = "pointer";
button.style.left = "calc(50% - 50px)";
button.style.width = "100px";
button.textContent = "ENTER VR";
button.onmouseenter = function() {
button.style.opacity = "1.0";
};
button.onmouseleave = function() {
button.style.opacity = "0.5";
};
button.onclick = function() {
if (currentSession === null) {
// WebXR's requestReferenceSpace only works if the corresponding feature
// was requested at session creation time. For simplicity, just ask for
// the interesting ones as optional features, but be aware that the
// requestReferenceSpace call will fail if it turns out to be unavailable.
// ('local' is always available for immersive sessions and doesn't need to
// be requested separately.)
var sessionInit = {
optionalFeatures: ["local-floor", "bounded-floor"]
};
navigator.xr
.requestSession("immersive-vr", sessionInit)
.then(onSessionStarted);
} else {
currentSession.end();
}
};
}
function disableButton() {
button.style.display = "";
button.style.cursor = "auto";
button.style.left = "calc(50% - 75px)";
button.style.width = "150px";
button.onmouseenter = null;
button.onmouseleave = null;
button.onclick = null;
}
function showWebXRNotFound() {
disableButton();
button.textContent = "VR NOT SUPPORTED";
}
function stylizeElement(element) {
element.style.position = "absolute";
element.style.bottom = "20px";
element.style.padding = "12px 6px";
element.style.border = "1px solid #fff";
element.style.borderRadius = "4px";
element.style.background = "rgba(0,0,0,0.1)";
element.style.color = "#fff";
element.style.font = "normal 13px sans-serif";
element.style.textAlign = "center";
element.style.opacity = "0.5";
element.style.outline = "none";
element.style.zIndex = "999";
}
if ("xr" in navigator) {
var button = document.createElement("button");
button.style.display = "none";
stylizeElement(button);
navigator.xr.isSessionSupported("immersive-vr").then(function(supported) {
supported ? showEnterVR() : showWebXRNotFound();
});
return button;
} else {
var message = document.createElement("a");
message.href = "https://immersiveweb.dev/";
if (window.isSecureContext === false) {
message.innerHTML = "WEBXR NEEDS HTTPS"; // TODO Improve message
} else {
message.innerHTML = "WEBXR NOT AVAILABLE";
}
message.style.left = "calc(50% - 90px)";
message.style.width = "180px";
message.style.textDecoration = "none";
stylizeElement(message);
return message;
}
}
};
export { VRButton };
import * as Comlink from "comlink";
import * as THREE from "three";
class BallShooter {
constructor({ numBalls, roomSize, radius, dampening }) {
this._dampening = dampening;
this._numBalls = numBalls;
this._roomSize = roomSize;
this._radius = radius;
this.framerate = 90;
this._positions = new Float32Array(this._numBalls * 4 * 4);
this._velocities = new Float32Array(this._numBalls * 3);
this._ballCounter = 0;
this.shootingRate = 50;
this._guns = [
{
shooting: false,
position: new THREE.Vector3(),
quaternion: new THREE.Quaternion()
},
{
shooting: false,
position: new THREE.Vector3(),
quaternion: new THREE.Quaternion()
}
];
// Each 4 * 4 elements are one matrix.
// Set them to the identity matrix.
this._positions.fill(0);
for (let i = 0; i < this._numBalls; i++) {
this._positions[i * 4 * 4 + 0] = 1;
this._positions[i * 4 * 4 + 5] = 1;
this._positions[i * 4 * 4 + 10] = 1;
this._positions[i * 4 * 4 + 15] = 1;
}
this._tmpVector = new THREE.Vector3();
this._balls = Array.from({ length: this._numBalls }, (_, i) => {
return {
index: i,
position: this._positions.subarray(i * 4 * 4 + 12, i * 4 * 4 + 15),
velocity: this._velocities.subarray(i * 3, i * 3 + 3)
};
});
this._init();
}
setCallback(cb) {
this._cb = cb;
}
_init() {
for (var i = 0; i < this._numBalls; i++) {
this._balls[i].position[0] = random(
-this._roomSize / 2 + 1,
this._roomSize / 2 - 1
);
this._balls[i].position[1] = random(0, this._roomSize);
this._balls[i].position[2] = random(
-this._roomSize / 2 + 1,
this._roomSize / 2 - 1
);
this._balls[i].velocity[0] = random(-0.005, 0.005);
this._balls[i].velocity[1] = random(-0.005, 0.005);
this._balls[i].velocity[2] = random(-0.005, 0.005);
}
}
startShootingGun(id) {
if (id > this._guns.length) {
return;
}
this._guns[id].shooting = true;
}
stopShootingGun(id) {
if (id > this._guns.length) {
return;
}
this._guns[id].shooting = false;
}
setGun(id, position, quaternion) {
if (id > this._guns.length) {
return;
}
this._guns[id].position.set(...position);
this._guns[id].quaternion.set(...quaternion);
}
start() {
this._lastFrame = performance.now();
this._running = true;
this._update();
}
getPositions() {
return this._positions;
}
put(buffer) {
this._pool.put(buffer);
}
_update() {
const currentFrame = performance.now();
const nextFrame = currentFrame + 1000 / this.framerate;
const delta = currentFrame - this._lastFrame;
///
this._doPhysics(delta / 1000);
this._shootBalls(delta / 1000);
this._cb(this._positions);
///
this._lastFrame = currentFrame;
if (this._running) {
let deltaToNextFrame = nextFrame - performance.now();
if (deltaToNextFrame < 0) {
deltaToNextFrame = 0;
}
setTimeout(() => this._update(), deltaToNextFrame);
}
}
_shootBalls(delta) {
for (const gun of this._guns) {
if (!gun.shooting) {
continue;
}
const previousBallCounter = Math.floor(this._ballCounter);
this._ballCounter += this.shootingRate * delta;
for (
let i = previousBallCounter;
i < Math.floor(this._ballCounter) && i < this._numBalls;
i++
) {
const ball = this._balls[i];
vectorSet(ball.position, gun.position.toArray());
this._tmpVector.set(random(-1, 1), random(-1, 1), -10);
this._tmpVector.applyQuaternion(gun.quaternion);
vectorSet(ball.velocity, this._tmpVector.toArray());
}
}
this._ballCounter %= this._numBalls;
}
_doPhysics(delta) {
const range = this._roomSize / 2 - this._radius;
const normal = new Float32Array(3);
const relativeVelocity = new Float32Array(3);
for (var i = 0; i < this._numBalls; i++) {
const ball = this._balls[i];
ball.position[0] += ball.velocity[0] * delta;
ball.position[1] += ball.velocity[1] * delta;
ball.position[2] += ball.velocity[2] * delta;
// Bounce of walls
if (ball.position[0] < -range || ball.position[0] > range) {
ball.position[0] = clamp(ball.position[0], -range, range);
ball.velocity[0] = -ball.velocity[0] * this._dampening;
}
if (
ball.position[1] < this._radius ||
ball.position[1] > this._roomSize
) {
ball.position[1] = Math.max(ball.position[1], this._radius);
ball.velocity[0] *= this._dampening;
ball.velocity[1] = -ball.velocity[1] * this._dampening;
ball.velocity[2] *= this._dampening;
}
if (ball.position[2] < -range || ball.position[2] > range) {
ball.position[2] = clamp(ball.position[2], -range, range);
ball.velocity[2] = -ball.velocity[2] * this._dampening;
}
// // Bounce of other balls
for (var j = i + 1; j < this._numBalls; j++) {
const otherBall = this._balls[j];
vectorDifference(normal, ball.position, otherBall.position);
const distance = vectorLength(normal, 0);
if (distance < 2 * this._radius) {
vectorScalarProduct(normal, normal, 0.5 * distance - this._radius);
vectorDifference(ball.position, ball.position, normal);
vectorSum(otherBall.position, otherBall.position, normal);
vectorNormalized(normal, normal);
vectorDifference(relativeVelocity, ball.velocity, otherBall.velocity);
vectorScalarProduct(
normal,
normal,
vectorDot(relativeVelocity, normal)
);
vectorDifference(ball.velocity, ball.velocity, normal);
vectorSum(otherBall.velocity, otherBall.velocity, normal);
}
}
// Gravity
ball.velocity[1] -= 9.8 * delta;
}
}
}
function clamp(v, min, max) {
if (v < min) {
return min;
}
if (v > max) {
return max;
}
return v;
}
function vectorDot(a, b) {
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
}
function vectorSum(t, a, b) {
for (let i = 0; i < 3; i++) {
t[i] = a[i] + b[i];
}
return t;
}
function vectorSet(t, [x, y, z]) {
t[0] = x;
t[1] = y;
t[2] = z;
return t;
}
function vectorDifference(t, a, b) {
for (let i = 0; i < 3; i++) {
t[i] = a[i] - b[i];
}
return t;
}
function vectorLength(a) {
let length = vectorDot(a, a);
length = Math.sqrt(length);
return length;
}
function vectorScalarProduct(t, a, s) {
for (let i = 0; i < 3; i++) {
t[i] = a[i] * s;
}
return t;
}
function vectorNormalized(t, a) {
const length = vectorLength(a);
for (let i = 0; i < 3; i++) {
t[i] = a[i] / length;
}
return t;
}
function random(a, b) {
return Math.random() * (b - a) + a;
}
Comlink.expose(BallShooter);
@surma
Copy link
Author

surma commented Feb 14, 2020

@keaukraine That’s correct. I actively decided against transferring the buffers. I explain why in the blog post:

If you thought I was going to talk about transferring ArrayBuffer, I can’t blame you. That was the plan! In my very first implementation I actually did transfer the ArrayBuffers and built a memory pool so I can reuse memory buffers. However, I realized that before sending the buffer over to the main thread I had to create a copy: I need the positions on the main thread to render the next frame and in the worker for the next tick of the physics calculations. So instead of making a copy myself and transferring buffers, I let the structured cloning algorithm take care of the copying. I discovered that it performed just as well which allowed me to get rid of the code for the memory pool and made the overall code simpler to read.

In general this project made me realize that structured cloning ArrayBuffer is incredibly fast. It might not be as fast as transferring them, but it’s way faster than structured cloning a JSON object and fast enough for most use cases it seems.

@keaukraine
Copy link

OK, thank you for explanation.

@jpryne
Copy link

jpryne commented Jan 20, 2021

I'm wondering if the data-race you mention is really of any consequence. Even if the physics thread is in the middle of updating the ball positions, could not the main thread charge on ahead with rendering the balls, some in position(t) & some in position(t+1)?

@jpryne
Copy link

jpryne commented Jan 21, 2021

Also, I believe the instanced rendering method could achieve different colored balls using a shader material which derives color from instance ID, yes?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment