티스토리 뷰

프로그래밍

C#으로 3D 모델 출력하기

야라바 2015. 7. 17. 14:38
728x90

닷넷 환경에서 3D 모델을 표현하려고 자료를 찾다보니 의외로 공부해야 될것이 많았습니다. C#으로 3D 모델을 출력하기 위하여 공부하거나 자료를 찾아야 할 것을 정리해 보면 아래와 같습니다.

  1. 3D 모델링 도구
    *.gif, *.jpg, *.svg등을 모두 2D 그래픽이라 합니다. 평면 위에 다양한 이미지를 표현하는 것입니다. 반면에 3D 모델은 X, Y 좌표에 Z좌표를 추가해서 표현하기 때문에 일반적으로 많이 사용하는 포토샵이나 페인트닷넷, 김프 등으로는 제작할 수 없습니다. 3D 모델을 제작하여 파일로 저장하는 대표적인 도구로는 3DS MAX, Maya, Mudbox등을 들수 있으며 무료로 사용할 수 있는 오픈소스 도구로는 블렌더가 있습니다(
    3D 모델링 도구 블렌더 설치하기참조) 이러한 3D 모델링 도구를 사용하여 C#으로 화면에 출력할 3D 모델을 준비해야 합니다.

  2. 그래픽 API( application programming interfaces)
    3D 모델은 단순히 점으로 표현하는 사진이나 이미지와는 달리 그래픽 표현에 수반하는 엄청난 연산과 대용량 메모리등 시스템 자원을 많이 사용하는 기능입니다. 그래서 
    GPU(Graphics Processing Units)는 다양한 그래픽 기능을 수행하는 API를 전달받아 빠른 처리를 돕습니다. 그래픽 표현과 관련한 여러가지 기능을 API 형태로 정의해서 여러 응용 프로그램에서는 이 API를 통해서 간편하게 3D 프로그램을 작성할 수 있도록 하고 있는데 이러한 API는 크게 Direct3D와 OpenGL로 나뉩니다. Direct3D는 윈도우 기반으로 마이크로스프트에서 독점권을 가지고 있지만 OpenGL은 크로스 플랫폼으로 어떤 운영체제에서도 사용할 수 있으며 개방형이라는 차이점이 있습니다. 안드로이드에서도 OpenGL을 채용하고 있습니다. Direct3D와 OpenGL이 독립적으로 발전해 왔지만 기능은 매우 유사해서 다양한 GPU(Graphics Processing Units)에서 Direct3D와 OpenGL을 모두 지원하고 있습니다. C#으로 윈도우에서 개발하지만 어떤 API를 사용할지 결정해야 합니다.

  3. 그래픽 API 라이브러리
    C#으로 개발하는 만큼 닷넷 프레임워크에서 기본적으로 지원하는 기능이라면 별도의 라이브러리도 필요없을 것입니다. 그렇지만 Direct3d와 OpenGL 모두 닷넷 프레임워크에는 포함되어 있지 않습니다. 윈도우와 같은 회사 제품인 Direct3D의 경우에는 SDK를 별도로 배포하고 있으며 C++기반이라 그마저도 C#에서는 사용에 적절한 모양이 아닙니다. 그래서 Managed DirectX(MDX)라는 프로젝트가 있었으나 
    XNA Game Studio Express로 흡수되었다는 소식입니다. OpenGL의 경우에는 당연히 별도의 C# 라이브러리가 있었야 할것입니다. 본 포스팅에서는 OpenTK를 사용합니다.

■ OpenTK 설치

여러가지를 검토한 끝에 C#으로 3D 모델을 OpenGL을 통해서 출력하기 위해서 OpenTK라는 오픈소스 라이브러리를 사용하기로 했습니다. OpenTK는 C#으로 만들어진 OpenGL 엔진으로 아래의 링크에서 다운로드 받아 설치하면 C# 프로젝트에서 간단하게 참조하여 프로그래밍 할 수 있습니다.

http://www.opentk.com/project/opentk#downloads에서 최신 버전을 다운로드합니다.

