Fork me on GitHub

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 文件在安装包中的大小远小于其解压后的原始大小。

Android 边框阴影绘制方案

在说解决方案之前,我们需要先明确两个问题

  1. 到底什么是阴影
  2. 为什么在 Android 端上绘制阴影效果这么麻烦

每当我向 Android 同学提出第一问时,通常他们会回答我:阴影是个10-25%透明度的线性减淡的黑色图层

但是当你尝试在移动端去绘制这样一个黑色图层去代表阴影,其视觉效果肉眼可见的差强人意

于是绝大多数 Android 同学会提议你去找设计出个带阴影的图,或者使用诸如 ShadowLayout 这样复杂的控件
https://github.com/lihangleo2/ShadowLayout

ShadowLayout 使用了 1600 行代码只为实现边框阴影

再看看 github 上其他绘制阴影的方案,不是采用 RenderScript 这种即将被谷歌废弃的图像处理库,就是使用 Android 12 以上才有的还要依赖硬件加速的 RenderEffect,更有甚者者干脆用 Canvas 硬画,不仅效果不好,性能更是很差,稍有复杂的情况,甚至可能导致卡顿发热

我们不经要发问:Android 10多年了,难道就没有一个不需要和设计掰扯,能兼容所有机型,对开发简单友好的阴影解决方案吗?

到底什么是阴影

先来看下阴影的组成部分

我们可以将阴影大致分成两个部分,全影/umbra半影/penumbra

半影区域就是阴影的过渡区, 也就是软阴影,有半影的阴影过渡时,视觉效果会好很多。

pEWsX28.md.png

通俗点说:全影就是大家常说的 10 - 25% 透明度的黑色图层,通常我们需要绘制的边框阴影是半影。

为什么在 Android 端上绘制阴影效果这么麻烦

半影的绘制实际上是一个多次采样模糊的过程

1
2
3
4
5
6
7
8
9
10
11
float PCF(float2 uv, float currentDepth) {
float shadow = 0.0;
float2 texelSize = 1.0 / _ShadowMap_TexelSize;
for (int x = -1; x <= 1; x++) {
for (int y = -1; y <= 1; y++) {
float depth = tex2D(_ShadowMap, uv + float2(x, y) * texelSize).r;
shadow += (currentDepth > depth) ? 1.0 : 0.0;
}
}
return shadow / 9.0;
}

在上述代码中,对 3* 3 的区域进行了模糊采样,也就是说每个像素点的颜色依赖其周围相邻的一圈像素点的颜色的计算结果,实现逐步衰减

切图和代码实现究竟哪个更好?

首先明确一点:切图和代码实现的本质区别是什么?

是预渲染和实时渲染的区别

如果我们当前卡片所在的背景是可枚举且数量较少的,我们应该倾向使用预渲染

举个例子:

  1. 如果一个页面只有日夜间两种阴影我们倾向于切图
  2. 如果需要七彩渐变非中心光源产生的(上下左右阴影不一致的)阴影,我们倾向于代码实现

使用 .9 图来规避切图的弊端

切图虽然方便,却有以下几种弊端

  1. 设计会想打你,尤其当你需要反复调试效果的时候
  2. 切图会增加包体积

解决方案

  1. 自己学会使用 figma 调试阴影切图

pEWy1G6.md.png

  1. 使用 .9 图

pEWsHUI.png

当我们想要绘制一个带阴影的圆角矩形的时候,我们会发现除了矩形的四个角外,整个矩形绝大部份都是重复可拉伸的

如果我切出一个这样的圆角矩形最小图,并设置其非圆角的区域为拉伸区域

那我们就可以用一个只有几 kb 大小的 .9 图实现固定圆角的带边框阴影的矩形

考虑到实际情况,我们可以切出 0 - 12度的圆角图共13张图,圆角越小的图片越小,易得总共大约 (1kb + 5kb ) / 2 * 13 = 39kb

再算上深色模式,未打包压缩前总共只需要 80kb

而 1600 行的 ShadowLayout.java 未压缩前有 55kb,引入 Androidx 的 CardView 就更大了,还需要考虑机型的兼容性和硬件加速开关

而使用 .9 图,开发同学只需要选择对应圆角的 .9 图设置为背景即可

使用

轻松使用

1
2
3
4
5
<View
android:layout_width="400dp"
android:layout_height="200dp"
android:backgroud="@drawable/rounded_corner_12dp_shadow"
/>

展望

我们可以进一步,将圆角和矩形分离,使用原子化的图片素材 + 微量代码,组合各种圆角与阴影效果

总结

以下是 预渲染(.9图)代码实现(如ShadowLayout / CardView) 的优劣势对比表格,结合性能、开发效率、灵活性等维度分析:


对比维度 预渲染(.9图) 代码实现(动态绘制)
性能 ✅ 极低开销(仅纹理采样) ⚠️ 较高开销(实时计算阴影、图层叠加)
内存占用 ✅ 小 ⚠️ 动态(取决于具体代码)
开发效率 ✅ 直接设置背景,无需编码 ⚠️ 需编写/维护复杂逻辑(如阴影算法)
灵活性 ❌ 仅支持预定义的圆角/阴影样式 ✅ 动态调整参数(颜色、圆角、模糊度等)
适配性 ✅ 无兼容性问题(Android/iOS通用) ⚠️ 需处理机型兼容(如硬件加速、API差异)
扩展性 ❌ 修改样式需重新切图 ✅ 可通过代码快速迭代新效果
文件体积 ✅ 极小(如80KB未压缩) ⚠️ 较大(代码+依赖库,如CardView)
动态效果支持 ❌ 无法实现动画或实时变化 ✅ 支持动态阴影(如跟随光源移动)
多分辨率适配 ✅ 自动拉伸(.9图特性) ⚠️ 需额外处理多分辨率下的绘制精度
维护成本 ✅ 低(替换图片即可) ⚠️ 高(需测试不同机型/系统版本)

适用场景总结

  1. 优先使用.9图
    • 样式固定(如标准化设计语言中的卡片阴影)。
    • 性能敏感场景(如列表项、高频刷新界面)。
    • 需要快速开发且无动态效果需求。

  2. 优先代码实现
    • 需要动态效果(如阴影动画、交互反馈)。
    • 样式高度自定义(如设计师要求的特殊渐变阴影)。

补充建议

混合方案:对静态部分使用.9图,动态部分叠加代码绘制(如悬浮按钮的阴影)。
工具化:将.9图生成和代码阴影封装成工具,供团队按需选择

通过明确场景需求,可以高效选择最优方案。

TextView layout_constrainedWidth 属性

layout_constrainedWidthConstraintLayout中的一个属性,用于在处理宽度约束时提供更多的灵活性和控制。当你使用ConstraintLayout构建复杂的布局时,可能会遇到某些视图在其内容过长时需要被约束宽度的情况,以避免它们超出屏幕边界或影响其他视图的布局。layout_constrainedWidth属性就是为了解决这类问题而设计的。

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
<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_constraintTop_toTopOf="parent"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintHorizontal_bias="0"
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"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintHorizontal_bias="0"
android:text="按钮" />

</androidx.constraintlayout.widget.ConstraintLayout>

拦截 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
}
}

如何理解 Vulkan MAX_CONCURRENT_FRAMES

MAX_CONCURRENT_FRAMES 参数是Vulkan多帧并行处理的核心设计,理解它需要从硬件工作原理性能调优两个维度来分析:

底层硬件机制

GPU流水线特性:现代GPU采用深度流水线设计,类似CPU的指令级并行。

当设置为2时:

1
CPU帧1记录 → GPU执行帧1 → CPU帧2记录 → GPU执行帧2

内存带宽影响:每增加一帧需要额外存储:

  • 命令缓冲区副本
  • Uniform缓冲区(本例中每帧144字节mat4×3)
  • 同步对象(约32字节/帧)

性能权衡公式

理论上吞吐量提升遵循:

1
理论最大帧率 = 1 / max(CPU帧处理时间, GPU帧渲染时间)

MAX_CONCURRENT_FRAMES=N时,可掩盖的延迟为:

1
可隐藏延迟 = (N-1) × min(CPU_time, GPU_time)

理论最大帧率公式

1
理论最大帧率 = \frac{1}{\max(T_{CPU}, T_{GPU})}
核心含义:
  • 系统瓶颈决定论:渲染管线的吞吐量由CPU和GPU中较慢的一方决定,符合木桶原理
  • 时间维度T_CPU包含命令记录+资源更新,T_GPU包含所有渲染阶段耗时
实例分析:

假设某场景:

  • CPU每帧处理时间:3ms
  • GPU每帧渲染时间:5ms
    则最大理论帧率 = 1/5ms = ​​200 FPS​

可隐藏延迟公式

1
可隐藏延迟 = (N-1) \times \min(T_{CPU}, T_{GPU})
关键洞察:
  • 并行窗口:当有N帧资源时,系统可以构建深度为N的流水线
  • 补偿机制:每增加一帧资源,就能多掩盖一个较慢者的处理周期
动态演示(N=3时):
1
2
3
4
5
6
时间轴   CPU活动            GPU活动
t0 记录帧1命令 空闲
t1 记录帧2命令 执行帧1
t2 记录帧3命令 执行帧2
t3 等待GPU 执行帧3
↑ 这里CPU必须等待,因为N=3的窗口已满

此时可隐藏的延迟 = (3-1)*min(3ms,5ms) = 6ms
意味着系统能容忍6ms的突发延迟而不掉帧

工程实践意义

