Search
🌎

miniRT

Created
2021/03/23
tag
42서울
42Seoul
miniRT
Ray Tracing
math.h
mlx.h

Subjects

본 글은 Mac OS X 상에서 진행된 내용을 다뤘다는 것을 미리 밝힙니다.

1. External Functions

1) perror

1. 의존성

#include <stdio.h>
C
복사

2. 함수 원형

void perror(const char *s);
C
복사

3. 함수 설명

perror라는 말은 print a system error message라는 의미이다. s가 널이 아니고, *s가 널 문자가 아닐 때 perror 함수를 이용하면, 인자로 들어온 문자열을 표준 에러를 이용하여 출력해준다. 인자로 넘긴 문자열을 출력하고 끝이 아니라, 현재 설정된 errno 에 해당하는 메세지를 콜론공백 문자를 앞에 붙여 출력한다. 예시를 참고하자.
errnoerrno.h에 매크로 선언으로 존재한다. 별 다른 Error가 발생하지 않았다면, 기본 값은 0이다. Error에 대한 index가 궁금하다면 errno.h에 전역적으로 관리되는 리스트인 sys_errlist를 통해 여러 Error들을 찾아볼 수 있다. 가장 큰 수의 indexsys_nerr 값 - 1이다. 직접 정의한 새로운 Error 값은 sys_errlist에 추가되지 않았을 수도 있으므로 sys_errlist에 대한 직접적인 사용은 주의할 필요가 있다. 또한 sys_errlist는 현재 deprecated이고, strerror를 주로 사용한다. 일반적으로 시스템 콜이 실패하면 -1을 리턴하는데, 이 값을 받게 되면 발생한 증상에 맞는 indexerrno가 설정된다. 시스템 콜이 성공하게 되면 errnoundefined의 상태로 존재한다. errno는 호출한 시스템 콜의 성공 여부에 따라서 그 값이 갱신되기 때문에 시스템 콜에 대한 호출 직후 perror를 사용하지 않으면 errno가 다른 시스템 콜에 의해 갱신될 수 있으므로 errno에 대한 값을 저장해둬야 할 수도 있다. 따라서 시스템 콜이 실패했다면 이를 반드시 사람이 읽을 수 있는 형태로 출력하도록 perror를 호출하는 것이 권장된다.

4. 예시

#include <stdio.h> #include <errno.h> int main(void) { printf("current errno: %d\n", errno); printf("current sys_nerr: %d\n", sys_nerr); perror("intentioned error"); return (0); }
C
복사

2) strerror

1. 의존성

#include <string.h>
C
복사

2. 함수 원형

char * strerror(int errnum);
C
복사

3. 함수 설명

perror에 대한 설명을 하면서 짤막하게 strerror에 대해서 설명을 했다. sys_errlistdeprecated여서 strerror를 사용한다고 했다. errno.h에는 Error 발생 시 설정되는 index들이 정의되어 있는데, 이 index들은 Error 발생 시 errno에 설정된다. Error에 대한 index 값을 strerror에 넣게 되면, index에 해당하는 문자열을 반환하게 된다. (정확히는 해당 문자열을 참조하는 포인터를 반환한다.) 기본적으로 반환되는 문자열은 영어로 되어 있지만, 로케일에 따라서 다른 언어로 된 문자열을 참조하여 반환한다. 반환되는 문자열은 문자열 리터럴을 참조하고 있기 때문에 별도의 수정은 일어날 수 없다.
strerror에 의해 반환되는 문자열은 현재 사용 중인 로케일, 컴파일러, 플랫폼에 따라서 다를 수 있다. 예를 들어 현재 사용중인 운영 체제가 Mac OS X라면 아래 그림과 같이 _DARWIN_ALIAS되어 사용되는 것을 볼 수 있다. (Mac OS XBSD 계열의 Darwin 운영체제에 기초한다.)

4. 예시

#include <errno.h> #include <stdio.h> #include <string.h> int main(void) { printf("current error: %s\n", strerror(errno)); return (0); }
C
복사

3) exit

1. 의존성

#include <stdlib.h>
C
복사

2. 함수 원형

noreturn void exit(int status);
C
복사
noreturn 이란? C11부터 제공된 _Noretrun이라는 키워드를 편리하게 사용할 수 있는 매크로이다. noreturn이라는 매크로를 이용하기 위해선 _NoreturnMapping하고 있는 <stdnoreturn.h>라는 라이브러리가 필요하다. _Noreturn은 호출한 함수에게 아무런 값을 반환하지 않아도 된다는 것을 컴파일러에게 미리 알리는 키워드이다. 이렇게 되면 컴파일러_Noreturn 함수의 호출 이후의 코드에 접근할 수 없다는 사실을 인식하게 된다. noreturn이 표기된 함수는 호출자에게 어떠한 값도 반환하지 않기 때문에 반환 형식을 포함해서는 안 되므로 void여야 한다. 호출자에게 반환되는 어떤 흐름을 조작해야할 가능성이 있다면 절대로 _Noreturn 키워드를 사용해선 안된다. noreturn의 주요 이점이라 함은 함수의 의도를 더 명확히 할 수 있다는 것과 접근 불가능한 코드에 대해 자연스럽게 캐치시킨다는 것이다. 대표적인 예시로는 exit 함수와 abort 함수가 있다.

3. 함수 설명

exit 함수는 프로세스에 대해서 정상적으로 종료할 수 있게 해준다. exit 함수에서의 정상적인 종료라는 말은 C 언어로 만들어진 프로세스에서 사용 중인 파일 입출력을 저장함과 동시에 프로세스를 종료하여 프로세스의 권한을 운영체제에 넘기는 것을 말한다. 사용 중인 파일 입출력을 저장한다는 것은 모든 열려진 파일을 닫고, 출력 버퍼에 데이터가 있으면 해당 내용을 write 하여 작업을 완료한다는 것이다.
exit프로세스의 종료를 의미했다면, return함수의 종료를 의미한다. 따라서 main 함수에서의 returnexit과 동일한 동작을 만들어낸다.
exitstatus는 정수를 받게되는데, 별도의 반환 값이 없다고 명시되어 있음에도 인자로 받은 정수를 운영체제에 반환하게 된다. 이를 EXITCODE라 한다. 일반적으로 오류가 없는 종료 시에는 0status로 넣게 되고, 오류가 있다면 그 외의 값을 인자로 (대체적으로 1을 사용) 넣게 된다. 따라서 exitstatus0을 사용하면 SUCCESS, 1을 사용하면 FAILURE를 의미하게 된다. (내부적으로 0EXIT_SUCCESS, 1EXIT_FAILURE로 정의 되어 있다.) 반환된 EXITCODEParent가 존재한다면 Parent 프로세스에게 전달된다. (exit 함수에 대한 linux manualLeast Significant ByteParent로 보낸다는 것이 EXITCODE를 보내는 것을 의미한다. wait 함수를 찾아보면 해당 내용에 대해 자세히 알 수 있다.)
일반적으로 EXITCODE16비트로 표현되고, 의미하는 바에 따라 상위 8비트하위 8비트로 나뉘게 된다. 상위 8비트exit 함수의 status를 의미한다. exitstatus0을 받아서 오류 없이 종료가 되었는지 판별하여, 오류가 없다면 상위 8비트0으로 채워진다. (반대로 오류가 있는 채로 종료되면 상위 8비트0이 아닌 값으로 채워진다.) 하위 8비트는 오류 없이 종료된 경우엔 0으로 채워지며, 오류가 있는채로 종료되면 어떤 SIGNAL에 의한 것인지 그 값이 채워진다. 이에 대한 내용은 <sys/wait.h>EXITCODE 계산 방법과 그에 따라 나열된 SIGNAL들을 확인해보면 쉽게 알 수 있다.
만일 waitpid 함수를 이용하여 status를 인자로 사용할 때, Child 프로세스가 exit으로 종료됨과 동시에 이에 대한 올바른 status를 얻고 싶다면 다음과 같이 WIFEXITED라는 매크로 함수로 조건 검사를 하고 WEXITSTATUS로 올바른 status 값을 얻을 수 있다. 비정상 종료 시에는 SIGNAL 값을 얻어야하므로 WIFSIGNALEDSIGNAL 발생 검사 후, WTERMSIG로 올바른 SIGNAL 값을 얻어낼 수 있다.
#include <string.h> #include <sys/types.h> #include <sys/wait.h> #include <unistd.h> int main() { int pid; int status; pid = fork(); if (pid < 0) { perror("fork error : "); exit(0); } if (pid == 0) { printf("Child\n"); sleep(10); return (2); } else { printf("Parent: wait (%d)\n", pid); waitpid(pid, &status, 0); if (WIFEXITED(status)) { printf("Normally Terminated\n"); printf("Exit Status : %d\n", WEXITSTATUS(status)); } else if (WIFSIGNALED(status)) { printf("Abnormally Terminated\n"); printf("Exit Signal : %d\n", WTERMSIG(status)); } } exit(0); }
C
복사

4. 예시

