Android 音视频屏幕旋转方案

正文

在Android平台上,实现音视频播放过程中的屏幕旋转涉及到以下几个方面:界面布局的调整、Activity的生命周期管理、保存和恢复播放状态、以及处理屏幕旋转时可能引发的音视频播放问题。以下是一个基本的音视频屏幕旋转方案的步骤:

布局调整

res文件夹中创建不同方向的布局文件,例如layout/activity_main.xmllayout-land/activity_main.xml分别表示竖屏和横屏时的布局。在这些布局文件中,你可以调整控件的摆放位置以适应不同方向的屏幕。

Activity的生命周期管理

当屏幕发生旋转时,Activity会经历重新创建的过程。确保你的Activity正确处理生命周期事件,特别是onCreateonSaveInstanceStateonRestoreInstanceState等方法。

保存和恢复播放状态

onSaveInstanceState方法中保存当前的播放状态,例如播放位置、播放状态(播放、暂停等),以及其他相关信息。然后在onCreateonRestoreInstanceState方法中恢复这些状态。

处理屏幕旋转可能引发的问题

屏幕旋转可能会导致播放器重新创建或重置,从而影响播放状态。你需要在Activity重新创建时恢复播放状态,并确保播放器不会中断或重置播放进度。

使用ViewModel来保存播放状态(可选)

使用ViewModel可以更方便地在屏幕旋转时保持数据。ViewModel会在Activity重新创建时保持其状态,因此你可以将播放状态等数据存储在ViewModel中,以确保不会丢失。

锁定屏幕方向(可选)

如果你希望在播放音视频时固定屏幕方向,可以在AndroidManifest.xml中的相应Activity中使用android:screenOrientation属性来指定所需的屏幕方向(如portraitlandscape)。

总结

总之,处理音视频播放过程中的屏幕旋转需要综合考虑界面布局、生命周期管理、数据保存和恢复等方面的问题。合理使用布局文件、Activity生命周期方法、ViewModel等工具,可以帮助你实现稳定且用户友好的音视频播放体验。

Android MVI GPT4 快问快答

MVI 层级架构

  • App 用于依赖注入 —— 可以访问所有层
  • Data 负责内部和外部存储 —— 可以访问 Domain
  • Domain 负责仓库和用例 —— 不访问任何层
  • Presentation 负责UI逻辑 —— 可以访问 Domain

GPT 4 块问快答:究竟什么是 MVI

为什么 Data 和 Presentation 可以访问领域层,举例子怎么访问的

在一个典型的分层架构中,每一层都有其独特的职责,而且这种架构通常允许某些层访问其他特定的层。在你提到的结构中,AppDataDomainPresentation层各自担负不同的角色,其中DataPresentation层被允许访问Domain层,主要是因为Domain层包含了应用程序的核心业务逻辑和规则,是其他层依赖的基础。

Data 层访问 Domain 层

  • 职责Data层负责处理数据的存取,无论是来自内部存储(如SQLite数据库、内存缓存等)还是外部存储(如网络API、远程数据库等)。
  • 访问方式Data层访问Domain层通常是通过实现Domain层定义的仓库接口(Repository Interfaces)。这些仓库接口定义了应用程序需要的数据操作(如获取用户数据、保存订单等),而Data层实现这些接口,具体处理数据的存取逻辑。

示例
假设在Domain层有一个UserRepository接口,它定义了获取用户信息的方法。Data层将实现这个接口,通过网络API或本地数据库获取用户信息。

Presentation 层访问 Domain 层

  • 职责Presentation层负责处理所有与用户界面(UI)相关的逻辑,如显示数据、响应用户操作等。
  • 访问方式Presentation层通过使用Domain层中定义的用例(Use Cases)或服务(Services)来获取所需的数据或执行特定的业务逻辑。用例封装了特定的业务逻辑操作,为Presentation层提供了清晰、简洁的API。

示例
如果有一个显示用户个人信息的界面,Presentation层的一个ViewModel或Presenter可能会调用一个GetUserProfile用例(从Domain层获取),该用例封装了获取用户个人信息的业务逻辑。

总结

通过允许DataPresentation层访问Domain层,可以确保应用程序的核心业务逻辑集中管理,并且易于测试和维护。同时,这种结构还有助于解耦,使得数据源的更改或UI的修改不会直接影响到业务逻辑的实现,从而提高了应用程序的灵活性和可维护性。

给出 GetUserProfile 代码例子

为了提供一个GetUserProfile的示例,我们将遵循典型的分层架构中的DomainDataPresentation层的交互方式。这个例子将使用Kotlin语言,因为它是在Android开发中常用的语言,同时也适用于Kotlin多平台移动(KMM)项目。

Domain 层

Domain层,我们定义一个用例(Use Case)GetUserProfile,它是业务逻辑的一部分。这个用例会从仓库(Repository)中获取用户的个人信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Domain层 - 定义用例
class GetUserProfile(private val userRepository: UserRepository) {
suspend fun execute(userId: String): UserProfile {
return userRepository.getUserProfile(userId)
}
}

// Domain层 - 定义仓库接口
interface UserRepository {
suspend fun getUserProfile(userId: String): UserProfile
}

// 用户个人信息的数据模型
data class UserProfile(val userId: String, val name: String, val email: String)

Data 层

Data层,我们实现了Domain层定义的UserRepository接口,提供了获取用户个人信息的实际逻辑。

1
2
3
4
5
6
7
// Data层 - 仓库实现
class UserRepositoryImpl : UserRepository {
override suspend fun getUserProfile(userId: String): UserProfile {
// 假设这里是从网络或本地数据库获取数据
return UserProfile(userId, "John Doe", "johndoe@example.com")
}
}

Presentation 层

Presentation层,我们使用GetUserProfile用例来获取用户个人信息,并展示到UI上。这里以一个简单的ViewModel为例。

1
2
3
4
5
6
7
8
9
10
11
12
// Presentation层 - ViewModel
class UserProfileViewModel(private val getUserProfile: GetUserProfile) : ViewModel() {
// LiveData用于观察数据变化,更新UI
private val _userProfile = MutableLiveData<UserProfile>()
val userProfile: LiveData<UserProfile> = _userProfile

fun loadUserProfile(userId: String) {
viewModelScope.launch {
_userProfile.value = getUserProfile.execute(userId)
}
}
}

小结

这个例子演示了在一个分层架构中,如何通过Domain层的用例来协调Data层和Presentation层的交互。Domain层定义了业务逻辑和接口,Data层实现了数据获取的逻辑,而Presentation层负责调用这些逻辑并更新UI。这种分层的方式有助于保持代码的清晰和可维护性。

描述一下,数据在几个层之间的传递,从起始到结束

数据在分层架构中的传递通常遵循一个定义良好的流程,确保应用的各个组成部分职责明确、解耦合,同时促进模块间的协作。以下是一个典型的从起始到结束的数据传递流程,以一个简化的用户个人信息获取功能为例,该功能遵循PresentationDomainData层的架构模式:

1. 用户界面触发

  • 起始点:用户在应用的界面上执行一个动作(比如点击一个按钮),请求查看个人信息。
  • 处理:这个动作被Presentation层的一个组件(例如,一个ViewModel或Activity)捕获。

2. Presentation 层处理

  • Presentation层的组件调用Domain层定义的用例(Use Case),比如GetUserProfile,来获取用户的个人信息。这个调用通常是异步的,以避免阻塞UI线程。
  • ViewModel通过用例接口,向Domain层表达获取数据的需求。

3. Domain 层中转

  • Domain层的GetUserProfile用例接收到请求,并将其转发给相应的UserRepository接口。此时,GetUserProfile仅知道需要调用仓库接口来获取数据,但不关心数据的具体来源。
  • Domain层起到了定义业务规则和操作流程的角色,但本身不处理数据的存取。

4. Data 层响应

  • Data层中,具体实现了UserRepository接口的类(如UserRepositoryImpl)负责处理数据的实际获取操作。这可能涉及访问网络API、查询本地数据库或读取缓存等操作。
  • 一旦数据被成功获取,它会被封装成一个模型(如UserProfile),并返回给调用者——在这个例子中是Domain层的GetUserProfile用例。

5. 数据回流到Presentation层

  • GetUserProfile用例收到Data层提供的用户信息后,将这些信息返回给最初请求它的Presentation层的组件。
  • Presentation层的ViewModel接收到这些数据后,更新UI绑定的数据模型,通常是通过LiveData或类似机制。

6. UI更新显示数据

  • 最后,随着ViewModel中LiveData(或其他观察者模式实现)的更新,UI监听这些变化,并据此刷新显示,向用户展示最新获取的用户个人信息。

总结

这个过程展示了数据从用户触发获取请求,通过应用的各层传递,并最终显示回用户界面的完整流程。每一层都有其特定的职责:Presentation层管理UI逻辑和用户交互,Domain层定义业务规则和数据获取的抽象接口,而Data层负责具体的数据存取逻辑。这种分层架构不仅有助于保持代码的组织性和可维护性,还促进了各模块间的清晰分离和互操作性。

解释 Domain 层如何定义业务规则

Domain层在分层架构中扮演着核心角色,它定义了应用程序的业务规则和逻辑。这一层是独立于用户界面(UI)和数据存储机制的,意味着它不应该依赖于任何特定的框架或库,以便于业务逻辑能够在不同的环境中重用和测试。Domain层的设计主要关注于应用程序的“什么”和“为什么”,而不是“怎么做”。

业务规则的定义

业务规则是指那些定义企业如何运作、决策应如何做出、业务流程应如何管理的规则。在软件开发中,这些规则转化为可以执行的代码,用于指导应用程序的行为。业务规则包括但不限于:

  • 实体(Entities):代表业务概念的对象,如用户、订单等,以及这些对象的属性和行为。
  • 用例(Use Cases)或服务(Services):封装了应用程序的核心功能,每个用例代表一个或多个业务操作,如“获取用户信息”、“创建订单”等。
  • 值对象(Value Objects):描述了业务中的值和它们的属性,但没有唯一标识符(与实体不同)。
  • 仓库接口(Repository Interfaces):定义了如何访问外部数据源(如数据库或远程服务)的抽象接口,但不实现具体的数据访问逻辑。

Domain层的职责

  • 封装业务逻辑Domain层包含应用程序的业务逻辑,确保业务操作的正确性、有效性和连贯性。
  • 定义业务模型:通过实体和值对象定义业务领域内的数据模型和它们之间的关系。
  • 业务决策的实现:实现业务决策逻辑,如验证规则、计算和数据转换等。
  • 保持独立性Domain层的设计应确保它不依赖于特定的UI实现或数据存储策略,使得业务逻辑可以在不同的应用场景下重用。

举例

假设有一个在线商店应用,其Domain层可能包含如下定义:

  • 实体Product(产品)、Customer(客户)、Order(订单)。
  • 用例PlaceOrder(下订单)、CancelOrder(取消订单)、UpdateCustomerProfile(更新客户资料)。
  • 值对象Address(地址)、Money(货币)。
  • 仓库接口OrderRepository(提供对订单数据的访问)、CustomerRepository(提供对客户数据的访问)。

在这个例子中,Domain层定义了在线商店运作的基本规则和逻辑,如何处理订单、如何管理客户信息,以及如何执行业务决策,所有这些都与特定的UI实现或数据持久化机制无关。

总结,Domain层的设计是为了确保应用程序的核心业务逻辑得到清晰定义、良好封装,从而易于维护和测试,同时提高了代码的可重用性。

解释 Domain 层定义了基本规则和逻辑,与特定的 UI 实现或数据持久化机制无关

Domain层在软件架构中的作用是封装和实现应用程序的核心业务逻辑,独立于用户界面(UI)和数据持久化(数据库等)的具体实现。这种设计方法使得业务逻辑更加模块化、清晰,并且易于测试和维护。在在线商店的例子中,Domain层定义的业务规则和逻辑涉及以下几个关键方面:

如何处理订单

Domain层中,处理订单的逻辑包括订单的创建、验证、支付、取消等步骤。这些操作反映了订单生命周期中的各个阶段,每个阶段都基于业务规则来执行。例如,订单支付前需要验证订单详情和客户支付信息的有效性;订单一旦支付成功,就不能随意取消,除非满足特定的退款条件。

如何管理客户信息

客户信息管理包括客户注册、信息更新、历史订单查询等功能。Domain层通过定义客户实体(Customer)和相关的业务操作(如UpdateCustomerProfile)来实现这一点。这些操作确保了客户信息的一致性和安全性,例如,客户密码需要加密存储,客户信息更新需要验证客户的身份。

如何执行业务决策

业务决策涉及到基于特定条件执行的逻辑选择,比如根据库存量决定是否接受订单、根据客户购买历史提供个性化推荐等。这些决策通常通过Domain层中的业务规则来实现,业务规则可能包括各种验证逻辑、计算公式和决策树。

与UI和数据持久化机制的独立性

Domain层的设计目的是保持对UI层和数据持久化层的独立性。这意味着,不管UI如何变化(无论是移动应用、Web页面还是桌面应用),Domain层的业务逻辑不需要做出改变。同样,数据存储的细节(是使用SQL数据库、NoSQL数据库还是云服务)对Domain层也是透明的。这一层只通过抽象的接口(如Repository接口)与数据交互,具体的实现细节由Data层负责。

通过将业务逻辑集中在Domain层,应用程序的核心功能和规则得到了清晰的定义和封装,这不仅有助于减少不同层之间的耦合,还使得业务逻辑更容易理解、测试和维护。此外,这种架构设计还增加了应用程序的灵活性,使得未来对应用程序的扩展和修改变得更加容易。

抽出 domain 层的意义是什么

抽出Domain层在软件架构中的意义主要体现在以下几个方面:

1. 业务逻辑集中化

将业务逻辑集中在Domain层,有助于明确应用程序的核心功能和规则,使得这些逻辑独立于其他层(如表示层和数据持久化层)的实现。这种集中化有助于保持业务规则的一致性,确保业务操作遵循同一套逻辑,无论是通过何种途径执行。

2. 提高可维护性

通过将业务逻辑与数据访问代码(Data层)和用户界面代码(Presentation层)分离,开发人员可以更容易地理解、修改和扩展业务规则。当业务需求发生变化时,通常只需要修改Domain层的代码,而无需触及到其他层的实现,这大大降低了维护成本和复杂性。

3. 增强可测试性

独立的Domain层使得业务逻辑的单元测试变得更加直接和简单。因为业务逻辑与外部的界面显示和数据存取逻辑解耦,测试可以集中在业务规则的正确性上,而不必担心UI或数据库的具体实现。这有助于提高测试覆盖率和发现潜在的业务逻辑错误。

4. 促进技术栈灵活性

由于Domain层独立于具体的技术实现,因此可以在不影响业务逻辑的前提下更换表示层框架或数据持久化技术。例如,可以从一个数据库系统迁移到另一个,或者在不同的前端技术(如Web、移动应用)之间共享相同的业务逻辑。

5. 便于多团队协作

在大型项目中,不同的团队可以负责不同的应用层,例如一个团队专注于Domain层的业务逻辑开发,而另一个团队专注于用户界面的实现。这种分工有助于提高开发效率,因为团队可以并行工作,而且减少了彼此工作的直接依赖。

6. 支持多种应用场景

Domain层的业务逻辑可以被不同的应用场景重用,比如同一个业务逻辑既能服务于Web应用,也能服务于移动应用或桌面应用。这种重用性可以显著降低开发和维护新平台应用的成本和时间。

总之,抽出Domain层是实现软件架构清晰分层、提高代码质量和维护性的重要策略,它有助于构建更加健壯、灵活和可持续发展的软件系统。

这样做为什么可以只需要修改Domain层的代码,而无需触及到其他层的实现

当业务需求发生变化时,通常只需要修改Domain层的代码而无需触及到其他层的实现,这个原理主要是基于软件设计中的分层架构和关注点分离的原则。让我们来详细解释一下为什么这样做是可行的:

分层架构

在一个典型的分层架构中,软件被划分为几个层次,每个层次负责处理不同的任务。这些层次通常包括:

  • 表示层(Presentation Layer):负责用户界面和用户交云的处理。
  • 领域层(Domain Layer):包含业务逻辑和业务规则。
  • 数据访问层(Data Access Layer):负责数据持久化和数据检索。

关注点分离

分层架构实现了关注点分离(Separation of Concerns, SoC)的原则,即不同的层次关注不同的方面。通过这种方式,每一层都可以独立于其他层变化,只要它们之间的接口保持不变。

对于业务逻辑的变化

  • 业务逻辑变化通常发生在Domain层:因为Domain层封装了应用程序的核心业务规则和逻辑。当业务需求变化时,这些变化几乎总是直接影响到业务规则和逻辑,而这些都是Domain层的职责范畴。
  • 表示层和数据访问层的独立性:由于表示层主要负责界面显示和用户交互,数据访问层主要负责数据的持久化和检索,这两层的工作与业务逻辑的具体内容相对独立。它们通过定义良好的接口与Domain层交互,只要这些接口不变,业务逻辑的变更就不会直接影响到它们。
  • 接口作为契约Domain层通过接口与其他层进行交互。当业务需求变化导致Domain层的逻辑变化时,只要接口保持不变,表示层和数据访问层就无需变化。即使接口需要变化,现代编程语言和框架提供的抽象和封装机制也能最小化这种变化的影响。

实例

假设一个电子商务应用,其中有一个计算订单总价的业务逻辑。如果业务规定更改了订单的折扣计算方式,这个变化直接关系到订单总价的计算规则,这是Domain层的一部分。只需修改Domain层中计算总价的代码即可。只要Domain层对外提供的计算总价的接口不变,表示层(可能显示总价的界面)和数据访问层(可能存储订单详情的数据库操作)就不需要任何改动。

结论

因此,将业务逻辑封装在Domain层,确保了应用程序在面对业务需求变化时,可以局部化修改影响,减少了需要修改的代码量,从而降低了维护成本和复杂性。这种设计使得软件系统更加灵活和可维护,更容易适应和响应业务变化。

给出代码例子

让我们通过一个简化的代码示例来说明当业务需求发生变化时,通常只需要修改Domain层的代码而无需触及到其他层的实现。考虑一个电子商务应用,其中包含一个功能:计算订单总价。我们将展示Domain层、Data层和Presentation层的简化代码,并展示当业务规则(例如,订单折扣规则)变化时,如何只修改Domain层代码来应对这一变化。

初始实现

Domain层

1
2
3
4
5
6
7
8
9
10
11
12
13
// Domain层 - 定义业务逻辑接口
interface OrderService {
fun calculateTotalPrice(items: List<OrderItem>): Double
}

// Domain层 - 实现
class OrderServiceImpl : OrderService {
override fun calculateTotalPrice(items: List<OrderItem>): Double {
return items.sumOf { it.price * it.quantity }
}
}

data class OrderItem(val price: Double, val quantity: Int)

Data层

假设数据层处理订单数据的存储和检索,但与计算总价无关,因此在此示例中略过。

Presentation层

1
2
3
4
5
6
7
// Presentation层 - 使用Domain层的服务展示总价
class OrderViewModel(private val orderService: OrderService) {
fun displayTotalPrice(items: List<OrderItem>) {
val totalPrice = orderService.calculateTotalPrice(items)
println("Total Price: $totalPrice")
}
}

业务需求变化

假设业务需求变化,现在需要对订单总价应用10%的折扣。

修改Domain层

我们只需要在Domain层中修改OrderServiceImpl的实现来反映这一变化:

1
2
3
4
5
6
7
8
// Domain层 - 修改后的实现
class OrderServiceImpl : OrderService {
override fun calculateTotalPrice(items: List<OrderItem>): Double {
val originalTotal = items.sumOf { it.price * it.quantity }
val discount = originalTotal * 0.1 // 应用10%的折扣
return originalTotal - discount
}
}

在这个修改中,我们没有触及Presentation层或Data层的代码。Presentation层仍然以相同的方式调用Domain层的calculateTotalPrice方法,而Data层(假设它负责数据的持久化)也不需要变化,因为它与订单价格的计算逻辑无关。

结论

通过这个简化的例子,我们可以看到,当业务规则变化(如订单折扣规则)时,只需修改Domain层中的业务逻辑实现。Presentation层和Data层不需要修改,因为它们与Domain层通过抽象的接口进行交互,这个接口(在本例中是OrderService.calculateTotalPrice)保持不变。这种设计使得应用程序能够灵活应对业务需求的变化,同时最小化了修改的影响范围,提高了代码的可维护性。

Android 音视频屏幕旋转方案

Q:我应该去哪里获取不同版本的存储策略

A:要获取不同版本的 Android 存储策略(Storage Policies),您可以访问以下资源,这些资源提供了详细的版本特定存储策略和变更的信息:

官方 Android 文档

Android 官方文档是了解不同版本存储策略最可靠的来源。以下是一些重要的链接:

  1. **Android Developers Storage Documentation**:
    这是 Android 官方的存储文档,涵盖了各种存储选项和最佳实践。

  2. **Android Platform Releases**:
    这个页面提供了每个 Android 版本的详细变更,包括存储策略的变更。

  3. **Scoped Storage**:
    针对 Android 10(API Level 29)及更高版本的分区存储(Scoped Storage),是存储策略的重要变更点。

官方博客和公告

Android 官方博客和公告也是了解存储策略变更的好资源:

  1. **Android Developers Blog**:
    在这个博客上,Google 发布了关于新功能和存储策略变更的详细信息和指导。

GitHub 和社区资源

