《HarmonyOS 7 ArkGraphics 3D 空间设计开发实战》06:3D对象拾取、家具选择与交互状态管理【鸿蒙心迹】
用户点的是屏幕上的二维点,程序怎么知道他点的是沙发?

前言
上一篇做了视角控制,用户可以自己转着看房间了。但只能看,不能操作。
这一篇加一个最基本的交互:用户点一下沙发,能选中它,显示选中状态,然后可以移动它、删除它。
看起来很简单:用户点屏幕,触发点击事件,拿到坐标,然后呢?
问题就出在这:用户点的是二维屏幕坐标,但沙发在三维空间里。你怎么知道屏幕上这个点,对应的是三维世界里的哪个物体?
这就是 3D 拾取(Picking)。从二维屏幕坐标,反推到三维空间里被点中的物体。
这一篇就讲这个:怎么从屏幕上的一个点,找到对应的 3D 节点,以及怎么管理选中状态。
一、先搞清楚:二维点怎么对应三维物体
先建立一个直觉:
- 相机从一个点看出去,看到的是三维空间里的一个锥形视野
- 用户点屏幕上某个点,相当于从相机出发,经过这个屏幕点,画一条射线到三维空间里
- 这条射线穿过哪些物体,最前面那个就是被点中的
这个过程叫"射线检测"(Ray Casting)。
打个比方:你站在房间里,眼睛就是相机。你用手指指向墙上的一幅画,从你的眼睛到手指尖画一条线,这条线穿过的第一个物体就是你指的那个。
3D 拾取就是干这个的:从屏幕坐标,算出这条射线,然后看它和哪个 3D 物体相交。
二、写一个 PickingManager
先写个工具类,把点击坐标转成射线,然后检测命中:
import { scene } from '@kit.ArkGraphics3DKit';
// 家具业务对象
interface FurnitureEntity {
nodeId: string; // 对应的3D节点ID
name: string; // 家具名称
type: string; // 家具类型
price: number; // 价格
position: {x: number, y: number, z: number};
}
export class PickingManager {
// 节点ID到业务对象的映射
private nodeEntityMap: Map<string, FurnitureEntity> = new Map();
// 当前选中的家具
private selectedEntity: FurnitureEntity | null = null;
constructor(private scene: scene.Scene) {}
/**
* 注册家具:把节点和业务对象对应起来
*/
registerFurniture(node: scene.Node, entity: FurnitureEntity): void {
this.nodeEntityMap.set(node.id, entity);
}
/**
* 处理屏幕点击
* @param screenX 屏幕x坐标(像素)
* @param screenY 屏幕y坐标(像素)
* @param viewWidth 视口宽度
* @param viewHeight 视口高度
*/
handleClick(screenX: number, screenY: number, viewWidth: number, viewHeight: number): FurnitureEntity | null {
// 1. 屏幕坐标归一化:转成 -1 到 1 之间的 NDC 坐标
const ndcX = (screenX / viewWidth) * 2 - 1;
const ndcY = 1 - (screenY / viewHeight) * 2;
console.info('PickingManager: click at ndc', ndcX, ndcY);
// 2. 从相机发射射线,检测命中
// ArkGraphics 3D 提供拾取接口
const hitNode = this.scene.pickNode(ndcX, ndcY);
if (!hitNode) {
// 没点中任何物体,取消选中
this.setSelected(null);
return null;
}
// 3. 通过节点ID找业务对象
const entity = this.nodeEntityMap.get(hitNode.id);
if (entity) {
this.setSelected(entity);
console.info('PickingManager: selected', entity.name);
return entity;
}
return null;
}
/**
* 设置选中状态
*/
private setSelected(entity: FurnitureEntity | null): void {
// 取消之前的选中状态
if (this.selectedEntity) {
const prevNode = this.findNodeById(this.selectedEntity.nodeId);
if (prevNode) {
// 恢复原来的材质边框
this.setNodeHighlight(prevNode, false);
}
}
this.selectedEntity = entity;
// 设置新的选中状态
if (entity) {
const node = this.findNodeById(entity.nodeId);
if (node) {
// 加个高亮边框,表示选中了
this.setNodeHighlight(node, true);
}
}
}
private findNodeById(nodeId: string): scene.Node | null {
// 递归找节点
const root = this.scene.getRoot();
return this.findNodeRecursive(root, nodeId);
}
private findNodeRecursive(node: scene.Node, nodeId: string): scene.Node | null {
if (node.id === nodeId) return node;
const children = node.getChildren();
for (const child of children) {
const found = this.findNodeRecursive(child, nodeId);
if (found) return found;
}
return null;
}
private setNodeHighlight(node: scene.Node, highlight: boolean): void {
// 选中的时候加个边框或者改颜色
console.info('PickingManager: set highlight', node.name, highlight);
}
getSelected(): FurnitureEntity | null {
return this.selectedEntity;
}
}
核心逻辑就三步:
- 把屏幕像素坐标转成 NDC 归一化坐标(-1 到 1)
- 调用 scene.pickNode() 拿到命中的节点
- 通过节点ID,找到对应的业务对象
这里有个关键设计:Scene Node 和业务对象是分开的。
Node 只负责 3D 渲染相关的东西:位置、旋转、Mesh、材质。业务对象(FurnitureEntity)负责业务数据:名称、价格、类型。两者通过 nodeId 关联。
为什么不把业务数据直接塞到 Node 上?因为 3D 引擎的 Node 是渲染层的东西,不应该知道你的业务逻辑。以后换渲染引擎,业务对象不用动。

