Jak przechodzić między wartościami, które zapętlają (takie jak odcień lub obrót)?

25

przykład wspólnej animacji

Zobacz demo

Próbuję sprawić, aby połączenie obracało się płynnie wokół środka płótna, w kierunku kąta wskaźnika myszy. To, co mam, działa, ale chcę, aby animowało jak najkrótszą odległość, aby uzyskać kąt myszy. Problem występuje, gdy wartość zapętla się wokół linii poziomej ( 3.14i -3.14). Najedź myszką na ten obszar, aby zobaczyć, jak zmienia się kierunek, i zajmie to długą drogę z powrotem.

Odpowiedni kod

// ease the current angle to the target angle
joint.angle += ( joint.targetAngle - joint.angle ) * 0.1;

// get angle from joint to mouse
var dx = e.clientX - joint.x,
    dy = e.clientY - joint.y;  
joint.targetAngle = Math.atan2( dy, dx );

Jak sprawić, by obracał się na najkrótszej odległości, nawet „w poprzek”?

chrząknięcie
źródło
Użyj modulo. : D en.wikipedia.org/wiki/Modular_arithmetic
Vaughan Hilts
1
@VaughanHilts Nie jestem pewien, jak użyłbym tego w mojej sytuacji. Czy możesz dalej opracowywać?
jackrugile

Odpowiedzi:

11

Nie zawsze jest to najlepsza metoda i może być droższa pod względem obliczeniowym (choć ostatecznie zależy to od sposobu przechowywania danych), ale przedstawię argument, że przeszukiwanie wartości 2D działa dość dobrze w większości przypadków. Zamiast ustawiać żądany kąt, możesz przeciągać pożądany znormalizowany wektor kierunku.

Zaletą tej metody w porównaniu z metodą „wybierz najkrótszą drogę do kąta” jest to, że działa ona, gdy trzeba interpolować więcej niż dwie wartości.

Kiedy lerping wartości barwy, można wymienić huew [cos(hue), sin(hue)]wektorze.

W twoim przypadku utrzymanie znormalizowanego kierunku połączenia:

// get normalised direction from joint to mouse
var dx = e.clientX - joint.x,
    dy = e.clientY - joint.y;
var len = Math.sqrt(dx * dx + dy * dy);
dx /= len ? len : 1.0; dy /= len ? len : 1.0;
// get current direction
var dirx = cos(joint.angle),
    diry = sin(joint.angle);
// ease the current direction to the target direction
dirx += (dx - dirx) * 0.1;
diry += (dy - diry) * 0.1;

joint.angle = Math.atan2(diry, dirx);

Kod może być krótszy, jeśli można użyć klasy wektorowej 2D. Na przykład:

// get normalised direction from joint to mouse
var desired_dir = normalize(vec2(e.clientX, e.clientY) - joint);
// get current direction
var current_dir = vec2(cos(joint.angle), sin(joint.angle));
// ease the current direction to the target direction
current_dir += (desired_dir - current_dir) * 0.1;

joint.angle = Math.atan2(current_dir.y, current_dir.x);
sam hocevar
źródło
Dziękuję, to działa świetnie tutaj: codepen.io/jackrugile/pen/45c356f06f08ebea0e58daa4d06d204f Rozumiem większość tego, co robisz, ale czy możesz rozwinąć nieco więcej w wierszu 5 kodu podczas normalizacji? Nie jestem pewien, co się tam dokładnie dzieje dx /= len...
jackrugile
1
Dzielenie wektora przez jego długość nazywa się normalizacją . Zapewnia, że ​​ma długość 1. len ? len : 1.0Część po prostu unika dzielenia przez zero, w rzadkim przypadku, gdy mysz jest umieszczona dokładnie w pozycji połączenia. To mogło być napisane: if (len != 0) dx /= len;.
sam hocevar
-1. W większości przypadków odpowiedź jest daleka od optymalnej. Co jeśli interpolujesz między i 180°? W postaci wektorowej: [1, 0]i [-1, 0]. Interpolację wektory da Ci albo , 180°albo podzielić przez 0 błędów, w przypadku t=0.5.
Gustavo Maciel
@GustavoMaciel to nie „większość przypadków”, to jeden bardzo konkretny przypadek narożny, który po prostu nigdy nie zdarza się w praktyce. Ponadto nie ma podziału na zero, sprawdź kod.
sam hocevar,
@GustavoMaciel po ponownym sprawdzeniu kodu jest naprawdę wyjątkowo bezpieczny i działa dokładnie tak, jak powinien, nawet w opisywanym przypadku w rogu.
sam hocevar
11

Sztuka polega na tym, aby pamiętać, że kąty (przynajmniej w przestrzeni euklidesowej) są okresowe o 2 * pi. Jeśli różnica między bieżącym kątem a kątem docelowym jest zbyt duża (tzn. Kursor przekroczył granicę), po prostu dostosuj aktualny kąt, odpowiednio dodając lub odejmując 2 * pi.

