꽃미남 프로그래머 김포프가 창립한 탑 프로그래머 양성 교육 기관 POCU 아카데미 오픈!
절찬리에 수강생 모집 중!
프로그래밍 언어 입문서가 아닌 프로그래밍 기초 개념 입문서
문과생, 비전공자를 위한 프로그래밍 입문책입니다.
jobGuid 꽃미남 프로그래머 "Pope Kim"님의 이론이나 수학에 치우치지 않고 실무에 곧바로 쓸 수 있는 실용적인 셰이더 프로그래밍 입문서 #겁나친절 jobGuid "1판의내용"에 "새로바뀐북미게임업계분위기"와 "비자관련정보", "1판을 기반으로 북미취업에 성공하신 분들의 생생한 경험담"을 담았습니다.
Posted by 알 수 없는 사용자
반갑습니다. cagetu 입니다. 

다른 분들 처럼 하나의 주제에 마음을 두고 연재를 하지 못하고, 그냥 이것 저것 닭~치는 대로 방황을 하고 있습니다. 오늘은 완전 또 이상한 곳으로 튀어서, OpenGL ES에 대해서 알아보려고 합니다.

제가 가지고 있는 아이폰 3GS가 얼마전 2년 약정이 끝난 것을 보면, 벌써 아이폰 개발 열풍이 꽤 많은 시간이 지났습니다.

2년 전으로 거슬러 올라가서 생각해보면, 개발자들이 너도 나도 아이폰으로 무엇인가를 만들어 보겠노라며, 모두들 아이폰을 장만하고, 맥북을 장만하고 OpenGL이나 Cocoa 등을 만지작 만지작 해봤을 거라 생각됩니다만, 아마도 오래 갔을리가 없겠죠?! ㅎ
맥북은 윈도우를 설치해서 워드질하고 있고, 아이폰은 그냥 카톡질이나 하고 있네요. ㅎㅎ

현재의 개발 모습은 어떨까요? 다들 아시다시피~ 대세는 요놈입니다.

아니면, 돈이 초큼 더 있다면, 언리얼 3 엔진을 하기도 합니다. 즉, 모바일도 엔진을 구매하는 시대입니다. 
물론, 엔진을 구매하지 않는다고 해도, cocos2D 라는 멋진 놈이 있기도 합니다. ㅎ

그런데, 왜 뒤늦게 OpenGL 렌더러 타령이냐? 그러게요?! 왜 그럴까요?

사실 이런 시대에 어쩌면 OpenGL ES 따위 몰라도 게임 만드는데 전혀 지장이 없을지도 모릅니다. 게임엔진들이 받쳐줄테니까요.
하지만, 모바일 디바이스들도 점점 발전하고, 게임 엔진 가격은 점점 비싸지고 있습니다. 거기에 경쟁은 점점 더 치열해지고 있지요. 이럴 때는 자체 엔진도 힘을 발휘 할 수 있는 충분한 기회가 있을수도 있을거라 생각이 드는군요.
아니면, 그냥 개발자들의 취미나 관심으로 한번 정도 관심을 가져봐도 좋을 듯 합니다. WebGL이 얼마나 주목을 받을지 모르지만, 좀 봐두는 것도 나쁘지는 않을테니까요. 

잡설이 길었군요~ 암튼 그런 의미로, 이번에는 OpenGL ES 입니다.

"이것만 보면 OpenGL ES용 렌더러를 만들 수 있다!~ 뙇~" 뭐 이런 건 아니고요~ DirectX에 익숙한 프로그래머들이 OpenGL ES을 사용할 때, 겪을 수 있는 시행착오들을 줄여보고자 제가 한 캐삽질 경험을 이야기해보도록 하겠습니다. 

셰이더를 사용할 수 있는 ES 2.0을 기준으로 잡았으며, 기본적인 내용은 OpenGL ES에 대한 서적들이 꼼꼼하게 잘 설명을 하고 있기 때문에, 특별히 다루지 않겠습니다.


OpenGL ES 2.0

하드웨어적인 제약이 심한 임베디드 환경을 위해서 더 작고 가벼운 OpenGL의 표준을 재정했는데, 이것이 바로 OpenGL ES(OpenGL Embedded System)입니다.

