鸿蒙 HarmonyOS 6 | ArkGraphics 3D 射线检测实战
做 3D 场景交互时,我最先遇到的问题通常不是模型能不能显示出来,而是用户点到模型以后,应用能不能准确判断当前命中的对象。屏幕点击给到的是二维坐标,3D 场景里真正要处理的是空间节点。中间要经过相机视角、投影关系、场景层级、坐标换算和命中结果解析。自己从零处理这套链路,代码量不小,也很容易在坐标方向、节点层级和初始化时机上踩坑。
前言
做 3D 场景交互时,我最先遇到的问题通常不是模型能不能显示出来,而是用户点到模型以后,应用能不能准确判断当前命中的对象。
屏幕点击给到的是二维坐标,3D 场景里真正要处理的是空间节点。中间要经过相机视角、投影关系、场景层级、坐标换算和命中结果解析。自己从零处理这套链路,代码量不小,也很容易在坐标方向、节点层级和初始化时机上踩坑。
HarmonyOS 6.0.0 API 20 对 ArkGraphics 3D 增加了 raycast 相关能力,支持从屏幕指定位置发射射线,检测并返回命中的 3D 物体信息。这里要先明确一个版本边界:ArkGraphics 3D 的 Scene 模块基础能力早于 API 20 已经存在,API 20 增加的是射线检测相关接口。Camera.raycast、RaycastResult、RaycastParameters 都带有 20+ 标记,使用时要把最低版本和兼容策略一起考虑进去。
这次我按照一个最常见的点选链路来拆。用户点击 3D 组件,点击位置转成 NDC 坐标,通过 Camera.raycast 命中场景节点,再把命中结果映射到业务对象。代码只保留关键逻辑,重点放在容易写错的地方。

