1 2 3 4 5 6 7 8 | var offset: CGPoint = .zero { didSet { // bounds is the bounding rect of the screen. tOriginOffset = CGAffineTransform(translationX: bounds.width/2.0 - offset.x, y: bounds.height/2.0 - offset.y) calculateTranslationMatrix() setNeedsDisplay() } } |
1 2 3 4 5 6 7 8 | var scale: CGFloat = 1.0 { didSet { scale = max(min(scale, QLCanvasView.MAX_SCALING), QLCanvasView.MIN_SCALING) tScaling = CGAffineTransform(scaleX: scale, y: scale) calculateTranslationMatrix() setNeedsDisplay() } } |
1 2 3 4 5 6 7 | func calculateTranslationMatrix() { // NOTE: Factor in scale in translation offset so that panning stays at a consistent speed. But it doesn't work as I expected. let scaledOffset = offset * (1.0/scale) let tx = (scaledOffset.x - tOriginOffset.tx) let ty = (scaledOffset.y - tOriginOffset.ty) tTranslation = CGAffineTransform(translationX: tx, y: ty) } |
1 2 3 4 | let ctm = tScaling.concatenating(tOriginOffset) context.concatenate(ctm) // effectively ctm = tScaling * tOriginOffset //... path.move(to: stroke.origin, transform: tTranslation) |
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 | r32 translation[ 16 ]; r32 scale[ 16 ]; r32 transform[ 16 ]; r32 screen[ 16 ]; r32 matrix[ 16 ]; static r32 panX = 0; static r32 panY = 0; static r32 zoom = 1.0f; static r32 lastMouseX = 0.0f; static r32 lastMouseY = 0.0f; static r32 zoomStartY = 0.0f; static r32 startZoom = 1.0f; static r32 startPanX = 0; static r32 startPanY = 0; static r32 centerX = 0; static r32 centerY = 0; if ( input_keyJustPressed( &window, vk_mouseLeft ) ) { lastMouseX = ( r32 ) window.mouseX; lastMouseY = ( r32 ) window.mouseY; zoomStartY = ( r32 ) window.mouseY; startZoom = zoom; startPanX = panX; startPanY = panY; /* To world space */ centerX = ( window.width * 0.5f ) * ( 1 / zoom ) - startPanX; centerY = ( window.height * 0.5f ) * ( 1 / zoom ) - startPanY; } if ( input_keyIsPressed( &window, vk_mouseLeft ) ) { r32 mouseX = ( r32 ) window.mouseX; r32 mouseY = ( r32 ) window.mouseY; if ( input_keyIsPressed( &window, vk_space ) ) { zoom = startZoom + ( zoomStartY- mouseY ) * 0.01f; /* To world space */ r32 newCenterX = ( window.width * 0.5f ) * ( 1 / zoom ) - startPanX; r32 newCenterY = ( window.height * 0.5f ) * ( 1 / zoom ) - startPanY; panX = ( newCenterX - centerX ) * zoom + startPanX; panY = ( newCenterY - centerY ) * zoom + startPanY; } else { panX += mouseX - lastMouseX; panY += lastMouseY - mouseY; /* Inverted because the mouse coordinate system is y+ goes up. */ } lastMouseX = ( r32 ) window.mouseX; lastMouseY = ( r32 ) window.mouseY; } matrix_createScale( scale, vec3( zoom, zoom, 0 ) ); matrix_createTranslation( translation, vec3( panX, panY, 0 ) ); // matrix_createIdentity( translation ); // matrix_multiply4x4( translation, scale, transform ); /* Order is important */ matrix_multiply4x4( scale, translation, transform ); matrix_createScreenSpaceTopDown( matrix, window.width, window.height ); matrix_multiply4x4( transform, matrix, screen ); lineBatch.matrix = screen; gl_batch( &commands, &lineBatch ); gl_render( &commands ); |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | float dscale = ...; // scale change: 0.1f or -0.1f or similar value float newscale = scale + dscale; if (newscale >= 0.1f && newscale <= 5.f) // clamp scale to [0.1 .. 5.0] interval { float t = newscale / scale; matrix m; mat_identity(m); mat_translate(m, centerx, centery); mat_scale(m, t, t); mat_translate(m, -centerx, -centery); mat_transform(m, &posx, &posy); scale = newscale; } |
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 | if isZooming { #if DEBUG let rect = CGRect(origin: focus - CGPoint(x: 2.0, y: 2.0), size: CGSize(width: 4.0, height: 4.0)) context.addEllipse(in: rect) context.setFillColor(UIColor.red.cgColor) context.fillPath() #endif #if false let tp = offset - focus t = .identity t = t.translatedBy(x: focus.x, y: focus.y) // translate to focus point so scaling is relative to it t = t.scaledBy(x: scale, y: scale) t = t.translatedBy(x: tp.x, y: tp.y) // translate "back" to compensate for focus point and include any panning offset #else let f = (focus - prevFocus)/scale // calculate focus point's delta let tp = deltaOffset*(1.0/scale) - f // calculate translation for compensating translation to focus point, and account for any panning movement t = t.translatedBy(x: f.x, y: f.y) // translate to focus point so scaling is relative to it t = t.scaledBy(x: scale/prevScale, y: scale/prevScale) t = t.translatedBy(x: tp.x, y: tp.y) // translate "back" to compensate for focus point #endif } else { let delta = deltaOffset * (1.0/scale) t = t.translatedBy(x: delta.x, y: delta.y) } prevFocus = focus |
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 | @objc func handlePan(_ sender: UIPanGestureRecognizer) { let translation = sender.translation(in: view) let canvasView = view as! QLCanvasView switch sender.state { case .began: baseOffset = canvasView.offset case .changed: canvasView.offset = baseOffset! + translation default: break } canvasView.setNeedsDisplay() } @objc func handlePinch(_ sender: UIPinchGestureRecognizer) { let canvasView = view as! QLCanvasView switch sender.state { case .began: baseScale = canvasView.scale canvasView.isZooming = true canvasView.prevFocus = canvasView.focus canvasView.focus = sender.location(in: view) case .changed: canvasView.scale = baseScale! + (baseScale! * (sender.scale - 1.0)) canvasView.focus = sender.location(in: view) case .ended: canvasView.isZooming = false default: break } canvasView.setNeedsDisplay() } |
mmozeiko
I don't think your "drifting" is related to matching previous transform/deltas. In my example I am also creating transformation matrix on every frame from scratch. I believe your calculation of how to apply scaling is wrong, it scales around different point than you expect. That's why it seems it is moving away. Check your math.
1 2 3 4 5 6 7 8 | T S | 1 0| |2 0 0| | 2 0 0| | 0 1| x |0 2 0| = | 0 2 0| |-2 -2| |-4 -4 0| Which is more or less the same as using a 3x3 matrix. | 1 0 0| |2 0 0| | 2 0 0| | 0 1 0| x |0 2 0| = | 0 2 0| |-2 -2 1| |0 0 1| |-4 -4 1| |
1 2 3 4 | T | 1 0| | 2 0| | 0 1| with mat_scale = | 0 2| |-2 -2| |-2 -2| |
mrmixer
If the above is correct and the code does what you want, why do we leave the translation part out of the scale multiply ?
1 2 3 4 | T I R | 1 0 0 | | 1 0 0 | | 1 0 0 | | 0 1 0 | x | 0 1 0 | = | 0 1 0 | |-2 -2 1 | | 0 0 1 | |-2 -2 1 | |
1 2 3 4 | S R R' | 2 0 0 | | 1 0 0 | | 2 0 0 | | 0 2 0 | x | 0 1 0 | = | 0 2 0 | | 0 0 1 | |-2 -2 1 | |-2 -2 1 | |
Sorry to ressurect an old thread, but I've been struggling with this for a while and can't seem to figure out why I'm getting massive oscillations when panning/zooming sometimes.
When the window is resized / panned / zoomed I call the following routine which recomputes the world to screen matrix, which gets applied to all Draw*
routines (I'm currently using Raylib):
internal inline M3x3 world_to_screen_recompute(V2 window_dims, Game *game) { M3x3 m = identity_m3x3(); translate_by_m3x3(&m, -game->mouse_pos.x, -game->mouse_pos.y); scale_by_m3x3(&m, game->camera_zoom, game->camera_zoom); translate_by_m3x3(&m, game->mouse_pos.x, game->mouse_pos.y); translate_by_m3x3(&m, -game->camera_pos.x, -game->camera_pos.y); scale_by_m3x3(&m, TILE_DIM_PX, -TILE_DIM_PX); translate_by_m3x3(&m, window_dims.x/2.f, window_dims.y/2.f); return m; }
mouse_pos
and camera_pos
are in world coords. TILE_DIM_PX
is just a constant (1 world unit is equivalent to the radius of a tile). It works totally fine if I don't do the mouse translate, and instead scale after the camera has been offset.
Any advice would be hugely appreciated
I'm not super confident with this, but I didn't see any issue with what you wrote. But it could be wrong if the matrix multiplication is in the wrong order, including how you multiply the points by the matrix.
The way I try to work out those issues is to write the math by hand and be sure that I understand what's happening. I often found that I did some operation in the wrong order, or assumed some convention that I didn't follow. It also let you compare the result of the code with a result that you should expect.
Here is an example of what I'm talking about using your example, but note that I don't like how at the end it didn't seem right and I had to change p' = p * m
to p' = m * p
. That's the kind of "error" that makes me uncomfortable and redo/verify the conventions/order I use. I'm not doing it here since I don't want to spend a lot of time. What I would also do is compute and write every step on the point, instead of computing the matrix combination. Like p1 = p * m1
, p2 = p1 * s
... to make sure it gives the same result as the matrix combination. It also let you understand what every operation does to the points.
p = [2,3,0] mouse = [1,1] i = | 1 0 0 | | 0 1 0 | | 0 0 1 | m1 -mouse | 1 0 0 | | 0 1 0 | | -x -y 1 | m = i * m1 m = m1 s scale | s 0 0 | | 0 s 0 | | 0 0 1 | m = m * s s, 0, 0 0, s, 0 -x*s, -y*s, 1 m2 mouse | 1 0 0 | | 0 1 0 | | x y 1 | m = m * m2 s, 0, 0 0, s, 0 -x*s+x, -y*s+y, 1 c -camera | 1 0 0 | | 0 1 0 | | -cx -cy 1 | m = m * c s, 0, 0 0, s, 0 -x*s+x-cx, -y*s+y-cy, 1 ts tile | tx 0 0 | | 0 ty 0 | | 0 0 1 | m = m * ts s*tx, 0, 0 0, s*ty, 0 (-x*s+x-cx)*tx, (-y*s+y-cy)*ty, 1 w window | 1 0 0 | | 0 1 0 | | -wx/2 -wy/2 1 | m = m * w s*tx, 0, 0 0, s*ty, 0 ((-x*s)+x-cx)*tx+(-wx/2), ((-y*s)+y-cy)*ty+(-wy/2), 1 p' = i * m1 * s * m2 * c * ts * w * p p' = m * p p = [2,3,1] s*tx*2 s*ty*3 ((-x*s)+x-cx)*tx+(-wx/2) * 2 + ((-y*s)+y-cy)*ty+(-wy/2) * 3 + 1 => doesn't seem right p' = p * i * m1 * s * m2 * c * ts * w p' = p * m p = [2,3,1] 2 * (s*tx) + ((-x*s)+x-cx)*tx+(-wx/2) 3 * (s*ty) + ((-y*s)+y-cy)*ty+(-wy/2) 1 2 * (2*10) + ((-1*2)+1-(-4))*10+(-400/2) = -130 3 * (2*10) + ((-1*2)+1-(-5))*10+(-200/2) = 0 1 Is this correct ? I don't know.
After testing the xform for a couple different zoom levels, it really feels to me like the calculation in its current form is somehow trying to achive 2 impossible constraints:
You can see that in this test output here - the mouse cursor pos stays constant, but the camera position is not always (0,0). If I were to account for the scaling of the camera's position, then the mouse cursor's position wouldn't be constant:
----- zoom: 1 ------ cam = Mf*( 1, 1) // Mf = translate origin to mouse pos Zf*(-1, -1) // Zf = zoom scale Mb*(-1, -1) // Mb = translate origin to world origin Cf*( 1, 1) // Cf = translate origin to camera pos = (0, 0) mouse = Mf*( 2, 2) Zf*( 0, 0) Mb*( 0, 0) Cf*( 2, 2) = (1, 1) ----- zoom: 2 ------ cam = Mf*( 1, 1) Zf*(-1, -1) Mb*(-2, -2) Cf*( 0, 0) = (-1, -1) mouse = Mf*( 2, 2) Zf*( 0, 0) Mb*( 0, 0) Cf*( 2, 2) = (1, 1) ----- zoom: 3 ------ cam = Mf*( 1, 1) Zf*(-1, -1) Mb*(-3, -3) Cf*(-1, -1) = (-2, -2) mouse = Mf*( 2, 2) Zf*( 0, 0) Mb*( 0, 0) Cf*( 2, 2) = (1, 1)
I'm absolutely baffled by this :/
Yeah, it seems that when we scale around to mouse position, we need to change the camera position if you want to keep the mouse at the same position. Something like scaling the camera position by 1/zoom.
camera_position = (camera_position - mouse_position)*(1/zoom) + mouse_position; // Now do the transform as before.
If that works, maybe there is a way to have that in the matrices directly. Maybe you could ask on the handmade discord. If you find out, I'd be interested to know.
This stayed in my mind so I tried to figure it out again (I already did when the thread was created). After I fixed some issues I did figure it out. And after reading the thread again, the code is just what mmozeiko shared in their gist. Still was nice to figure it out again now that I'm a bit more familiar with matrices.
To sum it up, we need to transform the camera position first to move it to a location where after the zoom the zoom origin is still at the same location. And then you can do a regular transform to render. Those need to be two separate operation.
The issues I ran into:
Just to have another example here is the code I wrote.
#include "../lib/common.h" #include "../lib/window.h" #include "../lib/gl.h" #include "../lib/matrix.h" int main( int argc, char** argv ) { window_t window = { 0 }; u32 window_error = 0; f32 width = 800; f32 height = 400; f32 zoom = 1; vec4 camera = v4( 0, 0, 0, 1 ); window_create( &window, string_l( "Zoom to cursor" ), cast( u32, width ), cast( u32, height ), s32_max, s32_max, 0, &window_error ); gl_t gl = gl_make( &window, gl_flag_alpha_blending, &g_gl_error ); gl_batch_t* lines = gl_add_line( &gl, &g_gl_error ); matrix_t final; matrix_identity_4( final.e ); lines->matrix = final.e; while ( window.running ) { window_handle_messages( &window, whm_none ); if ( window.close_requested ) { window.running = false; } if ( window.resized ) { gl_resize_frame_buffer( &gl, &g_gl_error ); width = cast( f32, window.width ); height = cast( f32, window.height ); } gl_frame_start( &gl, &g_gl_error ); gl_clear( &gl, black ); f32 previous_zoom = zoom; if ( window.scroll_y ) { zoom += math_sign( window.scroll_y ) * 0.1f; } if ( input_key_just_pressed_or_repeated( &window, vk_add ) ) { zoom += 0.1f; } else if ( input_key_just_pressed_or_repeated( &window, vk_subtract ) ) { zoom -= 0.1f; } if ( zoom < 0.1f ) { zoom = 0.1f; } f32 w_width = 10.0f * ( 1.0f / previous_zoom); f32 w_height = w_width * ( height / width ); debug_l( "world: " ); debug_f32( w_width, false ); debug_l( ", " ); debug_f32( w_height, true ); vec4 mouse = v4_s32( window.mouse_x, window.mouse_y, 0, 1 ); mouse.x /= width; mouse.x -= 0.5f; mouse.y /= height; mouse.y -= 0.5f; mouse = v4_hadamard( mouse, v4( w_width, w_height, 0, 1 ) ); mouse = v4_add( mouse, v4( camera.x, camera.y, 0, 0 ) ); debug_l( "mouse_pos: " ); debug_f32( mouse.x, false ); debug_l( ", " ); debug_f32( mouse.y, true ); if ( input_key_just_pressed( &window, vk_space ) ) { debug_break( ); } matrix_t m1, s, m2, out1, out2; matrix_translation_4( -mouse.x, -mouse.y, 0, m1.e ); matrix_scale_4( previous_zoom/zoom, previous_zoom/zoom, 1, s.e ); matrix_translation_4( mouse.x, mouse.y, 0, m2.e ); matrix_mul_4( m1.e, s.e, out1.e ); matrix_mul_4( out1.e, m2.e, out2.e ); camera = matrix_mul_vec_4( camera.e, out2.e ); matrix_t ct, cs, cm; matrix_translation_4( -camera.x, -camera.y, 0, ct.e ); matrix_scale_4( zoom, zoom, 1, cs.e ); matrix_orthographic_x_4( width, height, 10, 0.01f, 10.0f, cm.e ); matrix_mul_4( ct.e, cs.e, out1.e ); matrix_mul_4( out1.e, cm.e, final.e ); memory_free( &lines->uniform ); memory_push_copy_p( &lines->uniform, final.e, sizeof( final ), 4 ); gl_vertex_p2_c4_t vertices[ 2 ] = { 0 }; vertices[ 0 ].color = white; vertices[ 1 ].color = white; vertices[ 0 ].position = v2( -2.0f, -2.0f ); vertices[ 1 ].position = v2( -2.0f, 2.0f ); gl_batch_lines( lines, vertices->e, 2, white ); vertices[ 0 ].position = v2( -1.0f, -2.0f ); vertices[ 1 ].position = v2( -1.0f, 2.0f ); gl_batch_lines( lines, vertices->e, 2, white ); vertices[ 0 ].position = v2( -0.0f, -2.0f ); vertices[ 1 ].position = v2( -0.0f, 2.0f ); gl_batch_lines( lines, vertices->e, 2, white ); vertices[ 0 ].position = v2( 1.0f, -2.0f ); vertices[ 1 ].position = v2( 1.0f, 2.0f ); gl_batch_lines( lines, vertices->e, 2, white ); vertices[ 0 ].position = v2( 2.0f, -2.0f ); vertices[ 1 ].position = v2( 2.0f, 2.0f ); gl_batch_lines( lines, vertices->e, 2, white ); vertices[ 0 ].position = v2( -2.0f, -2.0f ); vertices[ 1 ].position = v2( 2.0f, -2.0f ); gl_batch_lines( lines, vertices->e, 2, white ); vertices[ 0 ].position = v2( -2.0f, -1.0f ); vertices[ 1 ].position = v2( 2.0f, -1.0f ); gl_batch_lines( lines, vertices->e, 2, white ); vertices[ 0 ].position = v2( -2.0f, 0.0f ); vertices[ 1 ].position = v2( 2.0f, 0.0f ); gl_batch_lines( lines, vertices->e, 2, white ); vertices[ 0 ].position = v2( -2.0f, 1.0f ); vertices[ 1 ].position = v2( 2.0f, 1.0f ); gl_batch_lines( lines, vertices->e, 2, white ); vertices[ 0 ].position = v2( -2.0f, 2.0f ); vertices[ 1 ].position = v2( 2.0f, 2.0f ); gl_batch_lines( lines, vertices->e, 2, white ); gl_render_batches( &gl, &g_gl_error ); window_set_cursor( &window, window.platform.cursor_arrow ); gl_swap_buffers( &gl ); } gl_cleanup( &gl, &g_gl_error ); log_to_file( g_log_filename ); return 0; }