Kamera pro 3D svět

V tomto článku se pokusíme implementovat snadno použitelnou třídu kamery, která bude vhodná pro pohyby v obecném 3D světě, například pro nějakou střílečku - myš mění směr natočení a šipky na klávesnici zajišťují pohyb. Přestože budeme používat maličko matematiky, nebojte se a směle do čtení!

Ortogonální a polární souřadnice

Když se vysloví spojení 'souřadnice ve 3D prostoru', většina lidí si pravděpodobně představí tři čísla označující složky polohového vektoru na osách x, y a z, není to však jediná možnost. Ortogonální souřadnicový systém lze bez problémů nahradit tzv. polárním systémem.

U polárního systému figurují také tři souřadnice. Horizontální a vertikální úhel definují směr natočení a průsečík s koulí určí jednoznačné souřadnice bodu. Pokud si to nedokáže představit, začněte ve 2D, kde figuruje jen jeden úhel s kružnicí a pak přejděte do 3D.

Například bod ležící na páté jednotce osy y [0,5,0] by se v polárním souřadnicovém systému vyjádřil jako horizontální úhel 90 stupňů, vertikální také 90 stupňů a poloměr pět jednotek ([0,0,0] se předpokládá na ose x).

Třída kamery - ccamera.h

Jak už jste z úvodu asi pochopili, pro implementace kamery se budou více hodit polární souřadnice, protože si při natáčení vystačíme s obyčejným sčítáním dvou čísel. Pokud jste si právě položili otázku, zda je možné v OpenGL používat polární souřadnice, je vidět, že už myslíte trochu dopředu. Ne, v OpenGL je pouze ortogonální souřadnicový systém, ale to se doufejme nějak poddá :-)

Ale pojďme ke kódu. Soubor cmath je C++ obdoba, céčkovského math.h a pomocí SDL_opengl.h požádáme multiplatformní knihovnu SDL, na které máme vystavěnou aplikaci, aby zpřístupnila OpenGL a GLU funkce.

Pozn.: Tímto se omlouvám všem, kteří preferují jiná API pro vytváření okýnek (např. glut nebo Win32 API), díky SDL bude možné zkompilovat a spustit program na všech běžně používaných operačních systémech, takže by si teoreticky neměli stěžovat ani Woknaři ani Linuxáci.

Třída kamery je navíc na SDL nezávislá, takže v případě xenofobie ze SDL stačí umazat řádek se SDL_opengl.h a vložit celou třídu do vlastního aplikačního kódu. Pokud by vás naopak SDL zaujalo, nedávno jsem se ho pokoušel popsat v sérii článků pro http://www.root.cz/.

#ifndef __CCAMERA_H__
#define __CCAMERA_H__

#include <cmath>
#include <SDL_opengl.h>

Další dva hlavičkové soubory souvisí s mým základním kódem pro vytváření aplikací, kterým se snažím urychlit si práci při zakládání nových aplikací. Basecode.h obsahuje základní nastavení a cvector.h poskytuje rozhraní k ortogonálnímu 3D vektoru.

Naše kamera nebude podporovat naklápění (např. letadlo při zatáčení), takže pro přehlednost a zkrácení definujeme standardní UP vektor směřující ve směru osy y.

#include "basecode.h"
#include "cvector.h"

// Standard up vector
#define UP CVector<float>(0.0f, 1.0f, 0.0f)


