Skip to content

Instantly share code, notes, and snippets.

@surma

surma/.gitignore

Last active Jul 21, 2020
Embed
What would you like to do?
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);
@keaukraine

This comment has been minimized.

Copy link

@keaukraine keaukraine commented Feb 14, 2020

Do I understand this right that you don't use transferables to pass ArrayBuffers between main thread and worker? (https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage).
Transferable buffers are faster than copying buffers. Actually, they are NOT copied at all, only reassigned between main thread and worker.
Comlink seems to support this too: https://github.com/GoogleChromeLabs/comlink#comlinktransfervalue-transferables-and-comlinkproxyvalue

@surma

This comment has been minimized.

Copy link
Owner Author

@surma 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

This comment has been minimized.

Copy link

@keaukraine keaukraine commented Feb 14, 2020

OK, thank you for explanation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.