[모두의 메모] OpenGL ES의 Shader, 그림 그리기 - boostcampwm-2021/android05-boomerang GitHub Wiki

렌더링을 위해서는 Shader가 필요합니다. 이번 포스트에서는 Shader에 대해 작성하도록 하겠습니다.

Shader

렌더링 파이프라인에서 Vertex ShaderFragment Shader는 필수적으로 사용자가 직접 구현해야 하는 부분입니다. GLSL이라는 언어를 사용하여 구현하여야 합니다.

Vertex Shader

입력되는 점마다 하나씩 수행됩니다. 예를 들어 삼각형을 하나 그린다고 하면 3개의 점이 필요하고, Vertex Shader는 총 세 번 수행되는 것입니다.

주된 역할은 점의 위치를 결정하는 것입니다. 각 점들은 NDC라고 하는 각 축마다 [-1.0, 1.0] 사이로 정의된 공간 내에 위치하도록 합니다. 2차원 좌표계에서는 왼쪽 아래가 (-1, -1)이고 오른쪽 위가 (1, 1)의 값을 가집니다.

도형에 별도로 색상을 넣지 않고 텍스처만 입힐 것이기 때문에 색상은 따로 지정하지 않았습니다.

uniform mat4 uMVPMatrix;
uniform mat4 uTexMatrix;
attribute vec4 aPosition;
attribute vec4 aTextureCoord;
varying vec2 vTextureCoord;
void main() {
    gl_Position = uMVPMatrix * aPosition;
    vTextureCoord = (uTexMatrix * aTextureCoord).xy;
}

4X4 Matrix에 4X1 Vertex를 곱해 새로운 Vertex 값을 출력할 수 있습니다.

MVPMatrix는 OpenGL ES에서 렌더링한 객체들을 사실적으로 표현하기 위해 조정하는 변환행렬입니다.

TexMatrix는 SurfaceTexture에서 getTransformMatrix(...)로 받은 변환행렬 정보입니다. 이미지가 전달되는 과정에서 뒤집히는 등의 변형이 발생해도, 변환행렬에 저장되어 있기 때문에 여기서 수정할 수 있습니다.

varying은 Vertex Shader의 출력이자 Fragment Shader의 입력으로 쓰일 수 있는, 두 Shader가 공유하는 형태의 변수입니다. 여기서 Fragment Shader로 보내야 할 정보는 텍스처 좌표(Texture Coordinate)입니다. 텍스처는 2차원 벡터이기 때문에(z축 값과 w 값을 제외한) vec2 형입니다.

gl_Position은 Vertex Shader의 출력으로, 결정된 Vertex의 위치를 내보내 이후 Primitive Assembly 단계에서 점, 선, 삼각형을 구성합니다.

Fragment Shader

#extension GL_OES_EGL_image_external : require

precision mediump float;
varying vec2 vTextureCoord;
uniform samplerExternalOES sTexture;
void main() {
    gl_FragColor = texture2D(sTexture, vTextureCoord);
}

외부 GLES 텍스처를 사용하였기 때문에, 첫 번째 줄의 #extension을 선언하여야 합니다.

precision은 실수부 연산의 정확도를 지정합니다. mediump는 16bit를 사용합니다(lowp: 10bit, highp: 32bit).

texture2D 함수는 텍스처에서 좌표(Vertex Shader으로부터 입력으로 받은 vTextureCoord)에 해당하는 텍셀의 색상 값을 반환합니다.

Vertex Buffer

Vertex Shader에 정점에 대한 입력값을 주기 위해서는 Vertex Buffer를 사용해야 합니다.

위 코드는 제가 GLSL에 대한 지식도 없고, OpenGL ES에 대한 지식도 부족했을 때 구글의 그래픽, 미디어 관련 예제 모음인 Grafika의 코드를 그대로 사용하였습니다. 굉장히 훌륭한 예제이고, Google I/O에서도 코드를 살펴볼 것을 권하고 있습니다. 하지만 프로젝트의 README 에도 적혀있듯이 구글의 공식 프로젝트도 아니고, 모든 부분이 최적화된 방법으로 작성된 코드도 아닙니다.

