[XNA] Kamera FPP

Bardzo ważnym elementem gry jest kamera. Można ją sobie rzeczywiście wyobrazić jako kamerę przekazującą obraz z konkretnego miejsca sceny, pod odpowiednim kątem i wysyłającą obraz na ekran. Pod spodem siedzi jednak kilka macierzy. Każdy wierzchołek jest przez te macierze mnożony, a finalnym wynikiem jest pozycja (ważne, dwuwymiarowa) na ekranie.

Dziś krótko o podstawowej kamerze typu First Person Perspective. Oparta jest na pozycji oraz dwóch kątach, można spokojnie skojarzyć to sobie ze współrzędnymi sferycznymi (gdzie [latex]\phi[/latex] to kąt w płaszczyźnie  XY, a [latex]\theta[/latex] – kąt w płaszczyźnie XZ). Rysunek poglądowy poniżej.

Układ współrzędnych sferycznychUżywam prawoskrętnego układu współrzędnych, jednak w górę skierowana jest oś Z, a nie (jak zwykle) Y. Tak się przyzwyczaiłem i kod jest skonstruowany zgodnie z tym założeniem.

Trochę geometrii

Jak obliczyć kąty z pozycji kamery oraz położenia punktu, na który jest zwrócona? Nic trudnego, wystarczy zaaplikować nieco trygonometrii:

public Vector3 Target
{
    get
    {
        Vector3 v = Vector3.UnitY;
        v = Vector3.Transform(v, Matrix.CreateRotationX(theta));
        v = Vector3.Transform(v, Matrix.CreateRotationZ(phi - MathHelper.PiOver2));
        v += Position;
        return v;
    }

    set
    {
        // calculate angles
        Vector3 dir = Vector3.Normalize(value - Position);
        float p = (float)Math.Sqrt(dir.X * dir.X + dir.Y * dir.Y);
        theta = (float)Math.Atan2(dir.Z, p);
        phi = CalculatePhi(dir);
    }
}

Zajmijmy się kodem linia po linii: dir to nic innego jak wektor reprezentujący kierunek patrzenia kamery. Do zmiennej p przypisujemy długość wektora dir zrzutowanego na płaszczyznę XY. Kąt [latex]\theta[/latex] można więc obliczyć z prostej zależności trygonometrycznej – arcus tangens(Z / p). Funkcja biblioteczna Atan2 doskonale sobie z tym radzi.

CalculatePhi nie jest funkcją biblioteczną – to dłuższy kawałek kodu, który wymaga dodatkowego wyjaśnienia. Najpierw jednak (jak zwykle) – rysunek poglądowy:

KątKąt [latex]\phi[/latex] jest przechowywany jako wartość z przedziału [0; [latex]2\pi[/latex]]. Dlatego przy obliczaniu go należy zwrócić uwagę w którą ćwiartkę układu współrzędnych jest zwrócony wektor kamery i na tej podstawie obliczyć kąt. Komentarze w kodzie opisują kolejne możliwe przypadki.

private static float CalculatePhi(Vector3 dir)
{
    // special case #1: dir parallel to X-axis
    if (dir.Y == 0.0f)
        return dir.X >= 0.0f ? 0.0f : MathHelper.Pi;

    // special case #2: dir parallel to Y-axis
    if (dir.X == 0.0f)
        return dir.Y >= 0.0f ? MathHelper.PiOver2 : MathHelper.PiOver2 + MathHelper.Pi;

    if (dir.Y > 0.0f)
    {
        if (dir.X > 0.0f)
            // first quarter of coordinate system
            return (float)Math.Atan2(dir.Y, dir.X);

        // second quarter of coordinate system
        // using -dir.X instead of Math.Abs(dir.X)
        return (float)Math.Atan2(-dir.X, dir.Y) + MathHelper.PiOver2;
    }

    // dir.Y < 0.0f
    if (dir.X < 0.0f)
        // third quarter of coordinate system
        // using negation instead of Math.Abs()
        return MathHelper.Pi + (float)Math.Atan2(Math.Abs(dir.Y), Math.Abs(dir.X));

    // fourth quarter of coordinate system
    // using -dir.Y instead of Math.Abs(dir.Y)
    return MathHelper.PiOver2 + MathHelper.Pi + (float)Math.Atan2(dir.X, -dir.Y);
}

Teoria stojąca za kamerą

W procesie przetwarzania wierzchołków w grafice komputerowej ważne są trzy macierze: macierz świata, macierz widoku i macierz projekcji (rzutowania). Każdy wierzchołek ma swoją pozycję w trójwymiarowej przestrzeni. Są to tzw. współrzędne w układzie lokalnym. Po przemnożeniu ich przez macierz świata (kombinacja skalowania, obrotów i translacji) uzyskujemy nowe współrzędne wierzchołka – tym razem w ujednoliconym dla całej sceny układzie świata (globalnym). Teraz do gry wchodzi nasza kamera – macierz widoku jest budowana na podstawie trzech wektorów: położenie kamery, położenie punktu, na który patrzy kamera oraz wektora wskazującego do góry. W mojej klasie kamery położenie uzyskujemy bezpośrednio z pola klasy, natomiast punkt, na który patrzy kamera jest obliczany na bieżąco na podstawie kątów:

public Matrix View
{
    get { return Matrix.CreateLookAt(Position, Target, Vector3.UnitZ); }
}

public Vector3 Target
{
    get
    {
        Vector3 v = Vector3.UnitY;
        v = Vector3.Transform(v, Matrix.CreateRotationX(theta));
        v = Vector3.Transform(v, Matrix.CreateRotationZ(phi - MathHelper.PiOver2));
        v += Position;
        return v;
    }

    set { /*..*/ }
}

Dlaczego przy drugiej transformacji wektora obracamy go o kąt [latex]\phi – \pi/2[/latex] a nie [latex]\phi[/latex]? Spójrzmy jeszcze raz na rysunek:

KątPoczątkowy wektor (0, 1, 0) jest już teoretycznie obrócony o kąt [latex]\pi / 2[/latex], którą pole phi już dla niego przechowuje. Więc każdy obrót wokół osi Z (czyli prawo-lewo) zmniejszamy o [latex]\pi / 2[/latex] – wartość, o którą już początkowo „obróciliśmy” wektor początkowy.

Po takiej transformacji uzyskujemy współrzędne wierzchołków w tzw. układzie kamery. Ostatnia macierz – macierz rzutowania transformuje te współrzędne na ostateczne dwuwymiarowe położenie wierzchołka na ekranie. Macierz taką buduje się następująco:

Matrix projectionMatrix = Matrix.CreatePerspectiveFieldOfView(
                                                MathHelper.ToRadians(45),
                                                (float)GraphicsDevice.Viewport.Width / GraphicsDevice.Viewport.Height,
                                                1.0f,
                                                800.0f);

W dużym skrócie: wynikiem jest macierz rzutowania perspektywicznego (wszyscy kojarzą pojęcie perspektywy?) z FOV (Field Of View) równym 45 stopni, drugim argumentem metody jest tzw. aspect ratio, które jest zwykle równe stosunkowi szerokości i wysokości ekranu, na którym rysujemy. Dwa ostatnie argumenty to odległość bliskiej i dalekiej płaszczyzny obcinania. Wszystkie wierzchołki, które po trzech przekształceniach znajdą się PRZED bliższą lub ZA dalszą płaszczyzną obcinania nie zostaną wyświetlone.

Poruszanie kamerą

Do modyfikacji położenia kamery jest szereg metod, kod poniżej. Można sterować kamerą idąc po prostu „do przodu”, czyli w kierunku, w który patrzy. Można przesuwać się na boki (popularny strafe) lub jeździć w górę i w dół.

/// <summary>
/// Move camera forward its view direction
/// </summary>
/// <param name="m">Distance of movement, positive means moving forward,
/// negative - moving backward</param>
public void MoveForward(float m)
{
    Position += Vector3.Normalize(Target - Position) * m;
}

/// <summary>
/// Strafe camera - move sideways
/// </summary>
/// <param name="m">Distance of movement, positive means moving right,
/// negative - moving left</param>
public void MoveRight(float m)
{
    Position += Right * m;
}

/// <summary>
/// Move camera up along its up vector, eg. if camera is looking down,
/// it will move up and slightly forward
/// </summary>
/// <param name="m">Distance of movement, positive means moving up,
/// negative - moving down</param>
public void MoveUp(float m)
{
    Position += Up * m;
}

/// <summary>
/// Translate camera regadless of phi and theta
/// </summary>
public void Translate(Vector3 translation)
{
    Position += translation;
}

/// <summary>
/// Rotate around X axis. It is analogous to looking up and down.
/// Maximum rotation angles are PI/2 and -PI/2 (camera can look
/// straight up and straight down, but not any further)
/// </summary>
/// <param name="angle">Angle of rotation (in radians). Positive
/// angle means looking up, negative - looking down</param>
public void RotateX(float angle)
{
    theta += angle;

    // Clamp to (-PI/2, PI/2)
    if (theta > (MathHelper.PiOver2))
        theta = MathHelper.PiOver2 - 0.01f;
    if (theta < -MathHelper.PiOver2)
        theta = -MathHelper.PiOver2 + 0.01f;
}

/// <summary>
/// Rotate camera around Z axis. It is analogous to looking
/// left and right.
/// </summary>
/// <param name="angle">Angle of rotation (in radians). Positive
/// angle means turning left, negative - turning right.</param>
public void RotateZ(float angle)
{
    phi += angle;

    // Clamp angle to [0, 2*PI]
    if (phi >= MathHelper.TwoPi)
        phi -= MathHelper.TwoPi;
    if (phi < 0.0f)
        phi += MathHelper.TwoPi;
}

Bardzo ciekawym pod względem algebraicznym jest krótka metoda zwracająca wektor skierowany „w bok” – będący wektorem prostopadłym do kierunku patrzenia kamery oraz wektora skierowanego w górę:

private Vector3 Right
{
    get { return Vector3.Normalize(Vector3.Cross(Vector3.Normalize(Target - Position), Up)); }
}

Na bieżąco zostają tu obliczone pozycje punktu, na który patrzy kamera, następnie wyznaczony wektor kierunku patrzenia kamery. Operacja Cross oblicza iloczyn wektorowy kierunku patrzenia z wektorem skierowanym w górę – wynikiem jest właśnie wektor „w bok”, który (po uprzedniej normalizacji) jest zwracany jako wynik metody.

Ufff.. to już chyba wszystko na temat kamery. Niby mały temat trochę się rozrósł, ale zależało mi na wyjaśnieniu całej mechaniki stojącej za tym stworkiem. Kod kamery można obejrzeć w całości pod adresem:

http://subversion.assembla.com/svn/themefestival/ThemeFestival/Camera.cs

Be Sociable, Share!
czoper opublikowano dnia 2010-9-11 Kategoria: Daj się poznać, Programowanie, Theme Festival | Tagi:, ,

Odpowiedzi: 2 Zostaw komentarz

  1. #2Michniewicz @ 2010-9-13 17:33

    Ej… Gdzie są wcięcia?!

    Powinieneś zrobić takie ładne formatowanie kodu również w komentarzach 😛

  2. #1Michniewicz @ 2010-9-13 17:32

    Mały tip: do zrobienia kamery w ogóle nie potrzebujesz funkcji Matrix.CreateLookAt() ani wektorów typu up, eye, target. Nie musisz też używać żadnych funkcji trygonometrycznych (przynajmniej nie jawnie – są one liczone zawsze gdy tworzona jest macierz obrotu).
    To jest metoda Update() z klasy kamery, którą napisałem na potrzeby swojej gry:

    public void Update()
    {
        Matrix cameraRotation =
            Matrix.CreateRotationY(yaw) *
            Matrix.CreateRotationX(pitch);
    
       view = (timeToStopShaking.Enabled? shakeMatrix:Matrix.Identity) *
               Matrix.CreateTranslation(-Position) * cameraRotation;
    }
    

    (to w nawiasie to implementacja efektu wizualnego przy kasowaniu budynku z użyciem dynamitu ;))

    I funkcja poruszania kamerą:

    public void Move(float dx, float dy, float dz) 
    {
        Matrix rotation = Matrix.CreateRotationY(yaw); 
        Vector3 v = Vector3.Transform(new Vector3(dx, dy, dz),
                                      Matrix.Invert(rotation));
        Position += v;
    }
    

    Kamera porusza się zawsze w płaszczyźnie równoległej do XZ („założenie projektowe”), ale łatwo to zmienić domnażając w funkcji move dodatkową macierz rotacji.

    Jeszcze słowo komentarza na temat obiektu „timeToStopShaking” (nazwa cokolwiek niezbyt trafna, bo już nie pamiętam konwencji w jakiej nadawałem nazwy obiektom tej klasy…) – w grach często pewne zdarzenia występują okresowo – przyrost surowca, leczenie postaci itp… Warto więc zrobić sobie taką klasę, która co jakiś czas wykona jakiś kawałek kodu. U mnie jakieś 80% logiki gry wykorzystuje taką klasę i pewnie też niedługo stwierdzisz, że coś takiego Ci się przyda:

    public class Notificator:IUpdateable
    {
        private EventHandler wakeUpAction;
        private EventArgs arguments;
        TimeSpan elapsedTime;
        TimeSpan alarmTime;
        bool enabled;
    
        public Notificator(TimeSpan alarmTime, EventHandler wakeUpAction, EventArgs arguments)
        {
            elapsedTime = TimeSpan.Zero;
            this.alarmTime = alarmTime;
            this.wakeUpAction += wakeUpAction;
            this.arguments = arguments;
            enabled = false;
        }
        #region IUpdateable Members
    
        public bool Enabled
        {
            get { return enabled; }
            set { enabled = value; }
        }
    
        public event EventHandler EnabledChanged;
    
        public void Update(GameTime gameTime)
        {
            if (!enabled) return;
            if (elapsedTime &gt;= alarmTime)
            {
                if (wakeUpAction != null)
                {
                    wakeUpAction(this, arguments);
                    elapsedTime = TimeSpan.Zero;
                }
            }
            elapsedTime += gameTime.ElapsedGameTime;
        }
    
        public int UpdateOrder
        {
            get { throw new NotImplementedException(); }
        }
    
        public event EventHandler UpdateOrderChanged;
    
        #endregion
    }
    

    W konstruktorze podajesz dwa argumenty: funkcję i czas, co jaki ma być wykonywana. I tak jeśli masz np. klasę obsługującą logikę fabryki to tworzysz dla niej tego typu obiekt i podajesz delegację do funkcji, która zwiększa ilość węgla.

    Dla przypomnienia – link do screencastu z mojej gry 🙂 – http://www.youtube.com/watch?v=wYJ5dYc4V6g

Zostaw odpowiedź

(Ctrl + Enter)