namespace basecode
{

Co se týče členských proměnných třídy, tak m_pos definuje pozici kamery a m_dir směr jejího natočení, vždy se bude jednat o jednotkový vektor. Pozici v podstatě nepotřebujeme, bez větších problémů by mohla být externí, nicméně ji definujeme také, ať máme vše pohromadě.

Další dva atributy specifikují úhly pro polární souřadnice. Poloměr nepotřebujeme, protože u kamery je důležitý pouze směr a ne přesný bod, na který je zaměřena.

Speed proměnné použijeme pro specifikaci rychlosti pohybů a m_max_vert_angle ukládá maximální vertikální úhel natočení. Bez ní, popř. složitějšího určování znamínek (80 i 100 stupňů má stejný sin), by se po překročení 90 stupňů vracela kamera nelogicky nazpátek. U leteckých a hlavně vesmírných simulátorů, kde není přesné 'dole', by se toto muselo ještě vyřešit.

class CCamera
{
protected:
	CVector<float> m_pos;
	CVector<float> m_dir;// Relative to position
	float m_horz_angle;// All angles are in degrees
	float m_vert_angle;
	float m_speed_rot;
	float m_speed;
	float m_max_vert_angle;

public:
	CCamera(const CVector<float>& pos);
	~CCamera();

Následující metody slouží jako rozhraní k zapouzdřeným atributům třídy. Všimněte si především funkcí GetLeft() a GetRight(), které slouží pro získání vektoru vlevo resp. vpravo. Využívá se u nich tzv. vektorového součinu dvou vektorů, jehož výsledkem je vektor na ně kolmý. U kvádru s hranami A, B a C by se operací A x B získala hrana C a operací B x A opačně orientovaná C.

	const CVector<float>& GetPos() const { return m_pos; }
	const CVector<float>& GetDir() const { return m_dir; }
	const CVector<float> GetLeft() const { return UP.Cross(m_dir); }
	const CVector<float> GetRight() const { return m_dir.Cross(UP); }
	const CVector<float> GetUp() const { return UP; }

	float GetXPos() const { return m_pos.GetX(); }
	float GetYPos() const { return m_pos.GetY(); }
	float GetZPos() const { return m_pos.GetZ(); }

	float GetXDir() const { return m_dir.GetX(); }
	float GetYDir() const { return m_dir.GetY(); }
	float GetZDir() const { return m_dir.GetZ(); }

	void SetPos(const CVector<float>& pos) { m_pos = pos; }
	void SetXPos(float x) { m_pos.SetX(x); }
	void SetYPos(float y) { m_pos.SetY(y); }
	void SetZPos(float z) { m_pos.SetZ(z); }

	float GetHorizontalAngle() const { return m_horz_angle; }
	float GetVerticalAngle() const { return m_vert_angle; }

	void SetHorizontalAngle(float horz_angle);
	void SetVerticalAngle(float vert_angle);

Go*() funkce slouží pro změnu polohy kamery, volají se při stisku šipek na klávesnici. Fungují velice jednoduše - požadovaný směr vynásobený rychlostí a převrácenou hodnotou fps se přičte k aktuální pozici.

	void GoFront(float fps) { m_pos += (m_dir*m_speed / fps); }
	void GoBack(float fps) { m_pos -= (m_dir*m_speed / fps); }
	void GoLeft(float fps) { m_pos += (UP.Cross(m_dir)*m_speed / fps); }
	void GoRight(float fps) { m_pos += (m_dir.Cross(UP)*m_speed / fps); }

Metoda Rotate() mění úhly natočení kamery. Aby bylo rozhraní co nejjednodušší, předávají se jí relativní pohyby myši získané z okenního manažeru. LookAt() se bude používat při renderingu scény, v podstatě pouze volá nad m_pos, m_dir a UP vektorem standardní funkci gluLookAt().

	void Rotate(int xrel, int yrel, float fps);
	void LookAt() const;// gluLookAt()

Poslední dvě metody slouží pro zjištění, zda kamera opustila obdélník herního hřiště, respektive k jejímu přesunu na jeho nejbližší okraj.

	// When the area (playground etc.) has borders
	bool IsInQuad(int x_half, int z_half);
	void PosToQuad(int x_half, int z_half);
};

} // namespace

#endif

Implementace kamery - ccamera.cpp

No, v podstatě nám toho doprogramovat už moc nezbylo, většina metod je v hlavičce třídy. V konstruktoru nastavíme všechny atributy na výchozí hodnoty a destruktor necháme prázdný.

#include "ccamera.h"

