Question

I'm rendering a wheel in a WebGL canvas using mrdoob's THREE.js.

wheel

I want the wheel to

  1. Spin around it's center
  2. Be draggable by mouse or touch interaction
  3. Slow down by applying fake friction
  4. Snap to the center of a wedge whenever the rotation speed reaches a certain threshold.

You may think of the behaviour of the wheel as that of a lottery wheel.

So far I have achieved points 1-3. This is my code:

'use strict';

var WIDTH = 1080,
    HEIGHT = 1080;

var VIEW_ANGLE = 45,
    ASPECT = WIDTH / HEIGHT,
    NEAR = 0.1,
    FAR = 10000;

var camera = new THREE.PerspectiveCamera(
    VIEW_ANGLE,
    ASPECT,
    NEAR,
    FAR);

var scene = new THREE.Scene();

scene.add(camera);

camera.position.z = 300;

// Create renderer

var container = document.querySelector('#test');

var renderer = new THREE.WebGLRenderer();
renderer.setSize(WIDTH, HEIGHT);
renderer.setClearColor(0x000000, 0);

container.appendChild(renderer.domElement);

// Create objects

var wheelMaterial = new THREE.MeshBasicMaterial({
    map: THREE.ImageUtils.loadTexture('wheel.png'),
    depthWrite: false,
    alphaTest: 0.5
});

wheelMaterial.overdraw = true;

var wheel = new THREE.Mesh(
    new THREE.PlaneGeometry(240, 240),
    wheelMaterial);

scene.add(wheel);


// Mouse interaction

var isDragging = false;
var lastMouseCoords = null;
var mouseCoords = null;

container.addEventListener('mousedown', onDragStart, false);
container.addEventListener('touchstart', onDragStart, false);

container.addEventListener('mouseup', onDragEnd, false);
container.addEventListener('mouseout', onDragEnd, false);
container.addEventListener('touchend', onDragEnd, false);

container.addEventListener('mousemove', onMouseMove, false);
container.addEventListener('touchmove', onMouseMove, false);


function onDragStart(e) {
    isDragging = true;
    console.log('Dragging', e);
    mouseCoords = pageCoordsToCanvasCoords(e);
    rotationHistory = [];
}


function onDragEnd(e) {
    isDragging = false;
    lastMouseCoords = null;
    mouseCoords = null;
    console.log('Drag end');
}

function onMouseMove(e) {
    e.preventDefault();
    mouseCoords = pageCoordsToCanvasCoords(e);
}

// Utility functions

function pageCoordsToCanvasCoords(e) {
    var canvasX;
    var canvasY;

    if ('touches' in e && e.touches.length > 0) {
        canvasX = e.touches[0].pageX;
        canvasY = e.touches[0].pageY;
    } else {
        canvasX = e.pageX
        canvasY = e.pageY
    }

    canvasX -= e.target.offsetLeft;
    canvasY -= e.target.offsetTop;

    console.log(canvasX, canvasY);

    return {
        x: canvasX,
        y: canvasY
    };
}

function mouseCoordsToRotation(x, y) {
    var origoX = WIDTH / 2.0,
        origoY = HEIGHT / 2.0;

    x = x - origoX;
    y = y - origoY;

    var atan = Math.atan2(x, y);
    return atan;
}

function getMeanVelocity(history) {
    if (history.length <= 1) {
        return 0;
    }

    var movements = [];
    var startTime = history[0].time;
    var totalTimeElapsed = 0;

    // Start from the second item in deltaRadians
    for (var i = 1; i < history.length; i++) {
        var item = history[i];

        var movement = item.deltaRad;
        movements.push(item.deltaRad);

        var movementTimeDelta = item.time - startTime - totalTimeElapsed;

        if (movementTimeDelta < 0) {
            console.error('movementTimeDelta for history entry #' +
                i + ' has travelled back in time somehow.');
        }

        totalTimeElapsed += movementTimeDelta;
    }

    var sum = movements.reduce(function (a, b) {
        return a + b;
    });

    return sum / totalTimeElapsed;
}

