Pravimo game engine od nule, deo 3: ko se boji renderovanja još…

Miroslav Gavrilov nas uvodi dalje u svet igara: Treći deo serijala u kome pravimo sopstveni game engine od nule zato što možemo i jer je poučno.

Miroslav Gavrilov - 30. Jul, 2015.

Svako normalan, naravno. Da je svet savršen, ovo bi bio prvi i početni deo serijala o pravljenju game enginea, i bio bi oko pola strane dug. Takvo iskustvo možete imati sa gotovim engineima, gde je sve ovo skriveno od vas, i to nije loše, nećete ovo da pišete baš svaki put. Mi smo morali da ga odložimo dok ne dobijemo priliku da poradimo na nekim bazičnijim stvarima, kao što su prozori i komunikacija. Slika odozgo je malo prilagođena slika vektorskih polja, naiva dvadeset i prvog veka.

Današnji tekst je čisto mučenje. Da biste koristili DirectX, eto, morate ponekad mnogo toga da napišete, a da rezultat bude minimalan. U našem trenutnom slučaju, radi se o tome da smo došli do inicijalizacije DirectXa, gde sad moramo da ga osposobimo da radi kako treba. Pišemo još jedan menadžer, i cela komplikacija će ležati u njegovoj Initialize() funkciji. A šta je to potrebno uraditi da bi DirectX radio? Pa… Moramo mu napraviti swap chain, što je niz bafera koje možemo da smenjujemo pri iscrtavanju kako bismo dobili lepu, tečnu grafiku na ekranu. Zatim, biće mu potrebne informacije oko toga da li želimo da nam igra bude fullscreen ili ne, i na kojoj rezoluciji, nešto što se zove display mode. Zatim nam treba backbuffer u koji ćemo crtati, zajedno sa depth buffer objektom koji nam zapravo neće biti ključan ali je bolje videti kako se pravi nešto takvo. Depth buffer radi sledeću stvar: od scene 3D objekata izvlači dubinu tako što iscrta jednu teksturu nijansama sive. Ovim procesom kasnije možemo da brzo testiramo koliko je šta duboko, poređenjem dve nijanse sive (a pošto je svaka nijansa sive u RGB formatu oblika (x, x, x), gde je x broj između 0 i 255, ustvari poredimo samo dva broja!).

Što dublje, to tamnije.

Potom nam treba rasterizer state, što je mehanizam koji 3D grafiku pretvara u 2D sliku na ekranu. U njemu definišemo da li crtamo linije ili pune oblike, da li antialiasujemo ili ne, itd. Sledi pravljenje blend state objekta, kojim ćemo da obavestimo engine o tome kako hoćemo da blendujemo grafiku, čime dobijamo transparenciju i slične efekte. Sledi obaveštavanje engine-a o tome koliki nam je radni prostor i to je kraj naše inicijalizacije. Pripremio sam vas, nemojte da se žalite kasnije!

Kada budemo gasili engine, moramo sve ovo da resetujemo, izađemo iz fullscreen moda ako smo bili u njemu i pozdravimo se sa svim svojim objektima koje smo namestili.

Najzanimljiviji deo ovonedeljneg tutorijala leži u Update() metodi: gde ćemo očistiti ekran i onda prezentovati šta god da swap chain ima da ponudi. Naravno da neće biti preterano zanimljivo, jer ove nedelje swap chain nema šta da ponudi, no sledećeg puta će u njemu biti trouglova i tekstura i ekran će biti šaren!

Kako sve ovo povezati? Na sreću, DirectX nam sređuje to sam od sebe: postoji jedan njegov objekat koji ćemo na početku stvoriti, koji se zove device context, i sve što treba da uradimo jeste da mu javimo za svaki sledeći korak koji prođemo. Da bi DirectX komunicirao sa svetom, tu je i neizostavni device objekat, koji je kao hWnd objekat u Windows programiranju od pre dve nedelje.

// GraphicsManagerDirectX.h
#pragma once

#include "IResources.h"
#pragma comment(lib, "dxgi.lib")
#pragma comment(lib, "d3d11.lib")
#pragma comment(lib, "d3dx11.lib")
#pragma comment(lib, "d3dx10.lib")

#include <windows.h>
#include <dxgi.h>
#include <d3dcommon.h>
#include <d3d11.h>
#include <d3dx10math.h>

