WPF 3D Construction Basics
(learn how to create 3D visual objects, use a Viewport, use a camera, and adding light)




Intro

 저는 나름대로 제 자신이 많은 분야에 대해서 공부했다고 생각합니다. :)
언어는 저급언어인 80x86 어셈블리부터 C, C++, .NET, Java 그리고 함수형 언어인 Ocaml, 이외 html, javascript. 그리고 잡다하게 조금씩 해 본 것으로는 lisp, python, perl 등이 있습니다.
분야로는 네트워크, 커널, 시리얼 통신, 윈도우 디바이스 드라이버, 웹, 미디어 그리고 각종 잡다한 어플들..
그런데 한번도 공부해 보지 못한 분야가 있었으니, 그것이 바로 3D 쪽입니다.

 프로젝트를 하면서 3D 를 할 일이 있었지만, 항상 같이 일하는 친구/동료들이 업무를 맡게 되었지요.
그래서 저는 뒤에서 지켜볼 기회는 많았지만, 공부를 해 본 적이 없습니다. 이런 이유로 이참에 한번 WPF 를 통해서(나름 쉽게 하려고 ㅎ) 공부를 해 보기로 마음 먹었습니다.

그러다가 구글에서 괜찮은 웹페이지를 찾았습니다. 다음 포스팅은 http://kindohm.com/technical/toc.htm 의 글을 번역(조금의 각색)한 것입니다. 번역이 어설프더라도 양해 바랍니다. :)



Windows Presentation Foundation (WPF) 3D Tutorial

이 튜토리얼의 목적은 WPF 를 이용하여, 3D 그래픽을 만드는 방법을 간략히 설명하고 예제를 제공하는 것입니다. 이 글에서는 3D 모델링 하는 방법에 대해서는 다루지 않으며, 3D 그래픽에 대해 지식이나 경험이 없는 사람을 위한 간략한 입문서입니다. 3D 또는 벡터(vector) 기반의 그래픽은 2D GDI 와는 다릅니다. 저는 3D 가 어떻게 돌아가고 동작하는지를 이해하기까지 추측하고, 해메고, 이것저것 테스트 해 보고, 실수하는데 많은 시간이 걸렸습니다. 그래서 저는 이 튜토리얼을 통해 여러분은 제가 겪은 고통을 겪지 않았으면 합니다.

여러분의 편의를 위하여, 튜토리얼 샘플코드에서 사용되는 모든 .NET 3.0 클래스, 구조체, 프로퍼티, 메소드들에 대해서 document 의 링크를 걸었습니다. 보고 싶을 때면, 언제든 클릭만 하시면 됩니다.




Disclaimer

튜토리얼에 있는 컨텐츠와 샘플코드를 만들면서, 정확하고 버그가 없도록 많은 노력과 시간을 투자했습니다. 하지만 튜토리얼에 나와있는 정보를 이용하고 코드를 사용하면서, 혹시라도 잘못되더라도(예를들어 충돌, 데이타 손실 문제 등) 이에 대해서 책임을 지지 않습니다. 버그를 고치고 가능한 컨텐츠가 완벽해 지도록 꾸준히 노력할 것입니다. 요컨데, 문제가 발생하면 여러분이 가지고 있는 상식과 이 문서를 동원해서 해결하시기 바랍니다. 




Mike's Version of 3D Graphics Theory

제가 WPF 를 가지고 3D 그래픽을 시작했을 때, 제가 하고 있는게 무엇인지도 몰랐습니다. mesh, triangle index, normal 이 무슨 의미인지도 전혀 몰랐습니다. 가장 어렵고 힘든 부분은 3D 모델링을 배우는 부분이었습니다. 왜냐면 정말 기본적인 지식 조차도 없어서, WPF 에서 제대로 화면에 출력되는게 하나도 없었습니다.(여러분은 운이 좋으면 한번에 될 수도 있겠네요) 요컨데, 여러분이 반드시 알아야하는 것은 mesh 가 무엇인지, 그리고 어떤 것들로 구성되어 있는지 입니다.[1]




What is a mesh?

mesh 란 근본적으로 표면(surface)의 묘사를 의미합니다. mesh 는 점과 선을 통해서 표면을 나타냅니다. 점은 표면의 고저(高低)를 표현하고, 선은 점에서 점으로 어떻게 연결 되었는지를 표현합니다.

표면(surface) 중 가장 간단한 것은 평면입니다. 한 평면을 정의하기 위해서는 세 점이 필요하지요. 그러므로 mesh 로 나타낼 수 있는 가장 간단한 표면(surface)은 단일 삼각형(single triangle)입니다. 즉, mesh 는 오로지 삼각형만을 이용해서 나타낼 수 있다는 것입니다. 왜냐면 삼각형은 가장 간단하면서도, 표면을 정의하기 위한 가장 작은 요소이기 때문이죠. 크고 복잡한 표면을 단 한개의 삼각형으로 섬세하게 표현할 수 없는 없습니다. 하지만 수 많은 작은 삼각형들을 이용하면 비슷하게 표현할 수가 있습니다. 어떤 분은 사각형을 사용해서 표면을 묘사할 수 있다고 할 지도 모릅니다. 하지만 이것은 삼각형 만큼 작은 요소가 아닙니다. 사각형은 두 개의 삼각형으로 다시 나눌 수 있거든요. 두 개의 삼각형은 한 개의 사각형을 이용하는 것 보다 더 자세히 표면을 묘사할 수 있습니다. 결론은 mesh 는 표면을 나타내기 위해서 수 많은 삼각형을 사용한다는 것입니다.


