В этой статье я поделюсь своей реализацией брелков через CableComponent.
Идея написания данной статьи возникла у меня во время реализации брелков для оружия. Как оказалось, в Unreal Engine на данный момент не существует готовых инструментов для реализации данного аксессуара. Тут можно было подумать, что брелки возможно реализовать через стоковый CableComponent, но как бы не так:
Как можно видеть, стоковый CableComponent имеет баг, из-за которого передвижение данного компонента ведет к обновлению всех партиклов кабеля (кабель имеет минимум два партикла: начало и конец. По желанию между этими двумя партиклами можно расставить несколько дополнительных, которые будут влиять на кривизну кабеля во время движения).
Реализовывать брелки мы будем именно через переписывание стокового CableComponent, так как в случае этого, нам не нужно будет писать код для аттачинга объекта к концу веревки, и регулировки этого объекта по длине кабеля.
Основы. Подготовка кабеля и правка багов
Для начала создадим компонент-наследник компонента UMeshComponent (UCableComponent является наследником этого компонента) под названием UBibelotCableComponent:

После создания, скопируем весь код стокового UCableComponent в наш новосозданный класс UBibelotCableComponent.
Баг с обновлением всех партиклов кабеля при движении компонента происходит из-за вызова метода InitParticles() в переопределенной функции OnUpdateTransform():
void UBibelotCableComponent::OnUpdateTransform(EUpdateTransformFlags UpdateTransformFlags, ETeleportType Teleport)
{
Super::OnUpdateTransform(UpdateTransformFlags, Teleport);
InitParticles();
UpdateBounds();
}
Чтобы от этого бага избавиться, достаточно будет просто прекратить вызывать OnTransformUpdate() при движении нашего компонента. Это можно сделать в конструкторе UBibelotCableComponent, установив значение флага bWantsOnUpdateTransform в false:
UBibelotCableComponent::UBibelotCableComponent(const FObjectInitializer& ObjectInitializer)
: Super(ObjectInitializer)
{
PrimaryComponentTick.bCanEverTick = true;
bTickInEditor = true;
bAutoActivate = true;
bAttachStart = true;
bAttachEnd = true;
CableWidth = 10.f;
NumSegments = 10;
NumSides = 4;
EndLocation = FVector(100.f, 0, 0);
CableLength = 100.f;
SubstepTime = 0.02f;
SolverIterations = 1;
TileMaterial = 1.f;
CollisionFriction = 0.2f;
CableGravityScale = 1.f;
SetCollisionProfileName(UCollisionProfile::PhysicsActor_ProfileName);
bWantsOnUpdateTransform = false; // Этого флага
}
Также, для корректного поведения кабеля нужно выполнить ряд действий с компонентом уже в блюпринтах:
Переключить Tick Group нашего BibelotCableComponent на Post Physics.
Выставить проперти Attach End в false.
Выставить следующие настройки кабеля:
Как результат, получаем такое поведеление кабеля:
Как можно видеть, такое поведение брелка очень далеко от реального, но с этим хотя бы уже можно работать.
Написание коллизии для брелка
Так как предполагается, что наш брелок будет привязан только к оружию, то соответственно есть смысл просчитывать коллизию только с этим оружием. То есть в данном случае достаточно будет просчитывать коллизию брелка с одной лишь плоскостью.
Коллизия будет просчитываться следующим образом: После всех вычислений положения конца кабеля (но до вызова функции рендеринга самого кабеля), мы будем проверять, пересек ли конец этого кабеля выбранную плоскость. Если же пересечение произошло, то мы пускаем луч из конца кабеля в сторону плоскости и фиксируем точку пересечения луча и этой плоскости. После чего точка пересечения луча и нашей плоскости становится новым концом кабеля.

