0
\$\begingroup\$

I have a very simple code that draws the shape of a pre-defined capsule. It handles everything well, except the case when the GameObject to which the CapsuleCollider2D is attached or any parent GameObject up the chain have scale set different than 1. The pre-computed matrix with lossyScale supposedly should take that into account but it does not:

Capsule diagram

var cap = GetComponent<CapsuleCollider2D>();

// -- basics
var radius = cap.size.x * 0.5f;
var halfH  = cap.size.y * 0.5f;
var center = cap.bounds.center;

// -- hemispehre offsets
var upperDiff = Mathf.Clamp(halfH - radius, 0, 999);
var lowerDiff = upperDiff * -1;

// -- matrix that supposedly hould take into account lossy scale
var mtx = Matrix4x4.TRS(cap.bounds.center, cap.transform.rotation, cap.transform.lossyScale);

// -- upper and lower hemisphere "centers"
 var upper = mtx.MultiplyPoint3x4(new Vector3(0, upperDiff, 0));
 var lower = mtx.MultiplyPoint3x4(new Vector3(0, lowerDiff, 0));

// -- drawing setup
int pts = 80;
float step = 360f / pts;

// -- central shape of the capsule essentially a box, so we find the "box" vertices
var topLeft = mtx.MultiplyPoint3x4(new Vector3(-radius, upperDiff, 0));
var topRight = mtx.MultiplyPoint3x4(new Vector3(radius, upperDiff, 0));
var lowLeft = mtx.MultiplyPoint3x4(new Vector3(-radius, lowerDiff, 0));
var lowRight = mtx.MultiplyPoint3x4(new Vector3(radius, lowerDiff, 0));


// drawing

for (int i = 0; i < pts / 2; i++)
{
    // first point
    float cx = Mathf.Cos(Mathf.Deg2Rad * step * i) * radius;
    float cy = Mathf.Sin(Mathf.Deg2Rad * step * i) * radius;
    Vector3 cur = new Vector3(cx, cy);
    
    // next point
    float nx = Mathf.Cos(Mathf.Deg2Rad * step * (i + 1)) * radius;
    float ny = Mathf.Sin(Mathf.Deg2Rad * step * (i + 1)) * radius;
    Vector3 next = new Vector3(nx, ny);
    
    // upper hemisphere
    var curUP =  mtx.MultiplyPoint3x4(cur + new Vector3(0, upperDiff, 0));
    var nextUP = mtx.MultiplyPoint3x4(next + new Vector3(0, upperDiff, 0));
    
    // lower hemisphere
    var curDOWN =  mtx.MultiplyPoint3x4(new Vector3(0, lowerDiff, 0) - cur);
    var nextDOWN = mtx.MultiplyPoint3x4(new Vector3(0, lowerDiff, 0) - next);
    
    // triangle in upper hemisphere
    GL.Vertex3(curUP.x, curUP.y, 0);
    GL.Vertex3(nextUP.x, nextUP.y, 0);
    GL.Vertex3(center.x, center.y, 0);

    // triangle in lower
    GL.Vertex3(curDOWN.x, curDOWN.y, 0);
    GL.Vertex3(nextDOWN.x, nextDOWN.y, 0);
    GL.Vertex3(center.x, center.y, 0);        
}

GL.Vertex3(topLeft.x, topLeft.y, 0);
GL.Vertex3(lowLeft.x, lowLeft.y, 0);
GL.Vertex3(center.x, center.y, 0);
// --
GL.Vertex3(topRight.x, topRight.y, 0);
GL.Vertex3(lowRight.x, lowRight.y, 0);
GL.Vertex3(center.x, center.y, 0);

Any ideas how can I account for scaling?

\$\endgroup\$
2
  • \$\begingroup\$ "lossyScale" is, as the name says, lossy: it does not and cannot account for all the effects of nested scaling. Have you considered using transform.localToWorldMatrix instead of building your own from parts? \$\endgroup\$
    – DMGregory
    Commented Jul 18 at 1:23
  • \$\begingroup\$ I'm pretty sure the issue isnt with lossyScale, because even with perfect round values it still exhibits this issue. I'm not sure in which way I can use transform.localToWorldMatrix without complicating the code tenfolds (unless you mean https://docs.unity3d.com/ScriptReference/Transform-localToWorldMatrix.html then yes, TransformPoint exhibits the same issue). \$\endgroup\$
    – Digika
    Commented Jul 18 at 1:40

1 Answer 1

0
\$\begingroup\$

If I understand you correctly, you want to match a set of vertices to the perimeter of a capsule collider, taking into account that the physics system doesn't handle non-uniform / non-axis-aligned scale and so it makes some compromises to get the collider to follow the object's transformation approximately.

First, we can define a data type that represents a capsule shape in world space. I've included a helper method for enumerating points on that capsule's perimeter. This keeps the code we write later nice and concise.

public readonly struct CapsuleShape {
    /// <summary>
    /// Center of the capsule.
    /// </summary>
    public readonly Vector2 center;

    /// <summary>
    /// Unit vector pointing along length of capsule.
    /// </summary>
    public readonly Vector2 direction;

    /// <summary>
    /// Radius of the capsule's cap semi-circles.
    /// </summary>
    public readonly float radius;

    /// <summary>
    /// Distance from center of capsule to its tip, including the cap.
    /// </summary>
    public readonly float halfLength;

    /// <summary>
    /// Constructs a capsule shape using its center, end offset, and radius.
    /// </summary>
    /// <param name="center">Center of the capsule.</param>     /// <param name="endOffset">Vector from the center of the capsule to its tip.</param>
    /// <param name="radius">Radius of the capsule's semi-circles.</param>
    public CapsuleShape(Vector2 center, Vector2 endOffset, float radius) {
        this.center = center;
        this.radius = radius;
        halfLength = endOffset.magnitude;
        direction = endOffset/halfLength;
    }

    public IEnumerator<Vector2> GetPoints(int capVertices) {
        int capSegments = Mathf.Max(0, capVertices) + 1;
        var capCenterOffset = direction * Mathf.Max(0, halfLength - radius);
        
        var forward = radius * direction;
        var right = new Vector2(forward.y, -forward.x);

        float angleStep = Mathf.PI / capSegments;

        // Draw the "front" semi-circle.
        var capCenter = center + capCenterOffset;
        for(int i = 0; i <= capSegments; i++) {
            float angle = i * angleStep;
            var point = capCenter + forward * Mathf.Sin(angle)
                                  + right * Mathf.Cos(angle);
            yield return point;
        }

        // Iterate the "back" semi-circle.
        capCenter = center - capCenterOffset;
        for(int i = 0; i <= capSegments; i++) {
            float angle = i * angleStep;
            var point = capCenter - forward * Mathf.Sin(angle)
                                  - right * Mathf.Cos(angle);
            yield return point;
        }
    }
}

Then we can write a method that extracts this shape from a capsule's properties and transform:

public static CapsuleShape GetColliderShape(CapsuleCollider2D capsule) {
    var transform = capsule.transform;
    var halfExtents = capsule.size * 0.5f;

    Vector2 toTip = Vector2.zero, 
            toSide = Vector2.zero;

    if (capsule.direction == CapsuleDirection2D.Horizontal) {
        toTip.x = halfExtents.x;
        toSide.y = halfExtents.y;     
    } else {
        toTip.y = halfExtents.y;
        toSide.x = halfExtents.x; 
    }

    return new CapsuleShape(
        transform.TransformPoint(capsule.offset),   // Center
        transform.TransformVector(toTip),           // End Offset
        transform.TransformVector(toSide).magnitude // Radius
    );
}

Lastly, we can draw this capsule as a gizmo like so. I used this to verify that the computed shape correctly matches Unity's collider gizmo even in the presence of non-uniform / non-axis-aligned scale in the capsule's transformation hierarchy.

var shape = GetColliderShape(capsule);

// Get an enumerator that walks over the points of the capsule,
// at a resolution of our choosing.
var pointEnumerator = shape.GetPoints(16);

// Advance to the first point on the capsule's perimeter.
pointEnumerator.MoveNext();
var first = pointEnumerator.Current;
var previous = first;

// Iterate over the rest of the points, joining them with lines.
while(pointEnumerator.MoveNext()) {
    Gizmos.DrawLine(previous, pointEnumerator.Current);
    previous = pointEnumerator.Current;
}

// Close the shape by joining the last point we drew to the first.
Gizmos.DrawLine(previous, first);

I initially solved this for the 3D case, because the title said "hemispheres" instead of "semicircles". For completeness, here's how we can match the physics system's scaling behaviour for 3D capsules too:

/// Draws wire spheres matching the caps of a 3D capsule collider.
void OnDrawGizmosSelected() {
    if (!TryGetComponent(out CapsuleCollider capsule)) return;

    var center = transform.TransformPoint(capsule.center);

    var localAxis = Vector3.zero;
    localAxis[capsule.direction] = 1;

    var worldAxis = transform.rotation * localAxis;
    
    var lossyScale = transform.lossyScale;
    var absScale = new Vector3(Mathf.Abs(lossyScale.x), Mathf.Abs(lossyScale.y), Mathf.Abs(lossyScale.z));

    var radiusScale = Mathf.Max(absScale[(capsule.direction + 1) % 3], absScale[(capsule.direction + 2) % 3]);

    var toCapCenter = Mathf.Max(0f, absScale[capsule.direction] * capsule.height/2 - capsule.radius * radiusScale);

    Gizmos.DrawWireSphere(center + worldAxis * toCapCenter, capsule.radius * radiusScale);
    Gizmos.DrawWireSphere(center - worldAxis * toCapCenter, capsule.radius * radiusScale);
}

\$\endgroup\$

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .