HarmonyOS AR Engine 环境 Mesh 实战:把真实三角网格画到相机画面
手机缓慢扫过柜门和地面后,青绿色三角网格直接贴在真实相机画面上;镜头右上角同时出现同一批 Mesh 数据生成的俯视图。移动手机,网格形状和可见三角面数量会跟着变化;按下锁定按钮,当前这一帧的图形和数据一起固定下来。

这张图是本次最终签名包的真机原始截图。画面中的三角线不是扫描框动画,也不是后期生成的效果:它们来自 ARSceneMesh 返回的顶点与三角形索引,经当前相机的 View、Projection 矩阵换算后绘制到 Canvas。
本次实验完成四件事:
- 创建启用环境 Mesh 的 WORLD 类型 AR 会话;
- 读取顶点、法线和三角形索引;
- 把世界坐标中的三角面投影到相机画面,并补一张 X/Z 俯视图;
- 锁定同一帧的图形与统计数据,完成暂停、恢复和销毁验证。
实验准备
| 项目 | 本次环境 |
|---|---|
| 开发工具 | DevEco Studio |
| 开发语言 | ArkTS |
| SDK | HarmonyOS API 26 |
| 测试设备 | HUAWEI Mate 60 Pro |
| 设备系统 | HarmonyOS 7.0 |
| AR 会话 | ARType.WORLD |
| Mesh 模式 | ARMeshMode.ENABLE |
| 深度模式 | ARDepthMode.AUTOMATIC |
| 对焦模式 | ARFocusMode.AUTO |
| 权限 | CAMERA、ACCELEROMETER、GYROSCOPE |
| 设备能力 | SLAM、MESH |
入口页先检查相机权限、SLAM 和 MESH。三项通过后,实验 07 才能打开相机页面。

权限声明如下:
"requestPermissions": [
{
"name": "ohos.permission.CAMERA",
"reason": "$string:permission_reason_camera",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
},
{
"name": "ohos.permission.ACCELEROMETER",
"reason": "$string:permission_reason_accelerometer",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
},
{
"name": "ohos.permission.GYROSCOPE",
"reason": "$string:permission_reason_gyroscope",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
}
]
入口判断保留在 AR 页面之外。能力不满足时停在实验列表,不创建受限会话:
private startEnvironmentMesh(): void {
if (!this.cameraGranted) {
this.latestMessage = '未获得相机权限,不启动环境 Mesh。';
return;
}
if (!this.canStartAR()) {
this.latestMessage = '当前设备不支持 SLAM,不启动环境 Mesh。';
return;
}
if (!this.canStartMesh()) {
this.latestMessage = '当前设备不支持环境 Mesh,不创建 AR 会话。';
return;
}
this.showEnvironmentMesh = true;
}
页面结构:ARView 上面叠一层 Canvas
第一次实现只读取了 Mesh 数组并显示计数,相机画面里没有任何真实网格。返工后的页面分成两层:底层 ARView 显示相机,透明 Canvas 覆盖在上面绘制三角形。
Stack({ alignContent: Alignment.TopStart }) {
if (this.arContext) {
ARView({ context: this.arContext })
.width('100%')
.height('100%')
Canvas(this.meshCanvasContext)
.width('100%')
.height('100%')
.onReady(() => {
this.canvasReady = true;
})
.hitTestBehavior(HitTestMode.Transparent)
}
this.buildTopStatus()
this.buildBottomSheet()
}
Canvas 不接收触控,页面按钮仍由 ArkUI 处理。它只承担两类绘制:相机对齐的三角网格,以及右上角的俯视网格。