struct GraphicsManagerDirectX : public IResourceManager
{	
	ID3D11Device* GetDevice() const;
	ID3D11DeviceContext* GetDeviceContext() const;
	void GetVideoCardInfo(char* videoCardName, int& videoCardMemory);	

	void Initialize();
	void Update();
	void Shutdown();
private:
	bool InitializeDriver();
	void InitializeDisplayMode();
	void InitializeSwapChain();
	void InitializeDepthBuffer();
	void InitializeRasterizer();
	void InitializeBlendState();
	void InitializeViewport();

	struct
	{
		unsigned int screenWidth, screenHeight;
		bool vsync;

		struct
		{
			unsigned int numberOfModes;
			unsigned int numerator;
			unsigned int denominator;
			unsigned int videoCardNameLength;
		} adapterOptions;
	} displayMode;

	struct
	{
		int memory;
		char description[128];
	} videoCard;

	IDXGISwapChain* swapChain;	
	ID3D11Device* device;
	ID3D11DeviceContext* deviceContext;	
	ID3D11RenderTargetView* renderTargetView;
	
	struct
	{
		ID3D11Texture2D* buffer;
		ID3D11DepthStencilState* state;
		ID3D11DepthStencilView* view;
	} depth;

	ID3D11RasterizerState* rasterState;
	ID3D11BlendState* blendState;
};

Ovo nam je zasad najveći .h fajl, većinom zbog gomile stvari koje su potrebne DirectX-u da proradi. Sada idemo deo po deo po .cpp fajlu:

// GraphicsManagerDirectX.cpp

#include "GraphicsManagerDirectX.h"

#include "HelpersDirectX.h"
#include "WindowsManagerDirectX.h"
#include "ConfigurationManager.h"
#include "SoosEngineDirectX.h"

void GraphicsManagerDirectX::Initialize()
{
	this->InitializeDisplayMode();
	this->InitializeSwapChain();
	this->InitializeDepthBuffer();
	this->InitializeRenderTargetView();
	this->InitializeRasterizer();
	this->InitializeBlendState();
	this->InitializeViewport();
}

Inicijalizaciju smo razbili na gomilu delova, kako ne bismo morali da čitamo jednu veliku metodu, to nikad nije bilo zanimljivo.

Kao što je i bilo zamišljeno, InitializeDisplayMode() inicijalizira prvi deo DirectX-a i odabere odgovarajući display mode.

void GraphicsManagerDirectX::InitializeDisplayMode()	
{
	this->device = nullptr;
	this->deviceContext = nullptr;

	IDXGIFactory* factory;
	IDXGIAdapter* adapter;
	IDXGIOutput* adapterOutput;

	RAGE_QUIT_UNLESS(CreateDXGIFactory(__uuidof(IDXGIFactory), (void**)&factory),
		L"[GraphicsManagerDirectX.cpp] Factory not created correctly.");

	RAGE_QUIT_UNLESS(factory->EnumAdapters(0, &adapter),
		L"[GraphicsManagerDirectX.cpp] Adapter could not be created");

	RAGE_QUIT_UNLESS(adapter->EnumOutputs(0, &adapterOutput),
		L"[GraphicsManagerDirectX.cpp] Adapter outputs could not be enumerated.");

	RAGE_QUIT_UNLESS(adapterOutput->GetDisplayModeList(
		DXGI_FORMAT_R8G8B8A8_UNORM, DXGI_ENUM_MODES_INTERLACED, 
		&displayMode.adapterOptions.numberOfModes, nullptr),
		L"[GraphicsManagerDirectX.cpp] Number of modes fitting the selected adapter options could not be found.");

	DXGI_MODE_DESC* displayModeList = new DXGI_MODE_DESC[displayMode.adapterOptions.numberOfModes];

	RAGE_QUIT_UNLESS(adapterOutput->GetDisplayModeList(
		DXGI_FORMAT_R8G8B8A8_UNORM, DXGI_ENUM_MODES_INTERLACED, 
		&displayMode.adapterOptions.numberOfModes, displayModeList),
		L"[GraphicsManagerDirectX.cpp] Cannot fill display mode list with designated modes.");

	displayMode.screenWidth = this->As()->ScreenWidth();
	displayMode.screenHeight = this->As()->ScreenHeight();

	for (int i = 0; i < displayMode.adapterOptions.numberOfModes; i++)
	{
		if (displayModeList[i].Width == displayMode.screenWidth &&
			displayModeList[i].Height == displayMode.screenHeight)
		{
			displayMode.adapterOptions.numerator = displayModeList[i].RefreshRate.Numerator;
			displayMode.adapterOptions.denominator = displayModeList[i].RefreshRate.Denominator;
		}
	}

	DXGI_ADAPTER_DESC adapterDesc;
	RAGE_QUIT_UNLESS(adapter->GetDesc(&adapterDesc),
		L"[GraphicsManagerDirectX.cpp] Cannot acquire adapter description.");

	videoCard.memory = (int)(adapterDesc.DedicatedVideoMemory / 1024 / 1024);
	wcstombs_s(&displayMode.adapterOptions.videoCardNameLength, videoCard.description,
		128, adapterDesc.Description, 128);

	delete[] displayModeList;
	displayModeList = nullptr;

	adapterOutput->Release();
	adapterOutput = nullptr;

	adapter->Release();
	adapter = nullptr;

	factory->Release();
	factory = nullptr;
}

