UE4分屏革命:用C++打造完全自定义的多人视口布局

当四个玩家挤在沙发上争夺同一个屏幕时,为什么总要忍受千篇一律的均等分屏?在《胡闹厨房》的混乱厨房里,主厨可能需要更大的视野;在《FIFA》的球场上,观众席或许只需一个小窗。本文将带你突破UE4默认分屏限制,实现真正的视口自由。

1. 理解UE4分屏系统的底层逻辑

引擎默认的分屏实现藏在 GameViewportClient.cpp 这个关键文件中。打开源码,你会发现一个名为 FPerPlayerSplitscreenData 的结构体,它就像分屏系统的DNA:

struct FPerPlayerSplitscreenData {
    float SizeX;  // 视口宽度比例 (0.0-1.0)
    float SizeY;  // 视口高度比例 
    float OriginX; // 视口左下角X坐标 
    float OriginY; // 视口左下角Y坐标
};

默认配置中,UE4通过 ESplitScreenType 枚举预设了几种固定布局。比如四人游戏时, FourPlayer_Grid 会将屏幕均分为四等份。这种"一刀切"的设计虽然简单,却严重限制了创意表达。

关键发现 :通过修改 SplitscreenInfo 数组中的数据,我们可以实时改变每个玩家的视口参数。但直接修改这些值存在风险:

  • 可能破坏视口间的相对关系
  • 未考虑UI适配问题
  • 缺乏对动态分辨率变化的响应

2. 构建安全的视口控制系统

我们需要创建一个更健壮的解决方案。首先定义蓝图友好的数据结构:

USTRUCT(BlueprintType)
struct FCustomViewportConfig {
    GENERATED_BODY()
    
    UPROPERTY(EditAnywhere, Category="Viewport")
    float RelativeWidth = 1.0f;
    
    UPROPERTY(EditAnywhere, Category="Viewport") 
    float RelativeHeight = 1.0f;
    
    UPROPERTY(EditAnywhere, Category="Viewport")
    FVector2D AnchorPoint = FVector2D(0,0);
    
    // 包含边界检查的setter方法
    void SetDimensions(float Width, float Height) {
        RelativeWidth = FMath::Clamp(Width, 0.1f, 1.0f);
        RelativeHeight = FMath::Clamp(Height, 0.1f, 1.0f);
    }
};

然后在GameMode中实现核心控制逻辑:

void AMyGameMode::ApplyViewportConfig(const TArray<FCustomViewportConfig>& Configs) {
    if (!GEngine || !GEngine->GameViewport) return;
    
    ESplitScreenType::Type CurrentLayout = GetCurrentSplitType();
    
    // 验证配置有效性
    if (Configs.Num() != GetNumPlayers() || 
        Configs.Num() > MAX_SUPPORTED_PLAYERS) {
        UE_LOG(LogTemp, Warning, TEXT("Invalid viewport config count!"));
        return;
    }
    
    for (int32 i = 0; i < Configs.Num(); ++i) {
        FPerPlayerSplitscreenData& PlayerData = 
            GEngine->GameViewport->SplitscreenInfo[CurrentLayout].PlayerData[i];
            
        PlayerData.SizeX = Configs[i].RelativeWidth;
        PlayerData.SizeY = Configs[i].RelativeHeight;
        PlayerData.OriginX = Configs[i].AnchorPoint.X;
        PlayerData.OriginY = Configs[i].AnchorPoint.Y;
    }
    
    // 强制刷新视口
    GEngine->GameViewport->LayoutPlayers();
}

重要提示:修改视口参数后必须调用LayoutPlayers(),否则更改不会立即生效

3. 高级应用:动态视口与特效结合

真正的魔法始于将视口控制与其他系统结合。以下是三个实战案例:

案例一:焦点玩家放大效果

// 在Tick中动态调整视口
void AMyGameMode::Tick(float DeltaTime) {
    Super::Tick(DeltaTime);
    
    if (bIsFocusModeActive) {
        TArray<FCustomViewportConfig> NewConfigs;
        
        // 主玩家获得75%宽度
        NewConfigs.Add(FCustomViewportConfig{0.75f, 1.0f, FVector2D(0,0)});
        
        // 其他玩家共享剩余空间
        for (int i = 1; i < GetNumPlayers(); ++i) {
            float Width = 0.25f / (GetNumPlayers() - 1);
            NewConfigs.Add(FCustomViewportConfig{
                Width, 1.0f, 
                FVector2D(0.75f + (i-1)*Width, 0)
            });
        }
        
        ApplyViewportConfig(NewConfigs);
    }
}

案例二:画中画观察模式

通过嵌套视口实现监控摄像头效果:

  1. 为主玩家创建全屏视口
  2. 在角落保留小窗口显示观察目标
  3. 使用RenderTexture实现画中画渲染

案例三:非矩形视口

虽然UE4原生不支持,但可以通过遮罩材质模拟特殊形状:

// 在PlayerController中
void AMyPlayerController::SetupCustomViewport() {
    if (GetLocalPlayer()) {
        ULocalPlayer* LocalPlayer = GetLocalPlayer();
        
        // 创建圆形遮罩材质实例
        UMaterialInstanceDynamic* MaskMaterial = UMaterialInstanceDynamic::Create(
            CircleMaskMaterial, this);
            
        // 应用到玩家视口
        LocalPlayer->ViewportClient->SetViewportOverlayMaterial(
            LocalPlayer->ViewportIndex, MaskMaterial);
    }
}

4. 避坑指南:分屏开发的七个致命陷阱

  1. UI适配问题
    使用 GetViewportSize GetViewportScale 时,必须考虑分屏后的实际显示区域:

    FVector2D AMyHUD::GetAdjustedPosition(const FVector2D& ScreenPosition) {
        FVector2D ViewportSize;
        GEngine->GameViewport->GetViewportSize(ViewportSize);
        
        float ViewportScale = GetLocalPlayer()->ViewportClient->GetDPIScale();
        
        // 转换为当前视口空间坐标
        return (ScreenPosition - ViewportOrigin) * ViewportScale;
    }
    
  2. 摄像机比例失调
    修改视口大小时,必须同步调整摄像机宽高比:

    视口比例 推荐摄像机设置
    宽屏(16:9) FOV 90°, AspectRatio 1.777
    方屏(1:1) FOV 75°, AspectRatio 1.0
    竖屏(9:16) FOV 60°, AspectRatio 0.5625
  3. 输入映射混乱
    每个玩家的输入必须限定在其视口区域:

    bool AMyPlayerController::IsInputInViewport(const FVector2D& ScreenPos) {
        FVector2D ViewportOrigin, ViewportSize;
        GetViewportDimensions(ViewportOrigin, ViewportSize);
        
        return ScreenPos.X >= ViewportOrigin.X && 
               ScreenPos.Y >= ViewportOrigin.Y &&
               ScreenPos.X <= (ViewportOrigin.X + ViewportSize.X) &&
               ScreenPos.Y <= (ViewportOrigin.Y + ViewportSize.Y);
    }
    
  4. 性能优化策略
    非对称分屏时,不同视口可采用不同渲染质量:

    • 小视口:降低分辨率比例(0.7-0.8)
    • 大视口:保持原生分辨率
    • 不可见视口:暂停渲染
  5. 动态布局切换
    使用状态模式管理不同布局配置:

    UENUM(BlueprintType)
    enum class EViewportLayout : uint8 {
        DefaultSplit,
        FocusMode,
        PictureInPicture,
        Custom
    };
    
    void AMyGameMode::SetActiveLayout(EViewportLayout NewLayout) {
        CurrentLayout = NewLayout;
        
        switch (NewLayout) {
            case EViewportLayout::FocusMode:
                ApplyFocusModeConfig();
                break;
            // 其他布局处理...
        }
    }
    
  6. 多显示器支持
    通过识别DisplayID将不同玩家分配到不同物理屏幕:

    void AMyGameMode::AssignToDisplay(int32 PlayerIndex, int32 DisplayID) {
        FDisplayMetrics Metrics;
        FDisplayMetrics::GetDisplayMetrics(Metrics);
        
        if (DisplayID < Metrics.MonitorInfo.Num()) {
            const FMonitorInfo& Info = Metrics.MonitorInfo[DisplayID];
            // 设置视口为显示器物理坐标...
        }
    }
    
  7. 回退机制
    任何自定义分屏系统都应保留恢复默认的路径:

    void AMyGameMode::ResetToDefaultSplit() {
        if (GEngine && GEngine->GameViewport) {
            GEngine->GameViewport->SetForceDisableSplitscreen(false);
            GEngine->GameViewport->LayoutPlayers();
        }
    }
    