다운로드한 최신 OpenTK 설치 파일을 다음과 같이 설치합니다.


[I Agree]로 진행합니다.


설치 폴더는 적절하게 선택하고 [Next]로 진행합니다.


위의 그림은 설치 요소 선택 과정인데 필자의 경우에는 "Source code"를 제외하고 설치했습니다.


[Finish]를 누르면 설치를 끝낼 수 있습니다. 


■ 프로젝트에서 OpenTK 참조 추가

설치를 끝내면 C# 프로젝트에서 OpenTK를 참조하여 프로그래밍을 시작할 수 있습니다. 아래의 그림과 같이 C# 프로젝트 참조에서 .NET 항목 중에 OpenTK를 찾아 추가 할 수 있습니다. OpenTK 관련 3가지 모듈을 모두 참조합니다.



■ C# 코드에서 인식할 수 있는 Wavefront 형태의 3D 모델 파일 준비

그래픽 API 사용을 위한 라이브러리를 준비했으면 준비한 3D 모델을 C#에서 인식할 수 있는 형태로 저장해야 합니다. 3D 모델을 저장한 파일 형식은 매우 다양한데 OpenTK 자체에는 이런 파일을 읽는 함수는 존재하지 않습니다. OpenTK 커뮤니티에 공개되어 있는 소스 코드 중에 Wavefront(*.obj)를 읽어서 출력하는 코드를 찾았는데 이 코드가 인식할 수 있는 파일 형태로 아래의 그림과 같이 준비한 3D 모델을 내보내기 합니다. 아래의 그림은 오픈 소스 3D 모델 저작도구인 블렌더에서 Wavefront 파일 형태로 내보내기 하는 그림입니다. 블렌더에서  파일>내보내기>Wavefront(.obj) 형식으로 저장합니다.


*.obj 파일로 내보내기 하면 아래의 그림과 같이 *.obj와 함께 *.mtl 파일이 자동 생성됩니다. C# 코드에서는 *.obj 파일만 사용합니다.


■ WinForm에 GLControl 추가

OpenTK를 사용하는 방식에는 메인 로직에서 GameWindow 클래스를 활용하는 방법과 윈도우폼의 판넬(Panel) 컨트롤 처럼 3D 모델 출력을 위한 캔버스 역할을 하는 GLControl을 사용하는 방법이 있습니다. GLControl은 레이블이나 버튼과 같은 디자인 요소처럼 비주얼스튜디오 도구상자에서 디자이너 창으로 끌어다 놓기하는 방식으로 추가할 수 있습니다. GLControl을 사용하기 위해서는 도구상자에 컨트롤을 추가해야 하는데, 비주얼스튜디오 도구상자에서 우측 마우스로 팝업 메뉴를 띄우고 항목 선택을 클릭하여 아래의 그림처럼 GLControl을 찾아 도구로 추가합니다.

GLControl을 도구상자에 추가한 다음 폼 디자이너 화면에 컨트롤을 추가하여 영역을 조정합니다.



■ C# 코드에서 Wavefront(*.obj) 형태의 3D 모델 파일 읽기, 출력하기

코드의 출처는 http://www.opentk.com/node/642입니다. 아래의 코드는 ObjMesh 클래스에서 몇가지를 약간 손본 결과입니다.

using System;
using System.Runtime.InteropServices;
using OpenTK;
using OpenTK.Graphics.OpenGL;

namespace gltest
{
public class ObjMesh
{
    public bool is_loaded = false;
    public ObjMesh(string fileName)
    {
        is_loaded = ObjMeshLoader.Load(this, fileName);
    }

    public ObjVertex[] Vertices
    {
        get { return vertices; }
        set { vertices = value; }
    }
    ObjVertex[] vertices;

    public ObjTriangle[] Triangles
    {
        get { return triangles; }
        set { triangles = value; }
    }
    ObjTriangle[] triangles;

    public ObjQuad[] Quads
    {
        get { return quads; }
        set { quads = value; }
    }
    ObjQuad[] quads;

