C++ / SDL로 망델브로 집합 그려보기 – 4

SDL로 망델브로 집합 그려보기 시리즈

4-1. 스크린샷 기능 추가

저번 편에서 만든 ImGui GUI에 스크린샷 메뉴를 추가해보도록 하겠습니다. 이미지를 저장하기 위해 SDL image 라이브러리를 사용할 수도 있으나 stb image 라이브러리를 사용해 보도록 하겠습니다. SDL에 기본으로 BMP 이미지로 파일을 저장할 수 있는 함수가 내장되어 있습니다. 하지만, BMP 파일이 무압축 포맷이라 워낙 용량이 크기 때문에 다른 라이브러리로 PNG로 저장하도록 합니다.

4-1-1. stb image 라이브러리 설치

stb image 라이브러리는 stb 라이브러리의 일부분 입니다. vcpkg에서 라이브러리를 설치하기 위해 다음 명령어를 실행합니다.

vcpkg install stb:x64-windows

4-1-2. GUI 클래스에 스크린샷 기능 추가

gui.h의 GUI 클래스에 몇가지 멤버를 추가해 줍니다. 스크린샷은 실패할 가능성이 높은 파일을 저장하는 동작을 포함하고 있기 때문에 예외를 처리하고 표시하기 위해 postErrorMessage() 메서드가 사용됩니다.

gui.h
	void processEvent(SDL_Event* event);
	bool mouseCaptured();
	bool keyCaptured();

	void render(const Mandelbrot* mandelbrot);
	void draw();

	void saveCapture(SDL_Surface* surface);

	struct {
		Acc accelerator;
		float move_speed;
		float scroll_scale;
		bool scaleToCursor;
		bool reset_params;
		char capture_dir[100];
		bool capture_no_ui;
	} settings;
	
	void postErrorMessage(const char* msg);

private:
	SDL_Renderer* renderer;
	SDL_Window* window;
	
	std::string message;
	int msg_timepoint;
};

이제 gui.cpp 파일을 수정합니다. time.h와 비슷한 기법을 사용하기 때문에 마찬가지로 하나의 소스파일에서만 STB_IMAGE_WRITE_IMPLEMENTATION 매크로를 선언해 줘야 합니다. 그 밑의 세 줄은 stb_image_write.h 헤더에서 내부적으로 좀 더 안전한 함수(sprintf_s 등)를 사용하게 하기 위합니다. getCaptureName() 함수는 캡쳐된 이미지가 저장될 이름을 시간에 따라 추출하는 함수입니다. 이미지의 이름 형식을 변경하고 싶다면 23번 줄의 포맷 문자열을 수정하면 됩니다. 133 ~ 140번 줄의 코드는 에러 메시지가 있을 때 3초간 ImGui 윈도우의 아래에 표시하는 부분입니다. saveCapture() 메서드는 ARGB8888인 SDL_Surface의 포맷을 RGBA8888로 변경해서 stb_write_png() 함수로 이미지를 저장합니다.

여담으로 처음부터 SDL_Surface의 포맷을 익숙한 RGBA8888로 하려고 했지만, SDL 렌더러에서 해당 포맷을 지원하지 않는다고 합니다. 그래서 지금껏 ARGB8888포맷을 사용했습니다.

gui.cpp
#include "gui.h"

#define STB_IMAGE_WRITE_IMPLEMENTATION
#ifndef __STDC_LIB_EXT1__
#define __STDC_LIB_EXT1__
#endif 

#include <sstream>
#include <imgui_impl_sdl.h>
#include <imgui_impl_sdlrenderer.h>
#include <stb_image_write.h>

#include "mandelbrot.h"
#include "time.h"

using namespace std;

static string getCaptureName() {
	time_t now = time(0);
	char buf[80];
	tm tstruct;
	localtime_s(&tstruct, &now);
	strftime(buf, sizeof(buf), "%Y-%m-%d-%H-%M-%S.png", &tstruct);
	return { buf };
}
...
	settings.scroll_scale  = 1.1f;
	settings.scaleToCursor = true;
	
	memset(settings.capture_dir, '\0', 100);
	strcat_s(settings.capture_dir, 100, "captures\\");
	settings.capture_no_ui = true;
	
	msg_timepoint = -3000;
