Last active
June 17, 2022 18:53
-
-
Save surma/83878d60b1edb0bb7d0cfd46c8b8cc56 to your computer and use it in GitHub Desktop.
Moving a Three.JS-based WebXR app to a worker
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
node_modules | |
build | |
package-lock.json |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* @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 }; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* @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); | |
}); | |
}); | |
}; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"name": "omt-ball", | |
"version": "0.0.1", | |
"description": "", | |
"main": "BoxLineGeometry.js", | |
"scripts": { | |
"build": "rollup -c", | |
"serve": "http-server -c0 build" | |
}, | |
"keywords": [], | |
"author": "Surma <[email protected]>", | |
"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" | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"); | |
} | |
} | |
] | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); |
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
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)?