开源社区和开发者论坛也提供了大量关于存储策略的信息:

  1. **Google Samples on GitHub**:
    Google 提供的示例项目,可以查看不同版本的存储策略实现。

  2. **Stack Overflow**:
    搜索关于 Android 存储策略的问题和答案,可以获取社区的解决方案和建议。

具体版本的存储策略变更

要了解具体版本的存储策略变更,可以参考以下步骤:

  1. 访问 Android 开发者文档中的版本变更日志
    每个 Android 版本的变更日志会详细列出该版本的新特性和变更,包括存储策略。

  2. 查找特定版本的存储策略文档
    在 Android Developers 网站上,您可以找到特定版本的 API 参考和变更日志。

直接搜索具体问题

如果您有特定的存储策略问题,可以直接在 Google 搜索:

  • 例如:Android 10 Scoped Storage
  • 例如:Android 11 Storage Policy Changes

通过这些资源,您应该能够找到详细的 Android 存储策略信息,并理解每个版本的特定变更。

Android 6 以下的文件存储和读取策略

在Android 6.0(API Level 23)及以下版本中,文件存储和读取的策略主要涉及以下几个方面:

1. 存储位置

Android系统提供了两种主要的存储位置:内部存储和外部存储。

内部存储

  • 内部存储是应用的私有存储区域,只有该应用可以访问。
  • 存储在内部存储中的文件默认在应用卸载时会被删除。
  • 使用Context对象的方法可以在内部存储中读写文件,例如:
    • openFileOutput(String name, int mode):写文件
    • openFileInput(String name):读文件

外部存储

  • 外部存储可以是设备的外部存储卡或者内置存储分区。
  • 分为公共外部存储和应用私有外部存储。
  • 公共外部存储可以被所有应用访问,例如图片、音频文件等。
  • 应用私有外部存储只能被创建该文件的应用访问,在应用卸载时会被删除。
  • 使用Environment类和Context对象的方法可以在外部存储中读写文件,例如:
    • getExternalFilesDir(String type):获取应用私有外部存储目录
    • Environment.getExternalStoragePublicDirectory(String type):获取公共外部存储目录

2. 权限管理

在Android 6.0及以下版本中,应用需要在AndroidManifest.xml中声明对外部存储的读写权限:

  • 读权限:<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
  • 写权限:<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>

这些权限在应用安装时由用户批准。

3. 文件操作代码示例

内部存储

写文件:

1
2
3
4
5
String filename = "myfile.txt";
String fileContents = "Hello, world!";
FileOutputStream fos = openFileOutput(filename, Context.MODE_PRIVATE);
fos.write(fileContents.getBytes());
fos.close();

读文件:

1
2
3
4
5
6
7
8
9
10
FileInputStream fis = openFileInput(filename);
InputStreamReader isr = new InputStreamReader(fis);
BufferedReader bufferedReader = new BufferedReader(isr);
StringBuilder sb = new StringBuilder();
String line;
while ((line = bufferedReader.readLine()) != null) {
sb.append(line);
}
String fileContents = sb.toString();
fis.close();

外部存储

检查存储状态:

1
2
3
4
String state = Environment.getExternalStorageState();
if (Environment.MEDIA_MOUNTED.equals(state)) {
// 可以读写外部存储
}

写文件:

1
2
3
4
File file = new File(getExternalFilesDir(null), "myfile.txt");
FileOutputStream fos = new FileOutputStream(file);
fos.write(fileContents.getBytes());
fos.close();

读文件:

1
2
3
4
5
6
7
8
9
10
11
File file = new File(getExternalFilesDir(null), "myfile.txt");
FileInputStream fis = new FileInputStream(file);
InputStreamReader isr = new InputStreamReader(fis);
BufferedReader bufferedReader = new BufferedReader(isr);
StringBuilder sb = new StringBuilder();
String line;
while ((line = bufferedReader.readLine()) != null) {
sb.append(line);
}
String fileContents = sb.toString();
fis.close();

这些代码示例展示了在Android 6.0及以下版本中如何进行基本的文件读写操作。随着Android版本的升级,文件存储和权限管理策略也发生了显著变化,因此在开发过程中需要根据目标API版本进行适配。

Android 6 以上文件存储和读取策略

在Android 6.0(API Level 23)及以上版本中,文件存储和读取策略发生了一些变化,特别是在权限管理和存储位置的使用方面。以下是各版本的策略变化和相应的文件存储读写方法。

1. Android 6.0 (API Level 23)

动态权限

  • 从Android 6.0开始,权限管理采用动态权限模式。用户可以在运行时授予或拒绝权限。
  • 应用在使用外部存储时,需要在AndroidManifest.xml中声明权限,并在运行时请求权限。

权限请求代码示例:

1
2
3
4
if (ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, REQUEST_CODE);
}

2. Android 7.0 (API Level 24)

文件URI策略

  • 从Android 7.0开始,File URI不再被允许在应用之间共享。必须使用FileProvider来共享文件URI。

FileProvider配置示例:
AndroidManifest.xml中:

1
2
3
4
5
6
7
8
9
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>

res/xml/file_paths.xml中:

1
2
3
<paths>
<external-path name="external_files" path="." />
</paths>

3. Android 8.0 (API Level 26)

安全性增强

  • 在后台执行文件操作受到限制,必须在前台任务或通过服务进行文件操作。

4. Android 9.0 (API Level 28)

共享存储访问

  • 在Android 9.0中引入了新的API,用于访问共享存储,推荐使用ACTION_OPEN_DOCUMENTACTION_CREATE_DOCUMENT来访问和创建文件。

使用示例:

1
2
3
4
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*");
startActivityForResult(intent, REQUEST_CODE_OPEN_DOCUMENT);

5. Android 10 (API Level 29)

分区存储 (Scoped Storage)

  • 引入了分区存储,应用只能访问其私有的外部存储空间,除非使用特定的API访问公共存储。
  • 可以通过请求Legacy存储模式来继续使用传统的存储访问方式(直到Android 11)。

分区存储代码示例:

1
2
3
4
File file = new File(getExternalFilesDir(null), "myfile.txt");
FileOutputStream fos = new FileOutputStream(file);
fos.write(fileContents.getBytes());
fos.close();

6. Android 11 (API Level 30)

强制分区存储

  • 强制启用分区存储,应用无法再使用requestLegacyExternalStorage标志。
  • 提供了新的访问存储的API,如MediaStore

MediaStore使用示例:

1
2
3
4
5
6
7
8
9
10
ContentValues values = new ContentValues();
values.put(MediaStore.MediaColumns.DISPLAY_NAME, "myfile.txt");
values.put(MediaStore.MediaColumns.MIME_TYPE, "text/plain");
values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOCUMENTS);
Uri uri = getContentResolver().insert(MediaStore.Files.getContentUri("external"), values);
if (uri != null) {
OutputStream outputStream = getContentResolver().openOutputStream(uri);
outputStream.write(fileContents.getBytes());
outputStream.close();
}

7. Android 12 (API Level 31) 及更高版本

进一步限制外部存储访问

  • 在Android 12中,对特定类型的文件访问进行了更多的限制,需要使用特定的API来访问不同类型的文件,如音频、视频和图片等。

总结:

  • Android 6.0及以上版本在权限管理上采用动态权限,需要在运行时请求权限。
  • Android 7.0开始引入FileProvider来共享文件URI。
  • Android 10引入分区存储,Android 11强制启用分区存储,进一步限制了应用对外部存储的访问。
  • 使用MediaStore等新的API来进行文件的读写操作,以适应最新的存储策略。

了解并适应这些变化,可以确保应用在不同Android版本上的文件存储和读取操作安全且有效。

Android aar 文件到底是什么

作者: 朕小猫与GPT4

问题起源:怎么打包发布一个含有 C++ 文件的 Library

起因是 KOOM 2.2.1 Release 存在后台分析进程在部分机型失效,想自己发布一个适合自己项目的版本。

.so文件可以被打包到最终的AAR文件中吗

是的,.so文件可以并且通常被打包到最终的AAR(Android Archive)文件中。AAR文件是Android开发中用于分发Android库的标准格式,它包含了库所需的所有资源、编译后的代码以及其他文件,比如.so文件(本地库文件)。

打包.so文件到AAR中的步骤

  1. 放置.so文件: 将你的.so文件放置在项目的src/main/jniLibs/目录下。按照不同的CPU架构,你可能需要创建相应的子目录,例如armeabi-v7aarm64-v8ax86等,然后将相应架构的.so文件放置在这些目录下。

  2. 配置Gradle构建脚本: 确保你的build.gradle文件正确配置,这样Gradle在构建过程中就会自动将jniLibs目录下的.so文件包括到AAR包中。大多数情况下,这个过程是自动完成的,无需额外配置。

示例目录结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
YourLibraryProject/
├── src/
│ └── main/
│ ├── java/
│ │ └── ... (your Java or Kotlin source files)
│ ├── res/
│ │ └── ... (your resources)
│ └── jniLibs/
│ ├── armeabi-v7a/
│ │ └── libyourlibrary.so
│ ├── arm64-v8a/
│ │ └── libyourlibrary.so
│ ├── x86/
│ │ └── libyourlibrary.so
│ └── x86_64/
│ └── libyourlibrary.so

构建AAR包

当你构建你的库时(例如,通过运行./gradlew assembleRelease),Gradle会自动将jniLibs目录下的所有.so文件包括到生成的AAR文件中。这样,当其他开发者将你的AAR文件作为依赖项添加到他们的项目中时,这些.so文件就会被自动解压到正确的位置,供他们的应用使用。

确保在发布你的库之前测试它,以确保.so文件被正确包含且可以按预期工作。这通常涉及到在包含不同CPU架构设备的模拟器或真实设备上运行和测试你的库。

.aar 里可以有哪些文件

一个AAR(Android Archive)文件是Android库项目的发布包格式,用于在开发者之间共享Android库。AAR文件包含了库所需要的所有内容,使其可以被其他的Android应用项目引用。以下是AAR文件通常包含的文件和目录:

必需的内容

  • classes.jar:包含库的所有编译后的Java类(.class文件)。
  • res/:包含库所使用的所有资源文件,如布局(layouts)、字符串(strings)、图片(drawables)等。
  • AndroidManifest.xml:库的Android清单文件,描述了库的包名、所需权限、活动(activities)、服务(services)等。

