プログラミング
GTFO VR Mod Postmortem
GTFO VR Mod Postmortem (dsprtn.dev)
要約
この記事は、人気ホラーシューターゲーム「GTFO」のVR Mod開発における5年以上にわたるポストモーテム(開発後分析)です。開発者は、Unityのデコンパイルとランタイムパッチング技術(Harmony、BepInEx)を駆使して、ゲームプレイ体験をVRに最適化しました。VR特有の課題(エイム、UI、インタラクション)を克服し、コミュニティの協力も得ながらModを完成させた経緯が語られています。
全文翻訳
GTFO VR Postmortem Contents
GTFO VR Postmortem Intro
Over half a decade, 500 commits, and the better part of a thousand hours ago, I spent a boring afternoon wondering what GTFO would look like in VR. Then I made the mistake of trying to find out.
I’ve kept an eye on GTFO ever since its gameplay trailer at the Game Awards in 2017. It ticked all the right boxes for me: Survival horror, co-op, shooter, extreme difficulty (allegedly.)
I managed to join every playtest and could not get enough of the game each time. GTFO’s setting; the ‘Complex’ is a marvel to look at and the monsters inhabiting it were unique and interesting. The game was (and still is) extremely difficult and extremely fun, just as promised.
First Foray Into Modding
I started tinkering with GTFO even before the first alpha test was finished. I quickly found out that decompiling and modifying Unity games is quite trivial. Using DnSpy/DotPeek, you can easily peer into basically-source-code of the game, because Mono (Unity’s default scripting backend) compiles to .NET bytecode, which retains almost everything needed to reconstruct the original source.
For example, this is what we can retrieve from a Unity project I quickly cobbled together:
```csharp
public class DeleteIfNear : MonoBehaviour {
[SerializeField] private GameObject Offender;
[SerializeField] private float Distance = 50f;
private void Update() {
if (!((Object) this.Offender != (Object) null) || (double) (this.transform.position - this.Offender.transform.position).sqrMagnitude >= (double) this.Distance * (double) this.Distance) return;
Debug.Log((object) ("Deleting offender " + this.Offender.name));
Object.Destroy((Object) this.Offender);
this.Offender = (GameObject) null;
}
}
```
From the original source:
```csharp
/// <summary>
/// Deletes an object if it gets close enough
/// </summary>
public class DeleteIfNear : MonoBehaviour {
[SerializeField] private GameObject Offender;
[SerializeField] private float Distance = 50.0f;
void Update() {
// If distance to target is less than Distance, we delete the offender
if (Offender != null) {
Vector3 toOffender = transform.position - Offender.transform.position;
if (toOffender.sqrMagnitude < Distance * Distance) {
Debug.Log($"Deleting offender {Offender.name}");
Destroy(Offender);
Offender = null;
}
}
}
}
```
Comments are stripped out, casts are explicit and the code is ‘optimized’ in general. All in all, it’s still perfectly readable. It’s not easy to navigate a large codebase like GTFO’s in the first place, but this definitely beats staring at raw disassembly.
At first, I only used DnSpy to modify assemblies in-place. Quite quickly, I was able to create my first (unreleased) mod for an alpha version of the game — a more ‘efficient’ solution to dealing with the game’s (at the time) most dangerous enemy, the scout, featuring custom code and ‘borrowed’ assets.
We blow up a scout with the BFG9000
The game’s full release soon followed and yet again, after getting through all the content, I still couldn’t get enough of it. With the next update nowhere in sight, that fateful, boring afternoon arrived.
Armed with knowledge of Unity and VR dev from my day job, beginner-level modding knowledge and some experience with SteamVR, I started cobbling together something that would allow you to admire GTFO’s bestiary up close and personal.
And up close and personal it was. Within VR, enemies that looked somewhat big on-screen were suddenly towering over you. Gruesome details became far more apparent and getting disoriented in the Complex was never this easy. It was everything I had hoped for.
Over the next few weeks I added fixes that allowed me to play even full matches in VR.
The community expressed interest in a public release. Meanwhile, DnSpy was giving me issues. Turns out modifying assemblies in-place repeatedly is not that stable. Keeping track of my modifications also started to become cumbersome. After one too many random crashes and the inability to legally distribute the mod as altered game assemblies, I needed a solution.
Harmony and BepInEx to the rescue
The solution was runtime patching. Harmony is a library that allows one to prepend, append (or even replace) code in existing methods at runtime. Much like the name suggests, because of prepending/appending code being the main paradigm, most mods can coexist by default. BepInEx is a mod manager for Unity games built around loading Harmony-based mods.
```csharp
/// <summary>
/// Add event calls for the player shooting weapons
/// </summary>
[HarmonyPatch(typeof(Weapon), nameof(Weapon.ApplyRecoil))]
internal class InjectPlayerWeaponFireEvents {
private static void Postfix(Weapon __instance) {
if(__instance.Owner.IsLocallyOwned) {
PlayerFireWeaponEvents.WeaponFired(__instance);
}
}
}
```
When the game starts, this code will be added to the end of the Weapon.ApplyRecoil method in GTFO’s code.
The main idea is that distributing modified game code is not legal because most of what you’d be distributing is copyrighted. However, distributing code that modifies the game code on the user’s PC is fine. Switching to the Harmony/BepInEx stack allowed me to finally upgrade the mod into a proper, organized project with source control and throw it on GitHub. That marks the first commit, on the 1st of February, 2020.
With the project in a maintainable state and released to the public, I continued adding features. The first goal was to get the game playable entirely within VR. Most notably, this meant getting motion controls, the game menu, in-game UI, and the in-game terminal interaction working. Over the next couple of months, this all ended up making it into the mod. Over the next couple of years, the mod was kept up to date with game updates. Various improvements have been made to many systems, including large contributions by other developers(!)
The Gameplay in VR
Switching to VR came with its own set of challenges. As GTFO is all about difficulty and cooperative gameplay, my main driving idea was that players should not be too disadvantaged or advantaged relative to non-VR players.
Shooting accurately was difficult in the VR version. For a long time, ironsights/optics did not render properly in VR and/or did not closely match the aiming direction. For this reason, I decided to include a built-in laser pointer for all weapons. Eventually, the devs themselves switched to a shader which made sights line up (and look) quite nicely, so I made the laser pointer toggleable.
Some effort also had to be spent on combating cheating. Peeking through doors by walking through them in roomscale or shooting through them by sticking your gun behind them were easy to do in VR. Thankfully this was easily mitigated by blacking out the screen if your head was inside collision. Had I not done this, players would find a way to optimize their own fun away.
UI was challenging in general. I wanted to avoid copying the 2D UI where possible to promote immersion. I ended up doing some custom watch-style UI like most other VR shooters. While being diegetic is very cool, I think it could have definitely used an artist’s/designer’s touch. MrKerag did end up creating a better model for the watch, so at the least you don’t have to brave the Complex with a Fitbit anymore.
The Fitbit in question
And the improved version
For the menu UI I went with a simple throwaway hack where I show an interactable, curved SteamVR overlay with the 2D game menu pasted onto it. This ended up working rather well, so I kept it. The steam overlay API was easy to work with and raycasting against it to position the in-game cursor was trivial. Most of all, I was able to completely circumvent dealing with any custom menu UI rendering and related problems.
The VR game menu
GTFO also features interactive computer terminals, which are quite an important part of the game. At first I used a virtual keyboard,