이 부분은 제 의견이 틀릴 수도 있지만(다른 생각을 가지고 계시다면 메일 등을 통해 알려주시면 대단히 감사하겠습니다), StackOverflow 등에서의 여러 의견을 종합한 내용들을 읽어보니 Buffer에 관해서는 개선될 여지가 있을 것으로 보입니다.

버퍼를 사용하는 방법은 두 가지가 있습니다. java.nio 패키지의 ByteBuffer를 사용할 수도 있고, OpenGL ES에서 버퍼를 생성할 수도 있습니다. 두 방식의 주요한 차이점은 메모리의 저장 위치에 있습니다. Java와 같이 클라이언트에서 생성한 버퍼는 CPU에서 접근 가능한 시스템 메모리에 위치하고, 이를 렌더링에 사용하기 위해서는 비디오 카드가 접근 가능한 메모리로 옮겨야 합니다.

OpenGL ES에서 제공하는 glBufferData를 통해 버퍼를 할당하면 담을 Vertex Array의 수정과 접근 빈도에 따라 GL_STATIC_DRAW, GL_DYNAMIC_DRAW, GL_STREAM_DRAW 등의 옵션이 주어집니다. 이 앱에서 점의 위치는 화면 전체로 고정하고, 텍스처만 계속 바뀌기 때문에 Vertex Array는 바뀌지 않습니다. 이 경우 GL_STATIC_DRAW를 적용할 수 있을 것입니다. Grafika에서는 Java의 패키지를 사용하는 전자의 방식을 사용하였는데, 이렇게 클라이언트의 버퍼를 사용하는 것은 OpenGL ES 3.2 에서는 사용이 불가능합니다.

그림 그리기

이 앱은 이미지를 그대로 렌더링해서 보여주는 것이 아니라 그림을 그려서 보여주고, 저장해야 합니다. 제가 그림을 그리기 위해 생각한 방식은 아래 그림과 같이 두 가지 방식이 있습니다.

draw

왼쪽의 방법은 Vertex Array에 Vertex 값을 넣고, Vertex Shader에서 각 점에 대해 너비를 계산해야 합니다. 제가 이 부분을 구현할 당시에는 OpenGL ES와 Shader에 대한 이해가 지금보다도 부족할 때여서 구현에 어려움이 있었고, 그래서 오른쪽 방식을 사용했습니다.

// 사용자로부터 입력받을 때
val last = currentPoint.last()
for (i in 1..50) {
    currentPoint.add(
        Triple(
            last.first + (x - last.first) * i / 100,
            last.second + (y - last.second) * i / 100,
            drawColor
        )
    )
}

// 그릴 때
currentPoint.forEach {
    GLES20.glClearColor(it.third.red, it.third.green, it.third.blue, 1f)
    GLES20.glEnable(GLES20.GL_SCISSOR_TEST)
    GLES20.glScissor(it.first, height - it.second, 15, 15)
    GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
    GLES20.glDisable(GLES20.GL_SCISSOR_TEST)
}

사용자로부터 입력받은 좌표 사이에 여러 개의 좌표를 더해 부드럽게 이어지는 것처럼 보이게 합니다. 그리고 그림을 그릴 때에는 Scissor Test에서 Fragment를 잘라내는 것을 이용해 각 좌표마다 glScissor로 잘라냈습니다.

Shader는 저에게 있어 아직 공부가 부족한 부분입니다. 추가로 학습하게 되면 내용을 보충, 개선하겠습니다. 다음 포스트에서는 이렇게 렌더링된 이미지를 어떻게 다른 인스턴스에 전달하는지에 대해 작성하겠습니다.