Ayy3

时间停止吧,你是多么的美丽

UE5 Multiplayer

LAN

在同一个局域网(LAN)中,可以创建一个游戏会话,并让多个玩家加入。

这里在PlayerController中创建函数,然后在蓝图调用。可以作为listen server开启服务器,也可以作为client通过ip地址加入别人的listen server

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void AMyPlayerController::OpenLobby()
{
UWorld* World = GetWorld();
if (World)
{
World->ServerTravel("/Game/ThirdPerson/Maps/Lobby?listen");
}
}

void AMyPlayerController::CallOpenLevel(const FString& Address)
{
UGameplayStatics::OpenLevel(this, *Address);
}

void AMyPlayerController::CallClientLevel(const FString& Address)
{
ClientTravel(Address, TRAVEL_Absolute);
}

Online Subsystem

通过LAN连接,需要保证每个player都在同一个局域网中。然而我们希望可以实现跨地区的连接,这表明不同的玩家可能处于不同的局域网中,无法采用直接输入局部IP的方式来加入某个listen server。

当然我们有全局ip,但是玩家还是需要手动输入ip地址,这显然不方便。我们希望能够在游戏中直接搜寻玩家的ID,然后加入他们的游戏。

一种方式是对你自己的游戏建立专门的服务器,该服务器要实现能够维护一个IP列表的功能来表明所有的玩家。然而这种方式成本较高,我们希望还是通过listen server的方式来实现。

另一种方式是采用所谓的在线服务(Online Services),例如steam和Xbox。这种在线服务不仅能够抽象IP层面,还有其它的功能(后续会提到,比如成就系统)。

当然,所谓的在线服务不直接决定使用 Listen Server 还是 Dedicated Server,它的核心目标是 帮助玩家发现和加入游戏会话,而会话的实际运行模式(Listen/Dedicated)由开发者配置。

对于不同的service,虽然它们提供的功能大致相同,但是它们在实现上有所不同。例如创建会话(Session),Steam有自己的一套实现,Xbox也有自己的一套实现。

UE5中,我们使用Online Subsystem来抽象这些不同的service。(这就好比用RHI来抽象不同的图形API?)

Online Subsystem Steam

这里采用Steam作为Online Service。在编辑器里加入插件,然后在游戏模块中加入OSS和Steam的模块,然后在DefaultEngine.ini中配置相关项。

目前的话,我们想要通过Steam来创建会话。

GPT: 在多人游戏开发中,“会话”(Session)通常指的是一次游戏匹配或房间实例,它包含了游戏设置、玩家列表、连接信息等。也就是说,当玩家创建一个游戏并允许其他玩家加入时,这个创建出来的组就叫做一个会话。会话管理确保所有参与者能够在同一环境下进行游戏互动,并且负责处理连接、匹配、邀请以及会话广告等功能。

我们首先要获取我们的OSS:

1
IOnlineSubsystem* OnlineSubsystem = IOnlineSubsystem::Get();

回顾一下OSS的定义:OnlineSubsystem - Series of interfaces to support communicating with various web/platform layer services,也就是说,OSS内部包含了一系列的接口,用于支持与各种web/平台层服务的通信。

然后有趣的是,OSS本身也是一个接口(它的前缀是I)。这里通过Get()获取的是一个具体的实现,例如SteamOnlineSubsystem。

可以通过阅览OnlineSubsystemSteam的源码来确认。FOnlineSubsystemSteam继承FOnlineSubsystemImpl,而FOnlineSubsystemImpl则继承了IOnlineSubsystem。

由于我们需要OSS的创建会话相关功能,故提取该接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
if (OnlineSubsystem)
{
OnlineSessionInterface = OnlineSubsystem->GetSessionInterface(); //这个是成员变量
if (GEngine)
{
GEngine->AddOnScreenDebugMessage(
-1,
15.f,
FColor::Blue,
FString::Printf(TEXT("Online Subsystem %s"), *(OnlineSubsystem->GetSubsystemName().ToString()))
);
}
}

然后就可以创建会话了。这里为了确认创建了会话,我们使用一个委托FOnCreateSessionCompleteDelegate,并将其绑定到一个自己的函数void OnCreateSessionComplete(FName SessionName, bool bWasSuccessful);

所谓delegate,我目前的理解就是在某些情况下进行回调。在ue中有很多类型的delegate,这里的FOnCreateSessionCompleteDelegate也是一类,它在创建会话后回调,它对应的回调函数要求有特定的函数签名(直接查看定义就能获取)。我们将其绑定到函数 void OnCreateSessionComplete(FName SessionName, bool bWasSuccessful); 上。绑定的过程可以直接和创建delegate一起:OnCreateSessionCompleteDelegate =(FOnCreateSessionCompleteDelegate::CreateUObject(this, &ThisClass::OnCreateSessionComplete))

然后可以正式创建会话:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
void AMenuSystemCharacter::CreateSession()
{
if (!OnlineSessionInterface.IsValid())
{
// Session Interface is null, just return
return;
}

// check whether we have created a session before. If it exists, destroy it!
const FNamedOnlineSession* ExistingSession = OnlineSessionInterface->GetNamedSession(NAME_GameSession);
if (ExistingSession)
{
OnlineSessionInterface->RemoveNamedSession(NAME_GameSession);
}

// Add our delegate to OnlineSessionInterface's delegate list, otherwise we'll not have our callback
OnlineSessionInterface->AddOnCreateSessionCompleteDelegate_Handle(OnCreateSessionCompleteDelegate);

const TSharedPtr<FOnlineSessionSettings> OnlineSessionSettings = MakeShareable(new FOnlineSessionSettings());
OnlineSessionSettings->bIsLANMatch = false; // We don't use LAN, we use the internet.
OnlineSessionSettings->bAllowJoinInProgress = true; // We allow other clients to join when the session is alive.
OnlineSessionSettings->NumPublicConnections = 4; // Public Connections don't need password?
OnlineSessionSettings->bAllowJoinViaPresence = true; // 在线方式???不太懂
OnlineSessionSettings->bShouldAdvertise = true; // 公开在线会话
OnlineSessionSettings->bUsesPresence = true; // 应该是搭配 bAllowJoinViaPresence。得先有Presence,才能via Presence
OnlineSessionSettings->bUseLobbiesIfAvailable = true; // 这个应该是steam必备的,不然无法创建会话

const TObjectPtr<ULocalPlayer> LocalPlayer = GetWorld()->GetFirstLocalPlayerFromController();
OnlineSessionInterface->CreateSession(*LocalPlayer->GetPreferredUniqueNetId(), NAME_GameSession, *OnlineSessionSettings);

if (GEngine)
{
GEngine->AddOnScreenDebugMessage(
-1,
15.f,
FColor::Blue,
FString("Try to Create Session!")
);
}
}
稍微理一下我写的方法。首先我们判断通过OSS获取的Session接口是否有效(这里的OnlineSessionInterface 被智能指针包裹着,因此需要用 isValid 来判断)。

