WPF에서 트랙볼 기능 구현

마우스를 이용한 카메라 회전
WPF에서 트랙볼(Trackball) 구현하기 


개요

보통 3D 모델을 화면에 표시하면, 그 다음으로 할 작업은 마우스로 모델을 회전해 보는 것이다. 마우스를 통해 3D 오브젝트를 회전하기 위한 가장 일반적인 기술은 트랙볼 기능이라고 알려져 있다. 이 글은 트랙볼이란 무엇이며, 이를 구현하기 위한 방 법을 살펴본다. 이 글의 마지막에 언급한 링크는 WPF 어플리케이션에서 마우스를 이용하여 카메라를 회전할 수 있는 샘플 코드이다.

사용자 삽입 이미지

그림1a) 기본 구성을 가지는 호랑이 모델

사용자 삽입 이미지

그림1b) 마우스를 눌러 왼쪽 아래로 조금 드래그하여 회전된 호랑이 모델


1. 소개

트랙볼은 마우스의 이동을 3D 회전으로 변환한 것이다. 이는 마우스의 위치를 그림2에서 보여지는 것처럼 Viewport3D 전면에 존재하는 가상의 구면으로 마우스의 위치를 투영한 것이다. 마우스를 움직임으로써 카메라(또는 장면)은 마우스 포인터 아래의 구면 위의 동일한 위치를 유지하면서 회전된다.

사용자 삽입 이미지

그럼2a) 정육면체 모델을 가지고 있는 Viewport3D와 사용자 시점에서 본 트랙볼

사용자 삽입 이미지

그림2b) 마우스 위치가 맵핑된 구면 상의 위치를 설명하기 위한 측면에서 본 그림

마우스가 수평으로 이동될 때, Y 축에 대한 회전은 마우스 포인터 아래에 동일한 위치가 유지되어야한다.

사용자 삽입 이미지

그림3) 수평으로 마우스를 움직이는 것은 Y축에 대해 장면을 회전시킨다.

이와 유사하게 마우스 위치를 수직으로 변경시키는 것은 X 축에 대한 회전을 발생시킨다.

사용자 삽입 이미지

그림4) 마우스를 수직으로 움직이는 것은 X 축에 대해 장면을 회전시키는 것이다.

이러한 인터페이스는 X와 Y축에 대한 회전의 조합을 적용하여 사용자가 원하는 방향에서 모델을 살펴볼 수 있는 매우 직관적인 방법이다.

2. 회전 계산
각각 마우스 이동 이벤트에서 마우스 포인터 아래의 동일한 위치를 유지하는 회전을 계산할 필요가 있으며 이를 수행하기 위한 2가지 단계가 필요하다. 첫번째는 마우스 포인터가 구면의 어느 위치에 있는지 계산하는 것이다. 두번째는 예전 위치를 새로운 위치로 변환하기 위해 필요한 회전을 계산하는 것이다.

사용자 삽입 이미지그림5a) 마우스 위치는 좌상단에 (0,0)을 가지는 UIElement의 좌표공간 상에서 알 수 있다.

사용자 삽입 이미지

그림5b) 2차원 마우스 위치를 Viewport3D의 구면 상의 위치로 투영하며 이 위치는 3차원이다.

우리는 회전을 계산하는 것만을 생각할 것이므로 우리에게 가장 편리한 구를 선택할 수 있다. 반지름이 1이며 중심이 (0,0,0)인 구를 사용하는 것이 가장 간단하다. 그림6에서 보는 것처럼 두개의 2D 좌표계 사이의 변환의 예에서 X, Y 요소를 찾는다.

사용자 삽입 이미지

그림6a) UIElement의 좌표계

사용자 삽입 이미지

그림6b) 우리가 정한 트랙볼의 좌표계

