Blog / Dev / Android MediaCodec GPU 영상 인코딩 — OpenG...
Dev

Android MediaCodec GPU 영상 인코딩 — OpenGL ES로 하드웨어 가속하기

Android MediaCodec과 OpenGL ES를 조합해 GPU 가속 영상 인코딩을 구현한다. FFMPEG 소프트웨어 인코딩의 속도 한계, Surface 입력, 셰이더 필터 적용까지 실전 코드로 정리한다.

쇼트폼 영상 편집 앱 Mix를 개발할 때 가장 큰 기술적 도전은 영상 필터를 빠르게 인코딩하는 일이었다. 처음에는 FFMPEG의 소프트웨어 인코더를 사용했는데, 30초 영상에 필터를 입히는 데 거의 1분이 걸렸다. GPU를 활용하면 이를 크게 줄일 수 있다는 걸 알고 MediaCodec + OpenGL ES 조합을 도입했다.

문제 정의

기존 파이프라인:

원본 영상 → FFMPEG 디코딩 → CPU 필터 처리 → FFMPEG 인코딩
  • 30초 영상 + 빈티지 필터 → 약 58초 소요
  • CPU 사용률 100% 지속, 기기 발열 심함

목표: 실시간 수준(30초 영상 → 10초 이내)

해결 방향: Surface 기반 인코딩

MediaCodec을 Surface 입력 모드로 쓰면 OpenGL ES로 렌더링한 프레임을 GPU 메모리에서 직접 인코더로 넘길 수 있다. CPU-GPU 메모리 복사가 사라진다.

MediaExtractor → MediaCodec(디코더)
                      ↓ (SurfaceTexture)
              OpenGL ES (필터 셰이더)
                      ↓ (Surface)
              MediaCodec(인코더) → MP4

핵심 구현

1. 인코더 Surface 생성

val encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC)

val format = MediaFormat.createVideoFormat(
    MediaFormat.MIMETYPE_VIDEO_AVC,
    outputWidth,
    outputHeight
).apply {
    setInteger(MediaFormat.KEY_BIT_RATE, 4_000_000) // 4Mbps
    setInteger(MediaFormat.KEY_FRAME_RATE, 30)
    setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1)
    setInteger(MediaFormat.KEY_COLOR_FORMAT,
        MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface) // ← 핵심
}

encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
val encoderSurface: Surface = encoder.createInputSurface() // ← GPU Surface
encoder.start()

2. EGL Context 설정

OpenGL로 렌더링하려면 EGL 환경을 직접 구성해야 한다.

class EglCore(private val encoderSurface: Surface) {
    private val eglDisplay: EGLDisplay
    private val eglContext: EGLContext
    private val eglSurface: EGLSurface

    init {
        eglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
        EGL14.eglInitialize(eglDisplay, null, 0, null, 0)

        val attribs = intArrayOf(
            EGL14.EGL_RED_SIZE, 8,
            EGL14.EGL_GREEN_SIZE, 8,
            EGL14.EGL_BLUE_SIZE, 8,
            EGL14.EGL_ALPHA_SIZE, 8,
            EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT,
            EGL14.EGL_NONE
        )
        val configs = arrayOfNulls<EGLConfig>(1)
        EGL14.eglChooseConfig(eglDisplay, attribs, 0, configs, 0, 1, null, 0)

        val ctxAttribs = intArrayOf(EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE)
        eglContext = EGL14.eglCreateContext(eglDisplay, configs[0], EGL14.EGL_NO_CONTEXT, ctxAttribs, 0)

        // ← 인코더 Surface를 EGL Surface로 래핑
        eglSurface = EGL14.eglCreateWindowSurface(eglDisplay, configs[0], encoderSurface, intArrayOf(EGL14.EGL_NONE), 0)
        EGL14.eglMakeCurrent(eglDisplay, eglSurface, eglSurface, eglContext)
    }

    fun swapBuffers() {
        EGL14.eglSwapBuffers(eglDisplay, eglSurface) // 프레임 완성 → 인코더로 전달
    }
}

3. 필터 셰이더 (GLSL)

빈티지 필터 예시:

// fragment shader
precision mediump float;
uniform sampler2D uTexture;
varying vec2 vTexCoord;

void main() {
    vec4 color = texture2D(uTexture, vTexCoord);

    // 채도 감소
    float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114));
    color.rgb = mix(color.rgb, vec3(gray), 0.4);

    // 따뜻한 색조
    color.r = min(color.r * 1.1, 1.0);
    color.b = max(color.b * 0.85, 0.0);

    // 비네팅
    vec2 uv = vTexCoord - 0.5;
    float vignette = 1.0 - dot(uv, uv) * 1.5;
    color.rgb *= max(vignette, 0.0);

    gl_FragColor = color;
}

4. 인코딩 루프

fun encode(extractor: MediaExtractor) {
    val decoder = setupDecoder(extractor)
    val bufferInfo = MediaCodec.BufferInfo()

    while (!isEOS) {
        // 디코더 입력 공급
        feedInputToDecoder(extractor, decoder)

        // 디코더 출력 → OpenGL → 인코더
        val outIdx = decoder.dequeueOutputBuffer(bufferInfo, 10_000)
        if (outIdx >= 0) {
            // SurfaceTexture.updateTexImage()로 프레임을 텍스처에 업로드
            surfaceTexture.updateTexImage()

            // OpenGL로 필터 적용해서 encoderSurface에 렌더링
            renderWithFilter()
            eglCore.swapBuffers() // ← 이 시점에 인코더가 프레임 받음

            // 타임스탬프 동기화 (중요!)
            EGLExt.eglPresentationTimeANDROID(
                eglDisplay, eglSurface,
                bufferInfo.presentationTimeUs * 1000L
            )

            decoder.releaseOutputBuffer(outIdx, true)
        }

        // 인코더 출력 수집 → MP4에 쓰기
        drainEncoder(muxer)
    }
}

결과

방법 30초 영상 처리 시간 CPU 피크
FFMPEG 소프트웨어 58초 100%
MediaCodec + OpenGL ES 8초 35%

7배 빠르고, 발열도 훨씬 줄었습니다.

주의할 점

타임스탬프 처리: EGLExt.eglPresentationTimeANDROID로 정확한 타임스탬프를 인코더에 전달하지 않으면 영상 재생 속도가 틀어집니다. microsecond를 nanosecond로 변환(* 1000)하는 것도 빠뜨리기 쉽습니다.

SurfaceTexture 스레드: SurfaceTexture.updateTexImage()는 반드시 EGL Context가 bind된 스레드에서 호출해야 한다. 다른 스레드에서 호출하면 GL 오류가 난다.

기기 호환성: COLOR_FormatSurface는 API 18 이상에서 지원하지만, 일부 구형 기기의 인코더 구현은 불안정하다. 예외 처리와 FFMPEG 폴백을 함께 두는 편이 안전하다.

CPU로 프레임을 왕복시키지 않고 디코더 Surface에서 OpenGL 필터를 거쳐 인코더 Surface로 직접 넘긴 것이 성능 개선의 핵심이었다.

댓글

블로그 목록으로
ACHIEVEMENT UNLOCKED
LOADING...
SCORE 000000
HITS
0