可选的内容

  • R.txt:包含库中所有资源的ID名称和类型的列表,这是为了在库被引用时帮助生成最终应用的R类。
  • aidl/:包含库所使用的所有AIDL(Android Interface Definition Language)文件,用于定义跨进程通信接口。
  • assets/:包含可以被库访问的原始文件,如文本、音频或视频文件。
  • libs/*.jar:包含库依赖的任何Java库的JAR文件。
  • jni/jniLibs/:包含库依赖的本地库(如.so文件),这些文件根据不同的CPU架构可能存放在不同的子目录下,如armeabi-v7a/arm64-v8a/等。
  • proguard.txt:包含用于代码混淆的ProGuard规则。
  • lint.jar:包含自定义lint检查规则的JAR,这些规则可以在使用库的项目中被lint程序使用。
  • public.txt:列出了库公开的资源ID,这些资源ID在库的使用者中是可见的。
  • annotations.zip:包含用于工具支持的注解,比如nullability注解等。

如何查看AAR文件内容

你可以通过解压AAR文件(它本质上是一个ZIP文件)来查看里面的内容。使用任何标准的压缩文件管理器,如WinRAR、7-Zip等,都可以打开AAR文件并浏览其中的内容。

使用AAR文件

当你将AAR文件作为依赖项添加到Android项目中时,项目构建系统(如Gradle)会自动处理并引入这些内容,包括解析资源、合并清单文件等,从而使库中的功能可用于应用程序。
aar文件分析.png

Android aar 文件到底是什么

作者: 朕小猫与GPT4

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
task downloadApk {
doLast {
def remoteApkPath = "/home/jason/AndroidStudioProjects/ncmusic/app/build/outputs/apk/debug/app-debug.apk"
def localApkDir = System.getProperty("user.home") + "/Downloads/"
def localApkPath = localApkDir + "app-debug.apk"

// 创建本地目录
new File(localApkDir).mkdirs()

// 使用 scp 下载远程 APK 文件到本地
def command = "sshpass -p 'password' scp jason@192.168.1.100:${remoteApkPath} ${localApkPath}"
println "Executing command: ${command}"

exec {
commandLine 'bash', '-c', command
}
}
}

task installAndStart {
dependsOn downloadApk
doLast {
def localApkPath = System.getProperty("user.home") + "/Downloads/app-debug.apk"

// 确保 adb 可用
def adbPath = '/Users/jason/Library/Android/sdk/platform-tools/adb'
if (!new File(adbPath).exists()) {
throw new GradleException("adb command not found at ${adbPath}! Please ensure Android SDK is properly installed.")
}

// 安装 APK
def installCommand = "${adbPath} install -r ${localApkPath}"

try {
exec {
commandLine 'bash', '-c', installCommand
}
} catch (Exception e) {
throw new GradleException("Failed to install APK: ${e.message}")
}

// 提取包名
def manifestFile = file("${rootProject.projectDir}/app/src/main/AndroidManifest.xml")
def packageName = ""
if (manifestFile.exists()) {
manifestFile.eachLine { line ->
if (line.contains("package=")) {
packageName = line.split('package=')[1].split('"')[1]
}
}
if (packageName == "") {
throw new GradleException("Failed to extract package name from AndroidManifest.xml")
}
} else {
throw new GradleException("AndroidManifest.xml file not found!")
}

// 启动应用
def launchCommand = "${adbPath} shell monkey -p ${packageName} -c android.intent.category.LAUNCHER 1"
println "Executing command: ${launchCommand}"

try {
exec {
commandLine 'bash', '-c', launchCommand
}
} catch (Exception e) {
throw new GradleException("Failed to launch application: ${e.message}")
}
}
}

task downloadBuild {
doLast {
subprojects.each { subproject ->
def remoteRootPath = "/home/jason/AndroidStudioProjects/"
def remoteDirPath = remoteRootPath + "${rootProject.name}/${subproject.name}/build"
def localDirPath = "${subproject.projectDir}"

// 创建本地目录
new File(localDirPath).mkdirs()

// 使用 rsync 同步远程文件到本地
def command = "sshpass -p 'password' rsync -avz --delete jason@192.168.1.100:${remoteDirPath} ${localDirPath}"
println "Executing command: ${command}"

exec {
commandLine 'bash', '-c', command
}
}
}
}

task remoteBuild {
doLast {
// 远程构建命令
def command = "sshpass -p 'password' ssh jason@192.168.1.100 'cd /home/jason/AndroidStudioProjects/ncmusic/ && ./gradlew assembleDebug'"
exec {
commandLine 'bash', '-c', command
}
}
}

// 定义一个任务来执行远程构建、下载 APK、安装并启动应用,然后同步构建产物
task buildDownloadInstallAndStart {
dependsOn remoteBuild
finalizedBy installAndStart, downloadBuild
}

CMake 中的静态连接和动态链接

作者: 朕小猫与GPT4

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
cmake_minimum_required(VERSION 3.4.1)
project(DualVectorFoil)

# 先设置 Assimp 配置项,禁用测试、样例和文档生成
set(ASSIMP_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(ASSIMP_BUILD_SAMPLES OFF CACHE BOOL "" FORCE)
set(ASSIMP_BUILD_DOCS OFF CACHE BOOL "" FORCE)

# 添加 Assimp 目录和包含目录
add_subdirectory(${CMAKE_SOURCE_DIR}/Assimp)
include_directories(${CMAKE_SOURCE_DIR}/Assimp/include)

# 定义库文件 DualVectorFoilLib
add_library(
DualVectorFoilLib # 库名称
SHARED # 库类型
# 你的源文件列表,示例:src/main/cpp/你的文件.cpp
)

# 链接必要的库
target_link_libraries(
DualVectorFoilLib
assimp # Assimp 库
android # Android 系统库
EGL # OpenGL 库
GLESv2 # OpenGL ES 2.0
m # 数学库
z # 压缩库
atomic # 原子操作库
)

Q: 这样写完之后,apk 安装包中有两个so文件 libDualVectorFoilLib 和 libassimp,都占用 14m,但其实,libDualVectorFoilLib 中包含的就是libassimp,不应该再占用这么大体积

A: 为了减少 APK 中的体积,你可以将 libassimp 静态链接到 DualVectorFoilLib 中,这样只会生成一个包含 assimplibDualVectorFoilLib.so 文件,而不会包含独立的 libassimp.so 文件。这种方式可以避免重复打包和减小 APK 的体积。具体修改如下:

修改 CMakeLists.txt 配置,将 assimp 静态链接

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
cmake_minimum_required(VERSION 3.4.1)
project(DualVectorFoil)

# 先设置 Assimp 配置项,禁用测试、样例和文档生成
set(ASSIMP_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(ASSIMP_BUILD_SAMPLES OFF CACHE BOOL "" FORCE)
set(ASSIMP_BUILD_DOCS OFF CACHE BOOL "" FORCE)
set(BUILD_SHARED_LIBS OFF) # 禁用 Assimp 的动态库生成,改为静态库

# 添加 Assimp 目录和包含目录
add_subdirectory(${CMAKE_SOURCE_DIR}/Assimp)
include_directories(${CMAKE_SOURCE_DIR}/Assimp/include)

# 定义库文件 DualVectorFoilLib
add_library(
DualVectorFoilLib # 库名称
SHARED # 库类型
# 你的源文件列表,示例:src/main/cpp/你的文件.cpp
)

# 链接必要的库
target_link_libraries(
DualVectorFoilLib
assimp # 静态链接 Assimp 库
android # Android 系统库
EGL # OpenGL 库
GLESv2 # OpenGL ES 2.0
m # 数学库
z # 压缩库
atomic # 原子操作库
)

解释

  1. **禁用 BUILD_SHARED_LIBS**:将 BUILD_SHARED_LIBS 设置为 OFF,使 Assimp 以静态库(.a 文件)形式生成,这样 Assimp 将不会生成独立的 .so 文件,而是将代码直接打包到 libDualVectorFoilLib.so 中。

  2. **静态链接 assimp**:在 target_link_libraries 中链接 assimp,这会将 Assimp 的代码直接合并进 libDualVectorFoilLib.so,避免生成和打包独立的 libassimp.so

  3. 结果:这样可以减小 APK 体积,因为不再包含重复的库文件。

之后重新构建项目,确保 APK 中只包含 libDualVectorFoilLib.so 文件,不再包含 libassimp.so 文件。

Q: 为什么改成静态连接之后小了好多

A: 改成静态链接之后 APK 大小减少,主要原因在于静态链接和动态链接的工作原理不同,以及 Android 应用打包时如何处理库文件:

  1. 避免重复加载的冗余文件:动态链接时,libassimp.so 作为独立的动态库被单独打包,并在运行时加载。而静态链接则将 libassimp 的代码直接整合到 libDualVectorFoilLib.so 中,避免了多余的 .so 文件。因此,只生成一个完整的 .so 文件,而不是两个重复的文件,节省了空间。

  2. 优化与裁剪:静态链接时,链接器会自动去除 Assimp 库中未使用的代码,只保留 DualVectorFoilLib 需要的部分。这意味着生成的 libDualVectorFoilLib.so 体积更小,因为一些多余的功能不会被包含进去。而动态链接时,通常整个库都被打包,即使项目不使用 Assimp 的所有功能,APK 也会包含整个库的内容。

  3. 减少符号表与动态加载信息:动态库需要额外的符号表和动态加载信息,以便在运行时进行地址解析和符号加载。静态链接的 .so 文件则不需要这些额外的信息,因此最终生成的文件会更小。

  4. 加载性能提升:除了体积减小,静态链接也会带来性能上的提升,因为它省去了动态加载库时的解析步骤,在某些场景下可以略微缩短应用启动时间。

通过静态链接,代码体积优化和去重工作会更加有效,因此可以显著减小 APK 大小。

Q: 如果我只想引入 assimp 不想引入 libDualVectorFoilLib.so 怎么处理

A: 如果只想引入 assimp 库,不需要 libDualVectorFoilLib.so,可以直接在 CMake 中只构建 assimp,而不创建其他自定义的 .so 库文件。这样可以实现只生成 libassimp.so,并确保它在你的 APK 中被正确引用。以下是可以实现的步骤:

  1. 直接构建 Assimp 并禁用其他库的生成
    只需要 assimp,无需自定义的库文件,可以移除自定义库的定义,只保留对 assimp 的配置与编译。

  2. 修改 CMakeLists.txt

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    cmake_minimum_required(VERSION 3.4.1)
    project(DualVectorFoil)

    # 配置 Assimp:禁用测试、样例和文档生成
    set(ASSIMP_BUILD_TESTS OFF CACHE BOOL "" FORCE)
    set(ASSIMP_BUILD_SAMPLES OFF CACHE BOOL "" FORCE)
    set(ASSIMP_BUILD_DOCS OFF CACHE BOOL "" FORCE)

    # 添加 Assimp 目录并包含头文件
    add_subdirectory(${CMAKE_SOURCE_DIR}/Assimp)
    include_directories(${CMAKE_SOURCE_DIR}/Assimp/include)

    # Assimp 将自动生成 libassimp.so,无需自定义其他库
  3. **在 build.gradle 中指定加载 libassimp.so**:
    在你的 Android 项目中确保在 Java 或 Kotlin 代码中正确加载 libassimp.so,例如:

    1
    System.loadLibrary("assimp")
  4. **检查并确保只有 libassimp.so**:
    构建 APK 后,检查生成的 APK 文件,确认只有 libassimp.so 被打包,而没有 libDualVectorFoilLib.so

这样配置后,APK 中只会包含 libassimp.so,并且该库可以直接使用,不再生成不必要的库文件。

Q: So 文件的 Raw File Size 和 Download Size 分别是什么?

A: 在 APK 文件的打包和发布过程中,文件会经历压缩处理,这使得 APK 下载包的体积通常小于安装后在设备上实际占用的空间。在 Android Studio 中,APK Analyzer 工具展示了两个关键指标来帮助我们理解这些差异:

  1. Raw File Size(原始文件大小):这是文件在 APK 解压后,在设备上占用的实际存储空间大小。在您提供的截图中,libassimpd.so 的原始文件大小为 14.4 MB,表示安装到设备后它会占用 14.4 MB 的空间。

  2. Download Size(下载大小):这是 APK 包含的文件在压缩后的大小。由于 APK 打包时会对文件进行压缩,libassimpd.so 的压缩后下载大小仅为 3.4 MB。这个值表示用户在下载 APK 包时实际需要传输的数据量。

总结来说:

  • Raw File Size 是文件在设备上解压后的实际存储空间需求。
  • Download Size 是文件在 APK 压缩包中的大小,直接影响 APK 的下载体积。

因此,虽然下载 APK 时 libassimpd.so 只占 3.4 MB,但安装后在设备上会占用 14.4 MB 的存储空间。

Q: So 文件是怎么实现从14M压缩到3.4M的

A: so 文件(共享库文件)的大小可以大幅度缩小,主要是因为 Android APK 打包过程中应用了压缩算法。以下是一些关键原因和过程:

1. ZIP 压缩

APK 文件本质上是一个 ZIP 文件,打包时会对资源文件和库文件应用 ZIP 压缩。共享库文件(so 文件)通常包含大量重复数据空白区域,这些内容在 ZIP 压缩时能够被有效地缩减。因此,像 libassimpd.so 这种大型的二进制文件,通过 ZIP 压缩可以大幅度减小体积。

2. 二进制格式的高压缩率

so 文件是二进制格式,包含大量的结构化数据代码段。这些二进制数据在结构上非常紧密,包含许多重复的模式和数据块,非常适合被压缩算法处理。常用的压缩算法(例如 DEFLATE)能够识别这些模式,减少冗余,从而将文件压缩到较小的尺寸。

3. 去除无用数据

一些调试信息和符号信息在生成 so 文件时可能会被包含在内,但是在发布 APK 时通常会将这些调试符号信息剥离。这会进一步减少 so 文件的体积,尤其在压缩之后,对这些无用数据的移除效果显著。

4. 压缩前未使用的代码和数据区域

so 文件可能包含一些预留的数据段对齐填充,这些部分在压缩后也会被进一步缩小。比如对齐填充区域在压缩时几乎不会占用空间,从而进一步减小了整体体积。

示例总结

在您的示例中,libassimpd.so 原始文件大小为 14.4 MB,而在 APK 中压缩后的下载大小是 3.4 MB。这种大幅缩小主要得益于 ZIP 压缩的高效性,以及 so 文件的二进制结构带来的高压缩比。

总结

APK 中的 so 文件能够实现从 14 MB 压缩到 3.4 MB,主要是由于以下原因:

  • ZIP 压缩算法的高效性
  • 二进制文件的结构适合压缩
  • 发布时剥离调试符号信息
  • 未使用的数据和对齐填充在压缩后基本不占空间

这些因素共同作用,使得 so 文件在安装包中的大小远小于其解压后的原始大小。

TextView layout_constrainedWidth 属性

layout_constrainedWidthConstraintLayout 中的一个核心属性。它主要解决的问题是:当一个 View 设置为 wrap_content 时,如果内容过长,它是否应该强制遵循约束(Constraints)所限定的边界。

1. 基础用法示例

在下面的代码中,TextView 设置了 wrap_content。如果没有 app:layout_constrainedWidth="true",当文本极长时,TextView 可能会挤压右侧的 Button 甚至超出屏幕。开启该属性后,TextView 的宽度将受限于 startend 约束。

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
<androidx.constraintlayout.widget.ConstraintLayout 
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">

<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constrainedWidth="true"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/buttonView"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintHorizontal_chainStyle="packed"
android:text="极其冗长的文本示例,如果没有开启约束宽度,它会无视右侧按钮的约束直接顶过去..." />

<Button
android:id="@+id/buttonView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintStart_toEndOf="@id/textView"
app:layout_constraintEnd_toEndOf="parent"
android:text="按钮" />

</androidx.constraintlayout.widget.ConstraintLayout>

2. 源码解析:它是如何实现的?

layout_constrainedWidth 的核心逻辑位于 ConstraintLayout 的测量阶段,特别是 ConstraintWidget 对约束的处理逻辑。

2.1 属性解析

ConstraintLayout.LayoutParams 的构造函数中,会解析 XML 中的该属性:

1
2
// LayoutParams 内部代码片段
constrainedWidth = a.getBoolean(R.styleable.ConstraintLayout_Layout_layout_constrainedWidth, false);

2.2 核心逻辑:影响计算模式

该属性最终会传递给底层的求解器(Solver)。其核心逻辑在于 ConstraintWidgetsetHorizontalDimensionBehaviour 处理:

  1. Wrap Content 的默认行为
    当 View 设为 WRAP_CONTENT 时,其 DimensionBehaviour 默认为 WRAP_CONTENT。在求解约束时,求解器优先满足内容的大小,此时约束(Constraints)像是“软约束”,容易被内容撑开。

  2. 开启 constrainedWidth 后的变化
    如果 constrainedWidthtrueConstraintLayout 在调用 onMeasure 期间,会将该信息告知 ConstraintWidget。在求解引擎(Solver)内部,这会触发以下逻辑:

    • 即使是 WRAP_CONTENT,求解器也会为该 Widget 添加强制的边界约束(类似 0dp 的处理方式)。
    • 系统会计算:finalWidth = min(wrapContentWidth, constraintAvailableWidth)

2.3 关键源码链路

androidx.constraintlayout.core.widgets.ConstraintWidget 中(以 2.0+ 版本为例):

1
2
3
4
5
6
7
// 伪代码逻辑描述
if (mHorizontalResolution == WRAP_CONTENT && mConstrainedWidth) {
// 强制限制宽度不得超过约束定义的范围
// 系统会向线性求解器(Linear System)添加一组不等式约束:
// LeftSide + Margin <= WidgetStart
// RightSide - Margin >= WidgetEnd
}

简单来说,开启该属性后,ConstraintLayout 会在测量时动态地将该 View 的测量模式从单纯的“内容优先”调整为“内容优先,但约束封顶”。


3. 常见使用场景

  • 左右排列,一侧自适应,另一侧固定:防止自适应端内容太长把固定端挤出屏幕。
  • **Packed Chain (打包链)**:在 packed 链中,配合 bias 使用,可以让长文本在不超过边界的前提下,依然保持左对齐或居中对齐。

4. 总结

layout_constrainedWidth="true" 实际上是给 wrap_content 戴上了一个“约束的紧箍咒”。它让 View 在“想长多大就长多大”和“必须死死贴住约束”之间找到了一个平衡点:按内容显示,但绝不跨过约束红线。

拦截 View 触摸事件,判断滑动方向

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
anyView.setOnTouchListener(object : View.OnTouchListener {
private var initialX = 0f
private var initialY = 0f
private var hasScrolled = false

override fun onTouch(v: View?, event: MotionEvent?): Boolean {
event ?: return false
when (event.action) {
MotionEvent.ACTION_DOWN -> {
// 记录初始触摸位置
initialX = event.x
initialY = event.y
hasScrolled = false
}
MotionEvent.ACTION_MOVE -> {
if (!hasScrolled) {
val diffX = event.x - initialX
val diffY = event.y - initialY

// 判断滑动方向,确保是水平滑动
if (abs(diffX) > abs(diffY)) {
val direction = if (diffX < 0) Direction.LEFT else Direction.RIGHT

handleHorizontalScroll(direction)
hasScrolled = true
}
}
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
hasScrolled = false
}
}

return false
}

private fun handleHorizontalScroll(direction: Direction) {
when (direction) {
Direction.LEFT -> { // 向左滑动
showToast("发生向左滑动")
// 在这里处理向左滑动的逻辑
}
Direction.RIGHT -> { // 向右滑动
showToast("发生向右滑动")
// 在这里处理向右滑动的逻辑
}
}
}

private fun showToast(message: String) {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}

enum class Direction {
LEFT, RIGHT
}
}

现代图形 API 的基石 Vulkan(一):绘制第一个三角形

从设置可以同时处理的帧数开始

1
2
3
4
// 我们希望让 GPU 和 CPU 保持忙碌。为了做到这一点,我们可以在上一个命令缓冲区仍在执行时就开始构建一个新的
// 此数字定义了可以同时处理的帧数
// 增加此数字可能会提高性能,但也会引入额外的延迟
#define MAX_CONCURRENT_FRAMES 2

我们可以将 CPU 和 GPU 的配合想象成一条流水线生产作业

为什么增加数字可以“提高性能”(提高吞吐量)?

这里的“性能”通常指的是 FPS(帧率/吞吐量)

在图形渲染中,CPU 和 GPU 是两个独立工作的处理器。

  • **如果设置 MAX_CONCURRENT_FRAMES = 1**:
    • CPU 必须先花时间录制完命令缓冲区。
    • GPU 接着渲染这一帧。
    • 在此期间,CPU 往往处于空闲等待状态(因为 GPU 没干完,CPU 不能提交下一帧)。
    • 反之,当 CPU 在录制新一帧时,GPU 也在空闲(如果上一帧还没开始渲染)。
    • 这种“一等一”的模式导致 CPU 和 GPU 无法同时满载,帧率上限受到限制。
  • 如果增加到 2 或更多
    • 你允许 CPU 在 GPU 渲染上一帧的同时,去“预录制”下一帧。
    • 当 GPU 渲染完 Frame N 时,CPU 已经准备好了 Frame N+1 的命令,GPU 可以无缝衔接继续工作。
    • 结果:CPU 和 GPU 都能保持高负载,GPU 几乎不会因为等待 CPU 录制命令而空转。这显著提高了平均 FPS。

为什么会引入“额外的延迟”(增加输入响应时间)?

这里的“延迟”指的是 Input Latency(输入延迟),即从你按下鼠标或键盘那一刻,到屏幕上看到画面变化的时间。

  • 缓冲队列的代价: 增加 MAX_CONCURRENT_FRAMES 本质上是增加了一个等待队列。如果你设置了 3 帧,这意味着当前 GPU 正在处理的一帧(Frame N),后面还排着 Frame N+1 和 Frame N+2。
  • 输入响应滞后: 如果你现在点击鼠标,你的输入指令只能被写入(或者是影响到)Frame N+3 的数据中。
    • 这意味着你的输入必须等待 Frame N, N+1, N+2 全部渲染完成并显示,才能看到结果。
    • 流水线越深,输入指令到达屏幕显示的时间就越长。

理解 CPU 必须先花时间录制完命令缓冲区,到底在录制什么?

在旧 API(如 OpenGL)中,你调用一个 glDrawArrays,驱动程序会一边替你翻译,一边在后台悄悄发送给 GPU。API 隐藏了录制的细节

但在 Vulkan 中,这一过程被显式化了:

  • **录制 (Recording)**:CPU 将你的图形逻辑(比如“我要画一个三角形”)翻译成 GPU 能够直接识别的二进制指令流。这个过程涉及到内存访问、状态校验,是非常消耗 CPU 时间的。
  • 非立即执行:当你调用 vkCmdDrawIndexed 时,什么都没画出来。你只是在内存里写下了一条指令。只有当这一大串指令通过 vkQueueSubmit 提交给 GPU 后,GPU 才会开始执行它们。

示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 1. 开始录制
vkBeginCommandBuffer(commandBuffer, &cmdBufInfo);

// 2. 写入指令:开始渲染通道
vkCmdBeginRenderPass(commandBuffer, &renderPassBeginInfo, VK_SUBPASS_CONTENTS_INLINE);

// 3. 写入指令:设置视口和裁剪
vkCmdSetViewport(commandBuffer, 0, 1, &viewport);
vkCmdSetScissor(commandBuffer, 0, 1, &scissor);

// 4. 写入指令:绑定资源(描述符集、流水线、缓冲区)
vkCmdBindDescriptorSets(commandBuffer, ...);
vkCmdBindPipeline(commandBuffer, ...);
vkCmdBindVertexBuffers(commandBuffer, ...);

// 5. 写入指令:这是真正的“绘制”指令
vkCmdDrawIndexed(commandBuffer, indices.count, 1, 0, 0, 0);

// 6. 结束渲染通道
vkCmdEndRenderPass(commandBuffer);

// 7. 结束录制
VK_CHECK_RESULT(vkEndCommandBuffer(commandBuffer));

CPU 必须完成这一连串繁琐的翻译和记录工作,才能让 GPU 开始渲染。

设置顶点缓冲区

顶点数据结构:组成部分

  • position[3]: 定义了点的 3D 坐标 (x, y, z)。这是为了告诉 GPU 这个点在空间中的位置。
  • color[3]: 定义了点的 RGB 颜色 (r, g, b)。这是为了告诉 GPU 这个点显示什么颜色。
1
2
3
4
5
// 三角形顶点布局数据结构
struct Vertex {
float position[3];
float color[3];
};

顶点缓冲区 (Vertex Buffer)

  • 角色:这是“原材料库”。它存的是点的坐标、颜色、法线等。
  • 管线作用:输入装配器(Input Assembler)从这里直接读取数据,然后一股脑儿送给顶点着色器(Vertex Shader)。
1
2
3
4
5
// 顶点缓冲区和属性
struct {
VkDeviceMemory memory{VK_NULL_HANDLE}; // 此缓冲区的设备内存句柄
VkBuffer buffer{VK_NULL_HANDLE}; // 内存所绑定的 Vulkan 缓冲区对象的句柄
} vertices;

在 Vulkan 中,这段代码体现了其最核心的设计哲学:显式控制(Explicit Control)

与 OpenGL 或 DirectX 等“自动挡” API 不同,Vulkan 不会将“资源定义”和“物理内存分配”混为一谈。理解这段代码,你需要区分两个概念:“逻辑上的房子”“物理上的地皮”

概念拆解:房子与地皮

你可以把这两个成员变量想象成房地产开发:

  • VkBuffer buffer(逻辑上的房子)

    • 它是一个句柄(Handle),告诉 GPU:“我是一个缓冲区,我要用来存顶点数据,我的大小是 1024 字节,我要被用作顶点输入。”
    • 注意:此时它仅仅是一个定义,它并没有真正的存储空间来存放数据。如果你现在尝试往里面写数据,程序会崩溃,因为它的地基是空的。
  • VkDeviceMemory memory(物理上的地皮)

    • 它是一个内存对象,代表了真正从 GPU 显存(VRAM)或者系统内存(RAM)中申请下来的一块“物理空间”。
    • 它有大小、属性(比如是允许 CPU 读写,还是仅 GPU 高速访问)。

为什么要分开?(为什么要这么麻烦?)

你可能会问:为什么要分得这么细,像 OpenGL 那样 glBufferData 一行代码搞定不好吗?

答案是:为了“精细化管理”和“高性能”。

  1. 内存池技术(Sub-allocation)
    在真实的商业游戏引擎中,向操作系统申请内存是一个非常昂贵的系统调用。如果我们为每一个 Buffer 都申请一块独立的内存,开销极大。

    • Vulkan 的做法:我们可以一次性申请一块巨大VkDeviceMemory(比如 256MB),然后将这块大空间切分成无数小块,分配给不同的 VkBufferVkImage
    • 这种设计允许我们将多个资源打包放在同一个内存块中,极大地减少了内存分配次数和显存碎片。
  2. 物理属性匹配
    有些内存适合 CPU 快速读写(Host Visible),有些适合 GPU 快速渲染(Device Local)。通过将 BufferMemory 分离,你可以创建一个 Buffer,然后根据需求选择最合适的“地皮”把它绑定上去。

如何产生联系?(vkBindBufferMemory

这代码定义的两个成员变量,只是“名义上的关联”。它们真正发生关系是在你调用以下函数时:

1
2
// 将上面申请的内存 "memory" 绑定到缓冲区 "buffer" 上
vkBindBufferMemory(device, vertices.buffer, vertices.memory, 0);

这一步操作就像是给“房子”分配了“地皮”。一旦执行了这行代码,buffer 才真正拥有了存储数据的物理基础。

总结

  • VkBuffer规范:定义了数据的用途和大小。
  • VkDeviceMemory资源:提供了数据的存放场所。
  • 这种分离赋予了开发者管理 GPU 显存的最大自由度,也是 Vulkan 高性能的基石。
1
2
3
4
5
6
// 索引缓冲区
struct {
VkDeviceMemory memory{VK_NULL_HANDLE};
VkBuffer buffer{VK_NULL_HANDLE};
uint32_t count{0};
} indices;

索引缓冲区和顶点缓冲区的区别

在 Vulkan 的内存管理层面,它们几乎没有区别;但在渲染逻辑(管线)层面,它们的作用天差地别。

让我们从“结构”和“逻辑”两个维度来拆解一下。


结构层面:它们是“双胞胎”

struct 的定义来看,你会发现 indicesvertices 在 Vulkan 看来都是缓冲区(Buffer)

  • 完全相同点
    • 内存分配:两者都需要 VkDeviceMemory,都需要分配显存,都需要映射,都需要通过 vkBindBufferMemory 进行绑定。
    • 暂存(Staging)机制:在你的 createVertexBuffer 代码中,你用了同样的逻辑把数据从 CPU 拷贝到 GPU(Staging Buffer -> Device Local Buffer)。Vulkan 并不关心缓冲区里存的是“顶点数据”还是“索引数据”,它只负责搬运字节流。

所以,你可以认为它们在 Vulkan 的内存管理眼界里,仅仅是“两块存着不同数据的显存区域”。


逻辑层面:它们的“身份”完全不同

虽然它们都是显存,但在图形管线(Graphics Pipeline)中,它们扮演的角色完全不同:

A. 顶点缓冲区 (Vertex Buffer)

  • 角色:这是“原材料库”。它存的是点的坐标、颜色、法线等。
  • 管线作用:输入装配器(Input Assembler)从这里直接读取数据,然后一股脑儿送给顶点着色器(Vertex Shader)。

B. 索引缓冲区 (Index Buffer)

  • 角色:这是“指针索引表”。它存的是 uint32_t 类型的整数,这些整数指向顶点缓冲区中某一个点的下标。
  • 管线作用:它用来复用顶点。
    • 例子:如果你要画一个正方形,你需要 4 个顶点。如果你不使用索引(直接画三角形列表),你需要定义 6 个顶点(两个三角形 x 3 个顶点)。但如果你使用索引,你只需要定义 4 个顶点,然后通过索引告诉 GPU:“第一个三角形用 0, 1, 2 号点,第二个三角形用 2, 3, 0 号点”。
    • 这大大节省了显存,并降低了顶点着色器的计算压力。

程序中的关键差异

为什么在你的结构体里 indices 多了一个 uint32_t count,而 vertices 没有?

这是因为调用绘图指令时,它们被喂给了不同的函数:

  • 顶点缓冲区 是通过 vkCmdBindVertexBuffers 绑定的,它只管绑定在哪儿。
  • 索引缓冲区 是通过 vkCmdBindIndexBuffer 绑定的。
  • 绘图指令
    • 如果你用的是顶点缓冲区(非索引渲染):调用 vkCmdDraw,你需要告诉 GPU 你有多少个顶点。
    • 如果你用的是索引缓冲区(索引渲染):调用 vkCmdDrawIndexed,你需要告诉 GPU 你有多少个索引(这就是 indices.count 的用途)。

所以,你可以把 vertices 看作“仓库”(存东西),把 indices 看作“目录”(告诉你去仓库拿哪个东西)。在代码结构上它们是对称的,但在 GPU 运行时,它们一个在“被读”,一个在“引导阅读”。

缓冲区块 UBO:OpenGL vs. Vulkan

1
2
3
4
5
6
7
8
9
10
// Uniform 缓冲区块对象
struct UniformBuffer {
VkDeviceMemory memory{VK_NULL_HANDLE};
VkBuffer buffer{VK_NULL_HANDLE};
// 描述符集存储了绑定到着色器中绑定点的资源
// 它将不同着色器的绑定点与用于这些绑定的缓冲区和图像连接起来
VkDescriptorSet descriptorSet{VK_NULL_HANDLE};
// 我们保留指向映射缓冲区的指针,以便可以通过 memcpy 轻松更新其内容
uint8_t *mapped{nullptr};
};

UniformBuffer 是 Vulkan 渲染中至关重要的数据结构,它负责处理那些在整个渲染过程中保持相对稳定,但每帧可能都会变化的数据,例如:投影矩阵(Projection Matrix)、视图矩阵(View Matrix)或模型矩阵(Model Matrix)。

可以把它理解为 “CPU 给 GPU 传递全局参数的窗口”

让我们拆解这个结构体的每一个成员,看看它们分别负责什么工作:

结构体成员详解

  • VkDeviceMemory memory & VkBuffer buffer

    • 上面已经陈述过。它们负责在显存中开辟一块空间,用来存放你的矩阵数据(ShaderData)。这块空间是“物理存储区”。
  • VkDescriptorSet descriptorSet (核心难点)

    • 这是什么? 把它想象成一个 “遥控器”或“引路人”
    • 为什么要它? 着色器(Shader)代码(如 GLSL)里写着 layout(set = 0, binding = 0) uniform UBO。着色器并不知道这个 UBO 到底对应哪一块显存。descriptorSet 的作用就是连接:它告诉 GPU,“当你运行到那个着色器代码时,请去使用这个特定的 buffer 里面的数据”。
    • 它是动态的:Vulkan 允许你通过更换不同的描述符集,在不同的渲染任务中指向不同的数据块。
  • uint8_t *mapped (性能优化)

    • 这是什么? 这是一个指向 CPU 内存的指针,它直接映射(Map)到了 GPU 的那一块内存上。
    • 它是怎么工作的? 在 Vulkan 中,通常更新数据需要一连串复杂的“拷贝-传输”命令。但对于 UniformBuffer,我们使用一种特殊内存(Host Visible & Host Coherent),允许 CPU 直接通过这个 mapped 指针进行 memcpy 操作。
    • 意义:这省去了 vkCmdCopyBuffer 等繁琐步骤,只要你在 CPU 上执行 memcpy,数据几乎实时就会出现在 GPU 可见的地方。这对于每一帧都要刷新的矩阵数据来说,是最高效的方式。

为什么需要这个复杂的结构?

在 OpenGL 中,你可能习惯了用 glUniformMatrix4fv 直接传参。但在 Vulkan 中,为了性能和多线程安全性,这种“即时传参”被彻底禁止了。

现在的逻辑链条是:

  1. 准备数据:CPU 计算好矩阵数据(projectionMatrix, modelMatrix 等)。
  2. 更新内存:CPU 通过 mapped 指针,直接把数据写到这块映射好的显存里(memcpy)。
  3. 绑定资源:通过 vkCmdBindDescriptorSets,把这块显存“喂”给 GPU 正在执行的渲染流水线。
  4. 着色器读取:GPU 执行渲染时,通过 descriptorSet 自动找到这块显存,读取数据进行顶点变换。

这个 UniformBuffer 结构体就是为了“快速且清晰地更新全局参数”而设计的。它利用 mapped 指针快速更新数据,利用 descriptorSet 告诉 GPU 数据在哪,从而实现了 CPU 到 GPU 高效的参数传递。

现在的代码里有 MAX_CONCURRENT_FRAMES(也就是双缓冲),这意味着实际上为每一帧都准备了一个独立的 UniformBuffer,这样 CPU 更新第 2 帧的矩阵时,绝对不会干扰到 GPU 正在渲染第 1 帧,这正是 Vulkan 并行渲染的高级之处。

对比 OpenGL (UBO) vs. Vulkan (UniformBuffer)

在 OpenGL 中,很多操作是“全局状态机”驱动的,而在 Vulkan 中,所有操作都是“对象绑定”驱动的。

特性 OpenGL (UBO) Vulkan (Uniform Buffer)
操作风格 基于全局状态 (State-based) 基于描述符对象 (Object-based)
绑定方式 调用 glBindBufferBase 将缓冲区绑定到全局“绑定点” 创建 DescriptorSet,显式关联 Buffer 和 Shader
代码量 少,几行代码搞定 多,需要创建 Pool, Layout, Set 等
线程安全性 很难在多线程中同时操作,容易引发状态冲突 原生支持,不同线程可以录制不同的描述符集

OpenGL 的做法(“简单模式”)

在 OpenGL 中,如果你想用 Uniform Buffer,流程大致是:

  1. 创建glGenBuffers
  2. 绑定glBindBuffer(GL_UNIFORM_BUFFER, buffer)
  3. 连接glBindBufferBase(GL_UNIFORM_BUFFER, binding_point, buffer)
  4. 着色器使用:在 GLSL 里写 layout(std140, binding = X) uniform ...

你会发现,OpenGL 只需要你把 Buffer 扔到某个“槽位”(Binding Point)上,着色器自动就能找到它。这很方便,但也意味着所有着色器都要争抢这几个有限的槽位,且很难管理。

Vulkan 的做法(“精细控制模式”)

Vulkan 抛弃了“槽位”的自动绑定,改用了**描述符集 (Descriptor Set)**:

  • 它不是“绑定到槽位”,而是“定义接口”:在 Vulkan 中,你必须预先定义 DescriptorSetLayout(蓝图),告诉 GPU 这个着色器需要什么样的资源。
  • 它不是“全局状态”,而是“局部引用”:你可以给每一个物体(或者每一帧)都分配一个独立的 DescriptorSet

为什么要这么折腾?

  • 为了多线程渲染:在 OpenGL 里,修改全局状态(Bind 某个 Buffer)会导致整个渲染管线“暂停”或产生依赖。而在 Vulkan 中,因为你明确地把 Buffer 包裹在 DescriptorSet 里,CPU 可以并行在多个线程里录制命令缓冲区,互不干扰。
  • 驱动优化:GPU 驱动程序在 Vulkan 中通过这种显式的描述符引用,能提前知道哪些资源会被用到,从而做更激进的优化。
1
2
// 我们每帧使用一个 UBO,这样我们就可以进行帧重叠,并确保 Uniform 在仍在使用时不会被更新
std::array<UniformBuffer, MAX_CONCURRENT_FRAMES> uniformBuffers;

开头已经说过 MAX_CONCURRENT_FRAMES 设置同时处理的帧数的意义。

二进制桥梁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 为了简单起见,我们使用与着色器中相同的 uniform 块布局:
//
// layout(set = 0, binding = 0) uniform UBO
// {
// mat4 projectionMatrix;
// mat4 modelMatrix;
// mat4 viewMatrix;
// } ubo;
//
// 这样我们就可以直接将 ubo 数据 memcopy 到 ubo 中
// 注意:应该使用与 GPU 对齐的数据类型,以避免手动填充 (vec4, mat4)
struct ShaderData {
glm::mat4 projectionMatrix;
glm::mat4 modelMatrix;
glm::mat4 viewMatrix;
};

memcpy(mapped, &shaderData, sizeof(ShaderData))

  • 它的含义:CPU 不去一个个变量地修改数据,而是直接把这块内存的二进制内容“暴力”地复制到显存中。
  • 前提条件:这个前提非常苛刻。CPU 结构体中第一个成员(projectionMatrix)的起始地址、每一个矩阵的大小、以及矩阵之间的间隔,必须和 GPU 着色器内部期待的格式完全一致。如果有一丁点儿偏差(比如编译器在 C++ 里加了填充字节,而 GPU 里没有),你渲染出来的画面就会因为读取了错误的内存偏移而扭曲、甚至崩溃。

在 Vulkan 开发中,这被称为“二进制对齐映射(Binary Layout Mapping)”。

简单来说,这段代码的目标是:让 CPU 的内存布局(C++ 结构体)和 GPU 的内存布局(GLSL 着色器)完全对齐,从而实现“零拷贝”的快速更新。

VkPipelineLayout 共享流水线布局

1
2
3
4
// 流水线布局被流水线用于访问描述符集
// 它定义了流水线所使用的着色器阶段与着色器资源之间的接口(不绑定任何实际数据)
// 只要接口匹配,流水线布局就可以在多个流水线之间共享
VkPipelineLayout pipelineLayout{VK_NULL_HANDLE};

VkPipelineLayout 之所以能够“共享”,是因为 Vulkan 将“接口定义”“实现逻辑”中剥离了出来,变成了显式的对象。

我们可以通过一个通俗的类比,并对比 OpenGL 和 Vulkan 的工作流,来理解为什么这一行代码揭示了两者最本质的设计差异。


核心对比:什么是“共享”?

在图形编程中,我们关注的是“管线(Shader)”如何获取“数据(Buffer/Texture)”。

OpenGL 的做法:隐式约定 (Implicit)

在 OpenGL 中,并不存在一个类似 PipelineLayout对象

  • 做法:你直接在着色器里写 layout(binding = 0)。在 CPU 端,你通过 glBindBuffer 或者 glUniform 把数据推送到插槽 0 上。
  • 共享机制:OpenGL 没有“共享接口”的概念。如果你有两个着色器,它们都用到同一个 UBO,你必须在 CPU 代码中人为地保证这两个着色器都去查询“插槽 0”。如果其中一个着色器改了绑定点,你必须手动去修改 CPU 代码。
  • 本质接口是“隐式”的,依赖于程序员的记忆和编码规范。

Vulkan 的做法:显式契约 (Explicit Contract)

在 Vulkan 中,VkPipelineLayout 是一个独立的对象

  • 做法:你先定义一个“布局对象”(蓝图),然后将这个对象绑定到不同的流水线(Pipeline)。
  • 共享机制:由于 PipelineLayout 是一个对象,你完全可以创建一个 GlobalDataLayout,然后把它传给“渲染地形的流水线”、“渲染角色的流水线”和“渲染UI的流水线”。它们都强制遵守同一个契约。
  • 本质接口是“显式”的,变成了一个可以存储、传递和复用的资源。

类比理解:电源插座 vs. 定制接口

  • OpenGL 的“电源排插”模式: 你有一个排插(OpenGL 上下文),上面有 8 个插孔(Binding Points)。不管你是电饭煲(着色器 A)还是吹风机(着色器 B),你都必须自己找个插孔插上去。如果你不小心把电饭煲插到了吹风机的插孔里,或者忘了插,程序就会出错。没有一个明确的对象来规定“谁必须插在哪”。
  • Vulkan 的“定制化接头”模式: 你设计了一个专门的接口(VkPipelineLayout),规定了必须有 3 根针脚(对应的 Buffer)。任何想要工作的设备(Pipeline),必须先证明它支持这个接口,并且通过这个接口接入电源。只要接口形状一致(Layout 匹配),你可以有 100 个设备复用同一个接口标准,它们甚至不需要关心对方是谁。

为什么“可以共享”如此重要?(性能与架构)

你代码里的这行注释,背后其实隐藏着 Vulkan 的两大核心优势:

A. 驱动性能优化(预编译)

在 OpenGL 中,因为没有显式的接口布局,驱动程序必须在每次你调用 glDraw 时,去校验:“当前着色器和当前绑定的 Buffer 匹配吗?”。这是一种每帧都在重复的运行时校验,非常浪费 CPU。

而在 Vulkan 中,因为你有 VkPipelineLayout

  • 驱动在 Pipeline 创建时(预编译) 就完成了所有校验。
  • 在渲染循环里,GPU 只需要确认:当前的 DescriptorSet 是否符合那个已知的 PipelineLayout。这是一个极其快速的地址核对操作,零校验开销

B. 多线程渲染的安全性

如果接口是“隐式”的(OpenGL),在多线程下修改全局绑定插槽,极易引发竞态条件,导致渲染崩溃。

因为 Vulkan 使用了 PipelineLayoutDescriptorSet,资源与接口被绑定在了一起,多线程可以并行地录制不同的命令缓冲区,因为每个线程都知道它们引用的资源布局(PipelineLayout)是确定的、不变的。

1
2
3
4
5
// 流水线(通常称为“流水线状态对象”)用于烘焙影响流水线的所有状态
// 虽然在 OpenGL 中几乎可以随时更改每个状态,但 Vulkan 要求预先布置图形(和计算)流水线状态
// 因此,对于每种非动态流水线状态组合,都需要一个新的流水线(有一些例外这里不讨论)
// 尽管这增加了一个提前规划的新维度,但对于驱动程序的性能优化来说,这是一个很好的机会
VkPipeline pipeline{VK_NULL_HANDLE};

怎么理解对于每种非动态流水线状态组合,都需要一个新的流水线?

这是一个非常硬核且核心的概念。在 Vulkan 中,这被称为 “流水线状态烘焙(Pipeline State Baking)”

为了让你彻底明白,我们可以把 OpenGL 的模式比作“厨师现场烹饪”,而把 Vulkan 的模式比作“工厂流水线生产”。

厨师与流水线的逻辑差异

OpenGL 的模式:厨师现场烹饪 (Runtime Validation)

在 OpenGL 中,当你调用 glDrawArrays 时,你可以随时修改状态(比如 glEnable(GL_DEPTH_TEST)glPolygonMode)。

  • 代价:对于 GPU 驱动程序来说,这就像是厨师在炒菜炒到一半,你突然让他换个锅、换个铲子。厨师必须停下来,检查此时所有的参数,重新校验这些参数是否合法,然后才能继续炒菜。
  • 性能损耗:这个“校验”过程非常昂贵,而且是发生在每一帧的渲染循环里的,极大地拖累了 CPU 的速度。

Vulkan 的模式:工厂流水线 (Pre-baked Pipelines)

在 Vulkan 中,流水线(Pipeline)就像是一个已经搭建好并调试完毕的专用生产线

  • 烘焙(Baking):在程序初始化时,你把所有状态(深度测试开关、混合模式、着色器等)一股脑儿塞进去,Vulkan 驱动程序会花费大量时间,将其编译成 GPU 最能直接执行的、高度优化的“机器码”。
  • 执行:一旦生成了 VkPipeline,它就是不可变的。在渲染循环里,你只需要切换 vkCmdBindPipeline,GPU 瞬间就能切入该状态,中间不需要任何“校验”和“翻译”。

为什么“每种状态组合”都需要一个新的流水线?

因为 “每种状态组合对应的机器码是不同的”

假设你的程序有两个功能:

  1. 场景渲染:开启深度测试,关闭混合。
  2. UI 渲染:关闭深度测试,开启混合。

在 Vulkan 看来,这两者的底层指令是完全不同的:

  • 对于场景,GPU 需要插入深度比较逻辑。
  • 对于 UI,GPU 需要插入颜色混合公式。

如果你想切换到 UI 渲染,你不能只修改场景流水线的一个“开关”,因为那样做会导致 GPU 内部逻辑出错。你必须调用 vkCmdBindPipeline,换上一条专门为“关闭深度测试+开启混合”而预编译好的生产线(流水线对象)。

这就是为什么它是“组合”的原因:
任何一个微小的状态改变(比如 depthCompareOpLESS 变成 LESS_OR_EQUAL),对于驱动程序来说,都可能意味着要重新生成一份 GPU 指令序列。

这带来了什么好处?

这种“看似繁琐”的设计,其实是现代图形 API 性能的基石:

  1. 零运行时开销:当调用 vkCmdDraw 时,GPU 已经完全知道当前状态的所有细节,不需要在绘制瞬间去问 CPU:“现在深度测试开没开?”
  2. 驱动程序解放:驱动程序不需要再像 OpenGL 那样去猜测你的意图,它在创建 Pipeline 时就已经把所有事情算好了。
  3. 多线程并行:因为 Pipeline 是不可变的(Immutable),所以多个线程可以安全地使用同一个 Pipeline 对象,而不会出现冲突。

什么是例外?(动态状态)

你代码里有一行很关键的注释:“(有一些例外这里不讨论)”

Vulkan 也意识到如果所有状态都必须预先“烘焙”,那确实会造成流水线对象爆炸( Pipeline State Object Explosion)。因此,Vulkan 允许一部分状态设置为 “动态的(Dynamic State)”

例如:

  • 视口大小 (Viewport) 和 **裁剪区域 (Scissor)**。

这两个状态在渲染过程中经常改变,但对 GPU 底层指令影响较小。因此,你可以在创建流水线时将其标记为 VK_DYNAMIC_STATE_VIEWPORT。这样,即使流水线没变,你也可以在录制命令缓冲区时,通过 vkCmdSetViewport 实时修改它们,而不需要创建新的流水线。

总结

理解这一点,你需要接受一个思维转变:

  • OpenGL:状态是“全局变量”,可以随时改,驱动帮你收拾烂摊子。
  • Vulkan:流水线是“静态蓝图”,必须提前规划好,驱动帮你预编译成最优指令。

为了性能,我们把“运行时开销”转移到了“初始化开销”上。

VkDescriptorSetLayout 描述符集布局

1
2
3
// 描述符集布局描述了着色器绑定布局(而不实际引用描述符)
// 像流水线布局一样,它基本上是一个蓝图,只要它们的布局匹配,就可以与不同的描述符集一起使用
VkDescriptorSetLayout descriptorSetLayout{VK_NULL_HANDLE};

OpenGL vs Vulkan

核心架构差异:显式控制 vs. 隐式自动化

在图形编程中,GPU 需要知道 Shader 如何与数据(Buffer/Texture)对接。

Vulkan:显式控制(Explicit Contract)

Vulkan 采取的是“先定义,后使用”的严谨模式。

  • 设计哲学:所有资源接口(Layout)必须在渲染循环开始前定义并烘焙。
  • **蓝图机制 (VkDescriptorSetLayout)**:
    • 定义蓝图VkDescriptorSetLayout 充当了“静态蓝图”的角色。它不绑定具体内存,只定义接口规格(例如:“槽位0必须是 UBO,槽位1必须是 Texture”)。
    • 数据解耦:这种设计将“接口定义”“数据实例”完全分离。Layout 是蓝图(静态),Descriptor Set 是填入具体 Buffer 句柄的实例(动态)。
  • 性能优势:驱动程序在创建 Pipeline 时就已完成所有资源布局的验证。在 DrawCall 时,GPU 没有任何运行时校验负担,这消除了驱动程序的“猜测”和“运行时补偿”开销。

OpenGL:隐式自动化(Implicit Automation)

OpenGL 采取的是“你只管用,驱动来猜”的自动化模式。

  • 设计哲学:尽可能减少开发者负担,将资源映射的任务交给驱动程序。
  • **自动映射 (glLinkProgram)**:
    • 当你调用 glLinkProgram 时,OpenGL 驱动会自动扫描你的 Shader 代码。它在幕后解析 layout(binding=...),并为你生成一套隐式的资源布局映射。
  • 性能代价:这种自动化是“运行时”发生的。每当你进行 DrawCall 时,驱动程序都需要检查当前的绑定状态是否符合 Shader 的预期。这种“运行时查询”带来了巨大的 CPU 开销。

关键对比总结表

维度 Vulkan (VkDescriptorSetLayout) OpenGL (隐式布局)
布局定义时机 初始化阶段(预编译) 链接阶段glLinkProgram 时)
验证时机 预校验(Pipeline 创建时) 运行时(Draw Call 时)
设计核心 资源接口显式化,提前优化 驱动程序自动匹配,降低开发难度
驱动负载 极低(只管执行指令) 较高(需在渲染时执行状态校验)
灵活性 高(可实现多线程并行录制) 中(受限于全局状态机)

理解“为什么 Vulkan 要这么麻烦?”

你可以将上述差异理解为“制造工艺”的差异:

  • OpenGL 像是“现场拼装”
    工人在施工现场(渲染循环)看到一个零件(Buffer),赶紧对照图纸(Shader 绑定点)看看能不能装上。如果装不上,工人还要报错。虽然灵活性高,但现场拼装极慢,且容易出错。

  • Vulkan 像是“模块化工厂”
    你必须在开工前(Pipeline 创建阶段)把蓝图(Layout)画好,确认所有接口都对得上。等真正开工时,所有组件(Descriptor Set)都已经按规格装配好了。工人(GPU)拿到直接插上就能跑,无需校验,无需等待

VkDescriptorSetLayout 共享

如果你的多个 Shader(比如“基础着色器”、“法线贴图着色器”、“阴影着色器”)都使用相同的资源布局(比如大家都在 binding 0 绑定 UBO,在 binding 1 绑定纹理),你完全不需要为每个 Shader 创建不同的 Layout。

共享的好处

  1. 减少内存开销:不用创建几十个重复的 Layout 对象。
  2. 流水线重用VkPipelineLayout 可以包含多个 VkDescriptorSetLayout。如果多个流水线共用同一个资源布局标准,它们就可以共用同一个 PipelineLayout,这在切换流水线时能省下大量的驱动验证开销。

同步原语:渲染流水线上的交通信号灯

1
2
3
4
5
6
7
8
9
10
// 同步原语
// 同步是 Vulkan 的一个重要概念,OpenGL 几乎将其隐藏了。正确处理这一点对于使用 Vulkan 至关重要。

// 信号量用于协调图形队列内的操作并确保正确的命令顺序
std::array<VkSemaphore, MAX_CONCURRENT_FRAMES> presentCompleteSemaphores{};
std::array<VkSemaphore, MAX_CONCURRENT_FRAMES> renderCompleteSemaphores{};

VkCommandPool commandPool{VK_NULL_HANDLE};
std::array<VkCommandBuffer, MAX_CONCURRENT_FRAMES> commandBuffers{};
std::array<VkFence, MAX_CONCURRENT_FRAMES> waitFences{};

理解同步原语(Synchronization Primitives)是学习 Vulkan 最具挑战性的一步,也是它与 OpenGL 最本质的区别。

简单来说:在 OpenGL 中,驱动程序帮你“隐式”处理了所有等待;而在 Vulkan 中,你必须通过这些“原语”显式地告诉 GPU:“这儿得等一下”、“那儿可以开始了”。

我们可以把它们看作是渲染流水线上的“交通信号灯”


VkFence (栅栏):CPU 与 GPU 的对话

定义VkFence 是用于主机 (CPU) 与设备 (GPU) 之间的同步。

  • 比喻:你是一个项目经理(CPU),GPU 是你的团队。你派团队去完成一个复杂的绘图任务(命令缓冲区)。当你问团队“活儿干完了吗?”时,你不能一直盯着他们看,你可以放一个“栅栏”在门口。如果团队还没干完,栅栏是关着的;团队干完了,会把栅栏打开。
  • 在代码中
    1
    vkWaitForFences(device, 1, &waitFences[currentFrame], VK_TRUE, UINT64_MAX);
    这里 CPU 在“等待栅栏”。如果 GPU 还没有处理完这一帧(比如还没画完上一帧的三角形),CPU 就会在这里“阻塞”住。这是为了确保我们在重复使用同一个命令缓冲区(Command Buffer)之前,它确实已经处于空闲状态了。

VkSemaphore (信号量):GPU 与 GPU 的对话

定义VkSemaphore 是用于图形队列内(GPU 操作之间)的同步。

  • 比喻:这就像是接力赛。厨师 A(图像获取)把盘子交给厨师 B(渲染操作),厨师 B 又把盘子交给厨师 C(显示操作)。
  • 代码中的两类信号量
    • **presentCompleteSemaphores**:告诉渲染队列:“显卡已经拿到要画的图像了,你可以开始渲染了。”(防止在图像还没准备好时就往上面画画)。
    • **renderCompleteSemaphores**:告诉显示系统:“我已经画好了,你可以把这张图扔到显示器上去了。”(防止在显卡还没画完时就把半成品呈现在屏幕上)。
  • 特点:它们完全是在 GPU 内部完成的,不需要 CPU 介入,也不会导致 CPU 阻塞,速度极快。

为什么需要 std::array (帧重叠策略)

你可能会问,为什么 VkFenceVkSemaphore 都要定义成 std::array,而且大小是 MAX_CONCURRENT_FRAMES

这就是我们在处理“**帧在飞行中 (Frames in Flight)**”的问题。

  • 没有数组时:如果只有一套同步对象,CPU 必须等 GPU 把第 0 帧画完,才能去录制第 1 帧。CPU 和 GPU 永远只能有一个在工作。
  • **有了数组 (MAX_CONCURRENT_FRAMES = 2)**:
    • 我们有两套同步逻辑(Fence A/Semaphore A 和 Fence B/Semaphore B)。
    • 当 GPU 正在处理第 0 帧(使用第一套同步逻辑)时,CPU 可以立即跳过“等待”,去录制第 1 帧(使用第二套同步逻辑)。
    • 这样 CPU 和 GPU 就实现了并行工作

总结:OpenGL 为什么不需要这个?

在 OpenGL 中,当你调用 glDrawArrays 时,OpenGL 驱动会在内部帮你做这些事:

  1. 如果上一帧还没渲染完,驱动会直接让你的 CPU “卡死”在 draw call 里等待。
  2. 驱动帮你管理了所有图像的交换和内存状态。

OpenGL 的代价是“透明的性能损失”:因为驱动为了保证不出错,往往采取极其保守的同步策略(强行让 CPU 等 GPU)。

Vulkan 的策略是“权限与责任并存”:它给了你手动控制这些“红绿灯”的权力。虽然代码变复杂了,但你现在可以精准地安排 CPU 和 GPU 谁该等、谁该动,从而榨干硬件的并行性能。

一句话总结:

  • Fence:CPU 问 GPU:“干完活了吗?(同步到主机)”
  • Semaphore:GPU 内部步骤之间说:“我准备好了,你开始吧。(同步在设备)”

选择同步对象,跟踪当前帧

1
2
// 为了选择正确的同步对象,我们需要跟踪当前帧
uint32_t currentFrame{0};

理解 currentFrame 变量是掌握 Vulkan “帧重叠(Frames in Flight)” 架构的最后一块拼图。

简单来说,**currentFrame 就是一个“指针”或“索引”**,它指向你当前正在处理的那一套资源。

核心逻辑:它是如何运作的?

因为你定义了 MAX_CONCURRENT_FRAMES = 2,你实际上在内存中为“两套”渲染资源各准备了一份拷贝。你可以把它们想象成两条生产线:

  • 第 0 号生产线:包含 commandBuffers[0], waitFences[0], uniformBuffers[0]
  • 第 1 号生产线:包含 commandBuffers[1], waitFences[1], uniformBuffers[1]

currentFrame 的作用就是:告诉 CPU 和 GPU,现在轮到哪条生产线工作了。

在代码中它是如何“跑”起来的?

render() 函数末尾,一行关键代码:

1
currentFrame = (currentFrame + 1) % MAX_CONCURRENT_FRAMES;

这就是一个经典的 “循环队列(Round Robin)” 逻辑:

  1. 初始时 currentFrame = 0
  2. 渲染完第 0 帧,代码执行 (0 + 1) % 2,结果是 1
  3. 下一次渲染,它就会去使用 [1] 号下标的所有资源。
  4. 渲染完第 1 帧,代码执行 (1 + 1) % 2,结果是 0
  5. 回到开头,重新使用 [0] 号资源。

通过这种方式,CPU 永远不会在渲染第 1 帧时,去覆盖掉 GPU 正在使用的第 0 帧的缓冲区数据。

一个非常容易混淆的点:currentFrame vs imageIndex

在阅读 Vulkan 代码时,很多初学者会把 currentFrameimageIndex 弄混,请务必区分它们:

  • currentFrame (逻辑帧索引):这是你自己定义的,用于管理你的 CPU 资源(同步原语、Uniform 缓冲区)。你是在控制“CPU 正在给哪一套同步对象打信号”。
  • **imageIndex (图像索引)**:这是 Vulkan 交换链 (Swapchain) 返回的。它是 GPU 告诉你的:“嘿,现在屏幕显示器准备好接收第 X 号图像数据了。”

总结它们的互动:
你用 currentFrame 锁定了当前的同步原语(Fence/Semaphore),然后用 imageIndex 锁定了当前的帧缓冲区(Framebuffer)。渲染时,这两者共同协作,将你的画面绘制到正确的图像上。

为什么要理解它?

如果你把 currentFrame 的更新逻辑删掉,或者写错,会发生什么?

  • 同步冲突:CPU 可能会在 GPU 还没用完资源时,就强行修改它。
  • 画面撕裂或崩溃:GPU 读取了 CPU 正在修改的数据,导致渲染结果出现闪烁或直接触发 Vulkan 验证层的错误。

currentFrame 就是你的“节奏器”。它确保了 CPU 和 GPU 能够在两条(或多条)并行轨道上交替工作,既不发生碰撞,又能满负荷运转。

初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
VulkanExample() : VulkanExampleBase() { 
// 构造函数:初始化当前类,并自动调用父类 VulkanExampleBase 的构造函数来准备 Vulkan 基础环境
title = "Vulkan Example - Basic indexed triangle";
// 设置操作系统窗口标题,方便在窗口管理器中识别此程序
settings.overlay = false;
// 禁用 UI 覆盖层 (ImGui)。在 Vulkan 中,渲染 UI 需引入额外的渲染通道 (Render Pass)、
// 流水线状态 (Pipeline State) 及描述符集 (Descriptor Sets)。关闭它可剥离辅助逻辑,
// 确保示例保留最纯粹的 Vulkan 核心架构 (如顶点着色与管线同步),方便学习与调试。
camera.type = Camera::CameraType::lookat;
// 指定相机控制模式为 'Look-at',通过位置(Position)和旋转(Rotation)来计算视图矩阵(View Matrix)
camera.setPosition(glm::vec3(0.0f, 0.0f, -2.5f));
// 设置摄像机位置:在世界空间 Z 轴后移 2.5 单位,确保三角形位于相机的视锥体前方
camera.setRotation(glm::vec3(0.0f));
// 设置摄像机旋转角度:初始化为 0,即摄像机默认朝向 Z 轴正方向
camera.setPerspective(60.0f, (float) width / (float) height, 1.0f, 256.0f);
// 配置透视投影矩阵:60度视场角(FOV)、使用当前窗口的宽高比、近裁剪面 1.0、远裁剪面 256.0
} // 此构造函数结束后,所有摄像机参数已准备好,随后将在 render() 中被上传到 UniformBuffer

内存异常判断

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 此函数用于请求支持我们请求的所有属性标志的设备内存类型(例如设备本地、主机可见)
// 如果成功,它将返回符合我们请求的内存属性的内存类型索引
// 这是必要的,因为实现可以提供任意数量具有不同内存属性的内存类型
// 您可以查看 https://vulkan.gpuinfo.org/ 了解不同内存配置的详细信息
uint32_t getMemoryTypeIndex(uint32_t typeBits, VkMemoryPropertyFlags properties) {
// 遍历此示例中使用的设备可用的所有内存类型
for (uint32_t i = 0; i < deviceMemoryProperties.memoryTypeCount; i++) {
if ((typeBits & 1) == 1) {
if ((deviceMemoryProperties.memoryTypes[i].propertyFlags & properties) ==
properties) {
return i;
}
}
typeBits >>= 1;
}

throw "Could not find a suitable memory type!";
}

硬件能力校验

它并不是在检查代码写错了没有,而是在检查:你要求的内存规格(Properties),这块 GPU 硬件是否真的给得起。

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
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
/*
* Vulkan Example - Basic indexed triangle rendering
*
* 注意:
* 这是一个“直面底层”的示例,旨在展示如何让 Vulkan 运行并显示内容
* 与其它示例不同,此示例不会使用辅助函数或初始化器
* (除了少数情况,例如交换链设置)
*
* Copyright (C) 2016-2024 by Sascha Willems - www.saschawillems.de
*
* This code is licensed under the MIT license (MIT) (http://opensource.org/licenses/MIT)
*/

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <assert.h>
#include <fstream>
#include <vector>
#include <exception>