모든 mesh 는 다음으로 구성되어 있습니다.

  • Mesh Positions
  • Triangle indeces
  • Triangle normals





Mesh Positions

mesh position 는 표면 위에 있는 한 점의 위치를 의미합니다. 점들이 빽빽하면 빽빽할수록,  mesh 는 표면을 더 정밀하게 묘사하게 됩니다.





Triangle Indeces

triangle index 란 mesh 에서 삼각형의 세 점 중 한 점을 정의하는 mesh position 을 의미합니다. mesh position 하나로 mesh triangle 을 나타낼 수 없습니다. position 하나를 추가하고 나면, 그 position 이 어느 삼각형의 요소로 이용되는지 정의 해 주어야 합니다.

WPF 에서는 mesh position 을 추가하는 순서가 매우 중요합니다. mesh 의 position 집합 안에서 position 의 index(주: 배열에의 index 를 의미) 값은 triangle ideces 를 추가할 때 사용됩니다. 예를들어 {p0, p1, p2, p3, p4} 처럼, 다섯 개의 position 로 이루어진 표면이 있다고 가정해 봅시다. 만약 p1, p3, p4 를 가지고 삼각형을 정의하고 싶다면, index 값으로 1, 3, 4 에 있는 triangle indeces 들을 추가해야 할 것입니다. 만약  position 이 {p3, p4, p0, p2, p1} 의 순서로 되어있고, 동일한 position 의 삼각형을 만들고 싶다면, index 값이 4, 0, 1 인 triangle indeces 를 추가해야 합니다.

그렇기 때문에 triangle indeces 를 추가하는 순서는 매우 중요합니다. 일반적으로 삼각형 하나를 정의할 때, 점들을 시계방향 또는 반시계 방향 순서로 정의합니다. 이것이 중요한 이유는 방향에 따라 삼각형의 보여지는 쪽이 결정되기 때문입니다. 여기서 '쪽' 이라는건 삼각형 세 모서리 중 하나가 아니라, 평면에서의 어느 쪽(주:앞면 뒷면)을 의미합니다.

여러분이 삼각형의 표면을 정면으로 바라보고 있다고 가정 해 봅시다. 만약 여러분이 index 를 시계방향으로 정의했다면, 지금 바라보고 있는 면은 보여지지 않으며(invisible), 반대면이 보여질(visible) 것입니다. 반대로 반시계 방향으로 index 를 정의했다면, 지금 바라보고 있는 면은 보여질(visible) 것이며, 반대면은 보이지 않을(invisible) 것입니다. 이는 "오른손 법칙"을 이용하면 쉽게 기억할 수 있습니다. 오른손 주먹을 쥔 뒤에, 엄지를 세웁니다. 마치 '최고'를 나타내는 것 처럼 말이죠. 그리고나서 손을 보면, 접혀있는 손가락의 굽어진 방향은 반시계 방향일 것입니다. 이것을 index 를 정의하는 순서의 방향이라고 가정하면, 엄지손이 가리키는 방향은 표면에서 보여지는 방향을 의미합니다.

사실 왜 index 의 순서가 오른손 법칙을 따르는지 모르겠습니다. 제 추측으로는 WPF 는 삼각형의 양쪽 면을 렌더링 하는 것을 불필요하거나 효과적이지 않다고 생각하는 것 같습니다.




Triangle Normals

position 들과 triangle index 를 정의하고 나면, 각각의 position 에 법선(normal)을 추가해야 합니다. 여러분이 triangle index 를 추가하면서 삼각형의 보여지는 면을 결정할 때, WPF 는 법선을 이용해서 광원에 표면이 어떻게 보여져야 하는지 알아 냅니다.

법선은 삼각형의 표면에 수직인 벡터를 말합니다. 법선 벡터는 삼각형을 이루는 두 선분 벡터의 "외적(cross product)"을 통해서 구합니다. 만약 A,B,C 점으로 구성된 삼각형이 있다면, 법선은 AB x AC, BC x BA 또는 CB x CA 를 통해서 구할 수 있습니다. 세 가지 방법 모두 같은 벡터를 계산해 냅니다. 아무튼 여전히 오른손 법칙을 따릅니다. AB x AC 의 결과 벡터는 AC x AB 결과 벡터와 반대방향 입니다.


 

보편적으로 법선의 방향과 삼각형 표면의 보여지는 면이 같은 방향이기를 원할 것입니다.그러나 법선이 직각에서 조금 어긋나면, 삼각형의 표면에는 더욱 재미있는 조명 효과들이 생깁니다.

mesh 의 각 positioin 은 그것에 대입되는 법선을 가지고 있어야만 합니다. 그리고 position 은 단 하나의 법선만 가질 수 있습니다. 여러분은 mesh 에 position 을 추가한 순서로 똑같이 법선들을 추가할 것 입니다. 다시 말해, mesh 에 법선 집합의 index 값은 position 집합의 index 와 일치합니다.