namespace basecode
{

CCamera::CCamera(const CVector<float>& pos) :
		m_pos(pos),
		m_dir(0.0f, 0.0f, -1.0f),
		m_horz_angle(-90.0f),
		m_vert_angle(0.0f),
		m_speed_rot(2.0f),
		m_speed(10.0f),
		m_max_vert_angle(90.0f)
{

}

CCamera::~CCamera()
{

}

Následující dvě metody slouží k nastavení úhlů natočení kamery. Protože by směrový vektor přestal být validní, nestačí pouhé přiřazení do proměnných, ale m_dir se musí ještě aktualizovat. Rotate() je v podstatě to samé.

void CCamera::SetHorizontalAngle(float horz_angle)
{
	m_horz_angle = horz_angle;

	m_dir.SetX(cos(DEGTORAD(m_horz_angle)));
	m_dir.SetZ(sin(DEGTORAD(m_horz_angle)));
}

void CCamera::SetVerticalAngle(float vert_angle)
{
	if(m_vert_angle > -m_max_vert_angle
	&& m_vert_angle < m_max_vert_angle)
	{
		m_vert_angle = vert_angle;
		m_dir.SetY(-sin(DEGTORAD(m_vert_angle)));
	}
}

void CCamera::Rotate(int xrel, int yrel, float fps)
{
	m_horz_angle += xrel*m_speed_rot / fps;

	m_dir.SetX(cos(DEGTORAD(m_horz_angle)));
	m_dir.SetZ(sin(DEGTORAD(m_horz_angle)));

	if((m_vert_angle <  m_max_vert_angle && yrel > 0)
	|| (m_vert_angle > -m_max_vert_angle && yrel < 0))
	{
		m_vert_angle += yrel*m_speed_rot / fps;

		m_dir.SetY(-sin(DEGTORAD(m_vert_angle)));
	}
}

Funkce LookAt() obsahuje pouze volání standardního gluLookAt() z knihovny GLU, která nahrazuje OpenGL glTranslatef() a glRotatef() jedním, o něco snadněji použitelným, příkazem. V prvních třech parametrech se jí předává aktuální pozice kamery, v dalších třech směr a v posledních třech UP vektor.

void CCamera::LookAt() const
{
	gluLookAt(	m_pos.GetX(),
			m_pos.GetY(),
			m_pos.GetZ(),
			m_pos.GetX()+m_dir.GetX(),
			m_pos.GetY()+m_dir.GetY(),
			m_pos.GetZ()+m_dir.GetZ(),
			0.0, 1.0, 0.0);
}

Poslední dvě metody ošetřují stav, kdy se kamera ocitne mimo obdélníkovou plochu hracího hřiště. Tím jsme si prošli celý kód třídy kamery a teď se můžeme konečně vrhnout na samotnou aplikaci.

bool CCamera::IsInQuad(int x_half, int z_half)
{
	if(m_pos.GetX() < -x_half)
		return false;
	if(m_pos.GetX() > x_half)
		return false;

	if(m_pos.GetZ() < -z_half)
		return false;
	if(m_pos.GetZ() > z_half)
		return false;

	return true;
}

void CCamera::PosToQuad(int x_half, int z_half)
{
	if(m_pos.GetX() < -x_half)
		m_pos.SetX(-x_half);
	if(m_pos.GetX() > x_half)
		m_pos.SetX(x_half);

	if(m_pos.GetZ() < -z_half)
		m_pos.SetZ(-z_half);
	if(m_pos.GetZ() > z_half)
		m_pos.SetZ(z_half);
}

} // namespace

Třída aplikace - ccameraapp.h

Aby nezůstalo jen u třídy kamery, ukážeme si ještě její použití v aplikaci. Opět nejprve inkludujeme hlavičkové soubory. Díky vectoru budeme moci pracovat se šablonou dynamického pole ze standardní knihovny šablon jazyka C++ (STL). CApplication je rodičovská třída naší aplikace, CGrid poskytuje funkce pro vykreslování jednoduchý drátěných modelů, o kameře je tento článek a CVector už byl také popsán.

