Updated "Starter Kit: Model Viewer" camera.js to Hammer 2.0

So, we’re working with the aforementioned starter kit as we plan to use PlayCanvas mostly for showcasing clients’ products. Unfortunately it uses hammer 1.1 in camera.js which has some problems (ie. tap doesn’t work reliably).

So we updated it to hammer 2.0.

Important:

  • Update the hammer.min.js file with the latest one from here
  • We don’t use panning with two fingers and we disabled it, if needed I might find the time to add it
  • Of course I’m not affiliated with PlayCanvas in anyway so use it as your own risk…
  • …but feel free to contribute :smile:

G.

the CODE:

var Camera = pc.createScript('camera');

Camera.attributes.add('maxElevation', {
    type: 'number',
    title: 'Max Elevation',
    default: 70
});

// initialize code called once per entity
Camera.prototype.initialize = function() {
    this.viewPos = new pc.Vec3();
    this.targetViewPos = new pc.Vec3();
    this.tempVec = new pc.Vec3();

    this.distance = 3;
    this.targetDistance = 3;

    this.rotX = -180;
    this.rotY = 0;
    this.targetRotX = -40;
    this.targetRotY = 30;
    this.quatX = new pc.Quat();
    this.quatY = new pc.Quat();

    this.transformStarted = false;

    // Disabling the context menu stops the browser disabling a menu when 
    // you right-click the page
    this.app.mouse.disableContextMenu();
    
    this.setBestCameraPositionForModel();

    ////////////////////
    // Touch controls //
    ////////////////////
    ////////////////////
    // Hammer 2.0     //
    ////////////////////
    this.hammer = Hammer(this.app.graphicsDevice.canvas);
    this.hammer.get('pan').set({ direction: Hammer.DIRECTION_ALL });
    // Orbit (1 finger)
    var cachedX, cachedY;
    this.hammer.on("panstart", function (event) {
        if (!this.transformStarted) {
            this.dragStarted = true;

            cachedX = event.center.x;
            cachedY = event.center.y;
        }
    }.bind(this));
    this.hammer.on("panend", function (event) {
        if (this.dragStarted) {
            this.dragStarted = false;
            this.panning = false;
        }
    }.bind(this));
    this.hammer.on("panmove", function (event) {
        var dx = event.center.x - cachedX;
        var dy = event.center.y - cachedY;

        this.orbit(dx * 0.5, dy * 0.5);
        
        cachedX = event.center.x;
        cachedY = event.center.y;
    }.bind(this));
    
    // Zoom
    this.hammer.add(new Hammer.Pinch());
    this.hammer.get('pinch').set({ enable: true });
    var cachedTargetDistance;
    this.hammer.on("pinchstart", function (event) {
        this.transformStarted = true;
        cachedTargetDistance = this.targetDistance;

        event.preventDefault();
        this.hammer.options.drag = false;
    }.bind(this));
    this.hammer.on("pinchend", function (event) {
        this.transformStarted = false;
        this.hammer.options.drag = true;
    }.bind(this));
    this.hammer.on("pinchmove", function (event) {
        if (this.transformStarted) {
            this.targetDistance = cachedTargetDistance / event.scale;
        }
    }.bind(this));
    
    
    this.app.mouse.on(pc.EVENT_MOUSEMOVE, this.onMouseMove, this);
    this.app.mouse.on(pc.EVENT_MOUSEWHEEL, this.onMouseWheel, this);
};

Camera.prototype.setBestCameraPositionForModel = function() {
    var i, j;

    // Position the camera somewhere sensible
    var models = this.app.scene.getModels();

    var isUnderCamera = function (mi) {
        var parent = mi.node.getParent();

        while (parent) {
            if (parent.camera) {
                return true;
            }
            parent = parent.getParent();
        }

        return false;
    };

    var meshInstances = [];
    for (i = 0; i < models.length; i++) {
        var mi = models[i].meshInstances;
        for (j = 0; j < mi.length; j++) {
            if (!isUnderCamera(mi[j])) {
                meshInstances.push(mi[j]);
            }
        }
    }

    if (meshInstances.length > 0) {
        var aabb = new pc.shape.Aabb();
        aabb.copy(meshInstances[0].aabb);
        for (i = 0; i < meshInstances.length; i++) {
            aabb.add(meshInstances[i].aabb);
        }

        var focus = aabb.center;
        var halfHeight = aabb.halfExtents.y;
        var halfDepth = aabb.halfExtents.z;
        var offset = 1.5 * halfHeight / Math.tan(0.5 * this.entity.camera.fov * Math.PI / 180.0);
        this.reset(focus, offset + halfDepth);
    } else {
        this.reset(pc.Vec3.ZERO, 3);
    }
};