position 이 많으면 많을수록, 법선도 많습니다. 법선이 많을수록, 더 멋진 빛과 그림자가 생깁니다. mesh 에서 한 점은 하나 이상의 삼각형에 대한 index 일 것입니다. 이 경우, 여러분은 동일한 좌표에 여러점을 사용해서, 이 점들에 여러개의 법선이 생기길 바랄 것입니다. 예를들어 정육면체의 모서리를 생각해 봅시다. 정육면체의 모서리는 서로 다른 세 삼각형의 교점입니다. 만약 mesh 에서 이 삼각형들의 공통된 index 를 위해서 단 하나의 position 만 사용한다면, 이 position 을 위해서 단 하나의 법선 벡터만 사용할 수 있습니다. 결국 이 position 에 두개의 삼각형은 그려져야 하는 위치에 그려지지 못합니다. 그렇기 때문에 정육면체의 모서리를 고유한 세 개의 position 으로 나타내는게 낫습니다. 이렇게 하면 각각의 삼각형은 자신의 고유한 점을 사용할 것이고, 하나가 아닌 세 개의 법선을 사용할 수 있게 됩니다.




Getting Started With the Code

저는 여러분이 WPF 에 대한 기본적인 지식을 가지고 있고, XAML 을 가지고 기본적인 WPF 사용환경을 만들 수 있다고 가정하여 설명을 드리겠습니다. 자, 그러면 Visual Studio .NET 을 가지고 새로운 WPF Application 을 만들어 봅시다. 아래의 XAML 코드를 application 에 넣어서, 3D 물체를 화면에 그릴때 사용할 Viewport3D 와 버튼이 들어갈 패널을 넣을 간단한 레이아웃을 만들어 봅시다.


<Grid>
    <DockPanel Width="Auto" VerticalAlignment="Stretch" Height="Auto"  HorizontalAlignment="Stretch" Grid.ColumnSpan="1" Grid.Column="0" Grid.Row="0" Margin="0,0,0,0" Grid.RowSpan="1">
        <StackPanel>
            <StackPanel.Background>
                <LinearGradientBrush>
                    <GradientStop Color="White" Offset="0"/>
                    <GradientStop Color="DarkKhaki" Offset=".3"/>
                    <GradientStop Color="DarkKhaki" Offset=".7"/>
                    <GradientStop Color="White" Offset="1"/>
                </LinearGradientBrush>
            </StackPanel.Background>
            <StackPanel Margin="10">
                <Button Name="simpleButton" Click="simpleButtonClick">Simple</Button>
            </StackPanel>
        </StackPanel>
        <Viewport3D Name="mainViewport" ClipToBounds="True">
            <Viewport3D.Camera>
                <PerspectiveCamera FarPlaneDistance="100" LookDirection="-11,-10,-9" UpDirection="0,1,0" NearPlaneDistance="1" Position="11,10,9" FieldOfView="70" />
            </Viewport3D.Camera>
            <ModelVisual3D>
                <ModelVisual3D.Content>
                    <DirectionalLight Color="White" Direction="-2,-3,-1" />
                </ModelVisual3D.Content>
            </ModelVisual3D>
        </Viewport3D>
    </DockPanel>
</Grid>


기본적으로 WPF 에서 모든 3D 물체는 Viewport3D control 안에 생깁니다. 그리고 이것을 3D 모델을 추가시킬 때, 코드가 시작되는 곳에서 넣으면 됩니다. PerspectiveCamera 가 Viewport3D 안에서 추가된 것을 주의 깊게 보세요. camera 가 있기 때문에, 화면을 통해서 model 에 있는 것들을 볼 수 있는 것입니다. camera 가 model 의 {0,0,0} 에 있는 점을 바라보고 있다는 것을 잊지 마세요.

또한 모델은 DirectionalLight 라는 광원을 가지고 있기 때문에, 모델 안에서 객체를 볼 수 있습니다.

저는 여러분이 샘플 코드를 이용하는 동안 카메라의  LookDirection 와 Position 값을 변경해 보기를 권장 합니다. 이 튜토리얼의 스크린샷에서는 XAML 의  LookDirection 와 Position 값을 바꿀 필요가 없습니다.
 
위 XAML 에는 simpleButton 이라는 이름의 Button 이 있습니다. 아래처럼 비하인드 코드 파일에 simpleButtonClick  라는 이름의 메소드를 만들어 버튼 클릭 이벤트와 연결을 합니다.



private void simpleButtonClick(object sender, RoutedEventArgs e)
{
} 




Creating a Simple Mesh

simpleButton 버튼을 클릭하면, 엄청 간단한 mesh 가 만들어지고 Viewport3D 에 이 model 이 추가되도록 할 것입니다. 하지만 그 전에, 앞으로 사용하게 될 3D 클래스와 구조체에 대해서 알아야 할 것들이 있습니다.

System.Windows.Media.Media3D 클래스에 대한 모든 문서는 msdn.microsoft.com 에서 보실 수 있습니다.




Add the code

시작에 앞서, 비하인드 코드에 System.Windows.Media.Media3D 네임스페이스를 추가해 주어야 합니다. 이 안에 우리가 사용할 클래스들이 들어 있습니다.


<code>using System.Windows.Media.Media3D; </code>


자, 이제 간단한 mesh 를 만들어서 Viewport3D 안에 넣어 보겠습니다. 가능한 가장 간단한 mesh 인 삼각형으로 시작해 봅시다. 삼각형은 모델의 원점 근처에서, 한쪽면은 X 축(5 단위 거리)을, 다른 한쪽은 Z 축(5단위 거리)을 따라 생길 것이며, 빗변은 이 두 점을 연결하고 있을 것입니다.

