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 spawnedAActorrepresenting one physical VR controller. Owns its ownUMotionControllerComponent, 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:
ClimbingStartLocationrecords the controller’s world position.- Gravity is disabled by switching the character movement to
MOVE_Flying. - 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
USplineMeshComponentarrays 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