Android용 OpenGL 자습서: Mandelbrot 집합 생성기 구축
게시 됨: 2022-03-11OpenGL은 다양한 프로그래밍 환경에서 시스템 하드웨어에 매우 가깝게 액세스할 수 있는 강력한 플랫폼 간 API입니다.
그렇다면 왜 사용해야 할까요?
2D 및 3D 모두에서 그래픽에 대해 매우 낮은 수준의 처리를 제공합니다. 일반적으로 이렇게 하면 인터프리터 또는 고급 프로그래밍 언어로 인해 발생하는 문제를 피할 수 있습니다. 그러나 더 중요한 것은 GPU라는 주요 기능에 대한 하드웨어 수준 액세스도 제공한다는 것입니다.
GPU는 많은 응용 프로그램의 속도를 크게 높일 수 있지만 컴퓨터에서는 매우 특정한 역할을 합니다. GPU 코어는 실제로 CPU 코어보다 느립니다. 동시 작업 없이 특히 직렬인 프로그램을 실행한다면 거의 항상 GPU 코어에서 CPU 코어보다 느릴 것입니다. 주요 차이점은 GPU가 대규모 병렬 처리를 지원한다는 것입니다. 한 번에 수백 개의 코어에서 효과적으로 실행되는 셰이더라는 작은 프로그램을 만들 수 있습니다. 즉, 믿을 수 없을 정도로 반복적인 작업을 동시에 수행할 수 있습니다.
이 기사에서는 OpenGL을 사용하여 콘텐츠를 화면에 렌더링하는 간단한 Android 애플리케이션을 구축할 것입니다. 시작하기 전에 Android 애플리케이션 작성에 대한 지식과 일부 C 유사 프로그래밍 언어의 구문에 익숙해지는 것이 중요합니다. 이 자습서의 전체 소스 코드는 GitHub에서 사용할 수 있습니다.
OpenGL 튜토리얼과 안드로이드
OpenGL의 힘을 보여주기 위해 우리는 안드로이드 기기를 위한 비교적 기본적인 애플리케이션을 작성할 것입니다. 이제 Android의 OpenGL은 임베디드 시스템용 OpenGL(OpenGL ES)이라는 하위 집합으로 배포됩니다. 필요한 핵심 기능은 계속 사용할 수 있지만 기본적으로 이것을 OpenGL의 제거된 버전으로 생각할 수 있습니다.
기본적인 "Hello World"를 작성하는 대신 Mandelbrot 집합 생성기라는 믿을 수 없을 정도로 간단한 응용 프로그램을 작성합니다. Mandelbrot 집합은 복소수 분야를 기반으로 합니다. 복잡한 분석은 아름답고 광대한 분야이므로 실제 수학보다 시각적 결과에 더 중점을 둘 것입니다.
버전 지원
응용 프로그램을 만들 때 적절한 OpenGL 지원이 있는 사람에게만 배포되도록 하고 싶습니다. 매니페스트 선언과 애플리케이션 사이의 매니페스트 파일에서 OpenGL 2.0 사용을 선언하여 시작합니다.
<uses-feature android:glEsVersion="0x00020000" android:required="true" />
이 시점에서 OpenGL 2.0에 대한 지원은 어디에나 있습니다. OpenGL 3.0 및 3.1은 호환성이 향상되고 있지만 둘 중 하나를 작성하면 장치의 약 65%가 누락되므로 추가 기능이 필요할 것이라고 확신하는 경우에만 결정을 내리십시오. 버전을 각각 '0x000300000' 및 '0x000300001'로 설정하여 구현할 수 있습니다.
애플리케이션 아키텍처
Android에서 이 OpenGL 애플리케이션을 만들 때 일반적으로 표면을 그리는 데 사용되는 세 가지 기본 클래스가 있습니다. MainActivity
, GLSurfaceView.Renderer
확장 및 GLSurfaceView
구현. 거기에서 도면을 캡슐화할 다양한 모델을 생성합니다.
이 예제에서 FractalGenerator
라고 하는 MainActivity
는 본질적으로 GLSurfaceView
를 인스턴스화하고 모든 전역 변경 사항을 라인 아래로 라우팅합니다. 다음은 본질적으로 상용구 코드가 될 예입니다.
public class FractalGenerator extends Activity { private GLSurfaceView mGLView; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); //Create and set GLSurfaceView mGLView = new FractalSurfaceView(this); setContentView(mGLView); } //[...] @Override protected void onPause() { super.onPause(); mGLView.onPause(); } @Override protected void onResume() { super.onResume(); mGLView.onResume(); } }
이것은 또한 (몰입형 전체 화면과 같은) 다른 활동 수준 수정자를 넣을 클래스가 될 것입니다.
한 단계 더 깊이 들어가면 기본 보기 역할을 하는 GLSurfaceView
확장이 있습니다. 이 클래스에서는 버전을 설정하고, 렌더러를 설정하고, 터치 이벤트를 제어합니다. 생성자에서 setEGLContextClientVersion(int version)
으로 OpenGL 버전을 설정하고 렌더러를 생성 및 설정하기만 하면 됩니다.
public FractalSurfaceView(Context context){ super(context); setEGLContextClientVersion(2); mRenderer = new FractalRenderer(); setRenderer(mRenderer); }
또한 setRenderMode(int renderMode)
를 사용하여 렌더링 모드와 같은 속성을 설정할 수 있습니다. Mandelbrot 세트를 생성하는 것은 매우 비용이 많이 들 수 있기 때문에 RENDERMODE_WHEN_DIRTY
를 사용할 것입니다. RENDERMODE_WHEN_DIRTY 는 초기화 시와 requestRender()
를 명시적으로 호출할 때만 장면을 렌더링합니다. 설정에 대한 추가 옵션은 GLSurfaceView
API에서 찾을 수 있습니다.
생성자를 얻은 후에는 일반적인 터치 기반 사용자 입력에 사용할 수 있는 onTouchEvent(MotionEvent event)
와 같은 다른 메서드를 하나 이상 재정의할 수 있습니다. 여기서는 강의의 주요 초점이 아니므로 여기에서 너무 자세히 설명하지 않겠습니다.
마지막으로 렌더러로 이동합니다. 렌더러는 대부분의 조명 작업이나 장면 변경이 일어나는 곳입니다. 먼저 그래픽 세계에서 행렬이 어떻게 작동하고 작동하는지 조금 살펴봐야 합니다.
선형 대수학의 빠른 수업
OpenGL은 행렬 사용에 크게 의존합니다. 행렬은 좌표의 일반화된 변경 시퀀스를 나타내는 매우 간결한 방법입니다. 일반적으로 임의의 회전, 팽창/수축 및 반사를 수행할 수 있지만 약간의 기교로 번역도 수행할 수 있습니다. 기본적으로 이 모든 것은 카메라를 움직이거나 물체를 키우는 것을 포함하여 원하는 합리적인 변경을 쉽게 수행할 수 있음을 의미합니다. 행렬에 좌표를 나타내는 벡터를 곱하면 새 좌표계를 효과적으로 생성할 수 있습니다.
OpenGL에서 제공하는 Matrix 클래스는 우리가 필요로 하는 행렬을 계산하기 위해 이미 만들어진 여러 가지 방법을 제공하지만, 간단한 변환으로 작업할 때도 어떻게 작동하는지 이해하는 것은 현명한 생각입니다.
먼저 좌표를 처리하기 위해 4차원 벡터와 행렬을 사용하는 이유를 살펴보겠습니다. 이것은 실제로 변환을 수행할 수 있도록 좌표 사용을 정교하게 한다는 아이디어로 돌아갑니다. 3D 공간에서의 변환은 3차원만으로는 불가능하지만 4차원을 추가하면 이 기능이 가능해집니다.
이를 설명하기 위해 매우 기본적인 일반 스케일/변환 행렬을 사용할 수 있습니다.
중요한 참고 사항으로 OpenGL 행렬은 열 방식이므로 이 행렬은 {a, 0, 0, 0, 0, b, 0, 0, 0, 0, c, 0, v_a, v_b, v_c, 1}
, 일반적으로 읽는 방법과 수직입니다. 이것은 곱셈에서 열로 나타나는 벡터가 행렬과 동일한 형식을 갖도록 함으로써 합리화될 수 있습니다.
코드로 돌아가기
행렬에 대한 이 지식으로 무장하면 렌더러 설계로 돌아갈 수 있습니다. 일반적으로 이 클래스에서 Model, View 및 Projection의 세 가지 행렬의 곱으로 구성된 행렬을 만듭니다. 이것은 적절하게 MVPMatrix라고 불립니다. 여기에서 보다 기본적인 변환 세트를 사용할 예정이므로 여기에서 세부 사항에 대해 자세히 알아볼 수 있습니다. Mandelbrot 세트는 2차원 전체 화면 모델이며 실제로 카메라에 대한 아이디어가 필요하지 않습니다.
먼저 클래스를 설정해 보겠습니다. 렌더러 인터페이스에 필요한 메소드 onSurfaceCreated(GL10 gl, EGLConfig config)
, onSurfaceChanged(GL10 gl, int width, int height)
및 onDrawFrame(GL10 gl)
을 구현해야 합니다. 전체 클래스는 다음과 같이 보일 것입니다.
public class FractalRenderer implements GLSurfaceView.Renderer { //Provide a tag for logging errors private static final String TAG = "FractalRenderer"; //Create all models private Fractal mFractal; //Transformation matrices private final float[] mMVPMatrix = new float[16]; //Any other private variables needed for transformations @Override public void onSurfaceCreated(GL10 unused, EGLConfig config) { // Set the background frame color GLES20.glClearColor(0.0f, 0.0f, 0.0f, 1.0f); //Instantiate all models mFractal = new Fractal(); } @Override public void onDrawFrame(GL10 unused) { //Clear the frame of any color information or depth information GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT | GLES20.GL_DEPTH_BUFFER_BIT); //Create a basic scale/translate matrix float[] mMVPMatrix = new float[]{ -1.0f/mZoom, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f/(mZoom*mRatio), 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, -mX, -mY, 0.0f, 1.0f}; //Pass the draw command down the line to all models, giving access to the transformation matrix mFractal.draw(mMVPMatrix); } @Override public void onSurfaceChanged(GL10 unused, int width, int height) { //Create the viewport as being fullscreen GLES20.glViewport(0, 0, width, height); //Change any projection matrices to reflect changes in screen orientation } //Other public access methods for transformations }
또한 제공된 코드에는 디버깅 및 셰이더 사용을 지원하기 위해 checkGLError
및 loadShaders
라는 두 가지 유틸리티 메서드가 사용됩니다.
이 모든 것에서 우리는 프로그램의 다른 부분을 캡슐화하기 위해 명령 체인을 계속해서 전달합니다. 우리는 마침내 우리 프로그램을 이론적으로 변경하는 방법 대신 프로그램이 실제로 수행 하는 작업을 작성할 수 있는 지점에 도달했습니다. 이 작업을 수행할 때 장면의 지정된 개체에 대해 표시해야 하는 정보가 포함된 모델 클래스를 만들어야 합니다. 복잡한 3D 장면에서 이것은 동물이나 찻주전자가 될 수 있지만 훨씬 간단한 2D 예제로 프랙탈을 수행할 것입니다.
Model 클래스에서 우리는 전체 클래스를 작성합니다. 사용해야 하는 슈퍼클래스는 없습니다. 생성자와 매개변수를 받는 일종의 그리기 메서드만 있으면 됩니다.
즉, 본질적으로 상용구를 가져야 하는 변수가 여전히 많이 있습니다. Fractal 클래스에서 사용되는 정확한 생성자를 살펴보겠습니다.
public Fractal() { // initialize vertex byte buffer for shape coordinates ByteBuffer bb = ByteBuffer.allocateDirect( // (# of coordinate values * 4 bytes per float) squareCoords.length * 4); bb.order(ByteOrder.nativeOrder()); vertexBuffer = bb.asFloatBuffer(); vertexBuffer.put(squareCoords); vertexBuffer.position(0); // initialize byte buffer for the draw list ByteBuffer dlb = ByteBuffer.allocateDirect( // (# of coordinate values * 2 bytes per short) drawOrder.length * 2); dlb.order(ByteOrder.nativeOrder()); drawListBuffer = dlb.asShortBuffer(); drawListBuffer.put(drawOrder); drawListBuffer.position(0); // Prepare shaders int vertexShader = FractalRenderer.loadShader( GLES20.GL_VERTEX_SHADER, vertexShaderCode); int fragmentShader = FractalRenderer.loadShader( GLES20.GL_FRAGMENT_SHADER, fragmentShaderCode); // create empty OpenGL Program mProgram = GLES20.glCreateProgram(); // add the vertex shader to program GLES20.glAttachShader(mProgram, vertexShader); // add the fragment shader to program GLES20.glAttachShader(mProgram, fragmentShader); // create OpenGL program executables GLES20.glLinkProgram(mProgram); }
꽤 한 입, 그렇지? 운 좋게도 이것은 모델 이름을 저장하고 전혀 변경할 필요가 없는 프로그램의 일부입니다. 클래스 변수를 적절하게 변경하면 기본 모양에 대해 잘 작동합니다.

이 부분에 대해 논의하기 위해 몇 가지 변수 선언을 살펴보겠습니다.
static float squareCoords[] = { -1.0f, 1.0f, 0.0f, // top left -1.0f, -1.0f, 0.0f, // bottom left 1.0f, -1.0f, 0.0f, // bottom right 1.0f, 1.0f, 0.0f }; // top right private final short drawOrder[] = { 0, 1, 2, 0, 2, 3 }; // order to draw vertices
squareCoords
에서 정사각형의 모든 좌표를 지정합니다. 화면의 모든 좌표는 왼쪽 하단에 (-1,-1)
오른쪽 상단에 (1,1)
이 있는 그리드로 표시됩니다.
drawOrder
에서 정사각형을 구성하는 삼각형을 기반으로 좌표의 순서를 지정합니다. 특히 일관성과 속도를 위해 OpenGL은 삼각형을 사용하여 모든 표면을 나타냅니다. 정사각형을 만들려면 대각선(이 경우 0
에서 2
)을 잘라서 두 개의 삼각형을 만듭니다.
이 두 가지를 모두 프로그램에 추가하려면 먼저 원시 바이트 버퍼로 변환하여 배열의 내용을 OpenGL 인터페이스와 직접 인터페이스해야 합니다. Java는 OpenGL 구현에서 사용하는 포인터 기반 C 배열과 직접 호환되지 않는 추가 정보를 포함하는 객체로 배열을 저장합니다. 이를 해결하기 위해 배열의 원시 메모리에 대한 액세스를 저장하는 ByteBuffers
가 사용됩니다.
정점과 그리기 순서에 대한 데이터를 입력한 후에는 셰이더를 만들어야 합니다.
셰이더
모델을 생성할 때 정점 셰이더와 조각(픽셀) 셰이더의 두 가지 셰이더를 만들어야 합니다. 모든 셰이더는 여러 내장 함수, 변수 수정자, 프리미티브 및 기본 입력/출력이 추가된 C 기반 언어인 GLSL(GL Shading Language)로 작성됩니다. Android에서는 Renderer의 두 가지 리소스 메서드 중 하나인 loadShader(int type, String shaderCode)
를 통해 최종 문자열로 전달됩니다. 먼저 다양한 유형의 한정자를 살펴보겠습니다.
-
const
: 모든 최종 변수는 상수로 선언할 수 있으므로 쉽게 액세스할 수 있도록 값을 저장할 수 있습니다. π와 같은 숫자는 셰이더 전체에서 자주 사용되는 경우 상수로 선언할 수 있습니다. 컴파일러는 구현에 따라 수정되지 않은 값을 상수로 자동 선언할 것입니다. -
uniform
: Uniform 변수는 단일 렌더링에 대해 상수로 선언된 변수입니다. 그것들은 본질적으로 셰이더에 대한 정적 인수로 사용됩니다. -
varying
: 변수가 가변으로 선언되고 정점 셰이더에서 설정되면 프래그먼트 셰이더에서 선형으로 보간됩니다. 이것은 색상의 모든 종류의 그라디언트를 만드는 데 유용하며 깊이 변경에 대해 암시적입니다. -
attribute
: 속성은 셰이더에 대한 비정적 인수로 생각할 수 있습니다. 정점에 특정한 입력 세트를 나타내며 정점 셰이더에만 나타납니다.
또한 추가된 두 가지 다른 유형의 프리미티브에 대해 논의해야 합니다.
-
vec2
,vec3
,vec4
: 주어진 차원의 부동 소수점 벡터. -
mat2
,mat3
,mat4
: 주어진 차원의 부동 소수점 행렬.
벡터는 구성요소 x
, y
, z
및 w
또는 r
, g
, b
및 a
를 통해 액세스할 수 있습니다. 또한 여러 인덱스가 있는 모든 크기의 벡터를 생성할 수 있습니다. vec3 a
경우 a.xxyz
는 a 의 해당 값과 함께 vec4
를 반환 a
.
행렬과 벡터는 배열로 인덱싱될 수도 있으며, 행렬은 구성 요소가 하나만 있는 벡터를 반환합니다. 이것은 mat2 matrix
의 경우 matrix[0].a
가 유효하고 matrix[0][0]
을 반환한다는 것을 의미합니다. 이것들로 작업할 때 객체가 아닌 기본 요소로 작동한다는 것을 기억하십시오. 예를 들어 다음 코드를 고려하십시오.
vec2 a = vec2(1.0,1.0); vec2 b = a; bx=2.0;
이것은 a=vec2(1.0,1.0)
및 b=vec2(2.0,1.0)
을 남깁니다. 이는 객체 동작에서 기대할 수 있는 것이 아닙니다. 여기서 두 번째 행은 b
에 a에 대한 포인터를 제공 a
.
Mandelbrot Set에서 대부분의 코드는 모든 픽셀에서 실행되는 셰이더인 프래그먼트 셰이더에 있습니다. 명목상 정점 셰이더는 색상이나 깊이에 대한 변경과 같이 정점별로 있을 속성을 포함하여 모든 정점에서 작동합니다. 프랙탈에 대한 매우 간단한 정점 셰이더를 살펴보겠습니다.
private final String vertexShaderCode = "attribute vec4 vPosition;" + "void main() {" + " gl_Position = vPosition;" + "}";
여기서 gl_Position
은 정점의 좌표를 기록하기 위해 OpenGL에서 정의한 출력 변수이다. 이 경우 우리는 gl_Position
을 설정한 각 정점에 대한 위치를 전달합니다. 대부분의 응용 프로그램에서 MVPMatrix
vPosition
곱하여 정점을 변환하지만 프랙탈이 항상 전체 화면이 되기를 원합니다. 모든 변환은 로컬 좌표계로 수행됩니다.
조각 셰이더는 세트를 생성하기 위해 대부분의 작업이 수행되는 곳입니다. fragmentShaderCode
를 다음과 같이 설정합니다.
precision highp float; uniform mat4 uMVPMatrix; void main() { //Scale point by input transformation matrix vec2 p = (uMVPMatrix * vec4(gl_PointCoord,0,1)).xy; vec2 c = p; //Set default color to HSV value for black vec3 color=vec3(0.0,0.0,0.0); //Max number of iterations will arbitrarily be defined as 100. Finer detail with more computation will be found for larger values. for(int i=0;i<100;i++){ //Perform complex number arithmetic p= vec2(px*px-py*py,2.0*px*py)+c; if (dot(p,p)>4.0){ //The point, c, is not part of the set, so smoothly color it. colorRegulator increases linearly by 1 for every extra step it takes to break free. float colorRegulator = float(i-1)-log(((log(dot(p,p)))/log(2.0)))/log(2.0); //This is a coloring algorithm I found to be appealing. Written in HSV, many functions will work. color = vec3(0.95 + .012*colorRegulator , 1.0, .2+.4*(1.0+sin(.3*colorRegulator))); break; } } //Change color from HSV to RGB. Algorithm from https://gist.github.com/patriciogonzalezvivo/114c1653de9e3da6e1e3 vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0); vec3 m = abs(fract(color.xxx + K.xyz) * 6.0 - K.www); gl_FragColor.rgb = color.z * mix(K.xxx, clamp(m - K.xxx, 0.0, 1.0), color.y); gl_FragColor.a=1.0; }
대부분의 코드는 집합이 작동하는 방식에 대한 수학 및 알고리즘일 뿐입니다. fract
, abs
, mix
, sin
및 clamp
와 같은 여러 내장 함수의 사용에 유의하십시오. 이 함수는 모두 벡터 또는 스칼라 및 반환 벡터 또는 스칼라에서 작동합니다. 또한 벡터 인수를 사용하고 스칼라를 반환하는 dot
사용됩니다.
이제 셰이더를 사용할 수 있도록 설정했으므로 모델에 draw
기능을 구현하는 마지막 단계가 있습니다.
public void draw(float[] mvpMatrix) { // Add program to OpenGL environment GLES20.glUseProgram(mProgram); // get handle to vertex shader's vPosition member mPositionHandle = GLES20.glGetAttribLocation(mProgram, "vPosition"); mMVPMatrixHandle = GLES20.glGetUniformLocation(mProgram, "uMVPMatrix"); //Pass uniform transformation matrix to shader GLES20.glUniformMatrix4fv(mMVPMatrixHandle, 1, false, mvpMatrix, 0); //Add attribute array of vertices GLES20.glEnableVertexAttribArray(mPositionHandle); GLES20.glVertexAttribPointer( mPositionHandle, COORDS_PER_VERTEX, GLES20.GL_FLOAT, false, vertexStride, vertexBuffer); // Draw the square GLES20.glDrawElements( GLES20.GL_TRIANGLES, drawOrder.length, GLES20.GL_UNSIGNED_SHORT, drawListBuffer); // Disable vertex array GLES20.glDisableVertexAttribArray(mPositionHandle); FractalRenderer.checkGlError("Test"); }
이 함수는 uniform
변환 행렬 및 attribute
위치를 포함하여 모든 인수를 셰이더에 전달합니다.
프로그램의 모든 부분을 조립한 후 마침내 실행할 수 있습니다. 적절한 터치 지원이 처리되면 절대적으로 매혹적인 장면이 그려집니다.
부동 소수점 정확도
조금 더 확대하면 이미지가 깨지기 시작합니다.
이것은 뒤에 있는 집합의 수학 및 OpenGL에서 숫자가 저장되고 처리되는 방식과 관련된 모든 것과 전혀 관련이 없습니다. 최근 배정밀도에 대한 지원이 이루어 float
double
을 지원하지 않습니다. 우리는 셰이더에서 precision highp float
와 함께 사용할 수 있는 가장 높은 정밀도 float로 특별히 지정했지만 그것으로도 충분하지 않습니다.
이 문제를 해결하려면 두 개의 float
를 사용하여 double
을 에뮬레이트하는 것이 유일한 방법입니다. 이 방법은 속도에 다소 심각한 비용이 있지만 실제로 기본적으로 구현된 실제 정밀도의 크기 내에서 제공됩니다. 더 높은 수준의 정확도를 원하는 경우 독자에게 연습 문제로 남겨 둘 것입니다.
결론
몇 가지 지원 클래스를 통해 OpenGL은 복잡한 장면의 실시간 렌더링을 빠르게 유지할 수 있습니다. GLSurfaceView
로 구성된 레이아웃 생성, Renderer
설정, 셰이더로 모델 생성은 모두 아름다운 수학적 구조의 시각화에서 절정에 달했습니다. OpenGL ES 응용 프로그램 개발에 많은 관심을 가지기를 바랍니다!