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

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

2-1. FPS 제한과 루프 시간 관리

먼저 전체 루프에서 FPS를 제한하고 프레임 시간을 관리하는 클래스인 Time 클래스를 만듭니다. 모든 멤버 변수가 static으로 선언되어 있으므로 사용시에 어떤 소스파일이나 헤더파일이든지 Time::fps처럼 클래스 인스턴스를 만들지 않고 사용할 수 있습니다. 하지만 static 멤버 변수의 특성상 하나의 소스파일에서 초기화를 해줘야 합니다. 따라서 매크로를 사용해서 이를 처리하도록 합니다.

time.h
#pragma once

#include <thread>
#include <chrono>

class Time {
	Time() = delete;
	
public:
	using clock_t      = std::chrono::high_resolution_clock;
	using time_point_t = clock_t::time_point;
	using milliseconds = std::chrono::milliseconds;

	static inline void update() {
		double delta  = elapsed();
		int32_t delay = 1e3 * (1. / fps_limit - delta);

		if (delay > 0) {
			std::this_thread::sleep_for(milliseconds(delay));
			delta = elapsed();
		} 

		fps   = 1. / delta;
		dt    = delta;
		begin = end;
	}

	static uint32_t fps_limit;
	static double fps;
	static double dt;

private:
	static inline double elapsed() {
		end = clock_t::now();
		return (end - begin).count() / 1e9;
	}

	static time_point_t begin;
	static time_point_t end;
};

#ifdef TIME_IMPL
uint32_t Time::fps_limit = 60;
double Time::fps         = 0.;
double Time::dt          = 0.;

Time::time_point_t Time::begin = Time::clock_t::now();
Time::time_point_t Time::end   = Time::clock_t::now();
#endif
C++

time.h에서 이미 포함했으므로 main.cpp에서 원래 2번 줄에 있던 #include <thread>를 지웁니다. 아래 처럼 time.h를 포함하기 전에 #define TIME_IMPL을 하면 main.cpp 소스파일에서만 Time 클래스의 static 변수들이 초기화 됩니다(그렇다고 다른 소스파일에서 Time 클래스를 못쓰는 것은 아닙니다).

main.cpp
#include <iostream>
#include <memory>
#include <SDL2/SDL.h>

#define TIME_IMPL
#include "mandelbrot.h"
#include "time.h"

#define INITIAL_WIDTH  680
#define INITIAL_HEIGHT 480
#define FRAME_LIMIT 60

