Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(troika-xr): add WristMountedUI component
This facade provides a container for arbitrary global UI, which is hidden by default but is easily brought up by a simple gesture. A wristband is added to one of the hand controllers, with an icon affordance on the inner wrist. When that icon is turned upwards, the UI is projected from it.
- Loading branch information
Showing
7 changed files
with
451 additions
and
0 deletions.
There are no files selected for viewing
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
import { utils } from 'troika-core' | ||
import { ExtrudeBufferGeometry, Mesh, MeshStandardMaterial, Shape } from 'three' | ||
import { MeshFacade } from 'troika-3d' | ||
|
||
const getCogGeometry = utils.memoize(() => { | ||
let outerRadius = 0.01 | ||
let innerRadius = 0.006 | ||
let midRadius = (innerRadius + outerRadius) * .75 | ||
let teeth = 8 | ||
let twoPi = Math.PI * 2 | ||
let shape = new Shape().moveTo(midRadius, 0) | ||
for (let i = 0; i < teeth; i++) { | ||
let angle = i / teeth * twoPi | ||
shape.lineTo(Math.cos(angle) * outerRadius, Math.sin(angle) * outerRadius) | ||
angle = (i + 0.5) / teeth * twoPi | ||
shape.lineTo(Math.cos(angle) * outerRadius, Math.sin(angle) * outerRadius) | ||
shape.lineTo(Math.cos(angle) * midRadius, Math.sin(angle) * midRadius) | ||
if (i === teeth - 1) { | ||
shape.lineTo(midRadius, 0) //close shape exactly | ||
} else { | ||
angle = (i + 1) / teeth * twoPi | ||
shape.lineTo(Math.cos(angle) * midRadius, Math.sin(angle) * midRadius) | ||
} | ||
} | ||
shape.moveTo(innerRadius, 0).absellipse(0, 0, innerRadius, innerRadius, 0, twoPi, true) | ||
return new ExtrudeBufferGeometry(shape, { | ||
curveSegments: teeth * 2, | ||
depth: 0.005, | ||
bevelEnabled: false | ||
}) | ||
}) | ||
|
||
export class Cog extends MeshFacade { | ||
get geometry() { | ||
return getCogGeometry() | ||
} | ||
} |
96 changes: 96 additions & 0 deletions
96
packages/troika-xr/src/facade/wrist-mounted-ui/ContentContainer.js
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,96 @@ | ||
import { Object3DFacade, PlaneFacade } from 'troika-3d' | ||
import { Group, Matrix4, Vector3 } from 'three' | ||
import { Projection } from './Projection.js' | ||
|
||
const tempMat4 = new Matrix4() | ||
const targetPos = new Vector3() | ||
const camPos = new Vector3() | ||
const curPos = new Vector3() | ||
|
||
export class ContentContainer extends Object3DFacade { | ||
constructor (parent) { | ||
super(parent, new Group()) | ||
this.distancePastGrip = 0.25 //distance past grip in camera-to-grip direction | ||
this.minDistanceFromCamera = 0.5 | ||
this.heightAboveGrip = 0.15 | ||
this.gripPose = null | ||
this.active = false | ||
} | ||
|
||
afterUpdate() { | ||
const {gripPose} = this | ||
if (gripPose) { | ||
this.getCameraPosition(camPos) | ||
|
||
// Find target position | ||
let targetScale | ||
if (this.active) { | ||
// Find direction vector and lengthen it to the target distance to find base position | ||
targetPos.copy(gripPose.transform.position) | ||
targetPos.y = camPos.y | ||
targetPos.sub(camPos) | ||
targetPos.setLength(Math.max(this.minDistanceFromCamera, targetPos.length() + this.distancePastGrip)) | ||
targetPos.add(camPos) | ||
targetPos.y = gripPose.transform.position.y + this.heightAboveGrip | ||
targetScale = 1 | ||
} else { | ||
targetPos.copy(gripPose.transform.position) | ||
targetScale = 0.001 | ||
} | ||
|
||
// Pull partway toward target position and scale, like a spring | ||
this.getWorldPosition(curPos) | ||
curPos.lerp(targetPos, 0.05) //move by 5% of distance each frame) | ||
curPos.copy.call(this, curPos) //sets x,y,z | ||
this.scale += (targetScale - this.scale) * 0.3 | ||
this.visible = this.scale > 0.01 //hide below a certain size | ||
|
||
// Rotate to face camera | ||
this.rotateY = Math.atan2(camPos.x - curPos.x, camPos.z - curPos.z) | ||
} else { | ||
this.visible = false | ||
} | ||
|
||
super.afterUpdate() | ||
} | ||
|
||
// shouldUpdateChildren () { | ||
// return !!this.active | ||
// } | ||
|
||
describeChildren() { | ||
let radiusX = 0.5 / 2 | ||
let radiusZ = 0.3 / 2 | ||
let kids = this._kidsTpl || (this._kidsTpl = [ | ||
{ | ||
key: '$platform', | ||
facade: PlaneFacade, | ||
width: radiusX * 2, | ||
depth: radiusZ * 2, | ||
'material.color': 0x666666, | ||
'material.metalness': 0.8, | ||
'material.roughness': 0.3 | ||
}, | ||
{ | ||
key: '$projection', | ||
facade: Projection, | ||
from: new Vector3(), | ||
to1: new Vector3(), | ||
to2: new Vector3(), | ||
to3: new Vector3(), | ||
to4: new Vector3() | ||
} | ||
]) | ||
if (this.gripPose && this.visible) { | ||
kids[1].from.set(0.027, 0.056, 0.056).applyMatrix4(tempMat4.fromArray(this.gripPose.transform.matrix)) | ||
|
||
let mtx = this.threeObject.matrixWorld | ||
kids[1].to1.set(-radiusX, 0, -radiusZ).applyMatrix4(mtx) | ||
kids[1].to2.set(radiusX, 0, -radiusZ).applyMatrix4(mtx) | ||
kids[1].to3.set(radiusX, 0, radiusZ).applyMatrix4(mtx) | ||
kids[1].to4.set(-radiusX, 0, radiusZ).applyMatrix4(mtx) | ||
} | ||
return kids.concat(this.children) | ||
} | ||
} | ||
|
113 changes: 113 additions & 0 deletions
113
packages/troika-xr/src/facade/wrist-mounted-ui/Projection.js
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,113 @@ | ||
import { MeshFacade } from 'troika-3d' | ||
import { | ||
BufferAttribute, | ||
BufferGeometry, | ||
Color, | ||
ShaderMaterial, | ||
Vector4 | ||
} from 'three' | ||
|
||
|
||
const vertexShader = ` | ||
varying vec2 vUV; | ||
void main() { | ||
vUV = uv; | ||
gl_Position = projectionMatrix * viewMatrix * vec4(position, 1.0); | ||
} | ||
` | ||
|
||
const baseAlpha = 0.2 | ||
const scanlines = [ | ||
// sep = separation between lines | ||
// vel = movement speed, in scans per second | ||
// size = width of line's gradient | ||
// alpha = alpha to add at line's center | ||
{sep: 0.4, vel: 0.15, size: 0.1, alpha: 0.1}, | ||
{sep: 0.7, vel: 0.2, size: 0.15, alpha: 0.1}, | ||
{sep: 0.3, vel: 0.3, size: 0.1, alpha: 0.1}, | ||
{sep: 0.2, vel: 0.6, size: 0.1, alpha: 0.04}, | ||
{sep: 0.1, vel: 0.4, size: 0.02, alpha: 0.04}, | ||
] | ||
|
||
const fragmentShader = ` | ||
uniform float time; | ||
uniform vec4 fade; | ||
uniform vec3 color; | ||
varying vec2 vUV; | ||
float distToScanline(float x, float separation, float velocity) { | ||
x += time / 1000.0 * velocity; | ||
float dist = abs(x - round(x / separation) * separation); | ||
return dist; | ||
} | ||
void main() { | ||
float alpha = ${baseAlpha}; | ||
${scanlines.map(({sep, vel, size, alpha}) => | ||
`alpha += ${alpha} * smoothstep(${size / 2}, 0.0, distToScanline(vUV.y, ${sep}, ${vel}));` | ||
).join('\n')} | ||
alpha *= min(smoothstep(fade.x, fade.y, vUV.y), smoothstep(fade.w, fade.z, vUV.y)); | ||
gl_FragColor = vec4(color, alpha); | ||
} | ||
` | ||
|
||
const epoch = Date.now() | ||
|
||
let createMaterial = function() { | ||
return new ShaderMaterial({ | ||
uniforms: { | ||
time: {get value() {return epoch - Date.now()}}, | ||
color: {value: new Color(0x3399ff)}, | ||
fade: {value: new Vector4(0, 0.4, 0.7, 1)} //fade in+out gradient stops | ||
}, | ||
vertexShader, | ||
fragmentShader, | ||
transparent: true, | ||
// side: DoubleSide | ||
}) | ||
} | ||
|
||
// 0 = cone point; 1-4 = top face's 4 corners in clockwise order | ||
const geomIndexes = [ | ||
0, 1, 2, | ||
0, 2, 3, | ||
0, 3, 4, | ||
0, 4, 1 | ||
] | ||
const uvs = new Float32Array([ | ||
0, 0, | ||
0, 1, | ||
0, 1, | ||
0, 1, | ||
0, 1 | ||
]) | ||
|
||
export class Projection extends MeshFacade { | ||
constructor (parent) { | ||
super(parent) | ||
this.threeObject.frustumCulled = false | ||
this.renderOrder = 99999 | ||
|
||
let geom = new BufferGeometry() | ||
geom.setAttribute('position', new BufferAttribute(new Float32Array(15), 3)) | ||
geom.setAttribute('uv', new BufferAttribute(uvs, 2)) | ||
geom.setIndex(geomIndexes) | ||
this.autoDisposeGeometry = true | ||
this.geometry = geom | ||
|
||
this.material = createMaterial() | ||
} | ||
|
||
afterUpdate() { | ||
// Update geometry vertices | ||
let { from, to1, to2, to3, to4 } = this | ||
let posAttr = this.geometry.getAttribute('position') | ||
posAttr.setXYZ(0, from.x, from.y, from.z) | ||
posAttr.setXYZ(1, to1.x, to1.y, to1.z) | ||
posAttr.setXYZ(2, to2.x, to2.y, to2.z) | ||
posAttr.setXYZ(3, to3.x, to3.y, to3.z) | ||
posAttr.setXYZ(4, to4.x, to4.y, to4.z) | ||
posAttr.needsUpdate = true | ||
super.afterUpdate() | ||
} | ||
} |
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
import { utils } from 'troika-core' | ||
import { CylinderBufferGeometry, DoubleSide, Mesh, MeshStandardMaterial } from 'three' | ||
import { Object3DFacade } from 'troika-3d' | ||
|
||
|
||
const getStrapGeometry = utils.memoize(() => { | ||
return new CylinderBufferGeometry( | ||
1, | ||
1, | ||
1, | ||
64, | ||
1, | ||
true, | ||
// Math.PI / 4 * 3, | ||
// Math.PI / 4 * 6 | ||
) | ||
.rotateX(Math.PI / 2) | ||
}) | ||
|
||
const getStrapMaterial = utils.memoize(() => { | ||
return new MeshStandardMaterial({ | ||
color: 0x333333, | ||
side: DoubleSide | ||
}) | ||
}) | ||
|
||
export class Strap extends Object3DFacade { | ||
constructor (parent) { | ||
super(parent, new Mesh(getStrapGeometry(), getStrapMaterial())) | ||
} | ||
set smallRadius(val) { | ||
this.scaleX = val | ||
} | ||
set largeRadius(val) { | ||
this.scaleY = val | ||
} | ||
set width(val) { | ||
this.scaleZ = val | ||
} | ||
} |
89 changes: 89 additions & 0 deletions
89
packages/troika-xr/src/facade/wrist-mounted-ui/WristMountedUI.js
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,89 @@ | ||
import { Group3DFacade } from 'troika-3d' | ||
import { | ||
Matrix4, | ||
Quaternion, | ||
Vector3 | ||
} from 'three' | ||
import { Wristband } from './Wristband.js' | ||
import { ContentContainer } from './ContentContainer.js' | ||
|
||
const tempMat4 = new Matrix4() | ||
const tempQuat = new Quaternion() | ||
const tempVec3 = new Vector3() | ||
const upVec3 = new Vector3(0, 1, 0) | ||
|
||
|
||
/** | ||
* This facade provides a container for arbitrary global UI, which is hidden by default | ||
* but is easily brought up by a simple gesture. A wristband is added to one of the hand | ||
* controllers, with an icon affordance on the inner wrist. When that icon is turned | ||
* upwards, the UI is projected from it. | ||
* | ||
* @property {('left'|'right')} preferredHand - which hand the wristband should appear on. | ||
*/ | ||
export class WristMountedUI extends Group3DFacade { | ||
constructor (parent) { | ||
super(parent) | ||
this.active = false | ||
this.activeUpAngle = Math.PI / 7 | ||
this.preferredHand = 'left' | ||
|
||
this.addEventListener('xrframe', this.onXRFrame.bind(this)) | ||
} | ||
|
||
describeChildren () { | ||
let children = this._childTpl || (this._childTpl = [ | ||
{ | ||
key: 'wristband', | ||
facade: Wristband, | ||
active: false, | ||
gripPose: null | ||
}, | ||
{ | ||
key: 'content', | ||
facade: ContentContainer, | ||
active: false, | ||
gripPose: null, | ||
children: null | ||
} | ||
]) | ||
|
||
let [wristbandDef, contentDef] = children | ||
wristbandDef.active = contentDef.active = this.active | ||
wristbandDef.gripPose = contentDef.gripPose = this.gripPose | ||
contentDef.children = this.children | ||
|
||
return children | ||
} | ||
|
||
onXRFrame (time, xrFrame) { | ||
let gripPose = null | ||
let inputSources = xrFrame.session.inputSources | ||
if (inputSources) { | ||
let gripSpace = null | ||
for (let i = 0, len = inputSources.length; i < len; i++) { | ||
if (inputSources[i].handedness === this.preferredHand) { | ||
gripSpace = inputSources[i].gripSpace | ||
break | ||
} | ||
} | ||
if (gripSpace) { | ||
// Calculate grip pose so we can pass it down to the Wristband | ||
gripPose = xrFrame.getPose(gripSpace, this.getCameraFacade().offsetReferenceSpace) | ||
|
||
// If turned to upward angle, set to active | ||
// TODO: needs debouncing! | ||
tempVec3.set(1, 0, 0).applyQuaternion( | ||
tempQuat.setFromRotationMatrix(tempMat4.fromArray(gripPose.transform.matrix)) | ||
) | ||
this.active = tempVec3.angleTo(upVec3) < this.activeUpAngle | ||
} | ||
} | ||
|
||
if (gripPose || !this.gripPose) { | ||
this.gripPose = gripPose | ||
this.afterUpdate() | ||
} | ||
} | ||
|
||
} |
Oops, something went wrong.