5. 源码剖析:视口布局的刷新机制

理解 UGameViewportClient 的工作流程至关重要。当调用 LayoutPlayers() 时,引擎内部会:

  1. 清除现有视口
  2. 根据当前 SplitscreenInfo 重新计算布局
  3. 为每个玩家创建新的 FSceneView
  4. 触发 OnViewportCreated 事件

关键覆盖点 :你可以继承 UGameViewportClient 来修改这一过程:

void UMyGameViewportClient::LayoutPlayers() {
    if (bUseCustomLayout) {
        // 完全自定义布局逻辑
        CalculateCustomViewports();
    } else {
        // 回退到原生实现
        Super::LayoutPlayers();
    }
    
    // 通知所有监听者
    OnViewportLayoutChanged.Broadcast();
}

对于需要每帧调整的高级应用,可以重写 Tick() 函数:

void UMyGameViewportClient::Tick(float DeltaTime) {
    Super::Tick(DeltaTime);
    
    if (bDynamicViewports) {
        UpdateDynamicViewports(DeltaTime);
    }
}

6. 蓝图集成:设计师友好的控制接口

为了让关卡设计师能自由实验不同布局,我们暴露以下蓝图函数:

  • Set Player Viewport Size - 调整单个玩家视口尺寸
  • Swap Viewport Positions - 动态交换两个玩家视口位置
  • Enable Focus Mode - 激活主玩家放大模式
  • Save Layout Preset - 将当前布局保存为可复用的资产

示例蓝图实现

Blueprint example for viewport control

通过 GameplayTag 系统,可以实现条件触发的布局切换:

void AMyGameMode::OnGameplayTagAdded(FGameplayTag Tag) {
    if (Tag.MatchesTag(FGameplayTag::RequestGameplayTag("Viewport.Layout.Focus"))) {
        ActivateFocusMode();
    }
}

7. 性能监控与调试技巧

自定义分屏系统需要特别的调试手段:

视口调试命令

命令 功能
ShowDebug Viewports 显示视口边界和玩家索引
DumpViewportInfo 输出当前布局参数到日志
TestViewportResize 触发动态尺寸测试

统计信息监控

// 在HUD上显示关键指标
void AMyHUD::DrawStats() {
    if (GEngine && GEngine->GameViewport) {
        for (int32 i = 0; i < GEngine->GameViewport->SplitscreenInfo.Num(); ++i) {
            const FPerPlayerSplitscreenData& Data = 
                GEngine->GameViewport->SplitscreenInfo[i].PlayerData[0];
                
            FString Info = FString::Printf(TEXT("P%d: %.1f x %.1f @ (%.1f,%.1f)"), 
                i, Data.SizeX, Data.SizeY, Data.OriginX, Data.OriginY);
                
            DrawText(Info, FVector2D(10, 100 + i*20), GEngine->GetSmallFont());
        }
    }
}

GPU负载均衡技巧

  • 将不同视口的渲染分散到不同帧
  • 根据视口大小动态调整渲染分辨率
  • 对不可见区域使用简化的渲染路径
// 在自定义ViewportClient中
void UMyGameViewportClient::BeginRenderViewport() {
    if (ShouldSkipRenderThisFrame()) {
        return; // 跳过小视口的偶帧渲染
    }
    Super::BeginRenderViewport();
}

更多推荐