然后判断是否已经创建了会话。

然后将我们创建的delegate添加到OnlineSessionInterface的delegate list中。

然后设置会话的属性。这里稍微提一下,我也不是很确定,就是Steam 要求匹配、好友邀请以及会话广告都是通过 Lobby 系统来实现的,因此一定要将 OnlineSessionSettings->bUseLobbiesIfAvailable 设置为 true。

注意:事实上这里的Presence代表的是Steam的Region。我们只支持在同一个区域内进行会话的查找,在Steam设置Region:

创建了会话之后,我们需要实现查找会话。原理是一样的,只不过需要设置不同的delegate和settings。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
void AMenuSystemCharacter::JoinSession()
{
if (!OnlineSessionInterface.IsValid())
{
// Session Interface is null, just return
return;
}
OnlineSessionInterface->AddOnFindSessionsCompleteDelegate_Handle(OnFindSessionsCompleteDelegate);

SessionSearch = MakeShareable(new FOnlineSessionSearch());
SessionSearch->bIsLanQuery = false;
SessionSearch->MaxSearchResults = 10000;
// We use Presence to create session, so we should find sessions which are created with Presence as well.
SessionSearch->QuerySettings.Set(SEARCH_PRESENCE, true, EOnlineComparisonOp::Equals);
const TObjectPtr<ULocalPlayer> LocalPlayer = GetWorld()->GetFirstLocalPlayerFromController();
OnlineSessionInterface->FindSessions(*LocalPlayer->GetPreferredUniqueNetId(), SessionSearch.ToSharedRef());
}

void AMenuSystemCharacter::OnFindSessionsComplete(bool bWasSuccessful)
{
if (!OnlineSessionInterface.IsValid())
{
return;
}
if (!bWasSuccessful)
{
if (GEngine)
{
GEngine->AddOnScreenDebugMessage(
-1,
15.f,
FColor::Blue,
FString("Failed to find Sessions!")
);
}
}
else
{
for (auto OnlineSessionSearchResult : SessionSearch->SearchResults)
{
FString Id = OnlineSessionSearchResult.GetSessionIdStr();
FString Name = OnlineSessionSearchResult.Session.OwningUserName;
FString MatchType;
if (GEngine)
{
GEngine->AddOnScreenDebugMessage(
-1,
15.f,
FColor::Cyan,
FString::Printf(TEXT("Id: %s, User: %s"), *Id, *Name)
);
}
if (OnlineSessionSearchResult.Session.SessionSettings.Get(FName("MatchType"), MatchType))
{
if (MatchType == FString("FuckNUAA"))
{
if (GEngine)
{
GEngine->AddOnScreenDebugMessage(
-1,
15.f,
FColor::Cyan,
FString::Printf(TEXT("Joining Match Type: %s"), *MatchType)
);
}
OnlineSessionInterface->AddOnJoinSessionCompleteDelegate_Handle(OnJoinSessionCompleteDelegate);

const TObjectPtr<ULocalPlayer> LocalPlayer = GetWorld()->GetFirstLocalPlayerFromController();
OnlineSessionInterface->JoinSession(*LocalPlayer->GetPreferredUniqueNetId(), NAME_GameSession, OnlineSessionSearchResult);
}
}
}
}
}

这里注意我们需要传入一个 FOnlineSessionSearch 的引用,在寻找会话时,会往其内部其添加寻找结果 SessionSearch->SearchResults ,这是一个 TArray<FOnlineSessionSearchResult> ,可以通过遍历查找我们要加入的Session。

为了查找我们要的Session,在创建会话时可以指定一个键值对:

1
2
// key value pair to filter sessions
OnlineSessionSettings->Set(FName("MatchType"), FString("FuckNUAA"), EOnlineDataAdvertisementType::ViaOnlineServiceAndPing);

查找到Session后,我们就可以加入会话了。在加入会话对应的delegate实现加入会话的逻辑,例如通过获取ip来进入会话的level:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
void AMenuSystemCharacter::OnJoinSessionComplete(FName SessionName, EOnJoinSessionCompleteResult::Type Result)
{
if (!OnlineSessionInterface.IsValid())
{
return;
}
FString Address;
if (OnlineSessionInterface->GetResolvedConnectString(NAME_GameSession, Address))
{
if (GEngine)
{
GEngine->AddOnScreenDebugMessage(
-1,
15.f,
FColor::Yellow,
FString::Printf(TEXT("Connect string: %s"), *Address)
);
}
APlayerController* PlayerController = GetGameInstance()->GetFirstLocalPlayerController();
if (PlayerController)
{
PlayerController->ClientTravel(Address, TRAVEL_Absolute);
}
}
}

Plugin

以上大致就是我们通过OSS来创建会话、查找会话、加入会话的流程。这流程和游戏项目本身无关,因此考虑将其封装成Plugin。

所谓的Plugin(插件),其实就是一系列独立的代码(Code)和资源(Resource),它们可以实现游戏运行时的一些功能,也可以实现编辑器的一些功能。可以直接在引擎里创建Plugin:

Module

讲到Plugin,顺便讲讲Module。Module是UE中的一种组织代码的方式,它将代码组织成一个独立的模块,可以被其他模块调用。Module只有代码没有资源,每个Module只实现一个单一的目标,且只包含一个 .Build.cs 文件(用于构建)。我们的游戏本身也是一个Module,可以在 .Build.cs 里调用其它Module。

Plugin本身其实就是由一个或者多个Module组成。

Module(Plugin)之间具有依赖关系,但这种依赖关系并不是没有限制的。事实上有层级限制:

显然Engine层面的Module或Plugin不能依赖Game层面的Module。但我们Game层面的Module可以依赖Engine层面的Module(Plugin)。

在游戏项目的 .uproject 文件中,我们可以看到我们当前依赖的Plugin,这里包括之前指定的OSSSteam:

1
2
3
4
5
6
7
8
9
10
11
12
13
"Plugins": [
{
"Name": "ModelingToolsEditorMode",
"Enabled": true,
"TargetAllowList": [
"Editor"
]
},
{
"Name": "OnlineSubsystemSteam",
"Enabled": true
}
]

对于Plugin,它对应的文件后缀为 .uplugin。我们在里面添加我们需要的Plugin:

1
2
3
4
5
6
7
8
9
10
"Plugins": [
{
"Name": "OnlineSubsystem",
"Enabled": true
},
{
"Name": "OnlineSubsystemSteam",
"Enabled": true
}
]

在对应的 .Build.cs 文件中,需要添加Module:

1
2
3
4
5
6
7
8
9
PublicDependencyModuleNames.AddRange(
new string[]
{
"Core",
"OnlineSubsystem",
"OnlineSubsystemSteam"
// ... add other public dependencies that you statically link with here ...
}
);

GPT: .uplugin 文件负责在插件层面声明依赖关系,确保必要的插件被加载;而 .Build.cs 文件则在编译层面指定模块依赖,确保编译过程顺利进行。​两者协同工作,确保插件在运行时和编译时都能正确地识别和使用其依赖项。

我也不是很能理解。

Replicate

接下来就正式进入游戏内容部分。

我们定义了一个 AWeapon 类,用于实现武器的逻辑。武器具有以下状态:

1
2
3
4
5
6
7
8
9
UENUM(BlueprintType)
enum class EWeaponState: uint8
{
EWS_Initial UMETA(DisplayName = "Initial State"),
EWS_Equipped UMETA(DisplayName = "Equipped"),
EWS_Dropped UMETA(DisplayName = "Dropped"),

EWS_MAX UMETA(DisplayName = "DefaultMAX")
};

当武器放置在地上的时候,我们希望角色能够可以拾取它。因此设置一个trigger sphere,当角色进入sphere时,显示拾取武器的Widget。

但是在多人游戏中,这种逻辑一般只在服务器上运行。要知道服务器上运行的游戏版本是权威的,同时只在服务器上运行重要的逻辑,具有单例模式的优势,处理起来也比较简单。

服务器处理完一定逻辑后,需要将一些变量同步给客户端,例如在角色类中,我们设置了 OverlapWeapon 变量。在服务器端可以使用SphereComponent自带的Delegate来触发:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void AWeapon::OnAreaSphereBeginOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor,
UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
AFuckerCharacter* Fucker = Cast<AFuckerCharacter>(OtherActor);
if (Fucker)
{
Fucker->SetOverlapWeapon(this);
}
}

void AWeapon::OnAreaSphereEndOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor,
UPrimitiveComponent* OtherComp, int32 OtherBodyIndex)
{
AFuckerCharacter* Fucker = Cast<AFuckerCharacter>(OtherActor);
if (Fucker)
{
Fucker->SetOverlapWeapon(nullptr);
}
}

以上函数只在服务器端运行。因此,当client的玩家进入sphere时,首先设置OverlapWeapon的是服务器的游戏实例上的角色。如果要设置同步,需要设置以下几点:

1
2
3
4
5
6
// 在角色类中
UPROPERTY(ReplicatedUsing=OnRep_OverlapWeapon)
TObjectPtr<AWeapon> OverlapWeapon;

UFUNCTION()
void OnRep_OverlapWeapon(AWeapon* LastOverlapWeapon);
这里将 OverlapWeapon 设置为Replicated,并设置 OnRep_OverlapWeapon 函数,该函数在每次 OverlapWeapon 变化时被调用,同时可以传入 OverlapWeapon 的旧值,这有利于我们实现逻辑。

然后需要将该变量进行注册:

1
2
3
4
5
6
7
void AFuckerCharacter::GetLifetimeReplicatedProps(TArray<class FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);

// Only replicate OverlapWeapon to the Fucker that owns the replicated OverlapWeapon
DOREPLIFETIME_CONDITION(AFuckerCharacter, OverlapWeapon, COND_OwnerOnly);
}

这里 DOREPLIFETIME_CONDITION 是UE中用于设置变量同步的宏,COND_OwnerOnly 表示只有拥有该变化的变量的角色才会同步该变量。

可以在 OnRep_OverlapWeapon 函数中实现Overlap武器的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Only Called on client
void AFuckerCharacter::OnRep_OverlapWeapon(AWeapon* LastOverlapWeapon)
{
// We now handle the OverlapWeapon on the client, but how about the server?
if (LastOverlapWeapon)
{
LastOverlapWeapon->ShowPickupWidget(false);
}
if (OverlapWeapon)
{
OverlapWeapon->ShowPickupWidget(true);
}
}

这样在Client端,当玩家进入sphere时,就会显示Widget。但是对于Server端,这个函数是不会触发的。

为了在Server端也触发这个逻辑,我们可以在修改OverlapWeapon时,特别考虑Server端的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Only Called on server
void AFuckerCharacter::SetOverlapWeapon(AWeapon* Weapon)
{
// This will replicate OverlapWeapon to the client
// Except a situation: the server's controlled Fucker
// So we'll handle that
if (OverlapWeapon)
{
// We should check this Condition. Otherwise when the client's pawn on the server
// end overlap the weapon that the server's pawn is overlapping, the server will
// not display the widget as well.
if (IsLocallyControlled())
{
OverlapWeapon->ShowPickupWidget(false);
}
}
OverlapWeapon = Weapon;
if (OverlapWeapon)
{
if (IsLocallyControlled())
{
OverlapWeapon->ShowPickupWidget(true);
}
}
}
IsLocallyControlled() 函数用于判断是否是本地控制的角色。由于该函数只在服务器端运行,因此这相当于判断该角色是否是服务器控制的角色。 > 第一次接触Replicate,我觉得有点复杂。我认为要将Server和Client分开,在OnRep系列函数实现Client端的逻辑,而在发生Replicate的地方特别执行Server端的逻辑。注意一定要判断 IsLocallyControlled() ,否则会导致逻辑混乱。(可以看我上面的注释,不判断的话会有bug)。

在本笔记中,我们将要接触的,是内置于UE5的Gameplay Ability System。我们将参考Udemy上的GAS课程,并根据虚幻官方的文档,来进行较为全面的理解。课程网址https://www.udemy.com/course/unreal-engine-5-gas-top-down-rpg/

基础概念(准备笔记)

Controller