먼저 MeshGeometry3D 을 생성합니다.


MeshGeometry3D triangleMesh = new MeshGeometry3D();  


다음으로 삼각형의 세 점을 정의합니다.


Point3D point0 = new Point3D(0, 0, 0);
Point3D point1 = new Point3D(5, 0, 0);
Point3D point2 = new Point3D(0, 0, 5);


다음으로 mesh 에 세 점을 position 으로 등록합니다.


triangleMesh.Positions.Add(point0);
triangleMesh.Positions.Add(point1);
triangleMesh.Positions.Add(point2);  



이제 우리는 메쉬의 삼각형을 정의하기 위해서 삼각형 index 를 추가해야 합니다. 우리가 만들 메쉬가 삼각형이 전부이므로, 불필요해 보일 수 있습니다. 하지만 WPF 는 이 점들을 어떻게 연결해야 하는지 알지 못합니다. 오른손 법칙을 기억하시기 바랍니다. 우리는 삼각형의 상단의 면이 보여지도록 할 것이기 때문에, 이에 맞는 순서로 index 를 추가해야 합니다.


triangleMesh.TriangleIndices.Add(0);
triangleMesh.TriangleIndices.Add(2);
triangleMesh.TriangleIndices.Add(1);


다음으로 mesh position 을 위해서 법선 벡터들을 추가해야 합니다.  지금의 경우 법선 벡터를 구하는 것은 쉽기 때문에(법선은 Y 축 방향과 수직), 따로 외적을 통해 구하지 않고, 계산된 값을 사용하도록 하겠습니다.


Vector3D normal = new Vector3D(0, 1, 0);
triangleMesh.Normals.Add(normal);
triangleMesh.Normals.Add(normal);
triangleMesh.Normals.Add(normal); 


다음으로 표면을 위해서 DiffuseMaterial 를 만들고 mesh 를 model 에 넣은 후, 이 model 을 Viewport3D 에 넣어야 합니다.


Material material = new DiffuseMaterial(new SolidColorBrush(Colors.DarkKhaki));
GeometryModel3D triangleModel = new GeometryModel3D(triangleMesh, material);
ModelVisual3D model = new ModelVisual3D();
model.Content = triangleModel;
this.mainViewport.Children.Add(model); 


이제 다 끝났습니다. simpleButtonClick 이벤트 핸들러는 아래와 같습니다.


private void simpleButtonClick(object sender, RoutedEventArgs e)
{
    MeshGeometry3D triangleMesh = new MeshGeometry3D();
    Point3D point0 = new Point3D(0, 0, 0);
    Point3D point1 = new Point3D(5, 0, 0);
    Point3D point2 = new Point3D(0, 0, 5);

    triangleMesh.Positions.Add(point0);
    triangleMesh.Positions.Add(point1);
    triangleMesh.Positions.Add(point2);
    triangleMesh.TriangleIndices.Add(0);
    triangleMesh.TriangleIndices.Add(2);
    triangleMesh.TriangleIndices.Add(1);

    Vector3D normal = new Vector3D(0, 1, 0);
    triangleMesh.Normals.Add(normal);
    triangleMesh.Normals.Add(normal);
    triangleMesh.Normals.Add(normal);

    Material material = new DiffuseMaterial(new SolidColorBrush(Colors.DarkKhaki));
    GeometryModel3D triangleModel = new GeometryModel3D(triangleMesh, material);
    ModelVisual3D model = new ModelVisual3D();
    model.Content = triangleModel;
    this.mainViewport.Children.Add(model);
}


바로 이것 입니다! 아래와 같은 결과가 나타날 것입니다.


정말 멋집니다. 이제 3D mesh 의 블럭을 만드는 방법을 알게 되었습니다. 재미 없나요? 좋습니다. 그렇다면 정육면체로 넘어가 보죠.






Creating a Cube

정육면체는 삼각형 만들기와 크게 다르지 않습니다. 차이점이라면 아래와 같습니다.

  • 정육면체는 하나가 아닌 12 개의 삼각형으로 이루어져 있습니다.(6 면이고, 각 면은 2 개의 삼각형으로 되어 있습니다.) 
  • 삼각형은 단번에 예상하기는 어려울지 모르는 Cartesian 좌표계의 각 방향으로 나타납니다. 특히 오른손 법칙을 적용하기 위해서, 반시계 방향이 어느 쪽인지 알아내는 것 조차 헷갈릴 수 있습니다.

 
자, XAML 의 버튼 패널에 새로운 버튼을 추가해 봅시다.


<Button Name="cubeButton" Click="cubeButtonClick">Cube</Button> 



그리고 cubeButtonClick 이벤트 핸들러를 비하인드 코드에 추가합니다.


private void cubeButtonClick(object sender, RoutedEventArgs e)
{
} 



