Book contents
Contents
raw Math
RAW Book Computer Graphics Collision Detection

Introduction to 2D Hit-Testing

Robert Eisele

Computer games and graphical user interfaces frequently need to determine whether two objects overlap, or whether an object is being selected or manipulated through a pointing device such as a mouse. The exact condition for such a hit-test depends heavily on the geometric primitives involved.

Hit-test: Circle and Point

Given the center point \(\mathbf{c}\) of a circle \(C\) with radius \(r\), and the position \(\mathbf{p}\) of a point \(P\), the point hits the circle iff it lies inside the circle, i.e. iff the hypotenuse of the right triangle formed by \(\mathbf{c}\) and \(\mathbf{p}\) is smaller than the radius:

The hypotenuse of the triangle is simply the distance between the two points, so the hit-test can be formulated as:

\[\begin{array}{rl} \text{hittest}_{CP}(C, P):=& |\mathbf{c} - \mathbf{p}| \leq r\\ =& (\mathbf{c} - \mathbf{p})\cdot(\mathbf{c} - \mathbf{p})\leq r^2 \end{array}\]

function hittestCP(C, P) {

  let tx = C.x - P.x;
  let ty = C.y - P.y;
  return tx * tx + ty * ty <= C.r * C.r;
}

Hit-test: Circle Outline and Point

Let \(\mathbf{c}\) be the center of a circle \(C\) with radius \(r\), and let \(P\) at position \(\mathbf{p}\) be the point to be tested. Hitting a thin circle exactly on its outline is difficult with an imprecise pointing device such as a mouse, so a margin of size \(\epsilon\) is placed around the outline. The distance between the center of the circle and the point to be tested is \(d=|\mathbf{c}-\mathbf{p}|\).

To check whether the point falls within the ring of width \(\epsilon\) around the circle's radius, start with the interval test on the distance \(d\) and square both bounds:

\[\begin{array}{rl} \text{hittest}_{CoP}(C, P) :=& r - \frac{1}{2}\epsilon \leq d\leq r+\frac{1}{2}\epsilon\\ \iff & \left(r - \frac{1}{2}\epsilon\right)^2 \leq d^2 \leq \left(r + \frac{1}{2}\epsilon\right)^2\\ \iff & \left(r - \frac{1}{2}\epsilon\right)^2 \leq (\mathbf{c}^x-\mathbf{p}^x)^2 + (\mathbf{c}^y-\mathbf{p}^y)^2 \leq \left(r + \frac{1}{2}\epsilon\right)^2 \end{array}\]

function hittestCoP(C, P, eps) {

  const dx = C.x - P.x;
  const dy = C.y - P.y;
  const d2 = dx * dx + dy * dy;

  const halfEps = eps * 0.5;
  const outer = C.r + halfEps;
  const inner = Math.max(0, C.r - halfEps);

  return d2 >= inner * inner &&
         d2 <= outer * outer;
}

Hit-test: Arc and Point

Let \(\mathbf{p}_1\) be the center of an arc \(A\) with radius \(r\), a starting angle \(\theta_1\) and an end angle \(\theta_2\), and let \(P\) at position \(\mathbf{p}_2\) be the point to be tested. As with the circle outline, a margin of size \(\epsilon\) is placed around the arc, and \(d=|\mathbf{p}_2-\mathbf{p}_1|\) denotes the distance between the center of the arc and the point.

The arc \(A\) is first treated as a circle \(C\) and tested with the circle-outline hit-test. If the point falls within the \(\epsilon\)-margin ring, the angle still has to fall within the arc's interval:

\[\begin{array}{rl} \text{hittest}_{AP}(A, P) :=& \text{hittest}_{CoP}(A, P) \land \text{angleBetween}(\phi, \theta_1, \theta_2) \end{array}\]

where \(\phi=\operatorname{atan2}\left(\mathbf{p}_2^y - \mathbf{p}_1^y, \mathbf{p}_2^x - \mathbf{p}_1^x\right)\) is the angle between the x-axis and the vector formed by the two points \(\mathbf{p}_1\) and \(\mathbf{p}_2\); \(\text{angleBetween}\) is the condition for checking if an angle is between two other angles.