在我之前使用UE4制作一些demo的时候,我通常直接在要控制的Character类中,实现一些逻辑(如控制输入)。然而,受MVC设计模式的启发,对于我们要possess的Character(或者Pawn),应当尽量将逻辑与功能解耦合。

我自己举一个例子吧,例如在射击游戏中,可能存在固定位置的机枪、岸防炮,我们应当将这些作为Pawn的子类,因为它们需要被玩家控制。对于各自武器的特定功能,例如发射子弹或者炮弹,应当在Pawn中实现,因为它们是独立于Controller的。对于Controller,我们可以切换自己possess的pawn,例如在使用固定机枪前控制我们的角色,这时候可以进行移动,而使用机枪时,则可以控制机枪射击,无法进行移动。而这便是Controller的职责。(对于切换移动,我目测可以采用UE5的Enhanced Input系统,通过切换Input Mapping Context来实现。

当然,对于Controller,它本身也内置了一些控制逻辑,例如我在制作射击游戏demo的时候,在角色死亡后,需要调用PlayerController内置的禁用输入,来防止诈尸。还有一个特定的Controller叫做AIController,它通过绑定行为树(Behavior Tree)和黑板(Blackboard)来负责控制AI的行为。例如在游戏中,AI需要根据玩家的位置,来判断是否需要进行移动,或者进行攻击。

我目前对于Controller的理解还不是很深刻,在之后的学习中,我会通过实际运用,例如完成Udemy上的GAS课程,来加深理解。 对于Controller的深入思考,推荐https://zhuanlan.zhihu.com/p/23480071

对于项目的配置,我们首先要保证能移动角色。首先进行动画蓝图的配置(这里不予赘述),然后就是在PlayerController中,配置移动逻辑。

我们在PlayerController的类中,添加增强输入系统所需要的IMC(Input Mapping Context)和IA(Input Action):

1
2
3
4
5
UPROPERTY(EditAnywhere, Category = Input)
TObjectPtr<UInputMappingContext> AuraContext;

UPROPERTY(EditAnywhere, Category = "Input | Actions")
TObjectPtr<UInputAction> MoveAction;

然后在 BeginPlay 中,通过 UEnhancedInputLocalPlayerSubsystem 来添加IMC:

1
2
3
UEnhancedInputLocalPlayerSubsystem* Subsystem = ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(GetLocalPlayer());
check(Subsystem); // assert
Subsystem->AddMappingContext(AuraContext, 0); // 0是优先级,这里我们先只添加一个IMC。对于Top Down RPG,目前我们没遇到比较复杂的输入情况。

对于PlayerController,我们可以override SetupInputComponent 来添加输入逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
void AAuraPlayerController::SetupInputComponent()
{
Super::SetupInputComponent();

UEnhancedInputComponent* EnhancedInputComponent = CastChecked<UEnhancedInputComponent>(InputComponent);

EnhancedInputComponent->BindAction(
MoveAction,
ETriggerEvent::Triggered,
this,
&AAuraPlayerController::Move // 需要自行实现Move逻辑(参数类型为const FInputActionValue&,这里不予赘述)
);
}

关于增强输入系统,我目前理解的也不是很深刻(例如这里的UEnhancedInputLocalPlayerSubsystem是什么?为什么需要它?是因为在多人游戏中,每个玩家都会有个独立的UEnhancedInputLocalPlayerSubsystem,来管理不同的输入情况吗?。目前我们需要知道的是,每个IA都是独立存在,它们通过IMC来作为于输入设备的胶合剂。我们只需要添加或删除IMC,就可以实现多样化的输入。如果有时间,可以学习官方的视频教程 https://www.bilibili.com/video/BV14r4y1r7nz/,然后我会回来专门写一节笔记。

另外再提一句,在 ACharacter 类中,内置了 SetupPlayerInputComponent 的函数。我之前一直重写这个函数,来绑定输入:

1
2
3
4
void ACharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
check(PlayerInputComponent);
}

再进一步,我们会惊讶地发现,InputComponentAActor 就有的成员变量!

1
2
3
4
// Actor.h
/** Component that handles input for this actor, if input is enabled. */
UPROPERTY(DuplicateTransient)
TObjectPtr<class UInputComponent> InputComponent;

也就是说,对于普通的Actor,我们也可以通过 InputComponent 来绑定输入。然而就和前面提到的,虚幻采用了类似MVC的设计模式,将逻辑与功能解耦合。对于输入,可以想一下,如果我们需要控制不同的Character,但是一些基本的移动逻辑,它们是共有的,我们应当在Controller中实现,而不是在Character中实现。

接口(Interface)

接口是C++中的一种抽象类型,它定义了类必须实现的一组函数。接口可以被类继承,从而实现多态。

在C++中,接口通常通过纯虚函数(pure virtual function)来实现。纯虚函数是一种没有实现的虚函数,它必须在派生类中实现。

在UE也是一样。我们在本课程要实现的一个功能,就是当鼠标移动到敌人身上时,显示敌人的轮廓。我们首先想到的是在敌人的基类中实现相关功能。但是如果我们不只是需要显示敌人的轮廓,可能我们需要显示其它物品的轮廓,那么在敌人的基类中实现显示轮廓的功能,是不是就不恰当了呢?

这时候,课程中引入了接口的概念。但是很奇怪,课程命名接口为 EnemyInterface,而不是 OutlineInterface

1
2
3
4
5
6
7
8
9
10
// EnemyInterface.h
class GAS_API IEnemyInterface
{
GENERATED_BODY()

// Add interface functions to this class. This is the class that will be inherited to implement this interface.
public:
virtual void HighlightActor() = 0;
virtual void UnHighlightActor() = 0;
};

然后我们就可以在Enemy类中,继承(实现)这个接口:

1
2
3
4
5
6
7
8
UCLASS()
class GAS_API AAuraEnemy : public AAuraCharacterBase, public IEnemyInterface
{
GENERATED_BODY()
public:
virtual void HighlightActor() override;
virtual void UnHighlightActor() override;
};

GAS

接下来,我们要将GAS系统集成到我们的项目中。首先我们考虑引进两个模块:Ability System Component和Attribute Set。Ability System Component是一个Actor和GAS进行联系的桥梁,而Attribute Set则是存储和修改Actor的属性。

一种想法是将Ability System Component和Attribute Set直接作为我们要控制的Character的成员。但是这么做提升了GAS和角色的耦合性。试想一下,如果我们的角色死亡了,我们会 Destroy 掉这个Character,那么我们之前为这个Character添加的Ability System Component和Attribute Set也会被销毁。因此,在本课程中,我们选择将Ability System Component和Attribute Set作为PlayerState的成员。

思考(坑):我们是否能将Ability System Component和Attribute Set作为PlayerController的成员呢?我目前没有完美的解答。事实上,我之前做的项目中,并没有运用到PlayerState这个类。而根据MVC设计模式,我们需要一个专门的类来存储我们的信息(如Attribute Set)。而PlayerState,顾名思义,或许是存储这些东西的最优选择。至于PlayerController,它需要解决的则是逻辑相关的东西。可以参考BehaviorTree和Blackboard之间的关系。

然而,对于普通的Enemy,或许直接在类中存储Ability System Component和Attribute Set会更好。我们目前对于Enemy的想法是简单的。因此目前的规划可以用下图表示:

准备工作

首先我们在Plugins里启用GAS,然后创建自己的AuraAbilitySystemComponent和AuraAttributeSet。

然后记得在Module里添加:

1
2
3
PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "EnhancedInput" });

