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

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

5-1. 최적화의 필요성

지금까진 망델브로 집합의 파라미터가 조금이라도 바뀌면 모든 경우에 만들어 놓은 이미지를 지우고 화면 전체를 다시 렌더링했었습니다. 그래서 이동, 스케일, iteration 변경을 할 때 화면이 깜빡거리는 현상을 볼 수 있었습니다. 이번엔 각 경우에 대해서 최대한 기존에 만들어 놓은 이미지를 활용해서 깜빡임을 줄이고 이동의 경우 연산량 또한 줄이는 방법을 적용해 보겠습니다.

5-1-1. Mandelbrot::setIteration() 메서드 최적화

iteration이 변하면 결국 모든 픽셀이 다시 렌더링 되어야 하지만 전체적인 이미지의 모습은 거의 비슷합니다. 따라서 iteration이 변경될 때는 전체 픽셀을 다시 그리되 이미 렌더링 된 이미지는 그대로 두고 새로운 픽셀이 덮어쓰도록 만듭니다. 먼저 update() 메서드를 수정하고 픽셀 별로 렌더링 되었는지 보관하는 추가 구조체인 RenderInfo와 각 픽셀의 정보를 담는 PixelInfo 구조체를 아래와 같이 Mandelbrot 클래스 내부에 추가합니다.

mandelbrot.h
	virtual void drawSurface();
	void update(bool rerender_all = true, bool clear_surface = true);

public:
	struct PixelInfo {
		bool rendered = false;
	};

	struct RenderInfo {
		void resize(uint32_t width, uint32_t height);
		void reset(PixelInfo info = {});
		void destroy();

		inline PixelInfo& at(uint32_t px, uint32_t py) {
			return *(pixels + py * width + px);
		};

		PixelInfo* pixels;
		uint32_t width;
		uint32_t height;
	};

protected:
	SDL_Window* window;
	SDL_Renderer* renderer;

	RenderInfo render_info;
	SDL_Surface* surface;

mandelbrot.cpp를 수정합니다. setIteration() 메서드의 135번 줄을 update(true, false)로 바꾸어 다시 렌더링 하 이미 렌더링 된 이미지는 유지하도록 합니다. 그리고 RenderInfo 구조체의 메서드를 구현합니다.

mandelbrot.cpp
	render_info.resize(width, height);
	surface  = SDL_CreateRGBSurface(0, width, height, 32, 0, 0, 0, 0);
	texture  = SDL_CreateTextureFromSurface(renderer, surface);
...
Mandelbrot::~Mandelbrot()
{
	render_info.destroy();
...
	render_info.resize(width, height);
	surface = SDL_CreateRGBSurface(0, width, height, 32, 0, 0, 0, 0);
	texture = SDL_CreateTextureFromSurface(renderer, surface);
...
void Mandelbrot::setIteration(uint32_t iter)
{
	stop();
	this->iter = iter;
	update(true, false);
}
...
			auto& info = render_info.at(w, h);
			if (info.rendered) continue;

			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);
			info.rendered = true;
...
void Mandelbrot::update(bool rerender_all, bool clear_surface)
{
	stop();
	updated = false;

	if (rerender_all)
		render_info.reset();
	if (clear_surface)
		SDL_FillRect(surface, nullptr, 0x00000000);
}

void Mandelbrot::RenderInfo::resize(uint32_t width, uint32_t height)
{
	if (pixels) destroy();
	pixels         = new PixelInfo[width * height];
	this->width    = width;
	this->height   = height;
}

void Mandelbrot::RenderInfo::reset(PixelInfo info)
{
	if (pixels) fill_n(pixels, width * height, info);
}

void Mandelbrot::RenderInfo::destroy()
{
	delete pixels; 
	pixels = nullptr;
}

그리고 mandelbrot_tbb.cpp의 drawSurface()도 렌더링 되지 않은 픽셀만 계산하도록 수정해 줍니다.

mandelbrot_tbb.cpp
			for (int w = r.cols().begin(); w < r.cols().end(); ++w) {
				auto& info = render_info.at(w, h);
				if (info.rendered) continue;

				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);
				info.rendered = true;
			}

각각 최적화하기 전과 후 입니다. 확실히 최적화 후 iteration이 부드럽게 조절되는것을 볼 수 있습니다.

5-1-2. Mandelbrot::setScale() 메서드 최적화