이를 수행하기 위해 Viewport3D의 경계를 [0,0]-[2,2] 범위로 맵핑되도록 크기 변환을 한다. 다음으로 좌상단 코너에서 중심 위치를 원점으로 움직이도록 변환한다. 위치를 범위 [-1,1]-[1,-1]로 놓는다. 마지막으로 2D 좌표계에서 Y축이 위 방향 대신 아래방향으로 향하도록 한다.사용자 삽입 이미지

// 범위가 0,0] -[2,2]가 되도록 크기 변환
double x = p.x / (width / 2);
double y = p.y / (height / 2);

// 0,0을 중심으로 이동
x = x - 1;

// Y축이 아래 방향 대신 위방향이 되도록 반전
y = 1 - y;

이제 z값을 가진 x와 y 위치에 대한 구면 상의 위치를 알수 있게 되었다. 구의 반지름이 1이므로 z는 다음의 공식으로 구할 수 있다.사용자 삽입 이미지

double z2 = 1 - x*x - y*y;
double z = z2 > 0 ? Math.Sqrt(z2) : 0;

Vector3D p = new Vector3D(x, y, z);
p.Normalize();

이제 마우스 포인터 아래의 구면 상의 위치 (x, y, z)를 알게 되었다.

2.2 포인트 간의 회전
우리는 마우스 이동시에 마우스 포인트 아래에 대한 구면 상의 동일한 위치를 유지하는 모델의 회전을  원한다. 마지막으로 호출된 마우스 이동 이벤트에서 구면상의 이전 위치를 기억하고 마우스 포인터 아래의 현재 위치로 변환될 회전을 만들어 이를 수행할 수 있다.

이 회전을 계산하기 위해 2가지가 필요하다.

  1. 회전 축
  2. 회전 각도

사용자 삽입 이미지

그림7)  v1에서 v2로 변환될 각도와 회전축을 계산할 필요가 있다.

구가 원점을 중심으로 하고 있으므로 위치를 백터로 해석할 수 있다. 회전 축과 회전 각도는 각각 백터의 내적과 외적을 사용하여 쉽게 구할 수 있다:
사용자 삽입 이미지

Vector3D axis = Vector3D.CrossProduct(v1, v2);
double theta = Vector3D.AngleBetween(v1, v2);

일단 축과 회전 각도 모두를 알게 되었다면 남은 것은 새로운 회전을 현재 방향에 적용하는 것이다:

// 각도에 음수를 취하는데, 이는 카메라를 회전하기 때문이다.
// 장면을 대신 회전한다면 이렇게 해서는 않된다.
Quaternion delta = new Quaternion(axis, -angle);

// RotateTransform3D로부터 현재의 방향을 얻는다.
RotateTransform3D rt = (RotateTransform3D)camera.Transform;
AxisAngleRotation r = (AxisAngleRotation3D)rt.Rotation;
Quaternion q = new Quaternion(r.Axis, r.Angle);

// 이전 방향과 delta를 합성한다.
q *= delta;

// 새로운 방향을 Rotation3D에 다시 지정한다.
r.Axis = q.Axis;
r.Angle = q.Angle;

3. 기타 세부사항
2절에서 간과한 몇가지 세부사항이 있다. 첫번째는 Viewport3D가 정사각형이라는 가정하고 구면 상에 마우스 포인터의 투영을 계산했다는 것이다. 만약 Viewport3D가 정사각형이 아니라면 트랙볼은 실제로 타원체의 모습일 것이다.

사용자 삽입 이미지

그림8) 만약 Viewport3D가 정사각형이 아니라면 트랙볼은 실제로 타원체 모양일 것이다.

이러한 효과는 실제로 주목할 만한 사실은 아니지만 정사각형이 아닌 사각형의 너비와 높이에 대한 비율이 크다면 짧은 축을 따라 더 빠르게 회전하는 현상이 발생한다. 이러한 현상을 막고자 한다면 2D 포인트를 (width, height) 구 대신에 가로와 세로의 길이가 동일한 구로 맵핑하면 된다. 한가지 좋은 예는 가로와 세로 길이를 모두 min(width, height)이다.