#define GLM_FORCE_RADIANS
#define GLM_FORCE_DEPTH_ZERO_TO_ONE

#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>

#include <vulkan/vulkan.h>
#include "vulkanexamplebase.h"

// 我们希望让 GPU 和 CPU 保持忙碌。为了做到这一点,我们可以在上一个命令缓冲区仍在执行时就开始构建一个新的
// 此数字定义了可以同时处理的帧数
// 增加此数字可能会提高性能,但也会引入额外的延迟
#define MAX_CONCURRENT_FRAMES 2

class VulkanExample : public VulkanExampleBase {
public:
// 三角形顶点布局数据结构
struct Vertex {
float position[3];
float color[3];
};

// 顶点缓冲区和属性
struct {
VkDeviceMemory memory{VK_NULL_HANDLE}; // 此缓冲区的设备内存句柄
VkBuffer buffer{VK_NULL_HANDLE}; // 内存所绑定的 Vulkan 缓冲区对象的句柄
} vertices;

// 索引缓冲区
struct {
VkDeviceMemory memory{VK_NULL_HANDLE};
VkBuffer buffer{VK_NULL_HANDLE};
uint32_t count{0};
} indices;

// Uniform 缓冲区块对象
struct UniformBuffer {
VkDeviceMemory memory{VK_NULL_HANDLE};
VkBuffer buffer{VK_NULL_HANDLE};
// 描述符集存储了绑定到着色器中绑定点的资源
// 它将不同着色器的绑定点与用于这些绑定的缓冲区和图像连接起来
VkDescriptorSet descriptorSet{VK_NULL_HANDLE};
// 我们保留指向映射缓冲区的指针,以便可以通过 memcpy 轻松更新其内容
uint8_t *mapped{nullptr};
};
// 我们每帧使用一个 UBO,这样我们就可以进行帧重叠,并确保 Uniform 在仍在使用时不会被更新
std::array<UniformBuffer, MAX_CONCURRENT_FRAMES> uniformBuffers;

// 为了简单起见,我们使用与着色器中相同的 uniform 块布局:
//
// layout(set = 0, binding = 0) uniform UBO
// {
// mat4 projectionMatrix;
// mat4 modelMatrix;
// mat4 viewMatrix;
// } ubo;
//
// 这样我们就可以直接将 ubo 数据 memcopy 到 ubo 中
// 注意:应该使用与 GPU 对齐的数据类型,以避免手动填充 (vec4, mat4)
struct ShaderData {
glm::mat4 projectionMatrix;
glm::mat4 modelMatrix;
glm::mat4 viewMatrix;
};

// 流水线布局被流水线用于访问描述符集
// 它定义了流水线所使用的着色器阶段与着色器资源之间的接口(不绑定任何实际数据)
// 只要接口匹配,流水线布局就可以在多个流水线之间共享
VkPipelineLayout pipelineLayout{VK_NULL_HANDLE};

// 流水线(通常称为“流水线状态对象”)用于烘焙影响流水线的所有状态
// 虽然在 OpenGL 中几乎可以随时更改每个状态,但 Vulkan 要求预先布置图形(和计算)流水线状态
// 因此,对于每种非动态流水线状态组合,都需要一个新的流水线(有一些例外这里不讨论)
// 尽管这增加了一个提前规划的新维度,但对于驱动程序的性能优化来说,这是一个很好的机会
VkPipeline pipeline{VK_NULL_HANDLE};

// 描述符集布局描述了着色器绑定布局(而不实际引用描述符)
// 像流水线布局一样,它基本上是一个蓝图,只要它们的布局匹配,就可以与不同的描述符集一起使用
VkDescriptorSetLayout descriptorSetLayout{VK_NULL_HANDLE};

// 同步原语
// 同步是 Vulkan 的一个重要概念,OpenGL 几乎将其隐藏了。正确处理这一点对于使用 Vulkan 至关重要。

// 信号量用于协调图形队列内的操作并确保正确的命令顺序
std::array<VkSemaphore, MAX_CONCURRENT_FRAMES> presentCompleteSemaphores{};
std::array<VkSemaphore, MAX_CONCURRENT_FRAMES> renderCompleteSemaphores{};

VkCommandPool commandPool{VK_NULL_HANDLE};
std::array<VkCommandBuffer, MAX_CONCURRENT_FRAMES> commandBuffers{};
std::array<VkFence, MAX_CONCURRENT_FRAMES> waitFences{};

// 为了选择正确的同步对象,我们需要跟踪当前帧
uint32_t currentFrame{0};

VulkanExample() : VulkanExampleBase() {
// 构造函数:初始化当前类,并自动调用父类 VulkanExampleBase 的构造函数来准备 Vulkan 基础环境
title = "Vulkan Example - Basic indexed triangle";
// 设置操作系统窗口标题,方便在窗口管理器中识别此程序
settings.overlay = false;
// 禁用 UI 覆盖层 (ImGui)。在 Vulkan 中,渲染 UI 需引入额外的渲染通道 (Render Pass)、
// 流水线状态 (Pipeline State) 及描述符集 (Descriptor Sets)。关闭它可剥离辅助逻辑,
// 确保示例保留最纯粹的 Vulkan 核心架构 (如顶点着色与管线同步),方便学习与调试。
camera.type = Camera::CameraType::lookat;
// 指定相机控制模式为 'Look-at',通过位置(Position)和旋转(Rotation)来计算视图矩阵(View Matrix)
camera.setPosition(glm::vec3(0.0f, 0.0f, -2.5f));
// 设置摄像机位置:在世界空间 Z 轴后移 2.5 单位,确保三角形位于相机的视锥体前方
camera.setRotation(glm::vec3(0.0f));
// 设置摄像机旋转角度:初始化为 0,即摄像机默认朝向 Z 轴正方向
camera.setPerspective(60.0f, (float) width / (float) height, 1.0f, 256.0f);
// 配置透视投影矩阵:60度视场角(FOV)、使用当前窗口的宽高比、近裁剪面 1.0、远裁剪面 256.0
} // 此构造函数结束后,所有摄像机参数已准备好,随后将在 render() 中被上传到 UniformBuffer

~VulkanExample() {
// 清理使用的 Vulkan 资源
// 注意:继承的析构函数会清理存储在基类中的资源
vkDestroyPipeline(device, pipeline, nullptr);

vkDestroyPipelineLayout(device, pipelineLayout, nullptr);
vkDestroyDescriptorSetLayout(device, descriptorSetLayout, nullptr);

vkDestroyBuffer(device, vertices.buffer, nullptr);
vkFreeMemory(device, vertices.memory, nullptr);

vkDestroyBuffer(device, indices.buffer, nullptr);
vkFreeMemory(device, indices.memory, nullptr);

vkDestroyCommandPool(device, commandPool, nullptr);

for (uint32_t i = 0; i < MAX_CONCURRENT_FRAMES; i++) {
vkDestroyFence(device, waitFences[i], nullptr);
vkDestroySemaphore(device, presentCompleteSemaphores[i], nullptr);
vkDestroySemaphore(device, renderCompleteSemaphores[i], nullptr);
vkDestroyBuffer(device, uniformBuffers[i].buffer, nullptr);
vkFreeMemory(device, uniformBuffers[i].memory, nullptr);
}
}

// 此函数用于请求支持我们请求的所有属性标志的设备内存类型(例如设备本地、主机可见)
// 如果成功,它将返回符合我们请求的内存属性的内存类型索引
// 这是必要的,因为实现可以提供任意数量具有不同内存属性的内存类型
// 您可以查看 https://vulkan.gpuinfo.org/ 了解不同内存配置的详细信息
uint32_t getMemoryTypeIndex(uint32_t typeBits, VkMemoryPropertyFlags properties) {
// 遍历此示例中使用的设备可用的所有内存类型
for (uint32_t i = 0; i < deviceMemoryProperties.memoryTypeCount; i++) {
if ((typeBits & 1) == 1) {
if ((deviceMemoryProperties.memoryTypes[i].propertyFlags & properties) ==
properties) {
return i;
}
}
typeBits >>= 1;
}

throw "Could not find a suitable memory type!";
}

// 创建此示例中使用的每帧(运行中)Vulkan 同步原语
void createSynchronizationPrimitives() {
// 信号量用于队列内的正确命令排序
VkSemaphoreCreateInfo semaphoreCI{};
semaphoreCI.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO;

// 栅栏用于在主机上检查绘制命令缓冲区的完成情况
VkFenceCreateInfo fenceCI{};
fenceCI.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO;
// 创建处于信号状态的栅栏(这样我们就不会在每个命令缓冲区的第一次渲染时等待)
fenceCI.flags = VK_FENCE_CREATE_SIGNALED_BIT;

for (uint32_t i = 0; i < MAX_CONCURRENT_FRAMES; i++) {
// 信号量用于确保在再次开始提交之前图像呈现完成
VK_CHECK_RESULT(vkCreateSemaphore(device, &semaphoreCI, nullptr,
&presentCompleteSemaphores[i]));
// 信号量用于确保在将图像提交到队列之前,所有提交的命令都已完成
VK_CHECK_RESULT(
vkCreateSemaphore(device, &semaphoreCI, nullptr, &renderCompleteSemaphores[i]));

// 栅栏用于确保命令缓冲区在再次使用之前已完成执行
VK_CHECK_RESULT(vkCreateFence(device, &fenceCI, nullptr, &waitFences[i]));
}
}

void createCommandBuffers() {
// 1. 创建命令池 (Command Pool)
// 命令池是命令缓冲区的“内存管理器”。
VkCommandPoolCreateInfo commandPoolCI{};
commandPoolCI.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO;

// 命令池必须绑定到特定的队列族 (Queue Family)。
// 这里的队列族必须与你创建的渲染队列(如图形队列)一致,否则 GPU 无法执行此池中的命令。
commandPoolCI.queueFamilyIndex = swapChain.queueNodeIndex;

// !!! 关键标志位 !!!
// VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT 允许我们重置 (Reset) 池中的缓冲区。
// 如果不设置此项,缓冲区一旦分配就很难更改,重置位使得我们可以在每一帧重复利用现有的缓冲区,
// 而不需要频繁销毁和重新创建,这能极大提升 CPU 性能。
commandPoolCI.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT;

// 创建命令池
VK_CHECK_RESULT(vkCreateCommandPool(device, &commandPoolCI, nullptr, &commandPool));

// 2. 分配命令缓冲区 (Command Buffer)
// 从刚才创建的命令池中,为“每一帧”分配一个对应的缓冲区。
VkCommandBufferAllocateInfo cmdBufAllocateInfo = vks::initializers::commandBufferAllocateInfo(
commandPool,
VK_COMMAND_BUFFER_LEVEL_PRIMARY, // PRIMARY 类型:可直接提交到队列,不同于 Secondary buffer (被调用者)
MAX_CONCURRENT_FRAMES); // 分配数量:与“最大并发帧数”一致

// 执行内存分配
VK_CHECK_RESULT(
vkAllocateCommandBuffers(device, &cmdBufAllocateInfo, commandBuffers.data()));
}

// 准备用于索引三角形的顶点和索引缓冲区
// 同时使用暂存(staging)将它们上传到设备本地内存,并初始化顶点输入和属性绑定以匹配顶点着色器
void createVertexBuffer() {
// 关于 Vulkan 中内存管理的说明:
// 这是一个非常复杂的主题。虽然在示例应用程序中进行小的独立内存分配是可以的,
// 但在实际应用中不应这样做,在实际应用中,您应该一次分配大块内存。

// 设置顶点
std::vector <Vertex> vertexBuffer{
{{1.0f, 1.0f, 0.0f}, {1.0f, 0.0f, 0.0f}},
{{-1.0f, 1.0f, 0.0f}, {0.0f, 1.0f, 0.0f}},
{{0.0f, -1.0f, 0.0f}, {0.0f, 0.0f, 1.0f}}
};
uint32_t vertexBufferSize = static_cast<uint32_t>(vertexBuffer.size()) * sizeof(Vertex);

// 设置索引
std::vector <uint32_t> indexBuffer{0, 1, 2};
indices.count = static_cast<uint32_t>(indexBuffer.size());
uint32_t indexBufferSize = indices.count * sizeof(uint32_t);

VkMemoryAllocateInfo memAlloc{};
memAlloc.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
VkMemoryRequirements memReqs;

// 像顶点和索引缓冲区这样的静态数据应该存储在设备内存中,以便 GPU 进行优化(且最快)的访问
//
// 为了实现这一点,我们使用所谓的“暂存缓冲区(staging buffers)”:
// - 创建一个对主机可见(并且可以映射)的缓冲区
// - 将数据复制到此缓冲区
// - 创建另一个大小相同的设备本地(VRAM)缓冲区
// - 使用命令缓冲区将数据从主机复制到设备
// - 删除主机可见的(暂存)缓冲区
// - 使用设备本地缓冲区进行渲染
//
// 注意:在主机 (CPU) 和 GPU 共享相同内存的统一内存架构上,暂存是不必要的
// 为了使此示例易于理解,这里没有进行相关的检查

struct StagingBuffer {
VkDeviceMemory memory;
VkBuffer buffer;
};

struct {
StagingBuffer vertices;
StagingBuffer indices;
} stagingBuffers{};

void *data;

// 顶点缓冲区
VkBufferCreateInfo vertexBufferInfoCI{};
vertexBufferInfoCI.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
vertexBufferInfoCI.size = vertexBufferSize;
// 缓冲区用作复制源
vertexBufferInfoCI.usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT;
// 创建一个主机可见的缓冲区来复制顶点数据(暂存缓冲区)
VK_CHECK_RESULT(vkCreateBuffer(device, &vertexBufferInfoCI, nullptr,
&stagingBuffers.vertices.buffer));
vkGetBufferMemoryRequirements(device, stagingBuffers.vertices.buffer, &memReqs);
memAlloc.allocationSize = memReqs.size;
// 请求一种主机可见的内存类型,可用于将数据复制到其中
// 同时请求它是一致的(coherent),这样在取消映射缓冲区后,写入内容对 GPU 是立即可见的
memAlloc.memoryTypeIndex = getMemoryTypeIndex(memReqs.memoryTypeBits,
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT);
VK_CHECK_RESULT(
vkAllocateMemory(device, &memAlloc, nullptr, &stagingBuffers.vertices.memory));
// 映射并复制
VK_CHECK_RESULT(
vkMapMemory(device, stagingBuffers.vertices.memory, 0, memAlloc.allocationSize, 0,
&data));
memcpy(data, vertexBuffer.data(), vertexBufferSize);
vkUnmapMemory(device, stagingBuffers.vertices.memory);
VK_CHECK_RESULT(vkBindBufferMemory(device, stagingBuffers.vertices.buffer,
stagingBuffers.vertices.memory, 0));

// 创建一个设备本地缓冲区,(主机本地的)顶点数据将被复制到其中,并用于渲染
vertexBufferInfoCI.usage =
VK_BUFFER_USAGE_VERTEX_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT;
VK_CHECK_RESULT(vkCreateBuffer(device, &vertexBufferInfoCI, nullptr, &vertices.buffer));
vkGetBufferMemoryRequirements(device, vertices.buffer, &memReqs);
memAlloc.allocationSize = memReqs.size;
memAlloc.memoryTypeIndex = getMemoryTypeIndex(memReqs.memoryTypeBits,
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
VK_CHECK_RESULT(vkAllocateMemory(device, &memAlloc, nullptr, &vertices.memory));
VK_CHECK_RESULT(vkBindBufferMemory(device, vertices.buffer, vertices.memory, 0));

// 索引缓冲区
VkBufferCreateInfo indexbufferCI{};
indexbufferCI.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
indexbufferCI.size = indexBufferSize;
indexbufferCI.usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT;
// 将索引数据复制到对主机可见的缓冲区(暂存缓冲区)
VK_CHECK_RESULT(
vkCreateBuffer(device, &indexbufferCI, nullptr, &stagingBuffers.indices.buffer));
vkGetBufferMemoryRequirements(device, stagingBuffers.indices.buffer, &memReqs);
memAlloc.allocationSize = memReqs.size;
memAlloc.memoryTypeIndex = getMemoryTypeIndex(memReqs.memoryTypeBits,
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT);
VK_CHECK_RESULT(
vkAllocateMemory(device, &memAlloc, nullptr, &stagingBuffers.indices.memory));
VK_CHECK_RESULT(
vkMapMemory(device, stagingBuffers.indices.memory, 0, indexBufferSize, 0, &data));
memcpy(data, indexBuffer.data(), indexBufferSize);
vkUnmapMemory(device, stagingBuffers.indices.memory);
VK_CHECK_RESULT(vkBindBufferMemory(device, stagingBuffers.indices.buffer,
stagingBuffers.indices.memory, 0));

// 创建仅对设备可见的目标缓冲区
indexbufferCI.usage = VK_BUFFER_USAGE_INDEX_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT;
VK_CHECK_RESULT(vkCreateBuffer(device, &indexbufferCI, nullptr, &indices.buffer));
vkGetBufferMemoryRequirements(device, indices.buffer, &memReqs);
memAlloc.allocationSize = memReqs.size;
memAlloc.memoryTypeIndex = getMemoryTypeIndex(memReqs.memoryTypeBits,
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
VK_CHECK_RESULT(vkAllocateMemory(device, &memAlloc, nullptr, &indices.memory));
VK_CHECK_RESULT(vkBindBufferMemory(device, indices.buffer, indices.memory, 0));

// 缓冲区复制必须提交到队列,因此我们需要一个命令缓冲区
// 注意:某些设备提供专用的传输队列(仅设置了传输位),在进行大量复制时可能会更快
VkCommandBuffer copyCmd;

VkCommandBufferAllocateInfo cmdBufAllocateInfo{};
cmdBufAllocateInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
cmdBufAllocateInfo.commandPool = commandPool;
cmdBufAllocateInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
cmdBufAllocateInfo.commandBufferCount = 1;
VK_CHECK_RESULT(vkAllocateCommandBuffers(device, &cmdBufAllocateInfo, &copyCmd));

VkCommandBufferBeginInfo cmdBufInfo = vks::initializers::commandBufferBeginInfo();
VK_CHECK_RESULT(vkBeginCommandBuffer(copyCmd, &cmdBufInfo));
// 将缓冲区区域复制放入命令缓冲区
VkBufferCopy copyRegion{};
// 顶点缓冲区
copyRegion.size = vertexBufferSize;
vkCmdCopyBuffer(copyCmd, stagingBuffers.vertices.buffer, vertices.buffer, 1, &copyRegion);
// 索引缓冲区
copyRegion.size = indexBufferSize;
vkCmdCopyBuffer(copyCmd, stagingBuffers.indices.buffer, indices.buffer, 1, &copyRegion);
VK_CHECK_RESULT(vkEndCommandBuffer(copyCmd));

// 将命令缓冲区提交到队列以完成复制
VkSubmitInfo submitInfo{};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &copyCmd;

// 创建栅栏以确保命令缓冲区已完成执行
VkFenceCreateInfo fenceCI{};
fenceCI.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO;
fenceCI.flags = 0;
VkFence fence;
VK_CHECK_RESULT(vkCreateFence(device, &fenceCI, nullptr, &fence));

// 提交到队列
VK_CHECK_RESULT(vkQueueSubmit(queue, 1, &submitInfo, fence));
// 等待栅栏发出信号,表明命令缓冲区已完成执行
VK_CHECK_RESULT(vkWaitForFences(device, 1, &fence, VK_TRUE, DEFAULT_FENCE_TIMEOUT));

vkDestroyFence(device, fence, nullptr);
vkFreeCommandBuffers(device, commandPool, 1, &copyCmd);

// 销毁暂存缓冲区
// 注意:在复制提交并执行之前,不得删除暂存缓冲区
vkDestroyBuffer(device, stagingBuffers.vertices.buffer, nullptr);
vkFreeMemory(device, stagingBuffers.vertices.memory, nullptr);
vkDestroyBuffer(device, stagingBuffers.indices.buffer, nullptr);
vkFreeMemory(device, stagingBuffers.indices.memory, nullptr);
}

// 描述符是从池中分配的,该池告诉实现我们将(最多)使用多少个以及什么类型的描述符
void createDescriptorPool() {
// 我们需要告诉 API 每个类型的最大请求描述符数量
VkDescriptorPoolSize descriptorTypeCounts[1]{};
// 此示例仅使用一种描述符类型(Uniform 缓冲区)
descriptorTypeCounts[0].type = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
// 我们每帧有一个缓冲区(因此也有一个描述符)
descriptorTypeCounts[0].descriptorCount = MAX_CONCURRENT_FRAMES;
// 对于其他类型,您需要在类型计数列表中添加新条目
// 例如,对于两个组合图像采样器:
// typeCounts[1].type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
// typeCounts[1].descriptorCount = 2;

// 创建全局描述符池
// 此示例中使用的所有描述符都是从此池中分配的
VkDescriptorPoolCreateInfo descriptorPoolCI{};
descriptorPoolCI.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO;
descriptorPoolCI.pNext = nullptr;
descriptorPoolCI.poolSizeCount = 1;
descriptorPoolCI.pPoolSizes = descriptorTypeCounts;
// 设置可以从此池请求的最大描述符集数量(超出此限制的请求将导致错误)
// 我们的示例将为每帧的每个 Uniform 缓冲区创建一个集
descriptorPoolCI.maxSets = MAX_CONCURRENT_FRAMES;
VK_CHECK_RESULT(
vkCreateDescriptorPool(device, &descriptorPoolCI, nullptr, &descriptorPool));
}

void createDescriptorSetLayout() {
// 1. 定义具体的“绑定(Binding)”规范
// VkDescriptorSetLayoutBinding 描述了接口中的一个“插槽”
VkDescriptorSetLayoutBinding layoutBinding{};

// 指定类型:这里是 VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER (UBO)
// 意味着着色器会在这里找一块由 CPU 更新的全局数据(如变换矩阵)
layoutBinding.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;

// 指定数量:1 表示这个绑定点对应一个单独的缓冲区
// 也可以是数组(例如多个 UBO),这里设为 1 即可
layoutBinding.descriptorCount = 1;

// 指定阶段:VK_SHADER_STAGE_VERTEX_BIT
// 这一行非常重要!它告诉驱动程序:这个数据只会被顶点着色器使用。
// 驱动程序利用这个信息进行优化,它不会把数据推送到片元着色器(Fragment Shader)中。
layoutBinding.stageFlags = VK_SHADER_STAGE_VERTEX_BIT;

// 纹理采样器相关(此处为 nullptr,因为我们目前只用 UBO)
layoutBinding.pImmutableSamplers = nullptr;

// 2. 创建布局的描述信息
// 这将所有的“绑定规范”打包,准备告诉驱动我们要创建一个布局对象
VkDescriptorSetLayoutCreateInfo descriptorLayoutCI{};
descriptorLayoutCI.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO;
descriptorLayoutCI.pNext = nullptr;

// 绑定点总数:这里我们只有一个 UBO,所以是 1
descriptorLayoutCI.bindingCount = 1;

// 指向上面定义的数组(绑定列表)
descriptorLayoutCI.pBindings = &layoutBinding;

// 3. 执行创建
// 这一步在内存中构建了一个“蓝图”对象,后续它将作为 Pipeline 创建的一部分
VK_CHECK_RESULT(vkCreateDescriptorSetLayout(device, &descriptorLayoutCI, nullptr, &descriptorSetLayout));
}

// 着色器使用指向我们 Uniform 缓冲区的描述符集来访问数据
// 描述符集使用了上面创建的描述符集布局
void createDescriptorSets() {
// 从全局描述符池中为每帧分配一个描述符集
for (uint32_t i = 0; i < MAX_CONCURRENT_FRAMES; i++) {
VkDescriptorSetAllocateInfo allocInfo{};
allocInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO;
allocInfo.descriptorPool = descriptorPool;
allocInfo.descriptorSetCount = 1;
allocInfo.pSetLayouts = &descriptorSetLayout;
VK_CHECK_RESULT(
vkAllocateDescriptorSets(device, &allocInfo, &uniformBuffers[i].descriptorSet));

// 更新确定着色器绑定点的描述符集
// 对于着色器中使用的每个绑定点,都需要有一个与该绑定点匹配的描述符集
VkWriteDescriptorSet writeDescriptorSet{};

// 缓冲区的信息使用描述符信息结构传递
VkDescriptorBufferInfo bufferInfo{};
bufferInfo.buffer = uniformBuffers[i].buffer;
bufferInfo.range = sizeof(ShaderData);

// 绑定 0:Uniform 缓冲区
writeDescriptorSet.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
writeDescriptorSet.dstSet = uniformBuffers[i].descriptorSet;
writeDescriptorSet.descriptorCount = 1;
writeDescriptorSet.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
writeDescriptorSet.pBufferInfo = &bufferInfo;
writeDescriptorSet.dstBinding = 0;
vkUpdateDescriptorSets(device, 1, &writeDescriptorSet, 0, nullptr);
}
}

// 创建我们帧缓冲区使用的深度(和模板)缓冲区附件
// 注意:基类中的虚函数重写,并在 VulkanExampleBase::prepare 中被调用
void setupDepthStencil() {
// 创建一个用作深度模板附件的最佳图像
VkImageCreateInfo imageCI{};
imageCI.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
imageCI.imageType = VK_IMAGE_TYPE_2D;
imageCI.format = depthFormat;
// 使用示例的高度和宽度
imageCI.extent = {width, height, 1};
imageCI.mipLevels = 1;
imageCI.arrayLayers = 1;
imageCI.samples = VK_SAMPLE_COUNT_1_BIT;
imageCI.tiling = VK_IMAGE_TILING_OPTIMAL;
imageCI.usage = VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT;
imageCI.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
VK_CHECK_RESULT(vkCreateImage(device, &imageCI, nullptr, &depthStencil.image));

// 为图像分配内存(设备本地)并将其绑定到我们的图像
VkMemoryAllocateInfo memAlloc{};
memAlloc.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
VkMemoryRequirements memReqs;
vkGetImageMemoryRequirements(device, depthStencil.image, &memReqs);
memAlloc.allocationSize = memReqs.size;
memAlloc.memoryTypeIndex = getMemoryTypeIndex(memReqs.memoryTypeBits,
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
VK_CHECK_RESULT(vkAllocateMemory(device, &memAlloc, nullptr, &depthStencil.memory));
VK_CHECK_RESULT(vkBindImageMemory(device, depthStencil.image, depthStencil.memory, 0));

// 为深度模板图像创建视图
// 在 Vulkan 中,图像不能直接访问,而是通过子资源范围描述的视图访问
// 这允许同一个图像有多个具有不同范围的视图(例如,用于不同的层)
VkImageViewCreateInfo depthStencilViewCI{};
depthStencilViewCI.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO;
depthStencilViewCI.viewType = VK_IMAGE_VIEW_TYPE_2D;
depthStencilViewCI.format = depthFormat;
depthStencilViewCI.subresourceRange = {};
depthStencilViewCI.subresourceRange.aspectMask = VK_IMAGE_ASPECT_DEPTH_BIT;
// 模板方面(Stencil aspect)仅应在深度+模板格式上设置 (VK_FORMAT_D16_UNORM_S8_UINT..VK_FORMAT_D32_SFLOAT_S8_UINT)
if (depthFormat >= VK_FORMAT_D16_UNORM_S8_UINT) {
depthStencilViewCI.subresourceRange.aspectMask |= VK_IMAGE_ASPECT_STENCIL_BIT;
}
depthStencilViewCI.subresourceRange.baseMipLevel = 0;
depthStencilViewCI.subresourceRange.levelCount = 1;
depthStencilViewCI.subresourceRange.baseArrayLayer = 0;
depthStencilViewCI.subresourceRange.layerCount = 1;
depthStencilViewCI.image = depthStencil.image;
VK_CHECK_RESULT(
vkCreateImageView(device, &depthStencilViewCI, nullptr, &depthStencil.view));
}

// 为每个交换链图像创建一个帧缓冲区
// 注意:基类中的虚函数重写,并在 VulkanExampleBase::prepare 中被调用
void setupFrameBuffer() {
// 为交换链中的每个图像创建一个帧缓冲区
frameBuffers.resize(swapChain.images.size());
for (size_t i = 0; i < frameBuffers.size(); i++) {
std::array<VkImageView, 2> attachments{};
// 颜色附件是交换链图像的视图
attachments[0] = swapChain.imageViews[i];
// 深度/模板附件对于所有帧缓冲区都是相同的,这是基于深度在当前 GPU 上的工作方式
attachments[1] = depthStencil.view;

VkFramebufferCreateInfo frameBufferCI{};
frameBufferCI.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO;
// 所有帧缓冲区使用相同的渲染通道设置
frameBufferCI.renderPass = renderPass;
frameBufferCI.attachmentCount = static_cast<uint32_t>(attachments.size());
frameBufferCI.pAttachments = attachments.data();
frameBufferCI.width = width;
frameBufferCI.height = height;
frameBufferCI.layers = 1;
// 创建帧缓冲区
VK_CHECK_RESULT(vkCreateFramebuffer(device, &frameBufferCI, nullptr, &frameBuffers[i]));
}
}

// 渲染通道设置
// 渲染通道(Render passes)是 Vulkan 中的一个新概念。它们描述了渲染过程中使用的附件,并且可能包含具有附件依赖关系的多个子通道
// 这允许驱动程序预先知道渲染的样子,并且是一个很好的优化机会,特别是在基于瓦片的渲染器上(具有多个子通道)
// 使用子通道依赖关系还会为所使用的附件添加隐式布局转换,因此我们不需要添加显式的图像内存屏障来转换它们
// 注意:基类中的虚函数重写,并在 VulkanExampleBase::prepare 中被调用
void setupRenderPass() {
// 此示例将使用一个带有单个子通道的渲染通道

// 此渲染通道使用的附件描述符
std::array<VkAttachmentDescription, 2> attachments{};

// 颜色附件
attachments[0].format = swapChain.colorFormat; // 使用交换链选择的颜色格式
attachments[0].samples = VK_SAMPLE_COUNT_1_BIT; // 在此示例中我们不使用多重采样
attachments[0].loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; // 在渲染通道开始时清除此附件
attachments[0].storeOp = VK_ATTACHMENT_STORE_OP_STORE; // 在渲染通道结束后保留其内容(用于显示)
attachments[0].stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; // 我们不使用模板,因此不关心加载
attachments[0].stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; // 存储也一样
attachments[0].initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; // 渲染通道开始时的布局。初始并不重要,所以我们使用未定义(undefined)
attachments[0].finalLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; // 渲染通道结束后附件转换到的布局
// 因为我们想将颜色缓冲区呈现给交换链,所以我们转换为 PRESENT_KHR

// 深度附件
attachments[1].format = depthFormat; // 在示例基类中选择了合适的深度格式
attachments[1].samples = VK_SAMPLE_COUNT_1_BIT;
attachments[1].loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; // 在第一个子通道开始时清除深度
attachments[1].storeOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; // 渲染通道结束后我们不需要深度(DONT_CARE 可能会带来更好的性能)
attachments[1].stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; // 无模板
attachments[1].stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; // 无模板
attachments[1].initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; // 渲染通道开始时的布局。初始并不重要,所以我们使用未定义
attachments[1].finalLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL; // 转换为深度/模板附件

// 设置附件引用
VkAttachmentReference colorReference{};
colorReference.attachment = 0; // 附件 0 是颜色
colorReference.layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; // 子通道期间用作颜色的附件布局

VkAttachmentReference depthReference{};
depthReference.attachment = 1; // 附件 1 是颜色
depthReference.layout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL; // 子通道期间用作深度/模板的附件

// 设置单个子通道引用
VkSubpassDescription subpassDescription{};
subpassDescription.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS;
subpassDescription.colorAttachmentCount = 1; // 子通道使用一个颜色附件
subpassDescription.pColorAttachments = &colorReference; // 引用槽 0 中的颜色附件
subpassDescription.pDepthStencilAttachment = &depthReference; // 引用槽 1 中的深度附件
subpassDescription.inputAttachmentCount = 0; // 输入附件可用于从前一个子通道的内容中采样
subpassDescription.pInputAttachments = nullptr; // (此示例不使用输入附件)
subpassDescription.preserveAttachmentCount = 0; // 保留附件可用于在子通道中循环(并保留)附件
subpassDescription.pPreserveAttachments = nullptr; // (此示例不使用保留附件)
subpassDescription.pResolveAttachments = nullptr; // 解析附件在子通道结束时解析,可用于例如多重采样

// 设置子通道依赖关系
// 这些将添加附件描述中指定的隐式附件布局转换
// 实际使用布局通过附件引用中指定的布局保留
// 每个子通道依赖关系将在 srcStageMask、dstStageMask、srcAccessMask、dstAccessMask(以及设置了 dependencyFlags)
// 描述的源和目标子通道之间引入内存和执行依赖关系
// 注意:VK_SUBPASS_EXTERNAL 是一个特殊的常量,指的是在实际渲染通道之外执行的所有命令)
std::array<VkSubpassDependency, 2> dependencies{};

// 对深度和颜色附件进行从最终布局到初始布局的转换
// 深度附件
dependencies[0].srcSubpass = VK_SUBPASS_EXTERNAL;
dependencies[0].dstSubpass = 0;
dependencies[0].srcStageMask = VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT |
VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT;
dependencies[0].dstStageMask = VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT |
VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT;
dependencies[0].srcAccessMask = VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT;
dependencies[0].dstAccessMask = VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT |
VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_READ_BIT;
dependencies[0].dependencyFlags = 0;
// 颜色附件
dependencies[1].srcSubpass = VK_SUBPASS_EXTERNAL;
dependencies[1].dstSubpass = 0;
dependencies[1].srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
dependencies[1].dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
dependencies[1].srcAccessMask = 0;
dependencies[1].dstAccessMask =
VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT | VK_ACCESS_COLOR_ATTACHMENT_READ_BIT;
dependencies[1].dependencyFlags = 0;

// 创建实际的渲染通道
VkRenderPassCreateInfo renderPassCI{};
renderPassCI.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO;
renderPassCI.attachmentCount = static_cast<uint32_t>(attachments.size()); // 此渲染通道使用的附件数量
renderPassCI.pAttachments = attachments.data(); // 渲染通道使用的附件描述
renderPassCI.subpassCount = 1; // 此示例中我们仅使用一个子通道
renderPassCI.pSubpasses = &subpassDescription; // 该子通道的描述
renderPassCI.dependencyCount = static_cast<uint32_t>(dependencies.size()); // 子通道依赖关系的数量
renderPassCI.pDependencies = dependencies.data(); // 渲染通道使用的子通道依赖关系
VK_CHECK_RESULT(vkCreateRenderPass(device, &renderPassCI, nullptr, &renderPass));
}


// Vulkan 从名为 SPIR-V 的即时二进制表示形式加载着色器
// 着色器使用参考 glslang 编译器从例如 GLSL 离线编译而来
// 此函数从二进制文件加载此类着色器并返回着色器模块结构
VkShaderModule loadSPIRVShader(std::string filename) {
size_t shaderSize;
char *shaderCode{nullptr};

#if defined(__ANDROID__)
// 从压缩的 asset 加载着色器
AAsset* asset = AAssetManager_open(androidApp->activity->assetManager, filename.c_str(), AASSET_MODE_STREAMING);
assert(asset);
shaderSize = AAsset_getLength(asset);
assert(shaderSize > 0);

shaderCode = new char[shaderSize];
AAsset_read(asset, shaderCode, shaderSize);
AAsset_close(asset);
#else
std::ifstream is(filename, std::ios::binary | std::ios::in | std::ios::ate);

if (is.is_open()) {
shaderSize = is.tellg();
is.seekg(0, std::ios::beg);
// 将文件内容复制到缓冲区
shaderCode = new char[shaderSize];
is.read(shaderCode, shaderSize);
is.close();
assert(shaderSize > 0);
}
#endif
if (shaderCode) {
// 创建一个将用于流水线创建的新着色器模块
VkShaderModuleCreateInfo shaderModuleCI{};
shaderModuleCI.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO;
shaderModuleCI.codeSize = shaderSize;
shaderModuleCI.pCode = (uint32_t *) shaderCode;

VkShaderModule shaderModule;
VK_CHECK_RESULT(vkCreateShaderModule(device, &shaderModuleCI, nullptr, &shaderModule));

delete[] shaderCode;

return shaderModule;
} else {
std::cerr << "Error: Could not open shader file \"" << filename << "\"" << std::endl;
return VK_NULL_HANDLE;
}
}

void createPipelines() {
// 创建用于生成基于此描述符集布局的渲染流水线的流水线布局
// 在更复杂的场景中,您将有不同的流水线布局用于可以重用的不同描述符集布局
VkPipelineLayoutCreateInfo pipelineLayoutCI{};
pipelineLayoutCI.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
pipelineLayoutCI.pNext = nullptr;
pipelineLayoutCI.setLayoutCount = 1;
pipelineLayoutCI.pSetLayouts = &descriptorSetLayout;
VK_CHECK_RESULT(
vkCreatePipelineLayout(device, &pipelineLayoutCI, nullptr, &pipelineLayout));

// 创建此示例中使用的图形流水线
// Vulkan 使用渲染流水线的概念来封装固定状态,取代了 OpenGL 复杂的有限状态机
// 然后将流水线存储在 GPU 上并进行哈希处理,使得流水线更改非常快
// 注意:仍然有一些动态状态不是流水线的直接部分(但使用了它们的信息)

VkGraphicsPipelineCreateInfo pipelineCI{};
pipelineCI.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO;
// 此流水线使用的布局(可以在使用相同布局的多个流水线之间共享)
pipelineCI.layout = pipelineLayout;
// 此流水线附加到的渲染通道
pipelineCI.renderPass = renderPass;

// 构建构成流水线的不同状态

// 输入装配状态描述了如何装配图元
// 此流水线将顶点数据装配为三角形列表(尽管我们只使用一个三角形)
VkPipelineInputAssemblyStateCreateInfo inputAssemblyStateCI{};
inputAssemblyStateCI.sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO;
inputAssemblyStateCI.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST;

// 光栅化状态
VkPipelineRasterizationStateCreateInfo rasterizationStateCI{};
rasterizationStateCI.sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO;
rasterizationStateCI.polygonMode = VK_POLYGON_MODE_FILL;
rasterizationStateCI.cullMode = VK_CULL_MODE_NONE;
rasterizationStateCI.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE;
rasterizationStateCI.depthClampEnable = VK_FALSE;
rasterizationStateCI.rasterizerDiscardEnable = VK_FALSE;
rasterizationStateCI.depthBiasEnable = VK_FALSE;
rasterizationStateCI.lineWidth = 1.0f;

// 颜色混合状态描述了如何计算混合因子(如果使用)
// 我们每个颜色附件需要一个混合附件状态(即使不使用混合)
VkPipelineColorBlendAttachmentState blendAttachmentState{};
blendAttachmentState.colorWriteMask = 0xf;
blendAttachmentState.blendEnable = VK_FALSE;
VkPipelineColorBlendStateCreateInfo colorBlendStateCI{};
colorBlendStateCI.sType = VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO;
colorBlendStateCI.attachmentCount = 1;
colorBlendStateCI.pAttachments = &blendAttachmentState;

// 视口状态设置此流水线中使用的视口和裁剪范围的数量
// 注意:这实际上被动态状态覆盖了(见下文)
VkPipelineViewportStateCreateInfo viewportStateCI{};
viewportStateCI.sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO;
viewportStateCI.viewportCount = 1;
viewportStateCI.scissorCount = 1;

// 启用动态状态
// 大多数状态被烘焙到流水线中,但仍然有一些动态状态可以在命令缓冲区内更改
// 为了能够更改这些状态,我们需要指定将使用此流水线更改哪些动态状态。它们的实际状态稍后在命令缓冲区中设置。
// 对于此示例,我们将使用动态状态设置视口和裁剪范围
std::vector <VkDynamicState> dynamicStateEnables;
dynamicStateEnables.push_back(VK_DYNAMIC_STATE_VIEWPORT);
dynamicStateEnables.push_back(VK_DYNAMIC_STATE_SCISSOR);
VkPipelineDynamicStateCreateInfo dynamicStateCI{};
dynamicStateCI.sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO;
dynamicStateCI.pDynamicStates = dynamicStateEnables.data();
dynamicStateCI.dynamicStateCount = static_cast<uint32_t>(dynamicStateEnables.size());

// 深度和模板状态,包含深度和模板比较与测试操作
// 我们仅使用深度测试,并希望启用深度测试和写入,并以小于或等于进行比较
VkPipelineDepthStencilStateCreateInfo depthStencilStateCI{};
depthStencilStateCI.sType = VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO;
depthStencilStateCI.depthTestEnable = VK_TRUE;
depthStencilStateCI.depthWriteEnable = VK_TRUE;
depthStencilStateCI.depthCompareOp = VK_COMPARE_OP_LESS_OR_EQUAL;
depthStencilStateCI.depthBoundsTestEnable = VK_FALSE;
depthStencilStateCI.back.failOp = VK_STENCIL_OP_KEEP;
depthStencilStateCI.back.passOp = VK_STENCIL_OP_KEEP;
depthStencilStateCI.back.compareOp = VK_COMPARE_OP_ALWAYS;
depthStencilStateCI.stencilTestEnable = VK_FALSE;
depthStencilStateCI.front = depthStencilStateCI.back;

// 多重采样状态
// 此示例不使用多重采样(用于抗锯齿),状态仍必须设置并传递给流水线
VkPipelineMultisampleStateCreateInfo multisampleStateCI{};
multisampleStateCI.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO;
multisampleStateCI.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT;
multisampleStateCI.pSampleMask = nullptr;

// 顶点输入描述
// 指定流水线的顶点输入参数

// 顶点输入绑定
// 此示例在绑定点 0 使用单个顶点输入绑定(参见 vkCmdBindVertexBuffers)
VkVertexInputBindingDescription vertexInputBinding{};
vertexInputBinding.binding = 0;
vertexInputBinding.stride = sizeof(Vertex);
vertexInputBinding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX;

// 输入属性绑定描述了着色器属性位置和内存布局
std::array<VkVertexInputAttributeDescription, 2> vertexInputAttributs{};
// 这些匹配以下着色器布局(参见 triangle.vert):
// layout (location = 0) in vec3 inPos;
// layout (location = 1) in vec3 inColor;
// 属性位置 0:位置
vertexInputAttributs[0].binding = 0;
vertexInputAttributs[0].location = 0;
// 位置属性是三个 32 位有符号 (SFLOAT) 浮点数 (R32 G32 B32)
vertexInputAttributs[0].format = VK_FORMAT_R32G32B32_SFLOAT;
vertexInputAttributs[0].offset = offsetof(Vertex, position);
// 属性位置 1:颜色
vertexInputAttributs[1].binding = 0;
vertexInputAttributs[1].location = 1;
// 颜色属性是三个 32 位有符号 (SFLOAT) 浮点数 (R32 G32 B32)
vertexInputAttributs[1].format = VK_FORMAT_R32G32B32_SFLOAT;
vertexInputAttributs[1].offset = offsetof(Vertex, color);

// 用于流水线创建的顶点输入状态
VkPipelineVertexInputStateCreateInfo vertexInputStateCI{};
vertexInputStateCI.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO;
vertexInputStateCI.vertexBindingDescriptionCount = 1;
vertexInputStateCI.pVertexBindingDescriptions = &vertexInputBinding;
vertexInputStateCI.vertexAttributeDescriptionCount = 2;
vertexInputStateCI.pVertexAttributeDescriptions = vertexInputAttributs.data();

// 着色器
std::array<VkPipelineShaderStageCreateInfo, 2> shaderStages{};

// 顶点着色器
shaderStages[0].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
// 设置此着色器的流水线阶段
shaderStages[0].stage = VK_SHADER_STAGE_VERTEX_BIT;
// 加载二进制 SPIR-V 着色器
shaderStages[0].module = loadSPIRVShader(getShadersPath() + "triangle/triangle.vert.spv");
// 着色器的主要入口点
shaderStages[0].pName = "main";
assert(shaderStages[0].module != VK_NULL_HANDLE);

// 片元着色器
shaderStages[1].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
// 设置此着色器的流水线阶段
shaderStages[1].stage = VK_SHADER_STAGE_FRAGMENT_BIT;
// 加载二进制 SPIR-V 着色器
shaderStages[1].module = loadSPIRVShader(getShadersPath() + "triangle/triangle.frag.spv");
// 着色器的主要入口点
shaderStages[1].pName = "main";
assert(shaderStages[1].module != VK_NULL_HANDLE);

// 设置流水线着色器阶段信息
pipelineCI.stageCount = static_cast<uint32_t>(shaderStages.size());
pipelineCI.pStages = shaderStages.data();

// 将流水线状态分配给流水线创建信息结构
pipelineCI.pVertexInputState = &vertexInputStateCI;
pipelineCI.pInputAssemblyState = &inputAssemblyStateCI;
pipelineCI.pRasterizationState = &rasterizationStateCI;
pipelineCI.pColorBlendState = &colorBlendStateCI;
pipelineCI.pMultisampleState = &multisampleStateCI;
pipelineCI.pViewportState = &viewportStateCI;
pipelineCI.pDepthStencilState = &depthStencilStateCI;
pipelineCI.pDynamicState = &dynamicStateCI;

// 使用指定状态创建渲染流水线
VK_CHECK_RESULT(vkCreateGraphicsPipelines(device, pipelineCache, 1, &pipelineCI, nullptr,
&pipeline));

// 图形流水线创建后,不再需要着色器模块
vkDestroyShaderModule(device, shaderStages[0].module, nullptr);
vkDestroyShaderModule(device, shaderStages[1].module, nullptr);
}

void createUniformBuffers() {
// 准备并初始化包含着色器 Uniform 的每帧 Uniform 缓冲区块
// OpenGL 中那样的单一 Uniform 在 Vulkan 中已不再存在。所有着色器 Uniform 均通过 Uniform 缓冲区块传递
VkMemoryRequirements memReqs;

// 顶点着色器 Uniform 缓冲区块
VkBufferCreateInfo bufferInfo{};
VkMemoryAllocateInfo allocInfo{};
allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
allocInfo.pNext = nullptr;
allocInfo.allocationSize = 0;
allocInfo.memoryTypeIndex = 0;

bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
bufferInfo.size = sizeof(ShaderData);
// 此缓冲区将用作 Uniform 缓冲区
bufferInfo.usage = VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT;

// 创建缓冲区
for (uint32_t i = 0; i < MAX_CONCURRENT_FRAMES; i++) {
VK_CHECK_RESULT(
vkCreateBuffer(device, &bufferInfo, nullptr, &uniformBuffers[i].buffer));
// 获取包括大小、对齐和内存类型在内的内存要求
vkGetBufferMemoryRequirements(device, uniformBuffers[i].buffer, &memReqs);
allocInfo.allocationSize = memReqs.size;
// 获取支持主机可见内存访问的内存类型索引
// 大多数实现提供多种内存类型,从正确的类型分配内存至关重要
// 我们还希望缓冲区是主机一致的(host coherent),这样我们就无需在每次更新后刷新(或同步)。
// 注意:这可能会影响性能,因此在定期更新缓冲区的实际应用程序中,您可能不想这样做
allocInfo.memoryTypeIndex = getMemoryTypeIndex(memReqs.memoryTypeBits,
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT);
// 为 Uniform 缓冲区分配内存
VK_CHECK_RESULT(
vkAllocateMemory(device, &allocInfo, nullptr, &(uniformBuffers[i].memory)));
// 将内存绑定到缓冲区
VK_CHECK_RESULT(
vkBindBufferMemory(device, uniformBuffers[i].buffer, uniformBuffers[i].memory,
0));
// 我们映射一次缓冲区,这样我们就可以更新它而无需再次映射
VK_CHECK_RESULT(vkMapMemory(device, uniformBuffers[i].memory, 0, sizeof(ShaderData), 0,
(void **) &uniformBuffers[i].mapped));
}

}

void prepare() {
// 调用基类初始化方法(通常负责创建 Vulkan 实例、逻辑设备、交换链等基础资源)
VulkanExampleBase::prepare();
// 创建同步原语(如信号量 Semaphores 和栅栏 Fences,用于控制渲染流程同步)
createSynchronizationPrimitives();
// 创建命令缓冲区(用于记录绘图命令)
createCommandBuffers();
// 创建并填充顶点缓冲区(上传顶点数据到 GPU)
createVertexBuffer();
// 创建统一缓冲区(Uniform Buffers,用于存储着色器所需的常量数据)
createUniformBuffers();
// 创建描述符集布局(定义着色器如何访问缓冲区和纹理)
createDescriptorSetLayout();
// 创建描述符池(用于分配描述符集)
createDescriptorPool();
// 创建描述符集(将具体的缓冲区或图像绑定到描述符布局中)
createDescriptorSets();
// 创建图形管线(配置着色器阶段、固定功能状态等)
createPipelines();
// 标记初始化准备已完成
prepared = true;
}

virtual void render() {
if (!prepared)
return;

// 使用栅栏等待,直到命令缓冲区完成执行后再再次使用它
vkWaitForFences(device, 1, &waitFences[currentFrame], VK_TRUE, UINT64_MAX);
VK_CHECK_RESULT(vkResetFences(device, 1, &waitFences[currentFrame]));

// 从实现中获取下一个交换链图像
// 请注意,实现可以以任何顺序返回图像,因此我们必须使用获取函数,不能自行循环图像/imageIndex
uint32_t imageIndex;
VkResult result = vkAcquireNextImageKHR(device, swapChain.swapChain, UINT64_MAX,
presentCompleteSemaphores[currentFrame],
VK_NULL_HANDLE, &imageIndex);
if (result == VK_ERROR_OUT_OF_DATE_KHR) {
windowResize();
return;
} else if ((result != VK_SUCCESS) && (result != VK_SUBOPTIMAL_KHR)) {
throw "Could not acquire the next swap chain image!";
}

// 更新下一帧的 Uniform 缓冲区
ShaderData shaderData{};
shaderData.projectionMatrix = camera.matrices.perspective;
shaderData.viewMatrix = camera.matrices.view;
shaderData.modelMatrix = glm::mat4(1.0f);

// 将当前矩阵复制到当前帧的 Uniform 缓冲区
// 注意:由于我们为 Uniform 缓冲区请求了主机一致的内存类型,因此该写入对 GPU 是立即可见的
memcpy(uniformBuffers[currentFrame].mapped, &shaderData, sizeof(ShaderData));

// 构建命令缓冲区
// 与 OpenGL 不同,所有渲染命令都被记录到命令缓冲区中,然后提交到队列
// 这允许在单独的线程中预先生成工作
// 对于基本命令缓冲区(如本示例),记录速度非常快,因此无需卸载此操作

vkResetCommandBuffer(commandBuffers[currentFrame], 0);

VkCommandBufferBeginInfo cmdBufInfo{};
cmdBufInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;

// 为所有 loadOp 设置为 clear 的帧缓冲区附件设置清除值
// 我们使用两个附件(颜色和深度),它们在子通道开始时被清除,因此我们需要为两者设置清除值
VkClearValue clearValues[2]{};
clearValues[0].color = {{0.0f, 0.0f, 0.2f, 1.0f}};
clearValues[1].depthStencil = {1.0f, 0};

VkRenderPassBeginInfo renderPassBeginInfo{};
renderPassBeginInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO;
renderPassBeginInfo.pNext = nullptr;
renderPassBeginInfo.renderPass = renderPass;
renderPassBeginInfo.renderArea.offset.x = 0;
renderPassBeginInfo.renderArea.offset.y = 0;
renderPassBeginInfo.renderArea.extent.width = width;
renderPassBeginInfo.renderArea.extent.height = height;
renderPassBeginInfo.clearValueCount = 2;
renderPassBeginInfo.pClearValues = clearValues;
renderPassBeginInfo.framebuffer = frameBuffers[imageIndex];

const VkCommandBuffer commandBuffer = commandBuffers[currentFrame];
VK_CHECK_RESULT(vkBeginCommandBuffer(commandBuffer, &cmdBufInfo));

// 开始基类通过我们的默认渲染通道设置指定的第一个子通道
// 这将清除颜色和深度附件
vkCmdBeginRenderPass(commandBuffer, &renderPassBeginInfo, VK_SUBPASS_CONTENTS_INLINE);
// 更新动态视口状态
VkViewport viewport{};
viewport.height = (float) height;
viewport.width = (float) width;
viewport.minDepth = (float) 0.0f;
viewport.maxDepth = (float) 1.0f;
vkCmdSetViewport(commandBuffer, 0, 1, &viewport);
// 更新动态裁剪范围状态
VkRect2D scissor{};
scissor.extent.width = width;
scissor.extent.height = height;
scissor.offset.x = 0;
scissor.offset.y = 0;
vkCmdSetScissor(commandBuffer, 0, 1, &scissor);
// 绑定当前帧 Uniform 缓冲区的描述符集,以便着色器在此绘制中使用该缓冲区的数据
vkCmdBindDescriptorSets(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout, 0,
1, &uniformBuffers[currentFrame].descriptorSet, 0, nullptr);
// 绑定渲染流水线
// 流水线(状态对象)包含渲染流水线的所有状态,绑定它将设置流水线创建时指定的所有状态
vkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline);
// 绑定三角形顶点缓冲区(包含位置和颜色)
VkDeviceSize offsets[1]{0};
vkCmdBindVertexBuffers(commandBuffer, 0, 1, &vertices.buffer, offsets);
// 绑定三角形索引缓冲区
vkCmdBindIndexBuffer(commandBuffer, indices.buffer, 0, VK_INDEX_TYPE_UINT32);
// 绘制索引三角形
vkCmdDrawIndexed(commandBuffer, indices.count, 1, 0, 0, 0);
vkCmdEndRenderPass(commandBuffer);
// 结束渲染通道将添加一个隐式屏障,将帧缓冲区颜色附件转换为
// VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,以便呈现给窗口系统
VK_CHECK_RESULT(vkEndCommandBuffer(commandBuffer));

// 将命令缓冲区提交到图形队列

// 队列提交将等待的流水线阶段(通过 pWaitSemaphores)
VkPipelineStageFlags waitStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
// 提交信息结构指定了一个命令缓冲区队列提交批次
VkSubmitInfo submitInfo{};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
submitInfo.pWaitDstStageMask = &waitStageMask; // 指向流水线阶段列表的指针,信号量等待将在这些阶段发生
submitInfo.pCommandBuffers = &commandBuffer; // 在此批次(提交)中执行的命令缓冲区
submitInfo.commandBufferCount = 1; // 我们提交单个命令缓冲区

// 提交的命令缓冲区开始执行前要等待的信号量
submitInfo.pWaitSemaphores = &presentCompleteSemaphores[currentFrame];
submitInfo.waitSemaphoreCount = 1;
// 命令缓冲区完成后要发出的信号量
submitInfo.pSignalSemaphores = &renderCompleteSemaphores[currentFrame];
submitInfo.signalSemaphoreCount = 1;

// 提交到图形队列,并传递一个等待栅栏
VK_CHECK_RESULT(vkQueueSubmit(queue, 1, &submitInfo, waitFences[currentFrame]));

// 将当前帧缓冲区呈现给交换链
// 将命令缓冲区提交产生的信号量从提交信息中作为交换链呈现的等待信号量传递
// 这确保了在所有命令提交之前,图像不会呈现给窗口系统

VkPresentInfoKHR presentInfo{};
presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR;
presentInfo.waitSemaphoreCount = 1;
presentInfo.pWaitSemaphores = &renderCompleteSemaphores[currentFrame];
presentInfo.swapchainCount = 1;
presentInfo.pSwapchains = &swapChain.swapChain;
presentInfo.pImageIndices = &imageIndex;
result = vkQueuePresentKHR(queue, &presentInfo);

if ((result == VK_ERROR_OUT_OF_DATE_KHR) || (result == VK_SUBOPTIMAL_KHR)) {
windowResize();
} else if (result != VK_SUCCESS) {
throw "Could not present the image to the swap chain!";
}

// 根据最大并发帧数选择要渲染的下一帧
currentFrame = (currentFrame + 1) % MAX_CONCURRENT_FRAMES;
}
};

// 操作系统特定的主入口点
// 大部分代码库在不同的支持操作系统之间共享,但消息处理等方面存在差异

#if defined(_WIN32)
// Windows 入口点
VulkanExample *vulkanExample;
LRESULT CALLBACK WndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
if (vulkanExample != NULL)
{
vulkanExample->handleMessages(hWnd, uMsg, wParam, lParam);
}
return (DefWindowProc(hWnd, uMsg, wParam, lParam));
}
int APIENTRY WinMain(_In_ HINSTANCE hInstance, _In_opt_ HINSTANCE hPrevInstance, _In_ LPSTR, _In_ int)
{
for (size_t i = 0; i < __argc; i++) { VulkanExample::args.push_back(__argv[i]); };
vulkanExample = new VulkanExample();
vulkanExample->initVulkan();
vulkanExample->setupWindow(hInstance, WndProc);
vulkanExample->prepare();
vulkanExample->renderLoop();
delete(vulkanExample);
return 0;
}

#elif defined(__ANDROID__)
// Android 入口点
VulkanExample *vulkanExample;
void android_main(android_app* state)
{
vulkanExample = new VulkanExample();
state->userData = vulkanExample;
state->onAppCmd = VulkanExample::handleAppCommand;
state->onInputEvent = VulkanExample::handleAppInput;
androidApp = state;
vulkanExample->renderLoop();
delete(vulkanExample);
}
#elif defined(_DIRECT2DISPLAY)

// 带有 Direct to Display WSI 的 Linux 入口点
// Direct to Displays (D2D) 用于嵌入式平台
VulkanExample *vulkanExample;
static void handleEvent()
{
}
int main(const int argc, const char *argv[])
{
for (size_t i = 0; i < argc; i++) { VulkanExample::args.push_back(argv[i]); };
vulkanExample = new VulkanExample();
vulkanExample->initVulkan();
vulkanExample->prepare();
vulkanExample->renderLoop();
delete(vulkanExample);
return 0;
}
#elif defined(VK_USE_PLATFORM_DIRECTFB_EXT)
VulkanExample *vulkanExample;
static void handleEvent(const DFBWindowEvent *event)
{
if (vulkanExample != NULL)
{
vulkanExample->handleEvent(event);
}
}
int main(const int argc, const char *argv[])
{
for (size_t i = 0; i < argc; i++) { VulkanExample::args.push_back(argv[i]); };
vulkanExample = new VulkanExample();
vulkanExample->initVulkan();
vulkanExample->setupWindow();
vulkanExample->prepare();
vulkanExample->renderLoop();
delete(vulkanExample);
return 0;
}
#elif defined(VK_USE_PLATFORM_WAYLAND_KHR)
VulkanExample *vulkanExample;
int main(const int argc, const char *argv[])
{
for (size_t i = 0; i < argc; i++) { VulkanExample::args.push_back(argv[i]); };
vulkanExample = new VulkanExample();
vulkanExample->initVulkan();
vulkanExample->setupWindow();
vulkanExample->prepare();
vulkanExample->renderLoop();
delete(vulkanExample);
return 0;
}
#elif defined(__linux__) || defined(__FreeBSD__)

// Linux 入口点
VulkanExample *vulkanExample;
#if defined(VK_USE_PLATFORM_XCB_KHR)
static void handleEvent(const xcb_generic_event_t *event)
{
if (vulkanExample != NULL)
{
vulkanExample->handleEvent(event);
}
}
#else
static void handleEvent()
{
}
#endif
int main(const int argc, const char *argv[])
{
for (size_t i = 0; i < argc; i++) { VulkanExample::args.push_back(argv[i]); };
vulkanExample = new VulkanExample();
vulkanExample->initVulkan();
vulkanExample->setupWindow();
vulkanExample->prepare();
vulkanExample->renderLoop();
delete(vulkanExample);
return 0;
}
#elif (defined(VK_USE_PLATFORM_MACOS_MVK) || defined(VK_USE_PLATFORM_METAL_EXT)) && defined(VK_EXAMPLE_XCODE_GENERATED)
VulkanExample *vulkanExample;
int main(const int argc, const char *argv[])
{
@autoreleasepool
{
for (size_t i = 0; i < argc; i++) { VulkanExample::args.push_back(argv[i]); };
vulkanExample = new VulkanExample();
vulkanExample->initVulkan();
vulkanExample->setupWindow(nullptr);
vulkanExample->prepare();
vulkanExample->renderLoop();
delete(vulkanExample);
}
return 0;
}
#elif defined(VK_USE_PLATFORM_SCREEN_QNX)
VULKAN_EXAMPLE_MAIN()
#endif

弹幕引擎 (一) 从流式渲染架构与批式渲染架构说起

两种渲染架构

这两种架构代表了计算机科学中处理任务的两种截然不同的哲学:“流式”追求实时响应(Latency-Optimized),而“批式”追求吞吐与效率(Throughput-Optimized)

在 Android 图形系统中,将这两者强行整合在一起,本质上就是在处理“阻抗不匹配”(Impedance Mismatch)问题。


流式架构 (Streaming Architecture) —— “持续交付”

流式架构的核心在于即时性。数据一旦产生,就必须尽快通过管道流向目的地,系统的目标是最小化“端到端延迟”(End-to-End Latency)。