Таким образом, конец кабеля никогда не окажется за пределами выбранной плоскости (то есть конец кабеля никогда не отрисуется в меше оружия).
Код метода, описывающего данный алгоритм:
void UBibelotCableComponent::FixEndParticleLocation()
{
FVector SurfaceDot = Particles[0].Position + GetOwner()->GetActorForwardVector() * (PositionWeaponXCheckOffset);
float SurfaceValue = GetSurfaceValue(SurfaceDot, SurfaceDot + GetOwner()->GetActorRightVector(), SurfaceDot + GetOwner()->GetActorUpVector(), Particles[1].Position);
if (SurfaceValue < 0)
{
auto Plane = UE::Math::TPlane<double>(SurfaceDot, SurfaceDot + GetOwner()->GetActorRightVector(), SurfaceDot + GetOwner()->GetActorUpVector());
auto IntersectDot = FMath::LinePlaneIntersection(Particles[1].Position, Particles[1].Position + GetOwner()->GetActorForwardVector() * 100, Plane);
Particles[1].Position = (IntersectDot - Particles[0].Position).GetSafeNormal() * CableLength + Particles[0].Position;
Particles[1].OldPosition = Particles[1].Position;
}
}
(*) Сейчас PositionWeaponXCheckOffset = 0.5f и служит параметром для регулировки плоскости оружия (то есть через него мы можем перемещать плоскость оружия по одной оси). Также, так как мы будем использовать всего два партикла, то Particles[0] - начало кабеля, а Particles[1] - конец кабеля.
Вызывать эту функцию мы должны в тике, перед вызовом функции MarkRenderDynamicDataDirty() (рендер кабеля).
Результат:
Как можно видеть, наша коллизия работает, но беспорядочные колебания кабеля остались.
Уравнения вынужденных колебаний маятника
Как можно видеть, стоковый кабель по умолчанию не предназначен для нужных нам колебаний. Расчет движения стокового кабеля достаточно прост. Он не предполагает никаких уравнений движения, и зависит от вектора, который указывает на новую позицию конца кабеля. Мы же подчиним колебания кабеля уравнениям вынужденных колебаний маятника (Вынужденные колебания - колебания, которые возникают под действием изменяющихся сил).
Дифференциальные уравнения вынужденных колебаний маятника имеют вид:

Где:
θ : угол отклонения маятника
ω : угловая скорость
g : сила гравитации
R : длина веревки
b : коэффициент трения
m : масса конца кабеля
A : амплитуда прикладываемой силы (именно этот параметр нужно динамически изменять для применения силы к маятнику)
t : время (также будет изменяться динамически в зависимости от DeltaTime)
Решать данное уравнение будем методом Рунге-Кутты (один из численных методов решения Дифференциальных уравнений), который отлично подходит для решения дифференциальных уравнений первого порядка. Наше уравнение (в изначальном виде) имеет второй порядок и может быть записано как система из двух уравнений первого порядка:

Решая эту систему по методу Рунге-Кутты получаем:

Где:

(*) h - длина шага.
По умолчанию все вычисления положения кабеля вычисляются в главном цикле метода VerletIntegrate(). Весь текущий код цикла оттуда нужно удалить, и вставить полученное решение уравнений вынужденных колебаний:
Код метода VerletIntegrate() после всех манипуляций:
void UBibelotCableComponent::VerletIntegrate(float InSubstepTime, const FVector& Gravity, float DeltaTime)
{
auto f = [&](float time, float tht, float omega) -> float {
return omega;
};
auto g = [&](float time, float tht, float omega) -> float {
return -(grav / R) * sin(tht) - ((FinalB / (m * R * R)) * omega) + ((A / (m * R * R)) * cos(omega * time));
};
const int32 NumParticles = NumSegments + 1;
const float SubstepTimeSqr = InSubstepTime * InSubstepTime;
Particles[0].OldPosition = Particles[0].Position;
for (int32 ParticleIdx = 0; ParticleIdx < NumParticles; ParticleIdx++)
{
FBibelotCableParticle& Particle = Particles[ParticleIdx];
if (Particle.bFree)
{
// Update position
FVector NewPosition;
if (omg > OmegaLimitMin)
{
FinalB = OmegaLimitB;
}
k1 = h * f(time, theta, omg);
l1 = h * g(time, theta, omg);
k2 = h * f(time + (0.5 * h), theta + (0.5 * k1), omg + (0.5 * l1));
l2 = h * g(time + (0.5 * h), theta + (0.5 * k1), omg + (0.5 * l1));
k3 = h * f(time + (0.5 * h), theta + (0.5 * k2), omg + (0.5 * l2));
l3 = h * g(time + (0.5 * h), theta + (0.5 * k2), omg + (0.5 * l2));
k4 = h * f(time + h, theta + k3, omg + l3);
l4 = h * g(time + h, theta + k3, omg + l3);
theta = theta + (k1 + (2 * k2) + (2 * k3) + k4) / 6;
omg = omg + (l1 + (2 * l2) + (2 * l3) + l4) / 6;
if (theta > 2 * PI)
theta = theta - (2 * PI);
if (theta < -2 * PI)
theta = theta + (2 * PI);
time = time + DeltaTime;
A = 0;
FinalB = b;
FVector XZVec = FVector{ CableLength * FMath::Cos(theta - PI / 2.0f), 0, CableLength * FMath::Sin(theta - PI / 2.0f) };
NewPosition = XZVec + Particles[0].Position;
Particle.OldPosition = Particle.Position;
Particle.Position = UKismetMathLibrary::VInterpTo(Particle.Position, NewPosition, DeltaTime, InterpToSpeed);
}
}
}
Получаем следующее поведение:
(*) Коэффициенты уравнения при этом равны:

