OpenGL

OpenGL을 사용하여 3D 모델을 로드하여 마우스로 회전시켜보는 Python 예제

webnautes 2025. 1. 3. 22:33
반응형

OpenGL을 사용하여 3D 모델을 로드하여 화면에 보여주고 마우스 드래그로 회전시켜보는 예제 코드입니다.

소프트웨어적으로 구현하니 너무 느려서 NVidia 그래픽카드 GPU를 사용하도록 했습니다.

 

2024. 1. 3 최초작성

 



실행결과입니다. 마우스 드래그로 모델을 좌우상하로 회전시킬수 있습니다. 하지만 아직 어색합니다. 그리고 모델 문제인지 구현한 코드 문제인지 약간 깨져 보입니다. 

 

 

 




그래서 다음 링크에 있는 다른 모델을 테스트해봤습니다. 깨짐이 없어 고양이 모델의 문제로 보입니다.  좌우상하 이동이 고양이에 비해 어색합니다. 

 

 https://free3d.com/3d-model/tree-74556.html 

 

 





Ubuntu 22.04에 설치된 miniconda를 사용한 파이썬 개발 환경에서 테스트를 진행했습니다.

 

Visual Studio Code와 Miniconda를 사용한 Python 개발 환경 만들기( Windows, Ubuntu, WSL2)

https://webnautes.tistory.com/1842 



파이썬 가상환경을 생성하고 활성화합니다.

$ conda create -n opengl python=3.10

$ conda activate opengl



필요한 패키지를 설치합니다.

 

$ pip install PyOpenGL PyOpenGL-accelerate pygame



다음 패키지를 추가로 설치해야 합니다.

 

$ sudo apt-get install -y libgl1-mesa-dev libgl1-mesa-glx mesa-utils libgl1-mesa-dri

$ sudo apt-get install -y xvfb x11-xserver-utils

$ sudo apt-get install -y python3-opengl



아래 링크에서 고양이 모델을 다운로드합니다. 다른 모델을 다운로드 해도 됩니다. 

https://free3d.com/3d-model/cat-v1--522281.html 

 

코드에서 지원하는 모델인 확장자가 obj인 파일을 복사해둡니다.



테스트에 사용한 코드입니다.

 

import os
os.environ['__NV_PRIME_RENDER_OFFLOAD'] = '1'
os.environ['__GLX_VENDOR_LIBRARY_NAME'] = 'nvidia'
import numpy as np
from OpenGL.GL import *
from OpenGL.GL import shaders
from OpenGL.GLU import *
import pygame

# 버텍스 쉐이더
VERTEX_SHADER = """
#version 330 core
layout(location = 0) in vec3 position;
layout(location = 1) in vec3 normal;

uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;

out vec3 FragPos;
out vec3 Normal;

void main()
{
    FragPos = vec3(model * vec4(position, 1.0));
    Normal = mat3(transpose(inverse(model))) * normal;
    gl_Position = projection * view * model * vec4(position, 1.0);
}
"""

# 프래그먼트 쉐이더 수정
FRAGMENT_SHADER = """
#version 330 core
in vec3 FragPos;
in vec3 Normal;

uniform vec3 lightPos;
uniform vec3 viewPos;
uniform vec3 lightColor;
uniform vec3 objectColor;

out vec4 FragColor;

void main()
{
    // 주변광 세기 증가
    float ambientStrength = 0.4;
    vec3 ambient = ambientStrength * lightColor;
   
    // 분산광 (디퓨즈)
    vec3 norm = normalize(Normal);
    vec3 lightDir = normalize(lightPos - FragPos);
    float diff = max(dot(norm, lightDir), 0.0);
    vec3 diffuse = diff * lightColor;
   
    // 반사광 (스페큘러) 추가
    float specularStrength = 0.5;
    vec3 viewDir = normalize(viewPos - FragPos);
    vec3 reflectDir = reflect(-lightDir, norm);
    float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32);
    vec3 specular = specularStrength * spec * lightColor;
   
    vec3 result = (ambient + diffuse + specular) * objectColor;
    FragColor = vec4(result, 1.0);
}
"""

class ModernModelViewer:
    def __init__(self, model_path):
        pygame.init()
       
        # OpenGL 설정
        pygame.display.gl_set_attribute(pygame.GL_MULTISAMPLEBUFFERS, 1)
        pygame.display.gl_set_attribute(pygame.GL_MULTISAMPLESAMPLES, 4)
        pygame.display.gl_set_attribute(pygame.GL_DEPTH_SIZE, 24)
        pygame.display.gl_set_attribute(pygame.GL_STENCIL_SIZE, 8)
       
        self.display = pygame.display.set_mode((800, 600),
                                            pygame.OPENGL |
                                            pygame.DOUBLEBUF |
                                            pygame.HWSURFACE)
        pygame.display.set_caption("3D Model Viewer (Modern OpenGL)")
       
        # 모델 데이터 로드
        self.vertices, self.faces, self.normals = self.load_obj(model_path)
           
        # 변환 행렬 초기화
        self.model_matrix = np.identity(4, dtype=np.float32)
        self.view_matrix = np.identity(4, dtype=np.float32)
        self.projection_matrix = self.perspective(45, 800/600, 0.1, 100.0)
       
        # 카메라/뷰어 상태
        self.camera_pos = np.array([0.0, 2.0, 5.0], dtype=np.float32)  # z축 거리를 늘리고 y축으로 올립니다
        self.rotation = np.array([30.0, 0.0, 0.0])  # x축으로 30도 회전하여 모델을 내려다보게 합니다
        self.scale = 1.0
       
        # 마우스 제어 관련 속성 추가
        self.dragging = False
        self.prev_mouse_pos = None
        self.mouse_sensitivity = 0.8
       
        # 쉐이더 프로그램 생성
        self.shader = self.create_shader_program()
       
        # VAO, VBO 설정
        self.setup_vertex_buffer()
       
       
        # FPS 제어
        self.clock = pygame.time.Clock()
        self.target_fps = 144

    def perspective(self, fovy, aspect, near, far):
        """원근 투영 행렬 생성"""
        f = 1.0 / np.tan(np.radians(fovy) / 2.0)
        return np.array([
            [f/aspect, 0.0, 0.0, 0.0],
            [0.0, f, 0.0, 0.0],
            [0.0, 0.0, (far+near)/(near-far), 2*far*near/(near-far)],
            [0.0, 0.0, -1.0, 0.0]
        ], dtype=np.float32)

    def create_shader_program(self):
        """쉐이더 프로그램 컴파일 및 링크"""
        try:
            vertex_shader = shaders.compileShader(VERTEX_SHADER, GL_VERTEX_SHADER)
            print("Vertex shader compiled successfully")
            fragment_shader = shaders.compileShader(FRAGMENT_SHADER, GL_FRAGMENT_SHADER)
            print("Fragment shader compiled successfully")
           
            shader_program = shaders.compileProgram(vertex_shader, fragment_shader)
            print("Shader program linked successfully")
           
            # 쉐이더 프로그램 검증
            glValidateProgram(shader_program)
            validation_status = glGetProgramiv(shader_program, GL_VALIDATE_STATUS)
            print(f"Shader program validation status: {validation_status}")
            if validation_status != GL_TRUE:
                print(glGetProgramInfoLog(shader_program))
               
            return shader_program
        except Exception as e:
            print(f"Error creating shader program: {str(e)}")
            raise


    def load_obj(self, filename):
        """OBJ 파일 로드 및 법선 벡터 계산"""
        vertices = []
        faces = []
        normals = []
       
        try:
            with open(filename, 'r') as f:
                for line in f:
                    if line.startswith('v '):
                        parts = line.split()
                        if len(parts) >= 4# v x y z 형식인지 확인
                            vertices.append([float(parts[1]), float(parts[2]), float(parts[3])])
                    elif line.startswith('f '):
                        face = []
                        parts = line.split()[1:]  # 첫 번째 'f' 제외
                        for part in parts:
                            # 빈 줄이나 잘못된 형식 건너뛰기
                            if '/' in part:
                                vertex_index = part.split('/')[0]
                                if vertex_index:
                                    face.append(int(vertex_index) - 1)
                        if len(face) >= 3# 최소 3개의 유효한 인덱스가 있는지 확인
                            faces.append(face)
           
            if not vertices or not faces:
                raise ValueError("No valid vertices or faces found in the file")
               
            vertices = np.array(vertices, dtype=np.float32)
            faces = np.array(faces, dtype=np.int32)
           
            # 모델 중심을 원점으로 이동
            center = (vertices.max(axis=0) + vertices.min(axis=0)) / 2
            vertices -= center
           
            # 모델 크기를 1로 정규화
            scale = np.max(np.abs(vertices))
            if scale != 0:
                vertices /= scale
           
            # 버텍스 법선 계산
            normals = np.zeros_like(vertices)
            for face in faces:
                if len(face) >= 3# 안전 검사 추가
                    v1, v2, v3 = vertices[face[0]], vertices[face[1]], vertices[face[2]]
                    normal = np.cross(v2 - v1, v3 - v1)
                    norm = np.linalg.norm(normal)
                    if norm > 1e-6:
                        normal = normal / norm
                        normals[face] += normal
           
            # 법선 벡터 정규화
            norms = np.linalg.norm(normals, axis=1)
            norms[norms == 0] = 1
            normals = normals / norms[:, np.newaxis]
           
            print(f"Loaded model with {len(vertices)} vertices and {len(faces)} faces")
            return vertices, faces, normals
           
        except Exception as e:
            print(f"Error loading OBJ file: {str(e)}")
            print(f"Current working directory: {os.getcwd()}")
            print(f"Trying to load file: {filename}")
            return np.array([]), np.array([]), np.array([])


    def update_matrices(self):
        """변환 행렬 업데이트"""
        # 모델 행렬
        model = np.identity(4, dtype=np.float32)
       
        # 회전 적용
        rx = self.rotation_matrix(self.rotation[0], [1, 0, 0])
        ry = self.rotation_matrix(self.rotation[1], [0, 1, 0])
        rz = self.rotation_matrix(self.rotation[2], [0, 0, 1])
       
        model = model.dot(rx).dot(ry).dot(rz)
        self.model_matrix = model
       
        # 뷰 행렬
        target = np.array([0.0, 0.0, 0.0])
        up = np.array([0.0, 1.0, 0.0])
        self.view_matrix = self.look_at(self.camera_pos, target, up)

    def rotation_matrix(self, angle, axis):
        """회전 행렬 생성"""
        angle = np.radians(angle)
        axis = np.array(axis) / np.linalg.norm(axis)
        a = np.cos(angle / 2.0)
        b, c, d = -axis * np.sin(angle / 2.0)
       
        return np.array([
            [a*a+b*b-c*c-d*d, 2*(b*c-a*d), 2*(b*d+a*c), 0],
            [2*(b*c+a*d), a*a+c*c-b*b-d*d, 2*(c*d-a*b), 0],
            [2*(b*d-a*c), 2*(c*d+a*b), a*a+d*d-b*b-c*c, 0],
            [0, 0, 0, 1]
        ], dtype=np.float32)

    def look_at(self, eye, target, up):
        """뷰 행렬 생성"""
        eye = np.array(eye, dtype=np.float32)
        target = np.array(target, dtype=np.float32)
        up = np.array(up, dtype=np.float32)

        # z axis = eye - target (normalized)
        z = eye - target
        z = z / np.linalg.norm(z)

        # x axis = up cross z (normalized)
        x = np.cross(up, z)
        if np.sum(x) == 0# 'up' 벡터와 'z' 벡터가 평행한 경우 처리
            x = np.array([1.0, 0.0, 0.0], dtype=np.float32)
        else:
            x = x / np.linalg.norm(x)

        # y axis = z cross x (normalized)
        y = np.cross(z, x)
        y = y / np.linalg.norm(y)

        translation = np.array([
            [1, 0, 0, -eye[0]],
            [0, 1, 0, -eye[1]],
            [0, 0, 1, -eye[2]],
            [0, 0, 0, 1]
        ], dtype=np.float32)

        rotation = np.array([
            [x[0], x[1], x[2], 0],
            [y[0], y[1], y[2], 0],
            [z[0], z[1], z[2], 0],
            [0, 0, 0, 1]
        ], dtype=np.float32)

        return rotation.dot(translation)

    def setup_vertex_buffer(self):
        """VAO와 VBO 설정"""
        try:
            self.vao = glGenVertexArrays(1)
            glBindVertexArray(self.vao)
           
            # 모든 버텍스 데이터를 하나의 배열로 준비
            vertices = []
            for face in self.faces:
                for vertex_id in face:
                    vertex = self.vertices[vertex_id]
                    normal = self.normals[vertex_id]
                    vertices.extend([*vertex, *normal])  # [x,y,z, nx,ny,nz]
           
            vertices = np.array(vertices, dtype=np.float32)
            self.vertex_count = len(self.faces) * 3
           
            # VBO 생성 및 데이터 업로드
            self.vbo = glGenBuffers(1)
            glBindBuffer(GL_ARRAY_BUFFER, self.vbo)
            glBufferData(GL_ARRAY_BUFFER, vertices.nbytes, vertices, GL_STATIC_DRAW)
           
            # vertex positions
            glEnableVertexAttribArray(0)
            glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 24, None)
           
            # vertex normals
            glEnableVertexAttribArray(1)
            glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 24, ctypes.c_void_p(12))
           
            # 디버그 출력
            print(f"Vertex count: {self.vertex_count}")
            print(f"Vertex data size: {vertices.nbytes} bytes")

        except Exception as e:
            print(f"Error in setup_vertex_buffer: {str(e)}")
            raise

    def render_model(self):
        try:
            glClearColor(0.1, 0.1, 0.1, 1.0)
            glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
           
            glEnable(GL_DEPTH_TEST)
            glEnable(GL_MULTISAMPLE)
            glEnable(GL_LINE_SMOOTH)
            glEnable(GL_POLYGON_SMOOTH)
            glHint(GL_LINE_SMOOTH_HINT, GL_NICEST)
            glHint(GL_POLYGON_SMOOTH_HINT, GL_NICEST)
           
            glUseProgram(self.shader)
           
            self.update_matrices()
           
            # 조명 설정
            glUniform3f(glGetUniformLocation(self.shader, "lightPos"), 3.0, 5.0, 3.0)
            glUniform3f(glGetUniformLocation(self.shader, "viewPos"), *self.camera_pos)
            glUniform3f(glGetUniformLocation(self.shader, "lightColor"), 1.0, 1.0, 0.95)
            glUniform3f(glGetUniformLocation(self.shader, "objectColor"), 0.9, 0.9, 0.9)
           
            # 행렬 전송
            glUniformMatrix4fv(glGetUniformLocation(self.shader, "model"), 1, GL_TRUE, self.model_matrix)
            glUniformMatrix4fv(glGetUniformLocation(self.shader, "view"), 1, GL_TRUE, self.view_matrix)
            glUniformMatrix4fv(glGetUniformLocation(self.shader, "projection"), 1, GL_TRUE, self.projection_matrix)
           
            # 모델 렌더링
            glBindVertexArray(self.vao)
            glDrawArrays(GL_TRIANGLES, 0, self.vertex_count)
           
            pygame.display.flip()
               
        except Exception as e:
            print(f"Render error: {str(e)}")

    def run(self):
        """메인 렌더링 루프"""
        while True:
            try:
                # 이벤트 처리
                for event in pygame.event.get():
                    if event.type == pygame.QUIT:
                        return
                    elif event.type == pygame.KEYDOWN:
                        if event.key == pygame.K_ESCAPE:
                            return
                    # 마우스 입력 처리 추가
                    elif event.type == pygame.MOUSEBUTTONDOWN:
                        if event.button == 1# 왼쪽 버튼
                            self.dragging = True
                            self.prev_mouse_pos = pygame.mouse.get_pos()
                    elif event.type == pygame.MOUSEBUTTONUP:
                        if event.button == 1# 왼쪽 버튼
                            self.dragging = False
                    elif event.type == pygame.MOUSEMOTION:
                        if self.dragging:
                            x, y = pygame.mouse.get_pos()
                            if self.prev_mouse_pos:
                                dx = x - self.prev_mouse_pos[0]
                                dy = y - self.prev_mouse_pos[1]
                                self.rotation[2] -= dx * self.mouse_sensitivity  # z축 회전으로 변경
                                self.rotation[0] -= dy * self.mouse_sensitivity  # x축 유지
                            self.prev_mouse_pos = (x, y)
               
                self.render_model()
                self.clock.tick(self.target_fps)
                   
            except Exception as e:
                print(f"Error during rendering: {str(e)}")
                break
       
        pygame.quit()
       
        # 리소스 정리
        glDeleteVertexArrays(1, [self.vao])
        glDeleteBuffers(1, [self.vbo])
        glDeleteProgram(self.shader)


if __name__ == "__main__":
    import sys
    import os

    if len(sys.argv) > 1:
        model_path = sys.argv[1]
    else:
        model_path = "12221_Cat_v1_l3.obj" # "Tree.obj"
   
    # 파일 존재 여부 확인
    if not os.path.exists(model_path):
        print(f"Error: File '{model_path}' does not exist")
        sys.exit(1)
       
    try:
        viewer = ModernModelViewer(model_path)
        viewer.run()
    except Exception as e:
        print(f"Error: {str(e)}")


반응형