Skip to content

Instantly share code, notes, and snippets.

@surma
Last active June 17, 2022 18:53
Show Gist options
  • 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);
@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