DSprtn / GTFO_VR_Plugin

A plugin to add full roomscale Virtual Reality support to your favorite game!
MIT License
143 stars 13 forks source link

Melee overhaul #52

Closed Nordskog closed 11 months ago

Nordskog commented 11 months ago

What

A complete overhaul of VR Melee collision and hit detection.

Below is a write-up of how melee works in pancake, the current VR implementation, and a summary of the issues that this PR seeks to resolve. It's mostly for my own reference, so feel free to skip to the collapsed sections.

Original Pancake logic The original pancake hit detection is handled by `MeleeWeaponFirstPerson.CheckForAttackTargets()` and checks for hits using 3 different methods: - Ray directly forward from camera center - Melee weapon hitbox sphere overlap - Ray between camera and melee weapon hitbox These are performed in order, and unless the weapon is piercing ( spear ), it will only return the hits for the first method. For every hit, the `BaseDamagable` `Dam_EnemyDamageBase` is queried and tagged with an identifier. If multiple hits are detected during the same call, any colliders whose `BaseDamagable` has already been tagged will be ignored. `MeleeWeaponFirstPerson` uses `eMeleeWeaponState`s that are mapped to a number of implementations of `MWS_Base`, which in turn execute the logic for the various states. The attack check loop occurs in `MWS_AttackSwingBase`, which is extended by `MWS_AttackLight` and `MWS_AttackHeavy`, corresponding to normal attacks and charged attacks. These states will run the hit detection check continuously for a window of time after the attacked has started ( mouse released ), defined by `MeleeAttackData.m_damageStartTime` and `MeleeAttackData.m_damageEndTime`. Once a target has been hit the state is changed to `AttackHitRight` or `AttackChargeHitRight`, both mapped to `MWS_AttackHit`. It will remain in this state for the duration defined in `MeleeAttackData.m_attackLength`. Here it loops through the hits returned by `MeleeWeaponFirstPerson.CheckForAttackTargets()` and call `MeleeWeaponFirstPerson.DoAttackDamage()` on each of them, actually applying the damage and creating gore and blood splashes and such. If the weapon is a piercing ( spear ) , it will also continue to check for new hits while in this state, adding them to the list of hits to be applied on the next call to this state's `Update()`. For a short while ( early R6 ) you could abuse this to damage a whole row of enemies by quickly swiping the spear across them. Currently, they still perform the extra hit detection, but damage is not applied to the resulting hits unless the time spent in the `AttackHit` state is less than `MeleeAttackData.m_damageEndTime`. As of writing this value is always `0`, so these hits are always ignored. The Spear can only damage multiple enemies if detected during the initial hit detection; all the other logic they presumably added for the spear is effectively disabled.
Existing VR logic First, `VRMeleeWeapon.Update()` updates the hitbox location ( `MeleeWeaponModelData.m_damageRefAttack` ) to match the head of the weapon model. All the melee weapons are shifted by `45 degrees` in `WeaponArchetypeVRData`, so the hitbox offset are all diagonal x/z coordinates. The offset coordinates are added to the local position of `Controllers.MainController` to determine the hitbox position. Problems: - In addition to the 45 degree offset, the `configWeaponRotationOffset` value is subtracted from the offset during initialization in `WeaponArchetypeVRData`. This results in the model being rotated by the offset ( permanently until restart ), in addition to the the offset being applied directly to the SteamVR pose. - The hitbox position offset is applied to `Controllers.MainController`, meaning: - The `configWeaponRotationOffset` applied to the weapon model is not taken into account and the hitbox will be off by the offset amount - other offsets defined in `WeaponArchetypeVRData` cannot be modified without having to redefine the hitbox offsets - Something changed at some point and the hitboxes are not quite where they probably should be anymore The size ( radius ) of the hitbox `WeaponHitboxSize` is also defined for each weapon, along with a `WeaponHitDetectionSphereCollisionSize` used to determine when to automatically release the charge. The hitbox is multiplied by `0.1f` for visualization purposes, and by the original `sphereRad` of the weapon when before being passed to the original `MeleeWeaponFirstPerson.CheckForAttackTargets()` function for hit detection. Automatically releasing the hammer when within range of something `bonkable` is handled in `InjectAutoReleaseHammerSmack` patching `MWS_ChargeUp.Update`. The conditions for releasing the charge is: - `damageRef` must be within `VRMeleeWeapon.WeaponHitDetectionSphereCollisionSize * .75f` of an enemy collider AND - `MainControllerPose.GetVelocity().magnitude` must be greater than `0.5f` OR - `damageRef` must be within `VRMeleeWeapon.WeaponHitDetectionSphereCollisionSize * .25f` of an enemy collider AND - `MainControllerPose.GetVelocity().magnitude` must be greater than `1.2f` If either is true, `MWS_ChargeUp.OnChargeupRelease()` will be called, changing the mapped state to `MWS_AttackHeavy`, which will call `MeleeWeaponFirstPerson.CheckForAttackTargets()` on a loop as it would in the pancake logic. `InjectAllowInstantDamageInVRSwing` modifies `MeleeAttackData.m_damageStartTime` to allow for hits to occur whenever, rather than being limited to the damage window of the pancake swing animation. Additionally, `MeleeWeaponFirstPerson.CheckForAttackTargets()` itself is detour'd to ensure no hits can be detected unless `MainControllerPose.GetVelocity().magnitude` is greater than `0.4f` When the charging is first started, it will be in the `MWS_AttackLight` state for a few moments, before transitioning to `MWS_ChargeUp`. During this window it will act similar to `MWS_AttackHeavy`. Problems: - There is a 1 frame delay between `MWS_ChargeUp.OnChargeupRelease()` being called and `MWS_AttackHeavy.Update()` first being called, often resulting in melee weapons phasing through objects before stick hit detection begins proper. - During the initial `MWS_AttackLight` window, and after charge is released manually, the weapon will continue to look for hits for the duration of the pancake attack animation, even if the player has removed their finger from the trigger. - `MainControllerPose.GetVelocity()` does not take angular velocity into account. This results in the velocity often being below the threshold when only rotating the wrist to bonk enemies, and hits not being detected. It also returns 0 when using `Pico's Streaming Assistant`, breaking melee completely. Finally, the stock `MeleeWeaponFirstPerson.CheckForAttackTargets()` itself has not been modified in any way, other than `InjectAllowInstantDamageInVRSwing` setting `MeleeWeaponFirstPerson.MeleeArchetypeData.CameraDamageRayLength` to `0` to disable the first ray-forward-from-camera collision check. This leaves us with a ray from camera to the `damageRef` position, and the `OverlapSphere` check. The latter is very framerate dependent and will easily phase through objects. The former is the reason you generally always hit *something*, since it will detect a hit as long as an enemy is between the camera and the `damageRef` position. Problems: - Melee weapon can phase through objects without detecting a hit - camera-damageRef racycast will generally ensure you hit something, even when you miss
Summary of problems - In addition to the 45 degree offset, the `configWeaponRotationOffset` value is subtracted from the offset during initialization in `WeaponArchetypeVRData`. This results in the model being rotated by the offset ( permanently until restart ), in addition to the the offset being applied directly to the SteamVR pose. - The hitbox position offset is applied to `Controllers.MainController`, meaning: - The `configWeaponRotationOffset` applied to the weapon model is not taken into account and the hitbox will be off by the offset amount - other offsets defined in `WeaponArchetypeVRData` cannot be modified without having to redefine the hitbox offsets - Hitboxes are not positioned correctly, and some are too small or too large. ![old_hitboxes](https://github.com/DSprtn/GTFO_VR_Plugin/assets/8961771/e718c51d-e801-41ae-a2c7-ab21cc3ed3f5) - There is a 1 frame delay between `MWS_ChargeUp.OnChargeupRelease()` being called and `MWS_AttackHeavy.Update()` first being called, often resulting in melee weapons phasing through objects before stick hit detection begins proper. [Bonk Phase Through-1.webm](https://github.com/DSprtn/GTFO_VR_Plugin/assets/8961771/ea3b15f1-668b-4b76-ab10-90a774fd313c) - During the initial `MWS_AttackLight` window, and after charge is released manually, the weapon will continue to look for hits for the duration of the pancake attack animation, even if the player has removed their finger from the trigger. - `MainControllerPose.GetVelocity()` does not take angular velocity into account. This results in the velocity often being below the threshold when only rotating the wrist to bonk enemies, and hits not being detected. It also returns 0 when using `Pico's Streaming Assistant`, breaking melee completely. - Single-frame `OverlapShpere` hit detection can result in melee weapons phasing through objects without detecting a hit - Raycast from camera to weapon melee hitbox will often result in a hit even when you miss ## New VR logic Summary: - `MainController` pose position and orientation tracked to determine velocity for minimum-bonk threshold - `damageRef` hitbox offset based on weapon position rather than hand position - Bonk prevented unless trigger is held - Hit detection completely replaced with a proper `CastSphere` to detect inter-frame collisions - Debug visualizations expanded to show path and enemy hitbox - Melee hitbox offsets tweaked, optional elongated hitboxes consisting of 1 to 3 spheres. ### ItemEquippable Ideally, the hitboxes should be relatively to the `MeleeWeaponFirstPerson` transform, rather than being based on the hand pose of the main controller, so we don't have to rotate anything or use diagonal coordinates to match the model offsets defined in `WeaponArchetypeVRData`. `VRPlayer.UpdateHeldItemTransform()` is responsible for transforming any `ItemEquippable` we have equipped to where it should be, including applying any offsets defined in `WeaponArchetypeVRData`. This functions is called multiple times a frame, including twice in response to pose updates at the beginning and the end of the frame. For some of these calls, the position evaluated will be incorrect, including the last call before `VRMeleeWeapon.Update()` is called, resulting in our hitbox ending up in the wrong position. [Wielded Item Offset-1.webm](https://github.com/DSprtn/GTFO_VR_Plugin/assets/8961771/6200d737-8f73-4373-9e6c-e5025927d921) `WeaponArchetypeVRData.CalculateGripOffset()` appears to be the culprit, but I am not entirely sure why it happens. ```csharp public static Vector3 CalculateGripOffset() { Transform itemEquip = ItemEquippableEvents.currentItem.transform; return itemEquip.position - itemEquip.TransformPoint(m_current.positonOffset); } ``` Rewriting the function with logic I am more capable of parsing produces the correct result every time it is called: ```csharp public static Vector3 CalculateGripOffset(Transform heldItem) { // Rotation of thing we're holding + item rotation offset Quaternion ControllerUpPlusOffset = heldItem.transform.rotation * m_current.rotationOffset; // Rotate offset position by rotation to get offset in space of heldItem return ControllerUpPlusOffset * (-m_current.positonOffset); } ``` ### Hitboxes Since we are using the already-offset `MeleeWeaponFirstPerson` transform now, hitbox offsets are aligned and the Y axis is straight up along the weapon. You now define `m_offsetTip` and `m_offsetBase` positions, which will be used to check for collisions using multiple hitboxes if `m_elongatedHitbox` is set to true. if `m_centerHitbox` is true, a third hitbox that is the average of the `tip` and `base` will be generated. The Hammer is defined as follows, with one sphere for each of the two hammer heads: ```csharp m_hitboxSize = .07f; m_offsetTip = new Vector3(0, 0.42f, 0.1f); // Front-facing hammer head m_offsetBase = new Vector3(0, 0.42f, -0.1f); // Back-facing hammer head m_elongatedHitbox = true; // we want to generate a hitbox for both the tip and the base m_centerHitbox = false; // We do not want a third hitbox between the tip and the base ``` The hitbox size values are now the actual radius of the collider, and and do not need to be multiplied by anything. ![new_hitboxes](https://github.com/DSprtn/GTFO_VR_Plugin/assets/8961771/f12473bc-e45a-41eb-8b63-236e456a5581) ### Velocity and previous position Since we ultimately can't rely on querying SteamVR for the controller velocity, and we need to keep track of where the hitboxes were on the previous frame, we store the position and rotation of the `MainController`, as well as the `tip` and `base` positions of the hitboxes, after evaluating them. The new `VelocityTracker` class tracks stores the position and rotation provided to it, and provides the average `velocity` and `angular velocity` for a configurable span of time ( delta time considered ). Internally it's just a `Queue` that stores the data points, and adds the velocities to a running average. When points are pushed out of the queue they are removed from the running averages. It also provides easy access to the last two points of data. `GetSmoothVelocity()` and `GetSmoothAngularVelocity()` provide the smoothed values, and are used by `VRMeleeWeapon.VelocityAboveThreshold()` to determine if the player is moving their controller fast enough to allow bonking. [Hammer Vectors-1.webm](https://github.com/DSprtn/GTFO_VR_Plugin/assets/8961771/187dcbaf-c14e-43a8-8df4-47155623b6a4) ### Charge release The stock `MeleeWeaponFirstPerson.CheckForAttackTargets()` has been completely replaced. `VRMeleeWeapon.CheckForAttackTargets()` is first called by us in `InjectAutoReleaseHammerSmack`, where we previously used a large `OverlapSphere` to determine if the melee weapon were in vicinity of something bonkable. After calling `MWS_ChargeUp.OnChargeupRelease()' it takes another frame for `Update()` to be called on the next state, so we call that immediately ourselves. After this it's mostly down to the stock logic, until it tries to call the original `MeleeWeaponFirstPerson.CheckForAttackTargets()`, and we replace it with our `VRMeleeWeapon.CheckForAttackTargets()` in `HammerAttackCheckDetour`. In addition to the aforementioned velocity check, we also ensure that player is still pressing the `fire` button, as the pancake hammer logic still runs when the button is released, checking for hits for the duration of the stock attack animation. ### Hit detection `VRMeleeWeapon.CheckForAttackTargets()` uses the position of the hitbox ( tip, base, and optional center ) on the previous and current frame, and performs a `CastSphere` between the two positions. For most weapons ( i.e everything but the spear ) we use the distance of the hit from to determine what it impacted first, and discard any later hits. The technically-but-not-really piercing spear is special and may return multiple hits on a single frame, but any hits that occur after a static collider is it will be discarded. If no hits are detected, or if it's a piercing weapon and we have not already encountered a static collider, we also run a normal `OverlapSphere` to catch improbable edge cases where the collider moves to encompass the previous the previous position of the hitbox, since `CastSphere` will not detect an intersection if already inside of the collider. All the pancake logic to tag the `BaseDamagable` so we only strike any given enemy once is retained, though this is only relevant if using the spear. The logic from `InjectVRHammerSmackDoors` was also moved to the beginning of `VRMeleeWeapon.CheckForAttackTargets()`. Previously the first hit to a door would impact a door-wide collider and not do any damage, while any follow-up hits would impact the individual panels and do damage. Bonking doors now works on the first hit. The logic appears to be unchanged from the base game. [Thin Object-1.webm](https://github.com/DSprtn/GTFO_VR_Plugin/assets/8961771/3fd37b0b-525d-4a63-993c-472fe61a326c) [Bonk Temporal Sort-1.webm](https://github.com/DSprtn/GTFO_VR_Plugin/assets/8961771/57ac6a63-1825-4028-8b6a-7a65e96e382e) ### Debug visualizations In a `Debug Build` with `configDebugShowHammerHitbox` enabled, the following visuals are rendered: - Melee weapon hitbox spheres - Colliders we can hit ( when close ) - Cones showing hitbox path between frames ( When looking for hits ) - Collider that was hit - hitbox path on hit - hit location GTFO's own `DebugDraw3D` was bugging out, so everything but `cones` are drawn using our own `GTFODebugDraw3D`, which correctly scales and draws all colliders, including capsules and meshes. Note that the hit position visualization is a sphere the size of the melee hitbox centered on the impact location, rather than where the hitbox would actually have been on impact. Close enough. [Bonk-1.webm](https://github.com/DSprtn/GTFO_VR_Plugin/assets/8961771/53ffc557-13ab-4f9e-9526-096bdb20c5e5) Note here that the arm colliders are still visualized, despite being destroyed. They can in fact still be hit. Destroying the torso will not remove the head collider, despite it being visually gone. You can still hit and destroy the head in this state. ### Obsolete patches `InjectPrioritizeProximityTargetsOnLightHit`, which would sort `MeleeWeaponFirstPerson.HitsForDamage` so hits closer to `damageRef` would come first, has been removed. As of writing, and as far as I can tell, the order in this list would only matter when using the spear to pierce multiple targets across multiple frames, as it checks this list for existing hits to the same `agent`. This logic is effectively disabled for the spear now, and the new hit detection should detect hits in the correct order anyway. `InjectMeleeIgnoreCameraDir` would set disable the camera-to-damageRef collision check by setting the `CameraDamageRayLength` to `0`. Since `MeleeWeaponFirstPerson.CheckForAttackTargets()` has been completely replaced, this is no longer needed. `InjectVRHammerKillVelocity` would replaced `MeleeWeaponDamageData.sourcePos, which would normally just be the player camera position, with a position offset from the hit position by the surface normal. We populate all of this directly now, and mostly just offset the hit position by the inverse of the normalize hitbox velocity from the previous frame.
DSprtn commented 11 months ago

Amazing work!

If I get back into playing GTFO it might be time to look into spicing up the ragdolls.

sauron-lordof-the-rings