Android

사실 내 로망중엔 OpenGL 도 있었어

최데브 2024. 8. 31. 14:43

카메라 관련 기술에 꾸준히 흥미가 있었는데

필터쪽으로 찾아보게 되면 항상 나오는 단어중 openGl 이라는게 있다.

openCV는 예전에 졸업 작품 만들때 써봤던 기억은 있는데 openGL 은 대충 그래픽 처리를 하는

뭐시기구나 하고만 있었다. 행렬도 나오고 좌표 , 카메라 이것저것 관련 지식들이 많이 필요하다보니

딥하게 알아보려고 하지 않았던거 같다.

 

마침 카메라 기능을 가진 클린아키텍쳐 샘플앱도 만들고 있겠다. 이번에 그냥 openGL 도

여기다가 써볼까한다.

 

직접 만들면서 개념도 정리해보자.

 

OpenGL의 기본 개념

OpenGL은 정점(Vertex)과 프래그먼트(Fragment)라는 기본 단위로 그래픽 데이터를 처리한다.

  1. 정점(Vertex): 화면에 그려질 점의 위치 데이터를 의미한다. OpenGL은 2D 또는 3D 좌표를 가지고 있으며, 이를 이용해 삼각형, 사각형 등의 도형을 구성한다.
  2. 프래그먼트(Fragment): 정점을 연결하여 만들어진 도형의 픽셀 단위로, 화면에 그려질 실제 색상 정보를 의미한다. 프래그먼트 셰이더(Fragment Shader)에서 프래그먼트의 색상을 결정한다.
  3. 셰이더(Shader): GPU에서 실행되는 작은 프로그램이다. GPU가 데이터를 그리는 방법을 알려주는 역할을 한다.각 정점이 화면 제어하는  정점 셰이더(Vertex Shader)와  각 프래그먼트가 그려지는 방식을 제어하는 프래그먼트 셰이더(Fragment Shader)가 있다

 

OpenGL Rendering Pipeline

  • 삼각형을 화면에 나타내기 위해 여러가지 과정을 거쳐 처리를 하게 되고 이러한 과정들을 렌더링 파이프라인이라고 한다. 그리고 이 파이프라인을 이용하려면 셰이더(Shader)라는 서브루틴(프로그램)을 이용해야한다.

OpenGl  구현의 기본

GLSurfaceView

opengl을 통해 그려지는 대상이 되는 view이다.

이놈은 객체 생성할 때 Renderer라는 놈을 붙여주어야 한다. 

나는 Compose 에서 AndroidView 로 불러와봤다.

    AndroidView(factory = { context ->
         //GLSurfaceView 는 openGl 컨텍스트를 생성하고 관리
        val glView = GLSurfaceView(context)
        glView.setEGLContextClientVersion(2) // OpenGL ES 2.0 사용
        glView.setRenderer(SimpleRenderer(context))// 렌더러 붙이기
        //glView.setRendererMode 라는것도 있는데 dirty 라는 옵션을 쓰면 drawing data 가 변했을때만 그리게 한다고 한다
        glView
    })

 

GLSurfaceView.Renderer

OpenGL로 그래픽을 그려주는 메소드를 정의하는 인터페이스 역할을 한다.

GLSurfaceView.setRenderer 메소드로 SurfaceView에 붙여주어야 한다. 렌더러는 다음 세가지의 메소드를 implement 해야한다

 

- onSurfaceCreated : GLSurfaceView 가 초기 생성될때 한번 불리는 메소드다. openGL 파라미터 초기화나 그래픽 오브젝트를 초기화 할때 사용된다. 나는 아래처럼 작업에 필요한 초기 데이터들을 생성하고 불러오는데 사용했다.

class SimpleRenderer(private val context: Context) : GLSurfaceView.Renderer {

    private var shaderProgram = 0
    private var textureId: Int = 0

    override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
        // 셰이더 프로그램 초기화
        shaderProgram = createShaderProgram()
        // 텍스처 로드 (예: 이미지 리소스)
        textureId = loadTexture(R.drawable.cat)
        // 정점 데이터 초기화
        initVertexData()
        // OpenGL 설정
        GLES20.glClearColor(0.0f, 0.0f, 0.0f, 1.0f)
    }
    .. 중략
   	
 }

 

- onDrawFrame : GLSurfaceView  에 그릴 때 마다 불리는 메소드다. 그래픽 오브젝트를 그리는 로직이 여기 구현되어야한다. 

    override fun onDrawFrame(gl: GL10?) {
        // 화면 지우기
        GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)

        // 셰이더 프로그램 사용
        GLES20.glUseProgram(shaderProgram)

        // 정점 데이터 활성화 및 전달(객체의 위치와 텍스처 매핑을 정의)
        val positionHandle = GLES20.glGetAttribLocation(shaderProgram, "aPosition")
        GLES20.glEnableVertexAttribArray(positionHandle)
        GLES20.glVertexAttribPointer(positionHandle, 2, GLES20.GL_FLOAT, false, 0, vertexBuffer)

        // 텍스처 좌표 활성화 및 전달(정점 및 텍스처 좌표 데이터를 GPU로 전송하여 그래픽 처리를 준비.)
        val texCoordHandle = GLES20.glGetAttribLocation(shaderProgram, "aTexCoord")
        GLES20.glEnableVertexAttribArray(texCoordHandle)
        GLES20.glVertexAttribPointer(texCoordHandle, 2, GLES20.GL_FLOAT, false, 0, texCoordBuffer)

        // 텍스처 활성화
        GLES20.glActiveTexture(GLES20.GL_TEXTURE0)//OpenGl은 여러개의 텍스쳐유닛을 지원하고 이 중에서 어느 유닛을 사용할지 지정, 여기서는 0
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId)// 텍스처 ID를 텍스처 유닛 0에 바인드. 이를 통해 이후의 텍스처 관련 작업은 이 텍스처를 대상으로 수행. 

        // 텍스처 유니폼 설정 (셰이더에서 사용할 텍스처를 설정하여 텍스처 매핑이 적용되도록 함.)
        val textureUniformHandle = GLES20.glGetUniformLocation(shaderProgram, "uTexture")
        GLES20.glUniform1i(textureUniformHandle, 0)
        //쉐이더 프로그램에서 uTexture라는 유니폼의 위치를 가져오고, 
        //해당 위치에 텍스처 유닛 0을 설정한다.
        //glUniform1i는 정수형 유니폼 값을 설정하는 함수로, 여기서는 텍스처 유닛 번호를 전달

        // 사각형 그리기
        GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)

        // 비활성화
        GLES20.glDisableVertexAttribArray(positionHandle)
        GLES20.glDisableVertexAttribArray(texCoordHandle)
    }

 

추가 설명 : 

 

  • glGetAttribLocation: 셰이더 프로그램 내에서 aPosition과 aTexCoord의 위치를 가져온다. 이 위치는 셰이더 코드에서 정의한 정점 및 텍스처 좌표의 속성.
  • glEnableVertexAttribArray: 지정된 위치의 정점 속성을 활성화하여 GPU가 해당 데이터를 사용하도록 설정.
  • glVertexAttribPointer: 정점 버퍼(vertexBuffer)와 텍스처 좌표 버퍼(texCoordBuffer)를 셰이더에 연결한다. 이 함수는 GPU가 데이터를 어떻게 해석해야 하는지(예: 데이터 형식, 크기 등)를 정의한다.
  • glDrawArrays: GPU에게 정점을 기반으로 삼각형들을 그리도록 지시한다. GL_TRIANGLE_STRIP을 사용하여 사각형을 구성하는 두 개의 삼각형을 연속적으로 그리게 한다.
  • glActiveTexture: 활성화할 텍스처 유닛을 선택한다.
  • glBindTexture: 선택한 텍스처 유닛에 텍스처 객체를 바인드한다.
  • glUniform1i: 셰이더에서 사용할 텍스처 유니폼 변수를 설정한다.

 

 

- onSurfaceChanged : GLSurfaceView   의 사이즈나 방향이 변경 될 때 호출된다. 나의 경우는 glViewport 를 사용하여 

openGL이 그릴 대상의 영역을 재지정 했다.

override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
    GLES20.glViewport(0, 0, width, height)
}

 

참고 : glViewport 함수

https://codinggirl.tistory.com/14

 

[GLUT/openGL] glViewport 함수

실습자료의 코드를 읽다가 glViewport 함수를 발견하였다. 이 함수가 대체 어떤 기능을 하는지 알아보는 과정에서 새로 알게된 용어들도 함께 정리해보려 한다. viewport 란 일단.. 컴퓨터, 휴대폰 등

codinggirl.tistory.com

 

쉐이더 만들기

솔직히 쉐이더 코드는 뭔지 잘 모르겠어서 찾아서 써봤다.

저렇게 쉐이더 코드를 작성하고 코드를 최종적으로  glShaderSoruce 로 전달하면 안드로이드에서도 해당 쉐이더를 쓸 수 있게 되는 모양이다. 하나의 그래픽엔진을 불러와서 쓰는 느낌..?

 

