Zooming smoothly

BlitzMax Forums/BlitzMax Programming/Zooming smoothly

I am trying to smoothly zoom into a portion of the screen over a given number of frames. For example, suppose the screen is 800x600 and I want to zoom in so that 150,150 to 170,170 now fills the screen, but I want it to zoom in gradually over several frames.
Problem is, if I do it linearly, it will appear to speed up as I get closer to the portion of the screen I'm zooming into. I also tried using a sine function to taper off the zoom, which made things a little better, but was still getting a speedup toward the end.
Here is a sample zooming in on the x on the screen using both linear zooming and sine zooming. As you can see, both versions visually speed up toward the end and isn't consistent across the entire zoom range.
SuperStrict

Graphics 800,600
'start out with the virtual coordinates at a 1:1 with the actual screen coordinates
Global ZX1:Double = 0 'Coordinate on left edge of screen
Global ZY1:Double = 0 'coordinate at to
Global ZX2:Double = 799 'coordinate at right
Global ZY2:Double = 599 'coordinate at bottom

While Not KeyHit(KEY_ESCAPE) And Not AppTerminate()
	Cls
	Line 150,150,170,170
	line 150,170,170,150
	DrawText "Press L for linear zoom",10,500
	DrawText "Press S for Sine zoom",10,520
	Flip
	If KeyHit(KEY_L) Then LinearZoom(150,150,170,170)
	If KeyHit(KEY_S) Then SineZoom(150,150,170,170)
Wend

'plots a line on the screen using virtual coordinates
Function Line(x1:Double,y1:Double,x2:Double,y2:Double)
	Local rx1:Int = (x1-ZX1)*800/(ZX2-ZX1) 'convert the virtual coordinates to screen coordinates
	Local rx2:Int = (x2-ZX1)*800/(ZX2-ZX1)
	Local ry1:Int = (y1-ZY1)*600/(ZY2-ZY1)
	Local ry2:Int = (y2-ZY1)*600/(ZY2-ZY1)

	DrawLine rx1,ry1,rx2,ry2
End Function

Function LinearZoom(ex1:Double,ey1:Double,ex2:Double,ey2:Double)
	Local sx1:Double = ZX1
	Local sx2:Double = ZX2
	Local sy1:Double = ZY1
	Local sy2:Double = ZY2
	
	For Local i:Int = 0 To 300
		Local Percent:Double = Double(i)/Double(300)
		ZX1 = ex1*Percent
		ZY1 = ey1*Percent
		ZX2 = (ex2-sx2)*Percent+sx2
		ZY2 = (ey2-sy2)*Percent+sy2
		Cls
		line(150,150,170,170)
		line(150,170,170,150)
		Flip
	Next
	WaitKey()
	ZX1 = 0
	ZX2 = 799
	ZY1 = 0
	ZY2 = 599
End Function

Function SineZoom(ex1:Double,ey1:Double,ex2:Double,ey2:Double)
	Local sx1:Double = ZX1
	Local sx2:Double = ZX2
	Local sy1:Double = ZY1
	Local sy2:Double = ZY2
	
	For Local i:Int = 0 To 300
		Local Time:Double = Double(i)/Double(300)
		Local Percent:Double = Sin(Time*Double(90))
		ZX1 = ex1*Percent
		ZY1 = ey1*Percent
		ZX2 = (ex2-sx2)*Percent+sx2
		ZY2 = (ey2-sy2)*Percent+sy2
		Cls
		line(150,150,170,170)
		line(150,170,170,150)
		Flip
	Next
	WaitKey()
	ZX1 = 0
	ZX2 = 799
	ZY1 = 0
	ZY2 = 599
End Function


OK, I've stared at it for a bit, and I think I can see what you're doing.
If you consider the part of the world that is drawn on the screen as a box in space, what you're doing is shrinking the box (linearly!) down until it fits the square (150,150)->(170,170).

But then to draw the screen, you convert the co-ordinates of your points using a function that isn't linear! If you take the line
Local rx1:Int = (x1-ZX1)*800/(ZX2-ZX1)

