J'essaie d'imiter l'accélération et la décélération dans Unity.
J'ai écrit en code pour générer une piste dans Unity et placer un objet à un emplacement spécifique sur la piste en fonction du temps. Le résultat ressemble un peu à ça.
Le problème que j'ai actuellement est que chaque section de la spline a une longueur différente et que le cube se déplace dans chaque section à une vitesse différente, mais uniforme. Cela provoque des sauts soudains dans le changement de la vitesse du cube lors de la transition entre les sections.
Pour essayer de résoudre ce problème, j'ai tenté d'utiliser l'équation d'assouplissement de Robert Penner sur la méthode GetTime(Vector3 p0, Vector3 p1, float alpha)
. Cependant, bien que cela ait quelque peu aidé, ce n'était pas suffisant. Il y avait encore des sauts de vitesse entre les transitions.
Quelqu'un a-t-il une idée de la manière dont je pourrais dynamiser de manière dynamique la position du cube pour lui donner l'impression d'accélérer et de décélérer, sans grands sauts de vitesse entre les segments de la piste?
J'ai écrit un script qui montre une implémentation simple de mon code. Il peut être attaché à n’importe quel objet du jeu. Pour voir facilement ce qui se passe lorsque le code est exécuté, attachez-le à quelque chose comme un cube ou une sphère.
using System.Collections.Generic;
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif
public class InterpolationExample : MonoBehaviour {
[Header("Time")]
[SerializeField]
private float currentTime;
private float lastTime = 0;
[SerializeField]
private float timeModifier = 1;
[SerializeField]
private bool running = true;
private bool runningBuffer = true;
[Header("Track Settings")]
[SerializeField]
[Range(0, 1)]
private float catmullRomAlpha = 0.5f;
[SerializeField]
private List<SimpleWayPoint> wayPoints = new List<SimpleWayPoint>
{
new SimpleWayPoint() {pos = new Vector3(-4.07f, 0, 6.5f), time = 0},
new SimpleWayPoint() {pos = new Vector3(-2.13f, 3.18f, 6.39f), time = 1},
new SimpleWayPoint() {pos = new Vector3(-1.14f, 0, 4.55f), time = 6},
new SimpleWayPoint() {pos = new Vector3(0.07f, -1.45f, 6.5f), time = 7},
new SimpleWayPoint() {pos = new Vector3(1.55f, 0, 3.86f), time = 7.2f},
new SimpleWayPoint() {pos = new Vector3(4.94f, 2.03f, 6.5f), time = 10}
};
[Header("Debug")]
[Header("WayPoints")]
[SerializeField]
private bool debugWayPoints = true;
[SerializeField]
private WayPointDebugType debugWayPointType = WayPointDebugType.SOLID;
[SerializeField]
private float debugWayPointSize = 0.2f;
[SerializeField]
private Color debugWayPointColour = Color.green;
[Header("Track")]
[SerializeField]
private bool debugTrack = true;
[SerializeField]
[Range(0, 1)]
private float debugTrackResolution = 0.04f;
[SerializeField]
private Color debugTrackColour = Color.red;
[System.Serializable]
private class SimpleWayPoint
{
public Vector3 pos;
public float time;
}
[System.Serializable]
private enum WayPointDebugType
{
SOLID,
WIRE
}
private void Start()
{
wayPoints.Sort((x, y) => x.time.CompareTo(y.time));
wayPoints.Insert(0, wayPoints[0]);
wayPoints.Add(wayPoints[wayPoints.Count - 1]);
}
private void LateUpdate()
{
//This means that if currentTime is paused, then resumed, there is not a big jump in time
if(runningBuffer != running)
{
runningBuffer = running;
lastTime = Time.time;
}
if(running)
{
currentTime += (Time.time - lastTime) * timeModifier;
lastTime = Time.time;
if(currentTime > wayPoints[wayPoints.Count - 1].time)
{
currentTime = 0;
}
}
transform.position = GetPosition(currentTime);
}
#region Catmull-Rom Math
public Vector3 GetPosition(float time)
{
//Check if before first waypoint
if(time <= wayPoints[0].time)
{
return wayPoints[0].pos;
}
//Check if after last waypoint
else if(time >= wayPoints[wayPoints.Count - 1].time)
{
return wayPoints[wayPoints.Count - 1].pos;
}
//Check time boundaries - Find the nearest WayPoint your object has passed
float minTime = -1;
float maxTime = -1;
int minIndex = -1;
for(int i = 1; i < wayPoints.Count; i++)
{
if(time > wayPoints[i - 1].time && time <= wayPoints[i].time)
{
maxTime = wayPoints[i].time;
int index = i - 1;
minTime = wayPoints[index].time;
minIndex = index;
}
}
float timeDiff = maxTime - minTime;
float percentageThroughSegment = 1 - ((maxTime - time) / timeDiff);
//Define the 4 points required to make a Catmull-Rom spline
Vector3 p0 = wayPoints[ClampListPos(minIndex - 1)].pos;
Vector3 p1 = wayPoints[minIndex].pos;
Vector3 p2 = wayPoints[ClampListPos(minIndex + 1)].pos;
Vector3 p3 = wayPoints[ClampListPos(minIndex + 2)].pos;
return GetCatmullRomPosition(percentageThroughSegment, p0, p1, p2, p3, catmullRomAlpha);
}
//Prevent Index Out of Array Bounds
private int ClampListPos(int pos)
{
if(pos < 0)
{
pos = wayPoints.Count - 1;
}
if(pos > wayPoints.Count)
{
pos = 1;
}
else if(pos > wayPoints.Count - 1)
{
pos = 0;
}
return pos;
}
//Math behind the Catmull-Rom curve. See here for a good explanation of how it works. https://stackoverflow.com/a/23980479/4601149
private Vector3 GetCatmullRomPosition(float t, Vector3 p0, Vector3 p1, Vector3 p2, Vector3 p3, float alpha)
{
float dt0 = GetTime(p0, p1, alpha);
float dt1 = GetTime(p1, p2, alpha);
float dt2 = GetTime(p2, p3, alpha);
Vector3 t1 = ((p1 - p0) / dt0) - ((p2 - p0) / (dt0 + dt1)) + ((p2 - p1) / dt1);
Vector3 t2 = ((p2 - p1) / dt1) - ((p3 - p1) / (dt1 + dt2)) + ((p3 - p2) / dt2);
t1 *= dt1;
t2 *= dt1;
Vector3 c0 = p1;
Vector3 c1 = t1;
Vector3 c2 = (3 * p2) - (3 * p1) - (2 * t1) - t2;
Vector3 c3 = (2 * p1) - (2 * p2) + t1 + t2;
Vector3 pos = CalculatePosition(t, c0, c1, c2, c3);
return pos;
}
private float GetTime(Vector3 p0, Vector3 p1, float alpha)
{
if(p0 == p1)
return 1;
return Mathf.Pow((p1 - p0).sqrMagnitude, 0.5f * alpha);
}
private Vector3 CalculatePosition(float t, Vector3 c0, Vector3 c1, Vector3 c2, Vector3 c3)
{
float t2 = t * t;
float t3 = t2 * t;
return c0 + c1 * t + c2 * t2 + c3 * t3;
}
//Utility method for drawing the track
private void DisplayCatmullRomSpline(int pos, float resolution)
{
Vector3 p0 = wayPoints[ClampListPos(pos - 1)].pos;
Vector3 p1 = wayPoints[pos].pos;
Vector3 p2 = wayPoints[ClampListPos(pos + 1)].pos;
Vector3 p3 = wayPoints[ClampListPos(pos + 2)].pos;
Vector3 lastPos = p1;
int maxLoopCount = Mathf.FloorToInt(1f / resolution);
for(int i = 1; i <= maxLoopCount; i++)
{
float t = i * resolution;
Vector3 newPos = GetCatmullRomPosition(t, p0, p1, p2, p3, catmullRomAlpha);
Gizmos.DrawLine(lastPos, newPos);
lastPos = newPos;
}
}
#endregion
private void OnDrawGizmos()
{
#if UNITY_EDITOR
if(EditorApplication.isPlaying)
{
if(debugWayPoints)
{
Gizmos.color = debugWayPointColour;
foreach(SimpleWayPoint s in wayPoints)
{
if(debugWayPointType == WayPointDebugType.SOLID)
{
Gizmos.DrawSphere(s.pos, debugWayPointSize);
}
else if(debugWayPointType == WayPointDebugType.WIRE)
{
Gizmos.DrawWireSphere(s.pos, debugWayPointSize);
}
}
}
if(debugTrack)
{
Gizmos.color = debugTrackColour;
if(wayPoints.Count >= 2)
{
for(int i = 0; i < wayPoints.Count; i++)
{
if(i == 0 || i == wayPoints.Count - 2 || i == wayPoints.Count - 1)
{
continue;
}
DisplayCatmullRomSpline(i, debugTrackResolution);
}
}
}
}
#endif
}
}
Ok, mettons un peu de math à ce sujet.
J'ai toujours été un défenseur de l'importance et de l'utilité des mathématiques dans gamedev, et peut-être que j'y vais trop loin dans cette réponse, mais je pense vraiment que votre question ne concerne pas du tout la programmation, mais la modélisation et la résolution d'un problème d'algèbre. . Quoi qu'il en soit, allons-y.
Si vous avez un diplôme universitaire, vous vous souviendrez peut-être de quelque chose à propos de fonctions - des opérations qui prennent un paramètre et donnent un résultat - et des graphes - une représentation graphique (ou graphique) de l'évolution de une fonction par rapport à son paramètre. f(x)
peut vous rappeler quelque chose: il indique qu'une fonction nommée f
dépend du prameter x
. Ainsi, "pour paramétrer " signifie à peu près exprimer un système en termes d'un ou de plusieurs paramètres.
Vous n'êtes peut-être pas familiarisé avec les termes, mais vous le faites tout le temps. Votre Track
, par exemple, est un système avec 3 paramètres: f(x,y,z)
.
Une chose intéressante à propos du paramétrage est que vous pouvez saisir un système et le décrire en termes d’autres paramètres. Encore une fois, vous le faites déjà. Lorsque vous décrivez l'évolution de votre tracé avec le temps, vous dites que chaque coordonnée est fonction du temps, f(x,y,z) = f(x(t),y(t),z(t)) = f(t)
. En d'autres termes, vous pouvez utiliser le temps pour calculer chaque coordonnée et utiliser les coordonnées pour positionner votre objet dans l'espace pour cette heure donnée.
Enfin, je vais commencer à répondre à votre question. Pour décrire complètement le système de suivi souhaité, vous aurez besoin de deux choses:
Vous avez pratiquement résolu cette partie déjà. Vous définissez des points dans l'espace Scène et utilisez une spline Catmull – Rom pour interpoler les points et générer un chemin. C'est intelligent, et il ne reste plus grand chose à faire.
De plus, vous avez ajouté un champ time
sur chaque point afin de vous assurer que l'objet en mouvement passera par cette vérification à cette heure précise. Je serai de retour plus tard.
Une chose intéressante à propos de votre solution Path est que vous avez paramétré le calcul du chemin avec un paramètre percentageThroughSegment
- une valeur comprise entre 0 et 1 représentant la position relative à l’intérieur du segment. Dans votre code, vous effectuez une itération à intervalles de temps fixes et votre percentageThroughSegment
sera la proportion entre le temps passé et la durée totale du segment. Comme chaque segment a une durée spécifique, vous émulez de nombreuses vitesses constantes.
C'est assez standard, mais il y a une subtilité. Vous ignorez une partie extrêmement importante de la description d’un mouvement: la distance parcourue .
Je vous suggère une approche différente. Utilisez la distance parcourue pour paramétrer votre parcours. Ensuite, le déplacement de l'objet sera la distance parcourue paramétrée en fonction du temps. De cette façon, vous aurez deux systèmes indépendants et cohérents. Les mains au travail!
À partir de maintenant, je ferai tout en 2D par souci de simplicité, mais le changer en 3D plus tard sera trivial.
Considérez le chemin suivant:
Où i
est l'index du segment, d
est la distance parcourue et x, y
sont les coordonnées dans le plan. Cela peut être un chemin créé par une spline comme la vôtre, ou avec des courbes de Bézier ou autre.
Le mouvement développé par un objet avec votre solution actuelle peut être décrit comme un graphique de distance traveled on the path
vs time
comme ceci:
Où t
dans la table est l'heure à laquelle l'objet doit atteindre la vérification, d
est à nouveau la distance parcourue jusqu'à cette position, v
est la vitesse et a
est l'accélération.
La partie supérieure montre comment l'objet avance avec le temps. L'axe horizontal est le temps et la verticale est la distance parcourue. Nous pouvons imaginer que l'axe vertical est le chemin "déroulé" dans une ligne plate. Le graphique inférieur représente l'évolution de la vitesse dans le temps.
Il faut rappeler quelques éléments physiques à ce stade et noter que, à chaque segment, le graphique de la distance est une ligne droite, qui correspond à un mouvement à vitesse constante, sans accélération. Un tel système est décrit par cette équation: d = do + v*t
Chaque fois que l'objet atteint les points de contrôle, sa valeur de vitesse change soudainement (car il n'y a pas de continuité dans son graphique) et cela produit un effet étrange dans la scène. Oui, vous le savez déjà et c'est précisément pourquoi vous avez posté la question.
Ok, comment pouvons-nous améliorer cela? Hmm ... si le graphique de vitesse était continu, le saut de vitesse ne serait pas ennuyeux. La description la plus simple d'un mouvement comme celui-ci pourrait être uniformément accélérée. Un tel système est décrit par cette équation: d = do + vo*t + a*t^2/2
. Nous devrons aussi assumer une vitesse initiale, je choisirai zéro ici (se séparer du repos).
Comme on pouvait s'y attendre, le graphe de vitesse est continu, le mouvement est accéléré par le chemin. Cela pourrait être codé dans Unity en changeant les méthodes de traitement Start
et GetPosition
comme ceci:
private List<float> lengths = new List<float>();
private List<float> speeds = new List<float>();
private List<float> accels = new List<float>();
public float spdInit = 0;
private void Start()
{
wayPoints.Sort((x, y) => x.time.CompareTo(y.time));
wayPoints.Insert(0, wayPoints[0]);
wayPoints.Add(wayPoints[wayPoints.Count - 1]);
for (int seg = 1; seg < wayPoints.Count - 2; seg++)
{
Vector3 p0 = wayPoints[seg - 1].pos;
Vector3 p1 = wayPoints[seg].pos;
Vector3 p2 = wayPoints[seg + 1].pos;
Vector3 p3 = wayPoints[seg + 2].pos;
float len = 0.0f;
Vector3 prevPos = GetCatmullRomPosition(0.0f, p0, p1, p2, p3, catmullRomAlpha);
for (int i = 1; i <= Mathf.FloorToInt(1f / debugTrackResolution); i++)
{
Vector3 pos = GetCatmullRomPosition(i * debugTrackResolution, p0, p1, p2, p3, catmullRomAlpha);
len += Vector3.Distance(pos, prevPos);
prevPos = pos;
}
float spd0 = seg == 1 ? spdInit : speeds[seg - 2];
float lapse = wayPoints[seg + 1].time - wayPoints[seg].time;
float acc = (len - spd0 * lapse) * 2 / lapse / lapse;
float speed = spd0 + acc * lapse;
lengths.Add(len);
speeds.Add(speed);
accels.Add(acc);
}
}
public Vector3 GetPosition(float time)
{
//Check if before first waypoint
if (time <= wayPoints[0].time)
{
return wayPoints[0].pos;
}
//Check if after last waypoint
else if (time >= wayPoints[wayPoints.Count - 1].time)
{
return wayPoints[wayPoints.Count - 1].pos;
}
//Check time boundaries - Find the nearest WayPoint your object has passed
float minTime = -1;
// float maxTime = -1;
int minIndex = -1;
for (int i = 1; i < wayPoints.Count; i++)
{
if (time > wayPoints[i - 1].time && time <= wayPoints[i].time)
{
// maxTime = wayPoints[i].time;
int index = i - 1;
minTime = wayPoints[index].time;
minIndex = index;
}
}
float spd0 = minIndex == 1 ? spdInit : speeds[minIndex - 2];
float len = lengths[minIndex - 1];
float acc = accels[minIndex - 1];
float t = time - minTime;
float posThroughSegment = spd0 * t + acc * t * t / 2;
float percentageThroughSegment = posThroughSegment / len;
//Define the 4 points required to make a Catmull-Rom spline
Vector3 p0 = wayPoints[ClampListPos(minIndex - 1)].pos;
Vector3 p1 = wayPoints[minIndex].pos;
Vector3 p2 = wayPoints[ClampListPos(minIndex + 1)].pos;
Vector3 p3 = wayPoints[ClampListPos(minIndex + 2)].pos;
return GetCatmullRomPosition(percentageThroughSegment, p0, p1, p2, p3, catmullRomAlpha);
}
Ok, voyons comment ça se passe ...
Euh ... uh-oh. Ça paraissait presque bon, sauf qu’à un moment donné, il recule et avance à nouveau. En fait, si nous vérifions nos graphiques, cela est décrit ici. Entre 12 et 16 secondes, la vitesse est négative. Pourquoi cela arrive-t-il? Parce que cette fonction du mouvement (accélérations constantes), même si elle est simple, présente certaines limites. Avec certaines variations brusques de vitesse, il peut ne pas y avoir une valeur constante d’accélération qui puisse garantir notre principe (passer des points de contrôle au bon moment) sans avoir d’effets secondaires comme ceux-là.
Qu'est-ce qu'on fait maintenant?
Vous avez beaucoup d'options:
AnimationCurve
à la classe et personnalisez votre graphique de mouvement dans Editor avec son tiroir intégré génial! Vous pouvez facilement ajouter des points de contrôle avec sa méthode AddKey
et extraire la position temporairement avec sa méthode Evaluate
. Vous pouvez même utiliser la méthode OnValidate
sur votre classe de composants pour mettre à jour automatiquement les points de la scène courbe et vice-versa.Ne t'arrête pas là! Ajoutez un dégradé sur la ligne du chemin Gizmo pour voir facilement où il va plus vite ou plus lentement, ajoutez des poignées pour manipuler le chemin en mode éditeur ... soyez créatif!
Autant que je sache, vous avez déjà la majeure partie de la solution, mais vous l'avez mal initialisée.
La vitesse locale dépend de la longueur de la spline, vous devez donc moduler la vitesse par l'inverse de la longueur du segment (que vous pouvez facilement approcher en quelques étapes).
Certes, dans votre cas, vous n'avez pas de contrôle sur la vitesse, mais uniquement sur le temps de saisie. Vous devez donc distribuer correctement les valeurs de SimpleWayPoint.time
en fonction de l'ordre et de la longueur des segments de spline précédents, au lieu de l'initialiser. manuellement dans la déclaration de champ . De cette façon, percentageThroughSegment
devrait être uniformément distribué.
Comme mentionné dans les commentaires, une partie de ces calculs pourrait paraître plus simple avec Lerp()
:)
Vous pouvez essayer de travailler avec le didacticiel Wheelcollider dont ils disposent pour leur système de roue.
Il comporte certaines variables que vous pouvez ajuster avec les variables Rigidbody pour obtenir une conduite simulée.
Comme ils écrivent
Vous pouvez avoir jusqu'à 20 roues sur un seul véhicule, chacune appliquant un couple de braquage, de moteur ou de freinage.
Disclaimer: Je n'ai qu'une expérience minimale de travail avec WheelColliders. Mais ils semblent ce que vous cherchez pour moi.
Définissons d'abord certains termes:
t
: variable d'interpolation pour chaque spline, allant de 0
à 1
.s
: la longueur de chaque spline. Selon le type de spline utilisé (Catmull-rom, Bézier, etc.), il existe des formules pour calculer la longueur totale estimée.dt
: le changement de t
par image. Dans votre cas, si cette constante est constante dans toutes les splines, vous verrez un changement de vitesse soudain aux extrémités de la spline, car chaque spline a une longueur différente s
.Le moyen le plus simple d’atténuer le changement de vitesse à chaque joint est le suivant:
void Update() {
float dt = 0.05f; //this is currently your "global" interpolation speed, for all splines
float v0 = s0/dt; //estimated linear speed in the first spline.
float v1 = s1/dt; //estimated linear speed in the second spline.
float dt0 = interpSpeed(t0, v0, v1) / s0; //t0 is the current interpolation variable where the object is at, in the first spline
transform.position = GetCatmullRomPosition(t0 + dt0*Time.deltaTime, ...); //update your new position in first spline
}
où:
float interpSpeed(float t, float v0, float v1, float tEaseStart=0.5f) {
float u = (t - tEaseStart)/(1f - tEaseStart);
return Mathf.Lerp(v0, v1, u);
}
L'intuition ci-dessus est que, alors que j'atteins la fin de ma première spline, je prédis la vitesse attendue dans la prochaine spline et j'accélère ma vitesse actuelle pour y parvenir.
Enfin, afin de rendre l’assouplissement encore meilleur:
interpSpeed()
.