private fun loadShader(type: Int, shaderCode: String): Int {
    return GLES20.glCreateShader(type).also { shader ->
        GLES20.glShaderSource(shader, shaderCode)
        GLES20.glCompileShader(shader)

        // 컴파일 에러 체크
        val compileStatus = IntArray(1)
        GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compileStatus, 0)
        if (compileStatus[0] == 0) {
            GLES20.glDeleteShader(shader)
            throw RuntimeException("Shader compilation failed: ${GLES20.glGetShaderInfoLog(shader)}")
        }
    }
}
private fun createShaderProgram(): Int {
    val vertexShaderCode = """
        attribute vec4 aPosition;
        attribute vec2 aTexCoord;
        varying vec2 vTexCoord;
        void main() {
            gl_Position = aPosition;
            vTexCoord = aTexCoord;
        }
    """

    val fragmentShaderCode = """
        precision mediump float;
        varying vec2 vTexCoord;
        uniform sampler2D uTexture;
        void main() {
            vec4 color = texture2D(uTexture, vTexCoord);
            float gray = (color.r + color.g + color.b) / 3.0;
            gl_FragColor = vec4(gray, gray, gray, color.a);
        }
    """

    val vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, vertexShaderCode)
    val fragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER, fragmentShaderCode)
    return GLES20.glCreateProgram().also { program ->
        GLES20.glAttachShader(program, vertexShader)
        GLES20.glAttachShader(program, fragmentShader)
        GLES20.glLinkProgram(program)

        val linkStatus = IntArray(1)
        GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linkStatus, 0)
        if (linkStatus[0] == 0) {
            GLES20.glDeleteProgram(program)
            throw RuntimeException("Program link failed: ${GLES20.glGetProgramInfoLog(program)}")
        }
    }
}

 

 

위 코드는 그레이스케일로 만드는 쉐이더다.

 

텍스쳐 불러오기

파라미터로는 drawable 폴더에 있는 이미지를 불러왔다.

 

private fun loadTexture(resourceId: Int): Int {
    val textureHandle = IntArray(1)
    GLES20.glGenTextures(1, textureHandle, 0)

    if (textureHandle[0] != 0) {
        val options = BitmapFactory.Options()
        options.inScaled = false
        val bitmap = BitmapFactory.decodeResource(context.resources, resourceId, options)

        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureHandle[0])
        GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
        GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)

        GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0)
        bitmap.recycle()
    } else {
        throw RuntimeException("Error loading texture.")
    }

    return textureHandle[0]
}

 

이미지에 필터를 씌우는 과정 요약

OpenGL의 2D 텍스처 렌더링 개념

  1. 텍스처 매핑(Texture Mapping):
    • OpenGL에서 이미지를 표시하거나 필터를 적용하려면 텍스처(Texture)를 사용해야 한다. 텍스처는 이미지 데이터를 GPU 메모리에 저장한 것. 이미지를 텍스처로 로드한 후, 이 텍스처를 화면에 출력한다.
  2. 사각형(Quad) 렌더링:
    • 이미지를 화면에 표시하기 위해 일반적으로 두 개의 삼각형으로 구성된 사각형(Quad)을 그린다. 이 사각형은 텍스처를 매핑할 수 있는 평면 역할을 한다.
    • 사각형을 그리는 이유는 텍스처가 2D 이미지이기 때문. 텍스처를 매핑하려면 사각형과 같은 2D 평면이 필요하다.
  3. 필터 적용:
    • 프래그먼트 셰이더를 사용하여 각 프래그먼트(픽셀)의 색상을 조정함으로써 필터를 적용합니다. 프래그먼트 셰이더는 사각형의 각 픽셀에 대해 호출되며, 이 과정에서 텍스처 데이터를 가져와 필터를 적용하게 된다.
    • OpenGL은 텍스처가 매핑된 사각형을 화면에 그린 후, 프래그먼트 셰이더를 사용하여 각 픽셀에 필터 효과를 한다
    • glDrawArrays로 그린 사각형의 모든 프래그먼트(픽셀)에 대해 프래그먼트 셰이더가 호출된다.
    • 셰이더는 각 프래그먼트에서 텍스처 색상을 읽고, 필터를 적용한다. 이 과정으로 인해 화면에 필터가 적용된 이미지가 렌더링된다.

 

결과물

 

 

간단한 예시지만 개념들을 하나씩 알아가며 해보려니 쉽지 않았다.

깊게 팔수록 알아야할게 많아보인다.

다른 필터들을 추가해보면서 좀 더 공부해보고 싶어졌다.