一、射线检测处理 2D 点击到 3D 节点的转换
3D 点选的底层逻辑,是从当前相机位置发出一条射线。这条射线穿过屏幕点击位置,进入 3D 场景,然后检查经过的节点。命中之后,应用就可以拿到被点中的节点和命中位置。
这个能力适合很多场景。比如 3D 产品展示里点击某个部件显示参数,虚拟展厅里点击展品切换介绍内容,教学模型里点击结构触发高亮,工业装配预览里点击零件显示安装说明。
API 20 中,raycast 相关类型来自 @kit.ArkGraphics3D。旧稿里如果写成 @ohos.arkui.scene,建议改掉。官方参考里的 Scene、Camera、Node、RaycastResult 等类型都使用 @kit.ArkGraphics3D 导入,相关系统能力是 SystemCapability.ArkUi.Graphics3D。
raycast 的调用位置也要注意。它挂在 Camera 上,接口形式是:
raycast(viewPosition: Vec2, params: RaycastParameters): Promise<RaycastResult[]>
这意味着射线检测需要依赖当前相机。相同的屏幕点击位置,在不同相机位置、不同视角、不同投影参数下,会对应不同的空间射线路径。把 raycast 放在 Camera 上,符合 3D 场景交互的实际逻辑。
二、点击坐标要先转换成 NDC
ArkUI 触摸事件里拿到的坐标,一般是组件内部的像素坐标。比如点击组件左上角,可能拿到 { x: 0, y: 0 }。这个值不能直接传给 Camera.raycast。
Camera.raycast 接收的 viewPosition 是标准化设备坐标,也就是 NDC。官方说明中,NDC 的取值范围是 [-1, 1],(-1, -1) 表示屏幕左下角,(1, 1) 表示屏幕右上角。返回结果是 Promise<RaycastResult[]>,命中结果数组按距离从近到远排列,没有命中时返回空数组。
多数 UI 坐标的原点在左上角,x 向右增加,y 向下增加。NDC 的 y 方向和 UI 坐标相反。这个地方如果直接传值,射线会出现方向偏差,表现出来就是点击总是命中不到预期节点,或者命中位置上下颠倒。
可以先写一个转换函数。
import { Vec2 } from '@kit.ArkGraphics3D';
function toNdc(x: number, y: number, width: number, height: number): Vec2 {
return {
x: (x / width) * 2 - 1,
y: 1 - (y / height) * 2
};
}
点击组件中心时,转换结果接近 { x: 0, y: 0 }。点击左上角时,结果接近 { x: -1, y: 1 }。点击右下角时,结果接近 { x: 1, y: -1 }。
实际开发里还要多处理一层坐标来源。3D 组件不一定铺满整个页面。页面可能有导航栏、标题区、底部操作栏,也可能在平板或折叠屏上做了左右分栏。触摸事件如果拿到的是全局坐标,就要先换算成 3D 组件内部坐标,再转成 NDC。少了这个步骤,点击位置会有固定偏移,屏幕越大越明显。
三、通过 Camera.raycast 拿到最近命中结果
射线检测的主流程可以压缩成四步。加载场景,拿到相机,准备检测范围,把点击坐标转成 NDC 后调用 camera.raycast。
下面这段示例只保留核心逻辑。模型路径、相机节点路径和资源文件路径,需要按自己的 glTF 结构调整。
import {
Scene,
Camera,
RaycastParameters,
RaycastResult,
Vec2
} from '@kit.ArkGraphics3D';
class ScenePickService {
private scene?: Scene;
private camera?: Camera;
async loadScene(): Promise<void> {
this.scene = await Scene.load($rawfile('gltf/CubeWithFloor/glTF/AnimatedCube.gltf'));
const cameraNode = this.scene.root?.getNodeByPath('rootNode_/Camera');
if (!cameraNode) {
throw new Error('Camera node not found');
}
this.camera = cameraNode as Camera;
this.camera.enabled = true;
}
async pick(viewPosition: Vec2): Promise<RaycastResult | null> {
if (!this.scene || !this.camera || !this.scene.root) {
return null;
}
const params: RaycastParameters = {
rootNode: this.scene.root
};
const results = await this.camera.raycast(viewPosition, params);
if (results.length === 0) {
return null;
}
return results[0];
}
}
这里有几个判断建议保留。
场景资源可能还没加载完成,相机节点可能没有取到,根节点也可能为空。3D 场景初始化容易受到资源路径、模型层级、页面生命周期影响。把这些判断提前写出来,后面排查会轻松很多。
RaycastParameters 里的 rootNode 用来限制检测范围。rootNode 可以把检测范围限定在某个节点及其子节点内;未设置时检测整个场景。复杂场景里,这个参数很有用。比如一个 3D 展厅里只有展品需要响应点击,背景、地板、装饰物都可以排除在检测范围外。
返回结果也不需要自己排序。官方接口说明里已经标明,命中结果数组按距离从近到远排序,直接取 results[0] 就是最近的命中对象。旧代码里如果用 distance 字段排序,需要调整。RaycastResult 的字段包括 node、centerDistance 和 hitPosition,没有 distance 和 normal 这两个标准字段。
四、把命中节点映射成业务对象
拿到 RaycastResult 后,业务层真正需要的是被点中的对象。node 表示被射线击中的 3D 场景节点,centerDistance 表示命中物体包围盒中心到摄像机中心的距离,hitPosition 表示射线与物体碰撞点的世界坐标。
调试阶段可以先根据节点路径判断业务对象。
import { RaycastResult } from '@kit.ArkGraphics3D';
function handlePickResult(result: RaycastResult | null): void {
if (!result) {
console.info('No 3D object hit');
return;
}
const node = result.node;
const hitPosition = result.hitPosition;
const centerDistance = result.centerDistance;
console.info(`Hit node path: ${node.path}`);
console.info(`Hit position: ${hitPosition.x}, ${hitPosition.y}, ${hitPosition.z}`);
console.info(`Center distance: ${centerDistance}`);
if (node.path.includes('Wheel')) {
showPartPanel('wheel');
return;
}
if (node.path.includes('Door')) {
showPartPanel('door');
return;
}
showPartPanel('default');
}
这种写法适合验证链路。先打印 node.path,确认 raycast 命中的节点是否符合预期。等点选链路跑通以后,再考虑业务结构。
进入正式项目后,我会把模型路径和业务对象拆开。模型路径属于资源结构,业务 ID 属于产品逻辑。3D 模型后续经常会调整,节点名称、层级、子节点拆分都有可能变化。如果业务逻辑散落在一堆 node.path.includes() 里,维护成本会越来越高。
可以准备一张映射表。
const nodeBizMap = new Map<string, string>([
['rootNode_/Car/Wheel_FL', 'wheel_front_left'],
['rootNode_/Car/Wheel_FR', 'wheel_front_right'],
['rootNode_/Car/Door_L', 'door_left']
]);
function getBizIdByNodePath(path: string): string | undefined {
return nodeBizMap.get(path);
}
处理命中结果时,只把节点路径转换成业务 ID。
function handlePickResult(result: RaycastResult | null): void {
if (!result) {
clearSelection();
return;
}
const bizId = getBizIdByNodePath(result.node.path);
if (!bizId) {
clearSelection();
return;
}
selectPart(bizId, result.hitPosition);
}
这种结构更适合后续扩展。业务层保存当前选中的 bizId,渲染层再根据 bizId 找到对应节点,做材质变化、透明度变化、高亮描边或属性面板联动。
五、性能、时序和兼容都要提前留出口
射线检测不要放进每一帧渲染逻辑里。普通点选场景里,用户点击一次检测一次就够了。拖拽场景也不建议每个移动事件都重新检测整个场景。
更稳的处理方式是在拖拽开始时做一次 raycast,确认当前选中的节点。拖拽过程中基于已选节点、手势偏移和约束平面继续计算。拖拽结束后清掉选中状态。
let selectedNodePath: string | undefined;
async function onTouchStart(x: number, y: number, width: number, height: number) {
const viewPosition = toNdc(x, y, width, height);
const result = await pickService.pick(viewPosition);
selectedNodePath = result?.node.path;
}
function onTouchMove(offsetX: number, offsetY: number) {
if (!selectedNodePath) {
return;
}
updateSelectedNodeTransform(selectedNodePath, offsetX, offsetY);
}
function onTouchEnd() {
selectedNodePath = undefined;
}
复杂场景里还要尽量缩小检测范围。rootNode 可以只指向可交互对象的父节点,避免把地板、背景、装饰节点都纳入检测。节点数量增加以后,这个习惯会影响交互响应速度,也能减少误命中。
时序问题也要提前处理。场景资源没加载完时不要检测,相机没启用时不要检测,页面尺寸还没计算出来时也不要转换坐标。很多看起来像 raycast 不准的问题,最后都能追到初始化顺序或坐标来源不一致。
兼容策略也要写进工程设计里。raycast 是 API 20 新增能力,低版本设备不能默认使用。应用如果还要覆盖更低版本,可以把 3D 点选能力放在 API 20 分支里。低版本保留模型预览、按钮选择、列表选择等替代路径。这样体验会有差异,但基础功能不会因为新接口不可用而中断。
总结
ArkGraphics 3D 的射线检测能力,适合处理 3D 场景里的点选交互。实际接入时,我会重点关注几个环节。
相关类型使用 @kit.ArkGraphics3D 引入,通过 Camera.raycast 发起检测。屏幕像素坐标要先转换成 NDC 坐标,再传入 raycast。命中结果按距离从近到远返回,最近命中对象直接取数组第一项。RaycastResult 里主要使用 node、hitPosition 和 centerDistance,不要额外假设存在 distance、normal 这类字段。
这套能力能减少相机投影、射线计算和场景命中方面的底层处理成本。工程接入时,坐标转换、相机节点、检测范围、初始化时机、业务映射和版本兼容都要一起考虑。
我更建议先从一个最小场景开始验证。一个 glTF 场景、一台相机、几个可点击节点,先把点击命中链路跑通。链路稳定以后,再继续做选中高亮、属性面板、拖拽移动和复杂交互。
更多推荐




所有评论(0)