Unreal 4 VR C++ – Architecture Visualizer

GitHub repository: ue4-vr-architecture-visualizer


This is an original VR architecture visualizer built with Unreal Engine 4 and C++. The project explores three interconnected VR problems: comfortable locomotion, physics-based climbing, and motion sickness mitigation — each solved at the C++ level with no Blueprint logic carrying gameplay weight.


Architecture Overview

The project is built around two classes:

  • AVRCharacter — the player pawn. Owns and coordinates all locomotion and comfort systems: room-scale offset correction, parabolic teleportation, and the dynamic blinker.
  • AHandController — a spawned AActor representing one physical VR controller. Owns its own UMotionControllerComponent, handles climbing logic, and communicates with its sibling via a cross-link set at spawn time.
AVRCharacter (ACharacter)
├── VRRoot (USceneComponent) ← absorbs room-scale drift
│ ├── Camera (UCameraComponent) ← driven by HMD runtime
│ ├── TeleportPath (USplineComponent)
│ ├── AHandController [Left]
│ └── AHandController [Right]
├── DestinationMarker (UStaticMeshComponent)
└── PostProcess (UPostProcessComponent) ← drives the blinker material

Both AHandController instances are spawned in BeginPlay, attached to VRRoot, and cross-linked so each hand can cancel the other’s climbing state when a new grip begins.


The VRRoot Pattern — Room-Scale Offset Correction

Room-scale VR introduces a fundamental mismatch: as the player physically moves in their play space, the HMD drifts away from the actor’s capsule origin. If left uncorrected, the capsule stays put while the camera floats freely — collision and interaction systems break entirely.

The fix is UpdateWorldOffsets(), called at the top of Tick:

cpp

void AVRCharacter::UpdateWorldOffsets()
{
FVector NewCameraOffset = Camera->GetComponentLocation() - GetActorLocation();
NewCameraOffset.Z = 0.0f;
// Shift the actor to follow the HMD, then subtract from VRRoot
// so all attached VR content stays world-stable.
AddActorWorldOffset(NewCameraOffset);
VRRoot->AddWorldOffset(-NewCameraOffset);
}

The actor (and its capsule) is moved each frame to stay under the HMD in XY. VRRoot is then moved by the exact opposite amount so that everything attached to it — the controllers, the spline, the camera itself — does not jump. The net effect is that the capsule tracks the player’s real-world position while the VR scene remains visually stable.


Parabolic Teleportation

The teleport system has three responsibilities: casting an arc, validating the destination, and executing the move with a smooth camera fade.

Arc Casting

GetTeleportDestination() uses UGameplayStatics::PredictProjectilePath launched from the right controller’s forward vector. This gives a physically-plausible parabolic arc that responds naturally to the wrist angle — tilting the controller up produces a longer arc, tilting it down produces a short one.

cpp

const FVector StartLocation = RightHandController->GetActorLocation();
const FVector ProjectileLaunchVelocity =
RightHandController->GetActorForwardVector() * TeleportProjectileSpeed;
FPredictProjectilePathParams Params(
TeleportProjectileRadius,
StartLocation,
ProjectileLaunchVelocity,
TeleportSimulationTime,
ECollisionChannel::ECC_Visibility,
this);
Params.bTraceComplex = true;

NavMesh Validation

The hit location is projected onto the NavMesh before the destination marker is shown. This prevents the player from teleporting onto geometry that is unreachable on foot — walls, ledges, the top of furniture.

cpp

bool AVRCharacter::GetNavMeshProjectedLocation(
const FVector& LocationToProject,
FVector& ProjectedLocation) const
{
const FVector Extent(100.0f, 100.0f, 100.0f);
FNavLocation NavLocation;
const bool bOnNavMesh =
NavigationSystem->ProjectPointToNavigation(LocationToProject, NavLocation, Extent);
// ...
}

Arc Visualisation — Pooled SplineMeshComponents

The arc is rendered using a USplineComponent populated each frame from the path data, with a USplineMeshComponent per segment. Components are pooled in TeleportArcMeshComponents and created on demand via GetOrCreateTeleportPathMeshComponent(). This avoids per-frame allocation: once the pool reaches the maximum arc segment count it will never allocate again — excess components are simply hidden.

Teleport Execution

The position change is hidden behind a camera fade. BeginTeleport() starts a black fade and sets a timer. EndTeleport() fires at peak fade — when the screen is fully black — moves the actor, then fades back in. The player never sees the world snap.