这张图由 ImageGen 制作,只解释数据流;成功结果以真机截图和日志为准。
创建启用 Mesh 的 AR 会话
页面加载时创建 ARViewContext。配置中同时打开 Mesh、自动深度和自动对焦:
private async initARView(): Promise<void> {
let pendingContext: arViewController.ARViewContext | undefined = undefined;
try {
const scene: Scene = await Scene.load();
pendingContext = new arViewController.ARViewContext();
this.callback = new EnvironmentMeshCallback(
(context: arViewController.ARViewContext, timestamp: number) => {
this.handleFrameUpdate(context, timestamp);
});
pendingContext.scene = scene;
pendingContext.callback = this.callback;
pendingContext.config = {
type: arEngine.ARType.WORLD,
planeFindingMode:
arEngine.ARPlaneFindingMode.HORIZONTAL_AND_VERTICAL,
powerMode: arEngine.ARPowerMode.NORMAL,
semanticMode: arEngine.ARSemanticMode.NONE,
poseMode: arEngine.ARPoseMode.GRAVITY,
depthMode: arEngine.ARDepthMode.AUTOMATIC,
meshMode: arEngine.ARMeshMode.ENABLE,
focusMode: arEngine.ARFocusMode.AUTO
};
await pendingContext.init();
this.arContext = pendingContext;
this.sessionReadyTime = Date.now();
} catch (error) {
const err: BusinessError = error as BusinessError;
this.sessionState = `初始化失败:${err.code}`;
if (pendingContext) {
await pendingContext.destroy();
}
}
}
从每一帧取得 ARSceneMesh
onFrameUpdate() 的频率较高,本次按 250 ms 采样一次,并用 samplingInFlight 避免重入。只有相机进入 TRACKING 后才读取 Mesh。
private async sampleCurrentMesh(
context: arViewController.ARViewContext
): Promise<void> {
const session: arEngine.ARSession | undefined = context.session;
if (!session) {
return;
}
let frame: arEngine.ARFrame | undefined = undefined;
let mesh: arEngine.ARSceneMesh | undefined = undefined;
try {
frame = session.getFrame();
const camera: arEngine.ARCamera = frame.getCamera();
if (camera.state !== arEngine.ARTrackingState.TRACKING) {
return;
}
mesh = frame.acquireSceneMesh();
const vertices: Float32Array =
new Float32Array(mesh.getVertices());
const normals: Float32Array =
new Float32Array(mesh.getVertexNormals());
const indices: Int32Array =
new Int32Array(mesh.getTriangleIndices());
this.sampleCount += 1;
this.updateMeshSnapshot(
vertices, normals, indices,
mesh.verticesSize, mesh.triangleIndicesSize);
this.drawMeshOverlay(
vertices,
indices,
camera.viewMatrix,
camera.getProjectionMatrix(0.05, 50.0));
} catch (error) {
const err: BusinessError = error as BusinessError;
this.sampleCount += 1;
if (err.code === MESH_NOT_READY_CODE && !this.hasMesh()) {
this.emptySampleCount += 1;
this.trackingHint = '环境网格尚未生成,继续扫描环境边缘';
return;
}
this.errorCount += 1;
this.trackingHint = `网格读取失败:${err.code}`;
} finally {
if (mesh) {
await mesh.release();
}
if (frame) {
await frame.release();
}
}
}
三个数组的含义直接反映在页面上:
vertices.length / 3是顶点数;normals.length / 3是法线数;indices.length / 3是三角形数。
每次采样结束后释放 ARSceneMesh 和 ARFrame。页面只保存计数与快照,不把整份大数组放进响应式状态。
把世界坐标投影到屏幕坐标
Mesh 顶点位于世界坐标系,不能直接当成屏幕像素。每个顶点依次经过 View 矩阵和 Projection 矩阵,得到裁剪空间坐标;再除以 clipW 得到标准化设备坐标,最后换算成 Canvas 像素。
private projectVertex(
vertices: Float32Array,
vertexIndex: number,
viewMatrix: Array<number>,
projectionMatrix: Array<number>,
canvasWidth: number,
canvasHeight: number
): number[] {
const offset: number = vertexIndex * 3;
const worldX: number = vertices[offset];
const worldY: number = vertices[offset + 1];
const worldZ: number = vertices[offset + 2];
const viewX: number = viewMatrix[0] * worldX +
viewMatrix[4] * worldY + viewMatrix[8] * worldZ + viewMatrix[12];
const viewY: number = viewMatrix[1] * worldX +
viewMatrix[5] * worldY + viewMatrix[9] * worldZ + viewMatrix[13];
const viewZ: number = viewMatrix[2] * worldX +
viewMatrix[6] * worldY + viewMatrix[10] * worldZ + viewMatrix[14];
const viewW: number = viewMatrix[3] * worldX +
viewMatrix[7] * worldY + viewMatrix[11] * worldZ + viewMatrix[15];
const clipX: number = projectionMatrix[0] * viewX +
projectionMatrix[4] * viewY + projectionMatrix[8] * viewZ +
projectionMatrix[12] * viewW;
const clipY: number = projectionMatrix[1] * viewX +
projectionMatrix[5] * viewY + projectionMatrix[9] * viewZ +
projectionMatrix[13] * viewW;
const clipZ: number = projectionMatrix[2] * viewX +
projectionMatrix[6] * viewY + projectionMatrix[10] * viewZ +
projectionMatrix[14] * viewW;
const clipW: number = projectionMatrix[3] * viewX +
projectionMatrix[7] * viewY + projectionMatrix[11] * viewZ +
projectionMatrix[15] * viewW;
if (!Number.isFinite(clipW) || clipW <= 0.001) {
return [];
}
const normalizedX: number = clipX / clipW;
const normalizedY: number = clipY / clipW;
const normalizedZ: number = clipZ / clipW;
if (normalizedX < -1.12 || normalizedX > 1.12 ||
normalizedY < -1.12 || normalizedY > 1.12 ||
normalizedZ < -1.0 || normalizedZ > 1.0) {
return [];
}
return [
(normalizedX * 0.5 + 0.5) * canvasWidth,
(0.5 - normalizedY * 0.5) * canvasHeight
];
}
屏幕外、相机后方、面积过小或异常巨大的三角形直接跳过。原始网格可能包含数万个三角形,显示层按固定步长抽样,每帧最多画 1200 个面;页面的“原始三角面”仍使用完整索引统计。
const MAX_OVERLAY_TRIANGLES: number = 1200;
const sourceTriangles: number = Math.floor(indices.length / 3);
const triangleStride: number = Math.max(
1,
Math.ceil(sourceTriangles / MAX_OVERLAY_TRIANGLES)
);
for (let triangle: number = 0;
triangle < sourceTriangles; triangle += triangleStride) {
// 取三个索引,投影三个顶点,组成 Canvas 路径。
}
this.meshCanvasContext.fillStyle = 'rgba(52, 236, 180, 0.10)';
this.meshCanvasContext.fill();
this.meshCanvasContext.strokeStyle = 'rgba(100, 255, 210, 0.88)';
this.meshCanvasContext.lineWidth = 0.75;
this.meshCanvasContext.stroke();
右上角俯视图继续使用同一份 vertices + indices,只取顶点的 X、Z 坐标,按当前最小值和最大值归一化到小画布。它不是预制图片,取景变化时会和主画面的网格一起更新。
锁定同一帧的网格证据
“锁定当前网格证据”同时保存可见面数、原始面数、顶点、法线、原始数组计数和覆盖范围,并停止 Canvas 重绘。AR 会话与后台采样继续运行,顶部采样次数仍会增加。
private recordCurrentMesh(): void {
if (!this.hasMesh() || this.drawnTriangleCount <= 0 || this.isPaused) {
return;
}
this.recorded = true;
this.overlayFrozen = true;
this.recordedSample = this.sampleCount;
this.recordedVertices = this.vertexCount;
this.recordedNormals = this.normalCount;
this.recordedTriangles = this.triangleCount;
this.recordedOverlayTriangles = this.drawnTriangleCount;
this.recordedApiVertexSize = this.apiVertexSize;
this.recordedApiIndexSize = this.apiIndexSize;
this.recordedRange = this.coverageText();
hilog.info(DOMAIN, TAG,
'MESH_RECORD sample=%{public}d vertices=%{public}d ' +
'normals=%{public}d triangles=%{public}d ' +
'overlayTriangles=%{public}d range=%{public}s',
this.recordedSample, this.recordedVertices,
this.recordedNormals, this.recordedTriangles,
this.recordedOverlayTriangles, this.recordedRange);
}
锁定态的主卡片也只读取 recorded* 字段,避免后台新帧的数字混进已经冻结的截图。
真机实操过程
1. 打开实验 07
入口页确认 CAMERA 已授权、SLAM 和 MESH 均为支持,然后进入“实验 07:扫描环境 Mesh”。会话建立后,相机先进入 TRACKING。
2. 从地面扫到墙角
镜头先对准有纹理的地面,缓慢移动到墙角,再扫过柜门和桌边。刚进入时 acquireSceneMesh() 可能还没有数据,页面保留等待状态;继续移动几秒后出现第一份非空 Mesh。
本轮从会话初始化到第一份有效 Mesh 用了 54.792 秒。第 199 次采样取得 192 个顶点、192 个法线和 266 个原始三角面,其中 217 个三角面进入当前相机画面。
3. 确认网格随取景变化
第一张返工真机图中,青绿色三角形已经贴在真实地面上,右上角也出现了金黄色俯视图;当时页面显示 269 个可见三角面和 387 个原始三角面。