...
	if (ImGui::TreeNode("screenshot")) {
		ImGui::Text("directory :");
		ImGui::InputText("##3", settings.capture_dir, 255);
		ImGui::Checkbox("no UI", &settings.capture_no_ui);
		if (ImGui::Button("capture")) {
			if (settings.capture_no_ui) {
				saveCapture(mandelbrot->getSurface());
			} else {
				SDL_Surface* surface = SDL_CreateRGBSurface(0, width, height, 32, 0, 0, 0, 0);
				auto* format         = (uint32_t*)(surface->format);
				SDL_RenderReadPixels(renderer, 0, *format, surface->pixels, width * 4);
				saveCapture(surface);
				SDL_FreeSurface(surface);
			}
		}
		ImGui::TreePop();
	}

	ImGui::SetWindowSize({ 260, ImGui::GetContentRegionAvail().y});

	if (clock() - msg_timepoint < 3000) {
		auto* drawlist = ImGui::GetForegroundDrawList();
		auto position  = ImGui::GetWindowPos();
		ImColor color  = { 255, 0, 0 };

		position.y += ImGui::GetWindowHeight();
		drawlist->AddText(position, color, message.c_str());
	}

	ImGui::End();
	ImGui::Render();
}

void GUI::draw()
{
	ImGui_ImplSDLRenderer_RenderDrawData(ImGui::GetDrawData());
}

void GUI::saveCapture(const Mandelbrot* mandelbrot)
{
	SDL_Surface* surface;
	auto& dir = settings.capture_dir;
	auto name = dir + getCaptureName();

	if (settings.capture_no_ui)
		surface = mandelbrot->getSurface();
	else surface = SDL_GetWindowSurface(window);

	surface = SDL_ConvertSurfaceFormat(surface, SDL_PIXELFORMAT_RGB24, 0);
	int res = stbi_write_png(name.c_str(), surface->w, surface->h, 3, surface->pixels, surface->w * 3);
	SDL_FreeSurface(surface);

	if (res == 0) postErrorMessage("couldn't save capture!\ncheck if your directory exists");
}

void GUI::postErrorMessage(const char* msg)
{
	msg_timepoint = clock();
	message       = msg;
}

스크린샷 설정에 이미지를 저장할 경로를 입력받는 텍스트 박스가 있습니다. 경로를 입력하는 동안 키보드 이벤트를 렌더링에 반영하지 못하도록 main.cpp의 다음 부분을 수정합니다.