    int verticesBufferId = 0;
    int trianglesBufferId = 0;
    int quadsBufferId = 0;

    public void Prepare()
    {
        if (!is_loaded) return;
        if (verticesBufferId == 0)
        {
            GL.GenBuffers(1, out verticesBufferId);
            GL.BindBuffer(BufferTarget.ArrayBuffer, verticesBufferId);
            GL.BufferData(BufferTarget.ArrayBuffer, (IntPtr)(vertices.Length * Marshal.SizeOf(typeof(ObjVertex))), vertices, BufferUsageHint.StaticDraw);
        }

        if (trianglesBufferId == 0)
        {
            GL.GenBuffers(1, out trianglesBufferId);
            GL.BindBuffer(BufferTarget.ElementArrayBuffer, trianglesBufferId);
            GL.BufferData(BufferTarget.ElementArrayBuffer, (IntPtr)(triangles.Length * Marshal.SizeOf(typeof(ObjTriangle))), triangles, BufferUsageHint.StaticDraw);
        }

        if (quadsBufferId == 0)
        {
            GL.GenBuffers(1, out quadsBufferId);
            GL.BindBuffer(BufferTarget.ElementArrayBuffer, quadsBufferId);
            GL.BufferData(BufferTarget.ElementArrayBuffer, (IntPtr)(quads.Length * Marshal.SizeOf(typeof(ObjQuad))), quads, BufferUsageHint.StaticDraw);
        }
    }

    public void Render()
    {
        if (!is_loaded) return;
        GL.PushClientAttrib(ClientAttribMask.ClientVertexArrayBit);
        GL.EnableClientState(ArrayCap.VertexArray);
        GL.BindBuffer(BufferTarget.ArrayBuffer, verticesBufferId);
        GL.InterleavedArrays(InterleavedArrayFormat.T2fN3fV3f, Marshal.SizeOf(typeof(ObjVertex)), IntPtr.Zero);
        if (triangles.Length > 0)
        {
            GL.BindBuffer(BufferTarget.ElementArrayBuffer, trianglesBufferId);
            GL.DrawElements(BeginMode.Triangles, triangles.Length * 3, DrawElementsType.UnsignedInt, 0);
        }

        if (quads.Length > 0)
        {
            GL.BindBuffer(BufferTarget.ElementArrayBuffer, quadsBufferId);
            GL.DrawElements(BeginMode.Quads, quads.Length * 4, DrawElementsType.UnsignedInt, 0);
        }

        GL.PopClientAttrib();
    }


    [StructLayout(LayoutKind.Sequential)]
    public struct ObjVertex
    {
        public Vector2 TexCoord;
        public Vector3 Normal;
        public Vector3 Vertex;
    }

    [StructLayout(LayoutKind.Sequential)]
    public struct ObjTriangle
    {
        public UInt32 Index0;
        public UInt32 Index1;
        public UInt32 Index2;
    }

    [StructLayout(LayoutKind.Sequential)]
    public struct ObjQuad
    {
        public UInt32 Index0;
        public UInt32 Index1;
        public UInt32 Index2;
        public UInt32 Index3;
    }
}
}


아래의 코드는 파일을 읽으면서 OpenGL 출력을 위한 배열을 준비하는 ObjMeshLoader클래스입니다. 원본 코드에서 몇가지 Exception을 처리한 결과물입니다.

using System;
using System.IO;
using System.Collections.Generic;
using OpenTK;

namespace gltest
{
public class ObjMeshLoader
{
    public static bool Load(ObjMesh mesh, string fileName)
    {
        try
        {
            using (StreamReader streamReader = new StreamReader(fileName))
            {
                Load(mesh, streamReader);
                streamReader.Close();
                return true;
            }
        }
        catch { return false; }
    }

    static char[] splitCharacters = new char[] { ' ' };

    static List<Vector3> vertices;
    static List<Vector3> normals;
    static List<Vector2> texCoords;
    static Dictionary<ObjMesh.ObjVertex, UInt32> objVerticesIndexDictionary;
    static List<ObjMesh.ObjVertex> objVertices;
    static List<ObjMesh.ObjTriangle> objTriangles;
    static List<ObjMesh.ObjQuad> objQuads;