#ifndef __CCAMERAAPP_H__
#define __CCAMERAAPP_H__

#include <vector>
#include "basecode.h"
#include "capplication.h"
#include "cgrid.h"
#include "ccamera.h"
#include "cvector.h"

#define SIZE 64.0f

using namespace std;

namespace basecode
{

Co se týká metod třídy, ze jmen by mělo být jasné, co má která na starost. Ještě se k nim vrátíme u implementace.

class CCameraApp : public CApplication
{
public:
	CCameraApp(int argc, char *argv[]);
	virtual ~CCameraApp();
	virtual void Init(const string& win_title);

protected:
	virtual void InitGL();
	virtual void OnInit();
	virtual void Draw();
	virtual void Update();
	virtual bool ProcessEvent(SDL_Event& event);

Atribut m_cam je objekt kamery a další dvě proměnné budou udržovat pozice a směry střel vypouštěných stiskem tlačítka myši.

private:
	CCamera m_cam;
	vector<CVector<float> > m_bullets_pos;
	vector<CVector<float> > m_bullets_dir;
};

}

#endif

Implementace třídy aplikace - ccameraapp.cpp

V konstruktoru třídy zavoláme pomocí inicializátorů konstruktor předka a nastavíme výchozí pozici kamery. V těle metody rezervujeme paměť pro sto střel a destruktor zůstává prázdný.

#include "ccameraapp.h"