나중을 위해서 코드를 조금 리팩토링 해야 합니다. 하지만 그 전에 Model3DGroup 클래스에 대해서 이야기 하고 넘어가도록 하죠. Model3DGroup 은 GeometryModel3D 객체의 집합입니다. 다시 말해 Model3DGroup 는 많은 mesh 를 포함할 수 있습니다. 뿐만 아니라 Model3DGroup 는 Model3DGroup 를 가질수도 있습니다. 마지막으로 Model3DGroup 는 Viewport3D 에 포함될 수 있습니다. 이것들이 의미하는게 무엇일가요? 이것은 3D 객체의 조그만 세트를 만들고 쉽게 다른 모델의 부분으로 이용할 수 있다는 의미입니다. 다른 User Control 에서 재사용 가능한 Windows User Control 을 만드는 것과 비슷하다고 생각하시면 됩니다.

그러면 이제 삼각형의 index 에 법선을 갖는 단일 삼각형 mesh 를 생성자를 추상화 시켜서 리팩토링 해 봅시다.
비하인드 코드에 CreateTriangleModel() 와 CalculateNormal() 두 개의 함수를 추가시킵니다.


private Model3DGroup CreateTriangleModel(Point3D p0, Point3D p1, Point3D p2)
{
    MeshGeometry3D mesh = new MeshGeometry3D();
    mesh.Positions.Add(p0);
    mesh.Positions.Add(p1);
    mesh.Positions.Add(p2);
    mesh.TriangleIndices.Add(0);
    mesh.TriangleIndices.Add(1);
    mesh.TriangleIndices.Add(2);
    Vector3D normal = CalculateNormal(p0, p1, p2);
    mesh.Normals.Add(normal);
    mesh.Normals.Add(normal);
    mesh.Normals.Add(normal);
    Material material = new DiffuseMaterial(
        new SolidColorBrush(Colors.DarkKhaki));
    GeometryModel3D model = new GeometryModel3D(
        mesh, material);
    Model3DGroup group = new Model3DGroup();
    group.Children.Add(model);
    return group;
}


private Vector3D CalculateNormal(Point3D p0, Point3D p1, Point3D p2)
{
    Vector3D v0 = new Vector3D(
        p1.X - p0.X, p1.Y - p0.Y, p1.Z - p0.Z);
    Vector3D v1 = new Vector3D(
        p2.X - p1.X, p2.Y - p1.Y, p2.Z - p1.Z);
    return Vector3D.CrossProduct(v0, v1);
} 



세 점으로 정의되어 있는 mesh 를 포함하는 Model3DGroup 를 생성할 때면, CreateTriangleModel() 메소드를 어디서든 사용할 수 있습니다. 정말 멋집니다. CalculateNormal() 메소드는 triangle index 의 법선을 구할 때 사용할 수 있습니다. 이것은 Vector3D 구조체의 CrossProduct 메소드를 쉽게 사용할 수 있도록 해 줍니다. 이제는 mesh position 과 두 메소드만 알고 있으면 되니 얼마나 멋집니까!

다음으로 정육면체가 Cartesian 공간에 보여지도록 해 봅시다. 8 개의 고유한 점이 있고, 아래와 같은 순서로 번호를 매길 것입니다.


이제 코드로 정육면체를 만들어 보겠습니다. cubeButtonClick() 이벤트 핸들러에 아래 코드를 넣습니다.


private void cubeButtonClick(object sender, RoutedEventArgs e)
{
    Model3DGroup cube = new Model3DGroup();
    Point3D p0 = new Point3D(0, 0, 0);
    Point3D p1 =new Point3D(5, 0, 0);
    Point3D p2 =new Point3D(5, 0, 5);
    Point3D p3 =new Point3D(0, 0, 5);
    Point3D p4 =new Point3D(0, 5, 0);
    Point3D p5 =new Point3D(5, 5, 0);
    Point3D p6 =new Point3D(5, 5, 5);
    Point3D p7 = new Point3D(0, 5, 5);
    //front side triangles
    cube.Children.Add(CreateTriangleModel(p3, p2, p6));
    cube.Children.Add(CreateTriangleModel(p3, p6, p7));
    //right side triangles
    cube.Children.Add(CreateTriangleModel(p2, p1, p5));
    cube.Children.Add(CreateTriangleModel(p2, p5, p6));
    //back side triangles
    cube.Children.Add(CreateTriangleModel(p1, p0, p4));
    cube.Children.Add(CreateTriangleModel(p1, p4, p5));
    //left side triangles
    cube.Children.Add(CreateTriangleModel(p0, p3, p7));
    cube.Children.Add(CreateTriangleModel(p0, p7, p4));
    //top side triangles
    cube.Children.Add(CreateTriangleModel(p7, p6, p5));
    cube.Children.Add(CreateTriangleModel(p7, p5, p4));
    //bottom side triangles
    cube.Children.Add(CreateTriangleModel(p2, p3, p0));
    cube.Children.Add(CreateTriangleModel(p2, p0, p1));
    
    ModelVisual3D model = new ModelVisual3D();
    model.Content = cube;
    this.mainViewport.Children.Add(model);
} 



코드를 실행하면, 아래와 같은 정육면체가 나타납니다.







Clearing the Viewport

"Simple" 버튼과 "Cube" 버튼을 순차적으로 눌러 보시면, 기본적으로 정육면체가 초기 삼각형 윗면의 오른쪽 부분에 생길 것입니다. 그리 중요한건 아니지만, 항상 깨끗한 상태로 유지하는 것은 귀찮은 일 입니다. 광원을 제외한 나머지 Viewport3D 를 지우기 위한 아래 메소드를 비하인드 코드에 추가합니다.