Climbing System

Climbing is owned entirely by AHandController. AVRCharacter only routes grip input to the correct hand — it has no knowledge of the climbing internals.

Surface Detection

Each AHandController owns a USphereComponent (radius 7 cm) that detects overlap with any actor tagged "Climbable". When the collider enters a tagged surface, bCanClimb is set to true and the controller plays haptic feedback — giving the player physical confirmation that the surface is grippable before they press the button.

The haptic is deliberately triggered on the rising edge only (bOldCanClimb == false → bCanClimb == true), so it fires once on contact, not continuously while the hand moves through the geometry.

The Climbing Mechanic

The climbing motion is an inverse-hand technique: rather than animating the character upward, the character is moved so that the hand appears to stay planted on the surface.

When BeginGrip() is called:

  1. ClimbingStartLocation records the controller’s world position.
  2. Gravity is disabled by switching the character movement to MOVE_Flying.
  3. The sibling hand’s climbing flag is cancelled — only one hand drives the climb at a time.

Each Tick, UpdateLocationFromClimbing() runs:

cpp

void AHandController::UpdateLocationFromClimbing()
{
if (!bIsClimbing) { return; }
// The delta from current position back to the grip anchor
// is how far the parent must move to keep the hand "planted".
const FVector HandControllerToClimbingStart =
ClimbingStartLocation - GetActorLocation();
GetAttachParentActor()->AddActorWorldOffset(HandControllerToClimbingStart);
}

As the player physically lifts their arm in real life, the controller moves upward in world space. The delta between the anchor and the new position is negative on Z — the character is pushed upward by that amount. The hand stays where it was gripped; the body rises toward it. When the grip is released, MOVE_Falling is restored and gravity takes over.


Blinker — Dynamic Comfort System

Sustained locomotion in VR causes motion sickness when the visual field contradicts the vestibular sense. The blinker system addresses this by progressively restricting the player’s peripheral vision as speed increases — a technique used in commercial VR titles.

Material Setup

In BeginPlay, a UMaterialInstanceDynamic is created from BlinkerMaterialBase and registered with the UPostProcessComponent. This produces a per-instance copy of the material that can have its parameters mutated each Tick without affecting the shared asset.

Speed-Driven Radius

UpdateBlinkerRadius() samples a designer-supplied UCurveFloat at the character’s current speed and pushes the result to the material’s "Radius" scalar parameter. At speed 0, the curve returns 1.0 (fully open). At maximum movement speed it should return a value near 0 (tight tunnel). The curve is the designer’s tool: no code change is needed to tune the aggressiveness of the effect.

Velocity-Tracking Center

The open circle of the blinker tracks the direction of travel rather than sitting at a fixed screen center. GetBlinkerCenterInViewport() computes a reference point 10 metres ahead in the velocity direction, projects it to screen space, and normalises it to [0, 1] viewport coordinates for the material’s "Center" vector parameter.

A subtle but important detail: when the player moves backward, the velocity vector opposes the camera’s forward vector. Without correction, the reference point would project behind the camera frustum and flip the blinker to the wrong side of the screen. A dot product sign check catches this case:

cpp

const float DotResult =
FVector::DotProduct(Camera->GetForwardVector(), UnitCharacterVelocity);
const float Sign = DotResult < 0.0f ? -1.0f : 1.0f;
const FVector ReferenceLocation =
CameraLocation + UnitCharacterVelocity * Sign * 1000.0f;

Key Takeaways

  • The VRRoot pattern is the foundational building block for any room-scale VR character. Without it, every gameplay system that relies on the actor’s capsule position (collision, interaction, physics) will produce incorrect results as the player moves in their play space.
  • Pooled USplineMeshComponent arrays are the right tool for dynamic arc visualisations. Creating and destroying components per frame is expensive; the pool amortises allocation cost to the first few frames and keeps the rest free of GC pressure.
  • Inverse-hand climbing is a lightweight technique that produces convincing physicality without IK or animation systems. The correctness depends entirely on the per-frame delta from a fixed anchor — the climbing start location — which makes it easy to reason about and debug.
  • The blinker’s backward-travel fix is the kind of edge case that only surfaces during playtesting. The dot product check is a one-liner but prevents a visually jarring artefact that would be unacceptable in production.

Leave a comment

Create a website or blog at WordPress.com

Up ↑