namespace basecode
{

CCameraApp::CCameraApp(int argc, char *argv[]) :
		CApplication(argc, argv),
		m_cam(CVector<float>(0.0f, 0.0f, 0.0f))
{
	m_bullets_pos.reserve(100);
	m_bullets_dir.reserve(100);
}

CCameraApp::~CCameraApp()
{

}

V metodě Init() skryjeme kurzor myši a v InitGL() nastavíme všechny požadované vlastnosti OpenGL.

void CCameraApp::Init(const string& win_title)
{
	CApplication::Init(win_title);
	SDL_ShowCursor(SDL_DISABLE);
}

void CCameraApp::InitGL()
{
	glClearColor(0.0, 0.0, 0.0, 0.0);
	glClearDepth(1.0);
	glDepthFunc(GL_LEQUAL);
	glEnable(GL_DEPTH_TEST);
	glShadeModel(GL_SMOOTH);
	glHint(GL_PERSPECTIVE_CORRECTION_HINT, GL_NICEST);

	glEnable(GL_LINE_SMOOTH);
	glLineWidth(3.0f);

	glEnable(GL_BLEND);
	glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
}

void CCameraApp::OnInit()
{

}

Ve funkci sloužící pro rendering scény hned po resetu matice zavoláme LookAt() kamery, která nastaví všechny potřebné translace a rotace. Poté vykreslíme pomocí třídy CGrid rovinu rovnoběžnou s osami x a z a následně pomocí stejné třídy souřadnicové osy. Tím máme vytvořen demonstrační 3D svět.

void CCameraApp::Draw()
{
	glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
	glLoadIdentity();

	m_cam.LookAt();

	glColor4ub(30, 200, 30, 255);
	CGrid::DrawPlaneXZ(SIZE, SIZE/4.0f, -0.2f);
	glColor4ub(255, 0, 0, 255);
	CGrid::DrawAxis(SIZE / 2.0f);

Dále vykreslíme všechny střely. Předem se omlouvám za jejich vzhled :-(, ale i když se jedná o nepřiklápějící se modré čtverečky, dostatečně demonstrují použití.

	glDisable(GL_BLEND);
	glBegin(GL_QUADS);
	glColor3ub(0, 0, 255);

	vector<CVector<float> >::iterator it;

	for(it = m_bullets_pos.begin(); it != m_bullets_pos.end(); it++)
	{
		glVertex3fv(*it + CVector<float>(-1,-1, 0));
		glVertex3fv(*it + CVector<float>( 1,-1, 0));
		glVertex3fv(*it + CVector<float>( 1, 1, 0));
		glVertex3fv(*it + CVector<float>(-1, 1, 0));
	}

	glEnd();
	glEnable(GL_BLEND);
}

V aktualizační funkci testujeme stisk šipek a kláves W, S, A a D, které umožňují chození po scéně. Y-ovou pozici kamery vždy nastavíme na pět jednotek nad povrchem, aby nemohla "uletět" z plochy. Na konci funkce přesuneme všechny střely na novou pozici.

void CCameraApp::Update()
{
	SDL_PumpEvents();

	Uint8* keys;
	keys = SDL_GetKeyState(NULL);

	if(keys[SDLK_UP] == SDL_PRESSED || keys[SDLK_w] == SDL_PRESSED)
		m_cam.GoFront(GetFPS());
	if(keys[SDLK_DOWN] == SDL_PRESSED || keys[SDLK_s] == SDL_PRESSED)
		m_cam.GoBack(GetFPS());
	if(keys[SDLK_LEFT] == SDL_PRESSED || keys[SDLK_a] == SDL_PRESSED)
		m_cam.GoLeft(GetFPS());
	if(keys[SDLK_RIGHT] == SDL_PRESSED || keys[SDLK_d] == SDL_PRESSED)
		m_cam.GoRight(GetFPS());

	m_cam.SetYPos(5.0f);


	vector<CVector<float> >::iterator it_pos;
	vector<CVector<float> >::iterator it_dir;

	for(it_pos = m_bullets_pos.begin(), it_dir = m_bullets_dir.begin();
	    it_pos != m_bullets_pos.end() || it_dir != m_bullets_dir.end();
	    it_pos++, it_dir++)
	{
		*it_pos += *it_dir / GetFPS();
	}
}

Došli jsme až ke zpracování událostí. Při pohybu myší by teoreticky mělo stačit zavolat funkci Rotate() kamery, ale bohužel to není tak jednoduché. Aby myš nemohla opustit okno, po každém posunutí přesuneme kurzor zpět doprostřed okna. Problémem je, že funkce SDL_WarpMouse() sama o sobě generuje událost SDL_MOUSEMOTION, takže by došlo k zacyklení (zpracování události generuje novou), ošetříme to podmínkou na začátku. Dále budeme ignorovat několik prvních událostí.

Také byste si mohli všimnou předávání relativních posunů myši funkci Rotate(). Toto je specialita SDL, kterou například Win32 API neposkytuje - předává pouze absolutní pozici v okně. Muselo by se to řešit pomocí dalších dvou proměnných obsahujících pozici z minula.

bool CCameraApp::ProcessEvent(SDL_Event& event)
{
	switch(event.type)
	{
	case SDL_MOUSEMOTION:
		// SDL_WarpMouse() generates SDL_MOUSEMOTION event :-(
		if(event.motion.x != GetWinWidth() >> 1
		|| event.motion.y != GetWinHeight() >> 1)
		{
			// First several messages MUST be ignored
			static int kriza = 0;
			if(kriza++ < 5)
				break;

			m_cam.Rotate(event.motion.xrel,
					event.motion.yrel, GetFPS());

			// Center mouse in window
			SDL_WarpMouse(GetWinWidth()>>1, GetWinHeight()>>1);
		}
		break;

Stisk libovolného tlačítka myši způsobí vypuštění střely, přidává se pouze jako nová položka na konec dynamického pole. Uvolnění se provede najednou až při ukončení aplikace, nic explicitního není pro jednoduchost implementováno.

	case SDL_MOUSEBUTTONDOWN:
		m_bullets_pos.push_back(CVector<float>(m_cam.GetPos()));
		m_bullets_dir.push_back(CVector<float>(m_cam.GetDir()*20.0f));
		break;


	default:// Other events
		return CApplication::ProcessEvent(event);
		break;
	}

	return true;
}

} // namespace

A to je z tohoto článku vše...

Zdrojové kódy

Michal Turek <WOQ (zavináč) seznam.cz>, 23.09.2005

Screenshot ukázkové aplikace