继续扫过墙面、柜门和地面交界后,日志中的可见三角面先后出现 465、765、759、101、17、447 等变化,画面也同步重绘。这一步用来确认网格不是固定装饰。
4. 锁定结果
网格覆盖清楚后点击“锁定当前网格证据”。页面变成黄色锁定状态,Canvas 保留点击瞬间的网格,主卡片、详情卡和 MESH_RECORD 日志使用同一帧数据。
本次锁定发生在第 1029 次采样:1858 个顶点、1858 个法线、3438 个原始三角面,其中 262 个三角面绘制在相机画面上,覆盖范围为 1.34 × 1.80 × 1.74 m。截图时顶部采样次数已经增长到 1077,锁定卡仍保持第 1029 次数据。
5. 暂停、恢复和销毁
点击“暂停扫描”后,采样次数停止增长;点击“恢复扫描”后重新取得 Mesh。最后点击“销毁退出”,ARViewContext 被释放并返回实验列表。
private async destroyARView(): Promise<void> {
if (!this.arContext || this.isDestroyed) {
return;
}
const context: arViewController.ARViewContext = this.arContext;
this.isDestroyed = true;
await context.destroy();
this.meshCanvasContext.clearRect(
0, 0,
this.meshCanvasContext.width,
this.meshCanvasContext.height
);
this.arContext = undefined;
this.callback = undefined;
}
销毁完成后,真机返回实验工作台,CAMERA、SLAM 和 MESH 的入口状态仍正常:

实操中遇到的情况
只有数字,没有 AR 网格
第一次版本已经能读取顶点和三角形,但页面中央只是固定扫描框。代码中没有 Canvas、camera.viewMatrix、getProjectionMatrix() 或三角形路径,真实截图无法证明 Mesh 已经显示。
处理方式是补上完整投影链路,再用真机确认三角形贴在地面和墙面上。旧版计数截图只保留为问题记录,不再作为最终结果。
相机正常,Mesh 暂时为空
会话刚启动时出现过 1009200001。这次将“尚未取得第一份 Mesh”的该错误归入等待状态,并继续扫描环境边缘:
MESH_PENDING sample=1 code=1009200001 empty=1
第 199 次采样取得有效数据,整个过程不需要重建会话:
MESH_FIRST sample=199 latencyMs=54792 vertices=192 normals=192 triangles=266
原始三角面很多,画面没有全部绘制
页面同时显示“原始三角面”和“可见三角面”。原始值来自完整索引数组;可见值还要经过抽样、相机裁切、顶点有效性和三角形面积检查。两者不同是显示链路的正常结果。
锁定后出现两套数字
第一次锁定调试时,Canvas 已冻结,但主卡片仍显示后台最新一帧的原始面数;详情卡显示的才是锁定帧。最终把顶点、法线、原始数组计数、范围和两类三角面数全部保存为 recorded* 快照。锁定截图中只保留一套结果。
真机日志
以下日志已删除时间、进程信息和应用标识,直接保留本次实验字段:
MESH_SESSION_INIT success costMs=43 meshMode=1 depthMode=1 focusMode=1
MESH_PENDING sample=1 code=1009200001 empty=1
MESH_FIRST sample=199 latencyMs=54792 vertices=192 normals=192 triangles=266
MESH_OVERLAY sample=199 sourceTriangles=266 drawnTriangles=217 projectedVertices=651 frozen=false
MESH_SAMPLE sample=336 vertices=21418 normals=21418 triangles=34612 apiVertices=64254 apiIndices=103836 range=5.700,3.064,6.176 errors=0 exhausted=0
MESH_OVERLAY sample=336 sourceTriangles=34612 drawnTriangles=759 projectedVertices=2277 frozen=false
MESH_RECORD sample=1029 vertices=1858 normals=1858 triangles=3438 overlayTriangles=262 range=1.34 × 1.80 × 1.74 m
MESH_SESSION_PAUSE success sample=1273 vertices=1813 triangles=3151
MESH_SESSION_RESUME success sample=1273 vertices=1813 triangles=3151
MESH_SESSION_DESTROY success samples=1391 maxVertices=24062 maxTriangles=38947 errors=0 exhausted=0
drawnTriangles 在扫描过程中持续变化,证明 Canvas 使用的是当前帧 Mesh。锁定日志把原始三角面与实际绘制面一起写出,截图中的数字可以直接和日志核对。
构建与验证
真实绘制链路的回归检查通过:
环境 Mesh 观测回归检查通过。
最终签名构建结果:
CompileArkTS... Finished
PackageHap... Finished
SignHap... Finished
BUILD SUCCESSFUL in 17 s 279 ms
构建保留一条第六篇 Native 模块的既有 SDK 校验提示;本篇环境 Mesh 页面没有新增编译错误。签名包覆盖安装成功。
最终结果
| 验证项 | 本次结果 |
|---|---|
| CAMERA、SLAM、MESH 门禁 | 通过 |
| AR 会话 | WORLD 类型初始化成功 |
| 第一份非空 Mesh | 192 顶点、192 法线、266 三角形 |
| 相机画面网格 | 真实三角面可见,并随取景变化 |
| 俯视图 | 使用同一批顶点和索引实时绘制 |
| 锁定证据 | 第 1029 次:1858 顶点、3438 原始面、262 可见面 |
| 帧与 Mesh 释放 | 每轮在 finally 中完成 |
| 暂停、恢复 | 第 1273 次暂停;恢复后继续增长 |
| 销毁 | 第 1391 次销毁,资源耗尽 0 次 |
| clean 构建与签名 | 通过 |
这次得到的不是完整 3D 模型文件,而是一条可观察、可锁定、可复现的环境 Mesh 链路:AR Engine 输出真实几何数据,页面负责统计、投影和显示。语义分类、纹理重建和 Mesh 文件导出不在本次范围内。
官方资料
更多推荐

所有评论(0)