Search Unity

  1. Welcome to the Unity Forums! Please take the time to read our Code of Conduct to familiarize yourself with the forum rules and how to post constructively.
  2. Join us on Dec 8, 2022, between 7 am & 7 pm EST, in the DOTS Dev Blitz Day 2022 - Q&A forum, Discord, and Unity3D Subreddit to learn more about DOTS directly from the Unity Developers.
    Dismiss Notice
  3. Have a look at our Games Focus blog post series which will show what Unity is doing for all game developers – now, next year, and in the future.
    Dismiss Notice

Resolved Quaternion.LookDirection with parent rotated

Discussion in 'Scripting' started by asdfasdf234, Feb 20, 2022.

  1. asdfasdf234

    asdfasdf234

    Joined:
    Nov 27, 2015
    Posts:
    70
    I've been stuck on this problem for a while, cant get the Turret to rotate correctly when its parent is rotated. I can think of one solution, but its kinda hacky which I really want to avoid if possible. I'm looking for any other cleaner solution.

    Hierarchy
    ------ EmptyGameObject (the GameObject that is rotated)
    --------------Turret (has this script attached)


    Here in Update I'am rotating a GameObject towards mouse cursor on Y axis.
    Code (CSharp):
    1.     void Update()
    2.     {
    3.         var direction = (GetMouseWorld3D(20) - transform.position).normalized;
    4.         var newRotation = Quaternion.LookRotation(direction);
    5.  
    6.         var newRotationInEuler = newRotation.eulerAngles;
    7.         newRotationInEuler.x = 0;
    8.         newRotationInEuler.z = 0;
    9.  
    10.         transform.localRotation = Quaternion.Euler(newRotationInEuler);
    11.     }
    Code (CSharp):
    1.     public static Vector3 GetMouseWorld3D(float distance)
    2.     {
    3.         Ray ray = _camera.ScreenPointToRay(Input.mousePosition);
    4.         if (Physics.Raycast(ray, out RaycastHit raycastHit, distance, _lm))
    5.             return raycastHit.point;
    6.         return ray.GetPoint(20);
    7.     }
    "the cyan sphere is the mouse cursor"

    Ignore the GUI label rotation value is incorrect: in the first image, parent of the Turret is not rotated so it works as expected. But that's not the case is all other images


    Rotated by 90


    Rotated by 40
     
    Last edited: Feb 20, 2022
  2. orionsyndrome

    orionsyndrome

    Joined:
    May 4, 2014
    Posts:
    2,173
    localRotation will ignore any parent by definition, yet you rely on transform.position meaning that you're operating in the world space, but then you modify the object space transformation.

    You must be mindful of the spaces when you're working with linear algebra in this. We use localRotation because it is de facto the storage of the actual quaternion being applied locally. It is faster and more precise.

    But when you're operating in the world space -- and when you have a turret aiming at some free target out there, that's a clear case of world space operation -- we rely on rotations and positions instead of their local counterparts.

    The difference is that rotation and positions have to take the hierarchy into account to be truly representative of the world space. Getting/setting them is slower and less precise, especially with scale (which is why it's called lossyScale), the reason being that the matrices are composited super-fast, but then you need to decompose the final matrix, to get the separate results, such as a quaternion, from it.

    This is not super slow in the general case like yours, and you definitely want to work like this, but here I'm trying to explain why we use localRotation and localPosition historically. And Unity actually uses these to KEEP the original information without having to pack/unpack matrices all the time. However as soon as you start building up a hierarchy, the actual result you see on the screen is all converted into world space matrices, and the original information is KEPT but not really rendered (unless you change the values, then it has to propagate upward to compute the world space again). If you attempt to change the object-space information to values that are derived from the world-space it will probably be buggy and non-sensical, unless you had a super-simple identity hierarchy to begin with (i.e. parent's rotation is 0,0,0).

    With all that in mind, you can however, convert the result from the world space to object space on the fly, if you really need to, by multiplying
    Transform.worldToLocalMatrix
    (it gets pretty advanced from there, and I don't recommend this if you're not familiar with matrices).

    (Edit: Ok, you're using eulerAngles to avoid having rotations on Y and Z. For some reason I read Quaternion.Euler, please disregard the next paragraph for this case, but it's a useful advice anyway. There are btw ways to avoid eulerAngles just as well, you don't need that.)

    Btw you don't really need Euler angles for what you're trying to achieve. Euler angles are not really useful unless you're the one driving them -- i.e. by actually setting the angles to some "designed" rotation. I have never seen a live algorithm employing a naturally occurring reorientation (i.e. turret aiming) that needs Euler angles. Euler angles transformation is super-slow and unreliable and should be used only as an in-code human-translation tool for when you really want to make a quaternion but don't want to mess with the weird values and you want your code readable. You call it once however and you're done with it, that's how one uses it.
     
    Last edited: Feb 20, 2022
  3. orionsyndrome

    orionsyndrome

    Joined:
    May 4, 2014
    Posts:
    2,173
    Let's now think about this from the beginning.

    I'm guessing you're having two objects that behave like this: one is the shaft that is fixed to some ground point, and rotates on the vertical (Y) axis (YAW), while its child object is allowed to go up/down (PITCH). Is this correct?

    I will show you the solution once we confirm the actual setup.

    Technically the parent should be called a turret. Turrets are actually the gun parents in such mechanisms.
    The gun itself either has some limited rotation in a cone, or like with a tank, it can only pitch up or down.
     
  4. asdfasdf234

    asdfasdf234

    Joined:
    Nov 27, 2015
    Posts:
    70
    You'r right, there are two objects for the Turret
    -----Base (rotates on Y)
    -----------Gun (rotates on X)

    in the actual game this Turret is attached as a child-GameObject to a Car-GameObject. In the game the Car will have wide range of motion being able to rotate in any direction. That's why I wanted to handle the Turret's rotation locally(or so I thought) since parent(the car) will have its own rotation.
     
  5. orionsyndrome

    orionsyndrome

    Joined:
    May 4, 2014
    Posts:
    2,173
    This is exactly why you want to work in world space.

    If you think about it, why do you care about the arrangements and compound transformations, when you can absolutely orient some gun toward some random point in space, and it will work at all times. The only issue you do have is that the rotations are constrained, and so certain configurations are not doable by the turret. For starters the gun is probably unable to pitch below the horizon (maybe only slightly), so to act like a true turret you actually model legal orientations as a half-dome.

    Imagine sticking this half-dome to the roof of your car, and rotate this car however you want, that's basically your orientation space. The gun can aim at a target only if there is a clear uninterrupted ray between the half-dome's center and the target.

    Now let's unpack the half-dome itself, because once we've decided the target is aimable, we need to come up with the mechanical rotations inside the turret, and yet we have two independent rotations which need to combine into just one thing. This is btw an incredibly hard problem, BUT because your two axes of rotations are axis-aligned, it becomes trivial. At this point we can also completely ignore the fact that the car itself rotates, and allow this to be an afterthought.

    So, practically, we want to decompose a simple, very directional orientation into two separate but inter-dependent orientations. For example (in yaw+pitch notation) if you need to aim at 60°+30° that just means rotate base by Y60° then rotate gun by X30°. However, what is needed is a way to turn the compound rotation into two base rotations.

    I wrote some code that does this, it'll be in the next post.
     
  6. AnimalMan

    AnimalMan

    Joined:
    Apr 1, 2018
    Posts:
    1,050
    You can use eulers if you are a local rotation.


    Let me know if eulers fail I’ll see if I can dig out some code where eulers work for you.
     
  7. orionsyndrome

    orionsyndrome

    Joined:
    May 4, 2014
    Posts:
    2,173
    Sorry about the delay, I have severe technical problems with the forum and I'm posting this in the hope it'll somehow refresh the saved draft which bugged for some reason and I can't neither post nor preview due to some error.
     
  8. orionsyndrome

    orionsyndrome

    Joined:
    May 4, 2014
    Posts:
    2,173
    I'll slowly build up the code, so let's start with the basics.
    First we make the environment that allows us to work with this in an isolated manner.
    Code (csharp):
    1. using UnityEngine;
    2.  
    3. public class CarTurretRotation : MonoBehaviour {
    4.  
    5.   [SerializeField] GameObject gun;
    6.   [SerializeField] GameObject target;
    7.  
    8. }
    These are just some basic slots to drag'n'drop empty game objects. Make both 'turret' and 'target' in the scene (not parented to anything), then add a child object to 'turret' and name it 'gun'. Make sure that all three transforms are reset. From now on, we'll need only world position from 'target', so it doesn't really matter if it's rotated, however we won't change 'turret' or 'gun'.

    In short, the hierarchy looks like this:
    turret (top-level object; script goes here)
    - gun (empty child object)
    target (empty top-level object)

    Next, let's make sure this script works immediately in the editor. And we'll add a couple more stuff.
    Code (csharp):
    1. using UnityEngine;
    2.  
    3. [ExecuteInEditMode]
    4. public class CarTurretRotation : MonoBehaviour {
    5.  
    6.   [SerializeField] GameObject gun;
    7.   [SerializeField] GameObject target;
    8.  
    9.   void Update() {
    10.     if(gun is null) return; // safety bail
    11.  
    12.     // let's start by storing local rotations
    13.     var baseRot = transform.localRotation;
    14.     var gunRot = gun.transform.localRotation;
    15.   }
    16.  
    17.   void OnDrawGizmos { // if I type () after OnDrawGizmos, the forum stops working o.O
    18.     // I can't post or preview the message, it took me an hour to discover this
    19.     // anyway we'll fix it later, hopefully once I post this, that broken cache will go away
    20.   }
    21.  
    22. }
    To be continued
     
    emkay4597 likes this.
  9. AnimalMan

    AnimalMan

    Joined:
    Apr 1, 2018
    Posts:
    1,050
    Test eulers should be controllable on the one axis during local.
    The alternative is a complex quaternion subtract quaternion I believe
     
  10. orionsyndrome

    orionsyndrome

    Joined:
    May 4, 2014
    Posts:
    2,173
    We'll use OnDrawGizmos to draw some gizmo lines, without having to rely on meshes.
    So let's do this
    Code (csharp):
    1. void OnDrawGizmos { // yet again I can't type the parentheses () here :(
    2.   if(gun is null) return; // safety bail
    3.   drawBase(Color.red); // this will draw a line that represents the base
    4.   drawGun(Color.yellow); // this will draw a line that represents the gun
    5.  
    6.   // obviously the gun is supposed to be offset from the base's pivot
    7.   // we'll consider the gun's local pivot position to be this offset (to make things easy)
    8.  
    9.   // finally, we want to draw a line from the gun's pivot to target
    10.   // this represent the aiming world direction
    11.   if(target is null) return; // another safety bail because we need a target object for this
    12.   drawAimLine(Color.cyan);
    13. }
    Next, we should compute a couple of interesting points. Namely the world position of the gun's pivot and the world position of the gun's nozzle. This will make drawing lines easier.

    Gun's pivot we already have, it's just
    Code (csharp):
    1. Vector3 getGunPivotPos() => gun.transform.position;
    Gun's nozzle however, is a little more complicated, and we need the length of the gun for this.
    So let's add this at the top.

    Code (csharp):
    1. [SerializeField] [Min(0f)] float gunLength;
    Now we can think about the nozzle. The position of the nozzle is affected by the general offset of the gun, as well as the world rotation of the gun. One way to find it, is to multiply gunLength with the forward vector (0, 0, 1), and this gives us a segment that's just as long. Then we rotate this vector according to the gun's world rotation. Finally we move the origin of this segment to match the gun's pivot.

    Thus
    Code (csharp):
    1. Vector3 getGunNozzlePos() => getGunPivotPos() + gun.transform.rotation * (gunLength * Vector3.forward);
    Now we can write the three gizmo drawing functions.
    Code (csharp):
    1. void drawBase(Color color) {
    2.   var p1 = transform.position; // this script lives on 'turret', its origin is point1
    3.   var p2 = getGunPivotPos(); // gun's pivot is point2
    4.   drawLine(p1, p2, color);
    5. }
    6.  
    7. void drawGun(Color color) {
    8.   var p1 = getGunPivotPos();
    9.   var p2 = getGunNozzlePos();
    10.   drawLine(p1, p2, color);
    11. }
    12.  
    13. void drawAimLine(Color color) {
    14.   var p1 = getGunPivotPos();
    15.   var p2 = target.transform.position;
    16.   drawLine(p1, p2, color);
    17. }
    18.  
    19. void drawLine(Vector3 a, Vector3 b, Color color) {
    20.   Gizmos.color = color;
    21.   Gizmos.DrawLine(a, b);
    22. }
    To be continued.
     
    emkay4597 likes this.
  11. orionsyndrome

    orionsyndrome

    Joined:
    May 4, 2014
    Posts:
    2,173
    a) Place the script on 'turret',
    b) Connect (drag'n'drop) 'gun' and 'target' objects to eponymous fields in the inspector,
    c) You should be able to see a blue line in the scene, going from 'turret' to 'target',
    d) If you modify 'Gun Length' you should be able to see a yellow line,
    e) Select 'gun' and move it slightly upwards with the move tool (Y), the yellow line should move and you should be able to see a red line,
    f) You are now free to move the target object live in the editor, blue line should update on its own,
    g) Additionally you may change the object icon for 'target' so that it's easier to locate in the scene.
     
    emkay4597 likes this.
  12. orionsyndrome

    orionsyndrome

    Joined:
    May 4, 2014
    Posts:
    2,173
    Now let's add a toggle for the blue line.

    Code (csharp):
    1. [SerializeField] bool showAimLine;
    2.  
    3. void OnDrawGizmos { // and again pls add () in your code
    4.   // ... keep everything else already here
    5.   if(showAimLine) drawAimLine(Color.cyan);
    6. }
    From now on, everything else is in update.
    Here's what we can do

    Code (csharp):
    1. void Update() {
    2.   var baseRot = transform.localRotation;
    3.   var gunRot = gun.transform.localRotation;
    4.  
    5.   // we do some computation here
    6.  
    7.   transform.localRotation = baseRot;
    8.   gun.transform.localRotation = gunRot;
    9. }
    With the Update loop prepared, we can now think of how exactly we can find out the rotations involved.
    Here's one way. We can begin by getting a direction from gun's pivot to target. Let's call this
    tdir
    for 'target direction'. To find a direction subtract two points B - A and what you get is a vector that runs from A to B. Now normalize this vector to get a vector of length 1.
    Code (csharp):
    1. var tdir = (target.transform.position - getGunPivotPos()).normalized;
    For now we treat the setup as if the turret is laid on the flat XZ ground, so no car rotations are involved, yet. This means that we can use the XZ plane to project this directional vector to it.

    Reasoning behind this
    This is a way to constrain the rotation to just one major axis, in this case Y for turret base rotation. Because we're going to compute a quaternion from this
    tdir
    direction, it is much easier and faster to flatten it down, then to remove this extra rotation from a quaternion.

    The easiest way to do it
    We just nullify y component of
    tdir
    .

    Code (csharp):
    1. var tdir = target.transform.position - getGunPivotPos(); // we'll do normalization later
    2. var pdir = new Vector3(tdir.x, 0f, tdir.z); // projected direction
    To be continued.
     
    emkay4597 likes this.
  13. orionsyndrome

    orionsyndrome

    Joined:
    May 4, 2014
    Posts:
    2,173
    Damn, it took me much less to make this then to post it :) the forum is messed up big time.

    Anyway, now that we have
    pdir
    , we can nail down rotation Y all while making sure that the turret base's local up stays that way.

    Code (csharp):
    1. // we can use the world's up because locally it's true
    2. var baseQuat = Quaternion.LookRotation(pdir.normalized, Vector3.up);
    Now, for reasons that I haven't managed to nail down exactly -- it's probably something I did (or didn't do) in the setup -- we have to rotate this result by -90 degrees on Y to make it correct. But who cares if it's easy to solve.

    Let's add this right after serialized fields.
    Code (csharp):
    1. Quaternion _twist = Quaternion.Euler(0f, -90f, 0f);
    Now we can add this to
    Update

    Code (csharp):
    1. baseRot = _twist * baseQuat;
    These things in Update should be guarded against null target anyway, so here's the full Update function so far
    Code (csharp):
    1. void Update() {
    2.   // get local rotations
    3.   var baseRot = transform.localRotation;
    4.   var gunRot = gun.transform.localRotation;
    5.  
    6.   if(target is null) {
    7.     // we'll use this later
    8.  
    9.   } else {
    10.     // technically these are not directions, but deltas, but we'll normalize them on the spot
    11.     var tdir = target.transform.position - getGunPivotPos();
    12.     var pdir = new Vector3(tdir.x, 0f, tdir.z);
    13.  
    14.     var baseQuat = Quaternion.LookRotation(pdir.normalized, Vector3.up);
    15.     baseRot = _twist * baseQuat;
    16.  
    17.     // here we'll add gun rotation at a later point, code is below
    18.  
    19.   }
    20.  
    21.   // store rotations back
    22.   transform.localRotation = baseRot;
    23.   gun.transform.localRotation = gunRot;
    24. }
    25.  
    26. }
    To be continued.
     
  14. orionsyndrome

    orionsyndrome

    Joined:
    May 4, 2014
    Posts:
    2,173
    Two things remain to be done.

    First, to rotate the gun. The gun is supposed to be oriented in such a way to always point directly toward the target with its nozzle. It should also always pitch, and should never roll. Now you can see what's the idea behind the blue line: if everything works properly the yellow line should always line up exactly with the blue one.

    Second, we need to account for the rotating car underneath this contraption. We'll do this as the last thing.

    When it comes to gun rotation, there is a simple trick how to get the secondary rotation, now that we have the horizontal one.

    What we need is something that's called a decomposition of quaternions. The final rotation (lets call it Rf) consists of two independent ones (R1 and R2). Just imagine if we could represent this with simple math, it would look like Rf = R1 + R2. In this case we already have Rf and R1, but we need to find R2. If we could only subtract the rotations, right? R2 = Rf - R1 would be the answer.

    (I'm sorry for slicing this up so much, but I can't post otherwise.)
     
  15. orionsyndrome

    orionsyndrome

    Joined:
    May 4, 2014
    Posts:
    2,173
    Well, quaternions can be "subtracted" -- or better put, a differential rotation can be computed simply by multiplying one with the inverse of another. And so, in our case, we practically "cancel" the turret base's Y rotation from the compound one.
    Code (csharp):
    1. var gunQuat = Quaternion.Inverse(baseRot) * Quaternion.LookRotation(tdir.normalized, Vector3.up);
    2. gunRot = gunQuat;
    Decomposition of quaternions is not an easy problem, but this trick when you're having just a single hinge makes it very easy. Fun fact: the only remaining rotation that would perfectly complete this rotation is the gun's pitch.

    There is one more thing we could add. A way to tell how much the gun is pitched, and to prohibit certain angles. For example, if we don't want the gun to pitch below the horizon, or if there is a maximum angle above the horizon.

    To be continued.
     
  16. AnimalMan

    AnimalMan

    Joined:
    Apr 1, 2018
    Posts:
    1,050
    They kno it’s coming
     
  17. AnimalMan

    AnimalMan

    Joined:
    Apr 1, 2018
    Posts:
    1,050
  18. AnimalMan

    AnimalMan

    Joined:
    Apr 1, 2018
    Posts:
    1,050
    So your current angle relative to your parent angle is

    transform.localEulerAngles.
    You can just += new Vector(X,Y,Z)
    No trouble at all.

    but if you want to read the angle reliably you’ll have to make a string or int of it.

    so to clamp your angle to 45* you write a vector3 of 45* on the axis you want, and then compare the strings or ints or rounded floats of your current vector and your clamp vector.
     
    Last edited: Feb 20, 2022
  19. orionsyndrome

    orionsyndrome

    Joined:
    May 4, 2014
    Posts:
    2,173
    To check out the gun's pitch and its angles, we need to use eulerAngles computed from gunQuat. Here the X rotation will show the pitch in degrees. But the numbers are all over the place even though they are in the 0..360 range.

    When I made this I wanted to have a strict control over what numbers make sense in this scenario. I wanted the number to be +90° when the gun is pointing directly up, -90° when it's pointing directly down, and 0° when it's pointing at the horizon.

    So here's a short recipe of what I did to make this happen.

    1) I got the angle,
    2) incremented it by 180°,
    3) took a 360° modulo and made sure that the value is now in the 0..360° range and not something like -200 or +1400,
    4) subtracted this result from 180°.

    This process was more of a trial and error, not really something important or special, but hey you want to do it in one go, and this will help with the parameters that you can set to invalidate the pitch.

    But we need this modulo thing, right?
    Code (csharp):
    1. float mod(float v, float m) => (v %= m) < 0f? v + m : v;
    Now you can add the limiting parameters (top code) and the invalid flag.
    Code (csharp):
    1. [SerializeField] [Range(-90f, 90f)] float pitchMin; // i.e. 5° below horizon (-5)
    2. [SerializeField] [Range(-90f, 90f)] float pitchMax; // i.e. 75
    3.  
    4. bool _invalid; // if true then this aiming angle is beyond limits
    5. Quaternion _twist = Quaternion.Euler(0f, -90f, 0f); // this was already here
    Now let's change drawGun call in OnDrawGizmos so that it shows the gun in a different color when the aim is invalid.
    Code (csharp):
    1. ...
    2. drawGun(!_invalid? Color.yellow : Color.magenta);
    3. ...
    Finally, we evaluate whether the aim is fine in the Update
    Code (csharp):
    1. var pitch = 180f - mod((gunQuat.eulerAngles.x + 180f, 360f);
    2. _invalid = pitch < pitchMin || pitch > pitchMax;
     
  20. orionsyndrome

    orionsyndrome

    Joined:
    May 4, 2014
    Posts:
    2,173
    Lastly, we need to account for the tilting (or backflipping) car.

    This probably sounds daunting considering all these quaternions, but here's another trick. Because we do the whole calculation for the local space we are actually independent from any reference frame. This means that the only thing that's coming from the external frame of reference and will change its relative position if the car rotates is the target.

    Notice that, in the relative terms, from the perspective of the turret it doesn't matter what rotates, the only thing that matters is the blue aim line. It is what we use to determine rotations.

    Let's change the hierarchy slightly to make accommodations for the car. Manually add 'turret' to a new object 'car', so that you get:
    car (new empty object)
    - turret (script lives here)
    - - gun
    target

    Once we add the following line of code you'll be free to rotate car however you want, and the thing should hopefully work as intended. We also want to invalidate the aim if there is no target, so here's the full Update function after these changes

    Code (csharp):
    1. void Update() {
    2.   var baseRot = transform.localRotation;
    3.   var gunRot = gun.transform.localRotation;
    4.  
    5.   if(target is null) {
    6.     _invalid = true;
    7.  
    8.   } else {
    9.     var tdir = target.transform.position - getGunPivotPos();
    10.  
    11.     // this is the trick, we apply inverted car's rotation to the target instead
    12.     tdir = Quaternion.Inverse(transform.parent.rotation) * tdir;
    13.  
    14.     var pdir = new Vector3(tdir.x, 0f, tdir.z);
    15.  
    16.     var baseQuat = Quaternion.LookRotation(pdir.normalized, Vector3.up);
    17.     baseRot = _twist * baseQuat;
    18.  
    19.     var gunQuat = Quaternion.Inverse(baseRot) * Quaternion.LookRotation(tdir.normalized, Vector3.up);
    20.     gunRot = gunQuat;
    21.  
    22.     var pitch = 180f - mod(gunQuat.eulerAngles.x + 180f, 360f);
    23.     _invalid = pitch < pitchMin || pitch > pitchMax;
    24.  
    25.   }
    26.  
    27.   transform.localRotation = baseRot;
    28.   gun.transform.localRotation = gunRot;
    29.  
    30. }
    And that's it.

    I've tested this with some boxes for some time and it appeared to be working solidly. You can move and/or rotate both 'car' and 'target' and the parts should behave correctly. You can test for yourself by parenting elongated cubes to 'turret' and 'gun'. Make sure to adjust the gun's pivot (nest it inside another object if needed) so that it lands on local zero.
     
  21. orionsyndrome

    orionsyndrome

    Joined:
    May 4, 2014
    Posts:
    2,173
    Code (csharp):
    1. using UnityEngine;
    2.  
    3. [ExecuteInEditMode]
    4. public class CarTurretRotation : MonoBehaviour {
    5.  
    6.   [SerializeField] GameObject gun;
    7.   [SerializeField] float gunLength;
    8.   [SerializeField] GameObject target;
    9.   [SerializeField] bool showAimLine;
    10.   [SerializeField] [Range(-90f, 90f)] float pitchMin;
    11.   [SerializeField] [Range(-90f, 90f)] float pitchMax;
    12.  
    13.   bool _invalid;
    14.   Quaternion _twist = Quaternion.Euler(0f, -90f, 0f);
    15.  
    16.   void Update() {
    17.     var baseRot = transform.localRotation;
    18.     var gunRot = gun.transform.localRotation;
    19.  
    20.     if(target is null) {
    21.       _invalid = true;
    22.  
    23.     } else {
    24.       var tdir = getTargetPivotPos() - getGunPivotPos();
    25.       tdir = Quaternion.Inverse(getCarRotation()) * tdir;
    26.  
    27.       var pdir = new Vector3(tdir.x, 0f, tdir.z);
    28.  
    29.       var baseQuat = Quaternion.LookRotation(pdir.normalized, Vector3.up);
    30.       baseRot = _twist * baseQuat;
    31.  
    32.       var gunQuat = Quaternion.Inverse(baseRot) * Quaternion.LookRotation(tdir.normalized, Vector3.up);
    33.       gunRot = gunQuat;
    34.  
    35.       var pitch = 180f - mod(gunQuat.eulerAngles.x + 180f, 360f);
    36.       _invalid = pitch < pitchMin || pitch > pitchMax;
    37.  
    38.     }
    39.  
    40.     transform.localRotation = baseRot;
    41.     gun.transform.localRotation = gunRot;
    42.   }
    43.  
    44.   float mod(float v, float m) => (v %= m) < 0f? v + m : v;
    45.  
    46.   void OnDrawGizmos { // again with ()
    47.     if(gun is null) return;
    48.     drawBase(Color.red);
    49.     drawGun(!_invalid? Color.yellow : Color.magenta);
    50.  
    51.     if(target is null) return;
    52.     if(showAimLine) drawAimLine(Color.cyan);
    53.   }
    54.  
    55.   // I've decided to name everything, for clarity
    56.   // please note that this is not optimized code, and there is a lot room for improvement
    57.   Quaternion getCarRotation() => transform.parent.rotation;
    58.   Vector3 getGunPivotPos() => gun.transform.position;
    59.   Vector3 getGunNozzlePos() => getGunPivotPos() + gun.transform.rotation * (gunLength * Vector3.forward);
    60.   Vector3 getTargetPivotPos() => target.transform.position;
    61.   Vector3 getBasePivotPos() => transform.position;
    62.  
    63.   void drawBase(Color color) {
    64.     var p1 = getBasePivotPos();
    65.     var p2 = getGunPivotPos();
    66.     drawLine(p1, p2, color);
    67.   }
    68.  
    69.   void drawGun(Color color) {
    70.     var p1 = getGunPivotPos();
    71.     var p2 = getGunNozzlePos();
    72.     drawLine(p1, p2, color);
    73.   }
    74.  
    75.   void drawAimLine(Color color) {
    76.     var p1 = getGunPivotPos();
    77.     var p2 = getTargetPivotPos();
    78.     drawLine(p1, p2, color);
    79.   }
    80.  
    81.   void drawLine(Vector3 a, Vector3 b, Color color) {
    82.     Gizmos.color = color;
    83.     Gizmos.DrawLine(a, b);
    84.   }
    85.  
    86. }
     
    Last edited: Feb 20, 2022
    alexeu likes this.
  22. orionsyndrome

    orionsyndrome

    Joined:
    May 4, 2014
    Posts:
    2,173
    Please ignore line 55 in the previous post (saying just
    Vector3.forward
    ), that's a typo that I cannot edit because of the issues with the forum.


    I've managed to edit the post. Guess how -- by removing () from OnDrawGizmos :)
    Please don't forget to add these back. Sigh..
     
    Last edited: Feb 20, 2022
  23. asdfasdf234

    asdfasdf234

    Joined:
    Nov 27, 2015
    Posts:
    70
    What the F*** is this access denied every time I try to post my reply...
     
  24. asdfasdf234

    asdfasdf234

    Joined:
    Nov 27, 2015
    Posts:
    70
    Wow 21 reply's lol, thank you for taking your time helping me man, I really appreciate it.
    Anyways I've read your reply's(really informative) and made a copy of this script I gave it a go and it indeed works really good with any parent rotation.

    Only one thing left really, and that is to prevent gun rotation outside pitch-Range. Now I only know how to do it with EulerAngles. Should I do it another way? Well this is what I currently have
    Code (CSharp):
    1.             var newRot = gunRot.eulerAngles;
    2.             if (pitch < pitchMin)
    3.             {
    4.                 newRot.x = -pitchMin;
    5.                 gunRot = Quaternion.Euler(newRot);
    6.             }
    7.             else if(pitch > pitchMax)
    8.             {
    9.                 newRot.x = -pitchMax;
    10.                 gunRot = Quaternion.Euler(newRot);
    11.             }
     
  25. orionsyndrome

    orionsyndrome

    Joined:
    May 4, 2014
    Posts:
    2,173
    Have you seen post #19?
    It's in the lines 35 and 36 in the final code (which is in the post #21).
     
    Last edited: Feb 21, 2022
  26. asdfasdf234

    asdfasdf234

    Joined:
    Nov 27, 2015
    Posts:
    70
    Oh, my bad I overlooked it, guess I could just use the _invalid-bool to stop gun rotation beyond range.
    Code (CSharp):
    1.          
    2. if(!_invalid)
    3.                 gunRot = gunQuat;
    4.  
     
  27. orionsyndrome

    orionsyndrome

    Joined:
    May 4, 2014
    Posts:
    2,173
    That was the idea, yes.
    Or you can use it to make the turret adopt a default orientation or whatever.
    You can also add a layer of logic on top of it, to enable smooth animation, to make a turret where the speed of rotations plays a role in how effective it is when aiming, and so on.
     
  28. orionsyndrome

    orionsyndrome

    Joined:
    May 4, 2014
    Posts:
    2,173
    Btw to limit gun rotation you need to be able to produce a rotation that sits on that limit.

    Stopping can work as well, but to implement this, you need to add the animation logic to it, so that the gun pursues a destination rotation over time, then you simply stop this pursue whenever the aim is deemed as invalid.

    Currently, the gun acutely snaps into whatever rotation is required (i.e. if target teleports, the gun will teleport as well, there is no notion of continuity), therefore, there is nothing to stop. I'll try to improve on this.

    Another thing, there is exactly one other way to do this without using eulerAngles, but it's slightly harder to do and includes at least one additional quaternion multiplication, so only a benchmark would prove whether it's performing better or not. I'm not really sure if it's worth trying, especially if it's not overly slow this way either.

    The idea is to compute the pitch angle from a planar projection, therefore by simply using Atan2. That's faster than Euler (or eulerAngles), which is a really complicated method with a lot of messy trigonometry (eulerAngles is practically an inverse of Euler, so probably uses arcsin/cos which are expensive). However, you need to be able to force the best possible planar projection which isn't exactly trivial (but it's not a hard math problem either).

    Oh and before I forget, keep in mind that caching transforms is a good idea, because the
    transform
    shortcut (as in
    myobject.transform
    ) is not really a good thing to call frequently, as it's not a hard reference, but a component query (which is very stupid, imho; I guess they're expecting the high-level cache to be better suited than the general low-level one).

    I left the code as is for clarity, but this isn't production ready, think of it more as a solid proof of concept.
     
    Last edited: Feb 21, 2022
  29. orionsyndrome

    orionsyndrome

    Joined:
    May 4, 2014
    Posts:
    2,173
    Here's the finalized solution. I've cleaned up the code, made it much more optimal, and introduced a couple of new features.

    • It supports animation, and you're free to configure angular speeds for both the base and the gun,
    • Animation works in the editor as well (but you need to turn on 'Always Refresh' in the scene view),
    • You can now toggle a rather fancy visualization for the pitch limits and see exactly what's going on,
    • Gun will never go past the limits (rotation is effectively clamped), but will still try to go after the target and slide on the limits,
    • If you clear the target, the turret will try to get back to identity rotations (park),
    • You have a full public interface at your disposal, for when you want to set the target, get the yaw angle, or check whether aiming is possible etc.
    • Some things are made slightly more robust and production-ready.

    There is still some room for improvement, but this is now in a very good form, afaik.
    I tried to test it extensively, but there could be bugs lurking. Tell me if you notice anything.

    (Edit: Newer version of this in post #31)
    Code (csharp):
    1. using UnityEngine;
    2.  
    3. [ExecuteInEditMode]
    4. public class TurretBehaviour : MonoBehaviour {
    5.  
    6.   // 1) Add this script to an object that is the "base" of a turret; it works live in the editor
    7.   // 2) Make a child "gun" object and assign it; freely move this object relative to the "base", for example on Y
    8.   // 3) Make a "target" object and assign it; move it anywhere both in the scene and hierarchy; don't make it a child of "base"
    9.   // 4) Make sure to toggle 'Always Refresh' in the scene view or else animation frames will be skipped in the editor
    10.   // 5) Tweak parameters and move the "target" around to see it in action.
    11.   // 6) Turret's "base" can be further nested to some parent object; parent's transform will be respected (scaling is not supported though).
    12.  
    13.   [SerializeField] [Tooltip("Should be a child object")] GameObject gun;
    14.   [SerializeField] [Min(0f)] [Tooltip("Visualizations only")] float gunLength = .8f;
    15.   [SerializeField] GameObject target;
    16.   [SerializeField] bool showAimLine;
    17.   [SerializeField] bool showPitchLimits;
    18.   [SerializeField] [Range(-90f, 90f)] public float pitchMin = -5f;
    19.   [SerializeField] [Range(-90f, 90f)] public float pitchMax = 50f;
    20.   [SerializeField] [Min(0.1f)] public float baseAngularSpeed = 50f;
    21.   [SerializeField] [Min(0.1f)] public float gunAngularSpeed = 50f;
    22.  
    23.   // public stuff
    24.   public GameObject Target => target;
    25.   public bool HasTarget => target != null;
    26.   public void SetTarget(GameObject target) { this.target = target; Start(); }
    27.   public void ClearTarget() { target = null; Start(); }
    28.   public float DistanceToTarget => targetDelta.magnitude;
    29.   public bool IsTargetAimable => target != null && _validPitch;
    30.   public float TurretYawDegrees => xfBase.localRotation.eulerAngles.y;
    31.   public float GunPitchDegrees => _pitch;
    32.   public Quaternion TowardsTarget => setGunRot * setBaseRot;
    33.   public Quaternion TowardsTargetYaw => setBaseRot;
    34.   public Quaternion TowardsTargetPitch => setGunRot;
    35.  
    36.   // class vars
    37.   float _pitch;
    38.   bool _validPitch;
    39.   readonly Quaternion _twist = Quaternion.Euler(0f, -90f, 0f);
    40.  
    41.   // transforms are cached for better performance
    42.   Transform xfParent, xfBase, xfGun, xfTarget;
    43.  
    44.   // if there is a parent, its transform will be considered
    45.   Quaternion invertedWorldRotation => xfParent == null? Quaternion.identity : Quaternion.Inverse(xfParent.rotation);
    46.  
    47.   // differential vector
    48.   Vector3 targetDelta => xfTarget.position - xfGun.position;
    49.  
    50.   // destination orientations which the animation will pursue
    51.   Quaternion setBaseRot, setGunRot;
    52.  
    53.   void Start() {
    54.     xfBase = transform;
    55.     setBaseRot = xfBase.localRotation;
    56.  
    57.     xfParent = transform.parent;
    58.     xfTarget = (target == null)? null : target.transform;
    59.  
    60.     if(gun != null) {
    61.       xfGun = gun.transform;
    62.       setGunRot = xfGun.localRotation;
    63.     } else {
    64.       xfGun = null;
    65.       setGunRot = Quaternion.identity;
    66.       Debug.Log("Gun object not connected to script.");
    67.     }
    68.   }
    69.  
    70.   void Update() {
    71.     // base is not supposed to be moved locally, move its parent instead
    72.     xfBase.localPosition = Vector3.zero;
    73.  
    74.     // bail out if there's no gun assigned
    75.     if(xfGun == null) return;
    76.  
    77.     // the aim cannot be accomplished if there's no target
    78.     if(xfTarget == null) {
    79.       setBaseRot = Quaternion.identity;
    80.       setGunRot = Quaternion.identity;
    81.  
    82.     } else { // if there's a target...
    83.       // target direction is used to find the compound rotation
    84.       var tdir = invertedWorldRotation * targetDelta; // apply the inverse of the parent's orientation, so that turret can tumble in space
    85.       var pdir = new Vector3(tdir.x, 0f, tdir.z); // target direction is now projected to XZ plane, to diminish all rotations but yaw
    86.  
    87.       // we take extra care there are no rolls involved in the local rotations
    88.       // and here the directions get normalized before use
    89.       var bq = _twist * Quaternion.LookRotation(pdir.normalized, Vector3.up); // base rotation (yaw)
    90.       var gq = Quaternion.Inverse(bq) * Quaternion.LookRotation(tdir.normalized, Vector3.up); // gun rotation (pitch)
    91.  
    92.       // we use eulerAngles to read back angles, but the values are messy
    93.       // a transformation is needed in order to set pitch to -90..+90 interval
    94.       // where 0 is aiming toward the horizon, +90 is aiming up, and -90 down
    95.       _pitch = 180f - mod(gq.eulerAngles.x + 180f, 360f);
    96.       _validPitch = _pitch >= pitchMin && _pitch <= pitchMax;
    97.  
    98.       // if pitch is off the limits, clamp it back
    99.       if(!_validPitch) {
    100.         _pitch = -Mathf.Clamp(_pitch, pitchMin, pitchMax); // it's easy to convert the pitch back
    101.         gq = Quaternion.Euler(_pitch, 90f, 0f);
    102.       }
    103.  
    104.       setBaseRot = bq;
    105.       setGunRot = gq;
    106.  
    107.     }
    108.  
    109.     // don't let this confuse you: mod is now a local function (a function within a function)
    110.     float mod(float v, float m) => (v %= m) < 0f? v + m : v;
    111.  
    112.     // also a local function, as we only need these things locally; this is usually good for the compiler
    113.     bool quaternionsMatch(Quaternion q1, Quaternion q2, float epsilon = Quaternion.kEpsilon)
    114.       => Quaternion.Dot(q1, q2) - 1f > -epsilon; // true only when dot approximates +1
    115.  
    116.     // this will progressively animate the two rotations using set speeds
    117.     // this is guarded by a cheap test whether the final orientation was achieved or not
    118.     if(!quaternionsMatch(xfBase.localRotation, setBaseRot) || !quaternionsMatch(xfGun.localRotation, setGunRot)) {
    119.       xfBase.localRotation = Quaternion.RotateTowards(xfBase.localRotation, setBaseRot, baseAngularSpeed * Time.deltaTime);
    120.        xfGun.localRotation = Quaternion.RotateTowards( xfGun.localRotation, setGunRot,  gunAngularSpeed  * Time.deltaTime);
    121.     }
    122.   }
    123.  
    124.   void Reset() => Start();
    125.  
    126. // this block of code is valid only in the editor, gizmos and such
    127. #if UNITY_EDITOR
    128.  
    129.   // this triggers if something changes in the inspector
    130.   void OnValidate() => Start();
    131.  
    132.   // draws gizmos in the scene
    133.   void OnDrawGizmos() {
    134.     // this lets Update() get called continuously in the editor
    135.     UnityEditor.EditorApplication.QueuePlayerLoopUpdate();
    136.  
    137.     if(xfGun == null) return;
    138.     drawBase(Color.red);
    139.     drawGun(_validPitch? Color.yellow : Color.magenta);
    140.     if(showPitchLimits) drawPitchLimits(Color.magenta);
    141.  
    142.     if(xfTarget == null) return;
    143.     if(showAimLine) drawAimLine(Color.cyan);
    144.   }
    145.  
    146.   void drawBase(Color color) => drawLine(xfBase.position, xfGun.position, color);
    147.   void drawGun(Color color) => drawLine(xfGun.position, gunNozzlePos, color);
    148.   void drawAimLine(Color color) => drawLine(xfGun.position, xfTarget.position, color);
    149.  
    150.   // only used for visualizations
    151.   Vector3 gunNozzlePos => xfGun.position + xfGun.rotation * (gunLength * Vector3.forward);
    152.  
    153.   // a fancy routine that draws pitch limit circles as if they were spherical slices
    154.   void drawPitchLimits(Color color) {
    155.     drawBubble(xfGun.position, gunLength, Color.white);
    156.  
    157.     for(int i = 0; i < 2; i++) {
    158.       var rads = (i == 0? pitchMax : pitchMin) * Mathf.Deg2Rad;
    159.       drawCircle(xfGun.position + gunLength * Mathf.Sin(rads) * xfBase.up, xfBase.up, gunLength * Mathf.Cos(rads), color);
    160.     }
    161.  
    162.     drawCircle(xfGun.position, xfBase.up, gunLength, Color.red); // horizon
    163.   }
    164.  
    165.   // draws a circle suspended in space from short linear segments
    166.   void drawCircle(Vector3 c, Vector3 normal, float radius, Color color, int steps = 24) {
    167.     Gizmos.color = color;
    168.     var ip = 180f / steps;
    169.     var radial = radius * getPerpTo(normal);
    170.     var last = Vector3.zero;
    171.     for(int i = 0; i <= steps; i++) {
    172.       var a = ip * (i << 1);
    173.       var p = c + Quaternion.AngleAxis(a, normal) * radial;
    174.       if(i > 0) Gizmos.DrawLine(last, p);
    175.       last = p;
    176.     }
    177.   }
    178.  
    179.   // finds a vector guaranteed to be perpendicular to unit
    180.   Vector3 getPerpTo(Vector3 unit) {
    181.     var orth = Vector3.Cross(unit, Vector3.forward);
    182.     if(unit.sqrMagnitude < (1f - Vector3.kEpsilon))
    183.       orth = Vector3.Cross(unit, Vector3.right);
    184.     return orth.normalized;
    185.   }
    186.  
    187.   void drawLine(Vector3 a, Vector3 b, Color color) {
    188.     Gizmos.color = color;
    189.     Gizmos.DrawLine(a, b);
    190.   }
    191.  
    192.   // a circle that's rendered to resemble a sphere from every angle
    193.   void drawBubble(Vector3 c, float radius, Color color) {
    194.     var cam = UnityEditor.SceneView.lastActiveSceneView.camera;
    195.     drawCircle(c, -cam.transform.forward, radius, color, 48);
    196.   }
    197.  
    198.   // unused
    199.   void drawWireSphere(Vector3 c, float radius, Color color) {
    200.     Gizmos.color = Color.white;
    201.     Gizmos.DrawWireSphere(xfGun.position, gunLength);
    202.   }
    203.  
    204. #endif
    205.  
    206. }

    This was a fun problem to solve. Enjoy.
     
    Last edited: Feb 21, 2022
  30. orionsyndrome

    orionsyndrome

    Joined:
    May 4, 2014
    Posts:
    2,173
    Please note that UnityEngine.Object doesn't play nice with C#
    null
    and null-related operators.

    This is why I had to get rid of all
    is
    tests (which are actually C# recommended) that I used before, among other things. This is also the reason behind this crazy line of code.
    Code (csharp):
    1. xfTarget = (target == null)? null : target.transform;
    The normal way you would write this in C# is
    Code (csharp):
    1. xfTarget = target?.transform; // ?. evaluates as null if target is null
    But, you see, target (being GameObject) isn't actually
    null
    even when it is, and loads of weird bugs will crop up.

    This solution also doesn't work for the similar reasons
    Code (csharp):
    1. if(!(target is null)) xfTarget = target.transform; // 'is' doesn't work with UnityEngine.Object
    Same goes for Components as well, xfTarget is
    Transform
    for example, and thus UnityEngine.Object again.

    Sigh. I don't know when Unity is going to fix this, but it's becoming potentially very dangerous with every iteration of C#. It really wasn't that much of a deal before.

    So as a takeaway: tests made with
    ==
    and
    !=
    are fine because these operators are actually internally handled by Unity to play consistently. Hence, that ludicrous line of code is the proper way to do this kind of thing.
     
    Last edited: Feb 21, 2022
  31. orionsyndrome

    orionsyndrome

    Joined:
    May 4, 2014
    Posts:
    2,173
    Of course I had to change the code :)
    I had to squash a couple of minor bugs and to make some improvements with the public interface.

    Turret "parking" didn't work as intended, so I fixed it, but other than that you can interrogate the turret whether it's ready to fire. Which is kinda important. However, it's not as easy as it sounds: stopping animation when the orientation is close enough is one thing, but you want to begin shooting when it's "good enough", not wait until it's perfect. So I had to introduce a measure of lateral distance as well. Full explanation in code.

    The point of these methods is to be able to call them from somewhere else in code, but you can also tweak this imprecision tolerance in the inspector to test it, and the gun gizmo will now blink when the gun is "close enough". This behavior can be turned off with the "Ready To Fire" checkbox.

    You can also try making several targets and switching between them.

    Code (csharp):
    1. using UnityEngine;
    2.  
    3. [ExecuteInEditMode]
    4. public class TurretBehaviour : MonoBehaviour {
    5.  
    6.   // 1) Add this script to an object that is the "base" of a turret; it works live in the editor
    7.   // 2) Make a child "gun" object and assign it; freely move this object relative to the "base", for example on Y
    8.   // 3) Make a "target" object and assign it; move it anywhere both in the scene and hierarchy; don't make it a child of "base"
    9.   // 4) Make sure to toggle 'Always Refresh' in the scene view or else animation frames will be skipped in the editor
    10.   // 5) Tweak parameters and move the "target" around to see it in action.
    11.   // 6) Turret's "base" can be further nested to some parent object; parent's transform will be respected (scaling is not supported though).
    12.  
    13.   [SerializeField] [Tooltip("Should be a child object")] GameObject gun;
    14.   [SerializeField] [Min(0f)] [Tooltip("Needed for visualizations")] float gunLength = .8f;
    15.   [SerializeField] GameObject target;
    16.   [SerializeField] bool showAimLine;
    17.   [SerializeField] bool showPitchLimits;
    18.   [SerializeField] [Range(-90f, 90f)] public float pitchMin = -5f;
    19.   [SerializeField] [Range(-90f, 90f)] public float pitchMax = 50f;
    20.   [SerializeField] [Min(0.1f)] public float baseAngularSpeed = 50f;
    21.   [SerializeField] [Min(0.1f)] public float gunAngularSpeed = 50f;
    22.   [SerializeField] [Tooltip("For visualizing firing tolerance")] bool readyToFire = true;
    23.   [SerializeField] [Min(0f)] [Tooltip("Tolerance for aiming imprecision")] float tolerance = .1f;
    24.  
    25.   // public stuff that can be used to query, clear, or replace the target
    26.   public GameObject Target => target;
    27.   public bool HasTarget => target != null;
    28.   public void SetTarget(GameObject target) => setTarget(target);
    29.   public void ClearTarget() => setTarget(null);
    30.  
    31.   // distances
    32.   public float DistanceToTargetSquared => targetDelta.sqrMagnitude;
    33.   public float DistanceToTarget => targetDelta.magnitude;
    34.   public float LateralAimingDistanceSquared => _lateralDistanceSqr;
    35.   public float LateralAimingDistance => Mathf.Sqrt(_lateralDistanceSqr);
    36.  
    37.   // status queries
    38.   public bool IsGunSliding => !_isValidPitch;
    39.   public bool IsTargetAimable => HasTarget && !IsGunSliding;
    40.   public bool IsTurretMoving => enabled && HasTarget && !onTarget();
    41.  
    42.   // this allows querying against an imprecision tolerance, see Update for more info
    43.   public bool IsReadyToFire(float tolerance = 2E-1f)
    44.     => enabled && HasTarget && (_lateralDistanceSqr <= tolerance * tolerance);
    45.  
    46.   // ongoing rotations in degrees
    47.   public float TurretYawDegrees => xfBase.localRotation.eulerAngles.y;
    48.   public float GunPitchDegrees => _currentPitch;
    49.  
    50.   // final rotations as quaternions
    51.   public Quaternion TowardsTarget => setGunRot * setBaseRot;
    52.   public Quaternion TowardsTargetYaw => setBaseRot;
    53.   public Quaternion TowardsTargetPitch => setGunRot;
    54.  
    55.   // class vars
    56.   readonly Quaternion _twist = Quaternion.Euler(0f, -90f, 0f);
    57.   readonly Quaternion _invTwist = Quaternion.Inverse(Quaternion.Euler(0f, -90f, 0f));
    58.   float _currentPitch, _lateralDistanceSqr;
    59.   bool _isValidPitch;
    60.  
    61.   // transforms are cached for better performance
    62.   Transform xfParent, xfBase, xfGun, xfTarget;
    63.  
    64.   // if there is a parent, its transform will be considered
    65.   Quaternion invertedWorldRotation => xfParent != null? Quaternion.Inverse(xfParent.rotation)
    66.                                                       : Quaternion.identity;
    67.   // differential vector
    68.   Vector3 targetDelta => xfTarget.position - xfGun.position;
    69.  
    70.   // destination orientations which the animation will pursue
    71.   Quaternion setBaseRot, setGunRot;
    72.  
    73.   void Start() {
    74.     xfBase = transform;
    75.     xfParent = transform.parent;
    76.     setTarget(target);
    77.  
    78.     if(gun != null) {
    79.       xfGun = gun.transform;
    80.     } else {
    81.       xfGun = null;
    82.       Debug.Log("Gun object not connected to script.");
    83.     }
    84.   }
    85.  
    86.   void setTarget(GameObject target) {
    87.     this.target = target;
    88.     xfTarget = (target == null)? null : target.transform;
    89.   }
    90.  
    91.   void Update() {
    92.     // base is not supposed to be moved locally, move its parent instead
    93.     xfBase.localPosition = Vector3.zero;
    94.  
    95.     // bail out if there's no gun assigned
    96.     if(xfGun == null) return;
    97.  
    98.     // the aim cannot be accomplished if there's no target
    99.     if(xfTarget == null) {
    100.       setBaseRot = _twist;
    101.       setGunRot = _invTwist;
    102.  
    103.     } else { // if there's a target...
    104.       // target direction is used to find the compound rotation
    105.       var tdir = invertedWorldRotation * targetDelta; // apply the inverse of the parent's orientation, so that turret can tumble in space
    106.       var pdir = new Vector3(tdir.x, 0f, tdir.z); // target direction is now projected to XZ plane, to diminish all rotations but yaw
    107.  
    108.       // we take extra care there are no rolls involved in the local rotations
    109.       // and here the directions get normalized before use
    110.       var bq = _twist * Quaternion.LookRotation(pdir.normalized, Vector3.up); // base rotation (yaw)
    111.       var gq = Quaternion.Inverse(bq) * Quaternion.LookRotation(tdir.normalized, Vector3.up); // gun rotation (pitch)
    112.  
    113.       // we use eulerAngles to read back angles, but the values are messy
    114.       // a transformation is needed in order to set pitch to -90..+90 interval
    115.       // where 0 is aiming toward the horizon, +90 is aiming up, and -90 down
    116.       _currentPitch = 180f - mod(gq.eulerAngles.x + 180f, 360f);
    117.       _isValidPitch = _currentPitch >= pitchMin && _currentPitch <= pitchMax;
    118.  
    119.       // if pitch is off the limits, clamp it back
    120.       if(!_isValidPitch) {
    121.         _currentPitch = -Mathf.Clamp(_currentPitch, pitchMin, pitchMax); // it's easy to convert the pitch back
    122.         gq = Quaternion.Euler(_currentPitch, 90f, 0f);
    123.       }
    124.  
    125.       setBaseRot = bq;
    126.       setGunRot = gq;
    127.  
    128.     }
    129.  
    130.     // don't let this confuse you: mod is a local function (a function within a function)
    131.     float mod(float v, float m) => (v %= m) < 0f? v + m : v;
    132.  
    133.     // this will progressively animate the two rotations using set speeds
    134.     // this is guarded by a cheap test whether the final orientation was achieved or not
    135.     if(!onTarget()) {
    136.       xfBase.localRotation = Quaternion.RotateTowards(xfBase.localRotation, setBaseRot, baseAngularSpeed * Time.deltaTime);
    137.        xfGun.localRotation = Quaternion.RotateTowards( xfGun.localRotation, setGunRot,  gunAngularSpeed  * Time.deltaTime);
    138.  
    139.       // here we compute the lateral distance from the target
    140.       // this is the distance measured relative to the aiming line when you're looking down the gun's sight
    141.       // we need this to introduce a tolerance, so you don't have to aim >>exactly<< at the target before you can shoot
    142.       // instead we make this vague circular area surrounding the target, allowing us to define a close enough aim
    143.       // because we will only compare the distances, we can keep the squared result, to avoid having to take the root
    144.       // apart from this one quaternion multiplication (which isn't that bad) the rest of this is extremely efficient
    145.       _lateralDistanceSqr = target != null? sqrDistanceFromLine(xfGun.position, xfGun.rotation * Vector3.forward, xfTarget.position)
    146.                                           : 0f;
    147.     }
    148.  
    149.   }
    150.  
    151.   // no longer a local function, because we need onTarget test elsewhere
    152.   bool quatsMatch(Quaternion q1, Quaternion q2, float epsilon = Quaternion.kEpsilon)
    153.     => Quaternion.Dot(q1, q2) - 1f > -epsilon; // true only when dot approximates +1
    154.  
    155.   // whether the gun is aiming at the target right now or not
    156.   bool onTarget() => quatsMatch(xfBase.localRotation, setBaseRot) && quatsMatch(xfGun.localRotation, setGunRot);
    157.  
    158.   float sqrDistanceFromLine(Vector3 ro, Vector3 rd, Vector3 p)
    159.     => (closestPointOnLine(ro, rd, p) - p).sqrMagnitude;
    160.  
    161.   // here's some more reusable stuff
    162.   // ro and rd are ray origin and ray direction respectively, but we treat the result as a line
    163.   // a line can be thought of as an intersection of two planes, so we abuse the plane math here
    164.   // this is incredibly inexpensive btw: only adding and simple multiplications (dot products)
    165.   Vector3 closestPointOnLine(Vector3 ro, Vector3 rd, Vector3 p) {
    166.     var bp = new Plane(ro, ro + rd, p); // planes can be defined via 3 distinct points in space
    167.     var lp = new Plane(ro, ro + rd, ro + bp.normal);
    168.     return lp.ClosestPointOnPlane(bp.ClosestPointOnPlane(p));
    169.   }
    170.  
    171.   void Reset() => Start();
    172.  
    173. // this block of code is valid only in the editor, for gizmos and such
    174. #if UNITY_EDITOR
    175.  
    176.   // this triggers if something changes in the inspector
    177.   void OnValidate() => Start();
    178.  
    179.   // draws gizmos in the scene
    180.   void OnDrawGizmos() {
    181.     // this lets Update() get called continuously in the editor
    182.     UnityEditor.EditorApplication.QueuePlayerLoopUpdate();
    183.  
    184.     if(xfGun == null) return;
    185.     drawBaseLine(Color.red);
    186.     drawGunLine(determineGunLineColor());
    187.  
    188.     if(showPitchLimits) drawPitchLimits(Color.magenta);
    189.  
    190.     if(xfTarget == null) return;
    191.     if(showAimLine) drawAimLine(Color.cyan);
    192.   }
    193.  
    194.   Color determineGunLineColor() {
    195.     const float BLINK_RATE = 3f; // per second
    196.     var glc = Color.magenta;
    197.  
    198.     if(_isValidPitch) {
    199.       if(readyToFire && IsReadyToFire(tolerance)) {
    200.         glc = (int)(Time.realtimeSinceStartup * BLINK_RATE) % 2 == 0? Color.white : Color.black;
    201.       } else {
    202.         glc = Color.yellow;
    203.       }
    204.     }
    205.  
    206.     return glc;
    207.   }
    208.  
    209.   void drawBaseLine(Color color) => drawLine(xfBase.position, xfGun.position, color);
    210.   void drawGunLine(Color color) => drawLine(xfGun.position, gunNozzlePos, color);
    211.   void drawAimLine(Color color) => drawLine(xfGun.position, xfTarget.position, color);
    212.  
    213.   // used for visualizations
    214.   Vector3 gunNozzlePos => xfGun.position + xfGun.rotation * (gunLength * Vector3.forward);
    215.  
    216.   // a fancy routine that draws pitch limit circles as if they were spherical slices
    217.   void drawPitchLimits(Color color) {
    218.     drawBubble(xfGun.position, gunLength, Color.white);
    219.  
    220.     for(int i = 0; i < 2; i++) {
    221.       var rads = (i == 0? pitchMax : pitchMin) * Mathf.Deg2Rad;
    222.       drawCircle(xfGun.position + gunLength * Mathf.Sin(rads) * xfBase.up, xfBase.up, gunLength * Mathf.Cos(rads), color);
    223.     }
    224.  
    225.     drawCircle(xfGun.position, xfBase.up, gunLength, Color.red); // horizon
    226.   }
    227.  
    228.   // draws a circle suspended in space from short linear segments
    229.   void drawCircle(Vector3 c, Vector3 normal, float radius, Color color, int steps = 24) {
    230.     Gizmos.color = color;
    231.     var ip = 180f / steps;
    232.     var radial = radius * getPerpTo(normal);
    233.     var last = Vector3.zero;
    234.     for(int i = 0; i <= steps; i++) {
    235.       var a = ip * (i << 1);
    236.       var p = c + Quaternion.AngleAxis(a, normal) * radial;
    237.       if(i > 0) Gizmos.DrawLine(last, p);
    238.       last = p;
    239.     }
    240.   }
    241.  
    242.   // finds a vector guaranteed to be perpendicular to unit
    243.   Vector3 getPerpTo(Vector3 unit) {
    244.     var orth = Vector3.Cross(unit, Vector3.forward);
    245.     if(unit.sqrMagnitude < (1f - Vector3.kEpsilon))
    246.       orth = Vector3.Cross(unit, Vector3.right);
    247.     return orth.normalized;
    248.   }
    249.  
    250.   void drawLine(Vector3 a, Vector3 b, Color color) {
    251.     Gizmos.color = color;
    252.     Gizmos.DrawLine(a, b);
    253.   }
    254.  
    255.   // a circle that's rendered to resemble a sphere from every angle
    256.   void drawBubble(Vector3 c, float radius, Color color) {
    257.     var cam = UnityEditor.SceneView.lastActiveSceneView.camera;
    258.     drawCircle(c, -cam.transform.forward, radius, color, 48);
    259.   }
    260.  
    261.   // unused
    262.   void drawWireSphere(Vector3 c, float radius, Color color) {
    263.     Gizmos.color = Color.white;
    264.     Gizmos.DrawWireSphere(xfGun.position, gunLength);
    265.   }
    266.  
    267. #endif
    268.  
    269. }


    I will at some point also try to implement a proper slerping animation, to try and simulate a more agile mechanism with less jerkiness and more "organic" motion, but that's a bonus.
     
    alexeu likes this.
  32. alexeu

    alexeu

    Joined:
    Jan 24, 2016
    Posts:
    257
    Interesting and useful. Thanks Orion :)
     
  33. orionsyndrome

    orionsyndrome

    Joined:
    May 4, 2014
    Posts:
    2,173
    Hey I thought this might be useful. I remember seeing the rail guns and solar panels in Dyson Sphere Program and thinking how neatly they were made, especially considering the orbital motion and tilted planetary rotations and everything.

    You're welcome.
     
  34. orionsyndrome

    orionsyndrome

    Joined:
    May 4, 2014
    Posts:
    2,173
    This is just to amend the last code slightly, before I finish the next iteration.
    There is a small bug in the lines 119-123, it should go like this
    Code (csharp):
    1. // clamp the pitch if it's off limits
    2. if(!_isValidPitch) {
    3.   _currentPitch = Mathf.Clamp(_currentPitch, pitchMin, pitchMax);
    4.   gq = Quaternion.Euler(-_currentPitch, 90f, 0f); // it's easy to convert the pitch back
    5. }
    Previously it would flip the sign permanently, which wouldn't change the behavior, but would return the wrong value in line 48 (GunPitchDegrees).
     
  35. asdfasdf234

    asdfasdf234

    Joined:
    Nov 27, 2015
    Posts:
    70
    Another thing that could be useful is clamping yaw rotation.
    Code (CSharp):
    1.   [SerializeField] [Range(-180f, 180f)] public float yawMin = -40f;
    2.   [SerializeField] [Range(-180f, 180f)] public float yawMax = 40f;
    3.   float _currentYaw;
    4.   bool _isValidYaw;
    5.  
    6.  
    7.       _currentYaw = 180f - mod(bq.eulerAngles.y + 180f, 360f);
    8.       _isValidYaw = _currentYaw >= yawMin && _currentYaw <= yawMax;
    9.       if(!_isValidYaw) {
    10.         _currentYaw = Mathf.Clamp(_currentYaw, yawMin, yawMax);
    11.         bq = Quaternion.Euler(0f, -_currentYaw, 0f);
    12.       }
    Above code works unless you want a different orientation, which I haven't been able to figure out where to add the offset. I tried putting offset in a bunch of variables but they all result in the same weird behaviour. (Need some kind of offset mostly because turrets 0,0,0 local-rotation isn't facing forward in world-space)

    Edit: Found the problem I think ;). Guess one solution make empty parent and rotate it to compensate for this

    Also is there a reason why not just use Lerp or Slerp?
     
    Last edited: Feb 22, 2022
  36. emkay4597

    emkay4597

    Joined:
    Sep 16, 2013
    Posts:
    14
    Thank you for that
    orionsyndrome. quite helpful.
     
  37. orionsyndrome

    orionsyndrome

    Joined:
    May 4, 2014
    Posts:
    2,173
    I know. But that requires some forethought I think. I haven't planned for it from the get go and now I think that has to be v2.

    You can't do that the way you tried. It's more delicate than that.

    No, no reason at all. This is what I meant to do at some point. But I'd like to make it a bit more fancy than just slerping. We'll see if I have time.
     
  38. orionsyndrome

    orionsyndrome

    Joined:
    May 4, 2014
    Posts:
    2,173
    I have noticed an error in line 168, function closestPointOnLine. Well, it's not an error but a redundancy.
    Can you notice it? It's really stupid.
    Code (csharp):
    1. Vector3 closestPointOnLine(Vector3 ro, Vector3 rd, Vector3 p) {
    2.   var bp = new Plane(ro, ro + rd, p);
    3.   var lp = new Plane(ro, ro + rd, ro + bp.normal);
    4.   return lp.ClosestPointOnPlane(bp.ClosestPointOnPlane(p));
    5. }
    Yes,
    bp.ClosestPointOnPlane(p)
    is completely redundant. OBVIOUSLY the point p is contained by the plane bp, I've used it in bp's definition :) So the line should be simply
    Code (csharp):
    1. return lp.ClosestPointOnPlane(p);
    This happened because I've lowered(*) some of my other code to accomplish this in a compact way for this particular purpose. (* lowering is the act of translating high-level code into low-level representation.)

    But now that I have noticed it's less than perfect, it made me think about lowering it further (we don't need no formal
    Plane
    structs, even though it's plane math) because I lied when I said it's just dot products. It's not. It's actually two crosses and one dot. Which is still nothing to fret about. A dot in 3D is just 3 multiplications, while a cross is 6.

    Anyway, I thought to make a little "tour" and explain what's going on here, why I'm doing this and how exactly does this yield the closest point on a line. I can also break down dots and crosses to something less opaque if someone wants to learn more, because there are certain crossovers (I don't know if pun was intended, maybe) with something known as a perpdot in 2D, and a cross is a natural extension of that (let's stay away from Clifford algebra and keep it agnostic).

    Perpdots are a fun twist on 2D dots, weird but fun. The concept is very well understood by many people, I don't claim there is anything revolutionary about it, I just wish these things were more transparent to newbies in Unity.

    Anyone interested? It shouldn't be lengthier than a single post with a picture. We'll cover directions, rays, planes, dots, crosses, orthonormals, and computing a distance to a plane. Basically all the ingredients needed so you can figure these things on your own.

    (I will later return to an updated version of this code above; I've made some fixes/changes already, but I want to also add a visualization for the "ready to hit" tolerance.)
     
  39. orionsyndrome

    orionsyndrome

    Joined:
    May 4, 2014
    Posts:
    2,173
    This is just a natural progression to set things in place before I'm ready to tackle the various modes of animation.
    Can't exactly name everything I did, but these are the major changes that I can think of:

    - additional/improved visualizations including aiming cones and aiming reticles
    - parking mode (turret will park if this is set to true and there is no target, then the script auto-disables)
    - improved public interface with xml doc comments
    - events
    - better computation of lateral distance (unfortunately the math required normalization)
    - tweaks and fixes

    There are three types of events: 1) target setting/clearing, 2) ready to fire, 3) cease fire.
    There is DispatchEvents property which can be turned off (in code). In that case, the script must be interrogated manually (by calling IsReadyToFire). If set to true, however, the events can be used to guide the rest of the behavior.

    All private serialized fields are for use in design-mode, not in runtime. Thus lateral tolerance can be set off (considerCone = false), and if that's the case, 'ready to fire' event should dispatch when the turret stops moving.

    (Edit: new version in post #43)
    Code (csharp):
    1. using UnityEngine;
    2.  
    3. [ExecuteInEditMode]
    4. public class TurretBehaviour : MonoBehaviour {
    5.  
    6.   // 1) Add this script to an object that is the "base" of a turret; it works live in the editor
    7.   // 2) Make a child "gun" object and assign it; freely move this object relative to the "base", for example on Y
    8.   // 3) Make a "target" object and assign it; move it anywhere both in the scene and hierarchy; don't make it a child of "base"
    9.   // 4) Make sure to toggle 'Always Refresh' in the scene view or else animation frames will be skipped in the editor
    10.   // 5) Tweak parameters and move the "target" around to see it in action.
    11.   // 6) Turret's "base" can be further nested to some parent object; parent's transform will be respected (scaling is not supported though).
    12.  
    13.   [Header("Objects")]
    14.   [SerializeField] [Tooltip("Should be a child object")] GameObject gun;
    15.   [SerializeField] GameObject target;
    16.   [Header("Visualization")]
    17.   [SerializeField] [Min(0f)] [Tooltip("Needed for visualizations")] float gunLength = 1f;
    18.   [SerializeField] bool showAimDots;
    19.   [SerializeField] bool showPitchLimits;
    20.   [SerializeField] bool showGunReticles;
    21.   [Header("Pitch Limits")]
    22.   [SerializeField] [Range(-90f, 90f)] public float pitchMin = -5f;
    23.   [SerializeField] [Range(-90f, 90f)] public float pitchMax = 50f;
    24.   [Header("Operation")]
    25.   [SerializeField] bool parkSetting;
    26.   [SerializeField] [Min(0.1f)] public float baseAngVelocity = 50f;
    27.   [SerializeField] [Min(0.1f)] public float gunAngVelocity = 50f;
    28.   [Header("Aim Cone")]
    29.   [SerializeField] [Tooltip("Firing tolerance; will blink when ready to fire")] bool considerCone = true;
    30.   [SerializeField] bool showTolerance;
    31.   [SerializeField] [Min(0f)] [Tooltip("Aiming tolerance; for editor testing only")] float aimTolerance = .1f;
    32.  
    33.   private const float QRTR_DEGS =  90f;
    34.   private const float HALF_DEGS = 180f;
    35.   private const float FULL_DEGS = 360f;
    36.  
    37.   /// <summary>Returns or sets the currently selected target. Will clear target if set to null.
    38.   /// (If <seealso cref="ParkMode"/> is set to true, clearing target will park the turret.)</summary>
    39.   public GameObject Target {
    40.     get => target;
    41.     set => setTarget(value);
    42.   }
    43.  
    44.   /// <summary>Returns true if a target was set, otherwise false.</summary>
    45.   public bool HasTarget => target != null;
    46.  
    47.   /// <summary>If set to true, parks the turret automatically if there's no target selected.
    48.   /// Parking will bring the turret to its default pose, then auto-disable the script.</summary>
    49.   public bool ParkMode { get; set; } = false;
    50.  
    51.   /// <summary>If set to true, will automatically dispatch events.</summary>
    52.   public bool DispatchEvents { get; set; } = true;
    53.  
    54.   /// <summary>Forces immediate parking of the turret. Target will be cleared.</summary>
    55.   public void Park() { ParkMode = true; Target = null; }
    56.  
    57.   public float DistanceToTargetSquared => targetDelta.sqrMagnitude;
    58.   public float DistanceToTarget => targetDelta.magnitude;
    59.   public float LateralAimingDistanceSquared => _lateralDistanceSqr;
    60.   public float LateralAimingDistance => Mathf.Sqrt(_lateralDistanceSqr);
    61.  
    62.   /// <summary>Returns true if the turret's gun is at its pitch limit, false otherwise.</summary>
    63.   public bool IsGunSliding => !_isValidPitch;
    64.  
    65.   /// <summary>Returns true if a target was set *and* the gun has not yet hit the pitch limit, false otherwise.</summary>
    66.   public bool IsTargetAimable => HasTarget && !IsGunSliding;
    67.  
    68.   /// <summary>Returns true if any part of the turret is currently moving, false otherwise.</summary>
    69.   public bool IsTurretMoving => enabled && HasTarget && !isFacingTarget();
    70.  
    71.   /// <summary>Allows checking for the lateral aim tolerance from the target. Returns true when the gun is ready, false otherwise.</summary>
    72.   public bool IsReadyToFire(float tolerance = 2E-1f)
    73.     => considerCone? enabled && HasTarget && (_lateralDistanceSqr <= tolerance * tolerance)
    74.                    : !IsTurretMoving;
    75.  
    76.   /// <summary>Returns the local yaw rotation of the turret's base, in degrees (0-360).</summary>
    77.   public float TurretYawDegrees => angMod(xfBase.localRotation.eulerAngles.y);
    78.  
    79.   /// <summary>Returns the local pitch rotation of the turret's gun, in degrees. Aiming toward the horizon returns 0, aiming up 90, and aiming down -90.</summary>
    80.   public float GunPitchDegrees => _currentPitch;
    81.  
    82.   public delegate void ReadyHandler(TurretBehaviour sender, GameObject target, float longitudal, float lateral);
    83.   public delegate void TargetHandler(TurretBehaviour sender, GameObject target);
    84.  
    85.   bool _firingState;
    86.  
    87.   /// <summary>Will dispatch when gun is ready to fire. Provides both longitudal and lateral distance to target.</summary>
    88.   public event ReadyHandler OnReadyToFire;
    89.  
    90.   /// <summary>Will dispatch when gun should cease firing.</summary>
    91.   public event TargetHandler OnCeaseFire;
    92.  
    93.   /// <summary>Will dispatch whenever the target is set or cleared. (Only works when target is changed via code.)</summary>
    94.   public event TargetHandler OnTargetChange;
    95.  
    96.   // set quaternion rotations
    97.   public Quaternion TowardsTarget => setGunRot * setBaseRot;
    98.   public Quaternion TowardsTargetYaw => setBaseRot;
    99.   public Quaternion TowardsTargetPitch => setGunRot;
    100.  
    101.   // class vars
    102.   readonly Quaternion _twist = Quaternion.Euler(0f, -QRTR_DEGS, 0f);
    103.   readonly Quaternion _invTwist = Quaternion.Inverse(Quaternion.Euler(0f, -QRTR_DEGS, 0f));
    104.   float _currentPitch, _lateralDistanceSqr;
    105.   bool _isValidPitch;
    106.  
    107.   // transforms are cached for better performance
    108.   Transform xfParent, xfBase, xfGun, xfTarget;
    109.  
    110.   // if there is a parent, its rotation will be considered
    111.   Quaternion invertedWorldRotation => xfParent != null? Quaternion.Inverse(xfParent.rotation)
    112.                                                       : Quaternion.identity;
    113.  
    114.   // destination orientations which the animation will pursue
    115.   Quaternion setBaseRot, setGunRot;
    116.  
    117.   void Start() {
    118.     xfBase = transform;
    119.     xfParent = transform.parent;
    120.     setTarget(target);
    121.  
    122.     if(gun != null) {
    123.       xfGun = gun.transform;
    124.     } else {
    125.       xfGun = null;
    126.       Debug.Log("ERROR: Gun object not set.");
    127.     }
    128.  
    129.     ParkMode = parkSetting;
    130.   }
    131.  
    132.   void OnDisable() {
    133.     // I'm absolutely positive that handling this is a requirement, but I need to test more
    134.   }
    135.  
    136.   // differential vector
    137.   Vector3 targetDelta => xfTarget.position - xfGun.position;
    138.  
    139.   // target setter
    140.   void setTarget(GameObject target) {
    141.     xfTarget = (target == null)? null : target.transform;
    142.  
    143.     if(DispatchEvents) {
    144.       OnTargetChange?.Invoke(this, target);
    145.       Debug.Log("Target has changed");
    146.     }
    147.  
    148.     if(target == null) _firingState = false;
    149.     dispatchFiringEvents();
    150.   }
    151.  
    152.   void Update() {
    153.     // base is not supposed to be moved locally, move its parent instead
    154.     xfBase.localPosition = Vector3.zero;
    155.  
    156.     // bail out if there's no gun assigned
    157.     if(xfGun == null) return;
    158.  
    159.     // the aim cannot be accomplished if there's no target
    160.     if(!HasTarget) {
    161.  
    162.       if(ParkMode) { // auto-park
    163.         setBaseRot = _twist;
    164.         setGunRot = _invTwist;
    165.       }
    166.  
    167.     } else { // if there's a target...
    168.  
    169.       // target direction is used to find the compound rotation
    170.       var tdir = invertedWorldRotation * targetDelta; // apply the inverse of the parent's orientation, to allow for arbitrary environmental rotations
    171.       var pdir = new Vector3(tdir.x, 0f, tdir.z); // target direction is now projected to XZ plane, to diminish all local rotations but yaw
    172.  
    173.       // we take extra care there are no rolls involved in the local rotations
    174.       // and here the directions get normalized before use
    175.       var bq = _twist * Quaternion.LookRotation(pdir.normalized, Vector3.up); // base rotation (yaw)
    176.       var gq = Quaternion.Inverse(bq) * Quaternion.LookRotation(tdir.normalized, Vector3.up); // gun rotation (pitch)
    177.  
    178.       // we use eulerAngles to read back angles, but the values are messy
    179.       // a transformation is needed in order to set pitch to -90..+90 interval
    180.       // where 0 is aiming toward the horizon, +90 is aiming up, and -90 down
    181.       _currentPitch = HALF_DEGS - angMod(gq.eulerAngles.x + HALF_DEGS);
    182.       _isValidPitch = _currentPitch >= pitchMin && _currentPitch <= pitchMax;
    183.  
    184.       // clamp the pitch if it's off limits
    185.       if(!_isValidPitch) {
    186.         _currentPitch = Mathf.Clamp(_currentPitch, pitchMin, pitchMax);
    187.         gq = Quaternion.Euler(-_currentPitch, QRTR_DEGS, 0f); // it's easy to convert the pitch back
    188.       }
    189.  
    190.       // assign desired rotations
    191.       setBaseRot = bq;
    192.       setGunRot = gq;
    193.  
    194.     }
    195.  
    196.     // this will progressively animate the two rotations using set angular velocities
    197.     // this is guarded by a cheap test whether the final orientations were achieved or not
    198.     if(!isFacingTarget()) {
    199.       xfBase.localRotation = Quaternion.RotateTowards(xfBase.localRotation, setBaseRot, baseAngVelocity * Time.deltaTime);
    200.        xfGun.localRotation = Quaternion.RotateTowards( xfGun.localRotation, setGunRot,  gunAngVelocity  * Time.deltaTime);
    201.  
    202.       // compute the lateral distance from target
    203.       _lateralDistanceSqr = HasTarget? sqrDistanceFromLine(xfGun.position, xfGun.forward, xfTarget.position)
    204.                                      : 0f;
    205.  
    206.       if(HasTarget && DispatchEvents) dispatchFiringEvents();
    207.  
    208.     } else {
    209.       if(!HasTarget && ParkMode) enabled = false;
    210.  
    211.     }
    212.  
    213.   }
    214.  
    215.   void dispatchFiringEvents() {
    216.     if(!_firingState && IsReadyToFire(aimTolerance)) {
    217.       OnReadyToFire?.Invoke(this, target, DistanceToTarget, LateralAimingDistance);
    218.       Debug.Log($"Ready to fire at target {target.name} at distance {DistanceToTarget:F1} / lateral: {LateralAimingDistance:F3}");
    219.       _firingState = true;
    220.  
    221.     } else if(_firingState && !IsReadyToFire(aimTolerance)) {
    222.       OnCeaseFire?.Invoke(this, target);
    223.       Debug.Log("Cease fire");
    224.       _firingState = false;
    225.  
    226.     }
    227.   }
    228.  
    229.   // true 360 modulo
    230.   float angMod(float v) => (v %= FULL_DEGS) < 0f? v + FULL_DEGS : v;
    231.  
    232.   bool quatsMatch(Quaternion q1, Quaternion q2, float epsilon = Quaternion.kEpsilon)
    233.     => Quaternion.Dot(q1, q2) - 1f > -epsilon; // true when dot approximates +1
    234.  
    235.   // whether the gun is aiming at the target right now or not
    236.   bool isFacingTarget() => quatsMatch(xfBase.localRotation, setBaseRot) && quatsMatch(xfGun.localRotation, setGunRot);
    237.  
    238.   Vector3 _cpol; // used in visualizations
    239.  
    240.   float sqrDistanceFromLine(Vector3 ro, Vector3 rd, Vector3 p) {
    241.     if(!closestPointOnLine(ro, rd, p, out _cpol)) return float.PositiveInfinity;
    242.     return (_cpol - p).sqrMagnitude;
    243.   }
    244.  
    245.   // ro and rd are ray origin and ray direction respectively, but we treat the result as a line
    246.   // a line can be thought of as an intersection of two orthogonal planes
    247.   bool closestPointOnLine(Vector3 ro, Vector3 rd, Vector3 p, out Vector3 result) {
    248.     var ro2p = p - ro;
    249.     var n2 = Vector3.Cross(rd, Vector3.Cross(rd, ro2p)).normalized;
    250.     result = p + Vector3.Dot(-ro2p, n2) * n2;
    251.  
    252.     // make sure we can differentiate between the gun's front and rear
    253.     if(Vector3.Dot(result, xfGun.forward) < 0f) {
    254.       result = ro;
    255.       return false;
    256.     }
    257.  
    258.     return true;
    259.  
    260.     // var bp = new Plane(ro, ro+rd, p);
    261.     // var lp = new Plane(ro, ro+rd, ro + bp.normal);
    262.     // return lp.ClosestPointOnPlane(p);
    263.   }
    264.  
    265.   void Reset() => Start();
    266.  
    267. // this block of code is valid only in the editor, for gizmos and such
    268. #if UNITY_EDITOR
    269.  
    270.   void OnValidate() => Start();
    271.  
    272.   void OnDrawGizmos() {
    273.     // this lets Update() get called continuously in the editor
    274.     UnityEditor.EditorApplication.QueuePlayerLoopUpdate();
    275.  
    276.     if(xfGun == null) return;
    277.     drawBaseLine(Color.red);
    278.     var glc = determineGunLineColor();
    279.     drawGunLine(glc);
    280.  
    281.     if(showPitchLimits) drawPitchLimits(Color.magenta);
    282.  
    283.     if(xfTarget == null) return;
    284.     if(showAimDots) drawAimDots(glc);
    285.     if(showGunReticles) drawGunReticles();
    286.     if(showTolerance) drawAimingCone(Color.cyan);
    287.   }
    288.  
    289.   Color determineGunLineColor() {
    290.     const float BLINK_RATE = 3f; // per second
    291.     var glc = Color.magenta;
    292.  
    293.     if(_isValidPitch) {
    294.       if(considerCone && IsReadyToFire(aimTolerance)) {
    295.         glc = ((int)(Time.realtimeSinceStartup * BLINK_RATE) & 1) == 0? Color.white : Color.black;
    296.       } else {
    297.         glc = Color.yellow;
    298.       }
    299.     }
    300.  
    301.     return glc;
    302.   }
    303.  
    304.   void drawBaseLine(Color color) => drawLine(xfBase.position, xfGun.position, color);
    305.   void drawGunLine(Color color) => drawLine(xfGun.position, gunNozzlePos, color);
    306.  
    307.   void drawAimDots(Color color) {
    308.     Gizmos.color = color;
    309.     var rd = targetDelta.normalized;
    310.     var l = targetDelta.magnitude;
    311.     var p = xfGun.position;
    312.     var t = Time.realtimeSinceStartup % 1f;
    313.     for(float d = 0f; d < l; d++) Gizmos.DrawSphere(p + Mathf.Min(l, d + t) * rd, .03f);
    314.   }
    315.  
    316.   Texture _reticle1, _reticle2;
    317.  
    318.   void drawGunReticles() {
    319.     if(_reticle1 is null) {
    320.       _reticle1 = UnityEditor.EditorGUIUtility.IconContent("d_curvekeyframe").image;
    321.       _reticle2 = UnityEditor.EditorGUIUtility.IconContent("d_curvekeyframeweighted").image;
    322.     }
    323.  
    324.     var cam = getSceneCamera();
    325.  
    326.     // reticle #1 is projected on the sphere at the target's distance
    327.     var p1 = getADSPoint(DistanceToTarget);
    328.     drawReticle(cam, _reticle1, p1);
    329.  
    330.     // reticle #2 is rendered on the aim line at the point closest to the target
    331.     var p2 = _cpol;
    332.     drawReticle(cam, _reticle2, p2);
    333.  
    334.     // reticle connector
    335.     Gizmos.color = Color.red;
    336.     Gizmos.DrawLine(p1, p2);
    337.  
    338.     // green proximity connector, to indicate a lateral distance that is within tolerance
    339.     if(considerCone && IsReadyToFire(aimTolerance)) {
    340.       Gizmos.color = Color.green;
    341.       Gizmos.DrawLine(xfTarget.position, _cpol);
    342.     }
    343.   }
    344.  
    345.   void drawReticle(Camera cam, Texture tex, Vector3 pos) {
    346.     var p = cam.WorldToScreenPoint(pos);
    347.     p -= new Vector3((_reticle1.width >> 1) - 1.5f, .5f - (_reticle1.height >> 1), 0f); // screen-space offset
    348.     UnityEditor.Handles.Label(cam.ScreenToWorldPoint(p), tex);
    349.   }
    350.  
    351.   // used for visualizations
    352.   Vector3 gunNozzlePos => getADSPoint(gunLength);
    353.   Vector3 getADSPoint(float distance) => xfGun.position + distance * xfGun.forward;
    354.  
    355.   // treats pitch limits as spherical slices
    356.   void drawPitchLimits(Color color) {
    357.     drawBubble(xfGun.position, gunLength, Color.white);
    358.  
    359.     for(int i = 0; i < 2; i++) {
    360.       var rads = (i == 0? pitchMax : pitchMin) * Mathf.Deg2Rad;
    361.       drawCircle(xfGun.position + gunLength * Mathf.Sin(rads) * xfBase.up, xfBase.up, gunLength * Mathf.Cos(rads), color);
    362.     }
    363.  
    364.     drawCircle(xfGun.position, xfBase.up, gunLength, Color.red); // horizon
    365.   }
    366.  
    367.   void drawAimingCone(Color color)
    368.     => drawCone(xfTarget.position, aimTolerance, xfGun.position, color);
    369.  
    370.   // draws a rotating faux cone (a circle with added slant height lines)
    371.   void drawCone(Vector3 baseVertex, float baseRadius, Vector3 tipVertex, Color color, int slantSteps = 4, int steps = 24) {
    372.     Gizmos.color = color;
    373.     var normal = (tipVertex - baseVertex).normalized;
    374.     var astep = FULL_DEGS / steps;
    375.     var radial = baseRadius * getPerpTo(normal);
    376.     var last = Vector3.zero;
    377.     var aoff = (Time.realtimeSinceStartup % 4f) * .25f * slantSteps * astep;
    378.     for(int i = 0; i <= steps; i++) {
    379.       var p = baseVertex + Quaternion.AngleAxis(aoff + astep * i, normal) * radial;
    380.       if(i > 0) Gizmos.DrawLine(last, p);
    381.       if(i % slantSteps == 0) Gizmos.DrawLine(tipVertex, p);
    382.       last = p;
    383.     }
    384.   }
    385.  
    386.   // draws a circle that's arbitrarily oriented in space, made out of short linear segments
    387.   void drawCircle(Vector3 center, Vector3 normal, float radius, Color color, int steps = 24) {
    388.     Gizmos.color = color;
    389.     var astep = FULL_DEGS / steps;
    390.     var radial = radius * getPerpTo(normal);
    391.     var last = Vector3.zero;
    392.     for(int i = 0; i <= steps; i++) {
    393.       var p = center + Quaternion.AngleAxis(astep * i, normal) * radial;
    394.       if(i > 0) Gizmos.DrawLine(last, p);
    395.       last = p;
    396.     }
    397.   }
    398.  
    399.   // finds a vector guaranteed to be perpendicular to some unit vector
    400.   Vector3 getPerpTo(Vector3 unitVec) {
    401.     var orth = Vector3.Cross(unitVec, Vector3.forward);
    402.     if(unitVec.sqrMagnitude < (1f - Vector3.kEpsilon)) // failsafe
    403.       orth = Vector3.Cross(unitVec, Vector3.right);
    404.     return orth.normalized;
    405.   }
    406.  
    407.   void drawLine(Vector3 a, Vector3 b, Color color) {
    408.     Gizmos.color = color;
    409.     Gizmos.DrawLine(a, b);
    410.   }
    411.  
    412.   // a circle that's rendered in such a way to resemble a sphere from every viewing angle
    413.   // it's just an approximation, doesn't work well when object is near camera due to perspective distortion
    414.   void drawBubble(Vector3 c, float radius, Color color)
    415.     => drawCircle(c, -getSceneCamera().transform.forward, radius, color, 48);
    416.  
    417.   Camera getSceneCamera() => UnityEditor.SceneView.lastActiveSceneView.camera;
    418.  
    419. #endif
    420.  
    421. }

    There are probably some bugs and many small logical improvements still to be implemented. But I wanted to make sure the codebase is solid before moving on to other types of animation. Namely I want to experiment with nlerp (not slerp) and some other approaches I came up with in the meantime.
     
    Last edited: Feb 25, 2022
  40. asdfasdf234

    asdfasdf234

    Joined:
    Nov 27, 2015
    Posts:
    70
    Amazing, thank you. Clean and thoroughly documented as always!

    Small bug: In start method. Had to move "setTarget(target)" below "if(gun != null) {}"
    Code (CSharp):
    1.   void Start() {
    2.     xfBase = transform;
    3.     xfParent = transform.parent;
    4.  
    5.     if(gun != null) {
    6.       xfGun = gun.transform;
    7.     } else {
    8.       xfGun = null;
    9.       Debug.Log("ERROR: Gun object not set.");
    10.     }
    11.  
    12.     setTarget(target);
    13.     ParkMode = parkSetting;
    14.   }


    Is that more like a shouldn't? Because yaw clamp in my previous post does work indeed, I've played around with it quite a bit and haven't notices any problems. Just have to make an empty parent and rotate it by 90 on y. So when the turret-base faces forward its local rotation is 0,0,0.
     
    Last edited: Feb 25, 2022
    orionsyndrome likes this.
  41. orionsyndrome

    orionsyndrome

    Joined:
    May 4, 2014
    Posts:
    2,173
    You're welcome!

    That's weird because target and gun are completely unrelated. I'll have to look into that. What was the bug?

    Great if that turned out simple! That's totally by accident, and hopefully there are no further implications down the line. I can't think of any, but make sure to test it well.
     
  42. orionsyndrome

    orionsyndrome

    Joined:
    May 4, 2014
    Posts:
    2,173
    I think I know where the bug is, though I can't tell if it's related for sure. When working in the editor, things sometimes behave weirdly because of serialized values, persistence, and all kinds of corrupted states that can occur through a combination of these factors. Sometimes this corruption doesn't appear as error, but hides an error and so on.

    A bit silly, this only works because the field is serialized, but would fail otherwise.
    Code (csharp):
    1.   // target setter
    2.   void setTarget(GameObject target) {
    3.     xfTarget = (target == null)? null : target.transform;
    4.  
    5.     if(DispatchEvents) {
    6.       OnTargetChange?.Invoke(this, target); // if target is null, this should return null
    7.       Debug.Log("Target has changed");
    8.     }
    9.  
    10.     if(target == null) _firingState = false;
    11.     dispatchFiringEvents(); // but cease fire should return the actual target prior to setting to null
    12.     // this is because it reads from the class field 'target'
    13.  
    14.     this.target = target; // finally we need to set this class field, which I forgot to do
    15.   }
    16.  
     
  43. orionsyndrome

    orionsyndrome

    Joined:
    May 4, 2014
    Posts:
    2,173
    Two big improvements and some fixes.

    - internal firing state is now actively validated against the script's
    enabled
    state and should dispatch events accordingly (also OnEnable and OnDisable are handled properly).
    - there was a peculiar state when the gun's pitch was off limits which would make lateral distance not update, and thus it would erroneously report being able to fire when it was not true; the drawback is that lateral distance must be computed at nearly all times. I don't know if you're using the latest version, but for what it's worth, any change you've already made to validate yaw angle will also contribute to this. I will probably do the yaw limit myself, so that you have a complete solution.
    - I've also revisited the
    setTarget
    method.
    - Edit: fixed ParkMode property.
    - Edit2: changed _previousTarget to _lockedTarget.
    - Edit3: fixed getPerpTo. Edit5: fixed it again :)
    - Edit4: drastically optimized closestPointOnLine (and changed it to closestPointOnRay).
    - Edit6: major update in post #48

    A major optimization to how lateral tolerance works can be made by having a dedicated TurretTarget script which would dispatch an event when the target is moving (Edit4: sorted out all issues with lateral distance being expensive to call). This is actually a recommended way to handle many issues that stem from having to constantly check for the target's state in every frame. However I won't do it for simplicity sake (and the script is already getting quite large).

    (Edit6: this code is now obsolete, check post #48)
    Code (csharp):
    1. using UnityEngine;
    2.  
    3. [ExecuteInEditMode]
    4. public class TurretBehaviour : MonoBehaviour {
    5.  
    6.   // 1) Add this script to an object that is the "base" of a turret; it works live in the editor
    7.   // 2) Make a child "gun" object and assign it; freely move this object relative to the "base", for example on Y
    8.   // 3) Make a "target" object and assign it; move it anywhere both in the scene and hierarchy; don't make it a child of "base"
    9.   // 4) Make sure to toggle 'Always Refresh' in the scene view or else animation frames will be skipped in the editor
    10.   // 5) Tweak parameters and move the "target" around to see it in action.
    11.   // 6) Turret's "base" can be further nested to some parent object; parent's transform will be respected (scaling is not supported though).
    12.  
    13.   [Header("Objects")]
    14.   [SerializeField] [Tooltip("Should be a child object")] GameObject gun;
    15.   [SerializeField] GameObject target;
    16.   [Header("Visualization")]
    17.   [SerializeField] [Min(0f)] [Tooltip("Needed for visualizations")] float gunLength = 1f;
    18.   [SerializeField] bool showAimDots;
    19.   [SerializeField] bool showPitchLimits;
    20.   [SerializeField] bool showGunReticles;
    21.   [Header("Pitch Limits")]
    22.   [SerializeField] [Range(-90f, 90f)] public float pitchMin = -5f;
    23.   [SerializeField] [Range(-90f, 90f)] public float pitchMax = 50f;
    24.   [Header("Operation")]
    25.   [SerializeField] bool parkSetting;
    26.   [SerializeField] [Min(0.1f)] public float baseAngVelocity = 50f;
    27.   [SerializeField] [Min(0.1f)] public float gunAngVelocity = 50f;
    28.   [Header("Aim Cone")]
    29.   [SerializeField] [Tooltip("Firing tolerance; will blink when ready to fire")] bool considerCone = true;
    30.   [SerializeField] bool showTolerance;
    31.   [SerializeField] [Min(0f)] [Tooltip("Aiming tolerance; for editor testing only")] float aimTolerance = .1f;
    32.  
    33.   private const float QRTR_DEGS =  90f;
    34.   private const float HALF_DEGS = 180f;
    35.   private const float FULL_DEGS = 360f;
    36.  
    37.   /// <summary>Returns or sets the currently selected target. Will clear target if set to null.
    38.   /// (If <seealso cref="ParkMode"/> is set to true, clearing target will park the turret.)</summary>
    39.   public GameObject Target {
    40.     get => target;
    41.     set => setTarget(value);
    42.   }
    43.  
    44.   /// <summary>Returns true if a target was set, otherwise false.</summary>
    45.   public bool HasTarget => target != null;
    46.  
    47.   /// <summary>If set to true, parks the turret automatically if there's no target selected.
    48.   /// Parking will bring the turret to its default pose, then auto-disable the script.</summary>
    49.   public bool ParkMode {
    50.     get => parkSetting;
    51.     set => parkSetting = value;
    52.   }
    53.  
    54.   /// <summary>If set to true, will automatically dispatch events.</summary>
    55.   public bool DispatchEvents { get; set; } = true;
    56.  
    57.   /// <summary>Forces immediate parking of the turret. Target will be cleared.</summary>
    58.   public void Park() { ParkMode = true; Target = null; }
    59.  
    60.   public float DistanceToTargetSquared => targetDelta.sqrMagnitude;
    61.   public float DistanceToTarget => targetDelta.magnitude;
    62.   public float LateralAimingDistanceSquared => _lateralDistanceSqr;
    63.   public float LateralAimingDistance => Mathf.Sqrt(_lateralDistanceSqr);
    64.  
    65.   /// <summary>Returns true if the turret's gun is at its pitch limit, false otherwise.</summary>
    66.   public bool IsGunSliding => !_isValidPitch;
    67.  
    68.   /// <summary>Returns true if a target was set *and* the gun has not yet hit the pitch limit, false otherwise.</summary>
    69.   public bool IsTargetAimable => HasTarget && !IsGunSliding;
    70.  
    71.   /// <summary>Returns true if any part of the turret is currently moving, false otherwise.</summary>
    72.   public bool IsTurretMoving => enabled && HasTarget && !isDoneReorienting();
    73.  
    74.   /// <summary>
    75.   /// Allows checking for the lateral aim tolerance from the target. Returns true when the gun is ready, false otherwise.
    76.   /// If <seealso cref="considerCone"/> was set to false, the tolerance is ignored, and instead the gun is ready only when it's actively aiming but not moving.
    77.   /// </summary>
    78.   public bool IsReadyToFire(float tolerance = 2E-1f)
    79.     => considerCone? enabled && HasTarget && (_lateralDistanceSqr <= tolerance * tolerance)
    80.                    : !IsTurretMoving;
    81.  
    82.   /// <summary>Returns the local yaw rotation of the turret's base, in degrees (0-360).</summary>
    83.   public float TurretYawDegrees => angMod(xfBase.localRotation.eulerAngles.y);
    84.  
    85.   /// <summary>Returns the local pitch rotation of the turret's gun, in degrees. Aiming toward the horizon returns 0, aiming up 90, and aiming down -90.</summary>
    86.   public float GunPitchDegrees => _currentPitch;
    87.  
    88.   public delegate void ReadyHandler(TurretBehaviour sender, GameObject target, float longitudal, float lateral);
    89.   public delegate void TargetHandler(TurretBehaviour sender, GameObject target);
    90.  
    91.   /// <summary>Will dispatch when gun is ready to fire. Provides both longitudal and lateral distance to target.</summary>
    92.   public event ReadyHandler OnReadyToFire;
    93.  
    94.   /// <summary>Will dispatch when gun should cease firing. Returns the target that was previously fired upon.</summary>
    95.   public event TargetHandler OnCeaseFire;
    96.  
    97.   /// <summary>Will dispatch whenever the target is set or cleared.</summary>
    98.   public event TargetHandler OnTargetChange;
    99.  
    100.   // set quaternion rotations
    101.   public Quaternion TowardsTarget => setGunRot * setBaseRot;
    102.   public Quaternion TowardsTargetYaw => setBaseRot;
    103.   public Quaternion TowardsTargetPitch => setGunRot;
    104.  
    105.   // class fields
    106.   readonly Quaternion _twist = Quaternion.Euler(0f, -QRTR_DEGS, 0f);
    107.   readonly Quaternion _invTwist = Quaternion.Inverse(Quaternion.Euler(0f, -QRTR_DEGS, 0f));
    108.  
    109.   float _currentPitch, _lateralDistanceSqr;
    110.   bool _isValidPitch, _firingState;
    111.  
    112.   GameObject _lockedTarget;
    113.  
    114.   // transforms are cached for better performance
    115.   Transform xfParent, xfBase, xfGun, xfTarget;
    116.  
    117.   // if there is a parent, its rotation will be considered
    118.   Quaternion invertedWorldRotation => xfParent != null? Quaternion.Inverse(xfParent.rotation)
    119.                                                       : Quaternion.identity;
    120.  
    121.   // destination orientations which the animation will pursue
    122.   Quaternion setBaseRot, setGunRot;
    123.  
    124.   void initialize() {
    125.     xfBase = transform;
    126.     xfParent = transform.parent;
    127.     validateFiringState(target);
    128.     setTarget(target);
    129.  
    130.     if(gun != null) {
    131.       xfGun = gun.transform;
    132.     } else {
    133.       xfGun = null;
    134.       Debug.Log($"{nameof(TurretBehaviour)} ERROR: Gun object not set.");
    135.     }
    136.   }
    137.  
    138.   // differential vector
    139.   Vector3 targetDelta => xfTarget.position - xfGun.position;
    140.  
    141.   void validateFiringState(GameObject target) {
    142.     if((!enabled || target == null) == _firingState)
    143.       dispatchFiringEvents();
    144.   }
    145.  
    146.   // target setter
    147.   void setTarget(GameObject target) {
    148.     if(this.target == _lockedTarget) return;
    149.  
    150.     this.target = target; // acknowledge and store this new target
    151.     xfTarget = (target == null)? null : target.transform; // cache transform
    152.  
    153.     if(DispatchEvents) {
    154.       OnTargetChange?.Invoke(this, target);
    155.       Debug.Log($"Target has changed to: {((target == null)? "none" : target.name)}");
    156.     }
    157.  
    158.     dispatchFiringEvents(); // 'cease fire' will report the _lockedTarget
    159.     _lockedTarget = this.target;
    160.   }
    161.  
    162.   void Update() {
    163.     // base is not supposed to be moved locally, move its parent instead
    164.     xfBase.localPosition = Vector3.zero;
    165.  
    166.     // bail out if there's no gun assigned
    167.     if(xfGun == null) return;
    168.  
    169.     // the aim cannot be accomplished if there's no target
    170.     if(!HasTarget) {
    171.  
    172.       if(ParkMode) { // auto-park
    173.         setBaseRot = _twist;
    174.         setGunRot = _invTwist;
    175.       }
    176.  
    177.     } else { // if there's a target...
    178.  
    179.       // target direction is used to find the compound rotation
    180.       var tdir = invertedWorldRotation * targetDelta; // apply the inverse of the parent's orientation, to allow for arbitrary environmental rotations
    181.       var pdir = new Vector3(tdir.x, 0f, tdir.z); // target direction is now projected to XZ plane, to diminish all local rotations but yaw
    182.  
    183.       // we take extra care there are no rolls involved in the local rotations
    184.       // and here the directions get normalized before use
    185.       var bq = _twist * Quaternion.LookRotation(pdir.normalized, Vector3.up); // base rotation (yaw)
    186.       var gq = Quaternion.Inverse(bq) * Quaternion.LookRotation(tdir.normalized, Vector3.up); // gun rotation (pitch)
    187.  
    188.       // we use eulerAngles to read back angles, but the values are messy
    189.       // a transformation is needed in order to set pitch to -90..+90 interval
    190.       // where 0 is aiming toward the horizon, +90 is aiming up, and -90 down
    191.       _currentPitch = HALF_DEGS - angMod(gq.eulerAngles.x + HALF_DEGS);
    192.       _isValidPitch = _currentPitch >= pitchMin && _currentPitch <= pitchMax;
    193.  
    194.       // clamp the pitch if it's off limits
    195.       if(!_isValidPitch) {
    196.         _currentPitch = Mathf.Clamp(_currentPitch, pitchMin, pitchMax);
    197.         gq = Quaternion.Euler(-_currentPitch, QRTR_DEGS, 0f); // it's easy to convert the pitch back
    198.       }
    199.  
    200.       // assign desired rotations
    201.       setBaseRot = bq;
    202.       setGunRot = gq;
    203.  
    204.     }
    205.  
    206.     // this will progressively animate the two rotations using set angular velocities
    207.     // this is guarded by a cheap test whether the final orientations were achieved or not
    208.     if(!isDoneReorienting()) {
    209.       xfBase.localRotation = Quaternion.RotateTowards(xfBase.localRotation, setBaseRot, baseAngVelocity * Time.deltaTime);
    210.        xfGun.localRotation = Quaternion.RotateTowards( xfGun.localRotation, setGunRot,  gunAngVelocity  * Time.deltaTime);
    211.  
    212.       updateLateralDistance();
    213.       dispatchFiringEvents();
    214.  
    215.     } else {
    216.       if(!_isValidPitch) {
    217.         updateLateralDistance();
    218.         dispatchFiringEvents();
    219.       }
    220.  
    221.       if(!HasTarget && ParkMode) enabled = false;
    222.  
    223.     }
    224.  
    225.   }
    226.  
    227.   // computes the lateral distance from target
    228.   void updateLateralDistance() => _lateralDistanceSqr = HasTarget? sqrDistanceFromRay(xfGun.position, xfGun.forward, xfTarget.position)
    229.                                                                  : 0f;
    230.  
    231.   void dispatchFiringEvents() {
    232.     if(!DispatchEvents) return;
    233.  
    234.     if(!_firingState && IsReadyToFire(aimTolerance)) {
    235.       OnReadyToFire?.Invoke(this, target, DistanceToTarget, LateralAimingDistance);
    236.       Debug.Log($"Ready to fire at target {target.name} at distance {DistanceToTarget:F1} / lateral: {LateralAimingDistance:F3}");
    237.       _firingState = true;
    238.  
    239.     } else if(_firingState && !IsReadyToFire(aimTolerance)) {
    240.       OnCeaseFire?.Invoke(this, _lockedTarget); // will report the locked target, not the current one
    241.       Debug.Log("Cease fire");
    242.       _firingState = false;
    243.  
    244.     }
    245.   }
    246.  
    247.   // true 360 modulo
    248.   float angMod(float v) => (v %= FULL_DEGS) < 0f? v + FULL_DEGS : v;
    249.  
    250.   bool quatsMatch(Quaternion q1, Quaternion q2, float epsilon = Quaternion.kEpsilon)
    251.     => Quaternion.Dot(q1, q2) - 1f > -epsilon; // true when dot approximates +1
    252.  
    253.   // whether the gun is aiming at the target right now or not
    254.   bool isDoneReorienting() => quatsMatch(xfBase.localRotation, setBaseRot) && quatsMatch(xfGun.localRotation, setGunRot);
    255.  
    256.   // ro and rd are ray origin and ray direction respectively
    257.   float sqrDistanceFromRay(Vector3 ro, Vector3 rd, Vector3 p)
    258.     => (closestPointOnRay(ro, rd, p) - p).sqrMagnitude;
    259.  
    260.   Vector3 _cpol; // used in visualizations
    261.  
    262.   Vector3 closestPointOnRay(Vector3 ro, Vector3 rd, Vector3 p)
    263.     => _cpol = ro + Mathf.Max(0f, Vector3.Dot(rd, p - ro)) * rd;
    264.  
    265.   void Start() => initialize();
    266.   void Reset() => initialize();
    267.   void OnEnable() => initialize();
    268.   void OnDisable() => initialize();
    269.  
    270. // this block of code is valid only in the editor, for gizmos and such
    271. #if UNITY_EDITOR
    272.  
    273.   void OnValidate() => initialize();
    274.  
    275.   void OnDrawGizmos() {
    276.     // this lets Update() get called continuously in the editor
    277.     UnityEditor.EditorApplication.QueuePlayerLoopUpdate();
    278.  
    279.     if(xfGun == null) return;
    280.     drawBaseLine(Color.red);
    281.     var glc = determineGunLineColor();
    282.     drawGunLine(glc);
    283.  
    284.     if(showPitchLimits) drawPitchLimits(Color.magenta);
    285.  
    286.     if(xfTarget == null) return;
    287.     if(showAimDots) drawAimDots(glc);
    288.     if(showGunReticles) drawGunReticles();
    289.     if(showTolerance) drawAimingCone(Color.cyan);
    290.   }
    291.  
    292.   Color determineGunLineColor() {
    293.     const float BLINK_RATE = 3f; // per second
    294.     var glc = Color.magenta;
    295.  
    296.     if(_isValidPitch) {
    297.       if(considerCone && IsReadyToFire(aimTolerance)) {
    298.         glc = ((int)(Time.realtimeSinceStartup * BLINK_RATE) & 1) == 0? Color.white : Color.black;
    299.       } else {
    300.         glc = Color.yellow;
    301.       }
    302.     }
    303.  
    304.     return glc;
    305.   }
    306.  
    307.   void drawBaseLine(Color color) => drawLine(xfBase.position, xfGun.position, color);
    308.   void drawGunLine(Color color) => drawLine(xfGun.position, gunNozzlePos, color);
    309.  
    310.   void drawAimDots(Color color) {
    311.     Gizmos.color = color;
    312.     var rd = targetDelta.normalized;
    313.     var l = targetDelta.magnitude;
    314.     var p = xfGun.position;
    315.     var t = Time.realtimeSinceStartup % 1f;
    316.     for(float d = 0f; d < l; d++) Gizmos.DrawSphere(p + Mathf.Min(l, d + t) * rd, .03f);
    317.   }
    318.  
    319.   Texture _reticle1, _reticle2;
    320.  
    321.   void drawGunReticles() {
    322.     if(_reticle1 is null) {
    323.       _reticle1 = UnityEditor.EditorGUIUtility.IconContent("d_curvekeyframe").image;
    324.       _reticle2 = UnityEditor.EditorGUIUtility.IconContent("d_curvekeyframeweighted").image;
    325.     }
    326.  
    327.     var cam = getSceneCamera();
    328.  
    329.     // reticle #1 is projected on the sphere at the target's distance
    330.     var p1 = getADSPoint(DistanceToTarget);
    331.     drawReticle(cam, _reticle1, p1);
    332.  
    333.     // reticle #2 is rendered on the aim line at the point closest to the target
    334.     var p2 = _cpol;
    335.     drawReticle(cam, _reticle2, p2);
    336.  
    337.     // reticle connector
    338.     Gizmos.color = Color.red;
    339.     Gizmos.DrawLine(p1, p2);
    340.  
    341.     // green proximity connector, to indicate that the point is contained in the aiming cone
    342.     if(considerCone && IsReadyToFire(aimTolerance)) {
    343.       Gizmos.color = Color.green;
    344.       Gizmos.DrawLine(xfTarget.position, _cpol);
    345.     }
    346.   }
    347.  
    348.   void drawReticle(Camera cam, Texture tex, Vector3 pos) {
    349.     var p = cam.WorldToScreenPoint(pos);
    350.     p -= new Vector3((_reticle1.width >> 1) - 1.5f, .5f - (_reticle1.height >> 1), 0f); // screen-space offset
    351.     UnityEditor.Handles.Label(cam.ScreenToWorldPoint(p), tex);
    352.   }
    353.  
    354.   // used for visualizations
    355.   Vector3 gunNozzlePos => getADSPoint(gunLength);
    356.   Vector3 getADSPoint(float distance) => xfGun.position + distance * xfGun.forward;
    357.  
    358.   // treats pitch limits as spherical slices
    359.   void drawPitchLimits(Color color) {
    360.     drawBubble(xfGun.position, gunLength, Color.white);
    361.  
    362.     for(int i = 0; i < 2; i++) {
    363.       var rads = (i == 0? pitchMax : pitchMin) * Mathf.Deg2Rad;
    364.       drawCircle(xfGun.position + gunLength * Mathf.Sin(rads) * xfBase.up, xfBase.up, gunLength * Mathf.Cos(rads), color);
    365.     }
    366.  
    367.     drawCircle(xfGun.position, xfBase.up, gunLength, Color.red); // horizon
    368.   }
    369.  
    370.   void drawAimingCone(Color color)
    371.     => drawCone(xfTarget.position, aimTolerance, xfGun.position, color);
    372.  
    373.   // draws a rotating faux cone (a circle with added slant height lines)
    374.   void drawCone(Vector3 baseVertex, float baseRadius, Vector3 tipVertex, Color color, int slantSteps = 4, int steps = 24) {
    375.     Gizmos.color = color;
    376.     var normal = (tipVertex - baseVertex).normalized;
    377.     var astep = FULL_DEGS / steps;
    378.     var radial = baseRadius * getPerpTo(normal);
    379.     var last = Vector3.zero;
    380.     var aoff = (Time.realtimeSinceStartup % 4f) * .25f * slantSteps * astep;
    381.     for(int i = 0; i <= steps; i++) {
    382.       var p = baseVertex + Quaternion.AngleAxis(aoff + astep * i, normal) * radial;
    383.       if(i > 0) Gizmos.DrawLine(last, p);
    384.       if(i < steps && i % slantSteps == 0) Gizmos.DrawLine(tipVertex, p);
    385.       last = p;
    386.     }
    387.   }
    388.  
    389.   // draws a circle that's arbitrarily oriented in space, made out of short linear segments
    390.   void drawCircle(Vector3 center, Vector3 normal, float radius, Color color, int steps = 24) {
    391.     Gizmos.color = color;
    392.     var astep = FULL_DEGS / steps;
    393.     var radial = radius * getPerpTo(normal);
    394.     var last = Vector3.zero;
    395.     for(int i = 0; i <= steps; i++) {
    396.       var p = center + Quaternion.AngleAxis(astep * i, normal) * radial;
    397.       if(i > 0) Gizmos.DrawLine(last, p);
    398.       last = p;
    399.     }
    400.   }
    401.  
    402.   // returns a unit vector guaranteed to be perpendicular to the input vector
    403.   Vector3 getPerpTo(Vector3 vec) {
    404.     const float EPS_SQR = Vector3.kEpsilon * Vector3.kEpsilon;
    405.     var perp = Vector3.Cross(vec, Vector3.forward);
    406.     if(vec.sqrMagnitude <= EPS_SQR) // failsafe for when magnitude is <= 0.00001
    407.       perp = Vector3.Cross(vec, Vector3.right);
    408.     return perp.normalized;
    409.   }
    410.  
    411.   void drawLine(Vector3 a, Vector3 b, Color color) {
    412.     Gizmos.color = color;
    413.     Gizmos.DrawLine(a, b);
    414.   }
    415.  
    416.   // a circle that's rendered in such a way to resemble a sphere from every viewing angle
    417.   // it's just an approximation, doesn't work well when object is near camera due to perspective distortion
    418.   void drawBubble(Vector3 c, float radius, Color color)
    419.     => drawCircle(c, -getSceneCamera().transform.forward, radius, color, 48);
    420.  
    421.   Camera getSceneCamera() => UnityEditor.SceneView.lastActiveSceneView.camera;
    422.  
    423. #endif
    424.  
    425. }
     
    Last edited: Feb 26, 2022
  44. orionsyndrome

    orionsyndrome

    Joined:
    May 4, 2014
    Posts:
    2,173
    Oh I was too quick to post that, I need to fix parkSetting as well. That doesn't work too well in play mode. I'll post the fix here. The serialized field should be the backing field of the property, instead of copying the values in
    initialize
    . I was obviously Mathf.Max(tired, drunk) when I made that.

    Edit:
    Done.
    Change ParkMode to
    Code (csharp):
    1.   public bool ParkMode {
    2.     get => parkSetting;
    3.     set => parkSetting = value;
    4.   }
    Remove
    ParkMode = parkSetting;
    from
    initialize()


    (I will also try to edit the last code box, but forum doesn't let me sometimes, that Access Denied error is still around even though we've reported it elsewhere.)
     
  45. orionsyndrome

    orionsyndrome

    Joined:
    May 4, 2014
    Posts:
    2,173
    Oh man, so many little things. I think I'll have to revisit setTarget again. Cease fire will get you a previous target, but now I'm sure this is not the right logic, because it can be dispatched intermittently for the current target as well. That was one of a couple of typical scenarios, which completely slipped off my mind. The other scenario was when you switch the target to something else, which is why I'm now keeping the previous target around. I need to think some more about this.

    I think I've mixed two different contexts in my head. _previousTarget is in a global context, whereas it should've been tied to state machine logic. That's how you fool yourself with a bad name! It should've been called _lockedTarget. Right.

    Well I need to make a break, then I'm gonna fix this, and that's it for today.

    Edit:
    Nah, I clearly need more coffee :) the logic seems solid. I did rename it to _lockedTarget, because it's a better name, however no (true) changes are required. This variable will change only when the new target is confirmed to be different, but before that 'cease fire' will have an opportunity to dispatch the previously locked target. And if it doesn't change, it just reports the currently locked one.
     
    Last edited: Feb 25, 2022
  46. orionsyndrome

    orionsyndrome

    Joined:
    May 4, 2014
    Posts:
    2,173
    Another fix, getPerpTo was doomed to always executing a failsafe, which meant having to compute two crosses all the time, instead of doing that only in rare events. Not a biggie because this is used only in the visualizations, but for the sake of correctness...

    It is worth noting that the cross product doesn't guarantee to be unit even if the two vectors were unit to begin with (unless they were also perpendicular to each other). I was a cheapskate and wanted to skip having to do normalizations twice in the worst case. But in the end I did crosses twice, always.

    The failsafe is there in case the input vector is approximately collinear to Vector3.forward (parallel or antiparallel) in which case the result is a zero vector (magnitude of 0), and thus undefined (as you can't normalize a zero vector to something meaningful), so there's a better way to write this actually.

    Maybe it's slightly better to hardcode this test component-wise, because it's faster to check against known primitive values, than getting
    vec.sqrMagnitude
    (which is practically the same thing as
    Vector3.Dot(vec, vec)
    ), but meh.

    (Edit: Code was not good, see Edit2 below)
    Code (csharp):
    1.   // returns a unit vector guaranteed to be perpendicular to the input vector
    2.   Vector3 getPerpTo(Vector3 vec) {
    3.     const float EPS_SQR = Vector3.kEpsilon * Vector3.kEpsilon;
    4.     var perp = Vector3.Cross(vec, Vector3.forward).normalized;
    5.     if(vec.sqrMagnitude <= EPS_SQR) // failsafe for when magnitude is <= 0.00001
    6.       perp = Vector3.Cross(vec, Vector3.right).normalized;
    7.     return perp;
    8.   }

    Will edit the code above to include this solution.

    Edit:
    I should maybe test this against inputs that are acutely touching the "collinearity zone" to account for the numerical instability. You know, normalizing terribly small vectors isn't actually the smartest thing to do, they will jitter all over the place. I will probably revise this into something more robust.

    Edit2:
    GRAARGH note to myself: make sure you check the magnitude BEFORE you normalize the vector, dummy :D

    Edit3:
    And I still had a typo, that's just great xD

    Oh well
    Code (csharp):
    1.   // returns a unit vector guaranteed to be perpendicular to the input vector
    2.   Vector3 getPerpTo(Vector3 vec) {
    3.     const float EPS_SQR = Vector3.kEpsilon * Vector3.kEpsilon;
    4.     var perp = Vector3.Cross(vec, Vector3.forward);
    5.     if(perp.sqrMagnitude <= EPS_SQR) // failsafe for when magnitude is <= 0.00001
    6.       perp = Vector3.Cross(vec, Vector3.right);
    7.     return perp.normalized;
    8.   }
     
    Last edited: Feb 26, 2022
  47. orionsyndrome

    orionsyndrome

    Joined:
    May 4, 2014
    Posts:
    2,173
    Well, so much talk about the lateral distance, and I knew intuitively there was a swift technique, but alas it has been a while. All in all this code
    Code (csharp):
    1. // ro and rd are ray origin and ray direction respectively, but we treat the result as a line
    2. // a line can be thought of as an intersection of two orthogonal planes
    3. bool closestPointOnLine(Vector3 ro, Vector3 rd, Vector3 p, out Vector3 result) {
    4.   var ro2p = p - ro;
    5.   var n2 = Vector3.Cross(rd, Vector3.Cross(rd, ro2p)).normalized;
    6.   result = p + Vector3.Dot(-ro2p, n2) * n2;
    7.  
    8.   // make sure we can differentiate between the gun's front and rear
    9.   if(Vector3.Dot(result, xfGun.forward) < 0f) {
    10.     result = ro;
    11.     return false;
    12.   }
    13.  
    14.   return true;
    15. }
    was a bit smelly to me, but sure it did the job. But I couldn't let it go, and so I decided to go after a simple vector projection, instead of doing the orthogonal planes. Mathematically it's immensely hard to sort that out even if you do lay out the crosses as expressions, but you know, intuitively, when I see double crosses, orthogonal to orthogonal right? Also this check where I make sure we can differentiate front and rear ends, that's not a line anymore right? It's a ray.

    The reason why I couldn't simply check whether that first dot for whether it's negative or not is because the minus got baked into the normal itself. It's a cumulative error, basically, because I wanted an easy way out and reused some general purpose, much bulkier code.

    The reality is that these crosses conveniently cancel out completely (but also this is from the perspective of ro, not p as in the original solution). And what you end up with is this. That's all there is to it :)
    Code (csharp):
    1. float sqrDistanceFromRay(Vector3 ro, Vector3 rd, Vector3 p)
    2.   => (closestPointOnRay(ro, rd, p) - p).sqrMagnitude;
    3.  
    4. Vector3 closestPointOnRay(Vector3 ro, Vector3 rd, Vector3 p)
    5.   => ro + Mathf.Max(0f, Vector3.Dot(rd, p - ro)) * rd; // this max bans the antiparallel projection
    Phew I thought I was losing my mind. Well that's now solved.
    Will edit the last code box.
     
    Last edited: Feb 25, 2022
  48. orionsyndrome

    orionsyndrome

    Joined:
    May 4, 2014
    Posts:
    2,173
    Get ready for a massive update.

    - Yaw limits and the corresponding visualizations are now fully implemented
    - Limits can be optionally hard -- meaning they get to push the gun around!
    - I got rid of the twisting rotation (don't ask me why it was there in the first place, that was weird)
    - Did some bug fixing, refactoring, and quality of life improvements
    - I've also updated/revisited many of the comments to streamline the learning process

    One known (high-level) issue I'm aware of is that the turret is dumb to understand to go the other way around in some cases. In other words when you squeeze its freedom of motion in a particular way, it will attempt to track the target via the shortest route even though that particular route is blocked. This issue is relatively advanced, so I won't attempt to solve this in a foreseeable future. (Another a bit more pressing issue is in the next post, I'll fix that soon.)

    One other thing: the code got big and got very robust. I have also benchmarked it to an extent, and it's extremely fast (I'm not counting visualizations, in-editor stuff is always a bit slow). It will always be free, it's not about that, this is a learning tool and I'm happy to have helped you, but I'm considering to open another thread to advertise it better. At this point it really should have a proper license notice and I wish more people would grab it.

    In the meantime, here's the solution so far.
    Code (csharp):
    1. // TurretBehaviour v0.8.0, by orionsyndrome (2022)
    2. // @ https://forum.unity.com/threads/quaternion-lookdirection-with-parent-rotated.1242805
    3.  
    4. // 1) Add this script to an object that is the "base" of a turret; it works live in the editor
    5. // 2) Make a child "gun" object and assign it; freely move this object relative to the "base", for example on Y
    6. // 3) Make a "target" object and assign it; move it anywhere both in the scene and hierarchy; don't make it a child of "base" or it's a donkey-carrot situation
    7. // 4) Make sure to toggle 'Always Refresh' in the scene view or else animation frames will be skipped in the editor
    8. // 5) Tweak parameters and move the "target" around to see the turret in action
    9. // 6) Turret's "base" can be further nested to some parent object; parent's transform will be respected (scaling is not supported though).
    10.  
    11. // Glitches and edge-case issues: Exist
    12. // Considered as finished: No
    13.  
    14. // This script was intended as a learning tool as it deals with quaternions and rotating turrets in games are a commonplace.
    15. // It includes the surrounding logic, angular limits, in-editor motion, event dispatching, and all kinds of visualizations.
    16. // Nevertheless it's written in a professional capacity, and should be performing very well in a production environment.
    17.  
    18. // Usage license:
    19. // Commercial use: Yes
    20. // Sharing / Reuploading: Yes (keep this header though)
    21. // Selling: No (as a standalone product nor in a bundle of similar products)
    22. // Attribution required: No (like my comments on Unity forums if you care about that sort of thing)
    23.  
    24. using UnityEngine;
    25.  
    26. [ExecuteInEditMode]
    27. public class TurretBehaviour : MonoBehaviour {
    28.  
    29.   [Header("Objects")]
    30.   [SerializeField] [Tooltip("Drag a GameObject here; Must be a child object.")] GameObject gun;
    31.   [SerializeField] [Tooltip("Drag a GameObject here; Must not be a child object.")] GameObject target;
    32.   [Header("Visualization / Debug")]
    33.   [SerializeField] [Min(0f)] [Tooltip("Required for visualizations.")] float gunLength = 1f;
    34.   [SerializeField] bool showAimDots;
    35.   [SerializeField] bool showYawLimits;
    36.   [SerializeField] bool showPitchLimits;
    37.   [SerializeField] bool showReticles;
    38.   [SerializeField] bool logEvents = true;
    39.   [Header("Angular Limits")]
    40.   [SerializeField] [Tooltip("To preview, turn Show Yaw Limits on.")] [Range(-180f, 180f)] public float yawSkew = 0f;
    41.   [SerializeField] [Tooltip("To preview, turn Show Yaw Limits on.")] [Range(0f, 180f)] public float yawLimit = 180f;
    42.   [SerializeField] [Tooltip("To preview, turn Show Pitch Limits on.")] [Range(-90f, 90f)] public float pitchMin = -5f;
    43.   [SerializeField] [Tooltip("To preview, turn Show Pitch Limits on.")] [Range(-90f, 90f)] public float pitchMax = 50f;
    44.   [Header("Operation")]
    45.   [SerializeField] [Tooltip("Auto-parks turret when there is no target.")] bool autoPark;
    46.   [SerializeField] [Tooltip("Allow the limits to forcefully push the gun. Otherwise the limits are more of a suggestion.")] bool hardLimits;
    47.   [SerializeField] [Tooltip("In degrees/sec.")] [Min(0.1f)] public float baseAngVelocity = 50f;
    48.   [SerializeField] [Tooltip("In degrees/sec.")] [Min(0.1f)] public float gunAngVelocity = 50f;
    49.   [Header("Aim Cone")]
    50.   [SerializeField] [Tooltip("Enable firing tolerance. When ready to fire, aiming gizmos will blink in the editor.")] bool considerCone = true;
    51.   [SerializeField] bool showAimCone;
    52.   [SerializeField] [Min(0f)] [Tooltip("Aiming tolerance. If Consider Cone is on, this parameter will be used for event dispatching.")] float aimTolerance = .1f;
    53.  
    54.   private const float QRTR_DEGS =  90f;
    55.   private const float HALF_DEGS = 180f;
    56.   private const float FULL_DEGS = 360f;
    57.  
    58.   //-------------------------------------------------------------------------------------
    59.   // Public interface
    60.  
    61.   /// <summary>Returns or sets the currently selected target. Will clear target if set to null.
    62.   /// (If <seealso cref="ParkMode"/> is set to true, clearing target will park the turret.)</summary>
    63.   public GameObject Target {
    64.     get => target;
    65.     set => setTarget(value);
    66.   }
    67.  
    68.   /// <summary>Returns true if a target was set, otherwise false.</summary>
    69.   public bool HasTarget => target != null;
    70.  
    71.   /// <summary>If set to true, parks the turret automatically if there's no target selected.
    72.   /// Parking will bring the turret to its default pose, then disable the script.</summary>
    73.   public bool ParkMode {
    74.     get => autoPark;
    75.     set => autoPark = value;
    76.   }
    77.  
    78.   /// <summary>Script will dispatch events if set to true.</summary>
    79.   public bool DispatchEvents {