参数选择策略:
瓶颈方 优化建议 数学原理
CPU受限 增加MAX_CONCURRENT_FRAMES 增大N使T_GPU×N > T_CPU
GPU受限 降低画质/分辨率 减小分母max(T_CPU,T_GPU)
均衡状态 保持N=2~3 达到1/max(T,T)的理论极限
性能诊断方法:
1
2
3
4
5
auto calc_optimal_N = [](float cpu_time, float gpu_time) {
float bottleneck = std::max(cpu_time, gpu_time);
float hidden_latency = (target_latency - vsync_interval);
return std::ceil(hidden_latency / std::min(cpu_time,gpu_time)) + 1;
};

现代GPU架构的影响

在RDNA3/Turing等架构中,由于:

  1. 异步计算引擎的引入
  2. 硬件指令级并行(ILP)增强
  3. 显存压缩技术

实际观测到公式需要修正为:

1
有效帧率 = \frac{1}{\max(T_{CPU}, \alpha \cdot T_{GPU})}

其中α是GPU并行化系数(通常0.7~0.9)

高级应用场景

在VR系统中,通常需要:

1
N = \left\lceil \frac{motion-to-photon}{\min(T_{CPU},T_{GPU})} \right\rceil + 1

例如Oculus Rift要求的20ms延迟:

  • 如果min(T_CPU,T_GPU)=5ms → N=20/5+1=5

这种场景下就需要更复杂的帧交错(Frame Interleaving)技术。

C++具体实现分析

在代码中体现为三重资源组:

1
2
3
4
5
6
7
8
9
// 每组资源包含:
struct FrameResources {
VkCommandBuffer commandBuffer; // 命令缓冲区
VkSemaphore presentCompleteSem; // 图像可用信号量
VkSemaphore renderCompleteSem; // 渲染完成信号量
VkFence queueSubmitFence; // 队列提交栅栏
UniformBuffer uniformBuffer; // 每帧UBO
};
std::array<FrameResources, MAX_CONCURRENT_FRAMES> frames;

参数选择建议

根据硬件特性调整:

硬件配置 推荐值 理论收益
集成显卡(移动设备) 2 避免内存带宽饱和
独立显卡(桌面级) 3 利用高显存带宽
多GPU交火系统 4 掩盖跨GPU通信延迟

延迟分析

增加该值会引入:

1
总延迟 = 呈现延迟 + (N-1)×帧间隔

例如60FPS下:

  • N=2 → 最大延迟16.7ms + 16.7ms = 33.4ms
  • N=3 → 最大延迟增加到50.1ms

调试技巧

可通过Vulkan调试扩展验证实际利用率:

1
2
3
4
5
6
void checkPipelineState() {
VkPipelineStageFlags stage;
vkGetFenceStatus(device, frames[currentFrame].queueSubmitFence, &stage);
// 如果stage长期处于VK_PIPELINE_STAGE_ALL_COMMANDS_BIT
// 说明GPU资源饱和,可考虑增大MAX_CONCURRENT_FRAMES
}

高级用法

配合VK_KHR_timeline_semaphore扩展可实现动态帧数:

1
2
uint32_t dynamicFrameCount = calculate_optimal_frame_count();
resizeFrameResources(dynamicFrameCount); // 运行时调整资源组大小

最佳实践:从2开始逐步增加,使用VK_LAYER_KHRONOS_performance层监测GPU空闲率,当达到95%以上GPU利用率时停止增加。

C++ 第一课-万物皆地址

作者:朕小猫与GPT4

怎么理解C++万物皆地址

在 C++ 程序中,几乎所有元素(包括变量、对象、函数等)都可以通过它们的内存地址来引用或操作。这一点归根结底是因为 C++ 是一种底层的编程语言,它允许直接访问内存和底层硬件资源。这种能力带来了极高的灵活性和控制能力,但同时也要求程序员对程序的内存使用有深入的理解。下面是如何通过内存地址来引用或操作元素的几个方面:

  1. 变量和对象的内存地址

    在 C++ 中,每个变量或对象都占据内存中的一个或多个连续位置。你可以使用 & 操作符获得一个变量的内存地址,这个地址可以赋给一个指针变量。通过指针,你可以读取或修改存储在该地址的数据。这对于数组和自定义数据结构尤其有用,因为可以通过指针算术进行高效的遍历和访问。

  2. 函数的地址

    C++ 允许获取函数的地址,并将其赋给函数指针。这使得程序可以在运行时决定调用哪个函数,提高了代码的灵活性和可重用性。函数指针广泛应用于回调机制、事件驱动编程和接口设计中。

  3. 引用

    C++ 的引用机制实际上是一种特殊的语法糖,它在内部使用地址来实现。引用允许你创建一个变量的别名,通过别名可以直接访问和修改原始变量,而无需显式解引用。这样不仅代码更加简洁,而且避免了指针可能带来的错误。

  4. 动态内存分配

    C++ 通过 newdelete 操作符提供了动态内存分配的能力。使用 new 分配的内存位于堆区,它会返回指向分配内存的指针。通过这个指针,程序可以在运行时动态地分配、访问和释放内存。

