Last active
December 6, 2021 02:45
-
-
Save pofulu/b907418116ccc4e4d26575c62f2d9636 to your computer and use it in GitHub Desktop.
The TypeScript version of script in Spark AR 3D Animated Poster sample project
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 Time from 'Time'; | |
import Scene from 'Scene'; | |
import Patches from 'Patches'; | |
import Textures from 'Textures'; | |
import Reactive from 'Reactive'; | |
import Animation from 'Animation'; | |
import TouchGestures from 'TouchGestures'; | |
// Constants for the default visual and interaction style. | |
const TARGET_ENVELOPE_SCALE = 1.1; | |
const ICON_PLANE_DISTANCE = 0.1; | |
const DEFAULT_PLANE_SIZE = 0.1; | |
const PLANE_TO_TARGET_TRACKER_RATIO = 3; | |
const ICON_STATE = { Hidden: 0, Minimized: 1, Maximized: 2 } | |
const ICON_TRANSITION_DURATION = 500; | |
const ICON_FADE_DURATION = 250; | |
const ICON_START_DELAY = 0; | |
const ICON_MAXIMIZE_TIMEOUT = 3000; | |
const RETICLE_TRANSITION_DURATION = 500; | |
const RETICLE_FADE_DURATION = 500; | |
const FIT_SCALE = 0.55; | |
const TARGET_TRACKER_TEXTURE = 'replaceMe'; | |
class TargetTrackerDemo { | |
iconState: number; | |
iconMaximizationTimeout: Subscription; | |
reticleTransitionTimeout: Subscription; | |
camera: Camera; | |
targetTracker: TargetTracker; | |
targetEnvelope: Plane; | |
screenFit: SceneObject; | |
icon: Plane; | |
reticle: Plane; | |
glint: SceneObject; | |
glintPivot: SceneObject; | |
targetTexture: TextureBase; | |
slamNuxProgress: ScalarSignal; | |
isFixedTarget: BoolSignal; | |
aspectRatio: ScalarSignal; | |
targetInView: BoolSignal; | |
reticleTransitionDriver: TimeDriver; | |
reticleFadeDriver: TimeDriver; | |
iconFadeDriver: TimeDriver; | |
iconTransitionDriver: TimeDriver; | |
reticleTransition: ScalarSignal; | |
constructor() { | |
this.iconState = ICON_STATE.Minimized; | |
this.iconMaximizationTimeout = null; | |
this.reticleTransitionTimeout = null; | |
} | |
async initialize() { | |
[ | |
this.camera, | |
this.targetTracker, | |
this.targetEnvelope, | |
this.screenFit, | |
this.icon, | |
this.reticle, | |
this.glint, | |
this.glintPivot, | |
] = await Promise.all([ | |
Scene.root.findFirst('Camera') as unknown as Camera, | |
Scene.root.findFirst('targetTracker0') as unknown as TargetTracker, | |
Scene.root.findFirst('targetEnvelope') as unknown as Plane, | |
Scene.root.findFirst('screenFit'), | |
Scene.root.findFirst('icon') as unknown as Plane, | |
Scene.root.findFirst('reticle') as undefined as Plane, | |
Scene.root.findFirst('glint'), | |
Scene.root.findFirst('glintPivot'), | |
]); | |
[ | |
this.targetTexture, | |
this.slamNuxProgress, | |
this.isFixedTarget | |
] = await Promise.all([ | |
Textures.findFirst(TARGET_TRACKER_TEXTURE), | |
Patches.outputs.getScalar('trackerInstructionProgress'), | |
Patches.outputs.getBoolean('isFixedTarget') | |
]); | |
// initAfterPromiseResolved | |
this.aspectRatio = this.targetTexture.width.div(this.targetTexture.height); | |
this.setupReticleFade(); | |
this.setupReticleTransition(); | |
this.setupTargetEnvelope(); | |
this.setupScreenFit(); | |
this.setupReticle(); | |
this.setupIconFade(); | |
this.setupIconYaw(); | |
this.setupTouchGestures(); | |
this.setupGlint(); | |
this.setupTrackerInstruction(); | |
this.maximizeIcon(); | |
this.outputToPatch(); | |
} | |
/** Setup a square plane to envelop the target image in world space. This serves as an anchor for the reticle. When target are found, the reticle transition to targetEnvelope */ | |
setupTargetEnvelope() { | |
const worldTransform = this.targetTracker.worldTransform; | |
this.targetEnvelope.transform.position = worldTransform.position; | |
this.targetEnvelope.transform.rotationX = worldTransform.rotationX; | |
this.targetEnvelope.transform.rotationY = worldTransform.rotationY; | |
this.targetEnvelope.transform.rotationZ = worldTransform.rotationZ; | |
this.targetEnvelope.transform.scale = worldTransform.scale | |
.mul(PLANE_TO_TARGET_TRACKER_RATIO) | |
.div(this.aspectRatio.gt(1).ifThenElse(1, this.aspectRatio)) | |
.mul(TARGET_ENVELOPE_SCALE); | |
this.targetInView = this.isTargetInView( | |
this.targetEnvelope.transform.position, | |
this.targetEnvelope.transform.scaleX.mul(DEFAULT_PLANE_SIZE)); | |
} | |
/** Setup a square plane that fit in the center of the screen. This serves as an anchor for the reticle. When tracking is not initialized, the reticle rest around the on-screen target image. */ | |
setupScreenFit() { | |
const iconScale = Reactive.div(ICON_PLANE_DISTANCE, this.camera.focalPlane.distance); | |
const fitSize = this.camera.focalPlane.width.gt(this.camera.focalPlane.height) | |
.ifThenElse(this.camera.focalPlane.height, this.camera.focalPlane.width); | |
this.screenFit.transform.scaleX = iconScale | |
.mul(fitSize).div(DEFAULT_PLANE_SIZE).mul(FIT_SCALE); | |
this.screenFit.transform.scaleY = iconScale | |
.mul(fitSize).div(DEFAULT_PLANE_SIZE).mul(FIT_SCALE); | |
this.screenFit.transform.z = Reactive.val(-ICON_PLANE_DISTANCE); | |
} | |
/** Setup the reticle to interpolate between targetEnvelope and screenFit. */ | |
setupReticle() { | |
this.reticle.transform = Reactive.mix( | |
this.screenFit.transform.toSignal(), | |
this.targetEnvelope.transform.toSignal(), | |
this.reticleTransition | |
); | |
} | |
/** Setup the animation driver for reticle transition. */ | |
setupReticleTransition() { | |
this.reticleTransitionDriver = Animation.timeDriver({ durationMilliseconds: 1000 }); | |
const animationSampler = Animation.samplers.easeInOutCubic(0, 1); | |
this.reticleTransition = Animation.animate(this.reticleTransitionDriver, animationSampler); | |
Patches.inputs.setScalar('reticleTransition', this.reticleTransition); | |
this.targetTracker.confidence.monitor({ fireOnInitialValue: true }).subscribe(v => { | |
if (this.reticleTransitionTimeout != null) { | |
Time.clearTimeout(this.reticleTransitionTimeout); | |
} | |
if (v.newValue != 'NOT_TRACKING') { | |
this.reticleTransitionDriver.reset(); | |
this.reticleTransitionDriver.start(); | |
this.reticleTransitionTimeout = Time.setTimeout(() => this.fadeReticle(), RETICLE_TRANSITION_DURATION + RETICLE_FADE_DURATION); | |
} else { | |
this.reticleTransitionDriver.reverse(); | |
this.showReticle(); | |
} | |
}); | |
} | |
/** Setup the animation driver for reticle showing and fading. */ | |
setupReticleFade() { | |
this.reticleFadeDriver = Animation.timeDriver({ durationMilliseconds: 1000 }); | |
const animationSampler = Animation.samplers.easeInOutCubic(1, 0); | |
Patches.inputs.setScalar('reticleFade', Animation.animate(this.reticleFadeDriver, animationSampler)); | |
} | |
fadeReticle() { | |
this.reticleFadeDriver.reset(); | |
this.reticleFadeDriver.start(); | |
} | |
showReticle() { | |
this.reticleFadeDriver.reverse(); | |
} | |
/** Setup icon fade animation driver. The on-screen image icon should fade out when tracking starts. */ | |
setupIconFade() { | |
Time.setTimeout(() => this.showIcon(), ICON_START_DELAY); | |
this.targetTracker.confidence.eq('NOT_TRACKING').onOn().subscribe(() => this.showIcon()); | |
this.targetTracker.confidence.eq('NOT_TRACKING').onOff().subscribe(() => this.fadeIcon()); | |
this.iconFadeDriver = Animation.timeDriver({ durationMilliseconds: ICON_FADE_DURATION }); | |
const animationSampler = Animation.samplers.easeInOutCubic(0, 1); | |
Patches.inputs.setScalar('iconFade', Animation.animate(this.iconFadeDriver, animationSampler)); | |
} | |
fadeIcon() { | |
this.iconFadeDriver.reverse(); | |
} | |
showIcon() { | |
this.iconFadeDriver.reset(); | |
this.iconFadeDriver.start(); | |
this.maximizeIcon(); | |
} | |
/** Setup touch gestures for tapping on the icon. Tapping on the icon expand to a full image view. */ | |
setupTouchGestures() { | |
this.iconTransitionDriver = Animation.timeDriver({ durationMilliseconds: ICON_TRANSITION_DURATION }); | |
const animationSampler = Animation.samplers.easeInOutCubic(0, 1); | |
Patches.inputs.setScalar('iconTransition', Animation.animate(this.iconTransitionDriver, animationSampler)); | |
TouchGestures.onTap(this.icon).subscribe(gesture => { | |
if (this.iconState == ICON_STATE.Minimized) { | |
this.maximizeIcon(); | |
} else { | |
this.minimizeIcon(); | |
} | |
}); | |
} | |
minimizeIcon() { | |
this.iconTransitionDriver.reverse(); | |
this.clearIconMaximizationTimeout(); | |
this.iconState = ICON_STATE.Minimized; | |
} | |
maximizeIcon() { | |
this.iconTransitionDriver.reset(); | |
this.iconTransitionDriver.start(); | |
this.clearIconMaximizationTimeout(); | |
this.iconMaximizationTimeout = Time.setTimeout(() => this.minimizeIcon(), ICON_MAXIMIZE_TIMEOUT); | |
this.iconState = ICON_STATE.Maximized; | |
} | |
/** Setup a slight yaw motion to make the icon react to tracking instruction animation. */ | |
setupIconYaw() { | |
const driver = Animation.valueDriver(this.slamNuxProgress, 0.0, 1.0); | |
const sampler = Animation.samplers.easeInOutQuad(-0.1, 0.1); | |
this.icon.transform.rotationY = Animation.animate(driver, sampler); | |
} | |
/** Setup the glint to indicate where the target is if the target is not in camera's view. This UI can guide user to the main tracking target. */ | |
setupGlint() { | |
const targetPositionX = this.targetEnvelope.transform.x; | |
const targetPositionY = this.targetEnvelope.transform.y; | |
const xSign = Reactive.sign(targetPositionX); | |
const ySign = Reactive.sign(targetPositionY); | |
const slope = targetPositionX.eq(0).ifThenElse(0, targetPositionY.div(targetPositionX).abs()); | |
const screenX = Reactive.mul(DEFAULT_PLANE_SIZE / 2, xSign); | |
const screenY = Reactive.mul(DEFAULT_PLANE_SIZE / 2, ySign); | |
const tipPosition = Reactive.point( | |
slope.gt(1).ifThenElse(screenX.div(slope), screenX), | |
slope.gt(1).ifThenElse(screenY, screenY.mul(slope)), 0); | |
this.glint.transform.position = tipPosition; | |
this.glintPivot.transform.rotationZ = this.getZEulerRotation(tipPosition).neg(); | |
const glintVisibility = this.targetTracker.outOfViewTrackingActive.and(this.targetInView.not()).and(this.isInEditor().not()); | |
this.glint.hidden = glintVisibility.not(); | |
} | |
/** Setup tracker instruction to guide the user to initialize SLAM. SLAM is required for successful fixed target tracking experience because the tracker need to understand the world position of the target to establish stable tracking. */ | |
setupTrackerInstruction() { | |
const snapshot = { isFixedTarget: this.isFixedTarget }; | |
type Pairwise = { | |
newValue: boolean; | |
oldValue: boolean; | |
}; | |
this.targetTracker.outOfViewTrackingActive.monitor({ fireOnInitialValue: true }).subscribeWithSnapshot(snapshot, (v: Pairwise, s: typeof snapshot) => { | |
if (s.isFixedTarget) { | |
Patches.inputs.setBoolean('trackerInstructionHidden', v.newValue); | |
} else { | |
Patches.inputs.setBoolean('trackerInstructionHidden', true); | |
} | |
}); | |
} | |
/** Helper function to get the Z euler rotation value from a direction vector */ | |
getZEulerRotation(vector3: PointSignal) { | |
const normalizedVector = vector3.normalize(); | |
return vector3.magnitude().eq(0).ifThenElse(0, Reactive.atan2(normalizedVector.x, normalizedVector.y)); | |
} | |
/** Clear timeout if user make another touch gesture to prevent timed animations to happen */ | |
clearIconMaximizationTimeout() { | |
if (this.iconMaximizationTimeout != null) { | |
Time.clearTimeout(this.iconMaximizationTimeout); | |
} | |
} | |
/** Helper method to project a position in camera space to focal plane space. */ | |
projectToFocalPlane(positionInCameraSpace: PointSignal) { | |
const focalDistanceOverZ = this.camera.focalPlane.distance.div(positionInCameraSpace.z.abs()); | |
return Reactive.point( | |
positionInCameraSpace.x.mul(focalDistanceOverZ), | |
positionInCameraSpace.y.mul(focalDistanceOverZ), | |
this.camera.focalPlane.distance); | |
} | |
/** Helper method to project an estimated size to focal plane space. */ | |
projectSizeOnFocalPlane(positionInCameraSpace: PointSignal, sizeInCameraSpace: PointSignal) { | |
const focalDistanceOverZ = this.camera.focalPlane.distance.div(positionInCameraSpace.z.abs()); | |
return sizeInCameraSpace.mul(focalDistanceOverZ); | |
} | |
/** Helper method to estimate if the target is in camera's view */ | |
isTargetInView(positionInCameraSpace: PointSignal, sizeInCameraSize: PointSignal) { | |
const position = this.projectToFocalPlane(positionInCameraSpace); | |
const size = this.projectSizeOnFocalPlane(positionInCameraSpace, sizeInCameraSize); | |
const xEdge = this.camera.focalPlane.width.add(size).div(2); | |
const yEdge = this.camera.focalPlane.height.add(size).div(2); | |
const isInside = (position.x.lt(xEdge)).and(position.x.gt(xEdge.neg())) | |
.and(position.y.lt(yEdge).and(position.y.gt(yEdge.neg()))) | |
.and(positionInCameraSpace.z.lt(0)); | |
return isInside; | |
} | |
/** Helper method to determine if the script is running in editor. This is based on the behavior that target tracker and camera is in the same world space position while running in editor. */ | |
isInEditor() { | |
const trackerPosition = this.targetTracker.worldTransform.position; | |
const cameraPosition = this.camera.worldTransform.position; | |
const isXEqual = trackerPosition.x.eq(cameraPosition.x); | |
const isYEqual = trackerPosition.y.eq(cameraPosition.y); | |
const isZEqual = trackerPosition.z.eq(cameraPosition.z); | |
return isXEqual.and(isYEqual).and(isZEqual); | |
} | |
/** Output signals for visual and interaction logic done by Patches */ | |
outputToPatch() { | |
Patches.inputs.setScalar('aspectRatio', this.aspectRatio); | |
Patches.inputs.setBoolean('isInEditor', this.isInEditor()); | |
} | |
} | |
export default new TargetTrackerDemo().initialize(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment