toDataURL() not taking correct screenshots from PlayCanvas on iPad

I’m running into a peculiar issue where the JavaScript toDataURL() functionality isn’t grabbing the expected image data from the PlayCanvas canvas on iPad (and likely other tablets).

http://54.161.163.84/playcanvas-camera/

Above is a project using a basic 3D model with the camera code we’re using in our actual project (will be included at the end of the post).

In our actual application, we first call this code to gain access to PlayCanvas’s camera object:

var app = pc.Application.getApplication('application-canvas');
var context = app.context;
var camera = context.root.findByName('Camera');

Next, we run the following code to reorient the camera back to its default position and then move it to the position we want it to be moved to:

camera.script.camera.initialize();
camera.script.camera.orbit(-220, 20);

In our actual script, we have an Ajax function that takes a screenshot and saves it to a folder, but in this example we’ll have to either log it out to the console or open it in a new window (latter doesn’t seem to work properly in iOS for whatever reason):

(Also, note that I’m using [0] here because our main application uses jQuery. This application is also using jQuery for debugging/testing purposes.)

console.log($("#application-canvas")[0].toDataURL());
window.open($("#application-canvas")[0].toDataURL());

The code appears to work as expected on desktop, but I’m noticing on the iPad where it either works as expected, lags behind for a while until it works a few seconds later, or never works at all (but then I move it a little bit, and it works again — but sometimes it stops working again for no reason).

Any idea what could be causing this problem on the iPad? Is it a resources issue or some setting in PlayCanvas I need to enable/disable? Or a different way to grab the dataURL value that works better in iOS?

[Below is the camera script, in case that helps with debugging. Note that there’s references to hammer.js, but it’s just the basic minified library and isn’t anything customized or special.]

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

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

Camera.attributes.add('minZ', {
    type: 'number',
    title: 'Min Z',
    default: 0.14
});

Camera.attributes.add('maxZ', {
    type: 'number',
    title: 'Max Z',
    default: 0.20
});


// 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 //
    ////////////////////
    var options = {
        prevent_default: true,
        drag_max_touches: 2,
        transform_min_scale: 0.08,
        transform_min_rotation: 180,
        transform_always_block: true,
        hold: false,
        release: false,
        swipe: false,
        tap: false
    };
    this.hammer = Hammer(this.app.graphicsDevice.canvas, options);

    // Pinch zoom
    var cachedTargetDistance;
    this.hammer.on("transformstart", function (event) {
        this.transformStarted = true;
        cachedTargetDistance = this.targetDistance;

        event.preventDefault();
        this.hammer.options.drag = false;
    }.bind(this));
    this.hammer.on("transformend", function (event) {
        this.transformStarted = false;
        this.hammer.options.drag = true;
    }.bind(this));
    this.hammer.on("transform", function (event) {
        if (this.transformStarted) {
            var gesture = event.gesture;
            var scale = gesture.scale;
            this.targetDistance = cachedTargetDistance / scale;
        }
    }.bind(this));

    // Orbit (1 finger) and pan (2 fingers)
    var cachedX, cachedY;
    this.hammer.on("dragstart", function (event) {
        if (!this.transformStarted) {
            var gesture = event.gesture;
            var numTouches = (gesture.touches !== undefined) ? gesture.touches.length : 1;
            this.panning = (numTouches === 2);
            this.dragStarted = true;

            cachedX = gesture.center.pageX;
            cachedY = gesture.center.pageY;
        }
    }.bind(this));
    this.hammer.on("dragend", function (event) {
        if (this.dragStarted) {
            this.dragStarted = false;
            this.panning = false;
        }
    }.bind(this));
    this.hammer.on("drag", function (event) {
        var gesture = event.gesture;
        var dx = gesture.center.pageX - cachedX;
        var dy = gesture.center.pageY - cachedY;
        if (this.panning) {
            this.pan(dx * -0.025, dy * 0.025);
        } else {
            this.orbit(dx * 0.5, dy * 0.5);
        }
        cachedX = gesture.center.pageX;
        cachedY = gesture.center.pageY;
    }.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.pan = function(movex, movey) {
    // Pan around in the camera's local XY plane
    this.tempVec.copy(this.entity.right).scale(movex);
    this.targetViewPos.add(this.tempVec);
    this.tempVec.copy(this.entity.up).scale(movey);
    this.targetViewPos.add(this.tempVec);
};

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

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);
    } else if (event.buttons[pc.MOUSEBUTTON_RIGHT]) {
        var factor = this.distance / 700;
        this.pan(event.dx * -factor, event.dy * factor);
    }
};

// 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);
};

Sorry, could you clarify what is “not working”.