또 다른 이슈는 트랙볼 상의 위치에 맵핑되지 않는 마우스 포인터가 발생할 경우에 대한 처리이다.

사용자 삽입 이미지

그림9) 회색 지역은 트랙볼 상의 위치로 맵핑되지 않는다.

한가지 해결법은 이런 경우에 z를 0으로 한정하는 것으로 2.1절의 끝에서 보였다:

double z = z2 > 0 ? Math.Sqrt(z2):0;

기술적으로 x와 y를 정규화해야 하는데, 이는 Z=0인 평면에서 트랙볼 상에 가장 가까운 위치를 찾기 위함이다. 정규화하지 않는다면 반환된 위치는 구면상에 있지 않게 된다:
사용자 삽입 이미지
그러나, 2.2절에서 우리는 정규화된 벡터에 해당하는 Vector3D.AngleBetween(v1, v2)를 사용했다. 이는 위에서 처럼 정규화된 x와 y와 동일한 결과이다.

우리는 또한 모델과 카메라의 초기 위치에 대해 이야기 하지 않았다. 이 구현은 모델이 원점에 존재하며 카메라는 원점을 보고 있고 모델이 보이는 위치에 놓여졌다고 가정한다.

마지막으로 이 글은 확대/축소에 대해 언급하지 않았지만 샘플 코드에서 이에 대한 구현을 포함하고 있으므로 살펴보기 바란다.

4. 샘플 코드
샘플 코드는 재사용이 가능한 3개 파일로 구성된다.

Trackball.cs : 유틸리티 클래스 파일이며 FrameworkElement에 대한 마우스 이벤트를 처리한다. 또한 결과로써 회전과 크기변환을 가지는 Transform3D를 업데이트 한다.

Trackport.proj : loose.xaml 로부터 Model3D을 읽고 표시하는 UserControl이며 트랙볼 기능(Trackball.cs)이 적용되었다.

ModelViewer.proj : 그림 1의 모델 뷰어 어플리케이션(Trackport.proj를 사용하는 예)

감사의 말
모델 뷰 샘플을 제공해준 나의 아내, Bonnie에게 감사한다.

Java 프로세스의 종료시점 후킹

자신이 개발하고 있는 어플리케이션을 완전히 제어하고픈 개발자(나를 포함한..)에게 Java에 매우 좋은 API를 제공합니다. 바로 Java 프로세스의 종료 시점을 어떠한 상황에서든 잡아 내 그 시점에서 원하는 코드를 실행하게 할 수 있는 방법인데요.. 아래의 간단한 코드를 살펴 보는 것으로 시작하겠습니다.

public class TestMain {
    public static void main(String[] args) throws InterruptedException {
        System.out.println("processing something in main(" + 
            Thread.currentThread().getName() + ") start...");
  
        Runtime rt = Runtime.getRuntime();
  
        rt.addShutdownHook(
            new Thread() {
                public void run() {
                    System.out.println("======================");
                    System.out.println("do arrange resource !!");
                    System.out.println("======================");
            }
        } );
  
        System.out.println("Waiting while 10 seconds or hit ^C for exit.");
  
        Thread.sleep(10000);
        System.out.println("processing something in main end...");
        System.exit(0);
  
        System.out.println("this code never run!");
    }
}

핵심은 바로 Runtime.getRuntime()으로 얻어진 Runtime 타입의 인스턴스입니다. 이 인스턴스에 addShutdownHook 매서드를 통해 자바 가상 머신이 종료하는 시점에서 실행하는 코드를 개발자가 유연하게(얼마까지 유연할지는 모르겠지만…) 붙일 수 있습니다. 자바 어플리케이션이 콘솔이여서 ^C를 눌러 강제로 종료시키든… 예외나 에러가 발생하든 Runtime의 addShutdownHook로 지정한 스레드의 실행(run) 로직은 반드시 실행된다는 점입니다.

