用户点的是屏幕上的二维点,程序怎么知道他点的是沙发?

文章封面

前言

上一篇做了视角控制,用户可以自己转着看房间了。但只能看,不能操作。

这一篇加一个最基本的交互:用户点一下沙发,能选中它,显示选中状态,然后可以移动它、删除它。

看起来很简单:用户点屏幕,触发点击事件,拿到坐标,然后呢?

问题就出在这:用户点的是二维屏幕坐标,但沙发在三维空间里。你怎么知道屏幕上这个点,对应的是三维世界里的哪个物体?

这就是 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;
  }
}

核心逻辑就三步:

  1. 把屏幕像素坐标转成 NDC 归一化坐标(-1 到 1)
  2. 调用 scene.pickNode() 拿到命中的节点
  3. 通过节点ID,找到对应的业务对象

这里有个关键设计:Scene Node 和业务对象是分开的。

Node 只负责 3D 渲染相关的东西:位置、旋转、Mesh、材质。业务对象(FurnitureEntity)负责业务数据:名称、价格、类型。两者通过 nodeId 关联。

为什么不把业务数据直接塞到 Node 上?因为 3D 引擎的 Node 是渲染层的东西,不应该知道你的业务逻辑。以后换渲染引擎,业务对象不用动。

3D拾取链路时序图


三、接 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 拾取本质上是把二维屏幕坐标反推成三维射线,再找和哪个物体相交。

  1. 从屏幕点击坐标,算出一条从相机出发的射线
  2. 射线和场景里的物体求交,最前面的就是被点中的
  3. Node(渲染层)和业务对象(数据层)分开,通过ID关联
  4. 选中状态要统一管理,UI和3D高亮要同步
  5. 要区分点击和拖动,不然转视角的时候会误触选中

SpaceRoom 现在能点选家具了。下一篇做性能优化:家具加到 50 个、100 个的时候,帧率为什么掉下来,怎么优化。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