/* global AFRAME, THREE, NAF, CurrentWorld */ // Picking things up and drawing in the air with them, like the pens in // VRChat. A world offers `tools` (see world.js), each lying along its own +x. // In VR, squeeze grip near one and it goes into your hand by its grip; let go // and it floats where you left it. Pull the trigger while holding it to draw // from its tip. With an empty hand, touch a line and pull the trigger to erase // it. On desktop, F picks up (or lets go of) the one you're looking at, holding // the mouse button draws, and Backspace erases your last line. In a shared room // everyone sees who holds what and every line, and newcomers are caught up. // A world can also offer `touchables` (the temple's Veil): reach an empty hand // to one, a label above your hand says what the trigger will do, and the // trigger does it. (function () { const GRAB_REACH = 0.1; // hand to tool (m) const LOOK_REACH = 2.2; // desktop: head to tool const LOOK_AIM = 0.35; // desktop: how far off your line of sight it can be const ERASE_REACH = 0.05; // hand to line const TOUCH_REACH = 0.2; // hand to a touchable const STEP = 0.003; // a new point every 3 mm of tip travel const RADIUS = 0.004; const SIDES = 6; const MAX_POINTS = 1024; // per line; a longer stroke carries on in a new line const MAX_LINES = 300; const MAX_TOTAL = 100000; // points in all lines; the oldest lines go first const POSE_MS = 66; // how often a held tool's pose is sent const INK_MS = 100; // how often new points are sent const SYNC_POINTS = 3000; // points per catch-up message const SMOOTH = 0.05; // seconds to ease toward someone else's pose // In the WebXR grip space a held object runs along -Z out of the fist and // the palm faces ±X, so the tool's +x goes to -Z and its flat faces the palm. const HOLD = new THREE.Quaternion().setFromRotationMatrix(new THREE.Matrix4().makeBasis( new THREE.Vector3(0, 0, -1), new THREE.Vector3(-1, 0, 0), new THREE.Vector3(0, 1, 0))); // On desktop it's held low and to the right, its tip toward the middle of your view. const DESK_POS = new THREE.Vector3(0.16, -0.2, -0.3); const DESK_QUAT = new THREE.Quaternion().setFromEuler(new THREE.Euler(0.35, 0.35, 0, 'YXZ')).multiply(HOLD); const v1 = new THREE.Vector3(); const v2 = new THREE.Vector3(); const v3 = new THREE.Vector3(); const side = new THREE.Vector3(); const up = new THREE.Vector3(); const q1 = new THREE.Quaternion(); const online = () => typeof NAF !== 'undefined' && NAF.connection && NAF.connection.isConnected(); const finite = (a, n) => Array.isArray(a) && a.length === n && a.every((x) => Number.isFinite(x) && Math.abs(x) < 1000); const validId = (id) => typeof id === 'string' && id.length > 0 && id.length <= 40; const validColor = (c) => typeof c === 'string' && /^#[0-9a-f]{6}$/i.test(c); const round = (x) => Math.round(x * 1e4) / 1e4; // Distance from p to the segment a–b. function toSegment(p, a, b) { v3.subVectors(b, a); const t = THREE.MathUtils.clamp(v3.dot(v1.subVectors(p, a)) / v3.lengthSq(), 0, 1); return p.distanceTo(v1.copy(a).addScaledVector(v3, t)); } // Every line has the same triangles, so they share one index. let sharedIndex = null; function lineIndex() { if (sharedIndex) return sharedIndex; const index = new Uint16Array((MAX_POINTS - 1) * SIDES * 6); let k = 0; for (let seg = 0; seg < MAX_POINTS - 1; seg++) { for (let s = 0; s < SIDES; s++) { const a = seg * SIDES + s; const b = seg * SIDES + ((s + 1) % SIDES); index.set([a, b, a + SIDES, b, b + SIDES, a + SIDES], k); k += 6; } } sharedIndex = new THREE.BufferAttribute(index, 1); return sharedIndex; } const materials = new Map(); function inkMaterial(color) { if (!materials.has(color)) { materials.set(color, new THREE.MeshBasicMaterial({ color, toneMapped: false, fog: false, side: THREE.DoubleSide })); } return materials.get(color); } // One stroke: a thin tube that grows a ring at a time. class Line { constructor(id, color) { this.id = id; this.color = color; this.points = []; // x, y, z, … this.count = 0; this.normal = new THREE.Vector3(); this.tangent = new THREE.Vector3(); const geometry = new THREE.BufferGeometry(); this.position = new THREE.BufferAttribute(new Float32Array(MAX_POINTS * SIDES * 3), 3); this.position.setUsage(THREE.DynamicDrawUsage); geometry.setAttribute('position', this.position); geometry.setIndex(lineIndex()); geometry.setDrawRange(0, 0); this.mesh = new THREE.Mesh(geometry, inkMaterial(color)); this.mesh.frustumCulled = false; } get full() { return this.count >= MAX_POINTS; } last(out) { const p = this.points; return out.set(p[p.length - 3], p[p.length - 2], p[p.length - 1]); } push(x, y, z) { if (this.full) return; const i = this.count; this.points.push(x, y, z); this.count++; if (i === 0) return; const p = this.points; const t = this.tangent.set(x - p[3 * i - 3], y - p[3 * i - 2], z - p[3 * i - 1]); if (t.lengthSq() < 1e-12) t.set(1, 0, 0); t.normalize(); // Carry the ring's orientation along the line so the tube doesn't twist. const n = this.normal; if (i > 1) n.addScaledVector(t, -n.dot(t)); if (i === 1 || n.lengthSq() < 1e-6) { up.set(0, 1, 0); if (Math.abs(t.y) > 0.9) up.set(1, 0, 0); n.copy(up).addScaledVector(t, -up.dot(t)); } n.normalize(); if (i === 1) this.ring(0); this.ring(i); this.mesh.geometry.setDrawRange(0, (this.count - 1) * SIDES * 6); this.position.needsUpdate = true; } ring(i) { const p = this.points; const b = side.crossVectors(this.tangent, this.normal); const a = this.position.array; for (let s = 0; s < SIDES; s++) { const angle = (s / SIDES) * Math.PI * 2; const c = Math.cos(angle) * RADIUS; const d = Math.sin(angle) * RADIUS; const o = (i * SIDES + s) * 3; a[o] = p[3 * i] + this.normal.x * c + b.x * d; a[o + 1] = p[3 * i + 1] + this.normal.y * c + b.y * d; a[o + 2] = p[3 * i + 2] + this.normal.z * c + b.z * d; } } near(pos, reach) { const p = this.points; const r2 = reach * reach; for (let k = 0; k < p.length; k += 3) { const dx = p[k] - pos.x; const dy = p[k + 1] - pos.y; const dz = p[k + 2] - pos.z; if (dx * dx + dy * dy + dz * dz < r2) return true; } return false; } dispose() { this.mesh.removeFromParent(); this.mesh.geometry.setIndex(null); // the index is shared; keep it this.mesh.geometry.dispose(); } } function buzz(handEl, strength, ms) { try { const tracked = handEl.components['tracked-controls']; const pad = tracked && tracked.controller && tracked.controller.gamepad; const motor = pad && pad.hapticActuators && pad.hapticActuators[0]; if (motor && motor.pulse) Promise.resolve(motor.pulse(strength, ms)).catch(() => {}); } catch (e) { // No haptics on this controller. } } AFRAME.registerComponent('wield', { schema: { head: { type: 'selector', default: '#player' }, leftHand: { type: 'selector', default: '#left-hand' }, rightHand: { type: 'selector', default: '#right-hand' }, }, init() { this.tools = null; // the world's, once it has loaded this.touchables = []; this.lines = new Map(); // id → Line, oldest first this.erased = new Set(); // ids of erased lines, so a late catch-up doesn't bring them back this.total = 0; this.prefix = Math.random().toString(36).slice(2, 8); this.serial = 0; this.lastPose = 0; this.group = new THREE.Group(); this.el.sceneEl.object3D.add(this.group); const hand = (el, vr) => ({ el, vr, tool: null, line: null, sent: 0, sentAt: 0, trigger: false, erasing: false, touching: null }); this.hands = [hand(this.data.leftHand, true), hand(this.data.rightHand, true)]; this.desk = hand(this.data.head, false); for (const h of this.hands) h.prompt = this.makePrompt(); this.listeners = []; const on = (target, type, fn) => { target.addEventListener(type, fn); this.listeners.push([target, type, fn]); }; for (const h of this.hands) { on(h.el, 'gripdown', () => this.grabNear(h)); on(h.el, 'gripup', () => this.release(h)); on(h.el, 'triggerdown', () => this.pull(h, true)); on(h.el, 'triggerup', () => this.pull(h, false)); on(h.el, 'controllerdisconnected', () => this.release(h)); } on(this.el.sceneEl, 'enter-vr', () => this.release(this.desk)); on(this.el.sceneEl, 'exit-vr', () => this.hands.forEach((h) => this.release(h))); const typing = (evt) => evt.target.closest && evt.target.closest('input, textarea'); on(window, 'keydown', (evt) => { if (evt.repeat || typing(evt) || this.el.sceneEl.is('vr-mode')) return; if (evt.code === 'KeyF') { if (this.desk.tool) this.release(this.desk); else this.grabLooked(); } if (evt.code === 'Backspace') { evt.preventDefault(); this.eraseLast(); } }); on(window, 'mousedown', (evt) => { if (evt.button === 0 && document.pointerLockElement === this.el.sceneEl.canvas) this.pull(this.desk, true); }); on(window, 'mouseup', (evt) => { if (evt.button === 0) this.pull(this.desk, false); }); on(window, 'blur', () => this.pull(this.desk, false)); if (typeof NAF !== 'undefined' && NAF.connection) { NAF.connection.subscribeToDataChannel('wield', (from, type, msg) => this.onPose(msg)); NAF.connection.subscribeToDataChannel('ink', (from, type, msg) => this.onInk(msg)); on(document.body, 'clientConnected', (evt) => this.catchUp(evt.detail.clientId)); on(document.body, 'clientDisconnected', (evt) => { for (const tool of this.tools || []) if (tool.by === evt.detail.clientId) tool.by = null; }); } }, remove() { for (const [target, type, fn] of this.listeners) target.removeEventListener(type, fn); this.group.removeFromParent(); }, // A label that floats above your hand while it's near something to touch. makePrompt() { const el = document.createElement('a-entity'); el.setAttribute('geometry', { primitive: 'plane', width: 0.26, height: 0.045 }); el.setAttribute('material', { shader: 'flat', color: '#000000', opacity: 0.6, transparent: true }); el.setAttribute('text', { value: '', align: 'center', width: 0.4, wrapCount: 28, color: '#ffe2c4' }); el.object3D.visible = false; this.el.sceneEl.appendChild(el); return el; }, // The world is added after the rig, so look for its tools until they're there. findTools() { const world = typeof CurrentWorld === 'function' ? CurrentWorld(this.el.sceneEl) : null; if (!world) return; this.touchables = world.touchables || []; this.tools = (world.tools || []).map((spec) => { const mark = new THREE.Mesh(new THREE.SphereGeometry(0.005, 10, 8), inkMaterial(spec.ink)); mark.position.copy(spec.tip); mark.visible = false; spec.object.add(mark); return { ...spec, mark, holder: null, by: null, v: 0, target: null }; }); }, // ---- holding ------------------------------------------------------------- myId() { return online() ? NAF.clientId : 'me'; }, // Who holds it now: you, someone else, or no one (null). holderId(tool) { return tool.holder ? this.myId() : tool.by; }, worldEnds(tool) { tool.object.updateWorldMatrix(true, false); return [tool.object.localToWorld(new THREE.Vector3().copy(tool.butt)), tool.object.localToWorld(new THREE.Vector3().copy(tool.tip))]; }, grabNear(hand) { if (!this.tools || hand.tool) return; const at = hand.el.object3D.getWorldPosition(new THREE.Vector3()); let best = null; let bestDistance = GRAB_REACH; for (const tool of this.tools) { if (tool.by) continue; // someone else has it const [a, b] = this.worldEnds(tool); const d = toSegment(at, a, b); if (d < bestDistance) { best = tool; bestDistance = d; } } if (best) this.grab(hand, best); }, // Desktop: the tool nearest your line of sight, within reach. grabLooked() { if (!this.tools) return; const head = this.data.head.object3D; const eye = head.getWorldPosition(new THREE.Vector3()); const ahead = new THREE.Vector3(0, 0, -1).applyQuaternion(head.getWorldQuaternion(q1)); let best = null; let bestAim = LOOK_AIM; let heldNearby = null; for (const tool of this.tools) { const [a, b] = this.worldEnds(tool); for (let i = 0; i <= 8; i++) { const p = v2.lerpVectors(a, b, i / 8).sub(eye); const along = p.dot(ahead); if (along < 0 || p.length() > LOOK_REACH) continue; const aim = p.addScaledVector(ahead, -along).length(); if (aim >= bestAim) continue; if (tool.by) { heldNearby = tool; continue; } best = tool; bestAim = aim; } } if (best) this.grab(this.desk, best); else this.el.emit('wield-failed', { reason: heldNearby ? 'held' : 'far', label: heldNearby && heldNearby.label }, false); }, grab(hand, tool) { if (tool.holder && tool.holder !== hand) { // From your other hand. this.stopDrawing(tool.holder); tool.holder.tool = null; } const obj = tool.object; hand.el.object3D.add(obj); obj.quaternion.copy(hand.vr ? HOLD : DESK_QUAT); obj.position.copy(tool.grip).applyQuaternion(obj.quaternion).negate(); if (!hand.vr) obj.position.add(DESK_POS); tool.holder = hand; tool.by = null; tool.target = null; hand.tool = tool; tool.v++; this.sendPose(tool, true); if (hand.vr) buzz(hand.el, 0.4, 40); hand.erasing = false; if (hand.trigger) this.startLine(hand); // trigger already held }, // Lets go: it stays exactly where it was, floating. release(hand) { const tool = hand.tool; if (!tool) return; this.stopDrawing(hand); this.el.sceneEl.object3D.attach(tool.object); tool.holder = null; hand.tool = null; tool.v++; this.sendPose(tool, true); }, sendPose(tool, guaranteed, to) { if (!online()) return; const obj = tool.object; obj.updateWorldMatrix(true, false); const p = obj.getWorldPosition(v1); const q = obj.getWorldQuaternion(q1); const msg = { t: tool.name, by: this.holderId(tool), v: tool.v, p: [round(p.x), round(p.y), round(p.z)], q: [round(q.x), round(q.y), round(q.z), round(q.w)], }; if (to) NAF.connection.sendDataGuaranteed(to, 'wield', msg); else if (guaranteed) NAF.connection.broadcastDataGuaranteed('wield', msg); else NAF.connection.broadcastData('wield', msg); }, onPose(msg) { if (!this.tools) this.findTools(); if (!this.tools || !msg) return; const tool = this.tools.find((t) => t.name === msg.t); if (!tool || !Number.isInteger(msg.v) || !finite(msg.p, 3) || !finite(msg.q, 4)) return; const by = typeof msg.by === 'string' ? msg.by : null; const current = this.holderId(tool); if (msg.v < tool.v) return; // Two people grabbed it at once: the lower id keeps it, the same for everyone. if (msg.v === tool.v && by !== current && (by || '￿') > (current || '￿')) return; tool.v = msg.v; if (tool.holder && by !== this.myId()) { const hand = tool.holder; this.stopDrawing(hand); hand.tool = null; tool.holder = null; } if (tool.holder) return; tool.by = by; if (tool.object.parent !== this.el.sceneEl.object3D) this.el.sceneEl.object3D.attach(tool.object); const q = new THREE.Quaternion().fromArray(msg.q); if (q.lengthSq() < 1e-6) return; tool.target = { p: new THREE.Vector3().fromArray(msg.p), q: q.normalize() }; }, // ---- drawing and erasing ------------------------------------------------- // Trigger (or mouse button) down or up: draw with what you hold, use what // an empty hand is touching, or else erase with it. pull(hand, down) { if (hand.trigger === down) return; hand.trigger = down; if (!down) { hand.erasing = false; this.stopDrawing(hand); } else if (hand.tool) { this.startLine(hand); } else if (hand.touching) { hand.touching.use(); buzz(hand.el, 0.5, 60); } else { hand.erasing = hand.vr; } }, // Which touchable an empty hand is near, and its label above the hand. feel(hand) { let near = null; if (!hand.tool && this.el.sceneEl.is('vr-mode') && hand.el.object3D.visible) { const at = hand.el.object3D.getWorldPosition(v1); let best = TOUCH_REACH; for (const t of this.touchables) { const d = t.distance(at); if (d < best) { near = t; best = d; } } } if (near && near !== hand.touching) buzz(hand.el, 0.15, 20); hand.touching = near; const prompt = hand.prompt.object3D; prompt.visible = Boolean(near); if (!near) return; const text = `Trigger: ${near.label()}`; if (hand.prompt.getAttribute('text').value !== text) hand.prompt.setAttribute('text', 'value', text); prompt.position.copy(v1).y += 0.1; prompt.lookAt(this.data.head.object3D.getWorldPosition(v2)); }, startLine(hand) { const line = this.addLine(`${this.prefix}.${this.serial++}`, hand.tool.ink); hand.line = line; hand.sent = 0; hand.sentAt = 0; }, stopDrawing(hand) { const line = hand.line; if (!line) return; this.sendInk(hand); hand.line = null; if (line.count < 2) this.erase([line.id], hand.sent > 0); }, addLine(id, color) { const line = new Line(id, color); this.lines.set(id, line); this.group.add(line.mesh); return line; }, addPoints(line, x, y, z) { line.push(x, y, z); this.total++; }, // The oldest lines go once there are too many, here and for everyone else // by the same rule. trim() { for (const line of this.lines.values()) { if (this.lines.size <= MAX_LINES && this.total <= MAX_TOTAL) return; if (this.hands.concat(this.desk).some((h) => h.line === line)) continue; this.erase([line.id], false); } }, erase(ids, share) { for (const id of ids) { this.erased.add(id); const line = this.lines.get(id); if (!line) continue; this.total -= line.count; line.dispose(); this.lines.delete(id); } if (this.erased.size > 5000) this.erased.delete(this.erased.values().next().value); if (share && online()) NAF.connection.broadcastDataGuaranteed('ink', { op: 'erase', ids }); }, eraseLast() { const mine = [...this.lines.values()].reverse().find((l) => l.id.startsWith(`${this.prefix}.`) && l !== this.desk.line); if (mine) this.erase([mine.id], true); }, sendInk(hand) { const line = hand.line; if (!line || hand.sent >= line.count) return; if (online()) { const pts = line.points.slice(hand.sent * 3).map((x) => Math.round(x * 1000)); NAF.connection.broadcastDataGuaranteed('ink', { op: 'add', id: line.id, c: line.color, n: hand.sent, pts }); } hand.sent = line.count; }, // Points (in mm) for a line, from someone else; `n` is how many it already had. takePoints(id, color, n, pts) { if (!validId(id) || this.erased.has(id) || !validColor(color) || !Number.isInteger(n) || n < 0) return; if (!Array.isArray(pts) || pts.length % 3 || pts.length > MAX_POINTS * 3 || !pts.every((x) => Number.isFinite(x) && Math.abs(x) < 1e6)) return; const line = this.lines.get(id) || this.addLine(id, color); for (let k = Math.max(0, line.count - n) * 3; k < pts.length && !line.full; k += 3) { this.addPoints(line, pts[k] / 1000, pts[k + 1] / 1000, pts[k + 2] / 1000); } this.trim(); }, onInk(msg) { if (!msg) return; if (msg.op === 'add') this.takePoints(msg.id, msg.c, msg.n, msg.pts); if (msg.op === 'erase' && Array.isArray(msg.ids)) this.erase(msg.ids.filter(validId).slice(0, 50), false); if (msg.op === 'sync') { if (Array.isArray(msg.erased)) this.erase(msg.erased.filter(validId).slice(0, 1000), false); if (Array.isArray(msg.lines)) for (const l of msg.lines.slice(0, MAX_LINES)) if (l) this.takePoints(l.id, l.c, 0, l.pts); } }, // Tells someone who just arrived where the tools are and what's drawn. // Everyone here does this; lines they already have are skipped. catchUp(clientId) { if (!online()) return; for (const tool of this.tools || []) if (tool.v > 0) this.sendPose(tool, true, clientId); let batch = []; let size = 0; const send = (erased) => { NAF.connection.sendDataGuaranteed(clientId, 'ink', { op: 'sync', lines: batch, erased }); batch = []; size = 0; }; for (const line of this.lines.values()) { batch.push({ id: line.id, c: line.color, pts: line.points.map((x) => Math.round(x * 1000)) }); size += line.count; if (size >= SYNC_POINTS) send([]); } send([...this.erased].slice(-1000)); }, // ---- every frame ----------------------------------------------------------- tick(time, delta) { if (!this.tools) { this.findTools(); if (!this.tools) return; } const dt = Math.min(delta, 100) / 1000; const ease = 1 - Math.exp(-dt / SMOOTH); for (const tool of this.tools) { tool.mark.visible = Boolean(tool.holder || tool.by); if (!tool.target || tool.holder) continue; const obj = tool.object; obj.position.lerp(tool.target.p, ease); obj.quaternion.slerp(tool.target.q, ease); if (!tool.by && obj.position.distanceToSquared(tool.target.p) < 1e-8) tool.target = null; } for (const hand of this.hands) this.feel(hand); for (const hand of this.hands.concat(this.desk)) { if (hand.line) this.draw(hand, time); if (hand.erasing) { const at = hand.el.object3D.getWorldPosition(v1); const hit = [...this.lines.values()].filter((l) => l.near(at, ERASE_REACH)).map((l) => l.id); if (hit.length) { this.erase(hit, true); buzz(hand.el, 0.25, 25); } } } if (time - this.lastPose >= POSE_MS) { this.lastPose = time; for (const tool of this.tools) if (tool.holder) this.sendPose(tool, false); } }, draw(hand, time) { const tool = hand.tool; tool.object.updateWorldMatrix(true, false); const tip = tool.object.localToWorld(v2.copy(tool.tip)); let line = hand.line; if (line.count && line.last(v3).distanceTo(tip) < STEP) return; if (line.full) { // Carry on in a new line from where this one ends. const end = line.last(new THREE.Vector3()); this.sendInk(hand); this.startLine(hand); line = hand.line; this.addPoints(line, end.x, end.y, end.z); } this.addPoints(line, tip.x, tip.y, tip.z); this.trim(); if (time - hand.sentAt >= INK_MS) { hand.sentAt = time; this.sendInk(hand); } }, }); })();