【UE·UI&功能开发篇】屏幕指示器UI功能开发

在很多游戏里,经常能看到围绕屏幕中心,有一个指示器用来表示目标点相对玩家的位置信息。本文以原神为例,做一个类似功能的指示器UI。
《地平线:零之曙光》:在这里插入图片描述《原神》:
在这里插入图片描述
在这里插入图片描述

功能拆解

可以看到指示器有2种显示状态:

  • 目标点在屏幕的椭圆范围内时,指示器UI的位置就是目标点的世界坐标
  • 目标点在屏幕的椭圆范围外时,指示器UI的位置在椭圆轨迹上,且此时表示目标点的方位。

难点拆解

椭圆范围外时的情况比较难,因此我们先考虑第一种情况。

难点1:如何判断世界坐标系上的某一点在屏幕范围内?

这个比较简单,UE已经提供了方法。
UWidgetLayoutLibrary::ProjectWorldLocationToWidgetPosition (把世界坐标转换为UI坐标)的说明:

/**
	 * Gets the projected world to screen position for a player, then converts it into a widget
	 * position, which takes into account any quality scaling.
	 * @param PlayerController The player controller to project the position in the world to their screen.
	 * @param WorldLocation The world location to project from.
	 * @param ScreenPosition The position in the viewport with quality scale removed and DPI scale remove.
	 * @param bPlayerViewportRelative Should this be relative to the player viewport subregion (useful when using player attached widgets in split screen or when aspect-ratio constrained)
	 * @return true if the position projects onto the screen.
	 */
	UFUNCTION(BlueprintPure, BlueprintCosmetic, Category="Viewport")
	static bool ProjectWorldLocationToWidgetPosition(APlayerController* PlayerController, FVector WorldLocation, FVector2D& ScreenPosition, bool bPlayerViewportRelative);

调用举例:

FVector2D ScreenPosition;
if (UWidgetLayoutLibrary::ProjectWorldLocationToWidgetPosition(playController, destination, ScreenPosition, false))
{

}

调用后会发现即使返回true,传入的点也可能不在屏幕上,与我们要求不符,这个点超出屏幕的一小部分范围时也是会返回true的,而如果这个点如果在相机后面,肯定是返回false的。因此我们需要对求出来的UI坐标进行进一步判断。

难点2:如何求出屏幕边缘点的坐标?

在讲这个之前,需要理清三个概念,ViewPort、DesignScreenSize、Resolution

  • ViewPort:游戏视口大小。比如你的电脑屏幕是1920x1080,游戏窗口占用了四分之一的屏幕。那么ViewPort就是960x540。
int32 viewPortX, viewPortY;
playController->GetViewportSize(viewPortX, viewPortY);
  • DesignScreenSize:UI设计时的屏幕基准大小。比如我们设计UI时都是基于1920x1080的大小进行布局,游戏运行时的视口大小如果完全符合那么万事大吉,如果不一样则进行一定规则的缩放。【UE4 dpi scale 研究总结
float designSizeX = viewPortX / UWidgetLayoutLibrary::GetViewportScale(this);
float designSizeY = viewPortY / UWidgetLayoutLibrary::GetViewportScale(this);
  • Resolution:游戏运行的分辨率。如果是窗口模式,那么分辨率和视口大小是相等的。如果是全屏就不一定。比如你电脑1920x1080的全屏模式,依然可以把游戏分辨率选择为1280x720,此时的Resolution就是1280x720。【How to get current screen size/resolution?
FVector2D Result = FVector2D( 1, 1 );
Result.X = GSystemResolution.ResX;
Result.Y = GSystemResolution.ResY;

经过实验发现,屏幕左上角的UI坐标为(0,0),右下角坐标为(designSizeX,designSizeY),中心点坐标为(designSzieX/2,designSizeY/2)。自此,上一个问题也迎刃而解了。同时在这个案例中,我把任务指示器的锚点设为屏幕中心,因此算出来的坐标点需要减掉一般的desginSize。

//前面计算desginSize过程省略,完整代码在文章末尾。
FVector2D ScreenPosition;
if (UWidgetLayoutLibrary::ProjectWorldLocationToWidgetPosition(playController, destination, ScreenPosition, false))
{
	//算出来的ScreenPos原点在左上角
	auto ScreenPosX = ScreenPosition.X;
	auto ScreenPosY = ScreenPosition.Y;
	
	if (ScreenPosX < designSizeX && ScreenPosY < designSizeY && ScreenPosX > 0 && ScreenPosY > 0)
	{
		ScreenPosX -= designSizeX / 2;
		ScreenPosY -= designSizeY / 2;
	}
}

难点3:如何判断点在椭圆内?

让我们复习一下椭圆公式。其中a为长半轴,b为短半轴。
在这里插入图片描述
我们设长轴(2a)占屏幕大小为LongAxisPercent,短轴(2b)占屏幕大小为MinorAixsPercent。那么a、b为:

float a = LongAxisPercent * designSizeX / 2;
float b = MinorAixsPercent * designSizeY / 2;

要计算点是否在椭圆内,只需要把上一步计算出的点代入公式,注意我们上一步计算时已经把点的位置从左上角的原点坐标转换为以屏幕中心为坐标原点,而椭圆公式也是以屏幕中心为坐标原点,因此可以直接代入,结果小于1则说明在椭圆内。

FVector2D ScreenPosition;
if (UWidgetLayoutLibrary::ProjectWorldLocationToWidgetPosition(playController, destination, ScreenPosition, false))
{
	//算出来的ScreenPos原点在左上角
	auto ScreenPosX = ScreenPosition.X;
	auto ScreenPosY = ScreenPosition.Y;
	
	if (ScreenPosX < designSizeX && ScreenPosY < designSizeY && ScreenPosX > 0 && ScreenPosY > 0)
	{
		ScreenPosX -= designSizeX / 2;
		ScreenPosY -= designSizeY / 2;
		if (((ScreenPosX * ScreenPosX) / (a * a) + (ScreenPosY * ScreenPosY) / (b * b)) < 1)
		{
			ScreenPos.X = ScreenPosX;
			ScreenPos.Y = ScreenPosY;
		}
	}
}

难点4:如何求出相对位置?

最后回到开头说的第一种情况。

  • 目标点在屏幕的椭圆范围外时,指示器UI的位置在椭圆轨迹上,且此时表示目标点的方位。

首先,先来思考这个相对位置是相对玩家还是相对相机?很显然是相机,因为相机转的时候玩家不一定会跟着转向,而相机转的时候这个指示器一定会跟着变化。到这里的分析我们还是不足以写出代码。所以我举一些特例并配合画图来思考整个过程。(在UE中,Z轴是上方,X轴是前方,Y轴是右方)
如图,假设目标点在玩家正左边,那么在屏幕上的椭圆位置为x轴最左边。
在这里插入图片描述
第二种情况,把目标点往x轴正向移动,在椭圆上的位置也是不应该变的。
在这里插入图片描述
第三种情况,x轴依然在最左方,y轴为0,把目标点往z轴移动,因为左图是俯视图所以不会发生变化,同时为了方便理解,我把目标点在屏幕上的位置也一起画了出来。此时,我们把指示器正确的位置也画出来,并且把目标点和指示器连成一条线。设这个向量为dir。
在这里插入图片描述
到这里,问题已经迎刃而解了。我们只需要求出dir所在直线与椭圆的交点,取x轴方向和dir的x轴方向相同的那个交点即可。

接下来求dir的单位向量,很显然,dir的x轴/y轴等于目标点相对于相机的坐标的y轴/z轴

直线公式y = k * x。k = dir.Y/dir.X。把直线公式导入椭圆公式可得交点。

