-
-
Save surma/83878d60b1edb0bb7d0cfd46c8b8cc56 to your computer and use it in GitHub Desktop.
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 <[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" | |
} | |
} |
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"); | |
} | |
} | |
] | |
}; |
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 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.
OK, thank you for explanation.
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)?
Also, I believe the instanced rendering method could achieve different colored balls using a shader material which derives color from instance ID, yes?
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