  • 核心原则:Event-Driven(事件驱动)。每一条数据(Frame)都是一个独立处理的事件。
  • 时间观念:基于“数据到达时间”(Event Time)。渲染引擎产生一帧,就意味着该帧必须立即显示。
  • 系统特征
    • 低延迟:不等待后续数据,无需缓存队列。
    • 恒定开销:渲染引擎需要保持持续的计算负载,无法利用“闲时休息”来节省资源。
    • 不可预测性:数据的到达频率若波动,系统必须能立即响应,否则就会造成抖动。

在你的架构中:OpenGL 渲染线程就是典型的流式架构,它要求“此时此刻”就要渲染出当前状态。


批式架构 (Batching Architecture) —— “批量吞吐”

批式架构的核心在于优化总成本与效率。它故意引入“延迟”,将多次小的操作合并为一次大的操作(Coalescing),目的是摊薄系统调用的开销。

  • 核心原则:Task-Coalescing(任务合并)。等待任务累积到阈值(时间片或数量),再一次性提交。
  • 时间观念:基于“系统时钟”(System Cycle/Tick)。无论任务多早完成,都必须等待下一个“发车时刻”(VSync 信号)。
  • 系统特征
    • 高效率:通过减少上下文切换、减少 CPU/GPU 切换、利用 CPU 缓存亲和性,降低单次任务的平均成本。
    • 高吞吐:在单位时间内处理更多数据。
    • 容忍延迟:只要最终结果符合预期的交付周期,内部的处理顺序和时机可以灵活调整。

在你的架构中:Android View 系统(ViewRootImpl)就是批式架构,它把所有的 UI 更新请求收集起来,等到 Choreographer 信号到来时,统一进行遍历(Traversal)。


为什么两者会产生冲突?(底层哲学冲突)

这种冲突在工程上被称为 “时域上的不兼容”