main.cpp
void EventAsync(unique_ptr<GUI>& gui, unique_ptr<Mandelbrot>& mandelbrot) {
	if (gui->keyCaptured()) return;
	auto* keyStates = SDL_GetKeyboardState(NULL);
...
		case SDL_KEYDOWN:
			if (gui->keyCaptured()) break;
			KeyProc(e.key, mandelbrot);
			break;

이제 아래와 같이 스크린샷 항목이 생긴것을 확인할 수 있습니다. 스크린샷이 정상적으로 캡쳐되려면 프로그램의 작업 디렉토리(working directory)에 captures 폴더가 있어야 합니다. 일반적으로 프로그램은 작업 디렉토리가 프로그램이 실행된 그 폴더와 일치합니다. 하지만, 비주얼 스튜디오에서 실행시켰다면(직접 .exe를 실행시킨 경우 제외) 작업 디렉토리는 디폴트로 프로젝트 폴더가 됩니다. 따라서 main.cpp가 있는 폴더와 동일한 곳에 captures 폴더를 만들면 정상적으로 동작할 겁니다.

4-2. 비동기 렌더링 기능 추가

비동기 렌더링 기능을 추가하면 UI 스레드와 렌더링 스레드가 분리되어 집합 전체가 렌더링되지 않은 경우에도 화면에 출력할 수 있습니다. 이 기능을 추가하기 위해 아주 많은 부분이 수정되어야 합니다. 먼저 기존의 draw() 메서드의 기능인 망델브로 집합 렌더링, 화면 그리기를 분리해서 render() 메서드가 집합을 렌더링하고, draw() 메서드가 그 시점까지 렌더링 된 이미지를 화면에 그리도록 변경합니다. render() 메서드는 기존처럼 동기적으로 그릴지 아닐지 선택할 수 있게 합니다. 그리고 나머지 비동기 동작에 필요한 몇몇 멤버 변수와 메서드를 추가합니다.

std::atomic 클래스는 std::mutex 없이도 템플릿 인자인 T에 들어간 어떤 타입을 읽고 쓰는데 thread safe하게 만들어 줍니다. 렌더링이 시작되거나 종료될 때 멤버변수 is_rendering의 값은 true나 false가 됩니다. stop_all은 비동기 렌더링을 작업 중간에 종료하기 위해 사용되는 플래그 입니다. 사실 이건 굳이 atomic하게 접근할 필요는 없기 때문에 그냥 bool로 선언해도 상관은 없습니다.

mandelbrot.h
#include <complex>
#include <future>
#include <atomic>
#include <SDL2/SDL.h>

class Mandelbrot
{
public:
	using real_t = double;

	Mandelbrot(SDL_Renderer* renderer);
	~Mandelbrot();

	void render(bool async = true);
	void draw();

	void stop();
	void wait();
	bool isRendering() const;
...
	std::complex<real_t> pixelToComplex(real_t px, real_t py) const;

protected:
	void startAsync();
	void drawSurface();
	void update();
...
	uint32_t iter;

	std::future<void> future;
	std::atomic<bool> is_rendering;
	std::atomic<bool> stop_all;
	bool updated;

이제 mandelbrot.cpp를 수정해야 하는데 수정할 부분이 좀 많습니다(…). 사용된 std::async() 함수와 std::future 클래스에 대해서 간략하게 설명하자면 std::async()는 C++ 표준에서 지원하는 비동기 실행 함수입니다. 첫번째 인자로 std::launch::async가 들어가면 새로운 스레드에서 두번째 인자로 넘겨진 Callable을 실행하고, 기존 스레드는 std::async() 함수가 리턴한 future를 통해 원하는 시점에서 std::future::wait() 메서드를 호출해서 이 작업이 끝날때까지 기다릴 수 있습니다. 이때 std::async()가 리턴한 std::future를 버려버리면 비동기로 실행이 안되고 std::async()에서 블록됩니다.

mandelbrot.cpp
	iter  = 100;

	is_rendering = false;
	stop_all     = false;
	updated      = false;
}
...
void Mandelbrot::render(bool async)
{
	if (!updated) {
		if (!async) {
			is_rendering = true; // just in case...
			drawSurface();
			is_rendering = false;
		} else startAsync();

		updated = true;
	}
}

void Mandelbrot::draw()
{
	SDL_UpdateTexture(texture, NULL, surface->pixels, surface->pitch);
	SDL_RenderCopy(renderer, texture, nullptr, nullptr);
}


void Mandelbrot::stop()
{
	if (is_rendering) {
		stop_all = true;
		wait();
		stop_all = false;
		is_rendering = false;
	}
}

void Mandelbrot::wait()
{
	if (is_rendering)
		future.get(); // or wait()
}

bool Mandelbrot::isRendering() const
{
	return is_rendering;
}

void Mandelbrot::resize()
{
	stop();
	SDL_FreeSurface(surface);
...
	texture = SDL_CreateTextureFromSurface(renderer, surface);
	update();
}

void Mandelbrot::setPosition(real_t x, real_t y)
{
	stop();
	pos_x   = x;
	pos_y   = y;
	update();
}

void Mandelbrot::move(int32_t rel_px, int32_t rel_py)
{
	stop();
	real_t dx = 4. * scale * aspect / width;
	real_t dy = 4. * scale / height;
	
	pos_x  -= dx * rel_px;
	pos_y  += dy * rel_py;
	update();
}

void Mandelbrot::setScale(real_t scale)
{
	stop();
	this->scale = scale;
	update();
}

void Mandelbrot::setScaleTo(real_t scale, real_t px, real_t py)
{
	stop();
	auto point  = pixelToComplex(px, py);
...
	pos_y = point.imag() - 2. * scale + py * dy;
	update();
}

void Mandelbrot::setIteration(uint32_t iter)
{
	stop();
	this->iter = iter;
	update();
}
...
void Mandelbrot::startAsync()
{
	future = async(launch::async, [this] {
		is_rendering = true;
		drawSurface();
		is_rendering = false;
	});
}
...
void Mandelbrot::update()
{
	stop();
	updated = false;
}

마지막으로 main.cpp를 수정합니다. 124번 줄의 render()의 인자로 bool값을 넘겨 동기/비동기를 선택할 수 있습니다. 인자를 넘기지 않으면 디폴트로 비동기로 실행됩니다.

main.cpp
	while (!closed) {
		closed = EventProc(gui, mandelbrot);
		
		mandelbrot->render(); // async
		gui->render(mandelbrot.get());
		
		if (gui->settings.reset_params) {
			mandelbrot->setPosition(0., 0.);
			mandelbrot->setScale(1.);
			mandelbrot->setIteration(32);
			gui->settings.reset_params = false;
		}
		
		mandelbrot->draw();
		gui->draw();
		SDL_RenderPresent(renderer);

		Time::update();
	}

효과를 좀 더 강조하기 위해 좀 더 느려지도록 디버그 모드로 빌드하고 실행시켰습니다. 아래는 기존의 비동기 렌더링이 들어가지 않은 상태입니다. 이땐 마우스로 드래그를 했을 때 렌더링이 완료되어야 화면에 표시됩니다. 또 왼편을 보면 렌더링이 되는 동안 UI를 스레드가 묶여있기 때문에 프레임이 뚝뚝 끊기는 모습을 볼 수 있습니다.

비동기 렌더링이 켜지지 않은 상태

반면에 비동기 렌더링이 켜진 상태에선 렌더링이 완료되지 않아도 화면에 표시됩니다. 그래서 위에서 부터 아래로 그려지는 모습을 실시간으로 볼 수 있습니다. 또 UI스레드와 렌더링 스레드가 분 리되기 때문에 UI나 마우스 입력이 끊기지 않습니다. 아직까진 별로 차이가 느껴지지 않을 수 있지만 나중에 멀티스레드 렌더링을 구현한다면 아주 중요한 역할을 합니다.

비동기 렌더링이 켜진 상태

4-3. OneTBB 라이브러리를 이용한 멀티스레드 렌더링

TBB(Thread Building Block) 라이브러리는 예전에 인텔에서 만든 복잡한 애플리케이션에서도 쉽게 병렬화를 할 수 있게 해주는 라이브러리 입니다. 지금은 한 세대 발전하여 OneTBB라는 이름으로 배포되고 있습니다. 이 라이브러리는 멀티 스레딩에 대한 지식이 충분하지 않아도 쉽게 병렬처리를 구현할 수 있도록 도와줍니다. 따라서 이번 튜토리얼에선 스레드 풀을 따로 만들지 않고 이 라이브러리를 사용하도록 하겠습니다.

OneTBB Github에 가보면 아래와 같이 써있어 AMD CPU가 호환되는진 잘 모르겠습니다.

4-3-1. OneAPI OneTBB 라이브러리 설치

TBB 라이브러리를 vcpkg로 설치하기 위해 다음 명령어를 실행합니다.

vcpkg install tbb:x64-windows

4-3-2. MandelbrotTBB 클래스 만들기

새로운 클래스에서 사용하기 위해 mandelbrot.cpp에 있던 make_color() 함수와 mandelbrot() 함수를 mandelbrot.h로 옮깁니다. mandelbrot() 함수가 기존엔 complex<T> 클래스를 사용했었는데 디버그 빌드와 릴리즈 빌드 차이가 너무 커서 실수 자료형을 바로 사용하도록 수정합니다. 그리고 Mandelbrot 클래스를 상속받는 클래스인 MandelbrotTBB를 만들건데 다형성을 사용할 것이므로 오버라이드될 것 같은 부분을 모두 virtual로 선언하도록 합니다.

mandelbrot.h
#include <SDL2/SDL.h>

inline uint32_t make_color(uint8_t r, uint8_t g, uint8_t b, uint8_t a = 0xff) {
	return (a << 24) + (r << 16) + (g << 8) + b;
}


template <class T>
inline uint32_t mandelbrot(T cx, T cy, uint32_t max_iter) {
	T zx = 0., zy = 0.;
	int i = 0;

	do {
		T temp = zx * zx - zy * zy + cx;
		zy = 2. * zx * zy + cy;
		zx = temp;
	} while (zx * zx + zy * zy < 4. && ++i < max_iter);

	return i;
}
...
	virtual ~Mandelbrot();
	
	void render(bool async = true);
	virtual void draw();

	virtual void stop();
	virtual void wait();
	virtual bool isRendering() const;
...
protected:
	virtual void startAsync();
	virtual void drawSurface();
	void update();

mandelbrot() 함수의 바뀐 인자를 반영하도록 mandelbrot.cpp 파일도 수정해야 합니다.

mandelbrot.cpp
			auto iterated = mandelbrot<real_t>(cx, cy , iter);

이제 MandelbrotTBB클래스를 만들어 봅시다. mandelbrot_tbb.h 파일을 작성합니다. override는 오버라이드된 가상함수라는 것을 나타내는 키워드 입니다.

mandelbrot_tbb.h
#pragma once

#include "mandelbrot.h"

class MandelbrotTBB : public Mandelbrot
{
public:
	using real_t = Mandelbrot::real_t;

	MandelbrotTBB(SDL_Renderer* renderer);
	~MandelbrotTBB() override;

private:
	void drawSurface() override;
};
C++

소스파일인 mandelbrot_tbb.cpp를 작성합니다. 여기서 TBB 라이브러리의 편리함이 드러납니다. 스레드 풀을 만들어서 병렬연산을 실행하는 코드가 사실상 27번 줄의 tbb::pararell_for() 함수가 전부입니다.

mandelbrot_tbb.cpp
#include "mandelbrot_tbb.h"

#include <oneapi/tbb/parallel_for.h>
#include <oneapi/tbb/blocked_range2d.h>
#include <oneapi/tbb/task.h>

using namespace std;
using namespace oneapi;

MandelbrotTBB::MandelbrotTBB(SDL_Renderer* renderer)
	: Mandelbrot(renderer) 
{
}

MandelbrotTBB::~MandelbrotTBB()
{
}

void MandelbrotTBB::drawSurface()
{
	using range_t = tbb::blocked_range2d<int, int>;

	real_t min_x   = pos_x - 2. * scale * aspect;
	real_t max_y   = pos_y + 2. * scale;
	real_t dx      = 4. * scale * aspect / width;
	real_t dy      = 4. * scale / height;

	tbb::parallel_for(range_t(0, height, 0, width), [=](range_t& r) {
		for (int h = r.rows().begin(); h < r.rows().end(); ++h) {
			if (stop_all) {
				tbb::task::current_context()->cancel_group_execution();
				return;
			}

			for (int w = r.cols().begin(); w < r.cols().end(); ++w) {
				uint32_t& pixel = *((uint32_t*)surface->pixels + h * surface->w + w);

				real_t cx = min_x + dx * (w + 0.5f);
				real_t cy = max_y - dy * (h + 0.5f);

				auto iterated = mandelbrot<real_t>(cx, cy, iter);

				uint8_t col = 255.99f * (float)iterated / iter;
				pixel = make_color(col, col, col);
			}
		}
	});
}
C++

이제 main.cpp에서 디폴트로 TBB를 사용하도록 바꿉니다.

main.cpp
	unique_ptr<Mandelbrot> mandelbrot = make_unique<MandelbrotTBB>(renderer);

실행시켜보면 확연히 빨라진것을 확인할 수 있습니다. gif 프레임이 낮아 효과가 더 체감이 안돼서 아쉽습니다.

멀티 스레드 환경에서 최고의 성능을 뽑아내려면 작업 중에 모든 스레드가 쉬지않고 일해야 합니다. 작업을 먼저 끝내는 스레드가 있더라도 쉬게하면 안되고 바로 다음 작업을 넘겨줘야 합니다. 가장 이상적인 경우에 모든 스레드가 분할된 작업을 동시에 끝냅니다. TBB 라이브러리가 최대한 이렇게 스레드에 작업을 할당하도록 자동으로 스레드를 스케줄링합니다.

아래는 일부러 딜레이를 걸어서 TBB로 렌더링 되는 과정을 보여줍니다. 아래 이미지에서 채워지는 작은 각 사각형의 크기를 grain size라고 합니다. TBB에서 모든 스레드가 최대한 동시에 끝나게 해서 성능을 향상하도록 grain size를 자동으로 조절하는 auto granize 작업을 해줍니다.

다음 시간에는 UI를 좀 더 다듬고 여러 최적화를 수행해 보겠습니다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다