通过上述机制,C++ 程序可以精确地控制内存的使用方式,从而优化程序性能和资源管理。然而,这也意呀着程序员必须对指针、内存地址以及内存管理有充分的理解,才能有效地利用这些特性编写高效、安全的代码。

编写教程时,目标是清晰、简洁且具有实用性。以下是一个关于“如何在 C++ 中使用指针和地址”教程的示例内容。这段内容旨在介绍初学者到 C++ 的指针和地址概念,帮助他们理解和运用“万物皆地址”的原则。


C++ 指针和地址入门

在 C++ 中,理解指针和地址是掌握语言的基石。本教程将引导你通过简单的步骤来理解和使用指针和地址。

1. 理解地址

在 C++ 中,每个变量都存储在内存的一个特定位置,这个位置可以通过地址来访问。你可以使用 & 操作符来获取变量的地址。

1
2
3
4
5
6
7
8
#include <iostream>
using namespace std;

int main() {
int var = 10;
cout << "变量的地址: " << &var << endl;
return 0;
}

这段代码会输出变量 var 的内存地址。

2. 使用指针

指针是存储另一个变量地址的变量。在 C++ 中,你可以使用 * 来声明指针。

1
2
3
4
5
6
7
8
9
10
#include <iostream>
using namespace std;

int main() {
int var = 10;
int *ptr = &var;
cout << "ptr 指向的地址: " << ptr << endl;
cout << "ptr 指向的地址上的值: " << *ptr << endl;
return 0;
}

在这个例子中,ptr 是一个指向 var 地址的指针。通过 *ptr,我们可以访问这个地址上的值。

3. 指针的使用场景

指针在 C++ 中的应用非常广泛,包括:

  • 动态内存管理:使用 newdelete 操作符分配和释放内存。
  • 函数参数传递:通过传递指针或引用来修改函数外的变量。
  • 构建复杂数据结构:如链表、树等。

4. 练习:使用指针交换两个变量的值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
using namespace std;

void swap(int *x, int *y) {
int temp = *x;
*x = *y;
*y = temp;
}

int main() {
int a = 5, b = 10;
swap(&a, &b);
cout << "a: " << a << ", b: " << b << endl;
return 0;
}

这个练习演示了如何使用指针来交换两个变量的值。

C++ 通过地址修改值

在 C++ 中,通过地址修改值是指针操作的一个基本用途。指针是一种特殊的变量,其值为另一个变量的内存地址。
通过指针,你可以直接访问和修改它指向的内存位置上存储的数据。
这一过程涉及几个关键步骤:获取变量的地址、使用指针指向该地址、通过指针修改该地址上的值。
下面通过一个简单的示例来说明这一过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
using namespace std;

int main() {
int value = 10; // 定义一个变量value并初始化为10
int *ptr = &value; // 定义一个指针ptr,将其初始化为value的地址

cout << "Original value: " << value << endl; // 显示原始的value值

*ptr = 20; // 通过指针ptr修改value的值

cout << "Modified value: " << value << endl; // 显示修改后的value值

return 0;
}

在上述代码中:

  1. 我们首先定义了一个类型为 int 的变量 value 并初始化为 10。
  2. 然后,我们定义了一个类型为 int* 的指针 ptr 并将其初始化为 value 的地址(&value)。
  3. 通过 cout 输出语句,我们可以看到 value 的原始值。
  4. 接着,我们通过指针 ptr 来修改 value 的值。这里的 *ptr = 20; 表示将 ptr 指向的内存位置(即 value 的位置)上的数据修改为 20。*ptr 是解引用操作符的应用,它获取指针指向的内存地址上存储的值。
  5. 最后,再次通过 cout 输出 value 的值,可以看到它已经被修改为 20。

函数传参的内存地址

在 C++ 中,理解函数传参的内存地址涉及到两个主要概念:按值传递(Pass by Value)和按引用传递(Pass by Reference)。理解这些概念有助于深入理解 C++ 如何在函数调用中处理参数的内存地址。

按值传递(Pass by Value)

当函数参数是按值传递时,函数接收的是实参的一个副本。这意味着函数内部对参数所做的任何修改都不会影响到原始数据。在内存层面,这个过程涉及到将原始数据的值复制到新的内存地址中。这个新的地址是函数参数在函数调用栈上的局部地址。

优点

  • 保护了原始数据,避免了意外修改。
  • 对于基本数据类型,这种方式简单且效率较高。

缺点

  • 对于大型结构或类实例,复制可能导致性能下降。
  • 无法在函数外部反映函数内部对数据的修改。

按引用传递(Pass by Reference)

按引用传递意味着函数接收的是实参的引用(或者说是内存地址)。这样,函数内部对参数的任何修改都会直接影响到原始数据。在内存层面,这避免了数据的复制,函数参数直接使用了实参的地址。