SDL에는 SDL_Surface의 위치와 크기를 변경해서 복사하는 SDL_BlitScaled() 함수가 있습니다. setScale() 메서드도 마찬가지로 기존에 계산해놓은 surface를 확대하거나 축소해놓고 surface를 그대로 둔 상태에서 렌더링을 하도록 만듭니다. 이동 연산은 생각해보면 임시 SDL_Surface가 필요없지만, 이동이 추가된 확대/축소 연산은 무조건 임시 SDL_Surface가 필요합니다. 앞으로 임시 SDL_Surface를 사용할 일이 많으므로 미리 할당하여 중복되는 할당/해제 오버헤드를 없앱니다.

mandelbrot.h
	RenderInfo render_info;
	SDL_Surface* surface_temp;
	SDL_Surface* surface;
	SDL_Texture* texture;
...
mandelbrot.cpp
	render_info.resize(width, height);
	surface_temp = SDL_CreateRGBSurface(0, width, height, 32, 0, 0, 0, 0);
	surface      = SDL_CreateRGBSurface(0, width, height, 32, 0, 0, 0, 0);
	texture      = SDL_CreateTextureFromSurface(renderer, surface);
...
	render_info.destroy();
	SDL_FreeSurface(surface_temp);
	SDL_FreeSurface(surface);
...
	render_info.resize(width, height);
	surface_temp = SDL_CreateRGBSurface(0, width, height, 32, 0, 0, 0, 0);
	surface      = SDL_CreateRGBSurface(0, width, height, 32, 0, 0, 0, 0);
...

위에서 설명한 SDL_BlitScaled() 함수로 surface를 surface_temp로 크기를 바꾸어 복사합니다. 다시 surface_temp를 surface로 복사하지 않고 std::swap() 함수로 그냥 둘을 바꿔치기 하면 추가적인 복사 없이 깔끔하고 빠른 코드를 짤 수 있습니다.

mandelbrot.cpp
void Mandelbrot::setScale(real_t scale)
{
	stop();
	auto mag = this->scale / scale;
	int w    = width * (1. - mag);
	int h    = height * (1. - mag);

	SDL_Rect rect = { w / 2, h / 2, width - w, height - h };

	SDL_FillRect(surface_temp, nullptr, 0x00000000);
	SDL_BlitScaled(surface, nullptr, surface_temp, &rect);
	std::swap(surface, surface_temp);
	
	this->scale = scale; 
	update(true, false);
}

5-1-3. Mandelbrot::setScaleTo() 메서드 최적화

마찬가지로 setScaleTo() 메서드도 수정해 줍니다. 코드를 보시면 아시겠지만 위와 거의 비슷합니다. setScale() 메서드 내부를 setScaleTo(scale, width / 2, height / 2)로 대체할 수 있지만 이미 만들어 놓은거 그냥 쓰도록 하겠습니다…

mandelbrot.cpp
void Mandelbrot::setScaleTo(real_t scale, real_t px, real_t py)
{
	stop();
	auto point  = pixelToComplex(px, py);
	auto mag    = this->scale / scale;
	int w       = width * (1. - mag);
	int h       = height * (1. - mag);

	SDL_Rect rect = { w * (px / width), h * (py / height), width - w, height - h};

	SDL_FillRect(surface_temp, nullptr, 0x00000000);
	SDL_BlitScaled(surface, nullptr, surface_temp, &rect);
	std::swap(surface, surface_temp);

	this->scale = scale;

	real_t dx = 4. * scale * aspect / width;
	real_t dy = 4. * scale / height;

	pos_x = point.real() + 2. * scale * aspect - px * dx;
	pos_y = point.imag() - 2. * scale + py * dy;
	update(true, false);
}

5-1-4. Mandelbrot::move() 메서드 최적화

move() 메서드는 위와 처리방식이 다릅니다. 이미지를 이동시키고 남은 공간을 배경색으로 칠해줘야 하고, 이동된 공간은 다시 렌더링 하지 않아야 합니다. 따라서 위와는 다르게 update(false, false)(전체를 다시 렌더링 하지도 않고 surface를 초기화 하지도 않음)로 해야 합니다. 그리고 픽셀이 렌더링 되었는지 확인하기 위한 멤버인 render_info.pixels 또한 이미지와 똑같이 이동시켜야 합니다. 이미지는 SDL_BlitSurface() 함수로 쉽게 이동할 수 있지만, render_info.pixels 데이터는 직접 이동연산을 구현해야 합니다. 이는 C++의 std::move(), std::move_backward() 함수의 조합으로 만들 수 있습니다. 254번 줄에서 이 부분을 볼 수 있습니다. 110번 줄부터 이어지는 긴 if 문은 이동 후 배경색으로 칠할 남는 공간을 계산하기 위한 부분입니다.