Mnogo RAGE QUITova na ovoj strani, no o tome ćemo kasnije. Zasad treba znati samo da je to naša metoda koja će naterati program da pukne, uz lepu poruku koju prima kao drugi parametar.

Dalje, pravimo swap chain:

void GraphicsManagerDirectX::InitializeSwapChain()
{
	this->swapChain = nullptr;

	this->displayMode.vsync = this->As()->VSyncEnabled;

	DXGI_SWAP_CHAIN_DESC swapChainDesc;
	ZeroMemory(&swapChainDesc, sizeof(swapChainDesc));
	{
		swapChainDesc.BufferCount = 1;
		swapChainDesc.BufferDesc.Width = displayMode.screenWidth;
		swapChainDesc.BufferDesc.Height = displayMode.screenHeight;
		swapChainDesc.BufferDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;

		if (this->displayMode.vsync)
		{
			swapChainDesc.BufferDesc.RefreshRate.Numerator = displayMode.adapterOptions.numerator;
			swapChainDesc.BufferDesc.RefreshRate.Denominator = displayMode.adapterOptions.denominator;
		}
		else
		{
			swapChainDesc.BufferDesc.RefreshRate.Numerator = 0;
			swapChainDesc.BufferDesc.RefreshRate.Denominator = 1;
		}

		swapChainDesc.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;
		swapChainDesc.OutputWindow = this->As()->WindowHandle();

		swapChainDesc.SampleDesc.Count = 1;
		swapChainDesc.SampleDesc.Quality = 0;

		swapChainDesc.Windowed = !this->As()->WindowFullscreen;

		swapChainDesc.BufferDesc.ScanlineOrdering = DXGI_MODE_SCANLINE_ORDER_UNSPECIFIED;
		swapChainDesc.BufferDesc.Scaling = DXGI_MODE_SCALING_UNSPECIFIED;

		swapChainDesc.SwapEffect = DXGI_SWAP_EFFECT_DISCARD;
		swapChainDesc.Flags = 0;
	}

	D3D_FEATURE_LEVEL featureLevel;
	featureLevel = D3D_FEATURE_LEVEL_11_0;

	RAGE_QUIT_UNLESS(D3D11CreateDeviceAndSwapChain(
		nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, 0, &featureLevel, 1, D3D11_SDK_VERSION,
		&swapChainDesc, &this->swapChain, &this->device, nullptr, &this->deviceContext),
		L"[GraphicsManagerDirectX.cpp] Cannot create DirectX device and swap chain.");
}

Većina stvari je razumljiva, a ako nije, evo i lektire. Ništa lepše od malo čitanja pred spavanje. Dobro, sada inicijalizacija dubinskog bafera i, pošto su dovoljno povezani, render target view-a:

void GraphicsManagerDirectX::InitializeDepthBuffer()
{
	this->depth.buffer = nullptr;
	this->depth.state = nullptr;
	this->depth.view = nullptr;

	ID3D11Texture2D* backBufferPtr;

	RAGE_QUIT_UNLESS(this->swapChain->GetBuffer(
		0, __uuidof(ID3D11Texture2D), (LPVOID*)&backBufferPtr),
		L"[GraphicsManagerDirectX.cpp] Cannot get pointer to back buffer.");

	RAGE_QUIT_UNLESS(this->device->CreateRenderTargetView(
		backBufferPtr, nullptr, &this->renderTargetView),
		L"[GraphicsManagerDirectX.cpp] Cannot create render target view.");

	backBufferPtr->Release();
	backBufferPtr = nullptr;

	struct
	{
		D3D11_TEXTURE2D_DESC buffer;
		D3D11_DEPTH_STENCIL_DESC stencil;
		D3D11_DEPTH_STENCIL_VIEW_DESC view;
	} depthDesc;

	ZeroMemory(&depthDesc, sizeof(depthDesc));
	{
		depthDesc.buffer.Width = displayMode.screenWidth;
		depthDesc.buffer.Height = displayMode.screenHeight;
		depthDesc.buffer.MipLevels = 1;
		depthDesc.buffer.ArraySize = 1;
		depthDesc.buffer.Format = DXGI_FORMAT_D24_UNORM_S8_UINT;
		depthDesc.buffer.SampleDesc.Count = 1;
		depthDesc.buffer.SampleDesc.Quality = 0;
		depthDesc.buffer.Usage = D3D11_USAGE_DEFAULT;
		depthDesc.buffer.BindFlags = D3D11_BIND_DEPTH_STENCIL;
		depthDesc.buffer.CPUAccessFlags = 0;
		depthDesc.buffer.MiscFlags = 0;
	}

	RAGE_QUIT_UNLESS(this->device->CreateTexture2D(
		&depthDesc.buffer, nullptr, &this->depth.buffer),
		L"[GraphicsManagerDirectX.cpp] Cannot create depth stencil buffer.");

	{
		depthDesc.stencil.DepthEnable = true;
		depthDesc.stencil.DepthWriteMask = D3D11_DEPTH_WRITE_MASK_ALL;
		depthDesc.stencil.DepthFunc = D3D11_COMPARISON_LESS;

		depthDesc.stencil.StencilEnable = true;
		depthDesc.stencil.StencilReadMask = 0xff;
		depthDesc.stencil.StencilWriteMask = 0xff;

		depthDesc.stencil.FrontFace.StencilFailOp = D3D11_STENCIL_OP_KEEP;
		depthDesc.stencil.FrontFace.StencilDepthFailOp = D3D11_STENCIL_OP_INCR;
		depthDesc.stencil.FrontFace.StencilPassOp = D3D11_STENCIL_OP_KEEP;
		depthDesc.stencil.FrontFace.StencilFunc = D3D11_COMPARISON_ALWAYS;

		depthDesc.stencil.BackFace.StencilFailOp = D3D11_STENCIL_OP_KEEP;
		depthDesc.stencil.BackFace.StencilDepthFailOp = D3D11_STENCIL_OP_DECR;
		depthDesc.stencil.BackFace.StencilPassOp = D3D11_STENCIL_OP_KEEP;
		depthDesc.stencil.BackFace.StencilFunc = D3D11_COMPARISON_ALWAYS;
		depthDesc.stencil.DepthEnable = true;
	}

	RAGE_QUIT_UNLESS(this->device->CreateDepthStencilState(
		&depthDesc.stencil, &this->depth.state),
		L"[GraphicsManagerDirectX.cpp] Cannot create depth stencil state.");

	{
		depthDesc.view.Format = DXGI_FORMAT_D24_UNORM_S8_UINT;
		depthDesc.view.ViewDimension = D3D11_DSV_DIMENSION_TEXTURE2D;
		depthDesc.view.Texture2D.MipSlice = 0;
	}

	RAGE_QUIT_UNLESS(this->device->CreateDepthStencilView(
		this->depth.buffer, &depthDesc.view, &this->depth.view),
		L"[GraphicsManagerDirectX.cpp] Cannot create depth stencil view.");

	this->deviceContext->OMSetRenderTargets(1, &this->renderTargetView, this->depth.view);
}

Izdržali ste dovde? Evo vam kapri!

size_561_350_1361283693kapri

Dobro, odavde stvari postaju mnogo kraće.