代码如下:

//这两个变量是定义在头文件里的
//FTransform CameraTransform;
//FVector2D ScreenPos;

CameraTransform.SetLocation(UGameplayStatics::GetPlayerCameraManager(GetWorld(), 0)->GetCameraLocation());
CameraTransform.SetRotation(UGameplayStatics::GetPlayerCameraManager(GetWorld(), 0)->GetCameraRotation().Quaternion());

auto localDestination = CameraTransform.InverseTransformPositionNoScale(destination);//求解相对坐标
FVector2D destinationDir = FVector2D(localDestination.Y, localDestination.Z);
destinationDir.Normalize();
int32 viewPortX, viewPortY;
playController->GetViewportSize(viewPortX, viewPortY);

float k = destinationDir.Y / destinationDir.X;

float designSizeX = viewPortX / UWidgetLayoutLibrary::GetViewportScale(this);
float designSizeY = viewPortY / UWidgetLayoutLibrary::GetViewportScale(this);

//在UI空间下x轴往右,y轴往下
//椭圆公式 y^2/a^2 + x^2/b^2 = 1
float a = LongAxisPercent * designSizeX / 2;
float b = MinorAixsPercent * designSizeY / 2;

//求解交点
float x = UKismetMathLibrary::SignOfFloat(destinationDir.X) * UKismetMathLibrary::Sqrt((a * a * b * b) / (b * b + a * a * k * k));
float y = k * x;
ScreenPos.X = x;

ScreenPos.Y = -1 * y;

完整代码

UIndicatorWidget.h

#pragma once

#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "Components/Image.h"
#include "IndicatorWidget.generated.h"


/**
 * 
 */
UCLASS()
class MYTPSPROJECT_API UIndicatorWidget : public UUserWidget
{
	GENERATED_BODY()
protected:
	//绑定UMG蓝图控件
	UPROPERTY(BlueprintReadWrite, meta = (BindWidget))
		UImage* IndicatorIcon;

	UPROPERTY(EditAnywhere, Meta = (ClampMin = 0, ClampMax = 1))
		float LongAxisPercent = 0.7f;//椭圆长轴占屏幕宽度百分比
	UPROPERTY(EditAnywhere, Meta = (ClampMin = 0, ClampMax = 1))
		float MinorAixsPercent = 0.7f;//椭圆短轴占屏幕高度百分比

	FVector2D ScreenPos;//缓存计算结果

	FTransform CameraTransform;//构造相机的Transform,用于计算相对坐标
	
protected:
	void CalculateIndicatorScreenPos(FVector destination);

	void NativeTick(const FGeometry& MyGeometry, float InDeltaTime) override;
};

UIndicatorWidget.cpp

// Fill out your copyright notice in the Description page of Project Settings.


#include "IndicatorWidget.h"

#include "Blueprint/WidgetLayoutLibrary.h"
#include "GameFramework/Character.h"
#include "Components/CanvasPanelSlot.h"
#include "Kismet/GameplayStatics.h"
#include "Kismet/KismetMathLibrary.h"