private void ClearViewport()
{
    ModelVisual3D m;
    for (int i = mainViewport.Children.Count - 1; i >= 0; i--)
    {
        m = (ModelVisual3D)mainViewport.Children[i];
        if (m.Content is DirectionalLight == false)
            mainViewport.Children.Remove(m);
    }
} 



깨끗이 지우고 싶을 때(예를 들어 무언가를 추가하기 위해서 버튼을 매번 누를 때), ClearViewport() 를 호출만 하면 됩니다.




Controlling the Camera

좀 더 쉽게 카메라를 이동시키고, 카메라가 XAML 의 코드 어디에 위치하고 있는지 매번 찾을 필요가 없도록 코드를 수정해 봅시다. XMAL 에서 앞서 우리가 추가한 버튼들 위에 아래의 TextBlock 와 TextBox 코드를 추가합니다.


<TextBlock Text="Camera X Position:"/>
<TextBox Name="cameraPositionXTextBox" MaxLength="5"    HorizontalAlignment="Left" Text="9"/>
<TextBlock Text="Camera Y Position:"/>
<TextBox Name="cameraPositionYTextBox" MaxLength="5"    HorizontalAlignment="Left" Text="8"/>
<TextBlock Text="Camera Z Position:"/>
<TextBox Name="cameraPositionZTextBox" MaxLength="5"    HorizontalAlignment="Left" Text="10"/>
<Separator/>
<TextBlock Text="Look Direction X:"/>
<TextBox Name="lookAtXTextBox" MaxLength="5"    HorizontalAlignment="Left" Text="-9"/>
<TextBlock Text="Look Direction Y:"/>
<TextBox Name="lookAtYTextBox" MaxLength="5"    HorizontalAlignment="Left" Text="-8"/>
<TextBlock Text="Look Direction Z:"/>
<TextBox Name="lookAtZTextBox" MaxLength="5"    HorizontalAlignment="Left" Text="-10"/>
<Separator/>
<!-- buttons -->
<Button Name="simpleButton" Click="simpleButtonClick">Simple</Button>
<Button Name="cubeButton" Click="cubeButtonClick">Cube</Button>



이제 아래에 있는 SetCamera() 라는 메소드를 비하인드 코드에 새로 추가합니다.


private void SetCamera()
{
    PerspectiveCamera camera = (PerspectiveCamera)mainViewport.Camera;
    Point3D position = new Point3D(
        Convert.ToDouble(cameraPositionXTextBox.Text),
        Convert.ToDouble(cameraPositionYTextBox.Text),
        Convert.ToDouble(cameraPositionZTextBox.Text)
    );
    Vector3D lookDirection = new Vector3D(
        Convert.ToDouble(lookAtXTextBox.Text),
        Convert.ToDouble(lookAtYTextBox.Text),
        Convert.ToDouble(lookAtZTextBox.Text)
    );
    camera.Position = position;
    camera.LookDirection = lookDirection;
} 



이 코드는 텍스트 박스에 입력된 포인트를 가져와, 이 값들로 Point3D 를 생성하고, 카메라의 Position, LookDirection 프로퍼티 값에 대입합니다. 코드를 추가시킨 후에, 버튼을 클릭하거나 Viewport3D 에 무언가를 추가시킬 때, SetCamera() 를 호출하도록 만듭니다.

이제 카메라를 돌려서, 사물의 다른 모습을 볼 수 있습니다.








The ScreenSpaceLines3D Class

WPF 베타버젼 시절에는 ScreenSpaceLines3D 라는 클래스가 있었습니다. ScreenSpaceLines3D 는 3D 공간에서 선을 그리는데 사용했습니다. 불행하게도 .NET 3.0 Framework 에서는 ScreenSpaceLines3D 가 없어졌습니다. 그리고 이와 같은 기능을 하는 클래스도 없습니다.

하지만 다행이도, Dan Lehenbauer 는 .NET 3.0 과 WPF 에서 사용할 수 있는 새로운 ScreenSpaceLines3D 클래스를 만들었습니다. 이 튜토리얼 마지막에서 이 클래스를 사용할 것입니다. 그러므로 this 3DTools project on CodePlex.com 에서 그가 만든 코드를 다운로드 받으셔야 합니다. 파일의 압축을 풀고 솔루션을 빌드한 뒤, 여러분의 WPF 프로젝트에서 3DTools dll 을 참조하도록 합니다. 그리고 나서 아래의 using 문을 여러분 코드에 추가시킵니다.


using _3DTools; 



이제 ScreenSpaceLines3D 를 이용하면, 법선과 wireframe 을 가지고 재미있는 것을 할 수 있습니다. 하지만 아직은 아닙니다. 조금만 있다가 살펴 봅시다.




Add Normals to the Rendered Model

법선들이 어디에 있고, 가리키는 방향이 어디인지 정확히 볼 수 있다면, 재밌을 것 같지 않나요? 맞습니다. 반드시 필요할까요? 경우에 따라서 다릅니다. 훨씬 더 복잡한 표면(이 튜토리얼 마지막 부분처럼)을 만들 때, 매우 유용합니다. ScreenSpaceLines3D 클래스를 사용하면 쉽게 할 수 있습니다. 우리가 해 볼 것은 triangle index 에서 index 법선 방향으로 짧게 뻗은 선을 그리는 것입니다. 이것을 하기 위해서, XMAL에서 컨트롤들이 들어있는 패털에 아래 코드를 추가시킵니다.