Is it screenshot has not what you expect, or it is not even saving at all? Could you provide 2 screenshots, one from desktop and one from iOS?

Have you looked at this project? http://developer.playcanvas.com/zh/tutorials/capturing-a-screenshot/

1 Like
http://54.161.163.84/playcanvas-camera/examples/correct1.png
http://54.161.163.84/playcanvas-camera/examples/correct2.png
http://54.161.163.84/playcanvas-camera/examples/incorrect_1_1.png
http://54.161.163.84/playcanvas-camera/examples/incorrect_1_2.png
http://54.161.163.84/playcanvas-camera/examples/incorrect_2_1.png
http://54.161.163.84/playcanvas-camera/examples/incorrect_2_2.png

These are from the example project, but the same thing occurs on my test project originally posted as well. So I’m using screenshots from that example project you linked to to explain what the issue is.

As you can see in the first link, if you take a screenshot (the example project does it via a PlayCanvas function, but my code was doing the same toDataURL() method, just outside of PlayCanvas) it works as expected.

The problem occurs when you move the object by touch (or in my app’s example, optionally manually in code via the orbit() function). PlayCanvas will, for a brief moment, reset itself back to what appears to be the last “position” it was prior and takes the screenshot of that instead of what it should be. This can be seen in the second set of images.

I also came across a rare instance of the screenshot returning a solid blue background instead of the image. In my example this happens a lot more often (maybe the more effects enabled, higher chance?), but it’s a lot harder to trigger in that example.

On desktop, it works as intended without any issues for me.

I’ve modified slightly behavior in fork of screenshot project, to try to take screenshot just after rendering, check if it helps on iOS:
https://playcanvas.com/project/461577/overview/screenshottest

@max, is the “once” event handler an one “fire” handler and after the event is detached? Didn’t know about that.
And didn’t want to start a new topic, just saw your code and couldn’t find any documentation on this.

this.app.on('ui:takeScreenshot', function() {
    this.app.once('postRender', this.takeScreenshot, this);
}, this);

Sorry for the spam @Battle.

@Leonidas hah, yes, it is single trigger subscribe.
Looks like indeed: https://github.com/playcanvas/engine/blob/master/src/core/events.js#L160 documentation is missing, will fix that.

EDIT: pushed will be deployed some time in future to affect autocomplete and API Reference.

1 Like

Sorry for the delay, needed some time to debug locally on iOS.

I copied the modification over to my original paired down example project posted originally and it appears to render the initialize/orbit combo movements now without any problems.

Although depending on how wildly I move the object via touch alone I can get a blank canvas screenshot. However, now it does seem to render as intended after 5-10 seconds if I wait before calling my code to render the screenshot again.

My guess is that iOS + Safari is lagging slightly internally rendering the canvas, so toDataURL() is getting the in-between frames or something similar, which would explain the entirely blank canvas. But that code you provided was a big help as it at least was able to get a screenshot printed out to the screen at some point, instead of none at all. :slight_smile:

It is weird regarding iOS.

Could you please provide exact version of iOS and its browser as well as screenshot of http://webglreport.com ?
We have some iOS devices on site to do some tests to verify if this is actually happening on OS level.

http://54.161.163.84/playcanvas-camera/examples/webglreport_1.PNG
http://54.161.163.84/playcanvas-camera/examples/webglreport_2.PNG
http://54.161.163.84/playcanvas-camera/examples/webglreport_3.PNG

It’s an iPad Air 2, latest version of iOS (and thus Safari as well). Browser I’ve been using is Safari.

Was able to get the code running into the actual project I’m having the issue with (can’t post the code here for it unfortunately), it does help to a degree but it renders old canvas data in the buffer or partial data from the model (as if it was in mid-render), which makes my theory about the buffer catching up make sense.

Sometimes if I wait long enough it’ll render as intended, but it doesn’t always do this (granted this model is a larger one, so it might be hitting it’s limit on the iPad quite possibly).

I’ve updated project slightly, could you please run it on iOS again? https://playcanvas.com/editor/scene/501397
It does look like it tries to take screenshot before all GL commands been executed on GPU and back-buffer refreshed.

If current version still persists the problems, then there is another alternative - wait one more frame, and that should do it.

I’ve just tested on iPhone 6 with latest iOS, and all is fine on it. I’m not sure iPad would behave differently. So you could be doing something wrong in your application logic perhaps.

@max I had the issues on iOS, and with the postRender trick I managed to fix it.

But the method fails on Amazon Kindle (FireOS), grabbing a solid colour image only or corrupted graphics (much like it did on iOS).

Is there an alternative method to screen grab?

Try enabling “Preserve Drawing Buffer” in project settings, and calling toDataUrl() either at the end of loop, or at the beginning (before rendering).