PrivateDependencyModuleNames.AddRange(new string[] { "GameplayAbilities", "GameplayTags", "GameplayTasks" });

我们将GAS的模块添加到PrivateDependencyModule中。

关于UE中Module的配置,可以参考:https://www.cnblogs.com/tomato-haha/p/17422871.html。用一句话来概括,就是当你的模块需要被其它模块调用时,该模块只能访问到你PublicDependencyModule中的模块的头文件信息。如果要链接,还是要在模块里显示添加。 但是你的游戏模块不太可能被其它模块添加,所以在这里其实无所谓。

Multiplayer

这门课涉及到了一些Multiplayer的内容。首先我们清楚CS架构,在UE中,大部分情况下只有一个Server,然后Client连接到Server。

Server分为两种: - Dedicated Server: 专门用于运行游戏逻辑的Server。它不代表玩家,也没有渲染画面的需求。 - Listen Server:代表一个玩家,该玩家Host这个游戏,且没有网络延迟(Lag)。

我们认为 Server is the authority。也就是说,在Server上运行的游戏版本,是正确的版本,我们需要在该版本上做“重要”的事情。

现在我们来扫清一些疑问。例如在构造PlayerController的时候,我们设置了 bReplicates = true;,这意味着什么呢?以及我们在构造PlayerState的时候,为什么要设置 NetUpdateFrequency = 100.f;?

在课程中,列举了一些类在Client和Server上的分布:

我们稍微解析一下: - GameMode:只在Server上有。在Client访问会得到nullptr。 - PlayerController:在Server和Client上都有。然而Server拥有所有Client的PlayerController的引用,而Client只拥有自己的PlayerController的引用。 - PlayerState:在Server和Client上都有,且所有版本都有。 - Pawn:和PlayerState一样。这个很好理解,我们显然要在自己的游戏中看到其它玩家。PlayerState我们现在理解为Pawn的存储,因此也很好理解。 - HUD和Widgets:只在Client上有,且只具有自己的版本。

那么bReplicates = true意味着什么呢?假设在某个Pawn上有一个 bReplicates = true 的变量,如果该变量在Server上被修改,那么在下一次 net update的时候,Server会将该变量发送给所有Client。但是,这个过程是单向的!也就是说,如果在Client上修改该变量,它将不会被传输到Server。因此我们不希望在Client上修改该变量。(很奇怪)如果要将本地的变量修改上传到Server,我们需要使用一个叫做RPC的东西。

NetUpdateFrequency = 100.f;则意为着该变量Replicate的更新频率。这里代表一秒更新100次。

在设置ASC和AS的时候,我们需要将ASC设置为:

1
2
AbilitySystemComponent->SetIsReplicated(true);
AbilitySystemComponent->SetReplicationMode(EGameplayEffectReplicationMode::Mixed);
而对于Enemy的ASC,将ReplicationMode设置为Minimal。ReplicationMode有三种,以下是它们的区别:

目前我们还不知道什么是Gameplay Effect,因此我们只需要遵守上面的Rule of Thumb。即对于玩家操控的角色,我们希望至少该玩家能够接受它在server上的变动,即Mixed;而对于AI-Controlled的角色,例如Enemy,我们玩家不需要知道它的变动,由服务器知道即可,因此设置为Minimal。

接下来,在GAS中很重要的一点是,对于我们构建的ASC,我们需要在初始化的时候(或者某些状态改变的时候),设置它的Owner Actor和Avatar Actor。Owner Actor顾名思义是构建(拥有)了该ASC的Actor,在我们的例子里是PlayerState。而Avator Actor则是ASC在World中联系的Actor,在我们的例子里是Character。对于Enemy,由于我们直接在Character类里构造了ASC,因此Owner Actor和Avatar Actor都是Character。

设置这两个Actor的方法是 InitAbilityActorInfo。那么我们要在哪里调用这个方法呢?

推荐GAS Setup资料:https://dev.epicgames.com/community/learning/tutorials/DPpd/unreal-engine-gameplay-ability-system-best-practices-for-setup

很明显在server和client,我们对于ASC的不同副本都需要设置这两个Actor。但是server和client并不相同。不过有一点值得确定,就是对于任意使用了ASC的Pawn,我们必须要在它设置了Controller之后才能调用 InitAbilityActorInfo。这点在上面链接文档里也有提到。

课程给出了调用该方法的Rule of Thumb:

思考:PossessedBy这个函数是只在server端调用的吗?根据咨询Deepseek,答案是肯定的。因此我们在client端则需要在OnRep系列函数中调用。在本项目中我们需要确保Controller和PlayerState都是有效的,然而课程中直接给出结论,就是在 OnRep_PlayerState 调用的时候,Controller也是有效的。这个我目前不是很能理解,按理来讲该方法只是在 PlayerState 执行Replicate操作后的Notify,和Controller有什么关系?

而当ASC在Pawn的时候,我们并不是在 OnRep_Controller 调用 InitAbilityActorInfo,而是在另一个方法 AcknowledgePossession 调用。这又是为什么呢?

顺带插一句,由于本人在学习该课程的时候没有任何网络基础,因此会有另一个疑问:OnRep_PlayerState 会在什么时候调用?根据Udemy中的Q&A,可以知道该函数只会在 PlayerState 本身在server端被改变的时候会进行Replicate,因此大部分情况下,该函数只会被调用一次。

