Ci sono due cose cruciali per rendere il movimento più fluido, il primo è ovviamente quello che il rendering deve corrispondere allo stato previsto nel momento in cui il frame viene presentato all'utente, il secondo è che devi presentare i frame all'utente ad un intervallo relativamente fisso. Presentare un frame a T + 10ms, poi un altro a T + 30ms, quindi un altro a T + 40ms, sembrerà all'utente di giudicare, anche se ciò che è effettivamente mostrato per quei tempi è corretto secondo la simulazione.
Il tuo loop principale sembra non avere alcun meccanismo di gating per assicurarti di eseguire il rendering solo a intervalli regolari. Quindi a volte potresti fare 3 aggiornamenti tra i render, a volte potresti fare 4. Fondamentalmente il tuo loop verrà eseguito il rendering il più spesso possibile, non appena avrai simulato abbastanza tempo per spingere lo stato di simulazione davanti all'ora corrente, quindi rendere quello stato. Ma qualsiasi variabilità nel tempo necessario per l'aggiornamento o il rendering e anche l'intervallo tra i frame varierà. Hai un timestep fisso per la tua simulazione, ma un timestep variabile per il tuo rendering.
Ciò di cui probabilmente hai bisogno è un'attesa appena prima del rendering, che ti assicuri di iniziare il rendering solo all'inizio di un intervallo di rendering. Idealmente dovrebbe essere adattivo: se hai impiegato troppo tempo per aggiornare / renderizzare e l'inizio dell'intervallo è già passato, dovresti renderizzarlo immediatamente, ma anche aumentare la lunghezza dell'intervallo, fino a quando puoi renderizzare e aggiornare costantemente e ancora arrivare a il rendering successivo prima che l'intervallo sia terminato. Se hai un sacco di tempo da perdere, puoi ridurre lentamente l'intervallo (ovvero aumentare la frequenza dei fotogrammi) per eseguire nuovamente il rendering più velocemente.
Ma, ed ecco il kicker, se non si esegue il rendering del frame immediatamente dopo aver rilevato che lo stato della simulazione è stato aggiornato a "now", si introduce l'alias temporale. Il frame che viene presentato all'utente viene presentato al momento leggermente sbagliato, e questo di per sé sembrerà una balbuzie.
Questo è il motivo del "timestep parziale" che vedrai menzionato negli articoli che hai letto. È lì per una buona ragione, e questo perché, a meno che non fissi il tuo timestep fisico a un multiplo integrale fisso del tuo timestep di rendering fisso, semplicemente non puoi presentare i frame al momento giusto. Finisci per presentarli troppo presto o troppo tardi. L'unico modo per ottenere una frequenza di rendering fissa e presentare ancora qualcosa che è fisicamente corretto, è accettare che nel momento in cui si verifica l'intervallo di rendering, molto probabilmente sarai a metà strada tra due dei tuoi timestep fisici fissi. Ciò non significa che gli oggetti vengano modificati durante il rendering, solo che il rendering deve stabilire temporaneamente dove si trovano gli oggetti in modo da renderli da qualche parte tra dove erano prima e dove sono dopo l'aggiornamento. Questo è importante: non cambiare mai lo stato mondiale per il rendering, solo gli aggiornamenti dovrebbero cambiare lo stato mondiale.
Quindi, per inserirlo in un ciclo pseudocodice, penso che tu abbia bisogno di qualcosa di più simile a:
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);
}
Affinché ciò funzioni, tutti gli oggetti in fase di aggiornamento devono preservare la conoscenza di dove si trovavano prima e dove si trovano ora, in modo che il rendering possa utilizzare la sua conoscenza di dove si trova l'oggetto.
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);
}
}
E definiamo una linea temporale in millisecondi, dicendo che il rendering richiede 3ms per essere completato, l'aggiornamento richiede 1ms, il tuo time-step di aggiornamento è fissato a 5ms e il tuo timestep di rendering inizia (e rimane) a 16ms [60Hz].
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
- Prima inizializziamo al tempo 0 (quindi currentTime = 0)
- Facciamo il rendering con una proporzione di 1.0 (100% currentTime), che disegnerà il mondo al tempo 0
- Al termine, il tempo effettivo è 3 e non prevediamo che il frame termini fino alle 16, quindi è necessario eseguire alcuni aggiornamenti
- T + 3: aggiorniamo da 0 a 5 (quindi in seguito currentTime = 5, previousTime = 0)
- T + 4: ancora prima della fine del frame, quindi aggiorniamo da 5 a 10
- T + 5: ancora prima della fine del frame, quindi aggiorniamo da 10 a 15
- T + 6: ancora prima della fine del frame, quindi aggiorniamo da 15 a 20
- T + 7: ancora prima della fine del frame, ma currentTime è appena oltre la fine del frame. Non vogliamo simulare ulteriormente perché farlo ci spingerebbe oltre il tempo che vorremmo renderizzare. Invece aspettiamo piano il prossimo intervallo di rendering (16)
- T + 16: è tempo di eseguire nuovamente il rendering. previousTime è 15, currentTime è 20. Quindi, se vogliamo renderizzare a T + 16, siamo 1ms di distanza attraverso il timestep lungo 5ms. Quindi siamo al 20% del modo attraverso il frame (proporzione = 0,2). Quando eseguiamo il rendering, disegniamo il 20% degli oggetti tra la loro posizione precedente e la loro posizione corrente.
- Torna al 3. e continua indefinitamente.
C'è un'altra sfumatura qui sulla simulazione troppo in anticipo, il che significa che gli input dell'utente potrebbero essere ignorati anche se sono avvenuti prima che il frame fosse effettivamente riprodotto, ma non preoccuparti finché non sei sicuro che il loop stia simulando senza problemi.