One of iPad's most talked about apps is Apple's own iBooks e-reader. Perhaps its most eye-catching but completely superfluous feature is the beautiful, dynamic page curling effect that follows your finger naturally as you drag to turn pages. Unlike cheap implementations using simple masks and gradients, iBook's page curling is very realistic, with content that bleeds through and deforms accurately along the curve of the page. Shortly after the iPad announcement Steven Troughton-Smith discovered the page curl effect hiding inside a private API but it offers few cues to the actual implementation.
This article aims to demonstrate one technique that Apple may have used to implement their page curling behavior but doesn't assert that they did. Instead of a 3D approach through OpenGL it's quite possible that they may have simply used Core Image and some fancy math to implement everything entirely in 2D, something hinted at by the fact their page flattens out for large deformations rather than exhibiting a uniform curvature. Whether or not this is the same algorithm Apple uses, it's a simple, elegant solution that works well from all viewing angles, not just straight on.
rho, theta,
and
A
for time
t
to generate a decent looking page flip animation. This is acceptable for an automatic, non-interactive page flip, say for when the user taps or swipes the page to quickly go to the next one. However, to generate a page curl that dynamically tracks the user's finger requires calculating appropriate values of
rho, theta,
and
A
from the touch location as a 2D coordinate. That requires a bit more complicated math and isn't included yet, but will hopefully be addressed in a future article.
rho, theta,
and
A
sliders to see how they affect the geometry of the curl.
CCPage
class, specifically the
-deform
method which performs all the magic in a scant few lines of code, in fact just six lines for the actual deformation process! The method takes no arguments because
rho, theta,
and
A
are properties of
CCPage
. Just set them before calling
-deform
. Take a close look and feel free to step through it in the debugger. There's plenty of inline documentation to guide you through.
This algorithm simply iterates through each vertex in our input mesh, deforming it according to properties- ( void)deform{Vertex2f vi; // Current input vertexVertex3f v1; // First stage of the deformationVertex3f *vo; // Pointer to the finished vertexCGFloat R, r, beta;for ( ushort ii = 0; ii < numVertices_; ii++){// Get the current input vertex.vi = inputMesh_[ii];// Radius of the circle circumscribed by vertex (vi.x, vi.y) around A on the x-y planeR = sqrt(vi. x * vi. x + pow(vi. y - A, 2));// Now get the radius of the cone cross section intersected by our vertex in 3D space.r = R * sin( theta);// Angle subtended by arc |ST| on the cone cross section.beta = asin(vi. x / R) / sin( theta);// *** MAGIC!!! ***v1. x = r * sin(beta);v1. y = R + A - r * ( 1 - cos(beta)) * sin( theta);v1. z = r * ( 1 - cos(beta)) * cos( theta);// Apply a basic rotation transform around the y axis to rotate the curled page.// These two steps could be combined through simple substitution, but are left// separate to keep the math simple for debugging and illustrative purposes.vo = & outputMesh_[ii];vo-> x = (v1. x * cos( rho) - v1. z * sin( rho));vo-> y = v1. y;vo-> z = (v1. x * sin( rho) + v1. z * cos( rho));}}
rho, theta,
and
A
, and puts the result in our output mesh, which is fed to our OpenGL renderer for display. Since each vertex is completely independent of any other, this algorithm is a prime candidate for parallelization, such as with blocks or OpenGL ES 2.0 vertex shaders.
USE_TRIANGLE_STRIPS
in CCCommon.h
.)
As I was developing the ABC of the Sea App, which is an interactive picture-book based on a proof-of-concept Tara (my wife) made many years ago and did not publish, one of the early and obvious items on the checklist of cool things it should do is have a nice curling transition when moving between pages. Initially I expected this to be easy – Apples Apps do this, iBooks on the iPad especially, so it’s built in, right?
Wrong. Well, mostly wrong. I quickly discovered that the only documented curling transition for a UIView
goes up/down, not left/right. It’s the one that Maps uses. Some Googling then revealed that an undocumented transition did perform left/right curling. However, being undocumented means it’s not viable for an App Store submission. It may later acquire an official constant and become kosher, but not yet.
My quest continued. I came across a number of implementations but most of them were lame approximations of a proper page curl. The most promising one was by CodeFlakes that promised very good page-corner-tracking using touch events, so a user could drag the corner around, but it didn’t attempt to curl – it used shadows and gradients to fake the effect. When I queried this they said nobody had asked for real curling.
Then I came across this blog post by W. Dana Nuon: Implementing iBooks page curling using a conical deformation algorithm which was pretty close to what I wanted, except it used OpenGL – as yet an unknown quantity to me. I downloaded the code, examined it, tried to ignore the mathematics involved and eventually decided to use the concepts from it in my code. This took a while and I ended up with acceptable results, at least to be going on with. I parked the issue since I had working code and I wanted to focus on other aspects of the project.
Eventually I came back to it after I was working on some code to pre-render pages that touched the OpenGL code. As I dug in to it my confidence grew and then I finally decided to brave the math since I wanted to develop a touch-tracking mechanism, to drag the page around.
Cone Parameters © PARC
The original algorithm for mapping a point onto the surface of a cone, from Nuon’s sample code and taken directly from the mechanism described by PARC, looked like this:
1
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
R
=
sqrt
(vi.x
* vi.x
+
pow
(vi.y
- A, 2.0f
)
);
// Radius of the circle
//circumscribed by vertex //(vi.x, vi.y) around A on the //x-y plane. r = R * sin (theta ); // From R, calculate the //radius of the cone cross //section intersected by our //vertex in 3D space. beta = asin (vi.x / R ) / sin (theta ); // Angle SCT, the angle of the //cone cross section subtended //by the arc |ST|. v1.x = r * sin (beta ); v1.y = R + A - r * (1.0f - cos (beta ) ) * sin (theta ); // *** MAGIC!!! *** v1.z = r * (1.0f - cos (beta ) ) * cos (theta ); |
The short version of what this does is assume that the distance from the apex of the cone to the 2D location of a point on the flat page is the same as the distance from the apex to where that point would be when mapped onto the cone. Some simple geometry gives you a short cut to then work out the angle around the cone that this point ends up.
When I started to dissect this algorithm, ostensibly to invert the equations, I came across some significant hurdles. The most significant one was that the cone deformation, as implemented, had a serious limitation of where it would position the corner of the page in the view-port. There were two major elements to this limitation:
After messing around with a sheet of paper for 30 minutes and after examining some sketches of a page curl I decided I could overcome most of these limitations if I could introduce flat-parts into the deformation. In effect, I wanted to add an x-component to the position of the apex of the cone and limit how far around the cone a page would curl. It was time to dust off the trigonometry that I had not used in twenty years.
Using my imagination, I called the new parameter B (meaning that {x,y} of the apex would be {B,A} in the code) and once I realised it was a small matter of offsetting in the right places and conditionally transforming a point, ended up with this:
1
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
R
=
sqrt
(
pow
(vi.x
- B, 2.0f
)
+
pow
(vi.y
- A, 2.0f
)
);
// Radius of the circle
//circumscribed by vertex //(vi.x, vi.y) around A //on the x-y plane. r = R * sin (theta ); // From R, calculate the //radius of the cone //cross section //intersected by our //vertex in 3D space. if (vi.x < B ) beta = 0.0f; else beta = asin ( (vi.x - B ) / R ) / sin (theta ); // Angle SCT, the angle //of the cone cross //section subtended by //the arc |ST|. if (vi.x < B ) v1.x = vi.x; else v1.x = B + r * sin (beta ); if (vi.x < B ) v1.y = vi.y; else v1.y = R + A - r * (1.0f - cos (beta ) ) * sin (theta ); v1.z = r * (1.0f - cos (beta ) ) * cos (theta ); |
The effect of this code is to provide a flat page prior to the cone, which starts at x=B. Experimentally I found that the useful range of θ was about 18 to 30 degrees. I then wrote some code to map an arbitrary {x,y} point to the parameters for the curl and, though it was better than for the stock deformation, it was not perfect:
1
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
CGFloat h
=
sqrt
(
(x
* x
)
+
(y
* y
)
);
if (h > 1.0f ) { // eep - map to somewhere we might actually //be able to get to CGFloat e = (h - 1.0f ); x -= e; y -= e; } x *= width; y *= height; // Attempts to set the deform parameters to something that would put // the corner of the page in the given position. CGFloat aRho = 0, aA = -15.0f, aB = 0.0f, aTheta = M_PI / 2; CGFloat wx = sqrt ( (x * x ) + (y * y ) ); wx *= 0.7; wx *= x; wx += 0.3f; aTheta = DEGREES_TO_RADIANS (funcLinear (y, 30, 18 ) ); // Initial part of turn is done mostly by B aA = -0.06f; aB = wx; aRho = 0.0f; rho = aRho; theta = aTheta; A = aA; B = aB; |
In particular, though the corner does move with the touch location, it’s not directly under it. I decided this was a matter of refinement but, more pressing, there was still a significant limitation to where it could place the corner of the page – it needed to flatten the page after the cone in order to reach more places.
This proved to be rather tricky. In order to do this, you need to transform the points relative to the last position they would have had on the cone. All of the math discovered this location in terms of the distance from the apex, which is a function of the original 2D position. Not something that can be easily inferred from an arbitrary point.
Instead of going down a path of nasty trigonometry, I decided to attack it from a different angle. Instead of a cone, why not a cylinder whose point of rotation was freely moveable? The geometry involved would be much simpler – and it should be pretty easy to reverse, too. I went and found a kitchen paper-towel tube and a sheet of paper and convinced myself it was a reasonable model.
Cylinders are pretty easy objects to draw in 3D – it’s just a 2D circle, in our case with radius C, that you transform into 3D and then extrude. A bit trickier is using this simple model and wrapping a page around it – this is because the page doesn’t simply follow the cylinder around in a circle, it’s an elipse and for a given plane in the 2D page, one complete revolution around the tube does not end up at the same point it started – just wrap a page around a tube to see. The page travels up the tube when it’s at an angle.
Initially I thought that the angle formed by the path leading into the cylinder and the path leading out was 2θ but that ended up being too simplistic, not least because I inadvertently changed the meaning of θ to be the angle between the x-axis and the vertex that ran along the face of the cylinder at z=0. What I ended up with was .
The code to perform this deformation ended up reasonably clean, if not as simple as the conical one looked:
1
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 |
#define CYL_RX(C, b) (C * sin(b))
#define CYL_DY(C, d) (C * (d > 2.0f ? 2.0f : d)) #define ROT_X(xin, yin, angle) ((xin * cos(angle)) - (yin * sin(angle))) #define ROT_Y(xin, yin, angle) ((xin * sin(angle)) + (yin * cos(angle))) // theta is angle of cylinder in x,y plane // A,B are y,x of "bottom" of cylinder // C is radius of cylinder // Take Y and work out the corresponding X for the start of the cylinder sx = B + ( (vi.y - A ) / tan (theta ) ); // amount of flat x-travel around cylinder cx = (C * M_PI ) * sin (theta ); // How far round the cylinder we are tx = vi.x - sx; if (tx < 0.0f ) tx = 0.0f; else if (tx > cx ) tx = cx; // Excess of x after the cylinder. -ve if not finished xx = vi.x - (cx + sx ); if (vi.x < sx ) { // Flat before cylinder v1.x = vi.x; v1.y = vi.y; v1.z = 0.0f; } else if (xx > = 0.0f ) { // Flat after cylinder beta = M_PI; dy = tan (M_PI_2 - (theta * 1.0f ) ); // Construct a vertical cylinder v0.x = CYL_RX (C, beta ); v0.y = CYL_DY (C, dy ); // Rotate... v1.x = ROT_X (v0.x, v0.y, theta - M_PI_2 ); v1.y = ROT_Y (v0.x, v0.y, theta - M_PI_2 ); // Extend by the excess v0.x = xx; v0.y = 0; // Rotate... v1.x += ROT_X (v0.x, v0.y, theta * 2.0f ); v1.y += ROT_Y (v0.x, v0.y, theta * 2.0f ); // Translate into place v1.x += sx; v1.y += vi.y; v1.z = C * 2.0f; } else { // Curl around cylinder beta = (tx / cx ) * M_PI; // Lateral travel along cylinder dy = tan (M_PI / 2.0f - (theta * 1.0f ) ) * (tx / cx ); // Construct a vertical cylinder v0.x = CYL_RX (C, beta ); v0.y = CYL_DY (C, dy ); // Rotate... v1.x = ROT_X (v0.x, v0.y, theta - M_PI_2 ); v1.y = ROT_Y (v0.x, v0.y, theta - M_PI_2 ); // Translate into place v1.x += sx; v1.y += vi.y; v1.z = C * (1.0f - cos (beta ) ); } |
Now this is not perfect either, in particular, for values of approaching there is a small tendancy for the page to curve below the y-axis a little. This I hope to remedy later. It’s also straightforward to curl from a different corner, or even an edge, thanks to the complete flexibility of the rotation point of the cylinder. Significantly, however, the logic to map an {x,y} location to deformation parameters proved, if not straight forward then very achievable.
By looking at a sketch of three phases of the cylindrical deformation it became clear that there was a single equation that could involve all the parameters to hand. I called the distance along the bottom edge of the page that was after the cylinder h, the distance along that edge that was wrapped around the cylinder c and the distance along the bottom edge, on the x-axis, that runs from where the page leaves the x-axis back to the x-coordinate of the corner of the page being dragged Δ.
Page Curl Phases
and you know what? It works. Calculating θ was simple pythagoras and I ended up with:
1
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
A
= 0.0f;
rho = 0.0f; C = 0.02f; // How much is lost in the curve? CGFloat dc = (C * M_PI * 0.5f ); // Funky! CGFloat dx = (y * y - x * x + (2.0f - 2.0f * dc ) * x - dc * dc + 2.0f * dc - 1 ) / (2.0f * x + 2.0f * dc - 2.0f ); // Limits on the range of B CGFloat mx = dx - dc; if ( (x + mx ) < = 0.0f ) { dx = - (x + dc ); mx = -x; } B = (x + mx ) * width; // And since we now have a right triangle, the angle is... if (dx == 0.0f ) theta = M_PI_4; else { theta = atan (y / fabs (dx ) ); theta = (M_PI - theta ) / 2.0f; if (dx < 0 ) theta = M_PI_4 - (theta - M_PI_4 ); } |