    static void Load(ObjMesh mesh, TextReader textReader)
    {
        vertices = new List<Vector3>();
        normals = new List<Vector3>();
        texCoords = new List<Vector2>();
        objVerticesIndexDictionary = new Dictionary<ObjMesh.ObjVertex, UInt32>();
        objVertices = new List<ObjMesh.ObjVertex>();
        objTriangles = new List<ObjMesh.ObjTriangle>();
        objQuads = new List<ObjMesh.ObjQuad>();

        string line;
        while ((line = textReader.ReadLine()) != null)
        {
            line = line.Trim(splitCharacters);
            line = line.Replace("  ", " ");

            string[] parameters = line.Split(splitCharacters);

            switch (parameters[0])
            {
                case "p": // Point
                    break;

                case "v": // Vertex
                    float x = float.Parse(parameters[1]);
                    float y = float.Parse(parameters[2]);
                    float z = float.Parse(parameters[3]);
                    vertices.Add(new Vector3(x, y, z));
                    break;

                case "vt": // TexCoord
                    float u = float.Parse(parameters[1]);
                    float v = float.Parse(parameters[2]);
                    texCoords.Add(new Vector2(u, v));
                    break;

                case "vn": // Normal
                    float nx = float.Parse(parameters[1]);
                    float ny = float.Parse(parameters[2]);
                    float nz = float.Parse(parameters[3]);
                    normals.Add(new Vector3(nx, ny, nz));
                    break;

                case "f":
                    switch (parameters.Length)
                    {
                        case 4:
                            ObjMesh.ObjTriangle objTriangle = new ObjMesh.ObjTriangle();
                            objTriangle.Index0 = ParseFaceParameter(parameters[1]);
                            objTriangle.Index1 = ParseFaceParameter(parameters[2]);
                            objTriangle.Index2 = ParseFaceParameter(parameters[3]);
                            objTriangles.Add(objTriangle);
                            break;

                        case 5:
                            ObjMesh.ObjQuad objQuad = new ObjMesh.ObjQuad();
                            objQuad.Index0 = ParseFaceParameter(parameters[1]);
                            objQuad.Index1 = ParseFaceParameter(parameters[2]);
                            objQuad.Index2 = ParseFaceParameter(parameters[3]);
                            objQuad.Index3 = ParseFaceParameter(parameters[4]);
                            objQuads.Add(objQuad);
                            break;
                    }
                    break;
            }
        }

        mesh.Vertices = objVertices.ToArray();
        mesh.Triangles = objTriangles.ToArray();
        mesh.Quads = objQuads.ToArray();

        objVerticesIndexDictionary = null;
        vertices = null;
        normals = null;
        texCoords = null;
        objVertices = null;
        objTriangles = null;
        objQuads = null;
    }

    static char[] faceParamaterSplitter = new char[] { '/' };
    static UInt32 ParseFaceParameter(string faceParameter)
    {
        Vector3 vertex = new Vector3();
        Vector2 texCoord = new Vector2(0, 0);
        Vector3 normal = new Vector3();

        string[] parameters = faceParameter.Split(faceParamaterSplitter, StringSplitOptions.None);

        int vertexIndex = int.Parse(parameters[0]);
        if (vertexIndex < 0) vertexIndex = vertices.Count + vertexIndex;
        else vertexIndex = vertexIndex - 1;
        vertex = vertices[vertexIndex];

        if (parameters.Length > 1 && texCoords.Count > 0 && parameters[1].Length > 0)
        {
            int texCoordIndex = int.Parse(parameters[1]);
            if (texCoordIndex < 0) texCoordIndex = texCoords.Count + texCoordIndex;
            else texCoordIndex = texCoordIndex - 1;
            texCoord = texCoords[texCoordIndex];
        }

        if (parameters.Length > 2 && normals.Count > 0)
        {
            int normalIndex = int.Parse(parameters[2]);
            if (normalIndex < 0) normalIndex = normals.Count + normalIndex;
            else normalIndex = normalIndex - 1;
            normal = normals[normalIndex];
        }

        return FindOrAddObjVertex(ref vertex, ref texCoord, ref normal);
    }

    static UInt32 FindOrAddObjVertex(ref Vector3 vertex, ref Vector2 texCoord, ref Vector3 normal)
    {
        ObjMesh.ObjVertex newObjVertex = new ObjMesh.ObjVertex();
        newObjVertex.Vertex = vertex;
        newObjVertex.TexCoord = texCoord;
        newObjVertex.Normal = normal;

        UInt32 index;
        if (objVerticesIndexDictionary.TryGetValue(newObjVertex, out index))
        {
            return index;
        }
        else
        {
            objVertices.Add(newObjVertex);
            objVerticesIndexDictionary[newObjVertex] = (UInt32)(objVertices.Count - 1);
            return (UInt32)(objVertices.Count - 1);
        }
    }
}
}


위의 *obj 모델 파일을 읽어서 렌더링하는 코드가 준비되었으면 윈도우 폼에서 해당 코드를 삽입하여 실행시킵니다. 

using System;
using System.IO;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Windows.Forms;
using System.Data.SqlClient;
using System.Runtime.InteropServices;
using OpenTK;
using OpenTK.Graphics.OpenGL;

namespace gltest
{
    public partial class GLTest : Form
    {
        ushort uigrp = 0;
        Matrix4 modelview, projection;
        ObjMesh mesh1 = null;
        const bool HighQuality = true;

        public GLTest()
        {
            InitializeComponent();
        }

        private void glControl1_Load(object sender, EventArgs e)
        {
            glControl1.MouseMove += new MouseEventHandler(glControl_MouseMove);
            glControl1.MouseWheel += new MouseEventHandler(glControl_MouseWheel);

            GL.ClearColor(Color.DarkSlateGray);
            GL.Color3(1f, 1f, 1f); // Points Color
            SetupViewport();
        }

        private void SetupViewport()
        {
            if (this.WindowState == FormWindowState.Minimized) return;
            glControl1.Width = this.Width - 32;
            glControl1.Height = this.Height - 80;
            GL.MatrixMode(MatrixMode.Projection);
            //GL.LoadIdentity();
            GL.Ortho(0, glControl1.Width, 0, glControl1.Height, -1, 1); 
            GL.Viewport(0, 0, glControl1.Width, glControl1.Height); 
            GL.Enable(EnableCap.DepthTest);

            // Improve visual quality at the expense of performance
            if (HighQuality)
            {
                int max_size;
                GL.GetInteger(GetPName.PointSizeMax, out max_size);
                GL.Enable(EnableCap.PointSmooth);
            }

            mesh1 = new ObjMesh("C:\\tmp\\tele.obj");
            mesh1.Prepare();

            float aspect_ratio = this.Width / (float)this.Height;
            projection = Matrix4.CreatePerspectiveFieldOfView(MathHelper.PiOver4, aspect_ratio, 1, 1024);
            GL.MatrixMode(MatrixMode.Projection);
            GL.LoadMatrix(ref projection);

            //Set Lighting
            float[] mat_specular = { 1.0f, 1.0f, 1.0f, 1.0f };
            float[] mat_shininess = { 30.0f };
            float[] light_position = { 1.0f, 1.0f, 1.0f, 0.0f };
            float[] light_ambient = { 0.5f, 0.5f, 0.5f, 1.0f };

            GL.ClearColor(0.0f, 0.0f, 0.0f, 0.0f);
            GL.ShadeModel(ShadingModel.Smooth);

            GL.Material(MaterialFace.Front, MaterialParameter.Specular, mat_specular);
            GL.Material(MaterialFace.Front, MaterialParameter.Shininess, mat_shininess);
            GL.Light(LightName.Light0, LightParameter.Position, light_position);
            GL.Light(LightName.Light0, LightParameter.Ambient, light_ambient);
            GL.Light(LightName.Light0, LightParameter.Diffuse, mat_specular);

            GL.Enable(EnableCap.Lighting);
            GL.Enable(EnableCap.Light0);
            GL.Enable(EnableCap.DepthTest);
            GL.Enable(EnableCap.ColorMaterial);
            GL.Enable(EnableCap.CullFace);
        }

        
        private void glControl1_Paint(object sender, PaintEventArgs e)
        {
            GL.Clear(ClearBufferMask.ColorBufferBit |
                     ClearBufferMask.DepthBufferBit |
                     ClearBufferMask.StencilBufferBit);

            if (HighQuality)
            {
                GL.PointParameter(PointParameterName.PointDistanceAttenuation,
                    new float[] { 0, 0, (float)Math.Pow(1 / (projection.M11 * Width / 2), 2) });
            }

            modelview = Matrix4.LookAt(0f, 20f, zoomFactor, 0, 0, 0, 0.0f, 1.0f, 0.0f);
            var aspect_ratio = Width / (float)Height;
            projection = Matrix4.CreatePerspectiveFieldOfView(MathHelper.PiOver6, aspect_ratio, 1, 256);

            GL.MatrixMode(MatrixMode.Projection);
            GL.LoadMatrix(ref projection);
            GL.MatrixMode(MatrixMode.Modelview);
            GL.LoadMatrix(ref modelview);
            GL.Rotate(angleY, 1.0f, 0, 0);
            GL.Rotate(angleX, 0, 1.0f, 0);

            // draw a VBO:
            mesh1.Render();

            glControl1.SwapBuffers();
        }