  1. 处理模型的“错位”
  • 流式架构要求“随时”处理任务。
  • 批式架构要求“准点”处理任务。
  • 当流式产生的数据(OpenGL 帧)试图进入批式系统(View 树)时,它会被强制进入“等待队列”。这种等待,人为地将原本平滑的流打断了。
  1. 吞吐量与延迟的权衡(Trade-off)
  • Android 系统设计 View 系统的批式处理,是为了让整个 App 的界面渲染更加经济(省电、省 CPU),这是为了全局最优。
  • 但你的渲染引擎是局部优化(追求单组件的极致平滑),这与全局策略产生了利益冲突。

理解这两个架构

**私家车 (Streaming / OpenGL 渲染线程)**:你是驾驶者,追求的是“随到随走”。你的油门由你的引擎逻辑(渲染循环)控制,你希望以恒定的 60km/h(60fps)在路上行驶。

**轮渡 (Batching / Android View 系统)**:这是公共交通系统,它严格遵循“时刻表”运行。轮渡只在固定的时刻(VSync 信号)才会靠岸并进行装载(Traversal 绘制流程)。

码头闸机 (mWillDrawSoon):这是码头管理处。它的规则是:如果轮渡已经在准备启航了(mWillDrawSoon = true),为了保障航行安全,码头闸机必须关闭,不再允许任何车辆进入码头。


GLSurfaceView 与 GLTextureView 的架构本质对比

首先需要明确一点:Android 原生 SDK 中并没有 GLTextureView 这个类。通常开发者会使用 TextureView 并配合自定义的 OpenGL 渲染逻辑来实现类似的功能。

我们将这一逻辑架构下的组件称为 **GLTextureView**(即自定义封装了 OpenGL 渲染能力的 TextureView)。以下是重写后的对比分析:


如果说 GLSurfaceView 是将“流”直接注入到了图形合成器(SurfaceFlinger)的轨道上,那么 GLTextureView 则是将渲染出的“流”强行塞入了一个 View 系统的“容器”中。

以下是 GLTextureView 作为“批式容器”架构的三个关键原则:

1. 受限的生产自主权(依附于 View 树)

