What Problem Does This Solve?
Following best practices helps you:
- Avoid common integration mistakes
- Optimize recording performance
- Handle errors gracefully
- Write maintainable code
- Deliver smooth user experience
This page collects lessons learned from hundreds of LCK integrations.
When to Use This
Read this when:
- First-time LCK integration
- Debugging performance issues
- Building custom recording UI
- Optimizing for Quest devices
- Code review / refactoring
Recording
Quality Guidelines
| Quality | Resolution | Bitrate | FPS | Use Case |
|---|
| SD | 1280×720 | 2-4 Mbps | 30 | Quest 2, performance mode |
| HD | 1920×1080 | 4-8 Mbps | 30 | Standard quality (recommended) |
| 2K | 2560×1440 | 8-12 Mbps | 30 | Quest 3/Pro, high quality |
| 4K | 3840×2160 | 12-20 Mbps | 30 | PCVR only, max quality |
Quest recommendations:
- Quest 2: Use HD (1080p) @ 30fps for best balance
- Quest 3/Pro: Can handle 2K if game performance allows
- Avoid 4K on Quest devices (thermal/performance)
File size estimates:
- HD @ 30fps: ~2 GB per hour
- 2K @ 30fps: ~4 GB per hour
- 4K @ 30fps: ~8 GB per hour
Use Async Methods
Do this:
Service->StartRecordingAsync(
FOnLCKRecorderBoolResult::CreateLambda([this](bool bSuccess)
{
if (bSuccess)
{
ShowRecordingIndicator();
PlayRecordingSound();
}
else
{
ShowError(TEXT("Failed to start recording"));
}
})
);
Don’t do this:
// No error feedback, blocks game thread
bool bSuccess = Service->StartRecording();
Why async is better:
- Non-blocking—doesn’t freeze game
- Detailed error callbacks
- Progress tracking for save operations
- Better user experience
Subscribe to Recording Events
Do this:
void ARecordingUI::BeginPlay()
{
Super::BeginPlay();
// Subscribe to events
Service->OnRecordingSaveFinished.AddDynamic(this, &ARecordingUI::OnSaveFinished);
Service->OnRecordingError.AddDynamic(this, &ARecordingUI::OnError);
Service->OnRecordingSaveProgress.AddDynamic(this, &ARecordingUI::OnProgress);
// Get state changes via DataModel
DataModel->OnRecordingStateChanged.AddDynamic(this, &ARecordingUI::OnStateChanged);
}
UFUNCTION()
void ARecordingUI::OnSaveFinished(bool bSuccess)
{
if (bSuccess)
ShowNotification(TEXT("Recording saved!"));
}
Don’t do this:
// Polling state every frame = BAD
void Tick(float DeltaTime)
{
if (Service->IsRecording() != bWasRecording)
{
bWasRecording = Service->IsRecording();
UpdateUI();
}
}
Why events are better:
- React immediately to state changes
- No performance cost of polling
- Cleaner code
- More responsive UI
Validate Before Recording
bool ARecorder::CanStartRecording()
{
// 1. Check tracking ID
ULCKDeveloperSettings* Settings = ULCKDeveloperSettings::Get();
if (!Settings->IsTrackingIdValid())
{
ShowError(TEXT("Recording not configured"));
return false;
}
// 2. Check if already recording
if (Service->IsRecording())
{
UE_LOG(LogLCK, Warning, TEXT("Already recording"));
return false;
}
// 3. Check storage space
int64 FreeSpace = FPlatformMisc::GetDiskFreeSpace(FPaths::ProjectSavedDir());
int64 RequiredSpace = 500 * 1024 * 1024; // 500 MB
if (FreeSpace < RequiredSpace)
{
ShowError(FString::Printf(
TEXT("Low storage: %d MB free. Need 500 MB minimum."),
FreeSpace / (1024 * 1024)
));
return false;
}
// 4. Validate audio config (warnings, not blocking)
FLCKAudioConfigValidation AudioValidation = Settings->ValidateAudioConfig();
for (const FString& Warning : AudioValidation.Warnings)
{
UE_LOG(LogLCK, Warning, TEXT("Audio: %s"), *Warning);
}
return true;
}
void ARecorder::StartRecording()
{
if (!CanStartRecording())
return;
Service->StartRecordingAsync(/* ... */);
}
Audio
Audio Source Priority
When multiple audio plugins are enabled, LCK uses priority order:
- LCKFMOD (highest priority)
- LCKWwise
- LCKUnrealAudio (lowest priority, always available)
Only ONE game audio source is active at a time. Microphone and voice chat can run alongside game audio.
Check which is active:
ELCKGameAudioType ActiveAudio = Settings->GetActiveGameAudioType();
switch (ActiveAudio)
{
case ELCKGameAudioType::FMOD:
UE_LOG(LogLCK, Log, TEXT("Using FMOD for game audio"));
break;
case ELCKGameAudioType::Wwise:
UE_LOG(LogLCK, Log, TEXT("Using Wwise for game audio"));
break;
case ELCKGameAudioType::UnrealAudio:
UE_LOG(LogLCK, Log, TEXT("Using Unreal Audio"));
break;
}
Match Sample Rates
Do this:
// Get Unreal Audio sample rate
int32 SampleRate = ULCKUnrealAudioBPL::GetUnrealAudioSamplerate();
// Configure encoder with matching rate
FLCKRecorderParams Params;
Params.Width = 1920;
Params.Height = 1080;
Params.Framerate = 30;
Params.Samplerate = SampleRate; // Match!
Recorder->SetupRecorder(Params, CaptureComponent);
Don’t do this:
// Hardcoded sample rate = audio distortion/sync issues
Params.Samplerate = 48000; // May not match actual audio
Thread-Safe Audio Callbacks
Audio callbacks may come from different threads. If you need to access game thread objects (UObject properties, UI), use AsyncTask.
Do this:
AudioSource->OnAudioDataDelegate.BindLambda([this](
TArrayView<const float> PCM,
int32 Channels,
ELCKAudioChannel SourceChannel)
{
// Calculate volume on audio thread (OK)
float Volume = CalculateRMS(PCM);
// Update UI on game thread
AsyncTask(ENamedThreads::GameThread, [this, Volume]()
{
VolumeIndicator->SetPercent(Volume);
});
});
Don’t do this:
AudioSource->OnAudioDataDelegate.BindLambda([this](auto PCM, auto Channels, auto Source)
{
// CRASH: Accessing UObject from non-game thread
VolumeIndicator->SetPercent(CalculateRMS(PCM));
});
Audio Delegate is Single, Not Multicast
OnAudioDataDelegate is a SINGLE delegate. Use BindLambda(), NOT AddLambda().
Do this:
// BindLambda replaces any existing binding
AudioSource->OnAudioDataDelegate.BindLambda([](auto PCM, auto Channels, auto Source)
{
// Process audio
});
Don’t do this:
// AddLambda doesn't exist on single delegates
AudioSource->OnAudioDataDelegate.AddLambda([](auto PCM, auto Channels, auto Source)
{
// Compilation error
});
LCK buttons include automatic 0.25s cooldown.
Do this:
// Just bind the event, cooldown is automatic
RecordButton->OnTapStarted.AddDynamic(this, &AMyActor::OnRecordPressed);
Don’t do this:
// Redundant cooldown logic
void OnRecordPressed()
{
if (FPlatformTime::Seconds() - LastPressTime < 0.25f)
return; // Unnecessary!
LastPressTime = FPlatformTime::Seconds();
ToggleRecording();
}
Use Showable Groups for Batch Operations
Do this:
// Group related UI elements
ULCKShowablesGroup* SettingsGroup = NewObject<ULCKShowablesGroup>(this);
SettingsGroup->Add(FOVButton);
SettingsGroup->Add(DistanceButton);
SettingsGroup->Add(SmoothnessButton);
// Batch show/hide
void ShowSettings()
{
SettingsGroup->Show();
}
void HideSettings()
{
SettingsGroup->Hide();
}
Don’t do this:
// Manually show/hide each element
void ShowSettings()
{
FOVButton->SetVisibility(true);
DistanceButton->SetVisibility(true);
SmoothnessButton->SetVisibility(true);
}
Optimize Scene Capture Component
void ARecorder::SetupCaptureComponent(USceneCaptureComponent2D* Capture)
{
// Disable expensive capture options
Capture->CaptureSource = ESceneCaptureSource::SCS_FinalColorLDR;
Capture->bCaptureEveryFrame = false; // Manual capture
Capture->bCaptureOnMovement = false; // Manual capture
Capture->bAlwaysPersistRenderingState = true;
// Disable post-processing for performance
Capture->PostProcessSettings.bOverride_AmbientOcclusionIntensity = true;
Capture->PostProcessSettings.AmbientOcclusionIntensity = 0.0f;
// Disable expensive features
Capture->ShowFlags.SetTemporalAA(false);
Capture->ShowFlags.SetMotionBlur(false);
}
Unregister Capture Components
Do this:
void ARecorder::BeginDestroy()
{
Super::BeginDestroy();
if (Service)
{
Service->StopRecording();
Service->UnregisterCaptureComponent(TEXT("MainCapture"));
}
}
Don’t do this:
// Memory leak - capture component never cleaned up
void ARecorder::BeginDestroy()
{
Super::BeginDestroy();
Service->StopRecording();
}
Monitor Frame Times During Recording
void APerformanceMonitor::Tick(float DeltaTime)
{
if (!Service || !Service->IsRecording())
return;
float FrameTimeMs = DeltaTime * 1000.0f;
// Track frame time history
FrameTimeHistory.Add(FrameTimeMs);
if (FrameTimeHistory.Num() > 60) // 2 seconds at 30fps
{
FrameTimeHistory.RemoveAt(0);
}
// Calculate average
float AvgFrameTime = 0.0f;
for (float Time : FrameTimeHistory)
{
AvgFrameTime += Time;
}
AvgFrameTime /= FrameTimeHistory.Num();
// Warn if frame time is too high
float TargetFrameTime = 1000.0f / 30.0f; // 33.3ms for 30fps
if (AvgFrameTime > TargetFrameTime * 1.2f) // 20% over target
{
UE_LOG(LogLCK, Warning, TEXT("Recording impacting performance: %.1fms avg"),
AvgFrameTime);
SuggestLowerQuality();
}
}
Common Pitfalls
1. Not Handling Recording State Feedback
Problem: No feedback when recording starts/stops/fails
Solution: Subscribe to delegates
Service->OnRecordingSaveFinished.AddDynamic(this, &AMyUI::OnSaveFinished);
Service->OnRecordingError.AddDynamic(this, &AMyUI::OnError);
DataModel->OnRecordingStateChanged.AddDynamic(this, &AMyUI::OnStateChanged);
2. Mismatched Resolutions
Problem: Render target doesn’t match recording resolution → distortion
Solution: Match exactly
// Render target
RenderTarget->ResX = 1920;
RenderTarget->ResY = 1080;
// Recording params
FLCKRecorderParams Params;
Params.Width = 1920; // Must match RenderTarget->ResX
Params.Height = 1080; // Must match RenderTarget->ResY
3. Forgetting to Unregister Capture Component
Problem: Memory leak from orphaned capture components
Solution: Always unregister on cleanup
void ARecorder::BeginDestroy()
{
Super::BeginDestroy();
if (Service)
{
Service->UnregisterCaptureComponent(CaptureComponentName);
}
}
4. Sample Rate Mismatch
Problem: Audio distortion or A/V sync issues
Solution: Query and match sample rates
int32 SampleRate = ULCKUnrealAudioBPL::GetUnrealAudioSamplerate();
Params.Samplerate = SampleRate;
5. Using AddLambda for Audio Delegate
Problem: OnAudioDataDelegate is single, not multicast
Solution: Use BindLambda() instead
// Correct
AudioSource->OnAudioDataDelegate.BindLambda([](auto PCM, auto Channels, auto Source) {
// ...
});
// Wrong - compilation error
AudioSource->OnAudioDataDelegate.AddLambda([](auto PCM, auto Channels, auto Source) {
// ...
});
Android (Quest)
Vulkan enabled in Project Settings → Android
RECORD_AUDIO permission in AndroidManifest.xml
WRITE_EXTERNAL_STORAGE permission (API < 29)
LCKOboe plugin enabled for low-latency mic
LCKVulkan loads at EarliestPossible (don’t change!)
AndroidManifest.xml:
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
Windows (PCVR)
Media Foundation available (Windows 10+)
DirectX 11 compatible GPU
H.264 hardware encoding support
Debugging
Enable Verbose Logging
; DefaultEngine.ini
[Core.Log]
LogLCK=VeryVerbose
LogLCKEncoding=VeryVerbose
LogLCKAudio=VeryVerbose
LogLCKUI=Verbose
LogLCKTablet=Verbose
What you’ll see:
LogLCK: Recording started
LogLCKEncoding: Encoder initialized: 1920x1080 @ 30fps, 8 Mbps
LogLCKAudio: Audio source registered: UnrealAudio
LogLCKEncoding: Frame 0 encoded (8.2ms)
LogLCKEncoding: Frame 30 encoded (7.9ms)
LogLCK: Recording stopped
LogLCKEncoding: Finalizing video file...
LogLCK: Recording saved: /Game/Movies/recording_001.mp4
Common Log Messages
| Log Message | Meaning | Action |
|---|
Recording started | Recording began successfully | - |
Encoder initialized | Platform encoder ready | - |
Audio source registered | Audio capture active | - |
Invalid Tracking ID | Tracking ID missing/invalid | Add ID from dashboard |
Encoder not available | Platform encoder failed | Check platform requirements |
Permission denied | Missing permissions (Android) | Request permissions |
Quick Reference
Start recording:
Service->StartRecordingAsync(FOnLCKRecorderBoolResult::CreateLambda([](bool bSuccess) {
// Handle result
}));
Stop recording:
Service->StopRecordingAsync(
FOnLCKRecorderBoolResult::CreateLambda([](bool bSuccess) { /* Done */ }),
FOnLCKRecorderProgress::CreateLambda([](float Progress) { /* 0.0-1.0 */ })
);
Check state:
bool bRecording = Service->IsRecording();
float Duration = Service->GetRecordingDuration();
Subscribe to events:
Service->OnRecordingSaveFinished.AddDynamic(this, &AMyActor::OnSaveFinished);
Service->OnRecordingError.AddDynamic(this, &AMyActor::OnError);
DataModel->OnRecordingStateChanged.AddDynamic(this, &AMyActor::OnStateChanged);
Key Takeaways
Use async methods for better error handling
Subscribe to events instead of polling state
Validate before recording (tracking ID, storage, state)
Match sample rates to avoid audio issues
Audio callbacks on any thread — use AsyncTask for UI updates
Single delegate for audio — use BindLambda, not AddLambda
Unregister captures to prevent memory leaks
HD @ 30fps for Quest 2 — higher quality impacts performance