주어진 좌표와 선분 사이의 주어진 거리에 위치하는 선분의 좌표 구하기

사용자 삽입 이미지
제목이 난해하니 먼저 그림부터 보였습니다. 주어진 선분이 있습니다. 이 선분의 시작점은 (X1, Y1)이고 끝점은 (X2, Y2)입니다. 그리고 주어진 좌표가 있으며 (a, b)입니다. 이 선분과 좌표에 대해서 거리 ln를 가지는 선분상의 좌표를 구하는 것에 대한 정리 포스트입니다. 즉, 위의 그림에서 파란색 점은 주어진 좌표이고 빨간 점을 구하겠다는 것입니다.

먼저 선분에 대한 아래와 같은 매개변수 방정식을 정합니다.

사용자 삽입 이미지
우리가 구해야할 점은 선분상의 점이니 위의 매개변수 방정식에서 x와 y가 바로 우리가 원하는 값입니다. 이 x와 y를 구하기 위해서는 매개변수 t를 구하면 됩니다. 아시겠지만 t가 주어진 선분위에 존재하려면 0~1사이의 값이여야 합니다. 이 값을 벗어나면 답은 없음… 입니다.

이 한가지 관계만 가지고는 않됩니다. 또 하나의 관계를 맺어줘야 합니다. 그 관계는 주어진 좌표(a, b)와 구하고자 하는 선분상의 점(x, y)사이의 거리가 값 ln이라는 사실로부터 다음과 같은 식을 얻을 수 있습니다.

사용자 삽입 이미지
이제 처음 선분에 대한 방정식을 위의 방정식의 x, y에 대입하고 t에 대해 정리를 하면 아래와 같은 t에 대한 2차 방정식이 도출되며 이 2차 방정식을 근의 공식을 통해 t를 구해 보면 다음과 같습니다.

사용자 삽입 이미지
이렇게 구한 t에 대해서 범위가 0~1사이 인지를 검사하고 이 범위에 있다면 이 t를 선분의 방정식에 대입하여 구한 (x, y)가 구하고자 하는 좌표입니다.

Java와 C의 zip 압축 연산에 대한 퍼포먼스 비교

먼저 C의 zip 압축은 Jean-loup Gailly님이 만들어 공개한 zlib 1.2.2를 사용했습니다. C/C++에서 데이터의 압축에서 사용하는 압축 라이브러리는 흔히 이 zlib를 사용합니다.  그리고 Java에서 압축은 기본적으로 제공하는 java.util.zip.Deflater 클래스를 사용했습니다. 테스트를 한 이유는 Java가 C/C++에 비해서 얼마나 느릴까… 하는 기대였습니다. =_=;

Java와 C/C++ 모두 사용한 압축 데이터는 0.2메가 정도되는 jpg 파일로 했습니다. 그리고 Java와 C/C++ 모두 결과는 압축 레벨을 3으로 했을때 동일한 결과와 크기였으며 원본 크기에 비해 75% 정도의 압축되었습니다. 결과는 다음 같습니다. 첫번째 이미지는 Java의 결과이고.. 두번째는 C/C++의 결과입니다.

사용자 삽입 이미지
와우!! Java가 C/C++에 비해 상당히 느릴것으로 기대했는데… 그렇지 않았습니다. C/C++와 성능은 거의 비슷한 것으로 생각됩니다. 두 경우 모두 최악의 경우 0.016초정도 소요됩니다. 다만…. Java의 경우 가끔씩 튀는 부분이 있었는데.. 0.031초 정도 소요되는 부분이 가끔 나옵니다. 아마도 Java의 gc기능 때문이 아닌가… 가볍게 짐작해봅니다. 하지만 이런 부분은 제외하면 정말 C/C++과 같은 Native 컴파일러 못지 않은 성능이라고 판단됩니다.