        #region GLControl. Mouse event handlers
        private int _mouseStartX = 0;
        private int _mouseStartY = 0;
        private float angleX = 0;
        private float angleY = 0;
        private float panX = 0;
        private float panY = 0;

        private void glControl_MouseMove(object sender, MouseEventArgs e)
        {
            if (e.Button == MouseButtons.Right)
            {
                angleX += (e.X - _mouseStartX);
                angleY -= (e.Y - _mouseStartY);

                this.Cursor = Cursors.Cross;

                glControl1.Invalidate();
            }
            if (e.Button == MouseButtons.Left)
            {
                panX += (e.X - _mouseStartX);
                panY -= (e.Y - _mouseStartY);
                GL.Viewport((int)panX, (int)panY, glControl1.Width, glControl1.Height); // Use all of the glControl painting area
                this.Cursor = Cursors.Hand;
                glControl1.Invalidate();
            }
            _mouseStartX = e.X;
            _mouseStartY = e.Y;
        }

        float zoomFactor = 1;
        private void glControl_MouseWheel(object sender, MouseEventArgs e)
        {
            if (e.Delta > 0) zoomFactor += 7f;
            else zoomFactor -= 7f;
            glControl1.Invalidate();
        }
        #endregion    

        private void glControl1_MouseUp(object sender, MouseEventArgs e)
        {
            this.Cursor = Cursors.Default;
        }
        
    }

} 

앞서 디자이너 창에 추가한 GLControl에 대해서 Load, Paint, MouseMove, MouseWheel, MouseUp 이벤트를 처리하는 로직을 추가하는 방식으로 실행하면 좌측 마우스로 움직이면 모델을 이동시키는 동작을 수행하고 우측 마우스로 움직이면 회전하는 동작을 시연해 볼 수 있습니다. 아래의 그림은 예제 모델의 실행 결과입니다. 복잡한 모델인 경우 파일을 분석하는데도 많은 시간이 걸리므로 주의하셔야 합니다.



아래의 그림은 마우스를 움직여 각도를 다르게 표시한 결과입니다.


위에 첨부한 로직에는 기본적인 조명을 비추는 설정도 포함되어 있습니다. 조명이 있어야 3D 모델에 대한 정확한 표현을 확인할 수 있습니다.

728x90
댓글
최근에 올라온 글
최근에 달린 댓글
«   2024/04   »
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
글 보관함