#include <stdio.h> #include <stdlib.h> int main(int argc, char **argv) { if (argc == 1) { printf("invalid args: %d\n", argc); printf("detail: %s\n", *argv); exit(1); } else { printf("valid args: %d\n", argc); while (*argv) printf("detail: %s\n", *argv++); exit(0); } }
C
복사

2. <math.h>

miniRT<math.h>를 전면 허용한다. 어떤 함수들이 있는지 작성해두었으니 아래 링크에서 알아보자.

3. "mlx.h"

cub3dminiRT 둘 중 무엇을 선택하더라도 "mlx.h"를 사용하게 된다. Mac OS X의 그래픽을 처리하는 프레임워크에 어플리케이션을 등록함으로써 그래픽 요소에 대한 접근이 가능해졌을 때, "mlx.h"는 이를 처리해주는 Small Home-Made Library이다. 사용 방법은 intra에서 짧은 동영상 강의를 제공한다.
42 Docs에서는 이에 대한 상세한 Document를 제공하기도 한다.
프로젝트를 등록하면 Subject 파일과 함께 minilibx_mmsminilibx_opengl을 이용할 수 있도록 해뒀는데, mmsopengl을 다운로드 해보면 아래 그림과 같이 압축 파일로 되어 있는 것을 확인할 수 있다.
각 파일을 압축 해제하여 mlx에 넣은 후 내부 소스들을 확인해보자.
가장 눈에 띄는 것은 각 디렉토리 내부의 Makefilemms 내부의 man인데 이에 대해서 각각 확인해보자.

1) man

