diff --git a/README.md b/README.md new file mode 100644 index 0000000..dae59ed --- /dev/null +++ b/README.md @@ -0,0 +1,289 @@ +# Face SDK 使用说明 + +基于 Vulkan + MediaPipe FaceLandmarker 的实时人脸贴图渲染 SDK,主要面向**美妆 / AR 试妆**场景:摄像头预览 → 人脸 landmark 检测 → 多帧 PNG 贴图按 motion 序列贴在人脸上。 + +支持任意分辨率手机(采用"短边正方形画布 + 长边 letterbox"布局)、前置 / 后置摄像头自动切换、镜子镜像效果。 + +--- + +## 目录结构 + +``` +face_sdk/ +├── app/ ← SDK 主体(可作为 AAR 给业务方接入) +│ ├── src/main/java/com/hmwl/face_sdk/ +│ │ ├── FaceActivity.java ← 业务方继承的核心 Activity +│ │ ├── FaceLandmarkerHelper.kt ← MediaPipe 封装 +│ │ ├── DebugLog.java ← Java/C++ 统一日志(写文件) +│ │ ├── InitArg.java / Motion.java / MotionList.java / Frame.java ← JSON 配置 POJO +│ │ └── ... +│ ├── src/main/cpp/main.cpp ← JNI + native 主循环 +│ └── src/main/assets/shaders/ ← Vulkan GLSL(bg/texture/...) +│ +├── vulkan/ ← 跨平台 Vulkan 渲染核心 +│ ├── Application.{h,cpp} ← Vulkan 初始化 / 窗口生命周期 +│ └── FaceApp.{h,cpp} ← 业务渲染(face mesh + bg quad + motion 调度) +│ +├── example/ ← 示例 app,演示完整接入 +│ ├── src/main/java/com/inewme/uvmirror/ +│ │ ├── MainActivity.kt ← 入口(Compose UI,前/后置选择) +│ │ ├── MakeupActivity.kt ← 业务 Activity(继承 FaceActivity) +│ │ └── MotionManager.kt ← motion 资源加载封装 +│ ├── src/main/AndroidManifest.xml +│ └── src/main/assets/pic/ +│ ├── motion_data_ex.json ← motion 元数据(帧文件名 + 锚点 x/y) +│ └── /000xxx.png ← motion 各帧贴图 +│ +├── compile_shader.bat ← 编译所有 .vert/.frag 为 .spv(必须在改 shader 后执行) +└── third_party/ ← VMA / glm / glfw / mediapipe … +``` + +--- + +## 快速上手 + +### 1. 编译 + 运行 example + +```powershell +# Windows 上,确保 SDK 路径在 local.properties 里,以及 NDK 已安装 +.\gradlew.bat :example:installDebug +``` + +启动后: +- 主界面有"前置摄像头 / 后置摄像头"两个 RadioButton(默认前置) +- 点击 "Go to Makeup" 进入 `MakeupActivity` +- `MakeupActivity` 自动加载 `assets/pic/motion_data_ex.json` 配置的 motion,按 `update()` 里的脚本播放 + +### 2. 改 shader 后必须重编 + +```powershell +.\compile_shader.bat +.\gradlew.bat :example:installDebug +``` + +--- + +## 在自己的 App 里集成 + +### Step 1:依赖 SDK 模块 + +在你的 `settings.gradle` / `build.gradle` 引入 `:app` 模块(或打包成 AAR)。 + +### Step 2:在 `AndroidManifest.xml` 声明业务 Activity + +业务 Activity 必须**竖屏**,且**屏蔽常见 configChanges 不要重建**: + +```xml + +``` + +### Step 3:继承 `FaceActivity` 写业务 Activity + +```kotlin +class MakeupActivity : FaceActivity() { + lateinit var motionMgr: MotionManager + private var isMotionLoadFinished = false + + override fun onCreate(savedInstanceState: Bundle?) { + // 在 super.onCreate 之前构造 MotionManager(它会调 SetInitArg) + motionMgr = MotionManager( + this, + /* fps */ 10, + /* zoom */ 1.5f, + /* r,g,b */ 0f, 0f, 1f, // letterbox / 圆外纯色(蓝) + /* radius */ 240f, // 480 设计基线下的圆半径,SDK 自动按 canvas_size/480 缩放 + /* offset */ 200f, -200f // 480 设计基线下的中心偏移 + ) + + super.onCreate(savedInstanceState) + + // 一次性加载所有要用到的 motion,加载完才能 PlayMotionList + motionMgr.LoadMotionList(listOf("4", "56")) { + isMotionLoadFinished = true + } + + // 自己的 update 循环(生命周期感知,避免泄漏) + lifecycleScope.launch { + while (true) { + update() + delay(10) + } + } + } + + private fun update() { + if (!isMotionLoadFinished) return + // 想播什么就调,调 SDK API(详见下面"API 参考") + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { + if (keyCode == KeyEvent.KEYCODE_BACK) { + Stop() // ← 退出 Activity 前必须先 Stop(),否则 native 线程仍在跑 + finish() + return true + } + return super.onKeyDown(keyCode, event) + } +} +``` + +### Step 4:从入口 Activity 启动业务 Activity,传入摄像头朝向 + +```kotlin +import androidx.camera.core.CameraSelector +import com.hmwl.face_sdk.FaceActivity + +val intent = Intent(context, MakeupActivity::class.java).apply { + // 前置:CameraSelector.LENS_FACING_FRONT;后置:LENS_FACING_BACK + // 不传时 SDK 默认走后置(向后兼容) + putExtra(FaceActivity.EXTRA_LENS_FACING, CameraSelector.LENS_FACING_FRONT) +} +context.startActivity(intent) +``` + +前置摄像头 SDK 会自动: +- 在 GPU 端把 bg + face mesh 整体水平翻转 → 镜子效果 +- mediapipe 输入仍是 raw(未镜像)帧,所以 landmark 与 raw 帧 UV 严格对齐 + +--- + +## API 参考(`FaceActivity` 公开方法) + +| 方法 | 说明 | +| --- | --- | +| `static final String EXTRA_LENS_FACING` | Intent extra key,值为 `CameraSelector.LENS_FACING_FRONT` 或 `LENS_FACING_BACK` | +| `void SetInitArg(String json)` | 设置全局参数(zoom / r,g,b / radius / offset_x,offset_y / action_fps),JSON 格式见 `InitArg` | +| `String PreLoadAction(String json, LoadMotionListFinishedCallback cb)` | 异步预加载一组 motion 资源;加载完毕回调 cb | +| `void PlayMotionList(List motionList, boolean loop, PlayMotionListFinishedCallback cb)` | 播放 motion 序列,`loop=true` 时无限循环,`cb` 为播完一轮的回调(loop 模式下不会触发,传 `null` 即可) | +| `void StopMotion()` | 暂停当前 motion 播放(停在当前帧) | +| `void ResumeMotion()` | 恢复 `StopMotion()` 之前的播放进度 | +| `void Stop()` | **完全停止 native 渲染**,退出 Activity 前必须调 | + +**注意:** `LoadMotionList` / `PreLoadAction` 是异步的,回调里设置 `isMotionLoadFinished=true` 之后才能 `PlayMotionList`,否则播放命令会被静默丢弃。 + +--- + +## 资源准备 + +### Motion 帧图 + +每个 motion 用一个独立目录,PNG 帧按文件名顺序播放: + +``` +assets/pic/4/000000.png +assets/pic/4/000001.png +... +assets/pic/4/000031.png +``` + +PNG 必须是 **RGBA**(透明通道),SDK 直接采样后 alpha-blend 到 bg 上方。 + +### `motion_data_ex.json` + +motion 元数据,描述每帧贴图的**锚点偏移**(贴在脸上的相对位置,画布坐标系,原点在屏幕中心): + +```json +{ + "4": { + "000000.png": { "x": 0.0, "y": 0.0 }, + "000001.png": { "x": 0.0, "y": 0.0 }, + ... + }, + "56": { + "000000.png": { "x": 0.0, "y": 0.0 }, + ... + } +} +``` + +--- + +## 设计要点(接入方可不读,但要改 shader / 渲染时必读) + +### 1. 画布与 letterbox + +- **画布**:屏幕短边的正方形,居中。屏幕长边方向自然 letterbox(上下或左右黑边)。 +- 画布坐标系("画布 NDC")∈ [-1, +1]²,原点在画布中心。 +- mediapipe FaceLandmarker 输出的归一化 landmark ∈ [0, 1]² 直接对应画布 NDC(经过 `*2-1`)。 +- 业务参数 `zoom / offset_x / offset_y / radius` 都是**屏幕物理像素**(SDK 端按 `canvas_size / 480` 自动缩放,业务方用 480 设计基线即可)。 + +### 2. 摄像头帧 → 画布的几何流水线 + +``` +raw 摄像头帧 (W×H, 4:3 典型) + └─① 中央裁切 min(W,H)×min(W,H) 正方形 + └─② Matrix.postRotate(rotationDegrees) 顺时针旋转 → 正立 face crop + └─③ MediaPipe 输出 landmark ∈ [0,1]² ─────▶ 画布 NDC + └─ shader 做反向变换贴回 raw 帧 UV (bg) + └─ shader 直接当 face mesh 顶点 (face) +``` + +### 3. 镜子效果(前置摄像头) + +`PushConstants.mirror_x ∈ {0.0, 1.0}`: +- 前置时 = 1.0,shader 里把最终 NDC.x 翻转 (`pos.x *= 1.0 - 2.0*mirror_x`) +- bg + face mesh 都做同样翻转 → 整体水平镜像,对齐保持 +- mediapipe 仍接收**未镜像**的 raw 帧,避免双重镜像把 landmark 弄反 + +### 4. bg 与 face mesh 对齐不变量 + +bg 圆内显示摄像头视频,face mesh 把 motion png 贴到 landmark 位置——两者对齐依赖: + +> **屏幕同一像素位置,bg 显示的 raw UV = 把这个屏幕位置反算回 mediapipe landmark 坐标** + +实现方式:bg quad 与 face mesh **完全相同**地经过 `zoom × offset × letterbox × mirror` 几何链路;同时 bg 的 `outTexCoord` 基于"未变换的 quad 顶点 pos"算 raw UV——这样屏幕 fragment 反算的 pos 就等价于"对应 landmark 的画布 NDC",UV 完美贴合。 + +bg quad 顶点取 `±10`(而非 `±1`),保证任何 zoom/offset 下 quad 在屏幕上 GPU clip 后整屏覆盖,letterbox 也被 bg.frag 的 r/g/b 蓝色填充。 + +### 5. 摄像头分辨率适配 + +- CameraX 用 `setTargetResolution(640, 480)` 强制拿低分辨率帧(SDK 不需要高分辨率) +- shader 用 `camera_aspect = W/H` 和 `camera_rotation`(CameraX 给的 rotationDegrees)做 UV 反变换 +- 摄像头帧分辨率/朝向变化时,SDK 会在 `processCameraFrame` 里自动重建 bg 纹理 + +### 6. 窗口生命周期 + +- `APP_CMD_TERM_WINDOW` → `Application::cleanupForWindowLost()`:只销毁**窗口资源**(surface / swapchain / framebuffers / imageViews / commandBuffers / sync),保留 renderPass / pipelines / device / VMA / textures +- `APP_CMD_INIT_WINDOW` → `Application::reinitForNewWindow()`:复用全局资源,只重建窗口资源 +- `android_main` 退出时**不再额外销毁** Vulkan 资源(保留给下次 NativeActivity 复用),避免 use-after-free 崩溃 + +--- + +## 调试 + +### 日志文件 + +SDK 把 Java/Kotlin 日志(通过 `com.hmwl.face_sdk.DebugLog.i / e`)和 C++ 日志(`DebugLog::log`)**统一写入**: + +``` +/data/user/0//files/face_sdk_debug.log +``` + +```powershell +# 拉到本地分析 +adb pull /data/user/0/com.inewme.uvmirror.f20260113/files/face_sdk_debug.log . +``` + +崩溃时还会自动追加一段含信号、寄存器、backtrace 的 dump(`========== FACE_SDK CRASH ==========`)。 + +### 常见问题 + +| 现象 | 排查方向 | +| --- | --- | +| 黑屏 / 没有摄像头预览 | 检查相机权限是否授予;查看日志是否有 `processImageNative` 调用 | +| 人脸贴图不出现 | 检查 `motion_data_ex.json` 里的 motion id 是否在 `LoadMotionList` 里加载过;检查 `isMotionLoadFinished` 是否被回调置 true | +| 贴图不贴合人脸 | bg 与 face mesh 的 zoom/offset/mirror 必须严格一致——见"设计要点 #4 对齐不变量" | +| 切换摄像头后崩溃 | 见 `cleanupSecondInit` 注释;不要在 Android 路径调用它,仅 desktop 入口可用 | +| 改了 shader 不生效 | 必须先跑 `compile_shader.bat` 重新生成 `.spv` | + +--- + +## 兼容性 + +- minSdk 29,targetSdk 看 `app/build.gradle` +- 设备需支持 Vulkan 1.0+ 和 MediaPipe TFLite delegate +- 已在 MI 9(Android 10、Adreno 640、Vulkan vendor `qglinternal`)上验证通过 diff --git a/app/build.gradle b/app/build.gradle index da81e90..783f41d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -13,7 +13,7 @@ android { compileSdk 36 defaultConfig { //applicationId "com.hmwl.face_sdk" - minSdk 30 + minSdk 29 targetSdk 36 versionCode 1 versionName "1.0" @@ -117,7 +117,6 @@ dependencies { //implementation fileTree(dir: 'libs/tasks-vision', include: ['*.jar']) //implementation fileTree(dir: 'libs/tasks-core', include: ['*.jar']) - def camerax_version = '1.4.2' implementation "androidx.camera:camera-core:$camerax_version" implementation "androidx.camera:camera-camera2:$camerax_version" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1fab4eb..6455ce0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -16,7 +16,13 @@ + android:screenOrientation="portrait" + android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|keyboard|navigation|smallestScreenSize" + tools:ignore="MissingClass,LockedOrientationActivity"> + diff --git a/app/src/main/assets/shaders/bg.frag b/app/src/main/assets/shaders/bg.frag index 52a3257..b0f6a6c 100644 --- a/app/src/main/assets/shaders/bg.frag +++ b/app/src/main/assets/shaders/bg.frag @@ -1,41 +1,46 @@ #version 450 -// 输入变量(与顶点着色器输出对应) layout(location = 0) in vec2 inTexCoord; - -// 输出颜色 layout(location = 0) out vec4 outColor; -// 纹理采样器 layout(binding = 0) uniform sampler2D texSampler; +// ★ PushConstants 必须和 bg.vert / FaceApp.h 完全一致(包括我们没用到的 +// 字段 zoom/ux/uy/offset_x/offset_y,因为 push constant 是按 offset 取的, +// 缺字段会导致后面 radius/screen_w/screen_h 落到错误偏移)。 layout(push_constant) uniform PushConstants { float zoom; - float r; - float g; - float b; - float radius; - float ux; - float uy; -} pushConstants; + float r; + float g; + float b; + float radius; + float ux; + float uy; + float offset_x; + float offset_y; + float canvas_size; + float screen_w; + float screen_h; + // 相机帧元信息(fragment 不用,仅为对齐 push constant layout) + float camera_aspect; + float camera_rotation; + float mirror_x; +} pc; void main() { - // outFragColor = color; - // 获取当前片元的屏幕坐标(左下角为原点) + // gl_FragCoord 是屏幕 framebuffer 里的像素坐标(左下原点)。圆形 mask 中心 + // 锚定在屏幕真实中心 = (screen_w, screen_h)/2。这里不再用画布中心,因为 + // letterbox 后画布中心和屏幕中心其实是同一个点(min 边居中),但用屏幕坐标 + // 写法更直观也对 cover 模式更友好。 vec2 fragCoord = gl_FragCoord.xy; - - // 屏幕中心(480x480 的中心是 (240, 240)) - vec2 center = vec2(240.0, 240.0); - - // 计算到中心的距离(欧氏距离,单位:像素) + vec2 center = vec2(pc.screen_w, pc.screen_h) * 0.5; float dist = length(fragCoord - center); - // 判断是否在半径之外 - if (dist > pushConstants.radius) { - // 使用 push constant 中的 RGB 颜色(注意 alpha 设为 1.0) - outColor = vec4(pushConstants.r, pushConstants.g, pushConstants.b, 1.0); + if (dist > pc.radius) { + // 画布外(含 letterbox 黑边)+ 圆外区域统一用业务给的纯色覆盖。 + // 业务一般给 r=g=0 / b=1 之类,与原版语义保持一致。 + outColor = vec4(pc.r, pc.g, pc.b, 1.0); } else { - // 采样纹理 outColor = texture(texSampler, inTexCoord); } -} \ No newline at end of file +} diff --git a/app/src/main/assets/shaders/bg.frag.spv b/app/src/main/assets/shaders/bg.frag.spv index dcb02d2..8a6bc59 100644 Binary files a/app/src/main/assets/shaders/bg.frag.spv and b/app/src/main/assets/shaders/bg.frag.spv differ diff --git a/app/src/main/assets/shaders/bg.vert b/app/src/main/assets/shaders/bg.vert index 4d90fb5..ecfea8f 100644 --- a/app/src/main/assets/shaders/bg.vert +++ b/app/src/main/assets/shaders/bg.vert @@ -1,68 +1,131 @@ #version 450 -// 硬编码的顶点数据 - 4个顶点组成三角形带覆盖整个屏幕 -// const vec2 positions[6] = vec2[6]( -// vec2( 1.0, 1.0), // 右上 - 三角形1 -// vec2(-1.0, 1.0), // 左上 - 三角形1 -// vec2(-1.0, -1.0), // 左下 - 三角形1 - -// vec2(-1.0, -1.0), // 左下 - 三角形2 -// vec2( 1.0, -1.0), // 右下 - 三角形2 -// vec2( 1.0, 1.0) // 右上 - 三角形2 -// ); - -const float offset = (640-480)/(480.0); - - +// ★ bg quad 顶点放大到 ±10(而不是 ±1)的原因: +// +// bg 与 face mesh 的对齐依赖一个关键不变量—— +// "屏幕上同一像素位置,bg 显示的 raw UV = 把这个屏幕位置反算回 mediapipe +// landmark 坐标"。要保住这个不变量,bg quad 必须经过和 face mesh 完全相 +// 同的 zoom × offset × letterbox × mirror 链路 (见下方 gl_Position 计算), +// 而 outTexCoord 必须基于"未变换的 quad 顶点 pos"算 raw UV。这样屏幕 fragment +// 反算出的 quad pos 就等价于"对应的 landmark NDC",UV 完美对齐。 +// +// 但是 zoom (>1) + 非零 offset + screen_h > screen_w 的 letterbox 会让 quad +// 在屏幕上呈非对称分布——之前 quad 顶点 ±1 时实测在 zoom=1.5 offset=(450,-450) +// 屏幕 1080×2340 下,quad 在 NDC.y 只覆盖 [-0.885, 0.500],屏幕底部 +// [0.500, 1.0] 大片 letterbox 没有被 bg 覆盖,露出 RenderPass 的 clearColor +// 黑色——表现为"上面有蓝色填充,下面没有"。 +// +// 解法:把 quad 顶点扩大到 ±10。GPU 会把超出 NDC [-1, 1] 的部分硬件 clip 掉, +// 但 clip 之后留在屏幕内的 fragment 上,outTexCoord 是顶点的"线性插值",**屏幕 +// 内 fragment 反算出的 pos 与 quad 顶点取值范围无关**——所以 face mesh 对齐 +// 完全不受影响。同时 ±10 在任何合理的 zoom/offset/letterbox 下都能保证 quad +// 在屏幕上完全覆盖 [-1, 1]² 含 letterbox。 +// +// Worst-case 验证:zoom=1.5, offset=(450,-450), canvas=1080, screen=1080×2340, +// pos.y = -10 → screen_ndc.y = (-10*1.5 + (-450/1080)) * 1080/2340 = -7.12 ✓ +// pos.y = +10 → screen_ndc.y = (+10*1.5 + (-450/1080)) * 1080/2340 = +6.73 ✓ +// 远超屏幕范围,clip 后保证整屏 letterbox 都被 bg.frag 的 r/g/b 蓝色覆盖。 const vec2 positions[6] = vec2[6]( - vec2( 1.0, -1.0 -offset), // 右下 - vec2(1.0, 1.0 + offset), // 右上 - 三角形1 - vec2(-1.0, 1.0 + offset), // 左上 - 三角形1 + vec2( 10.0, -10.0), + vec2( 10.0, 10.0), + vec2(-10.0, 10.0), - vec2(-1.0, 1.0 + offset), // 左上 - 三角形2 - vec2( -1.0, -1.0-offset), // 左下 - 三角形2 - vec2( 1.0, -1.0-offset) // 右下 - 三角形2 + vec2(-10.0, 10.0), + vec2(-10.0, -10.0), + vec2( 10.0, -10.0) ); - - -const vec2 texCoords[6] = vec2[6]( - vec2(1.0, 1.0), // 右上 - vec2(0.0, 1.0), // 左上 - vec2(0.0, 0.0), // 左下 - - vec2(0.0, 0.0), // 左下 - vec2(1.0, 0.0), // 右下 - vec2(1.0, 1.0) // 右上 -); - -// 定义 Push Constant,只有一个 float +// ★ PushConstants 必须和 C++ FaceApp.h 严格一致。前 9 个 float 是业务原始值 +// (SDK 已按 canvas_size/480 缩放到屏幕物理像素),后 5 个 float 是当前帧 +// 的画布几何 + 相机帧元信息,由 FaceApp::render() 每帧写入。 layout(push_constant) uniform PushConstants { float zoom; - float r; - float g; - float b; - float radius; - float ux; - float uy; + float r; + float g; + float b; + float radius; + float ux; + float uy; float offset_x; - float offset_y; -} pushConstants; + float offset_y; + // 画布几何(每帧写入) + float canvas_size; + float screen_w; + float screen_h; + // 相机帧元信息(每次相机帧上传时写入) + float camera_aspect; // raw width / height(典型 4:3 = 1.333…) + float camera_rotation; // CameraX rotationDegrees: 0/90/180/270 + // 镜子效果开关:1.0 = 前置摄像头,最终 NDC.x 翻转一次。 + // 这与 FaceLandmarkerHelper 在前置时对 bitmap 做 postScale(-1,1) 的语义对偶: + // ① mediapipe 输入是"用户视角"图像 → landmark 也是用户视角; + // ② bg.vert 算出的 outTexCoord 还是 raw 帧 UV(旋转矩阵把 user 视角→raw); + // ③ 最后我们把 NDC.x 翻转,bg 在屏幕上呈现为水平镜像 = 镜子效果; + // ④ texture.vert 同样翻转一次,face 贴图与 bg 完美对齐。 + float mirror_x; +} pc; - -// 输出变量 layout(location = 0) out vec2 outTexCoord; void main() { - // 获取顶点位置 - vec2 position = positions[gl_VertexIndex]; - position = position * pushConstants.zoom; - position.x = position.x + (pushConstants.offset_x/480); - position.y = position.y + (pushConstants.offset_y/480); - - // 设置输出位置(Vulkan使用不同的坐标系) - gl_Position = vec4(position, 0.0, 1.0); - - // 输出纹理坐标 - outTexCoord = texCoords[gl_VertexIndex]; -} \ No newline at end of file + vec2 pos = positions[gl_VertexIndex]; + + // ===== outTexCoord:基于"未变换的 quad pos" 算 raw UV ===== + // + // 重点:这里必须用未做 zoom/offset/letterbox/mirror 变换的原始 pos 来计算 + // outTexCoord,而 gl_Position 在下面才做这些变换。这样: + // - 屏幕 fragment X 处对应的 quad pos = (X 反过 mirror、letterbox、offset、zoom) + // - face mesh 顶点的 mediapipe landmark 在屏幕上的位置 X' 经过相同链路 + // - 当 X = X' 时(face mesh 顶点重叠某个屏幕像素),fragment 反算的 pos + // 刚好等于 face landmark 的画布 NDC,bg 该 fragment 显示 landmark 对应 + // raw UV → 与 face mesh 完美贴合。 + // + // 公式链:画布 NDC ─[逆时针 rotation]→ 中央方形 NDC ─[scale & shift]→ raw UV + // + // 关于旋转方向: + // Android Matrix.postRotate(deg) 在 Bitmap canvas(y 朝下)里是「视觉顺时针」。 + // Vulkan NDC 也是 y 朝下。所以画布 NDC → 中央方形 NDC 是「视觉逆时针 rotation」。 + // GLSL column-major mat2(c, -s, s, c) 对应矩阵 [c, s; -s, c]。 + // 验证 rotation=0 时矩阵 = identity ✓ + // 验证 rotation=90 时矩阵 = [0, 1; -1, 0],apply 到画布右上 (1,-1): + // (0*1 + 1*(-1), -1*1 + 0*(-1)) = (-1, -1) → 中央方形左上 ✓ + // 物理:mediapipe 输入右上像素 = 中央方形顺时针 90° 后的右上 = 中央方形左上 ✓ + float ang = radians(pc.camera_rotation); + float c = cos(ang); + float s = sin(ang); + mat2 R = mat2(c, -s, s, c); // 画布 NDC → 中央方形 NDC(视觉逆时针 rotation) + vec2 cm = R * pos; + + // 中央方形 NDC ∈ [-1, +1]² → raw 帧 UV。中央方形在 raw 帧 UV 上是中央 + // min(W, H) x min(W, H) 的正方形,对应的 UV 半径: + // raw 横向长(W >= H):halfU = H/(2W) = 1/(2 * aspect),halfV = 0.5 + // raw 纵向长(H > W):halfU = 0.5,halfV = W/(2H) = aspect/2 + float halfU, halfV; + if (pc.camera_aspect >= 1.0) { + halfU = 0.5 / pc.camera_aspect; + halfV = 0.5; + } else { + halfU = 0.5; + halfV = 0.5 * pc.camera_aspect; + } + outTexCoord = vec2(0.5 + halfU * cm.x, 0.5 + halfV * cm.y); + + // ===== gl_Position:业务 zoom/offset + letterbox + mirror,与 texture.vert 严格一致 ===== + // + // pos 仍按 quad 顶点 ±10 参与位置计算,pc.offset_x / pc.offset_y 已是屏幕物理 + // 像素(SDK 端做过 canvas_size/480 缩放),/canvas_size 后是画布 NDC 单位; + // 最后 *canvas_size/screen_{w,h} 把画布缩到屏幕中央正方形,长边方向自然 + // letterbox。quad 顶点放大到 ±10 让 GPU clip 后整屏覆盖。 + pos *= pc.zoom; + pos.x += pc.offset_x / pc.canvas_size; + pos.y += pc.offset_y / pc.canvas_size; + pos.x *= pc.canvas_size / pc.screen_w; + pos.y *= pc.canvas_size / pc.screen_h; + + // 镜子效果:前置时 mirror_x=1.0,把 NDC.x 翻转一次。无分支: + // 后置:(1 - 2*0) = +1 → 不变; + // 前置:(1 - 2*1) = -1 → 水平翻转。 + // texture.vert 也做同样翻转,bg + face 整体水平镜像,face 贴图与 bg 完美对齐。 + pos.x *= (1.0 - 2.0 * pc.mirror_x); + + gl_Position = vec4(pos, 0.0, 1.0); +} diff --git a/app/src/main/assets/shaders/bg.vert.spv b/app/src/main/assets/shaders/bg.vert.spv index d26a9d5..3503588 100644 Binary files a/app/src/main/assets/shaders/bg.vert.spv and b/app/src/main/assets/shaders/bg.vert.spv differ diff --git a/app/src/main/assets/shaders/texture.frag b/app/src/main/assets/shaders/texture.frag index e228877..9cc477a 100644 --- a/app/src/main/assets/shaders/texture.frag +++ b/app/src/main/assets/shaders/texture.frag @@ -5,40 +5,48 @@ layout (binding = 1) uniform sampler2D samplerColor; layout (location = 0) in vec2 inUV; layout (location = 1) in vec3 inNormal; +// ★ PushConstants 必须和 C++ FaceApp.h / texture.vert 严格一致。 +// 关键:原版这里只声明到 uy,缺了 offset_x 之后的字段,那时之所以没报错是因为 +// fragment 没读到那些字段。本次重构 fragment 要读 screen_w/screen_h,所以必须 +// 把整个块声明完整,否则 layout offset 会错位。 layout(push_constant) uniform PushConstants { float zoom; - float r; - float g; - float b; - float radius; - float ux; - float uy; -} pushConstants; - + float r; + float g; + float b; + float radius; + float ux; + float uy; + float offset_x; + float offset_y; + float canvas_size; + float screen_w; + float screen_h; + // 相机帧元信息(fragment 不用,仅为对齐 push constant layout) + float camera_aspect; + float camera_rotation; + float mirror_x; +} pc; layout (location = 0) out vec4 outFragColor; void main() { - // outFragColor = color; - // 获取当前片元的屏幕坐标(左下角为原点) + // gl_FragCoord 是屏幕 framebuffer 像素坐标。圆形 mask 中心锚定在屏幕真实中心, + // 跟随分辨率自动适配;不再写死 (240,240)。 vec2 fragCoord = gl_FragCoord.xy; - - // 屏幕中心(480x480 的中心是 (240, 240)) - vec2 center = vec2(240.0, 240.0); - - // 计算到中心的距离(欧氏距离,单位:像素) + vec2 center = vec2(pc.screen_w, pc.screen_h) * 0.5; float dist = length(fragCoord - center); - // 判断是否在半径之外 - if (dist > pushConstants.radius) { - // 使用 push constant 中的 RGB 颜色(注意 alpha 设为 1.0) - outFragColor = vec4(pushConstants.r, pushConstants.g, pushConstants.b, 1.0); + if (dist > pc.radius) { + // 画布外(含 letterbox 黑边)+ 圆外区域用业务纯色覆盖,与 bg.frag 一致。 + outFragColor = vec4(pc.r, pc.g, pc.b, 1.0); } else { - vec2 pos = vec2(pushConstants.ux/4096.f, pushConstants.uy/2048.f); - vec2 uv = pos + vec2(inUV.x/8.f, inUV.y/4.f); + // out.png 是 4096x2048 的 8x4 子图图集;ux/uy 选定当前 motion 帧子图, + // inUV 在子图内插值。这里和分辨率重构无关,保持原版逻辑不动。 + vec2 pos = vec2(pc.ux / 4096.0, pc.uy / 2048.0); + vec2 uv = pos + vec2(inUV.x / 8.0, inUV.y / 4.0); vec4 color = texture(samplerColor, uv); outFragColor = color; } - -} \ No newline at end of file +} diff --git a/app/src/main/assets/shaders/texture.frag.spv b/app/src/main/assets/shaders/texture.frag.spv index 0c4f42b..5a67edd 100644 Binary files a/app/src/main/assets/shaders/texture.frag.spv and b/app/src/main/assets/shaders/texture.frag.spv differ diff --git a/app/src/main/assets/shaders/texture.vert b/app/src/main/assets/shaders/texture.vert index 92a3ec8..b2a4a22 100644 --- a/app/src/main/assets/shaders/texture.vert +++ b/app/src/main/assets/shaders/texture.vert @@ -1,6 +1,5 @@ #version 450 - layout (location = 0) in vec3 inPos; layout (location = 1) in vec2 inUV; layout (location = 2) in vec3 inNormal; @@ -16,17 +15,31 @@ layout (binding = 0) uniform UBO layout (location = 0) out vec2 outUV; layout (location = 1) out vec3 outNormal; +// ★ PushConstants 必须和 C++ FaceApp.h / bg.vert / bg.frag 严格一致。 +// 前 9 个 float 是业务原始值(已被 SDK 在 render() 里按 canvas_size/480 缩放成 +// 屏幕物理像素);后 3 个 float 是当前帧画布几何,由 SDK 每帧写入。 layout(push_constant) uniform PushConstants { float zoom; - float r; - float g; - float b; - float radius; - float ux; - float uy; - float offset_x; - float offset_y; -} pushConstants; + float r; + float g; + float b; + float radius; + float ux; + float uy; + float offset_x; + float offset_y; + float canvas_size; + float screen_w; + float screen_h; + // 相机帧元信息:camera_aspect / camera_rotation 在 face 渲染中不用——face + // landmark 已经是"正立中央方形"的归一化坐标,mediapipe 端转过;保留只是 + // 为了对齐 push constant layout。 + float camera_aspect; + float camera_rotation; + // mirror_x:1.0 表示前置摄像头镜子效果——把最终 gl_Position.x 翻转一次。 + // bg.vert 也做同样翻转,所以 bg 与 face 同步水平镜像、互相对齐。 + float mirror_x; +} pc; out gl_PerVertex { @@ -37,13 +50,33 @@ void main() { outUV = inUV; - vec2 position = vec2(inPos.xy*2 -1); - position = position * pushConstants.zoom; - position.x = position.x + (pushConstants.offset_x/480); - position.y = position.y + (pushConstants.offset_y/480); - gl_Position = vec4(position, 0.5, 1.0); + // 模型顶点坐标 inPos.xy 来自 OBJ,已经是 [0,1]² 归一化范围。先映射到 + // "画布 NDC ∈ [-1,+1]"。 + vec2 position = vec2(inPos.xy * 2.0 - 1.0); - vec4 pos = ubo.model * vec4(inPos, 1.0); + // 画布坐标系:缩放 + 业务像素偏移。 + // pc.offset_x / pc.offset_y 是屏幕物理像素(SDK 已做过 canvas_size/480 缩放), + // 除以 canvas_size 转成画布 NDC,跟原版 "/480" 在 480 屏上完全等价。 + position = position * pc.zoom; + position.x = position.x + (pc.offset_x / pc.canvas_size); + position.y = position.y + (pc.offset_y / pc.canvas_size); + + // 画布 NDC → 屏幕 NDC:屏幕长边方向乘以 canvas_size/screen_long < 1.0, + // 让模型只占据屏幕中央正方形画布区域,长边方向的画布外是 letterbox。 + position.x = position.x * (pc.canvas_size / pc.screen_w); + position.y = position.y * (pc.canvas_size / pc.screen_h); + + // 镜子效果:前置摄像头时 mirror_x=1.0,把 NDC.x 翻转一次。 + // 后置:(1 - 2*0) = +1 → 不变 + // 前置:(1 - 2*1) = -1 → 水平翻转 + // 无分支写法,配合 bg.vert 同样的翻转,让 bg + face 整体镜像、保持对齐。 + position.x *= (1.0 - 2.0 * pc.mirror_x); + + gl_Position = vec4(position, 0.5, 1.0); + + // 注意:ubo.projection / ubo.model 在当前管线里没有真正参与 gl_Position 的 + // 计算(顶点位置已直接给出 NDC)。这里仍按原版保留 normal 的世界变换,避免 + // 影响其它依赖 outNormal 的 fragment 逻辑(例如 thick 版本)。 + vec4 pos = ubo.model * vec4(inPos, 1.0); outNormal = mat3(inverse(transpose(ubo.model))) * inNormal; - } diff --git a/app/src/main/assets/shaders/texture.vert.spv b/app/src/main/assets/shaders/texture.vert.spv index 357e47a..dd70cf8 100644 Binary files a/app/src/main/assets/shaders/texture.vert.spv and b/app/src/main/assets/shaders/texture.vert.spv differ diff --git a/app/src/main/cpp/main.cpp b/app/src/main/cpp/main.cpp index e22efc7..40c1f91 100644 --- a/app/src/main/cpp/main.cpp +++ b/app/src/main/cpp/main.cpp @@ -191,19 +191,35 @@ void android_main(struct android_app *pApp) { } } while (!pApp->destroyRequested); DebugLog::log("android_main: destroyRequested, running cleanup"); - //application.cleanup(); - g_Application->cleanupSecondInit(); + + // 关键:这里只复位 FaceApp 的"second-init" 状态机标记,**不销毁**任何 + // Vulkan 资源。 + // + // - 窗口相关资源(surface/swapchain/framebuffers/imageViews/ + // commandBuffers/sync)已经在 APP_CMD_TERM_WINDOW → onWindowLost() + // 里被 cleanupForWindowLost() 干净销毁; + // - renderPass / graphicsPipeline / pipelineLayout / VkDevice / + // VkInstance / VMA / 所有 textures / FaceApp 自身 pipelines 是 g_Application + // 单例的"全局资源",跨 NativeActivity 实例复用。下次 Activity 启动 + // onWindowInit 会走 reinitForNewWindow() 重建窗口资源、复用全局资源。 + // + // 历史教训:之前这里调过 g_Application->cleanupSecondInit(),那个函数会 + // 把 renderPass / graphicsPipeline / pipelineLayout 全部 destroy 掉但既 + // 不置 VK_NULL_HANDLE 也不复位 _applicationInited。结果用户从主界面切镜 + // 头再进 MakeupActivity 时,onWindowInit 看到 _applicationInited=1 走 reinit + // 复用路径,引用悬空的 renderPass 调 vkCreateFramebuffer,validation + // layer 检测出 use-after-free 直接 SEGV。 g_Application->clearnSecondFaceApp(); DebugLog::log("android_main: exit"); } } -extern void TextureLoadProcessWithVulkan(uint8_t* data, int width, int height, int rowStride, size_t dataSize); +extern void TextureLoadProcessWithVulkan(uint8_t* data, int width, int height, int rowStride, size_t dataSize, int rotation, bool mirrorX); extern void ReceiveFacePoint(float* pos, int count, int width, int height); void processWithVulkan(uint8_t* data, int width, int height, int format, - int rowStride, size_t dataSize) { - TextureLoadProcessWithVulkan(data, width, height, rowStride, dataSize); + int rowStride, size_t dataSize, int rotation, bool mirrorX) { + TextureLoadProcessWithVulkan(data, width, height, rowStride, dataSize, rotation, mirrorX); } @@ -213,14 +229,13 @@ JNIEXPORT void JNICALL Java_com_hmwl_face_1sdk_FaceActivity_processImageNative(JNIEnv *env, jobject thiz, jobject buffer, jint width, jint height, jint format, jint row_stride, jint pixel_stride, - jint rotation) { + jint rotation, jboolean mirror_x) { static std::atomic s_imgCount{0}; uint64_t n = s_imgCount.fetch_add(1) + 1; if (n == 1 || n % 300 == 0) { - DebugLog::log("processImageNative #%llu tid=%d w=%d h=%d", - (unsigned long long)n, dbg_tid(), width, height); + DebugLog::log("processImageNative #%llu tid=%d w=%d h=%d mirror=%d", + (unsigned long long)n, dbg_tid(), width, height, (int)mirror_x); } - // TODO: implement processImageNative() uint8_t* imageData = static_cast(env->GetDirectBufferAddress(buffer)); jlong capacity = env->GetDirectBufferCapacity(buffer); @@ -228,9 +243,12 @@ Java_com_hmwl_face_1sdk_FaceActivity_processImageNative(JNIEnv *env, jobject thi return; } - // 在这里将图像数据传递给 Vulkan - // 创建 Vulkan 图像或更新现有图像 - processWithVulkan(imageData, width, height, format, row_stride, capacity); + // rotation = ImageInfo.getRotationDegrees(),给 shader 用来做 UV 旋转, + // 让不同 sensor orientation 的设备显示方向一致。 + // mirror_x = true 表示前置摄像头:所有顶点 shader 会把 gl_Position.x 翻转 + // 一次,达到"镜子效果"——这是化妆/美妆类 app 的标准体验。 + processWithVulkan(imageData, width, height, format, row_stride, capacity, + (int)rotation, mirror_x == JNI_TRUE); } extern "C" JNIEXPORT void JNICALL @@ -238,19 +256,21 @@ Java_com_hmwl_face_1sdk_FaceActivity_passDataToNative(JNIEnv *env, jobject thiz, jint point_count, jint width, jint height) { static std::atomic s_ptCount{0}; uint64_t n = s_ptCount.fetch_add(1) + 1; - if (n == 1 || n % 300 == 0) { - DebugLog::log("passDataToNative #%llu tid=%d point_count=%d w=%d h=%d", - (unsigned long long)n, dbg_tid(), point_count, width, height); - } - // TODO: implement passDataToNative() float* pos = static_cast(env->GetDirectBufferAddress(buffer)); if (pos == nullptr) { - // 处理错误 + DebugLog::log_throttled("passDataToNative.nullBuffer", + "passDataToNative #%llu got NULL DirectBufferAddress!", + (unsigned long long)n); return; } + // 节流:每 120 次实际数据通路打一行(约每 4 秒一次,足够确认通路活着即可)。 + if (n == 1 || n % 120 == 0) { + DebugLog::log("passDataToNative #%llu point_count=%d w=%d h=%d p0=(%.3f,%.3f,%.3f)", + (unsigned long long)n, point_count, width, height, + pos[0], pos[1], pos[2]); + } - // 或者直接将指针传递给Vulkan ReceiveFacePoint(pos, point_count, width, height); } @@ -491,4 +511,23 @@ JNIEXPORT void JNICALL Java_com_hmwl_face_1sdk_FaceActivity_ResumeMotionNative(JNIEnv *env, jobject thiz) { DebugLog::log("JNI ResumeMotionNative tid=%d", dbg_tid()); g_Application->ResumeMotion(); +} + +// 给 Java/Kotlin 侧用的日志桥:把 Java 端关键事件写进 native 的 +// face_sdk_debug.log,使 Java + native + Vulkan 渲染日志能在同一个 +// 文件里按时间戳排好序,一次 adb pull 就能拿到完整时间线。 +extern "C" +JNIEXPORT void JNICALL +Java_com_hmwl_face_1sdk_DebugLog_nativeLog(JNIEnv *env, jclass clazz, + jstring jtag, jstring jmsg) { + if (jmsg == nullptr) return; + const char* tag = jtag ? env->GetStringUTFChars(jtag, nullptr) : nullptr; + const char* msg = env->GetStringUTFChars(jmsg, nullptr); + if (tag) { + DebugLog::log("[J][%s] %s", tag, msg); + } else { + DebugLog::log("[J] %s", msg); + } + if (tag) env->ReleaseStringUTFChars(jtag, tag); + env->ReleaseStringUTFChars(jmsg, msg); } \ No newline at end of file diff --git a/app/src/main/java/com/hmwl/face_sdk/DebugLog.java b/app/src/main/java/com/hmwl/face_sdk/DebugLog.java new file mode 100644 index 0000000..dcf2bb4 --- /dev/null +++ b/app/src/main/java/com/hmwl/face_sdk/DebugLog.java @@ -0,0 +1,38 @@ +package com.hmwl.face_sdk; + +import android.util.Log; + +/** + * Java/Kotlin 端日志桥。所有调用最终通过 JNI 写到 native 端的 + * face_sdk_debug.log 文件(与 native DebugLog::log 共用一份), + * 同时镜像到 logcat(tag = 调用方 tag),方便联机调试时一次 adb pull + * 就能拿到完整时间线(Java + native + Vulkan 渲染)。 + * + * 使用约定: + * - 业务低频事件用 i(tag, msg) + * - 错误用 e(tag, msg) + * - 高频帧级日志请自行节流,本类不再做二次节流 + */ +public final class DebugLog { + private DebugLog() {} + + public static void i(String tag, String msg) { + Log.i(tag, msg); + try { + nativeLog(tag, msg); + } catch (UnsatisfiedLinkError e) { + // native 还没 attach 的极早期阶段,吞掉避免崩溃 + } + } + + public static void e(String tag, String msg) { + Log.e(tag, msg); + try { + nativeLog("[E]" + tag, msg); + } catch (UnsatisfiedLinkError e) { + // 同上 + } + } + + private static native void nativeLog(String tag, String msg); +} diff --git a/app/src/main/java/com/hmwl/face_sdk/FaceActivity.java b/app/src/main/java/com/hmwl/face_sdk/FaceActivity.java index effe851..07850cc 100644 --- a/app/src/main/java/com/hmwl/face_sdk/FaceActivity.java +++ b/app/src/main/java/com/hmwl/face_sdk/FaceActivity.java @@ -7,7 +7,8 @@ import android.util.Log; import android.view.View; import androidx.annotation.NonNull; -import androidx.camera.core.AspectRatio; +import android.util.Size; + import androidx.camera.core.CameraSelector; import androidx.camera.core.ImageAnalysis; import androidx.camera.core.ImageProxy; @@ -38,6 +39,27 @@ public class FaceActivity extends GameActivity implements FaceLandmarkerHelper.L System.loadLibrary("face_sdk"); } + /** + * Intent extra key:调用方(example/MainActivity 等)通过它告诉 FaceActivity + * 启动哪个朝向的摄像头。值类型 int,取自 androidx.camera.core.CameraSelector: + * - CameraSelector.LENS_FACING_BACK (0) + * - CameraSelector.LENS_FACING_FRONT (1) + * 缺省(未提供 extra)时走 LENS_FACING_BACK,保留旧调用方的行为。 + * + * 该值同时决定两件事: + * ① CameraSelector 朝向; + * ② mirrorX 镜像标志:前置摄像头时 sensor 帧是"用户右手在画面右侧", + * 为了显示成"镜子效果"(用户右手在屏幕左侧),所有顶点 shader 在 + * gl_Position 阶段对 NDC.x 做一次水平翻转。这件事通过 push constant + * 的 mirror_x 字段透传到 GPU;对应地 FaceLandmarkerHelper 也会对 + * bitmap 做 postScale(-1, 1) 让 mediapipe 输入与显示画面方向一致, + * 确保 face 贴图与 bg 完美对齐。 + */ + public static final String EXTRA_LENS_FACING = "com.hmwl.face_sdk.LENS_FACING"; + + /** 当前 session 使用的摄像头朝向。在 onCreate 里从 Intent extra 读取并固定。 */ + private int lensFacing = CameraSelector.LENS_FACING_BACK; + private ProcessCameraProvider cameraProvider; private void initCamera(){ backgroundExecutor = Executors.newSingleThreadExecutor(); @@ -72,11 +94,28 @@ public class FaceActivity extends GameActivity implements FaceLandmarkerHelper.L cameraProvider = cameraProviderFuture.get(); CameraSelector cameraSelector = new CameraSelector.Builder() - .requireLensFacing(CameraSelector.LENS_FACING_BACK) + .requireLensFacing(lensFacing) .build(); + // 显式锁定分析帧分辨率为 480x640(CameraX UI 坐标系,竖屏:短边 x 长边)。 + // 不同手机的相机硬件原生支持的分辨率差异很大(旗舰机随便给 1920x1440、 + // 入门机可能是 320x240),CameraX 默认会按"最贴近 setTargetAspectRatio + // 的 supported size"挑一个,结果在不同设备上拿到的分析帧大小都不一样。 + // 这会带来三个麻烦: + // ① bg 纹理 GPU 内存占用波动很大(高端机一帧 ~10MB staging buffer); + // ② mediapipe 推理耗时随分辨率非线性升高(Pixel 上 1920x1440 比 + // 640x480 慢 4-6 倍),帧率掉得明显; + // ③ 我们的 SDK 用归一化 landmark + 中央方形 crop,本来就不需要更高 + // 分辨率,多出来的像素是纯浪费。 + // + // 锁定 480x640 后:sensor 坐标系下分析帧固定 640x480 (rotation=90 时), + // FaceLandmarkerHelper 中央方形裁切固定 480x480,bg 上传纹理也固定, + // 不同手机上行为一致。 + // + // 实际尺寸由 CameraX 决定(会找最接近的硬件支持档位),但设备实测 + // 几乎都能精确给出 640x480。 imageAnalyzer = new ImageAnalysis.Builder() - .setTargetAspectRatio(AspectRatio.RATIO_4_3) + .setTargetResolution(new Size(480, 640)) .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888) .build(); @@ -114,24 +153,24 @@ public class FaceActivity extends GameActivity implements FaceLandmarkerHelper.L } private void processImageForVulkan(ImageProxy image) { - // 获取图像信息 int width = image.getWidth(); int height = image.getHeight(); int format = image.getFormat(); - // 获取图像数据平面 ImageProxy.PlaneProxy[] planes = image.getPlanes(); - // 对于 RGBA_8888 格式,通常只有一个平面 if (planes.length > 0) { ImageProxy.PlaneProxy plane = planes[0]; ByteBuffer buffer = plane.getBuffer(); int rowStride = plane.getRowStride(); int pixelStride = plane.getPixelStride(); - // 调用 Native 方法处理图像 + // mirrorX 为 true 时所有顶点 shader 会把 gl_Position.x 翻转一次,达到 + // "镜子效果"——前置摄像头唯一需要的额外处理。后置不开镜像。 + boolean mirrorX = (lensFacing == CameraSelector.LENS_FACING_FRONT); processImageNative(buffer, width, height, format, - rowStride, pixelStride, image.getImageInfo().getRotationDegrees()); + rowStride, pixelStride, image.getImageInfo().getRotationDegrees(), + mirrorX); } } @@ -154,11 +193,16 @@ public class FaceActivity extends GameActivity implements FaceLandmarkerHelper.L // Native 方法 private native void processImageNative(ByteBuffer buffer, int width, int height, int format, int rowStride, int pixelStride, - int rotation); + int rotation, boolean mirrorX); private void detectFace(ImageProxy imageProxy) { + // 前置摄像头:FaceLandmarkerHelper 内部会对 bitmap 做 postScale(-1, 1), + // 让 mediapipe 看到的是"用户视角"的图像。这样 landmark 输出就是用户视角 + // 归一化坐标,跟 shader 里 gl_Position.x 翻转一次后的 bg 画面方向一致, + // face 贴图位置才能精确对齐。 + boolean isFrontCamera = (lensFacing == CameraSelector.LENS_FACING_FRONT); faceLandmarkerHelper.detectLiveStream( - imageProxy,false + imageProxy, isFrontCamera ); } @@ -181,6 +225,15 @@ public class FaceActivity extends GameActivity implements FaceLandmarkerHelper.L @Override protected void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); + // 必须在 initCamera() 之前读,CameraSelector 的朝向就靠这个 lensFacing。 + // 兼容旧调用方:未提供 EXTRA_LENS_FACING 时默认后置。 + if (getIntent() != null) { + lensFacing = getIntent().getIntExtra( + EXTRA_LENS_FACING, CameraSelector.LENS_FACING_BACK); + } + Log.d(TAG, "onCreate lensFacing=" + lensFacing + + " (front=" + CameraSelector.LENS_FACING_FRONT + + " back=" + CameraSelector.LENS_FACING_BACK + ")"); initCamera(); backgroundExecutor.execute(new Runnable() { @@ -280,11 +333,18 @@ public class FaceActivity extends GameActivity implements FaceLandmarkerHelper.L FloatBuffer floatBuffer = nativeBuffer.asFloatBuffer(); floatBuffer.put(points); floatBuffer.position(0); - long cur_time = System.currentTimeMillis(); - Log.i("TimeLatency","Result Data ProcessTime:" + (cur_time-start_time)); + // 节流:每 30 次调用打一次到 native 文件,方便和 native 那边的 + // passDataToNative.tick 对账。 + onResultsCallCount++; + if (onResultsCallCount % 30 == 1) { + DebugLog.i("FaceActivity", + "onResults call#" + onResultsCallCount + + " index=" + index + " input=" + width + "x" + height + + " p0=(" + points[0] + "," + points[1] + "," + points[2] + ")"); + } passDataToNative(nativeBuffer, index, width, height); - Log.i("TimeLatency","passDataToNative ProcessTime:" + (System.currentTimeMillis() - cur_time)); } + private long onResultsCallCount = 0; public native void passDataToNative(ByteBuffer buffer, int pointCount, int width, int height); diff --git a/app/src/main/java/com/hmwl/face_sdk/FaceLandmarkerHelper.kt b/app/src/main/java/com/hmwl/face_sdk/FaceLandmarkerHelper.kt index c9f0cba..62ec6e6 100644 --- a/app/src/main/java/com/hmwl/face_sdk/FaceLandmarkerHelper.kt +++ b/app/src/main/java/com/hmwl/face_sdk/FaceLandmarkerHelper.kt @@ -173,6 +173,13 @@ class FaceLandmarkerHelper( " while not using RunningMode.LIVE_STREAM" ) } + // 节流日志:每 ~30 次调用打一次(detectFace 在 FaceActivity 里只在 + // frameCount % 3 == 0 时调用,所以 30 次大约对应 90 帧 ≈ 3 秒)。用来 + // 确认 mediapipe 入口活着、isFrontCamera/rotation/分辨率有没有跑偏。 + if ((frameId % 30L) == 0L) { + Log.i(TAG, "detectLiveStream tick frameId=$frameId src=${imageProxy.width}x${imageProxy.height}" + + " rot=${imageProxy.imageInfo.rotationDegrees} isFrontCamera=$isFrontCamera") + } val frameTime = SystemClock.uptimeMillis() val currentFrameId = frameId++ @@ -194,22 +201,40 @@ class FaceLandmarkerHelper( imageProxy.use { bitmapBuffer.copyPixelsFromBuffer(imageProxy.planes[0].buffer) } //imageProxy.close() + // 重要:这里 *不* 对前置摄像头做 postScale(-1, 1)!理由如下: + // + // 我们的渲染策略是 —— bg 与 face 顶点 shader 末尾统一对 gl_Position.x + // 乘 (1 - 2*mirror_x) 做镜子翻转。bg 是直接采样 sensor 帧、然后 shader + // 末尾翻一次;face 顶点用的是 mediapipe 输出的归一化坐标,shader 末尾 + // 也翻一次。两者只有同时基于"sensor 视角"做输入、再一起在 shader 末尾 + // 翻一次,face 才能精确对齐 bg 上的脸。 + // + // 如果在这里给 mediapipe 喂"已镜像"的 bitmap(旧实现),mediapipe 输出 + // 的 landmark 就是"用户视角"坐标,再被 shader 翻一次 → face 实际上被 + // 翻了两次,跟只翻一次的 bg 错位(通常会在屏幕另一半,看起来就是"face + // 完全没渲染")。这正是我们最近调试看到的现象。 + // + // mediapipe 对左右镜像不敏感(训练集本身覆盖各角度),sensor 视角下也 + // 能稳定检测出 478 个 landmark,无需迎合"镜子视角"。因此 isFrontCamera + // 参数保留以备扩展,但当前不再据此修改输入图。 val matrix = Matrix().apply { - // Rotate the frame received from the camera to be in the same direction as it'll be shown postRotate(imageProxy.imageInfo.rotationDegrees.toFloat()) - - // flip image if user use front camera - if (isFrontCamera) { - postScale( - -1f, - 1f, - imageProxy.width.toFloat(), - imageProxy.height.toFloat() - ) - } } + // 旧代码硬编码了 (640-480)/2 = 80 和 bitmapBuffer.height(=480),假设 + // 相机帧是 640x480。在不同手机/不同 CameraX 配置下相机分辨率会变(比如 + // 480x640 portrait sensor、1280x720 等),硬编码会越界裁切或丢一半画面。 + // + // 改成:根据 imageProxy 的实际尺寸取中央 min(W,H) x min(W,H) 正方形作为 + // mediapipe 输入。这与 bg.vert 的 UV 计算严格对齐 —— shader 那边也是从 + // raw 帧的中央 min(W,H) 正方形采样,再按 rotation 旋转。两端语义一致才能 + // 让 FaceLandmark 输出的归一化坐标 [0,1] 直接对应屏幕画布 NDC。 + val srcW = imageProxy.width + val srcH = imageProxy.height + val side = minOf(srcW, srcH) + val cropX = (srcW - side) / 2 + val cropY = (srcH - side) / 2 val rotatedBitmap = Bitmap.createBitmap( - bitmapBuffer, (640-480)/2, 0, bitmapBuffer.height, bitmapBuffer.height, + bitmapBuffer, cropX, cropY, side, side, matrix, true ) @@ -270,9 +295,14 @@ class FaceLandmarkerHelper( //{ val finishTimeMs = SystemClock.uptimeMillis() val inferenceTime = finishTimeMs - result.timestampMs() - Log.i("TimeLatency", - "总延时: ${inferenceTime}ms | " - ) + hitResultCount++ + if (hitResultCount % 30L == 1L) { + // 节流:检测到人脸的频次。和 emptyResultCount 配合看就能知道 + // 命中率(hitResultCount / (hitResultCount + emptyResultCount))。 + Log.i(TAG, "returnLivestreamResult: HIT count=$hitResultCount" + + " landmarks=${result.faceLandmarks().firstOrNull()?.size}" + + " input=${input.width}x${input.height} infer=${inferenceTime}ms") + } faceLandmarkerHelperListener?.onResults( ResultBundle( result, @@ -284,9 +314,19 @@ class FaceLandmarkerHelper( //} } else { + // 节流:mediapipe 拿到 frame 但没识别出人脸时,每 30 次空结果打一行。 + // 只要这条日志在刷,就说明 mediapipe pipeline 正常活着,断点在"喂进去 + // 的图本身没有可识别的脸"——常见于 isFrontCamera 路径下输入图被翻坏。 + emptyResultCount++ + if (emptyResultCount % 30L == 1L) { + Log.w(TAG, "returnLivestreamResult: faceLandmarks empty (count=$emptyResultCount)" + + " input=${input.width}x${input.height} ts=$frameId") + } faceLandmarkerHelperListener?.onEmpty() } } + private var emptyResultCount: Long = 0 + private var hitResultCount: Long = 0 // Return errors thrown during detection to this FaceLandmarkerHelper's // caller diff --git a/example/build.gradle b/example/build.gradle index c81bf46..8582c7a 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -10,7 +10,7 @@ android { defaultConfig { applicationId "com.inewme.uvmirror.f20260113" - minSdk 30 + minSdk 29 targetSdk 36 versionCode 1 versionName "1.0" diff --git a/example/src/main/AndroidManifest.xml b/example/src/main/AndroidManifest.xml index 70485aa..5d480de 100644 --- a/example/src/main/AndroidManifest.xml +++ b/example/src/main/AndroidManifest.xml @@ -23,7 +23,11 @@ + android:exported="false" + android:screenOrientation="portrait" + android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|keyboard|navigation|smallestScreenSize" /> + \ No newline at end of file diff --git a/example/src/main/java/com/inewme/uvmirror/MainActivity.kt b/example/src/main/java/com/inewme/uvmirror/MainActivity.kt index 41077d4..dad5772 100644 --- a/example/src/main/java/com/inewme/uvmirror/MainActivity.kt +++ b/example/src/main/java/com/inewme/uvmirror/MainActivity.kt @@ -5,16 +5,27 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import androidx.compose.ui.tooling.preview.Preview +import androidx.camera.core.CameraSelector +import com.hmwl.face_sdk.FaceActivity class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -36,17 +47,30 @@ fun MyApp(modifier: Modifier = Modifier) { @Composable fun Greeting(name: String, modifier: Modifier = Modifier) { - val context = LocalContext.current // 获取上下文用于启动 Activity + val context = LocalContext.current + // 默认前置:化妆/美妆类典型场景。 + // 用 CameraSelector.LENS_FACING_FRONT / LENS_FACING_BACK 直接当 state,省去 + // 中间枚举类型,最后通过 Intent extra 透传给 FaceActivity。 + var lensFacing by remember { mutableStateOf(CameraSelector.LENS_FACING_FRONT) } Column( modifier = modifier.padding(16.dp) ) { Text(text = "Hello $name!") + LensFacingRadioGroup( + selected = lensFacing, + onSelected = { lensFacing = it } + ) + Button( onClick = { - // 跳转到 MakeupActivity - val intent = Intent(context, MakeupActivity::class.java) + val intent = Intent(context, MakeupActivity::class.java).apply { + // FaceActivity 在 onCreate 里读这个 extra 来决定 CameraSelector 朝向 + // 与 isFrontCamera 镜像逻辑;缺省时(旧调用方)走 LENS_FACING_BACK, + // 向后兼容。 + putExtra(FaceActivity.EXTRA_LENS_FACING, lensFacing) + } context.startActivity(intent) } ) { @@ -55,10 +79,43 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { } } +@Composable +private fun LensFacingRadioGroup( + selected: Int, + onSelected: (Int) -> Unit, + modifier: Modifier = Modifier +) { + val options = listOf( + CameraSelector.LENS_FACING_FRONT to "前置摄像头", + CameraSelector.LENS_FACING_BACK to "后置摄像头" + ) + Column(modifier = modifier.padding(vertical = 8.dp)) { + Text(text = "摄像头朝向") + options.forEach { (value, label) -> + Row( + modifier = Modifier + .selectable( + selected = (value == selected), + onClick = { onSelected(value) }, + role = Role.RadioButton + ) + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = (value == selected), + onClick = null + ) + Text(text = label, modifier = Modifier.padding(start = 8.dp)) + } + } + } +} + @Preview(showBackground = true) @Composable fun GreetingPreview() { MaterialTheme { Greeting("Android") } -} \ No newline at end of file +} diff --git a/example/src/main/java/com/inewme/uvmirror/MakeupActivity.kt b/example/src/main/java/com/inewme/uvmirror/MakeupActivity.kt index b94d2c9..ba5db8b 100644 --- a/example/src/main/java/com/inewme/uvmirror/MakeupActivity.kt +++ b/example/src/main/java/com/inewme/uvmirror/MakeupActivity.kt @@ -8,6 +8,7 @@ import android.view.KeyEvent.KEYCODE_BACK import android.view.MotionEvent import androidx.lifecycle.lifecycleScope +import com.hmwl.face_sdk.DebugLog import com.hmwl.face_sdk.FaceActivity import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -29,9 +30,12 @@ class MakeupActivity : FaceActivity() } } + DebugLog.i("MakeupActivity", "onCreate: starting LoadMotionList(sequenceAll=$sequenceAll)") // 首先一次性加载当前业务逻辑所要求的所有动画资源, 之后就可以任意跳转,和播放,不用考虑状态 motionMgr.LoadMotionList(sequenceAll){ //当加载完成了所有资源后会回调 + DebugLog.i("MakeupActivity", + "LoadMotionList callback fired -> isMotionLoadFinished=true (sequenceAll=$sequenceAll)") isMotionLoadFinished = true } } @@ -58,6 +62,7 @@ class MakeupActivity : FaceActivity() if(isMotionLoadFinished) { if(elapsedTime == 0){ + DebugLog.i("MakeupActivity", "update: first call -> PlayMotionList(sequenceA=$sequenceA, loop=true)") //这次播放是一直循环播放,我们不需要判断动画是否播放完成, 回调函数直接传入null PlayMotionList(sequenceA, true, null) } @@ -65,17 +70,21 @@ class MakeupActivity : FaceActivity() elapsedTime += 10 // 动画加载完成后开始计时,每次调用增加 10ms if(elapsedTime == 10*1000){ + DebugLog.i("MakeupActivity", "update: 10s tick -> StopMotion()") //暂停动画 StopMotion() } if(elapsedTime == 10*1000 + 5*1000){ + DebugLog.i("MakeupActivity", "update: 15s tick -> ResumeMotion()") //恢复播放动画 ResumeMotion() } if(elapsedTime == 10*1000 + 5*1000 + 8*1000){ + DebugLog.i("MakeupActivity", "update: 23s tick -> PlayMotionList(sequenceB=$sequenceB, loop=true)") PlayMotionList(sequenceB, true){ + DebugLog.i("MakeupActivity", "sequenceB cb -> PlayMotionList(sequenceA, loop=true)") //这里是播放完动画后的回调,再次切换到原来的 sequenceA PlayMotionList(sequenceA, true, null) } diff --git a/vulkan/Application.cpp b/vulkan/Application.cpp index b23940b..705b902 100644 --- a/vulkan/Application.cpp +++ b/vulkan/Application.cpp @@ -60,7 +60,10 @@ void Application::initWindow() glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API); glfwWindowHint(GLFW_RESIZABLE, GLFW_FALSE); - window = glfwCreateWindow(480, 480, "Vulkan", nullptr, nullptr); + // Windows 端仅用于本机调试。改成接近手机的竖屏比例(540x960), + // 这样能直观验证「画布居中正方形 + 上下黑边 letterbox」的效果。 + // Android 端走另一条分支,窗口尺寸由系统 surface 决定。 + window = glfwCreateWindow(540, 960, "Vulkan", nullptr, nullptr); #else #endif @@ -134,6 +137,17 @@ void Application::initVulkan() } } +// ⚠️ Android 路径不要调这个!这个函数销毁的是「第二阶段 init」期间创建的所有 +// 资源(含 renderPass / graphicsPipeline / pipelineLayout),但**没有**重置 +// _applicationInited / _sceondInited,也没有把销毁的句柄置 VK_NULL_HANDLE。 +// +// 之前 main.cpp android_main 退出时调过它,结果用户在主界面切换镜头再进入 +// MakeupActivity 时 onWindowInit 看到 _applicationInited=1 走 reinit 复用路径 +// 引用悬空 renderPass,validation layer 检测出 use-after-free 触发 SEGV。 +// +// Android 上的窗口生命周期是 cleanupForWindowLost() + reinitForNewWindow(), +// renderPass / pipeline 跨 NativeActivity 实例由 g_Application 单例持有。 +// 这个函数目前只保留给 vulkan/main.cpp 那个独立 desktop 入口的进程退出路径。 void Application::cleanupSecondInit() { vkDestroySwapchainKHR(device, swapChain, nullptr); diff --git a/vulkan/FaceApp.cpp b/vulkan/FaceApp.cpp index 0df27f0..17ec702 100644 --- a/vulkan/FaceApp.cpp +++ b/vulkan/FaceApp.cpp @@ -1,4 +1,4 @@ -#include "FaceApp.h" +#include "FaceApp.h" #include "hardcode_data.h" #include "lodepng.h" #include @@ -47,10 +47,13 @@ void FaceApp::Stop() void ReceiveFacePoint(float* pos, int pointCount, int width, int height) { FaceApp* self = FaceApp::Get(); - if (self != nullptr) + if (self == nullptr) { - FaceApp::Get()->update_face_vertex_buffer(pos, pointCount); + FACE_DBG_LOG_THROTTLED("ReceiveFacePoint.noFaceApp", + "ReceiveFacePoint dropped: FaceApp::Get() == nullptr"); + return; } + self->update_face_vertex_buffer(pos, pointCount); } // 定义帧数据结构 @@ -60,10 +63,12 @@ struct FrameData { int height; int rowStride; size_t dataSize; + int rotation; // CameraX 给的 rotationDegrees,用于 shader 端 UV 旋转 + bool mirrorX; // 前置摄像头时为 true,shader 在 gl_Position.x 翻转一次 - FrameData(uint8_t* d, int w, int h, int rs, size_t ds) - : data(nullptr), width(w), height(h), rowStride(rs), dataSize(ds) { - // 深拷贝数据 + FrameData(uint8_t* d, int w, int h, int rs, size_t ds, int rot, bool mx) + : data(nullptr), width(w), height(h), rowStride(rs), dataSize(ds), + rotation(rot), mirrorX(mx) { if (d && ds > 0) { data = new uint8_t[dataSize]; memcpy(data, d, dataSize); @@ -77,18 +82,16 @@ struct FrameData { } } - // 禁止拷贝构造和赋值(使用移动语义) FrameData(const FrameData&) = delete; FrameData& operator=(const FrameData&) = delete; - // 移动构造 FrameData(FrameData&& other) noexcept : data(other.data), width(other.width), height(other.height), - rowStride(other.rowStride), dataSize(other.dataSize) { + rowStride(other.rowStride), dataSize(other.dataSize), + rotation(other.rotation), mirrorX(other.mirrorX) { other.data = nullptr; } - // 移动赋值 FrameData& operator=(FrameData&& other) noexcept { if (this != &other) { if (data) delete[] data; @@ -97,6 +100,8 @@ struct FrameData { height = other.height; rowStride = other.rowStride; dataSize = other.dataSize; + rotation = other.rotation; + mirrorX = other.mirrorX; other.data = nullptr; } return *this; @@ -108,7 +113,7 @@ std::queue frameQueue; std::mutex queueMutex; const int MAX_QUEUE_SIZE = 4; -void TextureLoadProcessWithVulkan(uint8_t* data, int width, int height, int rowStride, size_t dataSize) +void TextureLoadProcessWithVulkan(uint8_t* data, int width, int height, int rowStride, size_t dataSize, int rotation, bool mirrorX) { if (!FaceApp::Get()->isInited()) { return; @@ -116,24 +121,25 @@ void TextureLoadProcessWithVulkan(uint8_t* data, int width, int height, int rowS std::lock_guard lock(queueMutex); - // 添加新帧数据到队列 - frameQueue.emplace(data, width, height, rowStride, dataSize); + frameQueue.emplace(data, width, height, rowStride, dataSize, rotation, mirrorX); // 如果队列大小达到阈值,开始处理最旧的一帧 if (frameQueue.size() >= MAX_QUEUE_SIZE) { - Texture& tex_bg = FaceApp::Get()->tex_bg; FrameData& oldestFrame = frameQueue.front(); - FaceApp::Get()->processWithVulkan( + // 走 FaceApp::processCameraFrame:相比 base::processWithVulkan,它多做两件 + // 事 —— ① 检测帧尺寸变化时 destroy/recreate tex_bg(不同手机或 CameraX + // 重建 session 后第一帧尺寸可能与之前不同);② 缓存 raw_aspect / rotation + // / mirrorX 到 m_cameraAspect / m_cameraRotation / m_mirrorX,由 render() + // 推到 push constant。 + FaceApp::Get()->processCameraFrame( oldestFrame.data, oldestFrame.width, oldestFrame.height, oldestFrame.rowStride, oldestFrame.dataSize, - tex_bg, - false, - FaceApp::Get()->commandPool, - "data_from_camera" + oldestFrame.rotation, + oldestFrame.mirrorX ); frameQueue.pop(); @@ -298,15 +304,21 @@ void FaceApp::create_face_pipelines() VkPipelineRasterizationStateCreateInfo rasterization_state{}; rasterization_state.sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO; rasterization_state.polygonMode = VK_POLYGON_MODE_FILL; + // ★ 关键:face 必须 NONE。原因: + // 1. face mesh 是 2D 平面网格(texture.vert 让所有顶点 z=0.5),不存在 + // 自遮挡,没必要 cull; + // 2. 前置摄像头镜子效果是在 vertex shader 里通过 NDC.x 翻转实现的 + // (`position.x *= (1.0 - 2.0 * pc.mirror_x)`)。X 翻转会把所有三角 + // 形的卷绕方向从 CCW 变 CW。如果这里 cullMode=BACK_BIT + frontFace= + // CCW,前置时整张脸会被 cull,face 完全不可见——这正是历史踩坑点。 + // 所以请勿改回 BACK_BIT。如果未来要再加 cull,必须配两套 pipeline(前置 + // 用 CW、后置用 CCW),或在前置时改成 frontFace=CW,否则一定会复现这个 bug。 rasterization_state.cullMode = VK_CULL_MODE_NONE; rasterization_state.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE; rasterization_state.flags = 0; rasterization_state.depthClampEnable = VK_FALSE; rasterization_state.lineWidth = 1.0f; - rasterization_state.cullMode = VK_CULL_MODE_BACK_BIT; - rasterization_state.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE; - VkPipelineColorBlendAttachmentState colorBlendAttachment{}; @@ -333,13 +345,16 @@ void FaceApp::create_face_pipelines() colorBlending.attachmentCount = 1; colorBlending.pAttachments = &colorBlendAttachment; - // Note: Using reversed depth-buffer for increased precision, so Greater depth values are kept + // face 是 2D 平面网格(texture.vert 让顶点 z=0.5 一致),完全不需要 depth + // test/write。bg 管线本来也是关 depth 的;保持两边一致,让"先 bg 再 face" + // 的覆盖顺序由 draw 顺序而非 depth 决定,避免 reversed-depth(GREATER)+ + // renderpass 没显式 clear depth attachment 时引发的"face fragment 全部 + // 被 depth test fail"的隐性 bug。 VkPipelineDepthStencilStateCreateInfo depth_stencil_state = {}; - depth_stencil_state.sType = VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO; - depth_stencil_state.depthTestEnable = VK_TRUE; - depth_stencil_state.depthWriteEnable = VK_TRUE; - depth_stencil_state.depthCompareOp = VK_COMPARE_OP_GREATER; + depth_stencil_state.depthTestEnable = VK_FALSE; + depth_stencil_state.depthWriteEnable = VK_FALSE; + depth_stencil_state.depthCompareOp = VK_COMPARE_OP_ALWAYS; depth_stencil_state.front = depth_stencil_state.back; depth_stencil_state.back.compareOp = VK_COMPARE_OP_ALWAYS; @@ -617,6 +632,8 @@ void FaceApp::setup_descriptor_set() void FaceApp::update_descriptor_set(vector& texs, vector& descriptSet) { + FACE_DBG_LOG("update_descriptor_set: texs.size=%zu descriptSet.size=%zu", + texs.size(), descriptSet.size()); for (int i = 0; i < texs.size(); ++i) { if (texs[i].image == VK_NULL_HANDLE) @@ -697,21 +714,84 @@ void FaceApp::render(VkCommandBuffer commandBuffer, long long frameTime) //Application::render(commandBuffer); + // === 分辨率适配:每帧把"业务参数"缩放到当前屏幕物理像素 === + // 业务层(MotionManager / InitArg)传进来的 radius / offset_x / offset_y 一直 + // 都是按"480x480 设计画布"给的,比如 radius=240 表示在 480 屏上半径 240px + // (即 50% 短边)。现在屏幕变成任意分辨率后,要保持视觉一致: + // 实际像素 = 业务值 * (画布短边 / 480) + // 同时把 canvas/screen 几何写进 push constant,shader 用它们: + // 1) 把"画布 NDC ∈ [-1,+1]" 缩到屏幕中央正方形(letterbox); + // 2) 把 fragment 里的圆心从写死的 (240,240) 改成屏幕真实中心。 + // + // 注意 pushConstants 本身保留业务原始值不动(SetInitArg 设进来的), + // 这里只把缩放后的副本 pc 推给 GPU。下面 ux/uy 仍然写回 pushConstants, + // 是因为它们和分辨率无关,是 motion 帧的纹理坐标。 + updateCanvasMetrics(); + PushConstants pc = pushConstants; + const float k = m_canvasSize / 480.0f; + pc.radius *= k; + pc.offset_x *= k; + pc.offset_y *= k; + pc.canvas_size = m_canvasSize; + pc.screen_w = m_screenW; + pc.screen_h = m_screenH; + // 相机帧元信息:由 processCameraFrame 在每帧上传纹理之前更新。第一帧之前 + // (还没收到相机数据时)m_cameraAspect/m_cameraRotation 是构造函数设的 + // 4:3 + 90° 默认值 —— 这两个值正是 CameraX RATIO_4_3 + 大部分 Android 后置 + // 主摄竖屏的典型组合,所以即使首帧 bg 用默认值渲染也不会出现明显错位。 + pc.camera_aspect = m_cameraAspect; + pc.camera_rotation = m_cameraRotation; + // mirror_x: 前置摄像头时 = 1.0,所有顶点 shader 在最后输出 gl_Position 前 + // 把 NDC.x 翻转一次,达到镜子效果(用户右手 → 屏幕左侧)。bg 与 face 都 + // 同步翻转,对齐保持。后置时 = 0.0,shader 公式 (1 - 2 * mirror_x) = 1 短路。 + pc.mirror_x = m_mirrorX; + vkCmdBindDescriptorSets(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, m_pipelineLayout_bg, 0, 1, &m_descriptor_set_bg, 0, NULL); vkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, m_graphicsPipeline_bg); - vkCmdPushConstants(commandBuffer, m_pipelineLayout_bg, VK_SHADER_STAGE_VERTEX_BIT, 0, sizeof(pushConstants), &pushConstants); + // bg.frag 也读 push constant(r/g/b/radius/screen_w/screen_h)所以 stage 必须 + // 同时包含 fragment;老代码这里只写了 VERTEX_BIT,是个隐藏的 spec 违规。 + vkCmdPushConstants(commandBuffer, m_pipelineLayout_bg, VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, 0, sizeof(pc), &pc); vkCmdDraw(commandBuffer, 6, 1, 0, 0); + // 诊断:把 face 渲染所有"前置门"状态打成一行节流日志。一旦 face 没出现, + // 直接看这条就能知道断在哪一关: + // _isChangeMostion=0 → 业务还没调 PlayMotionList / changeMotionList + // _curMotions=0 → motion list 是空的(getMotionByName 全 miss) + // _curMotionIndex 越界 → 业务逻辑或 callback 状态机错了 + // delta_last_update >= 2000 → mediapipe / passDataToNative 链路 2s 没动静 + const long long now_ms = getCurrentTimeMillis(); + const long long delta_lu = now_ms - (long long)last_update_time; + FACE_DBG_LOG_THROTTLED("render.face.gate", + "render.face gate _isChangeMostion=%d _curMotions=%zu" + " _curMotionIndex=%d _curFrameIndex=%d delta_lastUpdate=%lldms" + " _playMotion=%d", + (int)_isChangeMostion, _curMotions.size(), + (int)_curMotionIndex, (int)_curFrameIndex, + delta_lu, (int)_playMotion); + if (!_isChangeMostion) { return; } + if (_curMotions.empty()) + { + FACE_DBG_LOG_THROTTLED("render.face.emptyMotions", + "render.face dropped: _isChangeMostion=1 but _curMotions is empty"); + return; + } + string curMotionName = _curMotions[_curMotionIndex].name; int loadMotionIndex = motion_list_map[curMotionName]; + + // face draw 前关键参数节流日志,保留少量"任何时候挂了能立刻判断断点"的字段。 + FACE_DBG_LOG_THROTTLED("render.face.preDraw", + "render.face preDraw motion='%s' loadMotionIdx=%d obj_idx=%zu mirror=%.1f", + curMotionName.c_str(), loadMotionIndex, obj_indices.size(), pc.mirror_x); + //if (cur_left) //{ vkCmdBindDescriptorSets(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, m_pipelineLayout, 0, 1, &m_descriptor_sets_left[loadMotionIndex], 0, NULL); @@ -730,12 +810,14 @@ void FaceApp::render(VkCommandBuffer commandBuffer, long long frameTime) float uy = _curMotions[_curMotionIndex].frames[_curFrameIndex].y; pushConstants.ux = ux; pushConstants.uy = uy; + pc.ux = ux; + pc.uy = uy; } } //fsValues[1] = 0; //fsValues[2] = 360; - vkCmdPushConstants(commandBuffer, m_pipelineLayout, VK_SHADER_STAGE_VERTEX_BIT| VK_SHADER_STAGE_FRAGMENT_BIT, 0, sizeof(pushConstants), &pushConstants); + vkCmdPushConstants(commandBuffer, m_pipelineLayout, VK_SHADER_STAGE_VERTEX_BIT| VK_SHADER_STAGE_FRAGMENT_BIT, 0, sizeof(pc), &pc); //vkCmdPushConstants(commandBuffer, m_pipelineLayout, VK_SHADER_STAGE_FRAGMENT_BIT, 0, sizeof(fsValues), fsValues); @@ -751,7 +833,16 @@ void FaceApp::render(VkCommandBuffer commandBuffer, long long frameTime) #else if (getCurrentTimeMillis() - last_update_time < 2000) { - vkCmdDrawIndexed(commandBuffer, obj_indices.size(), 1, 0, 0, 0); + FACE_DBG_LOG_THROTTLED("render.face.draw", + "render.face vkCmdDrawIndexed indices=%zu motion=%s frame=%d", + obj_indices.size(), curMotionName.c_str(), (int)_curFrameIndex); + vkCmdDrawIndexed(commandBuffer, obj_indices.size(), 1, 0, 0, 0); + } + else + { + FACE_DBG_LOG_THROTTLED("render.face.heartbeatStale", + "render.face SKIP vkCmdDrawIndexed: delta_last_update=%lldms (>= 2000ms)", + (long long)(getCurrentTimeMillis() - last_update_time)); } #endif } @@ -964,11 +1055,14 @@ void FaceApp::recreatePipelinesForSwapchain() void FaceApp::update_uniform_buffers() { - uint32_t width = 480; - uint32_t height = 480; - float zoom = 2; + // 注意:当前 vertex shader (texture.vert) 实际并没有使用 ubo.projection 把模型 + // 投影到屏幕——它直接拿 inPos.xy*2-1 当 NDC 用。所以下面 aspect 取 1.0 只是 + // 为了让 ubo 不再依赖原本写死的 480x480 输入分辨率;要真正影响屏幕显示比例的 + // 是 push constant 里的 canvas_size / screen_w / screen_h,由 render() 写入。 + const float aspect = 1.0f; + const float zoom = 2.0f; // Vertex shader - ubo_vs.projection = glm::perspective(glm::radians(60.0f), static_cast(width) / static_cast(height), 0.001f, 256.0f); + ubo_vs.projection = glm::perspective(glm::radians(60.0f), aspect, 0.001f, 256.0f); glm::mat4 view_matrix = glm::translate(glm::mat4(1.0f), glm::vec3(0.0f, 0.0f, zoom)); ubo_vs.model = view_matrix * glm::translate(glm::mat4(1.0f), camera_pos); @@ -981,6 +1075,26 @@ void FaceApp::update_uniform_buffers() memcpy(uniform_buffer_mapped, &ubo_vs, sizeof(ubo_vs)); } +void FaceApp::updateCanvasMetrics() +{ + // 把"屏幕中央 min(w,h) 的正方形"作为设计画布。画布外的部分会落在 + // shader 计算的 NDC ∈ [-1,+1] 之外,被 GPU 自动裁剪为黑色 letterbox。 + // + // swapChainExtent 来自 Application::createSwapChain 里的 + // surface_capabilities.currentExtent,即当前窗口/Surface 的真实分辨率。 + // 任何 swapchain 重建(onWindowLost -> onWindowInit)都会刷新它,所以 + // 这里每帧重算是廉价且自洽的。 + m_screenW = (float)swapChainExtent.width; + m_screenH = (float)swapChainExtent.height; + m_canvasSize = (m_screenW < m_screenH) ? m_screenW : m_screenH; + if (m_canvasSize <= 0.0f) { + // swapchain 还没初始化 / 已销毁时给个安全值,避免 shader 除零。 + m_canvasSize = 480.0f; + m_screenW = 480.0f; + m_screenH = 480.0f; + } +} + void FaceApp::createVertexBuffer() { VkDeviceSize vertexBufferSize = sizeof(TextureLoadingVertexStructure) * obj_vertices.size(); @@ -1029,17 +1143,85 @@ void FaceApp::uploadVertexData() { +void FaceApp::processCameraFrame(uint8_t* data, int width, int height, + int rowStride, size_t dataSize, int rotation, + bool mirrorX) +{ + if (!isInited() || data == nullptr || width <= 0 || height <= 0) { + return; + } + + // 1) rotation 收敛到 0/90/180/270。CameraX 文档保证只会给这四个值, + // 但理论上 ((rotation % 360) + 360) % 360 更鲁棒。 + int rotNorm = ((rotation % 360) + 360) % 360; + if (rotNorm != 0 && rotNorm != 90 && rotNorm != 180 && rotNorm != 270) { + // 非法值时保守用 90(与原 hardcode 一致)。 + rotNorm = 90; + } + + // 2) 元信息更新(无锁单写者:仅相机分析线程写,render 线程读)。 + // 放在 sizeChanged 判断前后都安全:render() 每帧重新拷到 push constant。 + m_cameraAspect = (float)width / (float)height; + m_cameraRotation = (float)rotNorm; + m_mirrorX = mirrorX ? 1.0f : 0.0f; + + // 3) 检查帧尺寸是否与现有 tex_bg 一致。 + // 场景:CameraX session 重建(如 Activity onResume)后第一帧分辨率可能 + // 不同;或者 setTargetResolution 在不同设备上落地为不同尺寸。 + bool sizeChanged = (tex_bg.image != VK_NULL_HANDLE) + && (tex_bg.width != width || tex_bg.height != height); + + if (!sizeChanged) { + // 快路径(每帧):直接走 base 标准上传路径。第一次 image == VK_NULL_HANDLE + // base 会自动 createTexture(width, height, ...) 自适应分辨率;之后每帧 + // image 已存在,直接走 staging buffer 拷贝。 + processWithVulkan(data, width, height, rowStride, dataSize, + tex_bg, false, commandPool, "data_from_camera"); + return; + } + + // 慢路径(罕见,整个 session 通常 1 次):尺寸变化 → 重建 image + 刷新 + // descriptor set。整个 ceremony 必须严格串行: + // ① createTextureMtx 与 drawFrame / 其它 processWithVulkan 互斥 + // (FaceApp::drawFrame 进 Application::drawFrame 之前会持此锁,所以 + // 我们持锁期间渲染线程被阻塞,不会有新的 cmdbuf 绑定 m_descriptor_set_bg + // 并 submit)。 + // ② vkDeviceWaitIdle 等待所有"已 submit 但未完成"的 cmdbuf 跑完,确保 + // 销毁旧 image/view 时 GPU 已经不再采样它。 + // ③ destroy → create → updateTexture → refresh_descriptor_set_bg + // 全部在锁内完成,期间 m_descriptor_set_bg 处于"指向无效 view"的中间 + // 状态,但因为渲染线程被锁阻塞,这个中间状态对 GPU 不可见。 + // 注意不能调 base::processWithVulkan,那会再次 lock createTextureMtx 死锁; + // 直接调 createTexture + updateTexture,这两个 base 方法不持 createTextureMtx。 +#ifndef _WIN32 + DebugLog::log("processCameraFrame: bg size changed %dx%d -> %dx%d, recreate texture", + tex_bg.width, tex_bg.height, width, height); +#endif + std::unique_lock lk_tex(createTextureMtx); + { + std::lock_guard lk_pool(poolQueueMtx); + vkDeviceWaitIdle(device); + } + destroyTexture(device, tex_bg); + createTexture(device, physicalDevice, width, height, tex_bg, false, + "data_from_camera", dataSize); + updateTexture(device, physicalDevice, commandPool, graphicsQueue, + data, width, height, rowStride, dataSize, tex_bg); + refresh_descriptor_set_bg(); +} + void FaceApp::update_face_vertex_buffer(float* pos, int pointCount) { if(!isInited()) { + FACE_DBG_LOG_THROTTLED("update_face_vertex_buffer.notInited", + "update_face_vertex_buffer dropped: !isInited()"); return; } std::lock_guard lock(mtx_point); last_update_time = getCurrentTimeMillis(); - for (int i = 0; i < obj_vertices.size(); ++i) { @@ -1251,8 +1433,12 @@ void FaceApp::setup_descriptor_set_layout_bg() pipeline_layout_create_info.pSetLayouts = &m_descriptorSetLayout_bg; + // bg.frag 同样要读 push constant(r/g/b/radius,以及分辨率适配重构后的 + // screen_w/screen_h),所以 stage 必须包含 FRAGMENT_BIT。老代码这里只写了 + // VERTEX_BIT,按 Vulkan spec 是非法的(验证层会 warning,部分驱动会读到 + // 未定义内容),借这次重构修掉。 VkPushConstantRange pushConstantRanges[1]; - pushConstantRanges[0].stageFlags = VK_SHADER_STAGE_VERTEX_BIT; + pushConstantRanges[0].stageFlags = VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT; pushConstantRanges[0].offset = 0; pushConstantRanges[0].size = sizeof(PushConstants); @@ -1273,7 +1459,18 @@ void FaceApp::setup_descriptor_set_bg() VK_CHECK(vkAllocateDescriptorSets(device, &alloc_info, &m_descriptor_set_bg)); + refresh_descriptor_set_bg(); +} +void FaceApp::refresh_descriptor_set_bg() +{ + if (m_descriptor_set_bg == VK_NULL_HANDLE) { + return; + } + if (tex_bg.view == VK_NULL_HANDLE || tex_bg.sampler == VK_NULL_HANDLE) { + // 还没 loadTexture / processCameraFrame 过,没法绑定。 + return; + } VkDescriptorImageInfo image_descriptor; image_descriptor.imageView = tex_bg.view; @@ -1288,9 +1485,7 @@ void FaceApp::setup_descriptor_set_bg() write_descriptor_set.pImageInfo = &image_descriptor; write_descriptor_set.descriptorCount = 1; - std::vector write_descriptor_sets = { write_descriptor_set }; - - vkUpdateDescriptorSets(device, static_cast(write_descriptor_sets.size()), write_descriptor_sets.data(), 0, NULL); + vkUpdateDescriptorSets(device, 1, &write_descriptor_set, 0, NULL); } void FaceApp::destroyTexture(VkDevice device, Texture& texture) { @@ -1604,6 +1799,7 @@ void FaceApp::loadMotionThread() string name = m.name; if (motion_list_map.find(name) != motion_list_map.end()) { + FACE_DBG_LOG("loadMotionThread: skip already-loaded motion='%s'", name.c_str()); continue; } m_texs_left.push_back(Texture()); @@ -1620,6 +1816,10 @@ void FaceApp::loadMotionThread() #endif // _WIN32 motion_list_map[name] = m_texs_left.size() - 1; _loadMotionMap[name] = m; + FACE_DBG_LOG("loadMotionThread: loaded motion='%s' idx=%zu tex_image=%p tex_view=%p tex_sampler=%p frames=%zu", + name.c_str(), m_texs_left.size() - 1, + (void*)newTex.image, (void*)newTex.view, (void*)newTex.sampler, + m.frames.size()); } // for (int i = 0; i < _loadMotions.size(); ++i) @@ -1680,7 +1880,11 @@ void FaceApp::changeMotionList(vector motions, AnimationFinishedCallback vector motion_list; for (auto ms : motions) { Motion m = getMotionByName(ms); - motion_list.push_back(m); + auto it = motion_list_map.find(ms); + int idx = (it == motion_list_map.end()) ? -1 : (int)it->second; + FACE_DBG_LOG("changeMotionList: requested='%s' idx=%d frames=%zu (-1=NOT FOUND)", + ms.c_str(), idx, m.frames.size()); + motion_list.push_back(m); } _curMotions = motion_list; diff --git a/vulkan/FaceApp.h b/vulkan/FaceApp.h index a29815f..2d46552 100644 --- a/vulkan/FaceApp.h +++ b/vulkan/FaceApp.h @@ -49,6 +49,15 @@ struct InitArg }; +// 推送给 shader 的常量。前 9 个 float 是「业务原始值」,单位以 480x480 设计画布 +// 为基准(向后兼容):业务给 radius=240 / offset_x=200 / offset_y=-200 等都按这个画布 +// 思考。后 3 个 float 是 SDK 在每次 render() 时根据当前 swapchain 尺寸计算并写入的 +// 「画布几何」,shader 通过它把「画布坐标」映射到「屏幕坐标」并把圆形 mask 中心 +// 锚定在屏幕中心。 +// +// ★ 字段顺序和命名必须和 GLSL 中的 layout(push_constant) 块严格一致,否则会读到 +// 错位数据。修改时四个 shader (bg.vert / bg.frag / texture.vert / texture.frag) +// 都要同步更新。 struct PushConstants { float zoom = 0.5f; float r = 0; @@ -59,6 +68,32 @@ struct PushConstants { float uy = 0; float offset_x = 0; float offset_y = 0; + // 由 SDK 在 FaceApp::render() 内每帧写入,业务无感。 + // canvas_size = min(swapchain.w, swapchain.h),单位:物理像素 + // screen_w / screen_h = swapchain 当前实际尺寸,单位:物理像素 + // 设计画布始终居中铺在屏幕中央正方形区域;外围是 letterbox 黑边。 + float canvas_size = 480.0f; + float screen_w = 480.0f; + float screen_h = 480.0f; + + // 相机帧元信息,由 SDK 在收到第一帧 / 帧尺寸变化时更新。shader 用来: + // 1) 把相机帧(任意 raw aspect)正确 cover 到 1:1 画布; + // 2) 按 camera_rotation 旋转 UV,让画面在屏幕上正立——这是不同手机的 + // sensor orientation 不同导致画面侧着 / 倒着的根因。 + // camera_aspect = camera_raw_w / camera_raw_h(典型 4:3 = 1.333…) + // camera_rotation = CameraX 的 ImageInfo.getRotationDegrees(), + // 表示「图像顺时针旋转多少度才能正立」,离散 0/90/180/270。 + // 默认值 4/3 + 90° 是兼容老 hardcode 行为(大部分 Android 手机后置主摄竖屏)。 + float camera_aspect = 4.0f / 3.0f; + float camera_rotation = 90.0f; + + // 镜像标志:1.0 表示前置摄像头需要"镜子效果",所有顶点 shader 会把 + // gl_Position.x 翻转一次(NDC 水平翻转)。配合 FaceLandmarkerHelper 在 + // bitmap 阶段做的 postScale(-1,1),bg 与 face 同步翻转、互相对齐。 + // 后置摄像头此值为 0.0,shader 短路无开销。 + // 用 float 而不是 bool/int 是为了和 Vulkan push constant 4-byte 对齐 + GLSL + // 端 layout 简单一致;shader 里 `1.0 - 2.0 * mirror_x` 实现无分支翻转。 + float mirror_x = 0.0f; }; using Callback = std::function; @@ -163,13 +198,64 @@ private: //float myFloatValue = 1.5f; + // 「业务原始值」缓存:SetInitArg 把 InitArg 拷到这里,render() 每帧读出来再 + // 缩放 + 注入屏幕几何。注意 ux/uy 由 motion 帧每帧写入,也保留在这里。 PushConstants pushConstants; + // ===== 屏幕分辨率适配 ===== + // 原 SDK 把屏幕写死 480x480,所有业务参数都按 480 像素画布给。现在改成支持 + // 任意分辨率:把"屏幕中央 min(w,h) 边长的正方形"作为设计画布,画布外是黑边。 + // 业务参数语义不变,SDK 在 render() 里按 (canvas_size / 480) 缩放成屏幕物理 + // 像素后再推给 shader。 + // m_canvasSize = min(swapChainExtent.w, swapChainExtent.h) + // m_screenW / m_screenH = swapChainExtent.w / swapChainExtent.h + // updateCanvasMetrics() 由 render() 在每次 vkCmdPushConstants 之前调用。 + // 因为 swapchain 重建会走 onWindowLost -> onWindowInit -> + // recreatePipelinesForSwapchain,而 render() 永远在 drawFrame 里被调,所以 + // 这套数值天然会跟随 swapchain 变化。 + float m_canvasSize = 480.0f; + float m_screenW = 480.0f; + float m_screenH = 480.0f; + void updateCanvasMetrics(); + + // ===== 相机帧元信息适配 ===== + // 不同手机摄像头物理分辨率和 sensor orientation 都不一样,CameraX 给的图像 + // 既不一定是精确 4:3,也不一定是「正立」朝向。SDK 在这里缓存最近一帧的 + // (raw_aspect, rotation),render() 时塞进 push constant 让 shader 自己处理: + // - bg.vert 按 m_cameraRotation 旋转 UV,让画面在屏幕上方向正确 + // - bg.vert 按 m_cameraAspect 自适应 cover 画布,避免拉扁/拉长 + // 由 processCameraFrame() 根据每次相机帧实际 width / height / rotation 更新。 + // 第一帧之前用安全默认值(4:3 + 90°,对应大部分 Android 后置主摄竖屏行为)。 + float m_cameraAspect = 4.0f / 3.0f; + float m_cameraRotation = 90.0f; + // 镜像标志缓存:与 m_cameraAspect 同写者(相机分析线程),由 render() 拷到 + // push constant 的 mirror_x 字段。前置=1.0,后置=0.0。 + float m_mirrorX = 0.0f; +public: + // JNI 入口(main.cpp)调用,用来在每帧上传相机纹理之前同步元信息。 + // 内部会: + // 1) 检测 width × height 与已有 tex_bg 是否一致,不一致就 destroy + recreate; + // 2) 更新 m_cameraAspect / m_cameraRotation / m_mirrorX; + // 3) 调用 base 的 processWithVulkan 走 staging buffer 上传。 + // mirrorX 来自 Java 层 lensFacing == LENS_FACING_FRONT,表示是否需要镜子效果。 + // 必须由唯一的相机分析线程调用(FaceActivity backgroundExecutor), + // 内部依赖 createTextureMtx 序列化与 drawFrame / cleanup 的并发。 + void processCameraFrame(uint8_t* data, int width, int height, + int rowStride, size_t dataSize, int rotation, + bool mirrorX); +private: + void create_pipelines_bg(); void setup_descriptor_set_layout_bg(); void setup_descriptor_set_bg(); + // 把 m_descriptor_set_bg 重新绑定到当前的 tex_bg.view / sampler。 + // 当相机分辨率变化导致 tex_bg 被 destroy + recreate 后,新建的 image + // 有新的 VkImageView 句柄,旧的 descriptor set 里缓存的 handle 失效, + // 必须 vkUpdateDescriptorSets 让 descriptor 指向新 view。该函数不重新 + // 分配 descriptor set,仅刷新绑定。 + void refresh_descriptor_set_bg(); VkPipeline m_graphicsPipeline_bg = VK_NULL_HANDLE; VkPipelineLayout m_pipelineLayout_bg = VK_NULL_HANDLE; VkDescriptorSetLayout m_descriptorSetLayout_bg = VK_NULL_HANDLE;