Są dwie rzeczy niezbędne do uzyskania płynnego ruchu, po pierwsze, oczywiste jest, że to, co renderujesz, musi odpowiadać oczekiwanemu stanowi w momencie, w którym ramka jest prezentowana użytkownikowi, po drugie, musisz przedstawić klatki użytkownikowi w stosunkowo ustalonym odstępie czasu. Prezentowanie klatki w T + 10ms, potem kolejna w T + 30ms, a następnie w T + 40ms, wydaje się użytkownikowi, że ocenia, nawet jeśli to, co faktycznie pokazano dla tych czasów, jest zgodne z symulacją.
Wydaje się, że w głównej pętli brakuje mechanizmu bramkowania, aby zapewnić, że renderujesz tylko w regularnych odstępach czasu. Czasami możesz zrobić 3 aktualizacje między renderami, czasem możesz zrobić 4. Zasadniczo twoja pętla będzie renderować tak często, jak to możliwe, gdy tylko zasymulujesz wystarczająco dużo czasu, aby przesunąć stan symulacji przed bieżącym czasem, będziesz następnie render ten stan. Ale każda zmienność czasu potrzebnego do aktualizacji lub renderowania oraz odstępy między ramkami również będą się różnić. Masz ustalony czas symulacji, ale zmienny czas renderowania.
Prawdopodobnie potrzebujesz poczekać tuż przed renderowaniem, co gwarantuje, że rendering zaczniesz tylko na początku interwału renderowania. Idealnie byłoby, gdyby był adaptacyjny: jeśli zbyt długo trwało aktualizowanie / renderowanie, a początek interwału już minął, należy renderować natychmiast, ale także zwiększać długość interwału, dopóki nie będzie można konsekwentnie renderować i aktualizować, a następnie przejść do następne renderowanie przed zakończeniem interwału. Jeśli masz dużo czasu, możesz powoli zmniejszyć interwał (tj. Zwiększyć liczbę klatek na sekundę), aby ponownie renderować szybciej.
Ale tutaj jest kicker, jeśli nie wyrenderujesz ramki natychmiast po wykryciu, że stan symulacji został zaktualizowany do „teraz”, to wprowadzisz tymczasowe aliasing. Ramka prezentowana użytkownikowi jest prezentowana w nieco niewłaściwym czasie, a to samo w sobie będzie się jąkać.
Jest to powód „częściowego pomiaru czasu”, o którym wspominasz w artykułach, które przeczytałeś. Jest tam z dobrego powodu, a to dlatego, że dopóki nie ustawisz timepsu fizyki na jakąś stałą całkowitą wielokrotność twojego timeptu renderowania, po prostu nie możesz przedstawić ramek we właściwym czasie. W końcu albo prezentujesz je za wcześnie, albo za późno. Jedynym sposobem na uzyskanie stałej szybkości renderowania i nadal prezentowanie czegoś, co jest fizycznie poprawne, jest zaakceptowanie tego, że w momencie, gdy nadejdzie interwał renderowania, najprawdopodobniej będziesz w połowie drogi między dwoma ustalonymi czasami fizyki. Ale to nie znaczy, że obiekty są modyfikowane podczas renderowania, wystarczy, że rendering musi tymczasowo ustalić, gdzie znajdują się obiekty, aby mógł je renderować gdzieś pomiędzy tym, gdzie były przed i gdzie są po aktualizacji. To ważne - nigdy nie zmieniaj stanu świata do renderowania, tylko aktualizacje powinny zmieniać stan świata.
Aby umieścić go w pętli pseudokodu, myślę, że potrzebujesz czegoś więcej:
InitialiseWorldState();
previousTime = currentTime = 0.0;
renderInterval = 1.0 / 60.0; //A nice high starting interval
subFrameProportion = 1.0; //100% currentFrame, 0% previousFrame
while (true)
{
frameStart = ActualTime();
//Render the world state as if it was some proportion
// between previousTime and currentTime
// E.g. if subFrameProportion is 0.5, previousTime is 0.1 and
// currentTime is 0.2, then we actually want to render the state
// as it would be at time 0.15. We'd do that by interpolating
// between movingObject.previousPosition and movingObject.currentPosition
// with a lerp parameter of 0.5
Render(subFrameProportion);
//Check we've not taken too long and missed our render interval
frameTime = ActualTime() - frameStart;
if (frameTime > renderInterval)
{
renderInterval = frameTime * 1.2f; //Give us a more reasonable render interval that we actually have a chance of hitting
}
expectedFrameEnd = frameStart + renderInterval;
//Loop until it's time to render the next frame
while (ActualTime() < expectedFrameEnd)
{
//step the simulation forward until it has moved just beyond the frame end
if (previousTime < expectedFrameEnd) &&
currentTime >= expectedFrameEnd)
{
previousTime = currentTime;
Update();
currentTime += fixedTimeStep;
//After the update, all objects will be in the position they should be for
// currentTime, **but** they also need to remember where they were before,
// so that the rendering can draw them somewhere between previousTime and
// currentTime
//Check again we've not taken too long and missed our render interval
frameTime = ActualTime() - frameStart;
if (frameTime > renderInterval)
{
renderInterval = frameTime * 1.2f; //Give us a more reasonable render interval that we actually have a chance of hitting
expectedFrameEnd = frameStart + renderInterval
}
}
else
{
//We've brought the simulation to just after the next time
// we expect to render, so we just want to wait.
// Ideally sleep or spin in a tight loop while waiting.
timeTillFrameEnd = expectedFrameEnd - ActualTime();
sleep(timeTillFrameEnd);
}
}
//How far between update timesteps (i.e. previousTime and currentTime)
// will we be at the end of the frame when we start the next render?
subFrameProportion = (expectedFrameEnd - previousTime) / (currentTime - previousTime);
}
Aby to zadziałało, wszystkie aktualizowane obiekty muszą zachować wiedzę o tym, gdzie były wcześniej i gdzie są teraz, aby rendering mógł wykorzystać swoją wiedzę o tym, gdzie jest obiekt.
class MovingObject
{
Vector velocity;
Vector previousPosition;
Vector currentPosition;
Initialise(startPosition, startVelocity)
{
currentPosition = startPosition; // position at time 0
velocity = startVelocity;
//ignore previousPosition because we should never render before time 0
}
Update()
{
previousPosition = currentPosition;
currentPosition += velocity * fixedTimeStep;
}
Render(subFrameProportion)
{
Vector actualPosition =
Lerp(previousPosition, currentPosition, subFrameProportion);
RenderAt(actualPosition);
}
}
Nakreślmy oś czasu w milisekundach, mówiąc, że renderowanie zajmuje 3 ms, aktualizacja zajmuje 1 ms, krok aktualizacji jest ustalony na 5 ms, a czas renderowania rozpoczyna się (i pozostaje) na 16 ms [60 Hz].
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
R0 U5 U10 U15 U20 W16 R16 U25 U30 U35 W32 R32
- Najpierw inicjalizujemy w czasie 0 (więc currentTime = 0)
- Renderujemy z proporcją 1,0 (100% currentTime), która narysuje świat w czasie 0
- Kiedy to się skończy, rzeczywisty czas to 3 i nie spodziewamy się, że ramka skończy się do 16, więc musimy uruchomić kilka aktualizacji
- T + 3: Aktualizujemy od 0 do 5 (więc później currentTime = 5, previousTime = 0)
- T + 4: wciąż przed końcem ramki, więc aktualizujemy z 5 do 10
- T + 5: jeszcze przed końcem ramki, więc aktualizujemy z 10 do 15
- T + 6: wciąż przed końcem ramki, więc aktualizujemy z 15 do 20
- T + 7: wciąż przed końcem ramki, ale currentTime jest tuż poza końcem ramki. Nie chcemy dalej symulować, ponieważ spowodowałoby to przekroczenie terminu renderowania. Zamiast tego cicho czekamy na następny interwał renderowania (16)
- T + 16: Czas ponownie renderować. previousTime to 15, currentTime to 20. Więc jeśli chcemy renderować w T + 16, to jesteśmy 1ms drogi przez 5ms długi czas. Czyli jesteśmy 20% drogi przez ramkę (proporcja = 0,2). Podczas renderowania rysujemy obiekty w odległości 20% między ich poprzednią pozycją a obecną pozycją.
- Powróć do 3. i kontynuuj w nieskończoność.
Jest jeszcze jeden niuans dotyczący zbytniej symulacji z wyprzedzeniem, co oznacza, że dane wejściowe użytkownika mogą zostać zignorowane, nawet jeśli zdarzyły się przed faktycznym renderowaniem ramki, ale nie przejmuj się tym, dopóki nie upewnisz się, że pętla symuluje płynnie.