优点

  • 可以直接修改原始数据。
  • 避免了大型数据结构的复制,提高了效率。
  • 可以通过返回多个结果值(通过修改传入的引用或指针参数)。

缺点

  • 如果不希望修改原始数据,需要谨慎操作。
  • 使用不当可能导致错误或数据损坏。

举个例子

假设我们有一个简单的函数,目的是修改一个整数的值。

1
2
3
4
5
6
7
8
9
10
11
// 按值传递
void addTenByValue(int number) {
number += 10;
// 这里修改的是number的副本,外部的原始变量不受影响
}

// 按引用传递
void addTenByReference(int &number) {
number += 10;
// 这里直接修改的是传入变量的值,外部的原始变量也会被修改
}

在这个例子中,addTenByValue 函数无法修改外部变量的值,因为它仅操作了参数的副本。而 addTenByReference 函数则直接操作了实参的内存地址,因此它能够修改外部变量的值。

Android Game Project 核心 Renderer.cpp

Renderer 类图组成:

  • 类名:Renderer

  • 属性

    • EGLDisplay display_: 用于OpenGL ES渲染的显示设备。它是一个与本地显示系统相关联的EGL显示连接。
    • EGLSurface surface_: OpenGL ES渲染的表面。这是一个EGL表面,代表可以渲染OpenGL ES图形的绘图目标。
    • EGLContext context_: OpenGL ES渲染的上下文。它是一个封装了OpenGL ES状态机的EGL渲染上下文。
    • int width_: 渲染表面的宽度,以像素为单位。
    • int height_: 渲染表面的高度,以像素为单位。
    • bool shaderNeedsNewProjectionMatrix_: 一个标志,指示是否需要为着色器生成新的投影矩阵。当渲染表面的大小改变时,这个标志会被设置为true
    • std::unique_ptr shader_: 指向当前使用的Shader对象的智能指针。Shader对象用于编译、链接和使用顶点和片段着色器。
    • std::vector models_: 包含所有要渲染的模型的容器。每个Model对象包含顶点数据、索引数据和纹理数据。

    方法

    • 析构函数 ~Renderer(): 清理Renderer对象,包括释放EGL资源(如显示设备、渲染表面和上下文)。
    • void render(): 执行渲染循环的一次迭代。这包括更新渲染状态、绘制模型和交换渲染表面的缓冲区。
    • void initRenderer(): 初始化渲染器,包括设置EGL上下文、选择EGL配置、创建渲染表面和上下文、初始化OpenGL ES状态和加载着色器。
    • void updateRenderArea(): 更新渲染区域的大小。如果渲染表面的大小发生变化,此方法更新width_height_属性,并标记需要为着色器生成新的投影矩阵。
    • void createModels(): 创建演示模型。这个方法加载模型的顶点、索引和纹理数据,然后将模型添加到models_容器中。
    • void handleInput(): 处理输入事件,如触摸和按键。这个方法从Android的输入事件队列中读取事件,并根据需要响应这些事件。
  • 关联和依赖关系

    • 依赖于Shader类:由于shader_属性和在initRenderer()方法中对Shader的调用。
    • 依赖于VertexIndex类型:在createModels()方法中使用这些类型来创建模型。
    • 依赖于TextureAsset类:在createModels()方法中加载纹理资源。
    • 使用了标准库中的类型,如std::vectorstd::unique_ptr

这个类图还可以展示Renderer类如何与Android的本地应用粘合层(native_app_glue)和OpenGL ES 3.0交互,特别是如何处理EGL上下文、表面创建和渲染循环。

下面是一个简化的类图表示,重点在于Renderer类及其直接关系:

1
2
3
4
5
6
7
8
[Renderer] --|> [EGLDisplay]
[Renderer] --|> [EGLSurface]
[Renderer] --|> [EGLContext]
[Renderer] ---> [Shader]
[Renderer] --* [Model]
[Model] --* [Vertex]
[Model] --* [Index]
[Model] ---> [TextureAsset]

说明:

  • --|> 表示拥有或创建关系。
  • ---> 表示依赖关系。
  • --* 表示包含或集合关系。
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
#include "Renderer.h"

#include <game-activity/native_app_glue/android_native_app_glue.h>
#include <GLES3/gl3.h>
#include <memory>
#include <vector>
#include <android/imagedecoder.h>

#include "AndroidOut.h"
#include "Shader.h"
#include "Utility.h"
#include "TextureAsset.h"

// 宏定义,执行glGetString并将结果输出到logcat
#define PRINT_GL_STRING(s) {aout << #s": "<< glGetString(s) << std::endl;}