  • 原理GLTextureView 本质上是一个 TextureView。它的生命周期、测量(Measure)、布局(Layout)和绘制(Draw)完全被 ViewRootImpl 的调度所接管。
  • 对比GLSurfaceView 拥有独立的 Surface,直接对冲系统合成器;而 GLTextureView 的渲染产出必须作为 View 树的一个 Layer,每一帧都必须经过 View 系统繁琐的 traversal 流程才能上屏。
  • 结论:它无法摆脱 mWillDrawSoon 的锁链。因为它的生产过程必须等待主线程的 View 遍历周期(Traversal),它被迫服从 UI 系统的调度频率,而不是自主的渲染频率。

2. “被动式”的渲染循环(受限于 UI 节拍)

  • 原理:虽然 GLTextureView 内部也可以开辟渲染线程,但它的数据交付(Commit)必须通过 SurfaceTexture 触发 invalidate()requestLayout()
  • 时序耦合:它的数据上屏时间必须与 UI 主线程的 VSync 信号对齐。当 ViewRootImpl 处于 performTraversals() 的忙碌期时,渲染线程提交的数据会被“搁置”或“合并”。
  • 结论:它是典型的“批式”架构。渲染线程虽然在独立生产,但数据被 View 系统“缓存”在 TextureView 的缓冲区中,必须等到 View 系统允许绘制时,才能通过合成流程输出。

3. 数据流的“二次转运”(非零拷贝)