mandelbrot.h
	struct RenderInfo {
		void resize(uint32_t width, uint32_t height);
		void reset(PixelInfo info = {});
		void move(int32_t rel_px, int32_t rel_py);
		void fillRect(SDL_Rect* rect, PixelInfo info = {});
		void destroy();
mandelbrot.cpp
void Mandelbrot::move(int32_t rel_px, int32_t rel_py)
{
	stop();
	SDL_Rect rect1, rect2;
	SDL_Rect rect = { rel_px, rel_py, width + rel_px, height + rel_px };
	SDL_BlitSurface(surface, nullptr, surface, &rect);

	if (rel_px >= 0 && rel_py >= 0) {
		rect1 = { 0, 0, width, rel_py };
		rect2 = { 0, rel_py, rel_px, height - rel_py };
	} else if (rel_px >= 0 && rel_py <= 0) {
		rect1 = { 0, height + rel_py, width, -rel_py };
		rect2 = { 0, 0, rel_px, height + rel_py };
	} else if (rel_px <= 0 && rel_py >= 0) {
		rect1 = { 0, 0, width, rel_py };
		rect2 = { width + rel_px, rel_py, -rel_px, height - rel_py };
	} else if (rel_px <= 0 && rel_py <= 0) {
		rect1 = { 0, height + rel_py, width, -rel_py };
		rect2 = { width + rel_px, 0, -rel_px, height + rel_py };
	}

	render_info.move(rel_px, rel_py);
	render_info.fillRect(&rect1);
	render_info.fillRect(&rect2);
	SDL_FillRect(surface, &rect1, 0x00000000);
	SDL_FillRect(surface, &rect2, 0x00000000);

	real_t dx = 4. * scale * aspect / width;
	real_t dy = 4. * scale / height;
	
	pos_x -= dx * rel_px;
	pos_y += dy * rel_py;
	update(false, false);
}
...
void Mandelbrot::RenderInfo::reset(PixelInfo info)
{
	if (pixels) fill_n(pixels, width * height, info);
}

void Mandelbrot::RenderInfo::move(int32_t rel_px, int32_t rel_py)
{
	if (!pixels) return;

	if (rel_px >= 0 && rel_py >= 0) {
		for (int h = height - rel_py - 1; h >= 0; --h) 
			std::move_backward(&at(0, h), &at(width - rel_px, h), &at(width, h + rel_py));
	} else if (rel_px >= 0 && rel_py <= 0) {
		for (int h = -rel_py; h < height; ++h) 
			std::move_backward(&at(0, h), &at(width - rel_px, h), &at(width, h + rel_py));
	} else if (rel_px <= 0 && rel_py >= 0) {
		for (int h = height - rel_py - 1; h >= 0; --h) 
			std::move(&at(-rel_px, h), &at(width, h), &at(0, h + rel_py));
	} else if (rel_px <= 0 && rel_py <= 0) {
		for (int h = -rel_py; h < height; ++h) 
			std::move(&at(-rel_px, h), &at(width, h), &at(0, h + rel_py));
	}
}

void Mandelbrot::RenderInfo::fillRect(SDL_Rect* rect, PixelInfo info)
{
	if (!pixels) return;

	int h_min  = SDL_clamp(rect->y, 0, height);
	int h_max  = SDL_clamp(rect->y + rect->h, 0, height);
	int w_min  = SDL_clamp(rect->x, 0, width);
	int w_size = SDL_clamp(rect->x + rect->w, 0, width) - rect->x;

	for (int h = h_min; h < h_max; ++h)
		fill_n(&at(w_min, h), w_size, info);
}

5-2. GUI 클래스 개선

GUI 클래스가 좀 많이 바뀌었는데 변경된 부분은 다음과 같습니다.

  • render() 메서드 분리 – 코드를 깔끔하게 만드려면 함수는 이름에서 바로 유추할 수 있는 한 작업을 수행해야 합니다. 그런데 지금껏 render() 메소드는 UI를 업데이트 하면서 UI렌더링까지 같이 도맡아 했습니다. 그래서 이번에 이것을 두 메서드인 update()와 render() 두 메서드로 분리했습니다.
  • ImGui에서 이름없는 레이블 자동화 – 모든 ImGui 요소들은 고유한 레이블을 가져야 합니다. 그런데 레이블을 실제 UI에 표시하지 않으려면 ##1과 같은 이름을 사용해야 하는데 이걸 __COUNT__매크로로 자동으로 만들 수 있습니다. 기존에는 수동으로 이런 이름을 달아줬는데 IMGUI_NO_NAME 매크로만 집어넣으면 이제 자동으로 이런 이름을 달아줍니다.
  • settings.reset_params 제거 – 기존에는 render() 메서드가 인자로 const Mandelbrot* 을 받았기 때문에 Mandelbrot 클래스를 직접적으로 수정할 수 없었습니다. 그래서 settings.reset_params = true로 설정하여 main.cpp 내부의 루프에서 Mandelbrot 클래스를 수정했었습니다. 하지만, 이번에 렌더링 방식을 선택할 수 있게 하면서 main.cpp의 unique_ptr<Mandelbrot>가 담는 포인터 자체를 바꿔야 하기 때문에 update() 메서드가 unique_ptr<Mandelbrot>의 참조형을 받게 되면서 자연스럽게 settings.reset_params도 제거되었습니다.
  • iteration 자동화 기능 – 확대가 될 수록 iteration이 커져야 하는건 맞는데… 이걸 확대 해가면서 수동으로 하긴 좀 번거로웠습니다. 이 기능을 켜면(디폴트로 켜져있습니다) iteration을 자동으로 조정합니다. 이 기능을 제대로 구현 하려면 픽셀 데이터를 분석해야 하지만, 간단하게 만들기 위해 로그스케일로 iteration이 조정되게 만들었습니다.
  • 렌더링 표시기능 – 렌더링 되는 동안 UI에 빨간 글자로 rendering…이 표시되고 렌더링이 종료된 후 초록색 글자로 rendered가 표시됩니다. 렌더링 경과를 나타내는 작업도 Mandelbrot 클래스의 render_info.pixels를 읽어서 렌더링 된 픽셀의 비율을 계산하면 가능하긴 합니다. 이 기능은 최적화가 필요해서 다음에 하도록 하겠습니다.
  • 렌더링 방식 선택 기능 – 멀티 스레딩 기능을 만들고 드디어 런타임에 렌더링 방식을 선택할 수 있습니다. UI의 accelerator 콤보박스가 변경되면 accelerator changed 함수에서 unique_ptr<Mandelbrot>을 직접 수정해서 Mandelbrot이나 MandelbrotTBB의 인스턴스를 집어넣습니다.
gui.h
#include <string>
#include <memory>
#include <SDL2/SDL.h>
...
	bool keyCaptured();

	void update(std::unique_ptr<Mandelbrot>& mandelbrot);
	void render();
	void draw();
...
	struct {
		Acc accelerator;
		float move_speed;
		float scroll_scale;
		bool render_async;
		bool scaleToCursor;
		bool auto_iter;
		int32_t initial_iter;
		char capture_dir[256];
		bool capture_no_ui;
	} settings;
...
	void postErrorMessage(const char* msg);

private:
	void acceleratorChanged(std::unique_ptr<Mandelbrot>& mandelbrot);
gui.cpp
#define STRINGIZE_DETAIL(x) #x
#define STRINGIZE(x) STRINGIZE_DETAIL(x)
#define IMGUI_NO_LABEL "##" STRINGIZE(__COUNTER__)

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

#include "mandelbrot_tbb.h"
#include "time.h"
...
	settings.accelerator     = Acc::CPU_TBB;
	settings.move_speed      = 300.f;
	settings.scroll_scale    = 1.1f;
	settings.render_async    = true;
	settings.scaleToCursor   = true;
	settings.auto_iter       = true;
	settings.initial_iter    = 32;
...
void GUI::update(std::unique_ptr<Mandelbrot>& mandelbrot)
{
	ImGui_ImplSDLRenderer_NewFrame();
	ImGui_ImplSDL2_NewFrame(window);
	ImGui::NewFrame();
	ImGui::Begin("control", nullptr, ImGuiWindowFlags_NoResize);

	stringstream ss;
	int px, py, width, height;

	SDL_GetMouseState(&px, &py);
	SDL_GetWindowSize(window, &width, &height);

	ss << "resolution: " << width << "X" << height << "\n";
	ss << "mouse : (" << px << ", " << py << ")\n";
	ss << "fps   : " << round(Time::fps * 10) / 10;
	ss << "(" << round(10000. * Time::dt) / 10. << "ms)\n";
	ss << "cursor: " << mandelbrot->pixelToComplex(px, py) << "\n";
	ss << "pos   : " << mandelbrot->getPosition() << "\n";
	ss << "scale : " << mandelbrot->getScale() << "\n";
	ss << "iter  : " << mandelbrot->getIteration() << "\n";
	ImGui::Text(ss.str().c_str());

	if (mandelbrot->isRendering())
		ImGui::TextColored(ImColor(255, 0, 0), "rendering...");
	else ImGui::TextColored(ImColor(0, 255, 0), "rendered");

	if (ImGui::Button("reset parameters")) {
		mandelbrot->setPosition(0., 0.);
		mandelbrot->setScale(1.);
		mandelbrot->setIteration(32);
	}

	if (ImGui::TreeNode("more settings...")) {
		static const char* items[] = { "CPU", "CPU - TBB" };
		ImGui::Text("accelerator :");
		if (ImGui::Combo(IMGUI_NO_LABEL, (int*)&settings.accelerator, items, 2))
			acceleratorChanged(mandelbrot);

		ImGui::Text("move speed (pixel/s) :");
		ImGui::SliderFloat(IMGUI_NO_LABEL, &settings.move_speed, 200.f, 600.f);
		
		ImGui::Text("scroll scale :");
		ImGui::SliderFloat(IMGUI_NO_LABEL, &settings.scroll_scale, 1.1f, 10.f, "%f", 32);
		
		ImGui::Checkbox("render async", &settings.render_async);

		ImGui::Checkbox("scale to cursor", &settings.scaleToCursor);

		ImGui::Checkbox("auto iter", &settings.auto_iter);
		if (settings.auto_iter) {
			ImGui::Text("initial iteration:");
			ImGui::InputInt(IMGUI_NO_LABEL, &settings.initial_iter);
		}
		ImGui::TreePop();
	}

	if (settings.auto_iter) {
		auto iter    = mandelbrot->getIteration();
		auto mag     = 1. / mandelbrot->getScale();
		int new_iter = settings.initial_iter * (log(mag + 1) - log(2) + 1.);
		if (iter != new_iter) mandelbrot->setIteration(new_iter);
	}

	if (ImGui::TreeNode("screenshot")) {
		ImGui::Text("directory :");
		ImGui::InputText(IMGUI_NO_LABEL, 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();
}

void GUI::render()
{
	ImGui::Render();
}
...
void GUI::acceleratorChanged(std::unique_ptr<Mandelbrot>& mandelbrot)
{
	auto pos   = mandelbrot->getPosition();
	auto scale = mandelbrot->getScale();
	auto iter  = mandelbrot->getIteration();
	mandelbrot->stop();

	if (settings.accelerator == Acc::CPU)
		mandelbrot = make_unique<Mandelbrot>(renderer);
	else if (settings.accelerator == Acc::CPU_TBB)
		mandelbrot = make_unique<MandelbrotTBB>(renderer);

	mandelbrot->setPosition(pos.real(), pos.imag());
	mandelbrot->setScale(scale);
	mandelbrot->setIteration(iter);
}

변경된 사항을 반영하도록 main.cpp도 수정해 줍니다.

main.cpp
	while (!closed) {
		closed = EventProc(gui, mandelbrot);
		
		mandelbrot->render(gui.settings.render_async);
		gui.update(mandelbrot);
		gui.render();
		
		mandelbrot->draw();
		gui.draw();
		SDL_RenderPresent(renderer);

		Time::update();
	}

이제 수정된 GUI를 볼 수 있습니다. 모든 UI 요소를 볼 수 있도록 전부 펼쳐놨습니다.

이번 편까지 최종적으로 수정된 GUI

이제 Debug 빌드에서도 아주 부드럽게 돌아갑니다. iteration도 자동으로 조절되는 모습을 볼 수 있습니다.

다음 편에선 MandelbrotTBB에서 스레드 수를 선택할 수 있도록 기능을 추가하고, 밋밋한 이미지에 색을 입혀 보도록 하겠습니다.

답글 남기기

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