关于网络连接的一些知识,可以先学习官方文档:https://dev.epicgames.com/documentation/en-us/unreal-engine/multiplayer-programming-quick-start-for-unreal-engine#4creatingaprojectilewithreplication,对所谓的Replicate,OnRep,RPC等概念有个大致的了解即可。

Attribute

我们首先在我们的 UAuraAttributeSet 中,添加我们需要的属性。关于添加属性,以下是课程中给的固定的流程,有些地方你可以在我上面给的官方文档中找到类似的东西。

首先是在头文件中添加属性,这个属性统一为 FGameplayAttributeData 类型,并使用ReplicatedUsing = OnRep_##PropertyName来添加Replicate Notify:

1
2
3
UPROPERTY(BlueprintReadOnly, ReplicatedUsing = OnRep_Health, Category = "Vital Attributes")
FGameplayAttributeData Health;
ATTRIBUTE_ACCESSORS(UAuraAttributeSet, Health)

这里的 ATTRIBUTE_ACCESSORS 宏定义了关于该属性的Accessor,例如Getter和Setter,它的定义如下:

1
2
3
4
5
#define ATTRIBUTE_ACCESSORS(ClassName, PropertyName) \
GAMEPLAYATTRIBUTE_PROPERTY_GETTER(ClassName, PropertyName) \
GAMEPLAYATTRIBUTE_VALUE_GETTER(PropertyName) \
GAMEPLAYATTRIBUTE_VALUE_SETTER(PropertyName) \
GAMEPLAYATTRIBUTE_VALUE_INITTER(PropertyName)

对于需要Replicate的ASC内的AS的属性,需要在对应的OnRep系列函数中添加:

1
2
3
4
void UAuraAttributeSet::OnRep_Health(const FGameplayAttributeData& OldHealth) const
{
GAMEPLAYATTRIBUTE_REPNOTIFY(UAuraAttributeSet, Health, OldHealth);
}

这个 GAMEPLAYATTRIBUTE_REPNOTIFY 宏,我目前不知道是干什么的,根据源码注释,这是个用于handle attributes that will be predictively modified by clients。对于predict,课程有稍微提到,在多人游戏中,为了提高游戏体验,对于Attribute的修改,可以通过predict的方式在Client上先修改,然后通过Server进行确认,而不需要先在Server上进行修改,然后replicate回来。但是对于内部的实现,我根本就看不懂,反正先记住这是设置Attribute的必备工作即可。

GAS,包括UE的源码,有太多的内部实现细节等待挖掘。有时候我对一些模块的内部机理感到好奇,但是困于自己难以对源码进行解析,因此总是碰壁。

接下来,还需要在 GetLifetimeReplicatedProps 方法来添加需要Replicate的Attribute。之前在UPROPERTY中,我们使用 ReplicatedUsing 来告诉GAS该Attribute需要Replicate,然后这里的话应该是告诉GAS该如何Attribute。例如:

1
2
3
4
5
6
void UAuraAttributeSet::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);

DOREPLIFETIME_CONDITION_NOTIFY(UAuraAttributeSet, Health, COND_None, REPNOTIFY_Always);
}

这里告诉GAS系统,Health没有需要Replicate的条件,且需要一直Replicate(只要赋值就要复制,即便前后的值是相等的)。

这样我们就成功地配置了一个属性。可以构建游戏项目并运行,然后Play,按 "`" 键,打开控制台,输入 showdebug abilitysystem,然后回车,就可以看到我们配置的属性。用 "PageUp" 和 "PageDown" 可以切换配置了ASC的Actor。

Games202学习笔记

可编程渲染管线

先来说说什么是渲染管线。管线的英文是pipeline,其实正确的翻译应该是流水线,或者一系列流程。那么渲染管线就可以看作我可以输入一堆原始图形数据,经过这个流水线,能得到每个像素颜色的输出。

渲染管线大致分为两个部分:一是将3D信息转换为2D信息,二是将2D信息转换为一系列有颜色的像素。具体流程如下图:

所有的这些阶段在不同的图形API(例如本文涉及的OpenGL)上都是高度专门化的,并且它们十分容易并行执行,因此可以将各个阶段的程序搬到GPU上去运行,由这些程序操纵被绑到GPU显存上的顶点数据,完成渲染工作。

其中有几个阶段是用户可编程的(上图蓝色部分),例如最常用到的vertex shader(将3D坐标转换为另一种3D坐标)和fragment shader(计算某个像素的颜色)。我们可以向OpenGL注入我们自己写的shader,所使用的语言就是GLSL(Games202需用)。

crash course

NDC(Normalized Device Coordinates)

这个在Games101提到MVP变换中有提及,它的中文叫做标准化设备坐标,就是x,y,z都是在[-1,1]的空间,对应的就是MVP中的Projection。

前面提到,Vertex shader的作用是将3D坐标装换成另外的3D坐标,因此可以推测出,MVP变换通常是在Vertex shader中进行的。

VBO(Vertex Buffer Objects)

顶点缓冲对象,听上去是拿来存储顶点信息的,事实也正是如此,只不过后面加了个对象的概念。

对于OpenGL中的对象,它们都对应着一个ID。对于VBO,可以使用 glGenBuffers 来生成并获得对应ID:

1
2
unsigned int VBO;
glGenBuffers(1, &VBO);

关于OpenGL的API详细文档,推荐查阅 docs.gl

既然VBO内部其实是Buffer,那么就会有很多其它的Buffer Object,因此需要分个类,俗称缓冲类型。VBO的缓冲类型是GL_ARRAY_BUFFER(没毛病,其实就是数组)。

OpenGL实质上是一个状态机,因此在渲染流程中,要渲染的VBO应该属于当前的OpenGL上下文,即被绑定。要绑定某个VBO,可以:

1
glBindBuffer(GL_ARRAY_BUFFER, VBO);  

绑定完成后,我们对于任何VBO(GL_ARRAY_BUFFER)的修改,都会影响到这个被绑定的VBO。

说了半天,我们VBO内部是要有顶点的,那么数据在哪呢?我们可以自己定义一个float数组:

1
2
3
4
5
float vertices[] = {
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
0.0f, 0.5f, 0.0f
};

这里有三个顶点,顶点属性尚且只有坐标(其实可以加上颜色、法向等)。可以观察到所有坐标都是在NDC范围内的。

好了,现在我们有了直接在cpp里hard code的顶点属性,我们需要和当前绑定的VBO(并不是指定VBO哦,记住opengl的逻辑是一个状态机):

1
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