<Separator/>

<CheckBox Name="normalsCheckBox">Show Normals</CheckBox>
<TextBlock Text="Normal Size:"/>
<TextBox Name="normalSizeTextBox" Text="1"/>



이 XAML 코드는 법선을 표시할 것인지, 말 것인지를 선택할 수 있는 체크박스를 만들고, 법선의 크기를 얼마로 할 것인지 설정할 수 있도록 해 봅니다. 다음으로, 비하인드 코드에 아래 메소드를 추가 시킵니다.


private Model3DGroup BuildNormals(
    Point3D p0,
    Point3D p1,
    Point3D p2,
    Vector3D normal)
{
    Model3DGroup normalGroup = new Model3DGroup();
    Point3D p;
    ScreenSpaceLines3D normal0Wire = new ScreenSpaceLines3D();
    ScreenSpaceLines3D normal1Wire = new ScreenSpaceLines3D();
    ScreenSpaceLines3D normal2Wire = new ScreenSpaceLines3D();
    Color c = Colors.Blue;
    int width = 1;
    normal0Wire.Thickness = width;
    normal0Wire.Color = c;
    normal1Wire.Thickness = width;
    normal1Wire.Color = c;
    normal2Wire.Thickness = width;
    normal2Wire.Color = c;
    double num = 1;
    double mult = .01;
    double denom = mult * Convert.ToDouble(normalSizeTextBox.Text);
    double factor = num / denom;
    p = Vector3D.Add(Vector3D.Divide(normal, factor), p0);
    normal0Wire.Points.Add(p0);
    normal0Wire.Points.Add(p);
    p = Vector3D.Add(Vector3D.Divide(normal, factor), p1);
    normal1Wire.Points.Add(p1);
    normal1Wire.Points.Add(p);
    p = Vector3D.Add(Vector3D.Divide(normal, factor), p2);
    normal2Wire.Points.Add(p2);
    normal2Wire.Points.Add(p);

    //Normal wires are not models, so we can't
    //add them to the normal group.  Just add them
    //to the viewport for now...
    this.mainViewport.Children.Add(normal0Wire);
    this.mainViewport.Children.Add(normal1Wire);
    this.mainViewport.Children.Add(normal2Wire);

    return normalGroup;
}
 


이 메소드는 세 점과(삼각형의 세 점) 주어진 법선 벡터를 파라미터로 받습니다. 그러면 점에서 법선 방향으로 선을 그리고, 그것들을 Model3DGroup 에 추가시킵니다. Vector3D 구조체의 Divide 메소드를 사용해서 normalSizeTextBox 에 입력한 값으로 법선의 크기를 조절합니다. 매우 간단합니다.

CreateTriangleModel 메소드 끝부분에 다음 코드를 추가합니다.


<font face="Courier New">if (normalsCheckBox.IsChecked == true) {
    group.Children.Add(BuildNormals(p0, p1, p2, normal));
}</font>




법선으로 정육면체를 만들고나면, 아래와 같이 보여질 것입니다.







Building a Topography

이제 여러분은 저의 부족함이 많은 3D 이론의 기초와 WPF 에서 mesh 가 어떻게 동작하는지 아셨을 것입니다. 그러면 이번에는 이렇게 수직이 아닌 표면을 만들어 봅시다. 예를들어 산 꼭대기와 골짜기 같은 지형을 만든다고 생각해 봅시다. 공간에서 서로 모여서 장엄한 춤을 추는 듯한 벡터들로 연결된 점이 있는 표면은 여러분이 키보드에 침을 질질 흘릴정도로 멋진 광경을 만들 것입니다.

첫째, 컨트롤 패널에 버튼을 하나 추가합니다. 모델에 지형을 만들고 추가하는데 이 버튼을 사용할 것입니다.


<Button Name="topographyButton" Click="topographyButtonClick">
    Topography
</Button> 



본질적으로, Y축으로 꼭대기와 골짜기를 가지고 X-Z 평면상에 쭉 뻗은 지형을 만들 생각입니다. 10x10 크기의 표면을 만들것입니다. mesh 는 마치 칸이 삼각형 두개로 나누어져 있는 사각형이 모여있는 체크 말판처럼 보일 것입니다. 이중 반복문(하나는 X 축, 하나는 X 축)과 난수 발생을 이용해서 지형의 점들을 만들 수 있습니다.


private Point3D[] GetRandomTopographyPoints()
{
    //create a 10x10 topography.
    Point3D[] points = new Point3D[100];
    Random r = new Random();
    double y;
    double denom = 1000;
    int count = 0;
    for (int z = 0; z < 10; z++)
    {
        for (int x = 0; x < 10; x++)
        {
            System.Threading.Thread.Sleep(1);
            y = Convert.ToDouble(r.Next(1, 999)) / denom;
            points[count] = new Point3D(x, y, z);
            count += 1;
        }
    }
    return points;
} 



점의 배열을 얻게 되면, 우리의 오랜 친구인 CreateTriangleModel() 메소드를 사용해서, 삼각형들에 점들을 로드할 또 다른 간단한 반복문의 집합을 사용할 수 있습니다. XMAL 에 topographyButton 라는 이름으로 패널에 새로운 버튼을  추가하고, 비하인드 코드에 topographyButtonClick  라는 이름으로 이벤트 핸들러를 만듭니다.