void GraphicsManagerDirectX::InitializeRasterizer()
{
	this->rasterState = nullptr;

	D3D11_RASTERIZER_DESC rasterDesc;
	{
		rasterDesc.AntialiasedLineEnable = false;
		rasterDesc.CullMode = D3D11_CULL_NONE;
		rasterDesc.DepthBias = 0;
		rasterDesc.DepthBiasClamp = 0.0f;
		rasterDesc.DepthClipEnable = true;
		rasterDesc.FillMode = D3D11_FILL_SOLID;
		rasterDesc.FrontCounterClockwise = false;
		rasterDesc.MultisampleEnable = false;
		rasterDesc.ScissorEnable = false;
		rasterDesc.SlopeScaledDepthBias = 0.0f;
	}

	RAGE_QUIT_UNLESS(this->device->CreateRasterizerState(&rasterDesc, &this->rasterState),
		L"[GraphicsManagerDirectX.cpp] Cannot create rasterizer state.");

	this->deviceContext->RSSetState(this->rasterState);
}

Bez blendovanja bismo imali najgori 2D engine ikad, tako da ćemo sada i blend state da uključimo u jednačinu:

void GraphicsManagerDirectX::InitializeBlendState()
{
	D3D11_BLEND_DESC blendStateDesc;
	ZeroMemory(&blendStateDesc, sizeof(D3D11_BLEND_DESC));
	blendStateDesc.AlphaToCoverageEnable = FALSE;
	blendStateDesc.IndependentBlendEnable = FALSE;
	blendStateDesc.RenderTarget[0].BlendEnable = TRUE;
	blendStateDesc.RenderTarget[0].SrcBlend = D3D11_BLEND_SRC_ALPHA;
	blendStateDesc.RenderTarget[0].DestBlend = D3D11_BLEND_INV_SRC_ALPHA;
	blendStateDesc.RenderTarget[0].BlendOp = D3D11_BLEND_OP_ADD;

	blendStateDesc.RenderTarget[0].SrcBlendAlpha = D3D11_BLEND_SRC_ALPHA;
	blendStateDesc.RenderTarget[0].DestBlendAlpha = D3D11_BLEND_DEST_ALPHA;
	blendStateDesc.RenderTarget[0].BlendOpAlpha = D3D11_BLEND_OP_ADD;

	blendStateDesc.RenderTarget[0].RenderTargetWriteMask = D3D11_COLOR_WRITE_ENABLE_ALL;

	RAGE_QUIT_UNLESS(this->device->CreateBlendState(&blendStateDesc, &this->blendState),
		L"Failed To Create Blend State\n");

	deviceContext->OMSetBlendState(blendState, NULL, 0xFFFFFF);
}

Ovde bih se malo zaustavio, pošto ćemo se kasnije možda morati vratiti na ovo: iscrtavanje na ekran se izvodi kao specijalan slučaj crtanja na neku metu renderovanja (render target), što su obične teksture po kojima crtamo. Ekran je, što se renderera tiče, samo još jedna ovakva tekstura, poslednja na koju ćemo nešto nacrtati. Ovo pominjemo sad izričito zbog toga što se za post-processing efekte koriste upravo po nekoliko render target-a: jedna za sliku bez efekta, onda još jedna za izvođenje efekta korišćenjem prve kao ulaza. Blur, recimo, uzme sasvim jasnu sliku i onda u drugom prolazu (na drugom render target-u), koristi piksele te prve slike i pomalo ih izmeni, tako da budu sličniji jedni drugima.

Svaki render target ima svoje blend modove, što je možda lakše razumeti u kontekstu blend modova iz programa kao što su Photoshop i njemu slični. Zato, evo primera, ne baš ovoga što radimo, no dovoljno sličnog da bi odgovaralo:

IC554564

Zasad nećemo komplikovati više od ovoga, možete već da vidite koliko se stvari ovime može uraditi. Ako nekoga zanima ova tema u više detalja, evo i štiva.

Dobro, za sam kraj:

void GraphicsManagerDirectX::InitializeViewport()
{
	D3D11_VIEWPORT viewport;
	{
		viewport.Width = (float)displayMode.screenWidth;
		viewport.Height = (float)displayMode.screenHeight;
		viewport.MinDepth = 0.0f;
		viewport.MaxDepth = 1.0f;
		viewport.TopLeftX = 0.0f;
		viewport.TopLeftY = 0.0f;
	}

	this->deviceContext->RSSetViewports(1, &viewport);
}

Izborismo se! Toliko o inicijalizaciji. Hajdemo sada na Shutdown() metodu:

void GraphicsManagerDirectX::Shutdown()
{
	if (this->swapChain) this->swapChain->SetFullscreenState(false, nullptr);

	SAFE_RELEASE(this->rasterState);
	SAFE_RELEASE(this->depth.view);
	SAFE_RELEASE(this->depth.state);
	SAFE_RELEASE(this->depth.buffer);
	SAFE_RELEASE(this->renderTargetView);
	SAFE_RELEASE(this->deviceContext);
	SAFE_RELEASE(this->device);
	SAFE_RELEASE(this->swapChain);
}

Priznajte da ste očekivali nešto strašnije! Slede neke veoma jednostavne metode koje će nam kasnije biti korisne:


void GraphicsManagerDirectX::GetVideoCardInfo(char* name, int& memory)
{
	strcpy_s(name, 128, this->videoCard.description);
	memory = this->videoCard.memory;
	return;
}

ID3D11Device* GraphicsManagerDirectX::GetDevice() const
{
	return this->device;
}

ID3D11DeviceContext* GraphicsManagerDirectX::GetDeviceContext() const
{
	return this->deviceContext;
}

I posle svega ovog, sada je konačno vreme za zanimljiv deo. Sada ćemo da renderujemo!

void GraphicsManagerDirectX::Update()
{
	float color[4] = { 0, 0, 1, 1 };

	this->deviceContext->ClearDepthStencilView(this->depth.view, D3D11_CLEAR_DEPTH, 1.0f, 0);
	this->deviceContext->ClearRenderTargetView(this->renderTargetView, color);

	this->swapChain->Present(this->vsync ? 1 : 0, 0);
}

Jej! Renderujemo! Ne zaboravite da dodate menadžer u engine, kao što smo i prethodne dodavali. Sad, da bi ovo radilo, da definišemo i onaj RAGE QUIT od ranije:

// HelpersDirectX.h

#pragma once

#include <sstream>

#define MSGBOX(msg, title)	{ std::wstringstream msgb; msgb << (msg); MessageBox(nullptr, msgb.str().c_str(), (title), MB_OK); }

#define RAGE_QUIT_UNLESS(x, msg)	{ if(FAILED((x))) { MSGBOX((msg), L"Rage quit!"); exit(1); } }

#define SAFE_RELEASE(x)		{ if(x) { (x)->Release(); (x) = nullptr; } }

Hajde čisto da uporedimo ovo sa prethodnom nedeljom:

soos33

Ovo bi trebalo da nas uveri da mi zaista menjamo stvari sada, i to iz DirectXa. Ako još uvek nije, hajde da vidimo šta kaže Microsoftova dijagnostika (ovo su gifovi, kliknite ih, valjda):

soos31

Ako nikad niste otišli dalje od ovoga, kliknite ime frejma koji ste upravo uhvatili. Desiće se čudo.

soos32

Ovaj alat je jako koristan. Toliko da mi se čini da je Visual Studio postao oko 100% bolji sa njim u sebi. Šta sve može da radi? Pa, recimo samo da ćemo ga koristiti za neke od najtežih sesija rešavanja bagova ikad, kada budemo gnjavili shadere. Kako čujem, Visual Studio 2015 Community Edition će Graphics Diagnostics Toolkit imati u sebi, pa eto teme za radovanje.

U najavi za sledeću nedelju: zanimljivosti! Modeli, teksture, shaderi, učitavanje iz fajlova. Ako ste izdržali dovde, vratite se nazad i shvatite da sam vam dao poklon koji nastavlja da poklanja. Tako je, ona slika kaprija se ne topi! Vidimo se sledeće nedelje! Pitanja, komentari i sve drugo, ispod teksta, ili na tviteru, fejsbuku i ostalim mrežama! Do tad, sve najbolje!

triturn

PS! Rekoh da ću svake nedelje reklamirati nekog indie gamedeva i, eto, došao je još jedan na red! Ovog puta, Triturn! Muči me već neko vreme, od kad sam ga dobio. Izgleda da prosto nisam dovoljno elastičan za njega. Možda ćete vi imati više umeća od mene!

*Obnovite prvi i drugi deo