三、接 ArkUI 的点击手势
PickingManager 写好了,接下来接 ArkUI 的点击:
@Entry
@Component
struct SpaceRoomPage {
private pickingMgr: PickingManager | null = null;
@State selectedName: string = ''; // 选中家具的名字,显示在UI上
build() {
Stack() {
scene.SceneView({ scene: undefined })
.width('100%')
.height('100%')
.onClick((event: ClickEvent) => {
// 拿到点击的屏幕坐标
const x = event.x;
const y = event.y;
// 调用拾取
const entity = this.pickingMgr?.handleClick(x, y, 1080, 1920);
// 更新UI显示
if (entity) {
this.selectedName = `选中:${entity.name}(${entity.type})`;
} else {
this.selectedName = '';
}
})
// 底部显示选中信息的浮层
Column() {
if (this.selectedName) {
Text(this.selectedName)
.fontSize(14)
.fontColor(Color.White)
.backgroundColor('rgba(0,0,0,0.7)')
.padding(10)
.borderRadius(8)
}
}
.position({x: 20, y: 100})
}
.width('100%')
.height('100%')
}
}
现在用户点沙发,底部就会显示"选中:三人沙发(沙发类)"。点空白地方,选中状态取消。

四、几个最容易踩的拾取坑
把这一篇遇到的坑总结一下:
| 坑 | 现象 | 解决办法 |
|---|---|---|
| 业务数据直接塞Node里 | 以后换引擎全要改 | Node和业务对象分开,通过ID关联 |
| 点中了父节点不是真实物体 | 点沙发上靠垫,选中整个沙发组 | 拾取时找最底层的叶子节点 |
| 透明物体也被点中 | 点窗户玻璃,选中玻璃而不是后面的墙 | 透明物体设置不参与拾取 |
| 删除家具后映射表没清 | 删除后还能选到已经删掉的家具 | 删除家具时同步从map里移除 |
| UI选中状态和3D状态不同步 | 界面显示选中了,但3D里没高亮 | 状态统一在一个地方管理 |
| 点击和拖动冲突 | 拖视角的时候也触发选中 | 区分点击和拖动,移动距离大就不算点击 |
这些问题在简单场景下看不出来,家具多了、层级复杂了就全冒出来了。
特别是最后一个:用户拖动视角的时候,如果拖动结束的位置刚好在一个家具上,就会触发选中。这就需要判断:手指移动的距离很小,才算点击;移动距离大,就是拖动,不算。

总结
第六篇的核心就一句话:3D 拾取本质上是把二维屏幕坐标反推成三维射线,再找和哪个物体相交。
- 从屏幕点击坐标,算出一条从相机出发的射线
- 射线和场景里的物体求交,最前面的就是被点中的
- Node(渲染层)和业务对象(数据层)分开,通过ID关联
- 选中状态要统一管理,UI和3D高亮要同步
- 要区分点击和拖动,不然转视角的时候会误触选中
SpaceRoom 现在能点选家具了。下一篇做性能优化:家具加到 50 个、100 个的时候,帧率为什么掉下来,怎么优化。
更多推荐




所有评论(0)