VR Development April 2026

How to Implement a Performant VR Weapon Scope in Unreal Engine

Using RVR's Scope API - tick-gated rendering, aggressive LOD, and zero SceneCaptureComponent2D overhead.

10 min read - For VR Developers & Technical Artists

The Problem: Scopes Kill VR Frame Rates

Every VR shooter needs weapon scopes. But the standard Unreal Engine approach - SceneCaptureComponent2D - renders the entire scene a second time every frame. In VR, where you already render two eyes at 90 Hz, that extra scene render can easily push you over budget and cause reprojection.

Common workarounds all have tradeoffs:

Approach Problem
SceneCaptureComponent2D Full scene render every frame. 5-15ms GPU cost in VR.
Fake scope (zoomed overlay) Breaks immersion, no parallax, can't see around the scope.
SceneCapture with timer Manual tick management, no LOD control, still renders everything.
Stencil masking Complex setup, Z-fighting, no magnification.

The Solution: RVR Scope API

RVR's Scope View is a purpose-built rendering pipeline for weapon optics. Instead of capturing the full scene at full quality every frame, it renders a lean, low-resolution view at a controlled frame rate with aggressive performance defaults.

What makes it different from SceneCaptureComponent2D:

  • ✓ Tick-gated rendering - only renders at the capture FPS you set (default 20 Hz), not every game frame
  • ✓ Aggressive LOD bias - at 512px width, objects use LODs as if 2.5x farther away
  • ✓ Reduced far clip - culls distant geometry before vertex shading
  • ✓ Stripped show flags - disables translucency, particles, foliage, motion blur, DoF, SSR, AO by default
  • ✓ FXAA anti-aliasing - single-frame, no temporal dependency (TAA requires history buffers)
  • ✓ Screen percentage - default 50%, so a 512x512 target actually renders at 256x256 internally
  • ✓ PF_B8G8R8A8 render target - 32bpp instead of PF_FloatRGBA (64bpp), halving memory bandwidth
  • ✓ Warmup spreading - renders one warmup frame per tick to avoid a hitch on the first frame

Quick Start: Blueprint

The minimal setup is a single Blueprint node. Attach a CameraComponent to your weapon scope mesh, set a narrow FOV (10-20 degrees for magnification), then call StartScopeView.

// In your weapon Blueprint (e.g. BeginPlay or ADS event):

1. Get RVR subsystem: Get Engine Subsystem -> Runtime Video Recorder
2. Call StartScopeView:
   - ScopeCamera: your scope CameraComponent (FOV = 12 for 6x magnification)
   - Width: 512
   - Height: 512
   - CaptureFPS: 20
   - ScreenPercentage: 50.0
3. Take the returned Render Target
4. Set it as a texture parameter on your scope lens material:
   - Create Dynamic Material Instance (scope lens mesh)
   - Set Texture Parameter Value ("ScopeTexture", returned RT)

When the player exits ADS or holsters the weapon, call StopScopeView to release the render target and stop the extra renders.

C++ Implementation

Full C++ setup for a VR sniper rifle scope:

// WeaponScope.h
UPROPERTY(VisibleAnywhere)
UCameraComponent* ScopeCamera;

UPROPERTY()
UMaterialInstanceDynamic* ScopeLensMaterial;

void ActivateScope();
void DeactivateScope();

// WeaponScope.cpp
void AWeaponScope::ActivateScope()
{
    auto* RVR = GEngine->GetEngineSubsystem<URuntimeVideoRecorder>();
    if (!RVR) return;

    FRuntimeScopeRenderSettings RenderSettings;
    RenderSettings.MaxScopeDistance = 80000.f;  // 800m for a sniper
    RenderSettings.bEnableDynamicShadows = true;
    RenderSettings.bEnableFog = true;
    RenderSettings.bEnableAtmosphere = true;
    // Everything else defaults to OFF

    UTextureRenderTarget2D* ScopeRT = RVR->StartScopeView(
        ScopeCamera,
        512,    // Width
        512,    // Height
        20,     // CaptureFPS
        50.0f,  // ScreenPercentage
        FRuntimeSelectiveRenderingSettings(),
        nullptr, // No custom post-process
        RenderSettings
    );

    if (ScopeRT && ScopeLensMaterial)
    {
        ScopeLensMaterial->SetTextureParameterValue(
            TEXT("ScopeTexture"), ScopeRT);
    }
}

void AWeaponScope::DeactivateScope()
{
    auto* RVR = GEngine->GetEngineSubsystem<URuntimeVideoRecorder>();
    if (RVR) RVR->StopScopeView();
}

StartScopeView API Reference

Parameter Type Default Notes
ScopeCamera UCameraComponent* required Set a narrow FOV for magnification (e.g. 12 = ~6x zoom)
Width / Height int32 512 Render target resolution. Lower = faster. 256 works for close-range optics.
CaptureFPS int32 20 How often the scope renders. 20 Hz is visually smooth for a scope view.
ScreenPercentage float 50.0 Internal render scale (5-100). At 50%, a 512 target renders at 256 internally.
SelectiveRendering FRuntimeSelectiveRenderingSettings empty ShowOnlyActors / HiddenActors arrays for filtering what the scope sees.
CustomPostProcessMaterial UMaterialInterface* nullptr Crosshair overlay, vignette, night vision, thermal - applied as post-process.
ScopeRenderSettings FRuntimeScopeRenderSettings see below Fine-grained toggle for every rendering feature.