Camera.prototype.reset = function(target, distance) {
    this.viewPos.copy(target);
    this.targetViewPos.copy(target);

    this.distance = distance;
    this.targetDistance = distance;

    this.rotX = -180;
    this.rotY = 0;
    this.targetRotX = -40;
    this.targetRotY = 30;
};

Camera.prototype.dolly = function (movez) {
    // Dolly along the Z axis of the camera's local transform
    this.targetDistance += movez;
    if (this.targetDistance < 0) {
        this.targetDistance = 0;
    }
};

Camera.prototype.orbit = function (movex, movey) {
    this.targetRotX += movex;
    this.targetRotY += movey;
    this.targetRotY = pc.math.clamp(this.targetRotY, -this.maxElevation, this.maxElevation);
};

Camera.prototype.onMouseWheel = function (event) {
    event.event.preventDefault();
    this.dolly(event.wheel * -0.25);
};

Camera.prototype.onMouseMove = function (event) {
    if (event.buttons[pc.MOUSEBUTTON_LEFT]) {
        this.orbit(event.dx * 0.2, event.dy * 0.2);
    }
};

// update code called every frame
Camera.prototype.update = function(dt) {
    if (this.app.keyboard.wasPressed(pc.KEY_SPACE)) {
        this.setBestCameraPositionForModel();
    }

    // Implement a delay in camera controls by lerping towards a target
    this.viewPos.lerp(this.viewPos, this.targetViewPos, dt / 0.1);
    this.distance = pc.math.lerp(this.distance, this.targetDistance, dt / 0.2);
    this.rotX = pc.math.lerp(this.rotX, this.targetRotX, dt / 0.2);
    this.rotY = pc.math.lerp(this.rotY, this.targetRotY, dt / 0.2);

    // Calculate the camera's rotation
    this.quatX.setFromAxisAngle(pc.Vec3.RIGHT, -this.rotY);
    this.quatY.setFromAxisAngle(pc.Vec3.UP, -this.rotX);
    this.quatY.mul(this.quatX);

    // Set the camera's current position and orientation
    this.entity.setPosition(this.viewPos);
    this.entity.setRotation(this.quatY);
    this.entity.translateLocal(0, 0, this.distance);
};
1 Like

Hi. Great, thanks for this!

I’m also building an app based on the model viewver along with some feature hotspots. However, there are som things I’d like to confirm which I don’t understand:

  1. I was a bit surprised that touch screens are not supported right of the bat, without including some third party js (hammer). Is that right, or did I misunderstand it?

  2. When I used your code, and try my model viewer on an iphone, I can feel a slight delay to my movements when rotating. At least compared to using the mouse on desktop. What could this be?

  3. In the code, I wanted to restrict zooming to occur only within a defined span, so I added this piece of code similar to what was allready present when it comes to orbiting. However, when on the iphone I can zoom to infinity, so it’s not respected. The orbit maxElevation stuff does work though:

     Camera.prototype.dolly = function (movez) {
         // Dolly along the Z axis of the camera's local transform
         //console.log(this.distance);
         this.targetDistance += movez;
         this.targetDistance = pc.math.clamp(this.targetDistance, this.minDistance, this.maxDistance);
         if (this.targetDistance < 0) {
             this.targetDistance = 0;
         }
     };
    
     Camera.prototype.orbit = function (movex, movey) {
         this.targetRotX += movex;
         this.targetRotY += movey;
         this.targetRotY = pc.math.clamp(this.targetRotY, this.minElevation, this.maxElevation);
     };
    
  4. I also have raycast logic to listen to mouse clicks and execute code if one of my hotspots are clicked, but it doesn’t work on the iPhone with the tap. Perhaps I should listen to something else than a mouse click when on smartphone?