function applyFakeFriction(velocity, time) {
    /*
    var currentRotation = wheel.rotation.z;

    var nearestBorder = 0;
    var nearestBorderDistance = 100;

    for (var i = 0; i < PARTITIONS; i++) {
        var partition =  PARTITION_ARC * i - PARTITION_ARC * PARTITIONS / 2;
        var distance = currentRotation - partition;

        if (distance < 0) {
            distance /= -1;
        }

        if (distance < nearestBorderDistance) {
            console.log('distance is less than nearestBorderDistance')
            nearestBorder = partition;
            nearestBorderDistance = distance;
            if (nearestBorderDistance < 0) {
                nearestBorderDistance /= -1;
            }
        }
    }


    console.log('nearestBorderDistance: ', nearestBorderDistance);
    */

    for (var i = 0; i < time; i++) {
        velocity -= WHEEL_FRICTION; // * (nearestBorderDistance * BORDER_FRICTION);
    }
    return velocity;
}

var rotation = 1;

function snap() {
    isSnapping = true;

    /* Disabled, this the issue I'm asking about in the post
    var update = function () {
        cube.position.rotation = current.rotation;
    }
    var current = {
        rotation: rotation
    };

    TWEEN.removeAll();

    var easing = TWEEN.Easing['Elastic']['EaseInOut'];

    var tweenHead = neww TWEEN.Tween(current)
        .to({rotation: rotation})
        .easing(easing)
        .onUpdate(update);

    tweenHead.start();
    */ 
}

var rotationHistory = []
var ROTATION_HISTORY_MAX_LENGTH = 5;


var WHEEL_FRICTION = 0.000001;
var BORDER_FRICTION = 2;

var PARTITIONS = 12;
var PARTITION_ARC = 1 * Math.PI / (PARTITIONS / 2); // The width of each section

var wheelVelocity = 0.1;
var wheelSlowDownVelocity = 0;
var lastWheelRotationTime;

var isSnapping = false;

// Render

function  tick() {
    // Rotate wheel
    var currentTime = (new Date).getTime();

    if (lastMouseCoords && isDragging) {
        // Reset the velocity for the slowdown
        wheelSlowDownVelocity = 0;

        // Get the delta rotation since last mouse coordinates
        var deltaRadians = mouseCoordsToRotation(mouseCoords.x, mouseCoords.y)
            - mouseCoordsToRotation(lastMouseCoords.x, lastMouseCoords.y);

        // Set the wheel rotation
        wheel.rotation.z += deltaRadians;

        // Save the rotation in the history and remove any excessive elements
        rotationHistory.push({
            time: currentTime,
            deltaRad: deltaRadians
        });

        while (rotationHistory.length > ROTATION_HISTORY_MAX_LENGTH) {
            rotationHistory.shift();
        }
    }

    if (isDragging) {
        lastMouseCoords = mouseCoords;
    }

    // Continue rotation of the released wheel
    if (!isDragging && !lastMouseCoords && lastWheelRotationTime) {
        var delta = currentTime - lastWheelRotationTime;

        if (wheelSlowDownVelocity == 0) {
            var meanVelocityOverTime = getMeanVelocity(rotationHistory);

            wheelSlowDownVelocity = meanVelocityOverTime;
        } else {
            var currentIsNegative = wheelSlowDownVelocity < 0 ? true : false;

            var currentVelocity = wheelSlowDownVelocity;

            if (currentIsNegative) {
                currentVelocity /= -1;
            }

            console.log('Current velocity: ', currentVelocity);

            console.log('delta: ', delta);

            var newVelocity = applyFakeFriction(currentVelocity,
                                                delta);

            console.log('New velocity: ', newVelocity);

            if (newVelocity < 0) {
                wheelSlowDownVelocity = 0;
                rotationHistory = [];
            } else {
                if (currentIsNegative) {
                    // Revert to old polarity
                    newVelocity /= -1;
                }
                wheelSlowDownVelocity = newVelocity;
            }
        }

        wheel.rotation.z += wheelSlowDownVelocity * delta;
    }

    while (wheel.rotation.z > 2 * Math.PI) {
        console.log('Correcting rotation: ', wheel.rotation.z);
        wheel.rotation.z -= 2 * Math.PI;
    }

    while (wheel.rotation.z < - (2 * Math.PI)) {
        console.log('Correcting rotation: ', wheel.rotation.z);
        wheel.rotation.z += 2 * Math.PI;
    }


    // Update the history record
    lastWheelRotationTime = currentTime;

    // Render scene and attach render callback to next animation frame.
    renderer.render(scene, camera);
    window.requestAnimationFrame(tick);
}