private void topographyButtonClick(object sender, RoutedEventArgs e)
{
    ClearViewport();
    SetCamera();
    Model3DGroup topography = new Model3DGroup();
    Point3D[] points = GetRandomTopographyPoints();
    for (int z = 0; z <= 80; z = z + 10)
    {
        for (int x = 0; x < 9; x++)
        {
            topography.Children.Add(
                CreateTriangleModel(
                        points[x + z], 
                        points[x + z + 10], 
                        points[x + z + 1])
            );
            topography.Children.Add(
                CreateTriangleModel(
                        points[x + z + 1], 
                        points[x + z + 10], 
                        points[x + z + 11])
            );
        }
    }
    ModelVisual3D model = new ModelVisual3D();
    model.Content = topography;
    this.mainViewport.Children.Add(model);
} 



기본적으로, 이중 반복문은 그리드 전반에 걸쳐 지그제그로 삼각형들을 위한 점을 추가합니다. 프로그램을 실행시키면, 아래와 비슷한 모습을 볼 수 있습니다.


normals 를 on 했을 경우:








Add a Wireframe

mesh 를 시각화하는 또 다른 도구는 메시의 "wireframe" 입니다. wireframe 이란 단지 mesh 의 position 과 mesh triangle 의 변을 시각화 하여 보여주는 것 뿐 입니다. 모든 모서리와 꼭대기, 골짜기를 볼 수 있도록 해 줌으로써, 표면을 좀 더 구체적으로 표현해 줍니다. wirefame 을 그리기 위해서 ScreenSpaceLines3D 클래스를 다시 한번 사용할 것입니다. 우리가 해야 하는 것은 CreateTriangleModel() 메소드를 상속받는 것 뿐 입니다.

먼저, 프로그램의 컨트롤 패널에 XAML 을 조금 더 추가해서, model 이 렌더링 될 때 wireframe 을 보여줄 것인지에 대한 옵션을 추가합니다.


<Separator/>
<CheckBox Name="wireframeCheckBox">Show Wireframe</CheckBox> 



다음으로 CreateTriangleModel() 메소드 끝부분에 다음 코드를 추가하여 wireframeCheckBox 컨트롤이 체크 되어 있을 때, wireframe 이 추가되도록 합니다.


if (wireframeCheckBox.IsChecked == true)
{
 ScreenSpaceLines3D wireframe = new ScreenSpaceLines3D();
        wireframe.Points.Add(p0);
        wireframe.Points.Add(p1);
        wireframe.Points.Add(p2);
        wireframe.Points.Add(p0);
        wireframe.Color = Colors.LightBlue;
        wireframe.Thickness = 3;
        
        this.mainViewport.Children.Add(wireframe);
} 



위에 코드는 단지 이미 메소드 안에서 정의한 점들을 연결하는 것 뿐입니다. 그러면 path 는 viewport 안에 추가됩니다. 마지막 CreateTriangleModel() 메소드는 아래와 같습니다.


private Model3DGroup CreateTriangleModel(Point3D p0, Point3D p1, Point3D p2)
{
   MeshGeometry3D mesh = new MeshGeometry3D();
   mesh.Positions.Add(p0);
   mesh.Positions.Add(p1);
   mesh.Positions.Add(p2);
   mesh.TriangleIndices.Add(0);
   mesh.TriangleIndices.Add(1);
   mesh.TriangleIndices.Add(2);
   Vector3D normal = CalculateNormal(p0, p1, p2);
   mesh.Normals.Add(normal);
   mesh.Normals.Add(normal);
   mesh.Normals.Add(normal);
   Material material = new DiffuseMaterial(
      new SolidColorBrush(Colors.DarkKhaki));
   GeometryModel3D model = new GeometryModel3D(mesh, material);
   Model3DGroup group = new Model3DGroup();
   group.Children.Add(model);

   if (normalsCheckBox.IsChecked == true)
         group.Children.Add(BuildNormals(p0, p1, p2, normal));

   if (wireframeCheckBox.IsChecked == true)
   {
         ScreenSpaceLines3D wireframe = new ScreenSpaceLines3D();
         wireframe.Points.Add(p0);
         wireframe.Points.Add(p1);
         wireframe.Points.Add(p2);
         wireframe.Points.Add(p0);
         wireframe.Color = Colors.LightBlue;
         wireframe.Thickness = 3;
         this.mainViewport.Children.Add(wireframe);
   }

   return group;
} 



wireframe 을 model 에 추가하면, 아래처럼 됩니다.







Welcome to 3D Land!

끝났습니다. 이제 여러분은 mesh 의 블럭을 만드는 기본지식(positions, triangle indeces, 법선)과 WPF 에서 3D 객체를 만드는데 필요한 클래스들에 대해서 배웠습니다. 이제 이 원리를 이용해서, 좀 더 복잡한 3D 모양을 만들고, WPF 에서 발생되는 문제를 해결 할 수 있을 것입니다.





'Microsoft > WPF' 카테고리의 다른 글

TypeConverter  (0) 2014.05.12
XAML 의 문법구조  (0) 2014.05.12
xaml 과 cs 파일의 관계  (1) 2014.05.08
WPF 기본 동작구조  (0) 2014.05.08
Animated Image  (0) 2010.12.10