man 디렉토리를 타고 끝까지 들어가보면, 아래 그림과 같이 5개의 매뉴얼 파일이 존재하는 것을 볼 수 있다.
매뉴얼의 확인은 아래 명령어에 기재된 것처럼 상대 경로로도 열 수 있고, 절대 경로로도 열 수 있다.
특정 파일을 man으로 열 수 있도록 하려면 특정 양식에 맞춰 작성되어야 하고, 특정 양식에 맞춰 작성되어 있기 떄문에 vim으로 여는 것보다 man으로 파일을 확인했을 때 조금 더 높은 가독성이 보장된다.
man ./mlx.3 man /Users/bigpel/Downloads/mlx/minilibx_mms_20200219/man/man3/mlx.3
제공된 5개의 매뉴얼 파일 덕분에 "mlx.h"에서 어떤 함수들을 제공하는지 조금 더 편리하게 알 수 있게 되었다. 조금 불편한 점이 있다면 각 매뉴얼 파일을 확인하기 위해서 상대 경로절대 경로든 경로를 알고 있어야 man을 사용할 수 있다는 점인데, man에 대한 설정을 조금만 바꿔주면 경로 없이 편리하게 이용할 수 있다.
우선 매뉴얼 파일들이 존재하는 위치를 알아야 하는데, 아래의 명령어를 이용해도 좋고 혹은 /private/etc/man.confvim으로 열어서 확인할 수 있다.
manpath
manpath의 수행결과는 아래 그림과 같다.
/private/etc/man.conf를 열어보면 man에 대한 Path가 아래 그림처럼 명시되어 있는걸 볼 수 있다.
제공 받은 매뉴얼 파일을 위치시키기 위해, 이 글에서는 /usr/local/share/man이라는 경로를 이용할 것이다. 수동으로 매뉴얼 파일을 해당 경로에 넣으려고 하면 수행이 되지 않기 때문에, 터미널 환경에서 sudo 명령어를 이용하여 복사할 것이다.
주어진 명령어를 자신의 경로에 맞게 수정하여, 아래 그림과 같이 man3 디렉토리의 파일들을 /usr/local/share/man/man3로 복사하면 된다.
sudo cp ./* /usr/local/share/man/man3
위 명령어를 통해 매뉴얼 파일들의 복사가 끝났다면, man 명령어를 이용하는데 있어서 이전처럼 경로로 인자를 줄 필요가 없기 때문에 편하게 사용할 수 있다. 예를 들어, 이전에는 man ./mlx.3로 쳤었다면 확장자명도 필요없이 man mlx만으로 매뉴얼을 읽을 수 있게 된 것이다.
man3에서 3라이브러리 내부의 함수 호출에 대한 매뉴얼을 의미한다. man에 대한 다른 index들도 의미 별로 다르게 존재하는데, 해당 내용은 아래 링크에서 확인할 수 있다.
man 명령어 관련 설정에 대한 내용은 아래 링크에서 조금 더 자세하게 알아볼 수 있다.

2) Makefile

매뉴얼 파일들을 읽어보면 "mlx.h"에서 꽤 많은 함수들을 제공해주는 것을 확인할 수 있다. 제공되는 함수들을 사용하여 프로그램을 만들기 위해선 "mlx.h"에 대한 라이브러리를 먼저 생성해야 한다. openglmms 디렉토리를 열어보면 모두 Makefile이 존재하는 것을 확인할 수 있고, 이를 이용하여 컴파일하면 라이브러리가 생성되는 것을 확인할 수 있다.
opengl 디렉토리에서 make 명령어로 컴파일 하고 나면 아래의 그림처럼 libmlx.a라는 정적 라이브러리가 생성되는 것을 확인할 수 있다.
opengl의 Makefile을 이용한 컴파일 이전의 파일들
opengl의 Makefile을 이용한 컴파일 이후의 파일들
mms 디렉토리에서 make 명령어로 컴파일을 하고 나면 아래 그림처럼 libmlx.dylib라는 동적 라이브러리가 생성되는 것을 확인할 수 있다.
mms의 Makefile을 이용한 컴파일 이전의 파일들
mms의 Makefile을 이용한 컴파일 이후의 파일들
두 라이브러리를 모두 이용할 필요는 없고, 둘 중 하나만 선택하여 작성한 코드와 함께 컴파일하면 된다. 정적 라이브러리동적 라이브러리에 대해서는 플랫폼 마다 이를 지칭하는 확장자가 모두 다르다.
정적 라이브러리 특정 기능을 하는 라이브러리를 정적 (static)으로 생성한다는 것은 실행 파일을 만드는 Link 단계에서 라이브러리를 포함시키겠다는 것을 의미한다. 따라서 라이브러리 내부의 코드가 이를 사용하는 실행 파일에 함께 포함되기 때문에, 별도의 작업 없이 실행 파일만으로 라이브러리 내부의 코드를 이용할 수 있게 된다. 하지만 Multiple-Caller Program이 존재하는 경우, 즉 동시에 동일한 정적 라이브러리를 이용하는 프로세스가 존재하게 되면 메모리의 공간 효율이 떨어지는 현상이 발생할 수 있다. 정적 라이브러리는 머신 간에 프로그램의 이동이 발생했을 때 조금 더 구동이 쉽도록 만들어주고, 보다 작은 프로그램을 빠르게 만들고 싶을 때 주로 이용하게 된다.
동적 라이브러리 라이브러리를 동적 (dynamic)으로 생성한다는 것은 실행 파일에 라이브러리의 코드가 직접 들어가는 것이 아니라 필요 시에 라이브러리 코드를 사용할 수 있는 최소한의 정보만이 포함되어 Link 되는 것을 의미한다. 따라서 정적 라이브러리를 이용할 때보다 실행 파일의 크기가 작은 편이고, 라이브러리 자체는 프로그램이 실행될 때 Link 된다고 보면 된다. 이러한 동적인 Link는 시스템의 가상 메모리를 최대한 활용하기 위해 사용되는 최적화 기술이라고 볼 수 있는데, 라이브러리의 코드가 사용되고 있는 페이지는 여러 프로세스들이 공유하게 된다. 따라서 Multiple-Caller Program이 존재하더라도 메모리 공간의 효율이 크게 떨어지지 않는다. 예를 들어, libc++은 모든 C++ 프로그램에 Link 되어야 하는데 라이브러리 코드를 모든 프로그램에 복사하는 대신 가상의 페이지를 통해 여러 프로세스와 동적으로 연결할 수 있다. 실행 파일을 만드는 과정에서 어떤 동적 라이브러리를 이용할지 명시해주게 되면, 프로그램이 실행되었을 때 동적으로 가상의 페이지 위에서 동작하고 있는 라이브러리 내부의 코드를 참조하게 된다. 따라서 여러 프로세스가 동적으로 하나의 라이브러리 코드를 참조하게 되므로, 프로그램이 동작하는 환경 변경이 필요한 경우 일괄적으로 이를 적용 시키는 것이 가능하다.
두 라이브러리에 대해 사용할 예제 코드 test.c는 아래와 같다.
#include "mlx.h" int main(void) { void *m_ptr; void *w_ptr; m_ptr = mlx_init(); w_ptr = mlx_new_window(m_ptr, 1200, 800, "test window"); mlx_loop(m_ptr); return (0); }
C
복사

[1] 정적 라이브러리를 이용한 컴파일

정적 라이브러리로 컴파일 하여 test window를 띄워보자.
test.c를 목적 파일인 test.o로 먼저 만들어줘야 하므로 gcc -c 옵션을 통해 컴파일 해줘야 한다. 이 때, 코드 내부에서 사용하고 있는 "mlx.h"의 위치를 잡아주지 않으면 아래 그림과 같이 "mlx.h"를 찾지 못했다는 에러를 볼 수 있다.
정적 라이브러리opengl 디렉토리에 위치해 있고, opengl 디렉토리에는 mlx.h라는 파일이 존재하는 것을 볼 수 있다. 따라서 gcc -c로 컴파일 할 때, opengl 디렉토리를 -I 옵션으로 명시하여 Header의 위치를 잡아주도록 한다. 아래 그림처럼 test.o라는 목적 파일이 생성된 것을 확인할 수 있다.
생성된 목적 파일을 실행 파일로 만들기 위해, -L 옵션을 통해 라이브러리의 위치를 명시하고 -l 옵션을 통해 사용하려는 라이브러리를 명시하여 컴파일을 수행한다. 또한 "mlx.h"를 이용하여 컴파일 할 때는 -framework OpenGL -framework AppKit이라는 옵션을 함께 명시해줘야 한다. 아래 그림처럼 test라는 실행 파일이 정상적으로 생성된 것을 확인할 수 있다.
test 파일을 실행해보면 test window가 실행되는 것도 확인할 수 있다.

[2] 동적 라이브러리를 이용한 컴파일

동적 라이브러리로 컴파일 하여 test window를 띄워보자. 정적 라이브러리로 컴파일하는 것과 크게 다를 것은 없지만 추가적인 작업이 하나 더 필요하다.
test.c를 목적 파일인 test.o로 먼저 만들어줘야 하므로 gcc -c 옵션을 통해 컴파일 해줘야 한다. 이 때, 코드 내부에서 사용하고 있는 "mlx.h"의 위치를 잡아주지 않으면 아래 그림과 같이 "mlx.h"를 찾지 못했다는 에러를 볼 수 있다.
동적 라이브러리mms 디렉토리에 위치해 있고, mms 디렉토리에는 mlx.h라는 파일이 존재하는 것을 볼 수 있다. 따라서 gcc -c로 컴파일 할 때, mms 디렉토리를 -I 옵션으로 명시하여 Header의 위치를 잡아주도록 한다. 아래 그림처럼 test.o라는 목적 파일이 생성된 것을 확인할 수 있다.
생성된 목적 파일을 실행 파일로 만들기 위해, -L 옵션을 통해 라이브러리의 위치를 명시하고 -l 옵션을 통해 사용하려는 라이브러리를 명시하여 컴파일을 수행한다. 또한 "mlx.h"를 이용하여 컴파일 할 때는 -framework OpenGL -framework AppKit이라는 옵션을 함께 명시해줘야 한다. 아래 그림처럼 test라는 실행 파일이 정상적으로 생성된 것을 확인할 수 있다.
test 파일을 실행해보면 test window가 실행되지 않고 dyld: Library not loaded라는 오류를 볼 수 있다. 이는 Mac OS X에서 dylib라는 확장자를 갖는 동적 라이브러리를 이용할 때 경로 설정이 되어 있지 않아서 발생하는 오류이다. 이를 처리하는 방법은 직접적으로 경로를 수정하는 방법명령어를 이용하여 경로를 수정하는 방법으로 2가지가 존재한다.
직접적으로 경로를 수정하는 방법
이 방법을 이용할 때는 DYLD_LIBRARY_PATH를 설정 해줘야 하는데 확인해보면 아무런 값도 설정되어 있지 않은 것을 확인할 수 있다.
동적 라이브러리가 위치한 곳의 절대 경로를 확인한 후 이를 DYLD_LIBRARY_PATH로 등록해준다.
만일 기존에 동적 라이브러리를 위한 DYLD_LIBRARY_PATH를 이용 중이라면, 기존 경로에 추가해야하므로 export DYLD_LIBRARY_PATH=$DYLD_LIBRARY_PATH:$newPath와 같이 명령어를 이용하면 된다.
DYLD_LIBRARY_PATH에 대한 설정이 되었다면 test를 다시 실행해보면 test window가 실행되는 것을 확인할 수 있다.
명령어를 이용하여 경로를 수정하는 방법
이 방법은 install_name_tool이라는 명령어를 이용하여 실행 파일에서 요구하는 동적 라이브러리에 대한 경로 설정을 설정하는 것이다. man을 이용하여 install_name_tool을 살펴보면 공유되고 있는 동적 라이브러리에 대한 파일 이름 조작 혹은 경로에 대한 조작이 가능한 것을 볼 수 있다.
아래 명령어처럼 매뉴얼에서 제시한 순서로 인자를 넣어 사용하면 된다.
❯ install_name_tool -change libmlx.dylib ./minilibx_mms_20200219/libmlx.dylib test
test를 실행해보면 test window가 실행되는 것을 확인할 수 있다.

[3] 어떤 라이브러리를 이용해야 되는가?

정적 라이브러리동적 라이브러리는 각각의 특성이 있기 때문에, 프로그램 작성에 있어서 어떤 라이브러리를 이용할지는 특성을 고려하여 선택하면 된다. 하지만 cub3d 혹은 miniRT 프로젝트를 먼저 끝낸 사람들의 얘기를 들어보면 opengl을 이용한 정적 라이브러리는 꽤나 이슈가 있는 것으로 확인되므로 동적 라이브러리를 이용하여 프로젝트를 진행하는 것이 권장되어 보인다. 동적 라이브러리를 이용하는 경우 위에서 본 대로 DYLD_LIBRARY_PATH에 대한 설정을 하도록 Makefile에 꼭 명시하도록 하자.
또한 위 라이브러리들을 이용하기 위해서, 프로젝트의 Makefile에서 라이브러리에 대한 Makefile을 먼저 수행할 수 있어야 한다. 즉, 프로젝트의 Makefile에서 make 명령어를 이용하여 라이브러리 컴파일을 먼저 해줘야하는데 이 때 -C 옵션을 통해 순환 make를 수행할 수 있도록 해주면 된다. 기본적으로 make는 실행되는 모든 명령어를 터미널 상에서 출력해주는데, 라이브러리에 대한 명령어 수행을 터미널 상에서 보고 싶지 않다면 @를 붙여 수행시키면 되므로 이를 적극 활용해보자. 아래 구문을 Makefile에 넣어서 적절히 활용하면 되겠다.
@make -C $dirname
순환 make에 대한 내용은 아래 링크의 Document를 통해 더 자세히 알 수 있다.
make 명령어의 중요 옵션들은 아래 링크를 참고하자.

3) 라이브러리 함수 (첨부 링크 작성 중)

man을 통해서 "mlx.h"에서 지원하는 함수들을 이해하는 것이 어렵다면, MiniLibX에 대한 내용들과 함께 라이브러리 함수들에 대해 작성해두었다.

4) 참고 사항

opengl을 이용했을 때, han님의 경우에는 illegal hardware instruction이라는 오류가 발생했었고 youlee님의 경우에는 화면 깨짐 현상이 발생했었다.
opengl을 이용하고 싶다면 Mac OS XCocoa API가 있어야하므로 Mac OS X외의 환경에서는 이용이 불가능하다.
gcc-framework라는 옵션은 Mac OS X에서만 이용할 수 있는 옵션이다.
intra에서 제공하는 "mlx.h"를 이용할 때 Mac OS X가 아니라 linux 환경이라면, 아래 링크에 있는 MiniLibX를 이용하도록 한다.
"mlx.h"를 이용한 테스트 코드들은 아래 링크에서도 학습할 수 있다.

4. Ray Tracing이란 무엇인가?

Ray Tracer를 직접 만들어보며 RT를 이해하고 싶다면, 그 방법을 아래 링크에 작성해두었다.

5. Ray Intersection

Ray Tracer를 만들다보면, ObjectHit 여부를 통해서 Hit PointColor를 기록하여 처리하게 된다. 따라서 ObjectHit 여부를 판별하는 것이 가장 중요하다고 볼 수 있는데, 각 물체 별로 Hit 판별을 위한 수식이 다르고 Hit Point를 구하는 방법이 다르기 때문에 이에 대해서 알아보려 한다.

1) Sphere Intersection

SphereHitRay Tracing in One Weekend의 자료를 참고하도록 한다.

2 ) Plane Intersection

아래 그림과 같이 Plane 상의 한 점을 PP라고 하고, 이 때의 Normalnn이라고 해보자. 그리고 RayOriginee라 하고 RayDirectionμ\mu라고 한다면, Raye+μte + \mu t로 표현할 수 있다. RayPlaneHit 한다면 e+μte + \mu t가 곧 Hit Point가 된다.

Hit Determinant

RayPlaneHit하는지 확인하기 위해서, RayPlane이 평행하는지 확인하는 것이 우선이다. 이는 RayDirectionPlaneNormal이 서로 수직인 경우에 해당하므로 두 Vector를 내적했을 때 0이라면 Hit Point를 구할 필요가 없어진다. 따라서 μn0\mu \cdot n \not = 0 이라면 μn\mu \cdot n분모 (Denominator)로써 이용할 것이므로 잘 기록해두면 된다.

Hit Point

Plane의 방정식은 ax+by+cz+d=0ax + by + cz + d = 0인데, 이 때 각 계수 (Coefficient) aa, bb, ccNormal nn의 각 xx, yy, zz에 해당한다. 또한 방정식에서 사용되고 있는 미지수 xx, yy, zzPlane 상의 점을 의미하기 때문에 주어진 점 PP의 좌표를 이용하면된다. 따라서 Plane 방정식의 dd는 다음과 같이 정리된다.
nP+d=0n \cdot P + d = 0 nP=dn \cdot P = -d d=nPd = -n \cdot P
dd 에 대해 정리가 되었고 RayHit Pointe+μte + \mu t라면, ax+by+cz+d=0ax + by + cz + d = 0은 아래와 같이 tt에 대해서 정리할 수 있다.
ax+by+cz+d=0ax + by + cz + d = 0 n(e+μt)+d=0n \cdot (e + \mu t) + d = 0 n(e+μt)=dn \cdot (e + \mu t) = -d n(e+μt)=npn \cdot (e + \mu t) = n \cdot p
ne+nμt=npn \cdot e + n \cdot \mu t = n \cdot p
t=npnenμt = \frac{n \cdot p - n \cdot e}{n \cdot \mu}
이 때 tt 값을 결정 짓는 nμn \cdot \muHit Determinant에서 구했던 분모에 해당하므로 그대로 이용하면 된다.

3) Square Intersection

Raye+μte + \mu t로 표현한다고 하자. Square2D Figure 이므로 기본적으로 Plane에 기반한다. 따라서 Hit 판별에 대한 1차적인 수식은 PlaneHit Determinant를 따른다. PlanePP가 곧 Square의 중심으로 주어진 점 PP가 되고, Planenn을 곧 Square의 방향을 나타내는 nn으로써 이용하면 된다.

Hit Determinant

1차적인 Hit 판별 여부가 Plane과 동일하기 때문에 μn0\mu \cdot n \not = 0 인지 확인한다. 그리고 Square와 동일한 Plane 상의 점이 맞다면, Raye+μte + \mu t에서의 tt를 구할 수 있기 때문에 tt를 이용하여 Square의 크기 내에만 존재하는지 확인해주면 된다. 이 과정이 Hit Determinant이자 곧 Hit Point를 구하는 과정이 된다.

Hit Point

Square 크기 내에 존재하는지에 대한 판별을 위해선 3D 공간에서 Square의 한 변에 대한 명확한 방향만 잡아내면 수훨하게 진행할 수 있다. SquareSide Vector를 찾아내기 위해선 Squarenn과 임의의 Vector rr를 외적하여 찾아낼 수 있다. 내 경우에는 Squarennyy1이라면 rr(1,0,0)(1, 0, 0)으로 두었고, 그렇지 않다면 rr(0,1,0)(0, 1, 0)으로 두어 진행하였다. 즉, Side Vectorn×rn \times r이 된다는 것이다.
2D 공간에서는 Square의 기울어짐 등을 볼 필요가 없기 때문에 단순히 크기 비교를 통해 Square 내부의 점인지 확인할 수 있지만, 3D에서는 추가적인 작업이 필요하다. 3D 상에서 Square의 내부의 점인지 단순히 크기를 통해서 보려한다면 이는 정확한 형태를 알 수 없기 때문에 팽이처럼 원을 그리면서 도는 형태가 되어 정확한 판별이 불가능해진다. 따라서 한 변이 향하고 있는 벡터에 대해서 알아내야 정확한 Hit Determinant를 수행할 수 있다.
위 그림에서 보는 것처럼 θ\theta를 찾아내기 위해선 내적을 이용하면 되는데, 이는 RayPlane에 닿은 Hit Point e+μte + \mu t와 중심점 PP를 뺀 VectorSide Vector rr을 내적한 후, 각 Vector의 크기로 나눠주면 된다. cosθ=((e+μt)P)r(e+μt)Prcos\theta = \frac {((e + \mu t) - P) \cdot r} {||(e + \mu t) - P|| \cdot ||r||}이므로 Square내에서의 한계치 mm을 비례 관계로 찾아낼 수 있다.
m=rcosθm = \frac {||r||} {cos\theta} (단, cosθ22cos\theta \le \frac{\sqrt{2}}{2})
단, 주의해야할 것이 있는데, 이런 비례 관계를 이용하기 위해선 θ\thetaπ2\frac {\pi}{2}를 넘어선 안 된다. 위 그림에서 ?에 해당하는 부분을 모르기 때문에 비례 관계를 이용할 수 없기 때문이다. 따라서 cosθ>22cos\theta > \frac {\sqrt2}{2}라면 이를 적절히 조정해줘야한다. cosθcos\theta에 해당하는 Radianacosacos으로 찾아낸 후, π2\frac{\pi}{2}에서 빼낸 후 coscos을 취하는 것이다. 이를 통해 적절한 mm을 찾아낼수 있다.
m=rcos(π2acos(cosθ))m = \frac {||r||} { cos(\frac{\pi}{2} - acos(cos\theta))} (단, cosθ>22cos\theta \gt \frac {\sqrt{2}}{2})
m(e+μt)Pm ≥ (e + \mu t) - P 라면 Square 내부의 점이라고 볼 수 있고, e+μte + \mu t가 곧 Hit Point가 된다.

4) Cylinder Intersection with Cap

아래 그림과 같이 Cylinder의 중심점을 PP라고 하고, 이 때 Cylinder가 향하고 있는 Directionoo라고 해보자. 그리고 RayOriginee라 하고 RayDirectionμ\mu라고 한다면, Raye+μte + \mu t로 표현할 수 있다. RayCylinderHit 한다면 e+μte + \mu t가 곧 Hit Point가 된다. Cylinder의 경우 윗면과 아랫면을 덮고 있는 Cap이 존재하지 않는 Infinite Cylinder가 기본 형태이며, Cap을 만들기 위해선 별도의 Hit Determinant가 요구된다. 따라서 Finite Cylinder 구현 시에는 옆면에 대한 Hit Determinant 근 최대 2개, 그리고 윗면과 아랫면에 대한 Hit Determinant 근 최대 2개 중 하나의 Hit Point를 찾아내는 것이 필요하다.

Hit Determinant on Rectangle

옆면은 Cylinder의 공식을 통해서 tt값을 풀어낼 수 있다. Cylinder의 일반 공식은 P+qoP + qo라는 축을 기준으로, ((e+μtP)×o)2r2=0((e + \mu t - P) \times o)^2 - r^2 = 0 이므로 이를 at2+bt+c=0at^2 + bt + c = 0 꼴로 풀어낼 수 있다. 이를 풀어서 각 계수 aa, bb, cc값을 정리하면 다음과 같다. 이 때 ΔP\Delta PePe - P이다.
a=(μ×o)2a = (\mu \times o)^2 b2=(μ×o)(ΔP×o)\frac {b}{2} = (\mu \times o) \cdot (\Delta P \times o) c=(ΔP×o)2r2c = (\Delta P \times o)^2 - r^2
이를 이용하여 짝수 판별식을 통해 근의 개수를 구하여, 근이 0개면 Hit Point가 존재하지 않고, 근이 1개면 1개의 Hit Point, 근이 2개면 2개의 Hit Point가 존재하는 것이 된다. 근이 2개인 경우에는 더 작은 값이 유망한 Hit Point가 된다.

Hit Determinant on Circle

RayDirection μ\muCylinderDirection oo를 내적하여 그 값이 0 이 나온 경우에는 CylinderRay가 평행하기 때문에 윗면 혹은 아랫면에서는 Hit Point가 생기지 않는다는 것을 알 수 있다. Hit Point가 생기는 경우에는 윗면과 아랫면의 각 tt값 비교를 통해서 더 유망한 Hit Point를 결정하게 된다. 이 때 윗면의 tt값과 아랫면의 tt값을 구하는 방식은 같은데, 두 값 중 더 작은 값이 유망한 Hit Point가 된다.
윗면 혹은 아랫면의 tt값을 구하는 방식은 동일하므로 윗면을 기준으로 풀어보겠다. RayOrigin에서 윗면의 중심점까지의 VectorFF라고 했을 때, FFCylinderDirectionoo와의 내적을 통해서 tt를 구할 수 있다.
위 그림처럼 실제로 Ray가 닿은 지점은 모르기 때문에 이 점을 직접 이용하여 tt를 구하는 것에는 어려움이 있지만, Ray가 닿은 지점과 oo를 내적한 값은 구할 수 있기 때문에 이 내적 값을 기준 값으로 사용하면 된다. 이 내적 값을 cosθcos \theta라고 했을 때 이를 분모 (Denominator)로 이용한다. 분자 (Numerator)로 이용하는 값은 Ray의 Origin에서부터 실제 좌표를 알고 있는 윗면의 중심점까지 VectorFFCylinderDirectionoo와의 내적 값인 cosθcos \theta'이다. cosθcosθ\frac {cos \theta'} {cos \theta}가 바로 tt가 되므로 Hit Point e+μte + \mu t를 구할 수 있게 된다.

Hit Point

구해진 4개의 Hit Point 중에서 가장 작은 tt를 가진 e+μte + \mu t가 가장 유망한 Hit Point가 된다. 이 때, 윗면과 아랫면의 평행성은 검증했는데, 옆면의 평행성은 검증하지 않았다. 이는 Finite Cylinder를 그리려는 경우에만 검사를 해주면 된다. 윗면의 중심점 TCTCP+h2oP + \frac{h}{2} \cdot o 이고, 아랫면의 중심점 BCBCPh2oP - \frac{h}{2} \cdot o이다. TC(e+μt)o=0TC - (e + \mu t) \cdot o = 0이거나 BC(e+μt)o=0BC - (e + \mu t) \cdot o = 0이라면 Finite Cylinder의 옆면과 Ray가 평행하므로 옆면에 대한 tt는 유망하지 않게 된다.

5) Triangle Intersection

Raye+μte + \mu t로 표현한다고 하자. Triangle2D Figure 이므로 기본적으로 Plane에 기반한다. 따라서 Hit 판별에 대한 1차적인 수식은 PlaneHit Determinant를 따른다. PlanePP가 곧 Triangle의 한 점 PP가 되고, Planenn을 곧 Triangle의 방향을 나타내는 nn으로써 이용하면 된다.
TrianglePP는 주어진 점 P1P1, P2P2, P3P3P1P1으로 이용하면 되고, Trianglenn은 외적을 통해서 찾아낼 수 있다.
n=(P2P1)×(P3P1)n = (P2 - P1) \times (P3 - P1)

Hit Determinant

1차적인 Hit 판별 여부가 Plane과 동일하기 때문에 μn0\mu \cdot n \not = 0 인지 확인한다. 그리고 Triangle와 동일한 Plane 상의 점이 맞다면, Raye+μte + \mu t에서의 tt를 구할 수 있기 때문에 tt를 이용하여 Triangle의 크기 내에만 존재하는지 확인해주면 된다. 이 과정이 Hit Determinant이자 곧 Hit Point를 구하는 과정이 된다.

Hit Point

Triangle 내부의 점인지 확인하는 방법은 그리 어렵지 않다. P1P1에서 e+μte +\mu t까지의 Vectorww라고 하고, P1P1에서 P2P2uu, P1P1에서 P3P3vv라고 하자. wwuuvv를 통해서 w=xu+yvw = xu + yv로 표현할 수 있게 된다.
ww를 구성하는 xx, yy0x,y10 ≤ x, y ≤ 1이면서 동시에 x+y1x +y ≤ 1를 만족하게 되면 e+μte + \mu tTriangle 내부의 점이 된다. xx, yyuu와 수직을 이루는 upup, vv와 수직을 이루는 vpvp를 통해서 구할 수 있다. upupn×un \times u이고 vpvpn×vn \times v로 구할 수 있다.
upupvpvp에 대해서 정의했으므로 w=xu+yvw = xu + yv의 양변에 upup를 곱하여 정리해보자.
wup=xuup+yvupw \cdot up = xu \cdot up + yv \cdot up wup=yvupw \cdot up = yv \cdot up (uuupup는 수직이므로 내적 시 0이 된다.) y=wupvupy = \frac {w \cdot up}{v \cdot up}
upupvpvp에 대해서 정의했으므로 w=xu+yvw = xu + yv의 양변에 vpvp를 곱하여 정리해보자.
wvp=xuvp+yvvpw \cdot vp = xu \cdot vp + yv \cdot vp wvp=xuvpw \cdot vp = xu \cdot vp (vvvpvp는 수직이므로 내적 시 0이 된다.) x=wvpuvpx = \frac {w \cdot vp}{u \cdot vp}
이와 같이 xx, yy를 구했으므로 0x,y10 ≤ x, y ≤ 1 이고 x+y1x + y ≤ 1을 만족하게 되면 e+μte+ \mu t가 곧 Hit Point가 된다.
xx, yy를 구할 때 조금 더 최적화할 수 있다. vpvpnnvv를 외적하여 만든 Vector이므로 uvp=u(n×v)u \cdot vp = u \cdot (n \times v)로 표현할 수 있는데 Vector의 외적 성질 중에 a(b×c)=(a×b)ca \cdot (b \times c) = (a \times b) \cdot c 덕분에 u(n×v)=v(u×n)u \cdot (n \times v) = v \cdot (u \times n)이 된다. Vector의 외적은 교환 법칙이 성립하지 않으므로 v(u×n)=v(n×u)=v(n×u)=vupv \cdot (u \times n) = v \cdot (-n \times u) = -v (n \times u) = -v \cdot up 가 된다. 따라서 xx, yy의 공통 분모로써 vupv \cdot up를 계산해두면 조금 더 수훨하게 이를 계산할 수 있게 된다.

6) Cone Intersection with Cap

아래 그림과 같이 Cone의 밑면의 중심점을 PP라 하고, 이 때 Cone이 향하고 있는 Directionoo라고 해보자. 그리고 RayOriginee라하고 RayDirectionμ\mu라고 한다면, Raye+μte + \mu t로 표현할 수 있다. RayConeHit 한다면 e+μte + \mu t가 곧 Hit Point가 된다. Cone의 경우에는 밑면이 별도로 존재하지 않는 모래 시계 형태인 Infinite Cone이 기본 형태이며, 밑면을 만들기 위해선 별도의 Hit Determinant가 요구된다. 따라서 Finite Cone 구현 시에는 옆면에 대한 Hit Determinant 근 최대 2개, 그리고 밑면에 대한 Hit Determinant 근 최대 1개 중 가장 유망한 하나의 Hit Point를 찾아내는 것이 필요하다.

Hit Determinant on Circular Sector

옆면은 Cone의 공식을 통해서 tt값을 풀어낼 수 있다. Cone의 일반 공식은 P+qoP + qo라는 축을 기준으로, cos2α((e+μt)P×o)2sin2α(o((e+μt)P))2=0cos^2\alpha \cdot ((e + \mu t) - P \times o)^2 - sin^2\alpha \cdot (o \cdot ((e + \mu t) - P))^2 = 0이다. 따라서 주어진 식을 위 그림에서 제시한 값들을 적절히 활용하여 at2+bt+c=0at^2 + bt + c = 0꼴로 풀어낼 수 있다. 이를 풀어서 각 계수 aa, bb, cc값을 정리하면 다음과 같다.
a=cos2α(μc2)2sin2α(c1c1)a = cos^2\alpha \cdot (\mu - c2)^2 - sin^2 \alpha \cdot(c1 \cdot c1) b2=cos2α(μc2)(ΔPc4)sin2α(c1c3)\frac {b}{2} = cos^2 \alpha \cdot (\mu - c2) \cdot (\Delta P - c4) - sin^2 \alpha \cdot (c1 \cdot c3) c=cos2α(ΔPc4)2sin2α(c3c3)c = cos^2 \alpha \cdot (\Delta P -c4)^2 - sin^2 \alpha \cdot (c3 \cdot c3)
이를 이용하여 짝수 판별식을 통해 근의 개수를 구하여, 근이 0개면 Hit Point가 존재하지 않고, 근이 1개면 1개의 Hit Point, 근이 2개면 2개의 Hit Point가 존재하는 것이 된다. 근이 2개인 경우에는 더 유망한 Hit Point를 골라내야 한다.

Hit Determinant on Cap

CapHit DeterminantCylinder의 방식과 동일하다. 다만 Cone의 경우에는 밑면만 존재하기 때문에 1회만 수행하면 된다. 분모 (Denominator)μo\mu \cdot o가 되는데 이 값이 0인 경우에는 밑면과 평행하기 때문에 RayHit 되지 않아 유망하지 않은 tt가 된다. μo0 \mu \cdot o \not = 0이라면 이 때의 ttCylinderCap에서 tt를 구했던 것과 마찬가지로 (Pe)oμo\frac {(P - e) \cdot o} {\mu \cdot o}tt값이 되어, 이를 이용한 e+μte + \mu tHit Point의 후보 중 하나가 된다.

Hit Point

옆면의 후보 2개, 밑면의 후보 1개 중 가장 유망한 tt를 찾아내면, 이 값을 이용한 e+μte + \mu tHit Point가 된다. 옆면 후보의 유효성을 검사하는 방식과 밑면 후보의 유효성을 검사하는 방식이 다르기 때문에 나눠서 확인해보자.
옆면의 경우 후보를 각각 t1t1, t2t2라고 해보자. t1t1의 경우에는 t10t1 ≥ 0 이면서 o(ΔP+(μt1))>0o \cdot (\Delta P + (\mu \cdot t1)) > 0이라면 t1t1은 유효한 tt이며 유망성을 따질 수 있게 된다. t2t2의 경우도 t1t1의 방식과 동일하게 유효성 검사를 하면된다. t20t2 ≥ 0 이면서 o(ΔP+(μt2))>0o \cdot (\Delta P + (\mu \cdot t2)) > 0이라면 t2t2는 유효한 tt이며 유망성을 따질 수 있게 된다.
밑면의 후보를 t3t3라고 해보자. t3t3t30t3 ≥ 0이면서 (μt3+ΔP)2r2(\mu \cdot t3 + \Delta P)^2 ≤ r^2이면 유효한 tt이며 유망성을 따질 수 있게 된다.
이렇게 찾은 t1t1, t2t2, t3t3 3개의 후보들 중에서 검증을 마친 유효한 tt값들에 한하여 가장 작은 값이 가장 유망한 tt가 된다. 이 때 Hit Pointe+μte + \mu t가 아니라 가장 유망한 tt에 대해 Scale을 해줘야 한다. 가장 유망한 ttμ||\mu||로 나눈 값이 곧 Hit Point를 계산하는데 사용되는 tt가 된다.

6. Bitmap Image File Format

1) BMP

Image File Format Image File FormatImage를 디지털로 구성하고 저장하는 표준화된 방법을 의미하는데, 이 형식에 따라서 실제 데이터를 압축되지 않은 형식, 압축된 형식, 벡터 형식 등으로 저장할 수 있다. 형식에 맞게 갖춰진 Image 파일은 컴퓨터 디스플레이 혹은 프린터에서 파일 내의 데이터를 Rastering하여 사용하게 된다. Rastering을 하는 것을 Rasterization이라 하는데, 이는 Image의 데이터를 Pixel Grid로 변환하는 것을 의미한다. 각 Pixel은 색상을 지정할 수 있도록 여러 비트들을 갖고 있는데, 특정 장치에서 Rasterization을 하게 되면 장치 내의 bits_per_pixel을 읽어 Pixel을 올바르게 처리하게 된다.
Bitmap으로 이뤄진 Image File Format을 곧 BMP라고 부른다. 압축하지 않은 채로 ImageRow×ColRow \times Col 만큼의 Pixel 정보를 모두 저장해야하기 때문에 Vector 방식의 Image 혹은 Text에 비해 용량이 꽤 크고 그만큼 처리 속도가 느린 편이다. 이를 개선하기 위해 GIF, JPEG, PNG등 다양한 Image File Format이 개발되었고, 이와 동시에 BMP 자체도 RLE 압축 방식을 지원하는 쪽으로 발전했다. BMP의 확장자는 .BMP이며 다른 Image File Format을 이용하는 파일처럼 디지털로 된 Image를 나타내기 위해 이용된다. BMPPNG와 마찬가지로 16777216가지의 색을 표현할 수 있도록 지원한다. 상대적으로 특허에서 자유롭기 때문에 많은 운영체제에서 제공하는 Image Reader들이 공통적으로 처리할 수 있는 Image File Format이 되었다.

2) 데이터 블록

BMP 파일은 내부적으로 BMP 헤더, DIB 헤더, Bitmap 데이터로 구분된다. DIB 헤더의 경우에는 호환성 때문에 오래된 DIB 헤더를 이용하는 것이 일반적이었지만, 현재는 BITMAPINFOHEADER 혹은 BITMAPCOREHEADER를 주로 이용한다. BITMAPINFOHEADER는 크기가 40 바이트이고, BITMAPCOREHEADER는 크기가 12 바이트이다. 채우게 되는 값들에 대해서, 문자열을 제외한 모든 값들은 사용하고 있는 Endian에 맞춰서 기록해야 한다.
Endian (Endianness) 메모리처럼 1차원 공간이 주어졌을 때, 요소들을 연속적으로 배치하는 방법을 Endian이라고 한다. 이 때 바이트 단위로 배치하는 것을 Byte Order라고 한다. Byte Order로 메모리 상에 요소들을 두려고 할 때, 큰 단위가 먼저 표현되는 것이 Big Endian이고 작은 단위가 먼저 표현되는 것이 Little Endian이 된다. 둘 다 지원을 하거나 둘 다 지원하지 않는 경우에는 Middle Endian이라 부른다. 일반적으로 사람들이 숫자를 표기할 때 사용하는 방법이 Big Endian이다. Byte Order에 따른 Big EndianLittle Endian의 예를 살펴보자. Big Endian 0x1234 → 12 34 0x12345678 → 12 34 56 78 Little Endian 0x1234 → 34 12 0x12345678 → 78 56 34 12
BMP 헤더BMP 파일에 대한 전반적인 정보를 담고 있다.
DIB 헤더Bitmap에 대한 자세한 정보를 담고 있다.
Bitmap 데이터Image에 대한 실제 데이터를 담고 있다.
Search
BMP 헤더
Offset
Size
Description
2
BMP 파일 식별을 위한 Magic Number를 기록한다. BMP 파일을 인식하는 2개의 수는 BM을 의미하는 0x420x4D를 이용하게 된다.
4
BMP 파일의 크기를 Byte로 명시한다.
2
응용 프로그램 혹은 플랫폼에 따라 사용되는 값이 달라지는데, 대체적으로 사용되지 않는다.
2
응용 프로그램 혹은 플랫폼에 따라 사용되는 값이 달라지는데, 대체적으로 사용되지 않는다.
4
Bitmap 데이터의 시작 Offset을 의미한다.
COUNT5
Search
Offset
Size
Description
4
DIB 헤더의 크기를 의미한다.
4
Bitmap의 가로 Pixel 수를 Signed Integer로 명시한다.
4
Bitmap의 세로 Pixel 수를 Signed Integer로 명시한다.
2
사용하는 Color Pane의 수를 의미하고 일반적으로 1로 설정한다.
2
bits_per_pixel (Depth of the Image)를 의미한다. 1, 4, 8, 16, 24, 32 중 하나의 값으로 이용하는 것이 일반적이다.
4
압축 방식을 명시한 값이다. <값> <식별자> <압축 방식> 0 BI_RGB 압축 없음 1 BI_RLE8 RLE 8-bit/pixel 2 BI_RLE4 RLE 4-bit/pixel 3 BI_FITFIELDS Huffman1D 4 BI_JPEG RLE-24 5 BI_PNG 6 BI_ALPHABITFILEDS RGBA bit field masks 11 BI_CMYK 압축 없음 12 BI_CMYKRLE8 RLE-8 13 BI_CMYKRLE4 RLE-4
4
압축하지 않은 Bitmap 데이터의 크기를 의미한다. 파일의 크기와는 다르다. (압축 방식 0BI_RGB를 이용하는 경우에는 이 필드의 값이 0이 될 수 있다.)
4
Image의 가로 해상도를 Pixels/Meter로 준 값이다. 일반적으로는 Dots Per Inch라는 DPI를 계산 한 뒤, Inch Per Meter 값인 39.3701를 곱하는 식으로 계산한다. DPI 계산 예제는 다음과 같다. 1200×18001200 \times 1800 Pixel (Dot)Image가 존재하고, 이를 4×64 \times 6 (단위는 Inch)의 크기로 나타내려 한다. Image12001200Pixel 높이와 18001800Pixel 너비라고 한다면, 이는 곧 4 Inch의 높이와 6 Inch의 너비를 의미한다. 따라서 이 때 Image300 DPI를 갖는다. 1800dots6inch=300dots1inch=300DPI\frac {1800 dots} {6 inch} = \frac {300 dots} {1 inch} = 300 DPI 이 때의 Pixels/Meter300×39.3701300 \times 39.3701이 된다.
4
Image의 세로 해상도를 Pixels/Meter로 준 값이다. 일반적으로는 Dots Per Inch라는 DPI를 계산 한 뒤, Inch Per Meter 값인 39.3701를 곱하는 식으로 계산한다. DPI 계산 예제는 다음과 같다. 1200×18001200 \times 1800 Pixel (Dot)Image가 존재하고, 이를 4×64 \times 6 (단위는 Inch)의 크기로 나타내려 한다. Image12001200Pixel 높이와 18001800Pixel 너비라고 한다면, 이는 곧 4 Inch의 높이와 6 Inch의 너비를 의미한다. 따라서 이 때 Image300 DPI를 갖는다. 1800dots6inch=300dots1inch=300DPI\frac {1800 dots} {6 inch} = \frac {300 dots} {1 inch} = 300 DPI 이 때의 Pixels/Meter300×39.3701300 \times 39.3701이 된다.
4
팔레트가 갖고 있는 색의 수로 2n2^n 형태의 값을 갖는다.
4
중요한 색의 수를 명시한다. 모든 색이 중요한 경우에는 0으로 사용하는데, 대체적으로 무시하는 필드이다.
COUNT11

Bitmap 데이터

Pixel을 나타내기 위해서 1개의 Pixel3 바이트를 이용하여 색상을 표현한다. Image를 표현할 때는 제일 우측 Column의 상단부터 하단으로 처리를 하게 되고, 이 때 사용된 바이트 크기가 4 바이트 단위가 되도록 Column의 마지막 Pixel 표현 이후에 Padding을 붙이게 된다. Padding의 값은 0으로 이용하기도 하고 사용 목적에 따라 다른 값이 될 수도 있다. (예를 들어, 5개의 Row가 존재하여 1개의 Column을 처리할 때 5개의 Pixel을 명시해야 한다면, 3×5=153 \times 5 = 15이므로 4 바이트 단위로 만드는 Padding1 바이트가 된다.)
Bitmap 데이터 명시 순서 (0,0)(0, 0) (0,1)(0, 1) (1,0)(1, 0) (1,1)(1, 1) 과 같은 좌표로 유지된다고 할 때, Bitmap 데이터(0,1)(0,1)(1,1)(1, 1)(0,0)(0, 0)(1,0)(1, 0)의 순으로 처리하게 된다.

3) 예시

아래 그림과 같이 2×22 \times 2 크기의 Pixel이 주어져 있고 bits_per_pixel24일 때, 위에서 설명한 BMP 헤더, DIB 헤더, Bitmap 데이터를 이용하여 BMP 파일의 각 영역들을 채워보자. (단, DIB 헤더BITMAPINFOHEADER를 이용한다.)
Search
Offset
Size
Hex Value
Value
Description
2
42 4D
"BM"
BMP 파일 식별을 위한 Magic Number (B, M)
4
46 00 00 00
70 Bytes
BMP 헤더 크기 (14) + DIB 헤더 크기 (40) + Bitmap 데이터 크기 (16)
2
00 00
Unused
.
2
00 00
Unused
.
4
36 00 00 00
54 Bytes
Bitmap 데이터의 시작 OffsetBMP 헤더 크기 (14) + DIB 헤더 크기 (40)
COUNT5
Search
Offset
Size
Hex Value
Value
Description
4
28 00 00 00
40 Bytes
DIB 헤더 크기 (40)
4
02 00 00 00
2 Pixels
가로 Pixel의 수
4
02 00 00 00
2 Pixels
세로 Pixel의 수
2
01 00
1 Plane
일반적으로 사용되는 Color Pane 값 (1)
2
18 00
24 Bits
bits_per_pixel 값 (24)
4
00 00 00 00
0
Pixel 요소들을 압축하지 않는 BI_RGB (0)
4
10 00 00 00
16 Bytes
압축되지 않은 Bitmap 데이터의 크기 (16)
4
13 0B 00 00
2835 Pixels/Meter
Image의 해상도 값으로 72DPI×39.3701=2834.647272 DPI \times 39.3701 = 2834.6472
4
13 0B 00 00
2835 Pixels/Meter
Image의 해상도 값으로 72DPI×39.3701=2834.647272 DPI \times 39.3701 = 2834.6472
4
00 00 00 00
0 Colors
팔레트가 갖고 있는 색의 수 (0)
4
00 00 00 00
0 Important Colors
중요한 색의 수 (0)
COUNT11
Search
Offset
Size
Hex Value
Value
Description
3
00 00 FF
0 0 255
(0,1)(0, 1)에 위치한 Pixel의 색상 (Red)
3
FF FF FF
255 255 255
(1,1)(1, 1)에 위치한 Pixel의 색상 (White)
2
00 00
0 0
4 바이트 단위를 만들기 위한 Padding
3
FF 00 00
255 0 0
(0,0)(0, 0)에 위치한 Pixel의 색상 (Blue)
3
00 FF 00
0 255 0
(1,0)(1, 0)에 위치한 Pixel의 색상 (Green)
2
00 00
0 0
4 바이트 단위를 만들기 위한 Padding
COUNT6
BMP 파일을 구성하는 요소에 대해서 이해했고 그 값을 채울 수 있게 되었다면, 위 예시 값을 이용하여 직접 BMP 파일을 만들어보자. 예시 코드는 아래와 같다. 값을 미리 1 바이트 단위로 만들어 배열 내에 기록을 했고, 색상 값 같은 경우에는 255까지 사용할 수 있기 때문에 unsigned char 타입으로 배열을 만들었다.
#include <iostream> unsigned char data[] = { 0x42, 0x4d, 0x46, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x36, 0x00, 0x00, 0x00, 0x28, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x13, 0x0b, 0x00, 0x00, 0x13, 0x0b, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0xff, 0x00, 0x00, 0x00, 0xff, 0x00, 0x00, 0x00, }; int main(void) { int i; i = -1; while (++i < 70) std::cout << data[i]; return (0); }
C++
복사
위 그림처럼 컴파일 하여 실행한 뒤, stdin으로 Redirection 하여 test.bmp를 만든다. 이를 실행까지 하게 되면, 아무것도 없는 것처럼 보이지만 자세히 살펴보면 그렇지 않다는 것을 알 수 있다. 만든 BMP 파일은 2×22 \times 2 Pixel짜리므로 굉장히 작은 파일이다. 확대해보면 예시와 같은 Image를 얻은 것을 확인할 수 있다.
결론적으로 BMP 파일을 성공적으로 작성하기 위해선 Endian 값에 맞춰서 바이트 단위로 쓸 수 있어야 한다는 것을 알 수 있었고, 값을 미리 정해두지 않은 채로 BMP 파일을 만들고 싶다면 제네릭하게 동작할 수 있도록 해당 모듈을 만들어야 한다는 것을 암시한다.

7. Multi-Threading

프로그램이 State를 갖게 되어 프로세스로써 동작하고 있다면 이는 Thread 단위로 처리 되기 때문에 프로세스는 적어도 하나의 Main Thread를 갖고 있다는 것을 의미한다. 여기서 제시할 Thread 내용은 pthread_t를 어떻게 사용하는지 보다는 pthread_t를 이용할 때 함께 쓰이는 pthread_mutex_t의 초기화 방법과 Main Thread에서 pthread_t로 운용되는 Sub Thread들의 처리를 기다리는 방법에 대해서 다룰 것이다. 그리고 얼만큼의 Thread를 운용할 수 있는지에 대해서도 다뤄볼 것이다.

1) pthread_mutex_t 초기화

Thread 간 작업에 있어서 메모리 상의 공동 자원을 건드리게 되면 원치 않는 결과를 얻을 수 있기 때문에 해당 영역을 임계 영역(Critical Section)으로 두고 권한을 얻은 Thread만이 접근할 수 있도록 만드는 것이 중요하다. 임계 영역에 접근하기 위한 여러 방법 중 Mutex를 이용하기 위해선 pthread_mutex_t를 이용해야하고, 이에 대한 초기화가 필수적으로 수반된다. pthread_mutex_t를 초기화 하는데 있어서는 정적인 방식과 동적인 방식이 존재한다.

정적 초기화

pthread_mutext_t의 정적 초기화는 매크로로 정의된 함수를 이용함으로써 가능하다. <pthread.h>를 살펴보면 PTHREAD_MUTEX_INITIALIZER라는 매크로 정의문을 볼 수 있는데 이를 이용하는 것이다. 주의해야 할 점은 PTHREAD_MUTEX_INITIALIZERConstant Initializer 이므로 원칙적으로는 초기화 구문에서만 이용할 수 있다. 따라서 아래 코드는 컴파일을 수행하지 않는다.
int main(void) { pthread_mutex_t lock; lock = PTHREAD_MUTEX_INITIALIZER; return (0); }
C
복사
Constant Initializer를 올바르게 작성한구문은 아래와 같다.
int main(void) { pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; return (0); }
C
복사
일반적으로는 위처럼 이용하면 되지만, 42를 진행할 때는 변수 선언과 함께 초기화를 할 수 없기 때문이 이 방법을 이용할 수는 없다. 이 때는 Constant로 이용되는 값을 적절히 형 변환하게 되면 할당문에서 Constant Initializer를 이용할 수 있다.
int main(void) { pthread_mutex_t lock; lock = (pthread_mutex_t)PTHREAD_MUTEX_INITIALIZER; return (0); }
C
복사

동적 초기화

일반적으로 pthread_mutex_tattribute라는 설정 값에 따라서 각기 다른 속성을 갖게 된다. 기본적으로는 0 (NULL)이 할당되어 이용되고, PTHREAD_MUTEX_INITIALIZER가 이 방법에 해당한다. 이 프로젝트를 진행하는데 있어서 pthread_mutex_t에 별도로 속성 값을 매길 필요가 없기는 하지만 다른 프로젝트에서는 여러 속성을 띈 pthread_mutex_t를 이용해야할 수도 있다. 또한 상황에 따라 Run Time에 각기 다른 속성으로 이용해야 할 수도 있을 것인데, 해당 경우에 이용하는 방식이 동적 초기화이다. 이는 pthread_mutex_init이라는 함수를 호출함으로써 pthread_mutex_t를 초기화 할 수 있다. <pthread.h>를 살펴보면 아래와 같이 함수 원형이 주어진 것을 확인할 수 있다.
__API_AVAILABLE(macos(10.4), ios(2.0)) int pthread_mutex_init(pthread_mutex_t * __restrict, const pthread_mutexattr_t * _Nullable __restrict);
C
복사
따라서 아래 코드와 같이 이용하면 되고, 내 경우에는 정적 초기화 대신 동적 초기화를 이용하여 프로젝트를 진행했다.
#include <pthread.h> int main(void) { pthread_mutex_t lock; pthread_mutex_init(&lock, NULL); return (0); }
C
복사

2) pthread_join

pthread_create 함수를 통해서 Sub Thread를 생성하게 되면, 이는 Main Thread와는 별개의 Context로 동작하기 때문에 Sub Thread들의 작업이 끝내기도 전에 Main Thread의 작업이 끝날 수도 있고 Sub Thread의 작업 결과를 이용해야하는 경우에는 예기치 못한 접근이 일어날 수도 있다. 따라서 이렇게 Sub Thread의 결과를 기다려야 하는 경우에 이용하는 것이 pthread_join 함수이다. <pthread.h>에서 pthread_join의 함수 원형을 확인해보면 아래와 같다.
__API_AVAILABLE(macos(10.4), ios(2.0)) int pthread_join(pthread_t , void * _Nullable * _Nullable) __DARWIN_ALIAS_C(pthread_join);
C
복사
아래와 같이 10개의 Sub Thread를 생성하고, Thread를 동작시키는 함수에서는 자신의 인덱스에 해당하는 수를 출력하도록 만들었다. 해당 코드는 Sub Thread의 작업 완료를 보장하지 않기 때문에 Main Thread의 실행이 종료되면 작업 중에던 Sub Thread는 그 동작을 멈추게 된다. 따라서 출력 결과를 보면 0~9까지의 출력이 보장되지 않는 것을 확인할 수 있다.
#include <pthread.h> #include <stdio.h> void *thread_function(void *idx) { printf("%d\n", *(int *)idx); return (NULL); } int main(void) { int i; int n[10]; pthread_t threads[10]; i = -1; while (++i < 10) { n[i] = i; if (pthread_create(&(threads[i]), NULL, thread_function, &(n[i]))) return (1); } return (0); }
C
복사
Sub Thread들의 작업 완료를 보장하기 위해서 pthread_join 함수를 추가한 코드는 아래와 같고, pthread_create 때와는 달리 각 Sub ThreadReference를 넘기는 것이 아니라 Value를 넘기는 것을 확인할 수 있다. 출력 결과를 확인해보면, 0~9까지의 출력이 보장되는 것도 확인할 수 있다.
#include <pthread.h> #include <stdio.h> void *thread_function(void *idx) { printf("%d\n", *(int *)idx); return (NULL); } int main(void) { int i; int n[10]; pthread_t threads[10]; i = -1; while (++i < 10) { n[i] = i; if (pthread_create(&(threads[i]), NULL, thread_function, &(n[i]))) return (1); } while (i--) if (pthread_join(threads[i], NULL)) return (1); return (0); }
C
복사

3) sysctl kern

위의 예시를 통해 pthread를 생성하고 이 작업에 대한 완료를 보장하는 것까지 확인할 수 있었는데, 그렇다면 이런 Sub Thread들은 몇 개까지 생성이 가능할까? 시스템 단에서 유지하고 있는 이런 정보들은 특정 파일에 모두 기록되어 있다. Linux에서는 /proc 경로의 파일들에서 확인할 수 있는데, Mac OS X에서는 /proc이 존재하지 않는다. 따라서 Mac OS X상에서는 sysctl이라는 명령어를 통해서 시스템 단의 정보들을 확인할 수 있다.
sysctl 명령어의 매뉴얼을 살펴보면 커널의 상태를 찾아내고, 이를 이용할 수 있도록 만들어준다. sysctl의 인자 형태는 $(MIB를 따르는 이름).$(요소 이름)인 것을 확인할 수 있다. (.$(요소 이름)은 생략 가능하며, 이 때는 $(MIB를 따르는 이름)에 해당하는 모든 정보들을 출력해준다.)
Thread 관련 정보들 역시 커널 상의 정보이므로 아래 명령어를 통해서 확인할 수 있다.
자세히 살펴보면 Thread의 수와 관련된 항목으로 kern.num_threadskern.num_taskthreads가 있는 것을 확인할 수 있다. num_threads는 시스템에서 현재 실행 중인 모든 프로세스 (Task)들이 동시에 유지할 수 있는 최대 Thread 수를 의미하고, num_taskthreads는 하나의 프로세스 (Task)가 동시에 유지할 수 있는 최대 Thread 수를 의미한다. sysctl 내의 파일 시스템 관련 정보들과 같은 특정 정보들은 수정이 가능하지만, Thread와 같은 수치는 변경이 불가능하다.
이를 통해서 알 수 있는 것은, 작업이 충분히 가볍다면 4096개 이상의 Thread를 생성하여 운용하는 것도 가능하겠지만 그렇지 않은 경우에는 현재 운용이 가능한 Thread 수에 유의하여 코드를 작성해야 한다는 것이다. 이와 별개로 무작정 Thread를 늘려서 작업하는 것이 더 나은 성능을 보장하는 것은 아니라는 점도 알아둬야 한다.

8. Random Function Implementation

Ray Tracing in One Weekend를 번역하면서 Anti-Aliasing 구현에 요구되는 Random을 기재한 적이 있다. 해당 책은 C++로 진행되기 때문에 C++RandomC 언어에서의 Random과 비교하여 설명했었다. 간단하게 요약하자면, C 언어에서의 Random선형 합동(Linear Congruential)이라는 방식으로 만들어진다. 직접 Random 함수를 구현하지 않는 이상 선형 합동Random을 사용하기 때문에 그 성능이 C++Random 보다 좋기는 힘들다. 하지만 이번 프로젝트에서는 뛰어난 성능을 가진 Random을 요구하는 것이 아니기 때문에 간단한 방식으로 Random 함수를 만들어 이용할 것이다. (randsrand가 허용 함수가 아니므로)
가장 만들기 쉬운 방법은 선형 함수를 이용하는 것이다. 흔하게 접할 수 있는 선형 함수의 형태는 y=ax+by = ax + b인데, Seed 값을 통해서 매 함수 호출마다 바뀌는 Random 함수를 만들 수 있다. 함수 구현에 앞 서, 결과로 얻을 값은 int 타입으로 한다는 것을 전제한다.
ax+bax + b의 결과가 int 타입의 최대 범위인 214783647을 넘지 않는다면 함수 수행으로 얻은 Randomax+bax+b라는 상관 관계를 갖기 때문에 Random과는 거리가 멀어진다. 따라서 ax+bax+b의 결과가 Overflow가 나도록 설계하고 이를 양수로 이용할 수 있도록 만들어줘야 한다.
Overflow가 나도록 만드는데는 덧셈으로 이용되는 bb를 이용할 수도 있겠지만, 몇 회에 걸쳐 Overflow를 낼 수 있는 aa에 조금 더 신경 쓰는 것이 좋다. aa를 충분히 큰 값으로 만들어 여러 차례의 Overflow를 낼 수 있도록 만들었다면 bb는 그 값이 크든 작든 상관 없다. 이렇게 만들어진 값은 부호 비트가 1인 음수일 수 있기 때문에 항상 결과 값이 양수가 되도록 해야한다. 값이 0보다 작은지 판별하여 -1을 곱하는 것보다는 &를 통해 0x7FFFFFFF을 논리 곱셈을 수행하는 것이 더 낫다. (이렇게 하면 음수가 나왔을 때는 부호 비트 값이 0으로 나오기 때문에 양수가 될 뿐 아니라, 부호 비트가 잘렸기 때문에 ax+bax + b의 결과와는 조금 더 멀어질 수 있다.)
aabb의 값을 정했다면 인자로 들어갈 xx값에 대해서 생각해봐야 한다. 설계한 함수를 호출할 때는 항상 인자 xx를 넣어 호출하는 방식이 아니라 인자가 없는 형태로 호출할 것이기 때문이다. 이에 대해서는 초기 Seed값을 정해두고, 이를 호출할 때마다 갱신하는 식으로 이용하면 되는데 이에 적합한 것이 Seed값을 함수의 정적 변수로 선언하는 것이다. 이를 코드로 나타내면 아래와 같이 나타낼 수 있다.
int randv(void) { static int seed; seed = (seed * 1103515245 + 12345) & 0x7FFFFFFF; return (seed); }
C
복사
작성된 함수를 그대로 이용하는 것보다는 0result<10 ≤ result < 1 범위를 갖는 값으로 반환화도록 만들어주면 int 타입만큼의 범위 제한 늪에서 벗어날 수 있다. 결과로 얻은 값은 0result21474836470 ≤ result ≤ 2147483647의 범위를 갖기 때문에 각 항을 2147483648로 나누어주면 원하는 범위의 값으로 Scaling 할 수 있다. 코드는 아래와 같다.
double randv(void) { static int seed; seed = (seed * 1103515245 + 12345) & 0x7fffffff; return ((double)seed / (double)0x80000000); }
C
복사
0result<10 ≤ result < 1의 값을 갖도록 Random 함수를 만들었기 때문에 특정 범위에 존재하는 Random 함수를 만드는 것은 그리 어렵지 않다.
double randr(double min, double max) { return ((randv() * (max - min)) + min); }
C
복사
main 함수에서 randr 함수를 여러 차례 호출하여 0 ~ 100 사이의 결과가 나오는지 확인해보자. 첫 값이 항상 0으로 고정되는 것과 항상 정해진 Random 값이 나온다는 것을 유의하여 사용하면 된다. 이 방법 말고도 Shift Rotating 방식으로 간단한 선형 합동 Random을 만들 수도 있으니 제시된 방법이 마음에 들지 않는다면 이를 찾아보는 것도 좋을 것이다.
#include <stdio.h> double randv(void) { static int seed; seed = (seed * 1103515245 + 12345) & 0x7fffffff; return ((double)seed / (double)0x80000000); } double randr(double min, double max) { return ((randv() * (max - min)) + min); } int main(void) { int i; i = -1; while (++i < 10) printf("%d\n", (int)(randr(0, 100))); return (0); }
C
복사

9. Reference