// 宏定义,如果glGetString返回一个空格分隔的列表,则将每个元素打印在新行上
/*!
* @brief if glGetString returns a space separated list of elements, prints each one on a new line
*
* This works by creating an istringstream of the input c-style string. Then that is used to create
* a vector -- each element of the vector is a new element in the input string. Finally a foreach
* loop consumes this and outputs it to logcat using @a aout
*/
#define PRINT_GL_STRING_AS_LIST(s) { \
std::istringstream extensionStream((const char *) glGetString(s));\
std::vector<std::string> extensionList(\
std::istream_iterator<std::string>{extensionStream},\
std::istream_iterator<std::string>());\
aout << #s":\n";\
for (auto& extension: extensionList) {\
aout << extension << "\n";\
}\
aout << std::endl;\
}

// 定义一种颜色,玉米花蓝色。可以直接发送给glClearColor函数。
#define CORNFLOWER_BLUE 100 / 255.f, 149 / 255.f, 237 / 255.f, 1

// 顶点着色器和片段着色器的代码,通常这些会从资源文件中加载
static const char *vertex = R"vertex(#version 300 es
in vec3 inPosition;
in vec2 inUV;

out vec2 fragUV;

uniform mat4 uProjection;

void main() {
fragUV = inUV;
gl_Position = uProjection * vec4(inPosition, 1.0);
}
)vertex";

static const char *fragment = R"fragment(#version 300 es
precision mediump float;

in vec2 fragUV;

uniform sampler2D uTexture;

out vec4 outColor;

void main() {
outColor = texture(uTexture, fragUV);
}
)fragment";


// 投影矩阵的半高度,这将给你一个从-2到2的高度为4的可渲染区域
static constexpr float kProjectionHalfHeight = 2.f;


// 投影矩阵的近平面距离。由于这是一个正交投影矩阵,负值便于排序(避免在0处的z-fighting)
static constexpr float kProjectionNearPlane = -1.f;

// 投影矩阵的远平面距离。与近平面等距的设置便于处理。
/*!
* The far plane distance for the projection matrix. Since this is an orthographic porjection
* matrix, it's convenient to have the far plane equidistant from 0 as the near plane.
*/
static constexpr float kProjectionFarPlane = 1.f;