Hit-test: Line segment and Circle

Given a line segment \(L\) defined by two points \(\mathbf{p}_1\) and \(\mathbf{p}_2\), and a circle \(C\) with position \(\mathbf{p}_3\) and radius \(r\). Among the infinitely many points on the line, the point \(\mathbf{p}\) with minimal distance to the circle is the one where the connecting line is orthogonal to \(L\):

Dragging the circle across the plot updates the projection and the hit-test result live.

The two points of the line form the vector \(\mathbf{b}=\mathbf{p}_2 - \mathbf{p}_1\), the first point of the line and the center of the circle form the vector \(\mathbf{a}=\mathbf{p}_3 - \mathbf{p}_1\), and the first point of the line and point \(\mathbf{p}\) form the vector \(\mathbf{c}=\mathbf{p}-\mathbf{p}_1\). Point \(\mathbf{p}\) is the orthogonal projection of vector \(\mathbf{a}\) onto vector \(\mathbf{b}\), plus the original point \(\mathbf{p}_1\) as position vector:

\[\mathbf{p} = \mathbf{p}_1 + \frac{\mathbf{a}\cdot\mathbf{b}}{\mathbf{b}\cdot\mathbf{b}}\mathbf{b}\]

Using point \(\mathbf{p}\) and the center of the circle \(\mathbf{p}_3\), the distance \(d=|\mathbf{p}_3 - \mathbf{p}|\) between the circle and the line can be determined. A necessary condition for a hit is therefore \(d \leq r\).

This check alone is not sufficient, since it produces false positives outside the range of the line segment. An additional test is required to ensure the length of vector \(\mathbf{c}\) does not exceed the length of vector \(\mathbf{b}\) on the right-hand side, and the direction of \(\mathbf{c}\) relative to \(\mathbf{b}\) is limited on the left-hand side by requiring the angle between the vectors to stay below \(90°\), i.e. \(\mathbf{b}\cdot\mathbf{c}\geq 0\).

With only these tests, the hit-test would stop orthogonally at the line-caps, excluding them. Including the line-caps is done by additionally checking whether they themselves fall within the radius \(r\).

The final hit-test condition for a circle and a line segment is then:

\[\begin{array}{rl} \text{hittest}_{LC}(L, C):=& (d\leq r\land |\mathbf{c}|\leq|\mathbf{b}|\land\mathbf{b}\cdot\mathbf{c}\geq 0)\lor |\mathbf{p}_3 - \mathbf{p}_1|\leq r\lor |\mathbf{p}_3 - \mathbf{p}_2|\leq r \end{array}\]

Hit-test: Line segment and Point

Given a line segment \(L\) defined by two points \(\mathbf{p}_1\) and \(\mathbf{p}_2\), and the point \(\mathbf{p}\) to be tested, a typical use case for \(\mathbf{p}\) being the mouse cursor. Applying the line-circle hit-test with a small radius would not hit the line segment reliably. Instead, a small margin \(\epsilon\) is placed around the line, and the orthogonal distance \(|\mathbf{m}|\leq\epsilon\) is checked:

The two points of the line form the vector \(\mathbf{b}=\mathbf{p}_2 - \mathbf{p}_1\), and the first point of the line together with the point to be tested form the vector \(\mathbf{a}=\mathbf{p} - \mathbf{p}_1\).

\(\mathbf{m}\) is the orthogonal projection of \(\mathbf{a}\) onto the normal vector \(\mathbf{n}=\mathbf{b}^\perp\). Since only the length \(m\) of \(\mathbf{m}\) is needed:

\[m^2 = (\mathbf{a}\cdot\hat{\mathbf{n}})^2=\frac{\left(\mathbf{b}^\perp\cdot\mathbf{a}\right)^2}{\mathbf{b}\cdot\mathbf{b}}\]

The sufficient condition is that the length of the margin \(|m| \leq\epsilon\) (which is why \(m^2\) is used directly), where \(\epsilon\) is the maximum activation range of the margin. What remains is a check for the line-endings: the angle between \(\mathbf{a}\) and \(\mathbf{b}\) may not exceed \(90°\), i.e. \(\mathbf{a}\cdot\mathbf{b}\geq 0\), and the length of the orthogonal projection of \(\mathbf{a}\) onto \(\mathbf{b}\) may not exceed the length of \(\mathbf{b}\) between \(\mathbf{p}_1\) and \(\mathbf{p}_2\), or:

\[\begin{array}{rrl} \Leftrightarrow & \left|\mathbf{\frac{\mathbf{a}\cdot\mathbf{b}}{\mathbf{b}\cdot\mathbf{b}}}\mathbf{b}\right|&\leq|\mathbf{b}|\\ \Leftrightarrow & \left|\mathbf{\frac{\mathbf{a}\cdot\mathbf{b}}{\mathbf{b}\cdot\mathbf{b}}}\right|\cdot |\mathbf{b}|&\leq|\mathbf{b}|\\ \Leftrightarrow & \mathbf{\frac{\mathbf{a}\cdot\mathbf{b}}{\mathbf{b}\cdot\mathbf{b}}} &\leq 1\\ \Leftrightarrow & \mathbf{a}\cdot\mathbf{b} &\leq \mathbf{b}\cdot\mathbf{b}\\ \end{array}\]

The final hit-test condition for a point and a line segment is then:

\[\text{hittest}_{LP}(L, P):= 0\leq\mathbf{a}\cdot\mathbf{b} \leq \mathbf{b}\cdot\mathbf{b} \land \left(\mathbf{b}^\perp\cdot\mathbf{a}\right)^2 \leq\epsilon^2 (\mathbf{b}\cdot\mathbf{b})\]

Hit-test: Circle and Circle

Given two circles \(C_1\) and \(C_2\) with center points \(\mathbf{p}_1\) and \(\mathbf{p}_2\) and radii \(r_1\) and \(r_2\), the distance \(d\) between the two circles is:

\[d = |\mathbf{p}_2 - \mathbf{p}_1|\]

The hit-test condition is then whether the distance is smaller than the sum of the two radii:

\[\text{hittest}_{CC}(C_1, C_2):= d \leq r_1 + r_2\]

function hittestCC(C1, C2) {

  let dx = C2.x - C1.x;
  let dy = C2.y - C1.y;
  let ar = C1.r + C2.r;
  return dx * dx + dy * dy <= ar * ar;
}

Hit-test: Rectangle and Rectangle

Given two rectangles \(R_1\) and \(R_2\), each defined by an upper-left point (\(\mathbf{a}_1\) and \(\mathbf{a}_2\)) and a bottom-right point (\(\mathbf{b}_1\) and \(\mathbf{b}_2\)), the following properties must hold:

which is:

\[\text{hittest}_{RR}(R_1, R_2):= \mathbf{a}_2^{X} \leq \mathbf{b}_1^{X} \land \mathbf{a}_1^{X} \leq \mathbf{b}_2^{X} \land \mathbf{a}_2^{Y} \leq \mathbf{b}_1^{Y} \land \mathbf{a}_1^{Y} \leq \mathbf{b}_2^{Y}\]

function hittestRR(R1, R2) {

  return R2.x1 <= R1.x2 && R1.x1 <= R2.x2 && R2.y1 <= R1.y2 && R1.y1 <= R2.y2;
}

Hit-test: Rectangle and Circle

Given a rectangle \(R\) defined by an upper-left point \(\mathbf{a}\) and a bottom-right point \(\mathbf{b}\), and a circle \(C\) defined by radius \(r\) and position \(\mathbf{c}\), the hit-test can be formalized via the distance between \(\mathbf{c}\) and the closest point \(\mathbf{p}\) on the rectangle. That closest point is found by clamping \(\mathbf{c}\) to the rectangle's dimensions:

Dragging the circle across the plot updates the closest point and the hit-test result live.

function clamp(x, min, max) {
  return Math.min(Math.max(x, min), max);
}