and expand it out, the bit depends on t (the time, starting at t=0 and ending at t=1) behaves like 1/t, which is where you're getting the acceleration from.

So, we need some new thinking.
We want to stretch a rectangle with top-left corner (x1,y1) and bottom-right corner (x2,y2) so that every point in the rectangle moves towards its final position at a constant speed.
Importantly, (x1,y1) and (x2,y2) must not move in the virtual space, just when they are projected onto the screen.
So we will make a function Zoom(x,y,t) that, given a point (x,y) in the virtual space and the time t, returns a point (px,py) on the screen at which to draw that point.
Minor niggle here that it's difficult to return two things at once in BMax, so we'll actually need two functions, ZoomX(x,t) and ZoomY(y,t).

We need ZoomX(x1,0) = x1 and ZoomX(x1,1) = 0. We also need ZoomX(x2,0)=x2 and ZoomX(x2,1)=800.

A note about linear interpolation, which I think you understand because you had it half-right in your code, but I might as well make it explicit:
To interpolate a value X linearly between two values A and B, so that at time t=0, X = A ,and at time t=1, X = B, set
X = A * (1 - t) + B * t


So, try:
Function ZoomX:Double(x:Double,t:Double,x1:Double,x2:Double)
	Local zx1:Double=x1*(1-t)	'this is the position of x1 on the screen at time t.
						'note that this is linear, and has zx1(t=0) = x1 and zx1(t=1) = 0
						'we could also work out zx2, for the point x2, but we don't need to.
					
	Local width:Double=(x2-x1)*(1-t)+800*t	'this gives us how wide, in pixels, the rectangle is 
										'on the screen at time t. Note that it is linear and 
										'we have width(t=0) = x2 - x1 and width(t=1) = 800.
								
	Local nx:Double=(x-x1)/(x2-x1)	'We want to know where the point x is in the rectangle as
								'a proportion of the width. Note that this number does not
								'depend on t, so remains constant throughout.
	
	Local px:Double=zx1+nx*width		'finally, this gives us the position on the screen of our point.
								'Note that if x=x1 then nx=0 so px = zx1 as expected, and if
								'x=x2 then nx=1 so we will get px = x2*(1-t)+800*t = zx2.
								
	Return px
	
End Function


The function for ZoomY looks much the same, but with Ys instead of Xs and 600 instead of 800.

Now, we can rewrite the LinearZoom function and put it back in the program:
SuperStrict

Graphics 800,600

While Not KeyHit(KEY_ESCAPE) And Not AppTerminate()
	Cls
	DrawLine 150,150,170,170
	DrawLine 150,170,170,150
	DrawText "Press L for linear zoom",10,500
	Flip
	If KeyHit(KEY_L) Then LinearZoom(150,150,170,170)
Wend


Function ZoomX:Double(x:Double,t:Double,x1:Double,x2:Double)
	Local zx1:Double=x1*(1-t)	'this is the position of x1 on the screen at time t.
						'note that this is linear, and has zx1(t=0) = x1 and zx1(t=1) = 0
						'we could also work out zx2, for the point x2, but we don't need to.
					
	Local width:Double=(x2-x1)*(1-t)+800*t	'this gives us how wide, in pixels, the rectangle is 
										'on the screen at time t. Note that it is linear and 
										'we have width(t=0) = x2 - x1 and width(t=1) = 800.
								
	Local nx:Double=(x-x1)/(x2-x1)	'We want to know where the point x is in the rectangle as
								'a proportion of the width. Note that this number does not
								'depend on t, so remains constant throughout.
	
	Local px:Double=zx1+nx*width		'finally, this gives us the position on the screen of our point.
								'Note that if x=x1 then nx=0 so px = zx1 as expected, and if
								'x=x2 then nx=1 so we will get px = x2*(1-t)+800*t = zx2.
								
	Return px
	
End Function

Function ZoomY:Double(y:Double,t:Double,y1:Double,y2:Double)
	Local zy1:Double=y1*(1-t)	'this is the position of y1 on the screen at time t.
						'note that this is linear, and has zy1(t=0) = y1 and zy1(t=1) = 0
						'we could also work out zy2, for the point y2, but we don't need to.
					
	Local width:Double=(y2-y1)*(1-t)+600*t	'this gives us how wide, in pixels, the rectangle is 
										'on the screen at time t. Note that it is linear and 
										'we have width(t=0) = y2 - y1 and width(t=1) = 600.
								
	Local ny:Double=(y-y1)/(y2-y1)	'We want to know where the point y is in the rectangle as
								'a proportion of the width. Note that this number does not
								'depend on t, so remains constant throughout.
	
	Local py:Double=zy1+ny*width		'finally, this gives us the position on the screen of our point.
								'Note that if y=y1 then ny=0 so py = zy1 as expected, and if
								'y=y2 then ny=1 so we will get py = y2*(1-t)+600*t = zy2.
								
	Return py
	
End Function


Function LinearZoom(ex1:Double,ey1:Double,ex2:Double,ey2:Double)
	For Local i:Int = 0 To 300
		Local t:Double = Double(i)/Double(300)
		Line(150,150,170,170,t,ex1,ey1,ex2,ey2)
		Line(150,170,170,150,t,ex1,ey1,ex2,ey2)
		Flip
		Cls
	Next
	WaitKey()
End Function

Function Line(x1:Double,y1:Double,x2:Double,y2:Double,t:Double,ex1:Double,ey1:Double,ex2:Double,ey2:Double)
	Local zx1:Double=ZoomX(x1,t,ex1,ex2)
	Local zy1:Double=ZoomY(y1,t,ey1,ey2)
	Local zx2:Double=ZoomX(x2,t,ex1,ex2)
	Local zy2:Double=ZoomY(y2,t,ey1,ey2)


	DrawLine zx1,zy1,zx2,zy2
End Function


Note that I pass the ex1,ey1, etc. around all the time because I didn't want to confuse matters with new globals, but if you're always doing the same zoom you could just make them globals and set them at the start of the process.

Thanks warpy, this will be a big help. I will probably come up with a couple more questions, but right now, time to work for an actual paycheck :)

Ok, I had some time to mess with your code and found it doesn't work. It looks fine when only the X is being drawn, but when you're zooming in with all kinds of things drawn around the outside, you notice a definite deceleration.

i thought about it for a while and realized something. If you stretch the world (or shrink the viewport) so that only 90% of the original shows after the first iteration, then on the second iteration, to appear a consistent speed, 90% of the screen from the result of the first iteration needs to be showing.
So instead of decreasing the original viewport linearly 90%, 80%, 70% etc. You need to decrease it exponentially. 90%, 81%, 72.9% etc...
After a bit of thinking, I finally came up with a formula.
X(i)=a*(p^i)
X(i) is the point after i iterations. a is the original position of the point. p is a scaler.
So the idea is, in order to move from a to b in n number of iterations, you need to calculate the value of p in such a way that when i=1 then X(i) = a and when i=n then X(i) = b.
Here's the nifty little formula for that.
p = (b/a)^(1/n)
There's only a few restrictions. One is that the center of the rectangle you are zooming into must be translated to the center of the world before the calculations are performed, which means everything must be translated back afterwards.
Another is that b cannot be 0. That can only happen if the rectangle you are zooming into has either 0 width or 0 height.

Here is an example of zooming with the above method. I have drawn a bunch of polygons around the screen to give a little better perspective of what's going on. On part of the screen is a bunch of concentric circles which marks the bullseye of where I want to zoom into.
As you can see, the zooming appears very consistent.
SuperStrict
Graphics 800,600

Type TEntity 'for making shapes on the screen
	Field numsides:Int
	Field x:Double
	Field y:Double
	Field radius:Double
End Type
Global List:TList = CreateList()

For Local i:Int = 1 To 100 'create a bunch of randomly placed, randomly shaped objects
	Local Entity:TEntity = New TEntity
	Entity.x = Rnd(800)-400
	entity.y = Rnd(600)-300
	Entity.radius = Rnd(.1,50)
	entity.numsides = Rand(3,8)
	List.Addlast(Entity)