tick();

I have the complete code, minus wheel.png over at https://gist.github.com/joar/5747498.

I have been searching for examples of this behaviour but this far I haven't found any.


Note to editors. Please do not change the tags of this post. tween.js != TweenJS.

Was it helpful?

Solution

I have solved the issue.

'use strict';
function Wheel (element, options) {
    var self = this;

    // Variable initialization
    var WIDTH = options.width;
    var HEIGHT = options.height;

    if (!options.image) {
        throw new Error('Image argument missing');
    }

    var image = options.image;

    var showStats = options.showStats || options.stats;

    // Core variables
    var stats;
    var wheel;
    var domElement;
    var scene;
    var camera;
    var renderer;
    var rotationHistory;
    var input;
    var animate;
    var run = false;
    var ROTATION_HISTORY_MAX_LENGTH = 5;

    switch (typeof element) {
        case 'string':
            domElement = document.querySelector(element);
            break;
        default:
            if ('className' in element) {
                domElement = element;
            } else {
                throw new Error('Invalid element: ', element);
            }
    }

    if (typeof element == 'undefined') {
        throw new Error('Invalid element.')
    }

    /* Initializes the WebGL canvas with the wheel plane */
    function setupScene() {
        var VIEW_ANGLE = 45,
            ASPECT = WIDTH / HEIGHT,
            NEAR = 0.1,
            FAR = 10000;

        camera = new THREE.PerspectiveCamera(
            VIEW_ANGLE,
            ASPECT,
            NEAR,
            FAR);

        scene = new THREE.Scene();

        scene.add(camera);

        camera.position.z = 300;

        // Create renderer

        var container = domElement;

        renderer = new THREE.WebGLRenderer();
        renderer.setSize(WIDTH * 2, HEIGHT * 2);
        renderer.setClearColor(0x000000, 0);

        // Create objects

        var wheelMaterial = new THREE.MeshBasicMaterial({
            map: THREE.ImageUtils.loadTexture(image),
            depthWrite: false,
            alphaTest: 0.5
        });

        wheelMaterial.overdraw = true;

        wheel = new THREE.Mesh(
            new THREE.PlaneGeometry(245, 245),
            wheelMaterial);

        scene.add(wheel);

        container.appendChild(renderer.domElement);
    }

    function setupStats() {
        // Init stats
        stats = new Stats();
        stats.domElement.style.position = 'absolute';
        stats.domElement.style.top = '0px';
        document.body.appendChild(stats.domElement);
    }

    function setup() {
        setupScene();
        if (showStats) {
            setupStats();
        }
    }

    setup();

    // The tick function
    function update() {

        animate.update(); // Process interactions

        self.renderer.render(self.scene, self.camera);

        if (showStats) {
            self.stats.update();
        }
        if (run) {
            window.requestAnimationFrame(update);
        }
    }

    animate = new Animate();

    // Start and run the wheel every animationframe
    function start() {
        self.input = input = new Input();  // Start capturing input
        run = true;
        update();
    }

    /**
     * Animate the wheel
     */
    function Animate() {
        var self = this;

        self.velocity = 0;

        var velocityPositive = 0;

        self.friction = 0.001;

        self.snapThreshold = 0.03;

        self.isSnapping = false;

        var lastAnimationTime;

        self.tween;

        var rotationHistory = [];

        var PARTITIONS = 12;
        var PARTITION_ARC = 1 * Math.PI / (PARTITIONS / 2); // The width of each section

        function update() {
            var currentTime = (new Date).getTime();

            velocityPositive = self.velocity;

            if (velocityPositive < 0) {
                velocityPositive /= -1;
            }

            if (!self.isSnapping
                && !input.isDragging
                && velocityPositive < self.snapThreshold
                && velocityPositive > 0
                && lastAnimationTime) {
                rotationHistory = [];
                snap();
            }

            if (input.isDragging) {
                self.isSnapping = false;
                TWEEN.removeAll();
            }

            if (!self.isSnapping) {
                /**
                 * If the mouse is dragging the wheel
                 */
                if (input.lastMouseCoords && input.isDragging && !input.isSnapping) {
                    // Reset the velocity for the slowdown
                    self.velocity = 0;

                    // Get the delta rotation since last mouse coordinates
                    var deltaRadians = input.mouseCoordsToRadian(
                        input.mouseCoords.x, input.mouseCoords.y)
                        - input.mouseCoordsToRadian(
                            input.lastMouseCoords.x,
                            input.lastMouseCoords.y);

                    // Set the wheel rotation
                    wheel.rotation.z += deltaRadians;

                    // Save the rotation in the history and remove any excessive elements
                    rotationHistory.push({
                        time: currentTime,
                        deltaRad: deltaRadians
                    });

                    while (rotationHistory.length > ROTATION_HISTORY_MAX_LENGTH) {
                        rotationHistory.shift();
                    }
                }

                if (input.isDragging) {
                    input.lastMouseCoords = input.mouseCoords;
                }
                // Continue rotation of the released wheel
                if (!input.isDragging
                    && !input.lastMouseCoords
                    && lastAnimationTime
                    && !self.isSnapping) {

                    var delta = currentTime - lastAnimationTime;

                    if (self.velocity == 0) {
                        var meanVelocityOverTime = getMeanVelocity(rotationHistory);

                        self.velocity = meanVelocityOverTime;
                    } else if (!self.isSnapping && !self.isDragging) {
                        var currentIsNegative = self.velocity < 0 ? true : false;

                        var currentVelocity = self.velocity;

                        if (currentIsNegative) {
                            currentVelocity /= -1;
                        }

                        var newVelocity = applyFakeFriction(currentVelocity,
                                                            delta);

                        if (newVelocity < 0) {
                            self.velocity = 0;
                            rotationHistory = [];
                        } else {
                            if (currentIsNegative) {
                                // Revert to old polarity
                                newVelocity /= -1;
                            }
                            self.velocity = newVelocity;
                        }
                    }

                    wheel.rotation.z += self.velocity * delta;

                }

                if (!self.isSnapping) {
                    while (wheel.rotation.z > 2 * Math.PI) {
                        wheel.rotation.z -= 2 * Math.PI;
                    }

                    while (wheel.rotation.z < - (2 * Math.PI)) {
                        wheel.rotation.z += 2 * Math.PI;
                    }
                }
            }

            // Update snap tween
            TWEEN.update();

            // Update the history record
            lastAnimationTime = currentTime;
        }


        function applyFakeFriction(velocity, time) {
            /*
            var currentRotation = wheel.rotation.z;

            var nearestBorder = 0;
            var nearestBorderDistance = 100;

            for (var i = 0; i < PARTITIONS; i++) {
                var partition =  PARTITION_ARC * i - PARTITION_ARC * PARTITIONS / 2;
                var distance = currentRotation - partition;

                if (distance < 0) {
                    distance /= -1;
                }

                if (distance < nearestBorderDistance) {
                    console.log('distance is less than nearestBorderDistance')
                    nearestBorder = partition;
                    nearestBorderDistance = distance;
                    if (nearestBorderDistance < 0) {
                        nearestBorderDistance /= -1;
                    }
                }
            }


            console.log('nearestBorderDistance: ', nearestBorderDistance);
            */

            for (var i = 0; i < time; i++) {
                velocity -= self.friction; // * (10000 * wheelSlowDownVelocityPositive); // * (nearestBorderDistance * BORDER_FRICTION);
            }
            return velocity;
        }


        function getNearestWedge() {
            var currentRotation = wheel.rotation.z;

            var nearestBorder = 0;
            var nearestBorderDistance = 100;

            for (var i = 0; i < PARTITIONS; i++) {
                var partition =  PARTITION_ARC * i - PARTITION_ARC * PARTITIONS / 2;
                var distance = currentRotation - partition;

                if (distance < 0) {
                    distance /= -1;
                }

                if (distance < nearestBorderDistance) {
                    console.log('distance is less than nearestBorderDistance')
                    nearestBorder = partition;
                    nearestBorderDistance = distance;
                    if (nearestBorderDistance < 0) {
                        nearestBorderDistance /= -1;
                    }
                }
            }

            return {
                position: nearestBorder,
                distance: nearestBorderDistance
            };
        }

        function snap() {
            console.log('Snapping');
            if (self.isSnapping) {
                console.error('Already snapping, aborting.');
                return;
            }

            self.isSnapping = true;
            self.velocity = 0;
            var nearest = getNearestWedge();

            TWEEN.removeAll();

            console.log('nearest: ', nearest.position, nearest.distance)

            self.tween = new TWEEN.Tween({r: wheel.rotation.z})
                .to({r: nearest.position})
                .easing(TWEEN.Easing.Elastic.Out)
                .onUpdate(onUpdate)
                .onComplete(onComplete)
                .start();

            function onUpdate() {
                //console.log('current: ', this.r, self.velocity);
                wheel.rotation.z = this.r;
            };

            function onComplete() {
                self.isSnapping = false;
                console.log('Not snapping');;
            }

        }

        function getMeanVelocity(history) {
            if (history.length <= 1) {
                return 0;
            }

            var movements = [];
            var startTime = history[0].time;
            var totalTimeElapsed = 0;

            // Start from the second item in deltaRadians
            for (var i = 1; i < history.length; i++) {
                var item = history[i];

                var movement = item.deltaRad;
                movements.push(item.deltaRad);

                var movementTimeDelta = item.time - startTime - totalTimeElapsed;

                if (movementTimeDelta < 0) {
                    console.error('movementTimeDelta for history entry #' +
                        i + ' has travelled back in time somehow.');
                }

                totalTimeElapsed += movementTimeDelta;
            }

            var sum = movements.reduce(function (a, b) {
                return a + b;
            });

            return sum / totalTimeElapsed;
        }

        // Internal utilities
        function log() {
            if (console && _log) {
                var args = Array.prototype.slice.call(arguments, 0);
                args.unshift('Animate: ')
                console.log.apply(console, args);
            }
        }

        // exports
        this.update = update;
        this.rotationHistory = rotationHistory;
        this.PARTITIONS = PARTITIONS;
        this.PARTITION_ARC = PARTITION_ARC;
        this.snap = snap;

        return this;
    }


    /**
     * Handles input to the wheel.
     */
    function Input() {
        var self = this;
        var _log = true;

        domElement.addEventListener('mousedown', onDragStart, false);
        //domElement.addEventListener('touchstart', onDragStart, false);

        domElement.addEventListener('mouseup', onDragEnd, false);
        domElement.addEventListener('mouseout', onDragEnd, false);
        //domElement.addEventListener('touchend', onDragEnd, false);

        domElement.addEventListener('mousemove', onMouseMove, false);
        //domElement.addEventListener('touchmove', onMouseMove, false);

        function onDragStart(e) {
            self.isDragging = true;
            log('Drag start');
            self.mouseCoords = pageCoordsToCanvasCoords(e);
            animate.rotationHistory = [];
        }

        function onDragEnd(e) {
            self.isDragging = false;
            self.lastMouseCoords = null;
            self.mouseCoords = null;
            log('Drag end');
        }

        function onMouseMove(e) {
            e.preventDefault();
            self.mouseCoords = pageCoordsToCanvasCoords(e);
        }

        function pageCoordsToCanvasCoords(e) {
            var canvasX, canvasY;

            if ('touches' in e && e.touches.length > 0) {
                canvasX = e.touches[0].pageX;
                canvasY = e.touches[0].pageY;
            } else {
                canvasX = e.pageX
                canvasY = e.pageY
            }

            canvasX -= e.target.offsetLeft;
            canvasY -= e.target.offsetTop;

            // console.log(canvasX, canvasY);

            return {
                x: canvasX,
                y: canvasY
            };
        }

        function mouseCoordsToRadian(x, y) {
            var origoX = WIDTH / 2.0,
                origoY = HEIGHT / 2.0;

            x = x - origoX;
            y = y - origoY;

            var atan = Math.atan2(x, y);
            return atan;
        }

        // exports
        this.mouseCoordsToRadian = mouseCoordsToRadian;

        function log() {
            if (console && _log) {
                var args = Array.prototype.slice.call(arguments, 0);
                args.unshift('Input: ')
                console.log.apply(console, args);
            }
        }
        return this;
    }

    // Internal utils
    function log() {
        if (console && _log) {
            var args = Array.prototype.slice.call(arguments, 0);
            args.unshift('Wheel: ')
            console.log.apply(console, args);
        }
    }

    // exports
    self.start = start;
    self.update = update;
    self.scene = scene;
    self.camera = camera;
    self.wheel = wheel;
    self.renderer = renderer;
    self.stats = stats;
    self.domElement = domElement;
    self.input = input;
    self.animate = animate;

    return self;
}
Licensed under: CC-BY-SA with attribution
Not affiliated with StackOverflow
scroll top