W takim przypadku możesz spróbować wykonać następujące czynności: (Nigdy wcześniej nie programowałem w JavaScript, więc wybacz mój styl kodowania).

  var dtheta = joint.targetAngle - joint.angle;
  if (dtheta > Math.PI) joint.angle += 2*Math.PI;
  else if (dtheta < -Math.PI) joint.angle -= 2*Math.PI;
  joint.angle += ( joint.targetAngle - joint.angle ) * joint.easing;

EDYCJA : W tej implementacji zbyt szybkie przesuwanie kursora wokół środka stawu powoduje jego szarpnięcie. Jest to zamierzone zachowanie, ponieważ prędkość kątowa złącza jest zawsze proporcjonalna do dtheta. Jeśli takie zachowanie jest niepożądane, problem można łatwo rozwiązać, nakładając kołpak na przyspieszenie kątowe stawu.

Aby to zrobić, musimy śledzić prędkość połączenia i nałożyć maksymalne przyspieszenie:

  joint = {
    // snip
    velocity: 0,
    maxAccel: 0.01
  },

Następnie dla naszej wygody wprowadzimy funkcję obcinania:

function clip(x, min, max) {
  return x < min ? min : x > max ? max : x
}

Teraz nasz kod ruchu wygląda tak. Najpierw obliczamy dthetajak poprzednio, dostosowując joint.anglew razie potrzeby:

  var dtheta = joint.targetAngle - joint.angle;
  if (dtheta > Math.PI) joint.angle += 2*Math.PI;
  else if (dtheta < -Math.PI) joint.angle -= 2*Math.PI;

Następnie, zamiast natychmiastowego przesunięcia stawu, obliczamy prędkość docelową i używamy jej clipdo wymuszenia jej w dopuszczalnym zakresie.

  var targetVel = ( joint.targetAngle - joint.angle ) * joint.easing;
  joint.velocity = clip(targetVel,
                        joint.velocity - joint.maxAccel,
                        joint.velocity + joint.maxAccel);
  joint.angle += joint.velocity;

Zapewnia to płynny ruch, nawet przy zmianie kierunku, podczas wykonywania obliczeń tylko w jednym wymiarze. Ponadto umożliwia niezależną regulację prędkości i przyspieszenia złącza. Zobacz demo tutaj: http://codepen.io/anon/pen/HGnDF/

David Zhang
źródło
Ta metoda jest bardzo bliska, ale jeśli zbyt szybko myszy, zacznie nieznacznie podskakiwać. Demo tutaj, daj mi znać, jeśli nie zaimplementowałem tego poprawnie: codepen.io/jackrugile/pen/db40aee91e1c0b693346e6cec4546e98
jackrugile
1
Nie jestem pewien, co masz na myśli, skacząc lekko w niewłaściwy sposób; dla mnie złącze zawsze porusza się we właściwym kierunku. Oczywiście, jeśli przesuniesz mysz zbyt szybko po środku, wybiegniesz ze stawu i szarpnie się, gdy zmienia się z jednego kierunku na drugi. Jest to zamierzone zachowanie, ponieważ prędkość obrotu jest zawsze proporcjonalna do dtheta. Czy zamierzałeś, aby staw miał pewien impet?
David Zhang
Zobacz moją ostatnią edycję, aby dowiedzieć się, jak wyeliminować gwałtowny ruch powstały przez przekroczenie złącza.
David Zhang
Hej, wypróbowałem link do wersji demonstracyjnej, ale wygląda na to, że wskazuje tylko mój oryginalny. Zapisałeś nowy? Jeśli nie jest to w porządku, powinienem być w stanie wdrożyć to później wieczorem i zobaczyć, jak to działa. Myślę, że to, co opisałeś w swojej edycji, było tym, czego szukałem. Wrócę do ciebie, dzięki!
jackrugile
1
O tak, masz rację. Przepraszam, mój błąd. Naprawię to.
David Zhang
3

Uwielbiam inne udzielone odpowiedzi. Bardzo techniczne!

Jeśli chcesz, mam bardzo prostą metodę, aby to osiągnąć. Przyjmiemy kąty dla tych przykładów. Pojęcie to można ekstrapolować na inne typy wartości, takie jak kolory.

double MAX_ANGLE = 360.0;
double startAngle = 300.0;
double endAngle = 15.0;
double distanceForward = 0.0;  // Clockwise
double distanceBackward = 0.0; // Counter-Clockwise

// Calculate both distances, forward and backward:
distanceForward = endAngle - startAngle;    // -285.00
distanceBackward = startAngle - endAngle;   // +285.00

// Which direction is shortest?
// Forward? (normalized to 75)
if (NormalizeAngle(distanceForward) < NormalizeAngle(distanceBackward)) {
    // Adjust for 360/0 degree wrap
    if (endAngle < startAngle) endAngle += MAX_ANGLE; // Will be above 360
}