아래는 Java에서 퍼포먼스 테스트로 사용했던 코드입니다.

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.zip.DataFormatException;
import java.util.zip.Deflater;
import java.util.zip.Inflater;
import java.io.*;

public class TestMain {
    public static void main(String[] args) 
        throws IOException, DataFormatException
    {
        FileInputStream fis = new FileInputStream("d:/a.jpg");
        ByteArrayOutputStream baos = new ByteArrayOutputStream(fis.available());
        byte [] buffer = new byte[512];
        int cntRead;
        while((cntRead=fis.read(buffer)) != -1) {
            baos.write(buffer, 0, cntRead);
        }

        byte[] compressedBytes = null;
        for(int i=0; i<20; i++) { // 20 times repeat..
            System.out.println("Performance Test Start...");
            long stime = System.currentTimeMillis();
 
            compressedBytes = Compress(baos.toByteArray());
   
            long etime = System.currentTimeMillis();
            System.out.println("Performance Test Result : " 
                + (etime-stime)+" MS.");
        }   
 
         FileOutputStream fos = new FileOutputStream("d:/a_java.jpg.zip");
         ByteArrayInputStream bais = new ByteArrayInputStream(compressedBytes);
         while((cntRead=bais.read(buffer)) != -1) {
             fos.write(buffer, 0, cntRead);
         }
    }

    private static byte[] Compress(byte[] bytesToCompress) throws IOException
    {
        Deflater compressor = new Deflater(3);
        compressor.setInput(bytesToCompress);
        compressor.finish();
 
        ByteArrayOutputStream bos = 
            new ByteArrayOutputStream(bytesToCompress.length);
 
        byte[] buf = new byte[bytesToCompress.length + 100];
        while (!compressor.finished())
        {
            bos.write(buf, 0, compressor.deflate(buf));
        }

        bos.close();
 
        return bos.toByteArray();
    }
}

대략 살펴보시면 위의 코드에는 IO에 대한 Buffer 기능과 같은.. 여전히 최적화의 여지가 남아 있습니다. 더 이상 제가 갖고 있는 “자바는 느리다”라는 선입견이 상당 부분 깨진 느낌입니다.

텍스트 효과 단위기능 테스트

구글맵이나 네이버지도, 다음지도 등등 모든 포털 지도사이트에서 제공하는 지도 서비스는 지도 이미지를 256×256 픽셀 크기로 잘려진 타일이미지 형태로 서비스되고 있습니다. 자사만의 지도 타일이미지 렌더링 프로그램을 직접 개발한 곳도 있을테고…. 구글처럼 오픈소스 라이브러리를 이용해 구축한 곳도 있을테고….

현재  RIA 기반의 지도 솔루션 개발에 필요한 지도 타일 이미지 렌더러를 개발하고 있습니다. 오픈소스를 활용하여 개발할까… 생각도 해보았지만… 다양한 사용자가 원하는 자신만의 지도 스타일의 충족이라는 틈새 시장을 놓고 볼때… 오픈소스의 획일화된 지도 이미지 스타일은 제 기획 의도와 맞지 않다고 판단하여 직접 개발하기로 결정하게 되었습니다.

사용자 삽입 이미지
위의 결과 이미지는 현재 개발중인 지도 타일이미지 렌더러에서 텍스트 표현을 위해 고민해본 텍스트 효과입니다. 실제 종이 지도를 살펴보고 글자에 적용된 효과를 살펴보면서 위의 3가지 효과정도면 될듯하여 하나 하나 구현해 보았습니다.

추후 타일 이미지 렌더러… 개발이 완료되면 위의 텍스트 효과가 적용된 지도를 통해 좀더 분명한 결과를 알릴 수 있겠지만.. 지금 단계에서 현재 제 스스로가 무엇을 하고 있는지… 어느 방향으로 가고 있는지에 대해 한 조각이라도 구체화하기 위한 과정으로써의 글을 올려봅니다.