Camera.prototype.onSelect = function (event) {
     var from = this.entity.camera.screenToWorld(event.x, event.y, this.entity.camera.nearClip);
     var to = this.entity.camera.screenToWorld(event.x, event.y, this.entity.camera.farClip);
     var result =  this.app.systems.rigidbody.raycastFirst(from, to);
     if (result !== null) {
          result.entity.script.hotspot.hit();
     }    
};

Anyone have something clever to say about this? Is hammer required to get touchscreen/mobile functionality?

Disclaimer: I’m in no way associated with PlayCanvas team. Just to clarify :slight_smile:

  1. From my experience Hammer was required to get such functionalities. I also see no problem in this as I’m against reinventing the wheel and I found that one of the playcanvas benefits I enjoy the most is the ability to integrate third party stable libraries easily.

  2. Will check. I didn’t notice it.

  3. This is what I used and it’s working for me on iOS phones:

Camera.attributes.add('minDistance', {
    type: 'number',
    title: 'Min Distance',
    default: 2
});
[...]
Camera.prototype.dolly = function (movez) {
    // Dolly along the Z axis of the camera's local transform
    this.targetDistance += movez;
    if (this.targetDistance < this.minDistance) {
        this.targetDistance = this.minDistance;
    }
};

I have no restriction on maxDistance though.

  1. I can confirm the problem on iOS devices. Still working on it. At the moment we’re still prototyping the UX for the client and stick with android devices and desktop version for the time being. This project doesn’t require to be available on iOS devices too but we will have to look into this issue in the near future.

Just to weigh in on point 1.

Hammer is not required as such. PlayCanvas is built on existing browser APIs and we have pc.TouchDevice (usually available as this.app.touch) which you can use to respond to touch events.

However, Hammer as a library has a well tested implementation of common touch inputs such as pinch zoom, etc and we found it straightforward to use it for the model viewer input instead of implementing our own solution.

1 Like

Thanks a lot for your answer! I tried to implement your zoom restriction code and added a maxDistance aswell, like so:

Camera.prototype.dolly = function (movez) {
    // Dolly along the Z axis of the camera's local transform
    this.targetDistance += movez;
    if (this.targetDistance < this.minDistance) {
        this.targetDistance = this.minDistance;
    } else if (this.targetDistance > this.maxDistance) {
        this.targetDistance = this.maxDistance;
   
    }
};

But it doesn not seem to work for me when I try it on my iPhone 6. You can try it here if you wish:

https://playcanv.as/p/7Zu0a7iJ.

for your information that demo project works on the iPhone 7. Probably the issue is something about the webkit version or iOS version.

Oh wow how very strange. I’ve tried both chrome and safari on my iPhone 6 (latest iOS though) and I can zoom as far out and in as I want.

Here’s the project btw. https://playcanvas.com/project/436511/overview/zoom-test

I see, thanks for letting us know. Do you have any other examples that work good on the iPhone? Since there seems to be some problem with the hammer, at least for me.

hey, do you still have the project so I can try it on different iPhones ? :slight_smile:

It’s worth noting that the Start Kit: Model Viewer no longer uses hammer.js https://playcanvas.com/project/446385/overview/starter-kit-model-viewer

thanks!
at the moment I do the swipe detection manualy, but than I figured I also need the velocity and some other stuff, so It’s a bit stupid to Invent the wheel allover again,

what would you suggest to use? (hammer/ something else?..)
:slight_smile:

For the Model Viewer, I just used the displacement from the last frame of touches and rotate/pan/zoom by that amount.

Are you looking to detect gestures like swiping left and right?

yep, as I wrote at the moment I am doing it with my own script but maybe that is a bit silly…
so mainly swipe left right and the velocity/speed of the swipe…

If it’s only left/right gesture recognition, I be tempted to do it myself TBH as it isn’t too complicated.

hey, yep I did it myself in the end…
but than I had to add UP option so everything turned a little more complicated, and I was wondering what I should do in future projects…
(eventually I came up with something that resemble a phone menu navigation, it follows your finger while swiping and than continue when you leave it)
the problem was with the swipe up: blocking x movement while y movement and vise versa …