Next
For Local i:Int = 1 To 20 'create a "Bullseye" shape for where we're going to zoom to
	Local Entity:TEntity = New TEntity
	Entity.x = 70
	Entity.y = 65
	Entity.numsides = 20
	Entity.Radius = i
	List.AddLast(Entity)
Next
SetOrigin 400,300 'Make 0,0 at the center of the screen

While Not KeyHit(KEY_ESCAPE) And Not AppTerminate()
	Cls
	Drawobjects(-400,-300,400,300)
	SetColor 255,255,0
	DrawText "Press Z to zoom",10,10
	SetColor 255,255,255
	Flip
	If KeyHit(KEY_Z) Then zoom(50,50,90,80,600)
Wend
 
Function DrawObjects(x1:Double,y1:Double,x2:Double,y2:Double) 'Goes through the list and draws onto the World coordinates
	For Local Entity:TEntity = EachIn List
		Local Angle:Double = 360.0/Double(Entity.numsides)
		Local ox:Double = Entity.Radius + Entity.X
		Local oy:Double = Entity.y
		While Angle < 361
			Local x:Int = Cos(Angle)*Entity.Radius+Entity.x
			Local y:Int = Sin(Angle)*Entity.Radius+Entity.y
			Line(ox,oy,x,y,x1,y1,x2,y2)
			Angle :+ 360.0/Double(Entity.numsides)
			ox=x
			oy=y
		Wend
	Next	
			
End Function

Function Line(sx:Double,sy:Double,ex:Double,ey:Double,x1:Double,y1:Double,x2:Double,y2:Double)' translate from world coordinates to screen and renders
	Local cx:Double = (x2-x1)/Double(2)+x1
	Local cy:Double = (y2-y1)/Double(2)+y1
	Local sxs:Int = (sx-cx)/(x2-x1)*800
	Local sys:Int = (sy-cy)/(y2-y1)*600
	Local exs:Int = (ex-cx)/(x2-x1)*800
	Local eys:Int = (ey-cy)/(y2-y1)*600
	
	DrawLine sxs,sys,exs,eys
End Function

'zx1, zy1, zx2, zy2 is the rectangle we are zooming into
Function Zoom(zx1:Double,zy1:Double,zx2:Double,zy2:Double,frames:Int)
	Local cx:Double = (zx2-zx1)/2.0 'the distance from the edge to the center of the zooming rect
	Local cy:Double = (zy2-zy1)/2.0
	Local tx:Double = cx+zx1 'distance from the center of the zooming rect to the world origin
	Local ty:Double = cy+zy1
	
	Local px:Double = (cx/400.0)^(Double(1)/Double(frames)) 'calculate the scalar for zooming
	Local py:Double = (cy/300.0)^(Double(1)/Double(frames))
	Local ptx:Double = (cx/(400.0+tx))^(Double(1)/Double(frames))'calculate the scaler for translating
	Local pty:Double = (cy/(300.0+tx))^(Double(1)/Double(frames))
	
	For Local i:Int = 1 To frames
		Local nx1:Double = Double(-400)*(px^Double(i)) 'corner relative to rect center
		Local nx2:Double = -nx1 'opposite corner are negative of each other
		Local ny1:Double = Double(-300)*(py^Double(i))
		Local ny2:Double = -ny1
		
		Local ntx:Double = -((400.0+tx)*(ptx^Double(i))-tx) 'Where the corner of the screen is after translating
		Local nty:Double = -((300.0+ty)*(pty^Double(i))-ty)
		Local nsx:Double = ntx-nx1 'The amount needed to translate the current viewport from the zooming rect to the world coordinates
		Local nsy:Double = nty-ny1
		Cls
		drawobjects(nx1+nsx,ny1+nsy,nx2+nsx,ny2+nsy) 'draw objects at current viewport dimentions
		Flip
	Next
	WaitKey()	
End Function