  • 原理:在 GLTextureView 中,你的 OpenGL 渲染结果先写入 SurfaceTexture,然后需要经过 GPU 拷贝/转换到 View 的 Layer 纹理中(由 UI 系统合成)。
  • 对比GLSurfaceView 的数据流是“直通”的,它直接作为 Surface 层被合成器读取。而 GLTextureView 存在一个“转运”过程——View 系统必须先获取 SurfaceTexture 的数据,将其合成到整个 App 的 UI 树中,这个过程增加了额外的 CPU/GPU 调度开销,也正是延迟的来源。

GLTextureView 抖动的问题

我们将 GLTextureView 定义为一个“强行塞入批式容器的流式渲染组件”。这种架构上的错位,是导致渲染抖动(Jitter)的根本源头。在高性能弹幕引擎的开发中,这种抖动并非逻辑错误,而是时序调度上的“水土不服”

渲染流与 UI 循环的“错位”

GLTextureView 的最大痛点在于其双重时钟依赖

  • 内部时钟(Render Thread):负责产出渲染帧,通常以恒定速率(如 60fps/120fps)运行。
  • 外部时钟(ViewRootImpl):负责 UI 的 measure/layout/draw,严格受 VSync 信号驱动,具有极高的间歇性和突发性。

当 OpenGL 线程完成一帧渲染时,它通过 SurfaceTexture 更新回调通知 UI 线程。然而,View 系统并不总是处于“准备好接收”的状态。一旦 View 树中出现了耗时布局(如嵌套过深的 Layout 或频繁的 invalidate),UI 线程就会进入长时间的 performTraversals 周期。

此时,渲染帧被迫“滞后”。对于肉眼而言,这就形成了抖动:上一帧间隔 16ms,由于被拦截,下一帧间隔变成了 32ms,再下一帧为了追赶进度,可能在 10ms 就执行了。这种帧呈现时长(Frame Presentation Time)的剧烈波动,就是用户感知到的“顿挫感”。


调度拦截:mWillDrawSoon 导致的“闸机锁死”

回顾我们提到的“码头闸机”比喻,mWillDrawSoon 就是那个一旦合上就绝不通融的机制。

GLTextureView 中,invalidate() 逻辑是其更新 UI 的唯一入口。当 OpenGL 线程频繁调用 invalidate() 请求更新时:

  • 如果 UI 线程正在执行布局任务ViewRootImpl 会将 mWillDrawSoon 置为 true
  • 拒绝服务:此时 TextureView 发出的重绘请求会被系统判定为“冗余”,直接丢弃或合并。
  • 视觉后果:OpenGL 线程以为这一帧已经提交成功,但实际上它在缓冲区(BufferQueue)中被“锁”住了,直到 UI 线程完成当前循环并处理下一个 traversal。这种强制的等待,使得原本流畅的弹幕运动轨迹在某一瞬间“卡”住了。

缓冲区失衡:挤压与丢帧的博弈

由于 GLTextureView 本质上是一个 TextureView,它必须通过 BufferQueueSurfaceFlinger 握手。这里的生产与消费关系极易失衡:

  • BufferQueue 挤压 (Congestion):如果你的 OpenGL 渲染引擎产出过快(例如渲染线程没有设置 Frame Rate 限制),BufferQueue 会迅速填满。此时,如果 TextureView 的消费能力跟不上,会导致系统阻塞 Producer 或产生明显的输入延迟(Input Lag)。用户会感觉弹幕“跟手性”很差,仿佛有一层厚厚的阻尼感。
  • 帧丢弃 (Frame Drop):如果系统为了维持 App 的整体流畅度,配置了 DROP_OLDEST 策略,那么当 BufferQueue 满载时,旧帧会被直接丢弃。对于弹幕引擎来说,这意味着弹幕位置的“跳跃”——上一秒弹幕还在屏幕左侧,下一秒直接出现在了右侧,运动轨迹的连续性被彻底破坏。

这是一个非常经典的“时序冲突”模型。通过“私家车、轮渡、码头闸机”的比喻,我们可以清晰地看到卡顿(Jitter)是如何从“微观等待”演变成“宏观视觉抖动”的。

让我们把这个过程拆解为三个阶段,来看看你的“私家车”是如何在码头被迫经历那场“抖动”的。

第一阶段:完美的节奏 (平滑渲染)

  • 状态:码头(View 系统)一切正常,轮渡(VSync)准时靠岸。
  • 过程:你的私家车(渲染帧)以 60km/h(16.6ms/帧)的速度匀速行驶。每一辆车到达码头时,闸机(mWillDrawSoon)都是打开的。
  • 结果:车子顺利开上船,轮渡准时出发。观众看到的是均匀平滑的动画。

第二阶段:闸机锁死 (卡顿发生的瞬间)

  • 状态:轮渡(UI 线程)突然变得极其忙碌。因为它是“公共交通”,它不仅要装你的车,还要处理一大堆复杂的“乘客”(View 树的 Measure/Layout 布局计算)。
  • 冲突:当你的车(新的一帧)准时到达码头时,闸机管理员(ViewRootImpl)看了一眼忙碌的甲板,大喊一声:“轮渡已经准备发船了,现在是 mWillDrawSoon = true,闸机关闭!
  • 过程:你的车被迫停在码头干等。虽然引擎(OpenGL 线程)还在轰鸣,但你被拦在了闸机外。

第三阶段:抖动 (Jitter 的视觉真相)

抖动并不是因为你“停下”了,而是因为你被迫改变了行驶节奏

  1. 车 1:顺利上船,第 0ms 出发。
  2. 车 2:到达时闸机已关闭,被迫在码头等到了第 32ms,才赶上下一班轮渡。
  3. 车 3:轮渡恢复正常,第 48ms 出发。

视觉上的“抖动”感由此产生:

  • 节奏断裂:观众看到的画面间隔是:16ms -> 32ms -> 16ms
  • 这种不一致才是“抖动”的本质。如果所有的车都等 32ms,那画面看起来只是“慢”(30fps);但因为你一会儿 16ms,一会儿 32ms,这种节奏的剧烈突变,导致人眼立刻捕捉到了弹幕轨迹的“顿挫”或“瞬移”。

深入理解:为什么闸机关闭会导致“抖动”?

在技术层面,这个过程有三个细节决定了抖动的严重程度:

  1. “被迫排队”的后坐力
    当闸机(mWillDrawSoon)最终打开时,轮渡(View 系统)可能会因为刚才的积压,导致下一次发船更加匆忙。这种“忽快忽慢”的调度节奏,会将你的私家车(OpenGL 帧)的交付周期彻底搞乱。

  2. BufferQueue 的“洪峰效应”
    在闸机关闭期间,你的 OpenGL 引擎并没有停下来,它还在持续不断地往码头输送“车流”。如果你的缓冲区(BufferQueue)有限,闸机一打开,可能会瞬间涌入好几辆车。系统为了追赶进度,可能会直接丢弃中间的车,这就会导致“跳帧”——你原本打算显示的弹幕运动过程,被直接跨越了,导致视觉上的“瞬间跳跃”。

  3. 不确定性的“黑盒”
    最可怕的是,作为“私家车”驾驶员(渲染线程),你根本不知道码头闸机什么时候关,什么时候开。你唯一的感知就是:我明明踩着油门,速度却被这个码头的交通规则强制修正了。

总结

抖动,其实就是你的渲染引擎(私家车)试图维持恒定节奏,却被系统的调度规则(码头闸机)强行改写了发车时间。

反压机制(抖动变卡顿)

反压(Backpressure) 是计算机科学中用于处理“生产者速度过快,而消费者消费速度跟不上”这一矛盾的调节机制。

简单来说,当系统无法处理此时到达的流量时,通过某种手段“向上游”发出信号,让上游放慢速度。

通过人工手段将一个异步、非确定性的系统(Async/Unpredictable),强行转化为一个同步、确定性的“步进系统”(Lock-step)。

从工程学的角度来看,“将不可控的抖动(Jitter)转化为可控的卡顿(Stutter)”,在很多高性能实时系统(如游戏引擎的帧率锁定)中,是一个非常经典的优化策略。

实现方案

在 TextureView 中添加一个 mHasContentToDraw,监听到有待绘制的下一帧或者 swapBuffer 之后,设置为ture,阻止新的帧绘制,在 onSurfaceTextureUpdated 之后设置为 false,这样,把抖动变成卡顿

mHasContentToDraw = true

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Override
public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) {
try {
Field listenerField = SurfaceTexture.class.getDeclaredField("mOnFrameAvailableListener");
listenerField.setAccessible(true);
final SurfaceTexture.OnFrameAvailableListener originalListener = (SurfaceTexture.OnFrameAvailableListener) listenerField.get(surface);
surface.setOnFrameAvailableListener(new SurfaceTexture.OnFrameAvailableListener() {
@Override
public void onFrameAvailable(SurfaceTexture surfaceTexture) {
mHasContentToDraw = true;
if (originalListener != null) {
originalListener.onFrameAvailable(surfaceTexture);
}
postInvalidate();
}
});
} catch (Exception e) {
Log.e(TAG, "Fail to hijack ST", e);
}
mGLThread.surfaceCreated();
onSurfaceTextureSizeChanged(surface, width, height);
}
1
2
3
4
5
6
7
8
9
10
11
public int swap() {
if (! mEgl.eglSwapBuffers(mEglDisplay, mEglSurface)) {
return mEgl.eglGetError();
}
GLTextureView view = mGLTextureViewWeakRef.get();
if (view != null) {
view.mHasContentToDraw = true;
view.postInvalidate();
}
return EGL10.EGL_SUCCESS;
}

mHasContentToDraw = false

1
2
3
4
@Override
public void onSurfaceTextureUpdated(SurfaceTexture surface) {
mHasContentToDraw = false;
}

原生的状态:Producer(GL 线程)和 Consumer(UI 线程)处于“失联”状态,GL 线程只管拼命生产,UI 线程只管按自己的频率消费。这种异步导致了 BufferQueue 经常发生竞态条件(Race Condition),从而产生了抖动。

改进的方案:渲染管线从“异步并发”变成了“同步串行”。这种步进式(Step-by-step)的渲染,确实消除了随机的时序错乱(Jitter),将其变成了固定的帧处理间隔。