Graphics Programming

렌더링 일지 - 스크린샷 기능 본문

Season 2

렌더링 일지 - 스크린샷 기능

minseoklee 2022. 10. 1. 14:04

쉬어갈 겸 보조 기능으로 스크린샷 찍는 것을 구현해봤다.

 

콘솔 창에서 screenshot 커맨드를 실행

 

프레임버퍼를 읽어서 png로 출력

VRAM에 있는 텍스처를 제대로 읽기만 하면 할 일의 90%는 끝난다. 그런데 검색을 해봐도 GL_HALF_FLOAT, 즉 16비트 정밀도의 rgba16f 텍스처를 어떻게 읽어와야 하는 지에 대해 파편화된 정보들만 있어서 삽질을 조금 했다.

 

내 렌더링 파이프라인에서는 포스트 프로세싱 패스들을 토글할 수 있기 때문에 마지막으로 실행된 포스트 프로세싱이 무엇인지에 따라 백버퍼로 blit하기 직전 sceneColor 텍스처의 픽셀 포맷이 달라질 수 있다. 그래서 최종 PP 실행 결과를 무조건 rgba16f 포맷의 sceneFinal 텍스처에 복사하고, 스크린샷을 찍어야 하면 sceneFinal을 읽고, sceneFinal을 백버퍼로 blit하도록 절차를 수정했다.

 

// Assumes rendering result is always written to sceneFinal and it's pixel format is rgba16f.
if (scene->bScreenshotReserved) {
    cmdList.bindFramebuffer(GL_READ_FRAMEBUFFER, fboScreenshot);
    cmdList.namedFramebufferTexture(fboScreenshot, GL_COLOR_ATTACHMENT0, sceneFinal, 0);
    cmdList.namedFramebufferReadBuffer(fboScreenshot, GL_COLOR_ATTACHMENT0);

    // NOTE: std::vector<uint16> screenshotRawData;
    scene->screenshotSize = vector2i(sceneWidth, sceneHeight);
    scene->screenshotRawData.resize(4 * sceneWidth * sceneHeight);
    cmdList.pixelStorei(GL_PACK_ALIGNMENT, 1);
    cmdList.readPixels(0, 0, sceneWidth, sceneHeight,
    	GL_RGBA, GL_HALF_FLOAT,
    	scene->screenshotRawData.data());
    cmdList.pixelStorei(GL_PACK_ALIGNMENT, 4);
}

half float은 16비트이므로 uint16_t 버퍼를 준비하고 glReadPixels()로 읽어온다. 이 half float 값들을 일반적인 32비트 float으로 변환해야 한다. 변환 공식은 https://stackoverflow.com/a/60047308 의 것을 가져다 썼다.

 

// Transfer screenshot pixels if exist.
if (sceneProxy->bScreenshotReserved && sceneProxy->screenshotRawData.size() > 0) {
    vector2i screenshotSize = sceneProxy->screenshotSize;

    auto half_to_float = [](const uint16 x) -> float { ... };
    
    const int32 totalPixels = screenshotSize.x * screenshotSize.y;
    const std::vector<uint16>& rawPixels = sceneProxy->screenshotRawData;
    uint8* pixels = new uint8[totalPixels * 3];
    
    for (int32 i = 0; i < totalPixels; ++i) {
        float R = half_to_float(rawPixels[i * 4 + 0]);
        float G = half_to_float(rawPixels[i * 4 + 1]);
        float B = half_to_float(rawPixels[i * 4 + 2]);
        pixels[i * 3 + 0] = (uint8)badger::clamp(0u, (uint32)(B * 255.0f), 255u);
        pixels[i * 3 + 1] = (uint8)badger::clamp(0u, (uint32)(G * 255.0f), 255u);
        pixels[i * 3 + 2] = (uint8)badger::clamp(0u, (uint32)(R * 255.0f), 255u);
    }
    
    auto screenshot = std::make_pair(sceneProxy->screenshotSize, pixels);
    gEngine->pushScreenshot(screenshot);
}

sceneFinal에서 읽어온 픽셀들은 이미 톤매핑까지 끝낸 LDR 값들이므로 0.0 ~ 1.0 사이의 float으로 변환하고 나면 다시 0x00 ~ 0xff 범위로 매핑한다. 최종적으로는 이미지 라이브러리를 통해 png로 저장하며, 위 코드에서는 생략했다.

 

glReadPixels()만 테스트해보는 데모였다면 드로우콜 → glFlush() -> glReadPixels() → 이미지 파일로 출력을 한번에 처리했겠지만 이미 나름의 아키텍처가 있는 프로젝트에 추가하다보니 실제 처리 과정은 조금 더 복잡하다.

  1. 메인 쓰레드: screenshot 커맨드를 감지하여 렌더 쓰레드에 스크린샷 촬영 요청
  2. 렌더 쓰레드: 바로 다음에 렌더링할 scene proxy에 스크린샷 플래그를 설정하고, 플래그가 설정된 경우에만 glReadPixels() 실행하여 screenshot raw data를 생성
  3. 렌더 쓰레드: scene proxy 렌더링 끝냈는데 screenshot raw data가 존재한다면 스크린샷 큐에 넣고 메인 쓰레드에 통지
  4. 메인 쓰레드: 스크린샷 큐에 있는 raw data를 적절하게 변환하여 이미지 파일로 출력

 

HDR 이미지 포맷으로 저장하는 기능도 생각해볼 수 있는데, 그러려면 톤매핑 패스에서 HDR → LDR 변환을 하지 않고 HDR 값을 그대로 출력해야 한다. 이는 나중에 해볼 HDR 디스플레이 지원과도 관련있다.

Comments