Как можно видеть, теперь движение кабеля походит на колебания брелка, но в данный момент эти движения происходят всегда в одной плоскости (в данном случае вдоль оружия).
Регулировка плоскости колебаний маятника

Будем регулировать (задавать) плоскость колебаний маятника в зависимости от направления вектора силы, прикладываемого к нашему кабелю. При добавлении нового вектора силы, текущий будет интерполироваться к новому.
Для этого заведем вектор FVector GeneralForce, который и будет конечным вектором направления силы, которому подчиняется плоскость колебания маятника.
Добавим в метод VerletIntegrate() следующие куски кода:
Код зануления вектора направления силы:
if (FMath::IsNearlyZero(omg)) // Если угловая скорость маятника близка к нулю
{
GeneralForce = { 0, 0, 0 };
}
Код изменения вектора направления силы и придания силы маятнику в зависимости от перемещения оружия:
Vel = GetOwner()->GetOwner()->GetActorLocation() - OldBibelotLoc;
if (!Vel.IsNearlyZero())
{
VelForce = -Vel.GetSafeNormal();
float FinalOmgVelChange = CachedCharacter->GetVelocity().IsNearlyZero() ? AVelChangeNoVelocity : AVelChangeVelocity;
A += FinalOmgVelChange * Vel.Length();
}
GeneralForce = UKismetMathLibrary::VInterpTo(GeneralForce, VelForce, DeltaTime, GeneralForceInterpSpeed);
(*) В данном случае OldBibelotLoc вычисляется в самом конце тика компонента, и равняется GetOwner()->GetOwner()->GetActorLocation().
Изменим вычисление вектора NewPosition:
NewPosition = BackRotateToXAxis(GeneralForce, XZVec) + Particles[0].Position;
Где функция BackRotateToXAxis поворачивает вектор XZVec так, чтобы тот лежал в вертикальной плоскости вектора GeneralForce.
Код функции BackRotateToXAxis:
FVector UBibelotCableComponent::BackRotateToXAxis(FVector StartAxis, FVector RotVector)
{
float ZComp = RotVector.Z;
RotVector = { RotVector.X, RotVector.Y, 0 };
float Angle = FMath::RadiansToDegrees(FMath::Acos(FVector::DotProduct(FVector{ StartAxis.X, StartAxis.Y, 0 }.GetSafeNormal(), RotVector.GetSafeNormal())));
RotVector = RotVector.RotateAngleAxis(Angle * FMath::Sign(FVector::CrossProduct(RotVector, StartAxis).Z), FVector{ 0, 0, 1 });
RotVector.Z = ZComp;
return RotVector;
}
В результате получаем следующее поведение:
Проблема с FPS
Чтобы исправить проблему с изменением поведения кабеля при разном FPS, нужно добавить следующий цикл в метод TickComponent():
for (int32 i = 0; i < (CableFPS * CableFPS) / (1.0f / DeltaTime); ++i)
{
TickBody(1.0f / (float)CableFPS);
}
То есть мы устанавливаем некоторое константное кол-во тиков (не зависящее от текущего FPS), которое должно проигрываться в секунду (например при CableFPS = 60, метод TickBody() должен будет вызваться 3600 раз за секунду. При этом CableFPS мы устанавливаем сами, в зависимости от поведения маятника, которое мы хотим получить).
(*) в метод TickBody() вынесен весь код тика.
Косметические добавления
Добавим крепление для брелка (кабеля), состоящее из двух мешей:
Крепление к самому оружию (может вращаться только в плоскости Forward вектора оружия).
Крепление к брелку (Крепится к первому мешу, и может вращаться только в плоскости Right вектора оружия). Сам же кабель будет крепиться ко второму мешу.
Pivot для первого меша будет находится в Left части меша, а Pivot для второго меша будет находится в Top части своего меша (опять же в зависимости от используемых мешей могут быть разные расположения пивотов).
(*) Pivot - опорная точка объекта. В данном случае мы ее изменяем для корректного вращения мешей
Вращение данных мешей будет происходить в тике блюпринта брелка, и зависеть от вращений самого кабеля.
Код вращения первого меша:

Код вращения второго меша:

Где метод GetXZCableRotation() возвращает FRotator кабеля в плоскости XZ:
FRotator UBibelotCableComponent::GetXZCableRotation()
{
return UKismetMathLibrary::FindLookAtRotation({ 0, 0, 0 }, RotateToXAxis(Particles[1].Position - Particles[0].Position));
}
(*) Функция RotateToXAxis() - это обратная операция от функции BackRotateToXAxis(). То есть, если функция BackRotateToXAxis() вращает вектор из плоскости XZ в другую плоскость, то функция RotateToXAxis() вращает вектор из какой-либо плоскости в плоскость XZ:
FVector UBibelotCableComponent::RotateToXAxis(FVector RotVector)
{
float ZComp = RotVector.Z;
RotVector = { RotVector.X, RotVector.Y, 0 };
float Angle = FMath::RadiansToDegrees(FMath::Acos(FVector::DotProduct({ 1, 0, 0 }, RotVector.GetSafeNormal())));
RotVector = RotVector.RotateAngleAxis(Angle * FMath::Sign(FVector::CrossProduct(RotVector, { 1, 0, 0 }).Z), FVector{ 0, 0, 1 });
RotVector.Z = ZComp;
return RotVector;
}
Улучшение коллизии брелка
Так как мы крепим брелок ко второму мешу (который движится), то следовательно и начало кабеля так же будет перемещаться. А так как сейчас вычисление плоскости оружия для коллизии зависит от локации начала кабеля (Particles[0].Position), то коллизия будет работать не корректно.
То есть нужно вычислять плоскость оружия для коллизии не относительно начала кабеля, а относительно первого меша (так как он не перемещается):
void UBibelotCableComponent::FixEndParticleLocation()
{
auto BibelotMesh = GetOwner()->FindComponentByTag<UStaticMeshComponent>("BibelotMesh");
FVector SocketLoc1 = BibelotMesh->GetSocketLocation("ScaleSocket1");
FVector SocketLoc2 = BibelotMesh->GetSocketLocation("ScaleSocket2");
auto BibelotFixasion = GetOwner()->FindComponentByTag<UStaticMeshComponent>("BibelotFixation1");
float PositionWeaponXCheckOffset = (SocketLoc1 - SocketLoc2).Length();
float AdvancedWeaponXOffset = abs(BibelotFixasion->GetSocketLocation("BibelotFixationSocket").X - Particles[0].Position.X);
FVector SurfaceDot = Particles[0].Position + GetOwner()->GetActorForwardVector() * (PositionWeaponXCheckOffset - AdvancedWeaponXOffset);
float SurfaceValue = GetSurfaceValue(SurfaceDot, SurfaceDot + GetOwner()->GetActorRightVector(), SurfaceDot + GetOwner()->GetActorUpVector(), Particles[1].Position);
if (SurfaceValue < 0)
{
auto Plane = UE::Math::TPlane<double>(SurfaceDot, SurfaceDot + GetOwner()->GetActorRightVector(), SurfaceDot + GetOwner()->GetActorUpVector());
auto IntersectDot = FMath::LinePlaneIntersection(Particles[1].Position, Particles[1].Position + GetOwner()->GetActorForwardVector() * 100, Plane);
Particles[1].Position = (IntersectDot - Particles[0].Position).GetSafeNormal() * CableLength + Particles[0].Position;
Particles[1].OldPosition = Particles[1].Position;
}
}
В данном случае компонент с тегом "BibelotFixation1" является первым мешем ("BibelotFixationSocket" - сокет, относительно которого будет рассчитываться положение плоскости оружия); Компонент с тегом "BibelotMesh" есть меш, который крепится к концу кабеля ("ScaleSocket1" - центр меша, "ScaleSocket2" - конец меша).