Лучевые запросы: слои, исключения и выбор мышью
Мировые координаты DirectSpaceState, локальная цель RayCast и полный обработчик выбора 3D из viewport.
Два способа пустить луч
RayCast2D/3D — сущность с обновляемым результатом. Её Position задаёт начало, TargetPosition — локальную конечную точку относительно неё. DirectSpaceState.IntersectRay — разовый запрос с мировыми From/To. To — конец отрезка, не направление и не длина. Для конца по направлению вычислите origin + normalizedDirection × distance.
В самостоятельном уровне 2D и уровне 3D есть оба способа. Методы Cast показаны целиком в этих классах; LastRayHit у 2D обновляется в _PhysicsProcess. Для RayCast сначала проверьте IsColliding и только затем GetCollider/GetCollisionPoint/GetCollisionNormal. Если в этом же шаге меняли цель и нужен новый результат, ForceRaycastUpdate принудительно обновляет его, в том числе при Enabled = false. Это не выполняет новый шаг всей физики.
Доступ к миру и результат
Берите GetWorld2D/3D().DirectSpaceState у сущности в нужном viewport/world. Вход и выход должны быть конечными координатами в одной мировой системе. Выполняйте запрос в _PhysicsProcess: при потоковой физике пространство может быть заблокировано в неподходящей фазе. Обработчик ввода сохраняет намерение, а физический callback выполняет запрос.
IntersectRay возвращает пустой Dictionary при промахе. При попадании полезны collider, position, normal, rid и shape. Проверяйте Count/ключи перед чтением и тип collider перед приведением к игровому классу. Ссылка на найденный объект не гарантирует, что он ещё существует при последующей отложенной обработке. Сохраняйте нужные данные или проверяйте IsInstanceValid/IsQueuedForDeletion.
Фильтрация
CollisionMask запроса сравнивается с CollisionLayer кандидатов. Mask тела, которое держит оружие или камеру, автоматически в запрос не копируется. CollideWithBodies и CollideWithAreas выбирают категории; зоны по умолчанию не включены. Для исключения используйте RID физического объекта в Exclude, не GetInstanceId и не ссылку на визуальный MeshInstance. У RayCast есть AddException/RemoveException и исключение родительского тела; его область не равна исключению всей игровой иерархии.
Если начало внутри формы, HitFromInside меняет поведение поддерживаемых форм; при таком попадании normal может быть нулевой. Не нормализуйте её без проверки. Полые concave-поверхности и обратные стороны треугольников имеют отдельные ограничения; HitBackFaces в 3D не превращает trimesh в заполненный объём. Пример проверяет inside-hit у BoxShape3D в установленном backend, а не все формы всех backend.
Выбрать объект в 3D
Добавьте ScreenPicker3D.cs к обычной Entity в вашем 3D-уровне и назначьте Camera в Inspector. На выбираемых телах включите Layer 3 (бит 4), либо измените Mask. Distance должна быть конечной и положительной. Готовый CollisionLab3D создаёт и назначает этот компонент сам.
_UnhandledInput получает щелчок, который не был обработан интерфейсом. Координаты click.Position относятся к его viewport; Camera должна смотреть в тот же viewport. ProjectRayOrigin/Normal учитывают тип проекции. Нельзя всегда брать Camera.GlobalPosition за начало: это неверно для ортографической проекции. При SubViewportContainer или UI-масштабировании сначала согласуйте координаты viewport.
ScreenPicker3D.cs
using Reckfell;
using Hit = Reckfell.Collections.Dictionary;
public partial class ScreenPicker3D : Entity
{
[Export] public Camera3D? Camera { get; set; }
[Export] public uint Mask { get; set; } = 4;
[Export] public float Distance { get; set; } = 100;
private Vector2? _pending;
public Hit LastHit { get; private set; } = new();
public int QueryCount { get; private set; }
public void RequestPick(Vector2 viewportPosition) { if (viewportPosition.IsFinite()) _pending = viewportPosition; }
public override void _UnhandledInput(InputEvent @event)
{
if (@event is InputEventMouseButton { Pressed: true, ButtonIndex: MouseButton.Left } click)
RequestPick(click.Position);
}
public override void _PhysicsProcess(double delta)
{
if (_pending is not Vector2 screen) return;
_pending = null; LastHit = new();
if (Camera is null || !ReckfellObject.IsInstanceValid(Camera) || !Camera.IsInsideTree() || !float.IsFinite(Distance) || Distance <= 0) return;
Vector3 origin = Camera.ProjectRayOrigin(screen);
Vector3 end = origin + Camera.ProjectRayNormal(screen) * Distance;
var query = PhysicsRayQueryParameters3D.Create(origin, end, Mask);
query.CollideWithAreas = false;
LastHit = Camera.GetWorld3D().DirectSpaceState.IntersectRay(query);
QueryCount++;
}
}LastHit обновится после физического callback. Следующий промах очищает прежнее попадание. Повторные запросы до шага объединяются в последний щелчок — это выбранное поведение компонента; для очереди выстрелов храните все события отдельно. Скрипт не открывает ссылки, не удаляет объекты и не рисует выделение. GUI должен действительно обрабатывать щелчок, если он не должен проходить в мир.
Если попадает не туда
Промах — проверьте From/To, длину, Layer цели, Mask, категории тел/зон и текущий мир. Попадает в себя — RID-исключение и родителя RayCast. Неверный конец — локальный TargetPosition перепутан с мировым To. Неверный выбор мышью — координаты окна смешаны с viewport или выбрана другая камера. Луч не имеет толщины: для объёма снаряда используйте подходящий ShapeCast/shape-query, а не ряд случайно сдвинутых лучей без проверки.