// Renderer析构函数,处理EGL上下文的清理工作。
Renderer::~Renderer() {
if (display_ != EGL_NO_DISPLAY) {
eglMakeCurrent(display_, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
if (context_ != EGL_NO_CONTEXT) {
eglDestroyContext(display_, context_);
context_ = EGL_NO_CONTEXT;
}
if (surface_ != EGL_NO_SURFACE) {
eglDestroySurface(display_, surface_);
surface_ = EGL_NO_SURFACE;
}
eglTerminate(display_);
display_ = EGL_NO_DISPLAY;
}
}

// 渲染函数,包括渲染过程中的各种状态更新和绘制调用。
void Renderer::render() {
// Check to see if the surface has changed size. This is _necessary_ to do every frame when
// using immersive mode as you'll get no other notification that your renderable area has
// changed.
updateRenderArea();

// When the renderable area changes, the projection matrix has to also be updated. This is true
// even if you change from the sample orthographic projection matrix as your aspect ratio has
// likely changed.
if (shaderNeedsNewProjectionMatrix_) {
// a placeholder projection matrix allocated on the stack. Column-major memory layout
float projectionMatrix[16] = {0};

// build an orthographic projection matrix for 2d rendering
Utility::buildOrthographicMatrix(
projectionMatrix,
kProjectionHalfHeight,
float(width_) / height_,
kProjectionNearPlane,
kProjectionFarPlane);

// send the matrix to the shader
// Note: the shader must be active for this to work. Since we only have one shader for this
// demo, we can assume that it's active.
shader_->setProjectionMatrix(projectionMatrix);

// make sure the matrix isn't generated every frame
shaderNeedsNewProjectionMatrix_ = false;
}

// clear the color buffer
glClear(GL_COLOR_BUFFER_BIT);

// Render all the models. There's no depth testing in this sample so they're accepted in the
// order provided. But the sample EGL setup requests a 24 bit depth buffer so you could
// configure it at the end of initRenderer
if (!models_.empty()) {
for (const auto &model: models_) {
shader_->drawModel(model);
}
}

// Present the rendered image. This is an implicit glFlush.
auto swapResult = eglSwapBuffers(display_, surface_);
assert(swapResult == EGL_TRUE);
}

// 初始化渲染器,设置EGL上下文和OpenGL状态。
void Renderer::initRenderer() {
// Choose your render attributes
constexpr
EGLint attribs[] = {
EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT,
EGL_SURFACE_TYPE, EGL_WINDOW_BIT,
EGL_BLUE_SIZE, 8,
EGL_GREEN_SIZE, 8,
EGL_RED_SIZE, 8,
EGL_DEPTH_SIZE, 24,
EGL_NONE
};

// The default display is probably what you want on Android
auto display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
eglInitialize(display, nullptr, nullptr);

// figure out how many configs there are
EGLint numConfigs;
eglChooseConfig(display, attribs, nullptr, 0, &numConfigs);

// get the list of configurations
std::unique_ptr < EGLConfig[] > supportedConfigs(new EGLConfig[numConfigs]);
eglChooseConfig(display, attribs, supportedConfigs.get(), numConfigs, &numConfigs);

// Find a config we like.
// Could likely just grab the first if we don't care about anything else in the config.
// Otherwise hook in your own heuristic
auto config = *std::find_if(
supportedConfigs.get(),
supportedConfigs.get() + numConfigs,
[&display](const EGLConfig &config) {
EGLint red, green, blue, depth;
if (eglGetConfigAttrib(display, config, EGL_RED_SIZE, &red)
&& eglGetConfigAttrib(display, config, EGL_GREEN_SIZE, &green)
&& eglGetConfigAttrib(display, config, EGL_BLUE_SIZE, &blue)
&& eglGetConfigAttrib(display, config, EGL_DEPTH_SIZE, &depth)) {

aout << "Found config with " << red << ", " << green << ", " << blue << ", "
<< depth << std::endl;
return red == 8 && green == 8 && blue == 8 && depth == 24;
}
return false;
});

aout << "Found " << numConfigs << " configs" << std::endl;
aout << "Chose " << config << std::endl;

// create the proper window surface
EGLint format;
eglGetConfigAttrib(display, config, EGL_NATIVE_VISUAL_ID, &format);
EGLSurface surface = eglCreateWindowSurface(display, config, app_->window, nullptr);

// Create a GLES 3 context
EGLint contextAttribs[] = {EGL_CONTEXT_CLIENT_VERSION, 3, EGL_NONE};
EGLContext context = eglCreateContext(display, config, nullptr, contextAttribs);

// get some window metrics
auto madeCurrent = eglMakeCurrent(display, surface, surface, context);
assert(madeCurrent);

display_ = display;
surface_ = surface;
context_ = context;

// make width and height invalid so it gets updated the first frame in @a updateRenderArea()
width_ = -1;
height_ = -1;

PRINT_GL_STRING(GL_VENDOR);
PRINT_GL_STRING(GL_RENDERER);
PRINT_GL_STRING(GL_VERSION);
PRINT_GL_STRING_AS_LIST(GL_EXTENSIONS);

shader_ = std::unique_ptr<Shader>(
Shader::loadShader(vertex, fragment, "inPosition", "inUV", "uProjection"));
assert(shader_);

// Note: there's only one shader in this demo, so I'll activate it here. For a more complex game
// you'll want to track the active shader and activate/deactivate it as necessary
shader_->activate();

// setup any other gl related global states
glClearColor(CORNFLOWER_BLUE);

// enable alpha globally for now, you probably don't want to do this in a game
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

// get some demo models into memory
createModels();
}

// 更新渲染区域的大小,如果有变化,则更新视口和投影矩阵。
void Renderer::updateRenderArea() {
EGLint width;
eglQuerySurface(display_, surface_, EGL_WIDTH, &width);

EGLint height;
eglQuerySurface(display_, surface_, EGL_HEIGHT, &height);

if (width != width_ || height != height_) {
width_ = width;
height_ = height;
glViewport(0, 0, width, height);

// make sure that we lazily recreate the projection matrix before we render
shaderNeedsNewProjectionMatrix_ = true;
}
}

// 创建演示模型的函数。
/**
* @brief Create any demo models we want for this demo.
*/
void Renderer::createModels() {
/*
* This is a square:
* 0 --- 1
* | \ |
* | \ |
* | \ |
* 3 --- 2
*/
std::vector <Vertex> vertices = {
Vertex(Vector3{1, 1, 0}, Vector2{0, 0}), // 0
Vertex(Vector3{-1, 1, 0}, Vector2{1, 0}), // 1
Vertex(Vector3{-1, -1, 0}, Vector2{1, 1}), // 2
Vertex(Vector3{1, -1, 0}, Vector2{0, 1}) // 3
};
std::vector <Index> indices = {
0, 1, 2, 0, 2, 3
};

// loads an image and assigns it to the square.
//
// Note: there is no texture management in this sample, so if you reuse an image be careful not
// to load it repeatedly. Since you get a shared_ptr you can safely reuse it in many models.
auto assetManager = app_->activity->assetManager;
auto spAndroidRobotTexture = TextureAsset::loadAsset(assetManager, "android_robot.png");

// Create a model and put it in the back of the render list.
models_.emplace_back(vertices, indices, spAndroidRobotTexture);
}

// 处理输入事件的函数,如触摸和按键事件。
void Renderer::handleInput() {
// handle all queued inputs
auto *inputBuffer = android_app_swap_input_buffers(app_);
if (!inputBuffer) {
// no inputs yet.
return;
}

// handle motion events (motionEventsCounts can be 0).
for (auto i = 0; i < inputBuffer->motionEventsCount; i++) {
auto &motionEvent = inputBuffer->motionEvents[i];
auto action = motionEvent.action;

// Find the pointer index, mask and bitshift to turn it into a readable value.
auto pointerIndex = (action & AMOTION_EVENT_ACTION_POINTER_INDEX_MASK)
>> AMOTION_EVENT_ACTION_POINTER_INDEX_SHIFT;
aout << "Pointer(s): ";

// get the x and y position of this event if it is not ACTION_MOVE.
auto &pointer = motionEvent.pointers[pointerIndex];
auto x = GameActivityPointerAxes_getX(&pointer);
auto y = GameActivityPointerAxes_getY(&pointer);

// determine the action type and process the event accordingly.
switch (action & AMOTION_EVENT_ACTION_MASK) {
case AMOTION_EVENT_ACTION_DOWN:
case AMOTION_EVENT_ACTION_POINTER_DOWN:
aout << "(" << pointer.id << ", " << x << ", " << y << ") "
<< "Pointer Down";
break;

case AMOTION_EVENT_ACTION_CANCEL:
// treat the CANCEL as an UP event: doing nothing in the app, except
// removing the pointer from the cache if pointers are locally saved.
// code pass through on purpose.
case AMOTION_EVENT_ACTION_UP:
case AMOTION_EVENT_ACTION_POINTER_UP:
aout << "(" << pointer.id << ", " << x << ", " << y << ") "
<< "Pointer Up";
break;

case AMOTION_EVENT_ACTION_MOVE:
// There is no pointer index for ACTION_MOVE, only a snapshot of
// all active pointers; app needs to cache previous active pointers
// to figure out which ones are actually moved.
for (auto index = 0; index < motionEvent.pointerCount; index++) {
pointer = motionEvent.pointers[index];
x = GameActivityPointerAxes_getX(&pointer);
y = GameActivityPointerAxes_getY(&pointer);
aout << "(" << pointer.id << ", " << x << ", " << y << ")";

if (index != (motionEvent.pointerCount - 1)) aout << ",";
aout << " ";
}
aout << "Pointer Move";
break;
default:
aout << "Unknown MotionEvent Action: " << action;
}
aout << std::endl;
}
// clear the motion input count in this buffer for main thread to re-use.
android_app_clear_motion_events(inputBuffer);

// handle input key events.
for (auto i = 0; i < inputBuffer->keyEventsCount; i++) {
auto &keyEvent = inputBuffer->keyEvents[i];
aout << "Key: " << keyEvent.keyCode << " ";
switch (keyEvent.action) {
case AKEY_EVENT_ACTION_DOWN:
aout << "Key Down";
break;
case AKEY_EVENT_ACTION_UP:
aout << "Key Up";
break;
case AKEY_EVENT_ACTION_MULTIPLE:
// Deprecated since Android API level 29.
aout << "Multiple Key Actions";
break;
default:
aout << "Unknown KeyEvent Action: " << keyEvent.action;
}
aout << std::endl;
}
// clear the key input count too.
android_app_clear_key_events(inputBuffer);
}

UBO 与帧重叠

这段代码定义了一个名为 uniformBuffers 的数组,它包含 MAX_CONCURRENT_FRAMESUniformBuffer 对象。这种设计通常用于图形渲染中的多缓冲技术(如双缓冲或三缓冲),目的是实现帧重叠(frame overlapping)并避免资源冲突。

关键点解释:

  1. 帧重叠(Frame Overlapping)

    • 现代图形 API(如 Vulkan/DirectX 12)允许 CPU 准备下一帧的数据,同时 GPU 仍在渲染当前帧。
    • 这需要为每一帧使用独立的资源,防止 CPU 覆盖 GPU 正在使用的数据。
  2. **MAX_CONCURRENT_FRAMES**:

    • 表示系统支持的最大并行处理帧数(通常是 2 或 3)。
    • 例如:Vulkan 中常设为 VK_MAX_FRAMES_IN_FLIGHT
  3. **UniformBuffer**:

    • 存储每帧需要的统一/常量数据(如 MVP 矩阵、光照参数等)。
    • 每个活跃帧都有自己独立的 UBO,防止写入冲突。
  4. 工作流程示例

    1
    2
    3
    4
    5
    // 帧N使用它的UBO
    drawFrameN(uniformBuffers[currentFrame]);

    // 同时,CPU可以更新帧N+1的UBO
    updateUniformBuffer(uniformBuffers[(currentFrame + 1) % MAX_CONCURRENT_FRAMES]);

为什么需要这样设计?

  • 安全性:避免 GPU 读取 UBO 时被 CPU 写入破坏数据。
  • 性能:允许 CPU 和 GPU 并行工作,提高吞吐量。
  • 同步:配合栅栏(Fence)/信号量(Semaphore)实现正确的帧调度。

这是现代图形编程中实现高效资源管理的典型模式。