Returns a UTextureRenderTarget2D* that you assign to your scope mesh material. Only one scope view can be active at a time; calling again stops the previous one.

FRuntimeScopeRenderSettings - What's On and Off

Every feature defaults to OFF for maximum VR performance. Enable only what you need:

Feature Default When to Enable
MaxScopeDistance 50000 cm (500m) Increase for sniper rifles, decrease for red-dot sights
Dynamic Shadows ON Critical for depth perception. Disable only on Quest 2.
Contact Shadows OFF Barely visible at scope resolution. Not worth the cost.
Capsule Shadows OFF Enable if characters look like they float.
Translucency OFF Enable if you need glass, smoke shells, or transparent surfaces.
Particles OFF Enable for muzzle flash and tracer visibility through scope.
Foliage OFF Extremely expensive at scope resolution. Rarely needed.
Decals OFF Enable for bullet-hole visibility.
Fog / Atmosphere ON Cheap and helps depth perception at distance.
Bloom / DoF / Motion Blur / Lens Flares / Volumetric Fog / AO / SSR OFF All post-process effects off. A scope is a rigid optic - no motion blur or DoF.

Performance Under the Hood

Here's what the Scope API does behind the scenes to keep costs minimal:

Tick-Gated Rendering

The scope only renders when the capture interval elapses. At 20 FPS, that's once every 50ms. Your game's 90 Hz VR loop runs uninterrupted for the other frames. SceneCaptureComponent2D renders every single frame.

Aggressive LOD Bias

LOD distance factor is calculated as 1280 / Width, clamped between 1.0 and 8.0. At 512px, that's 2.5x - objects pick LODs as if they were 2.5x farther away. This dramatically reduces polygon count without visible quality loss at scope resolution.

Reduced Far Clip Plane

MaxScopeDistance (default 500m) rejects distant geometry before vertex shading even begins. The main camera still sees the full world, but the scope only processes what's within range.

Manual Exposure

Auto-exposure is forced off. The scope uses manual exposure to prevent brightness flickering as you pan across different areas. This also skips the luminance compute pass.

Warmup Frame Spreading

When the scope activates, warmup frames are spread across multiple ticks (one per tick) instead of rendering all at once. This prevents a frame spike when the player first aims down sights.

Custom Post-Process: Crosshair, Night Vision, Thermal

Pass a CustomPostProcessMaterial to overlay a crosshair reticle, add a vignette, or build a full night-vision/thermal scope. When a custom material is provided, the view mode switches to Unlit with FinalColorLDR capture, and your material receives the scene as input.

// Create a night-vision post-process material in your asset pipeline,
// then pass it to StartScopeView:

UMaterialInterface* NightVisionMat = LoadObject<UMaterialInterface>(
    nullptr, TEXT("/Game/Materials/MI_NightVision"));

RVR->StartScopeView(
    ScopeCamera, 512, 512, 20, 50.0f,
    FRuntimeSelectiveRenderingSettings(),
    NightVisionMat,  // Applied as post-process
    FRuntimeScopeRenderSettings()
);

The material gets the full scope scene as a texture input. Use it to add green tint + noise for night vision, or remap colors for a thermal overlay.

Selective Rendering: Hide or Show Specific Actors

Use FRuntimeSelectiveRenderingSettings to control what the scope sees. This is useful for:

  • - Hiding the player's own weapon mesh from the scope view
  • - Showing only tagged "enemy" actors for a wall-hack scope power-up
  • - Filtering out UI/HUD elements
FRuntimeSelectiveRenderingSettings Selective;
Selective.HiddenActors.Add(PlayerWeaponActor);
Selective.HiddenActors.Add(PlayerHandsActor);

RVR->StartScopeView(
    ScopeCamera, 512, 512, 20, 50.0f,
    Selective,
    nullptr,
    FRuntimeScopeRenderSettings()
);

Events and Delegates

RVR broadcasts two delegates for scope lifecycle:

  • OnScopeViewStarted - fires when the scope render target is created and ready
  • OnScopeViewStopped - fires when the scope is deactivated and the render target released

Bind to these to show/hide a scope overlay widget, play an ADS animation, or switch the crosshair style.

Helper Methods

  • IsScopeViewActive() - returns true if a scope view is currently rendering
  • GetScopeViewRenderTarget() - returns the active render target (or nullptr)
  • StopScopeView() - stops rendering and releases the render target

The scope is automatically stopped on EndPlay and Deinitialize, so you don't need to manually clean up when the level ends.

Tuning Guide: Quest vs PCVR

Quest 2/3 (Mobile VR)

  • - Resolution: 256x256
  • - CaptureFPS: 15
  • - ScreenPercentage: 30-40%
  • - Dynamic Shadows: OFF
  • - MaxScopeDistance: 30000 (300m)
  • - All post-process: OFF

PCVR (Index, Vive Pro)

  • - Resolution: 512x512
  • - CaptureFPS: 30
  • - ScreenPercentage: 50-70%
  • - Dynamic Shadows: ON
  • - MaxScopeDistance: 80000 (800m)
  • - Fog + Atmosphere: ON
Tip: Start with the defaults (512, 20 FPS, 50%) and profile with stat gpu. The scope render should cost under 1ms on PCVR and under 2ms on Quest 3.