这里的最后一个参数涉及到API实现方的优化,具体见 docs.gl

Vertex shader

这里的Shader language是GLSL,对于当前的Vertex shader,我们并不需要给它进行什么复杂的变换(例如MVP变换),因此直接返回对应的齐次坐标即可:

1
2
3
4
5
6
7
#version 330 core
layout (location = 0) in vec3 aPos;

void main()
{
gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
}

第一行是版本说明,也明确表示我们使用核心模式。第二行指定了输入数据。main 函数内则指定了输出位置 gl_Position

接下来我们要在openGL创建Vertex shader(注意没有绑定,后面会提到,绑定的是整个program):

1
2
3
4
unsigned int vertexShader;
vertexShader = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
glCompileShader(vertexShader);

Fragment shader

和Vertex shader相似:

1
2
3
4
5
6
7
#version 330 core
out vec4 FragColor;

void main()
{
FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);
}

Fragment shader只需要一个输出变量RGBA,我们可以在开头声明。

链接着色器程序

创建一个program对象,然后将shader链接进去即可,最后如果不需要的话可以删掉前面创建的shader,我们只需要program:

1
2
3
4
5
6
7
8
9
10
unsigned int shaderProgram;
shaderProgram = glCreateProgram();
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);

glUseProgram(shaderProgram);

glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);

解释顶点属性分布

我们前面用 glBufferData 绑定了VBO,现在需要解释顶点属性分布,这样shader才知道输入数据。

1
2
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);

这里第一个参数对应着Vertex shader的输入变量 layout (location = 0) in vec3 aPos;,第二个参数是顶点属性的大小,第三个参数是类型,第四个参数表示是否希望数据被标准化,第五个参数是步长,即两个顶点属性之间的间隔,第六个参数是偏移量。

glEnableVertexAttribArray 表示启用顶点属性,这里0表示第一个顶点属性,即 layout (location = 0) in vec3 aPos;

至此其实已经具备了渲染一个简单三角形的能力,代码会长的像这样:

1
2
3
4
5
6
7
8
9
10
// 0. 复制顶点数组到缓冲中供OpenGL使用
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 1. 设置顶点属性指针
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
// 2. 当我们渲染一个物体时要使用着色器程序
glUseProgram(shaderProgram);
// 3. 绘制物体
someOpenGLFunctionThatDrawsOurTriangle();

VAO (Vertex Array Object)

VAO会记录以下顶点属性调用: 1、glEnableVertexAttribArray和glDisableVertexAttribArray的调用 2、通过glVertexAttribPointer设置的顶点属性配置 3、通过glVertexAttribPointer调用与顶点属性关联的顶点缓冲对象VBO

创建一个VAO:

1
2
unsigned int VAO;
glGenVertexArrays(1, &VAO);

因此,在绘制一个物体的时候,我们可以首先绑定一个VAO,然后绑定配置VBO及其属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ..:: 初始化代码(只运行一次 (除非你的物体频繁改变)) :: ..
// 1. 绑定VAO
glBindVertexArray(VAO);
// 2. 把顶点数组复制到缓冲中供OpenGL使用
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 3. 设置顶点属性指针
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);

[...]

// ..:: 绘制代码(渲染循环中) :: ..
// 4. 绘制物体
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
someOpenGLFunctionThatDrawsOurTriangle();

IBO (Index Buffer Object)

IBO是用来存储顶点索引的,可以减少VBO的内存占用。在绑定VAO的时候,我们可以同时绑定IBO,这样在绘制的时候,OpenGL会根据IBO的索引去VBO中找到对应的顶点属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
#include <glad/glad.h>
#include <GLFW/glfw3.h>

#include <iostream>

void framebuffer_size_callback(GLFWwindow* window, int width, int height);
void processInput(GLFWwindow* window);

// settings
const unsigned int SCR_WIDTH = 800;
const unsigned int SCR_HEIGHT = 600;

const char* vertexShaderSource = "#version 330 core\n"
"layout (location = 0) in vec3 aPos;\n"
"void main()\n"
"{\n"
" gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);\n"
"}\0";
const char* fragmentShaderSource = "#version 330 core\n"
"out vec4 FragColor;\n"
"void main()\n"
"{\n"
" FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);\n"
"}\n\0";

float vertices[] = {
0.5f, 0.5f, 0.0f, // top right
0.5f, -0.5f, 0.0f, // bottom right
-0.5f, -0.5f, 0.0f, // bottom left
-0.5f, 0.5f, 0.0f // top left
};

unsigned int indices[] = {
0, 1, 3,
1, 2, 3
};

int main()
{
// glfw: initialize and configure
// ------------------------------
glfwInit();
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);

#ifdef __APPLE__
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);
#endif

// glfw window creation
// --------------------
GLFWwindow* window = glfwCreateWindow(SCR_WIDTH, SCR_HEIGHT, "LearnOpenGL", NULL, NULL);
if (window == NULL)
{
std::cout << "Failed to create GLFW window" << std::endl;
glfwTerminate();
return -1;
}
glfwMakeContextCurrent(window);
glfwSetFramebufferSizeCallback(window, framebuffer_size_callback);

// glad: load all OpenGL function pointers
// ---------------------------------------
if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress))
{
std::cout << "Failed to initialize GLAD" << std::endl;
return -1;
}

unsigned int VAO, VBO, EBO;
glGenVertexArrays(1, &VAO);
glBindVertexArray(VAO);

glGenBuffers(1, &VBO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, false, sizeof(float) * 3, (void*)0);
glEnableVertexAttribArray(0);

glGenBuffers(1, &EBO);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

glBindBuffer(GL_ARRAY_BUFFER, 0);
// 注意不能在绑定VAO的情况下解绑EBO!EBO是存储在VAO里的!
glBindVertexArray(0);

unsigned int VertexShader = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(VertexShader, 1, &vertexShaderSource, NULL);
glCompileShader(VertexShader);
// check for shader compile errors
int success;
char infoLog[512];
glGetShaderiv(VertexShader, GL_COMPILE_STATUS, &success);
if (!success)
{
glGetShaderInfoLog(VertexShader, 512, NULL, infoLog);
std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" << infoLog << std::endl;
}
unsigned int FragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(FragmentShader, 1, &fragmentShaderSource, NULL);
glCompileShader(FragmentShader);
// check for shader compile errors
glGetShaderiv(FragmentShader, GL_COMPILE_STATUS, &success);
if (!success)
{
glGetShaderInfoLog(FragmentShader, 512, NULL, infoLog);
std::cout << "ERROR::SHADER::FRAGMENT::COMPILATION_FAILED\n" << infoLog << std::endl;
}

