Snapchat Crossbow Filter

Aim your phone, pull back the arrow and release to shoot at the bullseye board in AR!

Done in 2018 over a few weeks to test the limits of Lens Studio. Both Lens Studio features and custom script components were utilized.

Process
Concept -> Define Key Components -> Create Assets & Effects -> Prototype

crossbow1 (2).jpg

Custom Script Components

Input Handling: Elastic Firing

The user pulls the arrow back with his finger, and fires it on release. The arrow is subjected to an elastic force mimicking the bowstring, hence the arrow pullback distance should decrease logarithmically as the user's finger pull distance increases. If the pullback is minimal, the arrow should reset on release, instead of firing.

The finger motion is a recreation of the actual bow arrow firing motion. Recreating a realistic motion is important in keeping the suspense of disbelief in AR space.

//arrow pullback is subjected to a logarithmic increase
function moveTouch() {
    var curTouchPos = touchMoveEvent.getTouchPosition();
    totalTouch -= (prevTouchPos.y - curTouchPos.y)*100;
    outForce = 2*Math.log(2*totalTouch); //logarithmic mapping
    var newPos = originPos.add(forward.uniformScale(-outForce));
    transform.setLocalPosition(newPos);
}
//on tap release, fire arrow
function endTouch() {
    if (shooting) return;
    if (outForce<2) { //if below min pull
        endShoot(); //reset arrow
    } else { //else, fire arrow
        //change parent to world
        newPosW = transform.getWorldPosition();
        newRotW = transform.getWorldRotation();
        script.getSceneObject().setParent(null);
        transform.setWorldPosition(newPosW);
        transform.setWorldRotation(newRotW);
        //set values for shootingFunction()
        forwardW = transform.back.normalize();
        //set values for arrowLeaveBowCheck()
        frontPosW = script.front.getTransform().getWorldPosition();
        endPosW = script.end.getTransform().getWorldPosition();
        //shoot
        shooting = true;
        script.detect.api.UpdateShooting(true); //start checking for collision
    }
}

Parabolic Motion & Rotation

To achieve a realistic motion, there are 2 cases to consider. First, when the arrow is still on the bow, there should be no gravity, so the arrow doesn't phase through the bow. Second, after the arrow is off the bow, it should be subjected to gravity to achieve a parabolic motion.

When the arrow is still on the bow, the next position is calculated with s=vt using instantaneous time, no change in rotation, and the start position is updated. When the arrow is off the bow, the next position is calculated with s=vt+0.5at^2 using total elapsed time from since the arrow left the bow.

The rotation is achieved with position over time as a quadratic graph y=x^2+2x, where the highest height (zero angle) is half the complete time, and current angle is derived from current total elapsed time. The angle is clamped at 90 degrees (pointing down).

function shootingFunction() {
    if (shooting) {
        //if arrow hit ground, end shoot
        if(script.front.getTransform().getWorldPosition().y <= 0 ){
            script.text.api.miss(); //show 'miss' effect
            endShoot();
            return;
        }
        var pos = startPosW; //world start position
        var vel = forwardW.uniformScale(outForce*4); //world forward velocity
        var acc = new vec3(0,-1*gravity,0); //world acceleration
        nextTime = new Date().getTime();
        var elapsed = (nextTime-curTime)/1000; //instantaneous elapsed time
        // if arrow still on bow (no gravity, no rotation)
        if (!arrowLeaveBowCheck){
            vt = vel.uniformScale(elapsed); //instantaneous elapsed time
            startPosW = startPosW.add(vt); //update start position
            pos = startPosW;
        }
        //if arrow left bow (calculate gravity, rotation)
        else {
            totalTime += elapsed; //total elapsed time
            //get next position
            var vt = vel.uniformScale(totalTime);
            var at2 = acc.uniformScale(0.5*totalTime*totalTime);
            pos = startPosWorld.add(vt).add(at2);
            //calculate rotation
            var rot = transform.getWorldRotation();
            var halfT = (2*vel.length*Math.sin(launchAngle)/gravity)/2;
            var x = totalTime/halfT;
            var y = -(x*x)+2*x;
            var newAngle = (x<1) ? launchAngle-y*launchAngle : -(launchAngle-y*launchAngle);
            newAngle = (newAngle < -Math.PI/2) ? -Math.PI/2:newAngle; //clamp
            //set angle
            var newRotV = rot.toEulerAngles();
            newRotV.x = newAngle;
            rot = quat.fromEulerVec(newRotV);
        }
        transform.setWorldRotation(rot);
        transform.setWorldPosition(pos);
    }
}

Collision Detection

Initially, I implemented a basic Axis Aligned Bounding Box (AABB) Collider & Detector. However, it cannot handle rotation, as the scale of the cube cannot be updated to the object dimensions in axis space. Instead, I went with a custom Cylinder-Point collision check, with the point as the arrow tip, and the cylinder the target board.

The cylinder collision script handles 2 checks, if the point is within its 2 planes, and if the perpendicular distance of the point from its normal vector is within its radius.

//cylinder-point collision check
var unit_cube = 2; //unit length of default cylinder
var transformShape = script.shape.getTransform();
var thickness = unit_cube * transformShape.getLocalScale().y;
var radius = unit_cube/2 * transformShape.getLocalScale().x; //assume circle (x scale = z scale)
script.api.collisionCheck = function(point){
    var normal = transformShape.up.normalize();
    var origin = transformShape.getWorldPosition().add(normal.uniformScale(thickness/2));
    var dist_pointToPlane = normal.dot(point.sub(origin));
    var projected_point = point.sub(normal.uniformScale(dist_pointToPlane));
    var dist_projectedToOrigin = projected_point.sub(origin).length;
    var collided = (Math.abs(dist_pointToPlane) < thickness && dist_projectedToOrigin <= radius) ? true : false;
    var outRadius = dist_projectedToOrigin/transformShape.getLocalScale().x;
    return [collided,outRadius];
}
Previous
Previous

Taiko Tower

Next
Next

Parking Day