Hello everyone,
I have a Canvas displaying an image of a hexagon. That has six children, each displaying a small circle, as in the image below. I want to achieve this behaviour:
v6bnz7
Instead, I have the behaviour below. The raycast does hit the circle (as evidenced by the pulse and the debug info shown under ‘Current Raycast’), but not until the pointer is also inside the parent hexagon.
f534ui
I believe I’ve isolated the problem to this component, which is attached to the parent:
Component implementing the interface ‘ICanvasRaycastFilter’
[RequireComponent(typeof(PolygonCollider2D), typeof(RectTransform))]
public class PolygonColliderBasedRaycastFilter : MonoBehaviour, ICanvasRaycastFilter
{
/// <summary>
/// Works in conjunction with a Polygon Collider 2D to filter out any raycasts
/// which would normally hit the Rect of this object, but are outside the collider.
/// For when we have UI elements that are non-rectangular, and need their
/// raycasts to be accurate to their actual shape.
/// Based on a Unity Answers post by miguelSantirso here:
/// https://answers.unity.com/questions/882844/how-to-stop-non-rectangular-buttons-from-overlappi.html?childToView=981558#answer-981558
/// </summary>
private PolygonCollider2D myCollider;
private RectTransform myRectTransform;
private void Awake()
{
if (!TryGetComponent(out myCollider))
throw new System.NullReferenceException();
if (!TryGetComponent(out myRectTransform))
throw new System.NullReferenceException();
}
bool ICanvasRaycastFilter.IsRaycastLocationValid(Vector2 screenPosition, Camera eventCamera)
{
var successfullyHitPlane =
RectTransformUtility.ScreenPointToWorldPointInRectangle(
rect: myRectTransform,
screenPoint: screenPosition,
cam: eventCamera,
out var resultingPoint);
if (!successfullyHitPlane) return false;
var resultingPointIsInsideCollider = myCollider.OverlapPoint(resultingPoint);
return resultingPointIsInsideCollider;
}
}
(The hexagon has a PolygonCollider2D matching its shape, and the component above filters out any raycasts not inside that collider.)
I can eliminate the issue by removing this component or by editing the code above so that it explicitly checks against the child colliders, too. But I still don’t understand the underlying problem: why does the component on the parent object affect raycasts to its children?
In general I didn’t think raycasting was affected by parent-child relationships. And while this interface in particular should of course affect raycasts to the parent, I can’t find anything to suggest it should affect raycasts to the child.
Is this a bug, or is there some other behaviour here that I’m missing?
Many thanks in advance for your help.
Just to avoid any confusion, In the following text I am talking only about UGUI raycasts which is a completely different thing than physics raycasts.
One of the most common case for UGUI raycasts being affected by parent child relationship is buttons. If you have a fancy button that has additional icon or text drawn on top, you probably want button to click even when you hit the icon or text instead of base image. Mouse events being affected by object hierachy is also very useful for implementing popout menus.
If you search for use of ICanvasRaycastFilter in UGUI source code (reminder UGUI source code is available to you just like it is for many other packages) you will find Graphic component which is base class for most of the UGUI stuff.
Looking part that is using ICanvasRaycastFilter, you will see that checking of parent object filters is clearly intentional.
Looking at how various builtin UGUI components use ICanvasRaycastFilter explains this behavior. One of the few components using it are Mask and RectMask which are used by Scrollview. When an object inside scrollview is not visible due to current scroll position, you wouldn’t want it to be clickable.
Only other use of ICanvasRaycastFilter is pixel alpha based click filtering, but that doesn’t seem like well supported feature, as indicated by the option to enable it not being exposed to editor and also it not being serialized.
1 Like
Thank you very much for the detailed explanation. I really appreciate it.
It sounds like this behaviour is intentional, then.
In case anybody finds this post in the future, here’s the extra code I wrote to work around:
Alternative version of the component implementing ‘ICanvasRaycastFilter’
[RequireComponent(typeof(Collider2D), typeof(RectTransform))]
public class ColliderBasedRaycastFilter : MonoBehaviour, ICanvasRaycastFilter
{
/// <summary>
/// Works in conjunction with a Collider2D to filter out any raycasts which would normally hit the Rect of this object, but are outside the collider.
/// For when we have UI elements that are non-rectangular, and need their raycasts to be accurate to their actual shape.
/// Based on a Unity Answers post by miguelSantirso here: https://answers.unity.com/questions/882844/how-to-stop-non-rectangular-buttons-from-overlappi.html?childToView=981558#answer-981558
/// </summary>
[Tooltip("Components implementing ICanvasRaycastFilter will (by design, according to a forum answer I received) filter all raycasts to not only this object but also any of its children. If they're outside the collider, they won't receive raycasts either. To counteract this, if this option is set to true, the component will check not only the collider on the object it's attached to but also the colliders on any immediate children before filtering out a raycast. Note, however, that only objects with a RectTransform will be checked.")]
[SerializeField] private bool alsoTryImmediateChildColliders;
[SerializeField] private Collider2D myCollider;
private RectTransform myRectTransform;
private void Awake()
{
if (myCollider == null) throw new System.NullReferenceException();
if (!TryGetComponent(out myRectTransform)) throw new System.NullReferenceException();
}
bool ICanvasRaycastFilter.IsRaycastLocationValid(Vector2 screenPosition, Camera eventCamera)
{
var hitsParent = RaycastHitsCollider(myRectTransform, myCollider);
if (hitsParent) return true;
if (!alsoTryImmediateChildColliders) return false;
// See if the raycast hits (the collider of) any immediate children
foreach (Transform childTransform in transform)
{
if (childTransform is not RectTransform childRectTransform) continue;
if (!childRectTransform.TryGetComponent<Collider2D>(out var childCollider)) continue;
if (RaycastHitsCollider(childRectTransform, childCollider)) return true;
}
// If not, then the raycast doesn't hit
return false;
bool RaycastHitsCollider(RectTransform targetRectTransform, Collider2D targetCollider)
{
var successfullyHitPlane = RectTransformUtility.ScreenPointToWorldPointInRectangle(
rect: targetRectTransform,
screenPoint: screenPosition,
cam: eventCamera,
out var resultingPoint);
if (!successfullyHitPlane) return false;
var resultingPointIsInsideCollider = targetCollider.OverlapPoint(resultingPoint);
return resultingPointIsInsideCollider;
}
}
}
Ran into this and it feels so stupid.
The IsRaycastLocationValid implementation on Image components only has one use: checking against an alpha threshold for a compatible sprite, and whether the pointer is inside the rect is determined elsewhere.
But if you use it the way it’s supposed to be used then any child Image that is placed outside the bounds of the parent rect becomes completely uninteractable. Or maybe that’s just if the closest pixel of the sprite is below the alpha threshold? Anyway, it’s terrible.
But for Masks, the only thing IsRaycastLocationValid checks is whether the pointer is inside the rect.
Huh? That’s either entirely redundant, or the EventSystem is treating different ICanvasRaycastFilters implementers entirely differently.
Very, very, very, smelly.
This feels like a self inflicted wound, because a child Images are checked before their parents, but if either returns false the whole thing fails. But if the child is valid then the validity of the parent shouldn’t matter. I keep trying to think of a context where it would, but keep coming up blank.
My own workaround was to, inside of my Image-derived component, keep a record of the last frame IsRaycastLocationValid returned true, and automatically return true for any subsequent IsRaycastLocationValid calls for the same frame. I expected that to break something and tried to make it break, but no, it seems to work everywhere, even with masks. Still expect it to break some extreme edge case somewhere.