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

锁定真实环境 Mesh 的真机结果

这张图是本次最终签名包的真机原始截图。画面中的三角线不是扫描框动画,也不是后期生成的效果:它们来自 ARSceneMesh 返回的顶点与三角形索引,经当前相机的 View、Projection 矩阵换算后绘制到 Canvas。

本次实验完成四件事:

  1. 创建启用环境 Mesh 的 WORLD 类型 AR 会话;
  2. 读取顶点、法线和三角形索引;
  3. 把世界坐标中的三角面投影到相机画面,并补一张 X/Z 俯视图;
  4. 锁定同一帧的图形与统计数据,完成暂停、恢复和销毁验证。

实验准备

项目本次环境
开发工具DevEco Studio
开发语言ArkTS
SDKHarmonyOS 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 才能打开相机页面。

设备支持环境 Mesh

权限声明如下:

"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 处理。它只承担两类绘制:相机对齐的三角网格,以及右上角的俯视网格。

真实 Mesh 投影流程示意图

这张图由 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 是三角形数。

每次采样结束后释放 ARSceneMeshARFrame。页面只保存计数与快照,不把整份大数组放进响应式状态。

把世界坐标投影到屏幕坐标

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 会话后返回实验列表

实操中遇到的情况

只有数字,没有 AR 网格

第一次版本已经能读取顶点和三角形,但页面中央只是固定扫描框。代码中没有 Canvas、camera.viewMatrixgetProjectionMatrix() 或三角形路径,真实截图无法证明 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 类型初始化成功
第一份非空 Mesh192 顶点、192 法线、266 三角形
相机画面网格真实三角面可见,并随取景变化
俯视图使用同一批顶点和索引实时绘制
锁定证据第 1029 次:1858 顶点、3438 原始面、262 可见面
帧与 Mesh 释放每轮在 finally 中完成
暂停、恢复第 1273 次暂停;恢复后继续增长
销毁第 1391 次销毁,资源耗尽 0 次
clean 构建与签名通过

这次得到的不是完整 3D 模型文件,而是一条可观察、可锁定、可复现的环境 Mesh 链路:AR Engine 输出真实几何数据,页面负责统计、投影和显示。语义分类、纹理重建和 Mesh 文件导出不在本次范围内。

官方资料

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