function collideCircleRect(C, R) {

  let closeX = clamp(C.x, R.x1, R.x2); // R.x1 <= C.x <= R.x2
  let closeY = clamp(C.y, R.y1, R.y2); // R.y1 <= C.y <= R.y2

  let dx = C.x - closeX;
  let dy = C.y - closeY;
  return dx * dx + dy * dy <= C.r * C.r;
}

Hit-test: Bezier curve and Point

For a cubic Bezier curve \(B(t) = (1-t)^3\mathbf{p}_0 + 3(1-t)^2t\,\mathbf{p}_1 + 3(1-t)t^2\,\mathbf{p}_2 + t^3\,\mathbf{p}_3\) with \(t\in[0,1]\), no closed-form hit-test exists. The squared distance between the curve and a point \(\mathbf{p}\),

\[f(t) = |B(t) - \mathbf{p}|^2\]

is a degree-6 polynomial in \(t\), so minimizing it means finding the roots of its derivative \(f'(t)=0\), a degree-5 polynomial that has no general closed-form solution.

Two practical approaches are used instead. The curve can be flattened into a sequence of short line segments by recursive subdivision until each segment is nearly straight, and then tested with the line-segment hit-tests derived above. Alternatively, \(f(t)\) can be minimized numerically, for instance with a few steps of Newton's method started from the parameter of the closest point among a small number of samples along the curve, which converges quickly since \(f\) is smooth and, for points not too far from the curve, has a single nearby minimum.

Hit-test: Triangle and Point

Given a triangle \(T\) with vertices \(\mathbf{p}_1\), \(\mathbf{p}_2\), \(\mathbf{p}_3\) and a point \(\mathbf{p}\) to be tested, the point lies inside the triangle iff it is on the same side of all three edges. "Same side" for an edge from \(\mathbf{q}_1\) to \(\mathbf{q}_2\) is decided by the sign of the 2D cross product

\[\text{cross}(\mathbf{u}, \mathbf{v}) := u^x v^y - u^y v^x\]

applied to the edge vector and the vector from \(\mathbf{q}_1\) to \(\mathbf{p}\). For the three edges of \(T\):

\[\begin{array}{rl} d_1 &:= \text{cross}(\mathbf{p}_2-\mathbf{p}_1,\ \mathbf{p}-\mathbf{p}_1)\\ d_2 &:= \text{cross}(\mathbf{p}_3-\mathbf{p}_2,\ \mathbf{p}-\mathbf{p}_2)\\ d_3 &:= \text{cross}(\mathbf{p}_1-\mathbf{p}_3,\ \mathbf{p}-\mathbf{p}_3)\\ \end{array}\]

All three signs agree exactly when \(\mathbf{p}\) is on the same side of every edge, so the sign checks work regardless of the winding order of \(T\):

\[\text{hittest}_{TP}(T, P):= \lnot\bigl((d_1<0\lor d_2<0\lor d_3<0)\land(d_1>0\lor d_2>0\lor d_3>0)\bigr)\]

function cross(u, v) {
  return u.x * v.y - u.y * v.x;
}

function hittestTP(T, P) {

  let d1 = cross({ x: T.p2.x - T.p1.x, y: T.p2.y - T.p1.y }, { x: P.x - T.p1.x, y: P.y - T.p1.y });
  let d2 = cross({ x: T.p3.x - T.p2.x, y: T.p3.y - T.p2.y }, { x: P.x - T.p2.x, y: P.y - T.p2.y });
  let d3 = cross({ x: T.p1.x - T.p3.x, y: T.p1.y - T.p3.y }, { x: P.x - T.p3.x, y: P.y - T.p3.y });

  let hasNeg = d1 < 0 || d2 < 0 || d3 < 0;
  let hasPos = d1 > 0 || d2 > 0 || d3 > 0;

  return !(hasNeg && hasPos);
}

Performance Considerations

Depending on the method used and the size of the problem, it can be worthwhile to first check a bounding rectangle around each object's area before running a more detailed test, saving computation time in all other cases.

For objects with more complex shapes, drawing all objects in a monochrome color space onto a hidden canvas and using it as a lookup table can be an effective alternative.