Skip to content

Commit

Permalink
feat(troika-xr): add WristMountedUI component
Browse files Browse the repository at this point in the history
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
lojjic committed Feb 22, 2020
1 parent c463198 commit 74f5b10
Show file tree
Hide file tree
Showing 7 changed files with 451 additions and 0 deletions.
37 changes: 37 additions & 0 deletions packages/troika-xr/src/facade/wrist-mounted-ui/Cog.js
@@ -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 packages/troika-xr/src/facade/wrist-mounted-ui/ContentContainer.js
@@ -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 packages/troika-xr/src/facade/wrist-mounted-ui/Projection.js
@@ -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()
}
}
40 changes: 40 additions & 0 deletions packages/troika-xr/src/facade/wrist-mounted-ui/Strap.js
@@ -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 packages/troika-xr/src/facade/wrist-mounted-ui/WristMountedUI.js
@@ -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()
}
}

}

0 comments on commit 74f5b10

Please sign in to comment.