OpenGL ES의 표준은 kronos group(
http://kr.khronos.org/)에서 관리를 하고 있습니다. OpenGL과 OpenGL ES와의 발전 관계를 보면 다음과 같습니다.

   - OpenGL 1.3 -> OpenGL ES 1.0
   - OpenGL 1.5 -> OpenGL ES 1.1
   - OpenGL 2.0 -> OpenGL ES 2.0 (2007년도)

가장 주목할 만한 것이 바로 최신 버전인 OpenGL ES 2.0 입니다. 1.x 버전은 그래픽 처리에 대해서 고정파이프라인(Fixed Pipeline)을 사용하는 반면에, 2.0 버전은 프로그래머블 셰이더(Programmable Shader)를 이용할 수 있습니다. 이를 통해서, 상위 버전인 OpenGL과 DirectX와 마찬가지로 강력하고 빠른 처리가 가능해졌습니다. 멋지죠?! ㅎㅎ
따라서, 1.x 버전과 2.0 버전은 서로 호환성을 가지지 않습니다. 즉, 플랫폼에 맞추어서 적절히 적용할 수 있도록 처리를 해주어야 한다는 의미가 되겠습니다.


아이폰의 경우에는 3GS 모델 이상 부터가 ES 2.0을 지원하고 있고, 최근 나오는 스마트폰의 경우에는 일반적으로 ES 2.0 을 거의 지원하고 있습니다.
 
OpenGL ES 셰이더

OpenGL ES 2.0 의 셰이더는 기본적으로 GLSL 문법을 사용하고 있습니다. GLSL에 대해서 간단히 소개하면, OpenGL Shader Language 의 약자로 DirectX에서 사용하는 HLSL과 유사한 상위 레벨 셰이더 언어입니다.

GLSL의 문법은 HLSL을 사용해보셨다면, 어렵지 않게 익히실 수 있을텐데요.
자세한 것은 참고자료(http://mew.cx/glsl_quickref.pdf )를 통해서 보시면 되겠습니다만, 간단하게 정리를 해보도록 하겠습니다.

GLSL 에서의 정식 명칭은 Vertex Shader와 Fragment Shader 입니다. 보통 DirectX에서는 Vertex Shader와 Pixel Shader라고 보통 많이 이야기를 하지요. (이후 부터는 그냥 Vertex Shader와 Pixel Shader로 통일해서 사용하겠습니다.)

DirectX의 경우, 보통 HLSL을 직접 사용하지 않고, Effect 파일(.fx)을 많이 사용하는데, Effect 파일의 경우, 하나의 파일에서 Vertex/Pixel Shader를 모두 작성하는 반면에, GLSL에서는 보통 Vertex Shader와 Pixel Shader 파일을 나누어서 작성합니다. (물론, Unity3D 등과 같이 GLSL을 감싸는 자신만의 셰이더 파일을 만들어 사용하기도 합니다.)

버텍스 셰이더의 경우, 크게 분류하면, "Vertex 입력 선업부, 셰이더 변수 선언부, Vertex 출력부, 본문"으로 나눌 수 있고요.
픽셀 셰이더의 경우에는 "Vertex 출력(입력)부, 셰이더 변수 선언부, 본문"으로 나눌 수 있습니다.  
이는 다음과 같은 키워드로 분류가 됩니다.

   - attribute : 버텍스 입력을 정의한다. (DirectX의 버텍스 입력 구조체와 동일)
   - uniform : 셰이더 변수를 정의한다. (DirectX의 셰이더 변수를 선언하는 것과 동일하다.) 
   - varying : 버텍스 셰이더에서 픽셀 셰이더로 전달할 변수들을 정의한다. (DirectX의 버텍스 출력 구조체와 동일)
   - void main : 본문 
       - gl_Position : 버텍스 셰이더에서 버텍스 연산 결과를 출력하기 위한 변수이다.
       - gl_FragColor : 픽셀 셰이더의 최종 결과를 출력하기 위한 변수이다.


그렇다면, 이렇게 셰이더에서 정의된 내용들과 네이티브 코드에서 어떻게 연결해줄까요?

GLSL은 HLSL에서 처럼 Sementic이라는 개념이 없습니다. 대부분 셰이더에서 선언된 순서를 인덱스로 하여 네이티브 코드에서 연결을 해주거나, 이름을 이용해서 셰이더와 연결을 해줍니다.

GLSL을 사용하면서 굉장히 당황스러웠던 부분은 바로, 최적화를 위해서 "셰이더에서 사용하지 않으면, 삭제해버린다"라는 것이 었는데요. GLSL을 이용해서 셰이더 코드를 작성했으나, 실제로 렌더링 결과에 영향을 주지 않는 셰이더 코드와 변수들을 모두 최적화를 위해서 컴파일 할 때 모두 삭제가 됩니다. 예를 들면, 아래와 같은 경우에는 DiffuseMap, DiffuseLight의 부분은 실제 코드에서 사용되고 있지 않기 때문에, 자동으로 삭제가 되므로 최종적으로는 "gl_FragColor = vec4(1.0);"이 되는 것입니다. 
void main()
{
//<@ 사용하지 않으니, 컴파일시 삭제되요~ vec4 DiffuseMap = texture2D(DiffuseTextexture, uv); vec4 DiffuseLight = max(0.0, dot(normal, lightdir) * normal;
//>@ gl_FragColor = vec4(1.0); }
이 내용을 잘 기억해두지 않으시면, 굉장히 까다로운 에러가 마구 튀어나오니 주의 하셔야 합니다...

[attribute]

attribute는 기본적으로 DirectX의 Vertex Declaration과 동일하게 생각하시면 됩니다.
struct Vertex
{   
   vec3 position;
   vec3 normal;
   vec2 texcoord;
};
라는 버텍스 구조체가 있다면, attribute는 다음과 같이 선언해주면 됩니다.
attribute vec3 position;
attribute vec3 normal;
attribute vec2 texcoord;
일반적으로 HLSL의 경우에는 버텍스 구조체를 정의하고, 버텍스 버퍼에 맞추어서 Vertex Declaration을 정의했다면, 셰이더에서 사용여부와 상관없이 Vertex Declaration을 SetVertexDeclaration으로 설정을 해주기만 하면 됩니다. 
하지만, GLSL의 경우에는 버텍스 정의를 했다고 하더라도, 실제 셰이더에서 사용하지 않는다면, Attribute가 삭제되어 버리기 때문에, 실제로 셰이더 "어떤 버텍스 요소들이 사용되고 있는가?"를 체크해서 바인딩을 해주어야 합니다. 

즉, glGetAttribLocation를 이용해서, 버텍스 요소가 사용되고 있는지 체크를 하고, 사용되는 버텍스 요소라면, "glVertexAttribPointer(index, size, type, normalized, stride, pointer)" 를 사용해서, attribute와 선언해주면 됩니다. GLSL은 Sementic이라는 개념이 없기 때문에, 이름 기반으로 처리를 해야합니다.

주의사항...
1. 쉽게 처리를 하기 위해서는 Sementic과 같이 내부에서 이름과 타입을 미리 정의해놓고 사용하면 좋습니다. 
2. 버텍스가 Pos, Normal, UV라고 설정했을 때, 셰이더에서 Pos, UV만 사용하다면, 
glVertexAttribPointer 를 설정할 때, 지워진 버텍스 요소를 고려하여, Offset을 잘 설정해주셔야 합니다.
3. 
normalized 인데요. true로 설정된다면, normalized된 value들이 하나의 integer format내로 저장되며, [-1, 1]이나 [0, 1]로 매핑됩니다. 따라서, COLOR 정보나, Skinning 처리 시 BoneWeight 정보들을 normalize를 true로 해주어야 합니다. 

[uniform]

uniform의 경우에는 일반적으로 변수의 이름을 기반으로 네이티브 코드와 연결을 해줍니다. 일반적으로 DirectX에서 셰이더를 처리할 때와 유사하게 사용하시면 됩니다. 
특이한 것 하나는 셰이더 변수에 배열을 사용할 때, "uniform mat4 BoneList[_MAXBONE];"이라고 선언되었을 때, 이 셰이더 변수의 이름은 "BoneList[0]"이 된다는 것입니다.

uniform 또한 사용하지 않을경우에는 자동으로 셰이더 내부에서 삭제가 되어 버립니다. 이 점을 참고하셔야 합니다.


보신 것과 같이 HLSL과 비교했을 때, 전체적인 흐름이 많이 다른지 않습니다. 단지 작업을 하실 때, OpenGL의 좌표계 특성과 문법의 차이 정도만 익숙해진다면, HLSL을 사용하는 것처럼 셰이더를 자유롭게 컨트롤 할 수 있을 것입니다.


[컨트롤 하고 싶은 욕구가 막 생기십니까?! ㅎㅎ]


버텍스 / 인덱스 버퍼 / Draw

특별히 내용상으로 다른 부분은 없습니다. 하지만, DirectX에 익숙한 개발자라면, 충분히 헷갈릴 수 있는 부분이 있기 때문에 한번 같이 보면 좋을 것 같아서 비교해보도록 하겠습니다.

DirectX
Vertex Buffer / Index Buffer를 만들고, SetSourceStream, SetIndices를 이용해서, 바인딩하고, DrawIndexedPrimitive를 이용하여 그려줍니다. 
OpenGLES
Vertex Buffer / Index Buffer를 만들고, glBindBuffer를 이용해서 바인딩해주고, glDrawElement를 이용해서 줍니다.


딱 봐도 다른 부분이 없습니다. 아직까지는... 훗~

여기서의 OpenGLES 2.0에서의 가장 큰 특징인 Lock/Unlock에 관련된 API를 지원하지 않는다는 것입니다. Lock / Unlock과 동일한 개념을 구현하려면, glBindData / glSubBindData를 이용하면 됩니다. 어떤 식으로 처리 되는지는 참고자료를 보시기 바랍니다(http://www.arcsynthesis.org/gltut/Positioning/Tutorial%2003.html ). 보시면 아시겠지만, DirectX에서의 Lock / Unlock하는 것과 크게 달라보이지 않습니다.
(좀 더 디테일하게 구현 사례를 보시고 싶으신 분들은 Orge3d 엔진의 "OgreGLESHardwareVertexBuffer.cpp"를 참고하시면 되겠습니다.) 


언뜻보면, 크게 달라보이지 않는데, 문제는 Vertex Offset에 있습니다.

DirectX에서 렌더링이 시작되는 Vertex의 시작 인덱스는 어떻게 구해지는지 보도록하지요.

DrawIndexedPrimitive(Type, BaseVertexIndex, MinIndex,  NumVertices, StartIndex, PrimitiveCount)
- BaseVertexIndex : IndexBuffer에 저장된 각 VertexBuffer의 인덱스에 추가되는 값
- StartIndex : IndexBuffer의 첫번째 Index를 사용
- MinIndex : 사용되는 가장 작은 VertexBuffer의 인덱스
- NumVertices : 버텍스개수. 첫 버텍스 인덱스는 BaseVertexIndex + MinIndex의 위치임
 
SetSourceStream(StreamNumber, pStreamData, OffsetInBytes, Stride)
- OffsetInBytes : 버텍스 Stream의 시작점에 대한 Offset (Offset된 위치부터 "버텍스 버퍼의 인덱스가 0이 됨") 

자~ 다들 기억 나시나요? 아마 많은 분들이 이 변수 설정에 따른 Index의 위치에 대해서 많이 헷갈리실 수 있을 것입니다.
즉, 현재 그리고자 삼각형(그냥 간단하게...)의 버텍스 버퍼에서의 시작 위치는 "OffsetInBytes + BaseVertexIndex + MinIndex + StartIndex"가 되겠네요. (맞나요???)

OpenGL의 경우에는 불행히도, SetSourceStream에 해당하는 OffsetInBytes의 기능이 없습니다.  

glDrawElements(type, count, type, indices) 
-  indices : 시작되는 indexbuffer의 포인터 (StartIndex만큼 포인터를 이동시켜서 설정해준다.)

즉, "StartIndex" 정도가 지정할 수 있는 전부가 되겠습니다. DirectX에서 사용하던 OffsetInBytes의 기능을 적용하려면, StartIndex에 OffsetInBytes를 추가해주던가, 아니면, 버텍스 버퍼에 값을 채울 때, 항상 0번부터 채워서 Offset 자체가 없도록 만들 수 있을 것입니다. 

특히, 배칭과 같은 처리를 하기 위해서는 Dynamic Buffer를 운영하여 하는데, Offset과 Lock에 대해서 그 차이점이 확실히 알고 있어야 조금 삽질을 줄일 수 있습니다.

결론적으로, DirectX에서 구현했던 내용을 OpenGLES로 포팅할 때, 그 차이에 대해서 주의하지 않으면, 아마 꽤 많은 시간을 삽질하셔야만 할 것입니다. (제가 그랬다구욧~ ㅜㅜ) 

깊이버퍼의 정밀도

DirectX에서 보통 설정하는 깊이버퍼 옵션은 D24S8입니다. 즉, 24비트 깊이버퍼와 8비트 스텐실 버퍼라는 말이겠지요.

OpenGL ES의 경우에는 16비트 깊이 버퍼를 이용하게 됩니다. 이는 렌더타겟에 깊이버퍼(Z버퍼)를 생성해서 붙여주어도 마찬가지로 16비트 깊이 버퍼를 사용합니다.

정밀도가 부족한  OpenGL의 경우, 상대적으로 먼 거리의 객체의 경우에는 z-fighting문제가 발생할 수 있습니다. 특히, 이유는 모르겠지만, FrameBuffer에서 깊이버퍼를 설정하고 렌더링했을 때보다, 렌더타겟에 장면을 그리기 위해서, 깊이버퍼를 생성해서 렌더타겟과 연결해주는 경우는 더욱 원거리 z-fighting이 심해집니다. (아이폰의 경우, 기본적으로 이렇게 버퍼를 생성해서 사용하기 때문에, z-fighting 문제를 해결해야 합니다.)
 
이 문제를 해결하기 위해서는 다음과 같이 처리를 해야 합니다.

   - near 평면을 너무 가깝게 설정하지 않는다.
   - far 평면을 너무 멀리 하지 않는다.
   - 오브젝트이 크기를 스케일해서 (축소) 사용한다.
   - 거리가 멀리 있는 오브젝트는 Z-Test를 끈다.
   - glDepthRangef를 이용하여, 렌더링 구간을 지정해주고, 근거리와 먼거리를 나누어서 두 패스로 렌더링 한다. 



여기서 재미있는 것은 glDepthRange를 이용해서, 깊이버퍼의 구간을 지정할 수 있다는 것인데요. 그래서, 원거리와 근거리를 나누어서 렌더링하기도 하네요.
glDepthRangef(0.0, 0.5);
draw_near_scene();
glDepthRange(0.5, 1.0);
draw_far_scene();
특별한 처리가 없이, 적당히 잘 나오게 하려면, near를 크게 하거나, 전체의 축척을 작게 설정하는 것이 가장 효율적으로 보입니다.

마무리

보신것과 같이 DirectX와 OpenGLES가 그렇게 많이 다른편은 아닙니다. 사실 API도 굉장히 단순하고, 직관적입니다. 따라서, DirectX에 익숙한 국내 개발자들도 충분히 쉽게 익히고 사용할 수 있습니다. 기본적은 3D 개념이 다른게 있을리도 없습니다. 단지, 만들면서, 두 API나 문법의 차이 때문에 막히는 부분이 힘들 뿐입니다. 

하지만, 이런 작업 상의 Tip 같은 내용을 많이 접하기가 어려워서, 나름 삽질을 꽤 많이 했습니다. 제가 삽질한 내용이 부디 다른 사람들의 삽질을 조금이나마 막아 주기를 바라는 마음에 이 글을 씁니다. 조금이나마 도움이 되었으면 좋겠군요. ㅎㅎ.

앞으로, OpenGL ES는 WebGL 뿐만 아니라, PSVita에도 사용하고 있기 때문에, 기회가 된다면, 휴대용 콘솔 게임도 한번 도전해 보고 싶네욤.. ㅎㅎ
 

그러니, 만들어 머겅... 두 번 머겅.... 끝!

참고자료

OpenGL ES로 모바일용 렌더러를 만들고 싶으시다구요?! 스펙을 참고하시려면 여기를...
- 유니티3D 문서창고 : 
http://unity3d.com/unity/ 
- 언리얼 모바일 개발문서 : http://udk.com

OpenGL ES 강좌
-  OpenGL ES 및 cocos2D 강좌

OpenGL ES Programming Guide
https://developer.apple.com/library/ios/#documentation/3DDrawing/Conceptual/OpenGLES_ProgrammingGuide/Introduction/Introduction.html
반응형
,