using namespace std;
...
int main(int argc, char* argv[]) {
	Time::fps_limit = FRAME_LIMIT;
	Init();
...
	while (!closed) {
		SDL_Event e;
		while (SDL_PollEvent(&e)) {
			switch (e.type) {
			case SDL_QUIT: closed = true; break;
			}
		}

		mandelbrot->draw();
		SDL_RenderPresent(renderer);
		
		Time::update();
		cout << Time::fps << "  " << Time::dt << endl;
	}
C++

코드를 수정하고 아래 화면과 같이 콘솔에서 각 행에 fps와 각 루프 실행 시간이 표시되면 됩니다. 콘솔 출력의 모든 첫번째 열에서 main.cpp의 11번 줄에서 설정한 FPS 제한 값인 60 언저리의 값이 출력되는 것을 볼 수 있습니다.

2-2. 키 입력 추가 – 이동 및 iteration 조정

다음으로 이동 기능과 iteration 조정 기능을 만들어 보겠습니다. 먼저 mandelbrot.h와 mandelbrot.cpp를 수정합니다. 확대/축소를 위한 getScale() 메서드와 setScale() 메서드도 미리 만듭니다.

mandelbrot.h
class Mandelbrot
{
public:
	using real_t = double;

	Mandelbrot(SDL_Renderer* renderer);
	~Mandelbrot()

	void draw();

	std::complex<real_t> getPosition() const { return { pos_x, pos_y }; }
	void setPosition(real_t x, real_t y);
	void move(int32_t rel_px, int32_t rel_py);

	inline real_t getScale() const { return scale; };
	void setScale(real_t scale);

	inline uint32_t getIteration() const { return iter; }
	void setIteration(uint32_t iter);

private:
	void drawSurface();

setPosition() 메서드는 화면상의 중심이 복소평면의 어디에 위치하게 하라라는 메서드입니다. 반면에 move() 메서드는 화면의 픽셀값을 기준으로 망델브로 집합을 이동시킵니다. set이 붙은 메서드는 멤버 변수 updated를 updated = false로 설정해서 변경사항이 화면에 적용되도록 합니다.

mandelbrot.cpp
#include "mandelbrot.h"

#include <complex>

using namespace std;

using real_t = Mandelbrot::real_t;
...
void Mandelbrot::draw()
{
	if (!updated) {
		drawSurface();
		SDL_UpdateTexture(texture, NULL, surface->pixels, surface->pitch);
		updated = true;
	}

	SDL_RenderCopy(renderer, texture, nullptr, nullptr);
}

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

void Mandelbrot::move(int32_t rel_px, int32_t rel_py)
{
	real_t dx = 4. * scale * aspect / width;
	real_t dy = 4. * scale / height;

	pos_x  -= dx * rel_px;
	pos_y  += dy * rel_py;
	updated = false;
}

void Mandelbrot::setScale(real_t scale) 
{
	this->scale = scale;
	updated     = false;
}

void Mandelbrot::setIteration(uint32_t iter)
{
	this->iter = iter;
	updated    = false;
}

이제 새로운 이벤트를 처리할 수 있도록 main.cpp를 수정합니다. 기존에 main() 안에 있던 switch 문을 EventProc() 함수로 밖으로 빼냅니다. 그리고 비동기 키 입력을 처리하는 EventAsync() 함수와 키 이벤트를 처리하는 KeyProc() 함수를 만듭니다. 키 입력에 이 두 가지를 구분해야 합니다. 키가 눌리는 동안 이동이 되어야 하기 때문에 EventAsync() 함수에서 매 루프마다 키가 눌려있다면 시점을 등속으로 이동합니다. 만약 KeyProc()에서 이를 처리한다면 부드럽게 이동이 안되고 키를 연타해서 이동해야 합니다. EventAsync()함수에서 100 * Time::dt로 된 식을 볼 수 있는데 이는 속력 x 시간으로 fps가 변경되어도 등속으로 이동할 수 있게 해줍니다. 정확하게 따지자면 초당 100픽셀을 이동합니다.

main.cpp
void Init() {
	if (SDL_Init(SDL_INIT_VIDEO)) {
		cout << "error initializing SDL: " << SDL_GetError() << endl;
		exit(-1);
	}
}

void Terminate() {
	SDL_Quit();
}

void EventAsync(unique_ptr<Mandelbrot>& mandelbrot) {
	auto* keyStates = SDL_GetKeyboardState(NULL);
	if (keyStates[SDL_SCANCODE_W] || keyStates[SDL_SCANCODE_UP])
		mandelbrot->move(0, 100 * Time::dt);
	if (keyStates[SDL_SCANCODE_A] || keyStates[SDL_SCANCODE_LEFT])
		mandelbrot->move(100 * Time::dt, 0);
	if (keyStates[SDL_SCANCODE_S] || keyStates[SDL_SCANCODE_DOWN])
		mandelbrot->move(0, -100 * Time::dt);
	if (keyStates[SDL_SCANCODE_D] || keyStates[SDL_SCANCODE_RIGHT])
		mandelbrot->move(-100 * Time::dt, 0);
}

void KeyProc(SDL_KeyboardEvent& e, unique_ptr<Mandelbrot>& mandelbrot) {
	switch (e.keysym.scancode) {
	case SDL_SCANCODE_COMMA: {
		auto iter = mandelbrot->getIteration();
		if (iter > 1)
			mandelbrot->setIteration(iter - 1);
		break;
	}
	case SDL_SCANCODE_PERIOD:
		auto iter = mandelbrot->getIteration();
		mandelbrot->setIteration(iter + 1);
		break;
	}
}

bool EventProc(unique_ptr<Mandelbrot>& mandelbrot) {
	SDL_Event e;
	while (SDL_PollEvent(&e)) {
		switch (e.type) {
		case SDL_QUIT: return true;
		case SDL_KEYDOWN:
			KeyProc(e.key, mandelbrot);
			break;
		}
	}

	EventAsync(mandelbrot);
	return false;
}
...
	bool closed = false;
	while (!closed) {
		closed = EventProc(mandelbrot);

		mandelbrot->draw();
		SDL_RenderPresent(renderer);
		
		Time::update();
		cout << Time::fps << "  " << Time::dt << endl;
	}

이제 프로그램을 release로 빌드하고 실행해 봅니다. 움직이는 이미지라 계산량이 상당하기 때문에 debug로 빌드한다면 프레임이 매우 끊기기 때문입니다. 실행해보면 WASD키나 방향키로 시점을 자유롭게 움직일 수 있습니다. 그리고 키보드의 <, >키로 iteration 값을 조정할 수 있습니다.

2-3. 마우스 입력 추가 – 이동 및 확대/축소

마우스 하나만 있으면 드래그와 스크롤로 이동과 확대/축소를 모두 할 수 있습니다. 이번엔 마우스 입력을 받도록 해봅시다. main.cpp의 EventProc을 다음과 같이 수정합니다.

main.cpp
bool EventProc(unique_ptr<Mandelbrot>& mandelbrot) {
	static bool mouse_pressed = false;
	
	SDL_Event e;
	while (SDL_PollEvent(&e)) {
		switch (e.type) {
		case SDL_QUIT: return true;
		case SDL_KEYDOWN:
			KeyProc(e.key, mandelbrot);
			break;
		case SDL_MOUSEBUTTONDOWN:
		case SDL_MOUSEBUTTONUP:
			if (e.button.button == SDL_BUTTON_LEFT)
				mouse_pressed = e.button.state;
			break;
		case SDL_MOUSEMOTION:
			if (mouse_pressed)
				mandelbrot->move(e.motion.xrel, e.motion.yrel);
			break;
		case SDL_MOUSEWHEEL:
			if (e.wheel.y != 0) {
				auto scale = mandelbrot->getScale();
				scale     *= e.wheel.y > 0 ? 1.1 : 1. / 1.1;
				mandelbrot->setScale(scale);
			}
			break;
		}
	}

	EventAsync(mandelbrot);
	return false;
}

이제 마우스로 망델브로 집합을 움직일 수 있게 되었습니다. 그리고 확대/축소도 가능한데… 흠… 근데 뭔가 부족해 보입니다. 지금은 화면 중심을 기준으로 하지만, 마우스 커서를 올려놓은 부분을 기준으로 확대/축소할 수 있으면 좋을텐데 말입니다.

여기서 확대/축소 기능을 개선하기 위해 코드를 더 수정합니다. 먼저 mandelbrot.cpp의 #include <complex>를 옮기고 mandelbrot.h에 메서드 두개를 추가합니다.

mandelbrot.h
#pragma once

#include <complex>
#include <SDL2/SDL.h>
	inline real_t getScale() const { return scale; }
	void setScale(real_t scale);
	void setScaleTo(real_t scale, real_t px, real_t py);

	inline uint32_t getIteration() const { return iter; }
	void setIteration(uint32_t iter);
	
	std::complex<real_t> pixelToComplex(real_t px, real_t py) const;
		
private:
	void drawSurface();

mandelbrot.cpp에 #include <complex>를 없애고 추가한 두 메서드를 구현합니다. setScaleTo() 메서드는 새로운 scale과 화면상의 커서 위치를 인자로 받습니다. 이 함수는 setScale() 함수와는 다르게 커서가 가리키고 있는 복소평면 상의 좌표가 scale을 수정하고도 변하지 않도록 해줍니다. 간단하게 앞의 설명을 그대로 수식으로 옮기고 이항을 하면 그 공식을 구할 수 있습니다. pixelToComplex() 메서드는 화면 상의 커서 위치를 복소평면 상의 좌표로 변환하는 함수입니다. 클래스 외부에서도 사용할 수 있으므로 public으로 선언합니다.

mandelbrot.cpp
#include "mandelbrot.h"
// #include <complex> -> moved to mandelbrot.h !!
using namespace std;

using real_t = Mandelbrot::real_t;
...
void Mandelbrot::setScaleTo(real_t scale, real_t px, real_t py)
{
	auto point  = pixelToComplex(px, py);
	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;
	updated = false;
}
...
std::complex<real_t> Mandelbrot::pixelToComplex(real_t px, real_t py) const
{
	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;
	return { min_x + px * dx, max_y - py * dy };
}

마지막으로 main.cpp에서 EventProc()을 새로 만든 메서드를 사용하도록 변경합니다.

main.cpp
		case SDL_MOUSEWHEEL:
			if (e.wheel.y != 0) {
				int px, py;
				SDL_GetMouseState(&px, &py);
				auto scale = mandelbrot->getScale();
				scale     *= e.wheel.y > 0 ? 1.1 : 1. / 1.1;
				mandelbrot->setScaleTo(scale, px, py);
			}
			break;

이제 실행시켜보면 마우스 커서를 기준으로 확대/축소하는 것을 확인할 수 있습니다.

이번 편까지 진행한 내용을 바탕으로 파라미터들을 조정해가면서 얻은 이미지입니다.

현재 윈도우는 크기를 조정할 수 없지만, 다음편에서 윈도우 리사이징을 허용하고 유명한 GUI 라이브러리인 ImGui 라이브러리로 GUI를 추가해보겠습니다.

답글 남기기

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