unsigned int Program = glCreateProgram();
glAttachShader(Program, VertexShader);
glAttachShader(Program, FragmentShader);
glLinkProgram(Program);
// check for linking errors
glGetProgramiv(Program, GL_LINK_STATUS, &success);
if (!success) {
glGetProgramInfoLog(Program, 512, NULL, infoLog);
std::cout << "ERROR::SHADER::PROGRAM::LINKING_FAILED\n" << infoLog << std::endl;
}
glDeleteShader(FragmentShader);
glDeleteShader(VertexShader);

// render loop
// -----------
while (!glfwWindowShouldClose(window))
{
// input
// -----
processInput(window);

// render
// ------
glBindVertexArray(VAO);
glUseProgram(Program);

glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);

// glfw: swap buffers and poll IO events (keys pressed/released, mouse moved etc.)
// -------------------------------------------------------------------------------
glfwSwapBuffers(window);
glfwPollEvents();
}

glDeleteVertexArrays(1, &VAO);
glDeleteBuffers(1, &VBO);
glDeleteBuffers(1, &EBO);

glDeleteProgram(Program);

// glfw: terminate, clearing all previously allocated GLFW resources.
// ------------------------------------------------------------------
glfwTerminate();
return 0;
}

// process all input: query GLFW whether relevant keys are pressed/released this frame and react accordingly
// ---------------------------------------------------------------------------------------------------------
void processInput(GLFWwindow* window)
{
if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS)
glfwSetWindowShouldClose(window, true);
}

// glfw: whenever the window size changed (by OS or user resize) this callback function executes
// ---------------------------------------------------------------------------------------------
void framebuffer_size_callback(GLFWwindow* window, int width, int height)
{
// make sure the viewport matches the new window dimensions; note that width and
// height will be significantly larger than specified on retina displays.
glViewport(0, 0, width, height);
}

着色器GLSL

着色器英文叫做shader,是运行在GPU上的小程序,不同类型的shader分别为图形管线中的不同阶段提供不同的功能。对于OpenGL,我们采用GLSL语言来编写shader。

输入输出

GLSL定义 inout 关键字来表示输入和输出,对于每个shader(vertex shader和fragment shader),我们都需要定义输入和输出。上个阶段的shader的输出就是下一个阶段的输入。

vertex shader有特定的输入,它前面采用 layout (location = 0) in vec3 aPos; 这种类型的方式,这个其实对应的是CPU端 glVertexAttribPointer 的第一个参数。因此layout关键字能够实现CPU和GPU端的数据传递。

另一种数据传输的方式是 uniform ,它不同于用 inout 关键字定义的变量,uniform 是全局的,它不能被shader修改,而是由CPU来修改。

绑定Program后,我们可以使用 glGetUniformLocation 来获取 uniform 的地址,然后使用 glUniform 系列函数来修改它的值。

可以采用OOP的方式将shader封装进一个类。

本来打算今天拆笔记本的,结果没动力,啥都不想干。

前几天被舍友叫去打CS2,然后正在等人的时候,突然steam上一个不认识但是是好友的人问我能不能一起打。

我说可以,但我都忘记我什么时候加的这个人的了。后面他开语音,我才记得是高一的舍友。

他问我是不是保去复旦了,我说我爱南航。他是厦大的,我自然不如他,当然打游戏也不如他。他5e1900分,我1000分。

没什么脸,哪怕是去回忆高中的那些同学。除了我全是985本科,我还搁哪苦恼能不能保研成功呢,他们已经有工作了。

小学同学肯定是都忘了,估计也死了几个。初中同学当然也忘的差不多了。

大学,呵呵。

回望大一的时候,特别喜欢玩Unity,特别喜欢算法竞赛。

现在已经没什么欲望了。

要我说,在南航的四年,不至于生不如死,因为我还不希望我出生呢。

这就是个垃圾学校,经费全喂狗的学校,或者拿去充榜单排名,然后天天碰瓷985的出生学校。

以前的我满怀期待地去做大创,满怀期待地加入ACM校队,满怀期待地上机器学习课。

我不知道我在期待什么,明知道自己的水平肯定不只有南航的水平,但是就是想要得到指导,想要从学校学点知识。神经病啊。想在南航学习?

自从搬到天目湖后,南航的ACM就是个狗屎。但我还比较贱,即便身边根本没人打CF,我还搁哪训。

结果训emo了。不是因为我水平低,而是因为我发现自己复健后的水平其实比拿银牌的时候还要高。

发现很有潜力?呵呵,南航直接毁了你的潜力。

我的水平在这里一直都是独一档,断档压制一般的oier和其他人,但是比不过中学学过很多年的有NOI牌子的人。

他们固然是成功的,其他人固然是失败的,我是莫名其妙的死的。

我没有报过任何网课,我全靠看博客起来的。不过管你什么样,只要你初始没有省队的水平,再怎么努力都是失败,因为这里是南航。

这里是能偷改你学分,让你大四下还得上课的学校。

这里是能毁掉你的技术热情,让你被病态的内卷环境压力到变形的学校。

南航的同学,大部分也是傻逼。天天搁哪看AI论文,搁哪训神经网络,甚至在ACM实验室都没人刷题,一个个在哪看paper。懂点技术的又菜又爱装,眼高手低。

哦,我才是傻逼。我是AI的专业第一,但是天天逃课,骂学校骂老师骂同学,现在天天在那整游戏开发,大作业都让别人做。

我才是有错的人,身边的同学都太厉害拉,我是害虫。

南航太好了,是个好学校,如果你想死,建议你报考。


好了,正常点。我最近情绪又变得比较低迷,虽然从南航回到家里很开心,但是我发现我找不到动力了。明明大一的时候我能摸早起来启动Unity或者启动洛谷直接撸码,而现在做什么都没精神。原因只能是,我的本科经历,真的痛不欲生。

我懒吗?我学习能力差吗?

还是那句话,我痛苦的原因,不是我做错了什么,而是我什么都没做错,但是下场跟什么都做错了一样。

毕业了就和所有南航的人永别吧,这会让我更快乐,不要再想和南航有关的事了,肯定和解不了。好在现在认识的朋友都是校外的网友,互联网当e人准没错。

离南航和南航的人远点。

离南航和南航的人远点。

离南航和南航的人远点。

0%