void UIndicatorWidget::CalculateIndicatorScreenPos(FVector destination)
{
	auto playController = UGameplayStatics::GetPlayerController(GetWorld(), 0);
	ACharacter* myCharacter = UGameplayStatics::GetPlayerCharacter(GetWorld(), 0);
	if (playController == nullptr || myCharacter == nullptr)
	{
		return;
	}
	CameraTransform.SetLocation(UGameplayStatics::GetPlayerCameraManager(GetWorld(), 0)->GetCameraLocation());
	CameraTransform.SetRotation(UGameplayStatics::GetPlayerCameraManager(GetWorld(), 0)->GetCameraRotation().Quaternion());

	auto localDestination = CameraTransform.InverseTransformPositionNoScale(destination);

	FVector2D destinationDir = FVector2D(localDestination.Y, localDestination.Z);
	destinationDir.Normalize();
	int32 viewPortX, viewPortY;
	playController->GetViewportSize(viewPortX, viewPortY);

	float k = destinationDir.Y / destinationDir.X;

	float designSizeX = viewPortX / UWidgetLayoutLibrary::GetViewportScale(this);
	float designSizeY = viewPortY / UWidgetLayoutLibrary::GetViewportScale(this);
	
	//在UI空间下x轴往右,y轴往下,y轴与我们习惯的数学上的坐标系相反,所以最后结果要乘以-1
	//椭圆公式 y^2/a^2 + x^2/b^2 = 1
	float a = LongAxisPercent * designSizeX / 2;
	float b = MinorAixsPercent * designSizeY / 2;

	//求解交点
	float x = UKismetMathLibrary::SignOfFloat(destinationDir.X) * UKismetMathLibrary::Sqrt((a * a * b * b) / (b * b + a * a * k * k));
	float y = k * x;
	ScreenPos.X = x;

	ScreenPos.Y = -1 * y;

	FVector2D ScreenPosition;
	if (UWidgetLayoutLibrary::ProjectWorldLocationToWidgetPosition(playController, destination, ScreenPosition, false))
	{
		//算出来的ScreenPos原点在左上角
		auto ScreenPosX = ScreenPosition.X;
		auto ScreenPosY = ScreenPosition.Y;
		
		if (ScreenPosX < designSizeX && ScreenPosY < designSizeY && ScreenPosX > 0 && ScreenPosY > 0)
		{
			ScreenPosX -= designSizeX / 2;
			ScreenPosY -= designSizeY / 2;
			if (((ScreenPosX * ScreenPosX) / (a * a) + (ScreenPosY * ScreenPosY) / (b * b)) < 1)
			{
				ScreenPos.X = ScreenPosX;
				ScreenPos.Y = ScreenPosY;
			}
		}
	}
}

void UIndicatorWidget::NativeTick(const FGeometry& MyGeometry, float InDeltaTime)
{
	//测试用的位置,实际使用时传入需要的位置
	CalculateIndicatorScreenPos(FVector::UpVector * 200);
	auto canvasSlot = Cast<UCanvasPanelSlot>(Slot);
	if (canvasSlot) {
		canvasSlot->SetPosition(ScreenPos);
	}
}


复盘

本次的开发过程并不是很顺利。难点在上面也都提到了。其中求方位是最难的,思考了比较长时间。

  • 坐标系的变换。世界坐标到相机坐标的变换。世界坐标到UI坐标的变换。UI坐标算完了之后也不能直接使用,要考虑到控件的锚点,锚点在中心需要再进行一轮变换。这一功能的实现需要对UE的一些接口熟悉。
  • 屏幕坐标的正确范围是DesignScreenSize。按屏幕百分比取长轴短轴需要拿DesginScreenSize进行计算,一开始使用了ViewPort进行计算,在高分辨率(4k)的机子上表现为椭圆会超出屏幕边缘,这是因为此时的ViewPort是4000多,乘以百分比后依然是可能大于DesignScreenSize(通常为1920x1080)。
  • 在椭圆轨迹上表示方位。这步花了最长时间。这个方位实际上和x轴的坐标无关,也就是目标点相对于相机的前后距离在UI上是看不到的。当目标点位于相机后上方时,指示器位置却是在屏幕上半个椭圆上。这个直观上很难想象出来,需要画图和举例才能总结出规律。.

关于作者

  • 水曜日鸡,喜欢ACG的游戏程序员。曾参与索尼中国之星项目《硬核机甲》的开发。 目前在某大厂做UE4项目。

CSDN博客:https://blog.csdn.net/j756915370
知乎专栏:https://zhuanlan.zhihu.com/c_1241442143220363264
游戏同行聊天群:891809847

猜你喜欢

转载自blog.csdn.net/j756915370/article/details/119144492