// Backward? (normalized to 285)
else {
    // Adjust for 360/0 degree wrap
    if (endAngle > startAngle) endAngle -= MAX_ANGLE; // Will be below 0
}

// Now, Lerp between startAngle and endAngle. 

// EndAngle can be above 360 if wrapping clockwise past 0, or
// EndAngle can be below 0 if wrapping counter-clockwise before 0.
// Normalize each returned Lerp value to bring angle in range of 0 to 360 if required.  Most engines will automatically do this for you.


double NormalizeAngle(double angle) {
    while (angle < 0) 
        angle += MAX_ANGLE;
    while (angle >= MAX_ANGLE) 
        angle -= MAX_ANGLE;
    return angle;
}

Właśnie utworzyłem to w przeglądarce i nigdy nie byłem testowany. Mam nadzieję, że dobrze zrozumiałem logikę przy pierwszej próbie.

[Edytuj] 2017/06/02 - Trochę wyjaśniono logikę.

Zacznij od obliczenia odległości do przodu i odległości do tyłu i pozwól, aby wyniki wykroczyły poza zakres (0–360).

Normalizowanie kątów przywraca te wartości z zakresu (0–360). Aby to zrobić, dodajesz 360, aż wartość przekroczy zero, i odejmujesz 360, gdy wartość jest wyższa niż 360. Wynikowe kąty początkowe / końcowe będą równoważne (-285 jest takie samo jak 75).

Następnie znajduje się najmniejszy znormalizowany kąt odległości do przodu lub do tyłu. distanceForward w tym przykładzie staje się 75, czyli mniej niż znormalizowana wartość distanceBackward (300).

Jeśli distanceForward jest najmniejszym ORAZ endAngle <startAngle, przedłuż endAngle poza 360 poprzez dodanie 360. (w przykładzie staje się 375).

Jeśli distanceBackward jest najmniejszym ORAZ endAngle> startAngle, przedłuż endAngle do wartości poniżej 0, odejmując 360.

Będziesz teraz lerp od startAngle (300) do nowego endAngle (375). Silnik powinien automatycznie dostosowywać wartości powyżej 360, odejmując dla Ciebie 360. W przeciwnym razie musiałbyś lerp od 300 do 360, NASTĘPNIE lerp od 0 do 15, jeśli silnik nie znormalizuje dla ciebie wartości.

Doug.McFarlane
źródło
Cóż, chociaż atan2pomysł oparty na tym jest prosty, ten może zaoszczędzić trochę miejsca i trochę wydajności (nie trzeba przechowywać xiy, ani zbyt często obliczać sin, cos i atan2). Myślę, że warto nieco wyjaśnić, dlaczego to rozwiązanie jest poprawne (tj. Wybierając najkrótszą ścieżkę na okręgu lub kuli, tak jak SLERP robi dla czwartorzędów). To pytanie i odpowiedź należy umieścić na wiki społeczności, ponieważ jest to bardzo częsty problem, z którym boryka się większość programistów.
teodron
Świetna odpowiedź. Podoba mi się również inna odpowiedź udzielona przez @Davida Zhanga. Zawsze jednak dziwnie się denerwuję, używając jego edytowanego rozwiązania, gdy wykonuję szybki zwrot. Ale twoja odpowiedź idealnie pasuje do mojej gry. Interesuje mnie teoria matematyki, która stoi za twoją odpowiedzią. Chociaż wygląda to prosto, ale nie jest oczywiste, dlaczego powinniśmy porównać znormalizowaną odległość kątową dla różnych kierunków.
newguy
Cieszę się, że mój kod działa. Jak wspomniano, zostało to po prostu wpisane do przeglądarki, a nie przetestowane w rzeczywistym projekcie. Nie pamiętam nawet odpowiedzi na to pytanie (trzy lata temu)! Ale patrząc na to, wygląda na to, że właśnie rozszerzyłem zakres (0-360), aby umożliwić wartości testowe wykraczające poza / przed tym zakresem, aby porównać różnicę całkowitą stopni i przyjąć najkrótszą różnicę. Normalizacja po prostu ponownie wprowadza te wartości do zakresu (0–360). Zatem odległość do przodu staje się 75 (-285 + 360), czyli mniej niż odległość do tyłu (285), więc jest to najkrótsza odległość.
Doug.McFarlane
Ponieważ DistanceForward jest najkrótszą odległością, używana jest pierwsza część zdania IF. Ponieważ endAngle (15) jest mniejszy niż startAngle (300), dodajemy 360, aby uzyskać 375. Tak więc lerp od startAngle (300) do nowego endAngle (375). Silnik powinien automatycznie dostosowywać wartości powyżej 360, odejmując dla Ciebie 360.
Doug.McFarlane
Zredagowałem odpowiedź, aby dodać więcej jasności.
Doug.McFarlane